quiver-cli 1.3.0 → 1.3.1

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 (120) hide show
  1. package/package.json +1 -1
  2. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  3. package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
  4. package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
  5. package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
  6. package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
  7. package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
  8. package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
  9. package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
  10. package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
  11. package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
  12. package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
  13. package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
  14. package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
  15. package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
  16. package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
  17. package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
  18. package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
  19. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
  20. package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
  21. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
  22. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
  23. package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
  24. package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
  25. package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
  26. package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
  27. package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
  28. package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
  29. package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
  30. package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
  31. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
  32. package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
  33. package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
  34. package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
  35. package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
  36. package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
  37. package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
  38. package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
  39. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
  40. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
  41. package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
  42. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
  43. package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
  44. package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
  45. package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
  46. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
  47. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
  48. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
  49. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
  50. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
  51. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
  52. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
  53. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
  54. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
  55. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
  56. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
  57. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
  58. package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
  59. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
  60. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
  61. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
  62. package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
  63. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
  64. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
  65. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
  66. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
  67. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
  68. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
  69. package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
  70. package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
  71. package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
  72. package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
  73. package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
  74. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
  75. package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
  76. package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
  77. package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
  78. package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
  79. package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
  81. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
  83. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
  84. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
  85. package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
  86. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
  87. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
  88. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
  89. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
  90. package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
  91. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
  92. package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
  93. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
  94. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
  95. package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
  96. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
  97. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
  98. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
  99. package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
  100. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
  101. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
  102. package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
  103. package/template/.agents/skills/hono/SKILL.md +17 -3
  104. package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
  105. package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
  106. package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
  107. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
  108. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
  109. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
  110. package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
  111. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
  112. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
  113. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
  114. package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
  115. package/template/.agents/skills/supabase/SKILL.md +5 -1
  116. package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
  117. package/template/.agents/skills/writing/humanizer/README.md +209 -0
  118. package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
  119. package/template/.agents/upstreams.json +21 -22
  120. package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
@@ -0,0 +1,391 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * comp-diff: measure a build screenshot against its approved comp and produce
4
+ * the evidence a reviewer (human or model) needs to judge fidelity without
5
+ * trusting anyone's memory of the image.
6
+ *
7
+ * node comp-diff.mjs --comp .impeccable/mocks/approved.png --build .impeccable/review/hero-repro.png
8
+ * node comp-diff.mjs --comp comp.png --build desktop.png --spec .impeccable/build/spec.json --out-dir .impeccable/review/diff
9
+ * node comp-diff.mjs ... --json # machine-readable report on stdout
10
+ * node comp-diff.mjs ... --threshold 0.75 # exit 3 when the overall score is below
11
+ *
12
+ * Inputs: two PNGs. The build capture may be taller than the comp (a full-page
13
+ * screenshot); it is scaled to the comp's width and the top comp-height rows
14
+ * are compared, because the comp is the first viewport. `--align stretch`
15
+ * squashes the whole build onto the comp instead, for a comp that covers a
16
+ * whole page.
17
+ *
18
+ * Outputs (in --out-dir, default .impeccable/review/diff):
19
+ * side-by-side.png comp | build, same size, labeled, with the score
20
+ * heatmap.png build with the difference painted over it (red = wrong)
21
+ * regions/<id>.png paired crops per region at legible scale, scored
22
+ * report.json every number below, plus per-region rows
23
+ *
24
+ * Scores (0..1): structure (blurred SSIM: is the composition the same?),
25
+ * color (histogram + dominant palette: is it the same palette at the same
26
+ * coverage?), detail (high-frequency energy ratio: did the material survive,
27
+ * or did an illustration become a gradient?), bands (do the horizontal
28
+ * sections line up?). `overall` weights them 0.35 / 0.25 / 0.25 / 0.15.
29
+ *
30
+ * Regions come from --spec (comp-spec.mjs output: normalized boxes) or, with
31
+ * none, from the comp's own horizontal bands, so the per-region crops exist
32
+ * either way. Every region row carries the same four scores plus `verdict`:
33
+ * match (>= 0.8), drift (>= 0.6), missing (detail ratio < 0.35 with structure
34
+ * < 0.6), or contradicted (everything else). The words are the finish
35
+ * reviewer's fidelity vocabulary on purpose.
36
+ *
37
+ * Exit codes: 0 measured (and above threshold when one is given), 1 usage or
38
+ * unreadable input, 3 below threshold.
39
+ */
40
+ import fs from 'node:fs';
41
+ import path from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { decodePng, encodePng, loadRaster } from './lib/png.mjs';
44
+ import { crop, resize, fit, blit, createImage, fillRect, strokeRect, drawLabel } from './lib/raster.mjs';
45
+ import { structureScore, colorScore, detailScore, diffMap, horizontalBands, bandScore, dominantColors, toGray, blurGray, ssimShifted } from './lib/image-metrics.mjs';
46
+
47
+ function arg(name, fallback = null) {
48
+ const i = process.argv.indexOf(`--${name}`);
49
+ if (i === -1) return fallback;
50
+ const v = process.argv[i + 1];
51
+ return v && !v.startsWith('--') ? v : fallback;
52
+ }
53
+ const flag = (name) => process.argv.includes(`--${name}`);
54
+
55
+ export function readPng(file) {
56
+ return loadRaster(file).image;
57
+ }
58
+
59
+ /**
60
+ * Scale the build to the comp's width; take the top comp-height rows
61
+ * (align=top), squash the whole build onto the comp (align=stretch), or scale
62
+ * to cover and center-crop (align=cover, the way `object-fit: cover` will show
63
+ * a plate whose aspect differs from its region).
64
+ */
65
+ export function alignBuild(comp, build, align = 'top') {
66
+ if (align === 'stretch') return resize(build, comp.width, comp.height);
67
+ if (align === 'cover') {
68
+ const s = Math.max(comp.width / build.width, comp.height / build.height);
69
+ const scaled = resize(build, build.width * s, build.height * s);
70
+ return crop(scaled, (scaled.width - comp.width) / 2, (scaled.height - comp.height) / 2, comp.width, comp.height);
71
+ }
72
+ const scaled = build.width === comp.width ? build : resize(build, comp.width, Math.round((build.height / build.width) * comp.width));
73
+ if (scaled.height === comp.height) return scaled;
74
+ if (scaled.height > comp.height) return crop(scaled, 0, 0, comp.width, comp.height);
75
+ // shorter than the comp: pad with white so a short page reads as missing content, not as a resize
76
+ const out = createImage(comp.width, comp.height, [255, 255, 255, 255]);
77
+ blit(out, scaled, 0, 0);
78
+ return out;
79
+ }
80
+
81
+ /** Weights per region kind: what a region is made of decides what losing it looks like. */
82
+ const WEIGHTS = {
83
+ default: { structure: 0.35, color: 0.25, detail: 0.25, bands: 0.15 },
84
+ plate: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 },
85
+ image: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 },
86
+ texture: { structure: 0.15, color: 0.35, detail: 0.5, bands: 0 },
87
+ text: { structure: 0.5, color: 0.25, detail: 0.15, bands: 0.1 },
88
+ control: { structure: 0.45, color: 0.35, detail: 0.2, bands: 0 },
89
+ };
90
+
91
+ export function scorePair(a, b, kind = null) {
92
+ const structure = structureScore(a, b);
93
+ const color = colorScore(a, b);
94
+ const detail = detailScore(a, b);
95
+ const bandsA = horizontalBands(a), bandsB = horizontalBands(b);
96
+ const bands = bandScore(bandsA, bandsB);
97
+ const w = WEIGHTS[kind] || WEIGHTS.default;
98
+ const overall = w.structure * structure + w.color * color.score + w.detail * detail.score + w.bands * bands;
99
+ return {
100
+ overall: r4(overall),
101
+ structure: r4(structure),
102
+ color: r4(color.score),
103
+ colorIntersection: r4(color.intersection),
104
+ paletteMatch: r4(color.paletteMatch),
105
+ detail: r4(detail.score),
106
+ detailRaw: r4(detail.rawScore ?? detail.score),
107
+ detailAdded: r4(detail.addedFraction),
108
+ bands: r4(bands),
109
+ _detail: detail,
110
+ _bands: { comp: bandsA, build: bandsB },
111
+ };
112
+ }
113
+
114
+ /** Kinds that carry the direction: a wrong one is the wrong page, whatever the mean says. */
115
+ export const DIRECTION_KINDS = new Set(['plate', 'image', 'text']);
116
+
117
+ /** Best small global translation (build relative to comp), in pixels, by blurred-gray SSIM. */
118
+ export function bestShift(comp, build, workWidth = 256) {
119
+ const h = Math.max(8, Math.round((comp.height / comp.width) * workWidth));
120
+ const a = blurGray(toGray(resize(comp, workWidth, h)), 2);
121
+ const b = blurGray(toGray(resize(build, workWidth, h)), 2);
122
+ const maxShift = Math.max(2, Math.round(workWidth * 0.04));
123
+ let best = { dx: 0, dy: 0, score: ssimShifted(a, b, 0, 0) };
124
+ for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
125
+ for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
126
+ const sc = ssimShifted(a, b, Math.round(dx), Math.round(dy));
127
+ if (sc > best.score + 0.01) best = { dx: Math.round(dx), dy: Math.round(dy), score: sc };
128
+ }
129
+ }
130
+ const scale = comp.width / workWidth;
131
+ return { dx: Math.round(best.dx * scale), dy: Math.round(best.dy * scale), score: best.score };
132
+ }
133
+
134
+ export function verdictFor(s, kind = null) {
135
+ const painted = kind === 'plate' || kind === 'image' || kind === 'texture';
136
+ // Nothing drawn where the comp drew something is missing whatever the
137
+ // palette says: a footer strip the build pushed below the fold read as
138
+ // 'drift' on ground colour alone (detail 4%, structure 92%). detailRaw is
139
+ // 1 when the comp region itself is calm, so a low value already means the
140
+ // comp had material there.
141
+ if (s.detailRaw != null && s.detailRaw < 0.15) return 'missing';
142
+ if (painted && s.detail < 0.5) return 'missing';
143
+ // For text, chrome, and controls "missing" means the build has nothing
144
+ // there, not that a thin strip sits a few pixels off: require the build's
145
+ // own energy to be near zero relative to the comp (rawScore, before the
146
+ // added-detail penalty), and drift for a mere misalignment.
147
+ if (!painted && s.detail < 0.35 && s.structure < 0.6) {
148
+ if (s.detailRaw != null && s.detailRaw < 0.2) return 'missing';
149
+ // low detail with structure and palette both holding is grain the build
150
+ // renders flatter (a spine of rotated type on textured red), not a
151
+ // different composition
152
+ if (s.structure >= 0.5 && s.color >= 0.5) return 'drift';
153
+ return 'contradicted';
154
+ }
155
+ if (s.detail < 0.35 && s.structure < 0.6) return 'missing';
156
+ // Structure is the one thing a wrong-but-busy region cannot fake: noise,
157
+ // a mirrored crop, a swapped column, a tile shuffle all keep color and
158
+ // energy and lose structure. Below the floor it is contradicted whatever
159
+ // the weighted mean says; painted regions with invented detail likewise.
160
+ if (s.structure < 0.3) return 'contradicted';
161
+ if (painted && (s.structure < 0.45 || s.detailAdded > 0.4)) return 'contradicted';
162
+ // Text is set in a substitute face at a slightly different metric almost
163
+ // always, and blurred SSIM reads glyph shape; a text region with its
164
+ // structure above the swap floor and its palette intact is drift at worst.
165
+ // Chasing it past that point is what burned eight to thirteen hero attempts
166
+ // per build in the first simulated round.
167
+ if (kind === 'text' && s.color >= 0.5) return s.overall >= 0.8 ? 'match' : 'drift';
168
+ // Chrome and controls are thin strips whose "detail" is mostly ground grain
169
+ // (a paper texture the build renders flatter, a scanline). When their
170
+ // structure and palette hold, low detail is drift, not contradiction.
171
+ if ((kind === 'chrome' || kind === 'control') && s.structure >= 0.5 && s.color >= 0.5) return s.overall >= 0.8 ? 'match' : 'drift';
172
+ if (s.overall >= 0.8) return 'match';
173
+ if (s.overall >= 0.6) return 'drift';
174
+ return 'contradicted';
175
+ }
176
+
177
+ const r4 = (v) => Math.round(v * 10000) / 10000;
178
+
179
+ /** Regions from a spec (normalized boxes) or derived from the comp's bands. */
180
+ export function resolveRegions(comp, spec) {
181
+ const regions = [];
182
+ if (spec && Array.isArray(spec.regions) && spec.regions.length) {
183
+ for (const r of spec.regions) {
184
+ const box = r.box || r;
185
+ if ([box.x, box.y, box.w, box.h].some((v) => typeof v !== 'number')) continue;
186
+ regions.push({ id: r.id || `region-${regions.length + 1}`, x: box.x, y: box.y, w: box.w, h: box.h, kind: r.kind || null });
187
+ }
188
+ if (regions.length) return regions;
189
+ }
190
+ const bands = horizontalBands(comp).filter((b) => b.strength > 0.2);
191
+ const cuts = [0, ...bands.map((b) => b.y), 1].filter((v, i, arr) => i === 0 || v - arr[i - 1] > 0.06);
192
+ if (cuts[cuts.length - 1] !== 1) cuts.push(1);
193
+ for (let i = 0; i + 1 < cuts.length; i++) {
194
+ regions.push({ id: `band-${i + 1}`, x: 0, y: cuts[i], w: 1, h: cuts[i + 1] - cuts[i], kind: 'band' });
195
+ }
196
+ if (regions.length < 2) {
197
+ return [
198
+ { id: 'top', x: 0, y: 0, w: 1, h: 0.5, kind: 'band' },
199
+ { id: 'bottom', x: 0, y: 0.5, w: 1, h: 0.5, kind: 'band' },
200
+ ];
201
+ }
202
+ return regions;
203
+ }
204
+
205
+ /** Crop a normalized region; regions thinner than 48px in either axis are grown to that so tiny strips do not swing on subpixel noise. */
206
+ /** Bounding box of ink (pixels darker/lighter than the region's ground by a margin) within a crop, in px. */
207
+ export function inkBox(img) {
208
+ const g = toGray(img);
209
+ // ground = median gray; ink = |v - ground| > 48
210
+ const sample = []; for (let i = 0; i < g.data.length; i += Math.max(1, Math.floor(g.data.length / 4000))) sample.push(g.data[i]);
211
+ sample.sort((p, q) => p - q); const ground = sample[Math.floor(sample.length / 2)] || 255;
212
+ let x0 = img.width, y0 = img.height, x1 = -1, y1 = -1;
213
+ for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) {
214
+ if (Math.abs(g.data[y * img.width + x] - ground) > 48) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
215
+ }
216
+ if (x1 < 0) return null;
217
+ return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
218
+ }
219
+
220
+ function regionCrop(img, r) {
221
+ const minPx = 48;
222
+ let x = r.x * img.width, y = r.y * img.height, w = r.w * img.width, h = r.h * img.height;
223
+ if (h < minPx) { y -= (minPx - h) / 2; h = minPx; }
224
+ if (w < minPx) { x -= (minPx - w) / 2; w = minPx; }
225
+ return crop(img, x, y, w, h);
226
+ }
227
+
228
+ const HEAT_LABEL = { match: [40, 160, 80, 255], drift: [220, 160, 30, 255], missing: [200, 40, 40, 255], contradicted: [200, 40, 40, 255] };
229
+
230
+ export function renderSideBySide(comp, build, label, score) {
231
+ const gap = 24, pad = 48;
232
+ const targetW = Math.min(comp.width, 1400);
233
+ const a = fit(comp, targetW, 100000), b = resize(build, a.width, a.height);
234
+ const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 24, [24, 24, 28, 255]);
235
+ blit(out, a, pad, pad + 24);
236
+ blit(out, b, pad + a.width + gap, pad + 24);
237
+ drawLabel(out, 'COMP', pad, pad - 4, { scale: 2 });
238
+ drawLabel(out, `BUILD ${label ? label.toUpperCase() : ''}`.trim(), pad + a.width + gap, pad - 4, { scale: 2 });
239
+ const s = `OVERALL ${(score.overall * 100).toFixed(0)}% STRUCT ${(score.structure * 100).toFixed(0)}% COLOR ${(score.color * 100).toFixed(0)}% DETAIL ${(score.detail * 100).toFixed(0)}% BANDS ${(score.bands * 100).toFixed(0)}%`;
240
+ drawLabel(out, s, pad, out.height - pad + 8, { scale: 2, bg: HEAT_LABEL[verdictFor(score)] });
241
+ return out;
242
+ }
243
+
244
+ export function renderHeatmap(comp, build) {
245
+ const map = diffMap(comp, build);
246
+ const base = resize(build, map.width, map.height);
247
+ const out = { width: base.width, height: base.height, data: new Uint8Array(base.data) };
248
+ for (let i = 0, p = 0; i < map.data.length; i++, p += 4) {
249
+ const d = map.data[i];
250
+ if (d < 0.12) { // dim what matches so wrong stands out
251
+ out.data[p] = out.data[p] * 0.55 + 255 * 0.45 * 0.2; out.data[p + 1] = out.data[p + 1] * 0.55; out.data[p + 2] = out.data[p + 2] * 0.55; continue;
252
+ }
253
+ const a = Math.min(1, (d - 0.12) / 0.5);
254
+ out.data[p] = out.data[p] * (1 - a) + 235 * a; out.data[p + 1] = out.data[p + 1] * (1 - a) + 40 * a; out.data[p + 2] = out.data[p + 2] * (1 - a) + 40 * a;
255
+ }
256
+ const scaled = resize(out, comp.width, comp.height);
257
+ drawLabel(scaled, 'DIFF: RED = DIFFERS FROM COMP', 12, 12, { scale: 2 });
258
+ return scaled;
259
+ }
260
+
261
+ export function renderRegionPair(compCrop, buildCrop, id, score) {
262
+ const gap = 16, pad = 12;
263
+ const maxW = 700;
264
+ const a = fit(compCrop, maxW, 700, true), b = resize(buildCrop, a.width, a.height);
265
+ const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 30, [24, 24, 28, 255]);
266
+ blit(out, a, pad, pad + 30);
267
+ blit(out, b, pad + a.width + gap, pad + 30);
268
+ const v = verdictFor(score);
269
+ drawLabel(out, `${id.toUpperCase()} COMP`, pad, pad, { scale: 2 });
270
+ drawLabel(out, `BUILD ${v.toUpperCase()} ${(score.overall * 100).toFixed(0)}%`, pad + a.width + gap, pad, { scale: 2, bg: HEAT_LABEL[v] });
271
+ return out;
272
+ }
273
+
274
+ export function compare({ comp, build, spec = null, align = 'top', label = '', kind = null }) {
275
+ let aligned = alignBuild(comp, build, align);
276
+ const whole = scorePair(comp, aligned, kind);
277
+ // Region crops are taken at fixed boxes, so a small global offset (a
278
+ // taller masthead, a scrollbar) would read every thin region as
279
+ // contradicted while the whole-image search forgives it. Find the best
280
+ // global translation once and shift the aligned build by it before
281
+ // cropping regions; the whole score above stays as measured.
282
+ // The side-by-side and heatmap show the build as captured; only the
283
+ // region crops read the shifted copy. (The shifted copy used to be what the
284
+ // side-by-side drew, and its padding read as a white "letterbox" on the
285
+ // build in every human review.)
286
+ const asCaptured = aligned;
287
+ const shift = bestShift(comp, aligned);
288
+ if (shift.dx || shift.dy) {
289
+ const shifted = createImage(aligned.width, aligned.height, [255, 255, 255, 255]);
290
+ blit(shifted, aligned, -shift.dx, -shift.dy);
291
+ aligned = shifted;
292
+ }
293
+ const regions = resolveRegions(comp, spec).map((r) => {
294
+ const a = regionCrop(comp, r), b = regionCrop(aligned, r);
295
+ const s = scorePair(a, b, r.kind);
296
+ return { ...r, score: strip(s), verdict: verdictFor(s, r.kind), inkBox: { comp: inkBox(a), build: inkBox(b) }, _a: a, _b: b };
297
+ });
298
+ const compPalette = dominantColors(comp), buildPalette = dominantColors(aligned);
299
+ return { label, align, whole: strip(whole), regions, aligned: asCaptured, alignedShifted: aligned, shift, compPalette, buildPalette, _whole: whole };
300
+ }
301
+
302
+ function strip(s) {
303
+ const { _detail, _bands, ...rest } = s;
304
+ return rest;
305
+ }
306
+
307
+ export function writeArtifacts(result, comp, outDir) {
308
+ fs.mkdirSync(path.join(outDir, 'regions'), { recursive: true });
309
+ const side = renderSideBySide(comp, result.aligned, result.label, result.whole);
310
+ fs.writeFileSync(path.join(outDir, 'side-by-side.png'), encodePng(side));
311
+ fs.writeFileSync(path.join(outDir, 'heatmap.png'), encodePng(renderHeatmap(comp, result.aligned)));
312
+ const regionFiles = [];
313
+ for (const r of result.regions) {
314
+ const file = path.join(outDir, 'regions', `${r.id}.png`);
315
+ fs.writeFileSync(file, encodePng(renderRegionPair(r._a, r._b, r.id, r.score)));
316
+ regionFiles.push(file);
317
+ }
318
+ return { sideBySide: path.join(outDir, 'side-by-side.png'), heatmap: path.join(outDir, 'heatmap.png'), regionFiles };
319
+ }
320
+
321
+ export function buildReport(result, files, meta) {
322
+ return {
323
+ tool: 'comp-diff',
324
+ version: 1,
325
+ createdAt: new Date().toISOString(),
326
+ ...meta,
327
+ align: result.align,
328
+ overall: result.whole.overall,
329
+ verdict: verdictFor(result.whole),
330
+ scores: result.whole,
331
+ palette: { comp: result.compPalette.map(({ hex, coverage }) => ({ hex, coverage })), build: result.buildPalette.map(({ hex, coverage }) => ({ hex, coverage })) },
332
+ regions: result.regions.map(({ _a, _b, ...r }) => r),
333
+ files,
334
+ };
335
+ }
336
+
337
+ function summarize(report) {
338
+ const lines = [];
339
+ lines.push(`COMP-DIFF ${report.label ? `[${report.label}] ` : ''}overall ${(report.overall * 100).toFixed(0)}% (${report.verdict}) structure ${(report.scores.structure * 100).toFixed(0)}% color ${(report.scores.color * 100).toFixed(0)}% detail ${(report.scores.detail * 100).toFixed(0)}% bands ${(report.scores.bands * 100).toFixed(0)}%`);
340
+ lines.push(`PALETTE comp ${report.palette.comp.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
341
+ lines.push(`PALETTE build ${report.palette.build.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
342
+ for (const r of report.regions) {
343
+ lines.push(`REGION ${r.id.padEnd(18)} ${r.verdict.padEnd(12)} ${(r.score.overall * 100).toFixed(0).padStart(3)}% structure ${(r.score.structure * 100).toFixed(0).padStart(3)}% color ${(r.score.color * 100).toFixed(0).padStart(3)}% detail ${(r.score.detail * 100).toFixed(0).padStart(3)}%${r.score.detailAdded > 0.25 ? ' +invented detail' : ''}`);
344
+ }
345
+ if (report.files) {
346
+ lines.push(`FILES side-by-side ${report.files.sideBySide}`);
347
+ lines.push(`FILES heatmap ${report.files.heatmap}`);
348
+ lines.push(`FILES regions ${report.files.regionFiles.length} under ${path.dirname(report.files.regionFiles[0] || report.files.heatmap)}`);
349
+ }
350
+ const worst = [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3);
351
+ if (worst.length) lines.push(`WORST ${worst.map((r) => `${r.id} (${r.verdict}, ${(r.score.overall * 100).toFixed(0)}%)`).join('; ')}`);
352
+ lines.push('OPEN the side-by-side and the worst region pairs before deciding anything; the numbers rank, the crops decide.');
353
+ return lines.join('\n');
354
+ }
355
+
356
+ async function main() {
357
+ const compPath = arg('comp'), buildPath = arg('build');
358
+ if (!compPath || !buildPath) {
359
+ console.error('usage: comp-diff.mjs --comp <png> --build <png> [--spec spec.json] [--out-dir dir] [--align top|stretch] [--label name] [--threshold 0.75] [--json]');
360
+ process.exit(1);
361
+ }
362
+ let comp, build;
363
+ try { comp = readPng(compPath); } catch (e) { console.error(`comp-diff: cannot read comp ${compPath}: ${e.message}`); process.exit(1); }
364
+ try { build = readPng(buildPath); } catch (e) { console.error(`comp-diff: cannot read build ${buildPath}: ${e.message}`); process.exit(1); }
365
+ let spec = null;
366
+ const specPath = arg('spec');
367
+ if (specPath) {
368
+ try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); } catch (e) { console.error(`comp-diff: cannot read spec ${specPath}: ${e.message}`); process.exit(1); }
369
+ }
370
+ const outDir = arg('out-dir', path.join(path.dirname(buildPath), 'diff'));
371
+ const label = arg('label', path.basename(buildPath, '.png'));
372
+ const result = compare({ comp, build, spec, align: arg('align', 'top'), label });
373
+ const files = flag('no-files') ? null : writeArtifacts(result, comp, outDir);
374
+ const report = buildReport(result, files, { label, comp: compPath, build: buildPath, spec: specPath || null, compSize: `${comp.width}x${comp.height}`, buildSize: `${build.width}x${build.height}` });
375
+ if (files) fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2));
376
+ if (flag('json')) console.log(JSON.stringify(report, null, 2));
377
+ else console.log(summarize(report));
378
+ const threshold = arg('threshold') ? parseFloat(arg('threshold')) : null;
379
+ if (threshold != null && report.overall < threshold) {
380
+ if (!flag('json')) console.log(`BELOW THRESHOLD ${(threshold * 100).toFixed(0)}%: the reproduction is not done. Fix the worst regions and re-run; do not build past the hero.`);
381
+ process.exit(3);
382
+ }
383
+ }
384
+
385
+ // realpath on both sides: a skill mounted through a symlink (Cursor, a
386
+ // worktree, an eval stage) must still run as a CLI.
387
+ const isMain = (() => {
388
+ try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
389
+ catch { return !!process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); }
390
+ })();
391
+ if (isMain) main();