@trueclone/cli 0.2.0 → 0.4.0-rc.1

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.
@@ -1,14 +1,20 @@
1
- // Record one-shot scroll reveals on the reference, frame by frame, so the clone can replay them exactly.
2
- // A fresh page is scrolled down like a visitor would; every element that sat below the fold in a hidden or offset
3
- // state (opacity ~0, a translate, a scale, a blur) is watched with requestAnimationFrame, and when it starts to
4
- // change its opacity, translate, scale and blur are sampled every frame until they settle. The result is the
5
- // reveal's real curve (delay, duration, easing, stagger included), whatever library drew it: Framer Motion, GSAP,
6
- // AOS, IntersectionObserver plus CSS transitions. `trueclone seed` marks these elements in the clone and adds a
7
- // small player that replays the curves with the Web Animations API when they scroll into view.
1
+ // Record what scrolling does to the reference, so the clone can replay it exactly:
2
+ // - one-shot scroll reveals, frame by frame: every element that sat below the fold in a hidden or offset state
3
+ // (opacity ~0, a translate, a scale, a blur) is watched with requestAnimationFrame, and when it starts to change
4
+ // its opacity, translate, scale and blur are sampled every frame until they settle. The result is the reveal's
5
+ // real curve (delay, duration, easing, stagger included), whatever library drew it: Framer Motion, GSAP, AOS,
6
+ // IntersectionObserver plus CSS transitions;
7
+ // - scroll-linked curves (parallax, a scrubbed fade or scale): every element is sampled at each settled scroll
8
+ // position; what follows the scroll position, and comes back to its start when the page returns to the top, is a
9
+ // curve keyed by scroll position. A translate that follows the scroll one to one is a pin and is listed, not
10
+ // replayed (the clone's script recreates pins);
11
+ // - class changes caused by scrolling (a header that gets its backdrop past 12px, an "is-visible" toggle).
12
+ // `trueclone seed` marks these elements in the clone and adds a small player that replays them.
8
13
  //
9
14
  // Elements are identified by their path of element children, skipping script/style/meta-like tags, which is
10
15
  // the same in the live page and in the script-free mirror the clone is seeded from.
11
16
  //
17
+ // Runs at every width by default (offsets differ per width): one file each, recorded in parallel.
12
18
  // usage: node scripts/reveals.mjs <name> [--viewport desktop] -> sites/<name>/reference/reveals.<vp>.json
13
19
  import fs from 'node:fs/promises';
14
20
  import path from 'node:path';
@@ -17,14 +23,19 @@ import { VIEWPORTS, parseArgs, openBrowser, newPage, waitStill, wheelTo } from '
17
23
  const { pos, flags } = parseArgs(process.argv.slice(2));
18
24
  const [name] = pos;
19
25
  if (!name) {
20
- console.error('usage: trueclone reveals <name> [--viewport desktop]');
26
+ console.error('usage: trueclone reveals <name> [--viewport desktop|tablet|mobile]');
21
27
  process.exit(1);
22
28
  }
23
29
  const root = path.resolve('sites', name);
24
30
  const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
25
- const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
31
+ const vps = flags.viewport ? [VIEWPORTS.find((v) => v.name === flags.viewport)] : VIEWPORTS;
32
+ if (vps.some((v) => !v)) {
33
+ console.error(`unknown viewport ${flags.viewport}: desktop, tablet or mobile`);
34
+ process.exit(1);
35
+ }
26
36
 
27
- // Runs in the page before any scroll: picks the candidates and starts the frame sampler.
37
+ // Runs in the page before any scroll: picks the reveal candidates, starts the frame sampler, and prepares the
38
+ // per-position sampler for every element.
28
39
  function watchInPage() {
29
40
  const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
30
41
  const pathOf = (el) => {
@@ -35,6 +46,7 @@ function watchInPage() {
35
46
  }
36
47
  return parts.join('>');
37
48
  };
49
+ const clsOf = (el) => String(el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.');
38
50
  const read = (el) => {
39
51
  const s = getComputedStyle(el);
40
52
  const m = new DOMMatrixReadOnly(s.transform === 'none' ? undefined : s.transform);
@@ -43,8 +55,10 @@ function watchInPage() {
43
55
  };
44
56
  const hiddenish = (st) => st.o <= 0.05 || Math.hypot(st.tx, st.ty) >= 8 || Math.abs(st.s - 1) >= 0.04 || st.blur > 0;
45
57
  const candidates = [];
58
+ const tracked = [];
46
59
  for (const el of document.body.querySelectorAll('*')) {
47
60
  if (SKIP.has(el.tagName) || el.closest('svg') && el.tagName !== 'svg') continue;
61
+ tracked.push({ el, samples: [], last: null, back: null });
48
62
  const r = el.getBoundingClientRect();
49
63
  if (r.top < innerHeight || r.width < 8 || r.height < 8) continue;
50
64
  const st = read(el);
@@ -54,9 +68,67 @@ function watchInPage() {
54
68
  if (endless(el) || (el.parentElement && endless(el.parentElement))) continue;
55
69
  // keep the outermost element of a revealing group (a hidden card hides its children too)
56
70
  if (candidates.some((c) => c.el.contains(el))) continue;
57
- candidates.push({ el, path: pathOf(el), from: st, frames: null, start: null, topAtStart: null, last: JSON.stringify(st), still: 0, done: false });
71
+ candidates.push({ el, path: pathOf(el), from: st, frames: null, start: null, topAtStart: null, last: JSON.stringify(st), still: 0, done: false, doneAtY: null, movedStill: 0, lastY: Math.round(scrollY) });
58
72
  }
59
73
  window.__tcReveals = candidates;
74
+ // one sample per element per settled scroll position, kept only when the state changed
75
+ window.__tcSample = () => {
76
+ const y = Math.round(scrollY);
77
+ for (const t of tracked) {
78
+ if (!t.el.isConnected) continue;
79
+ const r = t.el.getBoundingClientRect();
80
+ if (r.width < 8 || r.height < 8) continue;
81
+ const st = read(t.el);
82
+ const key = JSON.stringify(st);
83
+ if (key === t.last) continue;
84
+ t.last = key;
85
+ // v: where the element's layout box sits in the viewport (its translate taken out), the way ScrollTrigger sees it
86
+ t.samples.push({ y, v: Math.round(r.top - st.ty), ...st });
87
+ }
88
+ };
89
+ window.__tcSampleBack = () => { for (const t of tracked) if (t.el.isConnected) { const st = read(t.el); t.back = { y: Math.round(scrollY), v: Math.round(t.el.getBoundingClientRect().top - st.ty), ...st }; } };
90
+ // called twice while the page stands still: what changed in between moves on its own (a loop, a timer), not with the scroll
91
+ window.__tcSampleIdle = () => { for (const t of tracked) { if (!t.el.isConnected) continue; const key = JSON.stringify(read(t.el)); if (t.idleKey !== undefined && key !== t.idleKey) t.idleMoved = true; t.idleKey = key; } };
92
+ // after the scroll-through: which sampled elements follow the scroll position
93
+ window.__tcClassify = (classPathList) => {
94
+ const classPaths = new Set(classPathList);
95
+ const revealOf = new Map(candidates.filter((c) => c.done && c.movedStill >= 2).map((c) => [c.el, c]));
96
+ const scrubs = [], pins = [];
97
+ for (const t of tracked) {
98
+ if (!t.el.isConnected || t.samples.length < 2 || t.idleMoved) continue;
99
+ const p = pathOf(t.el);
100
+ if (classPaths.has(p)) continue; // a class change the class player replays
101
+ const rev = revealOf.get(t.el);
102
+ let samples = t.samples;
103
+ if (rev) {
104
+ // after its one-shot reveal: the state back at the top (revealed, scroll position 0), then from where the
105
+ // reveal ended; between the two the curve is interpolated (a parallax is linear in the scroll position)
106
+ const last = rev.frames[rev.frames.length - 1];
107
+ const after = samples.filter((s) => s.y > rev.doneAtY);
108
+ samples = [...(t.back && t.back.y < rev.doneAtY ? [t.back] : []), { y: rev.doneAtY, v: after[0] ? after[0].v + (after[0].y - rev.doneAtY) : undefined, o: last.o, tx: last.tx, ty: last.ty, s: last.s, blur: last.blur }, ...after];
109
+ }
110
+ if (samples.length < 3) continue; // two states is a toggle, not a curve
111
+ // a pin: translateY follows the scroll one to one between two positions, nothing else changes
112
+ const slopes = [];
113
+ for (let i = 1; i < samples.length; i++) slopes.push((samples[i].ty - samples[i - 1].ty) / ((samples[i].y - samples[i - 1].y) || 1));
114
+ const isPin = (k) => Math.abs(k - 1) < 0.05, isFlat = (k) => Math.abs(k) < 0.02;
115
+ if (slopes.filter(isPin).length >= 2 && slopes.every((k) => isPin(k) || isFlat(k)) && samples.every((s) => Math.abs(s.tx - samples[0].tx) < 2 && Math.abs(s.o - samples[0].o) < 0.03 && Math.abs(s.s - samples[0].s) < 0.01)) {
116
+ const first = slopes.findIndex(isPin);
117
+ let lastI = slopes.length - 1;
118
+ while (lastI > first && !isPin(slopes[lastI])) lastI--;
119
+ pins.push({ path: p, cls: clsOf(t.el), start: samples[first].y, end: samples[lastI + 1].y });
120
+ continue;
121
+ }
122
+ const step = Math.max(1, Math.ceil(samples.length / 40));
123
+ // keyed by the element's viewport position when it moves with the page (a card's parallax: the same curve in
124
+ // any viewport height, as a ScrollTrigger on the element would give), by scroll position when it doesn't (a
125
+ // line inside a pinned stage: its box stays put while the page scrolls)
126
+ const a = samples[0], b = samples[samples.length - 1];
127
+ const key = b.y !== a.y && a.v !== undefined && Math.abs((b.v - a.v) / (b.y - a.y) + 1) < 0.15 ? 'v' : 'y';
128
+ scrubs.push({ path: p, cls: clsOf(t.el), key, samples: samples.filter((s, i) => i % step === 0 || i === samples.length - 1) });
129
+ }
130
+ return { scrubs, pins };
131
+ };
60
132
  const tick = (now) => {
61
133
  for (const c of candidates) {
62
134
  if (c.done) continue;
@@ -73,11 +145,16 @@ function watchInPage() {
73
145
  continue;
74
146
  }
75
147
  c.frames.push({ t: Math.round(now - c.start), ...st });
148
+ // motion while the page stands still is a one-shot animation; motion only while scrolling is scroll-linked
149
+ const y = Math.round(scrollY);
150
+ if (key !== c.last && y === c.lastY) c.movedStill++;
151
+ c.lastY = y;
76
152
  c.still = key === c.last ? c.still + 1 : 0;
77
153
  c.last = key;
78
154
  if (c.still >= 8 || now - c.start > 6000) {
79
155
  c.frames = c.frames.slice(0, c.frames.length - c.still + 1); // drop the trailing identical frames
80
156
  c.done = true;
157
+ c.doneAtY = Math.round(scrollY);
81
158
  }
82
159
  }
83
160
  if (candidates.some((c) => !c.done)) requestAnimationFrame(tick);
@@ -111,81 +188,102 @@ function watchClassesInPage() {
111
188
  const now = el.getAttribute('class');
112
189
  if (now === r.oldValue) continue;
113
190
  seen.add(el);
114
- const rect = el.getBoundingClientRect();
191
+ // an element without a box (display: contents, an empty wrapper) is placed by its first descendant that has one
192
+ let boxEl = el;
193
+ if (!boxEl.getBoundingClientRect().height) boxEl = [...el.querySelectorAll('*')].find((d) => d.getBoundingClientRect().height) || el;
194
+ const rect = boxEl.getBoundingClientRect();
115
195
  window.__tcClasses.push({ el, path: pathOf(el), from: r.oldValue, to: now, scrollY: Math.round(scrollY), ratio: +(rect.top / innerHeight).toFixed(3), sticky: pinned(el) });
116
196
  }
117
197
  }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'], attributeOldValue: true });
118
198
  }
119
199
 
120
- const browser = await openBrowser(flags.cdp);
121
- let result;
122
- try {
123
- const { page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
124
- await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
125
- await page.mouse.move(5, vp.height - 5);
126
- await waitStill(page, { timeout: 15000, interval: 200 });
127
- const count = await page.evaluate(watchInPage);
128
- // Class changes caused by scrolling (a header that gets its backdrop, an "is-visible" toggle): watched with a
129
- // MutationObserver. Anything that changes while the page sits idle (timers, carousels) is ignored.
130
- await page.evaluate(watchClassesInPage);
131
- await page.waitForTimeout(1500);
132
- await page.evaluate(() => { window.__tcClassIdle = false; });
133
- // scroll down a third of a window at a time and give each reveal time to play
134
- const max = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
135
- for (let y = 0; y <= max; y += Math.round(vp.height / 3)) {
136
- await wheelTo(page, y, vp);
137
- await page.waitForTimeout(450);
138
- }
139
- await page.waitForTimeout(2500);
140
- // back to the top: does each class change revert (scroll state) or stay (one-shot)?
141
- await page.evaluate(() => window.scrollTo(0, 0));
142
- await page.waitForTimeout(1200);
143
- // class order changes when a site removes and re-adds classes: compare the sets
144
- // It reverts when the classes the scroll removed are back; classes added once (a one-time animation) may stay, so the
145
- // state after returning ('back') is recorded too and used by the clone after the first scroll.
146
- await page.evaluate(() => {
147
- const toks = (v) => String(v || '').split(/\s+/).filter(Boolean);
148
- for (const c of window.__tcClasses) {
149
- c.back = c.el.getAttribute('class');
150
- const removed = toks(c.from).filter((t) => !toks(c.to).includes(t));
151
- c.reverts = removed.length ? removed.every((t) => toks(c.back).includes(t)) : !toks(c.back).some((t) => toks(c.to).includes(t) && !toks(c.from).includes(t));
152
- c.marks = toks(c.to).filter((t) => !toks(c.back).includes(t)); // classes that only exist while scrolled
200
+ async function record(vp) {
201
+ const browser = await openBrowser(flags.cdp);
202
+ let result, scrubs, pins;
203
+ try {
204
+ const { page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
205
+ await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
206
+ await page.mouse.move(5, vp.height - 5);
207
+ await waitStill(page, { timeout: 15000, interval: 200 });
208
+ // Only what scrolling does: with pointer events off, whatever scrolls under the resting pointer is never hovered,
209
+ // so a card that opens on hover is not recorded as opened by the scroll. (Wheel input still reaches the page.)
210
+ await page.addStyleTag({ content: '*, *::before, *::after { pointer-events: none !important; }' });
211
+ await page.mouse.move(vp.width - 2, Math.round(vp.height / 2));
212
+ const count = await page.evaluate(watchInPage);
213
+ // Class changes caused by scrolling (a header that gets its backdrop, an "is-visible" toggle): watched with a
214
+ // MutationObserver. Anything that changes while the page sits idle (timers, carousels) is ignored.
215
+ await page.evaluate(watchClassesInPage);
216
+ await page.waitForTimeout(1500);
217
+ await page.evaluate(() => { window.__tcClassIdle = false; });
218
+ await page.evaluate(() => window.__tcSample()); // every element's state at the top
219
+ // scroll down a third of a window at a time, give each reveal time to play, sample every element where it settled
220
+ const max = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
221
+ for (let y = 0; y <= max; y += Math.round(vp.height / 3)) {
222
+ await wheelTo(page, y, vp, { settle: true });
223
+ await page.waitForTimeout(450);
224
+ await page.evaluate(() => window.__tcSample());
153
225
  }
154
- });
155
- // sticky/fixed elements switch at a scroll position: find it precisely by bisection
156
- const stickyCount = await page.evaluate(() => window.__tcClasses.filter((c) => c.sticky && c.reverts).length);
157
- for (let i = 0; i < stickyCount; i++) {
158
- let lo = 0, hi = await page.evaluate((k) => window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].scrollY, i);
159
- while (hi - lo > 2) {
160
- const mid = Math.round((lo + hi) / 2);
161
- await page.evaluate((y) => window.scrollTo(0, y), mid);
162
- await page.waitForTimeout(160);
163
- const on = await page.evaluate((k) => { const c = window.__tcClasses.filter((x) => x.sticky && x.reverts)[k]; const now = String(c.el.getAttribute('class') || '').split(/\s+/); return c.marks.length > 0 && c.marks.every((t) => now.includes(t)); }, i);
164
- if (on) hi = mid; else lo = mid;
226
+ await page.waitForTimeout(1300);
227
+ await page.evaluate(() => { window.__tcSample(); window.__tcSampleIdle(); });
228
+ await page.waitForTimeout(1200);
229
+ await page.evaluate(() => window.__tcSampleIdle());
230
+ // back to the top: does each class change revert (scroll state) or stay (one-shot)? does each moved element
231
+ // return to its starting state (scroll-linked) or not (one-shot, time-driven)?
232
+ await page.evaluate(() => window.scrollTo(0, 0));
233
+ await page.waitForTimeout(1200);
234
+ await page.evaluate(() => window.__tcSampleBack());
235
+ // class order changes when a site removes and re-adds classes: compare the sets
236
+ // It reverts when the classes the scroll removed are back; classes added once (a one-time animation) may stay, so the
237
+ // state after returning ('back') is recorded too and used by the clone after the first scroll.
238
+ await page.evaluate(() => {
239
+ const toks = (v) => String(v || '').split(/\s+/).filter(Boolean);
240
+ for (const c of window.__tcClasses) {
241
+ c.back = c.el.getAttribute('class');
242
+ const removed = toks(c.from).filter((t) => !toks(c.to).includes(t));
243
+ c.reverts = removed.length ? removed.every((t) => toks(c.back).includes(t)) : !toks(c.back).some((t) => toks(c.to).includes(t) && !toks(c.from).includes(t));
244
+ c.marks = toks(c.to).filter((t) => !toks(c.back).includes(t)); // classes that only exist while scrolled
245
+ }
246
+ });
247
+ // sticky/fixed elements switch at a scroll position: find it precisely by bisection
248
+ const stickyCount = await page.evaluate(() => window.__tcClasses.filter((c) => c.sticky && c.reverts).length);
249
+ for (let i = 0; i < stickyCount; i++) {
250
+ let lo = 0, hi = await page.evaluate((k) => window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].scrollY, i);
251
+ while (hi - lo > 2) {
252
+ const mid = Math.round((lo + hi) / 2);
253
+ await page.evaluate((y) => window.scrollTo(0, y), mid);
254
+ await page.waitForTimeout(160);
255
+ const on = await page.evaluate((k) => { const c = window.__tcClasses.filter((x) => x.sticky && x.reverts)[k]; const now = String(c.el.getAttribute('class') || '').split(/\s+/); return c.marks.length > 0 && c.marks.every((t) => now.includes(t)); }, i);
256
+ if (on) hi = mid; else lo = mid;
257
+ }
258
+ await page.evaluate(([k, y]) => { window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].threshold = y; }, [i, hi]);
165
259
  }
166
- await page.evaluate(([k, y]) => { window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].threshold = y; }, [i, hi]);
260
+ const classes = await page.evaluate(() => window.__tcClasses.map((c) => ({ path: c.path, from: c.from, to: c.to, back: c.back, sticky: c.sticky, reverts: c.reverts, ...(c.sticky ? { threshold: c.threshold ?? c.scrollY } : { trigger: c.ratio }) })));
261
+ // a reveal settles; anything still moving at the 6s cap is continuous motion driven some other way
262
+ // a reveal settles, and moves while the page stands still; anything still moving at the 6s cap is continuous
263
+ // motion driven some other way, and a change seen only during scroll steps is scroll-linked (a curve, below)
264
+ result = await page.evaluate(() => window.__tcReveals.filter((c) => c.frames && c.frames.length > 2 && c.movedStill >= 2 && c.frames[c.frames.length - 1].t < 5900).map((c) => {
265
+ const last = c.frames[c.frames.length - 1];
266
+ // keep at most ~60 keyframes: enough for any easing, small enough for the clone
267
+ const step = Math.max(1, Math.ceil(c.frames.length / 60));
268
+ const frames = c.frames.filter((f, i) => i % step === 0 || i === c.frames.length - 1);
269
+ return { path: c.path, cls: String(c.el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.'), from: c.from, to: { o: last.o, tx: last.tx, ty: last.ty, s: last.s, blur: last.blur }, trigger: c.topAtStart, duration: last.t, frames };
270
+ }));
271
+ ({ scrubs, pins } = await page.evaluate((cp) => window.__tcClassify(cp), classes.map((c) => c.path)));
272
+ console.log(`${vp.name}: ${count} candidates below the fold, ${result.length} revealed while scrolling; ${scrubs.length} scroll-linked curve(s), ${pins.length} pin(s)${pins.length ? ` (${pins.map((p) => `${p.cls || p.path.split('>').pop()} ${p.start}-${p.end}px`).join(', ')})` : ''}`);
273
+ result.classes = classes;
274
+ console.log(`${vp.name}: ${classes.length} class change(s) caused by scrolling: ${classes.filter((c) => c.sticky).length} at a scroll position (sticky/fixed), ${classes.filter((c) => !c.sticky).length} when in view; ${classes.filter((c) => c.reverts).length} revert on the way back up`);
275
+ } finally {
276
+ if (!flags.cdp) await browser.close();
167
277
  }
168
- const classes = await page.evaluate(() => window.__tcClasses.map((c) => ({ path: c.path, from: c.from, to: c.to, back: c.back, sticky: c.sticky, reverts: c.reverts, ...(c.sticky ? { threshold: c.threshold ?? c.scrollY } : { trigger: c.ratio }) })));
169
- // a reveal settles; anything still moving at the 6s cap is continuous motion driven some other way
170
- result = await page.evaluate(() => window.__tcReveals.filter((c) => c.frames && c.frames.length > 2 && c.frames[c.frames.length - 1].t < 5900).map((c) => {
171
- const last = c.frames[c.frames.length - 1];
172
- // keep at most ~60 keyframes: enough for any easing, small enough for the clone
173
- const step = Math.max(1, Math.ceil(c.frames.length / 60));
174
- const frames = c.frames.filter((f, i) => i % step === 0 || i === c.frames.length - 1);
175
- return { path: c.path, cls: String(c.el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.'), from: c.from, to: { o: last.o, tx: last.tx, ty: last.ty, s: last.s, blur: last.blur }, trigger: c.topAtStart, duration: last.t, frames };
176
- }));
177
- console.log(`${count} candidates below the fold, ${result.length} revealed while scrolling`);
178
- result.classes = classes;
179
- console.log(`${classes.length} class change(s) caused by scrolling: ${classes.filter((c) => c.sticky).length} at a scroll position (sticky/fixed), ${classes.filter((c) => !c.sticky).length} when in view; ${classes.filter((c) => c.reverts).length} revert on the way back up`);
180
- } finally {
181
- if (!flags.cdp) await browser.close();
182
- }
183
- const out = path.join(root, 'reference', `reveals.${vp.name}.json`);
184
- await fs.writeFile(out, JSON.stringify({ url: meta.url, viewport: vp, at: new Date().toISOString(), reveals: result, classes: result.classes }, null, 2));
185
- const groups = new Map();
186
- for (const r of result) {
187
- const k = `${JSON.stringify(r.from)} -> ${Math.round(r.duration / 50) * 50}ms`;
188
- groups.set(k, (groups.get(k) || 0) + 1);
278
+ const out = path.join(root, 'reference', `reveals.${vp.name}.json`);
279
+ await fs.writeFile(out, JSON.stringify({ url: meta.url, viewport: vp, at: new Date().toISOString(), reveals: result, scrubs, pins, classes: result.classes }, null, 2));
280
+ const groups = new Map();
281
+ for (const r of result) {
282
+ const k = `${JSON.stringify(r.from)} -> ${Math.round(r.duration / 50) * 50}ms`;
283
+ groups.set(k, (groups.get(k) || 0) + 1);
284
+ }
285
+ for (const [k, n] of [...groups].sort((a, b) => b[1] - a[1]).slice(0, 8)) console.log(` ${vp.name} ${String(n).padStart(3)}x from ${k}`);
286
+ console.log(`-> ${path.relative(process.cwd(), out)}`);
189
287
  }
190
- for (const [k, n] of [...groups].sort((a, b) => b[1] - a[1]).slice(0, 8)) console.log(` ${String(n).padStart(3)}x from ${k}`);
191
- console.log(`-> ${path.relative(process.cwd(), out)}`);
288
+
289
+ await Promise.all(vps.map(record));
@@ -0,0 +1,149 @@
1
+ // Review: the reference and the clone side by side, the way a person looks at a website, at a size the clone was never
2
+ // built from (default: the held-out 1920x1080). Frames: the load at 0.3, 1.5 and 4.5 s, then every section (and every
3
+ // screen of a tall one) after scrolling to it with the wheel, each also with the pointer resting on the control nearest
4
+ // the middle of the screen. Each frame is one image, reference left, clone right, in compare/review/<size>/, listed in
5
+ // compare/review/<size>/index.md with its pixel match.
6
+ //
7
+ // The agent looks at every frame and writes a verdict per frame in notes.md under "## Review":
8
+ // - 03-section-2 wide: same
9
+ // - 07-section-4-hover wide: differs: the panel opens on the wrong card
10
+ // REPORT.md counts the frames signed off; "differs" and frames without a verdict are listed as open.
11
+ //
12
+ // usage: node scripts/review.mjs <name> [--size wide|laptop|desktop|tablet|mobile] [--url <clone url>]
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { SCORED, parseArgs, openBrowser, newPage, serve, matchPct, PNG } 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 review <name> [--size wide] [--url <clone url>]');
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 vp = SCORED.find((v) => v.name === (flags.size || 'wide'));
27
+ if (!vp) { console.error(`unknown size ${flags.size}: ${SCORED.map((v) => v.name).join(', ')}`); process.exit(1); }
28
+ const server = typeof flags.url === 'string' ? null : await serve(path.join(root, 'clone'));
29
+ const cloneUrl = server ? `${server.url}index.html` : flags.url;
30
+ const outDir = path.join(root, 'compare', 'review', vp.name);
31
+ await fs.rm(outDir, { recursive: true, force: true });
32
+ await fs.mkdir(outDir, { recursive: true });
33
+
34
+ // the frame plan comes from the reference at this size: section tops, and a frame per screen in tall sections
35
+ const sections = JSON.parse(await fs.readFile(path.join(ref, `${vp.name}.sections.json`), 'utf8').catch(() => 'null'));
36
+ if (!sections) { console.error(`no capture at ${vp.name} (${vp.width}x${vp.height}): re-capture with trueclone 0.4+`); process.exit(1); }
37
+ const plan = [];
38
+ sections.forEach((s, i) => {
39
+ const top = Math.max(0, s.y);
40
+ const screens = Math.max(1, Math.min(4, Math.round((s.height || s.h || vp.height) / vp.height)));
41
+ for (let k = 0; k < screens; k++) plan.push({ label: `section-${i + 1}${screens > 1 ? `-${k + 1}` : ''}`, y: top + k * vp.height });
42
+ });
43
+ const frames = plan.slice(0, 30);
44
+
45
+ // the control nearest the middle of the screen: links, buttons, pointer-cursor elements, cards with a hover listener
46
+ function hoverPoint() {
47
+ let best = null;
48
+ for (const el of document.querySelectorAll('a, button, [role="button"], [role="tab"], summary, *')) {
49
+ const s = getComputedStyle(el);
50
+ if (s.cursor !== 'pointer' && !['A', 'BUTTON', 'SUMMARY'].includes(el.tagName)) continue;
51
+ const r = el.getBoundingClientRect();
52
+ if (r.width * r.height < 1500 || r.bottom < 60 || r.top > innerHeight - 20 || r.width > innerWidth * 0.9) continue;
53
+ const cx = r.left + r.width / 2, cy = Math.min(Math.max(r.top + r.height / 2, 70), innerHeight - 20);
54
+ const d = Math.hypot(cx - innerWidth / 2, cy - innerHeight / 2);
55
+ if (!best || d < best.d) best = { x: Math.round(cx), y: Math.round(cy), d };
56
+ }
57
+ return best;
58
+ }
59
+
60
+ async function shoot(url, side) {
61
+ const { context, page } = await newPage(await browserP, vp, { reducedMotion: 'no-preference' });
62
+ const shots = {};
63
+ try {
64
+ const t0 = Date.now();
65
+ const nav = page.goto(url, { waitUntil: 'load', timeout: 60000 }).catch(() => null);
66
+ for (const t of [300, 1500, 4500]) {
67
+ const wait = t0 + t - Date.now();
68
+ if (wait > 0) await page.waitForTimeout(wait);
69
+ shots[`load-${t}`] = await page.screenshot().catch(() => null);
70
+ }
71
+ await nav;
72
+ await page.waitForTimeout(1500);
73
+ const scrollTo = async (y) => {
74
+ // with the wheel, the way a visitor scrolls: scroll-linked scenes and smooth scrolling follow wheel input
75
+ await page.mouse.move(Math.round(vp.width * 0.98), Math.round(vp.height * 0.5));
76
+ const now = await page.evaluate(() => scrollY);
77
+ const n = Math.max(1, Math.ceil(Math.abs(y - now) / 400));
78
+ for (let i = 0; i < n; i++) { await page.mouse.wheel(0, (y - now) / n); await page.waitForTimeout(60); }
79
+ await page.waitForTimeout(1600);
80
+ };
81
+ for (const f of frames) {
82
+ await scrollTo(f.y);
83
+ shots[f.label] = await page.screenshot();
84
+ const at = side === 'ref' ? await page.evaluate(hoverPoint) : hoverPoints[f.label];
85
+ if (side === 'ref') hoverPoints[f.label] = at;
86
+ if (at) {
87
+ await page.mouse.move(at.x - 30, at.y, { steps: 4 });
88
+ await page.mouse.move(at.x, at.y, { steps: 6 });
89
+ await page.waitForTimeout(1100);
90
+ shots[`${f.label}-hover`] = await page.screenshot();
91
+ await page.mouse.move(Math.round(vp.width * 0.98), Math.round(vp.height * 0.5));
92
+ }
93
+ }
94
+ } finally {
95
+ await context.close();
96
+ }
97
+ return shots;
98
+ }
99
+
100
+ const hoverPoints = {};
101
+ const browserP = openBrowser(flags.cdp);
102
+ const browser = await browserP;
103
+ let refShots, cloneShots;
104
+ try {
105
+ refShots = await shoot(meta.url, 'ref');
106
+ cloneShots = await shoot(cloneUrl, 'clone');
107
+ } finally {
108
+ if (!flags.cdp) await browser.close();
109
+ server?.close();
110
+ }
111
+
112
+ // one image per frame: reference | clone, half size, with the frame's pixel match
113
+ const lines = [];
114
+ let n = 0;
115
+ for (const label of Object.keys(refShots)) {
116
+ const a = refShots[label] && PNG.sync.read(refShots[label]);
117
+ const b = cloneShots[label] && PNG.sync.read(cloneShots[label]);
118
+ if (!a || !b) continue;
119
+ const scale = 0.5, w = Math.round(vp.width * scale), h = Math.round(vp.height * scale);
120
+ const img = new PNG({ width: w * 2 + 12, height: h });
121
+ img.data.fill(255);
122
+ [a, b].forEach((src, k) => {
123
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
124
+ const sx = Math.min(src.width - 1, Math.round(x / scale)), sy = Math.min(src.height - 1, Math.round(y / scale));
125
+ const si = (sy * src.width + sx) * 4, di = (y * img.width + x + k * (w + 12)) * 4;
126
+ img.data[di] = src.data[si]; img.data[di + 1] = src.data[si + 1]; img.data[di + 2] = src.data[si + 2]; img.data[di + 3] = 255;
127
+ }
128
+ });
129
+ const id = `${String(n++).padStart(2, '0')}-${label}`;
130
+ await fs.writeFile(path.join(outDir, `${id}.png`), PNG.sync.write(img));
131
+ const pct = matchPct(a, b).pct;
132
+ lines.push({ id, pct });
133
+ }
134
+ await fs.writeFile(path.join(outDir, 'index.md'), `# Review at ${vp.name} (${vp.width}x${vp.height}): reference left, clone right
135
+
136
+ Look at every frame. In notes.md under "## Review", one line per frame: \`- <frame> ${vp.name}: same\` or
137
+ \`- <frame> ${vp.name}: differs: <what you see>\`. Pixel match is a hint, not the verdict: a 99% frame can hide a wrong
138
+ panel, and a 90% frame can be a random animation phase.
139
+
140
+ | frame | pixel match |
141
+ |---|---|
142
+ ${lines.map((l) => `| [${l.id}](${l.id}.png) | ${l.pct}% |`).join('\n')}
143
+ `);
144
+ // make sure notes.md has the section the verdicts go in
145
+ const notesFile = path.join(root, 'notes.md');
146
+ const notes = await fs.readFile(notesFile, 'utf8').catch(() => '');
147
+ if (!/^## Review/m.test(notes)) await fs.appendFile(notesFile, `\n## Review\n\n<!-- one line per frame of compare/review/<size>/index.md: "- <frame> <size>: same" or "- <frame> <size>: differs: <what>". REPORT.md counts them. -->\n`);
148
+ await fs.writeFile(path.join(outDir, 'frames.json'), JSON.stringify({ size: vp.name, at: new Date().toISOString(), frames: lines }, null, 2));
149
+ console.log(`review (${vp.name} ${vp.width}x${vp.height}): ${lines.length} frames -> ${path.relative(process.cwd(), path.join(outDir, 'index.md'))}; lowest pixel match: ${[...lines].sort((x, y) => x.pct - y.pct).slice(0, 4).map((l) => `${l.id} ${l.pct}%`).join(', ')}`);
package/scripts/score.mjs CHANGED
@@ -7,9 +7,14 @@
7
7
  // tool mostly waits on smooth scroll and settling, so running them side by side costs little. --serial runs
8
8
  // one at a time (for debugging a flaky score).
9
9
  //
10
- // usage: node scripts/score.mjs <name> [--quick] [--url <clone url>] [--refresh-ref] [--step-minutes 20] [--serial]
10
+ // A check whose inputs are unchanged since its last run is reported from its last result instead of re-measured
11
+ // (--fresh re-measures). --only detail,load runs those checks alone; --viewport tablet narrows every check to one width.
12
+ //
13
+ // usage: node scripts/score.mjs <name> [--quick] [--only <checks>] [--viewport <vp>] [--fresh] [--url <clone url>] [--refresh-ref] [--step-minutes 20] [--serial]
11
14
  import { spawn } from 'node:child_process';
15
+ import fs from 'node:fs/promises';
12
16
  import path from 'node:path';
17
+ import crypto from 'node:crypto';
13
18
  import { fileURLToPath } from 'node:url';
14
19
  import { parseArgs } from './lib.mjs';
15
20
 
@@ -27,16 +32,63 @@ const refresh = flags['refresh-ref'] ? ['--refresh-ref'] : [];
27
32
  const limitMs = Number(flags['step-minutes'] || 20) * 60 * 1000;
28
33
  const withUrl = (args) => (url ? [...args, '--url', url] : args);
29
34
 
30
- const steps = [
35
+ // --viewport desktop narrows every check that can run per width; --only detail,load runs those checks alone
36
+ const vpOnly = typeof flags.viewport === 'string' ? flags.viewport : null;
37
+ const vpList = vpOnly ? [vpOnly] : ['desktop', 'tablet', 'mobile'];
38
+ const only = typeof flags.only === 'string' ? flags.only.split(',').map((s) => s.trim()).filter(Boolean) : null;
39
+ const allSteps = [
40
+ // first and alone: has the live page changed since the capture? (the live-measured checks are then marked stale)
41
+ ...(flags.quick ? [] : [{ group: 0, label: 'drift', args: ['scripts/drift.mjs', name, ...(vpOnly ? ['--viewport', vpOnly] : [])], pick: /(\w+): live (\d+)px/g, unit: 'live px', live: true }]),
31
42
  { group: 1, label: 'layout', args: withUrl(['scripts/diff.mjs', name, '--structure']), pick: /│ \d+\s+│ '(\w+)'\s+│ ([\d.]+)/g, unit: 'layout %' },
32
- { group: 1, label: 'states', args: ['scripts/states.mjs', name, '--clone', ...(url ? [url] : []), '--structure', ...(flags.quick ? ['--viewports', 'desktop'] : [])], pick: /│ \d+\s+│ '(\w+)'\s+│ ([\d.]+)/g, unit: 'states %' },
33
- ...(flags.quick ? [] : ['desktop', 'tablet', 'mobile'].map((v) => ({ group: 2, label: `curves ${v}`, args: withUrl(['scripts/curves.mjs', name, '--viewport', v, ...refresh]), pick: /motion score \((\w+)\): ([\d.]+)%/g, unit: 'motion %' }))),
43
+ { group: 1, label: 'states', args: ['scripts/states.mjs', name, '--clone', ...(url ? [url] : []), '--structure', ...(flags.quick || vpOnly ? ['--viewports', vpOnly || 'desktop'] : [])], pick: /│ \d+\s+│ '(\w+)'\s+│ ([\d.]+)/g, unit: 'states %' },
44
+ ...(flags.quick ? [] : vpList.map((v) => ({ group: 2, label: `curves ${v}`, args: withUrl(['scripts/curves.mjs', name, '--viewport', v, ...refresh]), pick: /motion score \((\w+)\): ([\d.]+)%/g, unit: 'motion %' }))),
34
45
  { group: 3, label: 'interact', args: withUrl(['scripts/interact.mjs', name, ...refresh]), pick: /│ \d+\s+│ '([^']+)'\s+│ ([\d.]+)/g, unit: 'interaction %', fail: /FAIL/g },
35
46
  ...(flags.quick ? [] : [
36
47
  { group: 3, label: 'hovers', args: withUrl(['scripts/hovers.mjs', name, ...refresh]), pick: /hover match \((\w+)\): ([\d.]+)%/g, unit: 'hover %' },
37
- { group: 3, label: 'detail', args: withUrl(['scripts/detail.mjs', name, '--viewport', 'all', ...refresh]), pick: /(\w+): detail match ([\d.]+)%/g, unit: 'detail %' },
48
+ { group: 3, label: 'detail', args: withUrl(['scripts/detail.mjs', name, '--viewport', vpOnly || 'all', ...refresh]), pick: /(\w+): detail match ([\d.]+)%/g, unit: 'detail %' },
49
+ // alone, last: the load sequence is a timing measurement, and the other checks must not be starved by it either
50
+ { group: 4, label: 'load', args: withUrl(['scripts/load.mjs', name, ...(vpOnly ? ['--viewport', vpOnly] : []), ...refresh]), pick: /(\w+): load match ([\d.]+)%/g, unit: 'load %' },
38
51
  ]),
39
52
  ];
53
+ const steps = only ? allSteps.filter((st) => only.some((o) => st.label === o || st.label.startsWith(`${o} `))) : allSteps;
54
+ if (!steps.length) {
55
+ console.error(`--only matched no check; checks: ${[...new Set(allSteps.map((st) => st.label.split(' ')[0]))].join(', ')}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ // Incremental: a check whose inputs have not changed since its last run (the clone's files, the capture, the
60
+ // options) is not re-measured; its last result is reported again. --fresh re-measures everything. Scoring another
61
+ // build (--url) is always measured, since its files are not the clone's.
62
+ const root = path.resolve('sites', name);
63
+ const capturedAt = await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8').then((s) => JSON.parse(s).capturedAt).catch(() => null);
64
+ async function cloneHash() {
65
+ const h = crypto.createHash('sha1');
66
+ const walk = async (dir) => {
67
+ for (const e of (await fs.readdir(dir, { withFileTypes: true }).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name))) {
68
+ const p = path.join(dir, e.name);
69
+ if (e.isDirectory()) { if (e.name !== 'node_modules') await walk(p); continue; }
70
+ const st = await fs.stat(p);
71
+ h.update(`${path.relative(root, p)}:${st.size}:${Math.round(st.mtimeMs)}\n`);
72
+ }
73
+ };
74
+ await walk(path.join(root, 'clone'));
75
+ for (const f of ['interactions.json', 'motion-map.json']) h.update(`${f}:${await fs.readFile(path.join(root, f), 'utf8').catch(() => '')}\n`);
76
+ // reference measurements other steps produce (live scroll states, reveals, motion, cached probe reads) are inputs too
77
+ for (const e of (await fs.readdir(path.join(root, 'reference'), { withFileTypes: true }).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name))) {
78
+ if (['assets', 'mirror', 'load', 'tiles'].includes(e.name)) continue;
79
+ const p = path.join(root, 'reference', e.name);
80
+ if (e.isDirectory()) await walk(p); else { const st = await fs.stat(p); h.update(`reference/${e.name}:${st.size}:${Math.round(st.mtimeMs)}\n`); }
81
+ }
82
+ return h.digest('hex');
83
+ }
84
+ const stampsFile = path.join(root, 'compare', '.stamps.json');
85
+ const stamps = url ? {} : await fs.readFile(stampsFile, 'utf8').then(JSON.parse).catch(() => ({}));
86
+ const inputs = url ? null : { hash: await cloneHash(), capturedAt, args: null };
87
+ // the step's own code is an input too: a changed check must re-measure, never report its old number
88
+ const codeHash = async (file) => crypto.createHash('sha1').update(await fs.readFile(path.join(HERE, path.basename(file)), 'utf8').catch(() => '')).update(await fs.readFile(path.join(HERE, 'lib.mjs'), 'utf8').catch(() => '')).digest('hex').slice(0, 12);
89
+ const codeHashes = Object.fromEntries(await Promise.all(steps.map(async (st) => [st.label, await codeHash(st.args[0])])));
90
+ const stampKey = (step) => JSON.stringify({ hash: inputs?.hash, capturedAt, args: step.args, code: codeHashes[step.label] });
91
+ const reusable = (step) => !step.live && !url && !flags.fresh && !flags['refresh-ref'] && stamps[step.label]?.key === stampKey(step) && stamps[step.label].values !== undefined;
40
92
 
41
93
  const runStep = (step) => new Promise((resolve) => {
42
94
  const started = Date.now();
@@ -66,17 +118,30 @@ const finish = (step, { code, out, ms }) => {
66
118
  if (code !== 0 && !timedOut) console.log(out.split('\n').filter(Boolean).slice(-4).map((l) => ` ${l}`).join('\n'));
67
119
  [...errors, ...warnings].forEach((l) => console.log(` ${l}`));
68
120
  summary.push({ step: step.label, status, seconds: Math.round(ms / 1000), [step.unit]: values.join(' ') || '-' });
121
+ if (!url && (status === 'ok' || status.endsWith('FAIL'))) stamps[step.label] = { key: stampKey(step), status, values: values.join(' '), seconds: Math.round(ms / 1000), at: new Date().toISOString() };
69
122
  };
70
123
  const groups = flags.serial
71
124
  ? steps.map((st) => [st])
72
125
  : [...new Set(steps.map((st) => st.group))].map((g) => steps.filter((st) => st.group === g));
73
126
  for (const group of groups) {
74
- console.log(`running: ${group.map((st) => st.label).join(', ')}`);
75
- await Promise.all(group.map(async (step) => finish(step, await runStep(step))));
127
+ const todo = group.filter((st) => !reusable(st));
128
+ for (const st of group.filter(reusable)) {
129
+ const s = stamps[st.label];
130
+ console.log(` ${st.label.padEnd(16)} ${s.status} (unchanged since ${s.at.slice(0, 16).replace('T', ' ')}, not re-measured; --fresh to re-run) ${s.values}`);
131
+ summary.push({ step: st.label, status: `${s.status} (kept)`, seconds: 0, [st.unit]: s.values || '-' });
132
+ }
133
+ if (!todo.length) continue;
134
+ console.log(`running: ${todo.map((st) => st.label).join(', ')}`);
135
+ await Promise.all(todo.map(async (step) => finish(step, await runStep(step))));
76
136
  }
77
137
  summary.sort((x, y) => steps.findIndex((st) => st.label === x.step) - steps.findIndex((st) => st.label === y.step));
78
- console.log(`\n${flags.quick ? 'quick' : 'full'} score for ${name}${url ? ` (${url})` : ''} in ${((Date.now() - t0) / 60000).toFixed(1)} min`);
79
- console.table(summary.map((s) => ({ step: s.step, status: s.status, 's': s.seconds, result: Object.entries(s).find(([k]) => k.endsWith('%'))?.[1] })));
138
+ const totalMin = +((Date.now() - t0) / 60000).toFixed(1);
139
+ console.log(`\n${flags.quick ? 'quick' : 'full'} score for ${name}${url ? ` (${url})` : ''} in ${totalMin} min`);
140
+ console.table(summary.map((s) => ({ step: s.step, status: s.status, 's': s.seconds, result: Object.entries(s).find(([k]) => k.endsWith('%') || k.endsWith('px'))?.[1] })));
141
+ await fs.mkdir(path.join(root, 'compare'), { recursive: true });
142
+ if (!url) await fs.writeFile(stampsFile, JSON.stringify(stamps, null, 2));
143
+ // where the time went, for REPORT.md and for choosing what to re-run
144
+ await fs.writeFile(path.join(root, 'compare', 'score-times.json'), JSON.stringify({ at: new Date().toISOString(), kind: flags.quick ? 'quick' : 'full', only, viewport: vpOnly, url, totalMin, steps: Object.fromEntries(summary.map((s) => [s.step, { status: s.status, seconds: s.seconds }])) }, null, 2));
80
145
  // REPORT.md: every score against its target, what to fix first, and what was not copied
81
146
  const report = await runStep({ args: ['report.mjs', name] });
82
147
  console.log(report.out.trim());