@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.
- package/README.md +9 -6
- package/bin/trueclone.mjs +6 -1
- package/package.json +1 -1
- package/scripts/capture.mjs +124 -6
- package/scripts/detail.mjs +19 -6
- package/scripts/drift.mjs +48 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/hovers.mjs +17 -8
- package/scripts/lib.mjs +8 -24
- package/scripts/load.mjs +102 -0
- package/scripts/next.mjs +91 -26
- package/scripts/owned.mjs +66 -0
- package/scripts/report.mjs +43 -11
- package/scripts/reveals.mjs +174 -80
- package/scripts/score.mjs +71 -9
- package/scripts/seed.mjs +316 -25
- package/scripts/understand.mjs +231 -0
- package/skill/SKILL.md +42 -13
package/scripts/load.mjs
ADDED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
|
431
|
-
//
|
|
432
|
-
|
|
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(
|
|
494
|
+
const SCRIPTS: { src?: string; code?: string; type?: string }[] = ${JSON.stringify(scriptList)};
|
|
435
495
|
|
|
436
496
|
export default function SiteScripts() {
|
|
437
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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'))}`);
|
package/scripts/report.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
48
|
-
if (meta.mode === 'exact') row('Layout (real pixels)', V,
|
|
49
|
-
row('Scroll states', S,
|
|
50
|
-
row('Motion curves', C,
|
|
51
|
-
row('Detail (styles)', D,
|
|
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
|
|
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
|
|