@trueclone/cli 0.0.0-stage → 0.2.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.
@@ -0,0 +1,114 @@
1
+ // REPORT.md for a clone: every score against its target, the weakest spots, and what was not (or cannot be)
2
+ // copied. Reads the latest score files under sites/<name>/compare and the capture under sites/<name>/reference;
3
+ // `trueclone score` writes it at the end of every run.
4
+ //
5
+ // usage: node scripts/report.mjs <name>
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { parseArgs } from './lib.mjs';
9
+
10
+ const { pos } = parseArgs(process.argv.slice(2));
11
+ const [name] = pos;
12
+ if (!name) {
13
+ console.error('usage: trueclone report <name>');
14
+ process.exit(1);
15
+ }
16
+ const root = path.resolve('sites', name);
17
+ const read = (rel) => fs.readFile(path.join(root, rel), 'utf8').then(JSON.parse, () => null);
18
+ const meta = await read('reference/meta.json');
19
+ if (!meta) {
20
+ console.error(`no capture for ${name}: run trueclone capture <url> ${name} first`);
21
+ process.exit(1);
22
+ }
23
+
24
+ // Targets a clone has to meet (the same ones the skill gives the agent).
25
+ const T = { layout: 98, statesGap: 5, motion: 95, detail: 95, hovers: 90 };
26
+ const mark = (ok) => (ok === null ? '–' : ok ? 'pass' : '**below target**');
27
+ const pct = (v) => (v === undefined || v === null ? '–' : `${v}%`);
28
+ const VPS = ['desktop', 'tablet', 'mobile'];
29
+
30
+ const layout = await read('compare/report.mask.json');
31
+ const visual = await read('compare/report.json');
32
+ const states = await read('compare/states/report.mask.json');
33
+ const curves = Object.fromEntries(await Promise.all(VPS.map(async (v) => [v, await read(`compare/curves.${v}.json`)])));
34
+ const interact = await read('compare/interact/report.json');
35
+ const hovers = await read('compare/hovers.desktop.json');
36
+ const detail = await read('compare/detail.json');
37
+ const motionInv = await read('reference/motion.desktop.json');
38
+
39
+ const byVp = (rep, key = 'match') => Object.fromEntries((rep?.viewports || []).map((v) => [v.viewport, v[key] ?? v.score]));
40
+ const L = byVp(layout), V = byVp(visual), S = byVp(states), D = byVp(detail, 'score');
41
+ const C = Object.fromEntries(VPS.map((v) => [v, curves[v]?.score]));
42
+
43
+ const rows = [];
44
+ const row = (check, values, ok, note = '') => rows.push(`| ${check} | ${VPS.map((v) => pct(values[v])).join(' | ')} | ${mark(ok)} | ${note} |`);
45
+ const present = (vals) => VPS.filter((v) => vals[v] !== undefined && vals[v] !== null);
46
+ const all = (vals, test) => (present(vals).length ? present(vals).every((v) => test(vals[v], v)) : null);
47
+ row(`Layout (structure)`, L, all(L, (x) => x >= T.layout), `target ≥ ${T.layout}%; text and media masked`);
48
+ if (meta.mode === 'exact') row('Layout (real pixels)', V, all(V, (x) => x >= T.layout), 'exact mode: same text and media');
49
+ row('Scroll states', S, all(S, (x, v) => L[v] === undefined || x >= L[v] - T.statesGap), `within ${T.statesGap} points of layout`);
50
+ row('Motion curves', C, all(C, (x) => x >= T.motion), present(C).length ? `target ≥ ${T.motion}%` : 'no motion map (no scroll or time animation to score)');
51
+ row('Detail (styles)', D, all(D, (x) => x >= T.detail), `target ≥ ${T.detail}%; font, size, weight, line height, colour, boxes`);
52
+ row('Hovers', { desktop: hovers?.score }, hovers ? hovers.score >= T.hovers : null, `target ≥ ${T.hovers}% of the reference hover effects`);
53
+ const scenarios = interact?.scenarios || [];
54
+ const failed = scenarios.filter((s) => s.expect && !s.expect.pass);
55
+ row('Interactions', { desktop: scenarios.length ? +(scenarios.reduce((n, s) => n + (s.score || 0), 0) / scenarios.length).toFixed(2) : undefined }, scenarios.length ? failed.length === 0 : null,
56
+ scenarios.length ? `${scenarios.length - failed.length}/${scenarios.length} scenarios change something on both sides` : 'no interactions.json');
57
+
58
+ const failing = rows.filter((r) => r.includes('below target')).length;
59
+ const verdict = failing ? `${failing} check(s) below target` : 'every scored check meets its target';
60
+
61
+ // Weakest spots, to fix first.
62
+ const weak = [];
63
+ for (const v of VPS) for (const s of (layout?.viewports.find((x) => x.viewport === v)?.worstSections || []).slice(0, 2)) if (s.match < 99) weak.push(`- Layout ${v}: section "${String(s.label).slice(0, 50)}" at y ${s.y} is ${s.match}%`);
64
+ for (const v of VPS) for (const e of (curves[v]?.entries || []).filter((x) => x.score < 90).slice(0, 3)) weak.push(`- Motion ${v}: "${e.name}" ${e.score}%${e.note ? ` (${e.note})` : ''}`);
65
+ for (const s of failed) weak.push(`- Interaction "${s.name}": the expected change happened on ${s.expect.ref ? 'the reference' : 'neither side'}${s.expect.clone ? '' : ', not on the clone'}`);
66
+ for (const v of detail?.viewports || []) {
67
+ const groups = new Map();
68
+ for (const it of v.issues || []) { const k = it.props[0].split(' ').slice(0, 1)[0]; groups.set(k, (groups.get(k) || 0) + 1); }
69
+ const top = [...groups].sort((a, b) => b[1] - a[1]).slice(0, 3);
70
+ if (top.length) weak.push(`- Detail ${v.viewport}: ${top.map(([k, n]) => `${n}× ${k === 'no' ? 'missing element' : k}`).join(', ')}`);
71
+ }
72
+
73
+ // What the clone does not copy: server-side behaviour, tracking, and the original's data/API calls.
74
+ let calls = [];
75
+ try {
76
+ const har = JSON.parse(await fs.readFile(path.join(root, 'reference', 'desktop.har'), 'utf8'));
77
+ calls = har.log.entries.filter((e) => ['xhr', 'fetch'].includes(e._resourceType)).map((e) => ({ method: e.request.method, url: e.request.url, status: e.response.status, type: e.response.content.mimeType || '' }));
78
+ } catch { /* no HAR */ }
79
+ const TRACKING = /google-analytics|googletagmanager|doubleclick|facebook\.(com|net)|hotjar|segment\.(io|com)|plausible|cloudflareinsights|clarity\.ms|sentry|datadog|mixpanel|amplitude|posthog|intercom|hubspot|linkedin\.com\/px|tiktok|challenges\.cloudflare|cdn-cgi\/rum|recaptcha|hcaptcha|turnstile/i;
80
+ const tracking = calls.filter((c) => TRACKING.test(c.url));
81
+ const data = calls.filter((c) => !TRACKING.test(c.url));
82
+ const host = (u) => { try { return new URL(u).host; } catch { return u; } };
83
+ const uniq = (list) => [...new Map(list.map((c) => [`${c.method} ${c.url.split('?')[0]}`, c])).values()];
84
+
85
+ const libs = motionInv ? Object.entries(motionInv.libs || {}).filter(([, v]) => v).map(([k]) => k) : [];
86
+ const md = `# trueclone report: ${name}
87
+
88
+ | | |
89
+ |---|---|
90
+ | Reference | ${meta.url} |
91
+ | Mode | ${meta.mode === 'exact' ? `exact (the site's own text and media). Permission: ${meta.permission}` : 'template (structure, motion and behaviour; placeholder text and media)'} |
92
+ | Captured | ${meta.capturedAt} |
93
+ | Scored | ${layout?.at || '–'}${layout?.clone && !/127\.0\.0\.1:\d+\/$/.test(layout.clone) ? ` against ${layout.clone}` : ''} |
94
+ | Verdict | **${verdict}** |
95
+
96
+ ## Scores
97
+
98
+ | Check | Desktop | Tablet | Mobile | Status | Notes |
99
+ |---|---|---|---|---|---|
100
+ ${rows.join('\n')}
101
+
102
+ ${weak.length ? `## Fix first\n\n${weak.join('\n')}\n` : '## Fix first\n\nNothing below 99% on layout, 90% on motion or failing.\n'}
103
+ ## Motion found on the reference
104
+
105
+ ${motionInv ? `Libraries: ${libs.length ? libs.join(', ') : 'none (no GSAP, Lenis, Swiper or similar)'}. ScrollTriggers: ${motionInv.triggers?.length ?? 0}. Tweens created while scrolling: ${motionInv.onScroll?.length ?? 0}. CSS animations: ${(motionInv.css || []).length}.` : 'Not inventoried yet (trueclone motion <name>).'}
106
+
107
+ ## Not copied
108
+
109
+ - **Server-side behaviour** is never visible from the outside: databases, accounts, payments, search indexes. The clone reproduces the client.
110
+ - **Tracking, analytics and bot protection** are left out on purpose${tracking.length ? `: ${[...new Set(tracking.map((c) => { try { const u = new URL(c.url); return `${u.host}${u.pathname.split('/').slice(0, 3).join('/')}`; } catch { return host(c.url); } }))].join(', ')}` : ''}.
111
+ ${data.length ? `- **Data and API calls** the reference makes (${uniq(data).length}), which the clone does not call; their captured responses are in reference/desktop.har:\n${uniq(data).slice(0, 15).map((c) => ` - \`${c.method} ${c.url.split('?')[0].slice(0, 110)}\` → ${c.status} ${c.type.split(';')[0]}`).join('\n')}` : '- **Data and API calls**: none seen during the capture (static content).'}
112
+ `;
113
+ await fs.writeFile(path.join(root, 'REPORT.md'), md);
114
+ console.log(`${verdict} -> ${path.relative(process.cwd(), path.join(root, 'REPORT.md'))}`);
@@ -0,0 +1,191 @@
1
+ // Record one-shot scroll reveals on the reference, frame by frame, so the clone can replay them exactly.
2
+ // A fresh page is scrolled down like a visitor would; every element that sat below the fold in a hidden or offset
3
+ // state (opacity ~0, a translate, a scale, a blur) is watched with requestAnimationFrame, and when it starts to
4
+ // change its opacity, translate, scale and blur are sampled every frame until they settle. The result is the
5
+ // reveal's real curve (delay, duration, easing, stagger included), whatever library drew it: Framer Motion, GSAP,
6
+ // AOS, IntersectionObserver plus CSS transitions. `trueclone seed` marks these elements in the clone and adds a
7
+ // small player that replays the curves with the Web Animations API when they scroll into view.
8
+ //
9
+ // Elements are identified by their path of element children, skipping script/style/meta-like tags, which is
10
+ // the same in the live page and in the script-free mirror the clone is seeded from.
11
+ //
12
+ // usage: node scripts/reveals.mjs <name> [--viewport desktop] -> sites/<name>/reference/reveals.<vp>.json
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, waitStill, wheelTo } from './lib.mjs';
16
+
17
+ const { pos, flags } = parseArgs(process.argv.slice(2));
18
+ const [name] = pos;
19
+ if (!name) {
20
+ console.error('usage: trueclone reveals <name> [--viewport desktop]');
21
+ process.exit(1);
22
+ }
23
+ const root = path.resolve('sites', name);
24
+ const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
25
+ const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
26
+
27
+ // Runs in the page before any scroll: picks the candidates and starts the frame sampler.
28
+ function watchInPage() {
29
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
30
+ const pathOf = (el) => {
31
+ const parts = [];
32
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
33
+ const sibs = [...n.parentElement.children].filter((c) => !SKIP.has(c.tagName));
34
+ parts.unshift(`${n.tagName.toLowerCase()}:${sibs.indexOf(n) + 1}`);
35
+ }
36
+ return parts.join('>');
37
+ };
38
+ const read = (el) => {
39
+ const s = getComputedStyle(el);
40
+ const m = new DOMMatrixReadOnly(s.transform === 'none' ? undefined : s.transform);
41
+ const blur = /blur\(([\d.]+)px\)/.exec(s.filter);
42
+ return { o: +(+s.opacity).toFixed(3), tx: +m.m41.toFixed(2), ty: +m.m42.toFixed(2), s: +Math.hypot(m.m11, m.m12).toFixed(4), blur: blur ? +blur[1] : 0 };
43
+ };
44
+ const hiddenish = (st) => st.o <= 0.05 || Math.hypot(st.tx, st.ty) >= 8 || Math.abs(st.s - 1) >= 0.04 || st.blur > 0;
45
+ const candidates = [];
46
+ for (const el of document.body.querySelectorAll('*')) {
47
+ if (SKIP.has(el.tagName) || el.closest('svg') && el.tagName !== 'svg') continue;
48
+ const r = el.getBoundingClientRect();
49
+ if (r.top < innerHeight || r.width < 8 || r.height < 8) continue;
50
+ const st = read(el);
51
+ if (!hiddenish(st)) continue;
52
+ // continuous motion (marquees, loops) is not a reveal: skip anything running an endless animation
53
+ const endless = (e) => e.getAnimations().some((a) => a.effect?.getTiming().iterations === Infinity);
54
+ if (endless(el) || (el.parentElement && endless(el.parentElement))) continue;
55
+ // keep the outermost element of a revealing group (a hidden card hides its children too)
56
+ if (candidates.some((c) => c.el.contains(el))) continue;
57
+ candidates.push({ el, path: pathOf(el), from: st, frames: null, start: null, topAtStart: null, last: JSON.stringify(st), still: 0, done: false });
58
+ }
59
+ window.__tcReveals = candidates;
60
+ const tick = (now) => {
61
+ for (const c of candidates) {
62
+ if (c.done) continue;
63
+ const st = read(c.el);
64
+ const key = JSON.stringify(st);
65
+ if (c.frames === null) {
66
+ if (key !== c.last) {
67
+ c.frames = [{ t: 0, ...c.from }, { t: 16, ...st }];
68
+ c.start = now - 16;
69
+ const r = c.el.getBoundingClientRect();
70
+ c.topAtStart = +(r.top / innerHeight).toFixed(3);
71
+ c.last = key;
72
+ }
73
+ continue;
74
+ }
75
+ c.frames.push({ t: Math.round(now - c.start), ...st });
76
+ c.still = key === c.last ? c.still + 1 : 0;
77
+ c.last = key;
78
+ if (c.still >= 8 || now - c.start > 6000) {
79
+ c.frames = c.frames.slice(0, c.frames.length - c.still + 1); // drop the trailing identical frames
80
+ c.done = true;
81
+ }
82
+ }
83
+ if (candidates.some((c) => !c.done)) requestAnimationFrame(tick);
84
+ };
85
+ requestAnimationFrame(tick);
86
+ return candidates.length;
87
+ }
88
+
89
+ // Records the first class change of each element while the page scrolls, with where it happened.
90
+ function watchClassesInPage() {
91
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
92
+ const pathOf = (el) => {
93
+ const parts = [];
94
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
95
+ const sibs = [...n.parentElement.children].filter((c) => !SKIP.has(c.tagName));
96
+ parts.unshift(`${n.tagName.toLowerCase()}:${sibs.indexOf(n) + 1}`);
97
+ }
98
+ return parts.join('>');
99
+ };
100
+ const pinned = (el) => { for (let n = el; n && n !== document.body; n = n.parentElement) { const p = getComputedStyle(n).position; if (p === 'fixed' || p === 'sticky') return true; } return false; };
101
+ window.__tcClasses = [];
102
+ window.__tcClassIdle = true;
103
+ const idleChanged = new Set();
104
+ const seen = new Set();
105
+ new MutationObserver((records) => {
106
+ for (const r of records) {
107
+ const el = r.target;
108
+ if (!(el instanceof Element) || !el.isConnected || el === document.body || el === document.documentElement) continue;
109
+ if (window.__tcClassIdle) { idleChanged.add(el); continue; }
110
+ if (idleChanged.has(el) || seen.has(el)) continue;
111
+ const now = el.getAttribute('class');
112
+ if (now === r.oldValue) continue;
113
+ seen.add(el);
114
+ const rect = el.getBoundingClientRect();
115
+ window.__tcClasses.push({ el, path: pathOf(el), from: r.oldValue, to: now, scrollY: Math.round(scrollY), ratio: +(rect.top / innerHeight).toFixed(3), sticky: pinned(el) });
116
+ }
117
+ }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'], attributeOldValue: true });
118
+ }
119
+
120
+ const browser = await openBrowser(flags.cdp);
121
+ let result;
122
+ try {
123
+ const { page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
124
+ await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
125
+ await page.mouse.move(5, vp.height - 5);
126
+ await waitStill(page, { timeout: 15000, interval: 200 });
127
+ const count = await page.evaluate(watchInPage);
128
+ // Class changes caused by scrolling (a header that gets its backdrop, an "is-visible" toggle): watched with a
129
+ // MutationObserver. Anything that changes while the page sits idle (timers, carousels) is ignored.
130
+ await page.evaluate(watchClassesInPage);
131
+ await page.waitForTimeout(1500);
132
+ await page.evaluate(() => { window.__tcClassIdle = false; });
133
+ // scroll down a third of a window at a time and give each reveal time to play
134
+ const max = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
135
+ for (let y = 0; y <= max; y += Math.round(vp.height / 3)) {
136
+ await wheelTo(page, y, vp);
137
+ await page.waitForTimeout(450);
138
+ }
139
+ await page.waitForTimeout(2500);
140
+ // back to the top: does each class change revert (scroll state) or stay (one-shot)?
141
+ await page.evaluate(() => window.scrollTo(0, 0));
142
+ await page.waitForTimeout(1200);
143
+ // class order changes when a site removes and re-adds classes: compare the sets
144
+ // It reverts when the classes the scroll removed are back; classes added once (a one-time animation) may stay, so the
145
+ // state after returning ('back') is recorded too and used by the clone after the first scroll.
146
+ await page.evaluate(() => {
147
+ const toks = (v) => String(v || '').split(/\s+/).filter(Boolean);
148
+ for (const c of window.__tcClasses) {
149
+ c.back = c.el.getAttribute('class');
150
+ const removed = toks(c.from).filter((t) => !toks(c.to).includes(t));
151
+ c.reverts = removed.length ? removed.every((t) => toks(c.back).includes(t)) : !toks(c.back).some((t) => toks(c.to).includes(t) && !toks(c.from).includes(t));
152
+ c.marks = toks(c.to).filter((t) => !toks(c.back).includes(t)); // classes that only exist while scrolled
153
+ }
154
+ });
155
+ // sticky/fixed elements switch at a scroll position: find it precisely by bisection
156
+ const stickyCount = await page.evaluate(() => window.__tcClasses.filter((c) => c.sticky && c.reverts).length);
157
+ for (let i = 0; i < stickyCount; i++) {
158
+ let lo = 0, hi = await page.evaluate((k) => window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].scrollY, i);
159
+ while (hi - lo > 2) {
160
+ const mid = Math.round((lo + hi) / 2);
161
+ await page.evaluate((y) => window.scrollTo(0, y), mid);
162
+ await page.waitForTimeout(160);
163
+ const on = await page.evaluate((k) => { const c = window.__tcClasses.filter((x) => x.sticky && x.reverts)[k]; const now = String(c.el.getAttribute('class') || '').split(/\s+/); return c.marks.length > 0 && c.marks.every((t) => now.includes(t)); }, i);
164
+ if (on) hi = mid; else lo = mid;
165
+ }
166
+ await page.evaluate(([k, y]) => { window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].threshold = y; }, [i, hi]);
167
+ }
168
+ const classes = await page.evaluate(() => window.__tcClasses.map((c) => ({ path: c.path, from: c.from, to: c.to, back: c.back, sticky: c.sticky, reverts: c.reverts, ...(c.sticky ? { threshold: c.threshold ?? c.scrollY } : { trigger: c.ratio }) })));
169
+ // a reveal settles; anything still moving at the 6s cap is continuous motion driven some other way
170
+ result = await page.evaluate(() => window.__tcReveals.filter((c) => c.frames && c.frames.length > 2 && c.frames[c.frames.length - 1].t < 5900).map((c) => {
171
+ const last = c.frames[c.frames.length - 1];
172
+ // keep at most ~60 keyframes: enough for any easing, small enough for the clone
173
+ const step = Math.max(1, Math.ceil(c.frames.length / 60));
174
+ const frames = c.frames.filter((f, i) => i % step === 0 || i === c.frames.length - 1);
175
+ return { path: c.path, cls: String(c.el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.'), from: c.from, to: { o: last.o, tx: last.tx, ty: last.ty, s: last.s, blur: last.blur }, trigger: c.topAtStart, duration: last.t, frames };
176
+ }));
177
+ console.log(`${count} candidates below the fold, ${result.length} revealed while scrolling`);
178
+ result.classes = classes;
179
+ console.log(`${classes.length} class change(s) caused by scrolling: ${classes.filter((c) => c.sticky).length} at a scroll position (sticky/fixed), ${classes.filter((c) => !c.sticky).length} when in view; ${classes.filter((c) => c.reverts).length} revert on the way back up`);
180
+ } finally {
181
+ if (!flags.cdp) await browser.close();
182
+ }
183
+ const out = path.join(root, 'reference', `reveals.${vp.name}.json`);
184
+ await fs.writeFile(out, JSON.stringify({ url: meta.url, viewport: vp, at: new Date().toISOString(), reveals: result, classes: result.classes }, null, 2));
185
+ const groups = new Map();
186
+ for (const r of result) {
187
+ const k = `${JSON.stringify(r.from)} -> ${Math.round(r.duration / 50) * 50}ms`;
188
+ groups.set(k, (groups.get(k) || 0) + 1);
189
+ }
190
+ for (const [k, n] of [...groups].sort((a, b) => b[1] - a[1]).slice(0, 8)) console.log(` ${String(n).padStart(3)}x from ${k}`);
191
+ console.log(`-> ${path.relative(process.cwd(), out)}`);
@@ -0,0 +1,109 @@
1
+ // CSS rules for one element, like the DevTools Styles pane: every rule that matches the element and its
2
+ // ::before / ::after, in source order, with the @media / @supports / @layer it sits in and where it came from.
3
+ // Build from the original's real CSS instead of approximating what the computed values look like
4
+ // (e.g. a "capsize" ::before/::after text trim is not the same as text-box-trim inside flex and grid).
5
+ // Cross-origin stylesheets (CDNs) are fetched outside the page and parsed, so they are included too.
6
+ //
7
+ // usage: node scripts/rules.mjs <name> "<selector>[@n]" [--viewport desktop] [--clone] [--url <page>]
8
+ // node scripts/rules.mjs <name> --text "Car number" (the element whose own text starts with this)
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, settle, serve, waitStill } from './lib.mjs';
12
+
13
+ const { pos, flags } = parseArgs(process.argv.slice(2));
14
+ const [name, target] = pos;
15
+ if (!name || (!target && typeof flags.text !== 'string')) {
16
+ console.error('usage: npm run rules -- <name> "<selector>[@n]" [--viewport desktop] [--clone] | <name> --text "label"');
17
+ process.exit(1);
18
+ }
19
+ const root = path.resolve('sites', name);
20
+ const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
21
+ const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
22
+ const server = flags.clone && typeof flags.url !== 'string' ? await serve(path.join(root, 'clone')) : null;
23
+ const url = typeof flags.url === 'string' ? flags.url : server ? server.url : meta.url;
24
+
25
+ const browser = await openBrowser(flags.cdp);
26
+ try {
27
+ const { page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
28
+ await page.goto(url, { waitUntil: 'load', timeout: 60000 });
29
+ await waitStill(page, { timeout: 15000, interval: 200 });
30
+ await settle(page);
31
+
32
+ // Sheets the page cannot read (cross-origin without CORS): fetch them here, parse them in the page.
33
+ const blocked = await page.evaluate(() => [...document.styleSheets].map((s, i) => {
34
+ try { void s.cssRules; return null; } catch { return { i, href: s.href, media: s.media.mediaText }; }
35
+ }).filter(Boolean));
36
+ for (const b of blocked) {
37
+ const text = await page.context().request.get(b.href).then((r) => r.text()).catch(() => '');
38
+ await page.evaluate(([b, text]) => {
39
+ const sheet = new CSSStyleSheet({ media: b.media || undefined });
40
+ sheet.replaceSync(text.replace(/@import[^;]+;/g, ''));
41
+ (window.__labSheets ||= []).push({ i: b.i, href: b.href, sheet });
42
+ }, [b, text]);
43
+ }
44
+
45
+ const result = await page.evaluate(([target, text]) => {
46
+ let el;
47
+ if (text) {
48
+ el = [...document.body.querySelectorAll('*')].find((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().startsWith(text)));
49
+ } else {
50
+ const [sel, idx] = target.split('@');
51
+ el = document.querySelectorAll(sel)[Number(idx || 0)];
52
+ }
53
+ if (!el) return null;
54
+ // split a selector list on top-level commas only (":is(a, b)" stays whole)
55
+ const split = (s) => { const out = []; let depth = 0, cur = ''; for (const ch of s) { if (ch === '(' || ch === '[') depth++; if (ch === ')' || ch === ']') depth--; if (ch === ',' && !depth) { out.push(cur.trim()); cur = ''; } else cur += ch; } if (cur.trim()) out.push(cur.trim()); return out; };
56
+ const PSEUDO = /::?(before|after)\b/;
57
+ const found = { element: [], before: [], after: [] };
58
+ const sheets = [...document.styleSheets].map((s, i) => {
59
+ if (s.ownerNode?.hasAttribute?.('data-lab-freeze')) return { rules: [], source: 'lab' }; // the lab's own freeze styles
60
+ const own = (() => { try { return s.cssRules; } catch { return null; } })();
61
+ const parsed = (window.__labSheets || []).find((x) => x.i === i);
62
+ return { rules: own || parsed?.sheet.cssRules || [], source: s.href ? s.href.split('/').pop().split('?')[0] : `inline <style> #${i}` };
63
+ });
64
+ const walk = (rules, source, ctx) => {
65
+ for (const r of rules) {
66
+ if (r instanceof CSSMediaRule) { if (matchMedia(r.conditionText).matches) walk(r.cssRules, source, [...ctx, `@media ${r.conditionText}`]); continue; }
67
+ if (r instanceof CSSSupportsRule) { if (CSS.supports(r.conditionText)) walk(r.cssRules, source, [...ctx, `@supports ${r.conditionText}`]); continue; }
68
+ if (window.CSSLayerBlockRule && r instanceof CSSLayerBlockRule) { walk(r.cssRules, source, [...ctx, `@layer ${r.name}`]); continue; }
69
+ if (!(r instanceof CSSStyleRule)) continue;
70
+ for (const part of split(r.selectorText)) {
71
+ const m = part.match(PSEUDO);
72
+ if (!m && /::?(marker|placeholder|selection|first-line|first-letter|-webkit-|-moz-)/.test(part)) continue; // other pseudo-elements
73
+ const base = part.replace(/::?(before|after|marker|placeholder|selection|first-line|first-letter)\b/g, '').trim() || '*';
74
+ let hit = false;
75
+ try { hit = el.matches(base); } catch { /* selector the engine does not support */ }
76
+ if (!hit) continue;
77
+ (found[m ? m[1] : 'element']).push({ selector: part, where: [source, ...ctx].join(' '), css: r.style.cssText });
78
+ break;
79
+ }
80
+ }
81
+ };
82
+ sheets.forEach((s) => walk(s.rules, s.source, []));
83
+ const inline = el.getAttribute('style');
84
+ if (inline) found.element.push({ selector: 'style=""', where: 'inline attribute', css: inline });
85
+ const pick = (s) => ({ display: s.display, size: `${s.width} x ${s.height}`, margin: s.margin, padding: s.padding, font: `${s.fontWeight} ${s.fontSize}/${s.lineHeight} ${s.fontFamily.split(',')[0]}`, color: s.color, background: s.backgroundColor, transform: s.transform, content: s.content });
86
+ const r = el.getBoundingClientRect();
87
+ return {
88
+ el: `${el.tagName.toLowerCase()}.${String(el.getAttribute('class') || '').split(/\s+/).join('.')}`,
89
+ box: `${Math.round(r.width)} x ${Math.round(r.height)} at page y ${Math.round(r.top + scrollY)}`,
90
+ computed: { element: pick(getComputedStyle(el)), before: pick(getComputedStyle(el, '::before')), after: pick(getComputedStyle(el, '::after')) },
91
+ found,
92
+ };
93
+ }, [target || null, typeof flags.text === 'string' ? flags.text : null]);
94
+
95
+ if (!result) { console.error(`nothing matched ${target || flags.text} on ${url}`); process.exitCode = 1; }
96
+ else {
97
+ console.log(`${result.el} (${result.box}) on ${flags.clone ? 'the clone' : url}, ${vp.name}`);
98
+ for (const part of ['element', 'before', 'after']) {
99
+ const rules = result.found[part];
100
+ const c = result.computed[part];
101
+ if (part !== 'element' && !rules.length && c.content === 'none') continue;
102
+ console.log(`\n${part === 'element' ? 'element' : `::${part}`} computed: ${Object.entries(c).filter(([k, v]) => v && v !== 'none' && v !== 'normal' && !(k === 'content' && part === 'element')).map(([k, v]) => `${k} ${v}`).join(' | ')}`);
103
+ for (const r of rules) console.log(` ${r.selector} { ${r.css} }\n ${r.where}`);
104
+ }
105
+ }
106
+ } finally {
107
+ if (!flags.cdp) await browser.close();
108
+ server?.close();
109
+ }
@@ -0,0 +1,82 @@
1
+ // One command for all scores, with a time limit per step and a summary at the end.
2
+ // --quick layout (all viewports) + live states (desktop) + interactions: the fast loop while building
3
+ // (full) layout + states (all) + motion curves (desktop/tablet/mobile) + interactions + hovers + detail
4
+ // --url <clone url> score another build (e.g. the Next.js app); --refresh-ref re-measures the reference
5
+ // A step that runs past its limit is killed and reported as TIMEOUT; the rest still run.
6
+ // Steps run in three parallel groups (layout+states, the three curve viewports, interact+hovers+detail): each
7
+ // tool mostly waits on smooth scroll and settling, so running them side by side costs little. --serial runs
8
+ // one at a time (for debugging a flaky score).
9
+ //
10
+ // usage: node scripts/score.mjs <name> [--quick] [--url <clone url>] [--refresh-ref] [--step-minutes 20] [--serial]
11
+ import { spawn } from 'node:child_process';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { parseArgs } from './lib.mjs';
15
+
16
+ // steps are the sibling scripts of this file, wherever the package is installed; sites/ is in the current folder
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ const { pos, flags } = parseArgs(process.argv.slice(2));
20
+ const [name] = pos;
21
+ if (!name) {
22
+ console.error('usage: npm run score -- <name> [--quick] [--url <clone url>] [--refresh-ref] [--serial]');
23
+ process.exit(1);
24
+ }
25
+ const url = typeof flags.url === 'string' ? flags.url : null;
26
+ const refresh = flags['refresh-ref'] ? ['--refresh-ref'] : [];
27
+ const limitMs = Number(flags['step-minutes'] || 20) * 60 * 1000;
28
+ const withUrl = (args) => (url ? [...args, '--url', url] : args);
29
+
30
+ const steps = [
31
+ { group: 1, label: 'layout', args: withUrl(['scripts/diff.mjs', name, '--structure']), pick: /│ \d+\s+│ '(\w+)'\s+│ ([\d.]+)/g, unit: 'layout %' },
32
+ { group: 1, label: 'states', args: ['scripts/states.mjs', name, '--clone', ...(url ? [url] : []), '--structure', ...(flags.quick ? ['--viewports', 'desktop'] : [])], pick: /│ \d+\s+│ '(\w+)'\s+│ ([\d.]+)/g, unit: 'states %' },
33
+ ...(flags.quick ? [] : ['desktop', 'tablet', 'mobile'].map((v) => ({ group: 2, label: `curves ${v}`, args: withUrl(['scripts/curves.mjs', name, '--viewport', v, ...refresh]), pick: /motion score \((\w+)\): ([\d.]+)%/g, unit: 'motion %' }))),
34
+ { group: 3, label: 'interact', args: withUrl(['scripts/interact.mjs', name, ...refresh]), pick: /│ \d+\s+│ '([^']+)'\s+│ ([\d.]+)/g, unit: 'interaction %', fail: /FAIL/g },
35
+ ...(flags.quick ? [] : [
36
+ { group: 3, label: 'hovers', args: withUrl(['scripts/hovers.mjs', name, ...refresh]), pick: /hover match \((\w+)\): ([\d.]+)%/g, unit: 'hover %' },
37
+ { group: 3, label: 'detail', args: withUrl(['scripts/detail.mjs', name, '--viewport', 'all', ...refresh]), pick: /(\w+): detail match ([\d.]+)%/g, unit: 'detail %' },
38
+ ]),
39
+ ];
40
+
41
+ const runStep = (step) => new Promise((resolve) => {
42
+ const started = Date.now();
43
+ const [file, ...args] = step.args;
44
+ const child = spawn(process.execPath, [path.join(HERE, path.basename(file)), ...args], { cwd: process.cwd() });
45
+ let out = '';
46
+ child.stdout.on('data', (d) => { out += d; });
47
+ child.stderr.on('data', (d) => { out += d; });
48
+ const timer = setTimeout(() => {
49
+ out += '\nTIMEOUT';
50
+ if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F']);
51
+ else child.kill('SIGKILL');
52
+ }, limitMs);
53
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, ms: Date.now() - started }); });
54
+ });
55
+
56
+ const summary = [];
57
+ const t0 = Date.now();
58
+ const finish = (step, { code, out, ms }) => {
59
+ const values = [...out.matchAll(step.pick)].map((m) => `${m[1]} ${m[2]}`);
60
+ const fails = step.fail ? (out.match(step.fail) || []).length : 0;
61
+ const timedOut = out.includes('\nTIMEOUT');
62
+ const errors = (out.match(/PAGE ERROR[^\n]*/g) || []).slice(0, 2);
63
+ const warnings = (out.match(/WARNING[^\n]*/g) || []).slice(0, 2);
64
+ const status = timedOut ? 'TIMEOUT' : code !== 0 ? `EXIT ${code}` : fails ? `${fails} FAIL` : 'ok';
65
+ console.log(` ${step.label.padEnd(16)} ${status} in ${(ms / 1000).toFixed(0)}s ${values.join(' ')}`);
66
+ if (code !== 0 && !timedOut) console.log(out.split('\n').filter(Boolean).slice(-4).map((l) => ` ${l}`).join('\n'));
67
+ [...errors, ...warnings].forEach((l) => console.log(` ${l}`));
68
+ summary.push({ step: step.label, status, seconds: Math.round(ms / 1000), [step.unit]: values.join(' ') || '-' });
69
+ };
70
+ const groups = flags.serial
71
+ ? steps.map((st) => [st])
72
+ : [...new Set(steps.map((st) => st.group))].map((g) => steps.filter((st) => st.group === g));
73
+ for (const group of groups) {
74
+ console.log(`running: ${group.map((st) => st.label).join(', ')}`);
75
+ await Promise.all(group.map(async (step) => finish(step, await runStep(step))));
76
+ }
77
+ summary.sort((x, y) => steps.findIndex((st) => st.label === x.step) - steps.findIndex((st) => st.label === y.step));
78
+ console.log(`\n${flags.quick ? 'quick' : 'full'} score for ${name}${url ? ` (${url})` : ''} in ${((Date.now() - t0) / 60000).toFixed(1)} min`);
79
+ console.table(summary.map((s) => ({ step: s.step, status: s.status, 's': s.seconds, result: Object.entries(s).find(([k]) => k.endsWith('%'))?.[1] })));
80
+ // REPORT.md: every score against its target, what to fix first, and what was not copied
81
+ const report = await runStep({ args: ['report.mjs', name] });
82
+ console.log(report.out.trim());