@trueclone/cli 0.2.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.
@@ -0,0 +1,102 @@
1
+ // Load sequence: what a visitor sees between the first byte and the settled page, on both sides, measured the same
2
+ // way. Each side loads in a fresh context (cold cache, cache disabled) on the same emulated network, and the visible
3
+ // viewport is photographed 250, 500, 1000, 2000 and 4000 ms after navigation starts, then once more when the page has
4
+ // settled. "Settled at t" is how much the frame at t already matches the final frame (pixel match), so a clone that
5
+ // paints a wrong skeleton for two seconds, or settles much later or earlier than the original, shows up as a gap
6
+ // between the two curves. The score is 100 minus the mean gap. Reference curves are cached per capture.
7
+ //
8
+ // usage: node scripts/load.mjs <name> [--viewport desktop|tablet|mobile|all] [--url <clone url>] [--refresh-ref]
9
+ // -> sites/<name>/compare/load.json, frames in compare/load/ and reference/load/
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, matchPct, cached, PNG, withTimeout } from './lib.mjs';
13
+
14
+ const { pos, flags } = parseArgs(process.argv.slice(2));
15
+ const [name] = pos;
16
+ if (!name) {
17
+ console.error('usage: trueclone load <name> [--viewport desktop|all] [--url <clone url>] [--refresh-ref]');
18
+ process.exit(1);
19
+ }
20
+ const root = path.resolve('sites', name);
21
+ const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
22
+ const vps = !flags.viewport || flags.viewport === 'all' ? VIEWPORTS : [VIEWPORTS.find((v) => v.name === flags.viewport)].filter(Boolean);
23
+ const TIMES = [250, 500, 1000, 2000, 4000];
24
+ const SETTLED = 98; // a frame this close to the final one counts as settled
25
+ const NOISE_LIMIT = 15; // two runs of one side further apart than this: that side is unstable, the width is not scored
26
+ // the same network for both sides: the clone is served from this machine, the reference from a CDN, so both are
27
+ // bounded by the emulated link, not by where they come from
28
+ const NETWORK = { offline: false, latency: 40, downloadThroughput: 1.25e6, uploadThroughput: 6e5 };
29
+
30
+ async function sequence(browser, url, vp, dir) {
31
+ const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
32
+ try {
33
+ const cdp = await context.newCDPSession(page);
34
+ await cdp.send('Network.enable');
35
+ await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
36
+ await cdp.send('Network.emulateNetworkConditions', NETWORK);
37
+ await fs.mkdir(dir, { recursive: true });
38
+ const frames = {};
39
+ const t0 = Date.now();
40
+ const nav = page.goto(url, { waitUntil: 'load', timeout: 60000 }).catch(() => null);
41
+ for (const t of TIMES) {
42
+ const wait = t0 + t - Date.now();
43
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
44
+ // the frame may be blank before the first byte (a valid part of the sequence)
45
+ const shot = await withTimeout(page.screenshot({ fullPage: false, animations: 'allow' }), 5000, `load shot ${t}`).catch(() => null);
46
+ frames[t] = shot ? PNG.sync.read(shot) : null;
47
+ if (shot) await fs.writeFile(path.join(dir, `${vp.name}-${t}.png`), shot);
48
+ }
49
+ await nav;
50
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
51
+ await page.evaluate(() => document.fonts.ready).catch(() => {});
52
+ await page.waitForTimeout(1500);
53
+ const finalShot = await page.screenshot({ fullPage: false, animations: 'allow' });
54
+ await fs.writeFile(path.join(dir, `${vp.name}-final.png`), finalShot);
55
+ const final = PNG.sync.read(finalShot);
56
+ const curve = {};
57
+ for (const t of TIMES) curve[t] = frames[t] ? matchPct(frames[t], final).pct : 0;
58
+ const settledAt = TIMES.find((t) => curve[t] >= SETTLED) ?? null;
59
+ return { curve, settledAt };
60
+ } finally {
61
+ await context.close();
62
+ }
63
+ }
64
+
65
+ const cloneUrl = typeof flags.url === 'string' ? flags.url : null;
66
+ const server = cloneUrl ? null : await serve(path.join(root, 'clone'));
67
+ const browser = await openBrowser(flags.cdp);
68
+ const viewports = [];
69
+ try {
70
+ // each side twice: a load is never the same twice (an image that arrives a frame later moves an intro), so the
71
+ // curve is the mean of two runs and the spread between them is reported as the measurement's own noise
72
+ const twice = async (url, dir, vp) => {
73
+ const a = await sequence(browser, url, vp, dir);
74
+ const b = await sequence(browser, url, vp, dir);
75
+ const curve = Object.fromEntries(TIMES.map((t) => [t, +((a.curve[t] + b.curve[t]) / 2).toFixed(2)]));
76
+ const noise = +Math.max(...TIMES.map((t) => Math.abs(a.curve[t] - b.curve[t]))).toFixed(2);
77
+ return { curve, noise, settledAt: TIMES.find((t) => curve[t] >= SETTLED) ?? null, runs: [a, b] };
78
+ };
79
+ for (const vp of vps) {
80
+ const ref = await cached(root, { tool: 'load', vp: vp.name, times: TIMES, network: NETWORK, v: 2 }, () => twice(meta.url, path.join(root, 'reference', 'load'), vp), Boolean(flags['refresh-ref']));
81
+ const clone = await twice(cloneUrl || `${server.url}index.html`, path.join(root, 'compare', 'load'), vp);
82
+ const gap = Object.fromEntries(TIMES.map((t) => [t, +Math.abs(ref.curve[t] - clone.curve[t]).toFixed(2)]));
83
+ const noise = +Math.max(ref.noise, clone.noise).toFixed(2);
84
+ // a side that does not agree with itself (an intro that starts a second later in one run) cannot be scored
85
+ // against anything: the width is reported as unstable, like a probe, instead of as a number
86
+ const unstable = noise > NOISE_LIMIT;
87
+ const score = unstable ? null : +(100 - TIMES.reduce((s, t) => s + gap[t], 0) / TIMES.length).toFixed(2);
88
+ viewports.push({ viewport: vp.name, score, unstable, noise, gap, ref, clone });
89
+ console.log(unstable
90
+ ? `${vp.name}: load unstable (noise ±${noise} between two runs of the ${ref.noise > clone.noise ? 'reference' : 'clone'} itself; not scored; curves ref ${TIMES.map((t) => ref.curve[t]).join('/')} clone ${TIMES.map((t) => clone.curve[t]).join('/')})`
91
+ : `${vp.name}: load match ${score}% (noise ±${noise}; settled: reference ${ref.settledAt ?? '>4000'}ms, clone ${clone.settledAt ?? '>4000'}ms; curves ref ${TIMES.map((t) => ref.curve[t]).join('/')} clone ${TIMES.map((t) => clone.curve[t]).join('/')})`);
92
+ }
93
+ } finally {
94
+ if (!flags.cdp) await browser.close();
95
+ server?.close();
96
+ }
97
+ await fs.mkdir(path.join(root, 'compare'), { recursive: true });
98
+ const out = path.join(root, 'compare', 'load.json');
99
+ const previous = await fs.readFile(out, 'utf8').then(JSON.parse).catch(() => null);
100
+ const merged = vps.length === VIEWPORTS.length || !previous ? viewports : [...(previous.viewports || []).filter((v) => !viewports.some((n) => n.viewport === v.viewport)), ...viewports];
101
+ await fs.writeFile(out, JSON.stringify({ at: new Date().toISOString(), clone: cloneUrl || server?.url, times: TIMES, network: NETWORK, viewports: merged }, null, 2));
102
+ console.log(`-> ${path.relative(process.cwd(), out)}`);
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,22 @@ 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
+ 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 });
295
322
  await fs.writeFile(path.join(out, 'postcss.config.mjs'), "export default { plugins: { '@tailwindcss/postcss': {} } };\n");
296
323
  await fs.writeFile(path.join(out, 'next.config.mjs'), `import path from 'node:path';
297
324
  import { fileURLToPath } from 'node:url';
@@ -335,7 +362,11 @@ async function seededApp() {
335
362
  await fs.mkdir(path.join(out, 'styles', 'site'), { recursive: true });
336
363
  const imports = [];
337
364
  let inline = 0, usesTailwind = false;
338
- for (const e of els(head)) {
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]) {
339
370
  const a = attrsOf(e);
340
371
  let css = null, file = null;
341
372
  if (e.tagName === 'link' && a.rel === 'stylesheet' && a.href && !/^(https?:)?\/\//.test(a.href)) {
@@ -424,30 +455,60 @@ async function seededApp() {
424
455
  const scripts = els(body).filter((e) => e.tagName === 'script');
425
456
  const dataBlocks = scripts.filter((s) => /json/.test(attrsOf(s).type || '') && attrsOf(s).id)
426
457
  .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)));
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' } : {}) }));
429
486
  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';
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';
433
493
 
434
- const SCRIPTS = ${JSON.stringify(srcs.map((s) => '/' + path.basename(s)))};
494
+ const SCRIPTS: { src?: string; code?: string; type?: string }[] = ${JSON.stringify(scriptList)};
435
495
 
436
496
  export default function SiteScripts() {
437
- useEffect(() => {
497
+ useLayoutEffect(() => {
438
498
  const w = window as unknown as Record<string, unknown>;
439
499
  if (w.__siteScripts) return; // React strict mode runs effects twice in dev
440
500
  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
- })();
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();
451
512
  }, []);
452
513
  return null;
453
514
  }
@@ -471,6 +532,10 @@ export const metadata: Metadata = {
471
532
  export default function RootLayout({ children }: { children: React.ReactNode }) {
472
533
  return (
473
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>
474
539
  <body${bodyAttrs} suppressHydrationWarning>{children}</body>
475
540
  </html>
476
541
  );
@@ -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