@trueclone/cli 0.2.0 → 0.3.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
@@ -56,15 +56,23 @@ verbum vereor vero verto verus vester veto vetus via video vinco vinum vir virgo
56
56
  voluntas vos votum vox vulnus vultus`;
57
57
  const LOREM = LOREM_TEXT.split(/\s+/).filter(Boolean);
58
58
 
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
+
59
62
  // Reveal player shipped with the clone (trueclone's own code): replays each recorded curve with the Web Animations
60
63
  // API once the element's top crosses the recorded trigger line; with reduced motion it shows the end state.
61
64
  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).
65
+ // reveals frame by frame, scroll-linked curves (parallax, scrubbed fades) by scroll position, and class changes (a
66
+ // header that gets its backdrop past 12px, an "is-visible" toggle). Records exist per width; the one for the
67
+ // visitor's width is used (desktop from 1100px, tablet from 600px, otherwise mobile).
63
68
  (() => {
64
69
  const raw = JSON.parse(document.getElementById('tc-reveals')?.textContent || '{}');
65
70
  const data = Array.isArray(raw) ? raw : raw.reveals || [];
66
71
  const classData = Array.isArray(raw) ? [] : raw.classes || [];
72
+ const scrubData = Array.isArray(raw) ? [] : raw.scrubs || [];
67
73
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
74
+ const vpName = innerWidth >= 1100 ? 'desktop' : innerWidth >= 600 ? 'tablet' : 'mobile';
75
+ const pick = (entry) => (entry && (entry.desktop || entry.tablet || entry.mobile) ? entry[vpName] || entry.desktop || entry.tablet || entry.mobile : entry);
68
76
 
69
77
  // class changes are state, not motion: they apply with reduced motion too
70
78
  let scrolled = false;
@@ -74,10 +82,12 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
74
82
  if (!c) continue;
75
83
  if (c.sticky) { pinned.push([el, c]); continue; }
76
84
  const line = Math.max(0, Math.min(1, c.trigger ?? 1));
85
+ // an element without a box (display: contents) never intersects: watch its first descendant that has one
86
+ const watched = el.getBoundingClientRect().height ? el : [...el.querySelectorAll('*')].find((d) => d.getBoundingClientRect().height) || el;
77
87
  new IntersectionObserver(([e], io) => {
78
88
  if (e.isIntersecting) { el.setAttribute('class', c.to); if (!c.reverts) io.disconnect(); }
79
89
  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);
90
+ }, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` }).observe(watched);
81
91
  }
82
92
  const applyPinned = () => {
83
93
  if (scrollY > 0) scrolled = true;
@@ -93,15 +103,55 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
93
103
  applyPinned();
94
104
  }
95
105
  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));
106
+ // scroll-linked curves: the recorded state at the two nearest scroll positions, interpolated. An element that also
107
+ // plays a one-shot reveal follows its curve once the reveal has finished.
108
+ // A curve is keyed by the element's own position in the viewport (key "v": its layout box top, translate taken
109
+ // out, the way a ScrollTrigger on the element sees it, so any viewport height gives the same curve) or, for an
110
+ // element inside a pinned stage, by the scroll position (key "y").
111
+ const scrubs = [];
112
+ for (const el of document.querySelectorAll('[data-tc-scrub]')) {
113
+ const rec = pick(scrubData[Number(el.dataset.tcScrub)]);
114
+ const s = Array.isArray(rec) ? rec : rec && rec.samples;
115
+ if (!s || s.length < 2) continue;
116
+ const key = !Array.isArray(rec) && rec.key === 'v' && s[0].v !== undefined ? 'v' : 'y';
117
+ scrubs.push({ el, key, s: key === 'v' ? [...s].sort((p, q) => p.v - q.v) : s, ty: 0, ready: !el.hasAttribute('data-tc-reveal') });
118
+ }
119
+ const at = (s, key, pos) => {
120
+ if (pos <= s[0][key]) return s[0];
121
+ for (let i = 1; i < s.length; i++) {
122
+ if (pos > s[i][key]) continue;
123
+ const a = s[i - 1], b = s[i], k = (pos - a[key]) / ((b[key] - a[key]) || 1);
124
+ const L = (p) => a[p] + (b[p] - a[p]) * k;
125
+ return { o: L('o'), tx: L('tx'), ty: L('ty'), s: L('s'), blur: L('blur') };
126
+ }
127
+ return s[s.length - 1];
128
+ };
129
+ // script.js takes an element over by removing its data-tc-scrub / data-tc-reveal mark: the player then leaves it alone
130
+ const applyScrubs = () => {
131
+ for (const x of scrubs) {
132
+ if (!x.ready || !x.el.hasAttribute('data-tc-scrub')) continue;
133
+ const pos = x.key === 'v' ? x.el.getBoundingClientRect().top - x.ty : scrollY;
134
+ const f = at(x.s, x.key, pos);
135
+ x.ty = f.ty;
136
+ Object.assign(x.el.style, style(f));
137
+ }
138
+ };
139
+ if (scrubs.length) {
140
+ let queued = false;
141
+ addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; applyScrubs(); }); } }, { passive: true });
142
+ applyScrubs();
143
+ }
144
+ const release = (el) => { const x = scrubs.find((q) => q.el === el); if (x) { x.ready = true; applyScrubs(); } };
145
+ const settle = (el, r) => { Object.assign(el.style, style(r.to)); release(el); };
97
146
  const play = (el, r) => {
98
147
  const frames = r.frames.map((f) => ({ ...style(f), offset: r.duration ? Math.min(1, f.t / r.duration) : 1 }));
99
148
  const anim = el.animate(frames, { duration: r.duration, fill: 'forwards', easing: 'linear' });
100
149
  anim.onfinish = () => { settle(el, r); anim.cancel(); };
101
150
  };
102
151
  for (const el of document.querySelectorAll('[data-tc-reveal]')) {
103
- const r = data[Number(el.dataset.tcReveal)];
104
- if (!r) continue;
152
+ const r = pick(data[Number(el.dataset.tcReveal)]);
153
+ if (!r) { release(el); continue; }
154
+ if (r.from) Object.assign(el.style, style(r.from)); // this width's starting state (the seed inlined the desktop one)
105
155
  if (reduce) { settle(el, r); continue; }
106
156
  // the recorded trigger is where the element's top was (as a share of the window height) when it started
107
157
  const line = Math.max(0, Math.min(1, r.trigger ?? 1));
@@ -109,18 +159,24 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
109
159
  for (const e of entries) {
110
160
  if (!e.isIntersecting) continue;
111
161
  io.disconnect();
112
- play(el, r);
162
+ if (el.hasAttribute('data-tc-reveal')) play(el, r);
113
163
  }
114
164
  }, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` });
115
165
  io.observe(el);
116
166
  }
167
+ // boot contract: the page shows once every clone script has run (DOMContentLoaded comes after the deferred ones;
168
+ // the Next app's injector releases after its last script) and every hold is released. script.js holds the boot
169
+ // while it prepares what a visitor must not see half-built (an intro waiting for its image): tcBoot.hold() at the
170
+ // top, tcBoot.release() when ready.
171
+ const boot = window.tcBoot = { holds: 1, hold() { this.holds++; }, release() { if (--this.holds <= 0) requestAnimationFrame(() => document.documentElement.classList.remove('tc-boot')); } };
172
+ if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => boot.release()); else if (!window.__tcInjected) boot.release();
117
173
  })();
118
174
  `;
119
175
 
120
176
  const { pos, flags } = parseArgs(process.argv.slice(2));
121
177
  const [name] = pos;
122
178
  if (!name) {
123
- console.error('usage: trueclone seed <name> [--force]');
179
+ console.error('usage: trueclone seed <name> [--force] [--hide-until-boot "<selectors a script rebuilds at boot>"]');
124
180
  process.exit(1);
125
181
  }
126
182
  const root = path.resolve('sites', name);
@@ -160,7 +216,8 @@ const pull = async (rel) => {
160
216
  // placeholders and a final sweep guarantees no reference path survives.
161
217
  const REF_ASSETS = '../reference/assets/';
162
218
  const rewrite = async (text, prefix, replacement, forPass = false) => {
163
- const re = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 'assets/([^"\')\\s]+)', 'g');
219
+ // saved asset names are [a-z0-9._-]; `&` ends a path inside a style attribute (url(&quot;../assets/x.webp&quot;))
220
+ const re = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 'assets/([^"\')\\s&]+)', 'g');
164
221
  for (const m of text.matchAll(re)) if (exact || isFont(m[1])) await pull(m[1]);
165
222
  return text.replace(re, (all, rel) => (exact || isFont(rel) ? `${replacement}assets/${rel}` : forPass ? `${REF_ASSETS}${rel}` : `${replacement}${PLACEHOLDER}`));
166
223
  };
@@ -186,44 +243,126 @@ if (!exact) {
186
243
  }
187
244
  // Scroll reveals recorded on the reference (trueclone reveals): mark the elements, start them in their recorded
188
245
  // 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 || [];
246
+ // One file per width when `reveals` ran at every width: elements are matched by path (the same DOM at every width)
247
+ // and the player uses the record for the visitor's width, so phone offsets are phone offsets, not desktop pixels.
248
+ const recs = {};
249
+ for (const v of ['desktop', 'tablet', 'mobile']) {
250
+ const r = JSON.parse(await fs.readFile(path.join(ref, `reveals.${v}.json`), 'utf8').catch(() => 'null'));
251
+ if (r) recs[v] = r;
252
+ }
253
+ const primary = recs.desktop || recs.tablet || recs.mobile || null;
254
+ const byPath = (key) => {
255
+ const m = new Map();
256
+ for (const [v, r] of Object.entries(recs)) for (const x of r[key] || []) {
257
+ if (!m.has(x.path)) m.set(x.path, { path: x.path, cls: x.cls });
258
+ m.get(x.path)[v] = x;
259
+ }
260
+ return [...m.values()];
261
+ };
262
+ const recorded = byPath('reveals');
263
+ const scrubbed = byPath('scrubs');
264
+ const classChanges = primary?.classes || [];
192
265
  // Widget states recorded on the reference (trueclone widgets): tabs, menus. Template mode only for now: the snapshots
193
266
  // are placeholdered like the page; exact-mode snapshots would still point at the site's media.
194
267
  const widgetRec = exact ? null : JSON.parse(await fs.readFile(path.join(ref, 'widgets.desktop.json'), 'utf8').catch(() => 'null'));
195
268
  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));
269
+ let marked = 0, classMarked = 0, scrubMarked = 0, widgets = null, runtime = null;
270
+ ({ html, marked, classMarked, scrubMarked, widgets, runtime } = await browserPass(!exact, recorded, classChanges, widgetGroups, scrubbed));
198
271
  if (!exact) html = html.split(REF_ASSETS).join('__tc_ref__').replace(/__tc_ref__[^"')\s]+/g, PLACEHOLDER);
199
272
  if (widgets?.groups.length) {
200
273
  // a plain script (not fetched JSON), so the clone also works opened from disk
201
274
  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
275
  html = html.replace(/<\/body>/i, '<script src="trueclone-widgets.js" defer></script>\n</body>');
203
276
  }
204
- if (marked || classMarked) {
277
+ if (marked || classMarked || scrubMarked) {
205
278
  await fs.writeFile(path.join(clone, 'trueclone-reveals.js'), PLAYER);
279
+ const perWidth = (r, shape) => Object.fromEntries(['desktop', 'tablet', 'mobile'].filter((v) => r[v]).map((v) => [v, shape(r[v])]));
206
280
  const data = JSON.stringify({
207
- reveals: recorded.map(({ trigger, duration, frames, to }) => ({ trigger, duration, frames, to })),
281
+ 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 }))),
208
283
  classes: classChanges.map(({ from, to, back, sticky, reverts, threshold, trigger }) => ({ from, to, back, sticky, reverts, threshold, trigger })),
209
284
  });
210
285
  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
286
  }
287
+ // Boot contract: <html class="tc-boot"> from the first byte until every clone script has run (the reveal player drops
288
+ // it after DOMContentLoaded; the Next app after its last script). Regions a script builds or re-lays out at boot
289
+ // (--hide-until-boot "<selectors>") stay invisible meanwhile, so a visitor never sees their captured, pre-script state;
290
+ // a 3 s failsafe shows everything if no script ever runs.
291
+ 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">`));
292
+ 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
293
  // the clone's own behaviour script, loaded once the DOM is parsed
213
294
  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
+ }
214
310
  await fs.writeFile(path.join(clone, 'index.html'), html);
215
311
 
312
+ // notes.md: the agent's record, with the sections the report reads back (Findings, Accepted differences). Written once.
313
+ const notesFile = path.join(root, 'notes.md');
314
+ if (!(await fs.stat(notesFile).then(() => true, () => false))) {
315
+ await fs.writeFile(notesFile, `# ${name}: clone notes
316
+
317
+ Reference: ${meta.url}. Mode: ${exact ? `exact (permission: ${meta.permission})` : 'template'}. Captured ${meta.capturedAt}.
318
+ Keep every section; the report reads "Findings" and "Accepted differences" back. One line per entry, no prose.
319
+
320
+ ## Stack
321
+
322
+ <!-- what the reference is built with: platform, libraries and versions, fonts, breakpoints (from motion.*.md, reference/code.md, the HAR) -->
323
+
324
+ ## Behaviours
325
+
326
+ <!-- the motion inventory and the interactions as a list: element, trigger, property, range, timing; one line each -->
327
+
328
+ ## Own-code map
329
+
330
+ <!-- file -> behaviours it implements -> where its parameters came from (a motion entry, code.md, a measured value) -->
331
+
332
+ ## Findings
333
+
334
+ <!-- defects in the tool, not in the site: symptom, cause if known, the workaround used. Listed in REPORT.md -->
335
+
336
+ ## Accepted differences
337
+
338
+ <!-- what cannot or should not match, one per line: "<check> <width> <where>: <why>" (random motion, a live feed, a
339
+ hover that lands by timing, a third-party embed). Listed first in REPORT.md; a check below target that is covered
340
+ here is marked "accepted" there. Every entry needs the why. -->
341
+
342
+ ## Unknowns
343
+
344
+ <!-- what you could not determine: a rule you could not read, a value you measured instead of derived -->
345
+ `);
346
+ }
347
+
216
348
  const scriptFile = path.join(clone, 'script.js');
217
349
  if (!(await fs.stat(scriptFile).then(() => true, () => false))) {
218
350
  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
351
  }
220
352
  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)' : ''}`);
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)' : ''}`);
354
+ if (runtime) {
355
+ const pins = runtime.pins.map((p) => `${p.el} pinned for ${p.distance}px`).join(', ');
356
+ 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)`);
358
+ if (runtime.hidden) console.log(`boot contract: ${runtime.hidden} element(s) hidden until the clone's scripts have run (data-tc-boot-hide)`);
359
+ }
222
360
  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(', ')}` : ''}`);
361
+ if (patches.length) console.log(`patches applied from clone/patches/: ${patches.join(', ')}`);
223
362
  console.log(`next: write clone/script.js (behaviour), then npx trueclone score ${name}`);
224
363
 
225
364
  // ---------- One browser pass over the seeded page: placeholders (template mode) and reveal marks ----------
226
- async function browserPass(template, reveals, classes, groups = []) {
365
+ async function browserPass(template, reveals, classes, groups = [], scrubs = []) {
227
366
  // served from sites/<name>/ so the pass can reach reference/assets (see `forPass`)
228
367
  const server = await serve(root);
229
368
  const browser = await openBrowser(flags.cdp);
@@ -231,7 +370,7 @@ async function browserPass(template, reveals, classes, groups = []) {
231
370
  const { page } = await newPage(browser, VIEWPORTS.find((v) => v.name === 'desktop'), {});
232
371
  await page.goto(`${server.url}clone/index.html`, { waitUntil: 'load', timeout: 60000 });
233
372
  await page.evaluate(() => document.fonts.ready);
234
- const counts = reveals.length || classes.length ? await page.evaluate(markRevealsInPage, [reveals, classes]) : [0, 0];
373
+ const counts = reveals.length || classes.length || scrubs.length ? await page.evaluate(markRevealsInPage, [reveals, classes, scrubs]) : [0, 0, 0];
235
374
  // widgets: mark regions and triggers, then give every recorded state the same placeholders as the page by
236
375
  // placing it into its region (so it is laid out and measured for real) and reading it back
237
376
  let widgets = null;
@@ -259,8 +398,24 @@ async function browserPass(template, reveals, classes, groups = []) {
259
398
  widgets.groups = Array.from(widgets.groups, (x) => x || null);
260
399
  }
261
400
  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 };
401
+ // last, because the recorded paths above were taken on the live page, pin-spacers included
402
+ const runtime = await page.evaluate(cleanRuntimeInPage);
403
+ // regions the clone's script builds at boot, hidden until it has run (see the boot contract below)
404
+ runtime.hidden = typeof flags['hide-until-boot'] === 'string'
405
+ ? 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
+ : 0;
407
+ // inline styles that differ between widths (script-computed sizes and offsets) become media-query rules
408
+ 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;
410
+ // getHTML keeps the declarative shadow roots the capture serialised (outerHTML would drop them); it is the inner
411
+ // HTML, so the <html> tag and its attributes (font classes, dark mode, lang) are written back around it
412
+ const out = await page.evaluate(() => {
413
+ const el = document.documentElement;
414
+ const inner = el.getHTML ? el.getHTML({ serializableShadowRoots: true }) : el.innerHTML;
415
+ const attrs = [...el.attributes].map((a) => ` ${a.name}="${a.value.replace(/&/g, '&amp;').replace(/"/g, '&quot;')}"`).join('');
416
+ return `<html${attrs}>${inner}</html>`;
417
+ });
418
+ return { html: `<!DOCTYPE html>\n${out}`, marked: counts[0], classMarked: counts[1], scrubMarked: counts[2], widgets, runtime };
264
419
  } finally {
265
420
  if (!flags.cdp) await browser.close();
266
421
  server.close();
@@ -332,7 +487,7 @@ function widgetPlayer() {
332
487
  });
333
488
  }
334
489
 
335
- function markRevealsInPage([reveals, classes]) {
490
+ function markRevealsInPage([reveals, classes, scrubs = []]) {
336
491
  const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
337
492
  const resolve = (p) => {
338
493
  let el = document.body;
@@ -344,16 +499,25 @@ function markRevealsInPage([reveals, classes]) {
344
499
  }
345
500
  return el;
346
501
  };
502
+ // each reveal's starting state is a rule on its mark (no flash before the player runs; the player then applies the
503
+ // visitor's width); script.js hands an element over by removing the mark, which releases the state with it
504
+ const rules = [];
347
505
  let count = 0;
348
506
  reveals.forEach((r, i) => {
349
507
  const el = resolve(r.path);
350
508
  if (!el) return;
509
+ const rec = r.desktop || r.tablet || r.mobile || r;
351
510
  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)`;
511
+ 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
512
  count++;
356
513
  });
514
+ if (rules.length) {
515
+ document.getElementById('tc-reveal-styles')?.remove();
516
+ const style = document.createElement('style');
517
+ style.id = 'tc-reveal-styles';
518
+ style.textContent = `/* trueclone seed: starting states of recorded scroll reveals (desktop); trueclone-reveals.js plays them */\n${rules.join('\n')}\n`;
519
+ document.head.appendChild(style);
520
+ }
357
521
  let classCount = 0;
358
522
  classes.forEach((c, i) => {
359
523
  const el = resolve(c.path);
@@ -362,7 +526,134 @@ function markRevealsInPage([reveals, classes]) {
362
526
  if (c.from !== null && c.from !== undefined) el.setAttribute('class', c.from);
363
527
  classCount++;
364
528
  });
365
- return [count, classCount];
529
+ let scrubCount = 0;
530
+ scrubs.forEach((s, i) => {
531
+ const el = resolve(s.path);
532
+ if (!el) return;
533
+ el.setAttribute('data-tc-scrub', String(i));
534
+ scrubCount++;
535
+ });
536
+ return [count, classCount, scrubCount];
537
+ }
538
+
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]) {
546
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
547
+ const resolve = (p) => {
548
+ let el = document.body;
549
+ for (const part of p.split('>')) {
550
+ const [tag, n] = part.split(':');
551
+ const kids = [...el.children].filter((c) => !SKIP.has(c.tagName));
552
+ el = kids[Number(n) - 1];
553
+ if (!el || el.tagName.toLowerCase() !== tag) return null;
554
+ }
555
+ return el;
556
+ };
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
+ const MOTION = new Set(['transform', 'translate', 'rotate', 'scale', 'opacity', 'filter']);
568
+ 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])));
571
+ const rules = [];
572
+ 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;
579
+ const el = resolve(p);
580
+ 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
+ }
595
+ continue;
596
+ }
597
+ 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} } }`);
610
+ }
611
+ }
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);
617
+ }
618
+ return count;
619
+ }
620
+
621
+ // The mirror is a snapshot taken after the site's scripts ran, so it carries what they left in the DOM: GSAP's
622
+ // pin-spacer wrappers and the pin's inline geometry (fixed desktop pixels that break every other width), and canvas
623
+ // buffer sizes set for one viewport. A pin is a sticky element inside a spacer, which CSS does natively: the seed
624
+ // keeps the spacer as a plain block with the pin distance as padding (`data-tc-pin-spacer`), makes the element
625
+ // sticky, and records the distance on it (`data-tc-pin`), so layout and scroll states hold without a script. The
626
+ // clone's script may take the pin over (unwrap the spacer, drop the sticky style) and draws the marked canvases.
627
+ function cleanRuntimeInPage() {
628
+ const log = { pins: [], canvases: 0 };
629
+ const label = (el) => `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : ''}`;
630
+ // what ScrollTrigger writes on the pinned element while pinning (its own values live in the stylesheet)
631
+ 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'];
632
+ // what the spacer copied from the element so the parent's flex/grid layout holds: kept; its geometry: dropped
633
+ 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']);
634
+ for (const spacer of document.querySelectorAll('.pin-spacer')) {
635
+ const pinned = spacer.firstElementChild;
636
+ const horizontal = !spacer.style.paddingBottom && !!spacer.style.paddingRight;
637
+ const distance = Math.round(parseFloat(horizontal ? spacer.style.paddingRight : spacer.style.paddingBottom) || 0);
638
+ if (!pinned) { spacer.replaceWith(...spacer.childNodes); continue; }
639
+ for (const p of PIN_PROPS) pinned.style.removeProperty(p);
640
+ pinned.style.position = 'sticky';
641
+ pinned.style.top = '0px';
642
+ pinned.setAttribute('data-tc-runtime', 'pin');
643
+ pinned.setAttribute('data-tc-pin', String(distance));
644
+ const keep = [...spacer.style].filter((p) => SPACER_KEEP.has(p)).map((p) => `${p}: ${spacer.style.getPropertyValue(p)}`);
645
+ spacer.removeAttribute('class');
646
+ spacer.setAttribute('style', `${keep.join('; ')}${keep.length ? '; ' : ''}${horizontal ? 'padding-right' : 'padding-bottom'}: ${distance}px`);
647
+ spacer.setAttribute('data-tc-pin-spacer', String(distance));
648
+ log.pins.push({ el: label(pinned), distance });
649
+ }
650
+ // a canvas keeps its buffer size: with only a CSS width, the height follows the buffer's aspect ratio, and the
651
+ // clone's own scene sets the buffer it needs; the mark says a script draws here
652
+ for (const c of document.querySelectorAll('canvas')) {
653
+ c.setAttribute('data-tc-runtime', 'canvas');
654
+ log.canvases++;
655
+ }
656
+ return log;
366
657
  }
367
658
 
368
659