@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/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
- const vpName = innerWidth >= 1100 ? 'desktop' : innerWidth >= 600 ? 'tablet' : 'mobile';
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; add --force to overwrite it`);
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
- const scrubbed = byPath('scrubs');
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: scrubbed.map((r) => perWidth(r, (x) => ({ key: x.key, samples: x.samples }))),
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
- // Patches: what a clone needs on top of the seed lives in clone/patches/ and is applied here on every seed, so the
296
- // generated index.html is never edited by hand and a re-seed never loses a fix. In name order:
297
- // head-*.html inserted before </head> (meta, extra stylesheet links)
298
- // *.css linked before </head> (after the site's stylesheets, so they win ties)
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 || scrubbed.length) console.log(`replayed by trueclone-reveals.js: ${marked}/${recorded.length} scroll reveals, ${scrubMarked}/${scrubbed.length} scroll-linked curves, ${classMarked}/${classChanges.length} scroll class changes (${Object.keys(recs).join(', ') || 'no'} widths recorded)${marked < recorded.length || classMarked < classChanges.length || scrubMarked < scrubbed.length ? ' (the rest did not resolve to an element in the mirror)' : ''}`);
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.widthStyled) console.log(`per-width inline styles: ${runtime.widthStyled} element(s) sized or placed differently by the site's scripts at tablet/mobile, now media-query rules (data-tc-w)`);
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) await page.evaluate(placeholdersInPage, [LOREM, null]);
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
- runtime.widthStyled = inlineStyles ? await page.evaluate(widthStylesInPage, [inlineStyles, WIDTH_MEDIA]) : 0;
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
- // Inline styles the capture read at every width, by element path. A declaration with the same value at every width
540
- // stays inline; one that differs (a height a script computed from the container, an offset, a custom property)
541
- // moves into a media-query rule per width, so each width gets exactly what the site showed there. The rule's
542
- // selector is stacked to the precedence an inline style had over the stylesheets, without !important, so a value
543
- // script.js writes inline later still wins. Motion properties of elements the reveal player drives, and the pin
544
- // geometry the seed removed, are left out.
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
- const widths = Object.keys(styles).filter((w) => media[w]);
570
- const paths = new Set(widths.flatMap((w) => Object.keys(styles[w])));
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
- for (const p of paths) {
575
- const per = Object.fromEntries(widths.map((w) => [w, decls(styles[w][p])]));
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
- // a pin spacer keeps only its pin distance per width (the site computes it from the viewport height); GSAP writes
582
- // it as the padding shorthand (padding: 0px 0px 5600px)
583
- const spacer = el.hasAttribute('data-tc-pin-spacer');
584
- if (spacer) {
585
- const horizontal = /^padding-right|^padding:\s*0(px)?\s+[1-9]/.test(styles[widths[0]][p] || '');
586
- const distance = (m) => { if (m.has(horizontal ? 'padding-right' : 'padding-bottom')) return parseFloat(m.get(horizontal ? 'padding-right' : 'padding-bottom')); const v = (m.get('padding') || '').trim().split(/\s+/); return parseFloat(horizontal ? v[1] ?? v[0] : v[2] ?? v[0]) || 0; };
587
- const d = Object.fromEntries(widths.map((w) => [w, Math.round(distance(per[w]))]));
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 moved = differs.filter((k) => !skip(k));
599
- if (!moved.length) continue;
600
- const i = count++;
601
- el.setAttribute('data-tc-w', String(i));
602
- // the inline attribute keeps what is the same everywhere (as the desktop mirror had it)
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
- if (rules.length) {
613
- const style = document.createElement('style');
614
- style.id = 'tc-width-styles';
615
- style.textContent = `/* trueclone seed: inline styles the site's scripts set per width (captured at desktop, tablet and mobile) */\n${rules.join('\n')}\n`;
616
- document.head.appendChild(style);
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 count;
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
- const meter = document.createElement('span');
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
- let current = null;
677
- const cache = new Map();
678
- const widthIn = (s, word) => {
679
- const key = PROPS.map((p) => s[p]).join('|');
680
- let words = cache.get(key);
681
- if (!words) cache.set(key, (words = new Map()));
682
- if (!words.has(word)) {
683
- if (current !== key) { for (const p of PROPS) meter.style[p] = s[p]; current = key; }
684
- meter.textContent = word;
685
- words.set(word, meter.getBoundingClientRect().width);
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 words.get(word);
741
+ return f.lists.get(casing);
688
742
  };
689
743
  const RUN = /([\p{L}\p{N}]+)/u;
690
- const pick = (s, run, refWidth, state) => {
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 cased = (w) => (caps ? w.toUpperCase() : cap ? w[0].toUpperCase() + w.slice(1) : w);
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 < lorem.length; i++) {
699
- const w = cased(lorem[(i + state.seed) % lorem.length]);
700
- const gap = Math.abs(widthIn(s, w) - target);
701
- if (gap < bestGap) { best = w; bestGap = gap; }
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), cased(a) + tail(sorted[k][1])]);
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
- for (const [, word] of tries.slice(0, 12)) { // estimates; measure the real compound (kerning at the join)
718
- const gap = Math.abs(widthIn(s, word) - target);
719
- if (gap < bestGap) { best = word; bestGap = gap; }
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(s, best);
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 s = getComputedStyle(el || document.body);
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(s, run, widthIn(s, run), state) : run)).join('');
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, style: getComputedStyle(n.parentElement) });
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.style, part, item.widths[i] ?? widthIn(item.style, part), state);
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
+ }
@@ -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 { VIEWPORTS, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, wheelTo, statesInPage, maskToggle, freezeLoops, matchPct, PNG } from './lib.mjs';
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 viewports = VIEWPORTS.filter((v) => !wanted || wanted.includes(v.name));
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
- steps.push({ i, y: actual, target: y, settled: still.settled, settleMs: still.ms });
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
- return { viewport: vp.name, match: avg, worst: [...scored].sort((a, b) => a.match - b.match).slice(0, 5), steps: scored };
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
  }