Components
What a zenit component promises, when to drop down a layer, and the three contracts: mounting, overlays and testing. Per-component recordings, props and result types live on the component site.
What “component” means here
A zenit component isn’t a helper that glues colors and corner radii together. A public component typically packages node structure, persistent state, event routing, focus and keyboard semantics, accessibility properties, theme tokens, overlay lifetime, and a test boundary the harness can locate and read back. They are all exported from ui.widgets.
Choose the right layer first
ui.widgets.*Business state, copy, callbacks and placementui.hooks · ui.interaction · ui.select_headlessDrawing, tokens, a11y labels and composition boundariesui.Node + ui.eventsHit-testing, keyboard, focus, IME, a11y and the test contractCatalog
Every component has its own page on the component site: a recording of the real zenit Storybook.app, props and result types generated from source, and what the recording proves. The harness locates each story by test_id, drives a virtual cursor or input, and records the Metal drawable directly.
Mount, state & cleanup
Most visual components use the builder form Config.mount(scope, cx): ui.widgets.Button(props) returns a builder and mount actually builds the tree. The return value is either the root *Node or a result struct with handles such as wrapper, trigger, body, panel and state.
const EditorActions = struct {
document: *Document,
fn save(self: *EditorActions) void {
self.document.save();
}
};
const bindings = try cx.bindState(EditorActions, .{ .document = document });
const save = try ui.widgets.Button(.{
.label = "Save",
.variant = .primary,
.on_click = cx.on(EditorActions, bindings, EditorActions.save),
}).mount(scope, cx);
const name = try ui.widgets.Input(.{
.label_text = "Project name",
.placeholder = "Untitled",
.required = true,
.width = 320,
}).mount(scope, cx);
try form.appendChild(cx.allocator, name);
try form.appendChild(cx.allocator, save);A few components use the function form, taking props, scope and cx in one call: mountScrollArea, mountGrid, plus Select, ComboBox, TagsInput, NumberStepper, FileUpload and DataTable (these are aliases of mountX functions in ui.widgets, called as ui.widgets.Select(props, scope, cx)).
// Function form: props, scope and cx in one call; returns a handle struct.
const area = try ui.widgets.mountScrollArea(.{ .height = 320 }, scope, cx);
try area.content.appendChild(cx.allocator, list);
try root.appendChild(cx.allocator, area.container);Button · Input*NodeModalModalResult{ overlay, dialog, body, portaled }TooltipTooltipResult{ wrapper, trigger, content }SelectSelectMount{ wrapper, trigger, panel, state, is_open }mountScrollAreaScrollAreaResult{ container, content, state }mountGridGridResult{ root, state }Don’t treat config as live props you can write to later. A component reads its config at mount time to build nodes and state, so a signal.get() inside config is read once, as a snapshot. Data that keeps changing flows in through the returned state, a Signal (such as Modal’s .visible(sig)) or the component’s public methods; never keep a pointer to a temporary config across frames. A component’s internal state lives on the child Scope that mount creates and is released when the parent Scope is disposed.
Why overlays must be components
Tooltip, Popover, Menu, Modal, Sheet and the in-app Notifier all register a layer with the OverlayStack. Layers with a barrier (Modal / Sheet) are re-parented to the window-level portal (WindowOverlayPortal under cx.root) so they escape any ancestor’s overflow clipping. Z values come from semantic tiers: overlay 100, dialog 1000, toast 2000 (where the Notifier lives), tooltip 3000, with the devtools overlay fixed at 32000. An overlay opened from inside another always stacks above its host. Absolute positioning and a large z-index on a box give you none of this.
const visible = try scope.createSignal(bool, false);
const modal = try ui.widgets.Modal(.{
.title = "Delete document?",
.width = 420,
}).visible(visible).mount(scope, cx);
try modal.body.appendChild(cx.allocator, confirm_content);
// modal.portaled == true: the barrier already lives under the window-level
// portal. Do not append modal.overlay to the current tree.
// Open it from any handler:
visible.set(true);- ✓
Inside clicks stay inside the dialog / panel and never trigger outside-dismiss; clicking the barrier closes it per
close_on_overlay. - ✓
Escape closes only the topmost overlay; nested layers unwind one at a time.
- ✓
Focus: an open Modal traps and auto-focuses, then restores focus to where it was on close.
- ✓
Scope disposal actively detaches the barrier subtree from the portal, so switching pages never leaves an invisible hit layer behind.
- ✓
Exit transitions keep the overlay visible until the animation commits, then suspend it. There is no jump to the end state.
For non-blocking app-wide notifications use ui.widgets.Notifier (it replaces the old ToastManager): Notifier.init(scope, cx, .{}) registers a toast-tier layer and, when the window portal exists, mounts its full-window container there (if portaled is false, append container to your root yourself). Then show(.{ .tone = .success, .title = "Saved" }) returns an id, update transforms a card in place and dismiss retires it. Dismissals, expiry and button actions come back to the app as Listener events; history, do-not-disturb and other notification-center logic belong to the app.
The showcase is also a regression suite
e2e/storybook.test.ts switches to each story via nav.<key>, collects the subtree’s text and asserts every expected data value is present, then takes a screenshot; interactive components also assert state, geometry or pixels after interacting. The component site’s recordings reuse the same app and the same semantic ids, so the demo and the test never fork into two implementations.