actual-css 0.13.0 → 0.14.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +4 -4
  3. package/components.json +116 -10
  4. package/dist/actual.full.css +648 -77
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/navigation.json +2 -0
  8. package/docs/pages/components/alert.md +12 -2
  9. package/docs/pages/components/button.md +3 -2
  10. package/docs/pages/components/list.md +6 -3
  11. package/docs/pages/components/segmented.md +83 -0
  12. package/docs/pages/components/steps.md +4 -2
  13. package/docs/pages/enhancements/flyout.md +5 -0
  14. package/docs/pages/enhancements/tabs.md +7 -2
  15. package/docs/pages/examples/overview.md +3 -1
  16. package/docs/pages/forms/color-swatches.md +131 -0
  17. package/docs/pages/forms/combobox.md +2 -2
  18. package/docs/pages/forms/controls.md +6 -3
  19. package/docs/pages/forms/joined-controls.md +6 -2
  20. package/docs/pages/forms/overview.md +1 -0
  21. package/docs/pages/forms/switch.md +4 -2
  22. package/docs/pages/foundations/theming.md +1 -1
  23. package/docs/pages/foundations/tokens.md +54 -23
  24. package/docs/pages/guides/design-tools.md +13 -7
  25. package/docs/pages/guides/introduction.md +1 -1
  26. package/docs/pages/layout/scroll-snap.md +4 -2
  27. package/package.json +2 -2
  28. package/reserved-classes.json +3 -0
  29. package/src/cli/actual-css.js +3 -3
  30. package/src/css/components/alert.css +65 -14
  31. package/src/css/components/app-nav.css +17 -9
  32. package/src/css/components/badge.css +23 -9
  33. package/src/css/components/button.css +17 -2
  34. package/src/css/components/close.css +6 -3
  35. package/src/css/components/index.css +1 -0
  36. package/src/css/components/list.css +18 -3
  37. package/src/css/components/navbar.css +5 -2
  38. package/src/css/components/segmented.css +186 -0
  39. package/src/css/components/steps.css +6 -3
  40. package/src/css/components/tab.css +1 -1
  41. package/src/css/core/focus.css +3 -0
  42. package/src/css/core/tokens.css +20 -4
  43. package/src/css/core/variants.css +16 -5
  44. package/src/css/forms/color-swatch.css +159 -0
  45. package/src/css/forms/form-actions.css +9 -0
  46. package/src/css/forms/index.css +1 -0
  47. package/src/css/forms/native.css +50 -20
  48. package/src/css/forms/switch.css +5 -2
  49. package/src/css/layout/scroll-snap.css +30 -0
  50. package/src/css/layout/topbar.css +10 -0
  51. package/src/js/focus.js +16 -1
  52. package/src/js/surface.js +10 -0
  53. package/src/tooling/color.js +14 -1
  54. package/src/tooling/css-bundle.js +0 -5
  55. package/src/tooling/design-components.js +9 -5
  56. package/src/tooling/design-export.js +8 -0
  57. package/src/tooling/design-tokens.js +163 -43
  58. package/src/tooling/theme-contrast.js +151 -22
@@ -1,7 +1,8 @@
1
1
  /*
2
- * Design-tool token export: resolves a theme's tokens in headless Chrome and
3
- * writes them for Figma (DTCG files, one per mode) and Penpot (one token file
4
- * with its sets and themes).
2
+ * Design-tool token export: resolves a theme's tokens in headless Chrome into
3
+ * a target-neutral model, then writes them for Figma, Penpot, and a portable
4
+ * DTCG profile. Figma and Penpot get the compromises their importers need; the
5
+ * DTCG profile stays spec-conformant (correct $types, 6-digit `hex`).
5
6
  *
6
7
  * Values are read from the browser, never from the CSS text: tokens are built
7
8
  * with light-dark(), color-mix() and var() chains that only the browser
@@ -56,8 +57,9 @@ export const THEME_COLORS = [
56
57
  "backdrop-fill",
57
58
  ];
58
59
 
59
- /* Mode-independent tokens by role. The role tells a design tool where a value
60
- applies (Penpot token types); every role but font-weight is a length. */
60
+ /* Mode-independent tokens by role. The role drives the DTCG type (see
61
+ DTCG_TYPE) and the Penpot type; length roles plus font-weight are the ones
62
+ Figma and Penpot read, the rest are DTCG-only. */
61
63
  export const FOUNDATIONS = {
62
64
  spacing: [
63
65
  "space-10",
@@ -89,32 +91,23 @@ export const FOUNDATIONS = {
89
91
  "font-weight-semibold",
90
92
  "font-weight-bold",
91
93
  ],
94
+ "font-family": ["font-sans", "font-mono"],
95
+ duration: ["duration", "duration-fast", "duration-slow", "duration-spin", "duration-shimmer"],
96
+ "cubic-bezier": ["ease-enter", "ease-exit"],
97
+ number: ["line-height", "line-height-tight", "line-height-relaxed"],
92
98
  };
93
99
 
94
100
  /* Every custom property declared by the core token files is exported or
95
101
  listed here (tests/design-tokens.test.js), so a new token cannot silently
96
102
  stay out of the design tools. */
97
103
  export const NOT_EXPORTED = {
98
- "font stack; a design tool font token holds one family": ["font-sans", "font-mono"],
99
104
  "internal 0/1 weights selecting a control step; the steps are exported": [
100
105
  "control-step-sm",
101
106
  "control-step-md",
102
107
  "control-step-lg",
103
108
  ],
104
- "no design tool equivalent": [
109
+ "no DTCG type that keeps its meaning": [
105
110
  "font-width",
106
- "line-height",
107
- "line-height-tight",
108
- "line-height-relaxed",
109
- "shadow",
110
- "shadow-popout",
111
- "duration",
112
- "duration-fast",
113
- "duration-slow",
114
- "duration-spin",
115
- "duration-shimmer",
116
- "ease-enter",
117
- "ease-exit",
118
111
  "viewport-block",
119
112
  "viewport-inline",
120
113
  "z-sticky",
@@ -125,6 +118,7 @@ export const NOT_EXPORTED = {
125
118
  "icon-close",
126
119
  "icon-plus",
127
120
  ],
121
+ "composite DTCG types the export does not build yet": ["shadow", "shadow-popout"],
128
122
  "input of a derived value": [
129
123
  "backdrop-color",
130
124
  "backdrop-opacity",
@@ -142,6 +136,7 @@ export const NOT_EXPORTED = {
142
136
  "danger-soft-fg",
143
137
  "neutral-soft-fg",
144
138
  ],
139
+ "fallback-only state hook, read at the point of use": ["state-selected-text"],
145
140
  "internal or plumbing": [
146
141
  "density-space",
147
142
  "disabled-opacity",
@@ -152,10 +147,13 @@ export const NOT_EXPORTED = {
152
147
  ],
153
148
  };
154
149
 
150
+ // Length roles carry a px value plus the CSS source; every other role carries
151
+ // one scalar read as-is and parsed by role (see PARSE).
152
+ const LENGTH_ROLES = ["spacing", "radius", "border-width", "font-size", "size", "offset"];
153
+ const RAW_ROLES = ["font-family", "duration", "cubic-bezier", "number"];
154
+ const LENGTHS = LENGTH_ROLES.flatMap((role) => FOUNDATIONS[role]);
155
155
  const WEIGHTS = FOUNDATIONS["font-weight"];
156
- const LENGTHS = Object.entries(FOUNDATIONS).flatMap(([role, names]) =>
157
- role === "font-weight" ? [] : names,
158
- );
156
+ const RAWS = RAW_ROLES.flatMap((role) => FOUNDATIONS[role]);
159
157
  const ROLE_OF = new Map(
160
158
  Object.entries(FOUNDATIONS).flatMap(([role, names]) => names.map((n) => [n, role])),
161
159
  );
@@ -220,7 +218,7 @@ export async function coreTokenCss() {
220
218
  * what light-dark() resolves against. Colors go through color-mix(in srgb) so
221
219
  * every color syntax computes to the same `color(srgb r g b / a)` form.
222
220
  */
223
- function readIslands({ css, islands, colors, lengths, weights }) {
221
+ function readIslands({ css, islands, colors, lengths, weights, raws }) {
224
222
  const style = document.createElement("style");
225
223
  style.textContent = css;
226
224
  document.head.append(style);
@@ -236,7 +234,7 @@ function readIslands({ css, islands, colors, lengths, weights }) {
236
234
  document.body.append(island);
237
235
 
238
236
  const computed = getComputedStyle(probe);
239
- const values = { colors: {}, lengths: {}, weights: {}, missing: [] };
237
+ const values = { colors: {}, lengths: {}, weights: {}, raws: {}, missing: [] };
240
238
  const read = (name) => {
241
239
  const raw = computed.getPropertyValue(`--${name}`).trim();
242
240
  if (!raw) values.missing.push(name);
@@ -260,6 +258,10 @@ function readIslands({ css, islands, colors, lengths, weights }) {
260
258
  const raw = read(name);
261
259
  if (raw) values.weights[name] = Number(raw);
262
260
  }
261
+ for (const name of raws) {
262
+ const raw = read(name);
263
+ if (raw) values.raws[name] = raw;
264
+ }
263
265
  values.scheme = getComputedStyle(island).colorScheme;
264
266
  result.push(values);
265
267
  island.remove();
@@ -295,6 +297,7 @@ export async function resolveTokens({ css, theme = null, aliasSources = [] }) {
295
297
  colors: [],
296
298
  lengths: [],
297
299
  weights: [],
300
+ raws: [],
298
301
  });
299
302
  const schemes = declared.scheme.split(/\s+/).filter((s) => s === "light" || s === "dark");
300
303
  const islands = (schemes.length > 0 ? schemes : ["light"]).map((mode) => ({
@@ -309,6 +312,7 @@ export async function resolveTokens({ css, theme = null, aliasSources = [] }) {
309
312
  colors: THEME_COLORS,
310
313
  lengths: LENGTHS,
311
314
  weights: WEIGHTS,
315
+ raws: RAWS,
312
316
  });
313
317
  const missing = [...new Set(read.flatMap((values) => values.missing))];
314
318
  if (missing.length > 0) {
@@ -321,9 +325,13 @@ export async function resolveTokens({ css, theme = null, aliasSources = [] }) {
321
325
  Object.fromEntries(Object.entries(read[i].colors).map(([n, c]) => [n, parseSrgb(c)])),
322
326
  ]),
323
327
  );
324
- // Geometry does not follow the color scheme; the first mode is the reference.
325
- const [{ lengths, weights }] = read;
326
- return { islands, modes, lengths, weights, aliases: aliasCandidates(...aliasSources) };
328
+ // Geometry and the raw foundations do not follow the color scheme; the first
329
+ // mode is the reference.
330
+ const [{ lengths, weights, raws }] = read;
331
+ const extras = Object.fromEntries(
332
+ Object.entries(raws).map(([name, raw]) => [name, PARSE[ROLE_OF.get(name)](raw)]),
333
+ );
334
+ return { islands, modes, lengths, weights, extras, aliases: aliasCandidates(...aliasSources) };
327
335
  }
328
336
 
329
337
  export function parseSrgb(value) {
@@ -336,14 +344,60 @@ export function parseSrgb(value) {
336
344
  };
337
345
  }
338
346
 
347
+ /* Raw foundation readers. Each role maps to one parser, so a value the browser
348
+ reports in an unexpected shape fails loudly instead of exporting garbage. */
349
+ export function parseDuration(value) {
350
+ const match = value.match(/^([\d.]+)(ms|s)$/);
351
+ if (!match) throw new Error(`Unexpected duration: ${value}`);
352
+ return { value: Number(match[1]), unit: match[2] };
353
+ }
354
+
355
+ export function parseCubicBezier(value) {
356
+ const match = value.match(/^cubic-bezier\(([^)]+)\)$/);
357
+ const numbers = match?.[1].split(",").map((n) => Number(n.trim()));
358
+ if (numbers?.length !== 4 || numbers.some(Number.isNaN)) {
359
+ throw new Error(`Unexpected cubic-bezier: ${value}`);
360
+ }
361
+ return numbers;
362
+ }
363
+
364
+ export function parseFontFamily(value) {
365
+ return value
366
+ .split(",")
367
+ .map((family) => family.trim().replace(/^["']|["']$/g, ""))
368
+ .filter(Boolean);
369
+ }
370
+
371
+ export function parseNumber(value) {
372
+ const n = Number(value);
373
+ if (Number.isNaN(n)) throw new Error(`Unexpected number: ${value}`);
374
+ return n;
375
+ }
376
+
377
+ const PARSE = {
378
+ "font-family": parseFontFamily,
379
+ duration: parseDuration,
380
+ "cubic-bezier": parseCubicBezier,
381
+ number: parseNumber,
382
+ };
383
+
339
384
  const round = (n) => Math.round(n * 10000) / 10000;
340
385
 
386
+ const byte = (n) =>
387
+ Math.round(n * 255)
388
+ .toString(16)
389
+ .padStart(2, "0");
390
+
391
+ /* DTCG color `hex` is a 6-digit fallback: alpha is carried separately, and the
392
+ spec requires the hex itself to be 6 digits. */
393
+ export function hexRgbOf({ components }) {
394
+ return `#${components.map(byte).join("")}`;
395
+ }
396
+
397
+ /* The CSS-fidelity form: keeps alpha in the hex for Penpot and for comparing
398
+ colors across modes (two colors that share RGB but not alpha must differ). */
341
399
  export function hexOf({ components, alpha }) {
342
- const byte = (n) =>
343
- Math.round(n * 255)
344
- .toString(16)
345
- .padStart(2, "0");
346
- return `#${components.map(byte).join("")}${alpha < 1 ? byte(alpha) : ""}`;
400
+ return `${hexRgbOf({ components })}${alpha < 1 ? byte(alpha) : ""}`;
347
401
  }
348
402
 
349
403
  const colorsEqual = (a, b) =>
@@ -357,11 +411,12 @@ function aliasOf(name, valueSets, aliases, same) {
357
411
  }
358
412
 
359
413
  /*
360
- * The target-neutral model both writers read: per mode, each color is
361
- * `{ alias }` or `{ color, hex }`; each foundation carries its role and is
362
- * `{ alias }`, `{ px, css }` or `{ weight }`.
414
+ * The target-neutral model the writers read: per mode, each color is
415
+ * `{ alias }` or `{ color, hex }` (hex 6-digit, the DTCG form); each
416
+ * foundation carries its role and is `{ alias }`, `{ px, css }`, `{ weight }`
417
+ * or `{ value }` for the raw roles.
363
418
  */
364
- export function linkTokens({ modes, lengths, weights, aliases }) {
419
+ export function linkTokens({ modes, lengths, weights, extras = {}, aliases }) {
365
420
  const modeSets = Object.values(modes);
366
421
  const linkedModes = Object.fromEntries(
367
422
  Object.entries(modes).map(([mode, colors]) => [
@@ -369,7 +424,7 @@ export function linkTokens({ modes, lengths, weights, aliases }) {
369
424
  Object.fromEntries(
370
425
  Object.entries(colors).map(([name, color]) => {
371
426
  const alias = aliasOf(name, modeSets, aliases, colorsEqual);
372
- return [name, alias ? { alias } : { color, hex: hexOf(color) }];
427
+ return [name, alias ? { alias } : { color, hex: hexRgbOf(color) }];
373
428
  }),
374
429
  ),
375
430
  ]),
@@ -383,6 +438,9 @@ export function linkTokens({ modes, lengths, weights, aliases }) {
383
438
  for (const [name, weight] of Object.entries(weights)) {
384
439
  foundations[name] = { role: "font-weight", weight };
385
440
  }
441
+ for (const [name, value] of Object.entries(extras)) {
442
+ foundations[name] = { role: ROLE_OF.get(name), value };
443
+ }
386
444
  return { modes: linkedModes, foundations };
387
445
  }
388
446
 
@@ -411,13 +469,73 @@ export function toFigma({ modes, foundations }, recipes = {}) {
411
469
  }
412
470
 
413
471
  files.foundations = Object.fromEntries(
414
- Object.entries(foundations).map(([name, token]) => {
472
+ Object.entries(foundations).flatMap(([name, token]) => {
415
473
  // `number`, not DTCG `fontWeight`: Figma's import drops fontWeight
416
474
  // tokens, and a number variable is what binds to a text layer's weight.
417
- if (token.role === "font-weight") return [name, { $type: "number", $value: token.weight }];
475
+ if (token.role === "font-weight") {
476
+ return [[name, { $type: "number", $value: token.weight }]];
477
+ }
478
+ // Figma reads dimensions only; the raw roles stay in the DTCG profile.
479
+ if (!LENGTH_ROLES.includes(token.role)) return [];
418
480
  const $value = token.alias ? `{${token.alias}}` : { value: token.px, unit: "px" };
419
481
  const doc = { $type: "dimension", $value };
420
482
  if (token.css) doc.$extensions = { [EXTENSION]: { css: token.css } };
483
+ return [[name, doc]];
484
+ }),
485
+ );
486
+ return files;
487
+ }
488
+
489
+ /* DTCG `$type` per role. Figma keeps its own adaptations (a font weight as a
490
+ number); the neutral profile uses the type the spec defines. */
491
+ const DTCG_TYPE = {
492
+ spacing: "dimension",
493
+ radius: "dimension",
494
+ "border-width": "dimension",
495
+ "font-size": "dimension",
496
+ size: "dimension",
497
+ offset: "dimension",
498
+ "font-weight": "fontWeight",
499
+ "font-family": "fontFamily",
500
+ duration: "duration",
501
+ "cubic-bezier": "cubicBezier",
502
+ number: "number",
503
+ };
504
+
505
+ function dtcgValue(token) {
506
+ if (token.alias) return `{${token.alias}}`;
507
+ if (token.role === "font-weight") return token.weight;
508
+ if (LENGTH_ROLES.includes(token.role)) return { value: token.px, unit: "px" };
509
+ return token.value;
510
+ }
511
+
512
+ /*
513
+ * DTCG: the portable profile. Same file split as Figma, but every foundation
514
+ * keeps its spec type (`fontWeight`, `duration`, `cubicBezier`, `fontFamily`,
515
+ * `number`), colors carry a 6-digit `hex` with alpha separate, and dimensions
516
+ * keep their CSS value in `$extensions`. No tool adaptation: any DTCG reader
517
+ * can import it.
518
+ */
519
+ export function toDtcg({ modes, foundations }, recipes = {}) {
520
+ const files = {};
521
+ for (const [mode, colors] of Object.entries(modes)) {
522
+ files[mode] = Object.fromEntries(
523
+ Object.entries({ ...colors, ...recipes[mode] }).map(([name, token]) => [
524
+ name,
525
+ {
526
+ $type: "color",
527
+ $value: token.alias
528
+ ? `{${token.alias}}`
529
+ : { colorSpace: "srgb", ...token.color, hex: token.hex },
530
+ },
531
+ ]),
532
+ );
533
+ }
534
+
535
+ files.foundations = Object.fromEntries(
536
+ Object.entries(foundations).map(([name, token]) => {
537
+ const doc = { $type: DTCG_TYPE[token.role], $value: dtcgValue(token) };
538
+ if (token.css) doc.$extensions = { [EXTENSION]: { css: token.css } };
421
539
  return [name, doc];
422
540
  }),
423
541
  );
@@ -449,10 +567,12 @@ const PENPOT_THEME_GROUP = "Mode";
449
567
  export function toPenpot({ modes, foundations }, recipes = {}) {
450
568
  const doc = {
451
569
  foundations: Object.fromEntries(
452
- Object.entries(foundations).map(([name, token]) => {
570
+ Object.entries(foundations).flatMap(([name, token]) => {
453
571
  const $type = PENPOT_TYPES[token.role];
454
- if (token.alias) return [name, { $type, $value: `{${token.alias}}` }];
455
- return [name, { $type, $value: token.weight ?? `${token.px}px` }];
572
+ // Penpot reads the roles it types; the DTCG-only roles stay out.
573
+ if (!$type) return [];
574
+ if (token.alias) return [[name, { $type, $value: `{${token.alias}}` }]];
575
+ return [[name, { $type, $value: token.weight ?? `${token.px}px` }]];
456
576
  }),
457
577
  ),
458
578
  };
@@ -461,7 +581,7 @@ export function toPenpot({ modes, foundations }, recipes = {}) {
461
581
  Object.fromEntries(
462
582
  Object.entries(colors).map(([name, token]) => [
463
583
  name,
464
- { $type: "color", $value: token.alias ? `{${token.alias}}` : token.hex },
584
+ { $type: "color", $value: token.alias ? `{${token.alias}}` : hexOf(token.color) },
465
585
  ]),
466
586
  );
467
587
  const schemes = Object.keys(modes);
@@ -5,17 +5,25 @@
5
5
  * (`bun run report:theme-contrast`).
6
6
  *
7
7
  * Measured per island (one per color scheme the theme declares):
8
- * - soft ink against the resting and the hovered soft fill (4.5:1)
8
+ * - soft ink against the resting and the hovered soft fill (4.5:1), on each
9
+ * surface a soft element sits on
10
+ * - the solid pairs, -fg on the intent fill (4.5:1); a fill painted by a
11
+ * background-image reads n/a
12
+ * - selected ink on those surfaces: the current nav link at rest and on
13
+ * hover, the selected tab and the current step number (4.5:1), and the
14
+ * current step's ring (3:1)
9
15
  * - the focus line against --surface and --surface-solid (3:1)
10
16
  * - the invalid-field focus line against the field's own background (3:1)
11
17
  * - --surface-solid-fg on --surface-solid (4.5:1)
12
- * A pair involving a translucent color has no ratio until it is composited
13
- * over its backdrop, so it reads n/a instead of a number.
18
+ * Soft fills are translucent, so each is composited over the surface under it
19
+ * before it is measured: the pair is what the page displays, not the token.
20
+ * A surface that is itself translucent (a glass theme) has no defined
21
+ * backdrop, so its pairs read n/a instead of a number.
14
22
  *
15
23
  * Bun only: Bun.WebView drives the browser.
16
24
  */
17
25
  import { fileURLToPath } from "node:url";
18
- import { contrast, RASTERIZE } from "./color.js";
26
+ import { composite, contrast, RASTERIZE } from "./color.js";
19
27
  import { inlineImports } from "./css-bundle.js";
20
28
  import { themeNameOf } from "./design-tokens.js";
21
29
 
@@ -23,6 +31,11 @@ const FRAMEWORK = fileURLToPath(new URL("../css/actual.full.css", import.meta.ur
23
31
 
24
32
  export const INTENTS = ["primary", "secondary", "success", "warning", "danger", "neutral"];
25
33
 
34
+ /* The surfaces a soft badge or button is expected to sit on. --surface-subtle
35
+ is usually the binding one: a translucent fill over it ends darker in light
36
+ and lighter in dark than over --surface. */
37
+ export const SURFACES = ["surface", "surface-raised", "surface-subtle"];
38
+
26
39
  /* Badges state .soft explicitly: a theme may fill badges by default
27
40
  (bootstrap-v6), which would measure a solid pair against the soft hover.
28
41
  The theme name is read from a file by a regex, so it never enters this
@@ -30,9 +43,23 @@ export const INTENTS = ["primary", "secondary", "success", "warning", "danger",
30
43
  "light" or "dark". */
31
44
  function islandMarkup(id, scheme) {
32
45
  const style = scheme ? ` style="color-scheme: ${scheme}"` : "";
46
+ const softPairs = SURFACES.map(
47
+ (surface) => `<div data-surface="${surface}" style="background: var(--${surface})">
48
+ ${INTENTS.map((i) => `<span class="badge soft ${i}" data-badge="${i}">t</span>`).join("")}
49
+ ${INTENTS.map((i) => `<button class="btn soft ${i}" data-hover="${i}" type="button">t</button>`).join("")}
50
+ <nav class="navbar" aria-label="t">
51
+ <a class="nav-link" href="#t" aria-current="page" data-selected="nav">t</a>
52
+ <a class="nav-link" href="#t" aria-current="page" data-selected="nav-hover">t</a>
53
+ </nav>
54
+ <div class="tabs" role="tablist">
55
+ <button class="tab" role="tab" aria-selected="true" type="button" data-selected="tab">t</button>
56
+ </div>
57
+ <ol class="steps"><li aria-current="step" data-selected="step"><span class="step-label">t</span></li></ol>
58
+ </div>`,
59
+ ).join("");
33
60
  return `<div${style} id="${id}">
34
- ${INTENTS.map((i) => `<span class="badge soft ${i}" data-badge="${i}">t</span>`).join("")}
35
- ${INTENTS.map((i) => `<button class="btn soft ${i}" data-hover="${i}" type="button">t</button>`).join("")}
61
+ ${softPairs}
62
+ ${INTENTS.map((i) => `<button class="btn solid ${i}" data-solid="${i}" type="button">t</button>`).join("")}
36
63
  <span data-pair="focus-surface" style="color: var(--focus); background: var(--surface)"></span>
37
64
  <span data-pair="focus-solid" style="color: var(--focus); background: var(--surface-solid)"></span>
38
65
  <span data-pair="inverse" style="color: var(--surface-solid-fg); background: var(--surface-solid)"></span>
@@ -91,7 +118,7 @@ export async function measureContrast({ css, themes, label = "default" }) {
91
118
  const { root } = await view.cdp("DOM.getDocument");
92
119
  const { nodeIds } = await view.cdp("DOM.querySelectorAll", {
93
120
  nodeId: root.nodeId,
94
- selector: "[data-hover]",
121
+ selector: '[data-hover], [data-selected="nav-hover"]',
95
122
  });
96
123
  for (const nodeId of nodeIds) {
97
124
  await view.cdp("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: ["hover"] });
@@ -109,30 +136,101 @@ export async function measureContrast({ css, themes, label = "default" }) {
109
136
  const fg = el.dataset.pair === "invalid-field" ? cs(el).borderBlockEndColor : cs(el).color;
110
137
  pairs[el.dataset.pair] = { fg: rgba(fg), bg: rgba(cs(el).backgroundColor) };
111
138
  }
139
+ // A solid fill painted by a background-image (a gradient theme) has no
140
+ // single color to measure: it is reported, not sampled.
141
+ const solid = {};
142
+ for (const el of island.querySelectorAll("[data-solid]")) {
143
+ solid[el.dataset.solid] =
144
+ cs(el).backgroundImage === "none"
145
+ ? { fg: rgba(cs(el).color), bg: rgba(cs(el).backgroundColor) }
146
+ : null;
147
+ }
112
148
  const soft = {};
113
- for (const el of island.querySelectorAll("[data-badge]")) {
114
- const hover = island.querySelector('[data-hover="' + el.dataset.badge + '"]');
115
- soft[el.dataset.badge] = {
116
- fg: rgba(cs(el).color),
117
- rest: rgba(cs(el).backgroundColor),
118
- hover: rgba(cs(hover).backgroundColor),
149
+ // Fills from the surface down to the element, outermost first: the stack
150
+ // the page composites under a selected ink.
151
+ const layers = (surface, el) => {
152
+ const chain = [];
153
+ for (let n = el; n !== surface.parentElement; n = n.parentElement) chain.unshift(n);
154
+ return chain.map((n) => rgba(cs(n).backgroundColor));
155
+ };
156
+ for (const surface of island.querySelectorAll("[data-surface]")) {
157
+ const fills = {};
158
+ for (const el of surface.querySelectorAll("[data-badge]")) {
159
+ const hover = surface.querySelector('[data-hover="' + el.dataset.badge + '"]');
160
+ fills[el.dataset.badge] = {
161
+ fg: rgba(cs(el).color),
162
+ rest: rgba(cs(el).backgroundColor),
163
+ hover: rgba(cs(hover).backgroundColor),
164
+ };
165
+ }
166
+ const selected = {};
167
+ for (const el of surface.querySelectorAll("[data-selected]")) {
168
+ const kind = el.dataset.selected;
169
+ if (kind === "step") {
170
+ // The number and ring live on the marker pseudo; the ring reads
171
+ // against what is under the step, the number against the marker.
172
+ const marker = getComputedStyle(el, "::before");
173
+ const under = layers(surface, el);
174
+ selected[kind] = {
175
+ layers: [...under, rgba(marker.backgroundColor)],
176
+ fg: rgba(marker.color),
177
+ ring: { layers: under, fg: rgba(marker.borderTopColor) },
178
+ };
179
+ } else {
180
+ selected[kind] = { layers: layers(surface, el), fg: rgba(cs(el).color) };
181
+ }
182
+ }
183
+ soft[surface.dataset.surface] = {
184
+ backdrop: rgba(cs(surface).backgroundColor),
185
+ fills,
186
+ selected,
119
187
  };
120
188
  }
121
- return { pairs, soft };
189
+ return { pairs, soft, solid };
122
190
  });
123
191
  })()`);
124
192
 
125
193
  return islands.map(({ theme, scheme }, i) => {
126
- const { pairs, soft } = colors[i];
194
+ const { pairs, soft, solid } = colors[i];
127
195
  const pair = (key) => ratio(pairs[key].fg, pairs[key].bg);
196
+ // Fill over the surface, then ink over the fill: the colors the page shows.
197
+ const shown = (fg, fill, backdrop) => {
198
+ const bg = composite(fill, backdrop);
199
+ return ratio(composite(fg, bg), bg);
200
+ };
201
+ const stacked = ({ layers, fg }) => {
202
+ const [base, ...rest] = layers;
203
+ const bg = rest.reduce((under, fill) => composite(fill, under), base);
204
+ return ratio(composite(fg, bg), bg);
205
+ };
128
206
  return {
129
207
  theme,
130
208
  scheme,
131
- soft: INTENTS.map((intent) => ({
209
+ solid: INTENTS.map((intent) => ({
132
210
  intent,
133
- rest: ratio(soft[intent].fg, soft[intent].rest),
134
- hover: ratio(soft[intent].fg, soft[intent].hover),
211
+ value: solid[intent] ? ratio(solid[intent].fg, solid[intent].bg) : null,
135
212
  })),
213
+ // Selected ink on a regular surface (--state-selected-text): text needs
214
+ // 4.5:1, the current step's ring is a non-text accent at 3:1.
215
+ selected: SURFACES.flatMap((surface) => {
216
+ const { selected } = soft[surface];
217
+ return [
218
+ { kind: "nav link", surface, min: 4.5, value: stacked(selected.nav) },
219
+ { kind: "nav link hover", surface, min: 4.5, value: stacked(selected["nav-hover"]) },
220
+ { kind: "tab", surface, min: 4.5, value: stacked(selected.tab) },
221
+ { kind: "step number", surface, min: 4.5, value: stacked(selected.step) },
222
+ { kind: "step ring", surface, min: 3, value: stacked(selected.step.ring) },
223
+ ];
224
+ }),
225
+ soft: SURFACES.flatMap((surface) => {
226
+ const { backdrop, fills } = soft[surface];
227
+ return INTENTS.map((intent) => ({
228
+ intent,
229
+ surface,
230
+ rest: shown(fills[intent].fg, fills[intent].rest, backdrop),
231
+ hover: shown(fills[intent].fg, fills[intent].hover, backdrop),
232
+ }));
233
+ }),
136
234
  focusSurface: pair("focus-surface"),
137
235
  focusSolid: pair("focus-solid"),
138
236
  invalidFocus: pair("invalid-field"),
@@ -167,15 +265,46 @@ export function formatContrast(rows) {
167
265
  const flag = (cells, min) => (cells.some((c) => c.miss) ? ` <-- under ${min}` : "");
168
266
  const lines = [];
169
267
 
170
- lines.push("Soft pair contrast (ink vs resting / hovered fill), needs 4.5:1.", "");
171
- lines.push(`${"theme".padEnd(width)} scheme intent rest hover`);
268
+ lines.push(
269
+ "Soft pair contrast (ink vs resting / hovered fill, composited on each surface), needs 4.5:1.",
270
+ "",
271
+ );
272
+ lines.push(`${"theme".padEnd(width)} scheme surface intent rest hover`);
172
273
  for (const row of rows) {
173
- for (const { intent, rest, hover } of row.soft) {
274
+ for (const { intent, surface, rest, hover } of row.soft) {
174
275
  // Both states gate: the hovered fill is usually the closer one, but a
175
276
  // theme can invert that, and a 1:1 resting pair once passed silently.
176
277
  const cells = [cell(rest, 4.5), cell(hover, 4.5)];
177
278
  lines.push(
178
- `${head(row)} ${intent.padEnd(9)} ${cells[0].text.padStart(8)} ${cells[1].text.padStart(8)}${flag(cells, 4.5)}`,
279
+ `${head(row)} ${surface.padEnd(15)} ${intent.padEnd(9)} ${cells[0].text.padStart(8)} ${cells[1].text.padStart(8)}${flag(cells, 4.5)}`,
280
+ );
281
+ }
282
+ }
283
+
284
+ lines.push(
285
+ "",
286
+ "Solid pair contrast (-fg on the intent fill), needs 4.5:1. n/a: a gradient fill, not sampled.",
287
+ "",
288
+ );
289
+ lines.push(`${"theme".padEnd(width)} scheme intent ratio`);
290
+ for (const row of rows) {
291
+ for (const { intent, value } of row.solid ?? []) {
292
+ const c = cell(value, 4.5);
293
+ lines.push(`${head(row)} ${intent.padEnd(9)} ${c.text.padStart(8)}${flag([c], 4.5)}`);
294
+ }
295
+ }
296
+
297
+ lines.push(
298
+ "",
299
+ "Selected ink on a surface (--state-selected-text), text 4.5:1, step ring 3:1.",
300
+ "",
301
+ );
302
+ lines.push(`${"theme".padEnd(width)} scheme surface selected ratio`);
303
+ for (const row of rows) {
304
+ for (const { kind, surface, min, value } of row.selected ?? []) {
305
+ const c = cell(value, min);
306
+ lines.push(
307
+ `${head(row)} ${surface.padEnd(15)} ${kind.padEnd(15)} ${c.text.padStart(8)}${flag([c], min)}`,
179
308
  );
180
309
  }
181
310
  }