@adia-ai/web-components 0.4.3 → 0.4.5
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/components/alert/alert.a2ui.json +17 -2
- package/components/alert/alert.js +100 -9
- package/components/alert/alert.test.js +180 -0
- package/components/alert/alert.yaml +30 -2
- package/components/badge/badge.a2ui.json +4 -0
- package/components/badge/badge.js +1 -0
- package/components/badge/badge.yaml +4 -0
- package/components/button/button.a2ui.json +14 -4
- package/components/button/button.js +1 -0
- package/components/button/button.yaml +18 -3
- package/components/calendar-picker/calendar-picker.js +1 -1
- package/components/check/check.a2ui.json +8 -1
- package/components/check/check.js +1 -1
- package/components/check/check.yaml +11 -2
- package/components/code/code.a2ui.json +4 -0
- package/components/code/code.js +1 -0
- package/components/code/code.yaml +4 -0
- package/components/col/col.a2ui.json +5 -0
- package/components/col/col.js +1 -0
- package/components/col/col.yaml +5 -0
- package/components/field/field.a2ui.json +17 -6
- package/components/field/field.test.js +8 -2
- package/components/field/field.yaml +50 -8
- package/components/index.js +1 -0
- package/components/input/input.a2ui.json +20 -0
- package/components/input/input.js +9 -9
- package/components/input/input.yaml +15 -0
- package/components/link/link.a2ui.json +166 -0
- package/components/link/link.css +102 -0
- package/components/link/link.js +177 -0
- package/components/link/link.test.js +143 -0
- package/components/link/link.yaml +162 -0
- package/components/option-card/option-card.js +1 -1
- package/components/otp-input/otp-input.js +3 -3
- package/components/radio/radio.a2ui.json +8 -1
- package/components/radio/radio.js +1 -1
- package/components/radio/radio.yaml +11 -2
- package/components/range/range.js +3 -3
- package/components/rating/rating.js +1 -1
- package/components/row/row.a2ui.json +5 -0
- package/components/row/row.js +1 -0
- package/components/row/row.yaml +5 -0
- package/components/search/search.js +2 -2
- package/components/select/select.a2ui.json +15 -0
- package/components/select/select.js +2 -2
- package/components/select/select.yaml +14 -0
- package/components/slider/slider.js +4 -4
- package/components/slider/slider.test.js +105 -0
- package/components/switch/switch.a2ui.json +8 -1
- package/components/switch/switch.js +1 -1
- package/components/switch/switch.yaml +11 -2
- package/components/table/table.a2ui.json +10 -0
- package/components/table/table.yaml +8 -0
- package/components/tag/tag.a2ui.json +4 -0
- package/components/tag/tag.js +1 -0
- package/components/tag/tag.yaml +4 -0
- package/components/text/text.a2ui.json +5 -0
- package/components/text/text.js +1 -0
- package/components/text/text.yaml +5 -0
- package/components/textarea/textarea.a2ui.json +5 -0
- package/components/textarea/textarea.js +2 -2
- package/components/textarea/textarea.yaml +4 -0
- package/components/upload/upload.js +1 -1
- package/package.json +2 -1
- package/styles/design-tokens-export.js +554 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /tokens/colors — Figma Variables Pro export.
|
|
3
|
+
*
|
|
4
|
+
* Reads the live --a-* color token surface, walks var()/light-dark() chains
|
|
5
|
+
* symbolically to keep cssVar identity in references, converts every literal
|
|
6
|
+
* OKLCH value through OKLab → linear-sRGB → sRGB → HSL in JS floats (no
|
|
7
|
+
* canvas quantization), and assembles a Variables Pro JSON document.
|
|
8
|
+
*
|
|
9
|
+
* Public surface:
|
|
10
|
+
* buildFigmaJson({ format }) → JSON object ready for JSON.stringify
|
|
11
|
+
* getExportStats() → counts + reference-coverage stats
|
|
12
|
+
* downloadJson(filename, data) → trigger browser file save
|
|
13
|
+
* copyJson(data) → copy stringified JSON to clipboard
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/* ─── CSS Object Model scan ──────────────────────────────────────────────── */
|
|
17
|
+
function buildCssVarMap() {
|
|
18
|
+
const map = new Map();
|
|
19
|
+
for (const sheet of document.styleSheets) {
|
|
20
|
+
let rules;
|
|
21
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
22
|
+
walkRules(rules, map);
|
|
23
|
+
}
|
|
24
|
+
return map;
|
|
25
|
+
}
|
|
26
|
+
// Only GLOBAL-scope token definitions are authoritative. Attribute-scoped
|
|
27
|
+
// selectors like `[color="info"]` conditionally override --a-fg etc. and
|
|
28
|
+
// must not leak into the default-state token map.
|
|
29
|
+
function isAuthoritativeSelector(sel) {
|
|
30
|
+
return /(^|,)\s*(:root|theme-ui|\[data-theme\])\b/i.test(sel);
|
|
31
|
+
}
|
|
32
|
+
function walkRules(rules, map) {
|
|
33
|
+
for (const rule of rules) {
|
|
34
|
+
if (rule instanceof CSSStyleRule) {
|
|
35
|
+
if (!isAuthoritativeSelector(rule.selectorText)) continue;
|
|
36
|
+
const decl = rule.style;
|
|
37
|
+
for (let i = 0; i < decl.length; i++) {
|
|
38
|
+
const name = decl.item(i);
|
|
39
|
+
if (!name.startsWith('--a-')) continue;
|
|
40
|
+
const raw = decl.getPropertyValue(name).trim();
|
|
41
|
+
if (raw) map.set(name, raw);
|
|
42
|
+
}
|
|
43
|
+
} else if (rule.cssRules) {
|
|
44
|
+
walkRules(rule.cssRules, map);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ─── CSS expression parsers ─────────────────────────────────────────────── */
|
|
50
|
+
function splitArgs(s) {
|
|
51
|
+
const out = [];
|
|
52
|
+
let depth = 0, start = 0;
|
|
53
|
+
for (let i = 0; i < s.length; i++) {
|
|
54
|
+
const c = s[i];
|
|
55
|
+
if (c === '(') depth++;
|
|
56
|
+
else if (c === ')') depth--;
|
|
57
|
+
else if (c === ',' && depth === 0) { out.push(s.slice(start, i).trim()); start = i + 1; }
|
|
58
|
+
}
|
|
59
|
+
out.push(s.slice(start).trim());
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function matchSingleVar(expr) {
|
|
63
|
+
const m = expr.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\s*\)$/s);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
return { name: m[1], fallback: m[2]?.trim() ?? null };
|
|
66
|
+
}
|
|
67
|
+
function matchLightDark(expr) {
|
|
68
|
+
if (!expr.startsWith('light-dark(')) return null;
|
|
69
|
+
if (!expr.endsWith(')')) return null;
|
|
70
|
+
const inner = expr.slice('light-dark('.length, -1);
|
|
71
|
+
const args = splitArgs(inner);
|
|
72
|
+
if (args.length !== 2) return null;
|
|
73
|
+
return { light: args[0], dark: args[1] };
|
|
74
|
+
}
|
|
75
|
+
function isColorLiteral(expr) {
|
|
76
|
+
return /^(oklch|oklab|rgb|rgba|hsl|hsla|#|color\()/i.test(expr);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ─── Symbolic chain walker ──────────────────────────────────────────────── */
|
|
80
|
+
function walkChain(startCssVar, mode, map) {
|
|
81
|
+
const hops = [];
|
|
82
|
+
const visited = new Set();
|
|
83
|
+
let current = startCssVar;
|
|
84
|
+
|
|
85
|
+
while (true) {
|
|
86
|
+
if (visited.has(current)) return { terminal: 'cycle', hops };
|
|
87
|
+
visited.add(current);
|
|
88
|
+
hops.push(current);
|
|
89
|
+
|
|
90
|
+
const rhs = map.get(current);
|
|
91
|
+
if (!rhs) return { terminal: 'unresolved', hops };
|
|
92
|
+
|
|
93
|
+
let expr = rhs;
|
|
94
|
+
let jumped = false;
|
|
95
|
+
for (let i = 0; i < 4; i++) {
|
|
96
|
+
const ld = matchLightDark(expr);
|
|
97
|
+
if (ld) { expr = (mode === 'light' ? ld.light : ld.dark).trim(); continue; }
|
|
98
|
+
const v = matchSingleVar(expr);
|
|
99
|
+
if (v) {
|
|
100
|
+
if (map.has(v.name)) { current = v.name; jumped = true; break; }
|
|
101
|
+
if (v.fallback) { expr = v.fallback; continue; }
|
|
102
|
+
return { terminal: 'unresolved', hops };
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (jumped) continue;
|
|
107
|
+
|
|
108
|
+
if (isColorLiteral(expr)) {
|
|
109
|
+
return { terminal: 'literal', leafCssVar: current, leafLiteral: expr, hops };
|
|
110
|
+
}
|
|
111
|
+
return { terminal: 'inline-literal', leafCssVar: current, leafLiteral: expr, hops };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ─── Probe-based value resolver ─────────────────────────────────────────── */
|
|
116
|
+
function withProbes(work) {
|
|
117
|
+
const root = document.createElement('div');
|
|
118
|
+
root.style.cssText = 'position:fixed;left:-99999px;top:0;width:0;height:0;overflow:hidden;';
|
|
119
|
+
document.body.appendChild(root);
|
|
120
|
+
const make = scheme => {
|
|
121
|
+
const h = document.createElement('div');
|
|
122
|
+
h.style.colorScheme = `only ${scheme}`;
|
|
123
|
+
root.appendChild(h);
|
|
124
|
+
return h;
|
|
125
|
+
};
|
|
126
|
+
const lightHost = make('light');
|
|
127
|
+
const darkHost = make('dark');
|
|
128
|
+
try {
|
|
129
|
+
return work({ lightHost, darkHost });
|
|
130
|
+
} finally {
|
|
131
|
+
document.body.removeChild(root);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function readComputed(host, cssVar) {
|
|
135
|
+
const p = document.createElement('div');
|
|
136
|
+
p.style.color = `var(${cssVar})`;
|
|
137
|
+
host.appendChild(p);
|
|
138
|
+
const v = getComputedStyle(p).color;
|
|
139
|
+
host.removeChild(p);
|
|
140
|
+
return v;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ─── Color-literal parser + math (OKLCH→OKLab→linear sRGB→sRGB→HSL) ───── */
|
|
144
|
+
function parseComputedColor(str) {
|
|
145
|
+
str = (str || '').trim();
|
|
146
|
+
let m;
|
|
147
|
+
if ((m = str.match(/^oklch\(\s*([\d.+-]+%?)\s+([\d.+-]+%?)\s+([\d.+-]+)(deg|rad|turn|grad)?\s*(?:\/\s*([\d.+-]+%?))?\s*\)$/i))) {
|
|
148
|
+
let L = parseFloat(m[1]); if (m[1].endsWith('%')) L /= 100;
|
|
149
|
+
let C = parseFloat(m[2]); if (m[2].endsWith('%')) C = C / 100 * 0.4;
|
|
150
|
+
let h = parseFloat(m[3]);
|
|
151
|
+
switch (m[4]) { case 'rad': h = h * 180 / Math.PI; break;
|
|
152
|
+
case 'turn': h *= 360; break;
|
|
153
|
+
case 'grad': h *= 0.9; break; }
|
|
154
|
+
let a = 1;
|
|
155
|
+
if (m[5]) { a = parseFloat(m[5]); if (m[5].endsWith('%')) a /= 100; }
|
|
156
|
+
return { space: 'oklch', L, C, h, alpha: a };
|
|
157
|
+
}
|
|
158
|
+
if ((m = str.match(/^oklab\(\s*([\d.+-]+%?)\s+([\d.+-]+%?)\s+([\d.+-]+%?)\s*(?:\/\s*([\d.+-]+%?))?\s*\)$/i))) {
|
|
159
|
+
let L = parseFloat(m[1]); if (m[1].endsWith('%')) L /= 100;
|
|
160
|
+
let a = parseFloat(m[2]); if (m[2].endsWith('%')) a = a / 100 * 0.4;
|
|
161
|
+
let b = parseFloat(m[3]); if (m[3].endsWith('%')) b = b / 100 * 0.4;
|
|
162
|
+
let A = 1;
|
|
163
|
+
if (m[4]) { A = parseFloat(m[4]); if (m[4].endsWith('%')) A /= 100; }
|
|
164
|
+
return { space: 'oklab', L, a, b, alpha: A };
|
|
165
|
+
}
|
|
166
|
+
if ((m = str.match(/^rgba?\(\s*([\d.+-]+)[,\s]+([\d.+-]+)[,\s]+([\d.+-]+)\s*(?:[,/]\s*([\d.+-]+%?))?\s*\)$/i))) {
|
|
167
|
+
const a = m[4] ? (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4])) : 1;
|
|
168
|
+
return { space: 'srgb', r: parseFloat(m[1]) / 255, g: parseFloat(m[2]) / 255, b: parseFloat(m[3]) / 255, alpha: a };
|
|
169
|
+
}
|
|
170
|
+
if ((m = str.match(/^color\(\s*srgb\s+([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)\s*(?:\/\s*([\d.+-]+%?))?\s*\)$/i))) {
|
|
171
|
+
const a = m[4] ? (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4])) : 1;
|
|
172
|
+
return { space: 'srgb', r: parseFloat(m[1]), g: parseFloat(m[2]), b: parseFloat(m[3]), alpha: a };
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
function oklchToLab({ L, C, h }) {
|
|
177
|
+
const rad = h * Math.PI / 180;
|
|
178
|
+
return { L, a: C * Math.cos(rad), b: C * Math.sin(rad) };
|
|
179
|
+
}
|
|
180
|
+
// Björn Ottosson's OKLab → linear-sRGB matrix.
|
|
181
|
+
function oklabToLinearSrgb({ L, a, b }) {
|
|
182
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
183
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
184
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
185
|
+
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
|
186
|
+
return {
|
|
187
|
+
r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
188
|
+
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
189
|
+
b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function linearToSrgb(x) {
|
|
193
|
+
const sign = x < 0 ? -1 : 1;
|
|
194
|
+
const ax = Math.abs(x);
|
|
195
|
+
const v = ax <= 0.0031308 ? 12.92 * ax : 1.055 * Math.pow(ax, 1 / 2.4) - 0.055;
|
|
196
|
+
return sign * v;
|
|
197
|
+
}
|
|
198
|
+
const clamp01 = x => Math.min(1, Math.max(0, x));
|
|
199
|
+
function colorToFloatSrgb(parsed) {
|
|
200
|
+
if (!parsed) return null;
|
|
201
|
+
if (parsed.space === 'srgb') {
|
|
202
|
+
return { r: clamp01(parsed.r), g: clamp01(parsed.g), b: clamp01(parsed.b), alpha: parsed.alpha, gamut: 'in' };
|
|
203
|
+
}
|
|
204
|
+
const lab = parsed.space === 'oklab'
|
|
205
|
+
? { L: parsed.L, a: parsed.a, b: parsed.b }
|
|
206
|
+
: oklchToLab(parsed);
|
|
207
|
+
const lin = oklabToLinearSrgb(lab);
|
|
208
|
+
const E = 1e-3;
|
|
209
|
+
const gamutIn = lin.r >= -E && lin.r <= 1 + E && lin.g >= -E && lin.g <= 1 + E && lin.b >= -E && lin.b <= 1 + E;
|
|
210
|
+
return {
|
|
211
|
+
r: clamp01(linearToSrgb(lin.r)),
|
|
212
|
+
g: clamp01(linearToSrgb(lin.g)),
|
|
213
|
+
b: clamp01(linearToSrgb(lin.b)),
|
|
214
|
+
alpha: parsed.alpha,
|
|
215
|
+
gamut: gamutIn ? 'in' : 'clamped',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function srgbToHsl({ r, g, b }) {
|
|
219
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
220
|
+
const L = (max + min) / 2;
|
|
221
|
+
let H = 0, S = 0;
|
|
222
|
+
const d = max - min;
|
|
223
|
+
if (d > 1e-12) {
|
|
224
|
+
S = L > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
225
|
+
if (max === r) H = ((g - b) / d) + (g < b ? 6 : 0);
|
|
226
|
+
else if (max === g) H = ((b - r) / d) + 2;
|
|
227
|
+
else H = ((r - g) / d) + 4;
|
|
228
|
+
H *= 60;
|
|
229
|
+
}
|
|
230
|
+
return { h: H, s: S * 100, l: L * 100 };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ─── Output formatters ──────────────────────────────────────────────────── */
|
|
234
|
+
const dp = (n, d = 4) => Number.isFinite(n) ? Number(n.toFixed(d)) : 0;
|
|
235
|
+
function formatHsl(srgb) {
|
|
236
|
+
const { h, s, l } = srgbToHsl(srgb);
|
|
237
|
+
const a = srgb.alpha;
|
|
238
|
+
return a < 0.999
|
|
239
|
+
? `hsla(${dp(h, 2)}, ${dp(s, 4)}%, ${dp(l, 4)}%, ${dp(a, 4)})`
|
|
240
|
+
: `hsl(${dp(h, 2)}, ${dp(s, 4)}%, ${dp(l, 4)}%)`;
|
|
241
|
+
}
|
|
242
|
+
function formatHex(srgb) {
|
|
243
|
+
const to = x => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
244
|
+
return srgb.alpha < 0.999
|
|
245
|
+
? `rgba(${Math.round(srgb.r * 255)}, ${Math.round(srgb.g * 255)}, ${Math.round(srgb.b * 255)}, ${dp(srgb.alpha, 4)})`
|
|
246
|
+
: '#' + to(srgb.r) + to(srgb.g) + to(srgb.b);
|
|
247
|
+
}
|
|
248
|
+
// Float-RGB literal matches Figma's parseColor floatRgbRegex —
|
|
249
|
+
// /^\{\s*r:\s*[\d\.]+,\s*g:\s*[\d\.]+,\s*b:\s*[\d\.]+(,\s*opacity:\s*[\d\.]+)?\s*\}$/
|
|
250
|
+
// Sub-byte precision (theoretically up to f64), but the canonical sample
|
|
251
|
+
// plugin's importer has a bug (it calls JSON.parse on unquoted keys);
|
|
252
|
+
// permissive parsers like Variables Pro may accept it.
|
|
253
|
+
function formatFloatRgb(srgb) {
|
|
254
|
+
const f = x => dp(x, 6);
|
|
255
|
+
return srgb.alpha < 0.999
|
|
256
|
+
? `{r: ${f(srgb.r)}, g: ${f(srgb.g)}, b: ${f(srgb.b)}, opacity: ${dp(srgb.alpha, 4)}}`
|
|
257
|
+
: `{r: ${f(srgb.r)}, g: ${f(srgb.g)}, b: ${f(srgb.b)}}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ─── Classifier + Figma key derivation ──────────────────────────────────── */
|
|
261
|
+
function isColorCssVar(name, rhs, map) {
|
|
262
|
+
if (rhs.startsWith('#')) return true;
|
|
263
|
+
if (/^(oklch|oklab|rgb|rgba|hsl|hsla|color)\(/.test(rhs)) return true;
|
|
264
|
+
if (/^(var\(|light-dark\()/.test(rhs)) {
|
|
265
|
+
const chain = walkChain(name, 'light', map);
|
|
266
|
+
if (chain.terminal !== 'literal') return false;
|
|
267
|
+
return isColorLiteral(chain.leafLiteral);
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Typography color tokens (--a-display-color, --a-title-color, etc.) get
|
|
273
|
+
// folded into a single 'text' group rather than scattered as 1-leaf groups.
|
|
274
|
+
const TEXT_FAMILIES = new Set([
|
|
275
|
+
'display', 'title', 'heading', 'kicker', 'label', 'caption',
|
|
276
|
+
'deck', 'section', 'subsection', 'metric', 'body', 'code',
|
|
277
|
+
]);
|
|
278
|
+
|
|
279
|
+
// Identify a cssVar as a primitive (tonal-ramp source) and return its
|
|
280
|
+
// ADAPTIVE figma key — polarity-collapsed (no -tint/-shade suffix).
|
|
281
|
+
// Both `--a-neutral-50-tint` and `--a-neutral-50-shade` (and the adaptive
|
|
282
|
+
// `--a-neutral-50` wrapper if present) map to `{neutral.50}`.
|
|
283
|
+
// Returns null if cssVar isn't a primitive shape.
|
|
284
|
+
function primitiveFigmaKey(cssVar) {
|
|
285
|
+
const body = cssVar.replace(/^--a-/, '');
|
|
286
|
+
let m;
|
|
287
|
+
// Step+polarity+scrim (fine or coarse): --a-{fam}-{N}-{tint|shade}-scrim
|
|
288
|
+
if ((m = body.match(/^([a-z]+)-(\d{1,2})-(?:tint|shade)-scrim$/))) return { group: m[1], leaf: `scrim-${m[2]}` };
|
|
289
|
+
// Step+polarity (fine or coarse): --a-{fam}-{N}-{tint|shade}
|
|
290
|
+
if ((m = body.match(/^([a-z]+)-(\d{1,2})-(?:tint|shade)$/))) return { group: m[1], leaf: m[2] };
|
|
291
|
+
// Adaptive scrim wrapper: --a-{fam}-{N}-scrim
|
|
292
|
+
if ((m = body.match(/^([a-z]+)-(\d{1,2})-scrim$/))) return { group: m[1], leaf: `scrim-${m[2]}` };
|
|
293
|
+
// Adaptive step wrapper: --a-{fam}-{N}
|
|
294
|
+
if ((m = body.match(/^([a-z]+)-(\d{1,2})$/))) return { group: m[1], leaf: m[2] };
|
|
295
|
+
// Data slot: --a-data-{N}
|
|
296
|
+
if ((m = body.match(/^data-(\d{1,2})$/))) return { group: 'data', leaf: m[1] };
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// For an adaptive primitive name, return the raw cssVars to probe in each
|
|
301
|
+
// mode. Light reads from the -tint primitive, Dark from the -shade primitive.
|
|
302
|
+
// Data slots are mode-aware via @property so the same cssVar is probed twice.
|
|
303
|
+
function primitiveProbeCssVars({ group, leaf }) {
|
|
304
|
+
if (group === 'data') {
|
|
305
|
+
const v = `--a-data-${leaf}`;
|
|
306
|
+
return { light: v, dark: v };
|
|
307
|
+
}
|
|
308
|
+
if (leaf.startsWith('scrim-')) {
|
|
309
|
+
const step = leaf.slice('scrim-'.length);
|
|
310
|
+
return {
|
|
311
|
+
light: `--a-${group}-${step}-tint-scrim`,
|
|
312
|
+
dark: `--a-${group}-${step}-shade-scrim`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
light: `--a-${group}-${leaf}-tint`,
|
|
317
|
+
dark: `--a-${group}-${leaf}-shade`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Figma key for any cssVar — primitives use the adaptive (polarity-collapsed)
|
|
322
|
+
// key; consumables get a camelCased fallback. Typography color overrides
|
|
323
|
+
// (display/title/heading/...) consolidate under a 'text' group.
|
|
324
|
+
function figmaKey(cssVar) {
|
|
325
|
+
const prim = primitiveFigmaKey(cssVar);
|
|
326
|
+
if (prim) return prim;
|
|
327
|
+
|
|
328
|
+
const body = cssVar.replace(/^--a-/, '');
|
|
329
|
+
const parts = body.split('-');
|
|
330
|
+
|
|
331
|
+
// --a-{family}-color (e.g. --a-display-color → text.display)
|
|
332
|
+
if (parts.length === 2 && parts[1] === 'color' && TEXT_FAMILIES.has(parts[0])) {
|
|
333
|
+
return { group: 'text', leaf: parts[0] };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const leaf = parts.slice(1).map((p, i) => i === 0 ? p : p[0].toUpperCase() + p.slice(1)).join('');
|
|
337
|
+
return { group: parts[0], leaf: leaf || 'default' };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* ─── Extraction pipeline ────────────────────────────────────────────────── */
|
|
341
|
+
function extract() {
|
|
342
|
+
const map = buildCssVarMap();
|
|
343
|
+
const colorEntries = [...map.entries()].filter(([k, v]) => isColorCssVar(k, v, map));
|
|
344
|
+
|
|
345
|
+
// A cssVar is a real adaptive primitive iff its name shape matches a
|
|
346
|
+
// tonal-primitive AND its chain terminates at a literal that maps to
|
|
347
|
+
// the SAME adaptive key. Aliases like --a-canvas-0 → --a-neutral-0 have
|
|
348
|
+
// a matching name shape but resolve to a different family, so they're
|
|
349
|
+
// consumables, not primitives.
|
|
350
|
+
const isAdaptivePrimitive = (cssVar) => {
|
|
351
|
+
const startKey = primitiveFigmaKey(cssVar);
|
|
352
|
+
if (!startKey) return false;
|
|
353
|
+
for (const mode of ['light', 'dark']) {
|
|
354
|
+
const chain = walkChain(cssVar, mode, map);
|
|
355
|
+
if (chain.terminal !== 'literal') continue;
|
|
356
|
+
const leafKey = primitiveFigmaKey(chain.leafCssVar);
|
|
357
|
+
if (leafKey && leafKey.group === startKey.group && leafKey.leaf === startKey.leaf) return true;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
};
|
|
361
|
+
const consumables = colorEntries
|
|
362
|
+
.filter(([k]) => !isAdaptivePrimitive(k))
|
|
363
|
+
.map(([k]) => k);
|
|
364
|
+
|
|
365
|
+
return withProbes(({ lightHost, darkHost }) => {
|
|
366
|
+
// Walk every consumable's chain in BOTH modes. The chain bottoms out on
|
|
367
|
+
// a raw polarity-suffixed primitive (e.g. --a-neutral-50-tint in light,
|
|
368
|
+
// --a-neutral-60-shade in dark). We map each to its adaptive primitive
|
|
369
|
+
// key so both modes alias the same Figma variable name (e.g. {neutral.50}).
|
|
370
|
+
const consumableResolutions = consumables.map(cssVar => {
|
|
371
|
+
const lightChain = walkChain(cssVar, 'light', map);
|
|
372
|
+
const darkChain = walkChain(cssVar, 'dark', map);
|
|
373
|
+
const lightPrimKey = lightChain.leafCssVar ? primitiveFigmaKey(lightChain.leafCssVar) : null;
|
|
374
|
+
const darkPrimKey = darkChain.leafCssVar ? primitiveFigmaKey(darkChain.leafCssVar) : null;
|
|
375
|
+
// Inline-resolved values (used when the chain doesn't reach a primitive
|
|
376
|
+
// we'd emit — e.g. for tokens with composed/calc expressions).
|
|
377
|
+
const lightSrgb = colorToFloatSrgb(parseComputedColor(readComputed(lightHost, cssVar)));
|
|
378
|
+
const darkSrgb = colorToFloatSrgb(parseComputedColor(readComputed(darkHost, cssVar)));
|
|
379
|
+
return { cssVar, lightChain, darkChain, lightPrimKey, darkPrimKey, lightSrgb, darkSrgb };
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Enumerate every real adaptive primitive (reusing the same classifier
|
|
383
|
+
// that decided which cssVars are NOT consumables).
|
|
384
|
+
const discovered = new Map(); // 'group.leaf' → {group, leaf}
|
|
385
|
+
const recordPrim = (k) => { if (k) discovered.set(`${k.group}.${k.leaf}`, k); };
|
|
386
|
+
for (const cssVar of map.keys()) {
|
|
387
|
+
if (isAdaptivePrimitive(cssVar)) recordPrim(primitiveFigmaKey(cssVar));
|
|
388
|
+
}
|
|
389
|
+
// Defensive: include anything a consumable chain reached that we missed.
|
|
390
|
+
for (const c of consumableResolutions) {
|
|
391
|
+
recordPrim(c.lightPrimKey);
|
|
392
|
+
recordPrim(c.darkPrimKey);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// For each adaptive primitive, probe the underlying -tint cssVar in
|
|
396
|
+
// light mode and the -shade cssVar in dark mode. (Data slots probe the
|
|
397
|
+
// same mode-aware cssVar in both modes.)
|
|
398
|
+
const primitiveResolutions = [...discovered.values()]
|
|
399
|
+
.sort((a, b) => `${a.group}.${a.leaf}`.localeCompare(`${b.group}.${b.leaf}`))
|
|
400
|
+
.map(key => {
|
|
401
|
+
const probes = primitiveProbeCssVars(key);
|
|
402
|
+
return {
|
|
403
|
+
key,
|
|
404
|
+
lightSrgb: colorToFloatSrgb(parseComputedColor(readComputed(lightHost, probes.light))),
|
|
405
|
+
darkSrgb: colorToFloatSrgb(parseComputedColor(readComputed(darkHost, probes.dark))),
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return { primitiveResolutions, consumableResolutions };
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function formatValue(srgb, format) {
|
|
414
|
+
switch (format) {
|
|
415
|
+
case 'hsl': return formatHsl(srgb);
|
|
416
|
+
case 'float-rgb': return formatFloatRgb(srgb);
|
|
417
|
+
case 'hex': return formatHex(srgb);
|
|
418
|
+
case 'dtcg': {
|
|
419
|
+
const f = x => dp(x, 6);
|
|
420
|
+
const to = x => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
421
|
+
return {
|
|
422
|
+
colorSpace: 'srgb',
|
|
423
|
+
components: [f(srgb.r), f(srgb.g), f(srgb.b)],
|
|
424
|
+
alpha: dp(srgb.alpha, 4),
|
|
425
|
+
hex: '#' + to(srgb.r) + to(srgb.g) + to(srgb.b),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
default: return formatHex(srgb);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function buildEntry({ refKey, srgb, format }) {
|
|
432
|
+
// DTCG mode: minimal W3C shape, no Variables-Pro extensions.
|
|
433
|
+
if (format === 'dtcg') {
|
|
434
|
+
const base = { $type: 'color' };
|
|
435
|
+
if (refKey) return { ...base, $value: `{${refKey.group}.${refKey.leaf}}` };
|
|
436
|
+
return { ...base, $value: srgb ? formatValue(srgb, format) : { colorSpace: 'srgb', components: [0, 0, 0], alpha: 1, hex: '#000000' } };
|
|
437
|
+
}
|
|
438
|
+
// Variables-Pro legacy shape — string $value + Figma plugin extension fields.
|
|
439
|
+
const base = { $scopes: ['ALL_SCOPES'], $hiddenFromPublishing: true, $type: 'color' };
|
|
440
|
+
if (refKey) {
|
|
441
|
+
return { ...base, $libraryName: '', $collectionName: 'Tokens', $value: `{${refKey.group}.${refKey.leaf}}` };
|
|
442
|
+
}
|
|
443
|
+
if (!srgb) return { ...base, $value: '#000000' };
|
|
444
|
+
return { ...base, $value: formatValue(srgb, format) };
|
|
445
|
+
}
|
|
446
|
+
function buildMode(modeKey, format, { primitiveResolutions, consumableResolutions }) {
|
|
447
|
+
const tree = {};
|
|
448
|
+
// Primitives — emit by adaptive identity, value differs per mode
|
|
449
|
+
for (const p of primitiveResolutions) {
|
|
450
|
+
const { group, leaf } = p.key;
|
|
451
|
+
const srgb = modeKey === 'light' ? p.lightSrgb : p.darkSrgb;
|
|
452
|
+
tree[group] ??= {};
|
|
453
|
+
tree[group][leaf] = buildEntry({ srgb, format });
|
|
454
|
+
}
|
|
455
|
+
// Consumables — alias the primitive the chain ends on. Light and Dark
|
|
456
|
+
// may alias different primitives (e.g. canvas.strong → neutral.50 in
|
|
457
|
+
// Light, neutral.60 in Dark — that's how the source CSS works).
|
|
458
|
+
for (const c of consumableResolutions) {
|
|
459
|
+
const { group, leaf } = figmaKey(c.cssVar);
|
|
460
|
+
const chain = modeKey === 'light' ? c.lightChain : c.darkChain;
|
|
461
|
+
const primKey = modeKey === 'light' ? c.lightPrimKey : c.darkPrimKey;
|
|
462
|
+
const srgb = modeKey === 'light' ? c.lightSrgb : c.darkSrgb;
|
|
463
|
+
const refKey = (chain.terminal === 'literal' && primKey)
|
|
464
|
+
? primKey
|
|
465
|
+
: null;
|
|
466
|
+
tree[group] ??= {};
|
|
467
|
+
tree[group][leaf] = buildEntry({ refKey, srgb, format });
|
|
468
|
+
}
|
|
469
|
+
return tree;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* ─── Public API ─────────────────────────────────────────────────────────── */
|
|
473
|
+
// format: 'dtcg' — Figma's NATIVE direct-import format. W3C Design
|
|
474
|
+
// Tokens 1.0 spec. $value is a structured object
|
|
475
|
+
// {colorSpace, components, alpha, hex}. Emits ONE
|
|
476
|
+
// FILE PER MODE — call buildDtcgFiles() for both.
|
|
477
|
+
// 'hex' — Variables Pro / jake-figma legacy. $value is a
|
|
478
|
+
// '#hex' or 'rgba(int, int, int, float)' string.
|
|
479
|
+
// Modes wrapped under [{Tokens: {modes: {Light, Dark}}}].
|
|
480
|
+
// 'float-rgb' — Legacy float-RGB literal string (permissive parsers only).
|
|
481
|
+
// 'hsl' — Legacy decimal-HSL (non-standard).
|
|
482
|
+
export function buildFigmaJson({ format = 'dtcg' } = {}) {
|
|
483
|
+
const ex = extract();
|
|
484
|
+
if (format === 'dtcg') {
|
|
485
|
+
// For DTCG, default to a SINGLE FILE containing the Light mode (Figma's
|
|
486
|
+
// canonical shape). Use buildDtcgFiles() to get both modes as separate
|
|
487
|
+
// files.
|
|
488
|
+
return buildMode('light', 'dtcg', ex);
|
|
489
|
+
}
|
|
490
|
+
// Variables Pro legacy: nested modes wrapper, string $value.
|
|
491
|
+
return [{
|
|
492
|
+
Tokens: {
|
|
493
|
+
modes: {
|
|
494
|
+
Light: buildMode('light', format, ex),
|
|
495
|
+
Dark: buildMode('dark', format, ex),
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
}];
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// DTCG emits one file per mode. Returns a list of {filename, body} pairs
|
|
502
|
+
// suitable for sequential downloads. Filenames are simple mode names so
|
|
503
|
+
// that Figma's import (which derives the mode name from the filename
|
|
504
|
+
// basename minus `.tokens.json`) creates modes called "light" and "dark".
|
|
505
|
+
export function buildDtcgFiles() {
|
|
506
|
+
const ex = extract();
|
|
507
|
+
return [
|
|
508
|
+
{ filename: `light.tokens.json`, body: buildMode('light', 'dtcg', ex) },
|
|
509
|
+
{ filename: `dark.tokens.json`, body: buildMode('dark', 'dtcg', ex) },
|
|
510
|
+
];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function getExportStats() {
|
|
514
|
+
const ex = extract();
|
|
515
|
+
let refCovL = 0, refCovD = 0, clampedL = 0, clampedD = 0;
|
|
516
|
+
for (const c of ex.consumableResolutions) {
|
|
517
|
+
if (c.lightPrimKey) refCovL++;
|
|
518
|
+
if (c.darkPrimKey) refCovD++;
|
|
519
|
+
if (c.lightSrgb?.gamut === 'clamped') clampedL++;
|
|
520
|
+
if (c.darkSrgb?.gamut === 'clamped') clampedD++;
|
|
521
|
+
}
|
|
522
|
+
for (const p of ex.primitiveResolutions) {
|
|
523
|
+
if (p.lightSrgb?.gamut === 'clamped') clampedL++;
|
|
524
|
+
if (p.darkSrgb?.gamut === 'clamped') clampedD++;
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
primitives: ex.primitiveResolutions.length,
|
|
528
|
+
consumables: ex.consumableResolutions.length,
|
|
529
|
+
referenceCoverageLight: refCovL,
|
|
530
|
+
referenceCoverageDark: refCovD,
|
|
531
|
+
gamutClampedLight: clampedL,
|
|
532
|
+
gamutClampedDark: clampedD,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function downloadJson(filename, data) {
|
|
537
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
538
|
+
const url = URL.createObjectURL(blob);
|
|
539
|
+
const a = document.createElement('a');
|
|
540
|
+
a.href = url;
|
|
541
|
+
a.download = filename;
|
|
542
|
+
// The site's SPA router listens for document-level click events on
|
|
543
|
+
// anchors to trigger client-side navigation and would try to
|
|
544
|
+
// history.pushState() the blob URL (which throws cross-origin).
|
|
545
|
+
// Dispatch a non-bubbling synthetic click so the event never reaches
|
|
546
|
+
// document — the browser's default download action still fires because
|
|
547
|
+
// it's tied to the event target's anchor element, not propagation.
|
|
548
|
+
a.dispatchEvent(new MouseEvent('click', { bubbles: false, cancelable: true, view: window }));
|
|
549
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export async function copyJson(data) {
|
|
553
|
+
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
|
554
|
+
}
|