@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
@@ -3,30 +3,43 @@
3
3
  // level it emits. There is deliberately no SCSS `@use ... with ()` seam — two ways to change the
4
4
  // accent colour is the ambiguity axiom 1 exists to delete.
5
5
 
6
- import {
7
- insufficientContrastError,
8
- invalidBrandTokenError,
9
- runtimeMissingError,
10
- unknownTokenError,
11
- } from '../errors';
12
- import { contrastRatio, parseChannels } from '../tokens/contrast';
6
+ import { insufficientContrastError, invalidBrandTokenError, runtimeMissingError } from '../errors';
7
+ import { contrastRatio } from '../tokens/contrast';
13
8
  import { CONTRAST_PAIRS } from '../tokens/contrast-pairs';
14
9
  import {
15
10
  COLOR_ROLES,
16
11
  type ColorRole,
17
12
  colorTokens,
18
13
  type RadiusName,
19
- radiusTokens,
14
+ SHADOW_NAMES,
15
+ type ShadowName,
16
+ shadowTokens,
20
17
  type Theme,
21
18
  } from '../tokens/tokens';
19
+ import {
20
+ colorDeclarations,
21
+ fontDeclarations,
22
+ radiusDeclarations,
23
+ shadowDeclarations,
24
+ } from './brand-declarations';
25
+ import type { FontSlot, ThemePreset } from './preset-shape';
26
+ import { THEME_PRESET_NAMES, THEME_PRESETS, type ThemePresetName } from './presets';
27
+
28
+ export type { FontSlot } from './preset-shape';
29
+ export { FONT_SLOTS } from './preset-shape';
22
30
 
23
- /** The two font slots `_typography.scss` emits. */
24
- export const FONT_SLOTS = ['sans', 'mono'] as const;
25
- export type FontSlot = (typeof FONT_SLOTS)[number];
31
+ type Themed<K extends string> = Partial<Record<Theme, Partial<Record<K, string>>>>;
26
32
 
27
33
  export interface BrandInput {
34
+ /**
35
+ * A shipped palette to start from (`THEME_PRESETS`). Every other slot layers onto it role by
36
+ * role, so `{ preset: 'scifi', colors: { dark: { accent: … } } }` is scifi with your accent.
37
+ */
38
+ preset?: ThemePresetName | undefined;
28
39
  /** Channel overrides per theme. Omit a theme to leave it at the shipped palette. */
29
- colors?: Partial<Record<Theme, Partial<Record<ColorRole, string>>>> | undefined;
40
+ colors?: Themed<ColorRole> | undefined;
41
+ /** Shadow overrides per theme, by rung (`SHADOW_NAMES`) — elevation is themed like colour. */
42
+ shadows?: Themed<ShadowName> | undefined;
30
43
  radius?: Partial<Record<RadiusName, string>> | undefined;
31
44
  font?: Partial<Record<FontSlot, string>> | undefined;
32
45
  }
@@ -36,50 +49,91 @@ export interface Brand {
36
49
  readonly css: string;
37
50
  }
38
51
 
39
- /** `0` or a number with a CSS length unit. No `calc()`, no `var()` — a scale rung is a value. */
40
- const LENGTH_PATTERN = /^(0|\d+(\.\d+)?(px|rem|em|ch|%))$/;
41
-
42
- /** Family names, quotes and separators only: everything a `font-family` list legitimately needs. */
43
- const FONT_STACK_PATTERN = /^[\w\s,'"-]{1,200}$/;
44
-
45
- const LENGTH_EXPECTED = 'a bare CSS length such as "0.5rem", "4px" or "0"';
46
- const STACK_EXPECTED = 'a font-family list such as "Inter, system-ui, sans-serif"';
47
- const CHANNELS_EXPECTED = 'space-separated RGB channels such as "31 110 178"';
48
-
49
52
  /**
50
53
  * Validate and freeze a brand. Every value is checked here rather than at render time, so a bad
51
54
  * override fails at the app's entry point with the role that broke it named — not as a silently
52
55
  * dropped declaration a human has to spot in devtools.
53
56
  */
54
57
  export function defineTheme(input: BrandInput): Brand {
55
- const blocks: string[] = [];
56
- const light = colorDeclarations(input.colors?.light, 'colors.light');
57
- const dark = colorDeclarations(input.colors?.dark, 'colors.dark');
58
+ const base = presetOf(input.preset);
59
+ const colors = {
60
+ light: layer(base?.colors.light, input.colors?.light),
61
+ dark: layer(base?.colors.dark, input.colors?.dark),
62
+ };
63
+ const shadows = {
64
+ light: layer(base?.shadows?.light, input.shadows?.light),
65
+ dark: layer(base?.shadows?.dark, input.shadows?.dark),
66
+ };
67
+ const light = [
68
+ ...colorDeclarations(colors.light, 'colors.light'),
69
+ ...shadowDeclarations(shadows.light, 'shadows.light'),
70
+ ];
71
+ const dark = [
72
+ ...colorDeclarations(colors.dark, 'colors.dark'),
73
+ ...shadowDeclarations(shadows.dark, 'shadows.dark'),
74
+ ];
58
75
  // Measured AFTER the channels parse and BEFORE a single declaration is rendered: a palette that
59
76
  // fails AA is not a stylesheet with a warning attached, it is a refusal. The pairings are the
60
77
  // ones the framework's own palette is held to (`CONTRAST_PAIRS`), so an app cannot ship a brand
61
- // the design system would have failed its own tests over.
62
- assertContrast('light', input.colors?.light);
63
- assertContrast('dark', input.colors?.dark);
78
+ // the design system would have failed its own tests over. A preset is measured as part of the
79
+ // palette it builds — the merged result is what renders.
80
+ assertContrast('light', colors.light);
81
+ assertContrast('dark', colors.dark);
64
82
  const root: string[] = [
65
83
  ...light,
66
- ...radiusDeclarations(input.radius),
67
- ...fontDeclarations(input.font),
84
+ ...radiusDeclarations(layer(base?.radius, input.radius)),
85
+ ...fontDeclarations(layer(base?.font, input.font)),
68
86
  ];
87
+ const blocks: string[] = [];
69
88
  if (root.length > 0) blocks.push(rule(':root', root));
70
89
 
71
90
  // `theme.scss` emits light at `:root`, dark behind the media query, and BOTH again under
72
91
  // `html[data-theme]`. A brand that only wrote `:root` would lose to those attribute rules on
73
92
  // specificity, so every level it emits is answered here, in the same order.
74
93
  if (light.length > 0) blocks.push(rule("html[data-theme='light']", light));
75
- if (dark.length > 0) {
76
- blocks.push(`@media (prefers-color-scheme: dark) {\n${indent(rule(':root', dark))}\n}`);
77
- blocks.push(rule("html[data-theme='dark']", dark));
94
+ const media = [
95
+ ...darkMediaDeclarations('color', COLOR_ROLES, colors, colorTokens.dark),
96
+ ...darkMediaDeclarations('shadow', SHADOW_NAMES, shadows, shadowTokens.dark),
97
+ ];
98
+ if (media.length > 0) {
99
+ blocks.push(`@media (prefers-color-scheme: dark) {\n${indent(rule(':root', media))}\n}`);
78
100
  }
101
+ if (dark.length > 0) blocks.push(rule("html[data-theme='dark']", dark));
79
102
 
80
103
  return Object.freeze({ css: blocks.join('\n\n') });
81
104
  }
82
105
 
106
+ /**
107
+ * The named preset, or `undefined` for none. Own-property lookup: a brand read from a JSON config
108
+ * reaches here untyped, and `'toString'` must be a refusal, not `Object.prototype.toString`.
109
+ */
110
+ function presetOf(name: ThemePresetName | undefined): ThemePreset | undefined {
111
+ if (name === undefined) return undefined;
112
+ if (!Object.hasOwn(THEME_PRESETS, name)) {
113
+ const known = THEME_PRESET_NAMES.map((preset) => `"${preset}"`).join(', ');
114
+ throw invalidBrandTokenError('input', 'preset', name, `a preset @ultimat3/ui ships: ${known}`);
115
+ }
116
+ return THEME_PRESETS[name];
117
+ }
118
+
119
+ /**
120
+ * `overrides` on top of `base`, key by key. An `undefined` value is no override — a spread would
121
+ * copy it and erase the preset's value for that key, rendering the shipped default in its place.
122
+ */
123
+ function layer<V extends string>(
124
+ base: Readonly<Partial<Record<string, V>>> | undefined,
125
+ overrides: Readonly<Partial<Record<string, V>>> | undefined,
126
+ ): Record<string, V> | undefined {
127
+ if (base === undefined && overrides === undefined) return undefined;
128
+ const out: Record<string, V> = {};
129
+ for (const source of [base, overrides]) {
130
+ for (const [key, value] of Object.entries(source ?? {})) {
131
+ if (value !== undefined) out[key] = value;
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
83
137
  /** The exact tag to inline, after `global.scss` so the overrides land later in the cascade. */
84
138
  export function brandStyleTag(brand: Brand): string {
85
139
  return `<style>${brand.css}</style>`;
@@ -112,64 +166,26 @@ function indent(block: string): string {
112
166
  }
113
167
 
114
168
  /**
115
- * Ordered by the canonical role list, not by the caller's object — a brand file rendered twice
116
- * must be byte-identical, or every consumer's CSP hash and diff churns for nothing.
169
+ * The dark media `:root` must answer every token the brand's own `:root` touched. That `:root`
170
+ * (light) comes after `theme.scss`'s dark media `:root` at equal specificity, so a role overridden
171
+ * in light only would otherwise reach an OS-dark document with no `data-theme` — scripting off, or
172
+ * storage throwing in the boot script — on the dark palette it was never measured against. Such a
173
+ * token is answered with the SHIPPED dark value. Called after both scopes were validated.
117
174
  */
118
- function colorDeclarations(
119
- overrides: Partial<Record<ColorRole, string>> | undefined,
120
- scope: string,
175
+ function darkMediaDeclarations<K extends string>(
176
+ prefix: string,
177
+ names: readonly K[],
178
+ scopes: {
179
+ readonly light: Partial<Record<string, string>> | undefined;
180
+ readonly dark: Partial<Record<string, string>> | undefined;
181
+ },
182
+ shippedDark: Readonly<Record<K, string>>,
121
183
  ): string[] {
122
- if (overrides === undefined) return [];
123
- for (const role of Object.keys(overrides)) {
124
- if (!(COLOR_ROLES as readonly string[]).includes(role)) {
125
- throw unknownTokenError('color', role, COLOR_ROLES);
126
- }
127
- }
128
- const out: string[] = [];
129
- for (const role of COLOR_ROLES) {
130
- const value = overrides[role];
131
- if (value === undefined) continue;
132
- assertChannels(scope, role, value);
133
- out.push(`--color-${role}: ${value};`);
134
- }
135
- return out;
136
- }
137
-
138
- function radiusDeclarations(overrides: Partial<Record<RadiusName, string>> | undefined): string[] {
139
- if (overrides === undefined) return [];
140
- const known = Object.keys(radiusTokens) as RadiusName[];
141
- for (const name of Object.keys(overrides)) {
142
- if (!(known as readonly string[]).includes(name)) {
143
- throw unknownTokenError('radius', name, known, '_radius.scss');
144
- }
145
- }
146
- const out: string[] = [];
147
- for (const name of known) {
148
- const value = overrides[name];
149
- if (value === undefined) continue;
150
- if (!LENGTH_PATTERN.test(value)) {
151
- throw invalidBrandTokenError('radius', name, value, LENGTH_EXPECTED);
152
- }
153
- out.push(`--radius-${name}: ${value};`);
154
- }
155
- return out;
156
- }
157
-
158
- function fontDeclarations(overrides: Partial<Record<FontSlot, string>> | undefined): string[] {
159
- if (overrides === undefined) return [];
160
- for (const slot of Object.keys(overrides)) {
161
- if (!(FONT_SLOTS as readonly string[]).includes(slot)) {
162
- throw unknownTokenError('font', slot, FONT_SLOTS, '_typography.scss');
163
- }
164
- }
165
184
  const out: string[] = [];
166
- for (const slot of FONT_SLOTS) {
167
- const value = overrides[slot];
168
- if (value === undefined) continue;
169
- if (!FONT_STACK_PATTERN.test(value)) {
170
- throw invalidBrandTokenError('font', slot, value, STACK_EXPECTED);
171
- }
172
- out.push(`--font-${slot}: ${value};`);
185
+ for (const name of names) {
186
+ const value =
187
+ scopes.dark?.[name] ?? (scopes.light?.[name] === undefined ? undefined : shippedDark[name]);
188
+ if (value !== undefined) out.push(`--${prefix}-${name}: ${value};`);
173
189
  }
174
190
  return out;
175
191
  }
@@ -197,11 +213,3 @@ function assertContrast(
197
213
  throw insufficientContrastError(theme, pair.what, pair.fg, pair.bg, ratio, pair.minimum);
198
214
  }
199
215
  }
200
-
201
- function assertChannels(scope: string, role: string, value: string): void {
202
- try {
203
- parseChannels(value);
204
- } catch {
205
- throw invalidBrandTokenError(scope, role, value, CHANNELS_EXPECTED);
206
- }
207
- }
@@ -36,7 +36,7 @@ export const UI_DEFAULT_CURRENCY = 'USD';
36
36
  /**
37
37
  * Loud-miss translator: a forgotten catalog key renders ⟦key⟧, never blank.
38
38
  *
39
- * Written here rather than as `createTranslator({}, locale)`, and behaviourally the same call: an
39
+ * Written here rather than as `catalogTranslator({}, locale)`, and behaviourally the same call: an
40
40
  * empty catalog has no key, so every lookup is a miss, `has` is false, `raw` is undefined and
41
41
  * `keys` is empty — `context.test.ts` holds each of those against `@ultimat3/i18n`'s own. What
42
42
  * the spelling buys is that a browser chunk with a `UiProvider` in it no longer reaches the i18n
@@ -0,0 +1,99 @@
1
+ // The `scifi` preset: deep near-black panels in the dark, cool paper in the light, a cyan accent
2
+ // that turns magenta under the pointer, glowing edges on every raised surface, and sharp corners.
3
+ // Data, not a stylesheet — `defineTheme({ preset: 'scifi' })` renders it, and `presets.test.ts`
4
+ // holds it to every pairing in `CONTRAST_PAIRS` plus the chart series' colour-blind floor.
5
+
6
+ import type { ThemePreset } from './preset-shape';
7
+
8
+ export const SCIFI_PRESET: ThemePreset = {
9
+ colors: {
10
+ light: {
11
+ bg: '240 245 250',
12
+ 'bg-soft': '229 236 244',
13
+ surface: '247 250 253',
14
+ 'surface-raised': '255 255 255',
15
+ fg: '20 28 42',
16
+ 'fg-strong': '4 8 18',
17
+ 'fg-muted': '70 84 104',
18
+ line: '176 192 212',
19
+ scrim: '2 4 10',
20
+ accent: '0 108 135',
21
+ 'accent-strong': '168 0 124',
22
+ 'accent-fg': '255 255 255',
23
+ success: '0 112 70',
24
+ 'success-soft': '220 244 234',
25
+ 'success-fg': '255 255 255',
26
+ warning: '140 82 0',
27
+ 'warning-soft': '252 240 214',
28
+ 'warning-fg': '255 255 255',
29
+ danger: '185 30 50',
30
+ 'danger-soft': '253 228 232',
31
+ 'danger-fg': '255 255 255',
32
+ info: '30 90 190',
33
+ 'info-soft': '225 236 252',
34
+ 'info-fg': '255 255 255',
35
+ 'chart-1': '0 112 140',
36
+ 'chart-2': '179 5 130',
37
+ 'chart-3': '189 107 14',
38
+ 'chart-4': '59 110 49',
39
+ 'chart-5': '70 70 210',
40
+ 'chart-6': '188 28 13',
41
+ 'chart-7': '106 97 20',
42
+ 'chart-8': '60 60 60',
43
+ },
44
+ dark: {
45
+ bg: '5 7 13',
46
+ 'bg-soft': '9 12 21',
47
+ surface: '13 17 29',
48
+ 'surface-raised': '20 26 42',
49
+ fg: '210 225 240',
50
+ 'fg-strong': '240 250 255',
51
+ 'fg-muted': '125 145 170',
52
+ line: '44 60 88',
53
+ scrim: '0 0 0',
54
+ accent: '0 229 255',
55
+ 'accent-strong': '255 92 214',
56
+ 'accent-fg': '2 8 16',
57
+ success: '40 230 150',
58
+ 'success-soft': '6 36 28',
59
+ 'success-fg': '2 16 10',
60
+ warning: '255 200 60',
61
+ 'warning-soft': '40 30 6',
62
+ 'warning-fg': '22 14 0',
63
+ danger: '255 95 120',
64
+ 'danger-soft': '46 12 22',
65
+ 'danger-fg': '26 4 10',
66
+ info: '110 170 255',
67
+ 'info-soft': '10 26 50',
68
+ 'info-fg': '2 10 24',
69
+ 'chart-1': '48 229 255',
70
+ 'chart-2': '238 88 205',
71
+ 'chart-3': '170 255 90',
72
+ 'chart-4': '255 175 26',
73
+ 'chart-5': '148 130 255',
74
+ 'chart-6': '255 110 90',
75
+ 'chart-7': '88 198 148',
76
+ 'chart-8': '204 214 216',
77
+ },
78
+ },
79
+ // Dark only: a black drop shadow is invisible on a near-black page, so elevation there is an
80
+ // accent hairline plus a halo, with the drop kept under it for depth. Light keeps the shipped
81
+ // rungs, whose glows already read the (now cyan) accent.
82
+ shadows: {
83
+ dark: {
84
+ xs: '0 0 0 1px rgb(var(--color-accent) / 0.14)',
85
+ sm: '0 0 0 1px rgb(var(--color-accent) / 0.18), 0 2px 10px rgb(0 0 0 / 0.6)',
86
+ md: '0 0 0 1px rgb(var(--color-accent) / 0.22), 0 0 18px rgb(var(--color-accent) / 0.12), 0 8px 24px rgb(0 0 0 / 0.6)',
87
+ lg: '0 0 0 1px rgb(var(--color-accent) / 0.26), 0 0 28px rgb(var(--color-accent) / 0.14), 0 16px 40px rgb(0 0 0 / 0.7)',
88
+ xl: '0 0 0 1px rgb(var(--color-accent) / 0.3), 0 0 40px rgb(var(--color-accent) / 0.16), 0 28px 64px rgb(0 0 0 / 0.75)',
89
+ 'glow-sm': '0 0 8px rgb(var(--color-accent) / 0.6)',
90
+ 'glow-md': '0 0 16px rgb(var(--color-accent) / 0.5)',
91
+ 'glow-lg': '0 0 32px rgb(var(--color-accent) / 0.45)',
92
+ // Coloured elevation in the second accent, so a lifted card reads magenta beside cyan edges.
93
+ 'tinted-sm': '0 2px 10px rgb(var(--color-accent-strong) / 0.3)',
94
+ 'tinted-md': '0 6px 20px rgb(var(--color-accent-strong) / 0.34)',
95
+ 'tinted-lg': '0 14px 40px rgb(var(--color-accent-strong) / 0.38)',
96
+ },
97
+ },
98
+ radius: { sm: '0.125rem', md: '0.25rem', lg: '0.375rem', xl: '0.5rem' },
99
+ };
@@ -0,0 +1,18 @@
1
+ // What a theme preset IS: a complete palette for both themes plus optional shadow, radius and font
2
+ // overrides — the same slots `defineTheme()` takes, with colours made total so a preset is measured
3
+ // alone and never leans on the default palette to pass.
4
+
5
+ import type { ColorRole, RadiusName, ShadowName, Theme } from '../tokens/tokens';
6
+
7
+ /** The font slots `_typography.scss` emits — `defineTheme()` validates against this list. */
8
+ export const FONT_SLOTS = ['sans', 'mono', 'data'] as const;
9
+ export type FontSlot = (typeof FONT_SLOTS)[number];
10
+
11
+ export interface ThemePreset {
12
+ readonly colors: Readonly<Record<Theme, Readonly<Record<ColorRole, string>>>>;
13
+ readonly shadows?: Readonly<
14
+ Partial<Record<Theme, Readonly<Partial<Record<ShadowName, string>>>>>
15
+ >;
16
+ readonly radius?: Readonly<Partial<Record<RadiusName, string>>>;
17
+ readonly font?: Readonly<Partial<Record<FontSlot, string>>>;
18
+ }
@@ -0,0 +1,15 @@
1
+ // The presets `defineTheme({ preset })` accepts. A preset is a BASE the app's own overrides layer
2
+ // onto, role by role — never a mode with rules of its own — so every check the brand seam makes
3
+ // runs over the merged result. Adding one is a row here plus its file; `presets.test.ts` measures
4
+ // every row against the full contrast table.
5
+
6
+ import { SCIFI_PRESET } from './preset-scifi';
7
+ import type { ThemePreset } from './preset-shape';
8
+
9
+ export const THEME_PRESETS = {
10
+ scifi: SCIFI_PRESET,
11
+ } as const satisfies Readonly<Record<string, ThemePreset>>;
12
+
13
+ export type ThemePresetName = keyof typeof THEME_PRESETS;
14
+
15
+ export const THEME_PRESET_NAMES = Object.keys(THEME_PRESETS) as readonly ThemePresetName[];
@@ -0,0 +1,39 @@
1
+ // The grammar a `defineTheme()` shadow override must match. Validated, never escaped: the value is
2
+ // written into a `<style>` element, so the only safe answer to a `;`, a `}` or a `</style>` is a
3
+ // refusal. Strict enough to hold that line, wide enough for every rung `_shadow.scss` ships.
4
+
5
+ import { COLOR_ROLES } from '../tokens/color-tokens';
6
+
7
+ /** `0`, or a signed length in a unit a shadow is written in. No `calc()`, no `var()`. */
8
+ const LENGTH = String.raw`(?:0|-?\d+(?:\.\d+)?(?:px|rem|em))`;
9
+ const ALPHA = String.raw`(?:0|1|0?\.\d+)`;
10
+ const CHANNEL = String.raw`\d{1,3}`;
11
+ /** A role reference, so a shadow can follow the theme — the shape `t.role()` compiles to. */
12
+ const ROLE_COLOUR = String.raw`rgb\(var\(--color-([a-z0-9-]+)\) / ${ALPHA}\)`;
13
+ const CHANNEL_COLOUR = String.raw`rgb\((${CHANNEL}) (${CHANNEL}) (${CHANNEL})(?: / ${ALPHA})?\)`;
14
+ const LAYER = `(?:inset )?${LENGTH} ${LENGTH}(?: ${LENGTH}){0,2} (?:${ROLE_COLOUR}|${CHANNEL_COLOUR})`;
15
+
16
+ const LAYER_PATTERN = new RegExp(`^${LAYER}$`);
17
+
18
+ /** Long enough for five layers; a value past it is not a shadow anyone designed. */
19
+ const MAX_LENGTH = 400;
20
+
21
+ export const SHADOW_EXPECTED =
22
+ 'a box-shadow such as "0 4px 14px rgb(17 15 13 / 0.1)" or "0 0 14px rgb(var(--color-accent) / 0.4)" — layers comma-separated, colours as rgb() over channels or a colour role, or "none"';
23
+
24
+ /**
25
+ * Whether `value` is a shadow this seam will write. Every layer is matched whole, every channel is
26
+ * 0–255, and every role a layer names is a role the palette has — a `--color-brand` that resolves
27
+ * to nothing would render no shadow at all and no error anywhere.
28
+ */
29
+ export function isShadowValue(value: string): boolean {
30
+ if (value === 'none') return true;
31
+ if (value.length === 0 || value.length > MAX_LENGTH) return false;
32
+ return value.split(', ').every((layer) => {
33
+ const match = LAYER_PATTERN.exec(layer);
34
+ if (match === null) return false;
35
+ const [, role, r, g, b] = match;
36
+ if (role !== undefined) return (COLOR_ROLES as readonly string[]).includes(role);
37
+ return [r, g, b].every((channel) => Number(channel) <= 255);
38
+ });
39
+ }
@@ -1,19 +1,26 @@
1
- // Theme resolution. One rule: an explicit choice in storage wins, otherwise the
2
- // OS decides. Every side effect goes through an injected `ThemeEnv`, so this is
3
- // testable without a DOM and reusable on the server.
1
+ // Theme resolution. One rule: an explicit choice in storage wins, then the theme the boot script
2
+ // already stamped on the document, then the app's `theme.defaultMode` (the OS for `'system'`). Every side effect
3
+ // goes through an injected `ThemeEnv`, so this is testable without a DOM and reusable on the server.
4
4
 
5
+ import { THEME_STORAGE_KEY } from '@ultimat3/core';
5
6
  import { invalidThemeError, runtimeMissingError } from '../errors';
6
7
  import type { Theme } from '../tokens/tokens';
7
8
 
8
9
  export type { Theme };
9
10
 
10
- export const THEME_STORAGE_KEY = 'ultimate.theme';
11
11
  export const THEME_ATTRIBUTE = 'data-theme';
12
+ /** Where the boot script records `theme.defaultMode` itself — `'light'`, `'dark'` or `'system'`.
13
+ * `@ultimat3/render`'s `themeScriptBody` writes `${attribute}-default`; this is that name. */
14
+ export const THEME_DEFAULT_ATTRIBUTE = `${THEME_ATTRIBUTE}-default`;
12
15
  export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)';
13
16
 
14
17
  /** The host capabilities theme control needs. Injected so tests need no DOM. */
15
18
  export interface ThemeEnv {
16
19
  read(): string | null;
20
+ /** The theme the document carries right now — what the boot script stamped, or the last apply. */
21
+ current(): string | null;
22
+ /** The app's `theme.defaultMode` as the boot stamped it, or `null` when no boot ran. */
23
+ appDefault(): string | null;
17
24
  write(value: string): void;
18
25
  remove(): void;
19
26
  prefersDark(): boolean;
@@ -40,9 +47,25 @@ export function osTheme(env: ThemeEnv): Theme {
40
47
  return env.prefersDark() ? 'dark' : 'light';
41
48
  }
42
49
 
43
- /** Resolution order: explicit stored choice -> OS preference. */
50
+ /**
51
+ * What "no explicit choice" means for this app: its fixed `theme.defaultMode` when it has one, the
52
+ * OS when it is `'system'` or unknown. Clearing a choice and following the OS both answer this, so
53
+ * an app that opens dark is never flipped light by an OS change or a "system" pick.
54
+ */
55
+ export function defaultTheme(env: ThemeEnv): Theme {
56
+ const fixed = env.appDefault();
57
+ return isTheme(fixed) ? fixed : osTheme(env);
58
+ }
59
+
60
+ /**
61
+ * Resolution order: explicit stored choice -> the theme on the document -> OS preference. The
62
+ * middle step is the app's default: with `theme.defaultMode: 'dark'` the boot script stamps dark
63
+ * on a light-OS machine, and answering the OS here made the first toggle write the theme already
64
+ * on screen — a click that did nothing.
65
+ */
44
66
  export function resolveTheme(env: ThemeEnv): Theme {
45
- return storedTheme(env) ?? osTheme(env);
67
+ const current = env.current();
68
+ return storedTheme(env) ?? (isTheme(current) ? current : defaultTheme(env));
46
69
  }
47
70
 
48
71
  /** Run once on load. Idempotent — safe after the inline head script. */
@@ -60,10 +83,10 @@ export function setTheme(theme: Theme, env: ThemeEnv = browserThemeEnv()): Theme
60
83
  return theme;
61
84
  }
62
85
 
63
- /** Forget the explicit choice and follow the OS again. */
86
+ /** Forget the explicit choice and return to the app's default (the OS, for `'system'`). */
64
87
  export function clearTheme(env: ThemeEnv = browserThemeEnv()): Theme {
65
88
  env.remove();
66
- const theme = osTheme(env);
89
+ const theme = defaultTheme(env);
67
90
  env.apply(theme);
68
91
  return theme;
69
92
  }
@@ -74,12 +97,12 @@ export function toggleTheme(env: ThemeEnv = browserThemeEnv()): Theme {
74
97
  }
75
98
 
76
99
  /**
77
- * Follow the OS live — but only while the user has made no explicit choice.
78
- * Returns an unsubscribe so a component can drop it on cleanup.
100
+ * Follow the OS live — but only while the user has made no explicit choice AND the app's default
101
+ * is the OS. Returns an unsubscribe so a component can drop it on cleanup.
79
102
  */
80
103
  export function watchOsTheme(env: ThemeEnv = browserThemeEnv()): () => void {
81
104
  return env.observeOs(() => {
82
- if (storedTheme(env) === null) env.apply(osTheme(env));
105
+ if (storedTheme(env) === null && !isTheme(env.appDefault())) env.apply(osTheme(env));
83
106
  });
84
107
  }
85
108
 
@@ -94,6 +117,8 @@ export function browserThemeEnv(): ThemeEnv {
94
117
  const media = window.matchMedia(THEME_MEDIA_QUERY);
95
118
  return {
96
119
  read: () => safeStorage(() => window.localStorage.getItem(THEME_STORAGE_KEY), null),
120
+ current: () => document.documentElement.getAttribute(THEME_ATTRIBUTE),
121
+ appDefault: () => document.documentElement.getAttribute(THEME_DEFAULT_ATTRIBUTE),
97
122
  write: (value) => safeStorage(() => window.localStorage.setItem(THEME_STORAGE_KEY, value)),
98
123
  remove: () => safeStorage(() => window.localStorage.removeItem(THEME_STORAGE_KEY)),
99
124
  prefersDark: () => media.matches,
Binary file
@@ -84,7 +84,7 @@ function defaultEnv(): ToastEnv {
84
84
  return typeof document === 'undefined' ? INERT_TOAST_ENV : browserToastEnv();
85
85
  }
86
86
 
87
- export function createToastStore(env: ToastEnv = defaultEnv()): ToastStore {
87
+ export function toastStore(env: ToastEnv = defaultEnv()): ToastStore {
88
88
  const holds = new Set<ToastHold>();
89
89
  const listeners = new Set<(queue: ToastQueue) => void>();
90
90
  let queue: ToastQueue = EMPTY_TOAST_QUEUE;
@@ -1,4 +1,4 @@
1
- // The reactive shell over `createToastStore`, and the whole of what it adds: the queue lands in a
1
+ // The reactive shell over `toastStore`, and the whole of what it adds: the queue lands in a
2
2
  // signal, so `<Toaster>` re-renders when a message arrives or a dwell runs out.
3
3
  //
4
4
  // The subscription lives in an EFFECT, which is the package's one seam for DOM-only work: a server
@@ -1,6 +1,10 @@
1
1
  // Canonical colour token source. Semantic roles only, stored as space-separated
2
2
  // RGB channels so `rgb(var(--color-x) / 0.5)` composites without a second token.
3
3
  // `tokens.ts` mirrors these values; `x verify` fails if the two drift.
4
+ //
5
+ // `chart-1` … `chart-8` are the categorical series: Okabe–Ito hues, darkened for the light page and
6
+ // lifted for the dark one until each clears 3:1 on every surface and every pair stays apart under
7
+ // protan/deutan/tritan simulation (`contrast.test.ts`, `colour-vision.test.ts`).
4
8
 
5
9
  $light: (
6
10
  bg: 253 246 240,
@@ -27,6 +31,14 @@ $light: (
27
31
  info: 31 110 178,
28
32
  info-soft: 224 239 252,
29
33
  info-fg: 255 255 255,
34
+ chart-1: 0 107 170,
35
+ chart-2: 189 107 14,
36
+ chart-3: 0 125 90,
37
+ chart-4: 189 44 16,
38
+ chart-5: 170 70 140,
39
+ chart-6: 47 135 195,
40
+ chart-7: 118 97 18,
41
+ chart-8: 60 60 60,
30
42
  );
31
43
 
32
44
  $dark: (
@@ -54,6 +66,14 @@ $dark: (
54
66
  info: 96 170 240,
55
67
  info-soft: 22 38 56,
56
68
  info-fg: 12 20 30,
69
+ chart-1: 105 201 255,
70
+ chart-2: 230 153 0,
71
+ chart-3: 0 167 113,
72
+ chart-4: 236 111 60,
73
+ chart-5: 203 126 189,
74
+ chart-6: 124 153 252,
75
+ chart-7: 246 234 72,
76
+ chart-8: 181 181 180,
57
77
  );
58
78
 
59
79
  /// The tone vocabulary, in the order components expose it. Mirrors `TONES` in
@@ -0,0 +1,59 @@
1
+ // Surface effects a dashboard opts into: a glowing edge and a grid/scanline ground. Paired with
2
+ // the `scifi` preset (`defineTheme({ preset: 'scifi' })`) and meaningful without it — every colour
3
+ // is a role and every glow a shadow token, so each follows whatever palette the page resolved.
4
+ // Nothing here animates, so there is no motion for `prefers-reduced-motion` to take away.
5
+
6
+ @use 'colors';
7
+ @use 'shadow';
8
+ @use 'space';
9
+ @use 'stroke';
10
+
11
+ /// A hairline ring in the accent plus a glow rung, as one `box-shadow`. For a live tile, a focused
12
+ /// panel, a selected series. Forced-colours mode drops box shadows itself; the `border` an
13
+ /// element already has is what remains there, which is why this draws no border of its own.
14
+ @mixin glow-edge($glow: 'glow-sm', $alpha: 0.55) {
15
+ box-shadow:
16
+ inset 0 0 0 stroke.stroke(hairline) colors.role('accent', $alpha),
17
+ shadow.shadow($glow);
18
+ }
19
+
20
+ /// A faint accent grid on one spacing pitch, with optional horizontal scanlines over it: the
21
+ /// "instrument panel" ground. Only `background-image` and `background-size` are written, so the
22
+ /// element's own `background-color` shows through. Withdrawn under `prefers-contrast: more` and
23
+ /// `forced-colors: active`, where texture behind text is noise and not atmosphere.
24
+ @mixin grid-texture($pitch: space.space(6), $alpha: 0.08, $scanlines: true) {
25
+ $grid-x: linear-gradient(
26
+ 90deg,
27
+ colors.role('accent', $alpha) stroke.stroke(hairline),
28
+ transparent stroke.stroke(hairline)
29
+ );
30
+ $grid-y: linear-gradient(
31
+ 180deg,
32
+ colors.role('accent', $alpha) stroke.stroke(hairline),
33
+ transparent stroke.stroke(hairline)
34
+ );
35
+
36
+ @if $scanlines {
37
+ background-image:
38
+ repeating-linear-gradient(
39
+ 180deg,
40
+ colors.role('fg', $alpha * 0.5) 0 stroke.stroke(hairline),
41
+ transparent stroke.stroke(hairline) stroke.stroke(heavy)
42
+ ),
43
+ $grid-x,
44
+ $grid-y;
45
+ background-size:
46
+ auto,
47
+ $pitch $pitch,
48
+ $pitch $pitch;
49
+ } @else {
50
+ background-image: $grid-x, $grid-y;
51
+ background-size:
52
+ $pitch $pitch,
53
+ $pitch $pitch;
54
+ }
55
+
56
+ @media (prefers-contrast: more), (forced-colors: active) {
57
+ background-image: none;
58
+ }
59
+ }