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