@trueclone/cli 0.0.0-stage → 0.1.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,225 @@
1
+ // Detail diff: style-level comparison by page position, no mapping needed. Once the layout matches (diff ≥ 97%),
2
+ // the element at a given page coordinate on the reference corresponds to the element at the same coordinate on
3
+ // the clone. For every reference text element and every styled box (background, border, radius) this probes the
4
+ // clone at the same point and compares computed styles: font family, size, weight, line height, letter spacing,
5
+ // transform, colour, background, radius, padding. Only style values are compared, never content.
6
+ //
7
+ // usage: node scripts/detail.mjs <name> [--viewport desktop|tablet|mobile|all] [--url <clone url>] [--max 400]
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, settle, serve, cached, waitStill } from './lib.mjs';
11
+
12
+ const { pos, flags } = parseArgs(process.argv.slice(2));
13
+ const [name] = pos;
14
+ if (!name) {
15
+ console.error('usage: npm run detail -- <name> [--viewport desktop|all] [--url <clone url>]');
16
+ process.exit(1);
17
+ }
18
+ const root = path.resolve('sites', name);
19
+ const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
20
+ const wanted = !flags.viewport || flags.viewport === 'all' ? VIEWPORTS.map((v) => v.name) : String(flags.viewport).split(',');
21
+ const MAX = Number(flags.max || 400);
22
+ const server = typeof flags.url === 'string' ? null : await serve(path.join(root, 'clone'));
23
+ const cloneUrl = server ? server.url : flags.url;
24
+
25
+ // Styles that define "detail". Colours are compared as rgba with alpha.
26
+ function styleOf(el) {
27
+ const s = getComputedStyle(el);
28
+ return {
29
+ family: s.fontFamily.split(',')[0].replace(/["']/g, '').trim().toLowerCase(),
30
+ size: parseFloat(s.fontSize), weight: Number(s.fontWeight), lh: s.lineHeight === 'normal' ? parseFloat(s.fontSize) * 1.2 : parseFloat(s.lineHeight),
31
+ ls: s.letterSpacing === 'normal' ? 0 : parseFloat(s.letterSpacing), tt: s.textTransform,
32
+ color: s.color, bg: s.backgroundColor, radius: parseFloat(s.borderTopLeftRadius) || 0,
33
+ pad: [s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft].map(parseFloat),
34
+ opacity: Number(s.opacity),
35
+ };
36
+ }
37
+
38
+ // Reference probes: text elements and styled boxes in the current viewport, as page coordinates plus the scroll
39
+ // position they were seen at. The clone is probed at that same scroll position, so sticky and pinned content
40
+ // (which moves with the scroll) is compared where it actually sits on screen.
41
+ function collectProbes(max) {
42
+ const own = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
43
+ const out = [];
44
+ const scroll = Math.round(scrollY);
45
+ for (const el of document.body.querySelectorAll('*')) {
46
+ if (el.__labProbed) continue;
47
+ if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG', 'PATH', 'IMG', 'VIDEO', 'CANVAS', 'HTML', 'BODY', 'MAIN', 'SECTION', 'FOOTER', 'HEADER'].includes(el.tagName.toUpperCase())) continue;
48
+ const s = getComputedStyle(el);
49
+ if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) continue;
50
+ // content of a closed <details> (and other content-visibility: hidden subtrees) still reports a box but is
51
+ // never painted: checkVisibility() is the browser's own answer
52
+ if (el.checkVisibility && !el.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true, opacityProperty: true })) continue;
53
+ const r = el.getBoundingClientRect();
54
+ const midY = r.top + r.height / 2;
55
+ if (r.width < 6 || r.height < 6 || midY < 0 || midY >= innerHeight) continue;
56
+ // Only what a visitor can actually see: not clipped away by an overflow ancestor, not inside a fixed
57
+ // overlay (menus, cursors), not under a hidden or fully transparent ancestor.
58
+ let hidden = false;
59
+ const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
60
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
61
+ const ps = getComputedStyle(p);
62
+ if (ps.position === 'fixed' || ps.visibility === 'hidden' || +ps.opacity === 0) { hidden = true; break; }
63
+ if (ps.overflow !== 'visible' || ps.overflowX !== 'visible' || ps.overflowY !== 'visible') {
64
+ const pr = p.getBoundingClientRect();
65
+ if (cx < pr.left || cx > pr.right || cy < pr.top || cy > pr.bottom) { hidden = true; break; }
66
+ }
67
+ }
68
+ if (hidden) continue;
69
+ // Split text (one span per character or word, for hover/reveal animations): probe the container once,
70
+ // not every letter; otherwise one button label becomes 20 probes aimed at characters.
71
+ const sib = el.parentElement ? [...el.parentElement.children].filter((c) => c.tagName === el.tagName) : [];
72
+ if (own(el) && el.textContent.trim().length <= 2 && sib.length >= 3) {
73
+ const parent = el.parentElement;
74
+ if (!parent.__labSplit) {
75
+ parent.__labSplit = true;
76
+ const pr = parent.getBoundingClientRect();
77
+ out.push({ kind: 'text', x: Math.round(pr.left + pr.width / 2), xs: [pr.left + pr.width / 2, pr.left + Math.min(8, pr.width / 2), pr.right - Math.min(8, pr.width / 2)].map(Math.round), y: Math.round(pr.top + scrollY + Math.min(pr.height / 2, 12)), w: Math.round(pr.width), h: Math.round(pr.height), sel: `${parent.tagName.toLowerCase()}.${String(parent.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.')} (split)`, scroll, style: window.__styleOf(el) });
78
+ }
79
+ continue;
80
+ }
81
+ const text = own(el);
82
+ const alpha = (c) => { const m = c.match(/[\d.]+/g) || []; return m.length > 3 ? +m[3] : m.length ? 1 : 0; };
83
+ // a box only counts if it is visible: a background with some alpha, or a border
84
+ const box = !text && (alpha(s.backgroundColor) > 0.02 || parseFloat(s.borderTopWidth) > 0) && r.width < innerWidth * 0.95;
85
+ if (!text && !box) continue;
86
+ // The reference's style is read from the element itself; the clone is probed at three points for text
87
+ // (left, centre, right: covers left/right/centre-aligned text of different lengths), the centre for boxes.
88
+ const xs = text ? [r.left + r.width / 2, r.left + Math.min(8, r.width / 2), r.right - Math.min(8, r.width / 2)] : [r.left + r.width / 2];
89
+ out.push({ kind: text ? 'text' : 'box', x: Math.round(r.left + r.width / 2), xs: xs.map(Math.round), y: Math.round(r.top + scrollY + Math.min(r.height / 2, 12)), w: Math.round(r.width), h: Math.round(r.height), sel: `${el.tagName.toLowerCase()}.${String(el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.')}`, scroll, style: window.__styleOf(el) });
90
+ el.__labProbed = true;
91
+ if (out.length >= max) break;
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // At a page point, the element of the requested kind (deepest first).
97
+ function probeAt([xs, py, kind, W, H]) {
98
+ const vy = py - scrollY;
99
+ if (vy < 0 || vy >= innerHeight) return { offscreen: true };
100
+ const own = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
101
+ const styled = (e) => { const s = getComputedStyle(e); return s.backgroundColor !== 'rgba(0, 0, 0, 0)' || parseFloat(s.borderTopLeftRadius) > 0 || parseFloat(s.borderTopWidth) > 0; };
102
+ const gap = (e) => { const r = e.getBoundingClientRect(); return Math.abs(r.width - W) + Math.abs(r.height - H); };
103
+ let el = null, stack = [];
104
+ // text boxes are cap-trimmed (often ~10px tall), so allow a few px of vertical drift
105
+ const dys = kind === 'text' ? [0, -3, 3, -6, 6] : [0];
106
+ outer: for (const dy of dys) {
107
+ for (const px of xs) {
108
+ if (vy + dy < 0 || vy + dy >= innerHeight) continue;
109
+ stack = document.elementsFromPoint(px, vy + dy);
110
+ // boxes: of the styled elements under the point, the one closest in size to the reference element
111
+ el = kind === 'text' ? stack.find(own) : stack.filter((e) => !['HTML', 'BODY'].includes(e.tagName) && styled(e)).sort((a, b) => gap(a) - gap(b))[0];
112
+ if (!el && kind === 'text') {
113
+ // split text (a span per character, e.g. a typing prompt): the point can fall between characters, so take
114
+ // the split container under it and read the style of its first character
115
+ const split = stack.find((e) => e.children.length >= 3 && [...e.children].filter(own).length >= 3 && [...e.children].every((c) => c.textContent.trim().length <= 2));
116
+ if (split) el = [...split.children].find(own);
117
+ }
118
+ if (el) break outer;
119
+ }
120
+ }
121
+ if (!el) return { none: true, top: stack[0] ? `${stack[0].tagName.toLowerCase()}.${String(stack[0].getAttribute('class') || '').split(' ')[0]}` : null };
122
+ const r = el.getBoundingClientRect();
123
+ return { sel: `${el.tagName.toLowerCase()}.${String(el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.')}`, w: Math.round(r.width), h: Math.round(r.height), style: window.__styleOf(el) };
124
+ }
125
+
126
+ async function sample(browser, url, vp, probes) {
127
+ // Motion on, as visitors see it: many originals ignore prefers-reduced-motion, while a clone that honours it
128
+ // shows a different (unpinned, unrevealed) layout. CSS transitions are frozen so states snap.
129
+ const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
130
+ await page.goto(url, { waitUntil: 'load', timeout: 60000 });
131
+ await page.mouse.move(5, vp.height - 5);
132
+ await waitStill(page, { timeout: 15000, interval: 200 }); // intros and loaders first
133
+ await settle(page);
134
+ // hit tests (elementsFromPoint) skip pointer-events:none layers, and text overlays often are
135
+ await page.addStyleTag({ content: '*{pointer-events:auto!important}' });
136
+ await page.evaluate(`window.__styleOf = ${styleOf.toString()}`);
137
+ const go = async (y) => {
138
+ await page.evaluate((yy) => window.scrollTo(0, yy), y);
139
+ await waitStill(page, { timeout: 3000 }); // scroll-triggered reveals and pins finish
140
+ return page.evaluate(() => Math.round(scrollY));
141
+ };
142
+ if (!probes) {
143
+ // reference: walk the page a viewport at a time (with overlap), collecting what is on screen
144
+ // Each point is then measured exactly as the clone will be (hit-tested at the same scroll), so both sides are
145
+ // read the same way: an inline <strong> under the point is the <strong> on both pages, a point between split
146
+ // characters misses on both, and an identical page scores 100% by construction.
147
+ const list = [];
148
+ const results = [];
149
+ const max = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
150
+ for (let y = 0; list.length < MAX; y += vp.height - 80) {
151
+ await go(Math.min(y, max));
152
+ const found = await page.evaluate(collectProbes, MAX - list.length);
153
+ for (const p of found) results.push(await page.evaluate(probeAt, [p.xs || [p.x], p.y, p.kind, p.w, p.h]));
154
+ list.push(...found);
155
+ if (y >= max) break;
156
+ }
157
+ await context.close();
158
+ return { list, results };
159
+ }
160
+ const list = probes;
161
+ const results = new Array(list.length);
162
+ let at = null;
163
+ for (const [p, i] of list.map((q, k) => [q, k]).sort((x, y) => x[0].scroll - y[0].scroll)) {
164
+ if (at === null || Math.abs(at - p.scroll) > 1) at = await go(p.scroll);
165
+ results[i] = await page.evaluate(probeAt, [p.xs || [p.x], p.y, p.kind, p.w, p.h]);
166
+ }
167
+ await context.close();
168
+ return { list, results };
169
+ }
170
+
171
+ const near = (a, b, tol) => Math.abs(a - b) <= tol;
172
+ const rgba = (c) => (c.match(/[\d.]+/g) || []).map(Number).concat([1]).slice(0, 4);
173
+ const colorDist = (a, b) => { const x = rgba(a), y = rgba(b); return Math.abs(x[0] - y[0]) + Math.abs(x[1] - y[1]) + Math.abs(x[2] - y[2]) + Math.abs(x[3] - y[3]) * 255; };
174
+
175
+ const browser = await openBrowser(flags.cdp);
176
+ const report = { name, at: new Date().toISOString(), viewports: [] };
177
+ try {
178
+ for (const vpName of wanted) {
179
+ const vp = VIEWPORTS.find((v) => v.name === vpName);
180
+ // reference probes are cached until the next capture (--refresh-ref to re-measure)
181
+ const ref = await cached(root, { tool: 'detail', vp: vpName, max: MAX, v: 8 }, () => sample(browser, meta.url, vp, null), Boolean(flags['refresh-ref']));
182
+ const cl = await sample(browser, cloneUrl, vp, ref.list.map(({ x, xs, y, kind, w, h, scroll }) => ({ x, xs, y, kind, w, h, scroll })));
183
+ const issues = [];
184
+ let clean = 0, missing = 0;
185
+ ref.list.forEach((p, i) => {
186
+ const a = ref.results[i], b = cl.results[i];
187
+ if (!a?.style) return;
188
+ if (!b?.style) { missing++; issues.push({ y: p.y, kind: p.kind, ref: a.sel, clone: b?.top || '(nothing)', props: [`no ${p.kind} element at this point on the clone`] }); return; }
189
+ const A = a.style, B = b.style, props = [];
190
+ if (p.kind === 'text') {
191
+ if (A.family !== B.family) props.push(`font ${A.family} vs ${B.family}`);
192
+ if (!near(A.size, B.size, 0.6)) props.push(`size ${A.size} vs ${B.size}px`);
193
+ if (A.weight !== B.weight) props.push(`weight ${A.weight} vs ${B.weight}`);
194
+ if (!near(A.lh, B.lh, 1)) props.push(`line-height ${A.lh.toFixed(1)} vs ${B.lh.toFixed(1)}px`);
195
+ if (!near(A.ls, B.ls, 0.2)) props.push(`letter-spacing ${A.ls} vs ${B.ls}px`);
196
+ if (A.tt !== B.tt) props.push(`transform ${A.tt} vs ${B.tt}`);
197
+ if (colorDist(A.color, B.color) > 24) props.push(`color ${A.color} vs ${B.color}`);
198
+ } else {
199
+ if (colorDist(A.bg, B.bg) > 24) props.push(`background ${A.bg} vs ${B.bg}`);
200
+ if (!near(A.radius, B.radius, 1)) props.push(`radius ${A.radius} vs ${B.radius}px`);
201
+ if (!near(a.w, b.w, 3) || !near(a.h, b.h, 3)) props.push(`size ${a.w}x${a.h} vs ${b.w}x${b.h}`);
202
+ }
203
+ if (props.length) issues.push({ y: p.y, kind: p.kind, ref: a.sel, clone: b.sel, props });
204
+ else clean++;
205
+ });
206
+ const total = ref.list.filter((p, i) => ref.results[i]?.style).length;
207
+ const byProp = {};
208
+ issues.forEach((it) => it.props.forEach((pr) => { const k = pr.split(' ')[0] === 'no' ? 'missing element' : pr.split(' ')[0]; byProp[k] = (byProp[k] || 0) + 1; }));
209
+ report.viewports.push({ viewport: vpName, probes: total, clean, missing, score: +(100 * clean / Math.max(1, total)).toFixed(1), byProp, issues });
210
+ }
211
+ } finally {
212
+ if (!flags.cdp) await browser.close();
213
+ server?.close();
214
+ }
215
+ const out = path.join(root, 'compare', 'detail.json');
216
+ await fs.mkdir(path.dirname(out), { recursive: true });
217
+ await fs.writeFile(out, JSON.stringify(report, null, 2));
218
+ for (const v of report.viewports) {
219
+ console.log(`\n${v.viewport}: detail match ${v.score}% (${v.clean}/${v.probes} probes clean, ${v.missing} with no counterpart) by property: ${JSON.stringify(v.byProp)}`);
220
+ // group identical issues so one root cause prints once with a count
221
+ const groups = new Map();
222
+ for (const it of v.issues) { const k = `${it.ref} -> ${it.clone}: ${it.props.join('; ')}`; const g = groups.get(k) || { ...it, n: 0 }; g.n++; groups.set(k, g); }
223
+ [...groups.values()].sort((a, b) => b.n - a.n).slice(0, 18).forEach((g) => console.log(` x${String(g.n).padEnd(3)} y${String(g.y).padEnd(6)} ${g.ref} -> ${g.clone}: ${g.props.join('; ')}`));
224
+ }
225
+ console.log(`-> ${path.relative(process.cwd(), out)}`);
@@ -0,0 +1,72 @@
1
+ // Render the clone exactly like the reference and score it: whole-page and per-section pixel match.
2
+ // usage: node scripts/diff.mjs <name> [--structure] [--url http://localhost:5173/] [--cdp]
3
+ // Without --url it serves sites/<name>/clone/ itself on a free local port.
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, settle, mask, serve, pad, crop, match, PNG } from './lib.mjs';
7
+
8
+ const { pos, flags } = parseArgs(process.argv.slice(2));
9
+ const [name] = pos;
10
+ if (!name) {
11
+ console.error('usage: npm run diff -- <name> [--url <dev server url>]');
12
+ process.exit(1);
13
+ }
14
+ const root = path.resolve('sites', name);
15
+ const ref = path.join(root, 'reference');
16
+ const out = path.join(root, 'compare');
17
+ const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
18
+ await fs.mkdir(out, { recursive: true });
19
+
20
+ // --structure: compare layout only (text and media masked on both sides), for placeholder-content clones.
21
+ const structure = Boolean(flags.structure);
22
+ const suffix = structure ? '.mask' : '';
23
+ const server = flags.url ? null : await serve(path.join(root, 'clone'));
24
+ const target = typeof flags.url === 'string' ? flags.url : server.url;
25
+ const browser = await openBrowser(flags.cdp);
26
+ const report = { name, mode: structure ? 'structure' : 'visual', source: meta.url, clone: target, at: new Date().toISOString(), viewports: [] };
27
+ try {
28
+ for (const vp of VIEWPORTS) {
29
+ const { context, page } = await newPage(browser, vp);
30
+ await page.goto(target, { waitUntil: 'load', timeout: 60000 });
31
+ await settle(page);
32
+ if (structure) await mask(page);
33
+ const clonePath = path.join(out, `${vp.name}${suffix}.clone.png`);
34
+ await page.screenshot({ path: clonePath, fullPage: true });
35
+ await context.close();
36
+
37
+ const a0 = PNG.sync.read(await fs.readFile(path.join(ref, `${vp.name}${suffix}.png`)));
38
+ const b0 = PNG.sync.read(await fs.readFile(clonePath));
39
+ const w = Math.max(a0.width, b0.width), h = Math.max(a0.height, b0.height);
40
+ const a = pad(a0, w, h), b = pad(b0, w, h);
41
+ const diff = new PNG({ width: w, height: h });
42
+ const bad = match(a, b, diff);
43
+ await fs.writeFile(path.join(out, `${vp.name}${suffix}.diff.png`), PNG.sync.write(diff));
44
+
45
+ const sections = JSON.parse(await fs.readFile(path.join(ref, `${vp.name}.sections.json`), 'utf8'));
46
+ const perSection = sections
47
+ // sections can start above the page (a skip link parked at y -80) or run past it: score the visible part
48
+ .filter((s) => s.height > 0 && s.y < h && s.y + s.height > 0)
49
+ .map((s) => {
50
+ const y0 = Math.max(0, s.y);
51
+ const sh = Math.min(s.y + s.height, h) - y0;
52
+ const miss = match(crop(a, y0, sh), crop(b, y0, sh));
53
+ return { index: s.index, label: s.heading || s.id || s.classes || s.tag, y: s.y, height: s.height, match: +(100 - (miss / (w * sh)) * 100).toFixed(2) };
54
+ });
55
+ report.viewports.push({
56
+ viewport: vp.name,
57
+ match: +(100 - (bad / (w * h)) * 100).toFixed(2),
58
+ heightRef: a0.height,
59
+ heightClone: b0.height,
60
+ worstSections: [...perSection].sort((x, y) => x.match - y.match).slice(0, 5),
61
+ sections: perSection,
62
+ });
63
+ }
64
+ } finally {
65
+ if (!flags.cdp) await browser.close();
66
+ server?.close();
67
+ }
68
+
69
+ await fs.writeFile(path.join(out, `report${suffix}.json`), JSON.stringify(report, null, 2));
70
+ console.log(`mode: ${report.mode}`);
71
+ console.table(report.viewports.map((v) => ({ viewport: v.viewport, 'match %': v.match, 'ref h': v.heightRef, 'clone h': v.heightClone, worst: v.worstSections[0] ? `${v.worstSections[0].label} (${v.worstSections[0].match}%)` : '-' })));
72
+ console.log(`Diff images + report: ${path.relative(process.cwd(), out)}`);
@@ -0,0 +1,116 @@
1
+ // Hover inventory + comparison by position. Hovers every interactive element on the reference (links, buttons,
2
+ // anything with cursor:pointer), records which visual properties change on it and its first descendants, then
3
+ // hovers the same page point on the clone and checks it reacts the same way. Catches missing hover states,
4
+ // which screenshots at rest never show.
5
+ //
6
+ // usage: node scripts/hovers.mjs <name> [--viewport desktop] [--url <clone url>] [--max 40]
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, settle, serve, cached } from './lib.mjs';
10
+
11
+ const { pos, flags } = parseArgs(process.argv.slice(2));
12
+ const [name] = pos;
13
+ if (!name) {
14
+ console.error('usage: npm run hovers -- <name> [--viewport desktop] [--url <clone url>]');
15
+ process.exit(1);
16
+ }
17
+ const root = path.resolve('sites', name);
18
+ const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
19
+ const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
20
+ const MAX = Number(flags.max || 40);
21
+ const server = typeof flags.url === 'string' ? null : await serve(path.join(root, 'clone'));
22
+ const cloneUrl = server ? server.url : flags.url;
23
+
24
+ function targets(max) {
25
+ const seen = new Set();
26
+ const out = [];
27
+ for (const el of document.querySelectorAll('a, button, [role="button"], *')) {
28
+ const s = getComputedStyle(el);
29
+ if (s.cursor !== 'pointer' && !['A', 'BUTTON'].includes(el.tagName)) continue;
30
+ if (s.display === 'none' || s.visibility === 'hidden') continue;
31
+ const r = el.getBoundingClientRect();
32
+ if (r.width < 10 || r.height < 10 || r.width > innerWidth * 0.9) continue;
33
+ // one target per interactive root: skip descendants of an already listed element
34
+ let p = el.parentElement, nested = false;
35
+ while (p) { if (seen.has(p)) { nested = true; break; } p = p.parentElement; }
36
+ if (nested) continue;
37
+ seen.add(el);
38
+ out.push({ x: Math.round(r.left + r.width / 2), y: Math.round(r.top + scrollY + r.height / 2), sel: `${el.tagName.toLowerCase()}.${String(el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.')}` });
39
+ if (out.length >= max) break;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ // Signature of the interactive root under a point and its first descendants.
45
+ function signature([px, vy]) {
46
+ let el = document.elementFromPoint(px, vy);
47
+ // cursor:pointer is inherited, so prefer the nearest real control (a/button/[role=button]); otherwise the
48
+ // outermost ancestor that still has a pointer cursor
49
+ const control = el?.closest('a, button, [role="button"]');
50
+ if (control) el = control;
51
+ else {
52
+ let top = null;
53
+ for (let n = el; n && n !== document.body; n = n.parentElement) if (getComputedStyle(n).cursor === 'pointer') top = n;
54
+ el = top;
55
+ }
56
+ if (!el) return null;
57
+ const nodes = [el, ...[...el.querySelectorAll('*')].slice(0, 12)];
58
+ const props = {};
59
+ nodes.forEach((n, i) => {
60
+ const s = getComputedStyle(n);
61
+ props[i] = { color: s.color, background: s.backgroundColor, transform: s.transform, opacity: s.opacity, shadow: s.boxShadow, decoration: s.textDecorationLine, border: s.borderColor, filter: s.filter, scale: s.scale };
62
+ });
63
+ return { sel: `${el.tagName.toLowerCase()}.${String(el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.')}`, props };
64
+ }
65
+ const changedKinds = (a, b) => {
66
+ const kinds = new Set();
67
+ for (const i of Object.keys(a.props)) for (const k of Object.keys(a.props[i])) if (b.props[i] && a.props[i][k] !== b.props[i][k]) kinds.add(k === 'scale' ? 'transform' : k);
68
+ return [...kinds].sort();
69
+ };
70
+
71
+ async function hoverAll(browser, url, list) {
72
+ const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
73
+ await page.goto(url, { waitUntil: 'load', timeout: 60000 });
74
+ await settle(page);
75
+ if (!list) list = await page.evaluate(targets, MAX);
76
+ const out = [];
77
+ for (const t of list) {
78
+ const top = Math.max(0, t.y - Math.round(vp.height / 2));
79
+ await page.evaluate((y) => window.scrollTo(0, y), top);
80
+ await page.mouse.move(2, 2);
81
+ await page.waitForTimeout(400);
82
+ const vy = t.y - (await page.evaluate(() => scrollY));
83
+ const before = await page.evaluate(signature, [t.x, vy]);
84
+ await page.mouse.move(t.x, vy, { steps: 6 });
85
+ await page.waitForTimeout(800);
86
+ const after = await page.evaluate(signature, [t.x, vy]);
87
+ out.push({ sel: before?.sel || null, kinds: before && after && before.sel === after.sel ? changedKinds(before, after) : [] });
88
+ }
89
+ await context.close();
90
+ return { list, out };
91
+ }
92
+
93
+ const browser = await openBrowser(flags.cdp);
94
+ let ref, cl;
95
+ try {
96
+ // reference hovers are cached until the next capture (--refresh-ref to re-measure)
97
+ ref = await cached(root, { tool: 'hovers', vp: vp.name, max: MAX, v: 2 }, () => hoverAll(browser, meta.url, null), Boolean(flags['refresh-ref']));
98
+ cl = await hoverAll(browser, cloneUrl, ref.list);
99
+ } finally {
100
+ if (!flags.cdp) await browser.close();
101
+ server?.close();
102
+ }
103
+ const rows = ref.list.map((t, i) => {
104
+ const a = ref.out[i], b = cl.out[i];
105
+ const status = !a.kinds.length ? (b.kinds.length ? 'extra' : 'static') : !b.sel ? 'no element' : !b.kinds.length ? 'MISSING' : a.kinds.some((k) => b.kinds.includes(k)) ? 'ok' : 'different';
106
+ return { y: t.y, ref: a.sel || t.sel, refChanges: a.kinds.join(',') || '-', clone: b.sel || '-', cloneChanges: b.kinds.join(',') || '-', status };
107
+ });
108
+ const withHover = rows.filter((r) => r.refChanges !== '-');
109
+ const ok = withHover.filter((r) => r.status === 'ok').length;
110
+ const report = { name, viewport: vp.name, at: new Date().toISOString(), score: +(100 * ok / Math.max(1, withHover.length)).toFixed(1), rows };
111
+ const out = path.join(root, 'compare', `hovers.${vp.name}.json`);
112
+ await fs.mkdir(path.dirname(out), { recursive: true });
113
+ await fs.writeFile(out, JSON.stringify(report, null, 2));
114
+ console.log(`hover match (${vp.name}): ${report.score}% of ${withHover.length} reference hover effects reproduced (${rows.length} interactive elements probed)`);
115
+ console.table(rows.filter((r) => r.status !== 'static' && r.status !== 'ok').slice(0, 25));
116
+ console.log(`-> ${path.relative(process.cwd(), out)}`);
@@ -0,0 +1,184 @@
1
+ // Interaction tests: run the same scripted scenario on the reference and the clone (hover, cursor paths,
2
+ // drag, click, keys, waits), screenshot named moments, and compare them. Each scenario can also assert that
3
+ // an element actually changed (moved, faded, resized, new text, state attribute or canvas pixels) on both sides, so "the button exists but does nothing"
4
+ // fails even when screenshots happen to look alike.
5
+ //
6
+ // sites/<name>/interactions.json:
7
+ // [{ "name": "media slider drags", "viewport": "desktop",
8
+ // "at": { "ref": "div.section_media", "clone": ".media", "offset": 0 }, // scroll this element to the top first
9
+ // "steps": [ { "drag": { "from": [0.7, 0.62], "to": [0.25, 0.62] } }, { "wait": 1500 }, { "shot": "after-drag" } ],
10
+ // "expectChange": { "ref": "div.swiper-wrapper", "clone": ".media-track" },
11
+ // "compare": "structure" }] // or "visual"
12
+ // Step kinds: move [x,y] (fractions of the viewport), hover [x,y], path [[x,y],...], drag {from,to}, down/up [x,y],
13
+ // click {ref,clone} selector or [x,y], key "Escape", wait ms, shot "label".
14
+ // usage: node scripts/interact.mjs <name> [--only "<text>"] | node scripts/interact.mjs <name> --draft
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, wheelTo, maskToggle, matchPct, PNG, cached, readCache } from './lib.mjs';
18
+
19
+ const { pos, flags } = parseArgs(process.argv.slice(2));
20
+ const [name] = pos;
21
+ if (!name) {
22
+ console.error('usage: npm run interact -- <name> [--only text] | --draft');
23
+ process.exit(1);
24
+ }
25
+ const root = path.resolve('sites', name);
26
+ const ref = path.join(root, 'reference');
27
+ const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
28
+
29
+ // ---------- Draft scenarios from the motion inventory ----------
30
+ if (flags.draft) {
31
+ const motion = JSON.parse(await fs.readFile(path.join(ref, 'motion.desktop.json'), 'utf8'));
32
+ const drafts = [];
33
+ for (const s of motion.swipers) {
34
+ drafts.push({ name: `slider drag ${s.el.sel}`, viewport: 'desktop', at: { ref: s.el.section || s.el.sel, clone: 'TODO', offset: 0 },
35
+ steps: [{ drag: { from: [0.7, 0.6], to: [0.25, 0.6] } }, { wait: 1500 }, { shot: 'after-drag' }], expectChange: { ref: `${s.el.sel} .swiper-wrapper`, clone: 'TODO' }, compare: 'structure' });
36
+ }
37
+ const pointerTargets = [...new Set(motion.pointer.map((p) => p.target.sel))];
38
+ for (const sel of pointerTargets) {
39
+ drafts.push({ name: `cursor follow ${sel}`, viewport: 'desktop', at: { ref: 'body', clone: 'body', offset: 0 },
40
+ steps: [{ move: [0.3, 0.4] }, { wait: 1500 }, { shot: 'left' }, { path: [[0.3, 0.4], [0.5, 0.5], [0.7, 0.6]] }, { wait: 1500 }, { shot: 'right' }], expectChange: { ref: sel, clone: 'TODO' }, compare: 'structure' });
41
+ }
42
+ drafts.push({ name: 'menu opens', viewport: 'desktop', at: { ref: 'body', clone: 'body', offset: 0 }, steps: [{ click: { ref: 'TODO', clone: 'TODO' } }, { wait: 1200 }, { shot: 'open' }, { key: 'Escape' }, { wait: 1200 }], compare: 'structure' });
43
+ const out = path.join(root, 'interactions.draft.json');
44
+ await fs.writeFile(out, JSON.stringify(drafts, null, 2));
45
+ console.log(`${drafts.length} scenarios -> ${path.relative(process.cwd(), out)}. Fill in the TODOs, then save as interactions.json.`);
46
+ process.exit(0);
47
+ }
48
+
49
+ const scenarioText = await fs.readFile(path.join(root, 'interactions.json'), 'utf8').catch(() => null);
50
+ if (scenarioText === null) {
51
+ console.log('interactions: skipped, no interactions.json (run --draft, or write the scenarios by hand)');
52
+ process.exit(0);
53
+ }
54
+ let scenarios = JSON.parse(scenarioText);
55
+ if (typeof flags.only === 'string') scenarios = scenarios.filter((s) => s.name.includes(flags.only));
56
+ const outDir = path.join(root, 'compare', 'interact');
57
+ await fs.mkdir(outDir, { recursive: true });
58
+
59
+ const stateOf = (page, selector) => page.evaluate((sel) => {
60
+ const [s, idx] = sel.split('@');
61
+ const el = document.querySelectorAll(s)[Number(idx || 0)];
62
+ if (!el) return null;
63
+ const cs = getComputedStyle(el);
64
+ const r = el.getBoundingClientRect();
65
+ // Besides geometry and visibility: text, state attributes and canvas pixels, so a button that changes its label,
66
+ // a tab that flips aria-pressed or a game that redraws its canvas counts as a change too.
67
+ const attrs = ['class', 'aria-pressed', 'aria-current', 'aria-expanded', 'open', 'hidden', 'disabled']
68
+ .map((a) => (el.hasAttribute(a) ? `${a}=${el.getAttribute(a)}` : '')).join(',') // boolean attributes are "" + (el.value !== undefined ? `,${el.value}` : '');
69
+ const text = el.textContent.replace(/\s+/g, ' ').trim().slice(0, 200);
70
+ let pixels = '';
71
+ if (el.tagName === 'CANVAS') {
72
+ try { const d = el.toDataURL(); let h = 0; for (let i = 0; i < d.length; i += 7) h = (h * 31 + d.charCodeAt(i)) | 0; pixels = String(h); } catch { /* tainted canvas */ }
73
+ }
74
+ return `${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)},${Math.round(r.height)}|${cs.transform}|${cs.opacity}|${cs.visibility}|${cs.clipPath}|${attrs}|${text}|${pixels}`;
75
+ }, selector);
76
+
77
+ async function runSide(url, side, list) {
78
+ if (!list.length) return [];
79
+ const browser = await openBrowser(flags.cdp);
80
+ const results = [];
81
+ try {
82
+ for (const sc of list) {
83
+ const vp = VIEWPORTS.find((v) => v.name === (sc.viewport || 'desktop'));
84
+ const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
85
+ await page.goto(url, { waitUntil: 'load', timeout: 60000 });
86
+ await page.mouse.move(5, vp.height - 5);
87
+ await waitStill(page, { timeout: 15000, interval: 200 });
88
+ await warmUp(page);
89
+ const atSel = sc.at?.[side];
90
+ if (atSel && atSel !== 'body') {
91
+ const y = await page.evaluate((s) => { const el = document.querySelector(s); return el ? el.getBoundingClientRect().top + scrollY : 0; }, atSel);
92
+ await wheelTo(page, Math.max(0, Math.round(y + (sc.at.offset || 0))), vp);
93
+ } else {
94
+ await wheelTo(page, sc.at?.offset || 0, vp);
95
+ }
96
+ await waitStill(page, { timeout: 3000 });
97
+ const px = ([x, y]) => [Math.round(x * vp.width), Math.round(y * vp.height)];
98
+ const expectSel = sc.expectChange?.[side];
99
+ const before = expectSel ? await stateOf(page, expectSel) : null;
100
+ const shots = {};
101
+ const states = [];
102
+ const shot = async (label) => {
103
+ if (expectSel) states.push(await stateOf(page, expectSel));
104
+ const file = path.join(outDir, `${sc.name.replace(/[^a-z0-9]+/gi, '-')}.${label}.${side}${sc.compare === 'structure' ? '.mask' : ''}.png`);
105
+ if (sc.compare === 'structure') await maskToggle(page, true);
106
+ await page.screenshot({ path: file });
107
+ if (sc.compare === 'structure') await maskToggle(page, false);
108
+ shots[label] = file;
109
+ };
110
+ await shot('before');
111
+ for (const st of sc.steps) {
112
+ if (st.move || st.hover) await page.mouse.move(...px(st.move || st.hover), { steps: 15 });
113
+ else if (st.path) for (const p of st.path) await page.mouse.move(...px(p), { steps: 20 });
114
+ else if (st.drag) {
115
+ await page.mouse.move(...px(st.drag.from), { steps: 5 });
116
+ await page.mouse.down();
117
+ await page.mouse.move(...px(st.drag.to), { steps: 25 });
118
+ await page.mouse.up();
119
+ } else if (st.down) { await page.mouse.move(...px(st.down)); await page.mouse.down(); }
120
+ else if (st.up) { await page.mouse.move(...px(st.up), { steps: 20 }); await page.mouse.up(); }
121
+ else if (st.click) {
122
+ const target = Array.isArray(st.click) ? st.click : st.click[side];
123
+ if (Array.isArray(target)) await page.mouse.click(...px(target));
124
+ else await page.click(target, { timeout: 5000 }).catch((e) => results.push({ name: sc.name, error: `click ${target}: ${e.message.split('\n')[0]}` }));
125
+ } else if (st.key) await page.keyboard.press(st.key);
126
+ else if (st.wait) await page.waitForTimeout(st.wait);
127
+ else if (st.shot) await shot(st.shot);
128
+ }
129
+ const after = expectSel ? await stateOf(page, expectSel) : null;
130
+ const changed = expectSel ? before !== null && [...states, after].some((s) => s !== null && s !== before) : null;
131
+ results.push({ name: sc.name, shots, changed, before, after });
132
+ await context.close();
133
+ }
134
+ } finally {
135
+ if (!flags.cdp) await browser.close();
136
+ }
137
+ return results;
138
+ }
139
+
140
+ // --url scores another build of the clone (e.g. the Next.js app) instead of serving clone/
141
+ const server = typeof flags.url === 'string' ? null : await serve(path.join(root, 'clone'));
142
+ const cloneUrl = server ? server.url : flags.url;
143
+ // Reference results are cached per scenario (reference side + viewport + capture time) as long as the shot
144
+ // files still exist; only new or edited scenarios touch the live site. --refresh-ref re-measures all.
145
+ const refKey = (sc) => ({ tool: 'interact', name: sc.name, viewport: sc.viewport, at: sc.at, steps: sc.steps, expect: sc.expectChange?.ref, compare: sc.compare, click: JSON.stringify(sc.steps) });
146
+ const refRes = [];
147
+ const missing = [];
148
+ for (const sc of scenarios) {
149
+ const hit = flags['refresh-ref'] ? null : await readCache(root, refKey(sc));
150
+ const filesOk = hit && (await Promise.all(Object.values(hit.shots || {}).map((f) => fs.access(f).then(() => true, () => false)))).every(Boolean);
151
+ if (hit && filesOk) refRes.push(hit); else missing.push(sc);
152
+ }
153
+ if (missing.length) console.log(`measuring ${missing.length} reference scenario(s) (${scenarios.length - missing.length} cached)`);
154
+ for (const r of await runSide(meta.url, 'ref', missing)) {
155
+ refRes.push(r);
156
+ const sc = missing.find((m) => m.name === r.name);
157
+ if (sc && r.shots) await cached(root, refKey(sc), async () => r, true);
158
+ }
159
+ const cloneRes = await runSide(cloneUrl, 'clone', scenarios);
160
+ server?.close();
161
+
162
+ const report = { name, at: new Date().toISOString(), scenarios: [] };
163
+ for (const sc of scenarios) {
164
+ const a = refRes.find((r) => r.name === sc.name && r.shots), b = cloneRes.find((r) => r.name === sc.name && r.shots);
165
+ const errors = [...refRes, ...cloneRes].filter((r) => r.name === sc.name && r.error).map((r) => r.error);
166
+ const shots = {};
167
+ for (const label of Object.keys(a?.shots || {})) {
168
+ if (!b?.shots[label]) continue;
169
+ shots[label] = matchPct(PNG.sync.read(await fs.readFile(a.shots[label])), PNG.sync.read(await fs.readFile(b.shots[label]))).pct;
170
+ }
171
+ const labels = Object.keys(shots).filter((l) => l !== 'before');
172
+ const score = labels.length ? +(labels.reduce((n, l) => n + shots[l], 0) / labels.length).toFixed(2) : null;
173
+ const expect = sc.expectChange ? { ref: a?.changed, clone: b?.changed, pass: a?.changed === true && b?.changed === true } : null;
174
+ report.scenarios.push({ name: sc.name, score, shots, expect, errors });
175
+ }
176
+ await fs.writeFile(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2));
177
+ console.table(report.scenarios.map((s) => ({
178
+ scenario: s.name,
179
+ 'match %': s.score,
180
+ shots: Object.entries(s.shots).map(([k, v]) => `${k} ${v}`).join(' '),
181
+ 'changes (ref/clone)': s.expect ? `${s.expect.ref ? 'yes' : 'NO'} / ${s.expect.clone ? 'yes' : 'NO'}${s.expect.pass ? '' : ' FAIL'}` : '-',
182
+ errors: s.errors.join('; ') || '',
183
+ })));
184
+ console.log(`-> ${path.relative(process.cwd(), path.join(outDir, 'report.json'))}`);