@terpjs/contract 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ import fs from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import { parseRules } from "./css-rules.js";
7
+
8
+ // Text legibility of the token sheet, measured rather than reviewed.
9
+ //
10
+ // The sheet is the single source of every colour a Terp app renders, and a foreground and
11
+ // a background are chosen independently — one token moves for a good reason and a pairing
12
+ // three components away stops being readable. Nothing catches that today: the token guard
13
+ // checks spelling, the theme test checks completeness, and neither knows that
14
+ // `--color-status-warning` is only ever painted on `--color-status-warning-soft`.
15
+ //
16
+ // So the pairings are declared here, as data, and held to WCAG 2.1 contrast. Each entry is
17
+ // a pairing some framework component actually renders as text; decorative boundaries are
18
+ // deliberately absent, because WCAG sets no ratio for a divider and asserting one would
19
+ // only teach the next reader to ignore this file.
20
+
21
+ const here = (name) => fileURLToPath(new URL(name, import.meta.url));
22
+
23
+ const tokensCss = fs.readFileSync(here("./tokens.css"), "utf8");
24
+ const registry = JSON.parse(fs.readFileSync(here("../themes.json"), "utf8"));
25
+
26
+ /** WCAG 2.1 AA, normal-size text. Large text and UI boundaries would be 3.0. */
27
+ const AA_NORMAL_TEXT = 4.5;
28
+
29
+ /** WCAG 2.1 AAA, normal-size text — the bar a theme named for contrast has to clear. */
30
+ const AAA_NORMAL_TEXT = 7;
31
+
32
+ /**
33
+ * WCAG 2.1 SC 1.4.11, non-text contrast: the bar for a control's visual boundary and for a
34
+ * state or focus indicator. Flat across every theme, including the one that raises its text
35
+ * floor to AAA, because WCAG defines no AAA tier for non-text contrast — `minimumContrast` in
36
+ * themes.json is a promise about reading, and inventing a stricter non-text bar from it would
37
+ * be this file asserting a standard nobody wrote.
38
+ */
39
+ const UI_COMPONENT = 3;
40
+
41
+ /**
42
+ * Pairings the framework renders as text, read from the shared data file.
43
+ *
44
+ * `token-pairs.json` is the single source: this gate holds each pairing to AA, and the
45
+ * generated manifest publishes the same list so a theme editor or an agent can tell which
46
+ * tokens must stay legible against which. Restating them here would let the gate and the
47
+ * published contract disagree about what is guaranteed.
48
+ *
49
+ * `label` names the component surface so a failure says what a user would be looking at, and
50
+ * `id` is the stable key — labels intentionally repeat across the primitive and semantic
51
+ * layers ("body text on the canvas" describes both), so only the id can identify a pairing.
52
+ */
53
+ const PAIRS = JSON.parse(fs.readFileSync(here("../token-pairs.json"), "utf8"));
54
+ const TEXT_PAIRS = PAIRS.textPairs;
55
+
56
+ /**
57
+ * Pairings the framework renders as a boundary or an indicator rather than as text, from the
58
+ * same file, held to {@link UI_COMPONENT} instead of AA.
59
+ *
60
+ * The section exists because three measured ratios had nowhere to live and so were recorded as
61
+ * prose in `styles.ts` — the shared focus ring, the border that says which layout toggle is
62
+ * active, and the neutral-300 control outline. A number in a comment is not a gate: the focus
63
+ * ring shipped at 1.67:1 for exactly as long as its value was only ever read by a person.
64
+ *
65
+ * What is deliberately NOT here is as load-bearing as what is. The focus ring's translucent
66
+ * box-shadow halo is excluded: the opaque outline is the indicator SC 1.4.11 measures, and the
67
+ * halo is reinforcement around it — declaring the halo would assert a ratio WCAG does not ask
68
+ * for, which is the same reason dividers are absent from `textPairs`. The active toggle's
69
+ * neutral-100 fill is excluded for the same reason, at 1.10, and it is why that rule carries a
70
+ * border at all rather than a wash. And neither the toggle's border against the toolbar band
71
+ * nor the focus ring on a card is an entry, because both name the same two tokens as the TEXT
72
+ * pairing `accent-on-surface` — measuring one pairing twice under two names would make the
73
+ * ratchet lie about how much is covered, and here the other name is held to a stricter bar.
74
+ * `declares no pairing the text section already holds to a stricter bar` enforces that.
75
+ */
76
+ const NON_TEXT_PAIRS = PAIRS.nonTextPairs;
77
+
78
+
79
+ /**
80
+ * Pairings that do not reach AA today, with the ratio measured when they were recorded.
81
+ *
82
+ * A ratchet, not a mute button, and it moves in one direction only: a pairing may not drop
83
+ * below its recorded floor, and once it reaches AA it must leave this table — so an
84
+ * improvement cannot quietly leave a stale allowance behind, and a regression cannot hide
85
+ * behind an existing one. Keyed `<theme>/<label>`.
86
+ *
87
+ * Floors are the measured ratio truncated to four places, and the comparison below is
88
+ * against the *raw* ratio. Rounding to two places would straddle the AA boundary in both
89
+ * directions — 4.4951 would round into a pass, and two of these floors would only hold
90
+ * because rounding lifted them — so neither the gate nor the ratchet may depend on it.
91
+ *
92
+ * All five clear 3.0 (AA for large text and UI components) and fail 4.5 (normal text).
93
+ * Badge copy and button labels are normal text, so these are real defects, deliberately
94
+ * left visible: changing a token value repaints every app, which belongs to the semantic
95
+ * token layer rather than to the test that found it. Emptying this table is that work's
96
+ * acceptance criterion.
97
+ *
98
+ * Every entry is in `light` or `dark`, the two themes that shipped before the table existed.
99
+ * The themes added since carry none: a theme authored against this gate has no reason to land
100
+ * below AA, so an allowance for a new theme is a design mistake and not a legacy to record.
101
+ */
102
+ const BELOW_AA = new Map([]);
103
+
104
+ /**
105
+ * Non-text pairings that do not reach 3:1 today, with the ratio measured when they were
106
+ * recorded. Same ratchet contract as {@link BELOW_AA}: a floor may only rise, and a pairing
107
+ * that reaches the bar must leave the table.
108
+ *
109
+ * Every entry is the same defect. `--color-neutral-300` is the control outline — the border on
110
+ * an input, a secondary button, a card, a combobox, a menu, the layout toggles — and against
111
+ * the surfaces those controls sit on it measures 1.42 to 2.36, so a bordered control's edge is
112
+ * effectively invisible to anyone who needs the boundary in order to see the control. That is a
113
+ * genuine SC 1.4.11 failure in four of the five themes, deliberately recorded rather than
114
+ * fixed: the fix is the token value, and moving it repaints every bordered control in the
115
+ * package, which is a decision about how the framework looks and not a side effect of adding a
116
+ * gate. The contrast theme already clears it at 10.37, which is what shows the fix is a value
117
+ * and not a structure.
118
+ *
119
+ * Unlike {@link BELOW_AA} the entries are not confined to the themes that predate the gate, and
120
+ * pretending otherwise would be the dishonest option — every palette inherited the same
121
+ * 300-step boundary, so the defect is one token's value seen five times rather than five
122
+ * independent mistakes. The guard below is therefore different in kind: the allowance may name
123
+ * only the control-boundary pairings. A new pairing cannot be added to it at all.
124
+ */
125
+ const BELOW_UI = new Map([
126
+ ["dark/control-boundary-on-canvas", 2.3559],
127
+ ["dark/control-boundary-on-surface", 1.9305],
128
+ ["light/control-boundary-on-canvas", 1.419],
129
+ ["light/control-boundary-on-surface", 1.4847],
130
+ ["midnight/control-boundary-on-canvas", 1.6826],
131
+ ["midnight/control-boundary-on-surface", 1.5506],
132
+ ["twilight/control-boundary-on-canvas", 1.982],
133
+ ["twilight/control-boundary-on-surface", 1.7807],
134
+ ]);
135
+
136
+ /** The only pairings {@link BELOW_UI} is allowed to name. */
137
+ const CONTROL_BOUNDARY_IDS = ["control-boundary-on-canvas", "control-boundary-on-surface"];
138
+
139
+ /** The declarations of the one rule whose selector is exactly `selector`. */
140
+ function declarationsFor(selector) {
141
+ const matches = parseRules(tokensCss).filter((rule) => rule.selector === selector);
142
+ if (matches.length !== 1) {
143
+ throw new Error(
144
+ `expected exactly one \`${selector}\` rule in tokens.css, found ${matches.length}`,
145
+ );
146
+ }
147
+ return matches[0].declarations;
148
+ }
149
+
150
+ // A theme is the base root with its own colours laid over it — the same resolution the
151
+ // cascade performs, so a pairing is measured at the value a browser would paint. The theme
152
+ // list comes from the registry rather than from a literal here, so a theme cannot be added to
153
+ // the sheet without this gate measuring it.
154
+ const base = declarationsFor(":root");
155
+ const THEMES = Object.fromEntries(
156
+ registry.themes.map((theme) => [
157
+ theme.name,
158
+ theme.name === registry.base
159
+ ? base
160
+ : new Map([...base, ...declarationsFor(`[data-theme='${theme.name}']`)]),
161
+ ]),
162
+ );
163
+
164
+ /** sRGB channel → linear light, per WCAG 2.1 relative luminance. */
165
+ function linearise(channel) {
166
+ const scaled = channel / 255;
167
+ return scaled <= 0.04045 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
168
+ }
169
+
170
+ /** Relative luminance of a `#rrggbb` value. */
171
+ function relativeLuminance(hex) {
172
+ const match = /^#([0-9a-f]{6})$/i.exec(hex);
173
+ if (!match) throw new Error(`not a six-digit hex colour: ${hex}`);
174
+ const [red, green, blue] = [0, 2, 4].map((offset) =>
175
+ linearise(Number.parseInt(match[1].slice(offset, offset + 2), 16)),
176
+ );
177
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
178
+ }
179
+
180
+ /** WCAG 2.1 contrast ratio between two `#rrggbb` values, 1.0 – 21.0. */
181
+ function contrastRatio(a, b) {
182
+ const [darker, lighter] = [relativeLuminance(a), relativeLuminance(b)].sort(
183
+ (x, y) => x - y,
184
+ );
185
+ return (lighter + 0.05) / (darker + 0.05);
186
+ }
187
+
188
+ /**
189
+ * The ratio a theme's pairings must reach. AA for normal text by default; a theme may declare
190
+ * a higher floor in `themes.json`, which is how the high-contrast theme's promise is a gate
191
+ * rather than a sentence in its description.
192
+ */
193
+ const floorFor = (name) =>
194
+ registry.themes.find((theme) => theme.name === name)?.minimumContrast ?? AA_NORMAL_TEXT;
195
+
196
+ /**
197
+ * Every pairing in *list*, in every registered theme, tagged with its ratchet key and the
198
+ * ratio it has to reach.
199
+ *
200
+ * Shared by both suites because they differ in exactly one thing — the bar — and writing the
201
+ * fan-out twice is how the two would drift into measuring different theme sets.
202
+ */
203
+ const casesFor = (list, floorOf) =>
204
+ Object.entries(THEMES).flatMap(([theme, declarations]) =>
205
+ list.map((pair) => ({
206
+ ...pair,
207
+ theme,
208
+ declarations,
209
+ key: `${theme}/${pair.id}`,
210
+ floor: floorOf(theme),
211
+ })),
212
+ );
213
+
214
+ /** Every text pairing, in every registered theme, tagged with its `BELOW_AA` key. */
215
+ const cases = casesFor(TEXT_PAIRS, floorFor);
216
+
217
+ /** The measured ratio for one case, with the painted values for the failure message. */
218
+ function measure({ fg, bg, declarations }) {
219
+ const foreground = declarations.get(fg);
220
+ const background = declarations.get(bg);
221
+ expect(foreground, `${fg} is not declared`).toBeDefined();
222
+ expect(background, `${bg} is not declared`).toBeDefined();
223
+ return {
224
+ ratio: contrastRatio(foreground, background),
225
+ painted: `${fg} (${foreground}) on ${bg} (${background})`,
226
+ };
227
+ }
228
+
229
+ const meetsAa = cases.filter(({ key }) => !BELOW_AA.has(key));
230
+ const knownGaps = cases.filter(({ key }) => BELOW_AA.has(key));
231
+
232
+ /** The same three lists for the non-text section. Its bar is flat, so every floor is the same. */
233
+ const uiCases = casesFor(NON_TEXT_PAIRS, () => UI_COMPONENT);
234
+ const meetsUi = uiCases.filter(({ key }) => !BELOW_UI.has(key));
235
+ const uiGaps = uiCases.filter(({ key }) => BELOW_UI.has(key));
236
+
237
+ describe("token sheet text contrast", () => {
238
+ it("measures a known ratio correctly", () => {
239
+ // The calculator itself needs a fixture, or a subtly wrong exponent would move every
240
+ // ratio below in the same direction and the suite would still look green.
241
+ expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5);
242
+ expect(contrastRatio("#ffffff", "#ffffff")).toBeCloseTo(1, 5);
243
+ // A published mid-tone pair, so the curve is checked and not just its endpoints.
244
+ expect(contrastRatio("#767676", "#ffffff")).toBeCloseTo(4.5422, 4);
245
+ });
246
+
247
+ it("gives every pairing a unique id, across both sections", () => {
248
+ // The id is the ratchet key and the manifest's handle. A duplicate would silently make
249
+ // one pairing's allowance apply to another, and labels cannot substitute — they repeat
250
+ // across the primitive and semantic layers on purpose.
251
+ //
252
+ // Both sections at once, because the two ratchets key the same way: `light/x` has to name
253
+ // one pairing whichever table it appears in, or an allowance would apply the wrong bar.
254
+ const ids = [...TEXT_PAIRS, ...NON_TEXT_PAIRS].map((pair) => pair.id);
255
+ expect(ids.filter((id) => !id)).toEqual([]);
256
+ expect(new Set(ids).size).toBe(ids.length);
257
+ });
258
+
259
+ it("measures every registered theme", () => {
260
+ // The theme list is read from `themes.json`, so a theme added to the sheet is measured
261
+ // automatically — but only if the resolution above actually found its block. A theme that
262
+ // resolved to an empty map would produce cases that all read the base values and pass,
263
+ // which is the failure mode worth pinning: silent, and it looks like coverage.
264
+ expect(Object.keys(THEMES)).toEqual(registry.themes.map((theme) => theme.name));
265
+ for (const [name, declarations] of Object.entries(THEMES)) {
266
+ expect(declarations.size, `${name} resolved to nothing`).toBe(base.size);
267
+ }
268
+ expect(cases).toHaveLength(registry.themes.length * TEXT_PAIRS.length);
269
+ });
270
+
271
+ it("keeps the known gaps to the themes that predate the gate", () => {
272
+ // A theme authored against this gate has no reason to land below AA, so an allowance for a
273
+ // newer theme would be a design mistake being recorded as history. Naming the two
274
+ // grandfathered themes explicitly is what stops the table from becoming a general amnesty.
275
+ const grandfathered = new Set(["light", "dark"]);
276
+ const themeOf = (key) => key.slice(0, key.indexOf("/"));
277
+ expect([...BELOW_AA.keys()].filter((key) => !grandfathered.has(themeOf(key)))).toEqual([]);
278
+ });
279
+
280
+ it("holds every pairing in exactly one of the two sets", () => {
281
+ // A typo in a `BELOW_AA` key would silently move a pairing from the strict set into
282
+ // neither set, so the gate would stop asserting anything about it.
283
+ expect(meetsAa.length + knownGaps.length).toBe(cases.length);
284
+ expect(knownGaps).toHaveLength(BELOW_AA.size);
285
+ const known = new Set(cases.map(({ key }) => key));
286
+ expect([...BELOW_AA.keys()].filter((key) => !known.has(key))).toEqual([]);
287
+ expect([...BELOW_AA.keys()]).toEqual([...BELOW_AA.keys()].sort());
288
+ });
289
+
290
+ it.each(meetsAa)("$theme: $id ($label) reaches $floor:1 for normal text", (testCase) => {
291
+ const { ratio, painted } = measure(testCase);
292
+ expect(ratio, painted).toBeGreaterThanOrEqual(testCase.floor);
293
+ });
294
+
295
+ it("holds the high-contrast theme to AAA rather than AA", () => {
296
+ // A theme called "high contrast" that only cleared the same bar as every other theme would
297
+ // be a name doing the work a measurement should. The floor is declared per theme in
298
+ // `themes.json`; this asserts at least one theme actually raises it, so the mechanism
299
+ // cannot rot into an unused field that reads as enforcement.
300
+ const raised = registry.themes.filter((theme) => theme.minimumContrast !== undefined);
301
+ expect(raised.length).toBeGreaterThan(0);
302
+ for (const theme of raised) {
303
+ expect(theme.minimumContrast, `${theme.name} floor`).toBeGreaterThanOrEqual(
304
+ AAA_NORMAL_TEXT,
305
+ );
306
+ expect(
307
+ [...BELOW_AA.keys()].filter((key) => key.startsWith(`${theme.name}/`)),
308
+ `${theme.name} raises its floor, so it cannot also carry an allowance`,
309
+ ).toEqual([]);
310
+ }
311
+ });
312
+
313
+ // Deliberately titled as a gap, not as a pass: a green line reading "warning badge
314
+ // reaches AA" for a pairing measuring 3.07 is worse than no test, because it is the line
315
+ // a reviewer trusts.
316
+ it.each(knownGaps)(
317
+ "$theme: $id ($label) is a known contrast gap, held at its floor",
318
+ (testCase) => {
319
+ const { ratio, painted } = measure(testCase);
320
+ const floor = BELOW_AA.get(testCase.key);
321
+ expect(ratio, `${painted} regressed below its recorded floor`).toBeGreaterThanOrEqual(
322
+ floor,
323
+ );
324
+ expect(ratio, `${painted} now reaches AA — remove it from BELOW_AA`).toBeLessThan(
325
+ AA_NORMAL_TEXT,
326
+ );
327
+ },
328
+ );
329
+ });
330
+
331
+ describe("token sheet non-text contrast", () => {
332
+ it("declares no pairing the text section already holds to a stricter bar", () => {
333
+ // The guard this section was one review away from needing. `focus-ring-on-surface` shipped
334
+ // here naming --color-fg-accent on --color-bg-surface, which is exactly what the text
335
+ // pairing `accent-on-surface` already holds to 4.5 — so the non-text case could never fail
336
+ // unless the stricter one had failed first, and its only effect was to make the section
337
+ // look like it covered one surface more than it did. The ring on a card is still measured;
338
+ // it is measured by the entry that would go red first.
339
+ //
340
+ // Compared on token NAMES rather than values on purpose: `body-on-card` and
341
+ // `body-on-surface` resolve to identical values in every theme and are both declared,
342
+ // because a theme author retargeting the semantic alias needs the alias measured too. That
343
+ // is the file working as intended; two names for one pair inside one bar is not.
344
+ const textPairKeys = new Set(TEXT_PAIRS.map((pair) => `${pair.fg} on ${pair.bg}`));
345
+ const restated = NON_TEXT_PAIRS.filter((pair) =>
346
+ textPairKeys.has(`${pair.fg} on ${pair.bg}`),
347
+ ).map((pair) => pair.id);
348
+ expect(restated).toEqual([]);
349
+ });
350
+
351
+ it("covers every registered theme once per pairing", () => {
352
+ // Narrower than its namesake in the text suite on purpose: that one also proves each theme
353
+ // RESOLVED, which is the failure mode that looks like coverage, and it proves it for the
354
+ // shared THEMES map this suite reads. Re-asserting it here would be a second copy of one
355
+ // fact. What is not covered there is the empty-list case — with no pairings the count check
356
+ // would read 0 === 0 and pass — so that is the assertion this one adds.
357
+ expect(NON_TEXT_PAIRS.length).toBeGreaterThan(0);
358
+ expect(uiCases).toHaveLength(registry.themes.length * NON_TEXT_PAIRS.length);
359
+ });
360
+
361
+ it("holds every pairing in exactly one of the two sets", () => {
362
+ expect(meetsUi.length + uiGaps.length).toBe(uiCases.length);
363
+ expect(uiGaps).toHaveLength(BELOW_UI.size);
364
+ const known = new Set(uiCases.map(({ key }) => key));
365
+ expect([...BELOW_UI.keys()].filter((key) => !known.has(key))).toEqual([]);
366
+ expect([...BELOW_UI.keys()]).toEqual([...BELOW_UI.keys()].sort());
367
+ });
368
+
369
+ it("lets the allowance name the control boundary and nothing else", () => {
370
+ // The one guard that keeps this from becoming a general amnesty. BELOW_AA restricts its
371
+ // allowance by THEME, which works there because a new theme has no excuse to ship below AA.
372
+ // That reasoning does not transfer: this defect is one token value that every palette
373
+ // inherited, so it shows up in themes that postdate the gate through no fault of their own.
374
+ // Restricting by PAIRING instead says the same thing the theme rule says — no new debt —
375
+ // without pretending the existing debt is older than it is.
376
+ const idOf = (key) => key.slice(key.indexOf("/") + 1);
377
+ expect([...BELOW_UI.keys()].filter((key) => !CONTROL_BOUNDARY_IDS.includes(idOf(key)))).toEqual(
378
+ [],
379
+ );
380
+ });
381
+
382
+ it.each(meetsUi)("$theme: $id ($label) reaches $floor:1 as a non-text pairing", (testCase) => {
383
+ const { ratio, painted } = measure(testCase);
384
+ expect(ratio, painted).toBeGreaterThanOrEqual(testCase.floor);
385
+ });
386
+
387
+ it.each(uiGaps)("$theme: $id ($label) is a known non-text gap, held at its floor", (testCase) => {
388
+ const { ratio, painted } = measure(testCase);
389
+ const floor = BELOW_UI.get(testCase.key);
390
+ expect(ratio, `${painted} regressed below its recorded floor`).toBeGreaterThanOrEqual(floor);
391
+ expect(ratio, `${painted} now reaches 3:1 — remove it from BELOW_UI`).toBeLessThan(
392
+ UI_COMPONENT,
393
+ );
394
+ });
395
+ });