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,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * reassemble.js — join pxRead() chunks into a validated snapshot file.
4
+ *
5
+ * Hand-concatenating pxStash/pxRead chunks is the single most error-prone step in the
6
+ * capture loop: a dropped character at a chunk boundary produced a silently-corrupt
7
+ * live.json that only surfaced rounds later as unexplainable diffs (found during the
8
+ * HN dogfood run). This makes reassembly a verified operation instead:
9
+ * 1. joins the chunk files IN THE ORDER GIVEN,
10
+ * 2. checks the total byte count against pxStash's reported `bytes` (pass --bytes),
11
+ * 3. JSON-parses the result and requires the snapshot shape (.elements),
12
+ * and only then writes the output. Any failure names the exact problem and exits 2.
13
+ *
14
+ * usage: node tools/reassemble.js <out.json> [--bytes N] <chunk-file> [<chunk-file>…]
15
+ * Save each pxRead(i) result to its own file (chunk-00.txt, chunk-01.txt, …), then:
16
+ * node tools/reassemble.js targets/hn/live.json --bytes 18342 chunk-*.txt
17
+ * (shell glob order is lexicographic — zero-pad chunk indexes so it matches read order.)
18
+ */
19
+ "use strict";
20
+ const fs = require("fs");
21
+
22
+ const argv = process.argv.slice(2);
23
+ const files = [];
24
+ let out = null, bytes = null, bytesRaw;
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const a = argv[i];
27
+ if (a === "--bytes") { bytesRaw = argv[++i]; bytes = parseInt(bytesRaw, 10); }
28
+ else if (a.startsWith("--")) { console.error(`unknown flag "${a}". valid: --bytes <n>`); process.exit(2); }
29
+ else if (out === null) out = a;
30
+ else files.push(a);
31
+ }
32
+ if (!out || !files.length) {
33
+ console.error("usage: node tools/reassemble.js <out.json> [--bytes N] <chunk-file> [<chunk-file>…]\n --bytes the `bytes` value pxStash() reported — verifies nothing was lost in transit");
34
+ process.exit(2);
35
+ }
36
+ if (bytesRaw !== undefined && !(Number.isInteger(bytes) && bytes > 0)) {
37
+ console.error(`--bytes needs a positive integer (got "${bytesRaw}").`);
38
+ process.exit(2);
39
+ }
40
+
41
+ let joined = "";
42
+ for (const f of files) {
43
+ if (!fs.existsSync(f)) { console.error(`chunk file ${f} not found — save each pxRead(i) result to its own file first.`); process.exit(2); }
44
+ joined += fs.readFileSync(f, "utf8");
45
+ }
46
+
47
+ if (bytes !== null && joined.length !== bytes) {
48
+ console.error(`byte count mismatch: pxStash reported ${bytes}, chunks join to ${joined.length} (${joined.length < bytes ? "missing" : "extra"} ${Math.abs(joined.length - bytes)} bytes).\n A chunk was dropped, duplicated, or truncated — re-run the pxRead(i) that looks short and check the file order (zero-pad indexes so glob order matches).`);
49
+ process.exit(2);
50
+ }
51
+
52
+ let snap;
53
+ try { snap = JSON.parse(joined); } catch (e) {
54
+ console.error(`joined chunks are not valid JSON: ${e.message}\n A chunk boundary is corrupt — re-read the chunk around the reported position; do not hand-edit the JSON.`);
55
+ process.exit(2);
56
+ }
57
+ if (!snap || !snap.elements) {
58
+ console.error(`joined JSON has no "elements" — this isn't a pxCapture() snapshot (a pxInspect() dump is fine to save directly, no reassembly needed).`);
59
+ process.exit(2);
60
+ }
61
+
62
+ fs.writeFileSync(out, joined);
63
+ console.log(`✓ ${out} written — ${joined.length} bytes, ${Object.keys(snap.elements).length} elements, JSON valid${bytes !== null ? ", byte count matches pxStash" : ""}.`);
@@ -0,0 +1,67 @@
1
+ // selftest.js — guards the DIFF ENGINE itself (run: node tools/selftest.js)
2
+ //
3
+ // The kit's whole promise is "a green --visual means it looks identical." That promise
4
+ // is only as good as the properties the diff actually compares. Two classes of miss
5
+ // (an underline measured as a boolean; -webkit-font-smoothing not measured) each cost
6
+ // several human rounds before they were added to the gate (LEARNINGS #12, #13). This
7
+ // file locks those in: it feeds diffSnapshots synthetic snapshots and asserts that
8
+ // (a) an identical pair PASSES, and
9
+ // (b) the exact defects that used to slip a green sweep now FAIL, on the named props.
10
+ // If someone later refactors pixel-diff.js and drops one of these comparisons, this
11
+ // test goes red — the guarantee can't silently regress. Zero deps.
12
+
13
+ const { diffSnapshots } = require("./pixel-diff.js");
14
+
15
+ let failures = 0;
16
+ const check = (name, cond) => { console.log(`${cond ? "✓" : "✗"} ${name}`); if (!cond) failures++; };
17
+
18
+ // A minimal text target (the "sign in" label) with the marks the gate must compare.
19
+ const signin = (over = {}) => ({
20
+ present: true,
21
+ rect: { x: 1395, y: 81, w: 186, h: 17, top: 81, right: 1581, bottom: 98, fromRight: 147 },
22
+ font: {
23
+ family: "proxima-nova", weight: "600", size: 14, line: 18.2, spacing: 0.7,
24
+ transform: "uppercase", color: "rgb(0, 0, 0)", decoration: "none",
25
+ smoothing: "antialiased", underline: true,
26
+ ...(over.font || {}),
27
+ },
28
+ box: {}, layout: {}, parent: { display: "flex", gap: 8 },
29
+ text: { x: 1395, right: 1581, top: 81, bottom: 98, w: 186, h: 17 },
30
+ // the underline as a BOX (drawn by an ancestor border-bottom), not a boolean:
31
+ underline: { present: true, thickness: 2, x: 1369, right: 1581, w: 212, top: 100, bottom: 102,
32
+ ...(over.underline || {}) },
33
+ });
34
+
35
+ const snap = (el) => ({ viewport: { width: 1728 }, elements: { signin: el } });
36
+ const failedProps = (res) => res.rows.filter((r) => !r.pass).map((r) => r.prop);
37
+
38
+ // 1) identical → --visual PASSES
39
+ {
40
+ const res = diffSnapshots(snap(signin()), snap(signin()), { visual: true });
41
+ check("identical pair passes --visual", res.ok);
42
+ }
43
+
44
+ // 2) the ORIGINAL defects (LEARNINGS #12/#13) → --visual FAILS on the named props
45
+ {
46
+ const bad = signin({
47
+ font: { smoothing: "auto" }, // #13 perceived weight
48
+ underline: { thickness: 1, x: 1395, w: 186, top: 98, bottom: 99 }, // #12 thin / text-only / wrong Y
49
+ });
50
+ const res = diffSnapshots(snap(signin()), snap(bad), { visual: true });
51
+ const props = failedProps(res);
52
+ check("smoothing regression fails", !res.ok && props.includes("font.smoothing"));
53
+ check("underline thickness fails", props.includes("underline.thickness"));
54
+ check("underline width fails", props.includes("underline.w"));
55
+ check("underline x-offset fails", props.includes("underline.x"));
56
+ check("underline vertical fails", props.includes("underline.top") || props.includes("underline.bottom"));
57
+ }
58
+
59
+ // 3) a present underline on one side only is caught (not silently skipped)
60
+ {
61
+ const noUnderline = signin({ font: { underline: false }, underline: { present: false } });
62
+ const res = diffSnapshots(snap(signin()), snap(noUnderline), { visual: true });
63
+ check("missing underline is caught", !res.ok && failedProps(res).includes("underline.present"));
64
+ }
65
+
66
+ console.log(failures ? `\n❌ selftest: ${failures} check(s) failed — the diff gate has a hole.` : "\n✓ selftest: the gate compares underline-box + smoothing as required.");
67
+ process.exit(failures ? 1 : 0);
package/tools/sink.js ADDED
@@ -0,0 +1,80 @@
1
+ // sink.js — a small receiver for snapshots (run: node tools/sink.js, or `ppk sink`)
2
+ //
3
+ // The clone (same-origin localhost, no strict CSP) can POST its capture straight
4
+ // to a file with one call — `pxSend("http://localhost:7799/clone.json")` — instead
5
+ // of the stash/read dance the live CSP site needs. This is that receiver: it writes
6
+ // the POST body to ./<name> where <name> comes from the URL path.
7
+ //
8
+ // ppk sink
9
+ // // in the clone page console / automation:
10
+ // await pxSend("http://localhost:7799/clone.json") // full snapshot
11
+ // await pxSend("http://localhost:7799/el_clone.json", null) // (or stash an inspect dump)
12
+ const http = require("http"), fs = require("fs");
13
+
14
+ // A snapshot is KBs; anything near this is a mistake, not a capture. Env-overridable so the
15
+ // mid-stream abort below is testable with a small limit (PPK_SINK_MAX_BYTES) and the port
16
+ // doesn't collide in tests (PPK_SINK_PORT).
17
+ const MAX_BYTES = +(process.env.PPK_SINK_MAX_BYTES || 20 * 1024 * 1024);
18
+ const PORT = +(process.env.PPK_SINK_PORT || 7799);
19
+
20
+ // Decide what to do with a received body BEFORE writing it, so we never silently persist
21
+ // garbage (an empty POST, a "[BLOCKED…]" automation sentinel, or non-JSON under a .json name).
22
+ // Pure + exported so it's unit-tested without opening a socket. Returns { status, write, message }.
23
+ function classifyBody(name, body) {
24
+ if (!body || !body.trim()) return { status: 400, write: false, message: `empty body for ${name} — capture returned nothing (the finder/injection likely failed).` };
25
+ if (body.length > MAX_BYTES) return { status: 413, write: false, message: `body for ${name} is ${body.length} bytes (> ${MAX_BYTES}) — that's not a snapshot; refusing.` };
26
+ if (/^\s*\[BLOCKED/.test(body)) return { status: 422, write: false, message: `body for ${name} is a "[BLOCKED…]" automation sentinel, not a snapshot — the return was blocked; use the stash/read path (RUNBOOK).` };
27
+ if (/\.json$/i.test(name)) { try { JSON.parse(body); } catch (e) { return { status: 200, write: true, message: `⚠ wrote ${name} but it is NOT valid JSON (${e.message}) — pixel-diff will reject it; re-capture.` }; } }
28
+ return { status: 200, write: true, message: `ok ${name}` };
29
+ }
30
+
31
+ // A query string is transport noise, not part of the filename — strip it BEFORE sanitizing,
32
+ // or `/clone.json?t=1` would fuse into `clone.jsont1` and the artifact lands under the wrong
33
+ // name while the caller sees "ok".
34
+ function sanitizeName(url) {
35
+ const raw = url.split("?")[0].replace(/^\//, "");
36
+ const clean = raw.replace(/[^a-z0-9._-]/gi, "") || "snap.json";
37
+ return { clean, renamed: clean !== raw };
38
+ }
39
+
40
+ if (require.main === module) {
41
+ http
42
+ .createServer((q, s) => {
43
+ s.setHeader("Access-Control-Allow-Origin", "*");
44
+ if (q.method === "OPTIONS") { s.end(); return; }
45
+ const { clean: name, renamed } = sanitizeName(q.url);
46
+ if (renamed) console.warn(`⚠ POST path "${q.url}" sanitized to ./${name} (only a-z0-9._- kept — sub-paths are flattened).`);
47
+ let body = "", aborted = false;
48
+ // Enforce the size bound DURING streaming — checking only at `end` would accumulate an
49
+ // unbounded body in memory first (a runaway stream could OOM the process).
50
+ q.on("data", (d) => {
51
+ if (aborted) return;
52
+ body += d;
53
+ if (body.length > MAX_BYTES) {
54
+ aborted = true;
55
+ const msg = `body for ${name} exceeded ${MAX_BYTES} bytes — aborted mid-stream; that's not a snapshot.`;
56
+ console.log(msg);
57
+ s.writeHead(413);
58
+ s.end(msg);
59
+ q.destroy();
60
+ body = "";
61
+ }
62
+ });
63
+ q.on("end", () => {
64
+ if (aborted) return;
65
+ const r = classifyBody(name, body);
66
+ if (r.write) fs.writeFileSync(name, body);
67
+ console.log(r.message);
68
+ s.writeHead(r.status);
69
+ s.end(r.message);
70
+ });
71
+ })
72
+ .on("error", (e) => {
73
+ // self-describing, actionable — never a raw stack for a predictable failure
74
+ if (e.code === "EADDRINUSE") { console.error(`port ${PORT} is already in use (another sink running?) — stop it, or set PPK_SINK_PORT=<n>`); process.exit(1); }
75
+ console.error(`sink failed: ${e.message}`);
76
+ process.exit(1);
77
+ })
78
+ .listen(PORT, () => console.log(`sink on http://localhost:${PORT} (POST /<name>.json → ./<name>.json)`));
79
+ }
80
+ module.exports = { classifyBody, sanitizeName, MAX_BYTES };