aetna-core 0.3.1

Aetna — backend-agnostic UI library core
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# Aetna — Widget Kit

> The contract for building widgets on top of Aetna. **Stock widgets get no privileged APIs that user widgets don't** — this document is the public surface that proves it.

## The symmetry invariant

If a stock widget (button, card, badge, alert, avatar, skeleton, dialog, sheet, modal, scroll, …) can do something, a widget defined in an app crate must be able to do the same thing using the same API. No backdoor methods on `El`, no internal-only fields, no library-side `match` on `Kind` that lights up behaviour for one variant but not another.

Stock widgets in `crates/aetna-core/src/widgets/` are reference compositions, not privileged code paths. An app can fork any of them and produce an equivalent widget without depending on internals. **`widgets/button.rs` is the dogfood proof** — it uses only the surface documented below.

## What's in the kit

A widget is a function (or struct + builder) that returns an [`El`]. To make widgets that look and behave like stock widgets, you have these things to work with — nothing else, nothing less:

### 1. The `El` builder

The whole grammar from `crates/aetna-core/src/tree/`. Sizing (`width`, `height`, `padding`, `gap`, `axis`, `align`, `justify`, `size`, `metrics_role`), visuals (`fill`, `stroke`, `stroke_width`, `radius`, `shadow`, `surface_role`), text (`text`, `text_color`, `text_align`, `text_role`, `font_size`, `font_weight`, `mono`, `italic`, `underline`, `strikethrough`, `link`, `wrap_text`, `text_overflow`, `ellipsis`, `max_lines`), icons (`icon`, `icon_name`, `icon_size`, `icon_stroke_width`), the paint-time transforms (`opacity`, `translate`, `scale`, `animate`), and the cross-cutting flags `clip()` (scissor children to this node's painted rect) and `scrollable()` (route wheel events to this node so it can scroll). `Kind::Scroll` already turns both on; `clip()` and `scrollable()` are the primitives behind it, available to any user widget that wants the same behaviour without claiming the structural variant.

### 1.1 Layout — sizing, alignment, container axes

Containers are El factories with axis + CSS-like defaults. `column([...])` is
`axis = Column, align = Stretch, height = Hug`; `row([...])` is
`axis = Row, align = Stretch, height = Hug`; `stack([...])` is
`axis = Overlay`. Each container has a **main axis** (the axis its children flow
along) and a **cross axis** (perpendicular). Both `column` and `row` default to
`Hug` on their main axis. To make a column or row claim its parent's extent, set
`.width(Size::Fill(1.0))` / `.height(Size::Fill(1.0))` explicitly.

Each child has a `Size` intent on each axis:

- `Fixed(px)` — exact size.
- `Hug` — intrinsic size of the child's content.
- `Fill(weight)` — claim a share of leftover space.

On the **main axis**, Fill siblings split leftover space proportional to weight. On the **cross axis**, Fill always claims the container's full extent — `Align` does not affect Fill children because there is no slack to position. `Align` positions Hug/Fixed children that are smaller than the container.

`Justify` distributes leftover main-axis space (`Start` / `Center` / `End` / `SpaceBetween`).

```rust
// Sidebar + content, both filling viewport height. The row's
// `Center` align is fine — Fill children fill regardless.
row([sidebar(), content()])
    .gap(tokens::SPACE_4)
    .height(Size::Fill(1.0))

// Card row of icon + text + button. `align(Center)` is the
// Tailwind `items-center` equivalent for everyday content rows.
row([icon("settings"), label, button("Edit")])
    .gap(tokens::SPACE_2)
    .padding(tokens::SPACE_3)
    .align(Align::Center)

// Two-pane fill: left pane gets 1/3, right gets 2/3.
column([
    left_pane().height(Size::Fill(1.0)),
    right_pane().height(Size::Fill(2.0)),
])
```

Common pitfalls to avoid:

- **A normal icon/text/action row usually wants `.align(Align::Center)`.**
  `row()` follows CSS flexbox and defaults to cross-axis stretch. Stock widgets
  set center alignment where they need it, but app-authored rows should spell
  out the familiar `items-center` intent.
- **A `Fill`-cross-axis child neutralizes the parent's `align`.** `align(Center)` only positions children that have slack to be positioned — Fill claims the full extent, so it's a no-op for that child. Where the visible content sits inside a Fill child is then determined by the *child's own* main-axis `justify` (which defaults to `Start`). Symptom: in a row of `align(Center)` siblings, a `Fill`-height column appears to "stick to the top" because its content top-aligns inside the box. Fix: `.height(Size::Hug)` on the inner column, so it sizes to content and the parent center alignment has slack to work with. (`column()` and `row()` now both default to `Hug` on their non-fill axis, which makes this the easy path. The footgun only resurfaces if you explicitly set `Fill` on the cross axis.)
- **Two `Fill` siblings in a column will split the column's height proportionally to weight** — give one of them `.height(Size::Hug)` if it should size to content (panel header above scrollable body, etc).
- **A row of full-height columns needs `.height(Size::Fill(1.0))` on the row itself.** Row defaults to `Hug` height, so it shrinks to its tallest child's hug height; nested `Fill`-height children then have nothing to fill.
- **`stack()` (overlay) children share the parent's rect.** Use it for layered visuals (focus rings, tooltips) — not as a generic container. Z-order is child order.

Shortcuts: `.fill_size()` for `.width(Fill(1.0)).height(Fill(1.0))`; `.hug()` for both Hug. `.padding(Sides::xy(h, v))` for asymmetric padding.

### 1.2 Component size

Two independent knobs: `ComponentSize` scales control height / icon
size on the size-driven widgets (button / tab / input / badge / …);
container padding is per-constructor and overridden at the call site
(see §1.2.1). There is no global density knob.

Stock controls have a t-shirt size that matches shadcn's `size` prop
1:1: `ComponentSize::{Xs, Sm, Md, Lg}`. Local modifiers:

```rust
button("Preview").small()
button("Publish").large()
text_input(&query, &selection, "search").size(ComponentSize::Sm)
progress(value, tokens::PRIMARY).small()
```

Aetna's built-in default starts at `ComponentSize::Sm` so desktop apps
land in a denser baseline. Use
`Theme::aetna_dark().with_default_component_size(ComponentSize::Md)`
to bump everything one rung, or
`Theme::aetna_dark().with_button_size(ComponentSize::Md)` to scope a
size override to a single control class.

Explicit layout calls always win. If an app writes
`.height(Size::Fixed(44.0))` or `.padding(20.0)`, the metrics pass
leaves that author choice alone. Custom widgets opt into stock control
sizing by setting `.metrics_role(...)` to one of the stock
`MetricsRole`s — `Button` / `IconButton` / `Input` / `TextArea` /
`Badge` for control-like surfaces, `TabTrigger` / `ChoiceControl` for
size-driven children, and `Slider` / `Progress` for range indicators.

### 1.2.1 Page rhythm and per-call padding

There is **no global density knob**. Container surfaces (card / form /
list / menu / table / panel / tabs / text-area) bake their padding /
gap / height / radius recipes directly in their constructors —
shadcn's stock recipe, visible at the call site. The metrics pass does
not override them. Override per-call, Tailwind-shaped:

```rust
card_header([card_title("Documents")])              // shadcn p-6 baked in
card_header([card_title("Settings")]).padding(tokens::SPACE_4)  // p-4 override
card_content([scroll([...])]).padding(0.0)          // flush scroll
card_content([rows]).pt(tokens::SPACE_2)            // p-6 + pt-2 (= shadcn p-6 pt-0 idiom)
accordion_trigger(...)                              // 40px tall, SPACE_3 sides baked in
accordion_trigger(...).height(Size::Fixed(32.0))    // override one widget
```

Page-level rhythm (page padding, section gaps, cluster gaps) is also
not configured by a knob — pick a `tokens::SPACE_*` constant where you
need it, the way a Tailwind author writes `p-8` / `space-y-6` /
`gap-4`. The token ladder maps 1:1 to Tailwind's spacing scale
(`SPACE_4` = `16` = `p-4`, `SPACE_6` = `24` = `p-6`, `SPACE_8` = `32`
= `p-8`). For a typical shadcn-shaped app shell:

```rust
column([
    toolbar([toolbar_title("Documents"), spacer(), button("New").primary()]),
    /* content */
])
.padding(tokens::SPACE_8)       // p-8 page chrome
.gap(tokens::SPACE_6)           // space-y-6 between sections
```

### 1.3 Typography family

Aetna treats the proportional UI font as a theme default, not a random
renderer detail. The default is Inter; Roboto is available as a
Material-style/compatibility alternate via the `roboto` Cargo feature:

```rust
Theme::aetna_dark().with_font_family(FontFamily::Inter)
Theme::aetna_dark().with_font_family(FontFamily::Roboto) // requires `roboto` feature
```

Text nodes inherit the theme family before layout, so intrinsic sizes,
wrapping, ellipsis, SVG artifacts, and backend glyph shaping agree.
Local text can still opt out with `.font_family(...)`, or use the
convenience shorthands `.inter()` and `.roboto()`.

Run `cargo run -p aetna-core --example font_family_comparison
--features roboto` to regenerate the current Roboto/Inter comparison
fixture.

### 1.4 Color vocabulary

Color tokens intentionally mirror the shadcn/Tailwind semantic split.
Use the role name, not the old implementation name:

```rust
tokens::BACKGROUND
tokens::FOREGROUND
tokens::CARD
tokens::CARD_FOREGROUND
tokens::POPOVER
tokens::POPOVER_FOREGROUND
tokens::PRIMARY
tokens::PRIMARY_FOREGROUND
tokens::SECONDARY
tokens::SECONDARY_FOREGROUND
tokens::MUTED
tokens::MUTED_FOREGROUND
tokens::ACCENT
tokens::ACCENT_FOREGROUND
tokens::DESTRUCTIVE
tokens::DESTRUCTIVE_FOREGROUND
tokens::BORDER
tokens::INPUT
tokens::RING
tokens::SUCCESS
tokens::SUCCESS_FOREGROUND
tokens::WARNING
tokens::WARNING_FOREGROUND
tokens::INFO
tokens::INFO_FOREGROUND
```

The paired `*-FOREGROUND` tokens are for solid fills. A primary button
uses `PRIMARY` plus `PRIMARY_FOREGROUND`; a secondary button uses
`SECONDARY` plus `SECONDARY_FOREGROUND`. `BORDER` is the normal
separator/card stroke, `INPUT` is the stronger control stroke, and
`RING` is the focus outline color. Link, scrollbar, overlay, and
selection colors are extension tokens because they describe a specific
component/domain rather than the reusable core palette.

Theme metrics can tune the global page rhythm and per-control-class sizes:

```rust
Theme::aetna_dark()
    .with_default_component_size(ComponentSize::Md)  // bump every control one size
    .with_input_size(ComponentSize::Sm)              // but keep inputs Sm
    .with_tab_size(ComponentSize::Sm)                // and tabs Sm
```

Card / form / list / menu / panel / preference / table padding, gap,
height, and radius are baked into each constructor (shadcn-shaped
defaults visible at the call site) and override per-call via
`.padding(...)` / `.pt(...)` / `.px(...)` / `.height(...)` / `.gap(...)`.
There is no `Density` enum, no `with_*_density` knob, no
`Theme::compact()` — the previous global selectors produced silent
drift across surfaces that an LLM author could not predict from
reading a single constructor.

### 2. Identity & a11y tags

- `key(s)` — stable identity for hit-test routing and event delivery.
- `at_loc(loc)` — source-mapped location, set automatically when your builder is `#[track_caller]`.
- `Kind::Custom("widget-name")` — the recommended kind for any user widget. Surfaces the name in tree dumps and lint output without claiming any built-in behaviour.

The decorative `Kind` variants (`Button`, `Card`, `Badge`, `Heading`, `Modal`, `Scrim`) are inspector tags only. The library does not dispatch behaviour on them. Use them or use `Custom` — the rendered output is the same.

### 3. Style profiles + surface roles

`StyleProfile` (`Solid`, `Tinted`, `Surface`, `TextOnly`) controls how the cross-cutting modifiers (`.primary`, `.success`, `.warning`, `.destructive`, `.info`, `.muted`, `.ghost`, `.outline`, `.secondary`) react to your widget. Set it once in your builder; the modifier vocabulary just works.

This is the rule that lets new widgets ship without editing `style.rs`. If your widget should react to `.primary()` like a button (solid fill), use `StyleProfile::Solid`. Like a badge (tinted alpha), use `Tinted`. Like a card (surface tint), use `Surface`. Pure text colour shifts only, use `TextOnly`.

`SurfaceRole` (`Panel`, `Raised`, `Sunken`, `Popover`, `Selected`, `Current`, `Input`, `Danger`) is the theme-facing semantic role for rect-shaped surfaces. Set it with `.surface_role(...)` when the widget's surface should be themed as a panel, input, popover, selected row, current nav item, and so on. The draw-op pass emits both the normal rounded-rect uniforms and a `surface_role` uniform; `Theme` can route different roles to different shaders via `with_role_shader`.

Use roles for meaning and profiles for modifier behavior. A text input, for example, uses `StyleProfile::Surface` so `.invalid()` can affect stroke/fill, and `SurfaceRole::Input` so a theme can render it as an inset/sunken material.

### 3.1 Text overflow policy

Single-line app chrome should choose an overflow policy explicitly. The default is `TextOverflow::Clip`; `.ellipsis()` switches a nowrap text element to truncation with a trailing ellipsis at draw-op construction time, so SVG fallback and GPU renderers see the same shortened string.

Use `.ellipsis()` for table cells, sidebar labels, command palette rows, email/name columns, badges with bounded slots, and any other fixed-width text where clipping would look like a rendering bug. The lint pass reports horizontally overflowing nowrap text as `FindingKind::TextOverflow` and suggests `.ellipsis()`, `wrap_text()`, or a wider box.

For bounded wrapped copy, use `.wrap_text().max_lines(n)`. The draw-op pass clamps the displayed text and ellipsizes the final visible line, so wrapped descriptions can stay inside cards, list rows, and helper panels without silently expanding the layout.

### 3.2 Typography roles

`TextRole` (`Body`, `Caption`, `Label`, `Title`, `Heading`, `Display`, `Code`) is the semantic typography role for text-bearing nodes. Set it with `.text_role(...)`, or use the role modifiers `.body()`, `.caption()`, `.label()`, `.title()`, `.heading()`, `.display()`, and `.code()`.

Roles apply default size/line-height/weight/color so product code can say what a text run is before overriding a specific detail. Aetna's typography tokens intentionally mirror Tailwind pairs such as `text-sm` = 14/20, `text-2xl` = 24/32, and `text-3xl` = 30/36; use `.line_height(...)` only for deliberate custom typography. For example, table headers and tiny metadata should usually be `.caption()`, button/menu labels should be `.label()`, card titles should be `.title()`, page titles should be `.heading()` or `.display()`, and inline code should use `.code()`. For shadcn-style secondary copy such as page subtitles, card descriptions, and explanatory helper text, prefer `.muted()` on body text; that preserves the normal 14px body rhythm while switching to `MUTED_FOREGROUND`. Tree dumps show non-body roles as `text_role=...`, which gives the agent loop a semantic handle when tuning density and hierarchy.

### 3.3 Icons

Use `icon("search")` for built-in vector icons, `icon_button("menu")` for the standard theme-sized icon-only button surface, and `button_with_icon("upload", "Publish")` for label+icon actions. The names intentionally mirror common lucide/shadcn names: `menu`, `search`, `bell`, `layout-dashboard`, `file-text`, `folder`, `users`, `bar-chart`, `git-branch`, `git-commit`, `refresh-cw`, `alert-circle`, `check`, `x`, `plus`, `chevron-right`, and related basics.

Icons are normal `El`s: set `.color(...)`, `.icon_size(...)`, `.icon_stroke_width(...)`, width/height, padding, or put them inside rows the same way as text. Prefer the icon-size tokens (`tokens::ICON_XS` = 14, `tokens::ICON_SM` = 16, `tokens::ICON_MD` = 20) over borrowing typography tokens for icon geometry. Tree dumps show `icon=<name>`, draw-op artifacts include `Icon` records, and the SVG fallback renders the vector path directly. The wgpu renderer, browser WebGPU path, and Vulkano renderer all render SVG-backed vector geometry through the shared vector mesh.

### 3.4 Form rows

`field_row("Volume (52%)", slider(...).key("volume"))` is the [label … control] row that fills 80% of a settings panel. The label is `.label()`-styled, a spacer pushes the control to the right edge, and the row vertical-centers and fills its parent's width so a column of `field_row`s lays out as a clean form. For multi-control rows (e.g. a value readout next to a slider), wrap them in `row([...])` and pass that as the control. Forks fine — the helper is a 4-line composition over `row`, `spacer`, and `text(...).label()`.

Pair `field_row` with `slider::apply_input(&mut value, &event, key, step, page_step)` for forms with several sliders: one call dispatches both the pointer drag and the keyboard arrows, so the event handler stays one branch per slider rather than two `match` blocks dispatching by event source. `bin/settings_modal.rs` is the worked example — a tabbed modal at a custom 720×620 panel size, with a scrollable body between sticky tabs and a sticky footer.

### 3.5 Dialog, sheet, and modal anatomy

Use `dialog(key, [dialog_header([...]), body, dialog_footer([...])])` for the shadcn-shaped path: content, header, title, description, body, footer. Use `sheet(key, SheetSide::Right, [...])` for the same anatomy attached to an edge. Both are pure overlay compositions: scrim first, blocking panel second, no portal or retained overlay stack.

The older `modal(key, title, body)` helper stays as the compact convenience API and bakes a 420 px panel. For settings dialogs and other form-heavy modals, compose with `overlay` + `modal_panel` directly so the panel's size lives at the call site:

```rust
overlay([
    scrim("settings:dismiss"),
    modal_panel("Settings", [tabs_list(...), scroll([body]), footer])
        .width(Size::Fixed(720.0))
        .height(Size::Fixed(620.0))
        .block_pointer(),
])
```

`modal_panel` is `axis = Column, align = Stretch`, so a `scroll([body])` child claims the remaining height between any `Hug`-sized siblings (title, tabs, footer) — the footer stays visible while a long form scrolls inside the panel. The `.block_pointer()` chain is what stops clicks on the panel from passing through to the scrim and dismissing the modal.

### 4. Focus + interaction

- `.focusable()` — opt into Tab focus order and the focus ring. The library writes `focus_color` + `focus_width` uniforms onto your node's quad whenever the focus envelope is non-zero (animated by the runtime). The `RoundedRect` stock shader draws the ring in the `paint_overflow` band; if you bind a custom shader, you receive the same uniforms and decide what to paint with them.
- `.paint_overflow(Sides)` — extend your painted area beyond your layout bounds. Layout-neutral (siblings don't shift, hit-testing still uses layout bounds). Use this to give the focus ring (or a drop shadow, or a glow halo, or a custom focus visual) somewhere to render outside the box.
- `.block_pointer()` — stop pointer events from passing through to siblings underneath. Used by modal panels and similar.

The library handles `Hover` / `Press` / `Focus` envelopes automatically once `focusable` is set: hover lightens, press darkens, focus rings fade in/out. None of these are kind-keyed — they apply to any focusable node.

#### Hover affordances beyond the built-in state

For "show on hover" patterns whose visibility shouldn't shift the surrounding layout — close × on a tab, secondary actions on a list row, hover-only validation icons — reach for `.hover_alpha(rest, peak)`. It binds the element's drawn alpha to the **subtree interaction envelope** (max of hover, focus, and press over the subtree rooted at this node), so a hover-revealed close icon stays visible while the tab is keyboard-focused or while the cursor moves to a focusable descendant. CSS `:hover`-style cascade.

For other hover-driven affordances (lift, scale-pop, tint shift), drive the prop from app code:

```rust
fn build(&self, cx: &BuildCx) -> El {
    let lifted = cx.is_hovering_within("card");
    card([...])
        .key("card")
        .focusable()
        .translate(0.0, if lifted { -2.0 } else { 0.0 })
        .scale(if lifted { 1.02 } else { 1.0 })
        .animate(Timing::SPRING_QUICK)
}
```

`BuildCx::is_hovering_within(key)` reads the same subtree predicate `hover_alpha` consumes. `.animate()` eases the prop between the two build values across frames, so transitions stay smooth without a per-channel declarative API. For transition-driven side effects (analytics, prefetch, sound), match `UiEventKind::PointerEnter` / `PointerLeave` on the corresponding key in `App::on_event`.

### 5. Custom shaders & custom layout

The two **escape hatches** documented in `docs/SHADER_VISION.md`:

- `.shader(ShaderBinding)` — bind your own shader for the surface paint, replacing `stock::rounded_rect`. The library injects `inner_rect` and `focus_color` / `focus_width` (when focusable + focused) uniforms into your binding — your shader can use them or ignore them.
- `.layout(F)` — supply your own positioning function for direct children. The library still recurses into each child and drives hit-test / focus / animation off the rects you return. The `LayoutCtx` handed to your function carries `container` (your inner rect), `children` (read-only), `measure` (intrinsic for any child), and `rect_of_key(&str) → Option<Rect>` (look up any keyed element's laid-out rect — used by anchored popovers and any cross-tree positioning).

### 6. Controlled widget state

App-facing widgets are **controlled**: the app owns their state and passes
that state into the widget builder on every `build()`.

```rust
use aetna_core::prelude::*;

struct Form {
    name: String,
    selection: Selection,
}

impl App for Form {
    fn build(&self, _cx: &BuildCx) -> El {
        text_input(&self.name, &self.selection, "name")
    }

    fn on_event(&mut self, event: UiEvent) {
        if event.target_key() == Some("name") {
            text_input::apply_event(&mut self.name, &mut self.selection, "name", &event);
        }
    }

    fn selection(&self) -> Selection {
        self.selection.clone()
    }
}
```

That pattern is intentional. It keeps generated application code
obvious: state lives in the app struct, `build()` projects it into an
`El`, and `on_event()` folds routed events back into the state.

The same shape extends to selection-style widgets. `tabs_list("k", &self.tab, [...])` paints a segmented row of triggers; `tabs::apply_event(&mut self.tab, &event, "k", parse)` folds clicks into the app's tab field. The page body is a plain `match self.tab` — there is no implicit "tab content" sibling; Rust's match is more honest than a wrapper that hides itself when not active. The naming and routed-key shape (`{key}:tab:{value}`) mirror shadcn / Radix Tabs and the WAI-ARIA tablist pattern so an LLM author finds familiar terrain. `select_trigger` + `select_menu` follow the same rule with `{key}:option:{value}`, and `radio_group` parallels `tabs_list` with a vertical layout and `{key}:radio:{value}`.

Two-state controls follow the same controlled pattern in their simplest form. `switch(self.auto_save).key("auto_save")` (track + thumb, like shadcn Switch) and `checkbox(self.agree).key("agree")` (square + check, like shadcn Checkbox) project a `bool` into a visual; `switch::apply_event(&mut self.auto_save, &event, "auto_save")` and `checkbox::apply_event` fold clicks back into the field. They share the same one-shape rule: app owns the `bool`, widget projects it, helper folds the event.

Read-only data displays skip the helper entirely. `progress(value, tokens::PRIMARY)` (like shadcn Progress) draws a track + filled portion for a `0.0..=1.0` ratio; there is no `apply_event` because the widget doesn't accept input — the underlying value is whatever the app derived from a snapshot, timer, or computation.

There is also an advanced `UiState::widget_state::<T>` typed bucket used
by tests, diagnostics, and future host/widget experiments. Normal widget
builders do not receive `UiState`, so do not reach for it when writing
app-level widgets. If a stock widget needs hidden state that an app
widget cannot express with controlled state, the kit is missing a public
primitive and should grow one instead.

### 6.1 Optimistic overrides for externally-driven state

The controlled pattern in §6 assumes the *app* owns state. When the
truth lives in an external system (an audio server, a network peer, a
database) and the app sees it through periodic snapshots, naïvely
binding `build()` to the snapshot makes user input feel sluggish: the
slider snaps back to the snapshot value while the side effect is in
flight, then jumps to the new value the next time the snapshot ticks.

The pattern: **keep a `HashMap<Id, Override>` of pending values
alongside the snapshot**, render `override.unwrap_or(snapshot)`, fire
the side effect immediately on user input, and clear the entry on the
next snapshot whose value matches (within a small epsilon for floats).

```rust
fn percent_for(&self, node: &AudioNode) -> u32 {
    let snapshot_pct = node.volume.as_ref().map(Volume::percent);
    let override_pct = self.volume_overrides.borrow().get(&node.id).copied();
    match (override_pct, snapshot_pct) {
        // Snapshot caught up — drop the override.
        (Some(o), Some(s)) if o.abs_diff(s) <= 1 => {
            self.volume_overrides.borrow_mut().remove(&node.id);
            s
        }
        (Some(o), _) => o,            // override wins until reconciled
        (None, Some(s)) => s,         // pure snapshot
        (None, None) => 100,          // safe default before first snapshot
    }
}
```

`aetna-volume` uses this for volume, mute, and active-profile state.
The widget builder remains "controlled" — `build()` reads
`percent_for(node)` and projects that into the slider — but the value
behind it now reconciles two sources without flicker.

### 6.2 Tooltips

`.tooltip(text)` attaches a hover-driven tooltip to any element. The
runtime — not the app — owns the lifecycle: after the pointer rests
on the trigger for ~500ms, the library synthesizes a styled tooltip
layer at the El root, anchored below the trigger (flipping above on
viewport collision). Pointer leaves the trigger, or the user clicks,
the tooltip dismisses.

```rust
button("Save")
    .key("save")
    .tooltip("Save the current document (Ctrl+S)")
```

This is the only floating layer the library adds on the app's
behalf. Modals and popovers stay app-owned (rendered explicitly
from app state at the El root) — see `widgets/popover.rs` for the
"no portal hoist" rationale. Tooltips fit a different rule because
they are a pure read-out of hover state; the trigger doesn't need to
be keyed or focusable, and the synthesized layer is hit-test
transparent so it doesn't interfere with continued hover on the
trigger underneath.

### 7. Hotkeys & key delivery

Hotkeys are an app-level concern (`App::hotkeys()` returns `Vec<(KeyChord, String)>`); the library matches them in `key_down` ahead of focus activation. Widget builders that want a hotkey advertise the chord via the host's hotkey registry — there's no widget-private hotkey table.

Focused-node key capture: a widget that wants to consume Tab/Enter/Escape (and arrow keys / Backspace / Delete / Home / End / character keys) opts in with `.capture_keys()`. While that node is the focused target, the library's Tab traversal and Enter/Space activation defaults are bypassed and the raw `KeyDown` is delivered for the widget to interpret. Registered hotkeys still match first — an app's global Ctrl+S beats a text input's local consumption of S.

### 8. Host integration surface (not for widgets)

A handful of `UiState` methods exist for **host code** — backend `Runner` shells, the `aetna-web` wasm entry, port crates that integrate Aetna into a larger app — not for widget builders. Calling them from inside a widget would be a symmetry violation, since user widgets have no access to the runner-side state these talk to. They live in the public API because the host crates that use them are *also* downstream of `aetna-core`, but they aren't part of the widget kit.

- `UiState::rect_of_key(root, key) -> Option<Rect>` and `UiState::target_of_key(root, key) -> Option<UiTarget>` — let a host look up the laid-out rect (or full event-routing target) for a keyed element. Used to anchor native overlays over a reserved viewport region, or to forward a host-side event into an externally-painted region. Widget code looking up another node's rect should use `LayoutCtx::rect_of_key` (§5) instead — that's the kit primitive.
- `UiState::set_animation_mode(mode)` — switch between real-time and frozen animation evaluation. Used by headless render fixtures and tests to get deterministic output.
- `UiState::has_animations_in_flight() -> bool` — host's frame-pacing decision: keep ticking the loop or sleep until input. Each backend `Runner::prepare()` already returns a `needs_redraw` derived from this; calling it directly is for hosts that want the signal independent of `prepare()`.
- `UiState::debug_summary() -> String` — terse per-frame state dump for `console.log`-style instrumentation in browser builds.

These all interact with library-owned bookkeeping (focus tracker, animations, computed-rect map). They aren't backdoors past the kit — they're a different audience's surface. If a widget ever wants one of these, that's a sign the kit is missing a primitive, and the right move is to add it under §1–§7, not to reach for the host method.

## Common smells

The library has a small, named vocabulary precisely so a widget — or an app `build()` — doesn't need to invent one. The patterns below mean an existing affordance is being missed:

- **`.font_size(...).font_weight(...).text_color(...)` on a single text node.** That's what role modifiers exist for. `.heading()`, `.title()`, `.label()`, `.caption()`, `.code()` set size + weight + theme-aware color in one call. Reaching for the underlying primitives is how typography drifts (one hand-written 16px semibold title looks subtly different from another).
- **`column([...]).fill(CARD).stroke(BORDER).radius(...)` for grouped content.** That's `card([card_header([card_title("Title")]), card_content([...])])`. Cards route through `SurfaceRole::Panel` so the theme can swap the material later (shader, shadow, inset) without touching the call site.
- **`column([text(label).label(), text_input(...)]).gap(...)` for vertical fields.** That's `form_item([form_label(label), form_control(text_input(...)), form_description(...)])` inside `form([...])`. `form_item` bakes `gap(SPACE_2)` (≈ shadcn `space-y-2`) and `form` bakes `gap(SPACE_3)`; override per-call when a layout calls for tighter or looser stacks.
- **`row([...]).metrics_role(TableRow).align(Center)` for table rows.** That's `table_row([...])` inside `table([table_header([...]), table_body([...])])`. `table_header` promotes direct `table_row` children to header metrics, and table rows center their cells by default.
- **Status as a unicode bullet or emoji** (`text("● Online")`, `text("⚠ Failed")`). That's `badge("Online").success()` / `badge("Failed").destructive()`. Badges read as proper status pills and pick the theme color through the StyleProfile.
- **Callouts as custom cards.** That's `alert([alert_title("Heads up"), alert_description("Details")]).warning()`: the alert bundles the surface profile, the destructive/warning/info color route, and the shadcn-shaped padding recipe so callouts stay visually consistent.
- **Identity chips as ad hoc circles.** That's `avatar_fallback("Alicia Koch")`, `avatar_initials("AK")`, or `avatar_image(img)`. The stock avatar keeps tables, nav, and activity feeds on the same circle size/radius.
- **Loading placeholders as raw muted boxes.** That's `skeleton()` plus normal `.width(...)` / `.height(...)`, or `skeleton_circle(32.0)` for avatar placeholders.
- **Command palette rows as repeated `row([...])` snippets.** That's `command_row("git-branch", "New branch", "Ctrl+B")`, or `command_item([command_icon(...), command_label(...), command_shortcut(...)])` when the row needs custom children.
- **Collapsible sections as button-plus-chevron snippets.** That's `accordion_item(...)`, `accordion_trigger(...)`, and `accordion::apply_event(...)`; the trigger bakes a 40px-tall list-row recipe with focus, pointer cursor, and the standard chevron treatment.
- **Sidebar navigation as custom columns.** That's `sidebar(...)`, `sidebar_group(...)`, `sidebar_menu(...)`, and `sidebar_menu_button_with_icon(...)`; the buttons share the same 40px list-row recipe, and selected items use the shared `.current()` treatment.
- **Toolbars as hand-aligned rows.** That's `toolbar(...)` and `toolbar_group(...)`; action rows should center their controls and use the same gap cadence as table/page chrome.
- **Dropdown menus as a popover full of custom rows.** That's `dropdown_menu(...)`, `dropdown_menu_content(...)`, `dropdown_menu_label(...)`, `dropdown_menu_separator()`, and `dropdown_menu_item_with_icon_and_shortcut(...)`; the stock rows bake the shadcn-shaped menu-item recipe (height + side padding + gap + radius) and arrow navigation.
- **Dialog and sheet surfaces as custom overlay cards.** That's `dialog(key, [dialog_header([...]), ..., dialog_footer([...])])` or `sheet(key, SheetSide::Right, [...])`; both keep the scrim/panel/block-pointer shape consistent with modal and popover behavior.
- **Breadcrumbs as slash-delimited text.** That's `breadcrumb_list([breadcrumb_link(...), breadcrumb_separator(), breadcrumb_page(...)])`; the links, current page, separators, and centered row rhythm are separate named pieces.
- **Pagination as custom button rows.** That's `pagination_content([pagination_previous(), pagination_link("1", true), pagination_next()])`; page links get a stable square control box and previous/next use the built-in chevron icons.
- **`.gap(0.0)`.** The default *is* `0.0`. Setting it explicitly is noise that signals the author misremembered the default — and usually means actual gap is missing somewhere else where it should be added.
- **Wrapping a single child in `row([single])` to apply padding.** `.padding(Sides::all(...))` is on every `El`. The wrapper is dead weight.
- **Tree indent built from `row([spacer().width(Fixed(indent)), ...])`.** Use `.padding(Sides { left: indent, ..Sides::zero() })` on the row — left-only padding does the job without an extra child. `Sides::xy(h, v)` is also there for symmetric horizontal/vertical padding.
- **Explicit `.fill(tokens::BACKGROUND)` on the root.** The host paints `BACKGROUND` behind the tree before draw-ops run; the root fill is redundant.
- **A built-in `IconName::*` standing in for an app-specific SVG.** Apps ship `SvgIcon::parse_current_color(include_str!("..."))` once (typically as a `LazyLock`) and pass the result to `icon(...)` — same pipeline, same `text_color` tinting as the built-ins.

These aren't style nits — they're load-bearing in keeping LLM-authored UI from drifting into raw-rectangle territory. If you find yourself writing one of them, that's a kit-discoverability problem worth flagging in this doc rather than coding around.

## What you don't get

These would be symmetry violations and aren't part of the kit:

- **No stock-only fields on `El`.** Every public field/builder method is yours too.
- **No library-side `match` on `Kind::*`.** The decorative variants are inspector tags. The structural ones (`Group`, `Spacer`, `Divider`, `Scroll`, `VirtualList`, `Inlines`, `HardBreak`, `Custom`, `Text`) earn their place — they affect layout/event semantics — and apply to your widget the same way they apply to stock.
- **No reaching past the runner.** The `Runner` in each backend crate consumes `DrawOp` and `UiState`; widgets produce `El` trees. There's no widget API that pokes the runner directly.

## The dogfood test

A widget passes the kit if it can be written using *only* the items above. The compiler can't enforce this — the API is open. The convention is enforced by `widgets/button.rs`, `widgets/badge.rs`, `widgets/card.rs`: each is a tight composition of public surface, no internals.

If you find yourself wanting a feature that requires reaching past this kit, that's a signal to **add the feature to the kit** rather than carving an exception. Open an issue or rev `widget_kit.md`. The point of the symmetry invariant is that the library is a substrate, not a library of fixed components.