aetna-core 0.3.6

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
479
480
481
482
<img src="https://raw.githubusercontent.com/computer-whisperer/aetna/main/assets/aetna_badge_icon.svg" alt="Aetna badge icon" width="96">

# aetna-core

![Aetna hero demo — release console rendered headlessly through the wgpu backend](https://raw.githubusercontent.com/computer-whisperer/aetna/main/assets/aetna_hero.png)

Backend-agnostic UI primitives for Aetna apps.

Aetna is shaped around how an LLM authors UI: vocabulary parity with the
training distribution matters more than configurability, and the *minimum*
output should be the *correct* output. The catalog below — `card`, `sidebar`,
`tabs_list`, `dialog`, `toolbar`, `item`, etc. — mirrors the shadcn /
WAI-ARIA shapes models already know. **Reach for those before composing
primitives.** `column` / `row` / `stack` / `button` / `text` are layout
fallbacks for when no named widget fits, not the canonical app vocabulary.

## Reach for these first

When scaffolding a UI, prefer the named affordance over the underlying
primitives. The list is short:

| Intent | Idiomatic call | Avoid |
|---|---|---|
| Grouped content (settings card, panel of fields, any "boxed" surface) | `card([card_header([card_title("Title")]), card_content([...])])` or `titled_card("Title", [...])` | `column([...]).fill(CARD).stroke(BORDER).radius(...)` or `column(...).surface_role(SurfaceRole::Panel)` (Panel only sets stroke + shadow — not fill) |
| Flat sidebar / nav rail | `sidebar([sidebar_header(...), sidebar_group([...])])` plus `sidebar_menu_button_with_icon(...)` for leaf items | `column(...).fill(CARD).stroke(BORDER).width(SIDEBAR_WIDTH)` or `column(...).surface_role(SurfaceRole::Panel)` for the sidebar surface |
| Sidebar tree / dense resource list | keep `sidebar([...])`, then make one local `tree_row(depth, leading, label, trailing, current)` helper from `row([...]).focusable().height(Size::Fixed(28.0..40.0)).current()` and indent via padding | forcing every branch/file/stash into flat `sidebar_menu_button(...)`, or using card/table rows inside the sidebar |
| Toolbar / page header row | `toolbar([toolbar_title("Documents"), spacer(), toolbar_group([...])]).padding(Sides::xy(tokens::SPACE_4, tokens::SPACE_2))` as app chrome; use `card_header` only inside a card | wrapping the top toolbar in `card([card_content([toolbar(...)])])`, or ad hoc action rows with inconsistent vertical alignment |
| Top-level app menus | `menubar([menubar_trigger("app-menu", "file", "File", open == Some("file"))])` plus root-layer `menubar_menu("app-menu", "file", [...])`, folded with `menubar::apply_event(&mut open, &event, "app-menu")` | a toolbar row of unrelated dropdown buttons, or a hand-rolled File/Edit/View strip |
| Conversation / event-log row | a local `log_row(role_color, faint_fill, content)` helper built from `row([gutter, content])`; use `accordion_item` for collapsible reasoning/tool details | `card([card_header([badge(role)]), card_content([message])])` repeated for every chat message |
| Tabs / segmented control | `tabs_list(key, &current, options)` + `tabs::apply_event`; for icon/badge/count tabs, use `tabs_list_from_triggers([tab_trigger_content(key, value, [...], selected)])` | manual `row([button, button]).fill(MUTED)` segment, or hand-rolled selected-tab state |
| Object/action list row (recent repo, file, project, person) | `item([item_media_icon(...), item_content([item_title(...), item_description(...)]), item_actions(...)])` inside `item_group([...])` | `row([column([text, text]), button, button]).key(...)` — every clickable repo/file/project/person row is `item`, not a hand-rolled focusable row |
| Dialog | `dialog(key, [dialog_header([...]), body, dialog_footer([...])])` | a custom centered overlay card |
| Edge sheet | `sheet(key, SheetSide::Right, [sheet_header([...]), body])` | a modal manually pinned to the viewport edge |
| Dropdown / context menu *(action menu — items perform side-effects; for a value-bound picker see the next row)* | `dropdown_menu(key, trigger, [dropdown_menu_label(...), dropdown_menu_item_with_shortcut(...)])` | a popover full of hand-rolled rows; per-row `[Edit][Delete]` button pairs that should collapse into one trigger |
| Value picker (model, timezone, status, any enum field) | `select_trigger(key, &current_label)` + `select_menu(key, [(value, label), …])` paired via `select::apply_event(&mut value, &mut open, &event, key, parse)` | `dropdown_menu` with hand-rolled state, an `accordion`-based picker, or a hand-rolled popover full of `menu_item`s |
| Standard tooltip | `.tooltip("...")` on any element | a manually-positioned popover |
| Callout / validation summary | `alert([alert_title("Heads up"), alert_description("Details")]).warning()` | a manually styled card with status-colored text |
| Status indicator (Online, Pending, Failed) | `badge("Online").success()` (also `.warning()` / `.destructive()` / `.info()` / `.muted()`) | `text("● Online").text_color(SUCCESS)` |
| User identity chip | `avatar_fallback("Alicia Koch")` or `avatar_image(img)` | a bare image/text node with custom circle styling |
| Loading placeholder | `skeleton().width(Size::Fixed(220.0))` or `skeleton_circle(32.0)` | hard-coded muted rectangles |
| Section divider | `separator()` / `vertical_separator()` | hand-rolled 1px boxes |
| Command/menu row | `command_row("git-branch", "New branch", "Ctrl+B")` or `command_item([...])` | repeating icon-slot/label/shortcut rows by hand |
| Collapsible section | `accordion_item("settings", "security", "Security", open, [...])` + `accordion::apply_event(...)` | a button plus hand-managed chevron row |
| Breadcrumb path | `breadcrumb_list([breadcrumb_link("Projects"), breadcrumb_separator(), breadcrumb_page("Aetna")])` | a raw slash-delimited text string |
| Pagination | `pagination_content([pagination_previous(), pagination_link("1", true), pagination_next()])` | unaligned text buttons with custom square sizing |
| Section heading / page title | `.heading()` / `h2(...)` (or `.title()` / `h3(...)`) | `.font_size(16.0).font_weight(Bold).text_color(...)` |
| Field label | `.label()` | `.font_weight(Semibold).text_color(...)` |
| Helper / hint text | `.caption()` or `.muted()` | `.font_size(12.0).text_color(tokens::MUTED_FOREGROUND)` |
| Inline code / mono | `.code()` or `mono(...)` | `.font_family("monospace")` (no such API) |
| Selected row in a collection | `.selected()` chainable | `surface_role(SurfaceRole::Selected)` (works, but `.selected()` reads better and sets content color) |
| Current nav / page item | `.current()` chainable | `surface_role(SurfaceRole::Current)` |
| Resizable divider between two panes | `resize_handle(Axis::Row).key(...)` + `resize_handle::apply_event_fixed(...)` | `divider()` (which is non-interactive) plus drag plumbing |
| Indent inside a list (e.g. tree depth) | `.padding(Sides { left: indent, ..Sides::zero() })` | `row([spacer().width(Fixed(indent)), ...])` |
| Toggle (preferences) | `switch(self.value).key(k)` + `switch::apply_event(...)` | a button with two text labels |
| Labelled control row (settings, prefs) | `field_row("Label", control)` | hand-rolled `row([text("Label").label(), spacer(), control])` repeated everywhere |
| Stacked long field (URL, path, token, search) | `form_item([form_label("Repository URL"), form_control(text_input(...).width(Size::Fill(1.0))), form_description(...)])` inside `form([...])` | using `field_row` for long strings, or repeating `column([text(label).label(), text_input(...)])` |
| Date picker / month grid | `calendar_month("billing-date", "May 2026", days)` with `CalendarDay::new(value, label).selected() / .outside() / .disabled()` and `calendar::apply_event(&mut selected, &event, "billing-date")` | a hand-rolled grid of tiny buttons with ad hoc selected/outside-month styling |
| Raster image (logo, screenshot, thumbnail) | `image(Image::from_rgba8(...)).image_fit(ImageFit::Contain)` | reaching for a custom shader |
| Throwaway notification | accumulate `ToastSpec::success("Saved")` and return them from `App::drain_toasts` | spinning a manual modal with a timer |

## Smells that mean an affordance is being missed

One per line — if any of these appear in your tree, a named widget is the
right reach instead.

- `column(...).surface_role(SurfaceRole::Panel)` — use `card()` or `sidebar()`. `Panel` decorates, it doesn't fill.
- `column([row, body]).fill(CARD).stroke(BORDER)` reinventing the card silhouette — call `card([...])` (or `titled_card(...)`).
- `column(...).fill(CARD).stroke(BORDER).width(SIDEBAR_WIDTH)` reinventing the sidebar surface — call `sidebar([...])`.
- A keyed/focusable `row([column([t1,t2]), button, button])` used as a clickable file/repo/project/person/asset entry — use `item([item_media, item_content([item_title, item_description]), item_actions([...])])` so hover, press, focus, the rail, and the slots are named.
- Per-row `[Edit][Delete]` button pairs in a narrow list — collapse to one `dropdown_menu` trigger or a single icon-button kebab; let selection drive editing in the right pane.
- `card([card_content([toolbar(...)])])` for the top app header — a toolbar is chrome, not a boxed content object.
- A File / Edit / View strip built from separate `button(...)` or `dropdown_menu(...)` calls — use `menubar`, `menubar_trigger`, and `menubar_menu` so the root, triggers, dismiss keys, and menu-row anatomy stay named.
- `row([title, spacer(), action]).fill(MUTED).stroke(BORDER)` *header bar* sitting above a body inside a `card` — that's a hand-rolled `card_header`. Lift the row into `card_header([...]).fill(MUTED)`, or split the "header bar over body" block into its own `card([card_header(...), card_content(...)])`.
- A sidebar full of unrelated `card()` sections — use `sidebar_group`, `accordion_item`, or a local dense `tree_row` helper inside `sidebar`.
- A transcript rendered as one `card()` per message — use an event-log row with a narrow role gutter so long assistant output reads as a stream.
- `field_row` squeezing a repository URL, filesystem path, token, or search query into the right edge of a dialog — use stacked `form_item`.
- A date picker rendered as a manual 7-column row/column grid — use `calendar_month` so day sizing, outside-month dimming, selected state, and nav keys are canonical.
- `.gap(0.0)` — already the default; delete it.
- `.font_size(...).font_weight(...).text_color(...)` on the same node — use a role modifier (`.heading()` / `.label()` / `.caption()` / `.muted()`).
- Wrapping a single child in `row([single])` to apply `.padding(...)` — every `El` has `.padding()` directly.
- An explicit `.fill(tokens::BACKGROUND)` on the root — the host already paints it.
- `IconName::AlertCircle` as a placeholder when the project has its own SVG — use `SvgIcon::parse_current_color(include_str!("..."))` and pass it to `icon(...)`.

## Common app shells

When the catalog widget doesn't fit your data shape exactly, *wrap* it
rather than replace it. `card([...])` and `sidebar([...])` are
column-flavored containers that bundle the canonical fill + stroke +
radius + shadow + role recipe — you can put any composition inside.

A **two-pane workbench** (sidebar + main):

```ignore
row([
    sidebar([
        sidebar_header([h3("Repository")]),
        sidebar_group([
            sidebar_group_label("Branches"),
            sidebar_menu([/* sidebar_menu_button_with_icon(...) per branch */]),
        ]),
    ]),
    column([
        toolbar([toolbar_title("main"), spacer(), button("Push").primary().key("push")]),
        card([card_content([/* your view */])]).height(Size::Fill(1.0)),
    ])
    .width(Size::Fill(1.0))
    .height(Size::Fill(1.0)),
])
```

A **three-column workbench** (sidebar + center + inspector). Use `card()`
for the inspector pane the same way — it gives you the same recipe the
sidebar uses, just at `Size::Fixed(WIDTH)` instead of `SIDEBAR_WIDTH`.
Reach into `card_header` for selected-item identity (title, metadata,
copy / open actions) and `card_content` for the scrollable body. The
slots bake shadcn's stock recipe directly into their constructors —
`card_header` is `p-6` with a small `space-y-1.5` between title and
description; `card_content` and `card_footer` are `p-6 pt-0`. Naive
use produces the right visual without an explicit `.padding(...)`.
Override per-call, Tailwind-shaped: `.padding(SPACE_4)` to swap the
whole recipe (= `p-4`), or the additive shorthands `.pt(...)`,
`.pb(...)`, `.pl(...)`, `.pr(...)`, `.px(...)`, `.py(...)` to override
a single side or axis while preserving the constructor's defaults
elsewhere (= `p-6 pt-0`). The two compose, so a tighter card body that
keeps the no-double-pad seam is
`card_content([...]).padding(tokens::SPACE_3).pt(0.0)` (= `p-3 pt-0`)
— `.padding(SPACE_3)` alone would reset the bundled `pt-0` and leave a
visible doubled gap below the header. The override case below uses
`.padding(0.0)` on `card_content` because its only child is a
`scroll(...)` that should reach the card edges.

```ignore
row([
    sidebar([/* nav */]),
    column([/* center pane */]).width(Size::Fill(1.0)),
    card([
        card_header([
            row([h3(item.title.clone()), spacer(), button("Copy ID").ghost().key("copy")])
                .align(Align::Center)
                .gap(tokens::SPACE_2),
            text(item.subtitle.clone()).muted().caption(),
        ]),
        card_content([scroll([/* sub-cards, fields */])])
            .padding(0.0)
            .height(Size::Fill(1.0)),
    ])
    .width(Size::Fixed(320.0))
    .height(Size::Fill(1.0)),
])
```

A list of selectable objects inside a card (recent repos, project
imports, search results) — this is `item` + `item_group`, not a
column of hand-rolled rows:

```ignore
titled_card(
    "Recent repositories",
    [item_group([
        item([
            item_media_icon(IconName::Folder),
            item_content([
                item_title("aetna"),
                item_description("/home/christian/workspace/aetna"),
            ]),
            item_actions([badge("current").info()]),
        ])
        .key("recent:aetna")
        .current(),
        item([
            item_media_icon(IconName::Folder),
            item_content([
                item_title("whisper-git"),
                item_description("/home/christian/workspace/whisper-git"),
            ]),
            item_actions([icon(IconName::ChevronRight).muted()]),
        ])
        .key("recent:whisper"),
    ])],
)
```

A **tabbed page**:

```ignore
column([
    tabs_list("view", &self.tab, [("working", "Working"), ("history", "History")]),
    match self.tab.as_str() {
        "history" => history_view(self),
        _ => working_view(self),
    },
])
```

A **chat / event-log workbench**:

Keep the app shell boring: `sidebar` on the left, `toolbar` for selected
thread identity and actions, `scroll(log_rows)` for the transcript, and a
bottom composer row with `text_area` plus actions. The transcript itself is
not a card collection. Cards isolate objects; event logs should scan as one
continuous record with small role markers.

```ignore
fn log_row(role_color: Color, faint_fill: Option<Color>, content: El) -> El {
    let row = row([
        El::new(Kind::Custom("log_gutter"))
            .fill(role_color)
            .width(Size::Fixed(3.0))
            .height(Size::Fill(1.0)),
        content
            .padding(Sides {
                left: tokens::SPACE_3,
                right: tokens::SPACE_2,
                top: tokens::SPACE_2,
                bottom: tokens::SPACE_2,
            })
            .width(Size::Fill(1.0)),
    ])
    .width(Size::Fill(1.0));
    if let Some(fill) = faint_fill { row.fill(fill) } else { row }
}

column([
    toolbar([toolbar_title(thread.title.clone()), spacer(), badge(thread.state_label)]),
    scroll(thread.items.iter().map(|item| match item {
        // `paragraph` for plain user input; `md(...)` when the source
        // is markdown (assistant streams, tool output prose).
        ChatItem::User(text) => log_row(tokens::INFO, Some(tokens::INFO.with_alpha(38)), paragraph(text)),
        ChatItem::Assistant(text) => log_row(tokens::SUCCESS, None, md(text)),
        ChatItem::Reasoning { id, open, preview, body } => log_row(
            tokens::MUTED_FOREGROUND,
            None,
            accordion_item("reasoning", id, preview, *open, [md(body)]),
        ),
        ChatItem::Tool(call) => log_row(
            tokens::WARNING,
            None,
            accordion_item("tool", call.id, call.summary, call.open, [code_block(call.details)]),
        ),
    }))
    .key(format!("thread-scroll:{}", thread.id))
    .padding(tokens::SPACE_4)
    .gap(tokens::SPACE_2)
    .height(Size::Fill(1.0)),
    row([
        text_area(&self.compose, &self.selection, "compose").height(Size::Fixed(120.0)),
        button("Send").primary().key("send"),
    ])
    .gap(tokens::SPACE_3)
    .padding(tokens::SPACE_3)
    .align(Align::End),
])
```

When a `text_area` is fixed-height like the composer above, the app
should queue `text_area::caret_scroll_request_for(...)` from
`App::drain_scroll_requests` after `text_area::apply_event(...)`
returns `true`. That lets PageUp/PageDown, arrows, paste, and typing
keep the caret visible without emitting a scroll request every frame.

If `sidebar_menu_button_with_icon` doesn't fit your row anatomy (count
badges, nested sub-groups, custom leading icons), keep the outer
`sidebar([...])` for the panel surface and compose the rows freely
inside. Same for `card_content` — anything column-shaped goes there.

## App trait scaffolding

Once the shell is in place, the `App` trait wires it to the runtime.
`build` returns the `El` tree; `on_event` handles routed events keyed by
the same string passed to `.key("...")` — same identifier, no separate
`.on_click(...)` registration. Hover, press, and focus visuals are
applied automatically; the author never tags a node "this one is
hovered."

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

struct Counter {
    value: i32,
}

impl App for Counter {
    fn build(&self, _cx: &BuildCx) -> El {
        column([
            h1(format!("{}", self.value)),
            row([
                button("-").key("dec"),
                button("+").key("inc").primary(),
            ])
            .gap(tokens::SPACE_2),
        ])
        .gap(tokens::SPACE_3)
        .padding(tokens::SPACE_4)
    }

    fn on_event(&mut self, event: UiEvent) {
        if event.is_click_or_activate("inc") {
            self.value += 1;
        } else if event.is_click_or_activate("dec") {
            self.value -= 1;
        }
    }
}
```

This is a deliberately tiny example — `column`/`row`/`button` is fine
for a counter, but for a real app start from the workbench skeletons
above. Use `aetna-winit-wgpu` to open a native desktop window. Use
`aetna-wgpu` directly only when writing a custom host or embedding
Aetna in an existing render loop. If the UI mirrors external state,
refresh it in `App::before_build` — hosts call that hook immediately
before each `build`.

## Surface roles, briefly

`SurfaceRole` is a *decoration* layer, not a fill recipe. `Panel` and
`Raised` set stroke and shadow only — they assume you (or the widget
wrapping you) supplied a fill. `Sunken` / `Selected` / `Current` /
`Input` / `Danger` *do* default a fill from the palette. Per-variant
contracts live on the `SurfaceRole` enum's rustdoc; reach for the
`.selected()` / `.current()` chainables for state, and for `card()` /
`sidebar()` / `dialog()` / `popover()` for surface containers.

## El modifier reference

Every modifier below is a chainable method on `El`. They compose freely
and order rarely matters. Grouped by what you're saying. For the full
widget surface (constructors like `card`, `tabs_list`, `dropdown_menu`,
…), see the "Reach for these first" catalog above and the
`aetna_core::prelude` re-exports.

### Sizing

| Modifier | Meaning |
|---|---|
| `.width(Size)` / `.height(Size)` | Set axis size. `Size::Fixed(n)`, `Size::Hug`, `Size::Fill(weight)`. |
| `.fill_width()` / `.fill_height()` | Shorthand for `Size::Fill(1.0)` on that axis. CSS `width: 100%` / `height: 100%`. |
| `.fill_size()` | Both axes fill. |
| `.hug()` | Both axes hug content. |
| `.min_width(n)` / `.max_width(n)` / `.min_height(n)` / `.max_height(n)` | Bound a resolved axis. Compose with any `Size`. |
| `.size(ComponentSize)` / `.medium()` / `.large()` | T-shirt size for stock controls. |

### Padding

| Modifier | Meaning |
|---|---|
| `.padding(Sides or f32)` | Set all sides (wholesale, like CSS `padding`). |
| `.pt(n)` / `.pb(n)` / `.pl(n)` / `.pr(n)` | Override one side. Mirrors Tailwind `pt-N` etc., and preserves the constructor's other sides. |
| `.px(n)` / `.py(n)` | Override one axis. Mirrors Tailwind `px-N` / `py-N`. |

### Layout (container nodes)

| Modifier | Meaning |
|---|---|
| `.gap(n)` | Inter-child spacing. |
| `.axis(Axis)` | Override flow direction. |
| `.align(Align)` | Cross-axis alignment. |
| `.justify(Justify)` | Main-axis distribution. |
| `.clip()` | Clip overflow to this node's rect. |
| `.scrollable()` | Make this node a scroll viewport. |
| `.scrollbar()` / `.no_scrollbar()` | Show or suppress the draggable thumb. |
| `.pin_end()` | Stick offset to the tail (chat/log behaviour). |
| `.arrow_nav_siblings()` | Treat focusable children as one arrow-navigable group. |
| `.virtual_anchor_policy(p)` | Override virtual-list anchor point. |
| `.layout(\|cx\| -> Vec<Rect>)` | Replace child distribution with a custom function. |
| `.child(c)` / `.children(iter)` | Append children. |

### Text content

| Modifier | Meaning |
|---|---|
| `.text(s)` | Set the text content. |
| `.text_color(c)` / `.color(c)` | Color the run. |
| `.text_align(a)` / `.center_text()` / `.end_text()` | Block alignment. |
| `.text_wrap(w)` / `.wrap_text()` / `.nowrap_text()` | Wrap mode. |
| `.text_overflow(o)` / `.ellipsis()` / `.max_lines(n)` | Truncation. |
| `.font_size(n)` / `.line_height(n)` | Type metrics. |
| `.font_weight(w)` / `.bold()` / `.semibold()` | Weight. |
| `.font_family(f)` / `.inter()` / `.roboto()` | Sans face. |
| `.mono_font_family(f)` / `.jetbrains_mono()` / `.mono()` | Mono face. |
| `.italic()` / `.underline()` / `.strikethrough()` | Inline style. |
| `.background(c)` | Inline-run highlight (HTML `<mark>`). |
| `.code()` | Markdown-flavoured inline code. |
| `.link(url)` | Link run. |

### Text role presets

| Modifier | Role |
|---|---|
| `.display()` / `.heading()` / `.title()` | h1 / h2 / h3 sizes. The `h1` / `h2` / `h3` constructors are shorthands for these on a fresh text node. |
| `.body()` | Default running text. |
| `.label()` | Field labels. |
| `.caption()` | Hint / helper text. |
| `.text_role(TextRole)` | Set the role directly when none of the above presets fit. |
| `.small()` / `.xsmall()` | Step the text role size down. |
| `.muted()` | Subdued foreground over any role (color only). |

### Variants & states (theme-aware)

| Modifier | Meaning |
|---|---|
| `.primary()` / `.secondary()` / `.ghost()` / `.outline()` | Button/badge variant. |
| `.success()` / `.warning()` / `.destructive()` / `.info()` | Status color. |
| `.selected()` / `.current()` | Active row in a collection / current nav item. |
| `.disabled()` / `.invalid()` / `.loading()` | Interactive state. |

### Visual decoration

| Modifier | Meaning |
|---|---|
| `.fill(Color)` | Solid background fill. |
| `.dim_fill(Color)` | Fill that envelopes from 0 → 1 with hover/press. |
| `.stroke(Color)` / `.stroke_width(n)` | Border. |
| `.radius(Corners or f32)` | Rounded corners. |
| `.shadow(n)` | Drop shadow. |
| `.surface_role(SurfaceRole)` | Token-driven decoration (Panel, Raised, Sunken, …). |
| `.paint_overflow(Sides)` | Allow paint to extend beyond the layout rect. |
| `.focus_ring_inside()` / `.focus_ring_outside()` | Pin focus ring position. |
| `.tooltip(s)` | Standard tooltip text. |
| `.cursor(Cursor)` / `.cursor_pressed(Cursor)` | Hover cursor / press-time override. |

### Icon / image / vector / surface

| Modifier | Meaning |
|---|---|
| `.icon_source(src)` / `.icon_name(src)` | Built-in `IconName` or an `SvgIcon`. |
| `.icon_size(n)` / `.icon_stroke_width(n)` | Icon sizing. |
| `.image(img)` / `.image_fit(f)` / `.image_tint(c)` | Raster source. |
| `.vector_source(a)` / `.vector_painted()` / `.vector_mask(c)` / `.vector_render_mode(m)` | Vector source and paint mode. |
| `.surface_source(s)` / `.surface_alpha(a)` / `.surface_fit(f)` / `.surface_transform(t)` | App-owned GPU texture. |

### Animation, transform, custom paint

| Modifier | Meaning |
|---|---|
| `.opacity(v)` / `.translate(x, y)` / `.scale(v)` | Per-frame transform. |
| `.animate(Timing)` | Per-(node, prop) tween/spring. |
| `.redraw_within(Duration)` | Ask the host to drive a frame within deadline. |
| `.shader(ShaderBinding)` | Custom shader. |

### Identity & interaction

| Modifier | Meaning |
|---|---|
| `.key(s)` | Stable id for event routing, hit-test, and state survival across rebuilds. |
| `.focusable()` / `.always_show_focus_ring()` | Tab participation. |
| `.selectable()` | Static-text selection opt-in. |
| `.capture_keys()` | Route raw key events here while focused. |
| `.block_pointer()` | Stop pointer hits at this node. |
| `.hit_overflow(Sides)` | Enlarge the hit area beyond the rect. |
| `.consumes_touch_drag()` | Claim drag from a touch panner. |
| `.alpha_follows_focused_ancestor()` / `.blink_when_focused()` / `.state_follows_interactive_ancestor()` | Inherit interaction visuals from a focused/interactive ancestor. |
| `.hover_alpha(rest, peak)` | Custom hover envelope. |
| `.metrics_role(MetricsRole)` | Theme-facing metrics role. |
| `.selection_source(src)` | Override selection identity. |
| `.at(file, line)` / `.at_loc(loc)` | Override source-location identity for `#[track_caller]`-aware widgets. |

### Math

| Modifier | Meaning |
|---|---|
| `.math_expr(expr)` / `.math_display(d)` | Lay out a `MathExpr` (MathML or TeX-parsed). |

## API layers

- `prelude` — the app and widget author surface; what an LLM should
  usually import.
- `widgets` — controlled widget builders and their `apply_event` /
  `apply_input` helpers (e.g. `text_input::apply_event`,
  `slider::normalized_from_event`).
- `bundle` — headless artifacts (`tree.txt` / `draw_ops.txt` / `lint.txt`
  / `.svg`) for tests and design review. The lint pass catches raw
  colors, text overflow, alignment misses, missing surface fills, and
  duplicate ids.
- `ir`, `paint`, `runtime`, text atlas, vector mesh, and MSDF modules
  are advanced backend / diagnostic surfaces. Public because sibling
  backend crates use them; ordinary app code should not start there.

The crate ships runnable examples under `examples/`: `settings`,
`scroll_list`, `virtual_list`, `inline_runs`, `modal`, `custom_shader`,
`circular_layout`, plus the `dashboard_01_calibration` reference fixture
that mirrors the shadcn dashboard-01 demo through stock widgets.