actual-css 0.11.0 → 0.11.1
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 +26 -0
- package/README.md +4 -0
- package/components.json +12 -2
- package/dist/actual.full.css +169 -22
- package/dist/actual.full.min.css +1 -1
- package/docs/navigation.json +2 -1
- package/docs/pages/components/bleed.md +5 -4
- package/docs/pages/components/close.md +9 -3
- package/docs/pages/components/dialog.md +140 -11
- package/docs/pages/components/meter.md +2 -0
- package/docs/pages/examples/overview.md +1 -0
- package/docs/pages/guides/design-tools.md +36 -0
- package/package.json +2 -1
- package/skills/actual-css/references/enhancements.md +35 -0
- package/src/cli/actual-css.js +49 -6
- package/src/css/components/bleed.css +5 -5
- package/src/css/components/button.css +14 -1
- package/src/css/components/close.css +17 -6
- package/src/css/components/meter.css +5 -2
- package/src/css/components/modal.css +123 -7
- package/src/css/forms/native.css +5 -1
- package/src/tooling/design-components.js +286 -0
- package/src/tooling/design-export.js +69 -0
- package/src/tooling/design-tokens.js +466 -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
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Public hooks:
|
|
10
10
|
* --modal-size
|
|
11
|
+
* --modal-pad
|
|
12
|
+
* --modal-header-bg
|
|
11
13
|
* --dialog-viewport-gap
|
|
12
14
|
* --dialog-icon-size
|
|
13
15
|
* --dialog-icon-glyph-size
|
|
@@ -15,6 +17,7 @@
|
|
|
15
17
|
* The corner close control is .close (components/close.css).
|
|
16
18
|
*
|
|
17
19
|
* Internal:
|
|
20
|
+
* --dialog-inner-radius
|
|
18
21
|
* --dialog-focus-bleed
|
|
19
22
|
* --dialog-viewport-block
|
|
20
23
|
* --dialog-viewport-gap-default
|
|
@@ -33,6 +36,11 @@
|
|
|
33
36
|
var(--dialog-viewport-gap, var(--dialog-viewport-gap-default))
|
|
34
37
|
);
|
|
35
38
|
|
|
39
|
+
/* The panel is borderless, so its inner and outer radius are the same. Kept
|
|
40
|
+
as one name for the padded wrapper and any edge band (header,
|
|
41
|
+
confirmation footer) so none of them drifts from the corner. */
|
|
42
|
+
--dialog-inner-radius: var(--radius-lg);
|
|
43
|
+
|
|
36
44
|
/* Keep the surface anchored to the viewport in the top layer. This also
|
|
37
45
|
provides the containing block for the absolutely positioned close button. */
|
|
38
46
|
position: fixed;
|
|
@@ -46,7 +54,9 @@
|
|
|
46
54
|
dynamic token; only the modal pins the small viewport. */
|
|
47
55
|
--dialog-viewport-block: var(--viewport-block);
|
|
48
56
|
max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
|
|
49
|
-
|
|
57
|
+
/* Zero rather than absent: the UA stylesheet draws `border: solid` on
|
|
58
|
+
dialog, and an authored edge reads as a hard seam against .bleed bands. */
|
|
59
|
+
border: 0;
|
|
50
60
|
border-radius: var(--radius-lg);
|
|
51
61
|
background: var(--surface-raised);
|
|
52
62
|
color: var(--text);
|
|
@@ -68,14 +78,73 @@
|
|
|
68
78
|
transform var(--duration) var(--ease-exit);
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
@media (forced-colors: active) {
|
|
82
|
+
/* With no authored border the panel edge disappears once colors collapse.
|
|
83
|
+
The browser forces the border color, so only the width is authored. */
|
|
84
|
+
:where(dialog).modal {
|
|
85
|
+
border: var(--border-width) solid CanvasText;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
:where(dialog).modal > form,
|
|
72
90
|
:where(dialog).modal > .stack {
|
|
91
|
+
--surface-pad: var(--modal-pad, var(--space-40));
|
|
92
|
+
|
|
73
93
|
display: grid;
|
|
74
94
|
gap: var(--space-40);
|
|
75
95
|
max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
|
|
96
|
+
border-radius: var(--dialog-inner-radius);
|
|
76
97
|
overflow: auto;
|
|
77
98
|
overscroll-behavior: contain;
|
|
78
|
-
padding: var(--
|
|
99
|
+
padding: var(--surface-pad);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Optional dialog chrome lives beside the padded body wrapper. Its leading
|
|
103
|
+
region and trailing controls share one center axis; wrapping the close in
|
|
104
|
+
the trailing cluster keeps it in flow instead of invoking the absolute
|
|
105
|
+
corner treatment from close.css. */
|
|
106
|
+
:where(dialog).modal > header {
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
justify-content: space-between;
|
|
110
|
+
gap: var(--space-30);
|
|
111
|
+
min-block-size: var(--close-size, var(--control-size));
|
|
112
|
+
padding: var(--modal-pad, var(--space-40));
|
|
113
|
+
border-start-start-radius: var(--dialog-inner-radius);
|
|
114
|
+
border-start-end-radius: var(--dialog-inner-radius);
|
|
115
|
+
background: var(--modal-header-bg, var(--surface-subtle));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
:where(dialog).modal > header > .media {
|
|
119
|
+
flex: 1 1 auto;
|
|
120
|
+
min-inline-size: 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
:where(dialog).modal > header > .media > :is(h1, h2, h3, h4, h5, h6) {
|
|
124
|
+
display: flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
min-block-size: var(--close-size, var(--control-size));
|
|
127
|
+
margin-block: 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
:where(dialog).modal > header > .cluster {
|
|
131
|
+
--cluster-wrap: nowrap;
|
|
132
|
+
|
|
133
|
+
flex: none;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@supports selector(dialog:has(> header)) {
|
|
137
|
+
/* The header stays fixed while the body wrapper owns overflow. Without
|
|
138
|
+
:has(), compact dialogs keep ordinary block flow as the fallback. */
|
|
139
|
+
:where(dialog).modal[open]:has(> header) {
|
|
140
|
+
display: grid;
|
|
141
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
:where(dialog).modal:has(> header) > :is(form, .stack) {
|
|
145
|
+
min-block-size: 0;
|
|
146
|
+
max-block-size: none;
|
|
147
|
+
}
|
|
79
148
|
}
|
|
80
149
|
|
|
81
150
|
@supports (block-size: 100svh) {
|
|
@@ -127,12 +196,32 @@
|
|
|
127
196
|
margin-block-end: 0;
|
|
128
197
|
}
|
|
129
198
|
|
|
199
|
+
/* A media row inside the structural header is title anatomy, so its heading
|
|
200
|
+
does not bring prose margins into the icon alignment. */
|
|
201
|
+
:where(dialog).modal > :where(form, .stack) > header > .media > :is(h1, h2, h3, h4, h5, h6) {
|
|
202
|
+
margin-block: 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
130
205
|
/* The close button is anchored to the dialog at top inline-end, out of the
|
|
131
206
|
content flow; headers reserve inline room so titles never run under it.
|
|
132
207
|
The form/.stack anatomy is structural, so it stays in :where() and never
|
|
133
208
|
lends .stack's specificity to a bare <form>. */
|
|
134
209
|
:where(dialog).modal > :where(form, .stack) > header {
|
|
135
|
-
padding-inline-end: calc(
|
|
210
|
+
padding-inline-end: calc(
|
|
211
|
+
var(--close-size, var(--control-size)) +
|
|
212
|
+
var(--space-20) +
|
|
213
|
+
var(--space-30)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* A corner .close that is a direct child of the chrome header needs the same
|
|
218
|
+
reserve, but never below the header's own pad. Kept as its own rule so an
|
|
219
|
+
unsupported :has() drops only this geometry, not the wrapper reserve. */
|
|
220
|
+
:where(dialog).modal > header:has(> .close) {
|
|
221
|
+
padding-inline-end: max(
|
|
222
|
+
var(--modal-pad, var(--space-40)),
|
|
223
|
+
calc(var(--close-size, var(--control-size)) + var(--space-20) + var(--space-30))
|
|
224
|
+
);
|
|
136
225
|
}
|
|
137
226
|
|
|
138
227
|
:where(dialog).modal > :where(form, .stack) > footer {
|
|
@@ -146,19 +235,25 @@
|
|
|
146
235
|
action band. The media primitive owns icon/content alignment; this variant
|
|
147
236
|
only removes the standard form inset so both regions can own their padding. */
|
|
148
237
|
:where(dialog).modal.dialog-confirmation > form {
|
|
238
|
+
--surface-pad: 0px;
|
|
239
|
+
|
|
149
240
|
gap: 0;
|
|
150
241
|
padding: 0;
|
|
151
242
|
}
|
|
152
243
|
|
|
153
|
-
:where(dialog).modal.dialog-confirmation > form > .media {
|
|
244
|
+
:where(dialog).modal.dialog-confirmation > form > :is(.media, header) {
|
|
154
245
|
gap: var(--space-40);
|
|
155
246
|
padding: var(--space-50);
|
|
156
247
|
}
|
|
157
248
|
|
|
249
|
+
/* The action band splits a decision pair: the cancel-style action holds the
|
|
250
|
+
leading edge and the committing action the trailing one, so the destructive
|
|
251
|
+
choice is never adjacent to the dismiss. */
|
|
158
252
|
:where(dialog).modal.dialog-confirmation > form > footer {
|
|
253
|
+
justify-content: space-between;
|
|
159
254
|
padding: var(--space-40) var(--space-50);
|
|
160
|
-
border-end-start-radius:
|
|
161
|
-
border-end-end-radius:
|
|
255
|
+
border-end-start-radius: var(--dialog-inner-radius);
|
|
256
|
+
border-end-end-radius: var(--dialog-inner-radius);
|
|
162
257
|
background: var(--surface-subtle);
|
|
163
258
|
}
|
|
164
259
|
|
|
@@ -195,8 +290,29 @@
|
|
|
195
290
|
line-height: 1;
|
|
196
291
|
}
|
|
197
292
|
|
|
293
|
+
:where(dialog).modal.dialog-confirmation > form > .media > header {
|
|
294
|
+
display: grid;
|
|
295
|
+
gap: var(--space-20);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
:where(dialog).modal.dialog-confirmation > form > .media > header > * {
|
|
299
|
+
margin-block: 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* The first heading occupies the icon row, so its text centers on the icon
|
|
303
|
+
without a font-relative nudge. Following copy stays in the same column. */
|
|
304
|
+
:where(dialog).modal.dialog-confirmation
|
|
305
|
+
> form
|
|
306
|
+
> .media
|
|
307
|
+
> header
|
|
308
|
+
> :is(h1, h2, h3, h4, h5, h6):first-child {
|
|
309
|
+
display: flex;
|
|
310
|
+
align-items: center;
|
|
311
|
+
min-block-size: var(--dialog-icon-size, 3rem);
|
|
312
|
+
}
|
|
313
|
+
|
|
198
314
|
@media (max-width: 30rem) {
|
|
199
|
-
:where(dialog).modal.dialog-confirmation > form > .media,
|
|
315
|
+
:where(dialog).modal.dialog-confirmation > form > :is(.media, header),
|
|
200
316
|
:where(dialog).modal.dialog-confirmation > form > footer {
|
|
201
317
|
padding: var(--space-40);
|
|
202
318
|
}
|
package/src/css/forms/native.css
CHANGED
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
the whole control, not just the filename text. */
|
|
20
20
|
min-block-size: var(--control-size);
|
|
21
21
|
padding-block: var(--space-10);
|
|
22
|
-
|
|
22
|
+
/* Border-box height: both borders come out of the padding, as on .btn. */
|
|
23
|
+
padding-block: max(
|
|
24
|
+
var(--space-10),
|
|
25
|
+
calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
|
|
26
|
+
);
|
|
23
27
|
padding-inline: var(--space-20);
|
|
24
28
|
border: var(--border-width) solid var(--border);
|
|
25
29
|
border-radius: var(--radius-sm);
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Design-tool component export: renders every variant of a component in the
|
|
3
|
+
* theme's islands, measures the computed box, and binds each value to a token.
|
|
4
|
+
*
|
|
5
|
+
* Nothing is modelled: the browser answers what a `.btn.danger.soft.lg`
|
|
6
|
+
* looks like, so a theme's overrides and every derived value come along for
|
|
7
|
+
* free. A component is described as one box and its texts — the box's layout
|
|
8
|
+
* (hug or fill, fixed or content height, row or column, centred or not) is
|
|
9
|
+
* read from the rendered element too, so the plugin only replays it.
|
|
10
|
+
*
|
|
11
|
+
* Colors bind to the theme token that paints the same value in every mode; a
|
|
12
|
+
* color no theme token paints (a soft fill, a soft border) becomes a recipe
|
|
13
|
+
* token named `<variant>-<intent>-<role>`, so the design tool still switches
|
|
14
|
+
* it with the mode. Lengths stay measured px: binding them too (radius,
|
|
15
|
+
* padding, height, type — about 1,800 bindings over 221 variants) froze
|
|
16
|
+
* Penpot, and a theme's own lengths already arrive through the measurement.
|
|
17
|
+
*/
|
|
18
|
+
import { hexOf, inPage, parseSrgb } from "./design-tokens.js";
|
|
19
|
+
|
|
20
|
+
/* Every list starts with its default: the first combination is the one a
|
|
21
|
+
design tool picks for a new instance. */
|
|
22
|
+
const INTENTS = ["default", "primary", "secondary", "success", "warning", "danger", "neutral"];
|
|
23
|
+
const SIZES = ["md", "sm", "lg"];
|
|
24
|
+
const BUTTON_VARIANTS = ["solid", "soft", "outline", "surface", "ghost", "link"];
|
|
25
|
+
|
|
26
|
+
const classes = (...names) => names.filter((n) => n && n !== "default" && n !== "md").join(" ");
|
|
27
|
+
|
|
28
|
+
/* One entry per design-tool component. Every combination of `properties` is
|
|
29
|
+
measured and built, and each property becomes a variant property, in the
|
|
30
|
+
order given — Intent, Variant, Size, the way an author stacks the classes.
|
|
31
|
+
`html` renders one combination: classes are the public API, so the markup
|
|
32
|
+
is what an author writes. "default" and "md" add no class. A soft-by-default
|
|
33
|
+
component (badge, alert) still measures its default as `soft`, so its
|
|
34
|
+
recipes share the button's names (`soft-danger-bg`) instead of doubling
|
|
35
|
+
them; the recipe guard below fails if the two ever paint differently. */
|
|
36
|
+
export const COMPONENTS = [
|
|
37
|
+
{
|
|
38
|
+
name: "Button",
|
|
39
|
+
properties: { Intent: INTENTS, Variant: BUTTON_VARIANTS, Size: SIZES },
|
|
40
|
+
html: ({ Intent, Variant, Size }) =>
|
|
41
|
+
`<button class="btn ${classes(Intent, Variant, Size)}" type="button">Button</button>`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "Input",
|
|
45
|
+
properties: { Size: SIZES },
|
|
46
|
+
html: ({ Size }) => `<input class="input ${classes(Size)}" value="Input" aria-label="Input">`,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "Badge",
|
|
50
|
+
properties: { Intent: INTENTS, Variant: ["soft", "solid", "outline"], Size: SIZES },
|
|
51
|
+
html: ({ Intent, Variant, Size }) =>
|
|
52
|
+
`<span class="badge ${classes(Intent, Variant, Size)}">Badge</span>`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "Alert",
|
|
56
|
+
properties: { Intent: INTENTS, Variant: ["soft", "solid", "outline", "surface"] },
|
|
57
|
+
html: ({ Intent, Variant }) =>
|
|
58
|
+
`<div class="alert ${classes(Intent, Variant)}" role="status">Alert message</div>`,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "Card",
|
|
62
|
+
properties: {},
|
|
63
|
+
html: () => '<article class="card"><h3>Card title</h3><p>Card content goes here.</p></article>',
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/* Wide enough for a card, narrow enough that a fill-width component (input,
|
|
68
|
+
alert) reads as a component rather than a page-wide bar. */
|
|
69
|
+
const SPECIMEN_WIDTH = "20rem";
|
|
70
|
+
|
|
71
|
+
/* Runs in the page: every specimen in every island, computed values only.
|
|
72
|
+
A specimen is one box plus its texts: the element's own text, an input's
|
|
73
|
+
value, or each child element (a card's title and body). */
|
|
74
|
+
function measureSpecimens({ css, islands, specimens, width }) {
|
|
75
|
+
const style = document.createElement("style");
|
|
76
|
+
style.textContent = css;
|
|
77
|
+
document.head.append(style);
|
|
78
|
+
const probe = document.createElement("div");
|
|
79
|
+
document.body.append(probe);
|
|
80
|
+
const srgb = (value) => {
|
|
81
|
+
probe.style.color = `color-mix(in srgb, ${value}, ${value})`;
|
|
82
|
+
return getComputedStyle(probe).color;
|
|
83
|
+
};
|
|
84
|
+
const px = (value) => Number.parseFloat(value) || 0;
|
|
85
|
+
|
|
86
|
+
const text = (cs, rect, label) => ({
|
|
87
|
+
label,
|
|
88
|
+
width: rect.width,
|
|
89
|
+
height: rect.height,
|
|
90
|
+
color: srgb(cs.color),
|
|
91
|
+
fontSize: px(cs.fontSize),
|
|
92
|
+
fontWeight: Number(cs.fontWeight),
|
|
93
|
+
lineHeight: cs.lineHeight === "normal" ? 1.2 : px(cs.lineHeight) / px(cs.fontSize),
|
|
94
|
+
underline: cs.textDecorationLine.includes("underline"),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const texts = (el, cs, rect) => {
|
|
98
|
+
if (el instanceof HTMLInputElement) {
|
|
99
|
+
const inner = {
|
|
100
|
+
width:
|
|
101
|
+
rect.width -
|
|
102
|
+
px(cs.paddingLeft) -
|
|
103
|
+
px(cs.paddingRight) -
|
|
104
|
+
px(cs.borderLeftWidth) -
|
|
105
|
+
px(cs.borderRightWidth),
|
|
106
|
+
height: cs.lineHeight === "normal" ? px(cs.fontSize) * 1.2 : px(cs.lineHeight),
|
|
107
|
+
};
|
|
108
|
+
return [{ ...text(cs, inner, el.value), rect: inner }];
|
|
109
|
+
}
|
|
110
|
+
if (el.children.length > 0) {
|
|
111
|
+
return [...el.children].map((child) => {
|
|
112
|
+
const box = child.getBoundingClientRect();
|
|
113
|
+
return { ...text(getComputedStyle(child), box, child.textContent.trim()), rect: box };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const range = document.createRange();
|
|
117
|
+
range.selectNodeContents(el);
|
|
118
|
+
const box = range.getBoundingClientRect();
|
|
119
|
+
return [{ ...text(cs, box, el.textContent.trim()), rect: box }];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return islands.map(({ attribute, scheme }) => {
|
|
123
|
+
const island = document.createElement("div");
|
|
124
|
+
if (attribute) island.dataset.theme = attribute;
|
|
125
|
+
if (scheme) island.style.colorScheme = scheme;
|
|
126
|
+
// What body gives the page: an island does not reset `color`, and the
|
|
127
|
+
// currentColor variants (outline, ghost, link) would otherwise inherit
|
|
128
|
+
// the ink of body's scheme instead of this island's.
|
|
129
|
+
island.style.color = "var(--text)";
|
|
130
|
+
island.innerHTML = specimens
|
|
131
|
+
.map((html) => `<div style="inline-size: ${width}">${html}</div>`)
|
|
132
|
+
.join("");
|
|
133
|
+
document.body.append(island);
|
|
134
|
+
|
|
135
|
+
const measured = [...island.children].map((wrap) => {
|
|
136
|
+
const el = wrap.firstElementChild;
|
|
137
|
+
const cs = getComputedStyle(el);
|
|
138
|
+
const rect = el.getBoundingClientRect();
|
|
139
|
+
const children = texts(el, cs, rect);
|
|
140
|
+
const flex = cs.display.includes("flex");
|
|
141
|
+
const column = flex ? cs.flexDirection.startsWith("column") : children.length > 1;
|
|
142
|
+
const [a, b] = children;
|
|
143
|
+
return {
|
|
144
|
+
width: rect.width,
|
|
145
|
+
height: rect.height,
|
|
146
|
+
// Narrower than its container: the component sizes to its content.
|
|
147
|
+
hug: rect.width < wrap.getBoundingClientRect().width - 0.5,
|
|
148
|
+
// A minimum height is a control size; otherwise content decides.
|
|
149
|
+
fixedHeight: px(cs.minHeight) > 0,
|
|
150
|
+
dir: column ? "column" : "row",
|
|
151
|
+
justify: cs.justifyContent === "center" ? "center" : "start",
|
|
152
|
+
gap: b ? Math.max(0, column ? b.rect.top - a.rect.bottom : b.rect.left - a.rect.right) : 0,
|
|
153
|
+
padding: {
|
|
154
|
+
top: px(cs.paddingTop),
|
|
155
|
+
right: px(cs.paddingRight),
|
|
156
|
+
bottom: px(cs.paddingBottom),
|
|
157
|
+
left: px(cs.paddingLeft),
|
|
158
|
+
},
|
|
159
|
+
borderWidth: px(cs.borderTopWidth),
|
|
160
|
+
radius: px(cs.borderTopLeftRadius),
|
|
161
|
+
bg: srgb(cs.backgroundColor),
|
|
162
|
+
border: srgb(cs.borderTopColor),
|
|
163
|
+
children: children.map(({ rect: _, ...child }) => child),
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
island.remove();
|
|
167
|
+
return measured;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function combinations(properties) {
|
|
172
|
+
return Object.entries(properties).reduce(
|
|
173
|
+
(acc, [name, values]) => acc.flatMap((props) => values.map((v) => ({ ...props, [name]: v }))),
|
|
174
|
+
[{}],
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/*
|
|
179
|
+
* Measures `components` for the islands of a resolved theme. Returns the
|
|
180
|
+
* recipe colors per mode (same shape as linked theme colors) and the spec a
|
|
181
|
+
* design-tool plugin builds from: per variant, the box and its texts in px,
|
|
182
|
+
* and each color's value for the first mode plus the token it binds to.
|
|
183
|
+
*/
|
|
184
|
+
export async function measureComponents({ css, resolved, linked, components = COMPONENTS }) {
|
|
185
|
+
const modes = Object.keys(resolved.modes);
|
|
186
|
+
const specimens = components.flatMap((component) =>
|
|
187
|
+
combinations(component.properties).map((props) => ({ component, props })),
|
|
188
|
+
);
|
|
189
|
+
const measured = await inPage(measureSpecimens, {
|
|
190
|
+
css,
|
|
191
|
+
islands: resolved.islands,
|
|
192
|
+
specimens: specimens.map(({ component, props }) => component.html(props)),
|
|
193
|
+
width: SPECIMEN_WIDTH,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Theme colors keyed by their value in every mode; aliases are skipped so a
|
|
197
|
+
// color binds to the concrete token (`text`, not `heading`).
|
|
198
|
+
const themeByValue = new Map();
|
|
199
|
+
for (const name of Object.keys(linked.modes[modes[0]])) {
|
|
200
|
+
if (linked.modes[modes[0]][name].alias) continue;
|
|
201
|
+
const key = modes.map((mode) => hexOf(resolved.modes[mode][name])).join(" ");
|
|
202
|
+
if (!themeByValue.has(key)) themeByValue.set(key, name);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const recipes = Object.fromEntries(modes.map((mode) => [mode, {}]));
|
|
206
|
+
// `perMode` is one computed color per mode.
|
|
207
|
+
const bindColor = (perMode, recipeName) => {
|
|
208
|
+
const colors = perMode.map(parseSrgb);
|
|
209
|
+
if (colors.every((c) => c.alpha === 0)) return null;
|
|
210
|
+
const hexes = colors.map(hexOf);
|
|
211
|
+
let token = themeByValue.get(hexes.join(" "));
|
|
212
|
+
if (!token) {
|
|
213
|
+
token = recipeName;
|
|
214
|
+
modes.forEach((mode, m) => {
|
|
215
|
+
const known = recipes[mode][token];
|
|
216
|
+
// A recipe name carries no size and no component: if two of them
|
|
217
|
+
// ever paint different colors, the name has to grow a dimension.
|
|
218
|
+
if (known && known.hex !== hexes[m]) {
|
|
219
|
+
throw new Error(`Recipe ${token} paints ${known.hex} and ${hexes[m]} in ${mode}`);
|
|
220
|
+
}
|
|
221
|
+
recipes[mode][token] = { color: colors[m], hex: hexes[m] };
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const [first] = colors;
|
|
225
|
+
return { hex: hexOf({ ...first, alpha: 1 }), opacity: first.alpha, token };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const describe = (component, props, i) => {
|
|
229
|
+
const [m] = measured.map((island) => island[i]);
|
|
230
|
+
const each = (read) => measured.map((island) => read(island[i]));
|
|
231
|
+
// Intent and variant name the recipe; a component with neither uses its
|
|
232
|
+
// own name, so two plain components never share one.
|
|
233
|
+
const scope = [props.Variant, props.Intent].filter(Boolean);
|
|
234
|
+
const recipe = (role) =>
|
|
235
|
+
[...(scope.length ? scope : [component.name]), role].join("-").toLowerCase();
|
|
236
|
+
return {
|
|
237
|
+
props,
|
|
238
|
+
box: {
|
|
239
|
+
width: Math.ceil(m.width),
|
|
240
|
+
height: m.height,
|
|
241
|
+
hug: m.hug,
|
|
242
|
+
fixedHeight: m.fixedHeight,
|
|
243
|
+
dir: m.dir,
|
|
244
|
+
justify: m.justify,
|
|
245
|
+
gap: m.gap,
|
|
246
|
+
padding: m.padding,
|
|
247
|
+
radius: m.radius,
|
|
248
|
+
borderWidth: m.borderWidth,
|
|
249
|
+
fill: bindColor(
|
|
250
|
+
each((s) => s.bg),
|
|
251
|
+
recipe("bg"),
|
|
252
|
+
),
|
|
253
|
+
stroke:
|
|
254
|
+
m.borderWidth > 0
|
|
255
|
+
? bindColor(
|
|
256
|
+
each((s) => s.border),
|
|
257
|
+
recipe("border"),
|
|
258
|
+
)
|
|
259
|
+
: null,
|
|
260
|
+
},
|
|
261
|
+
texts: m.children.map((child, c) => ({
|
|
262
|
+
label: child.label,
|
|
263
|
+
width: Math.ceil(child.width),
|
|
264
|
+
height: Math.ceil(child.height),
|
|
265
|
+
lineHeight: Math.round(child.lineHeight * 1000) / 1000,
|
|
266
|
+
fontSize: child.fontSize,
|
|
267
|
+
fontWeight: child.fontWeight,
|
|
268
|
+
underline: child.underline,
|
|
269
|
+
color: bindColor(
|
|
270
|
+
each((s) => s.children[c].color),
|
|
271
|
+
recipe(c === 0 ? "fg" : `fg-${c + 1}`),
|
|
272
|
+
),
|
|
273
|
+
})),
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const spec = components.map((component) => ({
|
|
278
|
+
name: component.name,
|
|
279
|
+
properties: Object.keys(component.properties),
|
|
280
|
+
variants: specimens.flatMap(({ component: owner, props }, i) =>
|
|
281
|
+
owner === component ? [describe(component, props, i)] : [],
|
|
282
|
+
),
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
return { recipes, spec: { components: spec } };
|
|
286
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Design-tool export for one theme: Figma and Penpot tokens, the measured
|
|
3
|
+
* component spec, and the Penpot plugin that builds it. Shared by the CLI
|
|
4
|
+
* (`actual-css design`) and the repository script (`bun run export:design`).
|
|
5
|
+
* Bun only: values are read in headless Chrome through Bun.WebView.
|
|
6
|
+
*/
|
|
7
|
+
import { copyFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { inlineImports } from "./css-bundle.js";
|
|
11
|
+
import { measureComponents } from "./design-components.js";
|
|
12
|
+
import {
|
|
13
|
+
coreTokenCss,
|
|
14
|
+
linkTokens,
|
|
15
|
+
resolveTokens,
|
|
16
|
+
themeNameOf,
|
|
17
|
+
toFigma,
|
|
18
|
+
toPenpot,
|
|
19
|
+
} from "./design-tokens.js";
|
|
20
|
+
|
|
21
|
+
const FRAMEWORK = fileURLToPath(new URL("../css/actual.full.css", import.meta.url));
|
|
22
|
+
const PLUGIN = fileURLToPath(new URL("./penpot-plugin/", import.meta.url));
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* Writes, under `out`:
|
|
26
|
+
* figma/foundations.tokens.json, figma/<scheme>.tokens.json
|
|
27
|
+
* penpot/tokens.json
|
|
28
|
+
* penpot/plugin/ (manifest.json, plugin.js, ui.html, spec.json)
|
|
29
|
+
* Without `themeFile`, exports the default theme. `name` overrides the
|
|
30
|
+
* data-theme name read from the theme file. Returns the written paths.
|
|
31
|
+
*/
|
|
32
|
+
export async function exportDesign({ themeFile, name, out }) {
|
|
33
|
+
const themeCss = themeFile ? await inlineImports(themeFile) : "";
|
|
34
|
+
const theme = themeFile ? (name ?? themeNameOf(themeCss)) : null;
|
|
35
|
+
const css = `${await inlineImports(FRAMEWORK)}\n${themeCss}`;
|
|
36
|
+
|
|
37
|
+
const resolved = await resolveTokens({
|
|
38
|
+
css,
|
|
39
|
+
theme,
|
|
40
|
+
aliasSources: [await coreTokenCss(), themeFile ? await readFile(themeFile, "utf8") : ""],
|
|
41
|
+
});
|
|
42
|
+
const linked = linkTokens(resolved);
|
|
43
|
+
const { recipes, spec } = await measureComponents({ css, resolved, linked });
|
|
44
|
+
|
|
45
|
+
const documents = {
|
|
46
|
+
...Object.fromEntries(
|
|
47
|
+
Object.entries(toFigma(linked, recipes)).map(([file, doc]) => [
|
|
48
|
+
`figma/${file}.tokens.json`,
|
|
49
|
+
doc,
|
|
50
|
+
]),
|
|
51
|
+
),
|
|
52
|
+
"penpot/tokens.json": toPenpot(linked, recipes),
|
|
53
|
+
"penpot/plugin/spec.json": spec,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const written = [];
|
|
57
|
+
for (const [path, doc] of Object.entries(documents)) {
|
|
58
|
+
const file = join(out, path);
|
|
59
|
+
await mkdir(dirname(file), { recursive: true });
|
|
60
|
+
await writeFile(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
61
|
+
written.push(file);
|
|
62
|
+
}
|
|
63
|
+
for (const entry of await readdir(PLUGIN)) {
|
|
64
|
+
const file = join(out, "penpot", "plugin", entry);
|
|
65
|
+
await copyFile(join(PLUGIN, entry), file);
|
|
66
|
+
written.push(file);
|
|
67
|
+
}
|
|
68
|
+
return written;
|
|
69
|
+
}
|