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.
- package/CHANGELOG.md +49 -0
- package/README.md +7 -8
- package/components.json +8 -2
- package/dist/actual.full.css +505 -233
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/design-notes/architecture.md +67 -0
- package/docs/design-notes/steps.md +45 -28
- package/docs/pages/components/avatar.md +17 -11
- package/docs/pages/components/breadcrumb.md +16 -2
- package/docs/pages/components/list.md +9 -13
- package/docs/pages/components/navbar.md +42 -22
- package/docs/pages/components/pagination.md +19 -0
- package/docs/pages/components/steps.md +24 -20
- package/docs/pages/enhancements/scrollspy.md +6 -6
- package/docs/pages/enhancements/tabs.md +74 -3
- package/docs/pages/forms/floating-field.md +21 -7
- package/docs/pages/forms/form-actions.md +3 -6
- package/docs/pages/forms/states-and-sizes.md +15 -15
- package/docs/pages/foundations/philosophy.md +97 -1
- package/docs/pages/foundations/tokens.md +40 -4
- package/docs/pages/guides/design-tools.md +1 -1
- package/docs/pages/guides/integrating-actual-css.md +74 -8
- package/docs/pages/guides/introduction.md +3 -2
- package/docs/pages/guides/modular-import.md +1 -1
- package/docs/pages/guides/progressive-enhancement.md +25 -7
- package/docs/pages/guides/upgrading.md +86 -9
- package/docs/pages/layout/center.md +2 -1
- package/docs/pages/layout/cluster.md +11 -4
- package/docs/pages/layout/grid.md +2 -0
- package/docs/pages/layout/sidebar-layout.md +3 -0
- package/docs/pages/layout/switcher.md +2 -0
- package/docs/pages/layout/topbar.md +6 -4
- package/docs/pages/patterns/icon-slot.md +17 -1
- package/docs/pages/utilities/flex.md +1 -1
- package/docs/pages/utilities/sizing-wrapping.md +51 -6
- package/package.json +5 -5
- package/src/cli/actual-css.js +1 -1
- package/src/css/components/alert.css +34 -34
- package/src/css/components/avatar.css +2 -18
- package/src/css/components/badge.css +0 -1
- package/src/css/components/breadcrumb.css +74 -8
- package/src/css/components/drawer.css +23 -42
- package/src/css/components/flyout.css +0 -1
- package/src/css/components/join.css +2 -2
- package/src/css/components/list.css +10 -10
- package/src/css/components/modal.css +17 -10
- package/src/css/components/navbar.css +10 -8
- package/src/css/components/pagination.css +5 -0
- package/src/css/components/scrollspy.css +1 -1
- package/src/css/components/steps.css +66 -12
- package/src/css/components/tab.css +134 -4
- package/src/css/core/focus.css +8 -6
- package/src/css/core/theme.css +6 -1
- package/src/css/core/tokens.css +4 -2
- package/src/css/forms/choice-card.css +1 -1
- package/src/css/forms/choice.css +1 -1
- package/src/css/forms/control.css +1 -1
- package/src/css/forms/floating-field.css +20 -18
- package/src/css/forms/form-actions.css +5 -8
- package/src/css/forms/native.css +2 -2
- package/src/css/forms/otp.css +4 -5
- package/src/css/forms/switch.css +1 -1
- package/src/css/forms/validation.css +23 -30
- package/src/css/layout/center.css +2 -0
- package/src/css/layout/cluster.css +14 -0
- package/src/css/layout/grid.css +7 -0
- package/src/css/layout/scroller.css +19 -3
- package/src/css/layout/sidebar-layout.css +3 -0
- package/src/css/layout/switcher.css +3 -0
- package/src/css/utilities/extra.css +3 -3
- package/src/js/full.js +4 -0
- package/src/js/inline-visible.js +54 -0
- package/src/js/parsed.js +21 -0
- package/src/js/tab.js +33 -7
- package/src/tooling/design-export.js +1 -1
- package/src/tooling/design-tokens.js +44 -33
- 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
|
+
}
|
package/src/js/parsed.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 () =>
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 ${
|
|
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 =
|
|
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
|
|
270
|
-
*
|
|
271
|
-
* gives two modes, anything else one. `islands` is
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
]
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
45
|
-
*
|
|
46
|
-
* its `color-scheme` declares. Returns one
|
|
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((
|
|
69
|
-
theme
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
.
|
|
80
|
-
|
|
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 ?
|
|
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. */
|