actual-css 0.11.1 → 0.13.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 (123) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +25 -6
  3. package/components.json +12 -18
  4. package/dist/actual.full.css +780 -479
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/brand/favicon.svg +5 -0
  8. package/docs/brand/lockup.svg +12 -0
  9. package/docs/brand/logo-dark.svg +9 -0
  10. package/docs/brand/logo-light.svg +9 -0
  11. package/docs/brand/logo.svg +11 -0
  12. package/docs/brand/mark.svg +11 -0
  13. package/docs/design-notes/architecture.md +67 -0
  14. package/docs/design-notes/steps.md +45 -28
  15. package/docs/navigation.json +3 -2
  16. package/docs/pages/components/alert.md +2 -10
  17. package/docs/pages/components/avatar.md +17 -11
  18. package/docs/pages/components/badge.md +3 -9
  19. package/docs/pages/components/breadcrumb.md +16 -2
  20. package/docs/pages/components/card.md +31 -2
  21. package/docs/pages/components/dialog.md +121 -33
  22. package/docs/pages/components/drawer.md +2 -2
  23. package/docs/pages/components/list.md +9 -13
  24. package/docs/pages/components/navbar.md +49 -30
  25. package/docs/pages/components/pagination.md +19 -0
  26. package/docs/pages/components/steps.md +24 -20
  27. package/docs/pages/components/table.md +3 -3
  28. package/docs/pages/enhancements/flyout.md +2 -2
  29. package/docs/pages/enhancements/scrollspy.md +7 -7
  30. package/docs/pages/enhancements/status-bar.md +2 -2
  31. package/docs/pages/enhancements/tabs.md +74 -3
  32. package/docs/pages/examples/overview.md +2 -1
  33. package/docs/pages/forms/date-picker.md +90 -0
  34. package/docs/pages/forms/detached-actions.md +37 -30
  35. package/docs/pages/forms/floating-field.md +21 -7
  36. package/docs/pages/forms/form-actions.md +58 -28
  37. package/docs/pages/forms/input-policy.md +1 -1
  38. package/docs/pages/forms/overview.md +6 -5
  39. package/docs/pages/forms/states-and-sizes.md +15 -15
  40. package/docs/pages/forms/sticky-actions.md +85 -83
  41. package/docs/pages/forms/validation.md +5 -5
  42. package/docs/pages/foundations/philosophy.md +102 -4
  43. package/docs/pages/foundations/theming.md +15 -4
  44. package/docs/pages/foundations/tokens.md +89 -36
  45. package/docs/pages/foundations/typography.md +4 -7
  46. package/docs/pages/guides/bootstrap.md +2 -2
  47. package/docs/pages/guides/design-tools.md +2 -2
  48. package/docs/pages/guides/integrating-actual-css.md +79 -9
  49. package/docs/pages/guides/introduction.md +5 -5
  50. package/docs/pages/guides/modular-import.md +3 -0
  51. package/docs/pages/guides/progressive-enhancement.md +27 -9
  52. package/docs/pages/guides/tailwind.md +70 -0
  53. package/docs/pages/guides/upgrading.md +151 -0
  54. package/docs/pages/layout/center.md +2 -1
  55. package/docs/pages/layout/cluster.md +37 -4
  56. package/docs/pages/layout/grid.md +2 -0
  57. package/docs/pages/layout/overview.md +1 -1
  58. package/docs/pages/layout/sidebar-layout.md +3 -0
  59. package/docs/pages/layout/stack.md +21 -16
  60. package/docs/pages/layout/switcher.md +2 -0
  61. package/docs/pages/layout/topbar.md +7 -5
  62. package/docs/pages/patterns/icon-slot.md +17 -1
  63. package/docs/pages/patterns/navigation-list.md +16 -46
  64. package/docs/pages/patterns/overview.md +3 -4
  65. package/docs/pages/utilities/flex.md +30 -20
  66. package/docs/pages/utilities/sizing-wrapping.md +52 -7
  67. package/docs/pages/utilities/spacing.md +4 -3
  68. package/package.json +6 -6
  69. package/reserved-classes.json +3 -3
  70. package/skills/actual-css/references/enhancements.md +10 -7
  71. package/src/cli/actual-css.js +39 -13
  72. package/src/css/components/alert.css +34 -34
  73. package/src/css/components/avatar.css +8 -17
  74. package/src/css/components/badge.css +1 -6
  75. package/src/css/components/breadcrumb.css +74 -8
  76. package/src/css/components/button.css +3 -9
  77. package/src/css/components/card.css +18 -10
  78. package/src/css/components/chat.css +4 -5
  79. package/src/css/components/drawer.css +42 -45
  80. package/src/css/components/flyout.css +3 -1
  81. package/src/css/components/index.css +0 -1
  82. package/src/css/components/join.css +2 -2
  83. package/src/css/components/list.css +10 -10
  84. package/src/css/components/modal.css +113 -134
  85. package/src/css/components/navbar.css +22 -31
  86. package/src/css/components/pagination.css +5 -0
  87. package/src/css/components/scrollspy.css +1 -1
  88. package/src/css/components/steps.css +66 -12
  89. package/src/css/components/tab.css +134 -4
  90. package/src/css/core/base.css +1 -1
  91. package/src/css/core/focus.css +8 -6
  92. package/src/css/core/reset.css +2 -1
  93. package/src/css/core/theme.css +10 -1
  94. package/src/css/core/tokens.css +32 -12
  95. package/src/css/core/variants.css +41 -14
  96. package/src/css/forms/choice-card.css +1 -1
  97. package/src/css/forms/choice.css +1 -1
  98. package/src/css/forms/control.css +1 -1
  99. package/src/css/forms/floating-field.css +20 -18
  100. package/src/css/forms/form-actions.css +14 -12
  101. package/src/css/forms/native.css +2 -2
  102. package/src/css/forms/otp.css +4 -5
  103. package/src/css/forms/switch.css +1 -1
  104. package/src/css/forms/validation.css +23 -30
  105. package/src/css/layout/center.css +2 -0
  106. package/src/css/layout/cluster.css +25 -0
  107. package/src/css/layout/grid.css +7 -0
  108. package/src/css/layout/scroller.css +19 -3
  109. package/src/css/layout/sidebar-layout.css +3 -0
  110. package/src/css/layout/switcher.css +3 -0
  111. package/src/css/layout/topbar.css +12 -6
  112. package/src/css/utilities/base.css +5 -0
  113. package/src/css/utilities/extra.css +3 -7
  114. package/src/js/full.js +4 -0
  115. package/src/js/inline-visible.js +54 -0
  116. package/src/js/parsed.js +21 -0
  117. package/src/js/tab.js +33 -7
  118. package/src/tooling/color.js +65 -0
  119. package/src/tooling/design-export.js +1 -1
  120. package/src/tooling/design-tokens.js +53 -34
  121. package/src/tooling/theme-contrast.js +217 -0
  122. package/docs/pages/patterns/actions.md +0 -52
  123. package/src/css/components/actions.css +0 -27
@@ -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
+ };
@@ -31,7 +31,7 @@ const PLUGIN = fileURLToPath(new URL("./penpot-plugin/", import.meta.url));
31
31
  */
32
32
  export async function exportDesign({ themeFile, name, out }) {
33
33
  const themeCss = themeFile ? await inlineImports(themeFile) : "";
34
- const theme = themeFile ? (name ?? themeNameOf(themeCss)) : null;
34
+ const theme = themeFile ? themeNameOf(themeCss, name) : null;
35
35
  const css = `${await inlineImports(FRAMEWORK)}\n${themeCss}`;
36
36
 
37
37
  const resolved = await resolveTokens({
@@ -42,8 +42,10 @@ export const THEME_COLORS = [
42
42
  "text-subtle",
43
43
  "heading",
44
44
  "border",
45
+ "border-control",
45
46
  "focus",
46
47
  "hover-overlay",
48
+ "hover-overlay-solid",
47
49
  "shadow-color",
48
50
  "selection-bg",
49
51
  "selection-fg",
@@ -65,6 +67,9 @@ export const FOUNDATIONS = {
65
67
  "space-50",
66
68
  "space-60",
67
69
  "gap",
70
+ "control-pad-x-sm",
71
+ "control-pad-x-md",
72
+ "control-pad-x-lg",
68
73
  "control-pad-x",
69
74
  ],
70
75
  radius: ["radius-sm", "radius", "radius-lg", "radius-full"],
@@ -91,9 +96,13 @@ export const FOUNDATIONS = {
91
96
  stay out of the design tools. */
92
97
  export const NOT_EXPORTED = {
93
98
  "font stack; a design tool font token holds one family": ["font-sans", "font-mono"],
99
+ "internal 0/1 weights selecting a control step; the steps are exported": [
100
+ "control-step-sm",
101
+ "control-step-md",
102
+ "control-step-lg",
103
+ ],
94
104
  "no design tool equivalent": [
95
105
  "font-width",
96
- "font-width-dense",
97
106
  "line-height",
98
107
  "line-height-tight",
99
108
  "line-height-relaxed",
@@ -123,7 +132,6 @@ export const NOT_EXPORTED = {
123
132
  "soft-border-mix",
124
133
  "soft-hover-alpha",
125
134
  "soft-fg-mix",
126
- "focus-outline",
127
135
  "focus-outline-color",
128
136
  ],
129
137
  "soft recipe, exported with the components": [
@@ -154,7 +162,9 @@ const ROLE_OF = new Map(
154
162
 
155
163
  const DECLARATION_RE = /(?<![-\w])--([a-z0-9-]+)\s*:\s*([^;{}]*);/gi;
156
164
  const ALIAS_RE = /^var\(\s*--([a-z0-9-]+)\s*\)$/i;
157
- const THEME_NAME_RE = /\[data-theme="([^"]+)"\]/g;
165
+ // Every valid spelling — either quote, a bare ident, whitespace inside the
166
+ // brackets, an i/s flag: a missed name falls back to the :root theme silently.
167
+ const THEME_NAME_RE = /\[\s*data-theme\s*=\s*(?:(["'])([^"']+)\1|([-\w]+))\s*(?:[is]\s*)?\]/gi;
158
168
 
159
169
  export function declaredTokens(css) {
160
170
  return new Set([...stripComments(css).matchAll(DECLARATION_RE)].map((m) => m[1]));
@@ -175,17 +185,29 @@ export function aliasCandidates(...sources) {
175
185
  return aliases;
176
186
  }
177
187
 
178
- /* The one data-theme value a theme file declares, light/dark excluded. */
179
- export function themeNameOf(css) {
188
+ /* The one data-theme value a theme file declares, light/dark excluded. null
189
+ when it declares none: the theme is written on :root, the application's
190
+ own sheet rather than an island. An explicit name must be one the file
191
+ declares: an island no rule matches would measure the framework defaults
192
+ and report them as the theme (a typo passed silently). */
193
+ export function themeNameOf(css, explicit) {
180
194
  const names = new Set(
181
- [...css.matchAll(THEME_NAME_RE)].map((m) => m[1]).filter((n) => n !== "light" && n !== "dark"),
195
+ // Comments stripped: a selector quoted in a doc comment is not a theme.
196
+ [...stripComments(css).matchAll(THEME_NAME_RE)]
197
+ .map((m) => m[2] ?? m[3])
198
+ .filter((n) => n !== "light" && n !== "dark"),
182
199
  );
183
- if (names.size !== 1) {
200
+ if (explicit != null) {
201
+ if (names.has(explicit)) return explicit;
202
+ const found = names.size ? `it declares ${[...names].join(", ")}` : "it declares none";
203
+ throw new Error(`The theme declares no [data-theme="${explicit}"]; ${found}.`);
204
+ }
205
+ if (names.size > 1) {
184
206
  throw new Error(
185
- `Expected one [data-theme="…"] name in the theme, found ${names.size === 0 ? "none" : [...names].join(", ")}; pass the name explicitly.`,
207
+ `Expected one [data-theme="…"] name in the theme, found ${[...names].join(", ")}; pass the name explicitly.`,
186
208
  );
187
209
  }
188
- return [...names][0];
210
+ return names.size === 1 ? [...names][0] : null;
189
211
  }
190
212
 
191
213
  export async function coreTokenCss() {
@@ -238,7 +260,7 @@ function readIslands({ css, islands, colors, lengths, weights }) {
238
260
  const raw = read(name);
239
261
  if (raw) values.weights[name] = Number(raw);
240
262
  }
241
- values.scheme = attribute ? getComputedStyle(island).colorScheme : null;
263
+ values.scheme = getComputedStyle(island).colorScheme;
242
264
  result.push(values);
243
265
  island.remove();
244
266
  }
@@ -258,31 +280,28 @@ const evaluateIslands = (input) => inPage(readIslands, input);
258
280
 
259
281
  /*
260
282
  * Resolves one theme. `css` is the full stylesheet (framework + theme, imports
261
- * inlined). Without `theme`, the default theme is read through its light and
262
- * dark boundaries. A named theme exports the schemes it declares: `light dark`
263
- * gives two modes, anything else one. `islands` is returned so component
264
- * measurements read the same boundaries.
283
+ * inlined). Without `theme`, the theme is the root's — the default one, or an
284
+ * application theme written on :root. The theme exports the schemes it
285
+ * declares: `light dark` gives two modes, anything else one. `islands` is
286
+ * returned so component measurements read the same boundaries.
265
287
  */
266
- export async function resolveTokens({ css, theme, aliasSources = [] }) {
267
- let islands = [
268
- { mode: "light", attribute: "light" },
269
- { mode: "dark", attribute: "dark" },
270
- ];
271
- if (theme) {
272
- const [declared] = await evaluateIslands({
273
- css,
274
- islands: [{ attribute: theme }],
275
- colors: [],
276
- lengths: [],
277
- weights: [],
278
- });
279
- const schemes = declared.scheme.split(/\s+/).filter((s) => s === "light" || s === "dark");
280
- islands = (schemes.length > 0 ? schemes : ["light"]).map((mode) => ({
281
- mode,
282
- attribute: theme,
283
- scheme: mode,
284
- }));
285
- }
288
+ export async function resolveTokens({ css, theme = null, aliasSources = [] }) {
289
+ // A root island carries no data-theme: the core redeclares the default
290
+ // palette on [data-theme="light"|"dark"], which would mask a :root theme.
291
+ // Pinning color-scheme alone is what picks the light-dark() side.
292
+ const [declared] = await evaluateIslands({
293
+ css,
294
+ islands: [{ attribute: theme }],
295
+ colors: [],
296
+ lengths: [],
297
+ weights: [],
298
+ });
299
+ const schemes = declared.scheme.split(/\s+/).filter((s) => s === "light" || s === "dark");
300
+ const islands = (schemes.length > 0 ? schemes : ["light"]).map((mode) => ({
301
+ mode,
302
+ attribute: theme,
303
+ scheme: mode,
304
+ }));
286
305
 
287
306
  const read = await evaluateIslands({
288
307
  css,
@@ -0,0 +1,217 @@
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
+ The theme name is read from a file by a regex, so it never enters this
29
+ markup: measureContrast() sets it through dataset. `scheme` is only ever
30
+ "light" or "dark". */
31
+ function islandMarkup(id, scheme) {
32
+ const style = scheme ? ` style="color-scheme: ${scheme}"` : "";
33
+ 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("")}
36
+ <span data-pair="focus-surface" style="color: var(--focus); background: var(--surface)"></span>
37
+ <span data-pair="focus-solid" style="color: var(--focus); background: var(--surface-solid)"></span>
38
+ <span data-pair="inverse" style="color: var(--surface-solid-fg); background: var(--surface-solid)"></span>
39
+ <input class="input" aria-invalid="true" data-pair="invalid-field" aria-label="t">
40
+ </div>`;
41
+ }
42
+
43
+ const ratio = (fg, bg) => (fg[3] < 255 || bg[3] < 255 ? null : contrast(fg, bg));
44
+
45
+ /*
46
+ * Measures each theme of `themes` (data-theme values) in `css`, the full
47
+ * stylesheet with imports inlined. `null` stands for the root's theme — the
48
+ * default one, or an application theme written on :root — labelled `label`.
49
+ * Each theme is read in every scheme its `color-scheme` declares. Returns one
50
+ * row per island.
51
+ */
52
+ export async function measureContrast({ css, themes, label = "default" }) {
53
+ await using view = new Bun.WebView({ backend: "chrome" });
54
+ await view.navigate("about:blank");
55
+
56
+ // Transitions off: a forced :hover must compute its end state at once.
57
+ const islands = await view.evaluate(`((css, themes, label) => {
58
+ const style = document.createElement("style");
59
+ style.textContent = css + "\\n* { transition-duration: 0s !important; }";
60
+ document.head.append(style);
61
+
62
+ // A root island carries no data-theme: the core redeclares the default
63
+ // palette on [data-theme="light"|"dark"], which would mask a :root theme.
64
+ // Pinning color-scheme alone is what picks the light-dark() side.
65
+ const schemesOf = (attribute) => {
66
+ const probe = document.createElement("div");
67
+ if (attribute) probe.dataset.theme = attribute;
68
+ document.body.append(probe);
69
+ const declared = getComputedStyle(probe).colorScheme.split(/\\s+/);
70
+ probe.remove();
71
+ const schemes = declared.filter((s) => s === "light" || s === "dark");
72
+ return schemes.length > 0 ? schemes : ["light"];
73
+ };
74
+
75
+ return themes.flatMap((attribute) =>
76
+ schemesOf(attribute).map((scheme) => ({ theme: attribute ?? label, scheme, attribute })),
77
+ );
78
+ })(${JSON.stringify(css)}, ${JSON.stringify(themes)}, ${JSON.stringify(label)})`);
79
+
80
+ const markup = islands.map((island, i) => islandMarkup(`island-${i}`, island.scheme)).join("\n");
81
+ const attributes = islands.map((island) => island.attribute);
82
+ await view.evaluate(`((markup, attributes) => {
83
+ document.body.insertAdjacentHTML("beforeend", markup);
84
+ attributes.forEach((attribute, i) => {
85
+ if (attribute) document.getElementById("island-" + i).dataset.theme = attribute;
86
+ });
87
+ })(${JSON.stringify(markup)}, ${JSON.stringify(attributes)})`);
88
+
89
+ await view.cdp("DOM.enable");
90
+ await view.cdp("CSS.enable");
91
+ const { root } = await view.cdp("DOM.getDocument");
92
+ const { nodeIds } = await view.cdp("DOM.querySelectorAll", {
93
+ nodeId: root.nodeId,
94
+ selector: "[data-hover]",
95
+ });
96
+ for (const nodeId of nodeIds) {
97
+ await view.cdp("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: ["hover"] });
98
+ }
99
+
100
+ const colors = await view.evaluate(`(() => {
101
+ const rgba = ${RASTERIZE};
102
+ return [...document.querySelectorAll("[id^=island-]")].map((island) => {
103
+ const cs = (el) => getComputedStyle(el);
104
+ const pairs = {};
105
+ for (const el of island.querySelectorAll("[data-pair]")) {
106
+ // The invalid field's block-end border carries --form-invalid-border,
107
+ // the hook its focus outline reads, even on an underline-only theme
108
+ // (material) whose other sides are clear.
109
+ const fg = el.dataset.pair === "invalid-field" ? cs(el).borderBlockEndColor : cs(el).color;
110
+ pairs[el.dataset.pair] = { fg: rgba(fg), bg: rgba(cs(el).backgroundColor) };
111
+ }
112
+ 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),
119
+ };
120
+ }
121
+ return { pairs, soft };
122
+ });
123
+ })()`);
124
+
125
+ return islands.map(({ theme, scheme }, i) => {
126
+ const { pairs, soft } = colors[i];
127
+ const pair = (key) => ratio(pairs[key].fg, pairs[key].bg);
128
+ return {
129
+ theme,
130
+ scheme,
131
+ soft: INTENTS.map((intent) => ({
132
+ intent,
133
+ rest: ratio(soft[intent].fg, soft[intent].rest),
134
+ hover: ratio(soft[intent].fg, soft[intent].hover),
135
+ })),
136
+ focusSurface: pair("focus-surface"),
137
+ focusSolid: pair("focus-solid"),
138
+ invalidFocus: pair("invalid-field"),
139
+ inverse: pair("inverse"),
140
+ };
141
+ });
142
+ }
143
+
144
+ /*
145
+ * Measures one theme file over the framework, or the default theme without
146
+ * `themeFile`. `name` overrides the data-theme name read from the file; a file
147
+ * that declares none is a :root theme.
148
+ */
149
+ export async function measureThemeFile({ themeFile, name }) {
150
+ const themeCss = themeFile ? await inlineImports(themeFile) : "";
151
+ const theme = themeFile ? themeNameOf(themeCss, name) : null;
152
+ const css = `${await inlineImports(FRAMEWORK)}\n${themeCss}`;
153
+ return measureContrast({ css, themes: [theme], label: themeFile ? ":root" : "default" });
154
+ }
155
+
156
+ /* Text tables for `rows`, and the number of pairs under their threshold. */
157
+ export function formatContrast(rows) {
158
+ let misses = 0;
159
+ const cell = (value, min) => {
160
+ if (value === null) return { text: "n/a", miss: false };
161
+ const miss = value < min;
162
+ if (miss) misses += 1;
163
+ return { text: `${value.toFixed(2)}:1`, miss };
164
+ };
165
+ const width = Math.max(5, ...rows.map((row) => row.theme.length));
166
+ const head = (row) => `${row.theme.padEnd(width)} ${row.scheme.padEnd(6)}`;
167
+ const flag = (cells, min) => (cells.some((c) => c.miss) ? ` <-- under ${min}` : "");
168
+ const lines = [];
169
+
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`);
172
+ for (const row of rows) {
173
+ for (const { intent, rest, hover } of row.soft) {
174
+ // Both states gate: the hovered fill is usually the closer one, but a
175
+ // theme can invert that, and a 1:1 resting pair once passed silently.
176
+ const cells = [cell(rest, 4.5), cell(hover, 4.5)];
177
+ lines.push(
178
+ `${head(row)} ${intent.padEnd(9)} ${cells[0].text.padStart(8)} ${cells[1].text.padStart(8)}${flag(cells, 4.5)}`,
179
+ );
180
+ }
181
+ }
182
+
183
+ const single = (title, key, min) => {
184
+ lines.push("", title, "");
185
+ lines.push(`${"theme".padEnd(width)} scheme ratio`);
186
+ for (const row of rows) {
187
+ const c = cell(row[key], min);
188
+ lines.push(`${head(row)} ${c.text.padStart(8)}${flag([c], min)}`);
189
+ }
190
+ };
191
+
192
+ lines.push("", "Focus line contrast (--focus vs --surface / --surface-solid), needs 3:1.", "");
193
+ lines.push(`${"theme".padEnd(width)} scheme surface solid`);
194
+ for (const row of rows) {
195
+ const cells = [cell(row.focusSurface, 3), cell(row.focusSolid, 3)];
196
+ lines.push(
197
+ `${head(row)} ${cells[0].text.padStart(8)} ${cells[1].text.padStart(8)}${flag(cells, 3)}`,
198
+ );
199
+ }
200
+
201
+ // Documented exception (focus.css): an invalid field focuses in
202
+ // --form-invalid-border. The line is inset, so it reads against the
203
+ // field's own background, not the surface around it.
204
+ single(
205
+ "Invalid focus line contrast (--form-invalid-border vs field), needs 3:1.",
206
+ "invalidFocus",
207
+ 3,
208
+ );
209
+ // Tooltip and status-bar text sit on this pair.
210
+ single(
211
+ "Inverse text contrast (--surface-solid-fg vs --surface-solid), needs 4.5:1.",
212
+ "inverse",
213
+ 4.5,
214
+ );
215
+
216
+ return { text: lines.join("\n"), misses };
217
+ }
@@ -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
- }