@stacksjs/defaults 0.75.361 → 0.75.362

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 (208) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +2 -2
  3. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  4. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  5. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  6. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  7. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  8. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  9. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  10. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  11. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  12. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  16. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  17. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  18. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  19. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  20. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  21. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  22. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  24. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  27. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  28. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  29. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  32. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  37. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  38. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  39. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  40. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  42. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  43. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  56. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  57. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  58. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  59. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  69. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  70. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  111. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  120. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  121. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  122. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  124. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  125. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  126. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  127. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  128. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  129. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  130. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  134. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  141. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  147. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  149. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  175. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  186. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  187. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  188. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  189. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  190. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  191. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  192. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  194. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  195. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  196. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  199. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  201. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  205. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  206. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  207. package/ide/vscode/package.json +1 -1
  208. package/package.json +2 -2
@@ -1,556 +1,1128 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
3
+ import { esc, renderDefinitions, renderSemanticSigil, textUnits, SEMANTIC_SIGIL_FOOTPRINT, SOURCE_BADGE_FOOTPRINT } 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 { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
6
+ import { legendFootprint, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
7
+ import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout, nodeTextFit } from '../shared/text-fit.mjs';
8
8
  import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
9
9
  import { translateMessage as i18nText } from '../shared/i18n.mjs';
10
- import {
11
- asArray,
12
- isFinitePoint,
13
- rectsOverlap,
14
- cleanEndpointSideProblems,
15
- cleanFlowProblems,
16
- cleanCrossingProblems,
17
- cleanAmbiguousCorridorProblems,
18
- cleanBorderRunProblems,
19
- cleanRouteRhythmProblems,
20
- cleanLabelRouteClearanceProblems,
21
- suggestLabelObstacleFix,
22
- suggestLabelPairFix,
23
- anchor,
24
- automaticPortSpread,
25
- defaultFromSide,
26
- defaultToSide,
27
- chosenSide,
28
- roundedPath,
29
- routePointsValue,
30
- labelPoint,
31
- arrowClassMap,
32
- variantAccent
33
- } from '../shared/geometry.mjs';
34
-
35
- const stateTextFit = {
36
- sublabelPreferred: 7,
37
- sublabelMinimum: 6,
38
- tagPreferred: 7,
39
- tagMinimum: 6,
40
- };
10
+ import { DESKTOP_READER_DIAGRAM_WIDTH, MIN_PROJECTED_NODE_TEXT_PX } from '../shared/desktop-readability.mjs';
11
+ import { asArray, rectsOverlap, segmentRectClearance, roundedPath, routePointsValue, arrowClassMap, edgeLabelAccent } from '../shared/geometry.mjs';
12
+
13
+ // Lifecycle v3 draws one shape: the main path as a left-to-right row, loops
14
+ // back to earlier phases as arcs above it, and every other state (exits,
15
+ // waiting or recovery states) in one row below the phase it branches from.
16
+ // Geometry is derived from that structure alone; there are no routing controls.
41
17
 
42
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
43
- const { diagram: lifecycle, template, outPath } = await loadDiagramWithBrandMarks({
19
+ const { diagram: lifecycle, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
44
20
  rendererDir: __dirname,
45
21
  diagramType: 'lifecycle',
46
- defaultExample: 'agent-run.lifecycle.json'
22
+ defaultExample: 'agent-run.lifecycle.json',
47
23
  });
48
24
 
49
- const viewBox = lifecycle.meta?.viewBox || [980, 660];
50
- const layout = {
51
- phaseY: 126,
52
- eventY: 278,
53
- outcomeY: 450,
54
- phaseW: 118,
55
- phaseH: 62,
56
- eventW: 126,
57
- eventH: 58,
58
- outcomeW: 118,
59
- outcomeH: 58,
60
- phaseXs: [94, 248, 402, 556, 710],
61
- eventXs: [402, 556, 710],
62
- outcomeXs: [402, 556, 710]
63
- };
25
+ const TEXT = { labelPreferred: 12, labelMinimum: 10, sublabelPreferred: 9, sublabelMinimum: 8, tagPreferred: 8, tagMinimum: 7, step: 8 };
26
+ const LABEL_FONT = 10;
27
+ const NOTE_FONT = 9;
28
+ const STATE_H = 64;
29
+ const STATE_MIN_W = 140;
30
+ const STATE_MAX_W = 220;
31
+ const MARGIN_X = 56;
32
+ const MARGIN_TOP = 28;
33
+ const PORT_PAD = 22;
34
+ const PORT_STEP = 26;
35
+ const CORNER = 12;
36
+ const FRAME_PAD = 10;
37
+ const ARC_BASE = 30;
38
+ const ARC_STEP = 24;
39
+ const TRACK_STEP = 18;
40
+ const LABEL_GAP = 6;
41
+ const CLEARANCE = 5;
42
+ const SPACING_ROUNDS = 5;
64
43
 
65
44
  const typeClass = {
66
- start: 'c-frontend',
67
- active: 'c-backend',
68
- waiting: 'c-cloud',
69
- decision: 'c-security',
70
- success: 'c-database',
71
- failure: 'c-security',
72
- neutral: 'c-external',
73
- external: 'c-external'
45
+ start: 'c-frontend', active: 'c-frontend', waiting: 'c-cloud', decision: 'c-database',
46
+ success: 'c-backend', failure: 'c-security', neutral: 'c-external', external: 'c-external',
74
47
  };
75
-
48
+ // Ordinary states carry no default corner sigil, so the ones a reader should
49
+ // notice (waiting, decision, outcomes, external) stand out. An ordinary state
50
+ // that ends the lifecycle is still an outcome and gets a stop sigil.
51
+ const QUIET_TYPES = new Set(['start', 'active', 'neutral']);
76
52
  const textClass = {
77
- start: 't-frontend',
78
- active: 't-backend',
79
- waiting: 't-cloud',
80
- decision: 't-security',
81
- success: 't-database',
82
- failure: 't-security',
83
- neutral: 't-muted',
84
- external: 't-muted'
53
+ start: 't-frontend', active: 't-frontend', waiting: 't-cloud', decision: 't-database',
54
+ success: 't-backend', failure: 't-security', neutral: 't-muted', external: 't-muted',
85
55
  };
86
56
 
87
- function legendY() {
88
- return viewBox[1] - 36;
89
- }
90
-
91
- // Keep the authored state-placement contract independent from the measured
92
- // legend's lower baseline. Moving legend chrome must not admit new state
93
- // geometry into the reserved outcome/legend band.
94
- function lifecycleAreaBottom() {
95
- return viewBox[1] - 122;
96
- }
97
-
98
- // Lane semantics are fixed: lane id "main" maps to the top phase band, lane id
99
- // "terminal" maps to the bottom outcome band, and every other lane shares the
100
- // middle event band (separated visually via yOffset).
101
- function bandFor(lane) {
102
- if (lane === 'main') return 'phase';
103
- if (lane === 'terminal') return 'outcome';
104
- return 'event';
105
- }
106
-
107
- function measureState(state) {
108
- const isPhase = bandFor(state.lane) === 'phase';
109
- const isOutcome = bandFor(state.lane) === 'outcome';
110
- const width = state.width || (isPhase ? layout.phaseW : isOutcome ? layout.outcomeW : layout.eventW);
111
- const height = state.height || (isPhase ? layout.phaseH : isOutcome ? layout.outcomeH : layout.eventH);
112
- const xs = isPhase ? layout.phaseXs : isOutcome ? layout.outcomeXs : layout.eventXs;
113
- const cx = xs[state.col] ?? xs[xs.length - 1];
114
- const y = (
115
- isPhase ? layout.phaseY :
116
- isOutcome ? layout.outcomeY :
117
- layout.eventY
118
- ) + (state.yOffset || 0);
57
+ const transitions = asArray(lifecycle.transitions);
58
+ const mainPath = asArray(lifecycle.mainPath);
59
+ const states = new Map(asArray(lifecycle.states).map((state) => [state.id, { ...state }]));
60
+ const spineIndex = new Map(mainPath.map((id, index) => [id, index]));
61
+ const onSpine = (id) => spineIndex.has(id);
62
+
63
+ validateStructure();
64
+
65
+ // ---------------------------------------------------------------- measure
66
+
67
+ function fonts(state, width) {
119
68
  return {
120
- ...state,
121
- width,
122
- height,
123
- x: cx - width / 2,
124
- y,
125
- cx,
126
- cy: y + height / 2
69
+ label: fittedNodeFontSize(state.label, brandLabelFitWidth(state, width), TEXT.labelPreferred, TEXT.labelMinimum),
70
+ sublabel: fittedNodeFontSize(state.sublabel, width, TEXT.sublabelPreferred, TEXT.sublabelMinimum),
71
+ tag: fittedNodeFontSize(state.tag, width, TEXT.tagPreferred, TEXT.tagMinimum),
127
72
  };
128
73
  }
129
74
 
130
- const states = new Map(asArray(lifecycle.states).map((state) => [state.id, measureState(state)]));
131
- const laneLabels = new Map(asArray(lifecycle.lanes).map((lane) => [lane.id, lane.label]));
132
- const stateSteps = new Map();
133
- for (const [index, transition] of asArray(lifecycle.transitions).entries()) {
134
- if (!stateSteps.has(transition.from)) stateSteps.set(transition.from, index);
135
- if (!stateSteps.has(transition.to)) stateSteps.set(transition.to, index + 1);
75
+ // Wide enough for every row at its preferred size; the label also clears the
76
+ // top-rail decorations (sigil or step, brand, source badge) that share its row.
77
+ function preferredWidth(state) {
78
+ const need = (text, font) => (text ? textUnits(text) * font * nodeTextFit.widthFactor + nodeTextFit.horizontalPadding + 36 : 0);
79
+ const left = Math.max(SEMANTIC_SIGIL_FOOTPRINT + 2, state.step ? 26 + minimumNodeTextWidth(state.step, TEXT.step) : 0);
80
+ const right = (brandMarkFor(state) ? 26 : 4) + (sourceEvidence?.nodes?.[state.id]?.length ? SOURCE_BADGE_FOOTPRINT : 0);
81
+ const label = minimumNodeTextWidth(state.label, TEXT.labelPreferred) + left + right + 4;
82
+ return Math.ceil(Math.min(STATE_MAX_W, Math.max(STATE_MIN_W,
83
+ label, need(state.sublabel, TEXT.sublabelPreferred), need(state.tag, TEXT.tagPreferred))));
84
+ }
85
+
86
+ for (const state of states.values()) {
87
+ state.width = preferredWidth(state);
88
+ state.height = STATE_H;
136
89
  }
137
- for (const [index, state] of asArray(lifecycle.states).entries()) {
138
- if (!stateSteps.has(state.id)) stateSteps.set(state.id, index);
90
+ const smallestText = Math.min(LABEL_FONT, ...[...states.values()].flatMap((state) => {
91
+ const fitted = fonts(state, state.width);
92
+ return [fitted.label, state.sublabel ? fitted.sublabel : Infinity];
93
+ }));
94
+ const readableWidthBudget = Math.floor(DESKTOP_READER_DIAGRAM_WIDTH * smallestText / MIN_PROJECTED_NODE_TEXT_PX);
95
+
96
+ function labelBox(transition) {
97
+ const text = transition.label || '';
98
+ const note = transition.note || '';
99
+ const width = Math.ceil(Math.max(
100
+ text ? textUnits(text) * LABEL_FONT * 0.6 : 0,
101
+ note ? textUnits(note) * NOTE_FONT * 0.6 : 0,
102
+ ) + 12);
103
+ return { width, height: text && note ? 28 : 16 };
139
104
  }
105
+ const hasLabel = (transition) => Boolean(transition.label || transition.note);
140
106
 
141
- function validateLifecycle() {
142
- const problems = [];
143
- if (states.size !== asArray(lifecycle.states).length) problems.push('State ids must be unique.');
107
+ // ---------------------------------------------------------------- classify
108
+
109
+ // The first forward transition between consecutive main-path states is the
110
+ // row itself; any other main-path pair is an arc above the row.
111
+ const spineEdges = new Map();
112
+ for (const transition of transitions) {
113
+ const from = spineIndex.get(transition.from);
114
+ const to = spineIndex.get(transition.to);
115
+ if (from !== undefined && to === from + 1 && !spineEdges.has(from)) spineEdges.set(from, transition);
116
+ }
117
+ const mainPathStateWidth = mainPath.reduce((sum, id) => sum + states.get(id).width, 0);
118
+ const minimumSpineGaps = mainPath.slice(0, -1).map((_, index) => {
119
+ const edge = spineEdges.get(index);
120
+ return (edge && hasLabel(edge) ? labelBox(edge).width : 0) + 26;
121
+ });
144
122
 
145
- // The three bands are fixed at y=112/264/436. Preserve the original
146
- // outcome/legend reserve even though measured legend rows now sit lower.
147
- if (lifecycleAreaBottom() + 4 < 448) {
148
- problems.push(`viewBox height ${viewBox[1]} is too short for the fixed band layout - set meta.viewBox[1] to at least 566.`);
123
+ // Every other state sits in a row below the main path at its distance from
124
+ // it, so each transition stays within one row or joins two adjacent rows.
125
+ const depth = new Map(mainPath.map((id) => [id, 0]));
126
+ for (let frontier = [...mainPath]; frontier.length;) {
127
+ const next = [];
128
+ for (const id of frontier) {
129
+ for (const transition of transitions) {
130
+ const other = transition.from === id ? transition.to : transition.to === id ? transition.from : null;
131
+ if (other && states.has(other) && !depth.has(other)) {
132
+ depth.set(other, depth.get(id) + 1);
133
+ next.push(other);
134
+ }
135
+ }
149
136
  }
137
+ frontier = next;
138
+ }
139
+ for (const id of states.keys()) if (!depth.has(id)) depth.set(id, 1);
140
+ const depthOf = (id) => depth.get(id);
141
+ const rowCount = Math.max(...depth.values()) + 1;
142
+ const rows = Array.from({ length: rowCount }, (_, row) => (row === 0
143
+ ? mainPath.map((id) => states.get(id))
144
+ : [...states.values()].filter((state) => depthOf(state.id) === row)));
145
+ const offStates = rows.slice(1).flat();
146
+
147
+ const kindOf = new Map(transitions.map((transition) => {
148
+ const [from, to] = [depthOf(transition.from), depthOf(transition.to)];
149
+ if (from === 0 && to === 0) return [transition, spineEdges.get(spineIndex.get(transition.from)) === transition ? 'spine' : 'arc'];
150
+ return [transition, from === to ? 'same' : 'between'];
151
+ }));
152
+ const ofKind = (kind) => transitions.filter((transition) => kindOf.get(transition) === kind);
150
153
 
151
- const laneIds = new Set(asArray(lifecycle.lanes).map((lane) => lane.id));
152
- if (laneIds.size !== asArray(lifecycle.lanes).length) problems.push('Lane ids must be unique.');
153
- if (!laneIds.has('main')) {
154
- problems.push('Lifecycle diagrams need a lane with id "main" (the phase rail). Lane ids "main" and "terminal" are reserved: "main" maps to the top phase band, "terminal" to the bottom outcome band, and all other lanes share the middle event band.');
154
+ // Exits that leave several main-path phases for one state are drawn once:
155
+ // from a composite frame around consecutive phases, or from a shared bus.
156
+ const downByTarget = new Map();
157
+ for (const transition of ofKind('between')) {
158
+ if (depthOf(transition.from) !== 0) continue;
159
+ if (!downByTarget.has(transition.to)) downByTarget.set(transition.to, []);
160
+ downByTarget.get(transition.to).push(transition);
161
+ }
162
+ // A merged exit promises one meaning. Only a target with one eligible
163
+ // complete-signature bucket joins a bracket; all other exits stay singles.
164
+ const exitSignature = (transition) => [transition.label ?? '', transition.note ?? '', variantOf(transition)].join('\u0000');
165
+ const brackets = new Map();
166
+ for (const [target, list] of downByTarget) {
167
+ const buckets = new Map();
168
+ for (const transition of list) {
169
+ const signature = exitSignature(transition);
170
+ if (!buckets.has(signature)) buckets.set(signature, []);
171
+ buckets.get(signature).push(transition);
155
172
  }
173
+ const eligible = [...buckets.values()].filter((bucket) => new Set(bucket.map((transition) => transition.from)).size >= 2);
174
+ if (eligible.length !== 1) continue;
175
+ const [group] = eligible;
176
+ const sources = [...new Set(group.map((transition) => transition.from))].sort((a, b) => spineIndex.get(a) - spineIndex.get(b));
177
+ const key = sources.join('\u0000');
178
+ if (!brackets.has(key)) brackets.set(key, { key, sources, targets: [], transitions: [] });
179
+ brackets.get(key).targets.push(target);
180
+ brackets.get(key).transitions.push(...group);
181
+ }
182
+ const bracketOf = new Map();
183
+ for (const bracket of brackets.values()) {
184
+ for (const transition of bracket.transitions) bracketOf.set(transition, bracket);
185
+ }
186
+ // Consecutive main-path phases form a composite state: one frame around them
187
+ // and one exit per target, like a UML superstate transition. Frames never
188
+ // partially overlap; such a bracket keeps a shared bus under its phases.
189
+ const frameSpans = [];
190
+ for (const bracket of brackets.values()) {
191
+ const indexes = bracket.sources.map((id) => spineIndex.get(id));
192
+ const lo = Math.min(...indexes);
193
+ const hi = Math.max(...indexes);
194
+ if (hi - lo + 1 !== indexes.length || frameSpans.some((span) => span.lo <= hi && lo <= span.hi)) continue;
195
+ bracket.frame = { lo, hi };
196
+ frameSpans.push(bracket.frame);
197
+ }
198
+ const bracketTargets = new Map();
199
+ for (const bracket of brackets.values()) for (const target of bracket.targets) bracketTargets.set(target, bracket);
156
200
 
157
- for (const state of states.values()) {
158
- if (!laneIds.has(state.lane)) {
159
- problems.push(`State "${state.id}" uses unknown lane "${state.lane}".`);
160
- continue;
201
+ const outgoing = new Set(transitions.map((transition) => transition.from));
202
+ const isFinal = (state) => !outgoing.has(state.id);
203
+
204
+ // ---------------------------------------------------------------- layout
205
+
206
+ // Each round tries ports pulled toward their partners and ports spread evenly,
207
+ // and keeps the placed layout with the fewest bends and short jogs.
208
+ let spacing = { spineGap: 72, rowGap: 44, drop: 58, firstTrack: 34 };
209
+ let geometry;
210
+ // Labels that could not be placed at tighter spacing and prompted retries.
211
+ const spacingWideners = new Set();
212
+ for (let round = 0; round < SPACING_ROUNDS; round += 1) {
213
+ const candidates = ['partner', 'even'].map((ports) => ({ ports, ...layout({ ...spacing, ports }) }));
214
+ const placed = candidates.filter((candidate) => !candidate.unplacedLabels.length);
215
+ const best = placed.sort((a, b) => routeCost(a.routes) - routeCost(b.routes))[0] || candidates[0];
216
+ // layout() positions the shared states, so the winner is laid out again.
217
+ geometry = best === candidates.at(-1) ? best : layout({ ...spacing, ports: best.ports });
218
+ if (placed.length) break;
219
+ if (round === 0) {
220
+ const closest = [...candidates].sort((a, b) => a.unplacedLabels.length - b.unplacedLabels.length)[0];
221
+ for (const transition of closest.unplacedLabels) spacingWideners.add(transition);
222
+ }
223
+ const nextSpineGap = spacing.spineGap + 28;
224
+ // The canvas includes this whole row. Widening beyond its text budget
225
+ // cannot succeed; keep its current gaps while other spacing still grows.
226
+ const nextMainPathWidth = MARGIN_X * 2 + mainPathStateWidth
227
+ + minimumSpineGaps.reduce((sum, minimum) => sum + Math.max(nextSpineGap, minimum), 0);
228
+ spacing = { spineGap: nextMainPathWidth > readableWidthBudget ? spacing.spineGap : nextSpineGap,
229
+ rowGap: spacing.rowGap + 28, drop: spacing.drop + 14, firstTrack: spacing.firstTrack + 6 };
230
+ }
231
+
232
+ function routeCost(routes) {
233
+ let cost = 0;
234
+ for (const { points } of routes.values()) {
235
+ cost += Math.max(0, points.length - 2) * 10;
236
+ for (let index = 1; index < points.length; index += 1) {
237
+ const run = Math.abs(points[index][0] - points[index - 1][0]);
238
+ if (points.length > 2 && Math.abs(points[index][1] - points[index - 1][1]) < 0.5) cost += run * 0.1 + (run < 30 ? 40 : 0);
161
239
  }
162
- const band = bandFor(state.lane);
163
- const maxCol = band === 'phase'
164
- ? layout.phaseXs.length
165
- : band === 'outcome'
166
- ? layout.outcomeXs.length
167
- : layout.eventXs.length;
168
- if (!Number.isInteger(state.col) || state.col < 0 || state.col >= maxCol) {
169
- problems.push(`State "${state.id}" uses invalid column ${state.col} - the ${band} band has integer columns 0..${maxCol - 1}.`);
240
+ }
241
+ return cost;
242
+ }
243
+
244
+ function layout({ spineGap, rowGap, drop, firstTrack, ports: portMode }) {
245
+ // Main-path x positions: each gap fits its row label.
246
+ let cursor = MARGIN_X;
247
+ mainPath.forEach((id, index) => {
248
+ const state = states.get(id);
249
+ state.x = cursor;
250
+ state.cx = cursor + state.width / 2;
251
+ cursor += state.width + Math.max(spineGap, minimumSpineGaps[index] ?? 26);
252
+ });
253
+ for (let row = 1; row < rowCount; row += 1) placeRow(row, rowGap);
254
+
255
+ // Shift horizontally so the leftmost element keeps the margin.
256
+ const minX = Math.min(...[...states.values()].map((state) => state.x));
257
+ const dx = MARGIN_X - minX;
258
+ for (const state of states.values()) { state.x += dx; state.cx += dx; }
259
+
260
+ const arcs = assignArcLevels();
261
+ const maxLevel = Math.max(0, ...arcs.map((arc) => arc.level));
262
+ const arcLabelReserve = arcs.some((arc) => hasLabel(arc.transition)) ? 12 : 0;
263
+ const spineTop = MARGIN_TOP + (maxLevel ? ARC_BASE + (maxLevel - 1) * ARC_STEP + arcLabelReserve : 0);
264
+
265
+ const ports = new Map();
266
+ const portOf = (id, side, key) => ports.get(`${id}|${side}`)?.get(key);
267
+ const requestPort = (id, side, key, otherX, order) => {
268
+ const slot = `${id}|${side}`;
269
+ if (!ports.has(slot)) ports.set(slot, new Map());
270
+ if (!ports.get(slot).has(key)) ports.get(slot).set(key, { key, otherX, order, x: 0 });
271
+ };
272
+ const cx = (id) => states.get(id).cx;
273
+
274
+ // Connectors cross the gap below row `gap`: single transitions between two
275
+ // rows, composite frame exits, shared buses, and loops between two
276
+ // non-adjacent states of one row.
277
+ const connectors = [];
278
+ for (const transition of ofKind('between')) {
279
+ if (bracketOf.has(transition)) continue;
280
+ const down = depthOf(transition.from) < depthOf(transition.to);
281
+ const upperId = down ? transition.from : transition.to;
282
+ const lowerId = down ? transition.to : transition.from;
283
+ connectors.push({ kind: 'single', transition, down, upperId, lowerId, gap: depthOf(upperId), order: transitions.indexOf(transition) });
284
+ }
285
+ const frames = [];
286
+ for (const bracket of brackets.values()) {
287
+ const order = transitions.indexOf(bracket.transitions[0]);
288
+ if (!bracket.frame) {
289
+ connectors.push({ kind: 'bracket', bracket, gap: 0, order });
170
290
  continue;
171
291
  }
172
- if (!isFinitePoint(state.x, state.y, state.cx, state.cy)) {
173
- problems.push(`State "${state.id}" produced non-finite coordinates - check col, width, height, and yOffset are numbers.`);
292
+ const first = states.get(mainPath[bracket.frame.lo]);
293
+ const last = states.get(mainPath[bracket.frame.hi]);
294
+ const rect = { x: first.x - FRAME_PAD, y: spineTop - FRAME_PAD, width: last.x + last.width - first.x + FRAME_PAD * 2, height: STATE_H + FRAME_PAD * 2 };
295
+ frames.push(rect);
296
+ for (const target of bracket.targets) connectors.push({ kind: 'frame', bracket, rect, lowerId: target, gap: 0, order });
297
+ }
298
+ // Neighbours in one lower row connect side to side; others loop underneath.
299
+ const rowOrder = rows.map((list) => [...list].sort((a, b) => a.x - b.x).map((state) => state.id));
300
+ const adjacent = (transition) => transition.from !== transition.to
301
+ && Math.abs(rowOrder[depthOf(transition.from)].indexOf(transition.from) - rowOrder[depthOf(transition.from)].indexOf(transition.to)) === 1;
302
+ const sides = ofKind('same').filter(adjacent);
303
+ // Side labels ride on the line for groups of three or more. Reserve their
304
+ // actual height before placing rows: horizontal gap growth cannot clear a
305
+ // label trapped between two full-width parallel strokes.
306
+ const sideGroups = new Map();
307
+ for (const transition of sides) {
308
+ const key = [transition.from, transition.to].sort().join('\u0000');
309
+ if (!sideGroups.has(key)) sideGroups.set(key, []);
310
+ sideGroups.get(key).push(transition);
311
+ }
312
+ const sideLanes = [];
313
+ const rowHeights = rows.map(() => STATE_H);
314
+ for (const group of sideGroups.values()) {
315
+ const rightward = (transition) => states.get(transition.from).x < states.get(transition.to).x;
316
+ const ordered = [...group.filter(rightward), ...group.filter((transition) => !rightward(transition))];
317
+ if (ordered.length > 5) {
318
+ const message = `${ordered.length} transitions between the neighbouring states "${ordered[0].from}" and "${ordered[0].to}" cannot spread apart on the shared edge; at most five stay legible.`;
319
+ throwDiagnosticProblems('Lifecycle validation failed', [message], {
320
+ subject: { diagramType: 'lifecycle' },
321
+ diagnostics: [structureProblem('lifecycle/crowded-side-transitions', message,
322
+ { collection: 'transitions', from: ordered[0].from, to: ordered[0].to },
323
+ ['merge the triggers into one transition label'])],
324
+ });
325
+ }
326
+ let step = Math.min(20, 48 / (ordered.length - 1));
327
+ if (ordered.length > 2) {
328
+ const heights = ordered.map((transition) => hasLabel(transition) ? labelBox(transition).height : 0);
329
+ step = Math.max(step, ...heights.map((height) => height ? height / 2 + CLEARANCE : 0),
330
+ ...heights.slice(1).map((height, index) => height && heights[index] ? (height + heights[index]) / 2 + 4 : 0));
331
+ const row = depthOf(ordered[0].from);
332
+ // Keep every side port eight pixels inside the rounded state corners.
333
+ rowHeights[row] = Math.max(rowHeights[row], step * (ordered.length - 1) + 16);
334
+ }
335
+ sideLanes.push({ ordered, step });
336
+ }
337
+ for (const transition of ofKind('same').filter((transition) => !adjacent(transition))) {
338
+ connectors.push({ kind: 'loop', transition, gap: depthOf(transition.from), order: transitions.indexOf(transition) });
339
+ }
340
+
341
+ for (const connector of connectors) {
342
+ if (connector.kind === 'single') {
343
+ requestPort(connector.upperId, 'bottom', connector, cx(connector.lowerId), connector.order);
344
+ requestPort(connector.lowerId, 'top', connector, cx(connector.upperId), connector.order);
345
+ } else if (connector.kind === 'frame') {
346
+ requestPort(connector.lowerId, 'top', connector, connector.rect.x + connector.rect.width / 2, connector.order);
347
+ } else if (connector.kind === 'loop') {
348
+ const { from, to } = connector.transition;
349
+ if (from === to) {
350
+ requestPort(from, 'bottom', `${connector.order}:out`, Infinity, 0);
351
+ requestPort(from, 'bottom', `${connector.order}:in`, Infinity, 1);
352
+ } else {
353
+ requestPort(from, 'bottom', `${connector.order}:out`, cx(to), connector.order);
354
+ requestPort(to, 'bottom', `${connector.order}:in`, cx(from), connector.order);
355
+ }
356
+ } else {
357
+ const { sources, targets } = connector.bracket;
358
+ const dropCenter = targets.reduce((sum, id) => sum + cx(id), 0) / targets.length;
359
+ const sourceCenter = sources.reduce((sum, id) => sum + cx(id), 0) / sources.length;
360
+ for (const id of sources) requestPort(id, 'bottom', connector, dropCenter, connector.order);
361
+ for (const id of targets) requestPort(id, 'top', connector, sourceCenter, connector.order);
362
+ }
363
+ }
364
+ for (const arc of arcs) {
365
+ const { transition } = arc;
366
+ if (transition.from === transition.to) {
367
+ requestPort(transition.from, 'top', `${arc.key}:out`, Infinity, 0);
368
+ requestPort(transition.from, 'top', `${arc.key}:in`, Infinity, 1);
369
+ } else {
370
+ requestPort(transition.from, 'top', `${arc.key}:out`, cx(transition.to), 0);
371
+ requestPort(transition.to, 'top', `${arc.key}:in`, cx(transition.from), 0);
372
+ }
373
+ }
374
+ distributePorts(ports, arcs, portMode);
375
+ placeFrameExits(connectors, ports, portOf);
376
+
377
+ // Each gap assigns its horizontal runs to tracks, then the next row follows.
378
+ const runs = connectors.map((connector) => {
379
+ if (connector.kind === 'loop') {
380
+ const { from, to } = connector.transition;
381
+ const tops = [portOf(from, 'bottom', `${connector.order}:out`).x, portOf(to, 'bottom', `${connector.order}:in`).x];
382
+ return { connector, tops, bottoms: [], lo: Math.min(...tops), hi: Math.max(...tops), straight: false };
383
+ }
384
+ if (connector.kind === 'bracket') {
385
+ const { sources, targets } = connector.bracket;
386
+ const tops = sources.map((id) => portOf(id, 'bottom', connector).x);
387
+ const bottoms = targets.map((id) => portOf(id, 'top', connector).x);
388
+ const xs = [...tops, ...bottoms];
389
+ return { connector, tops, bottoms, lo: Math.min(...xs), hi: Math.max(...xs), straight: false };
390
+ }
391
+ const top = connector.kind === 'frame' ? connector.exitX : portOf(connector.upperId, 'bottom', connector).x;
392
+ const bottom = portOf(connector.lowerId, 'top', connector).x;
393
+ return { connector, tops: [top], bottoms: [bottom], lo: Math.min(top, bottom), hi: Math.max(top, bottom), straight: Math.abs(top - bottom) < 0.5 };
394
+ });
395
+ const rowTop = [spineTop];
396
+ const trackY = [];
397
+ for (let row = 0; row < rowCount; row += 1) {
398
+ for (const state of rows[row]) {
399
+ state.y = rowTop[row];
400
+ state.height = rowHeights[row];
401
+ state.cy = rowTop[row] + state.height / 2;
402
+ }
403
+ const bottom = rowTop[row] + rowHeights[row];
404
+ const count = assignTracks(runs.filter((run) => run.connector.gap === row));
405
+ trackY[row] = (track) => bottom + firstTrack + track * TRACK_STEP;
406
+ if (row + 1 < rowCount) rowTop.push((count ? trackY[row](count - 1) : bottom + firstTrack) + drop);
407
+ }
408
+ const rowBottom = (row) => rowTop[row] + rowHeights[row];
409
+
410
+ // Routes, keyed by transition.
411
+ const routes = new Map();
412
+ const junctions = [];
413
+ for (const [index, transition] of spineEdges) {
414
+ const from = states.get(mainPath[index]);
415
+ const to = states.get(mainPath[index + 1]);
416
+ routes.set(transition, { points: [[from.x + from.width, from.cy], [to.x, to.cy]], segment: 0, labelMode: 'above' });
417
+ }
418
+ for (const arc of arcs) {
419
+ const { transition } = arc;
420
+ const out = portOf(transition.from, 'top', `${arc.key}:out`).x;
421
+ const inn = portOf(transition.to, 'top', `${arc.key}:in`).x;
422
+ const y = spineTop - ARC_BASE - (arc.level - 1) * ARC_STEP;
423
+ routes.set(transition, { points: [[out, spineTop], [out, y], [inn, y], [inn, spineTop]], segment: 1, labelMode: 'on' });
424
+ }
425
+ for (const run of runs) {
426
+ const { connector } = run;
427
+ const { gap } = connector;
428
+ const y = trackY[gap](run.track ?? 0);
429
+ const upper = rowBottom(gap);
430
+ const lower = rowTop[gap + 1];
431
+ if (connector.kind === 'loop') {
432
+ const [a, b] = run.tops;
433
+ routes.set(connector.transition, { points: [[a, upper], [a, y], [b, y], [b, upper]], segment: 1, labelMode: 'on' });
174
434
  continue;
175
435
  }
176
- if (state.x < 32 || state.x + state.width > viewBox[0] - 32) {
177
- problems.push(`State "${state.id}" exceeds the horizontal bounds of the diagram - reduce state.width or increase meta.viewBox[0].`);
178
- }
179
- if (state.y < 64 || state.y + state.height > lifecycleAreaBottom()) {
180
- problems.push(`State "${state.id}" exceeds the vertical lifecycle area - keep y between 64 and ${lifecycleAreaBottom()} (adjust yOffset or increase meta.viewBox[1]).`);
181
- }
182
- const estLabelW = textUnits(state.label) * 6.2;
183
- if (estLabelW > state.width + 6) {
184
- problems.push(`Label "${state.label}" (~${Math.round(estLabelW)}px) is wider than state "${state.id}" (${state.width}px) - shorten the label or increase state.width.`);
185
- }
186
- const brandRailProblem = brandTopRailProblem(state, state.width, 8, 'State');
187
- if (brandRailProblem) problems.push(brandRailProblem);
188
- // sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
189
- // handles the ordinary case, this rejects what it cannot rescue.
190
- const availableTextW = availableNodeTextWidth(state.width);
191
- for (const [field, value, minimum] of [
192
- ['Sublabel', state.sublabel, stateTextFit.sublabelMinimum],
193
- ['Tag', state.tag, stateTextFit.tagMinimum],
194
- ]) {
195
- if (!value) continue;
196
- const minimumW = minimumNodeTextWidth(value, minimum);
197
- if (minimumW > availableTextW) {
198
- problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but state "${state.id}" provides ${availableTextW}px - shorten the ${field.toLowerCase()} or increase state.width.`);
436
+ if (connector.kind === 'single' || connector.kind === 'frame') {
437
+ const [top] = run.tops;
438
+ const [bottom] = run.bottoms;
439
+ const start = connector.kind === 'frame' ? connector.rect.y + connector.rect.height : upper;
440
+ const down = run.straight ? [[top, start], [top, lower]] : [[top, start], [top, y], [bottom, y], [bottom, lower]];
441
+ if (connector.kind === 'single') {
442
+ const points = connector.down ? down : [...down].reverse();
443
+ routes.set(connector.transition, { points, segment: connector.down ? points.length - 2 : 0, labelMode: 'beside' });
444
+ } else {
445
+ connector.bracket.transitions.filter((transition) => transition.to === connector.lowerId)
446
+ .forEach((transition, index) => routes.set(transition, {
447
+ points: down, segment: down.length - 2, labelMode: index ? 'none' : 'beside', bracket: connector.bracket,
448
+ }));
199
449
  }
450
+ continue;
451
+ }
452
+ const { bracket } = connector;
453
+ const labelled = new Set();
454
+ for (const transition of bracket.transitions) {
455
+ const tick = portOf(transition.from, 'bottom', connector).x;
456
+ const dropX = portOf(transition.to, 'top', connector).x;
457
+ const points = Math.abs(tick - dropX) < 0.5
458
+ ? [[tick, upper], [tick, lower]]
459
+ : [[tick, upper], [tick, y], [dropX, y], [dropX, lower]];
460
+ const owner = !labelled.has(transition.to);
461
+ labelled.add(transition.to);
462
+ routes.set(transition, { points, segment: points.length - 2, labelMode: owner ? 'beside' : 'none', bracket });
463
+ }
464
+ for (const x of [...run.tops, ...run.bottoms]) {
465
+ if (x > run.lo + 0.5 && x < run.hi - 0.5) junctions.push([x, y]);
200
466
  }
201
467
  }
468
+ // Parallel transitions between two neighbours spread symmetrically around
469
+ // the shared edge, left-to-right ones first; a reciprocal pair lands at ±10.
470
+ for (const { ordered, step } of sideLanes) {
471
+ ordered.forEach((transition, index) => {
472
+ const from = states.get(transition.from);
473
+ const to = states.get(transition.to);
474
+ const y = from.cy + (index - (ordered.length - 1) / 2) * step;
475
+ const points = from.x < to.x ? [[from.x + from.width, y], [to.x, y]] : [[from.x, y], [to.x + to.width, y]];
476
+ // With three or more parallels the inner strokes leave no lane for a
477
+ // floating label, so their labels ride on the line like arc labels.
478
+ routes.set(transition, { points, segment: 0, labelMode: ordered.length > 2 ? 'on' : 'above' });
479
+ });
480
+ }
202
481
 
203
- // All non-main/non-terminal lanes share the same y band, so the overlap
204
- // check must run across lanes - not per-lane.
205
- const allStates = [...states.values()];
206
- for (let i = 0; i < allStates.length; i += 1) {
207
- for (let j = i + 1; j < allStates.length; j += 1) {
208
- if (rectsOverlap(allStates[i], allStates[j], 10)) {
209
- problems.push(`States "${allStates[i].id}" and "${allStates[j].id}" are less than 10px apart - move one to another col or separate them with yOffset (lanes other than "main"/"terminal" share one band).`);
482
+ const stateRight = Math.max(...[...states.values()].map((state) => state.x + state.width));
483
+ const placed = placeLabels({ routes, contentRight: stateRight });
484
+ const boxes = [...states.values(), ...placed.placedLabels.values()];
485
+ const contentBottom = Math.max(
486
+ ...boxes.map((box) => box.y + box.height),
487
+ ...[...routes.values()].flatMap((route) => route.points.map((point) => point[1])),
488
+ );
489
+ const contentRight = Math.max(...boxes.map((box) => box.x + box.width));
490
+ return { routes, junctions, frames, contentBottom, contentRight, ...placed };
491
+ }
492
+
493
+ // A composite exit leaves the frame directly above its target when possible,
494
+ // clear of the inner phases' own connectors and of sibling exits.
495
+ function placeFrameExits(connectors, ports, portOf) {
496
+ const frameConnectors = connectors.filter((connector) => connector.kind === 'frame');
497
+ for (const connector of frameConnectors) {
498
+ const { rect, bracket } = connector;
499
+ const inner = mainPath.slice(bracket.frame.lo, bracket.frame.hi + 1)
500
+ .flatMap((id) => [...(ports.get(`${id}|bottom`)?.values() || [])].map((port) => port.x));
501
+ const taken = [...inner, ...frameConnectors.filter((other) => other.rect === rect && other.exitX !== undefined).map((other) => other.exitX)];
502
+ const target = portOf(connector.lowerId, 'top', connector);
503
+ const low = rect.x + 16;
504
+ const high = rect.x + rect.width - 16;
505
+ const wanted = Math.min(high, Math.max(low, target.x));
506
+ const free = (x) => x >= low && x <= high && taken.every((other) => Math.abs(other - x) >= 14);
507
+ let exitX = wanted;
508
+ for (let step = 0; step <= 40 && !free(exitX); step += 1) {
509
+ exitX = [wanted + step * 7, wanted - step * 7].find(free) ?? exitX;
510
+ }
511
+ connector.exitX = exitX;
512
+ // Snap the target's port to the exit when that keeps its spacing.
513
+ const delta = Math.abs(exitX - target.x);
514
+ if (delta >= 0.5 && delta < 24) {
515
+ const state = states.get(connector.lowerId);
516
+ const others = [...ports.get(`${connector.lowerId}|top`).values()].filter((port) => port !== target);
517
+ if (exitX >= state.x + 12 && exitX <= state.x + state.width - 12 && others.every((port) => Math.abs(port.x - exitX) >= 12)) {
518
+ target.x = exitX;
519
+ } else if (delta < 16) {
520
+ connector.exitX = [target.x, exitX + Math.sign(exitX - target.x || 1) * (16 - delta)].find(free) ?? exitX;
210
521
  }
211
522
  }
212
523
  }
524
+ }
213
525
 
214
- for (const transition of asArray(lifecycle.transitions)) {
215
- if (!states.has(transition.from)) problems.push(`Transition "${transition.label || transition.from}" references unknown source "${transition.from}".`);
216
- if (!states.has(transition.to)) problems.push(`Transition "${transition.label || transition.to}" references unknown target "${transition.to}".`);
217
- if (states.has(transition.from) && states.has(transition.to)) {
218
- const routed = pathFor(transition);
219
- const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
220
- const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
221
- if (distance < 32) problems.push(`Transition "${transition.label || `${transition.from}->${transition.to}`}" is too short (${Math.round(distance)}px; minimum 32px) - route it through a channel or drop its label.`);
222
- }
223
- }
224
-
225
- // Authored via points are authoritative in schema v1, including under a
226
- // quality profile. Preserve and render them exactly: applying the endpoint
227
- // gate would either reject an existing typed input or require silently
228
- // falsifying its geometry. Automatic routes still receive the side gate.
229
- problems.push(...cleanEndpointSideProblems({
230
- relations: lifecycle.transitions,
231
- endpointIds: new Set(states.keys()),
232
- pathFor,
233
- diagramType: 'lifecycle',
234
- relationCollection: 'transitions',
235
- fromSideFor: (transition) => transitionSides(transition).fromSide,
236
- toSideFor: (transition) => transitionSides(transition).toSide,
237
- shouldCheckRelation: (transition) => !Array.isArray(transition.via),
238
- routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross state borders perpendicularly',
239
- }));
240
- problems.push(...cleanFlowProblems({
241
- relations: lifecycle.transitions,
242
- obstacles: states.values(),
243
- pathFor,
244
- diagramType: 'lifecycle',
245
- relationCollection: 'transitions',
246
- obstacleKind: 'state',
247
- routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the state with col/yOffset'
248
- }));
249
- problems.push(...cleanCrossingProblems({
250
- relations: lifecycle.transitions,
251
- endpointIds: new Set(states.keys()),
252
- pathFor,
253
- diagramType: 'lifecycle',
254
- relationCollection: 'transitions',
255
- profile: lifecycle.meta?.quality_profile,
256
- routeHint: 'adjust route/via or channelX/channelY so the transitions use separate lifecycle corridors'
257
- }));
258
- problems.push(...cleanAmbiguousCorridorProblems({
259
- relations: lifecycle.transitions,
260
- endpointIds: new Set(states.keys()),
261
- pathFor,
262
- diagramType: 'lifecycle',
263
- relationCollection: 'transitions',
264
- profile: lifecycle.meta?.quality_profile,
265
- routeHint: 'adjust route/via or channelX/channelY so unrelated transitions do not visually merge'
266
- }));
267
- // Lifecycle bands are dashed reading guides, not closed containers. Keep the
268
- // shared contract wired with an explicit empty frame set so future typed
269
- // lifecycle containers cannot accidentally inherit presentation geometry.
270
- problems.push(...cleanBorderRunProblems({
271
- relations: lifecycle.transitions,
272
- endpointIds: new Set(states.keys()),
273
- frames: [],
274
- pathFor,
275
- diagramType: 'lifecycle',
276
- relationCollection: 'transitions',
277
- profile: lifecycle.meta?.quality_profile
278
- }));
279
- problems.push(...cleanRouteRhythmProblems({
280
- relations: lifecycle.transitions,
281
- endpointIds: new Set(states.keys()),
282
- pathFor,
283
- diagramType: 'lifecycle',
284
- relationCollection: 'transitions',
285
- profile: lifecycle.meta?.quality_profile,
286
- routeHint: 'move route/via or channel coordinates so each lifecycle turn has a readable run-up'
287
- }));
288
-
289
- const labelRects = [];
290
- for (const [transitionIndex, transition] of asArray(lifecycle.transitions).entries()) {
291
- if (!transition.label || !states.has(transition.from) || !states.has(transition.to)) continue;
292
- const [lx, ly] = labelPoint(transition, pathFor(transition).points);
293
- const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
294
- const width = Math.max(32, longestLine * 4.9 + 12);
295
- const height = transition.note ? 27 : 16;
296
- labelRects.push({ relation: transition, relationIndex: transitionIndex, label: transition.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
297
- }
298
- for (const rect of labelRects) {
299
- for (const state of states.values()) {
300
- if (rectsOverlap(rect, state, -2)) {
301
- problems.push(`Label "${rect.label}" overlaps state "${state.id}" - adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, state, 'state')}`);
526
+ // Each lower row starts every state under the states it connects to in the
527
+ // row above; overlapping blocks merge and centre on their combined ideal.
528
+ function placeRow(row, rowGap) {
529
+ const members = rows[row];
530
+ const ideal = new Map();
531
+ for (const state of members) {
532
+ const xs = transitions.flatMap((transition) => {
533
+ const other = transition.to === state.id ? transition.from : transition.from === state.id ? transition.to : null;
534
+ return other && depthOf(other) === row - 1 ? [states.get(other).cx] : [];
535
+ });
536
+ if (xs.length) ideal.set(state.id, xs.reduce((sum, x) => sum + x, 0) / xs.length);
537
+ }
538
+ if (row === 1) {
539
+ for (const bracket of brackets.values()) {
540
+ const center = bracket.sources.reduce((sum, id) => sum + states.get(id).cx, 0) / bracket.sources.length;
541
+ bracket.targets.forEach((id) => ideal.set(id, center));
542
+ }
543
+ }
544
+ const fallback = Math.max(...mainPath.map((id) => states.get(id).cx)) + 1;
545
+ const blocks = [];
546
+ const grouped = new Set();
547
+ for (const state of members) {
548
+ if (grouped.has(state.id)) continue;
549
+ const bracket = row === 1 ? bracketTargets.get(state.id) : null;
550
+ const list = bracket ? bracket.targets.map((id) => states.get(id)) : [state];
551
+ list.forEach((member) => grouped.add(member.id));
552
+ blocks.push({ members: list, ideal: ideal.get(state.id) ?? fallback, weight: ideal.has(state.id) ? list.length : 0.05, order: members.indexOf(state) });
553
+ }
554
+ // Neighbours joined by a labelled transition keep room for its label.
555
+ const gapBetween = (left, right) => Math.max(rowGap, ...transitions
556
+ .filter((transition) => hasLabel(transition) && ((transition.from === left.id && transition.to === right.id)
557
+ || (transition.from === right.id && transition.to === left.id)))
558
+ .map((transition) => labelBox(transition).width + 32));
559
+ blocks.sort((a, b) => a.ideal - b.ideal || a.order - b.order);
560
+ // Lay a cluster of blocks side by side and return each block's centre offset.
561
+ const arrange = (list) => {
562
+ const offsets = [];
563
+ let x = 0;
564
+ let previous = null;
565
+ for (const block of list) {
566
+ const start = x;
567
+ for (const member of block.members) {
568
+ if (previous) x += gapBetween(previous, member);
569
+ member.offset = x;
570
+ x += member.width;
571
+ previous = member;
302
572
  }
573
+ offsets.push((start + x) / 2);
574
+ }
575
+ return { width: x, offsets: offsets.map((offset) => offset - x / 2) };
576
+ };
577
+ // A cluster's centre is the weighted mean of (ideal - offset) over its
578
+ // blocks, which minimises their squared drift.
579
+ const centre = (list) => {
580
+ const { width, offsets } = arrange(list);
581
+ const weight = list.reduce((sum, block) => sum + block.weight, 0);
582
+ return { width, x: list.reduce((sum, block, index) => sum + block.weight * (block.ideal - offsets[index]), 0) / weight };
583
+ };
584
+ const clusters = [];
585
+ for (const block of blocks) {
586
+ clusters.push([block]);
587
+ while (clusters.length > 1) {
588
+ const last = clusters.at(-1);
589
+ const previous = clusters.at(-2);
590
+ const a = centre(previous);
591
+ const b = centre(last);
592
+ if (a.x + a.width / 2 + gapBetween(previous.at(-1).members.at(-1), last[0].members[0]) <= b.x - b.width / 2) break;
593
+ clusters.splice(-2, 2, [...previous, ...last]);
303
594
  }
304
595
  }
305
- for (let i = 0; i < labelRects.length; i += 1) {
306
- for (let j = i + 1; j < labelRects.length; j += 1) {
307
- if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
308
- problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap - adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
596
+ for (const cluster of clusters) {
597
+ const { x, width } = centre(cluster);
598
+ arrange(cluster);
599
+ for (const block of cluster) {
600
+ for (const member of block.members) {
601
+ member.x = x - width / 2 + member.offset;
602
+ member.cx = member.x + member.width / 2;
309
603
  }
310
604
  }
311
605
  }
312
- problems.push(...cleanLabelRouteClearanceProblems({
313
- relations: lifecycle.transitions,
314
- labels: labelRects,
315
- endpointIds: new Set(states.keys()),
316
- pathFor,
317
- diagramType: 'lifecycle',
318
- relationCollection: 'transitions',
319
- profile: lifecycle.meta?.quality_profile,
320
- }));
606
+ }
607
+
608
+ // Arcs nest by span: a shorter arc stays lower; overlapping spans never share a level.
609
+ function assignArcLevels() {
610
+ const arcs = ofKind('arc').map((transition, index) => {
611
+ const a = spineIndex.get(transition.from);
612
+ const b = spineIndex.get(transition.to);
613
+ return { transition, key: `arc${transitions.indexOf(transition)}`, lo: Math.min(a, b), hi: Math.max(a, b), index };
614
+ });
615
+ const sorted = [...arcs].sort((left, right) => (left.hi - left.lo) - (right.hi - right.lo) || left.lo - right.lo || left.index - right.index);
616
+ for (const arc of sorted) {
617
+ const blocking = sorted.filter((other) => other !== arc && other.level
618
+ && ((other.lo < arc.hi && arc.lo < other.hi) || (other.lo === other.hi && arc.lo < other.lo && other.lo < arc.hi)
619
+ || (other.lo === arc.lo && other.hi === arc.hi)));
620
+ arc.level = 1 + Math.max(0, ...blocking.map((other) => other.level));
621
+ }
622
+ return arcs;
623
+ }
321
624
 
322
- if (problems.length) {
323
- throwDiagnosticProblems('Lifecycle layout validation failed', problems, {
324
- subject: { diagramType: 'lifecycle' },
625
+ // Port order on one side avoids crossings: connections toward the left use
626
+ // the left part of the side, connections toward the right the right part.
627
+ // Nested arcs put the outer (higher) arc's port furthest out and spread evenly;
628
+ // every other port moves toward its partner so connectors can drop straight.
629
+ function distributePorts(ports, arcs, mode) {
630
+ const arcLevel = new Map(arcs.flatMap((arc) => [[`${arc.key}:out`, arc.level], [`${arc.key}:in`, arc.level]]));
631
+ // Lower-row tops follow the upper ports they connect to, so place them last.
632
+ const lowerTop = ([slot]) => slot.endsWith('|top') && !onSpine(slot.split('|')[0]);
633
+ const slots = [...ports].sort((a, b) => lowerTop(a) - lowerTop(b));
634
+ for (const [slot, entries] of slots) {
635
+ const id = slot.split('|')[0];
636
+ const state = states.get(id);
637
+ const list = [...entries.values()];
638
+ if (lowerTop([slot])) {
639
+ for (const port of list) {
640
+ const upper = port.key?.kind === 'single' ? ports.get(`${port.key.upperId}|bottom`)?.get(port.key) : null;
641
+ if (upper) port.otherX = upper.x;
642
+ }
643
+ }
644
+ list.sort((left, right) => {
645
+ const leftDirection = Math.sign(left.otherX - state.cx);
646
+ const rightDirection = Math.sign(right.otherX - state.cx);
647
+ if (leftDirection !== rightDirection) return leftDirection - rightDirection;
648
+ const leftLevel = arcLevel.get(left.key);
649
+ const rightLevel = arcLevel.get(right.key);
650
+ if (leftLevel !== undefined && rightLevel !== undefined && leftLevel !== rightLevel) {
651
+ // An outer arc's riser must stay outside every inner span it rises past.
652
+ return leftDirection < 0 ? leftLevel - rightLevel : rightLevel - leftLevel;
653
+ }
654
+ return left.otherX - right.otherX || left.order - right.order;
325
655
  });
656
+ const usable = state.width - PORT_PAD * 2;
657
+ const step = list.length > 1 ? Math.min(PORT_STEP, usable / (list.length - 1)) : 0;
658
+ const xs = mode === 'even' || (onSpine(id) && slot.endsWith('|top'))
659
+ ? list.map((_, index) => state.cx - (step * (list.length - 1)) / 2 + index * step)
660
+ : fitPorts(list.map((port) => port.otherX), step, state.x + PORT_PAD, state.x + state.width - PORT_PAD);
661
+ list.forEach((port, index) => { port.x = Math.round(xs[index] * 10) / 10; });
326
662
  }
663
+ snapPorts(ports, mode);
327
664
  }
328
665
 
329
- function routeVia(transition, from, to, start, end, fromSide, toSide) {
330
- if (transition.via) return transition.via;
331
- switch (transition.route || 'auto') {
332
- case 'straight':
333
- return [];
334
- case 'drop': {
335
- const y = transition.channelY ?? (start[1] + end[1]) / 2;
336
- return [[start[0], y], [end[0], y]];
666
+ // Ordered ports as close to their wanted x as the step and side allow: a pool
667
+ // of adjacent violators (isotonic regression on wanted - index * step).
668
+ function fitPorts(wanted, step, low, high) {
669
+ const n = wanted.length;
670
+ const top = high - step * (n - 1);
671
+ const clamp = (x) => Math.min(Math.max(x, low), Math.max(low, top));
672
+ const pools = [];
673
+ wanted.forEach((x, index) => {
674
+ pools.push({ sum: (Number.isFinite(x) ? x : x > 0 ? high : low) - index * step, count: 1 });
675
+ while (pools.length > 1 && pools.at(-2).sum / pools.at(-2).count > pools.at(-1).sum / pools.at(-1).count) {
676
+ const last = pools.pop();
677
+ pools.at(-1).sum += last.sum;
678
+ pools.at(-1).count += last.count;
337
679
  }
338
- case 'bottom-channel': {
339
- const y = transition.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 34;
340
- return [[start[0], y], [end[0], y]];
680
+ });
681
+ return pools.flatMap((pool) => Array(pool.count).fill(clamp(pool.sum / pool.count))).map((x, index) => x + index * step);
682
+ }
683
+
684
+ // A nearly vertical connector becomes exactly vertical when a port can move
685
+ // without crowding its neighbours; otherwise its jog widens to a readable run.
686
+ function snapPorts(ports, mode) {
687
+ const fits = (id, side, moving, x) => {
688
+ const state = states.get(id);
689
+ return x >= state.x + PORT_PAD - 8 && x <= state.x + state.width - PORT_PAD + 8
690
+ && [...ports.get(`${id}|${side}`).values()].every((other) => other === moving || Math.abs(other.x - x) >= 12);
691
+ };
692
+ for (const [slot, entries] of ports) {
693
+ if (!slot.endsWith('|top')) continue;
694
+ const lowerId = slot.split('|')[0];
695
+ if (onSpine(lowerId)) continue;
696
+ // Validate the final group, since neighboring ports may move together.
697
+ const group = [...entries.values()].sort((a, b) => a.x - b.x);
698
+ const state = states.get(lowerId);
699
+ const targets = group.map((port) => port.key?.kind === 'single'
700
+ ? ports.get(`${port.key.upperId}|bottom`)?.get(port.key)?.x : undefined);
701
+ if (targets.every((x, index) => Number.isFinite(x)
702
+ && x >= state.x + PORT_PAD - 8 && x <= state.x + state.width - PORT_PAD + 8
703
+ && (index === 0 || x - targets[index - 1] >= 12))) {
704
+ group.forEach((port, index) => { port.x = targets[index]; });
705
+ continue;
341
706
  }
342
- case 'top-channel': {
343
- const y = transition.channelY ?? Math.min(from.y, to.y) - 28;
344
- return [[start[0], y], [end[0], y]];
707
+ for (const port of entries.values()) {
708
+ const connector = port.key;
709
+ if (connector?.kind === 'single') {
710
+ const upperPort = ports.get(`${connector.upperId}|bottom`).get(connector);
711
+ const delta = Math.abs(upperPort.x - port.x);
712
+ if (delta < 0.5 || (delta >= 16 && mode === 'even')) continue;
713
+ if (fits(lowerId, 'top', port, upperPort.x)) port.x = upperPort.x;
714
+ else if (delta >= 16) continue;
715
+ else if (fits(connector.upperId, 'bottom', upperPort, port.x)) upperPort.x = port.x;
716
+ else {
717
+ const away = Math.sign(port.x - upperPort.x) || 1;
718
+ const pushed = [upperPort.x + away * 16, upperPort.x - away * 16].find((x) => fits(lowerId, 'top', port, x));
719
+ if (pushed !== undefined) port.x = pushed;
720
+ }
721
+ } else if (connector?.kind === 'bracket') {
722
+ // A drop next to a tick would leave a micro run on the shared bus.
723
+ const ticks = connector.bracket.sources.map((id) => ports.get(`${id}|bottom`).get(connector).x);
724
+ const near = ticks.find((x) => Math.abs(x - port.x) >= 0.5 && Math.abs(x - port.x) < 16);
725
+ if (near !== undefined && fits(lowerId, 'top', port, near)) port.x = near;
726
+ }
345
727
  }
346
- case 'right-channel': {
347
- const x = transition.channelX ?? Math.max(from.x + from.width, to.x + to.width) + 36;
348
- return [[x, start[1]], [x, end[1]]];
728
+ }
729
+ }
730
+
731
+ // Greedy interval tracks for one gap, trying several orderings and keeping
732
+ // the one with the fewest crossings.
733
+ function assignTracks(runs) {
734
+ const needs = runs.filter((run) => !run.straight);
735
+ if (!needs.length) return 0;
736
+ const mean = (xs) => xs.reduce((sum, x) => sum + x, 0) / xs.length;
737
+ const direction = (run) => (run.bottoms.length ? Math.sign(mean(run.bottoms) - mean(run.tops)) : 0);
738
+ const start = (run) => mean(run.tops);
739
+ const byStart = (a, b) => (direction(a) >= 0 ? start(b) - start(a) : start(a) - start(b));
740
+ const loopsFirst = (a, b) => (a.bottoms.length ? 1 : 0) - (b.bottoms.length ? 1 : 0);
741
+ const orderings = [
742
+ [...needs].sort((a, b) => (direction(b) - direction(a)) || byStart(a, b)),
743
+ [...needs].sort((a, b) => (direction(a) - direction(b)) || byStart(a, b)),
744
+ [...needs].sort((a, b) => (a.hi - a.lo) - (b.hi - b.lo)),
745
+ [...needs].sort((a, b) => loopsFirst(a, b) || (a.hi - a.lo) - (b.hi - b.lo)),
746
+ [...needs].sort((a, b) => -loopsFirst(a, b) || (a.hi - a.lo) - (b.hi - b.lo)),
747
+ ];
748
+ let best = null;
749
+ for (const ordering of orderings) {
750
+ const tracks = [];
751
+ const assignment = new Map();
752
+ for (const run of ordering) {
753
+ let track = 0;
754
+ while (tracks[track]?.some((other) => other.lo - 10 < run.hi && run.lo < other.hi + 10)) track += 1;
755
+ (tracks[track] ||= []).push(run);
756
+ assignment.set(run, track);
349
757
  }
350
- case 'left-channel': {
351
- const x = transition.channelX ?? Math.min(from.x, to.x) - 36;
352
- return [[x, start[1]], [x, end[1]]];
758
+ const crossings = countCrossings(runs, assignment);
759
+ if (!best || crossings < best.crossings || (crossings === best.crossings && tracks.length < best.count)) {
760
+ best = { assignment, crossings, count: tracks.length };
353
761
  }
354
- case 'auto':
355
- default: {
356
- if (start[0] === end[0] || start[1] === end[1]) return [];
357
- const fromVertical = fromSide === 'top' || fromSide === 'bottom';
358
- const toVertical = toSide === 'top' || toSide === 'bottom';
359
- if (fromVertical !== toVertical) {
360
- return [fromVertical ? [start[0], end[1]] : [end[0], start[1]]];
762
+ }
763
+ for (const run of needs) run.track = best.assignment.get(run);
764
+ return best.count;
765
+ }
766
+
767
+ function countCrossings(runs, assignment) {
768
+ // Verticals run from the upper row (y 0) to a track, then on to the lower row (y 1000).
769
+ const pieces = runs.map((run) => {
770
+ const track = assignment.get(run);
771
+ if (track === undefined) return { verticals: [[run.tops[0], 0, 1000]], horizontals: [] };
772
+ const y = 10 + track;
773
+ return {
774
+ verticals: [...run.tops.map((x) => [x, 0, y]), ...run.bottoms.map((x) => [x, y, 1000])],
775
+ horizontals: [[y, run.lo, run.hi]],
776
+ };
777
+ });
778
+ let count = 0;
779
+ for (let left = 0; left < pieces.length; left += 1) {
780
+ for (let right = 0; right < pieces.length; right += 1) {
781
+ if (left === right) continue;
782
+ for (const [x, y1, y2] of pieces[left].verticals) {
783
+ for (const [y, x1, x2] of pieces[right].horizontals) {
784
+ if (x > x1 + 0.5 && x < x2 - 0.5 && y > y1 && y < y2) count += 1;
785
+ }
786
+ }
787
+ }
788
+ }
789
+ return count;
790
+ }
791
+
792
+ // Labels sit beside the segment that enters the target (or leaves the lower
793
+ // row), above main-path arrows, and on arc and lower-loop runs. Every
794
+ // candidate keeps clear of other routes, states and labels.
795
+ function placeLabels({ routes, contentRight }) {
796
+ const placedLabels = new Map();
797
+ const unplacedLabels = [];
798
+ const segmentsOf = (transition) => {
799
+ const points = routes.get(transition)?.points || [];
800
+ return points.slice(1).map((end, index) => ({ start: points[index], end }));
801
+ };
802
+ const siblings = (transition) => {
803
+ const bracket = routes.get(transition)?.bracket;
804
+ return bracket ? bracket.transitions.filter((other) => other.to === transition.to) : [transition];
805
+ };
806
+ const others = (transition) => transitions.filter((other) => !siblings(transition).includes(other));
807
+ for (const transition of transitions) {
808
+ const route = routes.get(transition);
809
+ if (!route || route.labelMode === 'none' || !hasLabel(transition)) continue;
810
+ const { width, height } = labelBox(transition);
811
+ const [start, end] = [route.points[route.segment], route.points[route.segment + 1]];
812
+ const candidates = [];
813
+ const at = (cx, cy) => ({ x: cx - width / 2, y: cy - height / 2, width, height, cx, cy });
814
+ if (route.labelMode === 'above') {
815
+ const mid = (start[0] + end[0]) / 2;
816
+ candidates.push(at(mid, start[1] - LABEL_GAP - height / 2), at(mid, start[1] + LABEL_GAP + height / 2));
817
+ } else if (route.labelMode === 'on') {
818
+ for (const fraction of [0.5, 0.35, 0.65, 0.2, 0.8]) {
819
+ candidates.push(at(start[0] + (end[0] - start[0]) * fraction, start[1]));
361
820
  }
362
- if (fromVertical) {
363
- const y = transition.channelY ?? (start[1] + end[1]) / 2;
364
- return [[start[0], y], [end[0], y]];
821
+ } else {
822
+ const verticals = route.points.slice(1).map((point, index) => [route.points[index], point])
823
+ .filter(([a, b]) => Math.abs(a[0] - b[0]) < 0.5);
824
+ const preferred = [[start, end], ...verticals.filter(([a]) => a !== start)];
825
+ const rowState = states.get(depthOf(transition.to) < depthOf(transition.from) ? transition.from : transition.to);
826
+ for (const [a, b] of preferred) {
827
+ const x = a[0];
828
+ const mid = (a[1] + b[1]) / 2;
829
+ const sides = x <= rowState.cx ? [-1, 1] : [1, -1];
830
+ for (const side of sides) {
831
+ for (const shift of [0, -10, 10]) candidates.push(at(x + side * (LABEL_GAP + width / 2), mid + shift));
832
+ }
365
833
  }
366
- const x = transition.channelX ?? (start[0] + end[0]) / 2;
367
- return [[x, start[1]], [x, end[1]]];
834
+ const horizontal = route.points.slice(1).map((point, index) => [route.points[index], point])
835
+ .find(([a, b]) => Math.abs(a[1] - b[1]) < 0.5 && Math.abs(a[0] - b[0]) > width + 8);
836
+ if (horizontal) candidates.push(at((horizontal[0][0] + horizontal[1][0]) / 2, horizontal[0][1]));
837
+ }
838
+ const otherSegments = others(transition).flatMap(segmentsOf);
839
+ const ownSegments = siblings(transition).flatMap(segmentsOf);
840
+ const free = (rect) => rect.x >= 8 && rect.x + rect.width <= contentRight + MARGIN_X - 8 && rect.y >= 4
841
+ && [...states.values()].every((state) => !rectsOverlap(rect, state, 4))
842
+ && [...placedLabels.values()].every((other) => !rectsOverlap(rect, other, 4))
843
+ && otherSegments.every((segment) => (segmentRectClearance(segment, rect) ?? Infinity) >= CLEARANCE)
844
+ && (route.labelMode === 'on' || ownSegments.every((segment) => (segmentRectClearance(segment, rect) ?? Infinity) >= LABEL_GAP - 1));
845
+ const chosen = candidates.find(free);
846
+ if (chosen) placedLabels.set(transition, chosen);
847
+ else {
848
+ unplacedLabels.push(transition);
849
+ placedLabels.set(transition, candidates[0]);
368
850
  }
369
851
  }
852
+ return { placedLabels, unplacedLabels };
370
853
  }
371
854
 
372
- const pathCache = new Map();
855
+ // ---------------------------------------------------------------- validate
373
856
 
374
- function transitionSides(transition) {
375
- const from = states.get(transition.from);
376
- const to = states.get(transition.to);
377
- return {
378
- fromSide: chosenSide(transition.fromSide, defaultFromSide(from, to)),
379
- toSide: chosenSide(transition.toSide, defaultToSide(from, to)),
380
- };
857
+ function structureProblem(code, message, subject, supportedFixes) {
858
+ return { code, severity: 'error', message, subject: { diagramType: 'lifecycle', ...subject }, evidence: {}, supportedFixes };
381
859
  }
382
860
 
383
- const automaticPorts = automaticPortSpread(lifecycle.transitions, states, {
384
- sideFor: (transition, endpoint) => transitionSides(transition)[endpoint === 'source' ? 'fromSide' : 'toSide'],
385
- });
861
+ function validateStructure() {
862
+ const diagnostics = [];
863
+ const problems = [];
864
+ const add = (...args) => {
865
+ const diagnostic = structureProblem(...args);
866
+ diagnostics.push(diagnostic);
867
+ problems.push(diagnostic.message);
868
+ };
869
+ if (states.size !== asArray(lifecycle.states).length) {
870
+ add('lifecycle/duplicate-state', 'State ids must be unique.', { collection: 'states' }, ['give every state a unique id']);
871
+ }
872
+ const ids = [...states.keys()];
873
+ mainPath.forEach((id, index) => {
874
+ if (!states.has(id)) {
875
+ add('lifecycle/unknown-main-path-state', `mainPath[${index}] "${id}" is not a state.`, { path: `/mainPath/${index}` },
876
+ ids.slice(0, 3).map((known) => `set /mainPath/${index} to state id "${known}"`));
877
+ }
878
+ });
879
+ if (new Set(mainPath).size !== mainPath.length) {
880
+ add('lifecycle/repeated-main-path-state', 'mainPath lists a state more than once; a return to an earlier phase is a transition, not a repeated step.',
881
+ { path: '/mainPath' }, ['list each main-path state once and keep the return as a transition']);
882
+ }
883
+ transitions.forEach((transition, index) => {
884
+ for (const field of ['from', 'to']) {
885
+ if (states.has(transition[field])) continue;
886
+ add('lifecycle/unknown-endpoint', `Transition ${index} references unknown ${field === 'from' ? 'source' : 'target'} "${transition[field]}".`,
887
+ { collection: 'transitions', path: `/transitions/${index}/${field}`, transition: transition.id ?? null },
888
+ ids.slice(0, 3).map((known) => `set /transitions/${index}/${field} to state id "${known}"`));
889
+ }
890
+ });
891
+ for (let index = 0; index + 1 < mainPath.length; index += 1) {
892
+ const [from, to] = [mainPath[index], mainPath[index + 1]];
893
+ if (!transitions.some((transition) => transition.from === from && transition.to === to)) {
894
+ add('lifecycle/main-path-gap', `mainPath step "${from}" -> "${to}" has no transition.`, { path: `/mainPath/${index}` },
895
+ [`add a transition from "${from}" to "${to}"`, 'remove the state from mainPath if it is not on the happy path']);
896
+ }
897
+ }
898
+ asArray(lifecycle.states).forEach((state, index) => {
899
+ const brandRail = brandTopRailProblem(state, preferredWidth(state), TEXT.labelMinimum, 'State');
900
+ if (brandRail) problems.push(brandRail);
901
+ for (const [field, name, minimum] of [['label', 'Label', TEXT.labelMinimum], ['sublabel', 'Sublabel', TEXT.sublabelMinimum], ['tag', 'Tag', TEXT.tagMinimum]]) {
902
+ const value = state[field];
903
+ const needed = Math.ceil(minimumNodeTextWidth(value || '', minimum));
904
+ const available = availableNodeTextWidth(STATE_MAX_W) - (field === 'label' && brandMarkFor(state) ? 26 : 0);
905
+ if (value && needed > available) {
906
+ add('lifecycle/state-text-too-long',
907
+ `${name} "${value}" of state "${state.id}" is wider than the widest ${STATE_MAX_W}px state: it needs ~${needed}px at the ${minimum}px legible minimum, but ${available}px fit — shorten the ${field} and move detail into a card.`,
908
+ { collection: 'states', path: `/states/${index}/${field}` }, [`shorten the ${field} and move detail into a card`]);
909
+ }
910
+ }
911
+ });
912
+ if (problems.length) throwDiagnosticProblems('Lifecycle validation failed', problems, { subject: { diagramType: 'lifecycle' }, diagnostics });
913
+ }
386
914
 
387
- function pathFor(transition) {
388
- if (pathCache.has(transition)) return pathCache.get(transition);
389
- const from = states.get(transition.from);
390
- const to = states.get(transition.to);
391
- const ports = automaticPorts.get(transition);
392
- const { fromSide, toSide } = transitionSides(transition);
393
- const start = ports?.from || anchor(from, fromSide);
394
- const end = ports?.to || anchor(to, toSide);
395
- let via = routeVia(transition, from, to, start, end, fromSide, toSide);
396
- if (ports && !via.length && Math.abs(start[0] - end[0]) >= 4 && Math.abs(start[1] - end[1]) >= 4) {
397
- const midX = (start[0] + end[0]) / 2;
398
- via = [[midX, start[1]], [midX, end[1]]];
399
- }
400
- const points = [start, ...via, end];
401
- const routed = {
402
- d: roundedPath(points, transition.cornerRadius ?? 10),
403
- points
915
+ function validateLayout() {
916
+ const problems = [];
917
+ const diagnostics = [];
918
+ // The common width factor cancels when comparing the two text lines.
919
+ const widthDriver = (transition) => {
920
+ const label = textUnits(transition.label || '') * LABEL_FONT;
921
+ const note = textUnits(transition.note || '') * NOTE_FONT;
922
+ return label === note ? 'label+note' : label > note ? 'label' : 'note';
404
923
  };
405
- pathCache.set(transition, routed);
406
- return routed;
407
- }
408
-
409
- function bandTitles() {
410
- const lanes = asArray(lifecycle.lanes);
411
- const mainLane = lanes.find((lane) => lane.id === 'main');
412
- const terminalLane = lanes.find((lane) => lane.id === 'terminal');
413
- const eventLanes = lanes.filter((lane) => lane.id !== 'main' && lane.id !== 'terminal');
414
- return [
415
- mainLane?.label || 'Lifecycle phases',
416
- eventLanes.length ? eventLanes.map((lane) => lane.label).join(' + ') : 'Interruptions + recovery',
417
- terminalLane?.label || 'Outcomes'
418
- ];
924
+ const preserveConditions = 'preserve every condition; shorten wording only, or move complete detail to a card explicitly associated with the same transition; shorter wording may not resolve other layout constraints';
925
+ if (viewBox[0] > readableWidthBudget) {
926
+ // A labelled main-path step widens its gap past the default spacing.
927
+ const wideStepLabels = mainPath.slice(0, -1).flatMap((id, index) => {
928
+ const edge = spineEdges.get(index);
929
+ const excessPx = edge && hasLabel(edge) ? Math.round(labelBox(edge).width + 26 - spacing.spineGap) : 0;
930
+ return excessPx > 0 ? [{ from: edge.from, to: edge.to, label: edge.label || edge.note, excessPx, widthDriver: widthDriver(edge) }] : [];
931
+ }).sort((a, b) => b.excessPx - a.excessPx);
932
+ const widenerLabels = [...spacingWideners].map((transition) => `"${transition.label || transition.note}"`);
933
+ const labelFixes = [
934
+ ...(widenerLabels.length ? [`relieve the routes crowding ${widenerLabels.join(', ')}: those label/note boxes found no clear spot at the base spacing, so lower-row and route spacing grew; main-path gap growth is bounded by the text readability budget. Only equivalent exits with identical labels, notes and variants can share an arrow; keep distinct notes separate, or move a secondary transition elsewhere`] : []),
935
+ ...(wideStepLabels.length ? [`review the width-driving main-path transition text: ${wideStepLabels.map((step) => `"${step.from}" -> "${step.to}" ${step.widthDriver.replace('+', ' and ')} (+${step.excessPx}px)`).join(', ')}; ${preserveConditions}`] : []),
936
+ ];
937
+ const message = `The lifecycle is ${viewBox[0]}px wide; at ${smallestText}px text it stays readable on a desktop only up to ${readableWidthBudget}px.${wideStepLabels.length ? ` Transition labels or notes widen their main-path gaps.` : ''}`;
938
+ diagnostics.push({
939
+ code: 'lifecycle/too-wide', severity: 'error', message,
940
+ subject: { diagramType: 'lifecycle', path: '/mainPath' },
941
+ evidence: { viewBoxWidth: viewBox[0], budgetPx: readableWidthBudget, mainPathStates: mainPath.length, lowerRowStates: offStates.length, wideStepLabels, spacingWideners: [...spacingWideners].map((transition) => ({ from: transition.from, to: transition.to, label: transition.label || transition.note, widthDriver: widthDriver(transition) })) },
942
+ supportedFixes: [...labelFixes, 'shorten state labels and sublabels', 'move secondary phases off mainPath or merge adjacent phases', 'split the lifecycle into two diagrams'],
943
+ });
944
+ problems.push(message);
945
+ }
946
+ for (const transition of geometry.unplacedLabels) {
947
+ const message = `Transition "${transition.from}" -> "${transition.to}" label "${transition.label || transition.note}" cannot be placed clear of nearby routes.${transition.note ? ' Its note contributes to the label box; the box size and nearby geometry both affect placement.' : ''}`;
948
+ diagnostics.push({
949
+ code: 'lifecycle/label-unplaced', severity: 'error', message,
950
+ subject: { diagramType: 'lifecycle', collection: 'transitions', index: transitions.indexOf(transition), from: transition.from, to: transition.to },
951
+ evidence: { widthDriver: widthDriver(transition) },
952
+ supportedFixes: [`review the transition ${transition.note ? transition.label ? 'label and note' : 'note' : 'label'} wording; ${preserveConditions}`, 'only equivalent exits with identical labels, notes and variants can share one arrow; keep distinct notes on separate exits without removing them to force sharing'],
953
+ });
954
+ problems.push(message);
955
+ }
956
+ if (problems.length) throwDiagnosticProblems('Lifecycle layout validation failed', problems, { subject: { diagramType: 'lifecycle' }, diagnostics });
957
+ }
958
+
959
+ // ---------------------------------------------------------------- canvas
960
+
961
+ const LEGEND_CATALOG = ['start', 'active', 'waiting', 'decision', 'success', 'failure', 'neutral', 'external'].map((kind) => ({
962
+ kind,
963
+ label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`),
964
+ swatchWidth: kind === 'start' ? 26 : undefined,
965
+ }));
966
+
967
+ function legendCatalog() {
968
+ const presentKinds = new Set([...states.values()].map((state) => state.type));
969
+ presentKinds.add('start');
970
+ const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
971
+ if (!entries.length || ![...states.values()].some(isFinal)) return entries;
972
+ return [...entries, { kind: 'final', label: i18nText(lifecycle.meta.locale, 'legend.lifecycle.final'), interactive: false, present: true, swatchWidth: 16 }];
419
973
  }
974
+ const legendEntries = legendCatalog();
975
+ const canvasWidth = Math.max(640, Math.ceil(geometry.contentRight + MARGIN_X));
976
+ const legend = legendFootprint(legendEntries, { width: canvasWidth - 80 });
977
+ const viewBox = [canvasWidth, Math.ceil(geometry.contentBottom + (legendEntries.length ? 78 + legend.extraHeight : 32))];
978
+ validateLayout();
420
979
 
421
- function renderBands() {
422
- const right = viewBox[0] - 72;
423
- const titles = bandTitles();
424
- return ` <path d="M 72 112 L ${right} 112" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
425
- <text x="72" y="100" class="t-dim" font-size="10" font-weight="600">01 / ${esc(titles[0])}</text>
426
- <path d="M 72 264 L ${right} 264" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
427
- <text x="72" y="252" class="t-dim" font-size="10" font-weight="600">02 / ${esc(titles[1])}</text>
428
- <path d="M 72 436 L ${right} 436" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
429
- <text x="72" y="424" class="t-dim" font-size="10" font-weight="600">03 / ${esc(titles[2])}</text>`;
980
+ const stateSteps = new Map();
981
+ mainPath.forEach((id, index) => stateSteps.set(id, index));
982
+ for (const [index, transition] of transitions.entries()) {
983
+ if (!stateSteps.has(transition.to)) stateSteps.set(transition.to, index + 1);
430
984
  }
431
985
 
986
+ // ---------------------------------------------------------------- render
987
+
432
988
  function renderState(state) {
433
989
  const fill = typeClass[state.type] || typeClass.neutral;
434
990
  const accent = textClass[state.type] || 't-muted';
435
991
  const hasSub = state.sublabel != null && state.sublabel !== '';
992
+ const { label: labelFont, sublabel: sublabelFont, tag: tagFont } = fonts(state, state.width);
993
+ // Baselines keep each row's full glyph box (ascent and descent) separate.
994
+ const [labelY, sublabelY, tagY] = hasSub && state.tag ? [23, 40, 55] : hasSub ? [27, 45] : state.tag ? [29, 0, 50] : [37];
995
+ const textOffset = (state.height - STATE_H) / 2;
996
+ const rows = [{ text: state.label, font: labelFont, y: labelY + textOffset }];
997
+ if (hasSub) rows.push({ text: state.sublabel, font: sublabelFont, y: sublabelY + textOffset });
998
+ if (state.tag) rows.push({ text: state.tag, font: tagFont, y: tagY + textOffset });
999
+ const labelLayout = nodeLabelLayout({
1000
+ width: state.width, height: state.height, rows,
1001
+ brand: Boolean(brandMarkFor(state)), source: Boolean(sourceEvidence?.nodes?.[state.id]?.length), side: 'left', step: state.step,
1002
+ });
436
1003
  const sub = hasSub
437
- ? `\n <text data-detail="context" x="${state.cx}" y="${state.y + 37}" class="t-muted" font-size="${fittedNodeFontSize(state.sublabel, state.width, stateTextFit.sublabelPreferred, stateTextFit.sublabelMinimum)}" text-anchor="middle">${esc(state.sublabel)}</text>`
1004
+ ? `\n <text data-detail="context" x="${state.cx}" y="${state.y + labelLayout.ys[1]}" class="t-muted" font-size="${sublabelFont}" text-anchor="middle">${esc(state.sublabel)}</text>`
438
1005
  : '';
439
1006
  const tag = state.tag
440
- ? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + state.height - 11}" class="${accent}" font-size="${fittedNodeFontSize(state.tag, state.width, stateTextFit.tagPreferred, stateTextFit.tagMinimum)}" text-anchor="middle">${esc(state.tag)}</text>`
1007
+ ? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + labelLayout.ys[hasSub ? 2 : 1]}" class="${accent}" font-size="${tagFont}" text-anchor="middle">${esc(state.tag)}</text>`
441
1008
  : '';
442
- const hasBrand = Boolean(brandMarkFor(state));
1009
+ const icon = state.icon ?? (QUIET_TYPES.has(state.type) ? (isFinal(state) ? 'stop' : 'none') : undefined);
443
1010
  const step = state.step
444
- ? `\n <text data-detail="fine" x="${state.x + (hasBrand ? 23 : 10)}" y="${state.y + 14}" class="${accent}" font-size="7" font-weight="700">${esc(state.step)}</text>`
1011
+ ? `\n <text data-detail="fine" x="${state.x + 23}" y="${state.y + 14}" class="${accent}" font-size="${TEXT.step}" font-weight="700">${esc(state.step)}</text>`
445
1012
  : '';
446
1013
  const brand = renderBrandMark(state, { x: state.x + state.width - 22, y: state.y + 6 });
447
- const labelFontSize = fittedNodeFontSize(state.label, brandLabelFitWidth(state, state.width), 10, 8);
1014
+ const initial = state.id === mainPath[0]
1015
+ ? `\n <g aria-hidden="true" data-lifecycle-initial-marker="">${initialMarkerShape(state.x - 26, state.x - 1, state.cy)}</g>`
1016
+ : '';
1017
+ const finalBorder = isFinal(state)
1018
+ ? `\n <rect x="${state.x + 3}" y="${state.y + 3}" width="${state.width - 6}" height="${state.height - 6}" rx="5" class="${fill}" style="fill: none" stroke-width="1"/>`
1019
+ : '';
448
1020
  const passport = {
449
1021
  kind: state.type,
450
1022
  sublabel: state.sublabel,
451
1023
  tag: state.tag,
452
- context: laneLabels.get(state.lane) || i18nText(lifecycle.meta.locale, 'node.context.lifecycle'),
1024
+ context: i18nText(lifecycle.meta.locale, 'node.context.lifecycle'),
453
1025
  ...brandMetadataFor(state),
454
1026
  };
455
1027
  return ` <g ${focusNodeAttrs(state.id, state.label, passport, lifecycle.meta.locale)}>
456
1028
  ${focusNodeTitle(state.label, passport)}
457
- <rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="c-mask"/>
458
- <rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="${fill}"${animateAttr(lifecycle.meta, 'node', stateSteps.get(state.id))} stroke-width="1.5"/>
459
- ${renderSemanticSigil(state.type, { x: hasBrand ? state.x + 6 : state.x + state.width - 17, y: state.y + 6 })}${brand ? `\n ${brand}` : ''}${step}
460
- <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.cx}" y="${state.y + 21}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
1029
+ <rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="8" class="c-mask"/>
1030
+ <rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="8" class="${fill}"${animateAttr(lifecycle.meta, 'node', stateSteps.get(state.id))} stroke-width="1.5"/>${finalBorder}${initial}
1031
+ ${renderSemanticSigil(state.type, { icon, x: state.x + 6, y: state.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}${step}
1032
+ <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.x + labelLayout.x}" y="${state.y + labelLayout.ys[0]}" class="t-primary" font-size="${labelFont}" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
461
1033
  </g>`;
462
1034
  }
463
1035
 
464
- function renderTransitionPath(transition, index) {
465
- const [cls, marker] = arrowClassMap[transition.variant || 'default'] || arrowClassMap.default;
466
- const routed = pathFor(transition);
467
- const strokeWidth = transition.width || (transition.variant === 'emphasis' ? 2 : 1.1);
468
- return ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
1036
+ function variantOf(transition) {
1037
+ if (transition.variant) return transition.variant;
1038
+ return kindOf.get(transition) === 'spine' ? 'emphasis' : 'default';
469
1039
  }
470
1040
 
471
- function renderTransitionLabel(transition, index) {
472
- if (!transition.label) return '';
473
- const routed = pathFor(transition);
474
- const [lx, ly] = labelPoint(transition, routed.points);
475
- const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
476
- const labelW = Math.max(32, longestLine * 4.9 + 12);
477
- const labelH = transition.note ? 27 : 16;
1041
+ function renderTransition(transition, index) {
1042
+ const route = geometry.routes.get(transition);
1043
+ const variant = variantOf(transition);
1044
+ const [cls, marker] = arrowClassMap[variant] || arrowClassMap.default;
1045
+ const strokeWidth = variant === 'emphasis' ? 1.8 : 1.2;
1046
+ const d = roundedPath(route.points, CORNER);
1047
+ const junction = route.bracket ? ` data-composition-junction="${esc(route.bracket.key.replaceAll('\u0000', '+'))}"` : '';
1048
+ const edge = ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)} data-composition-points="${routePointsValue(route.points)}" data-composition-crossover="halo" data-composition-independent="true"${junction} d="${d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
1049
+ const underlay = ` <path data-graph-role="automatic-crossover-underlay" d="${d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>`;
1050
+ return ` <g data-graph-role="automatic-crossover" style="--step:${index}">\n${underlay}\n${edge}\n </g>`;
1051
+ }
1052
+
1053
+ function renderLabel(transition, index) {
1054
+ const rect = geometry.placedLabels.get(transition);
1055
+ if (!rect) return '';
1056
+ const variant = variantOf(transition);
1057
+ const accent = variant === 'default' ? 't-muted' : edgeLabelAccent(variant);
1058
+ const baseline = rect.y + (transition.label ? 11.5 : 11);
1059
+ const label = transition.label
1060
+ ? `\n <text x="${rect.cx}" y="${baseline}" class="${accent}" font-size="${LABEL_FONT}" text-anchor="middle">${esc(transition.label)}</text>`
1061
+ : '';
478
1062
  const note = transition.note
479
- ? `\n <text data-detail="fine" x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(transition.note)}</text>`
1063
+ ? `\n <text data-detail="fine" x="${rect.cx}" y="${baseline + (transition.label ? 12 : 0)}" class="t-muted" font-size="${NOTE_FONT}" text-anchor="middle">${esc(transition.note)}</text>`
480
1064
  : '';
481
- return ` <g data-detail="context" ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)}>
482
- <rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
483
- <text x="${lx}" y="${ly}" class="${variantAccent(transition.variant)}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>${note}
1065
+ return ` <g data-detail="${transition.label ? 'context' : 'fine'}" ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)}>
1066
+ <rect x="${rect.x}" y="${rect.y}" width="${rect.width}" height="${rect.height}" rx="4" class="c-mask"/>${label}${note}
484
1067
  </g>`;
485
1068
  }
486
1069
 
487
- const LEGEND_CATALOG = [
488
- 'start',
489
- 'active',
490
- 'waiting',
491
- 'decision',
492
- 'success',
493
- 'failure',
494
- 'neutral',
495
- 'external',
496
- ].map((kind) => ({ kind, label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`) }));
1070
+ function initialMarkerShape(dotX, tipX, y) {
1071
+ return `<circle cx="${dotX}" cy="${y}" r="4.5" style="fill: var(--arrow-emphasis)"/><path d="M ${dotX + 4.5} ${y} L ${tipX - 6} ${y}" class="a-emphasis" stroke-width="1.4"/><path d="M ${tipX - 7} ${y - 3.5} L ${tipX} ${y} L ${tipX - 7} ${y + 3.5} Z" style="fill: var(--arrow-emphasis)"/>`;
1072
+ }
1073
+
1074
+ function renderSwatch(entry) {
1075
+ if (entry.kind === 'start') return initialMarkerShape(entry.x + 4.5, entry.x + 24, entry.baseline - 3.5);
1076
+ if (entry.kind === 'final') {
1077
+ return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-external" stroke-width="1"/><rect x="${entry.x + 2.5}" y="${entry.baseline - 5.5}" width="9" height="4" rx="1" class="c-external" style="fill: none" stroke-width="0.8"/>`;
1078
+ }
1079
+ return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${typeClass[entry.kind] || 'c-external'}" stroke-width="1"/>`;
1080
+ }
497
1081
 
498
1082
  function renderLegend() {
499
- const presentKinds = new Set([...states.values()].map((state) => state.type));
500
- const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
501
1083
  return renderResolvedLegend({
502
- entries,
1084
+ entries: legendEntries,
503
1085
  locale: lifecycle.meta.locale,
504
1086
  layout: {
505
1087
  x: 40,
506
- baselineY: legendY(),
1088
+ baselineY: viewBox[1] - 32,
507
1089
  width: viewBox[0] - 80,
508
- minTitleY: lifecycleAreaBottom() + 8,
1090
+ minTitleY: geometry.contentBottom + 12,
509
1091
  unfit: lifecycle.meta?.legend === undefined ? 'hide' : 'error',
510
1092
  diagramType: 'lifecycle',
511
1093
  },
512
- renderSwatch: (entry) => `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${typeClass[entry.kind] || 'c-external'}" stroke-width="1"/>`,
1094
+ renderSwatch,
513
1095
  });
514
1096
  }
515
1097
 
516
- function renderLifecycleRail() {
517
- const mainCols = [...states.values()]
518
- .filter((state) => bandFor(state.lane) === 'phase')
519
- .map((state) => state.col);
520
- if (!mainCols.length) return '';
521
- const railEnd = layout.phaseXs[Math.max(...mainCols)] + 38;
522
- return ` <path d="M 154 ${layout.phaseY + 31} L ${railEnd} ${layout.phaseY + 31}" class="a-emphasis" stroke-width="2.2" marker-end="url(#arrowhead-emphasis)"/>`;
523
- }
524
-
525
1098
  function renderSvg() {
526
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(lifecycle.meta)}>
1099
+ const junctions = geometry.junctions.map(([x, y]) => ` <circle cx="${x}" cy="${y}" r="2.6" data-lifecycle-junction="" style="fill: var(--arrow)" aria-hidden="true"/>`).join('\n');
1100
+ const frames = geometry.frames.map((rect) => ` <rect x="${rect.x}" y="${rect.y}" width="${rect.width}" height="${rect.height}" rx="14" data-lifecycle-frame="" fill="none" stroke="var(--arrow)" stroke-opacity="0.55" stroke-width="1" stroke-dasharray="1.5 3.5" stroke-linecap="round" aria-hidden="true"/>`).join('\n');
1101
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" data-reader-fit="intrinsic-height" data-lifecycle-layout="main-path-v3" ${svgRootAttrs(lifecycle.meta)} data-motion-choreography="walk">
527
1102
  ${svgAccessibleText(lifecycle.meta, 'lifecycle')}
528
1103
  ${renderDefinitions()}
529
1104
 
530
1105
  <!-- Background Grid -->
531
1106
  <rect width="100%" height="100%" fill="url(#grid)" />
532
1107
 
533
- <!-- Lifecycle bands -->
534
- ${renderBands()}
535
-
536
- <!-- Primary lifecycle rail -->
537
- ${renderLifecycleRail()}
1108
+ <!-- Composite phases -->
1109
+ ${frames}
538
1110
 
539
1111
  <!-- Transition paths -->
540
- ${asArray(lifecycle.transitions).map(renderTransitionPath).join('\n')}
1112
+ ${transitions.map(renderTransition).join('\n')}
1113
+ ${junctions}
541
1114
 
542
1115
  <!-- States -->
543
1116
  ${[...states.values()].map(renderState).join('\n\n')}
544
1117
 
545
1118
  <!-- Transition labels -->
546
- ${asArray(lifecycle.transitions).map(renderTransitionLabel).join('\n')}
1119
+ ${transitions.map(renderLabel).filter(Boolean).join('\n')}
547
1120
 
548
1121
  <!-- Legend -->
549
1122
  ${renderLegend()}
550
1123
  </svg>`;
551
1124
  }
552
1125
 
553
- validateLifecycle();
554
1126
  writeDiagram({
555
1127
  outPath,
556
1128
  template,
@@ -558,4 +1130,5 @@ writeDiagram({
558
1130
  meta: lifecycle.meta,
559
1131
  svg: renderSvg(),
560
1132
  cards: lifecycle.cards,
1133
+ sourceEvidence,
561
1134
  });