@trueclone/cli 0.3.0 → 0.4.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 +24 -32
- package/bin/trueclone.mjs +23 -19
- package/package.json +2 -1
- package/scripts/capture.mjs +136 -24
- package/scripts/curves.mjs +44 -7
- package/scripts/diff.mjs +4 -2
- package/scripts/drift.mjs +21 -14
- package/scripts/hovers.mjs +137 -45
- package/scripts/interact.mjs +35 -5
- package/scripts/lib.mjs +69 -5
- package/scripts/load.mjs +12 -8
- package/scripts/next.mjs +74 -9
- package/scripts/report.mjs +46 -12
- package/scripts/reveals.mjs +4 -0
- package/scripts/review.mjs +149 -0
- package/scripts/score.mjs +4 -1
- package/scripts/scripts-evidence.mjs +116 -0
- package/scripts/seed.mjs +188 -115
- package/scripts/states.mjs +38 -5
- package/scripts/tiles.mjs +33 -17
- package/scripts/viewport-fit.mjs +198 -0
- package/skill/SKILL.md +119 -145
package/scripts/tiles.mjs
CHANGED
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
// Cut a tall full-page screenshot into viewport-sized tiles you can actually read.
|
|
2
|
-
// usage: node scripts/tiles.mjs <
|
|
2
|
+
// usage: node scripts/tiles.mjs <name> [--size desktop] [--clone] -> sites/<name>/reference/tiles/<size>-01.png ...
|
|
3
|
+
// node scripts/tiles.mjs <png> [tileHeight=1200] -> <png-dir>/tiles/<png-name>-01.png ...
|
|
3
4
|
import fs from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { PNG } from 'pngjs';
|
|
7
|
+
import { SCORED, parseArgs } from './lib.mjs';
|
|
6
8
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
10
|
+
const [target, h] = pos;
|
|
11
|
+
if (!target) {
|
|
12
|
+
console.error('usage: trueclone tiles <name> [--size desktop|tablet|mobile|laptop|wide] [--clone] or trueclone tiles <file.png> [tileHeight]');
|
|
10
13
|
process.exit(1);
|
|
11
14
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
await fs.
|
|
15
|
+
let files;
|
|
16
|
+
let tileH = Number(h || 1200);
|
|
17
|
+
if (/\.png$/i.test(target)) files = [path.resolve(target)];
|
|
18
|
+
else {
|
|
19
|
+
// a site name: the reference's full-page shots (or the clone's last layout shots with --clone)
|
|
20
|
+
const root = path.resolve('sites', target);
|
|
21
|
+
const sizes = flags.size ? [flags.size] : SCORED.map((v) => v.name);
|
|
22
|
+
files = sizes.map((s) => (flags.clone ? path.join(root, 'compare', `${s}.mask.clone.png`) : path.join(root, 'reference', `${s}.png`)));
|
|
23
|
+
if (!h) tileH = 0; // one tile per screen of that size
|
|
24
|
+
}
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
const src = await fs.readFile(file).then((b) => PNG.sync.read(b), () => null);
|
|
27
|
+
if (!src) { console.log(`skipped (not found): ${path.relative(process.cwd(), file)}`); continue; }
|
|
28
|
+
const size = SCORED.find((v) => path.basename(file).startsWith(`${v.name}.`));
|
|
29
|
+
const th = tileH || size?.height || 1200;
|
|
30
|
+
const dir = path.join(path.dirname(file), 'tiles');
|
|
31
|
+
await fs.mkdir(dir, { recursive: true });
|
|
32
|
+
const base = path.basename(file, '.png');
|
|
33
|
+
let n = 0;
|
|
34
|
+
for (let y = 0; y < src.height; y += th) {
|
|
35
|
+
const hh = Math.min(th, src.height - y);
|
|
36
|
+
const out = new PNG({ width: src.width, height: hh });
|
|
37
|
+
PNG.bitblt(src, out, 0, y, src.width, hh, 0, 0);
|
|
38
|
+
await fs.writeFile(path.join(dir, `${base}-${String(++n).padStart(2, '0')}.png`), PNG.sync.write(out));
|
|
39
|
+
}
|
|
40
|
+
console.log(`${n} tiles of ${th}px -> ${path.relative(process.cwd(), dir)} (${base}-NN.png)`);
|
|
24
41
|
}
|
|
25
|
-
console.log(`${n} tiles -> ${dir}`);
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Inline styles a script set, read at many screen sizes (capture), turned into what the clone may carry:
|
|
2
|
+
// - a value that is the same at every size stays as it is;
|
|
3
|
+
// - a value that is a function of the viewport (a·width + b·height + c, in px) becomes that function as a CSS calc(),
|
|
4
|
+
// for all sizes when one function fits them all, or per range of the site's own breakpoints;
|
|
5
|
+
// - a value that fits no such function (it depends on content, time, a container, randomness) is NOT carried: the
|
|
6
|
+
// clone's script must compute it, the way the site does. It is listed as script-owned.
|
|
7
|
+
// Nothing here is ever exact at the captured sizes only: that is the point.
|
|
8
|
+
|
|
9
|
+
const MODELS = [
|
|
10
|
+
{ name: 'c', cols: () => [1] },
|
|
11
|
+
{ name: 'w', cols: (w) => [w] },
|
|
12
|
+
{ name: 'h', cols: (w, h) => [h] },
|
|
13
|
+
{ name: 'w+c', cols: (w) => [w, 1] },
|
|
14
|
+
{ name: 'h+c', cols: (w, h) => [h, 1] },
|
|
15
|
+
{ name: 'w+h', cols: (w, h) => [w, h] },
|
|
16
|
+
{ name: 'w+h+c', cols: (w, h) => [w, h, 1] },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function solve(A, b) {
|
|
20
|
+
const n = b.length;
|
|
21
|
+
const M = A.map((row, i) => [...row, b[i]]);
|
|
22
|
+
for (let c = 0; c < n; c++) {
|
|
23
|
+
let p = c;
|
|
24
|
+
for (let r = c + 1; r < n; r++) if (Math.abs(M[r][c]) > Math.abs(M[p][c])) p = r;
|
|
25
|
+
if (Math.abs(M[p][c]) < 1e-9) return null;
|
|
26
|
+
[M[c], M[p]] = [M[p], M[c]];
|
|
27
|
+
for (let r = 0; r < n; r++) {
|
|
28
|
+
if (r === c) continue;
|
|
29
|
+
const f = M[r][c] / M[c][c];
|
|
30
|
+
for (let k = c; k <= n; k++) M[r][k] -= f * M[c][k];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return M.map((row, i) => row[n] / row[i]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// points: [{ w, h, v }] -> { model, coef } or null. A model needs one point more than it has parameters, so every fit
|
|
37
|
+
// is checked on a point it was not determined by.
|
|
38
|
+
export function fitPoints(points, shaped = true) {
|
|
39
|
+
for (const m of MODELS) {
|
|
40
|
+
const k = m.cols(1, 1).length;
|
|
41
|
+
if (points.length < k + 1) continue;
|
|
42
|
+
const X = points.map((p) => m.cols(p.w, p.h));
|
|
43
|
+
const A = Array.from({ length: k }, () => new Array(k).fill(0));
|
|
44
|
+
const b = new Array(k).fill(0);
|
|
45
|
+
points.forEach((p, i) => { for (let r = 0; r < k; r++) { b[r] += X[i][r] * p.v; for (let c = 0; c < k; c++) A[r][c] += X[i][r] * X[i][c]; } });
|
|
46
|
+
const coef = solve(A, b);
|
|
47
|
+
if (!coef) continue;
|
|
48
|
+
const tol = Math.max(0.75, 0.003 * Math.max(...points.map((p) => Math.abs(p.v))));
|
|
49
|
+
if (points.every((p, i) => Math.abs(X[i].reduce((s, x, j) => s + x * coef[j], 0) - p.v) <= tol)) return { model: m.name, coef };
|
|
50
|
+
}
|
|
51
|
+
return shaped ? fitTwoLines(points) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The smaller or larger of two lines: min(L1, L2) / max(L1, L2), each L = a·w + b·h + c. This is what a script
|
|
55
|
+
// computes for "cover" (max(100vw, 177.8vh)), "contain", a cover image centred (min((w - 1.778·h) / 2, 0)), a tile
|
|
56
|
+
// of it, a size clamped at a limit. Which line applies switches at an aspect ratio, a width or a height: the points
|
|
57
|
+
// are split on each of those in turn, each side must fit one line on its own (checked as usual), and then every point
|
|
58
|
+
// must be the min (or max) of both lines. Eight points at least, so two short lines never fit noise.
|
|
59
|
+
function fitTwoLines(points) {
|
|
60
|
+
if (points.length < 8) return null;
|
|
61
|
+
const tol = Math.max(0.75, 0.003 * Math.max(...points.map((p) => Math.abs(p.v))));
|
|
62
|
+
for (const key of [(p) => p.w / p.h, (p) => p.w, (p) => p.h]) {
|
|
63
|
+
const sorted = [...points].sort((p, q) => key(p) - key(q));
|
|
64
|
+
for (let s = 2; s <= sorted.length - 2; s++) {
|
|
65
|
+
if (key(sorted[s - 1]) === key(sorted[s])) continue;
|
|
66
|
+
const lo = fitPoints(sorted.slice(0, s), false), hi = lo && fitPoints(sorted.slice(s), false);
|
|
67
|
+
if (!lo || !hi) continue;
|
|
68
|
+
for (const model of ['min', 'max']) {
|
|
69
|
+
const f = model === 'min' ? Math.min : Math.max;
|
|
70
|
+
if (points.every((p) => Math.abs(f(lineAt(lo, p.w, p.h), lineAt(hi, p.w, p.h)) - p.v) <= tol)) return { model, lines: [lo, hi] };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const lineAt = ({ model, coef }, w, h) => {
|
|
77
|
+
let i = 0, v = 0;
|
|
78
|
+
for (const t of model.split('+')) v += coef[i++] * (t === 'w' ? w : t === 'h' ? h : 1);
|
|
79
|
+
return v;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const r4 = (x) => +x.toFixed(4);
|
|
83
|
+
// a fitted px value as CSS: width terms use --tc-vw (1% of the layout width, scrollbar excluded, set by the seed)
|
|
84
|
+
export function cssOf({ model, coef, lines }) {
|
|
85
|
+
if (lines) return `${model}(${lines.map((l) => cssOf(l).replace(/^calc\((.*)\)$/, '$1')).join(', ')})`;
|
|
86
|
+
const parts = model.split('+');
|
|
87
|
+
let a = 0, b = 0, c = 0;
|
|
88
|
+
parts.forEach((p, i) => { if (p === 'w') a = coef[i]; else if (p === 'h') b = coef[i]; else c = coef[i]; });
|
|
89
|
+
const terms = [];
|
|
90
|
+
if (Math.abs(a) > 1e-6) terms.push([a * 100, (x) => `${r4(Math.abs(x))} * var(--tc-vw, 1vw)`]);
|
|
91
|
+
if (Math.abs(b) > 1e-6) terms.push([b * 100, (x) => `${r4(Math.abs(x))}vh`]);
|
|
92
|
+
if (Math.abs(c) > 0.005 || !terms.length) terms.push([c, (x) => `${+Math.abs(x).toFixed(2)}px`]);
|
|
93
|
+
if (terms.length === 1 && model === 'c') return `${+c.toFixed(2)}px`;
|
|
94
|
+
return `calc(${terms.map(([x, f], i) => `${i === 0 ? (x < 0 ? '-1 * ' : '') : x < 0 ? '- ' : '+ '}${f(x)}`).join(' ')})`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const PX = /-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?px/gi; // 5.68434e-14px is a number too (a script's float noise)
|
|
98
|
+
// one spelling per meaning, so a library that writes translate3d(0px, 0px, 0px) at one size and translate(0px, 0px)
|
|
99
|
+
// at another is not mistaken for a value that changes
|
|
100
|
+
const normalise = (v) => v
|
|
101
|
+
.replace(/translate3d\(\s*([^,()]+),\s*([^,()]+),\s*0(?:px)?\s*\)/g, 'translate($1, $2)')
|
|
102
|
+
.replace(/translateX\(\s*([^,()]+)\)/g, 'translate($1, 0px)')
|
|
103
|
+
.replace(/translateY\(\s*([^,()]+)\)/g, 'translate(0px, $1)')
|
|
104
|
+
.replace(/translate\(\s*([^,()]+)\)/g, 'translate($1, 0px)')
|
|
105
|
+
.replace(/(^|[\s(,])0(?=[\s),]|$)/g, '$10px');
|
|
106
|
+
const parseDecls = (text) => {
|
|
107
|
+
const map = new Map();
|
|
108
|
+
for (const d of String(text || '').split(/;(?![^(]*\))/)) {
|
|
109
|
+
const i = d.indexOf(':');
|
|
110
|
+
if (i < 0) continue;
|
|
111
|
+
const k = d.slice(0, i).trim(), v = d.slice(i + 1).trim().replace(/\s*!important$/i, '');
|
|
112
|
+
if (k) map.set(k, /transform|translate/.test(k) ? normalise(v) : v);
|
|
113
|
+
}
|
|
114
|
+
return map;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// values: [{ w, h, v: string }] -> CSS value string, or null when no viewport function fits
|
|
118
|
+
function fitValue(values) {
|
|
119
|
+
const tmpl = values[0].v.replace(PX, '§');
|
|
120
|
+
if (values.some((x) => x.v.replace(PX, '§') !== tmpl)) return null; // keywords or unitless numbers change: not a function of size
|
|
121
|
+
if (values.every((x) => x.v === values[0].v)) return values[0].v;
|
|
122
|
+
const nums = values.map((x) => (x.v.match(PX) || []).map(parseFloat));
|
|
123
|
+
const out = [];
|
|
124
|
+
for (let t = 0; t < nums[0].length; t++) {
|
|
125
|
+
const fit = fitPoints(values.map((x, i) => ({ w: x.w, h: x.h, v: nums[i][t] })));
|
|
126
|
+
if (!fit) return null;
|
|
127
|
+
out.push(cssOf(fit));
|
|
128
|
+
}
|
|
129
|
+
let k = 0;
|
|
130
|
+
return tmpl.replace(/§/g, () => out[k++]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// data: inline-styles.json ({ breakpoints, sizes, styles: { "WxH": { w, h, styles: { path: [style, class] } } } }), or
|
|
134
|
+
// the 0.3 shape ({ desktop: { path: style }, ... }). Returns, per element path: the declarations to set inline, the
|
|
135
|
+
// per-range rules, and the properties left to the clone's script.
|
|
136
|
+
export function planInlineStyles(data, viewports) {
|
|
137
|
+
let breakpoints = [], sizes = [];
|
|
138
|
+
if (data?.styles) {
|
|
139
|
+
breakpoints = data.breakpoints || [];
|
|
140
|
+
sizes = Object.values(data.styles).map((s) => ({ w: s.w, h: s.h, styles: Object.fromEntries(Object.entries(s.styles).map(([p, v]) => [p, Array.isArray(v) ? v[0] : v])) }));
|
|
141
|
+
} else if (data) {
|
|
142
|
+
sizes = Object.entries(data).map(([name, styles]) => { const vp = viewports.find((v) => v.name === name); return vp ? { w: vp.width, h: vp.height, styles } : null; }).filter(Boolean);
|
|
143
|
+
}
|
|
144
|
+
const edges = [0, ...breakpoints, 100000];
|
|
145
|
+
const ranges = edges.slice(0, -1).map((lo, i) => ({ lo: i === 0 ? 0 : lo + 1, hi: edges[i + 1] }));
|
|
146
|
+
const mediaOf = (r) => (r.lo === 0 ? `(max-width: ${r.hi}px)` : r.hi >= 100000 ? `(min-width: ${r.lo}px)` : `(min-width: ${r.lo}px) and (max-width: ${r.hi}px)`);
|
|
147
|
+
const plan = {};
|
|
148
|
+
const owned = [];
|
|
149
|
+
let fitted = 0, constant = 0;
|
|
150
|
+
const paths = new Set(sizes.flatMap((s) => Object.keys(s.styles)));
|
|
151
|
+
for (const p of paths) {
|
|
152
|
+
const per = sizes.map((s) => ({ w: s.w, h: s.h, d: parseDecls(s.styles[p]) }));
|
|
153
|
+
const props = new Set(per.flatMap((x) => [...x.d.keys()]));
|
|
154
|
+
const entry = { inline: {}, rules: [], dropped: [] };
|
|
155
|
+
for (const k of props) {
|
|
156
|
+
const vals = per.map((x) => ({ w: x.w, h: x.h, v: x.d.get(k) }));
|
|
157
|
+
if (vals.every((x) => x.v !== undefined && x.v === vals[0].v)) { constant++; continue; } // same everywhere: as captured
|
|
158
|
+
// one function for every size
|
|
159
|
+
if (vals.every((x) => x.v !== undefined)) {
|
|
160
|
+
const css = fitValue(vals);
|
|
161
|
+
if (css !== null) { entry.inline[k] = css; fitted++; continue; }
|
|
162
|
+
}
|
|
163
|
+
// per range of the site's breakpoints. Neighbouring ranges one function fits are one group (fewer rules, more
|
|
164
|
+
// points behind each fit); a range no captured size fell in joins its neighbour rather than losing the value.
|
|
165
|
+
const joins = (a, b) => {
|
|
166
|
+
if (!a.length || !b.length) return true;
|
|
167
|
+
const all = [...a, ...b];
|
|
168
|
+
const unset = all.filter((x) => x.v === undefined).length;
|
|
169
|
+
return unset === all.length || (!unset && fitValue(all) !== null);
|
|
170
|
+
};
|
|
171
|
+
const groups = [];
|
|
172
|
+
for (const r of ranges.length > 1 ? ranges : []) {
|
|
173
|
+
const inR = vals.filter((x) => x.w >= r.lo && x.w <= r.hi);
|
|
174
|
+
const last = groups[groups.length - 1];
|
|
175
|
+
if (last && joins(last.pts, inR)) { last.hi = r.hi; last.pts.push(...inR); } else groups.push({ lo: r.lo, hi: r.hi, pts: [...inR] });
|
|
176
|
+
}
|
|
177
|
+
let anyDrop = !groups.length;
|
|
178
|
+
const rules = [];
|
|
179
|
+
for (const g of groups) {
|
|
180
|
+
if (g.pts.every((x) => x.v === undefined)) continue; // the script doesn't set it at these sizes
|
|
181
|
+
// a fit needs a point it was not determined by; a group with unset and set sizes is not a function of size
|
|
182
|
+
if (g.pts.length < 2 || g.pts.some((x) => x.v === undefined)) { anyDrop = true; continue; }
|
|
183
|
+
const css = fitValue(g.pts);
|
|
184
|
+
if (css === null) { anyDrop = true; continue; }
|
|
185
|
+
rules.push({ media: mediaOf(g), prop: k, value: css });
|
|
186
|
+
}
|
|
187
|
+
// all or nothing: a property the clone's script must compute at some sizes is the script's at every size
|
|
188
|
+
// (half carried by the seed, half computed, the two would fight at the edges)
|
|
189
|
+
const anyRule = !anyDrop && rules.length > 0;
|
|
190
|
+
if (anyRule) entry.rules.push(...rules);
|
|
191
|
+
entry.inline[k] = null; // the captured constant no longer applies
|
|
192
|
+
if (anyRule) fitted++;
|
|
193
|
+
if (anyDrop || !anyRule) { entry.dropped.push(k); owned.push({ path: p, prop: k, samples: vals.slice(0, 5).map((x) => `${x.w}x${x.h}: ${x.v ?? '(unset)'}`) }); }
|
|
194
|
+
}
|
|
195
|
+
if (Object.keys(entry.inline).length || entry.rules.length || entry.dropped.length) plan[p] = entry;
|
|
196
|
+
}
|
|
197
|
+
return { plan, owned, fitted, constant, breakpoints, sizes: sizes.length };
|
|
198
|
+
}
|
package/skill/SKILL.md
CHANGED
|
@@ -1,187 +1,161 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: trueclone
|
|
3
|
-
description: Build
|
|
3
|
+
description: Build the exact same website from its URL - HTML/CSS/JS clone and Next.js + Tailwind + shadcn app, identical at every screen size, plus DESIGN.md and a shadcn globals.css - and prove it with layout, scroll, canvas, motion, hover, interaction, style and load checks at seed and held-out screen sizes, and a frame-by-frame visual review. Use when the user gives a URL and asks to clone, replicate, rebuild or migrate a site or page, or to extract its design system.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Build
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
6
|
+
# Build the same website, at every screen size
|
|
7
|
+
|
|
8
|
+
The goal: the user opens the clone on localhost on any device, at any window size, and it looks and behaves exactly
|
|
9
|
+
like the original. Not at three widths: at every width. Accuracy comes first, time second, scores third: a score is
|
|
10
|
+
an instrument; when it and your eyes disagree, your eyes win and you say so in `notes.md` (Findings).
|
|
11
|
+
|
|
12
|
+
Everything for a site lives in `sites/<name>/` (gitignored, local): `clone/` (the source of truth), `next/` (the
|
|
13
|
+
Next.js app generated from it), `notes.md` (your record, a contract), `REPORT.md` (the deliverable). Run every step as
|
|
14
|
+
`npx trueclone <step> <name>` from the project folder (`npx trueclone help` lists them).
|
|
15
|
+
|
|
16
|
+
## The rules
|
|
17
|
+
|
|
18
|
+
1. **Structure comes from the seed; behaviour is code.** The seed gives the site's markup, stylesheets, media and
|
|
19
|
+
every value that is the same at every size or a plain function of the screen size. Everything a script does
|
|
20
|
+
(layout it computes, scenes it builds, motion, hover, state, canvases) you re-implement in `clone/script.js`,
|
|
21
|
+
**porting the site's own rule** from `reference/scripts/` (`INDEX.md` ranks the scripts by what actually ran and
|
|
22
|
+
points every event listener at file:line). Read the algorithm; write your own code; never paste the site's code.
|
|
23
|
+
2. **Every screen size.** Your code computes from the live viewport, the way the site's does (`innerWidth`,
|
|
24
|
+
`innerHeight`, element boxes, `resize`, the site's own breakpoints). Never put a pixel value you measured or
|
|
25
|
+
recorded at one size into the clone. Elements marked `data-tc-script-owned` (and `reference/script-owned.json`) hold
|
|
26
|
+
values the site computes that no simple function of the screen size explains: they are yours to compute.
|
|
27
|
+
3. **Never edit generated files.** `clone/index.html` is seed output. What a clone needs on top goes in
|
|
28
|
+
`clone/patches/` (see Build). A fix that needs the generated markup changed is a tool defect: write it in Findings.
|
|
29
|
+
4. **Look, don't just score.** `npx trueclone review <name>` shows reference and clone side by side at a size the
|
|
30
|
+
clone was never built from, at load, at every section, with the pointer on a control. You give every frame a
|
|
31
|
+
verdict in `notes.md`. A frame that differs is open, whatever the numbers say.
|
|
32
|
+
5. **Done means done.** Every check passes at every scored size, the held-out sizes included; every review frame is
|
|
33
|
+
"same"; an accepted difference is only for the last points of a check already ≥ 95 and names an evidence file.
|
|
34
|
+
There is no pass limit: keep fixing until that holds or every remaining gap is proven a tool defect in Findings.
|
|
35
|
+
6. **Exact mode only with permission.** `template` (default) is for anyone's site: placeholder text and media, the
|
|
36
|
+
original's structure and behaviour. `exact` copies the site's own text and media and refuses to run without
|
|
37
|
+
`--permission "<who authorised it>"`; never use it on a site the user doesn't own or have permission for. Never get
|
|
38
|
+
past bot checks, logins or paywalls. No tracking scripts; forms submit nowhere; `noindex`.
|
|
25
39
|
|
|
26
40
|
## 1. Capture
|
|
27
41
|
|
|
28
42
|
```powershell
|
|
29
43
|
npx trueclone capture <url> [name] [--hide "<cookie banner / popup selectors>"] [--mode exact --permission "<owner>"]
|
|
30
|
-
npx trueclone states <name>
|
|
31
|
-
npx trueclone motion <name>
|
|
32
|
-
npx trueclone spec <name>
|
|
33
|
-
npx trueclone understand <name> # code evidence with REA: which scripts ran, each control's handler with its source -> code.md
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
`states`, `motion`, `spec` and `understand` only read the reference: run them together in the background while you read the capture.
|
|
37
|
-
|
|
38
|
-
`npx trueclone <url> [name]` is a shortcut for capture + design-system draft.
|
|
39
|
-
|
|
40
|
-
`reference/` then holds:
|
|
41
|
-
- full-page PNGs and their `*.mask.png` structure versions, `sections.json` and `rendered.html`;
|
|
42
|
-
- `tokens.json` (colours, type, radii, CSS variables), `assets/` with `manifest.json`, and `desktop.har` (every request, including the data/API calls);
|
|
43
|
-
- **`states/<vp>/NN.png`**: what a visitor sees at every half-viewport scroll step, after motion settles;
|
|
44
|
-
- **`states/<vp>.summary.json`** (the motion inventory): `reveals`, `animated`, `parallax`, `pinned` and `fixed` elements;
|
|
45
|
-
- **`motion.<vp>.md`**: the exact animation recipe when the site uses GSAP/ScrollTrigger, Lenis, Swiper or CSS animations: trigger, start/end, scrub, pin, properties and ranges, duration, easing, stagger. Rebuild these with the same public libraries and these numbers, in your own code;
|
|
46
|
-
- in exact mode, `mirror/index.html`: a script-free local snapshot with assets rewritten to local copies.
|
|
47
|
-
|
|
48
|
-
Read the motion inventory before building. Each entry is a behaviour the clone must have.
|
|
49
|
-
|
|
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.
|
|
51
|
-
|
|
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)
|
|
44
|
+
npx trueclone states <name> # scroll states at every scored size (reference/states/)
|
|
45
|
+
npx trueclone motion <name> # the animation runtime: ScrollTriggers, timelines, tweens, loops, Lenis/Swiper, CSS
|
|
46
|
+
npx trueclone spec <name> # measured boxes and styles per size
|
|
56
47
|
```
|
|
57
48
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
-
|
|
49
|
+
`states`, `motion` and `spec` only read the reference: run them one after another in the background while you read.
|
|
50
|
+
Capture shoots five sizes: the seed sizes 390×844, 768×1024, 1440×900 (the clone is built from these) and the
|
|
51
|
+
held-out 1280×720 and 1920×1080 (scored, never used to build). It also reads inline styles at extra sizes inside each
|
|
52
|
+
of the site's breakpoint ranges, and saves every script that ran (`reference/scripts/`). If the page is blank or
|
|
53
|
+
blocked, capture through your own Chrome over CDP (`--cdp`); never get around a bot check.
|
|
54
|
+
|
|
55
|
+
## 2. Understand
|
|
56
|
+
|
|
57
|
+
Write `notes.md` (the seed creates it; its sections are a contract): **Stack**, **Behaviours** (one line each:
|
|
58
|
+
element, trigger, property, range, timing, *and the file:line of the rule in reference/scripts/*), **Own-code map**,
|
|
59
|
+
**Findings**, **Accepted differences**, **Unknowns**, **Review**.
|
|
60
|
+
|
|
61
|
+
- `reference/scripts/INDEX.md`: the scripts by what ran, readable copies of minified ones, every listener's handler.
|
|
62
|
+
This is where a scene's algorithm is: a grid computed from the viewport, a spring, a pin length, a hover panel.
|
|
63
|
+
- `reference/script-owned.json`: every value the site's scripts set that no function of the screen size explains,
|
|
64
|
+
with its samples at many sizes (random, content-fitted, state- or scroll-driven); `reference/inline-styles.json`
|
|
65
|
+
`scriptQueries`: the widths the scripts themselves switch at (`matchMedia`).
|
|
66
|
+
- `reference/motion.<size>.md`: ScrollTriggers, tweens, easings, pins, as the runtime reports them.
|
|
67
|
+
- `reference/states/<size>.summary.json`: reveals, parallax, pinned and fixed elements; `npx trueclone tiles <name>`
|
|
68
|
+
cuts the full-page shots into readable screens.
|
|
69
|
+
- For a part no script reading explains (a bundled physics rule, a shader, WASM), REA is bundled:
|
|
70
|
+
`npx trueclone rea --help`. Rarely needed.
|
|
71
71
|
|
|
72
72
|
## 3. Design system
|
|
73
73
|
|
|
74
74
|
```powershell
|
|
75
|
-
npx trueclone design <name>
|
|
76
|
-
npx trueclone design <name> --redraft # fresh draft in design-tokens.draft.json (never overwrites your edits)
|
|
75
|
+
npx trueclone design <name> # design-tokens.json -> DESIGN.md (must lint with 0 errors), globals.css, clone/tokens.css
|
|
77
76
|
```
|
|
78
77
|
|
|
79
|
-
Review the draft
|
|
78
|
+
Review the draft (role names, notes, anything `origin: generated`) and re-run after every edit.
|
|
80
79
|
|
|
81
|
-
## 4. Build
|
|
82
|
-
|
|
83
|
-
**Start every clone from the mirror** (both modes):
|
|
80
|
+
## 4. Build
|
|
84
81
|
|
|
85
82
|
```powershell
|
|
86
|
-
npx trueclone reveals <name> #
|
|
87
|
-
npx trueclone widgets <name> #
|
|
88
|
-
npx trueclone seed <name> # clone/ from the script-free mirror
|
|
83
|
+
npx trueclone reveals <name> # one-shot scroll reveals and scroll class changes, recorded at the seed sizes
|
|
84
|
+
npx trueclone widgets <name> # every tab and menu state (template mode replays them)
|
|
85
|
+
npx trueclone seed <name> # clone/ from the script-free mirror
|
|
89
86
|
```
|
|
90
87
|
|
|
91
|
-
|
|
92
|
-
-
|
|
93
|
-
|
|
94
|
-
- **
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
- **Canvas/WebGL:** a site can draw its hero or a game on a `<canvas>`. Rebuild the drawing in your own code with the reference's parameters (exact mode) or a placeholder scene. Never drop it silently.
|
|
118
|
-
- **Motion:** implement every inventory entry: reveals, parallax speeds, pinned sections, fixed elements.
|
|
119
|
-
- **Interactions:** sliders, tabs, hover states, cursor followers, menus, counters, demos. Scope query selectors to their own component: a hidden duplicate (a mobile-only list) must never feed a desktop counter.
|
|
120
|
-
- **Sliders in flex/grid:** give the slider and every flex/grid ancestor `min-width: 0`, or Swiper and similar grow to fit their slides and run away. `states --clone` warns when the page height drifts more than 20%.
|
|
121
|
-
- `<meta name="robots" content="noindex">`, forms submit nowhere, no tracking scripts.
|
|
88
|
+
What the seed gives you, and what it leaves to you:
|
|
89
|
+
- the original's markup, stylesheets and media (template mode: lorem text of the same laid-out width, same-size
|
|
90
|
+
placeholders), open shadow roots as declarative shadow DOM, missing media fetched;
|
|
91
|
+
- **inline values a script set, as functions of the screen size** (`calc()` in `--tc-vw`/`vh`, or `max()`/`min()` for
|
|
92
|
+
cover/contain, per range of the site's and its scripts' breakpoints) when one fits every captured size; a property
|
|
93
|
+
that fits at no size or only at some is removed everywhere and marked `data-tc-script-owned`;
|
|
94
|
+
- **pins** as a sticky element in a spacer whose tail is the pin length (`--tc-pin`, a function of the viewport when
|
|
95
|
+
the site's is);
|
|
96
|
+
- one-shot reveals and scroll class changes replayed by `trueclone-reveals.js` at the visitor's size; **scroll-linked
|
|
97
|
+
curves (parallax, scrubbed fades) are not replayed**: their samples are evidence in `reference/reveals.<size>.json`,
|
|
98
|
+
and you write the rule;
|
|
99
|
+
- the boot contract: `<html class="tc-boot">` until your scripts have run; `--hide-until-boot "<selectors>"` keeps
|
|
100
|
+
regions your script builds invisible meanwhile; `tcBoot.hold()` / `tcBoot.release()` for an intro that waits.
|
|
101
|
+
|
|
102
|
+
Then `clone/script.js`, scene by scene from `notes.md` Behaviours: find the rule in `reference/scripts/`, port it,
|
|
103
|
+
compute everything from the viewport. When your script drives an element the seed's player also drives, hand it
|
|
104
|
+
over: remove its `data-tc-reveal` / `data-tc-class` mark at the top of `script.js`. After each build round,
|
|
105
|
+
`npx trueclone owned <name>` lists the sections your script rebuilds (hide those until boot if the original hides them).
|
|
106
|
+
|
|
107
|
+
`clone/patches/` (applied on every seed, or alone in a second with `npx trueclone seed <name> --patches`):
|
|
108
|
+
`head-*.html` before `</head>`, `*.css` linked after the site's stylesheets, `body-*.html` before `script.js`
|
|
109
|
+
(vendored libraries, a hand-over prelude), `*.js` deferred before `script.js`. Vendor libraries at the site's versions.
|
|
110
|
+
|
|
111
|
+
Copy the reference's CSS mechanism, not its look: `npx trueclone rules <name> "<selector>"` (add `--clone` for yours).
|
|
112
|
+
Canvases are scored: rebuild the drawing from the site's scene code with its parameters (exact mode) or as your own
|
|
113
|
+
scene with the same composition (template mode); never drop one.
|
|
122
114
|
|
|
123
115
|
## 5. Verify
|
|
124
116
|
|
|
125
117
|
```powershell
|
|
126
|
-
npx trueclone score <name>
|
|
127
|
-
npx trueclone score <name>
|
|
128
|
-
npx trueclone
|
|
129
|
-
npx trueclone
|
|
118
|
+
npx trueclone score <name> # every check at every scored size, then REPORT.md
|
|
119
|
+
npx trueclone score <name> --only layout,states --viewport wide # after a fix: the checks it can move, where
|
|
120
|
+
npx trueclone explain <name> <y | "section label"> --viewport wide # one place: what every check saw + a crop
|
|
121
|
+
npx trueclone review <name> --size wide # side by side by eye; then laptop, tablet, mobile
|
|
130
122
|
```
|
|
131
123
|
|
|
132
|
-
The
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
npx trueclone interact <name> --draft # interactions.draft.json: fill in TODOs, save as interactions.json
|
|
140
|
-
npx trueclone interact <name> # interactions: click/hover/drag/key scenarios + expectChange (geometry, text, state attributes, canvas pixels)
|
|
141
|
-
npx trueclone detail <name> --viewport all # styles at matching points: font, size, weight, line height, colour, boxes
|
|
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)
|
|
144
|
-
```
|
|
145
|
-
|
|
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.
|
|
147
|
-
|
|
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.
|
|
155
|
-
|
|
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.
|
|
157
|
-
|
|
158
|
-
How detail measures, so you can trust it:
|
|
159
|
-
- motion on (many originals ignore `prefers-reduced-motion`, and a clone that honours it lays out differently);
|
|
160
|
-
- both sides measured the same way: a hit test at the same point and scroll position, so sticky and pinned content is compared in place and an identical page scores 100%;
|
|
161
|
-
- through `pointer-events: none` overlays, and never on content the browser doesn't paint (closed `<details>`);
|
|
162
|
-
- colours written by scroll-scrubbed JS show up as in-between values: that is a motion difference, so check the motion inventory.
|
|
124
|
+
The checks: layout (full page, structure), scroll states (every half screen, scrolled with the wheel), canvases (what
|
|
125
|
+
each scene draws), motion curves, detail (styles at matching points), hovers (every control and every element with a
|
|
126
|
+
hover listener: the region after the hover must match), interactions (`interactions.json`: the change must happen on
|
|
127
|
+
both sides and end in the same state), load (0.25-4 s after navigation, cold cache, same network). Unstable probes
|
|
128
|
+
(random motion, timing) are measured twice on the reference and excluded; do not chase a number inside the noise.
|
|
129
|
+
Unchanged checks are not re-measured (`--fresh` forces it). `drift` opens every full score: if the live site changed
|
|
130
|
+
since the capture, the live-measured checks are marked stale; re-capture before trusting them.
|
|
163
131
|
|
|
164
|
-
|
|
165
|
-
|
|
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;
|
|
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.
|
|
132
|
+
Fix in this order: anything the review shows, the held-out sizes, page height drift, the worst layout section, the
|
|
133
|
+
worst state step, canvases, interactions, load, then detail and hovers. Fix the cause; never tune a number to a score.
|
|
168
134
|
|
|
169
|
-
|
|
135
|
+
**Review verdicts** in `notes.md` under `## Review`, one per frame of `compare/review/<size>/index.md`:
|
|
136
|
+
`- 03-section-2 wide: same` or `- 07-section-4-hover wide: differs: the panel opens on the wrong card`.
|
|
137
|
+
**Accepted differences**: `- <check> <size> <where>: <why>; evidence: compare/explain/wide-5200.png`, only for a check
|
|
138
|
+
already ≥ 95.
|
|
170
139
|
|
|
171
140
|
## 6. Next.js (after the HTML clone passes)
|
|
172
141
|
|
|
173
142
|
```powershell
|
|
174
|
-
npx trueclone next <name> --verify # generates
|
|
143
|
+
npx trueclone next <name> --verify # generates next/, builds, serves and scores it into REPORT.next.md
|
|
175
144
|
```
|
|
176
145
|
|
|
177
|
-
The HTML clone stays the source of truth: change `clone/`, then regenerate.
|
|
146
|
+
The HTML clone stays the source of truth: change `clone/`, then regenerate. One component per section, the clone's
|
|
147
|
+
scripts preloaded and run at hydration, the site's stylesheets after the shadcn theme in the site's cascade-layer order,
|
|
148
|
+
`components.json` for `npx shadcn add`. Done when its report matches the HTML clone's within a point everywhere.
|
|
178
149
|
|
|
179
150
|
## 7. Report
|
|
180
151
|
|
|
181
|
-
`
|
|
152
|
+
`REPORT.md` (and `REPORT.next.md`) is the deliverable: mode and permission, every check at every size, the review,
|
|
153
|
+
open frames, accepted differences with evidence, Findings, what to fix first, and what was not copied (server
|
|
154
|
+
behaviour, tracking, each data/API call). Hand it over with the clone.
|
|
182
155
|
|
|
183
156
|
## Honest limits
|
|
184
157
|
|
|
185
|
-
- Server-side behaviour is never visible from outside: databases, accounts, payments, search. The clone reproduces
|
|
186
|
-
|
|
187
|
-
-
|
|
158
|
+
- Server-side behaviour is never visible from outside: databases, accounts, payments, search. The clone reproduces
|
|
159
|
+
the client; data calls are listed in the report.
|
|
160
|
+
- A live site is never pixel-identical twice: rotating content, A/B tests and font hinting vary; that is what the
|
|
161
|
+
unstable-probe and noise handling is for, not a licence to stop.
|