@trueclone/cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,423 @@
1
+ // Shared capture settings so the reference and the clone are rendered identically.
2
+ import fs from 'node:fs/promises';
3
+ import crypto from 'node:crypto';
4
+ import { createReadStream } from 'node:fs';
5
+ import http from 'node:http';
6
+ import path from 'node:path';
7
+ import { chromium } from 'playwright';
8
+ import { PNG } from 'pngjs';
9
+ import pixelmatch from 'pixelmatch';
10
+
11
+ export { PNG };
12
+
13
+ export const VIEWPORTS = [
14
+ { name: 'mobile', width: 390, height: 844 },
15
+ { name: 'tablet', width: 768, height: 1024 },
16
+ { name: 'desktop', width: 1440, height: 900 },
17
+ ];
18
+
19
+ // Jump every animation/transition to its end state so screenshots are deterministic.
20
+ const FREEZE_CSS = `*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;
21
+ transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}`;
22
+
23
+ const MAX_SCROLL = 30000; // stop infinite-scroll pages from running forever
24
+
25
+ export function parseArgs(argv) {
26
+ const pos = [];
27
+ const flags = {};
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ if (a.startsWith('--')) {
31
+ const key = a.slice(2);
32
+ const next = argv[i + 1];
33
+ if (next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; } else flags[key] = true;
34
+ } else pos.push(a);
35
+ }
36
+ return { pos, flags };
37
+ }
38
+
39
+ export function siteName(url) {
40
+ return new URL(url).hostname.replace(/^www\./, '').replace(/[^a-z0-9]+/gi, '-').toLowerCase();
41
+ }
42
+
43
+ // --cdp attaches to the lab Chrome (npm run chrome) for sites that refuse headless browsers.
44
+ // Installed Google Chrome by default: Playwright's bundled Chromium has no H.264/AAC, so sites that wait
45
+ // for an HLS/MP4 video (intros, loaders) never finish loading in it. LAB_BROWSER=chromium forces the bundled one.
46
+ export async function openBrowser(cdp) {
47
+ if (cdp) return chromium.connectOverCDP(cdp === true ? 'http://127.0.0.1:9222' : cdp);
48
+ if (process.env.LAB_BROWSER !== 'chromium') {
49
+ try { return await chromium.launch({ channel: 'chrome' }); } catch { /* Chrome not installed: fall back */ }
50
+ }
51
+ return chromium.launch();
52
+ }
53
+
54
+ export async function newPage(browser, vp, extra = {}) {
55
+ const context = await browser.newContext({
56
+ viewport: { width: vp.width, height: vp.height },
57
+ deviceScaleFactor: 1,
58
+ reducedMotion: 'reduce',
59
+ ...extra,
60
+ });
61
+ const page = await context.newPage();
62
+ // Hard limits on Playwright actions: a stuck click or navigation fails loudly instead of hanging for hours.
63
+ page.setDefaultTimeout(30000);
64
+ page.setDefaultNavigationTimeout(60000);
65
+ // Surface script errors immediately: one uncaught error can stop a clone's whole motion layer and
66
+ // turn every score into noise, which is much harder to diagnose from the numbers alone.
67
+ page.on('pageerror', (e) => console.warn(`PAGE ERROR (${page.url()}): ${e.message}`));
68
+ return { context, page };
69
+ }
70
+
71
+ // page.evaluate has no timeout in Playwright; anything that loops over evaluate goes through this.
72
+ export function withTimeout(promise, ms, label) {
73
+ let timer;
74
+ const guard = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms: ${label}`)), ms); });
75
+ return Promise.race([promise.finally(() => clearTimeout(timer)), guard]);
76
+ }
77
+
78
+ // Reference results don't change between clone iterations: measure once, reuse until the next capture.
79
+ // The key includes the capture time, so `npm run capture` invalidates everything automatically.
80
+ async function cacheFile(siteRoot, keyObj) {
81
+ const meta = JSON.parse(await fs.readFile(path.join(siteRoot, 'reference', 'meta.json'), 'utf8'));
82
+ const key = crypto.createHash('sha1').update(JSON.stringify({ capturedAt: meta.capturedAt, ...keyObj })).digest('hex').slice(0, 16);
83
+ return path.join(siteRoot, 'reference', 'cache', `${key}.json`);
84
+ }
85
+
86
+ // Read-only lookup: the cached value, or null (never writes).
87
+ export async function readCache(siteRoot, keyObj) {
88
+ try { return JSON.parse(await fs.readFile(await cacheFile(siteRoot, keyObj), 'utf8')); } catch { return null; }
89
+ }
90
+
91
+ export async function cached(siteRoot, keyObj, compute, refresh = false) {
92
+ const file = await cacheFile(siteRoot, keyObj);
93
+ if (!refresh) {
94
+ try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { /* not cached yet */ }
95
+ }
96
+ const value = await compute();
97
+ await fs.mkdir(path.dirname(file), { recursive: true });
98
+ await fs.writeFile(file, JSON.stringify(value));
99
+ return value;
100
+ }
101
+
102
+ export async function settle(page, hide = []) {
103
+ const hideCss = hide.length ? `${hide.join(',')}{display:none!important}` : '';
104
+ const tag = await page.addStyleTag({ content: FREEZE_CSS + hideCss });
105
+ await tag.evaluate((el) => el.setAttribute('data-lab-freeze', ''));
106
+ // Scroll the whole page once so lazy images and in-view reveals load.
107
+ await page.evaluate(async (max) => {
108
+ const step = Math.max(200, window.innerHeight * 0.8);
109
+ for (let y = 0; y < Math.min(document.documentElement.scrollHeight, max); y += step) {
110
+ window.scrollTo(0, y);
111
+ await new Promise((r) => setTimeout(r, 150));
112
+ }
113
+ window.scrollTo(0, 0);
114
+ }, MAX_SCROLL);
115
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
116
+ await page.evaluate(() => document.fonts.ready);
117
+ await page.waitForTimeout(500);
118
+ }
119
+
120
+ // Structure mode: hide glyphs and turn every image/video/svg/background photo into a flat grey box,
121
+ // so a clone with placeholder content can be scored on layout alone. Applied to both sides.
122
+ const MASK_CSS = `*,*::before,*::after{color:transparent!important;text-shadow:none!important;
123
+ -webkit-text-stroke-color:transparent!important;text-decoration-color:transparent!important}
124
+ ::placeholder{color:transparent!important}
125
+ img,video{object-position:-99999px -99999px!important;background:#888!important}
126
+ svg{background:#888!important} svg *{visibility:hidden!important} iframe{visibility:hidden!important}
127
+ canvas{background:#888!important;filter:contrast(0) brightness(1.066)!important}
128
+ [data-lab-cliptext]{background:none!important;-webkit-text-fill-color:transparent!important}
129
+ [data-lab-bgb]::before,[data-lab-bga]::after{background-image:none!important;background-color:#888!important}`; // canvas = media (WebGL heroes, 3D models): flat grey box like images
130
+
131
+ // What CSS alone can't select: gradient text (background-clip: text paints the glyphs with its background, so a
132
+ // transparent colour still shows the letters) and photo backgrounds on ::before/::after. Marked with attributes
133
+ // the mask CSS targets; `off` removes them.
134
+ function maskExtrasInPage(enable) {
135
+ const marks = ['data-lab-cliptext', 'data-lab-bgb', 'data-lab-bga'];
136
+ if (!enable) {
137
+ for (const m of marks) for (const el of document.querySelectorAll(`[${m}]`)) el.removeAttribute(m);
138
+ return;
139
+ }
140
+ for (const el of document.querySelectorAll('*')) {
141
+ const s = getComputedStyle(el);
142
+ if (s.backgroundClip === 'text' || s.webkitBackgroundClip === 'text') el.setAttribute('data-lab-cliptext', '');
143
+ if (getComputedStyle(el, '::before').backgroundImage.includes('url(')) el.setAttribute('data-lab-bgb', '');
144
+ if (getComputedStyle(el, '::after').backgroundImage.includes('url(')) el.setAttribute('data-lab-bga', '');
145
+ }
146
+ }
147
+
148
+ export async function mask(page) {
149
+ const tag = await page.addStyleTag({ content: MASK_CSS });
150
+ await tag.evaluate((el) => el.setAttribute('data-lab-freeze', ''));
151
+ await page.evaluate(() => {
152
+ for (const el of document.querySelectorAll('*')) {
153
+ if (getComputedStyle(el).backgroundImage.includes('url(')) {
154
+ el.style.setProperty('background-image', 'none', 'important');
155
+ el.style.setProperty('background-color', '#888', 'important');
156
+ }
157
+ }
158
+ });
159
+ await page.evaluate(maskExtrasInPage, true);
160
+ await page.waitForTimeout(200);
161
+ }
162
+
163
+ // Endless animations (marquees, rotating borders, pulsing glows) sit at whatever phase the page's clock happens to
164
+ // be at, so two screenshots of the same page differ. Before a still comparison, every infinite animation is put on
165
+ // its first frame and paused, on both sides. Continuous motion is scored by the motion checks, not by stills.
166
+ export async function freezeLoops(page) {
167
+ await page.evaluate(() => {
168
+ for (const a of document.getAnimations()) {
169
+ if (a.effect?.getTiming().iterations !== Infinity) continue;
170
+ a.currentTime = 0;
171
+ a.pause();
172
+ }
173
+ }).catch(() => {});
174
+ }
175
+
176
+ // Reversible version of mask() for live pages: on() masks, off() restores every inline style it touched.
177
+ export async function maskToggle(page, on) {
178
+ await page.evaluate(([css, enable]) => {
179
+ if (enable) {
180
+ const tag = document.createElement('style');
181
+ tag.id = 'lab-mask';
182
+ tag.setAttribute('data-lab-freeze', '');
183
+ tag.textContent = css;
184
+ document.head.appendChild(tag);
185
+ for (const el of document.querySelectorAll('*')) {
186
+ if (getComputedStyle(el).backgroundImage.includes('url(')) {
187
+ el.setAttribute('data-lab-bg', el.style.cssText);
188
+ el.style.setProperty('background-image', 'none', 'important');
189
+ el.style.setProperty('background-color', '#888', 'important');
190
+ }
191
+ }
192
+ } else {
193
+ document.getElementById('lab-mask')?.remove();
194
+ for (const el of document.querySelectorAll('[data-lab-bg]')) {
195
+ el.style.cssText = el.getAttribute('data-lab-bg');
196
+ el.removeAttribute('data-lab-bg');
197
+ }
198
+ }
199
+ }, [MASK_CSS, on]);
200
+ await page.evaluate(maskExtrasInPage, on);
201
+ await page.waitForTimeout(on ? 150 : 50);
202
+ }
203
+
204
+ // Top-level visual sections: unwrap single-child wrappers (#__next, #root...), then list the blocks.
205
+ export async function sectionsOf(page) {
206
+ return page.evaluate(() => {
207
+ const visible = (el) => {
208
+ const s = getComputedStyle(el);
209
+ const r = el.getBoundingClientRect();
210
+ return s.display !== 'none' && s.visibility !== 'hidden' && r.height > 0 && r.width > 0
211
+ && !['SCRIPT', 'STYLE', 'NOSCRIPT', 'LINK', 'META'].includes(el.tagName);
212
+ };
213
+ let node = document.body;
214
+ for (let kids = [...node.children].filter(visible); kids.length === 1; kids = [...node.children].filter(visible)) {
215
+ node = kids[0];
216
+ }
217
+ let blocks = [...node.children].filter(visible);
218
+ blocks = blocks.flatMap((el) => (el.tagName === 'MAIN' && el.children.length > 1 ? [...el.children].filter(visible) : [el]));
219
+ return blocks.map((el, index) => {
220
+ const r = el.getBoundingClientRect();
221
+ const heading = el.querySelector('h1,h2,h3');
222
+ return {
223
+ index,
224
+ tag: el.tagName.toLowerCase(),
225
+ id: el.id || undefined,
226
+ classes: [...el.classList].slice(0, 3).join(' ') || undefined,
227
+ heading: heading ? heading.textContent.trim().replace(/\s+/g, ' ').slice(0, 60) : undefined,
228
+ y: Math.round(r.top + window.scrollY),
229
+ height: Math.round(r.height),
230
+ };
231
+ });
232
+ });
233
+ }
234
+
235
+ // ---------- Static server for clones (diff + states) ----------
236
+ const MIME = {
237
+ '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json',
238
+ '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
239
+ '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2',
240
+ '.ttf': 'font/ttf', '.otf': 'font/otf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.glb': 'model/gltf-binary',
241
+ '.gltf': 'model/gltf+json', '.m3u8': 'application/vnd.apple.mpegurl', '.ts': 'video/mp2t',
242
+ };
243
+
244
+ // Port 0 = a free port picked by the OS, so several scoring tools can run side by side; use server.url.
245
+ export function serve(dir, port = 0) {
246
+ return new Promise((resolve) => {
247
+ const server = http.createServer(async (req, res) => {
248
+ let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
249
+ if (p.endsWith('/')) p += 'index.html';
250
+ const file = path.join(dir, p);
251
+ if (!file.startsWith(dir)) { res.writeHead(403).end(); return; }
252
+ try {
253
+ await fs.access(file);
254
+ res.writeHead(200, { 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' });
255
+ createReadStream(file).pipe(res);
256
+ } catch { res.writeHead(404).end(); }
257
+ });
258
+ server.listen(port, '127.0.0.1', () => { server.url = `http://127.0.0.1:${server.address().port}/`; resolve(server); });
259
+ });
260
+ }
261
+
262
+ // ---------- Image comparison ----------
263
+ // White-pad so images of different sizes can be compared; the gap counts as mismatch.
264
+ export function pad(png, width, height) {
265
+ if (png.width === width && png.height === height) return png;
266
+ const out = new PNG({ width, height });
267
+ out.data.fill(255);
268
+ PNG.bitblt(png, out, 0, 0, png.width, png.height, 0, 0);
269
+ return out;
270
+ }
271
+
272
+ export function crop(png, y, h) {
273
+ const out = new PNG({ width: png.width, height: h });
274
+ PNG.bitblt(png, out, 0, y, png.width, h, 0, 0);
275
+ return out;
276
+ }
277
+
278
+ export const match = (a, b, diff) => pixelmatch(a.data, b.data, diff ? diff.data : null, a.width, a.height, { threshold: 0.1, includeAA: false });
279
+
280
+ export function matchPct(pngA, pngB, withDiff = false) {
281
+ const w = Math.max(pngA.width, pngB.width), h = Math.max(pngA.height, pngB.height);
282
+ const a = pad(pngA, w, h), b = pad(pngB, w, h);
283
+ const diff = withDiff ? new PNG({ width: w, height: h }) : null;
284
+ const bad = match(a, b, diff);
285
+ return { pct: +(100 - (bad / (w * h)) * 100).toFixed(2), diff };
286
+ }
287
+
288
+ // ---------- Live-state sampling (animations running, nothing frozen) ----------
289
+ // Loads lazy content without freezing anything: scroll once top to bottom, then back to the top.
290
+ export async function warmUp(page) {
291
+ await page.evaluate(async (max) => {
292
+ const step = Math.max(200, window.innerHeight * 0.8);
293
+ for (let y = 0; y < Math.min(document.documentElement.scrollHeight, max); y += step) {
294
+ window.scrollTo(0, y);
295
+ await new Promise((r) => setTimeout(r, 120));
296
+ }
297
+ window.scrollTo(0, 0);
298
+ }, MAX_SCROLL);
299
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
300
+ await page.evaluate(() => document.fonts.ready);
301
+ }
302
+
303
+ // Motion-relevant state of every element, keyed by a stable DOM path. Elements at opacity 0 or
304
+ // visibility:hidden are kept on purpose: "hidden until scrolled into view" is what we need to see.
305
+ export function statesInPage() {
306
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'LINK', 'META', 'BR', 'PATH', 'G', 'DEFS', 'USE', 'RECT', 'CIRCLE', 'LINE', 'POLYGON', 'TSPAN', 'STOP', 'CLIPPATH']);
307
+ const pathOf = (el) => {
308
+ const parts = [];
309
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
310
+ parts.unshift(`${n.tagName.toLowerCase()}:${[...n.parentElement.children].indexOf(n) + 1}`);
311
+ }
312
+ return parts.join('>');
313
+ };
314
+ const out = {};
315
+ for (const el of document.body.querySelectorAll('*')) {
316
+ if (SKIP.has(el.tagName.toUpperCase())) continue;
317
+ const s = getComputedStyle(el);
318
+ if (s.display === 'none') continue;
319
+ const r = el.getBoundingClientRect();
320
+ if (r.width < 8 || r.height < 8) continue;
321
+ const tag = el.tagName.toLowerCase();
322
+ out[pathOf(el)] = {
323
+ cls: String(el.getAttribute('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.'),
324
+ box: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)], // viewport-relative
325
+ o: +(+s.opacity).toFixed(2),
326
+ v: s.visibility === 'hidden' ? 0 : 1,
327
+ t: s.transform === 'none' ? '' : s.transform,
328
+ c: s.clipPath === 'none' ? '' : s.clipPath,
329
+ f: s.filter === 'none' ? '' : s.filter,
330
+ pos: s.position === 'fixed' || s.position === 'sticky' ? s.position : undefined,
331
+ media: ['img', 'video', 'canvas', 'svg', 'iframe'].includes(tag) ? tag : undefined,
332
+ };
333
+ }
334
+ return out;
335
+ }
336
+
337
+ // Wait until nothing moves: layout + opacity + transform unchanged for 3 polls (video/canvas ignored).
338
+ // Endless loops (a floating helmet, a marquee) would never settle, so an element that changed on at least
339
+ // 90% of polls after the first second counts as "continuous" and is ignored from then on.
340
+ export async function waitStill(page, { timeout = 4000, interval = 80 } = {}) {
341
+ const changed = () => withTimeout(page.evaluate(() => {
342
+ const prev = window.__labSig || new Map();
343
+ const next = new Map();
344
+ const out = [];
345
+ let i = 0;
346
+ const H = innerHeight;
347
+ for (const el of document.body.querySelectorAll('*')) {
348
+ i++;
349
+ if (['VIDEO', 'CANVAS', 'SCRIPT', 'STYLE'].includes(el.tagName)) continue;
350
+ // Only what is on (or just around) the screen can matter for a screenshot; reading styles for the
351
+ // whole document on every poll was the main cost of scoring (thousands of elements, every 80-120ms).
352
+ const r = el.getBoundingClientRect();
353
+ if (r.bottom < -200 || r.top > H + 200 || (r.width === 0 && r.height === 0)) continue;
354
+ const s = getComputedStyle(el);
355
+ if (s.display === 'none') continue;
356
+ const str = `${Math.round(r.top)}|${Math.round(r.left)}|${s.opacity}|${s.transform}`;
357
+ next.set(i, str);
358
+ if (prev.size && prev.get(i) !== str) out.push(i);
359
+ }
360
+ window.__labSig = next;
361
+ return { out, y: Math.round(scrollY) };
362
+ }), 15000, 'waitStill poll');
363
+ await withTimeout(page.evaluate(() => { window.__labSig = undefined; }), 15000, 'waitStill reset');
364
+ await changed();
365
+ const start = Date.now();
366
+ const counts = new Map();
367
+ let polls = 0, quiet = 0, lastY = null;
368
+ while (Date.now() - start < timeout) {
369
+ await page.waitForTimeout(interval);
370
+ const { out, y } = await changed();
371
+ polls++;
372
+ out.forEach((k) => counts.set(k, (counts.get(k) || 0) + 1));
373
+ const continuous = (k) => polls >= 8 && counts.get(k) >= polls * 0.9;
374
+ const real = out.filter((k) => !continuous(k));
375
+ quiet = real.length === 0 && y === lastY ? quiet + 1 : 0;
376
+ lastY = y;
377
+ if (quiet >= 2) return { settled: true, ms: Date.now() - start, continuous: [...counts].filter(([k]) => continuous(k)).length };
378
+ }
379
+ return { settled: false, ms: Date.now() - start };
380
+ }
381
+
382
+ // Wait only for the scroll position to stop moving (smooth-scroll easing): cheap, usually 100-600ms.
383
+ async function scrollSettled(page, maxMs = 1500) {
384
+ const start = Date.now();
385
+ let last = null, same = 0;
386
+ while (Date.now() - start < maxMs) {
387
+ const y = await withTimeout(page.evaluate(() => scrollY), 10000, 'scrollY');
388
+ same = y === last ? same + 1 : 0;
389
+ last = y;
390
+ if (same >= 2) break;
391
+ await page.waitForTimeout(50);
392
+ }
393
+ return last;
394
+ }
395
+ // Smooth-scroll libraries scale wheel input (Lenis wheelMultiplier 0.7 moves 315px for a 450px wheel). Retrying
396
+ // with the remainder took 5-6 glides of ~0.9s each per step, which was most of every scoring run. The page's
397
+ // scroll-per-wheel-pixel is learned from the first unclamped move and used to size the next wheels, so a step
398
+ // is one glide.
399
+ const wheelGain = new WeakMap();
400
+ export async function wheelTo(page, y, vp, { settle = false } = {}) {
401
+ await page.mouse.move(Math.round(vp.width * 0.02), Math.round(vp.height * 0.98)); // away from hover effects
402
+ const max = await withTimeout(page.evaluate(() => document.documentElement.scrollHeight - innerHeight), 10000, 'scrollMax');
403
+ let at = await scrollSettled(page);
404
+ for (let i = 0; i < 4 && Math.abs(y - at) >= 2; i++) {
405
+ const gain = wheelGain.get(page) || 1;
406
+ const want = y - at;
407
+ await withTimeout(page.mouse.wheel(0, want / gain), 10000, 'wheel');
408
+ const now = await scrollSettled(page);
409
+ const moved = now - at;
410
+ const clamped = now <= 1 || now >= max - 1;
411
+ if (!clamped && Math.abs(want) > 20 && Math.abs(moved) > 5 && Math.sign(moved) === Math.sign(want)) {
412
+ const learned = (gain * moved) / want;
413
+ if (learned > 0.1 && learned < 10) wheelGain.set(page, learned);
414
+ }
415
+ at = now;
416
+ }
417
+ if (Math.abs(at - y) >= 2) { // pages that swallow wheel events
418
+ await withTimeout(page.evaluate((yy) => window.scrollTo(0, yy), y), 10000, 'scrollTo');
419
+ await scrollSettled(page);
420
+ }
421
+ if (settle) await waitStill(page, { timeout: 2000 });
422
+ return withTimeout(page.evaluate(() => scrollY), 10000, 'scrollY');
423
+ }
@@ -0,0 +1,239 @@
1
+ // Motion inventory from the live runtime: what moves, what drives it, with what parameters.
2
+ // Reads library state after the intro finishes, so the numbers are the site's real settings, not guesses:
3
+ // - GSAP: every ScrollTrigger (trigger, start/end, scrub, pin, toggleActions) with its full timeline,
4
+ // pointer-driven tweens (gsap.quickTo), endless loops, other standalone tweens
5
+ // - Lenis options (constructor intercepted), Swiper params, CSS animations/transitions
6
+ // - a pointer probe: which elements move or change when the cursor moves
7
+ // Records parameters only (targets, ranges, durations, easing); the site's code itself is never saved.
8
+ //
9
+ // usage: node scripts/motion.mjs <name> [--viewport desktop|tablet|mobile]
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, waitStill, statesInPage } from './lib.mjs';
13
+
14
+ const { pos, flags } = parseArgs(process.argv.slice(2));
15
+ const [name] = pos;
16
+ if (!name) {
17
+ console.error('usage: npm run motion -- <name> [--viewport desktop]');
18
+ process.exit(1);
19
+ }
20
+ const ref = path.resolve('sites', name, 'reference');
21
+ const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
22
+ const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
23
+
24
+ // Runs before any page script: intercept constructors whose options are not reachable afterwards.
25
+ const INIT = () => {
26
+ const wrap = (globalName, store) => {
27
+ let value;
28
+ Object.defineProperty(window, globalName, {
29
+ configurable: true,
30
+ get() { return value; },
31
+ set(v) {
32
+ value = typeof v !== 'function' ? v : new Proxy(v, {
33
+ construct(target, args) {
34
+ const safe = JSON.parse(JSON.stringify(args[0] || {}, (k, x) => (typeof x === 'function' ? `[function ${x.name || 'anonymous'}]` : x instanceof Element ? `[element ${x.tagName.toLowerCase()}.${x.className}]` : x)));
35
+ (window[store] = window[store] || []).push(safe);
36
+ return Reflect.construct(target, args);
37
+ },
38
+ });
39
+ },
40
+ });
41
+ };
42
+ wrap('Lenis', '__labLenis');
43
+ };
44
+
45
+ function inventory() {
46
+ const pathOf = (el) => {
47
+ const parts = [];
48
+ for (let n = el; n && n !== document.body && n.parentElement; n = n.parentElement) parts.unshift(`${n.tagName.toLowerCase()}:${[...n.parentElement.children].indexOf(n) + 1}`);
49
+ return parts.join('>');
50
+ };
51
+ const sel = (el) => (el instanceof Element ? `${el.tagName.toLowerCase()}${String(el.getAttribute('class') || '').split(/\s+/).filter(Boolean).slice(0, 3).map((c) => '.' + c).join('')}` : null);
52
+ const section = (el) => {
53
+ if (!(el instanceof Element)) return null;
54
+ let n = el;
55
+ while (n.parentElement && n.parentElement !== document.body && !['MAIN'].includes(n.parentElement.tagName) && n.parentElement.children.length === 1) n = n.parentElement;
56
+ for (let p = el; p && p !== document.body; p = p.parentElement) {
57
+ if (/section|footer|header/i.test(p.tagName) || /(^|\s)section[_-]/.test(String(p.getAttribute('class') || ''))) return sel(p);
58
+ }
59
+ return null;
60
+ };
61
+ const target = (el) => (el instanceof Element ? { sel: sel(el), path: pathOf(el), section: section(el) } : { kind: typeof el });
62
+ const easeName = (e) => (typeof e === 'string' ? e : typeof e === 'function' ? (e.custom ? 'CustomEase' : e.name || 'function') : e);
63
+ const clean = (vars) => {
64
+ const out = {};
65
+ for (const [k, v] of Object.entries(vars || {})) {
66
+ if (['scrollTrigger', 'onComplete', 'onUpdate', 'onStart', 'callbackScope', 'data', 'id', 'parent'].includes(k)) continue;
67
+ if (k === 'ease') { out.ease = easeName(v); continue; }
68
+ if (k === 'startAt') { out.startAt = clean(v); continue; }
69
+ if (k === 'stagger' && v && typeof v === 'object') { out.stagger = clean(v); continue; }
70
+ if (typeof v === 'function') { out[k] = '[function]'; continue; }
71
+ if (v && typeof v === 'object') continue;
72
+ out[k] = v;
73
+ }
74
+ return out;
75
+ };
76
+ const describe = (a, depth = 0) => {
77
+ if (!a || depth > 6) return null;
78
+ if (a.getChildren) {
79
+ return {
80
+ type: 'timeline', duration: +a.duration().toFixed(3), repeat: a.repeat(), vars: clean(a.vars), labels: a.labels,
81
+ children: a.getChildren(false, true, true).map((c) => ({ start: +c.startTime().toFixed(3), ...describe(c, depth + 1) })),
82
+ };
83
+ }
84
+ const targets = a.targets();
85
+ const els = targets.filter((t) => t instanceof Element);
86
+ return {
87
+ type: 'tween', duration: +a.duration().toFixed(3), delay: a.delay(), repeat: a.repeat(), yoyo: a.yoyo(),
88
+ from: Boolean(a.vars.runBackwards), vars: clean(a.vars),
89
+ targets: els.slice(0, 6).map(target), targetCount: targets.length,
90
+ split: els.some((e) => /(^|\s)(line|word|char|split)/i.test(String(e.getAttribute('class') || '')) || e.parentElement?.hasAttribute('aria-label')),
91
+ };
92
+ };
93
+
94
+ const triggers = (window.ScrollTrigger ? ScrollTrigger.getAll() : []).map((st, i) => ({
95
+ i,
96
+ trigger: target(st.trigger),
97
+ start: st.vars.start ?? 'top bottom', end: st.vars.end ?? 'bottom top',
98
+ startPx: Math.round(st.start), endPx: Math.round(st.end),
99
+ scrub: st.vars.scrub ?? false,
100
+ pin: st.pin ? target(st.pin) : null, pinSpacing: st.vars.pinSpacing,
101
+ toggleActions: st.vars.toggleActions, once: st.vars.once,
102
+ snap: st.vars.snap ? JSON.stringify(st.vars.snap) : undefined,
103
+ callbacks: Object.keys(st.vars).filter((k) => k.startsWith('on')),
104
+ animation: st.animation ? describe(st.animation) : null,
105
+ }));
106
+
107
+ const standalone = window.gsap ? gsap.globalTimeline.getChildren(false, true, true).filter((a) => !a.scrollTrigger) : [];
108
+ const isElTween = (a) => !a.getChildren && a.targets().some((t) => t instanceof Element);
109
+ const quickTo = (a) => isElTween(a) && a.paused() && Object.values(a.vars).some((v) => v === '+=0.1');
110
+ const pointer = standalone.filter(quickTo).map((a) => ({ target: target(a.targets()[0]), prop: Object.keys(a.vars).find((k) => a.vars[k] === '+=0.1'), duration: a.duration(), ease: easeName(a.vars.ease) }));
111
+ const loops = standalone.filter((a) => a.repeat && a.repeat() === -1).map((a) => describe(a));
112
+ const other = standalone.filter((a) => !quickTo(a) && !(a.repeat && a.repeat() === -1) && (a.getChildren || isElTween(a))).map((a) => describe(a));
113
+
114
+ const swipers = [...document.querySelectorAll('*')].filter((e) => e.swiper).map((e) => {
115
+ const p = e.swiper.params;
116
+ const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o[k] !== undefined).map((k) => [k, typeof o[k] === 'object' ? JSON.parse(JSON.stringify(o[k], (kk, v) => (v instanceof Element ? sel(v) : typeof v === 'function' ? '[function]' : v))) : o[k]]));
117
+ return { el: target(e), slides: e.swiper.slides.length, params: pick(p, ['slidesPerView', 'spaceBetween', 'speed', 'loop', 'grabCursor', 'centeredSlides', 'freeMode', 'effect', 'direction', 'autoplay', 'breakpoints', 'navigation', 'pagination', 'simulateTouch', 'allowTouchMove', 'mousewheel', 'keyboard']) };
118
+ });
119
+
120
+ const css = document.getAnimations().map((a) => ({
121
+ type: a.constructor.name, name: a.animationName || a.transitionProperty || null,
122
+ target: a.effect?.target ? target(a.effect.target) : null,
123
+ duration: a.effect?.getTiming().duration, iterations: a.effect?.getTiming().iterations, easing: a.effect?.getTiming().easing,
124
+ keyframes: a.constructor.name === 'CSSAnimation' ? a.effect.getKeyframes().slice(0, 6) : undefined,
125
+ }));
126
+ const cssGrouped = Object.values(css.reduce((m, a) => { const k = `${a.type}|${a.name}|${a.target?.sel}`; (m[k] = m[k] || { ...a, count: 0 }).count++; return m; }, {}));
127
+
128
+ window.__lab = { describe, target };
129
+ return {
130
+ libs: {
131
+ gsap: window.gsap?.version, ScrollTrigger: Boolean(window.ScrollTrigger), SplitText: Boolean(window.SplitText), CustomEase: Boolean(window.CustomEase),
132
+ Lenis: Boolean(window.Lenis), Swiper: Boolean(window.Swiper), three: window.THREE?.REVISION, jquery: window.jQuery?.fn?.jquery,
133
+ webflow: Boolean(window.Webflow), hls: Boolean(window.Hls), lottie: Boolean(window.lottie),
134
+ },
135
+ lenis: window.__labLenis || [],
136
+ triggers, pointer, loops, other, swipers, css: cssGrouped,
137
+ };
138
+ }
139
+
140
+ const browser = await openBrowser(flags.cdp);
141
+ let result;
142
+ try {
143
+ const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
144
+ await page.addInitScript(INIT);
145
+ const t0 = Date.now();
146
+ await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
147
+ await page.mouse.move(5, vp.height - 5);
148
+ const intro = await waitStill(page, { timeout: 15000, interval: 200 });
149
+ const introMs = Date.now() - t0;
150
+ result = await page.evaluate(inventory);
151
+
152
+ // Pointer probe at the top of the page: which elements react when the cursor moves?
153
+ const probe = async (x, y) => { await page.mouse.move(x, y, { steps: 12 }); await page.waitForTimeout(1600); return page.evaluate(statesInPage); };
154
+ const a = await probe(Math.round(vp.width * 0.3), Math.round(vp.height * 0.35));
155
+ const b = await probe(Math.round(vp.width * 0.7), Math.round(vp.height * 0.65));
156
+ const reactive = Object.keys(b).filter((k) => a[k] && (a[k].t !== b[k].t || a[k].o !== b[k].o || a[k].c !== b[k].c || a[k].f !== b[k].f || a[k].box.join() !== b[k].box.join()))
157
+ .map((k) => ({ path: k, cls: b[k].cls, media: b[k].media, from: { t: a[k].t, o: a[k].o, c: a[k].c, f: a[k].f, box: a[k].box }, to: { t: b[k].t, o: b[k].o, c: b[k].c, f: b[k].f, box: b[k].box } }));
158
+ reactive.sort((x, y) => x.path.length - y.path.length);
159
+ const pruned = [];
160
+ for (const r of reactive) if (!pruned.some((p) => r.path.startsWith(p.path + '>') && p.cls === r.cls)) pruned.push(r);
161
+ // Scroll pass: callback-driven animations (onEnter reveals, SplitText) only exist once their trigger fires.
162
+ // Record every tween/timeline that appears while scrolling, with the scroll position it appeared at.
163
+ await page.mouse.move(5, vp.height - 5);
164
+ await page.evaluate(() => {
165
+ window.__labSeen = new WeakSet();
166
+ if (window.gsap) gsap.globalTimeline.getChildren(true, true, true).forEach((a) => window.__labSeen.add(a));
167
+ });
168
+ const scan = () => page.evaluate(() => {
169
+ if (!window.gsap) return [];
170
+ const out = [];
171
+ for (const a of gsap.globalTimeline.getChildren(true, true, true)) {
172
+ if (window.__labSeen.has(a)) continue;
173
+ window.__labSeen.add(a);
174
+ if (a.scrollTrigger || (a.parent && a.parent !== gsap.globalTimeline)) continue; // children are described with their parent
175
+ const d = window.__lab.describe(a);
176
+ const hasEls = d && (d.type === 'timeline' ? JSON.stringify(d).includes('"sel"') : d.targets.length);
177
+ if (hasEls) out.push({ y: Math.round(scrollY), ...d });
178
+ }
179
+ return out;
180
+ });
181
+ const created = [];
182
+ const maxY = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
183
+ for (let y = 0; y <= maxY + vp.height / 4; y += Math.round(vp.height / 4)) {
184
+ await page.mouse.wheel(0, Math.round(vp.height / 4));
185
+ for (let k = 0; k < 6; k++) { await page.waitForTimeout(100); created.push(...await scan()); }
186
+ }
187
+ // Collapse repeats (same targets + same vars) into one entry with every scroll position it fired at.
188
+ const groups = new Map();
189
+ for (const c of created) {
190
+ const key = JSON.stringify({ ...c, y: 0 });
191
+ const g = groups.get(key) || { ...c, firedAt: [] };
192
+ g.firedAt.push(c.y);
193
+ groups.set(key, g);
194
+ }
195
+ const onScroll = [...groups.values()].map(({ y, ...rest }) => rest);
196
+ result = { url: meta.url, viewport: vp, capturedAt: new Date().toISOString(), intro: { ms: introMs, settled: intro.settled, continuousElements: intro.continuous || 0 }, ...result, pointerReactive: pruned.slice(0, 40), onScroll };
197
+ await context.close();
198
+ } finally {
199
+ if (!flags.cdp) await browser.close();
200
+ }
201
+
202
+ const out = path.join(ref, `motion.${vp.name}.json`);
203
+ await fs.writeFile(out, JSON.stringify(result, null, 2));
204
+
205
+ // Human-readable digest, grouped by section, for building the clone.
206
+ const lines = [`# Motion inventory: ${meta.url} (${vp.name} ${vp.width}x${vp.height})`, '',
207
+ `Libraries: ${Object.entries(result.libs).filter(([, v]) => v).map(([k, v]) => (v === true ? k : `${k} ${v}`)).join(', ')}`,
208
+ `Intro: settled after ${(result.intro.ms / 1000).toFixed(1)} s${result.intro.settled ? '' : ' (did not fully settle)'}; ${result.intro.continuousElements} continuously animating elements.`,
209
+ `Lenis: ${result.lenis.length ? JSON.stringify(result.lenis) : 'not constructed through the global'}`, ''];
210
+ const tweenLine = (t, indent = '') => {
211
+ if (!t) return [];
212
+ if (t.type === 'timeline') return [`${indent}timeline ${t.duration}s${t.repeat ? ` repeat ${t.repeat}` : ''}`, ...t.children.flatMap((c) => tweenLine({ ...c }, indent + ' ').map((l, i) => (i === 0 ? l.replace(/^(\s*)/, `$1@${c.start}s `) : l)))];
213
+ const props = Object.entries(t.vars).filter(([k]) => !['duration', 'ease', 'delay', 'overwrite', 'immediateRender', 'lazy', 'inherit', 'paused', 'stagger', 'startAt', 'repeat', 'yoyo'].includes(k)).map(([k, v]) => `${k}:${v}`).join(' ');
214
+ const start = t.vars.startAt ? ` from {${Object.entries(t.vars.startAt).map(([k, v]) => `${k}:${v}`).join(' ')}}` : '';
215
+ return [`${indent}${t.from ? 'from' : 'to'} [${t.targets.map((x) => x.sel).join(', ')}${t.targetCount > t.targets.length ? ` +${t.targetCount - t.targets.length}` : ''}]${t.split ? ' (split text)' : ''} ${props}${start} | ${t.duration}s ${t.vars.ease || ''}${t.vars.stagger !== undefined ? ` stagger ${JSON.stringify(t.vars.stagger)}` : ''}${t.repeat ? ` repeat ${t.repeat}${t.yoyo ? ' yoyo' : ''}` : ''}`];
216
+ };
217
+ const bySection = {};
218
+ for (const st of result.triggers) (bySection[st.trigger.section || st.trigger.sel || '(no element)'] ||= []).push(st);
219
+ lines.push(`## Scroll-driven (${result.triggers.length} ScrollTriggers)`, '');
220
+ for (const [sec, list] of Object.entries(bySection)) {
221
+ lines.push(`### ${sec}`, '');
222
+ for (const st of list) {
223
+ lines.push(`- trigger \`${st.trigger.sel}\` start "${st.start}" end "${st.end}"${st.scrub !== false ? ` scrub ${st.scrub}` : ''}${st.pin ? ` pin \`${st.pin.sel}\`` : ''}${st.toggleActions ? ` toggleActions "${st.toggleActions}"` : ''}${st.once ? ' once' : ''}${st.callbacks.length ? ` callbacks ${st.callbacks.join(',')}` : ''}`);
224
+ tweenLine(st.animation, ' ').forEach((l) => lines.push(l));
225
+ }
226
+ lines.push('');
227
+ }
228
+ lines.push(`## Created while scrolling (callback-driven: reveals, SplitText, toggles) - ${result.onScroll.length} distinct`, '',
229
+ ...result.onScroll.flatMap((t) => [`- first at scrollY ${t.firedAt[0]}${t.firedAt.length > 1 ? ` (x${t.firedAt.length})` : ''}`, ...tweenLine(t, ' ')]), '');
230
+ lines.push(`## Pointer-driven (gsap.quickTo)`, '', ...result.pointer.map((p) => `- \`${p.target.sel}\` ${p.prop} ${p.duration}s ${p.ease}`), '');
231
+ lines.push(`## Elements that react to the cursor (probe)`, '', ...result.pointerReactive.map((r) => `- \`${r.cls || r.path}\`${r.media ? ` (${r.media})` : ''}: ${['t', 'o', 'c', 'f'].filter((k) => r.from[k] !== r.to[k]).map((k) => ({ t: 'transform', o: 'opacity', c: 'clip-path', f: 'filter' })[k]).join(', ') || 'position'}`), '');
232
+ lines.push(`## Endless loops`, '', ...result.loops.flatMap((t) => tweenLine(t, '- ')), '');
233
+ lines.push(`## Other standalone tweens/timelines`, '', ...result.other.flatMap((t) => tweenLine(t, '- ')), '');
234
+ lines.push(`## Sliders (Swiper)`, '', ...result.swipers.map((s) => `- \`${s.el.sel}\` ${s.slides} slides ${JSON.stringify(s.params)}`), '');
235
+ lines.push(`## CSS animations / transitions`, '', ...result.css.map((c) => `- ${c.type} ${c.name} on \`${c.target?.sel}\` x${c.count} ${c.duration}ms ${c.easing}${c.iterations === Infinity ? ' infinite' : ''}`), '');
236
+ await fs.writeFile(path.join(ref, `motion.${vp.name}.md`), lines.join('\n'));
237
+ console.log(`libs: ${JSON.stringify(result.libs)}`);
238
+ console.log(`intro ${result.intro.ms}ms settled=${result.intro.settled} | triggers ${result.triggers.length} | created-on-scroll ${result.onScroll.length} | pointer ${result.pointer.length} | cursor-reactive ${result.pointerReactive.length} | loops ${result.loops.length} | other ${result.other.length} | swipers ${result.swipers.length} | css ${result.css.length} | lenis ${result.lenis.length}`);
239
+ console.log(`-> ${path.relative(process.cwd(), out)} and motion.${vp.name}.md`);