@trueclone/cli 0.3.0 → 0.4.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 +24 -32
- package/bin/trueclone.mjs +23 -19
- package/package.json +2 -1
- package/scripts/capture.mjs +136 -24
- package/scripts/curves.mjs +44 -7
- package/scripts/diff.mjs +4 -2
- package/scripts/drift.mjs +21 -14
- package/scripts/hovers.mjs +137 -45
- package/scripts/interact.mjs +35 -5
- package/scripts/lib.mjs +69 -5
- package/scripts/load.mjs +12 -8
- package/scripts/next.mjs +74 -9
- package/scripts/report.mjs +46 -12
- package/scripts/reveals.mjs +4 -0
- package/scripts/review.mjs +149 -0
- package/scripts/score.mjs +4 -1
- package/scripts/scripts-evidence.mjs +116 -0
- package/scripts/seed.mjs +188 -115
- package/scripts/states.mjs +38 -5
- package/scripts/tiles.mjs +33 -17
- package/scripts/viewport-fit.mjs +198 -0
- package/skill/SKILL.md +119 -145
package/scripts/seed.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import fs from 'node:fs/promises';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { parseArgs, openBrowser, newPage, serve, VIEWPORTS } from './lib.mjs';
|
|
16
|
+
import { planInlineStyles } from './viewport-fit.mjs';
|
|
16
17
|
|
|
17
18
|
// placeholder vocabulary, wide enough to match most word widths
|
|
18
19
|
const LOREM_TEXT = `lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore
|
|
@@ -56,8 +57,6 @@ verbum vereor vero verto verus vester veto vetus via video vinco vinum vir virgo
|
|
|
56
57
|
voluntas vos votum vox vulnus vultus`;
|
|
57
58
|
const LOREM = LOREM_TEXT.split(/\s+/).filter(Boolean);
|
|
58
59
|
|
|
59
|
-
// The player's width classes (see PLAYER) as media queries: the same three widths the reference was captured at.
|
|
60
|
-
const WIDTH_MEDIA = { desktop: '(min-width: 1100px)', tablet: '(min-width: 600px) and (max-width: 1099px)', mobile: '(max-width: 599px)' };
|
|
61
60
|
|
|
62
61
|
// Reveal player shipped with the clone (trueclone's own code): replays each recorded curve with the Web Animations
|
|
63
62
|
// API once the element's top crosses the recorded trigger line; with reduced motion it shows the end state.
|
|
@@ -71,7 +70,14 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
|
|
|
71
70
|
const classData = Array.isArray(raw) ? [] : raw.classes || [];
|
|
72
71
|
const scrubData = Array.isArray(raw) ? [] : raw.scrubs || [];
|
|
73
72
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
74
|
-
|
|
73
|
+
// the record for the visitor's width: a recorded size in the same range of the site's own breakpoints (the nearest
|
|
74
|
+
// one), otherwise the nearest recorded size
|
|
75
|
+
const bps = raw.breakpoints || [];
|
|
76
|
+
const rangeOf = (w) => bps.filter((b) => w > b).length;
|
|
77
|
+
const seedSizes = raw.sizes || [{ name: 'mobile', width: 390 }, { name: 'tablet', width: 768 }, { name: 'desktop', width: 1440 }];
|
|
78
|
+
const W = document.documentElement.clientWidth;
|
|
79
|
+
const sameRange = seedSizes.filter((x) => rangeOf(x.width) === rangeOf(W));
|
|
80
|
+
const vpName = (sameRange.length ? sameRange : seedSizes).reduce((a, b) => (Math.abs(b.width - W) < Math.abs(a.width - W) ? b : a)).name;
|
|
75
81
|
const pick = (entry) => (entry && (entry.desktop || entry.tablet || entry.mobile) ? entry[vpName] || entry.desktop || entry.tablet || entry.mobile : entry);
|
|
76
82
|
|
|
77
83
|
// class changes are state, not motion: they apply with reduced motion too
|
|
@@ -189,9 +195,20 @@ if (!(await fs.stat(path.join(mirror, 'index.html')).then(() => true, () => fals
|
|
|
189
195
|
process.exit(1);
|
|
190
196
|
}
|
|
191
197
|
const clone = path.join(root, 'clone');
|
|
198
|
+
const t0 = Date.now();
|
|
199
|
+
const step = (msg) => console.log(`seed ${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s ${msg}`);
|
|
200
|
+
// --patches: re-apply clone/patches/ onto the last seed output in a second, without seeding again
|
|
201
|
+
if (flags.patches) {
|
|
202
|
+
const base = await fs.readFile(path.join(clone, '.seed-base.html'), 'utf8').catch(() => null);
|
|
203
|
+
if (base === null) { console.error(`no seed output to patch in ${path.relative(process.cwd(), clone)}: run trueclone seed ${name} first`); process.exit(1); }
|
|
204
|
+
const { html: patched, applied } = await applyPatches(base);
|
|
205
|
+
await fs.writeFile(path.join(clone, 'index.html'), patched);
|
|
206
|
+
console.log(`patches re-applied onto the last seed (no re-seed): ${applied.join(', ') || 'none in clone/patches/'}`);
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
192
209
|
const exists = await fs.stat(path.join(clone, 'index.html')).then(() => true, () => false);
|
|
193
210
|
if (exists && !flags.force) {
|
|
194
|
-
console.error(`${path.relative(process.cwd(), clone)} already has an index.html
|
|
211
|
+
console.error(`${path.relative(process.cwd(), clone)} already has an index.html: add --force to seed again (your script.js, patches/ and notes.md are kept), or --patches to only re-apply clone/patches/`);
|
|
195
212
|
process.exit(1);
|
|
196
213
|
}
|
|
197
214
|
await fs.mkdir(path.join(clone, 'css'), { recursive: true });
|
|
@@ -231,6 +248,7 @@ for (const file of await fs.readdir(path.join(mirror, 'css')).catch(() => [])) {
|
|
|
231
248
|
if (!exact) out = out.replace(/url\(\s*(['"]?)(?!data:)([^'")]+)\1\s*\)/g, (all, q, u) => (isFont(u) || u.trim().startsWith('#') ? all : `url("../${PLACEHOLDER_BG}")`));
|
|
232
249
|
await fs.writeFile(path.join(clone, 'css', file), out);
|
|
233
250
|
}
|
|
251
|
+
step(`stylesheets copied (${exact ? 'exact' : 'template'} mode)`);
|
|
234
252
|
let html = await fs.readFile(path.join(mirror, 'index.html'), 'utf8');
|
|
235
253
|
html = await rewrite(html, '../', '', !exact);
|
|
236
254
|
await fs.writeFile(path.join(clone, 'index.html'), html);
|
|
@@ -260,13 +278,17 @@ const byPath = (key) => {
|
|
|
260
278
|
return [...m.values()];
|
|
261
279
|
};
|
|
262
280
|
const recorded = byPath('reveals');
|
|
263
|
-
|
|
281
|
+
// Scroll-linked curves (parallax, scrubbed fades) are recorded px at three sizes: replaying them would be right at those
|
|
282
|
+
// sizes only. They stay in reference/reveals.<size>.json as evidence; script.js reproduces the rule behind them.
|
|
283
|
+
const recordedScrubs = byPath('scrubs');
|
|
284
|
+
const scrubbed = [];
|
|
264
285
|
const classChanges = primary?.classes || [];
|
|
265
286
|
// Widget states recorded on the reference (trueclone widgets): tabs, menus. Template mode only for now: the snapshots
|
|
266
287
|
// are placeholdered like the page; exact-mode snapshots would still point at the site's media.
|
|
267
288
|
const widgetRec = exact ? null : JSON.parse(await fs.readFile(path.join(ref, 'widgets.desktop.json'), 'utf8').catch(() => 'null'));
|
|
268
289
|
const widgetGroups = widgetRec?.groups || [];
|
|
269
290
|
let marked = 0, classMarked = 0, scrubMarked = 0, widgets = null, runtime = null;
|
|
291
|
+
step('browser pass: reveal marks, widgets, runtime clean-up' + (exact ? '' : ', placeholder text and media'));
|
|
270
292
|
({ html, marked, classMarked, scrubMarked, widgets, runtime } = await browserPass(!exact, recorded, classChanges, widgetGroups, scrubbed));
|
|
271
293
|
if (!exact) html = html.split(REF_ASSETS).join('__tc_ref__').replace(/__tc_ref__[^"')\s]+/g, PLACEHOLDER);
|
|
272
294
|
if (widgets?.groups.length) {
|
|
@@ -279,7 +301,9 @@ if (marked || classMarked || scrubMarked) {
|
|
|
279
301
|
const perWidth = (r, shape) => Object.fromEntries(['desktop', 'tablet', 'mobile'].filter((v) => r[v]).map((v) => [v, shape(r[v])]));
|
|
280
302
|
const data = JSON.stringify({
|
|
281
303
|
reveals: recorded.map((r) => perWidth(r, ({ trigger, duration, frames, to, from }) => ({ trigger, duration, frames, to, from }))),
|
|
282
|
-
scrubs:
|
|
304
|
+
scrubs: [],
|
|
305
|
+
breakpoints: JSON.parse(await fs.readFile(path.join(ref, 'inline-styles.json'), 'utf8').catch(() => '{}')).breakpoints || [],
|
|
306
|
+
sizes: VIEWPORTS.map((v) => ({ name: v.name, width: v.width })),
|
|
283
307
|
classes: classChanges.map(({ from, to, back, sticky, reverts, threshold, trigger }) => ({ from, to, back, sticky, reverts, threshold, trigger })),
|
|
284
308
|
});
|
|
285
309
|
html = html.replace(/<\/body>/i, `<script type="application/json" id="tc-reveals">${data}</script>\n<script src="trueclone-reveals.js" defer></script>\n</body>`);
|
|
@@ -292,22 +316,12 @@ html = html.replace(/<html\b([^>]*)>/i, (all, attrs) => (/\bclass=/.test(attrs)
|
|
|
292
316
|
html = html.replace(/<\/head>/i, '<style id="tc-boot-styles">/* trueclone seed: boot contract */@keyframes tc-boot-failsafe{to{visibility:visible}}html.tc-boot [data-tc-boot-hide]{visibility:hidden;animation:tc-boot-failsafe 0s 3s forwards}</style>\n</head>');
|
|
293
317
|
// the clone's own behaviour script, loaded once the DOM is parsed
|
|
294
318
|
if (!/<script[^>]+src="script\.js"/.test(html)) html = html.replace(/<\/body>/i, '<script src="script.js" defer></script>\n</body>');
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// body-*.html inserted before the clone's own <script src="script.js"> (vendor libraries, a hand-over prelude)
|
|
300
|
-
// *.js <script src defer> before script.js (runs after the players, before script.js)
|
|
301
|
-
const patchDir = path.join(clone, 'patches');
|
|
302
|
-
const patches = (await fs.readdir(patchDir).catch(() => [])).filter((f) => /\.(html|css|js)$/i.test(f)).sort();
|
|
303
|
-
const beforeScript = (s) => (/<script[^>]+src="script\.js"/.test(html) ? html.replace(/<script[^>]+src="script\.js"[^>]*><\/script>/i, (tag) => `${s}\n${tag}`) : html.replace(/<\/body>/i, `${s}\n</body>`));
|
|
304
|
-
for (const f of patches) {
|
|
305
|
-
if (/^head-.*\.html$/i.test(f)) html = html.replace(/<\/head>/i, `${await fs.readFile(path.join(patchDir, f), 'utf8')}\n</head>`);
|
|
306
|
-
else if (/\.css$/i.test(f)) html = html.replace(/<\/head>/i, `<link rel="stylesheet" href="patches/${f}">\n</head>`);
|
|
307
|
-
else if (/\.html$/i.test(f)) html = beforeScript(await fs.readFile(path.join(patchDir, f), 'utf8'));
|
|
308
|
-
else if (/\.js$/i.test(f)) html = beforeScript(`<script src="patches/${f}" defer></script>`);
|
|
309
|
-
}
|
|
319
|
+
// the seed's own output, kept so `seed --patches` can re-apply clone/patches/ onto it in a second
|
|
320
|
+
await fs.writeFile(path.join(clone, '.seed-base.html'), html);
|
|
321
|
+
const { html: patchedHtml, applied: patches } = await applyPatches(html);
|
|
322
|
+
html = patchedHtml;
|
|
310
323
|
await fs.writeFile(path.join(clone, 'index.html'), html);
|
|
324
|
+
step('index.html written');
|
|
311
325
|
|
|
312
326
|
// notes.md: the agent's record, with the sections the report reads back (Findings, Accepted differences). Written once.
|
|
313
327
|
const notesFile = path.join(root, 'notes.md');
|
|
@@ -350,11 +364,12 @@ if (!(await fs.stat(scriptFile).then(() => true, () => false))) {
|
|
|
350
364
|
await fs.writeFile(scriptFile, `// ${meta.url} - clone behaviour (your own code). Markup and styles come from the mirror${exact ? '' : ' (placeholder content)'}.\n// Rebuild every interaction and reveal from the reference's parameters; verify with npx trueclone score ${name}.\n`);
|
|
351
365
|
}
|
|
352
366
|
console.log(`seeded ${path.relative(process.cwd(), clone)} from the mirror (${exact ? 'exact: the site\'s own content' : 'template: placeholder text and media'}): index.html, ${(await fs.readdir(path.join(clone, 'css'))).length} stylesheet(s), ${copied.size} asset(s)${exact ? '' : ' (fonts only)'}`);
|
|
353
|
-
if (recorded.length || classChanges.length
|
|
367
|
+
if (recorded.length || classChanges.length) console.log(`replayed by trueclone-reveals.js: ${marked}/${recorded.length} one-shot scroll reveals, ${classMarked}/${classChanges.length} scroll class changes (${Object.keys(recs).join(', ') || 'no'} sizes recorded)${marked < recorded.length || classMarked < classChanges.length ? ' (the rest did not resolve to an element in the mirror)' : ''}`);
|
|
368
|
+
if (recordedScrubs.length) console.log(`not replayed: ${recordedScrubs.length} scroll-linked curve(s) (parallax, scrubbed fades): evidence in reference/reveals.<size>.json; script.js reproduces the rule behind them`);
|
|
354
369
|
if (runtime) {
|
|
355
|
-
const pins = runtime.pins.map((p) => `${p.el} pinned for ${p.distance}px`).join(', ');
|
|
370
|
+
const pins = runtime.pins.map((p) => `${p.el} pinned for ${p.distance}px${p.axis ? ' (sideways: left to script.js)' : ''}`).join(', ');
|
|
356
371
|
console.log(`runtime artefacts cleaned: ${runtime.pins.length} GSAP pin(s) made sticky${pins ? ` (${pins}; data-tc-pin on each)` : ''}, ${runtime.canvases} canvas(es) marked data-tc-runtime="canvas"`);
|
|
357
|
-
if (runtime.
|
|
372
|
+
if (runtime.viewport) console.log(`viewport values: ${runtime.viewport.fitted} value(s) a script computes from the screen size carried as functions of it (read at ${runtime.viewport.sizes} sizes, site breakpoints ${runtime.viewport.breakpoints.join('/') || 'none'})${runtime.viewport.dropped ? `; ${runtime.viewport.dropped} fit no such function and are left to script.js (data-tc-script-owned, reference/script-owned.json)` : ''}`);
|
|
358
373
|
if (runtime.hidden) console.log(`boot contract: ${runtime.hidden} element(s) hidden until the clone's scripts have run (data-tc-boot-hide)`);
|
|
359
374
|
}
|
|
360
375
|
if (widgetGroups.length) console.log(`replayed by trueclone-widgets.js: ${widgets?.groups.length || 0}/${widgetGroups.length} widgets (${widgets?.states || 0} recorded states)${widgetRec?.portals?.length ? `; not replayed (portal popovers): ${widgetRec.portals.join(', ')}` : ''}`);
|
|
@@ -375,6 +390,7 @@ async function browserPass(template, reveals, classes, groups = [], scrubs = [])
|
|
|
375
390
|
// placing it into its region (so it is laid out and measured for real) and reading it back
|
|
376
391
|
let widgets = null;
|
|
377
392
|
if (groups.length) {
|
|
393
|
+
step(`widgets: ${groups.length} recorded, placing each state`);
|
|
378
394
|
const ok = await page.evaluate(markWidgetsInPage, groups);
|
|
379
395
|
await page.evaluate(`window.__tcPlaceholders = ${placeholdersInPage.toString()}`);
|
|
380
396
|
widgets = { groups: [], states: 0 };
|
|
@@ -397,7 +413,11 @@ async function browserPass(template, reveals, classes, groups = [], scrubs = [])
|
|
|
397
413
|
}
|
|
398
414
|
widgets.groups = Array.from(widgets.groups, (x) => x || null);
|
|
399
415
|
}
|
|
400
|
-
if (template)
|
|
416
|
+
if (template) {
|
|
417
|
+
step('template placeholders: measuring every text run (the longest part on a big page)');
|
|
418
|
+
await page.evaluate(placeholdersInPage, [LOREM, null]);
|
|
419
|
+
step('placeholders done');
|
|
420
|
+
}
|
|
401
421
|
// last, because the recorded paths above were taken on the live page, pin-spacers included
|
|
402
422
|
const runtime = await page.evaluate(cleanRuntimeInPage);
|
|
403
423
|
// regions the clone's script builds at boot, hidden until it has run (see the boot contract below)
|
|
@@ -405,8 +425,14 @@ async function browserPass(template, reveals, classes, groups = [], scrubs = [])
|
|
|
405
425
|
? await page.evaluate((sels) => { let n = 0; for (const s of sels) for (const el of document.querySelectorAll(s)) { el.setAttribute('data-tc-boot-hide', ''); n++; } return n; }, flags['hide-until-boot'].split(',').map((s) => s.trim()).filter(Boolean))
|
|
406
426
|
: 0;
|
|
407
427
|
// inline styles that differ between widths (script-computed sizes and offsets) become media-query rules
|
|
428
|
+
// inline styles a script set: functions of the viewport where they are, left to script.js where they aren't
|
|
408
429
|
const inlineStyles = JSON.parse(await fs.readFile(path.join(ref, 'inline-styles.json'), 'utf8').catch(() => 'null'));
|
|
409
|
-
|
|
430
|
+
if (inlineStyles) {
|
|
431
|
+
const { plan, owned, fitted, sizes, breakpoints } = planInlineStyles(inlineStyles, VIEWPORTS);
|
|
432
|
+
const applied = await page.evaluate(applyInlineStylePlanInPage, plan);
|
|
433
|
+
runtime.viewport = { fitted, sizes, breakpoints, elements: applied.elements, dropped: applied.dropped };
|
|
434
|
+
await fs.writeFile(path.join(ref, 'script-owned.json'), JSON.stringify({ note: 'inline values a script computes that are not a function of the screen size: script.js must compute them, as the site does', owned: owned.filter((o) => applied.droppedPaths.includes(o.path)) }, null, 2));
|
|
435
|
+
}
|
|
410
436
|
// getHTML keeps the declarative shadow roots the capture serialised (outerHTML would drop them); it is the inner
|
|
411
437
|
// HTML, so the <html> tag and its attributes (font classes, dark mode, lang) are written back around it
|
|
412
438
|
const out = await page.evaluate(() => {
|
|
@@ -536,13 +562,12 @@ function markRevealsInPage([reveals, classes, scrubs = []]) {
|
|
|
536
562
|
return [count, classCount, scrubCount];
|
|
537
563
|
}
|
|
538
564
|
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
|
|
545
|
-
function widthStylesInPage([styles, media]) {
|
|
565
|
+
// The plan from viewport-fit.mjs, applied to the page: fitted values replace the captured constants inline; per-range
|
|
566
|
+
// values become media rules whose selector is stacked to the precedence an inline style had over the stylesheets
|
|
567
|
+
// (without !important, so a value script.js writes inline later still wins); values no function fits are removed and
|
|
568
|
+
// left to script.js. A pin spacer's distance becomes its --tc-pin. Motion properties of elements the reveal player
|
|
569
|
+
// drives, and the pin geometry the seed removed, are left alone.
|
|
570
|
+
function applyInlineStylePlanInPage(plan) {
|
|
546
571
|
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
547
572
|
const resolve = (p) => {
|
|
548
573
|
let el = document.body;
|
|
@@ -554,70 +579,59 @@ function widthStylesInPage([styles, media]) {
|
|
|
554
579
|
}
|
|
555
580
|
return el;
|
|
556
581
|
};
|
|
557
|
-
const decls = (text) => {
|
|
558
|
-
const map = new Map();
|
|
559
|
-
for (const d of String(text || '').split(/;(?![^(]*\))/)) {
|
|
560
|
-
const i = d.indexOf(':');
|
|
561
|
-
if (i < 0) continue;
|
|
562
|
-
const k = d.slice(0, i).trim(), v = d.slice(i + 1).trim().replace(/\s*!important$/i, '');
|
|
563
|
-
if (k) map.set(k, v);
|
|
564
|
-
}
|
|
565
|
-
return map;
|
|
566
|
-
};
|
|
567
582
|
const MOTION = new Set(['transform', 'translate', 'rotate', 'scale', 'opacity', 'filter']);
|
|
568
583
|
const PIN = new Set(['translate', 'rotate', 'scale', 'inset', 'top', 'left', 'right', 'bottom', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'max-width', 'width', 'max-height', 'height', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'box-sizing', 'position', 'transform', 'flex-basis']);
|
|
569
|
-
|
|
570
|
-
const
|
|
584
|
+
// the bottom (or right) length of a padding shorthand, calc() kept whole
|
|
585
|
+
const splitTop = (v) => { const out = []; let depth = 0, cur = ''; for (const ch of String(v)) { if (ch === '(') depth++; if (ch === ')') depth--; if (ch === ' ' && !depth) { if (cur) out.push(cur); cur = ''; } else cur += ch; } if (cur) out.push(cur); return out; };
|
|
586
|
+
const pinOf = (prop, v) => { if (prop === 'padding-bottom') return v; if (prop !== 'padding') return null; const t = splitTop(v); return t[2] ?? t[0]; };
|
|
571
587
|
const rules = [];
|
|
572
588
|
const sel = (i) => `[data-tc-w="${i}"]`.repeat(8); // (0,8,0): above any ordinary author rule, below inline
|
|
573
|
-
let count = 0;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const all = new Set(widths.flatMap((w) => [...per[w].keys()]));
|
|
577
|
-
const differs = [...all].filter((k) => widths.some((w) => per[w].get(k) !== per[widths[0]].get(k)));
|
|
578
|
-
if (!differs.length) continue;
|
|
589
|
+
let count = 0, dropped = 0;
|
|
590
|
+
const droppedPaths = [];
|
|
591
|
+
for (const [p, entry] of Object.entries(plan)) {
|
|
579
592
|
const el = resolve(p);
|
|
580
593
|
if (!el) continue;
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
if (widths.every((w) => d[w] === d[widths[0]])) continue;
|
|
589
|
-
const i = count++;
|
|
590
|
-
el.setAttribute('data-tc-w', String(i));
|
|
591
|
-
for (const w of widths) {
|
|
592
|
-
el.firstElementChild?.setAttribute(`data-tc-pin-${w}`, String(d[w]));
|
|
593
|
-
rules.push(`@media ${media[w]} { ${sel(i)} { ${horizontal ? 'padding-right' : 'padding-bottom'}: ${d[w]}px } }`);
|
|
594
|
-
}
|
|
594
|
+
const i = count;
|
|
595
|
+
let used = false;
|
|
596
|
+
if (el.hasAttribute('data-tc-pin-spacer')) {
|
|
597
|
+
// the spacer's padding was the pin distance: it is the tail's height now (--tc-pin)
|
|
598
|
+
for (const [k, v] of Object.entries(entry.inline)) { const d = v && pinOf(k, v); if (d) { el.style.setProperty('--tc-pin', d); used = true; } }
|
|
599
|
+
for (const r of entry.rules) { const d = pinOf(r.prop, r.value); if (d) { rules.push(`@media ${r.media} { ${sel(i)} { --tc-pin: ${d} } }`); used = true; } }
|
|
600
|
+
if (used) { el.setAttribute('data-tc-w', String(i)); count++; }
|
|
595
601
|
continue;
|
|
596
602
|
}
|
|
597
603
|
const skip = (k) => (el.hasAttribute('data-tc-runtime') && (PIN.has(k) || k === 'width' || k === 'height')) || ((el.hasAttribute('data-tc-reveal') || el.hasAttribute('data-tc-scrub')) && MOTION.has(k));
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const current = decls(el.getAttribute('style'));
|
|
604
|
-
for (const k of moved) current.delete(k);
|
|
605
|
-
const inline = [...current].map(([k, v]) => `${k}: ${v}`).join('; ');
|
|
606
|
-
if (inline) el.setAttribute('style', inline); else el.removeAttribute('style');
|
|
607
|
-
for (const w of widths) {
|
|
608
|
-
const body = moved.filter((k) => per[w].has(k)).map((k) => `${k}: ${per[w].get(k)}`).join('; ');
|
|
609
|
-
if (body) rules.push(`@media ${media[w]} { ${sel(i)} { ${body} } }`);
|
|
604
|
+
for (const [k, v] of Object.entries(entry.inline)) {
|
|
605
|
+
if (skip(k)) continue;
|
|
606
|
+
el.style.removeProperty(k);
|
|
607
|
+
if (v !== null) el.style.setProperty(k, v);
|
|
608
|
+
used = true;
|
|
610
609
|
}
|
|
610
|
+
const mine = entry.rules.filter((r) => !skip(r.prop));
|
|
611
|
+
for (const r of mine) rules.push(`@media ${r.media} { ${sel(i)} { ${r.prop}: ${r.value} } }`);
|
|
612
|
+
const lost = entry.dropped.filter((k) => !skip(k));
|
|
613
|
+
if (lost.length) { dropped += lost.length; droppedPaths.push(p); el.setAttribute('data-tc-script-owned', lost.join(' ')); }
|
|
614
|
+
if (mine.length) { el.setAttribute('data-tc-w', String(i)); count++; }
|
|
615
|
+
if (!el.getAttribute('style')) el.removeAttribute('style');
|
|
616
|
+
if (used || mine.length || lost.length) { /* counted */ }
|
|
611
617
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
618
|
+
document.getElementById('tc-width-styles')?.remove();
|
|
619
|
+
const style = document.createElement('style');
|
|
620
|
+
style.id = 'tc-width-styles';
|
|
621
|
+
// --tc-vw: 1% of the layout width with a classic scrollbar left out, as the site's scripts measure it; the
|
|
622
|
+
// tc-vw script below keeps it exact, and 1vw stands in without it
|
|
623
|
+
style.textContent = `/* trueclone seed: values the site's scripts compute from the screen size, as functions of it (viewport-fit) */\n:root { --tc-vw: 1vw }\n[data-tc-pin-tail] { height: var(--tc-pin, 0px) }\n${rules.join('\n')}\n`;
|
|
624
|
+
document.head.appendChild(style);
|
|
625
|
+
if (!document.getElementById('tc-vw')) {
|
|
626
|
+
const s = document.createElement('script');
|
|
627
|
+
s.id = 'tc-vw';
|
|
628
|
+
s.textContent = "(()=>{const r=document.documentElement,set=()=>r.style.setProperty('--tc-vw',r.clientWidth/100+'px');set();new ResizeObserver(set).observe(r)})();";
|
|
629
|
+
document.head.appendChild(s);
|
|
617
630
|
}
|
|
618
|
-
return
|
|
631
|
+
return { elements: Object.keys(plan).length, rules: rules.length, dropped, droppedPaths };
|
|
619
632
|
}
|
|
620
633
|
|
|
634
|
+
|
|
621
635
|
// The mirror is a snapshot taken after the site's scripts ran, so it carries what they left in the DOM: GSAP's
|
|
622
636
|
// pin-spacer wrappers and the pin's inline geometry (fixed desktop pixels that break every other width), and canvas
|
|
623
637
|
// buffer sizes set for one viewport. A pin is a sticky element inside a spacer, which CSS does natively: the seed
|
|
@@ -637,14 +651,27 @@ function cleanRuntimeInPage() {
|
|
|
637
651
|
const distance = Math.round(parseFloat(horizontal ? spacer.style.paddingRight : spacer.style.paddingBottom) || 0);
|
|
638
652
|
if (!pinned) { spacer.replaceWith(...spacer.childNodes); continue; }
|
|
639
653
|
for (const p of PIN_PROPS) pinned.style.removeProperty(p);
|
|
640
|
-
pinned.style.position = 'sticky';
|
|
641
|
-
pinned.style.top = '0px';
|
|
642
654
|
pinned.setAttribute('data-tc-runtime', 'pin');
|
|
643
655
|
pinned.setAttribute('data-tc-pin', String(distance));
|
|
644
656
|
const keep = [...spacer.style].filter((p) => SPACER_KEEP.has(p)).map((p) => `${p}: ${spacer.style.getPropertyValue(p)}`);
|
|
645
657
|
spacer.removeAttribute('class');
|
|
646
|
-
spacer.setAttribute('style', `${keep.join('; ')}${keep.length ? '; ' : ''}${horizontal ? 'padding-right' : 'padding-bottom'}: ${distance}px`);
|
|
647
658
|
spacer.setAttribute('data-tc-pin-spacer', String(distance));
|
|
659
|
+
if (horizontal) {
|
|
660
|
+
// a sideways pin (a horizontal scroller) is script territory: the spacer keeps its layout role, script.js pins
|
|
661
|
+
spacer.setAttribute('style', keep.join('; '));
|
|
662
|
+
log.pins.push({ el: label(pinned), distance, axis: 'x' });
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
// A sticky element travels within its parent's content box, never into its padding: the spacer holds the pinned
|
|
666
|
+
// element and a tail block as tall as the pin distance (--tc-pin, a function of the viewport when the site's is),
|
|
667
|
+
// so the element stays stuck for exactly that long and then scrolls away, like the original.
|
|
668
|
+
pinned.style.position = 'sticky';
|
|
669
|
+
pinned.style.top = '0px';
|
|
670
|
+
spacer.setAttribute('style', `${keep.join('; ')}${keep.length ? '; ' : ''}--tc-pin: ${distance}px`);
|
|
671
|
+
const tail = document.createElement('div');
|
|
672
|
+
tail.setAttribute('data-tc-pin-tail', '');
|
|
673
|
+
tail.setAttribute('aria-hidden', 'true');
|
|
674
|
+
pinned.after(tail);
|
|
648
675
|
log.pins.push({ el: label(pinned), distance });
|
|
649
676
|
}
|
|
650
677
|
// a canvas keeps its buffer size: with only a CSS width, the height follows the buffer's aspect ratio, and the
|
|
@@ -669,41 +696,67 @@ function placeholdersInPage([lorem, rootSel]) {
|
|
|
669
696
|
// Widths come from layout, not canvas: the reference words are measured with Ranges on the live text, candidates
|
|
670
697
|
// in an isolated box that copies the text style (contain: layout, so measuring never re-lays out the page). All
|
|
671
698
|
// reads happen before any text is written.
|
|
672
|
-
|
|
699
|
+
//
|
|
700
|
+
// Speed (a big page has tens of thousands of runs): an element's font is read from its computed style once, and
|
|
701
|
+
// every word list is measured in one layout per font (one block per word), never one forced layout per word.
|
|
702
|
+
const meter = document.createElement('div');
|
|
673
703
|
meter.style.cssText = 'position:absolute;left:-99999px;top:0;white-space:pre;contain:layout style paint;visibility:hidden';
|
|
674
704
|
document.body.appendChild(meter);
|
|
675
705
|
const PROPS = ['fontStyle', 'fontWeight', 'fontSize', 'fontFamily', 'fontStretch', 'fontVariant', 'fontFeatureSettings', 'fontKerning', 'letterSpacing', 'wordSpacing', 'textTransform'];
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
if (!
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
706
|
+
// key -> { key, vals, words: Map(word -> width), lists: Map(casing -> {words, widths, sorted}) }; kept on the page
|
|
707
|
+
// across calls (every widget state is shaped by its own call)
|
|
708
|
+
const fonts = window.__tcFonts || (window.__tcFonts = new Map());
|
|
709
|
+
const fontOf = (s) => {
|
|
710
|
+
const vals = PROPS.map((p) => s[p]);
|
|
711
|
+
const key = vals.join('|');
|
|
712
|
+
if (!fonts.has(key)) fonts.set(key, { key, vals, words: new Map(), lists: new Map() });
|
|
713
|
+
return fonts.get(key);
|
|
714
|
+
};
|
|
715
|
+
const measure = (f, list) => { // widths of many words in this font, one layout for all of them
|
|
716
|
+
const todo = [...new Set(list)].filter((w) => !f.words.has(w));
|
|
717
|
+
if (todo.length) {
|
|
718
|
+
meter.textContent = '';
|
|
719
|
+
PROPS.forEach((p, i) => { meter.style[p] = f.vals[i]; });
|
|
720
|
+
const frag = document.createDocumentFragment();
|
|
721
|
+
const blocks = todo.map((w) => { const b = document.createElement('div'); b.style.cssText = 'width:max-content'; b.textContent = w; frag.appendChild(b); return b; });
|
|
722
|
+
meter.appendChild(frag);
|
|
723
|
+
blocks.forEach((b, i) => f.words.set(todo[i], b.getBoundingClientRect().width));
|
|
724
|
+
meter.textContent = '';
|
|
725
|
+
}
|
|
726
|
+
return list.map((w) => f.words.get(w));
|
|
727
|
+
};
|
|
728
|
+
const widthIn = (f, word) => (f.words.has(word) ? f.words.get(word) : measure(f, [word])[0]);
|
|
729
|
+
const casings = {
|
|
730
|
+
caps: (w) => w.toUpperCase(),
|
|
731
|
+
cap: (w) => w[0].toUpperCase() + w.slice(1),
|
|
732
|
+
plain: (w) => w,
|
|
733
|
+
};
|
|
734
|
+
const listFor = (f, casing) => { // the vocabulary in one casing: words, widths, and the same sorted by width
|
|
735
|
+
if (!f.lists.has(casing)) {
|
|
736
|
+
const words = lorem.map(casings[casing]);
|
|
737
|
+
const widths = measure(f, words);
|
|
738
|
+
const sorted = words.map((w, i) => [widths[i], w, lorem[i]]).sort((a, b) => a[0] - b[0]);
|
|
739
|
+
f.lists.set(casing, { words, widths, sorted });
|
|
686
740
|
}
|
|
687
|
-
return
|
|
741
|
+
return f.lists.get(casing);
|
|
688
742
|
};
|
|
689
743
|
const RUN = /([\p{L}\p{N}]+)/u;
|
|
690
|
-
const pick = (
|
|
744
|
+
const pick = (f, run, refWidth, state) => {
|
|
691
745
|
if (/^\p{N}+$/u.test(run)) return /^(19|20)\d\d$/.test(run) ? run : run.replace(/\d/g, (d) => String((Number(d) + 3) % 10));
|
|
692
746
|
const caps = run.length > 1 && run === run.toUpperCase() && /\p{L}/u.test(run);
|
|
693
747
|
const cap = !caps && /\p{Lu}/u.test(run[0]);
|
|
694
|
-
const
|
|
748
|
+
const { words, widths, sorted } = listFor(f, caps ? 'caps' : cap ? 'cap' : 'plain');
|
|
695
749
|
const target = refWidth + state.drift;
|
|
696
750
|
let best = null, bestGap = Infinity;
|
|
697
751
|
state.seed = (state.seed * 31 + run.length) % 9973;
|
|
698
|
-
for (let i = 0; i <
|
|
699
|
-
const
|
|
700
|
-
const gap = Math.abs(
|
|
701
|
-
if (gap < bestGap) { best =
|
|
752
|
+
for (let i = 0; i < words.length; i++) {
|
|
753
|
+
const k = (i + state.seed) % words.length;
|
|
754
|
+
const gap = Math.abs(widths[k] - target);
|
|
755
|
+
if (gap < bestGap) { best = words[k]; bestGap = gap; }
|
|
702
756
|
}
|
|
703
757
|
// No single word close enough (a 2px miss moves a line break): try two lorem words joined into one. Still one
|
|
704
758
|
// word, so the break opportunities stay the reference's, but there are ~10^5 widths to choose from.
|
|
705
759
|
if (bestGap > 0.75 && target > 30) {
|
|
706
|
-
const sorted = lorem.map((w) => [widthIn(s, cased(w)), w]).sort((a, b) => a[0] - b[0]);
|
|
707
760
|
const tail = (w) => (caps ? w.toUpperCase() : w); // only the first part carries the capital
|
|
708
761
|
const tries = [];
|
|
709
762
|
for (const [wa, a] of sorted) {
|
|
@@ -711,22 +764,23 @@ function placeholdersInPage([lorem, rootSel]) {
|
|
|
711
764
|
if (need < 6) break;
|
|
712
765
|
let lo = 0, hi = sorted.length - 1;
|
|
713
766
|
while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid][0] < need) lo = mid + 1; else hi = mid; }
|
|
714
|
-
for (const k of [lo - 1, lo]) if (sorted[k]) tries.push([Math.abs(wa + sorted[k][0] - target),
|
|
767
|
+
for (const k of [lo - 1, lo]) if (sorted[k]) tries.push([Math.abs(wa + sorted[k][0] - target), a + tail(sorted[k][2])]);
|
|
715
768
|
}
|
|
716
769
|
tries.sort((x, y) => x[0] - y[0]);
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
770
|
+
const top = tries.slice(0, 12).map((t) => t[1]); // estimates; measure the real compounds (kerning at the join)
|
|
771
|
+
measure(f, top).forEach((w, i) => {
|
|
772
|
+
const gap = Math.abs(w - target);
|
|
773
|
+
if (gap < bestGap) { best = top[i]; bestGap = gap; }
|
|
774
|
+
});
|
|
721
775
|
}
|
|
722
|
-
state.drift = target - widthIn(
|
|
776
|
+
state.drift = target - widthIn(f, best);
|
|
723
777
|
return best;
|
|
724
778
|
};
|
|
725
779
|
// for attributes (alt, aria-label...): no layout to match, the element's own font is enough
|
|
726
780
|
const shaper = (el) => {
|
|
727
|
-
const
|
|
781
|
+
const f = fontOf(getComputedStyle(el || document.body));
|
|
728
782
|
const state = { drift: 0, seed: 7 };
|
|
729
|
-
return (text) => text.split(RUN).map((run) => (RUN.test(run) && !/^[^\p{L}\p{N}]*$/u.test(run) ? pick(
|
|
783
|
+
return (text) => text.split(RUN).map((run) => (RUN.test(run) && !/^[^\p{L}\p{N}]*$/u.test(run) ? pick(f, run, widthIn(f, run), state) : run)).join('');
|
|
730
784
|
};
|
|
731
785
|
|
|
732
786
|
// text nodes: measure every original letter run first (reads only)
|
|
@@ -747,7 +801,7 @@ function placeholdersInPage([lorem, rootSel]) {
|
|
|
747
801
|
const rects = [...r.getClientRects()];
|
|
748
802
|
return rects.reduce((sum, x) => sum + x.width, 0) || null; // a run hidden by display:none measures 0
|
|
749
803
|
});
|
|
750
|
-
plan.push({ n, parts, widths,
|
|
804
|
+
plan.push({ n, parts, widths, font: fontOf(getComputedStyle(n.parentElement)) });
|
|
751
805
|
}
|
|
752
806
|
// then choose the words (writes go to the isolated box only), then write all the text
|
|
753
807
|
const states = new Map();
|
|
@@ -756,7 +810,7 @@ function placeholdersInPage([lorem, rootSel]) {
|
|
|
756
810
|
states.set(item.n.parentElement, state);
|
|
757
811
|
item.text = item.parts.map((part, i) => {
|
|
758
812
|
if (!/[\p{L}\p{N}]/u.test(part)) return part;
|
|
759
|
-
return pick(item.
|
|
813
|
+
return pick(item.font, part, item.widths[i] ?? widthIn(item.font, part), state);
|
|
760
814
|
}).join('');
|
|
761
815
|
}
|
|
762
816
|
for (const item of plan) item.n.textContent = item.text;
|
|
@@ -802,3 +856,22 @@ function placeholdersInPage([lorem, rootSel]) {
|
|
|
802
856
|
document.title = shaper(null)(document.title);
|
|
803
857
|
return document.documentElement.outerHTML;
|
|
804
858
|
}
|
|
859
|
+
|
|
860
|
+
// Patches: what a clone needs on top of the seed lives in clone/patches/ and is applied on every seed (and alone with
|
|
861
|
+
// `seed --patches`), so the generated index.html is never edited by hand and a re-seed never loses a fix. In name order:
|
|
862
|
+
// head-*.html inserted before </head> (meta, preloads, extra stylesheet links)
|
|
863
|
+
// *.css linked before </head> (after the site's stylesheets, so they win ties)
|
|
864
|
+
// body-*.html inserted before the clone's own <script src="script.js"> (vendor libraries, a hand-over prelude)
|
|
865
|
+
// *.js <script src defer> before script.js (runs after the players, before script.js)
|
|
866
|
+
async function applyPatches(html) {
|
|
867
|
+
const patchDir = path.join(root, 'clone', 'patches');
|
|
868
|
+
const applied = (await fs.readdir(patchDir).catch(() => [])).filter((f) => /\.(html|css|js)$/i.test(f)).sort();
|
|
869
|
+
const beforeScript = (s) => (/<script[^>]+src="script\.js"/.test(html) ? html.replace(/<script[^>]+src="script\.js"[^>]*><\/script>/i, (tag) => `${s}\n${tag}`) : html.replace(/<\/body>/i, `${s}\n</body>`));
|
|
870
|
+
for (const f of applied) {
|
|
871
|
+
if (/^head-.*\.html$/i.test(f)) html = html.replace(/<\/head>/i, `${await fs.readFile(path.join(patchDir, f), 'utf8')}\n</head>`);
|
|
872
|
+
else if (/\.css$/i.test(f)) html = html.replace(/<\/head>/i, `<link rel="stylesheet" href="patches/${f}">\n</head>`);
|
|
873
|
+
else if (/\.html$/i.test(f)) html = beforeScript(await fs.readFile(path.join(patchDir, f), 'utf8'));
|
|
874
|
+
else if (/\.js$/i.test(f)) html = beforeScript(`<script src="patches/${f}" defer></script>`);
|
|
875
|
+
}
|
|
876
|
+
return { html, applied };
|
|
877
|
+
}
|
package/scripts/states.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// node scripts/states.mjs <name> --clone [url] [--structure]
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { SCORED, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, wheelTo, statesInPage, maskToggle, freezeLoops, matchPct, canvasSimilarity, PNG } from './lib.mjs';
|
|
11
11
|
|
|
12
12
|
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
13
13
|
const [name] = pos;
|
|
@@ -22,7 +22,13 @@ const cloneMode = Boolean(flags.clone);
|
|
|
22
22
|
const structure = Boolean(flags.structure);
|
|
23
23
|
const stepFrac = Number(flags.step || 0.5);
|
|
24
24
|
const wanted = typeof flags.viewports === 'string' ? flags.viewports.split(',') : null;
|
|
25
|
-
const
|
|
25
|
+
const viewportsAll = SCORED.filter((v) => !wanted || wanted.includes(v.name));
|
|
26
|
+
// the clone is only replayed where the reference has states (a capture older than the held-out sizes has none there)
|
|
27
|
+
const viewports = [];
|
|
28
|
+
for (const v of viewportsAll) {
|
|
29
|
+
if (flags.clone && !(await fs.stat(path.join(ref, 'states', `${v.name}.json`)).then(() => true, () => false))) { console.log(`${v.name}: no reference states (run trueclone states ${name}); skipped`); continue; }
|
|
30
|
+
viewports.push(v);
|
|
31
|
+
}
|
|
26
32
|
const outRoot = path.join(cloneMode ? path.join(root, 'compare') : ref, 'states');
|
|
27
33
|
|
|
28
34
|
const server = cloneMode && typeof flags.clone !== 'string' ? await serve(path.join(root, 'clone')) : null;
|
|
@@ -75,7 +81,13 @@ try {
|
|
|
75
81
|
await page.screenshot({ path: path.join(dir, `${file}.mask.png`) });
|
|
76
82
|
await maskToggle(page, false);
|
|
77
83
|
if (!cloneMode) samples.push(await page.evaluate(statesInPage));
|
|
78
|
-
|
|
84
|
+
// the canvases in view (scenes a script draws): masked out of the structure score, so compared on their own
|
|
85
|
+
const canvases = await page.evaluate(() => [...document.querySelectorAll('canvas')].map((c) => {
|
|
86
|
+
const r = c.getBoundingClientRect(), s = getComputedStyle(c);
|
|
87
|
+
if (r.width < 40 || r.height < 40 || r.bottom <= 0 || r.top >= innerHeight || s.visibility === 'hidden' || +s.opacity < 0.05) return null;
|
|
88
|
+
return [Math.max(0, r.left), Math.max(0, r.top), Math.min(innerWidth, r.right), Math.min(innerHeight, r.bottom)].map(Math.round);
|
|
89
|
+
}).filter((r) => r && r[2] - r[0] >= 40 && r[3] - r[1] >= 40));
|
|
90
|
+
steps.push({ i, y: actual, target: y, settled: still.settled, settleMs: still.ms, canvases });
|
|
79
91
|
}
|
|
80
92
|
await context.close();
|
|
81
93
|
|
|
@@ -89,7 +101,27 @@ try {
|
|
|
89
101
|
scored.push({ ...s, match: matchPct(a, b).pct });
|
|
90
102
|
}
|
|
91
103
|
const avg = +(scored.reduce((n, s) => n + s.match, 0) / scored.length).toFixed(2);
|
|
92
|
-
|
|
104
|
+
// canvases: the reference's canvas regions at each step, compared by what is drawn (real pixels, not the mask)
|
|
105
|
+
const refSteps = JSON.parse(await fs.readFile(path.join(ref, 'states', `${vp.name}.json`), 'utf8')).steps;
|
|
106
|
+
const canvasScores = [];
|
|
107
|
+
for (const s of steps) {
|
|
108
|
+
const regions = refSteps[s.i]?.canvases;
|
|
109
|
+
if (!regions?.length) continue;
|
|
110
|
+
const f = `${String(s.i).padStart(2, '0')}.png`;
|
|
111
|
+
const a = PNG.sync.read(await fs.readFile(path.join(ref, 'states', vp.name, f)));
|
|
112
|
+
const b = PNG.sync.read(await fs.readFile(path.join(dir, f)));
|
|
113
|
+
for (const [x0, y0, x1, y1] of regions) {
|
|
114
|
+
const w = Math.min(x1, a.width, b.width) - x0, h = Math.min(y1, a.height, b.height) - y0;
|
|
115
|
+
if (w < 40 || h < 40) continue;
|
|
116
|
+
const ca = new PNG({ width: w, height: h }), cb = new PNG({ width: w, height: h });
|
|
117
|
+
PNG.bitblt(a, ca, x0, y0, w, h, 0, 0);
|
|
118
|
+
PNG.bitblt(b, cb, x0, y0, w, h, 0, 0);
|
|
119
|
+
canvasScores.push({ i: s.i, y: s.y, region: [x0, y0, x1, y1], similarity: canvasSimilarity(ca, cb) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const hasCanvasData = refSteps.some((s) => Array.isArray(s.canvases));
|
|
123
|
+
const canvas = canvasScores.length ? +(canvasScores.reduce((n, c) => n + c.similarity, 0) / canvasScores.length).toFixed(2) : null;
|
|
124
|
+
return { viewport: vp.name, match: avg, worst: [...scored].sort((a, b) => a.match - b.match).slice(0, 5), steps: scored, canvas, canvasWorst: [...canvasScores].sort((a, b) => a.similarity - b.similarity).slice(0, 5), ...(hasCanvasData ? {} : { canvasNote: 'reference states predate the canvas check: re-run trueclone states' }) };
|
|
93
125
|
} else {
|
|
94
126
|
const summary = summarize(steps, samples, vp, initial);
|
|
95
127
|
await fs.writeFile(path.join(outRoot, `${vp.name}.json`), JSON.stringify({ viewport: vp, steps, samples }));
|
|
@@ -105,7 +137,8 @@ try {
|
|
|
105
137
|
await fs.writeFile(path.join(outRoot, cloneMode ? `report${structure ? '.mask' : ''}.json` : 'report.json'), JSON.stringify(report, null, 2));
|
|
106
138
|
if (cloneMode) {
|
|
107
139
|
console.log(`state match (${report.mode}) - average over scroll steps`);
|
|
108
|
-
console.table(report.viewports.map((v) => ({ viewport: v.viewport, 'match %': v.match, worst: v.worst.map((s) => `#${s.i} y${s.y} ${s.match}%`).join(' ') })));
|
|
140
|
+
console.table(report.viewports.map((v) => ({ viewport: v.viewport, 'match %': v.match, 'canvas %': v.canvas ?? '-', worst: v.worst.map((s) => `#${s.i} y${s.y} ${s.match}%`).join(' ') })));
|
|
141
|
+
for (const v of report.viewports) if (v.canvasNote) console.log(`${v.viewport}: ${v.canvasNote}`);
|
|
109
142
|
} else {
|
|
110
143
|
console.table(report.viewports);
|
|
111
144
|
}
|