@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.
- package/CATALOG.md +117 -13
- package/CLAUDE.md +14 -5
- package/README.md +109 -24
- package/package.json +6 -6
- package/src/a11y.ts +3 -3
- package/src/catalog/build-catalog.ts +2 -2
- package/src/components/Accordion.tsx +8 -2
- package/src/components/AppShell.module.scss +105 -4
- package/src/components/AppShell.tsx +48 -10
- package/src/components/BarChart.module.scss +47 -9
- package/src/components/BarChart.tsx +152 -72
- package/src/components/Card.tsx +52 -16
- package/src/components/ChartFrame.module.scss +188 -0
- package/src/components/ChartFrame.tsx +155 -0
- package/src/components/Container.tsx +46 -9
- package/src/components/DataTable.module.scss +190 -0
- package/src/components/DataTable.tsx +124 -17
- package/src/components/Divider.tsx +0 -2
- package/src/components/DonutChart.module.scss +73 -0
- package/src/components/DonutChart.tsx +154 -0
- package/src/components/Dropzone.tsx +9 -1
- package/src/components/ErrorState.tsx +39 -12
- package/src/components/Form.tsx +17 -4
- package/src/components/Gauge.module.scss +48 -0
- package/src/components/Gauge.tsx +67 -0
- package/src/components/Grid.tsx +34 -13
- package/src/components/Image.tsx +2 -2
- package/src/components/InlineBar.module.scss +23 -0
- package/src/components/InlineBar.tsx +31 -0
- package/src/components/LineChart.module.scss +147 -0
- package/src/components/LineChart.tsx +169 -0
- package/src/components/Menu.tsx +9 -5
- package/src/components/PageHeader.tsx +5 -2
- package/src/components/Popover.tsx +2 -2
- package/src/components/Section.tsx +45 -26
- package/src/components/Stack.tsx +59 -18
- package/src/components/Table.module.scss +30 -0
- package/src/components/Table.tsx +14 -3
- package/src/components/Tabs.tsx +2 -2
- package/src/components/Text.tsx +41 -15
- package/src/components/Toaster.tsx +1 -1
- package/src/components/Toolbar.tsx +7 -6
- package/src/components/app-shell-view.ts +6 -4
- package/src/components/bar-chart-view.ts +5 -4
- package/src/components/chart-frame-view.ts +219 -0
- package/src/components/chart-ticks-view.ts +173 -0
- package/src/components/data-table-view.ts +68 -0
- package/src/components/donut-chart-view.ts +120 -0
- package/src/components/file-input-view.ts +1 -1
- package/src/components/gauge-view.ts +43 -0
- package/src/components/heading-node.tsx +62 -0
- package/src/components/image-source.ts +3 -3
- package/src/components/line-chart-view.ts +153 -0
- package/src/components/money-view.ts +2 -7
- package/src/form/form-binding.ts +23 -6
- package/src/form/use-form.ts +3 -3
- package/src/global.scss +9 -0
- package/src/icons/build-icons.ts +15 -6
- package/src/index.ts +45 -9
- package/src/jsx-probe.ts +5 -4
- package/src/theme/brand-declarations.ts +113 -0
- package/src/theme/brand.ts +98 -109
- package/src/theme/context.ts +1 -1
- package/src/theme/preset-scifi.ts +99 -0
- package/src/theme/preset-shape.ts +18 -0
- package/src/theme/presets.ts +15 -0
- package/src/theme/shadow-value.ts +39 -0
- package/src/theme/theme.ts +1 -1
- package/src/toast/toast-store.ts +1 -1
- package/src/toast/use-toasts.ts +1 -1
- package/src/tokens/_colors.scss +20 -0
- package/src/tokens/_effects.scss +59 -0
- package/src/tokens/_index.scss +2 -0
- package/src/tokens/_mixins.scss +6 -4
- package/src/tokens/_shadow.scss +16 -0
- package/src/tokens/_touch.scss +39 -0
- package/src/tokens/_typography.scss +14 -0
- package/src/tokens/color-tokens.ts +133 -0
- package/src/tokens/colour-vision.ts +106 -0
- package/src/tokens/contrast-pairs.ts +6 -1
- package/src/tokens/contrast.ts +1 -1
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +41 -87
- package/src/components/qr-reader.ts +0 -187
- package/src/fake-dom.ts +0 -206
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
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),
|
|
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
|
|
114
|
-
| `
|
|
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
|
|
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 — `
|
|
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,
|
|
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
|
|
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/` |
|
|
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; `
|
|
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 {
|
|
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 =
|
|
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
|
|
161
|
-
|
|
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
|
-
|
|
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 {
|
|
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={
|
|
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)` — `
|
|
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 (`
|
|
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. `
|
|
587
|
-
|
|
588
|
-
|
|
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": "
|
|
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.
|
|
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": "
|
|
53
|
-
"@ultimat3/i18n": "
|
|
54
|
-
"@ultimat3/money": "
|
|
55
|
-
"@ultimat3/time": "
|
|
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
|
|
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
|
|
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
|
|
184
|
+
export function rovingTabindex(
|
|
185
185
|
getItems: () => readonly HTMLElement[],
|
|
186
186
|
options: RovingOptions = {},
|
|
187
187
|
): (event: KeyboardEvent) => void {
|