actual-css 0.11.1 → 0.12.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 (80) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +26 -6
  3. package/components.json +4 -16
  4. package/dist/actual.full.css +296 -267
  5. package/dist/actual.full.min.css +1 -1
  6. package/docs/brand/favicon.svg +5 -0
  7. package/docs/brand/lockup.svg +12 -0
  8. package/docs/brand/logo-dark.svg +9 -0
  9. package/docs/brand/logo-light.svg +9 -0
  10. package/docs/brand/logo.svg +11 -0
  11. package/docs/brand/mark.svg +11 -0
  12. package/docs/navigation.json +3 -2
  13. package/docs/pages/components/alert.md +2 -10
  14. package/docs/pages/components/badge.md +3 -9
  15. package/docs/pages/components/card.md +31 -2
  16. package/docs/pages/components/dialog.md +121 -33
  17. package/docs/pages/components/drawer.md +2 -2
  18. package/docs/pages/components/navbar.md +8 -9
  19. package/docs/pages/components/table.md +3 -3
  20. package/docs/pages/enhancements/flyout.md +2 -2
  21. package/docs/pages/enhancements/scrollspy.md +1 -1
  22. package/docs/pages/enhancements/status-bar.md +2 -2
  23. package/docs/pages/examples/overview.md +2 -1
  24. package/docs/pages/forms/date-picker.md +90 -0
  25. package/docs/pages/forms/detached-actions.md +37 -30
  26. package/docs/pages/forms/form-actions.md +61 -28
  27. package/docs/pages/forms/input-policy.md +1 -1
  28. package/docs/pages/forms/overview.md +6 -5
  29. package/docs/pages/forms/sticky-actions.md +85 -83
  30. package/docs/pages/forms/validation.md +5 -5
  31. package/docs/pages/foundations/philosophy.md +5 -3
  32. package/docs/pages/foundations/theming.md +15 -4
  33. package/docs/pages/foundations/tokens.md +50 -33
  34. package/docs/pages/foundations/typography.md +4 -7
  35. package/docs/pages/guides/bootstrap.md +2 -2
  36. package/docs/pages/guides/design-tools.md +2 -2
  37. package/docs/pages/guides/integrating-actual-css.md +5 -1
  38. package/docs/pages/guides/introduction.md +3 -4
  39. package/docs/pages/guides/modular-import.md +3 -0
  40. package/docs/pages/guides/progressive-enhancement.md +2 -2
  41. package/docs/pages/guides/tailwind.md +70 -0
  42. package/docs/pages/guides/upgrading.md +74 -0
  43. package/docs/pages/layout/cluster.md +28 -2
  44. package/docs/pages/layout/overview.md +1 -1
  45. package/docs/pages/layout/stack.md +21 -16
  46. package/docs/pages/layout/topbar.md +1 -1
  47. package/docs/pages/patterns/navigation-list.md +16 -46
  48. package/docs/pages/patterns/overview.md +3 -4
  49. package/docs/pages/utilities/flex.md +30 -20
  50. package/docs/pages/utilities/sizing-wrapping.md +1 -1
  51. package/docs/pages/utilities/spacing.md +4 -3
  52. package/package.json +2 -2
  53. package/reserved-classes.json +3 -3
  54. package/skills/actual-css/references/enhancements.md +10 -7
  55. package/src/cli/actual-css.js +39 -13
  56. package/src/css/components/avatar.css +16 -9
  57. package/src/css/components/badge.css +1 -5
  58. package/src/css/components/button.css +3 -9
  59. package/src/css/components/card.css +18 -10
  60. package/src/css/components/chat.css +4 -5
  61. package/src/css/components/drawer.css +19 -3
  62. package/src/css/components/flyout.css +3 -0
  63. package/src/css/components/index.css +0 -1
  64. package/src/css/components/modal.css +96 -124
  65. package/src/css/components/navbar.css +14 -25
  66. package/src/css/core/base.css +1 -1
  67. package/src/css/core/reset.css +2 -1
  68. package/src/css/core/theme.css +4 -0
  69. package/src/css/core/tokens.css +28 -10
  70. package/src/css/core/variants.css +41 -14
  71. package/src/css/forms/form-actions.css +15 -10
  72. package/src/css/layout/cluster.css +11 -0
  73. package/src/css/layout/topbar.css +12 -6
  74. package/src/css/utilities/base.css +5 -0
  75. package/src/css/utilities/extra.css +3 -7
  76. package/src/tooling/color.js +65 -0
  77. package/src/tooling/design-tokens.js +9 -1
  78. package/src/tooling/theme-contrast.js +209 -0
  79. package/docs/pages/patterns/actions.md +0 -52
  80. package/src/css/components/actions.css +0 -27
@@ -11,17 +11,21 @@
11
11
  * components/badge.css — .badge default soft behind explicit --ui-*
12
12
  * components/alert.css — .alert default soft behind explicit --ui-*
13
13
  *
14
- * .compact/.spacious set inherited density tokens: spacing rhythm and geometry
15
- * only, plus a slight typographic width bonus. Density never changes font size
16
- * or icon size. Controls consume --control-size; non-control components may
14
+ * Three axes, each predictable on its own:
15
+ * .sm/.lg size — typography + geometry, local to a component
16
+ * .compact/.spacious density — rhythm + geometry, inherited by a region
17
+ * .tight/.loose rhythm — --gap only, inherited by a region
18
+ *
19
+ * Density never touches typography: no font size, icon size or font width.
20
+ * A width bonus through font-stretch only showed on typefaces with a wdth axis,
21
+ * so .compact meant something different per OS and font; a condensed table is
22
+ * a typographic decision for the application. Controls consume --control-size
23
+ * and --control-pad-x, so a compact control is shorter and narrower (a height
24
+ * change alone read as the same control squashed); non-control components may
17
25
  * consume --density-* when the context should affect their local rhythm.
18
- * .compact applies --font-width-dense through font-stretch (a variable wdth
19
- * axis when available, otherwise normal width-face matching); layout must
20
- * never depend on the narrowing being available. .spacious restores
21
- * --font-width, so a spacious island inside a compact context returns to the
22
- * normal width exactly as it restores its own gap, spacing, and control size.
23
- * Themes whose typeface has no useful width axis opt out with
24
- * --font-width-dense: 100%.
26
+ *
27
+ * .tight/.loose reuse the density --gap steps, so a tight row and a compact
28
+ * one share one rhythm.
25
29
  *
26
30
  * .sm/.lg are local size roles, not shared measurements. Shared control
27
31
  * participants below map both typography and geometry; components with
@@ -60,18 +64,35 @@
60
64
  --ui-border: var(--border);
61
65
  }
62
66
 
67
+ /* Each boundary selects a control step twice: the values, resolved here, and
68
+ the 0/1 step weights a nested [data-theme] island rebuilds them from
69
+ (tokens.css). */
63
70
  .compact {
64
71
  --gap: var(--space-20);
65
72
  --density-space: var(--space-20);
66
73
  --control-size: var(--control-size-sm);
67
- font-stretch: var(--font-width-dense);
74
+ --control-pad-x: var(--control-pad-x-sm);
75
+ --control-step-sm: 1;
76
+ --control-step-md: 0;
77
+ --control-step-lg: 0;
68
78
  }
69
79
 
70
80
  .spacious {
71
81
  --gap: var(--space-50);
72
82
  --density-space: var(--space-50);
73
83
  --control-size: var(--control-size-lg);
74
- font-stretch: var(--font-width);
84
+ --control-pad-x: var(--control-pad-x-lg);
85
+ --control-step-sm: 0;
86
+ --control-step-md: 0;
87
+ --control-step-lg: 1;
88
+ }
89
+
90
+ .tight {
91
+ --gap: var(--space-20);
92
+ }
93
+
94
+ .loose {
95
+ --gap: var(--space-50);
75
96
  }
76
97
 
77
98
  /* Shared control scale. Wrappers are listed when their descendants form one
@@ -100,7 +121,10 @@
100
121
  ).sm {
101
122
  --control-size: var(--control-size-sm);
102
123
  --control-font-size: var(--font-size-sm);
103
- --control-pad-x: var(--font-size-sm);
124
+ --control-pad-x: var(--control-pad-x-sm);
125
+ --control-step-sm: 1;
126
+ --control-step-md: 0;
127
+ --control-step-lg: 0;
104
128
  }
105
129
 
106
130
  :where(
@@ -124,7 +148,10 @@
124
148
  ).lg {
125
149
  --control-size: var(--control-size-lg);
126
150
  --control-font-size: var(--font-size-lg);
127
- --control-pad-x: var(--font-size-lg);
151
+ --control-pad-x: var(--control-pad-x-lg);
152
+ --control-step-sm: 0;
153
+ --control-step-md: 0;
154
+ --control-step-lg: 1;
128
155
  }
129
156
 
130
157
  /* Modern color enhancement. Baseline variant values above remain usable
@@ -1,30 +1,35 @@
1
1
  /*
2
- * Form actions — bottom form bar with sticky variant.
2
+ * Form actions — the role of a .cluster action row that closes a form.
3
3
  *
4
4
  * Docs: docs/pages/forms/form-actions.md
5
5
  *
6
- * A flex cluster of buttons/actions that closes a form. The sticky variant
7
- * docks to the viewport bottom with a translucent blur where supported; at
8
- * rest it stays visually neutral so it does not double as a separator.
6
+ * Markup: `<div class="cluster form-actions">`. The cluster owns the row
7
+ * (flex, wrap, gap); this role owns the form-specific alignment policy and the
8
+ * opt-in sticky variant. The distance to the fields above belongs to the
9
+ * parent layout (.stack on the form), never to a margin here: a margin would
10
+ * add to a stack's gap. The sticky variant docks to the viewport bottom with a
11
+ * translucent blur where supported; at rest it stays visually neutral so it
12
+ * does not double as a separator.
9
13
  *
10
14
  * Public hooks:
11
15
  * --form-actions-align
12
16
  * --form-actions-justify
13
- * --form-actions-margin-block-start
14
17
  *
15
18
  * Internal:
16
19
  * --form-actions-sticky-block-offset (owned by .form-actions.sticky)
17
20
  * --form-actions-sticky-inline-offset (owned by .form-actions.sticky)
18
21
  * --form-actions-sticky-padding (owned by .form-actions.sticky)
19
22
  */
23
+
24
+ /* The policy hooks write the properties themselves rather than relaying
25
+ --cluster-*: a relay would inherit into any .cluster nested in the row.
26
+ These are equal-specificity overrides of .cluster, so they win only because
27
+ forms load after layout in the official bundle (tests/css-audit.test.js
28
+ guards the order); utilities load later and keep the author's last word. On
29
+ this row --cluster-justify/--cluster-align have no effect. */
20
30
  .form-actions {
21
- display: flex;
22
- flex-direction: row;
23
- flex-wrap: wrap;
24
- gap: var(--gap);
25
31
  align-items: var(--form-actions-align, center);
26
32
  justify-content: var(--form-actions-justify, flex-start);
27
- margin-block-start: var(--form-actions-margin-block-start, var(--space-50));
28
33
  }
29
34
 
30
35
  /* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
@@ -9,6 +9,7 @@
9
9
  * --cluster-wrap
10
10
  *
11
11
  * Child contract: direct-child margins are reset, spacing comes from --gap.
12
+ * A ul/ol/menu keeps its semantics but loses list chrome (see below).
12
13
  */
13
14
  .cluster {
14
15
  display: flex;
@@ -22,3 +23,13 @@
22
23
  .cluster > * {
23
24
  margin: 0;
24
25
  }
26
+
27
+ /* Horizontal, wrapping layout is incompatible with list markers and native
28
+ indentation: a ul/ol/menu applied with .cluster stays a list in the DOM, but
29
+ its visual chrome would be accidental. .stack intentionally leaves this
30
+ alone — a vertical list can keep its numbers. */
31
+ :where(ul, ol, menu).cluster {
32
+ margin: 0;
33
+ padding: 0;
34
+ list-style: none;
35
+ }
@@ -1,8 +1,9 @@
1
1
  /*
2
2
  * Sticky, frosted top bar shell for app/dashboard shells (persistent
3
3
  * sidebar + topbar layouts). Owns positioning, stacking, and the frosted
4
- * background only — row content (search, breadcrumb, actions) stays local
5
- * to the page.
4
+ * background; it also lays its children out as a default row that a layout
5
+ * primitive on the same element (such as .cluster) replaces. Row content
6
+ * (search, breadcrumb, actions) stays local to the page.
6
7
  *
7
8
  * Docs: docs/pages/layout/topbar.md
8
9
  *
@@ -16,16 +17,21 @@
16
17
  inset-block-start: 0;
17
18
  z-index: var(--z-sticky);
18
19
  isolation: isolate;
19
- display: flex;
20
- flex-direction: row;
21
- align-items: center;
22
- gap: var(--gap);
23
20
  padding-block: var(--space-30);
24
21
  padding-inline: var(--space-40);
25
22
  border-block-end: var(--border-width) solid var(--border);
26
23
  background: var(--surface);
27
24
  }
28
25
 
26
+ /* The row is a default at zero specificity: .topbar and the primitives share
27
+ layout/, so at class weight their file order alone would pick the winner. */
28
+ :where(.topbar) {
29
+ display: flex;
30
+ flex-direction: row;
31
+ align-items: center;
32
+ gap: var(--gap);
33
+ }
34
+
29
35
  /* Both conditions are required: the translucent background contains var(),
30
36
  so a browser with backdrop-filter but not color-mix (e.g. Safari < 16.2)
31
37
  would accept the declaration at parse time, fail it at computed-value
@@ -74,6 +74,11 @@
74
74
  align-items: end;
75
75
  }
76
76
 
77
+ .items-baseline {
78
+ --items-align: baseline;
79
+ align-items: baseline;
80
+ }
81
+
77
82
  .muted {
78
83
  color: var(--text-muted);
79
84
  }
@@ -10,9 +10,6 @@
10
10
  * - utilities consume the current context instead of defining their own -sm/-lg scale.
11
11
  * - optional utilities extend core utilities, they do not re-express them.
12
12
  * - follow verbose .{property}-{value} naming.
13
- *
14
- * Internal:
15
- * --cluster-justify (local relay consumed by .cluster)
16
13
  */
17
14
 
18
15
  /* Display */
@@ -87,23 +84,22 @@
87
84
  flex-wrap: nowrap;
88
85
  }
89
86
 
87
+ /* The property is written directly, never relayed through the cluster hook:
88
+ a relay would inherit into every .cluster nested inside. Utilities load last,
89
+ so the direct declaration already beats .cluster at equal specificity. */
90
90
  .justify-content-start {
91
- --cluster-justify: flex-start;
92
91
  justify-content: flex-start;
93
92
  }
94
93
 
95
94
  .justify-content-center {
96
- --cluster-justify: center;
97
95
  justify-content: center;
98
96
  }
99
97
 
100
98
  .justify-content-end {
101
- --cluster-justify: flex-end;
102
99
  justify-content: flex-end;
103
100
  }
104
101
 
105
102
  .justify-content-space-between {
106
- --cluster-justify: space-between;
107
103
  justify-content: space-between;
108
104
  }
109
105
 
@@ -0,0 +1,65 @@
1
+ /*
2
+ * sRGB color math shared by `actual-css contrast`, the repository reports and
3
+ * the browser tests.
4
+ * Colors are [r, g, b, a] byte arrays as returned by RASTERIZE in the page;
5
+ * plain [r, g, b] triplets are accepted and treated as opaque.
6
+ */
7
+
8
+ /*
9
+ * Page-side rasterizer, injected into a view.evaluate() program as
10
+ * `const rgba = ${RASTERIZE};`. getComputedStyle returns color-mix() and
11
+ * light-dark() results as oklab() or color(), so a 1x1 canvas turns any
12
+ * computed color into sRGB bytes. Alpha is kept so contrast() can refuse a
13
+ * translucent color instead of measuring it as if it were opaque.
14
+ */
15
+ export const RASTERIZE = `(() => {
16
+ const canvas = Object.assign(document.createElement("canvas"), { width: 1, height: 1 });
17
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
18
+ return (value) => {
19
+ ctx.clearRect(0, 0, 1, 1);
20
+ ctx.fillStyle = value;
21
+ ctx.fillRect(0, 0, 1, 1);
22
+ return [...ctx.getImageData(0, 0, 1, 1).data];
23
+ };
24
+ })()`;
25
+
26
+ /* 0.04045 is the sRGB (IEC 61966-2-1) threshold and the one WCAG 2.2 defines.
27
+ WCAG 2.0/2.1 printed 0.03928, from an older sRGB draft. No 8-bit channel
28
+ falls between the two (10/255 = 0.0392, 11/255 = 0.0431), so ratios are
29
+ identical either way. */
30
+ const toLinear = (byte) => {
31
+ const c = byte / 255;
32
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
33
+ };
34
+
35
+ /* WCAG 2 relative luminance. */
36
+ export const luminance = ([r, g, b]) =>
37
+ 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
38
+
39
+ /* WCAG 2 contrast ratio, 1 to 21. The formula has no alpha: a translucent
40
+ color must be composited over its real background before it is measured. */
41
+ export function contrast(a, b) {
42
+ if (a[3] < 255 || b[3] < 255) {
43
+ throw new Error(`contrast() needs opaque colors, got [${a}] and [${b}]`);
44
+ }
45
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
46
+ return (hi + 0.05) / (lo + 0.05);
47
+ }
48
+
49
+ /* OKLCH of an sRGB color: L 0-1, C chroma, h degrees. */
50
+ export function oklch([r, g, b]) {
51
+ const [lr, lg, lb] = [r, g, b].map(toLinear);
52
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
53
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
54
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
55
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
56
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
57
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
58
+ return { L, C: Math.hypot(A, B), h: ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360 };
59
+ }
60
+
61
+ /* Shortest arc between two hues, in degrees. */
62
+ export const hueDistance = (a, b) => {
63
+ const d = Math.abs(a - b) % 360;
64
+ return d > 180 ? 360 - d : d;
65
+ };
@@ -44,6 +44,7 @@ export const THEME_COLORS = [
44
44
  "border",
45
45
  "focus",
46
46
  "hover-overlay",
47
+ "hover-overlay-solid",
47
48
  "shadow-color",
48
49
  "selection-bg",
49
50
  "selection-fg",
@@ -65,6 +66,9 @@ export const FOUNDATIONS = {
65
66
  "space-50",
66
67
  "space-60",
67
68
  "gap",
69
+ "control-pad-x-sm",
70
+ "control-pad-x-md",
71
+ "control-pad-x-lg",
68
72
  "control-pad-x",
69
73
  ],
70
74
  radius: ["radius-sm", "radius", "radius-lg", "radius-full"],
@@ -91,9 +95,13 @@ export const FOUNDATIONS = {
91
95
  stay out of the design tools. */
92
96
  export const NOT_EXPORTED = {
93
97
  "font stack; a design tool font token holds one family": ["font-sans", "font-mono"],
98
+ "internal 0/1 weights selecting a control step; the steps are exported": [
99
+ "control-step-sm",
100
+ "control-step-md",
101
+ "control-step-lg",
102
+ ],
94
103
  "no design tool equivalent": [
95
104
  "font-width",
96
- "font-width-dense",
97
105
  "line-height",
98
106
  "line-height-tight",
99
107
  "line-height-relaxed",
@@ -0,0 +1,209 @@
1
+ /*
2
+ * Theme contrast report: the pairs a theme author tunes by hand, measured in
3
+ * headless Chrome on the theme's own boundaries. Shared by the CLI
4
+ * (`actual-css contrast`) and the repository report over the presets
5
+ * (`bun run report:theme-contrast`).
6
+ *
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)
9
+ * - the focus line against --surface and --surface-solid (3:1)
10
+ * - the invalid-field focus line against the field's own background (3:1)
11
+ * - --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.
14
+ *
15
+ * Bun only: Bun.WebView drives the browser.
16
+ */
17
+ import { fileURLToPath } from "node:url";
18
+ import { contrast, RASTERIZE } from "./color.js";
19
+ import { inlineImports } from "./css-bundle.js";
20
+ import { themeNameOf } from "./design-tokens.js";
21
+
22
+ const FRAMEWORK = fileURLToPath(new URL("../css/actual.full.css", import.meta.url));
23
+
24
+ export const INTENTS = ["primary", "secondary", "success", "warning", "danger", "neutral"];
25
+
26
+ /* Badges state .soft explicitly: a theme may fill badges by default
27
+ (bootstrap-v6), which would measure a solid pair against the soft hover. */
28
+ function islandMarkup(id, attribute, scheme) {
29
+ const style = scheme ? ` style="color-scheme: ${scheme}"` : "";
30
+ return `<div data-theme="${attribute}"${style} id="${id}">
31
+ ${INTENTS.map((i) => `<span class="badge soft ${i}" data-badge="${i}">t</span>`).join("")}
32
+ ${INTENTS.map((i) => `<button class="btn soft ${i}" data-hover="${i}" type="button">t</button>`).join("")}
33
+ <span data-pair="focus-surface" style="color: var(--focus); background: var(--surface)"></span>
34
+ <span data-pair="focus-solid" style="color: var(--focus); background: var(--surface-solid)"></span>
35
+ <span data-pair="inverse" style="color: var(--surface-solid-fg); background: var(--surface-solid)"></span>
36
+ <input class="input" aria-invalid="true" data-pair="invalid-field" aria-label="t">
37
+ </div>`;
38
+ }
39
+
40
+ const ratio = (fg, bg) => (fg[3] < 255 || bg[3] < 255 ? null : contrast(fg, bg));
41
+
42
+ /*
43
+ * Measures each theme of `themes` (data-theme values) in `css`, the full
44
+ * stylesheet with imports inlined. `null` stands for the default theme, read
45
+ * through its light and dark boundaries; a named theme is read in each scheme
46
+ * its `color-scheme` declares. Returns one row per island.
47
+ */
48
+ export async function measureContrast({ css, themes }) {
49
+ await using view = new Bun.WebView({ backend: "chrome" });
50
+ await view.navigate("about:blank");
51
+
52
+ // Transitions off: a forced :hover must compute its end state at once.
53
+ const islands = await view.evaluate(`((css, themes) => {
54
+ const style = document.createElement("style");
55
+ style.textContent = css + "\\n* { transition-duration: 0s !important; }";
56
+ document.head.append(style);
57
+
58
+ const schemesOf = (attribute) => {
59
+ const probe = document.createElement("div");
60
+ probe.dataset.theme = attribute;
61
+ document.body.append(probe);
62
+ const declared = getComputedStyle(probe).colorScheme.split(/\\s+/);
63
+ probe.remove();
64
+ const schemes = declared.filter((s) => s === "light" || s === "dark");
65
+ return schemes.length > 0 ? schemes : ["light"];
66
+ };
67
+
68
+ return themes.flatMap((theme) =>
69
+ theme === null
70
+ ? ["light", "dark"].map((scheme) => ({ theme: "default", scheme, attribute: scheme, pin: false }))
71
+ : schemesOf(theme).map((scheme, _, all) => ({ theme, scheme, attribute: theme, pin: all.length > 1 })),
72
+ );
73
+ })(${JSON.stringify(css)}, ${JSON.stringify(themes)})`);
74
+
75
+ const markup = islands
76
+ .map((island, i) =>
77
+ islandMarkup(`island-${i}`, island.attribute, island.pin ? island.scheme : null),
78
+ )
79
+ .join("\n");
80
+ await view.evaluate(`document.body.insertAdjacentHTML("beforeend", ${JSON.stringify(markup)})`);
81
+
82
+ await view.cdp("DOM.enable");
83
+ await view.cdp("CSS.enable");
84
+ const { root } = await view.cdp("DOM.getDocument");
85
+ const { nodeIds } = await view.cdp("DOM.querySelectorAll", {
86
+ nodeId: root.nodeId,
87
+ selector: "[data-hover]",
88
+ });
89
+ for (const nodeId of nodeIds) {
90
+ await view.cdp("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: ["hover"] });
91
+ }
92
+
93
+ const colors = await view.evaluate(`(() => {
94
+ const rgba = ${RASTERIZE};
95
+ return [...document.querySelectorAll("[id^=island-]")].map((island) => {
96
+ const cs = (el) => getComputedStyle(el);
97
+ const pairs = {};
98
+ for (const el of island.querySelectorAll("[data-pair]")) {
99
+ // The invalid field's block-end border carries --form-invalid-border,
100
+ // the hook its focus outline reads, even on an underline-only theme
101
+ // (material) whose other sides are clear.
102
+ const fg = el.dataset.pair === "invalid-field" ? cs(el).borderBlockEndColor : cs(el).color;
103
+ pairs[el.dataset.pair] = { fg: rgba(fg), bg: rgba(cs(el).backgroundColor) };
104
+ }
105
+ const soft = {};
106
+ for (const el of island.querySelectorAll("[data-badge]")) {
107
+ const hover = island.querySelector('[data-hover="' + el.dataset.badge + '"]');
108
+ soft[el.dataset.badge] = {
109
+ fg: rgba(cs(el).color),
110
+ rest: rgba(cs(el).backgroundColor),
111
+ hover: rgba(cs(hover).backgroundColor),
112
+ };
113
+ }
114
+ return { pairs, soft };
115
+ });
116
+ })()`);
117
+
118
+ return islands.map(({ theme, scheme }, i) => {
119
+ const { pairs, soft } = colors[i];
120
+ const pair = (key) => ratio(pairs[key].fg, pairs[key].bg);
121
+ return {
122
+ theme,
123
+ scheme,
124
+ soft: INTENTS.map((intent) => ({
125
+ intent,
126
+ rest: ratio(soft[intent].fg, soft[intent].rest),
127
+ hover: ratio(soft[intent].fg, soft[intent].hover),
128
+ })),
129
+ focusSurface: pair("focus-surface"),
130
+ focusSolid: pair("focus-solid"),
131
+ invalidFocus: pair("invalid-field"),
132
+ inverse: pair("inverse"),
133
+ };
134
+ });
135
+ }
136
+
137
+ /*
138
+ * Measures one theme file over the framework, or the default theme without
139
+ * `themeFile`. `name` overrides the data-theme name read from the file.
140
+ */
141
+ export async function measureThemeFile({ themeFile, name }) {
142
+ const themeCss = themeFile ? await inlineImports(themeFile) : "";
143
+ const theme = themeFile ? (name ?? themeNameOf(themeCss)) : null;
144
+ const css = `${await inlineImports(FRAMEWORK)}\n${themeCss}`;
145
+ return measureContrast({ css, themes: [theme] });
146
+ }
147
+
148
+ /* Text tables for `rows`, and the number of pairs under their threshold. */
149
+ export function formatContrast(rows) {
150
+ let misses = 0;
151
+ const cell = (value, min) => {
152
+ if (value === null) return { text: "n/a", miss: false };
153
+ const miss = value < min;
154
+ if (miss) misses += 1;
155
+ return { text: `${value.toFixed(2)}:1`, miss };
156
+ };
157
+ const width = Math.max(5, ...rows.map((row) => row.theme.length));
158
+ const head = (row) => `${row.theme.padEnd(width)} ${row.scheme.padEnd(6)}`;
159
+ const flag = (cells, min) => (cells.some((c) => c.miss) ? ` <-- under ${min}` : "");
160
+ const lines = [];
161
+
162
+ lines.push("Soft pair contrast (ink vs resting / hovered fill), needs 4.5:1.", "");
163
+ lines.push(`${"theme".padEnd(width)} scheme intent rest hover`);
164
+ for (const row of rows) {
165
+ for (const { intent, rest, hover } of row.soft) {
166
+ // Both states gate: the hovered fill is usually the closer one, but a
167
+ // theme can invert that, and a 1:1 resting pair once passed silently.
168
+ const cells = [cell(rest, 4.5), cell(hover, 4.5)];
169
+ lines.push(
170
+ `${head(row)} ${intent.padEnd(9)} ${cells[0].text.padStart(8)} ${cells[1].text.padStart(8)}${flag(cells, 4.5)}`,
171
+ );
172
+ }
173
+ }
174
+
175
+ const single = (title, key, min) => {
176
+ lines.push("", title, "");
177
+ lines.push(`${"theme".padEnd(width)} scheme ratio`);
178
+ for (const row of rows) {
179
+ const c = cell(row[key], min);
180
+ lines.push(`${head(row)} ${c.text.padStart(8)}${flag([c], min)}`);
181
+ }
182
+ };
183
+
184
+ lines.push("", "Focus line contrast (--focus vs --surface / --surface-solid), needs 3:1.", "");
185
+ lines.push(`${"theme".padEnd(width)} scheme surface solid`);
186
+ for (const row of rows) {
187
+ const cells = [cell(row.focusSurface, 3), cell(row.focusSolid, 3)];
188
+ lines.push(
189
+ `${head(row)} ${cells[0].text.padStart(8)} ${cells[1].text.padStart(8)}${flag(cells, 3)}`,
190
+ );
191
+ }
192
+
193
+ // Documented exception (focus.css): an invalid field focuses in
194
+ // --form-invalid-border. The line is inset, so it reads against the
195
+ // field's own background, not the surface around it.
196
+ single(
197
+ "Invalid focus line contrast (--form-invalid-border vs field), needs 3:1.",
198
+ "invalidFocus",
199
+ 3,
200
+ );
201
+ // Tooltip and status-bar text sit on this pair.
202
+ single(
203
+ "Inverse text contrast (--surface-solid-fg vs --surface-solid), needs 4.5:1.",
204
+ "inverse",
205
+ 4.5,
206
+ );
207
+
208
+ return { text: lines.join("\n"), misses };
209
+ }
@@ -1,52 +0,0 @@
1
- # Actions
2
-
3
- Use `.actions` for a semantic list of actions.
4
-
5
- It removes native list chrome and provides a baseline row layout (`display: flex`, wrap, shared gap). Compose with layout primitives only when you need a different direction or distribution.
6
-
7
- ```html demo
8
- <div class="stack">
9
- <menu class="actions cluster">
10
- <li><button class="btn primary" type="button">Save</button></li>
11
- <li><button class="btn outline" type="button">Cancel</button></li>
12
- </menu>
13
-
14
- <menu class="actions stack">
15
- <li><a class="btn primary" href="/billing">Update billing</a></li>
16
- <li><a href="/support">Contact support</a></li>
17
- </menu>
18
- </div>
19
- ```
20
-
21
- Use `.actions` for:
22
-
23
- - dialog actions
24
- - alert actions
25
- - card actions
26
- - toolbar actions
27
- - form submit/cancel groups
28
-
29
- Use layout primitives for contextual spacing.
30
-
31
- ```html demo
32
- <div class="alert warning stack">
33
- <p>Your billing method has expired.</p>
34
-
35
- <menu class="actions cluster">
36
- <li><a class="btn danger sm" href="/billing">Update billing</a></li>
37
- <li><a href="/support">Contact support</a></li>
38
- </menu>
39
- </div>
40
- ```
41
-
42
- Prefer composition over making each component reimplement action-list layout.
43
-
44
- ```css
45
- /* Avoid */
46
- .alert > menu {
47
- display: flex;
48
- gap: var(--space-20);
49
- padding: 0;
50
- list-style: none;
51
- }
52
- ```
@@ -1,27 +0,0 @@
1
- /*
2
- * Actions — a horizontal action-button cluster.
3
- *
4
- * Docs: docs/pages/patterns/actions.md
5
- *
6
- * Composes with .btn (or any inline element) and feeds layout via the shared
7
- * --gap token.
8
- *
9
- * Internal:
10
- * --gap (local value for the shared gap token)
11
- */
12
- .actions {
13
- --gap: var(--space-20);
14
-
15
- display: flex;
16
- flex-direction: row;
17
- flex-wrap: wrap;
18
- align-items: center;
19
- gap: var(--gap);
20
- margin: 0;
21
- padding: 0;
22
- list-style: none;
23
- }
24
-
25
- .actions > li {
26
- margin: 0;
27
- }