@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,1123 @@
1
+ // Shared visual-compare primitives: copy tables, locale helpers, board spec
2
+ // normalization, SVG building blocks, and image pipeline utilities.
3
+ // Split from visual-compare.js so board renderers stay focused on layout.
4
+
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import sharp from 'sharp';
8
+ import { compactTimestamp } from './time.js';
9
+ import {
10
+ OPENPRD_FALLBACK_LOCALE,
11
+ detectPrimaryLanguage,
12
+ isChineseLocale,
13
+ isEnglishHeavyText,
14
+ normalizeOutputLocale,
15
+ } from './language-policy.js';
16
+
17
+ export const DEFAULT_PANEL_WIDTH = 1180;
18
+ export const DEFAULT_QUALITY = 85;
19
+ export const DEFAULT_REFERENCE_LABEL = '效果图';
20
+ export const DEFAULT_ACTUAL_LABEL = '实现截图';
21
+ export const DEFAULT_BEFORE_LABEL = '修改前';
22
+ export const DEFAULT_AFTER_LABEL = '修改后';
23
+ export const DEFAULT_FOCUS_TITLE = '局部焦点证据板';
24
+ export const DEFAULT_PARALLEL_TITLE = '并行实验证据板';
25
+ export const DEFAULT_VERIFICATION_TITLE = '截图实测证据板';
26
+ export const DEFAULT_ALIGNMENT_TITLE = '对齐辅助线证据板';
27
+ export const DEFAULT_CENTERING_TITLE = '单元素内部居中证据板';
28
+ export const DEFAULT_PARALLEL_CARD_WIDTH = 380;
29
+ export const DEFAULT_PARALLEL_COLUMNS = 3;
30
+ export const OUTPUT_FORMATS = new Set(['jpg', 'jpeg', 'png', 'webp']);
31
+ export const NON_VISIBLE_BOARD_KEYS = new Set([
32
+ 'actual',
33
+ 'after',
34
+ 'before',
35
+ 'boardSource',
36
+ 'box',
37
+ 'boxes',
38
+ 'cardWidth',
39
+ 'color',
40
+ 'columns',
41
+ 'format',
42
+ 'generatedAt',
43
+ 'grid',
44
+ 'height',
45
+ 'id',
46
+ 'image',
47
+ 'mode',
48
+ 'outputPath',
49
+ 'overlay',
50
+ 'path',
51
+ 'reference',
52
+ 'schema',
53
+ 'screenshot',
54
+ 'source',
55
+ 'sourcePath',
56
+ 'targetBox',
57
+ 'unit',
58
+ 'url',
59
+ 'width',
60
+ 'x',
61
+ 'y',
62
+ ]);
63
+ export const VISUAL_COPY = {
64
+ zh: {
65
+ separator: ':',
66
+ materialLabel: (index) => `素材 ${index + 1}`,
67
+ metricTextLabel: '说明',
68
+ metricFallbackLabel: '指标',
69
+ referenceLabel: DEFAULT_REFERENCE_LABEL,
70
+ actualLabel: DEFAULT_ACTUAL_LABEL,
71
+ beforeLabel: DEFAULT_BEFORE_LABEL,
72
+ afterLabel: DEFAULT_AFTER_LABEL,
73
+ focusTitle: DEFAULT_FOCUS_TITLE,
74
+ parallelTitle: DEFAULT_PARALLEL_TITLE,
75
+ verificationTitle: DEFAULT_VERIFICATION_TITLE,
76
+ alignmentTitle: DEFAULT_ALIGNMENT_TITLE,
77
+ centeringTitle: DEFAULT_CENTERING_TITLE,
78
+ focusSummary: '先看整体标框,再看编号对应的局部放大;局部差异优先在这里复核。',
79
+ focusEyebrow: '视觉验收 / 局部焦点',
80
+ focusRegionLabel: (index) => `焦点 ${index + 1}`,
81
+ referenceEyebrow: '视觉验收 / 效果对比',
82
+ beforeAfterEyebrow: '视觉验收 / 修改前后',
83
+ parallelEyebrow: '视觉验收 / 并行实验',
84
+ parallelSummary: '把多方向产物、局部截图、GIF 首帧和指标放到一板里,方便统一审查。',
85
+ parallelItemLabel: (index) => `方案 ${index + 1}`,
86
+ verificationEyebrow: '视觉验收 / 截图实测',
87
+ verificationCardEyebrow: '截图实测',
88
+ verificationSingleLabel: '实测截图',
89
+ verificationItemLabel: (index) => `实测截图 ${index + 1}`,
90
+ verificationMediaLabel: (index) => `截图 ${index + 1}`,
91
+ alignmentSummary: '用辅助线和坐标 spread 同时验证同构列表、卡片、网格或表格的容器轨道与内部内容槽位是否对齐。',
92
+ alignmentEyebrow: '视觉验收 / 对齐辅助线',
93
+ alignmentScreenshotLabel: '真实截图',
94
+ alignmentOverlayLabel: '辅助线截图',
95
+ alignmentMeasureEyebrow: '对齐量测',
96
+ alignmentGroupLabel: (index) => `对齐分组 ${index + 1}`,
97
+ alignmentGuideLabel: (index) => `辅助线 ${index + 1}`,
98
+ alignmentRegionLabel: (index) => `区域 ${index + 1}`,
99
+ alignmentContainerLevel: '容器轨道',
100
+ alignmentContentSlotLevel: '内容槽位轨道',
101
+ alignmentExpectedPrefix: '预期',
102
+ alignmentStructurePrefix: '结构',
103
+ alignmentItemLabel: '对齐项',
104
+ alignmentThresholdLabel: '阈值',
105
+ passVerdict: '通过',
106
+ reworkVerdict: '需返工',
107
+ centeringSummary: '用像素 mask 识别主体,红线是画布中心,绿色框是主体外接框,黄色点是视觉重心;同时检查外接框中心和视觉重心偏移。',
108
+ centeringEyebrow: '视觉验收 / 内部居中',
109
+ centeringImageLabel: '目标元素',
110
+ centeringOverlayLabel: '量测覆盖图',
111
+ centeringMeasureLabel: '内部居中量测',
112
+ centeringMeasureEyebrow: '像素量测',
113
+ centeringThresholdLabel: '阈值',
114
+ centeringCanvasSizeLabel: '画布尺寸',
115
+ centeringBoundsLabel: '主体外接框',
116
+ centeringBboxOffsetLabel: '外接框中心偏移',
117
+ centeringCentroidOffsetLabel: '视觉重心偏移',
118
+ centeringMaxOffsetLabel: '最大偏移',
119
+ centeringPixelRatioLabel: '主体像素占比',
120
+ centeringCentroidLabel: '重心',
121
+ centeringNotes: '红色=画布中心;绿色=主体外接框和外接框中心;黄色=视觉重心。若 mask 误判,改用 subject.ranges 或 subject.colors 后重跑。',
122
+ resultPrefix: '结论',
123
+ referenceNextActions: [
124
+ '把输出图片作为视觉评审证据查看:左侧效果图,右侧实现截图。',
125
+ '如果仍有明显差异,继续按效果图复刻并重新运行 visual-compare。',
126
+ '只有对比图确认一致后,才声明本阶段界面视觉实现完成。',
127
+ ],
128
+ beforeAfterNextActions: [
129
+ '把输出图片作为视觉改动自检证据查看:左侧修改前,右侧修改后。',
130
+ '检查预期变化是否出现,以及未改区域是否有布局、颜色、密度或状态漂移。',
131
+ '没有效果图时,这张图只证明改动前后差异已自检;大界面方向性改造仍需先完成方案评审。',
132
+ ],
133
+ verificationSummary: (route) => route
134
+ ? `实测路径:${route}`
135
+ : '把普通截图、Computer/Browser 实测路径和检查点拼到同一张板里,避免只用单张截图口头收口。',
136
+ verificationNextActions: [
137
+ '先核对实测路径和每张截图对应的检查点,再判断是否能声明视觉或运行态验收完成。',
138
+ '普通截图只能作为原始素材;最终收口请引用这张截图实测证据板。',
139
+ '如果发现局部差异,再补一张局部焦点证据板或重新生成本证据板。',
140
+ ],
141
+ focusNextActions: [
142
+ '先看顶部编号框,再对照下面同编号的局部放大图复核差异。',
143
+ '若局部区域仍有问题,优先围绕该编号返工,并重新生成焦点证据板。',
144
+ '局部证据板适合补充整体对比,不建议只看整体图就结束界面验收。',
145
+ ],
146
+ parallelNextActions: [
147
+ '横向比较每个实验卡片,再结合指标和结论判断是否要保留多条方向继续迭代。',
148
+ '如果某个方向需要局部细看,再补一张局部焦点证据板,不要只盯整体缩略图。',
149
+ '并行实验证据板适合在 Agent 自验收和用户评审时一起使用,减少来回口头解释。',
150
+ ],
151
+ alignmentNextActions: [
152
+ '先看辅助线是否同时覆盖容器轨道和内容槽位轨道,再看每个分组里的 spread 是否低于阈值。',
153
+ '列表、卡片、网格或表格的新功能开发,只要有同构重复单元,就把标题、副标题、描述、标签、状态、价格、按钮、图标等内部槽位作为默认量测项。',
154
+ '如果只量了卡片外框、列宽或行顶,没有量内部内容槽位,不能声明卡片对齐已经完成。',
155
+ '如果任一关键槽位超过阈值且没有设计解释,先返工再重新生成对齐证据板。',
156
+ ],
157
+ centeringNextActions: [
158
+ '先看红色画布中心线、绿色主体外接框和黄色视觉重心点是否在同一中心附近。',
159
+ '如果外接框中心或视觉重心偏移超过阈值,先调整元素画布、内边距或素材裁切,再重新生成内部居中证据板。',
160
+ '如果自动 mask 把背景或阴影算进主体,改用 subject.ranges 或 subject.colors 明确主体颜色后重跑。',
161
+ '单张原始截图或“看起来居中”的主观判断不能替代这张内部居中证据板。',
162
+ ],
163
+ },
164
+ en: {
165
+ separator: ': ',
166
+ materialLabel: (index) => `Asset ${index + 1}`,
167
+ metricTextLabel: 'Notes',
168
+ metricFallbackLabel: 'Metric',
169
+ referenceLabel: 'Reference',
170
+ actualLabel: 'Implementation',
171
+ beforeLabel: 'Before',
172
+ afterLabel: 'After',
173
+ focusTitle: 'Focus Evidence Board',
174
+ parallelTitle: 'Parallel Experiment Board',
175
+ verificationTitle: 'Screenshot Verification Board',
176
+ alignmentTitle: 'Alignment Evidence Board',
177
+ centeringTitle: 'Element Centering Board',
178
+ focusSummary: 'Review the marked overview first, then inspect each numbered zoomed region.',
179
+ focusEyebrow: 'Visual QA / Focus Regions',
180
+ focusRegionLabel: (index) => `Focus ${index + 1}`,
181
+ referenceEyebrow: 'Visual QA / Reference Comparison',
182
+ beforeAfterEyebrow: 'Visual QA / Before and After',
183
+ parallelEyebrow: 'Visual QA / Parallel Experiments',
184
+ parallelSummary: 'Collect multiple directions, local screenshots, GIF key frames, and metrics on one board for review.',
185
+ parallelItemLabel: (index) => `Option ${index + 1}`,
186
+ verificationEyebrow: 'Visual QA / Screenshot Verification',
187
+ verificationCardEyebrow: 'Screenshot Check',
188
+ verificationSingleLabel: 'Verified Screenshot',
189
+ verificationItemLabel: (index) => `Verified Screenshot ${index + 1}`,
190
+ verificationMediaLabel: (index) => `Screenshot ${index + 1}`,
191
+ alignmentSummary: 'Use guides and coordinate spread to verify container tracks and internal content slots across repeated UI units.',
192
+ alignmentEyebrow: 'Visual QA / Alignment Guides',
193
+ alignmentScreenshotLabel: 'Live Screenshot',
194
+ alignmentOverlayLabel: 'Guide Overlay',
195
+ alignmentMeasureEyebrow: 'Alignment Measurement',
196
+ alignmentGroupLabel: (index) => `Alignment Group ${index + 1}`,
197
+ alignmentGuideLabel: (index) => `Guide ${index + 1}`,
198
+ alignmentRegionLabel: (index) => `Region ${index + 1}`,
199
+ alignmentContainerLevel: 'Container Track',
200
+ alignmentContentSlotLevel: 'Content Slot Track',
201
+ alignmentExpectedPrefix: 'Expected',
202
+ alignmentStructurePrefix: 'Structure',
203
+ alignmentItemLabel: 'Alignment Item',
204
+ alignmentThresholdLabel: 'Threshold',
205
+ passVerdict: 'Pass',
206
+ reworkVerdict: 'Needs Rework',
207
+ centeringSummary: 'Use a pixel mask to isolate the subject: red lines mark the canvas center, the green box marks subject bounds, and the yellow dot marks visual centroid.',
208
+ centeringEyebrow: 'Visual QA / Internal Centering',
209
+ centeringImageLabel: 'Target Element',
210
+ centeringOverlayLabel: 'Measurement Overlay',
211
+ centeringMeasureLabel: 'Internal Centering Measurement',
212
+ centeringMeasureEyebrow: 'Pixel Measurement',
213
+ centeringThresholdLabel: 'Threshold',
214
+ centeringCanvasSizeLabel: 'Canvas Size',
215
+ centeringBoundsLabel: 'Subject Bounds',
216
+ centeringBboxOffsetLabel: 'Bounds Center Offset',
217
+ centeringCentroidOffsetLabel: 'Visual Centroid Offset',
218
+ centeringMaxOffsetLabel: 'Max Offset',
219
+ centeringPixelRatioLabel: 'Subject Pixel Ratio',
220
+ centeringCentroidLabel: 'centroid',
221
+ centeringNotes: 'Red=canvas center; green=subject bounds and bounds center; yellow=visual centroid. If the mask is wrong, rerun with subject.ranges or subject.colors.',
222
+ resultPrefix: 'Result',
223
+ referenceNextActions: [
224
+ 'Review the output image as visual QA evidence: reference on the left, implementation on the right.',
225
+ 'If visible differences remain, keep iterating against the reference and rerun visual-compare.',
226
+ 'Only claim this UI stage is visually complete after the comparison looks aligned.',
227
+ ],
228
+ beforeAfterNextActions: [
229
+ 'Review the output image as before/after evidence for the visual change.',
230
+ 'Check that the intended change appeared and unchanged areas did not drift in layout, color, density, or state.',
231
+ 'Without a reference image, this only proves a before/after self-check; large directional UI changes still need pre-implementation visual review.',
232
+ ],
233
+ verificationSummary: (route) => route
234
+ ? `Checked path: ${route}`
235
+ : 'Combine screenshots, Computer/Browser check paths, and checkpoints into one board instead of closing on a raw screenshot.',
236
+ verificationNextActions: [
237
+ 'Review the checked path and every screenshot checkpoint before claiming visual or runtime verification is complete.',
238
+ 'Raw screenshots are only inputs; cite this screenshot verification board for final closeout.',
239
+ 'If a local difference appears, add a focus evidence board or regenerate this board.',
240
+ ],
241
+ focusNextActions: [
242
+ 'Review the numbered overview first, then inspect the matching zoomed regions below.',
243
+ 'If a focused region still has issues, iterate on that numbered region and regenerate the board.',
244
+ 'Use focus boards as supporting evidence for the overall comparison instead of closing on the overview alone.',
245
+ ],
246
+ parallelNextActions: [
247
+ 'Compare each experiment card side by side, then use the metrics and verdicts to decide which directions remain.',
248
+ 'If one direction needs closer inspection, add a focus evidence board instead of relying on the thumbnail.',
249
+ 'Use this board for both agent self-checks and user review to reduce back-and-forth explanation.',
250
+ ],
251
+ alignmentNextActions: [
252
+ 'Check that guides cover both container tracks and content-slot tracks, then confirm each group spread is under threshold.',
253
+ 'For repeated lists, cards, grids, or tables, measure internal slots such as title, subtitle, body, badge, status, price, buttons, and icons.',
254
+ 'If only card frames, column widths, or row tops were measured, do not claim card alignment is complete.',
255
+ 'If any critical slot exceeds threshold without a design rationale, fix it and regenerate the alignment board.',
256
+ ],
257
+ centeringNextActions: [
258
+ 'Check whether the red canvas center lines, green subject bounds, and yellow visual centroid land near the same center.',
259
+ 'If the bounds center or visual centroid exceeds threshold, adjust the canvas, padding, or asset crop and regenerate the board.',
260
+ 'If the automatic mask includes background or shadow pixels, rerun with explicit subject.ranges or subject.colors.',
261
+ 'A raw screenshot or a subjective “looks centered” check does not replace this centering evidence board.',
262
+ ],
263
+ },
264
+ };
265
+ export const FOCUS_COLORS = ['#f97316', '#22c55e', '#38bdf8', '#eab308', '#fb7185', '#a78bfa'];
266
+ export const ALIGNMENT_CONTAINER_TOKENS = [
267
+ 'container',
268
+ 'outer',
269
+ 'card edge',
270
+ 'column',
271
+ 'row',
272
+ 'width',
273
+ 'height',
274
+ '容器',
275
+ '外框',
276
+ '边界',
277
+ '列',
278
+ '行',
279
+ '卡片宽度',
280
+ '列间距',
281
+ '行顶',
282
+ '宽度',
283
+ '高度',
284
+ ];
285
+ export const ALIGNMENT_CONTENT_SLOT_TOKENS = [
286
+ 'content slot',
287
+ 'internal slot',
288
+ 'same slot',
289
+ 'title',
290
+ 'subtitle',
291
+ 'description',
292
+ 'body',
293
+ 'tag',
294
+ 'badge',
295
+ 'status',
296
+ 'price',
297
+ 'button',
298
+ 'action',
299
+ 'icon',
300
+ '内容槽位',
301
+ '内部槽位',
302
+ '相同槽位',
303
+ '文案类型',
304
+ '组件槽位',
305
+ '标题',
306
+ '副标题',
307
+ '描述',
308
+ '正文',
309
+ '标签',
310
+ '状态',
311
+ '价格',
312
+ '按钮',
313
+ '操作区',
314
+ '图标',
315
+ '基线',
316
+ ];
317
+ export const MODE_PREFIX = {
318
+ 'reference-actual': 'visual-compare',
319
+ 'before-after': 'visual-before-after',
320
+ 'focus-board': 'visual-focus-board',
321
+ 'parallel-board': 'visual-parallel-board',
322
+ 'verification-board': 'visual-verification-board',
323
+ 'alignment-board': 'visual-alignment-board',
324
+ 'centering-board': 'visual-centering-board',
325
+ };
326
+ export const BOARD_MODE_ALIASES = new Map([
327
+ ['focus', 'focus-board'],
328
+ ['focus-board', 'focus-board'],
329
+ ['focus-region', 'focus-board'],
330
+ ['focus-region-board', 'focus-board'],
331
+ ['parallel', 'parallel-board'],
332
+ ['parallel-board', 'parallel-board'],
333
+ ['parallel-experiment-board', 'parallel-board'],
334
+ ['experiment-board', 'parallel-board'],
335
+ ['verification', 'verification-board'],
336
+ ['verification-board', 'verification-board'],
337
+ ['screenshot-board', 'verification-board'],
338
+ ['screenshot-evidence-board', 'verification-board'],
339
+ ['visual-verification-board', 'verification-board'],
340
+ ['alignment', 'alignment-board'],
341
+ ['alignment-board', 'alignment-board'],
342
+ ['alignment-guide-board', 'alignment-board'],
343
+ ['grid-alignment', 'alignment-board'],
344
+ ['grid-alignment-board', 'alignment-board'],
345
+ ['visual-alignment-board', 'alignment-board'],
346
+ ['center', 'centering-board'],
347
+ ['centering', 'centering-board'],
348
+ ['center-board', 'centering-board'],
349
+ ['centering-board', 'centering-board'],
350
+ ['center-evidence-board', 'centering-board'],
351
+ ['visual-centering-board', 'centering-board'],
352
+ ]);
353
+
354
+ export function visualCopyForLocale(locale) {
355
+ return isChineseLocale(locale) ? VISUAL_COPY.zh : VISUAL_COPY.en;
356
+ }
357
+
358
+ export function localeFromOptions(options = {}) {
359
+ return normalizeOutputLocale(options.locale ?? options.lang);
360
+ }
361
+
362
+ export function resolveBoardLocale(options = {}, payload = {}) {
363
+ const explicit = options.locale ?? options.lang ?? payload.locale ?? payload.lang;
364
+ if (explicit) {
365
+ return normalizeOutputLocale(explicit);
366
+ }
367
+ return detectPrimaryLanguage(collectBoardVisibleTextEntries(payload).map((entry) => entry.value));
368
+ }
369
+
370
+ export function collectBoardVisibleTextEntries(value, basePath = '') {
371
+ const entries = [];
372
+ const visit = (node, pathKey) => {
373
+ if (typeof node === 'string') {
374
+ const key = pathKey.split('.').at(-1) ?? '';
375
+ if (!NON_VISIBLE_BOARD_KEYS.has(key) && node.trim()) {
376
+ entries.push({ path: pathKey || 'value', value: node.trim() });
377
+ }
378
+ return;
379
+ }
380
+ if (Array.isArray(node)) {
381
+ for (const [index, item] of node.entries()) {
382
+ visit(item, `${pathKey}.${index}`.replace(/^\./, ''));
383
+ }
384
+ return;
385
+ }
386
+ if (node && typeof node === 'object') {
387
+ for (const [key, item] of Object.entries(node)) {
388
+ if (NON_VISIBLE_BOARD_KEYS.has(key)) {
389
+ continue;
390
+ }
391
+ visit(item, `${pathKey}.${key}`.replace(/^\./, ''));
392
+ }
393
+ }
394
+ };
395
+ visit(value, basePath);
396
+ return entries;
397
+ }
398
+
399
+ export function validateBoardVisibleLanguage(board, locale) {
400
+ if (!isChineseLocale(locale)) {
401
+ return;
402
+ }
403
+ const offenders = collectBoardVisibleTextEntries(board.payload)
404
+ .filter((entry) => isEnglishHeavyText(entry.value))
405
+ .slice(0, 12);
406
+ if (offenders.length === 0) {
407
+ return;
408
+ }
409
+ const details = offenders
410
+ .map((entry) => `- ${entry.path} 应跟随中文语境,当前内容偏英文: ${entry.value.slice(0, 96)}`)
411
+ .join('\n');
412
+ throw new Error(`Invalid visual board language for locale zh-CN:\n${details}`);
413
+ }
414
+
415
+ export function normalizeFormat(format, outPath) {
416
+ const requested = String(format || '').trim().toLowerCase();
417
+ if (requested) {
418
+ if (!OUTPUT_FORMATS.has(requested)) {
419
+ throw new Error(`Unsupported visual compare format: ${format}. Use jpg, png, or webp.`);
420
+ }
421
+ return requested === 'jpeg' ? 'jpg' : requested;
422
+ }
423
+
424
+ const ext = path.extname(String(outPath || '')).slice(1).toLowerCase();
425
+ if (OUTPUT_FORMATS.has(ext)) {
426
+ return ext === 'jpeg' ? 'jpg' : ext;
427
+ }
428
+ return 'jpg';
429
+ }
430
+
431
+ export function outputExtension(format) {
432
+ return format === 'jpeg' ? 'jpg' : format;
433
+ }
434
+
435
+ export function normalizeWorkspacePath(value) {
436
+ return String(value ?? '').split(path.sep).join('/');
437
+ }
438
+
439
+ export function toWorkspacePath(projectRoot, filePath) {
440
+ const absolutePath = path.resolve(filePath);
441
+ const relativePath = path.relative(projectRoot, absolutePath);
442
+ if (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
443
+ return normalizeWorkspacePath(relativePath);
444
+ }
445
+ return absolutePath;
446
+ }
447
+
448
+ export function resolveProjectPath(projectRoot, filePath) {
449
+ return path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath);
450
+ }
451
+
452
+ export function defaultOutputPath(projectRoot, format, mode) {
453
+ const prefix = MODE_PREFIX[mode] ?? 'visual-compare';
454
+ return path.join(
455
+ projectRoot,
456
+ '.openprd',
457
+ 'harness',
458
+ 'visual-reviews',
459
+ `${prefix}-${compactTimestamp()}.${outputExtension(format)}`,
460
+ );
461
+ }
462
+
463
+ export function parsePositiveInteger(value, fallback, label) {
464
+ if (value === null || value === undefined || value === '') {
465
+ return fallback;
466
+ }
467
+ const parsed = Number.parseInt(String(value), 10);
468
+ if (!Number.isFinite(parsed) || parsed <= 0) {
469
+ throw new Error(`${label} must be a positive integer.`);
470
+ }
471
+ return parsed;
472
+ }
473
+
474
+ export function parseQuality(value) {
475
+ const quality = parsePositiveInteger(value, DEFAULT_QUALITY, '--quality');
476
+ if (quality < 1 || quality > 100) {
477
+ throw new Error('--quality must be between 1 and 100.');
478
+ }
479
+ return quality;
480
+ }
481
+
482
+ export function metadataPathForOutput(outputPath) {
483
+ const ext = path.extname(outputPath);
484
+ return ext ? outputPath.slice(0, -ext.length) + '.json' : `${outputPath}.json`;
485
+ }
486
+
487
+ export function escapeXml(value) {
488
+ return String(value)
489
+ .replaceAll('&', '&amp;')
490
+ .replaceAll('<', '&lt;')
491
+ .replaceAll('>', '&gt;')
492
+ .replaceAll('"', '&quot;')
493
+ .replaceAll("'", '&apos;');
494
+ }
495
+
496
+ export function charCount(value) {
497
+ return Array.from(String(value ?? '')).length;
498
+ }
499
+
500
+ export function compactLabel(value, maxChars = 14) {
501
+ const chars = Array.from(String(value ?? ''));
502
+ if (chars.length <= maxChars) {
503
+ return chars.join('');
504
+ }
505
+ return `${chars.slice(0, Math.max(1, maxChars - 3)).join('')}...`;
506
+ }
507
+
508
+ export function wrapText(value, maxCharsPerLine) {
509
+ const lines = [];
510
+ const normalized = String(value ?? '').trim();
511
+ if (!normalized) {
512
+ return lines;
513
+ }
514
+ for (const rawLine of normalized.split(/\r?\n/u)) {
515
+ const line = rawLine.trim();
516
+ if (!line) {
517
+ continue;
518
+ }
519
+ let buffer = '';
520
+ for (const char of Array.from(line)) {
521
+ if (charCount(buffer) >= maxCharsPerLine) {
522
+ lines.push(buffer);
523
+ buffer = '';
524
+ }
525
+ buffer += char;
526
+ }
527
+ if (buffer) {
528
+ lines.push(buffer);
529
+ }
530
+ }
531
+ return lines;
532
+ }
533
+
534
+ export function lineSvg(lines, {
535
+ x = 0,
536
+ y = 0,
537
+ lineHeight = 24,
538
+ fontSize = 18,
539
+ fill = '#e5e7eb',
540
+ fontWeight = 500,
541
+ } = {}) {
542
+ return lines.map((line, index) => (
543
+ `<text x="${x}" y="${y + index * lineHeight}" fill="${fill}" font-size="${fontSize}" font-weight="${fontWeight}" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${escapeXml(line)}</text>`
544
+ )).join('');
545
+ }
546
+
547
+ export function labelSvg(label, options = {}) {
548
+ const fontSize = options.fontSize ?? 22;
549
+ const height = options.height ?? 46;
550
+ const paddingX = options.paddingX ?? 21;
551
+ const radius = options.radius ?? 14;
552
+ const bg = options.background ?? '#111827';
553
+ const bgOpacity = options.backgroundOpacity ?? 0.82;
554
+ const stroke = options.stroke ?? '#ffffff';
555
+ const strokeOpacity = options.strokeOpacity ?? 0.22;
556
+ const maxWidth = Number.isFinite(Number(options.maxWidth)) ? Math.max(1, Number(options.maxWidth)) : Infinity;
557
+ const minWidth = Math.min(options.minWidth ?? 126, maxWidth);
558
+ const availableChars = Number.isFinite(maxWidth)
559
+ ? Math.max(1, Math.floor((maxWidth - paddingX * 2) / Math.max(fontSize + 4, 1)))
560
+ : charCount(label);
561
+ const displayLabel = Number.isFinite(maxWidth) ? compactLabel(label, availableChars) : String(label ?? '');
562
+ const text = escapeXml(displayLabel);
563
+ const width = Math.min(maxWidth, Math.max(minWidth, charCount(displayLabel) * (fontSize + 4) + paddingX * 2));
564
+ return Buffer.from(`
565
+ <svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
566
+ <rect x="0" y="0" width="${width}" height="${height}" rx="${radius}" fill="${bg}" fill-opacity="${bgOpacity}"/>
567
+ <rect x="0.75" y="0.75" width="${width - 1.5}" height="${height - 1.5}" rx="${Math.max(radius - 0.75, 1)}" fill="none" stroke="${stroke}" stroke-opacity="${strokeOpacity}" stroke-width="1.5"/>
568
+ <text x="${paddingX}" y="${Math.round(height * 0.64)}" fill="#ffffff" font-size="${fontSize}" font-weight="700" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${text}</text>
569
+ </svg>`);
570
+ }
571
+
572
+ export function titleBlockSvg(width, title, subtitle, eyebrow = null) {
573
+ const contentWidth = Math.max(Number(width) || 0, 1);
574
+ const titleLines = wrapText(title, Math.max(12, Math.floor((contentWidth - 48) / 18)));
575
+ const subtitleLines = wrapText(subtitle, Math.max(16, Math.floor((contentWidth - 48) / 16)));
576
+ const eyebrowLines = wrapText(eyebrow, Math.max(18, Math.floor((contentWidth - 48) / 18)));
577
+ let y = eyebrowLines.length > 0 ? 28 : 0;
578
+ const parts = [];
579
+ if (eyebrowLines.length > 0) {
580
+ parts.push(lineSvg(eyebrowLines, {
581
+ x: 0,
582
+ y: y,
583
+ lineHeight: 20,
584
+ fontSize: 16,
585
+ fill: '#93c5fd',
586
+ fontWeight: 700,
587
+ }));
588
+ y += eyebrowLines.length * 20 + 14;
589
+ }
590
+ parts.push(lineSvg(titleLines, {
591
+ x: 0,
592
+ y: y + 30,
593
+ lineHeight: 34,
594
+ fontSize: 30,
595
+ fill: '#f8fafc',
596
+ fontWeight: 800,
597
+ }));
598
+ y += titleLines.length * 34 + 10;
599
+ if (subtitleLines.length > 0) {
600
+ parts.push(lineSvg(subtitleLines, {
601
+ x: 0,
602
+ y: y + 24,
603
+ lineHeight: 24,
604
+ fontSize: 18,
605
+ fill: '#cbd5e1',
606
+ fontWeight: 500,
607
+ }));
608
+ y += subtitleLines.length * 24 + 6;
609
+ }
610
+ const height = Math.max(72, y + 16);
611
+ return {
612
+ height,
613
+ input: Buffer.from(`
614
+ <svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
615
+ ${parts.join('')}
616
+ </svg>`),
617
+ };
618
+ }
619
+
620
+ export function sectionHeaderSvg(width, index, label, reason = '') {
621
+ const contentWidth = Math.max(Number(width) || 0, 1);
622
+ const titleLines = wrapText(`${index}. ${label}`, Math.max(10, Math.floor((contentWidth - 48) / 18)));
623
+ const reasonLines = wrapText(reason, Math.max(14, Math.floor((contentWidth - 48) / 16)));
624
+ const height = 32 + titleLines.length * 28 + (reasonLines.length > 0 ? 10 + reasonLines.length * 22 : 0);
625
+ return {
626
+ height,
627
+ input: Buffer.from(`
628
+ <svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
629
+ <rect x="0" y="0" width="${contentWidth}" height="${height}" rx="18" fill="#0f172a" fill-opacity="0.92"/>
630
+ <rect x="0.75" y="0.75" width="${contentWidth - 1.5}" height="${height - 1.5}" rx="17.25" fill="none" stroke="#475569" stroke-opacity="0.45" stroke-width="1.5"/>
631
+ ${lineSvg(titleLines, {
632
+ x: 20,
633
+ y: 30,
634
+ lineHeight: 28,
635
+ fontSize: 24,
636
+ fill: '#f8fafc',
637
+ fontWeight: 800,
638
+ })}
639
+ ${reasonLines.length > 0 ? lineSvg(reasonLines, {
640
+ x: 20,
641
+ y: 30 + titleLines.length * 28 + 10,
642
+ lineHeight: 22,
643
+ fontSize: 16,
644
+ fill: '#cbd5e1',
645
+ fontWeight: 500,
646
+ }) : ''}
647
+ </svg>`),
648
+ };
649
+ }
650
+
651
+ export function metricsSvg(width, metrics = [], notes = null, locale = OPENPRD_FALLBACK_LOCALE) {
652
+ const copy = visualCopyForLocale(locale);
653
+ const contentWidth = Math.max(Number(width) || 0, 1);
654
+ const lines = [];
655
+ for (const metric of metrics) {
656
+ lines.push(`${metric.label}${copy.separator}${metric.value}`);
657
+ }
658
+ if (notes) {
659
+ lines.push(...wrapText(notes, Math.max(12, Math.floor((contentWidth - 24) / 16))));
660
+ }
661
+ if (lines.length === 0) {
662
+ return { height: 0, input: null };
663
+ }
664
+ const height = 18 + lines.length * 22;
665
+ return {
666
+ height,
667
+ input: Buffer.from(`
668
+ <svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
669
+ ${lineSvg(lines, {
670
+ x: 0,
671
+ y: 20,
672
+ lineHeight: 22,
673
+ fontSize: 16,
674
+ fill: '#cbd5e1',
675
+ fontWeight: 500,
676
+ })}
677
+ </svg>`),
678
+ };
679
+ }
680
+
681
+ export async function resizePanel(inputPath, panelWidth) {
682
+ const source = path.resolve(inputPath);
683
+ const metadata = await sharp(source).metadata();
684
+ if (!metadata.width || !metadata.height) {
685
+ throw new Error(`Cannot read image dimensions: ${inputPath}`);
686
+ }
687
+ const { data, info } = await sharp(source)
688
+ .rotate()
689
+ .resize({
690
+ width: panelWidth,
691
+ fit: 'inside',
692
+ withoutEnlargement: true,
693
+ })
694
+ .png()
695
+ .toBuffer({ resolveWithObject: true });
696
+ return {
697
+ input: data,
698
+ width: info.width,
699
+ height: info.height,
700
+ source,
701
+ original: {
702
+ width: metadata.width,
703
+ height: metadata.height,
704
+ format: metadata.format ?? null,
705
+ },
706
+ };
707
+ }
708
+
709
+ export async function extractCrop(inputPath, box, outputWidth) {
710
+ const source = path.resolve(inputPath);
711
+ const { data, info } = await sharp(source)
712
+ .rotate()
713
+ .extract({
714
+ left: box.x,
715
+ top: box.y,
716
+ width: box.width,
717
+ height: box.height,
718
+ })
719
+ .resize({
720
+ width: outputWidth,
721
+ fit: 'inside',
722
+ withoutEnlargement: false,
723
+ })
724
+ .png()
725
+ .toBuffer({ resolveWithObject: true });
726
+ return {
727
+ input: data,
728
+ width: info.width,
729
+ height: info.height,
730
+ source,
731
+ absolute: box,
732
+ };
733
+ }
734
+
735
+ export function encodePipeline(image, format, quality) {
736
+ if (format === 'png') {
737
+ return image.png();
738
+ }
739
+ if (format === 'webp') {
740
+ return image.webp({ quality });
741
+ }
742
+ return image.jpeg({ quality });
743
+ }
744
+
745
+ export function resolveComparisonInputs(options) {
746
+ const locale = localeFromOptions(options);
747
+ const copy = visualCopyForLocale(locale);
748
+ const hasReferenceActual = Boolean(options.reference || options.actual);
749
+ const hasBeforeAfter = Boolean(options.before || options.after);
750
+ const hasBoard = Boolean(options.board);
751
+ const modeCount = [hasReferenceActual, hasBeforeAfter, hasBoard].filter(Boolean).length;
752
+ if (modeCount > 1) {
753
+ throw new Error('Use either --reference/--actual, --before/--after, or --board, not multiple modes together.');
754
+ }
755
+ if (hasBeforeAfter) {
756
+ if (!options.before) {
757
+ throw new Error('Missing --before image path.');
758
+ }
759
+ if (!options.after) {
760
+ throw new Error('Missing --after image path.');
761
+ }
762
+ return {
763
+ mode: 'before-after',
764
+ locale,
765
+ left: options.before,
766
+ right: options.after,
767
+ leftLabel: options.referenceLabel || copy.beforeLabel,
768
+ rightLabel: options.actualLabel || copy.afterLabel,
769
+ eyebrow: copy.beforeAfterEyebrow,
770
+ nextActions: copy.beforeAfterNextActions,
771
+ };
772
+ }
773
+ if (hasReferenceActual) {
774
+ if (!options.reference) {
775
+ throw new Error('Missing --reference image path.');
776
+ }
777
+ if (!options.actual) {
778
+ throw new Error('Missing --actual image path.');
779
+ }
780
+ return {
781
+ mode: 'reference-actual',
782
+ locale,
783
+ left: options.reference,
784
+ right: options.actual,
785
+ leftLabel: options.referenceLabel || copy.referenceLabel,
786
+ rightLabel: options.actualLabel || copy.actualLabel,
787
+ eyebrow: copy.referenceEyebrow,
788
+ nextActions: copy.referenceNextActions,
789
+ };
790
+ }
791
+ if (hasBoard) {
792
+ return {
793
+ mode: 'board',
794
+ board: options.board,
795
+ };
796
+ }
797
+ throw new Error('Missing visual compare input. Use --reference/--actual, --before/--after, or --board.');
798
+ }
799
+
800
+ export function normalizeBoardMode(value) {
801
+ const key = String(value ?? '').trim().toLowerCase();
802
+ return BOARD_MODE_ALIASES.get(key) ?? null;
803
+ }
804
+
805
+ export async function readBoardSpec(projectRoot, boardPath) {
806
+ const sourcePath = resolveProjectPath(projectRoot, boardPath);
807
+ const payload = JSON.parse(await fs.readFile(sourcePath, 'utf8'));
808
+ const mode = normalizeBoardMode(payload.mode);
809
+ if (!mode) {
810
+ throw new Error(`Unsupported board mode in ${boardPath}. Use focus-board, parallel-board, verification-board, alignment-board, or centering-board.`);
811
+ }
812
+ return {
813
+ mode,
814
+ sourcePath,
815
+ payload,
816
+ };
817
+ }
818
+
819
+ export function normalizeImageSpec(projectRoot, value, fallbackLabel) {
820
+ if (!value) {
821
+ throw new Error('Board image spec is missing.');
822
+ }
823
+ if (typeof value === 'string') {
824
+ return {
825
+ path: resolveProjectPath(projectRoot, value),
826
+ label: fallbackLabel,
827
+ };
828
+ }
829
+ if (typeof value === 'object' && value.path) {
830
+ return {
831
+ path: resolveProjectPath(projectRoot, value.path),
832
+ label: value.label ? String(value.label) : fallbackLabel,
833
+ };
834
+ }
835
+ throw new Error('Board image spec must be a path string or an object with { path, label? }.');
836
+ }
837
+
838
+ export function normalizeMetricList(metrics, locale = OPENPRD_FALLBACK_LOCALE) {
839
+ const copy = visualCopyForLocale(locale);
840
+ if (!metrics) {
841
+ return [];
842
+ }
843
+ if (Array.isArray(metrics)) {
844
+ return metrics
845
+ .filter(Boolean)
846
+ .map((item) => {
847
+ if (typeof item === 'string') {
848
+ return { label: copy.metricTextLabel, value: item };
849
+ }
850
+ return {
851
+ label: String(item.label ?? item.key ?? copy.metricFallbackLabel),
852
+ value: String(item.value ?? ''),
853
+ };
854
+ })
855
+ .filter((item) => item.value.trim());
856
+ }
857
+ if (typeof metrics === 'object') {
858
+ return Object.entries(metrics)
859
+ .filter(([, value]) => value !== null && value !== undefined && String(value).trim())
860
+ .map(([key, value]) => ({ label: key, value: String(value) }));
861
+ }
862
+ return [];
863
+ }
864
+
865
+ export function normalizeMediaList(projectRoot, media, locale = OPENPRD_FALLBACK_LOCALE) {
866
+ const copy = visualCopyForLocale(locale);
867
+ if (!Array.isArray(media)) {
868
+ return [];
869
+ }
870
+ return media
871
+ .filter(Boolean)
872
+ .map((entry, index) => {
873
+ if (typeof entry === 'string') {
874
+ return {
875
+ path: resolveProjectPath(projectRoot, entry),
876
+ label: copy.materialLabel(index),
877
+ };
878
+ }
879
+ if (!entry.path) {
880
+ throw new Error(`Parallel board media[${index}] is missing path.`);
881
+ }
882
+ return {
883
+ path: resolveProjectPath(projectRoot, entry.path),
884
+ label: String(entry.label ?? copy.materialLabel(index)),
885
+ };
886
+ });
887
+ }
888
+
889
+ export function normalizeVerificationItems(projectRoot, payload, locale = OPENPRD_FALLBACK_LOCALE) {
890
+ const copy = visualCopyForLocale(locale);
891
+ let rawItems = Array.isArray(payload.items) ? payload.items : [];
892
+ if (rawItems.length === 0 && Array.isArray(payload.screenshots)) {
893
+ rawItems = payload.screenshots;
894
+ }
895
+ if (rawItems.length === 0) {
896
+ const singlePath = payload.screenshot ?? payload.actual ?? payload.path;
897
+ if (singlePath) {
898
+ rawItems = [{
899
+ path: singlePath,
900
+ label: payload.label ?? copy.verificationSingleLabel,
901
+ notes: payload.notes ?? payload.note ?? payload.summary,
902
+ checks: payload.checks ?? payload.checkpoints,
903
+ }];
904
+ }
905
+ }
906
+ return rawItems.map((item, index) => {
907
+ if (typeof item === 'string') {
908
+ return {
909
+ label: copy.verificationItemLabel(index),
910
+ subtitle: '',
911
+ verdict: '',
912
+ media: [{ path: item, label: copy.verificationSingleLabel }],
913
+ metrics: [],
914
+ notes: '',
915
+ };
916
+ }
917
+ const media = Array.isArray(item.media) && item.media.length > 0
918
+ ? item.media
919
+ : (item.path ? [{ path: item.path, label: item.mediaLabel ?? item.label ?? copy.verificationMediaLabel(index) }] : []);
920
+ return {
921
+ label: String(item.label ?? item.title ?? copy.verificationItemLabel(index)),
922
+ subtitle: String(item.subtitle ?? item.route ?? item.step ?? '').trim(),
923
+ verdict: String(item.verdict ?? item.status ?? '').trim(),
924
+ media,
925
+ metrics: item.metrics ?? item.checks ?? item.checkpoints,
926
+ notes: String(item.notes ?? item.note ?? item.expected ?? '').trim(),
927
+ };
928
+ });
929
+ }
930
+
931
+ export function parseCropDimension(box, key, alias) {
932
+ const primary = box[key];
933
+ const secondary = alias ? box[alias] : undefined;
934
+ const value = primary ?? secondary;
935
+ if (value === null || value === undefined || value === '') {
936
+ throw new Error(`Missing ${key} in focus region box.`);
937
+ }
938
+ const parsed = Number(value);
939
+ if (!Number.isFinite(parsed)) {
940
+ throw new Error(`Invalid ${key} in focus region box.`);
941
+ }
942
+ return parsed;
943
+ }
944
+
945
+ export function clampBox(box, original) {
946
+ const x = Math.max(0, Math.min(box.x, original.width - 1));
947
+ const y = Math.max(0, Math.min(box.y, original.height - 1));
948
+ const width = Math.max(1, Math.min(box.width, original.width - x));
949
+ const height = Math.max(1, Math.min(box.height, original.height - y));
950
+ return {
951
+ x,
952
+ y,
953
+ width,
954
+ height,
955
+ };
956
+ }
957
+
958
+ export function resolveCropBox(box, original) {
959
+ if (!box || typeof box !== 'object') {
960
+ throw new Error('Focus region box must be an object.');
961
+ }
962
+ const rawUnit = String(box.unit ?? 'ratio').trim().toLowerCase();
963
+ const x = parseCropDimension(box, 'x', 'left');
964
+ const y = parseCropDimension(box, 'y', 'top');
965
+ const width = parseCropDimension(box, 'width', 'w');
966
+ const height = parseCropDimension(box, 'height', 'h');
967
+ let absolute;
968
+
969
+ if (rawUnit === 'ratio') {
970
+ absolute = {
971
+ x: Math.round(x * original.width),
972
+ y: Math.round(y * original.height),
973
+ width: Math.round(width * original.width),
974
+ height: Math.round(height * original.height),
975
+ };
976
+ } else if (rawUnit === 'percent') {
977
+ absolute = {
978
+ x: Math.round((x / 100) * original.width),
979
+ y: Math.round((y / 100) * original.height),
980
+ width: Math.round((width / 100) * original.width),
981
+ height: Math.round((height / 100) * original.height),
982
+ };
983
+ } else if (rawUnit === 'px' || rawUnit === 'pixel' || rawUnit === 'pixels') {
984
+ absolute = {
985
+ x: Math.round(x),
986
+ y: Math.round(y),
987
+ width: Math.round(width),
988
+ height: Math.round(height),
989
+ };
990
+ } else {
991
+ throw new Error(`Unsupported focus region unit: ${rawUnit}. Use ratio, percent, or px.`);
992
+ }
993
+
994
+ return {
995
+ unit: rawUnit === 'pixel' || rawUnit === 'pixels' ? 'px' : rawUnit,
996
+ requested: { x, y, width, height },
997
+ absolute: clampBox(absolute, original),
998
+ };
999
+ }
1000
+
1001
+ export function renderedBoxFromAbsolute(box, panel) {
1002
+ const scaleX = panel.width / panel.original.width;
1003
+ const scaleY = panel.height / panel.original.height;
1004
+ return {
1005
+ x: Math.round(box.x * scaleX),
1006
+ y: Math.round(box.y * scaleY),
1007
+ width: Math.max(2, Math.round(box.width * scaleX)),
1008
+ height: Math.max(2, Math.round(box.height * scaleY)),
1009
+ };
1010
+ }
1011
+
1012
+ export function focusOverlaySvg(width, height, regions) {
1013
+ const overlays = regions.map((region, index) => {
1014
+ const color = region.color;
1015
+ const box = region.box;
1016
+ const badgeSize = 32;
1017
+ const badgeX = Math.max(10, box.x + 10);
1018
+ const badgeY = Math.max(10, box.y + 10);
1019
+ return `
1020
+ <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="16" fill="none" stroke="${color}" stroke-width="4"/>
1021
+ <rect x="${badgeX}" y="${badgeY}" width="${badgeSize}" height="${badgeSize}" rx="16" fill="${color}"/>
1022
+ <text x="${badgeX + 10}" y="${badgeY + 22}" fill="#0f172a" font-size="18" font-weight="800" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${index + 1}</text>
1023
+ `;
1024
+ }).join('');
1025
+ return Buffer.from(`
1026
+ <svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
1027
+ ${overlays}
1028
+ </svg>`);
1029
+ }
1030
+
1031
+
1032
+ export async function renderParallelCard(projectRoot, item, index, options = {}) {
1033
+ const cardWidth = options.cardWidth;
1034
+ const locale = localeFromOptions(options);
1035
+ const copy = visualCopyForLocale(locale);
1036
+ const contentWidth = cardWidth - 36;
1037
+ const title = String(item.label ?? item.title ?? copy.parallelItemLabel(index));
1038
+ const subtitle = String(item.subtitle ?? item.summary ?? '').trim();
1039
+ const verdict = String(item.verdict ?? '').trim();
1040
+ const mediaList = normalizeMediaList(projectRoot, item.media, locale);
1041
+ const metrics = normalizeMetricList(item.metrics ?? item.metricMap, locale);
1042
+ const notes = String(item.notes ?? item.note ?? '').trim();
1043
+ const header = titleBlockSvg(
1044
+ contentWidth,
1045
+ `${index + 1}. ${title}`,
1046
+ subtitle,
1047
+ verdict ? `${copy.resultPrefix}${copy.separator}${verdict}` : (options.eyebrow ?? copy.parallelEyebrow),
1048
+ );
1049
+ const composites = [
1050
+ { input: header.input, left: 18, top: 18 },
1051
+ ];
1052
+ const renderedMedia = [];
1053
+ let currentTop = 18 + header.height + 12;
1054
+
1055
+ for (const media of mediaList) {
1056
+ const label = labelSvg(media.label, {
1057
+ fontSize: 16,
1058
+ height: 36,
1059
+ minWidth: 96,
1060
+ maxWidth: contentWidth,
1061
+ paddingX: 16,
1062
+ radius: 12,
1063
+ });
1064
+ const mediaPanel = await resizePanel(media.path, contentWidth);
1065
+ composites.push(
1066
+ { input: label, left: 18, top: currentTop },
1067
+ { input: mediaPanel.input, left: 18 + Math.round((contentWidth - mediaPanel.width) / 2), top: currentTop + 42 },
1068
+ );
1069
+ renderedMedia.push({
1070
+ path: toWorkspacePath(projectRoot, media.path),
1071
+ label: media.label,
1072
+ original: mediaPanel.original,
1073
+ rendered: {
1074
+ width: mediaPanel.width,
1075
+ height: mediaPanel.height,
1076
+ },
1077
+ });
1078
+ currentTop += 42 + mediaPanel.height + 16;
1079
+ }
1080
+
1081
+ const metricsBlock = metricsSvg(contentWidth, metrics, notes, locale);
1082
+ if (metricsBlock.input) {
1083
+ composites.push({ input: metricsBlock.input, left: 18, top: currentTop });
1084
+ currentTop += metricsBlock.height + 12;
1085
+ }
1086
+
1087
+ const cardHeight = currentTop + 18;
1088
+ const card = sharp({
1089
+ create: {
1090
+ width: cardWidth,
1091
+ height: cardHeight,
1092
+ channels: 3,
1093
+ background: '#0f172a',
1094
+ },
1095
+ }).composite([
1096
+ ...composites,
1097
+ {
1098
+ input: Buffer.from(`
1099
+ <svg width="${cardWidth}" height="${cardHeight}" viewBox="0 0 ${cardWidth} ${cardHeight}" xmlns="http://www.w3.org/2000/svg">
1100
+ <rect x="0.75" y="0.75" width="${cardWidth - 1.5}" height="${cardHeight - 1.5}" rx="22" fill="none" stroke="#334155" stroke-width="1.5"/>
1101
+ </svg>`),
1102
+ left: 0,
1103
+ top: 0,
1104
+ },
1105
+ ]);
1106
+
1107
+ return {
1108
+ image: await card.png().toBuffer(),
1109
+ width: cardWidth,
1110
+ height: cardHeight,
1111
+ item: {
1112
+ index: index + 1,
1113
+ label: title,
1114
+ subtitle,
1115
+ level: item.level ?? '',
1116
+ levelLabel: item.levelLabel ?? '',
1117
+ verdict,
1118
+ metrics,
1119
+ notes,
1120
+ media: renderedMedia,
1121
+ },
1122
+ };
1123
+ }