@trueclone/cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/bin/trueclone.mjs +6 -1
- package/package.json +1 -1
- package/scripts/capture.mjs +124 -6
- package/scripts/detail.mjs +19 -6
- package/scripts/drift.mjs +48 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/hovers.mjs +17 -8
- package/scripts/lib.mjs +8 -24
- package/scripts/load.mjs +102 -0
- package/scripts/next.mjs +91 -26
- package/scripts/owned.mjs +66 -0
- package/scripts/report.mjs +43 -11
- package/scripts/reveals.mjs +174 -80
- package/scripts/score.mjs +71 -9
- package/scripts/seed.mjs +316 -25
- package/scripts/understand.mjs +231 -0
- package/skill/SKILL.md +42 -13
package/scripts/seed.mjs
CHANGED
|
@@ -56,15 +56,23 @@ verbum vereor vero verto verus vester veto vetus via video vinco vinum vir virgo
|
|
|
56
56
|
voluntas vos votum vox vulnus vultus`;
|
|
57
57
|
const LOREM = LOREM_TEXT.split(/\s+/).filter(Boolean);
|
|
58
58
|
|
|
59
|
+
// The player's width classes (see PLAYER) as media queries: the same three widths the reference was captured at.
|
|
60
|
+
const WIDTH_MEDIA = { desktop: '(min-width: 1100px)', tablet: '(min-width: 600px) and (max-width: 1099px)', mobile: '(max-width: 599px)' };
|
|
61
|
+
|
|
59
62
|
// Reveal player shipped with the clone (trueclone's own code): replays each recorded curve with the Web Animations
|
|
60
63
|
// API once the element's top crosses the recorded trigger line; with reduced motion it shows the end state.
|
|
61
64
|
const PLAYER = `// trueclone reveal player: replays what was recorded on the reference while scrolling (trueclone reveals): one-shot
|
|
62
|
-
// reveals frame by frame,
|
|
65
|
+
// reveals frame by frame, scroll-linked curves (parallax, scrubbed fades) by scroll position, and class changes (a
|
|
66
|
+
// header that gets its backdrop past 12px, an "is-visible" toggle). Records exist per width; the one for the
|
|
67
|
+
// visitor's width is used (desktop from 1100px, tablet from 600px, otherwise mobile).
|
|
63
68
|
(() => {
|
|
64
69
|
const raw = JSON.parse(document.getElementById('tc-reveals')?.textContent || '{}');
|
|
65
70
|
const data = Array.isArray(raw) ? raw : raw.reveals || [];
|
|
66
71
|
const classData = Array.isArray(raw) ? [] : raw.classes || [];
|
|
72
|
+
const scrubData = Array.isArray(raw) ? [] : raw.scrubs || [];
|
|
67
73
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
74
|
+
const vpName = innerWidth >= 1100 ? 'desktop' : innerWidth >= 600 ? 'tablet' : 'mobile';
|
|
75
|
+
const pick = (entry) => (entry && (entry.desktop || entry.tablet || entry.mobile) ? entry[vpName] || entry.desktop || entry.tablet || entry.mobile : entry);
|
|
68
76
|
|
|
69
77
|
// class changes are state, not motion: they apply with reduced motion too
|
|
70
78
|
let scrolled = false;
|
|
@@ -74,10 +82,12 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
|
|
|
74
82
|
if (!c) continue;
|
|
75
83
|
if (c.sticky) { pinned.push([el, c]); continue; }
|
|
76
84
|
const line = Math.max(0, Math.min(1, c.trigger ?? 1));
|
|
85
|
+
// an element without a box (display: contents) never intersects: watch its first descendant that has one
|
|
86
|
+
const watched = el.getBoundingClientRect().height ? el : [...el.querySelectorAll('*')].find((d) => d.getBoundingClientRect().height) || el;
|
|
77
87
|
new IntersectionObserver(([e], io) => {
|
|
78
88
|
if (e.isIntersecting) { el.setAttribute('class', c.to); if (!c.reverts) io.disconnect(); }
|
|
79
89
|
else if (c.reverts && el.getAttribute('class') === c.to) el.setAttribute('class', c.back ?? c.from);
|
|
80
|
-
}, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` }).observe(
|
|
90
|
+
}, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` }).observe(watched);
|
|
81
91
|
}
|
|
82
92
|
const applyPinned = () => {
|
|
83
93
|
if (scrollY > 0) scrolled = true;
|
|
@@ -93,15 +103,55 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
|
|
|
93
103
|
applyPinned();
|
|
94
104
|
}
|
|
95
105
|
const style = (f) => ({ opacity: f.o, transform: \`translate(\${f.tx}px, \${f.ty}px) scale(\${f.s})\`, filter: f.blur ? \`blur(\${f.blur}px)\` : 'none' });
|
|
96
|
-
|
|
106
|
+
// scroll-linked curves: the recorded state at the two nearest scroll positions, interpolated. An element that also
|
|
107
|
+
// plays a one-shot reveal follows its curve once the reveal has finished.
|
|
108
|
+
// A curve is keyed by the element's own position in the viewport (key "v": its layout box top, translate taken
|
|
109
|
+
// out, the way a ScrollTrigger on the element sees it, so any viewport height gives the same curve) or, for an
|
|
110
|
+
// element inside a pinned stage, by the scroll position (key "y").
|
|
111
|
+
const scrubs = [];
|
|
112
|
+
for (const el of document.querySelectorAll('[data-tc-scrub]')) {
|
|
113
|
+
const rec = pick(scrubData[Number(el.dataset.tcScrub)]);
|
|
114
|
+
const s = Array.isArray(rec) ? rec : rec && rec.samples;
|
|
115
|
+
if (!s || s.length < 2) continue;
|
|
116
|
+
const key = !Array.isArray(rec) && rec.key === 'v' && s[0].v !== undefined ? 'v' : 'y';
|
|
117
|
+
scrubs.push({ el, key, s: key === 'v' ? [...s].sort((p, q) => p.v - q.v) : s, ty: 0, ready: !el.hasAttribute('data-tc-reveal') });
|
|
118
|
+
}
|
|
119
|
+
const at = (s, key, pos) => {
|
|
120
|
+
if (pos <= s[0][key]) return s[0];
|
|
121
|
+
for (let i = 1; i < s.length; i++) {
|
|
122
|
+
if (pos > s[i][key]) continue;
|
|
123
|
+
const a = s[i - 1], b = s[i], k = (pos - a[key]) / ((b[key] - a[key]) || 1);
|
|
124
|
+
const L = (p) => a[p] + (b[p] - a[p]) * k;
|
|
125
|
+
return { o: L('o'), tx: L('tx'), ty: L('ty'), s: L('s'), blur: L('blur') };
|
|
126
|
+
}
|
|
127
|
+
return s[s.length - 1];
|
|
128
|
+
};
|
|
129
|
+
// script.js takes an element over by removing its data-tc-scrub / data-tc-reveal mark: the player then leaves it alone
|
|
130
|
+
const applyScrubs = () => {
|
|
131
|
+
for (const x of scrubs) {
|
|
132
|
+
if (!x.ready || !x.el.hasAttribute('data-tc-scrub')) continue;
|
|
133
|
+
const pos = x.key === 'v' ? x.el.getBoundingClientRect().top - x.ty : scrollY;
|
|
134
|
+
const f = at(x.s, x.key, pos);
|
|
135
|
+
x.ty = f.ty;
|
|
136
|
+
Object.assign(x.el.style, style(f));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
if (scrubs.length) {
|
|
140
|
+
let queued = false;
|
|
141
|
+
addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; applyScrubs(); }); } }, { passive: true });
|
|
142
|
+
applyScrubs();
|
|
143
|
+
}
|
|
144
|
+
const release = (el) => { const x = scrubs.find((q) => q.el === el); if (x) { x.ready = true; applyScrubs(); } };
|
|
145
|
+
const settle = (el, r) => { Object.assign(el.style, style(r.to)); release(el); };
|
|
97
146
|
const play = (el, r) => {
|
|
98
147
|
const frames = r.frames.map((f) => ({ ...style(f), offset: r.duration ? Math.min(1, f.t / r.duration) : 1 }));
|
|
99
148
|
const anim = el.animate(frames, { duration: r.duration, fill: 'forwards', easing: 'linear' });
|
|
100
149
|
anim.onfinish = () => { settle(el, r); anim.cancel(); };
|
|
101
150
|
};
|
|
102
151
|
for (const el of document.querySelectorAll('[data-tc-reveal]')) {
|
|
103
|
-
const r = data[Number(el.dataset.tcReveal)];
|
|
104
|
-
if (!r) continue;
|
|
152
|
+
const r = pick(data[Number(el.dataset.tcReveal)]);
|
|
153
|
+
if (!r) { release(el); continue; }
|
|
154
|
+
if (r.from) Object.assign(el.style, style(r.from)); // this width's starting state (the seed inlined the desktop one)
|
|
105
155
|
if (reduce) { settle(el, r); continue; }
|
|
106
156
|
// the recorded trigger is where the element's top was (as a share of the window height) when it started
|
|
107
157
|
const line = Math.max(0, Math.min(1, r.trigger ?? 1));
|
|
@@ -109,18 +159,24 @@ const PLAYER = `// trueclone reveal player: replays what was recorded on the ref
|
|
|
109
159
|
for (const e of entries) {
|
|
110
160
|
if (!e.isIntersecting) continue;
|
|
111
161
|
io.disconnect();
|
|
112
|
-
play(el, r);
|
|
162
|
+
if (el.hasAttribute('data-tc-reveal')) play(el, r);
|
|
113
163
|
}
|
|
114
164
|
}, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` });
|
|
115
165
|
io.observe(el);
|
|
116
166
|
}
|
|
167
|
+
// boot contract: the page shows once every clone script has run (DOMContentLoaded comes after the deferred ones;
|
|
168
|
+
// the Next app's injector releases after its last script) and every hold is released. script.js holds the boot
|
|
169
|
+
// while it prepares what a visitor must not see half-built (an intro waiting for its image): tcBoot.hold() at the
|
|
170
|
+
// top, tcBoot.release() when ready.
|
|
171
|
+
const boot = window.tcBoot = { holds: 1, hold() { this.holds++; }, release() { if (--this.holds <= 0) requestAnimationFrame(() => document.documentElement.classList.remove('tc-boot')); } };
|
|
172
|
+
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => boot.release()); else if (!window.__tcInjected) boot.release();
|
|
117
173
|
})();
|
|
118
174
|
`;
|
|
119
175
|
|
|
120
176
|
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
121
177
|
const [name] = pos;
|
|
122
178
|
if (!name) {
|
|
123
|
-
console.error('usage: trueclone seed <name> [--force]');
|
|
179
|
+
console.error('usage: trueclone seed <name> [--force] [--hide-until-boot "<selectors a script rebuilds at boot>"]');
|
|
124
180
|
process.exit(1);
|
|
125
181
|
}
|
|
126
182
|
const root = path.resolve('sites', name);
|
|
@@ -160,7 +216,8 @@ const pull = async (rel) => {
|
|
|
160
216
|
// placeholders and a final sweep guarantees no reference path survives.
|
|
161
217
|
const REF_ASSETS = '../reference/assets/';
|
|
162
218
|
const rewrite = async (text, prefix, replacement, forPass = false) => {
|
|
163
|
-
|
|
219
|
+
// saved asset names are [a-z0-9._-]; `&` ends a path inside a style attribute (url("../assets/x.webp"))
|
|
220
|
+
const re = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 'assets/([^"\')\\s&]+)', 'g');
|
|
164
221
|
for (const m of text.matchAll(re)) if (exact || isFont(m[1])) await pull(m[1]);
|
|
165
222
|
return text.replace(re, (all, rel) => (exact || isFont(rel) ? `${replacement}assets/${rel}` : forPass ? `${REF_ASSETS}${rel}` : `${replacement}${PLACEHOLDER}`));
|
|
166
223
|
};
|
|
@@ -186,44 +243,126 @@ if (!exact) {
|
|
|
186
243
|
}
|
|
187
244
|
// Scroll reveals recorded on the reference (trueclone reveals): mark the elements, start them in their recorded
|
|
188
245
|
// starting state, and replay the recorded curves with a small player when they scroll into view.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
246
|
+
// One file per width when `reveals` ran at every width: elements are matched by path (the same DOM at every width)
|
|
247
|
+
// and the player uses the record for the visitor's width, so phone offsets are phone offsets, not desktop pixels.
|
|
248
|
+
const recs = {};
|
|
249
|
+
for (const v of ['desktop', 'tablet', 'mobile']) {
|
|
250
|
+
const r = JSON.parse(await fs.readFile(path.join(ref, `reveals.${v}.json`), 'utf8').catch(() => 'null'));
|
|
251
|
+
if (r) recs[v] = r;
|
|
252
|
+
}
|
|
253
|
+
const primary = recs.desktop || recs.tablet || recs.mobile || null;
|
|
254
|
+
const byPath = (key) => {
|
|
255
|
+
const m = new Map();
|
|
256
|
+
for (const [v, r] of Object.entries(recs)) for (const x of r[key] || []) {
|
|
257
|
+
if (!m.has(x.path)) m.set(x.path, { path: x.path, cls: x.cls });
|
|
258
|
+
m.get(x.path)[v] = x;
|
|
259
|
+
}
|
|
260
|
+
return [...m.values()];
|
|
261
|
+
};
|
|
262
|
+
const recorded = byPath('reveals');
|
|
263
|
+
const scrubbed = byPath('scrubs');
|
|
264
|
+
const classChanges = primary?.classes || [];
|
|
192
265
|
// Widget states recorded on the reference (trueclone widgets): tabs, menus. Template mode only for now: the snapshots
|
|
193
266
|
// are placeholdered like the page; exact-mode snapshots would still point at the site's media.
|
|
194
267
|
const widgetRec = exact ? null : JSON.parse(await fs.readFile(path.join(ref, 'widgets.desktop.json'), 'utf8').catch(() => 'null'));
|
|
195
268
|
const widgetGroups = widgetRec?.groups || [];
|
|
196
|
-
let marked = 0, classMarked = 0, widgets = null;
|
|
197
|
-
|
|
269
|
+
let marked = 0, classMarked = 0, scrubMarked = 0, widgets = null, runtime = null;
|
|
270
|
+
({ html, marked, classMarked, scrubMarked, widgets, runtime } = await browserPass(!exact, recorded, classChanges, widgetGroups, scrubbed));
|
|
198
271
|
if (!exact) html = html.split(REF_ASSETS).join('__tc_ref__').replace(/__tc_ref__[^"')\s]+/g, PLACEHOLDER);
|
|
199
272
|
if (widgets?.groups.length) {
|
|
200
273
|
// a plain script (not fetched JSON), so the clone also works opened from disk
|
|
201
274
|
await fs.writeFile(path.join(clone, 'trueclone-widgets.js'), `// trueclone widget player: recorded states of the reference's tabs and menus (trueclone widgets), placeholdered.\nwindow.__tcWidgetData = ${JSON.stringify(widgets)};\n(${widgetPlayer.toString()})();\n`);
|
|
202
275
|
html = html.replace(/<\/body>/i, '<script src="trueclone-widgets.js" defer></script>\n</body>');
|
|
203
276
|
}
|
|
204
|
-
if (marked || classMarked) {
|
|
277
|
+
if (marked || classMarked || scrubMarked) {
|
|
205
278
|
await fs.writeFile(path.join(clone, 'trueclone-reveals.js'), PLAYER);
|
|
279
|
+
const perWidth = (r, shape) => Object.fromEntries(['desktop', 'tablet', 'mobile'].filter((v) => r[v]).map((v) => [v, shape(r[v])]));
|
|
206
280
|
const data = JSON.stringify({
|
|
207
|
-
reveals: recorded.map(({ trigger, duration, frames, to }) => ({ trigger, duration, frames, to })),
|
|
281
|
+
reveals: recorded.map((r) => perWidth(r, ({ trigger, duration, frames, to, from }) => ({ trigger, duration, frames, to, from }))),
|
|
282
|
+
scrubs: scrubbed.map((r) => perWidth(r, (x) => ({ key: x.key, samples: x.samples }))),
|
|
208
283
|
classes: classChanges.map(({ from, to, back, sticky, reverts, threshold, trigger }) => ({ from, to, back, sticky, reverts, threshold, trigger })),
|
|
209
284
|
});
|
|
210
285
|
html = html.replace(/<\/body>/i, `<script type="application/json" id="tc-reveals">${data}</script>\n<script src="trueclone-reveals.js" defer></script>\n</body>`);
|
|
211
286
|
}
|
|
287
|
+
// Boot contract: <html class="tc-boot"> from the first byte until every clone script has run (the reveal player drops
|
|
288
|
+
// it after DOMContentLoaded; the Next app after its last script). Regions a script builds or re-lays out at boot
|
|
289
|
+
// (--hide-until-boot "<selectors>") stay invisible meanwhile, so a visitor never sees their captured, pre-script state;
|
|
290
|
+
// a 3 s failsafe shows everything if no script ever runs.
|
|
291
|
+
html = html.replace(/<html\b([^>]*)>/i, (all, attrs) => (/\bclass=/.test(attrs) ? `<html${attrs.replace(/\bclass=(["'])([^"']*)\1/, (m, q, v) => `class=${q}${v.trim() ? `${v.trim()} ` : ''}tc-boot${q}`)}>` : `<html${attrs} class="tc-boot">`));
|
|
292
|
+
html = html.replace(/<\/head>/i, '<style id="tc-boot-styles">/* trueclone seed: boot contract */@keyframes tc-boot-failsafe{to{visibility:visible}}html.tc-boot [data-tc-boot-hide]{visibility:hidden;animation:tc-boot-failsafe 0s 3s forwards}</style>\n</head>');
|
|
212
293
|
// the clone's own behaviour script, loaded once the DOM is parsed
|
|
213
294
|
if (!/<script[^>]+src="script\.js"/.test(html)) html = html.replace(/<\/body>/i, '<script src="script.js" defer></script>\n</body>');
|
|
295
|
+
// Patches: what a clone needs on top of the seed lives in clone/patches/ and is applied here on every seed, so the
|
|
296
|
+
// generated index.html is never edited by hand and a re-seed never loses a fix. In name order:
|
|
297
|
+
// head-*.html inserted before </head> (meta, extra stylesheet links)
|
|
298
|
+
// *.css linked before </head> (after the site's stylesheets, so they win ties)
|
|
299
|
+
// body-*.html inserted before the clone's own <script src="script.js"> (vendor libraries, a hand-over prelude)
|
|
300
|
+
// *.js <script src defer> before script.js (runs after the players, before script.js)
|
|
301
|
+
const patchDir = path.join(clone, 'patches');
|
|
302
|
+
const patches = (await fs.readdir(patchDir).catch(() => [])).filter((f) => /\.(html|css|js)$/i.test(f)).sort();
|
|
303
|
+
const beforeScript = (s) => (/<script[^>]+src="script\.js"/.test(html) ? html.replace(/<script[^>]+src="script\.js"[^>]*><\/script>/i, (tag) => `${s}\n${tag}`) : html.replace(/<\/body>/i, `${s}\n</body>`));
|
|
304
|
+
for (const f of patches) {
|
|
305
|
+
if (/^head-.*\.html$/i.test(f)) html = html.replace(/<\/head>/i, `${await fs.readFile(path.join(patchDir, f), 'utf8')}\n</head>`);
|
|
306
|
+
else if (/\.css$/i.test(f)) html = html.replace(/<\/head>/i, `<link rel="stylesheet" href="patches/${f}">\n</head>`);
|
|
307
|
+
else if (/\.html$/i.test(f)) html = beforeScript(await fs.readFile(path.join(patchDir, f), 'utf8'));
|
|
308
|
+
else if (/\.js$/i.test(f)) html = beforeScript(`<script src="patches/${f}" defer></script>`);
|
|
309
|
+
}
|
|
214
310
|
await fs.writeFile(path.join(clone, 'index.html'), html);
|
|
215
311
|
|
|
312
|
+
// notes.md: the agent's record, with the sections the report reads back (Findings, Accepted differences). Written once.
|
|
313
|
+
const notesFile = path.join(root, 'notes.md');
|
|
314
|
+
if (!(await fs.stat(notesFile).then(() => true, () => false))) {
|
|
315
|
+
await fs.writeFile(notesFile, `# ${name}: clone notes
|
|
316
|
+
|
|
317
|
+
Reference: ${meta.url}. Mode: ${exact ? `exact (permission: ${meta.permission})` : 'template'}. Captured ${meta.capturedAt}.
|
|
318
|
+
Keep every section; the report reads "Findings" and "Accepted differences" back. One line per entry, no prose.
|
|
319
|
+
|
|
320
|
+
## Stack
|
|
321
|
+
|
|
322
|
+
<!-- what the reference is built with: platform, libraries and versions, fonts, breakpoints (from motion.*.md, reference/code.md, the HAR) -->
|
|
323
|
+
|
|
324
|
+
## Behaviours
|
|
325
|
+
|
|
326
|
+
<!-- the motion inventory and the interactions as a list: element, trigger, property, range, timing; one line each -->
|
|
327
|
+
|
|
328
|
+
## Own-code map
|
|
329
|
+
|
|
330
|
+
<!-- file -> behaviours it implements -> where its parameters came from (a motion entry, code.md, a measured value) -->
|
|
331
|
+
|
|
332
|
+
## Findings
|
|
333
|
+
|
|
334
|
+
<!-- defects in the tool, not in the site: symptom, cause if known, the workaround used. Listed in REPORT.md -->
|
|
335
|
+
|
|
336
|
+
## Accepted differences
|
|
337
|
+
|
|
338
|
+
<!-- what cannot or should not match, one per line: "<check> <width> <where>: <why>" (random motion, a live feed, a
|
|
339
|
+
hover that lands by timing, a third-party embed). Listed first in REPORT.md; a check below target that is covered
|
|
340
|
+
here is marked "accepted" there. Every entry needs the why. -->
|
|
341
|
+
|
|
342
|
+
## Unknowns
|
|
343
|
+
|
|
344
|
+
<!-- what you could not determine: a rule you could not read, a value you measured instead of derived -->
|
|
345
|
+
`);
|
|
346
|
+
}
|
|
347
|
+
|
|
216
348
|
const scriptFile = path.join(clone, 'script.js');
|
|
217
349
|
if (!(await fs.stat(scriptFile).then(() => true, () => false))) {
|
|
218
350
|
await fs.writeFile(scriptFile, `// ${meta.url} - clone behaviour (your own code). Markup and styles come from the mirror${exact ? '' : ' (placeholder content)'}.\n// Rebuild every interaction and reveal from the reference's parameters; verify with npx trueclone score ${name}.\n`);
|
|
219
351
|
}
|
|
220
352
|
console.log(`seeded ${path.relative(process.cwd(), clone)} from the mirror (${exact ? 'exact: the site\'s own content' : 'template: placeholder text and media'}): index.html, ${(await fs.readdir(path.join(clone, 'css'))).length} stylesheet(s), ${copied.size} asset(s)${exact ? '' : ' (fonts only)'}`);
|
|
221
|
-
if (recorded.length || classChanges.length) console.log(`replayed by trueclone-reveals.js: ${marked}/${recorded.length} scroll reveals, ${classMarked}/${classChanges.length} scroll class changes${marked < recorded.length || classMarked < classChanges.length ? ' (the rest did not resolve to an element in the mirror)' : ''}`);
|
|
353
|
+
if (recorded.length || classChanges.length || scrubbed.length) console.log(`replayed by trueclone-reveals.js: ${marked}/${recorded.length} scroll reveals, ${scrubMarked}/${scrubbed.length} scroll-linked curves, ${classMarked}/${classChanges.length} scroll class changes (${Object.keys(recs).join(', ') || 'no'} widths recorded)${marked < recorded.length || classMarked < classChanges.length || scrubMarked < scrubbed.length ? ' (the rest did not resolve to an element in the mirror)' : ''}`);
|
|
354
|
+
if (runtime) {
|
|
355
|
+
const pins = runtime.pins.map((p) => `${p.el} pinned for ${p.distance}px`).join(', ');
|
|
356
|
+
console.log(`runtime artefacts cleaned: ${runtime.pins.length} GSAP pin(s) made sticky${pins ? ` (${pins}; data-tc-pin on each)` : ''}, ${runtime.canvases} canvas(es) marked data-tc-runtime="canvas"`);
|
|
357
|
+
if (runtime.widthStyled) console.log(`per-width inline styles: ${runtime.widthStyled} element(s) sized or placed differently by the site's scripts at tablet/mobile, now media-query rules (data-tc-w)`);
|
|
358
|
+
if (runtime.hidden) console.log(`boot contract: ${runtime.hidden} element(s) hidden until the clone's scripts have run (data-tc-boot-hide)`);
|
|
359
|
+
}
|
|
222
360
|
if (widgetGroups.length) console.log(`replayed by trueclone-widgets.js: ${widgets?.groups.length || 0}/${widgetGroups.length} widgets (${widgets?.states || 0} recorded states)${widgetRec?.portals?.length ? `; not replayed (portal popovers): ${widgetRec.portals.join(', ')}` : ''}`);
|
|
361
|
+
if (patches.length) console.log(`patches applied from clone/patches/: ${patches.join(', ')}`);
|
|
223
362
|
console.log(`next: write clone/script.js (behaviour), then npx trueclone score ${name}`);
|
|
224
363
|
|
|
225
364
|
// ---------- One browser pass over the seeded page: placeholders (template mode) and reveal marks ----------
|
|
226
|
-
async function browserPass(template, reveals, classes, groups = []) {
|
|
365
|
+
async function browserPass(template, reveals, classes, groups = [], scrubs = []) {
|
|
227
366
|
// served from sites/<name>/ so the pass can reach reference/assets (see `forPass`)
|
|
228
367
|
const server = await serve(root);
|
|
229
368
|
const browser = await openBrowser(flags.cdp);
|
|
@@ -231,7 +370,7 @@ async function browserPass(template, reveals, classes, groups = []) {
|
|
|
231
370
|
const { page } = await newPage(browser, VIEWPORTS.find((v) => v.name === 'desktop'), {});
|
|
232
371
|
await page.goto(`${server.url}clone/index.html`, { waitUntil: 'load', timeout: 60000 });
|
|
233
372
|
await page.evaluate(() => document.fonts.ready);
|
|
234
|
-
const counts = reveals.length || classes.length ? await page.evaluate(markRevealsInPage, [reveals, classes]) : [0, 0];
|
|
373
|
+
const counts = reveals.length || classes.length || scrubs.length ? await page.evaluate(markRevealsInPage, [reveals, classes, scrubs]) : [0, 0, 0];
|
|
235
374
|
// widgets: mark regions and triggers, then give every recorded state the same placeholders as the page by
|
|
236
375
|
// placing it into its region (so it is laid out and measured for real) and reading it back
|
|
237
376
|
let widgets = null;
|
|
@@ -259,8 +398,24 @@ async function browserPass(template, reveals, classes, groups = []) {
|
|
|
259
398
|
widgets.groups = Array.from(widgets.groups, (x) => x || null);
|
|
260
399
|
}
|
|
261
400
|
if (template) await page.evaluate(placeholdersInPage, [LOREM, null]);
|
|
262
|
-
|
|
263
|
-
|
|
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, '&').replace(/"/g, '"')}"`).join('');
|
|
416
|
+
return `<html${attrs}>${inner}</html>`;
|
|
417
|
+
});
|
|
418
|
+
return { html: `<!DOCTYPE html>\n${out}`, marked: counts[0], classMarked: counts[1], scrubMarked: counts[2], widgets, runtime };
|
|
264
419
|
} finally {
|
|
265
420
|
if (!flags.cdp) await browser.close();
|
|
266
421
|
server.close();
|
|
@@ -332,7 +487,7 @@ function widgetPlayer() {
|
|
|
332
487
|
});
|
|
333
488
|
}
|
|
334
489
|
|
|
335
|
-
function markRevealsInPage([reveals, classes]) {
|
|
490
|
+
function markRevealsInPage([reveals, classes, scrubs = []]) {
|
|
336
491
|
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
337
492
|
const resolve = (p) => {
|
|
338
493
|
let el = document.body;
|
|
@@ -344,16 +499,25 @@ function markRevealsInPage([reveals, classes]) {
|
|
|
344
499
|
}
|
|
345
500
|
return el;
|
|
346
501
|
};
|
|
502
|
+
// each reveal's starting state is a rule on its mark (no flash before the player runs; the player then applies the
|
|
503
|
+
// visitor's width); script.js hands an element over by removing the mark, which releases the state with it
|
|
504
|
+
const rules = [];
|
|
347
505
|
let count = 0;
|
|
348
506
|
reveals.forEach((r, i) => {
|
|
349
507
|
const el = resolve(r.path);
|
|
350
508
|
if (!el) return;
|
|
509
|
+
const rec = r.desktop || r.tablet || r.mobile || r;
|
|
351
510
|
el.setAttribute('data-tc-reveal', String(i));
|
|
352
|
-
|
|
353
|
-
el.style.transform = `translate(${r.from.tx}px, ${r.from.ty}px) scale(${r.from.s})`;
|
|
354
|
-
if (r.from.blur) el.style.filter = `blur(${r.from.blur}px)`;
|
|
511
|
+
rules.push(`[data-tc-reveal="${i}"]{opacity:${rec.from.o};transform:translate(${rec.from.tx}px, ${rec.from.ty}px) scale(${rec.from.s})${rec.from.blur ? `;filter:blur(${rec.from.blur}px)` : ''}}`);
|
|
355
512
|
count++;
|
|
356
513
|
});
|
|
514
|
+
if (rules.length) {
|
|
515
|
+
document.getElementById('tc-reveal-styles')?.remove();
|
|
516
|
+
const style = document.createElement('style');
|
|
517
|
+
style.id = 'tc-reveal-styles';
|
|
518
|
+
style.textContent = `/* trueclone seed: starting states of recorded scroll reveals (desktop); trueclone-reveals.js plays them */\n${rules.join('\n')}\n`;
|
|
519
|
+
document.head.appendChild(style);
|
|
520
|
+
}
|
|
357
521
|
let classCount = 0;
|
|
358
522
|
classes.forEach((c, i) => {
|
|
359
523
|
const el = resolve(c.path);
|
|
@@ -362,7 +526,134 @@ function markRevealsInPage([reveals, classes]) {
|
|
|
362
526
|
if (c.from !== null && c.from !== undefined) el.setAttribute('class', c.from);
|
|
363
527
|
classCount++;
|
|
364
528
|
});
|
|
365
|
-
|
|
529
|
+
let scrubCount = 0;
|
|
530
|
+
scrubs.forEach((s, i) => {
|
|
531
|
+
const el = resolve(s.path);
|
|
532
|
+
if (!el) return;
|
|
533
|
+
el.setAttribute('data-tc-scrub', String(i));
|
|
534
|
+
scrubCount++;
|
|
535
|
+
});
|
|
536
|
+
return [count, classCount, scrubCount];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Inline styles the capture read at every width, by element path. A declaration with the same value at every width
|
|
540
|
+
// stays inline; one that differs (a height a script computed from the container, an offset, a custom property)
|
|
541
|
+
// moves into a media-query rule per width, so each width gets exactly what the site showed there. The rule's
|
|
542
|
+
// selector is stacked to the precedence an inline style had over the stylesheets, without !important, so a value
|
|
543
|
+
// script.js writes inline later still wins. Motion properties of elements the reveal player drives, and the pin
|
|
544
|
+
// geometry the seed removed, are left out.
|
|
545
|
+
function widthStylesInPage([styles, media]) {
|
|
546
|
+
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
547
|
+
const resolve = (p) => {
|
|
548
|
+
let el = document.body;
|
|
549
|
+
for (const part of p.split('>')) {
|
|
550
|
+
const [tag, n] = part.split(':');
|
|
551
|
+
const kids = [...el.children].filter((c) => !SKIP.has(c.tagName));
|
|
552
|
+
el = kids[Number(n) - 1];
|
|
553
|
+
if (!el || el.tagName.toLowerCase() !== tag) return null;
|
|
554
|
+
}
|
|
555
|
+
return el;
|
|
556
|
+
};
|
|
557
|
+
const decls = (text) => {
|
|
558
|
+
const map = new Map();
|
|
559
|
+
for (const d of String(text || '').split(/;(?![^(]*\))/)) {
|
|
560
|
+
const i = d.indexOf(':');
|
|
561
|
+
if (i < 0) continue;
|
|
562
|
+
const k = d.slice(0, i).trim(), v = d.slice(i + 1).trim().replace(/\s*!important$/i, '');
|
|
563
|
+
if (k) map.set(k, v);
|
|
564
|
+
}
|
|
565
|
+
return map;
|
|
566
|
+
};
|
|
567
|
+
const MOTION = new Set(['transform', 'translate', 'rotate', 'scale', 'opacity', 'filter']);
|
|
568
|
+
const PIN = new Set(['translate', 'rotate', 'scale', 'inset', 'top', 'left', 'right', 'bottom', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'max-width', 'width', 'max-height', 'height', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'box-sizing', 'position', 'transform', 'flex-basis']);
|
|
569
|
+
const widths = Object.keys(styles).filter((w) => media[w]);
|
|
570
|
+
const paths = new Set(widths.flatMap((w) => Object.keys(styles[w])));
|
|
571
|
+
const rules = [];
|
|
572
|
+
const sel = (i) => `[data-tc-w="${i}"]`.repeat(8); // (0,8,0): above any ordinary author rule, below inline
|
|
573
|
+
let count = 0;
|
|
574
|
+
for (const p of paths) {
|
|
575
|
+
const per = Object.fromEntries(widths.map((w) => [w, decls(styles[w][p])]));
|
|
576
|
+
const all = new Set(widths.flatMap((w) => [...per[w].keys()]));
|
|
577
|
+
const differs = [...all].filter((k) => widths.some((w) => per[w].get(k) !== per[widths[0]].get(k)));
|
|
578
|
+
if (!differs.length) continue;
|
|
579
|
+
const el = resolve(p);
|
|
580
|
+
if (!el) continue;
|
|
581
|
+
// a pin spacer keeps only its pin distance per width (the site computes it from the viewport height); GSAP writes
|
|
582
|
+
// it as the padding shorthand (padding: 0px 0px 5600px)
|
|
583
|
+
const spacer = el.hasAttribute('data-tc-pin-spacer');
|
|
584
|
+
if (spacer) {
|
|
585
|
+
const horizontal = /^padding-right|^padding:\s*0(px)?\s+[1-9]/.test(styles[widths[0]][p] || '');
|
|
586
|
+
const distance = (m) => { if (m.has(horizontal ? 'padding-right' : 'padding-bottom')) return parseFloat(m.get(horizontal ? 'padding-right' : 'padding-bottom')); const v = (m.get('padding') || '').trim().split(/\s+/); return parseFloat(horizontal ? v[1] ?? v[0] : v[2] ?? v[0]) || 0; };
|
|
587
|
+
const d = Object.fromEntries(widths.map((w) => [w, Math.round(distance(per[w]))]));
|
|
588
|
+
if (widths.every((w) => d[w] === d[widths[0]])) continue;
|
|
589
|
+
const i = count++;
|
|
590
|
+
el.setAttribute('data-tc-w', String(i));
|
|
591
|
+
for (const w of widths) {
|
|
592
|
+
el.firstElementChild?.setAttribute(`data-tc-pin-${w}`, String(d[w]));
|
|
593
|
+
rules.push(`@media ${media[w]} { ${sel(i)} { ${horizontal ? 'padding-right' : 'padding-bottom'}: ${d[w]}px } }`);
|
|
594
|
+
}
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const skip = (k) => (el.hasAttribute('data-tc-runtime') && (PIN.has(k) || k === 'width' || k === 'height')) || ((el.hasAttribute('data-tc-reveal') || el.hasAttribute('data-tc-scrub')) && MOTION.has(k));
|
|
598
|
+
const moved = differs.filter((k) => !skip(k));
|
|
599
|
+
if (!moved.length) continue;
|
|
600
|
+
const i = count++;
|
|
601
|
+
el.setAttribute('data-tc-w', String(i));
|
|
602
|
+
// the inline attribute keeps what is the same everywhere (as the desktop mirror had it)
|
|
603
|
+
const current = decls(el.getAttribute('style'));
|
|
604
|
+
for (const k of moved) current.delete(k);
|
|
605
|
+
const inline = [...current].map(([k, v]) => `${k}: ${v}`).join('; ');
|
|
606
|
+
if (inline) el.setAttribute('style', inline); else el.removeAttribute('style');
|
|
607
|
+
for (const w of widths) {
|
|
608
|
+
const body = moved.filter((k) => per[w].has(k)).map((k) => `${k}: ${per[w].get(k)}`).join('; ');
|
|
609
|
+
if (body) rules.push(`@media ${media[w]} { ${sel(i)} { ${body} } }`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (rules.length) {
|
|
613
|
+
const style = document.createElement('style');
|
|
614
|
+
style.id = 'tc-width-styles';
|
|
615
|
+
style.textContent = `/* trueclone seed: inline styles the site's scripts set per width (captured at desktop, tablet and mobile) */\n${rules.join('\n')}\n`;
|
|
616
|
+
document.head.appendChild(style);
|
|
617
|
+
}
|
|
618
|
+
return count;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// The mirror is a snapshot taken after the site's scripts ran, so it carries what they left in the DOM: GSAP's
|
|
622
|
+
// pin-spacer wrappers and the pin's inline geometry (fixed desktop pixels that break every other width), and canvas
|
|
623
|
+
// buffer sizes set for one viewport. A pin is a sticky element inside a spacer, which CSS does natively: the seed
|
|
624
|
+
// keeps the spacer as a plain block with the pin distance as padding (`data-tc-pin-spacer`), makes the element
|
|
625
|
+
// sticky, and records the distance on it (`data-tc-pin`), so layout and scroll states hold without a script. The
|
|
626
|
+
// clone's script may take the pin over (unwrap the spacer, drop the sticky style) and draws the marked canvases.
|
|
627
|
+
function cleanRuntimeInPage() {
|
|
628
|
+
const log = { pins: [], canvases: 0 };
|
|
629
|
+
const label = (el) => `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : ''}`;
|
|
630
|
+
// what ScrollTrigger writes on the pinned element while pinning (its own values live in the stylesheet)
|
|
631
|
+
const PIN_PROPS = ['translate', 'rotate', 'scale', 'inset', 'top', 'left', 'right', 'bottom', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'max-width', 'width', 'max-height', 'height', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'box-sizing', 'position', 'transform', 'flex-basis'];
|
|
632
|
+
// what the spacer copied from the element so the parent's flex/grid layout holds: kept; its geometry: dropped
|
|
633
|
+
const SPACER_KEEP = new Set(['order', 'place-self', 'grid-area', 'grid-column', 'grid-row', 'float', 'flex-shrink', 'flex-grow', 'flex-basis', 'display', 'align-self', 'justify-self']);
|
|
634
|
+
for (const spacer of document.querySelectorAll('.pin-spacer')) {
|
|
635
|
+
const pinned = spacer.firstElementChild;
|
|
636
|
+
const horizontal = !spacer.style.paddingBottom && !!spacer.style.paddingRight;
|
|
637
|
+
const distance = Math.round(parseFloat(horizontal ? spacer.style.paddingRight : spacer.style.paddingBottom) || 0);
|
|
638
|
+
if (!pinned) { spacer.replaceWith(...spacer.childNodes); continue; }
|
|
639
|
+
for (const p of PIN_PROPS) pinned.style.removeProperty(p);
|
|
640
|
+
pinned.style.position = 'sticky';
|
|
641
|
+
pinned.style.top = '0px';
|
|
642
|
+
pinned.setAttribute('data-tc-runtime', 'pin');
|
|
643
|
+
pinned.setAttribute('data-tc-pin', String(distance));
|
|
644
|
+
const keep = [...spacer.style].filter((p) => SPACER_KEEP.has(p)).map((p) => `${p}: ${spacer.style.getPropertyValue(p)}`);
|
|
645
|
+
spacer.removeAttribute('class');
|
|
646
|
+
spacer.setAttribute('style', `${keep.join('; ')}${keep.length ? '; ' : ''}${horizontal ? 'padding-right' : 'padding-bottom'}: ${distance}px`);
|
|
647
|
+
spacer.setAttribute('data-tc-pin-spacer', String(distance));
|
|
648
|
+
log.pins.push({ el: label(pinned), distance });
|
|
649
|
+
}
|
|
650
|
+
// a canvas keeps its buffer size: with only a CSS width, the height follows the buffer's aspect ratio, and the
|
|
651
|
+
// clone's own scene sets the buffer it needs; the mark says a script draws here
|
|
652
|
+
for (const c of document.querySelectorAll('canvas')) {
|
|
653
|
+
c.setAttribute('data-tc-runtime', 'canvas');
|
|
654
|
+
log.canvases++;
|
|
655
|
+
}
|
|
656
|
+
return log;
|
|
366
657
|
}
|
|
367
658
|
|
|
368
659
|
|