@trueclone/cli 0.2.0 → 0.4.0-rc.1

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,15 +57,28 @@ 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
 
60
+
59
61
  // Reveal player shipped with the clone (trueclone's own code): replays each recorded curve with the Web Animations
60
62
  // API once the element's top crosses the recorded trigger line; with reduced motion it shows the end state.
61
63
  const PLAYER = `// trueclone reveal player: replays what was recorded on the reference while scrolling (trueclone reveals): one-shot
62
- // reveals frame by frame, and class changes (a header that gets its backdrop past 12px, an "is-visible" toggle).
64
+ // reveals frame by frame, scroll-linked curves (parallax, scrubbed fades) by scroll position, and class changes (a
65
+ // header that gets its backdrop past 12px, an "is-visible" toggle). Records exist per width; the one for the
66
+ // visitor's width is used (desktop from 1100px, tablet from 600px, otherwise mobile).
63
67
  (() => {
64
68
  const raw = JSON.parse(document.getElementById('tc-reveals')?.textContent || '{}');
65
69
  const data = Array.isArray(raw) ? raw : raw.reveals || [];
66
70
  const classData = Array.isArray(raw) ? [] : raw.classes || [];
71
+ const scrubData = Array.isArray(raw) ? [] : raw.scrubs || [];
67
72
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
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;
81
+ const pick = (entry) => (entry && (entry.desktop || entry.tablet || entry.mobile) ? entry[vpName] || entry.desktop || entry.tablet || entry.mobile : entry);
68
82
 
69
83
  // class changes are state, not motion: they apply with reduced motion too
70
84
  let scrolled = false;
@@ -74,10 +88,12 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
74
88
  if (!c) continue;
75
89
  if (c.sticky) { pinned.push([el, c]); continue; }
76
90
  const line = Math.max(0, Math.min(1, c.trigger ?? 1));
91
+ // an element without a box (display: contents) never intersects: watch its first descendant that has one
92
+ const watched = el.getBoundingClientRect().height ? el : [...el.querySelectorAll('*')].find((d) => d.getBoundingClientRect().height) || el;
77
93
  new IntersectionObserver(([e], io) => {
78
94
  if (e.isIntersecting) { el.setAttribute('class', c.to); if (!c.reverts) io.disconnect(); }
79
95
  else if (c.reverts && el.getAttribute('class') === c.to) el.setAttribute('class', c.back ?? c.from);
80
- }, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` }).observe(el);
96
+ }, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` }).observe(watched);
81
97
  }
82
98
  const applyPinned = () => {
83
99
  if (scrollY > 0) scrolled = true;
@@ -93,15 +109,55 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
93
109
  applyPinned();
94
110
  }
95
111
  const style = (f) => ({ opacity: f.o, transform: \`translate(\${f.tx}px, \${f.ty}px) scale(\${f.s})\`, filter: f.blur ? \`blur(\${f.blur}px)\` : 'none' });
96
- const settle = (el, r) => Object.assign(el.style, style(r.to));
112
+ // scroll-linked curves: the recorded state at the two nearest scroll positions, interpolated. An element that also
113
+ // plays a one-shot reveal follows its curve once the reveal has finished.
114
+ // A curve is keyed by the element's own position in the viewport (key "v": its layout box top, translate taken
115
+ // out, the way a ScrollTrigger on the element sees it, so any viewport height gives the same curve) or, for an
116
+ // element inside a pinned stage, by the scroll position (key "y").
117
+ const scrubs = [];
118
+ for (const el of document.querySelectorAll('[data-tc-scrub]')) {
119
+ const rec = pick(scrubData[Number(el.dataset.tcScrub)]);
120
+ const s = Array.isArray(rec) ? rec : rec && rec.samples;
121
+ if (!s || s.length < 2) continue;
122
+ const key = !Array.isArray(rec) && rec.key === 'v' && s[0].v !== undefined ? 'v' : 'y';
123
+ scrubs.push({ el, key, s: key === 'v' ? [...s].sort((p, q) => p.v - q.v) : s, ty: 0, ready: !el.hasAttribute('data-tc-reveal') });
124
+ }
125
+ const at = (s, key, pos) => {
126
+ if (pos <= s[0][key]) return s[0];
127
+ for (let i = 1; i < s.length; i++) {
128
+ if (pos > s[i][key]) continue;
129
+ const a = s[i - 1], b = s[i], k = (pos - a[key]) / ((b[key] - a[key]) || 1);
130
+ const L = (p) => a[p] + (b[p] - a[p]) * k;
131
+ return { o: L('o'), tx: L('tx'), ty: L('ty'), s: L('s'), blur: L('blur') };
132
+ }
133
+ return s[s.length - 1];
134
+ };
135
+ // script.js takes an element over by removing its data-tc-scrub / data-tc-reveal mark: the player then leaves it alone
136
+ const applyScrubs = () => {
137
+ for (const x of scrubs) {
138
+ if (!x.ready || !x.el.hasAttribute('data-tc-scrub')) continue;
139
+ const pos = x.key === 'v' ? x.el.getBoundingClientRect().top - x.ty : scrollY;
140
+ const f = at(x.s, x.key, pos);
141
+ x.ty = f.ty;
142
+ Object.assign(x.el.style, style(f));
143
+ }
144
+ };
145
+ if (scrubs.length) {
146
+ let queued = false;
147
+ addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; applyScrubs(); }); } }, { passive: true });
148
+ applyScrubs();
149
+ }
150
+ const release = (el) => { const x = scrubs.find((q) => q.el === el); if (x) { x.ready = true; applyScrubs(); } };
151
+ const settle = (el, r) => { Object.assign(el.style, style(r.to)); release(el); };
97
152
  const play = (el, r) => {
98
153
  const frames = r.frames.map((f) => ({ ...style(f), offset: r.duration ? Math.min(1, f.t / r.duration) : 1 }));
99
154
  const anim = el.animate(frames, { duration: r.duration, fill: 'forwards', easing: 'linear' });
100
155
  anim.onfinish = () => { settle(el, r); anim.cancel(); };
101
156
  };
102
157
  for (const el of document.querySelectorAll('[data-tc-reveal]')) {
103
- const r = data[Number(el.dataset.tcReveal)];
104
- if (!r) continue;
158
+ const r = pick(data[Number(el.dataset.tcReveal)]);
159
+ if (!r) { release(el); continue; }
160
+ if (r.from) Object.assign(el.style, style(r.from)); // this width's starting state (the seed inlined the desktop one)
105
161
  if (reduce) { settle(el, r); continue; }
106
162
  // the recorded trigger is where the element's top was (as a share of the window height) when it started
107
163
  const line = Math.max(0, Math.min(1, r.trigger ?? 1));
@@ -109,18 +165,24 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
109
165
  for (const e of entries) {
110
166
  if (!e.isIntersecting) continue;
111
167
  io.disconnect();
112
- play(el, r);
168
+ if (el.hasAttribute('data-tc-reveal')) play(el, r);
113
169
  }
114
170
  }, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` });
115
171
  io.observe(el);
116
172
  }
173
+ // boot contract: the page shows once every clone script has run (DOMContentLoaded comes after the deferred ones;
174
+ // the Next app's injector releases after its last script) and every hold is released. script.js holds the boot
175
+ // while it prepares what a visitor must not see half-built (an intro waiting for its image): tcBoot.hold() at the
176
+ // top, tcBoot.release() when ready.
177
+ const boot = window.tcBoot = { holds: 1, hold() { this.holds++; }, release() { if (--this.holds <= 0) requestAnimationFrame(() => document.documentElement.classList.remove('tc-boot')); } };
178
+ if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => boot.release()); else if (!window.__tcInjected) boot.release();
117
179
  })();
118
180
  `;
119
181
 
120
182
  const { pos, flags } = parseArgs(process.argv.slice(2));
121
183
  const [name] = pos;
122
184
  if (!name) {
123
- console.error('usage: trueclone seed <name> [--force]');
185
+ console.error('usage: trueclone seed <name> [--force] [--hide-until-boot "<selectors a script rebuilds at boot>"]');
124
186
  process.exit(1);
125
187
  }
126
188
  const root = path.resolve('sites', name);
@@ -133,9 +195,20 @@ if (!(await fs.stat(path.join(mirror, 'index.html')).then(() => true, () => fals
133
195
  process.exit(1);
134
196
  }
135
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
+ }
136
209
  const exists = await fs.stat(path.join(clone, 'index.html')).then(() => true, () => false);
137
210
  if (exists && !flags.force) {
138
- 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/`);
139
212
  process.exit(1);
140
213
  }
141
214
  await fs.mkdir(path.join(clone, 'css'), { recursive: true });
@@ -160,7 +233,8 @@ const pull = async (rel) => {
160
233
  // placeholders and a final sweep guarantees no reference path survives.
161
234
  const REF_ASSETS = '../reference/assets/';
162
235
  const rewrite = async (text, prefix, replacement, forPass = false) => {
163
- const re = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 'assets/([^"\')\\s]+)', 'g');
236
+ // saved asset names are [a-z0-9._-]; `&` ends a path inside a style attribute (url(&quot;../assets/x.webp&quot;))
237
+ const re = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 'assets/([^"\')\\s&]+)', 'g');
164
238
  for (const m of text.matchAll(re)) if (exact || isFont(m[1])) await pull(m[1]);
165
239
  return text.replace(re, (all, rel) => (exact || isFont(rel) ? `${replacement}assets/${rel}` : forPass ? `${REF_ASSETS}${rel}` : `${replacement}${PLACEHOLDER}`));
166
240
  };
@@ -174,6 +248,7 @@ for (const file of await fs.readdir(path.join(mirror, 'css')).catch(() => [])) {
174
248
  if (!exact) out = out.replace(/url\(\s*(['"]?)(?!data:)([^'")]+)\1\s*\)/g, (all, q, u) => (isFont(u) || u.trim().startsWith('#') ? all : `url("../${PLACEHOLDER_BG}")`));
175
249
  await fs.writeFile(path.join(clone, 'css', file), out);
176
250
  }
251
+ step(`stylesheets copied (${exact ? 'exact' : 'template'} mode)`);
177
252
  let html = await fs.readFile(path.join(mirror, 'index.html'), 'utf8');
178
253
  html = await rewrite(html, '../', '', !exact);
179
254
  await fs.writeFile(path.join(clone, 'index.html'), html);
@@ -186,44 +261,123 @@ if (!exact) {
186
261
  }
187
262
  // Scroll reveals recorded on the reference (trueclone reveals): mark the elements, start them in their recorded
188
263
  // starting state, and replay the recorded curves with a small player when they scroll into view.
189
- const recording = JSON.parse(await fs.readFile(path.join(ref, 'reveals.desktop.json'), 'utf8').catch(() => 'null'));
190
- const recorded = recording?.reveals || [];
191
- const classChanges = recording?.classes || [];
264
+ // One file per width when `reveals` ran at every width: elements are matched by path (the same DOM at every width)
265
+ // and the player uses the record for the visitor's width, so phone offsets are phone offsets, not desktop pixels.
266
+ const recs = {};
267
+ for (const v of ['desktop', 'tablet', 'mobile']) {
268
+ const r = JSON.parse(await fs.readFile(path.join(ref, `reveals.${v}.json`), 'utf8').catch(() => 'null'));
269
+ if (r) recs[v] = r;
270
+ }
271
+ const primary = recs.desktop || recs.tablet || recs.mobile || null;
272
+ const byPath = (key) => {
273
+ const m = new Map();
274
+ for (const [v, r] of Object.entries(recs)) for (const x of r[key] || []) {
275
+ if (!m.has(x.path)) m.set(x.path, { path: x.path, cls: x.cls });
276
+ m.get(x.path)[v] = x;
277
+ }
278
+ return [...m.values()];
279
+ };
280
+ const recorded = byPath('reveals');
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 = [];
285
+ const classChanges = primary?.classes || [];
192
286
  // Widget states recorded on the reference (trueclone widgets): tabs, menus. Template mode only for now: the snapshots
193
287
  // are placeholdered like the page; exact-mode snapshots would still point at the site's media.
194
288
  const widgetRec = exact ? null : JSON.parse(await fs.readFile(path.join(ref, 'widgets.desktop.json'), 'utf8').catch(() => 'null'));
195
289
  const widgetGroups = widgetRec?.groups || [];
196
- let marked = 0, classMarked = 0, widgets = null;
197
- if (!exact || recorded.length || classChanges.length) ({ html, marked, classMarked, widgets } = await browserPass(!exact, recorded, classChanges, widgetGroups));
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'));
292
+ ({ html, marked, classMarked, scrubMarked, widgets, runtime } = await browserPass(!exact, recorded, classChanges, widgetGroups, scrubbed));
198
293
  if (!exact) html = html.split(REF_ASSETS).join('__tc_ref__').replace(/__tc_ref__[^"')\s]+/g, PLACEHOLDER);
199
294
  if (widgets?.groups.length) {
200
295
  // a plain script (not fetched JSON), so the clone also works opened from disk
201
296
  await fs.writeFile(path.join(clone, 'trueclone-widgets.js'), `// trueclone widget player: recorded states of the reference's tabs and menus (trueclone widgets), placeholdered.\nwindow.__tcWidgetData = ${JSON.stringify(widgets)};\n(${widgetPlayer.toString()})();\n`);
202
297
  html = html.replace(/<\/body>/i, '<script src="trueclone-widgets.js" defer></script>\n</body>');
203
298
  }
204
- if (marked || classMarked) {
299
+ if (marked || classMarked || scrubMarked) {
205
300
  await fs.writeFile(path.join(clone, 'trueclone-reveals.js'), PLAYER);
301
+ const perWidth = (r, shape) => Object.fromEntries(['desktop', 'tablet', 'mobile'].filter((v) => r[v]).map((v) => [v, shape(r[v])]));
206
302
  const data = JSON.stringify({
207
- reveals: recorded.map(({ trigger, duration, frames, to }) => ({ trigger, duration, frames, to })),
303
+ reveals: recorded.map((r) => perWidth(r, ({ trigger, duration, frames, to, from }) => ({ trigger, duration, frames, to, from }))),
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 })),
208
307
  classes: classChanges.map(({ from, to, back, sticky, reverts, threshold, trigger }) => ({ from, to, back, sticky, reverts, threshold, trigger })),
209
308
  });
210
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>`);
211
310
  }
311
+ // Boot contract: <html class="tc-boot"> from the first byte until every clone script has run (the reveal player drops
312
+ // it after DOMContentLoaded; the Next app after its last script). Regions a script builds or re-lays out at boot
313
+ // (--hide-until-boot "<selectors>") stay invisible meanwhile, so a visitor never sees their captured, pre-script state;
314
+ // a 3 s failsafe shows everything if no script ever runs.
315
+ html = html.replace(/<html\b([^>]*)>/i, (all, attrs) => (/\bclass=/.test(attrs) ? `<html${attrs.replace(/\bclass=(["'])([^"']*)\1/, (m, q, v) => `class=${q}${v.trim() ? `${v.trim()} ` : ''}tc-boot${q}`)}>` : `<html${attrs} class="tc-boot">`));
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>');
212
317
  // the clone's own behaviour script, loaded once the DOM is parsed
213
318
  if (!/<script[^>]+src="script\.js"/.test(html)) html = html.replace(/<\/body>/i, '<script src="script.js" defer></script>\n</body>');
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;
214
323
  await fs.writeFile(path.join(clone, 'index.html'), html);
324
+ step('index.html written');
325
+
326
+ // notes.md: the agent's record, with the sections the report reads back (Findings, Accepted differences). Written once.
327
+ const notesFile = path.join(root, 'notes.md');
328
+ if (!(await fs.stat(notesFile).then(() => true, () => false))) {
329
+ await fs.writeFile(notesFile, `# ${name}: clone notes
330
+
331
+ Reference: ${meta.url}. Mode: ${exact ? `exact (permission: ${meta.permission})` : 'template'}. Captured ${meta.capturedAt}.
332
+ Keep every section; the report reads "Findings" and "Accepted differences" back. One line per entry, no prose.
333
+
334
+ ## Stack
335
+
336
+ <!-- what the reference is built with: platform, libraries and versions, fonts, breakpoints (from motion.*.md, reference/code.md, the HAR) -->
337
+
338
+ ## Behaviours
339
+
340
+ <!-- the motion inventory and the interactions as a list: element, trigger, property, range, timing; one line each -->
341
+
342
+ ## Own-code map
343
+
344
+ <!-- file -> behaviours it implements -> where its parameters came from (a motion entry, code.md, a measured value) -->
345
+
346
+ ## Findings
347
+
348
+ <!-- defects in the tool, not in the site: symptom, cause if known, the workaround used. Listed in REPORT.md -->
349
+
350
+ ## Accepted differences
351
+
352
+ <!-- what cannot or should not match, one per line: "<check> <width> <where>: <why>" (random motion, a live feed, a
353
+ hover that lands by timing, a third-party embed). Listed first in REPORT.md; a check below target that is covered
354
+ here is marked "accepted" there. Every entry needs the why. -->
355
+
356
+ ## Unknowns
357
+
358
+ <!-- what you could not determine: a rule you could not read, a value you measured instead of derived -->
359
+ `);
360
+ }
215
361
 
216
362
  const scriptFile = path.join(clone, 'script.js');
217
363
  if (!(await fs.stat(scriptFile).then(() => true, () => false))) {
218
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`);
219
365
  }
220
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)'}`);
221
- if (recorded.length || classChanges.length) console.log(`replayed by trueclone-reveals.js: ${marked}/${recorded.length} scroll reveals, ${classMarked}/${classChanges.length} scroll class changes${marked < recorded.length || classMarked < classChanges.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`);
369
+ if (runtime) {
370
+ const pins = runtime.pins.map((p) => `${p.el} pinned for ${p.distance}px${p.axis ? ' (sideways: left to script.js)' : ''}`).join(', ');
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"`);
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)` : ''}`);
373
+ if (runtime.hidden) console.log(`boot contract: ${runtime.hidden} element(s) hidden until the clone's scripts have run (data-tc-boot-hide)`);
374
+ }
222
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(', ')}` : ''}`);
376
+ if (patches.length) console.log(`patches applied from clone/patches/: ${patches.join(', ')}`);
223
377
  console.log(`next: write clone/script.js (behaviour), then npx trueclone score ${name}`);
224
378
 
225
379
  // ---------- One browser pass over the seeded page: placeholders (template mode) and reveal marks ----------
226
- async function browserPass(template, reveals, classes, groups = []) {
380
+ async function browserPass(template, reveals, classes, groups = [], scrubs = []) {
227
381
  // served from sites/<name>/ so the pass can reach reference/assets (see `forPass`)
228
382
  const server = await serve(root);
229
383
  const browser = await openBrowser(flags.cdp);
@@ -231,11 +385,12 @@ async function browserPass(template, reveals, classes, groups = []) {
231
385
  const { page } = await newPage(browser, VIEWPORTS.find((v) => v.name === 'desktop'), {});
232
386
  await page.goto(`${server.url}clone/index.html`, { waitUntil: 'load', timeout: 60000 });
233
387
  await page.evaluate(() => document.fonts.ready);
234
- const counts = reveals.length || classes.length ? await page.evaluate(markRevealsInPage, [reveals, classes]) : [0, 0];
388
+ const counts = reveals.length || classes.length || scrubs.length ? await page.evaluate(markRevealsInPage, [reveals, classes, scrubs]) : [0, 0, 0];
235
389
  // widgets: mark regions and triggers, then give every recorded state the same placeholders as the page by
236
390
  // placing it into its region (so it is laid out and measured for real) and reading it back
237
391
  let widgets = null;
238
392
  if (groups.length) {
393
+ step(`widgets: ${groups.length} recorded, placing each state`);
239
394
  const ok = await page.evaluate(markWidgetsInPage, groups);
240
395
  await page.evaluate(`window.__tcPlaceholders = ${placeholdersInPage.toString()}`);
241
396
  widgets = { groups: [], states: 0 };
@@ -258,9 +413,35 @@ async function browserPass(template, reveals, classes, groups = []) {
258
413
  }
259
414
  widgets.groups = Array.from(widgets.groups, (x) => x || null);
260
415
  }
261
- if (template) await page.evaluate(placeholdersInPage, [LOREM, null]);
262
- const out = await page.evaluate(() => document.documentElement.outerHTML);
263
- return { html: `<!DOCTYPE html>\n${out}`, marked: counts[0], classMarked: counts[1], widgets };
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
+ }
421
+ // last, because the recorded paths above were taken on the live page, pin-spacers included
422
+ const runtime = await page.evaluate(cleanRuntimeInPage);
423
+ // regions the clone's script builds at boot, hidden until it has run (see the boot contract below)
424
+ runtime.hidden = typeof flags['hide-until-boot'] === 'string'
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))
426
+ : 0;
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
429
+ const inlineStyles = JSON.parse(await fs.readFile(path.join(ref, 'inline-styles.json'), 'utf8').catch(() => 'null'));
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
+ }
436
+ // getHTML keeps the declarative shadow roots the capture serialised (outerHTML would drop them); it is the inner
437
+ // HTML, so the <html> tag and its attributes (font classes, dark mode, lang) are written back around it
438
+ const out = await page.evaluate(() => {
439
+ const el = document.documentElement;
440
+ const inner = el.getHTML ? el.getHTML({ serializableShadowRoots: true }) : el.innerHTML;
441
+ const attrs = [...el.attributes].map((a) => ` ${a.name}="${a.value.replace(/&/g, '&amp;').replace(/"/g, '&quot;')}"`).join('');
442
+ return `<html${attrs}>${inner}</html>`;
443
+ });
444
+ return { html: `<!DOCTYPE html>\n${out}`, marked: counts[0], classMarked: counts[1], scrubMarked: counts[2], widgets, runtime };
264
445
  } finally {
265
446
  if (!flags.cdp) await browser.close();
266
447
  server.close();
@@ -332,7 +513,7 @@ function widgetPlayer() {
332
513
  });
333
514
  }
334
515
 
335
- function markRevealsInPage([reveals, classes]) {
516
+ function markRevealsInPage([reveals, classes, scrubs = []]) {
336
517
  const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
337
518
  const resolve = (p) => {
338
519
  let el = document.body;
@@ -344,16 +525,25 @@ function markRevealsInPage([reveals, classes]) {
344
525
  }
345
526
  return el;
346
527
  };
528
+ // each reveal's starting state is a rule on its mark (no flash before the player runs; the player then applies the
529
+ // visitor's width); script.js hands an element over by removing the mark, which releases the state with it
530
+ const rules = [];
347
531
  let count = 0;
348
532
  reveals.forEach((r, i) => {
349
533
  const el = resolve(r.path);
350
534
  if (!el) return;
535
+ const rec = r.desktop || r.tablet || r.mobile || r;
351
536
  el.setAttribute('data-tc-reveal', String(i));
352
- el.style.opacity = String(r.from.o);
353
- el.style.transform = `translate(${r.from.tx}px, ${r.from.ty}px) scale(${r.from.s})`;
354
- if (r.from.blur) el.style.filter = `blur(${r.from.blur}px)`;
537
+ rules.push(`[data-tc-reveal="${i}"]{opacity:${rec.from.o};transform:translate(${rec.from.tx}px, ${rec.from.ty}px) scale(${rec.from.s})${rec.from.blur ? `;filter:blur(${rec.from.blur}px)` : ''}}`);
355
538
  count++;
356
539
  });
540
+ if (rules.length) {
541
+ document.getElementById('tc-reveal-styles')?.remove();
542
+ const style = document.createElement('style');
543
+ style.id = 'tc-reveal-styles';
544
+ style.textContent = `/* trueclone seed: starting states of recorded scroll reveals (desktop); trueclone-reveals.js plays them */\n${rules.join('\n')}\n`;
545
+ document.head.appendChild(style);
546
+ }
357
547
  let classCount = 0;
358
548
  classes.forEach((c, i) => {
359
549
  const el = resolve(c.path);
@@ -362,7 +552,135 @@ function markRevealsInPage([reveals, classes]) {
362
552
  if (c.from !== null && c.from !== undefined) el.setAttribute('class', c.from);
363
553
  classCount++;
364
554
  });
365
- return [count, classCount];
555
+ let scrubCount = 0;
556
+ scrubs.forEach((s, i) => {
557
+ const el = resolve(s.path);
558
+ if (!el) return;
559
+ el.setAttribute('data-tc-scrub', String(i));
560
+ scrubCount++;
561
+ });
562
+ return [count, classCount, scrubCount];
563
+ }
564
+
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) {
571
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
572
+ const resolve = (p) => {
573
+ let el = document.body;
574
+ for (const part of p.split('>')) {
575
+ const [tag, n] = part.split(':');
576
+ const kids = [...el.children].filter((c) => !SKIP.has(c.tagName));
577
+ el = kids[Number(n) - 1];
578
+ if (!el || el.tagName.toLowerCase() !== tag) return null;
579
+ }
580
+ return el;
581
+ };
582
+ const MOTION = new Set(['transform', 'translate', 'rotate', 'scale', 'opacity', 'filter']);
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']);
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]; };
587
+ const rules = [];
588
+ const sel = (i) => `[data-tc-w="${i}"]`.repeat(8); // (0,8,0): above any ordinary author rule, below inline
589
+ let count = 0, dropped = 0;
590
+ const droppedPaths = [];
591
+ for (const [p, entry] of Object.entries(plan)) {
592
+ const el = resolve(p);
593
+ if (!el) continue;
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++; }
601
+ continue;
602
+ }
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));
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;
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 */ }
617
+ }
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);
630
+ }
631
+ return { elements: Object.keys(plan).length, rules: rules.length, dropped, droppedPaths };
632
+ }
633
+
634
+
635
+ // The mirror is a snapshot taken after the site's scripts ran, so it carries what they left in the DOM: GSAP's
636
+ // pin-spacer wrappers and the pin's inline geometry (fixed desktop pixels that break every other width), and canvas
637
+ // buffer sizes set for one viewport. A pin is a sticky element inside a spacer, which CSS does natively: the seed
638
+ // keeps the spacer as a plain block with the pin distance as padding (`data-tc-pin-spacer`), makes the element
639
+ // sticky, and records the distance on it (`data-tc-pin`), so layout and scroll states hold without a script. The
640
+ // clone's script may take the pin over (unwrap the spacer, drop the sticky style) and draws the marked canvases.
641
+ function cleanRuntimeInPage() {
642
+ const log = { pins: [], canvases: 0 };
643
+ const label = (el) => `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : ''}`;
644
+ // what ScrollTrigger writes on the pinned element while pinning (its own values live in the stylesheet)
645
+ const PIN_PROPS = ['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'];
646
+ // what the spacer copied from the element so the parent's flex/grid layout holds: kept; its geometry: dropped
647
+ const SPACER_KEEP = new Set(['order', 'place-self', 'grid-area', 'grid-column', 'grid-row', 'float', 'flex-shrink', 'flex-grow', 'flex-basis', 'display', 'align-self', 'justify-self']);
648
+ for (const spacer of document.querySelectorAll('.pin-spacer')) {
649
+ const pinned = spacer.firstElementChild;
650
+ const horizontal = !spacer.style.paddingBottom && !!spacer.style.paddingRight;
651
+ const distance = Math.round(parseFloat(horizontal ? spacer.style.paddingRight : spacer.style.paddingBottom) || 0);
652
+ if (!pinned) { spacer.replaceWith(...spacer.childNodes); continue; }
653
+ for (const p of PIN_PROPS) pinned.style.removeProperty(p);
654
+ pinned.setAttribute('data-tc-runtime', 'pin');
655
+ pinned.setAttribute('data-tc-pin', String(distance));
656
+ const keep = [...spacer.style].filter((p) => SPACER_KEEP.has(p)).map((p) => `${p}: ${spacer.style.getPropertyValue(p)}`);
657
+ spacer.removeAttribute('class');
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);
675
+ log.pins.push({ el: label(pinned), distance });
676
+ }
677
+ // a canvas keeps its buffer size: with only a CSS width, the height follows the buffer's aspect ratio, and the
678
+ // clone's own scene sets the buffer it needs; the mark says a script draws here
679
+ for (const c of document.querySelectorAll('canvas')) {
680
+ c.setAttribute('data-tc-runtime', 'canvas');
681
+ log.canvases++;
682
+ }
683
+ return log;
366
684
  }
367
685
 
368
686
 
@@ -378,41 +696,67 @@ function placeholdersInPage([lorem, rootSel]) {
378
696
  // Widths come from layout, not canvas: the reference words are measured with Ranges on the live text, candidates
379
697
  // in an isolated box that copies the text style (contain: layout, so measuring never re-lays out the page). All
380
698
  // reads happen before any text is written.
381
- 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');
382
703
  meter.style.cssText = 'position:absolute;left:-99999px;top:0;white-space:pre;contain:layout style paint;visibility:hidden';
383
704
  document.body.appendChild(meter);
384
705
  const PROPS = ['fontStyle', 'fontWeight', 'fontSize', 'fontFamily', 'fontStretch', 'fontVariant', 'fontFeatureSettings', 'fontKerning', 'letterSpacing', 'wordSpacing', 'textTransform'];
385
- let current = null;
386
- const cache = new Map();
387
- const widthIn = (s, word) => {
388
- const key = PROPS.map((p) => s[p]).join('|');
389
- let words = cache.get(key);
390
- if (!words) cache.set(key, (words = new Map()));
391
- if (!words.has(word)) {
392
- if (current !== key) { for (const p of PROPS) meter.style[p] = s[p]; current = key; }
393
- meter.textContent = word;
394
- 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 });
395
740
  }
396
- return words.get(word);
741
+ return f.lists.get(casing);
397
742
  };
398
743
  const RUN = /([\p{L}\p{N}]+)/u;
399
- const pick = (s, run, refWidth, state) => {
744
+ const pick = (f, run, refWidth, state) => {
400
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));
401
746
  const caps = run.length > 1 && run === run.toUpperCase() && /\p{L}/u.test(run);
402
747
  const cap = !caps && /\p{Lu}/u.test(run[0]);
403
- 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');
404
749
  const target = refWidth + state.drift;
405
750
  let best = null, bestGap = Infinity;
406
751
  state.seed = (state.seed * 31 + run.length) % 9973;
407
- for (let i = 0; i < lorem.length; i++) {
408
- const w = cased(lorem[(i + state.seed) % lorem.length]);
409
- const gap = Math.abs(widthIn(s, w) - target);
410
- 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; }
411
756
  }
412
757
  // No single word close enough (a 2px miss moves a line break): try two lorem words joined into one. Still one
413
758
  // word, so the break opportunities stay the reference's, but there are ~10^5 widths to choose from.
414
759
  if (bestGap > 0.75 && target > 30) {
415
- const sorted = lorem.map((w) => [widthIn(s, cased(w)), w]).sort((a, b) => a[0] - b[0]);
416
760
  const tail = (w) => (caps ? w.toUpperCase() : w); // only the first part carries the capital
417
761
  const tries = [];
418
762
  for (const [wa, a] of sorted) {
@@ -420,22 +764,23 @@ function placeholdersInPage([lorem, rootSel]) {
420
764
  if (need < 6) break;
421
765
  let lo = 0, hi = sorted.length - 1;
422
766
  while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid][0] < need) lo = mid + 1; else hi = mid; }
423
- 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])]);
424
768
  }
425
769
  tries.sort((x, y) => x[0] - y[0]);
426
- for (const [, word] of tries.slice(0, 12)) { // estimates; measure the real compound (kerning at the join)
427
- const gap = Math.abs(widthIn(s, word) - target);
428
- if (gap < bestGap) { best = word; bestGap = gap; }
429
- }
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
+ });
430
775
  }
431
- state.drift = target - widthIn(s, best);
776
+ state.drift = target - widthIn(f, best);
432
777
  return best;
433
778
  };
434
779
  // for attributes (alt, aria-label...): no layout to match, the element's own font is enough
435
780
  const shaper = (el) => {
436
- const s = getComputedStyle(el || document.body);
781
+ const f = fontOf(getComputedStyle(el || document.body));
437
782
  const state = { drift: 0, seed: 7 };
438
- 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('');
439
784
  };
440
785
 
441
786
  // text nodes: measure every original letter run first (reads only)
@@ -456,7 +801,7 @@ function placeholdersInPage([lorem, rootSel]) {
456
801
  const rects = [...r.getClientRects()];
457
802
  return rects.reduce((sum, x) => sum + x.width, 0) || null; // a run hidden by display:none measures 0
458
803
  });
459
- plan.push({ n, parts, widths, style: getComputedStyle(n.parentElement) });
804
+ plan.push({ n, parts, widths, font: fontOf(getComputedStyle(n.parentElement)) });
460
805
  }
461
806
  // then choose the words (writes go to the isolated box only), then write all the text
462
807
  const states = new Map();
@@ -465,7 +810,7 @@ function placeholdersInPage([lorem, rootSel]) {
465
810
  states.set(item.n.parentElement, state);
466
811
  item.text = item.parts.map((part, i) => {
467
812
  if (!/[\p{L}\p{N}]/u.test(part)) return part;
468
- 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);
469
814
  }).join('');
470
815
  }
471
816
  for (const item of plan) item.n.textContent = item.text;
@@ -511,3 +856,22 @@ function placeholdersInPage([lorem, rootSel]) {
511
856
  document.title = shaper(null)(document.title);
512
857
  return document.documentElement.outerHTML;
513
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
+ }