@ultimat3/ui 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CATALOG.md +119 -15
  2. package/CLAUDE.md +17 -8
  3. package/README.md +119 -33
  4. package/package.json +6 -6
  5. package/src/a11y.ts +27 -13
  6. package/src/catalog/build-catalog.ts +2 -2
  7. package/src/components/Accordion.tsx +8 -2
  8. package/src/components/AppShell.module.scss +105 -4
  9. package/src/components/AppShell.tsx +48 -10
  10. package/src/components/BarChart.module.scss +47 -9
  11. package/src/components/BarChart.tsx +152 -72
  12. package/src/components/Card.tsx +52 -16
  13. package/src/components/ChartFrame.module.scss +188 -0
  14. package/src/components/ChartFrame.tsx +155 -0
  15. package/src/components/CommandPalette.module.scss +8 -5
  16. package/src/components/Container.tsx +46 -9
  17. package/src/components/DataTable.module.scss +190 -0
  18. package/src/components/DataTable.tsx +161 -51
  19. package/src/components/Divider.tsx +0 -2
  20. package/src/components/DonutChart.module.scss +73 -0
  21. package/src/components/DonutChart.tsx +154 -0
  22. package/src/components/Dropzone.module.scss +3 -1
  23. package/src/components/Dropzone.tsx +17 -8
  24. package/src/components/ErrorState.tsx +39 -12
  25. package/src/components/FileInput.module.scss +3 -1
  26. package/src/components/FileInput.tsx +6 -2
  27. package/src/components/Form.tsx +17 -4
  28. package/src/components/Gauge.module.scss +48 -0
  29. package/src/components/Gauge.tsx +67 -0
  30. package/src/components/Grid.tsx +34 -13
  31. package/src/components/Image.tsx +2 -2
  32. package/src/components/InlineBar.module.scss +23 -0
  33. package/src/components/InlineBar.tsx +31 -0
  34. package/src/components/LineChart.module.scss +147 -0
  35. package/src/components/LineChart.tsx +169 -0
  36. package/src/components/Menu.tsx +9 -5
  37. package/src/components/Meter.tsx +3 -3
  38. package/src/components/PageHeader.tsx +5 -2
  39. package/src/components/Popover.module.scss +17 -20
  40. package/src/components/Popover.tsx +14 -7
  41. package/src/components/Section.tsx +45 -26
  42. package/src/components/Stack.tsx +59 -18
  43. package/src/components/Table.module.scss +30 -0
  44. package/src/components/Table.tsx +14 -3
  45. package/src/components/Tabs.module.scss +6 -0
  46. package/src/components/Tabs.tsx +2 -2
  47. package/src/components/Text.tsx +41 -15
  48. package/src/components/Textarea.tsx +7 -4
  49. package/src/components/Toaster.tsx +1 -1
  50. package/src/components/Toolbar.tsx +7 -6
  51. package/src/components/Tooltip.module.scss +8 -3
  52. package/src/components/app-shell-view.ts +6 -4
  53. package/src/components/bar-chart-view.ts +11 -6
  54. package/src/components/chart-frame-view.ts +219 -0
  55. package/src/components/chart-ticks-view.ts +173 -0
  56. package/src/components/data-table-view.ts +68 -0
  57. package/src/components/donut-chart-view.ts +120 -0
  58. package/src/components/file-input-view.ts +32 -13
  59. package/src/components/gauge-view.ts +43 -0
  60. package/src/components/heading-node.tsx +62 -0
  61. package/src/components/image-source.ts +3 -3
  62. package/src/components/line-chart-view.ts +153 -0
  63. package/src/components/link-target.ts +21 -1
  64. package/src/components/meter-view.ts +13 -0
  65. package/src/components/money-view.ts +2 -7
  66. package/src/components/qr-matrix.ts +16 -23
  67. package/src/components/relative-time-view.ts +11 -4
  68. package/src/form/form-binding.ts +32 -14
  69. package/src/form/use-form.ts +3 -3
  70. package/src/global.scss +9 -0
  71. package/src/icons/build-icons.ts +109 -24
  72. package/src/index.ts +47 -15
  73. package/src/jsx-probe.ts +5 -4
  74. package/src/theme/brand-declarations.ts +113 -0
  75. package/src/theme/brand.ts +104 -96
  76. package/src/theme/context.ts +1 -1
  77. package/src/theme/preset-scifi.ts +99 -0
  78. package/src/theme/preset-shape.ts +18 -0
  79. package/src/theme/presets.ts +15 -0
  80. package/src/theme/shadow-value.ts +39 -0
  81. package/src/theme/theme.ts +36 -11
  82. package/src/toast/toast-state.ts +0 -0
  83. package/src/toast/toast-store.ts +1 -1
  84. package/src/toast/use-toasts.ts +1 -1
  85. package/src/tokens/_colors.scss +20 -0
  86. package/src/tokens/_effects.scss +59 -0
  87. package/src/tokens/_index.scss +2 -0
  88. package/src/tokens/_mixins.scss +6 -4
  89. package/src/tokens/_shadow.scss +16 -0
  90. package/src/tokens/_touch.scss +39 -0
  91. package/src/tokens/_typography.scss +14 -0
  92. package/src/tokens/color-tokens.ts +133 -0
  93. package/src/tokens/colour-vision.ts +106 -0
  94. package/src/tokens/contrast-pairs.ts +6 -1
  95. package/src/tokens/contrast.ts +1 -1
  96. package/src/tokens/theme.scss +2 -0
  97. package/src/tokens/tokens.ts +41 -87
  98. package/src/fake-dom.ts +0 -206
  99. package/src/sass-probe.ts +0 -55
  100. package/src/theme/inline-script.ts +0 -49
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
 
@@ -44,10 +71,10 @@ A rung is `sm md lg xl 2xl`, quoted or bare. Prefer a container query (`t.contai
44
71
  | No raw colours | a hex or `rgb()` literal in a component stylesheet fails review; `tokens.test.ts` asserts the shared SCSS is hex-free |
45
72
  | No Tailwind | not a dependency, not a config, not an escape hatch |
46
73
  | No CSS-in-JS | styles are `Foo.module.scss` next to `Foo.tsx`, compiled at build |
47
- | No physical directions | `margin-inline`, `inset-inline-start`, `text-align: start` — RTL needs no second stylesheet |
74
+ | No physical directions | `margin-inline`, `inset-inline-start`, `text-align: start` — RTL needs no second stylesheet. Centring is `inset-inline` + `margin-inline: auto`, never a 50% logical inset beside a physical −50% translate, and an edge shadow's physical x offset gets a `[dir='rtl']` mirror (`components/rtl-sheets.test.ts`) |
48
75
  | No hardcoded strings | labels are props, or `t()` through `UI_KEYS` |
49
76
  | One token source | `src/tokens/*.scss` is canonical; `tokens.ts` mirrors it and `x verify` fails on drift |
50
- | AA contrast, both themes | `contrast.test.ts` measures every pairing a component renders — text, status fills, soft tints, focus rings, borders |
77
+ | AA contrast, both themes | `contrast.test.ts` measures every pairing a component renders — text, status fills, soft tints, focus rings, borders, chart series (3:1 on every surface) |
51
78
 
52
79
  ## Contrast
53
80
 
@@ -118,16 +145,16 @@ review comment. Three properties are structural, not documented:
118
145
 
119
146
  ## Toasts
120
147
 
121
- `Toast` and `ToastRegion` render; `createToastStore()` is the queue behind them, and `<Toaster>` is
148
+ `Toast` and `ToastRegion` render; `toastStore()` is the queue behind them, and `<Toaster>` is
122
149
  the one way to draw it.
123
150
 
124
151
  ```tsx
125
- import { createToastStore, Toaster } from '@ultimat3/ui';
152
+ import { toastStore, Toaster } from '@ultimat3/ui';
126
153
 
127
154
  declare const t: (key: string) => string;
128
155
  declare const restore: () => void;
129
156
 
130
- const toasts = createToastStore();
157
+ const toasts = toastStore();
131
158
  toasts.show({ message: t('post.saved'), tone: 'success' });
132
159
  toasts.show({ message: t('post.deleted'), action: { label: t('undo'), onAction: restore } });
133
160
 
@@ -157,11 +184,32 @@ Four composites cover the frame of an app screen. Below them are `Container`,
157
184
  | `Section` | a labelled `section` with a real heading and `aria-labelledby` | second-level structure inside a page |
158
185
  | `Toolbar` | `role="toolbar"` strip, start + end slots, arrow-key roving between its buttons (`As of 2026-08`) | filters and actions above a table or list |
159
186
 
160
- `AppShell` holds no state: below `md` the sidebar becomes a band above the content,
161
- and an off-canvas menu is `Drawer` — the one component that already does that.
187
+ `AppShell` holds no state: below `md` the sidebar is a native `popover` panel behind a menu
188
+ button (`menuLabel`), opened, closed and dismissed by the browser with no script; where `popover`
189
+ is unsupported it stays a band above the content. A modal off-canvas panel is still `Drawer`.
162
190
  Heading levels are props (`headingTag`, `nextHeadingLevel`), so a nested `Section`
163
191
  never skips a level.
164
192
 
193
+ ### Data display
194
+
195
+ Server-rendered, no island and no JS: a chart costs a route nothing until it asks to be focusable.
196
+ Every chart stands in a `ChartFrame` — a `figure`, its caption, a legend and a visually-hidden data
197
+ table — and every series has a dash and a marker as well as a colour (`chart-1` … `chart-8`).
198
+
199
+ | Component | Renders | Use for |
200
+ |---|---|---|
201
+ | `LineChart` / `AreaChart` | multi-series line on a 1-2-5 axis (filled for `AreaChart`); `null` is a gap | a trend over keys |
202
+ | `BarChart` | bars, an optional stacked second series with a legend | a count per key |
203
+ | `DonutChart` | ring segments, a centre figure, a legend with value and share | parts of a whole |
204
+ | `Gauge` | a 270° `meter` dial with a tone and a visible label | one value against its max |
205
+ | `ChartFrame` | the figure, caption, legend and data table every chart stands in | a custom chart |
206
+ | `niceTicks` · `chartTable` · `seriesStyle` | axis ticks, the fallback table, a series' dash and marker | building a custom chart |
207
+ | `DataTable` | sort, cursor paging, states; `narrow: 'cards'` (default) collapses rows to labelled cards below `sm`; `Column.priority` hides a column behind a per-row disclosure, never losing its value; `stickyFirstColumn` | any list of records |
208
+ | `Table` | a captioned table with a sticky header; `stickyFirstColumn` | static tabular data |
209
+ | `InlineBar` | a caller-formatted figure with a decorative proportional bar | a number in a table cell |
210
+ | `StatTile` / `Meter` / `Sparkline` | a KPI with its delta; a bar against a max; an inline mini line | the KPI row |
211
+
212
+
165
213
  ```tsx
166
214
  <AppShell header={<Toolbar label={t('nav.main')}>{nav}</Toolbar>} sidebar={<SideNav />}>
167
215
  <PageHeader
@@ -281,8 +329,13 @@ Components cancel theirs on cleanup, so a filter never fires into a tree that is
281
329
  override, no forked package, no second entry point — one call, validated, rendered
282
330
  as the custom properties that beat `theme.scss` at every specificity level it emits.
283
331
 
332
+ An app declares it once, as `export const brand` in `apps/web/shared/theme.ts`. `x dev`, the
333
+ container, `x build`'s static export and `/admin` inline it after the surface stylesheet, with its
334
+ `sha256` in `style-src`; with no such module nothing is emitted. There is nothing to paste.
335
+
284
336
  ```ts
285
- import { brandStyleCspSource, brandStyleTag, defineTheme } from '@ultimat3/ui';
337
+ // apps/web/shared/theme.ts
338
+ import { defineTheme } from '@ultimat3/ui';
286
339
 
287
340
  export const brand = defineTheme({
288
341
  colors: {
@@ -292,27 +345,56 @@ export const brand = defineTheme({
292
345
  radius: { md: '0.125rem', lg: '0.25rem' },
293
346
  font: { sans: "Inter, system-ui, sans-serif" },
294
347
  });
295
-
296
- // in <head>, AFTER global.scss
297
- `${brandStyleTag(brand)}`;
298
-
299
- // …and the one source that admits it under the framework's locked CSP. The baseline is
300
- // `style-src 'self' 'sha256-…'` with no 'unsafe-inline', so a tag whose hash the header does
301
- // not carry is a stylesheet the browser parses zero rules out of.
302
- `Content-Security-Policy: style-src 'self' ${brandStyleCspSource(brand)}`;
303
348
  ```
304
349
 
350
+ `brandStyleTag(brand)` and `brandStyleCspSource(brand)` are what the framework emits — exported
351
+ for a host that renders documents itself.
352
+
305
353
  | Slot | Accepts | Refused with |
306
354
  |---|---|---|
307
355
  | `colors.light` / `colors.dark` | any `ColorRole`, as `R G B` channels | `X_TOKEN_UNKNOWN` for the role, `X_UI_INVALID_VALUE` for the value |
308
356
  | `radius` | any `RadiusName`, as a bare CSS length | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
309
- | `font` | `sans`, `mono`, as a `font-family` list | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
357
+ | `font` | `sans`, `mono`, `data`, as a `font-family` list | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
358
+ | `shadows.light` / `shadows.dark` | any `ShadowName`, as a `box-shadow`: comma-separated layers of lengths plus `rgb(R G B / a)` or `rgb(var(--color-<role>) / a)`, or `none` | `X_TOKEN_UNKNOWN` for the rung, `X_UI_INVALID_VALUE` for the value |
359
+ | `preset` | a `THEME_PRESETS` name — `'scifi'` | `X_UI_INVALID_VALUE` |
310
360
 
311
361
  Values are validated, never escaped: the output goes into a `<style>` element, so
312
362
  anything carrying `;`, `}` or `</style>` is a refusal at the app's entry point rather
313
363
  than a CSS injection. Every component in the system follows the override — they only
314
364
  ever read the roles, never a colour.
315
365
 
366
+ ### The sci-fi preset
367
+
368
+ `defineTheme({ preset: 'scifi' })` swaps the whole palette for a dashboard look: near-black
369
+ panels in dark (`bg` `5 7 13`), cool paper in light, a cyan `accent` whose `accent-strong` (hover)
370
+ is magenta, its own eight chart series, sharper radii, and — dark only — every elevation rung
371
+ redrawn as an accent hairline plus a halo, so each card and popover has a glowing edge with no
372
+ stylesheet of yours. It is a **base**: every other slot layers onto it role by role, and the
373
+ merged palette is what `CONTRAST_PAIRS` measures.
374
+
375
+ ```ts
376
+ import { defineTheme } from '@ultimat3/ui';
377
+
378
+ export const brand = defineTheme({
379
+ preset: 'scifi',
380
+ colors: { dark: { 'chart-1': '120 220 255' } }, // scifi, with your own first series
381
+ });
382
+ ```
383
+
384
+ The preset ships tokens only (axiom 8). The texture and the edge are yours to place:
385
+
386
+ ```scss
387
+ @use '@ultimat3/ui/tokens' as t;
388
+
389
+ .main { background-color: t.role('bg'); @include t.grid-texture; }
390
+ .tile { @include t.surface; @include t.glow-edge; }
391
+ .tile[aria-current='true'] { @include t.glow-edge('glow-lg', 0.8); }
392
+ ```
393
+
394
+ A dashboard that should open dark whatever the OS says sets the app default theme
395
+ (`data-theme-default`, [Theme resolution](#theme-resolution)) — the preset never forces a theme.
396
+ Like every brand, export it from `apps/web/shared/theme.ts` and the framework ships it with its CSP hash.
397
+
316
398
  ## The two render paths
317
399
 
318
400
  Every component renders on the server. Half of them read the ambient presentation context —
@@ -358,7 +440,7 @@ import '../../shared/global'; // `shared/global.scss` is the app's one `@use '@u
358
440
 
359
441
  ```tsx
360
442
  // A client entry — an island's `mount()`, or a hydrated app shell. In this order, once.
361
- import { createTranslator } from '@ultimat3/i18n';
443
+ import { catalogTranslator } from '@ultimat3/i18n';
362
444
  import { setSolidRuntime, UiProvider } from '@ultimat3/ui';
363
445
  import type { JSX } from 'solid-js';
364
446
  import {
@@ -395,7 +477,7 @@ export function mount(el: HTMLElement, props: Props): void {
395
477
  locale={props.locale}
396
478
  timeZone={props.timeZone}
397
479
  currency={props.currency}
398
- t={createTranslator(props.strings, props.locale)}
480
+ t={catalogTranslator(props.strings, props.locale)}
399
481
  >
400
482
  {props.tree}
401
483
  </UiProvider>
@@ -406,7 +488,7 @@ export function mount(el: HTMLElement, props: Props): void {
406
488
  ```
407
489
 
408
490
  **The prop is `t`, it takes a `Translator`, and omitting it is not neutral.** `<UiProvider>` with
409
- no `t` falls back to `fallbackTranslator(locale)` — `createTranslator({}, locale)`, an **empty**
491
+ no `t` falls back to `fallbackTranslator(locale)` — `catalogTranslator({}, locale)`, an **empty**
410
492
  catalog — so every built-in string in the tree renders its key: `<Dialog>`'s close button reads
411
493
  `⟦ui.close⟧`, `<Field>`'s marker `⟦ui.required⟧`. The keys are `UI_KEYS`, and they live in the
412
494
  framework catalog the SERVER has registered; a browser chunk has none, which is why
@@ -462,7 +544,7 @@ is a type error rather than a review comment.
462
544
 
463
545
  | Prop | Emitted |
464
546
  |---|---|
465
- | `variants` | `srcset`, descriptors derived and ordered ascending (`srcsetFor`) |
547
+ | `variants` | `srcset`, descriptors derived and ordered ascending (`variantSrcset`) |
466
548
  | `sources` | `{ avif?, webp? }` width lists → `<source type="image/avif">`, then `image/webp`, before the `<img>` (`sourceSetsFor`) |
467
549
  | `sizes` | `sizes`, verbatim, on the `<img>` and every `<source>` |
468
550
  | `priority` | `loading="eager"` + `fetchpriority="high"`; otherwise `lazy` + `auto`, always `decoding="async"` |
@@ -496,14 +578,15 @@ one politeness — `politeness="assertive"` for a region that carries errors alo
496
578
 
497
579
  ## Theme resolution
498
580
 
499
- `explicit choice in localStorage` → `OS preference`. `setTheme()` persists,
500
- `clearTheme()` forgets and follows the OS again, and the OS listener only applies
501
- while nothing is stored. `themeInlineScriptTag()` goes in `<head>` and applies the
502
- result before first paint, so there is no flash and screenshots are deterministic:
503
-
504
- ```
505
- Content-Security-Policy: script-src 'self' 'sha256-…' # themeInlineScriptCspSource()
506
- ```
581
+ `explicit choice in localStorage` → `the theme already on <html>` → `defaultTheme(env)`: the
582
+ app's `theme.defaultMode` (`data-theme-default`), and the OS only when that is `'system'` or
583
+ absent. The framework's boot inlines `themeScript({ fallback })` from
584
+ `@ultimat3/render` and stamps `data-theme` before first paint, and `resolveTheme()` reads it back
585
+ through `ThemeEnv.current()` — so with `defaultMode: 'dark'` on a light-OS machine the first toggle
586
+ flips to light rather than writing the dark already on screen. `setTheme()` persists,
587
+ `clearTheme()` forgets and returns to the app default (`data-theme-default`, which the boot stamps
588
+ from `theme.defaultMode`; the OS for `'system'`), and the OS listener applies only while nothing is
589
+ stored and that default is `'system'`.
507
590
 
508
591
  ## Forms bound to an action's input schema
509
592
 
@@ -582,9 +665,12 @@ refuses the submit again because Enter in a text field touches no button at all.
582
665
 
583
666
  A failed submit focuses the **first invalid control** (`form.firstInvalidField()`, in declaration
584
667
  order — a server may report the last field first), and the announced summary keeps the focus only
585
- when the rejection names no control at all. `form.touch(path)` and `form.edit(path, value)` record
586
- touched and dirty against the binding's `initial`; an empty control and an absent baseline are the
587
- same thing, so deleting what you just typed leaves the form clean.
668
+ when the rejection names no control at all. Focus moves once per failure: a `touch()` or `edit()`
669
+ afterwards re-renders the form but never pulls focus back, so the user can work down the list.
670
+ `form.touch(path)` and `form.edit(path, value)` record touched and dirty against the binding's
671
+ `initial`; an empty control and an absent baseline are the same thing, so deleting what you just
672
+ typed leaves the form clean. A successful submit clears dirty except for the fields edited while it
673
+ was in flight — the server never saw those, so a navigation guard still has something to protect.
588
674
 
589
675
  Blur-time validation is deliberately **absent**: this binding is server-authoritative, the local
590
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": "23.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "LICENSE"
41
41
  ],
42
42
  "engines": {
43
- "bun": ">=1.4.0"
43
+ "bun": ">=1.4.2"
44
44
  },
45
45
  "scripts": {
46
46
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -49,10 +49,10 @@
49
49
  "icons": "bun run src/icons/build-icons.ts"
50
50
  },
51
51
  "dependencies": {
52
- "@ultimat3/core": "23.0.0",
53
- "@ultimat3/i18n": "23.0.0",
54
- "@ultimat3/money": "23.0.0",
55
- "@ultimat3/time": "23.0.0"
52
+ "@ultimat3/core": "25.0.0",
53
+ "@ultimat3/i18n": "25.0.0",
54
+ "@ultimat3/money": "25.0.0",
55
+ "@ultimat3/time": "25.0.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "solid-js": "^1.9.0"
package/src/a11y.ts CHANGED
@@ -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
- export function resetIdCounter(): void {
55
+ export function resetUseIdCounter(): void {
43
56
  idCounter = 0;
57
+ islandScope = undefined;
44
58
  }
45
59
 
46
60
  /**
@@ -76,7 +90,7 @@ export interface FocusTrap {
76
90
  * branch that pulls it back was unreachable by construction. Every branch still tests
77
91
  * `root.contains`, so the trap only acts while it is the active one.
78
92
  */
79
- export function createFocusTrap(root: HTMLElement): FocusTrap {
93
+ export function focusTrap(root: HTMLElement): FocusTrap {
80
94
  let previous: HTMLElement | null = null;
81
95
 
82
96
  /**
@@ -167,7 +181,7 @@ export function nextRovingIndex(
167
181
  }
168
182
 
169
183
  /** Wire a roving group: one tabbable item, arrows move focus and selection. */
170
- export function createRovingTabindex(
184
+ export function rovingTabindex(
171
185
  getItems: () => readonly HTMLElement[],
172
186
  options: RovingOptions = {},
173
187
  ): (event: KeyboardEvent) => void {
@@ -5,8 +5,8 @@ import type { ComponentDoc } from './parse-component';
5
5
  import { parseComponents } from './parse-component';
6
6
  import { renderCatalog } from './render-catalog';
7
7
 
8
- const COMPONENTS_DIR = new URL('../components/', import.meta.url).pathname;
9
- export const CATALOG_PATH = new URL('../../CATALOG.md', import.meta.url).pathname;
8
+ const COMPONENTS_DIR = Bun.fileURLToPath(new URL('../components/', import.meta.url));
9
+ export const CATALOG_PATH = Bun.fileURLToPath(new URL('../../CATALOG.md', import.meta.url));
10
10
 
11
11
  /** Alphabetical by file, so the page's order is the order an agent scans for a name. */
12
12
  export async function collectComponents(): Promise<ComponentDoc[]> {
@@ -11,6 +11,7 @@ import type { AccordionSection } from './accordion-view';
11
11
  import { accordionOpenIds } from './accordion-view';
12
12
  import type { HeadingLevel } from './heading-level';
13
13
  import { headingTag } from './heading-level';
14
+ import { headingNode } from './heading-node';
14
15
  import { Icon } from './Icon';
15
16
 
16
17
  export interface AccordionItem extends AccordionSection {
@@ -39,7 +40,8 @@ export interface AccordionProps {
39
40
  export function Accordion(props: AccordionProps): JSX.Element {
40
41
  const base = useId('accordion');
41
42
  const open = accordionOpenIds(props.items, props.exclusive === true);
42
- const Title = props.level === undefined ? 'span' : headingTag(props.level);
43
+ // Resolved once, so an off-scale level throws `X_UI_INVALID_VALUE` here as it always did.
44
+ const heading = props.level === undefined ? undefined : headingTag(props.level);
43
45
 
44
46
  return (
45
47
  <div class={cx(styles['accordion'], props.class)}>
@@ -53,7 +55,11 @@ export function Accordion(props: AccordionProps): JSX.Element {
53
55
  >
54
56
  <summary class={styles['summary']}>
55
57
  <Icon glyph={iconChevronDown} class={styles['marker']} size="sm" />
56
- <Title class={styles['title']}>{item.title}</Title>
58
+ {heading === undefined ? (
59
+ <span class={styles['title']}>{item.title}</span>
60
+ ) : (
61
+ headingNode(heading, { class: styles['title'] }, () => item.title)
62
+ )}
57
63
  </summary>
58
64
  <div class={styles['panel']}>{item.panel}</div>
59
65
  </details>
@@ -4,6 +4,9 @@
4
4
  // writing mode is handled by the grid itself, so RTL needs no second rule.
5
5
  .shell {
6
6
  display: grid;
7
+ // `minmax(0, …)`, never the implicit `auto`: an auto track is at least its content's min-content
8
+ // width, so a header that did not shrink laid a 390px phone out 429px wide.
9
+ grid-template-columns: minmax(0, 1fr);
7
10
  grid-template-areas: 'header' 'sidebar' 'main' 'footer';
8
11
  grid-template-rows: auto auto 1fr auto;
9
12
  min-block-size: 100dvh;
@@ -51,13 +54,66 @@
51
54
  }
52
55
  }
53
56
 
57
+ // The bar the banner sits in: the menu button (below `md`) and the app's header, side by side.
58
+ .top {
59
+ display: flex;
60
+ min-inline-size: 0;
61
+ align-items: center;
62
+ gap: t.space(2);
63
+ grid-area: header;
64
+ padding-inline-start: t.space(2);
65
+ border-block-end: 1px solid t.role('line');
66
+ background: t.role('surface');
67
+
68
+ @include t.respond-to(md) {
69
+ padding-inline-start: 0;
70
+ }
71
+ }
72
+
73
+ .menuOnly {
74
+ display: none;
75
+ }
76
+
54
77
  .header {
55
78
  @include t.row(t.space(4), center, space-between);
56
- grid-area: header;
79
+ flex: 1;
80
+ min-inline-size: 0;
57
81
  padding-block: t.space(3);
58
82
  padding-inline: t.space(4);
59
- border-block-end: 1px solid t.role('line');
60
- background: t.role('surface');
83
+ }
84
+
85
+ // Hidden unless the engine can open the panel it names: a button that does nothing is worse than
86
+ // the band an engine without popovers still shows. A touch target: this bar exists for a phone.
87
+ .menuButton,
88
+ .close {
89
+ @include t.focus-ring;
90
+ @include t.touch-target;
91
+ display: none;
92
+ align-items: center;
93
+ justify-content: center;
94
+ gap: t.space(2);
95
+ padding-inline: t.space(3);
96
+ border: 0;
97
+ border-radius: t.radius(md);
98
+ background: none;
99
+ color: t.role('fg-strong');
100
+ font: inherit;
101
+ cursor: pointer;
102
+
103
+ &:hover {
104
+ background: t.role('bg-soft');
105
+ }
106
+ }
107
+
108
+ .close {
109
+ margin-inline-start: auto;
110
+ }
111
+
112
+ // On a phone the menu button is its icon; the label stays in the accessibility tree as its name.
113
+ .menuLabel {
114
+ @include t.respond-down(sm) {
115
+ @include t.visually-hidden;
116
+ }
61
117
  }
62
118
 
63
119
  .sticky {
@@ -66,13 +122,23 @@
66
122
  inset-block-start: 0;
67
123
  }
68
124
 
69
- // Below `md` the sidebar is a band above the content; from `md` it is the inline-start column.
125
+ // From `md` the sidebar is the inline-start column; below it, the popover panel further down.
126
+ // Every `[popover]` gets a UA style — fixed, centred, bordered, hidden while closed — so the
127
+ // in-flow box undoes each, and is what an engine without popovers shows at every width.
70
128
  .sidebar {
71
129
  @include t.custom-scrollbar;
130
+ display: block;
131
+ position: static;
132
+ inset: auto;
133
+ inline-size: auto;
134
+ block-size: auto;
135
+ margin: 0;
72
136
  grid-area: sidebar;
73
137
  padding: t.space(4);
138
+ border: 0;
74
139
  border-block-end: 1px solid t.role('line');
75
140
  background: t.role('bg-soft');
141
+ color: t.role('fg');
76
142
  overflow: auto;
77
143
 
78
144
  // Deliberately not sticky: a sticky sidebar and a sticky header both anchored at 0 overlap,
@@ -108,3 +174,38 @@
108
174
  color: t.role('fg-muted');
109
175
  font-size: t.text(sm);
110
176
  }
177
+
178
+ // --- Below `md`, where the engine has popovers: the sidebar is an edge panel ----------------------
179
+ // The same look as `Drawer`'s inline-start side — raised surface, line edge, scrim — so the two
180
+ // off-canvas surfaces in an app read as one.
181
+ @include t.respond-down(md) {
182
+ @supports selector(:popover-open) {
183
+ .sidebar:not(:popover-open) {
184
+ display: none;
185
+ }
186
+
187
+ .sidebar:popover-open {
188
+ @include t.scrim-backdrop;
189
+ position: fixed;
190
+ inset-block: 0;
191
+ inset-inline-start: 0;
192
+ inset-inline-end: auto;
193
+ inline-size: min(var(--shell-sidebar, 16rem), calc(100vw - #{t.space(12)}));
194
+ block-size: 100dvh;
195
+ max-block-size: 100dvh;
196
+ border-block-end: 0;
197
+ border-inline-end: 1px solid t.role('line');
198
+ background: t.role('surface-raised');
199
+ box-shadow: t.shadow(xl);
200
+ }
201
+
202
+ .menuButton,
203
+ .close {
204
+ display: inline-flex;
205
+ }
206
+
207
+ .menuOnly {
208
+ display: flex;
209
+ }
210
+ }
211
+ }
@@ -1,28 +1,35 @@
1
1
  // The page frame every app screen sits in: skip link, banner, navigation, main, contentinfo.
2
- // Stateless on purpose — below `md` the sidebar becomes a band above the content instead of
3
- // growing an open/closed flag, because an off-canvas menu is already `Drawer` and axiom 1 allows
4
- // exactly one of those.
2
+ // Stateless, and scriptless: below `md` the sidebar `<nav>` is a native `popover` panel at the
3
+ // inline-start edge, opened by a `popovertarget` menu button — the browser holds the open state,
4
+ // Esc and light dismiss, so the frame on every page costs no island. Not `Drawer`: that is a modal
5
+ // `<dialog>` opened from an effect, so it needs a hydrated island, and a `<dialog>` cannot also be
6
+ // the in-flow `navigation` landmark at `md` and up. An engine with no popovers keeps the old band.
5
7
 
6
8
  import type { JSX } from 'solid-js';
7
9
  import { LIVE_REGION_LEVELS, liveRegionAttrs, useId } from '../a11y';
8
10
  import { cx } from '../cx';
9
11
  import { UI_KEYS } from '../i18n-keys';
12
+ import { iconMenu } from '../icons/glyphs/menu';
13
+ import { iconX } from '../icons/glyphs/x';
10
14
  import { useUi } from '../theme/context';
11
15
  import styles from './AppShell.module.scss';
12
16
  import { shellIds } from './app-shell-view';
17
+ import { Icon } from './Icon';
13
18
 
14
19
  export interface AppShellProps {
15
20
  /** The page. Rendered inside the one `<main>`, which is the skip link's target. */
16
21
  children: JSX.Element;
17
22
  header?: JSX.Element | undefined;
18
- /** Rendered inside a `<nav>` landmark at the inline start. */
23
+ /** Rendered inside a `<nav>` landmark: the inline-start column at `md` and up, a panel below. */
19
24
  sidebar?: JSX.Element | undefined;
20
25
  footer?: JSX.Element | undefined;
21
26
  /** Accessible name for the sidebar landmark. Defaults to the translated `ui.navigation`. */
22
27
  sidebarLabel?: string | undefined;
23
28
  /** Skip-link text. Defaults to the translated `ui.skip`. */
24
29
  skipLabel?: string | undefined;
25
- /** Sidebar track width at `md` and up. Any CSS length. */
30
+ /** The menu button's text below `md`. Defaults to the translated `ui.menu`. */
31
+ menuLabel?: string | undefined;
32
+ /** Sidebar track width at `md` and up, and the panel's width below. Any CSS length. */
26
33
  sidebarWidth?: string | undefined;
27
34
  /** Keeps the header pinned while the main region scrolls. */
28
35
  stickyHeader?: boolean | undefined;
@@ -41,13 +48,44 @@ export function AppShell(props: AppShellProps): JSX.Element {
41
48
  <a class={styles['skip']} href={ids.skipHref}>
42
49
  {props.skipLabel ?? ui.t(UI_KEYS.skip)}
43
50
  </a>
44
- {props.header === undefined ? null : (
45
- <header class={cx(styles['header'], props.stickyHeader !== false && styles['sticky'])}>
46
- {props.header}
47
- </header>
51
+ {props.header === undefined && props.sidebar === undefined ? null : (
52
+ // The menu button sits beside the banner, not inside it: the header holds exactly what
53
+ // the app handed it, and the bar is what sticks.
54
+ <div
55
+ class={cx(
56
+ styles['top'],
57
+ // A bar holding only the menu button is shown only where that button is.
58
+ props.header === undefined && styles['menuOnly'],
59
+ props.stickyHeader !== false && styles['sticky'],
60
+ )}
61
+ >
62
+ {props.sidebar === undefined ? null : (
63
+ <button type="button" class={styles['menuButton']} popovertarget={ids.navId}>
64
+ <Icon glyph={iconMenu} size="md" />
65
+ <span class={styles['menuLabel']}>{props.menuLabel ?? ui.t(UI_KEYS.menu)}</span>
66
+ </button>
67
+ )}
68
+ {props.header === undefined ? null : (
69
+ <header class={styles['header']}>{props.header}</header>
70
+ )}
71
+ </div>
48
72
  )}
49
73
  {props.sidebar === undefined ? null : (
50
- <nav class={styles['sidebar']} aria-label={props.sidebarLabel ?? ui.t(UI_KEYS.navigation)}>
74
+ <nav
75
+ id={ids.navId}
76
+ class={styles['sidebar']}
77
+ popover="auto"
78
+ aria-label={props.sidebarLabel ?? ui.t(UI_KEYS.navigation)}
79
+ >
80
+ <button
81
+ type="button"
82
+ class={styles['close']}
83
+ popovertarget={ids.navId}
84
+ popovertargetaction="hide"
85
+ aria-label={ui.t(UI_KEYS.close)}
86
+ >
87
+ <Icon glyph={iconX} size="md" />
88
+ </button>
51
89
  {props.sidebar}
52
90
  </nav>
53
91
  )}