pixel-perfect-kit 0.1.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/CLONE-ALOYOGA-HEADER.md +76 -0
- package/CLONE-ANY-HEADER.md +106 -0
- package/CLONE-ANY-SITE.md +120 -0
- package/DEVELOP.md +144 -0
- package/LAUNCH-PROMPT.md +66 -0
- package/LEARNINGS.md +398 -0
- package/LICENSE +21 -0
- package/PLAYBOOK.md +260 -0
- package/README.md +219 -0
- package/WORKFLOW.md +257 -0
- package/bin/ppk +4 -0
- package/harness/adopt.js +52 -0
- package/harness/agent-setup.js +55 -0
- package/harness/behavior-selftest.js +243 -0
- package/harness/benchmarks/README.md +34 -0
- package/harness/benchmarks/battery.js +86 -0
- package/harness/benchmarks/detection-power.js +75 -0
- package/harness/capture-build-selftest.js +134 -0
- package/harness/capture-build.js +323 -0
- package/harness/doctor-selftest.js +58 -0
- package/harness/doctor.js +85 -0
- package/harness/fixtures/01-backdrop-color.js +51 -0
- package/harness/fixtures/02-line-strut.js +53 -0
- package/harness/fixtures/03-compat-mode.js +44 -0
- package/harness/fixtures/README.md +31 -0
- package/harness/human-qa-selftest.js +201 -0
- package/harness/human-qa.js +406 -0
- package/harness/merge-snapshot-selftest.js +56 -0
- package/harness/new-target.js +101 -0
- package/harness/regression.js +50 -0
- package/harness/score.js +58 -0
- package/harness/serve.js +149 -0
- package/harness/setup-selftest.js +93 -0
- package/harness/setup.js +158 -0
- package/harness/tunnel-selftest.js +76 -0
- package/harness/tunnel.js +241 -0
- package/harness/workflow-selftest.js +211 -0
- package/harness/workflow.js +739 -0
- package/package.json +40 -0
- package/skill/ditto-finish/SKILL.md +52 -0
- package/skill/pixel-perfect-clone/SKILL.md +49 -0
- package/tools/RUNBOOK.md +228 -0
- package/tools/behavior-capture.js +307 -0
- package/tools/behavior-worksheet.js +82 -0
- package/tools/browser-capture.js +240 -0
- package/tools/cli-selftest.js +136 -0
- package/tools/extract-fonts.js +133 -0
- package/tools/extract-icons.js +255 -0
- package/tools/merge-snapshot.js +56 -0
- package/tools/pixel-diff.js +845 -0
- package/tools/reassemble.js +63 -0
- package/tools/selftest.js +67 -0
- package/tools/sink.js +80 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pixel-diff.js — exhaustive measure + numeric diff for pixel-perfect cloning.
|
|
3
|
+
*
|
|
4
|
+
* The point of this tool is to make "pixel-perfect" a PASS/FAIL fact, never an
|
|
5
|
+
* eyeball judgement. For every target element it captures the COMPLETE box
|
|
6
|
+
* (geometry, text-glyph box, full box-model, font INCLUDING line-height &
|
|
7
|
+
* letter-spacing, layout, and the parent's flex/grid gap), then diffs the two
|
|
8
|
+
* snapshots property-by-property. Any numeric delta over the tolerance, or any
|
|
9
|
+
* mismatched string, is a failure — printed in a table, and (in Node) a non-zero
|
|
10
|
+
* exit code so it can gate CI.
|
|
11
|
+
*
|
|
12
|
+
* Why it would have caught the bug it was written for: the "NEW" badge looked
|
|
13
|
+
* fine but its box was 12px tall vs the live 14.5px (a collapsed line-height).
|
|
14
|
+
* x / y / size / weight all matched — so a 4-property spot check passed. This
|
|
15
|
+
* tool measures `font.line` and `text.h` for EVERY element, so that row fails
|
|
16
|
+
* loudly instead of hiding behind a screenshot that "looks close".
|
|
17
|
+
*
|
|
18
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
* USAGE
|
|
20
|
+
*
|
|
21
|
+
* A) Measure each page (paste into DevTools console, or run via a browser
|
|
22
|
+
* automation javascript_tool — once on the LIVE site, once on the clone,
|
|
23
|
+
* both at the SAME viewport width):
|
|
24
|
+
*
|
|
25
|
+
* copy(pxCapture()) // returns a JSON snapshot string
|
|
26
|
+
*
|
|
27
|
+
* Save them as e.g. live.json and clone.json.
|
|
28
|
+
*
|
|
29
|
+
* B) Diff them deterministically in Node (exit 1 on any failure):
|
|
30
|
+
*
|
|
31
|
+
* node tools/pixel-diff.js live.json clone.json
|
|
32
|
+
* node tools/pixel-diff.js live.json clone.json --tol 0.5 --all
|
|
33
|
+
*
|
|
34
|
+
* Or diff in the browser without leaving the page:
|
|
35
|
+
*
|
|
36
|
+
* pxDiff(liveSnapshotObj, cloneSnapshotObj)
|
|
37
|
+
*
|
|
38
|
+
* C) Customise what gets measured by editing pxTargets (see TARGETS below).
|
|
39
|
+
* Finders match by TEXT / ROLE / aria-label — never by class name — so the
|
|
40
|
+
* same target resolves on both the live DOM and your clone.
|
|
41
|
+
*
|
|
42
|
+
* D) On a strict-CSP live site driven by BROWSER AUTOMATION (not the DevTools
|
|
43
|
+
* console), the convenience paths are blocked — `copy()` doesn't exist,
|
|
44
|
+
* `eval()` is refused by script-src, the Clipboard API needs focus, and
|
|
45
|
+
* `fetch` to localhost is refused by connect-src. Use the CSP-proof path:
|
|
46
|
+
* 1. Inject this file's SOURCE DIRECTLY (paste the whole file as the code
|
|
47
|
+
* to evaluate). A debugger-injected script is NOT gated by script-src,
|
|
48
|
+
* so the IIFE self-installs pxCapture/pxStash. Do NOT load it via
|
|
49
|
+
* fetch(...).then(eval) — that IS gated and will hang/fail.
|
|
50
|
+
* 2. Call pxStash() → writes the snapshot into a hidden <textarea> and
|
|
51
|
+
* returns {bytes, chunks, chunkSize}. Default chunk is 1000 chars to fit
|
|
52
|
+
* a typical automation result cap (~1–2KB); pass pxStash(null, N) to
|
|
53
|
+
* change it (a DevTools console has no cap — just use copy(pxCapture())).
|
|
54
|
+
* 3. Call pxRead(0), pxRead(1), … pxRead(chunks-1) → slices that fit inside
|
|
55
|
+
* one automation result without truncation. Concatenate them and save as
|
|
56
|
+
* live.json. (getComputedStyle/Range are never CSP-blocked, so the
|
|
57
|
+
* measurement itself always works.)
|
|
58
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
(function (root) {
|
|
62
|
+
"use strict";
|
|
63
|
+
|
|
64
|
+
// ===========================================================================
|
|
65
|
+
// 1. PURE DIFF ENGINE (runs in browser AND Node — no DOM needed)
|
|
66
|
+
// ===========================================================================
|
|
67
|
+
|
|
68
|
+
const DEFAULT_TOL = 0.5; // px — anything over this is a real, visible miss
|
|
69
|
+
|
|
70
|
+
// Flatten a nested measurement object to dotted leaf keys: {rect:{x:1}} -> {"rect.x":1}
|
|
71
|
+
function flatten(obj, prefix, out) {
|
|
72
|
+
out = out || {};
|
|
73
|
+
prefix = prefix || "";
|
|
74
|
+
if (obj == null || typeof obj !== "object") {
|
|
75
|
+
out[prefix.replace(/\.$/, "")] = obj;
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
for (const k of Object.keys(obj)) flatten(obj[k], prefix + k + ".", out);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Compare two leaf values. Returns {pass, delta} where delta is numeric or "".
|
|
83
|
+
function cmp(a, b, tol) {
|
|
84
|
+
const an = typeof a === "number" && isFinite(a);
|
|
85
|
+
const bn = typeof b === "number" && isFinite(b);
|
|
86
|
+
if (an && bn) {
|
|
87
|
+
const d = Math.round(Math.abs(a - b) * 100) / 100;
|
|
88
|
+
return { pass: d <= tol, delta: d };
|
|
89
|
+
}
|
|
90
|
+
// strings / booleans / nulls compare exactly
|
|
91
|
+
return { pass: a === b, delta: "" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* diffSnapshots(live, clone, opts) -> { ok, rows, summary }
|
|
96
|
+
* rows: [{ target, prop, live, clone, delta, pass }]
|
|
97
|
+
* Only failing rows are kept unless opts.all is true.
|
|
98
|
+
*/
|
|
99
|
+
// --visual judges the VISIBLE MARK and ignores wrapper/structure (see the
|
|
100
|
+
// per-element logic in diffSnapshots): text elements compare their text-glyph
|
|
101
|
+
// box + font metrics; graphics (icons/logo) compare center + width. Two valid
|
|
102
|
+
// CSS techniques (flex+gap vs grid; a tall hit-area vs a tight box) can render
|
|
103
|
+
// identical pixels, so those structural differences are not visual failures.
|
|
104
|
+
// Strict mode (default) still compares every property so nothing is hidden.
|
|
105
|
+
|
|
106
|
+
function diffSnapshots(live, clone, opts) {
|
|
107
|
+
opts = opts || {};
|
|
108
|
+
const tol = opts.tol == null ? DEFAULT_TOL : opts.tol;
|
|
109
|
+
const rows = [];
|
|
110
|
+
const warnings = [];
|
|
111
|
+
let compared = 0; // total properties actually compared (not just kept rows)
|
|
112
|
+
|
|
113
|
+
if (live.viewport && clone.viewport) {
|
|
114
|
+
if (live.viewport.width !== clone.viewport.width)
|
|
115
|
+
warnings.push(
|
|
116
|
+
`viewport width differs: live ${live.viewport.width} vs clone ${clone.viewport.width} — ` +
|
|
117
|
+
`x positions are not comparable. Re-measure both at the same width.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Rendering-mode mismatch is a DEFECT row, not a warning: quirks ("BackCompat") vs
|
|
122
|
+
// standards ("CSS1Compat") silently shifts line boxes everywhere with every computed
|
|
123
|
+
// style identical — a doctype the live page doesn't have (or vice versa) must fail
|
|
124
|
+
// loudly (LEARNINGS #18). Skipped when either snapshot predates the field.
|
|
125
|
+
if (live.mode && clone.mode) {
|
|
126
|
+
compared++;
|
|
127
|
+
if (live.mode !== clone.mode)
|
|
128
|
+
rows.push({ target: "page", prop: "mode", live: live.mode, clone: clone.mode, delta: "", pass: false });
|
|
129
|
+
else if (opts.all)
|
|
130
|
+
rows.push({ target: "page", prop: "mode", live: live.mode, clone: clone.mode, delta: "", pass: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const names = new Set([
|
|
134
|
+
...Object.keys(live.elements || {}),
|
|
135
|
+
...Object.keys(clone.elements || {}),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
for (const name of names) {
|
|
139
|
+
const L = live.elements[name];
|
|
140
|
+
const C = clone.elements[name];
|
|
141
|
+
|
|
142
|
+
// presence mismatch is the loudest possible failure
|
|
143
|
+
const lPresent = L && L.present;
|
|
144
|
+
const cPresent = C && C.present;
|
|
145
|
+
if (!lPresent || !cPresent) {
|
|
146
|
+
rows.push({
|
|
147
|
+
target: name,
|
|
148
|
+
prop: "present",
|
|
149
|
+
live: !!lPresent,
|
|
150
|
+
clone: !!cPresent,
|
|
151
|
+
delta: "",
|
|
152
|
+
pass: lPresent === cPresent, // both-absent is a "pass" but surfaced as a warning
|
|
153
|
+
});
|
|
154
|
+
if (!lPresent && !cPresent)
|
|
155
|
+
warnings.push(`target "${name}" not found on EITHER page — finder needs fixing.`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --visual compares the VISIBLE MARK, not the wrapper box:
|
|
160
|
+
// • text element → the text-glyph box (text.*) + font metrics. The element
|
|
161
|
+
// rect is ignored (it may be a full-width bar or wrap an icon — invisible).
|
|
162
|
+
// • graphic (icon/logo) → the PAINTED glyph (glyph.*: center, size, and
|
|
163
|
+
// background-position), NOT the wrapper rect. A top-aligned glyph in a
|
|
164
|
+
// taller control makes rect.cy lie (control 91.25 vs glyph 89); glyph.cy
|
|
165
|
+
// is the pixel truth. Falls back to the rect center only if no glyph was
|
|
166
|
+
// captured. Strict mode (default) still compares every flattened property.
|
|
167
|
+
if (opts.visual) {
|
|
168
|
+
const add = (prop, a, b) => {
|
|
169
|
+
const { pass, delta } = cmp(a, b, tol);
|
|
170
|
+
compared++;
|
|
171
|
+
if (!pass || opts.all) rows.push({ target: name, prop, live: a, clone: b, delta, pass });
|
|
172
|
+
};
|
|
173
|
+
// `smoothing` (-webkit-font-smoothing) changes perceived weight while
|
|
174
|
+
// font-weight matches — a "looks thicker" no weight/size check catches
|
|
175
|
+
// (LEARNINGS #13). `underline` is compared as a BOX below, not here.
|
|
176
|
+
// `strut` is the line-box CONTAINER's line-height — a leaf can match while
|
|
177
|
+
// the container that positions the line differs (`normal` vs 16px), which
|
|
178
|
+
// drifts across platforms even when the same-machine delta is sub-tolerance
|
|
179
|
+
// (LEARNINGS #17). Skipped when either side predates the strut capture.
|
|
180
|
+
const FONT = ["weight", "size", "line", "spacing", "transform", "color", "decoration", "underline", "smoothing", "strut"];
|
|
181
|
+
const isText = "text" in L || "text" in C; // text targets carry `text`; graphics carry `glyph`
|
|
182
|
+
if (isText) {
|
|
183
|
+
// Font metrics — INCLUDING color, decoration, underline, and smoothing — are
|
|
184
|
+
// compared for EVERY text target, even when the glyph box came back null. A
|
|
185
|
+
// finder that resolves a non-text WRAPPER (its own text node is empty) used
|
|
186
|
+
// to fall through to the rect-only branch below, silently skipping color and
|
|
187
|
+
// size. That blind spot is exactly how an invisible blue-on-blue
|
|
188
|
+
// announcement (text color == background) passed `--visual`.
|
|
189
|
+
if (L.font && C.font) for (const f of FONT) {
|
|
190
|
+
// strut arrived in a schema update — skip when EITHER capture predates it
|
|
191
|
+
// (undefined vs "normal" would false-positive a mixed old/new pair)
|
|
192
|
+
if (f === "strut" && (L.font.strut === undefined || C.font.strut === undefined)) continue;
|
|
193
|
+
add(`font.${f}`, L.font[f], C.font[f]);
|
|
194
|
+
}
|
|
195
|
+
if (L.text && C.text) {
|
|
196
|
+
for (const k of ["x", "right", "top", "bottom", "w", "h"]) add(`text.${k}`, L.text[k], C.text[k]);
|
|
197
|
+
} else {
|
|
198
|
+
// present:true but no text-glyph box on a side → the finder grabbed a
|
|
199
|
+
// wrapper, not the text. Surface it (fix the finder) instead of hiding it.
|
|
200
|
+
add("text.present", !!L.text, !!C.text);
|
|
201
|
+
}
|
|
202
|
+
// An underline is a painted mark with a box (thickness/width/offset), not a
|
|
203
|
+
// boolean — compare its geometry whenever either side draws one, so a too-thin
|
|
204
|
+
// / too-short / mis-offset underline fails on run one (LEARNINGS #12).
|
|
205
|
+
const lu = L.underline || { present: false }, cu = C.underline || { present: false };
|
|
206
|
+
if (lu.present || cu.present) {
|
|
207
|
+
add("underline.present", !!lu.present, !!cu.present);
|
|
208
|
+
if (lu.present && cu.present)
|
|
209
|
+
for (const k of ["thickness", "x", "right", "w", "top", "bottom"]) add(`underline.${k}`, lu[k], cu[k]);
|
|
210
|
+
}
|
|
211
|
+
} else if (L.glyph && C.glyph) {
|
|
212
|
+
for (const k of ["cx", "cy", "w", "h"]) add(`glyph.${k}`, L.glyph[k], C.glyph[k]);
|
|
213
|
+
if (L.glyph.bgPos !== undefined || C.glyph.bgPos !== undefined) add("glyph.bgPos", L.glyph.bgPos, C.glyph.bgPos);
|
|
214
|
+
} else {
|
|
215
|
+
add("rect.cx", L.rect.x + L.rect.w / 2, C.rect.x + C.rect.w / 2);
|
|
216
|
+
add("rect.cy", L.rect.y + L.rect.h / 2, C.rect.y + C.rect.h / 2);
|
|
217
|
+
add("rect.w", L.rect.w, C.rect.w);
|
|
218
|
+
}
|
|
219
|
+
// The painted BACKDROP colour (announcement bar / button / badge) — a
|
|
220
|
+
// solid background is a painted mark, but it lives on a container the
|
|
221
|
+
// per-target capture never measured, so a wrong bar colour slipped a green
|
|
222
|
+
// sweep (the aloyoga miss). Compare it, but ONLY when BOTH sides paint an
|
|
223
|
+
// OPAQUE colour: a translucent layer composites to pixels we can't
|
|
224
|
+
// reconstruct from the declared string (comparing it would false-positive a
|
|
225
|
+
// translucent-vs-solid pair that looks identical — proven in the detection
|
|
226
|
+
// battery), and transparent-on-white text/icons should add no noise. Whitespace
|
|
227
|
+
// is normalised so "rgb(0,0,0)" == "rgb(0, 0, 0)".
|
|
228
|
+
const OPAQUE = (v) => typeof v === "string" && /^rgb\(|^#|^[a-z]+$/i.test(v) && !/rgba\(/.test(v);
|
|
229
|
+
const norm = (v) => (typeof v === "string" ? v.replace(/\s+/g, "") : v);
|
|
230
|
+
if (OPAQUE(L.bg) && OPAQUE(C.bg)) add("bg", norm(L.bg), norm(C.bg));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const lf = flatten(L);
|
|
235
|
+
const cf = flatten(C);
|
|
236
|
+
const keys = new Set([...Object.keys(lf), ...Object.keys(cf)]);
|
|
237
|
+
for (const key of keys) {
|
|
238
|
+
if (key === "present" || key === "text") continue; // text is an object container
|
|
239
|
+
const { pass, delta } = cmp(lf[key], cf[key], tol);
|
|
240
|
+
compared++;
|
|
241
|
+
if (!pass || opts.all) {
|
|
242
|
+
rows.push({ target: name, prop: key, live: lf[key], clone: cf[key], delta, pass });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const fails = rows.filter((r) => !r.pass).length;
|
|
248
|
+
return {
|
|
249
|
+
ok: fails === 0,
|
|
250
|
+
rows,
|
|
251
|
+
warnings,
|
|
252
|
+
summary: { targets: names.size, comparisons: compared, failures: fails, tol },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Render a diff result as a console-friendly table string.
|
|
257
|
+
function formatDiff(result) {
|
|
258
|
+
const lines = [];
|
|
259
|
+
const pad = (s, n) => String(s == null ? "" : s).padEnd(n).slice(0, n);
|
|
260
|
+
for (const w of result.warnings) lines.push("⚠ " + w);
|
|
261
|
+
if (result.warnings.length) lines.push("");
|
|
262
|
+
|
|
263
|
+
const fails = result.rows.filter((r) => !r.pass);
|
|
264
|
+
const show = result.rows.length === fails.length ? fails : result.rows; // --all keeps passes
|
|
265
|
+
if (show.length) {
|
|
266
|
+
lines.push(
|
|
267
|
+
pad("element", 18) + pad("property", 22) + pad("live", 16) + pad("clone", 16) + pad("Δ", 8) + "ok"
|
|
268
|
+
);
|
|
269
|
+
lines.push("─".repeat(86));
|
|
270
|
+
for (const r of show) {
|
|
271
|
+
lines.push(
|
|
272
|
+
pad(r.target, 18) +
|
|
273
|
+
pad(r.prop, 22) +
|
|
274
|
+
pad(r.live, 16) +
|
|
275
|
+
pad(r.clone, 16) +
|
|
276
|
+
pad(r.delta === "" ? "" : r.delta, 8) +
|
|
277
|
+
(r.pass ? "✓" : "❌")
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
lines.push("");
|
|
281
|
+
}
|
|
282
|
+
const s = result.summary;
|
|
283
|
+
lines.push(
|
|
284
|
+
result.ok
|
|
285
|
+
? `✓ PASS — ${s.comparisons} comparisons across ${s.targets} targets, all within ${s.tol}px.`
|
|
286
|
+
: `❌ FAIL — ${s.failures} of ${s.comparisons} comparisons over ${s.tol}px tolerance. Fix or explain each row above.`
|
|
287
|
+
);
|
|
288
|
+
return lines.join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ===========================================================================
|
|
292
|
+
// 1b. SINGLE-ELEMENT INSPECTOR DIFF (the "human points → we measure it" path)
|
|
293
|
+
//
|
|
294
|
+
// When a human says "this looks wrong here", we don't want to guess which
|
|
295
|
+
// property matters — we diff the ELEMENT'S ENTIRE computed style (plus its
|
|
296
|
+
// painted marks) live-vs-clone and let every real difference surface. This is
|
|
297
|
+
// the opposite of pxTargets' curated allowlist: here the default is "compare
|
|
298
|
+
// everything, hide only provably-irrelevant props", so a novel property (a
|
|
299
|
+
// border, a shadow, a text-decoration) is caught the first time, not after
|
|
300
|
+
// someone notices. Feed it two `pxInspect(...)` dumps (same element, both pages).
|
|
301
|
+
// ===========================================================================
|
|
302
|
+
|
|
303
|
+
// Props that never change the rendered pixels — the ONLY things we hide.
|
|
304
|
+
const INSPECT_IGNORE =
|
|
305
|
+
/^(transition|animation|cursor|pointer-events|(-webkit-)?user-select|user-drag|will-change|scroll-|overscroll|touch-action|-webkit-tap-highlight|-webkit-locale|orphans|widows|speak|-webkit-line-break|-webkit-user-modify)/;
|
|
306
|
+
// Props that determine what's PAINTED (surfaced first — the fix is almost always here).
|
|
307
|
+
const INSPECT_PAINT =
|
|
308
|
+
/^(color|background|border(?!-collapse)|outline|box-shadow|text-decoration|text-shadow|text-transform|text-emphasis|-webkit-text-stroke|-webkit-text-fill-color|font|letter-spacing|word-spacing|line-height|opacity|visibility|transform|filter|backdrop-filter|fill|stroke|caret-color|accent-color|list-style|object-fit|object-position|mix-blend-mode|clip-path|-webkit-mask|mask|-webkit-font-smoothing)/;
|
|
309
|
+
|
|
310
|
+
// Compare two single-element inspector snapshots. Buckets every difference into
|
|
311
|
+
// paint (visible → fix these) / structural (layout technique → usually fine) and
|
|
312
|
+
// drops the ignore set. Geometry/text/glyph/underline are always paint.
|
|
313
|
+
function inspectDiff(a, b, opts) {
|
|
314
|
+
opts = opts || {};
|
|
315
|
+
const tol = opts.tol == null ? DEFAULT_TOL : opts.tol;
|
|
316
|
+
const paint = [], structural = [];
|
|
317
|
+
const push = (bucket, prop, la, lb) => {
|
|
318
|
+
const { pass, delta } = cmp(la, lb, tol);
|
|
319
|
+
if (!pass || opts.all) bucket.push({ prop, live: la, clone: lb, delta, pass });
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
if (!a || !a.present || !b || !b.present) {
|
|
323
|
+
return { ok: false, paint: [{ prop: "present", live: !!(a && a.present), clone: !!(b && b.present), delta: "", pass: false }], structural: [], summary: { paint: 1, structural: 0, tol } };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 1) painted marks & geometry (the numbers that decide where pixels land)
|
|
327
|
+
const geo = flatten({ rect: a.rect, text: a.text, glyph: a.glyph, underline: a.underline });
|
|
328
|
+
const geoB = flatten({ rect: b.rect, text: b.text, glyph: b.glyph, underline: b.underline });
|
|
329
|
+
for (const k of new Set([...Object.keys(geo), ...Object.keys(geoB)])) push(paint, k, geo[k], geoB[k]);
|
|
330
|
+
|
|
331
|
+
// 2) the FULL computed style, classified
|
|
332
|
+
const sa = a.style || {}, sb = b.style || {};
|
|
333
|
+
for (const p of new Set([...Object.keys(sa), ...Object.keys(sb)])) {
|
|
334
|
+
if (INSPECT_IGNORE.test(p)) continue;
|
|
335
|
+
const bucket = INSPECT_PAINT.test(p) ? paint : structural;
|
|
336
|
+
push(bucket, p, sa[p], sb[p]);
|
|
337
|
+
}
|
|
338
|
+
const fails = [...paint, ...structural].filter((r) => !r.pass).length;
|
|
339
|
+
return { ok: paint.filter((r) => !r.pass).length === 0, paint, structural, summary: { paint: paint.length, structural: structural.length, fails, tol } };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function formatInspect(res) {
|
|
343
|
+
const lines = [], pad = (s, n) => String(s == null ? "" : s).padEnd(n).slice(0, n);
|
|
344
|
+
const table = (rows) => {
|
|
345
|
+
for (const r of rows) lines.push(pad(r.prop, 26) + pad(r.live, 22) + pad(r.clone, 22) + pad(r.delta === "" ? "" : r.delta, 7) + (r.pass ? "✓" : "❌"));
|
|
346
|
+
};
|
|
347
|
+
const paintFails = res.paint.filter((r) => !r.pass);
|
|
348
|
+
lines.push("── PAINT (visible — fix these) ─────────────────────────────────────────────");
|
|
349
|
+
if (paintFails.length || res.paint.some((r) => r.pass)) { lines.push(pad("property", 26) + pad("live", 22) + pad("clone", 22) + pad("Δ", 7) + "ok"); table(res.paint.length && res.paint.length === paintFails.length ? paintFails : (paintFails.length ? paintFails : res.paint)); }
|
|
350
|
+
else lines.push(" (none — every painted property matches)");
|
|
351
|
+
const structFails = res.structural.filter((r) => !r.pass);
|
|
352
|
+
lines.push("");
|
|
353
|
+
lines.push(`── STRUCTURAL (layout technique — usually fine): ${structFails.length} differ ──`);
|
|
354
|
+
table(structFails);
|
|
355
|
+
lines.push("");
|
|
356
|
+
lines.push(res.ok
|
|
357
|
+
? `✓ PIXEL-PERFECT — 0 paint differences (${res.summary.structural} structural shown for context).`
|
|
358
|
+
: `❌ ${paintFails.length} paint differences to fix. Structural: ${structFails.length}.`);
|
|
359
|
+
return lines.join("\n");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ===========================================================================
|
|
363
|
+
// 2. BROWSER CAPTURE (needs a real DOM + getComputedStyle)
|
|
364
|
+
// ===========================================================================
|
|
365
|
+
|
|
366
|
+
function buildBrowserApi() {
|
|
367
|
+
const num = (v) => {
|
|
368
|
+
const n = parseFloat(v);
|
|
369
|
+
return isNaN(n) ? v : Math.round(n * 100) / 100;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// --- finders: resolve by TEXT / ROLE, so they work on live AND clone ---
|
|
373
|
+
const ownText = (el) =>
|
|
374
|
+
[...el.childNodes]
|
|
375
|
+
.filter((n) => n.nodeType === 3)
|
|
376
|
+
.map((n) => n.textContent)
|
|
377
|
+
.join("")
|
|
378
|
+
.trim();
|
|
379
|
+
|
|
380
|
+
// Region to search. Header/section clones must scope finders, or the SAME
|
|
381
|
+
// text elsewhere on the page silently wins (e.g. a footer "Shoes" link beat
|
|
382
|
+
// the nav item — found at y=318 instead of y=56). Default: top 200px (the
|
|
383
|
+
// header band). Override with pxRegion = {maxY: N} or {sel: "header"} before
|
|
384
|
+
// pxCapture/pxStash for a different section.
|
|
385
|
+
root.pxRegion = root.pxRegion || { maxY: 200 };
|
|
386
|
+
const inRegion = (el) => {
|
|
387
|
+
const reg = root.pxRegion || {};
|
|
388
|
+
if (reg.sel && !el.closest(reg.sel)) return false;
|
|
389
|
+
const r = el.getBoundingClientRect();
|
|
390
|
+
if (reg.maxY != null && r.top > reg.maxY) return false;
|
|
391
|
+
if (reg.minY != null && r.bottom < reg.minY) return false;
|
|
392
|
+
return r.width > 0;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// smallest element whose OWN text matches re, WITHIN the active region
|
|
396
|
+
// (avoids matching ancestors and same-text elements elsewhere on the page)
|
|
397
|
+
const byText = (re) => {
|
|
398
|
+
const hits = [...document.querySelectorAll("a,button,span,p,li,h1,h2,h3,h4,div,sup,small,strong")]
|
|
399
|
+
.filter((e) => re.test(ownText(e)) && inRegion(e));
|
|
400
|
+
return hits.sort((a, b) => ownText(a).length - ownText(b).length)[0] || null;
|
|
401
|
+
};
|
|
402
|
+
const byAria = (re) =>
|
|
403
|
+
[...document.querySelectorAll("[aria-label]")].find((e) => re.test(e.getAttribute("aria-label")) && inRegion(e)) ||
|
|
404
|
+
null;
|
|
405
|
+
// leftmost icon-sized control in the right half of the header band — used as a
|
|
406
|
+
// label-agnostic fallback (e.g. a search icon that carries no aria-label).
|
|
407
|
+
const leftmostRightIcon = () =>
|
|
408
|
+
[...document.querySelectorAll("a,button")]
|
|
409
|
+
.filter((e) => {
|
|
410
|
+
const r = e.getBoundingClientRect();
|
|
411
|
+
return inRegion(e) && r.x > window.innerWidth / 2 && r.width > 10 && r.width < 40 && r.height > 10 && r.height < 40;
|
|
412
|
+
})
|
|
413
|
+
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x)[0] || null;
|
|
414
|
+
|
|
415
|
+
// The STRUT of the line box that positions the glyphs: the nearest line-box
|
|
416
|
+
// CONTAINER's line-height. A leaf's own line-height can match live exactly
|
|
417
|
+
// (12 vs 12) while the container differs (authored 16px vs `normal`) — the
|
|
418
|
+
// glyphs then land lower/higher, sub-tolerance on the capture machine but
|
|
419
|
+
// visibly off on platforms where `normal` resolves differently (the HN header
|
|
420
|
+
// miss — LEARNINGS #17). `normal` vs a number is a TECHNIQUE mismatch, so it
|
|
421
|
+
// is kept as the string "normal", never coerced — the compare fails loudly.
|
|
422
|
+
const strutOf = (el) => {
|
|
423
|
+
let e = el, hops = 0;
|
|
424
|
+
while (e && hops < 8) {
|
|
425
|
+
const s = getComputedStyle(e);
|
|
426
|
+
if (/^(block|table-cell|list-item|flex|grid|inline-block|table-caption)$/.test(s.display)) {
|
|
427
|
+
return s.lineHeight === "normal" ? "normal" : num(s.lineHeight);
|
|
428
|
+
}
|
|
429
|
+
e = e.parentElement; hops++;
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// glyph box of an element's own text via Range (ignores padding) — this is
|
|
435
|
+
// what actually aligns visually, unlike getBoundingClientRect on the box.
|
|
436
|
+
const textBox = (el) => {
|
|
437
|
+
const tn = [...el.childNodes].find((n) => n.nodeType === 3 && n.textContent.trim());
|
|
438
|
+
if (!tn) return null;
|
|
439
|
+
const r = document.createRange();
|
|
440
|
+
r.selectNodeContents(tn);
|
|
441
|
+
const b = r.getBoundingClientRect();
|
|
442
|
+
return { x: num(b.x), right: num(b.right), top: num(b.top), bottom: num(b.bottom), w: num(b.width), h: num(b.height) };
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// An underline is a painted mark with a BOX — measure it like one (thickness,
|
|
446
|
+
// x/right/width, y top/bottom), not as a boolean. A boolean "has underline: true"
|
|
447
|
+
// passed on both sides while the live line was 2px and mine 1px, spanned the
|
|
448
|
+
// icon+text (211px) not just the text (185px), and sat at a different Y — three
|
|
449
|
+
// defects hidden behind one green flag (LEARNINGS #12). The mark is often drawn by
|
|
450
|
+
// an ANCESTOR or a sibling, so we resolve WHICH element paints it, then return its
|
|
451
|
+
// box. Detection order (aloyoga uses a `border-bottom` on an ancestor group):
|
|
452
|
+
// (a) `text-decoration-line: underline` on the element or a near ancestor,
|
|
453
|
+
// (b) a `border-bottom` on the element or a near ancestor (a short wrapper),
|
|
454
|
+
// (c) a thin horizontal rule (an <hr>/absolute <span>, by border or by height)
|
|
455
|
+
// painted just below the text, within the nearest link/li.
|
|
456
|
+
// Returns { present, thickness, x, right, w, top, bottom } (top/bottom null for a
|
|
457
|
+
// text-decoration underline, whose rect isn't directly queryable). All geometry —
|
|
458
|
+
// so `--visual` and `--inspect` compare where the line actually paints.
|
|
459
|
+
const NO_UL = { present: false };
|
|
460
|
+
const underlineBox = (el) => {
|
|
461
|
+
let e = el, hops = 0;
|
|
462
|
+
while (e && hops < 4) {
|
|
463
|
+
const s = getComputedStyle(e);
|
|
464
|
+
if ((s.textDecorationLine || "").includes("underline")) {
|
|
465
|
+
const tb = textBox(el) || {};
|
|
466
|
+
const th = s.textDecorationThickness && s.textDecorationThickness !== "auto"
|
|
467
|
+
? num(parseFloat(s.textDecorationThickness)) : "auto";
|
|
468
|
+
return { present: true, thickness: th, x: tb.x ?? null, right: tb.right ?? null, w: tb.w ?? null, top: null, bottom: null };
|
|
469
|
+
}
|
|
470
|
+
const bbw = parseFloat(s.borderBottomWidth) || 0;
|
|
471
|
+
const r = e.getBoundingClientRect();
|
|
472
|
+
if (bbw > 0 && s.borderBottomStyle !== "none" && r.height < 60)
|
|
473
|
+
return { present: true, thickness: num(bbw), x: num(r.x), right: num(r.right), w: num(r.width), top: num(r.bottom - bbw), bottom: num(r.bottom) };
|
|
474
|
+
e = e.parentElement; hops++;
|
|
475
|
+
}
|
|
476
|
+
const tb = textBox(el);
|
|
477
|
+
const base = el.closest("a,li") || el.parentElement;
|
|
478
|
+
if (tb && base) {
|
|
479
|
+
for (const cand of base.querySelectorAll("*")) {
|
|
480
|
+
if (cand === el || cand.contains(el)) continue;
|
|
481
|
+
const cs = getComputedStyle(cand);
|
|
482
|
+
const r = cand.getBoundingClientRect();
|
|
483
|
+
const bbw = parseFloat(cs.borderBottomWidth) || 0;
|
|
484
|
+
const isBorder = bbw > 0 && cs.borderBottomStyle !== "none";
|
|
485
|
+
const isFill = r.height <= 4;
|
|
486
|
+
if ((isBorder || isFill) && r.width >= tb.w * 0.4 && r.bottom >= tb.bottom - 1 && r.bottom <= tb.bottom + 8) {
|
|
487
|
+
const th = isBorder ? bbw : r.height;
|
|
488
|
+
return { present: true, thickness: num(th), x: num(r.x), right: num(r.right), w: num(r.width), top: num(r.bottom - th), bottom: num(r.bottom) };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return NO_UL;
|
|
493
|
+
};
|
|
494
|
+
const underlineOf = (el) => underlineBox(el).present; // boolean shorthand
|
|
495
|
+
|
|
496
|
+
// The PAINTED mark of a graphic (icon/logo), NOT its wrapper box. The wrapper
|
|
497
|
+
// often lies: live top-aligns a 20px glyph inside a 24.5px control, so the
|
|
498
|
+
// control centers at 91.25 while the visible glyph centers at 89. Measuring
|
|
499
|
+
// the wrapper reports "aligned" while the pixels are 2.25px off — the exact
|
|
500
|
+
// miss this guards against. Resolution order:
|
|
501
|
+
// 1) inline <svg> → union of its shape bboxes (the actual drawn pixels)
|
|
502
|
+
// 2) background-image → the DEEPEST element carrying it (a wrapper may also
|
|
503
|
+
// have one), plus its background-position/size (top-aligned vs centered
|
|
504
|
+
// changes the pixels)
|
|
505
|
+
// 3) fallback → the element's own box
|
|
506
|
+
// The painted BACKDROP behind an element — a solid `background-color` is a
|
|
507
|
+
// painted mark too (an announcement bar, a button, a badge), but it lives on a
|
|
508
|
+
// CONTAINER, not on the text/icon leaf, so the per-target capture never saw it:
|
|
509
|
+
// a bright-red bar passed a green `--visual` because the colour was nowhere in
|
|
510
|
+
// the schema (the aloyoga miss — DEVELOP meta-loop). Walk self→ancestors and
|
|
511
|
+
// return the first non-transparent background-color (the colour actually painted
|
|
512
|
+
// behind this mark), capped so we stay within the section. Compared by --visual,
|
|
513
|
+
// so a wrong bar/button colour fails on run one — the LEARNINGS #10 lesson
|
|
514
|
+
// (invisible blue-on-blue text) generalised from the glyph to its backdrop.
|
|
515
|
+
const TRANSPARENT_BG = "rgba(0, 0, 0, 0)";
|
|
516
|
+
const isTransparent = (bc) => !bc || bc === TRANSPARENT_BG || bc === "transparent" || /,\s*0\)\s*$/.test(bc);
|
|
517
|
+
const paintedBg = (el) => {
|
|
518
|
+
// Walk self→ancestors to the ROOT for the first opaque background-color. If the
|
|
519
|
+
// whole chain is transparent, the visible colour is the CANVAS default — white,
|
|
520
|
+
// unless html/body paint one (found in the walk). Resolving to the canvas is what
|
|
521
|
+
// makes a clone's explicit `body{background:#fff}` compare EQUAL to a live site
|
|
522
|
+
// that leaves the canvas transparent — same painted pixels, same value.
|
|
523
|
+
let e = el, hops = 0;
|
|
524
|
+
while (e && hops < 40) {
|
|
525
|
+
const bc = getComputedStyle(e).backgroundColor;
|
|
526
|
+
if (!isTransparent(bc)) return bc;
|
|
527
|
+
e = e.parentElement; hops++;
|
|
528
|
+
}
|
|
529
|
+
return "rgb(255, 255, 255)";
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const depth = (e) => { let d = 0; while ((e = e.parentElement)) d++; return d; };
|
|
533
|
+
const glyphBox = (el) => {
|
|
534
|
+
const wrap = (b, extra) => ({
|
|
535
|
+
cx: num(b.left + b.width / 2), cy: num(b.top + b.height / 2),
|
|
536
|
+
top: num(b.top), bottom: num(b.bottom), w: num(b.width), h: num(b.height),
|
|
537
|
+
...extra,
|
|
538
|
+
});
|
|
539
|
+
const svg = el.tagName.toLowerCase() === "svg" ? el : el.querySelector("svg");
|
|
540
|
+
if (svg) {
|
|
541
|
+
const shapes = [...svg.querySelectorAll("path,circle,rect,polygon,polyline,line,ellipse,use")];
|
|
542
|
+
let t = Infinity, l = Infinity, rr = -Infinity, bb = -Infinity;
|
|
543
|
+
for (const s of shapes) { const r = s.getBoundingClientRect(); if (r.width || r.height) { t = Math.min(t, r.top); l = Math.min(l, r.left); rr = Math.max(rr, r.right); bb = Math.max(bb, r.bottom); } }
|
|
544
|
+
if (isFinite(t)) return wrap({ left: l, top: t, width: rr - l, height: bb - t }, { src: "svg-path" });
|
|
545
|
+
return wrap(svg.getBoundingClientRect(), { src: "svg" });
|
|
546
|
+
}
|
|
547
|
+
// deepest-first: a background on the glyph itself beats one on a wrapper.
|
|
548
|
+
const withBg = [...el.querySelectorAll("*"), el].filter((e) => getComputedStyle(e).backgroundImage !== "none");
|
|
549
|
+
const bgEl = withBg.sort((a, b) => depth(b) - depth(a))[0];
|
|
550
|
+
if (bgEl) {
|
|
551
|
+
const bc = getComputedStyle(bgEl);
|
|
552
|
+
return wrap(bgEl.getBoundingClientRect(), { src: "bg", bgPos: bc.backgroundPosition, bgSize: bc.backgroundSize });
|
|
553
|
+
}
|
|
554
|
+
return wrap(el.getBoundingClientRect(), { src: "box" });
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// FULL measurement of one element — every property that can shift a pixel.
|
|
558
|
+
function measure(el, want) {
|
|
559
|
+
if (!el) return { present: false };
|
|
560
|
+
const r = el.getBoundingClientRect();
|
|
561
|
+
const c = getComputedStyle(el);
|
|
562
|
+
const vw = window.innerWidth;
|
|
563
|
+
const parent = el.parentElement;
|
|
564
|
+
const pc = parent ? getComputedStyle(parent) : null;
|
|
565
|
+
const prev = el.previousElementSibling;
|
|
566
|
+
const out = {
|
|
567
|
+
present: true,
|
|
568
|
+
rect: {
|
|
569
|
+
x: num(r.x),
|
|
570
|
+
y: num(r.y),
|
|
571
|
+
w: num(r.width),
|
|
572
|
+
h: num(r.height),
|
|
573
|
+
top: num(r.top),
|
|
574
|
+
right: num(r.right),
|
|
575
|
+
bottom: num(r.bottom),
|
|
576
|
+
fromRight: num(vw - r.right), // for right-anchored elements
|
|
577
|
+
},
|
|
578
|
+
font: {
|
|
579
|
+
family: (c.fontFamily || "").split(",")[0].replace(/["']/g, "").trim(),
|
|
580
|
+
weight: c.fontWeight,
|
|
581
|
+
size: num(c.fontSize),
|
|
582
|
+
line: c.lineHeight === "normal" ? "normal" : num(c.lineHeight), // ← the one people skip
|
|
583
|
+
spacing: c.letterSpacing === "normal" ? "normal" : num(c.letterSpacing),
|
|
584
|
+
transform: c.textTransform,
|
|
585
|
+
color: c.color,
|
|
586
|
+
decoration: c.textDecorationLine || "none", // underline/line-through/none
|
|
587
|
+
smoothing: c.webkitFontSmoothing || c.getPropertyValue("-webkit-font-smoothing") || "auto", // antialiased vs auto → perceived weight
|
|
588
|
+
},
|
|
589
|
+
box: {
|
|
590
|
+
padT: num(c.paddingTop), padR: num(c.paddingRight), padB: num(c.paddingBottom), padL: num(c.paddingLeft),
|
|
591
|
+
marT: num(c.marginTop), marR: num(c.marginRight), marB: num(c.marginBottom), marL: num(c.marginLeft),
|
|
592
|
+
bT: num(c.borderTopWidth), bR: num(c.borderRightWidth), bB: num(c.borderBottomWidth), bL: num(c.borderLeftWidth),
|
|
593
|
+
sizing: c.boxSizing,
|
|
594
|
+
},
|
|
595
|
+
layout: {
|
|
596
|
+
display: c.display,
|
|
597
|
+
position: c.position,
|
|
598
|
+
top: c.top === "auto" ? "auto" : num(c.top),
|
|
599
|
+
left: c.left === "auto" ? "auto" : num(c.left),
|
|
600
|
+
vAlign: c.verticalAlign,
|
|
601
|
+
},
|
|
602
|
+
parent: pc ? { display: pc.display, gap: pc.gap === "normal" ? 0 : num(pc.gap) } : null,
|
|
603
|
+
// the colour actually painted behind this mark (self or nearest painted
|
|
604
|
+
// ancestor) — so a wrong announcement-bar / button / badge colour is caught
|
|
605
|
+
bg: paintedBg(el),
|
|
606
|
+
};
|
|
607
|
+
if (want && want.text) {
|
|
608
|
+
out.text = textBox(el);
|
|
609
|
+
out.font.strut = strutOf(el); // the line-box container's line-height (LEARNINGS #17)
|
|
610
|
+
out.underline = underlineBox(el); // the underline as a painted BOX
|
|
611
|
+
out.font.underline = out.underline.present; // boolean shorthand (back-compat)
|
|
612
|
+
// relation to previous sibling (catches wrong gaps between nav items)
|
|
613
|
+
if (prev) out.rect.prevGap = num(r.left - prev.getBoundingClientRect().right);
|
|
614
|
+
} else {
|
|
615
|
+
// graphic: measure the painted mark, not just the wrapper box
|
|
616
|
+
out.glyph = glyphBox(el);
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ----- default TARGETS — REPLACE THESE with your own page's elements -----
|
|
622
|
+
// Each entry is [name, () => findElement(), measureTextBox?].
|
|
623
|
+
// • measureTextBox = true → text element: measures the text-glyph box (Range)
|
|
624
|
+
// + font metrics (incl. color, line, spacing, underline)
|
|
625
|
+
// • measureTextBox = false → graphic (icon/logo): measures the PAINTED glyph
|
|
626
|
+
// (SVG bbox, or bg element + background-position)
|
|
627
|
+
// Finders resolve by TEXT / ROLE / aria-label / selector — never by class name —
|
|
628
|
+
// so the SAME target resolves on both the live DOM and your clone. Scope them
|
|
629
|
+
// with pxRegion (default: top 200px) so the same text elsewhere can't win.
|
|
630
|
+
// See PLAYBOOK.md → "Step 5: close coverage" to build this list completely.
|
|
631
|
+
const TARGETS = [
|
|
632
|
+
// examples — delete and write your own:
|
|
633
|
+
["logo", () => document.querySelector("header svg, nav svg, [class*=logo] svg"), false],
|
|
634
|
+
["nav_first", () => byText(/^(home|shop|products|women)$/i), true],
|
|
635
|
+
["cart_icon", () => byAria(/cart|bag|basket/i) || leftmostRightIcon(), false],
|
|
636
|
+
];
|
|
637
|
+
|
|
638
|
+
function capture(targets, opts) {
|
|
639
|
+
const T = targets || (root.pxTargets = root.pxTargets || TARGETS);
|
|
640
|
+
const elements = {};
|
|
641
|
+
for (const [name, find, text] of T) {
|
|
642
|
+
let el = null;
|
|
643
|
+
try { el = find(); } catch (e) { /* finder threw → treat as absent */ }
|
|
644
|
+
elements[name] = measure(el, { text });
|
|
645
|
+
}
|
|
646
|
+
const snap = {
|
|
647
|
+
url: location.href,
|
|
648
|
+
viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio },
|
|
649
|
+
// rendering mode (quirks "BackCompat" vs standards "CSS1Compat") — pixel-determining
|
|
650
|
+
// for the whole page; kept schema-identical with browser-capture.js (LEARNINGS #18)
|
|
651
|
+
mode: document.compatMode,
|
|
652
|
+
elements,
|
|
653
|
+
};
|
|
654
|
+
// compact for machine export (smaller → fewer chunk reads); pretty for humans
|
|
655
|
+
return opts && opts.compact ? JSON.stringify(snap) : JSON.stringify(snap, null, 2);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ----- single-element inspector (the "human points → measure it" path) -----
|
|
659
|
+
// Resolve ONE element the human flagged — by visible text, aria-label, CSS
|
|
660
|
+
// selector, or a click coordinate — then dump its rect, painted marks, AND its
|
|
661
|
+
// FULL computed style. Same call on live and clone; diff the two with
|
|
662
|
+
// `inspectDiff` (node --inspect) to get every real difference, ranked
|
|
663
|
+
// paint-first. resolver: {text:"sign in"} | {aria:/cart/} | {sel:".x"} |
|
|
664
|
+
// {at:[x,y]} | a function returning an element.
|
|
665
|
+
function resolveOne(resolver) {
|
|
666
|
+
if (typeof resolver === "function") return resolver() || null;
|
|
667
|
+
const rq = resolver || {};
|
|
668
|
+
if (rq.sel) return document.querySelector(rq.sel);
|
|
669
|
+
if (rq.at) return document.elementFromPoint(rq.at[0], rq.at[1]);
|
|
670
|
+
if (rq.aria) { const re = rq.aria instanceof RegExp ? rq.aria : new RegExp(rq.aria, "i"); return byAria(re); }
|
|
671
|
+
if (rq.text) {
|
|
672
|
+
const re = rq.text instanceof RegExp ? rq.text : new RegExp(rq.text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
673
|
+
// smallest element whose OWN text matches, in region (same rule as byText)
|
|
674
|
+
return byText(re);
|
|
675
|
+
}
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function inspect(resolver, opts) {
|
|
680
|
+
const el = resolveOne(resolver);
|
|
681
|
+
if (!el) return JSON.stringify({ present: false, resolver: String(resolver && (resolver.text || resolver.aria || resolver.sel || resolver.at)) });
|
|
682
|
+
const c = getComputedStyle(el), r = el.getBoundingClientRect();
|
|
683
|
+
const style = {};
|
|
684
|
+
for (let i = 0; i < c.length; i++) { const p = c[i]; style[p] = c.getPropertyValue(p); }
|
|
685
|
+
const hasGraphic = el.tagName.toLowerCase() === "svg" || el.querySelector("svg") ||
|
|
686
|
+
[...el.querySelectorAll("*"), el].some((e) => getComputedStyle(e).backgroundImage !== "none");
|
|
687
|
+
const snap = {
|
|
688
|
+
present: true,
|
|
689
|
+
tag: el.tagName.toLowerCase(),
|
|
690
|
+
cls: (el.className && el.className.toString ? el.className.toString() : "").slice(0, 60),
|
|
691
|
+
rect: { x: num(r.x), y: num(r.y), w: num(r.width), h: num(r.height), top: num(r.top), right: num(r.right), bottom: num(r.bottom), fromRight: num(window.innerWidth - r.right) },
|
|
692
|
+
text: textBox(el),
|
|
693
|
+
glyph: hasGraphic ? glyphBox(el) : null,
|
|
694
|
+
underline: underlineBox(el), // the underline as a painted box (thickness/width/y), not a boolean
|
|
695
|
+
style,
|
|
696
|
+
};
|
|
697
|
+
return opts && opts.compact ? JSON.stringify(snap) : JSON.stringify(snap, null, 2);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return { capture, measure, inspect, resolveOne, byText, byAria, TARGETS };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ===========================================================================
|
|
704
|
+
// 3. WIRE UP per environment
|
|
705
|
+
// ===========================================================================
|
|
706
|
+
|
|
707
|
+
if (typeof window !== "undefined" && window.document) {
|
|
708
|
+
const api = buildBrowserApi();
|
|
709
|
+
root.pxCapture = api.capture; // -> JSON string snapshot (copy() it)
|
|
710
|
+
root.pxTargets = api.TARGETS; // editable array of [name, finder, measureText]
|
|
711
|
+
root.pxMeasure = api.measure; // measure one element ad hoc
|
|
712
|
+
root.pxByText = api.byText;
|
|
713
|
+
root.pxInspect = api.inspect; // full-computed-style dump of ONE flagged element
|
|
714
|
+
root.pxDiff = function (live, clone, opts) {
|
|
715
|
+
const a = typeof live === "string" ? JSON.parse(live) : live;
|
|
716
|
+
const b = typeof clone === "string" ? JSON.parse(clone) : clone;
|
|
717
|
+
const res = diffSnapshots(a, b, opts);
|
|
718
|
+
console.log(formatDiff(res));
|
|
719
|
+
return res;
|
|
720
|
+
};
|
|
721
|
+
// Diff two pxInspect dumps in the browser (paint-first). For the human-flagged
|
|
722
|
+
// element: pxInspect on live + clone, then pxInspectDiff(liveDump, cloneDump).
|
|
723
|
+
root.pxInspectDiff = function (a, b, opts) {
|
|
724
|
+
const A = typeof a === "string" ? JSON.parse(a) : a;
|
|
725
|
+
const B = typeof b === "string" ? JSON.parse(b) : b;
|
|
726
|
+
const res = inspectDiff(A, B, opts);
|
|
727
|
+
console.log(formatInspect(res));
|
|
728
|
+
return res;
|
|
729
|
+
};
|
|
730
|
+
// Compact stash/read of a single-element inspect dump (CSP-proof, like pxStash).
|
|
731
|
+
root.pxStashInspect = function (resolver, chunk) {
|
|
732
|
+
return root.pxStash(null, chunk, api.inspect(resolver, { compact: true }));
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
// --- CSP-proof export for browser AUTOMATION (no clipboard, no network) ---
|
|
736
|
+
// On a strict-CSP third-party site, `copy()` doesn't exist, the Clipboard API
|
|
737
|
+
// needs focus, and `fetch` to localhost is blocked by connect-src. So instead
|
|
738
|
+
// stash the snapshot in a hidden <textarea> in the page itself, then read it
|
|
739
|
+
// back in bounded slices with follow-up evaluate calls.
|
|
740
|
+
// 1) pxStash(targets, chunk?) → writes snapshot to #__pixeldiff, returns {bytes,chunks,chunkSize}
|
|
741
|
+
// 2) pxRead(i) → returns the i-th chunk (sized to fit one result)
|
|
742
|
+
// Reassemble the chunks on your side and save as live.json / clone.json.
|
|
743
|
+
//
|
|
744
|
+
// chunkSize must be SMALLER than the per-result cap of whatever is reading the
|
|
745
|
+
// value. A human in the DevTools console has no cap → pass a big number (or
|
|
746
|
+
// just use copy(pxCapture())). A browser-automation harness typically truncates
|
|
747
|
+
// each result at ~1–2KB → keep the default 1000. Pass an explicit size if your
|
|
748
|
+
// harness differs; pxStash remembers it so pxRead uses the same slicing.
|
|
749
|
+
// One call, same-origin (or any origin whose CSP allows connect to `url`):
|
|
750
|
+
// capture + POST in a single round-trip. Fastest path when not CSP-blocked —
|
|
751
|
+
// use it for the clone (localhost) and skip stash/read entirely.
|
|
752
|
+
// pxSend("http://localhost:7799/clone.json") → returns the sink's reply
|
|
753
|
+
root.pxSend = function (url, targets) {
|
|
754
|
+
return fetch(url, { method: "POST", body: api.capture(targets, { compact: true }) }).then((r) => r.text());
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
const DEFAULT_CHUNK = 1000;
|
|
758
|
+
root.pxStash = function (targets, chunk, preJson) {
|
|
759
|
+
// preJson lets callers (e.g. pxStashInspect) stash an already-built payload
|
|
760
|
+
const json = preJson != null ? preJson : api.capture(targets, { compact: true }); // compact → ~40% fewer chunks
|
|
761
|
+
const size = chunk || DEFAULT_CHUNK;
|
|
762
|
+
let ta = document.getElementById("__pixeldiff");
|
|
763
|
+
if (!ta) {
|
|
764
|
+
ta = document.createElement("textarea");
|
|
765
|
+
ta.id = "__pixeldiff";
|
|
766
|
+
ta.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px";
|
|
767
|
+
document.body.appendChild(ta);
|
|
768
|
+
}
|
|
769
|
+
ta.value = json;
|
|
770
|
+
ta.dataset.chunk = size; // remember slicing so pxRead matches
|
|
771
|
+
return { bytes: json.length, chunks: Math.ceil(json.length / size), chunkSize: size };
|
|
772
|
+
};
|
|
773
|
+
root.pxRead = function (i) {
|
|
774
|
+
const ta = document.getElementById("__pixeldiff");
|
|
775
|
+
if (!ta) return null;
|
|
776
|
+
const size = Number(ta.dataset.chunk) || DEFAULT_CHUNK;
|
|
777
|
+
return ta.value.slice(i * size, (i + 1) * size);
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
console.log(
|
|
781
|
+
"%cpixel-diff loaded.",
|
|
782
|
+
"font-weight:bold",
|
|
783
|
+
"\n console: copy(pxCapture()) snapshot this page" +
|
|
784
|
+
"\n same-origin: pxSend(sinkUrl) 1-call capture+POST (fastest)" +
|
|
785
|
+
"\n CSP/live: pxStash() then pxRead(0..n) inject once, batch the reads" +
|
|
786
|
+
"\n diff: pxDiff(liveObj, cloneObj) add {visual:true} for pixels-only" +
|
|
787
|
+
"\n edit pxTargets to add elements; pxRegion to scope the search"
|
|
788
|
+
);
|
|
789
|
+
} else if (typeof module !== "undefined" && module.exports) {
|
|
790
|
+
module.exports = { diffSnapshots, formatDiff, flatten, inspectDiff, formatInspect };
|
|
791
|
+
if (require.main === module) {
|
|
792
|
+
const fs = require("fs");
|
|
793
|
+
// Parse args properly: --tol consumes the NEXT token as its value (the old
|
|
794
|
+
// `filter(!startsWith("--"))` counted that value as a file, so `--tol 0.5` broke
|
|
795
|
+
// and printed usage). Unknown --flags are rejected, not silently ignored.
|
|
796
|
+
const argv = process.argv.slice(2);
|
|
797
|
+
const files = [];
|
|
798
|
+
let tol = DEFAULT_TOL, tolGiven = false, tolRaw;
|
|
799
|
+
for (let i = 0; i < argv.length; i++) {
|
|
800
|
+
const a = argv[i];
|
|
801
|
+
if (a === "--tol") { tolRaw = argv[++i]; tol = parseFloat(tolRaw); tolGiven = true; }
|
|
802
|
+
else if (a === "--all" || a === "--visual" || a === "--inspect") { /* boolean flag */ }
|
|
803
|
+
else if (a.startsWith("--")) { console.error(`unknown flag "${a}". valid: --visual --inspect --all --tol <px>`); process.exit(2); }
|
|
804
|
+
else files.push(a);
|
|
805
|
+
}
|
|
806
|
+
const opts = { all: argv.includes("--all"), visual: argv.includes("--visual"), tol };
|
|
807
|
+
const inspect = argv.includes("--inspect");
|
|
808
|
+
const USAGE =
|
|
809
|
+
"usage: node tools/pixel-diff.js <live.json> <clone.json> [--tol 0.5] [--all] [--visual]\n" +
|
|
810
|
+
" node tools/pixel-diff.js --inspect <live-el.json> <clone-el.json> [--tol] [--all]\n" +
|
|
811
|
+
" --visual compare only pixel-determining props (ignore structural CSS differences)\n" +
|
|
812
|
+
" --inspect diff two single-element pxInspect() dumps, ranked paint-first";
|
|
813
|
+
// --- input validation with self-describing, actionable errors (exit 2 = bad input) ---
|
|
814
|
+
if (files.length !== 2) {
|
|
815
|
+
console.error(`expected 2 snapshot files, got ${files.length}${files.length ? " (" + files.join(", ") + ")" : ""}.\n` + USAGE);
|
|
816
|
+
process.exit(2);
|
|
817
|
+
}
|
|
818
|
+
if (tolGiven && !(isFinite(tol) && tol >= 0)) {
|
|
819
|
+
console.error(`--tol needs a non-negative number (got "${tolRaw}").`);
|
|
820
|
+
process.exit(2);
|
|
821
|
+
}
|
|
822
|
+
const load = (f) => {
|
|
823
|
+
if (!fs.existsSync(f)) { console.error(`${f} not found — capture it first (see tools/RUNBOOK.md). Both a live and a clone snapshot are required.`); process.exit(2); }
|
|
824
|
+
let txt; try { txt = fs.readFileSync(f, "utf8"); } catch (e) { console.error(`could not read ${f}: ${e.message}`); process.exit(2); }
|
|
825
|
+
if (/^\s*\[BLOCKED/.test(txt)) { console.error(`${f} holds a "[BLOCKED…]" automation sentinel, not a snapshot — the capture was blocked. Re-capture via the sink/stash path (RUNBOOK).`); process.exit(2); }
|
|
826
|
+
try { return JSON.parse(txt); } catch (e) { console.error(`${f} is not valid JSON: ${e.message}. Re-capture it (a truncated or partial paste is the usual cause).`); process.exit(2); }
|
|
827
|
+
};
|
|
828
|
+
const a = load(files[0]);
|
|
829
|
+
const b = load(files[1]);
|
|
830
|
+
if (!inspect) {
|
|
831
|
+
// a full-snapshot diff needs .elements on both; a single-element pxInspect() dump here is a mix-up.
|
|
832
|
+
for (const [f, s] of [[files[0], a], [files[1], b]])
|
|
833
|
+
if (!s || !s.elements) { console.error(`${f} has no "elements" — is it a pxCapture() snapshot? (a pxInspect() dump goes with --inspect.)`); process.exit(2); }
|
|
834
|
+
}
|
|
835
|
+
if (inspect) {
|
|
836
|
+
const res = inspectDiff(a, b, opts);
|
|
837
|
+
console.log(formatInspect(res));
|
|
838
|
+
process.exit(res.ok ? 0 : 1); // "ok" = zero PAINT differences
|
|
839
|
+
}
|
|
840
|
+
const res = diffSnapshots(a, b, opts);
|
|
841
|
+
console.log(formatDiff(res));
|
|
842
|
+
process.exit(res.ok ? 0 : 1); // CI gate: 0 pass, 1 diff failure, 2 bad input
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
})(typeof window !== "undefined" ? window : globalThis);
|