@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/README.md
CHANGED
|
@@ -61,17 +61,17 @@ The hand-built portfolio isn't perfect yet, and its report says so: computed sty
|
|
|
61
61
|
|
|
62
62
|
```
|
|
63
63
|
URL ─► capture ─► understand ─► design system ─► build ─► score ─► report
|
|
64
|
-
3 widths motion DESIGN.md your
|
|
64
|
+
3 widths motion DESIGN.md your 8 checks REPORT.md
|
|
65
65
|
HAR, HTML inventory globals.css agent in parallel
|
|
66
66
|
assets + REA tokens.css ▲ │
|
|
67
67
|
└─────┘ until every target passes
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
1. **Capture** (`trueclone capture`): full-page screenshots at 390, 768 and 1440px, sections, rendered HTML, assets, every network request.
|
|
71
|
-
2. **Understand** (`trueclone motion`,
|
|
71
|
+
2. **Understand** (`trueclone motion`, `trueclone understand`): reads the live animation runtime (GSAP timelines, ScrollTriggers, Lenis and Swiper settings, CSS animations, cursor-driven tweens) and, with REA, which scripts actually run while a visitor scrolls, which handler each control has and the code around it, the endpoints in the code, source maps when published: `code.md`, so the agent copies rules instead of guessing them from effects.
|
|
72
72
|
3. **Design system** (`trueclone design`): one canonical `design-tokens.json`, rendered to DESIGN.md, globals.css and the clone's tokens.
|
|
73
|
-
4. **Build**: `trueclone reveals` records the original's scroll reveals frame by frame and its scroll-driven class changes; `trueclone widgets` clicks every tab and menu and records each state; `trueclone seed` turns the script-free mirror into the clone (template mode: layout-measured lorem text and same-size media placeholders) and replays what was recorded. Layout is at 99%+ before anyone writes CSS; your agent writes the remaining behaviour, guided by the skill. `trueclone rules` shows the original's CSS for any element (like DevTools).
|
|
74
|
-
5. **Score** (`trueclone score`): layout pixel diff per section, scroll states replayed, motion curves sampled, interaction scenarios with change assertions, every hover, computed styles at matching points. Reference measurements are cached, so only the clone is re-measured.
|
|
73
|
+
4. **Build**: `trueclone reveals` records, at every width, the original's scroll reveals frame by frame, its scroll-linked curves (parallax) and its scroll-driven class changes; `trueclone widgets` clicks every tab and menu and records each state; `trueclone seed` turns the script-free mirror into the clone (template mode: layout-measured lorem text and same-size media placeholders), cleans what the original's scripts left in the DOM (pin-spacers, canvas buffer sizes) and replays what was recorded. Layout is at 99%+ before anyone writes CSS; your agent writes the remaining behaviour, guided by the skill. `trueclone rules` shows the original's CSS for any element (like DevTools).
|
|
74
|
+
5. **Score** (`trueclone score`): layout pixel diff per section, scroll states replayed, motion curves sampled, interaction scenarios with change assertions, every hover, computed styles at matching points, and the load sequence (how settled each side is at 0.25 to 4 s after navigation, cold cache, same emulated network). Reference measurements are cached, so only the clone is re-measured.
|
|
75
75
|
6. **Report**: `REPORT.md` with the verdict, what to fix first, and what was not copied.
|
|
76
76
|
|
|
77
77
|
## Why not just REA?
|
|
@@ -86,7 +86,7 @@ trueclone **bundles** [REA](https://github.com/morluto/rea) (Reverse Engineer An
|
|
|
86
86
|
| Tells you what's wrong | No | "Footer column 2 is at x 189, the original's at 732", plus the original's CSS rule |
|
|
87
87
|
| Outputs | Whatever the agent writes | HTML clone, Next.js app, DESIGN.md, globals.css, motion recipe, REPORT.md |
|
|
88
88
|
|
|
89
|
-
REA answers *how does this work?* trueclone answers *is my copy identical, and where isn't it?* For the hard parts (a bundled physics rule, a shader,
|
|
89
|
+
REA answers *how does this work?* trueclone answers *is my copy identical, and where isn't it?* trueclone proves the visual and behavioural match on its own; `trueclone understand` adds REA's code-level evidence (which scripts ran, the handler behind each control with its source, endpoints, original sources when published) so the agent copies rules instead of inferring them. For the hard parts (a bundled physics rule, a shader, a module's data flow) REA's own CLI is one command away.
|
|
90
90
|
|
|
91
91
|
## Modes and guardrails
|
|
92
92
|
|
|
@@ -107,13 +107,16 @@ npx trueclone doctor # check Node, Chrome and REA
|
|
|
107
107
|
npx trueclone <url> [name] # capture + design-system draft
|
|
108
108
|
npx trueclone capture <url> [name] [--mode exact --permission "<owner>"]
|
|
109
109
|
npx trueclone motion <name> # motion inventory
|
|
110
|
+
npx trueclone understand <name> # code evidence with REA: which scripts ran, each control's handler with its source -> code.md
|
|
110
111
|
npx trueclone design <name> # DESIGN.md + globals.css
|
|
111
112
|
npx trueclone reveals <name> # record scroll reveals and scroll class changes
|
|
112
113
|
npx trueclone widgets <name> # record every tab and menu state
|
|
113
114
|
npx trueclone seed <name> # clone/ from the mirror (template: placeholder text and media)
|
|
114
115
|
npx trueclone rules <name> "<selector>" # the original's CSS rules for an element
|
|
115
116
|
npx trueclone textshape <name> --write # template mode: placeholder text with the original's line breaks
|
|
116
|
-
npx trueclone
|
|
117
|
+
npx trueclone load <name> # load sequence on both sides: settledness 0.25-4 s after navigation, cold cache, same network
|
|
118
|
+
npx trueclone score <name> [--quick] # every check, then REPORT.md (--only detail,load --viewport tablet after a fix; unchanged checks are kept)
|
|
119
|
+
npx trueclone explain <name> <y> # one place on the page: what every check saw there, plus a reference|clone|diff crop
|
|
117
120
|
npx trueclone next <name> --verify # Next.js + Tailwind + shadcn app, scored
|
|
118
121
|
npx trueclone rea <args> # bundled REA
|
|
119
122
|
npx trueclone help # every step
|
package/bin/trueclone.mjs
CHANGED
|
@@ -23,6 +23,7 @@ const STEPS = {
|
|
|
23
23
|
seed: ['seed.mjs', 'exact mode: start clone/ from the script-free mirror (markup, text, styles, media)'],
|
|
24
24
|
design: ['design.mjs', 'design-tokens.json -> DESIGN.md (linted), shadcn globals.css, clone/tokens.css'],
|
|
25
25
|
motion: ['motion.mjs', 'motion inventory from the live runtime (GSAP/ScrollTrigger, Lenis, Swiper, CSS animations)'],
|
|
26
|
+
understand: ['understand.mjs', 'code evidence with REA: which scripts ran, each element\'s handler with its source, endpoints, source maps -> reference/code.md'],
|
|
26
27
|
reveals: ['reveals.mjs', 'record one-shot scroll reveals frame by frame, so seed can replay them (any library)'],
|
|
27
28
|
widgets: ['widgets.mjs', 'record every tab and menu state by clicking it, so seed can replay them (template mode)'],
|
|
28
29
|
states: ['states.mjs', 'live scroll states; --clone scores the rebuild step by step'],
|
|
@@ -30,7 +31,11 @@ const STEPS = {
|
|
|
30
31
|
interact: ['interact.mjs', 'interaction tests: click/hover/drag/key scenarios on both sides (interactions.json)'],
|
|
31
32
|
detail: ['detail.mjs', 'style diff at matching points: font, size, weight, line height, colour, background, radius'],
|
|
32
33
|
hovers: ['hovers.mjs', 'hover every interactive element on both sides and compare what changes'],
|
|
33
|
-
|
|
34
|
+
load: ['load.mjs', 'load sequence: how settled each side is 0.25/0.5/1/2/4 s after navigation, cold cache, same network'],
|
|
35
|
+
drift: ['drift.mjs', 'has the live page changed since the capture? (page height and sections per width vs meta.json)'],
|
|
36
|
+
owned: ['owned.mjs', 'which sections of the clone script.js rebuilds at boot (load with scripts blocked vs running)'],
|
|
37
|
+
rules: ['rules.mjs', 'every CSS rule matching an element on the reference (+ ::before/::after, @media); --clone for the clone'],
|
|
38
|
+
explain: ['explain.mjs', 'one place on the page: what layout, states, detail and hovers saw there, plus a reference|clone|diff crop'],
|
|
34
39
|
textshape: ['textshape.mjs', 'template mode: placeholder words with the reference word widths, so lines break the same'],
|
|
35
40
|
diff: ['diff.mjs', 'full-page pixel diff per viewport and section (--structure masks text and media)'],
|
|
36
41
|
spec: ['spec.mjs', 'measured boxes and styles of the live page (--clone for the rebuild)'],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trueclone/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A working twin of any website from its URL: HTML clone, Next.js + Tailwind + shadcn app, DESIGN.md and globals.css, proven by layout, motion, interaction and style scores. Bundles REA.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/scripts/capture.mjs
CHANGED
|
@@ -92,6 +92,7 @@ const browser = await openBrowser(flags.cdp);
|
|
|
92
92
|
const manifest = [];
|
|
93
93
|
const summary = [];
|
|
94
94
|
const rootFontSize = {};
|
|
95
|
+
const inlineStyles = {};
|
|
95
96
|
try {
|
|
96
97
|
for (const vp of VIEWPORTS) {
|
|
97
98
|
const desktop = vp.name === 'desktop';
|
|
@@ -150,6 +151,26 @@ try {
|
|
|
150
151
|
await fs.writeFile(path.join(ref, `${vp.name}.png`), shot);
|
|
151
152
|
const sections = await sectionsOf(page);
|
|
152
153
|
await fs.writeFile(path.join(ref, `${vp.name}.sections.json`), JSON.stringify(sections, null, 2));
|
|
154
|
+
// inline styles at this width, by element path: scripts size and place things per width (a grid's computed
|
|
155
|
+
// height, a card's offset), and the rendered HTML only keeps the desktop values. The seed turns what differs
|
|
156
|
+
// between widths into media-query rules.
|
|
157
|
+
inlineStyles[vp.name] = await page.evaluate(() => {
|
|
158
|
+
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
159
|
+
const pathOf = (el) => {
|
|
160
|
+
const parts = [];
|
|
161
|
+
for (let n = el; n && n !== document.body; n = n.parentElement) {
|
|
162
|
+
const sibs = [...n.parentElement.children].filter((c) => !SKIP.has(c.tagName));
|
|
163
|
+
parts.unshift(`${n.tagName.toLowerCase()}:${sibs.indexOf(n) + 1}`);
|
|
164
|
+
}
|
|
165
|
+
return parts.join('>');
|
|
166
|
+
};
|
|
167
|
+
const out = {};
|
|
168
|
+
for (const el of document.body.querySelectorAll('[style]')) {
|
|
169
|
+
if (SKIP.has(el.tagName) || (el.closest('svg') && el.tagName !== 'svg')) continue;
|
|
170
|
+
out[pathOf(el)] = el.getAttribute('style');
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
});
|
|
153
174
|
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
154
175
|
rootFontSize[vp.name] = await page.evaluate(() => getComputedStyle(document.documentElement).fontSize);
|
|
155
176
|
if (desktop) {
|
|
@@ -162,6 +183,23 @@ try {
|
|
|
162
183
|
el.setAttribute('data-tc-rich', String(i));
|
|
163
184
|
restore[i++] = best.html;
|
|
164
185
|
}
|
|
186
|
+
// open shadow roots (web components, a Webflow code island) are not part of outerHTML: each becomes a
|
|
187
|
+
// declarative <template shadowrootmode="open"> inside its host, which the browser turns back into a shadow
|
|
188
|
+
// root while parsing, no script needed. Deepest first, so a host inside a shadow root is carried too.
|
|
189
|
+
// Constructed stylesheets (adoptedStyleSheets) are written out as a <style> at the top of the template.
|
|
190
|
+
const hosts = [];
|
|
191
|
+
(function find(scope) { for (const el of scope.querySelectorAll('*')) if (el.shadowRoot) { find(el.shadowRoot); hosts.push(el); } })(document);
|
|
192
|
+
for (const host of hosts) {
|
|
193
|
+
const t = document.createElement('template');
|
|
194
|
+
t.setAttribute('shadowrootmode', 'open');
|
|
195
|
+
t.setAttribute('shadowrootserializable', '');
|
|
196
|
+
const adopted = (host.shadowRoot.adoptedStyleSheets || []).map((s) => { try { return [...s.cssRules].map((r) => r.cssText).join('\n'); } catch { return ''; } }).filter(Boolean);
|
|
197
|
+
if (adopted.length) { const st = document.createElement('style'); st.textContent = adopted.join('\n'); t.content.appendChild(st); }
|
|
198
|
+
for (const n of host.shadowRoot.childNodes) t.content.appendChild(n.cloneNode(true));
|
|
199
|
+
host.prepend(t);
|
|
200
|
+
host.setAttribute('data-tc-shadow', '');
|
|
201
|
+
}
|
|
202
|
+
if (hosts.length) console.log(`tc-shadow: serialised ${hosts.length} open shadow root(s) as declarative shadow DOM`);
|
|
165
203
|
const doc = document.documentElement.cloneNode(true);
|
|
166
204
|
for (const el of document.querySelectorAll('[data-tc-rich]')) el.removeAttribute('data-tc-rich');
|
|
167
205
|
for (const el of doc.querySelectorAll('[data-tc-rich]')) {
|
|
@@ -188,6 +226,7 @@ try {
|
|
|
188
226
|
}
|
|
189
227
|
|
|
190
228
|
await fs.writeFile(path.join(ref, 'assets', 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
229
|
+
await fs.writeFile(path.join(ref, 'inline-styles.json'), JSON.stringify(inlineStyles));
|
|
191
230
|
await fs.writeFile(path.join(ref, 'meta.json'), JSON.stringify({ url, name, mode, permission, hide, capturedAt: new Date().toISOString(), rootFontSize, viewports: summary }, null, 2));
|
|
192
231
|
await buildMirror(manifest, url, mode === 'exact' ? `Exact mode, authorised by: ${permission}.` : "Template mode: the site's own content, kept as a reference only.");
|
|
193
232
|
console.table(summary);
|
|
@@ -202,6 +241,24 @@ async function buildMirror(manifest, url, note) {
|
|
|
202
241
|
const mirror = path.join(ref, 'mirror');
|
|
203
242
|
await fs.rm(mirror, { recursive: true, force: true });
|
|
204
243
|
await fs.mkdir(path.join(mirror, 'css'), { recursive: true });
|
|
244
|
+
// Rendered twice: the first pass shows which media the capture never saw (lazy images below the fold, hover and
|
|
245
|
+
// state images, sources behind a media query, fonts behind @import); fetchMissing() saves them and the second pass
|
|
246
|
+
// points at the local copies. A page is only as complete as its slowest-loading image.
|
|
247
|
+
let out = await renderMirror(manifest, url, note, mirror);
|
|
248
|
+
const missing = findRemoteMedia(out);
|
|
249
|
+
if (missing.length) {
|
|
250
|
+
const added = await fetchMissing(missing, manifest, url);
|
|
251
|
+
if (added) out = await renderMirror(manifest, url, note, mirror);
|
|
252
|
+
await fs.writeFile(path.join(ref, 'assets', 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
253
|
+
console.log(`mirror: fetched ${added}/${missing.length} media the capture had not seen (lazy, hover or conditional sources)`);
|
|
254
|
+
}
|
|
255
|
+
for (const [file, css] of out.css) await fs.writeFile(path.join(mirror, 'css', file), css);
|
|
256
|
+
await fs.writeFile(path.join(mirror, 'index.html'), out.html);
|
|
257
|
+
const left = findRemoteMedia(out);
|
|
258
|
+
console.log(`mirror: reference/mirror/index.html (${manifest.length} local assets${left.length ? `, ${left.length} remote media left: ${left.slice(0, 3).join(', ')}` : ''})`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function renderMirror(manifest, url, note, mirror) {
|
|
205
262
|
const byUrl = new Map(manifest.map((m) => [m.url.split('#')[0], m]));
|
|
206
263
|
const abs = (u, base) => { try { return new URL(u.trim().replace(/^['"]|['"]$/g, '').replaceAll('&', '&'), base).href.split('#')[0]; } catch { return null; } };
|
|
207
264
|
const cssName = (m) => path.basename(m.file);
|
|
@@ -218,9 +275,10 @@ async function buildMirror(manifest, url, note) {
|
|
|
218
275
|
.replace(/@import\s+(['"])([^'"]+)\1/g, (all, q, u) => `@import "${local(abs(u, base), fromDir) || u}"`);
|
|
219
276
|
|
|
220
277
|
// stylesheets: rewritten copies under mirror/css
|
|
278
|
+
const css = new Map();
|
|
221
279
|
for (const m of manifest.filter((x) => x.type === 'stylesheet')) {
|
|
222
|
-
const
|
|
223
|
-
|
|
280
|
+
const text = await fs.readFile(path.join(ref, m.file), 'utf8').catch(() => null);
|
|
281
|
+
if (text !== null) css.set(cssName(m), rewriteCssUrls(text, m.url, path.join(mirror, 'css')));
|
|
224
282
|
}
|
|
225
283
|
|
|
226
284
|
let html = await fs.readFile(path.join(ref, 'rendered.html'), 'utf8');
|
|
@@ -235,14 +293,74 @@ async function buildMirror(manifest, url, note) {
|
|
|
235
293
|
const l = local(abs(u, url), mirror);
|
|
236
294
|
return l ? ` ${attr}=${q}${l}${q}` : all;
|
|
237
295
|
})
|
|
296
|
+
// a subresource-integrity hash belongs to the bytes at the original URL; after the rewrite it only makes the
|
|
297
|
+
// browser refuse the local copy (and the whole stylesheet with it)
|
|
298
|
+
.replace(/<link\b[^>]*>/gi, (tag) => tag.replace(/\s(?:integrity|crossorigin)(?:=("[^"]*"|'[^']*'|[^\s>]+))?/gi, ''))
|
|
238
299
|
.replace(/\s(srcset|data-srcset)=("|')([^"']+)\2/gi, (all, attr, q, list) => ` ${attr}=${q}${list.split(',').map((part) => {
|
|
239
300
|
const [u, ...rest] = part.trim().split(/\s+/);
|
|
240
301
|
return [local(abs(u, url), mirror) || u, ...rest].join(' ');
|
|
241
302
|
}).join(', ')}${q}`)
|
|
242
303
|
.replace(/<style\b([^>]*)>([\s\S]*?)<\/style>/gi, (all, attrs, css) => `<style${attrs}>${rewriteCssUrls(css, url, mirror)}</style>`)
|
|
243
|
-
|
|
304
|
+
// quotes inside a style attribute are HTML-escaped (url("…")): unescape before rewriting, re-escape after
|
|
305
|
+
.replace(/\sstyle=(?:"([^"]*)"|'([^']*)')/gi, (all, d, s) => {
|
|
306
|
+
const value = d ?? s;
|
|
307
|
+
if (!/url\(/i.test(value)) return all;
|
|
308
|
+
const plain = value.replaceAll('"', '"').replaceAll(''', "'");
|
|
309
|
+
return ` style="${rewriteCssUrls(plain, url, mirror).replaceAll('"', '"')}"`;
|
|
310
|
+
})
|
|
244
311
|
.replace(/<head([^>]*)>/i, `<head$1>\n<!-- trueclone local mirror of ${url}. ${note.replace(/--/g, '-')} Local reference only: do not deploy. -->\n<meta name="robots" content="noindex,nofollow">`);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
312
|
+
return { html, css };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Media the page still points at on the network after the rewrite: image/video/font/stylesheet URLs in src, srcset,
|
|
316
|
+
// poster, data-src, <link> hrefs and CSS url(). Links to other pages (<a href>) are not media.
|
|
317
|
+
function findRemoteMedia({ html, css }) {
|
|
318
|
+
const urls = new Set();
|
|
319
|
+
const media = (u) => u && /^https?:\/\//.test(u) && !/^https?:\/\/[^/]+\/?$/.test(u) && !/\.(js|mjs|json|html?)(\?|$)/i.test(u);
|
|
320
|
+
const add = (u) => { if (media(u)) urls.add(u.replaceAll('&', '&')); };
|
|
321
|
+
for (const m of html.matchAll(/\s(?:src|poster|data-src)=["']([^"']+)["']/gi)) add(m[1]);
|
|
322
|
+
for (const m of html.matchAll(/\s(?:srcset|data-srcset)=["']([^"']+)["']/gi)) for (const part of m[1].split(',')) add(part.trim().split(/\s+/)[0]);
|
|
323
|
+
for (const m of html.matchAll(/<link\b[^>]*>/gi)) {
|
|
324
|
+
const rel = /\srel=["']?([^"'\s>]+)/i.exec(m[0])?.[1] || '';
|
|
325
|
+
const href = /\shref=["']([^"']+)["']/i.exec(m[0])?.[1];
|
|
326
|
+
if (/stylesheet|icon|apple-touch-icon|preload|prefetch/i.test(rel) && !/\sas=["']?(script|document|fetch)/i.test(m[0])) add(href);
|
|
327
|
+
}
|
|
328
|
+
const cssUrls = (text) => { for (const m of text.matchAll(/url\(\s*(?:"|["'])?(https?:\/\/[^)"'&\s]+)/gi)) add(m[1]); };
|
|
329
|
+
cssUrls(html);
|
|
330
|
+
for (const text of css.values()) cssUrls(text);
|
|
331
|
+
return [...urls].slice(0, 300);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function fetchMissing(urls, manifest, pageUrl) {
|
|
335
|
+
const typeOf = (u, contentType = '') => {
|
|
336
|
+
if (/text\/css/.test(contentType) || /\.css(\?|$)/i.test(u)) return 'stylesheet';
|
|
337
|
+
if (/^font\/|application\/(x-)?font|application\/vnd\.ms-fontobject/.test(contentType) || /\.(woff2?|ttf|otf|eot)(\?|$)/i.test(u)) return 'font';
|
|
338
|
+
if (/^image\//.test(contentType) || /\.(png|jpe?g|gif|webp|avif|svg|ico|bmp)(\?|$)/i.test(u)) return 'image';
|
|
339
|
+
return 'media';
|
|
340
|
+
};
|
|
341
|
+
let added = 0;
|
|
342
|
+
const queue = [...urls];
|
|
343
|
+
const worker = async () => {
|
|
344
|
+
for (let u = queue.shift(); u; u = queue.shift()) {
|
|
345
|
+
try {
|
|
346
|
+
const res = await fetch(u, { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', referer: pageUrl, accept: '*/*' }, signal: AbortSignal.timeout(20000), redirect: 'follow' });
|
|
347
|
+
if (!res.ok) continue;
|
|
348
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
349
|
+
if (!body.length || body.length > MAX_ASSET_BYTES) continue;
|
|
350
|
+
const contentType = res.headers.get('content-type') || '';
|
|
351
|
+
if (/text\/html/.test(contentType)) continue; // a page, not media (a soft 404)
|
|
352
|
+
const type = typeOf(u, contentType);
|
|
353
|
+
const parsed = new URL(u);
|
|
354
|
+
const base = (path.basename(parsed.pathname) || 'index').replace(/[^a-z0-9._-]/gi, '_').slice(-80);
|
|
355
|
+
const hash = crypto.createHash('sha1').update(u).digest('hex').slice(0, 8);
|
|
356
|
+
const file = path.join('assets', type, `${hash}-${base}`);
|
|
357
|
+
await fs.mkdir(path.join(ref, 'assets', type), { recursive: true });
|
|
358
|
+
await fs.writeFile(path.join(ref, file), body);
|
|
359
|
+
manifest.push({ url: u, type, file: file.replaceAll('\\', '/'), contentType, bytes: body.length, fetched: 'mirror' });
|
|
360
|
+
added++;
|
|
361
|
+
} catch { /* offline, blocked, timed out: stays remote and is listed */ }
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
await Promise.all(Array.from({ length: 6 }, worker));
|
|
365
|
+
return added;
|
|
248
366
|
}
|
package/scripts/detail.mjs
CHANGED
|
@@ -155,7 +155,7 @@ async function sample(browser, url, vp, probes) {
|
|
|
155
155
|
if (y >= max) break;
|
|
156
156
|
}
|
|
157
157
|
await context.close();
|
|
158
|
-
return { list, results };
|
|
158
|
+
return { list, results, pageHeight: max + vp.height };
|
|
159
159
|
}
|
|
160
160
|
const list = probes;
|
|
161
161
|
const results = new Array(list.length);
|
|
@@ -177,14 +177,27 @@ const report = { name, at: new Date().toISOString(), viewports: [] };
|
|
|
177
177
|
try {
|
|
178
178
|
for (const vpName of wanted) {
|
|
179
179
|
const vp = VIEWPORTS.find((v) => v.name === vpName);
|
|
180
|
-
// reference probes are cached until the next capture (--refresh-ref to re-measure)
|
|
181
|
-
|
|
180
|
+
// reference probes are cached until the next capture (--refresh-ref to re-measure). The reference is read twice:
|
|
181
|
+
// a probe that does not agree with itself (random motion, a hover that lands by timing, a rotating word) can
|
|
182
|
+
// never be matched by any clone and is set aside as unstable instead of being handed over as a gap.
|
|
183
|
+
const ref = await cached(root, { tool: 'detail', vp: vpName, max: MAX, v: 9 }, async () => {
|
|
184
|
+
const first = await sample(browser, meta.url, vp, null);
|
|
185
|
+
// a live site changes: a reference read against today's page scores a clone of yesterday's capture against the
|
|
186
|
+
// wrong thing. The page height is a cheap tell; the fix is a new capture, not a new clone.
|
|
187
|
+
const captured = meta.viewports?.find((v) => v.viewport === vpName)?.pageHeight;
|
|
188
|
+
if (captured && first.pageHeight && Math.abs(first.pageHeight - captured) > Math.max(40, captured * 0.01)) console.warn(`WARNING reference drift (${vpName}): the live page is ${first.pageHeight}px tall, the capture was ${captured}px (${meta.capturedAt}); re-capture before trusting this score`);
|
|
189
|
+
const again = await sample(browser, meta.url, vp, first.list.map(({ x, xs, y, kind, w, h, scroll }) => ({ x, xs, y, kind, w, h, scroll })));
|
|
190
|
+
const unstable = first.list.map((p, i) => JSON.stringify(first.results[i]?.style) !== JSON.stringify(again.results[i]?.style) || first.results[i]?.sel !== again.results[i]?.sel);
|
|
191
|
+
return { ...first, unstable };
|
|
192
|
+
}, Boolean(flags['refresh-ref']));
|
|
182
193
|
const cl = await sample(browser, cloneUrl, vp, ref.list.map(({ x, xs, y, kind, w, h, scroll }) => ({ x, xs, y, kind, w, h, scroll })));
|
|
183
194
|
const issues = [];
|
|
195
|
+
const unstableList = [];
|
|
184
196
|
let clean = 0, missing = 0;
|
|
185
197
|
ref.list.forEach((p, i) => {
|
|
186
198
|
const a = ref.results[i], b = cl.results[i];
|
|
187
199
|
if (!a?.style) return;
|
|
200
|
+
if (ref.unstable?.[i]) { unstableList.push({ y: p.y, kind: p.kind, ref: a.sel }); return; }
|
|
188
201
|
if (!b?.style) { missing++; issues.push({ y: p.y, kind: p.kind, ref: a.sel, clone: b?.top || '(nothing)', props: [`no ${p.kind} element at this point on the clone`] }); return; }
|
|
189
202
|
const A = a.style, B = b.style, props = [];
|
|
190
203
|
if (p.kind === 'text') {
|
|
@@ -203,10 +216,10 @@ try {
|
|
|
203
216
|
if (props.length) issues.push({ y: p.y, kind: p.kind, ref: a.sel, clone: b.sel, props });
|
|
204
217
|
else clean++;
|
|
205
218
|
});
|
|
206
|
-
const total = ref.list.filter((p, i) => ref.results[i]?.style).length;
|
|
219
|
+
const total = ref.list.filter((p, i) => ref.results[i]?.style && !ref.unstable?.[i]).length;
|
|
207
220
|
const byProp = {};
|
|
208
221
|
issues.forEach((it) => it.props.forEach((pr) => { const k = pr.split(' ')[0] === 'no' ? 'missing element' : pr.split(' ')[0]; byProp[k] = (byProp[k] || 0) + 1; }));
|
|
209
|
-
report.viewports.push({ viewport: vpName, probes: total, clean, missing, score: +(100 * clean / Math.max(1, total)).toFixed(1), byProp, issues });
|
|
222
|
+
report.viewports.push({ viewport: vpName, probes: total, clean, missing, unstable: unstableList.length, score: +(100 * clean / Math.max(1, total)).toFixed(1), byProp, issues, unstableProbes: unstableList });
|
|
210
223
|
}
|
|
211
224
|
} finally {
|
|
212
225
|
if (!flags.cdp) await browser.close();
|
|
@@ -216,7 +229,7 @@ const out = path.join(root, 'compare', 'detail.json');
|
|
|
216
229
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
217
230
|
await fs.writeFile(out, JSON.stringify(report, null, 2));
|
|
218
231
|
for (const v of report.viewports) {
|
|
219
|
-
console.log(`\n${v.viewport}: detail match ${v.score}% (${v.clean}/${v.probes} probes clean, ${v.missing} with no counterpart) by property: ${JSON.stringify(v.byProp)}`);
|
|
232
|
+
console.log(`\n${v.viewport}: detail match ${v.score}% (${v.clean}/${v.probes} probes clean, ${v.missing} with no counterpart${v.unstable ? `; ${v.unstable} unstable on the reference itself, not scored: ${[...new Set(v.unstableProbes.map((u) => u.ref))].slice(0, 4).join(', ')}` : ''}) by property: ${JSON.stringify(v.byProp)}`);
|
|
220
233
|
// group identical issues so one root cause prints once with a count
|
|
221
234
|
const groups = new Map();
|
|
222
235
|
for (const it of v.issues) { const k = `${it.ref} -> ${it.clone}: ${it.props.join('; ')}`; const g = groups.get(k) || { ...it, n: 0 }; g.n++; groups.set(k, g); }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Has the live page changed since the capture? Layout and scroll states score against the captured screenshots, but
|
|
2
|
+
// detail, hovers, motion curves, interactions and the load sequence measure the live page: when the site changed in
|
|
3
|
+
// between, those checks compare the clone with something it was never a copy of. One page load per width: the page
|
|
4
|
+
// height and the number of top-level sections against meta.json. Written to compare/drift.json; REPORT.md marks the
|
|
5
|
+
// live-measured checks "stale capture" and the advice is to re-capture, not to touch the clone.
|
|
6
|
+
// usage: node scripts/drift.mjs <name> [--viewport desktop|all]
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { VIEWPORTS, parseArgs, openBrowser, newPage, settle, sectionsOf } from './lib.mjs';
|
|
10
|
+
|
|
11
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
12
|
+
const [name] = pos;
|
|
13
|
+
if (!name) {
|
|
14
|
+
console.error('usage: trueclone drift <name> [--viewport desktop|all]');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const root = path.resolve('sites', name);
|
|
18
|
+
const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
|
|
19
|
+
const vps = !flags.viewport || flags.viewport === 'all' ? VIEWPORTS : [VIEWPORTS.find((v) => v.name === flags.viewport)].filter(Boolean);
|
|
20
|
+
|
|
21
|
+
const browser = await openBrowser(flags.cdp);
|
|
22
|
+
const viewports = [];
|
|
23
|
+
try {
|
|
24
|
+
for (const vp of vps) {
|
|
25
|
+
const captured = meta.viewports?.find((v) => v.viewport === vp.name);
|
|
26
|
+
if (!captured) continue;
|
|
27
|
+
const { context, page } = await newPage(browser, vp);
|
|
28
|
+
try {
|
|
29
|
+
await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
|
|
30
|
+
await settle(page, meta.hide || []);
|
|
31
|
+
const livePageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
32
|
+
const liveSections = (await sectionsOf(page)).length;
|
|
33
|
+
// 1% (at least 40px) absorbs font hinting and a rotating line of text; a new section or a block that moved is above that
|
|
34
|
+
const stale = Math.abs(livePageHeight - captured.pageHeight) > Math.max(40, captured.pageHeight * 0.01) || liveSections !== captured.sections;
|
|
35
|
+
viewports.push({ viewport: vp.name, livePageHeight, capturedPageHeight: captured.pageHeight, liveSections, capturedSections: captured.sections, stale });
|
|
36
|
+
console.log(`${vp.name}: live ${livePageHeight}px / ${liveSections} sections, captured ${captured.pageHeight}px / ${captured.sections} sections${stale ? ' <- changed since the capture' : ' (same)'}`);
|
|
37
|
+
} finally {
|
|
38
|
+
await context.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} finally {
|
|
42
|
+
if (!flags.cdp) await browser.close();
|
|
43
|
+
}
|
|
44
|
+
const stale = viewports.some((v) => v.stale);
|
|
45
|
+
await fs.mkdir(path.join(root, 'compare'), { recursive: true });
|
|
46
|
+
await fs.writeFile(path.join(root, 'compare', 'drift.json'), JSON.stringify({ at: new Date().toISOString(), capturedAt: meta.capturedAt, stale, viewports }, null, 2));
|
|
47
|
+
if (stale) console.warn(`WARNING reference drift: the live page has changed since the capture of ${meta.capturedAt}; detail, hovers, curves, interactions and load now measure a page the clone was never a copy of. Re-capture (trueclone capture, then states, reveals, seed --force) before trusting them.`);
|
|
48
|
+
else console.log(`drift: none since ${meta.capturedAt}`);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// One look at one place on the page: what every check saw there, so a gap is read from the tool's own data instead of
|
|
2
|
+
// re-measured by hand. Given a page y (or a section label) at one width, it prints the layout section's score, the
|
|
3
|
+
// scroll states near it, the detail probes that differ, the hover rows that differ, and writes a side-by-side crop
|
|
4
|
+
// (reference | clone | diff, masked) around that y.
|
|
5
|
+
// usage: node scripts/explain.mjs <name> <y | "section label"> [--viewport desktop] [--range 800]
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { PNG, parseArgs, crop } from './lib.mjs';
|
|
9
|
+
|
|
10
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
11
|
+
const [name, where] = pos;
|
|
12
|
+
if (!name || where === undefined) {
|
|
13
|
+
console.error('usage: trueclone explain <name> <y | "section label"> [--viewport desktop] [--range 800]');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
const vp = flags.viewport || 'desktop';
|
|
17
|
+
const range = Number(flags.range || 800);
|
|
18
|
+
const root = path.resolve('sites', name);
|
|
19
|
+
const read = async (rel) => fs.readFile(path.join(root, rel), 'utf8').then(JSON.parse).catch(() => null);
|
|
20
|
+
|
|
21
|
+
const layout = await read('compare/report.mask.json');
|
|
22
|
+
const lv = layout?.viewports.find((v) => v.viewport === vp);
|
|
23
|
+
if (!lv) {
|
|
24
|
+
console.error(`no layout score for ${vp} yet: run trueclone score ${name} first`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const section = /^\d+$/.test(where)
|
|
28
|
+
? lv.sections.find((s) => Number(where) >= s.y && Number(where) < s.y + s.height) || null
|
|
29
|
+
: lv.sections.find((s) => String(s.label).toLowerCase().includes(where.toLowerCase())) || null;
|
|
30
|
+
const y = /^\d+$/.test(where) ? Number(where) : section?.y;
|
|
31
|
+
if (y === undefined) {
|
|
32
|
+
console.error(`no section labelled like "${where}" at ${vp}; sections: ${lv.sections.map((s) => `${s.y} ${String(s.label).slice(0, 24)}`).join(' | ')}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const lo = Math.max(0, y - range / 2), hi = y + range / 2;
|
|
36
|
+
const within = (v) => v >= lo && v <= hi;
|
|
37
|
+
|
|
38
|
+
console.log(`${name} @ ${vp} y ${y} (±${range / 2}px)`);
|
|
39
|
+
console.log(`\nLayout: page ${lv.heightRef}px vs clone ${lv.heightClone}px (${lv.match}% overall)`);
|
|
40
|
+
if (section) console.log(` section "${String(section.label).slice(0, 60)}" y ${section.y} h ${section.height}: ${section.match}%`);
|
|
41
|
+
for (const s of lv.sections.filter((s) => s !== section && (within(s.y) || within(s.y + s.height)))) console.log(` nearby "${String(s.label).slice(0, 40)}" y ${s.y}: ${s.match}%`);
|
|
42
|
+
|
|
43
|
+
const states = await read('compare/states/report.mask.json');
|
|
44
|
+
const sv = states?.viewports?.find((v) => v.viewport === vp);
|
|
45
|
+
const steps = (sv?.steps || sv?.states || []).filter((st) => within(st.scrollY ?? st.y ?? st.scroll ?? -1));
|
|
46
|
+
if (steps.length) console.log(`\nScroll states scrolled to here: ${steps.map((st) => `${st.scrollY ?? st.y ?? st.scroll}px ${st.match ?? st.score}%`).join(', ')}`);
|
|
47
|
+
|
|
48
|
+
const detail = await read('compare/detail.json');
|
|
49
|
+
const dv = detail?.viewports?.find((v) => v.viewport === vp);
|
|
50
|
+
if (dv) {
|
|
51
|
+
const here = dv.issues.filter((it) => within(it.y));
|
|
52
|
+
const unstable = (dv.unstableProbes || []).filter((it) => within(it.y));
|
|
53
|
+
console.log(`\nDetail: ${here.length} differing probe(s) here${unstable.length ? `, ${unstable.length} unstable on the reference (not scored)` : ''}`);
|
|
54
|
+
const groups = new Map();
|
|
55
|
+
for (const it of here) { const k = `${it.ref} -> ${it.clone}: ${it.props.join('; ')}`; const g = groups.get(k) || { ...it, n: 0 }; g.n++; groups.set(k, g); }
|
|
56
|
+
[...groups.values()].sort((a, b) => b.n - a.n).slice(0, 12).forEach((g) => console.log(` x${String(g.n).padEnd(3)} y${String(g.y).padEnd(6)} ${g.ref} -> ${g.clone}: ${g.props.join('; ')}`));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const hovers = await read(`compare/hovers.${vp}.json`);
|
|
60
|
+
if (hovers) {
|
|
61
|
+
const rows = hovers.rows.filter((r) => within(r.y) && !['static', 'ok', 'unstable'].includes(r.status));
|
|
62
|
+
if (rows.length) { console.log(`\nHovers differing here:`); rows.slice(0, 10).forEach((r) => console.log(` y${r.y} ${r.ref}: reference changes ${r.refChanges}, clone ${r.cloneChanges} (${r.status})`)); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// the crop: reference | clone | diff, masked, half size
|
|
66
|
+
try {
|
|
67
|
+
const imgs = await Promise.all([`reference/${vp}.mask.png`, `compare/${vp}.mask.clone.png`, `compare/${vp}.mask.diff.png`].map(async (f) => PNG.sync.read(await fs.readFile(path.join(root, f)))));
|
|
68
|
+
const top = Math.max(0, Math.round(lo)), h = Math.min(Math.round(hi) - top, Math.min(...imgs.map((i) => i.height)) - top);
|
|
69
|
+
const parts = imgs.map((img) => crop(img, top, h));
|
|
70
|
+
const scale = 0.5, w = Math.round(parts[0].width * scale), hh = Math.round(h * scale);
|
|
71
|
+
const out = new PNG({ width: w * 3 + 20, height: hh });
|
|
72
|
+
out.data.fill(255);
|
|
73
|
+
parts.forEach((p, k) => {
|
|
74
|
+
for (let yy = 0; yy < hh; yy++) for (let x = 0; x < w; x++) {
|
|
75
|
+
const sx = Math.min(p.width - 1, Math.round(x / scale)), sy = Math.min(p.height - 1, Math.round(yy / scale));
|
|
76
|
+
const si = (sy * p.width + sx) * 4, di = (yy * out.width + x + k * (w + 10)) * 4;
|
|
77
|
+
out.data[di] = p.data[si]; out.data[di + 1] = p.data[si + 1]; out.data[di + 2] = p.data[si + 2]; out.data[di + 3] = 255;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
const file = path.join(root, 'compare', 'explain', `${vp}-${y}.png`);
|
|
81
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
82
|
+
await fs.writeFile(file, PNG.sync.write(out));
|
|
83
|
+
console.log(`\nCrop (reference | clone | diff, y ${top}-${top + h}): ${path.relative(process.cwd(), file)}`);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.log(`\n(no crop: ${e.message.split('\n')[0]})`);
|
|
86
|
+
}
|
|
87
|
+
console.log(`\nNext: npx trueclone rules ${name} "<selector>" --viewport ${vp} (the reference's CSS) and --clone (the clone's), or npx trueclone score ${name} --only layout,detail --viewport ${vp} after a fix.`);
|
package/scripts/hovers.mjs
CHANGED
|
@@ -86,15 +86,23 @@ async function hoverAll(browser, url, list) {
|
|
|
86
86
|
const after = await page.evaluate(signature, [t.x, vy]);
|
|
87
87
|
out.push({ sel: before?.sel || null, kinds: before && after && before.sel === after.sel ? changedKinds(before, after) : [] });
|
|
88
88
|
}
|
|
89
|
+
const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => null);
|
|
89
90
|
await context.close();
|
|
90
|
-
return { list, out };
|
|
91
|
+
return { list, out, pageHeight };
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
const browser = await openBrowser(flags.cdp);
|
|
94
95
|
let ref, cl;
|
|
95
96
|
try {
|
|
96
|
-
// reference hovers are cached until the next capture (--refresh-ref to re-measure)
|
|
97
|
-
|
|
97
|
+
// reference hovers are cached until the next capture (--refresh-ref to re-measure); read twice, so a hover effect
|
|
98
|
+
// that comes and goes by timing on the reference itself is set aside as unstable rather than demanded of the clone
|
|
99
|
+
ref = await cached(root, { tool: 'hovers', vp: vp.name, max: MAX, v: 3 }, async () => {
|
|
100
|
+
const first = await hoverAll(browser, meta.url, null);
|
|
101
|
+
const captured = meta.viewports?.find((v) => v.viewport === vp.name)?.pageHeight;
|
|
102
|
+
if (captured && first.pageHeight && Math.abs(first.pageHeight - captured) > Math.max(40, captured * 0.01)) console.warn(`WARNING reference drift (${vp.name}): the live page is ${first.pageHeight}px tall, the capture was ${captured}px (${meta.capturedAt}); re-capture before trusting this score`);
|
|
103
|
+
const again = await hoverAll(browser, meta.url, first.list);
|
|
104
|
+
return { ...first, unstable: first.out.map((a, i) => a.sel !== again.out[i]?.sel || a.kinds.join() !== (again.out[i]?.kinds || []).join()) };
|
|
105
|
+
}, Boolean(flags['refresh-ref']));
|
|
98
106
|
cl = await hoverAll(browser, cloneUrl, ref.list);
|
|
99
107
|
} finally {
|
|
100
108
|
if (!flags.cdp) await browser.close();
|
|
@@ -102,15 +110,16 @@ try {
|
|
|
102
110
|
}
|
|
103
111
|
const rows = ref.list.map((t, i) => {
|
|
104
112
|
const a = ref.out[i], b = cl.out[i];
|
|
105
|
-
const status = !a.kinds.length ? (b.kinds.length ? 'extra' : 'static') : !b.sel ? 'no element' : !b.kinds.length ? 'MISSING' : a.kinds.some((k) => b.kinds.includes(k)) ? 'ok' : 'different';
|
|
113
|
+
const status = ref.unstable?.[i] ? 'unstable' : !a.kinds.length ? (b.kinds.length ? 'extra' : 'static') : !b.sel ? 'no element' : !b.kinds.length ? 'MISSING' : a.kinds.some((k) => b.kinds.includes(k)) ? 'ok' : 'different';
|
|
106
114
|
return { y: t.y, ref: a.sel || t.sel, refChanges: a.kinds.join(',') || '-', clone: b.sel || '-', cloneChanges: b.kinds.join(',') || '-', status };
|
|
107
115
|
});
|
|
108
|
-
const withHover = rows.filter((r) => r.refChanges !== '-');
|
|
116
|
+
const withHover = rows.filter((r) => r.refChanges !== '-' && r.status !== 'unstable');
|
|
117
|
+
const unstable = rows.filter((r) => r.status === 'unstable').length;
|
|
109
118
|
const ok = withHover.filter((r) => r.status === 'ok').length;
|
|
110
|
-
const report = { name, viewport: vp.name, at: new Date().toISOString(), score: +(100 * ok / Math.max(1, withHover.length)).toFixed(1), rows };
|
|
119
|
+
const report = { name, viewport: vp.name, at: new Date().toISOString(), score: +(100 * ok / Math.max(1, withHover.length)).toFixed(1), unstable, rows };
|
|
111
120
|
const out = path.join(root, 'compare', `hovers.${vp.name}.json`);
|
|
112
121
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
113
122
|
await fs.writeFile(out, JSON.stringify(report, null, 2));
|
|
114
|
-
console.log(`hover match (${vp.name}): ${report.score}% of ${withHover.length} reference hover effects reproduced (${rows.length} interactive elements probed)`);
|
|
115
|
-
console.table(rows.filter((r) => r.status !== 'static' && r.status !== 'ok').slice(0, 25));
|
|
123
|
+
console.log(`hover match (${vp.name}): ${report.score}% of ${withHover.length} reference hover effects reproduced (${rows.length} interactive elements probed${unstable ? `, ${unstable} unstable on the reference itself, not scored` : ''})`);
|
|
124
|
+
console.table(rows.filter((r) => r.status !== 'static' && r.status !== 'ok' && r.status !== 'unstable').slice(0, 25));
|
|
116
125
|
console.log(`-> ${path.relative(process.cwd(), out)}`);
|
package/scripts/lib.mjs
CHANGED
|
@@ -126,19 +126,22 @@ img,video{object-position:-99999px -99999px!important;background:#888!important}
|
|
|
126
126
|
svg{background:#888!important} svg *{visibility:hidden!important} iframe{visibility:hidden!important}
|
|
127
127
|
canvas{background:#888!important;filter:contrast(0) brightness(1.066)!important}
|
|
128
128
|
[data-lab-cliptext]{background:none!important;-webkit-text-fill-color:transparent!important}
|
|
129
|
+
[data-lab-bgi]{background-image:none!important;background-color:#888!important}
|
|
129
130
|
[data-lab-bgb]::before,[data-lab-bga]::after{background-image:none!important;background-color:#888!important}`; // canvas = media (WebGL heroes, 3D models): flat grey box like images
|
|
130
131
|
|
|
131
|
-
// What CSS alone can't select:
|
|
132
|
-
//
|
|
133
|
-
// the
|
|
132
|
+
// What CSS alone can't select: photo backgrounds (a script that rewrites an element's inline background every frame
|
|
133
|
+
// would undo an inline override, so the mask is a stylesheet rule on a marker attribute, which inline styles lose
|
|
134
|
+
// to), gradient text (background-clip: text paints the glyphs with its background, so a transparent colour still
|
|
135
|
+
// shows the letters) and photo backgrounds on ::before/::after. `off` removes the markers.
|
|
134
136
|
function maskExtrasInPage(enable) {
|
|
135
|
-
const marks = ['data-lab-cliptext', 'data-lab-bgb', 'data-lab-bga'];
|
|
137
|
+
const marks = ['data-lab-bgi', 'data-lab-cliptext', 'data-lab-bgb', 'data-lab-bga'];
|
|
136
138
|
if (!enable) {
|
|
137
139
|
for (const m of marks) for (const el of document.querySelectorAll(`[${m}]`)) el.removeAttribute(m);
|
|
138
140
|
return;
|
|
139
141
|
}
|
|
140
142
|
for (const el of document.querySelectorAll('*')) {
|
|
141
143
|
const s = getComputedStyle(el);
|
|
144
|
+
if (s.backgroundImage.includes('url(')) el.setAttribute('data-lab-bgi', '');
|
|
142
145
|
if (s.backgroundClip === 'text' || s.webkitBackgroundClip === 'text') el.setAttribute('data-lab-cliptext', '');
|
|
143
146
|
if (getComputedStyle(el, '::before').backgroundImage.includes('url(')) el.setAttribute('data-lab-bgb', '');
|
|
144
147
|
if (getComputedStyle(el, '::after').backgroundImage.includes('url(')) el.setAttribute('data-lab-bga', '');
|
|
@@ -148,14 +151,6 @@ function maskExtrasInPage(enable) {
|
|
|
148
151
|
export async function mask(page) {
|
|
149
152
|
const tag = await page.addStyleTag({ content: MASK_CSS });
|
|
150
153
|
await tag.evaluate((el) => el.setAttribute('data-lab-freeze', ''));
|
|
151
|
-
await page.evaluate(() => {
|
|
152
|
-
for (const el of document.querySelectorAll('*')) {
|
|
153
|
-
if (getComputedStyle(el).backgroundImage.includes('url(')) {
|
|
154
|
-
el.style.setProperty('background-image', 'none', 'important');
|
|
155
|
-
el.style.setProperty('background-color', '#888', 'important');
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
154
|
await page.evaluate(maskExtrasInPage, true);
|
|
160
155
|
await page.waitForTimeout(200);
|
|
161
156
|
}
|
|
@@ -173,7 +168,7 @@ export async function freezeLoops(page) {
|
|
|
173
168
|
}).catch(() => {});
|
|
174
169
|
}
|
|
175
170
|
|
|
176
|
-
// Reversible version of mask() for live pages: on() masks, off()
|
|
171
|
+
// Reversible version of mask() for live pages: on() masks, off() removes the stylesheet and every marker.
|
|
177
172
|
export async function maskToggle(page, on) {
|
|
178
173
|
await page.evaluate(([css, enable]) => {
|
|
179
174
|
if (enable) {
|
|
@@ -182,19 +177,8 @@ export async function maskToggle(page, on) {
|
|
|
182
177
|
tag.setAttribute('data-lab-freeze', '');
|
|
183
178
|
tag.textContent = css;
|
|
184
179
|
document.head.appendChild(tag);
|
|
185
|
-
for (const el of document.querySelectorAll('*')) {
|
|
186
|
-
if (getComputedStyle(el).backgroundImage.includes('url(')) {
|
|
187
|
-
el.setAttribute('data-lab-bg', el.style.cssText);
|
|
188
|
-
el.style.setProperty('background-image', 'none', 'important');
|
|
189
|
-
el.style.setProperty('background-color', '#888', 'important');
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
180
|
} else {
|
|
193
181
|
document.getElementById('lab-mask')?.remove();
|
|
194
|
-
for (const el of document.querySelectorAll('[data-lab-bg]')) {
|
|
195
|
-
el.style.cssText = el.getAttribute('data-lab-bg');
|
|
196
|
-
el.removeAttribute('data-lab-bg');
|
|
197
|
-
}
|
|
198
182
|
}
|
|
199
183
|
}, [MASK_CSS, on]);
|
|
200
184
|
await page.evaluate(maskExtrasInPage, on);
|