@trueclone/cli 0.2.0 → 0.4.0-rc.1

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: {
@@ -84,12 +88,17 @@ function jsxAttrs(node, inSvg) {
84
88
  if (node.tagName === 'input' && a.name === 'value') { parts.push(attrString('defaultValue', a.value)); continue; }
85
89
  if (node.tagName === 'input' && a.name === 'checked') { parts.push('defaultChecked'); continue; }
86
90
  if (LEGACY.has(a.name) && !inSvg) { parts.push(`{...{ ${JSON.stringify(a.name)}: ${JSON.stringify(a.value)} }}`); continue; }
87
- if (a.name === 'style') { usesStyle = true; parts.push(`style={${JSON.stringify(styleObject(a.value))} as CSSProperties}`); 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; }
88
93
  // SVG elements have no typed boolean props (hidden), so they go through the spread too
89
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; }
90
95
  let n = RENAME[a.name] || a.name;
91
96
  if (!RENAME[a.name] && inSvg && !n.startsWith('data-') && !n.startsWith('aria-')) n = camel(n);
92
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; }
93
102
  const v = ['src', 'href', 'poster', 'srcSet'].includes(n) ? fixUrl(a.value) : a.value;
94
103
  // React types these as numbers: tabIndex="0" fails type-checking, tabIndex={0} is the same attribute
95
104
  if (['tabIndex', 'colSpan', 'rowSpan', 'maxLength', 'minLength', 'span', 'size', 'cols', 'rows'].includes(n) && /^-?\d+$/.test(v)) { parts.push(`${n}={${v}}`); continue; }
@@ -126,6 +135,12 @@ function toJsx(node, depth, inSvg = false, inPre = false) {
126
135
  const svg = inSvg || tag === 'svg';
127
136
  const pre = inPre || keepsWhitespace(node);
128
137
  const attrs = jsxAttrs(node, svg);
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
+ }
129
144
  const kids = (node.childNodes || []).map((c) => toJsx(c, depth + 1, svg, pre)).filter((s) => s !== '');
130
145
  if (VOID.has(tag) || !kids.length) return `${pad}<${tag}${attrs} />`;
131
146
  const inline = kids.every((k) => !k.startsWith(' ')) && kids.join('').length < 120;
@@ -151,10 +166,6 @@ await fs.rm(path.join(out, 'app'), { recursive: true, force: true });
151
166
  await fs.rm(path.join(out, 'components', 'site'), { recursive: true, force: true });
152
167
  for (const d of ['app', 'components/site', 'lib', 'styles', 'public']) await fs.mkdir(path.join(out, d), { recursive: true });
153
168
 
154
- // Two kinds of clone. A hand-built one (styles.css + one script.js using GSAP/Lenis/Swiper/three) is converted
155
- // section by section below; a seeded one (trueclone seed: the original's own stylesheets, trueclone's players and
156
- // a script.js) goes through seededApp(), further down.
157
- const seeded = await fs.stat(path.join(cloneDir, 'css')).then(() => true, () => false);
158
169
  let components = [];
159
170
  if (seeded) components = await seededApp();
160
171
  else {
@@ -292,6 +303,27 @@ await fs.rm(path.join(out, 'public', 'assets'), { recursive: true, force: true }
292
303
  await fs.cp(path.join(cloneDir, 'assets'), path.join(out, 'public', 'assets'), { recursive: true });
293
304
  for (const f of ['DESIGN.md', 'design-tokens.json']) await fs.copyFile(path.join(root, f), path.join(out, f)).catch(() => {});
294
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
+ // 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.
310
+ const custom = new Set();
311
+ (function walk(n) { if (n.tagName && n.tagName.includes('-')) custom.add(n.tagName); for (const c of n.childNodes || []) walk(c); })(body);
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';
315
+
316
+ declare module 'react' {
317
+ interface HTMLAttributes<T> {
318
+ [attribute: string]: unknown;
319
+ }
320
+ ${custom.size ? ` namespace JSX {
321
+ interface IntrinsicElements {
322
+ ${[...custom].map((t) => ` ${JSON.stringify(t)}: DetailedHTMLProps<ReactHTMLAttributes<HTMLElement>, HTMLElement>;`).join('\n')}
323
+ }
324
+ }
325
+ ` : ''}}
326
+ `);
295
327
  await fs.writeFile(path.join(out, 'postcss.config.mjs'), "export default { plugins: { '@tailwindcss/postcss': {} } };\n");
296
328
  await fs.writeFile(path.join(out, 'next.config.mjs'), `import path from 'node:path';
297
329
  import { fileURLToPath } from 'node:url';
@@ -335,12 +367,35 @@ async function seededApp() {
335
367
  await fs.mkdir(path.join(out, 'styles', 'site'), { recursive: true });
336
368
  const imports = [];
337
369
  let inline = 0, usesTailwind = false;
338
- for (const e of els(head)) {
370
+ // <style> blocks (and stylesheet links) can sit inside <body> too, anywhere in the tree: same cascade, so they
371
+ // follow the head's in document order
372
+ const bodyStyles = [];
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
+ };
392
+ for (const e of [...els(head), ...bodyStyles]) {
339
393
  const a = attrsOf(e);
340
- let css = null, file = null;
394
+ let css = null, file = null, fromDir = cloneDir;
341
395
  if (e.tagName === 'link' && a.rel === 'stylesheet' && a.href && !/^(https?:)?\/\//.test(a.href)) {
342
396
  css = await fs.readFile(path.join(cloneDir, a.href), 'utf8').catch(() => null);
343
397
  file = path.basename(a.href);
398
+ fromDir = path.dirname(path.join(cloneDir, a.href));
344
399
  } else if (e.tagName === 'style') {
345
400
  css = (e.childNodes || []).map((c) => c.value || '').join('');
346
401
  file = `inline-${++inline}.css`;
@@ -348,6 +403,8 @@ async function seededApp() {
348
403
  if (css === null) continue;
349
404
  // stylesheets lived in clone/css/ (assets at ../assets/), inline blocks in the page (assets at assets/)
350
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);
351
408
  usesTailwind ||= /--tw-|tailwindcss v\d/.test(css);
352
409
  await fs.writeFile(path.join(out, 'styles', 'site', file), css);
353
410
  imports.push(`import '../styles/site/${file}';`);
@@ -360,7 +417,11 @@ async function seededApp() {
360
417
  const tailwind = usesTailwind ? '@import "tailwindcss" source(none);'
361
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);';
362
419
  const scoped = theme.replace('@import "tailwindcss";', `${tailwind}\n@source "../components/ui";`);
363
- 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);
364
425
  const siteCssImports = imports;
365
426
 
366
427
  // markup
@@ -424,30 +485,84 @@ async function seededApp() {
424
485
  const scripts = els(body).filter((e) => e.tagName === 'script');
425
486
  const dataBlocks = scripts.filter((s) => /json/.test(attrsOf(s).type || '') && attrsOf(s).id)
426
487
  .map((s) => ` <script type="${attrsOf(s).type}" id="${attrsOf(s).id}" dangerouslySetInnerHTML={{ __html: ${JSON.stringify((s.childNodes || []).map((c) => c.value || '').join(''))} }} />`);
427
- const srcs = scripts.map((s) => attrsOf(s).src).filter((s) => s && !/^(https?:)?\/\//.test(s));
428
- for (const s of srcs) await fs.copyFile(path.join(cloneDir, s), path.join(out, 'public', path.basename(s)));
488
+ // the scripts, copied to public/ at their own relative paths (a script that loads a sibling file keeps working),
489
+ // with the files their import statements and import() calls name; a module script stays a module
490
+ // in document order: files (copied) and inline scripts (run as written); data blocks are rendered in the page
491
+ const scriptTags = scripts.map((s) => ({ ...attrsOf(s), code: (s.childNodes || []).map((c) => c.value || '').join('') }))
492
+ .filter((a) => !/json/.test(a.type || '') && (a.src ? !/^(https?:)?\/\//.test(a.src) : a.code.trim()));
493
+ const copied = new Set();
494
+ const copyScript = async (rel, depth = 0) => {
495
+ const clean = rel.split(/[?#]/)[0].replace(/^\.\//, '');
496
+ if (copied.has(clean) || depth > 4) return;
497
+ copied.add(clean);
498
+ const from = path.join(cloneDir, clean);
499
+ const text = await fs.readFile(from, 'utf8').catch(() => null);
500
+ if (text === null) { console.warn(`WARNING script referenced but not in clone/: ${clean}`); return; }
501
+ await fs.mkdir(path.dirname(path.join(out, 'public', clean)), { recursive: true });
502
+ await fs.copyFile(from, path.join(out, 'public', clean));
503
+ for (const m of text.matchAll(/(?:^|[^\w$.])import\s*(?:[\w$*{},\s]+from\s*)?['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g)) {
504
+ const spec = m[1] || m[2];
505
+ if (!spec || /^(https?:)?\/\//.test(spec) || !/^[./]/.test(spec)) continue;
506
+ await copyScript(path.posix.join(path.posix.dirname(clean), spec), depth + 1);
507
+ }
508
+ // a script file named in a string (new URL('vendor/three.module.min.js', here), a worker, a lazy loader)
509
+ for (const m of text.matchAll(/['"]([\w./-]+\.m?js)['"]/g)) {
510
+ const rel = path.posix.join(path.posix.dirname(clean), m[1].replace(/^\//, ''));
511
+ if (!copied.has(rel) && await fs.stat(path.join(cloneDir, rel)).then(() => true, () => false)) await copyScript(rel, depth + 1);
512
+ }
513
+ };
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
+ }
539
+ const scriptList = scriptTags.map((a) => ({ ...(a.src ? { src: '/' + a.src.split(/[?#]/)[0].replace(/^\.\//, '') } : { code: a.code }), ...(a.type === 'module' ? { type: 'module' } : {}) }));
429
540
  await fs.writeFile(path.join(out, 'components', 'site', 'SiteScripts.tsx'), `'use client';
430
- // Generated by scripts/next.mjs: runs the clone's scripts (trueclone's reveal and widget players, script.js) in
431
- // order, once, after hydration, exactly as the static clone does after parsing.
432
- import { useEffect } from 'react';
541
+ // Generated by scripts/next.mjs: runs the clone's scripts (trueclone's players, inline scripts, script.js) in order,
542
+ // once, the moment hydration is done. The HTML clone runs them as deferred scripts right after parsing; a component
543
+ // app cannot (hydration would re-render what they change), so the files are preloaded from the first byte (see
544
+ // layout.tsx), inserted together with async=false (ordered execution, parallel fetch), and the boot contract
545
+ // (<html class="tc-boot">, seed) keeps script-built regions hidden until the last one has run.
546
+ import { useLayoutEffect } from 'react';
433
547
 
434
- const SCRIPTS = ${JSON.stringify(srcs.map((s) => '/' + path.basename(s)))};
548
+ const SCRIPTS: { src?: string; code?: string; type?: string }[] = ${JSON.stringify(scriptList)};
435
549
 
436
550
  export default function SiteScripts() {
437
- useEffect(() => {
551
+ useLayoutEffect(() => {
438
552
  const w = window as unknown as Record<string, unknown>;
439
553
  if (w.__siteScripts) return; // React strict mode runs effects twice in dev
440
554
  w.__siteScripts = true;
441
- (async () => {
442
- for (const src of SCRIPTS) {
443
- await new Promise((resolve) => {
444
- const s = document.createElement('script');
445
- s.src = src;
446
- s.onload = s.onerror = () => resolve(null);
447
- document.body.appendChild(s);
448
- });
449
- }
450
- })();
555
+ w.__tcInjected = true;
556
+ let last: HTMLScriptElement | null = null;
557
+ for (const { src, code, type } of SCRIPTS) {
558
+ const s = document.createElement('script');
559
+ if (type) s.type = type;
560
+ if (src) { s.src = src; s.async = false; } else { s.textContent = code || ''; }
561
+ document.body.appendChild(s);
562
+ if (src) last = s;
563
+ }
564
+ const boot = () => { const b = (w as { tcBoot?: { release: () => void } }).tcBoot; if (b) b.release(); else requestAnimationFrame(() => document.documentElement.classList.remove('tc-boot')); };
565
+ if (last) last.addEventListener('load', boot), last.addEventListener('error', boot); else boot();
451
566
  }, []);
452
567
  return null;
453
568
  }
@@ -471,6 +586,11 @@ export const metadata: Metadata = {
471
586
  export default function RootLayout({ children }: { children: React.ReactNode }) {
472
587
  return (
473
588
  <html${htmlAttrs} suppressHydrationWarning>
589
+ <head>
590
+ ${headLinks.join('\n')}
591
+ {/* the clone's scripts start downloading with the page; SiteScripts runs them the moment hydration is done */}
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')}
593
+ </head>
474
594
  <body${bodyAttrs} suppressHydrationWarning>{children}</body>
475
595
  </html>
476
596
  );
@@ -522,11 +642,21 @@ if (flags.verify) {
522
642
  for (let i = 0; i < 60; i++) { try { if ((await fetch(url)).ok) break; } catch { /* starting */ } await new Promise((r) => setTimeout(r, 1000)); }
523
643
  const lab = path.resolve('.');
524
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); }
525
650
  try {
526
651
  // same scoring entry point as the HTML clone: time limit per step, summary at the end
527
652
  await score([path.join(path.dirname(toPath(import.meta.url)), 'score.mjs'), name, '--url', url, ...(flags.quick ? ['--quick'] : [])]);
528
653
  } finally {
529
654
  if (process.platform === 'win32') spawn('taskkill', ['/pid', String(server.pid), '/T', '/F']);
530
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`);
531
661
  }
532
662
  }
@@ -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'))}`);
@@ -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 };
26
- const mark = (ok) => (ok === null ? '–' : ok ? 'pass' : '**below target**');
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;
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');
@@ -34,35 +39,89 @@ const curves = Object.fromEntries(await Promise.all(VPS.map(async (v) => [v, awa
34
39
  const interact = await read('compare/interact/report.json');
35
40
  const hovers = await read('compare/hovers.desktop.json');
36
41
  const detail = await read('compare/detail.json');
42
+ const load = await read('compare/load.json');
43
+ const times = await read('compare/score-times.json');
44
+ const drift = await read('compare/drift.json');
37
45
  const motionInv = await read('reference/motion.desktop.json');
46
+ // notes.md: the agent's "Accepted differences" and "Findings" come into the report; an accepted entry that names a
47
+ // check (and optionally a width) turns that check's "below target" into "accepted"
48
+ const notes = await fs.readFile(path.join(root, 'notes.md'), 'utf8').catch(() => '');
49
+ const notesSection = (title) => {
50
+ const m = new RegExp(`^## ${title}[^\\n]*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(notes);
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+/, '')) : [];
52
+ };
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));
65
+ const findings = notesSection('Findings');
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));
38
71
 
39
72
  const byVp = (rep, key = 'match') => Object.fromEntries((rep?.viewports || []).map((v) => [v.viewport, v[key] ?? v.score]));
40
73
  const L = byVp(layout), V = byVp(visual), S = byVp(states), D = byVp(detail, 'score');
41
74
  const C = Object.fromEntries(VPS.map((v) => [v, curves[v]?.score]));
42
75
 
43
76
  const rows = [];
44
- const row = (check, values, ok, note = '') => rows.push(`| ${check} | ${VPS.map((v) => pct(values[v])).join(' | ')} | ${mark(ok)} | ${note} |`);
77
+ // `test` is a per-width predicate (value, width) => boolean, or a ready boolean/null for single-value checks
78
+ const row = (check, values, test, note = '') => {
79
+ const ok = typeof test === 'function' ? all(values, test) : test;
80
+ // below target, but every failing width has an accepted entry in notes.md: the decision is recorded, not hidden
81
+ const key = check.split(' ')[0].toLowerCase();
82
+ const failing = typeof test === 'function' ? present(values).filter((v) => !test(values[v], v)) : ok === false ? present(values) : [];
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);
85
+ const stale = drift?.stale && ['detail', 'hovers', 'motion', 'load', 'interactions'].includes(key) ? ' **stale capture**' : '';
86
+ rows.push(`| ${check} | ${VPS.map((v) => pct(values[v])).join(' | ')} | ${mark(ok, covered ? acceptedFor(key)[0].slice(0, 60) : null)}${stale} | ${note} |`);
87
+ };
45
88
  const present = (vals) => VPS.filter((v) => vals[v] !== undefined && vals[v] !== null);
46
89
  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`);
90
+ row(`Layout (structure)`, L, (x) => x >= T.layout, `target ≥ ${T.layout}%; text and media masked`);
91
+ if (meta.mode === 'exact') row('Layout (real pixels)', V, (x) => x >= T.layout, 'exact mode: same text and media');
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)`);
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)');
96
+ row('Detail (styles)', D, (x) => x >= T.detail, `target ≥ ${T.detail}%; font, size, weight, line height, colour, boxes`);
52
97
  row('Hovers', { desktop: hovers?.score }, hovers ? hovers.score >= T.hovers : null, `target ≥ ${T.hovers}% of the reference hover effects`);
98
+ const LD = byVp(load, 'score');
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);
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 |`);
53
105
  const scenarios = interact?.scenarios || [];
54
106
  const failed = scenarios.filter((s) => s.expect && !s.expect.pass);
55
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,
56
- 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');
57
109
 
58
110
  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';
111
+ const acceptedCount = rows.filter((r) => r.includes('| accepted (')).length;
112
+ 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
113
 
61
114
  // Weakest spots, to fix first.
62
115
  const weak = [];
63
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}%`);
64
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})` : ''}`);
65
- for (const s of failed) weak.push(`- Interaction "${s.name}": the expected change happened on ${s.expect.ref ? 'the reference' : 'neither side'}${s.expect.clone ? '' : ', not on the clone'}`);
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)`);
121
+ for (const v of (load?.viewports || []).filter((x) => x.score < T.load)) {
122
+ const worst = Object.entries(v.gap || {}).sort((a, b) => b[1] - a[1])[0];
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)`);
124
+ }
66
125
  for (const v of detail?.viewports || []) {
67
126
  const groups = new Map();
68
127
  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,15 +151,20 @@ const md = `# trueclone report: ${name}
92
151
  | Captured | ${meta.capturedAt} |
93
152
  | Scored | ${layout?.at || '–'}${layout?.clone && !/127\.0\.0\.1:\d+\/$/.test(layout.clone) ? ` against ${layout.clone}` : ''} |
94
153
  | Verdict | **${verdict}** |
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' : ''} |` : ''}
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 |` : ''}
95
157
 
96
158
  ## Scores
97
159
 
98
- | Check | Desktop | Tablet | Mobile | Status | Notes |
99
- |---|---|---|---|---|---|
160
+ | Check | Desktop 1440 | Tablet 768 | Mobile 390 | Laptop 1280 ° | Wide 1920 ° | Status | Notes |
161
+ |---|---|---|---|---|---|---|---|
100
162
  ${rows.join('\n')}
101
163
 
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
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'}
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
104
168
 
105
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>).'}
106
170
 
@@ -110,5 +174,7 @@ ${motionInv ? `Libraries: ${libs.length ? libs.join(', ') : 'none (no GSAP, Leni
110
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(', ')}` : ''}.
111
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).'}
112
176
  `;
113
- 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'));
114
180
  console.log(`${verdict} -> ${path.relative(process.cwd(), path.join(root, 'REPORT.md'))}`);