@trueclone/cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/bin/trueclone.mjs +6 -1
- package/package.json +1 -1
- package/scripts/capture.mjs +124 -6
- package/scripts/detail.mjs +19 -6
- package/scripts/drift.mjs +48 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/hovers.mjs +17 -8
- package/scripts/lib.mjs +8 -24
- package/scripts/load.mjs +102 -0
- package/scripts/next.mjs +91 -26
- package/scripts/owned.mjs +66 -0
- package/scripts/report.mjs +43 -11
- package/scripts/reveals.mjs +174 -80
- package/scripts/score.mjs +71 -9
- package/scripts/seed.mjs +316 -25
- package/scripts/understand.mjs +231 -0
- package/skill/SKILL.md +42 -13
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Code-level evidence about the reference, with REA (Reverse Engineer Anything, bundled) doing the reading and
|
|
2
|
+
// trueclone owning the browser: which scripts actually run while a visitor scrolls and hovers (V8 coverage), which
|
|
3
|
+
// handler each interactive element has and where its code is (CDP listeners, with the source around it), which
|
|
4
|
+
// endpoints exist in the code (fired during the capture or not), original sources when the site publishes source
|
|
5
|
+
// maps. Written to reference/code.md (for the agent) and reference/code.json. The capture already knows what the
|
|
6
|
+
// page looks like and does; this says which code does it, so script.js copies rules instead of guessing them.
|
|
7
|
+
//
|
|
8
|
+
// usage: node scripts/understand.mjs <name> [--elements 40] [--observe-ms 20000]
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { chromium } from 'playwright';
|
|
15
|
+
import { VIEWPORTS, parseArgs, newPage, settle, wheelTo, withTimeout } from './lib.mjs';
|
|
16
|
+
|
|
17
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
18
|
+
const [name] = pos;
|
|
19
|
+
if (!name) {
|
|
20
|
+
console.error('usage: trueclone understand <name> [--elements 40] [--observe-ms 20000]');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const root = path.resolve('sites', name);
|
|
24
|
+
const ref = path.join(root, 'reference');
|
|
25
|
+
const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
|
|
26
|
+
const MAX_ELEMENTS = Number(flags.elements || 40);
|
|
27
|
+
const OBSERVE_MS = Number(flags['observe-ms'] || 20000);
|
|
28
|
+
const vp = VIEWPORTS.find((v) => v.name === 'desktop');
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const REA = path.join(path.dirname(require.resolve('rea-agents/package.json')), 'scripts', 'rea.mjs');
|
|
31
|
+
const reaVersion = JSON.parse(await fs.readFile(require.resolve('rea-agents/package.json'), 'utf8')).version;
|
|
32
|
+
|
|
33
|
+
// REA attaches to a loopback CDP endpoint it does not own: trueclone launches Chrome with a debugging port
|
|
34
|
+
const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
|
|
35
|
+
const port = await freePort();
|
|
36
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
37
|
+
const launch = async () => {
|
|
38
|
+
const args = [`--remote-debugging-port=${port}`];
|
|
39
|
+
if (process.env.LAB_BROWSER !== 'chromium') { try { return await chromium.launch({ channel: 'chrome', args }); } catch { /* no Chrome */ } }
|
|
40
|
+
return chromium.launch({ args });
|
|
41
|
+
};
|
|
42
|
+
const rea = (args, { timeout = 90000, onProgress } = {}) => new Promise((resolve) => {
|
|
43
|
+
const child = spawn(process.execPath, [REA, ...args, '--format', 'json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
44
|
+
let out = '', err = '';
|
|
45
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
46
|
+
child.stderr.on('data', (d) => { err += d; if (onProgress) for (const line of String(d).split('\n')) if (line.includes('rea_progress')) onProgress(line); });
|
|
47
|
+
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, timeout);
|
|
48
|
+
child.on('close', (code) => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
let json = null;
|
|
51
|
+
try { json = JSON.parse(out); } catch { /* not JSON: an error */ }
|
|
52
|
+
const errText = err.split('\n').filter((l) => l && !l.includes('rea_progress')).join('\n').slice(0, 600);
|
|
53
|
+
resolve({ code, json, result: json?.normalized_result || null, err: errText || (json ? '' : `exit ${code}: ${out.slice(0, 400)}`) });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// origins whose scripts count: the page's own and every origin that served a script during the capture
|
|
58
|
+
const har = await fs.readFile(path.join(ref, 'desktop.har'), 'utf8').then(JSON.parse).catch(() => null);
|
|
59
|
+
const harEntries = har?.log?.entries || [];
|
|
60
|
+
const origins = new Set([new URL(meta.url).origin]);
|
|
61
|
+
for (const e of harEntries) if (e._resourceType === 'script') { try { origins.add(new URL(e.request.url).origin); } catch { /* data: */ } }
|
|
62
|
+
const originArgs = [...origins].flatMap((o) => ['--allowed-origins', o]); // one flag per origin
|
|
63
|
+
const fired = new Set(harEntries.map((e) => e.request.url.split('?')[0]));
|
|
64
|
+
|
|
65
|
+
const browser = await launch();
|
|
66
|
+
const out = { name, url: meta.url, at: new Date().toISOString(), rea: reaVersion, scripts: [], handlers: [], endpoints: [], sourceMaps: [], unknowns: [], notes: [] };
|
|
67
|
+
try {
|
|
68
|
+
const { page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
|
|
69
|
+
await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
|
|
70
|
+
await settle(page, meta.hide || []);
|
|
71
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
72
|
+
await page.waitForTimeout(500);
|
|
73
|
+
|
|
74
|
+
const targets = await rea(['list-browser-targets', endpoint, ...originArgs]);
|
|
75
|
+
const target = (targets.result?.targets || []).find((t) => (t.url || '').startsWith(meta.url.replace(/\/$/, ''))) || (targets.result?.targets || [])[0];
|
|
76
|
+
if (!target) throw new Error(`REA found no page target at ${endpoint}: ${targets.err || 'no output'}`);
|
|
77
|
+
const tid = target.target_id;
|
|
78
|
+
|
|
79
|
+
// 1. the bundle: scripts, chunk edges, endpoints and routes in the code, source maps when published
|
|
80
|
+
const bundle = await rea(['analyze-web-bundle', endpoint, tid, '--fetch-source-maps', '--observation-ms', '1500', ...originArgs], { timeout: 180000 });
|
|
81
|
+
const obs = bundle.result?.observations || {};
|
|
82
|
+
const nodes = obs.chunks?.nodes || [];
|
|
83
|
+
const byKey = new Map(nodes.map((n) => [n.script_key, n]));
|
|
84
|
+
// an endpoint is a URL or a path; a `.get("EXT_blend_minmax")` on a WebGL context is not one
|
|
85
|
+
const TRACKING = /google-analytics|googletagmanager|doubleclick|facebook\.(com|net)|hotjar|segment\.(io|com)|plausible|cloudflareinsights|clarity\.ms|sentry|datadog|mixpanel|amplitude|posthog|intercom|hubspot|challenges\.cloudflare|recaptcha|hcaptcha|turnstile/i;
|
|
86
|
+
const urlish = (v) => /^(https?:)?\/\/[^\s]+$/.test(v) || /^\.{0,2}\/[\w./-]+/.test(v) || /^wss?:\/\//.test(v);
|
|
87
|
+
out.endpoints = (obs.endpoints || []).filter((e) => typeof e.value === 'string' && urlish(e.value.trim()) && !/^(call:.*\.get|call:.*\.request)$/.test(e.mechanism || '') || /fetch|xhr|websocket|axios|beacon/i.test(e.mechanism || '') && urlish(String(e.value)))
|
|
88
|
+
.map((e) => ({ value: e.value, mechanism: e.mechanism, script: byKey.get(e.location?.script_key)?.url || e.location?.script_key, line: e.location?.line, fired: fired.has(String(e.value).split('?')[0]), tracking: TRACKING.test(e.value) }));
|
|
89
|
+
out.routes = (obs.routes || []).map((r) => r.value || r.path || JSON.stringify(r)).slice(0, 40);
|
|
90
|
+
out.sourceMaps = (obs.source_maps?.items || []).map((m) => ({ script: byKey.get(m.script_key)?.url || m.script_key, status: m.status, sources: (m.sources || m.original_sources || []).slice(0, 20) }));
|
|
91
|
+
out.unknowns.push(...(bundle.result?.unknowns || []).map((u) => `${u.dimension}: ${u.reason}`));
|
|
92
|
+
if (!bundle.result) out.notes.push(`analyze-web-bundle gave no result: ${bundle.err}`);
|
|
93
|
+
|
|
94
|
+
// 2. what ran: coverage while trueclone scrolls through the page and hovers what a visitor hovers
|
|
95
|
+
const interactive = await page.evaluate((max) => {
|
|
96
|
+
const SEL = 'a[href], button, [role="button"], [role="tab"], summary, input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
97
|
+
const uniq = (el) => {
|
|
98
|
+
if (el.id && document.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1) return `#${CSS.escape(el.id)}`;
|
|
99
|
+
const parts = [];
|
|
100
|
+
for (let n = el; n && n !== document.body; n = n.parentElement) {
|
|
101
|
+
const i = [...n.parentElement.children].indexOf(n) + 1;
|
|
102
|
+
parts.unshift(`${n.tagName.toLowerCase()}:nth-child(${i})`);
|
|
103
|
+
}
|
|
104
|
+
return `body > ${parts.join(' > ')}`;
|
|
105
|
+
};
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const list = [];
|
|
108
|
+
// a plain link navigates; it only has behaviour when it is wired to something (a hash, no real href, aria/data hooks)
|
|
109
|
+
const behaves = (el) => el.tagName !== 'A' || /^(#|javascript:|$)/.test((el.getAttribute('href') || '').trim()) || [...el.attributes].some((a) => /^(data-|aria-controls|aria-expanded|role)/.test(a.name));
|
|
110
|
+
for (const el of document.querySelectorAll(SEL)) {
|
|
111
|
+
const r = el.getBoundingClientRect();
|
|
112
|
+
if (r.width < 4 || r.height < 4 || r.top + scrollY < 0 || !el.checkVisibility?.() || !behaves(el)) continue;
|
|
113
|
+
const sig = `${el.tagName}.${el.className}`;
|
|
114
|
+
if (seen.has(sig) && list.length > max / 2) continue; // repeated rows share handlers: a few of each is enough
|
|
115
|
+
seen.add(sig);
|
|
116
|
+
list.push({ selector: uniq(el), label: `${el.tagName.toLowerCase()}${el.className ? `.${String(el.className).trim().split(/\s+/).slice(0, 2).join('.')}` : ''}`, text: (el.textContent || '').trim().slice(0, 40), x: Math.round(r.left + r.width / 2), y: Math.round(r.top + scrollY + r.height / 2) });
|
|
117
|
+
if (list.length >= max) break;
|
|
118
|
+
}
|
|
119
|
+
// delegated handlers live on the roots: a click on any row is handled here
|
|
120
|
+
for (const sel of ['body', 'html']) list.push({ selector: sel, label: sel, text: '', x: 2, y: 2, root: true });
|
|
121
|
+
return list;
|
|
122
|
+
}, MAX_ELEMENTS);
|
|
123
|
+
|
|
124
|
+
let armed = false;
|
|
125
|
+
const observing = rea(['observe-web-execution', endpoint, tid, '--observation-ms', String(OBSERVE_MS), ...originArgs], { timeout: OBSERVE_MS + 60000, onProgress: (l) => { if (l.includes('armed')) armed = true; } });
|
|
126
|
+
for (let i = 0; i < 50 && !armed; i++) await page.waitForTimeout(100);
|
|
127
|
+
const t0 = Date.now();
|
|
128
|
+
const max = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
|
|
129
|
+
const step = Math.round(vp.height / 2);
|
|
130
|
+
for (let y = step; y <= max && Date.now() - t0 < OBSERVE_MS * 0.6; y += step) { await wheelTo(page, y, vp); await page.waitForTimeout(150); }
|
|
131
|
+
for (const el of interactive.filter((e) => !e.root).slice(0, 12)) {
|
|
132
|
+
if (Date.now() - t0 > OBSERVE_MS * 0.9) break;
|
|
133
|
+
await page.evaluate((y) => window.scrollTo(0, Math.max(0, y - innerHeight / 2)), el.y);
|
|
134
|
+
await page.waitForTimeout(120);
|
|
135
|
+
const vy = el.y - (await page.evaluate(() => scrollY));
|
|
136
|
+
await page.mouse.move(el.x, vy, { steps: 4 }).catch(() => {});
|
|
137
|
+
await page.waitForTimeout(250);
|
|
138
|
+
}
|
|
139
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
140
|
+
const exec = await observing;
|
|
141
|
+
const cov = exec.result?.coverage || {};
|
|
142
|
+
const sourceText = new Map();
|
|
143
|
+
for (const s of exec.result?.sources || []) if (s.source?.text) sourceText.set(String(s.script_id), { url: s.url, text: s.source.text });
|
|
144
|
+
// scripts without a URL are the page's inline <script> blocks, or the automation's own helpers: only the former count
|
|
145
|
+
const rendered = await fs.readFile(path.join(ref, 'rendered.html'), 'utf8').catch(() => '');
|
|
146
|
+
const inlineIndex = new Map();
|
|
147
|
+
const labelOf = (s) => {
|
|
148
|
+
if (s.reported_url) return s.reported_url;
|
|
149
|
+
const text = sourceText.get(String(s.script_id))?.text || '';
|
|
150
|
+
const head = text.trim().slice(0, 80);
|
|
151
|
+
if (!head || !rendered.includes(head)) return null;
|
|
152
|
+
if (!inlineIndex.has(head)) inlineIndex.set(head, inlineIndex.size + 1);
|
|
153
|
+
return `inline <script> #${inlineIndex.get(head)} ("${head.slice(0, 40).replace(/\s+/g, ' ')}…")`;
|
|
154
|
+
};
|
|
155
|
+
out.scripts = (cov.scripts || []).map((s) => {
|
|
156
|
+
const url = labelOf(s);
|
|
157
|
+
if (!url) return null;
|
|
158
|
+
const fns = s.functions || [];
|
|
159
|
+
const ran = fns.filter((f) => f.ranges?.some((r) => r.count > 0)).map((f) => f.name).filter(Boolean);
|
|
160
|
+
return { url, functionsRan: ran.length, functions: fns.length, bytes: sourceText.get(String(s.script_id))?.text.length || null, ranNames: ran.slice(0, 12) };
|
|
161
|
+
}).filter(Boolean).sort((a, b) => b.functionsRan - a.functionsRan);
|
|
162
|
+
if (!exec.result) out.notes.push(`observe-web-execution gave no result: ${exec.err}`);
|
|
163
|
+
|
|
164
|
+
// 3. handlers: the listener on each interactive element and the code around it
|
|
165
|
+
// readable source: lines around the handler; in a minified one-line bundle, a window of characters around the column
|
|
166
|
+
const excerpt = (text, line, column = 0, before = 3, after = 22) => {
|
|
167
|
+
const lines = text.split('\n');
|
|
168
|
+
const at = lines[line] || '';
|
|
169
|
+
if (at.length > 400) {
|
|
170
|
+
const from = Math.max(0, column - 500), to = Math.min(at.length, column + 900);
|
|
171
|
+
return `${line + 1}:${column} (minified, ${at.length} chars on this line; ${from}-${to} shown)\n${at.slice(from, to).replace(/;/g, ';\n')}`;
|
|
172
|
+
}
|
|
173
|
+
const from = Math.max(0, line - before);
|
|
174
|
+
return lines.slice(from, Math.min(lines.length, line + after)).map((l, i) => `${String(from + i + 1).padStart(5)} ${l.length > 160 ? `${l.slice(0, 160)}…` : l}`).join('\n');
|
|
175
|
+
};
|
|
176
|
+
const seenLoc = new Map();
|
|
177
|
+
for (const el of interactive) {
|
|
178
|
+
const r = await rea(['inspect-web-event-listeners', endpoint, tid, el.selector, ...originArgs], { timeout: 60000 });
|
|
179
|
+
const listeners = r.result?.listeners || [];
|
|
180
|
+
for (const s of r.result?.sources || []) if (s.source?.text && !sourceText.has(String(s.script_id))) sourceText.set(String(s.script_id), { url: s.url, text: s.source.text });
|
|
181
|
+
const entry = { selector: el.selector, label: el.label, text: el.text, y: el.y, listeners: [] };
|
|
182
|
+
for (const l of listeners) {
|
|
183
|
+
const loc = l.location || {};
|
|
184
|
+
const key = `${loc.url}:${loc.line_number}:${loc.column_number}`;
|
|
185
|
+
const src = sourceText.get(String(loc.script_id));
|
|
186
|
+
const listener = { type: l.type, url: loc.url, line: (loc.line_number ?? 0) + 1, column: loc.column_number, fn: loc.function_name || null, sharedWith: seenLoc.get(key) || null, code: !seenLoc.has(key) && src ? excerpt(src.text, loc.line_number ?? 0, loc.column_number ?? 0) : null };
|
|
187
|
+
if (!seenLoc.has(key)) seenLoc.set(key, el.label);
|
|
188
|
+
entry.listeners.push(listener);
|
|
189
|
+
}
|
|
190
|
+
if (!listeners.length && r.result?.selected_node?.match === 'found') entry.note = 'no direct listener: delegated (an ancestor, a framework root) or CSS-only';
|
|
191
|
+
if (!r.result) entry.note = `not inspected: ${r.err.split('\n')[0] || 'no output'}`;
|
|
192
|
+
out.handlers.push(entry);
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
await browser.close();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// code.md
|
|
199
|
+
const md = [];
|
|
200
|
+
md.push(`# ${name}: code evidence`, '', `Reference ${meta.url}, read ${out.at} with REA ${out.rea} through \`trueclone understand\` (trueclone owned the browser; REA observed, never clicked or evaluated). Observed facts only; what REA could not see is under Unknowns.`, '');
|
|
201
|
+
md.push('## Scripts, by how much of them ran while scrolling the page and hovering its controls', '', '| script | functions that ran | of | names |', '|---|---|---|---|');
|
|
202
|
+
for (const s of out.scripts) md.push(`| ${s.url} | ${s.functionsRan} | ${s.functions} | ${s.ranNames.join(', ')} |`);
|
|
203
|
+
if (!out.scripts.length) md.push('| (no coverage captured) | | | |');
|
|
204
|
+
md.push('', 'A script with nothing that ran is not needed for what the page does on a scroll-through; start from the ones at the top.', '');
|
|
205
|
+
md.push('## Handlers on interactive elements', '', 'What `script.js` has to reproduce, with the original code around each handler (read the rule, write your own). Elements that share one handler are listed once.', '');
|
|
206
|
+
for (const h of out.handlers) {
|
|
207
|
+
const direct = h.listeners.filter((l) => !l.sharedWith);
|
|
208
|
+
const shared = h.listeners.filter((l) => l.sharedWith);
|
|
209
|
+
if (!h.listeners.length && !h.note) continue;
|
|
210
|
+
md.push(`### \`${h.label}\`${h.text ? ` "${h.text}"` : ''} at y ${h.y}`, '', `selector: \`${h.selector}\``);
|
|
211
|
+
if (h.note) md.push(`- ${h.note}`);
|
|
212
|
+
for (const l of shared) md.push(`- ${l.type} → ${l.url}:${l.line} (same handler as \`${l.sharedWith}\`)`);
|
|
213
|
+
for (const l of direct) {
|
|
214
|
+
md.push(`- ${l.type} → ${l.url}:${l.line}${l.fn ? ` (${l.fn})` : ''}`);
|
|
215
|
+
if (l.code) md.push('', '```js', l.code, '```');
|
|
216
|
+
}
|
|
217
|
+
md.push('');
|
|
218
|
+
}
|
|
219
|
+
md.push('## Endpoints in the code', '', '| endpoint | how | where | fired during the capture |', '|---|---|---|---|');
|
|
220
|
+
for (const e of out.endpoints) md.push(`| ${e.value} | ${e.mechanism}${e.tracking ? ' (tracking: never copied)' : ''} | ${e.script}:${e.line ?? '?'} | ${e.fired ? 'yes (response in desktop.har)' : 'no: a form, a later state or a condition the capture did not reach'} |`);
|
|
221
|
+
if (!out.endpoints.length) md.push('| (none found in the scripts) | | | |');
|
|
222
|
+
if (out.routes?.length) md.push('', `Routes in the code: ${out.routes.join(', ')}`);
|
|
223
|
+
md.push('', '## Source maps', '');
|
|
224
|
+
md.push(out.sourceMaps.length ? out.sourceMaps.map((m) => `- ${m.script}: ${m.status}${m.sources.length ? `; originals: ${m.sources.join(', ')}` : ''}`).join('\n') : '- none published: the scripts are read as shipped (minified where minified)');
|
|
225
|
+
md.push('', '## Unknowns', '');
|
|
226
|
+
md.push([...out.unknowns, ...out.notes].length ? [...out.unknowns, ...out.notes].map((u) => `- ${u}`).join('\n') : '- none reported');
|
|
227
|
+
md.push('', 'Deeper, by hand: `npx trueclone rea <command>` (REA\'s own CLI; `npx trueclone rea --help`).', '');
|
|
228
|
+
await fs.writeFile(path.join(ref, 'code.md'), md.join('\n'));
|
|
229
|
+
await fs.writeFile(path.join(ref, 'code.json'), JSON.stringify(out, null, 2));
|
|
230
|
+
console.log(`understand: ${out.scripts.length} script(s) with coverage (${out.scripts.filter((s) => s.functionsRan).length} ran), ${out.handlers.filter((h) => h.listeners.length).length}/${out.handlers.length} elements with a direct handler, ${out.endpoints.length} endpoint(s) in code (${out.endpoints.filter((e) => e.fired).length} fired), ${out.sourceMaps.length} source map(s)`);
|
|
231
|
+
console.log(`-> ${path.relative(process.cwd(), path.join(ref, 'code.md'))}`);
|
package/skill/SKILL.md
CHANGED
|
@@ -10,7 +10,7 @@ Everything for a site lives in `sites/<name>/` (gitignored, stays local):
|
|
|
10
10
|
- `next/`: the same site as a Next.js + Tailwind v4 + shadcn app, generated from `clone/`.
|
|
11
11
|
- `design-tokens.json`: the **one canonical source** of the design system, rendered to `DESIGN.md` (Google design.md format, linted), `globals.css` (shadcn theme) and `clone/tokens.css`.
|
|
12
12
|
- `REPORT.md`: every score against its target, what to fix first, what was not copied. This is the deliverable.
|
|
13
|
-
- `notes.md`: your
|
|
13
|
+
- `notes.md`: your record, written by the seed with fixed sections; `REPORT.md` reads Findings and Accepted differences from it (see 2).
|
|
14
14
|
|
|
15
15
|
Run every step as `npx trueclone <step> <name>` from the project folder (`npx trueclone help` lists them).
|
|
16
16
|
|
|
@@ -30,8 +30,11 @@ npx trueclone capture <url> [name] [--hide "<cookie banner / popup selectors>"]
|
|
|
30
30
|
npx trueclone states <name> # live scroll states + motion inventory (animations running)
|
|
31
31
|
npx trueclone motion <name> # animation runtime: ScrollTriggers, timelines, reveals, cursor tweens, loops, Lenis/Swiper, CSS
|
|
32
32
|
npx trueclone spec <name> # measured boxes and styles per viewport
|
|
33
|
+
npx trueclone understand <name> # code evidence with REA: which scripts ran, each control's handler with its source -> code.md
|
|
33
34
|
```
|
|
34
35
|
|
|
36
|
+
`states`, `motion`, `spec` and `understand` only read the reference: run them together in the background while you read the capture.
|
|
37
|
+
|
|
35
38
|
`npx trueclone <url> [name]` is a shortcut for capture + design-system draft.
|
|
36
39
|
|
|
37
40
|
`reference/` then holds:
|
|
@@ -46,14 +49,25 @@ Read the motion inventory before building. Each entry is a behaviour the clone m
|
|
|
46
49
|
|
|
47
50
|
If the page is blank or blocked, capture through your own Chrome over CDP (`chrome --remote-debugging-port=9222 --user-data-dir=.chrome-profile`, then `--cdp`). Never try to get past bot checks, logins or paywalls.
|
|
48
51
|
|
|
49
|
-
## 2. Understand it
|
|
52
|
+
## 2. Understand it
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
npx trueclone understand <name> # code evidence with REA -> reference/code.md (2-4 min; run it with states/motion/spec)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The capture says what the page looks like and does; `code.md` says **which code does it**. trueclone owns the browser and drives REA (Reverse Engineer Anything, bundled) for you; you never call REA directly for this. `code.md` has:
|
|
59
|
+
- the scripts ranked by how much of them ran while scrolling the page and hovering its controls: a script with nothing that ran is not needed for what you are rebuilding;
|
|
60
|
+
- every interactive element's handler, with the original code around it (`click → engine.js:412` and the twenty lines there), plus the delegated handlers on `body`/`html`: **read the rule, write your own**. When `code.md` has the handler, copy the rule (its constants, its conditions), never guess it from recorded effects;
|
|
61
|
+
- the endpoints that exist in the code, fired during the capture or not (a form's POST, a filter's fetch): they go into the report's "Not copied";
|
|
62
|
+
- original sources when the site publishes source maps (most don't);
|
|
63
|
+
- what REA could not see (delegated handlers through a framework, minified names) under Unknowns.
|
|
50
64
|
|
|
51
|
-
|
|
52
|
-
- `inspect_web_page`: DOM, scripts, resources.
|
|
53
|
-
- `analyze_web_bundle` with source maps: framework, vendors (GSAP, ScrollTrigger, Lenis, Swiper, Webflow IX2, three.js), routes and API endpoints.
|
|
54
|
-
- `inspect_web_event_listeners`: hover, drag, wheel and click logic.
|
|
65
|
+
For the part no capture or listener reaches (a bundled physics rule, a shader, a module's data flow), REA's own CLI is one command away: `npx trueclone rea --help`. Record what you learn in `notes.md` under Behaviours and Unknowns.
|
|
55
66
|
|
|
56
|
-
|
|
67
|
+
**`notes.md` is a contract**, not a diary. The seed writes it with six sections, and `REPORT.md` reads two of them back:
|
|
68
|
+
- Stack, Behaviours (one line per motion entry and interaction: element, trigger, property, range, timing), Own-code map (file → behaviours → where each parameter came from), Unknowns;
|
|
69
|
+
- **Findings**: defects in the tool you met (symptom, cause, workaround). They appear in `REPORT.md` and are how the tool gets fixed;
|
|
70
|
+
- **Accepted differences**: what cannot or should not match, one per line with the why (`detail desktop hero chips: random walk on both sides`). A check below target that is covered by an entry naming it (and its width) is marked "accepted" in `REPORT.md` instead of "below target". Every entry needs a reason that would convince the site's owner; an entry without one is a gap you are hiding.
|
|
57
71
|
|
|
58
72
|
## 3. Design system
|
|
59
73
|
|
|
@@ -69,20 +83,26 @@ Review the draft: role names, the `notes` prose (overview, do's and don'ts), any
|
|
|
69
83
|
**Start every clone from the mirror** (both modes):
|
|
70
84
|
|
|
71
85
|
```powershell
|
|
72
|
-
npx trueclone reveals <name> # records scroll reveals frame by frame
|
|
86
|
+
npx trueclone reveals <name> # records, at all three widths: scroll reveals frame by frame, scroll-linked curves (parallax), pins, class changes
|
|
73
87
|
npx trueclone widgets <name> # clicks every tab and menu and records each state (they often aren't in the page until clicked)
|
|
74
88
|
npx trueclone seed <name> # clone/ from the script-free mirror; replays what reveals and widgets recorded
|
|
75
89
|
```
|
|
76
90
|
|
|
77
|
-
- **Exact mode:** the site's own markup, text, stylesheets and media at local paths.
|
|
91
|
+
- **Exact mode:** the site's own markup, text, stylesheets and media at local paths. The mirror fetches media the capture never saw (lazy images, hover and state images, fonts behind `@import`), so nothing points at the site.
|
|
78
92
|
- **Template mode:** the same markup and stylesheets, but every text becomes lorem words of the same laid-out width (lines break where the reference's break), every image, video, large SVG and iframe a same-size placeholder, CSS backgrounds a transparent one, every link `#`, brand metadata and tracking hints dropped. No text or media of the site ends up in `clone/`; fonts stay for local study (swap licensed fonts before reusing the result).
|
|
79
|
-
- **Recorded behaviour comes with it:** one-shot scroll reveals replay their measured curve (delay, duration, easing, spring overshoot) with the Web Animations API,
|
|
93
|
+
- **Recorded behaviour comes with it:** one-shot scroll reveals replay their measured curve (delay, duration, easing, spring overshoot) with the Web Animations API; scroll-linked curves (parallax, a scrubbed fade) follow the scroll position through the recorded samples; scroll class changes (a header that gets its backdrop past 12px) toggle the same classes at the same point. Every record exists per width, and the player uses the visitor's width, so phone offsets are never desktop pixels. All of it lives in `clone/trueclone-reveals.js`; don't rewrite it by hand. When your own script must drive an element the player also drives (a hero scene you rebuild whole), hand it over: `el.removeAttribute('data-tc-reveal')` / `'data-tc-scrub'` / `'data-tc-class'` at the start of `script.js`; the player leaves it alone and its recorded starting state (a stylesheet rule on the mark) goes with the mark. Never let both run on one element.
|
|
94
|
+
- **Runtime artefacts are cleaned:** the mirror is a snapshot taken after the site's scripts ran. The seed turns every GSAP pin into its CSS equivalent, a `position: sticky; top: 0` element inside a spacer whose padding is the pin distance (`[data-tc-pin-spacer]` around `[data-tc-runtime="pin"][data-tc-pin="<px>"]`), so layout and scroll states hold with no script. Animate inside it as it is; if you must recreate the pin with ScrollTrigger (`pin: true`), unwrap the spacer and clear the sticky style first, or the page gets both. `reveals` lists transform-driven pins with their start and end positions. Every `<canvas>` is marked `data-tc-runtime="canvas"` (its buffer size is dropped when CSS sizes it; your scene sets it). Regions a script builds at runtime (a grid a hero script assembles) are captured in their built state: your script owns them, so rebuild them from the captured markup rather than beside it.
|
|
95
|
+
- **Per-width values come with it:** the capture reads every inline style at all three widths. What a script set differently per width (a container's computed height, a card's offset, a custom property, a pin distance) becomes a media-query rule on `[data-tc-w]` elements (`<style id="tc-width-styles">`), and a pin whose distance changes per width carries `data-tc-pin-desktop/tablet/mobile`. Open shadow roots (web components, a Webflow code island) are carried as declarative shadow DOM (`<template shadowrootmode="open">` inside the host), so they render with no script.
|
|
80
96
|
- **Recorded widgets come with it too** (template mode): every tab set and expandable menu, with each state the original showed after a click, placeholdered like the page. `clone/trueclone-widgets.js` swaps the region on click (menus in a header or nav also on hover). Popovers rendered in a portal (comboboxes, tooltips) are listed as not replayed: build those in `script.js`. In interaction scenarios, `selector@n` picks the n-th match in `at`, `click` and `expectChange`; mind hidden duplicates (a mobile menu button is often the first match).
|
|
81
97
|
|
|
98
|
+
**Never edit `clone/index.html` by hand.** It is seed output and every re-seed rewrites it. Anything a clone needs on top of the seed goes in `clone/patches/`, applied by the seed in name order: `head-*.html` before `</head>`, `*.css` linked before `</head>` (after the site's stylesheets), `body-*.html` before the clone's own `<script src="script.js">` (vendored libraries, a hand-over prelude), `*.js` as a deferred script before `script.js`. A fix that needs the generated markup to change is a missing seed feature: report it in `notes.md` under Findings.
|
|
99
|
+
|
|
82
100
|
Then write `clone/script.js`: every remaining interaction in your own code, using the parameters you read from the reference (timings, physics constants, offsets, breakpoints). Never copy the site's scripts. JS-set initial states, such as a `<details>` closed on phones, are part of the layout, so check every viewport. Data the page fetches live (a star count, a feed) stays as captured; no requests to third parties.
|
|
83
101
|
|
|
84
102
|
A seed is where layout ends up at 99%+ with zero hand-written CSS; your work is behaviour. Build by hand (below) only what the mirror can't carry.
|
|
85
103
|
|
|
104
|
+
After each build round, `npx trueclone owned <name>` lists the sections your script rebuilds at boot (the clone loaded with scripts blocked vs running). Those are yours: hand their marks over to `script.js` and hide them until boot if the original hides them too. Everything else stays with the seed's players.
|
|
105
|
+
|
|
86
106
|
**Building parts by hand:**
|
|
87
107
|
|
|
88
108
|
- `styles.css` starts with `@import url("tokens.css");` and uses its variables. Never hard-code a token value.
|
|
@@ -105,6 +125,8 @@ A seed is where layout ends up at 99%+ with zero hand-written CSS; your work is
|
|
|
105
125
|
```powershell
|
|
106
126
|
npx trueclone score <name> --quick # layout, desktop states, interactions: the loop while building (about a minute)
|
|
107
127
|
npx trueclone score <name> # everything, in parallel groups, then REPORT.md
|
|
128
|
+
npx trueclone score <name> --only detail,load --viewport desktop # after a fix: just the checks it can move, at that width
|
|
129
|
+
npx trueclone explain <name> <y> # one place on the page: what every check saw there + a reference|clone|diff crop
|
|
108
130
|
```
|
|
109
131
|
|
|
110
132
|
The steps, if you need one alone:
|
|
@@ -118,13 +140,20 @@ npx trueclone interact <name> --draft # interactions.draft.json: fi
|
|
|
118
140
|
npx trueclone interact <name> # interactions: click/hover/drag/key scenarios + expectChange (geometry, text, state attributes, canvas pixels)
|
|
119
141
|
npx trueclone detail <name> --viewport all # styles at matching points: font, size, weight, line height, colour, boxes
|
|
120
142
|
npx trueclone hovers <name> # hover effects present on the reference but missing on the clone
|
|
143
|
+
npx trueclone load <name> # load sequence: how settled each side is 0.25-4 s after navigation (cold cache, same network)
|
|
121
144
|
```
|
|
122
145
|
|
|
123
146
|
Reference measurements are cached until the next capture, so only the clone is re-measured. Sites without scroll/time motion or without interactions simply skip those steps.
|
|
124
147
|
|
|
125
|
-
|
|
148
|
+
**Spend scoring time only on what changed.** The first run is a full `score`. After a fix, re-run only the checks it can move, at the width it concerns: `npx trueclone score <name> --only layout,detail --viewport tablet`. A check whose inputs (the clone's files, the capture) have not changed since its last run is reported from that run and not re-measured (`--fresh` forces it). `REPORT.md` shows where the scoring time went. To understand one gap, run `npx trueclone explain <name> <y or "section label"> --viewport <vp>`: it prints what layout, states, detail and hovers saw there and writes a reference | clone | diff crop, so you read the tool's data instead of re-measuring with a browser.
|
|
149
|
+
|
|
150
|
+
**Unstable probes are not your gaps.** Detail probes and hover effects are read twice on the reference; what does not agree with itself (random motion, a hover that lands by timing, a rotating word) is listed as unstable and excluded from the score. The load check reports its own noise (±). Do not chase a number inside the noise: a gap of 0.3 on a check with ±1 noise is not a gap.
|
|
151
|
+
|
|
152
|
+
Fix in this order: page height drift, the worst layout section, the worst state step, the lowest motion curve, any interaction marked FAIL, the load sequence, then detail and hovers. `REPORT.md` lists them under "Fix first". For a drift, compare the element's ancestor boxes on both sides until one differs, then `rules` that ancestor: the cause is usually one declaration. Fix the cause; never tune numbers to the score.
|
|
153
|
+
|
|
154
|
+
**Done when:** layout ≥ 98% at every viewport, states within 5 points of layout, motion ≥ 95%, detail ≥ 95%, hovers ≥ 90%, load ≥ 95%, and every interaction passes. Otherwise stop after 8 passes and report what's left and why.
|
|
126
155
|
|
|
127
|
-
**
|
|
156
|
+
**The load sequence is scored too.** A visitor's first impression is the load, so both pages are photographed 0.25 to 4 s after navigation and compared with their own settled frame. A clone that paints a captured, pre-script state (a hero grid a script builds, a card layout a script places) until its scripts run shows a gap here that no other check sees. The seed's boot contract handles it: `<html class="tc-boot">` from the first byte until the clone's scripts have run and every hold is released; elements marked `data-tc-boot-hide` stay invisible meanwhile (3 s failsafe). Pass the regions your script builds at boot to the seed: `npx trueclone seed <name> --hide-until-boot "<selectors>"`. If your script needs more than "has run" (an intro that waits for its image), call `tcBoot.hold()` at the top of `script.js` and `tcBoot.release()` when the first frame is ready. Hide only what the original also does not show before its scripts run (its first frames are in `reference/load/`); hiding more than the original hides is a gap of its own.
|
|
128
157
|
|
|
129
158
|
How detail measures, so you can trust it:
|
|
130
159
|
- motion on (many originals ignore `prefers-reduced-motion`, and a clone that honours it lays out differently);
|
|
@@ -137,7 +166,7 @@ How layout and states measure:
|
|
|
137
166
|
- content that a component mounts only while in view (marked `data-tc-viewmount` by capture) can't appear in a full-page still on either side: layout paints its box the same on both and reports the count; the scroll-state check, with a real viewport, scores it;
|
|
138
167
|
- endless animations (marquees, rotating borders) are frozen on their first frame on both sides before every state screenshot: their phase depends on load time, not on the clone.
|
|
139
168
|
|
|
140
|
-
The live site can change after capture (a redeploy
|
|
169
|
+
The live site can change after capture (a redeploy, a new nav item, a rotating headline). Every full score starts with `drift`: the live page's height and sections against the capture's. When it reports a change, `REPORT.md` marks detail, hovers, motion, interactions and load **stale capture**: they now measure a page the clone was never a copy of. Re-capture (`capture`, then `states`, `reveals`, `seed --force`) before reading them; never fix the clone against a stale reference.
|
|
141
170
|
|
|
142
171
|
## 6. Next.js (after the HTML clone passes)
|
|
143
172
|
|