@stacksjs/defaults 0.75.361 → 0.75.366

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 (210) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +5 -5
  3. package/ai/skills/stacks-payments/SKILL.md +10 -0
  4. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  5. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  6. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  7. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  8. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  9. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  10. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  11. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  12. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  16. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  17. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  18. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  19. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  20. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  21. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  22. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  24. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  27. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  28. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  29. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  32. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  37. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  38. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  39. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  40. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  42. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  43. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  56. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  57. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  58. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  59. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  69. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  70. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  111. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  120. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  121. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  122. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  124. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  125. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  126. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  127. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  128. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  129. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  130. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  134. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  141. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  147. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  149. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  175. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  186. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  187. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  188. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  189. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  190. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  191. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  192. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  194. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  195. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  196. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  199. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  201. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  205. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  206. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  207. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  208. package/ai/skills/stacks-testing/SKILL.md +8 -1
  209. package/ide/vscode/package.json +1 -1
  210. package/package.json +2 -2
@@ -3,11 +3,12 @@ import { fileURLToPath } from 'node:url';
3
3
  import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
4
4
  import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
5
5
  import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
6
- import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
7
- import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, routePointsValue, asArray, isFinitePoint } from '../shared/geometry.mjs';
6
+ import { legendFootprint, measureLegend, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
7
+ import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, cleanLabelCanvasContainmentProblems, routePointsValue, asArray, isFinitePoint, edgeLabelAccent } from '../shared/geometry.mjs';
8
8
  import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
9
9
  import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
10
10
  import { translateMessage as i18nText } from '../shared/i18n.mjs';
11
+ import { DESKTOP_READER_DIAGRAM_WIDTH, MIN_PROJECTED_NODE_TEXT_PX, minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs';
11
12
 
12
13
  const participantTextFit = {
13
14
  sublabelPreferred: 7,
@@ -15,54 +16,337 @@ const participantTextFit = {
15
16
  };
16
17
 
17
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
- const { diagram: sequence, template, outPath } = await loadDiagramWithBrandMarks({
19
+ const { diagram: sequence, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
19
20
  rendererDir: __dirname,
20
21
  diagramType: 'sequence',
21
22
  defaultExample: 'cache-miss-request.sequence.json'
22
23
  });
23
24
 
24
- const viewBox = sequence.meta?.viewBox || [920, 760];
25
- // The timeline scales with viewBox height: a taller viewBox gains message room,
26
- // a shorter one shrinks the readable band (validated below) instead of clipping.
25
+ const LEGEND_CATALOG = [
26
+ { kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8 },
27
+ { kind: 'return', className: 'a-default', marker: 'arrowhead', dash: '3,5' },
28
+ { kind: 'security', className: 'a-security', marker: 'arrowhead-security' },
29
+ { kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed' },
30
+ { kind: 'default', className: 'a-default', marker: 'arrowhead' },
31
+ ].map((entry) => ({
32
+ ...entry,
33
+ interactive: false,
34
+ swatchWidth: 34,
35
+ swatchGap: 9,
36
+ label: i18nText(sequence.meta.locale, `legend.sequence.${entry.kind}`),
37
+ }));
38
+
39
+ function legendEntries() {
40
+ const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
41
+ return resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
42
+ }
43
+
44
+ // The legend sits below the timeline content: the last message and its note,
45
+ // activation bars, and segment frames. Its block starts LEGEND_CONTENT_GAP
46
+ // below that content; from the block top to the canvas bottom a one-row legend
47
+ // needs LEGEND_BLOCK_HEIGHT (title glyphs, row, and the 54px baseline inset).
48
+ const LEGEND_CONTENT_GAP = 12;
49
+ const LEGEND_BLOCK_HEIGHT = 86;
50
+
51
+ // A renderer-sized spread canvas widens until every participant label fits,
52
+ // but never past the width where 7px sublabels would project below the
53
+ // desktop reading minimum; an inherently crowded row still fails below.
54
+ const spreadParticipantWidth = (canvasWidth) => Math.max(86,
55
+ Math.min(190, Math.round((canvasWidth - 124) / Math.max(1, asArray(sequence.participants).length)) - 24));
56
+ function spreadColumnGeometry(canvasWidth, width = spreadParticipantWidth(canvasWidth)) {
57
+ const count = Math.max(1, asArray(sequence.participants).length);
58
+ const span = count * width + (count - 1) * 16;
59
+ const margin = Math.max(40, Math.min(62, canvasWidth - 40 - span));
60
+ return { width, margin, gap: count > 1 ? Math.max(width + 16, (canvasWidth - 40 - margin - width) / (count - 1)) : 108 };
61
+ }
62
+ // Evaluate the same spread coordinates and full masks used by the renderer;
63
+ // short participant names alone do not imply a compact timeline will fit.
64
+ function timelineLabelsFit(canvasWidth) {
65
+ const { width, margin, gap } = spreadColumnGeometry(canvasWidth);
66
+ const centers = new Map(asArray(sequence.participants).map((participant, index) => [participant.id, margin + width / 2 + index * gap]));
67
+ const unitWidth = sequence.meta?.quality_profile === 'showcase' ? 6.6 : 5.2;
68
+ return asArray(sequence.messages).every((message) => {
69
+ if (!centers.has(message.from) || !centers.has(message.to)) return true;
70
+ const center = (centers.get(message.from) + centers.get(message.to)) / 2;
71
+ const halfWidth = Math.max(34, textUnits(message.label) * unitWidth + 12) / 2;
72
+ return center - halfWidth >= 0 && center + halfWidth <= canvasWidth;
73
+ }) && asArray(sequence.segments).every((segment) => 56 + Math.max(42, textUnits(segment.label) * 5.2 + 14) <= canvasWidth - 48);
74
+ }
75
+ const readableCanvasWidth = Math.floor(DESKTOP_READER_DIAGRAM_WIDTH * participantTextFit.sublabelPreferred / MIN_PROJECTED_NODE_TEXT_PX);
76
+ function automaticCanvasWidth() {
77
+ const participantsFit = (width) => asArray(sequence.participants).every((participant) => (
78
+ textUnits(participant.label) * 6.8 <= width + 6
79
+ && (!participant.sublabel || minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelMinimum) <= availableNodeTextWidth(width))
80
+ ));
81
+ const count = Math.max(1, asArray(sequence.participants).length);
82
+ const columnFitMode = sequence.meta?.column_fit || 'spread';
83
+ if (columnFitMode !== 'spread') return 920;
84
+ if (participantsFit(spreadParticipantWidth(920))) {
85
+ // Few-participant automatic canvases: keep readable box sizes but drop the
86
+ // empty gutters a 920px spread leaves beside 2–3 lifelines. Authored
87
+ // viewBoxes and the widen-for-labels path below are unchanged.
88
+ if (count <= 3) {
89
+ for (const candidate of [560, 640, 720, 800, 920]) {
90
+ const box = spreadParticipantWidth(candidate);
91
+ const span = count * box + (count - 1) * 16;
92
+ if (box >= 86 && participantsFit(box) && candidate - 80 >= span && timelineLabelsFit(candidate)) return candidate;
93
+ }
94
+ }
95
+ return 920;
96
+ }
97
+ const needed = Math.max(...asArray(sequence.participants).map((participant) => Math.max(
98
+ textUnits(participant.label) * 6.8 - 6,
99
+ participant.sublabel ? minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelPreferred) + 86 - availableNodeTextWidth(86) : 0,
100
+ )));
101
+ return Math.min(readableCanvasWidth, Math.max(920, Math.ceil((Math.min(190, needed) + 25) * count + 124)));
102
+ }
103
+ const automaticWidth = sequence.meta?.viewBox ? null : automaticCanvasWidth();
104
+
105
+ // Column geometry depends only on the canvas width, which is known before the
106
+ // automatic height is: notes wrap inside lane gaps, and their wrapped height
107
+ // feeds that automatic height.
27
108
  // `column_fit: "spread"` widens the lanes with the viewBox instead of keeping
28
109
  // the fixed 108px gap, so a wide canvas gains column distance and label room
29
- // rather than dead space on the right. The default stays "fixed" so existing
30
- // diagrams keep their coordinates.
31
- const columnFit = sequence.meta?.column_fit === 'spread' ? 'spread' : 'fixed';
110
+ // rather than dead space on the right. Spread is the default on both automatic
111
+ // and authored canvases; explicit fixed retains historical coordinates.
112
+ const canvasWidth = sequence.meta?.viewBox?.[0] ?? automaticWidth;
113
+ const columnFit = sequence.meta?.column_fit || 'spread';
32
114
  const participantCount = Math.max(1, asArray(sequence.participants).length);
33
- const sideMargin = 62;
34
- const participantW = columnFit === 'spread'
35
- ? Math.max(86, Math.min(190, Math.round((viewBox[0] - sideMargin * 2) / participantCount) - 24))
36
- : 86;
115
+ // Resolve the actual final-canvas text floor before choosing header geometry.
116
+ // Notes and automatic height must use the resulting column gaps below.
117
+ const readableMessages = sequence.meta?.quality_profile === 'showcase';
118
+ const readableAutomaticSublabel = readableMessages && automaticWidth !== null && columnFit === 'spread';
119
+ const readableSublabelMinimum = readableAutomaticSublabel
120
+ ? Math.max(participantTextFit.sublabelMinimum, Math.ceil(minimumReadableSourceTextPx(canvasWidth) * 10) / 10)
121
+ : participantTextFit.sublabelMinimum;
122
+ const readableSublabelPreferred = Math.max(participantTextFit.sublabelPreferred, readableSublabelMinimum);
123
+
124
+ function participantSpreadGeometry() {
125
+ const nominal = spreadColumnGeometry(canvasWidth);
126
+ const participants = asArray(sequence.participants);
127
+ // Preserve every already-fitting row and all authored/fixed geometry. Only
128
+ // a failed primary label opens this bounded use of spare horizontal room.
129
+ if (automaticWidth === null || columnFit !== 'spread'
130
+ || !participants.some((participant) => textUnits(participant.label) * 6.8 > nominal.width + 6)) return nominal;
131
+ const required = Math.ceil(Math.max(nominal.width, ...participants.map((participant) => Math.max(
132
+ textUnits(participant.label) * 6.8 - 6,
133
+ participant.sublabel ? minimumNodeTextWidth(participant.sublabel, readableSublabelMinimum)
134
+ + nominal.width - availableNodeTextWidth(nominal.width) : 0,
135
+ ))));
136
+ const capacity = Math.floor(Math.min(190, (canvasWidth - 80 - (participantCount - 1) * 16) / participantCount));
137
+ // Keep the existing real failures if ordinary text or the brand rail cannot
138
+ // fit the minimum candidate. Do not clamp a demand into infeasible geometry.
139
+ if (required > capacity || participants.some((participant) => brandTopRailProblem(participant, required, 8, 'Participant'))) return nominal;
140
+ return spreadColumnGeometry(canvasWidth, required);
141
+ }
142
+
143
+ const preferredSideMargin = 62;
144
+ const spreadGeometry = participantSpreadGeometry();
145
+ const participantW = columnFit === 'spread' ? spreadGeometry.width : 86;
146
+ // Narrow feasible frames can reduce the left margin, while ordinary frames
147
+ // keep 62px. Compute card width first so this does not change its sizing rule.
148
+ const sideMargin = columnFit === 'spread'
149
+ ? spreadGeometry.margin
150
+ : preferredSideMargin;
151
+ // Fit the authored width when feasible, preserving a real 16px card gutter.
152
+ // Infeasible frames retain that minimum and fail the capacity check below.
37
153
  const colGap = columnFit === 'spread' && participantCount > 1
38
- ? Math.max(108, (viewBox[0] - 40 - sideMargin - participantW) / (participantCount - 1))
154
+ ? spreadGeometry.gap
39
155
  : 108;
156
+ const leftX = columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin;
157
+ const participantIndexById = new Map(asArray(sequence.participants).map((participant, index) => [participant.id, index]));
158
+
159
+ function participantX(index) {
160
+ return leftX + index * colGap;
161
+ }
162
+
163
+ // A note wraps inside one gap between neighbouring lifelines, so it never
164
+ // crosses an unrelated lifeline or activation bar (#676). Every character
165
+ // stays visible, including in image exports: long unbroken text (a URL or
166
+ // path) breaks after its punctuation, otherwise at any character.
167
+ const NOTE_FONT_SIZE = 7;
168
+ const NOTE_LINE_HEIGHT = NOTE_FONT_SIZE + 4;
169
+ // The first line keeps its original position: 12px past the arrow start,
170
+ // which sits 7px from the lifeline.
171
+ const NOTE_ARROW_INSET = 7;
172
+ const NOTE_TEXT_INSET = 12;
173
+ // Activation bars reach 5px past a lifeline; keep 6px of air beyond them.
174
+ const NOTE_LIFELINE_CLEARANCE = 11;
175
+ // Spread columns share one computed gap, but subtracting floating-point column
176
+ // centres can differ in the last digits; such gaps still count as equal.
177
+ const NOTE_GAP_TOLERANCE = 0.5;
178
+ const NOTE_BREAK_AFTER = new Set(['/', '.', '-', '?', '&', '=', '#', '_']);
179
+ const noteSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
180
+ let noteWordSegmenter;
181
+ const noteLayouts = new Map();
40
182
 
183
+ function noteTextWidth(text) {
184
+ return minimumNodeTextWidth(text, NOTE_FONT_SIZE);
185
+ }
186
+
187
+ // Index after the last punctuation break in `piece`, or its full length.
188
+ function noteBreakIndex(piece) {
189
+ for (let index = piece.length - 1; index > 0; index -= 1) {
190
+ if (NOTE_BREAK_AFTER.has(piece[index - 1])) return index;
191
+ }
192
+ return piece.length;
193
+ }
194
+
195
+ function wrapNoteText(text, width) {
196
+ const original = wrapNoteWords(String(text).match(/\s+|\S+/gu) || [], width);
197
+ if (!/\p{Script=Han}/u.test(text)) return original;
198
+
199
+ // Space-only tokens can waste half a line before a Chinese phrase. Split Han
200
+ // words, preserving other runs (identifiers, URLs, +4) and closing punctuation.
201
+ noteWordSegmenter ??= new Intl.Segmenter(undefined, { granularity: 'word' });
202
+ const words = [];
203
+ for (const token of String(text).match(/\s+|\p{Script=Han}[\p{Script=Han}\p{Mark}]*|[^\s\p{Script=Han}]+/gu) || []) {
204
+ if (/\p{Script=Han}/u.test(token)) {
205
+ for (const { segment } of noteWordSegmenter.segment(token)) words.push(segment);
206
+ } else if (/^[、。,;:!?)】》」』]+$/u.test(token) && words.length) {
207
+ words[words.length - 1] += token;
208
+ } else {
209
+ words.push(token);
210
+ }
211
+ }
212
+ const compact = wrapNoteWords(words, width);
213
+ // Never grow a note or change a same-height wrap merely to rebalance lines.
214
+ return compact.length < original.length ? compact : original;
215
+ }
216
+
217
+ function wrapNoteWords(words, width) {
218
+ const lines = [];
219
+ let line = '';
220
+ const flush = () => {
221
+ const trimmed = line.trimEnd();
222
+ if (trimmed) lines.push(trimmed);
223
+ line = '';
224
+ };
225
+ for (const word of words) {
226
+ if (/^\s+$/u.test(word)) {
227
+ if (line) line += ' ';
228
+ continue;
229
+ }
230
+ if (noteTextWidth(line + word) <= width) {
231
+ line += word;
232
+ continue;
233
+ }
234
+ flush();
235
+ let piece = '';
236
+ for (const { segment } of noteSegmenter.segment(word)) {
237
+ while (piece && noteTextWidth(piece + segment) > width) {
238
+ const cut = noteBreakIndex(piece);
239
+ lines.push(piece.slice(0, cut));
240
+ piece = piece.slice(cut);
241
+ }
242
+ piece += segment;
243
+ }
244
+ line = piece;
245
+ }
246
+ flush();
247
+ return lines;
248
+ }
249
+
250
+ // The widest gap the message spans; equal gaps resolve to the left-most one,
251
+ // where a note has always started, so notes keep their original position.
252
+ function noteGapIndex(fromIndex, toIndex) {
253
+ const low = Math.min(fromIndex, toIndex);
254
+ const high = Math.max(fromIndex, toIndex);
255
+ let best = null;
256
+ for (let gap = low; gap < high; gap += 1) {
257
+ const width = participantX(gap + 1) - participantX(gap);
258
+ if (!best || width > best.width + NOTE_GAP_TOLERANCE) best = { gap, width };
259
+ }
260
+ return best?.gap ?? null;
261
+ }
262
+
263
+ function noteRect(message) {
264
+ const note = noteLayout(message);
265
+ return note && { x: note.x, y: note.top, width: note.width, height: note.bottom - note.top };
266
+ }
267
+
268
+ function noteLayout(message) {
269
+ if (noteLayouts.has(message)) return noteLayouts.get(message);
270
+ let result = null;
271
+ const fromIndex = participantIndexById.get(message.from);
272
+ const toIndex = participantIndexById.get(message.to);
273
+ if (message.note && typeof message.y === 'number' && fromIndex !== undefined && toIndex !== undefined && fromIndex !== toIndex) {
274
+ const gap = noteGapIndex(fromIndex, toIndex);
275
+ const x = participantX(gap) + NOTE_ARROW_INSET + NOTE_TEXT_INSET;
276
+ const maxWidth = Math.max(1, participantX(gap + 1) - NOTE_LIFELINE_CLEARANCE - x);
277
+ const lines = noteTextWidth(message.note) <= maxWidth ? [message.note] : wrapNoteText(message.note, maxWidth);
278
+ const baseline = message.y + 18;
279
+ result = {
280
+ x,
281
+ baseline,
282
+ lines,
283
+ width: Math.max(...lines.map(noteTextWidth)),
284
+ // Include fallback-font ascent in the same bounds used for title clearance.
285
+ top: baseline - NOTE_FONT_SIZE * 1.2,
286
+ bottom: message.y + 22 + (lines.length - 1) * NOTE_LINE_HEIGHT,
287
+ };
288
+ }
289
+ noteLayouts.set(message, result);
290
+ return result;
291
+ }
292
+
293
+ const contentBottom = Math.max(
294
+ 0,
295
+ ...asArray(sequence.messages).map((message) => (message.note
296
+ ? noteLayout(message)?.bottom ?? message.y + 22
297
+ : message.y + 6)),
298
+ ...asArray(sequence.activations).map((activation) => activation.to),
299
+ ...asArray(sequence.segments).map((segment) => segment.to),
300
+ );
301
+ function legendRequiredHeight(width) {
302
+ const entries = legendEntries();
303
+ if (!entries.length) return 0;
304
+ return Math.ceil(contentBottom + LEGEND_CONTENT_GAP + LEGEND_BLOCK_HEIGHT
305
+ + legendFootprint(entries, { width: width - 80 }).extraHeight);
306
+ }
307
+ // A renderer-sized canvas grows to keep the legend clear of late messages and
308
+ // shrinks when the timeline is short. Keep a readable minimum band (lifelineTop
309
+ // 142 + 120px timeline + 65px footer = 327); honor authored canvases below.
310
+ const SEQUENCE_MIN_AUTO_HEIGHT = 327;
311
+ // Timeline clearance is independent of legend visibility. Messages need 18px
312
+ // before the lifeline bottom; notes, activations and frames need their footer.
313
+ const timelineRequiredHeight = Math.max(contentBottom + 65,
314
+ ...asArray(sequence.messages).map((message) => message.y + 18 + 65));
315
+ const automaticHeight = Math.max(SEQUENCE_MIN_AUTO_HEIGHT, timelineRequiredHeight, legendRequiredHeight(automaticWidth));
316
+ const viewBox = sequence.meta?.viewBox || [automaticWidth, automaticHeight];
317
+ // The timeline scales with viewBox height: a taller viewBox gains message room,
318
+ // a shorter one shrinks the readable band (validated below) instead of clipping.
319
+
320
+ // Showcase is the fast-authoring default; standard retains legacy label geometry.
321
+ const messageFontSize = readableMessages ? 11 : 9;
322
+ const messageUnitWidth = readableMessages ? 6.6 : 5.2;
41
323
  const layout = {
42
324
  topY: 72,
43
325
  participantW,
44
- participantH: 54,
326
+ // Keep a separate top rail for the 11px semantic sigil and 16px brand mark.
327
+ // Literal labels retain their fitted font size and full authored wording.
328
+ participantH: 60,
329
+ participantLabelY: 36,
330
+ participantSublabelY: 50,
45
331
  lifelineTop: 142,
46
332
  lifelineBottom: viewBox[1] - 65,
47
333
  legendY: viewBox[1] - 54,
48
- leftX: columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin,
334
+ leftX,
49
335
  colGap,
50
- labelH: 16
336
+ labelH: readableMessages ? 18 : 16,
51
337
  };
52
338
 
53
- const participantBoxWidthNote = columnFit === 'spread'
54
- ? `participant boxes are ${participantW}px for this viewBox width and ${participantCount} participants`
55
- : `participant boxes are a fixed ${participantW}px unless meta.column_fit is "spread"`;
339
+ const participantBoxWidthNote = automaticWidth
340
+ ? `participant boxes are ${participantW}px: the automatic ${viewBox[0]}px canvas cannot widen further without its 7px sublabels falling below the desktop reading minimum, so keep meta.viewBox omitted`
341
+ : columnFit === 'spread'
342
+ ? `participant boxes are ${participantW}px for this viewBox width and ${participantCount} participants`
343
+ : `participant boxes are a fixed ${participantW}px unless meta.column_fit is "spread"`;
56
344
 
57
345
  const arrowClass = {
58
346
  ...arrowClassMap,
59
347
  return: ['a-default', 'arrowhead']
60
348
  };
61
349
 
62
- function participantX(index) {
63
- return layout.leftX + index * layout.colGap;
64
- }
65
-
66
350
  const participants = new Map(asArray(sequence.participants).map((participant, index) => [
67
351
  participant.id,
68
352
  {
@@ -90,7 +374,7 @@ function messageGeometry(message) {
90
374
  function messageLabelBox(message, relationIndex = null) {
91
375
  const geometry = messageGeometry(message);
92
376
  if (!geometry) return null;
93
- const width = Math.max(34, textUnits(message.label) * 5.2 + 12);
377
+ const width = Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
94
378
  return {
95
379
  relation: message,
96
380
  relationIndex,
@@ -113,19 +397,141 @@ function messageRouteBox(message) {
113
397
  };
114
398
  }
115
399
 
116
- function segmentLabelBox(segment) {
400
+ // Message labels, routes and notes a segment label must not cover, with the
401
+ // source path of each so a failure can name what it hides.
402
+ function segmentLabelOccupants() {
403
+ return asArray(sequence.messages).flatMap((message, messageIndex) => [
404
+ { kind: 'message label', path: `/messages/${messageIndex}/label`, message, rect: messageLabelBox(message) },
405
+ { kind: 'message route', path: `/messages/${messageIndex}/y`, message, rect: messageRouteBox(message) },
406
+ { kind: 'note', path: `/messages/${messageIndex}/note`, message, rect: noteRect(message) },
407
+ ]).filter((occupant) => occupant.rect);
408
+ }
409
+
410
+ function legacySegmentLabelBox(segment) {
117
411
  const labelW = Math.max(42, textUnits(segment.label) * 5.2 + 14);
118
- const occupied = asArray(sequence.messages)
119
- .flatMap((message) => [messageLabelBox(message), messageRouteBox(message)])
120
- .filter(Boolean);
412
+ const occupied = segmentLabelOccupants();
121
413
  const label = { x: 56, y: segment.from - 22, width: labelW, height: 18 };
122
414
  for (let attempt = 0; attempt < 4; attempt += 1) {
123
- if (!occupied.some((rect) => rectsOverlap(label, rect, 2))) break;
415
+ if (!occupied.some(({ rect }) => rectsOverlap(label, rect, 2))) break;
124
416
  label.y -= 22;
125
417
  }
418
+ if (!segmentLabelMisplaced(label, segment)) return label;
419
+ // Abutting segments can leave no room on the border: the badge climbed
420
+ // behind the participant headers or into the previous segment, where it
421
+ // names the wrong phase. Its own frame's top-left corner, clear of messages,
422
+ // names the right one.
423
+ // Another segment's title normally sits on that segment's own top border,
424
+ // so its strip is taken too.
425
+ const otherTitles = asArray(sequence.segments).filter((other) => other !== segment)
426
+ .map((other) => ({ x: 56, y: other.from - 22, width: Math.max(42, textUnits(other.label) * 5.2 + 14), height: 18 }));
427
+ const inside = { x: 56, y: segment.from + 4, width: labelW, height: 18 };
428
+ for (let attempt = 0; attempt < 4 && inside.y + inside.height <= segment.to - 2; attempt += 1) {
429
+ if (!segmentLabelMisplaced(inside, segment)
430
+ && ![...occupied.map(({ rect }) => rect), ...otherTitles, ...participants.values()].some((rect) => rectsOverlap(inside, rect, 2))) return inside;
431
+ inside.y += 22;
432
+ }
126
433
  return label;
127
434
  }
128
435
 
436
+ function segmentLabelBox(segment) {
437
+ const label = legacySegmentLabelBox(segment);
438
+ const headers = [...participants.values()];
439
+ const coveredHeaders = headers.filter((header) => rectsOverlap(label, header, 0));
440
+ if (!coveredHeaders.length || label.y + label.height > segment.from) return label;
441
+
442
+ const others = asArray(sequence.segments).filter((other) => other !== segment);
443
+ // Only the unique first, non-overlapping phase gets this local adjustment.
444
+ // Nested phases keep their authored bounds and the existing title policy.
445
+ if (others.some((other) => other.from <= segment.from
446
+ || (other.from < segment.to && other.to > segment.from))) return label;
447
+
448
+ const candidate = { ...label, y: Math.max(...coveredHeaders.map((header) => header.y + header.height)) + 2 };
449
+ const bottom = candidate.y + candidate.height;
450
+ // The badge must still touch its own top border, before the first message;
451
+ // moving it deep into the phase would name the wrong part of the timeline.
452
+ if (candidate.y > segment.from || bottom < segment.from || bottom > segment.to - 2) return label;
453
+
454
+ const obstacles = [
455
+ ...segmentLabelOccupants().map(({ rect }) => rect),
456
+ ...headers,
457
+ // Use actual legacy placements: another title may have climbed above its
458
+ // nominal slot. Calling the unchanged helper keeps this non-recursive.
459
+ ...others.map(legacySegmentLabelBox),
460
+ ];
461
+ if (!obstacles.some((rect) => rectsOverlap(candidate, rect, 2))) return candidate;
462
+
463
+ // Only this blocked header-clear strip gains a horizontal fallback. Keep
464
+ // successful legacy placements intact, including their activation policy.
465
+ const fallbackObstacles = [
466
+ ...obstacles,
467
+ ...asArray(sequence.activations).flatMap((activation) => {
468
+ const participant = participants.get(activation.participant);
469
+ return participant ? [{
470
+ x: participant.cx - 5, y: activation.from,
471
+ width: 10, height: activation.to - activation.from,
472
+ }] : [];
473
+ }),
474
+ ];
475
+ // Every free interval on this strip starts at the left frame inset or just
476
+ // beyond an obstacle. Scan those finite boundaries instead of pixel steps.
477
+ const boundaries = [...new Set([
478
+ candidate.x,
479
+ ...fallbackObstacles
480
+ .filter((rect) => candidate.y < rect.y + rect.height + 2
481
+ && rect.y < candidate.y + candidate.height + 2)
482
+ .map((rect) => rect.x + rect.width + 2),
483
+ ])].filter((x) => x >= candidate.x && x + candidate.width <= viewBox[0] - 48)
484
+ .sort((a, b) => a - b);
485
+ for (const x of boundaries) {
486
+ const shifted = { ...candidate, x };
487
+ if (!fallbackObstacles.some((rect) => rectsOverlap(shifted, rect, 2))) return shifted;
488
+ }
489
+ return label;
490
+ }
491
+
492
+ // Whether moving one message to `y` keeps it readable: inside the timeline,
493
+ // 28px from messages sharing its horizontal span, and clear of every other
494
+ // label, route and note. Used to offer only repairs that hold.
495
+ function messageFitsAt(messageIndex, y) {
496
+ const messages = asArray(sequence.messages);
497
+ if (y < layout.lifelineTop + 18) return false;
498
+ // An automatic canvas recalculates its height after this edit; an authored
499
+ // canvas must still fit the suggested position without a second repair.
500
+ if (sequence.meta?.viewBox && y > layout.lifelineBottom - 18) return false;
501
+ const span = (message) => {
502
+ const from = participants.get(message.from)?.cx;
503
+ const to = participants.get(message.to)?.cx;
504
+ return [Math.min(from, to), Math.max(from, to)];
505
+ };
506
+ const moved = { ...messages[messageIndex], y };
507
+ const [left, right] = span(moved);
508
+ const movedRects = [messageLabelBox(moved), messageRouteBox(moved), noteRect(moved)].filter(Boolean);
509
+ return messages.every((other, otherIndex) => {
510
+ if (otherIndex === messageIndex || typeof other.y !== 'number') return true;
511
+ const [otherLeft, otherRight] = span(other);
512
+ if (Math.abs(other.y - y) < 28 && left < otherRight && otherLeft < right) return false;
513
+ const otherRects = [messageLabelBox(other), messageRouteBox(other), noteRect(other)].filter(Boolean);
514
+ return !movedRects.some((rect) => otherRects.some((otherRect) => rectsOverlap(rect, otherRect, 2)));
515
+ });
516
+ }
517
+
518
+ // The first occupant the segment label still covers after its upward steps.
519
+ function segmentLabelBlocker(segment) {
520
+ const label = segmentLabelBox(segment);
521
+ return segmentLabelOccupants().find(({ rect }) => rectsOverlap(label, rect, 2)) || null;
522
+ }
523
+
524
+ // A badge on its frame's top border is the intended title. It stops naming
525
+ // that frame once it hides mostly behind the participant headers, or once
526
+ // message labels pushed it up into another segment.
527
+ function segmentLabelMisplaced(label, segment) {
528
+ const half = label.height / 2;
529
+ if (layout.topY + layout.participantH - label.y > half) return true;
530
+ if (label.y >= segment.from - 22) return false;
531
+ return asArray(sequence.segments).some((other) => other !== segment
532
+ && Math.min(label.y + label.height, other.to) - Math.max(label.y, other.from) > half);
533
+ }
534
+
129
535
  const compositionFrames = asArray(sequence.segments).map((segment, index) => ({
130
536
  id: index,
131
537
  label: segment.label,
@@ -147,16 +553,31 @@ function messagePath(message) {
147
553
 
148
554
  function validateSequence() {
149
555
  const problems = [];
556
+ const diagnostics = [];
150
557
  if (participants.size !== asArray(sequence.participants).length) problems.push('Participant ids must be unique.');
151
558
 
152
559
  if (layout.lifelineBottom - layout.lifelineTop < 120) {
153
- problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline - set meta.viewBox[1] to at least ${layout.lifelineTop + 120 + 65}.`);
560
+ problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline — set meta.viewBox[1] to at least ${layout.lifelineTop + 120 + 65}.`);
154
561
  }
155
562
 
156
563
  for (const participant of participants.values()) {
564
+ const participantIndex = asArray(sequence.participants).findIndex((entry) => entry.id === participant.id);
565
+ const participantProblem = (field, message, evidence, supportedFixes) => {
566
+ problems.push(message);
567
+ diagnostics.push({
568
+ code: `sequence/participant-${field}-overflow`, severity: 'error', message,
569
+ subject: { diagramType: 'sequence', nodeId: participant.id, path: `/participants/${participantIndex}/${field}` },
570
+ evidence: { viewBoxWidth: viewBox[0], participantWidth: layout.participantW, ...evidence },
571
+ supportedFixes,
572
+ });
573
+ };
157
574
  const estLabelW = textUnits(participant.label) * 6.8;
158
575
  if (estLabelW > layout.participantW + 6) {
159
- problems.push(`Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than the ${layout.participantW}px participant box - shorten it.`);
576
+ const message = automaticWidth
577
+ ? `Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than component "${participant.id}" — shorten it to at most ${Math.floor((layout.participantW + 6) / 6.8)} text units (CJK counts 2; ${participantBoxWidthNote}).`
578
+ : `Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than component "${participant.id}" (${layout.participantW}px) — shorten the label or widen the participant box.`;
579
+ participantProblem('label', message, { text: participant.label, requiredWidth: estLabelW, availableWidth: layout.participantW + 6 },
580
+ ['shorten the participant label while preserving its role', ...(columnFit === 'fixed' ? ['set meta.column_fit to "spread" if fixed coordinates are not required'] : [])]);
160
581
  }
161
582
  const brandRailProblem = brandTopRailProblem(participant, layout.participantW, 8, 'Participant');
162
583
  if (brandRailProblem) problems.push(brandRailProblem);
@@ -164,23 +585,79 @@ function validateSequence() {
164
585
  // ordinary case, this rejects what it cannot rescue.
165
586
  if (participant.sublabel) {
166
587
  const availableTextW = availableNodeTextWidth(layout.participantW);
167
- const minimumW = minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelMinimum);
588
+ const minimumW = minimumNodeTextWidth(participant.sublabel, readableSublabelMinimum);
168
589
  if (minimumW > availableTextW) {
169
- problems.push(`Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${participantTextFit.sublabelMinimum}px legible minimum, but participant "${participant.id}" provides ${availableTextW}px - shorten the sublabel (${participantBoxWidthNote}).`);
590
+ const minimumDescription = readableAutomaticSublabel
591
+ ? `${readableSublabelMinimum}px minimum that stays readable on this ${viewBox[0]}px canvas`
592
+ : `${readableSublabelMinimum}px legible minimum`;
593
+ const message = `Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${minimumDescription}, but participant "${participant.id}" provides ${availableTextW}px — shorten the sublabel (${participantBoxWidthNote}).`;
594
+ participantProblem('sublabel', message, { text: participant.sublabel, requiredWidth: minimumW, availableWidth: availableTextW, minimumFontPx: readableSublabelMinimum },
595
+ ['shorten the participant sublabel while preserving its role or protocol; move supplementary detail into a card', ...(columnFit === 'fixed' ? ['set meta.column_fit to "spread" if fixed coordinates are not required'] : [])]);
170
596
  }
171
597
  }
172
598
  }
173
599
 
174
- for (const message of asArray(sequence.messages)) {
175
- if (!participants.has(message.from)) problems.push(`Message "${message.label}" references unknown source "${message.from}".`);
176
- if (!participants.has(message.to)) problems.push(`Message "${message.label}" references unknown target "${message.to}".`);
600
+ const messageList = asArray(sequence.messages);
601
+ const participantList = asArray(sequence.participants);
602
+ const participantOrder = new Map(participantList.map((p, index) => [p.id, index]));
603
+ for (const message of messageList) {
604
+ const messageIndex = messageList.indexOf(message);
605
+ for (const [field, endpoint] of [['from', 'source'], ['to', 'target']]) {
606
+ if (participants.has(message[field])) continue;
607
+ const problem = `Message "${message.label}" references unknown ${endpoint} "${message[field]}".`;
608
+ const otherField = field === 'from' ? 'to' : 'from';
609
+ const anchorOrder = participantOrder.get(message[otherField]) ?? 0;
610
+ const candidates = [...participantOrder.keys()]
611
+ .filter((id) => id !== message[otherField])
612
+ .sort((a, b) => Math.abs(participantOrder.get(a) - anchorOrder) - Math.abs(participantOrder.get(b) - anchorOrder));
613
+ diagnostics.push({
614
+ code: 'sequence/unknown-endpoint', severity: 'error', message: problem,
615
+ subject: {
616
+ diagramType: 'sequence',
617
+ message: message.label ?? null,
618
+ path: `/messages/${messageIndex}/${field}`,
619
+ from: message.from,
620
+ to: message.to,
621
+ },
622
+ evidence: { endpoint, unknownNodeId: message[field], availableNodeIds: candidates },
623
+ supportedFixes: candidates.slice(0, 3).map((id) => `set /messages/${messageIndex}/${field} to verified node id "${id}"`),
624
+ });
625
+ problems.push(problem);
626
+ }
177
627
  if (typeof message.y !== 'number') problems.push(`Message "${message.label}" must provide a numeric y.`);
178
628
  if (message.y < layout.lifelineTop + 18 || message.y > layout.lifelineBottom - 18) {
179
- problems.push(`Message "${message.label}" sits outside the readable timeline - keep y between ${layout.lifelineTop + 18} and ${layout.lifelineBottom - 18}.`);
629
+ problems.push(`Message "${message.label}" sits outside the readable timeline — keep y between ${layout.lifelineTop + 18} and ${layout.lifelineBottom - 18}.`);
180
630
  }
181
631
  if (participants.has(message.from) && participants.has(message.to)) {
182
- const distance = Math.abs(participants.get(message.to).cx - participants.get(message.from).cx);
183
- if (distance < 60) problems.push(`Message "${message.label}" spans ${Math.round(distance)}px (minimum 60px) - give its participants more column distance.`);
632
+ if (message.from === message.to) {
633
+ const problem = `Message "${message.label}" is a self-message on participant "${message.from}"; this Sequence renderer supports only messages between distinct participants. Participant spacing cannot repair it. Preserve the internal step's meaning and order in a supported representation such as a note on a real message or a card; do not invent a participant.`;
634
+ diagnostics.push({
635
+ code: 'sequence/self-message-unsupported', severity: 'error', message: problem,
636
+ subject: {
637
+ diagramType: 'sequence',
638
+ message: message.label,
639
+ collection: 'messages',
640
+ index: messageIndex,
641
+ path: `/messages/${messageIndex}`,
642
+ ...(message.id ? { id: message.id } : {}),
643
+ from: message.from,
644
+ to: message.to,
645
+ fromPath: `/messages/${messageIndex}/from`,
646
+ toPath: `/messages/${messageIndex}/to`,
647
+ },
648
+ evidence: {
649
+ participant: message.from,
650
+ participantPath: `/participants/${participantOrder.get(message.from)}`,
651
+ y: message.y,
652
+ supportedMessageGeometry: 'horizontal-between-distinct-participants',
653
+ },
654
+ supportedFixes: [],
655
+ });
656
+ problems.push(problem);
657
+ } else {
658
+ const distance = Math.abs(participants.get(message.to).cx - participants.get(message.from).cx);
659
+ if (distance < 60) problems.push(`Message "${message.label}" spans ${Math.round(distance)}px (minimum 60px) — give its participants more column distance.`);
660
+ }
184
661
  }
185
662
  }
186
663
 
@@ -248,20 +725,20 @@ function validateSequence() {
248
725
  for (let i = 0; i < placed.length; i += 1) {
249
726
  for (let j = i + 1; j < placed.length && placed[j].y - placed[i].y < 28; j += 1) {
250
727
  if (placed[i].x1 < placed[j].x2 && placed[j].x1 < placed[i].x2) {
251
- problems.push(`Messages "${placed[i].label}" and "${placed[j].label}" are less than 28px apart and share horizontal space - spread their y values.`);
728
+ problems.push(`Messages "${placed[i].label}" and "${placed[j].label}" are less than 28px apart and share horizontal space — spread their y values.`);
252
729
  }
253
730
  }
254
731
  }
255
732
 
256
733
  // Label masks can extend well past the arrow span, so check the actual
257
- // label rectangles too - tangent arrows with long labels still collide.
734
+ // label rectangles too — tangent arrows with long labels still collide.
258
735
  const labelRects = asArray(sequence.messages)
259
736
  .map((m, messageIndex) => messageLabelBox(m, messageIndex))
260
737
  .filter(Boolean);
261
738
  for (let i = 0; i < labelRects.length; i += 1) {
262
739
  for (let j = i + 1; j < labelRects.length; j += 1) {
263
740
  if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
264
- problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap - spread their message y values or shorten the labels.`);
741
+ problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — spread their message y values or shorten the labels.`);
265
742
  }
266
743
  }
267
744
  }
@@ -275,36 +752,179 @@ function validateSequence() {
275
752
  profile: sequence.meta?.quality_profile,
276
753
  routeHint: 'spread the message y values, shorten the label, or reorder participants so the adjacent route stays visible'
277
754
  }));
755
+ problems.push(...cleanLabelCanvasContainmentProblems({
756
+ labels: labelRects,
757
+ viewBox,
758
+ diagramType: 'sequence',
759
+ relationCollection: 'messages',
760
+ profile: sequence.meta?.quality_profile,
761
+ routeHint: 'shorten the label, reorder participants, or enlarge meta.viewBox',
762
+ }));
763
+
764
+ // A wrapped note keeps every character (#676), so a note that still reaches a
765
+ // later message or the canvas bottom needs the diagram changed; authored y
766
+ // values and viewBox stay as written. Showcase only, so standard keeps
767
+ // accepting existing diagrams.
768
+ const report = (problem, detail) => {
769
+ problems.push(problem);
770
+ diagnostics.push({ severity: 'error', message: problem, ...detail });
771
+ };
772
+ const bounds = (rect) => ({
773
+ x: Math.round(rect.x * 10) / 10,
774
+ y: Math.round(rect.y * 10) / 10,
775
+ width: Math.round(rect.width * 10) / 10,
776
+ height: Math.round(rect.height * 10) / 10,
777
+ });
778
+ if (readableMessages) {
779
+ const messages = asArray(sequence.messages);
780
+ const noteFloor = layout.lifelineBottom + 20;
781
+ messages.forEach((message, messageIndex) => {
782
+ const note = noteLayout(message);
783
+ if (!note) return;
784
+ const lines = note.lines.length === 1 ? '1 line' : `${note.lines.length} lines`;
785
+ const bottom = Math.ceil(note.bottom);
786
+ const ownRect = noteRect(message);
787
+ const subject = {
788
+ diagramType: 'sequence',
789
+ message: message.label ?? null,
790
+ path: `/messages/${messageIndex}/note`,
791
+ from: message.from,
792
+ to: message.to,
793
+ };
794
+ if (note.bottom > noteFloor) {
795
+ // A visible legend needs its own room below the note as well.
796
+ const requiredHeight = Math.max(Math.ceil(viewBox[1] + note.bottom - noteFloor), legendRequiredHeight(viewBox[0]));
797
+ const fix = sequence.meta?.viewBox
798
+ ? `set /meta/viewBox/1 to ${requiredHeight}`
799
+ : `set /meta/viewBox to [${viewBox[0]}, ${requiredHeight}]`;
800
+ report(`Note on message "${message.label}" wraps to ${lines} and ends at y=${bottom}, below the canvas content limit y=${noteFloor} — set meta.viewBox[1] to at least ${requiredHeight}, move the message up, or shorten the note.`, {
801
+ code: 'sequence/note-canvas-limit',
802
+ subject,
803
+ evidence: { lines: note.lines.length, note: bounds(ownRect), canvasLimitY: noteFloor, viewBoxHeight: viewBox[1], requiredViewBoxHeight: requiredHeight },
804
+ supportedFixes: [fix],
805
+ });
806
+ }
807
+ const blocker = messages
808
+ .map((other, otherIndex) => {
809
+ if (otherIndex === messageIndex || typeof other.y !== 'number' || other.y <= message.y) return null;
810
+ const hit = [
811
+ ['message label', messageLabelBox(other)],
812
+ ['message route', messageRouteBox(other)],
813
+ ['note', noteRect(other)],
814
+ ].find(([, rect]) => rect && rectsOverlap(ownRect, rect, 2));
815
+ return hit ? { message: other, index: otherIndex, kind: hit[0], rect: hit[1] } : null;
816
+ })
817
+ .filter(Boolean)
818
+ .sort((a, b) => a.message.y - b.message.y)[0];
819
+ if (!blocker) return;
820
+ const requiredY = Math.ceil(note.bottom + 22);
821
+ const blockerPath = `/messages/${blocker.index}/y`;
822
+ report(`Note on message "${message.label}" wraps to ${lines} (down to y=${bottom}) and reaches message "${blocker.message.label}" at y=${blocker.message.y} — move "${blocker.message.label}" and later messages down so it sits at y=${requiredY} or below, or shorten the note.`, {
823
+ code: 'sequence/note-overlap',
824
+ subject,
825
+ evidence: {
826
+ lines: note.lines.length,
827
+ note: bounds(ownRect),
828
+ blocker: { message: blocker.message.label ?? null, path: blockerPath, y: blocker.message.y, kind: blocker.kind, bounds: bounds(blocker.rect) },
829
+ requiredY,
830
+ },
831
+ supportedFixes: messageFitsAt(blocker.index, requiredY) ? [`set ${blockerPath} to ${requiredY}`] : [],
832
+ });
833
+ });
834
+ }
278
835
 
279
- for (const segment of asArray(sequence.segments)) {
836
+ asArray(sequence.segments).forEach((segment, segmentIndex) => {
280
837
  if (segment.to <= segment.from) {
281
- problems.push(`Segment "${segment.label}" has invalid y range (from ${segment.from} to ${segment.to}) - "to" must be greater than "from".`);
838
+ problems.push(`Segment "${segment.label}" has invalid y range (from ${segment.from} to ${segment.to}) — "to" must be greater than "from".`);
282
839
  }
283
840
  if (segment.from < layout.topY || segment.to > layout.lifelineBottom + 20) {
284
- problems.push(`Segment "${segment.label}" extends outside the canvas - keep its y range between ${layout.topY} and ${layout.lifelineBottom + 20}.`);
841
+ problems.push(`Segment "${segment.label}" extends outside the canvas — keep its y range between ${layout.topY} and ${layout.lifelineBottom + 20}.`);
842
+ }
843
+ // A frame edge may pass behind a masked message label, but an edge that
844
+ // runs along the arrow itself leaves the reader unable to tell which phase
845
+ // the message belongs to.
846
+ if (sequence.meta?.quality_profile === 'showcase') {
847
+ for (const edge of ['from', 'to']) {
848
+ const border = segment[edge];
849
+ const cut = asArray(sequence.messages).filter((message) => typeof message.y === 'number'
850
+ && Math.abs(border - message.y) < 4);
851
+ for (const message of cut) {
852
+ const problem = `Segment "${segment.label}" ${edge === 'from' ? 'top' : 'bottom'} edge at y ${border} runs along message "${message.label}" (arrow at y ${message.y}) — move the edge to at least ${message.y + 4} to leave the message above it, or to at most ${message.y - 4} to leave it below.`;
853
+ const segmentIndex = asArray(sequence.segments).indexOf(segment);
854
+ problems.push(problem);
855
+ diagnostics.push({
856
+ code: 'sequence/segment-message-border-run', severity: 'error', message: problem,
857
+ subject: { diagramType: 'sequence', collection: 'segments', index: segmentIndex, path: `/segments/${segmentIndex}/${edge}` },
858
+ evidence: { segmentLabel: segment.label, edge, borderY: border, messageIndex: messageList.indexOf(message), messageLabel: message.label, messageY: message.y, minimumClearancePx: 4 },
859
+ supportedFixes: [`set /segments/${segmentIndex}/${edge} to at least ${message.y + 4} to leave the message above it`, `set /segments/${segmentIndex}/${edge} to at most ${message.y - 4} to leave the message below it`],
860
+ });
861
+ }
862
+ }
285
863
  }
286
864
  const labelBox = segmentLabelBox(segment);
865
+ for (const other of asArray(sequence.segments)) {
866
+ if (other === segment || asArray(sequence.segments).indexOf(other) < asArray(sequence.segments).indexOf(segment)) continue;
867
+ if (rectsOverlap(labelBox, segmentLabelBox(other), 0)) {
868
+ problems.push(`Segment labels "${segment.label}" and "${other.label}" overlap — leave more room between the segments' messages near their shared border, or shorten a label.`);
869
+ }
870
+ }
287
871
  const availableWidth = Math.max(0, viewBox[0] - 48 - labelBox.x);
288
872
  if (labelBox.x + labelBox.width > viewBox[0] - 48) {
289
873
  const requiredWidth = Math.ceil(labelBox.x + labelBox.width + 48);
290
- problems.push(`Segment "${segment.label}" label (~${Math.round(labelBox.width)}px) exceeds the segment frame's available width (${availableWidth}px) - shorten the label or increase meta.viewBox[0] to at least ${requiredWidth}.`);
874
+ problems.push(`Segment "${segment.label}" label (~${Math.round(labelBox.width)}px) exceeds the segment frame's available width (${availableWidth}px) — shorten the label or increase meta.viewBox[0] to at least ${requiredWidth}.`);
291
875
  }
292
- }
876
+ // Segment labels are drawn above messages and notes and step up to stay
877
+ // clear of them; in showcase, a label left covering any of them fails.
878
+ const covered = readableMessages ? segmentLabelBlocker(segment) : null;
879
+ if (covered) {
880
+ // Offer the nearest start whose label lands clear, inside the segment.
881
+ let clearFrom = null;
882
+ for (let distance = 1; distance <= 300 && clearFrom === null; distance += 1) {
883
+ for (const candidate of [segment.from + distance, segment.from - distance]) {
884
+ if (candidate < layout.topY || candidate >= segment.to) continue;
885
+ if (!segmentLabelBlocker({ ...segment, from: candidate })) {
886
+ clearFrom = candidate;
887
+ break;
888
+ }
889
+ }
890
+ }
891
+ const fromPath = `/segments/${segmentIndex}/from`;
892
+ report(`Segment "${segment.label}" label would cover the ${covered.kind} of message "${covered.message.label}" — move the segment start (from: ${segment.from}) or that message so they are further apart${covered.kind === 'note' ? ', or shorten the note' : ''}.`, {
893
+ code: 'sequence/segment-label-overlap',
894
+ subject: { diagramType: 'sequence', segment: segment.label ?? null, path: fromPath },
895
+ evidence: {
896
+ label: bounds(labelBox),
897
+ attempts: 4,
898
+ covered: { kind: covered.kind, message: covered.message.label ?? null, path: covered.path, bounds: bounds(covered.rect) },
899
+ },
900
+ supportedFixes: clearFrom === null ? [] : [`set ${fromPath} to ${clearFrom}`],
901
+ });
902
+ }
903
+ });
293
904
 
294
905
  for (const activation of asArray(sequence.activations)) {
295
906
  if (!participants.has(activation.participant)) problems.push(`Activation references unknown participant "${activation.participant}".`);
296
- if (activation.to <= activation.from) problems.push(`Activation for "${activation.participant}" has invalid time range - "to" must be greater than "from".`);
907
+ if (activation.to <= activation.from) problems.push(`Activation for "${activation.participant}" has invalid time range — "to" must be greater than "from".`);
297
908
  }
298
909
 
299
910
  const lastParticipant = asArray(sequence.participants)[asArray(sequence.participants).length - 1];
300
911
  if (lastParticipant && participants.get(lastParticipant.id).cx + layout.participantW / 2 > viewBox[0] - 40) {
301
912
  const requiredWidth = Math.ceil(participants.get(lastParticipant.id).cx + layout.participantW / 2 + 40);
302
- problems.push(`Participants exceed viewBox width - set meta.viewBox[0] to at least ${requiredWidth} or remove a participant.`);
913
+ problems.push(`Participants exceed viewBox width — set meta.viewBox[0] to at least ${requiredWidth} or remove a participant.`);
914
+ }
915
+
916
+ // Showcase must not silently drop the implicit legend because late content
917
+ // leaves no room for it; give the exact canvas height instead.
918
+ const legendHeight = legendRequiredHeight(viewBox[0]);
919
+ if (sequence.meta?.quality_profile === 'showcase' && sequence.meta?.legend === undefined
920
+ && legendHeight > viewBox[1] && !measureLegend(legendEntries(), legendLayout())) {
921
+ problems.push(`Sequence content ends at y=${contentBottom}, leaving no room for the legend below it — set meta.viewBox[1] to at least ${legendHeight} or omit meta.viewBox so the canvas grows.`);
303
922
  }
304
923
 
305
924
  if (problems.length) {
306
925
  throwDiagnosticProblems('Sequence layout validation failed', problems, {
307
926
  subject: { diagramType: 'sequence' },
927
+ diagnostics,
308
928
  });
309
929
  }
310
930
  }
@@ -313,7 +933,7 @@ function renderParticipant(participant) {
313
933
  const fill = componentFill[participant.type] || 'c-external';
314
934
  const hasSub = participant.sublabel != null && participant.sublabel !== '';
315
935
  const sub = hasSub
316
- ? `\n <text data-detail="context" x="${participant.cx}" y="${layout.topY + 39}" class="t-muted" font-size="${fittedNodeFontSize(participant.sublabel, layout.participantW, participantTextFit.sublabelPreferred, participantTextFit.sublabelMinimum)}" text-anchor="middle">${esc(participant.sublabel)}</text>`
936
+ ? `\n <text data-detail="context" x="${participant.cx}" y="${layout.topY + layout.participantSublabelY}" class="t-muted" font-size="${fittedNodeFontSize(participant.sublabel, layout.participantW, readableSublabelPreferred, readableSublabelMinimum)}" text-anchor="middle">${esc(participant.sublabel)}</text>`
317
937
  : '';
318
938
  const brand = renderBrandMark(participant, { x: participant.x + layout.participantW - 22, y: layout.topY + 6 });
319
939
  const labelFontSize = fittedNodeFontSize(participant.label, brandLabelFitWidth(participant, layout.participantW), 11, 8);
@@ -327,13 +947,20 @@ function renderParticipant(participant) {
327
947
  ${focusNodeTitle(participant.label, passport)}
328
948
  <rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="c-mask"/>
329
949
  <rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="${fill}"${animateAttr(sequence.meta, 'node', participant.index)} stroke-width="1.5"/>
330
- ${renderSemanticSigil(participant.type, { x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''}
331
- <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${participant.cx}" y="${layout.topY + 22}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>${sub}
950
+ ${renderSemanticSigil(participant.type, { icon: participant.icon, x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''}
951
+ <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${participant.cx}" y="${layout.topY + layout.participantLabelY}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>${sub}
332
952
  </g>`;
333
953
  }
334
954
 
335
- function renderLifeline(participant) {
336
- return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${layout.lifelineBottom}" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>`;
955
+ // Lifelines never enter the legend band. The legend is placed below all
956
+ // timeline content, so stopping above its title still reaches every message.
957
+ function lifelineEnd() {
958
+ const legend = measureLegend(legendEntries(), legendLayout());
959
+ return legend?.titleY == null ? layout.lifelineBottom : Math.min(layout.lifelineBottom, legend.titleY - 22);
960
+ }
961
+
962
+ function renderLifeline(participant, end) {
963
+ return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${end}" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>`;
337
964
  }
338
965
 
339
966
  function renderSegment(segment, index) {
@@ -344,7 +971,7 @@ function renderSegmentLabel(segment, index) {
344
971
  const label = segmentLabelBox(segment);
345
972
  return ` <g data-graph-role="segment-label" data-segment-id="${index}">
346
973
  <rect x="${label.x}" y="${label.y}" width="${label.width}" height="${label.height}" rx="3" class="c-mask"/>
347
- <text x="${label.x + 6}" y="${label.y + 13}" class="t-dim" font-size="9" font-weight="600">${esc(segment.label)}</text>
974
+ <text x="${label.x + 6}" y="${label.y + 13}" class="t-muted" font-size="9" font-weight="600">${esc(segment.label)}</text>
348
975
  </g>`;
349
976
  }
350
977
 
@@ -361,69 +988,82 @@ function messageLabel(message, x1, x2) {
361
988
  const box = messageLabelBox(message);
362
989
  const center = box ? box.x + box.width / 2 : (x1 + x2) / 2;
363
990
  const y = message.y - 10;
364
- const labelW = box?.width || Math.max(34, textUnits(message.label) * 5.2 + 12);
365
- const accent = message.variant === 'security'
366
- ? 't-security'
367
- : message.variant === 'dashed'
368
- ? 't-messagebus'
369
- : message.variant === 'return'
370
- ? 't-muted'
371
- : 't-backend';
991
+ const labelW = box?.width || Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
992
+ // A colored line gets a label in the same color, as in the legend swatches.
993
+ // Gray lines (default and return) keep the readable muted text color.
994
+ const accent = ['emphasis', 'security', 'dashed'].includes(message.variant) ? edgeLabelAccent(message.variant) : 't-muted';
372
995
  return ` <g data-detail="context">
373
996
  <rect x="${center - labelW / 2}" y="${y - 10}" width="${labelW}" height="${layout.labelH}" rx="3" class="c-mask"/>
374
- <text x="${center}" y="${y}" class="${accent}" font-size="9" text-anchor="middle">${esc(message.label)}</text>
997
+ <text x="${center}" y="${y}" class="${accent}" font-size="${messageFontSize}" text-anchor="middle">${esc(message.label)}</text>
375
998
  </g>`;
376
999
  }
377
1000
 
1001
+ function noteBackdrop(message) {
1002
+ const note = noteRect(message);
1003
+ if (!note) return '';
1004
+ const box = { x: note.x - 2, y: note.y - 2, width: note.width + 4, height: note.height + 4 };
1005
+ const crossed = asArray(sequence.segments).some((segment) => [segment.from, segment.to]
1006
+ .some((border) => border >= box.y && border <= box.y + box.height));
1007
+ if (!crossed) return '';
1008
+ // The backdrop clears decorative frames, never another message. Standard
1009
+ // quality can retain overlapping annotations that showcase rejects.
1010
+ const coversMessage = asArray(sequence.messages).some((other) => other !== message
1011
+ && [messageLabelBox(other), messageRouteBox(other), noteRect(other)]
1012
+ .some((rect) => rect && rectsOverlap(box, rect, 0)));
1013
+ if (coversMessage) return '';
1014
+ return `\n <rect data-detail="context" data-graph-role="message-note-background" x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="2" class="c-mask" pointer-events="none"/>`;
1015
+ }
1016
+
378
1017
  function renderMessage(message, index) {
379
1018
  const { start, end } = messageGeometry(message);
380
1019
  const [cls, marker] = arrowClass[message.variant || 'default'] || arrowClass.default;
381
1020
  const strokeWidth = message.variant === 'emphasis' ? 1.8 : 1.4;
382
1021
  const dash = message.variant === 'return' ? ' stroke-dasharray="3,5"' : '';
383
- const note = message.note
384
- ? `\n <text data-detail="fine" x="${Math.min(start, end) + 12}" y="${message.y + 18}" class="t-dim" font-size="7">${esc(message.note)}</text>`
1022
+ const noteBox = noteLayout(message);
1023
+ const noteContent = noteBox?.lines.length > 1
1024
+ ? noteBox.lines.map((line, lineIndex) => `<tspan x="${noteBox.x}" dy="${lineIndex ? NOTE_LINE_HEIGHT : 0}">${esc(line)}</tspan>`).join('')
1025
+ : esc(message.note);
1026
+ // Notes carry message conditions and already reserve timeline space. Keep
1027
+ // them readable at the default depth; Map still hides context without reflow.
1028
+ const note = noteBox
1029
+ ? `\n <text data-detail="context" x="${noteBox.x}" y="${noteBox.baseline}" class="t-muted" font-size="${NOTE_FONT_SIZE}">${noteContent}</text>`
385
1030
  : '';
386
1031
  return ` <g ${focusEdgeAttrs(message.from, message.to, message.label, index, message.id)}>
387
1032
  <path data-composition-edge-from="${esc(message.from)}" data-composition-edge-to="${esc(message.to)}"${message.id ? ` data-composition-edge-id="${esc(message.id)}"` : ''} data-composition-points="${routePointsValue([[start, message.y], [end, message.y]])}" d="M ${start} ${message.y} L ${end} ${message.y}" class="${cls}"${animateAttr(sequence.meta, 'edge', index)} stroke-width="${strokeWidth}"${dash} marker-end="url(#${marker})"/>
388
- ${messageLabel(message, start, end)}${note}
1033
+ ${messageLabel(message, start, end)}${noteBackdrop(message)}${note}
389
1034
  </g>`;
390
1035
  }
391
1036
 
392
- const LEGEND_CATALOG = [
393
- { kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8 },
394
- { kind: 'return', className: 'a-default', marker: 'arrowhead', dash: '3,5' },
395
- { kind: 'security', className: 'a-security', marker: 'arrowhead-security' },
396
- { kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed' },
397
- { kind: 'default', className: 'a-default', marker: 'arrowhead' },
398
- ].map((entry) => ({
399
- ...entry,
400
- interactive: false,
401
- swatchWidth: 34,
402
- swatchGap: 9,
403
- label: i18nText(sequence.meta.locale, `legend.sequence.${entry.kind}`),
404
- }));
1037
+
1038
+ function legendLayout() {
1039
+ return {
1040
+ x: 40,
1041
+ baselineY: layout.legendY,
1042
+ width: viewBox[0] - 80,
1043
+ // The same content-based budget as legendRequiredHeight(): a wrapped
1044
+ // legend may use any rows it needs as long as it stays below the content.
1045
+ minTitleY: Math.max(layout.lifelineTop, contentBottom + LEGEND_CONTENT_GAP),
1046
+ unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
1047
+ diagramType: 'sequence',
1048
+ };
1049
+ }
405
1050
 
406
1051
  function renderLegend() {
407
- const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
408
- const entries = resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
409
1052
  return renderResolvedLegend({
410
- entries,
1053
+ entries: legendEntries(),
411
1054
  locale: sequence.meta.locale,
412
- layout: {
413
- x: 40,
414
- baselineY: layout.legendY,
415
- width: viewBox[0] - 80,
416
- minTitleY: layout.legendY - 30,
417
- unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
418
- diagramType: 'sequence',
419
- },
1055
+ layout: legendLayout(),
420
1056
  renderSwatch: (entry) => `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}"${entry.dash ? ` stroke-dasharray="${entry.dash}"` : ''} marker-end="url(#${entry.marker})"/>`,
421
1057
  });
422
1058
  }
423
1059
 
424
1060
  function renderSvg() {
425
1061
  const participantList = [...participants.values()];
426
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(sequence.meta)}>
1062
+ // Same default-canvas contract as lifecycle: 920x760 is below the 1.55 wide
1063
+ // ratio, so without intrinsic-height the desktop Reader can neither narrow
1064
+ // nor scroll it and every default sequence fails the browser gate.
1065
+ const readerFit = sequence.meta?.viewBox ? '' : ' data-reader-fit="width-first"';
1066
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" data-sequence-column-fit="${columnFit}"${readerFit} ${svgRootAttrs(sequence.meta)} data-motion-choreography="sequence">
427
1067
  ${svgAccessibleText(sequence.meta, 'sequence')}
428
1068
  ${renderDefinitions()}
429
1069
 
@@ -434,7 +1074,7 @@ ${renderDefinitions()}
434
1074
  ${asArray(sequence.segments).map(renderSegment).join('\n\n')}
435
1075
 
436
1076
  <!-- Lifelines -->
437
- ${participantList.map(renderLifeline).join('\n')}
1077
+ ${participantList.map((participant) => renderLifeline(participant, lifelineEnd())).join('\n')}
438
1078
 
439
1079
  <!-- Activations -->
440
1080
  ${asArray(sequence.activations).map(renderActivation).join('\n')}
@@ -461,4 +1101,5 @@ writeDiagram({
461
1101
  meta: sequence.meta,
462
1102
  svg: renderSvg(),
463
1103
  cards: sequence.cards,
1104
+ sourceEvidence,
464
1105
  });