@ultimat3/ui 22.15.0 → 24.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 (65) hide show
  1. package/CATALOG.md +45 -14
  2. package/CLAUDE.md +16 -52
  3. package/README.md +69 -13
  4. package/package.json +6 -5
  5. package/src/a11y.ts +24 -10
  6. package/src/catalog/parse-component.ts +85 -12
  7. package/src/catalog/render-catalog.ts +21 -1
  8. package/src/components/Avatar.module.scss +1 -1
  9. package/src/components/BarChart.module.scss +12 -1
  10. package/src/components/BarChart.tsx +41 -15
  11. package/src/components/Button.tsx +3 -8
  12. package/src/components/Card.module.scss +1 -1
  13. package/src/components/Checkbox.module.scss +4 -4
  14. package/src/components/Checkbox.tsx +6 -0
  15. package/src/components/CommandPalette.module.scss +8 -5
  16. package/src/components/DataTable.module.scss +2 -0
  17. package/src/components/DataTable.tsx +135 -71
  18. package/src/components/Dialog.module.scss +2 -2
  19. package/src/components/Dropzone.module.scss +6 -4
  20. package/src/components/Dropzone.tsx +9 -8
  21. package/src/components/FileInput.module.scss +4 -2
  22. package/src/components/FileInput.tsx +6 -2
  23. package/src/components/Form.module.scss +2 -2
  24. package/src/components/Input.module.scss +1 -1
  25. package/src/components/Link.tsx +64 -12
  26. package/src/components/Meter.tsx +3 -3
  27. package/src/components/Pagination.tsx +89 -37
  28. package/src/components/Popover.module.scss +17 -20
  29. package/src/components/Popover.tsx +12 -5
  30. package/src/components/Radio.module.scss +2 -2
  31. package/src/components/Select.module.scss +3 -3
  32. package/src/components/Skeleton.module.scss +1 -1
  33. package/src/components/Spinner.module.scss +3 -3
  34. package/src/components/Switch.module.scss +2 -2
  35. package/src/components/Table.module.scss +2 -2
  36. package/src/components/Tabs.module.scss +11 -5
  37. package/src/components/Textarea.tsx +7 -4
  38. package/src/components/ThemeToggle.module.scss +1 -1
  39. package/src/components/Toast.module.scss +2 -2
  40. package/src/components/Tooltip.module.scss +8 -3
  41. package/src/components/bar-chart-view.ts +35 -5
  42. package/src/components/button-classes.ts +25 -0
  43. package/src/components/file-input-view.ts +31 -12
  44. package/src/components/link-classes.ts +38 -0
  45. package/src/components/link-target.ts +34 -1
  46. package/src/components/meter-view.ts +13 -0
  47. package/src/components/qr-matrix.ts +16 -23
  48. package/src/components/qr-reader.ts +187 -0
  49. package/src/components/relative-time-view.ts +11 -4
  50. package/src/form/form-binding.ts +9 -8
  51. package/src/icons/build-icons.ts +96 -20
  52. package/src/index.ts +15 -9
  53. package/src/sass-probe.ts +55 -0
  54. package/src/theme/brand.ts +22 -3
  55. package/src/theme/theme.ts +35 -10
  56. package/src/toast/toast-state.ts +0 -0
  57. package/src/tokens/_index.scss +3 -1
  58. package/src/tokens/_mixins.scss +56 -8
  59. package/src/tokens/_motion.scss +4 -0
  60. package/src/tokens/_stroke.scss +20 -0
  61. package/src/tokens/_units.scss +47 -0
  62. package/src/tokens/reset.scss +4 -4
  63. package/src/tokens/theme.scss +2 -0
  64. package/src/tokens/tokens.ts +6 -0
  65. package/src/theme/inline-script.ts +0 -49
package/CATALOG.md CHANGED
@@ -103,13 +103,14 @@ Small status label. Tone maps straight onto the status colour roles so the same
103
103
 
104
104
  ### BarChart
105
105
 
106
- A bar chart of one series — clicks per day, signups per week — 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`.
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
107
 
108
108
  | Prop | Type | Required | Notes |
109
109
  |---|---|---|---|
110
110
  | `label` | `string` | yes | The accessible name for the whole chart, already translated. |
111
111
  | `points` | `readonly ChartPoint[]` | yes | Oldest first. Every bar comes from exactly this array — the caller zero-fills gaps. |
112
112
  | `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. |
113
114
  | `class` | `string` | — | |
114
115
 
115
116
  ### Breadcrumb
@@ -170,6 +171,7 @@ Native checkbox with a token-drawn indicator. The label element wraps the input,
170
171
  | `id` | `string` | — | |
171
172
  | `name` | `string` | — | |
172
173
  | `value` | `string` | — | |
174
+ | `form` | `string` | — | The id of the `<form>` this box submits with, when it cannot sit inside it — a row checkbox in a table whose page already holds another form. The platform's own association, no script. |
173
175
  | `checked` | `boolean` | — | |
174
176
  | `indeterminate` | `boolean` | — | Tri-state for "some children selected". The ONLY thing mirrored to `aria-checked`. |
175
177
  | `disabled` | `boolean` | — | |
@@ -258,7 +260,6 @@ Data-driven table: sortable headers, cursor pagination, and the four states a re
258
260
  | `rows` | `readonly Row[]` | yes | |
259
261
  | `rowKey` | `(row: Row) => string` | yes | |
260
262
  | `sort` | `SortState` | — | |
261
- | `onSortChange` | `(sort: SortState \| undefined) => void` | — | |
262
263
  | `loading` | `boolean` | — | |
263
264
  | `error` | `unknown` | — | An UltimateError (or anything shaped like one) from the query. |
264
265
  | `onRetry` | `(() => void)` | — | |
@@ -266,11 +267,14 @@ Data-driven table: sortable headers, cursor pagination, and the four states a re
266
267
  | `emptyDescription` | `string` | — | |
267
268
  | `nextCursor` | `string` | — | Opaque cursors from the query result; absent means no further page. |
268
269
  | `prevCursor` | `string` | — | |
269
- | `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | |
270
270
  | `stickyHeader` | `boolean` | — | |
271
271
  | `density` | `'comfortable' \| 'compact'` | — | |
272
272
  | `skeletonRows` | `number` | — | Placeholder row count while loading. Match the usual page size. |
273
273
  | `class` | `string` | — | |
274
+ | `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | Callback paging. Never beside `hrefFor` — the type refuses the pair. |
275
+ | `onSortChange` | `((sort: SortState \| undefined) => void)` | — | Callback sorting. Never beside `sortHrefFor` — the type refuses the pair. |
276
+ | `hrefFor` | `(cursor: string, direction: 'next' \| 'prev') => string` | — | Link paging: the URL of the page a cursor names, handed to `<Pagination hrefFor>`. |
277
+ | `sortHrefFor` | `((sort: SortState \| undefined) => string)` | — | Link sorting: the URL of the list under the NEXT sort state of a header's cycle (`undefined` is "unsorted"). Absent, a sortable header is plain text — never a button with no handler. |
274
278
 
275
279
  ### DateTime
276
280
 
@@ -546,7 +550,7 @@ A keyboard key or chord as the user reads it — "⌘K", "Esc", "/". A native `<
546
550
 
547
551
  ### Link
548
552
 
549
- Anchor primitive. External links get `rel` hardening and a translated "opens in a new tab" hint automatically — never a bare `target="_blank"`.
553
+ 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.
550
554
 
551
555
  | Prop | Type | Required | Notes |
552
556
  |---|---|---|---|
@@ -554,12 +558,19 @@ Anchor primitive. External links get `rel` hardening and a translated "opens in
554
558
  | `children` | `JSX.Element` | yes | |
555
559
  | `external` | `boolean` | — | |
556
560
  | `externalHint` | `string` | — | Announced suffix for external links; supply via `t()` at the call site. |
557
- | `underline` | `'always' \| 'hover' \| 'none'` | — | |
558
- | `tone` | `'accent' \| 'inherit'` | — | |
561
+ | `rel` | `'next' \| 'prev'` | — | A pager's direction. Joins the hardening an external link always carries, never replaces it. |
559
562
  | `id` | `string` | — | |
560
563
  | `class` | `string` | — | |
561
564
  | `aria-current` | `'page' \| 'step' \| 'true' \| false` | — | |
562
565
  | `onClick` | `JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>` | — | |
566
+ | `appearance` | `'link' \| 'button'` | — | `button` wears Button's classes on the same `<a>`; the two looks take different props. |
567
+ | `underline` | `'always' \| 'hover' \| 'none'` | — | |
568
+ | `tone` | `'accent' \| 'inherit' \| Tone` | — | A text link: `accent` or `inherit`. A button-link: any `Tone`. |
569
+ | `variant` | `ButtonVariant` | — | `appearance="button"` only. |
570
+ | `size` | `Size` | — | `appearance="button"` only. |
571
+ | `fullWidth` | `boolean` | — | `appearance="button"` only. |
572
+ | `iconStart` | `JSX.Element` | — | `appearance="button"` only. |
573
+ | `iconEnd` | `JSX.Element` | — | `appearance="button"` only. |
563
574
 
564
575
  ### LocaleSwitcher
565
576
 
@@ -631,29 +642,30 @@ The top of a screen: breadcrumbs, the page's one heading, a description, and the
631
642
 
632
643
  ### Pagination
633
644
 
634
- Cursor-first pagination — the only shape a Postgres-backed list should use. Numbered mode exists for prerendered archives where the total is known.
645
+ Cursor-first pagination — the only shape a Postgres-backed list should use. Numbered mode exists for prerendered archives where the total is known. One mode per use: callbacks (`onCursor` / `onPage`) for an island, or `hrefFor` for a server-rendered list — anchors the browser follows, with no script on the page.
635
646
 
636
647
  | Prop | Type | Required | Notes |
637
648
  |---|---|---|---|
638
649
  | `nextCursor` | `string` | — | Opaque cursor for the following page; absent disables "next". |
639
650
  | `prevCursor` | `string` | — | |
640
- | `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | |
641
- | `page` | `number` | — | Numbered mode: 1-based page and total. Ignored when cursors are present. |
642
- | `totalPages` | `number` | — | |
643
- | `onPage` | `((page: number) => void)` | — | |
644
651
  | `labelPrevious` | `string` | — | Already-translated; falls back to the ui.* catalog keys. |
645
652
  | `labelNext` | `string` | — | |
646
653
  | `class` | `string` | — | |
654
+ | `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | Callback mode. Never beside `hrefFor` — the type refuses the pair. |
655
+ | `page` | `number` | — | Numbered mode: 1-based page and total. Ignored when cursors are present. |
656
+ | `totalPages` | `number` | — | |
657
+ | `onPage` | `((page: number) => void)` | — | |
658
+ | `hrefFor` | `(cursor: string, direction: 'next' \| 'prev') => string` | — | Link mode: the URL of the page a cursor names. Renders `<a rel="next\|prev">` and attaches no handler, so it needs no island. Cursor paging only — never beside `onCursor`, `onPage`, `page` or `totalPages`. |
647
659
 
648
660
  ### Popover
649
661
 
650
- Non-modal anchored panel. Positioning is CSS-only (a relatively positioned anchor plus logical insets), so there is no measure/reflow loop and the panel flips sides automatically under `dir="rtl"`.
662
+ Non-modal anchored panel. Positioning is CSS-only (a relatively positioned anchor plus logical insets and auto-margin centring), so there is no measure/reflow loop and the panel mirrors under `dir="rtl"` — `rtl-sheets.test.ts` holds that.
651
663
 
652
664
  | Prop | Type | Required | Notes |
653
665
  |---|---|---|---|
654
666
  | `open` | `boolean` | yes | |
655
667
  | `onOpenChange` | `(open: boolean) => void` | yes | |
656
- | `trigger` | `(control: { id: string; 'aria-expanded': boolean; 'aria-controls': string; }) => JSX.Element` | yes | The anchor. Wire its onClick to `onOpenChange` in the caller. |
668
+ | `trigger` | `(control: { id: string; 'aria-expanded': boolean; 'aria-controls': string \| undefined; }) => JSX.Element` | yes | The anchor. Wire its onClick to `onOpenChange` in the caller. `aria-controls` is present only while the panel is mounted: an IDREF to an element not in the document names nothing. |
657
669
  | `children` | `JSX.Element` | yes | |
658
670
  | `label` | `string` | yes | Already-translated accessible name for the panel. |
659
671
  | `placement` | `Placement` | — | |
@@ -1014,6 +1026,14 @@ charts and email.
1014
1026
  | `pill` | `999px` |
1015
1027
  | `full` | `50%` |
1016
1028
 
1029
+ ### Stroke — `--stroke-*`
1030
+
1031
+ | Token | Value |
1032
+ |---|---|
1033
+ | `hairline` | `1px` |
1034
+ | `thick` | `2px` |
1035
+ | `heavy` | `3px` |
1036
+
1017
1037
  ### Font size — `--text-*`
1018
1038
 
1019
1039
  | Token | Value |
@@ -1078,7 +1098,7 @@ charts and email.
1078
1098
  | `toast` | `700` |
1079
1099
  | `skip-nav` | `800` |
1080
1100
 
1081
- ### Breakpoints — `@include t.respond-to(<name>)`
1101
+ ### Breakpoints — `respond-to` · `respond-down` · `respond-between`
1082
1102
 
1083
1103
  | Token | Value |
1084
1104
  |---|---|
@@ -1087,3 +1107,14 @@ charts and email.
1087
1107
  | `lg` | `1024px` |
1088
1108
  | `xl` | `1280px` |
1089
1109
  | `2xl` | `1536px` |
1110
+
1111
+ `@include t.respond-to(md)` is `md` and wider, `t.respond-down(md)` is narrower than `md`, and
1112
+ `t.respond-between(md, lg)` is `md` up to `lg`. A max-width arm stops 0.02px under its rung, so
1113
+ no width matches two of them.
1114
+
1115
+ ### Computed lengths
1116
+
1117
+ | Function | Answers |
1118
+ |---|---|
1119
+ | `t.rem(24px)` | `1.5rem` — px, rem or a unitless px count in, rem out |
1120
+ | `t.fluid(1rem, 2rem, 20rem, 80rem)` | one `clamp()`: the first size at a 20rem viewport, the second at 80rem, linear between |
package/CLAUDE.md CHANGED
@@ -12,76 +12,38 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
12
12
 
13
13
  - **SCSS modules only.** `Foo.tsx` + `Foo.module.scss`, always paired. No Tailwind, no CSS-in-JS, no inline `style` except CSS custom properties.
14
14
  - **No raw colours.** Only `t.role('<role>')` / `var(--color-*)`. Canonical roles live in `src/tokens/_colors.scss`; `tokens.ts` mirrors them and `tokens.test.ts` fails on drift.
15
+ - **No raw length, breakpoint, z-index, shadow or duration** in any sheet here: `t.space()` / `t.stroke()` / `t.rem()`, `respond-*`, `t.z()`, `t.shadow()`, `t.duration()`. Held by `packages/cli/src/templates/scaffold-guards-style.test.ts`, which runs the guards `x new` ships over this package.
15
16
  - **Logical properties only.** `margin-inline`, `inset-inline-start`, `text-align: start`. A `left`/`right` in a stylesheet is a bug.
16
17
  - **solid-js is a type-only import.** All reactive access goes through `src/theme/solid-adapter.ts`. Never `import { createSignal } from 'solid-js'`.
17
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.
18
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
- - **`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` (the i18n barrel installs the framework catalog at import: 16.1 kB per island). `src/theme/ambient.ts` registers `ambientUiContext` into `ambient-slot.ts` at import; `index.ts` imports it BARE above every re-export, and `package.json`'s `browser` field maps it to `ambient.browser.ts` (bundlers only — `bun test` and SSR get the server's). A test reaching components by module path must `import './ambient'` itself, as `inert-render.test.ts` does. `fallbackTranslator` is held member-for-member against `createTranslator({})` by `context.test.ts`. `errors.ts` is pure: `registerErrorCodes()` is `src/error-registry.ts`, imported bare. `barrel-bytes.test.ts` pins the theme island's retained-module list.
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.
20
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.
21
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.
22
23
  - **Every locale-sensitive call takes the locale as an ARGUMENT, `toLocaleUpperCase` included.** `initialsOf(name, locale)` reads `useUi().locale`; a bare `toLocaleUpperCase()` reads the runtime's default (Turkish dotted `i` → `İ`).
23
24
  - **`UiProvider` is client-only and throws on the server** (`providerNeedsRuntimeError`, the same `X_UI_RUNTIME_MISSING`). A Provider in an inert tree reaches no descendant — the tree is built before the renderer walks it, so consumers are walked outside every owner and read the context default even with a real Solid runtime registered. Rendering the children anyway would drop its locale, zone, currency and translator silently. Making it work needs the *renderer* to scope a context around the walk; until then it refuses.
24
25
  - **A component may call `solid()` freely.** Its effects must stay DOM-only work — they simply never run on the server.
25
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
+ - **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.
26
28
  - **No hardcoded strings.** Label props, or `useUi().t(UI_KEYS.x)`. New built-in strings go in `src/i18n-keys.ts`.
27
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`.
28
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.
29
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.
30
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.
31
33
  - **One icon, one module.** The `Icon` component takes a `glyph`, not a name: a `name → glyph` map would be one module holding 1767 icons, and no bundler can split that. Per-icon imports are the whole point (1 icon = 104 B minified, 50 = 8.9 kB).
32
- - **No client runtime exists yet.** A new interactive component must be correct server-rendered and usable with scripting off — `<details>` for disclosure, `<input list>`+`<datalist>` for suggestions, a real `rel="next"` link for paging — with listeners and observers as additive extras. A component that renders nothing until JS runs does not ship.
34
+ - **Server-rendered first, an island second.** Components run in islands (`examples/dummy`'s settings and feed islands render them), but every one must still be correct server-rendered and usable with scripting off — `<details>` for disclosure, `<input list>`+`<datalist>` for suggestions, a real `rel="next"` link for paging — with listeners and observers as additive extras. A component that renders nothing until JS runs does not ship. In an island the body runs ONCE: a branch decided there (an early `return <ErrorState>`) is decided for the island's life, so branches live inside the returned JSX (`AsyncRegion`, `DataTable`). A server-only spelling is gated on `typeof document` (`Textarea`'s parser newline). Ids minted in an island carry a per-copy scope (`a11y.ts`), because each island bundles its own counter.
33
35
  - **`inert-render.test.ts` must not assume which factory its `.tsx` compiled to.** `@ultimat3/render` installs a process-global `.tsx` `onLoad` plugin at import, so in one `bun test` process a component may compile to render's `h`. The walker recognises both (`Symbol.for('ultimate.render.jsx')`), and the first test asserts a recognised node — otherwise the file renders `"[object Object]"` and passes by shard packing.
34
36
  - **Components are not unit-tested through a renderer.** `.tsx` compiles to `@ultimat3/render`'s `h`, which this package may not import, so every rule lives in a pure module beside the component (`icon-glyph.ts`, `accordion-view.ts`, `combobox-filter.ts`, `infinite-scroll-view.ts`) and *that* is what the tests assert.
35
37
  - Formatting logic lives in a pure `*-view.ts` next to the component (`money-view.ts`, `date-time-view.ts`) so it is testable with no renderer. Every other renderer-free core follows the same rule under its own name (`sort-state.ts`, `image-source.ts`) — the `.tsx` holds markup, never a rule.
36
- - **The rule being pure is not enough — the WIRING has to be tested too.** `createRovingTabindex` was correct and `Menu` handed it `[role="menuitem"]`, so a disabled item made every item after it unreachable and every assertion in the package still passed. `src/jsx-probe.ts` reads the props an element actually carries (a `tabindex`, an `aria-live`, an `onKeyDown`, a `ref`) and `src/fake-dom.ts` gives it a DOM where a disabled control REFUSES focus, exactly as the real one does. Both are test-only and neither is in `index.ts`. `jsx-probe`'s `probe`/`unprobe` are the one exception, reachable at the subpath `@ultimat3/ui/jsx-probe` and still absent from the barrel: `globalThis.React` is ONE property, so its install/restore counter has to be ONE counter — `@ultimat3/admin` (tier 5) kept a second pair, and interleaved installs restored the two harnesses in the wrong order, leaving the global holding a harness the run had already torn down. `components/interaction.test.ts` is where a keyboard or form-participation claim gets proven; asserting the pure helper alone is how these shipped.
38
+ - **The rule being pure is not enough — the WIRING is tested too.** `src/jsx-probe.ts` reads the props an element carries and `src/fake-dom.ts` is a DOM where a disabled control refuses focus; both TEST-ONLY, neither in `index.ts`. `probe`/`unprobe` are the framework's ONE owner of `globalThis.React`, reachable at `@ultimat3/ui/jsx-probe` (admin imports them). A keyboard or form-participation claim is proven in `components/interaction.test.ts`.
37
39
  - **A roving group excludes disabled items from both answers** — the set arrows walk and the one item holding the tab stop (`src/roving.ts`). `focus()` on a disabled control is a no-op, so a disabled item left in the list pins the reducer on its index forever. And a control that answers arrows itself (`handlesOwnArrowKeys`) keeps them: a `Toolbar` exists to hold a search field.
38
40
  - **A single-winner state attribute is decided by POSITION, never by a missing prop.** `Breadcrumb` gives `aria-current="page"` to the last item and to nothing else; an href-less ancestor renders as plain text with no `aria-current` at all. Reading "no href" as "is the current page" put two of them in one `<nav>`. `Tabs.tsx` is the same rule for `tabindex`, and `Breadcrumb.test.ts` proves it through `jsx-probe` rather than through the pure helper.
39
- - **One async region, four branches, and `empty` is unreachable while `pending`.** `asyncBranch`
40
- (`src/components/async-branch.ts`) is the ONLY place the `(pending, failed, empty, data)` decision
41
- is made — `AsyncRegion` renders it and `DataTable` calls it, so a table and a card list cannot
42
- disagree about what "loading with stale rows" looks like. The property is structural: an
43
- `AsyncState` (declared in `@ultimat3/core` since 21.0.0 — imported here, never re-exported, so
44
- it has one import path) in `pending` carries no data, so nothing can be found empty in it, and "No results"
45
- for one frame before the first page arrives is unconstructible rather than discouraged.
46
- `<AsyncRegion>`'s `empty` and `ready` are REQUIRED props, so forgetting the empty state is a type
47
- error. `refreshing` CARRIES the previous data — a refetch dims what is on screen (`aria-busy`) and
48
- never tears it down, which is what makes a search box feel fast; the empty branch is reachable
49
- only from a completed result that returned zero. `reserve` feeds the placeholder AND the
50
- `min-block-size` of every branch, so the skeleton and the loaded content cannot be written into
51
- different boxes.
52
- - **A toast dwell stops for THREE independent reasons, and one of them is `document.hidden`.**
53
- Hover and focus-within are the two everybody implements; a backgrounded tab spends the whole dwell
54
- and the corner is empty when the user comes back. `ToastHold` is a set, never a boolean: a pointer
55
- leaving a toast a keyboard user is still inside must not restart the countdown, which one flag
56
- cannot express. Duration is a TOKEN (`TOAST_DWELL_MS`), never a per-call number of milliseconds.
57
- The queue's rules are pure (`src/toast/toast-state.ts`) and the clock is injected (`ToastEnv`), so
58
- a server render gets `INERT_TOAST_ENV` — nothing scheduled — and `ToastRegion` still emits its
59
- empty live region, which is what the document has to carry BEFORE the first message.
60
- - **`announce()` writes into a region `AppShell` already rendered.** A live region created and
61
- filled in the same frame is not announced by most screen readers, so `announce()`'s own
62
- create-if-absent branch could only ever be right from its second call. `liveRegionAttrs` is the
63
- one derivation both halves read. It is for a state change with no surface of its own ("12
64
- results", "sorted by name"); a notification is a `Toast`, and two announcement paths for one
65
- message is how a screen reader reads it twice.
66
- - **`loading` never sets the native `disabled` attribute** (`Button`), and that WILL read as a
67
- mistake. A control that disables itself mid-flow drops focus to `<body>`, is exempt from the
68
- contrast minimum, announces no reason, and still does not prevent the double submit — that race is
69
- on the server. `aria-disabled` + a refused click keeps it focusable and readable; `Button.module.scss`
70
- restores full opacity under `[aria-busy='true']` because `t.disabled` dims to 0.55. `<Form busy>`
71
- refuses the submit again, because Enter in a text field touches no button at all.
72
- - **A failed submit focuses the first invalid CONTROL, and the summary only when there is none.**
73
- GOV.UK's summary-with-links shape is not available here: `Field` mints its control ids internally,
74
- and inverting that ownership is the drift `Field` exists to prevent. `firstInvalidField` answers in
75
- DECLARATION order, never issue order — a server may report the last field first. `fieldSelector`
76
- is the allowlist between a caller's string and a selector; a name the grammar refuses reaches no
77
- `querySelector`.
78
- - **`defineTheme()` refuses a palette that fails WCAG 2.2 AA** (`X_UI_CONTRAST_INSUFFICIENT`),
79
- measured against `CONTRAST_PAIRS` — the same table `contrast.test.ts` holds the shipped palette to,
80
- in SOURCE rather than in a test, so an app cannot ship a brand the design system would have failed
81
- its own suite over. Only pairings the brand can have CHANGED are measured, on the resolved palette
82
- (shipped channels + overrides): a new `accent` against the shipped `accent-fg` is the commonest way
83
- a brand goes unreadable. AA, never APCA — APCA is not a standard, and AA is the operative legal
84
- benchmark.
41
+ - **One async region, four branches, and `empty` is unreachable while `pending`.** `asyncBranch` (`src/components/async-branch.ts`) is the ONLY place the decision is made — `AsyncRegion` renders it, `DataTable` calls it. `AsyncState` is core's (imported, never re-exported). `empty` and `ready` are REQUIRED props; `refreshing` carries the previous data and dims it (`aria-busy`), never tears it down; `reserve` feeds the placeholder AND every branch's `min-block-size`.
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
+ - **`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
+ - **`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.
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.
85
47
  - **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.
86
48
  - **`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.
87
49
  - **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.
@@ -96,17 +58,16 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
96
58
 
97
59
  | Path | Responsibility |
98
60
  |---|---|
99
- | `src/tokens/*.scss` | canonical token maps + `_mixins.scss` authoring helpers |
61
+ | `src/tokens/*.scss` | canonical token maps + `_mixins.scss` authoring helpers (`respond-to`/`-down`/`-between`) + `_units.scss` (`rem()`, `fluid()`). Every `@error` is `string.unquote('X_CODE: … fix: …')` with a REGISTERED code — `mixins.test.ts` |
100
62
  | `src/tokens/theme.scss` | the only stylesheet that emits global custom properties |
101
63
  | `src/tokens/reset.scss` | the reset, ZERO specificity: every rule a component may restyle sits in `:where(…)` whole, pseudo-class inside (`:where(a:hover)`, never `:where(a):hover`). `a:hover` (0,1,1) beat `.primary` and hid a button link's text until 22.3.2. `reset.test.ts` computes it |
102
64
  | `src/theme/runtime-slot.ts` | the module-scope slot holding the app's Solid runtime — and nothing else, so registering one costs an island 72 B |
103
65
  | `src/theme/solid-adapter.ts` | the runtime's SHAPE, and the one rule that decides which runtime a render gets |
104
66
  | `src/theme/inert-runtime.ts` | `INERT_SOLID_RUNTIME` — what a server render IS, not a stub of what it lacks |
105
- | `src/theme/theme.ts` | resolution: stored choice → OS; all side effects via injected `ThemeEnv` |
67
+ | `src/theme/theme.ts` | resolution: stored choice → the theme the boot stamped on `<html>` (`ThemeEnv.current()`) → the app default (`data-theme-default`, `ThemeEnv.appDefault()`) → OS; clearing and the OS listener answer the app default too; all side effects via injected `ThemeEnv` |
106
68
  | `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 |
107
69
  | `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 |
108
70
  | `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 |
109
- | `src/theme/inline-script.ts` | anti-flash `<head>` snippet + its CSP sha256 |
110
71
  | `src/components/` | 62 components, `PascalCase.tsx` (component convention overrides the repo's kebab-case) |
111
72
  | `src/icons/glyphs/` | GENERATED: 1767 per-icon modules, `@ultimat3/ui/icons/<name>` → `icon<Name>` |
112
73
  | `src/icons/build-icons.ts` | the generator + the pinned `LUCIDE_VERSION`; `LICENSE.lucide` is upstream's ISC text |
@@ -127,6 +88,8 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
127
88
  | `src/form/use-form.ts` | the Solid shell: the same binding with its state in a signal |
128
89
  | `src/form/form-values.ts` | `FormData` → the nested object the action's input schema declares |
129
90
  | `src/form/field-binding.test.ts` | the WIRING, end to end: a rejection reaching `aria-invalid` and `aria-describedby` on the control it named |
91
+ | `src/sass-probe.ts` | TEST-ONLY: what Sass EMITS. `sass` is resolved from `packages/render`'s directory — never an import of render, never a second copy |
92
+ | `src/components/button-classes.ts` | the keys of a button's look, asked by `Button` AND `<Link appearance="button">` (`link-classes.ts`). `Button` gets no `href` |
130
93
  | `src/fake-dom.ts` | TEST-ONLY: a DOM where a disabled control refuses focus. Never exported from `index.ts` |
131
94
  | `src/jsx-probe.ts` | TEST-ONLY: a component's node tree, so a test can assert the props and call the handlers an element carries. `probe`/`unprobe` are the framework's ONE owner of `globalThis.React`, reached from `@ultimat3/admin` at `@ultimat3/ui/jsx-probe`; the walkers stay internal |
132
95
  | `src/barrel-bytes.test.ts` | the build error behind the three claims above: the setter's byte ceiling, barrel-vs-deep-path parity, and the theme-toggle island's retained-module list — the last two read off the `minify: false` banners, never a byte allowance |
@@ -143,6 +106,7 @@ bun test # from the repo root
143
106
  bun run --filter @ultimat3/ui typecheck
144
107
  bun run --filter @ultimat3/ui catalog # after any prop change — CATALOG.md is gated
145
108
  bun run --filter @ultimat3/ui icons # regenerate the glyph set (network; dev-only)
109
+ bun run --filter @ultimat3/ui icons --bump # move LUCIDE_VERSION to the latest lucide-static, then regenerate
146
110
  ```
147
111
 
148
112
  ## Deep import
package/README.md CHANGED
@@ -21,10 +21,22 @@ channels** so `rgb(var(--color-accent) / 0.12)` gives a tint with no extra token
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
23
 
24
- Scales: `--space-*` (4px base), `--text-*` (fluid `clamp()`), `--radius-*`,
24
+ Scales: `--space-*` (4px base), `--stroke-*` (line weights: `hairline thick heavy`), `--text-*` (fluid `clamp()`), `--radius-*`,
25
25
  `--shadow-*` (themed — dark gets deeper, higher-alpha shadows), `--duration-*`,
26
26
  `--easing-*`, `--z-*` (named ladder, no magic numbers).
27
27
 
28
+ Authoring helpers, from the same `@use '@ultimat3/ui/tokens' as t` — an app writes no mixin of its own for any of them:
29
+
30
+ | Helper | Emits |
31
+ |---|---|
32
+ | `@include t.respond-to(md)` | `@media (min-width: 768px)` — the rung and wider |
33
+ | `@include t.respond-down(md)` | `@media (max-width: 767.98px)` — narrower than the rung; 0.02px under it, so no width matches both arms |
34
+ | `@include t.respond-between(md, lg)` | `@media (min-width: 768px) and (max-width: 1023.98px)` |
35
+ | `t.rem(24px)` | `1.5rem` — px, rem or a unitless px count in; `t.rem(20px, 10px)` states another root |
36
+ | `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 |
37
+
38
+ 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
+
28
40
  ## The law
29
41
 
30
42
  | Rule | Enforcement |
@@ -32,7 +44,7 @@ Scales: `--space-*` (4px base), `--text-*` (fluid `clamp()`), `--radius-*`,
32
44
  | No raw colours | a hex or `rgb()` literal in a component stylesheet fails review; `tokens.test.ts` asserts the shared SCSS is hex-free |
33
45
  | No Tailwind | not a dependency, not a config, not an escape hatch |
34
46
  | No CSS-in-JS | styles are `Foo.module.scss` next to `Foo.tsx`, compiled at build |
35
- | No physical directions | `margin-inline`, `inset-inline-start`, `text-align: start` — RTL needs no second stylesheet |
47
+ | 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`) |
36
48
  | No hardcoded strings | labels are props, or `t()` through `UI_KEYS` |
37
49
  | One token source | `src/tokens/*.scss` is canonical; `tokens.ts` mirrors it and `x verify` fails on drift |
38
50
  | AA contrast, both themes | `contrast.test.ts` measures every pairing a component renders — text, status fills, soft tints, focus rings, borders |
@@ -193,7 +205,7 @@ commit the diff. There is no hand-edited icon in the package.
193
205
 
194
206
  ## Works without JavaScript
195
207
 
196
- Three components are interactive without a client runtime, because the platform already
208
+ These components are interactive without a client runtime, because the platform already
197
209
  has the behaviour. Each is correct server-rendered, and the script layer only adds.
198
210
 
199
211
  | Component | Platform base | The enhancement |
@@ -201,6 +213,8 @@ has the behaviour. Each is correct server-rendered, and the script layer only ad
201
213
  | `Accordion` | `<details>` / `<summary>`; `exclusive` is the native `name` group | `onToggle` notification, after the browser has applied it |
202
214
  | `Combobox` | `<input list>` + `<datalist>` — typing, filtering, keyboard, mobile | `onFilter`, debounced, for a live/server-side query |
203
215
  | `InfiniteScroll` | a real `rel="next"` link to the next page | an `IntersectionObserver` sentinel that calls `onLoadMore` and intercepts the click |
216
+ | `Pagination` with `hrefFor` | `<a rel="next">` / `<a rel="prev">`, one per cursor | none — link mode attaches no handler |
217
+ | `Link appearance="button"` | an `<a href>` wearing Button's classes | none |
204
218
 
205
219
  ```tsx
206
220
  <Accordion level={3} exclusive items={[{ id: 'ship', title: t('faq.ship'), panel: <p>…</p> }]} />
@@ -217,6 +231,47 @@ scripting off the control is a link, and a link needs somewhere to go. `Combobox
217
231
  what it is given by `value` (`filterOptions` — case- and accent-insensitive, prefix first),
218
232
  so the list is right on the first paint and after a form round-trip, not only once JS runs.
219
233
 
234
+ ### A pager and a button that navigate
235
+
236
+ ```tsx
237
+ import { Link, Pagination } from '@ultimat3/ui';
238
+ import type { JSX } from 'solid-js';
239
+
240
+ interface PostsFooterProps {
241
+ prevCursor?: string | undefined;
242
+ nextCursor?: string | undefined;
243
+ /** Already translated: `t('posts.new')` at the call site. */
244
+ newLabel: string;
245
+ }
246
+
247
+ export function PostsFooter(props: PostsFooterProps): JSX.Element {
248
+ return (
249
+ <footer>
250
+ <Link appearance="button" variant="secondary" size="sm" href="/posts/new">
251
+ {props.newLabel}
252
+ </Link>
253
+ <Pagination
254
+ prevCursor={props.prevCursor}
255
+ nextCursor={props.nextCursor}
256
+ hrefFor={(cursor, direction) =>
257
+ `/posts?${direction === 'next' ? 'after' : 'before'}=${cursor}`
258
+ }
259
+ />
260
+ </footer>
261
+ );
262
+ }
263
+ ```
264
+
265
+ | Rule | Held by |
266
+ |---|---|
267
+ | `Pagination` is one mode per use: `hrefFor`, or `onCursor` / `onPage` / `page` / `totalPages` — never both | the type: `PaginationProps` is `PaginationLinkProps \| PaginationCallbackProps`, and each forbids the other's props |
268
+ | `DataTable` takes the same pair — `hrefFor` or `onCursor` — and hands it to its pager | the type: `DataTableProps<Row>` is `DataTableLinkProps<Row> \| DataTableCallbackProps<Row>` |
269
+ | a `DataTable` sort header follows the table's mode: an anchor at `sortHrefFor(next)` in link mode, a button calling `onSortChange` in callback mode; a linked table with no `sortHrefFor` renders the header as text | the same union — `sortHrefFor` lives on `DataTableLinkProps`, `onSortChange` on `DataTableCallbackProps` — and `DataTable.test.ts` |
270
+ | link mode pages by cursor only; a side with no cursor is a disabled button, never an anchor with nowhere to go | `Pagination.test.ts` |
271
+ | a control that navigates is a `Link`; `Button` has no `href` | `ButtonProps` |
272
+ | a button-link takes `variant` / `tone` / `size` / `fullWidth` / `iconStart` / `iconEnd`; a text link takes `underline` and `tone: 'accent' \| 'inherit'` | the type: `LinkProps` is `TextLinkProps \| ButtonLinkProps` |
273
+ | both elements wear ONE set of classes | `buttonClassKeys` (`button-classes.ts`), asked by `Button` and `Link` |
274
+
220
275
  `debounce(fn, ms)` is exported on its own: trailing edge, `cancel()`, `flush()`, `pending()`.
221
276
  Components cancel theirs on cleanup, so a filter never fires into a tree that is gone.
222
277
 
@@ -441,14 +496,15 @@ one politeness — `politeness="assertive"` for a region that carries errors alo
441
496
 
442
497
  ## Theme resolution
443
498
 
444
- `explicit choice in localStorage` → `OS preference`. `setTheme()` persists,
445
- `clearTheme()` forgets and follows the OS again, and the OS listener only applies
446
- while nothing is stored. `themeInlineScriptTag()` goes in `<head>` and applies the
447
- result before first paint, so there is no flash and screenshots are deterministic:
448
-
449
- ```
450
- Content-Security-Policy: script-src 'self' 'sha256-…' # themeInlineScriptCspSource()
451
- ```
499
+ `explicit choice in localStorage` → `the theme already on <html>` → `defaultTheme(env)`: the
500
+ app's `theme.defaultMode` (`data-theme-default`), and the OS only when that is `'system'` or
501
+ absent. The framework's boot inlines `themeScript({ fallback })` from
502
+ `@ultimat3/render` and stamps `data-theme` before first paint, and `resolveTheme()` reads it back
503
+ through `ThemeEnv.current()` — so with `defaultMode: 'dark'` on a light-OS machine the first toggle
504
+ flips to light rather than writing the dark already on screen. `setTheme()` persists,
505
+ `clearTheme()` forgets and returns to the app default (`data-theme-default`, which the boot stamps
506
+ from `theme.defaultMode`; the OS for `'system'`), and the OS listener applies only while nothing is
507
+ stored and that default is `'system'`.
452
508
 
453
509
  ## Forms bound to an action's input schema
454
510
 
@@ -539,13 +595,13 @@ of truth for the one thing the server decides.
539
595
 
540
596
  | Code | When |
541
597
  |---|---|
542
- | `X_TOKEN_UNKNOWN` | a token role the SCSS source does not define — including a `defineTheme()` override of a role, radius or font slot that is not in the scale |
598
+ | `X_TOKEN_UNKNOWN` | a token role the SCSS source does not define — including a `defineTheme()` override of a role, radius or font slot that is not in the scale, and a breakpoint rung `respond-to` / `respond-down` / `respond-between` was handed that is not in `$breakpoints` (a Sass `@error`: the stylesheet does not compile) |
543
599
  | `X_THEME_INVALID` | a theme other than `light` / `dark` |
544
600
  | `X_UI_RUNTIME_MISSING` | a DOM render with no registered Solid runtime, `<UiProvider>` on the server, or `browserThemeEnv()` off-DOM. A server render with no runtime is **not** one of them — it gets `INERT_SOLID_RUNTIME` |
545
601
  | `X_UI_FORM_PATH_INVALID` | a form field or control name the path grammar cannot read (`items.0.price`, `items[]`, `__proto__`), or two control names describing different shapes for one path (`user` beside `user.name`) |
546
602
  | `X_UI_CONTRAST_INSUFFICIENT` | a `defineTheme()` palette whose resolved channels put a pairing in `CONTRAST_PAIRS` below WCAG 2.2 AA — 4.5:1 for text, 3:1 for the focus ring. Only pairings the brand changed are measured; the cause names the measured ratio and the required one |
547
603
  | `X_UI_QR_CAPACITY` | a `<QrCode>` value over version 3's 42-byte ceiling (byte mode, error-correction level M). The encoder draws versions 1–3 only; the cause names the byte count and the ceiling |
548
- | `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors, one dimension without the other, or no reserved box at all, a heading level off 1–6, a `defineTheme()` value that is not a token value, an `<Icon>` glyph with a tag/attribute/colour outside `ICON_TAGS`, two `Accordion` items sharing an id, `InfiniteScroll` with `hasMore` and no `nextHref`, a negative `debounce` window, or (`As of 2026-08`) upstream icon data `bun run icons` refuses (not an object, no renderable nodes, an attribute value that is not glyph geometry) |
604
+ | `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors, one dimension without the other, or no reserved box at all, a heading level off 1–6, a `defineTheme()` value that is not a token value, an `<Icon>` glyph with a tag/attribute/colour outside `ICON_TAGS`, two `Accordion` items sharing an id, `InfiniteScroll` with `hasMore` and no `nextHref`, a negative `debounce` window, or (`As of 2026-08`) upstream icon data `bun run icons` refuses (not an object, no renderable nodes, an attribute value that is not glyph geometry), or — as a Sass `@error` — `t.rem()` / `t.fluid()` given a unit other than px or rem, `t.fluid()` given a viewport range that is empty or reversed, `respond-between` given `$from` at or above `$to` |
549
605
 
550
606
  ### Error classes
551
607
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ui",
3
- "version": "22.15.0",
3
+ "version": "24.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",
@@ -33,6 +33,7 @@
33
33
  "files": [
34
34
  "src",
35
35
  "!src/**/*.test.ts",
36
+ "!src/**/*-fixture.ts",
36
37
  "CATALOG.md",
37
38
  "CLAUDE.md",
38
39
  "README.md",
@@ -48,10 +49,10 @@
48
49
  "icons": "bun run src/icons/build-icons.ts"
49
50
  },
50
51
  "dependencies": {
51
- "@ultimat3/core": "22.15.0",
52
- "@ultimat3/i18n": "22.15.0",
53
- "@ultimat3/money": "22.15.0",
54
- "@ultimat3/time": "22.15.0"
52
+ "@ultimat3/core": "24.0.0",
53
+ "@ultimat3/i18n": "24.0.0",
54
+ "@ultimat3/money": "24.0.0",
55
+ "@ultimat3/time": "24.0.0"
55
56
  },
56
57
  "peerDependencies": {
57
58
  "solid-js": "^1.9.0"
package/src/a11y.ts CHANGED
@@ -20,27 +20,41 @@ export const FOCUSABLE_SELECTOR = [
20
20
  ].join(',');
21
21
 
22
22
  let idCounter = 0;
23
+ let islandScope: string | undefined;
23
24
 
24
25
  /**
25
- * Unique id for label/description wiring, from a PROCESS-wide counter. Prefixed so a mismatch is
26
- * obvious in the DOM rather than silent.
26
+ * The part of an id that tells two copies of this module apart. Each island is its own bundle by
27
+ * default (`islands.sharedChunks` off — measured: every island chunk inlines this counter; with it
28
+ * on, one shared chunk holds it), so each island counts from zero and two of them both minted
29
+ * `field-1`. In a browser every copy draws its own scope once; a server render has one copy and no
30
+ * scope, so a document's ids carry no random part.
31
+ */
32
+ function scope(): string {
33
+ if (typeof document === 'undefined') return '';
34
+ if (islandScope === undefined) {
35
+ const [draw = 0] = crypto.getRandomValues(new Uint32Array(1));
36
+ islandScope = draw.toString(36);
37
+ }
38
+ return `${islandScope}-`;
39
+ }
40
+
41
+ /**
42
+ * Unique id for label/description wiring. Prefixed so a mismatch is obvious in the DOM.
27
43
  *
28
- * Correct for the only render path that exists: a server render walks a tree once, so every id in
29
- * one document is distinct, which is all `for`/`aria-describedby` need. It is NOT stable across two
30
- * renders of the same tree — a second process, or a client re-render, starts its own count — so it
31
- * cannot survive hydration. That is latent rather than broken: this package has no client runtime
32
- * (see CLAUDE.md), so nothing re-renders a server tree yet. Making it survive needs a
33
- * RENDER-SCOPED counter, which means a seam in `@ultimat3/render` (or a `SolidRuntime` member);
34
- * resetting this one per request would only move the collision. Do not paper over it here.
44
+ * On the server it comes from a PROCESS-wide counter: every id in one document is distinct, which
45
+ * is all `for`/`aria-describedby` need, but two renders of one page mint different numbers — a
46
+ * render-scoped counter needs a seam in `@ultimat3/render`. An island never reuses a server id: it
47
+ * replaces its shell rather than hydrating it, and its own ids carry its scope (above).
35
48
  */
36
49
  export function useId(prefix = 'u'): string {
37
50
  idCounter += 1;
38
- return `${prefix}-${idCounter.toString(36)}`;
51
+ return `${prefix}-${scope()}${idCounter.toString(36)}`;
39
52
  }
40
53
 
41
54
  /** Test-only: make id assertions deterministic. */
42
55
  export function resetIdCounter(): void {
43
56
  idCounter = 0;
57
+ islandScope = undefined;
44
58
  }
45
59
 
46
60
  /**