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.
- package/CHANGELOG.md +73 -0
- package/README.md +29 -5
- package/components.json +15 -17
- package/dist/actual.full.css +403 -227
- package/dist/actual.full.min.css +1 -1
- package/docs/brand/favicon.svg +5 -0
- package/docs/brand/lockup.svg +12 -0
- package/docs/brand/logo-dark.svg +9 -0
- package/docs/brand/logo-light.svg +9 -0
- package/docs/brand/logo.svg +11 -0
- package/docs/brand/mark.svg +11 -0
- package/docs/navigation.json +4 -2
- package/docs/pages/components/alert.md +2 -10
- package/docs/pages/components/badge.md +3 -9
- package/docs/pages/components/bleed.md +5 -4
- package/docs/pages/components/card.md +31 -2
- package/docs/pages/components/close.md +9 -3
- package/docs/pages/components/dialog.md +248 -31
- package/docs/pages/components/drawer.md +2 -2
- package/docs/pages/components/meter.md +2 -0
- package/docs/pages/components/navbar.md +8 -9
- package/docs/pages/components/table.md +3 -3
- package/docs/pages/enhancements/flyout.md +2 -2
- package/docs/pages/enhancements/scrollspy.md +1 -1
- package/docs/pages/enhancements/status-bar.md +2 -2
- package/docs/pages/examples/overview.md +3 -1
- package/docs/pages/forms/date-picker.md +90 -0
- package/docs/pages/forms/detached-actions.md +37 -30
- package/docs/pages/forms/form-actions.md +61 -28
- package/docs/pages/forms/input-policy.md +1 -1
- package/docs/pages/forms/overview.md +6 -5
- package/docs/pages/forms/sticky-actions.md +85 -83
- package/docs/pages/forms/validation.md +5 -5
- package/docs/pages/foundations/philosophy.md +5 -3
- package/docs/pages/foundations/theming.md +15 -4
- package/docs/pages/foundations/tokens.md +50 -33
- package/docs/pages/foundations/typography.md +4 -7
- package/docs/pages/guides/bootstrap.md +2 -2
- package/docs/pages/guides/design-tools.md +36 -0
- package/docs/pages/guides/integrating-actual-css.md +5 -1
- package/docs/pages/guides/introduction.md +3 -4
- package/docs/pages/guides/modular-import.md +3 -0
- package/docs/pages/guides/progressive-enhancement.md +2 -2
- package/docs/pages/guides/tailwind.md +70 -0
- package/docs/pages/guides/upgrading.md +74 -0
- package/docs/pages/layout/cluster.md +28 -2
- package/docs/pages/layout/overview.md +1 -1
- package/docs/pages/layout/stack.md +21 -16
- package/docs/pages/layout/topbar.md +1 -1
- package/docs/pages/patterns/navigation-list.md +16 -46
- package/docs/pages/patterns/overview.md +3 -4
- package/docs/pages/utilities/flex.md +30 -20
- package/docs/pages/utilities/sizing-wrapping.md +1 -1
- package/docs/pages/utilities/spacing.md +4 -3
- package/package.json +3 -2
- package/reserved-classes.json +3 -3
- package/skills/actual-css/references/enhancements.md +38 -0
- package/src/cli/actual-css.js +75 -6
- package/src/css/components/avatar.css +16 -9
- package/src/css/components/badge.css +1 -5
- package/src/css/components/bleed.css +5 -5
- package/src/css/components/button.css +17 -10
- package/src/css/components/card.css +18 -10
- package/src/css/components/chat.css +4 -5
- package/src/css/components/close.css +17 -6
- package/src/css/components/drawer.css +19 -3
- package/src/css/components/flyout.css +3 -0
- package/src/css/components/index.css +0 -1
- package/src/css/components/meter.css +5 -2
- package/src/css/components/modal.css +157 -69
- package/src/css/components/navbar.css +14 -25
- package/src/css/core/base.css +1 -1
- package/src/css/core/reset.css +2 -1
- package/src/css/core/theme.css +4 -0
- package/src/css/core/tokens.css +28 -10
- package/src/css/core/variants.css +41 -14
- package/src/css/forms/form-actions.css +15 -10
- package/src/css/forms/native.css +5 -1
- package/src/css/layout/cluster.css +11 -0
- package/src/css/layout/topbar.css +12 -6
- package/src/css/utilities/base.css +5 -0
- package/src/css/utilities/extra.css +3 -7
- package/src/tooling/color.js +65 -0
- package/src/tooling/design-components.js +286 -0
- package/src/tooling/design-export.js +69 -0
- package/src/tooling/design-tokens.js +474 -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
- package/src/tooling/theme-contrast.js +209 -0
- package/docs/pages/patterns/actions.md +0 -52
- package/src/css/components/actions.css +0 -27
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Design-tool token export: resolves a theme's tokens in headless Chrome and
|
|
3
|
+
* writes them for Figma (DTCG files, one per mode) and Penpot (one token file
|
|
4
|
+
* with its sets and themes).
|
|
5
|
+
*
|
|
6
|
+
* Values are read from the browser, never from the CSS text: tokens are built
|
|
7
|
+
* with light-dark(), color-mix() and var() chains that only the browser
|
|
8
|
+
* evaluates faithfully. The CSS source only tells which declarations are pure
|
|
9
|
+
* aliases (`--a: var(--b)`), so the design tool keeps the relationship instead
|
|
10
|
+
* of two unrelated equal values. Names stay the CSS names without `--`.
|
|
11
|
+
* Bun only: Bun.WebView drives the browser.
|
|
12
|
+
*/
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import { stripComments } from "./css-bundle.js";
|
|
15
|
+
|
|
16
|
+
const CORE_TOKEN_FILES = ["../css/core/tokens.css", "../css/core/theme.css"];
|
|
17
|
+
|
|
18
|
+
/* Painted colors, one value per mode. The knobs that produce derived colors
|
|
19
|
+
(mix ratios, backdrop color and opacity) are not exported: the design tool
|
|
20
|
+
receives what is painted, not how it was computed. */
|
|
21
|
+
export const THEME_COLORS = [
|
|
22
|
+
"primary",
|
|
23
|
+
"primary-fg",
|
|
24
|
+
"secondary",
|
|
25
|
+
"secondary-fg",
|
|
26
|
+
"success",
|
|
27
|
+
"success-fg",
|
|
28
|
+
"warning",
|
|
29
|
+
"warning-fg",
|
|
30
|
+
"danger",
|
|
31
|
+
"danger-fg",
|
|
32
|
+
"neutral",
|
|
33
|
+
"neutral-fg",
|
|
34
|
+
"surface",
|
|
35
|
+
"surface-raised",
|
|
36
|
+
"surface-subtle",
|
|
37
|
+
"surface-solid",
|
|
38
|
+
"surface-solid-fg",
|
|
39
|
+
"surface-opaque",
|
|
40
|
+
"text",
|
|
41
|
+
"text-muted",
|
|
42
|
+
"text-subtle",
|
|
43
|
+
"heading",
|
|
44
|
+
"border",
|
|
45
|
+
"focus",
|
|
46
|
+
"hover-overlay",
|
|
47
|
+
"hover-overlay-solid",
|
|
48
|
+
"shadow-color",
|
|
49
|
+
"selection-bg",
|
|
50
|
+
"selection-fg",
|
|
51
|
+
"state-selected",
|
|
52
|
+
"state-selected-fg",
|
|
53
|
+
"state-disabled",
|
|
54
|
+
"indicator-ring",
|
|
55
|
+
"backdrop-fill",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/* Mode-independent tokens by role. The role tells a design tool where a value
|
|
59
|
+
applies (Penpot token types); every role but font-weight is a length. */
|
|
60
|
+
export const FOUNDATIONS = {
|
|
61
|
+
spacing: [
|
|
62
|
+
"space-10",
|
|
63
|
+
"space-20",
|
|
64
|
+
"space-30",
|
|
65
|
+
"space-40",
|
|
66
|
+
"space-50",
|
|
67
|
+
"space-60",
|
|
68
|
+
"gap",
|
|
69
|
+
"control-pad-x-sm",
|
|
70
|
+
"control-pad-x-md",
|
|
71
|
+
"control-pad-x-lg",
|
|
72
|
+
"control-pad-x",
|
|
73
|
+
],
|
|
74
|
+
radius: ["radius-sm", "radius", "radius-lg", "radius-full"],
|
|
75
|
+
"border-width": ["border-width", "focus-ring-width"],
|
|
76
|
+
"font-size": [
|
|
77
|
+
"font-size-xs",
|
|
78
|
+
"font-size-sm",
|
|
79
|
+
"font-size-md",
|
|
80
|
+
"font-size-lg",
|
|
81
|
+
"control-font-size",
|
|
82
|
+
],
|
|
83
|
+
size: ["control-size-sm", "control-size-md", "control-size-lg", "control-size", "bar-height"],
|
|
84
|
+
offset: ["focus-outline-offset"],
|
|
85
|
+
"font-weight": [
|
|
86
|
+
"font-weight-normal",
|
|
87
|
+
"font-weight-medium",
|
|
88
|
+
"font-weight-semibold",
|
|
89
|
+
"font-weight-bold",
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/* Every custom property declared by the core token files is exported or
|
|
94
|
+
listed here (tests/design-tokens.test.js), so a new token cannot silently
|
|
95
|
+
stay out of the design tools. */
|
|
96
|
+
export const NOT_EXPORTED = {
|
|
97
|
+
"font stack; a design tool font token holds one family": ["font-sans", "font-mono"],
|
|
98
|
+
"internal 0/1 weights selecting a control step; the steps are exported": [
|
|
99
|
+
"control-step-sm",
|
|
100
|
+
"control-step-md",
|
|
101
|
+
"control-step-lg",
|
|
102
|
+
],
|
|
103
|
+
"no design tool equivalent": [
|
|
104
|
+
"font-width",
|
|
105
|
+
"line-height",
|
|
106
|
+
"line-height-tight",
|
|
107
|
+
"line-height-relaxed",
|
|
108
|
+
"shadow",
|
|
109
|
+
"shadow-popout",
|
|
110
|
+
"duration",
|
|
111
|
+
"duration-fast",
|
|
112
|
+
"duration-slow",
|
|
113
|
+
"duration-spin",
|
|
114
|
+
"duration-shimmer",
|
|
115
|
+
"ease-enter",
|
|
116
|
+
"ease-exit",
|
|
117
|
+
"viewport-block",
|
|
118
|
+
"viewport-inline",
|
|
119
|
+
"z-sticky",
|
|
120
|
+
"z-menu",
|
|
121
|
+
"z-status",
|
|
122
|
+
"icon-chevron",
|
|
123
|
+
"icon-check",
|
|
124
|
+
"icon-close",
|
|
125
|
+
"icon-plus",
|
|
126
|
+
],
|
|
127
|
+
"input of a derived value": [
|
|
128
|
+
"backdrop-color",
|
|
129
|
+
"backdrop-opacity",
|
|
130
|
+
"soft-bg-mix",
|
|
131
|
+
"soft-border-mix",
|
|
132
|
+
"soft-hover-alpha",
|
|
133
|
+
"soft-fg-mix",
|
|
134
|
+
"focus-outline",
|
|
135
|
+
"focus-outline-color",
|
|
136
|
+
],
|
|
137
|
+
"soft recipe, exported with the components": [
|
|
138
|
+
"primary-soft-fg",
|
|
139
|
+
"secondary-soft-fg",
|
|
140
|
+
"success-soft-fg",
|
|
141
|
+
"warning-soft-fg",
|
|
142
|
+
"danger-soft-fg",
|
|
143
|
+
"neutral-soft-fg",
|
|
144
|
+
],
|
|
145
|
+
"internal or plumbing": [
|
|
146
|
+
"density-space",
|
|
147
|
+
"disabled-opacity",
|
|
148
|
+
"indicator-offset",
|
|
149
|
+
"choice-control-size",
|
|
150
|
+
"hgroup-gap",
|
|
151
|
+
"tone-fg",
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const WEIGHTS = FOUNDATIONS["font-weight"];
|
|
156
|
+
const LENGTHS = Object.entries(FOUNDATIONS).flatMap(([role, names]) =>
|
|
157
|
+
role === "font-weight" ? [] : names,
|
|
158
|
+
);
|
|
159
|
+
const ROLE_OF = new Map(
|
|
160
|
+
Object.entries(FOUNDATIONS).flatMap(([role, names]) => names.map((n) => [n, role])),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const DECLARATION_RE = /(?<![-\w])--([a-z0-9-]+)\s*:\s*([^;{}]*);/gi;
|
|
164
|
+
const ALIAS_RE = /^var\(\s*--([a-z0-9-]+)\s*\)$/i;
|
|
165
|
+
const THEME_NAME_RE = /\[data-theme="([^"]+)"\]/g;
|
|
166
|
+
|
|
167
|
+
export function declaredTokens(css) {
|
|
168
|
+
return new Set([...stripComments(css).matchAll(DECLARATION_RE)].map((m) => m[1]));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* Every `--a: var(--b)` declaration, per name. Only candidates: the source
|
|
172
|
+
cannot tell which one applies (a theme may alias a token the core declares
|
|
173
|
+
literally, a forced-colors block may replace an alias), so the resolved
|
|
174
|
+
values decide. */
|
|
175
|
+
export function aliasCandidates(...sources) {
|
|
176
|
+
const aliases = new Map();
|
|
177
|
+
for (const css of sources) {
|
|
178
|
+
for (const [, name, value] of stripComments(css).matchAll(DECLARATION_RE)) {
|
|
179
|
+
const target = value.trim().match(ALIAS_RE)?.[1];
|
|
180
|
+
if (target) aliases.set(name, [...(aliases.get(name) ?? []), target]);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return aliases;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* The one data-theme value a theme file declares, light/dark excluded. */
|
|
187
|
+
export function themeNameOf(css) {
|
|
188
|
+
const names = new Set(
|
|
189
|
+
[...css.matchAll(THEME_NAME_RE)].map((m) => m[1]).filter((n) => n !== "light" && n !== "dark"),
|
|
190
|
+
);
|
|
191
|
+
if (names.size !== 1) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Expected one [data-theme="…"] name in the theme, found ${names.size === 0 ? "none" : [...names].join(", ")}; pass the name explicitly.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return [...names][0];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function coreTokenCss() {
|
|
200
|
+
const files = CORE_TOKEN_FILES.map((path) => readFile(new URL(path, import.meta.url), "utf8"));
|
|
201
|
+
return (await Promise.all(files)).join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/*
|
|
205
|
+
* Runs in the page. Each island is one mode: `color-scheme` on the island is
|
|
206
|
+
* what light-dark() resolves against. Colors go through color-mix(in srgb) so
|
|
207
|
+
* every color syntax computes to the same `color(srgb r g b / a)` form.
|
|
208
|
+
*/
|
|
209
|
+
function readIslands({ css, islands, colors, lengths, weights }) {
|
|
210
|
+
const style = document.createElement("style");
|
|
211
|
+
style.textContent = css;
|
|
212
|
+
document.head.append(style);
|
|
213
|
+
|
|
214
|
+
const result = [];
|
|
215
|
+
for (const { attribute, scheme } of islands) {
|
|
216
|
+
const island = document.createElement("div");
|
|
217
|
+
if (attribute) island.dataset.theme = attribute;
|
|
218
|
+
if (scheme) island.style.colorScheme = scheme;
|
|
219
|
+
const probe = document.createElement("div");
|
|
220
|
+
probe.style.position = "absolute";
|
|
221
|
+
island.append(probe);
|
|
222
|
+
document.body.append(island);
|
|
223
|
+
|
|
224
|
+
const computed = getComputedStyle(probe);
|
|
225
|
+
const values = { colors: {}, lengths: {}, weights: {}, missing: [] };
|
|
226
|
+
const read = (name) => {
|
|
227
|
+
const raw = computed.getPropertyValue(`--${name}`).trim();
|
|
228
|
+
if (!raw) values.missing.push(name);
|
|
229
|
+
return raw;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
for (const name of colors) {
|
|
233
|
+
if (!read(name)) continue;
|
|
234
|
+
probe.style.color = `color-mix(in srgb, var(--${name}), var(--${name}))`;
|
|
235
|
+
values.colors[name] = computed.color;
|
|
236
|
+
}
|
|
237
|
+
for (const name of lengths) {
|
|
238
|
+
const raw = read(name);
|
|
239
|
+
if (!raw) continue;
|
|
240
|
+
// column-gap reports the computed length; width would report the used
|
|
241
|
+
// one, snapped to 1/64px by layout (0.65rem came out as 10.39).
|
|
242
|
+
probe.style.columnGap = `var(--${name})`;
|
|
243
|
+
values.lengths[name] = { px: Number.parseFloat(computed.columnGap), css: raw };
|
|
244
|
+
}
|
|
245
|
+
for (const name of weights) {
|
|
246
|
+
const raw = read(name);
|
|
247
|
+
if (raw) values.weights[name] = Number(raw);
|
|
248
|
+
}
|
|
249
|
+
values.scheme = attribute ? getComputedStyle(island).colorScheme : null;
|
|
250
|
+
result.push(values);
|
|
251
|
+
island.remove();
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Runs `fn(input)` in a blank headless page. `fn` is serialized, so it must be
|
|
257
|
+
self-contained: no closure over module scope. */
|
|
258
|
+
export async function inPage(fn, input) {
|
|
259
|
+
await using view = new Bun.WebView({ backend: "chrome" });
|
|
260
|
+
await view.navigate("about:blank");
|
|
261
|
+
// `return await`: a bare return lets `await using` close the view first.
|
|
262
|
+
return await view.evaluate(`(${fn.toString()})(${JSON.stringify(input)})`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const evaluateIslands = (input) => inPage(readIslands, input);
|
|
266
|
+
|
|
267
|
+
/*
|
|
268
|
+
* Resolves one theme. `css` is the full stylesheet (framework + theme, imports
|
|
269
|
+
* inlined). Without `theme`, the default theme is read through its light and
|
|
270
|
+
* dark boundaries. A named theme exports the schemes it declares: `light dark`
|
|
271
|
+
* gives two modes, anything else one. `islands` is returned so component
|
|
272
|
+
* measurements read the same boundaries.
|
|
273
|
+
*/
|
|
274
|
+
export async function resolveTokens({ css, theme, aliasSources = [] }) {
|
|
275
|
+
let islands = [
|
|
276
|
+
{ mode: "light", attribute: "light" },
|
|
277
|
+
{ mode: "dark", attribute: "dark" },
|
|
278
|
+
];
|
|
279
|
+
if (theme) {
|
|
280
|
+
const [declared] = await evaluateIslands({
|
|
281
|
+
css,
|
|
282
|
+
islands: [{ attribute: theme }],
|
|
283
|
+
colors: [],
|
|
284
|
+
lengths: [],
|
|
285
|
+
weights: [],
|
|
286
|
+
});
|
|
287
|
+
const schemes = declared.scheme.split(/\s+/).filter((s) => s === "light" || s === "dark");
|
|
288
|
+
islands = (schemes.length > 0 ? schemes : ["light"]).map((mode) => ({
|
|
289
|
+
mode,
|
|
290
|
+
attribute: theme,
|
|
291
|
+
scheme: mode,
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const read = await evaluateIslands({
|
|
296
|
+
css,
|
|
297
|
+
islands,
|
|
298
|
+
colors: THEME_COLORS,
|
|
299
|
+
lengths: LENGTHS,
|
|
300
|
+
weights: WEIGHTS,
|
|
301
|
+
});
|
|
302
|
+
const missing = [...new Set(read.flatMap((values) => values.missing))];
|
|
303
|
+
if (missing.length > 0) {
|
|
304
|
+
throw new Error(`Tokens without a value: ${missing.map((n) => `--${n}`).join(", ")}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const modes = Object.fromEntries(
|
|
308
|
+
islands.map(({ mode }, i) => [
|
|
309
|
+
mode,
|
|
310
|
+
Object.fromEntries(Object.entries(read[i].colors).map(([n, c]) => [n, parseSrgb(c)])),
|
|
311
|
+
]),
|
|
312
|
+
);
|
|
313
|
+
// Geometry does not follow the color scheme; the first mode is the reference.
|
|
314
|
+
const [{ lengths, weights }] = read;
|
|
315
|
+
return { islands, modes, lengths, weights, aliases: aliasCandidates(...aliasSources) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function parseSrgb(value) {
|
|
319
|
+
const match = value.match(/^color\(srgb ([^ ]+) ([^ ]+) ([^ )/]+)(?: \/ ([^ )]+))?\)$/);
|
|
320
|
+
if (!match) throw new Error(`Unexpected computed color: ${value}`);
|
|
321
|
+
const clamp = (n) => Math.min(1, Math.max(0, Number(n)));
|
|
322
|
+
return {
|
|
323
|
+
components: match.slice(1, 4).map((n) => round(clamp(n))),
|
|
324
|
+
alpha: match[4] === undefined ? 1 : round(clamp(match[4])),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const round = (n) => Math.round(n * 10000) / 10000;
|
|
329
|
+
|
|
330
|
+
export function hexOf({ components, alpha }) {
|
|
331
|
+
const byte = (n) =>
|
|
332
|
+
Math.round(n * 255)
|
|
333
|
+
.toString(16)
|
|
334
|
+
.padStart(2, "0");
|
|
335
|
+
return `#${components.map(byte).join("")}${alpha < 1 ? byte(alpha) : ""}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const colorsEqual = (a, b) =>
|
|
339
|
+
a.alpha === b.alpha && a.components.every((n, i) => n === b.components[i]);
|
|
340
|
+
|
|
341
|
+
/* A candidate becomes an alias only when its target is exported in the same
|
|
342
|
+
group and holds the same value in every mode; otherwise the theme overrode
|
|
343
|
+
the token literally. */
|
|
344
|
+
function aliasOf(name, valueSets, aliases, same) {
|
|
345
|
+
return aliases.get(name)?.find((t) => valueSets.every((v) => t in v && same(v[t], v[name])));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/*
|
|
349
|
+
* The target-neutral model both writers read: per mode, each color is
|
|
350
|
+
* `{ alias }` or `{ color, hex }`; each foundation carries its role and is
|
|
351
|
+
* `{ alias }`, `{ px, css }` or `{ weight }`.
|
|
352
|
+
*/
|
|
353
|
+
export function linkTokens({ modes, lengths, weights, aliases }) {
|
|
354
|
+
const modeSets = Object.values(modes);
|
|
355
|
+
const linkedModes = Object.fromEntries(
|
|
356
|
+
Object.entries(modes).map(([mode, colors]) => [
|
|
357
|
+
mode,
|
|
358
|
+
Object.fromEntries(
|
|
359
|
+
Object.entries(colors).map(([name, color]) => {
|
|
360
|
+
const alias = aliasOf(name, modeSets, aliases, colorsEqual);
|
|
361
|
+
return [name, alias ? { alias } : { color, hex: hexOf(color) }];
|
|
362
|
+
}),
|
|
363
|
+
),
|
|
364
|
+
]),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const foundations = {};
|
|
368
|
+
for (const [name, length] of Object.entries(lengths)) {
|
|
369
|
+
const alias = aliasOf(name, [lengths], aliases, (a, b) => a.px === b.px);
|
|
370
|
+
foundations[name] = { role: ROLE_OF.get(name), ...(alias ? { alias } : length) };
|
|
371
|
+
}
|
|
372
|
+
for (const [name, weight] of Object.entries(weights)) {
|
|
373
|
+
foundations[name] = { role: "font-weight", weight };
|
|
374
|
+
}
|
|
375
|
+
return { modes: linkedModes, foundations };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const EXTENSION = "actual-css";
|
|
379
|
+
|
|
380
|
+
/*
|
|
381
|
+
* Figma: DTCG documents keyed by file name, `foundations` plus one per color
|
|
382
|
+
* mode. Figma turns each imported file into a collection whose single mode is
|
|
383
|
+
* named after the file, hence `light` / `dark`. Recipe colors (see
|
|
384
|
+
* design-components.js) join the mode files so they switch with the mode.
|
|
385
|
+
*/
|
|
386
|
+
export function toFigma({ modes, foundations }, recipes = {}) {
|
|
387
|
+
const files = {};
|
|
388
|
+
for (const [mode, colors] of Object.entries(modes)) {
|
|
389
|
+
files[mode] = Object.fromEntries(
|
|
390
|
+
Object.entries({ ...colors, ...recipes[mode] }).map(([name, token]) => [
|
|
391
|
+
name,
|
|
392
|
+
{
|
|
393
|
+
$type: "color",
|
|
394
|
+
$value: token.alias
|
|
395
|
+
? `{${token.alias}}`
|
|
396
|
+
: { colorSpace: "srgb", ...token.color, hex: token.hex },
|
|
397
|
+
},
|
|
398
|
+
]),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
files.foundations = Object.fromEntries(
|
|
403
|
+
Object.entries(foundations).map(([name, token]) => {
|
|
404
|
+
// `number`, not DTCG `fontWeight`: Figma's import drops fontWeight
|
|
405
|
+
// tokens, and a number variable is what binds to a text layer's weight.
|
|
406
|
+
if (token.role === "font-weight") return [name, { $type: "number", $value: token.weight }];
|
|
407
|
+
const $value = token.alias ? `{${token.alias}}` : { value: token.px, unit: "px" };
|
|
408
|
+
const doc = { $type: "dimension", $value };
|
|
409
|
+
if (token.css) doc.$extensions = { [EXTENSION]: { css: token.css } };
|
|
410
|
+
return [name, doc];
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
return files;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/* Penpot token types per role: the type decides which property a token can
|
|
417
|
+
be applied to in the Penpot UI. */
|
|
418
|
+
const PENPOT_TYPES = {
|
|
419
|
+
spacing: "spacing",
|
|
420
|
+
radius: "borderRadius",
|
|
421
|
+
"border-width": "borderWidth",
|
|
422
|
+
"font-size": "fontSizes",
|
|
423
|
+
size: "sizing",
|
|
424
|
+
offset: "dimension",
|
|
425
|
+
"font-weight": "fontWeights",
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const PENPOT_THEME_GROUP = "Mode";
|
|
429
|
+
|
|
430
|
+
/*
|
|
431
|
+
* Penpot: one token file holding every set plus `$themes` and `$metadata`.
|
|
432
|
+
* Sets are `foundations` and `mode/<scheme>`, recipe colors included (their
|
|
433
|
+
* names keep them apart, and they always switch with the mode); each theme of
|
|
434
|
+
* the Mode group enables foundations and one scheme, so switching theme only
|
|
435
|
+
* swaps colors. Values are plain strings (hex, `16px`): Penpot does not read
|
|
436
|
+
* the DTCG object forms.
|
|
437
|
+
*/
|
|
438
|
+
export function toPenpot({ modes, foundations }, recipes = {}) {
|
|
439
|
+
const doc = {
|
|
440
|
+
foundations: Object.fromEntries(
|
|
441
|
+
Object.entries(foundations).map(([name, token]) => {
|
|
442
|
+
const $type = PENPOT_TYPES[token.role];
|
|
443
|
+
if (token.alias) return [name, { $type, $value: `{${token.alias}}` }];
|
|
444
|
+
return [name, { $type, $value: token.weight ?? `${token.px}px` }];
|
|
445
|
+
}),
|
|
446
|
+
),
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const colorSet = (colors) =>
|
|
450
|
+
Object.fromEntries(
|
|
451
|
+
Object.entries(colors).map(([name, token]) => [
|
|
452
|
+
name,
|
|
453
|
+
{ $type: "color", $value: token.alias ? `{${token.alias}}` : token.hex },
|
|
454
|
+
]),
|
|
455
|
+
);
|
|
456
|
+
const schemes = Object.keys(modes);
|
|
457
|
+
for (const mode of schemes) {
|
|
458
|
+
doc[`mode/${mode}`] = colorSet({ ...modes[mode], ...recipes[mode] });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const title = (mode) => mode[0].toUpperCase() + mode.slice(1);
|
|
462
|
+
doc.$themes = schemes.map((mode) => ({
|
|
463
|
+
id: `mode-${mode}`,
|
|
464
|
+
name: title(mode),
|
|
465
|
+
group: PENPOT_THEME_GROUP,
|
|
466
|
+
selectedTokenSets: { foundations: "enabled", [`mode/${mode}`]: "enabled" },
|
|
467
|
+
}));
|
|
468
|
+
doc.$metadata = {
|
|
469
|
+
tokenSetOrder: ["foundations", ...schemes.map((mode) => `mode/${mode}`)],
|
|
470
|
+
activeThemes: [`${PENPOT_THEME_GROUP}/${title(schemes[0])}`],
|
|
471
|
+
activeSets: ["foundations", `mode/${schemes[0]}`],
|
|
472
|
+
};
|
|
473
|
+
return doc;
|
|
474
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Actual CSS components",
|
|
3
|
+
"description": "Builds Actual CSS components from a measured spec and binds them to the imported tokens.",
|
|
4
|
+
"version": 2,
|
|
5
|
+
"code": "plugin.js",
|
|
6
|
+
"permissions": ["content:read", "content:write", "library:read", "library:write"]
|
|
7
|
+
}
|