@ultimat3/ui 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CATALOG.md +491 -24
  2. package/CLAUDE.md +23 -16
  3. package/README.md +338 -33
  4. package/package.json +16 -5
  5. package/src/components/AppShell.module.scss +23 -7
  6. package/src/components/AppShell.tsx +9 -0
  7. package/src/components/BarChart.module.scss +26 -0
  8. package/src/components/BarChart.tsx +242 -93
  9. package/src/components/Board.module.scss +30 -0
  10. package/src/components/Board.tsx +47 -0
  11. package/src/components/BottomNav.module.scss +49 -0
  12. package/src/components/BottomNav.tsx +57 -0
  13. package/src/components/Calendar.module.scss +118 -0
  14. package/src/components/Calendar.tsx +246 -0
  15. package/src/components/ChartFrame.module.scss +40 -3
  16. package/src/components/ChartFrame.tsx +84 -28
  17. package/src/components/CodeBlock.module.scss +101 -0
  18. package/src/components/CodeBlock.tsx +76 -0
  19. package/src/components/CommandPalette.module.scss +25 -0
  20. package/src/components/CommandPalette.tsx +50 -22
  21. package/src/components/ConfirmDialog.module.scss +36 -0
  22. package/src/components/ConfirmDialog.tsx +188 -0
  23. package/src/components/DataTable.tsx +92 -33
  24. package/src/components/DatePicker.module.scss +37 -0
  25. package/src/components/DatePicker.tsx +134 -0
  26. package/src/components/DateRangePicker.module.scss +54 -0
  27. package/src/components/DateRangePicker.tsx +156 -0
  28. package/src/components/DescriptionList.module.scss +46 -0
  29. package/src/components/DescriptionList.tsx +38 -0
  30. package/src/components/DiffView.module.scss +124 -0
  31. package/src/components/DiffView.tsx +169 -0
  32. package/src/components/DonutChart.tsx +13 -4
  33. package/src/components/Drawer.module.scss +40 -0
  34. package/src/components/Drawer.tsx +56 -0
  35. package/src/components/Gauge.module.scss +8 -0
  36. package/src/components/Gauge.tsx +40 -13
  37. package/src/components/HeatStrip.module.scss +133 -0
  38. package/src/components/HeatStrip.tsx +64 -0
  39. package/src/components/JsonView.module.scss +64 -0
  40. package/src/components/JsonView.tsx +68 -0
  41. package/src/components/LineChart.tsx +45 -18
  42. package/src/components/LiveIndicator.module.scss +44 -0
  43. package/src/components/LiveIndicator.tsx +45 -0
  44. package/src/components/LogViewer.module.scss +102 -0
  45. package/src/components/LogViewer.tsx +123 -0
  46. package/src/components/Markdown.module.scss +87 -0
  47. package/src/components/Markdown.tsx +84 -0
  48. package/src/components/Meter.module.scss +7 -0
  49. package/src/components/Meter.tsx +16 -0
  50. package/src/components/NavList.module.scss +66 -0
  51. package/src/components/NavList.tsx +95 -0
  52. package/src/components/Pagination.module.scss +6 -2
  53. package/src/components/Pagination.tsx +57 -13
  54. package/src/components/Popover.tsx +12 -2
  55. package/src/components/PullToRefresh.module.scss +41 -0
  56. package/src/components/PullToRefresh.tsx +108 -0
  57. package/src/components/Radio.module.scss +51 -0
  58. package/src/components/Radio.tsx +12 -1
  59. package/src/components/SearchableSelect.module.scss +216 -0
  60. package/src/components/SearchableSelect.tsx +331 -0
  61. package/src/components/SearchableSelectChips.tsx +41 -0
  62. package/src/components/SearchableSelectNative.tsx +89 -0
  63. package/src/components/ShortcutSheet.module.scss +33 -0
  64. package/src/components/ShortcutSheet.tsx +67 -0
  65. package/src/components/Slider.module.scss +50 -0
  66. package/src/components/Slider.tsx +68 -0
  67. package/src/components/SortableList.module.scss +72 -0
  68. package/src/components/SortableList.tsx +42 -0
  69. package/src/components/Sparkline.module.scss +10 -2
  70. package/src/components/Sparkline.tsx +27 -10
  71. package/src/components/SplitPane.module.scss +39 -0
  72. package/src/components/SplitPane.tsx +96 -0
  73. package/src/components/StatTile.module.scss +29 -3
  74. package/src/components/StatTile.tsx +75 -16
  75. package/src/components/Stepper.module.scss +78 -0
  76. package/src/components/Stepper.tsx +73 -0
  77. package/src/components/Tabs.module.scss +13 -1
  78. package/src/components/Tabs.tsx +77 -1
  79. package/src/components/TerminalView.module.scss +68 -0
  80. package/src/components/TerminalView.tsx +97 -0
  81. package/src/components/TimeRangePicker.module.scss +85 -0
  82. package/src/components/TimeRangePicker.tsx +91 -0
  83. package/src/components/Timeline.module.scss +74 -0
  84. package/src/components/Timeline.tsx +60 -0
  85. package/src/components/Tree.module.scss +98 -0
  86. package/src/components/Tree.tsx +185 -0
  87. package/src/components/VirtualList.module.scss +60 -0
  88. package/src/components/VirtualList.tsx +251 -0
  89. package/src/components/app-shell-view.ts +4 -1
  90. package/src/components/bar-chart-view.ts +121 -39
  91. package/src/components/board-view.ts +25 -0
  92. package/src/components/bottom-nav-view.ts +18 -0
  93. package/src/components/calendar-view.ts +226 -0
  94. package/src/components/chart-checks.ts +59 -0
  95. package/src/components/chart-frame-view.ts +33 -2
  96. package/src/components/chart-reference-view.ts +41 -0
  97. package/src/components/command-palette-view.ts +47 -6
  98. package/src/components/confirm-dialog-view.ts +47 -0
  99. package/src/components/data-table-rows.ts +93 -0
  100. package/src/components/date-picker-view.ts +38 -0
  101. package/src/components/date-range-view.ts +46 -0
  102. package/src/components/diff-view-view.ts +85 -0
  103. package/src/components/drawer-gesture-view.ts +33 -0
  104. package/src/components/gauge-view.ts +29 -0
  105. package/src/components/heat-strip-view.ts +68 -0
  106. package/src/components/json-view-view.ts +54 -0
  107. package/src/components/line-chart-view.ts +87 -25
  108. package/src/components/log-viewer-view.ts +51 -0
  109. package/src/components/markdown-nodes.tsx +150 -0
  110. package/src/components/nav-list-view.ts +37 -0
  111. package/src/components/pull-refresh-view.ts +93 -0
  112. package/src/components/rank-options.ts +61 -0
  113. package/src/components/searchable-select-keys.ts +118 -0
  114. package/src/components/searchable-select-view.ts +187 -0
  115. package/src/components/slider-view.ts +47 -0
  116. package/src/components/sort-pointer.ts +141 -0
  117. package/src/components/sort-surface.tsx +231 -0
  118. package/src/components/sparkline-view.ts +74 -14
  119. package/src/components/split-pane-view.ts +69 -0
  120. package/src/components/stat-delta.ts +54 -11
  121. package/src/components/stepper-view.ts +38 -0
  122. package/src/components/thunk.ts +9 -0
  123. package/src/components/timeline-view.ts +29 -0
  124. package/src/components/tree-node.tsx +133 -0
  125. package/src/components/tree-view.ts +185 -0
  126. package/src/components/use-command-palette.ts +153 -0
  127. package/src/components/virtual-list-anchor.ts +35 -0
  128. package/src/components/virtual-list-observer.ts +58 -0
  129. package/src/components/virtual-list-view.ts +145 -0
  130. package/src/content/ansi.ts +144 -0
  131. package/src/content/heading-slug.ts +32 -0
  132. package/src/content/highlight.ts +129 -0
  133. package/src/content/languages/all.ts +21 -0
  134. package/src/content/languages/diff.ts +14 -0
  135. package/src/content/languages/html.ts +16 -0
  136. package/src/content/languages/json.ts +15 -0
  137. package/src/content/languages/shell.ts +20 -0
  138. package/src/content/languages/sql.ts +27 -0
  139. package/src/content/languages/ts.ts +26 -0
  140. package/src/content/languages/yaml.ts +18 -0
  141. package/src/content/line-diff.ts +191 -0
  142. package/src/content/markdown-inline.ts +368 -0
  143. package/src/content/markdown-parse.ts +267 -0
  144. package/src/content/markdown-tree.ts +58 -0
  145. package/src/content/unified-patch.ts +53 -0
  146. package/src/drag/pointer-drag.ts +185 -0
  147. package/src/drag/sort-controller.ts +196 -0
  148. package/src/drag/sort-view.ts +201 -0
  149. package/src/errors.ts +1 -1
  150. package/src/form/field-path.ts +7 -33
  151. package/src/form/form-binding.ts +5 -8
  152. package/src/form/form-issue.ts +30 -36
  153. package/src/form/form-values.ts +3 -2
  154. package/src/hotkeys-view.ts +150 -0
  155. package/src/i18n-keys.ts +7 -50
  156. package/src/index.ts +214 -15
  157. package/src/list-url.ts +123 -0
  158. package/src/live-window.ts +23 -0
  159. package/src/roving.ts +2 -0
  160. package/src/theme/inert-runtime.ts +4 -0
  161. package/src/theme/runtime-slot.ts +2 -2
  162. package/src/theme/solid-adapter.ts +11 -5
  163. package/src/time-range-url.ts +200 -0
  164. package/src/tokens/_mixins.scss +1 -1
  165. package/src/tokens/_touch.scss +30 -1
  166. package/src/tokens/contrast-pairs.ts +12 -0
  167. package/src/ui-keys.ts +126 -0
  168. package/src/use-hotkeys.ts +145 -0
package/CATALOG.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Every component and every token, projected from source. Import all of it from `@ultimat3/ui`.
6
6
 
7
- 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`
7
+ 99 components: `Accordion` · `Alert` · `AppShell` · `AsyncRegion` · `Avatar` · `Badge` · `BarChart` · `Board` · `BottomNav` · `Breadcrumb` · `Button` · `Calendar` · `Card` · `ChartFrame` · `Checkbox` · `CodeBlock` · `Combobox` · `CommandPalette` · `ConfirmDialog` · `Container` · `CopyButton` · `DataTable` · `DatePicker` · `DateRangePicker` · `DateTime` · `DescriptionList` · `Dialog` · `DiffView` · `Divider` · `DonutChart` · `Drawer` · `Dropzone` · `EmptyState` · `ErrorState` · `Field` · `FileInput` · `Form` · `Gauge` · `Grid` · `HeatStrip` · `Icon` · `IconButton` · `Image` · `InfiniteScroll` · `InlineBar` · `Input` · `JsonView` · `Kbd` · `LineChart` · `AreaChart` · `Link` · `LiveIndicator` · `LocaleSwitcher` · `LogViewer` · `Markdown` · `Menu` · `Meter` · `Money` · `NavList` · `PageHeader` · `Pagination` · `Popover` · `PullToRefresh` · `QrCode` · `Radio` · `RelativeTime` · `SearchableSelect` · `SearchableSelectChips` · `SearchableSelectNative` · `Section` · `Select` · `ShortcutSheet` · `Skeleton` · `Slider` · `SortableList` · `Sparkline` · `Spinner` · `SplitPane` · `Stack` · `StatTile` · `Stepper` · `Switch` · `Table` · `Tabs` · `TerminalView` · `Text` · `Textarea` · `ThemeToggle` · `TimeRangePicker` · `Timeline` · `ToastRegion` · `Toast` · `Toaster` · `Toolbar` · `Tooltip` · `Tree` · `VirtualList` · `TreeLinkNode` · `TreeSelectNode`
8
8
 
9
9
  ## Vocabulary
10
10
 
@@ -55,6 +55,7 @@ The page frame every app screen sits in: skip link, banner, navigation, main, co
55
55
  | `header` | `JSX.Element` | — | |
56
56
  | `sidebar` | `JSX.Element` | — | Rendered inside a `<nav>` landmark: the inline-start column at `md` and up, a panel below. |
57
57
  | `footer` | `JSX.Element` | — | |
58
+ | `bottomNav` | `JSX.Element` | — | A `<BottomNav>`: shown below `md` only, pinned to the bottom edge in thumb reach, and in flow, so the page reserves its height and no content scrolls under it. The menu button stays for everything that does not fit five destinations. |
58
59
  | `sidebarLabel` | `string` | — | Accessible name for the sidebar landmark. Defaults to the translated `ui.navigation`. |
59
60
  | `skipLabel` | `string` | — | Skip-link text. Defaults to the translated `ui.skip`. |
60
61
  | `menuLabel` | `string` | — | The menu button's text below `md`. Defaults to the translated `ui.menu`. |
@@ -104,19 +105,50 @@ Small status label. Tone maps straight onto the status colour roles so the same
104
105
 
105
106
  ### BarChart
106
107
 
107
- 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.
108
+ A bar chart of one series — clicks per day, signups per week — two stacked (done, and failed on top), or n side by side over shared keys: SVG bars, HTML axis labels. Signed: a loss hangs below a drawn zero line, a gap is no bar. No charting library: `<rect>` bars cost nothing to hydrate, so the server-rendered shell IS the chart. It stands in a `ChartFrame`: legend, hidden table.
108
109
 
109
110
  | Prop | Type | Required | Notes |
110
111
  |---|---|---|---|
111
112
  | `label` | `string` | yes | The accessible name for the whole chart, already translated. |
112
- | `points` | `readonly ChartPoint[]` | yes | Oldest first. Every bar comes from exactly this array — the caller zero-fills gaps. |
113
113
  | `highlightLast` | `boolean` | — | Draw the last bar at full strength — the eye lands where the live number is. Default on. |
114
- | `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. |
115
114
  | `keyLabel` | `string` | — | The key column's head in the data table, already translated — "Day". |
116
115
  | `format` | `((value: number) => string)` | — | Formats every value. Default: `Intl.NumberFormat` in the page's locale. |
117
- | `focusable` | `boolean` | — | Give every bar a tab stop and a hover/focus readout. Default off: no tab stops. |
116
+ | `references` | `readonly ChartReference[]` | — | Dashed rules at a value — a target, a limit. Each widens the range so it stays on the plot. |
117
+ | `focusable` | `boolean` | — | Give every bar a tab stop and a hover/focus/tap readout. Default off: no tab stops. |
118
118
  | `showCaption` | `boolean` | — | Show the caption above the plot. Default off — it names the figure either way. |
119
119
  | `class` | `string` | — | Lands on the root `<figure>`, which holds the svg and its HTML axis labels. |
120
+ | `points` | `readonly ChartPoint[]` | — | Oldest first. `value: null` is unknown — no bar, and the table says so. Never zero-fill. |
121
+ | `seriesLabels` | `{ readonly primary: string; readonly secondary: string }` | — | Names of the two series, already translated, when a point carries `secondary`. |
122
+ | `tone` | `Tone` | — | The primary bars' colour role, in place of `accent` — a failure count in `danger`. |
123
+ | `keys` | `readonly string[]` | — | |
124
+ | `series` | `readonly ChartSeries[]` | — | `values[i]` belongs to `keys[i]`, exactly one per key — `null` for a gap. |
125
+
126
+ ### Board
127
+
128
+ Columns of sortable groups sharing one gesture: a card moves within a column or across to another by pointer, touch, the arrow keys, or — with scripting off — a target `select` per card. No card look and no column rules: `renderItem` and `canDrop` are the caller's.
129
+
130
+ | Prop | Type | Required | Notes |
131
+ |---|---|---|---|
132
+ | `groups` | `readonly BoardColumn<T>[]` | yes | |
133
+ | `itemKey` | `(item: T) => string` | yes | Unique across EVERY column: a card is one node wherever it sits. |
134
+ | `renderItem` | `(item: T) => JSX.Element` | yes | |
135
+ | `label` | `string` | yes | Already-translated name of the whole board. |
136
+ | `onMove` | `(move: SortMove) => unknown` | yes | |
137
+ | `canDrop` | `CanDrop<T>` | — | The one seam for a business rule — a WIP limit, a status machine. |
138
+ | `action` | `string` | — | The action URL the no-script target select posts to. |
139
+ | `fields` | `SortFields` | — | |
140
+ | `class` | `string` | — | |
141
+
142
+ ### BottomNav
143
+
144
+ The phone's destination bar: up to five links along the bottom edge, in thumb reach, the current one marked by `currentOf` — the same rule as `NavList`. No state and no script: links with `aria-current`, server-rendered. Its padding clears the home indicator (`safe-area-padding`); `AppShell bottomNav` shows it below `md` and reserves the space so no content hides under it.
145
+
146
+ | Prop | Type | Required | Notes |
147
+ |---|---|---|---|
148
+ | `items` | `readonly BottomNavItem[]` | yes | At most five; a sixth is `X_UI_INVALID_VALUE`. |
149
+ | `currentHref` | `string` | yes | |
150
+ | `label` | `string` | — | The landmark's name. Defaults to the translated `ui.navigation`. |
151
+ | `class` | `string` | — | |
120
152
 
121
153
  ### Breadcrumb
122
154
 
@@ -151,6 +183,24 @@ The one button. Variants and tones are token-driven, so dark mode and RTL need n
151
183
  | `aria-expanded` | `boolean` | — | |
152
184
  | `onClick` | `JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>` | — | |
153
185
 
186
+ ### Calendar
187
+
188
+ A month you can pick a day from. Server-rendered and correct with no effect run: in link mode every day is a link (`hrefFor`), in select mode an APG grid with one tab stop. Inline on a booking page, and what `DatePicker`/`DateRangePicker` open. Rules: `calendar-view.ts`.
189
+
190
+ | Prop | Type | Required | Notes |
191
+ |---|---|---|---|
192
+ | `value` | `string` | — | The chosen day, `YYYY-MM-DD`. |
193
+ | `range` | `DateRange` | — | A span to mark selected — what `DateRangePicker` passes. |
194
+ | `month` | `string` | — | The month shown first; defaults to `value`'s, then `min`'s, then today's in `useUi().timeZone`. |
195
+ | `min` | `string` | — | |
196
+ | `max` | `string` | — | |
197
+ | `isDisabled` | `((date: PlainDate) => boolean)` | — | |
198
+ | `weekStart` | `Weekday` | — | Overrides the locale's week start (`Intl.Locale#getWeekInfo`). 1 is Monday, 7 Sunday. |
199
+ | `class` | `string` | — | |
200
+ | `hrefFor` | `(date: PlainDate) => string` | — | |
201
+ | `monthHrefFor` | `((month: PlainDate) => string)` | — | The previous and next month's links; omitted, the month does not change. |
202
+ | `onSelect` | `(date: PlainDate) => void` | — | |
203
+
154
204
  ### Card
155
205
 
156
206
  Surface container. Elevation is a token rung, and because the shadow tokens are themed the same `elevation` reads correctly on light and dark.
@@ -200,6 +250,20 @@ Native checkbox with a token-drawn indicator. The label element wraps the input,
200
250
  | `aria-invalid` | `boolean` | — | |
201
251
  | `onChange` | `JSX.EventHandlerUnion<HTMLInputElement, Event>` | — | |
202
252
 
253
+ ### CodeBlock
254
+
255
+ A block of code: `<pre><code>` of classed runs, a named scroll region that stays left-to-right under `dir="rtl"`, and — with script — a wrap toggle and a copy button. The runs are text nodes, so code can never become markup. Languages are passed in, never imported here (axiom 6).
256
+
257
+ | Prop | Type | Required | Notes |
258
+ |---|---|---|---|
259
+ | `code` | `string` | yes | |
260
+ | `language` | `string` | — | The language's name or alias — a fence's info string. Unknown or absent: plain text. |
261
+ | `languages` | `readonly Language[]` | — | The languages this block may highlight with: `[tsLanguage]`, or `allLanguages` on a server page. |
262
+ | `label` | `string` | — | Already-translated name of the scroll region; defaults to `ui.code.region`. |
263
+ | `wrap` | `boolean` | — | Start wrapped. The reader can toggle it once script runs. |
264
+ | `copy` | `boolean` | — | `false` hides the copy button. |
265
+ | `class` | `string` | — | |
266
+
203
267
  ### Combobox
204
268
 
205
269
  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.
@@ -224,7 +288,7 @@ A searchable text field with suggestions: one `<input list>` plus a `<datalist>`
224
288
 
225
289
  ### CommandPalette
226
290
 
227
- 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.
291
+ 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. Items carrying a `group` render under one `role="group"` heading per run; `useCommandPalette` is the shell that owns the open flag, the query, `⌘K` and focus.
228
292
 
229
293
  | Prop | Type | Required | Notes |
230
294
  |---|---|---|---|
@@ -238,11 +302,31 @@ The ⌘K palette: a filter field over a list of commands and destinations, in a
238
302
  | `onHoverItem` | `(id: string) => void` | yes | |
239
303
  | `onRunItem` | `(id: string) => void` | yes | |
240
304
  | `onClose` | `() => void` | yes | |
305
+ | `loading` | `boolean` | — | Items are being fetched for the query (`onQuery`): says so where the list goes. |
241
306
  | `id` | `string` | — | The element's id, so a trigger can name it with `aria-controls`. |
242
307
  | `filterRef` | `((el: HTMLInputElement) => void)` | — | Refs out, so a caller can read focus or the attribute behind its own capability guard. |
243
308
  | `dialogRef` | `((el: HTMLDialogElement) => void)` | — | |
244
309
  | `class` | `string` | — | |
245
310
 
311
+ ### ConfirmDialog
312
+
313
+ A destructive confirmation, the one way: a trigger that opens a modal `<dialog>` by invoker command (`commandfor` + `show-modal`, no script), a real `<form method="post">` inside it, Cancel holding `autofocus` so a stray Enter destroys nothing, Esc closing and the backdrop never. With `onConfirm` instead of `action` an island runs the act: busy refuses a second submit, a refusal keeps the dialog open with the reason in the form summary, focused. The native `<dialog>` and `Dialog`'s sheet, not `Dialog` itself: that one opens from an effect and closes from a script button, and this one must open, cancel and post with scripting off.
314
+
315
+ | Prop | Type | Required | Notes |
316
+ |---|---|---|---|
317
+ | `title` | `string` | yes | Already translated. The dialog's accessible name: the question, "Withdraw this post?". |
318
+ | `consequence` | `string` | yes | What happens, in one sentence, read before the act: "Readers lose the page at once." |
319
+ | `confirmLabel` | `string` | yes | The verb — "Withdraw post", never "OK". No default; an empty one is `X_UI_INVALID_VALUE`. |
320
+ | `trigger` | `string` | yes | The trigger button's label. |
321
+ | `tone` | `Tone` | — | The confirm button's tone; `danger` when omitted. |
322
+ | `cancelLabel` | `string` | — | Defaults to the translated `ui.cancel`. |
323
+ | `error` | `string` | — | A refusal to show on open — the server's, after a native post came back refused. |
324
+ | `children` | `JSX.Element` | — | Hidden inputs, and any gate on the act — a typed name, a reason — with native constraints. |
325
+ | `id` | `string` | — | |
326
+ | `class` | `string` | — | |
327
+ | `action` | `string` | — | |
328
+ | `onConfirm` | `(data: FormData) => Promise<string \| undefined>` | — | |
329
+
246
330
  ### Container
247
331
 
248
332
  Centred measure with a gutter. `margin-inline: auto` and `min()` mean one declaration covers every viewport and both writing directions.
@@ -276,15 +360,15 @@ Data-driven table: sortable headers, cursor pagination, and the four states a re
276
360
  | `caption` | `string` | yes | |
277
361
  | `columns` | `readonly Column<Row>[]` | yes | |
278
362
  | `rows` | `readonly Row[]` | yes | |
279
- | `rowKey` | `(row: Row) => string` | yes | |
363
+ | `rowKey` | `(row: Row) => string` | yes | The row's identity: its primary key — unique within `rows` and stable across updates, never an index or a value that can change. A live update keeps the `<tr>` of every key it still holds: the same object runs nothing, so focus and an open "more" stay put; a new object redraws that row's cells. Two rows under one key are `X_UI_INVALID_VALUE`. |
280
364
  | `sort` | `SortState` | — | |
281
365
  | `loading` | `boolean` | — | |
282
366
  | `error` | `unknown` | — | An UltimateError (or anything shaped like one) from the query. |
283
367
  | `onRetry` | `(() => void)` | — | |
284
368
  | `emptyTitle` | `string` | — | |
285
369
  | `emptyDescription` | `string` | — | |
286
- | `nextCursor` | `string` | — | Opaque cursors from the query result; absent means no further page. |
287
- | `prevCursor` | `string` | — | |
370
+ | `nextCursor` | `string \| null` | — | Opaque cursors from the query result; `null` or absent means no further page, so a framework `Page`'s `nextCursor` is handed straight through. A keyset `Page` has no `prevCursor`. |
371
+ | `prevCursor` | `string \| null` | — | |
288
372
  | `stickyHeader` | `boolean` | — | |
289
373
  | `stickyFirstColumn` | `boolean` | — | Pins the first column while the table scrolls sideways. |
290
374
  | `narrow` | `DataTableNarrow` | — | What a table narrower than `sm` becomes. Default `cards`. |
@@ -293,8 +377,51 @@ Data-driven table: sortable headers, cursor pagination, and the four states a re
293
377
  | `class` | `string` | — | |
294
378
  | `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | Callback paging. Never beside `hrefFor` — the type refuses the pair. |
295
379
  | `onSortChange` | `((sort: SortState \| undefined) => void)` | — | Callback sorting. Never beside `sortHrefFor` — the type refuses the pair. |
380
+ | `onFirst` | `(() => void)` | — | Back to the first page: the pager's "First" button. Pass it only past the first page. |
296
381
  | `hrefFor` | `(cursor: string, direction: 'next' \| 'prev') => string` | — | Link paging: the URL of the page a cursor names, handed to `<Pagination hrefFor>`. |
297
382
  | `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. |
383
+ | `firstHref` | `string` | — | The first page's URL, for the pager's "First" anchor — `listUrl().firstHref`. |
384
+
385
+ ### DatePicker
386
+
387
+ A date field that is a native `<input type="date">` first, always: on the server, with scripting off and on a coarse pointer it is all there is, and it is the value either way, so the form posts the same thing. A fine pointer with a script also gets a button opening a `Calendar` popover.
388
+
389
+ | Prop | Type | Required | Notes |
390
+ |---|---|---|---|
391
+ | `name` | `string` | yes | The form field name: the post is the native input's, `YYYY-MM-DD`. |
392
+ | `id` | `string` | — | |
393
+ | `value` | `string` | — | `YYYY-MM-DD`; anything else is `X_UI_INVALID_VALUE`. |
394
+ | `min` | `string` | — | |
395
+ | `max` | `string` | — | |
396
+ | `isDisabled` | `((date: PlainDate) => boolean)` | — | Days the calendar refuses. The native input cannot express it; validate on the server too. |
397
+ | `weekStart` | `Weekday` | — | |
398
+ | `required` | `boolean` | — | |
399
+ | `disabled` | `boolean` | — | |
400
+ | `month` | `string` | — | The month the calendar opens on with no value; else today's in `useUi().timeZone`. |
401
+ | `aria-label` | `string` | — | |
402
+ | `aria-describedby` | `string` | — | |
403
+ | `aria-invalid` | `boolean` | — | |
404
+ | `onChange` | `((date: PlainDate \| undefined) => void)` | — | |
405
+ | `class` | `string` | — | |
406
+
407
+ ### DateRangePicker
408
+
409
+ A date range as two native date inputs — `fromName` and `toName` — so a GET filter or a POST works with scripting off and on a phone. A fine pointer with a script also gets one calendar: the first pick sets the start, the second completes the range. Rules: `date-range-view.ts`.
410
+
411
+ | Prop | Type | Required | Notes |
412
+ |---|---|---|---|
413
+ | `legend` | `string` | yes | Already-translated name for the pair, rendered as the `<legend>`. |
414
+ | `fromName` | `string` | yes | |
415
+ | `toName` | `string` | yes | |
416
+ | `from` | `string` | — | `YYYY-MM-DD` each; anything else is `X_UI_INVALID_VALUE`. |
417
+ | `to` | `string` | — | |
418
+ | `min` | `string` | — | |
419
+ | `max` | `string` | — | |
420
+ | `isDisabled` | `((date: PlainDate) => boolean)` | — | |
421
+ | `weekStart` | `Weekday` | — | |
422
+ | `month` | `string` | — | The month the calendar opens on with no `from`; else today's in `useUi().timeZone`. |
423
+ | `onChange` | `((range: DateRange) => void)` | — | Called with the range in order whenever either end changes. |
424
+ | `class` | `string` | — | |
298
425
 
299
426
  ### DateTime
300
427
 
@@ -310,6 +437,16 @@ Renders <time datetime="<ISO instant>"> with text formatted in the context time
310
437
  | `format` | `DateTimeFormatter` | — | |
311
438
  | `class` | `string` | — | |
312
439
 
440
+ ### DescriptionList
441
+
442
+ Terms and their details: `<dl>` with one `<div>` per group, so a pair stays together when the list stacks. A `Table` of two columns is the wrong landmark for facts about one thing. Server-only: no signal, no effect, 0 B of script on a static route.
443
+
444
+ | Prop | Type | Required | Notes |
445
+ |---|---|---|---|
446
+ | `items` | `readonly DescriptionItem[]` | yes | |
447
+ | `layout` | `'columns' \| 'stacked'` | — | `columns` puts each term beside its detail and stacks them when the list's OWN width falls below `sm` (a container query, never the viewport). `stacked` always stacks. |
448
+ | `class` | `string` | — | |
449
+
313
450
  ### Dialog
314
451
 
315
452
  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.
@@ -325,6 +462,19 @@ Modal built on native <dialog>: the platform gives us the top layer, the backdro
325
462
  | `dismissOnBackdrop` | `boolean` | — | Clicking the backdrop closes. Turn off for destructive confirmations. |
326
463
  | `class` | `string` | — | |
327
464
 
465
+ ### DiffView
466
+
467
+ A diff a person can read with colour off: `+` and `-` gutters, a spoken word per changed row, word marks inside a changed pair, line numbers on each side. Unified below `md`, side by side from it, by `@container`. Two texts or a unified patch — one parser to rows.
468
+
469
+ | Prop | Type | Required | Notes |
470
+ |---|---|---|---|
471
+ | `label` | `string` | — | Already-translated name of the scroll region; defaults to `ui.diff.region`. |
472
+ | `maxLines` | `number` | — | Rows past this fold into one line saying how many. Defaults to 2,000. |
473
+ | `class` | `string` | — | |
474
+ | `old` | `string` | — | |
475
+ | `new` | `string` | — | |
476
+ | `patch` | `string` | — | A unified patch, as `git diff` writes it. |
477
+
328
478
  ### Divider
329
479
 
330
480
  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.
@@ -354,7 +504,7 @@ Parts of one whole — traffic by device, runs by outcome — as a ring of segme
354
504
 
355
505
  ### Drawer
356
506
 
357
- 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.
507
+ 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. `side="block-end"` is the bottom sheet: with `grabber`, a drag from the grabber (only — the body keeps its scroll) moves it between `half` and `full` or closes it (`drawer-gesture-view.ts`).
358
508
 
359
509
  | Prop | Type | Required | Notes |
360
510
  |---|---|---|---|
@@ -365,6 +515,8 @@ Edge-anchored panel, also a native <dialog> so it inherits the top layer and ine
365
515
  | `side` | `DrawerSide` | — | |
366
516
  | `size` | `string` | — | |
367
517
  | `footer` | `JSX.Element` | — | |
518
+ | `grabber` | `boolean` | — | `block-end` only: a handle to drag the sheet by. The drag has no keyboard form, so the close button stays; Esc closes as ever. |
519
+ | `snap` | `DrawerSnap` | — | The height a `grabber` sheet opens at: `half` (the default) or `full` of the viewport. |
368
520
  | `class` | `string` | — | |
369
521
 
370
522
  ### Dropzone
@@ -474,14 +626,16 @@ Form shell. Owns the one thing every form needs and always forgets: a top-of-for
474
626
 
475
627
  ### Gauge
476
628
 
477
- 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.
629
+ 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. `null` is unknown: the track alone and `ui.chart.none`, never an empty dial that reads as zero. Thresholds pick the tone; a target is a tick.
478
630
 
479
631
  | Prop | Type | Required | Notes |
480
632
  |---|---|---|---|
481
633
  | `label` | `string` | yes | What is measured, already translated: shown under the dial, and the meter's name. |
482
- | `value` | `number` | yes | |
634
+ | `value` | `number \| null` | yes | `null` is unknown: the track alone, and the readout says so. |
483
635
  | `max` | `number` | yes | |
484
- | `tone` | `Tone` | — | The fill's colour role. Default `accent`; a caller picks `warning`/`danger` by threshold. |
636
+ | `tone` | `Tone` | — | The fill's colour role below every threshold. Default `accent`. |
637
+ | `thresholds` | `readonly GaugeThreshold[]` | — | Ascending by `at` (the value's unit): the highest one reached sets the fill's tone. |
638
+ | `target` | `number` | — | A tick across the ring at this value — the goal the fill is read against. |
485
639
  | `format` | `((value: number, max: number) => string)` | — | The readout, from the value and max. Default: the share as a percentage in the page's locale. |
486
640
  | `class` | `string` | — | Lands on the root `<figure>`. |
487
641
 
@@ -499,6 +653,17 @@ Grid layout primitive. The default is an intrinsic responsive grid (`auto-fit` +
499
653
  | `as` | `'div' \| 'ul' \| 'ol' \| 'section'` | — | |
500
654
  | `class` | `string` | — | |
501
655
 
656
+ ### HeatStrip
657
+
658
+ Rows of status cells — a check per five minutes, a service per row — as HTML, not a chart: each cell is one element named by its whole reading, shaped by its tone, and a link when it has a page. Arrow keys never: a linked cell is a tab stop like any link, an unlinked one is none.
659
+
660
+ | Prop | Type | Required | Notes |
661
+ |---|---|---|---|
662
+ | `label` | `string` | yes | The strip's name, already translated: its caption. |
663
+ | `rows` | `readonly HeatRow[]` | yes | One row per thing watched; cells oldest first. |
664
+ | `showCaption` | `boolean` | — | Show the caption. Default off — it names the figure for assistive tech either way. |
665
+ | `class` | `string` | — | |
666
+
502
667
  ### Icon
503
668
 
504
669
  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.
@@ -601,6 +766,17 @@ Single-line text control. No `type="number"` convenience wrapper: numeric input
601
766
  | `onChange` | `JSX.EventHandlerUnion<HTMLInputElement, Event>` | — | |
602
767
  | `onBlur` | `JSX.EventHandlerUnion<HTMLInputElement, FocusEvent>` | — | |
603
768
 
769
+ ### JsonView
770
+
771
+ Any value as nested native `<details>`: keyboard and no-script for free, keys in the order given, long strings wrapped. A webhook body, an event, a tool call — whatever arrived prints as text.
772
+
773
+ | Prop | Type | Required | Notes |
774
+ |---|---|---|---|
775
+ | `value` | `unknown` | yes | |
776
+ | `label` | `string` | yes | Already-translated name of the whole value. |
777
+ | `open` | `number` | — | Levels open on first paint; deeper containers start closed. Defaults to 1. |
778
+ | `class` | `string` | — | |
779
+
604
780
  ### Kbd
605
781
 
606
782
  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.
@@ -618,7 +794,9 @@ A line chart of one or more series over the same keys — signups per day, p95 p
618
794
  |---|---|---|---|
619
795
  | `label` | `string` | yes | The chart's name, already translated: the figure's caption and the svg's accessible name. |
620
796
  | `keys` | `readonly string[]` | yes | The x axis, oldest first, already formatted for display — "17 Sep", "14:00". |
621
- | `series` | `readonly ChartSeries[]` | yes | One entry per line. `values[i]` belongs to `keys[i]`; `null` is a gap, never a zero. |
797
+ | `series` | `readonly ChartSeries[]` | yes | One entry per line, exactly one value per key; `null` is a gap, never a zero. |
798
+ | `at` | `readonly number[]` | — | True x positions, one per key, strictly ascending — `row.at.getTime()` for a time axis. Absent, keys are evenly spaced, which is honest only for regular samples. |
799
+ | `references` | `readonly ChartReference[]` | — | Dashed rules at a value — a target, an SLO. Each widens the axis so it stays on the plot. |
622
800
  | `keyLabel` | `string` | — | The key column's head in the data table, already translated — "Day". |
623
801
  | `format` | `((value: number) => string)` | — | Formats every value and tick. Default: `Intl.NumberFormat` in the page's locale. |
624
802
  | `zero` | `boolean` | — | Start the value axis at zero. Default on; off fits the axis to the data. |
@@ -634,13 +812,16 @@ A line chart of one or more series over the same keys — signups per day, p95 p
634
812
  |---|---|---|---|
635
813
  | `label` | `string` | yes | The chart's name, already translated: the figure's caption and the svg's accessible name. |
636
814
  | `keys` | `readonly string[]` | yes | The x axis, oldest first, already formatted for display — "17 Sep", "14:00". |
637
- | `series` | `readonly ChartSeries[]` | yes | One entry per line. `values[i]` belongs to `keys[i]`; `null` is a gap, never a zero. |
815
+ | `series` | `readonly ChartSeries[]` | yes | One entry per line, exactly one value per key; `null` is a gap, never a zero. |
816
+ | `at` | `readonly number[]` | — | True x positions, one per key, strictly ascending — `row.at.getTime()` for a time axis. Absent, keys are evenly spaced, which is honest only for regular samples. |
817
+ | `references` | `readonly ChartReference[]` | — | Dashed rules at a value — a target, an SLO. Each widens the axis so it stays on the plot. |
638
818
  | `keyLabel` | `string` | — | The key column's head in the data table, already translated — "Day". |
639
819
  | `format` | `((value: number) => string)` | — | Formats every value and tick. Default: `Intl.NumberFormat` in the page's locale. |
640
820
  | `zero` | `boolean` | — | Start the value axis at zero. Default on; off fits the axis to the data. |
641
821
  | `focusable` | `boolean` | — | Give every point a tab stop and a hover/focus readout. Default off: no tab stops. |
642
822
  | `showCaption` | `boolean` | — | Show the caption above the plot. Default off — it names the figure either way. |
643
823
  | `class` | `string` | — | Lands on the root `<figure>`. |
824
+ | `stacked` | `boolean` | — | Each series' band sits on the series before it; the axis reads the total. Default off. |
644
825
 
645
826
  ### Link
646
827
 
@@ -666,6 +847,15 @@ Anchor primitive. External links get `rel` hardening and a translated "opens in
666
847
  | `iconStart` | `JSX.Element` | — | `appearance="button"` only. |
667
848
  | `iconEnd` | `JSX.Element` | — | `appearance="button"` only. |
668
849
 
850
+ ### LiveIndicator
851
+
852
+ Whether a live view is live: a static dot whose SHAPE is the state, with the word beside it — live, reconnecting, paused, offline. Never blinks: a pulse is motion a reader cannot stop and says nothing the word does not. A change of state is announced once; a tick never is.
853
+
854
+ | Prop | Type | Required | Notes |
855
+ |---|---|---|---|
856
+ | `state` | `LiveIndicatorState` | yes | |
857
+ | `class` | `string` | — | |
858
+
669
859
  ### LocaleSwitcher
670
860
 
671
861
  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.
@@ -680,6 +870,31 @@ Locale picker. Option labels come from `Intl.DisplayNames` in each locale's own
680
870
  | `defaultLocale` | `Locale` | — | The unprefixed locale. Defaults to `locales[0]`, which is where `routedLocales()` puts it. |
681
871
  | `class` | `string` | — | |
682
872
 
873
+ ### LogViewer
874
+
875
+ Structured log rows on `VirtualList`: a 100,000-line run mounts a screenful, follows the end while the reader is there, and keeps only the newest `cap` lines. With script, a text filter, a level floor and a follow toggle; without it, every line given is in the document.
876
+
877
+ | Prop | Type | Required | Notes |
878
+ |---|---|---|---|
879
+ | `lines` | `readonly LogLine[]` | yes | |
880
+ | `label` | `string` | — | Already-translated name of the log; defaults to `ui.log.region`. |
881
+ | `follow` | `boolean` | — | Start following the end. Defaults to true; the reader can turn it off. |
882
+ | `cap` | `number` | — | The newest lines kept. Defaults to 10,000. |
883
+ | `class` | `string` | — | |
884
+
885
+ ### Markdown
886
+
887
+ What a model or a person wrote, rendered: text → tree → elements, never HTML. Raw HTML, a `javascript:` link and a remote image all print inert. Re-parsed per change and keyed by block index, so a streaming body keeps every finished block's node and only the open one redraws.
888
+
889
+ | Prop | Type | Required | Notes |
890
+ |---|---|---|---|
891
+ | `text` | `string` | yes | The body. Malformed markdown is never an error: what cannot parse prints as text. |
892
+ | `baseLevel` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | — | The level a `#` renders at. Defaults to 2: a body never adds a second `h1` to a page. |
893
+ | `resolveImage` | `((src: string) => ResolvedImage \| null)` | — | Vouch for an image: its served source and box, or `null` to print its alt as a link. |
894
+ | `headingIds` | `boolean` | — | Give each heading the id the GitHub wiki would (`headingSlug`), de-duplicated `-1`, `-2` in order, so a `#anchor` link scrolls. Off by default: two bodies on one page would collide. |
895
+ | `languages` | `readonly Language[]` | — | Languages fences may highlight with. Absent: fences are plain text, and an island pays 0 B. |
896
+ | `class` | `string` | — | |
897
+
683
898
  ### Menu
684
899
 
685
900
  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.
@@ -704,6 +919,7 @@ A thin horizontal bar showing one value against a maximum — a table cell's "ho
704
919
  | `max` | `number` | yes | |
705
920
  | `label` | `string` | — | The accessible name. Omit for a purely decorative bar beside a visible number. |
706
921
  | `tone` | `Tone` | — | |
922
+ | `target` | `number` | — | A tick at this value — a bullet bar: the fill read against its goal. |
707
923
  | `class` | `string` | — | |
708
924
 
709
925
  ### Money
@@ -720,6 +936,16 @@ Formats through @ultimat3/money using the locale and currency from context — n
720
936
  | `format` | `MoneyFormatter` | — | |
721
937
  | `class` | `string` | — | |
722
938
 
939
+ ### NavList
940
+
941
+ The ways around an app: links only (a command is `Menu`'s), in groups, one marked current by `currentOf` — the longest href covering the page. Server-rendered and scriptless; a collapsible group is a `<details>`, open when it holds the current page. Renders lists, not a `<nav>`: it sits in `AppShell`'s sidebar, which is already the navigation landmark, and a second one inside it would announce the same region twice.
942
+
943
+ | Prop | Type | Required | Notes |
944
+ |---|---|---|---|
945
+ | `items` | `readonly NavListEntry[]` | yes | |
946
+ | `currentHref` | `string` | yes | The page's own path — `currentOf` decides which one link carries `aria-current="page"`. |
947
+ | `class` | `string` | — | |
948
+
723
949
  ### PageHeader
724
950
 
725
951
  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.
@@ -736,20 +962,23 @@ The top of a screen: breadcrumbs, the page's one heading, a description, and the
736
962
 
737
963
  ### Pagination
738
964
 
739
- 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.
965
+ Cursor-first pagination — the only shape a Postgres-backed list should use; numbered mode is for prerendered archives with a known total. One mode per use: callbacks for an island, or `hrefFor` anchors with no script. Keyset paging has no way back, so Previous is drawn only when there is one, and `firstHref`/`onFirst` restart the list.
740
966
 
741
967
  | Prop | Type | Required | Notes |
742
968
  |---|---|---|---|
743
- | `nextCursor` | `string` | — | Opaque cursor for the following page; absent disables "next". |
744
- | `prevCursor` | `string` | — | |
969
+ | `nextCursor` | `string \| null` | — | Opaque cursor for the following page; `null` or absent disables "next" — one state, so a framework `Page`'s `nextCursor` is handed straight through. |
970
+ | `prevCursor` | `string \| null` | — | The cursor back. A keyset `Page` has none, and with none no Previous is drawn at all. |
745
971
  | `labelPrevious` | `string` | — | Already-translated; falls back to the ui.* catalog keys. |
746
972
  | `labelNext` | `string` | — | |
973
+ | `labelFirst` | `string` | — | |
747
974
  | `class` | `string` | — | |
748
975
  | `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | Callback mode. Never beside `hrefFor` — the type refuses the pair. |
749
976
  | `page` | `number` | — | Numbered mode: 1-based page and total. Ignored when cursors are present. |
750
977
  | `totalPages` | `number` | — | |
751
978
  | `onPage` | `((page: number) => void)` | — | |
979
+ | `onFirst` | `(() => void)` | — | Back to the start: a "First" button. Callback mode's `firstHref`. |
752
980
  | `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`. |
981
+ | `firstHref` | `string` | — | The URL of the first page: a "First" anchor. Absent — already there — draws none. |
753
982
 
754
983
  ### Popover
755
984
 
@@ -766,6 +995,17 @@ Non-modal anchored panel. Positioning is CSS-only (a relatively positioned ancho
766
995
  | `align` | `'start' \| 'center' \| 'end'` | — | |
767
996
  | `class` | `string` | — | |
768
997
 
998
+ ### PullToRefresh
999
+
1000
+ Pull down at the top of a list to refresh it — the gesture an installed app has no browser to give it. No refresh of its own: `onRefresh` is the caller's (`refresh()` from the page's router, usually), so `ui` never imports `render`. Touch only, and only at the top: a pull mid-list is the page's scroll, and a fine pointer gets nothing — the caller's toolbar button is the way there, and the way for every keyboard. Inert with scripting off; the scroll position stays.
1001
+
1002
+ | Prop | Type | Required | Notes |
1003
+ |---|---|---|---|
1004
+ | `onRefresh` | `() => Promise<void>` | yes | Re-reads the content. Its settling — resolved or rejected — ends the busy state. |
1005
+ | `onError` | `(error: unknown) => void` | yes | A rejected refresh, after the busy state ended. Required: a failed refresh is never silent. |
1006
+ | `children` | `JSX.Element` | yes | |
1007
+ | `class` | `string` | — | |
1008
+
769
1009
  ### QrCode
770
1010
 
771
1011
  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`.
@@ -790,6 +1030,7 @@ Radio group. Rendered as a <fieldset>/<legend> so the group has a name, and as o
790
1030
  | `disabled` | `boolean` | — | |
791
1031
  | `required` | `boolean` | — | |
792
1032
  | `direction` | `'row' \| 'column'` | — | |
1033
+ | `appearance` | `'default' \| 'segmented'` | — | `segmented` draws the options as one joined strip of buttons — still native radios, so arrows, a form post and scripting off are the platform's. One prop, not a second component. |
793
1034
  | `class` | `string` | — | |
794
1035
  | `aria-describedby` | `string` | — | |
795
1036
  | `onChange` | `JSX.EventHandlerUnion<HTMLInputElement, Event>` | — | |
@@ -808,6 +1049,60 @@ Radio group. Rendered as a <fieldset>/<legend> so the group has a name, and as o
808
1049
  | `format` | `DateTimeFormatter` | — | |
809
1050
  | `class` | `string` | — | |
810
1051
 
1052
+ ### SearchableSelect
1053
+
1054
+ One — or many — of a long, closed list, chosen by typing, where the posted value is not the label. Without script it IS a native `<select>` (`multiple`, `<optgroup>`), which posts the same `name` and values; with script an effect swaps in a WAI-ARIA combobox whose text is never posted: hidden inputs carry the chosen values, so a half-typed name cannot reach the server.
1055
+
1056
+ | Prop | Type | Required | Notes |
1057
+ |---|---|---|---|
1058
+ | `name` | `string` | yes | The form field name every chosen value posts under. |
1059
+ | `options` | `readonly SearchableOption[]` | yes | Unique by `value`; a group's options adjacent. Chosen values must be among them. |
1060
+ | `onSearch` | `((query: string) => void)` | — | The caller searches: called `debounceMs` after typing stops, and hands back `options`. The list is then shown as given, never filtered again here; keep the chosen options in it. |
1061
+ | `debounceMs` | `number` | — | Debounce window for `onSearch`, in ms. Read once. |
1062
+ | `limit` | `number` | — | Rows shown at most; past it the control says "first N of M". Default `COMBOBOX_LIMIT`. |
1063
+ | `placeholder` | `string` | — | Already translated: the no-script placeholder option and the empty field's hint. |
1064
+ | `id` | `string` | — | Lands on whichever control is live, so `Field`'s `<label for>` names it in both modes. |
1065
+ | `required` | `boolean` | — | |
1066
+ | `disabled` | `boolean` | — | |
1067
+ | `size` | `Size` | — | |
1068
+ | `class` | `string` | — | |
1069
+ | `aria-label` | `string` | — | |
1070
+ | `aria-describedby` | `string` | — | |
1071
+ | `aria-invalid` | `boolean` | — | |
1072
+ | `multiple` | `false \| true` | — | |
1073
+ | `value` | `string \| readonly string[]` | — | The chosen value; absent is nothing chosen. |
1074
+ | `onChange` | `((value: string) => void) \| ((values: readonly string[]) => void)` | — | |
1075
+
1076
+ ### SearchableSelectChips
1077
+
1078
+ The chosen values of a many-valued SearchableSelect, as removable chips: each a label and one button naming what it removes. Split from `SearchableSelect.tsx` by the 200-line target; it owns no state — every removal is the parent's `onRemove`, so a chip and the posted inputs agree.
1079
+
1080
+ | Prop | Type | Required | Notes |
1081
+ |---|---|---|---|
1082
+ | `chosen` | `readonly SearchableOption[]` | yes | The chosen options, in the order they were chosen. |
1083
+ | `onRemove` | `(value: string) => void` | yes | |
1084
+ | `disabled` | `boolean` | — | |
1085
+
1086
+ ### SearchableSelectNative
1087
+
1088
+ A SearchableSelect with scripting off — and its first client render: the real `<select>`, with `multiple` for many and `<optgroup>` for groups, posting the same `name` and values the enhanced control does. Split from `SearchableSelect.tsx` by the 200-line target; it holds no state.
1089
+
1090
+ | Prop | Type | Required | Notes |
1091
+ |---|---|---|---|
1092
+ | `ref` | `(el: HTMLSelectElement) => void` | yes | |
1093
+ | `name` | `string` | yes | |
1094
+ | `options` | `readonly SearchableOption[]` | yes | |
1095
+ | `chosen` | `readonly string[]` | yes | |
1096
+ | `multiple` | `boolean` | yes | |
1097
+ | `placeholder` | `string` | yes | |
1098
+ | `id` | `string` | yes | |
1099
+ | `required` | `boolean` | yes | |
1100
+ | `disabled` | `boolean` | yes | |
1101
+ | `size` | `Size` | yes | |
1102
+ | `aria-label` | `string` | yes | |
1103
+ | `aria-describedby` | `string` | yes | |
1104
+ | `aria-invalid` | `boolean` | yes | |
1105
+
811
1106
  ### Section
812
1107
 
813
1108
  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.
@@ -842,6 +1137,15 @@ Native <select>. Deliberately not a custom listbox: the platform control wins on
842
1137
  | `aria-invalid` | `boolean` | — | |
843
1138
  | `onChange` | `JSX.EventHandlerUnion<HTMLSelectElement, Event>` | — | |
844
1139
 
1140
+ ### ShortcutSheet
1141
+
1142
+ The page's keyboard legend: a `Dialog` listing every row every island registered, opened by `?` — itself a row of the same table, so the sheet lists exactly the bindings that work. Hidden with scripting off (a shortcut needs a listener) and never offered where nothing hovers: a phone.
1143
+
1144
+ | Prop | Type | Required | Notes |
1145
+ |---|---|---|---|
1146
+ | `title` | `string` | — | The dialog's title and the `?` row's legend. Defaults to the translated `ui.shortcuts`. |
1147
+ | `class` | `string` | — | |
1148
+
845
1149
  ### Skeleton
846
1150
 
847
1151
  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.
@@ -854,14 +1158,52 @@ Loading placeholder. Sized by the caller so the real content lands in the same b
854
1158
  | `lines` | `number` | — | Repeat as stacked lines, e.g. a paragraph placeholder. |
855
1159
  | `class` | `string` | — | |
856
1160
 
1161
+ ### Slider
1162
+
1163
+ A bounded number: a native `<input type="range">` with its label and an `<output>` bound to it. The platform owns the thumb, the keyboard and the touch; the paint is tokens (`accent-color`). No thumb script — a kit without one sends an agent to a div. Rules: `slider-view.ts`.
1164
+
1165
+ | Prop | Type | Required | Notes |
1166
+ |---|---|---|---|
1167
+ | `name` | `string` | yes | |
1168
+ | `label` | `string` | yes | Already-translated visible label. |
1169
+ | `id` | `string` | — | |
1170
+ | `min` | `number` | — | |
1171
+ | `max` | `number` | — | |
1172
+ | `step` | `number` | — | |
1173
+ | `value` | `number` | — | |
1174
+ | `format` | `((value: number) => string)` | — | The output's text and the control's `aria-valuetext`, in the caller's locale and unit. |
1175
+ | `disabled` | `boolean` | — | |
1176
+ | `aria-describedby` | `string` | — | |
1177
+ | `onInput` | `((value: number) => void)` | — | |
1178
+ | `class` | `string` | — | |
1179
+
1180
+ ### SortableList
1181
+
1182
+ A list a person reorders by hand: a handle per row that lifts by pointer, touch or keyboard, and a drop that REQUESTS a move through `onMove` — the caller's action decides. With scripting off each row carries Move up and Move down as real form buttons posting to `action`.
1183
+
1184
+ | Prop | Type | Required | Notes |
1185
+ |---|---|---|---|
1186
+ | `items` | `readonly T[]` | yes | |
1187
+ | `itemKey` | `(item: T) => string` | yes | Unique per item; the row keeps its node under it across live updates. |
1188
+ | `renderItem` | `(item: T) => JSX.Element` | yes | The row's look is the caller's: a `Card`, a line of text. |
1189
+ | `label` | `string` | yes | Already-translated name of the list, read with every announcement. |
1190
+ | `onMove` | `(move: SortMove) => unknown` | yes | Called once per drop that moves something. A rejection puts the row back and says so. |
1191
+ | `canDrop` | `CanDrop<T>` | — | |
1192
+ | `action` | `string` | — | The action URL the no-script Up and Down buttons post to. Without it, no fallback renders. |
1193
+ | `fields` | `SortFields` | — | The field names those buttons post: `id`, `fromGroup`, `toGroup`, `toIndex` by default. |
1194
+ | `group` | `string` | — | The id `onMove` names as `fromGroup` and `toGroup`. Defaults to `list`. |
1195
+ | `class` | `string` | — | |
1196
+
857
1197
  ### Sparkline
858
1198
 
859
- 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`.
1199
+ One series as a single line with no axes — the trend beside a figure, in a table cell, on a StatTile. 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. Signed through zero; the path and the area come from `sparkline-view.ts`.
860
1200
 
861
1201
  | Prop | Type | Required | Notes |
862
1202
  |---|---|---|---|
863
1203
  | `label` | `string` | yes | The accessible name for the whole chart, already translated. |
864
- | `points` | `readonly ChartPoint[]` | yes | Oldest first. Every point comes from exactly this array — the caller zero-fills gaps. |
1204
+ | `points` | `readonly SparklinePoint[]` | yes | Oldest first. `value: null` is unknown — a gap in the line, never a zero. |
1205
+ | `tone` | `Tone` | — | The line's colour role, in place of `accent`. |
1206
+ | `area` | `boolean` | — | Fill under the line, down to zero — above it for a gain, below it for a loss. |
865
1207
  | `class` | `string` | — | |
866
1208
 
867
1209
  ### Spinner
@@ -875,6 +1217,22 @@ Indeterminate progress. `role="status"` plus a translated name, because a spinne
875
1217
  | `decorative` | `boolean` | — | Hide from assistive tech when a parent already announces the busy state. |
876
1218
  | `class` | `string` | — | |
877
1219
 
1220
+ ### SplitPane
1221
+
1222
+ Two panes and a `role="separator"` between them, its value a percentage of the start pane. Arrows step it, Home and End go to the bounds, a pointer drags it, Esc mid-drag restores. Below `md` the panes stack and the separator is gone: no gesture on a phone. Storage is the caller's.
1223
+
1224
+ | Prop | Type | Required | Notes |
1225
+ |---|---|---|---|
1226
+ | `start` | `JSX.Element` | yes | |
1227
+ | `end` | `JSX.Element` | yes | |
1228
+ | `value` | `number` | — | Controlled: the start pane's share in percent. Pair it with `onResize`. |
1229
+ | `defaultValue` | `number` | yes | Where the split sits before any input, and with scripting off. |
1230
+ | `min` | `number` | — | |
1231
+ | `max` | `number` | — | |
1232
+ | `onResize` | `((percent: number) => void)` | — | Each change, in percent. Persisting it is the caller's: storage is never ambient. |
1233
+ | `label` | `string` | — | Already-translated name of the separator; defaults to `ui.pane.resize`. |
1234
+ | `class` | `string` | — | |
1235
+
878
1236
  ### Stack
879
1237
 
880
1238
  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"`.
@@ -892,7 +1250,7 @@ Flex layout primitive. Gap comes from the space scale as a custom property, so t
892
1250
 
893
1251
  ### StatTile
894
1252
 
895
- 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.
1253
+ One labelled figure with an optional trend chip, a sparkline and a line of context — the tile a dashboard's top row is made of. The figure arrives formatted (the caller's `Intl.NumberFormat`), and whether up is good is the caller's one prop (`goodWhen`): the arrow always shows the way, the colour only what the caller said it means. Intrinsic tags only: a runtime-chosen root (`const Tag = props.as ?? 'div'`) is called as a component by the island build, so a linked tile is a second literal `<a>`, never a variable tag.
896
1254
 
897
1255
  | Prop | Type | Required | Notes |
898
1256
  |---|---|---|---|
@@ -900,9 +1258,26 @@ One labelled figure with an optional trend chip and a line of context — the ti
900
1258
  | `value` | `string` | yes | The figure, already formatted as the string this tile shows. |
901
1259
  | `stat` | `string` | yes | The stable hook a test or a live check reads the figure through: `data-stat`. |
902
1260
  | `delta` | `StatDelta` | — | The trend chip; `deltaOf()` builds one from a current and a baseline. |
1261
+ | `goodWhen` | `StatGoodWhen` | — | Which direction of `delta` is good news. Default `'up'`; `'none'` colours neither. |
1262
+ | `of` | `string` | — | The denominator, formatted — "of 40" reads after the figure: "12 of 40". |
1263
+ | `trend` | `readonly SparklinePoint[]` | — | The recent series behind the figure, oldest first; `null` is a gap. |
1264
+ | `tone` | `Tone` | — | A status edge on the tile — `danger` for a figure past its limit. |
1265
+ | `href` | `string` | — | The page behind the figure: the whole tile becomes one link. |
1266
+ | `loading` | `boolean` | — | The figure is on its way: a skeleton in its box, so the row does not shift when it lands. |
903
1267
  | `hint` | `string` | — | One short line of context under the figure: "vs yesterday", "849 clicks". |
904
1268
  | `class` | `string` | — | |
905
1269
 
1270
+ ### Stepper
1271
+
1272
+ Progress through a multi-step flow: an `<ol>`, the current step `aria-current="step"`, a passed step a link (its `href`) or a button (`onSelect`) and an upcoming one plain text. Exactly one current, by `stepperModel`. Below `sm` the list folds into one line, "Step {n} of {total}: Details".
1273
+
1274
+ | Prop | Type | Required | Notes |
1275
+ |---|---|---|---|
1276
+ | `steps` | `readonly StepperStep[]` | yes | |
1277
+ | `label` | `string` | yes | Already-translated name of the list — "Checkout progress". |
1278
+ | `onSelect` | `((id: string) => void)` | — | A passed step with no `href` becomes a button that calls this. |
1279
+ | `class` | `string` | — | |
1280
+
906
1281
  ### Switch
907
1282
 
908
1283
  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.
@@ -937,16 +1312,30 @@ Presentational table. Owns what every hand-rolled table gets wrong: a sticky hea
937
1312
 
938
1313
  ### Tabs
939
1314
 
940
- 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`.
1315
+ 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`. Link mode (`hrefFor`, no `onChange`) is a `<nav>` of links with `aria-current`, never `role="tab"`: ARIA tabs promise panels on this page, and a link tab's panel is the next page.
941
1316
 
942
1317
  | Prop | Type | Required | Notes |
943
1318
  |---|---|---|---|
944
- | `items` | `readonly TabItem[]` | yes | |
1319
+ | `items` | `readonly TabItem[] \| readonly TabLinkItem[]` | yes | |
945
1320
  | `value` | `string` | yes | Selected tab id. Controlled — the caller owns the state. |
946
- | `onChange` | `(id: string) => void` | yes | |
1321
+ | `onChange` | `(id: string) => void` | — | |
947
1322
  | `label` | `string` | yes | Already-translated accessible name for the tablist. |
948
1323
  | `orientation` | `'horizontal' \| 'vertical'` | — | |
949
1324
  | `class` | `string` | — | |
1325
+ | `hrefFor` | `(id: string) => string` | — | Each tab's URL. Server-rendered links: no script, and the back button works. |
1326
+ | `children` | `JSX.Element` | — | The current tab's content, rendered under the links. |
1327
+
1328
+ ### TerminalView
1329
+
1330
+ One styled stream of terminal output on `VirtualList`: SGR colours as roles, every other escape dropped, wrapped by default, left-to-right under `dir="rtl"`. A focusable region where Home and End jump. Output only: keystrokes are an app's island over its own transport.
1331
+
1332
+ | Prop | Type | Required | Notes |
1333
+ |---|---|---|---|
1334
+ | `text` | `string` | yes | Raw output, escapes and all. |
1335
+ | `label` | `string` | — | Already-translated name of the region; defaults to `ui.code.terminal`. |
1336
+ | `rows` | `number` | — | Lines tall, before it scrolls. Defaults to 24, a terminal's own. |
1337
+ | `follow` | `boolean` | — | Follow the end as output arrives. Defaults to true. |
1338
+ | `class` | `string` | — | |
950
1339
 
951
1340
  ### Text
952
1341
 
@@ -995,6 +1384,28 @@ Theme control. `toggle` flips light/dark; `select` also offers "system", which c
995
1384
  | `env` | `ThemeEnv` | — | Injectable for tests and for non-DOM hosts. |
996
1385
  | `class` | `string` | — | |
997
1386
 
1387
+ ### TimeRangePicker
1388
+
1389
+ "This metric over that window": the app's presets as links, and a custom window as a GET form of two `datetime-local` inputs in a `<details>`. Links and a form, so 0 B of script on a static route; the URL is the state (`timeRangeUrl`), so a window survives reload, share and paging.
1390
+
1391
+ | Prop | Type | Required | Notes |
1392
+ |---|---|---|---|
1393
+ | `range` | `TimeRangeUrl` | yes | The window the URL holds, read by `timeRangeUrl` in the loader. |
1394
+ | `timeZone` | `TimeZone` | yes | The zone the custom window's wall-clock times are in. Required, and it must be the one the window was read in: there is no ambient zone, and a mismatch would post a different window. |
1395
+ | `label` | `string` | yes | Already-translated name for the control. |
1396
+ | `class` | `string` | — | |
1397
+
1398
+ ### Timeline
1399
+
1400
+ What happened, in order: when, what, a marker and a tone. An `<ol>` because the order IS the content; day headers come from `groupBy`, already formatted. Server-only, no signal: an activity feed is this plus `InfiniteScroll` and `RelativeTime`, never a component of its own.
1401
+
1402
+ | Prop | Type | Required | Notes |
1403
+ |---|---|---|---|
1404
+ | `items` | `readonly TimelineItem[]` | yes | |
1405
+ | `label` | `string` | yes | Already-translated accessible name for the list. |
1406
+ | `groupBy` | `((item: TimelineItem) => string)` | — | A day header per run of items: answer a label already formatted in the caller's zone. |
1407
+ | `class` | `string` | — | |
1408
+
998
1409
  ### ToastRegion
999
1410
 
1000
1411
  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.
@@ -1059,6 +1470,62 @@ Supplementary hint, shown on hover AND on keyboard focus. CSS-only, so it costs
1059
1470
  | `placement` | `'block-start' \| 'block-end'` | — | |
1060
1471
  | `class` | `string` | — | |
1061
1472
 
1473
+ ### Tree
1474
+
1475
+ Files, folders, org charts: one node list, two renderings. With `hrefFor` it is nested `<ul>` and `<details>` with links — open and close are the platform's, 0 B of script. With `onSelect` it is an APG `role="tree"` with one tab stop, arrows, type-ahead and lazy children: an island's.
1476
+
1477
+ | Prop | Type | Required | Notes |
1478
+ |---|---|---|---|
1479
+ | `nodes` | `readonly TreeNode[]` | yes | |
1480
+ | `label` | `string` | yes | Already-translated accessible name. |
1481
+ | `selected` | `string` | — | The current node: `aria-current="page"` in link mode, `aria-selected` in select mode. Its ancestors open. |
1482
+ | `open` | `readonly string[]` | — | Branches open on first render, beside the selected node's ancestors. |
1483
+ | `class` | `string` | — | |
1484
+ | `hrefFor` | `(node: TreeNode) => string` | — | |
1485
+ | `onSelect` | `(id: string) => void` | — | |
1486
+ | `onExpand` | `((id: string) => Promise<readonly TreeNode[]>)` | — | |
1487
+
1488
+ ### VirtualList
1489
+
1490
+ A long list that mounts only what is on screen: a real `<ul>` in a scroll box, padded above and below by what is not drawn. Server-rendered, and with scripting off, every row renders — the document carries the whole list. The arithmetic is `virtual-list-view.ts`; this file is wiring.
1491
+
1492
+ | Prop | Type | Required | Notes |
1493
+ |---|---|---|---|
1494
+ | `items` | `readonly T[]` | yes | |
1495
+ | `itemKey` | `(item: T) => string` | yes | Unique per item: a visible row keeps its node — and its focus — across a live prepend. |
1496
+ | `renderItem` | `(item: T) => JSX.Element` | yes | |
1497
+ | `estimate` | `number` | — | A row's block size in CSS px until it is measured. A right estimate IS fixed-height mode. |
1498
+ | `overscan` | `number` | — | Rows drawn beyond each edge of the box. |
1499
+ | `follow` | `boolean` | — | Stay at the end as items arrive, while the reader is there; offer a way back when not. |
1500
+ | `label` | `string` | yes | Already-translated accessible name for the list. |
1501
+ | `class` | `string` | — | Size the box here: the list scrolls inside the caller's height, and a box with none renders every row. |
1502
+
1503
+ ### TreeLinkNode
1504
+
1505
+ One node of a `Tree`, in either of its two renderings. Link mode is the platform's: a branch is a `<details>` whose `<summary>` opens it, a leaf is a link — no script at all. Select mode is the APG tree item, which only an island drives; its state arrives as accessors from `Tree`.
1506
+
1507
+ | Prop | Type | Required | Notes |
1508
+ |---|---|---|---|
1509
+ | `node` | `TreeNode` | yes | |
1510
+ | `hrefFor` | `(node: TreeNode) => string` | yes | |
1511
+ | `selected` | `string` | yes | |
1512
+ | `open` | `ReadonlySet<string>` | yes | |
1513
+
1514
+ ### TreeSelectNode
1515
+
1516
+ One node of a `Tree`, in either of its two renderings. Link mode is the platform's: a branch is a `<details>` whose `<summary>` opens it, a leaf is a link — no script at all. Select mode is the APG tree item, which only an island drives; its state arrives as accessors from `Tree`.
1517
+
1518
+ | Prop | Type | Required | Notes |
1519
+ |---|---|---|---|
1520
+ | `node` | `TreeNode` | yes | |
1521
+ | `state` | `(id: string) => TreeItemState` | yes | Level and position, read from the flattened tree. |
1522
+ | `isOpen` | `(id: string) => boolean` | yes | |
1523
+ | `isLoading` | `(id: string) => boolean` | yes | |
1524
+ | `isStop` | `(id: string) => boolean` | yes | The node holding the group's one tab stop. |
1525
+ | `selected` | `string` | yes | |
1526
+ | `childrenOf` | `(node: TreeNode) => readonly TreeNode[]` | yes | Loaded children: the node's own, or what `onExpand` answered for it. |
1527
+ | `onActivate` | `(id: string) => void` | yes | |
1528
+
1062
1529
  ## Tokens
1063
1530
 
1064
1531
  Colour roles are the only colours that exist. Use them through SCSS (`t.role('accent')`) or