actual-css 0.13.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.
- package/CHANGELOG.md +53 -0
- package/README.md +4 -4
- package/components.json +116 -10
- package/dist/actual.full.css +648 -77
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/navigation.json +2 -0
- package/docs/pages/components/alert.md +12 -2
- package/docs/pages/components/button.md +3 -2
- package/docs/pages/components/list.md +6 -3
- package/docs/pages/components/segmented.md +83 -0
- package/docs/pages/components/steps.md +4 -2
- package/docs/pages/enhancements/flyout.md +5 -0
- package/docs/pages/enhancements/tabs.md +7 -2
- package/docs/pages/examples/overview.md +3 -1
- package/docs/pages/forms/color-swatches.md +131 -0
- package/docs/pages/forms/combobox.md +2 -2
- package/docs/pages/forms/controls.md +6 -3
- package/docs/pages/forms/joined-controls.md +6 -2
- package/docs/pages/forms/overview.md +1 -0
- package/docs/pages/forms/switch.md +4 -2
- package/docs/pages/foundations/theming.md +1 -1
- package/docs/pages/foundations/tokens.md +54 -23
- package/docs/pages/guides/design-tools.md +13 -7
- package/docs/pages/guides/introduction.md +1 -1
- package/docs/pages/layout/scroll-snap.md +4 -2
- package/package.json +2 -2
- package/reserved-classes.json +3 -0
- package/src/cli/actual-css.js +3 -3
- package/src/css/components/alert.css +65 -14
- package/src/css/components/app-nav.css +17 -9
- package/src/css/components/badge.css +23 -9
- package/src/css/components/button.css +17 -2
- package/src/css/components/close.css +6 -3
- package/src/css/components/index.css +1 -0
- package/src/css/components/list.css +18 -3
- package/src/css/components/navbar.css +5 -2
- package/src/css/components/segmented.css +186 -0
- package/src/css/components/steps.css +6 -3
- package/src/css/components/tab.css +1 -1
- package/src/css/core/focus.css +3 -0
- package/src/css/core/tokens.css +20 -4
- package/src/css/core/variants.css +16 -5
- package/src/css/forms/color-swatch.css +159 -0
- package/src/css/forms/form-actions.css +9 -0
- package/src/css/forms/index.css +1 -0
- package/src/css/forms/native.css +50 -20
- package/src/css/forms/switch.css +5 -2
- package/src/css/layout/scroll-snap.css +30 -0
- package/src/css/layout/topbar.css +10 -0
- package/src/js/focus.js +16 -1
- package/src/js/surface.js +10 -0
- package/src/tooling/color.js +14 -1
- package/src/tooling/css-bundle.js +0 -5
- package/src/tooling/design-components.js +9 -5
- package/src/tooling/design-export.js +8 -0
- package/src/tooling/design-tokens.js +163 -43
- package/src/tooling/theme-contrast.js +151 -22
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Design-tool token export: resolves a theme's tokens in headless Chrome
|
|
3
|
-
* writes them for Figma
|
|
4
|
-
*
|
|
2
|
+
* Design-tool token export: resolves a theme's tokens in headless Chrome into
|
|
3
|
+
* a target-neutral model, then writes them for Figma, Penpot, and a portable
|
|
4
|
+
* DTCG profile. Figma and Penpot get the compromises their importers need; the
|
|
5
|
+
* DTCG profile stays spec-conformant (correct $types, 6-digit `hex`).
|
|
5
6
|
*
|
|
6
7
|
* Values are read from the browser, never from the CSS text: tokens are built
|
|
7
8
|
* with light-dark(), color-mix() and var() chains that only the browser
|
|
@@ -56,8 +57,9 @@ export const THEME_COLORS = [
|
|
|
56
57
|
"backdrop-fill",
|
|
57
58
|
];
|
|
58
59
|
|
|
59
|
-
/* Mode-independent tokens by role. The role
|
|
60
|
-
|
|
60
|
+
/* Mode-independent tokens by role. The role drives the DTCG type (see
|
|
61
|
+
DTCG_TYPE) and the Penpot type; length roles plus font-weight are the ones
|
|
62
|
+
Figma and Penpot read, the rest are DTCG-only. */
|
|
61
63
|
export const FOUNDATIONS = {
|
|
62
64
|
spacing: [
|
|
63
65
|
"space-10",
|
|
@@ -89,32 +91,23 @@ export const FOUNDATIONS = {
|
|
|
89
91
|
"font-weight-semibold",
|
|
90
92
|
"font-weight-bold",
|
|
91
93
|
],
|
|
94
|
+
"font-family": ["font-sans", "font-mono"],
|
|
95
|
+
duration: ["duration", "duration-fast", "duration-slow", "duration-spin", "duration-shimmer"],
|
|
96
|
+
"cubic-bezier": ["ease-enter", "ease-exit"],
|
|
97
|
+
number: ["line-height", "line-height-tight", "line-height-relaxed"],
|
|
92
98
|
};
|
|
93
99
|
|
|
94
100
|
/* Every custom property declared by the core token files is exported or
|
|
95
101
|
listed here (tests/design-tokens.test.js), so a new token cannot silently
|
|
96
102
|
stay out of the design tools. */
|
|
97
103
|
export const NOT_EXPORTED = {
|
|
98
|
-
"font stack; a design tool font token holds one family": ["font-sans", "font-mono"],
|
|
99
104
|
"internal 0/1 weights selecting a control step; the steps are exported": [
|
|
100
105
|
"control-step-sm",
|
|
101
106
|
"control-step-md",
|
|
102
107
|
"control-step-lg",
|
|
103
108
|
],
|
|
104
|
-
"no
|
|
109
|
+
"no DTCG type that keeps its meaning": [
|
|
105
110
|
"font-width",
|
|
106
|
-
"line-height",
|
|
107
|
-
"line-height-tight",
|
|
108
|
-
"line-height-relaxed",
|
|
109
|
-
"shadow",
|
|
110
|
-
"shadow-popout",
|
|
111
|
-
"duration",
|
|
112
|
-
"duration-fast",
|
|
113
|
-
"duration-slow",
|
|
114
|
-
"duration-spin",
|
|
115
|
-
"duration-shimmer",
|
|
116
|
-
"ease-enter",
|
|
117
|
-
"ease-exit",
|
|
118
111
|
"viewport-block",
|
|
119
112
|
"viewport-inline",
|
|
120
113
|
"z-sticky",
|
|
@@ -125,6 +118,7 @@ export const NOT_EXPORTED = {
|
|
|
125
118
|
"icon-close",
|
|
126
119
|
"icon-plus",
|
|
127
120
|
],
|
|
121
|
+
"composite DTCG types the export does not build yet": ["shadow", "shadow-popout"],
|
|
128
122
|
"input of a derived value": [
|
|
129
123
|
"backdrop-color",
|
|
130
124
|
"backdrop-opacity",
|
|
@@ -142,6 +136,7 @@ export const NOT_EXPORTED = {
|
|
|
142
136
|
"danger-soft-fg",
|
|
143
137
|
"neutral-soft-fg",
|
|
144
138
|
],
|
|
139
|
+
"fallback-only state hook, read at the point of use": ["state-selected-text"],
|
|
145
140
|
"internal or plumbing": [
|
|
146
141
|
"density-space",
|
|
147
142
|
"disabled-opacity",
|
|
@@ -152,10 +147,13 @@ export const NOT_EXPORTED = {
|
|
|
152
147
|
],
|
|
153
148
|
};
|
|
154
149
|
|
|
150
|
+
// Length roles carry a px value plus the CSS source; every other role carries
|
|
151
|
+
// one scalar read as-is and parsed by role (see PARSE).
|
|
152
|
+
const LENGTH_ROLES = ["spacing", "radius", "border-width", "font-size", "size", "offset"];
|
|
153
|
+
const RAW_ROLES = ["font-family", "duration", "cubic-bezier", "number"];
|
|
154
|
+
const LENGTHS = LENGTH_ROLES.flatMap((role) => FOUNDATIONS[role]);
|
|
155
155
|
const WEIGHTS = FOUNDATIONS["font-weight"];
|
|
156
|
-
const
|
|
157
|
-
role === "font-weight" ? [] : names,
|
|
158
|
-
);
|
|
156
|
+
const RAWS = RAW_ROLES.flatMap((role) => FOUNDATIONS[role]);
|
|
159
157
|
const ROLE_OF = new Map(
|
|
160
158
|
Object.entries(FOUNDATIONS).flatMap(([role, names]) => names.map((n) => [n, role])),
|
|
161
159
|
);
|
|
@@ -220,7 +218,7 @@ export async function coreTokenCss() {
|
|
|
220
218
|
* what light-dark() resolves against. Colors go through color-mix(in srgb) so
|
|
221
219
|
* every color syntax computes to the same `color(srgb r g b / a)` form.
|
|
222
220
|
*/
|
|
223
|
-
function readIslands({ css, islands, colors, lengths, weights }) {
|
|
221
|
+
function readIslands({ css, islands, colors, lengths, weights, raws }) {
|
|
224
222
|
const style = document.createElement("style");
|
|
225
223
|
style.textContent = css;
|
|
226
224
|
document.head.append(style);
|
|
@@ -236,7 +234,7 @@ function readIslands({ css, islands, colors, lengths, weights }) {
|
|
|
236
234
|
document.body.append(island);
|
|
237
235
|
|
|
238
236
|
const computed = getComputedStyle(probe);
|
|
239
|
-
const values = { colors: {}, lengths: {}, weights: {}, missing: [] };
|
|
237
|
+
const values = { colors: {}, lengths: {}, weights: {}, raws: {}, missing: [] };
|
|
240
238
|
const read = (name) => {
|
|
241
239
|
const raw = computed.getPropertyValue(`--${name}`).trim();
|
|
242
240
|
if (!raw) values.missing.push(name);
|
|
@@ -260,6 +258,10 @@ function readIslands({ css, islands, colors, lengths, weights }) {
|
|
|
260
258
|
const raw = read(name);
|
|
261
259
|
if (raw) values.weights[name] = Number(raw);
|
|
262
260
|
}
|
|
261
|
+
for (const name of raws) {
|
|
262
|
+
const raw = read(name);
|
|
263
|
+
if (raw) values.raws[name] = raw;
|
|
264
|
+
}
|
|
263
265
|
values.scheme = getComputedStyle(island).colorScheme;
|
|
264
266
|
result.push(values);
|
|
265
267
|
island.remove();
|
|
@@ -295,6 +297,7 @@ export async function resolveTokens({ css, theme = null, aliasSources = [] }) {
|
|
|
295
297
|
colors: [],
|
|
296
298
|
lengths: [],
|
|
297
299
|
weights: [],
|
|
300
|
+
raws: [],
|
|
298
301
|
});
|
|
299
302
|
const schemes = declared.scheme.split(/\s+/).filter((s) => s === "light" || s === "dark");
|
|
300
303
|
const islands = (schemes.length > 0 ? schemes : ["light"]).map((mode) => ({
|
|
@@ -309,6 +312,7 @@ export async function resolveTokens({ css, theme = null, aliasSources = [] }) {
|
|
|
309
312
|
colors: THEME_COLORS,
|
|
310
313
|
lengths: LENGTHS,
|
|
311
314
|
weights: WEIGHTS,
|
|
315
|
+
raws: RAWS,
|
|
312
316
|
});
|
|
313
317
|
const missing = [...new Set(read.flatMap((values) => values.missing))];
|
|
314
318
|
if (missing.length > 0) {
|
|
@@ -321,9 +325,13 @@ export async function resolveTokens({ css, theme = null, aliasSources = [] }) {
|
|
|
321
325
|
Object.fromEntries(Object.entries(read[i].colors).map(([n, c]) => [n, parseSrgb(c)])),
|
|
322
326
|
]),
|
|
323
327
|
);
|
|
324
|
-
// Geometry
|
|
325
|
-
|
|
326
|
-
|
|
328
|
+
// Geometry and the raw foundations do not follow the color scheme; the first
|
|
329
|
+
// mode is the reference.
|
|
330
|
+
const [{ lengths, weights, raws }] = read;
|
|
331
|
+
const extras = Object.fromEntries(
|
|
332
|
+
Object.entries(raws).map(([name, raw]) => [name, PARSE[ROLE_OF.get(name)](raw)]),
|
|
333
|
+
);
|
|
334
|
+
return { islands, modes, lengths, weights, extras, aliases: aliasCandidates(...aliasSources) };
|
|
327
335
|
}
|
|
328
336
|
|
|
329
337
|
export function parseSrgb(value) {
|
|
@@ -336,14 +344,60 @@ export function parseSrgb(value) {
|
|
|
336
344
|
};
|
|
337
345
|
}
|
|
338
346
|
|
|
347
|
+
/* Raw foundation readers. Each role maps to one parser, so a value the browser
|
|
348
|
+
reports in an unexpected shape fails loudly instead of exporting garbage. */
|
|
349
|
+
export function parseDuration(value) {
|
|
350
|
+
const match = value.match(/^([\d.]+)(ms|s)$/);
|
|
351
|
+
if (!match) throw new Error(`Unexpected duration: ${value}`);
|
|
352
|
+
return { value: Number(match[1]), unit: match[2] };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function parseCubicBezier(value) {
|
|
356
|
+
const match = value.match(/^cubic-bezier\(([^)]+)\)$/);
|
|
357
|
+
const numbers = match?.[1].split(",").map((n) => Number(n.trim()));
|
|
358
|
+
if (numbers?.length !== 4 || numbers.some(Number.isNaN)) {
|
|
359
|
+
throw new Error(`Unexpected cubic-bezier: ${value}`);
|
|
360
|
+
}
|
|
361
|
+
return numbers;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function parseFontFamily(value) {
|
|
365
|
+
return value
|
|
366
|
+
.split(",")
|
|
367
|
+
.map((family) => family.trim().replace(/^["']|["']$/g, ""))
|
|
368
|
+
.filter(Boolean);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function parseNumber(value) {
|
|
372
|
+
const n = Number(value);
|
|
373
|
+
if (Number.isNaN(n)) throw new Error(`Unexpected number: ${value}`);
|
|
374
|
+
return n;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const PARSE = {
|
|
378
|
+
"font-family": parseFontFamily,
|
|
379
|
+
duration: parseDuration,
|
|
380
|
+
"cubic-bezier": parseCubicBezier,
|
|
381
|
+
number: parseNumber,
|
|
382
|
+
};
|
|
383
|
+
|
|
339
384
|
const round = (n) => Math.round(n * 10000) / 10000;
|
|
340
385
|
|
|
386
|
+
const byte = (n) =>
|
|
387
|
+
Math.round(n * 255)
|
|
388
|
+
.toString(16)
|
|
389
|
+
.padStart(2, "0");
|
|
390
|
+
|
|
391
|
+
/* DTCG color `hex` is a 6-digit fallback: alpha is carried separately, and the
|
|
392
|
+
spec requires the hex itself to be 6 digits. */
|
|
393
|
+
export function hexRgbOf({ components }) {
|
|
394
|
+
return `#${components.map(byte).join("")}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/* The CSS-fidelity form: keeps alpha in the hex for Penpot and for comparing
|
|
398
|
+
colors across modes (two colors that share RGB but not alpha must differ). */
|
|
341
399
|
export function hexOf({ components, alpha }) {
|
|
342
|
-
|
|
343
|
-
Math.round(n * 255)
|
|
344
|
-
.toString(16)
|
|
345
|
-
.padStart(2, "0");
|
|
346
|
-
return `#${components.map(byte).join("")}${alpha < 1 ? byte(alpha) : ""}`;
|
|
400
|
+
return `${hexRgbOf({ components })}${alpha < 1 ? byte(alpha) : ""}`;
|
|
347
401
|
}
|
|
348
402
|
|
|
349
403
|
const colorsEqual = (a, b) =>
|
|
@@ -357,11 +411,12 @@ function aliasOf(name, valueSets, aliases, same) {
|
|
|
357
411
|
}
|
|
358
412
|
|
|
359
413
|
/*
|
|
360
|
-
* The target-neutral model
|
|
361
|
-
* `{ alias }` or `{ color, hex }
|
|
362
|
-
* `{ alias }`, `{ px, css }
|
|
414
|
+
* The target-neutral model the writers read: per mode, each color is
|
|
415
|
+
* `{ alias }` or `{ color, hex }` (hex 6-digit, the DTCG form); each
|
|
416
|
+
* foundation carries its role and is `{ alias }`, `{ px, css }`, `{ weight }`
|
|
417
|
+
* or `{ value }` for the raw roles.
|
|
363
418
|
*/
|
|
364
|
-
export function linkTokens({ modes, lengths, weights, aliases }) {
|
|
419
|
+
export function linkTokens({ modes, lengths, weights, extras = {}, aliases }) {
|
|
365
420
|
const modeSets = Object.values(modes);
|
|
366
421
|
const linkedModes = Object.fromEntries(
|
|
367
422
|
Object.entries(modes).map(([mode, colors]) => [
|
|
@@ -369,7 +424,7 @@ export function linkTokens({ modes, lengths, weights, aliases }) {
|
|
|
369
424
|
Object.fromEntries(
|
|
370
425
|
Object.entries(colors).map(([name, color]) => {
|
|
371
426
|
const alias = aliasOf(name, modeSets, aliases, colorsEqual);
|
|
372
|
-
return [name, alias ? { alias } : { color, hex:
|
|
427
|
+
return [name, alias ? { alias } : { color, hex: hexRgbOf(color) }];
|
|
373
428
|
}),
|
|
374
429
|
),
|
|
375
430
|
]),
|
|
@@ -383,6 +438,9 @@ export function linkTokens({ modes, lengths, weights, aliases }) {
|
|
|
383
438
|
for (const [name, weight] of Object.entries(weights)) {
|
|
384
439
|
foundations[name] = { role: "font-weight", weight };
|
|
385
440
|
}
|
|
441
|
+
for (const [name, value] of Object.entries(extras)) {
|
|
442
|
+
foundations[name] = { role: ROLE_OF.get(name), value };
|
|
443
|
+
}
|
|
386
444
|
return { modes: linkedModes, foundations };
|
|
387
445
|
}
|
|
388
446
|
|
|
@@ -411,13 +469,73 @@ export function toFigma({ modes, foundations }, recipes = {}) {
|
|
|
411
469
|
}
|
|
412
470
|
|
|
413
471
|
files.foundations = Object.fromEntries(
|
|
414
|
-
Object.entries(foundations).
|
|
472
|
+
Object.entries(foundations).flatMap(([name, token]) => {
|
|
415
473
|
// `number`, not DTCG `fontWeight`: Figma's import drops fontWeight
|
|
416
474
|
// tokens, and a number variable is what binds to a text layer's weight.
|
|
417
|
-
if (token.role === "font-weight")
|
|
475
|
+
if (token.role === "font-weight") {
|
|
476
|
+
return [[name, { $type: "number", $value: token.weight }]];
|
|
477
|
+
}
|
|
478
|
+
// Figma reads dimensions only; the raw roles stay in the DTCG profile.
|
|
479
|
+
if (!LENGTH_ROLES.includes(token.role)) return [];
|
|
418
480
|
const $value = token.alias ? `{${token.alias}}` : { value: token.px, unit: "px" };
|
|
419
481
|
const doc = { $type: "dimension", $value };
|
|
420
482
|
if (token.css) doc.$extensions = { [EXTENSION]: { css: token.css } };
|
|
483
|
+
return [[name, doc]];
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
return files;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* DTCG `$type` per role. Figma keeps its own adaptations (a font weight as a
|
|
490
|
+
number); the neutral profile uses the type the spec defines. */
|
|
491
|
+
const DTCG_TYPE = {
|
|
492
|
+
spacing: "dimension",
|
|
493
|
+
radius: "dimension",
|
|
494
|
+
"border-width": "dimension",
|
|
495
|
+
"font-size": "dimension",
|
|
496
|
+
size: "dimension",
|
|
497
|
+
offset: "dimension",
|
|
498
|
+
"font-weight": "fontWeight",
|
|
499
|
+
"font-family": "fontFamily",
|
|
500
|
+
duration: "duration",
|
|
501
|
+
"cubic-bezier": "cubicBezier",
|
|
502
|
+
number: "number",
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
function dtcgValue(token) {
|
|
506
|
+
if (token.alias) return `{${token.alias}}`;
|
|
507
|
+
if (token.role === "font-weight") return token.weight;
|
|
508
|
+
if (LENGTH_ROLES.includes(token.role)) return { value: token.px, unit: "px" };
|
|
509
|
+
return token.value;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/*
|
|
513
|
+
* DTCG: the portable profile. Same file split as Figma, but every foundation
|
|
514
|
+
* keeps its spec type (`fontWeight`, `duration`, `cubicBezier`, `fontFamily`,
|
|
515
|
+
* `number`), colors carry a 6-digit `hex` with alpha separate, and dimensions
|
|
516
|
+
* keep their CSS value in `$extensions`. No tool adaptation: any DTCG reader
|
|
517
|
+
* can import it.
|
|
518
|
+
*/
|
|
519
|
+
export function toDtcg({ modes, foundations }, recipes = {}) {
|
|
520
|
+
const files = {};
|
|
521
|
+
for (const [mode, colors] of Object.entries(modes)) {
|
|
522
|
+
files[mode] = Object.fromEntries(
|
|
523
|
+
Object.entries({ ...colors, ...recipes[mode] }).map(([name, token]) => [
|
|
524
|
+
name,
|
|
525
|
+
{
|
|
526
|
+
$type: "color",
|
|
527
|
+
$value: token.alias
|
|
528
|
+
? `{${token.alias}}`
|
|
529
|
+
: { colorSpace: "srgb", ...token.color, hex: token.hex },
|
|
530
|
+
},
|
|
531
|
+
]),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
files.foundations = Object.fromEntries(
|
|
536
|
+
Object.entries(foundations).map(([name, token]) => {
|
|
537
|
+
const doc = { $type: DTCG_TYPE[token.role], $value: dtcgValue(token) };
|
|
538
|
+
if (token.css) doc.$extensions = { [EXTENSION]: { css: token.css } };
|
|
421
539
|
return [name, doc];
|
|
422
540
|
}),
|
|
423
541
|
);
|
|
@@ -449,10 +567,12 @@ const PENPOT_THEME_GROUP = "Mode";
|
|
|
449
567
|
export function toPenpot({ modes, foundations }, recipes = {}) {
|
|
450
568
|
const doc = {
|
|
451
569
|
foundations: Object.fromEntries(
|
|
452
|
-
Object.entries(foundations).
|
|
570
|
+
Object.entries(foundations).flatMap(([name, token]) => {
|
|
453
571
|
const $type = PENPOT_TYPES[token.role];
|
|
454
|
-
|
|
455
|
-
|
|
572
|
+
// Penpot reads the roles it types; the DTCG-only roles stay out.
|
|
573
|
+
if (!$type) return [];
|
|
574
|
+
if (token.alias) return [[name, { $type, $value: `{${token.alias}}` }]];
|
|
575
|
+
return [[name, { $type, $value: token.weight ?? `${token.px}px` }]];
|
|
456
576
|
}),
|
|
457
577
|
),
|
|
458
578
|
};
|
|
@@ -461,7 +581,7 @@ export function toPenpot({ modes, foundations }, recipes = {}) {
|
|
|
461
581
|
Object.fromEntries(
|
|
462
582
|
Object.entries(colors).map(([name, token]) => [
|
|
463
583
|
name,
|
|
464
|
-
{ $type: "color", $value: token.alias ? `{${token.alias}}` : token.
|
|
584
|
+
{ $type: "color", $value: token.alias ? `{${token.alias}}` : hexOf(token.color) },
|
|
465
585
|
]),
|
|
466
586
|
);
|
|
467
587
|
const schemes = Object.keys(modes);
|
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
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,6 +31,11 @@ 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
40
|
(bootstrap-v6), which would measure a solid pair against the soft hover.
|
|
28
41
|
The theme name is read from a file by a regex, so it never enters this
|
|
@@ -30,9 +43,23 @@ export const INTENTS = ["primary", "secondary", "success", "warning", "danger",
|
|
|
30
43
|
"light" or "dark". */
|
|
31
44
|
function islandMarkup(id, scheme) {
|
|
32
45
|
const style = scheme ? ` style="color-scheme: ${scheme}"` : "";
|
|
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("");
|
|
33
60
|
return `<div${style} id="${id}">
|
|
34
|
-
${
|
|
35
|
-
${INTENTS.map((i) => `<button class="btn
|
|
61
|
+
${softPairs}
|
|
62
|
+
${INTENTS.map((i) => `<button class="btn solid ${i}" data-solid="${i}" type="button">t</button>`).join("")}
|
|
36
63
|
<span data-pair="focus-surface" style="color: var(--focus); background: var(--surface)"></span>
|
|
37
64
|
<span data-pair="focus-solid" style="color: var(--focus); background: var(--surface-solid)"></span>
|
|
38
65
|
<span data-pair="inverse" style="color: var(--surface-solid-fg); background: var(--surface-solid)"></span>
|
|
@@ -91,7 +118,7 @@ export async function measureContrast({ css, themes, label = "default" }) {
|
|
|
91
118
|
const { root } = await view.cdp("DOM.getDocument");
|
|
92
119
|
const { nodeIds } = await view.cdp("DOM.querySelectorAll", {
|
|
93
120
|
nodeId: root.nodeId,
|
|
94
|
-
selector:
|
|
121
|
+
selector: '[data-hover], [data-selected="nav-hover"]',
|
|
95
122
|
});
|
|
96
123
|
for (const nodeId of nodeIds) {
|
|
97
124
|
await view.cdp("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: ["hover"] });
|
|
@@ -109,30 +136,101 @@ export async function measureContrast({ css, themes, label = "default" }) {
|
|
|
109
136
|
const fg = el.dataset.pair === "invalid-field" ? cs(el).borderBlockEndColor : cs(el).color;
|
|
110
137
|
pairs[el.dataset.pair] = { fg: rgba(fg), bg: rgba(cs(el).backgroundColor) };
|
|
111
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
|
+
}
|
|
112
148
|
const soft = {};
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
|
119
187
|
};
|
|
120
188
|
}
|
|
121
|
-
return { pairs, soft };
|
|
189
|
+
return { pairs, soft, solid };
|
|
122
190
|
});
|
|
123
191
|
})()`);
|
|
124
192
|
|
|
125
193
|
return islands.map(({ theme, scheme }, i) => {
|
|
126
|
-
const { pairs, soft } = colors[i];
|
|
194
|
+
const { pairs, soft, solid } = colors[i];
|
|
127
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
|
+
};
|
|
128
206
|
return {
|
|
129
207
|
theme,
|
|
130
208
|
scheme,
|
|
131
|
-
|
|
209
|
+
solid: INTENTS.map((intent) => ({
|
|
132
210
|
intent,
|
|
133
|
-
|
|
134
|
-
hover: ratio(soft[intent].fg, soft[intent].hover),
|
|
211
|
+
value: solid[intent] ? ratio(solid[intent].fg, solid[intent].bg) : null,
|
|
135
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
|
+
}),
|
|
136
234
|
focusSurface: pair("focus-surface"),
|
|
137
235
|
focusSolid: pair("focus-solid"),
|
|
138
236
|
invalidFocus: pair("invalid-field"),
|
|
@@ -167,15 +265,46 @@ export function formatContrast(rows) {
|
|
|
167
265
|
const flag = (cells, min) => (cells.some((c) => c.miss) ? ` <-- under ${min}` : "");
|
|
168
266
|
const lines = [];
|
|
169
267
|
|
|
170
|
-
lines.push(
|
|
171
|
-
|
|
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`);
|
|
172
273
|
for (const row of rows) {
|
|
173
|
-
for (const { intent, rest, hover } of row.soft) {
|
|
274
|
+
for (const { intent, surface, rest, hover } of row.soft) {
|
|
174
275
|
// Both states gate: the hovered fill is usually the closer one, but a
|
|
175
276
|
// theme can invert that, and a 1:1 resting pair once passed silently.
|
|
176
277
|
const cells = [cell(rest, 4.5), cell(hover, 4.5)];
|
|
177
278
|
lines.push(
|
|
178
|
-
`${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)}`,
|
|
179
308
|
);
|
|
180
309
|
}
|
|
181
310
|
}
|