@trueclone/cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/bin/trueclone.mjs +6 -1
- package/package.json +1 -1
- package/scripts/capture.mjs +124 -6
- package/scripts/detail.mjs +19 -6
- package/scripts/drift.mjs +48 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/hovers.mjs +17 -8
- package/scripts/lib.mjs +8 -24
- package/scripts/load.mjs +102 -0
- package/scripts/next.mjs +91 -26
- package/scripts/owned.mjs +66 -0
- package/scripts/report.mjs +43 -11
- package/scripts/reveals.mjs +174 -80
- package/scripts/score.mjs +71 -9
- package/scripts/seed.mjs +316 -25
- package/scripts/understand.mjs +231 -0
- package/skill/SKILL.md +42 -13
package/scripts/reveals.mjs
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
// Record
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
await
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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((
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 ? [] :
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
console.
|
|
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());
|