@openprd/cli 0.1.11 → 0.1.18

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 (134) hide show
  1. package/.openprd/changes/adaptive-project-framing/.openprd.yaml +2 -0
  2. package/.openprd/changes/adaptive-project-framing/design.md +47 -0
  3. package/.openprd/changes/adaptive-project-framing/proposal.md +35 -0
  4. package/.openprd/changes/adaptive-project-framing/specs/agent-requirements/spec.md +16 -0
  5. package/.openprd/changes/adaptive-project-framing/task-events.jsonl +20 -0
  6. package/.openprd/changes/adaptive-project-framing/tasks.md +340 -0
  7. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/.openprd.yaml +2 -0
  8. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/design.md +34 -0
  9. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/proposal.md +23 -0
  10. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/specs/agent-requirements/spec.md +16 -0
  11. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/task-events.jsonl +14 -0
  12. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/tasks.md +238 -0
  13. package/.openprd/changes/loop-isolated-worktree-commit/.openprd.yaml +2 -0
  14. package/.openprd/changes/loop-isolated-worktree-commit/design.md +53 -0
  15. package/.openprd/changes/loop-isolated-worktree-commit/proposal.md +38 -0
  16. package/.openprd/changes/loop-isolated-worktree-commit/specs/agent-requirements/spec.md +16 -0
  17. package/.openprd/changes/loop-isolated-worktree-commit/task-events.jsonl +22 -0
  18. package/.openprd/changes/loop-isolated-worktree-commit/tasks.md +357 -0
  19. package/.openprd/changes/openprd/.openprd.yaml +2 -0
  20. package/.openprd/changes/openprd/design.md +46 -0
  21. package/.openprd/changes/openprd/proposal.md +36 -0
  22. package/.openprd/changes/openprd/specs/agent-requirements/spec.md +16 -0
  23. package/.openprd/changes/openprd/task-events.jsonl +23 -0
  24. package/.openprd/changes/openprd/tasks.md +242 -0
  25. package/.openprd/changes/pm-human-in-the-loop/.openprd.yaml +2 -0
  26. package/.openprd/changes/pm-human-in-the-loop/design.md +38 -0
  27. package/.openprd/changes/pm-human-in-the-loop/proposal.md +29 -0
  28. package/.openprd/changes/pm-human-in-the-loop/specs/agent-requirements/spec.md +16 -0
  29. package/.openprd/changes/pm-human-in-the-loop/task-events.jsonl +18 -0
  30. package/.openprd/changes/pm-human-in-the-loop/tasks.md +306 -0
  31. package/.openprd/changes/test-strategy-router/.openprd.yaml +2 -0
  32. package/.openprd/changes/test-strategy-router/design.md +65 -0
  33. package/.openprd/changes/test-strategy-router/proposal.md +45 -0
  34. package/.openprd/changes/test-strategy-router/specs/agent-requirements/spec.md +16 -0
  35. package/.openprd/changes/test-strategy-router/task-events.jsonl +1 -0
  36. package/.openprd/changes/test-strategy-router/tasks.md +132 -0
  37. package/.openprd/changes/verify-boundaries-and-historical-refresh/.openprd.yaml +2 -0
  38. package/.openprd/changes/verify-boundaries-and-historical-refresh/design.md +28 -0
  39. package/.openprd/changes/verify-boundaries-and-historical-refresh/proposal.md +23 -0
  40. package/.openprd/changes/verify-boundaries-and-historical-refresh/specs/agent-requirements/spec.md +16 -0
  41. package/.openprd/changes/verify-boundaries-and-historical-refresh/task-events.jsonl +11 -0
  42. package/.openprd/changes/verify-boundaries-and-historical-refresh/tasks.md +66 -0
  43. package/.openprd/changes/vibe-coding-dev-check/.openprd.yaml +2 -0
  44. package/.openprd/changes/vibe-coding-dev-check/design.md +37 -0
  45. package/.openprd/changes/vibe-coding-dev-check/proposal.md +28 -0
  46. package/.openprd/changes/vibe-coding-dev-check/specs/agent-requirements/spec.md +16 -0
  47. package/.openprd/changes/vibe-coding-dev-check/task-events.jsonl +18 -0
  48. package/.openprd/changes/vibe-coding-dev-check/tasks.md +306 -0
  49. package/.openprd/design/README.md +11 -6
  50. package/.openprd/design/README_EN.md +4 -1
  51. package/.openprd/design/active/asset-spec.md +6 -0
  52. package/.openprd/design/active/direction-plan.md +5 -5
  53. package/.openprd/design/active/selected-direction.md +2 -0
  54. package/.openprd/design/anti-slop.md +26 -1
  55. package/.openprd/design/assets/surface-presets.md +6 -0
  56. package/.openprd/design/checklists/ui-quality-gate.md +7 -0
  57. package/.openprd/design/lenses/frontend-lenses.md +28 -0
  58. package/.openprd/design/recipes/content-experience.md +8 -1
  59. package/.openprd/design/recipes/operational-tool.md +7 -0
  60. package/.openprd/design/recipes/product-launch.md +8 -1
  61. package/.openprd/design/templates/README.md +6 -3
  62. package/.openprd/design/templates/README_EN.md +6 -3
  63. package/.openprd/design/templates/ops-dashboard.html +8 -1
  64. package/.openprd/design/templates/product-launch.html +2 -1
  65. package/.openprd/design/themes/theme-catalog.json +23 -5
  66. package/.openprd/engagements/active/prd.md +103 -100
  67. package/.openprd/standards/config.json +7 -1
  68. package/AGENTS.md +13 -3
  69. package/README.md +107 -14
  70. package/README_EN.md +131 -13
  71. package/package.json +1 -1
  72. package/scripts/dev-check-wrapup-copy.mjs +46 -21
  73. package/skills/openprd-diagram-review/SKILL.md +27 -2
  74. package/skills/openprd-diagram-review/references/explanation-svg-patterns.md +131 -0
  75. package/skills/openprd-discovery-loop/SKILL.md +2 -2
  76. package/skills/openprd-frontend-design/SKILL.md +44 -20
  77. package/skills/openprd-frontend-design/references/design-asset-contract.md +6 -0
  78. package/skills/openprd-frontend-design/references/direction-engine.md +29 -4
  79. package/skills/openprd-harness/SKILL.md +22 -16
  80. package/skills/openprd-quality/SKILL.md +10 -8
  81. package/skills/openprd-requirement-intake/SKILL.md +2 -0
  82. package/skills/openprd-router/SKILL.md +2 -1
  83. package/skills/openprd-shared/SKILL.md +26 -16
  84. package/skills/openprd-standards/SKILL.md +4 -2
  85. package/src/agent-canonical-content.js +808 -0
  86. package/src/agent-integration.js +258 -777
  87. package/src/canvas-app.html.js +2910 -0
  88. package/src/canvas-i18n.js +274 -0
  89. package/src/canvas-workspace.js +2271 -0
  90. package/src/cli/args.js +72 -4
  91. package/src/cli/basic-print.js +3 -0
  92. package/src/cli/canvas-print.js +106 -0
  93. package/src/cli/doctor-print.js +20 -0
  94. package/src/cli/print.js +2 -0
  95. package/src/cli/quality-commands.js +222 -0
  96. package/src/cli/quality-print.js +28 -2
  97. package/src/cli/run-print.js +5 -0
  98. package/src/cli/workflow-print.js +3 -0
  99. package/src/codex-app-server-relay.js +251 -0
  100. package/src/codex-hook-runner-template.mjs +957 -59
  101. package/src/design-starter-support.js +2 -2
  102. package/src/design-starter.js +39 -3
  103. package/src/dev-standards.js +125 -10
  104. package/src/diagram-core.js +6 -20
  105. package/src/discovery.js +2 -8
  106. package/src/docs-compact.js +125 -0
  107. package/src/execution-strategy.js +1 -1
  108. package/src/fleet.js +9 -3
  109. package/src/guide.js +227 -0
  110. package/src/html-artifacts.js +428 -7
  111. package/src/knowledge.js +45 -11
  112. package/src/language-policy.js +63 -3
  113. package/src/learning-review.js +1 -1
  114. package/src/loop.js +1 -1
  115. package/src/openprd.js +68 -131
  116. package/src/openspec/change-validate.js +2 -2
  117. package/src/openspec/constants.js +6 -7
  118. package/src/openspec/execute.js +2 -2
  119. package/src/openspec/generate.js +318 -102
  120. package/src/openspec/migration.js +168 -0
  121. package/src/openspec/paths.js +1 -22
  122. package/src/prd-core.js +1 -1
  123. package/src/quality-visual-review.js +257 -14
  124. package/src/quality.js +46 -8
  125. package/src/review-presentation.js +60 -0
  126. package/src/run-harness.js +41 -3
  127. package/src/session-evidence.js +363 -0
  128. package/src/standards.js +64 -0
  129. package/src/test-strategy.js +7 -4
  130. package/src/visual-compare-centering.js +622 -0
  131. package/src/visual-compare-core.js +1123 -0
  132. package/src/visual-compare.js +620 -698
  133. package/src/workspace-core.js +94 -4
  134. package/src/workspace-workflow.js +46 -1
@@ -0,0 +1,622 @@
1
+ // Centering-board renderer: analyzes a single asset for canvas-center offset,
2
+ // subject bounding-box offset, and visual weight drift, then renders the board.
3
+ // Split from visual-compare.js.
4
+
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import sharp from 'sharp';
8
+ import { VISUAL_REVIEW_SCHEMA } from './quality-visual-review.js';
9
+ import { timestamp } from './time.js';
10
+ import {
11
+ DEFAULT_PANEL_WIDTH,
12
+ charCount,
13
+ compactLabel,
14
+ defaultOutputPath,
15
+ encodePipeline,
16
+ escapeXml,
17
+ labelSvg,
18
+ localeFromOptions,
19
+ metadataPathForOutput,
20
+ normalizeFormat,
21
+ normalizeImageSpec,
22
+ parsePositiveInteger,
23
+ parseQuality,
24
+ renderParallelCard,
25
+ resolveCropBox,
26
+ titleBlockSvg,
27
+ toWorkspacePath,
28
+ visualCopyForLocale,
29
+ } from './visual-compare-core.js';
30
+
31
+ function round1(value) {
32
+ const rounded = Math.round(Number(value) * 10) / 10;
33
+ return Object.is(rounded, -0) ? 0 : rounded;
34
+ }
35
+
36
+ function formatSignedPx(value) {
37
+ const rounded = round1(value);
38
+ return `${rounded >= 0 ? '+' : ''}${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}px`;
39
+ }
40
+
41
+ function luminance(r, g, b) {
42
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
43
+ }
44
+
45
+ function colorDistance(r, g, b, color) {
46
+ return Math.sqrt(
47
+ ((r - color.r) ** 2)
48
+ + ((g - color.g) ** 2)
49
+ + ((b - color.b) ** 2),
50
+ );
51
+ }
52
+
53
+ function parseChannelRange(range, key) {
54
+ const raw = range?.[key];
55
+ if (raw === null || raw === undefined || raw === '') {
56
+ return [key === 'a' ? 1 : 0, 255];
57
+ }
58
+ if (Array.isArray(raw)) {
59
+ return [
60
+ Math.max(0, Math.min(255, Number(raw[0] ?? 0))),
61
+ Math.max(0, Math.min(255, Number(raw[1] ?? 255))),
62
+ ];
63
+ }
64
+ if (typeof raw === 'object') {
65
+ return [
66
+ Math.max(0, Math.min(255, Number(raw.min ?? raw.from ?? 0))),
67
+ Math.max(0, Math.min(255, Number(raw.max ?? raw.to ?? 255))),
68
+ ];
69
+ }
70
+ const value = Math.max(0, Math.min(255, Number(raw)));
71
+ return [value, value];
72
+ }
73
+
74
+ function pixelInRange(r, g, b, a, range) {
75
+ const [rMin, rMax] = parseChannelRange(range, 'r');
76
+ const [gMin, gMax] = parseChannelRange(range, 'g');
77
+ const [bMin, bMax] = parseChannelRange(range, 'b');
78
+ const [aMin, aMax] = parseChannelRange(range, 'a');
79
+ return r >= rMin && r <= rMax
80
+ && g >= gMin && g <= gMax
81
+ && b >= bMin && b <= bMax
82
+ && a >= aMin && a <= aMax;
83
+ }
84
+
85
+ function parseColorSpec(value, fallbackTolerance) {
86
+ if (!value) {
87
+ return null;
88
+ }
89
+ if (typeof value === 'string') {
90
+ const hex = value.trim().replace(/^#/u, '');
91
+ if (/^[0-9a-f]{6}$/iu.test(hex)) {
92
+ return {
93
+ r: Number.parseInt(hex.slice(0, 2), 16),
94
+ g: Number.parseInt(hex.slice(2, 4), 16),
95
+ b: Number.parseInt(hex.slice(4, 6), 16),
96
+ tolerance: fallbackTolerance,
97
+ };
98
+ }
99
+ return null;
100
+ }
101
+ if (typeof value !== 'object') {
102
+ return null;
103
+ }
104
+ const fromHex = parseColorSpec(value.hex ?? value.color, fallbackTolerance);
105
+ if (fromHex) {
106
+ return {
107
+ ...fromHex,
108
+ tolerance: Number(value.tolerance ?? value.threshold ?? fallbackTolerance),
109
+ };
110
+ }
111
+ const r = Number(value.r);
112
+ const g = Number(value.g);
113
+ const b = Number(value.b);
114
+ if (![r, g, b].every(Number.isFinite)) {
115
+ return null;
116
+ }
117
+ return {
118
+ r: Math.max(0, Math.min(255, r)),
119
+ g: Math.max(0, Math.min(255, g)),
120
+ b: Math.max(0, Math.min(255, b)),
121
+ tolerance: Number(value.tolerance ?? value.threshold ?? fallbackTolerance),
122
+ };
123
+ }
124
+
125
+ function normalizeColorSpecs(subject, fallbackTolerance) {
126
+ const raw = subject.colors ?? subject.color ?? subject.includeColors;
127
+ const entries = Array.isArray(raw) ? raw : (raw ? [raw] : []);
128
+ return entries
129
+ .map((entry) => parseColorSpec(entry, fallbackTolerance))
130
+ .filter(Boolean);
131
+ }
132
+
133
+ function normalizeCenteringSubject(payload) {
134
+ const subject = (payload.subject && typeof payload.subject === 'object')
135
+ ? payload.subject
136
+ : ((payload.mask && typeof payload.mask === 'object') ? payload.mask : {});
137
+ const mode = String(subject.mode ?? payload.maskMode ?? 'auto').trim().toLowerCase();
138
+ const colorDistanceThreshold = Number(subject.colorDistanceThreshold ?? subject.colorThreshold ?? payload.colorDistanceThreshold ?? 28);
139
+ const lumaThreshold = Number(subject.lumaThreshold ?? payload.lumaThreshold ?? 22);
140
+ return {
141
+ mode: ['alpha', 'range', 'ranges', 'color', 'colors'].includes(mode)
142
+ ? (mode === 'ranges' ? 'range' : (mode === 'colors' ? 'color' : mode))
143
+ : 'auto',
144
+ alphaThreshold: Number(subject.alphaThreshold ?? payload.alphaThreshold ?? 8),
145
+ colorDistanceThreshold,
146
+ lumaThreshold,
147
+ weight: String(subject.weight ?? payload.weight ?? 'contrast').trim().toLowerCase(),
148
+ ranges: Array.isArray(subject.ranges ?? subject.include)
149
+ ? (subject.ranges ?? subject.include)
150
+ : (subject.range ? [subject.range] : []),
151
+ colors: normalizeColorSpecs(subject, colorDistanceThreshold),
152
+ };
153
+ }
154
+
155
+ function estimateCornerBackground(data, width, height, channels) {
156
+ const sample = Math.max(2, Math.min(12, Math.floor(Math.min(width, height) * 0.06)));
157
+ const points = [];
158
+ for (const yStart of [0, Math.max(0, height - sample)]) {
159
+ for (const xStart of [0, Math.max(0, width - sample)]) {
160
+ for (let y = yStart; y < Math.min(height, yStart + sample); y += 1) {
161
+ for (let x = xStart; x < Math.min(width, xStart + sample); x += 1) {
162
+ points.push((y * width + x) * channels);
163
+ }
164
+ }
165
+ }
166
+ }
167
+ const totals = { r: 0, g: 0, b: 0, a: 0 };
168
+ for (const index of points) {
169
+ totals.r += data[index];
170
+ totals.g += data[index + 1];
171
+ totals.b += data[index + 2];
172
+ totals.a += data[index + 3] ?? 255;
173
+ }
174
+ const count = Math.max(points.length, 1);
175
+ return {
176
+ r: totals.r / count,
177
+ g: totals.g / count,
178
+ b: totals.b / count,
179
+ a: totals.a / count,
180
+ };
181
+ }
182
+
183
+ function centerPoint(width, height) {
184
+ return {
185
+ x: width / 2,
186
+ y: height / 2,
187
+ };
188
+ }
189
+
190
+ function centeringPixelActive(r, g, b, a, subject, background) {
191
+ if (a <= subject.alphaThreshold) {
192
+ return false;
193
+ }
194
+ if (subject.mode === 'alpha') {
195
+ return true;
196
+ }
197
+ if (subject.mode === 'range') {
198
+ return subject.ranges.some((range) => pixelInRange(r, g, b, a, range));
199
+ }
200
+ if (subject.mode === 'color') {
201
+ return subject.colors.some((color) => colorDistance(r, g, b, color) <= color.tolerance);
202
+ }
203
+ if (background.a <= subject.alphaThreshold) {
204
+ return true;
205
+ }
206
+ return colorDistance(r, g, b, background) >= subject.colorDistanceThreshold
207
+ || Math.abs(luminance(r, g, b) - luminance(background.r, background.g, background.b)) >= subject.lumaThreshold;
208
+ }
209
+
210
+ function centeringPixelWeight(r, g, b, a, subject, background) {
211
+ if (subject.weight === 'presence') {
212
+ return 1;
213
+ }
214
+ if (subject.weight === 'alpha') {
215
+ return Math.max(a / 255, 0.01);
216
+ }
217
+ if (subject.weight === 'luma') {
218
+ return Math.max(luminance(r, g, b) / 255, 0.01);
219
+ }
220
+ return Math.max(colorDistance(r, g, b, background), 1);
221
+ }
222
+
223
+ async function loadCenteringRaw(sourcePath, box) {
224
+ const { data, info } = await sharp(sourcePath)
225
+ .rotate()
226
+ .extract({
227
+ left: box.x,
228
+ top: box.y,
229
+ width: box.width,
230
+ height: box.height,
231
+ })
232
+ .ensureAlpha()
233
+ .raw()
234
+ .toBuffer({ resolveWithObject: true });
235
+ return {
236
+ data,
237
+ width: info.width,
238
+ height: info.height,
239
+ channels: info.channels,
240
+ };
241
+ }
242
+
243
+ async function extractCenteringTargetPanel(inputPath, box, outputWidth, sourceOriginal) {
244
+ const source = path.resolve(inputPath);
245
+ const { data, info } = await sharp(source)
246
+ .rotate()
247
+ .extract({
248
+ left: box.x,
249
+ top: box.y,
250
+ width: box.width,
251
+ height: box.height,
252
+ })
253
+ .resize({
254
+ width: outputWidth,
255
+ fit: 'inside',
256
+ withoutEnlargement: true,
257
+ })
258
+ .png()
259
+ .toBuffer({ resolveWithObject: true });
260
+ return {
261
+ input: data,
262
+ width: info.width,
263
+ height: info.height,
264
+ source,
265
+ absolute: box,
266
+ original: {
267
+ width: box.width,
268
+ height: box.height,
269
+ sourceWidth: sourceOriginal.width,
270
+ sourceHeight: sourceOriginal.height,
271
+ format: sourceOriginal.format ?? null,
272
+ },
273
+ };
274
+ }
275
+
276
+ async function analyzeCenteringTarget(sourcePath, box, payload, locale = OPENPRD_FALLBACK_LOCALE) {
277
+ const copy = visualCopyForLocale(locale);
278
+ const raw = await loadCenteringRaw(sourcePath, box);
279
+ const subject = normalizeCenteringSubject(payload);
280
+ if (subject.mode === 'range' && subject.ranges.length === 0) {
281
+ throw new Error('Centering board subject.mode=range requires subject.ranges or subject.include.');
282
+ }
283
+ if (subject.mode === 'color' && subject.colors.length === 0) {
284
+ throw new Error('Centering board subject.mode=color requires subject.colors or subject.color.');
285
+ }
286
+ const background = estimateCornerBackground(raw.data, raw.width, raw.height, raw.channels);
287
+ let minX = raw.width;
288
+ let minY = raw.height;
289
+ let maxX = -1;
290
+ let maxY = -1;
291
+ let activePixelCount = 0;
292
+ let weightSum = 0;
293
+ let weightedX = 0;
294
+ let weightedY = 0;
295
+
296
+ for (let y = 0; y < raw.height; y += 1) {
297
+ for (let x = 0; x < raw.width; x += 1) {
298
+ const index = (y * raw.width + x) * raw.channels;
299
+ const r = raw.data[index];
300
+ const g = raw.data[index + 1];
301
+ const b = raw.data[index + 2];
302
+ const a = raw.data[index + 3] ?? 255;
303
+ if (!centeringPixelActive(r, g, b, a, subject, background)) {
304
+ continue;
305
+ }
306
+ activePixelCount += 1;
307
+ minX = Math.min(minX, x);
308
+ minY = Math.min(minY, y);
309
+ maxX = Math.max(maxX, x);
310
+ maxY = Math.max(maxY, y);
311
+ const weight = centeringPixelWeight(r, g, b, a, subject, background);
312
+ weightSum += weight;
313
+ weightedX += (x + 0.5) * weight;
314
+ weightedY += (y + 0.5) * weight;
315
+ }
316
+ }
317
+
318
+ if (activePixelCount === 0) {
319
+ throw new Error('Centering board could not isolate active subject pixels. Use subject.mode=range or subject.mode=color with explicit ranges/colors.');
320
+ }
321
+
322
+ const bounds = {
323
+ x: minX,
324
+ y: minY,
325
+ width: maxX - minX + 1,
326
+ height: maxY - minY + 1,
327
+ };
328
+ const canvasCenter = centerPoint(raw.width, raw.height);
329
+ const bboxCenter = {
330
+ x: bounds.x + bounds.width / 2,
331
+ y: bounds.y + bounds.height / 2,
332
+ };
333
+ const visualCentroid = {
334
+ x: weightedX / Math.max(weightSum, 1),
335
+ y: weightedY / Math.max(weightSum, 1),
336
+ };
337
+ const bboxCenterOffset = {
338
+ x: round1(bboxCenter.x - canvasCenter.x),
339
+ y: round1(bboxCenter.y - canvasCenter.y),
340
+ };
341
+ const visualCentroidOffset = {
342
+ x: round1(visualCentroid.x - canvasCenter.x),
343
+ y: round1(visualCentroid.y - canvasCenter.y),
344
+ };
345
+ const thresholdPx = Number(payload.thresholdPx ?? payload.threshold ?? 8);
346
+ const maxAbsOffset = Math.max(
347
+ Math.abs(bboxCenterOffset.x),
348
+ Math.abs(bboxCenterOffset.y),
349
+ Math.abs(visualCentroidOffset.x),
350
+ Math.abs(visualCentroidOffset.y),
351
+ );
352
+ const passed = maxAbsOffset <= thresholdPx;
353
+ return {
354
+ thresholdPx,
355
+ verdict: passed ? copy.passVerdict : copy.reworkVerdict,
356
+ passed,
357
+ maxAbsOffset: round1(maxAbsOffset),
358
+ canvas: {
359
+ width: raw.width,
360
+ height: raw.height,
361
+ },
362
+ canvasCenter: {
363
+ x: round1(canvasCenter.x),
364
+ y: round1(canvasCenter.y),
365
+ },
366
+ activeBounds: bounds,
367
+ bboxCenter: {
368
+ x: round1(bboxCenter.x),
369
+ y: round1(bboxCenter.y),
370
+ },
371
+ bboxCenterOffset,
372
+ visualCentroid: {
373
+ x: round1(visualCentroid.x),
374
+ y: round1(visualCentroid.y),
375
+ },
376
+ visualCentroidOffset,
377
+ activePixelCount,
378
+ activePixelRatio: round1((activePixelCount / Math.max(raw.width * raw.height, 1)) * 100),
379
+ subject: {
380
+ mode: subject.mode,
381
+ weight: subject.weight,
382
+ alphaThreshold: subject.alphaThreshold,
383
+ colorDistanceThreshold: subject.colorDistanceThreshold,
384
+ lumaThreshold: subject.lumaThreshold,
385
+ rangeCount: subject.ranges.length,
386
+ colorCount: subject.colors.length,
387
+ },
388
+ background: {
389
+ r: round1(background.r),
390
+ g: round1(background.g),
391
+ b: round1(background.b),
392
+ a: round1(background.a),
393
+ },
394
+ };
395
+ }
396
+
397
+ function centeringOverlaySvg(panel, analysis, locale = OPENPRD_FALLBACK_LOCALE) {
398
+ const copy = visualCopyForLocale(locale);
399
+ const scaleX = panel.width / analysis.canvas.width;
400
+ const scaleY = panel.height / analysis.canvas.height;
401
+ const centerX = round1(analysis.canvasCenter.x * scaleX);
402
+ const centerY = round1(analysis.canvasCenter.y * scaleY);
403
+ const bounds = {
404
+ x: round1(analysis.activeBounds.x * scaleX),
405
+ y: round1(analysis.activeBounds.y * scaleY),
406
+ width: Math.max(2, round1(analysis.activeBounds.width * scaleX)),
407
+ height: Math.max(2, round1(analysis.activeBounds.height * scaleY)),
408
+ };
409
+ const bboxCenterX = round1(analysis.bboxCenter.x * scaleX);
410
+ const bboxCenterY = round1(analysis.bboxCenter.y * scaleY);
411
+ const centroidX = round1(analysis.visualCentroid.x * scaleX);
412
+ const centroidY = round1(analysis.visualCentroid.y * scaleY);
413
+ const gridLines = [];
414
+ for (const ratio of [0.25, 0.5, 0.75]) {
415
+ const x = round1(panel.width * ratio);
416
+ const y = round1(panel.height * ratio);
417
+ gridLines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${panel.height}" stroke="#94a3b8" stroke-opacity="0.22" stroke-width="1"/>`);
418
+ gridLines.push(`<line x1="0" y1="${y}" x2="${panel.width}" y2="${y}" stroke="#94a3b8" stroke-opacity="0.22" stroke-width="1"/>`);
419
+ }
420
+ const bboxText = `bbox ${formatSignedPx(analysis.bboxCenterOffset.x)}, ${formatSignedPx(analysis.bboxCenterOffset.y)}`;
421
+ const centroidText = `${copy.centeringCentroidLabel} ${formatSignedPx(analysis.visualCentroidOffset.x)}, ${formatSignedPx(analysis.visualCentroidOffset.y)}`;
422
+ const badgeText = panel.width < 360 ? bboxText : `${bboxText} / ${centroidText}`;
423
+ const badgeWidth = Math.max(24, Math.min(Math.max(24, panel.width - 24), Math.max(320, charCount(badgeText) * 13 + 32)));
424
+ return Buffer.from(`
425
+ <svg width="${panel.width}" height="${panel.height}" viewBox="0 0 ${panel.width} ${panel.height}" xmlns="http://www.w3.org/2000/svg">
426
+ <rect x="0" y="0" width="${panel.width}" height="${panel.height}" fill="#020617" fill-opacity="0.06"/>
427
+ ${gridLines.join('')}
428
+ <line x1="${centerX}" y1="0" x2="${centerX}" y2="${panel.height}" stroke="#ef4444" stroke-width="3" stroke-dasharray="12 8"/>
429
+ <line x1="0" y1="${centerY}" x2="${panel.width}" y2="${centerY}" stroke="#ef4444" stroke-width="3" stroke-dasharray="12 8"/>
430
+ <rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}" rx="8" fill="none" stroke="#22c55e" stroke-width="4"/>
431
+ <line x1="${bboxCenterX - 16}" y1="${bboxCenterY}" x2="${bboxCenterX + 16}" y2="${bboxCenterY}" stroke="#22c55e" stroke-width="4"/>
432
+ <line x1="${bboxCenterX}" y1="${bboxCenterY - 16}" x2="${bboxCenterX}" y2="${bboxCenterY + 16}" stroke="#22c55e" stroke-width="4"/>
433
+ <circle cx="${centroidX}" cy="${centroidY}" r="9" fill="#facc15" stroke="#111827" stroke-width="3"/>
434
+ <rect x="12" y="12" width="${badgeWidth}" height="34" rx="17" fill="#0f172a" fill-opacity="0.88"/>
435
+ <text x="28" y="35" fill="#f8fafc" font-size="16" font-weight="800" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${escapeXml(badgeText)}</text>
436
+ <rect x="0.75" y="0.75" width="${panel.width - 1.5}" height="${panel.height - 1.5}" rx="12" fill="none" stroke="#475569" stroke-opacity="0.6" stroke-width="1.5"/>
437
+ </svg>`);
438
+ }
439
+
440
+ async function createCenteringOverlayPanel(targetPanel, analysis, locale = OPENPRD_FALLBACK_LOCALE) {
441
+ const overlay = centeringOverlaySvg(targetPanel, analysis, locale);
442
+ const input = await sharp(targetPanel.input)
443
+ .composite([{ input: overlay, left: 0, top: 0 }])
444
+ .png()
445
+ .toBuffer();
446
+ return {
447
+ ...targetPanel,
448
+ input,
449
+ };
450
+ }
451
+
452
+ function normalizeCenteringTargetBox(rawBox, original) {
453
+ if (!rawBox) {
454
+ const width = Math.max(1, Math.round(original.width));
455
+ const height = Math.max(1, Math.round(original.height));
456
+ return {
457
+ unit: 'px',
458
+ requested: { x: 0, y: 0, width, height },
459
+ absolute: { x: 0, y: 0, width, height },
460
+ };
461
+ }
462
+ return resolveCropBox(rawBox, original);
463
+ }
464
+
465
+ export async function renderCenteringBoard(projectRoot, board, options = {}) {
466
+ const locale = localeFromOptions(options);
467
+ const copy = visualCopyForLocale(locale);
468
+ const format = normalizeFormat(options.format, options.out);
469
+ const outputPath = options.out
470
+ ? path.resolve(projectRoot, options.out)
471
+ : defaultOutputPath(projectRoot, format, 'centering-board');
472
+ const quality = parseQuality(options.quality);
473
+ const maxPanelWidth = parsePositiveInteger(options.maxPanelWidth, DEFAULT_PANEL_WIDTH, '--max-panel-width');
474
+ const rawImage = board.payload.image ?? board.payload.screenshot ?? board.payload.actual ?? board.payload.path;
475
+ if (!rawImage) {
476
+ throw new Error('Centering board requires image, screenshot, actual, or path.');
477
+ }
478
+ const imageSpec = normalizeImageSpec(
479
+ projectRoot,
480
+ rawImage,
481
+ board.payload.imageLabel ?? board.payload.screenshotLabel ?? copy.centeringImageLabel,
482
+ );
483
+ const sourceMetadata = await sharp(imageSpec.path).metadata();
484
+ if (!sourceMetadata.width || !sourceMetadata.height) {
485
+ throw new Error(`Cannot read image dimensions: ${imageSpec.path}`);
486
+ }
487
+ const sourceOriginal = {
488
+ width: sourceMetadata.width,
489
+ height: sourceMetadata.height,
490
+ format: sourceMetadata.format ?? null,
491
+ };
492
+ const targetBox = normalizeCenteringTargetBox(
493
+ board.payload.targetBox ?? board.payload.box ?? board.payload.region,
494
+ sourceOriginal,
495
+ );
496
+ const targetPanel = await extractCenteringTargetPanel(
497
+ imageSpec.path,
498
+ targetBox.absolute,
499
+ maxPanelWidth,
500
+ sourceOriginal,
501
+ );
502
+ const analysis = await analyzeCenteringTarget(imageSpec.path, targetBox.absolute, board.payload, locale);
503
+ const overlayPanel = await createCenteringOverlayPanel(targetPanel, analysis, locale);
504
+ const panelWidth = Math.min(maxPanelWidth, Math.max(targetPanel.width, overlayPanel.width, 180));
505
+ const overviewHeight = Math.max(targetPanel.height, overlayPanel.height);
506
+ const margin = 24;
507
+ const gap = 24;
508
+ const contentWidth = Math.max(560, margin * 2 + panelWidth * 2 + gap);
509
+ const summary = String(board.payload.summary ?? copy.centeringSummary);
510
+ const titleBlock = titleBlockSvg(
511
+ contentWidth - margin * 2,
512
+ String(board.payload.title ?? copy.centeringTitle),
513
+ summary,
514
+ copy.centeringEyebrow,
515
+ );
516
+ const targetX = margin + Math.round((panelWidth - targetPanel.width) / 2);
517
+ const overlayX = margin + panelWidth + gap + Math.round((panelWidth - overlayPanel.width) / 2);
518
+ const overviewTop = margin + titleBlock.height + 18;
519
+ const targetY = overviewTop + Math.round((overviewHeight - targetPanel.height) / 2);
520
+ const overlayY = overviewTop + Math.round((overviewHeight - overlayPanel.height) / 2);
521
+ const metricsCard = await renderParallelCard(projectRoot, {
522
+ label: copy.centeringMeasureLabel,
523
+ subtitle: `${copy.centeringThresholdLabel} ${analysis.thresholdPx}px / ${analysis.verdict}`,
524
+ verdict: analysis.verdict,
525
+ metrics: [
526
+ { label: copy.centeringCanvasSizeLabel, value: `${analysis.canvas.width}x${analysis.canvas.height}px` },
527
+ { label: copy.centeringBoundsLabel, value: `x=${analysis.activeBounds.x}, y=${analysis.activeBounds.y}, ${analysis.activeBounds.width}x${analysis.activeBounds.height}px` },
528
+ { label: copy.centeringBboxOffsetLabel, value: `${formatSignedPx(analysis.bboxCenterOffset.x)}, ${formatSignedPx(analysis.bboxCenterOffset.y)}` },
529
+ { label: copy.centeringCentroidOffsetLabel, value: `${formatSignedPx(analysis.visualCentroidOffset.x)}, ${formatSignedPx(analysis.visualCentroidOffset.y)}` },
530
+ { label: copy.centeringMaxOffsetLabel, value: `${analysis.maxAbsOffset}px` },
531
+ { label: copy.centeringPixelRatioLabel, value: `${analysis.activePixelRatio}%` },
532
+ { label: 'mask', value: `${analysis.subject.mode} / weight=${analysis.subject.weight}` },
533
+ ],
534
+ notes: copy.centeringNotes,
535
+ }, 0, {
536
+ cardWidth: Math.min(contentWidth - margin * 2, Math.max(560, panelWidth * 2 + gap)),
537
+ eyebrow: copy.centeringMeasureEyebrow,
538
+ locale,
539
+ });
540
+ const metricsTop = overviewTop + overviewHeight + 28;
541
+ const composites = [
542
+ { input: titleBlock.input, left: margin, top: margin },
543
+ { input: targetPanel.input, left: targetX, top: targetY },
544
+ { input: overlayPanel.input, left: overlayX, top: overlayY },
545
+ {
546
+ input: labelSvg(compactLabel(imageSpec.label), {
547
+ fontSize: 14,
548
+ height: 32,
549
+ paddingX: 12,
550
+ radius: 10,
551
+ minWidth: 80,
552
+ maxWidth: Math.max(32, targetPanel.width - 32),
553
+ }),
554
+ left: targetX + 16,
555
+ top: targetY + 16,
556
+ },
557
+ { input: metricsCard.image, left: margin, top: metricsTop },
558
+ ];
559
+ const canvasHeight = metricsTop + metricsCard.height + margin;
560
+ const canvas = sharp({
561
+ create: {
562
+ width: contentWidth,
563
+ height: canvasHeight,
564
+ channels: 3,
565
+ background: '#111827',
566
+ },
567
+ }).composite(composites);
568
+
569
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
570
+ await encodePipeline(canvas, format, quality).toFile(outputPath);
571
+ const metadataPath = metadataPathForOutput(outputPath);
572
+ const reviewArtifact = {
573
+ version: 1,
574
+ schema: VISUAL_REVIEW_SCHEMA,
575
+ generatedAt: timestamp(),
576
+ mode: 'centering-board',
577
+ locale,
578
+ outputPath: toWorkspacePath(projectRoot, outputPath),
579
+ format,
580
+ boardSource: toWorkspacePath(projectRoot, board.sourcePath),
581
+ title: String(board.payload.title ?? copy.centeringTitle),
582
+ labels: {
583
+ image: imageSpec.label,
584
+ overlay: copy.centeringOverlayLabel,
585
+ },
586
+ target: {
587
+ path: toWorkspacePath(projectRoot, imageSpec.path),
588
+ original: sourceOriginal,
589
+ box: {
590
+ unit: targetBox.unit,
591
+ requested: targetBox.requested,
592
+ absolute: targetBox.absolute,
593
+ },
594
+ rendered: {
595
+ width: targetPanel.width,
596
+ height: targetPanel.height,
597
+ },
598
+ },
599
+ centering: analysis,
600
+ canvas: {
601
+ width: contentWidth,
602
+ height: canvasHeight,
603
+ },
604
+ };
605
+ await fs.writeFile(metadataPath, `${JSON.stringify(reviewArtifact, null, 2)}\n`, 'utf8');
606
+
607
+ return {
608
+ ok: true,
609
+ action: 'visual-compare',
610
+ mode: 'centering-board',
611
+ projectRoot,
612
+ outputPath,
613
+ metadataPath,
614
+ format,
615
+ quality: format === 'png' ? null : quality,
616
+ canvas: reviewArtifact.canvas,
617
+ target: reviewArtifact.target,
618
+ centering: analysis,
619
+ reviewArtifact,
620
+ nextActions: copy.centeringNextActions,
621
+ };
622
+ }