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
package/LEARNINGS.md ADDED
@@ -0,0 +1,398 @@
1
+ # LEARNINGS — the failure catalog (read before trusting any "match")
2
+
3
+ Every rule here was paid for by a real miss while building pixel-perfect clones. They
4
+ are all variants of one meta-lesson:
5
+
6
+ > **Measure the *visible mark*, not the wrapper — and measure it *completely*.**
7
+
8
+ A container box is a valid stand-in for what's drawn only when the content fills it
9
+ and is centered in it. Padding, line-height, a full-width bar, a tall hit-area, a
10
+ top-aligned glyph, an ancestor's border, an inherited colour — every one of these
11
+ breaks that assumption, and every time someone trusted the wrapper (or a hand-picked
12
+ subset of properties), a real pixel difference hid behind a number that said "match."
13
+
14
+ The fix is always the same shape: measure the thing that actually renders (glyph box
15
+ via `Range`; painted glyph via SVG bbox or background element + position; colour and
16
+ underline as painted marks), compare **all** of its properties numerically, and treat
17
+ any delta over tolerance as a defect to fix or explain — never eyeball a screenshot
18
+ to confirm.
19
+
20
+ ---
21
+
22
+ ## 1. Text shifted, but the box edges matched
23
+ Nav labels looked right of live. Each item had horizontal padding with boxes butted
24
+ together, so the box *left edges* matched while the **text** rendered 15px right.
25
+ **Lesson:** measure the **text position** (a `Range` over the text node), not the
26
+ element's box edges. Matching boxes ≠ matching glyphs.
27
+
28
+ ## 2. A collapsed line-height (spot-check lied)
29
+ A badge passed an x/y/size/weight check but was visibly off: `leading-none` forced a
30
+ 12px line-box vs the live 14.5px, lifting the glyphs ~1.25px.
31
+ **Lesson:** measure the **complete box**, and treat `line-height` as mandatory — it
32
+ decides where glyphs land inside the line box. A subset spot-check that agrees is not
33
+ a match.
34
+
35
+ ## 3. Verification by eyeball → numeric PASS/FAIL
36
+ "Looks close" kept passing wrong things. The fix was a tool that captures the complete
37
+ box of each target and exits non-zero on any delta.
38
+ **Lesson:** "pixel-perfect" must be a **command that exits 0**, not a judgement.
39
+ Screenshots only decide *what* to measure.
40
+
41
+ ## 4. Getting data off a strict-CSP live site
42
+ `fetch(...).then(eval)` **hung to a 45s timeout**; `copy()` was undefined; clipboard
43
+ needed focus. The CSP blocks runtime `eval` (`script-src`) and *sometimes* cross-origin
44
+ `fetch` (`connect-src`). **Measurement is never blocked** (`getComputedStyle`/`Range`
45
+ always work) — only **delivery** is.
46
+ **Lesson:** inject the script **source directly** (bypasses `script-src`), then
47
+ separate "can I measure" (always yes) from "can I get the bytes out" (the real problem).
48
+
49
+ **Amendment (paid for again on aloyoga, ~6 min):** *don't assume delivery is blocked —
50
+ **probe it first.*** A direct `fetch` POST to `http://localhost:7799` **succeeded** on
51
+ aloyoga; `connect-src` was looser than this note implied. Assuming it was blocked (per
52
+ the original wording above) triggered a needless 9-chunk stash/read reassembly. So:
53
+ FIRST try `pxSend('http://localhost:7799/live.json')`; if `./live.json` lands, you're
54
+ done. Only fall back to stash-to-`<textarea>` + chunked `pxRead` when the POST is
55
+ genuinely refused. (RUNBOOK Step 0.)
56
+
57
+ **Injection transport (paid for on aloyoga, ~7 min):** inject the capture code as
58
+ **plain source** — paste `tools/browser-capture.js` (the browser half of
59
+ `pixel-diff.js`, split out so it drops in whole) directly as the code to evaluate.
60
+ Do **not** base64/gzip the file to shrink the paste: those transports corrupt in
61
+ transit and/or trip the automation harness's content filter. Same rule for reading
62
+ values out — never return a URL / data-URI / base64 blob through the JS tool, or the
63
+ harness may blank it (`[BLOCKED: …]`); POST it to the sink or stash + read one dump.
64
+
65
+ ## 5. The finder grabbed the same text elsewhere
66
+ `byText(/^shoes$/)` matched a body element far down the page instead of the nav item.
67
+ **Lesson:** **scope finders** to the section (`pxRegion`), and sanity-check each
68
+ target's `rect.y` is where you expect. A confident measurement of the wrong element
69
+ is worse than none.
70
+
71
+ ## 6. Strict diff drowned in structural (invisible) differences
72
+ A visually-identical header showed dozens of ❌ (flex+gap vs `<ul>`+grid, padding vs
73
+ line-height, `proxima` vs `proxima-nova` alias).
74
+ **Lesson:** separate "does it look identical?" (`--visual`) from "is the DOM/CSS
75
+ identical?" (strict). Two valid implementations can render the same pixels; don't fail
76
+ the pixel check on structure — but *do* document each structural choice.
77
+
78
+ ## 7. Slow verification (round-trips, not compute)
79
+ A single diff took ~20 serial browser calls + two 45s hangs.
80
+ **Lesson:** each browser call is a slow CDP round-trip — **batch** aggressively; a
81
+ blocked call costs a full 45s timeout, so avoid it entirely. Target ~2–4 calls + 1
82
+ `node` call.
83
+
84
+ ## 8. Box-height chain
85
+ "The height is off" even after centers matched — main bar, nav link, and logo wrapper
86
+ each had a slightly wrong box height, and centering nudges are calibrated against the
87
+ container height (change one, re-derive all).
88
+ **Lesson:** matching centers isn't enough for structural parity — measure and match
89
+ the box **heights** too.
90
+
91
+ ## 9. THE BIG ONE — icon glyphs measured at the wrapper, not the paint
92
+ Icons "needed to move up a few pixels" but measurements insisted they matched. Live
93
+ **top-aligns** each intrinsic-size glyph inside a taller control
94
+ (`background-position: 0% 0%`), so the *control* centered at 91.25 while the *visible
95
+ glyph* centered at 89. Measuring the control's `getBoundingClientRect` matched by
96
+ coincidence; the painted glyph was 2.25px off.
97
+ **Lesson:** for any background/SVG graphic, measure the **painted element** (path
98
+ bbox, or background element **plus its `background-position`**), never the clickable
99
+ wrapper. And: a user saying "it's off" against your "0 delta" means you're measuring
100
+ the wrong element — drill into what actually paints.
101
+
102
+ ## 10. Invisible blue-on-blue text (`--visual` silently skipped colour)
103
+ Announcement text was painted in the **background colour** (a Tailwind token-name
104
+ collision: a colour and a font-size both named the same thing). It passed `--visual`,
105
+ because `--visual` only compared `font.color` *inside* the "both have a text box"
106
+ branch — and the finder had resolved a full-width **wrapper** whose own text node was
107
+ empty (`text: null`), so it fell through to a rect-only fallback and never compared
108
+ colour. Strict *did* flag it, but it was waved off as "structural."
109
+ **Lesson (tool):** compare `font.*` (incl. `color`) for **every** text target even
110
+ when the glyph box is null; raise a `text.present` failure when a finder resolves a
111
+ non-text wrapper. **Lesson (you):** a colour / visibility delta is **never**
112
+ structural — never wave off `font.color` in the strict table.
113
+
114
+ ## 11. A missing underline (a painted mark the tool never measured)
115
+ An underline was missing. The tool measured border *widths* on the element but not
116
+ `text-decoration`, and the live underline was a `border-bottom` on an **ancestor**
117
+ wrapper — invisible to a per-element check.
118
+ **Lesson:** "measure the painted mark" applies to **decorations** too (underline /
119
+ strike), not just text and icons — and a mark can be drawn by an ancestor or a
120
+ sibling, so detect it by what's rendered near the text (`text-decoration` on
121
+ self/ancestor, a short ancestor's `border-bottom`, or a thin rule painted just below
122
+ the text), never by one property on the one element you labelled.
123
+
124
+ ## 12. THE OTHER BIG ONE — an underline measured as a *boolean*, not a *box* (3 human rounds)
125
+ A sign-in label's underline was wrong **three ways in a row** — too thin (1px vs the
126
+ live 2px), too short (it spanned only the text, not the icon+text group → 185px vs
127
+ 211px), and vertically drifting — yet **every `--visual` and strict run stayed green**.
128
+ Cause: the capture records `font.underline` as a **boolean** (present / absent). Both
129
+ sides had *an* underline, so the gate said "match" while the painted line differed in
130
+ thickness, width, and Y. And the underline is drawn by an **ancestor** group's
131
+ `border-bottom` (`.loyalty-name-part`), so `--inspect` on the *text* element never saw
132
+ it either. Each round I patched the one facet the human named with a **hand-tuned
133
+ offset** (`bottom:-3.6px`), so the next facet surfaced and the human had to point
134
+ again — and the positioned `<span>` rasterised unlike a real `border`, failing the
135
+ flicker test even when the number was ~0.01px.
136
+ **Lesson (general — this is the durable one):** *any* painted mark that isn't the text
137
+ glyph or an icon — a decoration (underline/strike), a `border`, a `box-shadow`, an
138
+ `outline`, a rule — is a **box**, drawn by **some element** (often an **ancestor**).
139
+ Measure that box (thickness, x/width, y) on the element that draws it; never a boolean,
140
+ never one property on the element you labelled.
141
+ > 🔒 **Enforced now:** the gate measures underlines this way — `underlineBox` →
142
+ > `underline.{thickness,x,right,w,top,bottom}`, compared by `--visual`. Trust the diff
143
+ > for underlines; don't re-derive them by hand. For a mark the tool doesn't
144
+ > special-case (a shadow, an outline), apply the same principle via `--inspect`.
145
+ > 👁 **Still yours:** *reproducing the technique.* The gate flags a wrong mark but never
146
+ > fixes it — redraw it the way live does (e.g. a `border-bottom` on a same-sized box) so
147
+ > its position/size **emerge** from the box model. A magic offset that lands the number
148
+ > rasterises unlike a real border and fails a flicker/overlay compare even at ~0.01px
149
+ > (→ #14, #15).
150
+
151
+ ## 13. Perceived weight differed while every `font-weight` matched — `-webkit-font-smoothing`
152
+ "The text looks thicker" — but `font-weight` was 600 on both sides. Cause: live sets
153
+ `-webkit-font-smoothing: antialiased` (lighter); the clone defaulted to `auto`
154
+ (subpixel, heavier). The capture's font metrics didn't include smoothing, so the
155
+ regression sweep was **blind**; only the full-computed-style `--inspect` surfaced it —
156
+ after a human pointed.
157
+ **Lesson:** `-webkit-font-smoothing` / `-moz-osx-font-smoothing` change *perceived
158
+ weight* without changing `font-weight`. Set `antialiased` / `grayscale` on the clone
159
+ root by default (most design systems do).
160
+ > 🔒 **Enforced now:** captured as `font.smoothing` and compared by `--visual`.
161
+
162
+ ## 14. Patched the symptom, not the mark — one human round per facet
163
+ The pattern behind #12: when a human flags a mark, the cheap move is to fix exactly the
164
+ property they named. But a mark has several painted dimensions (a decoration: thickness,
165
+ width, offset, colour, technique), and fixing one leaves the others — so the human
166
+ iterates once per facet. The fixes were hand-tuned offsets that landed the number but
167
+ mismatched the *technique*.
168
+ **Lesson:** on any human flag, **localise the element that actually paints the mark and
169
+ reproduce its whole box model + technique in one shot** (PLAYBOOK Phase 6) — same
170
+ element type, `height`, `border`, `box-sizing` — instead of nudging the text with magic
171
+ offsets. Detection is the human; the *first* fix should be measured and complete, not a
172
+ one-property patch that guarantees another round.
173
+ > 👁 **The gate can't do this.** The diff tells you *what's* wrong on the element you
174
+ > inspected — not that you inspected the wrong element, patched one facet, or drew it
175
+ > with a different primitive. `--inspect` the **painting** element, fix its whole box.
176
+
177
+ ## 15. Chasing tolerance toward 0 is the wrong knob
178
+ A mark still "looked off" while the diff read ~0.01px. The instinct — tighten tolerance
179
+ — is wrong: at that delta the numbers already say *equal*; what's left is a
180
+ **technique / rasterisation** mismatch (a positioned `<div>` vs a real `border`), which
181
+ a tolerance can't catch. And converging *every* property to 0.00 across two **different**
182
+ implementations is high-effort and often non-convergent (fractional layout falls out of
183
+ the whole box chain — each nudge perturbs neighbours; below ~0.3px you fit rounding
184
+ noise) for **zero** visual payoff (sub-0.5px is <1 device pixel, antialiased
185
+ identically).
186
+ **Lesson:** keep the gate at **0.5px**. When something reads wrong at 0.01px, the defect
187
+ is the *technique* — reproduce it (→ #12, #14) and **flicker/overlay** the two at 1:1 —
188
+ never dial the tolerance down. 0.5px is the "looks identical" threshold on purpose.
189
+
190
+ ## 16. A painted BACKDROP colour the gate never measured (announcement bar / button / badge)
191
+ Found on aloyoga. The header's announcement bar is a solid `background-color`
192
+ (`rgb(113,198,235)`) — a painted mark. But it lives on a **container**, not on the
193
+ text leaf, so the per-target capture (which measured the *text's* `font.color`, never
194
+ its backdrop) had no field for it. Proof of the hole: setting the clone bar bright
195
+ **red** left `--visual` green (exit 0), and the red-bar snapshot was **byte-identical**
196
+ to the good one — the colour was nowhere in the schema. Coverage didn't catch it either:
197
+ a solid-colour container with a text child isn't enumerated as a "painted leaf."
198
+ **Lesson:** a solid `background-color` (bar, button, badge, chip) is a painted mark, and
199
+ it's usually on an ancestor of the text/icon you targeted. Measure the **effective
200
+ backdrop** behind every mark — the nearest opaque `background-color` up the ancestor
201
+ chain, resolving a fully-transparent chain to the **canvas default (white)** so an
202
+ explicit `body{background:#fff}` clone compares equal to a live canvas left transparent.
203
+ This is #10 (invisible blue-on-blue text) generalised from the glyph to its backdrop.
204
+ > 🔒 **Enforced now:** captured as `bg` (nearest painted background, canvas→white) and
205
+ > compared by `--visual` for every target — but only when a real colour is painted on
206
+ > either side, so transparent-on-white text/icons add no noise. `harness/fixtures/01-backdrop-color.js`
207
+ > locks it in. Trust the diff for bar/button/badge colours; don't re-derive by hand.
208
+ > 👁 **Still yours:** a colour-only container (a bar with no text/icon child of its own)
209
+ > won't be *targeted* by a text/icon finder — enumerate it in coverage, or target the
210
+ > mark that sits on it (its text leaf now carries the backdrop).
211
+
212
+ ## 17. The STRUT positioned the text — every leaf property matched, the container's didn't
213
+ Found on the Hacker News header, flagged by a human tester **after** a green `--visual`
214
+ (165 comparisons, 0 fails): "the text of the header is a little lower than it should be."
215
+ Live authors `line-height:12pt` (16px) **inline on the td**; the clone matched every
216
+ *measured leaf* exactly — the links' own `line-height` was 12 vs 12 ✓ — but left the td
217
+ at `normal`. The td's line-height is the **strut** of the line box that positions the
218
+ glyphs, and the td was never a target. On the capture machine the drift was 0.25px —
219
+ under the 0.5px tolerance, correctly so (#15: don't chase tolerance). But `normal`
220
+ resolves from *platform font metrics* while `16px` doesn't, so on the tester's machine
221
+ the same clone rendered visibly lower. A sub-tolerance same-machine delta plus a
222
+ technique mismatch = a cross-platform miss no tolerance tightening can catch.
223
+ **Lesson:** the glyphs' vertical position is decided by the line box's strut — the
224
+ nearest **line-box container's** line-height, usually an ancestor (td, div, flex item)
225
+ you never targeted. Measure it per text target, and treat `normal` vs a number as a
226
+ **technique mismatch** (string ≠ number → fails loudly), exactly like a hand-tuned
227
+ offset vs a real border (#14): matching today's number on one platform is not matching
228
+ the pixels everywhere. This is #12's "the ancestor draws the mark" generalised from
229
+ painting to **positioning**.
230
+ > 🔒 **Enforced now:** captured as `font.strut` (nearest block/table-cell/flex container's
231
+ > line-height, `normal` kept as a string) and compared by `--visual` for every text
232
+ > target; skipped when either snapshot predates the field, so old captures don't
233
+ > false-positive. `harness/fixtures/02-line-strut.js` locks it in; the battery scores it
234
+ > (defect caught, `normal`-vs-`normal` and old-schema controls clean).
235
+ > 👁 **Still yours:** reproduce the *authored* technique (put the line-height where live
236
+ > puts it — the td, not the leaf), and remember cross-platform rendering: a green sweep
237
+ > on your machine plus a technique mismatch can still be a visible miss on another OS.
238
+
239
+ ## 18. The DOCUMENT MODE moved the pixels — every computed style was byte-identical
240
+ Found on the Hacker News header, same human round as #17's fix: after byte-matching the
241
+ markup and the strut, the login line still sat 0.25px lower — with the td, span, and
242
+ anchor **identical in every computed property** on both pages (same font, line-height,
243
+ padding, valign, rects). The cause was above CSS entirely: live HN ships **no doctype**,
244
+ so it renders in **quirks mode** (`document.compatMode === "BackCompat"`); the clone's
245
+ tidy `<!doctype html>` put it in standards mode, and quirks computes table-cell line
246
+ boxes differently. No element-level measurement could ever catch this — the difference
247
+ lives on the *document*, not on any node.
248
+ **Lesson:** the rendering mode is a pixel-determining property of the **whole page**.
249
+ Clone the doctype (or its absence) before cloning anything else, and capture
250
+ `document.compatMode` in the snapshot so a mismatch fails the sweep on run one instead
251
+ of surfacing as an unexplainable sub-pixel offset three rounds later. This is the
252
+ technique principle (#14/#17) taken to its limit: the "technique" can be the parser mode
253
+ the page opted into decades ago.
254
+ > 🔒 **Enforced now:** every capture records `mode` (quirks `BackCompat` vs standards
255
+ > `CSS1Compat`) in the snapshot root, and a mismatch is a failing `page.mode` row in both
256
+ > `--visual` and strict; skipped when either snapshot predates the field.
257
+ > `harness/fixtures/03-compat-mode.js` locks it in; the battery scores it.
258
+ > 👁 **Still yours:** copy live's doctype line (or its absence) into the clone scaffold —
259
+ > the gate can only tell you the modes differ, not which ancient parser quirk you need.
260
+
261
+ ## 19. The BUILD STRATEGY determines the defect class — capture, don't reconstruct
262
+ Not one miss but the pattern behind the back half of this file, made undeniable by this
263
+ repo's own targets. Every technique mismatch the gate is structurally blind to — the
264
+ hand-tuned offset vs a real border (#12/#14), the sub-tolerance drift that's really a
265
+ rasterisation difference (#15), the strut authored on an ancestor (#17), the quirks-mode
266
+ doctype (#18), plus stripe's `font-feature-settings` and missing element — is
267
+ **self-inflicted by hand-rebuilding**: a reconstruction lands the same numbers by a
268
+ different construction. The evidence: the two hand-rebuilt targets with human QA burned
269
+ **3 rounds (hn)** and **8 rounds (stripe, never converged)** on exactly these misses
270
+ *after* fully green gates — while the one clone built **from the captured DOM** (github)
271
+ passed 3136 comparisons with **0 fails and 0 structural deltas in a single pipeline pass**.
272
+ A captured clone inherits live's doctype, authored line-heights, font-features, and
273
+ drawing primitives *by construction*; the whole defect class never exists.
274
+ **Lesson:** the numeric gate verifies a build; it cannot compensate for a build strategy
275
+ that manufactures defects it can't see. Capture the truth (post-hydration DOM + the site's
276
+ real CSS + self-hosted fonts); reconstruct only what capture can't express — behavior.
277
+ > 🔒 **Enforced now:** `ppk capture-build <name>` is the default build phase — it
278
+ > self-hosts every stylesheet + font (the assets gate checks their wOF2 magic), absolutizes
279
+ > the rest, strips scripts/CSP, and **preserves the doctype or its absence byte-for-byte**
280
+ > (#18). Failed downloads exit 1. `harness/capture-build-selftest.js` locks the contract in.
281
+ > 👁 **Still yours:** JS-driven behavior and animated/generative content (a WebGL hero, a
282
+ > marquee) can't be captured statically — reproduce them separately, and spend human QA
283
+ > rounds *there*, not on statics the gate proves. And when the deliverable is a component
284
+ > in your own stack, you're back on the rebuild path — every rule above applies in full.
285
+
286
+ ## 20. The review tool's viewport altered the REFERENCE — the human was comparing against Apple's own fallback
287
+ Found on iphone17, human round 9 — and it was the *reviewer* who solved it. For four
288
+ rounds the reviewer described the camera intro as a static two-part layout (frozen
289
+ phone still + tabs panel below) while insisting a normal browser shows a scroll-morph
290
+ (the phone rotates and *becomes* the tabs panel). Round 9 they caught the mechanism:
291
+ **resizing the window mid-scroll makes apple.com itself degrade to the static
292
+ two-part variant** — and the side-by-side compare view's iframe/viewport sizing
293
+ triggers exactly that degradation on the *reference* side. The clone had been judged
294
+ against Apple's own fallback all along ("which is probably why you weren't seeing it
295
+ on your end").
296
+ **Lesson:** the reference the human compares against is not automatically the
297
+ experience the designer meant — a responsive/resize-degrading site can serve its
298
+ fallback variant *inside the review tool*, making a correct clone of the full
299
+ experience read as WRONG and a clone of the degraded variant read as RIGHT. This is
300
+ the environment-inversion doctrine (automation sees no-js; the human is the live-side
301
+ instrument) extended one level: **the human's instrument has an environment too.**
302
+ When a reviewer's description of live keeps contradicting what any browser you control
303
+ shows, ask which *variant* of live their tool is rendering before engineering anything.
304
+ > 🔒 **Enforced now:** nothing — this lives above the capture/diff layer entirely.
305
+ > 👁 **Still yours:** when pins describe behavior you can't reproduce in ANY
306
+ > environment, enumerate the reference site's own authored degraded variants
307
+ > (resize-triggered, no-js, reduced-motion, `no-*` feature classes in its CSS) and
308
+ > check whether the review tool's viewport lands the reference in one of them. A
309
+ > clone matching the same authored variant the compare UI shows is a disclosed
310
+ > equivalence, not a defect — document it with the reviewer's own confirmation.
311
+ > **Kit-change candidate:** human-qa's filed test could state the compare view's
312
+ > viewport size in its instructions, so the reviewer knows which variant of a
313
+ > responsive reference they're looking at.
314
+ >
315
+ > **Round-12 addendum — the reviewer's own BROWSER is an environment too:** the same
316
+ > target later revealed the sharper form: the reviewer reviews in an iOS in-app
317
+ > browser where apple.com itself serves its degraded no-scroll-animation fallback.
318
+ > Two rounds were spent building a faithful, frame-verified scroll-scrub the
319
+ > reviewer's environment can never render — then retired by owner directive
320
+ > ("just copy the fallback"). Before reproducing ANY environment-conditional
321
+ > behavior, establish which variant the reviewer's browser receives from the
322
+ > reference site; reproducing a variant the judge cannot see is unfalsifiable work,
323
+ > however correct.
324
+
325
+ ---
326
+
327
+ ## The gate vs your eyes — the one split that keeps this general
328
+ Every lesson here is one of two kinds. Keep them apart, or you'll re-measure what the
329
+ tool guarantees and eyeball what it can't:
330
+
331
+ - 🔒 **Gate-enforced — trust `--visual`, don't re-derive by hand.** Same-width capture;
332
+ glyph box via `Range`; painted glyph + `background-position`; full box-model; font
333
+ incl. `line-height` / `letter-spacing` / `color` / `decoration` / **`smoothing`**; the
334
+ **underline box**; the **painted backdrop** (`bg` — bar/button/badge colour); parent
335
+ `gap`; presence & `text.present`. If the diff is green on
336
+ these, they match — pushing further is fitting sub-pixel noise (#15). *When you find a
337
+ new class of miss, add it to the tool (as underline/smoothing were added), not to a
338
+ human checklist — that's how it stops recurring.*
339
+ - 👁 **Judgment — the diff can't see these; they stay on you.** *Which* element paints a
340
+ mark (may be an ancestor — the tool special-cases underlines, not every shadow/outline);
341
+ **reproducing the technique** vs a magic offset; **coverage** (every painted leaf has a
342
+ target); **fixing the whole mark in one shot** (`--inspect`, not one facet); and the
343
+ **flicker/overlay** final check that catches a technique/rasterisation mismatch the
344
+ numbers pass. A green table only proves what you measured, drawn however you drew it.
345
+
346
+ ## Checklist distilled from the above
347
+ 1. **Text** → the text-glyph box via `Range` + **all** font metrics (incl.
348
+ `line-height`, `letter-spacing`, `color`, `underline`). Never the element box.
349
+ Colour and underline are painted marks — compare them even when the glyph box is null.
350
+ 2. **Icons/logos** → the painted glyph (SVG bbox, or background element +
351
+ `background-position`). Never the clickable wrapper.
352
+ 3. Measure the **complete** box (geometry, box-model incl. heights, layout, parent
353
+ `gap`) — don't spot-check a subset; let an unexpected property surface.
354
+ 4. **Scope finders** to the section; verify each target's `rect.y`. A `text.present`
355
+ failure means the finder grabbed a wrapper — fix it.
356
+ 5. Diff **numerically** (exit 0), not by screenshot. `--visual` for "looks identical",
357
+ strict for structural parity.
358
+ 6. Measure both pages at the **same viewport width**.
359
+ 7. On a CSP site: inject the capture as **plain source** (`browser-capture.js`, never
360
+ base64/gzip), **probe a direct POST first** (it often works — RUNBOOK Step 0), and
361
+ only stash/read if it's refused; never `fetch`+`eval` (it hangs). Don't return
362
+ URLs/data-URIs/base64 through the tool — the harness may blank them.
363
+ 8. **Batch** browser calls; avoid blocked calls (each costs a 45s timeout).
364
+ 9. A user saying "it's still off" against your "0 delta" means you're measuring the
365
+ wrong thing — find what actually paints.
366
+ 10. **A colour / visibility / underline delta is never "structural."** Read every
367
+ strict row; if `--visual` is green while strict shows a colour miss, `--visual`
368
+ has a hole — a finder resolved a wrapper and the gate narrowed what it compared.
369
+ 11. When a human flags one element, run `--inspect` (full computed-style diff), not a
370
+ guess — the paint bucket is the fix list, and "fixed" means it's empty. Resolve
371
+ the element that actually **paints** the flagged mark — it may be an ancestor
372
+ (the underline lived on `.loyalty-name-part`, not the text) — and fix the **whole
373
+ box in one shot** (reproduce technique + box model), not the one property named.
374
+ 12. **A decoration is a box, not a boolean** — 🔒 the gate measures underlines as
375
+ `underline.*` (thickness/x/width/y, off the drawing element, ancestor included).
376
+ 👁 Your part: **reproduce the technique** (a `border-bottom` on a same-sized box, not
377
+ a hand-tuned offset), and apply the box-not-boolean rule by hand to any mark the tool
378
+ doesn't special-case (`box-shadow`, `outline`) via `--inspect`.
379
+ 13. 🔒 **`font.smoothing` is captured and compared** — set the clone root to
380
+ `antialiased` / `grayscale` so perceived weight matches (`font-weight` alone won't).
381
+ 14. **Don't chase tolerance to 0** (#15): sub-0.5px is noise/rasterisation, not a defect
382
+ a tighter gate catches — reproduce the *technique* and flicker-test instead.
383
+ 15. 🔒 **A solid `background-color` is a painted mark** — 🔒 the gate captures `bg` (the
384
+ nearest opaque backdrop behind each mark, transparent→white canvas) and `--visual`
385
+ compares it, so a wrong bar/button/badge colour fails on run one (#16). 👁 A
386
+ colour-only container with no text/icon child of its own still needs a coverage entry.
387
+ 16. 🔒 **The line-box container's strut positions the glyphs** — the gate captures
388
+ `font.strut` (the nearest block/td/flex ancestor's line-height) and compares it;
389
+ `normal` vs a number is a technique mismatch that fails even when the same-machine
390
+ delta is sub-tolerance (#17). 👁 Put the line-height where live authors it — a leaf
391
+ that matches the number on your platform can still sit visibly off on another.
392
+ 17. 🔒 **The document mode is a pixel-determining property** — the gate captures `mode`
393
+ (`document.compatMode`) and fails a quirks-vs-standards mismatch on run one (#18).
394
+ 👁 Copy live's doctype (or its absence) into the clone before building anything.
395
+ 18. 🔒 **Build by capture, not reconstruction** (#19) — `ppk capture-build` builds the
396
+ clone from the captured post-hydration DOM (self-hosted CSS/fonts, doctype preserved),
397
+ eliminating the technique-mismatch class the gate can't see. 👁 Rebuild by hand only
398
+ when the deliverable is a component in your stack — and expect every rule above.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Durango
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.