actual-css 0.11.0 → 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 +73 -0
- package/README.md +29 -5
- package/components.json +15 -17
- package/dist/actual.full.css +403 -227
- 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 +4 -2
- package/docs/pages/components/alert.md +2 -10
- package/docs/pages/components/badge.md +3 -9
- package/docs/pages/components/bleed.md +5 -4
- package/docs/pages/components/card.md +31 -2
- package/docs/pages/components/close.md +9 -3
- package/docs/pages/components/dialog.md +248 -31
- package/docs/pages/components/drawer.md +2 -2
- package/docs/pages/components/meter.md +2 -0
- 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 +3 -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 +36 -0
- 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 +3 -2
- package/reserved-classes.json +3 -3
- package/skills/actual-css/references/enhancements.md +38 -0
- package/src/cli/actual-css.js +75 -6
- package/src/css/components/avatar.css +16 -9
- package/src/css/components/badge.css +1 -5
- package/src/css/components/bleed.css +5 -5
- package/src/css/components/button.css +17 -10
- package/src/css/components/card.css +18 -10
- package/src/css/components/chat.css +4 -5
- package/src/css/components/close.css +17 -6
- 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/meter.css +5 -2
- package/src/css/components/modal.css +157 -69
- 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/forms/native.css +5 -1
- 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-components.js +286 -0
- package/src/tooling/design-export.js +69 -0
- package/src/tooling/design-tokens.js +474 -0
- package/src/tooling/penpot-plugin/manifest.json +7 -0
- package/src/tooling/penpot-plugin/plugin.js +272 -0
- package/src/tooling/penpot-plugin/ui.html +58 -0
- package/src/tooling/theme-contrast.js +209 -0
- package/docs/pages/patterns/actions.md +0 -52
- package/src/css/components/actions.css +0 -27
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Penpot plugin: builds Actual CSS components from spec.json (written by
|
|
3
|
+
* `bun run export:design`) and binds them to the tokens imported from
|
|
4
|
+
* penpot/tokens.json.
|
|
5
|
+
*
|
|
6
|
+
* The spec holds measured values, not rules: each variant is one board with
|
|
7
|
+
* a flex layout and its texts, sized and painted as the browser rendered it.
|
|
8
|
+
* Only colors are bound to tokens. Literal values are set first and tokens
|
|
9
|
+
* applied on top, so a token the
|
|
10
|
+
* plugin API refuses leaves a correct-looking shape and a report line rather
|
|
11
|
+
* than a broken one. Tokens are applied before createComponent: the API drops
|
|
12
|
+
* token bindings on component children without an error (penpot/penpot#8520).
|
|
13
|
+
*/
|
|
14
|
+
penpot.ui.open("Actual CSS components", `ui.html?theme=${penpot.theme}`, {
|
|
15
|
+
width: 300,
|
|
16
|
+
height: 380,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
penpot.ui.onMessage(async (message) => {
|
|
20
|
+
if (message?.type !== "build") return;
|
|
21
|
+
let text;
|
|
22
|
+
try {
|
|
23
|
+
text = await build(message.spec, message.font);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
text = `Build failed: ${error?.message ?? error}`;
|
|
26
|
+
}
|
|
27
|
+
penpot.ui.sendMessage({ type: "report", text });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/* Tokens are applied by name; an active set wins over an inactive one with
|
|
31
|
+
the same token, like Penpot's own resolution. */
|
|
32
|
+
function tokensByName() {
|
|
33
|
+
const tokens = new Map();
|
|
34
|
+
for (const set of penpot.library.local.tokens.sets) {
|
|
35
|
+
for (const token of set.tokens) {
|
|
36
|
+
if (!tokens.has(token.name) || set.active) tokens.set(token.name, token);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return tokens;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const COMPONENT_GAP = 64;
|
|
43
|
+
const PAGE_NAME = "Actual CSS components";
|
|
44
|
+
|
|
45
|
+
/* Main components live on their own page, the usual home of a library: the
|
|
46
|
+
variant sets are large (a Button is Intent × Variant × Size) and a working
|
|
47
|
+
page only needs instances, dragged from Assets. New shapes land on the
|
|
48
|
+
active page, so the page is opened (and awaited) before building. */
|
|
49
|
+
async function openComponentsPage(log) {
|
|
50
|
+
try {
|
|
51
|
+
const page =
|
|
52
|
+
penpot.currentFile?.pages?.find((p) => p.name === PAGE_NAME) ?? penpot.createPage();
|
|
53
|
+
page.name = PAGE_NAME;
|
|
54
|
+
await penpot.openPage(page);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
log.notes.push(
|
|
57
|
+
`Built on the current page: "${PAGE_NAME}" could not be opened (${error?.message ?? error}).`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function build(spec, fontName) {
|
|
63
|
+
const tokens = tokensByName();
|
|
64
|
+
const log = {
|
|
65
|
+
variants: 0,
|
|
66
|
+
applied: 0,
|
|
67
|
+
built: [],
|
|
68
|
+
missing: new Set(),
|
|
69
|
+
failed: new Set(),
|
|
70
|
+
notes: [],
|
|
71
|
+
};
|
|
72
|
+
if (tokens.size === 0) log.notes.push("No tokens found: import penpot/tokens.json first.");
|
|
73
|
+
|
|
74
|
+
const bind = (shape, value, properties) => {
|
|
75
|
+
if (!value?.token) return;
|
|
76
|
+
const token = tokens.get(value.token);
|
|
77
|
+
if (!token) {
|
|
78
|
+
log.missing.add(value.token);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
shape.applyToken(token, properties);
|
|
83
|
+
log.applied += 1;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
log.failed.add(`${token.type} → ${properties ?? "default"}: ${error?.message ?? error}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const font = fontName ? penpot.fonts.findByName(fontName) : null;
|
|
90
|
+
if (fontName && !font) log.notes.push(`Font "${fontName}" not found; Penpot's default is used.`);
|
|
91
|
+
|
|
92
|
+
await openComponentsPage(log);
|
|
93
|
+
|
|
94
|
+
// Right of whatever the page holds: a rebuild over a previous one reads as
|
|
95
|
+
// broken components (transparent boards show the old text through).
|
|
96
|
+
const existing = penpot.currentPage?.root?.children ?? [];
|
|
97
|
+
const origin = {
|
|
98
|
+
x: existing.length ? Math.max(...existing.map((s) => s.x + s.width)) + 200 : 0,
|
|
99
|
+
y: existing.length ? Math.min(...existing.map((s) => s.y)) : 0,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Components side by side, each at the right of the previous one.
|
|
103
|
+
for (const component of spec.components) {
|
|
104
|
+
const shapes = buildComponent(component, { bind, font, log, origin });
|
|
105
|
+
origin.x = Math.max(...shapes.map((s) => s.x + s.width)) + COMPONENT_GAP;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Show the result: the build lands at the page origin, often off screen.
|
|
109
|
+
try {
|
|
110
|
+
penpot.selection = log.built;
|
|
111
|
+
penpot.viewport.zoomIntoView(log.built);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
log.notes.push(`Could not select the result: ${error?.message ?? error}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const names = spec.components.map((c) => c.name).join(", ");
|
|
117
|
+
return [
|
|
118
|
+
`${log.variants} variants built, ${log.applied} token bindings applied.`,
|
|
119
|
+
`${names}: built on the "${PAGE_NAME}" page and listed under Assets → Components.`,
|
|
120
|
+
"Drag an instance onto any page, then pick its Intent / Variant / Size in the right panel.",
|
|
121
|
+
"Check the bindings: Tokens → Themes → Mode / Dark repaints the components.",
|
|
122
|
+
...log.notes,
|
|
123
|
+
log.missing.size ? `Missing tokens: ${[...log.missing].join(", ")}` : "",
|
|
124
|
+
log.failed.size ? `Refused bindings:\n- ${[...log.failed].join("\n- ")}` : "",
|
|
125
|
+
]
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.join("\n\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function paint({ hex, opacity }) {
|
|
131
|
+
return { fillColor: hex, fillOpacity: opacity };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/*
|
|
135
|
+
* Penpot lays a text out only after the plugin call returns: a new text reads
|
|
136
|
+
* 0×0, and flex places that empty box (a centred label then grows from the
|
|
137
|
+
* centre down-right). Each text gets the browser's box up front; the grow type
|
|
138
|
+
* then corrects the few pixels the design font differs by. Waiting for the
|
|
139
|
+
* layout inside the plugin never ends: it does not happen while we run.
|
|
140
|
+
*/
|
|
141
|
+
function buildText(spec, { column, font, bind }) {
|
|
142
|
+
const text = penpot.createText(spec.label);
|
|
143
|
+
if (font) {
|
|
144
|
+
const weight = String(spec.fontWeight);
|
|
145
|
+
const face = font.variants?.find((v) => v.fontWeight === weight && v.fontStyle === "normal");
|
|
146
|
+
font.applyToText(text, face);
|
|
147
|
+
}
|
|
148
|
+
text.fontSize = String(spec.fontSize);
|
|
149
|
+
text.fontWeight = String(spec.fontWeight);
|
|
150
|
+
text.lineHeight = String(spec.lineHeight);
|
|
151
|
+
text.fills = [paint(spec.color)];
|
|
152
|
+
if (spec.underline) text.textDecoration = "underline";
|
|
153
|
+
text.resize(Math.max(1, spec.width), Math.ceil(spec.fontSize * spec.lineHeight));
|
|
154
|
+
// In a column the text keeps its width and wraps (a card body); in a row
|
|
155
|
+
// it follows its label (a button, a badge).
|
|
156
|
+
text.growType = column ? "auto-height" : "auto-width";
|
|
157
|
+
|
|
158
|
+
bind(text, spec.color, ["fill"]);
|
|
159
|
+
return text;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildVariant(component, variant, { bind, font }) {
|
|
163
|
+
const { box } = variant;
|
|
164
|
+
const column = box.dir === "column";
|
|
165
|
+
const board = penpot.createBoard();
|
|
166
|
+
board.name = component.name;
|
|
167
|
+
board.fills = box.fill ? [paint(box.fill)] : [];
|
|
168
|
+
board.strokes = box.stroke
|
|
169
|
+
? [
|
|
170
|
+
{
|
|
171
|
+
strokeColor: box.stroke.hex,
|
|
172
|
+
strokeOpacity: box.stroke.opacity,
|
|
173
|
+
strokeWidth: box.borderWidth,
|
|
174
|
+
strokeAlignment: "inner",
|
|
175
|
+
strokeStyle: "solid",
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
: [];
|
|
179
|
+
board.borderRadius = box.radius;
|
|
180
|
+
|
|
181
|
+
const flex = board.addFlexLayout();
|
|
182
|
+
flex.dir = box.dir;
|
|
183
|
+
flex.alignItems = column ? "start" : "center";
|
|
184
|
+
flex.justifyContent = box.justify;
|
|
185
|
+
flex.rowGap = box.gap;
|
|
186
|
+
flex.columnGap = box.gap;
|
|
187
|
+
flex.leftPadding = box.padding.left;
|
|
188
|
+
flex.rightPadding = box.padding.right;
|
|
189
|
+
// A control's height is fixed and centres its row; the vertical padding is
|
|
190
|
+
// only what the browser needed to get there.
|
|
191
|
+
if (!box.fixedHeight) {
|
|
192
|
+
flex.topPadding = box.padding.top;
|
|
193
|
+
flex.bottomPadding = box.padding.bottom;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const spec of variant.texts) board.appendChild(buildText(spec, { column, font, bind }));
|
|
197
|
+
|
|
198
|
+
board.resize(box.width, box.height);
|
|
199
|
+
// Hug the label so the component grows when the text is edited, and let a
|
|
200
|
+
// content-height box follow its texts. Safe only because the texts already
|
|
201
|
+
// have their box: hugging a 0×0 text shrank every board to its padding.
|
|
202
|
+
if (box.hug) flex.horizontalSizing = "auto";
|
|
203
|
+
if (!box.fixedHeight) flex.verticalSizing = "auto";
|
|
204
|
+
|
|
205
|
+
// Colors only: they are what a theme switch repaints. Lengths stay literal
|
|
206
|
+
// (see design-components.js for why).
|
|
207
|
+
bind(board, box.fill, ["fill"]);
|
|
208
|
+
if (box.stroke) bind(board, box.stroke, ["strokeColor"]);
|
|
209
|
+
return board;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* A single variant is a plain component. Several are laid out one row per
|
|
213
|
+
combination of the leading properties, one column per value of the last,
|
|
214
|
+
then combined into one variant set. Returns the shapes placed on the page. */
|
|
215
|
+
function buildComponent(component, { bind, font, log, origin }) {
|
|
216
|
+
const gap = 16;
|
|
217
|
+
const last = component.properties.at(-1);
|
|
218
|
+
const perRow = new Set(component.variants.map((v) => v.props[last])).size;
|
|
219
|
+
let x = 0;
|
|
220
|
+
let y = 0;
|
|
221
|
+
let rowHeight = 0;
|
|
222
|
+
|
|
223
|
+
const boards = component.variants.map((variant, i) => {
|
|
224
|
+
const board = buildVariant(component, variant, { bind, font });
|
|
225
|
+
if (i > 0 && i % perRow === 0) {
|
|
226
|
+
x = 0;
|
|
227
|
+
y += rowHeight + gap;
|
|
228
|
+
rowHeight = 0;
|
|
229
|
+
}
|
|
230
|
+
board.x = origin.x + x;
|
|
231
|
+
board.y = origin.y + y;
|
|
232
|
+
x += board.width + gap;
|
|
233
|
+
rowHeight = Math.max(rowHeight, board.height);
|
|
234
|
+
log.variants += 1;
|
|
235
|
+
return board;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const library = penpot.library.local;
|
|
239
|
+
const created = boards.map((board) => library.createComponent([board]));
|
|
240
|
+
if (created.length === 1) {
|
|
241
|
+
log.built.push(...boards);
|
|
242
|
+
return boards;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const [first, ...rest] = created.map((c) => c.mainInstance());
|
|
247
|
+
first.combineAsVariants(rest.map((shape) => shape.id));
|
|
248
|
+
|
|
249
|
+
const members = created.map((c) => library.components.find((m) => m.id === c.id) ?? c);
|
|
250
|
+
const { variants } = members[0];
|
|
251
|
+
while (variants.properties.length < component.properties.length) variants.addProperty();
|
|
252
|
+
component.properties.forEach((name, pos) => {
|
|
253
|
+
variants.renameProperty(pos, name);
|
|
254
|
+
});
|
|
255
|
+
members.forEach((member, i) => {
|
|
256
|
+
component.properties.forEach((name, pos) => {
|
|
257
|
+
member.setVariantProperty(pos, component.variants[i].props[name]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
// combineAsVariants wraps the main instances in the variant container.
|
|
261
|
+
// Painted with the theme page, so light-ink variants (outline, ghost,
|
|
262
|
+
// link in dark) sit on their own surface rather than on the canvas.
|
|
263
|
+
const container = first.parent ?? first;
|
|
264
|
+
if (container !== first) bind(container, { token: "surface" }, ["fill"]);
|
|
265
|
+
log.built.push(container);
|
|
266
|
+
return [container];
|
|
267
|
+
} catch (error) {
|
|
268
|
+
log.failed.add(`variants of ${component.name}: ${error?.message ?? error}`);
|
|
269
|
+
log.built.push(...boards);
|
|
270
|
+
return boards;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Actual CSS components</title>
|
|
6
|
+
<script>
|
|
7
|
+
/* Penpot passes its own theme (light/dark); the system colors below
|
|
8
|
+
follow it through color-scheme. */
|
|
9
|
+
document.documentElement.style.colorScheme =
|
|
10
|
+
new URLSearchParams(location.search).get("theme") === "light" ? "light" : "dark";
|
|
11
|
+
</script>
|
|
12
|
+
<style>
|
|
13
|
+
body {
|
|
14
|
+
background: Canvas;
|
|
15
|
+
color: CanvasText;
|
|
16
|
+
margin: 0;
|
|
17
|
+
padding: 12px;
|
|
18
|
+
font: 13px/1.4 system-ui, sans-serif;
|
|
19
|
+
}
|
|
20
|
+
label {
|
|
21
|
+
display: block;
|
|
22
|
+
margin-block-end: 8px;
|
|
23
|
+
}
|
|
24
|
+
input {
|
|
25
|
+
inline-size: 100%;
|
|
26
|
+
box-sizing: border-box;
|
|
27
|
+
}
|
|
28
|
+
pre {
|
|
29
|
+
white-space: pre-wrap;
|
|
30
|
+
font-size: 12px;
|
|
31
|
+
}
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
<p>Import <code>penpot/tokens.json</code> first (Tokens → Tools → Import).</p>
|
|
36
|
+
<label>
|
|
37
|
+
Font family
|
|
38
|
+
<input id="font" value="Inter" />
|
|
39
|
+
</label>
|
|
40
|
+
<button id="build" type="button">Build components</button>
|
|
41
|
+
<pre id="report"></pre>
|
|
42
|
+
<script>
|
|
43
|
+
/* The spec is written next to this page by `bun run export:design`. */
|
|
44
|
+
const report = document.getElementById("report");
|
|
45
|
+
document.getElementById("build").addEventListener("click", async () => {
|
|
46
|
+
report.textContent = "Building…";
|
|
47
|
+
const spec = await fetch("spec.json").then((response) => response.json());
|
|
48
|
+
parent.postMessage(
|
|
49
|
+
{ type: "build", spec, font: document.getElementById("font").value.trim() },
|
|
50
|
+
"*",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
window.addEventListener("message", (event) => {
|
|
54
|
+
if (event.data?.type === "report") report.textContent = event.data.text;
|
|
55
|
+
});
|
|
56
|
+
</script>
|
|
57
|
+
</body>
|
|
58
|
+
</html>
|
|
@@ -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
|
-
}
|