actual-css 0.12.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 (108) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -8
  3. package/components.json +124 -12
  4. package/dist/actual.full.css +1152 -309
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/architecture.md +67 -0
  8. package/docs/design-notes/steps.md +45 -28
  9. package/docs/navigation.json +2 -0
  10. package/docs/pages/components/alert.md +12 -2
  11. package/docs/pages/components/avatar.md +17 -11
  12. package/docs/pages/components/breadcrumb.md +16 -2
  13. package/docs/pages/components/button.md +3 -2
  14. package/docs/pages/components/list.md +15 -16
  15. package/docs/pages/components/navbar.md +42 -22
  16. package/docs/pages/components/pagination.md +19 -0
  17. package/docs/pages/components/segmented.md +83 -0
  18. package/docs/pages/components/steps.md +28 -22
  19. package/docs/pages/enhancements/flyout.md +5 -0
  20. package/docs/pages/enhancements/scrollspy.md +6 -6
  21. package/docs/pages/enhancements/tabs.md +79 -3
  22. package/docs/pages/examples/overview.md +3 -1
  23. package/docs/pages/forms/color-swatches.md +131 -0
  24. package/docs/pages/forms/combobox.md +2 -2
  25. package/docs/pages/forms/controls.md +6 -3
  26. package/docs/pages/forms/floating-field.md +21 -7
  27. package/docs/pages/forms/form-actions.md +3 -6
  28. package/docs/pages/forms/joined-controls.md +6 -2
  29. package/docs/pages/forms/overview.md +1 -0
  30. package/docs/pages/forms/states-and-sizes.md +15 -15
  31. package/docs/pages/forms/switch.md +4 -2
  32. package/docs/pages/foundations/philosophy.md +97 -1
  33. package/docs/pages/foundations/theming.md +1 -1
  34. package/docs/pages/foundations/tokens.md +94 -27
  35. package/docs/pages/guides/design-tools.md +14 -8
  36. package/docs/pages/guides/integrating-actual-css.md +74 -8
  37. package/docs/pages/guides/introduction.md +3 -2
  38. package/docs/pages/guides/modular-import.md +1 -1
  39. package/docs/pages/guides/progressive-enhancement.md +25 -7
  40. package/docs/pages/guides/upgrading.md +86 -9
  41. package/docs/pages/layout/center.md +2 -1
  42. package/docs/pages/layout/cluster.md +11 -4
  43. package/docs/pages/layout/grid.md +2 -0
  44. package/docs/pages/layout/scroll-snap.md +4 -2
  45. package/docs/pages/layout/sidebar-layout.md +3 -0
  46. package/docs/pages/layout/switcher.md +2 -0
  47. package/docs/pages/layout/topbar.md +6 -4
  48. package/docs/pages/patterns/icon-slot.md +17 -1
  49. package/docs/pages/utilities/flex.md +1 -1
  50. package/docs/pages/utilities/sizing-wrapping.md +51 -6
  51. package/package.json +6 -6
  52. package/reserved-classes.json +3 -0
  53. package/src/cli/actual-css.js +4 -4
  54. package/src/css/components/alert.css +99 -48
  55. package/src/css/components/app-nav.css +17 -9
  56. package/src/css/components/avatar.css +2 -18
  57. package/src/css/components/badge.css +23 -10
  58. package/src/css/components/breadcrumb.css +74 -8
  59. package/src/css/components/button.css +17 -2
  60. package/src/css/components/close.css +6 -3
  61. package/src/css/components/drawer.css +23 -42
  62. package/src/css/components/flyout.css +0 -1
  63. package/src/css/components/index.css +1 -0
  64. package/src/css/components/join.css +2 -2
  65. package/src/css/components/list.css +28 -13
  66. package/src/css/components/modal.css +17 -10
  67. package/src/css/components/navbar.css +15 -10
  68. package/src/css/components/pagination.css +5 -0
  69. package/src/css/components/scrollspy.css +1 -1
  70. package/src/css/components/segmented.css +186 -0
  71. package/src/css/components/steps.css +72 -15
  72. package/src/css/components/tab.css +135 -5
  73. package/src/css/core/focus.css +11 -6
  74. package/src/css/core/theme.css +6 -1
  75. package/src/css/core/tokens.css +24 -6
  76. package/src/css/core/variants.css +16 -5
  77. package/src/css/forms/choice-card.css +1 -1
  78. package/src/css/forms/choice.css +1 -1
  79. package/src/css/forms/color-swatch.css +159 -0
  80. package/src/css/forms/control.css +1 -1
  81. package/src/css/forms/floating-field.css +20 -18
  82. package/src/css/forms/form-actions.css +14 -8
  83. package/src/css/forms/index.css +1 -0
  84. package/src/css/forms/native.css +51 -21
  85. package/src/css/forms/otp.css +4 -5
  86. package/src/css/forms/switch.css +6 -3
  87. package/src/css/forms/validation.css +23 -30
  88. package/src/css/layout/center.css +2 -0
  89. package/src/css/layout/cluster.css +14 -0
  90. package/src/css/layout/grid.css +7 -0
  91. package/src/css/layout/scroll-snap.css +30 -0
  92. package/src/css/layout/scroller.css +19 -3
  93. package/src/css/layout/sidebar-layout.css +3 -0
  94. package/src/css/layout/switcher.css +3 -0
  95. package/src/css/layout/topbar.css +10 -0
  96. package/src/css/utilities/extra.css +3 -3
  97. package/src/js/focus.js +16 -1
  98. package/src/js/full.js +4 -0
  99. package/src/js/inline-visible.js +54 -0
  100. package/src/js/parsed.js +21 -0
  101. package/src/js/surface.js +10 -0
  102. package/src/js/tab.js +33 -7
  103. package/src/tooling/color.js +14 -1
  104. package/src/tooling/css-bundle.js +0 -5
  105. package/src/tooling/design-components.js +9 -5
  106. package/src/tooling/design-export.js +9 -1
  107. package/src/tooling/design-tokens.js +207 -76
  108. package/src/tooling/theme-contrast.js +182 -45
@@ -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,13 +31,35 @@ 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
- (bootstrap-v6), which would measure a solid pair against the soft hover. */
28
- function islandMarkup(id, attribute, scheme) {
40
+ (bootstrap-v6), which would measure a solid pair against the soft hover.
41
+ The theme name is read from a file by a regex, so it never enters this
42
+ markup: measureContrast() sets it through dataset. `scheme` is only ever
43
+ "light" or "dark". */
44
+ function islandMarkup(id, scheme) {
29
45
  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("")}
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("");
60
+ return `<div${style} id="${id}">
61
+ ${softPairs}
62
+ ${INTENTS.map((i) => `<button class="btn solid ${i}" data-solid="${i}" type="button">t</button>`).join("")}
33
63
  <span data-pair="focus-surface" style="color: var(--focus); background: var(--surface)"></span>
34
64
  <span data-pair="focus-solid" style="color: var(--focus); background: var(--surface-solid)"></span>
35
65
  <span data-pair="inverse" style="color: var(--surface-solid-fg); background: var(--surface-solid)"></span>
@@ -41,23 +71,27 @@ const ratio = (fg, bg) => (fg[3] < 255 || bg[3] < 255 ? null : contrast(fg, bg))
41
71
 
42
72
  /*
43
73
  * 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.
74
+ * stylesheet with imports inlined. `null` stands for the root's theme — the
75
+ * default one, or an application theme written on :root — labelled `label`.
76
+ * Each theme is read in every scheme its `color-scheme` declares. Returns one
77
+ * row per island.
47
78
  */
48
- export async function measureContrast({ css, themes }) {
79
+ export async function measureContrast({ css, themes, label = "default" }) {
49
80
  await using view = new Bun.WebView({ backend: "chrome" });
50
81
  await view.navigate("about:blank");
51
82
 
52
83
  // Transitions off: a forced :hover must compute its end state at once.
53
- const islands = await view.evaluate(`((css, themes) => {
84
+ const islands = await view.evaluate(`((css, themes, label) => {
54
85
  const style = document.createElement("style");
55
86
  style.textContent = css + "\\n* { transition-duration: 0s !important; }";
56
87
  document.head.append(style);
57
88
 
89
+ // A root island carries no data-theme: the core redeclares the default
90
+ // palette on [data-theme="light"|"dark"], which would mask a :root theme.
91
+ // Pinning color-scheme alone is what picks the light-dark() side.
58
92
  const schemesOf = (attribute) => {
59
93
  const probe = document.createElement("div");
60
- probe.dataset.theme = attribute;
94
+ if (attribute) probe.dataset.theme = attribute;
61
95
  document.body.append(probe);
62
96
  const declared = getComputedStyle(probe).colorScheme.split(/\\s+/);
63
97
  probe.remove();
@@ -65,26 +99,26 @@ export async function measureContrast({ css, themes }) {
65
99
  return schemes.length > 0 ? schemes : ["light"];
66
100
  };
67
101
 
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 })),
102
+ return themes.flatMap((attribute) =>
103
+ schemesOf(attribute).map((scheme) => ({ theme: attribute ?? label, scheme, attribute })),
72
104
  );
73
- })(${JSON.stringify(css)}, ${JSON.stringify(themes)})`);
105
+ })(${JSON.stringify(css)}, ${JSON.stringify(themes)}, ${JSON.stringify(label)})`);
74
106
 
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)})`);
107
+ const markup = islands.map((island, i) => islandMarkup(`island-${i}`, island.scheme)).join("\n");
108
+ const attributes = islands.map((island) => island.attribute);
109
+ await view.evaluate(`((markup, attributes) => {
110
+ document.body.insertAdjacentHTML("beforeend", markup);
111
+ attributes.forEach((attribute, i) => {
112
+ if (attribute) document.getElementById("island-" + i).dataset.theme = attribute;
113
+ });
114
+ })(${JSON.stringify(markup)}, ${JSON.stringify(attributes)})`);
81
115
 
82
116
  await view.cdp("DOM.enable");
83
117
  await view.cdp("CSS.enable");
84
118
  const { root } = await view.cdp("DOM.getDocument");
85
119
  const { nodeIds } = await view.cdp("DOM.querySelectorAll", {
86
120
  nodeId: root.nodeId,
87
- selector: "[data-hover]",
121
+ selector: '[data-hover], [data-selected="nav-hover"]',
88
122
  });
89
123
  for (const nodeId of nodeIds) {
90
124
  await view.cdp("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: ["hover"] });
@@ -102,30 +136,101 @@ export async function measureContrast({ css, themes }) {
102
136
  const fg = el.dataset.pair === "invalid-field" ? cs(el).borderBlockEndColor : cs(el).color;
103
137
  pairs[el.dataset.pair] = { fg: rgba(fg), bg: rgba(cs(el).backgroundColor) };
104
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
+ }
105
148
  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),
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,
112
187
  };
113
188
  }
114
- return { pairs, soft };
189
+ return { pairs, soft, solid };
115
190
  });
116
191
  })()`);
117
192
 
118
193
  return islands.map(({ theme, scheme }, i) => {
119
- const { pairs, soft } = colors[i];
194
+ const { pairs, soft, solid } = colors[i];
120
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
+ };
121
206
  return {
122
207
  theme,
123
208
  scheme,
124
- soft: INTENTS.map((intent) => ({
209
+ solid: INTENTS.map((intent) => ({
125
210
  intent,
126
- rest: ratio(soft[intent].fg, soft[intent].rest),
127
- hover: ratio(soft[intent].fg, soft[intent].hover),
211
+ value: solid[intent] ? ratio(solid[intent].fg, solid[intent].bg) : null,
128
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
+ }),
129
234
  focusSurface: pair("focus-surface"),
130
235
  focusSolid: pair("focus-solid"),
131
236
  invalidFocus: pair("invalid-field"),
@@ -136,13 +241,14 @@ export async function measureContrast({ css, themes }) {
136
241
 
137
242
  /*
138
243
  * Measures one theme file over the framework, or the default theme without
139
- * `themeFile`. `name` overrides the data-theme name read from the file.
244
+ * `themeFile`. `name` overrides the data-theme name read from the file; a file
245
+ * that declares none is a :root theme.
140
246
  */
141
247
  export async function measureThemeFile({ themeFile, name }) {
142
248
  const themeCss = themeFile ? await inlineImports(themeFile) : "";
143
- const theme = themeFile ? (name ?? themeNameOf(themeCss)) : null;
249
+ const theme = themeFile ? themeNameOf(themeCss, name) : null;
144
250
  const css = `${await inlineImports(FRAMEWORK)}\n${themeCss}`;
145
- return measureContrast({ css, themes: [theme] });
251
+ return measureContrast({ css, themes: [theme], label: themeFile ? ":root" : "default" });
146
252
  }
147
253
 
148
254
  /* Text tables for `rows`, and the number of pairs under their threshold. */
@@ -159,15 +265,46 @@ export function formatContrast(rows) {
159
265
  const flag = (cells, min) => (cells.some((c) => c.miss) ? ` <-- under ${min}` : "");
160
266
  const lines = [];
161
267
 
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`);
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`);
164
273
  for (const row of rows) {
165
- for (const { intent, rest, hover } of row.soft) {
274
+ for (const { intent, surface, rest, hover } of row.soft) {
166
275
  // Both states gate: the hovered fill is usually the closer one, but a
167
276
  // theme can invert that, and a 1:1 resting pair once passed silently.
168
277
  const cells = [cell(rest, 4.5), cell(hover, 4.5)];
169
278
  lines.push(
170
- `${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)}`,
171
308
  );
172
309
  }
173
310
  }