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,323 @@
1
+ // harness/capture-build.js <name> [dom.html] [--qa-toolbar] — build the clone BY CAPTURE.
2
+ //
3
+ // WHY THIS EXISTS (LEARNINGS #19). The gate's chronic blind spot is TECHNIQUE mismatches —
4
+ // a clone that lands the same numbers by a different construction and rasterises
5
+ // differently (#12 #14 #15 #17 #18). Every one of those is self-inflicted by hand-rebuilding.
6
+ // A clone built FROM the captured post-hydration DOM inherits live's doctype, authored
7
+ // line-heights, font-feature-settings, and drawing primitives BY CONSTRUCTION — the whole
8
+ // defect class never exists. Evidence across this repo's targets: the one capture-built
9
+ // clone (github) passed 3136 comparisons with 0 fails and 0 structural deltas in a single
10
+ // pipeline pass; the hand-rebuilt ones burned 3 (hn) and 8 (stripe) human QA rounds on
11
+ // exactly the misses capture eliminates.
12
+ //
13
+ // WHAT IT DOES — from a captured DOM serialization (get it with `pxSendDom(...)`, see
14
+ // tools/RUNBOOK.md "Build by capture") it writes targets/<name>/clone/index.html that
15
+ // renders standalone:
16
+ // - downloads every linked stylesheet → clone/assets/css/ (self-hosted)
17
+ // - downloads every font the CSS references → clone/assets/fonts/ (self-hosted —
18
+ // the assets gate verifies their wOF2 magic; cross-origin fonts need CORS anyway)
19
+ // - rewrites every other css/html asset ref to the ABSOLUTE live URL (byte-identical
20
+ // bytes from the origin/CDN; nothing redrawn, nothing re-encoded)
21
+ // - strips <script> (a static clone must not re-hydrate/redirect), script preloads,
22
+ // the CSP <meta> (it would block the local assets), and <base> (refs are absolutized)
23
+ // - forces loading="lazy" → "eager" (a lazy image in a hidden container never fires
24
+ // its viewport check on a static page)
25
+ // - PRESERVES THE DOCTYPE — or its absence — byte-for-byte. Never "fixes" it: a live
26
+ // site with no doctype renders in quirks mode, and the capture must too (#18).
27
+ //
28
+ // WHAT STAYS YOURS: JS-driven behavior and generative content (animations, WebGL,
29
+ // carousels) cannot be captured statically — reproduce them separately, and spend human
30
+ // QA rounds there, not on statics the gate proves. The measure→visual→coverage→strict
31
+ // gates run unchanged on a capture-built clone: they still catch a font that failed to
32
+ // self-host, a stripped script that removed a load-bearing class, and environment drift.
33
+ //
34
+ // USAGE
35
+ // node harness/capture-build.js <name> [domFile] [--qa-toolbar] [--fixes]
36
+ // ppk capture-build <name> [domFile] [--qa-toolbar] [--fixes]
37
+ // domFile defaults to targets/<name>/dom.html. --qa-toolbar injects the PingHumans
38
+ // toolbar before </head> for human QA rounds (off by default: keep the clone byte-honest).
39
+ // --fixes injects <script src="fixes.js" defer></script> before </body> — the ONE vanilla
40
+ // reproduction script for the `behavior` phase (WORKFLOW.md; method: lovable_dupe_html's
41
+ // CLONE_PLAYBOOK.md §8). It's a flag, not automatic, for the same reason --qa-toolbar is:
42
+ // a capture-built clone defaults to byte-honest (no script tags at all — the whole point of
43
+ // §18/#19 is that a static clone renders identically to a stripped-JS snapshot of live). Only
44
+ // once you've written targets/<name>/clone/fixes.js do you opt in to loading it. Re-running
45
+ // capture-build (e.g. after a live re-capture) is idempotent: it re-adds the tag if missing,
46
+ // never duplicates it, and never touches fixes.js itself (that file is source you maintain by
47
+ // hand, capture-build only wires the <script> tag). Downloads use node's built-in fetch
48
+ // (node >= 18) — no curl, no subprocess.
49
+ "use strict";
50
+
51
+ const fs = require("fs");
52
+ const path = require("path");
53
+
54
+ const WORK = process.cwd();
55
+
56
+ // ── url helpers ───────────────────────────────────────────────────────────────
57
+ // A ref we must leave untouched: data:/blob:/about:, fragments, javascript:, mailto:.
58
+ const isOpaqueRef = (ref) => /^(data:|blob:|about:|javascript:|mailto:|tel:|#)/i.test(ref.trim());
59
+
60
+ // Resolve any URL reference (root-relative, relative, protocol-relative, absolute)
61
+ // against a base absolute URL. Returns null for refs that must not be rewritten.
62
+ function absolutize(ref, baseUrl) {
63
+ const r = (ref || "").trim();
64
+ if (!r || isOpaqueRef(r)) return null;
65
+ try { return new URL(r, baseUrl).href; } catch (e) { return null; }
66
+ }
67
+
68
+ const sanitizeFile = (s) => (s.replace(/[^a-zA-Z0-9._-]/g, "") || "asset");
69
+ const baseNameOfUrl = (u) => sanitizeFile(path.posix.basename(new URL(u).pathname.split("?")[0]) || "asset");
70
+
71
+ // file:// origins are first-class: point target.json's url at a saved page (e.g. a
72
+ // SingleFile dump) and assets resolve from disk — also what makes the selftest run
73
+ // offline and inside sandboxes that block sockets.
74
+ async function fetchTo(url, dest) {
75
+ if (url.startsWith("file://")) { fs.copyFileSync(new URL(url), dest); return; }
76
+ const r = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(60_000) });
77
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
78
+ fs.writeFileSync(dest, Buffer.from(await r.arrayBuffer()));
79
+ }
80
+
81
+ // ── css processing (scan is separate from rewrite: downloads are async, replace
82
+ // callbacks can't await — so we collect font URLs first, download, THEN rewrite) ──
83
+ const FONT_RE = /\.(woff2?|ttf|otf|eot)([?#]|$)/i;
84
+ const CSS_URL_RE = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s][^)]*?))\s*\)/gi;
85
+
86
+ function scanCssFontUrls(css, cssUrl) {
87
+ const found = [];
88
+ for (const m of css.matchAll(CSS_URL_RE)) {
89
+ const abs = absolutize(m[1] != null ? m[1] : m[2] != null ? m[2] : m[3], cssUrl);
90
+ if (abs && FONT_RE.test(abs)) found.push(abs);
91
+ }
92
+ return found;
93
+ }
94
+
95
+ // Rewrite one CSS text: fonts → their fontMap entry, everything else → absolute.
96
+ // `cssUrl` is the absolute URL this CSS came from (relative refs resolve against IT).
97
+ function rewriteCss(css, cssUrl, state) {
98
+ // @import targets are absolutized but NOT recursed into — fonts behind an @import
99
+ // won't be self-hosted. Loud, never silent: each one is listed in the report.
100
+ css = css.replace(/@import\s+(?:url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)|"([^"]*)"|'([^']*)')/gi, (m, a, b, c, d, e) => {
101
+ const ref = a || b || c || d || e;
102
+ const abs = absolutize(ref, cssUrl);
103
+ if (!abs) return m;
104
+ state.imports.push(abs);
105
+ return m.replace(ref, abs);
106
+ });
107
+ return css.replace(CSS_URL_RE, (m, dq, sq, bare) => {
108
+ const abs = absolutize(dq != null ? dq : sq != null ? sq : bare, cssUrl);
109
+ if (!abs) return m; // data:/blob:/fragment/unparseable — leave byte-identical
110
+ if (state.fontMap.has(abs)) return `url(${state.fontMap.get(abs)})`;
111
+ return `url(${abs})`;
112
+ });
113
+ }
114
+
115
+ async function downloadFonts(urls, state) {
116
+ for (const abs of urls) {
117
+ if (state.fontMap.has(abs)) continue;
118
+ let file = baseNameOfUrl(abs);
119
+ // same basename from a different URL → disambiguate, never silently overwrite
120
+ if ([...state.fontMap.values()].includes(`/assets/fonts/${file}`)) file = `${state.fontMap.size}-${file}`;
121
+ try {
122
+ await fetchTo(abs, path.join(state.fontDir, file));
123
+ state.fontMap.set(abs, `/assets/fonts/${file}`);
124
+ } catch (e) {
125
+ state.failures.push(`font ${abs} — ${e.message}`);
126
+ state.fontMap.set(abs, abs); // keep the absolute URL so the page still tries the CDN
127
+ }
128
+ }
129
+ }
130
+
131
+ // ── html attribute helpers ────────────────────────────────────────────────────
132
+ // Attribute values are HTML-entity-encoded in the source (a multi-query-param URL like
133
+ // Google Fonts' combined css2?family=A&family=B legally authors the "&" as "&amp;" inside
134
+ // an attribute). Passing that raw string straight to `new URL()` parses "&amp;family=B" as
135
+ // a single bogus param named "amp;family" — the server silently drops every family after
136
+ // the first, which is a silent, load-bearing asset loss (LEARNINGS-class miss: a captured
137
+ // clone falls back to a system font while every measured metric quietly drifts). Decode the
138
+ // handful of entities legally found in attribute values before treating them as URLs.
139
+ const decodeAttrEntities = (s) =>
140
+ s.replace(/&amp;/gi, "&").replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">");
141
+ const attrValue = (tag, name) => {
142
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
143
+ const raw = m ? (m[1] != null ? m[1] : m[2] != null ? m[2] : m[3]) : null;
144
+ return raw != null ? decodeAttrEntities(raw) : null;
145
+ };
146
+ const setAttrValue = (tag, name, value) =>
147
+ tag.replace(new RegExp(`\\b(${name}\\s*=\\s*)(?:"[^"]*"|'[^']*'|[^\\s>]+)`, "i"), `$1"${value}"`);
148
+ const dropAttr = (tag, name) => tag.replace(new RegExp(`\\s+${name}\\s*=\\s*(?:"[^"]*"|'[^']*'|[^\\s>]+)`, "gi"), "");
149
+
150
+ async function main() {
151
+ const args = process.argv.slice(2);
152
+ const flags = new Set(args.filter((a) => a.startsWith("--")));
153
+ const [name, domArg] = args.filter((a) => !a.startsWith("--"));
154
+ if (!name) { console.error("usage: ppk capture-build <name> [domFile] [--qa-toolbar]"); process.exit(2); }
155
+ if (typeof fetch !== "function") { console.error("capture-build needs node >= 18 (built-in fetch)"); process.exit(1); }
156
+
157
+ const dir = path.join(WORK, "targets", name);
158
+ const targetPath = path.join(dir, "target.json");
159
+ if (!fs.existsSync(targetPath)) { console.error(`targets/${name}/target.json missing — run: ppk new ${name} <url> [width]`); process.exit(1); }
160
+ const target = JSON.parse(fs.readFileSync(targetPath, "utf8"));
161
+ if (!target.url) { console.error(`targets/${name}/target.json has no url`); process.exit(1); }
162
+
163
+ const domFile = domArg ? path.resolve(WORK, domArg) : path.join(dir, "dom.html");
164
+ if (!fs.existsSync(domFile)) {
165
+ console.error(`captured DOM not found: ${path.relative(WORK, domFile)}
166
+ capture it off the LIVE page first (tools/RUNBOOK.md "Build by capture"):
167
+ 1. inject tools/browser-capture.js as plain source on the live tab
168
+ 2. await pxSendDom('http://localhost:7799/dom.html') // sink running in targets/${name}/
169
+ (CSP-blocked POST → pxStash(null, 900, pxDomHtml()) + batched pxRead)`);
170
+ process.exit(1);
171
+ }
172
+ let html = fs.readFileSync(domFile, "utf8");
173
+ if (!html.trim()) { console.error(`${path.relative(WORK, domFile)} is empty — the capture failed; re-capture.`); process.exit(1); }
174
+
175
+ const cloneDir = path.join(dir, "clone");
176
+ const cssDir = path.join(cloneDir, "assets", "css");
177
+ const fontDir = path.join(cloneDir, "assets", "fonts");
178
+ fs.mkdirSync(cssDir, { recursive: true });
179
+ fs.mkdirSync(fontDir, { recursive: true });
180
+
181
+ const state = { fontMap: new Map(), imports: [], failures: [], fontDir };
182
+
183
+ // ── the doctype is a pixel-determining property of the whole page (#18) ─────
184
+ // Preserve exactly what was captured; only REPORT the implied rendering mode.
185
+ const hasDoctype = /^\s*<!doctype/i.test(html);
186
+ const mode = hasDoctype ? "standards (CSS1Compat)" : "QUIRKS (BackCompat — live ships no doctype; preserved, do not add one)";
187
+
188
+ // ── pass 1: discover + download stylesheets, then fonts, then rewrite CSS ───
189
+ // cssMap: absolute stylesheet URL → local /assets/css/ path (used to rewrite both
190
+ // the <link rel=stylesheet> tags and any rel=preload as=style pointing at the same URL).
191
+ const cssMap = new Map(); // absUrl → local path
192
+ const cssText = new Map(); // absUrl → { dest, css }
193
+ let cssIdx = 0;
194
+ for (const tag of html.match(/<link\b[^>]*>/gi) || []) {
195
+ const rel = (attrValue(tag, "rel") || "").toLowerCase();
196
+ if (!/\bstylesheet\b/.test(rel)) continue;
197
+ const href = attrValue(tag, "href");
198
+ const abs = href && absolutize(href, target.url);
199
+ if (!abs || cssMap.has(abs)) continue;
200
+ const file = `${String(cssIdx++).padStart(2, "0")}-${baseNameOfUrl(abs).replace(/\.css$|$/i, ".css")}`;
201
+ const dest = path.join(cssDir, file);
202
+ try {
203
+ await fetchTo(abs, dest);
204
+ cssText.set(abs, { dest, css: fs.readFileSync(dest, "utf8") });
205
+ cssMap.set(abs, `/assets/css/${file}`);
206
+ } catch (e) {
207
+ state.failures.push(`stylesheet ${abs} — ${e.message}`);
208
+ }
209
+ }
210
+
211
+ // fonts referenced by downloaded CSS or inline <style> blocks — collect, then fetch
212
+ const fontUrls = [];
213
+ for (const [abs, { css }] of cssText) fontUrls.push(...scanCssFontUrls(css, abs));
214
+ for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)) fontUrls.push(...scanCssFontUrls(m[1], target.url));
215
+ await downloadFonts(fontUrls, state);
216
+
217
+ for (const [abs, { dest, css }] of cssText) fs.writeFileSync(dest, rewriteCss(css, abs, state));
218
+
219
+ // ── pass 2: rewrite the HTML ─────────────────────────────────────────────────
220
+ // scripts: a static clone must never re-hydrate, redirect, or blank itself
221
+ html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "").replace(/<script\b[^>]*\/>/gi, "");
222
+ // CSP meta would block the self-hosted assets we just created
223
+ html = html.replace(/<meta\b[^>]*http-equiv\s*=\s*["']?content-security-policy["']?[^>]*>/gi, "");
224
+ // <base> would re-relativize everything — we absolutize instead, so it must go
225
+ const hadBase = /<base\b[^>]*>/i.test(html);
226
+ html = html.replace(/<base\b[^>]*>/gi, "");
227
+
228
+ html = html.replace(/<link\b[^>]*>/gi, (tag) => {
229
+ const rel = (attrValue(tag, "rel") || "").toLowerCase();
230
+ const as = (attrValue(tag, "as") || "").toLowerCase();
231
+ if (/\bmodulepreload\b/.test(rel) || (/\bpreload\b/.test(rel) && as === "script")) return ""; // script preloads: gone with the scripts
232
+ const href = attrValue(tag, "href");
233
+ const abs = href && absolutize(href, target.url);
234
+ if (!abs) return tag;
235
+ let local = null;
236
+ if (cssMap.has(abs)) local = cssMap.get(abs);
237
+ else if (state.fontMap.has(abs) && state.fontMap.get(abs) !== abs) local = state.fontMap.get(abs);
238
+ let out = setAttrValue(tag, "href", local || abs);
239
+ // SRI/crossorigin were computed for the ORIGINAL bytes at the original origin —
240
+ // they'd block the rewritten local copy
241
+ if (local) out = dropAttr(dropAttr(out, "integrity"), "crossorigin");
242
+ return out;
243
+ });
244
+
245
+ // src/poster on img/video/iframe/source/etc → absolute (bytes come from the live origin/CDN)
246
+ html = html.replace(/\b(src|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi, (m, attr, dq, sq) => {
247
+ const abs = absolutize(dq != null ? dq : sq, target.url);
248
+ return abs ? `${attr}="${abs}"` : m;
249
+ });
250
+ html = html.replace(/\bsrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi, (m, dq, sq) => {
251
+ const val = dq != null ? dq : sq;
252
+ if (/^data:/i.test(val.trim())) return m;
253
+ const rewritten = val.split(",").map((entry) => {
254
+ const t = entry.trim();
255
+ if (!t) return t;
256
+ const [u, ...desc] = t.split(/\s+/);
257
+ const abs = absolutize(u, target.url);
258
+ return [abs || u, ...desc].join(" ");
259
+ }).join(", ");
260
+ return `srcset="${rewritten}"`;
261
+ });
262
+ // a lazy image inside a hidden/JS-toggled container never fires its viewport check
263
+ html = html.replace(/\bloading\s*=\s*(?:"lazy"|'lazy'|lazy\b)/gi, 'loading="eager"');
264
+
265
+ // inline <style> blocks get the same treatment as downloaded CSS (base = the page URL)
266
+ html = html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style\s*>)/gi, (m, open, css, close) => open + rewriteCss(css, target.url, state) + close);
267
+
268
+ if (flags.has("--qa-toolbar")) html = html.replace(/<\/head>/i, '<script src="https://pinghumans.com/qa-toolbar.js"></script></head>');
269
+
270
+ // --fixes: wire the ONE vanilla behavior-reproduction script (never generated here — you
271
+ // write clone/fixes.js by hand, per-target, following lovable_dupe_html/CLONE_PLAYBOOK.md
272
+ // §8). Idempotent: skip if a fixes.js tag is already present (re-running capture-build
273
+ // after a live re-capture must not duplicate it or clobber a hand-edited script tag).
274
+ let fixesWired = false;
275
+ if (flags.has("--fixes")) {
276
+ if (/\bsrc\s*=\s*["']?\.?\/?fixes\.js\b/i.test(html)) { fixesWired = true; }
277
+ else { html = html.replace(/<\/body>/i, '<script src="fixes.js" defer></script></body>'); fixesWired = true; }
278
+ }
279
+
280
+ fs.writeFileSync(path.join(cloneDir, "index.html"), html);
281
+ // Scaffold a starter fixes.js ONLY if --fixes was requested and none exists yet — never
282
+ // overwrite one you've already written (that's the file discovery + measurement feeds).
283
+ const fixesPath = path.join(cloneDir, "fixes.js");
284
+ if (flags.has("--fixes") && !fs.existsSync(fixesPath)) {
285
+ fs.writeFileSync(fixesPath, `// clone/fixes.js — one vanilla IIFE that re-drives this target's JS-driven dynamics.
286
+ // No framework. The HTML/CSS stay byte-exact; this only animates what a static capture
287
+ // can't show. Method: lovable_dupe_html/CLONE_PLAYBOOK.md §8 (ported, not reinvented).
288
+ // Discover behaviors on LIVE first with tools/behavior-capture.js (pxBehaviorDiscover) —
289
+ // that inventory (behaviors-live.json) is your to-do list; each entry here should
290
+ // correspond to one key in it. Guard each behavior in its own try so one bug doesn't
291
+ // blank the rest of the page.
292
+ (function () {
293
+ "use strict";
294
+ const ready = (fn) => (document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", fn) : fn());
295
+ ready(function () {
296
+ // try { initYourBehaviorHere(); } catch (e) { console.error("fixes.js:", e); }
297
+ });
298
+ })();
299
+ `);
300
+ }
301
+
302
+ // ── report — loud about everything that is NOT a byte-identical capture ─────
303
+ console.log(`clone/index.html written — ${html.length} bytes (from ${path.relative(WORK, domFile)})
304
+ doctype: ${mode}
305
+ css: ${cssMap.size} stylesheet(s) self-hosted → clone/assets/css/
306
+ fonts: ${[...state.fontMap.values()].filter((v) => v.startsWith("/assets/")).length} self-hosted → clone/assets/fonts/ (assets gate checks their wOF2 magic)
307
+ stripped: <script> tags, script preloads, CSP <meta>${hadBase ? ", <base> (refs absolutized)" : ""}
308
+ fixes.js: ${fixesWired ? `wired (<script src="fixes.js" defer> before </body>)` : "not wired — pass --fixes once you have targets/" + name + "/clone/fixes.js (behavior phase)"}`);
309
+ if (state.imports.length) console.log(` ⚠ @import: ${state.imports.length} absolutized, NOT recursed — fonts behind them are not self-hosted:\n ${state.imports.join("\n ")}`);
310
+ if (state.failures.length) {
311
+ console.error(` ❌ ${state.failures.length} download(s) FAILED — these are pixel-determining; fix before advancing build:\n ${state.failures.join("\n ")}`);
312
+ process.exit(1);
313
+ }
314
+ console.log(`
315
+ next: serve + capture the clone (RUNBOOK), then the gates run unchanged:
316
+ ${process.env.PPK_ENTRY === "1" ? "ppk" : "node harness/workflow.js"} status ${name}
317
+ JS-driven behavior + animated/generative content can't be captured statically — discover +
318
+ measure it on live with tools/behavior-capture.js, reproduce in clone/fixes.js, rebuild with
319
+ --fixes, then capture behaviors-clone.json the same way. See WORKFLOW.md's \`behavior\` phase.`);
320
+ }
321
+
322
+ if (require.main === module) main().catch((e) => { console.error(`capture-build failed: ${e.message}`); process.exit(1); });
323
+ module.exports = { absolutize, scanCssFontUrls, rewriteCss };
@@ -0,0 +1,58 @@
1
+ // harness/doctor-selftest.js — guards the new-user onboarding surface: `ppk doctor`'s
2
+ // check/report logic and `ppk agent-setup`'s skill install. Offline + socket-free.
3
+ "use strict";
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const { checkNode, checkPingHumansToken, report } = require("./doctor.js");
8
+ const { install } = require("./agent-setup.js");
9
+
10
+ let failed = 0;
11
+ const ok = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { failed++; console.log(` ✗ ${msg}`); } };
12
+
13
+ console.log("doctor-selftest — onboarding preflight + agent skill install");
14
+
15
+ // ── doctor checks (pure) ──────────────────────────────────────────────────────
16
+ ok(checkNode("22.1.0").ok && checkNode("18.0.0").ok, "node 18/22 pass the version check");
17
+ ok(!checkNode("16.20.0").ok && checkNode("16.20.0").fix.includes("18"), "node 16 fails with an actionable fix");
18
+ ok(checkPingHumansToken(() => "tok_abc").ok, "token resolver returning a token passes");
19
+ const noTok = checkPingHumansToken(() => null);
20
+ ok(!noTok.ok && /cpyany setup/.test(noTok.fix), "missing token fails, fix names `npx cpyany setup`");
21
+ ok(!checkPingHumansToken(() => { throw new Error("boom"); }).ok, "a throwing resolver counts as no token, never crashes doctor");
22
+
23
+ // report(): exit code reflects REQUIRED failures only; optional misses are warnings
24
+ {
25
+ const logs = [];
26
+ const orig = console.log;
27
+ console.log = (...a) => logs.push(a.join(" "));
28
+ const codeAllGreen = report([{ name: "x", ok: true, detail: "d", required: true }]);
29
+ const codeOptionalMiss = report([{ name: "x", ok: true, detail: "d", required: true }, { name: "opt", ok: false, detail: "d", fix: "f", required: false }]);
30
+ const codeRequiredMiss = report([{ name: "x", ok: false, detail: "d", fix: "f", required: true }]);
31
+ console.log = orig;
32
+ ok(codeAllGreen === 0, "all green → exit 0");
33
+ ok(codeOptionalMiss === 0, "an optional miss is a warning, not a failure");
34
+ ok(codeRequiredMiss === 1, "a required miss → exit 1");
35
+ ok(logs.some((l) => /browser automation/i.test(l)), "report always names the browser-agent requirement (undetectable from node)");
36
+ ok(logs.some((l) => /agent-setup/.test(l)), "the all-green report points at the next step (ppk agent-setup)");
37
+ }
38
+
39
+ // ── agent-setup install (real files, fake HOME) ───────────────────────────────
40
+ {
41
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "ppk-doctor-"));
42
+ const r1 = install(home, false);
43
+ const dest = path.join(home, ".claude", "skills", "pixel-perfect-clone", "SKILL.md");
44
+ const destDitto = path.join(home, ".claude", "skills", "ditto-finish", "SKILL.md");
45
+ ok(r1.ok && fs.existsSync(dest) && fs.existsSync(destDitto), "installs ALL kit skills (pixel-perfect-clone + ditto-finish) into ~/.claude/skills/");
46
+ const body = fs.readFileSync(dest, "utf8");
47
+ ok(/^---\nname: pixel-perfect-clone/.test(body) && /description: .*[Cc]lone/.test(body), "installed skill has the frontmatter Claude Code discovers it by");
48
+ ok(/ppk doctor/.test(body) && /ppk where/.test(body) && /verdict button/i.test(body), "skill teaches preflight, kit location, and the reviewer contract");
49
+ const ditto = fs.readFileSync(destDitto, "utf8");
50
+ ok(/^---\nname: ditto-finish/.test(ditto) && /ppk adopt/.test(ditto) && /--changelog/.test(ditto) && /verdict/i.test(ditto), "ditto-finish skill teaches adopt → tunnel --url → human loop with changelogs + verdicts");
51
+ const r2 = install(home, false);
52
+ ok(!r2.ok && /--force/.test(r2.message), "refuses to overwrite an existing install without --force");
53
+ ok(install(home, true).ok, "--force overwrites");
54
+ fs.rmSync(home, { recursive: true, force: true });
55
+ }
56
+
57
+ console.log(failed ? `\n❌ doctor-selftest: ${failed} assertion(s) failed.` : "\n✓ doctor-selftest: all assertions pass.");
58
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,85 @@
1
+ // harness/doctor.js — `ppk doctor`: the one-command preflight for new installs.
2
+ //
3
+ // WHY: the kit's failure surface for a NEWCOMER is spread across four tools that fail at
4
+ // the worst possible moments — wrong node errors on the first fetch, missing cloudflared
5
+ // only at tunnel time (an hour into a run), a missing PingHumans login only at the human
6
+ // phase (hours in). Each failure is self-describing WHEN HIT; doctor moves all of them to
7
+ // minute one, with the fix command per miss. Run it right after install, and have agents
8
+ // run it before starting a clone (LAUNCH-PROMPT / the clone-site skill both say to).
9
+ //
10
+ // USAGE: ppk doctor (exit 0 = ready for the full pipeline, 1 = something missing)
11
+ "use strict";
12
+
13
+ const { spawnSync } = require("child_process");
14
+
15
+ // Each check: { name, ok, detail, fix?, required } — pure data so the selftest can drive
16
+ // the report logic without real binaries. Exported builders take injectable probes.
17
+ function checkNode(versionString) {
18
+ const major = parseInt((versionString || "").split(".")[0], 10);
19
+ return {
20
+ name: "node >= 18",
21
+ ok: major >= 18,
22
+ detail: `found ${versionString}`,
23
+ fix: "install Node 18+ (https://nodejs.org)",
24
+ required: true,
25
+ };
26
+ }
27
+
28
+ function checkBinary(name, args, why, fix, required) {
29
+ let ok = false, detail = "not found on PATH";
30
+ try {
31
+ const r = spawnSync(name, args, { stdio: "pipe", timeout: 10_000 });
32
+ if (r.status === 0 || (r.stdout && r.stdout.length) || (r.stderr && r.stderr.length && !r.error)) {
33
+ ok = !r.error;
34
+ detail = ok ? String(r.stdout || r.stderr).split("\n")[0].trim().slice(0, 60) : detail;
35
+ }
36
+ } catch (e) { /* not found */ }
37
+ return { name: `${name} (${why})`, ok, detail, fix, required };
38
+ }
39
+
40
+ function checkPingHumansToken(resolveToken) {
41
+ let token = null;
42
+ try { token = resolveToken(); } catch (e) {}
43
+ return {
44
+ name: "PingHumans login (marketplace review rounds)",
45
+ ok: !!token,
46
+ detail: token ? "token found" : "no token found",
47
+ fix: "run: npx cpyany setup — OR skip it: LOCAL review mode needs no account (`ppk human <name> file --local`, you review at /__review yourself)",
48
+ required: false,
49
+ };
50
+ }
51
+
52
+ function report(checks) {
53
+ let failedRequired = 0;
54
+ for (const c of checks) {
55
+ const mark = c.ok ? "✓" : c.required ? "❌" : "⚠";
56
+ console.log(`${mark} ${c.name} — ${c.detail}`);
57
+ if (!c.ok && c.fix) console.log(` fix: ${c.fix}`);
58
+ if (!c.ok && c.required) failedRequired++;
59
+ }
60
+ console.log(`
61
+ ℹ your AI agent needs BROWSER AUTOMATION to drive live-site captures (e.g. Claude Code
62
+ with the Chrome extension / claude-in-chrome MCP) — doctor can't verify that from here;
63
+ make sure your agent can open and script a browser tab.`);
64
+ if (failedRequired) {
65
+ console.log(`\n❌ ${failedRequired} required check(s) failed — fix the lines above, then re-run: ppk doctor`);
66
+ return 1;
67
+ }
68
+ console.log(`\n✓ ready. Teach your agent next (once): ppk agent-setup
69
+ then just ask it: "Clone https://example.com pixel-perfect."`);
70
+ return 0;
71
+ }
72
+
73
+ function main() {
74
+ const { resolveToken } = require("./human-qa.js");
75
+ const checks = [
76
+ checkNode(process.versions.node),
77
+ checkBinary("cloudflared", ["--version"], "public tunnels — needed for marketplace review, not for local mode", "brew install cloudflared (or https://developers.cloudflare.com/cloudflared)", false),
78
+ checkPingHumansToken(resolveToken),
79
+ checkBinary("ffmpeg", ["-version"], "optional — frame-level video verification", "brew install ffmpeg", false),
80
+ ];
81
+ process.exit(report(checks));
82
+ }
83
+
84
+ if (require.main === module) main();
85
+ module.exports = { checkNode, checkBinary, checkPingHumansToken, report };
@@ -0,0 +1,51 @@
1
+ // fixtures/01-backdrop-color.js — a painted BACKDROP colour (announcement bar /
2
+ // button / badge) is a mark the gate must compare. Found on aloyoga: the blue
3
+ // announcement bar lives on a CONTAINER, not the text leaf, so `background-color`
4
+ // was never captured — a bright-red bar passed a green --visual (the red-bar
5
+ // snapshot was byte-identical to the good one). The fix: capture `bg` = the nearest
6
+ // painted background behind each mark (transparent chain → the white canvas), and
7
+ // compare it in --visual. This fixture fails WITHOUT that comparison.
8
+ const { diffSnapshots } = require("../../tools/pixel-diff.js");
9
+ let bad = 0; const check = (n, c) => { console.log(`${c ? "✓" : "✗"} ${n}`); if (!c) bad++; };
10
+
11
+ // minimal text target carrying a painted backdrop colour
12
+ const el = (bg) => ({
13
+ present: true,
14
+ rect: { x: 15, y: 15, w: 1698, h: 20, top: 15, right: 1713, bottom: 35 },
15
+ font: { weight: "600", size: 14, color: "rgb(0, 0, 0)" },
16
+ box: {}, layout: {}, parent: null,
17
+ text: { x: 522, right: 1205, top: 16, bottom: 35, w: 683, h: 19 },
18
+ underline: { present: false },
19
+ bg,
20
+ });
21
+ const snap = (e) => ({ viewport: { width: 1728 }, elements: { announce: e } });
22
+
23
+ // 1) a wrong backdrop colour (live blue bar vs a red clone bar) must FAIL on `bg`
24
+ {
25
+ const res = diffSnapshots(snap(el("rgb(113, 198, 235)")), snap(el("rgb(255, 0, 0)")), { visual: true });
26
+ check("gate catches wrong backdrop colour", !res.ok && res.rows.some((r) => !r.pass && r.prop === "bg"));
27
+ }
28
+ // 2) an identical backdrop must PASS (no false positive)
29
+ {
30
+ const res = diffSnapshots(snap(el("rgb(113, 198, 235)")), snap(el("rgb(113, 198, 235)")), { visual: true });
31
+ check("identical backdrop passes", res.ok);
32
+ }
33
+ // 3) transparent-on-both must be SKIPPED (text/icons on the canvas add no noise)
34
+ {
35
+ const res = diffSnapshots(snap(el("rgba(0, 0, 0, 0)")), snap(el("rgba(0, 0, 0, 0)")), { visual: true });
36
+ check("transparent backdrop adds no bg row", res.ok && !res.rows.some((r) => r.prop === "bg"));
37
+ }
38
+ // 4) TRANSLUCENT backdrops are out of scope — the gate compares only OPAQUE colours
39
+ // (a translucent layer composites to pixels we can't reconstruct from the string, so
40
+ // comparing it would false-positive a translucent-vs-solid pair that looks identical).
41
+ {
42
+ const res = diffSnapshots(snap(el("rgba(0, 0, 0, 0.5)")), snap(el("rgb(128,128,128)")), { visual: true });
43
+ check("translucent-vs-solid adds no bg row (no false positive)", res.ok && !res.rows.some((r) => r.prop === "bg"));
44
+ }
45
+ // 5) whitespace in the colour string must not matter ("rgb(0,0,0)" == "rgb(0, 0, 0)")
46
+ {
47
+ const res = diffSnapshots(snap(el("rgb(113,198,235)")), snap(el("rgb(113, 198, 235)")), { visual: true });
48
+ check("colour compare is whitespace-insensitive", res.ok);
49
+ }
50
+
51
+ process.exit(bad ? 1 : 0);
@@ -0,0 +1,53 @@
1
+ // fixtures/02-line-strut.js — the line-box CONTAINER's line-height (the strut) positions
2
+ // the glyphs; the leaf's own line-height can match live exactly while the container
3
+ // differs. Found on the HN header (human-flagged after a green --visual): live authored
4
+ // `line-height:12pt` (16px) on the td, the clone left the td `normal` — every measured
5
+ // leaf matched (12 vs 12), the same-machine offset was 0.25px (under tolerance), but
6
+ // `normal` resolves differently across platforms so the text sat visibly lower for the
7
+ // human tester. The fix: capture `font.strut` = the nearest line-box container's
8
+ // line-height and compare it in --visual — `normal` vs a number is a technique mismatch
9
+ // that fails loudly regardless of the sub-pixel same-machine delta. LEARNINGS #17.
10
+ const { diffSnapshots } = require("../../tools/pixel-diff.js");
11
+ let bad = 0; const check = (n, c) => { console.log(`${c ? "✓" : "✗"} ${n}`); if (!c) bad++; };
12
+
13
+ // minimal text target: leaf line-height matches (12 vs 12); only the strut varies
14
+ const el = (strut) => ({
15
+ present: true,
16
+ rect: { x: 30, y: 10, w: 300, h: 20, top: 10, right: 330, bottom: 30 },
17
+ font: { weight: "700", size: 13.33, line: 12, spacing: "normal", transform: "none",
18
+ color: "rgb(0, 0, 0)", decoration: "none", smoothing: "auto",
19
+ ...(strut === undefined ? {} : { strut }) },
20
+ box: {}, layout: {}, parent: null,
21
+ text: { x: 30, right: 330, top: 11.5, bottom: 28, w: 300, h: 16.5 },
22
+ underline: { present: false },
23
+ bg: "rgb(255, 102, 0)",
24
+ });
25
+ const snap = (e) => ({ viewport: { width: 1512 }, elements: { title: e } });
26
+
27
+ // 1) authored strut (16px) vs `normal` must FAIL on font.strut — the exact HN defect
28
+ {
29
+ const res = diffSnapshots(snap(el(16)), snap(el("normal")), { visual: true });
30
+ check("gate catches strut mismatch (16 vs normal) even when the leaf line matches", !res.ok && res.rows.some((r) => !r.pass && r.prop === "font.strut"));
31
+ }
32
+ // 2) two different NUMBERS over tolerance must fail too (16 vs 18)
33
+ {
34
+ const res = diffSnapshots(snap(el(16)), snap(el(18)), { visual: true });
35
+ check("gate catches numeric strut delta (16 vs 18)", !res.ok && res.rows.some((r) => !r.pass && r.prop === "font.strut"));
36
+ }
37
+ // 3) identical struts pass (no false positive)
38
+ {
39
+ const res = diffSnapshots(snap(el(16)), snap(el(16)), { visual: true });
40
+ check("identical strut passes", res.ok);
41
+ }
42
+ // 4) both `normal` passes — same technique on both sides is a match
43
+ {
44
+ const res = diffSnapshots(snap(el("normal")), snap(el("normal")), { visual: true });
45
+ check("normal-vs-normal passes (same technique)", res.ok);
46
+ }
47
+ // 5) schema back-compat: a capture that predates strut (undefined) must add NO strut row
48
+ {
49
+ const res = diffSnapshots(snap(el(undefined)), snap(el(16)), { visual: true });
50
+ check("old-schema snapshot adds no strut row (no mixed-capture false positive)", res.ok && !res.rows.some((r) => r.prop === "font.strut"));
51
+ }
52
+
53
+ process.exit(bad ? 1 : 0);
@@ -0,0 +1,44 @@
1
+ // fixtures/03-compat-mode.js — the rendering MODE is a pixel-determining property of the
2
+ // whole page. Found on the HN header (human-flagged, round 3): live HN ships NO doctype →
3
+ // quirks mode ("BackCompat"); the clone's `<!doctype html>` → standards mode. Quirks
4
+ // computes table-cell line boxes differently, so the login line sat 0.25px lower with
5
+ // EVERY computed style byte-identical — no element-level property could ever catch it.
6
+ // The fix: capture `mode` (document.compatMode) in the snapshot root and fail loudly on a
7
+ // mismatch. LEARNINGS #18.
8
+ const { diffSnapshots } = require("../../tools/pixel-diff.js");
9
+ let bad = 0; const check = (n, c) => { console.log(`${c ? "✓" : "✗"} ${n}`); if (!c) bad++; };
10
+
11
+ const el = () => ({
12
+ present: true,
13
+ rect: { x: 30, y: 10, w: 300, h: 20, top: 10, right: 330, bottom: 30 },
14
+ font: { weight: "400", size: 13.33, line: 12, spacing: "normal", transform: "none",
15
+ color: "rgb(0, 0, 0)", decoration: "none", smoothing: "auto", strut: 16 },
16
+ box: {}, layout: {}, parent: null,
17
+ text: { x: 30, right: 330, top: 11.5, bottom: 28, w: 300, h: 16.5 },
18
+ underline: { present: false },
19
+ bg: "rgb(255, 102, 0)",
20
+ });
21
+ const snap = (mode) => ({ viewport: { width: 1512 }, ...(mode ? { mode } : {}), elements: { title: el() } });
22
+
23
+ // 1) quirks vs standards must FAIL on page.mode — the exact HN defect
24
+ {
25
+ const res = diffSnapshots(snap("BackCompat"), snap("CSS1Compat"), { visual: true });
26
+ check("gate catches quirks-vs-standards mode mismatch", !res.ok && res.rows.some((r) => !r.pass && r.target === "page" && r.prop === "mode"));
27
+ }
28
+ // 2) same mode on both sides passes
29
+ {
30
+ const res = diffSnapshots(snap("BackCompat"), snap("BackCompat"), { visual: true });
31
+ check("same mode passes", res.ok);
32
+ }
33
+ // 3) schema back-compat: a capture that predates `mode` adds NO row (no false positive)
34
+ {
35
+ const res = diffSnapshots(snap(null), snap("BackCompat"), { visual: true });
36
+ check("old-schema snapshot adds no mode row", res.ok && !res.rows.some((r) => r.prop === "mode"));
37
+ }
38
+ // 4) strict mode fails on it too (it's a defect in both modes of the diff)
39
+ {
40
+ const res = diffSnapshots(snap("BackCompat"), snap("CSS1Compat"), {});
41
+ check("strict also fails on mode mismatch", !res.ok && res.rows.some((r) => !r.pass && r.prop === "mode"));
42
+ }
43
+
44
+ process.exit(bad ? 1 : 0);