@trueclone/cli 0.1.0 → 0.2.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,137 @@
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
+ // Reveal player shipped with the clone (trueclone's own code): replays each recorded curve with the Web Animations
60
+ // API once the element's top crosses the recorded trigger line; with reduced motion it shows the end state.
61
+ 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).
63
+ (() => {
64
+ const raw = JSON.parse(document.getElementById('tc-reveals')?.textContent || '{}');
65
+ const data = Array.isArray(raw) ? raw : raw.reveals || [];
66
+ const classData = Array.isArray(raw) ? [] : raw.classes || [];
67
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
68
+
69
+ // class changes are state, not motion: they apply with reduced motion too
70
+ let scrolled = false;
71
+ const pinned = [];
72
+ for (const el of document.querySelectorAll('[data-tc-class]')) {
73
+ const c = classData[Number(el.dataset.tcClass)];
74
+ if (!c) continue;
75
+ if (c.sticky) { pinned.push([el, c]); continue; }
76
+ const line = Math.max(0, Math.min(1, c.trigger ?? 1));
77
+ new IntersectionObserver(([e], io) => {
78
+ if (e.isIntersecting) { el.setAttribute('class', c.to); if (!c.reverts) io.disconnect(); }
79
+ 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);
81
+ }
82
+ const applyPinned = () => {
83
+ if (scrollY > 0) scrolled = true;
84
+ for (const [el, c] of pinned) {
85
+ const past = scrollY >= (c.threshold ?? 1);
86
+ if (past) el.setAttribute('class', c.to);
87
+ else if (c.reverts && scrolled) el.setAttribute('class', c.back ?? c.from);
88
+ }
89
+ };
90
+ if (pinned.length) {
91
+ let queued = false;
92
+ addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; applyPinned(); }); } }, { passive: true });
93
+ applyPinned();
94
+ }
95
+ 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));
97
+ const play = (el, r) => {
98
+ const frames = r.frames.map((f) => ({ ...style(f), offset: r.duration ? Math.min(1, f.t / r.duration) : 1 }));
99
+ const anim = el.animate(frames, { duration: r.duration, fill: 'forwards', easing: 'linear' });
100
+ anim.onfinish = () => { settle(el, r); anim.cancel(); };
101
+ };
102
+ for (const el of document.querySelectorAll('[data-tc-reveal]')) {
103
+ const r = data[Number(el.dataset.tcReveal)];
104
+ if (!r) continue;
105
+ if (reduce) { settle(el, r); continue; }
106
+ // the recorded trigger is where the element's top was (as a share of the window height) when it started
107
+ const line = Math.max(0, Math.min(1, r.trigger ?? 1));
108
+ const io = new IntersectionObserver((entries) => {
109
+ for (const e of entries) {
110
+ if (!e.isIntersecting) continue;
111
+ io.disconnect();
112
+ play(el, r);
113
+ }
114
+ }, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` });
115
+ io.observe(el);
116
+ }
117
+ })();
118
+ `;
12
119
 
13
120
  const { pos, flags } = parseArgs(process.argv.slice(2));
14
121
  const [name] = pos;
15
122
  if (!name) {
16
- console.error('usage: npm run seed -- <name> [--force]');
123
+ console.error('usage: trueclone seed <name> [--force]');
17
124
  process.exit(1);
18
125
  }
19
126
  const root = path.resolve('sites', name);
20
127
  const ref = path.join(root, 'reference');
21
128
  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');
129
+ const exact = meta.mode === 'exact' && Boolean(meta.permission);
130
+ const mirror = path.join(ref, 'mirror');
131
+ if (!(await fs.stat(path.join(mirror, 'index.html')).then(() => true, () => false))) {
132
+ console.error(`no mirror yet: run trueclone capture ${meta.url} ${name} --mirror-only`);
24
133
  process.exit(1);
25
134
  }
26
- const mirror = path.join(ref, 'mirror');
27
135
  const clone = path.join(root, 'clone');
28
136
  const exists = await fs.stat(path.join(clone, 'index.html')).then(() => true, () => false);
29
137
  if (exists && !flags.force) {
@@ -33,36 +141,373 @@ if (exists && !flags.force) {
33
141
  await fs.mkdir(path.join(clone, 'css'), { recursive: true });
34
142
 
35
143
  // 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.
144
+ // clone/assets and the paths rewritten, so clone/ is self-contained. Template mode only takes fonts; every other
145
+ // asset reference becomes the placeholder.
146
+ const PLACEHOLDER = 'assets/placeholder.svg';
147
+ const PLACEHOLDER_BG = 'assets/placeholder-bg.svg'; // transparent: for CSS backgrounds
37
148
  const copied = new Set();
149
+ const isFont = (rel) => /\.(woff2?|ttf|otf|eot)([?#]|$)/i.test(rel) || /^font\//.test(rel);
38
150
  const pull = async (rel) => {
39
151
  const clean = decodeURIComponent(rel.split(/[?#]/)[0]);
40
152
  if (copied.has(clean)) return;
41
153
  copied.add(clean);
42
- const from = path.join(ref, 'assets', clean);
43
154
  const to = path.join(clone, 'assets', clean);
44
155
  await fs.mkdir(path.dirname(to), { recursive: true });
45
- await fs.copyFile(from, to).catch(() => console.warn(`WARNING missing asset ${clean}`));
156
+ await fs.copyFile(path.join(ref, 'assets', clean), to).catch(() => console.warn(`WARNING missing asset ${clean}`));
46
157
  };
47
- const rewrite = async (text, prefix, replacement) => {
158
+ // `forPass`: the page's media point at the original files in reference/ for the browser pass only, so images keep
159
+ // their intrinsic size (an image laid out by its aspect ratio needs it); the pass replaces them with same-size
160
+ // placeholders and a final sweep guarantees no reference path survives.
161
+ const REF_ASSETS = '../reference/assets/';
162
+ const rewrite = async (text, prefix, replacement, forPass = false) => {
48
163
  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}`);
164
+ for (const m of text.matchAll(re)) if (exact || isFont(m[1])) await pull(m[1]);
165
+ return text.replace(re, (all, rel) => (exact || isFont(rel) ? `${replacement}assets/${rel}` : forPass ? `${REF_ASSETS}${rel}` : `${replacement}${PLACEHOLDER}`));
51
166
  };
52
167
 
53
168
  for (const file of await fs.readdir(path.join(mirror, 'css')).catch(() => [])) {
54
169
  const css = await fs.readFile(path.join(mirror, 'css', file), 'utf8');
55
- await fs.writeFile(path.join(clone, 'css', file), await rewrite(css, '../../', '../'));
170
+ let out = await rewrite(css, '../../', '../');
171
+ // template mode: images the capture never downloaded still point at the site (url("https://…/bg.avif"));
172
+ // every non-font url() becomes a transparent placeholder (CSS backgrounds are mostly decorative: noise, grids,
173
+ // glows), so the clone never loads the site's media. url(#id) points inside the page (SVG paint) and stays.
174
+ if (!exact) out = out.replace(/url\(\s*(['"]?)(?!data:)([^'")]+)\1\s*\)/g, (all, q, u) => (isFont(u) || u.trim().startsWith('#') ? all : `url("../${PLACEHOLDER_BG}")`));
175
+ await fs.writeFile(path.join(clone, 'css', file), out);
56
176
  }
57
177
  let html = await fs.readFile(path.join(mirror, 'index.html'), 'utf8');
58
- html = await rewrite(html, '../', '');
178
+ html = await rewrite(html, '../', '', !exact);
179
+ await fs.writeFile(path.join(clone, 'index.html'), html);
180
+
181
+ if (!exact) {
182
+ // a neutral box that reads on light and dark pages
183
+ await fs.mkdir(path.join(clone, 'assets'), { recursive: true });
184
+ 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');
185
+ await fs.writeFile(path.join(clone, PLACEHOLDER_BG), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"/>\n');
186
+ }
187
+ // Scroll reveals recorded on the reference (trueclone reveals): mark the elements, start them in their recorded
188
+ // 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 || [];
192
+ // Widget states recorded on the reference (trueclone widgets): tabs, menus. Template mode only for now: the snapshots
193
+ // are placeholdered like the page; exact-mode snapshots would still point at the site's media.
194
+ const widgetRec = exact ? null : JSON.parse(await fs.readFile(path.join(ref, 'widgets.desktop.json'), 'utf8').catch(() => 'null'));
195
+ 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));
198
+ if (!exact) html = html.split(REF_ASSETS).join('__tc_ref__').replace(/__tc_ref__[^"')\s]+/g, PLACEHOLDER);
199
+ if (widgets?.groups.length) {
200
+ // a plain script (not fetched JSON), so the clone also works opened from disk
201
+ 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
+ html = html.replace(/<\/body>/i, '<script src="trueclone-widgets.js" defer></script>\n</body>');
203
+ }
204
+ if (marked || classMarked) {
205
+ await fs.writeFile(path.join(clone, 'trueclone-reveals.js'), PLAYER);
206
+ const data = JSON.stringify({
207
+ reveals: recorded.map(({ trigger, duration, frames, to }) => ({ trigger, duration, frames, to })),
208
+ classes: classChanges.map(({ from, to, back, sticky, reverts, threshold, trigger }) => ({ from, to, back, sticky, reverts, threshold, trigger })),
209
+ });
210
+ 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
+ }
59
212
  // the clone's own behaviour script, loaded once the DOM is parsed
60
213
  if (!/<script[^>]+src="script\.js"/.test(html)) html = html.replace(/<\/body>/i, '<script src="script.js" defer></script>\n</body>');
61
214
  await fs.writeFile(path.join(clone, 'index.html'), html);
62
215
 
63
216
  const scriptFile = path.join(clone, 'script.js');
64
217
  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`);
218
+ 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
+ }
220
+ 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)' : ''}`);
222
+ 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(', ')}` : ''}`);
223
+ console.log(`next: write clone/script.js (behaviour), then npx trueclone score ${name}`);
224
+
225
+ // ---------- One browser pass over the seeded page: placeholders (template mode) and reveal marks ----------
226
+ async function browserPass(template, reveals, classes, groups = []) {
227
+ // served from sites/<name>/ so the pass can reach reference/assets (see `forPass`)
228
+ const server = await serve(root);
229
+ const browser = await openBrowser(flags.cdp);
230
+ try {
231
+ const { page } = await newPage(browser, VIEWPORTS.find((v) => v.name === 'desktop'), {});
232
+ await page.goto(`${server.url}clone/index.html`, { waitUntil: 'load', timeout: 60000 });
233
+ await page.evaluate(() => document.fonts.ready);
234
+ const counts = reveals.length || classes.length ? await page.evaluate(markRevealsInPage, [reveals, classes]) : [0, 0];
235
+ // widgets: mark regions and triggers, then give every recorded state the same placeholders as the page by
236
+ // placing it into its region (so it is laid out and measured for real) and reading it back
237
+ let widgets = null;
238
+ if (groups.length) {
239
+ const ok = await page.evaluate(markWidgetsInPage, groups);
240
+ await page.evaluate(`window.__tcPlaceholders = ${placeholdersInPage.toString()}`);
241
+ widgets = { groups: [], states: 0 };
242
+ for (const g of groups) {
243
+ if (!ok[g.i]) continue;
244
+ const shape = (stateHtml) => (stateHtml == null ? null : page.evaluate(([i, h, lorem, t]) => {
245
+ const scope = document.querySelector(`[data-tc-scope~="${i}"]`);
246
+ if (!scope) return null;
247
+ const before = scope.innerHTML;
248
+ scope.innerHTML = h;
249
+ const out = t ? window.__tcPlaceholders([lorem, `[data-tc-scope~="${i}"]`]) : scope.innerHTML;
250
+ scope.innerHTML = before;
251
+ return out;
252
+ }, [g.i, stateHtml, LOREM, template]));
253
+ const states = [];
254
+ for (const st of g.states) states.push(await shape(st));
255
+ const closed = g.kind === 'expand' ? await shape(g.closed) : undefined;
256
+ widgets.states += states.filter(Boolean).length;
257
+ widgets.groups[g.i] = { kind: g.kind, states, closed };
258
+ }
259
+ widgets.groups = Array.from(widgets.groups, (x) => x || null);
260
+ }
261
+ if (template) await page.evaluate(placeholdersInPage, [LOREM, null]);
262
+ const out = await page.evaluate(() => document.documentElement.outerHTML);
263
+ return { html: `<!DOCTYPE html>\n${out}`, marked: counts[0], classMarked: counts[1], widgets };
264
+ } finally {
265
+ if (!flags.cdp) await browser.close();
266
+ server.close();
267
+ }
268
+ }
269
+
270
+ // The path of element children recorded by reveals.mjs (script/style/meta-like tags skipped) resolves to the same
271
+ // element in the script-free mirror. Each reveal gets data-tc-reveal and its recorded starting state inline; each
272
+ // scroll class change gets data-tc-class and its starting classes (the page as it is before any scroll).
273
+ // Widget regions and triggers, resolved by the recorded paths in the mirror (which shows each widget's initial
274
+ // state). A region can serve several widgets (every menu of a nav): data-tc-scope holds a space-separated list.
275
+ function markWidgetsInPage(groups) {
276
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
277
+ const resolve = (p) => {
278
+ let el = document.body;
279
+ for (const part of p.split('>')) {
280
+ const [tag, n] = part.split(':');
281
+ el = [...el.children].filter((c) => !SKIP.has(c.tagName))[Number(n) - 1];
282
+ if (!el || el.tagName.toLowerCase() !== tag) return null;
283
+ }
284
+ return el;
285
+ };
286
+ const ok = {};
287
+ for (const g of groups) {
288
+ const scope = resolve(g.scopePath);
289
+ const triggers = g.triggers.map((t) => resolve(t.path));
290
+ if (!scope || triggers.some((t) => !t)) continue;
291
+ const ids = (scope.getAttribute('data-tc-scope') || '').split(' ').filter(Boolean);
292
+ ids.push(String(g.i));
293
+ scope.setAttribute('data-tc-scope', ids.join(' '));
294
+ triggers.forEach((t, j) => t.setAttribute('data-tc-trigger', `${g.i}:${j}`));
295
+ ok[g.i] = true;
296
+ }
297
+ return ok;
298
+ }
299
+
300
+ // Widget player shipped with the clone (trueclone's own code, serialised into clone/trueclone-widgets.js): a click on
301
+ // a trigger swaps its region to the recorded state; an expandable trigger toggles between its open and closed state;
302
+ // menus in a header or nav also open on hover and close when the pointer leaves their region, like the reference.
303
+ function widgetPlayer() {
304
+ const data = window.__tcWidgetData || { groups: [] };
305
+ const regionOf = (i) => document.querySelector(`[data-tc-scope~="${i}"]`);
306
+ const show = (i, html) => { const r = regionOf(i); if (r && html != null && r.innerHTML !== html) r.innerHTML = html; };
307
+ const parse = (t) => t.dataset.tcTrigger.split(':').map(Number);
308
+ document.addEventListener('click', (e) => {
309
+ const t = e.target.closest('[data-tc-trigger]');
310
+ if (!t) return;
311
+ const [i, j] = parse(t);
312
+ const g = data.groups[i];
313
+ if (!g) return;
314
+ e.preventDefault();
315
+ if (g.kind === 'tabs') show(i, g.states[j]);
316
+ else show(i, t.getAttribute('aria-expanded') === 'true' ? g.closed : g.states[j]);
317
+ });
318
+ const hoverMenu = (t) => t.closest('header, nav') && data.groups[parse(t)[0]]?.kind === 'expand';
319
+ document.addEventListener('pointerover', (e) => {
320
+ const t = e.target.closest('[data-tc-trigger]');
321
+ if (!t || !hoverMenu(t) || t.getAttribute('aria-expanded') === 'true') return;
322
+ const [i, j] = parse(t);
323
+ show(i, data.groups[i].states[j]);
324
+ });
325
+ document.addEventListener('pointerout', (e) => {
326
+ for (const [i, g] of data.groups.entries()) {
327
+ if (!g || g.kind !== 'expand') continue;
328
+ const r = regionOf(i);
329
+ if (!r || !r.contains(e.target) || r.contains(e.relatedTarget)) continue;
330
+ if (r.querySelector(`[data-tc-trigger^="${i}:"][aria-expanded="true"]`)) show(i, g.closed);
331
+ }
332
+ });
333
+ }
334
+
335
+ function markRevealsInPage([reveals, classes]) {
336
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
337
+ const resolve = (p) => {
338
+ let el = document.body;
339
+ for (const part of p.split('>')) {
340
+ const [tag, n] = part.split(':');
341
+ const kids = [...el.children].filter((c) => !SKIP.has(c.tagName));
342
+ el = kids[Number(n) - 1];
343
+ if (!el || el.tagName.toLowerCase() !== tag) return null;
344
+ }
345
+ return el;
346
+ };
347
+ let count = 0;
348
+ reveals.forEach((r, i) => {
349
+ const el = resolve(r.path);
350
+ if (!el) return;
351
+ 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)`;
355
+ count++;
356
+ });
357
+ let classCount = 0;
358
+ classes.forEach((c, i) => {
359
+ const el = resolve(c.path);
360
+ if (!el) return;
361
+ el.setAttribute('data-tc-class', String(i));
362
+ if (c.from !== null && c.from !== undefined) el.setAttribute('class', c.from);
363
+ classCount++;
364
+ });
365
+ return [count, classCount];
366
+ }
367
+
368
+
369
+ function placeholdersInPage([lorem, rootSel]) {
370
+ // the whole page, or one region (a widget's recorded state placed into the page to be measured)
371
+ const root = rootSel ? document.querySelector(rootSel) : document.body;
372
+ if (!root) return null;
373
+ // Each letter run becomes a lorem word of (nearly) the same laid-out width in the element's own font, keeping its
374
+ // capitalisation; the running width is tracked across an element's text so line breaks land where the reference's
375
+ // do (a 2px drift is enough to move a break on a full line). Digits keep their count (years stay), punctuation and
376
+ // whitespace stay exactly as they were.
377
+ //
378
+ // Widths come from layout, not canvas: the reference words are measured with Ranges on the live text, candidates
379
+ // in an isolated box that copies the text style (contain: layout, so measuring never re-lays out the page). All
380
+ // reads happen before any text is written.
381
+ const meter = document.createElement('span');
382
+ meter.style.cssText = 'position:absolute;left:-99999px;top:0;white-space:pre;contain:layout style paint;visibility:hidden';
383
+ document.body.appendChild(meter);
384
+ const PROPS = ['fontStyle', 'fontWeight', 'fontSize', 'fontFamily', 'fontStretch', 'fontVariant', 'fontFeatureSettings', 'fontKerning', 'letterSpacing', 'wordSpacing', 'textTransform'];
385
+ let current = null;
386
+ const cache = new Map();
387
+ const widthIn = (s, word) => {
388
+ const key = PROPS.map((p) => s[p]).join('|');
389
+ let words = cache.get(key);
390
+ if (!words) cache.set(key, (words = new Map()));
391
+ if (!words.has(word)) {
392
+ if (current !== key) { for (const p of PROPS) meter.style[p] = s[p]; current = key; }
393
+ meter.textContent = word;
394
+ words.set(word, meter.getBoundingClientRect().width);
395
+ }
396
+ return words.get(word);
397
+ };
398
+ const RUN = /([\p{L}\p{N}]+)/u;
399
+ const pick = (s, run, refWidth, state) => {
400
+ if (/^\p{N}+$/u.test(run)) return /^(19|20)\d\d$/.test(run) ? run : run.replace(/\d/g, (d) => String((Number(d) + 3) % 10));
401
+ const caps = run.length > 1 && run === run.toUpperCase() && /\p{L}/u.test(run);
402
+ const cap = !caps && /\p{Lu}/u.test(run[0]);
403
+ const cased = (w) => (caps ? w.toUpperCase() : cap ? w[0].toUpperCase() + w.slice(1) : w);
404
+ const target = refWidth + state.drift;
405
+ let best = null, bestGap = Infinity;
406
+ state.seed = (state.seed * 31 + run.length) % 9973;
407
+ for (let i = 0; i < lorem.length; i++) {
408
+ const w = cased(lorem[(i + state.seed) % lorem.length]);
409
+ const gap = Math.abs(widthIn(s, w) - target);
410
+ if (gap < bestGap) { best = w; bestGap = gap; }
411
+ }
412
+ // No single word close enough (a 2px miss moves a line break): try two lorem words joined into one. Still one
413
+ // word, so the break opportunities stay the reference's, but there are ~10^5 widths to choose from.
414
+ if (bestGap > 0.75 && target > 30) {
415
+ const sorted = lorem.map((w) => [widthIn(s, cased(w)), w]).sort((a, b) => a[0] - b[0]);
416
+ const tail = (w) => (caps ? w.toUpperCase() : w); // only the first part carries the capital
417
+ const tries = [];
418
+ for (const [wa, a] of sorted) {
419
+ const need = target - wa;
420
+ if (need < 6) break;
421
+ let lo = 0, hi = sorted.length - 1;
422
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid][0] < need) lo = mid + 1; else hi = mid; }
423
+ for (const k of [lo - 1, lo]) if (sorted[k]) tries.push([Math.abs(wa + sorted[k][0] - target), cased(a) + tail(sorted[k][1])]);
424
+ }
425
+ tries.sort((x, y) => x[0] - y[0]);
426
+ for (const [, word] of tries.slice(0, 12)) { // estimates; measure the real compound (kerning at the join)
427
+ const gap = Math.abs(widthIn(s, word) - target);
428
+ if (gap < bestGap) { best = word; bestGap = gap; }
429
+ }
430
+ }
431
+ state.drift = target - widthIn(s, best);
432
+ return best;
433
+ };
434
+ // for attributes (alt, aria-label...): no layout to match, the element's own font is enough
435
+ const shaper = (el) => {
436
+ const s = getComputedStyle(el || document.body);
437
+ const state = { drift: 0, seed: 7 };
438
+ return (text) => text.split(RUN).map((run) => (RUN.test(run) && !/^[^\p{L}\p{N}]*$/u.test(run) ? pick(s, run, widthIn(s, run), state) : run)).join('');
439
+ };
440
+
441
+ // text nodes: measure every original letter run first (reads only)
442
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
443
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
444
+ const plan = [];
445
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
446
+ if (!/[\p{L}\p{N}]/u.test(n.textContent) || SKIP.has(n.parentElement?.tagName) || n.parentElement === meter) continue;
447
+ const parts = n.textContent.split(RUN);
448
+ let at = 0;
449
+ const widths = parts.map((part) => {
450
+ const start = at;
451
+ at += part.length;
452
+ if (!/[\p{L}\p{N}]/u.test(part)) return null;
453
+ const r = document.createRange();
454
+ r.setStart(n, start);
455
+ r.setEnd(n, start + part.length);
456
+ const rects = [...r.getClientRects()];
457
+ return rects.reduce((sum, x) => sum + x.width, 0) || null; // a run hidden by display:none measures 0
458
+ });
459
+ plan.push({ n, parts, widths, style: getComputedStyle(n.parentElement) });
460
+ }
461
+ // then choose the words (writes go to the isolated box only), then write all the text
462
+ const states = new Map();
463
+ for (const item of plan) {
464
+ const state = states.get(item.n.parentElement) || { drift: 0, seed: 7 };
465
+ states.set(item.n.parentElement, state);
466
+ item.text = item.parts.map((part, i) => {
467
+ if (!/[\p{L}\p{N}]/u.test(part)) return part;
468
+ return pick(item.style, part, item.widths[i] ?? widthIn(item.style, part), state);
469
+ }).join('');
470
+ }
471
+ for (const item of plan) item.n.textContent = item.text;
472
+ meter.remove();
473
+ // text in attributes people read or hear
474
+ for (const el of root.querySelectorAll('[alt], [title], [aria-label], [placeholder], input[value], [data-text]')) {
475
+ 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)));
476
+ if (el.tagName === 'INPUT' && ['text', 'email', 'search', 'url', 'tel', ''].includes(el.type) && el.value) el.setAttribute('value', shaper(el)(el.value));
477
+ }
478
+
479
+ // media: same box, placeholder content
480
+ 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>`)}`;
481
+ for (const img of root.querySelectorAll('img')) {
482
+ const r = img.getBoundingClientRect();
483
+ img.setAttribute('src', box(img.naturalWidth || r.width, img.naturalHeight || r.height));
484
+ for (const a of ['srcset', 'sizes', 'data-src', 'data-srcset']) img.removeAttribute(a);
485
+ }
486
+ root.querySelectorAll('picture source, video source, audio source, track').forEach((s) => s.remove());
487
+ for (const v of root.querySelectorAll('video')) {
488
+ const r = v.getBoundingClientRect();
489
+ v.removeAttribute('src');
490
+ v.setAttribute('poster', box(v.videoWidth || r.width, v.videoHeight || r.height));
491
+ v.setAttribute('preload', 'none');
492
+ }
493
+ for (const f of root.querySelectorAll('iframe, embed, object')) f.setAttribute(f.tagName === 'OBJECT' ? 'data' : 'src', 'about:blank');
494
+ // inline SVG: small ones are interface icons and stay; large ones (logos, illustrations) become a same-size box
495
+ for (const svg of root.querySelectorAll('svg')) {
496
+ if (svg.parentElement?.closest('svg')) continue;
497
+ const r = svg.getBoundingClientRect();
498
+ if (r.width <= 48 && r.height <= 48) continue;
499
+ if (!svg.getAttribute('viewBox')) svg.setAttribute('viewBox', `0 0 ${Math.round(r.width) || 16} ${Math.round(r.height) || 16}`);
500
+ svg.setAttribute('preserveAspectRatio', 'none');
501
+ const [, , w, h] = svg.getAttribute('viewBox').split(/[\s,]+/).map(Number);
502
+ svg.innerHTML = `<rect width="${w || 16}" height="${h || 16}" fill="#8884"/>`;
503
+ }
504
+ for (const el of root.querySelectorAll('[style*="url("]')) el.setAttribute('style', el.getAttribute('style').replace(/url\((['"]?)(?!data:)[^)'"]+\1\)/g, 'url("assets/placeholder.svg")'));
505
+
506
+ // links, forms and brand metadata
507
+ root.querySelectorAll('a[href]').forEach((a) => { if (!a.getAttribute('href').startsWith('#')) a.setAttribute('href', '#'); });
508
+ root.querySelectorAll('form').forEach((f) => f.setAttribute('action', '#'));
509
+ if (root !== document.body) return root.innerHTML;
510
+ 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());
511
+ document.title = shaper(null)(document.title);
512
+ return document.documentElement.outerHTML;
66
513
  }
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);
@@ -7,7 +7,7 @@
7
7
  // node scripts/states.mjs <name> --clone [url] [--structure]
8
8
  import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
- import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, wheelTo, statesInPage, maskToggle, matchPct, PNG } from './lib.mjs';
10
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, wheelTo, statesInPage, maskToggle, freezeLoops, matchPct, PNG } from './lib.mjs';
11
11
 
12
12
  const { pos, flags } = parseArgs(process.argv.slice(2));
13
13
  const [name] = pos;
@@ -69,6 +69,7 @@ try {
69
69
  const actual = await wheelTo(page, y, vp);
70
70
  const still = await waitStill(page);
71
71
  const file = `${String(i).padStart(2, '0')}`;
72
+ await freezeLoops(page); // loops that started on entering view too, so every step is compared at the same phase
72
73
  await page.screenshot({ path: path.join(dir, `${file}.png`) });
73
74
  await maskToggle(page, true);
74
75
  await page.screenshot({ path: path.join(dir, `${file}.mask.png`) });