@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 CHANGED
@@ -23,46 +23,55 @@ npx @trueclone/cli init # once per project: installs trueclone, the s
23
23
 
24
24
  ## Proof
25
25
 
26
- Two benchmarks with very different stacks, scored by the tool itself:
26
+ Three benchmarks with very different stacks, scored by the tool itself:
27
27
 
28
28
  **[rea.tools](https://rea.tools/)**: static docs with a canvas game and interactive demos, exact mode (copied with the owner's permission).
29
29
 
30
30
  | Check | Desktop | Tablet | Mobile |
31
31
  |---|---|---|---|
32
32
  | Layout, real pixels | 100% | 100% | 100% |
33
- | Scroll states | 100% | 100% | 99.99% |
33
+ | Scroll states | 100% | 100% | 100% |
34
34
  | Computed styles (≈280 points per width) | 100% | 100% | 100% |
35
35
  | Hover effects | 100% | | |
36
36
  | Interactions (copy, tabs, calculator, dinosaur game, language menu) | 5/5 | | |
37
37
 
38
- **A Webflow motorsport portfolio** built with GSAP, ScrollTrigger, SplitText, Lenis, Swiper and three.js (97 scroll triggers), template mode (own placeholder content):
38
+ **A Next.js + React + Framer Motion developer landing page** (12,000px, 11 scroll reveals with spring easing, a scroll-state header, marquees, view-mounted components), template mode, **seeded automatically**: no hand-written layout code, no original text or media in the clone.
39
+
40
+ | Check | Desktop | Tablet | Mobile |
41
+ |---|---|---|---|
42
+ | Layout, structure | 99.99% | 99.93% | 99.94% |
43
+ | Scroll states | 99.96% | 99.92% | 99.94% |
44
+ | Computed styles | 99.5% | 98.7% | 99.2% |
45
+ | Hover effects | 100% | | |
46
+
47
+ **A Webflow motorsport portfolio** built with GSAP, ScrollTrigger, SplitText, Lenis, Swiper and three.js (97 scroll triggers), template mode, built by hand before seeding existed:
39
48
 
40
49
  | Check | Desktop | Tablet | Mobile |
41
50
  |---|---|---|---|
42
51
  | Layout, structure | 99.6% | 99.7% | 96.8% |
43
- | Scroll states | 97.1% | 96.7% | 96.5% |
44
- | Motion curves | 96.1% | 98.2% | 100% |
45
- | Computed styles | 94% | 88.9% | 81.8% |
52
+ | Scroll states | 98.0% | 96.6% | 96.5% |
53
+ | Motion curves | 96.9% | 98.2% | 99.6% |
54
+ | Computed styles | 94% | 88.9% | 85.4% |
46
55
  | Hover effects | 100% | | |
47
56
  | Interactions (drag sliders, drag gallery, cursor follow, menu, skip) | 5/5 | | |
48
57
 
49
- Not perfect yet, and the report says so: the computed-style check is below its 95% target (placeholder text in highlighted phrases, a scroll-linked colour fade, mobile spacing), and so is mobile layout. The generated Next.js app scores within half a point of the HTML clone on every check. A full scoring run takes 1 to 9 minutes.
58
+ The hand-built portfolio isn't perfect yet, and its report says so: computed styles and mobile layout are below target (placeholder text in highlighted phrases, a scroll-linked colour fade, mobile spacing). The generated Next.js app scores within half a point of the HTML clone on every check. A full scoring run takes 1.5 to 9 minutes.
50
59
 
51
60
  ## How it works
52
61
 
53
62
  ```
54
63
  URL ─► capture ─► understand ─► design system ─► build ─► score ─► report
55
- 3 widths motion DESIGN.md your 7 checks REPORT.md
64
+ 3 widths motion DESIGN.md your 8 checks REPORT.md
56
65
  HAR, HTML inventory globals.css agent in parallel
57
66
  assets + REA tokens.css ▲ │
58
67
  └─────┘ until every target passes
59
68
  ```
60
69
 
61
70
  1. **Capture** (`trueclone capture`): full-page screenshots at 390, 768 and 1440px, sections, rendered HTML, assets, every network request.
62
- 2. **Understand** (`trueclone motion`, REA): reads the live animation runtime (GSAP timelines, ScrollTriggers, Lenis and Swiper settings, CSS animations, cursor-driven tweens) and, through REA, bundles, source maps, event listeners and API endpoints.
71
+ 2. **Understand** (`trueclone motion`, `trueclone understand`): reads the live animation runtime (GSAP timelines, ScrollTriggers, Lenis and Swiper settings, CSS animations, cursor-driven tweens) and, with REA, which scripts actually run while a visitor scrolls, which handler each control has and the code around it, the endpoints in the code, source maps when published: `code.md`, so the agent copies rules instead of guessing them from effects.
63
72
  3. **Design system** (`trueclone design`): one canonical `design-tokens.json`, rendered to DESIGN.md, globals.css and the clone's tokens.
64
- 4. **Build**: your agent writes the clone, guided by the skill. `trueclone rules` shows the original's CSS for any element (like DevTools), `trueclone textshape` gives placeholder text the original's line breaks, `trueclone seed` starts an exact clone from a script-free mirror.
65
- 5. **Score** (`trueclone score`): layout pixel diff per section, scroll states replayed, motion curves sampled, interaction scenarios with change assertions, every hover, computed styles at matching points. Reference measurements are cached, so only the clone is re-measured.
73
+ 4. **Build**: `trueclone reveals` records, at every width, the original's scroll reveals frame by frame, its scroll-linked curves (parallax) and its scroll-driven class changes; `trueclone widgets` clicks every tab and menu and records each state; `trueclone seed` turns the script-free mirror into the clone (template mode: layout-measured lorem text and same-size media placeholders), cleans what the original's scripts left in the DOM (pin-spacers, canvas buffer sizes) and replays what was recorded. Layout is at 99%+ before anyone writes CSS; your agent writes the remaining behaviour, guided by the skill. `trueclone rules` shows the original's CSS for any element (like DevTools).
74
+ 5. **Score** (`trueclone score`): layout pixel diff per section, scroll states replayed, motion curves sampled, interaction scenarios with change assertions, every hover, computed styles at matching points, and the load sequence (how settled each side is at 0.25 to 4 s after navigation, cold cache, same emulated network). Reference measurements are cached, so only the clone is re-measured.
66
75
  6. **Report**: `REPORT.md` with the verdict, what to fix first, and what was not copied.
67
76
 
68
77
  ## Why not just REA?
@@ -77,7 +86,7 @@ trueclone **bundles** [REA](https://github.com/morluto/rea) (Reverse Engineer An
77
86
  | Tells you what's wrong | No | "Footer column 2 is at x 189, the original's at 732", plus the original's CSS rule |
78
87
  | Outputs | Whatever the agent writes | HTML clone, Next.js app, DESIGN.md, globals.css, motion recipe, REPORT.md |
79
88
 
80
- REA answers *how does this work?* trueclone answers *is my copy identical, and where isn't it?* For the hard parts (a bundled physics rule, a shader, the API a component calls) the skill sends your agent to REA.
89
+ REA answers *how does this work?* trueclone answers *is my copy identical, and where isn't it?* trueclone proves the visual and behavioural match on its own; `trueclone understand` adds REA's code-level evidence (which scripts ran, the handler behind each control with its source, endpoints, original sources when published) so the agent copies rules instead of inferring them. For the hard parts (a bundled physics rule, a shader, a module's data flow) REA's own CLI is one command away.
81
90
 
82
91
  ## Modes and guardrails
83
92
 
@@ -98,11 +107,16 @@ npx trueclone doctor # check Node, Chrome and REA
98
107
  npx trueclone <url> [name] # capture + design-system draft
99
108
  npx trueclone capture <url> [name] [--mode exact --permission "<owner>"]
100
109
  npx trueclone motion <name> # motion inventory
110
+ npx trueclone understand <name> # code evidence with REA: which scripts ran, each control's handler with its source -> code.md
101
111
  npx trueclone design <name> # DESIGN.md + globals.css
102
- npx trueclone seed <name> # exact mode: start clone/ from the mirror
112
+ npx trueclone reveals <name> # record scroll reveals and scroll class changes
113
+ npx trueclone widgets <name> # record every tab and menu state
114
+ npx trueclone seed <name> # clone/ from the mirror (template: placeholder text and media)
103
115
  npx trueclone rules <name> "<selector>" # the original's CSS rules for an element
104
116
  npx trueclone textshape <name> --write # template mode: placeholder text with the original's line breaks
105
- npx trueclone score <name> [--quick] # every check, then REPORT.md
117
+ npx trueclone load <name> # load sequence on both sides: settledness 0.25-4 s after navigation, cold cache, same network
118
+ npx trueclone score <name> [--quick] # every check, then REPORT.md (--only detail,load --viewport tablet after a fix; unchanged checks are kept)
119
+ npx trueclone explain <name> <y> # one place on the page: what every check saw there, plus a reference|clone|diff crop
106
120
  npx trueclone next <name> --verify # Next.js + Tailwind + shadcn app, scored
107
121
  npx trueclone rea <args> # bundled REA
108
122
  npx trueclone help # every step
package/bin/trueclone.mjs CHANGED
@@ -23,12 +23,19 @@ const STEPS = {
23
23
  seed: ['seed.mjs', 'exact mode: start clone/ from the script-free mirror (markup, text, styles, media)'],
24
24
  design: ['design.mjs', 'design-tokens.json -> DESIGN.md (linted), shadcn globals.css, clone/tokens.css'],
25
25
  motion: ['motion.mjs', 'motion inventory from the live runtime (GSAP/ScrollTrigger, Lenis, Swiper, CSS animations)'],
26
+ understand: ['understand.mjs', 'code evidence with REA: which scripts ran, each element\'s handler with its source, endpoints, source maps -> reference/code.md'],
27
+ reveals: ['reveals.mjs', 'record one-shot scroll reveals frame by frame, so seed can replay them (any library)'],
28
+ widgets: ['widgets.mjs', 'record every tab and menu state by clicking it, so seed can replay them (template mode)'],
26
29
  states: ['states.mjs', 'live scroll states; --clone scores the rebuild step by step'],
27
30
  curves: ['curves.mjs', 'motion score: sampled property curves, reference vs clone (motion-map.json)'],
28
31
  interact: ['interact.mjs', 'interaction tests: click/hover/drag/key scenarios on both sides (interactions.json)'],
29
32
  detail: ['detail.mjs', 'style diff at matching points: font, size, weight, line height, colour, background, radius'],
30
33
  hovers: ['hovers.mjs', 'hover every interactive element on both sides and compare what changes'],
31
- rules: ['rules.mjs', 'every CSS rule matching an element on the reference (+ ::before/::after, @media)'],
34
+ load: ['load.mjs', 'load sequence: how settled each side is 0.25/0.5/1/2/4 s after navigation, cold cache, same network'],
35
+ drift: ['drift.mjs', 'has the live page changed since the capture? (page height and sections per width vs meta.json)'],
36
+ owned: ['owned.mjs', 'which sections of the clone script.js rebuilds at boot (load with scripts blocked vs running)'],
37
+ rules: ['rules.mjs', 'every CSS rule matching an element on the reference (+ ::before/::after, @media); --clone for the clone'],
38
+ explain: ['explain.mjs', 'one place on the page: what layout, states, detail and hovers saw there, plus a reference|clone|diff crop'],
32
39
  textshape: ['textshape.mjs', 'template mode: placeholder words with the reference word widths, so lines break the same'],
33
40
  diff: ['diff.mjs', 'full-page pixel diff per viewport and section (--structure masks text and media)'],
34
41
  spec: ['spec.mjs', 'measured boxes and styles of the live page (--clone for the rebuild)'],
@@ -38,6 +45,11 @@ const STEPS = {
38
45
  report: ['report.mjs', 'REPORT.md from the latest scores: every number, every gap, what cannot be copied'],
39
46
  };
40
47
 
48
+ // npm/npx are .cmd files on Windows and need a shell; Node warns (DEP0190) when a shell gets separate arguments, so
49
+ // build one quoted command line there instead
50
+ const spawnCmd = (cmd, args, opts = {}) => (process.platform === 'win32'
51
+ ? spawn([cmd, ...args].map((a) => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(' '), { ...opts, shell: true })
52
+ : spawn(cmd, args, opts));
41
53
  const run = (file, args) => new Promise((resolve) => {
42
54
  const child = spawn(process.execPath, [file, ...args], { stdio: 'inherit' });
43
55
  child.on('close', (code) => resolve(code ?? 1));
@@ -88,7 +100,7 @@ async function init() {
88
100
  if (!process.argv.includes('--no-install')) {
89
101
  if (!(await exists(path.join(cwd, 'package.json')))) await fs.writeFile(path.join(cwd, 'package.json'), `${JSON.stringify({ name: path.basename(cwd).toLowerCase().replace(/[^a-z0-9-]+/g, '-'), private: true }, null, 2)}\n`);
90
102
  const code = await new Promise((resolve) => {
91
- const child = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install', '--save-dev', '--save-exact', `${name}@${version}`], { stdio: 'inherit', shell: process.platform === 'win32' });
103
+ const child = spawnCmd('npm', ['install', '--save-dev', '--save-exact', `${name}@${version}`], { stdio: 'inherit' });
92
104
  child.on('close', (c) => resolve(c ?? 1));
93
105
  });
94
106
  installed = code === 0 ? `${name}@${version} (dev dependency: npx trueclone <step> now runs it)` : `failed: run npm install -D ${name}@${version} yourself`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trueclone/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A working twin of any website from its URL: HTML clone, Next.js + Tailwind + shadcn app, DESIGN.md and globals.css, proven by layout, motion, interaction and style scores. Bundles REA.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  // Capture a live page as the clone's reference: full-page screenshots, sections, design tokens,
2
2
  // rendered HTML, CSS/image/font files and a HAR.
3
- // usage: node scripts/capture.mjs <url> [name] [--hide ".cookie-banner,#popup"] [--cdp [endpoint]]
3
+ // usage: node scripts/capture.mjs <url> [name] [--hide ".cookie-banner,#popup"] [--cdp [endpoint]] [--mode exact --permission "<owner>"]
4
+ // node scripts/capture.mjs <url> <name> --mirror-only (rebuild reference/mirror from the saved capture)
4
5
  import fs from 'node:fs/promises';
5
6
  import path from 'node:path';
6
7
  import crypto from 'node:crypto';
@@ -32,6 +33,14 @@ const SAVED_TYPES = new Set(['stylesheet', 'image', 'font', 'media']);
32
33
  const EXTRA_MEDIA = /\.(glb|gltf|bin|hdr|exr|ktx2|basis|mp4|webm|mov|m3u8|ts|m4s|lottie|riv|json)(\?|$)/i;
33
34
  const keepResponse = (type, url) => SAVED_TYPES.has(type) || (mode === 'exact' && ['fetch', 'xhr', 'other'].includes(type) && EXTRA_MEDIA.test(url));
34
35
 
36
+ // --mirror-only: rebuild reference/mirror from an existing capture (no new screenshots, caches stay valid)
37
+ if (flags['mirror-only']) {
38
+ const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
39
+ const saved = JSON.parse(await fs.readFile(path.join(ref, 'assets', 'manifest.json'), 'utf8'));
40
+ await buildMirror(saved, meta.url, meta.mode === 'exact' ? `Exact mode, authorised by: ${meta.permission}.` : "Template mode: the site's own content, kept as a reference only.");
41
+ process.exit(0);
42
+ }
43
+
35
44
  // Only clear what capture owns; states/, motion.*, *.spec.json come from other scripts and stay.
36
45
  await fs.rm(path.join(ref, 'assets'), { recursive: true, force: true });
37
46
  await fs.rm(path.join(ref, 'mirror'), { recursive: true, force: true });
@@ -83,6 +92,7 @@ const browser = await openBrowser(flags.cdp);
83
92
  const manifest = [];
84
93
  const summary = [];
85
94
  const rootFontSize = {};
95
+ const inlineStyles = {};
86
96
  try {
87
97
  for (const vp of VIEWPORTS) {
88
98
  const desktop = vp.name === 'desktop';
@@ -106,16 +116,101 @@ try {
106
116
  });
107
117
  }
108
118
  await page.goto(url, { waitUntil: 'load', timeout: 60000 });
119
+ // Components that mount only while in view (a lazy wordmark, an embed) are often unmounted again by the time
120
+ // the DOM is saved. Watch from load: an element that was empty, got content while scrolled into view and is empty
121
+ // again at snapshot time is saved with the fullest content it had. Things that start full and empty out (a
122
+ // loader) are left alone.
123
+ if (desktop) await page.evaluate(() => {
124
+ const count = (el) => el.getElementsByTagName('*').length;
125
+ const initial = new WeakMap();
126
+ for (const el of document.body.getElementsByTagName('*')) initial.set(el, count(el));
127
+ window.__tcRich = new Map();
128
+ new MutationObserver((records) => {
129
+ for (const r of records) {
130
+ for (let el = r.target, up = 0; el instanceof Element && el !== document.body && up < 8; el = el.parentElement, up++) {
131
+ const start = initial.has(el) ? initial.get(el) : 0;
132
+ const n = count(el);
133
+ if (start > 0 || n === 0) continue;
134
+ const best = window.__tcRich.get(el);
135
+ if (!best || n > best.n) window.__tcRich.set(el, { n, html: el.innerHTML });
136
+ }
137
+ }
138
+ }).observe(document.body, { subtree: true, childList: true });
139
+ });
109
140
  await settle(page, hide);
110
- await page.screenshot({ path: path.join(ref, `${vp.name}.png`), fullPage: true });
141
+ // A full-page screenshot brings the whole page into view at once, which mounts view-mounted components and starts
142
+ // their reveals: shoot until two shots agree, so the reference shows a settled page, not a fade caught at 0%.
143
+ let shot = await page.screenshot({ fullPage: true });
144
+ for (let tries = 0; tries < 3; tries++) {
145
+ await page.waitForTimeout(2000);
146
+ const again = await page.screenshot({ fullPage: true });
147
+ const same = again.equals(shot);
148
+ shot = again;
149
+ if (same) break;
150
+ }
151
+ await fs.writeFile(path.join(ref, `${vp.name}.png`), shot);
111
152
  const sections = await sectionsOf(page);
112
153
  await fs.writeFile(path.join(ref, `${vp.name}.sections.json`), JSON.stringify(sections, null, 2));
154
+ // inline styles at this width, by element path: scripts size and place things per width (a grid's computed
155
+ // height, a card's offset), and the rendered HTML only keeps the desktop values. The seed turns what differs
156
+ // between widths into media-query rules.
157
+ inlineStyles[vp.name] = await page.evaluate(() => {
158
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
159
+ const pathOf = (el) => {
160
+ const parts = [];
161
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
162
+ const sibs = [...n.parentElement.children].filter((c) => !SKIP.has(c.tagName));
163
+ parts.unshift(`${n.tagName.toLowerCase()}:${sibs.indexOf(n) + 1}`);
164
+ }
165
+ return parts.join('>');
166
+ };
167
+ const out = {};
168
+ for (const el of document.body.querySelectorAll('[style]')) {
169
+ if (SKIP.has(el.tagName) || (el.closest('svg') && el.tagName !== 'svg')) continue;
170
+ out[pathOf(el)] = el.getAttribute('style');
171
+ }
172
+ return out;
173
+ });
113
174
  const height = await page.evaluate(() => document.documentElement.scrollHeight);
114
175
  rootFontSize[vp.name] = await page.evaluate(() => getComputedStyle(document.documentElement).fontSize);
115
176
  if (desktop) {
116
177
  const html = await page.evaluate(() => {
178
+ // restore view-mounted content (see above): only elements that are empty again now
179
+ const restore = [];
180
+ let i = 0;
181
+ for (const [el, best] of window.__tcRich || []) {
182
+ if (!el.isConnected || el.getElementsByTagName('*').length > 0) continue;
183
+ el.setAttribute('data-tc-rich', String(i));
184
+ restore[i++] = best.html;
185
+ }
186
+ // open shadow roots (web components, a Webflow code island) are not part of outerHTML: each becomes a
187
+ // declarative <template shadowrootmode="open"> inside its host, which the browser turns back into a shadow
188
+ // root while parsing, no script needed. Deepest first, so a host inside a shadow root is carried too.
189
+ // Constructed stylesheets (adoptedStyleSheets) are written out as a <style> at the top of the template.
190
+ const hosts = [];
191
+ (function find(scope) { for (const el of scope.querySelectorAll('*')) if (el.shadowRoot) { find(el.shadowRoot); hosts.push(el); } })(document);
192
+ for (const host of hosts) {
193
+ const t = document.createElement('template');
194
+ t.setAttribute('shadowrootmode', 'open');
195
+ t.setAttribute('shadowrootserializable', '');
196
+ const adopted = (host.shadowRoot.adoptedStyleSheets || []).map((s) => { try { return [...s.cssRules].map((r) => r.cssText).join('\n'); } catch { return ''; } }).filter(Boolean);
197
+ if (adopted.length) { const st = document.createElement('style'); st.textContent = adopted.join('\n'); t.content.appendChild(st); }
198
+ for (const n of host.shadowRoot.childNodes) t.content.appendChild(n.cloneNode(true));
199
+ host.prepend(t);
200
+ host.setAttribute('data-tc-shadow', '');
201
+ }
202
+ if (hosts.length) console.log(`tc-shadow: serialised ${hosts.length} open shadow root(s) as declarative shadow DOM`);
117
203
  const doc = document.documentElement.cloneNode(true);
204
+ for (const el of document.querySelectorAll('[data-tc-rich]')) el.removeAttribute('data-tc-rich');
205
+ for (const el of doc.querySelectorAll('[data-tc-rich]')) {
206
+ el.innerHTML = restore[Number(el.getAttribute('data-tc-rich'))];
207
+ el.removeAttribute('data-tc-rich');
208
+ // marked for the layout diff: a full-page still can't show view-mounted content on either side, so its box
209
+ // is excluded there (the scroll-state check, with a real viewport, scores it)
210
+ el.setAttribute('data-tc-viewmount', '');
211
+ }
118
212
  doc.querySelector('style[data-lab-freeze]')?.remove();
213
+ console.log(`tc-rich: restored ${restore.length} view-mounted element(s)`);
119
214
  return '<!DOCTYPE html>\n' + doc.outerHTML;
120
215
  });
121
216
  await fs.writeFile(path.join(ref, 'rendered.html'), html);
@@ -131,19 +226,39 @@ try {
131
226
  }
132
227
 
133
228
  await fs.writeFile(path.join(ref, 'assets', 'manifest.json'), JSON.stringify(manifest, null, 2));
229
+ await fs.writeFile(path.join(ref, 'inline-styles.json'), JSON.stringify(inlineStyles));
134
230
  await fs.writeFile(path.join(ref, 'meta.json'), JSON.stringify({ url, name, mode, permission, hide, capturedAt: new Date().toISOString(), rootFontSize, viewports: summary }, null, 2));
135
- if (mode === 'exact') await buildMirror();
231
+ await buildMirror(manifest, url, mode === 'exact' ? `Exact mode, authorised by: ${permission}.` : "Template mode: the site's own content, kept as a reference only.");
136
232
  console.table(summary);
137
- console.log(`${manifest.length} assets saved. Reference: ${path.relative(process.cwd(), ref)}${mode === 'exact' ? ` (exact mode; mirror in reference/mirror, authorised by: ${permission})` : ''}`);
233
+ console.log(`${manifest.length} assets saved. Reference: ${path.relative(process.cwd(), ref)}, mirror in reference/mirror${mode === 'exact' ? ` (exact mode, authorised by: ${permission})` : ''}`);
138
234
 
139
- // ---------- Exact mode: static local mirror of the rendered page ----------
235
+ // ---------- Static local mirror of the rendered page ----------
140
236
  // Same DOM as rendered.html, asset URLs rewritten to the saved copies, every script and inline handler
141
237
  // removed (we never reuse the site's code), forms disabled, noindex. Static by design: motion is rebuilt
142
- // from the states/motion inventory, not replayed.
143
- async function buildMirror() {
238
+ // from the states/motion inventory, not replayed. Exact mode seeds the clone from it as is; template mode
239
+ // seeds it with every text and medium swapped for placeholders (seed.mjs).
240
+ async function buildMirror(manifest, url, note) {
144
241
  const mirror = path.join(ref, 'mirror');
145
242
  await fs.rm(mirror, { recursive: true, force: true });
146
243
  await fs.mkdir(path.join(mirror, 'css'), { recursive: true });
244
+ // Rendered twice: the first pass shows which media the capture never saw (lazy images below the fold, hover and
245
+ // state images, sources behind a media query, fonts behind @import); fetchMissing() saves them and the second pass
246
+ // points at the local copies. A page is only as complete as its slowest-loading image.
247
+ let out = await renderMirror(manifest, url, note, mirror);
248
+ const missing = findRemoteMedia(out);
249
+ if (missing.length) {
250
+ const added = await fetchMissing(missing, manifest, url);
251
+ if (added) out = await renderMirror(manifest, url, note, mirror);
252
+ await fs.writeFile(path.join(ref, 'assets', 'manifest.json'), JSON.stringify(manifest, null, 2));
253
+ console.log(`mirror: fetched ${added}/${missing.length} media the capture had not seen (lazy, hover or conditional sources)`);
254
+ }
255
+ for (const [file, css] of out.css) await fs.writeFile(path.join(mirror, 'css', file), css);
256
+ await fs.writeFile(path.join(mirror, 'index.html'), out.html);
257
+ const left = findRemoteMedia(out);
258
+ console.log(`mirror: reference/mirror/index.html (${manifest.length} local assets${left.length ? `, ${left.length} remote media left: ${left.slice(0, 3).join(', ')}` : ''})`);
259
+ }
260
+
261
+ async function renderMirror(manifest, url, note, mirror) {
147
262
  const byUrl = new Map(manifest.map((m) => [m.url.split('#')[0], m]));
148
263
  const abs = (u, base) => { try { return new URL(u.trim().replace(/^['"]|['"]$/g, '').replaceAll('&amp;', '&'), base).href.split('#')[0]; } catch { return null; } };
149
264
  const cssName = (m) => path.basename(m.file);
@@ -155,13 +270,15 @@ async function buildMirror() {
155
270
  return path.relative(fromDir, path.join(ref, m.file)).replaceAll('\\', '/');
156
271
  };
157
272
  const rewriteCssUrls = (css, base, fromDir) => css
158
- .replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (all, q, u) => (u.startsWith('data:') ? all : `url("${local(abs(u, base), fromDir) || abs(u, base) || u}")`))
273
+ // url(#id) points inside the document (SVG gradients, filters, masks): never resolve it against the stylesheet
274
+ .replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (all, q, u) => (u.startsWith('data:') || u.trim().startsWith('#') ? all : `url("${local(abs(u, base), fromDir) || abs(u, base) || u}")`))
159
275
  .replace(/@import\s+(['"])([^'"]+)\1/g, (all, q, u) => `@import "${local(abs(u, base), fromDir) || u}"`);
160
276
 
161
277
  // stylesheets: rewritten copies under mirror/css
278
+ const css = new Map();
162
279
  for (const m of manifest.filter((x) => x.type === 'stylesheet')) {
163
- const css = await fs.readFile(path.join(ref, m.file), 'utf8');
164
- await fs.writeFile(path.join(mirror, 'css', cssName(m)), rewriteCssUrls(css, m.url, path.join(mirror, 'css')));
280
+ const text = await fs.readFile(path.join(ref, m.file), 'utf8').catch(() => null);
281
+ if (text !== null) css.set(cssName(m), rewriteCssUrls(text, m.url, path.join(mirror, 'css')));
165
282
  }
166
283
 
167
284
  let html = await fs.readFile(path.join(ref, 'rendered.html'), 'utf8');
@@ -176,14 +293,74 @@ async function buildMirror() {
176
293
  const l = local(abs(u, url), mirror);
177
294
  return l ? ` ${attr}=${q}${l}${q}` : all;
178
295
  })
296
+ // a subresource-integrity hash belongs to the bytes at the original URL; after the rewrite it only makes the
297
+ // browser refuse the local copy (and the whole stylesheet with it)
298
+ .replace(/<link\b[^>]*>/gi, (tag) => tag.replace(/\s(?:integrity|crossorigin)(?:=("[^"]*"|'[^']*'|[^\s>]+))?/gi, ''))
179
299
  .replace(/\s(srcset|data-srcset)=("|')([^"']+)\2/gi, (all, attr, q, list) => ` ${attr}=${q}${list.split(',').map((part) => {
180
300
  const [u, ...rest] = part.trim().split(/\s+/);
181
301
  return [local(abs(u, url), mirror) || u, ...rest].join(' ');
182
302
  }).join(', ')}${q}`)
183
303
  .replace(/<style\b([^>]*)>([\s\S]*?)<\/style>/gi, (all, attrs, css) => `<style${attrs}>${rewriteCssUrls(css, url, mirror)}</style>`)
184
- .replace(/\sstyle=("|')([^"']*url\([^"']*)\1/gi, (all, q, css) => ` style=${q}${rewriteCssUrls(css, url, mirror)}${q}`)
185
- .replace(/<head([^>]*)>/i, `<head$1>\n<!-- rea-lab local mirror of ${url}. Authorised by: ${permission.replace(/--/g, '-')}. Local reference only: do not deploy. -->\n<meta name="robots" content="noindex,nofollow">`);
186
- await fs.writeFile(path.join(mirror, 'index.html'), html);
187
- const missing = [...html.matchAll(/\s(?:src|href)=["'](https?:[^"']+)["']/gi)].map((m) => m[1]).filter((u) => !/^https?:\/\/[^/]+\/?$/.test(u));
188
- console.log(`mirror: reference/mirror/index.html (${manifest.length} local assets, ${missing.length} remote URLs left, e.g. links)`);
304
+ // quotes inside a style attribute are HTML-escaped (url(&quot;…&quot;)): unescape before rewriting, re-escape after
305
+ .replace(/\sstyle=(?:"([^"]*)"|'([^']*)')/gi, (all, d, s) => {
306
+ const value = d ?? s;
307
+ if (!/url\(/i.test(value)) return all;
308
+ const plain = value.replaceAll('&quot;', '"').replaceAll('&#39;', "'");
309
+ return ` style="${rewriteCssUrls(plain, url, mirror).replaceAll('"', '&quot;')}"`;
310
+ })
311
+ .replace(/<head([^>]*)>/i, `<head$1>\n<!-- trueclone local mirror of ${url}. ${note.replace(/--/g, '-')} Local reference only: do not deploy. -->\n<meta name="robots" content="noindex,nofollow">`);
312
+ return { html, css };
313
+ }
314
+
315
+ // Media the page still points at on the network after the rewrite: image/video/font/stylesheet URLs in src, srcset,
316
+ // poster, data-src, <link> hrefs and CSS url(). Links to other pages (<a href>) are not media.
317
+ function findRemoteMedia({ html, css }) {
318
+ const urls = new Set();
319
+ const media = (u) => u && /^https?:\/\//.test(u) && !/^https?:\/\/[^/]+\/?$/.test(u) && !/\.(js|mjs|json|html?)(\?|$)/i.test(u);
320
+ const add = (u) => { if (media(u)) urls.add(u.replaceAll('&amp;', '&')); };
321
+ for (const m of html.matchAll(/\s(?:src|poster|data-src)=["']([^"']+)["']/gi)) add(m[1]);
322
+ for (const m of html.matchAll(/\s(?:srcset|data-srcset)=["']([^"']+)["']/gi)) for (const part of m[1].split(',')) add(part.trim().split(/\s+/)[0]);
323
+ for (const m of html.matchAll(/<link\b[^>]*>/gi)) {
324
+ const rel = /\srel=["']?([^"'\s>]+)/i.exec(m[0])?.[1] || '';
325
+ const href = /\shref=["']([^"']+)["']/i.exec(m[0])?.[1];
326
+ if (/stylesheet|icon|apple-touch-icon|preload|prefetch/i.test(rel) && !/\sas=["']?(script|document|fetch)/i.test(m[0])) add(href);
327
+ }
328
+ const cssUrls = (text) => { for (const m of text.matchAll(/url\(\s*(?:&quot;|["'])?(https?:\/\/[^)"'&\s]+)/gi)) add(m[1]); };
329
+ cssUrls(html);
330
+ for (const text of css.values()) cssUrls(text);
331
+ return [...urls].slice(0, 300);
332
+ }
333
+
334
+ async function fetchMissing(urls, manifest, pageUrl) {
335
+ const typeOf = (u, contentType = '') => {
336
+ if (/text\/css/.test(contentType) || /\.css(\?|$)/i.test(u)) return 'stylesheet';
337
+ if (/^font\/|application\/(x-)?font|application\/vnd\.ms-fontobject/.test(contentType) || /\.(woff2?|ttf|otf|eot)(\?|$)/i.test(u)) return 'font';
338
+ if (/^image\//.test(contentType) || /\.(png|jpe?g|gif|webp|avif|svg|ico|bmp)(\?|$)/i.test(u)) return 'image';
339
+ return 'media';
340
+ };
341
+ let added = 0;
342
+ const queue = [...urls];
343
+ const worker = async () => {
344
+ for (let u = queue.shift(); u; u = queue.shift()) {
345
+ try {
346
+ const res = await fetch(u, { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', referer: pageUrl, accept: '*/*' }, signal: AbortSignal.timeout(20000), redirect: 'follow' });
347
+ if (!res.ok) continue;
348
+ const body = Buffer.from(await res.arrayBuffer());
349
+ if (!body.length || body.length > MAX_ASSET_BYTES) continue;
350
+ const contentType = res.headers.get('content-type') || '';
351
+ if (/text\/html/.test(contentType)) continue; // a page, not media (a soft 404)
352
+ const type = typeOf(u, contentType);
353
+ const parsed = new URL(u);
354
+ const base = (path.basename(parsed.pathname) || 'index').replace(/[^a-z0-9._-]/gi, '_').slice(-80);
355
+ const hash = crypto.createHash('sha1').update(u).digest('hex').slice(0, 8);
356
+ const file = path.join('assets', type, `${hash}-${base}`);
357
+ await fs.mkdir(path.join(ref, 'assets', type), { recursive: true });
358
+ await fs.writeFile(path.join(ref, file), body);
359
+ manifest.push({ url: u, type, file: file.replaceAll('\\', '/'), contentType, bytes: body.length, fetched: 'mirror' });
360
+ added++;
361
+ } catch { /* offline, blocked, timed out: stays remote and is listed */ }
362
+ }
363
+ };
364
+ await Promise.all(Array.from({ length: 6 }, worker));
365
+ return added;
189
366
  }
@@ -155,7 +155,7 @@ async function sample(browser, url, vp, probes) {
155
155
  if (y >= max) break;
156
156
  }
157
157
  await context.close();
158
- return { list, results };
158
+ return { list, results, pageHeight: max + vp.height };
159
159
  }
160
160
  const list = probes;
161
161
  const results = new Array(list.length);
@@ -177,14 +177,27 @@ const report = { name, at: new Date().toISOString(), viewports: [] };
177
177
  try {
178
178
  for (const vpName of wanted) {
179
179
  const vp = VIEWPORTS.find((v) => v.name === vpName);
180
- // reference probes are cached until the next capture (--refresh-ref to re-measure)
181
- const ref = await cached(root, { tool: 'detail', vp: vpName, max: MAX, v: 8 }, () => sample(browser, meta.url, vp, null), Boolean(flags['refresh-ref']));
180
+ // reference probes are cached until the next capture (--refresh-ref to re-measure). The reference is read twice:
181
+ // a probe that does not agree with itself (random motion, a hover that lands by timing, a rotating word) can
182
+ // never be matched by any clone and is set aside as unstable instead of being handed over as a gap.
183
+ const ref = await cached(root, { tool: 'detail', vp: vpName, max: MAX, v: 9 }, async () => {
184
+ const first = await sample(browser, meta.url, vp, null);
185
+ // a live site changes: a reference read against today's page scores a clone of yesterday's capture against the
186
+ // wrong thing. The page height is a cheap tell; the fix is a new capture, not a new clone.
187
+ const captured = meta.viewports?.find((v) => v.viewport === vpName)?.pageHeight;
188
+ if (captured && first.pageHeight && Math.abs(first.pageHeight - captured) > Math.max(40, captured * 0.01)) console.warn(`WARNING reference drift (${vpName}): the live page is ${first.pageHeight}px tall, the capture was ${captured}px (${meta.capturedAt}); re-capture before trusting this score`);
189
+ const again = await sample(browser, meta.url, vp, first.list.map(({ x, xs, y, kind, w, h, scroll }) => ({ x, xs, y, kind, w, h, scroll })));
190
+ const unstable = first.list.map((p, i) => JSON.stringify(first.results[i]?.style) !== JSON.stringify(again.results[i]?.style) || first.results[i]?.sel !== again.results[i]?.sel);
191
+ return { ...first, unstable };
192
+ }, Boolean(flags['refresh-ref']));
182
193
  const cl = await sample(browser, cloneUrl, vp, ref.list.map(({ x, xs, y, kind, w, h, scroll }) => ({ x, xs, y, kind, w, h, scroll })));
183
194
  const issues = [];
195
+ const unstableList = [];
184
196
  let clean = 0, missing = 0;
185
197
  ref.list.forEach((p, i) => {
186
198
  const a = ref.results[i], b = cl.results[i];
187
199
  if (!a?.style) return;
200
+ if (ref.unstable?.[i]) { unstableList.push({ y: p.y, kind: p.kind, ref: a.sel }); return; }
188
201
  if (!b?.style) { missing++; issues.push({ y: p.y, kind: p.kind, ref: a.sel, clone: b?.top || '(nothing)', props: [`no ${p.kind} element at this point on the clone`] }); return; }
189
202
  const A = a.style, B = b.style, props = [];
190
203
  if (p.kind === 'text') {
@@ -203,10 +216,10 @@ try {
203
216
  if (props.length) issues.push({ y: p.y, kind: p.kind, ref: a.sel, clone: b.sel, props });
204
217
  else clean++;
205
218
  });
206
- const total = ref.list.filter((p, i) => ref.results[i]?.style).length;
219
+ const total = ref.list.filter((p, i) => ref.results[i]?.style && !ref.unstable?.[i]).length;
207
220
  const byProp = {};
208
221
  issues.forEach((it) => it.props.forEach((pr) => { const k = pr.split(' ')[0] === 'no' ? 'missing element' : pr.split(' ')[0]; byProp[k] = (byProp[k] || 0) + 1; }));
209
- report.viewports.push({ viewport: vpName, probes: total, clean, missing, score: +(100 * clean / Math.max(1, total)).toFixed(1), byProp, issues });
222
+ report.viewports.push({ viewport: vpName, probes: total, clean, missing, unstable: unstableList.length, score: +(100 * clean / Math.max(1, total)).toFixed(1), byProp, issues, unstableProbes: unstableList });
210
223
  }
211
224
  } finally {
212
225
  if (!flags.cdp) await browser.close();
@@ -216,7 +229,7 @@ const out = path.join(root, 'compare', 'detail.json');
216
229
  await fs.mkdir(path.dirname(out), { recursive: true });
217
230
  await fs.writeFile(out, JSON.stringify(report, null, 2));
218
231
  for (const v of report.viewports) {
219
- console.log(`\n${v.viewport}: detail match ${v.score}% (${v.clean}/${v.probes} probes clean, ${v.missing} with no counterpart) by property: ${JSON.stringify(v.byProp)}`);
232
+ console.log(`\n${v.viewport}: detail match ${v.score}% (${v.clean}/${v.probes} probes clean, ${v.missing} with no counterpart${v.unstable ? `; ${v.unstable} unstable on the reference itself, not scored: ${[...new Set(v.unstableProbes.map((u) => u.ref))].slice(0, 4).join(', ')}` : ''}) by property: ${JSON.stringify(v.byProp)}`);
220
233
  // group identical issues so one root cause prints once with a count
221
234
  const groups = new Map();
222
235
  for (const it of v.issues) { const k = `${it.ref} -> ${it.clone}: ${it.props.join('; ')}`; const g = groups.get(k) || { ...it, n: 0 }; g.n++; groups.set(k, g); }
package/scripts/diff.mjs CHANGED
@@ -32,12 +32,27 @@ try {
32
32
  if (structure) await mask(page);
33
33
  const clonePath = path.join(out, `${vp.name}${suffix}.clone.png`);
34
34
  await page.screenshot({ path: clonePath, fullPage: true });
35
+ // View-mounted content (marked by capture): a full-page still can't show it on either side, so its boxes are
36
+ // painted the same flat colour in both images; the scroll-state check scores it with a real viewport.
37
+ const excluded = await page.evaluate(() => [...document.querySelectorAll('[data-tc-viewmount]')]
38
+ .map((e) => { const r = e.getBoundingClientRect(); return [r.left, r.top + scrollY, r.width, r.height].map(Math.round); })
39
+ .filter((r) => r[2] > 0 && r[3] > 0));
35
40
  await context.close();
36
41
 
37
42
  const a0 = PNG.sync.read(await fs.readFile(path.join(ref, `${vp.name}${suffix}.png`)));
38
43
  const b0 = PNG.sync.read(await fs.readFile(clonePath));
39
44
  const w = Math.max(a0.width, b0.width), h = Math.max(a0.height, b0.height);
40
45
  const a = pad(a0, w, h), b = pad(b0, w, h);
46
+ for (const [x, y, rw, rh] of excluded) {
47
+ for (const img of [a, b]) {
48
+ for (let yy = Math.max(0, y); yy < Math.min(h, y + rh); yy++) {
49
+ for (let xx = Math.max(0, x); xx < Math.min(w, x + rw); xx++) {
50
+ const i = (yy * w + xx) * 4;
51
+ img.data[i] = 64; img.data[i + 1] = 64; img.data[i + 2] = 64; img.data[i + 3] = 255;
52
+ }
53
+ }
54
+ }
55
+ }
41
56
  const diff = new PNG({ width: w, height: h });
42
57
  const bad = match(a, b, diff);
43
58
  await fs.writeFile(path.join(out, `${vp.name}${suffix}.diff.png`), PNG.sync.write(diff));
@@ -57,6 +72,7 @@ try {
57
72
  match: +(100 - (bad / (w * h)) * 100).toFixed(2),
58
73
  heightRef: a0.height,
59
74
  heightClone: b0.height,
75
+ excludedViewMounted: excluded.length,
60
76
  worstSections: [...perSection].sort((x, y) => x.match - y.match).slice(0, 5),
61
77
  sections: perSection,
62
78
  });
@@ -0,0 +1,48 @@
1
+ // Has the live page changed since the capture? Layout and scroll states score against the captured screenshots, but
2
+ // detail, hovers, motion curves, interactions and the load sequence measure the live page: when the site changed in
3
+ // between, those checks compare the clone with something it was never a copy of. One page load per width: the page
4
+ // height and the number of top-level sections against meta.json. Written to compare/drift.json; REPORT.md marks the
5
+ // live-measured checks "stale capture" and the advice is to re-capture, not to touch the clone.
6
+ // usage: node scripts/drift.mjs <name> [--viewport desktop|all]
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, 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 drift <name> [--viewport desktop|all]');
15
+ process.exit(1);
16
+ }
17
+ const root = path.resolve('sites', name);
18
+ const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
19
+ const vps = !flags.viewport || flags.viewport === 'all' ? VIEWPORTS : [VIEWPORTS.find((v) => v.name === flags.viewport)].filter(Boolean);
20
+
21
+ const browser = await openBrowser(flags.cdp);
22
+ const viewports = [];
23
+ try {
24
+ for (const vp of vps) {
25
+ const captured = meta.viewports?.find((v) => v.viewport === vp.name);
26
+ if (!captured) continue;
27
+ const { context, page } = await newPage(browser, vp);
28
+ try {
29
+ await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
30
+ await settle(page, meta.hide || []);
31
+ const livePageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
32
+ const liveSections = (await sectionsOf(page)).length;
33
+ // 1% (at least 40px) absorbs font hinting and a rotating line of text; a new section or a block that moved is above that
34
+ const stale = Math.abs(livePageHeight - captured.pageHeight) > Math.max(40, captured.pageHeight * 0.01) || liveSections !== captured.sections;
35
+ viewports.push({ viewport: vp.name, livePageHeight, capturedPageHeight: captured.pageHeight, liveSections, capturedSections: captured.sections, stale });
36
+ console.log(`${vp.name}: live ${livePageHeight}px / ${liveSections} sections, captured ${captured.pageHeight}px / ${captured.sections} sections${stale ? ' <- changed since the capture' : ' (same)'}`);
37
+ } finally {
38
+ await context.close();
39
+ }
40
+ }
41
+ } finally {
42
+ if (!flags.cdp) await browser.close();
43
+ }
44
+ const stale = viewports.some((v) => v.stale);
45
+ await fs.mkdir(path.join(root, 'compare'), { recursive: true });
46
+ await fs.writeFile(path.join(root, 'compare', 'drift.json'), JSON.stringify({ at: new Date().toISOString(), capturedAt: meta.capturedAt, stale, viewports }, null, 2));
47
+ if (stale) console.warn(`WARNING reference drift: the live page has changed since the capture of ${meta.capturedAt}; detail, hovers, curves, interactions and load now measure a page the clone was never a copy of. Re-capture (trueclone capture, then states, reveals, seed --force) before trusting them.`);
48
+ else console.log(`drift: none since ${meta.capturedAt}`);