@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/README.md +27 -32
- package/bin/trueclone.mjs +28 -19
- package/package.json +2 -1
- package/scripts/capture.mjs +239 -9
- package/scripts/curves.mjs +44 -7
- package/scripts/detail.mjs +19 -6
- package/scripts/diff.mjs +4 -2
- package/scripts/drift.mjs +55 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/hovers.mjs +144 -43
- package/scripts/interact.mjs +35 -5
- package/scripts/lib.mjs +77 -29
- package/scripts/load.mjs +106 -0
- package/scripts/next.mjs +158 -28
- package/scripts/owned.mjs +66 -0
- package/scripts/report.mjs +83 -17
- package/scripts/reveals.mjs +178 -80
- package/scripts/review.mjs +149 -0
- package/scripts/score.mjs +74 -9
- package/scripts/scripts-evidence.mjs +116 -0
- package/scripts/seed.mjs +420 -56
- package/scripts/states.mjs +38 -5
- package/scripts/tiles.mjs +33 -17
- package/scripts/understand.mjs +231 -0
- package/scripts/viewport-fit.mjs +198 -0
- package/skill/SKILL.md +119 -116
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
|
431
|
-
//
|
|
432
|
-
|
|
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(
|
|
548
|
+
const SCRIPTS: { src?: string; code?: string; type?: string }[] = ${JSON.stringify(scriptList)};
|
|
435
549
|
|
|
436
550
|
export default function SiteScripts() {
|
|
437
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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'))}`);
|
package/scripts/report.mjs
CHANGED
|
@@ -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,
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
48
|
-
if (meta.mode === 'exact') row('Layout (real pixels)', V,
|
|
49
|
-
row('Scroll states', S,
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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'))}`);
|