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.
@@ -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>