@trueclone/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,98 @@ 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
+ const count = await page.evaluate(watchInPage);
209
+ // Class changes caused by scrolling (a header that gets its backdrop, an "is-visible" toggle): watched with a
210
+ // MutationObserver. Anything that changes while the page sits idle (timers, carousels) is ignored.
211
+ await page.evaluate(watchClassesInPage);
212
+ await page.waitForTimeout(1500);
213
+ await page.evaluate(() => { window.__tcClassIdle = false; });
214
+ await page.evaluate(() => window.__tcSample()); // every element's state at the top
215
+ // scroll down a third of a window at a time, give each reveal time to play, sample every element where it settled
216
+ const max = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
217
+ for (let y = 0; y <= max; y += Math.round(vp.height / 3)) {
218
+ await wheelTo(page, y, vp, { settle: true });
219
+ await page.waitForTimeout(450);
220
+ await page.evaluate(() => window.__tcSample());
153
221
  }
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;
222
+ await page.waitForTimeout(1300);
223
+ await page.evaluate(() => { window.__tcSample(); window.__tcSampleIdle(); });
224
+ await page.waitForTimeout(1200);
225
+ await page.evaluate(() => window.__tcSampleIdle());
226
+ // back to the top: does each class change revert (scroll state) or stay (one-shot)? does each moved element
227
+ // return to its starting state (scroll-linked) or not (one-shot, time-driven)?
228
+ await page.evaluate(() => window.scrollTo(0, 0));
229
+ await page.waitForTimeout(1200);
230
+ await page.evaluate(() => window.__tcSampleBack());
231
+ // class order changes when a site removes and re-adds classes: compare the sets
232
+ // It reverts when the classes the scroll removed are back; classes added once (a one-time animation) may stay, so the
233
+ // state after returning ('back') is recorded too and used by the clone after the first scroll.
234
+ await page.evaluate(() => {
235
+ const toks = (v) => String(v || '').split(/\s+/).filter(Boolean);
236
+ for (const c of window.__tcClasses) {
237
+ c.back = c.el.getAttribute('class');
238
+ const removed = toks(c.from).filter((t) => !toks(c.to).includes(t));
239
+ 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));
240
+ c.marks = toks(c.to).filter((t) => !toks(c.back).includes(t)); // classes that only exist while scrolled
241
+ }
242
+ });
243
+ // sticky/fixed elements switch at a scroll position: find it precisely by bisection
244
+ const stickyCount = await page.evaluate(() => window.__tcClasses.filter((c) => c.sticky && c.reverts).length);
245
+ for (let i = 0; i < stickyCount; i++) {
246
+ let lo = 0, hi = await page.evaluate((k) => window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].scrollY, i);
247
+ while (hi - lo > 2) {
248
+ const mid = Math.round((lo + hi) / 2);
249
+ await page.evaluate((y) => window.scrollTo(0, y), mid);
250
+ await page.waitForTimeout(160);
251
+ 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);
252
+ if (on) hi = mid; else lo = mid;
253
+ }
254
+ await page.evaluate(([k, y]) => { window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].threshold = y; }, [i, hi]);
165
255
  }
166
- await page.evaluate(([k, y]) => { window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].threshold = y; }, [i, hi]);
256
+ 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 }) })));
257
+ // a reveal settles; anything still moving at the 6s cap is continuous motion driven some other way
258
+ // a reveal settles, and moves while the page stands still; anything still moving at the 6s cap is continuous
259
+ // motion driven some other way, and a change seen only during scroll steps is scroll-linked (a curve, below)
260
+ 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) => {
261
+ const last = c.frames[c.frames.length - 1];
262
+ // keep at most ~60 keyframes: enough for any easing, small enough for the clone
263
+ const step = Math.max(1, Math.ceil(c.frames.length / 60));
264
+ const frames = c.frames.filter((f, i) => i % step === 0 || i === c.frames.length - 1);
265
+ 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 };
266
+ }));
267
+ ({ scrubs, pins } = await page.evaluate((cp) => window.__tcClassify(cp), classes.map((c) => c.path)));
268
+ 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(', ')})` : ''}`);
269
+ result.classes = classes;
270
+ 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`);
271
+ } finally {
272
+ if (!flags.cdp) await browser.close();
167
273
  }
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);
274
+ const out = path.join(root, 'reference', `reveals.${vp.name}.json`);
275
+ await fs.writeFile(out, JSON.stringify({ url: meta.url, viewport: vp, at: new Date().toISOString(), reveals: result, scrubs, pins, classes: result.classes }, null, 2));
276
+ const groups = new Map();
277
+ for (const r of result) {
278
+ const k = `${JSON.stringify(r.from)} -> ${Math.round(r.duration / 50) * 50}ms`;
279
+ groups.set(k, (groups.get(k) || 0) + 1);
280
+ }
281
+ 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}`);
282
+ console.log(`-> ${path.relative(process.cwd(), out)}`);
189
283
  }
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)}`);
284
+
285
+ await Promise.all(vps.map(record));
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,60 @@ 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
+ const stampKey = (step) => JSON.stringify({ hash: inputs?.hash, capturedAt, args: step.args });
88
+ const reusable = (step) => !step.live && !url && !flags.fresh && !flags['refresh-ref'] && stamps[step.label]?.key === stampKey(step) && stamps[step.label].values !== undefined;
40
89
 
41
90
  const runStep = (step) => new Promise((resolve) => {
42
91
  const started = Date.now();
@@ -66,17 +115,30 @@ const finish = (step, { code, out, ms }) => {
66
115
  if (code !== 0 && !timedOut) console.log(out.split('\n').filter(Boolean).slice(-4).map((l) => ` ${l}`).join('\n'));
67
116
  [...errors, ...warnings].forEach((l) => console.log(` ${l}`));
68
117
  summary.push({ step: step.label, status, seconds: Math.round(ms / 1000), [step.unit]: values.join(' ') || '-' });
118
+ 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
119
  };
70
120
  const groups = flags.serial
71
121
  ? steps.map((st) => [st])
72
122
  : [...new Set(steps.map((st) => st.group))].map((g) => steps.filter((st) => st.group === g));
73
123
  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))));
124
+ const todo = group.filter((st) => !reusable(st));
125
+ for (const st of group.filter(reusable)) {
126
+ const s = stamps[st.label];
127
+ 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}`);
128
+ summary.push({ step: st.label, status: `${s.status} (kept)`, seconds: 0, [st.unit]: s.values || '-' });
129
+ }
130
+ if (!todo.length) continue;
131
+ console.log(`running: ${todo.map((st) => st.label).join(', ')}`);
132
+ await Promise.all(todo.map(async (step) => finish(step, await runStep(step))));
76
133
  }
77
134
  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] })));
135
+ const totalMin = +((Date.now() - t0) / 60000).toFixed(1);
136
+ console.log(`\n${flags.quick ? 'quick' : 'full'} score for ${name}${url ? ` (${url})` : ''} in ${totalMin} min`);
137
+ 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] })));
138
+ await fs.mkdir(path.join(root, 'compare'), { recursive: true });
139
+ if (!url) await fs.writeFile(stampsFile, JSON.stringify(stamps, null, 2));
140
+ // where the time went, for REPORT.md and for choosing what to re-run
141
+ 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
142
  // REPORT.md: every score against its target, what to fix first, and what was not copied
81
143
  const report = await runStep({ args: ['report.mjs', name] });
82
144
  console.log(report.out.trim());