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.
- package/CHANGELOG.md +96 -0
- package/README.md +25 -6
- package/components.json +12 -18
- package/dist/actual.full.css +780 -479
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/brand/favicon.svg +5 -0
- package/docs/brand/lockup.svg +12 -0
- package/docs/brand/logo-dark.svg +9 -0
- package/docs/brand/logo-light.svg +9 -0
- package/docs/brand/logo.svg +11 -0
- package/docs/brand/mark.svg +11 -0
- package/docs/design-notes/architecture.md +67 -0
- package/docs/design-notes/steps.md +45 -28
- package/docs/navigation.json +3 -2
- package/docs/pages/components/alert.md +2 -10
- package/docs/pages/components/avatar.md +17 -11
- package/docs/pages/components/badge.md +3 -9
- package/docs/pages/components/breadcrumb.md +16 -2
- package/docs/pages/components/card.md +31 -2
- package/docs/pages/components/dialog.md +121 -33
- package/docs/pages/components/drawer.md +2 -2
- package/docs/pages/components/list.md +9 -13
- package/docs/pages/components/navbar.md +49 -30
- package/docs/pages/components/pagination.md +19 -0
- package/docs/pages/components/steps.md +24 -20
- package/docs/pages/components/table.md +3 -3
- package/docs/pages/enhancements/flyout.md +2 -2
- package/docs/pages/enhancements/scrollspy.md +7 -7
- package/docs/pages/enhancements/status-bar.md +2 -2
- package/docs/pages/enhancements/tabs.md +74 -3
- package/docs/pages/examples/overview.md +2 -1
- package/docs/pages/forms/date-picker.md +90 -0
- package/docs/pages/forms/detached-actions.md +37 -30
- package/docs/pages/forms/floating-field.md +21 -7
- package/docs/pages/forms/form-actions.md +58 -28
- package/docs/pages/forms/input-policy.md +1 -1
- package/docs/pages/forms/overview.md +6 -5
- package/docs/pages/forms/states-and-sizes.md +15 -15
- package/docs/pages/forms/sticky-actions.md +85 -83
- package/docs/pages/forms/validation.md +5 -5
- package/docs/pages/foundations/philosophy.md +102 -4
- package/docs/pages/foundations/theming.md +15 -4
- package/docs/pages/foundations/tokens.md +89 -36
- package/docs/pages/foundations/typography.md +4 -7
- package/docs/pages/guides/bootstrap.md +2 -2
- package/docs/pages/guides/design-tools.md +2 -2
- package/docs/pages/guides/integrating-actual-css.md +79 -9
- package/docs/pages/guides/introduction.md +5 -5
- package/docs/pages/guides/modular-import.md +3 -0
- package/docs/pages/guides/progressive-enhancement.md +27 -9
- package/docs/pages/guides/tailwind.md +70 -0
- package/docs/pages/guides/upgrading.md +151 -0
- package/docs/pages/layout/center.md +2 -1
- package/docs/pages/layout/cluster.md +37 -4
- package/docs/pages/layout/grid.md +2 -0
- package/docs/pages/layout/overview.md +1 -1
- package/docs/pages/layout/sidebar-layout.md +3 -0
- package/docs/pages/layout/stack.md +21 -16
- package/docs/pages/layout/switcher.md +2 -0
- package/docs/pages/layout/topbar.md +7 -5
- package/docs/pages/patterns/icon-slot.md +17 -1
- package/docs/pages/patterns/navigation-list.md +16 -46
- package/docs/pages/patterns/overview.md +3 -4
- package/docs/pages/utilities/flex.md +30 -20
- package/docs/pages/utilities/sizing-wrapping.md +52 -7
- package/docs/pages/utilities/spacing.md +4 -3
- package/package.json +6 -6
- package/reserved-classes.json +3 -3
- package/skills/actual-css/references/enhancements.md +10 -7
- package/src/cli/actual-css.js +39 -13
- package/src/css/components/alert.css +34 -34
- package/src/css/components/avatar.css +8 -17
- package/src/css/components/badge.css +1 -6
- package/src/css/components/breadcrumb.css +74 -8
- package/src/css/components/button.css +3 -9
- package/src/css/components/card.css +18 -10
- package/src/css/components/chat.css +4 -5
- package/src/css/components/drawer.css +42 -45
- package/src/css/components/flyout.css +3 -1
- package/src/css/components/index.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 +113 -134
- package/src/css/components/navbar.css +22 -31
- 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/base.css +1 -1
- package/src/css/core/focus.css +8 -6
- package/src/css/core/reset.css +2 -1
- package/src/css/core/theme.css +10 -1
- package/src/css/core/tokens.css +32 -12
- package/src/css/core/variants.css +41 -14
- 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 +14 -12
- 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 +25 -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/layout/topbar.css +12 -6
- package/src/css/utilities/base.css +5 -0
- package/src/css/utilities/extra.css +3 -7
- 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/color.js +65 -0
- package/src/tooling/design-export.js +1 -1
- package/src/tooling/design-tokens.js +53 -34
- package/src/tooling/theme-contrast.js +217 -0
- package/docs/pages/patterns/actions.md +0 -52
- 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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 ${
|
|
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 =
|
|
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
|
|
262
|
-
*
|
|
263
|
-
* gives two modes, anything else one. `islands` is
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
]
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
}
|