actual-css 0.10.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +99 -1
  2. package/README.md +6 -2
  3. package/components.json +14 -3
  4. package/dist/actual.full.css +483 -128
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/enhancement-contract.md +16 -7
  8. package/docs/design-notes/platform-alignment.md +8 -4
  9. package/docs/design-notes/rich-text.md +61 -0
  10. package/docs/design-notes/steps.md +2 -2
  11. package/docs/navigation.json +4 -1
  12. package/docs/pages/components/aura.md +12 -13
  13. package/docs/pages/components/badge.md +10 -0
  14. package/docs/pages/components/bleed.md +5 -4
  15. package/docs/pages/components/button.md +1 -1
  16. package/docs/pages/components/chat.md +5 -0
  17. package/docs/pages/components/close.md +9 -3
  18. package/docs/pages/components/dialog.md +145 -13
  19. package/docs/pages/components/meter.md +2 -0
  20. package/docs/pages/components/pagination.md +15 -15
  21. package/docs/pages/components/shimmer.md +57 -0
  22. package/docs/pages/components/skeleton.md +2 -1
  23. package/docs/pages/components/spinner.md +11 -6
  24. package/docs/pages/components/table.md +13 -0
  25. package/docs/pages/enhancements/flyout.md +2 -0
  26. package/docs/pages/enhancements/tabs.md +4 -0
  27. package/docs/pages/examples/overview.md +1 -0
  28. package/docs/pages/forms/choice-cards.md +77 -4
  29. package/docs/pages/forms/combobox.md +16 -1
  30. package/docs/pages/forms/overview.md +4 -1
  31. package/docs/pages/forms/range.md +110 -5
  32. package/docs/pages/forms/rich-text.md +110 -0
  33. package/docs/pages/forms/validation.md +5 -1
  34. package/docs/pages/foundations/theming.md +49 -3
  35. package/docs/pages/foundations/tokens.md +15 -5
  36. package/docs/pages/guides/bootstrap.md +2 -1
  37. package/docs/pages/guides/design-tools.md +36 -0
  38. package/docs/pages/guides/introduction.md +1 -1
  39. package/docs/pages/guides/modular-import.md +1 -1
  40. package/docs/pages/guides/progressive-enhancement.md +13 -14
  41. package/docs/pages/guides/tailwind.md +4 -2
  42. package/llms.txt +4 -3
  43. package/package.json +6 -1
  44. package/reserved-classes.json +1 -0
  45. package/skills/actual-css/references/enhancements.md +35 -0
  46. package/src/cli/actual-css.js +49 -6
  47. package/src/css/components/avatar.css +1 -1
  48. package/src/css/components/bleed.css +5 -5
  49. package/src/css/components/button.css +14 -1
  50. package/src/css/components/close.css +17 -6
  51. package/src/css/components/flyout.css +4 -1
  52. package/src/css/components/meter.css +6 -3
  53. package/src/css/components/modal.css +123 -7
  54. package/src/css/components/pagination.css +3 -2
  55. package/src/css/components/rating.css +1 -1
  56. package/src/css/components/status-bar.css +2 -2
  57. package/src/css/components/steps.css +3 -3
  58. package/src/css/components/tab.css +3 -3
  59. package/src/css/components/table.css +6 -0
  60. package/src/css/components/tooltip.css +1 -1
  61. package/src/css/core/focus.css +14 -4
  62. package/src/css/core/theme.css +8 -0
  63. package/src/css/core/tokens.css +8 -4
  64. package/src/css/core/variants.css +2 -1
  65. package/src/css/effects/aura.css +51 -33
  66. package/src/css/effects/index.css +1 -0
  67. package/src/css/effects/shimmer.css +57 -0
  68. package/src/css/forms/choice-card.css +71 -23
  69. package/src/css/forms/control.css +2 -2
  70. package/src/css/forms/native.css +5 -1
  71. package/src/css/forms/otp.css +9 -1
  72. package/src/css/forms/range.css +64 -21
  73. package/src/js/command.js +61 -32
  74. package/src/js/dialog.js +8 -23
  75. package/src/js/dismiss.js +1 -2
  76. package/src/js/enhance.js +84 -123
  77. package/src/js/full.js +1 -0
  78. package/src/js/password.js +1 -8
  79. package/src/js/range.js +151 -0
  80. package/src/js/scrollspy.js +4 -5
  81. package/src/js/status.js +2 -10
  82. package/src/js/tooltip.js +11 -1
  83. package/src/js/validation.js +4 -10
  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 +466 -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
@@ -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
+ }