@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.
Files changed (146) hide show
  1. package/.openprd/benchmarks/index.md +17 -15
  2. package/.openprd/benchmarks/sources.yaml +53 -1
  3. package/.openprd/changes/adaptive-project-framing/.openprd.yaml +2 -0
  4. package/.openprd/changes/adaptive-project-framing/design.md +47 -0
  5. package/.openprd/changes/adaptive-project-framing/proposal.md +35 -0
  6. package/.openprd/changes/adaptive-project-framing/specs/agent-requirements/spec.md +16 -0
  7. package/.openprd/changes/adaptive-project-framing/task-events.jsonl +20 -0
  8. package/.openprd/changes/adaptive-project-framing/tasks.md +340 -0
  9. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/.openprd.yaml +2 -0
  10. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/design.md +34 -0
  11. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/proposal.md +23 -0
  12. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/specs/agent-requirements/spec.md +16 -0
  13. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/task-events.jsonl +14 -0
  14. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/tasks.md +238 -0
  15. package/.openprd/changes/loop-isolated-worktree-commit/.openprd.yaml +2 -0
  16. package/.openprd/changes/loop-isolated-worktree-commit/design.md +53 -0
  17. package/.openprd/changes/loop-isolated-worktree-commit/proposal.md +38 -0
  18. package/.openprd/changes/loop-isolated-worktree-commit/specs/agent-requirements/spec.md +16 -0
  19. package/.openprd/changes/loop-isolated-worktree-commit/task-events.jsonl +22 -0
  20. package/.openprd/changes/loop-isolated-worktree-commit/tasks.md +357 -0
  21. package/.openprd/changes/openprd/.openprd.yaml +2 -0
  22. package/.openprd/changes/openprd/design.md +46 -0
  23. package/.openprd/changes/openprd/proposal.md +36 -0
  24. package/.openprd/changes/openprd/specs/agent-requirements/spec.md +16 -0
  25. package/.openprd/changes/openprd/task-events.jsonl +23 -0
  26. package/.openprd/changes/openprd/tasks.md +242 -0
  27. package/.openprd/changes/pm-human-in-the-loop/.openprd.yaml +2 -0
  28. package/.openprd/changes/pm-human-in-the-loop/design.md +38 -0
  29. package/.openprd/changes/pm-human-in-the-loop/proposal.md +29 -0
  30. package/.openprd/changes/pm-human-in-the-loop/specs/agent-requirements/spec.md +16 -0
  31. package/.openprd/changes/pm-human-in-the-loop/task-events.jsonl +18 -0
  32. package/.openprd/changes/pm-human-in-the-loop/tasks.md +306 -0
  33. package/.openprd/changes/test-strategy-router/.openprd.yaml +2 -0
  34. package/.openprd/changes/test-strategy-router/design.md +65 -0
  35. package/.openprd/changes/test-strategy-router/proposal.md +45 -0
  36. package/.openprd/changes/test-strategy-router/specs/agent-requirements/spec.md +16 -0
  37. package/.openprd/changes/test-strategy-router/task-events.jsonl +1 -0
  38. package/.openprd/changes/test-strategy-router/tasks.md +132 -0
  39. package/.openprd/changes/verify-boundaries-and-historical-refresh/.openprd.yaml +2 -0
  40. package/.openprd/changes/verify-boundaries-and-historical-refresh/design.md +28 -0
  41. package/.openprd/changes/verify-boundaries-and-historical-refresh/proposal.md +23 -0
  42. package/.openprd/changes/verify-boundaries-and-historical-refresh/specs/agent-requirements/spec.md +16 -0
  43. package/.openprd/changes/verify-boundaries-and-historical-refresh/task-events.jsonl +11 -0
  44. package/.openprd/changes/verify-boundaries-and-historical-refresh/tasks.md +66 -0
  45. package/.openprd/changes/vibe-coding-dev-check/.openprd.yaml +2 -0
  46. package/.openprd/changes/vibe-coding-dev-check/design.md +37 -0
  47. package/.openprd/changes/vibe-coding-dev-check/proposal.md +28 -0
  48. package/.openprd/changes/vibe-coding-dev-check/specs/agent-requirements/spec.md +16 -0
  49. package/.openprd/changes/vibe-coding-dev-check/task-events.jsonl +18 -0
  50. package/.openprd/changes/vibe-coding-dev-check/tasks.md +306 -0
  51. package/.openprd/design/README.md +11 -6
  52. package/.openprd/design/README_EN.md +4 -1
  53. package/.openprd/design/active/asset-spec.md +6 -0
  54. package/.openprd/design/active/direction-plan.md +5 -5
  55. package/.openprd/design/active/selected-direction.md +2 -0
  56. package/.openprd/design/anti-slop.md +26 -1
  57. package/.openprd/design/assets/surface-presets.md +6 -0
  58. package/.openprd/design/checklists/ui-quality-gate.md +7 -0
  59. package/.openprd/design/lenses/frontend-lenses.md +28 -0
  60. package/.openprd/design/recipes/content-experience.md +8 -1
  61. package/.openprd/design/recipes/operational-tool.md +7 -0
  62. package/.openprd/design/recipes/product-launch.md +8 -1
  63. package/.openprd/design/templates/README.md +6 -3
  64. package/.openprd/design/templates/README_EN.md +6 -3
  65. package/.openprd/design/templates/ops-dashboard.html +8 -1
  66. package/.openprd/design/templates/product-launch.html +2 -1
  67. package/.openprd/design/themes/theme-catalog.json +23 -5
  68. package/.openprd/engagements/active/prd.md +103 -100
  69. package/.openprd/schema/prd.schema.yaml +15 -0
  70. package/.openprd/standards/config.json +7 -1
  71. package/.openprd/templates/base/intake.md +13 -0
  72. package/.openprd/templates/base/prd.md +15 -0
  73. package/AGENTS.md +14 -4
  74. package/README.md +107 -14
  75. package/README_EN.md +131 -13
  76. package/package.json +1 -1
  77. package/scripts/dev-check-wrapup-copy.mjs +46 -21
  78. package/skills/openprd-benchmark-router/SKILL.md +2 -1
  79. package/skills/openprd-diagram-review/SKILL.md +27 -2
  80. package/skills/openprd-diagram-review/references/explanation-svg-patterns.md +131 -0
  81. package/skills/openprd-discovery-loop/SKILL.md +2 -2
  82. package/skills/openprd-frontend-design/SKILL.md +44 -20
  83. package/skills/openprd-frontend-design/references/design-asset-contract.md +6 -0
  84. package/skills/openprd-frontend-design/references/direction-engine.md +29 -4
  85. package/skills/openprd-harness/SKILL.md +22 -16
  86. package/skills/openprd-quality/SKILL.md +10 -8
  87. package/skills/openprd-requirement-intake/SKILL.md +5 -3
  88. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +2 -2
  89. package/skills/openprd-requirement-intake/references/startup-validation-lens.md +98 -13
  90. package/skills/openprd-router/SKILL.md +2 -1
  91. package/skills/openprd-shared/SKILL.md +27 -17
  92. package/skills/openprd-standards/SKILL.md +4 -2
  93. package/src/agent-canonical-content.js +808 -0
  94. package/src/agent-integration.js +259 -778
  95. package/src/brainstorm-artifacts.js +156 -8
  96. package/src/brainstorm-presentation.js +14 -4
  97. package/src/brainstorm.js +165 -2
  98. package/src/canvas-app.html.js +2910 -0
  99. package/src/canvas-i18n.js +274 -0
  100. package/src/canvas-workspace.js +2271 -0
  101. package/src/cli/args.js +72 -4
  102. package/src/cli/basic-print.js +3 -0
  103. package/src/cli/canvas-print.js +106 -0
  104. package/src/cli/doctor-print.js +20 -0
  105. package/src/cli/print.js +2 -0
  106. package/src/cli/quality-commands.js +222 -0
  107. package/src/cli/quality-print.js +37 -5
  108. package/src/cli/run-print.js +5 -0
  109. package/src/cli/workflow-print.js +3 -0
  110. package/src/codex-app-server-relay.js +251 -0
  111. package/src/codex-hook-runner-template.mjs +963 -65
  112. package/src/design-starter-support.js +2 -2
  113. package/src/design-starter.js +39 -3
  114. package/src/dev-standards.js +125 -10
  115. package/src/diagram-core.js +390 -171
  116. package/src/discovery.js +2 -8
  117. package/src/docs-compact.js +125 -0
  118. package/src/execution-strategy.js +1 -1
  119. package/src/fleet.js +9 -3
  120. package/src/guide.js +227 -0
  121. package/src/html-artifacts.js +825 -51
  122. package/src/knowledge.js +101 -20
  123. package/src/language-policy.js +63 -3
  124. package/src/learning-review.js +1 -1
  125. package/src/loop.js +1 -1
  126. package/src/openprd.js +68 -131
  127. package/src/openspec/change-validate.js +2 -2
  128. package/src/openspec/constants.js +6 -7
  129. package/src/openspec/execute.js +2 -2
  130. package/src/openspec/generate.js +318 -102
  131. package/src/openspec/migration.js +168 -0
  132. package/src/openspec/paths.js +1 -22
  133. package/src/prd-core.js +102 -3
  134. package/src/quality-html-artifact.js +30 -5
  135. package/src/quality-visual-review.js +257 -14
  136. package/src/quality.js +66 -18
  137. package/src/review-presentation.js +60 -0
  138. package/src/run-harness.js +41 -3
  139. package/src/session-evidence.js +363 -0
  140. package/src/standards.js +64 -0
  141. package/src/test-strategy.js +7 -4
  142. package/src/visual-compare-centering.js +622 -0
  143. package/src/visual-compare-core.js +1123 -0
  144. package/src/visual-compare.js +620 -698
  145. package/src/workspace-core.js +132 -4
  146. package/src/workspace-workflow.js +172 -10
@@ -1,4 +1,5 @@
1
1
  import { timestamp } from './time.js';
2
+ import { isEnglishHeavyText, normalizeOutputLocale } from './language-policy.js';
2
3
 
3
4
  function escapeHtml(value) {
4
5
  return `${value ?? ''}`
@@ -88,17 +89,17 @@ function pickValue(primary, fallback) {
88
89
 
89
90
  function theme(type) {
90
91
  const themes = {
91
- frontend: { fill: '#0f172a', stroke: '#22d3ee', title: '#67e8f9' },
92
- backend: { fill: '#0f172a', stroke: '#34d399', title: '#6ee7b7' },
93
- database: { fill: '#0f172a', stroke: '#c084fc', title: '#d8b4fe' },
94
- cloud: { fill: '#0f172a', stroke: '#f59e0b', title: '#fcd34d' },
95
- security: { fill: '#0f172a', stroke: '#fb7185', title: '#fda4af' },
96
- external: { fill: '#0f172a', stroke: '#94a3b8', title: '#e2e8f0' },
97
- user_action: { fill: '#0f172a', stroke: '#22d3ee', title: '#67e8f9' },
98
- system_process: { fill: '#0f172a', stroke: '#34d399', title: '#6ee7b7' },
99
- decision: { fill: '#0f172a', stroke: '#f59e0b', title: '#fcd34d' },
100
- error_path: { fill: '#0f172a', stroke: '#fb7185', title: '#fda4af' },
101
- success: { fill: '#0f172a', stroke: '#c084fc', title: '#d8b4fe' },
92
+ frontend: { fill: '#eff6ff', stroke: '#2563eb', title: '#1d4ed8' },
93
+ backend: { fill: '#f0fdfa', stroke: '#0f766e', title: '#0f766e' },
94
+ database: { fill: '#eef2ff', stroke: '#4f46e5', title: '#4338ca' },
95
+ cloud: { fill: '#fffbeb', stroke: '#b45309', title: '#b45309' },
96
+ security: { fill: '#fef2f2', stroke: '#dc2626', title: '#b91c1c' },
97
+ external: { fill: '#f8fafc', stroke: '#94a3b8', title: '#475569' },
98
+ user_action: { fill: '#eff6ff', stroke: '#2563eb', title: '#1d4ed8' },
99
+ system_process: { fill: '#f0fdfa', stroke: '#0f766e', title: '#0f766e' },
100
+ decision: { fill: '#fffbeb', stroke: '#b45309', title: '#b45309' },
101
+ error_path: { fill: '#fef2f2', stroke: '#dc2626', title: '#b91c1c' },
102
+ success: { fill: '#eef2ff', stroke: '#4f46e5', title: '#4338ca' },
102
103
  };
103
104
  return themes[type] ?? themes.external;
104
105
  }
@@ -120,28 +121,13 @@ function normalizePanel(panel, fallbackTitle = '评审备注', fallbackColor = '
120
121
  }
121
122
 
122
123
  function normalizeLocale(contract) {
123
- return pickValue(contract?.locale, contract?.lang ?? 'zh-CN');
124
+ return normalizeOutputLocale(pickValue(contract?.locale, contract?.lang ?? 'zh-CN'));
124
125
  }
125
126
 
126
127
  function normalizeReviewStatus(value) {
127
128
  return pickValue(value, 'pending-confirmation');
128
129
  }
129
130
 
130
- function hasCjk(text) {
131
- return /[\u3400-\u9fff]/.test(text);
132
- }
133
-
134
- function englishWords(text) {
135
- return `${text ?? ''}`.match(/[A-Za-z][A-Za-z0-9+_.-]*/g) ?? [];
136
- }
137
-
138
- function isEnglishHeavyText(text) {
139
- const value = `${text ?? ''}`.trim();
140
- if (!value || hasCjk(value)) return false;
141
- const words = englishWords(value);
142
- return words.length >= 4;
143
- }
144
-
145
131
  function collectDiagramTexts(model) {
146
132
  const entries = [];
147
133
  const push = (path, value) => {
@@ -181,7 +167,7 @@ function collectDiagramTexts(model) {
181
167
  }
182
168
 
183
169
  export function validateDiagramLanguage(model) {
184
- const locale = `${model?.locale ?? 'zh-CN'}`.toLowerCase();
170
+ const locale = normalizeOutputLocale(model?.locale ?? 'zh-CN').toLowerCase();
185
171
  if (!locale.startsWith('zh')) {
186
172
  return { valid: true, errors: [] };
187
173
  }
@@ -245,36 +231,32 @@ function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup,
245
231
  <title>${escapeHtml(title)}</title>
246
232
  <style>
247
233
  :root {
248
- color-scheme: dark;
249
- --bg: #020617;
250
- --panel: rgba(15, 23, 42, 0.92);
251
- --text: #e2e8f0;
252
- --muted: #94a3b8;
253
- --grid: rgba(148, 163, 184, 0.12);
234
+ color-scheme: light;
235
+ --bg: #f6f8fb;
236
+ --panel: #ffffff;
237
+ --panel-soft: #f9fafb;
238
+ --text: #172033;
239
+ --muted: #667085;
240
+ --line: #d8dee8;
254
241
  }
255
242
  * { box-sizing: border-box; }
256
243
  body {
257
244
  margin: 0;
258
- font-family: "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
245
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
259
246
  color: var(--text);
260
- background:
261
- linear-gradient(var(--grid) 1px, transparent 1px),
262
- linear-gradient(90deg, var(--grid) 1px, transparent 1px),
263
- radial-gradient(circle at top, rgba(34, 211, 238, 0.12), transparent 30%),
264
- var(--bg);
265
- background-size: 40px 40px, 40px 40px, 100% 100%, auto;
247
+ background: var(--bg);
266
248
  }
267
249
  .page { max-width: 1240px; margin: 0 auto; padding: 32px 24px 48px; }
268
250
  .header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
269
251
  .header-copy { display: flex; flex-direction: column; gap: 4px; }
270
252
  .pulse {
271
- width: 12px; height: 12px; border-radius: 999px; background: #22d3ee;
272
- box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.7); animation: pulse 2s infinite;
253
+ width: 12px; height: 12px; border-radius: 999px; background: #2563eb;
254
+ box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.5); animation: pulse 2s infinite;
273
255
  }
274
256
  @keyframes pulse {
275
- 0% { box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.7); }
276
- 70% { box-shadow: 0 0 0 10px rgba(34, 211, 238, 0); }
277
- 100% { box-shadow: 0 0 0 0 rgba(34, 211, 238, 0); }
257
+ 0% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.5); }
258
+ 70% { box-shadow: 0 0 0 10px rgba(37, 99, 235, 0); }
259
+ 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
278
260
  }
279
261
  .project-chip {
280
262
  display: inline-flex;
@@ -283,32 +265,37 @@ function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup,
283
265
  width: fit-content;
284
266
  padding: 4px 10px;
285
267
  border-radius: 999px;
286
- border: 1px solid rgba(148, 163, 184, 0.24);
287
- background: rgba(15, 23, 42, 0.72);
288
- color: #cbd5e1;
268
+ border: 1px solid var(--line);
269
+ background: var(--panel);
270
+ color: var(--muted);
289
271
  font-size: 11px;
290
272
  }
291
- h1 { margin: 0; font-size: 28px; }
273
+ h1 { margin: 0; font-size: 28px; font-weight: 700; }
292
274
  .subtitle-block { margin: 6px 0 0 24px; color: var(--muted); font-size: 13px; }
293
275
  .diagram-shell {
294
- margin-top: 24px; border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 20px;
295
- padding: 20px; background: rgba(2, 6, 23, 0.72); backdrop-filter: blur(6px);
276
+ margin-top: 24px; border: 1px solid var(--line); border-radius: 20px;
277
+ padding: 20px; background: var(--panel);
278
+ box-shadow: 0 12px 30px rgba(15, 23, 42, 0.05);
296
279
  }
297
280
  svg { width: 100%; height: auto; display: block; }
298
281
  .node-title { font-size: 13px; font-weight: 700; }
299
- .node-subtitle, .detail, .flow-label, .legend-label, .footer { font-size: 10px; fill: #cbd5e1; }
300
- .detail { fill: #94a3b8; }
282
+ .node-subtitle, .detail, .flow-label, .legend-label, .footer { font-size: 10px; fill: #334155; }
283
+ .detail { fill: #667085; }
284
+ .lane-chip { font-size: 9px; fill: #94a3b8; }
285
+ .legend-label { fill: #667085; }
286
+ .flow-label { fill: #334155; paint-order: stroke; stroke: #ffffff; stroke-width: 4px; stroke-linejoin: round; }
301
287
  .summary-grid, .side-grid { display: grid; gap: 16px; margin-top: 24px; }
302
288
  .summary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
303
289
  .side-grid { grid-template-columns: 1fr 1fr; }
304
290
  .card {
305
- border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 16px;
291
+ border: 1px solid var(--line); border-radius: 16px;
306
292
  background: var(--panel); padding: 14px 16px;
293
+ box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
307
294
  }
308
- .card-header { display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 8px; }
295
+ .card-header { display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 8px; font-weight: 600; }
309
296
  .dot { width: 10px; height: 10px; border-radius: 999px; }
310
- ul { padding-left: 18px; margin: 0; color: #cbd5e1; font-size: 12px; line-height: 1.65; }
311
- .footer { margin-top: 18px; color: var(--muted); }
297
+ ul { padding-left: 18px; margin: 0; color: #475467; font-size: 12px; line-height: 1.65; }
298
+ .footer { margin-top: 18px; color: var(--muted); font-size: 12px; }
312
299
  </style>
313
300
  </head>
314
301
  <body>
@@ -330,28 +317,273 @@ function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup,
330
317
  </html>`;
331
318
  }
332
319
 
320
+ function textUnits(text) {
321
+ let units = 0;
322
+ for (const ch of `${text ?? ''}`) {
323
+ units += /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch) ? 2 : 1;
324
+ }
325
+ return units;
326
+ }
327
+
328
+ function wrapTextLines(text, maxUnits, maxLines = 3) {
329
+ const value = `${text ?? ''}`.trim().replace(/\s+/g, ' ');
330
+ if (!value) return [];
331
+ const lines = [];
332
+ let current = '';
333
+ let currentUnits = 0;
334
+ for (const ch of value) {
335
+ const chUnits = textUnits(ch);
336
+ if (currentUnits + chUnits > maxUnits && current) {
337
+ lines.push(current);
338
+ current = ch === ' ' ? '' : ch;
339
+ currentUnits = ch === ' ' ? 0 : chUnits;
340
+ if (lines.length === maxLines) break;
341
+ } else {
342
+ current += ch;
343
+ currentUnits += chUnits;
344
+ }
345
+ }
346
+ if (lines.length < maxLines && current) {
347
+ lines.push(current);
348
+ } else if (lines.length === maxLines && current) {
349
+ const last = lines[maxLines - 1];
350
+ lines[maxLines - 1] = `${last.slice(0, Math.max(0, last.length - 1))}…`;
351
+ }
352
+ return lines;
353
+ }
354
+
355
+ const FLOW_LAYOUT_DEFAULTS = {
356
+ boxWidth: 232,
357
+ gapX: 96,
358
+ gapY: 30,
359
+ marginX: 72,
360
+ marginY: 96,
361
+ titleHeight: 30,
362
+ lineHeight: 15,
363
+ subtitleMaxLines: 3,
364
+ detailMaxItems: 4,
365
+ bottomPadding: 14,
366
+ };
367
+
368
+ function assignLayers(nodes, edges) {
369
+ const ids = new Set(nodes.map((node) => node.id));
370
+ const validEdges = edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to);
371
+ const layers = new Map(nodes.map((node) => [node.id, 0]));
372
+ const maxIterations = Math.max(4, nodes.length * nodes.length);
373
+ let changed = true;
374
+ let iterations = 0;
375
+ while (changed && iterations < maxIterations) {
376
+ changed = false;
377
+ iterations += 1;
378
+ for (const edge of validEdges) {
379
+ const required = layers.get(edge.from) + 1;
380
+ if (layers.get(edge.to) < required && required <= nodes.length) {
381
+ layers.set(edge.to, required);
382
+ changed = true;
383
+ }
384
+ }
385
+ }
386
+ return { layers, validEdges };
387
+ }
388
+
389
+ function layoutFlowDiagram(nodes, edges, options = {}) {
390
+ const config = { ...FLOW_LAYOUT_DEFAULTS, ...options };
391
+ const { layers, validEdges } = assignLayers(nodes, edges);
392
+
393
+ const positioned = new Map();
394
+ for (const node of nodes) {
395
+ const subtitle = `${node.subtitle ?? ''}`.trim() === '待补充' ? '' : node.subtitle;
396
+ const subtitleLines = wrapTextLines(subtitle, 34, config.subtitleMaxLines);
397
+ const detailLines = (node.details ?? []).slice(0, config.detailMaxItems)
398
+ .filter((line) => `${line ?? ''}`.trim() !== '待补充')
399
+ .map((line) => wrapTextLines(line, 34, 1)[0])
400
+ .filter(Boolean);
401
+ const height = config.titleHeight
402
+ + (subtitleLines.length * config.lineHeight)
403
+ + (detailLines.length > 0 ? 6 + detailLines.length * config.lineHeight : 0)
404
+ + config.bottomPadding;
405
+ positioned.set(node.id, {
406
+ node,
407
+ subtitleLines,
408
+ detailLines,
409
+ width: config.boxWidth,
410
+ height: Math.max(height, 64),
411
+ layer: layers.get(node.id) ?? 0,
412
+ x: 0,
413
+ y: 0,
414
+ });
415
+ }
416
+
417
+ const columns = new Map();
418
+ for (const entry of positioned.values()) {
419
+ if (!columns.has(entry.layer)) columns.set(entry.layer, []);
420
+ columns.get(entry.layer).push(entry);
421
+ }
422
+
423
+ const incoming = new Map();
424
+ for (const edge of validEdges) {
425
+ if (!incoming.has(edge.to)) incoming.set(edge.to, []);
426
+ incoming.get(edge.to).push(edge.from);
427
+ }
428
+
429
+ const layerKeys = [...columns.keys()].sort((a, b) => a - b);
430
+ let maxColumnHeight = 0;
431
+ for (const key of layerKeys) {
432
+ const column = columns.get(key);
433
+ column.sort((a, b) => {
434
+ const aPreds = incoming.get(a.node.id) ?? [];
435
+ const bPreds = incoming.get(b.node.id) ?? [];
436
+ const avg = (preds) => {
437
+ const ys = preds.map((id) => positioned.get(id)?.y ?? 0);
438
+ return ys.length > 0 ? ys.reduce((sum, y) => sum + y, 0) / ys.length : Number.MAX_SAFE_INTEGER;
439
+ };
440
+ const diff = avg(aPreds) - avg(bPreds);
441
+ if (diff !== 0) return diff;
442
+ return (a.node.type === 'error_path' ? 1 : 0) - (b.node.type === 'error_path' ? 1 : 0);
443
+ });
444
+ const columnHeight = column.reduce((sum, entry) => sum + entry.height, 0) + Math.max(0, column.length - 1) * config.gapY;
445
+ maxColumnHeight = Math.max(maxColumnHeight, columnHeight);
446
+ }
447
+
448
+ for (const [index, key] of layerKeys.entries()) {
449
+ const column = columns.get(key);
450
+ const columnHeight = column.reduce((sum, entry) => sum + entry.height, 0) + Math.max(0, column.length - 1) * config.gapY;
451
+ let cursorY = config.marginY + (maxColumnHeight - columnHeight) / 2;
452
+ for (const entry of column) {
453
+ entry.x = config.marginX + index * (config.boxWidth + config.gapX);
454
+ entry.y = cursorY;
455
+ cursorY += entry.height + config.gapY;
456
+ }
457
+ }
458
+
459
+ const width = config.marginX * 2 + layerKeys.length * config.boxWidth + Math.max(0, layerKeys.length - 1) * config.gapX;
460
+ const height = config.marginY + maxColumnHeight + 96;
461
+ return { positioned, validEdges, width: Math.max(width, 760), height: Math.max(height, 360), config };
462
+ }
463
+
464
+ function routeFlowEdge(source, target, index, laneOffset) {
465
+ if (target.layer > source.layer) {
466
+ const startX = source.x + source.width;
467
+ const startY = source.y + source.height / 2;
468
+ const endX = target.x;
469
+ const endY = target.y + target.height / 2;
470
+ const midX = (startX + endX) / 2;
471
+ return {
472
+ path: `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`,
473
+ labelX: midX,
474
+ labelY: (startY + endY) / 2 - 8 - laneOffset,
475
+ };
476
+ }
477
+ if (target.layer === source.layer) {
478
+ const downward = target.y > source.y;
479
+ const startX = source.x + source.width / 2;
480
+ const startY = downward ? source.y + source.height : source.y;
481
+ const endX = target.x + target.width / 2;
482
+ const endY = downward ? target.y : target.y + target.height;
483
+ const midY = (startY + endY) / 2;
484
+ return {
485
+ path: `M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`,
486
+ labelX: (startX + endX) / 2 + 10,
487
+ labelY: midY - laneOffset,
488
+ };
489
+ }
490
+ const startX = source.x;
491
+ const startY = source.y + source.height / 2;
492
+ const endX = target.x + target.width;
493
+ const endY = target.y + target.height / 2;
494
+ const drop = Math.max(startY, endY) + 56 + index * 14;
495
+ return {
496
+ path: `M ${startX} ${startY} C ${startX - 60} ${drop}, ${endX + 60} ${drop}, ${endX} ${endY}`,
497
+ labelX: (startX + endX) / 2,
498
+ labelY: drop - 8,
499
+ };
500
+ }
501
+
333
502
  function renderBox(node, layout) {
334
503
  const nodeTheme = theme(node.type);
335
- const detailLines = (node.details ?? []).slice(0, 4);
336
- const detailMarkup = detailLines.map((line, index) => (
337
- `<text x="${layout.x + 16}" y="${layout.y + 54 + (index * 16)}" class="detail">${escapeHtml(trimText(line, 42))}</text>`
338
- )).join('');
504
+ const subtitleLines = layout.subtitleLines ?? wrapTextLines(node.subtitle, 34, 3);
505
+ const detailLines = layout.detailLines
506
+ ?? (node.details ?? []).slice(0, 4).map((line) => wrapTextLines(line, 34, 1)[0]).filter(Boolean);
507
+ const lineHeight = 15;
508
+ let cursorY = layout.y + 24;
509
+ const titleMarkup = `<text x="${layout.x + 16}" y="${cursorY}" class="node-title" fill="${nodeTheme.title}">${escapeHtml(trimText(node.name, 30))}</text>`;
510
+ const laneMarkup = node.lane
511
+ ? `<text x="${layout.x + layout.width - 14}" y="${layout.y + 22}" class="lane-chip" text-anchor="end">${escapeHtml(trimText(node.lane, 12))}</text>`
512
+ : '';
513
+ cursorY += 18;
514
+ const subtitleMarkup = subtitleLines.map((line) => {
515
+ const markup = `<text x="${layout.x + 16}" y="${cursorY}" class="node-subtitle">${escapeHtml(line)}</text>`;
516
+ cursorY += lineHeight;
517
+ return markup;
518
+ }).join('');
519
+ if (detailLines.length > 0) cursorY += 6;
520
+ const detailMarkup = detailLines.map((line) => {
521
+ const markup = `<text x="${layout.x + 16}" y="${cursorY}" class="detail">${escapeHtml(line)}</text>`;
522
+ cursorY += lineHeight;
523
+ return markup;
524
+ }).join('');
339
525
 
340
526
  return `
341
527
  <g>
342
528
  <rect x="${layout.x}" y="${layout.y}" width="${layout.width}" height="${layout.height}" rx="14" fill="${nodeTheme.fill}" fill-opacity="0.92" stroke="${nodeTheme.stroke}" stroke-width="1.5"></rect>
343
- <text x="${layout.x + 16}" y="${layout.y + 28}" class="node-title" fill="${nodeTheme.title}">${escapeHtml(node.name)}</text>
344
- <text x="${layout.x + 16}" y="${layout.y + 44}" class="node-subtitle">${escapeHtml(trimText(node.subtitle, 48))}</text>
529
+ ${titleMarkup}
530
+ ${laneMarkup}
531
+ ${subtitleMarkup}
345
532
  ${detailMarkup}
346
533
  </g>
347
534
  `;
348
535
  }
349
536
 
350
537
  function renderArrow(def) {
351
- const dashed = def.type === 'security' || def.type === 'error_path' ? 'stroke-dasharray="6,4"' : '';
352
- const stroke = def.type === 'security' || def.type === 'error_path' ? '#fb7185' : '#7dd3fc';
353
- return `<path d="${def.path}" fill="none" stroke="${stroke}" stroke-width="2" ${dashed} marker-end="url(#arrowhead)"></path>
354
- <text x="${def.labelX}" y="${def.labelY}" class="flow-label">${escapeHtml(def.label)}</text>`;
538
+ const isError = def.type === 'security' || def.type === 'error_path';
539
+ const dashed = isError ? 'stroke-dasharray="6,4"' : '';
540
+ const stroke = isError ? '#dc2626' : '#64748b';
541
+ const marker = isError ? 'arrowhead-error' : 'arrowhead';
542
+ const label = trimText(def.label, 24);
543
+ const labelMarkup = label && label !== '待补充'
544
+ ? `<text x="${def.labelX}" y="${def.labelY}" class="flow-label" text-anchor="middle">${escapeHtml(label)}</text>`
545
+ : '';
546
+ return `<path d="${def.path}" fill="none" stroke="${stroke}" stroke-width="1.8" ${dashed} marker-end="url(#${marker})"></path>
547
+ ${labelMarkup}`;
548
+ }
549
+
550
+ const DIAGRAM_ARROW_DEFS = `
551
+ <defs>
552
+ <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
553
+ <polygon points="0 0, 10 5, 0 10" fill="#64748b"></polygon>
554
+ </marker>
555
+ <marker id="arrowhead-error" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
556
+ <polygon points="0 0, 10 5, 0 10" fill="#dc2626"></polygon>
557
+ </marker>
558
+ </defs>`;
559
+
560
+ function renderFlowEdges(layout) {
561
+ const labelSlots = new Map();
562
+ return layout.validEdges.map((edge, index) => {
563
+ const source = layout.positioned.get(edge.from);
564
+ const target = layout.positioned.get(edge.to);
565
+ if (!source || !target) return '';
566
+ const slotKey = `${source.layer}->${target.layer === source.layer ? `${edge.to}` : target.layer}`;
567
+ const slot = labelSlots.get(slotKey) ?? 0;
568
+ labelSlots.set(slotKey, slot + 1);
569
+ const routed = routeFlowEdge(source, target, index, slot * 16);
570
+ return renderArrow({ ...routed, label: edge.label, type: edge.type });
571
+ }).join('\n');
572
+ }
573
+
574
+ function renderLegend(entries, originY) {
575
+ let cursorX = 54;
576
+ const items = entries.map(({ color, label }) => {
577
+ const markup = `<rect x="${cursorX}" y="${originY + 14}" width="12" height="12" rx="3" fill="${color}"></rect><text x="${cursorX + 20}" y="${originY + 24}" class="legend-label">${escapeHtml(label)}</text>`;
578
+ cursorX += 20 + textUnits(label) * 7 + 36;
579
+ return markup;
580
+ }).join('');
581
+ return `
582
+ <g>
583
+ <text x="54" y="${originY}" class="legend-label">图例</text>
584
+ ${items}
585
+ </g>
586
+ `;
355
587
  }
356
588
 
357
589
  function resolveProductLayerTitle(productType) {
@@ -363,7 +595,7 @@ function resolveProductLayerTitle(productType) {
363
595
 
364
596
  function buildArchitectureComponents(snapshot) {
365
597
  const { sections } = snapshot;
366
- const reviewTarget = sections.handoff.targetSystem ?? 'OpenSpec';
598
+ const reviewTarget = sections.handoff.targetSystem ?? 'OpenPrd';
367
599
  return [
368
600
  {
369
601
  id: 'users',
@@ -487,64 +719,66 @@ export function buildArchitectureDiagramModel(snapshot) {
487
719
  productType: snapshot.productType ?? '未分类',
488
720
  owner: snapshot.owner,
489
721
  versionId: snapshot.versionId,
490
- targetSystem: snapshot.sections.handoff.targetSystem ?? 'OpenSpec',
722
+ targetSystem: snapshot.sections.handoff.targetSystem ?? 'OpenPrd',
491
723
  reviewStatus: normalizeReviewStatus(snapshot?.reviewStatus),
492
724
  },
493
725
  };
494
726
  }
495
727
 
496
728
  export function renderArchitectureDiagramHtml(model) {
497
- const layouts = {
498
- users: { x: 390, y: 48, width: 300, height: 96 },
499
- experience: { x: 70, y: 228, width: 290, height: 120 },
500
- core: { x: 395, y: 228, width: 290, height: 120 },
501
- delivery: { x: 720, y: 228, width: 290, height: 120 },
502
- integrations: { x: 180, y: 448, width: 290, height: 120 },
503
- governance: { x: 610, y: 448, width: 290, height: 120 },
504
- };
505
- const fallbackLayouts = [
506
- { x: 390, y: 48, width: 300, height: 96 },
507
- { x: 70, y: 228, width: 290, height: 120 },
508
- { x: 395, y: 228, width: 290, height: 120 },
509
- { x: 720, y: 228, width: 290, height: 120 },
510
- { x: 180, y: 448, width: 290, height: 120 },
511
- { x: 610, y: 448, width: 290, height: 120 },
512
- ];
513
-
514
- const arrows = [
515
- { path: 'M 540 144 C 540 182, 215 176, 215 228', label: model.flows[0]?.label ?? '用户流程', labelX: 312, labelY: 176, type: model.flows[0]?.type ?? 'standard' },
516
- { path: 'M 360 288 L 395 288', label: model.flows[1]?.label ?? '产品动作', labelX: 366, labelY: 276, type: model.flows[1]?.type ?? 'standard' },
517
- { path: 'M 685 288 L 720 288', label: model.flows[4]?.label ?? '成功标准', labelX: 694, labelY: 276, type: model.flows[4]?.type ?? 'standard' },
518
- { path: 'M 540 348 C 540 392, 325 396, 325 448', label: model.flows[2]?.label ?? '依赖', labelX: 300, labelY: 392, type: model.flows[2]?.type ?? 'standard' },
519
- { path: 'M 540 348 C 540 392, 755 396, 755 448', label: model.flows[3]?.label ?? '约束', labelX: 692, labelY: 392, type: model.flows[3]?.type ?? 'security' },
520
- { path: 'M 470 568 C 470 610, 820 610, 820 348', label: model.flows[5]?.label ?? '运营就绪', labelX: 596, labelY: 614, type: model.flows[5]?.type ?? 'standard' },
521
- { path: 'M 820 568 C 920 612, 920 416, 865 348', label: model.flows[6]?.label ?? '评审确认', labelX: 850, labelY: 612, type: model.flows[6]?.type ?? 'security' },
522
- ];
523
-
524
- const componentMarkup = model.components
525
- .map((component, index) => renderBox(component, layouts[component.id] ?? fallbackLayouts[index] ?? fallbackLayouts.at(-1)))
729
+ const components = Array.isArray(model.components) ? model.components : [];
730
+ const flows = Array.isArray(model.flows) ? model.flows : [];
731
+ const layout = layoutFlowDiagram(components, flows.map((flow) => ({
732
+ from: flow.source,
733
+ to: flow.target,
734
+ label: flow.label,
735
+ type: flow.type,
736
+ })), { boxWidth: 256 });
737
+
738
+ const componentMarkup = components
739
+ .map((component) => {
740
+ const entry = layout.positioned.get(component.id);
741
+ return entry ? renderBox(component, entry) : '';
742
+ })
526
743
  .join('\n');
527
- const arrowMarkup = arrows.map(renderArrow).join('\n');
744
+ const edgeMarkup = renderFlowEdges(layout);
745
+
746
+ const internalEntries = components
747
+ .filter((component) => component.type !== 'external')
748
+ .map((component) => layout.positioned.get(component.id))
749
+ .filter(Boolean);
750
+ let boundaryMarkup = '';
751
+ let boundaryBottom = layout.height - 96;
752
+ if (internalEntries.length > 0) {
753
+ const minX = Math.min(...internalEntries.map((entry) => entry.x)) - 26;
754
+ const minY = Math.min(...internalEntries.map((entry) => entry.y)) - 34;
755
+ const maxX = Math.max(...internalEntries.map((entry) => entry.x + entry.width)) + 26;
756
+ const maxY = Math.max(...internalEntries.map((entry) => entry.y + entry.height)) + 26;
757
+ boundaryBottom = Math.max(boundaryBottom, maxY);
758
+ boundaryMarkup = `
759
+ <rect x="${minX}" y="${minY}" width="${maxX - minX}" height="${maxY - minY}" rx="18" fill="none" stroke="#b45309" stroke-opacity="0.55" stroke-width="1.5" stroke-dasharray="8,5"></rect>
760
+ <text x="${minX + 18}" y="${minY + 22}" class="legend-label">方案边界</text>
761
+ `;
762
+ }
763
+
764
+ const legendY = boundaryBottom + 34;
765
+ const legendMarkup = renderLegend([
766
+ { color: '#2563eb', label: '体验' },
767
+ { color: '#0f766e', label: '核心逻辑' },
768
+ { color: '#4f46e5', label: '验证' },
769
+ { color: '#b45309', label: '依赖' },
770
+ { color: '#dc2626', label: '约束' },
771
+ { color: '#94a3b8', label: '外部/用户' },
772
+ ], legendY);
773
+
774
+ const svgHeight = legendY + 48;
528
775
  const svgMarkup = `
529
- <svg viewBox="0 0 1080 720" role="img" aria-label="${escapeHtml(model.title)}">
530
- <defs>
531
- <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
532
- <polygon points="0 0, 10 5, 0 10" fill="#7dd3fc"></polygon>
533
- </marker>
534
- </defs>
535
- <rect x="40" y="172" width="1000" height="430" rx="18" fill="none" stroke="#f59e0b" stroke-opacity="0.55" stroke-width="1.5" stroke-dasharray="8,5"></rect>
536
- <text x="58" y="194" class="legend-label">方案边界</text>
537
- ${arrowMarkup}
776
+ <svg viewBox="0 0 ${layout.width} ${svgHeight}" role="img" aria-label="${escapeHtml(model.title)}">
777
+ ${DIAGRAM_ARROW_DEFS}
778
+ ${boundaryMarkup}
779
+ ${edgeMarkup}
538
780
  ${componentMarkup}
539
- <g>
540
- <text x="54" y="652" class="legend-label">图例</text>
541
- <rect x="54" y="666" width="12" height="12" rx="3" fill="#22d3ee"></rect><text x="74" y="676" class="legend-label">体验</text>
542
- <rect x="182" y="666" width="12" height="12" rx="3" fill="#34d399"></rect><text x="202" y="676" class="legend-label">核心逻辑</text>
543
- <rect x="330" y="666" width="12" height="12" rx="3" fill="#c084fc"></rect><text x="350" y="676" class="legend-label">验证</text>
544
- <rect x="476" y="666" width="12" height="12" rx="3" fill="#f59e0b"></rect><text x="496" y="676" class="legend-label">依赖</text>
545
- <rect x="640" y="666" width="12" height="12" rx="3" fill="#fb7185"></rect><text x="660" y="676" class="legend-label">约束</text>
546
- <rect x="798" y="666" width="12" height="12" rx="3" fill="#94a3b8"></rect><text x="818" y="676" class="legend-label">外部/用户</text>
547
- </g>
781
+ ${legendMarkup}
548
782
  </svg>
549
783
  `;
550
784
 
@@ -678,69 +912,54 @@ export function buildProductFlowDiagramModel(snapshot) {
678
912
  productType: snapshot.productType ?? '未分类',
679
913
  owner: snapshot.owner,
680
914
  versionId: snapshot.versionId,
681
- targetSystem: snapshot.sections.handoff.targetSystem ?? 'OpenSpec',
915
+ targetSystem: snapshot.sections.handoff.targetSystem ?? 'OpenPrd',
682
916
  reviewStatus: normalizeReviewStatus(snapshot?.reviewStatus),
683
917
  },
684
918
  };
685
919
  }
686
920
 
687
921
  export function renderProductFlowDiagramHtml(model) {
688
- const layouts = {
689
- entry: { x: 90, y: 250, width: 190, height: 112 },
690
- experience: { x: 330, y: 250, width: 210, height: 112 },
691
- decision: { x: 590, y: 245, width: 180, height: 122 },
692
- success: { x: 820, y: 140, width: 180, height: 112 },
693
- failure: { x: 820, y: 360, width: 180, height: 122 },
694
- };
695
- const fallbackLayouts = [
696
- { x: 90, y: 250, width: 190, height: 112 },
697
- { x: 330, y: 250, width: 210, height: 112 },
698
- { x: 590, y: 245, width: 180, height: 122 },
699
- { x: 820, y: 140, width: 180, height: 112 },
700
- { x: 820, y: 360, width: 180, height: 122 },
701
- ];
702
-
703
- const laneMarkup = [
704
- { y: 118, label: '用户/触发' },
705
- { y: 220, label: '核心流程' },
706
- { y: 438, label: '结果/恢复' },
707
- ].map((lane) => `
708
- <g>
709
- <line x1="70" y1="${lane.y}" x2="1020" y2="${lane.y}" stroke="#334155" stroke-width="1" stroke-dasharray="6,4"></line>
710
- <text x="74" y="${lane.y - 8}" class="legend-label">${escapeHtml(lane.label)}</text>
711
- </g>
712
- `).join('\n');
713
-
714
- const stepMarkup = model.steps
715
- .map((step, index) => renderBox(step, layouts[step.id] ?? fallbackLayouts[index] ?? fallbackLayouts.at(-1)))
922
+ const steps = Array.isArray(model.steps) ? model.steps : [];
923
+ const transitions = Array.isArray(model.transitions) ? model.transitions : [];
924
+ const layout = layoutFlowDiagram(steps, transitions.map((transition) => ({
925
+ from: transition.from,
926
+ to: transition.to,
927
+ label: transition.label,
928
+ type: transition.type,
929
+ })));
930
+
931
+ const stepMarkup = steps
932
+ .map((step) => {
933
+ const entry = layout.positioned.get(step.id);
934
+ return entry ? renderBox(step, entry) : '';
935
+ })
716
936
  .join('\n');
717
- const transitions = [
718
- { path: 'M 280 306 L 330 306', label: model.transitions[0]?.label ?? '开始', labelX: 288, labelY: 294, type: model.transitions[0]?.type ?? 'standard' },
719
- { path: 'M 540 306 L 590 306', label: model.transitions[1]?.label ?? '核心步骤', labelX: 546, labelY: 294, type: model.transitions[1]?.type ?? 'standard' },
720
- { path: 'M 770 282 C 800 240, 820 220, 820 196', label: model.transitions[2]?.label ?? '成功路径', labelX: 786, labelY: 226, type: model.transitions[2]?.type ?? 'standard' },
721
- { path: 'M 770 330 C 800 370, 820 400, 820 420', label: model.transitions[3]?.label ?? '失败路径', labelX: 786, labelY: 388, type: model.transitions[3]?.type ?? 'error_path' },
722
- ].map(renderArrow).join('\n');
937
+ const edgeMarkup = renderFlowEdges(layout);
723
938
 
939
+ const boundary = {
940
+ x: layout.config.marginX - 24,
941
+ y: layout.config.marginY - 36,
942
+ width: layout.width - (layout.config.marginX - 24) * 2,
943
+ height: layout.height - layout.config.marginY - 36,
944
+ };
945
+ const legendY = boundary.y + boundary.height + 30;
946
+ const legendMarkup = renderLegend([
947
+ { color: '#2563eb', label: '用户动作' },
948
+ { color: '#0f766e', label: '系统处理' },
949
+ { color: '#b45309', label: '决策' },
950
+ { color: '#4f46e5', label: '成功' },
951
+ { color: '#dc2626', label: '错误/恢复' },
952
+ ], legendY);
953
+
954
+ const svgHeight = legendY + 48;
724
955
  const svgMarkup = `
725
- <svg viewBox="0 0 1080 720" role="img" aria-label="${escapeHtml(model.title)}">
726
- <defs>
727
- <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
728
- <polygon points="0 0, 10 5, 0 10" fill="#7dd3fc"></polygon>
729
- </marker>
730
- </defs>
731
- <rect x="56" y="92" width="968" height="520" rx="18" fill="none" stroke="#f59e0b" stroke-opacity="0.45" stroke-width="1.5" stroke-dasharray="8,5"></rect>
732
- <text x="74" y="118" class="legend-label">产品流程边界</text>
733
- ${laneMarkup}
734
- ${transitions}
956
+ <svg viewBox="0 0 ${layout.width} ${svgHeight}" role="img" aria-label="${escapeHtml(model.title)}">
957
+ ${DIAGRAM_ARROW_DEFS}
958
+ <rect x="${boundary.x}" y="${boundary.y}" width="${boundary.width}" height="${boundary.height}" rx="18" fill="none" stroke="#b45309" stroke-opacity="0.45" stroke-width="1.5" stroke-dasharray="8,5"></rect>
959
+ <text x="${boundary.x + 18}" y="${boundary.y + 24}" class="legend-label">产品流程边界</text>
960
+ ${edgeMarkup}
735
961
  ${stepMarkup}
736
- <g>
737
- <text x="54" y="652" class="legend-label">图例</text>
738
- <rect x="54" y="666" width="12" height="12" rx="3" fill="#22d3ee"></rect><text x="74" y="676" class="legend-label">用户动作</text>
739
- <rect x="196" y="666" width="12" height="12" rx="3" fill="#34d399"></rect><text x="216" y="676" class="legend-label">系统处理</text>
740
- <rect x="372" y="666" width="12" height="12" rx="3" fill="#f59e0b"></rect><text x="392" y="676" class="legend-label">决策</text>
741
- <rect x="516" y="666" width="12" height="12" rx="3" fill="#c084fc"></rect><text x="536" y="676" class="legend-label">成功</text>
742
- <rect x="648" y="666" width="12" height="12" rx="3" fill="#fb7185"></rect><text x="668" y="676" class="legend-label">错误/恢复</text>
743
- </g>
962
+ ${legendMarkup}
744
963
  </svg>
745
964
  `;
746
965