designlang 12.16.0 → 12.18.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 +51 -0
- package/CODE_OF_CONDUCT.md +132 -0
- package/README.md +27 -6
- package/assets/atlas-cloud-logo.svg +55 -0
- package/bin/design-extract.js +50 -1
- package/commands/extract.md +9 -1
- package/docs/INTERVIEW_PREP.md +98 -0
- package/package.json +7 -5
- package/src/classifiers/smart.js +54 -22
- package/src/formatters/motion-gsap.js +131 -0
- package/src/formatters/motion-waapi.js +140 -0
- package/src/formatters/verify.js +77 -0
- package/src/verify/diff.js +52 -0
- package/src/verify/index.js +122 -0
- package/src/verify/render.js +45 -0
- package/src/verify/restyle.js +155 -0
- package/src/verify/tokens.js +45 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// `verifyDesign(url)` — the fidelity loop.
|
|
2
|
+
//
|
|
3
|
+
// extract → for each target component: capture the real crop + its computed
|
|
4
|
+
// styles → re-style a clone using ONLY the extracted tokens → render → pixel-
|
|
5
|
+
// diff against the real crop → fidelity %. Aggregate to a site score, with
|
|
6
|
+
// per-token-family attribution so the number is explained, not asserted.
|
|
7
|
+
|
|
8
|
+
import { chromium } from 'playwright';
|
|
9
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { extractDesignLanguage } from '../index.js';
|
|
12
|
+
import { tokensFromDesign } from './tokens.js';
|
|
13
|
+
import { restyleComponent } from './restyle.js';
|
|
14
|
+
import { renderComponent } from './render.js';
|
|
15
|
+
import { diffPngBuffers, ratioToFidelity } from './diff.js';
|
|
16
|
+
|
|
17
|
+
const SELECTORS = {
|
|
18
|
+
button: 'button:not(:empty), a[role="button"]:not(:empty), [class*="btn"]:not(:empty)',
|
|
19
|
+
card: '[class*="card"]:not(:empty)',
|
|
20
|
+
input: 'input[type="text"], input[type="email"], input[type="search"], textarea',
|
|
21
|
+
nav: 'nav, [role="navigation"]',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// The computed-style slice restyle.js needs — read once, in-page. Passed as a
|
|
25
|
+
// real function to Playwright's evaluate (no eval/string-compile).
|
|
26
|
+
function captureInPage(el) {
|
|
27
|
+
const cs = getComputedStyle(el);
|
|
28
|
+
const r = el.getBoundingClientRect();
|
|
29
|
+
const pick = ['backgroundColor', 'color', 'borderTopColor', 'borderTopWidth', 'borderTopStyle', 'borderTopLeftRadius', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'boxShadow'];
|
|
30
|
+
const computed = {};
|
|
31
|
+
for (const p of pick) computed[p] = cs[p];
|
|
32
|
+
return { computed, outerHTML: el.outerHTML.slice(0, 20000), box: { w: r.width, h: r.height } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function pickRepresentative(page, selector) {
|
|
36
|
+
const handles = await page.$$(selector);
|
|
37
|
+
for (const h of handles) {
|
|
38
|
+
const ok = await h.evaluate((el) => {
|
|
39
|
+
const r = el.getBoundingClientRect();
|
|
40
|
+
const cs = getComputedStyle(el);
|
|
41
|
+
return r.width >= 24 && r.height >= 12 && r.width <= window.innerWidth &&
|
|
42
|
+
cs.visibility !== 'hidden' && cs.display !== 'none' && parseFloat(cs.opacity) >= 0.5;
|
|
43
|
+
}).catch(() => false);
|
|
44
|
+
if (ok) return h;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function attribution(deltas) {
|
|
50
|
+
const byFamily = {};
|
|
51
|
+
for (const d of deltas) {
|
|
52
|
+
const f = (byFamily[d.family] ||= { family: d.family, moves: 0, unmapped: 0 });
|
|
53
|
+
if (d.mapped === false) f.unmapped += 1;
|
|
54
|
+
else if ((d.family === 'color' && d.distance > 6) || (d.distance || 0) > 2) f.moves += 1;
|
|
55
|
+
}
|
|
56
|
+
return Object.values(byFamily)
|
|
57
|
+
.filter((f) => f.moves || f.unmapped)
|
|
58
|
+
.sort((a, b) => (b.unmapped * 3 + b.moves) - (a.unmapped * 3 + a.moves));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function verifyDesign(url, opts = {}) {
|
|
62
|
+
const components = opts.components?.length ? opts.components : ['button', 'card'];
|
|
63
|
+
const width = opts.width || 1280;
|
|
64
|
+
const height = opts.height || 800;
|
|
65
|
+
const outDir = opts.out ? join(opts.out) : null;
|
|
66
|
+
if (outDir) for (const d of ['original', 'rebuilt', 'diff']) mkdirSync(join(outDir, 'verify', d), { recursive: true });
|
|
67
|
+
|
|
68
|
+
const design = opts.design || await extractDesignLanguage(url, opts.browserOpts || {});
|
|
69
|
+
const tokens = tokensFromDesign(design);
|
|
70
|
+
|
|
71
|
+
const browser = await chromium.launch({ headless: true, ...(opts.channel && { channel: opts.channel }) });
|
|
72
|
+
const results = [];
|
|
73
|
+
try {
|
|
74
|
+
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2, colorScheme: 'light' });
|
|
75
|
+
const page = await context.newPage();
|
|
76
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
|
|
77
|
+
await page.waitForLoadState('networkidle').catch(() => {});
|
|
78
|
+
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
79
|
+
|
|
80
|
+
for (const kind of components) {
|
|
81
|
+
const sel = SELECTORS[kind];
|
|
82
|
+
if (!sel) { results.push({ component: kind, status: 'n/a', reason: 'unknown component' }); continue; }
|
|
83
|
+
const handle = await pickRepresentative(page, sel);
|
|
84
|
+
if (!handle) { results.push({ component: kind, status: 'n/a', reason: 'not found on page' }); continue; }
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const originalBuf = await handle.screenshot({ type: 'png' });
|
|
88
|
+
const cap = await handle.evaluate(captureInPage);
|
|
89
|
+
const { styled, deltas } = restyleComponent(cap.computed, tokens);
|
|
90
|
+
const rebuiltBuf = await renderComponent(browser, { outerHTML: cap.outerHTML, box: cap.box, dpr: 2, styled }, tokens);
|
|
91
|
+
const { ratio, heatmap, diffPixels } = diffPngBuffers(originalBuf, rebuiltBuf);
|
|
92
|
+
const fidelity = ratioToFidelity(ratio);
|
|
93
|
+
|
|
94
|
+
if (outDir) {
|
|
95
|
+
writeFileSync(join(outDir, 'verify', 'original', `${kind}.png`), originalBuf);
|
|
96
|
+
writeFileSync(join(outDir, 'verify', 'rebuilt', `${kind}.png`), rebuiltBuf);
|
|
97
|
+
writeFileSync(join(outDir, 'verify', 'diff', `${kind}.png`), heatmap);
|
|
98
|
+
}
|
|
99
|
+
results.push({ component: kind, status: 'ok', fidelity, diffPixels, attribution: attribution(deltas), deltas });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
results.push({ component: kind, status: 'error', reason: err?.message || 'render failed' });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
await browser.close();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const scored = results.filter((r) => r.status === 'ok');
|
|
109
|
+
const fidelity = scored.length ? Math.round(scored.reduce((s, r) => s + r.fidelity, 0) / scored.length) : null;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
url,
|
|
113
|
+
host: (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url; } })(),
|
|
114
|
+
generatedAt: new Date().toISOString(),
|
|
115
|
+
fidelity,
|
|
116
|
+
components: results,
|
|
117
|
+
tokenCounts: {
|
|
118
|
+
palette: tokens.palette.length, radii: tokens.radii.length, spacing: tokens.spacing.length,
|
|
119
|
+
fontSizes: tokens.fontSizes.length, shadows: tokens.shadows.length,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Render a re-styled component clone on a clean canvas and return its PNG.
|
|
2
|
+
//
|
|
3
|
+
// The clone keeps its DOM structure and text but loses the site's stylesheet
|
|
4
|
+
// (we never load it). We pin the root to the captured box and apply ONLY the
|
|
5
|
+
// token-snapped inline styles, so the screenshot is "this component as the
|
|
6
|
+
// extracted token system would express it" — nothing more. Children inherit
|
|
7
|
+
// the token body font/colour; author classes resolve to nothing, by design.
|
|
8
|
+
|
|
9
|
+
import { styledToCss } from './restyle.js';
|
|
10
|
+
|
|
11
|
+
// browser: a launched Playwright Browser. comp: { outerHTML, box:{w,h}, dpr, styled }.
|
|
12
|
+
// tokens: used only for the page's inherited body font/colour defaults.
|
|
13
|
+
export async function renderComponent(browser, comp, tokens = {}) {
|
|
14
|
+
const { outerHTML, box, dpr = 2, styled } = comp;
|
|
15
|
+
const w = Math.max(1, Math.ceil(box?.w || 1));
|
|
16
|
+
const h = Math.max(1, Math.ceil(box?.h || 1));
|
|
17
|
+
const css = styledToCss(styled);
|
|
18
|
+
|
|
19
|
+
const context = await browser.newContext({
|
|
20
|
+
viewport: { width: w + 8, height: h + 8 },
|
|
21
|
+
deviceScaleFactor: dpr,
|
|
22
|
+
colorScheme: 'light',
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
const page = await context.newPage();
|
|
26
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
27
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
28
|
+
html, body { background: ${tokens.bodyBg || '#ffffff'}; }
|
|
29
|
+
body { font-family: ${tokens.bodyFamily || 'sans-serif'}; color: ${tokens.bodyColor || '#111'}; padding: 4px; }
|
|
30
|
+
#dl-host > * { width: ${w}px; height: ${h}px; box-sizing: border-box; overflow: hidden; display: flex; align-items: center; justify-content: center; ${css}; }
|
|
31
|
+
</style></head><body><div id="dl-host"></div></body></html>`;
|
|
32
|
+
await page.setContent(html, { waitUntil: 'domcontentloaded' });
|
|
33
|
+
await page.evaluate(({ outer, inline }) => {
|
|
34
|
+
const host = document.getElementById('dl-host');
|
|
35
|
+
host.innerHTML = outer;
|
|
36
|
+
const root = host.firstElementChild;
|
|
37
|
+
if (root) root.setAttribute('style', `${root.getAttribute('style') || ''};${inline}`);
|
|
38
|
+
}, { outer: outerHTML, inline: css });
|
|
39
|
+
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
40
|
+
const root = page.locator('#dl-host > *').first();
|
|
41
|
+
return await root.screenshot({ type: 'png' });
|
|
42
|
+
} finally {
|
|
43
|
+
await context.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The re-style engine — the heart of `verify`.
|
|
2
|
+
//
|
|
3
|
+
// Pure function: given an element's *real* computed styles and the design's
|
|
4
|
+
// token sets, produce the styles that element would have if it could only use
|
|
5
|
+
// the extracted tokens. The gap between the two — rendered and pixel-diffed
|
|
6
|
+
// elsewhere — IS the tokenization loss the fidelity score reports.
|
|
7
|
+
//
|
|
8
|
+
// No browser, no I/O. Every visual property that determines a static crop is
|
|
9
|
+
// snapped to its nearest token; properties with no matching token family are
|
|
10
|
+
// flagged `mapped: false` and rendered with a neutral default (radius 0,
|
|
11
|
+
// shadow none) so the diff EXPOSES the missing extractor rather than hiding it.
|
|
12
|
+
//
|
|
13
|
+
// Motion/easing is deliberately excluded: it does not affect a static
|
|
14
|
+
// screenshot, so snapping it would add noise without signal.
|
|
15
|
+
|
|
16
|
+
// ── colour ──────────────────────────────────────────────────────
|
|
17
|
+
export function parseColor(str) {
|
|
18
|
+
if (!str) return null;
|
|
19
|
+
const s = String(str).trim();
|
|
20
|
+
const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)\s*(?:[,/]\s*([\d.]+))?\s*\)/i);
|
|
21
|
+
if (m) return { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] };
|
|
22
|
+
const h = s.match(/^#([0-9a-f]{6})$/i);
|
|
23
|
+
if (h) return { r: parseInt(h[1].slice(0, 2), 16), g: parseInt(h[1].slice(2, 4), 16), b: parseInt(h[1].slice(4, 6), 16), a: 1 };
|
|
24
|
+
const h3 = s.match(/^#([0-9a-f]{3})$/i);
|
|
25
|
+
if (h3) return { r: parseInt(h3[1][0] + h3[1][0], 16), g: parseInt(h3[1][1] + h3[1][1], 16), b: parseInt(h3[1][2] + h3[1][2], 16), a: 1 };
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toHex({ r, g, b }) {
|
|
30
|
+
return '#' + [r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// sRGB → CIE Lab, so colour distance tracks perception, not raw RGB.
|
|
34
|
+
function rgbToLab({ r, g, b }) {
|
|
35
|
+
let [R, G, B] = [r, g, b].map((v) => {
|
|
36
|
+
v /= 255;
|
|
37
|
+
return v > 0.04045 ? ((v + 0.055) / 1.055) ** 2.4 : v / 12.92;
|
|
38
|
+
});
|
|
39
|
+
let x = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
|
|
40
|
+
let y = (R * 0.2126 + G * 0.7152 + B * 0.0722) / 1.0;
|
|
41
|
+
let z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
|
|
42
|
+
[x, y, z] = [x, y, z].map((v) => (v > 0.008856 ? Math.cbrt(v) : 7.787 * v + 16 / 116));
|
|
43
|
+
return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function deltaE(hexA, hexB) {
|
|
47
|
+
const a = parseColor(hexA);
|
|
48
|
+
const b = parseColor(hexB);
|
|
49
|
+
if (!a || !b) return Infinity;
|
|
50
|
+
const [l1, a1, b1] = rgbToLab(a);
|
|
51
|
+
const [l2, a2, b2] = rgbToLab(b);
|
|
52
|
+
return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function nearestColor(value, palette) {
|
|
56
|
+
const col = parseColor(value);
|
|
57
|
+
if (!col || col.a < 0.05) return { value: 'transparent', mapped: true, transparent: true };
|
|
58
|
+
if (!palette?.length) return { value: toHex(col), mapped: false };
|
|
59
|
+
const here = toHex(col);
|
|
60
|
+
let best = palette[0];
|
|
61
|
+
let bestD = Infinity;
|
|
62
|
+
for (const p of palette) {
|
|
63
|
+
const d = deltaE(here, p);
|
|
64
|
+
if (d < bestD) { bestD = d; best = p; }
|
|
65
|
+
}
|
|
66
|
+
return { value: best, mapped: true, distance: bestD };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── numeric scales ──────────────────────────────────────────────
|
|
70
|
+
export function px(v) {
|
|
71
|
+
const n = parseFloat(v);
|
|
72
|
+
return Number.isFinite(n) ? n : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function nearestNumber(value, scale, { fallback = 0 } = {}) {
|
|
76
|
+
const n = px(value);
|
|
77
|
+
if (n === null) return { value: fallback, mapped: false };
|
|
78
|
+
if (!scale?.length) return { value: fallback, mapped: false, original: n };
|
|
79
|
+
let best = scale[0];
|
|
80
|
+
for (const s of scale) if (Math.abs(s - n) < Math.abs(best - n)) best = s;
|
|
81
|
+
return { value: best, mapped: true, distance: Math.abs(best - n) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── shadow ──────────────────────────────────────────────────────
|
|
85
|
+
function blurOf(shadow) {
|
|
86
|
+
const m = String(shadow).match(/(-?\d+(?:\.\d+)?)px\s+(-?\d+(?:\.\d+)?)px\s+(\d+(?:\.\d+)?)px/);
|
|
87
|
+
return m ? +m[3] : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function nearestShadow(value, shadows) {
|
|
91
|
+
const has = value && value !== 'none';
|
|
92
|
+
if (!has) return { value: 'none', mapped: true };
|
|
93
|
+
if (!shadows?.length) return { value: 'none', mapped: false };
|
|
94
|
+
const target = blurOf(value);
|
|
95
|
+
let best = shadows[0];
|
|
96
|
+
for (const s of shadows) if (Math.abs(blurOf(s) - target) < Math.abs(blurOf(best) - target)) best = s;
|
|
97
|
+
return { value: best, mapped: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── font family ─────────────────────────────────────────────────
|
|
101
|
+
function primaryFamily(stack) {
|
|
102
|
+
return String(stack || '').split(',')[0].trim().replace(/^["']|["']$/g, '');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function snapFamily(value, families, fallback) {
|
|
106
|
+
const want = primaryFamily(value).toLowerCase();
|
|
107
|
+
const hit = (families || []).find((f) => primaryFamily(f).toLowerCase() === want);
|
|
108
|
+
if (hit) return { value: hit, mapped: true };
|
|
109
|
+
return { value: fallback || families?.[0] || 'sans-serif', mapped: false };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── orchestration ───────────────────────────────────────────────
|
|
113
|
+
// `computed` is a flat map of the raw values read from getComputedStyle.
|
|
114
|
+
// Returns { styled, deltas } — styled is inline CSS to apply to the rebuild
|
|
115
|
+
// root; deltas explains every move (for the attribution / heatmap legend).
|
|
116
|
+
export function restyleComponent(computed = {}, tokens = {}) {
|
|
117
|
+
const styled = {};
|
|
118
|
+
const deltas = [];
|
|
119
|
+
const record = (prop, family, from, res) => {
|
|
120
|
+
styled[prop] = res.value;
|
|
121
|
+
deltas.push({ prop, family, from, to: res.value, mapped: res.mapped !== false, distance: res.distance });
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
record('background-color', 'color', computed.backgroundColor, nearestColor(computed.backgroundColor, tokens.palette));
|
|
125
|
+
record('color', 'color', computed.color, nearestColor(computed.color, tokens.palette));
|
|
126
|
+
|
|
127
|
+
const bw = px(computed.borderTopWidth) || 0;
|
|
128
|
+
if (bw > 0) {
|
|
129
|
+
record('border-style', 'border', computed.borderTopStyle, { value: computed.borderTopStyle || 'solid', mapped: true });
|
|
130
|
+
record('border-width', 'border', computed.borderTopWidth, nearestNumber(computed.borderTopWidth, tokens.borderWidths, { fallback: bw }));
|
|
131
|
+
record('border-color', 'color', computed.borderTopColor, nearestColor(computed.borderTopColor, tokens.palette));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
record('border-radius', 'radius', computed.borderTopLeftRadius, nearestNumber(computed.borderTopLeftRadius, tokens.radii, { fallback: 0 }));
|
|
135
|
+
|
|
136
|
+
for (const [prop, key] of [['padding-top', 'paddingTop'], ['padding-right', 'paddingRight'], ['padding-bottom', 'paddingBottom'], ['padding-left', 'paddingLeft']]) {
|
|
137
|
+
record(prop, 'spacing', computed[key], nearestNumber(computed[key], tokens.spacing, { fallback: px(computed[key]) || 0 }));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
record('font-family', 'type', computed.fontFamily, snapFamily(computed.fontFamily, tokens.fontFamilies, tokens.bodyFamily));
|
|
141
|
+
record('font-size', 'type', computed.fontSize, nearestNumber(computed.fontSize, tokens.fontSizes, { fallback: px(computed.fontSize) || 16 }));
|
|
142
|
+
record('font-weight', 'type', computed.fontWeight, nearestNumber(computed.fontWeight, tokens.fontWeights, { fallback: parseInt(computed.fontWeight, 10) || 400 }));
|
|
143
|
+
record('box-shadow', 'shadow', computed.boxShadow, nearestShadow(computed.boxShadow, tokens.shadows));
|
|
144
|
+
|
|
145
|
+
return { styled, deltas };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Turn the `styled` map into an inline style string, appending px units to the
|
|
149
|
+
// numeric props the snappers return as raw numbers.
|
|
150
|
+
export function styledToCss(styled = {}) {
|
|
151
|
+
const PX = new Set(['border-width', 'border-radius', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'font-size']);
|
|
152
|
+
return Object.entries(styled)
|
|
153
|
+
.map(([k, v]) => `${k}: ${PX.has(k) && typeof v === 'number' ? `${v}px` : v}`)
|
|
154
|
+
.join('; ');
|
|
155
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Normalise a `design` object into the flat token sets the re-style engine
|
|
2
|
+
// snaps against. One job: turn the rich extraction into "here are the allowed
|
|
3
|
+
// values per visual property", so restyle.js stays a pure function of
|
|
4
|
+
// (computedStyle, tokens).
|
|
5
|
+
|
|
6
|
+
function uniqNums(arr) {
|
|
7
|
+
return [...new Set((arr || []).map(Number).filter((n) => Number.isFinite(n)))].sort((a, b) => a - b);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function hex(s) {
|
|
11
|
+
return typeof s === 'string' && /^#[0-9a-f]{6}$/i.test(s) ? s.toLowerCase() : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function tokensFromDesign(design = {}) {
|
|
15
|
+
const c = design.colors || {};
|
|
16
|
+
const palette = [...new Set([
|
|
17
|
+
c.primary?.hex,
|
|
18
|
+
c.secondary?.hex,
|
|
19
|
+
c.accent?.hex,
|
|
20
|
+
...(c.neutrals || []).map((n) => n.hex),
|
|
21
|
+
...(c.backgrounds || []),
|
|
22
|
+
...(c.text || []),
|
|
23
|
+
...(c.all || []).map((x) => x.hex),
|
|
24
|
+
].map(hex).filter(Boolean))];
|
|
25
|
+
|
|
26
|
+
const ty = design.typography || {};
|
|
27
|
+
const fontFamilies = [...new Set((ty.families || []).map((f) => f.name).filter(Boolean))];
|
|
28
|
+
const fontSizes = uniqNums((ty.scale || []).map((s) => s.size));
|
|
29
|
+
const fontWeights = uniqNums((ty.scale || []).map((s) => parseInt(s.weight, 10)));
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
palette,
|
|
33
|
+
radii: uniqNums(design.borders?.radii),
|
|
34
|
+
borderWidths: uniqNums(design.borders?.widths),
|
|
35
|
+
spacing: uniqNums(design.spacing?.scale),
|
|
36
|
+
fontFamilies,
|
|
37
|
+
fontSizes,
|
|
38
|
+
fontWeights,
|
|
39
|
+
shadows: (design.shadows?.values || []).filter((s) => typeof s === 'string' && s && s !== 'none'),
|
|
40
|
+
// sensible wrapper defaults so rebuilt text inherits the token system
|
|
41
|
+
bodyFamily: ty.body?.family || fontFamilies[0] || 'sans-serif',
|
|
42
|
+
bodyColor: hex(c.text?.[0]) || (c.primary?.hex ? hex(c.primary.hex) : null) || '#111111',
|
|
43
|
+
bodyBg: hex(c.backgrounds?.[0]) || '#ffffff',
|
|
44
|
+
};
|
|
45
|
+
}
|