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,307 @@
1
+ // behavior-capture.js — the BROWSER half of the `behavior` phase, extracted verbatim (same
2
+ // reason as browser-capture.js: inject as PLAIN SOURCE on a strict-CSP live site — never
3
+ // base64/gzip/fetch+eval, see tools/RUNBOOK.md). Discovers and MEASURES JS-driven dynamics
4
+ // (animations, rotations, reveals, marquees, counters, hover-mounted content) so the
5
+ // `behavior` phase gate can compare live vs clone by NUMBER, never by eyeballing a replay.
6
+ //
7
+ // METHOD (ported from lovable_dupe_html/CLONE_PLAYBOOK.md §8a — port, don't reinvent):
8
+ // static pass — grep candidates: @keyframes names in <style>/stylesheets, and DOM
9
+ // markers (class/style hints for opacity-0, translate, blur, will-change,
10
+ // animate-*, data-state, data-[starting-style]). Necessary but noisy —
11
+ // every hit is a CANDIDATE, not a confirmed behavior.
12
+ // dynamic pass — the authoritative one: attach a MutationObserver (class/style/data-*)
13
+ // across the region, snapshot computed opacity/transform/filter PER
14
+ // ELEMENT before and after a scripted scroll sweep + hover of each
15
+ // candidate trigger, and diff. Whatever actually changed is a real
16
+ // behavior; whatever stayed frozen in its start state is presentational
17
+ // noise (a candidate that never fires isn't a behavior to reproduce).
18
+ // measure, don't eyeball — a marquee's speed is sampled translateX over a real time
19
+ // window and converted to px/sec; a reveal's duration is the wall-clock
20
+ // time between trigger and the computed style settling; there is no
21
+ // "looks about right" path through this file.
22
+ //
23
+ // The discovery pass's OWN metadata (scroll range, observer duration, elements scanned) is
24
+ // recorded in the output — this is what proves discovery ACTUALLY RAN on a page with zero
25
+ // live behaviors, vs. a script that silently no-oped (WORKFLOW.md: "no dynamic behaviors
26
+ // discovered" must be an evidenced gate result, never a free pass).
27
+ (function (root) {
28
+ "use strict";
29
+ const num = (v) => { const n = parseFloat(v); return isNaN(n) ? v : Math.round(n * 1000) / 1000; };
30
+ const now = () => (root.performance ? root.performance.now() : Date.now());
31
+
32
+ root.pxRegion = root.pxRegion || { maxY: 200 };
33
+ const inRegion = (el) => {
34
+ const reg = root.pxRegion || {};
35
+ if (reg.sel && !el.closest(reg.sel)) return false;
36
+ const r = el.getBoundingClientRect();
37
+ if (reg.maxY != null && r.top > reg.maxY + (reg.maxYSlack || 4000)) return false; // generous: behaviors can occur below the fold (scroll reveals)
38
+ if (reg.minY != null && r.bottom < reg.minY) return false;
39
+ return true; // unlike browser-capture's inRegion, width===0 is still a candidate (opacity:0 start state has zero box until revealed)
40
+ };
41
+
42
+ // ── static pass: candidate discovery (necessary, noisy) ─────────────────────
43
+ // Grep every reachable stylesheet's cssText for @keyframes names — same-origin sheets only;
44
+ // a cross-origin sheet throws on .cssRules (SecurityError), which we swallow (can't read it
45
+ // from script anyway; its keyframes still fire and get caught by the dynamic pass).
46
+ function keyframeNames() {
47
+ const names = new Set();
48
+ for (const sheet of Array.from(document.styleSheets)) {
49
+ let rules;
50
+ try { rules = sheet.cssRules; } catch (e) { continue; }
51
+ if (!rules) continue;
52
+ for (const rule of Array.from(rules)) {
53
+ if (rule.type === CSSRule.KEYFRAMES_RULE) names.add(rule.name);
54
+ }
55
+ }
56
+ return names;
57
+ }
58
+ const MARKER_RE = /(^|[\s"'`])(opacity-0|translate-[xy]-|blur-|will-change|animate-|data-\[starting-style\]|data-state)/;
59
+ // Attributes whose NAME declares animation intent (site-authored choreography markers —
60
+ // apple ships data-anim-scroll-group / data-video-load-kf etc.; generic data-scroll-* and
61
+ // data-animate-* cover the common libraries).
62
+ const DECLARED_ATTR_RE = /^data-(anim|animate|scroll|video-load|autoplay|parallax|reveal|carousel|gallery|progress)/i;
63
+ function declaredHints(el, cs) {
64
+ const hints = [];
65
+ if (cs.animationName && cs.animationName !== "none") hints.push("animation-name:" + cs.animationName);
66
+ if (el.className && typeof el.className === "string" && MARKER_RE.test(el.className)) hints.push("class-marker");
67
+ if (el.hasAttribute("data-state") || el.hasAttribute("data-starting-style")) hints.push("data-state");
68
+ if (cs.willChange && cs.willChange !== "auto") hints.push("will-change:" + cs.willChange);
69
+ for (const a of el.attributes) if (DECLARED_ATTR_RE.test(a.name)) hints.push("attr:" + a.name);
70
+ // a transition paired with a hidden/offset start state = a reveal waiting for a trigger
71
+ if (cs.transitionDuration && cs.transitionDuration !== "0s" && (parseFloat(cs.opacity) === 0 || (cs.transform && cs.transform !== "none"))) hints.push("transition-from-start-state");
72
+ if (el.tagName === "VIDEO") hints.push(`video:${el.autoplay ? "autoplay" : "scripted"}:${el.preload || "auto"}`);
73
+ return hints;
74
+ }
75
+ function staticCandidates(root_) {
76
+ const kf = keyframeNames();
77
+ const out = [];
78
+ const all = root_.querySelectorAll("*");
79
+ for (const el of all) {
80
+ if (!inRegion(el)) continue;
81
+ const cs = getComputedStyle(el);
82
+ const hints = declaredHints(el, cs);
83
+ if (hints.length) out.push({ el, hints });
84
+ }
85
+ return { keyframes: [...kf], candidates: out };
86
+ }
87
+
88
+ // ── dynamic differential pass (authoritative) ────────────────────────────────
89
+ // Per-element snapshot: opacity/transform/filter, the three properties JS-driven reveals
90
+ // and rotations actually touch (per the playbook). Cheap enough to run on every candidate
91
+ // plus a bounded scan of the region without a real perf hit.
92
+ function styleSnap(el) {
93
+ const cs = getComputedStyle(el);
94
+ return { opacity: num(cs.opacity), transform: cs.transform === "none" ? "none" : cs.transform, filter: cs.filter === "none" ? "none" : cs.filter };
95
+ }
96
+ const styleSnapEq = (a, b) => a.opacity === b.opacity && a.transform === b.transform && a.filter === b.filter;
97
+
98
+ // Sample an element's transform translation over a wall-clock window → px/sec on the
99
+ // DOMINANT axis (a logo belt is usually horizontal, a ticker/credits roll is vertical —
100
+ // sampling only X would read a vertical marquee as 0 and "pass" a frozen one). Playbook §8:
101
+ // "measure the real speed — sample the transform over 1s → px/sec"; never eyeball.
102
+ function sampleTranslateSpeed(el, ms) {
103
+ return new Promise((resolve) => {
104
+ const read = () => {
105
+ const m = /matrix(3d)?\(([^)]+)\)/.exec(getComputedStyle(el).transform);
106
+ if (!m) return { x: 0, y: 0 };
107
+ const p = m[2].split(",").map((s) => parseFloat(s.trim()));
108
+ return m[1] ? { x: p[12], y: p[13] } : { x: p[4], y: p[5] }; // matrix3d tx/ty at 12/13; matrix at 4/5
109
+ };
110
+ const t0 = now(), s0 = read();
111
+ setTimeout(() => {
112
+ const t1 = now(), s1 = read();
113
+ const dtSec = (t1 - t0) / 1000;
114
+ const dx = Math.abs(s1.x - s0.x), dy = Math.abs(s1.y - s0.y);
115
+ const axis = dx >= dy ? "x" : "y";
116
+ const dist = Math.max(dx, dy);
117
+ resolve({ pxPerSec: dtSec > 0 ? num(dist / dtSec) : 0, axis, from: num(axis === "x" ? s0.x : s0.y), to: num(axis === "x" ? s1.x : s1.y), sampledMs: num(t1 - t0) });
118
+ }, ms);
119
+ });
120
+ }
121
+
122
+ // Wait for a MutationObserver to go quiet for `quietMs`, capped at `maxMs`. Used to let a
123
+ // one-shot reveal settle before snapshotting its end state (never a fixed guess-sleep).
124
+ function waitQuiet(target, quietMs, maxMs) {
125
+ return new Promise((resolve) => {
126
+ let timer, done = false;
127
+ const finish = () => { if (done) return; done = true; try { mo.disconnect(); } catch (e) {} resolve(); };
128
+ const mo = new MutationObserver(() => { clearTimeout(timer); timer = setTimeout(finish, quietMs); });
129
+ mo.observe(target, { attributes: true, attributeFilter: ["class", "style"], subtree: true, attributeOldValue: false });
130
+ timer = setTimeout(finish, quietMs);
131
+ setTimeout(finish, maxMs); // hard cap — a page that mutates forever must not hang discovery
132
+ });
133
+ }
134
+
135
+ // Scripted scroll sweep in increments, dwelling briefly at each stop so scroll-linked /
136
+ // IntersectionObserver-triggered reveals get a chance to fire (playbook §8a).
137
+ async function scrollSweep(steps, dwellMs) {
138
+ const max = Math.max(0, (document.scrollingElement || document.documentElement).scrollHeight - window.innerHeight);
139
+ const positions = [];
140
+ for (let i = 0; i <= steps; i++) {
141
+ const y = Math.round((max * i) / steps);
142
+ window.scrollTo(0, y);
143
+ positions.push(y);
144
+ await new Promise((r) => setTimeout(r, dwellMs));
145
+ }
146
+ window.scrollTo(0, 0);
147
+ await new Promise((r) => setTimeout(r, dwellMs));
148
+ return { from: 0, to: max, steps, positions };
149
+ }
150
+
151
+ // Dispatch a synthetic hover on `el` (pointerover/enter + mouseover/enter — physical hover
152
+ // doesn't persist across separate automation calls, playbook §9), wait for it to settle,
153
+ // return the snapshot delta on `scope` (the element or a documented ancestor scope).
154
+ async function probeHover(el, scope, settleMs) {
155
+ // A hover that MOUNTS content (a mega-menu portal) changes the scope's CHILDREN, not the
156
+ // scope's own opacity/transform — snapshotting only the scope's style would read a mounted
157
+ // menu as "nothing changed". Count descendants too; either delta counts as a real change.
158
+ const snapScope = (s) => Object.assign(styleSnap(s), { descendants: s.querySelectorAll("*").length });
159
+ const before = snapScope(scope);
160
+ for (const type of ["pointerover", "pointerenter", "mouseover", "mouseenter"])
161
+ el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
162
+ await waitQuiet(scope, 150, settleMs);
163
+ const after = snapScope(scope);
164
+ // reset — move focus away so a later probe doesn't inherit an open state
165
+ for (const type of ["pointerout", "pointerleave", "mouseout", "mouseleave"])
166
+ el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
167
+ await new Promise((r) => setTimeout(r, 60));
168
+ return { before, after, changed: !styleSnapEq(before, after) || before.descendants !== after.descendants };
169
+ }
170
+
171
+ // A stable key for a behavior: prefer an explicit id/data-testid/aria-label, else a
172
+ // class-based descriptor, else a structural path. Stability across live/clone captures
173
+ // matters more than prettiness — the gate keys off exact string match.
174
+ function keyOf(el, prefix) {
175
+ const id = el.id || el.getAttribute("data-testid") || el.getAttribute("aria-label");
176
+ if (id) return `${prefix}:${id}`;
177
+ const cls = (typeof el.className === "string" ? el.className : "").trim().split(/\s+/).filter(Boolean).slice(0, 2).join(".");
178
+ if (cls) return `${prefix}:${el.tagName.toLowerCase()}.${cls}`;
179
+ let path = el.tagName.toLowerCase(), e = el, hops = 0;
180
+ while (e.parentElement && hops < 3) { const i = [...e.parentElement.children].indexOf(e); path = `${e.parentElement.tagName.toLowerCase()}>${path}[${i}]`; e = e.parentElement; hops++; }
181
+ return `${prefix}:${path}`;
182
+ }
183
+
184
+ // ── the discovery + measurement pass ─────────────────────────────────────────
185
+ // opts: { scrollSteps=6, dwellMs=250, hoverTriggers=[[name, findFn]], marqueeSelectors=[…],
186
+ // settleMs=1500 }
187
+ async function discover(opts) {
188
+ const o = Object.assign({ scrollSteps: 6, dwellMs: 250, hoverTriggers: [], marqueeSelectors: [], settleMs: 1500 }, opts || {});
189
+ const startedAt = new Date().toISOString();
190
+ const t0 = now();
191
+ const scanRoot = document.body;
192
+
193
+ const { keyframes, candidates } = staticCandidates(scanRoot);
194
+ const before = new Map(candidates.map((c) => [c.el, styleSnap(c.el)]));
195
+
196
+ // dynamic pass: MutationObserver across the whole region while we sweep+dwell
197
+ const mutated = new Set();
198
+ const mo = new MutationObserver((records) => { for (const r of records) if (r.target && r.target.nodeType === 1) mutated.add(r.target); });
199
+ mo.observe(scanRoot, { attributes: true, attributeFilter: ["class", "style"], subtree: true, attributeOldValue: false, childList: true });
200
+
201
+ const sweep = await scrollSweep(o.scrollSteps, o.dwellMs);
202
+ await waitQuiet(scanRoot, 200, o.settleMs);
203
+ mo.disconnect();
204
+
205
+ const behaviors = {};
206
+
207
+ // reconcile: candidate whose computed style moved from its captured start state is a
208
+ // confirmed one-shot/scroll-linked behavior. A candidate that NEVER moved is NOT
209
+ // discarded (the pre-iphone17 rule): its markers still DECLARE intent, and in an
210
+ // environment-inverted run (a site that gates its choreography behind no-js/bot
211
+ // detection) nothing fires while everything is still supposed to. Unfired candidates
212
+ // become the `declared` inventory — each gets an identity BEFORE any reproduction is
213
+ // engineered, so two distinct behaviors can't silently merge into one invented hybrid,
214
+ // and each row can be put in front of the human ("something happens here — what?").
215
+ const declared = {};
216
+ for (const { el, hints } of candidates) {
217
+ const b = before.get(el);
218
+ const a = styleSnap(el);
219
+ if (!styleSnapEq(a, b)) {
220
+ const key = keyOf(el, "reveal");
221
+ behaviors[key] = {
222
+ trigger: "scroll",
223
+ kind: "class-toggle-or-style-mutation",
224
+ hints,
225
+ measured: { before: b, after: a, mutatedDuringSweep: mutated.has(el) },
226
+ };
227
+ } else {
228
+ const key = keyOf(el, "declared");
229
+ if (!declared[key]) declared[key] = { hints, startState: b, text: (el.textContent || "").trim().slice(0, 60) || null };
230
+ }
231
+ }
232
+ // any element the observer saw mutate but that wasn't a static candidate (e.g. a purely
233
+ // JS-timed rotation with no CSS keyframe/class marker) still counts — MEASURE its delta
234
+ for (const el of mutated) {
235
+ if (!inRegion(el)) continue;
236
+ if (before.has(el)) continue; // already reconciled above
237
+ const key = keyOf(el, "mutation");
238
+ if (behaviors[key]) continue;
239
+ behaviors[key] = { trigger: "mutation", kind: "observed-mutation", measured: { after: styleSnap(el) } };
240
+ }
241
+
242
+ // marquees: explicit selectors (the caller names the moving belt — playbook §8: "animate
243
+ // the WHOLE wrapper, not one inner track", so discovery measures the wrapper the caller
244
+ // points at, never guesses which of several nested tracks is the real mover)
245
+ for (const [name, sel] of o.marqueeSelectors) {
246
+ const el = typeof sel === "function" ? sel() : document.querySelector(sel);
247
+ if (!el) continue;
248
+ const speed = await sampleTranslateSpeed(el, 1000);
249
+ const key = `marquee:${name}`;
250
+ behaviors[key] = { trigger: "load", kind: "marquee", measured: speed };
251
+ }
252
+
253
+ // hover-mounted content: caller supplies [name, triggerFindFn, scopeFindFn?]
254
+ for (const [name, findTrigger, findScope] of o.hoverTriggers) {
255
+ const trigger = typeof findTrigger === "function" ? findTrigger() : document.querySelector(findTrigger);
256
+ if (!trigger) continue;
257
+ const scope = (findScope ? (typeof findScope === "function" ? findScope() : document.querySelector(findScope)) : null) || document.body;
258
+ const result = await probeHover(trigger, scope, Math.min(o.settleMs, 1200));
259
+ const key = `hover:${name}`;
260
+ behaviors[key] = { trigger: "hover", kind: "hover-mount", measured: result };
261
+ }
262
+
263
+ const endedAt = new Date().toISOString();
264
+ return {
265
+ url: location.href,
266
+ viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio },
267
+ mode: document.compatMode,
268
+ // discovery pass metadata — the evidence that discovery ACTUALLY RAN (an absent/empty
269
+ // behaviors object could otherwise mean "nothing dynamic" OR "the script never fired").
270
+ discovery: {
271
+ startedAt, endedAt, durationMs: num(now() - t0),
272
+ scrollSweep: sweep,
273
+ observeMs: o.settleMs,
274
+ elementsScanned: scanRoot.querySelectorAll("*").length,
275
+ staticCandidateCount: candidates.length,
276
+ keyframesFound: keyframes,
277
+ hoverTriggersProbed: o.hoverTriggers.map(([n]) => n),
278
+ marqueeSelectorsProbed: o.marqueeSelectors.map(([n]) => n),
279
+ },
280
+ behaviors,
281
+ declared,
282
+ };
283
+ }
284
+
285
+ root.pxBehaviorDiscover = discover;
286
+ root.pxBehaviorCapture = function (opts) { return discover(opts).then((snap) => JSON.stringify(snap, null, 2)); };
287
+ // Same delivery pattern as browser-capture.js: direct POST preferred; stash/read fallback
288
+ // for a CSP that blocks page→localhost fetch.
289
+ root.pxBehaviorSend = function (url, opts) {
290
+ return discover(opts).then((snap) => fetch(url, { method: "POST", body: JSON.stringify(snap) }).then((r) => r.text()));
291
+ };
292
+ const DEFAULT_CHUNK = 900;
293
+ root.pxBehaviorStash = async function (opts, chunk) {
294
+ const json = JSON.stringify(await discover(opts));
295
+ const size = chunk || DEFAULT_CHUNK;
296
+ let ta = document.getElementById("__pxbehavior");
297
+ if (!ta) { ta = document.createElement("textarea"); ta.id = "__pxbehavior"; ta.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px"; document.body.appendChild(ta); }
298
+ ta.value = json; ta.dataset.chunk = size;
299
+ return { bytes: json.length, chunks: Math.ceil(json.length / size), chunkSize: size };
300
+ };
301
+ root.pxBehaviorRead = function (i) {
302
+ const ta = document.getElementById("__pxbehavior");
303
+ if (!ta) return null;
304
+ const size = Number(ta.dataset.chunk) || DEFAULT_CHUNK;
305
+ return ta.value.slice(i * size, (i + 1) * size);
306
+ };
307
+ })(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,82 @@
1
+ // tools/behavior-worksheet.js <name> — the BEHAVIOR WORKSHEET: one row per animation/
2
+ // dynamic the target is supposed to have, with its current disposition.
3
+ //
4
+ // WHY: when an agent doesn't know the complete list of things that are supposed to move,
5
+ // it conflates — the iphone17 run engineered ONE invented animation where live had TWO
6
+ // distinct behaviors (an intro animation + an inner phone video), and the reviewer had to
7
+ // catch it. The worksheet gives every behavior an IDENTITY up front, from three sources:
8
+ // observed — the dynamic pass saw it fire (behaviors in behaviors-live.json)
9
+ // declared — markers/keyframes/transitions say something is SUPPOSED to happen but it
10
+ // never fired in this environment (`declared` in behaviors-live.json — the
11
+ // environment-inverted case: no-js fallbacks, bot-gated choreography)
12
+ // and shows, per row: reproduced on the clone? excused in behavior-deviations.json? or
13
+ // UNRESOLVED — with a ready-to-send one-sided poll question for the human ("something is
14
+ // supposed to happen at X — on the real page, what?"), because when the machine can't
15
+ // observe the truth, the human is the measurement instrument.
16
+ //
17
+ // Exit 0 when every row has a disposition; exit 1 with the unresolved list otherwise —
18
+ // so "the worksheet is clean" is a checkable fact, and the behavior gate enforces the
19
+ // declared rows the same way (harness/workflow.js).
20
+ //
21
+ // USAGE
22
+ // node tools/behavior-worksheet.js <name>
23
+ "use strict";
24
+
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+
28
+ const WORK = process.cwd();
29
+ const dir = (name) => path.join(WORK, "targets", name);
30
+ const read = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
31
+ const descriptorOf = (key) => key.replace(/^[a-z-]+:/i, "");
32
+
33
+ // A declared row is REPRODUCED when the clone's dynamic pass observed the same element
34
+ // (by descriptor) actually firing — any observed prefix counts (reveal/mutation/hover/
35
+ // marquee): the clone made it move; which trigger class it landed under is secondary.
36
+ function dispositionOf(key, entry, cloneObserved, deviations) {
37
+ const d = descriptorOf(key);
38
+ const cloneKey = cloneObserved.find((k) => descriptorOf(k) === d);
39
+ if (cloneKey) return { status: "reproduced", via: cloneKey };
40
+ if (deviations[key] && String(deviations[key].reason || "").trim()) return { status: "excused", via: deviations[key].reason.slice(0, 80) };
41
+ return { status: "UNRESOLVED" };
42
+ }
43
+
44
+ function main() {
45
+ const name = process.argv[2];
46
+ if (!name) { console.error("usage: node tools/behavior-worksheet.js <name>"); process.exit(2); }
47
+ const livePath = path.join(dir(name), "behaviors-live.json");
48
+ if (!fs.existsSync(livePath)) { console.error(`targets/${name}/behaviors-live.json missing — run discovery first (RUNBOOK "Behavior discovery")`); process.exit(1); }
49
+ const live = read(livePath);
50
+ let clone = {};
51
+ try { clone = read(path.join(dir(name), "behaviors-clone.json")); } catch (e) {}
52
+ let dev = {};
53
+ try { dev = read(path.join(dir(name), "behavior-deviations.json")); } catch (e) {}
54
+ const cloneObserved = Object.keys((clone && clone.behaviors) || {});
55
+
56
+ const observed = Object.entries(live.behaviors || {});
57
+ const declared = Object.entries(live.declared || {});
58
+ console.log(`behavior worksheet — ${name} (${observed.length} observed, ${declared.length} declared-unfired)`);
59
+
60
+ const unresolved = [];
61
+ const row = (key, evidence, disp) => {
62
+ const mark = disp.status === "reproduced" ? "✓" : disp.status === "excused" ? "◦" : "✗";
63
+ console.log(` ${mark} ${disp.status.padEnd(10)} ${key}\n evidence: ${evidence}${disp.via ? `\n via: ${disp.via}` : ""}`);
64
+ if (disp.status === "UNRESOLVED") unresolved.push(key);
65
+ };
66
+ for (const [key, b] of observed) row(key, `${b.trigger} | ${b.kind}${b.hints ? " | " + b.hints.join(",") : ""}`, dispositionOf(key, b, cloneObserved, dev));
67
+ for (const [key, b] of declared) row(key, `${(b.hints || []).join(",")}${b.text ? ` | "${b.text}"` : ""}`, dispositionOf(key, b, cloneObserved, dev));
68
+
69
+ if (unresolved.length) {
70
+ console.log(`\n❌ ${unresolved.length} row(s) UNRESOLVED — each needs: reproduction in clone/fixes.js, a reasoned entry in behavior-deviations.json, or the human's description of what live does. Ready-to-send one-sided poll questions:`);
71
+ for (const key of unresolved.slice(0, 8)) {
72
+ const b = (live.declared && live.declared[key]) || (live.behaviors && live.behaviors[key]) || {};
73
+ const where = b.text ? `the element reading "${b.text}"` : descriptorOf(key);
74
+ console.log(` node harness/human-qa.js poll ${name} "On the real page, near ${where}: something is supposed to animate there (${(b.hints || [b.trigger]).slice(0, 2).join(", ")}). What happens as you scroll/interact?"`);
75
+ }
76
+ if (unresolved.length > 8) console.log(` … and ${unresolved.length - 8} more`);
77
+ process.exit(1);
78
+ }
79
+ console.log(`\n✓ worksheet clean — every supposed-to-move row is reproduced or excused.`);
80
+ }
81
+
82
+ main();
@@ -0,0 +1,240 @@
1
+ // browser-capture.js — the BROWSER half of pixel-diff.js, extracted verbatim so it
2
+ // can be injected as plain source on a strict-CSP live site (base64 transport of the
3
+ // whole file proved fragile). Same measurement logic → schema-identical snapshots on
4
+ // live and clone; diff them with `node tools/pixel-diff.js live.json clone.json`.
5
+ (function (root) {
6
+ "use strict";
7
+ const num = (v) => { const n = parseFloat(v); return isNaN(n) ? v : Math.round(n * 100) / 100; };
8
+ const ownText = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join("").trim();
9
+
10
+ root.pxRegion = root.pxRegion || { maxY: 200 };
11
+ const inRegion = (el) => {
12
+ const reg = root.pxRegion || {};
13
+ if (reg.sel && !el.closest(reg.sel)) return false;
14
+ const r = el.getBoundingClientRect();
15
+ if (reg.maxY != null && r.top > reg.maxY) return false;
16
+ if (reg.minY != null && r.bottom < reg.minY) return false;
17
+ return r.width > 0;
18
+ };
19
+ const byText = (re) => {
20
+ const hits = [...document.querySelectorAll("a,button,span,p,li,h1,h2,h3,h4,div,sup,small,strong")]
21
+ .filter((e) => re.test(ownText(e)) && inRegion(e));
22
+ return hits.sort((a, b) => ownText(a).length - ownText(b).length)[0] || null;
23
+ };
24
+ const byAria = (re) =>
25
+ [...document.querySelectorAll("[aria-label]")].find((e) => re.test(e.getAttribute("aria-label")) && inRegion(e)) || null;
26
+
27
+ const textBox = (el) => {
28
+ const tn = [...el.childNodes].find((n) => n.nodeType === 3 && n.textContent.trim());
29
+ if (!tn) return null;
30
+ const r = document.createRange();
31
+ r.selectNodeContents(tn);
32
+ const b = r.getBoundingClientRect();
33
+ 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) };
34
+ };
35
+
36
+ // The STRUT of the line box that positions the glyphs: the nearest line-box CONTAINER's
37
+ // line-height. A leaf can match live exactly (12 vs 12) while the container differs
38
+ // (authored 16px vs `normal`) — sub-tolerance drift on the capture machine, visibly off
39
+ // where `normal` resolves differently (the HN header miss — LEARNINGS #17). Kept
40
+ // schema-identical with pixel-diff.js measure().
41
+ const strutOf = (el) => {
42
+ let e = el, hops = 0;
43
+ while (e && hops < 8) {
44
+ const s = getComputedStyle(e);
45
+ if (/^(block|table-cell|list-item|flex|grid|inline-block|table-caption)$/.test(s.display)) {
46
+ return s.lineHeight === "normal" ? "normal" : num(s.lineHeight);
47
+ }
48
+ e = e.parentElement; hops++;
49
+ }
50
+ return null;
51
+ };
52
+
53
+ // An underline is a painted mark with a BOX (thickness/x/width/y), not a boolean —
54
+ // measure the element that actually draws it (often an ANCESTOR border-bottom) and
55
+ // return its geometry, so a too-thin / too-short / mis-offset underline is caught by
56
+ // the sweep, not by a human three rounds later (LEARNINGS #12). Schema must match
57
+ // pixel-diff.js's underlineBox exactly.
58
+ const NO_UL = { present: false };
59
+ const underlineBox = (el) => {
60
+ let e = el, hops = 0;
61
+ while (e && hops < 4) {
62
+ const s = getComputedStyle(e);
63
+ if ((s.textDecorationLine || "").includes("underline")) {
64
+ const tb = textBox(el) || {};
65
+ const th = s.textDecorationThickness && s.textDecorationThickness !== "auto"
66
+ ? num(parseFloat(s.textDecorationThickness)) : "auto";
67
+ return { present: true, thickness: th, x: tb.x ?? null, right: tb.right ?? null, w: tb.w ?? null, top: null, bottom: null };
68
+ }
69
+ const bbw = parseFloat(s.borderBottomWidth) || 0;
70
+ const r = e.getBoundingClientRect();
71
+ if (bbw > 0 && s.borderBottomStyle !== "none" && r.height < 60)
72
+ 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) };
73
+ e = e.parentElement; hops++;
74
+ }
75
+ const tb = textBox(el);
76
+ const base = el.closest("a,li") || el.parentElement;
77
+ if (tb && base) {
78
+ for (const cand of base.querySelectorAll("*")) {
79
+ if (cand === el || cand.contains(el)) continue;
80
+ const cs = getComputedStyle(cand);
81
+ const r = cand.getBoundingClientRect();
82
+ const bbw = parseFloat(cs.borderBottomWidth) || 0;
83
+ const isBorder = bbw > 0 && cs.borderBottomStyle !== "none";
84
+ const isFill = r.height <= 4;
85
+ if ((isBorder || isFill) && r.width >= tb.w * 0.4 && r.bottom >= tb.bottom - 1 && r.bottom <= tb.bottom + 8) {
86
+ const th = isBorder ? bbw : r.height;
87
+ 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) };
88
+ }
89
+ }
90
+ }
91
+ return NO_UL;
92
+ };
93
+
94
+ // The painted BACKDROP behind an element — a solid background-color is a painted
95
+ // mark (bar/button/badge) that lives on a CONTAINER, not the leaf, so the gate
96
+ // never saw it: a red announcement bar passed a green --visual. Walk self→ancestors
97
+ // for the first non-transparent background-color. Schema must match pixel-diff.js.
98
+ const TRANSPARENT_BG = "rgba(0, 0, 0, 0)";
99
+ const isTransparent = (bc) => !bc || bc === TRANSPARENT_BG || bc === "transparent" || /,\s*0\)\s*$/.test(bc);
100
+ const paintedBg = (el) => {
101
+ // Walk to the ROOT for the first opaque background; a fully-transparent chain
102
+ // resolves to the canvas default (white) so an explicit body{background:#fff}
103
+ // clone compares EQUAL to a live canvas left transparent. Match pixel-diff.js.
104
+ let e = el, hops = 0;
105
+ while (e && hops < 40) {
106
+ const bc = getComputedStyle(e).backgroundColor;
107
+ if (!isTransparent(bc)) return bc;
108
+ e = e.parentElement; hops++;
109
+ }
110
+ return "rgb(255, 255, 255)";
111
+ };
112
+
113
+ const depth = (e) => { let d = 0; while ((e = e.parentElement)) d++; return d; };
114
+ const glyphBox = (el) => {
115
+ const wrap = (b, extra) => ({
116
+ cx: num(b.left + b.width / 2), cy: num(b.top + b.height / 2),
117
+ top: num(b.top), bottom: num(b.bottom), w: num(b.width), h: num(b.height), ...extra,
118
+ });
119
+ const svg = el.tagName.toLowerCase() === "svg" ? el : el.querySelector("svg");
120
+ if (svg) {
121
+ const shapes = [...svg.querySelectorAll("path,circle,rect,polygon,polyline,line,ellipse,use")];
122
+ let t = Infinity, l = Infinity, rr = -Infinity, bb = -Infinity;
123
+ 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); } }
124
+ if (isFinite(t)) return wrap({ left: l, top: t, width: rr - l, height: bb - t }, { src: "svg-path" });
125
+ return wrap(svg.getBoundingClientRect(), { src: "svg" });
126
+ }
127
+ const withBg = [...el.querySelectorAll("*"), el].filter((e) => getComputedStyle(e).backgroundImage !== "none");
128
+ const bgEl = withBg.sort((a, b) => depth(b) - depth(a))[0];
129
+ if (bgEl) {
130
+ const bc = getComputedStyle(bgEl);
131
+ return wrap(bgEl.getBoundingClientRect(), { src: "bg", bgPos: bc.backgroundPosition, bgSize: bc.backgroundSize });
132
+ }
133
+ return wrap(el.getBoundingClientRect(), { src: "box" });
134
+ };
135
+
136
+ function measure(el, want) {
137
+ if (!el) return { present: false };
138
+ const r = el.getBoundingClientRect();
139
+ const c = getComputedStyle(el);
140
+ const vw = window.innerWidth;
141
+ const parent = el.parentElement;
142
+ const pc = parent ? getComputedStyle(parent) : null;
143
+ const prev = el.previousElementSibling;
144
+ const out = {
145
+ present: true,
146
+ 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(vw - r.right) },
147
+ font: {
148
+ family: (c.fontFamily || "").split(",")[0].replace(/["']/g, "").trim(),
149
+ weight: c.fontWeight, size: num(c.fontSize),
150
+ line: c.lineHeight === "normal" ? "normal" : num(c.lineHeight),
151
+ spacing: c.letterSpacing === "normal" ? "normal" : num(c.letterSpacing),
152
+ transform: c.textTransform, color: c.color,
153
+ decoration: c.textDecorationLine || "none",
154
+ smoothing: c.webkitFontSmoothing || c.getPropertyValue("-webkit-font-smoothing") || "auto",
155
+ },
156
+ box: {
157
+ padT: num(c.paddingTop), padR: num(c.paddingRight), padB: num(c.paddingBottom), padL: num(c.paddingLeft),
158
+ marT: num(c.marginTop), marR: num(c.marginRight), marB: num(c.marginBottom), marL: num(c.marginLeft),
159
+ bT: num(c.borderTopWidth), bR: num(c.borderRightWidth), bB: num(c.borderBottomWidth), bL: num(c.borderLeftWidth),
160
+ sizing: c.boxSizing,
161
+ },
162
+ layout: {
163
+ display: c.display, position: c.position,
164
+ top: c.top === "auto" ? "auto" : num(c.top),
165
+ left: c.left === "auto" ? "auto" : num(c.left),
166
+ vAlign: c.verticalAlign,
167
+ },
168
+ parent: pc ? { display: pc.display, gap: pc.gap === "normal" ? 0 : num(pc.gap) } : null,
169
+ bg: paintedBg(el), // painted backdrop colour (bar/button/badge) — compared by --visual
170
+ };
171
+ if (want && want.text) {
172
+ out.text = textBox(el);
173
+ out.font.strut = strutOf(el); // the line-box container's line-height (LEARNINGS #17)
174
+ out.underline = underlineBox(el); // the underline as a painted BOX
175
+ out.font.underline = out.underline.present; // boolean shorthand (back-compat)
176
+ if (prev) out.rect.prevGap = num(r.left - prev.getBoundingClientRect().right);
177
+ } else {
178
+ out.glyph = glyphBox(el);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ function capture(targets, opts) {
184
+ const T = targets || root.pxTargets || [];
185
+ const elements = {};
186
+ for (const [name, find, text] of T) {
187
+ let el = null;
188
+ try { el = find(); } catch (e) {}
189
+ elements[name] = measure(el, { text });
190
+ }
191
+ const snap = {
192
+ url: location.href,
193
+ viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio },
194
+ // The rendering MODE is a pixel-determining property of the whole page: a quirks-mode
195
+ // site (no doctype → "BackCompat") computes table-cell line boxes differently than a
196
+ // standards-mode clone, shifting text with every computed style identical (LEARNINGS #18).
197
+ mode: document.compatMode,
198
+ elements,
199
+ };
200
+ return opts && opts.compact ? JSON.stringify(snap) : JSON.stringify(snap, null, 2);
201
+ }
202
+
203
+ root.pxByText = byText;
204
+ root.pxByAria = byAria;
205
+ root.pxMeasure = measure;
206
+ root.pxCapture = capture;
207
+ root.pxSend = function (url, targets) {
208
+ return fetch(url, { method: "POST", body: capture(targets, { compact: true }) }).then((r) => r.text());
209
+ };
210
+ // The full post-hydration DOM, doctype INCLUDED-OR-ABSENT exactly as live ships it —
211
+ // outerHTML alone drops the doctype, and adding a tidy one to a quirks-mode site moves
212
+ // pixels with every computed style identical (LEARNINGS #18). Feed the result to
213
+ // `ppk capture-build <name>` (the default build strategy).
214
+ root.pxDomHtml = function () {
215
+ const dt = document.doctype;
216
+ const doctype = dt
217
+ ? "<!DOCTYPE " + dt.name + (dt.publicId ? ' PUBLIC "' + dt.publicId + '"' : "") + (!dt.publicId && dt.systemId ? " SYSTEM" : "") + (dt.systemId ? ' "' + dt.systemId + '"' : "") + ">\n"
218
+ : "";
219
+ return doctype + document.documentElement.outerHTML;
220
+ };
221
+ root.pxSendDom = function (url) {
222
+ // CSP-blocked POST? Fall back to the stash path: pxStash(null, 900, pxDomHtml()) + pxRead.
223
+ return fetch(url, { method: "POST", body: root.pxDomHtml() }).then((r) => r.text());
224
+ };
225
+ const DEFAULT_CHUNK = 900;
226
+ root.pxStash = function (targets, chunk, preJson) {
227
+ const json = preJson != null ? preJson : capture(targets, { compact: true });
228
+ const size = chunk || DEFAULT_CHUNK;
229
+ let ta = document.getElementById("__pixeldiff");
230
+ if (!ta) { ta = document.createElement("textarea"); ta.id = "__pixeldiff"; ta.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px"; document.body.appendChild(ta); }
231
+ ta.value = json; ta.dataset.chunk = size;
232
+ return { bytes: json.length, chunks: Math.ceil(json.length / size), chunkSize: size };
233
+ };
234
+ root.pxRead = function (i) {
235
+ const ta = document.getElementById("__pixeldiff");
236
+ if (!ta) return null;
237
+ const size = Number(ta.dataset.chunk) || DEFAULT_CHUNK;
238
+ return ta.value.slice(i * size, (i + 1) * size);
239
+ };
240
+ })(typeof window !== "undefined" ? window : globalThis);