@trueclone/cli 0.1.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
@@ -1,29 +1,193 @@
1
- // Exact mode: start the clone from the reference mirror instead of an empty folder. The mirror is the rendered page
2
- // with every script and inline handler removed and its stylesheets and media saved locally, so the seeded clone has
3
- // the site's own markup, text, styles and media at the right paths. What it lacks is behaviour: write that in
4
- // clone/script.js (your own code, using the parameters you read from the site), then verify with the usual scores.
1
+ // Start the clone from the reference mirror instead of an empty folder. The mirror is the rendered page with every
2
+ // script and inline handler removed and its stylesheets and media saved locally, so the seeded clone has the site's
3
+ // structure and styles at the right paths. What it lacks is behaviour: write that in clone/script.js (your own
4
+ // code, using the parameters you read from the site), then verify with the usual scores.
5
5
  //
6
- // Template clones are never seeded: their text and media must be placeholders.
6
+ // exact mode (the site's own content, with recorded permission): markup, text, styles and media as they are.
7
+ // template mode: the same markup and styles, but every text becomes lorem words of the same rendered width (so lines
8
+ // break where the reference's break), every image, video, large SVG and iframe a same-size placeholder, every link
9
+ // "#", and brand metadata is dropped. No text or media of the site ends up in clone/. Fonts stay for local study:
10
+ // swap licensed fonts before reusing the result.
7
11
  //
8
12
  // usage: node scripts/seed.mjs <name> [--force] (--force overwrites an existing clone/)
9
13
  import fs from 'node:fs/promises';
10
14
  import path from 'node:path';
11
- import { parseArgs } from './lib.mjs';
15
+ import { parseArgs, openBrowser, newPage, serve, VIEWPORTS } from './lib.mjs';
16
+
17
+ // placeholder vocabulary, wide enough to match most word widths
18
+ const LOREM_TEXT = `lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore
19
+ magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute
20
+ irure in reprehenderit voluptate velit esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt
21
+ culpa qui officia deserunt mollit anim id est laborum curabitur pretium tincidunt lacus nunc vitae faucibus porta felis
22
+ mauris viverra diam vel augue blandit nibh sapien rhoncus massa feugiat tellus integer vulputate morbi risus quam varius
23
+ orci nam libero justo laoreet sem lectus mattis praesent semper ante gravida eget fermentum turpis sodales neque arcu
24
+ egestas phasellus volutpat donec pharetra aenean placerat lobortis primis cras tortor iaculis lacinia suspendisse potenti
25
+ vivamus ultricies posuere cubilia curae maecenas ligula proin accumsan quisque ornare urna pellentesque habitant tristique
26
+ senectus netus malesuada fames ac a at nec mi ne te se eu per
27
+ abeo absum accedo accipio acer acies actus addo adeo adhuc aditus adsum aequor aequus aer aetas ager agito agmen ago
28
+ aio alacer albus alias alius alo alter altus amicus amo amor amplus anima animus annus ante antea aperio apud aqua arbor
29
+ arcus ardeo argentum arma ars artus arx asper astrum atque atrox audax audio aureus auris aurum aut autem auxilium avis
30
+ bellum bene bonus bos brevis cado caecus caelum calamus campus candidus canis cano capio caput carmen carus castra causa
31
+ cedo celer cena certus cervus ceteri cibus cinis circa cito civis clamo clarus claudo clavis coepi cogito cognosco cohors
32
+ colo color comes communis concilium condo confero coniunx consilium constat contra copia cor corpus cras credo creo cresco
33
+ crimen cubo culpa cum cunctus cupio cur cura curro cursus custos damnum de debeo decet decus deinde denique dens desino
34
+ deus dexter dico dies dignus diligo discipulus diu diversus divus do doceo dolor domus donum dorsum dubius duco dulcis dum
35
+ durus dux ecce edo effero ego emo eo epistula equus ergo erro et etiam excito exemplum exeo exerceo exitus expedio facies
36
+ facilis facio factum fallo fama fames fatum faveo felix femina fere fero ferrum fessus fides filius finis fio flamma flecto
37
+ fleo flos flumen fluo foedus folium fons fores forma fortis fortuna forum frango frater frons fructus frustra fuga fugio
38
+ fundo fundus genus gens gero gladius gloria gradus gratia gravis habeo haud herba heri hic hiems hodie homo honor hora
39
+ hortus hostis humus iaceo iam ianua ibi idem ignis ille imago imber imperium impero incipio inde ingens inimicus initium
40
+ iniuria inquam insula inter interea ipse ira iter iubeo iungo ius iuvenis labor lacrima laetus lapis late latus laudo laus
41
+ lectus legio lego lenis levis lex liber libertas licet limen lingua littera litus locus longus loquor luceo ludus lumen
42
+ luna lux magister maior malus maneo manus mare mater medius memoria mens mensa mereo metus meus miles mirus miser mitto
43
+ modo modus moenia moneo mons mora morbus morior mors mos moveo mox mulier multus munus murus muto narro nascor natura navis
44
+ nemo nemus nescio niger nihil nimis nisi nobilis noceo nomen nos noster novus nox nullus numen numerus nunquam nuntius
45
+ oculus odium officium olim omnis onus opera oppidum ops optimus opus ora orbis ordo orior os otium pando par parens paro
46
+ pars parvus pater patior patria pauci pax pectus pecunia pello pendeo pereo periculum pes peto pietas placeo plenus
47
+ plurimus poena pono pons populus porta posco possum post potens praeda praemium premo pretium primus princeps prior pro
48
+ procul prope propter proximus puella puer pugna pulcher puto qualis quando quantus quare quasi quidem quies quinque quoque
49
+ rapio ratio recipio reddo redeo refero regio regnum rego relinquo res respondeo rex rideo ripa rogo ruo rursus sacer
50
+ saepe saevus salus sanguis sapiens satis saxum scelus scio scribo secundus securus sedeo semel senex sensus sentio sermo
51
+ servo servus sic sidus signum silva similis simul sine socius sol soleo solus somnus sonus soror spatium species spero
52
+ spes spiritus statim statuo sto studium sub subito summus super surgo suus tacitus talis tam tamen tandem tango tantus
53
+ tectum tellus telum templum tempus teneo tener terra terreo tertius timeo tollo totus traho trans tristis tu tum turba
54
+ tutus tuus ubi ullus umbra umquam unda unde unus urbs usus uxor vaco valeo vallis varius vates veho vel venio ventus
55
+ verbum vereor vero verto verus vester veto vetus via video vinco vinum vir virgo virtus vis vita vito vivo vix voco volo
56
+ voluntas vos votum vox vulnus vultus`;
57
+ const LOREM = LOREM_TEXT.split(/\s+/).filter(Boolean);
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
+
62
+ // Reveal player shipped with the clone (trueclone's own code): replays each recorded curve with the Web Animations
63
+ // API once the element's top crosses the recorded trigger line; with reduced motion it shows the end state.
64
+ const PLAYER = `// trueclone reveal player: replays what was recorded on the reference while scrolling (trueclone reveals): one-shot
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).
68
+ (() => {
69
+ const raw = JSON.parse(document.getElementById('tc-reveals')?.textContent || '{}');
70
+ const data = Array.isArray(raw) ? raw : raw.reveals || [];
71
+ const classData = Array.isArray(raw) ? [] : raw.classes || [];
72
+ const scrubData = Array.isArray(raw) ? [] : raw.scrubs || [];
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);
76
+
77
+ // class changes are state, not motion: they apply with reduced motion too
78
+ let scrolled = false;
79
+ const pinned = [];
80
+ for (const el of document.querySelectorAll('[data-tc-class]')) {
81
+ const c = classData[Number(el.dataset.tcClass)];
82
+ if (!c) continue;
83
+ if (c.sticky) { pinned.push([el, c]); continue; }
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;
87
+ new IntersectionObserver(([e], io) => {
88
+ if (e.isIntersecting) { el.setAttribute('class', c.to); if (!c.reverts) io.disconnect(); }
89
+ else if (c.reverts && el.getAttribute('class') === c.to) el.setAttribute('class', c.back ?? c.from);
90
+ }, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` }).observe(watched);
91
+ }
92
+ const applyPinned = () => {
93
+ if (scrollY > 0) scrolled = true;
94
+ for (const [el, c] of pinned) {
95
+ const past = scrollY >= (c.threshold ?? 1);
96
+ if (past) el.setAttribute('class', c.to);
97
+ else if (c.reverts && scrolled) el.setAttribute('class', c.back ?? c.from);
98
+ }
99
+ };
100
+ if (pinned.length) {
101
+ let queued = false;
102
+ addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; applyPinned(); }); } }, { passive: true });
103
+ applyPinned();
104
+ }
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' });
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); };
146
+ const play = (el, r) => {
147
+ const frames = r.frames.map((f) => ({ ...style(f), offset: r.duration ? Math.min(1, f.t / r.duration) : 1 }));
148
+ const anim = el.animate(frames, { duration: r.duration, fill: 'forwards', easing: 'linear' });
149
+ anim.onfinish = () => { settle(el, r); anim.cancel(); };
150
+ };
151
+ for (const el of document.querySelectorAll('[data-tc-reveal]')) {
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)
155
+ if (reduce) { settle(el, r); continue; }
156
+ // the recorded trigger is where the element's top was (as a share of the window height) when it started
157
+ const line = Math.max(0, Math.min(1, r.trigger ?? 1));
158
+ const io = new IntersectionObserver((entries) => {
159
+ for (const e of entries) {
160
+ if (!e.isIntersecting) continue;
161
+ io.disconnect();
162
+ if (el.hasAttribute('data-tc-reveal')) play(el, r);
163
+ }
164
+ }, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` });
165
+ io.observe(el);
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();
173
+ })();
174
+ `;
12
175
 
13
176
  const { pos, flags } = parseArgs(process.argv.slice(2));
14
177
  const [name] = pos;
15
178
  if (!name) {
16
- console.error('usage: npm run seed -- <name> [--force]');
179
+ console.error('usage: trueclone seed <name> [--force] [--hide-until-boot "<selectors a script rebuilds at boot>"]');
17
180
  process.exit(1);
18
181
  }
19
182
  const root = path.resolve('sites', name);
20
183
  const ref = path.join(root, 'reference');
21
184
  const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
22
- if (meta.mode !== 'exact' || !meta.permission) {
23
- console.error('seed is for exact mode only (capture with --mode exact --permission "<owner>"); template clones use placeholder content');
185
+ const exact = meta.mode === 'exact' && Boolean(meta.permission);
186
+ const mirror = path.join(ref, 'mirror');
187
+ if (!(await fs.stat(path.join(mirror, 'index.html')).then(() => true, () => false))) {
188
+ console.error(`no mirror yet: run trueclone capture ${meta.url} ${name} --mirror-only`);
24
189
  process.exit(1);
25
190
  }
26
- const mirror = path.join(ref, 'mirror');
27
191
  const clone = path.join(root, 'clone');
28
192
  const exists = await fs.stat(path.join(clone, 'index.html')).then(() => true, () => false);
29
193
  if (exists && !flags.force) {
@@ -33,36 +197,608 @@ if (exists && !flags.force) {
33
197
  await fs.mkdir(path.join(clone, 'css'), { recursive: true });
34
198
 
35
199
  // Assets the mirror points at (../assets/... from the page, ../../assets/... from mirror/css) are copied into
36
- // clone/assets and the paths rewritten, so clone/ is self-contained.
200
+ // clone/assets and the paths rewritten, so clone/ is self-contained. Template mode only takes fonts; every other
201
+ // asset reference becomes the placeholder.
202
+ const PLACEHOLDER = 'assets/placeholder.svg';
203
+ const PLACEHOLDER_BG = 'assets/placeholder-bg.svg'; // transparent: for CSS backgrounds
37
204
  const copied = new Set();
205
+ const isFont = (rel) => /\.(woff2?|ttf|otf|eot)([?#]|$)/i.test(rel) || /^font\//.test(rel);
38
206
  const pull = async (rel) => {
39
207
  const clean = decodeURIComponent(rel.split(/[?#]/)[0]);
40
208
  if (copied.has(clean)) return;
41
209
  copied.add(clean);
42
- const from = path.join(ref, 'assets', clean);
43
210
  const to = path.join(clone, 'assets', clean);
44
211
  await fs.mkdir(path.dirname(to), { recursive: true });
45
- await fs.copyFile(from, to).catch(() => console.warn(`WARNING missing asset ${clean}`));
212
+ await fs.copyFile(path.join(ref, 'assets', clean), to).catch(() => console.warn(`WARNING missing asset ${clean}`));
46
213
  };
47
- const rewrite = async (text, prefix, replacement) => {
48
- const re = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 'assets/([^"\')\\s]+)', 'g');
49
- for (const m of text.matchAll(re)) await pull(m[1]);
50
- return text.replace(re, (all, rel) => `${replacement}assets/${rel}`);
214
+ // `forPass`: the page's media point at the original files in reference/ for the browser pass only, so images keep
215
+ // their intrinsic size (an image laid out by its aspect ratio needs it); the pass replaces them with same-size
216
+ // placeholders and a final sweep guarantees no reference path survives.
217
+ const REF_ASSETS = '../reference/assets/';
218
+ const rewrite = async (text, prefix, replacement, forPass = false) => {
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');
221
+ for (const m of text.matchAll(re)) if (exact || isFont(m[1])) await pull(m[1]);
222
+ return text.replace(re, (all, rel) => (exact || isFont(rel) ? `${replacement}assets/${rel}` : forPass ? `${REF_ASSETS}${rel}` : `${replacement}${PLACEHOLDER}`));
51
223
  };
52
224
 
53
225
  for (const file of await fs.readdir(path.join(mirror, 'css')).catch(() => [])) {
54
226
  const css = await fs.readFile(path.join(mirror, 'css', file), 'utf8');
55
- await fs.writeFile(path.join(clone, 'css', file), await rewrite(css, '../../', '../'));
227
+ let out = await rewrite(css, '../../', '../');
228
+ // template mode: images the capture never downloaded still point at the site (url("https://…/bg.avif"));
229
+ // every non-font url() becomes a transparent placeholder (CSS backgrounds are mostly decorative: noise, grids,
230
+ // glows), so the clone never loads the site's media. url(#id) points inside the page (SVG paint) and stays.
231
+ if (!exact) out = out.replace(/url\(\s*(['"]?)(?!data:)([^'")]+)\1\s*\)/g, (all, q, u) => (isFont(u) || u.trim().startsWith('#') ? all : `url("../${PLACEHOLDER_BG}")`));
232
+ await fs.writeFile(path.join(clone, 'css', file), out);
56
233
  }
57
234
  let html = await fs.readFile(path.join(mirror, 'index.html'), 'utf8');
58
- html = await rewrite(html, '../', '');
235
+ html = await rewrite(html, '../', '', !exact);
236
+ await fs.writeFile(path.join(clone, 'index.html'), html);
237
+
238
+ if (!exact) {
239
+ // a neutral box that reads on light and dark pages
240
+ await fs.mkdir(path.join(clone, 'assets'), { recursive: true });
241
+ await fs.writeFile(path.join(clone, PLACEHOLDER), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" preserveAspectRatio="none"><rect width="16" height="16" fill="#8884"/></svg>\n');
242
+ await fs.writeFile(path.join(clone, PLACEHOLDER_BG), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"/>\n');
243
+ }
244
+ // Scroll reveals recorded on the reference (trueclone reveals): mark the elements, start them in their recorded
245
+ // starting state, and replay the recorded curves with a small player when they scroll into view.
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 || [];
265
+ // Widget states recorded on the reference (trueclone widgets): tabs, menus. Template mode only for now: the snapshots
266
+ // are placeholdered like the page; exact-mode snapshots would still point at the site's media.
267
+ const widgetRec = exact ? null : JSON.parse(await fs.readFile(path.join(ref, 'widgets.desktop.json'), 'utf8').catch(() => 'null'));
268
+ const widgetGroups = widgetRec?.groups || [];
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));
271
+ if (!exact) html = html.split(REF_ASSETS).join('__tc_ref__').replace(/__tc_ref__[^"')\s]+/g, PLACEHOLDER);
272
+ if (widgets?.groups.length) {
273
+ // a plain script (not fetched JSON), so the clone also works opened from disk
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`);
275
+ html = html.replace(/<\/body>/i, '<script src="trueclone-widgets.js" defer></script>\n</body>');
276
+ }
277
+ if (marked || classMarked || scrubMarked) {
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])]));
280
+ const data = JSON.stringify({
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 }))),
283
+ classes: classChanges.map(({ from, to, back, sticky, reverts, threshold, trigger }) => ({ from, to, back, sticky, reverts, threshold, trigger })),
284
+ });
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>`);
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>');
59
293
  // the clone's own behaviour script, loaded once the DOM is parsed
60
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
+ }
61
310
  await fs.writeFile(path.join(clone, 'index.html'), html);
62
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
+
63
348
  const scriptFile = path.join(clone, 'script.js');
64
349
  if (!(await fs.stat(scriptFile).then(() => true, () => false))) {
65
- await fs.writeFile(scriptFile, `// ${meta.url} - clone behaviour (your own code). The markup, styles and media come from the mirror.\n// Rebuild every interaction from the reference's parameters; verify with npm run score -- ${name}.\n`);
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`);
351
+ }
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)'}`);
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
+ }
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(', ')}`);
362
+ console.log(`next: write clone/script.js (behaviour), then npx trueclone score ${name}`);
363
+
364
+ // ---------- One browser pass over the seeded page: placeholders (template mode) and reveal marks ----------
365
+ async function browserPass(template, reveals, classes, groups = [], scrubs = []) {
366
+ // served from sites/<name>/ so the pass can reach reference/assets (see `forPass`)
367
+ const server = await serve(root);
368
+ const browser = await openBrowser(flags.cdp);
369
+ try {
370
+ const { page } = await newPage(browser, VIEWPORTS.find((v) => v.name === 'desktop'), {});
371
+ await page.goto(`${server.url}clone/index.html`, { waitUntil: 'load', timeout: 60000 });
372
+ await page.evaluate(() => document.fonts.ready);
373
+ const counts = reveals.length || classes.length || scrubs.length ? await page.evaluate(markRevealsInPage, [reveals, classes, scrubs]) : [0, 0, 0];
374
+ // widgets: mark regions and triggers, then give every recorded state the same placeholders as the page by
375
+ // placing it into its region (so it is laid out and measured for real) and reading it back
376
+ let widgets = null;
377
+ if (groups.length) {
378
+ const ok = await page.evaluate(markWidgetsInPage, groups);
379
+ await page.evaluate(`window.__tcPlaceholders = ${placeholdersInPage.toString()}`);
380
+ widgets = { groups: [], states: 0 };
381
+ for (const g of groups) {
382
+ if (!ok[g.i]) continue;
383
+ const shape = (stateHtml) => (stateHtml == null ? null : page.evaluate(([i, h, lorem, t]) => {
384
+ const scope = document.querySelector(`[data-tc-scope~="${i}"]`);
385
+ if (!scope) return null;
386
+ const before = scope.innerHTML;
387
+ scope.innerHTML = h;
388
+ const out = t ? window.__tcPlaceholders([lorem, `[data-tc-scope~="${i}"]`]) : scope.innerHTML;
389
+ scope.innerHTML = before;
390
+ return out;
391
+ }, [g.i, stateHtml, LOREM, template]));
392
+ const states = [];
393
+ for (const st of g.states) states.push(await shape(st));
394
+ const closed = g.kind === 'expand' ? await shape(g.closed) : undefined;
395
+ widgets.states += states.filter(Boolean).length;
396
+ widgets.groups[g.i] = { kind: g.kind, states, closed };
397
+ }
398
+ widgets.groups = Array.from(widgets.groups, (x) => x || null);
399
+ }
400
+ if (template) await page.evaluate(placeholdersInPage, [LOREM, null]);
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 };
419
+ } finally {
420
+ if (!flags.cdp) await browser.close();
421
+ server.close();
422
+ }
423
+ }
424
+
425
+ // The path of element children recorded by reveals.mjs (script/style/meta-like tags skipped) resolves to the same
426
+ // element in the script-free mirror. Each reveal gets data-tc-reveal and its recorded starting state inline; each
427
+ // scroll class change gets data-tc-class and its starting classes (the page as it is before any scroll).
428
+ // Widget regions and triggers, resolved by the recorded paths in the mirror (which shows each widget's initial
429
+ // state). A region can serve several widgets (every menu of a nav): data-tc-scope holds a space-separated list.
430
+ function markWidgetsInPage(groups) {
431
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
432
+ const resolve = (p) => {
433
+ let el = document.body;
434
+ for (const part of p.split('>')) {
435
+ const [tag, n] = part.split(':');
436
+ el = [...el.children].filter((c) => !SKIP.has(c.tagName))[Number(n) - 1];
437
+ if (!el || el.tagName.toLowerCase() !== tag) return null;
438
+ }
439
+ return el;
440
+ };
441
+ const ok = {};
442
+ for (const g of groups) {
443
+ const scope = resolve(g.scopePath);
444
+ const triggers = g.triggers.map((t) => resolve(t.path));
445
+ if (!scope || triggers.some((t) => !t)) continue;
446
+ const ids = (scope.getAttribute('data-tc-scope') || '').split(' ').filter(Boolean);
447
+ ids.push(String(g.i));
448
+ scope.setAttribute('data-tc-scope', ids.join(' '));
449
+ triggers.forEach((t, j) => t.setAttribute('data-tc-trigger', `${g.i}:${j}`));
450
+ ok[g.i] = true;
451
+ }
452
+ return ok;
453
+ }
454
+
455
+ // Widget player shipped with the clone (trueclone's own code, serialised into clone/trueclone-widgets.js): a click on
456
+ // a trigger swaps its region to the recorded state; an expandable trigger toggles between its open and closed state;
457
+ // menus in a header or nav also open on hover and close when the pointer leaves their region, like the reference.
458
+ function widgetPlayer() {
459
+ const data = window.__tcWidgetData || { groups: [] };
460
+ const regionOf = (i) => document.querySelector(`[data-tc-scope~="${i}"]`);
461
+ const show = (i, html) => { const r = regionOf(i); if (r && html != null && r.innerHTML !== html) r.innerHTML = html; };
462
+ const parse = (t) => t.dataset.tcTrigger.split(':').map(Number);
463
+ document.addEventListener('click', (e) => {
464
+ const t = e.target.closest('[data-tc-trigger]');
465
+ if (!t) return;
466
+ const [i, j] = parse(t);
467
+ const g = data.groups[i];
468
+ if (!g) return;
469
+ e.preventDefault();
470
+ if (g.kind === 'tabs') show(i, g.states[j]);
471
+ else show(i, t.getAttribute('aria-expanded') === 'true' ? g.closed : g.states[j]);
472
+ });
473
+ const hoverMenu = (t) => t.closest('header, nav') && data.groups[parse(t)[0]]?.kind === 'expand';
474
+ document.addEventListener('pointerover', (e) => {
475
+ const t = e.target.closest('[data-tc-trigger]');
476
+ if (!t || !hoverMenu(t) || t.getAttribute('aria-expanded') === 'true') return;
477
+ const [i, j] = parse(t);
478
+ show(i, data.groups[i].states[j]);
479
+ });
480
+ document.addEventListener('pointerout', (e) => {
481
+ for (const [i, g] of data.groups.entries()) {
482
+ if (!g || g.kind !== 'expand') continue;
483
+ const r = regionOf(i);
484
+ if (!r || !r.contains(e.target) || r.contains(e.relatedTarget)) continue;
485
+ if (r.querySelector(`[data-tc-trigger^="${i}:"][aria-expanded="true"]`)) show(i, g.closed);
486
+ }
487
+ });
488
+ }
489
+
490
+ function markRevealsInPage([reveals, classes, scrubs = []]) {
491
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
492
+ const resolve = (p) => {
493
+ let el = document.body;
494
+ for (const part of p.split('>')) {
495
+ const [tag, n] = part.split(':');
496
+ const kids = [...el.children].filter((c) => !SKIP.has(c.tagName));
497
+ el = kids[Number(n) - 1];
498
+ if (!el || el.tagName.toLowerCase() !== tag) return null;
499
+ }
500
+ return el;
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 = [];
505
+ let count = 0;
506
+ reveals.forEach((r, i) => {
507
+ const el = resolve(r.path);
508
+ if (!el) return;
509
+ const rec = r.desktop || r.tablet || r.mobile || r;
510
+ el.setAttribute('data-tc-reveal', String(i));
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)` : ''}}`);
512
+ count++;
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
+ }
521
+ let classCount = 0;
522
+ classes.forEach((c, i) => {
523
+ const el = resolve(c.path);
524
+ if (!el) return;
525
+ el.setAttribute('data-tc-class', String(i));
526
+ if (c.from !== null && c.from !== undefined) el.setAttribute('class', c.from);
527
+ classCount++;
528
+ });
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;
657
+ }
658
+
659
+
660
+ function placeholdersInPage([lorem, rootSel]) {
661
+ // the whole page, or one region (a widget's recorded state placed into the page to be measured)
662
+ const root = rootSel ? document.querySelector(rootSel) : document.body;
663
+ if (!root) return null;
664
+ // Each letter run becomes a lorem word of (nearly) the same laid-out width in the element's own font, keeping its
665
+ // capitalisation; the running width is tracked across an element's text so line breaks land where the reference's
666
+ // do (a 2px drift is enough to move a break on a full line). Digits keep their count (years stay), punctuation and
667
+ // whitespace stay exactly as they were.
668
+ //
669
+ // Widths come from layout, not canvas: the reference words are measured with Ranges on the live text, candidates
670
+ // in an isolated box that copies the text style (contain: layout, so measuring never re-lays out the page). All
671
+ // reads happen before any text is written.
672
+ const meter = document.createElement('span');
673
+ meter.style.cssText = 'position:absolute;left:-99999px;top:0;white-space:pre;contain:layout style paint;visibility:hidden';
674
+ document.body.appendChild(meter);
675
+ 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);
686
+ }
687
+ return words.get(word);
688
+ };
689
+ const RUN = /([\p{L}\p{N}]+)/u;
690
+ const pick = (s, run, refWidth, state) => {
691
+ 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
+ const caps = run.length > 1 && run === run.toUpperCase() && /\p{L}/u.test(run);
693
+ const cap = !caps && /\p{Lu}/u.test(run[0]);
694
+ const cased = (w) => (caps ? w.toUpperCase() : cap ? w[0].toUpperCase() + w.slice(1) : w);
695
+ const target = refWidth + state.drift;
696
+ let best = null, bestGap = Infinity;
697
+ 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; }
702
+ }
703
+ // No single word close enough (a 2px miss moves a line break): try two lorem words joined into one. Still one
704
+ // word, so the break opportunities stay the reference's, but there are ~10^5 widths to choose from.
705
+ 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
+ const tail = (w) => (caps ? w.toUpperCase() : w); // only the first part carries the capital
708
+ const tries = [];
709
+ for (const [wa, a] of sorted) {
710
+ const need = target - wa;
711
+ if (need < 6) break;
712
+ let lo = 0, hi = sorted.length - 1;
713
+ 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])]);
715
+ }
716
+ 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
+ }
721
+ }
722
+ state.drift = target - widthIn(s, best);
723
+ return best;
724
+ };
725
+ // for attributes (alt, aria-label...): no layout to match, the element's own font is enough
726
+ const shaper = (el) => {
727
+ const s = getComputedStyle(el || document.body);
728
+ 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('');
730
+ };
731
+
732
+ // text nodes: measure every original letter run first (reads only)
733
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
734
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
735
+ const plan = [];
736
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
737
+ if (!/[\p{L}\p{N}]/u.test(n.textContent) || SKIP.has(n.parentElement?.tagName) || n.parentElement === meter) continue;
738
+ const parts = n.textContent.split(RUN);
739
+ let at = 0;
740
+ const widths = parts.map((part) => {
741
+ const start = at;
742
+ at += part.length;
743
+ if (!/[\p{L}\p{N}]/u.test(part)) return null;
744
+ const r = document.createRange();
745
+ r.setStart(n, start);
746
+ r.setEnd(n, start + part.length);
747
+ const rects = [...r.getClientRects()];
748
+ return rects.reduce((sum, x) => sum + x.width, 0) || null; // a run hidden by display:none measures 0
749
+ });
750
+ plan.push({ n, parts, widths, style: getComputedStyle(n.parentElement) });
751
+ }
752
+ // then choose the words (writes go to the isolated box only), then write all the text
753
+ const states = new Map();
754
+ for (const item of plan) {
755
+ const state = states.get(item.n.parentElement) || { drift: 0, seed: 7 };
756
+ states.set(item.n.parentElement, state);
757
+ item.text = item.parts.map((part, i) => {
758
+ if (!/[\p{L}\p{N}]/u.test(part)) return part;
759
+ return pick(item.style, part, item.widths[i] ?? widthIn(item.style, part), state);
760
+ }).join('');
761
+ }
762
+ for (const item of plan) item.n.textContent = item.text;
763
+ meter.remove();
764
+ // text in attributes people read or hear
765
+ for (const el of root.querySelectorAll('[alt], [title], [aria-label], [placeholder], input[value], [data-text]')) {
766
+ for (const a of ['alt', 'title', 'aria-label', 'placeholder', 'data-text']) if (el.hasAttribute(a) && el.getAttribute(a).trim()) el.setAttribute(a, shaper(el)(el.getAttribute(a)));
767
+ if (el.tagName === 'INPUT' && ['text', 'email', 'search', 'url', 'tel', ''].includes(el.type) && el.value) el.setAttribute('value', shaper(el)(el.value));
768
+ }
769
+
770
+ // media: same box, placeholder content
771
+ const box = (w, h) => `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(w) || 16}" height="${Math.round(h) || 16}" viewBox="0 0 16 16" preserveAspectRatio="none"><rect width="16" height="16" fill="#8884"/></svg>`)}`;
772
+ for (const img of root.querySelectorAll('img')) {
773
+ const r = img.getBoundingClientRect();
774
+ img.setAttribute('src', box(img.naturalWidth || r.width, img.naturalHeight || r.height));
775
+ for (const a of ['srcset', 'sizes', 'data-src', 'data-srcset']) img.removeAttribute(a);
776
+ }
777
+ root.querySelectorAll('picture source, video source, audio source, track').forEach((s) => s.remove());
778
+ for (const v of root.querySelectorAll('video')) {
779
+ const r = v.getBoundingClientRect();
780
+ v.removeAttribute('src');
781
+ v.setAttribute('poster', box(v.videoWidth || r.width, v.videoHeight || r.height));
782
+ v.setAttribute('preload', 'none');
783
+ }
784
+ for (const f of root.querySelectorAll('iframe, embed, object')) f.setAttribute(f.tagName === 'OBJECT' ? 'data' : 'src', 'about:blank');
785
+ // inline SVG: small ones are interface icons and stay; large ones (logos, illustrations) become a same-size box
786
+ for (const svg of root.querySelectorAll('svg')) {
787
+ if (svg.parentElement?.closest('svg')) continue;
788
+ const r = svg.getBoundingClientRect();
789
+ if (r.width <= 48 && r.height <= 48) continue;
790
+ if (!svg.getAttribute('viewBox')) svg.setAttribute('viewBox', `0 0 ${Math.round(r.width) || 16} ${Math.round(r.height) || 16}`);
791
+ svg.setAttribute('preserveAspectRatio', 'none');
792
+ const [, , w, h] = svg.getAttribute('viewBox').split(/[\s,]+/).map(Number);
793
+ svg.innerHTML = `<rect width="${w || 16}" height="${h || 16}" fill="#8884"/>`;
794
+ }
795
+ for (const el of root.querySelectorAll('[style*="url("]')) el.setAttribute('style', el.getAttribute('style').replace(/url\((['"]?)(?!data:)[^)'"]+\1\)/g, 'url("assets/placeholder.svg")'));
796
+
797
+ // links, forms and brand metadata
798
+ root.querySelectorAll('a[href]').forEach((a) => { if (!a.getAttribute('href').startsWith('#')) a.setAttribute('href', '#'); });
799
+ root.querySelectorAll('form').forEach((f) => f.setAttribute('action', '#'));
800
+ if (root !== document.body) return root.innerHTML;
801
+ document.querySelectorAll('meta[property^="og:"], meta[name^="twitter:"], meta[name="description"], meta[name="keywords"], meta[name="author"], meta[name="application-name"], meta[name="generator"], meta[name="apple-mobile-web-app-title"], link[rel="author"], link[rel="preconnect"], link[rel="dns-prefetch"], link[rel="preload"][as="image"], link[rel="prefetch"], link[rel~="icon"], link[rel="apple-touch-icon"], link[rel="manifest"], link[rel="canonical"], link[rel="alternate"], script[type="application/ld+json"]').forEach((m) => m.remove());
802
+ document.title = shaper(null)(document.title);
803
+ return document.documentElement.outerHTML;
66
804
  }
67
- console.log(`seeded ${path.relative(process.cwd(), clone)} from the mirror: index.html, ${(await fs.readdir(path.join(clone, 'css'))).length} stylesheet(s), ${copied.size} asset(s)`);
68
- console.log('next: write clone/script.js (behaviour), then npm run score -- ' + name);