@crossworks/share-ui 0.230.43

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 (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +68 -0
  3. package/src/app-bridge-protocol.ts +115 -0
  4. package/src/app-presenter.tsx +25 -0
  5. package/src/app-sandbox.tsx +552 -0
  6. package/src/appearance.ts +192 -0
  7. package/src/avatar.test.ts +229 -0
  8. package/src/avatar.ts +731 -0
  9. package/src/backgrounds.test.ts +119 -0
  10. package/src/backgrounds.ts +118 -0
  11. package/src/draw-presenter.tsx +39 -0
  12. package/src/event-presenter.tsx +62 -0
  13. package/src/file-presenter.tsx +76 -0
  14. package/src/formula-calculator.tsx +209 -0
  15. package/src/formula-presenter.test.ts +128 -0
  16. package/src/formula-presenter.tsx +301 -0
  17. package/src/help-topics.ts +104 -0
  18. package/src/lib/ink-audit.test.ts +314 -0
  19. package/src/lib/theme-css-blocks.ts +26 -0
  20. package/src/lib/theme-generator.test.ts +179 -0
  21. package/src/lib/theme-registry.gen.ts +352 -0
  22. package/src/lib/themes.test.ts +308 -0
  23. package/src/lib/themes.ts +75 -0
  24. package/src/lib/utils.ts +6 -0
  25. package/src/nav-items.ts +225 -0
  26. package/src/note-presenter.tsx +14 -0
  27. package/src/page-outline.tsx +127 -0
  28. package/src/table-presenter.tsx +226 -0
  29. package/src/task-presenter.tsx +60 -0
  30. package/src/ui/button.tsx +50 -0
  31. package/src/ui/input.tsx +18 -0
  32. package/src/ui/label.tsx +20 -0
  33. package/src/view-payload.ts +82 -0
  34. package/styles/app.css +1098 -0
  35. package/styles/themes.css +6198 -0
  36. package/themes/generate.d.mts +11 -0
  37. package/themes/generate.mjs +618 -0
  38. package/themes/model.d.mts +24 -0
  39. package/themes/model.mjs +213 -0
  40. package/themes/preview.html +145 -0
  41. package/themes/seeds.d.mts +16 -0
  42. package/themes/seeds.mjs +3694 -0
  43. package/tsconfig.json +15 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,352 @@
1
+ /* GENERATED FILE — do not edit. `pnpm themes:build` regenerates it from
2
+ * themes/seeds.mjs. Swatches are [primary, accent, background] of the generated
3
+ * light AND dark modes, so the picker previews the theme you would actually get
4
+ * in the mode you are in, and can never desync from the CSS. */
5
+ import type { ColorTheme } from './themes';
6
+
7
+ export const GENERATED_COLOR_THEMES: ColorTheme[] = [
8
+ {
9
+ id: 'clean-slate',
10
+ label: 'Clean Slate',
11
+ swatches: {
12
+ light: ['#6264ef', '#e0e7ff', '#f8fafc'],
13
+ dark: ['#818cf8', '#374151', '#0f172a'],
14
+ },
15
+ },
16
+ {
17
+ id: 'amber-minimal',
18
+ label: 'Amber Minimal',
19
+ swatches: {
20
+ light: ['#f59e0b', '#fffbeb', '#ffffff'],
21
+ dark: ['#f59e0b', '#92400e', '#171717'],
22
+ },
23
+ },
24
+ {
25
+ id: 'amethyst-haze',
26
+ label: 'Amethyst Haze',
27
+ swatches: {
28
+ light: ['#7e6d9e', '#e6a5b8', '#f8f7fa'],
29
+ dark: ['#a995c9', '#372e3f', '#1a1823'],
30
+ },
31
+ },
32
+ {
33
+ id: 'bold-tech',
34
+ label: 'Bold Tech',
35
+ swatches: {
36
+ light: ['#8757f1', '#dbeafe', '#ffffff'],
37
+ dark: ['#8757f1', '#4338ca', '#0f172a'],
38
+ },
39
+ },
40
+ {
41
+ id: 'bubblegum',
42
+ label: 'Bubblegum',
43
+ swatches: {
44
+ light: ['#c64590', '#fbe2a7', '#f6e6ee'],
45
+ dark: ['#fbe2a7', '#98526d', '#12242e'],
46
+ },
47
+ },
48
+ {
49
+ id: 'caffeine',
50
+ label: 'Caffeine',
51
+ swatches: {
52
+ light: ['#644a40', '#e8e8e8', '#f9f9f9'],
53
+ dark: ['#ffe0c2', '#2a2a2a', '#111111'],
54
+ },
55
+ },
56
+ {
57
+ id: 'candyland',
58
+ label: 'Candyland',
59
+ swatches: {
60
+ light: ['#ffc0cb', '#ffff00', '#f7f9fa'],
61
+ dark: ['#ff99cc', '#87ceeb', '#1a1d23'],
62
+ },
63
+ },
64
+ {
65
+ id: 'catppuccin',
66
+ label: 'Catppuccin',
67
+ swatches: {
68
+ light: ['#8839ef', '#007dbb', '#eff1f5'],
69
+ dark: ['#cba6f7', '#89dceb', '#181825'],
70
+ },
71
+ },
72
+ {
73
+ id: 'claude',
74
+ label: 'Claude',
75
+ swatches: {
76
+ light: ['#bd5937', '#e9e6dc', '#faf9f5'],
77
+ dark: ['#b95a3b', '#1a1915', '#262624'],
78
+ },
79
+ },
80
+ {
81
+ id: 'claymorphism',
82
+ label: 'Claymorphism',
83
+ swatches: {
84
+ light: ['#6264ef', '#f3e5f5', '#e7e5e4'],
85
+ dark: ['#818cf8', '#484441', '#1e1b18'],
86
+ },
87
+ },
88
+ {
89
+ id: 'cosmic-night',
90
+ label: 'Cosmic Night',
91
+ swatches: {
92
+ light: ['#6e56cf', '#d8e6ff', '#f5f5ff'],
93
+ dark: ['#a48fff', '#303060', '#0f0f1a'],
94
+ },
95
+ },
96
+ {
97
+ id: 'cyberpunk',
98
+ label: 'Cyberpunk',
99
+ swatches: {
100
+ light: ['#dc00a9', '#00ffcc', '#f8f9fa'],
101
+ dark: ['#dc00a9', '#00ffcc', '#0c0c1d'],
102
+ },
103
+ },
104
+ {
105
+ id: 'darkmatter',
106
+ label: 'Darkmatter',
107
+ swatches: {
108
+ light: ['#b85c23', '#eeeeee', '#ffffff'],
109
+ dark: ['#e78a53', '#333333', '#121113'],
110
+ },
111
+ },
112
+ {
113
+ id: 'doom-64',
114
+ label: 'Doom 64',
115
+ swatches: {
116
+ light: ['#b71c1c', '#3f7bad', '#cccccc'],
117
+ dark: ['#de312f', '#64b5f6', '#1a1a1a'],
118
+ },
119
+ },
120
+ {
121
+ id: 'elegant-luxury',
122
+ label: 'Elegant Luxury',
123
+ swatches: {
124
+ light: ['#9b2c2c', '#fef3c7', '#faf7f5'],
125
+ dark: ['#b91c1c', '#b45309', '#1c1917'],
126
+ },
127
+ },
128
+ {
129
+ id: 'graphite',
130
+ label: 'Graphite',
131
+ swatches: {
132
+ light: ['#606060', '#c0c0c0', '#f0f0f0'],
133
+ dark: ['#a0a0a0', '#404040', '#1a1a1a'],
134
+ },
135
+ },
136
+ {
137
+ id: 'kodama-grove',
138
+ label: 'Kodama Grove',
139
+ swatches: {
140
+ light: ['#6d7c2d', '#dbc894', '#e4d7b0'],
141
+ dark: ['#8a9f7b', '#a18f5c', '#3a3529'],
142
+ },
143
+ },
144
+ {
145
+ id: 'midnight-bloom',
146
+ label: 'Midnight Bloom',
147
+ swatches: {
148
+ light: ['#6c5ce7', '#727a4f', '#f9f9f9'],
149
+ dark: ['#6c5ce7', '#3664b7', '#1a1d23'],
150
+ },
151
+ },
152
+ {
153
+ id: 'mocha-mousse',
154
+ label: 'Mocha Mousse',
155
+ swatches: {
156
+ light: ['#966b59', '#e4c7b8', '#f1f0e5'],
157
+ dark: ['#c39e88', '#baab92', '#2d2521'],
158
+ },
159
+ },
160
+ {
161
+ id: 'modern-minimal',
162
+ label: 'Modern Minimal',
163
+ swatches: {
164
+ light: ['#2b72e5', '#e0f2fe', '#ffffff'],
165
+ dark: ['#2b72e5', '#1e3a8a', '#171717'],
166
+ },
167
+ },
168
+ {
169
+ id: 'mono',
170
+ label: 'Mono',
171
+ swatches: {
172
+ light: ['#737373', '#f5f5f5', '#ffffff'],
173
+ dark: ['#737373', '#404040', '#0a0a0a'],
174
+ },
175
+ },
176
+ {
177
+ id: 'nature',
178
+ label: 'Nature',
179
+ swatches: {
180
+ light: ['#2e7d32', '#c8e6c9', '#f8f5f0'],
181
+ dark: ['#4caf50', '#227b29', '#1c2a1f'],
182
+ },
183
+ },
184
+ {
185
+ id: 'neo-brutalism',
186
+ label: 'Neo Brutalism',
187
+ swatches: {
188
+ light: ['#ea101f', '#0066ff', '#ffffff'],
189
+ dark: ['#ff6666', '#3399ff', '#000000'],
190
+ },
191
+ },
192
+ {
193
+ id: 'notebook',
194
+ label: 'Notebook',
195
+ swatches: {
196
+ light: ['#606060', '#f3eac8', '#f9f9f9'],
197
+ dark: ['#b0b0b0', '#e0e0e0', '#2b2b2b'],
198
+ },
199
+ },
200
+ {
201
+ id: 'ocean-breeze',
202
+ label: 'Ocean Breeze',
203
+ swatches: {
204
+ light: ['#008a22', '#d1fae5', '#f0f8ff'],
205
+ dark: ['#34d399', '#2f3949', '#0f172a'],
206
+ },
207
+ },
208
+ {
209
+ id: 'pastel-dreams',
210
+ label: 'Pastel Dreams',
211
+ swatches: {
212
+ light: ['#8063ce', '#f3e5f5', '#f7f3f9'],
213
+ dark: ['#c0aafd', '#4a3d5a', '#1c1917'],
214
+ },
215
+ },
216
+ {
217
+ id: 'perpetuity',
218
+ label: 'Perpetuity',
219
+ swatches: {
220
+ light: ['#01838c', '#c9e5e7', '#e8f0f0'],
221
+ dark: ['#4de8e8', '#164955', '#0a1a20'],
222
+ },
223
+ },
224
+ {
225
+ id: 'pinnacle',
226
+ label: 'Pinnacle',
227
+ swatches: {
228
+ light: ['#457928', '#e9f1d6', '#f7faf7'],
229
+ dark: ['#6fbe44', '#1f3a33', '#0e1613'],
230
+ },
231
+ },
232
+ {
233
+ id: 'quantum-rose',
234
+ label: 'Quantum Rose',
235
+ swatches: {
236
+ light: ['#e40079', '#ffc1e3', '#fff0f8'],
237
+ dark: ['#ff6bef', '#5a1f5d', '#1a0922'],
238
+ },
239
+ },
240
+ {
241
+ id: 'sage-garden',
242
+ label: 'Sage Garden',
243
+ swatches: {
244
+ light: ['#687b6e', '#bfc9bb', '#f8f7f4'],
245
+ dark: ['#7c9082', '#36443a', '#0a0a0a'],
246
+ },
247
+ },
248
+ {
249
+ id: 'soft-pop',
250
+ label: 'Soft Pop',
251
+ swatches: {
252
+ light: ['#4f46e5', '#f59e0b', '#f7f9f3'],
253
+ dark: ['#818cf8', '#fcd34d', '#000000'],
254
+ },
255
+ },
256
+ {
257
+ id: 'solar-dusk',
258
+ label: 'Solar Dusk',
259
+ swatches: {
260
+ light: ['#b45309', '#f2daba', '#fdfbf7'],
261
+ dark: ['#cd4a00', '#1e4252', '#1c1917'],
262
+ },
263
+ },
264
+ {
265
+ id: 'starry-night',
266
+ label: 'Starry Night',
267
+ swatches: {
268
+ light: ['#3a5ba0', '#467a96', '#f5f7fa'],
269
+ dark: ['#3a5ba0', '#bccdf0', '#181a24'],
270
+ },
271
+ },
272
+ {
273
+ id: 'sunset-horizon',
274
+ label: 'Sunset Horizon',
275
+ swatches: {
276
+ light: ['#ca4e31', '#feb47b', '#fff9f5'],
277
+ dark: ['#ca4e31', '#feb47b', '#2a2024'],
278
+ },
279
+ },
280
+ {
281
+ id: 'supabase',
282
+ label: 'Supabase',
283
+ swatches: {
284
+ light: ['#72e3ad', '#ededed', '#fcfcfc'],
285
+ dark: ['#006239', '#313131', '#121212'],
286
+ },
287
+ },
288
+ {
289
+ id: 't3-chat',
290
+ label: 'T3 Chat',
291
+ swatches: {
292
+ light: ['#a84370', '#f1c4e6', '#faf5fa'],
293
+ dark: ['#a3004c', '#463753', '#221d27'],
294
+ },
295
+ },
296
+ {
297
+ id: 'tangerine',
298
+ label: 'Tangerine',
299
+ swatches: {
300
+ light: ['#cd4b25', '#d6e4f0', '#e8ebed'],
301
+ dark: ['#cd4b25', '#2a3656', '#1c2433'],
302
+ },
303
+ },
304
+ {
305
+ id: 'twitter',
306
+ label: 'Twitter',
307
+ swatches: {
308
+ light: ['#007acc', '#e3ecf6', '#ffffff'],
309
+ dark: ['#0079cb', '#061622', '#000000'],
310
+ },
311
+ },
312
+ {
313
+ id: 'vercel',
314
+ label: 'Vercel',
315
+ swatches: {
316
+ light: ['#000000', '#ebebeb', '#fcfcfc'],
317
+ dark: ['#ffffff', '#333333', '#000000'],
318
+ },
319
+ },
320
+ {
321
+ id: 'vintage-paper',
322
+ label: 'Vintage Paper',
323
+ swatches: {
324
+ light: ['#966d44', '#d4c8aa', '#f5f1e6'],
325
+ dark: ['#c0a080', '#59493e', '#2d2621'],
326
+ },
327
+ },
328
+ {
329
+ id: 'violet-bloom',
330
+ label: 'Violet Bloom',
331
+ swatches: {
332
+ light: ['#7033ff', '#e2ebff', '#fdfdfd'],
333
+ dark: ['#8553f6', '#1e293b', '#1a1b1e'],
334
+ },
335
+ },
336
+ {
337
+ id: 'jackdaw',
338
+ label: 'Jackdaw',
339
+ swatches: {
340
+ light: ['#e46e08', '#f0c88c', '#fde7bc'],
341
+ dark: ['#eb9f13', '#452a18', '#120f0e'],
342
+ },
343
+ },
344
+ {
345
+ id: 'jackdaw-harvest',
346
+ label: 'Jackdaw Harvest',
347
+ swatches: {
348
+ light: ['#14565d', '#c4e5e8', '#f8deb9'],
349
+ dark: ['#49a8b3', '#14565d', '#1f1f1f'],
350
+ },
351
+ },
352
+ ];
@@ -0,0 +1,308 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { COLOR_THEMES } from './themes';
5
+ import { parseThemeBlocks } from './theme-css-blocks';
6
+
7
+ /**
8
+ * Contrast contract for EVERY theme — light and dark.
9
+ *
10
+ * themes.css is generated (themes/generate.mjs solves each text token against
11
+ * the surfaces it must be legible on), so this suite can demand the full
12
+ * matrix from all themes instead of hand-asserting Pinnacle and hoping about
13
+ * the rest. It deliberately re-implements the colour maths and re-parses the
14
+ * CSS instead of importing the generator: the generator asserting its own
15
+ * output would prove nothing, this recomputes the WCAG numbers from scratch on
16
+ * the exact bytes that ship. If the generator's solver, gamut clamp or hex
17
+ * rounding drifts, these fail.
18
+ *
19
+ * 4.5:1 text — every `-foreground` on its own fill; `foreground` and
20
+ * `muted-foreground` on every neutral surface; every ink
21
+ * (primary/destructive/success/warning/info and the code-* syntax
22
+ * palette) on every neutral surface, because inks land wherever their
23
+ * ~330 call sites happen to sit.
24
+ * 3.0:1 non-text — focus rings against what they ring, and generated chart
25
+ * colours against the chart surfaces. Seeded brand ramps (pinnacle)
26
+ * are authored artwork and exempt from the chart bar.
27
+ */
28
+ const CSS = readFileSync(
29
+ fileURLToPath(new URL('../../styles/themes.css', import.meta.url)),
30
+ 'utf8',
31
+ );
32
+
33
+ const BLOCKS = parseThemeBlocks(CSS);
34
+
35
+ function tokens(selector: string): Record<string, string> {
36
+ const block = BLOCKS.get(selector);
37
+ if (!block) throw new Error(`no block for ${selector}`);
38
+ return block;
39
+ }
40
+
41
+ const clamp = (x: number) => Math.min(1, Math.max(0, x));
42
+ const toLinearChannel = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
43
+
44
+ /** A CSS colour value -> linear-light sRGB. The generator emits hex, but the
45
+ * parser keeps oklch()/hsl() support so a hand-written value in a consumer
46
+ * stylesheet can be measured too. */
47
+ function toLinear(value: string): [number, number, number] {
48
+ const v = value.trim();
49
+
50
+ if (v.startsWith('#')) {
51
+ let h = v.slice(1);
52
+ if (h.length === 3)
53
+ h = h
54
+ .split('')
55
+ .map((c) => c + c)
56
+ .join('');
57
+ if (h.length === 8) h = h.slice(0, 6);
58
+ if (!/^[0-9a-f]{6}$/i.test(h)) throw new Error(`bad hex: ${value}`);
59
+ return [0, 2, 4].map((i) => toLinearChannel(parseInt(h.slice(i, i + 2), 16) / 255)) as [
60
+ number,
61
+ number,
62
+ number,
63
+ ];
64
+ }
65
+
66
+ const args = v
67
+ .slice(v.indexOf('(') + 1, v.lastIndexOf(')'))
68
+ .split('/')[0]!
69
+ .trim()
70
+ .split(/[\s,]+/);
71
+
72
+ if (v.startsWith('oklch')) {
73
+ const L = args[0]!.endsWith('%') ? parseFloat(args[0]!) / 100 : parseFloat(args[0]!);
74
+ const C = args[1]!.endsWith('%') ? (parseFloat(args[1]!) / 100) * 0.4 : parseFloat(args[1]!);
75
+ const H = (parseFloat(args[2] ?? '0') || 0) * (Math.PI / 180);
76
+ const [a, b] = [C * Math.cos(H), C * Math.sin(H)];
77
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
78
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
79
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
80
+ // Out-of-gamut components are clamped, which is what a browser renders.
81
+ return [
82
+ clamp(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
83
+ clamp(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
84
+ clamp(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
85
+ ];
86
+ }
87
+
88
+ if (v.startsWith('hsl')) {
89
+ const [h, sPct, lPct] = args.map((x) => parseFloat(x));
90
+ const [S, L] = [sPct! / 100, lPct! / 100];
91
+ const k = (n: number) => (n + h! / 30) % 12;
92
+ const a = S * Math.min(L, 1 - L);
93
+ const f = (n: number) => L - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
94
+ return [f(0), f(8), f(4)].map(toLinearChannel) as [number, number, number];
95
+ }
96
+
97
+ throw new Error(`unsupported colour notation: ${value}`);
98
+ }
99
+
100
+ function luminance(value: string): number {
101
+ const [r, g, b] = toLinear(value);
102
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
103
+ }
104
+
105
+ /** Normalise any supported notation to a 6-digit hex, so colours written in
106
+ * different notations can be compared as colours. */
107
+ function toHex(value: string): string {
108
+ const toSrgb = (c: number) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055);
109
+ return (
110
+ '#' +
111
+ toLinear(value)
112
+ .map((c) =>
113
+ Math.round(clamp(toSrgb(clamp(c))) * 255)
114
+ .toString(16)
115
+ .padStart(2, '0'),
116
+ )
117
+ .join('')
118
+ );
119
+ }
120
+
121
+ function contrast(a: string, b: string): number {
122
+ const [la, lb] = [luminance(a), luminance(b)];
123
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
124
+ }
125
+
126
+ /** Every fill and the -foreground the theme declares goes on it. */
127
+ const ON_FILL_PAIRS: Array<[string, string]> = [
128
+ ['foreground', 'background'],
129
+ ['card-foreground', 'card'],
130
+ ['popover-foreground', 'popover'],
131
+ ['muted-foreground', 'muted'],
132
+ ['primary-foreground', 'primary'],
133
+ ['secondary-foreground', 'secondary'],
134
+ ['accent-foreground', 'accent'],
135
+ ['destructive-foreground', 'destructive'],
136
+ ['success-foreground', 'success'],
137
+ ['warning-foreground', 'warning'],
138
+ ['info-foreground', 'info'],
139
+ ['sidebar-foreground', 'sidebar'],
140
+ ['sidebar-primary-foreground', 'sidebar-primary'],
141
+ ['sidebar-accent-foreground', 'sidebar-accent'],
142
+ ];
143
+
144
+ /** Tokens used as TEXT on an undeclared surface, and the surfaces they can
145
+ * land on. THE POINT OF THE INK TOKENS: `text-primary-ink`, a code-* token in
146
+ * a highlighted block, a success-ink status line — none of these declare a
147
+ * background, so each must clear AA against ALL neutral surfaces, in every
148
+ * theme. That is a promise the fills could not keep (a fill must also stay
149
+ * behind its own light -foreground; in dark mode the two jobs pull opposite
150
+ * ways) — the split is what makes this assertion satisfiable at all. */
151
+ const INK_TOKENS = [
152
+ 'primary-ink',
153
+ 'destructive-ink',
154
+ 'success-ink',
155
+ 'warning-ink',
156
+ 'info-ink',
157
+ 'code-keyword',
158
+ 'code-string',
159
+ 'code-number',
160
+ 'code-title',
161
+ 'code-variable',
162
+ ] as const;
163
+ const INK_SURFACES = ['background', 'card', 'muted', 'popover', 'sidebar'] as const;
164
+
165
+ /** The app's body and secondary text land on every neutral surface too. */
166
+ const WIDE_TEXT = ['foreground', 'muted-foreground'] as const;
167
+
168
+ const UI_PAIRS: Array<[string, string]> = [
169
+ ['ring', 'background'],
170
+ ['ring', 'card'],
171
+ ['sidebar-ring', 'sidebar'],
172
+ ];
173
+
174
+ /** Selectors for a registered theme. `clean-slate` is the BASELINE — it has no
175
+ * `[data-color-theme]` block at all, it *is* `:root` / `.dark`. */
176
+ function selectorsFor(id: string): { light: string; dark: string } {
177
+ return id === 'clean-slate'
178
+ ? { light: ':root', dark: '.dark' }
179
+ : { light: `[data-color-theme="${id}"]`, dark: `.dark[data-color-theme="${id}"]` };
180
+ }
181
+
182
+ /** A theme block only overrides what it changes; the rest inherits from :root. */
183
+ function resolved(selector: string): Record<string, string> {
184
+ return { ...tokens(':root'), ...tokens(selector) };
185
+ }
186
+
187
+ describe('every registered theme', () => {
188
+ for (const theme of COLOR_THEMES) {
189
+ const sels = selectorsFor(theme.id);
190
+ for (const [mode, selector] of [
191
+ ['light', sels.light],
192
+ ['dark', sels.dark],
193
+ ] as const) {
194
+ const t = resolved(selector);
195
+
196
+ it(`${theme.id} ${mode}: every -foreground reaches AA on its own fill`, () => {
197
+ for (const [fg, fill] of ON_FILL_PAIRS) {
198
+ const ratio = contrast(t[fg]!, t[fill]!);
199
+ expect(
200
+ ratio,
201
+ `${theme.id} (${mode}) renders ${fg} ${t[fg]} on ${fill} ${t[fill]} at ` +
202
+ `${ratio.toFixed(2)}:1. The generator must solve this pair — fix the seed or ` +
203
+ `the solver, never the emitted CSS.`,
204
+ ).toBeGreaterThanOrEqual(4.5);
205
+ }
206
+ });
207
+
208
+ it(`${theme.id} ${mode}: every ink is legible on every neutral surface`, () => {
209
+ for (const ink of [...INK_TOKENS, ...WIDE_TEXT]) {
210
+ for (const surface of INK_SURFACES) {
211
+ const ratio = contrast(t[ink]!, t[surface]!);
212
+ expect(
213
+ ratio,
214
+ `${theme.id} (${mode}): ${ink} ${t[ink]} on ${surface} ${t[surface]} is ` +
215
+ `${ratio.toFixed(2)}:1 — inks land on any neutral surface, so all must clear AA.`,
216
+ ).toBeGreaterThanOrEqual(4.5);
217
+ }
218
+ }
219
+ });
220
+
221
+ it(`${theme.id} ${mode}: focus rings reach non-text contrast`, () => {
222
+ for (const [ring, surface] of UI_PAIRS) {
223
+ expect(
224
+ contrast(t[ring]!, t[surface]!),
225
+ `${theme.id} (${mode}): ${ring} on ${surface}`,
226
+ ).toBeGreaterThanOrEqual(3);
227
+ }
228
+ });
229
+ }
230
+
231
+ for (const mode of ['light', 'dark'] as const) {
232
+ it(`${theme.id}: picker swatches match its real ${mode}-mode colours`, () => {
233
+ // swatches are [primary, accent, background] per mode and drive the
234
+ // picker preview. They are generated from the same seeds as the CSS,
235
+ // so a mismatch means one artifact was rebuilt without the other —
236
+ // and a picker that advertises a colour the theme does not paint.
237
+ //
238
+ // Compare RENDERED COLOUR, not notation, so a hand-added registry
239
+ // entry written in oklch would still be judged fairly.
240
+ const t = resolved(sels[mode]);
241
+ expect(theme.swatches[mode].map(toHex), `${theme.id} ${mode} swatches are stale`).toEqual(
242
+ [t.primary!, t.accent!, t.background!].map(toHex),
243
+ );
244
+ });
245
+ }
246
+ }
247
+ });
248
+
249
+ describe('dropped themes stay dropped', () => {
250
+ // retro-arcade declared its own body text on its own muted fill at 2.01:1 —
251
+ // structurally self-contradicting palettes were removed, not repainted. A
252
+ // stored data-color-theme for one of these falls back to the :root baseline
253
+ // because no block matches, which is the intended degradation.
254
+ for (const id of ['retro-arcade', 'northern-lights']) {
255
+ it(`${id} has no CSS block and no registry entry`, () => {
256
+ expect(CSS).not.toContain(`[data-color-theme="${id}"]`);
257
+ expect(COLOR_THEMES.map((t) => t.id)).not.toContain(id);
258
+ });
259
+ }
260
+ });
261
+
262
+ describe('pinnacle theme', () => {
263
+ it('is registered in the picker', () => {
264
+ expect(COLOR_THEMES.map((t) => t.id)).toContain('pinnacle');
265
+ expect(CSS).toContain('[data-color-theme="pinnacle"]');
266
+ expect(CSS).toContain('.dark[data-color-theme="pinnacle"]');
267
+ });
268
+
269
+ it('defines the same token set as the baseline in both modes', () => {
270
+ // Colour tokens only — type, shadow and metric tokens inherit from :root.
271
+ const inherited = ['radius', 'spacing', 'letter-spacing'];
272
+ const baseline = Object.keys(tokens(':root')).filter(
273
+ (k) => !k.startsWith('font-') && !k.startsWith('shadow') && !k.startsWith('tracking'),
274
+ );
275
+ for (const selector of [
276
+ '[data-color-theme="pinnacle"]',
277
+ '.dark[data-color-theme="pinnacle"]',
278
+ ]) {
279
+ const t = tokens(selector);
280
+ const missing = baseline.filter((k) => !(k in t) && !inherited.includes(k));
281
+ expect(missing, `${selector} is missing tokens`).toEqual([]);
282
+ }
283
+ });
284
+
285
+ for (const [mode, selector] of [
286
+ ['light', '[data-color-theme="pinnacle"]'],
287
+ ['dark', '.dark[data-color-theme="pinnacle"]'],
288
+ ] as const) {
289
+ it(`${mode}: keeps the brand hues in the chart ramp`, () => {
290
+ // The one seeded `charts` override: Pinnacle's ramp is brand identity,
291
+ // pinned verbatim by the seed rather than generated.
292
+ const t = tokens(selector);
293
+ const brand = [
294
+ '#ea3635',
295
+ '#ec1c35',
296
+ '#d48b38',
297
+ '#b2d234',
298
+ '#6fbe44',
299
+ '#6fc173',
300
+ '#6ec7aa',
301
+ '#6ec8b7',
302
+ ];
303
+ const ramp = [1, 2, 3, 4, 5].map((n) => t[`chart-${n}`]!);
304
+ expect(ramp.every((c) => brand.includes(c))).toBe(true);
305
+ expect(new Set(ramp).size).toBe(5);
306
+ });
307
+ }
308
+ });