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.
Files changed (108) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -8
  3. package/components.json +124 -12
  4. package/dist/actual.full.css +1152 -309
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/architecture.md +67 -0
  8. package/docs/design-notes/steps.md +45 -28
  9. package/docs/navigation.json +2 -0
  10. package/docs/pages/components/alert.md +12 -2
  11. package/docs/pages/components/avatar.md +17 -11
  12. package/docs/pages/components/breadcrumb.md +16 -2
  13. package/docs/pages/components/button.md +3 -2
  14. package/docs/pages/components/list.md +15 -16
  15. package/docs/pages/components/navbar.md +42 -22
  16. package/docs/pages/components/pagination.md +19 -0
  17. package/docs/pages/components/segmented.md +83 -0
  18. package/docs/pages/components/steps.md +28 -22
  19. package/docs/pages/enhancements/flyout.md +5 -0
  20. package/docs/pages/enhancements/scrollspy.md +6 -6
  21. package/docs/pages/enhancements/tabs.md +79 -3
  22. package/docs/pages/examples/overview.md +3 -1
  23. package/docs/pages/forms/color-swatches.md +131 -0
  24. package/docs/pages/forms/combobox.md +2 -2
  25. package/docs/pages/forms/controls.md +6 -3
  26. package/docs/pages/forms/floating-field.md +21 -7
  27. package/docs/pages/forms/form-actions.md +3 -6
  28. package/docs/pages/forms/joined-controls.md +6 -2
  29. package/docs/pages/forms/overview.md +1 -0
  30. package/docs/pages/forms/states-and-sizes.md +15 -15
  31. package/docs/pages/forms/switch.md +4 -2
  32. package/docs/pages/foundations/philosophy.md +97 -1
  33. package/docs/pages/foundations/theming.md +1 -1
  34. package/docs/pages/foundations/tokens.md +94 -27
  35. package/docs/pages/guides/design-tools.md +14 -8
  36. package/docs/pages/guides/integrating-actual-css.md +74 -8
  37. package/docs/pages/guides/introduction.md +3 -2
  38. package/docs/pages/guides/modular-import.md +1 -1
  39. package/docs/pages/guides/progressive-enhancement.md +25 -7
  40. package/docs/pages/guides/upgrading.md +86 -9
  41. package/docs/pages/layout/center.md +2 -1
  42. package/docs/pages/layout/cluster.md +11 -4
  43. package/docs/pages/layout/grid.md +2 -0
  44. package/docs/pages/layout/scroll-snap.md +4 -2
  45. package/docs/pages/layout/sidebar-layout.md +3 -0
  46. package/docs/pages/layout/switcher.md +2 -0
  47. package/docs/pages/layout/topbar.md +6 -4
  48. package/docs/pages/patterns/icon-slot.md +17 -1
  49. package/docs/pages/utilities/flex.md +1 -1
  50. package/docs/pages/utilities/sizing-wrapping.md +51 -6
  51. package/package.json +6 -6
  52. package/reserved-classes.json +3 -0
  53. package/src/cli/actual-css.js +4 -4
  54. package/src/css/components/alert.css +99 -48
  55. package/src/css/components/app-nav.css +17 -9
  56. package/src/css/components/avatar.css +2 -18
  57. package/src/css/components/badge.css +23 -10
  58. package/src/css/components/breadcrumb.css +74 -8
  59. package/src/css/components/button.css +17 -2
  60. package/src/css/components/close.css +6 -3
  61. package/src/css/components/drawer.css +23 -42
  62. package/src/css/components/flyout.css +0 -1
  63. package/src/css/components/index.css +1 -0
  64. package/src/css/components/join.css +2 -2
  65. package/src/css/components/list.css +28 -13
  66. package/src/css/components/modal.css +17 -10
  67. package/src/css/components/navbar.css +15 -10
  68. package/src/css/components/pagination.css +5 -0
  69. package/src/css/components/scrollspy.css +1 -1
  70. package/src/css/components/segmented.css +186 -0
  71. package/src/css/components/steps.css +72 -15
  72. package/src/css/components/tab.css +135 -5
  73. package/src/css/core/focus.css +11 -6
  74. package/src/css/core/theme.css +6 -1
  75. package/src/css/core/tokens.css +24 -6
  76. package/src/css/core/variants.css +16 -5
  77. package/src/css/forms/choice-card.css +1 -1
  78. package/src/css/forms/choice.css +1 -1
  79. package/src/css/forms/color-swatch.css +159 -0
  80. package/src/css/forms/control.css +1 -1
  81. package/src/css/forms/floating-field.css +20 -18
  82. package/src/css/forms/form-actions.css +14 -8
  83. package/src/css/forms/index.css +1 -0
  84. package/src/css/forms/native.css +51 -21
  85. package/src/css/forms/otp.css +4 -5
  86. package/src/css/forms/switch.css +6 -3
  87. package/src/css/forms/validation.css +23 -30
  88. package/src/css/layout/center.css +2 -0
  89. package/src/css/layout/cluster.css +14 -0
  90. package/src/css/layout/grid.css +7 -0
  91. package/src/css/layout/scroll-snap.css +30 -0
  92. package/src/css/layout/scroller.css +19 -3
  93. package/src/css/layout/sidebar-layout.css +3 -0
  94. package/src/css/layout/switcher.css +3 -0
  95. package/src/css/layout/topbar.css +10 -0
  96. package/src/css/utilities/extra.css +3 -3
  97. package/src/js/focus.js +16 -1
  98. package/src/js/full.js +4 -0
  99. package/src/js/inline-visible.js +54 -0
  100. package/src/js/parsed.js +21 -0
  101. package/src/js/surface.js +10 -0
  102. package/src/js/tab.js +33 -7
  103. package/src/tooling/color.js +14 -1
  104. package/src/tooling/css-bundle.js +0 -5
  105. package/src/tooling/design-components.js +9 -5
  106. package/src/tooling/design-export.js +9 -1
  107. package/src/tooling/design-tokens.js +207 -76
  108. 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
- const TABLIST_SELECTOR = '[data-enhance~="tabs"]';
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
- function activateAndFocus(tab) {
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
- activate(tab);
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
- initialize(list);
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 () => controller.abort();
181
+ return () => {
182
+ cancelInit();
183
+ controller.abort();
184
+ };
159
185
  });
@@ -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
- if (known && known.hex !== hexes[m]) {
219
- throw new Error(`Recipe ${token} paints ${known.hex} and ${hexes[m]} in ${mode}`);
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: hexes[m] };
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 ? (name ?? themeNameOf(themeCss)) : null;
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 and
3
- * writes them for Figma (DTCG files, one per mode) and Penpot (one token file
4
- * with its sets and themes).
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 tells a design tool where a value
59
- applies (Penpot token types); every role but font-weight is a length. */
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 design tool equivalent": [
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 LENGTHS = Object.entries(FOUNDATIONS).flatMap(([role, names]) =>
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
- const THEME_NAME_RE = /\[data-theme="([^"]+)"\]/g;
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
- export function themeNameOf(css) {
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
- [...css.matchAll(THEME_NAME_RE)].map((m) => m[1]).filter((n) => n !== "light" && n !== "dark"),
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 (names.size !== 1) {
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 ${names.size === 0 ? "none" : [...names].join(", ")}; pass the name explicitly.`,
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
- values.scheme = attribute ? getComputedStyle(island).colorScheme : null;
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 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.
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
- 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
- }
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 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) };
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
- 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) : ""}`;
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 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 }`.
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: hexOf(color) }];
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).map(([name, token]) => {
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") return [name, { $type: "number", $value: token.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).map(([name, token]) => {
570
+ Object.entries(foundations).flatMap(([name, token]) => {
442
571
  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` }];
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.hex },
584
+ { $type: "color", $value: token.alias ? `{${token.alias}}` : hexOf(token.color) },
454
585
  ]),
455
586
  );
456
587
  const schemes = Object.keys(modes);