@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,11 @@
1
+ /** Hand-written declarations for generate.mjs. */
2
+ import type { ThemeModeSeed } from './seeds.mjs';
3
+
4
+ export const ROLE_HUES: Record<string, number>;
5
+ export function resolveSeed(mode: ThemeModeSeed): Record<string, string>;
6
+ export function generateMode(
7
+ modeSeed: ThemeModeSeed,
8
+ opts: { mode: 'light' | 'dark' },
9
+ ): Record<string, string>;
10
+ export function generateCss(): string;
11
+ export function generateRegistry(): string;
@@ -0,0 +1,618 @@
1
+ /**
2
+ * Theme generator — seeds.mjs -> styles/themes.css + the generated picker
3
+ * registry. Run via `pnpm themes:build` (or `node themes/generate.mjs`);
4
+ * `--check` regenerates in memory and fails on drift (CI calls this through
5
+ * the drift test); `--report` prints every token that differs from a given
6
+ * baseline css, with ΔE, so a repaint is measured instead of assumed.
7
+ *
8
+ * WHAT IS AUTHORED VS DERIVED
9
+ * verbatim from seeds…. surfaces (background/card/popover/muted/secondary/
10
+ * accent/sidebar/sidebar-accent), fills (primary/
11
+ * destructive), decor (border/input/sidebar-border),
12
+ * non-colour extras, and pinnacle's brand chart ramp.
13
+ * solved (anchored)……. every -foreground (anchor: the authored value, so a
14
+ * passing pair ships unchanged), every -ink (anchor:
15
+ * its fill), ring/sidebar-ring at the 3:1 non-text
16
+ * bar, the semantic roles, the code palette, and the
17
+ * categorical chart ramp.
18
+ *
19
+ * THE CONTRACTS (all measured on the emitted 8-bit hex)
20
+ * 4.5:1 text — each -foreground on its own fill; `foreground` on every
21
+ * neutral surface; `muted-foreground` on muted + the neutrals; every
22
+ * ink (primary/destructive/success/warning/info/code-*) on every
23
+ * neutral surface, because ~330 call sites use inks without declaring
24
+ * a background.
25
+ * 3.0:1 non-text — ring against background+card, sidebar-ring against
26
+ * sidebar, generated chart colours against background+card.
27
+ * (A seeded `charts` override is authored brand artwork and is
28
+ * exempt — pinnacle's ramp is identity, not data ink.)
29
+ *
30
+ * SEMANTIC ROLES. success/warning/info sit beside destructive: one global hue
31
+ * each, chroma borrowed from the theme (max of primary/destructive chroma,
32
+ * clamped) so `mono` gets them as quiet as its own destructive and `cyberpunk`
33
+ * gets them loud, lightness solved per theme. A new role is one line in
34
+ * ROLE_HUES — never another 168 hand-picked hex values.
35
+ *
36
+ * CODE PALETTE. `code-keyword` keeps the THEME's primary hue (the brand accent
37
+ * survives into code blocks); string/number/title/variable are fixed semantic
38
+ * hues (strings read green in every theme), nudged away from the keyword hue
39
+ * when a theme's primary would collide with one of them.
40
+ */
41
+ import { readFileSync, writeFileSync } from 'node:fs';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { join } from 'node:path';
44
+ import { THEME_SEEDS } from './seeds.mjs';
45
+ import {
46
+ cssToHex,
47
+ deltaE,
48
+ hueDistance,
49
+ oklchToSrgb,
50
+ parseOklch,
51
+ solvePair,
52
+ solveText,
53
+ toHex,
54
+ } from './model.mjs';
55
+
56
+ const HERE = fileURLToPath(new URL('.', import.meta.url));
57
+ const CSS_PATH = join(HERE, '..', 'styles', 'themes.css');
58
+ const REGISTRY_PATH = join(HERE, '..', 'src', 'lib', 'theme-registry.gen.ts');
59
+
60
+ // ── structural defaults (must match seeds.mjs docs) ──────────────────────────
61
+ const DEFAULTS = {
62
+ 'card-foreground': 'foreground',
63
+ popover: 'card',
64
+ 'popover-foreground': 'card-foreground',
65
+ 'secondary-foreground': 'foreground',
66
+ 'accent-foreground': 'foreground',
67
+ input: 'border',
68
+ ring: 'primary',
69
+ sidebar: 'muted',
70
+ 'sidebar-foreground': 'foreground',
71
+ 'sidebar-primary': 'primary',
72
+ 'sidebar-primary-foreground': 'primary-foreground',
73
+ 'sidebar-accent': 'accent',
74
+ 'sidebar-accent-foreground': 'accent-foreground',
75
+ 'sidebar-border': 'border',
76
+ 'sidebar-ring': 'ring',
77
+ };
78
+
79
+ /** Surfaces every unscoped ink can land on (mirrors the audit + ink tests). */
80
+ const NEUTRALS = ['background', 'card', 'popover', 'muted', 'sidebar'];
81
+
82
+ /** Semantic roles beside `destructive`. Adding a role = adding a line. */
83
+ export const ROLE_HUES = { success: 150, warning: 80, info: 245 };
84
+
85
+ /** Fixed hues of the code palette (keyword takes the theme's primary hue). */
86
+ const CODE_HUES = { string: 150, number: 75, title: 245, variable: 330 };
87
+
88
+ /** Relative hue steps of the generated categorical chart ramp (from primary). */
89
+ const CHART_HUE_STEPS = [0, 72, 144, 216, 288];
90
+
91
+ const clampC = (c, lo, hi) => Math.min(hi, Math.max(lo, c));
92
+
93
+ /** Resolve a seed mode through the default chain to all 27 authored tokens. */
94
+ export function resolveSeed(mode) {
95
+ const t = { ...mode };
96
+ delete t.charts;
97
+ delete t.extras;
98
+ for (const [token, fallback] of Object.entries(DEFAULTS)) {
99
+ if (!(token in t)) {
100
+ // fallbacks can chain (sidebar-ring -> ring -> primary)
101
+ let v = fallback;
102
+ while (!(v in t)) {
103
+ if (!(v in DEFAULTS)) throw new Error(`unresolvable default for --${token}`);
104
+ v = DEFAULTS[v];
105
+ }
106
+ t[token] = t[v];
107
+ }
108
+ }
109
+ return t;
110
+ }
111
+
112
+ /** Pure-function memo: seeds are immutable module data, and the CSS, the
113
+ * registry and the test suites all re-derive the same modes — solving each
114
+ * (seed, mode) once keeps the whole pipeline O(themes), not O(callers). */
115
+ const MODE_MEMO = new WeakMap();
116
+
117
+ /** Generate the full token map for one theme mode. */
118
+ export function generateMode(modeSeed, { mode }) {
119
+ const hit = MODE_MEMO.get(modeSeed);
120
+ if (hit?.[mode]) return { ...hit[mode] };
121
+ const out = generateModeUncached(modeSeed, { mode });
122
+ MODE_MEMO.set(modeSeed, { ...hit, [mode]: out });
123
+ return { ...out };
124
+ }
125
+
126
+ /** solveText, but refusing to emit an unmeetable contract. Infeasibility means
127
+ * the surfaces span mid-luminance in a way NO single ink can clear (one
128
+ * surface needs light text, another dark) — a seed problem, and the author
129
+ * should hear it as a generator error naming the surfaces, not as a cryptic
130
+ * contrast-test failure three artifacts downstream. */
131
+ function mustSolve(anchorCss, against, opts) {
132
+ const r = solveText(anchorCss, against, opts);
133
+ if (!r.feasible) {
134
+ throw new Error(
135
+ `no feasible ink: anchor ${anchorCss} cannot clear ${opts?.ratio ?? 4.5}:1 against ` +
136
+ `[${against.join(', ')}] — the surfaces demand light AND dark text at once; fix the seed`,
137
+ );
138
+ }
139
+ return r;
140
+ }
141
+
142
+ function generateModeUncached(modeSeed, { mode }) {
143
+ const s = resolveSeed(modeSeed);
144
+ const out = {};
145
+ const dark = mode === 'dark';
146
+
147
+ // 1. Neutral surfaces and decor — the theme's canvas, verbatim. These are
148
+ // what every text token is solved AGAINST; they never move.
149
+ for (const token of [
150
+ 'background',
151
+ 'card',
152
+ 'popover',
153
+ 'muted',
154
+ 'sidebar',
155
+ 'border',
156
+ 'input',
157
+ 'sidebar-border',
158
+ ]) {
159
+ out[token] = s[token];
160
+ }
161
+
162
+ // 2. Branded fills + their own -foreground — solved as a PAIR. A pair the
163
+ // theme authored consistently ships verbatim; a broken one moves
164
+ // whichever side shifts least (fill weighted heavier). See solvePair.
165
+ for (const [fill, fg] of [
166
+ ['primary', 'primary-foreground'],
167
+ ['secondary', 'secondary-foreground'],
168
+ ['accent', 'accent-foreground'],
169
+ ['destructive', 'destructive-foreground'],
170
+ ['sidebar-primary', 'sidebar-primary-foreground'],
171
+ ['sidebar-accent', 'sidebar-accent-foreground'],
172
+ ]) {
173
+ const pair = solvePair(s[fill], s[fg]);
174
+ out[fill] = pair.fill;
175
+ out[fg] = pair.fg;
176
+ }
177
+
178
+ // 3. Text on the neutral canvas — anchored on the authored value.
179
+ out.foreground = mustSolve(
180
+ s.foreground,
181
+ NEUTRALS.map((n) => out[n]),
182
+ ).hex;
183
+ out['card-foreground'] = mustSolve(s['card-foreground'], [out.card]).hex;
184
+ out['popover-foreground'] = mustSolve(s['popover-foreground'], [out.popover]).hex;
185
+ out['sidebar-foreground'] = mustSolve(s['sidebar-foreground'], [out.sidebar]).hex;
186
+ out['muted-foreground'] = mustSolve(
187
+ s['muted-foreground'],
188
+ NEUTRALS.map((n) => out[n]),
189
+ ).hex;
190
+
191
+ // 4. Inks — the fill's colour, at whatever lightness survives every surface.
192
+ const neutralSurfaces = NEUTRALS.map((n) => out[n]);
193
+ out['primary-ink'] = mustSolve(out.primary, neutralSurfaces).hex;
194
+ out['destructive-ink'] = mustSolve(out.destructive, neutralSurfaces).hex;
195
+
196
+ // 5. Semantic roles — global hue, the theme's own chroma and weight, and
197
+ // the SAME on-fill text convention as their sibling `destructive` (a
198
+ // theme whose destructive wears white gets white on success too — the
199
+ // pair solve deepens the fill to hold it, exactly as it would have for a
200
+ // hand-authored pair).
201
+ const [pL, pC] = parseOklch(out.primary);
202
+ const [dL, dC] = parseOklch(out.destructive);
203
+ const roleC = clampC(Math.max(pC, dC), 0.05, 0.16);
204
+ const roleL = dC >= 0.05 ? dL : pL; // sibling weight: match destructive when it is chromatic
205
+ const dHue = parseOklch(out.destructive)[2];
206
+ for (const [role, baseHue] of Object.entries(ROLE_HUES)) {
207
+ // A destructive that drifts toward a role's hue (doom-64's is orange)
208
+ // would make e.g. warning and destructive the same colour — push the role
209
+ // hue away until the two stay tellable-apart, biased toward the original.
210
+ let hue = baseHue;
211
+ if (dC >= 0.05 && hueDistance(hue, dHue) < 40) {
212
+ const up = (dHue + 45 + 360) % 360;
213
+ const down = (dHue - 45 + 360) % 360;
214
+ hue = hueDistance(up, baseHue) <= hueDistance(down, baseHue) ? up : down;
215
+ }
216
+ // Solve the pair, then make sure the fill is tellable-apart from
217
+ // destructive and the roles before it. Hue separation alone is not enough:
218
+ // at very low lightness sRGB has no ambers, so a dark theme's warning can
219
+ // gamut-collapse onto the exact browny-red of its destructive. When that
220
+ // happens, walk the anchor lightness away (lighter in dark mode, darker in
221
+ // light) and re-solve — each step only gains distance.
222
+ const siblings = ['destructive', ...Object.keys(ROLE_HUES).filter((r) => r in out)];
223
+ let L = roleL;
224
+ let pair;
225
+ for (let i = 0; i < 12; i++) {
226
+ pair = solvePair(toHex(oklchToSrgb([L, roleC, hue])), out['destructive-foreground']);
227
+ if (siblings.every((sib) => deltaE(pair.fill, out[sib]) >= 0.06)) break;
228
+ L = Math.min(0.95, Math.max(0.05, L + (dark ? 0.04 : -0.04)));
229
+ }
230
+ out[role] = pair.fill;
231
+ out[`${role}-foreground`] = pair.fg;
232
+ out[`${role}-ink`] = mustSolve(pair.fill, neutralSurfaces).hex;
233
+ }
234
+
235
+ // 6. Code palette — inks by contract (code sits on --muted, but like every
236
+ // ink these land wherever `.code-view` happens to be mounted). Keywords
237
+ // keep the brand: they anchor on primary-ink, which is already ink-safe,
238
+ // so in most themes `code-keyword` IS the primary ink at full chroma.
239
+ const keywordHue = pC >= 0.02 ? parseOklch(out.primary)[2] : 300;
240
+ const codeC = clampC(Math.max(pC, dC), 0.05, 0.14);
241
+ const codeAnchorL = dark ? 0.75 : 0.5;
242
+ out['code-keyword'] = mustSolve(out['primary-ink'], neutralSurfaces).hex;
243
+ for (const [role, hue] of Object.entries(CODE_HUES)) {
244
+ // a primary too close to a semantic code hue would make two token kinds
245
+ // identical — push the fixed hue away, keyword keeps the brand.
246
+ const h = hueDistance(hue, keywordHue) < 25 ? (hue + 40) % 360 : hue;
247
+ out[`code-${role}`] = mustSolve(
248
+ toHex(oklchToSrgb([codeAnchorL, codeC, h])),
249
+ neutralSurfaces,
250
+ ).hex;
251
+ }
252
+
253
+ // 7. Focus rings — non-text, 3:1 against what they ring. A ring the seed
254
+ // left defaulted follows the SOLVED fill, not the authored one.
255
+ out.ring = mustSolve(modeSeed.ring ?? out.primary, [out.background, out.card], {
256
+ ratio: 3,
257
+ }).hex;
258
+ out['sidebar-ring'] = mustSolve(modeSeed['sidebar-ring'] ?? out.ring, [out.sidebar], {
259
+ ratio: 3,
260
+ }).hex;
261
+
262
+ // 8. Charts — categorical data ink. Seeded override = authored brand ramp,
263
+ // exempt; otherwise five distinguishable hues anchored on the brand.
264
+ if (modeSeed.charts) {
265
+ modeSeed.charts.forEach((c, i) => (out[`chart-${i + 1}`] = c));
266
+ } else if (pC < 0.02) {
267
+ // achromatic theme: a lightness ramp keeps its character. Solve the step
268
+ // NEAREST the surfaces once (the 3:1 floor), then ladder strictly away
269
+ // from them — every further step only gains contrast, and the fixed ΔL
270
+ // keeps the five steps tellable-apart (solving each independently used to
271
+ // collapse chart-4 and chart-5 onto the same grey).
272
+ const floor = mustSolve(
273
+ toHex(oklchToSrgb([dark ? 0.5 : 0.6, 0, 0])),
274
+ [out.background, out.card],
275
+ {
276
+ ratio: 3,
277
+ },
278
+ );
279
+ const floorL = parseOklch(floor.hex)[0];
280
+ for (let i = 0; i < 5; i++) {
281
+ const L = Math.min(0.97, Math.max(0.05, floorL + (dark ? 1 : -1) * i * 0.1));
282
+ out[`chart-${i + 1}`] = toHex(oklchToSrgb([L, 0, 0]));
283
+ }
284
+ } else {
285
+ const chartC = clampC(Math.max(pC, dC), 0.06, 0.15);
286
+ const baseL = dark ? 0.7 : 0.55;
287
+ CHART_HUE_STEPS.forEach((step, i) => {
288
+ const hue = (keywordHue + step) % 360;
289
+ // chart-1 keeps the brand affinity most authored ramps had: it anchors
290
+ // at the primary's own lightness, so it reads as "the theme's colour".
291
+ out[`chart-${i + 1}`] = mustSolve(
292
+ toHex(oklchToSrgb([i === 0 ? pL : baseL, chartC, hue])),
293
+ [out.background, out.card],
294
+ { ratio: 3 },
295
+ ).hex;
296
+ });
297
+ }
298
+
299
+ return out;
300
+ }
301
+
302
+ // ── emission ─────────────────────────────────────────────────────────────────
303
+
304
+ /** Canonical token order for a theme block (stable, review-friendly diffs). */
305
+ const EMIT_ORDER = [
306
+ 'background',
307
+ 'foreground',
308
+ 'card',
309
+ 'card-foreground',
310
+ 'popover',
311
+ 'popover-foreground',
312
+ 'primary',
313
+ 'primary-foreground',
314
+ 'primary-ink',
315
+ 'secondary',
316
+ 'secondary-foreground',
317
+ 'muted',
318
+ 'muted-foreground',
319
+ 'accent',
320
+ 'accent-foreground',
321
+ 'destructive',
322
+ 'destructive-foreground',
323
+ 'destructive-ink',
324
+ 'success',
325
+ 'success-foreground',
326
+ 'success-ink',
327
+ 'warning',
328
+ 'warning-foreground',
329
+ 'warning-ink',
330
+ 'info',
331
+ 'info-foreground',
332
+ 'info-ink',
333
+ 'code-keyword',
334
+ 'code-string',
335
+ 'code-number',
336
+ 'code-title',
337
+ 'code-variable',
338
+ 'border',
339
+ 'input',
340
+ 'ring',
341
+ 'chart-1',
342
+ 'chart-2',
343
+ 'chart-3',
344
+ 'chart-4',
345
+ 'chart-5',
346
+ 'sidebar',
347
+ 'sidebar-foreground',
348
+ 'sidebar-primary',
349
+ 'sidebar-primary-foreground',
350
+ 'sidebar-accent',
351
+ 'sidebar-accent-foreground',
352
+ 'sidebar-border',
353
+ 'sidebar-ring',
354
+ ];
355
+
356
+ function emitBlock(selector, tokens, extras) {
357
+ const lines = [`${selector} {`];
358
+ for (const t of EMIT_ORDER) lines.push(` --${t}: ${tokens[t]};`);
359
+ for (const [k, v] of Object.entries(extras ?? {})) lines.push(` --${k}: ${v};`);
360
+ lines.push('}');
361
+ return lines.join('\n');
362
+ }
363
+
364
+ const THEME_INLINE = `@theme inline {
365
+ --color-background: var(--background);
366
+ --color-foreground: var(--foreground);
367
+ --color-card: var(--card);
368
+ --color-card-foreground: var(--card-foreground);
369
+ --color-popover: var(--popover);
370
+ --color-popover-foreground: var(--popover-foreground);
371
+ --color-primary: var(--primary);
372
+ --color-primary-foreground: var(--primary-foreground);
373
+ /* INK, not fill. A fill is tuned to sit BEHIND its own -foreground; as text
374
+ on a neutral surface it is a different job, and -ink is the derived token
375
+ that does it. Bare text-primary / text-destructive / text-success /
376
+ text-warning / text-info are lint errors (mantle/use-ink-for-text). */
377
+ --color-primary-ink: var(--primary-ink);
378
+ --color-secondary: var(--secondary);
379
+ --color-secondary-foreground: var(--secondary-foreground);
380
+ --color-muted: var(--muted);
381
+ --color-muted-foreground: var(--muted-foreground);
382
+ --color-accent: var(--accent);
383
+ --color-accent-foreground: var(--accent-foreground);
384
+ --color-destructive: var(--destructive);
385
+ --color-destructive-foreground: var(--destructive-foreground);
386
+ --color-destructive-ink: var(--destructive-ink);
387
+ --color-success: var(--success);
388
+ --color-success-foreground: var(--success-foreground);
389
+ --color-success-ink: var(--success-ink);
390
+ --color-warning: var(--warning);
391
+ --color-warning-foreground: var(--warning-foreground);
392
+ --color-warning-ink: var(--warning-ink);
393
+ --color-info: var(--info);
394
+ --color-info-foreground: var(--info-foreground);
395
+ --color-info-ink: var(--info-ink);
396
+ /* The code palette is ink-only (no fills): hljs rules use the vars directly,
397
+ and these mappings give JSX the same colours (json-tree, code chips). */
398
+ --color-code-keyword: var(--code-keyword);
399
+ --color-code-string: var(--code-string);
400
+ --color-code-number: var(--code-number);
401
+ --color-code-title: var(--code-title);
402
+ --color-code-variable: var(--code-variable);
403
+ --color-border: var(--border);
404
+ --color-input: var(--input);
405
+ --color-ring: var(--ring);
406
+ --color-chart-1: var(--chart-1);
407
+ --color-chart-2: var(--chart-2);
408
+ --color-chart-3: var(--chart-3);
409
+ --color-chart-4: var(--chart-4);
410
+ --color-chart-5: var(--chart-5);
411
+ --color-sidebar: var(--sidebar);
412
+ --color-sidebar-foreground: var(--sidebar-foreground);
413
+ --color-sidebar-primary: var(--sidebar-primary);
414
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
415
+ --color-sidebar-accent: var(--sidebar-accent);
416
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
417
+ --color-sidebar-border: var(--sidebar-border);
418
+ --color-sidebar-ring: var(--sidebar-ring);
419
+
420
+ --font-sans: var(--font-sans);
421
+ --font-mono: var(--font-mono);
422
+ --font-serif: var(--font-serif);
423
+
424
+ --radius-sm: calc(var(--radius) - 4px);
425
+ --radius-md: calc(var(--radius) - 2px);
426
+ --radius-lg: var(--radius);
427
+ --radius-xl: calc(var(--radius) + 4px);
428
+
429
+ --shadow-2xs: var(--shadow-2xs);
430
+ --shadow-xs: var(--shadow-xs);
431
+ --shadow-sm: var(--shadow-sm);
432
+ --shadow: var(--shadow);
433
+ --shadow-md: var(--shadow-md);
434
+ --shadow-lg: var(--shadow-lg);
435
+ --shadow-xl: var(--shadow-xl);
436
+ --shadow-2xl: var(--shadow-2xl);
437
+
438
+ --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
439
+ --tracking-tight: calc(var(--tracking-normal) - 0.025em);
440
+ --tracking-normal: var(--tracking-normal);
441
+ --tracking-wide: calc(var(--tracking-normal) + 0.025em);
442
+ --tracking-wider: calc(var(--tracking-normal) + 0.05em);
443
+ --tracking-widest: calc(var(--tracking-normal) + 0.1em);
444
+ }`;
445
+
446
+ export function generateCss() {
447
+ const parts = [
448
+ `/* GENERATED FILE — do not edit. \`pnpm themes:build\` regenerates it from
449
+ * themes/seeds.mjs (the authored source); the drift test fails CI if the two
450
+ * disagree. Every -foreground and -ink below is SOLVED against the surfaces it
451
+ * must be legible on (see themes/generate.mjs for the contracts), so a token
452
+ * here is correct by construction — fix a colour in seeds.mjs, never here.
453
+ *
454
+ * Imported via \`@import '@mantle/web-ui/styles/themes.css';\` by both Next
455
+ * apps, inside the Tailwind bundle (tokens must precede @layer base). */`,
456
+ ];
457
+ const [base, ...rest] = THEME_SEEDS;
458
+ if (base.id !== 'clean-slate') throw new Error('clean-slate must stay the baseline seed');
459
+ // MODE ISLANDS: `.light` rides every light selector, and each theme's dark
460
+ // block also matches a `.dark` WRAPPER under the themed root. A wrapper
461
+ // carrying either class re-declares that mode's tokens on itself, which
462
+ // beats whatever it would inherit — so a subtree can preview the opposite
463
+ // mode truthfully in the CURRENT theme. Costs only selector text (each
464
+ // block is emitted once), and exists for the logo strips in Settings →
465
+ // Appearance, which must show both modes at once. Without the
466
+ // `[data-color-theme] .dark` arm, a `.dark` wrapper's own declarations came
467
+ // from the BASELINE `.dark` block and beat the theme's inherited values —
468
+ // the forced-dark preview showed clean-slate's palette on every other
469
+ // theme. Note the islands change TOKENS only: `dark:` Tailwind variants
470
+ // inside still follow the real ancestor class.
471
+ parts.push(
472
+ emitBlock(':root, .light', generateMode(base.light, { mode: 'light' }), base.light.extras),
473
+ );
474
+ parts.push(emitBlock('.dark', generateMode(base.dark, { mode: 'dark' }), base.dark.extras));
475
+ for (const t of rest) {
476
+ parts.push(
477
+ emitBlock(
478
+ `[data-color-theme="${t.id}"], [data-color-theme="${t.id}"] .light`,
479
+ generateMode(t.light, { mode: 'light' }),
480
+ t.light.extras,
481
+ ),
482
+ );
483
+ parts.push(
484
+ emitBlock(
485
+ `.dark[data-color-theme="${t.id}"], [data-color-theme="${t.id}"] .dark`,
486
+ generateMode(t.dark, { mode: 'dark' }),
487
+ t.dark.extras,
488
+ ),
489
+ );
490
+ }
491
+ parts.push(THEME_INLINE);
492
+ return parts.join('\n\n') + '\n';
493
+ }
494
+
495
+ export function generateRegistry() {
496
+ const swatch = (seed, mode) => {
497
+ const m = generateMode(seed, { mode });
498
+ return [m.primary, m.accent, m.background].map((s) => `'${s}'`).join(', ');
499
+ };
500
+ const rows = THEME_SEEDS.map((t) => {
501
+ // Expanded object literals on purpose: prettier keeps an object multi-line
502
+ // when the source breaks after `{`, so the generated file is stable under
503
+ // `format:check` no matter how long a label or hex list gets.
504
+ return ` {
505
+ id: '${t.id}',
506
+ label: '${t.label}',
507
+ swatches: {
508
+ light: [${swatch(t.light, 'light')}],
509
+ dark: [${swatch(t.dark, 'dark')}],
510
+ },
511
+ },`;
512
+ });
513
+ return `/* GENERATED FILE — do not edit. \`pnpm themes:build\` regenerates it from
514
+ * themes/seeds.mjs. Swatches are [primary, accent, background] of the generated
515
+ * light AND dark modes, so the picker previews the theme you would actually get
516
+ * in the mode you are in, and can never desync from the CSS. */
517
+ import type { ColorTheme } from './themes';
518
+
519
+ export const GENERATED_COLOR_THEMES: ColorTheme[] = [
520
+ ${rows.join('\n')}
521
+ ];
522
+ `;
523
+ }
524
+
525
+ // ── CLI ──────────────────────────────────────────────────────────────────────
526
+ // Guarded to direct execution: the drift test IMPORTS generateCss/
527
+ // generateRegistry, and an import must never write or exit.
528
+ const invokedDirectly =
529
+ process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
530
+
531
+ if (!invokedDirectly) {
532
+ // imported as a library — expose the pure functions only
533
+ } else main(process.argv[2]);
534
+
535
+ function main(arg) {
536
+ if (arg === '--check') {
537
+ const cssOk = readFileSync(CSS_PATH, 'utf8') === generateCss();
538
+ const regOk = readFileSync(REGISTRY_PATH, 'utf8') === generateRegistry();
539
+ if (!cssOk || !regOk) {
540
+ console.error(
541
+ `drift: ${[!cssOk && 'styles/themes.css', !regOk && 'src/lib/theme-registry.gen.ts']
542
+ .filter(Boolean)
543
+ .join(', ')} do not match themes/seeds.mjs — run \`pnpm themes:build\``,
544
+ );
545
+ process.exit(1);
546
+ }
547
+ console.log('themes.css + registry match seeds');
548
+ } else if (arg === '--report') {
549
+ // Fidelity report: what would visibly change vs the css at `baseline` path.
550
+ const baselinePath = process.argv[3] ?? CSS_PATH;
551
+ const baseline = readFileSync(baselinePath, 'utf8');
552
+ // Light blocks carry the `.light` island selector since it was added;
553
+ // candidates are tried in order so a pre-island baseline still reports.
554
+ // (The bare `:root {` / `[data-color-theme="x"] {` forms MUST come second:
555
+ // as substrings they also occur inside the dark selectors.)
556
+ const blockOf = (selectors) => {
557
+ for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
558
+ const at = baseline.indexOf(`${selector} {`);
559
+ if (at < 0) continue;
560
+ const body = baseline.slice(at, baseline.indexOf('\n}', at));
561
+ const t = {};
562
+ for (const m of body.matchAll(/--([\w-]+):\s*([^;]+);/g)) t[m[1]] = m[2].trim();
563
+ return t;
564
+ }
565
+ return null;
566
+ };
567
+ const rootBase = blockOf([':root, .light', ':root']);
568
+ const buckets = {};
569
+ let moved = 0;
570
+ let total = 0;
571
+ for (const t of THEME_SEEDS) {
572
+ for (const mode of ['light', 'dark']) {
573
+ const sel =
574
+ t.id === 'clean-slate'
575
+ ? mode === 'light'
576
+ ? [':root, .light', ':root']
577
+ : ['.dark']
578
+ : mode === 'light'
579
+ ? [
580
+ `[data-color-theme="${t.id}"], [data-color-theme="${t.id}"] .light`,
581
+ `[data-color-theme="${t.id}"]`,
582
+ ]
583
+ : [
584
+ `.dark[data-color-theme="${t.id}"], [data-color-theme="${t.id}"] .dark`,
585
+ `.dark[data-color-theme="${t.id}"]`,
586
+ ];
587
+ const authored = { ...rootBase, ...(blockOf(sel) ?? {}) };
588
+ const gen = generateMode(t[mode], { mode });
589
+ for (const [token, hex] of Object.entries(gen)) {
590
+ if (!authored[token]) continue; // new token, nothing to compare
591
+ total++;
592
+ const d = deltaE(hex, authored[token]);
593
+ if (d < 0.005) continue;
594
+ moved++;
595
+ (buckets[token] ??= []).push({
596
+ id: `${t.id}/${mode}`,
597
+ d,
598
+ from: cssToHex(authored[token]),
599
+ to: hex,
600
+ });
601
+ }
602
+ }
603
+ }
604
+ console.log(`moved ${moved} of ${total} pre-existing token values (ΔE ≥ 0.005)\n`);
605
+ for (const [token, list] of Object.entries(buckets).sort((a, b) => b[1].length - a[1].length)) {
606
+ list.sort((a, b) => b.d - a.d);
607
+ const worst = list
608
+ .slice(0, 3)
609
+ .map((x) => `${x.id} ${x.from}→${x.to} ΔE ${x.d.toFixed(3)}`)
610
+ .join('; ');
611
+ console.log(` --${token}: ${list.length} moved worst: ${worst}`);
612
+ }
613
+ } else {
614
+ writeFileSync(CSS_PATH, generateCss());
615
+ writeFileSync(REGISTRY_PATH, generateRegistry());
616
+ console.log(`wrote ${CSS_PATH}\nwrote ${REGISTRY_PATH}`);
617
+ }
618
+ }
@@ -0,0 +1,24 @@
1
+ /** Hand-written declarations for model.mjs (plain JS so `node` runs it at
2
+ * build time with no transpile step; typecheck still sees real shapes). */
3
+ export function parseColor(value: string): [number, number, number];
4
+ export function oklchToSrgb(lch: [number, number, number]): [number, number, number];
5
+ export function srgbToOklab(rgb: [number, number, number]): [number, number, number];
6
+ export function srgbToOklch(rgb: [number, number, number]): [number, number, number];
7
+ export function parseOklch(css: string): [number, number, number];
8
+ export function toHex(rgb: [number, number, number]): string;
9
+ export function cssToHex(css: string): string;
10
+ export function luminance(rgb: [number, number, number]): number;
11
+ export function contrast(cssA: string, cssB: string): number;
12
+ export function deltaE(cssA: string, cssB: string): number;
13
+ export function hueDistance(a: number, b: number): number;
14
+ export const clamp01: (x: number) => number;
15
+ export function solveText(
16
+ anchorCss: string,
17
+ against: readonly string[],
18
+ opts?: { ratio?: number },
19
+ ): { hex: string; dL: number; dC: number; feasible: boolean };
20
+ export function solvePair(
21
+ fillCss: string,
22
+ fgAnchorCss: string,
23
+ opts?: { ratio?: number; fillWeight?: number },
24
+ ): { fill: string; fg: string; cost: number };