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
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.
|