@terpjs/contract 0.7.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/contract",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Terp frontend contract \u2014 the OpenAPI-generated TypeScript client, design tokens, and the stack-agnostic module/route/nav + auth types.",
6
6
  "exports": {
@@ -205,6 +205,9 @@ const manifest = {
205
205
  themeable: overlays.some((theme) => sources.get(theme.name).has(name)),
206
206
  })),
207
207
  textPairs: pairs.textPairs,
208
+ // Both sections, because a consumer that can only see the text pairings would read the
209
+ // absence of a boundary pairing as "no requirement" rather than "held elsewhere".
210
+ nonTextPairs: pairs.nonTextPairs,
208
211
  };
209
212
 
210
213
  writeFileSync(
@@ -213,5 +216,6 @@ writeFileSync(
213
216
  );
214
217
  console.log(
215
218
  `wrote src/tokens.manifest.json (${manifest.tokens.length} tokens, ` +
216
- `${manifest.themes.length} themes, ${manifest.textPairs.length} pairs)`,
219
+ `${manifest.themes.length} themes, ${manifest.textPairs.length} text pairs, ` +
220
+ `${manifest.nonTextPairs.length} non-text pairs)`,
217
221
  );
@@ -29,6 +29,15 @@ const AA_NORMAL_TEXT = 4.5;
29
29
  /** WCAG 2.1 AAA, normal-size text — the bar a theme named for contrast has to clear. */
30
30
  const AAA_NORMAL_TEXT = 7;
31
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
+
32
41
  /**
33
42
  * Pairings the framework renders as text, read from the shared data file.
34
43
  *
@@ -41,7 +50,30 @@ const AAA_NORMAL_TEXT = 7;
41
50
  * `id` is the stable key — labels intentionally repeat across the primitive and semantic
42
51
  * layers ("body text on the canvas" describes both), so only the id can identify a pairing.
43
52
  */
44
- const TEXT_PAIRS = JSON.parse(fs.readFileSync(here("../token-pairs.json"), "utf8")).textPairs;
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;
45
77
 
46
78
 
47
79
  /**
@@ -69,6 +101,41 @@ const TEXT_PAIRS = JSON.parse(fs.readFileSync(here("../token-pairs.json"), "utf8
69
101
  */
70
102
  const BELOW_AA = new Map([]);
71
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
+
72
139
  /** The declarations of the one rule whose selector is exactly `selector`. */
73
140
  function declarationsFor(selector) {
74
141
  const matches = parseRules(tokensCss).filter((rule) => rule.selector === selector);
@@ -126,16 +193,26 @@ function contrastRatio(a, b) {
126
193
  const floorFor = (name) =>
127
194
  registry.themes.find((theme) => theme.name === name)?.minimumContrast ?? AA_NORMAL_TEXT;
128
195
 
129
- /** Every pairing, in every registered theme, tagged with its `BELOW_AA` key. */
130
- const cases = Object.entries(THEMES).flatMap(([theme, declarations]) =>
131
- TEXT_PAIRS.map((pair) => ({
132
- ...pair,
133
- theme,
134
- declarations,
135
- key: `${theme}/${pair.id}`,
136
- floor: floorFor(theme),
137
- })),
138
- );
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);
139
216
 
140
217
  /** The measured ratio for one case, with the painted values for the failure message. */
141
218
  function measure({ fg, bg, declarations }) {
@@ -152,6 +229,11 @@ function measure({ fg, bg, declarations }) {
152
229
  const meetsAa = cases.filter(({ key }) => !BELOW_AA.has(key));
153
230
  const knownGaps = cases.filter(({ key }) => BELOW_AA.has(key));
154
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
+
155
237
  describe("token sheet text contrast", () => {
156
238
  it("measures a known ratio correctly", () => {
157
239
  // The calculator itself needs a fixture, or a subtly wrong exponent would move every
@@ -162,11 +244,14 @@ describe("token sheet text contrast", () => {
162
244
  expect(contrastRatio("#767676", "#ffffff")).toBeCloseTo(4.5422, 4);
163
245
  });
164
246
 
165
- it("gives every pairing a unique id", () => {
247
+ it("gives every pairing a unique id, across both sections", () => {
166
248
  // The id is the ratchet key and the manifest's handle. A duplicate would silently make
167
249
  // one pairing's allowance apply to another, and labels cannot substitute — they repeat
168
250
  // across the primitive and semantic layers on purpose.
169
- const ids = TEXT_PAIRS.map((pair) => pair.id);
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);
170
255
  expect(ids.filter((id) => !id)).toEqual([]);
171
256
  expect(new Set(ids).size).toBe(ids.length);
172
257
  });
@@ -242,3 +327,69 @@ describe("token sheet text contrast", () => {
242
327
  },
243
328
  );
244
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
+ });
package/src/tokens.css CHANGED
@@ -39,7 +39,6 @@
39
39
  --color-fg-muted: #475569;
40
40
  --color-fg-subtle: #64748b;
41
41
  --color-fg-accent: #1d4ed8;
42
- --color-fg-on-brand: #ffffff;
43
42
  --color-border-default: #e2e8f0;
44
43
  --color-border-strong: #cbd5e1;
45
44
  --color-border-subtle: #f1f5f9;
@@ -72,6 +71,12 @@
72
71
  --space-12: 3rem;
73
72
  --space-16: 4rem;
74
73
  --space-20: 5rem;
74
+ --density-control-min-height: 2.25rem;
75
+ --density-cell-pad-y: 0.75rem;
76
+ --density-cell-pad-x: 0.75rem;
77
+ --density-compact-control-min-height: 2rem;
78
+ --density-compact-cell-pad-y: 0.5rem;
79
+ --density-compact-cell-pad-x: 0.5rem;
75
80
  --radius-sm: 0.25rem;
76
81
  --radius-md: 0.5rem;
77
82
  --radius-lg: 0.75rem;
@@ -157,7 +162,6 @@
157
162
  --color-fg-muted: #b4c0d0;
158
163
  --color-fg-subtle: #94a3b8;
159
164
  --color-fg-accent: #60a5fa;
160
- --color-fg-on-brand: #ffffff;
161
165
  --color-border-default: #334155;
162
166
  --color-border-strong: #475569;
163
167
  --color-border-subtle: #263449;
@@ -212,7 +216,6 @@
212
216
  --color-fg-muted: #9aa4b2;
213
217
  --color-fg-subtle: #8b949e;
214
218
  --color-fg-accent: #58a6ff;
215
- --color-fg-on-brand: #ffffff;
216
219
  --color-border-default: #30363d;
217
220
  --color-border-strong: #484f58;
218
221
  --color-border-subtle: #21262d;
@@ -267,7 +270,6 @@
267
270
  --color-fg-muted: #b9aecd;
268
271
  --color-fg-subtle: #a294bd;
269
272
  --color-fg-accent: #a78bfa;
270
- --color-fg-on-brand: #ffffff;
271
273
  --color-border-default: #3a3350;
272
274
  --color-border-strong: #4d4468;
273
275
  --color-border-subtle: #2d2740;
@@ -322,7 +324,6 @@
322
324
  --color-fg-muted: #141414;
323
325
  --color-fg-subtle: #1f1f1f;
324
326
  --color-fg-accent: #0842a0;
325
- --color-fg-on-brand: #ffffff;
326
327
  --color-border-default: #595959;
327
328
  --color-border-strong: #000000;
328
329
  --color-border-subtle: #767676;
@@ -377,7 +378,6 @@
377
378
  --color-fg-muted: #b4c0d0;
378
379
  --color-fg-subtle: #94a3b8;
379
380
  --color-fg-accent: #60a5fa;
380
- --color-fg-on-brand: #ffffff;
381
381
  --color-border-default: #334155;
382
382
  --color-border-strong: #475569;
383
383
  --color-border-subtle: #263449;
@@ -419,18 +419,6 @@
419
419
  },
420
420
  "themeable": true
421
421
  },
422
- {
423
- "name": "--color-fg-on-brand",
424
- "category": "color",
425
- "values": {
426
- "light": "#ffffff",
427
- "dark": "#ffffff",
428
- "midnight": "#ffffff",
429
- "twilight": "#ffffff",
430
- "contrast": "#ffffff"
431
- },
432
- "themeable": true
433
- },
434
422
  {
435
423
  "name": "--color-border-default",
436
424
  "category": "color",
@@ -751,6 +739,54 @@
751
739
  },
752
740
  "themeable": false
753
741
  },
742
+ {
743
+ "name": "--density-control-min-height",
744
+ "category": "density",
745
+ "values": {
746
+ "light": "2.25rem"
747
+ },
748
+ "themeable": false
749
+ },
750
+ {
751
+ "name": "--density-cell-pad-y",
752
+ "category": "density",
753
+ "values": {
754
+ "light": "0.75rem"
755
+ },
756
+ "themeable": false
757
+ },
758
+ {
759
+ "name": "--density-cell-pad-x",
760
+ "category": "density",
761
+ "values": {
762
+ "light": "0.75rem"
763
+ },
764
+ "themeable": false
765
+ },
766
+ {
767
+ "name": "--density-compact-control-min-height",
768
+ "category": "density",
769
+ "values": {
770
+ "light": "2rem"
771
+ },
772
+ "themeable": false
773
+ },
774
+ {
775
+ "name": "--density-compact-cell-pad-y",
776
+ "category": "density",
777
+ "values": {
778
+ "light": "0.5rem"
779
+ },
780
+ "themeable": false
781
+ },
782
+ {
783
+ "name": "--density-compact-cell-pad-x",
784
+ "category": "density",
785
+ "values": {
786
+ "light": "0.5rem"
787
+ },
788
+ "themeable": false
789
+ },
754
790
  {
755
791
  "name": "--radius-sm",
756
792
  "category": "radius",
@@ -1248,6 +1284,13 @@
1248
1284
  "bg": "--color-brand-primary-soft",
1249
1285
  "layer": "semantic"
1250
1286
  },
1287
+ {
1288
+ "id": "muted-on-soft",
1289
+ "label": "muted text on the accent wash",
1290
+ "fg": "--color-fg-muted",
1291
+ "bg": "--color-brand-primary-soft",
1292
+ "layer": "semantic"
1293
+ },
1251
1294
  {
1252
1295
  "id": "sidebar-text",
1253
1296
  "label": "sidebar text",
@@ -1261,6 +1304,113 @@
1261
1304
  "fg": "--color-sidebar-muted",
1262
1305
  "bg": "--color-sidebar-bg",
1263
1306
  "layer": "semantic"
1307
+ },
1308
+ {
1309
+ "id": "muted-on-tone-neutral",
1310
+ "label": "muted text on a neutral-toned row or card",
1311
+ "fg": "--color-fg-muted",
1312
+ "bg": "--color-neutral-100",
1313
+ "layer": "semantic"
1314
+ },
1315
+ {
1316
+ "id": "muted-on-tone-info",
1317
+ "label": "muted text on an info-toned row or card",
1318
+ "fg": "--color-fg-muted",
1319
+ "bg": "--color-status-info-soft",
1320
+ "layer": "semantic"
1321
+ },
1322
+ {
1323
+ "id": "muted-on-tone-success",
1324
+ "label": "muted text on a success-toned row or card",
1325
+ "fg": "--color-fg-muted",
1326
+ "bg": "--color-status-success-soft",
1327
+ "layer": "semantic"
1328
+ },
1329
+ {
1330
+ "id": "muted-on-tone-warning",
1331
+ "label": "muted text on a warning-toned row or card",
1332
+ "fg": "--color-fg-muted",
1333
+ "bg": "--color-status-warning-soft",
1334
+ "layer": "semantic"
1335
+ },
1336
+ {
1337
+ "id": "muted-on-tone-danger",
1338
+ "label": "muted text on a danger-toned row or card",
1339
+ "fg": "--color-fg-muted",
1340
+ "bg": "--color-status-danger-soft",
1341
+ "layer": "semantic"
1342
+ }
1343
+ ],
1344
+ "nonTextPairs": [
1345
+ {
1346
+ "id": "focus-ring-on-canvas",
1347
+ "label": "the focus indicator on the canvas",
1348
+ "fg": "--color-fg-accent",
1349
+ "bg": "--color-bg-canvas",
1350
+ "layer": "semantic"
1351
+ },
1352
+ {
1353
+ "id": "active-toggle-border",
1354
+ "label": "the border marking which layout toggle is active",
1355
+ "fg": "--color-fg-accent",
1356
+ "bg": "--color-neutral-100",
1357
+ "layer": "semantic"
1358
+ },
1359
+ {
1360
+ "id": "subtle-glyph-on-tone-neutral",
1361
+ "label": "an icon button's glyph on a neutral wash",
1362
+ "fg": "--color-fg-subtle",
1363
+ "bg": "--color-neutral-100",
1364
+ "layer": "semantic"
1365
+ },
1366
+ {
1367
+ "id": "subtle-glyph-on-tone-info",
1368
+ "label": "an icon button's glyph on an info row",
1369
+ "fg": "--color-fg-subtle",
1370
+ "bg": "--color-status-info-soft",
1371
+ "layer": "semantic"
1372
+ },
1373
+ {
1374
+ "id": "subtle-glyph-on-tone-success",
1375
+ "label": "an icon button's glyph on a success row",
1376
+ "fg": "--color-fg-subtle",
1377
+ "bg": "--color-status-success-soft",
1378
+ "layer": "semantic"
1379
+ },
1380
+ {
1381
+ "id": "subtle-glyph-on-tone-warning",
1382
+ "label": "an icon button's glyph on a warning row",
1383
+ "fg": "--color-fg-subtle",
1384
+ "bg": "--color-status-warning-soft",
1385
+ "layer": "semantic"
1386
+ },
1387
+ {
1388
+ "id": "subtle-glyph-on-tone-danger",
1389
+ "label": "an icon button's glyph on a danger row",
1390
+ "fg": "--color-fg-subtle",
1391
+ "bg": "--color-status-danger-soft",
1392
+ "layer": "semantic"
1393
+ },
1394
+ {
1395
+ "id": "subtle-glyph-on-focus-wash",
1396
+ "label": "an icon button's glyph on a focused clickable row",
1397
+ "fg": "--color-fg-subtle",
1398
+ "bg": "--color-brand-primary-soft",
1399
+ "layer": "semantic"
1400
+ },
1401
+ {
1402
+ "id": "control-boundary-on-surface",
1403
+ "label": "a control's outline on a card",
1404
+ "fg": "--color-neutral-300",
1405
+ "bg": "--color-neutral-0",
1406
+ "layer": "primitive"
1407
+ },
1408
+ {
1409
+ "id": "control-boundary-on-canvas",
1410
+ "label": "a control's outline on the canvas",
1411
+ "fg": "--color-neutral-300",
1412
+ "bg": "--color-neutral-50",
1413
+ "layer": "primitive"
1264
1414
  }
1265
1415
  ]
1266
1416
  }
@@ -132,10 +132,27 @@ describe("token manifest", () => {
132
132
  // The manifest is a claim about what is guaranteed; the gate is what guarantees it. If
133
133
  // the two lists could differ, the published claim would be unverified.
134
134
  expect(manifest.textPairs).toEqual(pairsSource.textPairs);
135
+ expect(manifest.nonTextPairs).toEqual(pairsSource.nonTextPairs);
136
+ });
137
+
138
+ it("publishes both sections, so a missing one cannot read as no requirement", () => {
139
+ // `nonTextPairs` reached the manifest by being added to the builder's literal, which is a
140
+ // line that can be deleted without any other test noticing: a consumer would then see only
141
+ // the text pairings and read the absence of a boundary pairing as "nothing is required
142
+ // here" rather than "held in a section you were not given". Both sections are named
143
+ // explicitly rather than derived, because deriving them from the source file is what the
144
+ // assertion above already does — this one is about the shape the package publishes.
145
+ expect(Array.isArray(manifest.textPairs)).toBe(true);
146
+ expect(Array.isArray(manifest.nonTextPairs)).toBe(true);
147
+ expect(manifest.nonTextPairs.length).toBeGreaterThan(0);
135
148
  });
136
149
 
137
150
  it("references only tokens that exist, in both directions of every pairing", () => {
138
- for (const pair of manifest.textPairs) {
151
+ // Both sections. A typo in a token name is the failure this catches, and it is the only
152
+ // check that catches it for a pairing naming a token the sheet declares nowhere — the
153
+ // contrast gate would report it as an undefined declaration, which reads as a sheet
154
+ // problem rather than as a pairing problem.
155
+ for (const pair of [...manifest.textPairs, ...manifest.nonTextPairs]) {
139
156
  expect(tokenByName.has(pair.fg), `${pair.id} fg ${pair.fg}`).toBe(true);
140
157
  expect(tokenByName.has(pair.bg), `${pair.id} bg ${pair.bg}`).toBe(true);
141
158
  }
package/token-pairs.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "Foreground/background pairings the framework renders as text, as data. Two consumers read this: tokens.contrast.test.js holds each pairing to WCAG 2.1 AA, and the generated token manifest publishes them so a theme editor or an agent can tell which tokens must stay legible against which. Decorative boundaries are deliberately absent — WCAG sets no ratio for a divider, and asserting one would teach the next reader to ignore this file. Names are CSS custom properties because that is the vocabulary a theme author writes and a manifest consumer reads.",
2
+ "$comment": "Token pairings the framework renders, as data, in two sections held to two different bars. `textPairs` are foreground/background pairs painted as TEXT and held to WCAG 2.1 AA for normal text; `nonTextPairs` are the visual boundaries and state indicators SC 1.4.11 asks 3:1 of — a focus indicator, the border that says which control is active, the outline of a control against its surface. Two consumers read both: tokens.contrast.test.js measures each pairing at its own bar, and the generated token manifest publishes them so a theme editor or an agent can tell which tokens must stay legible against which. A pair of token names appears in one section only: the text bar is the stricter of the two, so restating a text pairing under a non-text name would add a case that cannot fail unless the stricter one already has, and would overstate how much the ratchets cover. Purely decorative marks stay absent from both sections — WCAG sets no ratio for a divider or for an aria-hidden ornament, and asserting one would teach the next reader to ignore this file. Names are CSS custom properties because that is the vocabulary a theme author writes and a manifest consumer reads.",
3
3
  "textPairs": [
4
4
  {
5
5
  "id": "body-on-card",
@@ -120,6 +120,13 @@
120
120
  "bg": "--color-brand-primary-soft",
121
121
  "layer": "semantic"
122
122
  },
123
+ {
124
+ "id": "muted-on-soft",
125
+ "label": "muted text on the accent wash",
126
+ "fg": "--color-fg-muted",
127
+ "bg": "--color-brand-primary-soft",
128
+ "layer": "semantic"
129
+ },
123
130
  {
124
131
  "id": "sidebar-text",
125
132
  "label": "sidebar text",
@@ -133,6 +140,113 @@
133
140
  "fg": "--color-sidebar-muted",
134
141
  "bg": "--color-sidebar-bg",
135
142
  "layer": "semantic"
143
+ },
144
+ {
145
+ "id": "muted-on-tone-neutral",
146
+ "label": "muted text on a neutral-toned row or card",
147
+ "fg": "--color-fg-muted",
148
+ "bg": "--color-neutral-100",
149
+ "layer": "semantic"
150
+ },
151
+ {
152
+ "id": "muted-on-tone-info",
153
+ "label": "muted text on an info-toned row or card",
154
+ "fg": "--color-fg-muted",
155
+ "bg": "--color-status-info-soft",
156
+ "layer": "semantic"
157
+ },
158
+ {
159
+ "id": "muted-on-tone-success",
160
+ "label": "muted text on a success-toned row or card",
161
+ "fg": "--color-fg-muted",
162
+ "bg": "--color-status-success-soft",
163
+ "layer": "semantic"
164
+ },
165
+ {
166
+ "id": "muted-on-tone-warning",
167
+ "label": "muted text on a warning-toned row or card",
168
+ "fg": "--color-fg-muted",
169
+ "bg": "--color-status-warning-soft",
170
+ "layer": "semantic"
171
+ },
172
+ {
173
+ "id": "muted-on-tone-danger",
174
+ "label": "muted text on a danger-toned row or card",
175
+ "fg": "--color-fg-muted",
176
+ "bg": "--color-status-danger-soft",
177
+ "layer": "semantic"
178
+ }
179
+ ],
180
+ "nonTextPairs": [
181
+ {
182
+ "id": "focus-ring-on-canvas",
183
+ "label": "the focus indicator on the canvas",
184
+ "fg": "--color-fg-accent",
185
+ "bg": "--color-bg-canvas",
186
+ "layer": "semantic"
187
+ },
188
+ {
189
+ "id": "active-toggle-border",
190
+ "label": "the border marking which layout toggle is active",
191
+ "fg": "--color-fg-accent",
192
+ "bg": "--color-neutral-100",
193
+ "layer": "semantic"
194
+ },
195
+ {
196
+ "id": "subtle-glyph-on-tone-neutral",
197
+ "label": "an icon button's glyph on a neutral wash",
198
+ "fg": "--color-fg-subtle",
199
+ "bg": "--color-neutral-100",
200
+ "layer": "semantic"
201
+ },
202
+ {
203
+ "id": "subtle-glyph-on-tone-info",
204
+ "label": "an icon button's glyph on an info row",
205
+ "fg": "--color-fg-subtle",
206
+ "bg": "--color-status-info-soft",
207
+ "layer": "semantic"
208
+ },
209
+ {
210
+ "id": "subtle-glyph-on-tone-success",
211
+ "label": "an icon button's glyph on a success row",
212
+ "fg": "--color-fg-subtle",
213
+ "bg": "--color-status-success-soft",
214
+ "layer": "semantic"
215
+ },
216
+ {
217
+ "id": "subtle-glyph-on-tone-warning",
218
+ "label": "an icon button's glyph on a warning row",
219
+ "fg": "--color-fg-subtle",
220
+ "bg": "--color-status-warning-soft",
221
+ "layer": "semantic"
222
+ },
223
+ {
224
+ "id": "subtle-glyph-on-tone-danger",
225
+ "label": "an icon button's glyph on a danger row",
226
+ "fg": "--color-fg-subtle",
227
+ "bg": "--color-status-danger-soft",
228
+ "layer": "semantic"
229
+ },
230
+ {
231
+ "id": "subtle-glyph-on-focus-wash",
232
+ "label": "an icon button's glyph on a focused clickable row",
233
+ "fg": "--color-fg-subtle",
234
+ "bg": "--color-brand-primary-soft",
235
+ "layer": "semantic"
236
+ },
237
+ {
238
+ "id": "control-boundary-on-surface",
239
+ "label": "a control's outline on a card",
240
+ "fg": "--color-neutral-300",
241
+ "bg": "--color-neutral-0",
242
+ "layer": "primitive"
243
+ },
244
+ {
245
+ "id": "control-boundary-on-canvas",
246
+ "label": "a control's outline on the canvas",
247
+ "fg": "--color-neutral-300",
248
+ "bg": "--color-neutral-50",
249
+ "layer": "primitive"
136
250
  }
137
251
  ]
138
252
  }
@@ -40,8 +40,7 @@
40
40
  "default": { "value": "#000000" },
41
41
  "muted": { "value": "#141414" },
42
42
  "subtle": { "value": "#1f1f1f" },
43
- "accent": { "value": "#0842a0" },
44
- "onBrand": { "value": "#ffffff" }
43
+ "accent": { "value": "#0842a0" }
45
44
  },
46
45
  "border": {
47
46
  "default": { "value": "#595959" },
package/tokens.dark.json CHANGED
@@ -40,8 +40,7 @@
40
40
  "default": { "value": "#f1f5f9" },
41
41
  "muted": { "value": "#b4c0d0" },
42
42
  "subtle": { "value": "#94a3b8" },
43
- "accent": { "value": "#60a5fa" },
44
- "onBrand": { "value": "#ffffff" }
43
+ "accent": { "value": "#60a5fa" }
45
44
  },
46
45
  "border": {
47
46
  "default": { "value": "#334155" },
package/tokens.json CHANGED
@@ -40,8 +40,7 @@
40
40
  "default": { "value": "#0f172a" },
41
41
  "muted": { "value": "#475569" },
42
42
  "subtle": { "value": "#64748b" },
43
- "accent": { "value": "#1d4ed8" },
44
- "onBrand": { "value": "#ffffff" }
43
+ "accent": { "value": "#1d4ed8" }
45
44
  },
46
45
  "border": {
47
46
  "default": { "value": "#e2e8f0" },
@@ -88,6 +87,24 @@
88
87
  "16": { "value": "4rem" },
89
88
  "20": { "value": "5rem" }
90
89
  },
90
+ "density": {
91
+ "control": {
92
+ "minHeight": { "value": "2.25rem" }
93
+ },
94
+ "cell": {
95
+ "padY": { "value": "0.75rem" },
96
+ "padX": { "value": "0.75rem" }
97
+ },
98
+ "compact": {
99
+ "control": {
100
+ "minHeight": { "value": "2rem" }
101
+ },
102
+ "cell": {
103
+ "padY": { "value": "0.5rem" },
104
+ "padX": { "value": "0.5rem" }
105
+ }
106
+ }
107
+ },
91
108
  "radius": {
92
109
  "sm": { "value": "0.25rem" },
93
110
  "md": { "value": "0.5rem" },
@@ -40,8 +40,7 @@
40
40
  "default": { "value": "#f0f6fc" },
41
41
  "muted": { "value": "#9aa4b2" },
42
42
  "subtle": { "value": "#8b949e" },
43
- "accent": { "value": "#58a6ff" },
44
- "onBrand": { "value": "#ffffff" }
43
+ "accent": { "value": "#58a6ff" }
45
44
  },
46
45
  "border": {
47
46
  "default": { "value": "#30363d" },
@@ -40,8 +40,7 @@
40
40
  "default": { "value": "#f6f3fa" },
41
41
  "muted": { "value": "#b9aecd" },
42
42
  "subtle": { "value": "#a294bd" },
43
- "accent": { "value": "#a78bfa" },
44
- "onBrand": { "value": "#ffffff" }
43
+ "accent": { "value": "#a78bfa" }
45
44
  },
46
45
  "border": {
47
46
  "default": { "value": "#3a3350" },