@trueclone/cli 0.3.0 → 0.4.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/scripts/next.mjs CHANGED
@@ -304,21 +304,26 @@ await fs.cp(path.join(cloneDir, 'assets'), path.join(out, 'public', 'assets'), {
304
304
  for (const f of ['DESIGN.md', 'design-tokens.json']) await fs.copyFile(path.join(root, f), path.join(out, f)).catch(() => {});
305
305
  await fs.writeFile(path.join(out, 'package.json'), JSON.stringify(PKG, null, 2) + '\n');
306
306
  // custom elements (<code-island>, <swiper-container>) have no JSX type: declared here, or `next build` fails
307
+ // Types for what the page uses that React's typings don't know: custom elements (<code-island>), and HTML attributes
308
+ // newer than the typings or outside them (command, commandfor, popovertarget, a framework's own): an HTML attribute
309
+ // is valid markup whether React lists it or not, so every element accepts any attribute here.
307
310
  const custom = new Set();
308
311
  (function walk(n) { if (n.tagName && n.tagName.includes('-')) custom.add(n.tagName); for (const c of n.childNodes || []) walk(c); })(body);
309
- if (custom.size) {
310
- await fs.writeFile(path.join(out, 'custom-elements.d.ts'), `// Generated by scripts/next.mjs: the page's custom elements, typed for JSX.
311
- import type { DetailedHTMLProps, HTMLAttributes } from 'react';
312
+ await fs.rm(path.join(out, 'custom-elements.d.ts'), { force: true });
313
+ await fs.writeFile(path.join(out, 'html-types.d.ts'), `// Generated by scripts/next.mjs: the page's markup, typed for JSX as the browser accepts it.
314
+ import type { DetailedHTMLProps, HTMLAttributes as ReactHTMLAttributes } from 'react';
312
315
 
313
316
  declare module 'react' {
314
- namespace JSX {
317
+ interface HTMLAttributes<T> {
318
+ [attribute: string]: unknown;
319
+ }
320
+ ${custom.size ? ` namespace JSX {
315
321
  interface IntrinsicElements {
316
- ${[...custom].map((t) => ` ${JSON.stringify(t)}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & Record<string, unknown>;`).join('\n')}
322
+ ${[...custom].map((t) => ` ${JSON.stringify(t)}: DetailedHTMLProps<ReactHTMLAttributes<HTMLElement>, HTMLElement>;`).join('\n')}
317
323
  }
318
324
  }
319
- }
325
+ ` : ''}}
320
326
  `);
321
- } else await fs.rm(path.join(out, 'custom-elements.d.ts'), { force: true });
322
327
  await fs.writeFile(path.join(out, 'postcss.config.mjs'), "export default { plugins: { '@tailwindcss/postcss': {} } };\n");
323
328
  await fs.writeFile(path.join(out, 'next.config.mjs'), `import path from 'node:path';
324
329
  import { fileURLToPath } from 'node:url';
@@ -366,12 +371,31 @@ async function seededApp() {
366
371
  // follow the head's in document order
367
372
  const bodyStyles = [];
368
373
  (function walk(n) { for (const c of n.childNodes || []) { if (c.tagName === 'style' || c.tagName === 'link') bodyStyles.push(c); else walk(c); } })(body);
374
+ const layerOrder = []; // the site's own cascade layers, in the order its stylesheets first declare them
375
+ // a stylesheet's relative @import (patches/x.css importing ../tokens.css): the imported file comes along, renamed
376
+ // into styles/site/, and the @import points at it there
377
+ const carryImports = async (css, fromDir) => {
378
+ const out2 = [];
379
+ for (const m of css.matchAll(/@import\s+(?:url\()?\s*(['"])([^'"]+)\1\s*\)?/g)) {
380
+ const spec = m[2];
381
+ if (/^(https?:)?\/\//.test(spec) || spec.startsWith('data:') || !/\.css(\?|$)/i.test(spec)) continue;
382
+ const src = path.resolve(fromDir, spec.split('?')[0]);
383
+ const text = await fs.readFile(src, 'utf8').catch(() => null);
384
+ if (text === null) { console.warn(`WARNING @import ${spec} not found next to its stylesheet`); continue; }
385
+ const name = `imported-${path.basename(src)}`;
386
+ await fs.writeFile(path.join(out, 'styles', 'site', name), text.replace(/url\((['"]?)(?:\.\.\/)*assets\//g, (q0, q) => `url(${q}/assets/`));
387
+ out2.push([m[0], `@import "./${name}"`]);
388
+ }
389
+ for (const [a, b] of out2) css = css.split(a).join(b);
390
+ return css;
391
+ };
369
392
  for (const e of [...els(head), ...bodyStyles]) {
370
393
  const a = attrsOf(e);
371
- let css = null, file = null;
394
+ let css = null, file = null, fromDir = cloneDir;
372
395
  if (e.tagName === 'link' && a.rel === 'stylesheet' && a.href && !/^(https?:)?\/\//.test(a.href)) {
373
396
  css = await fs.readFile(path.join(cloneDir, a.href), 'utf8').catch(() => null);
374
397
  file = path.basename(a.href);
398
+ fromDir = path.dirname(path.join(cloneDir, a.href));
375
399
  } else if (e.tagName === 'style') {
376
400
  css = (e.childNodes || []).map((c) => c.value || '').join('');
377
401
  file = `inline-${++inline}.css`;
@@ -379,6 +403,8 @@ async function seededApp() {
379
403
  if (css === null) continue;
380
404
  // stylesheets lived in clone/css/ (assets at ../assets/), inline blocks in the page (assets at assets/)
381
405
  css = css.replace(/url\((['"]?)(?:\.\.\/)?assets\//g, (m, q) => `url(${q}/assets/`);
406
+ css = await carryImports(css, fromDir);
407
+ for (const m of css.matchAll(/@layer\s+([\w-]+(?:\s*,\s*[\w-]+)*)\s*[;{]/g)) for (const l of m[1].split(',').map((x) => x.trim())) if (!layerOrder.includes(l)) layerOrder.push(l);
382
408
  usesTailwind ||= /--tw-|tailwindcss v\d/.test(css);
383
409
  await fs.writeFile(path.join(out, 'styles', 'site', file), css);
384
410
  imports.push(`import '../styles/site/${file}';`);
@@ -391,7 +417,11 @@ async function seededApp() {
391
417
  const tailwind = usesTailwind ? '@import "tailwindcss" source(none);'
392
418
  : '/* no preflight: the original was not built with Tailwind and relies on browser defaults */\n@layer theme, base, components, utilities;\n@import "tailwindcss/theme.css" layer(theme);\n@import "tailwindcss/utilities.css" layer(utilities) source(none);';
393
419
  const scoped = theme.replace('@import "tailwindcss";', `${tailwind}\n@source "../components/ui";`);
394
- await fs.writeFile(path.join(out, 'app', 'globals.css'), scoped);
420
+ // Cascade layers are ordered by their first declaration anywhere in the page. globals.css loads first, so the site's
421
+ // own order goes at its very top: otherwise Tailwind's "base" is declared before the site's "reset", and the site's
422
+ // reset ends up overriding its own base.
423
+ const layerHead = layerOrder.length ? `/* the original's cascade layer order, declared first (see scripts/next.mjs) */\n@layer ${layerOrder.join(', ')};\n` : '';
424
+ await fs.writeFile(path.join(out, 'app', 'globals.css'), layerHead + scoped);
395
425
  const siteCssImports = imports;
396
426
 
397
427
  // markup
@@ -482,6 +512,30 @@ async function seededApp() {
482
512
  }
483
513
  };
484
514
  for (const a of scriptTags) if (a.src) await copyScript(a.src);
515
+ // the page's other head links (icons, font preloads, preconnects, a manifest), as in the HTML clone; local files are
516
+ // served from public/ at the same path
517
+ const headLinks = [];
518
+ for (const e of els(head)) {
519
+ // the seed's own small head scripts (tc-vw keeps --tc-vw at 1% of the layout width): they must run before paint
520
+ if (e.tagName === 'script' && /^tc-/.test(attrsOf(e).id || '') && !attrsOf(e).src) {
521
+ headLinks.push(` <script id=${JSON.stringify(attrsOf(e).id)} dangerouslySetInnerHTML={{ __html: ${JSON.stringify((e.childNodes || []).map((c) => c.value || '').join(''))} }} />`);
522
+ continue;
523
+ }
524
+ if (e.tagName !== 'link') continue;
525
+ const a = attrsOf(e);
526
+ const rel = String(a.rel || '').toLowerCase();
527
+ if (!a.href || rel === 'stylesheet' || !/\b(icon|apple-touch-icon|mask-icon|preload|prefetch|preconnect|dns-prefetch|manifest)\b/.test(rel)) continue;
528
+ if (/\bpreload\b/.test(rel) && a.as === 'script') continue; // the scripts' own preloads are written below
529
+ let href = a.href;
530
+ if (!/^(https?:)?\/\//.test(href) && !href.startsWith('data:')) {
531
+ const clean = href.replace(/^\.\//, '').split(/[?#]/)[0];
532
+ const to = path.join(out, 'public', clean);
533
+ if (!clean.startsWith('assets/')) { await fs.mkdir(path.dirname(to), { recursive: true }); await fs.copyFile(path.join(cloneDir, clean), to).catch(() => {}); }
534
+ href = `/${clean}`;
535
+ }
536
+ const attrs = Object.entries({ ...a, href }).filter(([k]) => !/^on/.test(k)).map(([k, v]) => `${k === 'crossorigin' ? 'crossOrigin' : k === 'hreflang' ? 'hrefLang' : k === 'referrerpolicy' ? 'referrerPolicy' : k === 'fetchpriority' ? 'fetchPriority' : k}=${JSON.stringify(k === 'crossorigin' && v === '' ? 'anonymous' : v)}`);
537
+ headLinks.push(` <link ${attrs.join(' ')} />`);
538
+ }
485
539
  const scriptList = scriptTags.map((a) => ({ ...(a.src ? { src: '/' + a.src.split(/[?#]/)[0].replace(/^\.\//, '') } : { code: a.code }), ...(a.type === 'module' ? { type: 'module' } : {}) }));
486
540
  await fs.writeFile(path.join(out, 'components', 'site', 'SiteScripts.tsx'), `'use client';
487
541
  // Generated by scripts/next.mjs: runs the clone's scripts (trueclone's players, inline scripts, script.js) in order,
@@ -533,6 +587,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
533
587
  return (
534
588
  <html${htmlAttrs} suppressHydrationWarning>
535
589
  <head>
590
+ ${headLinks.join('\n')}
536
591
  {/* the clone's scripts start downloading with the page; SiteScripts runs them the moment hydration is done */}
537
592
  ${scriptList.filter((s) => s.src).map((s) => ` <link rel="${s.type === 'module' ? 'modulepreload' : 'preload'}" href=${JSON.stringify(s.src)}${s.type === 'module' ? '' : ' as="script"'} />`).join('\n')}
538
593
  </head>
@@ -587,11 +642,21 @@ if (flags.verify) {
587
642
  for (let i = 0; i < 60; i++) { try { if ((await fetch(url)).ok) break; } catch { /* starting */ } await new Promise((r) => setTimeout(r, 1000)); }
588
643
  const lab = path.resolve('.');
589
644
  const score = (args) => run('node', args, { cwd: lab });
645
+ // The app is scored with the same checks, into compare-next/ and REPORT.next.md: the HTML clone's compare/ and
646
+ // REPORT.md are moved aside first and put back after, so its scores are never overwritten by the app's.
647
+ const exists = (p) => fs.stat(p).then(() => true, () => false);
648
+ const keep = [[path.join(root, 'compare'), path.join(root, '.compare-html')], [path.join(root, 'REPORT.md'), path.join(root, '.REPORT-html.md')]];
649
+ for (const [from, to] of keep) { await fs.rm(to, { recursive: true, force: true }); if (await exists(from)) await fs.rename(from, to); }
590
650
  try {
591
651
  // same scoring entry point as the HTML clone: time limit per step, summary at the end
592
652
  await score([path.join(path.dirname(toPath(import.meta.url)), 'score.mjs'), name, '--url', url, ...(flags.quick ? ['--quick'] : [])]);
593
653
  } finally {
594
654
  if (process.platform === 'win32') spawn('taskkill', ['/pid', String(server.pid), '/T', '/F']);
595
655
  else server.kill();
656
+ await fs.rm(path.join(root, 'compare-next'), { recursive: true, force: true });
657
+ if (await exists(path.join(root, 'compare'))) await fs.rename(path.join(root, 'compare'), path.join(root, 'compare-next'));
658
+ if (await exists(path.join(root, 'REPORT.md'))) await fs.rename(path.join(root, 'REPORT.md'), path.join(root, 'REPORT.next.md'));
659
+ for (const [from, to] of keep) if (await exists(to)) await fs.rename(to, from);
660
+ console.log(`Next.js app scores: ${path.relative(process.cwd(), path.join(root, 'REPORT.next.md'))} (details in compare-next/); the HTML clone's REPORT.md is unchanged`);
596
661
  }
597
662
  }
@@ -4,6 +4,7 @@
4
4
  //
5
5
  // usage: node scripts/report.mjs <name>
6
6
  import fs from 'node:fs/promises';
7
+ import fsSync from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { parseArgs } from './lib.mjs';
9
10
 
@@ -22,10 +23,14 @@ if (!meta) {
22
23
  }
23
24
 
24
25
  // 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, load: 95 };
26
+ const T = { layout: 98, states: 95, canvas: 90, motion: 95, detail: 95, hovers: 90, load: 95 };
27
+ // an accepted difference never covers a value below this, and needs evidence (a file it names that exists)
28
+ const ACCEPT_FLOOR = 95;
26
29
  const mark = (ok, acceptedNote) => (ok === null ? '–' : ok ? 'pass' : acceptedNote ? `accepted (${acceptedNote})` : '**below target**');
27
30
  const pct = (v) => (v === undefined || v === null ? '–' : `${v}%`);
28
- const VPS = ['desktop', 'tablet', 'mobile'];
31
+ // the seed sizes, then the held-out sizes (captured and scored, never used to build the clone)
32
+ const VPS = ['desktop', 'tablet', 'mobile', 'laptop', 'wide'];
33
+ const HELD = new Set(['laptop', 'wide']);
29
34
 
30
35
  const layout = await read('compare/report.mask.json');
31
36
  const visual = await read('compare/report.json');
@@ -46,8 +51,23 @@ const notesSection = (title) => {
46
51
  return m ? m[1].replace(/<!--[\s\S]*?-->/g, '').split('\n').map((l) => l.trim()).filter((l) => /^[-*]\s+\S/.test(l)).map((l) => l.replace(/^[-*]\s+/, '')) : [];
47
52
  };
48
53
  const accepted = notesSection('Accepted differences');
54
+ // review: the frames trueclone review wrote, and the verdict the agent gave each in notes.md
55
+ const reviewVerdicts = notesSection('Review');
56
+ const reviewFrames = [];
57
+ for (const size of ['wide', 'laptop', 'desktop', 'tablet', 'mobile']) {
58
+ const fr = await read(`compare/review/${size}/frames.json`);
59
+ for (const x of fr?.frames || []) {
60
+ const v = reviewVerdicts.find((l) => l.startsWith(`${x.id} ${size}:`) || l.startsWith(`${x.id} ${size} :`));
61
+ reviewFrames.push({ size, id: x.id, pct: x.pct, verdict: v ? v.slice(v.indexOf(':') + 1).trim() : null });
62
+ }
63
+ }
64
+ const reviewOpen = reviewFrames.filter((x) => !x.verdict || !/^same\b/i.test(x.verdict));
49
65
  const findings = notesSection('Findings');
50
- const acceptedFor = (check, vp) => accepted.filter((a) => new RegExp(`\\b${check}\\b`, 'i').test(a) && (!vp || new RegExp(`\\b${vp}\\b`, 'i').test(a) || !/\b(desktop|tablet|mobile)\b/i.test(a)));
66
+ // an entry needs `evidence: <file>` naming a file in the site folder (a crop, a measurement): a reason without proof
67
+ // is a gap, not a decision
68
+ const hasEvidence = (a) => { const m = /evidence:\s*([^\s;,)]+)/i.exec(a); return Boolean(m && fsSync.existsSync(path.resolve(root, m[1]))); };
69
+ const acceptedFor = (check, vp) => accepted.filter((a) => hasEvidence(a) && new RegExp(`\\b${check}\\b`, 'i').test(a) && (!vp || new RegExp(`\\b${vp}\\b`, 'i').test(a) || !/\b(desktop|tablet|mobile|laptop|wide)\b/i.test(a)));
70
+ const unproven = accepted.filter((a) => !hasEvidence(a));
51
71
 
52
72
  const byVp = (rep, key = 'match') => Object.fromEntries((rep?.viewports || []).map((v) => [v.viewport, v[key] ?? v.score]));
53
73
  const L = byVp(layout), V = byVp(visual), S = byVp(states), D = byVp(detail, 'score');
@@ -60,7 +80,8 @@ const row = (check, values, test, note = '') => {
60
80
  // below target, but every failing width has an accepted entry in notes.md: the decision is recorded, not hidden
61
81
  const key = check.split(' ')[0].toLowerCase();
62
82
  const failing = typeof test === 'function' ? present(values).filter((v) => !test(values[v], v)) : ok === false ? present(values) : [];
63
- const covered = failing.length && failing.every((v) => acceptedFor(key, v).length);
83
+ // never below the floor: an accepted difference explains the last few points, not a broken page
84
+ const covered = failing.length && failing.every((v) => (typeof values[v] !== 'number' || values[v] >= ACCEPT_FLOOR) && acceptedFor(key, v).length);
64
85
  const stale = drift?.stale && ['detail', 'hovers', 'motion', 'load', 'interactions'].includes(key) ? ' **stale capture**' : '';
65
86
  rows.push(`| ${check} | ${VPS.map((v) => pct(values[v])).join(' | ')} | ${mark(ok, covered ? acceptedFor(key)[0].slice(0, 60) : null)}${stale} | ${note} |`);
66
87
  };
@@ -68,17 +89,23 @@ const present = (vals) => VPS.filter((v) => vals[v] !== undefined && vals[v] !==
68
89
  const all = (vals, test) => (present(vals).length ? present(vals).every((v) => test(vals[v], v)) : null);
69
90
  row(`Layout (structure)`, L, (x) => x >= T.layout, `target ≥ ${T.layout}%; text and media masked`);
70
91
  if (meta.mode === 'exact') row('Layout (real pixels)', V, (x) => x >= T.layout, 'exact mode: same text and media');
71
- row('Scroll states', S, (x, v) => L[v] === undefined || x >= L[v] - T.statesGap, `within ${T.statesGap} points of layout`);
92
+ row('Scroll states', S, (x) => x >= T.states, `target ≥ ${T.states}%`);
93
+ const CV = byVp(states, 'canvas');
94
+ if (present(CV).length) row('Canvases', CV, (x) => x >= T.canvas, `target ≥ ${T.canvas}%; what each scene draws, compared in the scroll-state frames (colours and coverage)`);
72
95
  row('Motion curves', C, (x) => x >= T.motion, present(C).length ? `target ≥ ${T.motion}%` : 'no motion map (no scroll or time animation to score)');
73
96
  row('Detail (styles)', D, (x) => x >= T.detail, `target ≥ ${T.detail}%; font, size, weight, line height, colour, boxes`);
74
97
  row('Hovers', { desktop: hovers?.score }, hovers ? hovers.score >= T.hovers : null, `target ≥ ${T.hovers}% of the reference hover effects`);
75
98
  const LD = byVp(load, 'score');
76
99
  const tts = (side) => VPS.map((v) => load?.viewports.find((x) => x.viewport === v)).filter(Boolean).map((x) => (x[side]?.settledAt === null ? '>4000' : x[side]?.settledAt)).filter((x) => x !== undefined);
77
100
  row('Load sequence', LD, (x) => x >= T.load, present(LD).length ? `target ≥ ${T.load}%; how settled each side is at 0.25 to 4 s, cold cache, same network (settled at: reference ${tts('ref').map((x) => `${x}ms`).join(' / ') || '–'}, clone ${tts('clone').map((x) => `${x}ms`).join(' / ') || '–'})` : 'not measured (trueclone load <name>)');
101
+ if (reviewFrames.length) {
102
+ const sizes = [...new Set(reviewFrames.map((x) => x.size))];
103
+ rows.push(`| Review (by eye) | ${VPS.map((v) => (sizes.includes(v) ? `${reviewFrames.filter((x) => x.size === v && x.verdict && /^same\b/i.test(x.verdict)).length}/${reviewFrames.filter((x) => x.size === v).length}` : '–')).join(' | ')} | ${reviewOpen.length ? '**below target**' : 'pass'} | frames signed off "same" in notes.md (trueclone review) |`);
104
+ } else rows.push(`| Review (by eye) | – | – | – | – | – | **below target** | not done: run trueclone review <name> and give every frame a verdict in notes.md |`);
78
105
  const scenarios = interact?.scenarios || [];
79
106
  const failed = scenarios.filter((s) => s.expect && !s.expect.pass);
80
107
  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,
81
- scenarios.length ? `${scenarios.length - failed.length}/${scenarios.length} scenarios change something on both sides` : 'no interactions.json');
108
+ scenarios.length ? `${scenarios.length - failed.length}/${scenarios.length} scenarios change on both sides and end in the reference's state (≥ 95)` : 'no interactions.json');
82
109
 
83
110
  const failing = rows.filter((r) => r.includes('below target')).length;
84
111
  const acceptedCount = rows.filter((r) => r.includes('| accepted (')).length;
@@ -88,7 +115,9 @@ const verdict = (failing ? `${failing} check(s) below target` : 'every scored ch
88
115
  const weak = [];
89
116
  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}%`);
90
117
  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})` : ''}`);
91
- 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'}`);
118
+ for (const s of failed) weak.push(s.expect.ref !== null && !(s.expect.ref && s.expect.clone)
119
+ ? `- Interaction "${s.name}": the expected change happened on ${s.expect.ref ? 'the reference' : 'neither side'}${s.expect.clone ? '' : ', not on the clone'}`
120
+ : `- Interaction "${s.name}": the clone ends in a different state (${s.score}% like the reference's)`);
92
121
  for (const v of (load?.viewports || []).filter((x) => x.score < T.load)) {
93
122
  const worst = Object.entries(v.gap || {}).sort((a, b) => b[1] - a[1])[0];
94
123
  if (worst) weak.push(`- Load ${v.viewport}: at ${worst[0]}ms the clone is ${v.clone.curve[worst[0]]}% settled, the reference ${v.ref.curve[worst[0]]}% (settled at ${v.clone.settledAt ?? '>4000'}ms vs ${v.ref.settledAt ?? '>4000'}ms)`);
@@ -123,15 +152,18 @@ const md = `# trueclone report: ${name}
123
152
  | Scored | ${layout?.at || '–'}${layout?.clone && !/127\.0\.0\.1:\d+\/$/.test(layout.clone) ? ` against ${layout.clone}` : ''} |
124
153
  | Verdict | **${verdict}** |
125
154
  ${times ? `| Scoring time | ${times.totalMin} min (${times.kind}${times.only ? `, only ${times.only.join(',')}` : ''}): ${Object.entries(times.steps).filter(([, s]) => s.seconds).map(([k, s]) => `${k} ${s.seconds}s`).join(', ')}${Object.values(times.steps).some((s) => /kept/.test(s.status)) ? '; unchanged checks kept from their last run' : ''} |` : ''}
126
- ${(detail?.viewports || []).some((v) => v.unstable) || hovers?.unstable || (load?.viewports || []).some((v) => v.unstable) ? `| Not scored | ${(detail?.viewports || []).filter((v) => v.unstable).map((v) => `${v.unstable} detail probe${v.unstable > 1 ? 's' : ''} (${v.viewport})`).concat(hovers?.unstable ? [`${hovers.unstable} hover${hovers.unstable > 1 ? 's' : ''}`] : [], (load?.viewports || []).filter((v) => v.unstable).map((v) => `load ${v.viewport} (±${v.noise} between two runs)`)).join(', ')}: unstable on the reference or the clone itself (random motion, timing), excluded from the scores above |` : ''}
155
+ ${(detail?.viewports || []).some((v) => v.unstable) || hovers?.unstable || (load?.viewports || []).some((v) => v.unstable) ? `| Not scored | ${(detail?.viewports || []).filter((v) => v.unstable).map((v) => `${v.unstable} detail probe${v.unstable > 1 ? 's' : ''} (${v.viewport})`).concat(hovers?.unstable ? [`${hovers.unstable} hover${hovers.unstable > 1 ? 's' : ''}`] : [], (load?.viewports || []).filter((v) => v.unstable).map((v) => `load ${v.viewport} (±${v.ref?.noise ?? v.noise} between two runs)`)).join(', ')}: unstable on the reference itself (random motion, timing), excluded from the scores above |` : ''}
156
+ ${(load?.viewports || []).some((v) => v.cloneUnstable) ? `| Clone loads inconsistently | ${load.viewports.filter((v) => v.cloneUnstable).map((v) => `${v.viewport} (±${v.clone.noise} between two runs)`).join(', ')}: the clone's own load differs from one run to the next; scored by its worse run, since a visitor can get either |` : ''}
127
157
 
128
158
  ## Scores
129
159
 
130
- | Check | Desktop | Tablet | Mobile | Status | Notes |
131
- |---|---|---|---|---|---|
160
+ | Check | Desktop 1440 | Tablet 768 | Mobile 390 | Laptop 1280 ° | Wide 1920 ° | Status | Notes |
161
+ |---|---|---|---|---|---|---|---|
132
162
  ${rows.join('\n')}
133
163
 
134
- ${drift?.stale ? `## Stale capture\n\nThe live page differs from the capture of ${meta.capturedAt}: ${drift.viewports.filter((v) => v.stale).map((v) => `${v.viewport} ${v.livePageHeight}px tall vs ${v.capturedPageHeight}px captured, ${v.liveSections} vs ${v.capturedSections} sections`).join('; ')}. Layout and states score against the captured screenshots; detail, hovers, motion, interactions and load measure the live page and are marked stale above. Re-capture (\`trueclone capture\`, then \`states\`, \`reveals\`, \`seed --force\`) before reading them.\n\n` : ''}${accepted.length ? `## Accepted differences\n\nRecorded in notes.md with a reason; a check below target that is covered here is marked "accepted" in the table.\n\n${accepted.map((a) => `- ${a}`).join('\n')}\n\n` : ''}${weak.length ? `## Fix first\n\n${weak.join('\n')}\n` : '## Fix first\n\nNothing below 99% on layout, 90% on motion or failing.\n'}
164
+ ° held out: captured and scored, never used to build the clone. A clone right at the seed sizes and wrong here is fitted to those sizes, not a copy of the site.
165
+ ${unproven.length ? `\n${unproven.length} accepted difference(s) in notes.md name no evidence file and are not applied: ${unproven.map((a) => `"${a.slice(0, 50)}…"`).join('; ')}\n` : ''}
166
+ ${drift?.stale ? `## Stale capture\n\nThe live page differs from the capture of ${meta.capturedAt}: ${drift.viewports.filter((v) => v.stale).map((v) => `${v.viewport} ${v.livePageHeight}px tall vs ${v.capturedPageHeight}px captured, ${v.liveSections} vs ${v.capturedSections} sections`).join('; ')}. Layout and states score against the captured screenshots; detail, hovers, motion, interactions and load measure the live page and are marked stale above. Re-capture (\`trueclone capture\`, then \`states\`, \`reveals\`, \`seed --force\`) before reading them.\n\n` : ''}${accepted.length ? `## Accepted differences\n\nRecorded in notes.md with a reason; a check below target that is covered here is marked "accepted" in the table.\n\n${accepted.map((a) => `- ${a}`).join('\n')}\n\n` : ''}${reviewOpen.length ? `## Review: open frames\n\n${reviewOpen.slice(0, 40).map((x) => `- ${x.id} ${x.size}: ${x.verdict || 'no verdict yet'} (compare/review/${x.size}/${x.id}.png)`).join('\n')}\n\n` : ''}${weak.length ? `## Fix first\n\n${weak.join('\n')}\n` : '## Fix first\n\nNothing below 99% on layout, 90% on motion or failing.\n'}
135
167
  ${findings.length ? `## Findings (tool defects met on this site)\n\n${findings.map((f) => `- ${f}`).join('\n')}\n\n` : ''}## Motion found on the reference
136
168
 
137
169
  ${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>).'}
@@ -142,5 +174,7 @@ ${motionInv ? `Libraries: ${libs.length ? libs.join(', ') : 'none (no GSAP, Leni
142
174
  - **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(', ')}` : ''}.
143
175
  ${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).'}
144
176
  `;
145
- await fs.writeFile(path.join(root, 'REPORT.md'), md);
177
+ // rows left out of the summary table (an empty optional row) must not leave a blank line that splits it
178
+ const [head, ...rest] = md.split('\n## Scores');
179
+ await fs.writeFile(path.join(root, 'REPORT.md'), [head.replace(/(\|[^\n]*)\n(?:\n)+(?=\|)/g, '$1\n'), ...rest].join('\n## Scores'));
146
180
  console.log(`${verdict} -> ${path.relative(process.cwd(), path.join(root, 'REPORT.md'))}`);
@@ -205,6 +205,10 @@ async function record(vp) {
205
205
  await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
206
206
  await page.mouse.move(5, vp.height - 5);
207
207
  await waitStill(page, { timeout: 15000, interval: 200 });
208
+ // Only what scrolling does: with pointer events off, whatever scrolls under the resting pointer is never hovered,
209
+ // so a card that opens on hover is not recorded as opened by the scroll. (Wheel input still reaches the page.)
210
+ await page.addStyleTag({ content: '*, *::before, *::after { pointer-events: none !important; }' });
211
+ await page.mouse.move(vp.width - 2, Math.round(vp.height / 2));
208
212
  const count = await page.evaluate(watchInPage);
209
213
  // Class changes caused by scrolling (a header that gets its backdrop, an "is-visible" toggle): watched with a
210
214
  // MutationObserver. Anything that changes while the page sits idle (timers, carousels) is ignored.
@@ -0,0 +1,149 @@
1
+ // Review: the reference and the clone side by side, the way a person looks at a website, at a size the clone was never
2
+ // built from (default: the held-out 1920x1080). Frames: the load at 0.3, 1.5 and 4.5 s, then every section (and every
3
+ // screen of a tall one) after scrolling to it with the wheel, each also with the pointer resting on the control nearest
4
+ // the middle of the screen. Each frame is one image, reference left, clone right, in compare/review/<size>/, listed in
5
+ // compare/review/<size>/index.md with its pixel match.
6
+ //
7
+ // The agent looks at every frame and writes a verdict per frame in notes.md under "## Review":
8
+ // - 03-section-2 wide: same
9
+ // - 07-section-4-hover wide: differs: the panel opens on the wrong card
10
+ // REPORT.md counts the frames signed off; "differs" and frames without a verdict are listed as open.
11
+ //
12
+ // usage: node scripts/review.mjs <name> [--size wide|laptop|desktop|tablet|mobile] [--url <clone url>]
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { SCORED, parseArgs, openBrowser, newPage, serve, matchPct, PNG } 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 review <name> [--size wide] [--url <clone url>]');
21
+ process.exit(1);
22
+ }
23
+ const root = path.resolve('sites', name);
24
+ const ref = path.join(root, 'reference');
25
+ const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
26
+ const vp = SCORED.find((v) => v.name === (flags.size || 'wide'));
27
+ if (!vp) { console.error(`unknown size ${flags.size}: ${SCORED.map((v) => v.name).join(', ')}`); process.exit(1); }
28
+ const server = typeof flags.url === 'string' ? null : await serve(path.join(root, 'clone'));
29
+ const cloneUrl = server ? `${server.url}index.html` : flags.url;
30
+ const outDir = path.join(root, 'compare', 'review', vp.name);
31
+ await fs.rm(outDir, { recursive: true, force: true });
32
+ await fs.mkdir(outDir, { recursive: true });
33
+
34
+ // the frame plan comes from the reference at this size: section tops, and a frame per screen in tall sections
35
+ const sections = JSON.parse(await fs.readFile(path.join(ref, `${vp.name}.sections.json`), 'utf8').catch(() => 'null'));
36
+ if (!sections) { console.error(`no capture at ${vp.name} (${vp.width}x${vp.height}): re-capture with trueclone 0.4+`); process.exit(1); }
37
+ const plan = [];
38
+ sections.forEach((s, i) => {
39
+ const top = Math.max(0, s.y);
40
+ const screens = Math.max(1, Math.min(4, Math.round((s.height || s.h || vp.height) / vp.height)));
41
+ for (let k = 0; k < screens; k++) plan.push({ label: `section-${i + 1}${screens > 1 ? `-${k + 1}` : ''}`, y: top + k * vp.height });
42
+ });
43
+ const frames = plan.slice(0, 30);
44
+
45
+ // the control nearest the middle of the screen: links, buttons, pointer-cursor elements, cards with a hover listener
46
+ function hoverPoint() {
47
+ let best = null;
48
+ for (const el of document.querySelectorAll('a, button, [role="button"], [role="tab"], summary, *')) {
49
+ const s = getComputedStyle(el);
50
+ if (s.cursor !== 'pointer' && !['A', 'BUTTON', 'SUMMARY'].includes(el.tagName)) continue;
51
+ const r = el.getBoundingClientRect();
52
+ if (r.width * r.height < 1500 || r.bottom < 60 || r.top > innerHeight - 20 || r.width > innerWidth * 0.9) continue;
53
+ const cx = r.left + r.width / 2, cy = Math.min(Math.max(r.top + r.height / 2, 70), innerHeight - 20);
54
+ const d = Math.hypot(cx - innerWidth / 2, cy - innerHeight / 2);
55
+ if (!best || d < best.d) best = { x: Math.round(cx), y: Math.round(cy), d };
56
+ }
57
+ return best;
58
+ }
59
+
60
+ async function shoot(url, side) {
61
+ const { context, page } = await newPage(await browserP, vp, { reducedMotion: 'no-preference' });
62
+ const shots = {};
63
+ try {
64
+ const t0 = Date.now();
65
+ const nav = page.goto(url, { waitUntil: 'load', timeout: 60000 }).catch(() => null);
66
+ for (const t of [300, 1500, 4500]) {
67
+ const wait = t0 + t - Date.now();
68
+ if (wait > 0) await page.waitForTimeout(wait);
69
+ shots[`load-${t}`] = await page.screenshot().catch(() => null);
70
+ }
71
+ await nav;
72
+ await page.waitForTimeout(1500);
73
+ const scrollTo = async (y) => {
74
+ // with the wheel, the way a visitor scrolls: scroll-linked scenes and smooth scrolling follow wheel input
75
+ await page.mouse.move(Math.round(vp.width * 0.98), Math.round(vp.height * 0.5));
76
+ const now = await page.evaluate(() => scrollY);
77
+ const n = Math.max(1, Math.ceil(Math.abs(y - now) / 400));
78
+ for (let i = 0; i < n; i++) { await page.mouse.wheel(0, (y - now) / n); await page.waitForTimeout(60); }
79
+ await page.waitForTimeout(1600);
80
+ };
81
+ for (const f of frames) {
82
+ await scrollTo(f.y);
83
+ shots[f.label] = await page.screenshot();
84
+ const at = side === 'ref' ? await page.evaluate(hoverPoint) : hoverPoints[f.label];
85
+ if (side === 'ref') hoverPoints[f.label] = at;
86
+ if (at) {
87
+ await page.mouse.move(at.x - 30, at.y, { steps: 4 });
88
+ await page.mouse.move(at.x, at.y, { steps: 6 });
89
+ await page.waitForTimeout(1100);
90
+ shots[`${f.label}-hover`] = await page.screenshot();
91
+ await page.mouse.move(Math.round(vp.width * 0.98), Math.round(vp.height * 0.5));
92
+ }
93
+ }
94
+ } finally {
95
+ await context.close();
96
+ }
97
+ return shots;
98
+ }
99
+
100
+ const hoverPoints = {};
101
+ const browserP = openBrowser(flags.cdp);
102
+ const browser = await browserP;
103
+ let refShots, cloneShots;
104
+ try {
105
+ refShots = await shoot(meta.url, 'ref');
106
+ cloneShots = await shoot(cloneUrl, 'clone');
107
+ } finally {
108
+ if (!flags.cdp) await browser.close();
109
+ server?.close();
110
+ }
111
+
112
+ // one image per frame: reference | clone, half size, with the frame's pixel match
113
+ const lines = [];
114
+ let n = 0;
115
+ for (const label of Object.keys(refShots)) {
116
+ const a = refShots[label] && PNG.sync.read(refShots[label]);
117
+ const b = cloneShots[label] && PNG.sync.read(cloneShots[label]);
118
+ if (!a || !b) continue;
119
+ const scale = 0.5, w = Math.round(vp.width * scale), h = Math.round(vp.height * scale);
120
+ const img = new PNG({ width: w * 2 + 12, height: h });
121
+ img.data.fill(255);
122
+ [a, b].forEach((src, k) => {
123
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
124
+ const sx = Math.min(src.width - 1, Math.round(x / scale)), sy = Math.min(src.height - 1, Math.round(y / scale));
125
+ const si = (sy * src.width + sx) * 4, di = (y * img.width + x + k * (w + 12)) * 4;
126
+ img.data[di] = src.data[si]; img.data[di + 1] = src.data[si + 1]; img.data[di + 2] = src.data[si + 2]; img.data[di + 3] = 255;
127
+ }
128
+ });
129
+ const id = `${String(n++).padStart(2, '0')}-${label}`;
130
+ await fs.writeFile(path.join(outDir, `${id}.png`), PNG.sync.write(img));
131
+ const pct = matchPct(a, b).pct;
132
+ lines.push({ id, pct });
133
+ }
134
+ await fs.writeFile(path.join(outDir, 'index.md'), `# Review at ${vp.name} (${vp.width}x${vp.height}): reference left, clone right
135
+
136
+ Look at every frame. In notes.md under "## Review", one line per frame: \`- <frame> ${vp.name}: same\` or
137
+ \`- <frame> ${vp.name}: differs: <what you see>\`. Pixel match is a hint, not the verdict: a 99% frame can hide a wrong
138
+ panel, and a 90% frame can be a random animation phase.
139
+
140
+ | frame | pixel match |
141
+ |---|---|
142
+ ${lines.map((l) => `| [${l.id}](${l.id}.png) | ${l.pct}% |`).join('\n')}
143
+ `);
144
+ // make sure notes.md has the section the verdicts go in
145
+ const notesFile = path.join(root, 'notes.md');
146
+ const notes = await fs.readFile(notesFile, 'utf8').catch(() => '');
147
+ if (!/^## Review/m.test(notes)) await fs.appendFile(notesFile, `\n## Review\n\n<!-- one line per frame of compare/review/<size>/index.md: "- <frame> <size>: same" or "- <frame> <size>: differs: <what>". REPORT.md counts them. -->\n`);
148
+ await fs.writeFile(path.join(outDir, 'frames.json'), JSON.stringify({ size: vp.name, at: new Date().toISOString(), frames: lines }, null, 2));
149
+ console.log(`review (${vp.name} ${vp.width}x${vp.height}): ${lines.length} frames -> ${path.relative(process.cwd(), path.join(outDir, 'index.md'))}; lowest pixel match: ${[...lines].sort((x, y) => x.pct - y.pct).slice(0, 4).map((l) => `${l.id} ${l.pct}%`).join(', ')}`);
package/scripts/score.mjs CHANGED
@@ -84,7 +84,10 @@ async function cloneHash() {
84
84
  const stampsFile = path.join(root, 'compare', '.stamps.json');
85
85
  const stamps = url ? {} : await fs.readFile(stampsFile, 'utf8').then(JSON.parse).catch(() => ({}));
86
86
  const inputs = url ? null : { hash: await cloneHash(), capturedAt, args: null };
87
- const stampKey = (step) => JSON.stringify({ hash: inputs?.hash, capturedAt, args: step.args });
87
+ // the step's own code is an input too: a changed check must re-measure, never report its old number
88
+ const codeHash = async (file) => crypto.createHash('sha1').update(await fs.readFile(path.join(HERE, path.basename(file)), 'utf8').catch(() => '')).update(await fs.readFile(path.join(HERE, 'lib.mjs'), 'utf8').catch(() => '')).digest('hex').slice(0, 12);
89
+ const codeHashes = Object.fromEntries(await Promise.all(steps.map(async (st) => [st.label, await codeHash(st.args[0])])));
90
+ const stampKey = (step) => JSON.stringify({ hash: inputs?.hash, capturedAt, args: step.args, code: codeHashes[step.label] });
88
91
  const reusable = (step) => !step.live && !url && !flags.fresh && !flags['refresh-ref'] && stamps[step.label]?.key === stampKey(step) && stamps[step.label].values !== undefined;
89
92
 
90
93
  const runStep = (step) => new Promise((resolve) => {
@@ -0,0 +1,116 @@
1
+ // The reference's own code, as evidence for the agent (never copied into the clone): every script the page ran, saved
2
+ // as shipped and as a readable copy, ranked by how much of it actually ran while the page loaded and was scrolled
3
+ // through (V8 precise coverage), with every element's event listeners pointing at file:line:col. This is what lets an
4
+ // agent port a scene's algorithm (a grid computed from the viewport, a spring, a scroll scene) instead of copying the
5
+ // numbers it produced at one screen size. Read through Chrome's DevTools protocol; no extra tool needed.
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const TRACKING = /google-analytics|googletagmanager|gtag|doubleclick|facebook\.(com|net)|fbevents|hotjar|segment\.(io|com)|plausible|cloudflareinsights|clarity\.ms|sentry|datadog|mixpanel|amplitude|posthog|intercom|hubspot|hs-scripts|linkedin|licdn|tiktok|twitter|ads-twitter|redditstatic|marketo|munchkin|challenges\.cloudflare|recaptcha|hcaptcha|turnstile|cdn-cgi\/rum|debugbear/i;
12
+
13
+ // Before navigation: start collecting. Returns finish(), to call once the page has loaded and been scrolled through.
14
+ export async function startScriptEvidence(page, pageUrl) {
15
+ const cdp = await page.context().newCDPSession(page);
16
+ const scripts = new Map(); // scriptId -> { url, inline }
17
+ cdp.on('Debugger.scriptParsed', (e) => {
18
+ if (!e.executionContextAuxData?.isDefault) return; // the page's own world, not tooling
19
+ if (!e.url) return; // eval'd code
20
+ scripts.set(e.scriptId, { url: e.url, inline: e.url.split('#')[0] === pageUrl.split('#')[0], start: e.startLine });
21
+ });
22
+ await cdp.send('Debugger.enable');
23
+ await cdp.send('Profiler.enable');
24
+ await cdp.send('Profiler.startPreciseCoverage', { callCount: true, detailed: false });
25
+
26
+ return async function finish(outDir) {
27
+ const { result: coverage } = await cdp.send('Profiler.takePreciseCoverage');
28
+ await cdp.send('Profiler.stopPreciseCoverage').catch(() => {});
29
+ // listeners anywhere in the document, with the element they sit on
30
+ let listeners = [];
31
+ try {
32
+ const { result } = await cdp.send('Runtime.evaluate', { expression: 'document', objectGroup: 'tc' });
33
+ ({ listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId, depth: -1, pierce: true }));
34
+ } catch { /* no listeners readable */ }
35
+ const labels = new Map();
36
+ const labelOf = async (backendNodeId) => {
37
+ if (!backendNodeId) return 'window';
38
+ if (labels.has(backendNodeId)) return labels.get(backendNodeId);
39
+ let label = '?';
40
+ try {
41
+ const { node } = await cdp.send('DOM.describeNode', { backendNodeId });
42
+ const attrs = {};
43
+ for (let i = 0; i < (node.attributes || []).length; i += 2) attrs[node.attributes[i]] = node.attributes[i + 1];
44
+ label = node.nodeName === '#document' ? 'document' : `${node.localName}${attrs.id ? `#${attrs.id}` : ''}${attrs.class ? `.${attrs.class.trim().split(/\s+/).slice(0, 2).join('.')}` : ''}`;
45
+ } catch { /* detached */ }
46
+ labels.set(backendNodeId, label);
47
+ return label;
48
+ };
49
+
50
+ await fs.rm(outDir, { recursive: true, force: true });
51
+ await fs.mkdir(outDir, { recursive: true });
52
+ const beautify = require('js-beautify').js;
53
+ const files = new Map(); // scriptId -> record
54
+ let n = 0;
55
+ for (const [scriptId, s] of scripts) {
56
+ let source = '';
57
+ try { ({ scriptSource: source } = await cdp.send('Debugger.getScriptSource', { scriptId })); } catch { continue; }
58
+ if (!source || source.length < 40) continue;
59
+ const base = s.inline ? `inline-${++n}` : `${String(++n).padStart(2, '0')}-${(new URL(s.url).pathname.split('/').pop() || 'script').replace(/[^a-z0-9._-]/gi, '_').replace(/\.m?js$/i, '').slice(-60)}`;
60
+ const file = `${base}.js`;
61
+ await fs.writeFile(path.join(outDir, file), source);
62
+ // a readable copy when the code is minified (long lines); line numbers in INDEX.md refer to the original
63
+ const lines = source.split('\n');
64
+ const minified = source.length / Math.max(1, lines.length) > 200;
65
+ let pretty = null;
66
+ if (minified && source.length < 3_000_000) {
67
+ try { pretty = `${base}.pretty.js`; await fs.writeFile(path.join(outDir, pretty), beautify(source, { indent_size: 2, max_preserve_newlines: 1 })); } catch { pretty = null; }
68
+ }
69
+ const cov = coverage.find((c) => c.scriptId === scriptId);
70
+ const fns = cov?.functions || [];
71
+ const ran = fns.filter((f) => f.ranges?.[0]?.count > 0);
72
+ files.set(scriptId, { file, pretty, url: s.inline ? `${s.url} (inline)` : s.url, bytes: source.length, functions: fns.length, functionsRan: ran.length, ranNames: [...new Set(ran.map((f) => f.functionName).filter(Boolean))].slice(0, 14), tracking: TRACKING.test(s.url) && !s.inline, source, start: s.start });
73
+ }
74
+
75
+ // listeners grouped by handler location
76
+ const groups = new Map();
77
+ for (const l of listeners) {
78
+ const f = files.get(l.scriptId);
79
+ const key = `${l.type}|${l.scriptId}|${l.lineNumber}|${l.columnNumber}`;
80
+ if (!groups.has(key)) groups.set(key, { type: l.type, file: f?.file || null, url: f?.url || null, line: l.lineNumber + 1, column: l.columnNumber, elements: [] });
81
+ const g = groups.get(key);
82
+ if (g.elements.length < 6) g.elements.push(await labelOf(l.backendNodeId));
83
+ else g.more = (g.more || 0) + 1;
84
+ }
85
+ const excerpt = (f, line, col) => {
86
+ const text = f?.source.split('\n')[line - 1 - (f.inline ? 0 : 0)] || '';
87
+ return text.length > 160 ? text.slice(Math.max(0, col - 40), col + 140) : text.trim();
88
+ };
89
+ const ranked = [...files.values()].sort((a, b) => b.functionsRan - a.functionsRan);
90
+ const listenerRows = [...groups.values()].filter((g) => g.file && !files.get([...files.entries()].find(([, v]) => v.file === g.file)?.[0])?.tracking)
91
+ .sort((a, b) => (a.file || '').localeCompare(b.file || '') || a.line - b.line);
92
+ const md = [
93
+ `# The reference's scripts (evidence, never copied)`,
94
+ '',
95
+ `Saved as the page shipped them (\`NN-name.js\`) and, when minified, as a readable copy (\`.pretty.js\`). Ranked by how much of each ran while the page loaded and was scrolled through. Read the algorithm, then write your own code: a value a script computes from the screen size must be computed again, not copied.`,
96
+ '',
97
+ '| file | ran | functions | size | from | names that ran |',
98
+ '|---|---|---|---|---|---|',
99
+ ...ranked.map((f) => `| [${f.file}](${f.file})${f.pretty ? ` ([readable](${f.pretty}))` : ''} | ${f.functionsRan} | ${f.functions} | ${Math.round(f.bytes / 1024)} KB | ${f.tracking ? 'tracking (never copy)' : f.url.slice(0, 90)} | ${f.ranNames.join(', ')} |`),
100
+ '',
101
+ '## Event listeners, by handler',
102
+ '',
103
+ 'Line and column refer to the original file. Elements that share one handler are listed together; a handler on `document` or `window` usually serves every matching element below it (delegation).',
104
+ '',
105
+ '| event | handler | on | code at the handler |',
106
+ '|---|---|---|---|',
107
+ ...listenerRows.map((g) => { const f = [...files.values()].find((x) => x.file === g.file); return `| ${g.type} | ${g.file}:${g.line}:${g.column} | ${g.elements.join(', ')}${g.more ? ` +${g.more}` : ''} | \`${excerpt(f, g.line, g.column).replace(/\|/g, '\\|').replace(/`/g, "'")}\` |`; }),
108
+ '',
109
+ ].join('\n');
110
+ await fs.writeFile(path.join(outDir, 'INDEX.md'), md);
111
+ await fs.writeFile(path.join(outDir, 'scripts.json'), JSON.stringify({ scripts: ranked.map(({ source, ...r }) => r), listeners: listenerRows }, null, 2));
112
+ await cdp.send('Debugger.disable').catch(() => {});
113
+ await cdp.detach().catch(() => {});
114
+ return { scripts: ranked.length, ran: ranked.filter((f) => f.functionsRan).length, listeners: listenerRows.length };
115
+ };
116
+ }