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.
Files changed (53) hide show
  1. package/CLONE-ALOYOGA-HEADER.md +76 -0
  2. package/CLONE-ANY-HEADER.md +106 -0
  3. package/CLONE-ANY-SITE.md +120 -0
  4. package/DEVELOP.md +144 -0
  5. package/LAUNCH-PROMPT.md +66 -0
  6. package/LEARNINGS.md +398 -0
  7. package/LICENSE +21 -0
  8. package/PLAYBOOK.md +260 -0
  9. package/README.md +219 -0
  10. package/WORKFLOW.md +257 -0
  11. package/bin/ppk +4 -0
  12. package/harness/adopt.js +52 -0
  13. package/harness/agent-setup.js +55 -0
  14. package/harness/behavior-selftest.js +243 -0
  15. package/harness/benchmarks/README.md +34 -0
  16. package/harness/benchmarks/battery.js +86 -0
  17. package/harness/benchmarks/detection-power.js +75 -0
  18. package/harness/capture-build-selftest.js +134 -0
  19. package/harness/capture-build.js +323 -0
  20. package/harness/doctor-selftest.js +58 -0
  21. package/harness/doctor.js +85 -0
  22. package/harness/fixtures/01-backdrop-color.js +51 -0
  23. package/harness/fixtures/02-line-strut.js +53 -0
  24. package/harness/fixtures/03-compat-mode.js +44 -0
  25. package/harness/fixtures/README.md +31 -0
  26. package/harness/human-qa-selftest.js +201 -0
  27. package/harness/human-qa.js +406 -0
  28. package/harness/merge-snapshot-selftest.js +56 -0
  29. package/harness/new-target.js +101 -0
  30. package/harness/regression.js +50 -0
  31. package/harness/score.js +58 -0
  32. package/harness/serve.js +149 -0
  33. package/harness/setup-selftest.js +93 -0
  34. package/harness/setup.js +158 -0
  35. package/harness/tunnel-selftest.js +76 -0
  36. package/harness/tunnel.js +241 -0
  37. package/harness/workflow-selftest.js +211 -0
  38. package/harness/workflow.js +739 -0
  39. package/package.json +40 -0
  40. package/skill/ditto-finish/SKILL.md +52 -0
  41. package/skill/pixel-perfect-clone/SKILL.md +49 -0
  42. package/tools/RUNBOOK.md +228 -0
  43. package/tools/behavior-capture.js +307 -0
  44. package/tools/behavior-worksheet.js +82 -0
  45. package/tools/browser-capture.js +240 -0
  46. package/tools/cli-selftest.js +136 -0
  47. package/tools/extract-fonts.js +133 -0
  48. package/tools/extract-icons.js +255 -0
  49. package/tools/merge-snapshot.js +56 -0
  50. package/tools/pixel-diff.js +845 -0
  51. package/tools/reassemble.js +63 -0
  52. package/tools/selftest.js +67 -0
  53. 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);