@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.
- package/LICENSE.md +135 -0
- package/package.json +68 -0
- package/src/app-bridge-protocol.ts +115 -0
- package/src/app-presenter.tsx +25 -0
- package/src/app-sandbox.tsx +552 -0
- package/src/appearance.ts +192 -0
- package/src/avatar.test.ts +229 -0
- package/src/avatar.ts +731 -0
- package/src/backgrounds.test.ts +119 -0
- package/src/backgrounds.ts +118 -0
- package/src/draw-presenter.tsx +39 -0
- package/src/event-presenter.tsx +62 -0
- package/src/file-presenter.tsx +76 -0
- package/src/formula-calculator.tsx +209 -0
- package/src/formula-presenter.test.ts +128 -0
- package/src/formula-presenter.tsx +301 -0
- package/src/help-topics.ts +104 -0
- package/src/lib/ink-audit.test.ts +314 -0
- package/src/lib/theme-css-blocks.ts +26 -0
- package/src/lib/theme-generator.test.ts +179 -0
- package/src/lib/theme-registry.gen.ts +352 -0
- package/src/lib/themes.test.ts +308 -0
- package/src/lib/themes.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/nav-items.ts +225 -0
- package/src/note-presenter.tsx +14 -0
- package/src/page-outline.tsx +127 -0
- package/src/table-presenter.tsx +226 -0
- package/src/task-presenter.tsx +60 -0
- package/src/ui/button.tsx +50 -0
- package/src/ui/input.tsx +18 -0
- package/src/ui/label.tsx +20 -0
- package/src/view-payload.ts +82 -0
- package/styles/app.css +1098 -0
- package/styles/themes.css +6198 -0
- package/themes/generate.d.mts +11 -0
- package/themes/generate.mjs +618 -0
- package/themes/model.d.mts +24 -0
- package/themes/model.mjs +213 -0
- package/themes/preview.html +145 -0
- package/themes/seeds.d.mts +16 -0
- package/themes/seeds.mjs +3694 -0
- package/tsconfig.json +15 -0
- 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
|
+
});
|