@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.
- package/package.json +3 -2
- package/scripts/build-tokens.mjs +160 -25
- package/src/css-rules.js +107 -0
- package/src/css-rules.test.js +110 -0
- package/src/tokens.contrast.test.js +395 -0
- package/src/tokens.css +293 -14
- package/src/tokens.manifest.json +1416 -0
- package/src/tokens.manifest.test.js +166 -0
- package/src/tokens.themes.test.js +177 -0
- package/themes.json +43 -0
- package/token-pairs.json +252 -0
- package/tokens.contrast.json +70 -0
- package/tokens.dark.json +40 -4
- package/tokens.json +115 -7
- package/tokens.midnight.json +70 -0
- package/tokens.twilight.json +70 -0
|
@@ -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
|
+
});
|