<!-- GENERATED by `bun run catalog` from packages/ui/src. Do not edit by hand. -->

# @ultimat3/ui catalog

Every component and every token, projected from source. Import all of it from `@ultimat3/ui`.

68 components: `Accordion` · `Alert` · `AppShell` · `AsyncRegion` · `Avatar` · `Badge` · `BarChart` · `Breadcrumb` · `Button` · `Card` · `ChartFrame` · `Checkbox` · `Combobox` · `CommandPalette` · `Container` · `CopyButton` · `DataTable` · `DateTime` · `Dialog` · `Divider` · `DonutChart` · `Drawer` · `Dropzone` · `EmptyState` · `ErrorState` · `Field` · `FileInput` · `Form` · `Gauge` · `Grid` · `Icon` · `IconButton` · `Image` · `InfiniteScroll` · `InlineBar` · `Input` · `Kbd` · `LineChart` · `AreaChart` · `Link` · `LocaleSwitcher` · `Menu` · `Meter` · `Money` · `PageHeader` · `Pagination` · `Popover` · `QrCode` · `Radio` · `RelativeTime` · `Section` · `Select` · `Skeleton` · `Sparkline` · `Spinner` · `Stack` · `StatTile` · `Switch` · `Table` · `Tabs` · `Text` · `Textarea` · `ThemeToggle` · `ToastRegion` · `Toast` · `Toaster` · `Toolbar` · `Tooltip`

## Vocabulary

One size scale, one tone scale, one variant scale — shared by every component that has them.

| Scale | Values |
|---|---|
| `Size` | `sm` · `md` · `lg` |
| `Tone` | `neutral` · `accent` · `success` · `warning` · `danger` · `info` |
| `ButtonVariant` | `primary` · `secondary` · `ghost` · `link` |
| `SpaceStep` | `0` · `1` · `2` · `3` · `4` · `5` · `6` · `8` · `10` · `12` · `16` |

## Components

### Accordion

Expandable sections built on `<details>`/`<summary>`: open/close, keyboard, focus and `aria-expanded` are the browser's, so the component works with JavaScript disabled and there is no open-state to hydrate. `exclusive` is the native `name` group, not a listener.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `items` | `readonly AccordionItem[]` | yes |  |
| `exclusive` | `boolean` | — | One section open at a time, through the native `<details name>` group. |
| `level` | `HeadingLevel` | — | Wraps each title in a heading of this level, putting the sections in the page outline. Left out, a title is plain text inside the disclosure button — which is what a screen reader announces either way. |
| `onToggle` | `((id: string, open: boolean) => void)` | — | Fires on every open and close, once the browser has already applied it. |
| `class` | `string` | — |  |

### Alert

Inline message bound to the page, not the viewport. `danger`/`warning` use `role="alert"` (assertive); the rest use `role="status"`, so an informational banner never interrupts what a screen-reader user is doing.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `title` | `string` | — | Already-translated heading. |
| `children` | `JSX.Element` | yes |  |
| `tone` | `Tone` | — |  |
| `icon` | `JSX.Element` | — |  |
| `onDismiss` | `(() => void)` | — | Provide both to make the alert dismissible; the label is the a11y name. |
| `dismissLabel` | `string` | — |  |
| `class` | `string` | — |  |

### AppShell

The page frame every app screen sits in: skip link, banner, navigation, main, contentinfo. Stateless, and scriptless: below `md` the sidebar `<nav>` is a native `popover` panel at the inline-start edge, opened by a `popovertarget` menu button — the browser holds the open state, Esc and light dismiss, so the frame on every page costs no island. Not `Drawer`: that is a modal `<dialog>` opened from an effect, so it needs a hydrated island, and a `<dialog>` cannot also be the in-flow `navigation` landmark at `md` and up. An engine with no popovers keeps the old band.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes | The page. Rendered inside the one `<main>`, which is the skip link's target. |
| `header` | `JSX.Element` | — |  |
| `sidebar` | `JSX.Element` | — | Rendered inside a `<nav>` landmark: the inline-start column at `md` and up, a panel below. |
| `footer` | `JSX.Element` | — |  |
| `sidebarLabel` | `string` | — | Accessible name for the sidebar landmark. Defaults to the translated `ui.navigation`. |
| `skipLabel` | `string` | — | Skip-link text. Defaults to the translated `ui.skip`. |
| `menuLabel` | `string` | — | The menu button's text below `md`. Defaults to the translated `ui.menu`. |
| `sidebarWidth` | `string` | — | Sidebar track width at `md` and up, and the panel's width below. Any CSS length. |
| `stickyHeader` | `boolean` | — | Keeps the header pinned while the main region scrolls. |
| `class` | `string` | — |  |

### AsyncRegion

The one way to render a region whose content has to arrive. Four branches, decided by `asyncBranch` and never by the caller: a skeleton while it loads, the error report when it fails, the empty state when a completed result held nothing, and the content.  `empty` and `ready` are REQUIRED props, so a region that forgot what "nothing here" looks like is a type error rather than a review comment. `pending` and `failed` are not props at all — the placeholder is derived from `reserve` so it cannot mismatch the loaded box, and the failure renders through `ErrorState`, which is the one thing allowed to phrase an error.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `state` | `AsyncState<T>` | yes | A live-query accessor, a resource, or a plain signal — narrowed to one of four shapes. |
| `reserve` | `ReserveBox` | yes | The box the placeholder holds, and the box the loaded content lands in. Same value, once. |
| `ready` | `(data: T) => JSX.Element` | yes | The content. Called only with data a completed result actually carried. |
| `empty` | `() => JSX.Element` | yes | Required with no default: "nothing here yet" is a screen, not an oversight, and `EmptyState` is one line of it — `empty={() => <EmptyState title={t('posts.none')} />}`. |
| `isEmpty` | `((data: T) => boolean)` | — | Emptiness for a shape that is not a list. Defaults to `isEmptyData`. |
| `onRetry` | `(() => void)` | — | Passed straight to `ErrorState` on the failed branch. |
| `class` | `string` | — |  |

### Avatar

Identity chip. An avatar image always carries intrinsic dimensions and an empty alt (the name is rendered as text or the accessible label), so it can never shift layout or duplicate the name to a screen reader.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `name` | `string` | yes | Already-localised display name. Drives initials and the accessible name. |
| `src` | `string` | — |  |
| `size` | `Size \| 'xs' \| 'xl'` | — |  |
| `shape` | `'circle' \| 'rounded'` | — |  |
| `status` | `'online' \| 'busy' \| 'offline'` | — | Presence ring; the meaning must also be conveyed in text nearby. |
| `class` | `string` | — |  |

### Badge

Small status label. Tone maps straight onto the status colour roles so the same component is legible on both themes with no per-theme override.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes |  |
| `tone` | `Tone` | — |  |
| `size` | `Size` | — |  |
| `variant` | `'soft' \| 'solid' \| 'outline'` | — |  |
| `dot` | `boolean` | — | Renders a leading dot; pair with a tone that carries the meaning. |
| `class` | `string` | — |  |

### BarChart

A bar chart of one series — clicks per day, signups per week — or two stacked (done, and failed on top): SVG bars, HTML axis labels. No charting library: the framework's own docs use a sparkline pulling one in as the cautionary example, and a route's JS budget counts raw minified bytes. `<rect>` bars cost nothing to hydrate, so the server-rendered shell IS the chart. The labels sit outside the svg because text inside a viewBox scales with it — a speck at phone width. It stands in a `ChartFrame`: a legend for two series, and a hidden table of the same numbers.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | The accessible name for the whole chart, already translated. |
| `points` | `readonly ChartPoint[]` | yes | Oldest first. Every bar comes from exactly this array — the caller zero-fills gaps. |
| `highlightLast` | `boolean` | — | Draw the last bar at full strength — the eye lands where the live number is. Default on. |
| `seriesLabels` | `{ readonly primary: string; readonly secondary: string }` | — | Names of the two series, already translated, read by each rect's `<title>`, the legend and the data table. Give them when a point carries `secondary`: a stacked bar with no name for its top is two numbers and no key. |
| `keyLabel` | `string` | — | The key column's head in the data table, already translated — "Day". |
| `format` | `((value: number) => string)` | — | Formats every value. Default: `Intl.NumberFormat` in the page's locale. |
| `focusable` | `boolean` | — | Give every bar a tab stop and a hover/focus readout. Default off: no tab stops. |
| `showCaption` | `boolean` | — | Show the caption above the plot. Default off — it names the figure either way. |
| `class` | `string` | — | Lands on the root `<figure>`, which holds the svg and its HTML axis labels. |

### Breadcrumb

Ancestor trail. The last item is the current page: rendered as text, never a link, and marked `aria-current="page"`. Separators are decorative CSS.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `items` | `readonly BreadcrumbItem[]` | yes |  |
| `label` | `string` | — | Overrides the translated `ui.breadcrumb` landmark name. |
| `class` | `string` | — |  |

### Button

The one button. Variants and tones are token-driven, so dark mode and RTL need no extra rules; `loading` keeps the label mounted to avoid a layout jump.  `loading` does NOT set the native `disabled` attribute, and that WILL read as a mistake — it set one until 2026-09. Four things go wrong when a control disables itself mid-flow: the browser moves focus off it to `<body>`, so a keyboard user's next Tab restarts at the top of the document; a disabled control is exempt from WCAG's contrast minimum, so the state the user most needs to read is the one allowed to be unreadable; it explains nothing, because `disabled` has no announced reason; and it does not actually prevent the double submit, which is a race on the server. `aria-disabled` says unavailable and keeps the control focusable, the click is refused here, and the form refuses it again — `Form busy` — because the button is not the only way in.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes | Visible label. Never hardcoded inside the component. |
| `variant` | `ButtonVariant` | — |  |
| `tone` | `Tone` | — |  |
| `size` | `Size` | — |  |
| `type` | `'button' \| 'submit' \| 'reset'` | — |  |
| `disabled` | `boolean` | — |  |
| `loading` | `boolean` | — | Blocks interaction and announces progress via `aria-busy`. |
| `fullWidth` | `boolean` | — |  |
| `iconStart` | `JSX.Element` | — |  |
| `iconEnd` | `JSX.Element` | — |  |
| `id` | `string` | — |  |
| `class` | `string` | — |  |
| `aria-label` | `string` | — |  |
| `aria-controls` | `string` | — |  |
| `aria-expanded` | `boolean` | — |  |
| `onClick` | `JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>` | — |  |

### Card

Surface container. Elevation is a token rung, and because the shadow tokens are themed the same `elevation` reads correctly on light and dark.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes |  |
| `header` | `JSX.Element` | — |  |
| `footer` | `JSX.Element` | — |  |
| `elevation` | `Elevation` | — |  |
| `padding` | `SpaceStep` | — |  |
| `interactive` | `boolean` | — | Adds hover affordance. Only use when the whole card is a link/button. |
| `as` | `'div' \| 'article' \| 'section' \| 'li'` | — |  |
| `class` | `string` | — |  |

### ChartFrame

The frame every chart stands in: a `<figure>` named by its `<figcaption>`, the plot, a legend that wraps under the plot on a narrow container and stands beside it on a wide one, and a visually-hidden `<table>` of the same numbers — what a screen reader and a reader who cannot tell the colours apart get instead of the picture. Server-rendered HTML: no island, no JS.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | The chart's name, already translated: the `<figcaption>`, and the data table's caption. |
| `children` | `JSX.Element` | yes | The plot — the chart's own svg and HTML axis labels. |
| `showCaption` | `boolean` | — | Show the caption above the plot. Off by default: a chart usually sits under a Card's own heading, and the caption names the figure for assistive tech either way. |
| `legend` | `readonly ChartLegendItem[]` | — | One entry per series. Omitted or empty renders no legend. |
| `table` | `ChartTable` | — | The same data as rows, rendered visually hidden (`chartTable`). |
| `class` | `string` | — | Lands on the root `<figure>`. |

### Checkbox

Native checkbox with a token-drawn indicator. The label element wraps the input, so the whole row is a hit target without a manual `for`/`id` pairing.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | Already-translated label. Required — an unlabelled checkbox is a bug. |
| `id` | `string` | — |  |
| `name` | `string` | — |  |
| `value` | `string` | — |  |
| `form` | `string` | — | The id of the `<form>` this box submits with, when it cannot sit inside it — a row checkbox in a table whose page already holds another form. The platform's own association, no script. |
| `checked` | `boolean` | — |  |
| `indeterminate` | `boolean` | — | Tri-state for "some children selected". The ONLY thing mirrored to `aria-checked`. |
| `disabled` | `boolean` | — |  |
| `required` | `boolean` | — |  |
| `description` | `string` | — |  |
| `class` | `string` | — |  |
| `aria-describedby` | `string` | — |  |
| `aria-invalid` | `boolean` | — |  |
| `onChange` | `JSX.EventHandlerUnion<HTMLInputElement, Event>` | — |  |

### Combobox

A searchable text field with suggestions: one `<input list>` plus a `<datalist>`, which is a real combobox in every engine — typing, filtering, keyboard and mobile are the platform's, and the field still works with scripting off. `onFilter` is the debounced enhancement on top.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `options` | `readonly ComboboxOption[]` | yes |  |
| `value` | `string` | — | The current query. Suggestions are filtered to it before they are rendered. |
| `onFilter` | `((query: string) => void)` | — | Fires `debounceMs` after typing stops, and immediately when a suggestion is picked. Left out, the field is a plain filtered datalist — still correct, just not live. |
| `debounceMs` | `number` | — | Debounce window in ms. Read once, when the field mounts. |
| `limit` | `number` | — | Cap on rendered suggestions. |
| `id` | `string` | — |  |
| `name` | `string` | — |  |
| `placeholder` | `string` | — | Already-translated placeholder. |
| `size` | `Size` | — |  |
| `required` | `boolean` | — |  |
| `disabled` | `boolean` | — |  |
| `class` | `string` | — |  |
| `aria-label` | `string` | — |  |
| `aria-describedby` | `string` | — |  |
| `aria-invalid` | `boolean` | — |  |

### CommandPalette

The ⌘K palette: a filter field over a list of commands and destinations, in a NON-modal `<dialog>` driven by its `open` attribute — never `showModal()`, so a test harness with no dialog methods and a server with no DOM both render it the same way. Controlled and presentational: the open flag, the query and the active item all live in the caller, and the rules (`filterItems`, `stepActive`, `keyAction`) live in `command-palette-view.ts` so the caller does not rewrite them. Renders closed on the server unless told otherwise; with scripting off it is inert markup.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `open` | `boolean` | yes |  |
| `labels` | `CommandPaletteLabels` | yes |  |
| `query` | `string` | yes |  |
| `items` | `readonly CommandPaletteItem[]` | yes | Already filtered — `filterItems(all, query)` is the caller's one line. |
| `activeId` | `string` | — | The roving selection; `settleActive`/`stepActive` keep it inside `items`. |
| `onQueryInput` | `(value: string) => void` | yes |  |
| `onFilterKeyDown` | `(event: KeyboardEvent) => void` | yes | The raw key event; `keyAction(event.key)` says what it means. |
| `onHoverItem` | `(id: string) => void` | yes |  |
| `onRunItem` | `(id: string) => void` | yes |  |
| `onClose` | `() => void` | yes |  |
| `id` | `string` | — | The element's id, so a trigger can name it with `aria-controls`. |
| `filterRef` | `((el: HTMLInputElement) => void)` | — | Refs out, so a caller can read focus or the attribute behind its own capability guard. |
| `dialogRef` | `((el: HTMLDialogElement) => void)` | — |  |
| `class` | `string` | — |  |

### Container

Centred measure with a gutter. `margin-inline: auto` and `min()` mean one declaration covers every viewport and both writing directions.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes |  |
| `size` | `ContainerSize` | — |  |
| `gutter` | `SpaceStep` | — |  |
| `as` | `'div' \| 'main' \| 'section' \| 'article' \| 'header' \| 'footer'` | — |  |
| `class` | `string` | — |  |

### CopyButton

Copies one string to the clipboard and says so — the control beside a short URL, an id, a command. Server-rendered as a real button; the clipboard write and the 1.6 s "copied" state are additive client behaviour, so with scripting off the button is there and does nothing, which is what a clipboard needs a script for anyway.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `value` | `string` | yes | What lands on the clipboard. |
| `label` | `string` | — | The accessible name; defaults to the catalog's `ui.copy`. |
| `copiedLabel` | `string` | — | Announced to assistive tech once the write succeeds; defaults to `ui.copied`. |
| `size` | `Size` | — |  |
| `class` | `string` | — |  |

### DataTable

Data-driven table: sortable headers, cursor pagination, and the four states a real list always has (loading, error, empty, data). The error state renders an UltimateError with the same code/cause/fix strings the terminal prints.  The four-way decision itself is NOT here — it is `asyncBranch`, shared with `AsyncRegion`, so a table and a card list cannot disagree about what "loading with stale rows" looks like. Only the PLACEHOLDER is local, because a table's is table-shaped: rows of cells, not lines of text.  A narrow table is the SAME table restyled as labelled cards (`DataTable.module.scss`), never a second copy: a duplicate is read twice or, hidden, is links the reader is told are not there.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `caption` | `string` | yes |  |
| `columns` | `readonly Column<Row>[]` | yes |  |
| `rows` | `readonly Row[]` | yes |  |
| `rowKey` | `(row: Row) => string` | yes |  |
| `sort` | `SortState` | — |  |
| `loading` | `boolean` | — |  |
| `error` | `unknown` | — | An UltimateError (or anything shaped like one) from the query. |
| `onRetry` | `(() => void)` | — |  |
| `emptyTitle` | `string` | — |  |
| `emptyDescription` | `string` | — |  |
| `nextCursor` | `string` | — | Opaque cursors from the query result; absent means no further page. |
| `prevCursor` | `string` | — |  |
| `stickyHeader` | `boolean` | — |  |
| `stickyFirstColumn` | `boolean` | — | Pins the first column while the table scrolls sideways. |
| `narrow` | `DataTableNarrow` | — | What a table narrower than `sm` becomes. Default `cards`. |
| `density` | `'comfortable' \| 'compact'` | — |  |
| `skeletonRows` | `number` | — | Placeholder row count while loading. Match the usual page size. |
| `class` | `string` | — |  |
| `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | Callback paging. Never beside `hrefFor` — the type refuses the pair. |
| `onSortChange` | `((sort: SortState \| undefined) => void)` | — | Callback sorting. Never beside `sortHrefFor` — the type refuses the pair. |
| `hrefFor` | `(cursor: string, direction: 'next' \| 'prev') => string` | — | Link paging: the URL of the page a cursor names, handed to `<Pagination hrefFor>`. |
| `sortHrefFor` | `((sort: SortState \| undefined) => string)` | — | Link sorting: the URL of the list under the NEXT sort state of a header's cycle (`undefined` is "unsorted"). Absent, a sortable header is plain text — never a button with no handler. |

### DateTime

Renders <time datetime="<ISO instant>"> with text formatted in the context time zone. The attribute is always the UTC instant so crawlers, tests, and other machines read the same value regardless of the viewer's zone.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `value` | `TimeInput` | yes |  |
| `timeZone` | `TimeZone` | — | Overrides the context zone. Use for "in the venue's local time" displays. |
| `locale` | `string` | — |  |
| `dateStyle` | `DateStyle` | — |  |
| `timeStyle` | `DateStyle` | — |  |
| `format` | `DateTimeFormatter` | — |  |
| `class` | `string` | — |  |

### Dialog

Modal built on native <dialog>: the platform gives us the top layer, the backdrop, inert background content, and Escape-to-close for free. We add the labelled heading and the close affordance. Body scroll locking is one CSS rule in `tokens/reset.scss` (`html:has(dialog:modal)`) rather than anything here, so it covers Drawer too and cannot leak when a close path throws.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `open` | `boolean` | yes |  |
| `title` | `string` | yes | Already-translated title. Becomes the dialog's accessible name. |
| `children` | `JSX.Element` | yes |  |
| `footer` | `JSX.Element` | — |  |
| `onClose` | `() => void` | yes |  |
| `size` | `'sm' \| 'md' \| 'lg' \| 'full'` | — |  |
| `dismissOnBackdrop` | `boolean` | — | Clicking the backdrop closes. Turn off for destructive confirmations. |
| `class` | `string` | — |  |

### Divider

A rule. Always an explicit `role="separator"` with an orientation; pass `label` to render a captioned separator, which is what a settings group actually needs.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `orientation` | `'horizontal' \| 'vertical'` | — |  |
| `label` | `string` | — | Visible, already-translated caption rendered inside the rule. |
| `class` | `string` | — |  |

### DonutChart

Parts of one whole — traffic by device, runs by outcome — as a ring of segments with the total (or the caller's figure) in its centre, a legend giving each part's value and share, and a hidden data table. Each segment carries its series' marker shape and a gap from its neighbours, so the ring reads in greyscale. SVG geometry from `donut-chart-view.ts`; text is HTML.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | The chart's name, already translated: the figure's caption and the svg's accessible name. |
| `segments` | `readonly DonutSegment[]` | yes | Clockwise from twelve o'clock, in the order the legend lists them. |
| `centreValue` | `string` | — | The figure in the ring's centre, already formatted. Default: the formatted total. |
| `centreLabel` | `string` | — | A short line under the centre figure, already translated — "Total", "Sessions". |
| `keyLabel` | `string` | — | The segment column's head in the data table, already translated — "Device". |
| `valueLabel` | `string` | — | The value column's head in the data table, already translated. Default: `label`. |
| `format` | `((value: number) => string)` | — | Formats every value. Default: `Intl.NumberFormat` in the page's locale. |
| `focusable` | `boolean` | — | Give every drawn segment a tab stop and a hover/focus readout. Default off. |
| `showCaption` | `boolean` | — | Show the caption above the chart. Default off — it names the figure either way. |
| `class` | `string` | — | Lands on the root `<figure>`. |

### Drawer

Edge-anchored panel, also a native <dialog> so it inherits the top layer and inert background. `side` is logical (`inline-start`/`inline-end`), so a drawer pinned to the start edge lands on the right in an RTL locale automatically.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `open` | `boolean` | yes |  |
| `title` | `string` | yes | Already-translated title. Becomes the drawer's accessible name. |
| `children` | `JSX.Element` | yes |  |
| `onClose` | `() => void` | yes |  |
| `side` | `DrawerSide` | — |  |
| `size` | `string` | — |  |
| `footer` | `JSX.Element` | — |  |
| `class` | `string` | — |  |

### Dropzone

A drop surface that is a real `<label>` around a real, visually-hidden `<input type="file">`. That shape is what makes it usable without a mouse: click, Enter and Space already open the picker, focus already lands somewhere, and the accessible name is the instruction the label renders. A `<div onClick>` with `tabindex` would reimplement all four and get one of them wrong. Dragging is the enhancement on top, never the only way in.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | Already-translated instruction. Required — it is the control's accessible name. |
| `hint` | `string` | — | Already-translated secondary line: the accepted types, the size ceiling. |
| `id` | `string` | — |  |
| `name` | `string` | — |  |
| `accept` | `string` | — | The `accept` attribute, and the same list a dropped file is partitioned against. |
| `multiple` | `boolean` | — | Without it the zone takes ONE file, dropped or picked — the rest are refused by `count`. |
| `required` | `boolean` | — |  |
| `disabled` | `boolean` | — |  |
| `maxBytes` | `number` | — | Client-side ceiling. The server enforces its own; this only spares a doomed transfer. |
| `maxFiles` | `number` | — | Read only with `multiple`: a single-file zone's ceiling is one. |
| `progress` | `number` | — | 0..1 while an upload runs. Absent renders no bar at all — never one stuck at zero. |
| `progressLabel` | `string` | — | Already-translated accessible name for the progress bar. |
| `class` | `string` | — |  |
| `aria-describedby` | `string` | — |  |
| `aria-invalid` | `boolean` | — |  |
| `onSelect` | `(selection: FileSelection<File>) => void` | yes | Every file offered, partitioned into accepted and refused-with-a-reason. |

### EmptyState

The "nothing here yet" surface. Title and description arrive as already translated strings; the action slot takes a Button so there is one CTA shape.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `title` | `string` | — | Already-translated. Falls back to the `ui.empty` catalog key. |
| `description` | `string` | — |  |
| `icon` | `JSX.Element` | — |  |
| `action` | `JSX.Element` | — |  |
| `class` | `string` | — |  |

### ErrorState

Renders an UltimateError with the same three strings the terminal prints: code, cause, fix. Identical text in the CLI, the overlay, and `--json` is the whole point of the error contract — this component must not paraphrase.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `error` | `unknown` | yes | An UltimateError, or any thrown value; unknown values get X_INTERNAL text. |
| `onRetry` | `(() => void)` | — |  |
| `retryLabel` | `string` | — | Already-translated; falls back to the ui.* catalog keys. |
| `showDocs` | `boolean` | — | Link the code to its docs page. On by default. |
| `class` | `string` | — |  |

### Field

Label + hint + error, wired for a11y. Field owns the ids and hands them to the control through a render prop, so `aria-describedby` and `aria-invalid` can never drift out of sync with what is actually rendered.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | Already-translated label. Rendered as a real <label for>. |
| `children` | `(control: FieldControl) => JSX.Element` | yes |  |
| `hint` | `string` | — |  |
| `error` | `string` | — | Present means invalid; the string is announced with the control. |
| `required` | `boolean` | — |  |
| `markOptional` | `boolean` | — | Appends a translated "(optional)" marker instead of marking required. |
| `class` | `string` | — |  |

### FileInput

A file picker that keeps the platform control and dresses it. The native button and the browser's own "no file chosen" summary are already localised, focusable and keyboard-driven, so replacing them with a div buys nothing and costs an accessible name. What the platform has no opinion about is added here: a determinate progress bar, and a partition of what it refused.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `id` | `string` | — |  |
| `name` | `string` | — |  |
| `accept` | `string` | — | The `accept` attribute, and the same list the refusal partition is computed against. |
| `multiple` | `boolean` | — |  |
| `size` | `Size` | — |  |
| `required` | `boolean` | — |  |
| `disabled` | `boolean` | — |  |
| `maxBytes` | `number` | — | Client-side ceiling. The server enforces its own; this only spares a doomed transfer. |
| `maxFiles` | `number` | — |  |
| `progress` | `number` | — | 0..1 while an upload runs. Absent renders no bar at all — never one stuck at zero. |
| `progressLabel` | `string` | — | Already-translated accessible name for the progress bar. |
| `class` | `string` | — |  |
| `aria-label` | `string` | — |  |
| `aria-describedby` | `string` | — |  |
| `aria-invalid` | `boolean` | — |  |
| `onSelect` | `((selection: FileSelection<File>) => void)` | — | Every chosen file, partitioned into accepted and refused-with-a-reason. |
| `onBlur` | `JSX.EventHandlerUnion<HTMLInputElement, FocusEvent>` | — |  |

### Form

Form shell. Owns the one thing every form needs and always forgets: a top-of-form error summary that is announced (the Alert inside it is a live region) and that TAKES focus when an error arrives — the focus move is what makes the summary reachable at all, since its id is internal.  Focus goes to the first INVALID CONTROL when there is one, and to the summary only when there is not. GOV.UK's tested pattern is a summary whose entries LINK to their fields; that shape is not available here, because `Field` mints its control ids internally (`Field.tsx`) and inverting that ownership is the drift `Field` exists to prevent — a summary cannot write an `href` to an id it cannot see. Focusing the control directly reaches the same place in one step. The summary still announces, and is still where a form-level rejection (a policy refusal, an unmatched issue) puts the reader, because that one names no control to send them to.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes |  |
| `error` | `string` | — | Already-translated summary shown above the fields when submit fails. |
| `errorTitle` | `string` | — | Already-translated heading for the error summary region. |
| `actions` | `JSX.Element` | — |  |
| `invalidField` | `string` | — | The `name` of the control a failed submit should send the reader to — `form.firstInvalidField()`. Focused in preference to the summary: the summary describes the problem, the control is where it is fixed, and leaving the user on the summary strands them one Tab away from nothing. |
| `busy` | `boolean` | — | A submit is in flight — `form.pending()`. Suppresses the submit outright, so a double submit is refused HERE and not only on whatever control happened to be clicked: Enter in a text field submits a form with no button involved at all. |
| `gap` | `SpaceStep` | — |  |
| `method` | `'get' \| 'post'` | — |  |
| `action` | `string` | — |  |
| `novalidate` | `boolean` | — |  |
| `class` | `string` | — |  |
| `aria-label` | `string` | — |  |
| `onSubmit` | `JSX.EventHandlerUnion<HTMLFormElement, SubmitEvent>` | — |  |

### Gauge

One value against its maximum as a 270° dial — CPU, a quota, a queue's fill — with the reading in its centre and its label under it. A `meter` role carries the value for assistive tech, the way `Meter` does; the readout is HTML over the svg so it keeps its size on a phone.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | What is measured, already translated: shown under the dial, and the meter's name. |
| `value` | `number` | yes |  |
| `max` | `number` | yes |  |
| `tone` | `Tone` | — | The fill's colour role. Default `accent`; a caller picks `warning`/`danger` by threshold. |
| `format` | `((value: number, max: number) => string)` | — | The readout, from the value and max. Default: the share as a percentage in the page's locale. |
| `class` | `string` | — | Lands on the root `<figure>`. |

### Grid

Grid layout primitive. The default is an intrinsic responsive grid (`auto-fit` + `minmax`) so most layouts need no breakpoint at all.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes |  |
| `columns` | `number` | — | Fixed track count. Omit for the intrinsic `auto-fit` behaviour. |
| `minColumn` | `string` | — | Minimum track width for the intrinsic grid. |
| `gap` | `SpaceStep` | — |  |
| `rowGap` | `SpaceStep` | — |  |
| `as` | `'div' \| 'ul' \| 'ol' \| 'section'` | — |  |
| `class` | `string` | — |  |

### Icon

Inline SVG from one Lucide glyph. Decorative by default (`aria-hidden`); a `label` promotes it to `role="img"` with a name. Paint is `currentColor` and the box is one `em`, so an icon takes the tone and the type step of whatever it sits inside.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `glyph` | `IconGlyph` | yes | One glyph module: `import { iconSearch } from '@ultimat3/ui/icons/search'`. |
| `label` | `string` | — | Already-translated accessible name. Omitted means decorative, and a decorative icon is hidden from assistive tech rather than read as a nameless image. |
| `size` | `Size` | — |  |
| `class` | `string` | — |  |

### IconButton

A button whose only content is an icon, so `label` is mandatory: it becomes the accessible name and the tooltip. There is no unlabelled icon button.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | Required accessible name — also used as the native tooltip. |
| `children` | `JSX.Element` | yes |  |
| `variant` | `ButtonVariant` | — |  |
| `tone` | `Tone` | — |  |
| `size` | `Size` | — |  |
| `disabled` | `boolean` | — |  |
| `round` | `boolean` | — |  |
| `id` | `string` | — |  |
| `class` | `string` | — |  |
| `aria-pressed` | `boolean` | — |  |
| `aria-expanded` | `boolean` | — |  |
| `aria-controls` | `string` | — |  |
| `onClick` | `JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>` | — |  |

### Image

Zero-CLS image primitive: a plain <img>, or a <picture> around one when AVIF/WebP renditions are handed in. No JS, no fetch, no client state.  The encoding half of the pipeline — AVIF/WebP renditions, measured dimensions — is the app's build step; the URLs come from `asset('assets/…')` in the page. This component emits exactly what it is handed and fabricates nothing, except the one thing it insists on: a reserved box.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `src` | `string` | yes | The fallback every browser can decode — usually the JPEG/PNG rendition. |
| `alt` | `string` | yes | Required, with no default: an <Image> whose meaning is undescribed is a type error at the call site. Pass `alt=""` for a decorative image, deliberately. |
| `variants` | `readonly ImageVariant[]` | — | Renditions of `src`'s own encoding; the descriptors and their order are derived. |
| `sources` | `ImageSources` | — | AVIF and WebP renditions, each a width list: `{ avif: [{ src: asset('…'), width: 640 }] }`. |
| `sizes` | `string` | — | Layout width of the box, e.g. `(max-width: 700px) 100vw, 620px`. Applies to every set. |
| `priority` | `boolean` | — | The LCP image, at most one per route: eager, `fetchpriority="high"`, never lazy. |
| `width` | `number` | — | Intrinsic size in CSS pixels, from the step that encoded it. Both, or `aspectRatio`. |
| `height` | `number` | — |  |
| `aspectRatio` | `ImageAspectRatio` | — | `'16 / 9'` — the reservation when the intrinsic size is not known. Never beside width/height. |
| `class` | `string` | — |  |

### InfiniteScroll

An endless list that degrades to pagination: the foot is always a real `rel="next"` link, so the next page is one navigation away with scripting off. With script, a sentinel below the list asks for that page before the reader reaches it, and the link's click is intercepted.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes | The items rendered so far. This component owns the foot, never the list. |
| `hasMore` | `boolean` | yes | Another page exists. |
| `nextHref` | `string` | — | The next page's URL. Required whenever `hasMore` — it is the no-JS path. |
| `loading` | `boolean` | — | A page is in flight; the control stops inviting a second request. |
| `onLoadMore` | `(() => void)` | — | The enhancement: called when the sentinel appears, and in place of following the link. |
| `rootMargin` | `string` | — | How far below the fold loading starts. A CSS length, as IntersectionObserver takes it. |
| `class` | `string` | — |  |

### InlineBar

A figure with a bar drawn to its share of a maximum — the in-cell bar a dashboard table uses to make a column of numbers scannable. The text is the fact and is what assistive tech reads; the bar is `Meter`'s decorative form, so the share rule and its clamping are written once. The figure arrives formatted, as `StatTile`'s does: this component never guesses a locale.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `value` | `number` | yes |  |
| `max` | `number` | yes | The whole the bar is a share of — usually the largest value in the column. |
| `text` | `string` | yes | The figure as the cell shows it, already formatted (`Intl.NumberFormat`, `formatMoney`). |
| `tone` | `Tone` | — |  |
| `class` | `string` | — |  |

### Input

Single-line text control. No `type="number"` convenience wrapper: numeric input uses `inputmode` + a text type so locale decimal separators survive.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `id` | `string` | — |  |
| `name` | `string` | — |  |
| `type` | `InputType` | — |  |
| `value` | `string` | — |  |
| `placeholder` | `string` | — |  |
| `size` | `Size` | — |  |
| `required` | `boolean` | — |  |
| `disabled` | `boolean` | — |  |
| `readonly` | `boolean` | — |  |
| `autocomplete` | `string` | — |  |
| `inputmode` | `'text' \| 'numeric' \| 'decimal' \| 'tel' \| 'email' \| 'url' \| 'search'` | — |  |
| `maxlength` | `number` | — |  |
| `prefix` | `JSX.Element` | — | Non-interactive adornments; keep them icons or units, never controls. |
| `suffix` | `JSX.Element` | — |  |
| `class` | `string` | — |  |
| `aria-label` | `string` | — |  |
| `aria-describedby` | `string` | — |  |
| `aria-invalid` | `boolean` | — |  |
| `onInput` | `JSX.EventHandlerUnion<HTMLInputElement, InputEvent>` | — |  |
| `onChange` | `JSX.EventHandlerUnion<HTMLInputElement, Event>` | — |  |
| `onBlur` | `JSX.EventHandlerUnion<HTMLInputElement, FocusEvent>` | — |  |

### Kbd

A keyboard key or chord as the user reads it — "⌘K", "Esc", "/". A native `<kbd>`, because a keyboard hint is inline code, not a badge: the element carries the meaning, so it works with no script and reads as a key to a screen reader.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes | The key or chord, already translated where a key name is a word. |
| `class` | `string` | — |  |

### LineChart

A line chart of one or more series over the same keys — signups per day, p95 per hour — and `AreaChart`, the same chart with each series' area filled to the baseline. SVG lines on a nice 1-2-5 value axis, HTML axis labels (text inside a viewBox scales to a speck on a phone), a legend, and a hidden data table. Every series has its own dash and marker, never colour alone.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | The chart's name, already translated: the figure's caption and the svg's accessible name. |
| `keys` | `readonly string[]` | yes | The x axis, oldest first, already formatted for display — "17 Sep", "14:00". |
| `series` | `readonly ChartSeries[]` | yes | One entry per line. `values[i]` belongs to `keys[i]`; `null` is a gap, never a zero. |
| `keyLabel` | `string` | — | The key column's head in the data table, already translated — "Day". |
| `format` | `((value: number) => string)` | — | Formats every value and tick. Default: `Intl.NumberFormat` in the page's locale. |
| `zero` | `boolean` | — | Start the value axis at zero. Default on; off fits the axis to the data. |
| `focusable` | `boolean` | — | Give every point a tab stop and a hover/focus readout. Default off: no tab stops. |
| `showCaption` | `boolean` | — | Show the caption above the plot. Default off — it names the figure either way. |
| `class` | `string` | — | Lands on the root `<figure>`. |

### AreaChart

A line chart of one or more series over the same keys — signups per day, p95 per hour — and `AreaChart`, the same chart with each series' area filled to the baseline. SVG lines on a nice 1-2-5 value axis, HTML axis labels (text inside a viewBox scales to a speck on a phone), a legend, and a hidden data table. Every series has its own dash and marker, never colour alone.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | The chart's name, already translated: the figure's caption and the svg's accessible name. |
| `keys` | `readonly string[]` | yes | The x axis, oldest first, already formatted for display — "17 Sep", "14:00". |
| `series` | `readonly ChartSeries[]` | yes | One entry per line. `values[i]` belongs to `keys[i]`; `null` is a gap, never a zero. |
| `keyLabel` | `string` | — | The key column's head in the data table, already translated — "Day". |
| `format` | `((value: number) => string)` | — | Formats every value and tick. Default: `Intl.NumberFormat` in the page's locale. |
| `zero` | `boolean` | — | Start the value axis at zero. Default on; off fits the axis to the data. |
| `focusable` | `boolean` | — | Give every point a tab stop and a hover/focus readout. Default off: no tab stops. |
| `showCaption` | `boolean` | — | Show the caption above the plot. Default off — it names the figure either way. |
| `class` | `string` | — | Lands on the root `<figure>`. |

### Link

Anchor primitive. External links get `rel` hardening and a translated "opens in a new tab" hint automatically — never a bare `target="_blank"`. `appearance="button"` is Button's look on the same anchor: a control that navigates is a link.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `href` | `string` | yes |  |
| `children` | `JSX.Element` | yes |  |
| `external` | `boolean` | — |  |
| `externalHint` | `string` | — | Announced suffix for external links; supply via `t()` at the call site. |
| `rel` | `'next' \| 'prev'` | — | A pager's direction. Joins the hardening an external link always carries, never replaces it. |
| `id` | `string` | — |  |
| `class` | `string` | — |  |
| `aria-current` | `'page' \| 'step' \| 'true' \| false` | — |  |
| `onClick` | `JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>` | — |  |
| `appearance` | `'link' \| 'button'` | — | `button` wears Button's classes on the same `<a>`; the two looks take different props. |
| `underline` | `'always' \| 'hover' \| 'none'` | — |  |
| `tone` | `'accent' \| 'inherit' \| Tone` | — | A text link: `accent` or `inherit`. A button-link: any `Tone`. |
| `variant` | `ButtonVariant` | — | `appearance="button"` only. |
| `size` | `Size` | — | `appearance="button"` only. |
| `fullWidth` | `boolean` | — | `appearance="button"` only. |
| `iconStart` | `JSX.Element` | — | `appearance="button"` only. |
| `iconEnd` | `JSX.Element` | — | `appearance="button"` only. |

### LocaleSwitcher

Locale picker. Option labels come from `Intl.DisplayNames` in each locale's own language (endonyms), so the list needs no translation catalog of its own.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `locales` | `readonly Locale[]` | yes | Every locale to offer, the DEFAULT first — `routedLocales()` from `@ultimat3/i18n`. |
| `value` | `Locale` | — | Defaults to the context locale. |
| `onLocaleChange` | `((locale: Locale) => void)` | — |  |
| `hrefFor` | `((locale: Locale) => string)` | — | Render as links instead of a select, for a 0kb-JS `site/` route. Defaults, when `path` is given, to that page in each locale — `localizedPath`: unprefixed for the default, `/en/…` otherwise — so a site's switcher needs no href of its own. |
| `path` | `string` | — | The page's own path, prefixed or not — `new URL(props.url).pathname`. Turns on links mode. |
| `defaultLocale` | `Locale` | — | The unprefixed locale. Defaults to `locales[0]`, which is where `routedLocales()` puts it. |
| `class` | `string` | — |  |

### Menu

Action menu: a trigger plus a `role="menu"` list with roving tabindex. Distinct from Select — a menu runs commands, it does not hold a form value.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `items` | `readonly MenuItem[]` | yes |  |
| `open` | `boolean` | yes |  |
| `onOpenChange` | `(open: boolean) => void` | yes |  |
| `trigger` | `(control: { id: string; 'aria-haspopup': 'menu'; 'aria-expanded': boolean; 'aria-controls': string \| undefined; }) => JSX.Element` | yes | The menu button. `aria-controls` is present only while the list is mounted: an IDREF to an element not in the document names nothing. |
| `label` | `string` | yes | Already-translated accessible name for the menu. |
| `align` | `'start' \| 'end'` | — |  |
| `class` | `string` | — |  |

### Meter

A thin horizontal bar showing one value against a maximum — a table cell's "how busy is this row" beside the number, or a quota. Decorative by default: with no `label` it is hidden from the accessibility tree, because the number beside it is the fact. With one it is a `meter` role with the value spoken.  An SVG with a `width` ATTRIBUTE rather than a styled box: it paints identically on the server copy and the hydrated one and asks nothing of the CSP's `style-src-attr`.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `value` | `number` | yes |  |
| `max` | `number` | yes |  |
| `label` | `string` | — | The accessible name. Omit for a purely decorative bar beside a visible number. |
| `tone` | `Tone` | — |  |
| `class` | `string` | — |  |

### Money

Formats through @ultimat3/money using the locale and currency from context — never a process-wide default, never a float, never a hardcoded symbol.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `value` | `MoneyValue \| number` | yes | A Money value, or bare minor units in the context currency. |
| `currency` | `string` | — | Overrides the context currency for bare-number values. |
| `locale` | `string` | — | Overrides the context locale. Use only for side-by-side comparisons. |
| `signed` | `boolean` | — | Colour negatives with the danger role, e.g. in a ledger. |
| `options` | `FormatMoneyOptions` | — | Passed to @ultimat3/money: display, accounting, grouping, fractionDigits. |
| `format` | `MoneyFormatter` | — |  |
| `class` | `string` | — |  |

### PageHeader

The top of a screen: breadcrumbs, the page's one heading, a description, and the actions that belong to the page rather than to a row in it. Hand-rolled, this is where the heading level, the landmark and the action alignment go wrong; here it is one component with one shape.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `title` | `string` | yes | Already-translated page title. Rendered as the heading, once. |
| `description` | `string` | — | Already-translated supporting line under the title. |
| `actions` | `JSX.Element` | — | Trailing controls — a Button, a Toolbar. Wraps under the title on narrow viewports. |
| `breadcrumbs` | `readonly BreadcrumbItem[]` | — |  |
| `level` | `HeadingLevel` | — | Heading level. 1 by default: a screen has exactly one h1, and this is it. |
| `media` | `JSX.Element` | — | Rendered before the title — an avatar, an icon, a status dot. |
| `class` | `string` | — |  |

### Pagination

Cursor-first pagination — the only shape a Postgres-backed list should use. Numbered mode exists for prerendered archives where the total is known. One mode per use: callbacks (`onCursor` / `onPage`) for an island, or `hrefFor` for a server-rendered list — anchors the browser follows, with no script on the page.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `nextCursor` | `string` | — | Opaque cursor for the following page; absent disables "next". |
| `prevCursor` | `string` | — |  |
| `labelPrevious` | `string` | — | Already-translated; falls back to the ui.* catalog keys. |
| `labelNext` | `string` | — |  |
| `class` | `string` | — |  |
| `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | Callback mode. Never beside `hrefFor` — the type refuses the pair. |
| `page` | `number` | — | Numbered mode: 1-based page and total. Ignored when cursors are present. |
| `totalPages` | `number` | — |  |
| `onPage` | `((page: number) => void)` | — |  |
| `hrefFor` | `(cursor: string, direction: 'next' \| 'prev') => string` | — | Link mode: the URL of the page a cursor names. Renders `<a rel="next\|prev">` and attaches no handler, so it needs no island. Cursor paging only — never beside `onCursor`, `onPage`, `page` or `totalPages`. |

### Popover

Non-modal anchored panel. Positioning is CSS-only (a relatively positioned anchor plus logical insets and auto-margin centring), so there is no measure/reflow loop and the panel mirrors under `dir="rtl"` — `rtl-sheets.test.ts` holds that.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `open` | `boolean` | yes |  |
| `onOpenChange` | `(open: boolean) => void` | yes |  |
| `trigger` | `(control: { id: string; 'aria-expanded': boolean; 'aria-controls': string \| undefined; }) => JSX.Element` | yes | The anchor. Wire its onClick to `onOpenChange` in the caller. `aria-controls` is present only while the panel is mounted: an IDREF to an element not in the document names nothing. |
| `children` | `JSX.Element` | yes |  |
| `label` | `string` | yes | Already-translated accessible name for the panel. |
| `placement` | `Placement` | — |  |
| `align` | `'start' \| 'center' \| 'end'` | — |  |
| `class` | `string` | — |  |

### QrCode

A QR code of one short value — a link's short URL, a join code — as static SVG from the pure-TypeScript encoder in `qr-encode.ts` / `qr-matrix.ts`. No QR library: a route's JS budget counts raw minified bytes, and one `<rect>` per dark module costs nothing to hydrate, so the server-rendered shell IS the code. Versions 1-3 only (42 bytes); longer is `X_UI_QR_CAPACITY`.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `value` | `string` | yes | What the code encodes — a short URL, at most 42 UTF-8 bytes. Longer throws. |
| `label` | `string` | yes | The accessible name, already translated — say what scanning it does, not "QR code". |
| `quietZone` | `number` | — | Light modules on every side. Default 4, the minimum a scanner needs to find the finders. |
| `class` | `string` | — |  |

### Radio

Radio group. Rendered as a <fieldset>/<legend> so the group has a name, and as one native radio per option so arrow-key navigation is the platform's job.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `legend` | `string` | yes | Already-translated group label, rendered as the <legend>. |
| `name` | `string` | yes |  |
| `options` | `readonly RadioOption[]` | yes |  |
| `value` | `string` | — |  |
| `disabled` | `boolean` | — |  |
| `required` | `boolean` | — |  |
| `direction` | `'row' \| 'column'` | — |  |
| `class` | `string` | — |  |
| `aria-describedby` | `string` | — |  |
| `onChange` | `JSX.EventHandlerUnion<HTMLInputElement, Event>` | — |  |

### RelativeTime

"3 minutes ago" with the absolute instant in both `datetime` and `title`, so hovering (or reading the DOM) always recovers the exact time.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `value` | `TimeInput` | yes |  |
| `now` | `TimeInput` | — | Pass explicitly for SSR so server and client render the same string. |
| `locale` | `string` | — |  |
| `timeZone` | `TimeZone` | — |  |
| `numeric` | `'always' \| 'auto'` | — |  |
| `format` | `DateTimeFormatter` | — |  |
| `class` | `string` | — |  |

### Section

A titled block inside a page. Exists so a screen's second-level structure is a labelled landmark with a real heading — `aria-labelledby` wired to the heading it renders — instead of a <div> with bold text, which is what an unassisted layout always becomes.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes |  |
| `title` | `string` | — | Already-translated section title. Omit for an unlabelled grouping. |
| `description` | `string` | — | Already-translated supporting line under the title. |
| `actions` | `JSX.Element` | — | Controls that act on this section only. |
| `level` | `HeadingLevel` | — | Heading level. 2 by default — the level under a PageHeader's h1. |
| `as` | `'section' \| 'article' \| 'aside'` | — |  |
| `class` | `string` | — |  |

### Select

Native <select>. Deliberately not a custom listbox: the platform control wins on mobile, on keyboard, and with screen readers.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `options` | `readonly SelectOption[]` | yes |  |
| `id` | `string` | — |  |
| `name` | `string` | — |  |
| `value` | `string` | — |  |
| `placeholder` | `string` | — | Rendered as a disabled first option, so it is never a submittable value. |
| `size` | `Size` | — |  |
| `required` | `boolean` | — |  |
| `disabled` | `boolean` | — |  |
| `class` | `string` | — |  |
| `aria-label` | `string` | — |  |
| `aria-describedby` | `string` | — |  |
| `aria-invalid` | `boolean` | — |  |
| `onChange` | `JSX.EventHandlerUnion<HTMLSelectElement, Event>` | — |  |

### Skeleton

Loading placeholder. Sized by the caller so the real content lands in the same box — a skeleton that changes size on load is just a slower layout shift.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `width` | `string` | — | CSS length; must match the real content's box to keep CLS at 0. |
| `height` | `string` | — |  |
| `shape` | `'text' \| 'block' \| 'circle'` | — |  |
| `lines` | `number` | — | Repeat as stacked lines, e.g. a paragraph placeholder. |
| `class` | `string` | — |  |

### Sparkline

One series as a single line with no axes — the trend beside a figure, in a table cell, on a detail page. Static SVG for `BarChart`'s reason: the framework's own docs use a sparkline pulling in a chart library as the cautionary example, and this file IS that sparkline written the way the docs say to. The path comes from `sparkline-view.ts`.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | The accessible name for the whole chart, already translated. |
| `points` | `readonly ChartPoint[]` | yes | Oldest first. Every point comes from exactly this array — the caller zero-fills gaps. |
| `class` | `string` | — |  |

### Spinner

Indeterminate progress. `role="status"` plus a translated name, because a spinner with no accessible name is silence to a screen reader.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `size` | `Size` | — |  |
| `label` | `string` | — | Overrides the translated `ui.loading` default. |
| `decorative` | `boolean` | — | Hide from assistive tech when a parent already announces the busy state. |
| `class` | `string` | — |  |

### Stack

Flex layout primitive. Gap comes from the space scale as a custom property, so there is no class per step, and `row` uses `flex-direction: row` — which the browser already mirrors under `dir="rtl"`.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes |  |
| `direction` | `'row' \| 'column'` | — |  |
| `gap` | `SpaceStep` | — |  |
| `align` | `Align` | — |  |
| `justify` | `Align` | — |  |
| `wrap` | `boolean` | — |  |
| `as` | `'div' \| 'ul' \| 'ol' \| 'nav' \| 'section' \| 'header' \| 'footer'` | — | Renders a semantic element instead of a div. |
| `class` | `string` | — |  |

### StatTile

One labelled figure with an optional trend chip and a line of context — the tile a dashboard's top row is made of. The figure arrives formatted: number formatting is the caller's (`Intl.NumberFormat` against the page locale), so the tile never guesses a locale.  Intrinsic tags only: a runtime-chosen root (`const Tag = props.as ?? 'div'`) is called as a component by the island build, so a tile built on `Card` hydrated differently from how it was served. A `<div>` renders identically on both sides.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | The tile's caption, already translated — "Total links", "Clicks today". |
| `value` | `string` | yes | The figure, already formatted as the string this tile shows. |
| `stat` | `string` | yes | The stable hook a test or a live check reads the figure through: `data-stat`. |
| `delta` | `StatDelta` | — | The trend chip; `deltaOf()` builds one from a current and a baseline. |
| `hint` | `string` | — | One short line of context under the figure: "vs yesterday", "849 clicks". |
| `class` | `string` | — |  |

### Switch

Boolean toggle with immediate effect (as opposed to Checkbox, which is part of a form submit). `role="switch"` on a native checkbox keeps keyboard behaviour — and its checked state, which is why nothing here writes `aria-checked`: `role="switch"` maps the host `checked` state on its own, and an attribute mirroring it never updates on the no-JS path, where ARIA would then outrank the truth and announce the switch stuck in whichever position it was rendered in.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `label` | `string` | yes | Already-translated label. Required — the state alone is not a name. |
| `checked` | `boolean` | — |  |
| `id` | `string` | — |  |
| `name` | `string` | — |  |
| `disabled` | `boolean` | — |  |
| `labelPosition` | `'start' \| 'end'` | — | Put the label before the track, e.g. in a settings row. |
| `class` | `string` | — |  |
| `aria-describedby` | `string` | — |  |
| `onChange` | `JSX.EventHandlerUnion<HTMLInputElement, Event>` | — |  |

### Table

Presentational table. Owns what every hand-rolled table gets wrong: a sticky header, a first column that stays put while the rest scrolls, and horizontal overflow contained inside the table's own scroll container so the page body never scrolls sideways.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `caption` | `string` | yes | Already-translated caption. Required: a table needs an accessible name. |
| `children` | `JSX.Element` | yes |  |
| `hideCaption` | `boolean` | — | Hide the caption visually while keeping it for assistive tech. |
| `stickyHeader` | `boolean` | — |  |
| `stickyFirstColumn` | `boolean` | — | Pins the first column at the inline start while the table scrolls sideways. Off by default. |
| `explicitRoles` | `boolean` | — | Declares `role="table"` on the element itself. For a caller that restyles the table's `display` (DataTable's card view): WebKit drops a table's semantics once `display` changes, and the explicit role is what keeps it a table. The rows and cells are the caller's to mark. |
| `density` | `'comfortable' \| 'compact'` | — |  |
| `striped` | `boolean` | — | Zebra striping. Off by default — a border is usually enough. |
| `class` | `string` | — |  |

### Tabs

Tablist with roving tabindex: one Tab in the page tab order, arrows move between them, Home/End jump to the ends, and arrow direction follows `dir`.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `items` | `readonly TabItem[]` | yes |  |
| `value` | `string` | yes | Selected tab id. Controlled — the caller owns the state. |
| `onChange` | `(id: string) => void` | yes |  |
| `label` | `string` | yes | Already-translated accessible name for the tablist. |
| `orientation` | `'horizontal' \| 'vertical'` | — |  |
| `class` | `string` | — |  |

### Text

Typography primitive. Tone is a foreground or status colour role, size and weight are keys of the type scale — so body copy, captions and inline status text come from one component and stay legible on both themes.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes |  |
| `tone` | `TextTone` | — |  |
| `size` | `TextSize` | — |  |
| `weight` | `TextWeight` | — |  |
| `as` | `'span' \| 'p' \| 'div' \| 'strong' \| 'em'` | — | Renders a semantic element instead of a span; `p` for flow text. `strong` and `em` carry meaning to assistive tech — `weight` and `tone` do not. |
| `class` | `string` | — |  |

### Textarea

Multi-line text control. `field-sizing: content` grows the box natively where supported, with `rows` as the floor — no resize observer, no JS.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `id` | `string` | — |  |
| `name` | `string` | — |  |
| `value` | `string` | — |  |
| `placeholder` | `string` | — |  |
| `rows` | `number` | — |  |
| `required` | `boolean` | — |  |
| `disabled` | `boolean` | — |  |
| `readonly` | `boolean` | — |  |
| `maxlength` | `number` | — |  |
| `autoGrow` | `boolean` | — |  |
| `class` | `string` | — |  |
| `aria-label` | `string` | — |  |
| `aria-describedby` | `string` | — |  |
| `aria-invalid` | `boolean` | — |  |
| `onInput` | `JSX.EventHandlerUnion<HTMLTextAreaElement, InputEvent>` | — |  |
| `onBlur` | `JSX.EventHandlerUnion<HTMLTextAreaElement, FocusEvent>` | — |  |

### ThemeToggle

Theme control. `toggle` flips light/dark; `select` also offers "system", which clears the stored choice so the OS takes over again. All strings come from the catalog, and the DOM work happens in theme.ts, never here.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `mode` | `'toggle' \| 'select'` | — |  |
| `initial` | `Theme` | — | The theme in force at server render — the toggle's glyph. Never the select's choice: nothing on the server knows whether the visitor chose it, so the select shows "system" until the effect reads storage. |
| `env` | `ThemeEnv` | — | Injectable for tests and for non-DOM hosts. |
| `class` | `string` | — |  |

### ToastRegion

Transient notification. ToastRegion is the single live region for the app; individual Toasts are its children, so announcements are not duplicated and the region exists before the first message. That ordering is the whole point: a live region created with its content already in it is not announced by most screen readers, which is why `aria-live` sits on the persistent <ol> here and NOT on the <li> each Toast renders.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes |  |
| `label` | `string` | yes | Already-translated landmark name, e.g. "Notifications". |
| `politeness` | `Politeness` | — | How the region announces. `polite` waits for a pause and is right for everything an app routinely confirms; `assertive` interrupts whatever the user is being read, so it belongs only to a region that carries errors alone. One region, one politeness — mixing tones inside one list cannot work, because the live semantics belong to the list, not to the message. |
| `placement` | `ToastPlacement` | — |  |
| `onHold` | `((reason: ToastHold) => void)` | — | Stop the dwell while the reader is engaged with the stack, and start it again when they leave. WCAG 2.2 2.2.1 wants a timing the user can extend, and a message that expires under the pointer reaching for its undo is the failure that rule is about.  Two reasons, reported separately: a pointer leaving a toast a keyboard user is still inside must not restart the countdown, which one boolean cannot express. |
| `onRelease` | `((reason: ToastHold) => void)` | — |  |
| `class` | `string` | — |  |

### Toast

Transient notification. ToastRegion is the single live region for the app; individual Toasts are its children, so announcements are not duplicated and the region exists before the first message. That ordering is the whole point: a live region created with its content already in it is not announced by most screen readers, which is why `aria-live` sits on the persistent <ol> here and NOT on the <li> each Toast renders.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes | Already-translated message. |
| `title` | `string` | — |  |
| `tone` | `Tone` | — |  |
| `action` | `JSX.Element` | — |  |
| `onDismiss` | `(() => void)` | — |  |
| `dismissLabel` | `string` | — |  |
| `class` | `string` | — |  |

### Toaster

The one way to render a toast queue: the store's VISIBLE slice, inside the region that owns the live semantics. The half that was missing — `Toast` and `ToastRegion` shipped with no store, so every app wrote its own queue, its own dwell and its own pause rules.  Toasts over the cap are not drawn and not counted on screen: they are still queued, and their dwell has not started. A "+3 more" badge would be a message about messages, in a corner the reader is already being asked to look away to.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `store` | `ToastStore` | yes | The queue. One per app — `toastStore()` in the island that mounts this. |
| `label` | `string` | yes | Already-translated landmark name, e.g. "Notifications". |
| `politeness` | `Politeness` | — |  |
| `placement` | `ToastPlacement` | — |  |
| `dismissLabel` | `string` | — | Already-translated; falls back to the `ui.dismiss` catalog key. |
| `class` | `string` | — |  |

### Toolbar

The control strip above a table or a list: filters and search at the inline start, actions at the inline end. `role="toolbar"` with the same roving-tabindex helper Tabs uses, so arrow keys move between the strip's buttons. It is NOT one tab stop: the strip holds arbitrary children it cannot reach into to set an initial `tabindex`, and a search field at the inline start keeps its own arrow keys, so making the strip a single stop would strand every control past it.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `children` | `JSX.Element` | yes | Leading controls — search, filters, a Select. |
| `actions` | `JSX.Element` | — | Trailing controls, pushed to the inline end. |
| `label` | `string` | yes | Already-translated accessible name. Required: an unnamed toolbar is an unnamed group. |
| `surface` | `boolean` | — | Renders the strip on a raised, bordered surface. |
| `class` | `string` | — |  |

### Tooltip

Supplementary hint, shown on hover AND on keyboard focus. CSS-only, so it costs no JS; the content is wired with `aria-describedby`, never `title`, because `title` is unreachable by keyboard and untranslatable by the platform.

| Prop | Type | Required | Notes |
|---|---|---|---|
| `content` | `string` | yes | Already-translated hint text. Never the element's only accessible name. |
| `children` | `(control: { 'aria-describedby': string }) => JSX.Element` | yes |  |
| `placement` | `'block-start' \| 'block-end'` | — |  |
| `class` | `string` | — |  |

## Tokens

Colour roles are the only colours that exist. Use them through SCSS (`t.role('accent')`) or
CSS (`rgb(var(--color-accent) / 0.12)`); `colorRgb(theme, role)` resolves one for canvas,
charts and email.

### Colour roles

| Role | Custom property |
|---|---|
| `bg` | `--color-bg` |
| `bg-soft` | `--color-bg-soft` |
| `surface` | `--color-surface` |
| `surface-raised` | `--color-surface-raised` |
| `fg` | `--color-fg` |
| `fg-strong` | `--color-fg-strong` |
| `fg-muted` | `--color-fg-muted` |
| `line` | `--color-line` |
| `scrim` | `--color-scrim` |
| `accent` | `--color-accent` |
| `accent-strong` | `--color-accent-strong` |
| `accent-fg` | `--color-accent-fg` |
| `success` | `--color-success` |
| `success-soft` | `--color-success-soft` |
| `success-fg` | `--color-success-fg` |
| `warning` | `--color-warning` |
| `warning-soft` | `--color-warning-soft` |
| `warning-fg` | `--color-warning-fg` |
| `danger` | `--color-danger` |
| `danger-soft` | `--color-danger-soft` |
| `danger-fg` | `--color-danger-fg` |
| `info` | `--color-info` |
| `info-soft` | `--color-info-soft` |
| `info-fg` | `--color-info-fg` |
| `chart-1` | `--color-chart-1` |
| `chart-2` | `--color-chart-2` |
| `chart-3` | `--color-chart-3` |
| `chart-4` | `--color-chart-4` |
| `chart-5` | `--color-chart-5` |
| `chart-6` | `--color-chart-6` |
| `chart-7` | `--color-chart-7` |
| `chart-8` | `--color-chart-8` |

### Space — `--space-*`

| Token | Value |
|---|---|
| `0` | `0` |
| `1` | `0.25rem` |
| `2` | `0.5rem` |
| `3` | `0.75rem` |
| `4` | `1rem` |
| `5` | `1.25rem` |
| `6` | `1.5rem` |
| `8` | `2rem` |
| `10` | `2.5rem` |
| `12` | `3rem` |
| `16` | `4rem` |

### Radius — `--radius-*`

| Token | Value |
|---|---|
| `none` | `0` |
| `sm` | `0.25rem` |
| `md` | `0.5rem` |
| `lg` | `0.75rem` |
| `xl` | `1rem` |
| `pill` | `999px` |
| `full` | `50%` |

### Stroke — `--stroke-*`

| Token | Value |
|---|---|
| `hairline` | `1px` |
| `thick` | `2px` |
| `heavy` | `3px` |

### Font size — `--text-*`

| Token | Value |
|---|---|
| `xs` | `clamp(0.75rem, 0.73rem + 0.1vw, 0.8125rem)` |
| `sm` | `clamp(0.875rem, 0.85rem + 0.15vw, 0.9375rem)` |
| `md` | `clamp(1rem, 0.96rem + 0.2vw, 1.0625rem)` |
| `lg` | `clamp(1.125rem, 1.05rem + 0.35vw, 1.25rem)` |
| `xl` | `clamp(1.375rem, 1.2rem + 0.7vw, 1.75rem)` |
| `2xl` | `clamp(1.75rem, 1.4rem + 1.4vw, 2.5rem)` |
| `3xl` | `clamp(2.25rem, 1.6rem + 2.6vw, 3.5rem)` |

### Font weight — `--weight-*`

| Token | Value |
|---|---|
| `normal` | `400` |
| `medium` | `500` |
| `semibold` | `600` |
| `bold` | `700` |

### Line height — `--leading-*`

| Token | Value |
|---|---|
| `tight` | `1.2` |
| `snug` | `1.35` |
| `normal` | `1.55` |
| `loose` | `1.75` |

### Duration — `--duration-*`

| Token | Value |
|---|---|
| `instant` | `0ms` |
| `fast` | `120ms` |
| `base` | `220ms` |
| `slow` | `400ms` |
| `slower` | `640ms` |

### Easing — `--easing-*`

| Token | Value |
|---|---|
| `out` | `cubic-bezier(0.16, 1, 0.3, 1)` |
| `in` | `cubic-bezier(0.5, 0, 0.75, 0)` |
| `in-out` | `cubic-bezier(0.65, 0, 0.35, 1)` |
| `spring` | `cubic-bezier(0.34, 1.56, 0.64, 1)` |

### Z-index — `--z-*`

| Token | Value |
|---|---|
| `base` | `0` |
| `raised` | `10` |
| `sticky` | `100` |
| `dropdown` | `200` |
| `drawer` | `300` |
| `dialog` | `400` |
| `popover` | `500` |
| `tooltip` | `600` |
| `toast` | `700` |
| `skip-nav` | `800` |

### Breakpoints — `respond-to` · `respond-down` · `respond-between`

| Token | Value |
|---|---|
| `sm` | `480px` |
| `md` | `768px` |
| `lg` | `1024px` |
| `xl` | `1280px` |
| `2xl` | `1536px` |

`@include t.respond-to(md)` is `md` and wider, `t.respond-down(md)` is narrower than `md`, and
`t.respond-between(md, lg)` is `md` up to `lg`. A max-width arm stops 0.02px under its rung, so
no width matches two of them.

### Computed lengths

| Function | Answers |
|---|---|
| `t.rem(24px)` | `1.5rem` — px, rem or a unitless px count in, rem out |
| `t.fluid(1rem, 2rem, 20rem, 80rem)` | one `clamp()`: the first size at a 20rem viewport, the second at 80rem, linear between |
