designlang 12.17.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 +25 -0
- package/README.md +1 -0
- package/bin/design-extract.js +45 -0
- package/package.json +4 -2
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [12.18.0] — 2026-06-12
|
|
4
|
+
|
|
5
|
+
**`verify` closes the loop — designlang now *proves* its extraction with a fidelity score.**
|
|
6
|
+
|
|
7
|
+
Every release so far *read* a design system and emitted artifacts. None proved
|
|
8
|
+
the artifacts could rebuild the source. `verify` does exactly that.
|
|
9
|
+
|
|
10
|
+
- **`designlang verify <url>`** — extracts the design, then for each detected
|
|
11
|
+
component (button + card in v1) rebuilds a clone styled **only** by the
|
|
12
|
+
extracted tokens (every visual property snapped to its nearest token; colours
|
|
13
|
+
matched in CIE-Lab ΔE space), renders it, and **pixel-diffs** it against the
|
|
14
|
+
live component. Emits a **0–100 fidelity score**, a per-token-family
|
|
15
|
+
attribution of where information was lost, and a `verify.html` triptych
|
|
16
|
+
(original │ rebuilt-from-tokens │ loss heatmap) per component, plus
|
|
17
|
+
`verify.json`. `--min <score>` exits non-zero for CI gating.
|
|
18
|
+
|
|
19
|
+
The score is honest by construction: a component not on the page is `n/a`
|
|
20
|
+
(excluded, never a silent 100), and a property with no matching token is
|
|
21
|
+
`unmapped` — counted as loss and surfaced in the attribution, so a weak
|
|
22
|
+
extractor *lowers* the score rather than hiding behind it. Size mismatches are
|
|
23
|
+
letterboxed, never stretched.
|
|
24
|
+
|
|
25
|
+
Adds `pixelmatch` + `pngjs` as dependencies. New modules under `src/verify/`
|
|
26
|
+
(`tokens`, `restyle`, `render`, `diff`, `index`) are isolated and unit-tested.
|
|
27
|
+
|
|
3
28
|
## [12.17.0] — 2026-06-12
|
|
4
29
|
|
|
5
30
|
**motionlang adds two more emitters — GSAP and the framework-free Web Animations API.**
|
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ It also goes where extractors don't: **layout patterns**, **responsive behavior
|
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
npx designlang https://stripe.com # extract everything
|
|
38
|
+
npx designlang verify stripe.com # fidelity score: rebuild from tokens vs live ← v12.18
|
|
38
39
|
npx designlang pair stripe.com linear.app # fuse two designs (visuals A × voice B) ← v12.8
|
|
39
40
|
npx designlang brand stripe.com # full brand-guidelines book (13 chapters) ← v12.7
|
|
40
41
|
npx designlang theme-swap stripe.com --primary "#ff4800" # recolour around your brand ← v12.6
|
package/bin/design-extract.js
CHANGED
|
@@ -1920,6 +1920,51 @@ program
|
|
|
1920
1920
|
}
|
|
1921
1921
|
});
|
|
1922
1922
|
|
|
1923
|
+
// ── Verify — fidelity loop: rebuild from tokens, pixel-diff vs original ──
|
|
1924
|
+
program
|
|
1925
|
+
.command('verify <url>')
|
|
1926
|
+
.description('Rebuild components from the extracted tokens and pixel-diff them against the live page — a 0-100 fidelity score + loss heatmaps.')
|
|
1927
|
+
.option('-o, --out <dir>', 'output directory', './design-extract-output')
|
|
1928
|
+
.option('-c, --components <list>', 'comma-separated components to check (button,card,input,nav)', 'button,card')
|
|
1929
|
+
.option('--min <score>', 'exit non-zero if site fidelity is below this (CI gate)', parseInt)
|
|
1930
|
+
.option('--system-chrome', 'use the system Chrome install instead of bundled Chromium')
|
|
1931
|
+
.action(async (url, opts) => {
|
|
1932
|
+
if (!url.startsWith('http')) url = `https://${url}`;
|
|
1933
|
+
validateUrl(url);
|
|
1934
|
+
const spinner = ora('Extracting + rebuilding from tokens').start();
|
|
1935
|
+
try {
|
|
1936
|
+
const { verifyDesign } = await import('../src/verify/index.js');
|
|
1937
|
+
const { formatVerifyHtml, formatVerifyJson } = await import('../src/formatters/verify.js');
|
|
1938
|
+
const components = String(opts.components).split(',').map((s) => s.trim()).filter(Boolean);
|
|
1939
|
+
const report = await verifyDesign(url, {
|
|
1940
|
+
components,
|
|
1941
|
+
out: resolve(opts.out),
|
|
1942
|
+
channel: opts.systemChrome ? 'chrome' : undefined,
|
|
1943
|
+
});
|
|
1944
|
+
mkdirSync(resolve(opts.out), { recursive: true });
|
|
1945
|
+
const htmlPath = join(resolve(opts.out), 'verify.html');
|
|
1946
|
+
const jsonPath = join(resolve(opts.out), 'verify.json');
|
|
1947
|
+
writeFileSync(htmlPath, formatVerifyHtml(report), 'utf8');
|
|
1948
|
+
writeFileSync(jsonPath, formatVerifyJson(report), 'utf8');
|
|
1949
|
+
|
|
1950
|
+
const score = report.fidelity;
|
|
1951
|
+
spinner.succeed(`Fidelity ${score == null ? 'n/a' : score + '/100'} → ${htmlPath}`);
|
|
1952
|
+
for (const c of report.components) {
|
|
1953
|
+
const line = c.status === 'ok'
|
|
1954
|
+
? `${chalk.bold(c.component.padEnd(8))} ${chalk.cyan(String(c.fidelity).padStart(3))}/100`
|
|
1955
|
+
: `${chalk.bold(c.component.padEnd(8))} ${chalk.gray(c.status)} ${chalk.gray(c.reason || '')}`;
|
|
1956
|
+
console.log(' ' + line);
|
|
1957
|
+
}
|
|
1958
|
+
if (opts.min != null && score != null && score < opts.min) {
|
|
1959
|
+
console.error(chalk.red(`\n Fidelity ${score} is below --min ${opts.min}\n`));
|
|
1960
|
+
process.exit(1);
|
|
1961
|
+
}
|
|
1962
|
+
} catch (err) {
|
|
1963
|
+
spinner.fail(err.message);
|
|
1964
|
+
process.exit(1);
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1923
1968
|
// ── Chat — REPL over a live extraction (v12) ──────────────
|
|
1924
1969
|
program
|
|
1925
1970
|
.command('chat <target>')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.18.0",
|
|
4
4
|
"description": "Extract the complete design language from any website and ship it — clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
"commander": "^12.0.0",
|
|
25
25
|
"ora": "^8.0.0",
|
|
26
26
|
"pdf-lib": "^1.17.1",
|
|
27
|
-
"
|
|
27
|
+
"pixelmatch": "^7.2.0",
|
|
28
|
+
"playwright": "^1.42.0",
|
|
29
|
+
"pngjs": "^7.0.0"
|
|
28
30
|
},
|
|
29
31
|
"engines": {
|
|
30
32
|
"node": ">=20"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Render a verify report as JSON and a self-contained HTML triptych
|
|
2
|
+
// (original │ rebuilt │ diff-heatmap per component) with the fidelity score.
|
|
3
|
+
|
|
4
|
+
export function formatVerifyJson(report) {
|
|
5
|
+
// Drop the verbose per-property deltas from JSON; keep the attribution summary.
|
|
6
|
+
const slim = {
|
|
7
|
+
...report,
|
|
8
|
+
components: report.components.map(({ deltas, ...c }) => c), // eslint-disable-line no-unused-vars
|
|
9
|
+
};
|
|
10
|
+
return JSON.stringify(slim, null, 2) + '\n';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function esc(s) {
|
|
14
|
+
return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function band(n) {
|
|
18
|
+
if (n == null) return '#888';
|
|
19
|
+
if (n >= 90) return '#16a34a';
|
|
20
|
+
if (n >= 75) return '#65a30d';
|
|
21
|
+
if (n >= 55) return '#d97706';
|
|
22
|
+
return '#dc2626';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatVerifyHtml(report) {
|
|
26
|
+
const score = report.fidelity;
|
|
27
|
+
const cards = report.components.map((c) => {
|
|
28
|
+
if (c.status !== 'ok') {
|
|
29
|
+
return `<div class="comp na"><div class="comp-head"><span class="kind">${esc(c.component)}</span><span class="tag">${esc(c.status)}</span></div><p class="reason">${esc(c.reason || '')}</p></div>`;
|
|
30
|
+
}
|
|
31
|
+
const attr = (c.attribution || []).map((a) =>
|
|
32
|
+
`<li><b>${esc(a.family)}</b> — ${a.unmapped ? `${a.unmapped} unmapped` : ''}${a.unmapped && a.moves ? ', ' : ''}${a.moves ? `${a.moves} shifted` : ''}</li>`).join('');
|
|
33
|
+
return `<div class="comp">
|
|
34
|
+
<div class="comp-head"><span class="kind">${esc(c.component)}</span><span class="score" style="color:${band(c.fidelity)}">${c.fidelity}<small>/100</small></span></div>
|
|
35
|
+
<div class="trip">
|
|
36
|
+
<figure><img src="verify/original/${esc(c.component)}.png" alt="original"><figcaption>original</figcaption></figure>
|
|
37
|
+
<figure><img src="verify/rebuilt/${esc(c.component)}.png" alt="rebuilt from tokens"><figcaption>rebuilt from tokens</figcaption></figure>
|
|
38
|
+
<figure><img src="verify/diff/${esc(c.component)}.png" alt="diff"><figcaption>loss heatmap</figcaption></figure>
|
|
39
|
+
</div>
|
|
40
|
+
${attr ? `<ul class="attr">${attr}</ul>` : '<p class="attr none">no significant token loss</p>'}
|
|
41
|
+
</div>`;
|
|
42
|
+
}).join('');
|
|
43
|
+
|
|
44
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
45
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
46
|
+
<title>designlang · fidelity · ${esc(report.host)}</title>
|
|
47
|
+
<style>
|
|
48
|
+
:root { --fg:#111; --mut:#666; --line:#e5e5e5; --bg:#fafafa; }
|
|
49
|
+
* { box-sizing: border-box; }
|
|
50
|
+
body { font: 15px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 40px 24px; }
|
|
51
|
+
.wrap { max-width: 1080px; margin: 0 auto; }
|
|
52
|
+
h1 { font-size: 28px; margin: 0 0 4px; }
|
|
53
|
+
.sub { color: var(--mut); font-size: 13px; margin: 0 0 28px; }
|
|
54
|
+
.big { display: flex; align-items: baseline; gap: 14px; margin: 0 0 32px; }
|
|
55
|
+
.big .n { font-size: 64px; font-weight: 800; line-height: 1; color: ${band(score)}; }
|
|
56
|
+
.big .l { color: var(--mut); font-size: 13px; text-transform: uppercase; letter-spacing: .08em; }
|
|
57
|
+
.comp { background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 18px; margin-bottom: 18px; }
|
|
58
|
+
.comp.na { opacity: .7; }
|
|
59
|
+
.comp-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
|
|
60
|
+
.kind { font-weight: 700; text-transform: capitalize; }
|
|
61
|
+
.score { font-size: 26px; font-weight: 800; } .score small { font-size: 12px; color: var(--mut); font-weight: 500; }
|
|
62
|
+
.tag { font: 11px/1 ui-monospace, monospace; text-transform: uppercase; letter-spacing: .1em; color: var(--mut); border: 1px solid var(--line); padding: 4px 8px; border-radius: 6px; }
|
|
63
|
+
.trip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
|
64
|
+
figure { margin: 0; text-align: center; } figure img { max-width: 100%; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
|
|
65
|
+
figcaption { color: var(--mut); font-size: 11px; margin-top: 6px; text-transform: uppercase; letter-spacing: .06em; }
|
|
66
|
+
.attr { margin: 14px 0 0; padding-left: 18px; color: var(--mut); font-size: 13px; } .attr.none { list-style: none; padding: 0; font-style: italic; }
|
|
67
|
+
.reason { color: var(--mut); font-size: 13px; margin: 0; }
|
|
68
|
+
footer { color: var(--mut); font-size: 12px; margin-top: 24px; }
|
|
69
|
+
</style></head>
|
|
70
|
+
<body><div class="wrap">
|
|
71
|
+
<h1>Fidelity report — ${esc(report.host)}</h1>
|
|
72
|
+
<p class="sub">How faithfully the extracted tokens reconstruct the live components. ${esc(report.generatedAt)}</p>
|
|
73
|
+
<div class="big"><span class="n">${score == null ? '—' : score}</span><span class="l">${score == null ? 'no components scored' : 'site fidelity / 100'}</span></div>
|
|
74
|
+
${cards}
|
|
75
|
+
<footer>Rebuilt using only designlang's extracted tokens. Lower scores point to where tokenization lost information — see the heatmaps. Generated by <b>designlang verify</b>.</footer>
|
|
76
|
+
</div></body></html>`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Pixel-diff two PNG buffers and return a differing-pixel ratio + a heatmap.
|
|
2
|
+
//
|
|
3
|
+
// Sizes rarely match (a snapped-down padding makes the rebuild smaller), so we
|
|
4
|
+
// letterbox both onto a common canvas anchored top-left, padding with white.
|
|
5
|
+
// We never STRETCH — a stretched compare would smear the score into fiction.
|
|
6
|
+
// Padding asymmetry is real signal: if the rebuild is smaller, the uncovered
|
|
7
|
+
// margin shows up as loss, which is correct.
|
|
8
|
+
|
|
9
|
+
import pixelmatch from 'pixelmatch';
|
|
10
|
+
import { PNG } from 'pngjs';
|
|
11
|
+
|
|
12
|
+
function pad(png, width, height, fill = 255) {
|
|
13
|
+
if (png.width === width && png.height === height) return png;
|
|
14
|
+
const out = new PNG({ width, height });
|
|
15
|
+
out.data.fill(fill); // opaque white canvas
|
|
16
|
+
for (let i = 3; i < out.data.length; i += 4) out.data[i] = 255;
|
|
17
|
+
PNG.bitblt(png, out, 0, 0, Math.min(png.width, width), Math.min(png.height, height), 0, 0);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// aBuf/bBuf: PNG file buffers. Returns { ratio, width, height, diffPixels, heatmap }.
|
|
22
|
+
export function diffPngBuffers(aBuf, bBuf, { threshold = 0.1 } = {}) {
|
|
23
|
+
const a = PNG.sync.read(aBuf);
|
|
24
|
+
const b = PNG.sync.read(bBuf);
|
|
25
|
+
const width = Math.max(a.width, b.width);
|
|
26
|
+
const height = Math.max(a.height, b.height);
|
|
27
|
+
|
|
28
|
+
const pa = pad(a, width, height);
|
|
29
|
+
const pb = pad(b, width, height);
|
|
30
|
+
const out = new PNG({ width, height });
|
|
31
|
+
|
|
32
|
+
const diffPixels = pixelmatch(pa.data, pb.data, out.data, width, height, {
|
|
33
|
+
threshold,
|
|
34
|
+
includeAA: false,
|
|
35
|
+
alpha: 0.25,
|
|
36
|
+
diffColor: [255, 0, 80],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const total = width * height;
|
|
40
|
+
return {
|
|
41
|
+
ratio: total ? diffPixels / total : 1,
|
|
42
|
+
width,
|
|
43
|
+
height,
|
|
44
|
+
diffPixels,
|
|
45
|
+
heatmap: PNG.sync.write(out),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Convenience: ratio → 0–100 fidelity, rounded.
|
|
50
|
+
export function ratioToFidelity(ratio) {
|
|
51
|
+
return Math.max(0, Math.min(100, Math.round((1 - ratio) * 100)));
|
|
52
|
+
}
|
|
@@ -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
|
+
}
|