@trueclone/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,254 @@
1
+ // Motion score: sample animated properties of matched elements on the reference and the clone, then
2
+ // compare the curves. Each entry in sites/<name>/motion-map.json pairs a reference selector with a clone
3
+ // selector and samples either across a scroll range or over time after a scroll (for onEnter reveals).
4
+ //
5
+ // { "name": "hero far layer", "ref": ".hero_depthmap-wrapper", "clone": ".hero-layer@0",
6
+ // "props": ["ty"], "scroll": { "from": 0, "to": 900, "steps": 10 } }
7
+ // { "name": "quote reveal", "ref": ".is-quote .reveal-line", "clone": ".is-quote .split-line",
8
+ // "props": ["ty"], "time": { "before": 0, "at": 700, "ms": 1400, "every": 100 } }
9
+ //
10
+ // selector@n picks the n-th match (default 0). Props: opacity, tx, ty, sx, sy, rot, top, left, width, height, saturate.
11
+ // Viewport-independent ranges: "scroll": { "of": { "ref": "...", "clone": "..." }, "start": "top bottom", "end": "bottom top", "steps": 10 }
12
+ // resolve each side's own scroll positions, so one map scores desktop, tablet and mobile (--viewport tablet).
13
+ // usage: node scripts/curves.mjs <name> [--viewport desktop] [--only "<name substring>"]
14
+ // node scripts/curves.mjs <name> --draft (writes motion-map.draft.json from reference/motion.<vp>.json)
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, wheelTo, withTimeout, cached, readCache } from './lib.mjs';
18
+
19
+ const { pos, flags } = parseArgs(process.argv.slice(2));
20
+ const [name] = pos;
21
+ if (!name) {
22
+ console.error('usage: npm run curves -- <name> [--viewport desktop] [--only text] | --draft');
23
+ process.exit(1);
24
+ }
25
+ const root = path.resolve('sites', name);
26
+ const ref = path.join(root, 'reference');
27
+ const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
28
+ const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
29
+
30
+ // ---------- Draft a map from the motion inventory ----------
31
+ if (flags.draft) {
32
+ const motion = JSON.parse(await fs.readFile(path.join(ref, `motion.${vp.name}.json`), 'utf8'));
33
+ const propsFor = (vars) => {
34
+ const out = new Set();
35
+ for (const k of Object.keys(vars || {})) {
36
+ if (['y', 'yPercent'].includes(k)) out.add('ty');
37
+ if (['x', 'xPercent'].includes(k)) out.add('tx');
38
+ if (['scale', 'scaleY'].includes(k)) out.add('sy');
39
+ if (k === 'scaleX') out.add('sx');
40
+ if (['rotation', 'rotate', 'rotationZ'].includes(k)) out.add('rot');
41
+ if (['opacity', 'autoAlpha'].includes(k)) out.add('opacity');
42
+ if (k === 'width') out.add('width');
43
+ if (k === 'height') out.add('height');
44
+ if (k === 'filter') out.add('saturate');
45
+ }
46
+ return [...out];
47
+ };
48
+ const tweensOf = (a) => (!a ? [] : a.type === 'timeline' ? a.children.flatMap(tweensOf) : [a]);
49
+ const entries = [];
50
+ const seen = new Set();
51
+ for (const st of motion.triggers) {
52
+ if (st.scrub === false || !st.animation) continue;
53
+ for (const tw of tweensOf(st.animation)) {
54
+ const sel = tw.targets[0]?.sel;
55
+ const props = propsFor({ ...tw.vars, ...(tw.vars.startAt || {}) });
56
+ if (!sel || !props.length || seen.has(sel + props)) continue;
57
+ seen.add(sel + props);
58
+ const pos = (v) => String(v).replace(/^clamp((.*))$/, '$1');
59
+ entries.push({ name: `${st.trigger.section || ''} ${sel}`.trim(), ref: sel, clone: 'TODO', props, scroll: { of: { ref: st.trigger.sel, clone: 'TODO' }, start: pos(st.start), end: pos(st.end), steps: 10 } });
60
+ }
61
+ }
62
+ for (const tw of motion.onScroll.slice(0, 40)) {
63
+ const leaves = tweensOf(tw).filter((t) => t.targets.length && propsFor({ ...t.vars, ...(t.vars.startAt || {}) }).length);
64
+ const leaf = leaves.find((t) => t.duration > 0) || leaves[0];
65
+ if (!leaf) continue;
66
+ const sel = leaf.targets[0].sel;
67
+ if (seen.has('time' + sel)) continue;
68
+ seen.add('time' + sel);
69
+ const y = tw.firedAt[0];
70
+ entries.push({ name: `reveal ${sel}`, ref: sel, clone: 'TODO', props: propsFor({ ...leaf.vars, ...(leaf.vars.startAt || {}) }), time: { before: Math.max(0, y - vp.height), at: y, ms: Math.ceil((tw.duration + 0.4) * 10) * 100, every: 100 } });
71
+ }
72
+ const out = path.join(root, 'motion-map.draft.json');
73
+ await fs.writeFile(out, JSON.stringify(entries, null, 2));
74
+ console.log(`${entries.length} entries -> ${path.relative(process.cwd(), out)}. Fill in the clone selectors, then save as motion-map.json.`);
75
+ process.exit(0);
76
+ }
77
+
78
+ // ---------- Sampling ----------
79
+ // No map = no scroll/time-driven motion to score (static sites): say so instead of failing the score run.
80
+ const mapText = await fs.readFile(path.join(root, 'motion-map.json'), 'utf8').catch(() => null);
81
+ if (mapText === null) {
82
+ console.log(`motion score (${vp.name}): skipped, no motion-map.json (run --draft if the motion inventory lists scroll or time animations)`);
83
+ process.exit(0);
84
+ }
85
+ let map = JSON.parse(mapText);
86
+ if (typeof flags.only === 'string') map = map.filter((e) => e.name.includes(flags.only));
87
+ map = map.filter((e) => e.clone && e.clone !== 'TODO' && (!e.viewports || e.viewports.includes(vp.name)));
88
+
89
+ function sampleInPage([selector, props]) {
90
+ const [sel, idx] = selector.split('@');
91
+ const el = document.querySelectorAll(sel)[Number(idx || 0)];
92
+ if (!el) return null;
93
+ const s = getComputedStyle(el);
94
+ const m = new DOMMatrixReadOnly(s.transform === 'none' ? undefined : s.transform);
95
+ const r = el.getBoundingClientRect();
96
+ const sat = /saturate\(([\d.]+)\)/.exec(s.filter);
97
+ const all = {
98
+ opacity: +s.opacity * (s.visibility === 'hidden' ? 0 : 1),
99
+ tx: m.m41, ty: m.m42,
100
+ sx: Math.hypot(m.m11, m.m12), sy: Math.hypot(m.m21, m.m22),
101
+ rot: (Math.atan2(m.m12, m.m11) * 180) / Math.PI,
102
+ top: r.top, left: r.left, width: r.width, height: r.height,
103
+ saturate: sat ? +sat[1] : 1,
104
+ };
105
+ return Object.fromEntries(props.map((p) => [p, all[p]]));
106
+ }
107
+
108
+ // Settle on the sampled element only: poll its values until they stop changing (scrub lag, smooth scroll),
109
+ // typically 0.3-0.8s, instead of waiting for the whole page (which never stops on sites with loops).
110
+ async function settleSample(page, selector, props) {
111
+ let last = null, same = 0, v = null;
112
+ const t0 = Date.now();
113
+ while (Date.now() - t0 < 2500) {
114
+ v = await withTimeout(page.evaluate(sampleInPage, [selector, props]), 15000, `sample ${selector}`);
115
+ const key = JSON.stringify(v && Object.fromEntries(Object.entries(v).map(([k, x]) => [k, Math.round(x * 10) / 10])));
116
+ same = key === last ? same + 1 : 0;
117
+ last = key;
118
+ if (same >= 2) break;
119
+ await page.waitForTimeout(70);
120
+ }
121
+ return v;
122
+ }
123
+
124
+ async function run(url, side, entries, onEntry) {
125
+ if (!entries.length) return [];
126
+ const browser = await openBrowser(flags.cdp);
127
+ const results = [];
128
+ const done = async (e, series) => {
129
+ const result = { name: e.name, selector: side === 'ref' ? e.ref : e.clone, series };
130
+ results.push(result);
131
+ if (onEntry) await onEntry(result); // saved immediately: a killed run keeps what it measured
132
+ };
133
+ try {
134
+ const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
135
+ await page.goto(url, { waitUntil: 'load', timeout: 60000 });
136
+ await page.mouse.move(5, vp.height - 5);
137
+ await waitStill(page, { timeout: 15000, interval: 200 });
138
+ await warmUp(page);
139
+ // Scroll entries: resolve every range first (at the top, before anything is pinned), then visit all sample
140
+ // positions of all curves in one downward pass, so the page glides short distances and never rewinds.
141
+ const resolve = (of, spec) => page.evaluate(([sel, s]) => {
142
+ const [selector, idx] = sel.split('@');
143
+ const el = document.querySelectorAll(selector)[Number(idx || 0)];
144
+ if (!el) return 0;
145
+ const r = el.getBoundingClientRect();
146
+ const top = r.top + scrollY;
147
+ const at = (word, size, base) => (word === 'top' ? base : word === 'bottom' ? base + size : word === 'center' ? base + size / 2 : base + (parseFloat(word) / 100) * size);
148
+ const [elWord, vpWord] = s.split(' ');
149
+ return Math.max(0, Math.round(at(elWord, r.height, top) - at(vpWord, innerHeight, 0)));
150
+ }, [of, spec]);
151
+ await wheelTo(page, 0, vp);
152
+ const plan = [];
153
+ const series = new Map();
154
+ const scrollEntries = entries.filter((e) => e.scroll);
155
+ for (const e of scrollEntries) {
156
+ const { steps = 10 } = e.scroll;
157
+ // Ranges can be absolute ({from, to}) or relative to an element, ScrollTrigger-style:
158
+ // { of: {ref, clone}, start: "top bottom", end: "bottom top" } -> resolved per side and per viewport.
159
+ let { from, to } = e.scroll;
160
+ if (e.scroll.of) {
161
+ from = await resolve(e.scroll.of[side], e.scroll.start || 'top bottom');
162
+ to = await resolve(e.scroll.of[side], e.scroll.end || 'bottom top');
163
+ }
164
+ series.set(e, new Array(steps + 1));
165
+ for (let i = 0; i <= steps; i++) plan.push({ e, i, y: Math.round(from + ((to - from) * i) / steps) });
166
+ }
167
+ plan.sort((p, q) => p.y - q.y);
168
+ const left = new Map(scrollEntries.map((e) => [e, (e.scroll.steps ?? 10) + 1]));
169
+ let at = null;
170
+ for (const p of plan) {
171
+ if (at === null || Math.abs(p.y - at) >= 2) at = await wheelTo(page, p.y, vp);
172
+ series.get(p.e)[p.i] = { x: p.y, v: await settleSample(page, side === 'ref' ? p.e.ref : p.e.clone, p.e.props) };
173
+ left.set(p.e, left.get(p.e) - 1);
174
+ if (!left.get(p.e)) await done(p.e, series.get(p.e));
175
+ }
176
+ for (const e of entries.filter((x) => x.time)) {
177
+ const selector = side === 'ref' ? e.ref : e.clone;
178
+ const samples = [];
179
+ await wheelTo(page, e.time.before, vp);
180
+ await waitStill(page, { timeout: 4000 });
181
+ await page.evaluate((y) => window.scrollTo(0, y), e.time.at); // jump: the reveal starts now
182
+ const t0 = Date.now();
183
+ for (let t = 0; t <= e.time.ms; t += e.time.every) {
184
+ const wait = t - (Date.now() - t0);
185
+ if (wait > 0) await page.waitForTimeout(wait);
186
+ samples.push({ x: t, v: await withTimeout(page.evaluate(sampleInPage, [selector, e.props]), 15000, `sample ${e.name}`) });
187
+ }
188
+ await done(e, samples);
189
+ }
190
+ await context.close();
191
+ } finally {
192
+ if (!flags.cdp) await browser.close();
193
+ }
194
+ return results;
195
+ }
196
+
197
+ // --url scores another build of the clone (e.g. the Next.js app) instead of serving clone/
198
+ const server = typeof flags.url === 'string' ? null : await serve(path.join(root, 'clone'));
199
+ const cloneUrl = server ? server.url : flags.url;
200
+ // Reference curves are cached per entry (keyed by the entry's reference side, viewport and capture time):
201
+ // only new or edited entries touch the live site. --refresh-ref measures everything again. Bump v when the
202
+ // sampling method changes, so reference and clone are always measured the same way.
203
+ const refKey = (e) => ({ tool: 'curves', v: 2, vp: vp.name, ref: e.ref, props: e.props, scroll: e.scroll && { ...e.scroll, of: e.scroll.of?.ref }, time: e.time });
204
+ const refCache = new Map();
205
+ const missing = [];
206
+ for (const e of map) {
207
+ const hit = flags['refresh-ref'] ? null : await readCache(root, refKey(e));
208
+ if (hit) refCache.set(e.name, hit); else missing.push(e);
209
+ }
210
+ console.log(missing.length ? `measuring ${missing.length} reference curve(s) (${map.length - missing.length} cached)` : `reference: all ${map.length} curves cached`);
211
+ // the reference (if anything is missing) and the clone are measured at the same time, in separate browsers
212
+ const [, cloneResults] = await Promise.all([
213
+ run(meta.url, 'ref', missing, async (r) => {
214
+ const e = missing.find((m) => m.name === r.name);
215
+ await cached(root, refKey(e), async () => r, true);
216
+ refCache.set(r.name, r);
217
+ }),
218
+ run(cloneUrl, 'clone', map),
219
+ ]);
220
+ const cloneByName = new Map(cloneResults.map((r) => [r.name, r]));
221
+ server?.close();
222
+ const refRuns = map.map((e) => refCache.get(e.name));
223
+ const cloneRuns = map.map((e) => cloneByName.get(e.name));
224
+
225
+ // ---------- Compare ----------
226
+ // Error per property = mean |ref - clone| over the samples, normalised by the reference's range of motion
227
+ // (or by a floor: 1 for opacity/scale/saturate, 10deg, 40px). Score = 100 * (1 - error), floored at 0.
228
+ const FLOOR = { opacity: 1, sx: 1, sy: 1, saturate: 1, rot: 10 };
229
+ const report = { name, viewport: vp.name, at: new Date().toISOString(), entries: [] };
230
+ for (const [i, e] of map.entries()) {
231
+ const a = refRuns[i].series, b = cloneRuns[i].series;
232
+ const missing = a.every((s) => !s.v) ? 'ref' : b.every((s) => !s.v) ? 'clone' : null;
233
+ if (missing) { report.entries.push({ name: e.name, score: 0, note: `selector matched nothing on ${missing}` }); continue; }
234
+ const props = e.props.map((p) => {
235
+ const ra = a.map((s) => s.v?.[p] ?? 0), rb = b.map((s) => s.v?.[p] ?? 0);
236
+ const range = Math.max(...ra) - Math.min(...ra);
237
+ const norm = Math.max(range, FLOOR[p] ?? 40);
238
+ const err = ra.reduce((n, v, k) => n + Math.abs(v - rb[k]), 0) / ra.length / norm;
239
+ return { prop: p, score: +Math.max(0, 100 * (1 - err)).toFixed(1), ref: ra.map((v) => +v.toFixed(2)), clone: rb.map((v) => +v.toFixed(2)) };
240
+ });
241
+ report.entries.push({ name: e.name, mode: e.scroll ? 'scroll' : 'time', score: +(props.reduce((n, p) => n + p.score, 0) / props.length).toFixed(1), props, x: a.map((s) => s.x) });
242
+ }
243
+ report.score = +(report.entries.reduce((n, e) => n + e.score, 0) / Math.max(1, report.entries.length)).toFixed(1);
244
+ const out = path.join(root, 'compare', `curves.${vp.name}.json`);
245
+ await fs.mkdir(path.dirname(out), { recursive: true });
246
+ await fs.writeFile(out, JSON.stringify(report, null, 2));
247
+
248
+ const spark = (vals) => { const lo = Math.min(...vals), hi = Math.max(...vals); const bars = '▁▂▃▄▅▆▇█'; return vals.map((v) => bars[hi === lo ? 0 : Math.round(((v - lo) / (hi - lo)) * 7)]).join(''); };
249
+ console.log(`motion score (${vp.name}): ${report.score}% over ${report.entries.length} curves`);
250
+ for (const e of [...report.entries].sort((x, y) => x.score - y.score)) {
251
+ console.log(`${String(e.score).padStart(5)}% ${e.name}${e.note ? ` (${e.note})` : ''}`);
252
+ for (const p of e.props || []) if (p.score < 90) console.log(` ${p.prop.padEnd(8)} ref ${spark(p.ref)} clone ${spark(p.clone)} (${p.ref[0]}..${p.ref[p.ref.length - 1]} vs ${p.clone[0]}..${p.clone[p.clone.length - 1]})`);
253
+ }
254
+ console.log(`-> ${path.relative(process.cwd(), out)}`);