actual-css 0.12.0 → 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 (78) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +7 -8
  3. package/components.json +8 -2
  4. package/dist/actual.full.css +505 -233
  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/pages/components/avatar.md +17 -11
  10. package/docs/pages/components/breadcrumb.md +16 -2
  11. package/docs/pages/components/list.md +9 -13
  12. package/docs/pages/components/navbar.md +42 -22
  13. package/docs/pages/components/pagination.md +19 -0
  14. package/docs/pages/components/steps.md +24 -20
  15. package/docs/pages/enhancements/scrollspy.md +6 -6
  16. package/docs/pages/enhancements/tabs.md +74 -3
  17. package/docs/pages/forms/floating-field.md +21 -7
  18. package/docs/pages/forms/form-actions.md +3 -6
  19. package/docs/pages/forms/states-and-sizes.md +15 -15
  20. package/docs/pages/foundations/philosophy.md +97 -1
  21. package/docs/pages/foundations/tokens.md +40 -4
  22. package/docs/pages/guides/design-tools.md +1 -1
  23. package/docs/pages/guides/integrating-actual-css.md +74 -8
  24. package/docs/pages/guides/introduction.md +3 -2
  25. package/docs/pages/guides/modular-import.md +1 -1
  26. package/docs/pages/guides/progressive-enhancement.md +25 -7
  27. package/docs/pages/guides/upgrading.md +86 -9
  28. package/docs/pages/layout/center.md +2 -1
  29. package/docs/pages/layout/cluster.md +11 -4
  30. package/docs/pages/layout/grid.md +2 -0
  31. package/docs/pages/layout/sidebar-layout.md +3 -0
  32. package/docs/pages/layout/switcher.md +2 -0
  33. package/docs/pages/layout/topbar.md +6 -4
  34. package/docs/pages/patterns/icon-slot.md +17 -1
  35. package/docs/pages/utilities/flex.md +1 -1
  36. package/docs/pages/utilities/sizing-wrapping.md +51 -6
  37. package/package.json +5 -5
  38. package/src/cli/actual-css.js +1 -1
  39. package/src/css/components/alert.css +34 -34
  40. package/src/css/components/avatar.css +2 -18
  41. package/src/css/components/badge.css +0 -1
  42. package/src/css/components/breadcrumb.css +74 -8
  43. package/src/css/components/drawer.css +23 -42
  44. package/src/css/components/flyout.css +0 -1
  45. package/src/css/components/join.css +2 -2
  46. package/src/css/components/list.css +10 -10
  47. package/src/css/components/modal.css +17 -10
  48. package/src/css/components/navbar.css +10 -8
  49. package/src/css/components/pagination.css +5 -0
  50. package/src/css/components/scrollspy.css +1 -1
  51. package/src/css/components/steps.css +66 -12
  52. package/src/css/components/tab.css +134 -4
  53. package/src/css/core/focus.css +8 -6
  54. package/src/css/core/theme.css +6 -1
  55. package/src/css/core/tokens.css +4 -2
  56. package/src/css/forms/choice-card.css +1 -1
  57. package/src/css/forms/choice.css +1 -1
  58. package/src/css/forms/control.css +1 -1
  59. package/src/css/forms/floating-field.css +20 -18
  60. package/src/css/forms/form-actions.css +5 -8
  61. package/src/css/forms/native.css +2 -2
  62. package/src/css/forms/otp.css +4 -5
  63. package/src/css/forms/switch.css +1 -1
  64. package/src/css/forms/validation.css +23 -30
  65. package/src/css/layout/center.css +2 -0
  66. package/src/css/layout/cluster.css +14 -0
  67. package/src/css/layout/grid.css +7 -0
  68. package/src/css/layout/scroller.css +19 -3
  69. package/src/css/layout/sidebar-layout.css +3 -0
  70. package/src/css/layout/switcher.css +3 -0
  71. package/src/css/utilities/extra.css +3 -3
  72. package/src/js/full.js +4 -0
  73. package/src/js/inline-visible.js +54 -0
  74. package/src/js/parsed.js +21 -0
  75. package/src/js/tab.js +33 -7
  76. package/src/tooling/design-export.js +1 -1
  77. package/src/tooling/design-tokens.js +44 -33
  78. package/src/tooling/theme-contrast.js +32 -24
@@ -0,0 +1,54 @@
1
+ /*
2
+ * Brings a target into view inside one horizontal scrollport — internal, not
3
+ * a package export. Tabs call it at connect, so a selected tab rendered
4
+ * off-screen starts visible, and on every selection.
5
+ *
6
+ * Contract:
7
+ * - Scrolls `container` only. scrollIntoView() would also scroll ancestors and
8
+ * the page, so a strip below the fold would yank the reader down to it.
9
+ * - Horizontal, nearest only: a target already visible stays put; one that
10
+ * overflows a single edge moves the minimum to show that edge; one wider
11
+ * than the visible area that covers it on both sides stays put too.
12
+ * - The visible area is the scrollport minus its computed scroll-padding, the
13
+ * native vocabulary also honoured by focus scrolling: a component that
14
+ * keeps an edge clear (a strip's focus line) declares it there, not here.
15
+ * - The delta comes from physical rects and goes through scrollBy(), so RTL
16
+ * needs no scrollLeft sign convention.
17
+ * - Rects are in transformed pixels; clientWidth, scroll-padding and scrollBy()
18
+ * are in layout pixels. The strip's own scale converts between them, so a
19
+ * strip under transform: scale() reveals the same item. Rotation is out of
20
+ * scope.
21
+ * - Inline axis only: the block scroll position is left as it is. Callers are
22
+ * one-row strips whose block axis does not scroll.
23
+ * - Requires a laid-out, visible container and target. A strip hidden at
24
+ * connect (inside a closed dialog or drawer) or a target without a box (a
25
+ * current link inside a closed flyout) gets a no-op, not a deferred retry.
26
+ */
27
+
28
+ // Sub-pixel layout must not read as overflow and nudge an aligned strip.
29
+ const TOLERANCE = 1;
30
+
31
+ // scroll-padding computes to `auto`, a length, or a percentage of the
32
+ // scrollport; auto adds no inset. A calc() mixing the two reads as none.
33
+ const inset = (value, scrollport) =>
34
+ value.endsWith("%")
35
+ ? (Number.parseFloat(value) * scrollport) / 100
36
+ : Number.parseFloat(value) || 0;
37
+
38
+ export function ensureInlineVisible(container, target) {
39
+ const width = container?.clientWidth;
40
+ if (!width || !target?.getClientRects().length) return;
41
+ const style = getComputedStyle(container);
42
+ const box = container.getBoundingClientRect();
43
+ const scale = box.width / container.offsetWidth || 1;
44
+ const start = box.left + (container.clientLeft + inset(style.scrollPaddingLeft, width)) * scale;
45
+ const end =
46
+ box.left + (container.clientLeft + width - inset(style.scrollPaddingRight, width)) * scale;
47
+ const rect = target.getBoundingClientRect();
48
+ const before = rect.left < start - TOLERANCE;
49
+ const after = rect.right > end + TOLERANCE;
50
+ if (before === after) return;
51
+ const delta = before ? rect.left - start : rect.right - end;
52
+ // A placement, not a transition: it must not animate under scroll-behavior: smooth.
53
+ container.scrollBy({ left: delta / scale, behavior: "instant" });
54
+ }
@@ -0,0 +1,21 @@
1
+ /*
2
+ * Runs a callback once the document has finished parsing — internal, not a
3
+ * package export.
4
+ *
5
+ * The runtime connects an element as soon as the parser inserts it. With a
6
+ * classic script in <head>, a <script> inside the element hands control back
7
+ * mid-parse, so an enhancer that reads its children at connect can find them
8
+ * missing. Deferring that one read to DOMContentLoaded needs no observer; after
9
+ * parsing the callback runs at once. Returns a cancel for the enhancer's
10
+ * cleanup, so an element removed before the document is parsed does nothing.
11
+ */
12
+ export function afterParse(node, callback) {
13
+ const doc = node.ownerDocument;
14
+ if (doc.readyState !== "loading") {
15
+ callback();
16
+ return () => {};
17
+ }
18
+ const controller = new AbortController();
19
+ doc.addEventListener("DOMContentLoaded", callback, { once: true, signal: controller.signal });
20
+ return () => controller.abort();
21
+ }
package/src/js/tab.js CHANGED
@@ -9,6 +9,8 @@
9
9
  * ArrowUp/Down for aria-orientation="vertical"
10
10
  * ArrowDown (focus selected panel)
11
11
  * [hidden], disabled and panel-less tabs are skipped
12
+ * Overflow: a horizontal strip shows its whole selected tab, at connect and
13
+ * on every selection
12
14
  *
13
15
  * Self-registers via registerEnhancement: injected tablists wire automatically.
14
16
  * The tab→panel map is rebuilt from the live tablist on each interaction,
@@ -17,9 +19,13 @@
17
19
  */
18
20
 
19
21
  import { registerEnhancement } from "./enhance.js";
22
+ import { ensureInlineVisible } from "./inline-visible.js";
20
23
  import { itemForKey, shouldIgnoreKey } from "./keys.js";
24
+ import { afterParse } from "./parsed.js";
21
25
 
22
- const TABLIST_SELECTOR = '[data-enhance~="tabs"]';
26
+ // Ownership follows the ARIA structure, not the activation token: a tab
27
+ // belongs to its closest role="tablist", enhanced or not.
28
+ const TABLIST_SELECTOR = '[role="tablist"]';
23
29
 
24
30
  // A tablist owns only the tabs directly beneath it, not those of a nested
25
31
  // tablist. Filtering by closest owner keeps the outer list from operating on
@@ -85,10 +91,19 @@ function activate(tab) {
85
91
  }
86
92
  }
87
93
 
88
- function activateAndFocus(tab) {
94
+ /* An overflowing horizontal strip shows the whole tab. At connect a
95
+ server-selected tab may render off-screen; on arrow keys and clicks the
96
+ native focus scroll is not enough: Chrome skips a tab already partly inside
97
+ the scrollport, so one showing a sliver at the edge stayed clipped. */
98
+ function reveal(list, tab) {
99
+ if (list.getAttribute("aria-orientation") !== "vertical") ensureInlineVisible(list, tab);
100
+ }
101
+
102
+ function activateAndFocus(list, tab) {
89
103
  if (!tab) return;
90
104
  activate(tab);
91
105
  tab.focus();
106
+ reveal(list, tab);
92
107
  }
93
108
 
94
109
  function initialize(list) {
@@ -96,6 +111,7 @@ function initialize(list) {
96
111
  if (!tabs.length) return;
97
112
  const selected = tabs.find((tab) => tab.getAttribute("aria-selected") === "true") || tabs[0];
98
113
  activate(selected);
114
+ reveal(list, selected);
99
115
  }
100
116
 
101
117
  function onKeydown(e) {
@@ -133,7 +149,7 @@ function onKeydown(e) {
133
149
  if (!target) return;
134
150
 
135
151
  e.preventDefault();
136
- activateAndFocus(target);
152
+ activateAndFocus(list, target);
137
153
  }
138
154
 
139
155
  function onClick(e) {
@@ -146,14 +162,24 @@ function onClick(e) {
146
162
  }
147
163
  e.preventDefault();
148
164
  if (tab.getAttribute("aria-selected") === "true") return;
149
- activate(tab);
150
- tab.focus();
165
+ activateAndFocus(list, tab);
151
166
  }
152
167
 
153
168
  registerEnhancement("tabs", (list) => {
169
+ // Tabs find their list through role="tablist"; without it nothing would
170
+ // respond, so say why instead of wiring a dead strip.
171
+ if (list.getAttribute("role") !== "tablist") {
172
+ console.warn('data-enhance="tabs" needs role="tablist" on the same element', list);
173
+ return;
174
+ }
154
175
  const controller = new AbortController();
155
- initialize(list);
176
+ // Once parsed: a tablist connected mid-parse had no tabs yet, so the roving
177
+ // tabindex and the selected tab's placement were skipped for good.
178
+ const cancelInit = afterParse(list, () => initialize(list));
156
179
  list.addEventListener("click", onClick, { signal: controller.signal });
157
180
  list.addEventListener("keydown", onKeydown, { signal: controller.signal });
158
- return () => controller.abort();
181
+ return () => {
182
+ cancelInit();
183
+ controller.abort();
184
+ };
159
185
  });
@@ -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,6 +42,7 @@ export const THEME_COLORS = [
42
42
  "text-subtle",
43
43
  "heading",
44
44
  "border",
45
+ "border-control",
45
46
  "focus",
46
47
  "hover-overlay",
47
48
  "hover-overlay-solid",
@@ -131,7 +132,6 @@ export const NOT_EXPORTED = {
131
132
  "soft-border-mix",
132
133
  "soft-hover-alpha",
133
134
  "soft-fg-mix",
134
- "focus-outline",
135
135
  "focus-outline-color",
136
136
  ],
137
137
  "soft recipe, exported with the components": [
@@ -162,7 +162,9 @@ const ROLE_OF = new Map(
162
162
 
163
163
  const DECLARATION_RE = /(?<![-\w])--([a-z0-9-]+)\s*:\s*([^;{}]*);/gi;
164
164
  const ALIAS_RE = /^var\(\s*--([a-z0-9-]+)\s*\)$/i;
165
- 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;
166
168
 
167
169
  export function declaredTokens(css) {
168
170
  return new Set([...stripComments(css).matchAll(DECLARATION_RE)].map((m) => m[1]));
@@ -183,17 +185,29 @@ export function aliasCandidates(...sources) {
183
185
  return aliases;
184
186
  }
185
187
 
186
- /* The one data-theme value a theme file declares, light/dark excluded. */
187
- 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) {
188
194
  const names = new Set(
189
- [...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"),
190
199
  );
191
- 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) {
192
206
  throw new Error(
193
- `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.`,
194
208
  );
195
209
  }
196
- return [...names][0];
210
+ return names.size === 1 ? [...names][0] : null;
197
211
  }
198
212
 
199
213
  export async function coreTokenCss() {
@@ -246,7 +260,7 @@ function readIslands({ css, islands, colors, lengths, weights }) {
246
260
  const raw = read(name);
247
261
  if (raw) values.weights[name] = Number(raw);
248
262
  }
249
- values.scheme = attribute ? getComputedStyle(island).colorScheme : null;
263
+ values.scheme = getComputedStyle(island).colorScheme;
250
264
  result.push(values);
251
265
  island.remove();
252
266
  }
@@ -266,31 +280,28 @@ const evaluateIslands = (input) => inPage(readIslands, input);
266
280
 
267
281
  /*
268
282
  * Resolves one theme. `css` is the full stylesheet (framework + theme, imports
269
- * inlined). Without `theme`, the default theme is read through its light and
270
- * dark boundaries. A named theme exports the schemes it declares: `light dark`
271
- * gives two modes, anything else one. `islands` is returned so component
272
- * 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.
273
287
  */
274
- export async function resolveTokens({ css, theme, aliasSources = [] }) {
275
- let islands = [
276
- { mode: "light", attribute: "light" },
277
- { mode: "dark", attribute: "dark" },
278
- ];
279
- if (theme) {
280
- const [declared] = await evaluateIslands({
281
- css,
282
- islands: [{ attribute: theme }],
283
- colors: [],
284
- lengths: [],
285
- weights: [],
286
- });
287
- const schemes = declared.scheme.split(/\s+/).filter((s) => s === "light" || s === "dark");
288
- islands = (schemes.length > 0 ? schemes : ["light"]).map((mode) => ({
289
- mode,
290
- attribute: theme,
291
- scheme: mode,
292
- }));
293
- }
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
+ }));
294
305
 
295
306
  const read = await evaluateIslands({
296
307
  css,
@@ -24,10 +24,13 @@ const FRAMEWORK = fileURLToPath(new URL("../css/actual.full.css", import.meta.ur
24
24
  export const INTENTS = ["primary", "secondary", "success", "warning", "danger", "neutral"];
25
25
 
26
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) {
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) {
29
32
  const style = scheme ? ` style="color-scheme: ${scheme}"` : "";
30
- return `<div data-theme="${attribute}"${style} id="${id}">
33
+ return `<div${style} id="${id}">
31
34
  ${INTENTS.map((i) => `<span class="badge soft ${i}" data-badge="${i}">t</span>`).join("")}
32
35
  ${INTENTS.map((i) => `<button class="btn soft ${i}" data-hover="${i}" type="button">t</button>`).join("")}
33
36
  <span data-pair="focus-surface" style="color: var(--focus); background: var(--surface)"></span>
@@ -41,23 +44,27 @@ const ratio = (fg, bg) => (fg[3] < 255 || bg[3] < 255 ? null : contrast(fg, bg))
41
44
 
42
45
  /*
43
46
  * 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
+ * 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.
47
51
  */
48
- export async function measureContrast({ css, themes }) {
52
+ export async function measureContrast({ css, themes, label = "default" }) {
49
53
  await using view = new Bun.WebView({ backend: "chrome" });
50
54
  await view.navigate("about:blank");
51
55
 
52
56
  // Transitions off: a forced :hover must compute its end state at once.
53
- const islands = await view.evaluate(`((css, themes) => {
57
+ const islands = await view.evaluate(`((css, themes, label) => {
54
58
  const style = document.createElement("style");
55
59
  style.textContent = css + "\\n* { transition-duration: 0s !important; }";
56
60
  document.head.append(style);
57
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.
58
65
  const schemesOf = (attribute) => {
59
66
  const probe = document.createElement("div");
60
- probe.dataset.theme = attribute;
67
+ if (attribute) probe.dataset.theme = attribute;
61
68
  document.body.append(probe);
62
69
  const declared = getComputedStyle(probe).colorScheme.split(/\\s+/);
63
70
  probe.remove();
@@ -65,19 +72,19 @@ export async function measureContrast({ css, themes }) {
65
72
  return schemes.length > 0 ? schemes : ["light"];
66
73
  };
67
74
 
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 })),
75
+ return themes.flatMap((attribute) =>
76
+ schemesOf(attribute).map((scheme) => ({ theme: attribute ?? label, scheme, attribute })),
72
77
  );
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)})`);
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)})`);
81
88
 
82
89
  await view.cdp("DOM.enable");
83
90
  await view.cdp("CSS.enable");
@@ -136,13 +143,14 @@ export async function measureContrast({ css, themes }) {
136
143
 
137
144
  /*
138
145
  * Measures one theme file over the framework, or the default theme without
139
- * `themeFile`. `name` overrides the data-theme name read from the file.
146
+ * `themeFile`. `name` overrides the data-theme name read from the file; a file
147
+ * that declares none is a :root theme.
140
148
  */
141
149
  export async function measureThemeFile({ themeFile, name }) {
142
150
  const themeCss = themeFile ? await inlineImports(themeFile) : "";
143
- const theme = themeFile ? (name ?? themeNameOf(themeCss)) : null;
151
+ const theme = themeFile ? themeNameOf(themeCss, name) : null;
144
152
  const css = `${await inlineImports(FRAMEWORK)}\n${themeCss}`;
145
- return measureContrast({ css, themes: [theme] });
153
+ return measureContrast({ css, themes: [theme], label: themeFile ? ":root" : "default" });
146
154
  }
147
155
 
148
156
  /* Text tables for `rows`, and the number of pairs under their threshold. */