@openprd/cli 0.1.10 → 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.
- package/.openprd/benchmarks/index.md +17 -15
- package/.openprd/benchmarks/sources.yaml +53 -1
- package/.openprd/changes/adaptive-project-framing/.openprd.yaml +2 -0
- package/.openprd/changes/adaptive-project-framing/design.md +47 -0
- package/.openprd/changes/adaptive-project-framing/proposal.md +35 -0
- package/.openprd/changes/adaptive-project-framing/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/adaptive-project-framing/task-events.jsonl +20 -0
- package/.openprd/changes/adaptive-project-framing/tasks.md +340 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/.openprd.yaml +2 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/design.md +34 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/proposal.md +23 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/task-events.jsonl +14 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/tasks.md +238 -0
- package/.openprd/changes/loop-isolated-worktree-commit/.openprd.yaml +2 -0
- package/.openprd/changes/loop-isolated-worktree-commit/design.md +53 -0
- package/.openprd/changes/loop-isolated-worktree-commit/proposal.md +38 -0
- package/.openprd/changes/loop-isolated-worktree-commit/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/loop-isolated-worktree-commit/task-events.jsonl +22 -0
- package/.openprd/changes/loop-isolated-worktree-commit/tasks.md +357 -0
- package/.openprd/changes/openprd/.openprd.yaml +2 -0
- package/.openprd/changes/openprd/design.md +46 -0
- package/.openprd/changes/openprd/proposal.md +36 -0
- package/.openprd/changes/openprd/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd/task-events.jsonl +23 -0
- package/.openprd/changes/openprd/tasks.md +242 -0
- package/.openprd/changes/pm-human-in-the-loop/.openprd.yaml +2 -0
- package/.openprd/changes/pm-human-in-the-loop/design.md +38 -0
- package/.openprd/changes/pm-human-in-the-loop/proposal.md +29 -0
- package/.openprd/changes/pm-human-in-the-loop/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/pm-human-in-the-loop/task-events.jsonl +18 -0
- package/.openprd/changes/pm-human-in-the-loop/tasks.md +306 -0
- package/.openprd/changes/test-strategy-router/.openprd.yaml +2 -0
- package/.openprd/changes/test-strategy-router/design.md +65 -0
- package/.openprd/changes/test-strategy-router/proposal.md +45 -0
- package/.openprd/changes/test-strategy-router/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/test-strategy-router/task-events.jsonl +1 -0
- package/.openprd/changes/test-strategy-router/tasks.md +132 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/.openprd.yaml +2 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/design.md +28 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/proposal.md +23 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/task-events.jsonl +11 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/tasks.md +66 -0
- package/.openprd/changes/vibe-coding-dev-check/.openprd.yaml +2 -0
- package/.openprd/changes/vibe-coding-dev-check/design.md +37 -0
- package/.openprd/changes/vibe-coding-dev-check/proposal.md +28 -0
- package/.openprd/changes/vibe-coding-dev-check/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/vibe-coding-dev-check/task-events.jsonl +18 -0
- package/.openprd/changes/vibe-coding-dev-check/tasks.md +306 -0
- package/.openprd/design/README.md +11 -6
- package/.openprd/design/README_EN.md +4 -1
- package/.openprd/design/active/asset-spec.md +6 -0
- package/.openprd/design/active/direction-plan.md +5 -5
- package/.openprd/design/active/selected-direction.md +2 -0
- package/.openprd/design/anti-slop.md +26 -1
- package/.openprd/design/assets/surface-presets.md +6 -0
- package/.openprd/design/checklists/ui-quality-gate.md +7 -0
- package/.openprd/design/lenses/frontend-lenses.md +28 -0
- package/.openprd/design/recipes/content-experience.md +8 -1
- package/.openprd/design/recipes/operational-tool.md +7 -0
- package/.openprd/design/recipes/product-launch.md +8 -1
- package/.openprd/design/templates/README.md +6 -3
- package/.openprd/design/templates/README_EN.md +6 -3
- package/.openprd/design/templates/ops-dashboard.html +8 -1
- package/.openprd/design/templates/product-launch.html +2 -1
- package/.openprd/design/themes/theme-catalog.json +23 -5
- package/.openprd/engagements/active/prd.md +103 -100
- package/.openprd/schema/prd.schema.yaml +15 -0
- package/.openprd/standards/config.json +7 -1
- package/.openprd/templates/base/intake.md +13 -0
- package/.openprd/templates/base/prd.md +15 -0
- package/AGENTS.md +14 -4
- package/README.md +107 -14
- package/README_EN.md +131 -13
- package/package.json +1 -1
- package/scripts/dev-check-wrapup-copy.mjs +46 -21
- package/skills/openprd-benchmark-router/SKILL.md +2 -1
- package/skills/openprd-diagram-review/SKILL.md +27 -2
- package/skills/openprd-diagram-review/references/explanation-svg-patterns.md +131 -0
- package/skills/openprd-discovery-loop/SKILL.md +2 -2
- package/skills/openprd-frontend-design/SKILL.md +44 -20
- package/skills/openprd-frontend-design/references/design-asset-contract.md +6 -0
- package/skills/openprd-frontend-design/references/direction-engine.md +29 -4
- package/skills/openprd-harness/SKILL.md +22 -16
- package/skills/openprd-quality/SKILL.md +10 -8
- package/skills/openprd-requirement-intake/SKILL.md +5 -3
- package/skills/openprd-requirement-intake/references/prd-template-lenses.md +2 -2
- package/skills/openprd-requirement-intake/references/startup-validation-lens.md +98 -13
- package/skills/openprd-router/SKILL.md +2 -1
- package/skills/openprd-shared/SKILL.md +27 -17
- package/skills/openprd-standards/SKILL.md +4 -2
- package/src/agent-canonical-content.js +808 -0
- package/src/agent-integration.js +259 -778
- package/src/brainstorm-artifacts.js +156 -8
- package/src/brainstorm-presentation.js +14 -4
- package/src/brainstorm.js +165 -2
- package/src/canvas-app.html.js +2910 -0
- package/src/canvas-i18n.js +274 -0
- package/src/canvas-workspace.js +2271 -0
- package/src/cli/args.js +72 -4
- package/src/cli/basic-print.js +3 -0
- package/src/cli/canvas-print.js +106 -0
- package/src/cli/doctor-print.js +20 -0
- package/src/cli/print.js +2 -0
- package/src/cli/quality-commands.js +222 -0
- package/src/cli/quality-print.js +37 -5
- package/src/cli/run-print.js +5 -0
- package/src/cli/workflow-print.js +3 -0
- package/src/codex-app-server-relay.js +251 -0
- package/src/codex-hook-runner-template.mjs +963 -65
- package/src/design-starter-support.js +2 -2
- package/src/design-starter.js +39 -3
- package/src/dev-standards.js +125 -10
- package/src/diagram-core.js +390 -171
- package/src/discovery.js +2 -8
- package/src/docs-compact.js +125 -0
- package/src/execution-strategy.js +1 -1
- package/src/fleet.js +9 -3
- package/src/guide.js +227 -0
- package/src/html-artifacts.js +825 -51
- package/src/knowledge.js +101 -20
- package/src/language-policy.js +63 -3
- package/src/learning-review.js +1 -1
- package/src/loop.js +1 -1
- package/src/openprd.js +68 -131
- package/src/openspec/change-validate.js +2 -2
- package/src/openspec/constants.js +6 -7
- package/src/openspec/execute.js +2 -2
- package/src/openspec/generate.js +318 -102
- package/src/openspec/migration.js +168 -0
- package/src/openspec/paths.js +1 -22
- package/src/prd-core.js +102 -3
- package/src/quality-html-artifact.js +30 -5
- package/src/quality-visual-review.js +257 -14
- package/src/quality.js +66 -18
- package/src/review-presentation.js +60 -0
- package/src/run-harness.js +41 -3
- package/src/session-evidence.js +363 -0
- package/src/standards.js +64 -0
- package/src/test-strategy.js +7 -4
- package/src/visual-compare-centering.js +622 -0
- package/src/visual-compare-core.js +1123 -0
- package/src/visual-compare.js +620 -698
- package/src/workspace-core.js +132 -4
- package/src/workspace-workflow.js +172 -10
package/src/visual-compare.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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('&', '&')
|
|
111
|
-
.replaceAll('<', '<')
|
|
112
|
-
.replaceAll('>', '>')
|
|
113
|
-
.replaceAll('"', '"')
|
|
114
|
-
.replaceAll("'", ''');
|
|
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 ??
|
|
717
|
-
const rightSpec = normalizeImageSpec(projectRoot, board.payload.right ?? board.payload.actual, board.payload.rightLabel ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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
|
|
893
|
-
const
|
|
894
|
-
const
|
|
895
|
-
const
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
|
945
|
-
const card = sharp({
|
|
396
|
+
const canvas = sharp({
|
|
946
397
|
create: {
|
|
947
|
-
width:
|
|
948
|
-
height:
|
|
398
|
+
width: canvasWidth,
|
|
399
|
+
height: canvasHeight,
|
|
949
400
|
channels: 3,
|
|
950
|
-
background: '#
|
|
401
|
+
background: '#111827',
|
|
951
402
|
},
|
|
952
|
-
}).composite(
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
|
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, '
|
|
453
|
+
: defaultOutputPath(projectRoot, format, 'verification-board');
|
|
985
454
|
const quality = parseQuality(options.quality);
|
|
986
|
-
const items =
|
|
455
|
+
const items = normalizeVerificationItems(projectRoot, board.payload, locale);
|
|
987
456
|
if (items.length === 0) {
|
|
988
|
-
throw new Error('
|
|
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 ??
|
|
997
|
-
|
|
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, {
|
|
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: '
|
|
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 ??
|
|
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: '
|
|
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: ``,
|
|
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,
|
|
1000
|
+
return attachChatEmbed(await renderFocusBoard(projectRoot, board, renderOptions));
|
|
1088
1001
|
}
|
|
1089
1002
|
if (board.mode === 'parallel-board') {
|
|
1090
|
-
return renderParallelBoard(projectRoot, board,
|
|
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 };
|