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,306 @@
1
+ /**
2
+ * Perceptual measures for comparing a comp with a build screenshot. Pure
3
+ * functions over `{ width, height, data }` RGBA images; no I/O.
4
+ *
5
+ * Three families, because a build fails a comp in three separable ways:
6
+ *
7
+ * - structure: is the composition the same? Measured as SSIM over a blurred
8
+ * grayscale downsample, which forgives font hinting and a few pixels of
9
+ * drift and punishes a moved, missing, or invented region.
10
+ * - color: is the palette and its distribution the same? Histogram
11
+ * intersection in a coarse quantized space plus a dominant-color extraction,
12
+ * so a navy page built from a bone comp fails even if the shapes match.
13
+ * - detail: is the material there? Local high-frequency energy per cell. A
14
+ * comp with an illustration, texture, or photograph carries energy a flat
15
+ * CSS stand-in does not; the ratio build/comp per region is the most direct
16
+ * measure of "the plate got replaced by a gradient".
17
+ */
18
+ import { resize } from './raster.mjs';
19
+
20
+ export function toGray(img) {
21
+ const g = new Float32Array(img.width * img.height);
22
+ for (let i = 0, p = 0; i < g.length; i++, p += 4) {
23
+ const a = img.data[p + 3] / 255;
24
+ // composite over white so transparent regions read as the page ground
25
+ const r = img.data[p] * a + 255 * (1 - a), gg = img.data[p + 1] * a + 255 * (1 - a), b = img.data[p + 2] * a + 255 * (1 - a);
26
+ g[i] = 0.2126 * r + 0.7152 * gg + 0.0722 * b;
27
+ }
28
+ return { width: img.width, height: img.height, data: g };
29
+ }
30
+
31
+ /** Separable box blur on a float gray image, radius r. */
32
+ export function blurGray(gray, r) {
33
+ if (r <= 0) return gray;
34
+ const { width, height, data } = gray;
35
+ const tmp = new Float32Array(data.length), out = new Float32Array(data.length);
36
+ const win = 2 * r + 1;
37
+ for (let y = 0; y < height; y++) {
38
+ let acc = 0;
39
+ for (let x = -r; x <= r; x++) acc += data[y * width + Math.min(width - 1, Math.max(0, x))];
40
+ for (let x = 0; x < width; x++) {
41
+ tmp[y * width + x] = acc / win;
42
+ const outX = x - r, inX = x + r + 1;
43
+ acc += data[y * width + Math.min(width - 1, inX)] - data[y * width + Math.max(0, outX)];
44
+ }
45
+ }
46
+ for (let x = 0; x < width; x++) {
47
+ let acc = 0;
48
+ for (let y = -r; y <= r; y++) acc += tmp[Math.min(height - 1, Math.max(0, y)) * width + x];
49
+ for (let y = 0; y < height; y++) {
50
+ out[y * width + x] = acc / win;
51
+ const outY = y - r, inY = y + r + 1;
52
+ acc += tmp[Math.min(height - 1, inY) * width + x] - tmp[Math.max(0, outY) * width + x];
53
+ }
54
+ }
55
+ return { width, height, data: out };
56
+ }
57
+
58
+ /** Global SSIM between two same-size gray images using an 8x8 window grid. */
59
+ export function ssim(a, b, win = 8) {
60
+ if (a.width !== b.width || a.height !== b.height) throw new Error('ssim: size mismatch');
61
+ const C1 = (0.01 * 255) ** 2, C2 = (0.03 * 255) ** 2;
62
+ let total = 0, n = 0;
63
+ for (let y = 0; y + win <= a.height; y += win) {
64
+ for (let x = 0; x + win <= a.width; x += win) {
65
+ let ma = 0, mb = 0;
66
+ for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; ma += a.data[i]; mb += b.data[i]; }
67
+ ma /= win * win; mb /= win * win;
68
+ let va = 0, vb = 0, cov = 0;
69
+ for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; const da = a.data[i] - ma, db = b.data[i] - mb; va += da * da; vb += db * db; cov += da * db; }
70
+ va /= win * win - 1; vb /= win * win - 1; cov /= win * win - 1;
71
+ total += ((2 * ma * mb + C1) * (2 * cov + C2)) / ((ma * ma + mb * mb + C1) * (va + vb + C2));
72
+ n++;
73
+ }
74
+ }
75
+ return n ? total / n : 1;
76
+ }
77
+
78
+ /** SSIM of `a` against `b` shifted by (dx, dy); the overlap is compared, edges dropped. */
79
+ export function ssimShifted(a, b, dx, dy, win = 8) {
80
+ const w = a.width - Math.abs(dx), h = a.height - Math.abs(dy);
81
+ if (w < win || h < win) return 0;
82
+ const sa = { width: w, height: h, data: new Float32Array(w * h) };
83
+ const sb = { width: w, height: h, data: new Float32Array(w * h) };
84
+ const ax = Math.max(0, -dx), ay = Math.max(0, -dy), bx = Math.max(0, dx), by = Math.max(0, dy);
85
+ for (let y = 0; y < h; y++) {
86
+ sa.data.set(a.data.subarray((y + ay) * a.width + ax, (y + ay) * a.width + ax + w), y * w);
87
+ sb.data.set(b.data.subarray((y + by) * b.width + bx, (y + by) * b.width + bx + w), y * w);
88
+ }
89
+ return ssim(sa, sb, win);
90
+ }
91
+
92
+ /**
93
+ * Structure score 0..1: SSIM over blurred grayscale at a fixed working width,
94
+ * taking the best of a small translation search so a composition that sits a
95
+ * few pixels off (a different masthead height, a scrollbar) is not read as a
96
+ * different composition. Shifts up to ~4% of the width are forgiven; a moved
97
+ * region is not.
98
+ */
99
+ export function structureScore(imgA, imgB, workWidth = 256) {
100
+ const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth));
101
+ const a = blurGray(toGray(resize(imgA, workWidth, h)), 2);
102
+ const b = blurGray(toGray(resize(imgB, workWidth, h)), 2);
103
+ const win = Math.min(8, Math.max(2, Math.floor(Math.min(workWidth, h) / 8)));
104
+ let best = ssim(a, b, win);
105
+ const maxShift = Math.max(2, Math.round(workWidth * 0.04));
106
+ for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
107
+ for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
108
+ if (!dx && !dy) continue;
109
+ best = Math.max(best, ssimShifted(a, b, Math.round(dx), Math.round(dy), win));
110
+ }
111
+ }
112
+ return Math.max(0, Math.min(1, best));
113
+ }
114
+
115
+ // ---- color -----------------------------------------------------------------
116
+
117
+ function rgbToLab(r, g, b) {
118
+ const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
119
+ const R = lin(r), G = lin(g), B = lin(b);
120
+ const X = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
121
+ const Y = (R * 0.2126 + G * 0.7152 + B * 0.0722) / 1.0;
122
+ const Z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
123
+ const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
124
+ const fx = f(X), fy = f(Y), fz = f(Z);
125
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
126
+ }
127
+
128
+ export function deltaE(lab1, lab2) {
129
+ return Math.hypot(lab1[0] - lab2[0], lab1[1] - lab2[1], lab1[2] - lab2[2]);
130
+ }
131
+
132
+ /** Quantized color histogram (4 bits per channel = 4096 bins), normalized. */
133
+ export function colorHistogram(img, sampleStep = 2) {
134
+ const bins = new Float32Array(4096);
135
+ let n = 0;
136
+ for (let y = 0; y < img.height; y += sampleStep) {
137
+ for (let x = 0; x < img.width; x += sampleStep) {
138
+ const p = (y * img.width + x) * 4;
139
+ if (img.data[p + 3] < 16) continue;
140
+ const key = ((img.data[p] >> 4) << 8) | ((img.data[p + 1] >> 4) << 4) | (img.data[p + 2] >> 4);
141
+ bins[key]++; n++;
142
+ }
143
+ }
144
+ if (n) for (let i = 0; i < bins.length; i++) bins[i] /= n;
145
+ return bins;
146
+ }
147
+
148
+ export function histogramIntersection(h1, h2) {
149
+ let s = 0;
150
+ for (let i = 0; i < h1.length; i++) s += Math.min(h1[i], h2[i]);
151
+ return s;
152
+ }
153
+
154
+ /**
155
+ * Dominant colors: merge histogram bins greedily by Lab distance into up to
156
+ * `k` clusters and return them sorted by coverage.
157
+ */
158
+ export function dominantColors(img, k = 6, sampleStep = 3) {
159
+ const hist = colorHistogram(img, sampleStep);
160
+ const entries = [];
161
+ for (let i = 0; i < hist.length; i++) if (hist[i] > 0.0005) entries.push({ key: i, w: hist[i] });
162
+ entries.sort((a, b) => b.w - a.w);
163
+ const clusters = [];
164
+ for (const e of entries) {
165
+ const r = ((e.key >> 8) & 15) * 16 + 8, g = ((e.key >> 4) & 15) * 16 + 8, b = (e.key & 15) * 16 + 8;
166
+ const lab = rgbToLab(r, g, b);
167
+ let best = null, bestD = Infinity;
168
+ for (const c of clusters) { const d = deltaE(c.lab, lab); if (d < bestD) { bestD = d; best = c; } }
169
+ if (best && bestD < 14) {
170
+ const tw = best.w + e.w;
171
+ best.rgb = [(best.rgb[0] * best.w + r * e.w) / tw, (best.rgb[1] * best.w + g * e.w) / tw, (best.rgb[2] * best.w + b * e.w) / tw];
172
+ best.lab = rgbToLab(...best.rgb); best.w = tw;
173
+ } else clusters.push({ rgb: [r, g, b], lab, w: e.w });
174
+ }
175
+ clusters.sort((a, b) => b.w - a.w);
176
+ const top = clusters.slice(0, k);
177
+ const covered = top.reduce((s, c) => s + c.w, 0) || 1;
178
+ return top.map((c) => ({ hex: toHex(c.rgb), coverage: +(c.w / covered).toFixed(4), lab: c.lab }));
179
+ }
180
+
181
+ export function toHex(rgb) {
182
+ return '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
183
+ }
184
+
185
+ /**
186
+ * Palette match 0..1: for each dominant comp color, coverage-weighted best
187
+ * Lab match in the build's dominant set (dE 0 -> 1, dE >= 25 -> 0).
188
+ */
189
+ export function paletteMatch(compColors, buildColors) {
190
+ if (!compColors.length) return 1;
191
+ let s = 0, wsum = 0;
192
+ for (const c of compColors) {
193
+ let best = Infinity;
194
+ for (const b of buildColors) best = Math.min(best, deltaE(c.lab, b.lab));
195
+ s += c.coverage * Math.max(0, 1 - best / 25); wsum += c.coverage;
196
+ }
197
+ return wsum ? s / wsum : 1;
198
+ }
199
+
200
+ /** Color score 0..1: blend of histogram intersection and dominant-palette match. */
201
+ export function colorScore(imgA, imgB) {
202
+ const inter = histogramIntersection(colorHistogram(imgA), colorHistogram(imgB));
203
+ const pm = paletteMatch(dominantColors(imgA), dominantColors(imgB));
204
+ return { score: 0.35 * inter + 0.65 * pm, intersection: inter, paletteMatch: pm };
205
+ }
206
+
207
+ // ---- detail ----------------------------------------------------------------
208
+
209
+ /** Mean absolute gradient (Sobel-lite) per cell over a cols x rows grid. */
210
+ export function detailGrid(img, cols = 12, rows = 8, workWidth = 512) {
211
+ const h = Math.max(rows, Math.round((img.height / img.width) * workWidth));
212
+ const g = toGray(resize(img, workWidth, h));
213
+ const grid = new Float32Array(cols * rows);
214
+ const counts = new Float32Array(cols * rows);
215
+ for (let y = 1; y < g.height - 1; y++) {
216
+ const cy = Math.min(rows - 1, Math.floor((y / g.height) * rows));
217
+ for (let x = 1; x < g.width - 1; x++) {
218
+ const cx = Math.min(cols - 1, Math.floor((x / g.width) * cols));
219
+ const i = y * g.width + x;
220
+ const gx = Math.abs(g.data[i + 1] - g.data[i - 1]);
221
+ const gy = Math.abs(g.data[i + g.width] - g.data[i - g.width]);
222
+ grid[cy * cols + cx] += gx + gy; counts[cy * cols + cx]++;
223
+ }
224
+ }
225
+ for (let i = 0; i < grid.length; i++) grid[i] = counts[i] ? grid[i] / counts[i] : 0;
226
+ return { cols, rows, cells: grid };
227
+ }
228
+
229
+ /**
230
+ * Detail score 0..1 and per-cell ratio. Cells where the comp is nearly flat
231
+ * are ignored (nothing to lose); the score is the coverage-weighted mean of
232
+ * min(1, build/comp) over cells with comp energy, so extra detail in the
233
+ * build (invented chrome) is reported separately as `added`.
234
+ */
235
+ export function detailScore(imgA, imgB, cols = 12, rows = 8) {
236
+ const a = detailGrid(imgA, cols, rows), b = detailGrid(imgB, cols, rows);
237
+ const floor = 1.5; // energy below this is a flat field at the 512px working width
238
+ let s = 0, w = 0, added = 0, addedW = 0;
239
+ const ratios = new Float32Array(cols * rows);
240
+ for (let i = 0; i < a.cells.length; i++) {
241
+ const ca = a.cells[i], cb = b.cells[i];
242
+ ratios[i] = ca > floor ? cb / ca : (cb > floor ? Infinity : 1);
243
+ // Signed: too much energy is as wrong as too little. Noise, a tile
244
+ // shuffle, or a mosaic saturate a one-sided ratio; a real plate does not.
245
+ if (ca > floor) { s += Math.min(cb / ca, ca / cb) * ca; w += ca; }
246
+ if (cb > ca * 1.8 && cb > floor * 2) { added += 1; }
247
+ addedW += 1;
248
+ }
249
+ const addedFraction = addedW ? added / addedW : 0;
250
+ const raw = w ? s / w : 1;
251
+ return { score: Math.max(0, raw - 0.5 * addedFraction), rawScore: raw, addedFraction, comp: a, build: b, ratios };
252
+ }
253
+
254
+ // ---- pixel diff -----------------------------------------------------------
255
+
256
+ /** Per-pixel Lab-ish difference map (0..1) at a working width; blurred a little. */
257
+ export function diffMap(imgA, imgB, workWidth = 384) {
258
+ const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth));
259
+ const a = resize(imgA, workWidth, h), b = resize(imgB, workWidth, h);
260
+ const out = new Float32Array(workWidth * h);
261
+ for (let i = 0, p = 0; i < out.length; i++, p += 4) {
262
+ const dr = a.data[p] - b.data[p], dg = a.data[p + 1] - b.data[p + 1], db = a.data[p + 2] - b.data[p + 2];
263
+ out[i] = Math.min(1, Math.sqrt(dr * dr + dg * dg + db * db) / 200);
264
+ }
265
+ return blurGray({ width: workWidth, height: h, data: out }, 1);
266
+ }
267
+
268
+ // ---- bands (horizontal layout structure) ---------------------------------
269
+
270
+ /**
271
+ * Detect horizontal band boundaries: rows where the mean color changes
272
+ * sharply. Returns normalized y positions (0..1) with strengths. This is the
273
+ * "layout grid" read of a page: header / hero / index / footer as bands.
274
+ */
275
+ export function horizontalBands(img, workWidth = 128, minGap = 0.02) {
276
+ const h = Math.max(16, Math.round((img.height / img.width) * workWidth));
277
+ const s = resize(img, workWidth, h);
278
+ const rowMean = new Float32Array(h * 3);
279
+ for (let y = 0; y < h; y++) {
280
+ let r = 0, g = 0, b = 0;
281
+ for (let x = 0; x < workWidth; x++) { const p = (y * workWidth + x) * 4; r += s.data[p]; g += s.data[p + 1]; b += s.data[p + 2]; }
282
+ rowMean[y * 3] = r / workWidth; rowMean[y * 3 + 1] = g / workWidth; rowMean[y * 3 + 2] = b / workWidth;
283
+ }
284
+ const edges = [];
285
+ for (let y = 1; y < h; y++) {
286
+ const d = Math.hypot(rowMean[y * 3] - rowMean[(y - 1) * 3], rowMean[y * 3 + 1] - rowMean[(y - 1) * 3 + 1], rowMean[y * 3 + 2] - rowMean[(y - 1) * 3 + 2]);
287
+ if (d > 18) edges.push({ y: y / h, strength: Math.min(1, d / 120) });
288
+ }
289
+ // merge close edges
290
+ const merged = [];
291
+ for (const e of edges) {
292
+ const last = merged[merged.length - 1];
293
+ if (last && e.y - last.y < minGap) { if (e.strength > last.strength) { last.y = e.y; last.strength = e.strength; } }
294
+ else merged.push({ ...e });
295
+ }
296
+ return merged;
297
+ }
298
+
299
+ /** Band agreement 0..1: fraction of comp bands with a build band within tolerance, and vice versa. */
300
+ export function bandScore(bandsA, bandsB, tol = 0.04) {
301
+ if (!bandsA.length && !bandsB.length) return 1;
302
+ const match = (from, to) => from.filter((a) => to.some((b) => Math.abs(a.y - b.y) <= tol)).length;
303
+ const recall = bandsA.length ? match(bandsA, bandsB) / bandsA.length : 1;
304
+ const precision = bandsB.length ? match(bandsB, bandsA) / bandsB.length : 1;
305
+ return 0.6 * recall + 0.4 * precision;
306
+ }
@@ -206,10 +206,10 @@ function parseIgnoreColor(value) {
206
206
  if (rgb) {
207
207
  const parts = splitColorArgs(rgb[1]);
208
208
  if (parts.length < 3 || parts.length > 4) return null;
209
- const r = parseRgbChannel(parts[0]);
210
- const g = parseRgbChannel(parts[1]);
211
- const b = parseRgbChannel(parts[2]);
212
- const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
209
+ const r = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.rgb);
210
+ const g = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.rgb);
211
+ const b = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.rgb);
212
+ const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
213
213
  if ([r, g, b, a].some((v) => v === null)) return null;
214
214
  return { r, g, b, a };
215
215
  }
@@ -218,10 +218,10 @@ function parseIgnoreColor(value) {
218
218
  if (hsl) {
219
219
  const parts = splitColorArgs(hsl[1]);
220
220
  if (parts.length < 3 || parts.length > 4) return null;
221
- const h = parseHueChannel(parts[0]);
222
- const s = parsePercentChannel(parts[1]);
223
- const l = parsePercentChannel(parts[2]);
224
- const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
221
+ const h = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.hue);
222
+ const s = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.percent);
223
+ const l = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.percent);
224
+ const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
225
225
  if ([h, s, l, a].some((v) => v === null)) return null;
226
226
  return hslToRgb(h, s, l, a);
227
227
  }
@@ -230,18 +230,13 @@ function parseIgnoreColor(value) {
230
230
  }
231
231
 
232
232
  function parseHexIgnoreColor(hex) {
233
- if (hex.length === 3 || hex.length === 4) {
234
- const r = parseInt(hex[0] + hex[0], 16);
235
- const g = parseInt(hex[1] + hex[1], 16);
236
- const b = parseInt(hex[2] + hex[2], 16);
237
- const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
238
- return { r, g, b, a };
239
- }
240
- const r = parseInt(hex.slice(0, 2), 16);
241
- const g = parseInt(hex.slice(2, 4), 16);
242
- const b = parseInt(hex.slice(4, 6), 16);
243
- const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
244
- return { r, g, b, a };
233
+ const expanded = hex.length <= 4
234
+ ? [...hex].map((digit) => digit.repeat(2)).join('')
235
+ : hex;
236
+ const [r, g, b, alpha = 255] = expanded
237
+ .match(/../g)
238
+ .map((channel) => Number.parseInt(channel, 16));
239
+ return { r, g, b, a: alpha / 255 };
245
240
  }
246
241
 
247
242
  function splitColorArgs(body) {
@@ -259,47 +254,34 @@ function splitColorArgs(body) {
259
254
  return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
260
255
  }
261
256
 
262
- function parseRgbChannel(raw) {
263
- const text = String(raw || '').trim();
264
- const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
265
- if (!match) return null;
266
- const value = Number.parseFloat(match[1]);
267
- if (!Number.isFinite(value)) return null;
268
- const scaled = match[2] ? value * 2.55 : value;
269
- if (scaled < 0 || scaled > 255) return null;
270
- return Math.round(scaled);
271
- }
272
-
273
- function parseAlphaChannel(raw) {
274
- const text = String(raw || '').trim();
275
- const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
276
- if (!match) return null;
277
- const value = Number.parseFloat(match[1]);
278
- if (!Number.isFinite(value)) return null;
279
- const alpha = match[2] ? value / 100 : value;
280
- return alpha >= 0 && alpha <= 1 ? alpha : null;
281
- }
282
-
283
- function parseHueChannel(raw) {
284
- const text = String(raw || '').trim();
285
- const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
286
- if (!match) return null;
287
- const value = Number.parseFloat(match[1]);
288
- if (!Number.isFinite(value)) return null;
289
- const unit = match[2] || 'deg';
290
- if (unit === 'turn') return value * 360;
291
- if (unit === 'rad') return value * (180 / Math.PI);
292
- if (unit === 'grad') return value * 0.9;
293
- return value;
294
- }
295
-
296
- function parsePercentChannel(raw) {
257
+ const CSS_NUMBER_RE = /^(-?\d*\.?\d+)(%|deg|rad|turn|grad)?$/;
258
+ const identity = (value) => value;
259
+ const COLOR_CHANNEL_FORMATS = {
260
+ rgb: { units: { '': identity, '%': (value) => value * 2.55 }, min: 0, max: 255, round: true },
261
+ alpha: { units: { '': identity, '%': (value) => value / 100 }, min: 0, max: 1 },
262
+ hue: {
263
+ units: {
264
+ '': identity,
265
+ deg: identity,
266
+ rad: (value) => value * (180 / Math.PI),
267
+ turn: (value) => value * 360,
268
+ grad: (value) => value * 0.9,
269
+ },
270
+ },
271
+ percent: { units: { '%': (value) => value / 100 }, min: 0, max: 1 },
272
+ };
273
+
274
+ function parseColorChannel(raw, { units, min = -Infinity, max = Infinity, round = false }) {
297
275
  const text = String(raw || '').trim();
298
- const match = text.match(/^(-?\d*\.?\d+)%$/);
276
+ const match = text.match(CSS_NUMBER_RE);
299
277
  if (!match) return null;
300
- const value = Number.parseFloat(match[1]);
301
- if (!Number.isFinite(value)) return null;
302
- return value >= 0 && value <= 100 ? value / 100 : null;
278
+ const convert = units[match[2] || ''];
279
+ if (!convert) return null;
280
+ const number = Number.parseFloat(match[1]);
281
+ if (!Number.isFinite(number)) return null;
282
+ const value = convert(number);
283
+ if (value < min || value > max) return null;
284
+ return round ? Math.round(value) : value;
303
285
  }
304
286
 
305
287
  function hslToRgb(hue, saturation, lightness, alpha) {
@@ -13,7 +13,7 @@
13
13
  * within the first ~300 characters — catches non-git projects.
14
14
  */
15
15
 
16
- import { execSync } from 'node:child_process';
16
+ import { execFileSync } from 'node:child_process';
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
 
@@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) {
41
41
 
42
42
  function isGitIgnored(absPath, cwd) {
43
43
  try {
44
- execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
44
+ // argv form, never a shell: this runs on every file the live-mode source
45
+ // walk reaches, so a hostile filename embedding $(...) or backticks must
46
+ // not be interpretable (issue #476). JSON.stringify is not shell quoting.
47
+ execFileSync('git', ['check-ignore', '--quiet', absPath], {
45
48
  cwd,
46
49
  stdio: 'ignore',
47
50
  });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Convert a live-config glob pattern to a RegExp.
3
+ *
4
+ * Supports `**` across path segments, `*` within one segment, and `?` for one
5
+ * character. Callers normalize project-relative paths to forward slashes.
6
+ */
7
+ export function livePathGlobToRegex(pattern) {
8
+ let re = '';
9
+ let i = 0;
10
+ while (i < pattern.length) {
11
+ const c = pattern[i];
12
+ if (c === '*') {
13
+ if (pattern[i + 1] === '*') {
14
+ if (pattern[i + 2] === '/') {
15
+ re += '(?:.*/)?';
16
+ i += 3;
17
+ } else {
18
+ re += '.*';
19
+ i += 2;
20
+ }
21
+ } else {
22
+ re += '[^/]*';
23
+ i += 1;
24
+ }
25
+ } else if (c === '?') {
26
+ re += '[^/]';
27
+ i += 1;
28
+ } else if (/[.+^${}()|[\]\\]/.test(c)) {
29
+ re += `\\${c}`;
30
+ i += 1;
31
+ } else {
32
+ re += c;
33
+ i += 1;
34
+ }
35
+ }
36
+ return new RegExp(`^${re}$`);
37
+ }
@@ -0,0 +1,26 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export function browserOpenCommand(url, {
4
+ platform = process.platform,
5
+ comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
6
+ } = {}) {
7
+ if (platform === 'darwin') return { command: 'open', args: [url] };
8
+ if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
9
+ return { command: 'xdg-open', args: [url] };
10
+ }
11
+
12
+ export function openSystemBrowser(url, {
13
+ platform = process.platform,
14
+ comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
15
+ spawnImpl = spawn,
16
+ } = {}) {
17
+ const { command, args } = browserOpenCommand(url, { platform, comspec });
18
+ try {
19
+ const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
20
+ child.on('error', () => {});
21
+ child.unref();
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }