actual-css 0.11.1 → 0.12.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 +47 -0
- package/README.md +26 -6
- package/components.json +4 -16
- package/dist/actual.full.css +296 -267
- 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/navigation.json +3 -2
- package/docs/pages/components/alert.md +2 -10
- package/docs/pages/components/badge.md +3 -9
- 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/navbar.md +8 -9
- package/docs/pages/components/table.md +3 -3
- package/docs/pages/enhancements/flyout.md +2 -2
- package/docs/pages/enhancements/scrollspy.md +1 -1
- package/docs/pages/enhancements/status-bar.md +2 -2
- 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/form-actions.md +61 -28
- package/docs/pages/forms/input-policy.md +1 -1
- package/docs/pages/forms/overview.md +6 -5
- package/docs/pages/forms/sticky-actions.md +85 -83
- package/docs/pages/forms/validation.md +5 -5
- package/docs/pages/foundations/philosophy.md +5 -3
- package/docs/pages/foundations/theming.md +15 -4
- package/docs/pages/foundations/tokens.md +50 -33
- 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 +5 -1
- package/docs/pages/guides/introduction.md +3 -4
- package/docs/pages/guides/modular-import.md +3 -0
- package/docs/pages/guides/progressive-enhancement.md +2 -2
- package/docs/pages/guides/tailwind.md +70 -0
- package/docs/pages/guides/upgrading.md +74 -0
- package/docs/pages/layout/cluster.md +28 -2
- package/docs/pages/layout/overview.md +1 -1
- package/docs/pages/layout/stack.md +21 -16
- package/docs/pages/layout/topbar.md +1 -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 +1 -1
- package/docs/pages/utilities/spacing.md +4 -3
- package/package.json +2 -2
- 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/avatar.css +16 -9
- package/src/css/components/badge.css +1 -5
- 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 +19 -3
- package/src/css/components/flyout.css +3 -0
- package/src/css/components/index.css +0 -1
- package/src/css/components/modal.css +96 -124
- package/src/css/components/navbar.css +14 -25
- package/src/css/core/base.css +1 -1
- package/src/css/core/reset.css +2 -1
- package/src/css/core/theme.css +4 -0
- package/src/css/core/tokens.css +28 -10
- package/src/css/core/variants.css +41 -14
- package/src/css/forms/form-actions.css +15 -10
- package/src/css/layout/cluster.css +11 -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/tooling/color.js +65 -0
- package/src/tooling/design-tokens.js +9 -1
- package/src/tooling/theme-contrast.js +209 -0
- package/docs/pages/patterns/actions.md +0 -52
- package/src/css/components/actions.css +0 -27
|
@@ -11,17 +11,21 @@
|
|
|
11
11
|
* components/badge.css — .badge default soft behind explicit --ui-*
|
|
12
12
|
* components/alert.css — .alert default soft behind explicit --ui-*
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Three axes, each predictable on its own:
|
|
15
|
+
* .sm/.lg size — typography + geometry, local to a component
|
|
16
|
+
* .compact/.spacious density — rhythm + geometry, inherited by a region
|
|
17
|
+
* .tight/.loose rhythm — --gap only, inherited by a region
|
|
18
|
+
*
|
|
19
|
+
* Density never touches typography: no font size, icon size or font width.
|
|
20
|
+
* A width bonus through font-stretch only showed on typefaces with a wdth axis,
|
|
21
|
+
* so .compact meant something different per OS and font; a condensed table is
|
|
22
|
+
* a typographic decision for the application. Controls consume --control-size
|
|
23
|
+
* and --control-pad-x, so a compact control is shorter and narrower (a height
|
|
24
|
+
* change alone read as the same control squashed); non-control components may
|
|
17
25
|
* consume --density-* when the context should affect their local rhythm.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* --font-width, so a spacious island inside a compact context returns to the
|
|
22
|
-
* normal width exactly as it restores its own gap, spacing, and control size.
|
|
23
|
-
* Themes whose typeface has no useful width axis opt out with
|
|
24
|
-
* --font-width-dense: 100%.
|
|
26
|
+
*
|
|
27
|
+
* .tight/.loose reuse the density --gap steps, so a tight row and a compact
|
|
28
|
+
* one share one rhythm.
|
|
25
29
|
*
|
|
26
30
|
* .sm/.lg are local size roles, not shared measurements. Shared control
|
|
27
31
|
* participants below map both typography and geometry; components with
|
|
@@ -60,18 +64,35 @@
|
|
|
60
64
|
--ui-border: var(--border);
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
/* Each boundary selects a control step twice: the values, resolved here, and
|
|
68
|
+
the 0/1 step weights a nested [data-theme] island rebuilds them from
|
|
69
|
+
(tokens.css). */
|
|
63
70
|
.compact {
|
|
64
71
|
--gap: var(--space-20);
|
|
65
72
|
--density-space: var(--space-20);
|
|
66
73
|
--control-size: var(--control-size-sm);
|
|
67
|
-
|
|
74
|
+
--control-pad-x: var(--control-pad-x-sm);
|
|
75
|
+
--control-step-sm: 1;
|
|
76
|
+
--control-step-md: 0;
|
|
77
|
+
--control-step-lg: 0;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
.spacious {
|
|
71
81
|
--gap: var(--space-50);
|
|
72
82
|
--density-space: var(--space-50);
|
|
73
83
|
--control-size: var(--control-size-lg);
|
|
74
|
-
|
|
84
|
+
--control-pad-x: var(--control-pad-x-lg);
|
|
85
|
+
--control-step-sm: 0;
|
|
86
|
+
--control-step-md: 0;
|
|
87
|
+
--control-step-lg: 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.tight {
|
|
91
|
+
--gap: var(--space-20);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.loose {
|
|
95
|
+
--gap: var(--space-50);
|
|
75
96
|
}
|
|
76
97
|
|
|
77
98
|
/* Shared control scale. Wrappers are listed when their descendants form one
|
|
@@ -100,7 +121,10 @@
|
|
|
100
121
|
).sm {
|
|
101
122
|
--control-size: var(--control-size-sm);
|
|
102
123
|
--control-font-size: var(--font-size-sm);
|
|
103
|
-
--control-pad-x: var(--
|
|
124
|
+
--control-pad-x: var(--control-pad-x-sm);
|
|
125
|
+
--control-step-sm: 1;
|
|
126
|
+
--control-step-md: 0;
|
|
127
|
+
--control-step-lg: 0;
|
|
104
128
|
}
|
|
105
129
|
|
|
106
130
|
:where(
|
|
@@ -124,7 +148,10 @@
|
|
|
124
148
|
).lg {
|
|
125
149
|
--control-size: var(--control-size-lg);
|
|
126
150
|
--control-font-size: var(--font-size-lg);
|
|
127
|
-
--control-pad-x: var(--
|
|
151
|
+
--control-pad-x: var(--control-pad-x-lg);
|
|
152
|
+
--control-step-sm: 0;
|
|
153
|
+
--control-step-md: 0;
|
|
154
|
+
--control-step-lg: 1;
|
|
128
155
|
}
|
|
129
156
|
|
|
130
157
|
/* Modern color enhancement. Baseline variant values above remain usable
|
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Form actions —
|
|
2
|
+
* Form actions — the role of a .cluster action row that closes a form.
|
|
3
3
|
*
|
|
4
4
|
* Docs: docs/pages/forms/form-actions.md
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Markup: `<div class="cluster form-actions">`. The cluster owns the row
|
|
7
|
+
* (flex, wrap, gap); this role owns the form-specific alignment policy and the
|
|
8
|
+
* opt-in sticky variant. The distance to the fields above belongs to the
|
|
9
|
+
* parent layout (.stack on the form), never to a margin here: a margin would
|
|
10
|
+
* add to a stack's gap. The sticky variant docks to the viewport bottom with a
|
|
11
|
+
* translucent blur where supported; at rest it stays visually neutral so it
|
|
12
|
+
* does not double as a separator.
|
|
9
13
|
*
|
|
10
14
|
* Public hooks:
|
|
11
15
|
* --form-actions-align
|
|
12
16
|
* --form-actions-justify
|
|
13
|
-
* --form-actions-margin-block-start
|
|
14
17
|
*
|
|
15
18
|
* Internal:
|
|
16
19
|
* --form-actions-sticky-block-offset (owned by .form-actions.sticky)
|
|
17
20
|
* --form-actions-sticky-inline-offset (owned by .form-actions.sticky)
|
|
18
21
|
* --form-actions-sticky-padding (owned by .form-actions.sticky)
|
|
19
22
|
*/
|
|
23
|
+
|
|
24
|
+
/* The policy hooks write the properties themselves rather than relaying
|
|
25
|
+
--cluster-*: a relay would inherit into any .cluster nested in the row.
|
|
26
|
+
These are equal-specificity overrides of .cluster, so they win only because
|
|
27
|
+
forms load after layout in the official bundle (tests/css-audit.test.js
|
|
28
|
+
guards the order); utilities load later and keep the author's last word. On
|
|
29
|
+
this row --cluster-justify/--cluster-align have no effect. */
|
|
20
30
|
.form-actions {
|
|
21
|
-
display: flex;
|
|
22
|
-
flex-direction: row;
|
|
23
|
-
flex-wrap: wrap;
|
|
24
|
-
gap: var(--gap);
|
|
25
31
|
align-items: var(--form-actions-align, center);
|
|
26
32
|
justify-content: var(--form-actions-justify, flex-start);
|
|
27
|
-
margin-block-start: var(--form-actions-margin-block-start, var(--space-50));
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* --cluster-wrap
|
|
10
10
|
*
|
|
11
11
|
* Child contract: direct-child margins are reset, spacing comes from --gap.
|
|
12
|
+
* A ul/ol/menu keeps its semantics but loses list chrome (see below).
|
|
12
13
|
*/
|
|
13
14
|
.cluster {
|
|
14
15
|
display: flex;
|
|
@@ -22,3 +23,13 @@
|
|
|
22
23
|
.cluster > * {
|
|
23
24
|
margin: 0;
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
/* Horizontal, wrapping layout is incompatible with list markers and native
|
|
28
|
+
indentation: a ul/ol/menu applied with .cluster stays a list in the DOM, but
|
|
29
|
+
its visual chrome would be accidental. .stack intentionally leaves this
|
|
30
|
+
alone — a vertical list can keep its numbers. */
|
|
31
|
+
:where(ul, ol, menu).cluster {
|
|
32
|
+
margin: 0;
|
|
33
|
+
padding: 0;
|
|
34
|
+
list-style: none;
|
|
35
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Sticky, frosted top bar shell for app/dashboard shells (persistent
|
|
3
3
|
* sidebar + topbar layouts). Owns positioning, stacking, and the frosted
|
|
4
|
-
* background
|
|
5
|
-
*
|
|
4
|
+
* background; it also lays its children out as a default row that a layout
|
|
5
|
+
* primitive on the same element (such as .cluster) replaces. Row content
|
|
6
|
+
* (search, breadcrumb, actions) stays local to the page.
|
|
6
7
|
*
|
|
7
8
|
* Docs: docs/pages/layout/topbar.md
|
|
8
9
|
*
|
|
@@ -16,16 +17,21 @@
|
|
|
16
17
|
inset-block-start: 0;
|
|
17
18
|
z-index: var(--z-sticky);
|
|
18
19
|
isolation: isolate;
|
|
19
|
-
display: flex;
|
|
20
|
-
flex-direction: row;
|
|
21
|
-
align-items: center;
|
|
22
|
-
gap: var(--gap);
|
|
23
20
|
padding-block: var(--space-30);
|
|
24
21
|
padding-inline: var(--space-40);
|
|
25
22
|
border-block-end: var(--border-width) solid var(--border);
|
|
26
23
|
background: var(--surface);
|
|
27
24
|
}
|
|
28
25
|
|
|
26
|
+
/* The row is a default at zero specificity: .topbar and the primitives share
|
|
27
|
+
layout/, so at class weight their file order alone would pick the winner. */
|
|
28
|
+
:where(.topbar) {
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: row;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: var(--gap);
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
/* Both conditions are required: the translucent background contains var(),
|
|
30
36
|
so a browser with backdrop-filter but not color-mix (e.g. Safari < 16.2)
|
|
31
37
|
would accept the declaration at parse time, fail it at computed-value
|
|
@@ -10,9 +10,6 @@
|
|
|
10
10
|
* - utilities consume the current context instead of defining their own -sm/-lg scale.
|
|
11
11
|
* - optional utilities extend core utilities, they do not re-express them.
|
|
12
12
|
* - follow verbose .{property}-{value} naming.
|
|
13
|
-
*
|
|
14
|
-
* Internal:
|
|
15
|
-
* --cluster-justify (local relay consumed by .cluster)
|
|
16
13
|
*/
|
|
17
14
|
|
|
18
15
|
/* Display */
|
|
@@ -87,23 +84,22 @@
|
|
|
87
84
|
flex-wrap: nowrap;
|
|
88
85
|
}
|
|
89
86
|
|
|
87
|
+
/* The property is written directly, never relayed through the cluster hook:
|
|
88
|
+
a relay would inherit into every .cluster nested inside. Utilities load last,
|
|
89
|
+
so the direct declaration already beats .cluster at equal specificity. */
|
|
90
90
|
.justify-content-start {
|
|
91
|
-
--cluster-justify: flex-start;
|
|
92
91
|
justify-content: flex-start;
|
|
93
92
|
}
|
|
94
93
|
|
|
95
94
|
.justify-content-center {
|
|
96
|
-
--cluster-justify: center;
|
|
97
95
|
justify-content: center;
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
.justify-content-end {
|
|
101
|
-
--cluster-justify: flex-end;
|
|
102
99
|
justify-content: flex-end;
|
|
103
100
|
}
|
|
104
101
|
|
|
105
102
|
.justify-content-space-between {
|
|
106
|
-
--cluster-justify: space-between;
|
|
107
103
|
justify-content: space-between;
|
|
108
104
|
}
|
|
109
105
|
|
|
@@ -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
|
+
};
|
|
@@ -44,6 +44,7 @@ export const THEME_COLORS = [
|
|
|
44
44
|
"border",
|
|
45
45
|
"focus",
|
|
46
46
|
"hover-overlay",
|
|
47
|
+
"hover-overlay-solid",
|
|
47
48
|
"shadow-color",
|
|
48
49
|
"selection-bg",
|
|
49
50
|
"selection-fg",
|
|
@@ -65,6 +66,9 @@ export const FOUNDATIONS = {
|
|
|
65
66
|
"space-50",
|
|
66
67
|
"space-60",
|
|
67
68
|
"gap",
|
|
69
|
+
"control-pad-x-sm",
|
|
70
|
+
"control-pad-x-md",
|
|
71
|
+
"control-pad-x-lg",
|
|
68
72
|
"control-pad-x",
|
|
69
73
|
],
|
|
70
74
|
radius: ["radius-sm", "radius", "radius-lg", "radius-full"],
|
|
@@ -91,9 +95,13 @@ export const FOUNDATIONS = {
|
|
|
91
95
|
stay out of the design tools. */
|
|
92
96
|
export const NOT_EXPORTED = {
|
|
93
97
|
"font stack; a design tool font token holds one family": ["font-sans", "font-mono"],
|
|
98
|
+
"internal 0/1 weights selecting a control step; the steps are exported": [
|
|
99
|
+
"control-step-sm",
|
|
100
|
+
"control-step-md",
|
|
101
|
+
"control-step-lg",
|
|
102
|
+
],
|
|
94
103
|
"no design tool equivalent": [
|
|
95
104
|
"font-width",
|
|
96
|
-
"font-width-dense",
|
|
97
105
|
"line-height",
|
|
98
106
|
"line-height-tight",
|
|
99
107
|
"line-height-relaxed",
|
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
function islandMarkup(id, attribute, scheme) {
|
|
29
|
+
const style = scheme ? ` style="color-scheme: ${scheme}"` : "";
|
|
30
|
+
return `<div data-theme="${attribute}"${style} id="${id}">
|
|
31
|
+
${INTENTS.map((i) => `<span class="badge soft ${i}" data-badge="${i}">t</span>`).join("")}
|
|
32
|
+
${INTENTS.map((i) => `<button class="btn soft ${i}" data-hover="${i}" type="button">t</button>`).join("")}
|
|
33
|
+
<span data-pair="focus-surface" style="color: var(--focus); background: var(--surface)"></span>
|
|
34
|
+
<span data-pair="focus-solid" style="color: var(--focus); background: var(--surface-solid)"></span>
|
|
35
|
+
<span data-pair="inverse" style="color: var(--surface-solid-fg); background: var(--surface-solid)"></span>
|
|
36
|
+
<input class="input" aria-invalid="true" data-pair="invalid-field" aria-label="t">
|
|
37
|
+
</div>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ratio = (fg, bg) => (fg[3] < 255 || bg[3] < 255 ? null : contrast(fg, bg));
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
* 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
|
+
*/
|
|
48
|
+
export async function measureContrast({ css, themes }) {
|
|
49
|
+
await using view = new Bun.WebView({ backend: "chrome" });
|
|
50
|
+
await view.navigate("about:blank");
|
|
51
|
+
|
|
52
|
+
// Transitions off: a forced :hover must compute its end state at once.
|
|
53
|
+
const islands = await view.evaluate(`((css, themes) => {
|
|
54
|
+
const style = document.createElement("style");
|
|
55
|
+
style.textContent = css + "\\n* { transition-duration: 0s !important; }";
|
|
56
|
+
document.head.append(style);
|
|
57
|
+
|
|
58
|
+
const schemesOf = (attribute) => {
|
|
59
|
+
const probe = document.createElement("div");
|
|
60
|
+
probe.dataset.theme = attribute;
|
|
61
|
+
document.body.append(probe);
|
|
62
|
+
const declared = getComputedStyle(probe).colorScheme.split(/\\s+/);
|
|
63
|
+
probe.remove();
|
|
64
|
+
const schemes = declared.filter((s) => s === "light" || s === "dark");
|
|
65
|
+
return schemes.length > 0 ? schemes : ["light"];
|
|
66
|
+
};
|
|
67
|
+
|
|
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 })),
|
|
72
|
+
);
|
|
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)})`);
|
|
81
|
+
|
|
82
|
+
await view.cdp("DOM.enable");
|
|
83
|
+
await view.cdp("CSS.enable");
|
|
84
|
+
const { root } = await view.cdp("DOM.getDocument");
|
|
85
|
+
const { nodeIds } = await view.cdp("DOM.querySelectorAll", {
|
|
86
|
+
nodeId: root.nodeId,
|
|
87
|
+
selector: "[data-hover]",
|
|
88
|
+
});
|
|
89
|
+
for (const nodeId of nodeIds) {
|
|
90
|
+
await view.cdp("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: ["hover"] });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const colors = await view.evaluate(`(() => {
|
|
94
|
+
const rgba = ${RASTERIZE};
|
|
95
|
+
return [...document.querySelectorAll("[id^=island-]")].map((island) => {
|
|
96
|
+
const cs = (el) => getComputedStyle(el);
|
|
97
|
+
const pairs = {};
|
|
98
|
+
for (const el of island.querySelectorAll("[data-pair]")) {
|
|
99
|
+
// The invalid field's block-end border carries --form-invalid-border,
|
|
100
|
+
// the hook its focus outline reads, even on an underline-only theme
|
|
101
|
+
// (material) whose other sides are clear.
|
|
102
|
+
const fg = el.dataset.pair === "invalid-field" ? cs(el).borderBlockEndColor : cs(el).color;
|
|
103
|
+
pairs[el.dataset.pair] = { fg: rgba(fg), bg: rgba(cs(el).backgroundColor) };
|
|
104
|
+
}
|
|
105
|
+
const soft = {};
|
|
106
|
+
for (const el of island.querySelectorAll("[data-badge]")) {
|
|
107
|
+
const hover = island.querySelector('[data-hover="' + el.dataset.badge + '"]');
|
|
108
|
+
soft[el.dataset.badge] = {
|
|
109
|
+
fg: rgba(cs(el).color),
|
|
110
|
+
rest: rgba(cs(el).backgroundColor),
|
|
111
|
+
hover: rgba(cs(hover).backgroundColor),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { pairs, soft };
|
|
115
|
+
});
|
|
116
|
+
})()`);
|
|
117
|
+
|
|
118
|
+
return islands.map(({ theme, scheme }, i) => {
|
|
119
|
+
const { pairs, soft } = colors[i];
|
|
120
|
+
const pair = (key) => ratio(pairs[key].fg, pairs[key].bg);
|
|
121
|
+
return {
|
|
122
|
+
theme,
|
|
123
|
+
scheme,
|
|
124
|
+
soft: INTENTS.map((intent) => ({
|
|
125
|
+
intent,
|
|
126
|
+
rest: ratio(soft[intent].fg, soft[intent].rest),
|
|
127
|
+
hover: ratio(soft[intent].fg, soft[intent].hover),
|
|
128
|
+
})),
|
|
129
|
+
focusSurface: pair("focus-surface"),
|
|
130
|
+
focusSolid: pair("focus-solid"),
|
|
131
|
+
invalidFocus: pair("invalid-field"),
|
|
132
|
+
inverse: pair("inverse"),
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* Measures one theme file over the framework, or the default theme without
|
|
139
|
+
* `themeFile`. `name` overrides the data-theme name read from the file.
|
|
140
|
+
*/
|
|
141
|
+
export async function measureThemeFile({ themeFile, name }) {
|
|
142
|
+
const themeCss = themeFile ? await inlineImports(themeFile) : "";
|
|
143
|
+
const theme = themeFile ? (name ?? themeNameOf(themeCss)) : null;
|
|
144
|
+
const css = `${await inlineImports(FRAMEWORK)}\n${themeCss}`;
|
|
145
|
+
return measureContrast({ css, themes: [theme] });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Text tables for `rows`, and the number of pairs under their threshold. */
|
|
149
|
+
export function formatContrast(rows) {
|
|
150
|
+
let misses = 0;
|
|
151
|
+
const cell = (value, min) => {
|
|
152
|
+
if (value === null) return { text: "n/a", miss: false };
|
|
153
|
+
const miss = value < min;
|
|
154
|
+
if (miss) misses += 1;
|
|
155
|
+
return { text: `${value.toFixed(2)}:1`, miss };
|
|
156
|
+
};
|
|
157
|
+
const width = Math.max(5, ...rows.map((row) => row.theme.length));
|
|
158
|
+
const head = (row) => `${row.theme.padEnd(width)} ${row.scheme.padEnd(6)}`;
|
|
159
|
+
const flag = (cells, min) => (cells.some((c) => c.miss) ? ` <-- under ${min}` : "");
|
|
160
|
+
const lines = [];
|
|
161
|
+
|
|
162
|
+
lines.push("Soft pair contrast (ink vs resting / hovered fill), needs 4.5:1.", "");
|
|
163
|
+
lines.push(`${"theme".padEnd(width)} scheme intent rest hover`);
|
|
164
|
+
for (const row of rows) {
|
|
165
|
+
for (const { intent, rest, hover } of row.soft) {
|
|
166
|
+
// Both states gate: the hovered fill is usually the closer one, but a
|
|
167
|
+
// theme can invert that, and a 1:1 resting pair once passed silently.
|
|
168
|
+
const cells = [cell(rest, 4.5), cell(hover, 4.5)];
|
|
169
|
+
lines.push(
|
|
170
|
+
`${head(row)} ${intent.padEnd(9)} ${cells[0].text.padStart(8)} ${cells[1].text.padStart(8)}${flag(cells, 4.5)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const single = (title, key, min) => {
|
|
176
|
+
lines.push("", title, "");
|
|
177
|
+
lines.push(`${"theme".padEnd(width)} scheme ratio`);
|
|
178
|
+
for (const row of rows) {
|
|
179
|
+
const c = cell(row[key], min);
|
|
180
|
+
lines.push(`${head(row)} ${c.text.padStart(8)}${flag([c], min)}`);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
lines.push("", "Focus line contrast (--focus vs --surface / --surface-solid), needs 3:1.", "");
|
|
185
|
+
lines.push(`${"theme".padEnd(width)} scheme surface solid`);
|
|
186
|
+
for (const row of rows) {
|
|
187
|
+
const cells = [cell(row.focusSurface, 3), cell(row.focusSolid, 3)];
|
|
188
|
+
lines.push(
|
|
189
|
+
`${head(row)} ${cells[0].text.padStart(8)} ${cells[1].text.padStart(8)}${flag(cells, 3)}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Documented exception (focus.css): an invalid field focuses in
|
|
194
|
+
// --form-invalid-border. The line is inset, so it reads against the
|
|
195
|
+
// field's own background, not the surface around it.
|
|
196
|
+
single(
|
|
197
|
+
"Invalid focus line contrast (--form-invalid-border vs field), needs 3:1.",
|
|
198
|
+
"invalidFocus",
|
|
199
|
+
3,
|
|
200
|
+
);
|
|
201
|
+
// Tooltip and status-bar text sit on this pair.
|
|
202
|
+
single(
|
|
203
|
+
"Inverse text contrast (--surface-solid-fg vs --surface-solid), needs 4.5:1.",
|
|
204
|
+
"inverse",
|
|
205
|
+
4.5,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
return { text: lines.join("\n"), misses };
|
|
209
|
+
}
|
|
@@ -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
|
-
}
|