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.
@@ -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
- border: var(--border-width) solid var(--border);
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(--space-40);
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(var(--close-size, var(--control-size)) + var(--space-30));
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: calc(var(--radius-lg) - var(--border-width));
161
- border-end-end-radius: calc(var(--radius-lg) - var(--border-width));
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
  }
@@ -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
- padding-block: max(var(--space-10), calc((var(--control-size) - 1lh) / 2));
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
+ }