---
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 — or 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://zenit.z.express/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` → a local checkout; `.url` → `~/.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 → [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. → [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).
   → [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.
   → [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 — 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` → `createWindowWith(cfg, mountFn)` → `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; design element → component table |
| [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 — symptom → cause → fix index |

## 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).
