@ultimat3/ui 24.0.0 → 25.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 (86) hide show
  1. package/CATALOG.md +117 -13
  2. package/CLAUDE.md +14 -5
  3. package/README.md +109 -24
  4. package/package.json +6 -6
  5. package/src/a11y.ts +3 -3
  6. package/src/catalog/build-catalog.ts +2 -2
  7. package/src/components/Accordion.tsx +8 -2
  8. package/src/components/AppShell.module.scss +105 -4
  9. package/src/components/AppShell.tsx +48 -10
  10. package/src/components/BarChart.module.scss +47 -9
  11. package/src/components/BarChart.tsx +152 -72
  12. package/src/components/Card.tsx +52 -16
  13. package/src/components/ChartFrame.module.scss +188 -0
  14. package/src/components/ChartFrame.tsx +155 -0
  15. package/src/components/Container.tsx +46 -9
  16. package/src/components/DataTable.module.scss +190 -0
  17. package/src/components/DataTable.tsx +124 -17
  18. package/src/components/Divider.tsx +0 -2
  19. package/src/components/DonutChart.module.scss +73 -0
  20. package/src/components/DonutChart.tsx +154 -0
  21. package/src/components/Dropzone.tsx +9 -1
  22. package/src/components/ErrorState.tsx +39 -12
  23. package/src/components/Form.tsx +17 -4
  24. package/src/components/Gauge.module.scss +48 -0
  25. package/src/components/Gauge.tsx +67 -0
  26. package/src/components/Grid.tsx +34 -13
  27. package/src/components/Image.tsx +2 -2
  28. package/src/components/InlineBar.module.scss +23 -0
  29. package/src/components/InlineBar.tsx +31 -0
  30. package/src/components/LineChart.module.scss +147 -0
  31. package/src/components/LineChart.tsx +169 -0
  32. package/src/components/Menu.tsx +9 -5
  33. package/src/components/PageHeader.tsx +5 -2
  34. package/src/components/Popover.tsx +2 -2
  35. package/src/components/Section.tsx +45 -26
  36. package/src/components/Stack.tsx +59 -18
  37. package/src/components/Table.module.scss +30 -0
  38. package/src/components/Table.tsx +14 -3
  39. package/src/components/Tabs.tsx +2 -2
  40. package/src/components/Text.tsx +41 -15
  41. package/src/components/Toaster.tsx +1 -1
  42. package/src/components/Toolbar.tsx +7 -6
  43. package/src/components/app-shell-view.ts +6 -4
  44. package/src/components/bar-chart-view.ts +5 -4
  45. package/src/components/chart-frame-view.ts +219 -0
  46. package/src/components/chart-ticks-view.ts +173 -0
  47. package/src/components/data-table-view.ts +68 -0
  48. package/src/components/donut-chart-view.ts +120 -0
  49. package/src/components/file-input-view.ts +1 -1
  50. package/src/components/gauge-view.ts +43 -0
  51. package/src/components/heading-node.tsx +62 -0
  52. package/src/components/image-source.ts +3 -3
  53. package/src/components/line-chart-view.ts +153 -0
  54. package/src/components/money-view.ts +2 -7
  55. package/src/form/form-binding.ts +23 -6
  56. package/src/form/use-form.ts +3 -3
  57. package/src/global.scss +9 -0
  58. package/src/icons/build-icons.ts +15 -6
  59. package/src/index.ts +45 -9
  60. package/src/jsx-probe.ts +5 -4
  61. package/src/theme/brand-declarations.ts +113 -0
  62. package/src/theme/brand.ts +98 -109
  63. package/src/theme/context.ts +1 -1
  64. package/src/theme/preset-scifi.ts +99 -0
  65. package/src/theme/preset-shape.ts +18 -0
  66. package/src/theme/presets.ts +15 -0
  67. package/src/theme/shadow-value.ts +39 -0
  68. package/src/theme/theme.ts +1 -1
  69. package/src/toast/toast-store.ts +1 -1
  70. package/src/toast/use-toasts.ts +1 -1
  71. package/src/tokens/_colors.scss +20 -0
  72. package/src/tokens/_effects.scss +59 -0
  73. package/src/tokens/_index.scss +2 -0
  74. package/src/tokens/_mixins.scss +6 -4
  75. package/src/tokens/_shadow.scss +16 -0
  76. package/src/tokens/_touch.scss +39 -0
  77. package/src/tokens/_typography.scss +14 -0
  78. package/src/tokens/color-tokens.ts +133 -0
  79. package/src/tokens/colour-vision.ts +106 -0
  80. package/src/tokens/contrast-pairs.ts +6 -1
  81. package/src/tokens/contrast.ts +1 -1
  82. package/src/tokens/theme.scss +2 -0
  83. package/src/tokens/tokens.ts +41 -87
  84. package/src/components/qr-reader.ts +0 -187
  85. package/src/fake-dom.ts +0 -206
  86. package/src/sass-probe.ts +0 -55
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
- 62 components: `Accordion` · `Alert` · `AppShell` · `AsyncRegion` · `Avatar` · `Badge` · `BarChart` · `Breadcrumb` · `Button` · `Card` · `Checkbox` · `Combobox` · `CommandPalette` · `Container` · `CopyButton` · `DataTable` · `DateTime` · `Dialog` · `Divider` · `Drawer` · `Dropzone` · `EmptyState` · `ErrorState` · `Field` · `FileInput` · `Form` · `Grid` · `Icon` · `IconButton` · `Image` · `InfiniteScroll` · `Input` · `Kbd` · `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
+ 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`
8
8
 
9
9
  ## Vocabulary
10
10
 
@@ -47,17 +47,18 @@ Inline message bound to the page, not the viewport. `danger`/`warning` use `role
47
47
 
48
48
  ### AppShell
49
49
 
50
- The page frame every app screen sits in: skip link, banner, navigation, main, contentinfo. Stateless on purpose — below `md` the sidebar becomes a band above the content instead of growing an open/closed flag, because an off-canvas menu is already `Drawer` and axiom 1 allows exactly one of those.
50
+ The page frame every app screen sits in: skip link, banner, navigation, main, contentinfo. Stateless, and scriptless: below `md` the sidebar `<nav>` is a native `popover` panel at the inline-start edge, opened by a `popovertarget` menu button — the browser holds the open state, Esc and light dismiss, so the frame on every page costs no island. Not `Drawer`: that is a modal `<dialog>` opened from an effect, so it needs a hydrated island, and a `<dialog>` cannot also be the in-flow `navigation` landmark at `md` and up. An engine with no popovers keeps the old band.
51
51
 
52
52
  | Prop | Type | Required | Notes |
53
53
  |---|---|---|---|
54
54
  | `children` | `JSX.Element` | yes | The page. Rendered inside the one `<main>`, which is the skip link's target. |
55
55
  | `header` | `JSX.Element` | — | |
56
- | `sidebar` | `JSX.Element` | — | Rendered inside a `<nav>` landmark at the inline start. |
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
58
  | `sidebarLabel` | `string` | — | Accessible name for the sidebar landmark. Defaults to the translated `ui.navigation`. |
59
59
  | `skipLabel` | `string` | — | Skip-link text. Defaults to the translated `ui.skip`. |
60
- | `sidebarWidth` | `string` | — | Sidebar track width at `md` and up. Any CSS length. |
60
+ | `menuLabel` | `string` | — | The menu button's text below `md`. Defaults to the translated `ui.menu`. |
61
+ | `sidebarWidth` | `string` | — | Sidebar track width at `md` and up, and the panel's width below. Any CSS length. |
61
62
  | `stickyHeader` | `boolean` | — | Keeps the header pinned while the main region scrolls. |
62
63
  | `class` | `string` | — | |
63
64
 
@@ -103,15 +104,19 @@ Small status label. Tone maps straight onto the status colour roles so the same
103
104
 
104
105
  ### BarChart
105
106
 
106
- A bar chart of one series — clicks per day, signups per week — or two stacked (done, and failed on top), as static SVG. 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. Geometry lives in `bar-chart-view.ts`.
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.
107
108
 
108
109
  | Prop | Type | Required | Notes |
109
110
  |---|---|---|---|
110
111
  | `label` | `string` | yes | The accessible name for the whole chart, already translated. |
111
112
  | `points` | `readonly ChartPoint[]` | yes | Oldest first. Every bar comes from exactly this array — the caller zero-fills gaps. |
112
113
  | `highlightLast` | `boolean` | — | Draw the last bar at full strength — the eye lands where the live number is. Default on. |
113
- | `seriesLabels` | `{ readonly primary: string; readonly secondary: string }` | — | Names of the two series, already translated, read by each rect's `<title>`. Give them when a point carries `secondary`: a stacked bar with no name for its top is two numbers and no key. |
114
- | `class` | `string` | — | |
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
+ | `keyLabel` | `string` | — | The key column's head in the data table, already translated — "Day". |
116
+ | `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. |
118
+ | `showCaption` | `boolean` | — | Show the caption above the plot. Default off — it names the figure either way. |
119
+ | `class` | `string` | — | Lands on the root `<figure>`, which holds the svg and its HTML axis labels. |
115
120
 
116
121
  ### Breadcrumb
117
122
 
@@ -161,6 +166,19 @@ Surface container. Elevation is a token rung, and because the shadow tokens are
161
166
  | `as` | `'div' \| 'article' \| 'section' \| 'li'` | — | |
162
167
  | `class` | `string` | — | |
163
168
 
169
+ ### ChartFrame
170
+
171
+ The frame every chart stands in: a `<figure>` named by its `<figcaption>`, the plot, a legend that wraps under the plot on a narrow container and stands beside it on a wide one, and a visually-hidden `<table>` of the same numbers — what a screen reader and a reader who cannot tell the colours apart get instead of the picture. Server-rendered HTML: no island, no JS.
172
+
173
+ | Prop | Type | Required | Notes |
174
+ |---|---|---|---|
175
+ | `label` | `string` | yes | The chart's name, already translated: the `<figcaption>`, and the data table's caption. |
176
+ | `children` | `JSX.Element` | yes | The plot — the chart's own svg and HTML axis labels. |
177
+ | `showCaption` | `boolean` | — | Show the caption above the plot. Off by default: a chart usually sits under a Card's own heading, and the caption names the figure for assistive tech either way. |
178
+ | `legend` | `readonly ChartLegendItem[]` | — | One entry per series. Omitted or empty renders no legend. |
179
+ | `table` | `ChartTable` | — | The same data as rows, rendered visually hidden (`chartTable`). |
180
+ | `class` | `string` | — | Lands on the root `<figure>`. |
181
+
164
182
  ### Checkbox
165
183
 
166
184
  Native checkbox with a token-drawn indicator. The label element wraps the input, so the whole row is a hit target without a manual `for`/`id` pairing.
@@ -251,7 +269,7 @@ Copies one string to the clipboard and says so — the control beside a short UR
251
269
 
252
270
  ### DataTable
253
271
 
254
- Data-driven table: sortable headers, cursor pagination, and the four states a real list always has (loading, error, empty, data). The error state renders an UltimateError with the same code/cause/fix strings the terminal prints. The four-way decision itself is NOT here — it is `asyncBranch`, shared with `AsyncRegion`, so a table and a card list cannot disagree about what "loading with stale rows" looks like. Only the PLACEHOLDER is local, because a table's is table-shaped: rows of cells, not lines of text.
272
+ Data-driven table: sortable headers, cursor pagination, and the four states a real list always has (loading, error, empty, data). The error state renders an UltimateError with the same code/cause/fix strings the terminal prints. The four-way decision itself is NOT here — it is `asyncBranch`, shared with `AsyncRegion`, so a table and a card list cannot disagree about what "loading with stale rows" looks like. Only the PLACEHOLDER is local, because a table's is table-shaped: rows of cells, not lines of text. A narrow table is the SAME table restyled as labelled cards (`DataTable.module.scss`), never a second copy: a duplicate is read twice or, hidden, is links the reader is told are not there.
255
273
 
256
274
  | Prop | Type | Required | Notes |
257
275
  |---|---|---|---|
@@ -268,6 +286,8 @@ Data-driven table: sortable headers, cursor pagination, and the four states a re
268
286
  | `nextCursor` | `string` | — | Opaque cursors from the query result; absent means no further page. |
269
287
  | `prevCursor` | `string` | — | |
270
288
  | `stickyHeader` | `boolean` | — | |
289
+ | `stickyFirstColumn` | `boolean` | — | Pins the first column while the table scrolls sideways. |
290
+ | `narrow` | `DataTableNarrow` | — | What a table narrower than `sm` becomes. Default `cards`. |
271
291
  | `density` | `'comfortable' \| 'compact'` | — | |
272
292
  | `skeletonRows` | `number` | — | Placeholder row count while loading. Match the usual page size. |
273
293
  | `class` | `string` | — | |
@@ -315,6 +335,23 @@ A rule. Always an explicit `role="separator"` with an orientation; pass `label`
315
335
  | `label` | `string` | — | Visible, already-translated caption rendered inside the rule. |
316
336
  | `class` | `string` | — | |
317
337
 
338
+ ### DonutChart
339
+
340
+ Parts of one whole — traffic by device, runs by outcome — as a ring of segments with the total (or the caller's figure) in its centre, a legend giving each part's value and share, and a hidden data table. Each segment carries its series' marker shape and a gap from its neighbours, so the ring reads in greyscale. SVG geometry from `donut-chart-view.ts`; text is HTML.
341
+
342
+ | Prop | Type | Required | Notes |
343
+ |---|---|---|---|
344
+ | `label` | `string` | yes | The chart's name, already translated: the figure's caption and the svg's accessible name. |
345
+ | `segments` | `readonly DonutSegment[]` | yes | Clockwise from twelve o'clock, in the order the legend lists them. |
346
+ | `centreValue` | `string` | — | The figure in the ring's centre, already formatted. Default: the formatted total. |
347
+ | `centreLabel` | `string` | — | A short line under the centre figure, already translated — "Total", "Sessions". |
348
+ | `keyLabel` | `string` | — | The segment column's head in the data table, already translated — "Device". |
349
+ | `valueLabel` | `string` | — | The value column's head in the data table, already translated. Default: `label`. |
350
+ | `format` | `((value: number) => string)` | — | Formats every value. Default: `Intl.NumberFormat` in the page's locale. |
351
+ | `focusable` | `boolean` | — | Give every drawn segment a tab stop and a hover/focus readout. Default off. |
352
+ | `showCaption` | `boolean` | — | Show the caption above the chart. Default off — it names the figure either way. |
353
+ | `class` | `string` | — | Lands on the root `<figure>`. |
354
+
318
355
  ### Drawer
319
356
 
320
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.
@@ -341,11 +378,11 @@ A drop surface that is a real `<label>` around a real, visually-hidden `<input t
341
378
  | `id` | `string` | — | |
342
379
  | `name` | `string` | — | |
343
380
  | `accept` | `string` | — | The `accept` attribute, and the same list a dropped file is partitioned against. |
344
- | `multiple` | `boolean` | — | |
381
+ | `multiple` | `boolean` | — | Without it the zone takes ONE file, dropped or picked — the rest are refused by `count`. |
345
382
  | `required` | `boolean` | — | |
346
383
  | `disabled` | `boolean` | — | |
347
384
  | `maxBytes` | `number` | — | Client-side ceiling. The server enforces its own; this only spares a doomed transfer. |
348
- | `maxFiles` | `number` | — | |
385
+ | `maxFiles` | `number` | — | Read only with `multiple`: a single-file zone's ceiling is one. |
349
386
  | `progress` | `number` | — | 0..1 while an upload runs. Absent renders no bar at all — never one stuck at zero. |
350
387
  | `progressLabel` | `string` | — | Already-translated accessible name for the progress bar. |
351
388
  | `class` | `string` | — | |
@@ -435,6 +472,19 @@ Form shell. Owns the one thing every form needs and always forgets: a top-of-for
435
472
  | `aria-label` | `string` | — | |
436
473
  | `onSubmit` | `JSX.EventHandlerUnion<HTMLFormElement, SubmitEvent>` | — | |
437
474
 
475
+ ### Gauge
476
+
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.
478
+
479
+ | Prop | Type | Required | Notes |
480
+ |---|---|---|---|
481
+ | `label` | `string` | yes | What is measured, already translated: shown under the dial, and the meter's name. |
482
+ | `value` | `number` | yes | |
483
+ | `max` | `number` | yes | |
484
+ | `tone` | `Tone` | — | The fill's colour role. Default `accent`; a caller picks `warning`/`danger` by threshold. |
485
+ | `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
+ | `class` | `string` | — | Lands on the root `<figure>`. |
487
+
438
488
  ### Grid
439
489
 
440
490
  Grid layout primitive. The default is an intrinsic responsive grid (`auto-fit` + `minmax`) so most layouts need no breakpoint at all.
@@ -511,6 +561,18 @@ An endless list that degrades to pagination: the foot is always a real `rel="nex
511
561
  | `rootMargin` | `string` | — | How far below the fold loading starts. A CSS length, as IntersectionObserver takes it. |
512
562
  | `class` | `string` | — | |
513
563
 
564
+ ### InlineBar
565
+
566
+ A figure with a bar drawn to its share of a maximum — the in-cell bar a dashboard table uses to make a column of numbers scannable. The text is the fact and is what assistive tech reads; the bar is `Meter`'s decorative form, so the share rule and its clamping are written once. The figure arrives formatted, as `StatTile`'s does: this component never guesses a locale.
567
+
568
+ | Prop | Type | Required | Notes |
569
+ |---|---|---|---|
570
+ | `value` | `number` | yes | |
571
+ | `max` | `number` | yes | The whole the bar is a share of — usually the largest value in the column. |
572
+ | `text` | `string` | yes | The figure as the cell shows it, already formatted (`Intl.NumberFormat`, `formatMoney`). |
573
+ | `tone` | `Tone` | — | |
574
+ | `class` | `string` | — | |
575
+
514
576
  ### Input
515
577
 
516
578
  Single-line text control. No `type="number"` convenience wrapper: numeric input uses `inputmode` + a text type so locale decimal separators survive.
@@ -548,6 +610,38 @@ A keyboard key or chord as the user reads it — "⌘K", "Esc", "/". A native `<
548
610
  | `children` | `JSX.Element` | yes | The key or chord, already translated where a key name is a word. |
549
611
  | `class` | `string` | — | |
550
612
 
613
+ ### LineChart
614
+
615
+ A line chart of one or more series over the same keys — signups per day, p95 per hour — and `AreaChart`, the same chart with each series' area filled to the baseline. SVG lines on a nice 1-2-5 value axis, HTML axis labels (text inside a viewBox scales to a speck on a phone), a legend, and a hidden data table. Every series has its own dash and marker, never colour alone.
616
+
617
+ | Prop | Type | Required | Notes |
618
+ |---|---|---|---|
619
+ | `label` | `string` | yes | The chart's name, already translated: the figure's caption and the svg's accessible name. |
620
+ | `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. |
622
+ | `keyLabel` | `string` | — | The key column's head in the data table, already translated — "Day". |
623
+ | `format` | `((value: number) => string)` | — | Formats every value and tick. Default: `Intl.NumberFormat` in the page's locale. |
624
+ | `zero` | `boolean` | — | Start the value axis at zero. Default on; off fits the axis to the data. |
625
+ | `focusable` | `boolean` | — | Give every point a tab stop and a hover/focus readout. Default off: no tab stops. |
626
+ | `showCaption` | `boolean` | — | Show the caption above the plot. Default off — it names the figure either way. |
627
+ | `class` | `string` | — | Lands on the root `<figure>`. |
628
+
629
+ ### AreaChart
630
+
631
+ A line chart of one or more series over the same keys — signups per day, p95 per hour — and `AreaChart`, the same chart with each series' area filled to the baseline. SVG lines on a nice 1-2-5 value axis, HTML axis labels (text inside a viewBox scales to a speck on a phone), a legend, and a hidden data table. Every series has its own dash and marker, never colour alone.
632
+
633
+ | Prop | Type | Required | Notes |
634
+ |---|---|---|---|
635
+ | `label` | `string` | yes | The chart's name, already translated: the figure's caption and the svg's accessible name. |
636
+ | `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. |
638
+ | `keyLabel` | `string` | — | The key column's head in the data table, already translated — "Day". |
639
+ | `format` | `((value: number) => string)` | — | Formats every value and tick. Default: `Intl.NumberFormat` in the page's locale. |
640
+ | `zero` | `boolean` | — | Start the value axis at zero. Default on; off fits the axis to the data. |
641
+ | `focusable` | `boolean` | — | Give every point a tab stop and a hover/focus readout. Default off: no tab stops. |
642
+ | `showCaption` | `boolean` | — | Show the caption above the plot. Default off — it names the figure either way. |
643
+ | `class` | `string` | — | Lands on the root `<figure>`. |
644
+
551
645
  ### Link
552
646
 
553
647
  Anchor primitive. External links get `rel` hardening and a translated "opens in a new tab" hint automatically — never a bare `target="_blank"`. `appearance="button"` is Button's look on the same anchor: a control that navigates is a link.
@@ -595,7 +689,7 @@ Action menu: a trigger plus a `role="menu"` list with roving tabindex. Distinct
595
689
  | `items` | `readonly MenuItem[]` | yes | |
596
690
  | `open` | `boolean` | yes | |
597
691
  | `onOpenChange` | `(open: boolean) => void` | yes | |
598
- | `trigger` | `(control: { id: string; 'aria-haspopup': 'menu'; 'aria-expanded': boolean; 'aria-controls': string; }) => JSX.Element` | yes | |
692
+ | `trigger` | `(control: { id: string; 'aria-haspopup': 'menu'; 'aria-expanded': boolean; 'aria-controls': string \| undefined; }) => JSX.Element` | yes | The menu button. `aria-controls` is present only while the list is mounted: an IDREF to an element not in the document names nothing. |
599
693
  | `label` | `string` | yes | Already-translated accessible name for the menu. |
600
694
  | `align` | `'start' \| 'end'` | — | |
601
695
  | `class` | `string` | — | |
@@ -827,7 +921,7 @@ Boolean toggle with immediate effect (as opposed to Checkbox, which is part of a
827
921
 
828
922
  ### Table
829
923
 
830
- Presentational table. Owns two things every hand-rolled table gets wrong: a sticky header, and horizontal overflow contained inside the table's own scroll container so the page body never scrolls sideways.
924
+ Presentational table. Owns what every hand-rolled table gets wrong: a sticky header, a first column that stays put while the rest scrolls, and horizontal overflow contained inside the table's own scroll container so the page body never scrolls sideways.
831
925
 
832
926
  | Prop | Type | Required | Notes |
833
927
  |---|---|---|---|
@@ -835,6 +929,8 @@ Presentational table. Owns two things every hand-rolled table gets wrong: a stic
835
929
  | `children` | `JSX.Element` | yes | |
836
930
  | `hideCaption` | `boolean` | — | Hide the caption visually while keeping it for assistive tech. |
837
931
  | `stickyHeader` | `boolean` | — | |
932
+ | `stickyFirstColumn` | `boolean` | — | Pins the first column at the inline start while the table scrolls sideways. Off by default. |
933
+ | `explicitRoles` | `boolean` | — | Declares `role="table"` on the element itself. For a caller that restyles the table's `display` (DataTable's card view): WebKit drops a table's semantics once `display` changes, and the explicit role is what keeps it a table. The rows and cells are the caller's to mark. |
838
934
  | `density` | `'comfortable' \| 'compact'` | — | |
839
935
  | `striped` | `boolean` | — | Zebra striping. Off by default — a border is usually enough. |
840
936
  | `class` | `string` | — | |
@@ -933,7 +1029,7 @@ The one way to render a toast queue: the store's VISIBLE slice, inside the regio
933
1029
 
934
1030
  | Prop | Type | Required | Notes |
935
1031
  |---|---|---|---|
936
- | `store` | `ToastStore` | yes | The queue. One per app — `createToastStore()` in the island that mounts this. |
1032
+ | `store` | `ToastStore` | yes | The queue. One per app — `toastStore()` in the island that mounts this. |
937
1033
  | `label` | `string` | yes | Already-translated landmark name, e.g. "Notifications". |
938
1034
  | `politeness` | `Politeness` | — | |
939
1035
  | `placement` | `ToastPlacement` | — | |
@@ -997,6 +1093,14 @@ charts and email.
997
1093
  | `info` | `--color-info` |
998
1094
  | `info-soft` | `--color-info-soft` |
999
1095
  | `info-fg` | `--color-info-fg` |
1096
+ | `chart-1` | `--color-chart-1` |
1097
+ | `chart-2` | `--color-chart-2` |
1098
+ | `chart-3` | `--color-chart-3` |
1099
+ | `chart-4` | `--color-chart-4` |
1100
+ | `chart-5` | `--color-chart-5` |
1101
+ | `chart-6` | `--color-chart-6` |
1102
+ | `chart-7` | `--color-chart-7` |
1103
+ | `chart-8` | `--color-chart-8` |
1000
1104
 
1001
1105
  ### Space — `--space-*`
1002
1106
 
package/CLAUDE.md CHANGED
@@ -16,7 +16,7 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
16
16
  - **Logical properties only.** `margin-inline`, `inset-inline-start`, `text-align: start`. A `left`/`right` in a stylesheet is a bug.
17
17
  - **solid-js is a type-only import.** All reactive access goes through `src/theme/solid-adapter.ts`. Never `import { createSignal } from 'solid-js'`.
18
18
  - **The runtime SLOT is `src/theme/runtime-slot.ts`; the RULE is `solid-adapter.ts`, and the split is a byte measurement.** `solid-adapter.ts` reaches `../errors` and so core's error registry; the slot imports nothing, so an island that only calls `setSolidRuntime` pays 72 B, not 5,719. `barrel-bytes.test.ts` holds the ceiling at 1 kB.
19
- - **The barrel is the ONE import path; component subpath exports are refused on measurement** (issue #275). A barrel import and a deep path emit the same modules and bytes, so `@ultimat3/ui/button` would be a second idiom for zero bytes. The two subpaths that exist are not exceptions: `./icons/*` is 1,767 data modules no bundler can split, and `./jsx-probe` is test-only. `barrel-bytes.test.ts` compares the two paths and names `schema-error-codes.ts` (Bun 1.4.0's load-correlated shake, issues #273 #276) rather than allowing bytes for it.
19
+ - **The barrel is the ONE import path; component subpath exports are refused on measurement** (issue #275). A barrel import and a deep path emit the same modules and bytes, so `@ultimat3/ui/button` would be a second idiom for zero bytes. The two subpaths that exist are not exceptions: `./icons/*` is 1,767 data modules no bundler can split, and `./jsx-probe` is test-only. `barrel-bytes.test.ts` compares the two paths and names `schema-error-codes.ts` (Bun 1.4.0's load-correlated shake, issue #273) rather than allowing bytes for it. Its sibling #276 — a pure re-export entry shaken to its export clause — was fixed in Bun 1.4.1 (oven-sh/bun#40578), and barrels have been built as their own entry since.
20
20
  - **`useUi()`'s server branch is a SLOT, and the module that fills it is browser-mapped** (issue #490). `context.ts` imports no value from `@ultimat3/i18n` or `@ultimat3/time`. `src/theme/ambient.ts` registers into `ambient-slot.ts` at import; `index.ts` imports it BARE above every re-export; `package.json`'s `browser` field maps it to `ambient.browser.ts`. A test reaching components by module path must `import './ambient'` itself. `errors.ts` is pure: `registerErrorCodes()` is `src/error-registry.ts`, imported bare.
21
21
  - **`solid()` always answers off-DOM.** No registered runtime and no DOM is a *server render*, and it gets `INERT_SOLID_RUNTIME` (`src/theme/inert-runtime.ts`) — signals hold, memos recompute on read, effects never run, `useContext` returns the default. No DOM means no reactivity to lose; a **DOM** with no runtime is still `X_UI_RUNTIME_MISSING`, because that one is the theme toggle that does nothing. Never widen this to "no runtime, never throw": that is the silent degradation the split exists to prevent.
22
22
  - **`useUi()` reads the request on the server**, via `ambientUiContext()` — `currentLocale()`, `currentTimeZone()`, `useI18n()`, which read **core's own `Ctx.locale` / `Ctx.tz`** (written once per request by `@ultimat3/http`'s `locale` stage). Never add a second ambient store here or in `i18n`/`time`. `theme`/`currency` have no ambient source at all.
@@ -26,7 +26,7 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
26
26
  - **No `{...rest}` prop spreading.** Splitting props reactively needs solid's `splitProps` (a value import), so components declare explicit props and read `props.x` inside JSX.
27
27
  - **One mode per use is a UNION of interfaces** (`LinkProps`, `PaginationProps`, `DataTableProps` — where the mode covers the pager AND the sort headers: `hrefFor` + `sortHrefFor`, or `onCursor` + `onSortChange`; a linked table never renders a button with no handler): a mode forbids the other's props with `x?: undefined`, and `catalog/parse-component.ts` merges the modes.
28
28
  - **No hardcoded strings.** Label props, or `useUi().t(UI_KEYS.x)`. New built-in strings go in `src/i18n-keys.ts`.
29
- - **Page layout is four composites**: `AppShell` (frame + skip link + landmarks), `PageHeader`, `Section`, `Toolbar`. A screen that hand-rolls a header grid is the bug they exist to prevent. `AppShell` holds no state — off-canvas is `Drawer`.
29
+ - **Page layout is four composites**: `AppShell` (frame + skip link + landmarks), `PageHeader`, `Section`, `Toolbar`. A screen that hand-rolls a header grid is the bug they exist to prevent. `AppShell` holds no state; its mobile nav is a no-JS `popover`, and `Drawer` stays the modal off-canvas.
30
30
  - **Restyling goes through `defineTheme()`**, never a forked stylesheet and never an SCSS `with ()` override. Its values are validated, not escaped: the output lands in a `<style>` element.
31
31
  - **`@include t.tone-classes`** emits the `.tone-*` custom-property blocks. Never hand-write a seventh copy; `$tones` in `_colors.scss` mirrors `TONES` and `variants.test.ts` gates it.
32
32
  - **Icons are generated, never authored.** `src/icons/glyphs/*.ts` is one module per Lucide icon, written by `bun run icons` from `lucide-static@LUCIDE_VERSION`. Never hand-edit a glyph, never add a hand-drawn one, never introduce a second icon source. An upstream fix is a version bump plus a re-run. `lucide` itself is NOT a dependency — the data is committed, so the package still installs, typechecks and renders offline with zero runtime deps.
@@ -42,8 +42,11 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
42
42
  - **A toast dwell stops for THREE independent reasons** — hover, focus-within, `document.hidden` — so `ToastHold` is a set, never a boolean. Duration is a TOKEN (`TOAST_DWELL_MS`). Rules are pure (`src/toast/toast-state.ts`), the clock injected (`ToastEnv`); a server render gets `INERT_TOAST_ENV` and `ToastRegion` still emits its empty live region.
43
43
  - **`announce()` writes into a region `AppShell` already rendered** (`liveRegionAttrs` is the one derivation). For a state change with no surface of its own; a notification is a `Toast` — never both for one message.
44
44
  - **`loading` never sets the native `disabled` attribute** (`Button`): `aria-disabled` + a refused click keeps it focusable and readable; `Button.module.scss` restores full opacity under `[aria-busy='true']`. `<Form busy>` refuses the submit again.
45
- - **A failed submit focuses the first invalid CONTROL, and the summary only when there is none.** `firstInvalidField` answers in DECLARATION order; `fieldSelector` is the allowlist between a caller's string and a selector.
45
+ - **A failed submit focuses the first invalid CONTROL, and the summary only when there is none.** `firstInvalidField` answers in DECLARATION order; `fieldSelector` is the allowlist between a caller's string and a selector. Once per failure: only a CHANGED `(invalidField, error)` moves focus (`Form.test.ts`, real Solid).
46
46
  - **`defineTheme()` refuses a palette that fails WCAG 2.2 AA** (`X_UI_CONTRAST_INSUFFICIENT`), measured against `CONTRAST_PAIRS` on the RESOLVED palette, only for pairings the brand changed. AA, never APCA.
47
+ - **A preset is a BASE, never a mode.** `defineTheme({ preset })` merges `THEME_PRESETS[name]` under the app's overrides key by key (an `undefined` override is no override), then runs every existing check on the merged result. A preset states ALL colour roles in both themes, so `presets.test.ts` measures it alone against `CONTRAST_PAIRS`, the chart CVD floor and the scrim rule. A new preset is a row in `presets.ts` plus its file; no preset forces a theme or ships CSS.
48
+ - **Chart series are measured twice**: 3:1 on all four surfaces (`CONTRAST_PAIRS`), and `CHART_DISTINCT_MIN` ΔE apart under protan/deutan/tritan simulation (`colour-vision.ts`, `colour-vision.test.ts`). Moving one channel of one series can break the second check without touching the first.
49
+ - **A `defineTheme()` shadow is a GRAMMAR match** (`theme/shadow-value.ts`), not a pattern of allowed characters: layers of lengths plus `rgb(R G B / a)` or `rgb(var(--color-<known role>) / a)`. The glow and tinted rungs read `--color-accent`, so a brand's accent recolours them for free — `tokens.test.ts` refuses a channel literal there.
47
50
  - **Live semantics belong to the container that outlives the message.** `ToastRegion`'s `<ol>` carries `aria-live`; a `Toast` is a plain `<li>`. A region created with its content already inside it is not announced, and a `role="status"` on the `<li>` also strips its `listitem` semantics.
48
51
  - **`aria-checked` never mirrors a native `checked`.** ARIA outranks host state in the accessibility tree, and on the no-JS path this package supports there is nothing to rewrite the attribute after the user ticks the box. `Checkbox` writes only `'mixed'` (an IDL property with no attribute form, so ARIA is the only server-side lever); `Switch` writes none at all, over a `biome-ignore` that says why.
49
52
  - **A form binds to an action; it never decides for one.** `useForm` (`src/form/`) is a binding over an existing `action`, not a ninth primitive and not a component — `submit` is REQUIRED and is the only producer of a `succeeded` state, and the client-side parse's **value** is discarded so that only its issues are read. A binding that submitted the locally-parsed value would let a browser choose what the server was asked to store; `FormSchema` has no output type for that reason.
@@ -68,10 +71,16 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
68
71
  | `src/theme/ambient-slot.ts` | the module-scope slot holding the reader `useUi()` falls back to on the server — imports nothing, so a browser chunk pays nothing for it |
69
72
  | `src/theme/ambient.ts` | the server's reader (`ambientUiContext`: request locale, zone, direction, translator), registered at import; `index.ts` imports it bare, and the `browser` field maps it to `ambient.browser.ts`, which answers the defaults and registers nothing |
70
73
  | `src/error-registry.ts` | the `registerErrorCodes()` call, alone, so `errors.ts` is pure and the barrel's re-export of it costs a chunk nothing |
71
- | `src/components/` | 62 components, `PascalCase.tsx` (component convention overrides the repo's kebab-case) |
74
+ | `src/components/` | 67 components, `PascalCase.tsx` (component convention overrides the repo's kebab-case) |
72
75
  | `src/icons/glyphs/` | GENERATED: 1767 per-icon modules, `@ultimat3/ui/icons/<name>` → `icon<Name>` |
73
76
  | `src/icons/build-icons.ts` | the generator + the pinned `LUCIDE_VERSION`; `LICENSE.lucide` is upstream's ISC text |
74
- | `src/theme/brand.ts` | `defineTheme()` — the ONE brand-override seam; there is no SCSS `@use ... with ()` path |
77
+ | `src/theme/brand.ts` | `defineTheme()` — the ONE brand-override seam; there is no SCSS `@use ... with ()` path. Merges a preset under the overrides, measures, emits at every level `theme.scss` does |
78
+ | `src/theme/brand-declarations.ts` | each slot's validation + its custom-property lines, in canonical order (colours, shadows, radius, font) |
79
+ | `src/theme/shadow-value.ts` | the shadow-value grammar a brand override must match |
80
+ | `src/theme/presets.ts` · `preset-shape.ts` · `preset-scifi.ts` | `THEME_PRESETS` (`scifi`), the `ThemePreset` shape + `FONT_SLOTS`, and the scifi data |
81
+ | `src/tokens/color-tokens.ts` | `COLOR_ROLES`, `CHART_ROLES`, the channel mirror — split from `tokens.ts` by size, re-exported by it |
82
+ | `src/tokens/colour-vision.ts` | Machado 2009 dichromacy simulation + CIE76 ΔE; `closestChartPair()` answers the pair to move |
83
+ | `src/tokens/_touch.scss` · `_effects.scss` | `--touch-target` + `touch-target` mixin; `glow-edge` and `grid-texture` (CSS only, static, withdrawn under `prefers-contrast: more` / `forced-colors`) |
75
84
  | `src/tokens/contrast.ts` | WCAG ratios over the channel tokens; `contrast.test.ts` gates AA in both themes |
76
85
  | `src/catalog/` | parses `components/*.tsx` into `CATALOG.md`; `bun run catalog` writes it, `catalog.test.ts` fails on drift |
77
86
  | `src/roving.ts` | the pure rules of a keyboard group: navigable set, tab stop, who keeps their own arrows |
package/README.md CHANGED
@@ -20,10 +20,32 @@ channels** so `rgb(var(--color-accent) / 0.12)` gives a tint with no extra token
20
20
  | `scrim` | modal backdrops |
21
21
  | `accent` / `accent-strong` / `accent-fg` | primary action, its hover, text on it |
22
22
  | `success` / `warning` / `danger` / `info` | status solid, each with a `-soft` tint and a `-fg` text-on-solid |
23
+ | `chart-1` … `chart-8` | categorical chart series, in assignment order (`CHART_ROLES`) |
24
+
25
+ The chart series are Okabe–Ito-derived and **measured, not picked** (`As of 2026-10`): each clears
26
+ 3:1 against all four surfaces in both themes (WCAG 1.4.11, in `CONTRAST_PAIRS`), and every pair
27
+ stays at least `CHART_DISTINCT_MIN` (ΔE 10) apart under protanopia, deuteranopia and tritanopia
28
+ simulation (`colour-vision.test.ts`). A ninth series reuses `chart-1` with a second encoding — a
29
+ dash, a pattern, a label — never a ninth hue.
23
30
 
24
31
  Scales: `--space-*` (4px base), `--stroke-*` (line weights: `hairline thick heavy`), `--text-*` (fluid `clamp()`), `--radius-*`,
25
32
  `--shadow-*` (themed — dark gets deeper, higher-alpha shadows), `--duration-*`,
26
- `--easing-*`, `--z-*` (named ladder, no magic numbers).
33
+ `--easing-*`, `--z-*` (named ladder, no magic numbers), `--touch-target` (44px, as `2.75rem`).
34
+
35
+ | Shadow rung (`t.shadow('…')`) | What it is |
36
+ |---|---|
37
+ | `xs` `sm` `md` `lg` `xl` | elevation: a drop shadow, deeper and darker in dark |
38
+ | `glow-sm` `glow-md` `glow-lg` | a halo drawn from the **accent** role, no offset — a live tile, a focused panel, a selected series |
39
+ | `tinted-sm` `tinted-md` `tinted-lg` | coloured elevation: a drop shadow in the accent hue |
40
+
41
+ Glow and tinted rungs read `--color-accent`, so a brand's accent recolours them with no second
42
+ override. `SHADOW_NAMES` / `ShadowName` is the list.
43
+
44
+ | Font slot | Default stack | Read by |
45
+ |---|---|---|
46
+ | `--font-sans` | `system-ui` … | body copy |
47
+ | `--font-mono` | `ui-monospace` … | `code` and `pre`, error frames |
48
+ | `--font-data` | `ui-monospace`, `'Cascadia Mono'`, `'JetBrains Mono'` … | `@include t.data-text` — figures in tables, axes and stat tiles, ids, `Kbd` |
27
49
 
28
50
  Authoring helpers, from the same `@use '@ultimat3/ui/tokens' as t` — an app writes no mixin of its own for any of them:
29
51
 
@@ -34,6 +56,11 @@ Authoring helpers, from the same `@use '@ultimat3/ui/tokens' as t` — an app wr
34
56
  | `@include t.respond-between(md, lg)` | `@media (min-width: 768px) and (max-width: 1023.98px)` |
35
57
  | `t.rem(24px)` | `1.5rem` — px, rem or a unitless px count in; `t.rem(20px, 10px)` states another root |
36
58
  | `t.fluid(1rem, 2rem, 20rem, 80rem)` | `clamp(1rem, 0.6666666667rem + 1.6666666667vw, 2rem)` — the first size at a 20rem viewport, the second at 80rem (the default range), linear between. Takes px too; a size that shrinks is legal |
59
+ | `@include t.data-text` | `font-family: var(--font-data); font-variant-numeric: tabular-nums` — columns of figures that do not wobble |
60
+ | `@include t.touch-target` | `min-block-size` + `min-inline-size` of `var(--touch-target)` — the box itself is hittable |
61
+ | `@include t.touch-target($extend: true)` | the box keeps its size; a centred `::after` grows the HIT AREA to the target — a chart point, a dense icon row |
62
+ | `@include t.glow-edge` | `box-shadow: inset 0 0 0 var(--stroke-hairline) <accent / 0.55>, var(--shadow-glow-sm)` — `t.glow-edge('glow-lg', 0.8)` picks rung and ring alpha |
63
+ | `@include t.grid-texture` | a faint accent grid on a `t.space(6)` pitch with scanlines over it, as `background-image` only; `t.grid-texture(t.space(4), 0.1, false)` drops the scanlines. Withdrawn under `prefers-contrast: more` and `forced-colors: active`; animates nothing |
37
64
 
38
65
  A rung is `sm md lg xl 2xl`, quoted or bare. Prefer a container query (`t.container` + `t.container-query`) to all three breakpoint mixins.
39
66
 
@@ -47,7 +74,7 @@ A rung is `sm md lg xl 2xl`, quoted or bare. Prefer a container query (`t.contai
47
74
  | No physical directions | `margin-inline`, `inset-inline-start`, `text-align: start` — RTL needs no second stylesheet. Centring is `inset-inline` + `margin-inline: auto`, never a 50% logical inset beside a physical −50% translate, and an edge shadow's physical x offset gets a `[dir='rtl']` mirror (`components/rtl-sheets.test.ts`) |
48
75
  | No hardcoded strings | labels are props, or `t()` through `UI_KEYS` |
49
76
  | One token source | `src/tokens/*.scss` is canonical; `tokens.ts` mirrors it and `x verify` fails on drift |
50
- | AA contrast, both themes | `contrast.test.ts` measures every pairing a component renders — text, status fills, soft tints, focus rings, borders |
77
+ | AA contrast, both themes | `contrast.test.ts` measures every pairing a component renders — text, status fills, soft tints, focus rings, borders, chart series (3:1 on every surface) |
51
78
 
52
79
  ## Contrast
53
80
 
@@ -118,16 +145,16 @@ review comment. Three properties are structural, not documented:
118
145
 
119
146
  ## Toasts
120
147
 
121
- `Toast` and `ToastRegion` render; `createToastStore()` is the queue behind them, and `<Toaster>` is
148
+ `Toast` and `ToastRegion` render; `toastStore()` is the queue behind them, and `<Toaster>` is
122
149
  the one way to draw it.
123
150
 
124
151
  ```tsx
125
- import { createToastStore, Toaster } from '@ultimat3/ui';
152
+ import { toastStore, Toaster } from '@ultimat3/ui';
126
153
 
127
154
  declare const t: (key: string) => string;
128
155
  declare const restore: () => void;
129
156
 
130
- const toasts = createToastStore();
157
+ const toasts = toastStore();
131
158
  toasts.show({ message: t('post.saved'), tone: 'success' });
132
159
  toasts.show({ message: t('post.deleted'), action: { label: t('undo'), onAction: restore } });
133
160
 
@@ -157,11 +184,32 @@ Four composites cover the frame of an app screen. Below them are `Container`,
157
184
  | `Section` | a labelled `section` with a real heading and `aria-labelledby` | second-level structure inside a page |
158
185
  | `Toolbar` | `role="toolbar"` strip, start + end slots, arrow-key roving between its buttons (`As of 2026-08`) | filters and actions above a table or list |
159
186
 
160
- `AppShell` holds no state: below `md` the sidebar becomes a band above the content,
161
- and an off-canvas menu is `Drawer` — the one component that already does that.
187
+ `AppShell` holds no state: below `md` the sidebar is a native `popover` panel behind a menu
188
+ button (`menuLabel`), opened, closed and dismissed by the browser with no script; where `popover`
189
+ is unsupported it stays a band above the content. A modal off-canvas panel is still `Drawer`.
162
190
  Heading levels are props (`headingTag`, `nextHeadingLevel`), so a nested `Section`
163
191
  never skips a level.
164
192
 
193
+ ### Data display
194
+
195
+ Server-rendered, no island and no JS: a chart costs a route nothing until it asks to be focusable.
196
+ Every chart stands in a `ChartFrame` — a `figure`, its caption, a legend and a visually-hidden data
197
+ table — and every series has a dash and a marker as well as a colour (`chart-1` … `chart-8`).
198
+
199
+ | Component | Renders | Use for |
200
+ |---|---|---|
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 |
203
+ | `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 |
205
+ | `ChartFrame` | the figure, caption, legend and data table every chart stands in | a custom chart |
206
+ | `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 |
208
+ | `Table` | a captioned table with a sticky header; `stickyFirstColumn` | static tabular data |
209
+ | `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 |
211
+
212
+
165
213
  ```tsx
166
214
  <AppShell header={<Toolbar label={t('nav.main')}>{nav}</Toolbar>} sidebar={<SideNav />}>
167
215
  <PageHeader
@@ -281,8 +329,13 @@ Components cancel theirs on cleanup, so a filter never fires into a tree that is
281
329
  override, no forked package, no second entry point — one call, validated, rendered
282
330
  as the custom properties that beat `theme.scss` at every specificity level it emits.
283
331
 
332
+ An app declares it once, as `export const brand` in `apps/web/shared/theme.ts`. `x dev`, the
333
+ container, `x build`'s static export and `/admin` inline it after the surface stylesheet, with its
334
+ `sha256` in `style-src`; with no such module nothing is emitted. There is nothing to paste.
335
+
284
336
  ```ts
285
- import { brandStyleCspSource, brandStyleTag, defineTheme } from '@ultimat3/ui';
337
+ // apps/web/shared/theme.ts
338
+ import { defineTheme } from '@ultimat3/ui';
286
339
 
287
340
  export const brand = defineTheme({
288
341
  colors: {
@@ -292,27 +345,56 @@ export const brand = defineTheme({
292
345
  radius: { md: '0.125rem', lg: '0.25rem' },
293
346
  font: { sans: "Inter, system-ui, sans-serif" },
294
347
  });
295
-
296
- // in <head>, AFTER global.scss
297
- `${brandStyleTag(brand)}`;
298
-
299
- // …and the one source that admits it under the framework's locked CSP. The baseline is
300
- // `style-src 'self' 'sha256-…'` with no 'unsafe-inline', so a tag whose hash the header does
301
- // not carry is a stylesheet the browser parses zero rules out of.
302
- `Content-Security-Policy: style-src 'self' ${brandStyleCspSource(brand)}`;
303
348
  ```
304
349
 
350
+ `brandStyleTag(brand)` and `brandStyleCspSource(brand)` are what the framework emits — exported
351
+ for a host that renders documents itself.
352
+
305
353
  | Slot | Accepts | Refused with |
306
354
  |---|---|---|
307
355
  | `colors.light` / `colors.dark` | any `ColorRole`, as `R G B` channels | `X_TOKEN_UNKNOWN` for the role, `X_UI_INVALID_VALUE` for the value |
308
356
  | `radius` | any `RadiusName`, as a bare CSS length | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
309
- | `font` | `sans`, `mono`, as a `font-family` list | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
357
+ | `font` | `sans`, `mono`, `data`, as a `font-family` list | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
358
+ | `shadows.light` / `shadows.dark` | any `ShadowName`, as a `box-shadow`: comma-separated layers of lengths plus `rgb(R G B / a)` or `rgb(var(--color-<role>) / a)`, or `none` | `X_TOKEN_UNKNOWN` for the rung, `X_UI_INVALID_VALUE` for the value |
359
+ | `preset` | a `THEME_PRESETS` name — `'scifi'` | `X_UI_INVALID_VALUE` |
310
360
 
311
361
  Values are validated, never escaped: the output goes into a `<style>` element, so
312
362
  anything carrying `;`, `}` or `</style>` is a refusal at the app's entry point rather
313
363
  than a CSS injection. Every component in the system follows the override — they only
314
364
  ever read the roles, never a colour.
315
365
 
366
+ ### The sci-fi preset
367
+
368
+ `defineTheme({ preset: 'scifi' })` swaps the whole palette for a dashboard look: near-black
369
+ panels in dark (`bg` `5 7 13`), cool paper in light, a cyan `accent` whose `accent-strong` (hover)
370
+ is magenta, its own eight chart series, sharper radii, and — dark only — every elevation rung
371
+ redrawn as an accent hairline plus a halo, so each card and popover has a glowing edge with no
372
+ stylesheet of yours. It is a **base**: every other slot layers onto it role by role, and the
373
+ merged palette is what `CONTRAST_PAIRS` measures.
374
+
375
+ ```ts
376
+ import { defineTheme } from '@ultimat3/ui';
377
+
378
+ export const brand = defineTheme({
379
+ preset: 'scifi',
380
+ colors: { dark: { 'chart-1': '120 220 255' } }, // scifi, with your own first series
381
+ });
382
+ ```
383
+
384
+ The preset ships tokens only (axiom 8). The texture and the edge are yours to place:
385
+
386
+ ```scss
387
+ @use '@ultimat3/ui/tokens' as t;
388
+
389
+ .main { background-color: t.role('bg'); @include t.grid-texture; }
390
+ .tile { @include t.surface; @include t.glow-edge; }
391
+ .tile[aria-current='true'] { @include t.glow-edge('glow-lg', 0.8); }
392
+ ```
393
+
394
+ A dashboard that should open dark whatever the OS says sets the app default theme
395
+ (`data-theme-default`, [Theme resolution](#theme-resolution)) — the preset never forces a theme.
396
+ Like every brand, export it from `apps/web/shared/theme.ts` and the framework ships it with its CSP hash.
397
+
316
398
  ## The two render paths
317
399
 
318
400
  Every component renders on the server. Half of them read the ambient presentation context —
@@ -358,7 +440,7 @@ import '../../shared/global'; // `shared/global.scss` is the app's one `@use '@u
358
440
 
359
441
  ```tsx
360
442
  // A client entry — an island's `mount()`, or a hydrated app shell. In this order, once.
361
- import { createTranslator } from '@ultimat3/i18n';
443
+ import { catalogTranslator } from '@ultimat3/i18n';
362
444
  import { setSolidRuntime, UiProvider } from '@ultimat3/ui';
363
445
  import type { JSX } from 'solid-js';
364
446
  import {
@@ -395,7 +477,7 @@ export function mount(el: HTMLElement, props: Props): void {
395
477
  locale={props.locale}
396
478
  timeZone={props.timeZone}
397
479
  currency={props.currency}
398
- t={createTranslator(props.strings, props.locale)}
480
+ t={catalogTranslator(props.strings, props.locale)}
399
481
  >
400
482
  {props.tree}
401
483
  </UiProvider>
@@ -406,7 +488,7 @@ export function mount(el: HTMLElement, props: Props): void {
406
488
  ```
407
489
 
408
490
  **The prop is `t`, it takes a `Translator`, and omitting it is not neutral.** `<UiProvider>` with
409
- no `t` falls back to `fallbackTranslator(locale)` — `createTranslator({}, locale)`, an **empty**
491
+ no `t` falls back to `fallbackTranslator(locale)` — `catalogTranslator({}, locale)`, an **empty**
410
492
  catalog — so every built-in string in the tree renders its key: `<Dialog>`'s close button reads
411
493
  `⟦ui.close⟧`, `<Field>`'s marker `⟦ui.required⟧`. The keys are `UI_KEYS`, and they live in the
412
494
  framework catalog the SERVER has registered; a browser chunk has none, which is why
@@ -462,7 +544,7 @@ is a type error rather than a review comment.
462
544
 
463
545
  | Prop | Emitted |
464
546
  |---|---|
465
- | `variants` | `srcset`, descriptors derived and ordered ascending (`srcsetFor`) |
547
+ | `variants` | `srcset`, descriptors derived and ordered ascending (`variantSrcset`) |
466
548
  | `sources` | `{ avif?, webp? }` width lists → `<source type="image/avif">`, then `image/webp`, before the `<img>` (`sourceSetsFor`) |
467
549
  | `sizes` | `sizes`, verbatim, on the `<img>` and every `<source>` |
468
550
  | `priority` | `loading="eager"` + `fetchpriority="high"`; otherwise `lazy` + `auto`, always `decoding="async"` |
@@ -583,9 +665,12 @@ refuses the submit again because Enter in a text field touches no button at all.
583
665
 
584
666
  A failed submit focuses the **first invalid control** (`form.firstInvalidField()`, in declaration
585
667
  order — a server may report the last field first), and the announced summary keeps the focus only
586
- when the rejection names no control at all. `form.touch(path)` and `form.edit(path, value)` record
587
- touched and dirty against the binding's `initial`; an empty control and an absent baseline are the
588
- same thing, so deleting what you just typed leaves the form clean.
668
+ when the rejection names no control at all. Focus moves once per failure: a `touch()` or `edit()`
669
+ afterwards re-renders the form but never pulls focus back, so the user can work down the list.
670
+ `form.touch(path)` and `form.edit(path, value)` record touched and dirty against the binding's
671
+ `initial`; an empty control and an absent baseline are the same thing, so deleting what you just
672
+ typed leaves the form clean. A successful submit clears dirty except for the fields edited while it
673
+ was in flight — the server never saw those, so a navigation guard still has something to protect.
589
674
 
590
675
  Blur-time validation is deliberately **absent**: this binding is server-authoritative, the local
591
676
  parse's value is already discarded, and a client-side "is this field valid" would be a second source
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ui",
3
- "version": "24.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "LICENSE"
41
41
  ],
42
42
  "engines": {
43
- "bun": ">=1.4.0"
43
+ "bun": ">=1.4.2"
44
44
  },
45
45
  "scripts": {
46
46
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -49,10 +49,10 @@
49
49
  "icons": "bun run src/icons/build-icons.ts"
50
50
  },
51
51
  "dependencies": {
52
- "@ultimat3/core": "24.0.0",
53
- "@ultimat3/i18n": "24.0.0",
54
- "@ultimat3/money": "24.0.0",
55
- "@ultimat3/time": "24.0.0"
52
+ "@ultimat3/core": "25.0.0",
53
+ "@ultimat3/i18n": "25.0.0",
54
+ "@ultimat3/money": "25.0.0",
55
+ "@ultimat3/time": "25.0.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "solid-js": "^1.9.0"
package/src/a11y.ts CHANGED
@@ -52,7 +52,7 @@ export function useId(prefix = 'u'): string {
52
52
  }
53
53
 
54
54
  /** Test-only: make id assertions deterministic. */
55
- export function resetIdCounter(): void {
55
+ export function resetUseIdCounter(): void {
56
56
  idCounter = 0;
57
57
  islandScope = undefined;
58
58
  }
@@ -90,7 +90,7 @@ export interface FocusTrap {
90
90
  * branch that pulls it back was unreachable by construction. Every branch still tests
91
91
  * `root.contains`, so the trap only acts while it is the active one.
92
92
  */
93
- export function createFocusTrap(root: HTMLElement): FocusTrap {
93
+ export function focusTrap(root: HTMLElement): FocusTrap {
94
94
  let previous: HTMLElement | null = null;
95
95
 
96
96
  /**
@@ -181,7 +181,7 @@ export function nextRovingIndex(
181
181
  }
182
182
 
183
183
  /** Wire a roving group: one tabbable item, arrows move focus and selection. */
184
- export function createRovingTabindex(
184
+ export function rovingTabindex(
185
185
  getItems: () => readonly HTMLElement[],
186
186
  options: RovingOptions = {},
187
187
  ): (event: KeyboardEvent) => void {