@ultimat3/ui 24.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CATALOG.md +117 -13
  2. package/CLAUDE.md +14 -5
  3. package/README.md +109 -24
  4. package/package.json +6 -6
  5. package/src/a11y.ts +3 -3
  6. package/src/catalog/build-catalog.ts +2 -2
  7. package/src/components/Accordion.tsx +8 -2
  8. package/src/components/AppShell.module.scss +105 -4
  9. package/src/components/AppShell.tsx +48 -10
  10. package/src/components/BarChart.module.scss +47 -9
  11. package/src/components/BarChart.tsx +152 -72
  12. package/src/components/Card.tsx +52 -16
  13. package/src/components/ChartFrame.module.scss +188 -0
  14. package/src/components/ChartFrame.tsx +155 -0
  15. package/src/components/Container.tsx +46 -9
  16. package/src/components/DataTable.module.scss +190 -0
  17. package/src/components/DataTable.tsx +124 -17
  18. package/src/components/Divider.tsx +0 -2
  19. package/src/components/DonutChart.module.scss +73 -0
  20. package/src/components/DonutChart.tsx +154 -0
  21. package/src/components/Dropzone.tsx +9 -1
  22. package/src/components/ErrorState.tsx +39 -12
  23. package/src/components/Form.tsx +17 -4
  24. package/src/components/Gauge.module.scss +48 -0
  25. package/src/components/Gauge.tsx +67 -0
  26. package/src/components/Grid.tsx +34 -13
  27. package/src/components/Image.tsx +2 -2
  28. package/src/components/InlineBar.module.scss +23 -0
  29. package/src/components/InlineBar.tsx +31 -0
  30. package/src/components/LineChart.module.scss +147 -0
  31. package/src/components/LineChart.tsx +169 -0
  32. package/src/components/Menu.tsx +9 -5
  33. package/src/components/PageHeader.tsx +5 -2
  34. package/src/components/Popover.tsx +2 -2
  35. package/src/components/Section.tsx +45 -26
  36. package/src/components/Stack.tsx +59 -18
  37. package/src/components/Table.module.scss +30 -0
  38. package/src/components/Table.tsx +14 -3
  39. package/src/components/Tabs.tsx +2 -2
  40. package/src/components/Text.tsx +41 -15
  41. package/src/components/Toaster.tsx +1 -1
  42. package/src/components/Toolbar.tsx +7 -6
  43. package/src/components/app-shell-view.ts +6 -4
  44. package/src/components/bar-chart-view.ts +5 -4
  45. package/src/components/chart-frame-view.ts +219 -0
  46. package/src/components/chart-ticks-view.ts +173 -0
  47. package/src/components/data-table-view.ts +68 -0
  48. package/src/components/donut-chart-view.ts +120 -0
  49. package/src/components/file-input-view.ts +1 -1
  50. package/src/components/gauge-view.ts +43 -0
  51. package/src/components/heading-node.tsx +62 -0
  52. package/src/components/image-source.ts +3 -3
  53. package/src/components/line-chart-view.ts +153 -0
  54. package/src/components/money-view.ts +2 -7
  55. package/src/form/form-binding.ts +23 -6
  56. package/src/form/use-form.ts +3 -3
  57. package/src/global.scss +9 -0
  58. package/src/icons/build-icons.ts +15 -6
  59. package/src/index.ts +45 -9
  60. package/src/jsx-probe.ts +5 -4
  61. package/src/theme/brand-declarations.ts +113 -0
  62. package/src/theme/brand.ts +98 -109
  63. package/src/theme/context.ts +1 -1
  64. package/src/theme/preset-scifi.ts +99 -0
  65. package/src/theme/preset-shape.ts +18 -0
  66. package/src/theme/presets.ts +15 -0
  67. package/src/theme/shadow-value.ts +39 -0
  68. package/src/theme/theme.ts +1 -1
  69. package/src/toast/toast-store.ts +1 -1
  70. package/src/toast/use-toasts.ts +1 -1
  71. package/src/tokens/_colors.scss +20 -0
  72. package/src/tokens/_effects.scss +59 -0
  73. package/src/tokens/_index.scss +2 -0
  74. package/src/tokens/_mixins.scss +6 -4
  75. package/src/tokens/_shadow.scss +16 -0
  76. package/src/tokens/_touch.scss +39 -0
  77. package/src/tokens/_typography.scss +14 -0
  78. package/src/tokens/color-tokens.ts +133 -0
  79. package/src/tokens/colour-vision.ts +106 -0
  80. package/src/tokens/contrast-pairs.ts +6 -1
  81. package/src/tokens/contrast.ts +1 -1
  82. package/src/tokens/theme.scss +2 -0
  83. package/src/tokens/tokens.ts +41 -87
  84. package/src/components/qr-reader.ts +0 -187
  85. package/src/fake-dom.ts +0 -206
  86. package/src/sass-probe.ts +0 -55
@@ -99,13 +99,19 @@ function declaredFields(fields: readonly string[]): ReadonlySet<string> {
99
99
  return declared;
100
100
  }
101
101
 
102
- export function createFormBinding<TValues, TResult>(
102
+ export function formBinding<TValues, TResult>(
103
103
  options: FormBindingOptions<TValues, TResult>,
104
104
  ): FormBinding<TValues, TResult> {
105
105
  const fields = declaredFields(options.fields);
106
106
  let state: FormState<TResult> = IDLE_FORM_STATE;
107
107
  let touch: FormTouch = NO_FORM_TOUCH;
108
108
  let inFlight: Promise<FormState<TResult>> | null = null;
109
+ /**
110
+ * The paths changed since the in-flight submit read the form, or `null` with none in flight.
111
+ * Every edit counts, a revert to `initial` included: the server now holds what was submitted,
112
+ * not the opening value, so the baseline cannot say whether an edit made after the read is lost.
113
+ */
114
+ let editedInFlight: Set<string> | null = null;
109
115
 
110
116
  /**
111
117
  * A transition names the submit's own members; `touched`/`dirty` are added here, from the one
@@ -146,6 +152,9 @@ export function createFormBinding<TValues, TResult>(
146
152
  const run = async (values: TValues): Promise<FormState<TResult>> => {
147
153
  // Cleared, never carried: a stale message would mark a control invalid for a value the user
148
154
  // has already changed, on the one screen where the user is watching for exactly that.
155
+ // Opened BEFORE the publish: `onState` is synchronous, so a subscriber can edit in reaction
156
+ // to `submitting` itself, and that edit already postdates what this submit read.
157
+ editedInFlight = new Set();
149
158
  publish({ status: 'submitting', ...NO_FORM_ERRORS, result: undefined, issues: [] });
150
159
 
151
160
  try {
@@ -159,12 +168,16 @@ export function createFormBinding<TValues, TResult>(
159
168
  if (local.length > 0) return failed(local);
160
169
  }
161
170
  const result = await options.submit(values);
162
- // The server accepted what the form held, so there is nothing left to lose. `touched` stays:
163
- // the user has still visited those fields, and a hint that vanishes on save is a flicker.
164
- touch = { touched: touch.touched, dirty: NO_FORM_TOUCH.dirty };
171
+ // The server accepted what the form held WHEN IT SUBMITTED, so only what changed since is
172
+ // left to lose — clearing that too let a navigation guard drop an edit typed during the save.
173
+ // `touched` stays: the user has still visited those fields, and a hint that vanishes on save
174
+ // is a flicker.
175
+ touch = { touched: touch.touched, dirty: editedInFlight ?? NO_FORM_TOUCH.dirty };
165
176
  return publish({ status: 'succeeded', ...NO_FORM_ERRORS, result, issues: [] });
166
177
  } catch (rejection) {
167
178
  return failed(issuesFromRejection(rejection));
179
+ } finally {
180
+ editedInFlight = null;
168
181
  }
169
182
  };
170
183
 
@@ -184,10 +197,14 @@ export function createFormBinding<TValues, TResult>(
184
197
  pending: () => state.status === 'submitting',
185
198
  firstInvalidField: () => firstInvalidField(state, fields),
186
199
  touch: (path) => publishTouch(markTouched(touch, path)),
187
- edit: (path, value) =>
188
- publishTouch(markDirty(touch, path, !sameFieldValue(value, baseline(path)))),
200
+ edit: (path, value) => {
201
+ editedInFlight?.add(path);
202
+ publishTouch(markDirty(touch, path, !sameFieldValue(value, baseline(path))));
203
+ },
189
204
  reset: () => {
190
205
  touch = NO_FORM_TOUCH;
206
+ // A reset mid-flight discards what was typed before it, the in-flight window's share too.
207
+ if (editedInFlight !== null) editedInFlight = new Set();
191
208
  publish(IDLE_FORM_STATE);
192
209
  },
193
210
  };
@@ -1,11 +1,11 @@
1
- // The reactive shell over `createFormBinding`, and the whole of what it adds: the state lands in a
1
+ // The reactive shell over `formBinding`, and the whole of what it adds: the state lands in a
2
2
  // signal, so a `<Field error={form.errorFor('title')}>` re-renders when a submit answers.
3
3
  //
4
4
  // Reactivity goes through `solid()` like every other read in this package — a component that
5
5
  // imported solid-js directly would make @ultimat3/ui depend on a runtime it deliberately does not.
6
6
 
7
7
  import { solid } from '../theme/solid-adapter';
8
- import { createFormBinding, type FormBinding, type FormBindingOptions } from './form-binding';
8
+ import { type FormBinding, type FormBindingOptions, formBinding } from './form-binding';
9
9
  import {
10
10
  errorOf,
11
11
  type FormState,
@@ -24,7 +24,7 @@ export function useForm<TValues, TResult>(
24
24
  ): FormBinding<TValues, TResult> {
25
25
  const runtime = solid();
26
26
  const [state, setState] = runtime.createSignal<FormState<TResult>>(IDLE_FORM_STATE);
27
- const binding = createFormBinding<TValues, TResult>({
27
+ const binding = formBinding<TValues, TResult>({
28
28
  ...options,
29
29
  onState: (next) => {
30
30
  options.onState?.(next);
package/src/global.scss CHANGED
@@ -5,3 +5,12 @@
5
5
 
6
6
  @use 'tokens/theme';
7
7
  @use 'tokens/reset';
8
+
9
+ // A held island's reveal that needs no script (`@ultimat3/render`'s `ISLAND_HOLD_REVEAL`, issue 506):
10
+ // its wrapper animates to this after the hold's cap, so JS off or a blocked runtime still shows
11
+ // the server's markup. The name is held equal to render's by `island-hold-replay.test.ts`.
12
+ @keyframes ultimate-hold-reveal {
13
+ to {
14
+ visibility: visible;
15
+ }
16
+ }
@@ -23,7 +23,7 @@ export const LUCIDE_LATEST_URL = 'https://registry.npmjs.org/lucide-static/lates
23
23
  /** The tail of every cause about data the PIN published: why re-running alone cannot help. */
24
24
  const PIN_REPEATS = `lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this; \`--bump\` raises LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts to the latest release and regenerates`;
25
25
 
26
- export const GLYPHS_DIR = new URL('./glyphs/', import.meta.url).pathname;
26
+ export const GLYPHS_DIR = Bun.fileURLToPath(new URL('./glyphs/', import.meta.url));
27
27
 
28
28
  /** `circle-alert` → `iconCircleAlert`. Prefixed because `delete`, `import` and `package` are icons
29
29
  * and reserved words — one uniform rule beats three exceptions an agent has to remember. */
@@ -167,13 +167,22 @@ export function moduleSource(name: string, glyph: IconGlyph): string {
167
167
  ].join('\n');
168
168
  }
169
169
 
170
+ /** The repo's own `node_modules/.bin`, where `bun install` links every tool binary. */
171
+ const REPO_BIN = Bun.fileURLToPath(new URL('../../../../node_modules/.bin', import.meta.url));
172
+
173
+ /**
174
+ * The biome executable in `binDir`, or `undefined`. Asked of `Bun.which` rather than spelled as
175
+ * `<bin>/biome`: on Windows the link is `biome.exe`, and a path with no extension is ENOENT there.
176
+ */
177
+ export function biomeBinary(binDir: string = REPO_BIN): string | undefined {
178
+ return Bun.which('biome', { PATH: binDir }) ?? undefined;
179
+ }
180
+
170
181
  /** Biome owns formatting in this repo, generated files included — so the generator asks it rather
171
182
  * than imitating it, and a regenerated set never shows up as a lint diff. */
172
183
  async function format(): Promise<void> {
173
- const biome = new URL('../../../../node_modules/.bin/biome', import.meta.url).pathname;
174
- if (!(await Bun.file(biome).exists())) {
175
- throw runtimeMissingError('the biome binary', 'bun install');
176
- }
184
+ const biome = biomeBinary();
185
+ if (biome === undefined) throw runtimeMissingError('the biome binary', 'bun install');
177
186
  // A formatter over one directory of generated files; a wedged one is the refusal below.
178
187
  const result = Bun.spawnSync([biome, 'format', '--write', GLYPHS_DIR], { timeout: 60_000 });
179
188
  if (result.exitCode !== 0) {
@@ -235,7 +244,7 @@ async function bumpPin(): Promise<string> {
235
244
  const body: unknown = await response.json();
236
245
  const version =
237
246
  typeof body === 'object' && body !== null && 'version' in body ? String(body.version) : '';
238
- const file = new URL(import.meta.url).pathname;
247
+ const file = import.meta.path;
239
248
  await Bun.write(file, withPin(await Bun.file(file).text(), version));
240
249
  return version;
241
250
  }
package/src/index.ts CHANGED
@@ -22,13 +22,13 @@ export type {
22
22
  export {
23
23
  announce,
24
24
  ariaBool,
25
- createFocusTrap,
26
- createRovingTabindex,
27
25
  FOCUSABLE_SELECTOR,
28
26
  focusableWithin,
27
+ focusTrap,
29
28
  LIVE_REGION_LEVELS,
30
29
  liveRegionAttrs,
31
- resetIdCounter,
30
+ resetUseIdCounter,
31
+ rovingTabindex,
32
32
  useId,
33
33
  } from './a11y';
34
34
  export type { AccordionItem, AccordionProps } from './components/Accordion';
@@ -70,6 +70,8 @@ export type { BarRect, ChartPoint } from './components/bar-chart-view';
70
70
  export { barRects } from './components/bar-chart-view';
71
71
  export type { CardProps, Elevation } from './components/Card';
72
72
  export { Card } from './components/Card';
73
+ export type { ChartFrameProps, ChartLegendItem } from './components/ChartFrame';
74
+ export { ChartFrame } from './components/ChartFrame';
73
75
  export type { CheckboxProps } from './components/Checkbox';
74
76
  export { Checkbox } from './components/Checkbox';
75
77
  export type { ComboboxProps } from './components/Combobox';
@@ -80,6 +82,17 @@ export type { ContainerProps, ContainerSize } from './components/Container';
80
82
  export { Container } from './components/Container';
81
83
  export type { CopyButtonProps } from './components/CopyButton';
82
84
  export { COPIED_MS, CopyButton } from './components/CopyButton';
85
+ export type {
86
+ ChartSeries,
87
+ ChartTable,
88
+ ChartTableInput,
89
+ ChartTableRow,
90
+ MarkerShape,
91
+ SeriesStyle,
92
+ } from './components/chart-frame-view';
93
+ export { CHART_SERIES_SLOTS, chartTable, seriesStyle } from './components/chart-frame-view';
94
+ export type { NiceScale, NiceTicksOptions } from './components/chart-ticks-view';
95
+ export { niceTicks } from './components/chart-ticks-view';
83
96
  export type { ComboboxOption } from './components/combobox-filter';
84
97
  export { filterOptions, normalizeQuery } from './components/combobox-filter';
85
98
  export type { CommandPaletteItem, PaletteKeyAction } from './components/command-palette-view';
@@ -93,6 +106,7 @@ export type {
93
106
  Column,
94
107
  DataTableCallbackProps,
95
108
  DataTableLinkProps,
109
+ DataTableNarrow,
96
110
  DataTableProps,
97
111
  } from './components/DataTable';
98
112
  export { DataTable } from './components/DataTable';
@@ -102,10 +116,13 @@ export type { DialogProps } from './components/Dialog';
102
116
  export { Dialog } from './components/Dialog';
103
117
  export type { DividerProps } from './components/Divider';
104
118
  export { Divider } from './components/Divider';
119
+ export type { DonutChartProps, DonutSegment } from './components/DonutChart';
120
+ export { DonutChart } from './components/DonutChart';
105
121
  export type { DrawerProps, DrawerSide } from './components/Drawer';
106
122
  export { Drawer } from './components/Drawer';
107
123
  export type { DropzoneProps } from './components/Dropzone';
108
124
  export { Dropzone } from './components/Dropzone';
125
+ export type { ColumnPriority } from './components/data-table-view';
109
126
  export type {
110
127
  DateStyle,
111
128
  DateTimeFormatter,
@@ -133,9 +150,11 @@ export type {
133
150
  FileTarget,
134
151
  } from './components/file-input-view';
135
152
  export {
136
- formatBytes,
153
+ formatFileSize,
137
154
  selectFiles,
138
155
  } from './components/file-input-view';
156
+ export type { GaugeProps } from './components/Gauge';
157
+ export { Gauge } from './components/Gauge';
139
158
  export type { GridProps } from './components/Grid';
140
159
  export { Grid } from './components/Grid';
141
160
  export type { HeadingLevel, HeadingTag } from './components/heading-level';
@@ -149,6 +168,8 @@ export type { ImageBaseProps, ImageDimensions, ImageProps } from './components/I
149
168
  export { Image } from './components/Image';
150
169
  export type { InfiniteScrollProps } from './components/InfiniteScroll';
151
170
  export { InfiniteScroll } from './components/InfiniteScroll';
171
+ export type { InlineBarProps } from './components/InlineBar';
172
+ export { InlineBar } from './components/InlineBar';
152
173
  export type { InputProps, InputType } from './components/Input';
153
174
  export { Input } from './components/Input';
154
175
  export type { IconElement, IconGlyph, IconTag } from './components/icon-glyph';
@@ -161,10 +182,12 @@ export type {
161
182
  ImageSources,
162
183
  ImageVariant,
163
184
  } from './components/image-source';
164
- export { boxFor, reservedRatio, sourceSetsFor, srcsetFor } from './components/image-source';
185
+ export { boxFor, reservedRatio, sourceSetsFor, variantSrcset } from './components/image-source';
165
186
  export type { LoadMoreInput, LoadMoreState } from './components/infinite-scroll-view';
166
187
  export type { KbdProps } from './components/Kbd';
167
188
  export { Kbd } from './components/Kbd';
189
+ export type { AreaChartProps, LineChartProps } from './components/LineChart';
190
+ export { AreaChart, LineChart } from './components/LineChart';
168
191
  export type { ButtonLinkProps, LinkProps, TextLinkProps } from './components/Link';
169
192
  export { Link } from './components/Link';
170
193
  export type { LocaleSwitcherProps } from './components/LocaleSwitcher';
@@ -265,7 +288,7 @@ export {
265
288
  export type { FieldPathSegment, IssuePathSegment } from './form/field-path';
266
289
  export { fieldSelector, formatFieldPath, MAX_FIELD_INDEX, parseFieldPath } from './form/field-path';
267
290
  export type { FormBinding, FormBindingOptions } from './form/form-binding';
268
- export { createFormBinding } from './form/form-binding';
291
+ export { formBinding } from './form/form-binding';
269
292
  export type {
270
293
  FormIssue,
271
294
  FormSchema,
@@ -312,6 +335,9 @@ export {
312
335
  useUi,
313
336
  } from './theme/context';
314
337
  export { INERT_SOLID_RUNTIME } from './theme/inert-runtime';
338
+ export type { ThemePreset } from './theme/preset-shape';
339
+ export type { ThemePresetName } from './theme/presets';
340
+ export { THEME_PRESET_NAMES, THEME_PRESETS } from './theme/presets';
315
341
  export type { UiProviderProps } from './theme/provider';
316
342
  export { UiProvider } from './theme/provider';
317
343
  // The slot is its own module so that registering a runtime does not drag `errors.ts` — and with it
@@ -334,7 +360,6 @@ export {
334
360
  THEME_ATTRIBUTE,
335
361
  THEME_DEFAULT_ATTRIBUTE,
336
362
  THEME_MEDIA_QUERY,
337
- THEME_STORAGE_KEY,
338
363
  toggleTheme,
339
364
  watchOsTheme,
340
365
  } from './theme/theme';
@@ -352,8 +377,16 @@ export {
352
377
  TOAST_MAX_VISIBLE,
353
378
  } from './toast/toast-state';
354
379
  export type { ToastEnv, ToastStore } from './toast/toast-store';
355
- export { browserToastEnv, createToastStore, INERT_TOAST_ENV } from './toast/toast-store';
380
+ export { browserToastEnv, INERT_TOAST_ENV, toastStore } from './toast/toast-store';
356
381
  export { useToasts } from './toast/use-toasts';
382
+ export type { ColourVision } from './tokens/colour-vision';
383
+ export {
384
+ CHART_DISTINCT_MIN,
385
+ COLOUR_VISIONS,
386
+ closestChartPair,
387
+ colourDistance,
388
+ simulateColourVision,
389
+ } from './tokens/colour-vision';
357
390
  export type { Channels } from './tokens/contrast';
358
391
  export {
359
392
  AA_LARGE,
@@ -365,11 +398,12 @@ export {
365
398
  } from './tokens/contrast';
366
399
  export type { ContrastPair } from './tokens/contrast-pairs';
367
400
  export { CONTRAST_PAIRS } from './tokens/contrast-pairs';
368
- export type { ColorRole, RadiusName, Theme } from './tokens/tokens';
401
+ export type { ChartRole, ColorRole, RadiusName, ShadowName, Theme } from './tokens/tokens';
369
402
  // --- tokens ------------------------------------------------------------------
370
403
  export {
371
404
  assertColorRole,
372
405
  breakpointTokens,
406
+ CHART_ROLES,
373
407
  COLOR_ROLES,
374
408
  color,
375
409
  colorRgb,
@@ -381,8 +415,10 @@ export {
381
415
  fontWeightTokens,
382
416
  lineHeightTokens,
383
417
  radiusTokens,
418
+ SHADOW_NAMES,
384
419
  shadowTokens,
385
420
  spaceTokens,
386
421
  strokeTokens,
422
+ touchTokens,
387
423
  zTokens,
388
424
  } from './tokens/tokens';
package/src/jsx-probe.ts CHANGED
@@ -38,10 +38,11 @@ function h(
38
38
  *
39
39
  * There may be exactly ONE such counter in the framework, and it is this one — `globalThis.React`
40
40
  * is a single property, so a second module counting its own depth over it restores in the wrong
41
- * order. `@ultimat3/admin`'s `inert-jsx.ts` kept a second pair and the two interleaved: admin
42
- * installs (saving the real binding), ui installs (saving ADMIN's factory), admin restores (both
43
- * counters at 1 → the real binding is back), ui restores (its counter hits 0 → admin's factory is
44
- * reinstalled over it). The global ended up holding a harness the run had already torn down.
41
+ * order. `@ultimat3/admin`'s `inert-jsx-fixture.ts` kept a second pair and the two interleaved:
42
+ * admin installs (saving the real binding), ui installs (saving ADMIN's factory), admin restores
43
+ * (both counters at 1 → the real binding is back), ui restores (its counter hits 0 → admin's
44
+ * factory is reinstalled over it). The global ended up holding a harness the run had already torn
45
+ * down.
45
46
  * `admin` is tier 5 and `ui` is tier 4, so admin imports these rather than declaring them.
46
47
  */
47
48
  let depth = 0;
@@ -0,0 +1,113 @@
1
+ // The validators behind `defineTheme()`: each slot's override checked against its canonical list
2
+ // and value grammar, then rendered as custom-property lines in that list's order — never the
3
+ // caller's — so a brand rendered twice is byte-identical and its CSP hash does not churn.
4
+
5
+ import { invalidBrandTokenError, unknownTokenError } from '../errors';
6
+ import { parseChannels } from '../tokens/contrast';
7
+ import {
8
+ COLOR_ROLES,
9
+ type ColorRole,
10
+ type RadiusName,
11
+ radiusTokens,
12
+ SHADOW_NAMES,
13
+ type ShadowName,
14
+ } from '../tokens/tokens';
15
+ import { FONT_SLOTS, type FontSlot } from './preset-shape';
16
+ import { isShadowValue, SHADOW_EXPECTED } from './shadow-value';
17
+
18
+ /** `0` or a number with a CSS length unit. No `calc()`, no `var()` — a scale rung is a value. */
19
+ const LENGTH_PATTERN = /^(0|\d+(\.\d+)?(px|rem|em|ch|%))$/;
20
+
21
+ /**
22
+ * Family names, quotes and separators only: everything a `font-family` list legitimately needs.
23
+ * The separator is a literal SPACE, never `\s`: the HTML parser rewrites a CR or CRLF inside a
24
+ * `<style>` to LF before the browser hashes it, so a stack carrying one served bytes whose sha256
25
+ * no longer matched the CSP hash computed here, and the brand's whole stylesheet was blocked.
26
+ */
27
+ const FONT_STACK_PATTERN = /^[\w ,'"-]{1,200}$/;
28
+
29
+ const LENGTH_EXPECTED = 'a bare CSS length such as "0.5rem", "4px" or "0"';
30
+ const STACK_EXPECTED = 'a font-family list such as "Inter, system-ui, sans-serif"';
31
+ const CHANNELS_EXPECTED = 'space-separated RGB channels such as "31 110 178"';
32
+
33
+ function assertKnown(
34
+ kind: string,
35
+ overrides: object,
36
+ known: readonly string[],
37
+ source?: string,
38
+ ): void {
39
+ for (const name of Object.keys(overrides)) {
40
+ if (!known.includes(name)) throw unknownTokenError(kind, name, known, source);
41
+ }
42
+ }
43
+
44
+ export function colorDeclarations(
45
+ overrides: Partial<Record<ColorRole, string>> | undefined,
46
+ scope: string,
47
+ ): string[] {
48
+ if (overrides === undefined) return [];
49
+ assertKnown('color', overrides, COLOR_ROLES);
50
+ const out: string[] = [];
51
+ for (const role of COLOR_ROLES) {
52
+ const value = overrides[role];
53
+ if (value === undefined) continue;
54
+ try {
55
+ parseChannels(value);
56
+ } catch {
57
+ throw invalidBrandTokenError(scope, role, value, CHANNELS_EXPECTED);
58
+ }
59
+ out.push(`--color-${role}: ${value};`);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export function shadowDeclarations(
65
+ overrides: Partial<Record<ShadowName, string>> | undefined,
66
+ scope: string,
67
+ ): string[] {
68
+ if (overrides === undefined) return [];
69
+ assertKnown('shadow', overrides, SHADOW_NAMES, '_shadow.scss');
70
+ const out: string[] = [];
71
+ for (const name of SHADOW_NAMES) {
72
+ const value = overrides[name];
73
+ if (value === undefined) continue;
74
+ if (!isShadowValue(value)) throw invalidBrandTokenError(scope, name, value, SHADOW_EXPECTED);
75
+ out.push(`--shadow-${name}: ${value};`);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ export function radiusDeclarations(
81
+ overrides: Partial<Record<RadiusName, string>> | undefined,
82
+ ): string[] {
83
+ if (overrides === undefined) return [];
84
+ const known = Object.keys(radiusTokens) as RadiusName[];
85
+ assertKnown('radius', overrides, known, '_radius.scss');
86
+ const out: string[] = [];
87
+ for (const name of known) {
88
+ const value = overrides[name];
89
+ if (value === undefined) continue;
90
+ if (!LENGTH_PATTERN.test(value)) {
91
+ throw invalidBrandTokenError('radius', name, value, LENGTH_EXPECTED);
92
+ }
93
+ out.push(`--radius-${name}: ${value};`);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ export function fontDeclarations(
99
+ overrides: Partial<Record<FontSlot, string>> | undefined,
100
+ ): string[] {
101
+ if (overrides === undefined) return [];
102
+ assertKnown('font', overrides, FONT_SLOTS, '_typography.scss');
103
+ const out: string[] = [];
104
+ for (const slot of FONT_SLOTS) {
105
+ const value = overrides[slot];
106
+ if (value === undefined) continue;
107
+ if (!FONT_STACK_PATTERN.test(value)) {
108
+ throw invalidBrandTokenError('font', slot, value, STACK_EXPECTED);
109
+ }
110
+ out.push(`--font-${slot}: ${value};`);
111
+ }
112
+ return out;
113
+ }