@trueclone/cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,68 @@
1
+ // Exact mode: start the clone from the reference mirror instead of an empty folder. The mirror is the rendered page
2
+ // with every script and inline handler removed and its stylesheets and media saved locally, so the seeded clone has
3
+ // the site's own markup, text, styles and media at the right paths. What it lacks is behaviour: write that in
4
+ // clone/script.js (your own code, using the parameters you read from the site), then verify with the usual scores.
5
+ //
6
+ // Template clones are never seeded: their text and media must be placeholders.
7
+ //
8
+ // usage: node scripts/seed.mjs <name> [--force] (--force overwrites an existing clone/)
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { parseArgs } from './lib.mjs';
12
+
13
+ const { pos, flags } = parseArgs(process.argv.slice(2));
14
+ const [name] = pos;
15
+ if (!name) {
16
+ console.error('usage: npm run seed -- <name> [--force]');
17
+ process.exit(1);
18
+ }
19
+ const root = path.resolve('sites', name);
20
+ const ref = path.join(root, 'reference');
21
+ const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
22
+ if (meta.mode !== 'exact' || !meta.permission) {
23
+ console.error('seed is for exact mode only (capture with --mode exact --permission "<owner>"); template clones use placeholder content');
24
+ process.exit(1);
25
+ }
26
+ const mirror = path.join(ref, 'mirror');
27
+ const clone = path.join(root, 'clone');
28
+ const exists = await fs.stat(path.join(clone, 'index.html')).then(() => true, () => false);
29
+ if (exists && !flags.force) {
30
+ console.error(`${path.relative(process.cwd(), clone)} already has an index.html; add --force to overwrite it`);
31
+ process.exit(1);
32
+ }
33
+ await fs.mkdir(path.join(clone, 'css'), { recursive: true });
34
+
35
+ // Assets the mirror points at (../assets/... from the page, ../../assets/... from mirror/css) are copied into
36
+ // clone/assets and the paths rewritten, so clone/ is self-contained.
37
+ const copied = new Set();
38
+ const pull = async (rel) => {
39
+ const clean = decodeURIComponent(rel.split(/[?#]/)[0]);
40
+ if (copied.has(clean)) return;
41
+ copied.add(clean);
42
+ const from = path.join(ref, 'assets', clean);
43
+ const to = path.join(clone, 'assets', clean);
44
+ await fs.mkdir(path.dirname(to), { recursive: true });
45
+ await fs.copyFile(from, to).catch(() => console.warn(`WARNING missing asset ${clean}`));
46
+ };
47
+ const rewrite = async (text, prefix, replacement) => {
48
+ const re = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 'assets/([^"\')\\s]+)', 'g');
49
+ for (const m of text.matchAll(re)) await pull(m[1]);
50
+ return text.replace(re, (all, rel) => `${replacement}assets/${rel}`);
51
+ };
52
+
53
+ for (const file of await fs.readdir(path.join(mirror, 'css')).catch(() => [])) {
54
+ const css = await fs.readFile(path.join(mirror, 'css', file), 'utf8');
55
+ await fs.writeFile(path.join(clone, 'css', file), await rewrite(css, '../../', '../'));
56
+ }
57
+ let html = await fs.readFile(path.join(mirror, 'index.html'), 'utf8');
58
+ html = await rewrite(html, '../', '');
59
+ // the clone's own behaviour script, loaded once the DOM is parsed
60
+ if (!/<script[^>]+src="script\.js"/.test(html)) html = html.replace(/<\/body>/i, '<script src="script.js" defer></script>\n</body>');
61
+ await fs.writeFile(path.join(clone, 'index.html'), html);
62
+
63
+ const scriptFile = path.join(clone, 'script.js');
64
+ if (!(await fs.stat(scriptFile).then(() => true, () => false))) {
65
+ await fs.writeFile(scriptFile, `// ${meta.url} - clone behaviour (your own code). The markup, styles and media come from the mirror.\n// Rebuild every interaction from the reference's parameters; verify with npm run score -- ${name}.\n`);
66
+ }
67
+ console.log(`seeded ${path.relative(process.cwd(), clone)} from the mirror: index.html, ${(await fs.readdir(path.join(clone, 'css'))).length} stylesheet(s), ${copied.size} asset(s)`);
68
+ console.log('next: write clone/script.js (behaviour), then npm run score -- ' + name);
@@ -0,0 +1,78 @@
1
+ // Measure the live page's layout as a build spec: per section, an element tree with page-relative
2
+ // boxes and the computed styles that matter. Text is recorded as length only, never content.
3
+ // usage: node scripts/spec.mjs <name> [--depth 6] [--hide "<selectors>"] [--cdp] [--clone [url]]
4
+ // --clone measures the rebuild instead (default: sites/<name>/clone/index.html) into compare/<vp>.clone.spec.json
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, settle } from './lib.mjs';
9
+
10
+ const { pos, flags } = parseArgs(process.argv.slice(2));
11
+ const [name] = pos;
12
+ if (!name) {
13
+ console.error('usage: npm run spec -- <name> [--depth 6]');
14
+ process.exit(1);
15
+ }
16
+ const ref = path.resolve('sites', name, 'reference');
17
+ const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
18
+ const depth = Number(flags.depth || 6);
19
+ const clone = flags.clone ? (typeof flags.clone === 'string' ? flags.clone : pathToFileURL(path.resolve('sites', name, 'clone', 'index.html')).href) : null;
20
+ const hide = clone ? [] : typeof flags.hide === 'string' ? flags.hide.split(',') : meta.hide || [];
21
+ const outDir = clone ? path.resolve('sites', name, 'compare') : ref;
22
+ await fs.mkdir(outDir, { recursive: true });
23
+
24
+ function specInPage(maxDepth) {
25
+ const KEEP = ['display', 'position', 'top', 'right', 'bottom', 'left', 'z-index', 'overflow', 'flex-direction', 'justify-content',
26
+ 'align-items', 'flex-wrap', 'gap', 'grid-template-columns', 'grid-template-rows', 'padding', 'margin', 'width', 'height',
27
+ 'max-width', 'min-height', 'font-family', 'font-size', 'line-height', 'font-weight', 'letter-spacing', 'text-transform',
28
+ 'text-align', 'color', 'background-color', 'background-image', 'border', 'border-bottom', 'border-left', 'border-radius',
29
+ 'opacity', 'transform', 'object-fit', 'mix-blend-mode', 'filter'];
30
+ const DEFAULTS = new Set(['static', 'auto', 'visible', 'normal', 'none', '0px', 'nowrap', 'row', 'stretch', 'start',
31
+ 'rgba(0, 0, 0, 0)', '1', 'fill', '0px none rgb(0, 0, 0)', 'flex-start', 'border-box']);
32
+ const visible = (el) => {
33
+ const r = el.getBoundingClientRect();
34
+ const s = getComputedStyle(el);
35
+ return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden' && !['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(el.tagName);
36
+ };
37
+ const walk = (el, d) => {
38
+ const r = el.getBoundingClientRect();
39
+ const s = getComputedStyle(el);
40
+ const style = {};
41
+ for (const k of KEEP) {
42
+ const v = s.getPropertyValue(k);
43
+ if (v && !DEFAULTS.has(v)) style[k] = v.length > 160 ? v.slice(0, 160) + '…' : v;
44
+ }
45
+ const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(' ').trim();
46
+ const node = {
47
+ tag: el.tagName.toLowerCase(),
48
+ cls: [...el.classList].slice(0, 3).join(' ') || undefined,
49
+ box: [Math.round(r.left), Math.round(r.top + scrollY), Math.round(r.width), Math.round(r.height)],
50
+ textLen: text.length || undefined,
51
+ style,
52
+ };
53
+ if (el.tagName === 'IMG') node.img = { natural: [el.naturalWidth, el.naturalHeight] };
54
+ if (el.tagName === 'VIDEO') node.video = true;
55
+ const kids = [...el.children].filter(visible);
56
+ if (d < maxDepth && kids.length) node.children = kids.slice(0, 40).map((k) => walk(k, d + 1));
57
+ else if (kids.length) node.more = kids.length;
58
+ return node;
59
+ };
60
+ let root = document.body;
61
+ for (let kids = [...root.children].filter(visible); kids.length === 1; kids = [...root.children].filter(visible)) root = kids[0];
62
+ return [...root.children].filter(visible).map((el) => walk(el, 0));
63
+ }
64
+
65
+ const browser = await openBrowser(flags.cdp);
66
+ try {
67
+ for (const vp of VIEWPORTS) {
68
+ const { context, page } = await newPage(browser, vp);
69
+ await page.goto(clone || meta.url, { waitUntil: 'load', timeout: 60000 });
70
+ await settle(page, hide);
71
+ const tree = await page.evaluate(specInPage, depth);
72
+ await fs.writeFile(path.join(outDir, `${vp.name}${clone ? '.clone' : ''}.spec.json`), JSON.stringify(tree));
73
+ console.log(`${vp.name}: ${tree.length} sections -> ${vp.name}.spec.json`);
74
+ await context.close();
75
+ }
76
+ } finally {
77
+ if (!flags.cdp) await browser.close();
78
+ }
@@ -0,0 +1,172 @@
1
+ // Multi-state capture: scroll the live page like a user (animations running), and at every step record
2
+ // a viewport screenshot (+ a masked one for structure scoring) and every element's opacity/transform/clip/
3
+ // position. From that it derives a motion inventory: scroll reveals, parallax, pinned and fixed elements.
4
+ // With --clone it replays the same scroll positions on the rebuild and scores each step.
5
+ //
6
+ // usage: node scripts/states.mjs <name> [--step 0.5] [--viewports desktop,mobile] [--cdp]
7
+ // node scripts/states.mjs <name> --clone [url] [--structure]
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, wheelTo, statesInPage, maskToggle, matchPct, PNG } from './lib.mjs';
11
+
12
+ const { pos, flags } = parseArgs(process.argv.slice(2));
13
+ const [name] = pos;
14
+ if (!name) {
15
+ console.error('usage: npm run states -- <name> [--step 0.5] [--viewports desktop] | <name> --clone [url] [--structure]');
16
+ process.exit(1);
17
+ }
18
+ const root = path.resolve('sites', name);
19
+ const ref = path.join(root, 'reference');
20
+ const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
21
+ const cloneMode = Boolean(flags.clone);
22
+ const structure = Boolean(flags.structure);
23
+ const stepFrac = Number(flags.step || 0.5);
24
+ const wanted = typeof flags.viewports === 'string' ? flags.viewports.split(',') : null;
25
+ const viewports = VIEWPORTS.filter((v) => !wanted || wanted.includes(v.name));
26
+ const outRoot = path.join(cloneMode ? path.join(root, 'compare') : ref, 'states');
27
+
28
+ const server = cloneMode && typeof flags.clone !== 'string' ? await serve(path.join(root, 'clone')) : null;
29
+ const target = cloneMode ? (typeof flags.clone === 'string' ? flags.clone : server.url) : meta.url;
30
+ const browser = await openBrowser(flags.cdp);
31
+ const report = { name, target, mode: cloneMode ? (structure ? 'clone-structure' : 'clone-visual') : 'reference', at: new Date().toISOString(), viewports: [] };
32
+
33
+ // Viewports run side by side (one page each): most of a step is waiting for smooth scroll to glide.
34
+ try {
35
+ report.viewports = await Promise.all(viewports.map(async (vp) => {
36
+ const dir = path.join(outRoot, vp.name);
37
+ await fs.rm(dir, { recursive: true, force: true });
38
+ await fs.mkdir(dir, { recursive: true });
39
+ const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
40
+ await page.goto(target, { waitUntil: 'load', timeout: 60000 });
41
+ await page.mouse.move(5, vp.height - 5);
42
+ const intro = await waitStill(page, { timeout: 15000, interval: 200 }); // let intros/loaders finish first
43
+ // Before the first scroll: one-shot reveals (Framer Motion whileInView once, IntersectionObserver classes) are
44
+ // still in their starting state here. The warm-up below fires them for good, so they must be read now.
45
+ const initial = cloneMode ? null : await page.evaluate(statesInPage);
46
+ await warmUp(page);
47
+ await wheelTo(page, 0, vp);
48
+
49
+ // Reference: step through the whole page. Clone: replay the reference's exact scroll positions.
50
+ // Guard: a clone whose page height runs away (e.g. a slider sizing loop) would make every step meaningless
51
+ const docH = await page.evaluate(() => document.documentElement.scrollHeight);
52
+ let ys;
53
+ if (cloneMode) {
54
+ const refMeta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
55
+ const refH = refMeta.viewports?.find((v) => v.viewport === vp.name)?.pageHeight;
56
+ if (refH && Math.abs(docH - refH) / refH > 0.2) console.warn(`WARNING ${vp.name}: clone page height ${docH}px vs reference ${refH}px after the warm-up scroll - a layout is running away; fix that before reading state scores.`);
57
+ ys = JSON.parse(await fs.readFile(path.join(ref, 'states', `${vp.name}.json`), 'utf8')).steps.map((s) => s.y);
58
+ } else {
59
+ const max = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
60
+ const step = Math.round(vp.height * stepFrac);
61
+ ys = [];
62
+ for (let y = 0; y < max; y += step) ys.push(y);
63
+ ys.push(max);
64
+ }
65
+
66
+ const steps = [];
67
+ const samples = []; // per step: { path: state }
68
+ for (const [i, y] of ys.entries()) {
69
+ const actual = await wheelTo(page, y, vp);
70
+ const still = await waitStill(page);
71
+ const file = `${String(i).padStart(2, '0')}`;
72
+ await page.screenshot({ path: path.join(dir, `${file}.png`) });
73
+ await maskToggle(page, true);
74
+ await page.screenshot({ path: path.join(dir, `${file}.mask.png`) });
75
+ await maskToggle(page, false);
76
+ if (!cloneMode) samples.push(await page.evaluate(statesInPage));
77
+ steps.push({ i, y: actual, target: y, settled: still.settled, settleMs: still.ms });
78
+ }
79
+ await context.close();
80
+
81
+ if (cloneMode) {
82
+ const sfx = structure ? '.mask' : '';
83
+ const scored = [];
84
+ for (const s of steps) {
85
+ const f = `${String(s.i).padStart(2, '0')}${sfx}.png`;
86
+ const a = PNG.sync.read(await fs.readFile(path.join(ref, 'states', vp.name, f)));
87
+ const b = PNG.sync.read(await fs.readFile(path.join(dir, f)));
88
+ scored.push({ ...s, match: matchPct(a, b).pct });
89
+ }
90
+ const avg = +(scored.reduce((n, s) => n + s.match, 0) / scored.length).toFixed(2);
91
+ return { viewport: vp.name, match: avg, worst: [...scored].sort((a, b) => a.match - b.match).slice(0, 5), steps: scored };
92
+ } else {
93
+ const summary = summarize(steps, samples, vp, initial);
94
+ await fs.writeFile(path.join(outRoot, `${vp.name}.json`), JSON.stringify({ viewport: vp, steps, samples }));
95
+ await fs.writeFile(path.join(outRoot, `${vp.name}.summary.json`), JSON.stringify(summary, null, 2));
96
+ return { viewport: vp.name, introMs: intro.ms, steps: steps.length, unsettled: steps.filter((s) => !s.settled).length, ...summary.counts };
97
+ }
98
+ }));
99
+ } finally {
100
+ if (!flags.cdp) await browser.close();
101
+ server?.close();
102
+ }
103
+
104
+ await fs.writeFile(path.join(outRoot, cloneMode ? `report${structure ? '.mask' : ''}.json` : 'report.json'), JSON.stringify(report, null, 2));
105
+ if (cloneMode) {
106
+ console.log(`state match (${report.mode}) - average over scroll steps`);
107
+ console.table(report.viewports.map((v) => ({ viewport: v.viewport, 'match %': v.match, worst: v.worst.map((s) => `#${s.i} y${s.y} ${s.match}%`).join(' ') })));
108
+ } else {
109
+ console.table(report.viewports);
110
+ }
111
+ console.log(`States: ${path.relative(process.cwd(), outRoot)}`);
112
+
113
+ // ---------- Motion inventory ----------
114
+ // Classifies what changes between scroll steps, per element (keyed by DOM path).
115
+ function summarize(steps, samples, vp, initial = {}) {
116
+ const inView = (st) => st.box[1] < vp.height && st.box[1] + st.box[3] > 0;
117
+ const paths = new Set(samples.flatMap((s) => Object.keys(s)));
118
+ const reveals = [], animated = [], parallax = [], pinned = [], fixed = [], revealsOnce = [];
119
+
120
+ // One-shot reveals: below the fold at load in a hidden or offset starting state (opacity ~0, a translate, a blur),
121
+ // and seen visible in view later. The starting state is the recipe the clone has to animate from.
122
+ const offset = (t) => { const m = /matrix\(([^)]+)\)/.exec(t || ''); if (!m) return 0; const v = m[1].split(',').map(Number); return Math.hypot(v[4], v[5]); };
123
+ for (const [p, s0] of Object.entries(initial || {})) {
124
+ if (s0.box[1] < vp.height) continue; // above the fold: part of the intro, not a scroll reveal
125
+ const hidden = s0.o <= 0.05 || !s0.v || offset(s0.t) >= 8 || /blur/.test(s0.f);
126
+ if (!hidden) continue;
127
+ const later = samples.map((s) => s[p]).find((s) => s && inView(s) && s.o > 0.5 && s.v);
128
+ if (!later || (later.o === s0.o && later.t === s0.t && later.f === s0.f)) continue;
129
+ revealsOnce.push({ path: p, cls: s0.cls, from: { opacity: s0.o, transform: s0.t || 'none', filter: s0.f || 'none' }, to: { opacity: later.o, transform: later.t || 'none', filter: later.f || 'none' }, box: later.box });
130
+ }
131
+ for (const p of paths) {
132
+ const seq = samples.map((s, i) => (s[p] ? { ...s[p], i, y: steps[i].y } : null));
133
+ const present = seq.filter(Boolean);
134
+ if (present.length < 2) continue;
135
+ const first = present[0];
136
+ const label = { path: p, cls: first.cls, media: first.media };
137
+
138
+ if (present.some((s) => s.pos === 'fixed')) fixed.push({ ...label, box: first.box });
139
+
140
+ // Hidden while in view at one step, visible while in view at a later step => scroll reveal.
141
+ const shownAt = present.find((s) => inView(s) && s.o > 0.05 && s.v);
142
+ const hiddenBefore = present.find((s) => inView(s) && (s.o <= 0.05 || !s.v) && (!shownAt || s.i < shownAt.i));
143
+ if (hiddenBefore && shownAt) reveals.push({ ...label, hiddenAtStep: hiddenBefore.i, shownAtStep: shownAt.i, box: shownAt.box });
144
+
145
+ // Properties that change between steps.
146
+ const props = ['o', 't', 'c', 'f'].filter((k) => new Set(present.map((s) => String(s[k]))).size > 1);
147
+ if (props.length) animated.push({ ...label, props: props.map((k) => ({ o: 'opacity', t: 'transform', c: 'clip-path', f: 'filter' })[k]), steps: present.length });
148
+
149
+ // Scroll speed relative to the page: 1 = normal, 0 = pinned, other = parallax.
150
+ const pairs = [];
151
+ for (let k = 1; k < present.length; k++) {
152
+ const a = present[k - 1], b = present[k];
153
+ const dScroll = b.y - a.y;
154
+ if (dScroll <= 0 || !inView(a) || !inView(b)) continue;
155
+ pairs.push(-(b.box[1] - a.box[1]) / dScroll);
156
+ }
157
+ if (pairs.length >= 2 && !present.some((s) => s.pos === 'fixed')) {
158
+ const speed = pairs.reduce((n, v) => n + v, 0) / pairs.length;
159
+ if (Math.abs(speed) < 0.05) pinned.push({ ...label, steps: pairs.length });
160
+ else if (Math.abs(speed - 1) > 0.08) parallax.push({ ...label, speed: +speed.toFixed(2) });
161
+ }
162
+ }
163
+ // Keep the list readable: outermost elements first (shorter paths), deduplicate children of listed parents.
164
+ const prune = (list) => {
165
+ list.sort((a, b) => a.path.length - b.path.length);
166
+ const kept = [];
167
+ for (const item of list) if (!kept.some((k) => item.path.startsWith(k.path + '>'))) kept.push(item);
168
+ return kept;
169
+ };
170
+ const result = { reveals: prune(reveals), revealsOnce: prune(revealsOnce), animated: prune(animated), parallax: prune(parallax), pinned: prune(pinned), fixed: prune(fixed) };
171
+ return { counts: Object.fromEntries(Object.entries(result).map(([k, v]) => [k, v.length])), ...result };
172
+ }