@trueclone/cli 0.2.0 → 0.4.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -32
- package/bin/trueclone.mjs +28 -19
- package/package.json +2 -1
- package/scripts/capture.mjs +239 -9
- package/scripts/curves.mjs +44 -7
- package/scripts/detail.mjs +19 -6
- package/scripts/diff.mjs +4 -2
- package/scripts/drift.mjs +55 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/hovers.mjs +144 -43
- package/scripts/interact.mjs +35 -5
- package/scripts/lib.mjs +77 -29
- package/scripts/load.mjs +106 -0
- package/scripts/next.mjs +158 -28
- package/scripts/owned.mjs +66 -0
- package/scripts/report.mjs +83 -17
- package/scripts/reveals.mjs +178 -80
- package/scripts/review.mjs +149 -0
- package/scripts/score.mjs +74 -9
- package/scripts/scripts-evidence.mjs +116 -0
- package/scripts/seed.mjs +420 -56
- package/scripts/states.mjs +38 -5
- package/scripts/tiles.mjs +33 -17
- package/scripts/understand.mjs +231 -0
- package/scripts/viewport-fit.mjs +198 -0
- package/skill/SKILL.md +119 -116
package/scripts/seed.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import fs from 'node:fs/promises';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { parseArgs, openBrowser, newPage, serve, VIEWPORTS } from './lib.mjs';
|
|
16
|
+
import { planInlineStyles } from './viewport-fit.mjs';
|
|
16
17
|
|
|
17
18
|
// placeholder vocabulary, wide enough to match most word widths
|
|
18
19
|
const LOREM_TEXT = `lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore
|
|
@@ -56,15 +57,28 @@ verbum vereor vero verto verus vester veto vetus via video vinco vinum vir virgo
|
|
|
56
57
|
voluntas vos votum vox vulnus vultus`;
|
|
57
58
|
const LOREM = LOREM_TEXT.split(/\s+/).filter(Boolean);
|
|
58
59
|
|
|
60
|
+
|
|
59
61
|
// Reveal player shipped with the clone (trueclone's own code): replays each recorded curve with the Web Animations
|
|
60
62
|
// API once the element's top crosses the recorded trigger line; with reduced motion it shows the end state.
|
|
61
63
|
const PLAYER = `// trueclone reveal player: replays what was recorded on the reference while scrolling (trueclone reveals): one-shot
|
|
62
|
-
// reveals frame by frame,
|
|
64
|
+
// reveals frame by frame, scroll-linked curves (parallax, scrubbed fades) by scroll position, and class changes (a
|
|
65
|
+
// header that gets its backdrop past 12px, an "is-visible" toggle). Records exist per width; the one for the
|
|
66
|
+
// visitor's width is used (desktop from 1100px, tablet from 600px, otherwise mobile).
|
|
63
67
|
(() => {
|
|
64
68
|
const raw = JSON.parse(document.getElementById('tc-reveals')?.textContent || '{}');
|
|
65
69
|
const data = Array.isArray(raw) ? raw : raw.reveals || [];
|
|
66
70
|
const classData = Array.isArray(raw) ? [] : raw.classes || [];
|
|
71
|
+
const scrubData = Array.isArray(raw) ? [] : raw.scrubs || [];
|
|
67
72
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
73
|
+
// the record for the visitor's width: a recorded size in the same range of the site's own breakpoints (the nearest
|
|
74
|
+
// one), otherwise the nearest recorded size
|
|
75
|
+
const bps = raw.breakpoints || [];
|
|
76
|
+
const rangeOf = (w) => bps.filter((b) => w > b).length;
|
|
77
|
+
const seedSizes = raw.sizes || [{ name: 'mobile', width: 390 }, { name: 'tablet', width: 768 }, { name: 'desktop', width: 1440 }];
|
|
78
|
+
const W = document.documentElement.clientWidth;
|
|
79
|
+
const sameRange = seedSizes.filter((x) => rangeOf(x.width) === rangeOf(W));
|
|
80
|
+
const vpName = (sameRange.length ? sameRange : seedSizes).reduce((a, b) => (Math.abs(b.width - W) < Math.abs(a.width - W) ? b : a)).name;
|
|
81
|
+
const pick = (entry) => (entry && (entry.desktop || entry.tablet || entry.mobile) ? entry[vpName] || entry.desktop || entry.tablet || entry.mobile : entry);
|
|
68
82
|
|
|
69
83
|
// class changes are state, not motion: they apply with reduced motion too
|
|
70
84
|
let scrolled = false;
|
|
@@ -74,10 +88,12 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
|
|
|
74
88
|
if (!c) continue;
|
|
75
89
|
if (c.sticky) { pinned.push([el, c]); continue; }
|
|
76
90
|
const line = Math.max(0, Math.min(1, c.trigger ?? 1));
|
|
91
|
+
// an element without a box (display: contents) never intersects: watch its first descendant that has one
|
|
92
|
+
const watched = el.getBoundingClientRect().height ? el : [...el.querySelectorAll('*')].find((d) => d.getBoundingClientRect().height) || el;
|
|
77
93
|
new IntersectionObserver(([e], io) => {
|
|
78
94
|
if (e.isIntersecting) { el.setAttribute('class', c.to); if (!c.reverts) io.disconnect(); }
|
|
79
95
|
else if (c.reverts && el.getAttribute('class') === c.to) el.setAttribute('class', c.back ?? c.from);
|
|
80
|
-
}, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` }).observe(
|
|
96
|
+
}, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` }).observe(watched);
|
|
81
97
|
}
|
|
82
98
|
const applyPinned = () => {
|
|
83
99
|
if (scrollY > 0) scrolled = true;
|
|
@@ -93,15 +109,55 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
|
|
|
93
109
|
applyPinned();
|
|
94
110
|
}
|
|
95
111
|
const style = (f) => ({ opacity: f.o, transform: \`translate(\${f.tx}px, \${f.ty}px) scale(\${f.s})\`, filter: f.blur ? \`blur(\${f.blur}px)\` : 'none' });
|
|
96
|
-
|
|
112
|
+
// scroll-linked curves: the recorded state at the two nearest scroll positions, interpolated. An element that also
|
|
113
|
+
// plays a one-shot reveal follows its curve once the reveal has finished.
|
|
114
|
+
// A curve is keyed by the element's own position in the viewport (key "v": its layout box top, translate taken
|
|
115
|
+
// out, the way a ScrollTrigger on the element sees it, so any viewport height gives the same curve) or, for an
|
|
116
|
+
// element inside a pinned stage, by the scroll position (key "y").
|
|
117
|
+
const scrubs = [];
|
|
118
|
+
for (const el of document.querySelectorAll('[data-tc-scrub]')) {
|
|
119
|
+
const rec = pick(scrubData[Number(el.dataset.tcScrub)]);
|
|
120
|
+
const s = Array.isArray(rec) ? rec : rec && rec.samples;
|
|
121
|
+
if (!s || s.length < 2) continue;
|
|
122
|
+
const key = !Array.isArray(rec) && rec.key === 'v' && s[0].v !== undefined ? 'v' : 'y';
|
|
123
|
+
scrubs.push({ el, key, s: key === 'v' ? [...s].sort((p, q) => p.v - q.v) : s, ty: 0, ready: !el.hasAttribute('data-tc-reveal') });
|
|
124
|
+
}
|
|
125
|
+
const at = (s, key, pos) => {
|
|
126
|
+
if (pos <= s[0][key]) return s[0];
|
|
127
|
+
for (let i = 1; i < s.length; i++) {
|
|
128
|
+
if (pos > s[i][key]) continue;
|
|
129
|
+
const a = s[i - 1], b = s[i], k = (pos - a[key]) / ((b[key] - a[key]) || 1);
|
|
130
|
+
const L = (p) => a[p] + (b[p] - a[p]) * k;
|
|
131
|
+
return { o: L('o'), tx: L('tx'), ty: L('ty'), s: L('s'), blur: L('blur') };
|
|
132
|
+
}
|
|
133
|
+
return s[s.length - 1];
|
|
134
|
+
};
|
|
135
|
+
// script.js takes an element over by removing its data-tc-scrub / data-tc-reveal mark: the player then leaves it alone
|
|
136
|
+
const applyScrubs = () => {
|
|
137
|
+
for (const x of scrubs) {
|
|
138
|
+
if (!x.ready || !x.el.hasAttribute('data-tc-scrub')) continue;
|
|
139
|
+
const pos = x.key === 'v' ? x.el.getBoundingClientRect().top - x.ty : scrollY;
|
|
140
|
+
const f = at(x.s, x.key, pos);
|
|
141
|
+
x.ty = f.ty;
|
|
142
|
+
Object.assign(x.el.style, style(f));
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
if (scrubs.length) {
|
|
146
|
+
let queued = false;
|
|
147
|
+
addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; applyScrubs(); }); } }, { passive: true });
|
|
148
|
+
applyScrubs();
|
|
149
|
+
}
|
|
150
|
+
const release = (el) => { const x = scrubs.find((q) => q.el === el); if (x) { x.ready = true; applyScrubs(); } };
|
|
151
|
+
const settle = (el, r) => { Object.assign(el.style, style(r.to)); release(el); };
|
|
97
152
|
const play = (el, r) => {
|
|
98
153
|
const frames = r.frames.map((f) => ({ ...style(f), offset: r.duration ? Math.min(1, f.t / r.duration) : 1 }));
|
|
99
154
|
const anim = el.animate(frames, { duration: r.duration, fill: 'forwards', easing: 'linear' });
|
|
100
155
|
anim.onfinish = () => { settle(el, r); anim.cancel(); };
|
|
101
156
|
};
|
|
102
157
|
for (const el of document.querySelectorAll('[data-tc-reveal]')) {
|
|
103
|
-
const r = data[Number(el.dataset.tcReveal)];
|
|
104
|
-
if (!r) continue;
|
|
158
|
+
const r = pick(data[Number(el.dataset.tcReveal)]);
|
|
159
|
+
if (!r) { release(el); continue; }
|
|
160
|
+
if (r.from) Object.assign(el.style, style(r.from)); // this width's starting state (the seed inlined the desktop one)
|
|
105
161
|
if (reduce) { settle(el, r); continue; }
|
|
106
162
|
// the recorded trigger is where the element's top was (as a share of the window height) when it started
|
|
107
163
|
const line = Math.max(0, Math.min(1, r.trigger ?? 1));
|
|
@@ -109,18 +165,24 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
|
|
|
109
165
|
for (const e of entries) {
|
|
110
166
|
if (!e.isIntersecting) continue;
|
|
111
167
|
io.disconnect();
|
|
112
|
-
play(el, r);
|
|
168
|
+
if (el.hasAttribute('data-tc-reveal')) play(el, r);
|
|
113
169
|
}
|
|
114
170
|
}, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` });
|
|
115
171
|
io.observe(el);
|
|
116
172
|
}
|
|
173
|
+
// boot contract: the page shows once every clone script has run (DOMContentLoaded comes after the deferred ones;
|
|
174
|
+
// the Next app's injector releases after its last script) and every hold is released. script.js holds the boot
|
|
175
|
+
// while it prepares what a visitor must not see half-built (an intro waiting for its image): tcBoot.hold() at the
|
|
176
|
+
// top, tcBoot.release() when ready.
|
|
177
|
+
const boot = window.tcBoot = { holds: 1, hold() { this.holds++; }, release() { if (--this.holds <= 0) requestAnimationFrame(() => document.documentElement.classList.remove('tc-boot')); } };
|
|
178
|
+
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => boot.release()); else if (!window.__tcInjected) boot.release();
|
|
117
179
|
})();
|
|
118
180
|
`;
|
|
119
181
|
|
|
120
182
|
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
121
183
|
const [name] = pos;
|
|
122
184
|
if (!name) {
|
|
123
|
-
console.error('usage: trueclone seed <name> [--force]');
|
|
185
|
+
console.error('usage: trueclone seed <name> [--force] [--hide-until-boot "<selectors a script rebuilds at boot>"]');
|
|
124
186
|
process.exit(1);
|
|
125
187
|
}
|
|
126
188
|
const root = path.resolve('sites', name);
|
|
@@ -133,9 +195,20 @@ if (!(await fs.stat(path.join(mirror, 'index.html')).then(() => true, () => fals
|
|
|
133
195
|
process.exit(1);
|
|
134
196
|
}
|
|
135
197
|
const clone = path.join(root, 'clone');
|
|
198
|
+
const t0 = Date.now();
|
|
199
|
+
const step = (msg) => console.log(`seed ${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s ${msg}`);
|
|
200
|
+
// --patches: re-apply clone/patches/ onto the last seed output in a second, without seeding again
|
|
201
|
+
if (flags.patches) {
|
|
202
|
+
const base = await fs.readFile(path.join(clone, '.seed-base.html'), 'utf8').catch(() => null);
|
|
203
|
+
if (base === null) { console.error(`no seed output to patch in ${path.relative(process.cwd(), clone)}: run trueclone seed ${name} first`); process.exit(1); }
|
|
204
|
+
const { html: patched, applied } = await applyPatches(base);
|
|
205
|
+
await fs.writeFile(path.join(clone, 'index.html'), patched);
|
|
206
|
+
console.log(`patches re-applied onto the last seed (no re-seed): ${applied.join(', ') || 'none in clone/patches/'}`);
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
136
209
|
const exists = await fs.stat(path.join(clone, 'index.html')).then(() => true, () => false);
|
|
137
210
|
if (exists && !flags.force) {
|
|
138
|
-
console.error(`${path.relative(process.cwd(), clone)} already has an index.html
|
|
211
|
+
console.error(`${path.relative(process.cwd(), clone)} already has an index.html: add --force to seed again (your script.js, patches/ and notes.md are kept), or --patches to only re-apply clone/patches/`);
|
|
139
212
|
process.exit(1);
|
|
140
213
|
}
|
|
141
214
|
await fs.mkdir(path.join(clone, 'css'), { recursive: true });
|
|
@@ -160,7 +233,8 @@ const pull = async (rel) => {
|
|
|
160
233
|
// placeholders and a final sweep guarantees no reference path survives.
|
|
161
234
|
const REF_ASSETS = '../reference/assets/';
|
|
162
235
|
const rewrite = async (text, prefix, replacement, forPass = false) => {
|
|
163
|
-
|
|
236
|
+
// saved asset names are [a-z0-9._-]; `&` ends a path inside a style attribute (url("../assets/x.webp"))
|
|
237
|
+
const re = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 'assets/([^"\')\\s&]+)', 'g');
|
|
164
238
|
for (const m of text.matchAll(re)) if (exact || isFont(m[1])) await pull(m[1]);
|
|
165
239
|
return text.replace(re, (all, rel) => (exact || isFont(rel) ? `${replacement}assets/${rel}` : forPass ? `${REF_ASSETS}${rel}` : `${replacement}${PLACEHOLDER}`));
|
|
166
240
|
};
|
|
@@ -174,6 +248,7 @@ for (const file of await fs.readdir(path.join(mirror, 'css')).catch(() => [])) {
|
|
|
174
248
|
if (!exact) out = out.replace(/url\(\s*(['"]?)(?!data:)([^'")]+)\1\s*\)/g, (all, q, u) => (isFont(u) || u.trim().startsWith('#') ? all : `url("../${PLACEHOLDER_BG}")`));
|
|
175
249
|
await fs.writeFile(path.join(clone, 'css', file), out);
|
|
176
250
|
}
|
|
251
|
+
step(`stylesheets copied (${exact ? 'exact' : 'template'} mode)`);
|
|
177
252
|
let html = await fs.readFile(path.join(mirror, 'index.html'), 'utf8');
|
|
178
253
|
html = await rewrite(html, '../', '', !exact);
|
|
179
254
|
await fs.writeFile(path.join(clone, 'index.html'), html);
|
|
@@ -186,44 +261,123 @@ if (!exact) {
|
|
|
186
261
|
}
|
|
187
262
|
// Scroll reveals recorded on the reference (trueclone reveals): mark the elements, start them in their recorded
|
|
188
263
|
// starting state, and replay the recorded curves with a small player when they scroll into view.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
264
|
+
// One file per width when `reveals` ran at every width: elements are matched by path (the same DOM at every width)
|
|
265
|
+
// and the player uses the record for the visitor's width, so phone offsets are phone offsets, not desktop pixels.
|
|
266
|
+
const recs = {};
|
|
267
|
+
for (const v of ['desktop', 'tablet', 'mobile']) {
|
|
268
|
+
const r = JSON.parse(await fs.readFile(path.join(ref, `reveals.${v}.json`), 'utf8').catch(() => 'null'));
|
|
269
|
+
if (r) recs[v] = r;
|
|
270
|
+
}
|
|
271
|
+
const primary = recs.desktop || recs.tablet || recs.mobile || null;
|
|
272
|
+
const byPath = (key) => {
|
|
273
|
+
const m = new Map();
|
|
274
|
+
for (const [v, r] of Object.entries(recs)) for (const x of r[key] || []) {
|
|
275
|
+
if (!m.has(x.path)) m.set(x.path, { path: x.path, cls: x.cls });
|
|
276
|
+
m.get(x.path)[v] = x;
|
|
277
|
+
}
|
|
278
|
+
return [...m.values()];
|
|
279
|
+
};
|
|
280
|
+
const recorded = byPath('reveals');
|
|
281
|
+
// Scroll-linked curves (parallax, scrubbed fades) are recorded px at three sizes: replaying them would be right at those
|
|
282
|
+
// sizes only. They stay in reference/reveals.<size>.json as evidence; script.js reproduces the rule behind them.
|
|
283
|
+
const recordedScrubs = byPath('scrubs');
|
|
284
|
+
const scrubbed = [];
|
|
285
|
+
const classChanges = primary?.classes || [];
|
|
192
286
|
// Widget states recorded on the reference (trueclone widgets): tabs, menus. Template mode only for now: the snapshots
|
|
193
287
|
// are placeholdered like the page; exact-mode snapshots would still point at the site's media.
|
|
194
288
|
const widgetRec = exact ? null : JSON.parse(await fs.readFile(path.join(ref, 'widgets.desktop.json'), 'utf8').catch(() => 'null'));
|
|
195
289
|
const widgetGroups = widgetRec?.groups || [];
|
|
196
|
-
let marked = 0, classMarked = 0, widgets = null;
|
|
197
|
-
|
|
290
|
+
let marked = 0, classMarked = 0, scrubMarked = 0, widgets = null, runtime = null;
|
|
291
|
+
step('browser pass: reveal marks, widgets, runtime clean-up' + (exact ? '' : ', placeholder text and media'));
|
|
292
|
+
({ html, marked, classMarked, scrubMarked, widgets, runtime } = await browserPass(!exact, recorded, classChanges, widgetGroups, scrubbed));
|
|
198
293
|
if (!exact) html = html.split(REF_ASSETS).join('__tc_ref__').replace(/__tc_ref__[^"')\s]+/g, PLACEHOLDER);
|
|
199
294
|
if (widgets?.groups.length) {
|
|
200
295
|
// a plain script (not fetched JSON), so the clone also works opened from disk
|
|
201
296
|
await fs.writeFile(path.join(clone, 'trueclone-widgets.js'), `// trueclone widget player: recorded states of the reference's tabs and menus (trueclone widgets), placeholdered.\nwindow.__tcWidgetData = ${JSON.stringify(widgets)};\n(${widgetPlayer.toString()})();\n`);
|
|
202
297
|
html = html.replace(/<\/body>/i, '<script src="trueclone-widgets.js" defer></script>\n</body>');
|
|
203
298
|
}
|
|
204
|
-
if (marked || classMarked) {
|
|
299
|
+
if (marked || classMarked || scrubMarked) {
|
|
205
300
|
await fs.writeFile(path.join(clone, 'trueclone-reveals.js'), PLAYER);
|
|
301
|
+
const perWidth = (r, shape) => Object.fromEntries(['desktop', 'tablet', 'mobile'].filter((v) => r[v]).map((v) => [v, shape(r[v])]));
|
|
206
302
|
const data = JSON.stringify({
|
|
207
|
-
reveals: recorded.map(({ trigger, duration, frames, to }) => ({ trigger, duration, frames, to })),
|
|
303
|
+
reveals: recorded.map((r) => perWidth(r, ({ trigger, duration, frames, to, from }) => ({ trigger, duration, frames, to, from }))),
|
|
304
|
+
scrubs: [],
|
|
305
|
+
breakpoints: JSON.parse(await fs.readFile(path.join(ref, 'inline-styles.json'), 'utf8').catch(() => '{}')).breakpoints || [],
|
|
306
|
+
sizes: VIEWPORTS.map((v) => ({ name: v.name, width: v.width })),
|
|
208
307
|
classes: classChanges.map(({ from, to, back, sticky, reverts, threshold, trigger }) => ({ from, to, back, sticky, reverts, threshold, trigger })),
|
|
209
308
|
});
|
|
210
309
|
html = html.replace(/<\/body>/i, `<script type="application/json" id="tc-reveals">${data}</script>\n<script src="trueclone-reveals.js" defer></script>\n</body>`);
|
|
211
310
|
}
|
|
311
|
+
// Boot contract: <html class="tc-boot"> from the first byte until every clone script has run (the reveal player drops
|
|
312
|
+
// it after DOMContentLoaded; the Next app after its last script). Regions a script builds or re-lays out at boot
|
|
313
|
+
// (--hide-until-boot "<selectors>") stay invisible meanwhile, so a visitor never sees their captured, pre-script state;
|
|
314
|
+
// a 3 s failsafe shows everything if no script ever runs.
|
|
315
|
+
html = html.replace(/<html\b([^>]*)>/i, (all, attrs) => (/\bclass=/.test(attrs) ? `<html${attrs.replace(/\bclass=(["'])([^"']*)\1/, (m, q, v) => `class=${q}${v.trim() ? `${v.trim()} ` : ''}tc-boot${q}`)}>` : `<html${attrs} class="tc-boot">`));
|
|
316
|
+
html = html.replace(/<\/head>/i, '<style id="tc-boot-styles">/* trueclone seed: boot contract */@keyframes tc-boot-failsafe{to{visibility:visible}}html.tc-boot [data-tc-boot-hide]{visibility:hidden;animation:tc-boot-failsafe 0s 3s forwards}</style>\n</head>');
|
|
212
317
|
// the clone's own behaviour script, loaded once the DOM is parsed
|
|
213
318
|
if (!/<script[^>]+src="script\.js"/.test(html)) html = html.replace(/<\/body>/i, '<script src="script.js" defer></script>\n</body>');
|
|
319
|
+
// the seed's own output, kept so `seed --patches` can re-apply clone/patches/ onto it in a second
|
|
320
|
+
await fs.writeFile(path.join(clone, '.seed-base.html'), html);
|
|
321
|
+
const { html: patchedHtml, applied: patches } = await applyPatches(html);
|
|
322
|
+
html = patchedHtml;
|
|
214
323
|
await fs.writeFile(path.join(clone, 'index.html'), html);
|
|
324
|
+
step('index.html written');
|
|
325
|
+
|
|
326
|
+
// notes.md: the agent's record, with the sections the report reads back (Findings, Accepted differences). Written once.
|
|
327
|
+
const notesFile = path.join(root, 'notes.md');
|
|
328
|
+
if (!(await fs.stat(notesFile).then(() => true, () => false))) {
|
|
329
|
+
await fs.writeFile(notesFile, `# ${name}: clone notes
|
|
330
|
+
|
|
331
|
+
Reference: ${meta.url}. Mode: ${exact ? `exact (permission: ${meta.permission})` : 'template'}. Captured ${meta.capturedAt}.
|
|
332
|
+
Keep every section; the report reads "Findings" and "Accepted differences" back. One line per entry, no prose.
|
|
333
|
+
|
|
334
|
+
## Stack
|
|
335
|
+
|
|
336
|
+
<!-- what the reference is built with: platform, libraries and versions, fonts, breakpoints (from motion.*.md, reference/code.md, the HAR) -->
|
|
337
|
+
|
|
338
|
+
## Behaviours
|
|
339
|
+
|
|
340
|
+
<!-- the motion inventory and the interactions as a list: element, trigger, property, range, timing; one line each -->
|
|
341
|
+
|
|
342
|
+
## Own-code map
|
|
343
|
+
|
|
344
|
+
<!-- file -> behaviours it implements -> where its parameters came from (a motion entry, code.md, a measured value) -->
|
|
345
|
+
|
|
346
|
+
## Findings
|
|
347
|
+
|
|
348
|
+
<!-- defects in the tool, not in the site: symptom, cause if known, the workaround used. Listed in REPORT.md -->
|
|
349
|
+
|
|
350
|
+
## Accepted differences
|
|
351
|
+
|
|
352
|
+
<!-- what cannot or should not match, one per line: "<check> <width> <where>: <why>" (random motion, a live feed, a
|
|
353
|
+
hover that lands by timing, a third-party embed). Listed first in REPORT.md; a check below target that is covered
|
|
354
|
+
here is marked "accepted" there. Every entry needs the why. -->
|
|
355
|
+
|
|
356
|
+
## Unknowns
|
|
357
|
+
|
|
358
|
+
<!-- what you could not determine: a rule you could not read, a value you measured instead of derived -->
|
|
359
|
+
`);
|
|
360
|
+
}
|
|
215
361
|
|
|
216
362
|
const scriptFile = path.join(clone, 'script.js');
|
|
217
363
|
if (!(await fs.stat(scriptFile).then(() => true, () => false))) {
|
|
218
364
|
await fs.writeFile(scriptFile, `// ${meta.url} - clone behaviour (your own code). Markup and styles come from the mirror${exact ? '' : ' (placeholder content)'}.\n// Rebuild every interaction and reveal from the reference's parameters; verify with npx trueclone score ${name}.\n`);
|
|
219
365
|
}
|
|
220
366
|
console.log(`seeded ${path.relative(process.cwd(), clone)} from the mirror (${exact ? 'exact: the site\'s own content' : 'template: placeholder text and media'}): index.html, ${(await fs.readdir(path.join(clone, 'css'))).length} stylesheet(s), ${copied.size} asset(s)${exact ? '' : ' (fonts only)'}`);
|
|
221
|
-
if (recorded.length || classChanges.length) console.log(`replayed by trueclone-reveals.js: ${marked}/${recorded.length} scroll reveals, ${classMarked}/${classChanges.length} scroll class changes${marked < recorded.length || classMarked < classChanges.length ? ' (the rest did not resolve to an element in the mirror)' : ''}`);
|
|
367
|
+
if (recorded.length || classChanges.length) console.log(`replayed by trueclone-reveals.js: ${marked}/${recorded.length} one-shot scroll reveals, ${classMarked}/${classChanges.length} scroll class changes (${Object.keys(recs).join(', ') || 'no'} sizes recorded)${marked < recorded.length || classMarked < classChanges.length ? ' (the rest did not resolve to an element in the mirror)' : ''}`);
|
|
368
|
+
if (recordedScrubs.length) console.log(`not replayed: ${recordedScrubs.length} scroll-linked curve(s) (parallax, scrubbed fades): evidence in reference/reveals.<size>.json; script.js reproduces the rule behind them`);
|
|
369
|
+
if (runtime) {
|
|
370
|
+
const pins = runtime.pins.map((p) => `${p.el} pinned for ${p.distance}px${p.axis ? ' (sideways: left to script.js)' : ''}`).join(', ');
|
|
371
|
+
console.log(`runtime artefacts cleaned: ${runtime.pins.length} GSAP pin(s) made sticky${pins ? ` (${pins}; data-tc-pin on each)` : ''}, ${runtime.canvases} canvas(es) marked data-tc-runtime="canvas"`);
|
|
372
|
+
if (runtime.viewport) console.log(`viewport values: ${runtime.viewport.fitted} value(s) a script computes from the screen size carried as functions of it (read at ${runtime.viewport.sizes} sizes, site breakpoints ${runtime.viewport.breakpoints.join('/') || 'none'})${runtime.viewport.dropped ? `; ${runtime.viewport.dropped} fit no such function and are left to script.js (data-tc-script-owned, reference/script-owned.json)` : ''}`);
|
|
373
|
+
if (runtime.hidden) console.log(`boot contract: ${runtime.hidden} element(s) hidden until the clone's scripts have run (data-tc-boot-hide)`);
|
|
374
|
+
}
|
|
222
375
|
if (widgetGroups.length) console.log(`replayed by trueclone-widgets.js: ${widgets?.groups.length || 0}/${widgetGroups.length} widgets (${widgets?.states || 0} recorded states)${widgetRec?.portals?.length ? `; not replayed (portal popovers): ${widgetRec.portals.join(', ')}` : ''}`);
|
|
376
|
+
if (patches.length) console.log(`patches applied from clone/patches/: ${patches.join(', ')}`);
|
|
223
377
|
console.log(`next: write clone/script.js (behaviour), then npx trueclone score ${name}`);
|
|
224
378
|
|
|
225
379
|
// ---------- One browser pass over the seeded page: placeholders (template mode) and reveal marks ----------
|
|
226
|
-
async function browserPass(template, reveals, classes, groups = []) {
|
|
380
|
+
async function browserPass(template, reveals, classes, groups = [], scrubs = []) {
|
|
227
381
|
// served from sites/<name>/ so the pass can reach reference/assets (see `forPass`)
|
|
228
382
|
const server = await serve(root);
|
|
229
383
|
const browser = await openBrowser(flags.cdp);
|
|
@@ -231,11 +385,12 @@ async function browserPass(template, reveals, classes, groups = []) {
|
|
|
231
385
|
const { page } = await newPage(browser, VIEWPORTS.find((v) => v.name === 'desktop'), {});
|
|
232
386
|
await page.goto(`${server.url}clone/index.html`, { waitUntil: 'load', timeout: 60000 });
|
|
233
387
|
await page.evaluate(() => document.fonts.ready);
|
|
234
|
-
const counts = reveals.length || classes.length ? await page.evaluate(markRevealsInPage, [reveals, classes]) : [0, 0];
|
|
388
|
+
const counts = reveals.length || classes.length || scrubs.length ? await page.evaluate(markRevealsInPage, [reveals, classes, scrubs]) : [0, 0, 0];
|
|
235
389
|
// widgets: mark regions and triggers, then give every recorded state the same placeholders as the page by
|
|
236
390
|
// placing it into its region (so it is laid out and measured for real) and reading it back
|
|
237
391
|
let widgets = null;
|
|
238
392
|
if (groups.length) {
|
|
393
|
+
step(`widgets: ${groups.length} recorded, placing each state`);
|
|
239
394
|
const ok = await page.evaluate(markWidgetsInPage, groups);
|
|
240
395
|
await page.evaluate(`window.__tcPlaceholders = ${placeholdersInPage.toString()}`);
|
|
241
396
|
widgets = { groups: [], states: 0 };
|
|
@@ -258,9 +413,35 @@ async function browserPass(template, reveals, classes, groups = []) {
|
|
|
258
413
|
}
|
|
259
414
|
widgets.groups = Array.from(widgets.groups, (x) => x || null);
|
|
260
415
|
}
|
|
261
|
-
if (template)
|
|
262
|
-
|
|
263
|
-
|
|
416
|
+
if (template) {
|
|
417
|
+
step('template placeholders: measuring every text run (the longest part on a big page)');
|
|
418
|
+
await page.evaluate(placeholdersInPage, [LOREM, null]);
|
|
419
|
+
step('placeholders done');
|
|
420
|
+
}
|
|
421
|
+
// last, because the recorded paths above were taken on the live page, pin-spacers included
|
|
422
|
+
const runtime = await page.evaluate(cleanRuntimeInPage);
|
|
423
|
+
// regions the clone's script builds at boot, hidden until it has run (see the boot contract below)
|
|
424
|
+
runtime.hidden = typeof flags['hide-until-boot'] === 'string'
|
|
425
|
+
? await page.evaluate((sels) => { let n = 0; for (const s of sels) for (const el of document.querySelectorAll(s)) { el.setAttribute('data-tc-boot-hide', ''); n++; } return n; }, flags['hide-until-boot'].split(',').map((s) => s.trim()).filter(Boolean))
|
|
426
|
+
: 0;
|
|
427
|
+
// inline styles that differ between widths (script-computed sizes and offsets) become media-query rules
|
|
428
|
+
// inline styles a script set: functions of the viewport where they are, left to script.js where they aren't
|
|
429
|
+
const inlineStyles = JSON.parse(await fs.readFile(path.join(ref, 'inline-styles.json'), 'utf8').catch(() => 'null'));
|
|
430
|
+
if (inlineStyles) {
|
|
431
|
+
const { plan, owned, fitted, sizes, breakpoints } = planInlineStyles(inlineStyles, VIEWPORTS);
|
|
432
|
+
const applied = await page.evaluate(applyInlineStylePlanInPage, plan);
|
|
433
|
+
runtime.viewport = { fitted, sizes, breakpoints, elements: applied.elements, dropped: applied.dropped };
|
|
434
|
+
await fs.writeFile(path.join(ref, 'script-owned.json'), JSON.stringify({ note: 'inline values a script computes that are not a function of the screen size: script.js must compute them, as the site does', owned: owned.filter((o) => applied.droppedPaths.includes(o.path)) }, null, 2));
|
|
435
|
+
}
|
|
436
|
+
// getHTML keeps the declarative shadow roots the capture serialised (outerHTML would drop them); it is the inner
|
|
437
|
+
// HTML, so the <html> tag and its attributes (font classes, dark mode, lang) are written back around it
|
|
438
|
+
const out = await page.evaluate(() => {
|
|
439
|
+
const el = document.documentElement;
|
|
440
|
+
const inner = el.getHTML ? el.getHTML({ serializableShadowRoots: true }) : el.innerHTML;
|
|
441
|
+
const attrs = [...el.attributes].map((a) => ` ${a.name}="${a.value.replace(/&/g, '&').replace(/"/g, '"')}"`).join('');
|
|
442
|
+
return `<html${attrs}>${inner}</html>`;
|
|
443
|
+
});
|
|
444
|
+
return { html: `<!DOCTYPE html>\n${out}`, marked: counts[0], classMarked: counts[1], scrubMarked: counts[2], widgets, runtime };
|
|
264
445
|
} finally {
|
|
265
446
|
if (!flags.cdp) await browser.close();
|
|
266
447
|
server.close();
|
|
@@ -332,7 +513,7 @@ function widgetPlayer() {
|
|
|
332
513
|
});
|
|
333
514
|
}
|
|
334
515
|
|
|
335
|
-
function markRevealsInPage([reveals, classes]) {
|
|
516
|
+
function markRevealsInPage([reveals, classes, scrubs = []]) {
|
|
336
517
|
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
337
518
|
const resolve = (p) => {
|
|
338
519
|
let el = document.body;
|
|
@@ -344,16 +525,25 @@ function markRevealsInPage([reveals, classes]) {
|
|
|
344
525
|
}
|
|
345
526
|
return el;
|
|
346
527
|
};
|
|
528
|
+
// each reveal's starting state is a rule on its mark (no flash before the player runs; the player then applies the
|
|
529
|
+
// visitor's width); script.js hands an element over by removing the mark, which releases the state with it
|
|
530
|
+
const rules = [];
|
|
347
531
|
let count = 0;
|
|
348
532
|
reveals.forEach((r, i) => {
|
|
349
533
|
const el = resolve(r.path);
|
|
350
534
|
if (!el) return;
|
|
535
|
+
const rec = r.desktop || r.tablet || r.mobile || r;
|
|
351
536
|
el.setAttribute('data-tc-reveal', String(i));
|
|
352
|
-
|
|
353
|
-
el.style.transform = `translate(${r.from.tx}px, ${r.from.ty}px) scale(${r.from.s})`;
|
|
354
|
-
if (r.from.blur) el.style.filter = `blur(${r.from.blur}px)`;
|
|
537
|
+
rules.push(`[data-tc-reveal="${i}"]{opacity:${rec.from.o};transform:translate(${rec.from.tx}px, ${rec.from.ty}px) scale(${rec.from.s})${rec.from.blur ? `;filter:blur(${rec.from.blur}px)` : ''}}`);
|
|
355
538
|
count++;
|
|
356
539
|
});
|
|
540
|
+
if (rules.length) {
|
|
541
|
+
document.getElementById('tc-reveal-styles')?.remove();
|
|
542
|
+
const style = document.createElement('style');
|
|
543
|
+
style.id = 'tc-reveal-styles';
|
|
544
|
+
style.textContent = `/* trueclone seed: starting states of recorded scroll reveals (desktop); trueclone-reveals.js plays them */\n${rules.join('\n')}\n`;
|
|
545
|
+
document.head.appendChild(style);
|
|
546
|
+
}
|
|
357
547
|
let classCount = 0;
|
|
358
548
|
classes.forEach((c, i) => {
|
|
359
549
|
const el = resolve(c.path);
|
|
@@ -362,7 +552,135 @@ function markRevealsInPage([reveals, classes]) {
|
|
|
362
552
|
if (c.from !== null && c.from !== undefined) el.setAttribute('class', c.from);
|
|
363
553
|
classCount++;
|
|
364
554
|
});
|
|
365
|
-
|
|
555
|
+
let scrubCount = 0;
|
|
556
|
+
scrubs.forEach((s, i) => {
|
|
557
|
+
const el = resolve(s.path);
|
|
558
|
+
if (!el) return;
|
|
559
|
+
el.setAttribute('data-tc-scrub', String(i));
|
|
560
|
+
scrubCount++;
|
|
561
|
+
});
|
|
562
|
+
return [count, classCount, scrubCount];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// The plan from viewport-fit.mjs, applied to the page: fitted values replace the captured constants inline; per-range
|
|
566
|
+
// values become media rules whose selector is stacked to the precedence an inline style had over the stylesheets
|
|
567
|
+
// (without !important, so a value script.js writes inline later still wins); values no function fits are removed and
|
|
568
|
+
// left to script.js. A pin spacer's distance becomes its --tc-pin. Motion properties of elements the reveal player
|
|
569
|
+
// drives, and the pin geometry the seed removed, are left alone.
|
|
570
|
+
function applyInlineStylePlanInPage(plan) {
|
|
571
|
+
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
572
|
+
const resolve = (p) => {
|
|
573
|
+
let el = document.body;
|
|
574
|
+
for (const part of p.split('>')) {
|
|
575
|
+
const [tag, n] = part.split(':');
|
|
576
|
+
const kids = [...el.children].filter((c) => !SKIP.has(c.tagName));
|
|
577
|
+
el = kids[Number(n) - 1];
|
|
578
|
+
if (!el || el.tagName.toLowerCase() !== tag) return null;
|
|
579
|
+
}
|
|
580
|
+
return el;
|
|
581
|
+
};
|
|
582
|
+
const MOTION = new Set(['transform', 'translate', 'rotate', 'scale', 'opacity', 'filter']);
|
|
583
|
+
const PIN = new Set(['translate', 'rotate', 'scale', 'inset', 'top', 'left', 'right', 'bottom', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'max-width', 'width', 'max-height', 'height', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'box-sizing', 'position', 'transform', 'flex-basis']);
|
|
584
|
+
// the bottom (or right) length of a padding shorthand, calc() kept whole
|
|
585
|
+
const splitTop = (v) => { const out = []; let depth = 0, cur = ''; for (const ch of String(v)) { if (ch === '(') depth++; if (ch === ')') depth--; if (ch === ' ' && !depth) { if (cur) out.push(cur); cur = ''; } else cur += ch; } if (cur) out.push(cur); return out; };
|
|
586
|
+
const pinOf = (prop, v) => { if (prop === 'padding-bottom') return v; if (prop !== 'padding') return null; const t = splitTop(v); return t[2] ?? t[0]; };
|
|
587
|
+
const rules = [];
|
|
588
|
+
const sel = (i) => `[data-tc-w="${i}"]`.repeat(8); // (0,8,0): above any ordinary author rule, below inline
|
|
589
|
+
let count = 0, dropped = 0;
|
|
590
|
+
const droppedPaths = [];
|
|
591
|
+
for (const [p, entry] of Object.entries(plan)) {
|
|
592
|
+
const el = resolve(p);
|
|
593
|
+
if (!el) continue;
|
|
594
|
+
const i = count;
|
|
595
|
+
let used = false;
|
|
596
|
+
if (el.hasAttribute('data-tc-pin-spacer')) {
|
|
597
|
+
// the spacer's padding was the pin distance: it is the tail's height now (--tc-pin)
|
|
598
|
+
for (const [k, v] of Object.entries(entry.inline)) { const d = v && pinOf(k, v); if (d) { el.style.setProperty('--tc-pin', d); used = true; } }
|
|
599
|
+
for (const r of entry.rules) { const d = pinOf(r.prop, r.value); if (d) { rules.push(`@media ${r.media} { ${sel(i)} { --tc-pin: ${d} } }`); used = true; } }
|
|
600
|
+
if (used) { el.setAttribute('data-tc-w', String(i)); count++; }
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const skip = (k) => (el.hasAttribute('data-tc-runtime') && (PIN.has(k) || k === 'width' || k === 'height')) || ((el.hasAttribute('data-tc-reveal') || el.hasAttribute('data-tc-scrub')) && MOTION.has(k));
|
|
604
|
+
for (const [k, v] of Object.entries(entry.inline)) {
|
|
605
|
+
if (skip(k)) continue;
|
|
606
|
+
el.style.removeProperty(k);
|
|
607
|
+
if (v !== null) el.style.setProperty(k, v);
|
|
608
|
+
used = true;
|
|
609
|
+
}
|
|
610
|
+
const mine = entry.rules.filter((r) => !skip(r.prop));
|
|
611
|
+
for (const r of mine) rules.push(`@media ${r.media} { ${sel(i)} { ${r.prop}: ${r.value} } }`);
|
|
612
|
+
const lost = entry.dropped.filter((k) => !skip(k));
|
|
613
|
+
if (lost.length) { dropped += lost.length; droppedPaths.push(p); el.setAttribute('data-tc-script-owned', lost.join(' ')); }
|
|
614
|
+
if (mine.length) { el.setAttribute('data-tc-w', String(i)); count++; }
|
|
615
|
+
if (!el.getAttribute('style')) el.removeAttribute('style');
|
|
616
|
+
if (used || mine.length || lost.length) { /* counted */ }
|
|
617
|
+
}
|
|
618
|
+
document.getElementById('tc-width-styles')?.remove();
|
|
619
|
+
const style = document.createElement('style');
|
|
620
|
+
style.id = 'tc-width-styles';
|
|
621
|
+
// --tc-vw: 1% of the layout width with a classic scrollbar left out, as the site's scripts measure it; the
|
|
622
|
+
// tc-vw script below keeps it exact, and 1vw stands in without it
|
|
623
|
+
style.textContent = `/* trueclone seed: values the site's scripts compute from the screen size, as functions of it (viewport-fit) */\n:root { --tc-vw: 1vw }\n[data-tc-pin-tail] { height: var(--tc-pin, 0px) }\n${rules.join('\n')}\n`;
|
|
624
|
+
document.head.appendChild(style);
|
|
625
|
+
if (!document.getElementById('tc-vw')) {
|
|
626
|
+
const s = document.createElement('script');
|
|
627
|
+
s.id = 'tc-vw';
|
|
628
|
+
s.textContent = "(()=>{const r=document.documentElement,set=()=>r.style.setProperty('--tc-vw',r.clientWidth/100+'px');set();new ResizeObserver(set).observe(r)})();";
|
|
629
|
+
document.head.appendChild(s);
|
|
630
|
+
}
|
|
631
|
+
return { elements: Object.keys(plan).length, rules: rules.length, dropped, droppedPaths };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
// The mirror is a snapshot taken after the site's scripts ran, so it carries what they left in the DOM: GSAP's
|
|
636
|
+
// pin-spacer wrappers and the pin's inline geometry (fixed desktop pixels that break every other width), and canvas
|
|
637
|
+
// buffer sizes set for one viewport. A pin is a sticky element inside a spacer, which CSS does natively: the seed
|
|
638
|
+
// keeps the spacer as a plain block with the pin distance as padding (`data-tc-pin-spacer`), makes the element
|
|
639
|
+
// sticky, and records the distance on it (`data-tc-pin`), so layout and scroll states hold without a script. The
|
|
640
|
+
// clone's script may take the pin over (unwrap the spacer, drop the sticky style) and draws the marked canvases.
|
|
641
|
+
function cleanRuntimeInPage() {
|
|
642
|
+
const log = { pins: [], canvases: 0 };
|
|
643
|
+
const label = (el) => `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : ''}`;
|
|
644
|
+
// what ScrollTrigger writes on the pinned element while pinning (its own values live in the stylesheet)
|
|
645
|
+
const PIN_PROPS = ['translate', 'rotate', 'scale', 'inset', 'top', 'left', 'right', 'bottom', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'max-width', 'width', 'max-height', 'height', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'box-sizing', 'position', 'transform', 'flex-basis'];
|
|
646
|
+
// what the spacer copied from the element so the parent's flex/grid layout holds: kept; its geometry: dropped
|
|
647
|
+
const SPACER_KEEP = new Set(['order', 'place-self', 'grid-area', 'grid-column', 'grid-row', 'float', 'flex-shrink', 'flex-grow', 'flex-basis', 'display', 'align-self', 'justify-self']);
|
|
648
|
+
for (const spacer of document.querySelectorAll('.pin-spacer')) {
|
|
649
|
+
const pinned = spacer.firstElementChild;
|
|
650
|
+
const horizontal = !spacer.style.paddingBottom && !!spacer.style.paddingRight;
|
|
651
|
+
const distance = Math.round(parseFloat(horizontal ? spacer.style.paddingRight : spacer.style.paddingBottom) || 0);
|
|
652
|
+
if (!pinned) { spacer.replaceWith(...spacer.childNodes); continue; }
|
|
653
|
+
for (const p of PIN_PROPS) pinned.style.removeProperty(p);
|
|
654
|
+
pinned.setAttribute('data-tc-runtime', 'pin');
|
|
655
|
+
pinned.setAttribute('data-tc-pin', String(distance));
|
|
656
|
+
const keep = [...spacer.style].filter((p) => SPACER_KEEP.has(p)).map((p) => `${p}: ${spacer.style.getPropertyValue(p)}`);
|
|
657
|
+
spacer.removeAttribute('class');
|
|
658
|
+
spacer.setAttribute('data-tc-pin-spacer', String(distance));
|
|
659
|
+
if (horizontal) {
|
|
660
|
+
// a sideways pin (a horizontal scroller) is script territory: the spacer keeps its layout role, script.js pins
|
|
661
|
+
spacer.setAttribute('style', keep.join('; '));
|
|
662
|
+
log.pins.push({ el: label(pinned), distance, axis: 'x' });
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
// A sticky element travels within its parent's content box, never into its padding: the spacer holds the pinned
|
|
666
|
+
// element and a tail block as tall as the pin distance (--tc-pin, a function of the viewport when the site's is),
|
|
667
|
+
// so the element stays stuck for exactly that long and then scrolls away, like the original.
|
|
668
|
+
pinned.style.position = 'sticky';
|
|
669
|
+
pinned.style.top = '0px';
|
|
670
|
+
spacer.setAttribute('style', `${keep.join('; ')}${keep.length ? '; ' : ''}--tc-pin: ${distance}px`);
|
|
671
|
+
const tail = document.createElement('div');
|
|
672
|
+
tail.setAttribute('data-tc-pin-tail', '');
|
|
673
|
+
tail.setAttribute('aria-hidden', 'true');
|
|
674
|
+
pinned.after(tail);
|
|
675
|
+
log.pins.push({ el: label(pinned), distance });
|
|
676
|
+
}
|
|
677
|
+
// a canvas keeps its buffer size: with only a CSS width, the height follows the buffer's aspect ratio, and the
|
|
678
|
+
// clone's own scene sets the buffer it needs; the mark says a script draws here
|
|
679
|
+
for (const c of document.querySelectorAll('canvas')) {
|
|
680
|
+
c.setAttribute('data-tc-runtime', 'canvas');
|
|
681
|
+
log.canvases++;
|
|
682
|
+
}
|
|
683
|
+
return log;
|
|
366
684
|
}
|
|
367
685
|
|
|
368
686
|
|
|
@@ -378,41 +696,67 @@ function placeholdersInPage([lorem, rootSel]) {
|
|
|
378
696
|
// Widths come from layout, not canvas: the reference words are measured with Ranges on the live text, candidates
|
|
379
697
|
// in an isolated box that copies the text style (contain: layout, so measuring never re-lays out the page). All
|
|
380
698
|
// reads happen before any text is written.
|
|
381
|
-
|
|
699
|
+
//
|
|
700
|
+
// Speed (a big page has tens of thousands of runs): an element's font is read from its computed style once, and
|
|
701
|
+
// every word list is measured in one layout per font (one block per word), never one forced layout per word.
|
|
702
|
+
const meter = document.createElement('div');
|
|
382
703
|
meter.style.cssText = 'position:absolute;left:-99999px;top:0;white-space:pre;contain:layout style paint;visibility:hidden';
|
|
383
704
|
document.body.appendChild(meter);
|
|
384
705
|
const PROPS = ['fontStyle', 'fontWeight', 'fontSize', 'fontFamily', 'fontStretch', 'fontVariant', 'fontFeatureSettings', 'fontKerning', 'letterSpacing', 'wordSpacing', 'textTransform'];
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (!
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
706
|
+
// key -> { key, vals, words: Map(word -> width), lists: Map(casing -> {words, widths, sorted}) }; kept on the page
|
|
707
|
+
// across calls (every widget state is shaped by its own call)
|
|
708
|
+
const fonts = window.__tcFonts || (window.__tcFonts = new Map());
|
|
709
|
+
const fontOf = (s) => {
|
|
710
|
+
const vals = PROPS.map((p) => s[p]);
|
|
711
|
+
const key = vals.join('|');
|
|
712
|
+
if (!fonts.has(key)) fonts.set(key, { key, vals, words: new Map(), lists: new Map() });
|
|
713
|
+
return fonts.get(key);
|
|
714
|
+
};
|
|
715
|
+
const measure = (f, list) => { // widths of many words in this font, one layout for all of them
|
|
716
|
+
const todo = [...new Set(list)].filter((w) => !f.words.has(w));
|
|
717
|
+
if (todo.length) {
|
|
718
|
+
meter.textContent = '';
|
|
719
|
+
PROPS.forEach((p, i) => { meter.style[p] = f.vals[i]; });
|
|
720
|
+
const frag = document.createDocumentFragment();
|
|
721
|
+
const blocks = todo.map((w) => { const b = document.createElement('div'); b.style.cssText = 'width:max-content'; b.textContent = w; frag.appendChild(b); return b; });
|
|
722
|
+
meter.appendChild(frag);
|
|
723
|
+
blocks.forEach((b, i) => f.words.set(todo[i], b.getBoundingClientRect().width));
|
|
724
|
+
meter.textContent = '';
|
|
725
|
+
}
|
|
726
|
+
return list.map((w) => f.words.get(w));
|
|
727
|
+
};
|
|
728
|
+
const widthIn = (f, word) => (f.words.has(word) ? f.words.get(word) : measure(f, [word])[0]);
|
|
729
|
+
const casings = {
|
|
730
|
+
caps: (w) => w.toUpperCase(),
|
|
731
|
+
cap: (w) => w[0].toUpperCase() + w.slice(1),
|
|
732
|
+
plain: (w) => w,
|
|
733
|
+
};
|
|
734
|
+
const listFor = (f, casing) => { // the vocabulary in one casing: words, widths, and the same sorted by width
|
|
735
|
+
if (!f.lists.has(casing)) {
|
|
736
|
+
const words = lorem.map(casings[casing]);
|
|
737
|
+
const widths = measure(f, words);
|
|
738
|
+
const sorted = words.map((w, i) => [widths[i], w, lorem[i]]).sort((a, b) => a[0] - b[0]);
|
|
739
|
+
f.lists.set(casing, { words, widths, sorted });
|
|
395
740
|
}
|
|
396
|
-
return
|
|
741
|
+
return f.lists.get(casing);
|
|
397
742
|
};
|
|
398
743
|
const RUN = /([\p{L}\p{N}]+)/u;
|
|
399
|
-
const pick = (
|
|
744
|
+
const pick = (f, run, refWidth, state) => {
|
|
400
745
|
if (/^\p{N}+$/u.test(run)) return /^(19|20)\d\d$/.test(run) ? run : run.replace(/\d/g, (d) => String((Number(d) + 3) % 10));
|
|
401
746
|
const caps = run.length > 1 && run === run.toUpperCase() && /\p{L}/u.test(run);
|
|
402
747
|
const cap = !caps && /\p{Lu}/u.test(run[0]);
|
|
403
|
-
const
|
|
748
|
+
const { words, widths, sorted } = listFor(f, caps ? 'caps' : cap ? 'cap' : 'plain');
|
|
404
749
|
const target = refWidth + state.drift;
|
|
405
750
|
let best = null, bestGap = Infinity;
|
|
406
751
|
state.seed = (state.seed * 31 + run.length) % 9973;
|
|
407
|
-
for (let i = 0; i <
|
|
408
|
-
const
|
|
409
|
-
const gap = Math.abs(
|
|
410
|
-
if (gap < bestGap) { best =
|
|
752
|
+
for (let i = 0; i < words.length; i++) {
|
|
753
|
+
const k = (i + state.seed) % words.length;
|
|
754
|
+
const gap = Math.abs(widths[k] - target);
|
|
755
|
+
if (gap < bestGap) { best = words[k]; bestGap = gap; }
|
|
411
756
|
}
|
|
412
757
|
// No single word close enough (a 2px miss moves a line break): try two lorem words joined into one. Still one
|
|
413
758
|
// word, so the break opportunities stay the reference's, but there are ~10^5 widths to choose from.
|
|
414
759
|
if (bestGap > 0.75 && target > 30) {
|
|
415
|
-
const sorted = lorem.map((w) => [widthIn(s, cased(w)), w]).sort((a, b) => a[0] - b[0]);
|
|
416
760
|
const tail = (w) => (caps ? w.toUpperCase() : w); // only the first part carries the capital
|
|
417
761
|
const tries = [];
|
|
418
762
|
for (const [wa, a] of sorted) {
|
|
@@ -420,22 +764,23 @@ function placeholdersInPage([lorem, rootSel]) {
|
|
|
420
764
|
if (need < 6) break;
|
|
421
765
|
let lo = 0, hi = sorted.length - 1;
|
|
422
766
|
while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid][0] < need) lo = mid + 1; else hi = mid; }
|
|
423
|
-
for (const k of [lo - 1, lo]) if (sorted[k]) tries.push([Math.abs(wa + sorted[k][0] - target),
|
|
767
|
+
for (const k of [lo - 1, lo]) if (sorted[k]) tries.push([Math.abs(wa + sorted[k][0] - target), a + tail(sorted[k][2])]);
|
|
424
768
|
}
|
|
425
769
|
tries.sort((x, y) => x[0] - y[0]);
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
770
|
+
const top = tries.slice(0, 12).map((t) => t[1]); // estimates; measure the real compounds (kerning at the join)
|
|
771
|
+
measure(f, top).forEach((w, i) => {
|
|
772
|
+
const gap = Math.abs(w - target);
|
|
773
|
+
if (gap < bestGap) { best = top[i]; bestGap = gap; }
|
|
774
|
+
});
|
|
430
775
|
}
|
|
431
|
-
state.drift = target - widthIn(
|
|
776
|
+
state.drift = target - widthIn(f, best);
|
|
432
777
|
return best;
|
|
433
778
|
};
|
|
434
779
|
// for attributes (alt, aria-label...): no layout to match, the element's own font is enough
|
|
435
780
|
const shaper = (el) => {
|
|
436
|
-
const
|
|
781
|
+
const f = fontOf(getComputedStyle(el || document.body));
|
|
437
782
|
const state = { drift: 0, seed: 7 };
|
|
438
|
-
return (text) => text.split(RUN).map((run) => (RUN.test(run) && !/^[^\p{L}\p{N}]*$/u.test(run) ? pick(
|
|
783
|
+
return (text) => text.split(RUN).map((run) => (RUN.test(run) && !/^[^\p{L}\p{N}]*$/u.test(run) ? pick(f, run, widthIn(f, run), state) : run)).join('');
|
|
439
784
|
};
|
|
440
785
|
|
|
441
786
|
// text nodes: measure every original letter run first (reads only)
|
|
@@ -456,7 +801,7 @@ function placeholdersInPage([lorem, rootSel]) {
|
|
|
456
801
|
const rects = [...r.getClientRects()];
|
|
457
802
|
return rects.reduce((sum, x) => sum + x.width, 0) || null; // a run hidden by display:none measures 0
|
|
458
803
|
});
|
|
459
|
-
plan.push({ n, parts, widths,
|
|
804
|
+
plan.push({ n, parts, widths, font: fontOf(getComputedStyle(n.parentElement)) });
|
|
460
805
|
}
|
|
461
806
|
// then choose the words (writes go to the isolated box only), then write all the text
|
|
462
807
|
const states = new Map();
|
|
@@ -465,7 +810,7 @@ function placeholdersInPage([lorem, rootSel]) {
|
|
|
465
810
|
states.set(item.n.parentElement, state);
|
|
466
811
|
item.text = item.parts.map((part, i) => {
|
|
467
812
|
if (!/[\p{L}\p{N}]/u.test(part)) return part;
|
|
468
|
-
return pick(item.
|
|
813
|
+
return pick(item.font, part, item.widths[i] ?? widthIn(item.font, part), state);
|
|
469
814
|
}).join('');
|
|
470
815
|
}
|
|
471
816
|
for (const item of plan) item.n.textContent = item.text;
|
|
@@ -511,3 +856,22 @@ function placeholdersInPage([lorem, rootSel]) {
|
|
|
511
856
|
document.title = shaper(null)(document.title);
|
|
512
857
|
return document.documentElement.outerHTML;
|
|
513
858
|
}
|
|
859
|
+
|
|
860
|
+
// Patches: what a clone needs on top of the seed lives in clone/patches/ and is applied on every seed (and alone with
|
|
861
|
+
// `seed --patches`), so the generated index.html is never edited by hand and a re-seed never loses a fix. In name order:
|
|
862
|
+
// head-*.html inserted before </head> (meta, preloads, extra stylesheet links)
|
|
863
|
+
// *.css linked before </head> (after the site's stylesheets, so they win ties)
|
|
864
|
+
// body-*.html inserted before the clone's own <script src="script.js"> (vendor libraries, a hand-over prelude)
|
|
865
|
+
// *.js <script src defer> before script.js (runs after the players, before script.js)
|
|
866
|
+
async function applyPatches(html) {
|
|
867
|
+
const patchDir = path.join(root, 'clone', 'patches');
|
|
868
|
+
const applied = (await fs.readdir(patchDir).catch(() => [])).filter((f) => /\.(html|css|js)$/i.test(f)).sort();
|
|
869
|
+
const beforeScript = (s) => (/<script[^>]+src="script\.js"/.test(html) ? html.replace(/<script[^>]+src="script\.js"[^>]*><\/script>/i, (tag) => `${s}\n${tag}`) : html.replace(/<\/body>/i, `${s}\n</body>`));
|
|
870
|
+
for (const f of applied) {
|
|
871
|
+
if (/^head-.*\.html$/i.test(f)) html = html.replace(/<\/head>/i, `${await fs.readFile(path.join(patchDir, f), 'utf8')}\n</head>`);
|
|
872
|
+
else if (/\.css$/i.test(f)) html = html.replace(/<\/head>/i, `<link rel="stylesheet" href="patches/${f}">\n</head>`);
|
|
873
|
+
else if (/\.html$/i.test(f)) html = beforeScript(await fs.readFile(path.join(patchDir, f), 'utf8'));
|
|
874
|
+
else if (/\.js$/i.test(f)) html = beforeScript(`<script src="patches/${f}" defer></script>`);
|
|
875
|
+
}
|
|
876
|
+
return { html, applied };
|
|
877
|
+
}
|