@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,314 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { join } from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { COLOR_THEMES } from './themes';
6
+ import { parseThemeBlocks } from './theme-css-blocks';
7
+
8
+ /**
9
+ * INK AUDIT — discovers what is used as text, instead of being told.
10
+ *
11
+ * `themes.test.ts` asserts a hand-written list of pairs. That is exactly why
12
+ * three separate instances of the same bug survived a session spent on
13
+ * contrast: a contract only covers the tokens it enumerates. The accent pair
14
+ * was listed, so it was caught. `chart-3` was not, so `hljs-title` rendered
15
+ * function names at 1.02:1 — the same colour as the code background — on the
16
+ * `claude` theme, and a user found it.
17
+ *
18
+ * So this test does not enumerate. It SCANS every `color:` declaration in the
19
+ * shipped CSS, resolves which token each one paints with, and measures that
20
+ * token against the surfaces it can land on, in every theme and both modes.
21
+ * A new `color: var(--whatever)` is audited the moment it is written, by
22
+ * nobody's decision.
23
+ *
24
+ * The rule it enforces: a token used as text must be either
25
+ * (a) INK-SAFE — it clears AA on every neutral surface in every theme, or
26
+ * (b) SCOPED — declared below as only ever landing on specific surfaces,
27
+ * and it clears AA on those.
28
+ * Anything else fails, and the fix is a `-ink` token (see `--primary-ink`).
29
+ */
30
+
31
+ const HERE = fileURLToPath(new URL('.', import.meta.url));
32
+ const REPO = join(HERE, '..', '..', '..', '..');
33
+ const THEME_CSS = readFileSync(join(HERE, '..', '..', 'styles', 'themes.css'), 'utf8');
34
+
35
+ /** Every stylesheet that ships to a browser. Discovered, not listed, so a new
36
+ * globals.css cannot quietly opt out of the audit. */
37
+ function shippedStylesheets(): string[] {
38
+ const out: string[] = [];
39
+ const walk = (dir: string, depth = 0) => {
40
+ if (depth > 4) return;
41
+ let entries: string[];
42
+ try {
43
+ entries = readdirSync(dir);
44
+ } catch {
45
+ return;
46
+ }
47
+ for (const e of entries) {
48
+ if (e === 'node_modules' || e === '.next' || e === 'dist' || e.startsWith('.')) continue;
49
+ const p = join(dir, e);
50
+ if (statSync(p).isDirectory()) walk(p, depth + 1);
51
+ else if (e.endsWith('.css') && e !== 'themes.css') out.push(p);
52
+ }
53
+ };
54
+ // Post-split roots: the shared stylesheets (app.css + friends) live in
55
+ // share-ui and the server app; jackdaw's own globals.css is audited by its
56
+ // repo (it imports these same files from the published package).
57
+ for (const root of ['server/web/app', 'packages/share-ui/styles']) {
58
+ walk(join(REPO, root));
59
+ }
60
+ return out;
61
+ }
62
+
63
+ // ── colour maths (shared shape with themes.test.ts) ──────────────────────────
64
+ const clamp = (x: number) => Math.min(1, Math.max(0, x));
65
+ const toLin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
66
+
67
+ function linear(value: string): [number, number, number] {
68
+ const v = value.trim();
69
+ if (v.startsWith('#')) {
70
+ let h = v.slice(1);
71
+ if (h.length === 3)
72
+ h = h
73
+ .split('')
74
+ .map((c) => c + c)
75
+ .join('');
76
+ if (h.length === 8) h = h.slice(0, 6);
77
+ return [0, 2, 4].map((i) => toLin(parseInt(h.slice(i, i + 2), 16) / 255)) as [
78
+ number,
79
+ number,
80
+ number,
81
+ ];
82
+ }
83
+ const args = v
84
+ .slice(v.indexOf('(') + 1, v.lastIndexOf(')'))
85
+ .split('/')[0]!
86
+ .trim()
87
+ .split(/[\s,]+/);
88
+ if (v.startsWith('oklch')) {
89
+ const L = args[0]!.endsWith('%') ? parseFloat(args[0]!) / 100 : parseFloat(args[0]!);
90
+ const C = args[1]!.endsWith('%') ? (parseFloat(args[1]!) / 100) * 0.4 : parseFloat(args[1]!);
91
+ const H = ((parseFloat(args[2] ?? '0') || 0) * Math.PI) / 180;
92
+ const [a, b] = [C * Math.cos(H), C * Math.sin(H)];
93
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
94
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
95
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
96
+ return [
97
+ clamp(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
98
+ clamp(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
99
+ clamp(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
100
+ ];
101
+ }
102
+ if (v.startsWith('hsl')) {
103
+ const [h, sPct, lPct] = args.map((x) => parseFloat(x));
104
+ const [S, L] = [sPct! / 100, lPct! / 100];
105
+ const k = (n: number) => (n + h! / 30) % 12;
106
+ const a = S * Math.min(L, 1 - L);
107
+ const f = (n: number) => L - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
108
+ return [f(0), f(8), f(4)].map(toLin) as [number, number, number];
109
+ }
110
+ throw new Error(`unsupported colour notation: ${value}`);
111
+ }
112
+
113
+ const lum = ([r, g, b]: [number, number, number]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
114
+ const contrast = (a: string, b: string) => {
115
+ const [x, y] = [lum(linear(a)), lum(linear(b))];
116
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
117
+ };
118
+
119
+ const BLOCKS = parseThemeBlocks(THEME_CSS);
120
+
121
+ function tokensOf(selector: string): Record<string, string> {
122
+ const block = BLOCKS.get(selector);
123
+ if (!block) throw new Error(`no block for ${selector}`);
124
+ return block;
125
+ }
126
+ const resolved = (sel: string) => ({ ...tokensOf(':root'), ...tokensOf(sel) });
127
+
128
+ /** Surfaces a token can land on when nothing narrows it. */
129
+ const NEUTRAL_SURFACES = ['background', 'card', 'muted', 'popover', 'sidebar'] as const;
130
+
131
+ /**
132
+ * Tokens that are legitimately scoped: they only ever paint text on the
133
+ * surface(s) named here, so they are measured against those and not the rest.
134
+ * Each entry is a CLAIM about where the token is used — narrower than the
135
+ * conservative default, and therefore reviewable. Adding one is a decision,
136
+ * not a workaround.
137
+ */
138
+ const SCOPED: Record<string, readonly string[]> = {
139
+ 'card-foreground': ['card'],
140
+ 'popover-foreground': ['popover'],
141
+ 'sidebar-foreground': ['sidebar'],
142
+ 'accent-foreground': ['accent'],
143
+ 'sidebar-accent-foreground': ['sidebar-accent'],
144
+ 'primary-foreground': ['primary'],
145
+ 'secondary-foreground': ['secondary'],
146
+ 'destructive-foreground': ['destructive'],
147
+ 'success-foreground': ['success'],
148
+ 'warning-foreground': ['warning'],
149
+ 'info-foreground': ['info'],
150
+ // (muted-foreground needs no entry: the app's standard secondary ink lands
151
+ // anywhere, so the conservative default — every neutral surface — IS its
152
+ // contract, and the generator solves it against exactly that set.)
153
+ };
154
+
155
+ /**
156
+ * KNOWN-UNSAFE BASELINE — debt that existed when this audit was written, with
157
+ * the reason and where it is tracked. The list may only ever SHRINK.
158
+ *
159
+ * Two rules keep it honest, and the second is the one that matters:
160
+ * 1. A token NOT listed here must be clean → a newly-introduced bad ink fails
161
+ * immediately, which is the entire point of the audit.
162
+ * 2. A token listed here must STILL be failing → the moment it is fixed, the
163
+ * test fails telling you to delete the entry. A baseline that can hold
164
+ * stale exemptions is just a mute button.
165
+ *
166
+ * EMPTY since the theme generator landed: every text token is now solved
167
+ * against its surfaces at build time, the chart-as-text consumers moved to the
168
+ * derived code + success/warning/info roles (task 002794f9), and the two
169
+ * self-contradicting palettes were dropped. The mechanism stays: the next bad
170
+ * ink fails CI, and parking it here is a visible, tracked decision.
171
+ */
172
+ const KNOWN_UNSAFE: Record<string, string> = {};
173
+
174
+ /** Non-colour `color:` values that carry no token to audit. */
175
+ const IGNORED_VALUES = /^(inherit|currentcolor|transparent|unset|initial|revert)$/i;
176
+
177
+ /**
178
+ * COMPUTED-VALUE ALLOWLIST. The audit can only measure a bare `var(--token)`;
179
+ * a `color-mix()`, a relative `oklch(from …)` or a literal hex is opaque to
180
+ * it. Those used to be SKIPPED SILENTLY — which is exactly how
181
+ * `.prose-accent h3`'s color-mix() shipped below AA on 52 of 164 surfaces and
182
+ * survived a session spent on contrast. Now an unresolvable ink FAILS unless
183
+ * its selector is listed here with a reason, and a listed selector that stops
184
+ * existing fails too (an allowlist that can hold stale entries is a mute
185
+ * button, same rule as KNOWN_UNSAFE).
186
+ */
187
+ const COMPUTED_ALLOWED: Record<string, string> = {
188
+ '.ProseMirror .diff-removed-body':
189
+ 'struck ghost text of a REMOVED diff block — 65% foreground is deliberate de-emphasis, ' +
190
+ 'the content is decoration around its Restore pill, not information',
191
+ };
192
+
193
+ type Use = { file: string; selector: string; token: string };
194
+ type Opaque = { file: string; selector: string; value: string };
195
+
196
+ /** Every `color:` declaration painting with a theme token, plus every one the
197
+ * audit CANNOT resolve (computed/literal values — see COMPUTED_ALLOWED).
198
+ * Deliberately not `background-color` / `border-color` — those are surfaces,
199
+ * not ink. */
200
+ function inkUses(): { uses: Use[]; opaque: Opaque[] } {
201
+ const uses: Use[] = [];
202
+ const opaque: Opaque[] = [];
203
+ for (const path of shippedStylesheets()) {
204
+ const css = readFileSync(path, 'utf8');
205
+ for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
206
+ const selector = rule[1]!.trim().split('\n').pop()!.trim();
207
+ for (const d of rule[2]!.matchAll(/(?:^|[;{]|\s)color:\s*([^;]+)/g)) {
208
+ const value = d[1]!.trim();
209
+ if (IGNORED_VALUES.test(value)) continue;
210
+ const varMatch = /^var\(--([a-z0-9-]+)\)$/.exec(value);
211
+ const file = path.replace(REPO + '/', '');
212
+ if (varMatch) uses.push({ file, selector, token: varMatch[1]! });
213
+ else opaque.push({ file, selector, value });
214
+ }
215
+ }
216
+ }
217
+ return { uses, opaque };
218
+ }
219
+
220
+ describe('ink audit — every token used as text', () => {
221
+ const { uses, opaque } = inkUses();
222
+
223
+ it('finds the `color:` declarations at all (guards against a dead scan)', () => {
224
+ // A scanner that silently matches nothing passes forever. This is the
225
+ // canary: the app demonstrably paints text with theme tokens.
226
+ expect(
227
+ uses.length,
228
+ 'no `color: var(--token)` declarations found — the scan is broken',
229
+ ).toBeGreaterThan(10);
230
+ });
231
+
232
+ it('every computed/literal ink is explicitly allowlisted', () => {
233
+ const unlisted = opaque.filter((o) => !(o.selector in COMPUTED_ALLOWED));
234
+ expect(
235
+ unlisted.map((o) => `${o.file} → ${o.selector}: color: ${o.value}`),
236
+ `these \`color:\` declarations use values the audit cannot measure. Either paint with a ` +
237
+ `bare var(--token) (deriving a new token in themes/ if needed), or add the selector to ` +
238
+ `COMPUTED_ALLOWED with a reason — silence is how .prose-accent h3 shipped below AA.`,
239
+ ).toEqual([]);
240
+ });
241
+
242
+ it('the computed allowlist names only selectors that still exist', () => {
243
+ const live = new Set(opaque.map((o) => o.selector));
244
+ const stale = Object.keys(COMPUTED_ALLOWED).filter((sel) => !live.has(sel));
245
+ expect(
246
+ stale,
247
+ `COMPUTED_ALLOWED lists ${stale.join(', ')}, which no stylesheet declares any more — ` +
248
+ `delete the entr${stale.length === 1 ? 'y' : 'ies'}.`,
249
+ ).toEqual([]);
250
+ });
251
+
252
+ const byToken = [...new Set(uses.map((u) => u.token))].sort();
253
+
254
+ it.each(byToken)('--%s is legible everywhere it can land', (token) => {
255
+ const surfaces = SCOPED[token] ?? NEUTRAL_SURFACES;
256
+ const where = uses
257
+ .filter((u) => u.token === token)
258
+ .map((u) => `${u.file} → ${u.selector}`)
259
+ .slice(0, 3);
260
+
261
+ const failures: string[] = [];
262
+ for (const theme of COLOR_THEMES) {
263
+ for (const [mode, selector] of [
264
+ ['light', theme.id === 'clean-slate' ? ':root' : `[data-color-theme="${theme.id}"]`],
265
+ ['dark', theme.id === 'clean-slate' ? '.dark' : `.dark[data-color-theme="${theme.id}"]`],
266
+ ] as const) {
267
+ const t = resolved(selector);
268
+ if (!t[token]) continue;
269
+ for (const surface of surfaces) {
270
+ if (!t[surface]) continue;
271
+ const ratio = contrast(t[token]!, t[surface]!);
272
+ if (ratio < 4.5) {
273
+ failures.push(`${theme.id} ${mode}: on ${surface} = ${ratio.toFixed(2)}:1`);
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ if (token in KNOWN_UNSAFE) {
280
+ // Rule 2: the baseline may only shrink. If this token now passes, the
281
+ // entry is stale and must go, or the list slowly becomes a mute button.
282
+ expect(
283
+ failures.length,
284
+ `--${token} is in KNOWN_UNSAFE but now passes everywhere. Delete its entry from ` +
285
+ `KNOWN_UNSAFE — the baseline is shrink-only.`,
286
+ ).toBeGreaterThan(0);
287
+ return;
288
+ }
289
+
290
+ expect(
291
+ failures,
292
+ `--${token} is used as TEXT here:\n ${where.join('\n ')}\n` +
293
+ ` …but it is illegible in ${failures.length} theme/surface combinations, worst first:\n ` +
294
+ failures
295
+ .sort((a, b) => parseFloat(a.split('= ')[1]!) - parseFloat(b.split('= ')[1]!))
296
+ .slice(0, 5)
297
+ .join('\n ') +
298
+ `\n Either give it a contrast-corrected \`-ink\` token (see --primary-ink), or if it ` +
299
+ `truly only ever lands on specific surfaces, declare that in SCOPED with a reason.`,
300
+ ).toEqual([]);
301
+ });
302
+
303
+ it('the known-unsafe baseline names only tokens that are actually used', () => {
304
+ // A baseline entry for a token nobody paints with any more is dead weight
305
+ // that makes the debt look bigger than it is.
306
+ const used = new Set(uses.map((u) => u.token));
307
+ const orphaned = Object.keys(KNOWN_UNSAFE).filter((t) => !used.has(t));
308
+ expect(
309
+ orphaned,
310
+ `KNOWN_UNSAFE lists ${orphaned.join(', ')}, which no \`color:\` declaration uses any more — ` +
311
+ `delete the entr${orphaned.length === 1 ? 'y' : 'ies'}.`,
312
+ ).toEqual([]);
313
+ });
314
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Parse a generated theme stylesheet into token blocks, indexed by EACH
3
+ * selector in a block's selector list — the shared lookup behind the theme
4
+ * audits (themes.test.ts, ink-audit.test.ts).
5
+ *
6
+ * Exact-selector indexing matters more than it looks: the audits used to find
7
+ * blocks by `indexOf('<selector> {')`, and once the light blocks grew the
8
+ * `.light` island (`:root, .light { … }`, see themes/generate.mjs) that
9
+ * substring stopped matching the light block — and for the per-theme form,
10
+ * `[data-color-theme="x"] {` matched INSIDE `.dark[data-color-theme="x"] {`,
11
+ * silently auditing dark values as light. Indexing every selector of every
12
+ * block makes the lookup exact and both failure modes structural.
13
+ */
14
+ export function parseThemeBlocks(css: string): Map<string, Record<string, string>> {
15
+ const map = new Map<string, Record<string, string>>();
16
+ // Comments out first, or the file's header comment glues itself onto the
17
+ // first block's selector list and `:root` never gets keyed.
18
+ const src = css.replace(/\/\*[\s\S]*?\*\//g, '');
19
+ for (const m of src.matchAll(/(^|\n)([^{}\n][^{}]*)\{([^}]*)\}/g)) {
20
+ const tokens: Record<string, string> = {};
21
+ for (const t of m[3]!.matchAll(/--([\w-]+):\s*([^;]+);/g)) tokens[t[1]!] = t[2]!.trim();
22
+ if (Object.keys(tokens).length === 0) continue;
23
+ for (const sel of m[2]!.split(',')) map.set(sel.trim(), tokens);
24
+ }
25
+ return map;
26
+ }
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ // The generator is plain .mjs (it runs under `node` at build time with no
5
+ // transpile step); vitest resolves it fine. Importing it is side-effect free —
6
+ // the CLI is guarded to direct execution.
7
+ import { generateCss, generateMode, generateRegistry, ROLE_HUES } from '../../themes/generate.mjs';
8
+ import { contrast, cssToHex, deltaE, solvePair, solveText } from '../../themes/model.mjs';
9
+ import { THEME_SEEDS } from '../../themes/seeds.mjs';
10
+
11
+ /**
12
+ * The generator's own guarantees. `themes.test.ts` re-measures the WCAG
13
+ * numbers on the shipped CSS with independent maths; this suite covers what
14
+ * that one can't:
15
+ *
16
+ * - DRIFT: the checked-in artifacts are exactly what the seeds generate.
17
+ * Editing themes.css by hand, or seeds without `pnpm themes:build`, fails
18
+ * here — the generated file cannot rot out from under its source.
19
+ * - the anchored-solver's behavioural contract (a passing value is emitted
20
+ * byte-for-byte; a failing one moves minimally; the ROUNDED hex is what
21
+ * gets validated — floats that clear 4.5 land at 4.48 as 8-bit hex, which
22
+ * cost nine test failures the first time),
23
+ * - distinguishability: the semantic roles stay tellable-apart from
24
+ * destructive and each other, and generated chart ramps never collapse
25
+ * two steps onto one colour (the achromatic dark ramps did exactly that
26
+ * before the ladder was made sequential).
27
+ */
28
+
29
+ const read = (rel: string) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8');
30
+
31
+ describe('drift', () => {
32
+ // Full regeneration solves every token of every theme — seconds, not ms.
33
+ it('styles/themes.css is exactly what the seeds generate', { timeout: 30_000 }, () => {
34
+ expect(
35
+ read('../../styles/themes.css') === generateCss(),
36
+ 'themes.css does not match themes/seeds.mjs — run `pnpm themes:build` (and never edit the generated file by hand)',
37
+ ).toBe(true);
38
+ });
39
+
40
+ it('the picker registry is exactly what the seeds generate', { timeout: 30_000 }, () => {
41
+ expect(
42
+ read('./theme-registry.gen.ts') === generateRegistry(),
43
+ 'theme-registry.gen.ts does not match themes/seeds.mjs — run `pnpm themes:build`',
44
+ ).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('anchored solver', () => {
49
+ it('emits a passing anchor byte-for-byte unchanged', () => {
50
+ expect(solveText('#1e293b', ['#ffffff', '#f8fafc']).hex).toBe('#1e293b');
51
+ });
52
+
53
+ it('moves a failing anchor the minimum, keeping hue and chroma', () => {
54
+ const r = solveText('#999999', ['#ffffff']);
55
+ expect(contrast(r.hex, '#ffffff')).toBeGreaterThanOrEqual(4.5);
56
+ // an achromatic anchor stays achromatic (hue/chroma untouched) …
57
+ const [rr, gg, bb] = [r.hex.slice(1, 3), r.hex.slice(3, 5), r.hex.slice(5, 7)];
58
+ expect(rr).toBe(gg);
59
+ expect(gg).toBe(bb);
60
+ // … and lands just past the threshold, not at some distant safe colour.
61
+ expect(contrast(r.hex, '#ffffff')).toBeLessThan(5.2);
62
+ });
63
+
64
+ it('validates the rounded hex it emits, not the float candidate', () => {
65
+ // Property: for a spread of anchors and surfaces, the EMITTED hex —
66
+ // re-parsed as 8-bit — must clear the ratio. A solver that measures floats
67
+ // fails this on boundary values.
68
+ const surfaces = ['#ffffff', '#f3f4f6', '#1b1b19', '#262626'];
69
+ for (const anchor of ['#6366f1', '#ef4444', '#0c9746', '#ae6f00', '#c96442', '#72e3ad']) {
70
+ for (const surface of surfaces) {
71
+ const r = solveText(anchor, [surface]);
72
+ expect(r.feasible).toBe(true);
73
+ expect(contrast(r.hex, surface), `${anchor} solved on ${surface}`).toBeGreaterThanOrEqual(
74
+ 4.5,
75
+ );
76
+ }
77
+ }
78
+ });
79
+
80
+ it('pair solve keeps a consistent authored pair verbatim', () => {
81
+ const pair = solvePair('#457928', '#ffffff'); // pinnacle primary: passes as authored
82
+ expect(pair.fill).toBe(cssToHex('#457928'));
83
+ expect(pair.fg).toBe('#ffffff');
84
+ });
85
+
86
+ it('pair solve deepens a broken fill rather than flipping its text dark', () => {
87
+ // White on #e6067a is 3.9:1 — the classic authored defect. The one-sided
88
+ // fix is near-black text (an identity flip); the pair solve must instead
89
+ // deepen the fill and KEEP the light foreground.
90
+ const pair = solvePair('#e6067a', '#ffffff');
91
+ expect(contrast(pair.fg, pair.fill)).toBeGreaterThanOrEqual(4.5);
92
+ expect(deltaE(pair.fg, '#ffffff'), 'foreground flipped instead of fill deepening').toBeLessThan(
93
+ 0.1,
94
+ );
95
+ expect(deltaE(pair.fill, '#e6067a')).toBeLessThan(0.2);
96
+ });
97
+ });
98
+
99
+ describe('generated palette distinguishability', () => {
100
+ const ROLES = Object.keys(ROLE_HUES);
101
+ for (const seed of THEME_SEEDS) {
102
+ for (const mode of ['light', 'dark'] as const) {
103
+ it(`${seed.id} ${mode}: roles and charts stay tellable-apart`, () => {
104
+ const t = generateMode(seed[mode], { mode }) as Record<string, string>;
105
+ // each semantic role vs destructive and vs its siblings
106
+ const fills = ['destructive', ...ROLES];
107
+ for (let i = 0; i < fills.length; i++) {
108
+ for (let j = i + 1; j < fills.length; j++) {
109
+ const d = deltaE(t[fills[i]!]!, t[fills[j]!]!);
110
+ expect(
111
+ d,
112
+ `${seed.id}/${mode}: ${fills[i]} and ${fills[j]} are ΔE ${d.toFixed(3)} apart — ` +
113
+ `a status colour that can be mistaken for another is worse than none`,
114
+ ).toBeGreaterThanOrEqual(0.06);
115
+ }
116
+ }
117
+ // charts: a categorical ramp with two equal steps mislabels data
118
+ if (!seed[mode].charts) {
119
+ for (let i = 1; i <= 5; i++) {
120
+ for (let j = i + 1; j <= 5; j++) {
121
+ const d = deltaE(t[`chart-${i}`]!, t[`chart-${j}`]!);
122
+ expect(
123
+ d,
124
+ `${seed.id}/${mode}: chart-${i} vs chart-${j} ΔE ${d.toFixed(3)}`,
125
+ ).toBeGreaterThanOrEqual(0.05);
126
+ }
127
+ }
128
+ }
129
+ });
130
+ }
131
+ }
132
+ });
133
+
134
+ describe('infeasible seeds fail loudly', () => {
135
+ it('a mid-luminance surface pair aborts generation instead of emitting a failing ink', () => {
136
+ // One surface needs light text, the other dark — no single ink clears
137
+ // 4.5:1 on both. The generator must throw a seed-naming error, not emit a
138
+ // value for CI to trip over three artifacts downstream.
139
+ const seed = {
140
+ background: '#8a8a8a', // mid-luminance: kills dark inks
141
+ card: '#3a5a6a', // dark-mid: kills light inks
142
+ foreground: '#1e293b',
143
+ primary: '#6366f1',
144
+ 'primary-foreground': '#ffffff',
145
+ secondary: '#e5e7eb',
146
+ muted: '#8a8a8a',
147
+ 'muted-foreground': '#3d4451',
148
+ accent: '#e0e7ff',
149
+ destructive: '#df3236',
150
+ 'destructive-foreground': '#ffffff',
151
+ border: '#d1d5db',
152
+ };
153
+ expect(() => generateMode(seed, { mode: 'light' })).toThrow(/no feasible ink/);
154
+ });
155
+ });
156
+
157
+ describe('seeds hygiene', () => {
158
+ it('ids are unique and clean-slate is the baseline', () => {
159
+ const ids = THEME_SEEDS.map((t) => t.id);
160
+ expect(new Set(ids).size).toBe(ids.length);
161
+ expect(ids[0]).toBe('clean-slate');
162
+ });
163
+
164
+ it('every seeded colour is normalised 6-digit hex', () => {
165
+ // The extractor normalised all notations; keeping seeds in one notation is
166
+ // what makes "passing anchors ship byte-for-byte" reviewable in a diff.
167
+ for (const t of THEME_SEEDS) {
168
+ for (const mode of ['light', 'dark'] as const) {
169
+ for (const [k, v] of Object.entries(t[mode])) {
170
+ if (k === 'extras') continue;
171
+ const values = k === 'charts' ? (v as string[]) : [v as string];
172
+ for (const value of values) {
173
+ expect(value, `${t.id}.${mode}.${k}`).toMatch(/^#[0-9a-f]{6}$/);
174
+ }
175
+ }
176
+ }
177
+ }
178
+ });
179
+ });