@stacksjs/defaults 0.75.361 → 0.75.366

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +5 -5
  3. package/ai/skills/stacks-payments/SKILL.md +10 -0
  4. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  5. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  6. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  7. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  8. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  9. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  10. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  11. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  12. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  16. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  17. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  18. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  19. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  20. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  21. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  22. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  24. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  27. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  28. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  29. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  32. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  37. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  38. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  39. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  40. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  42. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  43. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  56. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  57. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  58. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  59. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  69. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  70. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  111. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  120. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  121. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  122. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  124. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  125. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  126. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  127. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  128. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  129. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  130. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  134. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  141. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  147. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  149. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  175. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  186. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  187. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  188. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  189. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  190. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  191. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  192. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  194. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  195. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  196. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  199. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  201. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  205. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  206. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  207. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  208. package/ai/skills/stacks-testing/SKILL.md +8 -1
  209. package/ide/vscode/package.json +1 -1
  210. package/package.json +2 -2
@@ -17,6 +17,14 @@ export function isFinitePoint(...coords) {
17
17
  return coords.every((c) => Number.isFinite(c));
18
18
  }
19
19
 
20
+ // Separation the layout solver guarantees and separation this check re-derives
21
+ // travel through different arithmetic, so a pair the solver placed at exactly
22
+ // its minimum can land a few ulps short of this comparison. In #583 the CLI's
23
+ // own receipt has the pair `793.6 + 160 + 8` read as 2**-43 past a5's left edge
24
+ // at 961.5999999999999 — one ulp at that magnitude. 0.0001 is the numeric
25
+ // tolerance the route and layout geometry in this file already compares with.
26
+ const NUMERIC_CLEARANCE_PX = 0.0001;
27
+
20
28
  export function rectsOverlap(a, b, gap = 0) {
21
29
  // Non-finite geometry means "unknown", not "overlapping". Every comparison
22
30
  // below is false for NaN, so without this guard the negation reports a
@@ -27,10 +35,10 @@ export function rectsOverlap(a, b, gap = 0) {
27
35
  return false;
28
36
  }
29
37
  return !(
30
- a.x + a.width + gap <= b.x ||
31
- b.x + b.width + gap <= a.x ||
32
- a.y + a.height + gap <= b.y ||
33
- b.y + b.height + gap <= a.y
38
+ a.x + a.width + gap <= b.x + NUMERIC_CLEARANCE_PX ||
39
+ b.x + b.width + gap <= a.x + NUMERIC_CLEARANCE_PX ||
40
+ a.y + a.height + gap <= b.y + NUMERIC_CLEARANCE_PX ||
41
+ b.y + b.height + gap <= a.y + NUMERIC_CLEARANCE_PX
34
42
  );
35
43
  }
36
44
 
@@ -55,21 +63,39 @@ export function segmentRectClearance(segment, rect) {
55
63
  if (!segment || !rect) return null;
56
64
  const { start, end } = segment;
57
65
  if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
58
- if (!isFinitePoint(...start, ...end, rect.x, rect.y, rect.width, rect.height)) return null;
66
+ if (!isFinitePoint(start[0], start[1], end[0], end[1], rect.x, rect.y, rect.width, rect.height)) return null;
59
67
  if (rect.width < 0 || rect.height < 0) return null;
60
68
  if (segmentIntersectsRect(segment, rect)) return 0;
61
69
 
62
- const corners = [
63
- [rect.x, rect.y],
64
- [rect.x + rect.width, rect.y],
65
- [rect.x + rect.width, rect.y + rect.height],
66
- [rect.x, rect.y + rect.height],
67
- ];
68
- return Math.min(
69
- pointRectDistance(start, rect),
70
- pointRectDistance(end, rect),
71
- ...corners.map((corner) => pointSegmentDistance(corner, start, end)),
72
- );
70
+ const right = rect.x + rect.width;
71
+ const bottom = rect.y + rect.height;
72
+ let clearance = Math.min(pointRectDistance(start, rect), pointRectDistance(end, rect));
73
+ const corners = [rect.x, rect.y, right, rect.y, right, bottom, rect.x, bottom];
74
+ for (let index = 0; index < 8; index += 2) {
75
+ const distance = pointSegmentDistanceXY(corners[index], corners[index + 1], start, end);
76
+ if (distance < clearance) clearance = distance;
77
+ }
78
+ return clearance;
79
+ }
80
+
81
+ // The same clearance, but the caller already knows the threshold it compares
82
+ // against: when an axis gap alone proves the distance cannot be smaller, the
83
+ // exact distance is never computed and Infinity is returned.
84
+ export function segmentRectClearanceWithin(segment, rect, limit) {
85
+ if (!segment || !rect) return segmentRectClearance(segment, rect);
86
+ const { start, end } = segment;
87
+ if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
88
+ if (!isFinitePoint(start[0], start[1], end[0], end[1], rect.x, rect.y, rect.width, rect.height)) return null;
89
+ if (rect.width < 0 || rect.height < 0) return null;
90
+ if (segmentIntersectsRect(segment, rect)) return 0;
91
+ const minX = Math.min(start[0], end[0]);
92
+ const maxX = Math.max(start[0], end[0]);
93
+ const minY = Math.min(start[1], end[1]);
94
+ const maxY = Math.max(start[1], end[1]);
95
+ const gapX = Math.max(rect.x - maxX, minX - (rect.x + rect.width));
96
+ const gapY = Math.max(rect.y - maxY, minY - (rect.y + rect.height));
97
+ if (Math.max(gapX, gapY) > limit) return Number.POSITIVE_INFINITY;
98
+ return segmentRectClearance(segment, rect);
73
99
  }
74
100
 
75
101
  export function segmentRectIntersectionLength(segment, rect) {
@@ -104,8 +130,82 @@ export function segmentRectIntersectionLength(segment, rect) {
104
130
  return length * Math.max(0, leave - enter);
105
131
  }
106
132
 
133
+ const ROUTE_INDEX_CELL_SIZE = 128;
134
+ const ROUTE_INDEX_MAX_CELLS_PER_ROUTE = 256;
135
+
136
+ function routeBounds(route) {
137
+ let x1 = Infinity;
138
+ let y1 = Infinity;
139
+ let x2 = -Infinity;
140
+ let y2 = -Infinity;
141
+ for (const [x, y] of route.points) {
142
+ x1 = Math.min(x1, x);
143
+ y1 = Math.min(y1, y);
144
+ x2 = Math.max(x2, x);
145
+ y2 = Math.max(y2, y);
146
+ }
147
+ return { x1, y1, x2, y2 };
148
+ }
149
+
150
+ function createRouteCandidateIndex(routes) {
151
+ const cells = new Map();
152
+ const globalRouteIndexes = [];
153
+
154
+ routes.forEach((route, routeIndex) => {
155
+ const bounds = routeBounds(route);
156
+ const x1 = Math.floor(bounds.x1 / ROUTE_INDEX_CELL_SIZE);
157
+ const y1 = Math.floor(bounds.y1 / ROUTE_INDEX_CELL_SIZE);
158
+ const x2 = Math.floor(bounds.x2 / ROUTE_INDEX_CELL_SIZE);
159
+ const y2 = Math.floor(bounds.y2 / ROUTE_INDEX_CELL_SIZE);
160
+ if (![x1, y1, x2, y2].every(Number.isSafeInteger)) {
161
+ globalRouteIndexes.push(routeIndex);
162
+ return;
163
+ }
164
+ const cellCount = (x2 - x1 + 1) * (y2 - y1 + 1);
165
+
166
+ // Very long routes are cheaper to test once per label than to duplicate
167
+ // across a large part of the index.
168
+ if (!Number.isSafeInteger(cellCount) || cellCount > ROUTE_INDEX_MAX_CELLS_PER_ROUTE) {
169
+ globalRouteIndexes.push(routeIndex);
170
+ return;
171
+ }
172
+ for (let y = y1; y <= y2; y += 1) {
173
+ for (let x = x1; x <= x2; x += 1) {
174
+ const key = `${x}:${y}`;
175
+ const bucket = cells.get(key);
176
+ if (bucket) bucket.push(routeIndex);
177
+ else cells.set(key, [routeIndex]);
178
+ }
179
+ }
180
+ });
181
+
182
+ return function candidates(rect, threshold) {
183
+ const x1 = Math.floor((rect.x - threshold) / ROUTE_INDEX_CELL_SIZE);
184
+ const y1 = Math.floor((rect.y - threshold) / ROUTE_INDEX_CELL_SIZE);
185
+ const x2 = Math.floor((rect.x + rect.width + threshold) / ROUTE_INDEX_CELL_SIZE);
186
+ const y2 = Math.floor((rect.y + rect.height + threshold) / ROUTE_INDEX_CELL_SIZE);
187
+ const cellCount = (x2 - x1 + 1) * (y2 - y1 + 1);
188
+
189
+ // A huge threshold offers no selectivity and can overflow cell iteration.
190
+ if (![x1, y1, x2, y2].every(Number.isSafeInteger)
191
+ || !Number.isSafeInteger(cellCount)
192
+ || cellCount > Math.max(64, cells.size * 4)) return routes;
193
+
194
+ const routeIndexes = new Set(globalRouteIndexes);
195
+ for (let y = y1; y <= y2; y += 1) {
196
+ for (let x = x1; x <= x2; x += 1) {
197
+ for (const routeIndex of cells.get(`${x}:${y}`) || []) routeIndexes.add(routeIndex);
198
+ }
199
+ }
200
+ return [...routeIndexes]
201
+ .sort((left, right) => left - right)
202
+ .map((routeIndex) => routes[routeIndex]);
203
+ };
204
+ }
205
+
107
206
  export function collectLabelRouteClearance({ labels, routedRelations, threshold }) {
108
207
  if (!Number.isFinite(threshold) || threshold < 0) return [];
208
+ if (threshold === 0) return [];
109
209
  const routeCandidates = asArray(routedRelations).map((entry, fallbackIndex) => {
110
210
  const relation = entry?.relation || entry;
111
211
  const points = normalizeRoutePoints(entry?.points || relation?.routePoints);
@@ -125,6 +225,7 @@ export function collectLabelRouteClearance({ labels, routedRelations, threshold
125
225
  });
126
226
  const hits = [];
127
227
  const seenLabels = new Set();
228
+ const candidateRoutes = createRouteCandidateIndex(routes);
128
229
 
129
230
  for (const [fallbackIndex, label] of asArray(labels).entries()) {
130
231
  const rect = label?.rect || label;
@@ -133,7 +234,7 @@ export function collectLabelRouteClearance({ labels, routedRelations, threshold
133
234
  const labelIdentity = relationshipIdentity(label?.relation, relationIndex);
134
235
  if (seenLabels.has(labelIdentity)) continue;
135
236
  seenLabels.add(labelIdentity);
136
- for (const route of routes) {
237
+ for (const route of candidateRoutes(rect, threshold)) {
137
238
  if (relationIndex === route.relationIndex || sameRelationship(label?.relation, route.relation)) continue;
138
239
  let nearest = null;
139
240
  for (let segmentIndex = 0; segmentIndex < route.points.length - 1; segmentIndex += 1) {
@@ -167,6 +268,15 @@ export function collectLabelRouteClearance({ labels, routedRelations, threshold
167
268
  return hits;
168
269
  }
169
270
 
271
+ export function minimumLabelRouteClearance(measurements) {
272
+ if (!asArray(measurements).length) return null;
273
+ const minimum = measurements.reduce(
274
+ (value, hit) => Math.min(value, hit.clearance),
275
+ Infinity,
276
+ );
277
+ return Math.round(minimum * 10) / 10;
278
+ }
279
+
170
280
  function relationshipIdentity(relation, relationIndex) {
171
281
  if (relation?.key !== undefined) return `key:${relation.key}`;
172
282
  if (relation?.id) return `id:${relation.from || ''}\u0000${relation.to || ''}\u0000${relation.id}`;
@@ -191,6 +301,20 @@ function relationshipSubject(diagramType, relationCollection, relationIndex, rel
191
301
  };
192
302
  }
193
303
 
304
+ const AUTHORED_PLAN_KEYS = ['route', 'via', 'channelX', 'channelY', 'fromSide', 'toSide', 'bias', 'labelAt', 'labelDx', 'labelDy', 'labelSegment'];
305
+
306
+ // Re-planning advice only applies when the failing relation actually carries
307
+ // authored controls; telling an author to remove controls that do not exist
308
+ // just sends them hunting for a field that was never written.
309
+ function rePlanHint(relations, fallback) {
310
+ const controls = new Set();
311
+ for (const relation of asArray(relations)) {
312
+ for (const key of AUTHORED_PLAN_KEYS) if (relation?.[key] !== undefined) controls.add(key);
313
+ }
314
+ if (!controls.size) return fallback;
315
+ return `if the authored ${[...controls].join('/')} are not required by the user, remove them so the renderer can re-plan; otherwise preserve that intent and ${fallback}`;
316
+ }
317
+
194
318
  const ENDPOINT_SIDE_RULES = {
195
319
  left: {
196
320
  axis: 'horizontal',
@@ -254,7 +378,6 @@ export function routeHonorsEndpointSides(points, fromSide, toSide) {
254
378
  return !endpointSideIssue(points, 'source', fromSide)
255
379
  && !endpointSideIssue(points, 'target', toSide);
256
380
  }
257
-
258
381
  // Explicit fromSide/toSide are authored geometry, so a tangent or backwards
259
382
  // endpoint segment changes their meaning. Fail this universally instead of
260
383
  // leaving a malformed arrow for visual review to discover. Named routes and
@@ -307,7 +430,7 @@ export function cleanEndpointSideProblems({
307
430
  const segmentRole = issue.endpoint === 'source' ? 'first' : 'final';
308
431
  const from = issue.start.map((value) => Math.round(value * 10) / 10).join(', ');
309
432
  const to = issue.end.map((value) => Math.round(value * 10) / 10).join(', ');
310
- const message = `[clean-flow/endpoint-side-direction] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" ${segmentRole} segment ${issue.segmentIndex} [${from}] -> [${to}] does not honor ${sideField} "${issue.side}" - it must run ${issue.expectedAxis} ${issue.expectedDirection}; ${routeHint}.`;
433
+ const message = `[clean-flow/endpoint-side-direction] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" ${segmentRole} segment ${issue.segmentIndex} [${from}] -> [${to}] does not honor ${sideField} "${issue.side}" — it must run ${issue.expectedAxis} ${issue.expectedDirection}; ${routeHint}.`;
311
434
  recordDiagnostic({
312
435
  code: 'clean-flow/endpoint-side-direction',
313
436
  severity: 'error',
@@ -376,7 +499,8 @@ export function cleanFlowProblems({
376
499
  const from = points[hitSegment].map(Math.round).join(', ');
377
500
  const to = points[hitSegment + 1].map(Math.round).join(', ');
378
501
  const relationId = relation.id ? ` id "${relation.id}"` : '';
379
- const message = `[clean-flow/edge-through-node] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" crosses ${obstacleKind} "${obstacle.id}" (unrelated to this relationship) on segment ${hitSegment} [${from}] -> [${to}] (${clearance}px clearance) - ${routeHint}.`;
502
+ const hint = rePlanHint([relation], routeHint);
503
+ const message = `[clean-flow/edge-through-node] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" crosses ${obstacleKind} "${obstacle.id}" (unrelated to this relationship) on segment ${hitSegment} [${from}] -> [${to}] (${clearance}px clearance) — ${hint}.`;
380
504
  recordDiagnostic({
381
505
  code: 'clean-flow/edge-through-node',
382
506
  severity: 'error',
@@ -390,7 +514,7 @@ export function cleanFlowProblems({
390
514
  to: points[hitSegment + 1],
391
515
  clearancePx: clearance,
392
516
  },
393
- supportedFixes: [routeHint],
517
+ supportedFixes: [hint],
394
518
  });
395
519
  problems.push(message);
396
520
  }
@@ -477,10 +601,29 @@ function pointLiesOnSegment(point, start, end) {
477
601
  && point[1] <= Math.max(start[1], end[1]) + epsilon;
478
602
  }
479
603
 
480
- // Reject only a proper interior X between relationships that share no semantic
481
- // endpoint. Endpoint touches, branch/merge ports, and collinear shared
482
- // corridors are intentionally outside this contract because geometry alone
483
- // cannot tell whether those are authored junctions.
604
+ // A bend meeting another route's interior can look like a branch even when
605
+ // no pair of segment interiors forms a proper X. Keep actual route endpoints
606
+ // out of this geometric query; callers decide which relationships are allowed
607
+ // to share junctions. Collinear corridors remain a separate quality measure.
608
+ export function polylineBendContacts(left, right) {
609
+ const contacts = [];
610
+ const near = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= 0.0001;
611
+ for (const [points, other] of [[left, right], [right, left]]) {
612
+ if (other.length < 2) continue;
613
+ for (let index = 1; index < points.length - 1; index += 1) {
614
+ const point = points[index];
615
+ if (Math.abs(crossProduct(points[index - 1], point, points[index + 1])) <= 0.0001
616
+ || near(point, other[0]) || near(point, other.at(-1))) continue;
617
+ if (other.slice(1).some((end, segment) => pointLiesOnSegment(point, other[segment], end))
618
+ && !contacts.some(otherPoint => near(point, otherPoint))) contacts.push(point);
619
+ }
620
+ }
621
+ return contacts;
622
+ }
623
+
624
+ // Authored shared endpoints retain their junction interpretation by default.
625
+ // Renderers can opt their automatic routes into proper interior X checks;
626
+ // endpoint touches and collinear trunks still are not proper crossings.
484
627
  export function cleanCrossingProblems({
485
628
  relations,
486
629
  endpointIds,
@@ -490,9 +633,14 @@ export function cleanCrossingProblems({
490
633
  profile = 'standard',
491
634
  profileIsAuthoritative = false,
492
635
  mergeForwardCollinearWaypoints = false,
636
+ includeSharedEndpoints = () => false,
637
+ crossingResolved = () => false,
638
+ warnInStandard = false,
639
+ onDiagnostic = recordDiagnostic,
493
640
  routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors'
494
641
  }) {
495
- if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
642
+ const severity = qualityProfileForGate(profile, profileIsAuthoritative) === 'showcase' ? 'error' : 'warning';
643
+ if (severity === 'warning' && !warnInStandard) return [];
496
644
  const routed = asArray(relations).map((relation, index) => {
497
645
  if (!relation || !endpointIds.has(relation.from) || !endpointIds.has(relation.to)) return null;
498
646
  const points = pathFor(relation)?.points;
@@ -513,7 +661,8 @@ export function cleanCrossingProblems({
513
661
  const left = routed[leftIndex];
514
662
  for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
515
663
  const right = routed[rightIndex];
516
- if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)) continue;
664
+ if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)
665
+ && !includeSharedEndpoints(left.relation, right.relation)) continue;
517
666
 
518
667
  let hit = null;
519
668
  for (const leftSegment of left.analysisSegments) {
@@ -536,16 +685,18 @@ export function cleanCrossingProblems({
536
685
  }
537
686
  }
538
687
  if (!hit) continue;
688
+ if (crossingResolved(left.relation, right.relation, hit)) continue;
539
689
 
540
690
  const describe = ({ relation, index }) => {
541
691
  const id = relation.id ? ` id "${relation.id}"` : '';
542
692
  return `${relationCollection}[${index}]${id} "${relation.from}" -> "${relation.to}"`;
543
693
  };
544
694
  const point = hit.point.map((value) => Math.round(value * 10) / 10).join(', ');
545
- const message = `[composition/proper-crossing] showcase ${diagramType} ${describe(left)} crosses ${describe(right)} at [${point}] (segments ${hit.leftSegment} and ${hit.rightSegment}) - ${routeHint}.`;
546
- recordDiagnostic({
695
+ const hint = rePlanHint([left.relation, right.relation], routeHint);
696
+ const message = `[composition/proper-crossing] ${severity === 'error' ? 'showcase' : 'standard'} ${diagramType} ${describe(left)} crosses ${describe(right)} at [${point}] (segments ${hit.leftSegment} and ${hit.rightSegment}) — ${hint}.`;
697
+ onDiagnostic({
547
698
  code: 'composition/proper-crossing',
548
- severity: 'error',
699
+ severity,
549
700
  message,
550
701
  subject: relationshipSubject(diagramType, relationCollection, left.index, left.relation),
551
702
  evidence: {
@@ -554,9 +705,9 @@ export function cleanCrossingProblems({
554
705
  segmentIndex: hit.leftSegment,
555
706
  otherSegmentIndex: hit.rightSegment,
556
707
  },
557
- supportedFixes: [routeHint],
708
+ supportedFixes: [hint],
558
709
  });
559
- problems.push(message);
710
+ if (severity === 'error') problems.push(message);
560
711
  }
561
712
  }
562
713
  return problems;
@@ -564,12 +715,24 @@ export function cleanCrossingProblems({
564
715
 
565
716
  // Two unrelated relationships that occupy the same visible corridor can read
566
717
  // as one authored branch or merge even when neither relationship crosses a
567
- // node or forms a proper X. Keep shared semantic endpoints exempt: their
568
- // initial/final fan-out is real topology. Tiny overlaps below the route rhythm
718
+ // node or forms a proper X. Keep authored shared endpoints exempt by default;
719
+ // automatic architecture routes opt in because they promise separate ports.
720
+ // Workflow v2 opts into shared-endpoint checks with a bounded terminal-trunk
721
+ // exception. Other callers keep their existing authored-junction contract.
722
+ // The counterflow-only opt-in serves older workflow exports without a root
723
+ // readable-v2 contract; full shared-endpoint checking takes precedence.
724
+ // Relationships a renderer declares on one junction (a Lifecycle exit bracket)
725
+ // may share that junction's horizontal bus, the first tick from a common
726
+ // source port and, when they end at the same state, the merged drop into it;
727
+ // other overlaps between them count.
728
+ // Tiny overlaps below the route rhythm
569
729
  // floor are ignored to avoid turning sub-pixel rounding into a quality debt.
570
730
  export function collectAmbiguousCorridors({
571
731
  routedRelations,
572
732
  minOverlapPx = 8,
733
+ includeSharedEndpoints = () => false,
734
+ includeSharedEndpointCounterflow = () => false,
735
+ allowShortWorkflowTrunks = false,
573
736
  }) {
574
737
  const routed = asArray(routedRelations).map((entry, fallbackIndex) => {
575
738
  const relation = entry?.relation;
@@ -580,6 +743,8 @@ export function collectAmbiguousCorridors({
580
743
  relation,
581
744
  relationIndex: Number.isInteger(entry.relationIndex) ? entry.relationIndex : fallbackIndex,
582
745
  points,
746
+ ...(entry.sourceEndpoint === false ? { sourceEndpoint: false } : {}),
747
+ ...(entry.targetEndpoint === false ? { targetEndpoint: false } : {}),
583
748
  };
584
749
  }).filter(Boolean);
585
750
  const hits = [];
@@ -588,7 +753,10 @@ export function collectAmbiguousCorridors({
588
753
  const left = routed[leftIndex];
589
754
  for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
590
755
  const right = routed[rightIndex];
591
- if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)) continue;
756
+ const sharedBus = Boolean(left.relation.junction) && left.relation.junction === right.relation.junction;
757
+ const sharedEndpoint = [left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to);
758
+ const counterflowOnly = sharedEndpoint && !includeSharedEndpoints(left.relation, right.relation);
759
+ if (counterflowOnly && !includeSharedEndpointCounterflow(left.relation, right.relation)) continue;
592
760
 
593
761
  let longest = null;
594
762
  for (let leftSegment = 0; leftSegment < left.points.length - 1; leftSegment += 1) {
@@ -600,6 +768,21 @@ export function collectAmbiguousCorridors({
600
768
  right.points[rightSegment + 1],
601
769
  );
602
770
  if (!overlap || overlap.length + 0.0001 < minOverlapPx) continue;
771
+ if (allowShortWorkflowTrunks && shortWorkflowTrunk(left, right, leftSegment, rightSegment, overlap.length)) continue;
772
+ // A multi-target bracket repeats its source tick for each target.
773
+ // Limit that exemption to the first segment at the same source port:
774
+ // a common source identity cannot waive an interior vertical overlap.
775
+ const sharedSourceTick = left.sourceEndpoint !== false && right.sourceEndpoint !== false
776
+ && left.relation.from === right.relation.from && leftSegment === 0 && rightSegment === 0
777
+ && Math.abs(left.points[0][0] - right.points[0][0]) < 0.0001
778
+ && Math.abs(left.points[0][1] - right.points[0][1]) < 0.0001;
779
+ if (sharedBus && (Math.abs(overlap.start[1] - overlap.end[1]) < 0.0001
780
+ || left.relation.to === right.relation.to || sharedSourceTick)) continue;
781
+ if (counterflowOnly) {
782
+ const leftDelta = left.points[leftSegment + 1].map((value, axis) => value - left.points[leftSegment][axis]);
783
+ const rightDelta = right.points[rightSegment + 1].map((value, axis) => value - right.points[rightSegment][axis]);
784
+ if (leftDelta[0] * rightDelta[0] + leftDelta[1] * rightDelta[1] >= 0) continue;
785
+ }
603
786
  if (!longest || overlap.length > longest.overlapLength + 0.0001) {
604
787
  longest = {
605
788
  left,
@@ -619,6 +802,64 @@ export function collectAmbiguousCorridors({
619
802
  return hits;
620
803
  }
621
804
 
805
+ function shortWorkflowTrunk(left, right, leftSegment, rightSegment, length) {
806
+ if (length > 24 + 0.0001) return false;
807
+ const a = left.relation, b = right.relation;
808
+ const variant = (edge) => edge.variant || 'default';
809
+ const width = (edge) => edge.width || (variant(edge) === 'emphasis' ? 1.8 : 1.4);
810
+ if (variant(a) !== variant(b) || width(a) !== width(b) || (a.role || '') !== (b.role || '')) return false;
811
+ const same = (p, q) => Math.abs(p[0] - q[0]) < 0.0001 && Math.abs(p[1] - q[1]) < 0.0001;
812
+ const source = left.sourceEndpoint !== false && right.sourceEndpoint !== false
813
+ && a.from === b.from && leftSegment === 0 && rightSegment === 0
814
+ && same(left.points[0], right.points[0]);
815
+ const target = left.targetEndpoint !== false && right.targetEndpoint !== false
816
+ && a.to === b.to && leftSegment === left.points.length - 2 && rightSegment === right.points.length - 2
817
+ && same(left.points.at(-1), right.points.at(-1));
818
+ if (!source && !target) return false;
819
+ const p = left.points[leftSegment], q = left.points[leftSegment + 1];
820
+ const r = right.points[rightSegment], s = right.points[rightSegment + 1];
821
+ return (q[0] - p[0]) * (s[0] - r[0]) + (q[1] - p[1]) * (s[1] - r[1]) > 0;
822
+ }
823
+
824
+ // Bundled arrow markers are 7 stroke-widths across the direction of travel.
825
+ // Callers select the automatic routes they own; explicit junctions are preserved,
826
+ // and relations on one junction ending at the same state share a merged drop.
827
+ export function collectArrowheadCollisions({ routedRelations, allowShortWorkflowTrunks = false }) {
828
+ const incoming = new Map();
829
+ const hits = [];
830
+ for (const entry of asArray(routedRelations)) {
831
+ const points = normalizeRoutePoints(entry.points);
832
+ if (points.length < 2 || !entry.relation?.to) continue;
833
+ const tip = points.at(-1);
834
+ const previous = points.at(-2);
835
+ const vertical = Math.abs(tip[0] - previous[0]) < 0.0001;
836
+ const axis = vertical ? 0 : 1;
837
+ const direction = Math.sign(tip[1 - axis] - previous[1 - axis]);
838
+ const halfWidth = 3.5 * (entry.relation.width || (entry.relation.variant === 'emphasis' ? 1.8 : 1.5));
839
+ const key = `${entry.relation.to}\u0000${axis}\u0000${direction}`;
840
+ const siblings = incoming.get(key) || [];
841
+ const current = { ...entry, tip, halfWidth };
842
+ for (const sibling of siblings) {
843
+ // One junction's merged drop into a state carries a single arrowhead.
844
+ if (sibling.relation.junction && sibling.relation.junction === entry.relation.junction
845
+ && sibling.relation.to === entry.relation.to) continue;
846
+ if (Math.abs(tip[1 - axis] - sibling.tip[1 - axis]) > 0.0001) continue;
847
+ const distance = Math.abs(tip[axis] - sibling.tip[axis]);
848
+ const minimum = halfWidth + sibling.halfWidth;
849
+ if (allowShortWorkflowTrunks) {
850
+ const left = { ...sibling, points: normalizeRoutePoints(sibling.points) };
851
+ const right = { ...entry, points };
852
+ const overlap = collinearAxisOverlap(left.points.at(-2), left.points.at(-1), points.at(-2), tip);
853
+ if (overlap && shortWorkflowTrunk(left, right, left.points.length - 2, points.length - 2, overlap.length)) continue;
854
+ }
855
+ if (distance < minimum - 0.0001) hits.push({ left: sibling, right: current, distance, minimum });
856
+ }
857
+ siblings.push(current);
858
+ incoming.set(key, siblings);
859
+ }
860
+ return hits;
861
+ }
862
+
622
863
  export function cleanAmbiguousCorridorProblems({
623
864
  relations,
624
865
  endpointIds,
@@ -629,11 +870,16 @@ export function cleanAmbiguousCorridorProblems({
629
870
  profileIsAuthoritative = false,
630
871
  routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors',
631
872
  minOverlapPx = 8,
873
+ includeSharedEndpoints = () => false,
874
+ includeSharedEndpointCounterflow = () => false,
875
+ allowShortWorkflowTrunks = false,
876
+ onDiagnostic = recordDiagnostic,
632
877
  }) {
633
- if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
878
+ const severity = qualityProfileForGate(profile, profileIsAuthoritative) === 'showcase' ? 'error' : 'warning';
879
+ if (severity === 'warning' && !allowShortWorkflowTrunks) return [];
634
880
  const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
635
881
 
636
- return collectAmbiguousCorridors({ routedRelations, minOverlapPx }).map((hit) => {
882
+ return collectAmbiguousCorridors({ routedRelations, minOverlapPx, includeSharedEndpoints, includeSharedEndpointCounterflow, allowShortWorkflowTrunks }).map((hit) => {
637
883
  const describe = ({ relation, relationIndex }) => {
638
884
  const id = relation.id ? ` id "${relation.id}"` : '';
639
885
  return `${relationCollection}[${relationIndex}]${id} "${relation.from}" -> "${relation.to}"`;
@@ -641,10 +887,11 @@ export function cleanAmbiguousCorridorProblems({
641
887
  const length = Math.round(hit.overlapLength * 10) / 10;
642
888
  const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
643
889
  const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
644
- const message = `[composition/ambiguous-corridor] showcase ${diagramType} ${describe(hit.left)} shares a ${length}px corridor with ${describe(hit.right)} at [${from}] -> [${to}] (segments ${hit.leftSegment} and ${hit.rightSegment}; minimum ${minOverlapPx}px) - ${routeHint}.`;
645
- recordDiagnostic({
890
+ const hint = rePlanHint([hit.left.relation, hit.right.relation], routeHint);
891
+ const message = `[composition/ambiguous-corridor] ${severity === 'error' ? 'showcase' : 'standard'} ${diagramType} ${describe(hit.left)} shares a ${length}px corridor with ${describe(hit.right)} at [${from}] -> [${to}] (segments ${hit.leftSegment} and ${hit.rightSegment}; minimum ${minOverlapPx}px) — ${hint}.`;
892
+ onDiagnostic({
646
893
  code: 'composition/ambiguous-corridor',
647
- severity: 'error',
894
+ severity,
648
895
  message,
649
896
  subject: relationshipSubject(diagramType, relationCollection, hit.left.relationIndex, hit.left.relation),
650
897
  evidence: {
@@ -656,10 +903,10 @@ export function cleanAmbiguousCorridorProblems({
656
903
  segmentIndex: hit.leftSegment,
657
904
  otherSegmentIndex: hit.rightSegment,
658
905
  },
659
- supportedFixes: [routeHint],
906
+ supportedFixes: [hint],
660
907
  });
661
- return message;
662
- });
908
+ return severity === 'error' ? message : null;
909
+ }).filter(Boolean);
663
910
  }
664
911
 
665
912
  // Relationship paths may cross a structural frame, but they must not borrow a
@@ -701,7 +948,7 @@ export function collectBorderRuns({ routedRelations, frames }) {
701
948
  frame,
702
949
  frameIndex,
703
950
  side: border.side,
704
- segmentIndex: Math.min(...overlaps.map((overlap) => overlap.segmentIndex)),
951
+ segmentIndex: overlaps.reduce((min, overlap) => Math.min(min, overlap.segmentIndex), Infinity),
705
952
  overlapLength: merged.reduce((total, overlap) => total + overlap.length, 0),
706
953
  overlapStart: longest.start,
707
954
  overlapEnd: longest.end,
@@ -733,7 +980,8 @@ export function cleanBorderRunProblems({
733
980
  const length = Math.round(hit.overlapLength * 10) / 10;
734
981
  const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
735
982
  const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
736
- const message = `[composition/container-border-run] ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" follows ${frameKind} "${frameIdentity}" ${hit.side} border for ${length}px on segment ${hit.segmentIndex} [${from}] -> [${to}] - ${routeHint}.`;
983
+ const hint = rePlanHint([relation], routeHint);
984
+ const message = `[composition/container-border-run] ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" follows ${frameKind} "${frameIdentity}" ${hit.side} border for ${length}px on segment ${hit.segmentIndex} [${from}] -> [${to}] — ${hint}.`;
737
985
  recordDiagnostic({
738
986
  code: 'composition/container-border-run',
739
987
  severity: 'error',
@@ -749,7 +997,7 @@ export function cleanBorderRunProblems({
749
997
  from: hit.overlapStart,
750
998
  to: hit.overlapEnd,
751
999
  },
752
- supportedFixes: [routeHint],
1000
+ supportedFixes: [hint],
753
1001
  });
754
1002
  return message;
755
1003
  });
@@ -878,7 +1126,8 @@ export function cleanRouteRhythmProblems({
878
1126
  const rule = hit.code === 'composition/micro-segment'
879
1127
  ? `is below the ${microSegmentPx}px micro-segment floor`
880
1128
  : `is below the ${interiorSegmentPx}px interior-segment floor`;
881
- const message = `[${hit.code}] showcase ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" has a ${length}px ${hit.position} segment ${hit.segmentIndex} [${from}] -> [${to}] that ${rule} - ${routeHint}.`;
1129
+ const hint = rePlanHint([relation], routeHint);
1130
+ const message = `[${hit.code}] showcase ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" has a ${length}px ${hit.position} segment ${hit.segmentIndex} [${from}] -> [${to}] that ${rule} — ${hint}.`;
882
1131
  recordDiagnostic({
883
1132
  code: hit.code,
884
1133
  severity: 'error',
@@ -892,7 +1141,7 @@ export function cleanRouteRhythmProblems({
892
1141
  from: hit.start,
893
1142
  to: hit.end,
894
1143
  },
895
- supportedFixes: [routeHint],
1144
+ supportedFixes: [hint],
896
1145
  });
897
1146
  return message;
898
1147
  });
@@ -921,7 +1170,8 @@ export function cleanLabelRouteClearanceProblems({
921
1170
  const clearance = Math.round(hit.clearance * 10) / 10;
922
1171
  const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', ');
923
1172
  const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', ');
924
- const message = `[composition/label-route-clearance] showcase ${diagramType} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${describe(hit.labelRelation, hit.labelRelationIndex)} is ${clearance}px from ${describe(hit.otherRelation, hit.otherRelationIndex)} segment ${hit.segmentIndex} [${from}] -> [${to}] (label rect ${formatRect(hit.rect)}; minimum ${threshold}px) - ${routeHint}.`;
1173
+ const hint = rePlanHint([hit.labelRelation, hit.otherRelation], routeHint);
1174
+ const message = `[composition/label-route-clearance] showcase ${diagramType} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${describe(hit.labelRelation, hit.labelRelationIndex)} is ${clearance}px from ${describe(hit.otherRelation, hit.otherRelationIndex)} segment ${hit.segmentIndex} [${from}] -> [${to}] (label rect ${formatRect(hit.rect)}; minimum ${threshold}px) — ${hint}.`;
925
1175
  recordDiagnostic({
926
1176
  code: 'composition/label-route-clearance',
927
1177
  severity: 'error',
@@ -937,7 +1187,113 @@ export function cleanLabelRouteClearanceProblems({
937
1187
  from: hit.start,
938
1188
  to: hit.end,
939
1189
  },
940
- supportedFixes: [routeHint],
1190
+ supportedFixes: [hint],
1191
+ });
1192
+ return message;
1193
+ });
1194
+ }
1195
+
1196
+ // How far [start, start + size) leaves [0, extent), per end of one axis. This
1197
+ // is the single definition of "inside the canvas" that both the containment
1198
+ // rule and the repair hints measure against; an unknown extent bounds nothing.
1199
+ function axisCanvasOverflow(start, size, extent, origin = 0) {
1200
+ if (!Number.isFinite(extent)) return { start: 0, end: 0 };
1201
+ return { start: origin - start, end: start + size - (origin + extent) };
1202
+ }
1203
+
1204
+ // THE CANVAS CONTAINMENT RATIONALE (referenced from the other call sites).
1205
+ //
1206
+ // The SVG canvas clips whatever leaves the viewBox, so an edge label that
1207
+ // overhangs an edge ships as truncated text while every post-render check on
1208
+ // the emitted markup still passes: clipped text is still well-formed markup.
1209
+ // Renderers with a fixed canvas already bound their nodes, lanes, and legends
1210
+ // against it; edge label rects were the exception.
1211
+ //
1212
+ // Not applied to readable-v2 workflows: that compiler grows its canvas around
1213
+ // pinned label rects and rejects an authored viewBox that cannot hold the
1214
+ // result, so the only gap growth cannot close is the origin its own rule
1215
+ // already guards. Architecture's auto canvas grows the same way, which leaves
1216
+ // this rule reporting authored viewBoxes and the origin side.
1217
+ //
1218
+ // Showcase only: a standard document authored before the rule exists may
1219
+ // overhang by a few pixels, and failing it there would break compatibility
1220
+ // instead of repairing a diagram.
1221
+ export function collectLabelCanvasOverflow({ labels, viewBox, tolerance = 0.5 }) {
1222
+ // Renderers author origin-zero canvases (meta.viewBox is [width, height] by
1223
+ // schema), but `check` re-measures foreign artifacts whose SVG viewBox may
1224
+ // carry a legal non-zero min-x/min-y; those pass all four numbers.
1225
+ const box = asArray(viewBox);
1226
+ const [originX, originY, canvasWidth, canvasHeight] = box.length === 4 ? box : [0, 0, box[0], box[1]];
1227
+ if (!isFinitePoint(originX, originY, canvasWidth, canvasHeight)) return [];
1228
+ const hits = [];
1229
+ for (const [fallbackIndex, label] of asArray(labels).entries()) {
1230
+ const rect = label?.rect || label;
1231
+ if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height)) continue;
1232
+ // `check` parses foreign markup, so a malformed negative-size rect is
1233
+ // skipped like the other label collectors do — its flipped interval would
1234
+ // otherwise read as contained.
1235
+ if (rect.width < 0 || rect.height < 0) continue;
1236
+ const horizontal = axisCanvasOverflow(rect.x, rect.width, canvasWidth, originX);
1237
+ const vertical = axisCanvasOverflow(rect.y, rect.height, canvasHeight, originY);
1238
+ const overflow = {
1239
+ left: horizontal.start,
1240
+ right: horizontal.end,
1241
+ top: vertical.start,
1242
+ bottom: vertical.end,
1243
+ };
1244
+ const sides = Object.keys(overflow).filter((side) => overflow[side] > tolerance);
1245
+ if (!sides.length) continue;
1246
+ hits.push({
1247
+ label,
1248
+ relation: label?.relation,
1249
+ relationIndex: Number.isInteger(label?.relationIndex) ? label.relationIndex : fallbackIndex,
1250
+ rect,
1251
+ viewBox: [canvasWidth, canvasHeight],
1252
+ viewBoxOrigin: [originX, originY],
1253
+ sides,
1254
+ overflowPx: Object.fromEntries(sides.map((side) => [side, Math.round(overflow[side] * 10) / 10])),
1255
+ tolerance,
1256
+ });
1257
+ }
1258
+ return hits;
1259
+ }
1260
+
1261
+ export function describeLabelCanvasOverflow(hit) {
1262
+ return hit.sides.map((side) => `${side} edge by ${hit.overflowPx[side]}px`).join(' and ');
1263
+ }
1264
+
1265
+ export function cleanLabelCanvasContainmentProblems({
1266
+ labels,
1267
+ viewBox,
1268
+ diagramType,
1269
+ relationCollection,
1270
+ profile,
1271
+ profileIsAuthoritative = false,
1272
+ routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise enlarge meta.viewBox',
1273
+ // Label widths are estimated from the text, not measured, so sub-pixel
1274
+ // overhang is rounding noise rather than a visible truncation.
1275
+ tolerance = 0.5,
1276
+ }) {
1277
+ if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
1278
+ return collectLabelCanvasOverflow({ labels, viewBox, tolerance }).map((hit) => {
1279
+ const relation = hit.relation;
1280
+ const relationId = relation?.id ? ` id "${relation.id}"` : '';
1281
+ const labelText = hit.label?.label || relation?.label || '';
1282
+ const hint = rePlanHint([relation], routeHint);
1283
+ const message = `[composition/label-canvas-containment] showcase ${diagramType} label "${labelText}" on ${relationCollection}[${hit.relationIndex}]${relationId} "${relation?.from}" -> "${relation?.to}" extends past the ${describeLabelCanvasOverflow(hit)} (label rect ${formatRect(hit.rect)}; viewBox ${hit.viewBox[0]}x${hit.viewBox[1]}) — ${hint}.`;
1284
+ recordDiagnostic({
1285
+ code: 'composition/label-canvas-containment',
1286
+ severity: 'error',
1287
+ message,
1288
+ subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation),
1289
+ evidence: {
1290
+ label: labelText,
1291
+ labelRect: { x: hit.rect.x, y: hit.rect.y, width: hit.rect.width, height: hit.rect.height },
1292
+ viewBox: hit.viewBox,
1293
+ overflowPx: hit.overflowPx,
1294
+ tolerancePx: hit.tolerance,
1295
+ },
1296
+ supportedFixes: [hint],
941
1297
  });
942
1298
  return message;
943
1299
  });
@@ -963,18 +1319,54 @@ function segmentPosition(index, segmentCount) {
963
1319
  return 'interior';
964
1320
  }
965
1321
 
1322
+ // Normalizing a route is pure, and the same route array is normalized again by
1323
+ // every predicate that inspects it. Keyed by the array itself, so a rebuilt
1324
+ // route simply gets a new entry. The result's outer array is frozen. Callers
1325
+ // must also treat input arrays and their coordinate pairs as immutable, since
1326
+ // edits in place cannot invalidate this identity-based cache.
1327
+ const NORMALIZED_ROUTE_POINTS = new WeakMap();
1328
+
1329
+ // Assembling a route from an anchor, corridor points and an anchor is the same
1330
+ // normalization without the intermediate array, and the result is seeded in the
1331
+ // cache so later predicates do not normalize it again.
1332
+ export function joinRoutePoints(start, via, end) {
1333
+ const normalized = [];
1334
+ const accept = (point) => {
1335
+ if (!Array.isArray(point) || point.length !== 2 || !isFinitePoint(point[0], point[1])) return;
1336
+ const previous = normalized.at(-1);
1337
+ if (previous
1338
+ && Math.abs(point[0] - previous[0]) <= 0.0001
1339
+ && Math.abs(point[1] - previous[1]) <= 0.0001) return;
1340
+ while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop();
1341
+ normalized.push(point);
1342
+ };
1343
+ accept(start);
1344
+ for (const point of Array.isArray(via) ? via : []) accept(point);
1345
+ accept(end);
1346
+ Object.freeze(normalized);
1347
+ NORMALIZED_ROUTE_POINTS.set(normalized, normalized);
1348
+ return normalized;
1349
+ }
1350
+
966
1351
  export function normalizeRoutePoints(points) {
967
- const finite = asArray(points).filter((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point));
968
- const deduped = [];
969
- for (const point of finite) {
970
- const previous = deduped.at(-1);
971
- if (!previous || Math.abs(point[0] - previous[0]) > 0.0001 || Math.abs(point[1] - previous[1]) > 0.0001) deduped.push(point);
1352
+ if (Array.isArray(points)) {
1353
+ const cached = NORMALIZED_ROUTE_POINTS.get(points);
1354
+ if (cached) return cached;
972
1355
  }
973
1356
  const normalized = [];
974
- for (const point of deduped) {
1357
+ for (const point of Array.isArray(points) ? points : []) {
1358
+ if (!Array.isArray(point) || point.length !== 2 || !isFinitePoint(point[0], point[1])) continue;
1359
+ const previous = normalized.at(-1);
1360
+ if (previous
1361
+ && Math.abs(point[0] - previous[0]) <= 0.0001
1362
+ && Math.abs(point[1] - previous[1]) <= 0.0001) continue;
975
1363
  while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop();
976
1364
  normalized.push(point);
977
1365
  }
1366
+ if (Array.isArray(points)) {
1367
+ Object.freeze(normalized);
1368
+ NORMALIZED_ROUTE_POINTS.set(points, normalized);
1369
+ }
978
1370
  return normalized;
979
1371
  }
980
1372
 
@@ -984,13 +1376,17 @@ function pointRectDistance(point, rect) {
984
1376
  return Math.hypot(dx, dy);
985
1377
  }
986
1378
 
987
- function pointSegmentDistance(point, start, end) {
1379
+ function pointSegmentDistanceXY(px, py, start, end) {
988
1380
  const dx = end[0] - start[0];
989
1381
  const dy = end[1] - start[1];
990
1382
  const lengthSquared = dx * dx + dy * dy;
991
- if (lengthSquared <= 0.0000001) return Math.hypot(point[0] - start[0], point[1] - start[1]);
992
- const projection = Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared));
993
- return Math.hypot(point[0] - (start[0] + projection * dx), point[1] - (start[1] + projection * dy));
1383
+ if (lengthSquared <= 0.0000001) return Math.hypot(px - start[0], py - start[1]);
1384
+ const t = Math.max(0, Math.min(1, ((px - start[0]) * dx + (py - start[1]) * dy) / lengthSquared));
1385
+ return Math.hypot(px - (start[0] + t * dx), py - (start[1] + t * dy));
1386
+ }
1387
+
1388
+ function pointSegmentDistance(point, start, end) {
1389
+ return pointSegmentDistanceXY(point[0], point[1], start, end);
994
1390
  }
995
1391
 
996
1392
  function collinearForward(a, b, c) {
@@ -998,7 +1394,7 @@ function collinearForward(a, b, c) {
998
1394
  return (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) >= -0.0001;
999
1395
  }
1000
1396
 
1001
- function frameBorderSegments(frame) {
1397
+ export function frameBorderSegments(frame) {
1002
1398
  if (!frame || typeof frame !== 'object') return [];
1003
1399
  if (frame.shape === 'line') {
1004
1400
  const start = frame.start || [frame.x1, frame.y1];
@@ -1062,7 +1458,7 @@ function collinearAxisOverlap(a, b, c, d) {
1062
1458
  };
1063
1459
  }
1064
1460
 
1065
- function properSegmentIntersection(a, b, c, d) {
1461
+ export function properSegmentIntersection(a, b, c, d) {
1066
1462
  const abC = crossProduct(a, b, c);
1067
1463
  const abD = crossProduct(a, b, d);
1068
1464
  const cdA = crossProduct(c, d, a);
@@ -1129,6 +1525,82 @@ export function anchor(rect, side) {
1129
1525
  }
1130
1526
  }
1131
1527
 
1528
+ export function portHasCornerClearance(rect, side, point) {
1529
+ if (side === 'left' || side === 'right') {
1530
+ const inset = Math.min(16, rect.height / 2);
1531
+ return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset;
1532
+ }
1533
+ if (side === 'top' || side === 'bottom') {
1534
+ const inset = Math.min(16, rect.width / 2);
1535
+ return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset;
1536
+ }
1537
+ return false;
1538
+ }
1539
+
1540
+
1541
+ // Reuse the same bounded alignment for facing automatic ports; callers own
1542
+ // obstacle and occupied-port checks for their renderer. Spread slots stay fixed.
1543
+ export function alignAutomaticFacingPorts(conn, from, to, start, end, fromSide, toSide, ports, { accept } = {}) {
1544
+ const hasExplicitGeometry = (
1545
+ conn.via
1546
+ || (conn.route && conn.route !== 'auto')
1547
+ || conn.channelX !== undefined
1548
+ || conn.channelY !== undefined
1549
+ || conn.labelAt
1550
+ );
1551
+ const horizontallyFacing = (
1552
+ (fromSide === 'right' && toSide === 'left')
1553
+ || (fromSide === 'left' && toSide === 'right')
1554
+ );
1555
+ const verticallyFacing = (
1556
+ (fromSide === 'bottom' && toSide === 'top')
1557
+ || (fromSide === 'top' && toSide === 'bottom')
1558
+ );
1559
+ if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end };
1560
+
1561
+ const fromSpread = Boolean(ports?.from);
1562
+ const toSpread = Boolean(ports?.to);
1563
+ if (fromSpread && toSpread) return { start, end };
1564
+ const hasExplicitSides = (
1565
+ (conn.fromSide && conn.fromSide !== 'auto')
1566
+ || (conn.toSide && conn.toSide !== 'auto')
1567
+ );
1568
+ if (!fromSpread && !toSpread && hasExplicitSides) return { start, end };
1569
+
1570
+ const alignmentDelta = horizontallyFacing
1571
+ ? Math.abs(start[1] - end[1])
1572
+ : Math.abs(start[0] - end[0]);
1573
+ if (alignmentDelta >= 16) return { start, end };
1574
+
1575
+ // Keep the shared endpoint's distinct spread slot and move only the
1576
+ // relationship's unshared endpoint onto that axis. With no spread endpoint,
1577
+ // retain the existing least-movement choice between the two facing sides.
1578
+ // If both endpoints are shared, preserve the outside bridge so no competing
1579
+ // port is silently collapsed.
1580
+ const alignEndToStart = horizontallyFacing
1581
+ ? { start, end: [end[0], start[1]] }
1582
+ : { start, end: [start[0], end[1]] };
1583
+ const alignStartToEnd = horizontallyFacing
1584
+ ? { start: [start[0], end[1]], end }
1585
+ : { start: [end[0], start[1]], end };
1586
+ const candidates = fromSpread
1587
+ ? [alignEndToStart]
1588
+ : toSpread
1589
+ ? [alignStartToEnd]
1590
+ : [alignEndToStart, alignStartToEnd];
1591
+ for (const candidate of candidates) {
1592
+ const points = [candidate.start, candidate.end];
1593
+ if (portHasCornerClearance(from, fromSide, candidate.start)
1594
+ && portHasCornerClearance(to, toSide, candidate.end)
1595
+ && routeHonorsEndpointSides(points, fromSide, toSide)
1596
+ && (typeof accept !== 'function' || accept(points))) {
1597
+ return candidate;
1598
+ }
1599
+ }
1600
+ return { start, end };
1601
+ }
1602
+
1603
+
1132
1604
  const PORT_OUTWARD_VECTOR = {
1133
1605
  left: [-1, 0],
1134
1606
  right: [1, 0],
@@ -1154,48 +1626,65 @@ export function automaticPortRhythmBridge(
1154
1626
  const toVector = PORT_OUTWARD_VECTOR[toSide];
1155
1627
  if (!fromVector || !toVector) return null;
1156
1628
 
1157
- const startStub = [
1158
- start[0] + fromVector[0] * endpointStubPx,
1159
- start[1] + fromVector[1] * endpointStubPx,
1160
- ];
1161
- const endStub = [
1162
- end[0] + toVector[0] * endpointStubPx,
1163
- end[1] + toVector[1] * endpointStubPx,
1164
- ];
1629
+ const opposedFacingGap = (
1630
+ (fromSide === 'right' && toSide === 'left' && end[0] > start[0])
1631
+ || (fromSide === 'left' && toSide === 'right' && start[0] > end[0])
1632
+ ) ? Math.abs(end[0] - start[0]) : (
1633
+ (fromSide === 'bottom' && toSide === 'top' && end[1] > start[1])
1634
+ || (fromSide === 'top' && toSide === 'bottom' && start[1] > end[1])
1635
+ ) ? Math.abs(end[1] - start[1]) : null;
1636
+ const boundedStubPx = opposedFacingGap != null
1637
+ && opposedFacingGap - endpointStubPx * 2 < interiorSegmentPx
1638
+ ? Math.max(8, Math.min(endpointStubPx, (opposedFacingGap - interiorSegmentPx) / 2))
1639
+ : null;
1640
+ const stubDistances = [endpointStubPx];
1641
+ if (boundedStubPx != null && boundedStubPx >= 8 && boundedStubPx !== endpointStubPx) {
1642
+ stubDistances.push(boundedStubPx);
1643
+ }
1165
1644
  const candidates = [];
1166
1645
  const verticalSides = new Set(['top', 'bottom']);
1167
1646
  const horizontalSides = new Set(['left', 'right']);
1168
1647
 
1169
- if (verticalSides.has(fromSide) && verticalSides.has(toSide)
1170
- && Math.abs(start[0] - end[0]) < interiorSegmentPx) {
1171
- for (const channelX of [
1172
- Math.max(start[0], end[0]) + interiorSegmentPx,
1173
- Math.min(start[0], end[0]) - interiorSegmentPx,
1174
- ]) {
1175
- candidates.push([
1176
- start,
1177
- startStub,
1178
- [channelX, startStub[1]],
1179
- [channelX, endStub[1]],
1180
- endStub,
1181
- end,
1182
- ]);
1648
+ for (const stubPx of stubDistances) {
1649
+ const startStub = [
1650
+ start[0] + fromVector[0] * stubPx,
1651
+ start[1] + fromVector[1] * stubPx,
1652
+ ];
1653
+ const endStub = [
1654
+ end[0] + toVector[0] * stubPx,
1655
+ end[1] + toVector[1] * stubPx,
1656
+ ];
1657
+ if (verticalSides.has(fromSide) && verticalSides.has(toSide)
1658
+ && Math.abs(start[0] - end[0]) < interiorSegmentPx) {
1659
+ for (const channelX of [
1660
+ Math.max(start[0], end[0]) + interiorSegmentPx,
1661
+ Math.min(start[0], end[0]) - interiorSegmentPx,
1662
+ ]) {
1663
+ candidates.push([
1664
+ start,
1665
+ startStub,
1666
+ [channelX, startStub[1]],
1667
+ [channelX, endStub[1]],
1668
+ endStub,
1669
+ end,
1670
+ ]);
1671
+ }
1183
1672
  }
1184
- }
1185
- if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
1186
- && Math.abs(start[1] - end[1]) < interiorSegmentPx) {
1187
- for (const channelY of [
1188
- Math.max(start[1], end[1]) + interiorSegmentPx,
1189
- Math.min(start[1], end[1]) - interiorSegmentPx,
1190
- ]) {
1191
- candidates.push([
1192
- start,
1193
- startStub,
1194
- [startStub[0], channelY],
1195
- [endStub[0], channelY],
1196
- endStub,
1197
- end,
1198
- ]);
1673
+ if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
1674
+ && Math.abs(start[1] - end[1]) < interiorSegmentPx) {
1675
+ for (const channelY of [
1676
+ Math.max(start[1], end[1]) + interiorSegmentPx,
1677
+ Math.min(start[1], end[1]) - interiorSegmentPx,
1678
+ ]) {
1679
+ candidates.push([
1680
+ start,
1681
+ startStub,
1682
+ [startStub[0], channelY],
1683
+ [endStub[0], channelY],
1684
+ endStub,
1685
+ end,
1686
+ ]);
1687
+ }
1199
1688
  }
1200
1689
  }
1201
1690
 
@@ -1211,7 +1700,7 @@ export function automaticPortRhythmBridge(
1211
1700
  // Keep conservative auto-routed fan-out/fan-in relationships visually
1212
1701
  // distinct without changing authored route controls. The returned map only
1213
1702
  // contains endpoints that belong to a shared automatic midpoint anchor.
1214
- export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing = 14, sideFor } = {}) {
1703
+ export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing = 14, sideFor, spacingFor } = {}) {
1215
1704
  const groups = new Map();
1216
1705
  const spread = new Map();
1217
1706
 
@@ -1257,8 +1746,22 @@ export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing
1257
1746
  const spacing = Math.min(maxSpacing, usable / (items.length - 1));
1258
1747
  if (!(spacing > 0)) continue;
1259
1748
 
1749
+ // Width-aware callers reserve the whole group together. Moving a single
1750
+ // port around the legacy 14px slots can wrongly report a full side while
1751
+ // its still-unrouted neighbours could have fitted farther apart.
1752
+ let offsets;
1753
+ if (spacingFor) {
1754
+ const gaps = items.slice(1).map((item, index) => Math.max(maxSpacing,
1755
+ spacingFor(items[index].relation, item.relation)));
1756
+ const span = gaps.reduce((sum, gap) => sum + gap, 0);
1757
+ if (span <= usable && gaps.some((gap) => gap > maxSpacing)) {
1758
+ let offset = -span / 2;
1759
+ offsets = [offset, ...gaps.map((gap) => (offset += gap))];
1760
+ }
1761
+ }
1762
+
1260
1763
  for (const [index, item] of items.entries()) {
1261
- const offset = (index - (items.length - 1) / 2) * spacing;
1764
+ const offset = offsets?.[index] ?? (index - (items.length - 1) / 2) * spacing;
1262
1765
  const point = anchor(item.rect, item.side);
1263
1766
  if (verticalSide) point[1] += offset;
1264
1767
  else point[0] += offset;
@@ -1271,14 +1774,37 @@ export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing
1271
1774
  return spread;
1272
1775
  }
1273
1776
 
1777
+ // A centre delta on the horizontal axis does not by itself mean the route
1778
+ // leaves sideways: a hub above an offset spoke has both a horizontal and a
1779
+ // larger vertical delta, and the router draws the vertical dogleg. Compare the
1780
+ // deltas so the inferred side matches the axis the route actually uses. Equal
1781
+ // deltas select horizontal sides; dx === 0 preserves the previous vertical
1782
+ // result, while a vertical-dominant nonzero dx selects vertical sides.
1274
1783
  export function defaultFromSide(from, to) {
1784
+ const dx = to.cx - from.cx;
1785
+ const dy = to.cy - from.cy;
1786
+ if (dx !== 0 && Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? 'left' : 'right';
1787
+ return dy > 0 ? 'bottom' : 'top';
1788
+ }
1789
+
1790
+ export function defaultToSide(from, to) {
1791
+ const dx = to.cx - from.cx;
1792
+ const dy = to.cy - from.cy;
1793
+ if (dx !== 0 && Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? 'right' : 'left';
1794
+ return dy > 0 ? 'top' : 'bottom';
1795
+ }
1796
+
1797
+ // Non-architecture renderers retain their established horizontal-first
1798
+ // endpoint contract. Architecture opts into dominant-axis inference above so
1799
+ // a vertical hub-and-spoke route can use perpendicular automatic ports.
1800
+ export function legacyDefaultFromSide(from, to) {
1275
1801
  if (to.cx < from.cx) return 'left';
1276
1802
  if (to.cx > from.cx) return 'right';
1277
1803
  if (to.cy > from.cy) return 'bottom';
1278
1804
  return 'top';
1279
1805
  }
1280
1806
 
1281
- export function defaultToSide(from, to) {
1807
+ export function legacyDefaultToSide(from, to) {
1282
1808
  if (to.cx < from.cx) return 'right';
1283
1809
  if (to.cx > from.cx) return 'left';
1284
1810
  if (to.cy > from.cy) return 'top';
@@ -1300,6 +1826,16 @@ export function routePointsValue(points) {
1300
1826
  .join(';');
1301
1827
  }
1302
1828
 
1829
+ // Only a direct, explicitly authored diagonal needs an artifact-check exception.
1830
+ // Nonempty via takes precedence; empty via adds no intermediate geometry.
1831
+ export function authoredStraightRouteAttrs(relation, points) {
1832
+ if (relation.route !== 'straight' || relation.via?.length || points.length !== 2) return '';
1833
+ const [start, end] = points;
1834
+ return Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
1835
+ ? ' data-composition-route="straight"'
1836
+ : '';
1837
+ }
1838
+
1303
1839
  export function roundedPath(points, radius) {
1304
1840
  if (points.length < 3 || radius <= 0) {
1305
1841
  return polylinePath(points);
@@ -1370,38 +1906,138 @@ export const arrowClassMap = {
1370
1906
  dashed: ['a-dashed', 'arrowhead-dashed']
1371
1907
  };
1372
1908
 
1373
- // Label accent per edge variant. Workflow colors dashed (async trace) labels
1374
- // like the trace store it points at; the other renderers use the bus color.
1375
- export function variantAccent(variant, { dashed = 't-messagebus' } = {}) {
1909
+ // Structural phase/group accents retain their existing semantic colors.
1910
+ export function variantAccent(variant) {
1911
+ return variant === 'security' ? 't-security'
1912
+ : variant === 'emphasis' ? 't-backend'
1913
+ : variant === 'dashed' ? 't-messagebus' : 't-muted';
1914
+ }
1915
+
1916
+ // Relationship labels use the same theme token as their path. Keep this map
1917
+ // edge-specific: node-kind text colors only coincide with some path colors in
1918
+ // the classic preset and must not define the relationship's visual meaning.
1919
+ export function edgeLabelAccent(variant) {
1376
1920
  return variant === 'security'
1377
- ? 't-security'
1921
+ ? 't-edge-security'
1378
1922
  : variant === 'emphasis'
1379
- ? 't-backend'
1923
+ ? 't-edge-emphasis'
1380
1924
  : variant === 'dashed'
1381
- ? dashed
1382
- : 't-muted';
1925
+ ? 't-edge-dashed'
1926
+ : 't-edge-default';
1383
1927
  }
1384
1928
 
1385
1929
  export function formatRect(r) {
1386
1930
  return `[${Math.round(r.x)}, ${Math.round(r.y)}, ${Math.round(r.width)}, ${Math.round(r.height)}]`;
1387
1931
  }
1388
1932
 
1389
- function formatDelta(n) {
1390
- const v = Math.round(n);
1391
- return v >= 0 ? `+${v}` : String(v);
1933
+ function formatValue(n) {
1934
+ return String(Math.round(n));
1392
1935
  }
1393
1936
 
1394
- /** Actionable hint when an edge label rect hits a node/component box (#7). */
1395
- export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKind = 'component') {
1396
- const lxR = Math.round(lx);
1397
- const lyR = Math.round(ly);
1398
- const belowY = Math.round(obstacle.y + obstacle.height + 14);
1399
- const aboveY = Math.round(obstacle.y - 4);
1400
- return [
1937
+ // A label anchor is the text origin, so keeping a suggestion on the canvas
1938
+ // means moving the whole rect: offset is `rect - anchor` on that axis.
1939
+ function anchorFitsCanvas(anchorValue, offset, size, extent) {
1940
+ const overflow = axisCanvasOverflow(anchorValue + offset, size, extent);
1941
+ return overflow.start <= 0 && overflow.end <= 0;
1942
+ }
1943
+
1944
+ // Suggestions are emitted as integers, so the real-valued bounds are tightened
1945
+ // inward with ceil/floor first: every integer between them keeps the whole rect
1946
+ // on the canvas, which a bound like 616.7 would not. Returns null when the rect
1947
+ // is wider than the canvas and no anchor can contain it.
1948
+ function clampAnchorToCanvas(anchorValue, offset, size, extent) {
1949
+ const rounded = Math.round(anchorValue);
1950
+ if (!Number.isFinite(extent)) return rounded;
1951
+ const min = Math.ceil(-offset);
1952
+ const max = Math.floor(extent - size - offset);
1953
+ return max < min ? null : Math.min(Math.max(rounded, min), max);
1954
+ }
1955
+
1956
+ /**
1957
+ * Actionable hint when an edge label rect hits a node/component box (#7).
1958
+ * Every suggested value is a replacement for the authored field, not an
1959
+ * increment, and has to survive being applied to the document:
1960
+ * - the absolute form is nudged along x so the rect stays on the canvas, and a
1961
+ * vertical placement that cannot fit is dropped rather than clamped, since
1962
+ * clamping it back would push the label onto the obstacle it must clear;
1963
+ * - the relative form is offered only when labelDx/labelDy actually move this
1964
+ * label (labelPoint returns an authored labelAt as-is) and only when the
1965
+ * replacements, computed from the document's own labelDx/labelDy, land the
1966
+ * rect inside the canvas.
1967
+ * The authored values therefore come from `labelRect.relation`, the authored
1968
+ * relationship the renderer already attaches to every label record.
1969
+ * `obstacles` is every box the callers' own overlap loop tests (defaults to
1970
+ * the named obstacle alone): a placement that merely traded the named obstacle
1971
+ * for its neighbor would fail the same rule again when applied.
1972
+ */
1973
+ export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKind = 'component', viewBox, obstacles) {
1974
+ // Renderer canvases are origin-zero [width, height] by schema, but read a
1975
+ // 4-number [min-x, min-y, width, height] the same way the containment
1976
+ // collector does — degraded no-ajv runs must not clamp against the origin
1977
+ // pair as if it were the canvas size.
1978
+ const box = asArray(viewBox);
1979
+ const [canvasWidth, canvasHeight] = box.length === 4 ? box.slice(2) : box;
1980
+ const xOffset = labelRect.x - lx;
1981
+ const yOffset = labelRect.y - ly;
1982
+ const anchorX = clampAnchorToCanvas(lx, xOffset, labelRect.width, canvasWidth);
1983
+ // The placement anchors are derived from the label's own rect, not from the
1984
+ // obstacle alone: an obstacle-only "above" anchor assumes the 14px
1985
+ // single-line rect and lands a 27px two-line rect (dataflow classification,
1986
+ // lifecycle note) back on the obstacle it must clear. The overlap filter
1987
+ // uses the callers' own detection call, so a surviving hint cannot re-raise
1988
+ // the problem it repairs.
1989
+ const blockers = obstacles ? [...obstacles] : [obstacle];
1990
+ const placements = [
1991
+ { name: 'below', y: Math.round(obstacle.y + obstacle.height + 4 - yOffset) },
1992
+ { name: 'above', y: Math.round(obstacle.y - 4 - labelRect.height - yOffset) },
1993
+ ].filter(({ y }) => anchorX !== null
1994
+ && anchorFitsCanvas(y, yOffset, labelRect.height, canvasHeight)
1995
+ && blockers.every((blocker) => !rectsOverlap(
1996
+ { x: anchorX + xOffset, y: y + yOffset, width: labelRect.width, height: labelRect.height },
1997
+ blocker,
1998
+ -2,
1999
+ )));
2000
+ const lines = [
1401
2001
  ` label rect: ${formatRect(labelRect)}`,
1402
2002
  ` ${obstacleKind} "${obstacle.id}" rect: ${formatRect(obstacle)}`,
1403
- ` Suggested fix: labelAt [${lxR}, ${belowY}] or labelDy ${formatDelta(belowY - lyR)} (below); or labelAt [${lxR}, ${aboveY}] or labelDy ${formatDelta(aboveY - lyR)} (above)`,
1404
- ].join('\n');
2003
+ ];
2004
+ if (!placements.length) {
2005
+ // Only the vertical slots beside the named obstacle were tried, so an
2006
+ // unclaimed spot elsewhere may still exist — never assert that none does.
2007
+ lines.push(anchorX === null
2008
+ ? ` Suggested fix: the ${Math.round(labelRect.width)}px label rect cannot fit the ${canvasWidth}x${canvasHeight} viewBox at any anchor — shorten the label or enlarge meta.viewBox`
2009
+ : ` Suggested fix: no placement directly above or below "${obstacle.id}" stays clear inside the ${canvasWidth}x${canvasHeight} viewBox — move the label to an open area with labelAt, shorten the label, move the ${obstacleKind}, or enlarge meta.viewBox`);
2010
+ return lines.join('\n');
2011
+ }
2012
+ const authored = labelRect.relation || {};
2013
+ const authoredDx = Number.isFinite(authored.labelDx) ? authored.labelDx : 0;
2014
+ const authoredDy = Number.isFinite(authored.labelDy) ? authored.labelDy : 0;
2015
+ const relativeHint = (y) => {
2016
+ if (Array.isArray(authored.labelAt)) return null;
2017
+ // Both replacements are measured from the automatic label point that the
2018
+ // authored offsets are applied to, and against the unrounded anchor: a
2019
+ // rounded base would shift the applied label by up to a pixel.
2020
+ const dx = Math.round(authoredDx + anchorX - lx);
2021
+ const dy = Math.round(authoredDy + y - ly);
2022
+ // labelDx is authored as a number, so an authored fraction must not read as
2023
+ // a needed nudge once the replacement is rounded to an integer.
2024
+ const movesX = Math.abs(dx - authoredDx) >= 0.5;
2025
+ const landsAtX = lx - authoredDx + (movesX ? dx : authoredDx);
2026
+ const landsAtY = ly - authoredDy + dy;
2027
+ if (!anchorFitsCanvas(landsAtX, xOffset, labelRect.width, canvasWidth)) return null;
2028
+ if (!anchorFitsCanvas(landsAtY, yOffset, labelRect.height, canvasHeight)) return null;
2029
+ return movesX
2030
+ ? `set labelDx ${formatValue(dx)} with labelDy ${formatValue(dy)}`
2031
+ : `set labelDy ${formatValue(dy)}`;
2032
+ };
2033
+ const hint = placements
2034
+ .map(({ name, y }) => {
2035
+ const relative = relativeHint(y);
2036
+ return `set labelAt [${anchorX}, ${y}]${relative ? ` or ${relative}` : ''} (${name})`;
2037
+ })
2038
+ .join('; or ');
2039
+ lines.push(` Suggested fix: ${hint}`);
2040
+ return lines.join('\n');
1405
2041
  }
1406
2042
 
1407
2043
  /** Hint when two edge labels collide. */
@@ -1412,12 +2048,26 @@ export function suggestLabelPairFix(a, b) {
1412
2048
  ].join('\n');
1413
2049
  }
1414
2050
 
2051
+ /**
2052
+ * The two placements that separate b from a, in the order the prose hint
2053
+ * offers them. Shared so the text hint and its structured repair cannot
2054
+ * offer different coordinates.
2055
+ */
2056
+ export function componentSeparationOptions(a, b, minGap = 8) {
2057
+ // Ceil, not round: on fractional geometry a rounded right/below coordinate
2058
+ // can land a fraction of a pixel short of minGap, and the advised position
2059
+ // would never clear the diagnostic that offered it.
2060
+ return [
2061
+ { pos: [Math.ceil(a.x + a.width + minGap), Math.round(b.y)], relation: `right of "${a.id}"` },
2062
+ { pos: [Math.round(b.x), Math.ceil(a.y + a.height + minGap)], relation: 'below' },
2063
+ ];
2064
+ }
2065
+
1415
2066
  /** Hint when two components/nodes are too close. */
1416
2067
  export function suggestComponentSeparation(a, b, minGap = 8) {
1417
- const rightX = Math.round(a.x + a.width + minGap);
1418
- const belowY = Math.round(a.y + a.height + minGap);
2068
+ const [right, below] = componentSeparationOptions(a, b, minGap);
1419
2069
  return [
1420
2070
  ` "${a.id}" ${formatRect(a)}; "${b.id}" ${formatRect(b)}`,
1421
- ` Suggested fix: move "${b.id}" pos to [${rightX}, ${Math.round(b.y)}] (right of "${a.id}") or [${Math.round(b.x)}, ${belowY}] (below)`,
2071
+ ` Suggested fix: move "${b.id}" pos to [${right.pos[0]}, ${right.pos[1]}] (${right.relation}) or [${below.pos[0]}, ${below.pos[1]}] (${below.relation})`,
1422
2072
  ].join('\n');
1423
2073
  }