@trueclone/cli 0.1.0 → 0.3.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
@@ -15,7 +15,7 @@ import fs from 'node:fs/promises';
15
15
  import path from 'node:path';
16
16
  import { spawn } from 'node:child_process';
17
17
  import net from 'node:net';
18
- import { parse } from 'parse5';
18
+ import { parse, serialize } from 'parse5';
19
19
  import { fileURLToPath as toPath } from 'node:url';
20
20
  import { parseArgs } from './lib.mjs';
21
21
 
@@ -30,13 +30,17 @@ const cloneDir = path.join(root, 'clone');
30
30
  const out = path.join(root, 'next');
31
31
  const port = Number(flags.port || 3100);
32
32
 
33
- // Versions: motion libraries pinned to what the verified HTML clone loads, so behaviour is identical.
33
+ // Two kinds of clone. A hand-built one (styles.css + one script.js using GSAP/Lenis/Swiper/three from npm) is
34
+ // converted section by section; a seeded one (trueclone seed: the original's own stylesheets, trueclone's players, a
35
+ // script.js and the libraries it vendored) goes through seededApp() and needs no motion library from npm.
36
+ const seeded = await fs.stat(path.join(cloneDir, 'css')).then(() => true, () => false);
37
+ // Versions: motion libraries pinned to what the verified hand-built HTML clone loads, so behaviour is identical.
34
38
  const PKG = {
35
39
  name: `${name}-next`, private: true, version: '0.1.0',
36
40
  scripts: { dev: 'next dev', build: 'next build', start: `next start -p ${port}` },
37
41
  dependencies: {
38
42
  next: '16.4.0', react: '19.3.0', 'react-dom': '19.3.0',
39
- gsap: '3.13.0', lenis: '1.1.18', swiper: '11.2.6', three: '0.128.0',
43
+ ...(seeded ? {} : { gsap: '3.13.0', lenis: '1.1.18', swiper: '11.2.6', three: '0.128.0' }),
40
44
  clsx: '2.1.1', 'tailwind-merge': '3.7.0',
41
45
  },
42
46
  devDependencies: {
@@ -48,12 +52,19 @@ const PKG = {
48
52
  // ---------------- HTML -> JSX ----------------
49
53
  const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
50
54
  const RENAME = {
51
- class: 'className', for: 'htmlFor', tabindex: 'tabIndex', readonly: 'readOnly', maxlength: 'maxLength', minlength: 'minLength',
55
+ class: 'className', for: 'htmlFor', tabindex: 'tabIndex', readonly: 'readOnly', nomodule: 'noModule', formnovalidate: 'formNoValidate', itemscope: 'itemScope', maxlength: 'maxLength', minlength: 'minLength',
52
56
  crossorigin: 'crossOrigin', autoplay: 'autoPlay', playsinline: 'playsInline', srcset: 'srcSet', autocomplete: 'autoComplete',
53
57
  enctype: 'encType', colspan: 'colSpan', rowspan: 'rowSpan', contenteditable: 'contentEditable', spellcheck: 'spellCheck',
54
58
  novalidate: 'noValidate', allowfullscreen: 'allowFullScreen', frameborder: 'frameBorder', inputmode: 'inputMode', datetime: 'dateTime',
59
+ cellpadding: 'cellPadding', cellspacing: 'cellSpacing', charset: 'charSet', accesskey: 'accessKey', usemap: 'useMap',
60
+ formaction: 'formAction', marginwidth: 'marginWidth', marginheight: 'marginHeight', hreflang: 'hrefLang', srclang: 'srcLang',
61
+ srcdoc: 'srcDoc', referrerpolicy: 'referrerPolicy', fetchpriority: 'fetchPriority', enterkeyhint: 'enterKeyHint',
62
+ itemprop: 'itemProp', itemscope: 'itemScope', itemtype: 'itemType', 'accept-charset': 'acceptCharset', 'http-equiv': 'httpEquiv',
55
63
  };
56
- const BOOL = new Set(['muted', 'loop', 'playsinline', 'autoplay', 'controls', 'disabled', 'checked', 'required', 'hidden', 'novalidate', 'readonly', 'multiple', 'selected', 'allowfullscreen']);
64
+ // Legacy presentational attributes (email-style tables) that React has no typed prop for: passed through a spread,
65
+ // which React renders as-is and TypeScript doesn't reject.
66
+ const LEGACY = new Set(['valign', 'bgcolor', 'background', 'nowrap', 'hspace', 'vspace', 'clear', 'char', 'charoff', 'compact', 'frame', 'rules', 'axis']);
67
+ const BOOL = new Set(['muted', 'loop', 'playsinline', 'autoplay', 'controls', 'disabled', 'checked', 'required', 'hidden', 'novalidate', 'readonly', 'multiple', 'selected', 'allowfullscreen', 'open', 'default', 'reversed', 'async', 'defer', 'nomodule', 'formnovalidate', 'inert', 'itemscope']);
57
68
  const camel = (s) => s.replace(/[-:]([a-z])/g, (m, c) => c.toUpperCase());
58
69
  const styleObject = (css) => {
59
70
  const o = {};
@@ -73,30 +84,64 @@ function jsxAttrs(node, inSvg) {
73
84
  const parts = [];
74
85
  for (const a of node.attrs || []) {
75
86
  if (/^on[a-z]+$/.test(a.name)) continue; // inline handlers are strings; the motion script owns behaviour
76
- if (a.name === 'style') { usesStyle = true; parts.push(`style={${JSON.stringify(styleObject(a.value))} as CSSProperties}`); continue; }
77
- if (BOOL.has(a.name) && (a.value === '' || a.value === a.name)) { parts.push(RENAME[a.name] || a.name); continue; }
87
+ // an input's value/checked are its starting state here; as plain props React would make the field read-only
88
+ if (node.tagName === 'input' && a.name === 'value') { parts.push(attrString('defaultValue', a.value)); continue; }
89
+ if (node.tagName === 'input' && a.name === 'checked') { parts.push('defaultChecked'); continue; }
90
+ if (LEGACY.has(a.name) && !inSvg) { parts.push(`{...{ ${JSON.stringify(a.name)}: ${JSON.stringify(a.value)} }}`); continue; }
91
+ // url(assets/…) in an inline style resolves against the page in the clone; the app serves assets at /assets
92
+ if (a.name === 'style') { usesStyle = true; parts.push(`style={${JSON.stringify(styleObject(a.value.replace(/url\((['"]?)(?:\.\.\/)?assets\//g, (m, q) => `url(${q}/assets/`)))} as CSSProperties}`); continue; }
93
+ // SVG elements have no typed boolean props (hidden), so they go through the spread too
94
+ if (BOOL.has(a.name) && (a.value === '' || a.value === a.name)) { parts.push(inSvg ? `{...{ ${JSON.stringify(a.name)}: true }}` : RENAME[a.name] || a.name); continue; }
78
95
  let n = RENAME[a.name] || a.name;
79
96
  if (!RENAME[a.name] && inSvg && !n.startsWith('data-') && !n.startsWith('aria-')) n = camel(n);
80
97
  if (n === 'xmlns:xlink') continue;
98
+ // an empty src/srcset/poster makes the browser request the page itself (React logs an error for each); an
99
+ // empty href means "this page", which # also does without the request
100
+ if (['src', 'srcSet', 'poster'].includes(n) && a.value === '') continue;
101
+ if (n === 'href' && a.value === '') { parts.push('href="#"'); continue; }
81
102
  const v = ['src', 'href', 'poster', 'srcSet'].includes(n) ? fixUrl(a.value) : a.value;
103
+ // React types these as numbers: tabIndex="0" fails type-checking, tabIndex={0} is the same attribute
104
+ if (['tabIndex', 'colSpan', 'rowSpan', 'maxLength', 'minLength', 'span', 'size', 'cols', 'rows'].includes(n) && /^-?\d+$/.test(v)) { parts.push(`${n}={${v}}`); continue; }
82
105
  parts.push(attrString(n, v));
83
106
  }
84
107
  return parts.length ? ' ' + parts.join(' ') : '';
85
108
  }
86
109
 
87
- function toJsx(node, depth, inSvg = false) {
110
+ // JSX trims and collapses whitespace that HTML renders, so text is written to keep exactly what the browser showed:
111
+ // verbatim where white-space is preserved (code samples), elsewhere with its edge spaces as {" "}
112
+ const BLOCK = new Set(['address', 'article', 'aside', 'blockquote', 'body', 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hr', 'html', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul']);
113
+ const SPACE = '{" "}';
114
+ const keepsWhitespace = (node) => ['pre', 'textarea', 'listing', 'xmp'].includes(node.tagName)
115
+ || (node.attrs || []).some((a) => (a.name === 'class' && /(^|\s)whitespace-(pre|break-spaces)/.test(a.value)) || (a.name === 'style' && /white-space:\s*(pre|break-spaces)/.test(a.value)));
116
+ const textJsx = (s) => (/[{}<>&"']/.test(s) ? `{${JSON.stringify(s)}}` : s);
117
+
118
+ function toJsx(node, depth, inSvg = false, inPre = false) {
88
119
  const pad = ' '.repeat(depth);
89
120
  if (node.nodeName === '#comment') return '';
90
121
  if (node.nodeName === '#text') {
91
- if (!node.value.trim()) return node.value.includes('\n') ? '' : ' ';
122
+ if (inPre) return `{${JSON.stringify(node.value)}}`;
123
+ if (!node.value.trim()) {
124
+ // whitespace between two inline things renders as one space; next to a block, or at an edge, it renders nothing
125
+ const sibs = node.parentNode?.childNodes || [];
126
+ const i = sibs.indexOf(node);
127
+ const prev = sibs[i - 1], next = sibs[i + 1];
128
+ return prev && next && !BLOCK.has(prev.tagName) && !BLOCK.has(next.tagName) ? SPACE : '';
129
+ }
92
130
  const text = node.value.replace(/\s+/g, ' ');
93
- return /[{}<>&"']/.test(text) ? `{${JSON.stringify(text)}}` : text;
131
+ return `${/^\s/.test(text) ? SPACE : ''}${textJsx(text.trim())}${/\s$/.test(text) ? SPACE : ''}`;
94
132
  }
95
133
  if (['script', 'noscript', 'style'].includes(node.nodeName)) return '';
96
134
  const tag = node.tagName;
97
135
  const svg = inSvg || tag === 'svg';
136
+ const pre = inPre || keepsWhitespace(node);
98
137
  const attrs = jsxAttrs(node, svg);
99
- const kids = (node.childNodes || []).map((c) => toJsx(c, depth + 1, svg)).filter((s) => s !== '');
138
+ // a host with a declarative shadow root (<template shadowrootmode>): the browser's HTML parser builds the shadow
139
+ // root from the streamed markup, React must not touch the inside, so the host's content is emitted as raw HTML
140
+ if ((node.childNodes || []).some((c) => c.tagName === 'template' && (c.attrs || []).some((a) => a.name === 'shadowrootmode'))) {
141
+ const inner = serialize(node).replace(/url\((['"]?)(?:\.\.\/)?assets\//g, (m, q) => `url(${q}/assets/`).replace(/(src|href|poster)="assets\//g, '$1="/assets/');
142
+ return `${pad}<${tag}${attrs} suppressHydrationWarning dangerouslySetInnerHTML={{ __html: ${JSON.stringify(inner)} }} />`;
143
+ }
144
+ const kids = (node.childNodes || []).map((c) => toJsx(c, depth + 1, svg, pre)).filter((s) => s !== '');
100
145
  if (VOID.has(tag) || !kids.length) return `${pad}<${tag}${attrs} />`;
101
146
  const inline = kids.every((k) => !k.startsWith(' ')) && kids.join('').length < 120;
102
147
  if (inline) return `${pad}<${tag}${attrs}>${kids.map((k) => k.trim() || ' ').join('')}</${tag}>`;
@@ -121,12 +166,14 @@ await fs.rm(path.join(out, 'app'), { recursive: true, force: true });
121
166
  await fs.rm(path.join(out, 'components', 'site'), { recursive: true, force: true });
122
167
  for (const d of ['app', 'components/site', 'lib', 'styles', 'public']) await fs.mkdir(path.join(out, d), { recursive: true });
123
168
 
169
+ let components = [];
170
+ if (seeded) components = await seededApp();
171
+ else {
124
172
  // Sections: everything before <main> is "chrome" (nav, menu, intro, cursor); each child of <main> is a section.
125
173
  const mainEl = find(body, 'main');
126
174
  const chrome = els(body).filter((e) => e !== mainEl && e.tagName !== 'script');
127
175
  const sections = mainEl ? els(mainEl) : [];
128
176
  const used = new Set();
129
- const components = [];
130
177
  const writeComponent = async (compName, nodes, depth = 2) => {
131
178
  usesStyle = false;
132
179
  const jsx = nodes.map((n) => toJsx(n, depth)).filter(Boolean).join('\n');
@@ -249,12 +296,29 @@ ${components.map((c) => ` <${c} />`).join('\n')}
249
296
  );
250
297
  }
251
298
  `);
299
+ }
252
300
 
253
301
  // Assets, design system docs, config
254
302
  await fs.rm(path.join(out, 'public', 'assets'), { recursive: true, force: true });
255
303
  await fs.cp(path.join(cloneDir, 'assets'), path.join(out, 'public', 'assets'), { recursive: true });
256
304
  for (const f of ['DESIGN.md', 'design-tokens.json']) await fs.copyFile(path.join(root, f), path.join(out, f)).catch(() => {});
257
305
  await fs.writeFile(path.join(out, 'package.json'), JSON.stringify(PKG, null, 2) + '\n');
306
+ // custom elements (<code-island>, <swiper-container>) have no JSX type: declared here, or `next build` fails
307
+ const custom = new Set();
308
+ (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
+
313
+ declare module 'react' {
314
+ namespace JSX {
315
+ interface IntrinsicElements {
316
+ ${[...custom].map((t) => ` ${JSON.stringify(t)}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & Record<string, unknown>;`).join('\n')}
317
+ }
318
+ }
319
+ }
320
+ `);
321
+ } else await fs.rm(path.join(out, 'custom-elements.d.ts'), { force: true });
258
322
  await fs.writeFile(path.join(out, 'postcss.config.mjs'), "export default { plugins: { '@tailwindcss/postcss': {} } };\n");
259
323
  await fs.writeFile(path.join(out, 'next.config.mjs'), `import path from 'node:path';
260
324
  import { fileURLToPath } from 'node:url';
@@ -280,11 +344,228 @@ await fs.writeFile(path.join(out, 'components.json'), JSON.stringify({
280
344
  }, null, 2) + '\n');
281
345
  await fs.writeFile(path.join(out, 'lib', 'utils.ts'), "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n");
282
346
  await fs.writeFile(path.join(out, '.gitignore'), 'node_modules/\n.next/\nnext-env.d.ts\n*.tsbuildinfo\n');
283
- console.log(`generated ${path.relative(process.cwd(), out)}: ${components.length} section components + SiteChrome + SiteMotion`);
347
+ console.log(`generated ${path.relative(process.cwd(), out)} (${seeded ? 'seeded clone' : 'hand-built clone'}): ${components.length} components + ${seeded ? 'SiteScripts' : 'SiteChrome + SiteMotion'}`);
348
+
349
+ // ---------------- Seeded clones ----------------
350
+ // The page is the original's markup and stylesheets (trueclone seed), so the app keeps them as they are:
351
+ // - the stylesheets and inline <style> blocks of <head>, in their order, after the shadcn theme; Tailwind's own class
352
+ // scanning is limited to components/ui, because the original's compiled CSS already defines every class in the
353
+ // markup and a second build from a different theme would compete with it;
354
+ // - <html> and <body> attributes (font-variable classes, dark mode, color-scheme);
355
+ // - the largest body child is split at its first element with 3+ children: each of those becomes a component, the
356
+ // wrappers around them stay in the page with their classes; other body children are components of their own;
357
+ // - trueclone's players and script.js are served from public/ and run in order after hydration, with the data
358
+ // blocks they read (script type="application/json") rendered in the page.
359
+ async function seededApp() {
360
+ const attrsOf = (n) => Object.fromEntries((n.attrs || []).map((a) => [a.name, a.value]));
361
+ // styles
362
+ await fs.mkdir(path.join(out, 'styles', 'site'), { recursive: true });
363
+ const imports = [];
364
+ let inline = 0, usesTailwind = false;
365
+ // <style> blocks (and stylesheet links) can sit inside <body> too, anywhere in the tree: same cascade, so they
366
+ // follow the head's in document order
367
+ const bodyStyles = [];
368
+ (function walk(n) { for (const c of n.childNodes || []) { if (c.tagName === 'style' || c.tagName === 'link') bodyStyles.push(c); else walk(c); } })(body);
369
+ for (const e of [...els(head), ...bodyStyles]) {
370
+ const a = attrsOf(e);
371
+ let css = null, file = null;
372
+ if (e.tagName === 'link' && a.rel === 'stylesheet' && a.href && !/^(https?:)?\/\//.test(a.href)) {
373
+ css = await fs.readFile(path.join(cloneDir, a.href), 'utf8').catch(() => null);
374
+ file = path.basename(a.href);
375
+ } else if (e.tagName === 'style') {
376
+ css = (e.childNodes || []).map((c) => c.value || '').join('');
377
+ file = `inline-${++inline}.css`;
378
+ }
379
+ if (css === null) continue;
380
+ // stylesheets lived in clone/css/ (assets at ../assets/), inline blocks in the page (assets at assets/)
381
+ css = css.replace(/url\((['"]?)(?:\.\.\/)?assets\//g, (m, q) => `url(${q}/assets/`);
382
+ usesTailwind ||= /--tw-|tailwindcss v\d/.test(css);
383
+ await fs.writeFile(path.join(out, 'styles', 'site', file), css);
384
+ imports.push(`import '../styles/site/${file}';`);
385
+ }
386
+ // globals.css = the shadcn theme only; the original's stylesheets are imported by layout.tsx AFTER it, so they come
387
+ // later in the cascade and win every tie (same layer names, same variable names: the original is often Tailwind too)
388
+ const theme = await fs.readFile(path.join(root, 'globals.css'), 'utf8').catch(() => '@import "tailwindcss";\n');
389
+ // Tailwind's preflight resets what the browser draws by default (form controls, headings, lists); an original built
390
+ // without Tailwind relies on those defaults, so its clone gets Tailwind's theme and utilities but not the reset
391
+ const tailwind = usesTailwind ? '@import "tailwindcss" source(none);'
392
+ : '/* 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
+ const scoped = theme.replace('@import "tailwindcss";', `${tailwind}\n@source "../components/ui";`);
394
+ await fs.writeFile(path.join(out, 'app', 'globals.css'), scoped);
395
+ const siteCssImports = imports;
396
+
397
+ // markup
398
+ const SKIP_BODY = new Set(['script', 'noscript', 'next-route-announcer', 'template']);
399
+ const kids = els(body).filter((e) => !SKIP_BODY.has(e.tagName));
400
+ const size = (n) => (n.childNodes || []).reduce((s, c) => s + 1 + size(c), 0);
401
+ const content = kids.reduce((x, y) => (size(y) > size(x) ? y : x), kids[0]);
402
+ let container = content;
403
+ while (els(container).length < 3) {
404
+ const inner = els(container);
405
+ if (!inner.length) break;
406
+ container = inner.reduce((x, y) => (size(y) > size(x) ? y : x));
407
+ }
408
+ const used = new Set();
409
+ const names = [];
410
+ // names: the element's id when it has one, otherwise its tag and position (utility classes such as "mx-auto" and
411
+ // placeholder headings say nothing about what a section is)
412
+ let counter = 0;
413
+ const nameFor = (node, fallback) => {
414
+ counter++;
415
+ const id = attrsOf(node).id;
416
+ let n = id ? pascal(id) : `${pascal(node.tagName) || fallback}${String(counter).padStart(2, '0')}`;
417
+ if (/^\d/.test(n)) n = 'S' + n;
418
+ n = n.slice(0, 40) || fallback;
419
+ let k = n, i = 2;
420
+ while (used.has(k)) k = `${n}${i++}`;
421
+ used.add(k);
422
+ return k;
423
+ };
424
+ const writeComp = async (compName, node) => {
425
+ usesStyle = false;
426
+ const jsx = toJsx(node, 3);
427
+ const typeImport = usesStyle ? "import type { CSSProperties } from 'react';\n\n" : '';
428
+ await fs.writeFile(path.join(out, 'components', 'site', `${compName}.tsx`), `// Generated from clone/index.html by scripts/next.mjs. Edit the HTML clone and regenerate.\n${typeImport}export default function ${compName}() {\n return (\n <>\n${jsx}\n </>\n );\n}\n`);
429
+ names.push(compName);
430
+ };
431
+ // the page: wrappers kept inline down to the container, the container's children as components
432
+ let pageUsesStyle = false;
433
+ const wrap = async (node, depth) => {
434
+ const pad = ' '.repeat(depth);
435
+ if (node.nodeName === '#text' || node.nodeName === '#comment') return toJsx(node, depth);
436
+ usesStyle = false;
437
+ const attrs = jsxAttrs(node, false);
438
+ pageUsesStyle ||= usesStyle;
439
+ const children = [];
440
+ for (const c of node.childNodes || []) {
441
+ if (node === container && c.tagName) { const n = nameFor(c, 'Section'); await writeComp(n, c); children.push(`${pad} <${n} />`); }
442
+ else if (c.tagName && c !== container && !c.childNodes?.some(() => false) && !(contains(c, container))) { usesStyle = false; children.push(toJsx(c, depth + 1)); pageUsesStyle ||= usesStyle; }
443
+ else if (c.tagName) children.push(await wrap(c, depth + 1));
444
+ }
445
+ const body = children.filter((s) => s && s.trim()).join('\n');
446
+ return body ? `${pad}<${node.tagName}${attrs}>\n${body}\n${pad}</${node.tagName}>` : `${pad}<${node.tagName}${attrs} />`;
447
+ };
448
+ const contains = (a, b) => { for (let n = b; n; n = n.parentNode) if (n === a) return true; return false; };
449
+ const pageParts = [];
450
+ for (const k of kids) {
451
+ if (k === content) pageParts.push(await wrap(k, 3));
452
+ else { const n = nameFor(k, 'Part'); await writeComp(n, k); pageParts.push(` <${n} />`); }
453
+ }
454
+ // data blocks the players read, and the scripts themselves (copied to public/, run in order after hydration)
455
+ const scripts = els(body).filter((e) => e.tagName === 'script');
456
+ const dataBlocks = scripts.filter((s) => /json/.test(attrsOf(s).type || '') && attrsOf(s).id)
457
+ .map((s) => ` <script type="${attrsOf(s).type}" id="${attrsOf(s).id}" dangerouslySetInnerHTML={{ __html: ${JSON.stringify((s.childNodes || []).map((c) => c.value || '').join(''))} }} />`);
458
+ // the scripts, copied to public/ at their own relative paths (a script that loads a sibling file keeps working),
459
+ // with the files their import statements and import() calls name; a module script stays a module
460
+ // in document order: files (copied) and inline scripts (run as written); data blocks are rendered in the page
461
+ const scriptTags = scripts.map((s) => ({ ...attrsOf(s), code: (s.childNodes || []).map((c) => c.value || '').join('') }))
462
+ .filter((a) => !/json/.test(a.type || '') && (a.src ? !/^(https?:)?\/\//.test(a.src) : a.code.trim()));
463
+ const copied = new Set();
464
+ const copyScript = async (rel, depth = 0) => {
465
+ const clean = rel.split(/[?#]/)[0].replace(/^\.\//, '');
466
+ if (copied.has(clean) || depth > 4) return;
467
+ copied.add(clean);
468
+ const from = path.join(cloneDir, clean);
469
+ const text = await fs.readFile(from, 'utf8').catch(() => null);
470
+ if (text === null) { console.warn(`WARNING script referenced but not in clone/: ${clean}`); return; }
471
+ await fs.mkdir(path.dirname(path.join(out, 'public', clean)), { recursive: true });
472
+ await fs.copyFile(from, path.join(out, 'public', clean));
473
+ for (const m of text.matchAll(/(?:^|[^\w$.])import\s*(?:[\w$*{},\s]+from\s*)?['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g)) {
474
+ const spec = m[1] || m[2];
475
+ if (!spec || /^(https?:)?\/\//.test(spec) || !/^[./]/.test(spec)) continue;
476
+ await copyScript(path.posix.join(path.posix.dirname(clean), spec), depth + 1);
477
+ }
478
+ // a script file named in a string (new URL('vendor/three.module.min.js', here), a worker, a lazy loader)
479
+ for (const m of text.matchAll(/['"]([\w./-]+\.m?js)['"]/g)) {
480
+ const rel = path.posix.join(path.posix.dirname(clean), m[1].replace(/^\//, ''));
481
+ if (!copied.has(rel) && await fs.stat(path.join(cloneDir, rel)).then(() => true, () => false)) await copyScript(rel, depth + 1);
482
+ }
483
+ };
484
+ for (const a of scriptTags) if (a.src) await copyScript(a.src);
485
+ const scriptList = scriptTags.map((a) => ({ ...(a.src ? { src: '/' + a.src.split(/[?#]/)[0].replace(/^\.\//, '') } : { code: a.code }), ...(a.type === 'module' ? { type: 'module' } : {}) }));
486
+ await fs.writeFile(path.join(out, 'components', 'site', 'SiteScripts.tsx'), `'use client';
487
+ // Generated by scripts/next.mjs: runs the clone's scripts (trueclone's players, inline scripts, script.js) in order,
488
+ // once, the moment hydration is done. The HTML clone runs them as deferred scripts right after parsing; a component
489
+ // app cannot (hydration would re-render what they change), so the files are preloaded from the first byte (see
490
+ // layout.tsx), inserted together with async=false (ordered execution, parallel fetch), and the boot contract
491
+ // (<html class="tc-boot">, seed) keeps script-built regions hidden until the last one has run.
492
+ import { useLayoutEffect } from 'react';
493
+
494
+ const SCRIPTS: { src?: string; code?: string; type?: string }[] = ${JSON.stringify(scriptList)};
495
+
496
+ export default function SiteScripts() {
497
+ useLayoutEffect(() => {
498
+ const w = window as unknown as Record<string, unknown>;
499
+ if (w.__siteScripts) return; // React strict mode runs effects twice in dev
500
+ w.__siteScripts = true;
501
+ w.__tcInjected = true;
502
+ let last: HTMLScriptElement | null = null;
503
+ for (const { src, code, type } of SCRIPTS) {
504
+ const s = document.createElement('script');
505
+ if (type) s.type = type;
506
+ if (src) { s.src = src; s.async = false; } else { s.textContent = code || ''; }
507
+ document.body.appendChild(s);
508
+ if (src) last = s;
509
+ }
510
+ const boot = () => { const b = (w as { tcBoot?: { release: () => void } }).tcBoot; if (b) b.release(); else requestAnimationFrame(() => document.documentElement.classList.remove('tc-boot')); };
511
+ if (last) last.addEventListener('load', boot), last.addEventListener('error', boot); else boot();
512
+ }, []);
513
+ return null;
514
+ }
515
+ `);
516
+ // layout: the original's <html> and <body> attributes
517
+ usesStyle = false;
518
+ const htmlAttrs = jsxAttrs(htmlEl, false);
519
+ const bodyAttrs = jsxAttrs(body, false);
520
+ const layoutStyle = usesStyle;
521
+ await fs.writeFile(path.join(out, 'app', 'layout.tsx'), `// Generated by scripts/next.mjs
522
+ import type { Metadata } from 'next';
523
+ ${layoutStyle ? "import type { CSSProperties } from 'react';\n" : ''}import './globals.css';
524
+ // the original's stylesheets, in its <head> order, after the theme
525
+ ${siteCssImports.join('\n')}
526
+
527
+ export const metadata: Metadata = {
528
+ title: ${JSON.stringify(title)},
529
+ robots: { index: false, follow: false },
530
+ };
531
+
532
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
533
+ return (
534
+ <html${htmlAttrs} suppressHydrationWarning>
535
+ <head>
536
+ {/* the clone's scripts start downloading with the page; SiteScripts runs them the moment hydration is done */}
537
+ ${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
+ </head>
539
+ <body${bodyAttrs} suppressHydrationWarning>{children}</body>
540
+ </html>
541
+ );
542
+ }
543
+ `);
544
+ await fs.writeFile(path.join(out, 'app', 'page.tsx'), `// Generated by scripts/next.mjs from a seeded clone: the original's structure, one component per section.
545
+ ${pageUsesStyle ? "import type { CSSProperties } from 'react';\n" : ''}${names.map((c) => `import ${c} from '@/components/site/${c}';`).join('\n')}
546
+ import SiteScripts from '@/components/site/SiteScripts';
547
+
548
+ export default function Page() {
549
+ return (
550
+ <>
551
+ ${pageParts.join('\n')}
552
+ ${dataBlocks.join('\n')}
553
+ <SiteScripts />
554
+ </>
555
+ );
556
+ }
557
+ `);
558
+ return names;
559
+ }
284
560
 
285
561
  // ---------------- Install / build / verify ----------------
562
+ // npm/npx are .cmd files on Windows and need a shell; Node warns (DEP0190) when a shell gets separate arguments, so
563
+ // build one quoted command line there instead
564
+ const spawnCmd = (cmd, args, opts = {}) => (process.platform === 'win32'
565
+ ? spawn([cmd, ...args].map((a) => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(' '), { ...opts, shell: true })
566
+ : spawn(cmd, args, opts));
286
567
  const run = (cmd, args, opts = {}) => new Promise((resolve, reject) => {
287
- const p = spawn(cmd, args, { cwd: out, stdio: 'inherit', shell: process.platform === 'win32', ...opts });
568
+ const p = spawnCmd(cmd, args, { cwd: out, stdio: 'inherit', ...opts });
288
569
  p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited ${code}`))));
289
570
  });
290
571
  if (flags.install || flags.build || flags.verify) await run('npm', ['install', '--no-audit', '--no-fund']);
@@ -301,7 +582,7 @@ const freePort = async (start) => {
301
582
  if (flags.verify) {
302
583
  const verifyPort = await freePort(port);
303
584
  if (verifyPort !== port) console.warn(`port ${port} is busy (another server?) - verifying on ${verifyPort}`);
304
- const server = spawn('npx', ['next', 'start', '-p', String(verifyPort)], { cwd: out, shell: process.platform === 'win32', stdio: 'ignore' });
585
+ const server = spawnCmd('npx', ['next', 'start', '-p', String(verifyPort)], { cwd: out, stdio: 'ignore' });
305
586
  const url = `http://127.0.0.1:${verifyPort}/`;
306
587
  for (let i = 0; i < 60; i++) { try { if ((await fetch(url)).ok) break; } catch { /* starting */ } await new Promise((r) => setTimeout(r, 1000)); }
307
588
  const lab = path.resolve('.');
@@ -0,0 +1,66 @@
1
+ // Which regions of the clone does script.js own? The seed carries the original's markup in its post-script state; a
2
+ // script that rebuilds a region at boot (a hero grid, a card layout it places, a slider it wraps) replaces that
3
+ // markup. This loads the clone twice, with its scripts blocked and with them running, and lists the top-level sections
4
+ // whose structure the scripts changed: those are the agent's, and the seed's recorded players must not run there
5
+ // (hand them over, see SKILL.md). Runs again after each build round.
6
+ // usage: node scripts/owned.mjs <name> [--viewport desktop]
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, settle, sectionsOf } from './lib.mjs';
10
+
11
+ const { pos, flags } = parseArgs(process.argv.slice(2));
12
+ const [name] = pos;
13
+ if (!name) {
14
+ console.error('usage: trueclone owned <name> [--viewport desktop]');
15
+ process.exit(1);
16
+ }
17
+ const root = path.resolve('sites', name);
18
+ const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
19
+ const server = await serve(path.join(root, 'clone'));
20
+ const browser = await openBrowser(flags.cdp);
21
+
22
+ // a structural signature per top-level section: element count, tag sequence hash, inline-style count
23
+ const signatures = () => {
24
+ const hash = (s) => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(16); };
25
+ const unwrap = (el) => { let n = el; while (n.children.length === 1 && !n.matches('header,footer,main,section,nav,article,aside')) n = n.children[0]; return n; };
26
+ const tops = [...unwrap(document.body).children].filter((el) => !['SCRIPT', 'STYLE', 'TEMPLATE', 'LINK', 'NOSCRIPT'].includes(el.tagName));
27
+ return tops.map((el) => {
28
+ const r = el.getBoundingClientRect();
29
+ const all = el.getElementsByTagName('*');
30
+ return { label: `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : ''}`, y: Math.round(r.top + scrollY), elements: all.length, tags: hash([...all].map((e) => e.tagName).join(',')), styled: [...all].filter((e) => e.hasAttribute('style')).length, shadow: [...all].some((e) => e.shadowRoot) };
31
+ });
32
+ };
33
+ let before, after;
34
+ try {
35
+ const url = `${server.url}index.html`;
36
+ const a = await newPage(browser, vp);
37
+ await a.page.route(/\.m?js(\?|$)/, (r) => r.abort());
38
+ await a.page.goto(url, { waitUntil: 'load', timeout: 60000 });
39
+ await a.page.waitForTimeout(500);
40
+ before = await a.page.evaluate(signatures);
41
+ await a.context.close();
42
+ const b = await newPage(browser, vp, { reducedMotion: 'no-preference' });
43
+ await b.page.goto(url, { waitUntil: 'load', timeout: 60000 });
44
+ await settle(b.page);
45
+ after = await b.page.evaluate(signatures);
46
+ await b.context.close();
47
+ } finally {
48
+ if (!flags.cdp) await browser.close();
49
+ server.close();
50
+ }
51
+ const rows = before.map((s, i) => {
52
+ const t = after[i] || {};
53
+ const changed = !t.label ? 'removed' : t.elements !== s.elements || t.tags !== s.tags ? `rebuilt (${s.elements} → ${t.elements} elements)` : t.styled !== s.styled ? `restyled (${s.styled} → ${t.styled} inline styles)` : null;
54
+ return { section: s.label, y: s.y, before: s.elements, after: t.elements ?? null, changed };
55
+ });
56
+ for (const t of after.slice(before.length)) rows.push({ section: t.label, y: t.y, before: 0, after: t.elements, changed: 'added by a script' });
57
+ await fs.mkdir(path.join(root, 'compare'), { recursive: true });
58
+ await fs.writeFile(path.join(root, 'compare', 'owned.json'), JSON.stringify({ at: new Date().toISOString(), viewport: vp.name, rows }, null, 2));
59
+ // structure changed: the script owns the section; only inline styles changed: it animates inside the seed's markup
60
+ const owned = rows.filter((r) => r.changed && !r.changed.startsWith('restyled'));
61
+ const animated = rows.filter((r) => r.changed?.startsWith('restyled'));
62
+ console.log(owned.length ? `script.js rebuilds ${owned.length} section(s) at ${vp.name}:` : `script.js rebuilds no section at ${vp.name}: the seed's markup and players stand as they are`);
63
+ for (const r of owned) console.log(` ${r.section} (y ${r.y}): ${r.changed}`);
64
+ if (owned.length) console.log('hand these over to script.js (remove the data-tc-* marks there) and hide them until boot if the original hides them too (seed --hide-until-boot).');
65
+ if (animated.length) console.log(`animated inside the seed's markup (inline styles only): ${animated.map((r) => r.section).join(', ')}`);
66
+ console.log(`-> ${path.relative(process.cwd(), path.join(root, 'compare', 'owned.json'))}`);
@@ -22,8 +22,8 @@ if (!meta) {
22
22
  }
23
23
 
24
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**');
25
+ const T = { layout: 98, statesGap: 5, motion: 95, detail: 95, hovers: 90, load: 95 };
26
+ const mark = (ok, acceptedNote) => (ok === null ? '–' : ok ? 'pass' : acceptedNote ? `accepted (${acceptedNote})` : '**below target**');
27
27
  const pct = (v) => (v === undefined || v === null ? '–' : `${v}%`);
28
28
  const VPS = ['desktop', 'tablet', 'mobile'];
29
29
 
@@ -34,35 +34,65 @@ const curves = Object.fromEntries(await Promise.all(VPS.map(async (v) => [v, awa
34
34
  const interact = await read('compare/interact/report.json');
35
35
  const hovers = await read('compare/hovers.desktop.json');
36
36
  const detail = await read('compare/detail.json');
37
+ const load = await read('compare/load.json');
38
+ const times = await read('compare/score-times.json');
39
+ const drift = await read('compare/drift.json');
37
40
  const motionInv = await read('reference/motion.desktop.json');
41
+ // notes.md: the agent's "Accepted differences" and "Findings" come into the report; an accepted entry that names a
42
+ // check (and optionally a width) turns that check's "below target" into "accepted"
43
+ const notes = await fs.readFile(path.join(root, 'notes.md'), 'utf8').catch(() => '');
44
+ const notesSection = (title) => {
45
+ const m = new RegExp(`^## ${title}[^\\n]*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(notes);
46
+ 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
+ };
48
+ const accepted = notesSection('Accepted differences');
49
+ 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)));
38
51
 
39
52
  const byVp = (rep, key = 'match') => Object.fromEntries((rep?.viewports || []).map((v) => [v.viewport, v[key] ?? v.score]));
40
53
  const L = byVp(layout), V = byVp(visual), S = byVp(states), D = byVp(detail, 'score');
41
54
  const C = Object.fromEntries(VPS.map((v) => [v, curves[v]?.score]));
42
55
 
43
56
  const rows = [];
44
- const row = (check, values, ok, note = '') => rows.push(`| ${check} | ${VPS.map((v) => pct(values[v])).join(' | ')} | ${mark(ok)} | ${note} |`);
57
+ // `test` is a per-width predicate (value, width) => boolean, or a ready boolean/null for single-value checks
58
+ const row = (check, values, test, note = '') => {
59
+ const ok = typeof test === 'function' ? all(values, test) : test;
60
+ // below target, but every failing width has an accepted entry in notes.md: the decision is recorded, not hidden
61
+ const key = check.split(' ')[0].toLowerCase();
62
+ 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);
64
+ const stale = drift?.stale && ['detail', 'hovers', 'motion', 'load', 'interactions'].includes(key) ? ' **stale capture**' : '';
65
+ rows.push(`| ${check} | ${VPS.map((v) => pct(values[v])).join(' | ')} | ${mark(ok, covered ? acceptedFor(key)[0].slice(0, 60) : null)}${stale} | ${note} |`);
66
+ };
45
67
  const present = (vals) => VPS.filter((v) => vals[v] !== undefined && vals[v] !== null);
46
68
  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`);
69
+ row(`Layout (structure)`, L, (x) => x >= T.layout, `target ≥ ${T.layout}%; text and media masked`);
70
+ 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`);
72
+ 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
+ row('Detail (styles)', D, (x) => x >= T.detail, `target ≥ ${T.detail}%; font, size, weight, line height, colour, boxes`);
52
74
  row('Hovers', { desktop: hovers?.score }, hovers ? hovers.score >= T.hovers : null, `target ≥ ${T.hovers}% of the reference hover effects`);
75
+ const LD = byVp(load, 'score');
76
+ 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
+ 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>)');
53
78
  const scenarios = interact?.scenarios || [];
54
79
  const failed = scenarios.filter((s) => s.expect && !s.expect.pass);
55
80
  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
81
  scenarios.length ? `${scenarios.length - failed.length}/${scenarios.length} scenarios change something on both sides` : 'no interactions.json');
57
82
 
58
83
  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';
84
+ const acceptedCount = rows.filter((r) => r.includes('| accepted (')).length;
85
+ const verdict = (failing ? `${failing} check(s) below target` : 'every scored check meets its target') + (acceptedCount ? ` (${acceptedCount} accepted with a reason in notes.md)` : '') + (drift?.stale ? '; the live site has changed since the capture: re-capture before trusting the live-measured checks' : '');
60
86
 
61
87
  // Weakest spots, to fix first.
62
88
  const weak = [];
63
89
  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
90
  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
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'}`);
92
+ for (const v of (load?.viewports || []).filter((x) => x.score < T.load)) {
93
+ const worst = Object.entries(v.gap || {}).sort((a, b) => b[1] - a[1])[0];
94
+ 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)`);
95
+ }
66
96
  for (const v of detail?.viewports || []) {
67
97
  const groups = new Map();
68
98
  for (const it of v.issues || []) { const k = it.props[0].split(' ').slice(0, 1)[0]; groups.set(k, (groups.get(k) || 0) + 1); }
@@ -92,6 +122,8 @@ const md = `# trueclone report: ${name}
92
122
  | Captured | ${meta.capturedAt} |
93
123
  | Scored | ${layout?.at || '–'}${layout?.clone && !/127\.0\.0\.1:\d+\/$/.test(layout.clone) ? ` against ${layout.clone}` : ''} |
94
124
  | Verdict | **${verdict}** |
125
+ ${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 |` : ''}
95
127
 
96
128
  ## Scores
97
129
 
@@ -99,8 +131,8 @@ const md = `# trueclone report: ${name}
99
131
  |---|---|---|---|---|---|
100
132
  ${rows.join('\n')}
101
133
 
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
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'}
135
+ ${findings.length ? `## Findings (tool defects met on this site)\n\n${findings.map((f) => `- ${f}`).join('\n')}\n\n` : ''}## Motion found on the reference
104
136
 
105
137
  ${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
138