actual-css 0.12.0 → 0.14.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 +102 -0
- package/README.md +7 -8
- package/components.json +124 -12
- package/dist/actual.full.css +1152 -309
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/design-notes/architecture.md +67 -0
- package/docs/design-notes/steps.md +45 -28
- package/docs/navigation.json +2 -0
- package/docs/pages/components/alert.md +12 -2
- package/docs/pages/components/avatar.md +17 -11
- package/docs/pages/components/breadcrumb.md +16 -2
- package/docs/pages/components/button.md +3 -2
- package/docs/pages/components/list.md +15 -16
- package/docs/pages/components/navbar.md +42 -22
- package/docs/pages/components/pagination.md +19 -0
- package/docs/pages/components/segmented.md +83 -0
- package/docs/pages/components/steps.md +28 -22
- package/docs/pages/enhancements/flyout.md +5 -0
- package/docs/pages/enhancements/scrollspy.md +6 -6
- package/docs/pages/enhancements/tabs.md +79 -3
- package/docs/pages/examples/overview.md +3 -1
- package/docs/pages/forms/color-swatches.md +131 -0
- package/docs/pages/forms/combobox.md +2 -2
- package/docs/pages/forms/controls.md +6 -3
- package/docs/pages/forms/floating-field.md +21 -7
- package/docs/pages/forms/form-actions.md +3 -6
- package/docs/pages/forms/joined-controls.md +6 -2
- package/docs/pages/forms/overview.md +1 -0
- package/docs/pages/forms/states-and-sizes.md +15 -15
- package/docs/pages/forms/switch.md +4 -2
- package/docs/pages/foundations/philosophy.md +97 -1
- package/docs/pages/foundations/theming.md +1 -1
- package/docs/pages/foundations/tokens.md +94 -27
- package/docs/pages/guides/design-tools.md +14 -8
- package/docs/pages/guides/integrating-actual-css.md +74 -8
- package/docs/pages/guides/introduction.md +3 -2
- package/docs/pages/guides/modular-import.md +1 -1
- package/docs/pages/guides/progressive-enhancement.md +25 -7
- package/docs/pages/guides/upgrading.md +86 -9
- package/docs/pages/layout/center.md +2 -1
- package/docs/pages/layout/cluster.md +11 -4
- package/docs/pages/layout/grid.md +2 -0
- package/docs/pages/layout/scroll-snap.md +4 -2
- package/docs/pages/layout/sidebar-layout.md +3 -0
- package/docs/pages/layout/switcher.md +2 -0
- package/docs/pages/layout/topbar.md +6 -4
- package/docs/pages/patterns/icon-slot.md +17 -1
- package/docs/pages/utilities/flex.md +1 -1
- package/docs/pages/utilities/sizing-wrapping.md +51 -6
- package/package.json +6 -6
- package/reserved-classes.json +3 -0
- package/src/cli/actual-css.js +4 -4
- package/src/css/components/alert.css +99 -48
- package/src/css/components/app-nav.css +17 -9
- package/src/css/components/avatar.css +2 -18
- package/src/css/components/badge.css +23 -10
- package/src/css/components/breadcrumb.css +74 -8
- package/src/css/components/button.css +17 -2
- package/src/css/components/close.css +6 -3
- package/src/css/components/drawer.css +23 -42
- package/src/css/components/flyout.css +0 -1
- package/src/css/components/index.css +1 -0
- package/src/css/components/join.css +2 -2
- package/src/css/components/list.css +28 -13
- package/src/css/components/modal.css +17 -10
- package/src/css/components/navbar.css +15 -10
- package/src/css/components/pagination.css +5 -0
- package/src/css/components/scrollspy.css +1 -1
- package/src/css/components/segmented.css +186 -0
- package/src/css/components/steps.css +72 -15
- package/src/css/components/tab.css +135 -5
- package/src/css/core/focus.css +11 -6
- package/src/css/core/theme.css +6 -1
- package/src/css/core/tokens.css +24 -6
- package/src/css/core/variants.css +16 -5
- package/src/css/forms/choice-card.css +1 -1
- package/src/css/forms/choice.css +1 -1
- package/src/css/forms/color-swatch.css +159 -0
- package/src/css/forms/control.css +1 -1
- package/src/css/forms/floating-field.css +20 -18
- package/src/css/forms/form-actions.css +14 -8
- package/src/css/forms/index.css +1 -0
- package/src/css/forms/native.css +51 -21
- package/src/css/forms/otp.css +4 -5
- package/src/css/forms/switch.css +6 -3
- package/src/css/forms/validation.css +23 -30
- package/src/css/layout/center.css +2 -0
- package/src/css/layout/cluster.css +14 -0
- package/src/css/layout/grid.css +7 -0
- package/src/css/layout/scroll-snap.css +30 -0
- package/src/css/layout/scroller.css +19 -3
- package/src/css/layout/sidebar-layout.css +3 -0
- package/src/css/layout/switcher.css +3 -0
- package/src/css/layout/topbar.css +10 -0
- package/src/css/utilities/extra.css +3 -3
- package/src/js/focus.js +16 -1
- package/src/js/full.js +4 -0
- package/src/js/inline-visible.js +54 -0
- package/src/js/parsed.js +21 -0
- package/src/js/surface.js +10 -0
- package/src/js/tab.js +33 -7
- package/src/tooling/color.js +14 -1
- package/src/tooling/css-bundle.js +0 -5
- package/src/tooling/design-components.js +9 -5
- package/src/tooling/design-export.js +9 -1
- package/src/tooling/design-tokens.js +207 -76
- package/src/tooling/theme-contrast.js +182 -45
package/src/js/tab.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* ArrowUp/Down for aria-orientation="vertical"
|
|
10
10
|
* ArrowDown (focus selected panel)
|
|
11
11
|
* [hidden], disabled and panel-less tabs are skipped
|
|
12
|
+
* Overflow: a horizontal strip shows its whole selected tab, at connect and
|
|
13
|
+
* on every selection
|
|
12
14
|
*
|
|
13
15
|
* Self-registers via registerEnhancement: injected tablists wire automatically.
|
|
14
16
|
* The tab→panel map is rebuilt from the live tablist on each interaction,
|
|
@@ -17,9 +19,13 @@
|
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { registerEnhancement } from "./enhance.js";
|
|
22
|
+
import { ensureInlineVisible } from "./inline-visible.js";
|
|
20
23
|
import { itemForKey, shouldIgnoreKey } from "./keys.js";
|
|
24
|
+
import { afterParse } from "./parsed.js";
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
// Ownership follows the ARIA structure, not the activation token: a tab
|
|
27
|
+
// belongs to its closest role="tablist", enhanced or not.
|
|
28
|
+
const TABLIST_SELECTOR = '[role="tablist"]';
|
|
23
29
|
|
|
24
30
|
// A tablist owns only the tabs directly beneath it, not those of a nested
|
|
25
31
|
// tablist. Filtering by closest owner keeps the outer list from operating on
|
|
@@ -85,10 +91,19 @@ function activate(tab) {
|
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
|
|
88
|
-
|
|
94
|
+
/* An overflowing horizontal strip shows the whole tab. At connect a
|
|
95
|
+
server-selected tab may render off-screen; on arrow keys and clicks the
|
|
96
|
+
native focus scroll is not enough: Chrome skips a tab already partly inside
|
|
97
|
+
the scrollport, so one showing a sliver at the edge stayed clipped. */
|
|
98
|
+
function reveal(list, tab) {
|
|
99
|
+
if (list.getAttribute("aria-orientation") !== "vertical") ensureInlineVisible(list, tab);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function activateAndFocus(list, tab) {
|
|
89
103
|
if (!tab) return;
|
|
90
104
|
activate(tab);
|
|
91
105
|
tab.focus();
|
|
106
|
+
reveal(list, tab);
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
function initialize(list) {
|
|
@@ -96,6 +111,7 @@ function initialize(list) {
|
|
|
96
111
|
if (!tabs.length) return;
|
|
97
112
|
const selected = tabs.find((tab) => tab.getAttribute("aria-selected") === "true") || tabs[0];
|
|
98
113
|
activate(selected);
|
|
114
|
+
reveal(list, selected);
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
function onKeydown(e) {
|
|
@@ -133,7 +149,7 @@ function onKeydown(e) {
|
|
|
133
149
|
if (!target) return;
|
|
134
150
|
|
|
135
151
|
e.preventDefault();
|
|
136
|
-
activateAndFocus(target);
|
|
152
|
+
activateAndFocus(list, target);
|
|
137
153
|
}
|
|
138
154
|
|
|
139
155
|
function onClick(e) {
|
|
@@ -146,14 +162,24 @@ function onClick(e) {
|
|
|
146
162
|
}
|
|
147
163
|
e.preventDefault();
|
|
148
164
|
if (tab.getAttribute("aria-selected") === "true") return;
|
|
149
|
-
|
|
150
|
-
tab.focus();
|
|
165
|
+
activateAndFocus(list, tab);
|
|
151
166
|
}
|
|
152
167
|
|
|
153
168
|
registerEnhancement("tabs", (list) => {
|
|
169
|
+
// Tabs find their list through role="tablist"; without it nothing would
|
|
170
|
+
// respond, so say why instead of wiring a dead strip.
|
|
171
|
+
if (list.getAttribute("role") !== "tablist") {
|
|
172
|
+
console.warn('data-enhance="tabs" needs role="tablist" on the same element', list);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
154
175
|
const controller = new AbortController();
|
|
155
|
-
|
|
176
|
+
// Once parsed: a tablist connected mid-parse had no tabs yet, so the roving
|
|
177
|
+
// tabindex and the selected tab's placement were skipped for good.
|
|
178
|
+
const cancelInit = afterParse(list, () => initialize(list));
|
|
156
179
|
list.addEventListener("click", onClick, { signal: controller.signal });
|
|
157
180
|
list.addEventListener("keydown", onKeydown, { signal: controller.signal });
|
|
158
|
-
return () =>
|
|
181
|
+
return () => {
|
|
182
|
+
cancelInit();
|
|
183
|
+
controller.abort();
|
|
184
|
+
};
|
|
159
185
|
});
|
package/src/tooling/color.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* `const rgba = ${RASTERIZE};`. getComputedStyle returns color-mix() and
|
|
11
11
|
* light-dark() results as oklab() or color(), so a 1x1 canvas turns any
|
|
12
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
|
|
13
|
+
* translucent color instead of measuring it as if it were opaque; composite()
|
|
14
|
+
* resolves it over its backdrop first.
|
|
14
15
|
*/
|
|
15
16
|
export const RASTERIZE = `(() => {
|
|
16
17
|
const canvas = Object.assign(document.createElement("canvas"), { width: 1, height: 1 });
|
|
@@ -46,6 +47,18 @@ export function contrast(a, b) {
|
|
|
46
47
|
return (hi + 0.05) / (lo + 0.05);
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/* `top` painted over `base`, the way the page composites a fill: source-over
|
|
51
|
+
on gamma-encoded sRGB bytes. The result is opaque when `base` is, so a
|
|
52
|
+
translucent soft fill becomes measurable once its backdrop is known. */
|
|
53
|
+
export function composite(top, base) {
|
|
54
|
+
const a = (top[3] ?? 255) / 255;
|
|
55
|
+
const b = (base[3] ?? 255) / 255;
|
|
56
|
+
const alpha = a + b * (1 - a);
|
|
57
|
+
if (alpha === 0) return [0, 0, 0, 0];
|
|
58
|
+
const channel = (i) => Math.round((top[i] * a + base[i] * b * (1 - a)) / alpha);
|
|
59
|
+
return [channel(0), channel(1), channel(2), Math.round(alpha * 255)];
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
/* OKLCH of an sRGB color: L 0-1, C chroma, h degrees. */
|
|
50
63
|
export function oklch([r, g, b]) {
|
|
51
64
|
const [lr, lg, lb] = [r, g, b].map(toLinear);
|
|
@@ -298,11 +298,6 @@ export function bundledCssIssues(css) {
|
|
|
298
298
|
const issues = [];
|
|
299
299
|
|
|
300
300
|
if (css.includes("@import")) issues.push("contains unresolved @import");
|
|
301
|
-
if (css.includes("--lightningcss")) issues.push("contains --lightningcss-* variables");
|
|
302
|
-
if (/color-mix\(in srgb/.test(css)) issues.push("contains color-mix(in srgb, ...)");
|
|
303
|
-
if (/@media[^{]*\(\s*width\s*[<>]=/.test(css)) {
|
|
304
|
-
issues.push("contains transpiled media query range syntax");
|
|
305
|
-
}
|
|
306
301
|
|
|
307
302
|
return issues;
|
|
308
303
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* padding, height, type — about 1,800 bindings over 221 variants) froze
|
|
16
16
|
* Penpot, and a theme's own lengths already arrive through the measurement.
|
|
17
17
|
*/
|
|
18
|
-
import { hexOf, inPage, parseSrgb } from "./design-tokens.js";
|
|
18
|
+
import { hexOf, hexRgbOf, inPage, parseSrgb } from "./design-tokens.js";
|
|
19
19
|
|
|
20
20
|
/* Every list starts with its default: the first combination is the one a
|
|
21
21
|
design tool picks for a new instance. */
|
|
@@ -214,11 +214,15 @@ export async function measureComponents({ css, resolved, linked, components = CO
|
|
|
214
214
|
modes.forEach((mode, m) => {
|
|
215
215
|
const known = recipes[mode][token];
|
|
216
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
|
-
|
|
219
|
-
|
|
217
|
+
// ever paint different colors, the name has to grow a dimension. The
|
|
218
|
+
// comparison is on the CSS form (alpha included), the stored hex is the
|
|
219
|
+
// 6-digit DTCG form.
|
|
220
|
+
if (known && hexOf(known.color) !== hexes[m]) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Recipe ${token} paints ${hexOf(known.color)} and ${hexes[m]} in ${mode}`,
|
|
223
|
+
);
|
|
220
224
|
}
|
|
221
|
-
recipes[mode][token] = { color: colors[m], hex:
|
|
225
|
+
recipes[mode][token] = { color: colors[m], hex: hexRgbOf(colors[m]) };
|
|
222
226
|
});
|
|
223
227
|
}
|
|
224
228
|
const [first] = colors;
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
linkTokens,
|
|
15
15
|
resolveTokens,
|
|
16
16
|
themeNameOf,
|
|
17
|
+
toDtcg,
|
|
17
18
|
toFigma,
|
|
18
19
|
toPenpot,
|
|
19
20
|
} from "./design-tokens.js";
|
|
@@ -24,6 +25,7 @@ const PLUGIN = fileURLToPath(new URL("./penpot-plugin/", import.meta.url));
|
|
|
24
25
|
/*
|
|
25
26
|
* Writes, under `out`:
|
|
26
27
|
* figma/foundations.tokens.json, figma/<scheme>.tokens.json
|
|
28
|
+
* dtcg/foundations.tokens.json, dtcg/<scheme>.tokens.json
|
|
27
29
|
* penpot/tokens.json
|
|
28
30
|
* penpot/plugin/ (manifest.json, plugin.js, ui.html, spec.json)
|
|
29
31
|
* Without `themeFile`, exports the default theme. `name` overrides the
|
|
@@ -31,7 +33,7 @@ const PLUGIN = fileURLToPath(new URL("./penpot-plugin/", import.meta.url));
|
|
|
31
33
|
*/
|
|
32
34
|
export async function exportDesign({ themeFile, name, out }) {
|
|
33
35
|
const themeCss = themeFile ? await inlineImports(themeFile) : "";
|
|
34
|
-
const theme = themeFile ?
|
|
36
|
+
const theme = themeFile ? themeNameOf(themeCss, name) : null;
|
|
35
37
|
const css = `${await inlineImports(FRAMEWORK)}\n${themeCss}`;
|
|
36
38
|
|
|
37
39
|
const resolved = await resolveTokens({
|
|
@@ -49,6 +51,12 @@ export async function exportDesign({ themeFile, name, out }) {
|
|
|
49
51
|
doc,
|
|
50
52
|
]),
|
|
51
53
|
),
|
|
54
|
+
...Object.fromEntries(
|
|
55
|
+
Object.entries(toDtcg(linked, recipes)).map(([file, doc]) => [
|
|
56
|
+
`dtcg/${file}.tokens.json`,
|
|
57
|
+
doc,
|
|
58
|
+
]),
|
|
59
|
+
),
|
|
52
60
|
"penpot/tokens.json": toPenpot(linked, recipes),
|
|
53
61
|
"penpot/plugin/spec.json": spec,
|
|
54
62
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Design-tool token export: resolves a theme's tokens in headless Chrome
|
|
3
|
-
* writes them for Figma
|
|
4
|
-
*
|
|
2
|
+
* Design-tool token export: resolves a theme's tokens in headless Chrome into
|
|
3
|
+
* a target-neutral model, then writes them for Figma, Penpot, and a portable
|
|
4
|
+
* DTCG profile. Figma and Penpot get the compromises their importers need; the
|
|
5
|
+
* DTCG profile stays spec-conformant (correct $types, 6-digit `hex`).
|
|
5
6
|
*
|
|
6
7
|
* Values are read from the browser, never from the CSS text: tokens are built
|
|
7
8
|
* with light-dark(), color-mix() and var() chains that only the browser
|
|
@@ -42,6 +43,7 @@ export const THEME_COLORS = [
|
|
|
42
43
|
"text-subtle",
|
|
43
44
|
"heading",
|
|
44
45
|
"border",
|
|
46
|
+
"border-control",
|
|
45
47
|
"focus",
|
|
46
48
|
"hover-overlay",
|
|
47
49
|
"hover-overlay-solid",
|
|
@@ -55,8 +57,9 @@ export const THEME_COLORS = [
|
|
|
55
57
|
"backdrop-fill",
|
|
56
58
|
];
|
|
57
59
|
|
|
58
|
-
/* Mode-independent tokens by role. The role
|
|
59
|
-
|
|
60
|
+
/* Mode-independent tokens by role. The role drives the DTCG type (see
|
|
61
|
+
DTCG_TYPE) and the Penpot type; length roles plus font-weight are the ones
|
|
62
|
+
Figma and Penpot read, the rest are DTCG-only. */
|
|
60
63
|
export const FOUNDATIONS = {
|
|
61
64
|
spacing: [
|
|
62
65
|
"space-10",
|
|
@@ -88,32 +91,23 @@ export const FOUNDATIONS = {
|
|
|
88
91
|
"font-weight-semibold",
|
|
89
92
|
"font-weight-bold",
|
|
90
93
|
],
|
|
94
|
+
"font-family": ["font-sans", "font-mono"],
|
|
95
|
+
duration: ["duration", "duration-fast", "duration-slow", "duration-spin", "duration-shimmer"],
|
|
96
|
+
"cubic-bezier": ["ease-enter", "ease-exit"],
|
|
97
|
+
number: ["line-height", "line-height-tight", "line-height-relaxed"],
|
|
91
98
|
};
|
|
92
99
|
|
|
93
100
|
/* Every custom property declared by the core token files is exported or
|
|
94
101
|
listed here (tests/design-tokens.test.js), so a new token cannot silently
|
|
95
102
|
stay out of the design tools. */
|
|
96
103
|
export const NOT_EXPORTED = {
|
|
97
|
-
"font stack; a design tool font token holds one family": ["font-sans", "font-mono"],
|
|
98
104
|
"internal 0/1 weights selecting a control step; the steps are exported": [
|
|
99
105
|
"control-step-sm",
|
|
100
106
|
"control-step-md",
|
|
101
107
|
"control-step-lg",
|
|
102
108
|
],
|
|
103
|
-
"no
|
|
109
|
+
"no DTCG type that keeps its meaning": [
|
|
104
110
|
"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
111
|
"viewport-block",
|
|
118
112
|
"viewport-inline",
|
|
119
113
|
"z-sticky",
|
|
@@ -124,6 +118,7 @@ export const NOT_EXPORTED = {
|
|
|
124
118
|
"icon-close",
|
|
125
119
|
"icon-plus",
|
|
126
120
|
],
|
|
121
|
+
"composite DTCG types the export does not build yet": ["shadow", "shadow-popout"],
|
|
127
122
|
"input of a derived value": [
|
|
128
123
|
"backdrop-color",
|
|
129
124
|
"backdrop-opacity",
|
|
@@ -131,7 +126,6 @@ export const NOT_EXPORTED = {
|
|
|
131
126
|
"soft-border-mix",
|
|
132
127
|
"soft-hover-alpha",
|
|
133
128
|
"soft-fg-mix",
|
|
134
|
-
"focus-outline",
|
|
135
129
|
"focus-outline-color",
|
|
136
130
|
],
|
|
137
131
|
"soft recipe, exported with the components": [
|
|
@@ -142,6 +136,7 @@ export const NOT_EXPORTED = {
|
|
|
142
136
|
"danger-soft-fg",
|
|
143
137
|
"neutral-soft-fg",
|
|
144
138
|
],
|
|
139
|
+
"fallback-only state hook, read at the point of use": ["state-selected-text"],
|
|
145
140
|
"internal or plumbing": [
|
|
146
141
|
"density-space",
|
|
147
142
|
"disabled-opacity",
|
|
@@ -152,17 +147,22 @@ export const NOT_EXPORTED = {
|
|
|
152
147
|
],
|
|
153
148
|
};
|
|
154
149
|
|
|
150
|
+
// Length roles carry a px value plus the CSS source; every other role carries
|
|
151
|
+
// one scalar read as-is and parsed by role (see PARSE).
|
|
152
|
+
const LENGTH_ROLES = ["spacing", "radius", "border-width", "font-size", "size", "offset"];
|
|
153
|
+
const RAW_ROLES = ["font-family", "duration", "cubic-bezier", "number"];
|
|
154
|
+
const LENGTHS = LENGTH_ROLES.flatMap((role) => FOUNDATIONS[role]);
|
|
155
155
|
const WEIGHTS = FOUNDATIONS["font-weight"];
|
|
156
|
-
const
|
|
157
|
-
role === "font-weight" ? [] : names,
|
|
158
|
-
);
|
|
156
|
+
const RAWS = RAW_ROLES.flatMap((role) => FOUNDATIONS[role]);
|
|
159
157
|
const ROLE_OF = new Map(
|
|
160
158
|
Object.entries(FOUNDATIONS).flatMap(([role, names]) => names.map((n) => [n, role])),
|
|
161
159
|
);
|
|
162
160
|
|
|
163
161
|
const DECLARATION_RE = /(?<![-\w])--([a-z0-9-]+)\s*:\s*([^;{}]*);/gi;
|
|
164
162
|
const ALIAS_RE = /^var\(\s*--([a-z0-9-]+)\s*\)$/i;
|
|
165
|
-
|
|
163
|
+
// Every valid spelling — either quote, a bare ident, whitespace inside the
|
|
164
|
+
// brackets, an i/s flag: a missed name falls back to the :root theme silently.
|
|
165
|
+
const THEME_NAME_RE = /\[\s*data-theme\s*=\s*(?:(["'])([^"']+)\1|([-\w]+))\s*(?:[is]\s*)?\]/gi;
|
|
166
166
|
|
|
167
167
|
export function declaredTokens(css) {
|
|
168
168
|
return new Set([...stripComments(css).matchAll(DECLARATION_RE)].map((m) => m[1]));
|
|
@@ -183,17 +183,29 @@ export function aliasCandidates(...sources) {
|
|
|
183
183
|
return aliases;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
/* The one data-theme value a theme file declares, light/dark excluded.
|
|
187
|
-
|
|
186
|
+
/* The one data-theme value a theme file declares, light/dark excluded. null
|
|
187
|
+
when it declares none: the theme is written on :root, the application's
|
|
188
|
+
own sheet rather than an island. An explicit name must be one the file
|
|
189
|
+
declares: an island no rule matches would measure the framework defaults
|
|
190
|
+
and report them as the theme (a typo passed silently). */
|
|
191
|
+
export function themeNameOf(css, explicit) {
|
|
188
192
|
const names = new Set(
|
|
189
|
-
|
|
193
|
+
// Comments stripped: a selector quoted in a doc comment is not a theme.
|
|
194
|
+
[...stripComments(css).matchAll(THEME_NAME_RE)]
|
|
195
|
+
.map((m) => m[2] ?? m[3])
|
|
196
|
+
.filter((n) => n !== "light" && n !== "dark"),
|
|
190
197
|
);
|
|
191
|
-
if (
|
|
198
|
+
if (explicit != null) {
|
|
199
|
+
if (names.has(explicit)) return explicit;
|
|
200
|
+
const found = names.size ? `it declares ${[...names].join(", ")}` : "it declares none";
|
|
201
|
+
throw new Error(`The theme declares no [data-theme="${explicit}"]; ${found}.`);
|
|
202
|
+
}
|
|
203
|
+
if (names.size > 1) {
|
|
192
204
|
throw new Error(
|
|
193
|
-
`Expected one [data-theme="…"] name in the theme, found ${
|
|
205
|
+
`Expected one [data-theme="…"] name in the theme, found ${[...names].join(", ")}; pass the name explicitly.`,
|
|
194
206
|
);
|
|
195
207
|
}
|
|
196
|
-
return [...names][0];
|
|
208
|
+
return names.size === 1 ? [...names][0] : null;
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
export async function coreTokenCss() {
|
|
@@ -206,7 +218,7 @@ export async function coreTokenCss() {
|
|
|
206
218
|
* what light-dark() resolves against. Colors go through color-mix(in srgb) so
|
|
207
219
|
* every color syntax computes to the same `color(srgb r g b / a)` form.
|
|
208
220
|
*/
|
|
209
|
-
function readIslands({ css, islands, colors, lengths, weights }) {
|
|
221
|
+
function readIslands({ css, islands, colors, lengths, weights, raws }) {
|
|
210
222
|
const style = document.createElement("style");
|
|
211
223
|
style.textContent = css;
|
|
212
224
|
document.head.append(style);
|
|
@@ -222,7 +234,7 @@ function readIslands({ css, islands, colors, lengths, weights }) {
|
|
|
222
234
|
document.body.append(island);
|
|
223
235
|
|
|
224
236
|
const computed = getComputedStyle(probe);
|
|
225
|
-
const values = { colors: {}, lengths: {}, weights: {}, missing: [] };
|
|
237
|
+
const values = { colors: {}, lengths: {}, weights: {}, raws: {}, missing: [] };
|
|
226
238
|
const read = (name) => {
|
|
227
239
|
const raw = computed.getPropertyValue(`--${name}`).trim();
|
|
228
240
|
if (!raw) values.missing.push(name);
|
|
@@ -246,7 +258,11 @@ function readIslands({ css, islands, colors, lengths, weights }) {
|
|
|
246
258
|
const raw = read(name);
|
|
247
259
|
if (raw) values.weights[name] = Number(raw);
|
|
248
260
|
}
|
|
249
|
-
|
|
261
|
+
for (const name of raws) {
|
|
262
|
+
const raw = read(name);
|
|
263
|
+
if (raw) values.raws[name] = raw;
|
|
264
|
+
}
|
|
265
|
+
values.scheme = getComputedStyle(island).colorScheme;
|
|
250
266
|
result.push(values);
|
|
251
267
|
island.remove();
|
|
252
268
|
}
|
|
@@ -266,31 +282,29 @@ const evaluateIslands = (input) => inPage(readIslands, input);
|
|
|
266
282
|
|
|
267
283
|
/*
|
|
268
284
|
* Resolves one theme. `css` is the full stylesheet (framework + theme, imports
|
|
269
|
-
* inlined). Without `theme`, the
|
|
270
|
-
*
|
|
271
|
-
* gives two modes, anything else one. `islands` is
|
|
272
|
-
* measurements read the same boundaries.
|
|
285
|
+
* inlined). Without `theme`, the theme is the root's — the default one, or an
|
|
286
|
+
* application theme written on :root. The theme exports the schemes it
|
|
287
|
+
* declares: `light dark` gives two modes, anything else one. `islands` is
|
|
288
|
+
* returned so component measurements read the same boundaries.
|
|
273
289
|
*/
|
|
274
|
-
export async function resolveTokens({ css, theme, aliasSources = [] }) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
]
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}));
|
|
293
|
-
}
|
|
290
|
+
export async function resolveTokens({ css, theme = null, aliasSources = [] }) {
|
|
291
|
+
// A root island carries no data-theme: the core redeclares the default
|
|
292
|
+
// palette on [data-theme="light"|"dark"], which would mask a :root theme.
|
|
293
|
+
// Pinning color-scheme alone is what picks the light-dark() side.
|
|
294
|
+
const [declared] = await evaluateIslands({
|
|
295
|
+
css,
|
|
296
|
+
islands: [{ attribute: theme }],
|
|
297
|
+
colors: [],
|
|
298
|
+
lengths: [],
|
|
299
|
+
weights: [],
|
|
300
|
+
raws: [],
|
|
301
|
+
});
|
|
302
|
+
const schemes = declared.scheme.split(/\s+/).filter((s) => s === "light" || s === "dark");
|
|
303
|
+
const islands = (schemes.length > 0 ? schemes : ["light"]).map((mode) => ({
|
|
304
|
+
mode,
|
|
305
|
+
attribute: theme,
|
|
306
|
+
scheme: mode,
|
|
307
|
+
}));
|
|
294
308
|
|
|
295
309
|
const read = await evaluateIslands({
|
|
296
310
|
css,
|
|
@@ -298,6 +312,7 @@ export async function resolveTokens({ css, theme, aliasSources = [] }) {
|
|
|
298
312
|
colors: THEME_COLORS,
|
|
299
313
|
lengths: LENGTHS,
|
|
300
314
|
weights: WEIGHTS,
|
|
315
|
+
raws: RAWS,
|
|
301
316
|
});
|
|
302
317
|
const missing = [...new Set(read.flatMap((values) => values.missing))];
|
|
303
318
|
if (missing.length > 0) {
|
|
@@ -310,9 +325,13 @@ export async function resolveTokens({ css, theme, aliasSources = [] }) {
|
|
|
310
325
|
Object.fromEntries(Object.entries(read[i].colors).map(([n, c]) => [n, parseSrgb(c)])),
|
|
311
326
|
]),
|
|
312
327
|
);
|
|
313
|
-
// Geometry
|
|
314
|
-
|
|
315
|
-
|
|
328
|
+
// Geometry and the raw foundations do not follow the color scheme; the first
|
|
329
|
+
// mode is the reference.
|
|
330
|
+
const [{ lengths, weights, raws }] = read;
|
|
331
|
+
const extras = Object.fromEntries(
|
|
332
|
+
Object.entries(raws).map(([name, raw]) => [name, PARSE[ROLE_OF.get(name)](raw)]),
|
|
333
|
+
);
|
|
334
|
+
return { islands, modes, lengths, weights, extras, aliases: aliasCandidates(...aliasSources) };
|
|
316
335
|
}
|
|
317
336
|
|
|
318
337
|
export function parseSrgb(value) {
|
|
@@ -325,14 +344,60 @@ export function parseSrgb(value) {
|
|
|
325
344
|
};
|
|
326
345
|
}
|
|
327
346
|
|
|
347
|
+
/* Raw foundation readers. Each role maps to one parser, so a value the browser
|
|
348
|
+
reports in an unexpected shape fails loudly instead of exporting garbage. */
|
|
349
|
+
export function parseDuration(value) {
|
|
350
|
+
const match = value.match(/^([\d.]+)(ms|s)$/);
|
|
351
|
+
if (!match) throw new Error(`Unexpected duration: ${value}`);
|
|
352
|
+
return { value: Number(match[1]), unit: match[2] };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function parseCubicBezier(value) {
|
|
356
|
+
const match = value.match(/^cubic-bezier\(([^)]+)\)$/);
|
|
357
|
+
const numbers = match?.[1].split(",").map((n) => Number(n.trim()));
|
|
358
|
+
if (numbers?.length !== 4 || numbers.some(Number.isNaN)) {
|
|
359
|
+
throw new Error(`Unexpected cubic-bezier: ${value}`);
|
|
360
|
+
}
|
|
361
|
+
return numbers;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function parseFontFamily(value) {
|
|
365
|
+
return value
|
|
366
|
+
.split(",")
|
|
367
|
+
.map((family) => family.trim().replace(/^["']|["']$/g, ""))
|
|
368
|
+
.filter(Boolean);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function parseNumber(value) {
|
|
372
|
+
const n = Number(value);
|
|
373
|
+
if (Number.isNaN(n)) throw new Error(`Unexpected number: ${value}`);
|
|
374
|
+
return n;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const PARSE = {
|
|
378
|
+
"font-family": parseFontFamily,
|
|
379
|
+
duration: parseDuration,
|
|
380
|
+
"cubic-bezier": parseCubicBezier,
|
|
381
|
+
number: parseNumber,
|
|
382
|
+
};
|
|
383
|
+
|
|
328
384
|
const round = (n) => Math.round(n * 10000) / 10000;
|
|
329
385
|
|
|
386
|
+
const byte = (n) =>
|
|
387
|
+
Math.round(n * 255)
|
|
388
|
+
.toString(16)
|
|
389
|
+
.padStart(2, "0");
|
|
390
|
+
|
|
391
|
+
/* DTCG color `hex` is a 6-digit fallback: alpha is carried separately, and the
|
|
392
|
+
spec requires the hex itself to be 6 digits. */
|
|
393
|
+
export function hexRgbOf({ components }) {
|
|
394
|
+
return `#${components.map(byte).join("")}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/* The CSS-fidelity form: keeps alpha in the hex for Penpot and for comparing
|
|
398
|
+
colors across modes (two colors that share RGB but not alpha must differ). */
|
|
330
399
|
export function hexOf({ components, alpha }) {
|
|
331
|
-
|
|
332
|
-
Math.round(n * 255)
|
|
333
|
-
.toString(16)
|
|
334
|
-
.padStart(2, "0");
|
|
335
|
-
return `#${components.map(byte).join("")}${alpha < 1 ? byte(alpha) : ""}`;
|
|
400
|
+
return `${hexRgbOf({ components })}${alpha < 1 ? byte(alpha) : ""}`;
|
|
336
401
|
}
|
|
337
402
|
|
|
338
403
|
const colorsEqual = (a, b) =>
|
|
@@ -346,11 +411,12 @@ function aliasOf(name, valueSets, aliases, same) {
|
|
|
346
411
|
}
|
|
347
412
|
|
|
348
413
|
/*
|
|
349
|
-
* The target-neutral model
|
|
350
|
-
* `{ alias }` or `{ color, hex }
|
|
351
|
-
* `{ alias }`, `{ px, css }
|
|
414
|
+
* The target-neutral model the writers read: per mode, each color is
|
|
415
|
+
* `{ alias }` or `{ color, hex }` (hex 6-digit, the DTCG form); each
|
|
416
|
+
* foundation carries its role and is `{ alias }`, `{ px, css }`, `{ weight }`
|
|
417
|
+
* or `{ value }` for the raw roles.
|
|
352
418
|
*/
|
|
353
|
-
export function linkTokens({ modes, lengths, weights, aliases }) {
|
|
419
|
+
export function linkTokens({ modes, lengths, weights, extras = {}, aliases }) {
|
|
354
420
|
const modeSets = Object.values(modes);
|
|
355
421
|
const linkedModes = Object.fromEntries(
|
|
356
422
|
Object.entries(modes).map(([mode, colors]) => [
|
|
@@ -358,7 +424,7 @@ export function linkTokens({ modes, lengths, weights, aliases }) {
|
|
|
358
424
|
Object.fromEntries(
|
|
359
425
|
Object.entries(colors).map(([name, color]) => {
|
|
360
426
|
const alias = aliasOf(name, modeSets, aliases, colorsEqual);
|
|
361
|
-
return [name, alias ? { alias } : { color, hex:
|
|
427
|
+
return [name, alias ? { alias } : { color, hex: hexRgbOf(color) }];
|
|
362
428
|
}),
|
|
363
429
|
),
|
|
364
430
|
]),
|
|
@@ -372,6 +438,9 @@ export function linkTokens({ modes, lengths, weights, aliases }) {
|
|
|
372
438
|
for (const [name, weight] of Object.entries(weights)) {
|
|
373
439
|
foundations[name] = { role: "font-weight", weight };
|
|
374
440
|
}
|
|
441
|
+
for (const [name, value] of Object.entries(extras)) {
|
|
442
|
+
foundations[name] = { role: ROLE_OF.get(name), value };
|
|
443
|
+
}
|
|
375
444
|
return { modes: linkedModes, foundations };
|
|
376
445
|
}
|
|
377
446
|
|
|
@@ -400,13 +469,73 @@ export function toFigma({ modes, foundations }, recipes = {}) {
|
|
|
400
469
|
}
|
|
401
470
|
|
|
402
471
|
files.foundations = Object.fromEntries(
|
|
403
|
-
Object.entries(foundations).
|
|
472
|
+
Object.entries(foundations).flatMap(([name, token]) => {
|
|
404
473
|
// `number`, not DTCG `fontWeight`: Figma's import drops fontWeight
|
|
405
474
|
// tokens, and a number variable is what binds to a text layer's weight.
|
|
406
|
-
if (token.role === "font-weight")
|
|
475
|
+
if (token.role === "font-weight") {
|
|
476
|
+
return [[name, { $type: "number", $value: token.weight }]];
|
|
477
|
+
}
|
|
478
|
+
// Figma reads dimensions only; the raw roles stay in the DTCG profile.
|
|
479
|
+
if (!LENGTH_ROLES.includes(token.role)) return [];
|
|
407
480
|
const $value = token.alias ? `{${token.alias}}` : { value: token.px, unit: "px" };
|
|
408
481
|
const doc = { $type: "dimension", $value };
|
|
409
482
|
if (token.css) doc.$extensions = { [EXTENSION]: { css: token.css } };
|
|
483
|
+
return [[name, doc]];
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
return files;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* DTCG `$type` per role. Figma keeps its own adaptations (a font weight as a
|
|
490
|
+
number); the neutral profile uses the type the spec defines. */
|
|
491
|
+
const DTCG_TYPE = {
|
|
492
|
+
spacing: "dimension",
|
|
493
|
+
radius: "dimension",
|
|
494
|
+
"border-width": "dimension",
|
|
495
|
+
"font-size": "dimension",
|
|
496
|
+
size: "dimension",
|
|
497
|
+
offset: "dimension",
|
|
498
|
+
"font-weight": "fontWeight",
|
|
499
|
+
"font-family": "fontFamily",
|
|
500
|
+
duration: "duration",
|
|
501
|
+
"cubic-bezier": "cubicBezier",
|
|
502
|
+
number: "number",
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
function dtcgValue(token) {
|
|
506
|
+
if (token.alias) return `{${token.alias}}`;
|
|
507
|
+
if (token.role === "font-weight") return token.weight;
|
|
508
|
+
if (LENGTH_ROLES.includes(token.role)) return { value: token.px, unit: "px" };
|
|
509
|
+
return token.value;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/*
|
|
513
|
+
* DTCG: the portable profile. Same file split as Figma, but every foundation
|
|
514
|
+
* keeps its spec type (`fontWeight`, `duration`, `cubicBezier`, `fontFamily`,
|
|
515
|
+
* `number`), colors carry a 6-digit `hex` with alpha separate, and dimensions
|
|
516
|
+
* keep their CSS value in `$extensions`. No tool adaptation: any DTCG reader
|
|
517
|
+
* can import it.
|
|
518
|
+
*/
|
|
519
|
+
export function toDtcg({ modes, foundations }, recipes = {}) {
|
|
520
|
+
const files = {};
|
|
521
|
+
for (const [mode, colors] of Object.entries(modes)) {
|
|
522
|
+
files[mode] = Object.fromEntries(
|
|
523
|
+
Object.entries({ ...colors, ...recipes[mode] }).map(([name, token]) => [
|
|
524
|
+
name,
|
|
525
|
+
{
|
|
526
|
+
$type: "color",
|
|
527
|
+
$value: token.alias
|
|
528
|
+
? `{${token.alias}}`
|
|
529
|
+
: { colorSpace: "srgb", ...token.color, hex: token.hex },
|
|
530
|
+
},
|
|
531
|
+
]),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
files.foundations = Object.fromEntries(
|
|
536
|
+
Object.entries(foundations).map(([name, token]) => {
|
|
537
|
+
const doc = { $type: DTCG_TYPE[token.role], $value: dtcgValue(token) };
|
|
538
|
+
if (token.css) doc.$extensions = { [EXTENSION]: { css: token.css } };
|
|
410
539
|
return [name, doc];
|
|
411
540
|
}),
|
|
412
541
|
);
|
|
@@ -438,10 +567,12 @@ const PENPOT_THEME_GROUP = "Mode";
|
|
|
438
567
|
export function toPenpot({ modes, foundations }, recipes = {}) {
|
|
439
568
|
const doc = {
|
|
440
569
|
foundations: Object.fromEntries(
|
|
441
|
-
Object.entries(foundations).
|
|
570
|
+
Object.entries(foundations).flatMap(([name, token]) => {
|
|
442
571
|
const $type = PENPOT_TYPES[token.role];
|
|
443
|
-
|
|
444
|
-
|
|
572
|
+
// Penpot reads the roles it types; the DTCG-only roles stay out.
|
|
573
|
+
if (!$type) return [];
|
|
574
|
+
if (token.alias) return [[name, { $type, $value: `{${token.alias}}` }]];
|
|
575
|
+
return [[name, { $type, $value: token.weight ?? `${token.px}px` }]];
|
|
445
576
|
}),
|
|
446
577
|
),
|
|
447
578
|
};
|
|
@@ -450,7 +581,7 @@ export function toPenpot({ modes, foundations }, recipes = {}) {
|
|
|
450
581
|
Object.fromEntries(
|
|
451
582
|
Object.entries(colors).map(([name, token]) => [
|
|
452
583
|
name,
|
|
453
|
-
{ $type: "color", $value: token.alias ? `{${token.alias}}` : token.
|
|
584
|
+
{ $type: "color", $value: token.alias ? `{${token.alias}}` : hexOf(token.color) },
|
|
454
585
|
]),
|
|
455
586
|
);
|
|
456
587
|
const schemes = Object.keys(modes);
|