@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
@@ -3,594 +3,48 @@ import path from 'node:path';
3
3
  import sharp from 'sharp';
4
4
  import { VISUAL_REVIEW_SCHEMA } from './quality-visual-review.js';
5
5
  import { compactTimestamp, timestamp } from './time.js';
6
-
7
- const DEFAULT_PANEL_WIDTH = 1180;
8
- const DEFAULT_QUALITY = 85;
9
- const DEFAULT_REFERENCE_LABEL = '效果图';
10
- const DEFAULT_ACTUAL_LABEL = '实现截图';
11
- const DEFAULT_BEFORE_LABEL = '修改前';
12
- const DEFAULT_AFTER_LABEL = '修改后';
13
- const DEFAULT_FOCUS_TITLE = '局部焦点证据板';
14
- const DEFAULT_PARALLEL_TITLE = '并行实验证据板';
15
- const DEFAULT_PARALLEL_CARD_WIDTH = 380;
16
- const DEFAULT_PARALLEL_COLUMNS = 3;
17
- const OUTPUT_FORMATS = new Set(['jpg', 'jpeg', 'png', 'webp']);
18
- const FOCUS_COLORS = ['#f97316', '#22c55e', '#38bdf8', '#eab308', '#fb7185', '#a78bfa'];
19
- const MODE_PREFIX = {
20
- 'reference-actual': 'visual-compare',
21
- 'before-after': 'visual-before-after',
22
- 'focus-board': 'visual-focus-board',
23
- 'parallel-board': 'visual-parallel-board',
24
- };
25
- const BOARD_MODE_ALIASES = new Map([
26
- ['focus', 'focus-board'],
27
- ['focus-board', 'focus-board'],
28
- ['focus-region', 'focus-board'],
29
- ['focus-region-board', 'focus-board'],
30
- ['parallel', 'parallel-board'],
31
- ['parallel-board', 'parallel-board'],
32
- ['parallel-experiment-board', 'parallel-board'],
33
- ['experiment-board', 'parallel-board'],
34
- ]);
35
-
36
- function normalizeFormat(format, outPath) {
37
- const requested = String(format || '').trim().toLowerCase();
38
- if (requested) {
39
- if (!OUTPUT_FORMATS.has(requested)) {
40
- throw new Error(`Unsupported visual compare format: ${format}. Use jpg, png, or webp.`);
41
- }
42
- return requested === 'jpeg' ? 'jpg' : requested;
43
- }
44
-
45
- const ext = path.extname(String(outPath || '')).slice(1).toLowerCase();
46
- if (OUTPUT_FORMATS.has(ext)) {
47
- return ext === 'jpeg' ? 'jpg' : ext;
48
- }
49
- return 'jpg';
50
- }
51
-
52
- function outputExtension(format) {
53
- return format === 'jpeg' ? 'jpg' : format;
54
- }
55
-
56
- function normalizeWorkspacePath(value) {
57
- return String(value ?? '').split(path.sep).join('/');
58
- }
59
-
60
- function toWorkspacePath(projectRoot, filePath) {
61
- const absolutePath = path.resolve(filePath);
62
- const relativePath = path.relative(projectRoot, absolutePath);
63
- if (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
64
- return normalizeWorkspacePath(relativePath);
65
- }
66
- return absolutePath;
67
- }
68
-
69
- function resolveProjectPath(projectRoot, filePath) {
70
- return path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath);
71
- }
72
-
73
- function defaultOutputPath(projectRoot, format, mode) {
74
- const prefix = MODE_PREFIX[mode] ?? 'visual-compare';
75
- return path.join(
76
- projectRoot,
77
- '.openprd',
78
- 'harness',
79
- 'visual-reviews',
80
- `${prefix}-${compactTimestamp()}.${outputExtension(format)}`,
81
- );
82
- }
83
-
84
- function parsePositiveInteger(value, fallback, label) {
85
- if (value === null || value === undefined || value === '') {
86
- return fallback;
87
- }
88
- const parsed = Number.parseInt(String(value), 10);
89
- if (!Number.isFinite(parsed) || parsed <= 0) {
90
- throw new Error(`${label} must be a positive integer.`);
91
- }
92
- return parsed;
93
- }
94
-
95
- function parseQuality(value) {
96
- const quality = parsePositiveInteger(value, DEFAULT_QUALITY, '--quality');
97
- if (quality < 1 || quality > 100) {
98
- throw new Error('--quality must be between 1 and 100.');
99
- }
100
- return quality;
101
- }
102
-
103
- function metadataPathForOutput(outputPath) {
104
- const ext = path.extname(outputPath);
105
- return ext ? outputPath.slice(0, -ext.length) + '.json' : `${outputPath}.json`;
106
- }
107
-
108
- function escapeXml(value) {
109
- return String(value)
110
- .replaceAll('&', '&amp;')
111
- .replaceAll('<', '&lt;')
112
- .replaceAll('>', '&gt;')
113
- .replaceAll('"', '&quot;')
114
- .replaceAll("'", '&apos;');
115
- }
116
-
117
- function charCount(value) {
118
- return Array.from(String(value ?? '')).length;
119
- }
120
-
121
- function wrapText(value, maxCharsPerLine) {
122
- const lines = [];
123
- const normalized = String(value ?? '').trim();
124
- if (!normalized) {
125
- return lines;
126
- }
127
- for (const rawLine of normalized.split(/\r?\n/u)) {
128
- const line = rawLine.trim();
129
- if (!line) {
130
- continue;
131
- }
132
- let buffer = '';
133
- for (const char of Array.from(line)) {
134
- if (charCount(buffer) >= maxCharsPerLine) {
135
- lines.push(buffer);
136
- buffer = '';
137
- }
138
- buffer += char;
139
- }
140
- if (buffer) {
141
- lines.push(buffer);
142
- }
143
- }
144
- return lines;
145
- }
146
-
147
- function lineSvg(lines, {
148
- x = 0,
149
- y = 0,
150
- lineHeight = 24,
151
- fontSize = 18,
152
- fill = '#e5e7eb',
153
- fontWeight = 500,
154
- } = {}) {
155
- return lines.map((line, index) => (
156
- `<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>`
157
- )).join('');
158
- }
159
-
160
- function labelSvg(label, options = {}) {
161
- const text = escapeXml(label);
162
- const fontSize = options.fontSize ?? 22;
163
- const height = options.height ?? 46;
164
- const paddingX = options.paddingX ?? 21;
165
- const radius = options.radius ?? 14;
166
- const bg = options.background ?? '#111827';
167
- const bgOpacity = options.backgroundOpacity ?? 0.82;
168
- const stroke = options.stroke ?? '#ffffff';
169
- const strokeOpacity = options.strokeOpacity ?? 0.22;
170
- const width = Math.max(options.minWidth ?? 126, charCount(label) * (fontSize + 4) + paddingX * 2);
171
- return Buffer.from(`
172
- <svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
173
- <rect x="0" y="0" width="${width}" height="${height}" rx="${radius}" fill="${bg}" fill-opacity="${bgOpacity}"/>
174
- <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"/>
175
- <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>
176
- </svg>`);
177
- }
178
-
179
- function titleBlockSvg(width, title, subtitle, eyebrow = null) {
180
- const contentWidth = Math.max(Number(width) || 0, 1);
181
- const titleLines = wrapText(title, Math.max(12, Math.floor((contentWidth - 48) / 18)));
182
- const subtitleLines = wrapText(subtitle, Math.max(16, Math.floor((contentWidth - 48) / 16)));
183
- const eyebrowLines = wrapText(eyebrow, Math.max(18, Math.floor((contentWidth - 48) / 18)));
184
- let y = eyebrowLines.length > 0 ? 28 : 0;
185
- const parts = [];
186
- if (eyebrowLines.length > 0) {
187
- parts.push(lineSvg(eyebrowLines, {
188
- x: 0,
189
- y: y,
190
- lineHeight: 20,
191
- fontSize: 16,
192
- fill: '#93c5fd',
193
- fontWeight: 700,
194
- }));
195
- y += eyebrowLines.length * 20 + 14;
196
- }
197
- parts.push(lineSvg(titleLines, {
198
- x: 0,
199
- y: y + 30,
200
- lineHeight: 34,
201
- fontSize: 30,
202
- fill: '#f8fafc',
203
- fontWeight: 800,
204
- }));
205
- y += titleLines.length * 34 + 10;
206
- if (subtitleLines.length > 0) {
207
- parts.push(lineSvg(subtitleLines, {
208
- x: 0,
209
- y: y + 24,
210
- lineHeight: 24,
211
- fontSize: 18,
212
- fill: '#cbd5e1',
213
- fontWeight: 500,
214
- }));
215
- y += subtitleLines.length * 24 + 6;
216
- }
217
- const height = Math.max(72, y + 16);
218
- return {
219
- height,
220
- input: Buffer.from(`
221
- <svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
222
- ${parts.join('')}
223
- </svg>`),
224
- };
225
- }
226
-
227
- function sectionHeaderSvg(width, index, label, reason = '') {
228
- const contentWidth = Math.max(Number(width) || 0, 1);
229
- const titleLines = wrapText(`${index}. ${label}`, Math.max(10, Math.floor((contentWidth - 48) / 18)));
230
- const reasonLines = wrapText(reason, Math.max(14, Math.floor((contentWidth - 48) / 16)));
231
- const height = 32 + titleLines.length * 28 + (reasonLines.length > 0 ? 10 + reasonLines.length * 22 : 0);
232
- return {
233
- height,
234
- input: Buffer.from(`
235
- <svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
236
- <rect x="0" y="0" width="${contentWidth}" height="${height}" rx="18" fill="#0f172a" fill-opacity="0.92"/>
237
- <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"/>
238
- ${lineSvg(titleLines, {
239
- x: 20,
240
- y: 30,
241
- lineHeight: 28,
242
- fontSize: 24,
243
- fill: '#f8fafc',
244
- fontWeight: 800,
245
- })}
246
- ${reasonLines.length > 0 ? lineSvg(reasonLines, {
247
- x: 20,
248
- y: 30 + titleLines.length * 28 + 10,
249
- lineHeight: 22,
250
- fontSize: 16,
251
- fill: '#cbd5e1',
252
- fontWeight: 500,
253
- }) : ''}
254
- </svg>`),
255
- };
256
- }
257
-
258
- function metricsSvg(width, metrics = [], notes = null) {
259
- const contentWidth = Math.max(Number(width) || 0, 1);
260
- const lines = [];
261
- for (const metric of metrics) {
262
- lines.push(`${metric.label}:${metric.value}`);
263
- }
264
- if (notes) {
265
- lines.push(...wrapText(notes, Math.max(12, Math.floor((contentWidth - 24) / 16))));
266
- }
267
- if (lines.length === 0) {
268
- return { height: 0, input: null };
269
- }
270
- const height = 18 + lines.length * 22;
271
- return {
272
- height,
273
- input: Buffer.from(`
274
- <svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
275
- ${lineSvg(lines, {
276
- x: 0,
277
- y: 20,
278
- lineHeight: 22,
279
- fontSize: 16,
280
- fill: '#cbd5e1',
281
- fontWeight: 500,
282
- })}
283
- </svg>`),
284
- };
285
- }
286
-
287
- async function resizePanel(inputPath, panelWidth) {
288
- const source = path.resolve(inputPath);
289
- const metadata = await sharp(source).metadata();
290
- if (!metadata.width || !metadata.height) {
291
- throw new Error(`Cannot read image dimensions: ${inputPath}`);
292
- }
293
- const { data, info } = await sharp(source)
294
- .rotate()
295
- .resize({
296
- width: panelWidth,
297
- fit: 'inside',
298
- withoutEnlargement: true,
299
- })
300
- .png()
301
- .toBuffer({ resolveWithObject: true });
302
- return {
303
- input: data,
304
- width: info.width,
305
- height: info.height,
306
- source,
307
- original: {
308
- width: metadata.width,
309
- height: metadata.height,
310
- format: metadata.format ?? null,
311
- },
312
- };
313
- }
314
-
315
- async function extractCrop(inputPath, box, outputWidth) {
316
- const source = path.resolve(inputPath);
317
- const { data, info } = await sharp(source)
318
- .rotate()
319
- .extract({
320
- left: box.x,
321
- top: box.y,
322
- width: box.width,
323
- height: box.height,
324
- })
325
- .resize({
326
- width: outputWidth,
327
- fit: 'inside',
328
- withoutEnlargement: false,
329
- })
330
- .png()
331
- .toBuffer({ resolveWithObject: true });
332
- return {
333
- input: data,
334
- width: info.width,
335
- height: info.height,
336
- source,
337
- absolute: box,
338
- };
339
- }
340
-
341
- function encodePipeline(image, format, quality) {
342
- if (format === 'png') {
343
- return image.png();
344
- }
345
- if (format === 'webp') {
346
- return image.webp({ quality });
347
- }
348
- return image.jpeg({ quality });
349
- }
350
-
351
- function resolveComparisonInputs(options) {
352
- const hasReferenceActual = Boolean(options.reference || options.actual);
353
- const hasBeforeAfter = Boolean(options.before || options.after);
354
- const hasBoard = Boolean(options.board);
355
- const modeCount = [hasReferenceActual, hasBeforeAfter, hasBoard].filter(Boolean).length;
356
- if (modeCount > 1) {
357
- throw new Error('Use either --reference/--actual, --before/--after, or --board, not multiple modes together.');
358
- }
359
- if (hasBeforeAfter) {
360
- if (!options.before) {
361
- throw new Error('Missing --before image path.');
362
- }
363
- if (!options.after) {
364
- throw new Error('Missing --after image path.');
365
- }
366
- return {
367
- mode: 'before-after',
368
- left: options.before,
369
- right: options.after,
370
- leftLabel: options.referenceLabel || DEFAULT_BEFORE_LABEL,
371
- rightLabel: options.actualLabel || DEFAULT_AFTER_LABEL,
372
- nextActions: [
373
- '把输出图片作为视觉改动自检证据查看:左侧修改前,右侧修改后。',
374
- '检查预期变化是否出现,以及未改区域是否有布局、颜色、密度或状态漂移。',
375
- '没有效果图时,这张图只证明改动前后差异已自检;大界面方向性改造仍需先完成方案评审。',
376
- ],
377
- };
378
- }
379
- if (hasReferenceActual) {
380
- if (!options.reference) {
381
- throw new Error('Missing --reference image path.');
382
- }
383
- if (!options.actual) {
384
- throw new Error('Missing --actual image path.');
385
- }
386
- return {
387
- mode: 'reference-actual',
388
- left: options.reference,
389
- right: options.actual,
390
- leftLabel: options.referenceLabel || DEFAULT_REFERENCE_LABEL,
391
- rightLabel: options.actualLabel || DEFAULT_ACTUAL_LABEL,
392
- nextActions: [
393
- '把输出图片作为视觉评审证据查看:左侧效果图,右侧实现截图。',
394
- '如果仍有明显差异,继续按效果图复刻并重新运行 visual-compare。',
395
- '只有对比图确认一致后,才声明本阶段界面视觉实现完成。',
396
- ],
397
- };
398
- }
399
- if (hasBoard) {
400
- return {
401
- mode: 'board',
402
- board: options.board,
403
- };
404
- }
405
- throw new Error('Missing visual compare input. Use --reference/--actual, --before/--after, or --board.');
406
- }
407
-
408
- function normalizeBoardMode(value) {
409
- const key = String(value ?? '').trim().toLowerCase();
410
- return BOARD_MODE_ALIASES.get(key) ?? null;
411
- }
412
-
413
- async function readBoardSpec(projectRoot, boardPath) {
414
- const sourcePath = resolveProjectPath(projectRoot, boardPath);
415
- const payload = JSON.parse(await fs.readFile(sourcePath, 'utf8'));
416
- const mode = normalizeBoardMode(payload.mode);
417
- if (!mode) {
418
- throw new Error(`Unsupported board mode in ${boardPath}. Use focus-board or parallel-board.`);
419
- }
420
- return {
421
- mode,
422
- sourcePath,
423
- payload,
424
- };
425
- }
426
-
427
- function normalizeImageSpec(projectRoot, value, fallbackLabel) {
428
- if (!value) {
429
- throw new Error('Board image spec is missing.');
430
- }
431
- if (typeof value === 'string') {
432
- return {
433
- path: resolveProjectPath(projectRoot, value),
434
- label: fallbackLabel,
435
- };
436
- }
437
- if (typeof value === 'object' && value.path) {
438
- return {
439
- path: resolveProjectPath(projectRoot, value.path),
440
- label: value.label ? String(value.label) : fallbackLabel,
441
- };
442
- }
443
- throw new Error('Board image spec must be a path string or an object with { path, label? }.');
444
- }
445
-
446
- function normalizeMetricList(metrics) {
447
- if (!metrics) {
448
- return [];
449
- }
450
- if (Array.isArray(metrics)) {
451
- return metrics
452
- .filter(Boolean)
453
- .map((item) => {
454
- if (typeof item === 'string') {
455
- return { label: '说明', value: item };
456
- }
457
- return {
458
- label: String(item.label ?? item.key ?? '指标'),
459
- value: String(item.value ?? ''),
460
- };
461
- })
462
- .filter((item) => item.value.trim());
463
- }
464
- if (typeof metrics === 'object') {
465
- return Object.entries(metrics)
466
- .filter(([, value]) => value !== null && value !== undefined && String(value).trim())
467
- .map(([key, value]) => ({ label: key, value: String(value) }));
468
- }
469
- return [];
470
- }
471
-
472
- function normalizeMediaList(projectRoot, media) {
473
- if (!Array.isArray(media)) {
474
- return [];
475
- }
476
- return media
477
- .filter(Boolean)
478
- .map((entry, index) => {
479
- if (typeof entry === 'string') {
480
- return {
481
- path: resolveProjectPath(projectRoot, entry),
482
- label: `素材 ${index + 1}`,
483
- };
484
- }
485
- if (!entry.path) {
486
- throw new Error(`Parallel board media[${index}] is missing path.`);
487
- }
488
- return {
489
- path: resolveProjectPath(projectRoot, entry.path),
490
- label: String(entry.label ?? `素材 ${index + 1}`),
491
- };
492
- });
493
- }
494
-
495
- function parseCropDimension(box, key, alias) {
496
- const primary = box[key];
497
- const secondary = alias ? box[alias] : undefined;
498
- const value = primary ?? secondary;
499
- if (value === null || value === undefined || value === '') {
500
- throw new Error(`Missing ${key} in focus region box.`);
501
- }
502
- const parsed = Number(value);
503
- if (!Number.isFinite(parsed)) {
504
- throw new Error(`Invalid ${key} in focus region box.`);
505
- }
506
- return parsed;
507
- }
508
-
509
- function clampBox(box, original) {
510
- const x = Math.max(0, Math.min(box.x, original.width - 1));
511
- const y = Math.max(0, Math.min(box.y, original.height - 1));
512
- const width = Math.max(1, Math.min(box.width, original.width - x));
513
- const height = Math.max(1, Math.min(box.height, original.height - y));
514
- return {
515
- x,
516
- y,
517
- width,
518
- height,
519
- };
520
- }
521
-
522
- function resolveCropBox(box, original) {
523
- if (!box || typeof box !== 'object') {
524
- throw new Error('Focus region box must be an object.');
525
- }
526
- const rawUnit = String(box.unit ?? 'ratio').trim().toLowerCase();
527
- const x = parseCropDimension(box, 'x', 'left');
528
- const y = parseCropDimension(box, 'y', 'top');
529
- const width = parseCropDimension(box, 'width', 'w');
530
- const height = parseCropDimension(box, 'height', 'h');
531
- let absolute;
532
-
533
- if (rawUnit === 'ratio') {
534
- absolute = {
535
- x: Math.round(x * original.width),
536
- y: Math.round(y * original.height),
537
- width: Math.round(width * original.width),
538
- height: Math.round(height * original.height),
539
- };
540
- } else if (rawUnit === 'percent') {
541
- absolute = {
542
- x: Math.round((x / 100) * original.width),
543
- y: Math.round((y / 100) * original.height),
544
- width: Math.round((width / 100) * original.width),
545
- height: Math.round((height / 100) * original.height),
546
- };
547
- } else if (rawUnit === 'px' || rawUnit === 'pixel' || rawUnit === 'pixels') {
548
- absolute = {
549
- x: Math.round(x),
550
- y: Math.round(y),
551
- width: Math.round(width),
552
- height: Math.round(height),
553
- };
554
- } else {
555
- throw new Error(`Unsupported focus region unit: ${rawUnit}. Use ratio, percent, or px.`);
556
- }
557
-
558
- return {
559
- unit: rawUnit === 'pixel' || rawUnit === 'pixels' ? 'px' : rawUnit,
560
- requested: { x, y, width, height },
561
- absolute: clampBox(absolute, original),
562
- };
563
- }
564
-
565
- function renderedBoxFromAbsolute(box, panel) {
566
- const scaleX = panel.width / panel.original.width;
567
- const scaleY = panel.height / panel.original.height;
568
- return {
569
- x: Math.round(box.x * scaleX),
570
- y: Math.round(box.y * scaleY),
571
- width: Math.max(2, Math.round(box.width * scaleX)),
572
- height: Math.max(2, Math.round(box.height * scaleY)),
573
- };
574
- }
575
-
576
- function focusOverlaySvg(width, height, regions) {
577
- const overlays = regions.map((region, index) => {
578
- const color = region.color;
579
- const box = region.box;
580
- const badgeSize = 32;
581
- const badgeX = Math.max(10, box.x + 10);
582
- const badgeY = Math.max(10, box.y + 10);
583
- return `
584
- <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="16" fill="none" stroke="${color}" stroke-width="4"/>
585
- <rect x="${badgeX}" y="${badgeY}" width="${badgeSize}" height="${badgeSize}" rx="16" fill="${color}"/>
586
- <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>
587
- `;
588
- }).join('');
589
- return Buffer.from(`
590
- <svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
591
- ${overlays}
592
- </svg>`);
593
- }
6
+ import {
7
+ OPENPRD_FALLBACK_LOCALE,
8
+ detectPrimaryLanguage,
9
+ isChineseLocale,
10
+ isEnglishHeavyText,
11
+ normalizeOutputLocale,
12
+ } from './language-policy.js';
13
+ import {
14
+ ALIGNMENT_CONTAINER_TOKENS,
15
+ ALIGNMENT_CONTENT_SLOT_TOKENS,
16
+ DEFAULT_PANEL_WIDTH,
17
+ DEFAULT_PARALLEL_CARD_WIDTH,
18
+ DEFAULT_PARALLEL_COLUMNS,
19
+ FOCUS_COLORS,
20
+ defaultOutputPath,
21
+ encodePipeline,
22
+ escapeXml,
23
+ extractCrop,
24
+ focusOverlaySvg,
25
+ labelSvg,
26
+ localeFromOptions,
27
+ metadataPathForOutput,
28
+ normalizeFormat,
29
+ normalizeImageSpec,
30
+ normalizeMetricList,
31
+ normalizeVerificationItems,
32
+ parsePositiveInteger,
33
+ parseQuality,
34
+ readBoardSpec,
35
+ renderParallelCard,
36
+ renderedBoxFromAbsolute,
37
+ resizePanel,
38
+ resolveBoardLocale,
39
+ resolveComparisonInputs,
40
+ resolveCropBox,
41
+ sectionHeaderSvg,
42
+ titleBlockSvg,
43
+ toWorkspacePath,
44
+ validateBoardVisibleLanguage,
45
+ visualCopyForLocale,
46
+ } from './visual-compare-core.js';
47
+ import { renderCenteringBoard } from './visual-compare-centering.js';
594
48
 
595
49
  async function renderStandardComparison(projectRoot, comparison, options = {}) {
596
50
  const format = normalizeFormat(options.format, options.out);
@@ -626,8 +80,8 @@ async function renderStandardComparison(projectRoot, comparison, options = {}) {
626
80
  }).composite([
627
81
  { input: referencePanel.input, left: referenceLeft, top: referenceTop },
628
82
  { input: actualPanel.input, left: actualLeft, top: actualTop },
629
- { input: labelSvg(comparison.leftLabel), left: referenceLeft + 16, top: referenceTop + 16 },
630
- { input: labelSvg(comparison.rightLabel), left: actualLeft + 16, top: actualTop + 16 },
83
+ { input: labelSvg(comparison.leftLabel, { maxWidth: Math.max(32, referencePanel.width - 32) }), left: referenceLeft + 16, top: referenceTop + 16 },
84
+ { input: labelSvg(comparison.rightLabel, { maxWidth: Math.max(32, actualPanel.width - 32) }), left: actualLeft + 16, top: actualTop + 16 },
631
85
  ]);
632
86
 
633
87
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
@@ -638,6 +92,7 @@ async function renderStandardComparison(projectRoot, comparison, options = {}) {
638
92
  schema: VISUAL_REVIEW_SCHEMA,
639
93
  generatedAt: timestamp(),
640
94
  mode: comparison.mode,
95
+ locale: comparison.locale,
641
96
  outputPath: toWorkspacePath(projectRoot, outputPath),
642
97
  format,
643
98
  labels: {
@@ -707,14 +162,16 @@ async function renderStandardComparison(projectRoot, comparison, options = {}) {
707
162
  }
708
163
 
709
164
  async function renderFocusBoard(projectRoot, board, options = {}) {
165
+ const locale = localeFromOptions(options);
166
+ const copy = visualCopyForLocale(locale);
710
167
  const format = normalizeFormat(options.format, options.out);
711
168
  const outputPath = options.out
712
169
  ? path.resolve(projectRoot, options.out)
713
170
  : defaultOutputPath(projectRoot, format, 'focus-board');
714
171
  const quality = parseQuality(options.quality);
715
172
  const maxPanelWidth = parsePositiveInteger(options.maxPanelWidth, DEFAULT_PANEL_WIDTH, '--max-panel-width');
716
- const leftSpec = normalizeImageSpec(projectRoot, board.payload.left ?? board.payload.reference, board.payload.leftLabel ?? DEFAULT_REFERENCE_LABEL);
717
- const rightSpec = normalizeImageSpec(projectRoot, board.payload.right ?? board.payload.actual, board.payload.rightLabel ?? DEFAULT_ACTUAL_LABEL);
173
+ const leftSpec = normalizeImageSpec(projectRoot, board.payload.left ?? board.payload.reference, board.payload.leftLabel ?? copy.referenceLabel);
174
+ const rightSpec = normalizeImageSpec(projectRoot, board.payload.right ?? board.payload.actual, board.payload.rightLabel ?? copy.actualLabel);
718
175
  const focusRegions = Array.isArray(board.payload.focusRegions ?? board.payload.regions) ? (board.payload.focusRegions ?? board.payload.regions) : [];
719
176
  if (focusRegions.length === 0) {
720
177
  throw new Error('Focus board requires focusRegions with at least one region.');
@@ -730,9 +187,9 @@ async function renderFocusBoard(projectRoot, board, options = {}) {
730
187
  const contentWidth = margin * 2 + panelWidth * 2 + gap;
731
188
  const titleBlock = titleBlockSvg(
732
189
  contentWidth - margin * 2,
733
- String(board.payload.title ?? DEFAULT_FOCUS_TITLE),
734
- String(board.payload.summary ?? '先看整体标框,再看编号对应的局部放大;局部差异优先在这里复核。'),
735
- '视觉验收 / 局部焦点',
190
+ String(board.payload.title ?? copy.focusTitle),
191
+ String(board.payload.summary ?? copy.focusSummary),
192
+ copy.focusEyebrow,
736
193
  );
737
194
 
738
195
  const leftOverviewX = margin + Math.round((panelWidth - leftPanel.width) / 2);
@@ -745,8 +202,8 @@ async function renderFocusBoard(projectRoot, board, options = {}) {
745
202
  { input: titleBlock.input, left: margin, top: margin },
746
203
  { input: leftPanel.input, left: leftOverviewX, top: leftOverviewY },
747
204
  { input: rightPanel.input, left: rightOverviewX, top: rightOverviewY },
748
- { input: labelSvg(leftSpec.label), left: leftOverviewX + 16, top: leftOverviewY + 16 },
749
- { input: labelSvg(rightSpec.label), left: rightOverviewX + 16, top: rightOverviewY + 16 },
205
+ { input: labelSvg(leftSpec.label, { maxWidth: Math.max(32, leftPanel.width - 32) }), left: leftOverviewX + 16, top: leftOverviewY + 16 },
206
+ { input: labelSvg(rightSpec.label, { maxWidth: Math.max(32, rightPanel.width - 32) }), left: rightOverviewX + 16, top: rightOverviewY + 16 },
750
207
  ];
751
208
 
752
209
  const metadataRegions = [];
@@ -755,7 +212,7 @@ async function renderFocusBoard(projectRoot, board, options = {}) {
755
212
  let currentTop = overviewTop + overviewHeight + sectionGap;
756
213
 
757
214
  for (const [index, region] of focusRegions.entries()) {
758
- const label = String(region.label ?? region.name ?? `焦点 ${index + 1}`);
215
+ const label = String(region.label ?? region.name ?? copy.focusRegionLabel(index));
759
216
  const reason = String(region.reason ?? region.note ?? '').trim();
760
217
  const color = FOCUS_COLORS[index % FOCUS_COLORS.length];
761
218
  const leftResolved = resolveCropBox(region.leftBox ?? region.referenceBox ?? region.box, leftPanel.original);
@@ -776,8 +233,8 @@ async function renderFocusBoard(projectRoot, board, options = {}) {
776
233
  { input: header.input, left: margin, top: currentTop },
777
234
  { input: leftCrop.input, left: leftCropX, top: leftCropY },
778
235
  { input: rightCrop.input, left: rightCropX, top: rightCropY },
779
- { input: labelSvg(leftSpec.label, { fontSize: 18, height: 40, minWidth: 112 }), left: leftCropX + 12, top: leftCropY + 12 },
780
- { input: labelSvg(rightSpec.label, { fontSize: 18, height: 40, minWidth: 112 }), left: rightCropX + 12, top: rightCropY + 12 },
236
+ { input: labelSvg(leftSpec.label, { fontSize: 18, height: 40, minWidth: 112, maxWidth: Math.max(32, leftCrop.width - 24) }), left: leftCropX + 12, top: leftCropY + 12 },
237
+ { input: labelSvg(rightSpec.label, { fontSize: 18, height: 40, minWidth: 112, maxWidth: Math.max(32, rightCrop.width - 24) }), left: rightCropX + 12, top: rightCropY + 12 },
781
238
  );
782
239
  leftOverlayRegions.push({ color, box: leftRenderedBox });
783
240
  rightOverlayRegions.push({ color, box: rightRenderedBox });
@@ -833,10 +290,11 @@ async function renderFocusBoard(projectRoot, board, options = {}) {
833
290
  schema: VISUAL_REVIEW_SCHEMA,
834
291
  generatedAt: timestamp(),
835
292
  mode: 'focus-board',
293
+ locale,
836
294
  outputPath: toWorkspacePath(projectRoot, outputPath),
837
295
  format,
838
296
  boardSource: toWorkspacePath(projectRoot, board.sourcePath),
839
- title: String(board.payload.title ?? DEFAULT_FOCUS_TITLE),
297
+ title: String(board.payload.title ?? copy.focusTitle),
840
298
  labels: {
841
299
  reference: leftSpec.label,
842
300
  actual: rightSpec.label,
@@ -881,126 +339,143 @@ async function renderFocusBoard(projectRoot, board, options = {}) {
881
339
  focusRegions: metadataRegions,
882
340
  canvas: reviewArtifact.canvas,
883
341
  reviewArtifact,
884
- nextActions: [
885
- '先看顶部编号框,再对照下面同编号的局部放大图复核差异。',
886
- '若局部区域仍有问题,优先围绕该编号返工,并重新生成焦点证据板。',
887
- '局部证据板适合补充整体对比,不建议只看整体图就结束界面验收。',
888
- ],
342
+ nextActions: copy.focusNextActions,
889
343
  };
890
344
  }
891
345
 
892
- async function renderParallelCard(projectRoot, item, index, options = {}) {
893
- const cardWidth = options.cardWidth;
894
- const contentWidth = cardWidth - 36;
895
- const title = String(item.label ?? item.title ?? `方案 ${index + 1}`);
896
- const subtitle = String(item.subtitle ?? item.summary ?? '').trim();
897
- const verdict = String(item.verdict ?? '').trim();
898
- const mediaList = normalizeMediaList(projectRoot, item.media);
899
- const metrics = normalizeMetricList(item.metrics ?? item.metricMap);
900
- const notes = String(item.notes ?? item.note ?? '').trim();
901
- const header = titleBlockSvg(
902
- contentWidth,
903
- `${index + 1}. ${title}`,
904
- subtitle,
905
- verdict ? `结论:${verdict}` : '并行实验',
346
+ async function renderParallelBoard(projectRoot, board, options = {}) {
347
+ const locale = localeFromOptions(options);
348
+ const copy = visualCopyForLocale(locale);
349
+ const format = normalizeFormat(options.format, options.out);
350
+ const outputPath = options.out
351
+ ? path.resolve(projectRoot, options.out)
352
+ : defaultOutputPath(projectRoot, format, 'parallel-board');
353
+ const quality = parseQuality(options.quality);
354
+ const items = Array.isArray(board.payload.items) ? board.payload.items : [];
355
+ if (items.length === 0) {
356
+ throw new Error('Parallel board requires items with at least one experiment.');
357
+ }
358
+ const cardWidth = parsePositiveInteger(board.payload.cardWidth ?? options.maxPanelWidth, DEFAULT_PARALLEL_CARD_WIDTH, 'cardWidth');
359
+ const columns = Math.max(1, Math.min(parsePositiveInteger(board.payload.columns, Math.min(DEFAULT_PARALLEL_COLUMNS, items.length), 'columns'), 4));
360
+ const margin = 24;
361
+ const gap = 24;
362
+ const titleBlock = titleBlockSvg(
363
+ columns * cardWidth + (columns - 1) * gap,
364
+ String(board.payload.title ?? copy.parallelTitle),
365
+ String(board.payload.summary ?? copy.parallelSummary),
366
+ copy.parallelEyebrow,
906
367
  );
907
- const composites = [
908
- { input: header.input, left: 18, top: 18 },
909
- ];
910
- const renderedMedia = [];
911
- let currentTop = 18 + header.height + 12;
912
368
 
913
- for (const media of mediaList) {
914
- const label = labelSvg(media.label, {
915
- fontSize: 16,
916
- height: 36,
917
- minWidth: 96,
918
- paddingX: 16,
919
- radius: 12,
920
- });
921
- const mediaPanel = await resizePanel(media.path, contentWidth);
922
- composites.push(
923
- { input: label, left: 18, top: currentTop },
924
- { input: mediaPanel.input, left: 18 + Math.round((contentWidth - mediaPanel.width) / 2), top: currentTop + 42 },
925
- );
926
- renderedMedia.push({
927
- path: toWorkspacePath(projectRoot, media.path),
928
- label: media.label,
929
- original: mediaPanel.original,
930
- rendered: {
931
- width: mediaPanel.width,
932
- height: mediaPanel.height,
933
- },
934
- });
935
- currentTop += 42 + mediaPanel.height + 16;
369
+ const renderedCards = [];
370
+ for (const [index, item] of items.entries()) {
371
+ renderedCards.push(await renderParallelCard(projectRoot, item, index, { cardWidth, locale }));
936
372
  }
937
373
 
938
- const metricsBlock = metricsSvg(contentWidth, metrics, notes);
939
- if (metricsBlock.input) {
940
- composites.push({ input: metricsBlock.input, left: 18, top: currentTop });
941
- currentTop += metricsBlock.height + 12;
374
+ const rowHeights = [];
375
+ for (let index = 0; index < renderedCards.length; index += columns) {
376
+ rowHeights.push(Math.max(...renderedCards.slice(index, index + columns).map((card) => card.height)));
377
+ }
378
+ const canvasWidth = margin * 2 + columns * cardWidth + (columns - 1) * gap;
379
+ const canvasHeight = margin * 2 + titleBlock.height + 18 + rowHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, rowHeights.length - 1) * gap;
380
+ const composites = [
381
+ { input: titleBlock.input, left: margin, top: margin },
382
+ ];
383
+ let currentTop = margin + titleBlock.height + 18;
384
+ for (let row = 0; row < rowHeights.length; row += 1) {
385
+ const rowCards = renderedCards.slice(row * columns, row * columns + columns);
386
+ for (const [column, card] of rowCards.entries()) {
387
+ composites.push({
388
+ input: card.image,
389
+ left: margin + column * (cardWidth + gap),
390
+ top: currentTop,
391
+ });
392
+ }
393
+ currentTop += rowHeights[row] + gap;
942
394
  }
943
395
 
944
- const cardHeight = currentTop + 18;
945
- const card = sharp({
396
+ const canvas = sharp({
946
397
  create: {
947
- width: cardWidth,
948
- height: cardHeight,
398
+ width: canvasWidth,
399
+ height: canvasHeight,
949
400
  channels: 3,
950
- background: '#0f172a',
401
+ background: '#111827',
951
402
  },
952
- }).composite([
953
- ...composites,
954
- {
955
- input: Buffer.from(`
956
- <svg width="${cardWidth}" height="${cardHeight}" viewBox="0 0 ${cardWidth} ${cardHeight}" xmlns="http://www.w3.org/2000/svg">
957
- <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"/>
958
- </svg>`),
959
- left: 0,
960
- top: 0,
403
+ }).composite(composites);
404
+
405
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
406
+ await encodePipeline(canvas, format, quality).toFile(outputPath);
407
+ const metadataPath = metadataPathForOutput(outputPath);
408
+ const reviewArtifact = {
409
+ version: 1,
410
+ schema: VISUAL_REVIEW_SCHEMA,
411
+ generatedAt: timestamp(),
412
+ mode: 'parallel-board',
413
+ locale,
414
+ outputPath: toWorkspacePath(projectRoot, outputPath),
415
+ format,
416
+ boardSource: toWorkspacePath(projectRoot, board.sourcePath),
417
+ title: String(board.payload.title ?? copy.parallelTitle),
418
+ layout: {
419
+ columns,
420
+ cardWidth,
421
+ rowCount: rowHeights.length,
961
422
  },
962
- ]);
423
+ items: renderedCards.map((card) => card.item),
424
+ canvas: {
425
+ width: canvasWidth,
426
+ height: canvasHeight,
427
+ },
428
+ };
429
+ await fs.writeFile(metadataPath, `${JSON.stringify(reviewArtifact, null, 2)}\n`, 'utf8');
963
430
 
964
431
  return {
965
- image: await card.png().toBuffer(),
966
- width: cardWidth,
967
- height: cardHeight,
968
- item: {
969
- index: index + 1,
970
- label: title,
971
- subtitle,
972
- verdict,
973
- metrics,
974
- notes,
975
- media: renderedMedia,
976
- },
432
+ ok: true,
433
+ action: 'visual-compare',
434
+ mode: 'parallel-board',
435
+ projectRoot,
436
+ outputPath,
437
+ metadataPath,
438
+ format,
439
+ quality: format === 'png' ? null : quality,
440
+ canvas: reviewArtifact.canvas,
441
+ reviewArtifact,
442
+ items: reviewArtifact.items,
443
+ nextActions: copy.parallelNextActions,
977
444
  };
978
445
  }
979
446
 
980
- async function renderParallelBoard(projectRoot, board, options = {}) {
447
+ async function renderVerificationBoard(projectRoot, board, options = {}) {
448
+ const locale = localeFromOptions(options);
449
+ const copy = visualCopyForLocale(locale);
981
450
  const format = normalizeFormat(options.format, options.out);
982
451
  const outputPath = options.out
983
452
  ? path.resolve(projectRoot, options.out)
984
- : defaultOutputPath(projectRoot, format, 'parallel-board');
453
+ : defaultOutputPath(projectRoot, format, 'verification-board');
985
454
  const quality = parseQuality(options.quality);
986
- const items = Array.isArray(board.payload.items) ? board.payload.items : [];
455
+ const items = normalizeVerificationItems(projectRoot, board.payload, locale);
987
456
  if (items.length === 0) {
988
- throw new Error('Parallel board requires items with at least one experiment.');
457
+ throw new Error('Verification board requires screenshots, items, or a single screenshot/path entry.');
989
458
  }
990
459
  const cardWidth = parsePositiveInteger(board.payload.cardWidth ?? options.maxPanelWidth, DEFAULT_PARALLEL_CARD_WIDTH, 'cardWidth');
991
460
  const columns = Math.max(1, Math.min(parsePositiveInteger(board.payload.columns, Math.min(DEFAULT_PARALLEL_COLUMNS, items.length), 'columns'), 4));
992
461
  const margin = 24;
993
462
  const gap = 24;
463
+ const route = String(board.payload.route ?? board.payload.flow ?? board.payload.method ?? '').trim();
464
+ const summary = String(board.payload.summary ?? copy.verificationSummary(route));
994
465
  const titleBlock = titleBlockSvg(
995
466
  columns * cardWidth + (columns - 1) * gap,
996
- String(board.payload.title ?? DEFAULT_PARALLEL_TITLE),
997
- String(board.payload.summary ?? '把多方向产物、局部截图、GIF 首帧和指标放到一板里,方便统一审查。'),
998
- '视觉验收 / 并行实验',
467
+ String(board.payload.title ?? copy.verificationTitle),
468
+ summary,
469
+ copy.verificationEyebrow,
999
470
  );
1000
471
 
1001
472
  const renderedCards = [];
1002
473
  for (const [index, item] of items.entries()) {
1003
- renderedCards.push(await renderParallelCard(projectRoot, item, index, { cardWidth }));
474
+ renderedCards.push(await renderParallelCard(projectRoot, item, index, {
475
+ cardWidth,
476
+ eyebrow: copy.verificationCardEyebrow,
477
+ locale,
478
+ }));
1004
479
  }
1005
480
 
1006
481
  const rowHeights = [];
@@ -1041,11 +516,13 @@ async function renderParallelBoard(projectRoot, board, options = {}) {
1041
516
  version: 1,
1042
517
  schema: VISUAL_REVIEW_SCHEMA,
1043
518
  generatedAt: timestamp(),
1044
- mode: 'parallel-board',
519
+ mode: 'verification-board',
520
+ locale,
1045
521
  outputPath: toWorkspacePath(projectRoot, outputPath),
1046
522
  format,
1047
523
  boardSource: toWorkspacePath(projectRoot, board.sourcePath),
1048
- title: String(board.payload.title ?? DEFAULT_PARALLEL_TITLE),
524
+ title: String(board.payload.title ?? copy.verificationTitle),
525
+ route: route || null,
1049
526
  layout: {
1050
527
  columns,
1051
528
  cardWidth,
@@ -1062,7 +539,7 @@ async function renderParallelBoard(projectRoot, board, options = {}) {
1062
539
  return {
1063
540
  ok: true,
1064
541
  action: 'visual-compare',
1065
- mode: 'parallel-board',
542
+ mode: 'verification-board',
1066
543
  projectRoot,
1067
544
  outputPath,
1068
545
  metadataPath,
@@ -1071,27 +548,472 @@ async function renderParallelBoard(projectRoot, board, options = {}) {
1071
548
  canvas: reviewArtifact.canvas,
1072
549
  reviewArtifact,
1073
550
  items: reviewArtifact.items,
1074
- nextActions: [
1075
- '横向比较每个实验卡片,再结合指标和结论判断是否要保留多条方向继续迭代。',
1076
- '如果某个方向需要局部细看,再补一张局部焦点证据板,不要只盯整体缩略图。',
1077
- '并行实验证据板适合在 Agent 自验收和用户评审时一起使用,减少来回口头解释。',
1078
- ],
551
+ nextActions: copy.verificationNextActions,
1079
552
  };
1080
553
  }
1081
554
 
555
+ function normalizeAlignmentGroups(payload, locale = OPENPRD_FALLBACK_LOCALE) {
556
+ const copy = visualCopyForLocale(locale);
557
+ const groupEntries = normalizeAlignmentGroupEntries(payload);
558
+ const defaultThreshold = payload.thresholdPx ?? payload.threshold ?? 2;
559
+ return groupEntries.map(({ group, fallbackLevel }, index) => {
560
+ const threshold = group.thresholdPx ?? group.threshold ?? defaultThreshold;
561
+ const metrics = normalizeAlignmentMetrics(group.metrics ?? group.measurements ?? group.checks, threshold, locale);
562
+ const expected = String(group.expected ?? group.expectation ?? '').trim();
563
+ const pattern = String(group.pattern ?? group.structure ?? '').trim();
564
+ const level = normalizeAlignmentLevel(group.level ?? group.kind ?? group.type ?? group.scopeKind)
565
+ ?? fallbackLevel
566
+ ?? inferAlignmentLevel([group.label, group.title, group.subtitle, group.scope, pattern, expected, ...metrics.map((metric) => metric.label)].join(' '));
567
+ const levelLabel = alignmentLevelLabel(level, locale);
568
+ const notes = [
569
+ expected ? `${copy.alignmentExpectedPrefix}${copy.separator}${expected}` : '',
570
+ pattern ? `${copy.alignmentStructurePrefix}${copy.separator}${pattern}` : '',
571
+ String(group.notes ?? group.note ?? '').trim(),
572
+ ].filter(Boolean).join('\n');
573
+ const subtitle = [
574
+ levelLabel,
575
+ String(group.subtitle ?? group.scope ?? group.selector ?? '').trim(),
576
+ ].filter(Boolean).join(' / ');
577
+ return {
578
+ label: String(group.label ?? group.title ?? copy.alignmentGroupLabel(index)),
579
+ subtitle,
580
+ level,
581
+ levelLabel,
582
+ verdict: String(group.verdict ?? group.status ?? '').trim(),
583
+ metrics,
584
+ notes,
585
+ };
586
+ });
587
+ }
588
+
589
+ function normalizeAlignmentGroupEntries(payload) {
590
+ const entries = [];
591
+ const add = (groups, fallbackLevel) => {
592
+ if (!Array.isArray(groups)) {
593
+ return;
594
+ }
595
+ for (const group of groups) {
596
+ if (group) {
597
+ entries.push({ group, fallbackLevel });
598
+ }
599
+ }
600
+ };
601
+ if (Array.isArray(payload.groups)) {
602
+ add(payload.groups, null);
603
+ } else {
604
+ add(payload.items, null);
605
+ }
606
+ add(payload.containerGroups ?? payload.containerTracks, 'container');
607
+ add(payload.contentSlots ?? payload.slotGroups ?? payload.contentSlotGroups ?? payload.internalSlots, 'content-slot');
608
+ return entries;
609
+ }
610
+
611
+ function normalizeAlignmentLevel(value) {
612
+ const normalized = String(value ?? '').trim().toLowerCase();
613
+ if (!normalized) {
614
+ return null;
615
+ }
616
+ if (['container', 'outer', 'outer-frame', 'frame', 'grid', '容器', '外框', '网格'].includes(normalized)) {
617
+ return 'container';
618
+ }
619
+ if (['content', 'content-slot', 'internal', 'internal-slot', 'slot', 'same-slot', '内容', '内容槽位', '内部槽位', '槽位'].includes(normalized)) {
620
+ return 'content-slot';
621
+ }
622
+ return null;
623
+ }
624
+
625
+ function alignmentLevelLabel(level, locale = OPENPRD_FALLBACK_LOCALE) {
626
+ const copy = visualCopyForLocale(locale);
627
+ if (level === 'container') {
628
+ return copy.alignmentContainerLevel;
629
+ }
630
+ if (level === 'content-slot') {
631
+ return copy.alignmentContentSlotLevel;
632
+ }
633
+ return '';
634
+ }
635
+
636
+ function inferAlignmentLevel(text) {
637
+ const normalized = String(text ?? '').toLowerCase();
638
+ if (ALIGNMENT_CONTENT_SLOT_TOKENS.some((token) => normalized.includes(String(token).toLowerCase()))) {
639
+ return 'content-slot';
640
+ }
641
+ if (ALIGNMENT_CONTAINER_TOKENS.some((token) => normalized.includes(String(token).toLowerCase()))) {
642
+ return 'container';
643
+ }
644
+ return 'custom';
645
+ }
646
+
647
+ function summarizeAlignmentScope(groups) {
648
+ return {
649
+ containerGroups: groups.filter((group) => group.level === 'container').map((group) => group.label),
650
+ contentSlotGroups: groups.filter((group) => group.level === 'content-slot').map((group) => group.label),
651
+ };
652
+ }
653
+
654
+ function normalizeAlignmentMetrics(metrics, threshold, locale = OPENPRD_FALLBACK_LOCALE) {
655
+ const copy = visualCopyForLocale(locale);
656
+ if (!Array.isArray(metrics)) {
657
+ return normalizeMetricList(metrics, locale);
658
+ }
659
+ return metrics
660
+ .filter(Boolean)
661
+ .flatMap((metric) => {
662
+ if (typeof metric === 'string') {
663
+ return [{ label: copy.metricTextLabel, value: metric }];
664
+ }
665
+ const label = String(metric.label ?? metric.name ?? metric.key ?? copy.alignmentItemLabel);
666
+ const values = Array.isArray(metric.values)
667
+ ? metric.values.map((value) => Number(value)).filter((value) => Number.isFinite(value))
668
+ : [];
669
+ const metricThreshold = metric.thresholdPx ?? metric.threshold ?? threshold;
670
+ const spread = metric.spreadPx ?? metric.spread ?? (
671
+ values.length > 0 ? Math.max(...values) - Math.min(...values) : null
672
+ );
673
+ const parts = [];
674
+ if (values.length > 0) {
675
+ parts.push(`${values.join(', ')}px`);
676
+ } else if (metric.value !== null && metric.value !== undefined) {
677
+ parts.push(String(metric.value));
678
+ }
679
+ if (spread !== null && spread !== undefined && spread !== '') {
680
+ const verdict = metric.verdict ?? metric.status ?? (
681
+ Number(spread) <= Number(metricThreshold) ? copy.passVerdict : copy.reworkVerdict
682
+ );
683
+ parts.push(`spread=${spread}px`);
684
+ if (metricThreshold !== null && metricThreshold !== undefined && metricThreshold !== '') {
685
+ parts.push(`${copy.alignmentThresholdLabel}=${metricThreshold}px`);
686
+ }
687
+ parts.push(String(verdict));
688
+ }
689
+ return [{
690
+ label,
691
+ value: parts.filter(Boolean).join(' / '),
692
+ }];
693
+ })
694
+ .filter((metric) => metric.value.trim());
695
+ }
696
+
697
+ function normalizeAlignmentGuides(payload, locale = OPENPRD_FALLBACK_LOCALE) {
698
+ const copy = visualCopyForLocale(locale);
699
+ const rawGuides = Array.isArray(payload.guides)
700
+ ? payload.guides
701
+ : (Array.isArray(payload.lines) ? payload.lines : []);
702
+ return rawGuides
703
+ .filter(Boolean)
704
+ .map((guide, index) => ({
705
+ ...guide,
706
+ label: String(guide.label ?? guide.name ?? copy.alignmentGuideLabel(index)),
707
+ color: String(guide.color ?? FOCUS_COLORS[index % FOCUS_COLORS.length]),
708
+ }));
709
+ }
710
+
711
+ function normalizeAlignmentBoxes(payload, locale = OPENPRD_FALLBACK_LOCALE) {
712
+ const copy = visualCopyForLocale(locale);
713
+ const rawBoxes = Array.isArray(payload.boxes)
714
+ ? payload.boxes
715
+ : (Array.isArray(payload.regions) ? payload.regions : []);
716
+ return rawBoxes
717
+ .filter(Boolean)
718
+ .map((box, index) => ({
719
+ ...box,
720
+ label: String(box.label ?? box.name ?? copy.alignmentRegionLabel(index)),
721
+ color: String(box.color ?? FOCUS_COLORS[index % FOCUS_COLORS.length]),
722
+ }));
723
+ }
724
+
725
+ function resolveGuideAxis(guide) {
726
+ const raw = String(guide.axis ?? guide.orientation ?? '').trim().toLowerCase();
727
+ if (['x', 'vertical', 'v', 'left', 'right', 'column'].includes(raw)) {
728
+ return 'x';
729
+ }
730
+ if (['y', 'horizontal', 'h', 'top', 'bottom', 'row', 'baseline'].includes(raw)) {
731
+ return 'y';
732
+ }
733
+ if (guide.x !== null && guide.x !== undefined) {
734
+ return 'x';
735
+ }
736
+ return 'y';
737
+ }
738
+
739
+ function resolveGuidePosition(guide, axis, original) {
740
+ const raw = guide.position ?? guide.value ?? (axis === 'x' ? (guide.x ?? guide.left) : (guide.y ?? guide.top));
741
+ if (raw === null || raw === undefined || raw === '') {
742
+ throw new Error(`Alignment guide "${guide.label}" is missing position.`);
743
+ }
744
+ const value = Number(raw);
745
+ if (!Number.isFinite(value)) {
746
+ throw new Error(`Alignment guide "${guide.label}" has invalid position.`);
747
+ }
748
+ const max = axis === 'x' ? original.width : original.height;
749
+ const unit = String(guide.unit ?? 'px').trim().toLowerCase();
750
+ if (unit === 'ratio') {
751
+ return Math.round(value * max);
752
+ }
753
+ if (unit === 'percent') {
754
+ return Math.round((value / 100) * max);
755
+ }
756
+ return Math.round(value);
757
+ }
758
+
759
+ function alignmentGuideOverlaySvg(panel, payload, locale = OPENPRD_FALLBACK_LOCALE) {
760
+ const guides = normalizeAlignmentGuides(payload, locale);
761
+ const boxes = normalizeAlignmentBoxes(payload, locale);
762
+ const guideSvg = guides.map((guide, index) => {
763
+ const axis = resolveGuideAxis(guide);
764
+ const absolute = resolveGuidePosition(guide, axis, panel.original);
765
+ const color = escapeXml(guide.color);
766
+ const label = escapeXml(guide.label);
767
+ if (axis === 'x') {
768
+ const x = Math.round(absolute * (panel.width / panel.original.width));
769
+ const labelY = 28 + (index % 5) * 28;
770
+ return `
771
+ <line x1="${x}" y1="0" x2="${x}" y2="${panel.height}" stroke="${color}" stroke-width="2.5" stroke-dasharray="10 8"/>
772
+ <rect x="${Math.min(Math.max(x + 8, 8), Math.max(8, panel.width - 160))}" y="${labelY - 20}" width="148" height="24" rx="12" fill="#0f172a" fill-opacity="0.82"/>
773
+ <text x="${Math.min(Math.max(x + 18, 18), Math.max(18, panel.width - 150))}" y="${labelY - 4}" fill="${color}" font-size="14" font-weight="800" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${label}: x=${absolute}</text>
774
+ `;
775
+ }
776
+ const y = Math.round(absolute * (panel.height / panel.original.height));
777
+ const labelX = 14 + (index % 3) * 164;
778
+ return `
779
+ <line x1="0" y1="${y}" x2="${panel.width}" y2="${y}" stroke="${color}" stroke-width="2.5" stroke-dasharray="10 8"/>
780
+ <rect x="${labelX}" y="${Math.max(8, y - 30)}" width="154" height="24" rx="12" fill="#0f172a" fill-opacity="0.82"/>
781
+ <text x="${labelX + 10}" y="${Math.max(24, y - 14)}" fill="${color}" font-size="14" font-weight="800" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${label}: y=${absolute}</text>
782
+ `;
783
+ }).join('');
784
+ const boxSvg = boxes.map((box, index) => {
785
+ const resolved = resolveCropBox(box.box ?? box, panel.original);
786
+ const rendered = renderedBoxFromAbsolute(resolved.absolute, panel);
787
+ const color = escapeXml(box.color);
788
+ const label = escapeXml(box.label);
789
+ const badgeY = Math.max(10, rendered.y + 10);
790
+ const badgeX = Math.max(10, rendered.x + 10);
791
+ return `
792
+ <rect x="${rendered.x}" y="${rendered.y}" width="${rendered.width}" height="${rendered.height}" rx="12" fill="none" stroke="${color}" stroke-width="3"/>
793
+ <rect x="${badgeX}" y="${badgeY}" width="${Math.max(34, 24 + label.length * 14)}" height="28" rx="14" fill="${color}" fill-opacity="0.92"/>
794
+ <text x="${badgeX + 10}" y="${badgeY + 19}" fill="#0f172a" font-size="14" font-weight="800" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${index + 1}. ${label}</text>
795
+ `;
796
+ }).join('');
797
+ return Buffer.from(`
798
+ <svg width="${panel.width}" height="${panel.height}" viewBox="0 0 ${panel.width} ${panel.height}" xmlns="http://www.w3.org/2000/svg">
799
+ <rect x="0" y="0" width="${panel.width}" height="${panel.height}" fill="#020617" fill-opacity="0.08"/>
800
+ ${guideSvg}
801
+ ${boxSvg}
802
+ </svg>`);
803
+ }
804
+
805
+ async function createAlignmentOverlayPanel(basePanel, payload, locale = OPENPRD_FALLBACK_LOCALE) {
806
+ const overlay = alignmentGuideOverlaySvg(basePanel, payload, locale);
807
+ const input = await sharp(basePanel.input)
808
+ .composite([{ input: overlay, left: 0, top: 0 }])
809
+ .png()
810
+ .toBuffer();
811
+ return {
812
+ ...basePanel,
813
+ input,
814
+ };
815
+ }
816
+
817
+ async function renderAlignmentBoard(projectRoot, board, options = {}) {
818
+ const locale = localeFromOptions(options);
819
+ const copy = visualCopyForLocale(locale);
820
+ const format = normalizeFormat(options.format, options.out);
821
+ const outputPath = options.out
822
+ ? path.resolve(projectRoot, options.out)
823
+ : defaultOutputPath(projectRoot, format, 'alignment-board');
824
+ const quality = parseQuality(options.quality);
825
+ const maxPanelWidth = parsePositiveInteger(options.maxPanelWidth, DEFAULT_PANEL_WIDTH, '--max-panel-width');
826
+ const rawScreenshot = board.payload.screenshot ?? board.payload.actual ?? board.payload.path;
827
+ if (!rawScreenshot && !board.payload.overlay) {
828
+ throw new Error('Alignment board requires screenshot, actual, path, or overlay image.');
829
+ }
830
+ const screenshotSpec = normalizeImageSpec(
831
+ projectRoot,
832
+ rawScreenshot ?? board.payload.overlay,
833
+ board.payload.screenshotLabel ?? copy.alignmentScreenshotLabel,
834
+ );
835
+ const overlaySpec = board.payload.overlay
836
+ ? normalizeImageSpec(projectRoot, board.payload.overlay, board.payload.overlayLabel ?? copy.alignmentOverlayLabel)
837
+ : null;
838
+ const screenshotPanel = await resizePanel(screenshotSpec.path, maxPanelWidth);
839
+ const overlayPanel = overlaySpec
840
+ ? await resizePanel(overlaySpec.path, maxPanelWidth)
841
+ : await createAlignmentOverlayPanel(screenshotPanel, board.payload, locale);
842
+ const panelWidth = Math.min(maxPanelWidth, Math.max(screenshotPanel.width, overlayPanel.width));
843
+ const overviewHeight = Math.max(screenshotPanel.height, overlayPanel.height);
844
+ const margin = 24;
845
+ const gap = 24;
846
+ const groups = normalizeAlignmentGroups(board.payload, locale);
847
+ const cardWidth = parsePositiveInteger(board.payload.cardWidth ?? options.maxPanelWidth, DEFAULT_PARALLEL_CARD_WIDTH, 'cardWidth');
848
+ const columns = Math.max(1, Math.min(parsePositiveInteger(board.payload.columns, Math.min(DEFAULT_PARALLEL_COLUMNS, Math.max(groups.length, 1)), 'columns'), 4));
849
+ const contentWidth = Math.max(margin * 2 + panelWidth * 2 + gap, margin * 2 + columns * cardWidth + (columns - 1) * gap);
850
+ const summary = String(board.payload.summary ?? copy.alignmentSummary);
851
+ const titleBlock = titleBlockSvg(
852
+ contentWidth - margin * 2,
853
+ String(board.payload.title ?? copy.alignmentTitle),
854
+ summary,
855
+ copy.alignmentEyebrow,
856
+ );
857
+ const screenshotX = margin + Math.round((panelWidth - screenshotPanel.width) / 2);
858
+ const overlayX = margin + panelWidth + gap + Math.round((panelWidth - overlayPanel.width) / 2);
859
+ const overviewTop = margin + titleBlock.height + 18;
860
+ const screenshotY = overviewTop + Math.round((overviewHeight - screenshotPanel.height) / 2);
861
+ const overlayY = overviewTop + Math.round((overviewHeight - overlayPanel.height) / 2);
862
+ const composites = [
863
+ { input: titleBlock.input, left: margin, top: margin },
864
+ { input: screenshotPanel.input, left: screenshotX, top: screenshotY },
865
+ { input: overlayPanel.input, left: overlayX, top: overlayY },
866
+ { input: labelSvg(screenshotSpec.label, { maxWidth: Math.max(32, screenshotPanel.width - 32) }), left: screenshotX + 16, top: screenshotY + 16 },
867
+ { input: labelSvg(overlaySpec?.label ?? copy.alignmentOverlayLabel, { maxWidth: Math.max(32, overlayPanel.width - 32) }), left: overlayX + 16, top: overlayY + 16 },
868
+ ];
869
+ const renderedCards = [];
870
+ for (const [index, group] of groups.entries()) {
871
+ renderedCards.push(await renderParallelCard(projectRoot, group, index, {
872
+ cardWidth,
873
+ eyebrow: copy.alignmentMeasureEyebrow,
874
+ locale,
875
+ }));
876
+ }
877
+ const rowHeights = [];
878
+ for (let index = 0; index < renderedCards.length; index += columns) {
879
+ rowHeights.push(Math.max(...renderedCards.slice(index, index + columns).map((card) => card.height)));
880
+ }
881
+ let currentTop = overviewTop + overviewHeight + 28;
882
+ for (let row = 0; row < rowHeights.length; row += 1) {
883
+ const rowCards = renderedCards.slice(row * columns, row * columns + columns);
884
+ for (const [column, card] of rowCards.entries()) {
885
+ composites.push({
886
+ input: card.image,
887
+ left: margin + column * (cardWidth + gap),
888
+ top: currentTop,
889
+ });
890
+ }
891
+ currentTop += rowHeights[row] + gap;
892
+ }
893
+ const canvasHeight = currentTop + margin - (rowHeights.length > 0 ? gap : 0);
894
+ const canvas = sharp({
895
+ create: {
896
+ width: contentWidth,
897
+ height: canvasHeight,
898
+ channels: 3,
899
+ background: '#111827',
900
+ },
901
+ }).composite(composites);
902
+
903
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
904
+ await encodePipeline(canvas, format, quality).toFile(outputPath);
905
+ const metadataPath = metadataPathForOutput(outputPath);
906
+ const reviewArtifact = {
907
+ version: 1,
908
+ schema: VISUAL_REVIEW_SCHEMA,
909
+ generatedAt: timestamp(),
910
+ mode: 'alignment-board',
911
+ locale,
912
+ outputPath: toWorkspacePath(projectRoot, outputPath),
913
+ format,
914
+ boardSource: toWorkspacePath(projectRoot, board.sourcePath),
915
+ title: String(board.payload.title ?? copy.alignmentTitle),
916
+ alignmentScope: summarizeAlignmentScope(renderedCards.map((card) => card.item)),
917
+ labels: {
918
+ screenshot: screenshotSpec.label,
919
+ overlay: overlaySpec?.label ?? copy.alignmentOverlayLabel,
920
+ },
921
+ screenshot: {
922
+ path: toWorkspacePath(projectRoot, screenshotPanel.source),
923
+ original: screenshotPanel.original,
924
+ rendered: {
925
+ width: screenshotPanel.width,
926
+ height: screenshotPanel.height,
927
+ },
928
+ },
929
+ overlay: overlaySpec ? {
930
+ path: toWorkspacePath(projectRoot, overlayPanel.source),
931
+ original: overlayPanel.original,
932
+ rendered: {
933
+ width: overlayPanel.width,
934
+ height: overlayPanel.height,
935
+ },
936
+ } : {
937
+ generatedFrom: toWorkspacePath(projectRoot, screenshotPanel.source),
938
+ guides: normalizeAlignmentGuides(board.payload, locale),
939
+ boxes: normalizeAlignmentBoxes(board.payload, locale),
940
+ },
941
+ groups: renderedCards.map((card) => card.item),
942
+ canvas: {
943
+ width: contentWidth,
944
+ height: canvasHeight,
945
+ },
946
+ };
947
+ await fs.writeFile(metadataPath, `${JSON.stringify(reviewArtifact, null, 2)}\n`, 'utf8');
948
+
949
+ return {
950
+ ok: true,
951
+ action: 'visual-compare',
952
+ mode: 'alignment-board',
953
+ projectRoot,
954
+ outputPath,
955
+ metadataPath,
956
+ format,
957
+ quality: format === 'png' ? null : quality,
958
+ canvas: reviewArtifact.canvas,
959
+ reviewArtifact,
960
+ groups: reviewArtifact.groups,
961
+ nextActions: copy.alignmentNextActions,
962
+ };
963
+ }
964
+
965
+ const CHAT_EMBED_MODE_LABELS = {
966
+ 'before-after': '修改前后对比',
967
+ 'reference-actual': '效果图对比',
968
+ 'focus-board': '局部焦点证据板',
969
+ 'parallel-board': '并行实验证据板',
970
+ 'verification-board': '截图实测证据板',
971
+ 'alignment-board': '网格对齐证据板',
972
+ 'centering-board': '内部居中证据板',
973
+ };
974
+
975
+ function attachChatEmbed(result) {
976
+ if (!result?.outputPath) {
977
+ return result;
978
+ }
979
+ const label = CHAT_EMBED_MODE_LABELS[result.mode] ?? '视觉证据板';
980
+ result.chatEmbed = {
981
+ label,
982
+ markdown: `![视觉证据: ${label}](${result.outputPath})`,
983
+ hint: '把这行 markdown 直接贴进最终回复,让证据板在对话流里可见;不要只报告文件路径。',
984
+ };
985
+ result.nextActions = [
986
+ ...(result.nextActions ?? []),
987
+ `用 ${result.chatEmbed.markdown} 把证据板嵌入对话流展示,再基于图上标注说明气质、层级与对齐结论。`,
988
+ ];
989
+ return result;
990
+ }
991
+
1082
992
  async function visualCompareWorkspace(projectRoot, options = {}) {
1083
993
  const request = resolveComparisonInputs(options);
1084
994
  if (request.mode === 'board') {
1085
995
  const board = await readBoardSpec(projectRoot, request.board);
996
+ const locale = resolveBoardLocale(options, board.payload);
997
+ validateBoardVisibleLanguage(board, locale);
998
+ const renderOptions = { ...options, locale };
1086
999
  if (board.mode === 'focus-board') {
1087
- return renderFocusBoard(projectRoot, board, options);
1000
+ return attachChatEmbed(await renderFocusBoard(projectRoot, board, renderOptions));
1088
1001
  }
1089
1002
  if (board.mode === 'parallel-board') {
1090
- return renderParallelBoard(projectRoot, board, options);
1003
+ return attachChatEmbed(await renderParallelBoard(projectRoot, board, renderOptions));
1004
+ }
1005
+ if (board.mode === 'verification-board') {
1006
+ return attachChatEmbed(await renderVerificationBoard(projectRoot, board, renderOptions));
1007
+ }
1008
+ if (board.mode === 'alignment-board') {
1009
+ return attachChatEmbed(await renderAlignmentBoard(projectRoot, board, renderOptions));
1010
+ }
1011
+ if (board.mode === 'centering-board') {
1012
+ return attachChatEmbed(await renderCenteringBoard(projectRoot, board, renderOptions));
1091
1013
  }
1092
1014
  throw new Error(`Unsupported board mode: ${board.mode}`);
1093
1015
  }
1094
- return renderStandardComparison(projectRoot, request, options);
1016
+ return attachChatEmbed(await renderStandardComparison(projectRoot, request, options));
1095
1017
  }
1096
1018
 
1097
1019
  export { visualCompareWorkspace };