---
name: zenit-ui-dev
description: Build, change, debug and verify native macOS UI with zenit, the Zig GPU UI framework (Metal rendering, ui.box/ui.text/ui.widgets, Signals, styles.zig + recipes, zenit_app.App). Load whenever a task touches a zenit app: new windows, views or components, porting a design mockup (including Pencil / pen.dev `.pen` files via the Pencil MCP), styling or theming, state and event wiring, menus / shortcuts / file dialogs / drag and drop, VirtualList and performance, IME and text input, E2E harness or screenshots. Also load it when build.zig depends on zenit or code does @import("ui") / @import("zenit_app").
---

# zenit UI development

zenit is a pre-1.0 Zig GUI framework: a retained node tree with flex/grid layout,
fine-grained Signals, a ~60-component widget library, Metal rendering, full
Unicode text + IME, and a file-RPC E2E harness. macOS (Apple Silicon) is the
only supported platform.

This skill targets **zenit 0.1.0-alpha on Zig 0.15.2**
(source: https://github.com/version-next/zenit, manual: https://ui.surf/zenit/docs). The API can change
before 1.0. **When this skill and the zenit source disagree, the source wins**:
read `src/ui/ui.zig` (the entire public API) in the zenit checkout the project
depends on, and say which one you followed.

## Workflow

Follow these in order for any UI task. Skipping step 1 or 6 is the most common
cause of wrong or unverified UI.

1. **Locate zenit and pin the facts.** Find the dependency in `build.zig.zon`
   (`.path` points to a local checkout; `.url` to `~/.cache/zig/p/<hash>/`). Every API
   question is answered from there: `src/ui/ui.zig` (public surface),
   `src/ui/components/<name>/mod.zig` (widget props), and
   `examples/storybook/stories.zig` (one working `buildXxx` per widget, the
   best usage reference). Never import `ui.core.*` or internal files.
   If the task comes with a Pencil (`.pen`) design, read its exact values
   through the Pencil MCP first. See [references/pencil-design.md](references/pencil-design.md).
2. **Pick components before writing boxes.** Map every element of the request
   to a `ui.widgets.*` component using the table in
   [references/components.md](references/components.md). Hand-built buttons,
   inputs, menus, popovers or modals are a bug: they miss focus, keyboard,
   IME, hit-testing, portals and a11y that the widget already handles.
3. **Style through tokens.** Put named style functions in a `styles.zig` next
   to the view; use `ui.boxStyled` / `ui.textStyled`; use recipes for
   variants; use `ui.arb.*` for deliberate off-scale values. No bare color or
   size literals in view code. See [references/styling.md](references/styling.md)
4. **Wire state with the lifetime in mind.** `cx.bindState` + `cx.on` for
   handlers; Signals for derived/reactive text; decide per resource whether it
   lives with the window (Cx) or the page (Scope).
   See [references/state-reactivity.md](references/state-reactivity.md)
5. **Build.** `zig build` must pass with zero warnings you introduced. Run
   `zig build test` if the project has tests.
6. **Look at it.** Run the app and check the real window (screenshot via the
   harness, or ask the user to look) before claiming the UI works. "It
   compiles" and "the process started" are not evidence.
   See [references/verification.md](references/verification.md)

## Hard rules

These come from real bugs in zenit apps. Each one has a longer entry in
[references/pitfalls.md](references/pitfalls.md).

**Build & project**
- Zig must be exactly **0.15.2**. `build.zig.zon` `.path` deps must be relative.
- Call `zenit.attach(zenit_dep, exe)` after `addExecutable`. Forward
  `.@"test-mode"` and `.@"e2e-port"` into `b.dependency("zenit", …)` or
  `-Dtest-mode=true` silently never reaches zenit.
- Never ship a release build with `-Dtest-mode=true`.

**Theme**
- `cx.tokens` defaults to **`ui.theme.dark`** while the default clear color
  is white. Call `cx.setTheme(&ui.theme.light)` (or dark) explicitly at the
  top of `mountUI` so text and background agree.
- Only `*Styled` builders re-apply on `setTheme`; plain `ui.box(cx, .{…})`
  snapshots tokens at mount. Widgets also snapshot; rebuild the tree after a
  theme switch if widgets must follow.

**State & lifetime**
- `cx.bindState(T, init)`: `init` must not own resources yet (allocate after
  binding). If `T` has `pub fn deinit(self: *T) void`, zenit calls it when the
  Cx is destroyed. **Do not also** register it with `scope.onCleanup`
  (double free). Use `scope.onCleanup(T, ptr, T.release)` with a differently
  named function only for resources that must die with a page Scope.
- Never keep a `*ui.Node` or `*ui.Scope` from a page after that page's Scope
  is disposed (router `Match`, `Show`, closing a panel). Clear the pointer in
  the same place you dispose.
- Never destroy the subtree that contains the node whose handler is running
  (e.g. switching pages from a button inside the page). Record the request in
  state and apply it at a safe point (next frame / `before_render` hook).
- Update text with `node.setTextContent(cx.allocator, s)`. `node.setText`
  does not copy content longer than 16 bytes, so a stack buffer then dangles.

**Layout & drawing**
- `.width/.height` default to `.fit`. Use `.fill()` / `.{ .grow = .{} }` to
  expand; set `.flex_shrink = 0` on fixed-size rows, dividers and headers or
  flex will shrink them.
- `node.rect` is parent-relative; use `node.globalRect()` for window coords.
- Text is single-line by default; wrapping needs `.wrap = .word` and a width.
- Change styles with `node.setStyle(alloc, .field, value)` (it picks the right
  dirty level). Low-frequency fields (shadow, z_index, hit_behavior…) live in
  `style.ext`; use `setStyle` or `style.ensureExtFallible(alloc)` first.
- Plain boxes are hit-test pass-through. Overlay roots that must swallow
  clicks need `hit_behavior = .@"opaque"`.
- After mutating state from outside zenit's event path (timer, worker
  result), call `node.markRenderDirty()` or `cx.requestRedraw()`. Idle
  frames are skipped when the tree is clean. For timed wakeups (caret blink,
  autosave), use `cx.scheduleRedrawAfterNs(ns)`, not a redraw every frame.

**Components**
- Overlays (`Modal`, `Sheet`, `Popover`, `Tooltip`, `DropdownMenu`,
  `Notifier`) render through the window portal. For `Modal`/`Sheet`, append
  `result.overlay` yourself only when `result.portaled == false`.
- Programmatic setters (`Input.setText`, `Slider.setValue`, …) do **not**
  fire `on_change`. Props prefixed `initial_` are read only at mount.
- Lists that can exceed ~200 rows use `ui.widgets.VirtualList`.
- Give every interactive node a `test_id`
  (`node.meta.ownership.meta.test_id = "area.thing"`) so E2E and DevTools
  can find it.

**Threads**
- Signals, nodes, `Cx` and `SystemSdk` are main-thread only. Workers get
  copies of data and hand results back through a queue with a generation
  number; the main thread drops stale generations.

## Quick API map

| Need | Use |
|---|---|
| App entry | `const app = try App.init(gpa, .{ .window = .{ .title = "…", .width = 900, .height = 600 } }); try app.runWith(mountUI);` |
| Several windows | `zenit_app.MultiWindowApp`, then `createWindowWith(cfg, mountFn)`, then `run()` |
| Containers | `ui.box`, `ui.hstack`, `ui.vstack`, `ui.grid`, `ui.spacer` (+ `*Styled`) |
| Text / icons | `ui.text`, `ui.textStyled`, `ui.textFmt` (Signal-bound), `ui.iconTint(cx, ui.icons.search, color, style)` |
| Click on any node | `_ = ui.clickable(node, cx.on(T, state, T.method))` |
| Handlers with args | `ui.Cx.boolHandlerFrom(T, s, T.fn)`, `ui.Cx.strHandlerFrom(T, s, T.fn)` |
| Reactive | `scope.createSignal`, `scope.createMemo`, `scope.createEffect`, `ui.Show`, `ui.For`, `ui.Match` |
| Pages | `ui.fx.Router(Page)` + `ui.Match` |
| Theme | `cx.setTheme(&ui.theme.light)`, `cx.themeSignal(scope)` |
| Logs | `cx.console().scoped("area").info("…", .{})` |
| DevTools | `_ = try ui.devtools.overlay.attach(cx, scope, root, .{})` (dev only) |

## References (read the one you need)

| File | Read when |
|---|---|
| [project-setup.md](references/project-setup.md) | Creating a project, build.zig / zon, .app bundle, signing, build options |
| [ui-tree-layout.md](references/ui-tree-layout.md) | Building node trees, flex/grid sizing, updating or hiding nodes, coordinates |
| [state-reactivity.md](references/state-reactivity.md) | bindState, handlers, Signals / Memo / Effect, Scope lifetimes, routing |
| [components.md](references/components.md) | Choosing and mounting widgets; table mapping design elements to components |
| [styling.md](references/styling.md) | Tokens, styles.zig, recipes, themes, ui.arb, migrating inline styles |
| [app-architecture.md](references/app-architecture.md) | Structuring a real app (feature folders, shell, commands, workers) |
| [macos-native.md](references/macos-native.md) | Menus, shortcuts, dialogs, clipboard, drag & drop, titlebar, IME, multi-window |
| [performance.md](references/performance.md) | Large lists, dirty levels, idle frames, background work, frame budgets |
| [verification.md](references/verification.md) | Proving the UI works: harness, screenshots, E2E, DevTools, leak checks |
| [pencil-design.md](references/pencil-design.md) | Implementing a Pencil (pen.dev) `.pen` design: read values via MCP, map to zenit, diff screenshots with `tools/design_diff.ts` |
| [pitfalls.md](references/pitfalls.md) | Something behaves strangely: index of symptom, cause and fix |

## Reporting back

When you finish a UI change, tell the user: what you built and which widgets
you used, how you verified it (build, test, screenshot path or E2E run), and
anything you could not verify (e.g. IME with a real input method, VoiceOver;
zenit has no VoiceOver-verified components yet).
