@ultimat3/ui 29.4.0 → 30.1.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/README.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  SolidJS design system. Semantic tokens, SCSS modules, dark + RTL by construction.
4
4
 
5
+ `bun add @ultimat3/ui` · guide: [UI Components](https://github.com/developerz-ai/ultimate/wiki/UI-Components)
6
+
7
+ ```tsx
8
+ import { Button } from '@ultimat3/ui';
9
+
10
+ export const Save = (props: { label: string }) => <Button type="submit">{props.label}</Button>;
11
+ ```
12
+
5
13
  **[`CATALOG.md`](CATALOG.md) is the reference** — every component, every prop, every
6
14
  token, generated from source by `bun run catalog` and drift-checked by
7
15
  `catalog.test.ts`. Read it instead of reading `src/`.
@@ -190,6 +198,35 @@ is unsupported it stays a band above the content. A modal off-canvas panel is st
190
198
  Heading levels are props (`headingTag`, `nextHeadingLevel`), so a nested `Section`
191
199
  never skips a level.
192
200
 
201
+ ### Ways around an app
202
+
203
+ | Component | Renders | Rule |
204
+ |---|---|---|
205
+ | `NavList` | `<ul>` of links, groups (a `collapsible` one is a `<details>`), icon and badge | goes places; `Menu` runs commands. Sits in `AppShell`'s `sidebar`, which is the landmark |
206
+ | `BottomNav` | `<nav>` of up to five links, label under icon, safe-area padding | `AppShell bottomNav`: below `md` only, in flow and sticky, so no row hides under it. A sixth item is `X_UI_INVALID_VALUE` |
207
+ | `Tabs` link mode | `<nav>` of `<a>`, `aria-current="page"` | `hrefFor` and no `onChange`: tabs that are URLs. Never `role="tab"` — ARIA tabs promise panels on this page |
208
+ | `Stepper` | `<ol>`, `aria-current="step"` | exactly one `current` step (`stepperModel`, else `X_UI_INVALID_VALUE`); below `sm`, "Step {n} of {total}: Details" (`ui.step`) |
209
+ | `Drawer side="block-end" grabber` | the bottom sheet | drag from the grabber only, snaps `half`/`full`, past half closes (`drawer-gesture-view.ts`); `dvh` caps, safe area. There is no `Sheet` |
210
+ | `PullToRefresh` | wrapper, `overscroll-behavior-y: contain` | touch only, only at the top; `onRefresh` once per pull however many arrive; off for `(pointer: fine)` — a toolbar button is the way for every other pointer |
211
+
212
+ `NavList` and `BottomNav` mark the current link by ONE rule, `currentOf(items, href)`: the
213
+ longest href that is the page or a whole-segment ancestor of it wins, so `/runs/7` marks `/runs`,
214
+ never `/`. The safe area is `t.safe-area-inset(block-end | block-start | inline)` and
215
+ `@include t.safe-area-padding($edge, $base)` from `@ultimat3/ui/tokens`; the page opts in with
216
+ `viewport-fit=cover`.
217
+
218
+ ### Confirmations, shortcuts, the palette
219
+
220
+ | Piece | What it is | Rule |
221
+ |---|---|---|
222
+ | `ConfirmDialog` | a trigger (`commandfor` + `command="show-modal"`) and a modal `<dialog>` holding a real `<form method="post">` | Cancel holds `autofocus`, so Enter straight after open cancels; Esc closes; the backdrop never does. `action` posts natively with no script; `onConfirm` in an island resolves `undefined` (done) or a refusal, shown in the form summary, focused. `confirmLabel` is a verb with no default |
223
+ | `useHotkeys(table)` | `{ keys, label, run }[]` over one `keydown` listener per page (`Symbol.for` slot) | a bare key never fires while typing or with a modifier; `mod+k` (⌘ / Ctrl) fires anywhere; a chord is two keys inside `CHORD_WINDOW_MS`. A binding twice, a chord over two keys or a prefix clash is `X_UI_INVALID_VALUE` |
224
+ | `ShortcutSheet` | a `Dialog` listing every island's rows | opened by `?`, itself a row: the sheet shows exactly the bindings that work. Not offered on `(hover: none)` |
225
+ | `useCommandPalette({ items, onRun, label })` | the open flag, query, active row, focus back to the opener, `mod+k` | spread `.props` into the presentational `CommandPalette`. Items carry `group` and `keywords`; `filterItems` ranks with `rankOptions`, so `cafe` finds `Café` |
226
+
227
+ No binding ships: `/` or `g r` is an app's. A shipped guard, `guards/native-confirm.ts`, refuses
228
+ `confirm(` in an island or under `apps/web/` (`x g guard native-confirm`).
229
+
193
230
  ### Data display
194
231
 
195
232
  Server-rendered, no island and no JS: a chart costs a route nothing until it asks to be focusable.
@@ -198,16 +235,61 @@ table — and every series has a dash and a marker as well as a colour (`chart-1
198
235
 
199
236
  | Component | Renders | Use for |
200
237
  |---|---|---|
201
- | `LineChart` / `AreaChart` | multi-series line on a 1-2-5 axis (filled for `AreaChart`); `null` is a gap | a trend over keys |
202
- | `BarChart` | bars, an optional stacked second series with a legend | a count per key |
238
+ | `LineChart` / `AreaChart` | multi-series line on a 1-2-5 axis (filled for `AreaChart`, `stacked` bands); `null` is a gap; `at` puts irregular samples at their true x | a trend over keys |
239
+ | `BarChart` | signed bars below a real zero line, an optional stacked second series, or n series side by side (`keys` + `series`) | a count per key |
203
240
  | `DonutChart` | ring segments, a centre figure, a legend with value and share | parts of a whole |
204
- | `Gauge` | a 270° `meter` dial with a tone and a visible label | one value against its max |
241
+ | `Gauge` | a 270° `meter` dial; `thresholds` pick the tone, `target` is a tick, `null` reads "no data" | one value against its max |
242
+ | `HeatStrip` | rows of status cells, each one element named by its whole reading, shaped by its tone, a link when it has a page | uptime, checks per bucket |
243
+ | `LiveIndicator` | a static dot whose shape is the state, the word beside it: live, reconnecting, paused, offline; a change is announced once | a live view's connection |
205
244
  | `ChartFrame` | the figure, caption, legend and data table every chart stands in | a custom chart |
206
245
  | `niceTicks` · `chartTable` · `seriesStyle` | axis ticks, the fallback table, a series' dash and marker | building a custom chart |
207
- | `DataTable` | sort, cursor paging, states; `narrow: 'cards'` (default) collapses rows to labelled cards below `sm`; `Column.priority` hides a column behind a per-row disclosure, never losing its value; `stickyFirstColumn` | any list of records |
246
+ | `DataTable` | sort, cursor paging (link mode with `listUrl` needs no island), states; `narrow: 'cards'` (default) collapses rows to labelled cards below `sm`; `Column.priority` hides a column behind a per-row disclosure, never losing its value; `stickyFirstColumn` | any list of records |
208
247
  | `Table` | a captioned table with a sticky header; `stickyFirstColumn` | static tabular data |
209
248
  | `InlineBar` | a caller-formatted figure with a decorative proportional bar | a number in a table cell |
210
- | `StatTile` / `Meter` / `Sparkline` | a KPI with its delta; a bar against a max; an inline mini line | the KPI row |
249
+ | `StatTile` / `Meter` / `Sparkline` | a KPI with its delta (`goodWhen` says which way is good), `trend`, `of`, `href`, `loading`; a bar against a max with a `target` tick; a signed mini line with `area`, where `null` is a gap | the KPI row |
250
+
251
+ **Three facts, three marks.** A negative value hangs below a drawn zero line, `null` draws nothing
252
+ and reads `ui.chart.none` in the table, and zero is a 2px stub. Never zero-fill a gap. A series
253
+ whose `values` length differs from `keys`, an `at` that is not strictly ascending and unsorted
254
+ `thresholds` are `X_UI_INVALID_VALUE`. A series' `tone` replaces its slot colour; its dash and
255
+ marker stay. `deltaOf(current, baseline, { absoluteBelow })` is a signed difference from zero or
256
+ under a small baseline. A live chart takes its data from `useQuery`; `liveWindow(points, next, { max })`
257
+ is the pure trim, and focusable points are keyed (`ChartHit.id`), so a tick keeps focus. A stat
258
+ grid is `Grid minColumn` — two up on a phone — never a second layout name.
259
+
260
+ ```tsx
261
+ import type { SparklinePoint } from '@ultimat3/ui';
262
+ import { deltaOf, Gauge, Grid, liveWindow, StatTile } from '@ultimat3/ui';
263
+
264
+ const recent: readonly SparklinePoint[] = [
265
+ { key: '10:00', value: 2.1 },
266
+ { key: '10:01', value: null },
267
+ ];
268
+ const window = liveWindow(recent, { key: '10:02', value: 2.4 }, { max: 60 });
269
+
270
+ export const health = (
271
+ <Grid minColumn="10rem">
272
+ <StatTile
273
+ label="Error rate"
274
+ value="2.4%"
275
+ stat="error-rate"
276
+ delta={deltaOf(2.4, 2.1)}
277
+ goodWhen="down"
278
+ trend={window}
279
+ />
280
+ <Gauge
281
+ label="CPU"
282
+ value={93}
283
+ max={100}
284
+ target={80}
285
+ thresholds={[
286
+ { at: 70, tone: 'warning' },
287
+ { at: 90, tone: 'danger' },
288
+ ]}
289
+ />
290
+ </Grid>
291
+ );
292
+ ```
211
293
 
212
294
 
213
295
  ```tsx
@@ -224,6 +306,180 @@ table — and every series has a dash and a marker as well as a colour (`chart-1
224
306
  </AppShell>
225
307
  ```
226
308
 
309
+ ### Long lists, trees, timelines and facts
310
+
311
+ `As of 2026-11`. Four shapes of list, none of them a table. Which one: **records with columns →
312
+ `DataTable`; thousands of rows of one shape → `VirtualList`; things inside things → `Tree`; what
313
+ happened, in order → `Timeline`; facts about one thing → `DescriptionList`.**
314
+
315
+ | Component | Renders | No script |
316
+ |---|---|---|
317
+ | `VirtualList({ items, itemKey, renderItem, estimate?, overscan?, follow?, label })` | a real `<ul>` in a scroll box, padded for the rows not drawn; `aria-setsize`/`aria-posinset` hold the true count | every row, server-rendered: the document carries the whole list, so page it |
318
+ | `Tree({ nodes, hrefFor \| onSelect, onExpand?, selected?, open?, label })` | link mode: nested `<ul>` + `<details>` + links, 0 B; select mode: APG `role="tree"`, one tab stop, arrows (left/right follow `dir`), Home/End, type-ahead, lazy children | link mode, fully |
319
+ | `Timeline({ items, groupBy?, label })` | an `<ol>` per day header, a marker per item in its `tone`, a `when` slot for `DateTime`/`RelativeTime` | fully, 0 B |
320
+ | `DescriptionList({ items, layout? })` | `<dl>` with a `<div>` per pair; `columns` stacks below `sm` by its own width | fully, 0 B |
321
+
322
+ - **The box gives a `VirtualList` its height** (`class`): it scrolls inside the caller's box, and a
323
+ box with no height renders every row, never nothing. Rows are measured by one `ResizeObserver`;
324
+ `estimate` is the unseen row's size, so fixed-height mode is a right estimate, not a second API.
325
+ - **A live prepend moves nothing being read**: the row at the box's top edge is held by its DOM
326
+ position, and a focused row keeps its node and its focus (`itemKey`, the `DataTable` rule). With
327
+ `follow`, a reader at the end stays there; one who scrolled up is left alone and offered one
328
+ `ui.list.toEnd` button, scrolled to with `behavior: 'auto'` under `prefers-reduced-motion`.
329
+ - **Measured** (`packages/cli/e2e/ui-virtual-list.e2e.test.ts`, headless Chrome at 390 px): two
330
+ 10,000-row lists mount 65–129 ms and +2.2 MB of heap, with fewer than 100 `<li>` each.
331
+ - **Find in page** cannot find a row that is not drawn: the known cost. A log adds its own filter.
332
+ - A repeated `itemKey`, a repeated tree id or a node inside its own subtree is `X_UI_INVALID_VALUE`
333
+ naming the id. A non-finite `estimate` or `overscan` is `finiteOption`'s refusal.
334
+
335
+ **An activity feed is not a component**: `Timeline` + `InfiniteScroll` + `RelativeTime`.
336
+
337
+ ```tsx
338
+ import { DescriptionList, InfiniteScroll, RelativeTime, Timeline } from '@ultimat3/ui';
339
+
340
+ const events = [
341
+ { id: 'e2', what: 'Published', at: '2026-11-05T09:00:00Z', day: 'Thursday' },
342
+ { id: 'e1', what: 'Drafted', at: '2026-11-04T16:30:00Z', day: 'Wednesday' },
343
+ ];
344
+
345
+ export const activity = (
346
+ <InfiniteScroll hasMore nextHref="/activity?after=e1">
347
+ <Timeline
348
+ label="Activity"
349
+ items={events.map((event) => ({
350
+ key: event.id,
351
+ title: event.what,
352
+ when: <RelativeTime value={event.at} timeZone="Europe/Berlin" />,
353
+ tone: event.what === 'Published' ? 'success' : 'neutral',
354
+ }))}
355
+ groupBy={(item) => events.find((event) => event.id === item.key)?.day ?? ''}
356
+ />
357
+ </InfiniteScroll>
358
+ );
359
+
360
+ export const facts = (
361
+ <DescriptionList
362
+ items={[
363
+ { term: 'Author', detail: 'Ada' },
364
+ { term: 'Status', detail: 'Published' },
365
+ ]}
366
+ />
367
+ );
368
+ ```
369
+
370
+ ### Reordering, boards and panes
371
+
372
+ `As of 2026-11`. One gesture (`drag/pointer-drag.ts`: Pointer Events with capture, never HTML5
373
+ drag events, which fire on no touch screen and have no keyboard form) under three components.
374
+
375
+ | Component | Renders | Keyboard | No script |
376
+ |---|---|---|---|
377
+ | `SortableList({ items, itemKey, renderItem, label, onMove, canDrop?, action?, fields? })` | a `<ul>`, a handle `<button>` per row | Space lifts, arrows move, Space drops, Esc restores; Alt+arrow moves at once | each row a `<form method="post" action>` with Move up and Move down |
378
+ | `Board({ groups, itemKey, renderItem, label, onMove, canDrop?, action?, fields? })` | a `role="group"` column per group, columns snap-scroll sideways, one wide below `sm` | the same, plus the inline arrows across columns (mirrored under `dir="rtl"`) | each card a form with a target `select` |
379
+ | `SplitPane({ start, end, defaultValue, value?, min?, max?, onResize?, label? })` | a grid with a `role="separator"` holding `aria-valuenow` in percent | arrows step 5, Home, End; Esc mid-drag restores | fixed at `defaultValue`; one column below `md` |
380
+
381
+ - **A drop is a request.** `onMove({ id, fromGroup, toGroup, toIndex })` is called once; the
382
+ component holds the preview until it settles, and a rejection puts the row back and says so
383
+ (`ui.sort.cancelled`). The order the list shows is the caller's: optimistic or not, the
384
+ framework's existing write path decides.
385
+ - **`canDrop(item, group)` is the one seam** for a business rule — a WIP limit, a status machine.
386
+ A refused column is skipped by the arrows, never landed in, and left out of the no-script `select`.
387
+ - **Touch scrolls the page from a row's body**: only the handle has `touch-action: none`.
388
+ - **Every step is said** through `announce()`: lift, each move, the drop, a cancel.
389
+ - Two items under one key, in any group, `min` over `max`, or a `value` outside them, is
390
+ `X_UI_INVALID_VALUE` naming it. Storage of a split is the caller's (`onResize` + `value`).
391
+
392
+ ### Reading what agents and machines write
393
+
394
+ `As of 2026-11`. Text → tree → elements, **never HTML**: no viewer, parser or renderer names
395
+ `innerHTML` (`viewers-no-html.test.ts`), so a body from a model can only become text nodes.
396
+
397
+ | Component | Renders | No script |
398
+ |---|---|---|
399
+ | `Markdown({ text, baseLevel?, resolveImage?, languages?, headingIds? })` | CommonMark's working set an agent writes, plus tables, task lists, strikethrough, autolinks, nested lists — not full conformance | fully, 0 B on a server page |
400
+ | `CodeBlock({ code, language?, languages?, label?, wrap?, copy? })` | `<pre><code>` of classed runs, a named LTR scroll region; wrap toggle and `CopyButton` with script | selectable text |
401
+ | `JsonView({ value, label, open? })` | nested native `<details>`, keys in the order given, depth and width capped, cycles named | fully |
402
+ | `DiffView({ old, new } \| { patch }, maxLines?, label? })` | `<table>`, `+`/`-` gutters and a spoken word per changed row, word marks; unified below `md` of its own width, side by side from it | fully; Next change needs script |
403
+ | `LogViewer({ lines, follow?, cap?, label? })` | rows on `VirtualList`, a `<time>` per line, level and text filters, follow toggle | every line given |
404
+ | `TerminalView({ text, rows?, follow?, label? })` | SGR colours as roles on `VirtualList`; every other escape dropped | all text |
405
+
406
+ - **Raw HTML prints as text; a link is followed only for `http`, `https`, `mailto` or a relative
407
+ URL**, anything else prints as its source. A remote image is a tracking pixel: it prints its alt
408
+ as a link unless `resolveImage(src)` answers `{ src, width, height }` (a malformed answer is
409
+ `X_UI_INVALID_VALUE`). `baseLevel` defaults to 2, so a body never adds a second `h1`.
410
+ `headingIds` gives each heading the GitHub wiki's anchor (`headingSlug`, `-1`, `-2` on repeats).
411
+ - **Over a cap the rest is plain text, in linear time**: `MARKDOWN_MAX_CHARS`, nesting caps,
412
+ hostile `*`/`[` bodies (`markdown-parse.test.ts`). Malformed content is never an error.
413
+ - **Languages are passed, never imported by a viewer**: `tsLanguage`, `jsonLanguage`,
414
+ `shellLanguage`, `sqlLanguage`, `htmlLanguage`, `yamlLanguage`, `diffLanguage`, or
415
+ `allLanguages` on a server page. A `Markdown` with none retains no language module in an island
416
+ (`ui-barrel-bytes.test.ts`). `defineLanguage(name, rules, aliases?)` adds one; a rule that can
417
+ match the empty string is `X_UI_INVALID_VALUE`. The joined runs always equal the input.
418
+ - **Colours are roles**: code tokens are text roles measured AA on `bg-soft`; ANSI red, green,
419
+ yellow, blue map to the status roles, magenta and cyan to `chart-5` and `chart-1`, 256-colour
420
+ and truecolor to the default foreground (`contrast-pairs.ts`).
421
+ - **Diff input is two texts or a unified patch**, one parser to rows; Myers, bounded by an edit cap
422
+ past which the middle is a replace; over `maxLines` it folds and says how many.
423
+
424
+ ```tsx
425
+ import { allLanguages, DiffView, JsonView, Markdown } from '@ultimat3/ui';
426
+
427
+ export const body = <Markdown text={'# Notes\n\n<script> is text.'} languages={allLanguages} />;
428
+ export const usage = <JsonView value={{ tokens: 120, cached: true }} label="Usage" />;
429
+ export const change = <DiffView old={'one\ntwo'} new={'one\nthree'} />;
430
+ ```
431
+
432
+ ### Dates, windows and bounded values
433
+
434
+ `As of 2026-11`. The native control first, always: on the server, with scripting off and on a
435
+ coarse pointer the platform's own `<input>` is all there is, and it is the value either way.
436
+
437
+ | Component | Value | With script, on a fine pointer |
438
+ |---|---|---|
439
+ | `Calendar({ value?, range?, month?, min?, max?, isDisabled?, weekStart?, hrefFor \| onSelect })` | `PlainDate` (`YYYY-MM-DD`) | link mode is links (a booking page, 0 B); select mode is an APG grid, one tab stop, arrows follow `dir`, PageUp/PageDown a month, Shift a year, Home/End the week |
440
+ | `DatePicker({ name, value?, min?, max? })` | one `<input type="date">` | a button (F4, Alt+↓) opens a `Calendar` popover; Esc hands focus back |
441
+ | `DateRangePicker({ legend, fromName, toName, from?, to? })` | two `<input type="date">`, GET or POST | one calendar: the first pick starts, the second completes, `aria-selected` over the span |
442
+ | `TimeRangePicker({ range, timeZone, label })` | preset links + a GET form of two `datetime-local` | the same: links and a form, 0 B |
443
+ | `Slider({ name, label, min?, max?, step?, value?, format? })` | `<input type="range">` with a bound `<output>` | the output follows the thumb; paint is `accent-color` |
444
+ | `Radio appearance="segmented"` | native radios as one joined strip | native arrows |
445
+
446
+ - **A date has no zone; a window does.** `DatePicker`/`DateRangePicker` deal in `PlainDate`s.
447
+ `TimeRangePicker` and `timeRangeUrl` deal in instants and REQUIRE `timeZone` — omitting it is a
448
+ type error, and a zone that is not IANA is `X_TIMEZONE_INVALID`. Month and weekday names come from
449
+ `Intl.DateTimeFormat` with `timeZone: 'UTC'` on the plain date: no catalog, no ambient zone. The
450
+ week starts where the locale says (`Intl.Locale#getWeekInfo`: `en-US` Sunday, `de` Monday).
451
+ - **Presets are the app's** (`{ id, label, seconds | from/to }`): none ships, and "this quarter" is
452
+ business. `timeRangeUrl(url, { presets, default, ns?, timeZone, now })` composes `listUrl`, so a
453
+ changed window keeps every other parameter and drops every cursor; a malformed or backwards custom
454
+ window shows the default with `invalid: true`, and the picker says `ui.range.invalid`.
455
+ - Six weeks always, so the box never jumps; a disabled day never holds the tab stop; a cell is
456
+ `--touch-target`, 44 px, and a month fits a 320 px screen. A non-`PlainDate`, `min` after `max`, a
457
+ preset with neither `seconds` nor `from`/`to`, a `Slider` `step` of zero: `X_UI_INVALID_VALUE`.
458
+ - No number stepper (`Input` refuses a number wrapper on purpose) and no segmented component
459
+ (`Radio appearance`); auto-refresh is a `Select` calling `visibleInterval`, a page control.
460
+
461
+ ```tsx
462
+ import { DateRangePicker, TimeRangePicker, timeRangeUrl } from '@ultimat3/ui';
463
+
464
+ const range = timeRangeUrl('/health?range=week', {
465
+ presets: [
466
+ { id: 'day', label: 'Last 24 hours', seconds: 86_400 },
467
+ { id: 'week', label: 'Last 7 days', seconds: 604_800 },
468
+ ],
469
+ default: 'day',
470
+ timeZone: 'Europe/Berlin',
471
+ now: new Date('2026-11-06T12:00:00Z'),
472
+ });
473
+
474
+ export const window = <TimeRangePicker range={range} timeZone="Europe/Berlin" label="Window" />;
475
+
476
+ export const filter = (
477
+ <form method="get" action="/runs">
478
+ <DateRangePicker legend="Created" fromName="from" toName="to" from="2026-11-01" />
479
+ </form>
480
+ );
481
+ ```
482
+
227
483
  ## Icons
228
484
 
229
485
  The set is [Lucide](https://lucide.dev) (ISC), wrapped — not redrawn. Every one of its
@@ -260,6 +516,7 @@ has the behaviour. Each is correct server-rendered, and the script layer only ad
260
516
  |---|---|---|
261
517
  | `Accordion` | `<details>` / `<summary>`; `exclusive` is the native `name` group | `onToggle` notification, after the browser has applied it |
262
518
  | `Combobox` | `<input list>` + `<datalist>` — typing, filtering, keyboard, mobile | `onFilter`, debounced, for a live/server-side query |
519
+ | `SearchableSelect` | the real `<select>` (`multiple`, `<optgroup>`), posting the same `name` and values | a WAI-ARIA combobox over a listbox; hidden inputs post the value, never the typed text |
263
520
  | `InfiniteScroll` | a real `rel="next"` link to the next page | an `IntersectionObserver` sentinel that calls `onLoadMore` and intercepts the click |
264
521
  | `Pagination` with `hrefFor` | `<a rel="next">` / `<a rel="prev">`, one per cursor | none — link mode attaches no handler |
265
522
  | `Link appearance="button"` | an `<a href>` wearing Button's classes | none |
@@ -274,6 +531,28 @@ has the behaviour. Each is correct server-rendered, and the script layer only ad
274
531
  </InfiniteScroll>
275
532
  ```
276
533
 
534
+ **Three questions, three controls.** `Select`: a short closed list. `SearchableSelect`: a long
535
+ closed list where the label is not the value — a customer, a country, a time zone. `Combobox`: free
536
+ text with suggestions. Ranking is `rankOptions` (label prefix, word prefix, substring, over label,
537
+ description and keywords, one `normalizeQuery`); past `limit` the list says "first N of M", and
538
+ `onSearch` hands the query to the caller, who must keep the chosen options in what it returns.
539
+ Two options under one value, a split group and a value naming no option are `X_UI_INVALID_VALUE`.
540
+ Label it with `<label for>` (or `Field`), never a wrapping label.
541
+
542
+ ```tsx
543
+ import type { SearchableOption } from '@ultimat3/ui';
544
+ import { SearchableSelect } from '@ultimat3/ui';
545
+
546
+ const zones: readonly SearchableOption[] = [
547
+ { value: 'Europe/Berlin', label: 'Berlin', group: 'Europe' },
548
+ { value: 'Asia/Tokyo', label: 'Tokyo', group: 'Asia', keywords: ['JST'] },
549
+ ];
550
+
551
+ export const zonePicker = (
552
+ <SearchableSelect id="tz" name="tz" options={zones} value="Asia/Tokyo" required />
553
+ );
554
+ ```
555
+
277
556
  `InfiniteScroll` refuses `hasMore` without a `nextHref` (`X_UI_INVALID_VALUE`): with
278
557
  scripting off the control is a link, and a link needs somewhere to go. `Combobox` filters
279
558
  what it is given by `value` (`filterOptions` — case- and accent-insensitive, prefix first),
@@ -282,29 +561,26 @@ so the list is right on the first paint and after a form round-trip, not only on
282
561
  ### A pager and a button that navigate
283
562
 
284
563
  ```tsx
285
- import { Link, Pagination } from '@ultimat3/ui';
564
+ import { Link, listUrl, Pagination } from '@ultimat3/ui';
286
565
  import type { JSX } from 'solid-js';
287
566
 
288
567
  interface PostsFooterProps {
289
- prevCursor?: string | undefined;
290
- nextCursor?: string | undefined;
568
+ /** The page's own URL: the route hands it to `Page`. */
569
+ url: string;
570
+ /** A framework `Page`'s `nextCursor` — `null` on the last page, handed straight through. */
571
+ nextCursor: string | null;
291
572
  /** Already translated: `t('posts.new')` at the call site. */
292
573
  newLabel: string;
293
574
  }
294
575
 
295
576
  export function PostsFooter(props: PostsFooterProps): JSX.Element {
577
+ const list = listUrl(props.url, { sorts: [] });
296
578
  return (
297
579
  <footer>
298
580
  <Link appearance="button" variant="secondary" size="sm" href="/posts/new">
299
581
  {props.newLabel}
300
582
  </Link>
301
- <Pagination
302
- prevCursor={props.prevCursor}
303
- nextCursor={props.nextCursor}
304
- hrefFor={(cursor, direction) =>
305
- `/posts?${direction === 'next' ? 'after' : 'before'}=${cursor}`
306
- }
307
- />
583
+ <Pagination nextCursor={props.nextCursor} hrefFor={list.hrefFor} firstHref={list.firstHref} />
308
584
  </footer>
309
585
  );
310
586
  }
@@ -316,6 +592,8 @@ export function PostsFooter(props: PostsFooterProps): JSX.Element {
316
592
  | `DataTable` takes the same pair — `hrefFor` or `onCursor` — and hands it to its pager | the type: `DataTableProps<Row>` is `DataTableLinkProps<Row> \| DataTableCallbackProps<Row>` |
317
593
  | a `DataTable` sort header follows the table's mode: an anchor at `sortHrefFor(next)` in link mode, a button calling `onSortChange` in callback mode; a linked table with no `sortHrefFor` renders the header as text | the same union — `sortHrefFor` lives on `DataTableLinkProps`, `onSortChange` on `DataTableCallbackProps` — and `DataTable.test.ts` |
318
594
  | link mode pages by cursor only; a side with no cursor is a disabled button, never an anchor with nowhere to go | `Pagination.test.ts` |
595
+ | a cursor that is `null` or absent is one state; Previous is drawn only for a `prevCursor` or a numbered pager, and `firstHref` / `onFirst` draw First | `Pagination.test.ts` |
596
+ | `listUrl(url, { sorts, ns?, limit? })` is a list's URL state: parsing never throws, any change but a cursor drops the cursor (every cursor on the page under `ns`), and a component-only island retains none of it | `list-url.test.ts`, `scripts/ui-barrel-bytes.test.ts` |
319
597
  | a control that navigates is a `Link`; `Button` has no `href` | `ButtonProps` |
320
598
  | a button-link takes `variant` / `tone` / `size` / `fullWidth` / `iconStart` / `iconEnd`; a text link takes `underline` and `tone: 'accent' \| 'inherit'` | the type: `LinkProps` is `TextLinkProps \| ButtonLinkProps` |
321
599
  | both elements wear ONE set of classes | `buttonClassKeys` (`button-classes.ts`), asked by `Button` and `Link` |
@@ -405,7 +683,7 @@ to render on the server.**
405
683
  | | Server render | Client render |
406
684
  |---|---|---|
407
685
  | The renderer | `@ultimat3/render`'s inert JSX factory — a component is a plain function, called once | Solid, with a reactive graph |
408
- | The runtime | `INERT_SOLID_RUNTIME`, handed out automatically: signals hold, memos recompute on read, effects never run | the real one, registered once: `setSolidRuntime({ createContext, useContext, createSignal, createMemo, createEffect, onCleanup })`, six named imports from `'solid-js'` — never `import * as`, which keeps every export of solid-js in the chunk (14.8 kB minified, measured) |
686
+ | The runtime | `INERT_SOLID_RUNTIME`, handed out automatically: signals hold, memos recompute on read, effects never run | the real one, registered once: `setSolidRuntime({ createContext, useContext, createSignal, createMemo, createEffect, onCleanup, createRoot })`, seven named imports from `'solid-js'` — never `import * as`, which keeps every export of solid-js in the chunk (14.8 kB minified, measured) |
409
687
  | Where `useUi()` reads | the request — `currentLocale()`, `currentTimeZone()`, `useI18n()` | `<UiProvider>`, through Solid's context |
410
688
  | `<UiProvider>` | **throws** `X_UI_RUNTIME_MISSING` | the one injection point |
411
689
 
@@ -447,6 +725,7 @@ import {
447
725
  createContext,
448
726
  createEffect,
449
727
  createMemo,
728
+ createRoot,
450
729
  createSignal,
451
730
  onCleanup,
452
731
  useContext,
@@ -463,15 +742,24 @@ interface Props {
463
742
  readonly tree: JSX.Element;
464
743
  }
465
744
 
466
- export function mount(el: HTMLElement, props: Props): void {
745
+ export function mount(el: HTMLElement, props: Props): () => void {
467
746
  // NOT `await import('solid-js')`: the chunk already carries Solid statically, so the await buys
468
747
  // no bytes and makes `mount` async — and the hydration runtime calls it synchronously.
469
748
  // NOT `import * as solidRuntime` either: a namespace handed to a function cannot be shaken, and
470
- // it costs 14.8 kB of solid-js per island chunk that nothing calls. The six picks are the whole
471
- // contract.
472
- setSolidRuntime({ createContext, useContext, createSignal, createMemo, createEffect, onCleanup });
749
+ // it costs 14.8 kB of solid-js per island chunk that nothing calls. The seven picks are the
750
+ // whole contract; `createRoot` is what lets `DataTable` keep a row across a live update.
751
+ setSolidRuntime({
752
+ createContext,
753
+ useContext,
754
+ createSignal,
755
+ createMemo,
756
+ createEffect,
757
+ onCleanup,
758
+ createRoot,
759
+ });
473
760
  el.textContent = ''; // Solid's `render` APPENDS; the server's shell would stay above this one.
474
- render(
761
+ // Returned: the router disposes the island with it on navigation (X_ISLAND_DISPOSER_MISSING).
762
+ return render(
475
763
  () => (
476
764
  <UiProvider
477
765
  locale={props.locale}
@@ -570,6 +858,7 @@ enforced by tests:
570
858
  |---|---|
571
859
  | a disabled item is in neither the navigable set nor the tab stop (`MENU_ITEM_SELECTOR`, `TAB_SELECTOR`, `tabStopIndex`) | `focus()` on a disabled control is a **no-op**, so a disabled item left in the list pins the reducer on its index and hides everything after it |
572
860
  | the tab stop is the selection, or the first **enabled** item (`tabStopIndex`) | a group whose only tab stop is disabled cannot be entered at all |
861
+ | a tree item has no `disabled`: `TREE_ITEM_SELECTOR` excludes `aria-disabled="true"`, and a calendar's arrow steps over a disabled day (`stepDay`) | the same no-op `focus()`, on the two groups whose items are not form controls |
573
862
  | a control that answers arrows itself keeps them (`handlesOwnArrowKeys`) | `Toolbar` exists to hold a search field, and stealing ArrowRight from it eats the keystroke moving the caret |
574
863
 
575
864
  `Toolbar` is deliberately **not** a single Tab stop: it holds arbitrary children it cannot reach
@@ -601,7 +890,8 @@ binding over an existing `action`, the way `llm()` is a factory over one.
601
890
 
602
891
  ```tsx
603
892
  import { t } from '@ultimat3/i18n';
604
- import { Field, Form, type FormSchema, Input, useForm, valuesOfForm } from '@ultimat3/ui';
893
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
894
+ import { Field, Form, Input, useForm, valuesOfForm } from '@ultimat3/ui';
605
895
 
606
896
  // In an app this is `InferInput<typeof createPost.input>` — an object type, not an interface,
607
897
  // which is what lets `valuesOfForm`'s `Record<string, unknown>` be narrowed to it below.
@@ -610,8 +900,8 @@ interface Post {
610
900
  readonly id: string;
611
901
  }
612
902
 
613
- // The action and its typed client, both the app's: `createPost.input` IS a `FormSchema`.
614
- declare const createPost: { readonly input: FormSchema };
903
+ // The action and its typed client, both the app's: `createPost.input` IS a Standard Schema.
904
+ declare const createPost: { readonly input: StandardSchemaV1 };
615
905
  declare const api: { createPost(values: CreatePostInput): Promise<Post> };
616
906
 
617
907
  export function CreatePostForm() {
@@ -644,19 +934,34 @@ export function CreatePostForm() {
644
934
  | Rule | Why |
645
935
  |---|---|
646
936
  | **`submit` is required and is the only producer of `succeeded`** | a form that decides for itself that a value is acceptable is a security defect. The client-side parse is a latency optimisation; the action re-parses server-side on every path |
647
- | the local parse's **value** is discarded — only its issues are read | otherwise the browser decides what the server was asked to store. `FormSchema` deliberately has no output type |
648
- | a `name` attribute and an issue path are the **same string** (`items[0].price`) | one grammar, so a rejection finds its control with no per-form mapping table. `formatFieldPath` / `parseFieldPath` are the two directions; a name the grammar cannot read is `X_UI_FORM_PATH_INVALID`, refused where it is DECLARED |
937
+ | the local parse's **value** is discarded — only its issues are read | otherwise the browser decides what the server was asked to store. The binding takes any `StandardSchemaV1` and never reads its output |
938
+ | a `name` attribute and an issue path are the **same string** (`items[0].price`) | one grammar, so a rejection finds its control with no per-form mapping table. `@ultimat3/schema`'s `formatPath` / this package's `parseFieldPath` are the two directions; a name the grammar cannot read is `X_UI_FORM_PATH_INVALID`, refused where it is DECLARED |
649
939
  | an issue whose path matches no declared field goes to **`formErrors`**, never to a neighbouring control | a form that silently drops "the server rejected this" is worse than one with no binding at all. A near miss (`items` against a form holding `items[0].price`) is one of these |
650
940
  | every message is the app's, through `messageFor` | a schema issue (`expected number`) is diagnostic text, not a user-facing string. The framework ships the mapping and **no copy table** — there is no `ui.form.*` key to override. A translator that answers `''` or throws falls back to the diagnostic text, the way a missing catalog key renders `⟦key⟧` |
651
941
  | a second `submit` while one is in flight **joins** it | a double click is not a second write |
652
942
 
653
- **The wire carries no structured issue list yet.** A server-side validation failure arrives as
654
- `X_INPUT_INVALID` with the issues rendered into `cause` (`title: too short; items[0].price:
655
- expected number`) — `packages/action/src/errors.ts` puts nothing in `meta`, and `toProblem`
656
- (`packages/http/src/error-facts.ts`) has no `issues` member — so `issuesFromRejection` reads
657
- `meta.issues` where it exists and parses that line where it does not. It binds a fragment to a
658
- field only when the head is a declared path, so a message holding `'; '` or `': '` degrades to a
659
- form-level error rather than to a wrong control.
943
+ **The wire carries the structured issue list.** A server-side validation failure arrives as
944
+ `X_INPUT_INVALID` with `issues` on the problem document (`toProblem`,
945
+ `packages/http/src/error-facts.ts`), rebuilt by the typed client into `meta.issues`
946
+ (`packages/action/src/wire-issues.ts`) — `path`, `message`, and the `rule` and `params` a local
947
+ parse gives too. `issuesFromRejection` reads `meta.issues` first; only a rejection that lost it (a
948
+ proxy's body, an old server) falls back to parsing the `cause` line, binding a fragment to a field
949
+ only when its head is a declared path, so a message holding `'; '` degrades to a form-level error.
950
+
951
+ **Translate on `rule`, never on `message`.** `FormIssue.rule` is `@ultimat3/schema`'s
952
+ `SCHEMA_RULES` (`string.min`, `enum`, `required`, …) and `FormIssue.params` its declared facts
953
+ (`{ min: 3 }`), identical from the local parse and the server:
954
+
955
+ ```ts
956
+ import type { FormIssue } from '@ultimat3/ui';
957
+
958
+ declare const t: (key: string, params?: FormIssue['params']) => string;
959
+
960
+ const messageFor = (issue: FormIssue): string =>
961
+ issue.rule === undefined ? t('form.invalid') : t(`form.${issue.rule}`, issue.params);
962
+ ```
963
+
964
+ `rule` is absent for a library that names none; the translator falls back as above.
660
965
 
661
966
  ### The submit state, and why `disabled` is refused
662
967
 
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@ultimat3/ui",
3
- "version": "29.4.0",
3
+ "version": "30.1.0",
4
4
  "description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
5
+ "keywords": [
6
+ "ultimate",
7
+ "bun",
8
+ "framework",
9
+ "ui",
10
+ "solidjs",
11
+ "design-system",
12
+ "components"
13
+ ],
14
+ "homepage": "https://github.com/developerz-ai/ultimate/wiki/UI-Components",
5
15
  "license": "MIT",
6
16
  "type": "module",
7
17
  "sideEffects": [
@@ -48,10 +58,11 @@
48
58
  "icons": "bun run src/icons/build-icons.ts"
49
59
  },
50
60
  "dependencies": {
51
- "@ultimat3/core": "29.4.0",
52
- "@ultimat3/i18n": "29.4.0",
53
- "@ultimat3/money": "29.4.0",
54
- "@ultimat3/time": "29.4.0"
61
+ "@ultimat3/core": "30.1.0",
62
+ "@ultimat3/i18n": "30.1.0",
63
+ "@ultimat3/money": "30.1.0",
64
+ "@ultimat3/schema": "30.1.0",
65
+ "@ultimat3/time": "30.1.0"
55
66
  },
56
67
  "peerDependencies": {
57
68
  "solid-js": "^1.9.0"
@@ -7,26 +7,27 @@
7
7
  // `minmax(0, …)`, never the implicit `auto`: an auto track is at least its content's min-content
8
8
  // width, so a header that did not shrink laid a 390px phone out 429px wide.
9
9
  grid-template-columns: minmax(0, 1fr);
10
- grid-template-areas: 'header' 'sidebar' 'main' 'footer';
11
- grid-template-rows: auto auto 1fr auto;
10
+ // `bottom` is the phone's bar, last; an empty row is zero high.
11
+ grid-template-areas: 'header' 'sidebar' 'main' 'footer' 'bottom';
12
+ grid-template-rows: auto auto 1fr auto auto;
12
13
  min-block-size: 100dvh;
13
14
  background: t.role('bg');
14
15
  color: t.role('fg');
15
16
 
16
17
  @include t.respond-to(md) {
17
18
  grid-template-columns: var(--shell-sidebar, 16rem) minmax(0, 1fr);
18
- grid-template-areas: 'header header' 'sidebar main' 'footer footer';
19
- grid-template-rows: auto 1fr auto;
19
+ grid-template-areas: 'header header' 'sidebar main' 'footer footer' 'bottom bottom';
20
+ grid-template-rows: auto 1fr auto auto;
20
21
  }
21
22
  }
22
23
 
23
24
  .no-sidebar {
24
- grid-template-areas: 'header' 'main' 'footer';
25
- grid-template-rows: auto 1fr auto;
25
+ grid-template-areas: 'header' 'main' 'footer' 'bottom';
26
+ grid-template-rows: auto 1fr auto auto;
26
27
 
27
28
  @include t.respond-to(md) {
28
29
  grid-template-columns: minmax(0, 1fr);
29
- grid-template-areas: 'header' 'main' 'footer';
30
+ grid-template-areas: 'header' 'main' 'footer' 'bottom';
30
31
  }
31
32
  }
32
33
 
@@ -165,6 +166,21 @@
165
166
  }
166
167
  }
167
168
 
169
+ // The phone's bottom bar: the last row, sticky to the bottom edge. In flow, never `fixed`, so the
170
+ // page's height already includes it and the last row of `main` scrolls clear above it.
171
+ .bottomNav {
172
+ position: sticky;
173
+ z-index: t.z(sticky);
174
+ inset-block-end: 0;
175
+ // A named area, never auto-placed: below `md` the hidden sidebar leaves an empty cell ABOVE
176
+ // `main`, and an auto-placed bar filled it — pinned to the top of the page, not the bottom.
177
+ grid-area: bottom;
178
+
179
+ @include t.respond-to(md) {
180
+ display: none;
181
+ }
182
+ }
183
+
168
184
  .footer {
169
185
  grid-area: footer;
170
186
  padding-block: t.space(5);
@@ -23,6 +23,12 @@ export interface AppShellProps {
23
23
  /** Rendered inside a `<nav>` landmark: the inline-start column at `md` and up, a panel below. */
24
24
  sidebar?: JSX.Element | undefined;
25
25
  footer?: JSX.Element | undefined;
26
+ /**
27
+ * A `<BottomNav>`: shown below `md` only, pinned to the bottom edge in thumb reach, and in flow,
28
+ * so the page reserves its height and no content scrolls under it. The menu button stays for
29
+ * everything that does not fit five destinations.
30
+ */
31
+ bottomNav?: JSX.Element | undefined;
26
32
  /** Accessible name for the sidebar landmark. Defaults to the translated `ui.navigation`. */
27
33
  sidebarLabel?: string | undefined;
28
34
  /** Skip-link text. Defaults to the translated `ui.skip`. */
@@ -94,6 +100,9 @@ export function AppShell(props: AppShellProps): JSX.Element {
94
100
  {props.children}
95
101
  </main>
96
102
  {props.footer === undefined ? null : <footer class={styles['footer']}>{props.footer}</footer>}
103
+ {props.bottomNav === undefined ? null : (
104
+ <div class={styles['bottomNav']}>{props.bottomNav}</div>
105
+ )}
97
106
  {/* The two live regions, in the SERVER response and empty. `announce()` had none until
98
107
  2026-09 and built its own on first call — a region appended and written in the same frame,
99
108
  which most screen readers do not announce, so the first message of a session was silent.