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.
Files changed (92) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +29 -5
  3. package/components.json +15 -17
  4. package/dist/actual.full.css +403 -227
  5. package/dist/actual.full.min.css +1 -1
  6. package/docs/brand/favicon.svg +5 -0
  7. package/docs/brand/lockup.svg +12 -0
  8. package/docs/brand/logo-dark.svg +9 -0
  9. package/docs/brand/logo-light.svg +9 -0
  10. package/docs/brand/logo.svg +11 -0
  11. package/docs/brand/mark.svg +11 -0
  12. package/docs/navigation.json +4 -2
  13. package/docs/pages/components/alert.md +2 -10
  14. package/docs/pages/components/badge.md +3 -9
  15. package/docs/pages/components/bleed.md +5 -4
  16. package/docs/pages/components/card.md +31 -2
  17. package/docs/pages/components/close.md +9 -3
  18. package/docs/pages/components/dialog.md +248 -31
  19. package/docs/pages/components/drawer.md +2 -2
  20. package/docs/pages/components/meter.md +2 -0
  21. package/docs/pages/components/navbar.md +8 -9
  22. package/docs/pages/components/table.md +3 -3
  23. package/docs/pages/enhancements/flyout.md +2 -2
  24. package/docs/pages/enhancements/scrollspy.md +1 -1
  25. package/docs/pages/enhancements/status-bar.md +2 -2
  26. package/docs/pages/examples/overview.md +3 -1
  27. package/docs/pages/forms/date-picker.md +90 -0
  28. package/docs/pages/forms/detached-actions.md +37 -30
  29. package/docs/pages/forms/form-actions.md +61 -28
  30. package/docs/pages/forms/input-policy.md +1 -1
  31. package/docs/pages/forms/overview.md +6 -5
  32. package/docs/pages/forms/sticky-actions.md +85 -83
  33. package/docs/pages/forms/validation.md +5 -5
  34. package/docs/pages/foundations/philosophy.md +5 -3
  35. package/docs/pages/foundations/theming.md +15 -4
  36. package/docs/pages/foundations/tokens.md +50 -33
  37. package/docs/pages/foundations/typography.md +4 -7
  38. package/docs/pages/guides/bootstrap.md +2 -2
  39. package/docs/pages/guides/design-tools.md +36 -0
  40. package/docs/pages/guides/integrating-actual-css.md +5 -1
  41. package/docs/pages/guides/introduction.md +3 -4
  42. package/docs/pages/guides/modular-import.md +3 -0
  43. package/docs/pages/guides/progressive-enhancement.md +2 -2
  44. package/docs/pages/guides/tailwind.md +70 -0
  45. package/docs/pages/guides/upgrading.md +74 -0
  46. package/docs/pages/layout/cluster.md +28 -2
  47. package/docs/pages/layout/overview.md +1 -1
  48. package/docs/pages/layout/stack.md +21 -16
  49. package/docs/pages/layout/topbar.md +1 -1
  50. package/docs/pages/patterns/navigation-list.md +16 -46
  51. package/docs/pages/patterns/overview.md +3 -4
  52. package/docs/pages/utilities/flex.md +30 -20
  53. package/docs/pages/utilities/sizing-wrapping.md +1 -1
  54. package/docs/pages/utilities/spacing.md +4 -3
  55. package/package.json +3 -2
  56. package/reserved-classes.json +3 -3
  57. package/skills/actual-css/references/enhancements.md +38 -0
  58. package/src/cli/actual-css.js +75 -6
  59. package/src/css/components/avatar.css +16 -9
  60. package/src/css/components/badge.css +1 -5
  61. package/src/css/components/bleed.css +5 -5
  62. package/src/css/components/button.css +17 -10
  63. package/src/css/components/card.css +18 -10
  64. package/src/css/components/chat.css +4 -5
  65. package/src/css/components/close.css +17 -6
  66. package/src/css/components/drawer.css +19 -3
  67. package/src/css/components/flyout.css +3 -0
  68. package/src/css/components/index.css +0 -1
  69. package/src/css/components/meter.css +5 -2
  70. package/src/css/components/modal.css +157 -69
  71. package/src/css/components/navbar.css +14 -25
  72. package/src/css/core/base.css +1 -1
  73. package/src/css/core/reset.css +2 -1
  74. package/src/css/core/theme.css +4 -0
  75. package/src/css/core/tokens.css +28 -10
  76. package/src/css/core/variants.css +41 -14
  77. package/src/css/forms/form-actions.css +15 -10
  78. package/src/css/forms/native.css +5 -1
  79. package/src/css/layout/cluster.css +11 -0
  80. package/src/css/layout/topbar.css +12 -6
  81. package/src/css/utilities/base.css +5 -0
  82. package/src/css/utilities/extra.css +3 -7
  83. package/src/tooling/color.js +65 -0
  84. package/src/tooling/design-components.js +286 -0
  85. package/src/tooling/design-export.js +69 -0
  86. package/src/tooling/design-tokens.js +474 -0
  87. package/src/tooling/penpot-plugin/manifest.json +7 -0
  88. package/src/tooling/penpot-plugin/plugin.js +272 -0
  89. package/src/tooling/penpot-plugin/ui.html +58 -0
  90. package/src/tooling/theme-contrast.js +209 -0
  91. package/docs/pages/patterns/actions.md +0 -52
  92. package/src/css/components/actions.css +0 -27
@@ -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);
@@ -9,6 +9,7 @@
9
9
  * --cluster-wrap
10
10
  *
11
11
  * Child contract: direct-child margins are reset, spacing comes from --gap.
12
+ * A ul/ol/menu keeps its semantics but loses list chrome (see below).
12
13
  */
13
14
  .cluster {
14
15
  display: flex;
@@ -22,3 +23,13 @@
22
23
  .cluster > * {
23
24
  margin: 0;
24
25
  }
26
+
27
+ /* Horizontal, wrapping layout is incompatible with list markers and native
28
+ indentation: a ul/ol/menu applied with .cluster stays a list in the DOM, but
29
+ its visual chrome would be accidental. .stack intentionally leaves this
30
+ alone — a vertical list can keep its numbers. */
31
+ :where(ul, ol, menu).cluster {
32
+ margin: 0;
33
+ padding: 0;
34
+ list-style: none;
35
+ }
@@ -1,8 +1,9 @@
1
1
  /*
2
2
  * Sticky, frosted top bar shell for app/dashboard shells (persistent
3
3
  * sidebar + topbar layouts). Owns positioning, stacking, and the frosted
4
- * background only — row content (search, breadcrumb, actions) stays local
5
- * to the page.
4
+ * background; it also lays its children out as a default row that a layout
5
+ * primitive on the same element (such as .cluster) replaces. Row content
6
+ * (search, breadcrumb, actions) stays local to the page.
6
7
  *
7
8
  * Docs: docs/pages/layout/topbar.md
8
9
  *
@@ -16,16 +17,21 @@
16
17
  inset-block-start: 0;
17
18
  z-index: var(--z-sticky);
18
19
  isolation: isolate;
19
- display: flex;
20
- flex-direction: row;
21
- align-items: center;
22
- gap: var(--gap);
23
20
  padding-block: var(--space-30);
24
21
  padding-inline: var(--space-40);
25
22
  border-block-end: var(--border-width) solid var(--border);
26
23
  background: var(--surface);
27
24
  }
28
25
 
26
+ /* The row is a default at zero specificity: .topbar and the primitives share
27
+ layout/, so at class weight their file order alone would pick the winner. */
28
+ :where(.topbar) {
29
+ display: flex;
30
+ flex-direction: row;
31
+ align-items: center;
32
+ gap: var(--gap);
33
+ }
34
+
29
35
  /* Both conditions are required: the translucent background contains var(),
30
36
  so a browser with backdrop-filter but not color-mix (e.g. Safari < 16.2)
31
37
  would accept the declaration at parse time, fail it at computed-value
@@ -74,6 +74,11 @@
74
74
  align-items: end;
75
75
  }
76
76
 
77
+ .items-baseline {
78
+ --items-align: baseline;
79
+ align-items: baseline;
80
+ }
81
+
77
82
  .muted {
78
83
  color: var(--text-muted);
79
84
  }
@@ -10,9 +10,6 @@
10
10
  * - utilities consume the current context instead of defining their own -sm/-lg scale.
11
11
  * - optional utilities extend core utilities, they do not re-express them.
12
12
  * - follow verbose .{property}-{value} naming.
13
- *
14
- * Internal:
15
- * --cluster-justify (local relay consumed by .cluster)
16
13
  */
17
14
 
18
15
  /* Display */
@@ -87,23 +84,22 @@
87
84
  flex-wrap: nowrap;
88
85
  }
89
86
 
87
+ /* The property is written directly, never relayed through the cluster hook:
88
+ a relay would inherit into every .cluster nested inside. Utilities load last,
89
+ so the direct declaration already beats .cluster at equal specificity. */
90
90
  .justify-content-start {
91
- --cluster-justify: flex-start;
92
91
  justify-content: flex-start;
93
92
  }
94
93
 
95
94
  .justify-content-center {
96
- --cluster-justify: center;
97
95
  justify-content: center;
98
96
  }
99
97
 
100
98
  .justify-content-end {
101
- --cluster-justify: flex-end;
102
99
  justify-content: flex-end;
103
100
  }
104
101
 
105
102
  .justify-content-space-between {
106
- --cluster-justify: space-between;
107
103
  justify-content: space-between;
108
104
  }
109
105
 
@@ -0,0 +1,65 @@
1
+ /*
2
+ * sRGB color math shared by `actual-css contrast`, the repository reports and
3
+ * the browser tests.
4
+ * Colors are [r, g, b, a] byte arrays as returned by RASTERIZE in the page;
5
+ * plain [r, g, b] triplets are accepted and treated as opaque.
6
+ */
7
+
8
+ /*
9
+ * Page-side rasterizer, injected into a view.evaluate() program as
10
+ * `const rgba = ${RASTERIZE};`. getComputedStyle returns color-mix() and
11
+ * light-dark() results as oklab() or color(), so a 1x1 canvas turns any
12
+ * computed color into sRGB bytes. Alpha is kept so contrast() can refuse a
13
+ * translucent color instead of measuring it as if it were opaque.
14
+ */
15
+ export const RASTERIZE = `(() => {
16
+ const canvas = Object.assign(document.createElement("canvas"), { width: 1, height: 1 });
17
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
18
+ return (value) => {
19
+ ctx.clearRect(0, 0, 1, 1);
20
+ ctx.fillStyle = value;
21
+ ctx.fillRect(0, 0, 1, 1);
22
+ return [...ctx.getImageData(0, 0, 1, 1).data];
23
+ };
24
+ })()`;
25
+
26
+ /* 0.04045 is the sRGB (IEC 61966-2-1) threshold and the one WCAG 2.2 defines.
27
+ WCAG 2.0/2.1 printed 0.03928, from an older sRGB draft. No 8-bit channel
28
+ falls between the two (10/255 = 0.0392, 11/255 = 0.0431), so ratios are
29
+ identical either way. */
30
+ const toLinear = (byte) => {
31
+ const c = byte / 255;
32
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
33
+ };
34
+
35
+ /* WCAG 2 relative luminance. */
36
+ export const luminance = ([r, g, b]) =>
37
+ 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
38
+
39
+ /* WCAG 2 contrast ratio, 1 to 21. The formula has no alpha: a translucent
40
+ color must be composited over its real background before it is measured. */
41
+ export function contrast(a, b) {
42
+ if (a[3] < 255 || b[3] < 255) {
43
+ throw new Error(`contrast() needs opaque colors, got [${a}] and [${b}]`);
44
+ }
45
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
46
+ return (hi + 0.05) / (lo + 0.05);
47
+ }
48
+
49
+ /* OKLCH of an sRGB color: L 0-1, C chroma, h degrees. */
50
+ export function oklch([r, g, b]) {
51
+ const [lr, lg, lb] = [r, g, b].map(toLinear);
52
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
53
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
54
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
55
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
56
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
57
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
58
+ return { L, C: Math.hypot(A, B), h: ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360 };
59
+ }
60
+
61
+ /* Shortest arc between two hues, in degrees. */
62
+ export const hueDistance = (a, b) => {
63
+ const d = Math.abs(a - b) % 360;
64
+ return d > 180 ? 360 - d : d;
65
+ };
@@ -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
+ }