@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,15 +1,19 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
4
+ import { NODE_CARD_ATTR, nodeCardStyle } from '../shared/node-card-style.mjs';
4
5
  import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
5
6
  import { componentBox, boundaryBox, connectionPath } from '../shared/layout-report.mjs';
6
- import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
7
+ import { rendererFailure, throwDiagnosticProblems } from '../shared/diagnostics.mjs';
7
8
  import { legendFootprint, relationshipLegendObstacles, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
8
9
  import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
9
10
  import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
10
11
  import { minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs';
11
12
  import { translateMessage as i18nText } from '../shared/i18n.mjs';
12
13
  import { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs';
14
+ import { createRouter } from './routing.mjs';
15
+ import { placeAutomaticLabels, reservedLabelRect } from '../shared/automatic-labels.mjs';
16
+ import { cleanRouteDetourProblems } from '../shared/route-quality.mjs';
13
17
  import {
14
18
  asArray,
15
19
  isFinitePoint,
@@ -19,27 +23,22 @@ import {
19
23
  cleanFlowProblems,
20
24
  cleanCrossingProblems,
21
25
  cleanAmbiguousCorridorProblems,
26
+ collectArrowheadCollisions,
22
27
  cleanBorderRunProblems,
23
28
  cleanRouteRhythmProblems,
24
29
  cleanLabelRouteClearanceProblems,
30
+ cleanLabelCanvasContainmentProblems,
25
31
  suggestLabelObstacleFix,
32
+ componentSeparationOptions,
26
33
  suggestComponentSeparation,
27
- anchor,
28
- automaticPortSpread,
29
- automaticPortRhythmBridge,
30
- defaultFromSide,
31
- defaultToSide,
32
- chosenSide,
33
- routeHonorsEndpointSides,
34
- normalizeRoutePoints,
35
34
  polylinePath,
36
35
  routePointsValue,
37
- roundedPath,
36
+ authoredStraightRouteAttrs,
38
37
  labelPoint,
39
38
  componentFill,
40
39
  componentText,
41
40
  arrowClassMap,
42
- variantAccent,
41
+ edgeLabelAccent,
43
42
  } from '../shared/geometry.mjs';
44
43
 
45
44
  const componentTextFit = {
@@ -65,7 +64,7 @@ const layout = {
65
64
  defaultW: 120,
66
65
  defaultH: 60,
67
66
  margin: 40,
68
- // Boundary padding - the 30/50 rule that was a hand-arithmetic footgun
67
+ // Boundary padding — the 30/50 rule that was a hand-arithmetic footgun
69
68
  // (CHANGELOG v2.2.1): 30px on top/left/right, plus 20px extra at the bottom.
70
69
  boundaryPad: 30,
71
70
  boundaryExtraBottom: 20,
@@ -111,14 +110,24 @@ for (const [index, c] of asArray(arch.components).entries()) {
111
110
  function boundaryRect(boundary) {
112
111
  const members = asArray(boundary.wraps).map((id) => components.get(id)).filter(Boolean);
113
112
  if (!members.length) return null;
114
- const minX = Math.min(...members.map((m) => m.x));
115
- const minY = Math.min(...members.map((m) => m.y));
116
- const maxX = Math.max(...members.map((m) => m.x + m.width));
117
- const maxY = Math.max(...members.map((m) => m.y + m.height));
113
+ let minX = Infinity;
114
+ let minY = Infinity;
115
+ let maxX = -Infinity;
116
+ let maxY = -Infinity;
117
+ for (const member of members) {
118
+ minX = Math.min(minX, member.x);
119
+ minY = Math.min(minY, member.y);
120
+ maxX = Math.max(maxX, member.x + member.width);
121
+ maxY = Math.max(maxY, member.y + member.height);
122
+ }
118
123
  const pad = boundary.pad ?? layout.boundaryPad;
119
124
  const topPad = Math.max(
120
125
  pad,
121
126
  layout.boundaryLabelBaseline + layout.boundaryLabelClearance,
127
+ // Routes must avoid the title rail that the final frame will contain.
128
+ enforcesBoundaryTitleComposition
129
+ ? layout.boundaryLabelMaskHeight + layout.boundaryLabelClearance + layout.boundaryLabelFrameInset
130
+ : 0,
122
131
  );
123
132
  return {
124
133
  ...boundary,
@@ -148,17 +157,40 @@ const architectureLegendEntries = resolveLegend(
148
157
  new Set([...components.values()].map((component) => component.type)),
149
158
  );
150
159
 
151
- function autoViewBoxFor(candidateBoundaries) {
152
- const maxX = Math.max(
153
- 0,
154
- ...[...components.values()].map((component) => component.x + component.width),
155
- ...candidateBoundaries.map((boundary) => boundary.x + boundary.width),
156
- );
157
- const maxY = Math.max(
158
- 0,
159
- ...[...components.values()].map((component) => component.y + component.height),
160
- ...candidateBoundaries.map((boundary) => boundary.y + boundary.height),
161
- );
160
+ // One source for connection label geometry: the rect the containment rule
161
+ // measures is the rect the SVG mask draws, the auto canvas covers, the legend
162
+ // avoids, and the layout report publishes.
163
+ const resolvedLabelPoints = new Map();
164
+ function connectionLabelBox(conn) {
165
+ if (!conn.label) return null;
166
+ return connectionLabelBoxAt(conn, resolvedLabelPoints.get(conn) || labelPoint(conn, pathFor(conn).points));
167
+ }
168
+
169
+ function connectionLabelBoxAt(conn, [lx, ly]) {
170
+ const width = Math.max(30, textUnits(conn.label) * 4.8 + 10);
171
+ return { x: lx - width / 2, y: ly - 10, width, height: 14, lx, ly };
172
+ }
173
+
174
+ function connectionLabelRects() {
175
+ const rects = [];
176
+ for (const [relationIndex, conn] of asArray(arch.connections).entries()) {
177
+ if (!components.has(conn.from) || !components.has(conn.to)) continue;
178
+ const box = connectionLabelBox(conn);
179
+ if (!box) continue;
180
+ rects.push({ relation: conn, relationIndex, label: conn.label, ...box });
181
+ }
182
+ return rects;
183
+ }
184
+
185
+ function autoViewBoxFor(candidateBoundaries, extraRects = []) {
186
+ let maxX = 0;
187
+ let maxY = 0;
188
+ for (const rects of [components.values(), candidateBoundaries, extraRects]) {
189
+ for (const rect of rects) {
190
+ maxX = Math.max(maxX, rect.x + rect.width);
191
+ maxY = Math.max(maxY, rect.y + rect.height);
192
+ }
193
+ }
162
194
  let width = Math.ceil(maxX + layout.margin);
163
195
  let footprint = legendFootprint(architectureLegendEntries, {
164
196
  width: Math.max(1, width - layout.margin * 2),
@@ -179,7 +211,7 @@ function resolvedViewBoxWidth(candidateBoundaries) {
179
211
  if (Array.isArray(arch.meta?.viewBox) && Number.isFinite(arch.meta.viewBox[0])) {
180
212
  return arch.meta.viewBox[0];
181
213
  }
182
- return autoViewBoxFor(candidateBoundaries)[0];
214
+ return autoViewBoxFor(candidateBoundaries, connectionGeometry)[0];
183
215
  }
184
216
 
185
217
  function expandBoundaryForReadableTitle(boundary, minimumFontSize) {
@@ -227,6 +259,42 @@ function horizontalOverlap(left, right) {
227
259
  return left.x < right.x + right.width && left.x + left.width > right.x;
228
260
  }
229
261
 
262
+ // A route through a title disappears under its mask and reads as struck-through
263
+ // text. Keep the left rail position when it is clear; otherwise slide the title
264
+ // along its rail to the nearest clear position inside the frame.
265
+ let titleRouteSegments = null;
266
+ function slideTitleOffRoutes(boundary, title, placedTitles) {
267
+ if (!enforcesBoundaryTitleComposition) return;
268
+ titleRouteSegments ||= asArray(arch.connections)
269
+ .filter((conn) => components.has(conn.from) && components.has(conn.to))
270
+ .flatMap((conn) => {
271
+ const points = pathFor(conn).points;
272
+ return points.slice(1).map((end, index) => ({ start: points[index], end }));
273
+ });
274
+ const clear = (x) => {
275
+ const rect = { ...title, x };
276
+ return !titleRouteSegments.some((segment) => segmentIntersectsRect(segment, rect, 2))
277
+ && ![...placedTitles, ...components.values(), ...connectionLabels].some((other) => rectsOverlap(rect, other));
278
+ };
279
+ if (clear(title.x)) return;
280
+ const left = boundary.x + layout.boundaryLabelFrameInset;
281
+ const right = boundary.x + boundary.width - layout.boundaryLabelFrameInset - title.width;
282
+ const band = titleRouteSegments.filter(({ start, end }) => (
283
+ Math.max(start[1], end[1]) >= title.y - 2 && Math.min(start[1], end[1]) <= title.y + title.height + 2
284
+ ));
285
+ const labelBlockers = connectionLabels.filter((label) => (
286
+ label.y < title.y + title.height && label.y + label.height > title.y
287
+ ));
288
+ const x = [right, ...band.flatMap(({ start, end }) => [
289
+ Math.max(start[0], end[0]) + 8, Math.min(start[0], end[0]) - 8 - title.width,
290
+ ]), ...labelBlockers.flatMap((label) => [
291
+ label.x + label.width + 8, label.x - 8 - title.width,
292
+ ])].filter((value) => value >= left && value <= right)
293
+ .sort((a, b) => a - b)
294
+ .find(clear);
295
+ if (x !== undefined) title.x = x;
296
+ }
297
+
230
298
  function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
231
299
  const placedTitles = [];
232
300
  const measured = new Map();
@@ -250,10 +318,12 @@ function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
250
318
  ...components.values(),
251
319
  ].filter((candidate) => horizontalOverlap(title, candidate) && rectsOverlap(title, candidate));
252
320
  if (!blockers.length) break;
253
- title.y = Math.min(
254
- ...blockers.map((blocker) => blocker.y - layout.boundaryLabelRailGap - title.height),
321
+ title.y = blockers.reduce(
322
+ (min, blocker) => Math.min(min, blocker.y - layout.boundaryLabelRailGap - title.height),
323
+ Infinity,
255
324
  );
256
325
  }
326
+ slideTitleOffRoutes(boundary, title, placedTitles);
257
327
  placedTitles.push(title);
258
328
  measured.set(index, { boundary, title });
259
329
  }
@@ -276,7 +346,52 @@ function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
276
346
  });
277
347
  }
278
348
 
349
+ // ---- Routing state ----------------------------------------------------------
350
+ // Initialized before the boundary-title work below: connection label rects are
351
+ // part of the derived canvas, so the title convergence must measure the same
352
+ // width the diagram actually renders into (a title sized for a narrower canvas
353
+ // would fall below the desktop-readability floor once labels grow it). Routing
354
+ // reads components, connections and the member-derived boundary frames (so an
355
+ // automatic route never borrows a frame border as its corridor), never the
356
+ // title-expanded frames or the viewBox.
279
357
  const rawBoundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean);
358
+ const routerOptions = {
359
+ distinctAutomaticPorts: true,
360
+ preferReadableRoutes: true,
361
+ // A single outward stub below the scene can remove a crossing without
362
+ // widening the canvas or shrinking its text. Fixed canvases keep their bounds.
363
+ readabilityBottomMargin: arch.meta?.viewBox ? 0 : 24,
364
+ frames: rawBoundaries.map((boundary) => ({
365
+ ...boundary,
366
+ radius: boundary.kind === 'security-group' ? 8 : 12,
367
+ })),
368
+ labelRectFor: (conn, points, { routes, labels }) => (conn.label ? reservedLabelRect({
369
+ label: { relation: conn, label: conn.label, ...connectionLabelBoxAt(conn, labelPoint(conn, points)) },
370
+ points,
371
+ routes: routes.map((route, index) => ({ relationIndex: index, points: route })),
372
+ labels,
373
+ components: [...components.values()],
374
+ }) : null),
375
+ };
376
+ const { pathFor, connectionSides, connectionEndpointSide } = createRouter(components, arch.connections, routerOptions);
377
+ function hasAutomaticRouteGeometry(connection) {
378
+ return !Array.isArray(connection?.via)
379
+ && (!connection?.route || connection.route === 'auto')
380
+ && connection?.channelX === undefined
381
+ && connection?.channelY === undefined;
382
+ }
383
+ // The auto canvas has to cover these rects; an authored viewBox is never
384
+ // resized to fit them — there the containment rule reports the clipping.
385
+ let connectionLabels = connectionLabelRects();
386
+ // Unlabelled outer corridors are geometry too. Fitting only nodes and labels
387
+ // can clip a valid explicit via route while every browser overflow check passes.
388
+ const connectionGeometry = [
389
+ ...connectionLabels,
390
+ ...asArray(arch.connections)
391
+ .filter((conn) => components.has(conn.from) && components.has(conn.to))
392
+ .flatMap((conn) => pathFor(conn).points.map(([x, y]) => ({ x, y, width: 0, height: 0 }))),
393
+ ];
394
+
280
395
  function resolveBoundaryTitles() {
281
396
  if (!enforcesBoundaryTitleComposition || rawBoundaries.length === 0) {
282
397
  return {
@@ -308,7 +423,7 @@ function resolveBoundaryTitles() {
308
423
  const finalViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
309
424
  return {
310
425
  boundaries: candidateBoundaries,
311
- readabilityProblem: `[composition/desktop-readability] Boundary title layout did not converge after ${maximumIterations} iterations for the final ${finalViewBoxWidth}px viewBox - shorten boundary labels, provide a wider authored viewBox, or move wrapped components closer to the left edge.`,
426
+ readabilityProblem: `[composition/desktop-readability] Boundary title layout did not converge after ${maximumIterations} iterations for the final ${finalViewBoxWidth}px viewBox — shorten boundary labels, provide a wider authored viewBox, or move wrapped components closer to the left edge.`,
312
427
  };
313
428
  }
314
429
 
@@ -330,12 +445,48 @@ function componentContext(component) {
330
445
  }
331
446
 
332
447
  // ---- Auto viewBox: fit all geometry + the measured resolved legend ----------
333
- const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries);
448
+ // Connection labels are diagram content, so an auto canvas that stopped at the
449
+ // component/boundary bbox would clip them; the label rects join the fit here
450
+ // and in the title convergence above, which sizes fonts for this same width.
451
+ const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries, connectionGeometry);
334
452
  const legendY = () => viewBox[1] - 16;
335
453
 
454
+ // Fit titles and canvas from the original geometry first. Fallback labels must
455
+ // fit inside that canvas, so moving a label cannot trigger title reflow or a
456
+ // canvas/label feedback loop. Keep standard and every authored label control.
457
+ if (arch.meta?.quality_profile === 'showcase') {
458
+ connectionLabels = placeAutomaticLabels({
459
+ keepFallbackNearRoute: true,
460
+ labels: connectionLabels,
461
+ routes: asArray(arch.connections).flatMap((conn, relationIndex) => (
462
+ components.has(conn.from) && components.has(conn.to)
463
+ ? [{ relationIndex, points: pathFor(conn).points }] : []
464
+ )),
465
+ components: [...components.values()],
466
+ titles: boundaries.map(boundary => boundary.title),
467
+ viewBox,
468
+ // Leave the resolved legend band available; moving a label must not hide
469
+ // an otherwise visible legend. Existing labels keep their placement.
470
+ placementBottom: architectureLegendEntries.length
471
+ ? legendY() - 32 - legendFootprint(architectureLegendEntries, { width: viewBox[0] - layout.margin * 2 }).extraHeight
472
+ : viewBox[1],
473
+ });
474
+ for (const rect of connectionLabels) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
475
+ }
476
+
336
477
  // ---- Validation: mechanical correctness, never layout taste -----------------
478
+ function componentDiagnosticBox(component) {
479
+ return {
480
+ ...componentBox(component),
481
+ x: component.x,
482
+ y: component.y,
483
+ ...(Array.isArray(component.pos) ? { pos: [...component.pos] } : {}),
484
+ };
485
+ }
486
+
337
487
  function validateArchitecture() {
338
488
  const problems = [];
489
+ const diagnostics = [];
339
490
  if (resolvedBoundaryTitles.readabilityProblem) {
340
491
  problems.push(resolvedBoundaryTitles.readabilityProblem);
341
492
  }
@@ -353,19 +504,80 @@ function validateArchitecture() {
353
504
 
354
505
  for (const c of components.values()) {
355
506
  if (!isFinitePoint(c.x, c.y, c.width, c.height)) {
356
- problems.push(`Component "${c.id}" has non-finite pos/size - pos and size must be [number, number].`);
507
+ const message = `Component "${c.id}" has non-finite pos/size — pos and size must be [number, number].`;
508
+ diagnostics.push({
509
+ code: 'architecture/non-finite-component-geometry', severity: 'error', message,
510
+ subject: { diagramType: 'architecture', nodeId: c.id },
511
+ evidence: { pos: c.pos ?? null, size: [c.width, c.height] },
512
+ supportedFixes: [`replace component "${c.id}" pos and size with finite [number, number] values`],
513
+ });
514
+ problems.push(message);
357
515
  continue;
358
516
  }
359
517
  if (c.width <= 0 || c.height <= 0) {
360
- problems.push(`Component "${c.id}" has invalid size ${c.width}x${c.height} - width and height must be greater than 0.`);
518
+ const message = `Component "${c.id}" has invalid size ${c.width}x${c.height} — width and height must be greater than 0.`;
519
+ diagnostics.push({
520
+ code: 'architecture/invalid-component-size', severity: 'error', message,
521
+ subject: { diagramType: 'architecture', nodeId: c.id },
522
+ evidence: { width: c.width, height: c.height },
523
+ supportedFixes: [`set component "${c.id}" width and height to values greater than 0`],
524
+ });
525
+ problems.push(message);
361
526
  continue;
362
527
  }
363
528
  if (c.x < 0 || c.y < 0 || c.x + c.width > viewBox[0] || c.y + c.height > viewBox[1]) {
364
- problems.push(`Component "${c.id}" falls outside the viewBox ${viewBox[0]}x${viewBox[1]} - adjust pos/size or set a larger meta.viewBox.`);
529
+ const message = `Component "${c.id}" falls outside the viewBox ${viewBox[0]}x${viewBox[1]} — adjust pos/size or set a larger meta.viewBox.`;
530
+ const overflow = {
531
+ left: Math.max(0, -c.x),
532
+ top: Math.max(0, -c.y),
533
+ right: Math.max(0, c.x + c.width - viewBox[0]),
534
+ bottom: Math.max(0, c.y + c.height - viewBox[1]),
535
+ };
536
+ const widerThanCanvas = overflow.left > 0 && overflow.right > 0;
537
+ const tallerThanCanvas = overflow.top > 0 && overflow.bottom > 0;
538
+ const supportedFixes = [];
539
+ // Move by exactly the overflow: it lands the edge flush (x + (-x) is exact
540
+ // in IEEE 754), while a rounded-up move pushes the opposite edge past a
541
+ // tight canvas and a rounded-down one leaves the overflow in place. When
542
+ // the component is larger than the canvas, the move must shrink it too.
543
+ if (overflow.left > 0) {
544
+ const shrink = Math.max(0, c.width - viewBox[0]);
545
+ supportedFixes.push(shrink > 0
546
+ ? `move component "${c.id}" right by ${overflow.left}px and shrink its width by at least ${shrink}px`
547
+ : `move component "${c.id}" right by ${overflow.left}px`);
548
+ }
549
+ if (overflow.top > 0) {
550
+ const shrink = Math.max(0, c.height - viewBox[1]);
551
+ supportedFixes.push(shrink > 0
552
+ ? `move component "${c.id}" down by ${overflow.top}px and shrink its height by at least ${shrink}px`
553
+ : `move component "${c.id}" down by ${overflow.top}px`);
554
+ }
555
+ if ((overflow.right > 0 && !widerThanCanvas) || (overflow.bottom > 0 && !tallerThanCanvas)) {
556
+ const coveredWidth = overflow.right > 0 && !widerThanCanvas ? Math.ceil(c.x + c.width) : viewBox[0];
557
+ const coveredHeight = overflow.bottom > 0 && !tallerThanCanvas ? Math.ceil(c.y + c.height) : viewBox[1];
558
+ supportedFixes.push(`set meta.viewBox to at least [${Math.max(viewBox[0], coveredWidth)}, ${Math.max(viewBox[1], coveredHeight)}]`);
559
+ }
560
+ diagnostics.push({
561
+ code: 'layout/component-out-of-bounds', severity: 'error', message,
562
+ subject: { diagramType: 'architecture', nodeId: c.id },
563
+ evidence: { bounds: componentDiagnosticBox(c), viewBox: [...viewBox], overflow },
564
+ supportedFixes,
565
+ });
566
+ problems.push(message);
365
567
  }
366
568
  const estLabelW = textUnits(c.label) * 6.6;
367
569
  if (estLabelW > c.width + 8) {
368
- problems.push(`Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) - shorten the label or widen size.`);
570
+ const message = `Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) — shorten the label or widen size.`;
571
+ diagnostics.push({
572
+ code: 'architecture/component-label-overflow', severity: 'error', message,
573
+ subject: { diagramType: 'architecture', nodeId: c.id },
574
+ evidence: { text: c.label, estimatedWidthPx: Math.round(estLabelW), availableWidthPx: c.width },
575
+ supportedFixes: [
576
+ `shorten component "${c.id}" label "${c.label}" while preserving its meaning`,
577
+ `or widen component "${c.id}" to at least ${Math.ceil(estLabelW - 8)}px`,
578
+ ],
579
+ });
580
+ problems.push(message);
369
581
  }
370
582
  const brandRailProblem = brandTopRailProblem(c, c.width, 8, 'Component');
371
583
  if (brandRailProblem) problems.push(brandRailProblem);
@@ -379,17 +591,40 @@ function validateArchitecture() {
379
591
  if (!value) continue;
380
592
  const minimumW = minimumNodeTextWidth(value, minimum);
381
593
  if (minimumW > availableTextW) {
382
- problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but component "${c.id}" provides ${availableTextW}px - shorten the ${field.toLowerCase()} or widen size.`);
594
+ const fieldKey = field.toLowerCase();
595
+ const message = `${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but component "${c.id}" provides ${availableTextW}px — shorten the ${fieldKey} or widen size.`;
596
+ diagnostics.push({
597
+ code: `architecture/component-${fieldKey}-overflow`, severity: 'error', message,
598
+ subject: { diagramType: 'architecture', nodeId: c.id },
599
+ evidence: { text: value, minimumFontPx: minimum, requiredWidthPx: Math.ceil(minimumW), availableWidthPx: availableTextW },
600
+ supportedFixes: [
601
+ `shorten component "${c.id}" ${fieldKey} "${value}" while preserving its meaning`,
602
+ `or widen component "${c.id}" so the ${fieldKey} keeps at least ${Math.ceil(minimumW)}px of text width at the ${minimum}px legible minimum`,
603
+ ],
604
+ });
605
+ problems.push(message);
383
606
  }
384
607
  }
385
608
  }
386
609
 
387
- // Component overlap - the highest-traffic hand-placement failure mode.
610
+ // Component overlap — the highest-traffic hand-placement failure mode.
388
611
  const list = [...components.values()];
389
612
  for (let i = 0; i < list.length; i += 1) {
390
613
  for (let j = i + 1; j < list.length; j += 1) {
391
614
  if (rectsOverlap(list[i], list[j], 8)) {
392
- problems.push(`Components "${list[i].id}" and "${list[j].id}" are less than 8px apart - move one or shrink its size.\n${suggestComponentSeparation(list[i], list[j], 8)}`);
615
+ const separation = componentSeparationOptions(list[i], list[j], 8);
616
+ const message = `Components "${list[i].id}" and "${list[j].id}" are less than 8px apart — move one or shrink its size.\n${suggestComponentSeparation(list[i], list[j], 8)}`;
617
+ diagnostics.push({
618
+ code: 'layout/component-overlap', severity: 'error', message,
619
+ subject: { diagramType: 'architecture', nodeId: list[i].id },
620
+ evidence: { otherId: list[j].id, minimumGapPx: 8, boxes: [componentDiagnosticBox(list[i]), componentDiagnosticBox(list[j])] },
621
+ supportedFixes: [
622
+ `move component "${list[j].id}" pos to [${separation[0].pos[0]}, ${separation[0].pos[1]}] (${separation[0].relation})`,
623
+ `or move component "${list[j].id}" pos to [${separation[1].pos[0]}, ${separation[1].pos[1]}] (${separation[1].relation})`,
624
+ `or shrink "${list[i].id}" or "${list[j].id}" while keeping every label and sublabel readable`,
625
+ ],
626
+ });
627
+ problems.push(message);
393
628
  }
394
629
  }
395
630
  }
@@ -406,24 +641,24 @@ function validateArchitecture() {
406
641
  if (boundary.title.minimumWidth > boundary.title.availableWidth) {
407
642
  problems.push(
408
643
  `Boundary label "${boundary.label}" needs ~${Math.ceil(boundary.title.minimumWidth)}px to fit at the `
409
- + `${Number(boundary.title.minimumFontSize.toFixed(2))}px desktop-readable source minimum, but its frame provides ${Math.floor(boundary.title.availableWidth)}px - `
644
+ + `${Number(boundary.title.minimumFontSize.toFixed(2))}px desktop-readable source minimum, but its frame provides ${Math.floor(boundary.title.availableWidth)}px — `
410
645
  + 'shorten the boundary label, increase pad, or widen the wrapped component layout.',
411
646
  );
412
647
  }
413
648
  if (!rectContains(boundary, boundary.title)) {
414
649
  problems.push(
415
- `Boundary label "${boundary.label}" extends outside its final frame - shorten the label or increase boundary pad.`,
650
+ `Boundary label "${boundary.label}" extends outside its final frame — shorten the label or increase boundary pad.`,
416
651
  );
417
652
  }
418
653
  if (!rectContains(viewBoxRect, boundary.title)) {
419
654
  problems.push(
420
- `Boundary label "${boundary.label}" extends outside the viewBox - move wrapped components away from the canvas edge, shorten the label, or increase the viewBox.`,
655
+ `Boundary label "${boundary.label}" extends outside the viewBox — move wrapped components away from the canvas edge, shorten the label, or increase the viewBox.`,
421
656
  );
422
657
  }
423
658
  for (const component of components.values()) {
424
659
  if (!rectsOverlap(boundary.title, component)) continue;
425
660
  problems.push(
426
- `Boundary label "${boundary.label}" overlaps component "${component.id}" - move the component, increase boundary title space, or shorten the label.`,
661
+ `Boundary label "${boundary.label}" overlaps component "${component.id}" — move the component, increase boundary title space, or shorten the label.`,
427
662
  );
428
663
  }
429
664
  }
@@ -434,7 +669,7 @@ function validateArchitecture() {
434
669
  const right = boundaries[rightIndex];
435
670
  if (enforcesBoundaryTitleComposition && rectsOverlap(left.title, right.title)) {
436
671
  problems.push(
437
- `Boundary labels "${left.label}" and "${right.label}" overlap - shorten a label or increase boundary title space.`,
672
+ `Boundary labels "${left.label}" and "${right.label}" overlap — shorten a label or increase boundary title space.`,
438
673
  );
439
674
  }
440
675
  // Ordinary architecture boundaries are sets, not an implied ownership
@@ -454,7 +689,7 @@ function validateArchitecture() {
454
689
  `Boundary "${left.label}" crosses boundary "${right.label}" because their memberships partially overlap `
455
690
  + `(shared: ${shared.map((id) => `"${id}"`).join(', ')}; `
456
691
  + `only in "${left.label}": ${leftOnly.map((id) => `"${id}"`).join(', ')}; `
457
- + `only in "${right.label}": ${rightOnly.map((id) => `"${id}"`).join(', ')}) - `
692
+ + `only in "${right.label}": ${rightOnly.map((id) => `"${id}"`).join(', ')}) — `
458
693
  + 'keep one boundary fully nested by removing outside members, or split the boundary.',
459
694
  );
460
695
  continue;
@@ -465,7 +700,7 @@ function validateArchitecture() {
465
700
  const rightContainsLeft = rectContains(right, left);
466
701
  if (!leftContainsRight && !rightContainsLeft) {
467
702
  problems.push(
468
- `Boundary "${left.label}" and boundary "${right.label}" final frames partially overlap - `
703
+ `Boundary "${left.label}" and boundary "${right.label}" final frames partially overlap — `
469
704
  + 'adjust wraps, pad, or component positions so the frames are disjoint or one fully contains the other.',
470
705
  );
471
706
  continue;
@@ -473,7 +708,7 @@ function validateArchitecture() {
473
708
 
474
709
  if (!shared.length) {
475
710
  problems.push(
476
- `Boundary "${left.label}" and boundary "${right.label}" final frames overlap even though their memberships are disjoint - `
711
+ `Boundary "${left.label}" and boundary "${right.label}" final frames overlap even though their memberships are disjoint — `
477
712
  + 'adjust pad or component positions so the frames are disjoint, or make wraps express the intended nesting.',
478
713
  );
479
714
  continue;
@@ -483,7 +718,7 @@ function validateArchitecture() {
483
718
  || (rightNested && leftContainsRight);
484
719
  if (!containmentMatchesMembership) {
485
720
  problems.push(
486
- `Boundary "${left.label}" and boundary "${right.label}" final frame containment contradicts their wraps membership - `
721
+ `Boundary "${left.label}" and boundary "${right.label}" final frame containment contradicts their wraps membership — `
487
722
  + 'reduce the inner boundary pad, move its components, or correct wraps so geometry and nesting agree.',
488
723
  );
489
724
  }
@@ -491,18 +726,133 @@ function validateArchitecture() {
491
726
  }
492
727
  for (const b of boundaries) {
493
728
  if (b.x < 0 || b.y < 0 || b.x + b.width > viewBox[0] || b.y + b.height > viewBox[1]) {
494
- problems.push(`Boundary "${b.label}" extends outside the viewBox - its members sit too close to the canvas edge; add margin or enlarge meta.viewBox.`);
729
+ const overflow = {
730
+ left: Math.max(0, -b.x),
731
+ top: Math.max(0, -b.y),
732
+ right: Math.max(0, b.x + b.width - viewBox[0]),
733
+ bottom: Math.max(0, b.y + b.height - viewBox[1]),
734
+ };
735
+ const sides = Object.entries(overflow).filter(([, pixels]) => pixels > 0)
736
+ .map(([side, pixels]) => `${side} by ${Math.ceil(pixels)}px`).join(', ');
737
+ const supportedFixes = [];
738
+ if (overflow.left || overflow.top) {
739
+ supportedFixes.push(`move the wrapped components right by at least ${Math.ceil(overflow.left)}px and down by at least ${Math.ceil(overflow.top)}px, then revalidate connected routes and the opposite canvas sides; enlarging meta.viewBox cannot fix left/top overflow`);
740
+ }
741
+ if (overflow.right || overflow.bottom) {
742
+ supportedFixes.push(`increase meta.viewBox to at least [${Math.ceil(Math.max(viewBox[0], b.x + b.width))}, ${Math.ceil(Math.max(viewBox[1], b.y + b.height))}] for right/bottom overflow, or move the wrapped components inward; revalidate desktop readability`);
743
+ }
744
+ const message = `Boundary "${b.label}" extends outside the viewBox (${sides}) — preserve wraps membership and repair the measured canvas side.`;
745
+ diagnostics.push({
746
+ code: 'layout/boundary-out-of-bounds',
747
+ severity: 'error',
748
+ message,
749
+ subject: { diagramType: 'architecture', boundary: { kind: b.kind, label: b.label, wraps: b.wraps } },
750
+ evidence: {
751
+ bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
752
+ viewBox: [...viewBox],
753
+ overflow,
754
+ members: asArray(b.wraps).map((id) => components.get(id)).filter(Boolean).map(componentBox),
755
+ },
756
+ supportedFixes,
757
+ });
758
+ problems.push(message);
495
759
  }
496
760
  }
497
761
 
498
- for (const conn of asArray(arch.connections)) {
499
- if (!components.has(conn.from)) problems.push(`Connection "${conn.label || conn.from}" references unknown source "${conn.from}".`);
500
- if (!components.has(conn.to)) problems.push(`Connection "${conn.label || conn.to}" references unknown target "${conn.to}".`);
762
+ // A boundary frame is drawn as the padded box around its members, so a
763
+ // component that is not in `wraps` but sits wholly inside that box reads as
764
+ // a member. Report it instead of letting the picture claim false ownership.
765
+ // A box the frame border visibly cuts through does not claim membership,
766
+ // and earlier valid inputs depend on that, so only full enclosure fails.
767
+ // This new composition gate applies to showcase; standard keeps its existing
768
+ // acceptance contract while authors can opt into the stricter check.
769
+ if (arch.meta?.quality_profile === 'showcase') {
770
+ for (const b of boundaries) {
771
+ const members = new Set(asArray(b.wraps));
772
+ for (const component of components.values()) {
773
+ if (members.has(component.id) || !rectContains(b, component)) continue;
774
+ const message = `Component "${component.id}" is not wrapped by boundary "${b.label}" but sits inside its frame — `
775
+ + 'move it outside the frame, place the wrapped members in one compact axis-aligned cluster (no empty bay for a non-member), or add it to wraps only when it truly belongs there.';
776
+ problems.push(message);
777
+ diagnostics.push({
778
+ code: 'layout/boundary-encloses-non-member',
779
+ severity: 'error',
780
+ message,
781
+ subject: { diagramType: 'architecture', boundary: { kind: b.kind, label: b.label, wraps: b.wraps }, component: component.id },
782
+ evidence: {
783
+ bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
784
+ component: componentBox(component),
785
+ },
786
+ supportedFixes: [
787
+ `move "${component.id}" outside the boundary frame`,
788
+ 'reposition the wrapped members into one compact axis-aligned cluster (no empty bay) that leaves non-members outside',
789
+ ],
790
+ });
791
+ }
792
+ }
793
+ }
794
+
795
+ const connectionList = asArray(arch.connections);
796
+ const knownComponentIds = [...components.keys()].sort();
797
+ for (const conn of connectionList) {
798
+ const connIndex = connectionList.indexOf(conn);
799
+ for (const [field, endpoint] of [['from', 'source'], ['to', 'target']]) {
800
+ if (components.has(conn[field])) continue;
801
+ const message = `Connection "${conn.label || conn[field]}" references unknown ${endpoint} "${conn[field]}".`;
802
+ const otherId = conn[field === 'from' ? 'to' : 'from'];
803
+ const neighbors = new Set();
804
+ for (const edge of connectionList) {
805
+ if (edge === conn) continue;
806
+ if (edge.from === otherId) neighbors.add(edge.to);
807
+ if (edge.to === otherId) neighbors.add(edge.from);
808
+ }
809
+ const candidates = knownComponentIds
810
+ .filter((id) => id !== otherId)
811
+ .sort((a, b) => (neighbors.has(b) - neighbors.has(a)) || a.localeCompare(b));
812
+ diagnostics.push({
813
+ code: 'architecture/unknown-endpoint', severity: 'error', message,
814
+ subject: {
815
+ diagramType: 'architecture',
816
+ connection: conn.id ?? null,
817
+ path: `/connections/${connIndex}/${field}`,
818
+ from: conn.from,
819
+ to: conn.to,
820
+ },
821
+ evidence: { endpoint, unknownNodeId: conn[field], availableNodeIds: knownComponentIds },
822
+ supportedFixes: candidates.slice(0, 3).map((id) => `set /connections/${connIndex}/${field} to verified node id "${id}"`),
823
+ });
824
+ problems.push(message);
825
+ problems.push(`Connection "${conn.label || conn[field]}" endpoint "${conn[field]}" does not name a declared component.`);
826
+ }
501
827
  if (components.has(conn.from) && components.has(conn.to)) {
502
828
  const routed = pathFor(conn);
829
+ const outsidePoints = routed.points.filter(([x, y]) => x < 0 || y < 0 || x > viewBox[0] || y > viewBox[1]);
830
+ if (arch.meta?.quality_profile === 'showcase' && outsidePoints.length) {
831
+ const message = `Connection "${conn.id || `${conn.from}->${conn.to}`}" extends outside the viewBox — move the measured outside route points inward or enlarge an authored viewBox for right/bottom overflow.`;
832
+ diagnostics.push({
833
+ code: 'layout/route-out-of-bounds', severity: 'error', message,
834
+ subject: { diagramType: 'architecture', collection: 'connections', index: asArray(arch.connections).indexOf(conn), ...(conn.id ? { id: conn.id } : {}), from: conn.from, to: conn.to },
835
+ evidence: { viewBox: [...viewBox], outsidePoints, points: routed.points },
836
+ supportedFixes: ['move negative route coordinates inside the canvas; for right/bottom overflow, enlarge meta.viewBox or reroute inward; preserve endpoints, direction and labels, then revalidate'],
837
+ });
838
+ problems.push(message);
839
+ }
503
840
  const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
504
841
  const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
505
- if (distance < 24) problems.push(`Connection "${conn.label || `${conn.from}->${conn.to}`}" is too short (${Math.round(distance)}px; minimum 24px) - place its components farther apart.`);
842
+ if (distance < 24 && conn.from === conn.to) {
843
+ // "Move the components apart" cannot be executed for a self-loop; the
844
+ // ports sit on one component and the sides decide how far apart.
845
+ const message = `Self-loop "${conn.id || conn.label || conn.from}" on component "${conn.from}" has its two ports only ${Math.round(distance)}px apart (minimum 24px) — remove fromSide/toSide so the renderer can choose the loop's sides, or set fromSide and toSide to different sides.`;
846
+ diagnostics.push({
847
+ code: 'layout/self-loop-ports', severity: 'error', message,
848
+ subject: { diagramType: 'architecture', collection: 'connections', index: asArray(arch.connections).indexOf(conn), ...(conn.id ? { id: conn.id } : {}), from: conn.from, to: conn.to },
849
+ evidence: { distancePx: Math.round(distance), minimumPx: 24, fromSide: connectionEndpointSide(conn, 'source'), toSide: connectionEndpointSide(conn, 'target'), points: routed.points },
850
+ supportedFixes: ['remove fromSide/toSide from the self-loop', 'set fromSide and toSide to different sides of the component'],
851
+ });
852
+ problems.push(message);
853
+ } else if (distance < 24) {
854
+ problems.push(`Connection "${conn.label || `${conn.from}->${conn.to}`}" is too short (${Math.round(distance)}px; minimum 24px) — place its components farther apart.`);
855
+ }
506
856
  }
507
857
  }
508
858
 
@@ -532,6 +882,12 @@ function validateArchitecture() {
532
882
  diagramType: 'architecture',
533
883
  relationCollection: 'connections',
534
884
  profile: arch.meta?.quality_profile,
885
+ // Automatic architecture routes render with an opaque crossover halo.
886
+ // That makes a proper X visually unambiguous while explicit authored
887
+ // crossings remain a blocking composition error.
888
+ crossingResolved: (left, right) => (
889
+ hasAutomaticRouteGeometry(left) && hasAutomaticRouteGeometry(right)
890
+ ),
535
891
  routeHint: 'adjust route/via or fromSide/toSide so the connections use separate corridors'
536
892
  }));
537
893
  problems.push(...cleanAmbiguousCorridorProblems({
@@ -541,8 +897,29 @@ function validateArchitecture() {
541
897
  diagramType: 'architecture',
542
898
  relationCollection: 'connections',
543
899
  profile: arch.meta?.quality_profile,
544
- routeHint: 'adjust route/via or fromSide/toSide so unrelated connections do not visually merge'
900
+ includeSharedEndpoints: (left, right) => hasAutomaticRouteGeometry(left) && !left.labelAt
901
+ && hasAutomaticRouteGeometry(right) && !right.labelAt,
902
+ routeHint: 'adjust route/via or fromSide/toSide so distinct connections do not visually merge'
545
903
  }));
904
+ if ((process.env.ARCHIFY_QUALITY_PROFILE || arch.meta?.quality_profile) === 'showcase') {
905
+ const collisions = collectArrowheadCollisions({
906
+ routedRelations: asArray(arch.connections)
907
+ .filter((conn) => components.has(conn.from) && components.has(conn.to) && hasAutomaticRouteGeometry(conn) && !conn.labelAt)
908
+ .map((relation) => ({ relation, points: pathFor(relation).points })),
909
+ });
910
+ for (const hit of collisions) {
911
+ const left = hit.left.relation;
912
+ const right = hit.right.relation;
913
+ const message = `[composition/arrowhead-collision] automatic connections "${left.id || left.from}" and "${right.id || right.from}" into "${left.to}" have arrowheads ${hit.distance}px apart (minimum ${hit.minimum}px) — enlarge or reposition the destination, or choose separate toSide ports.`;
914
+ problems.push(message);
915
+ diagnostics.push({
916
+ code: 'composition/arrowhead-collision', severity: 'error', message,
917
+ subject: { diagramType: 'architecture', collection: 'connections', id: left.id, from: left.from, to: left.to },
918
+ evidence: { otherId: right.id, distancePx: hit.distance, minimumPx: hit.minimum, endpoints: [hit.left.tip, hit.right.tip] },
919
+ supportedFixes: ['enlarge or reposition the destination', 'choose separate toSide ports'],
920
+ });
921
+ }
922
+ }
546
923
  problems.push(...cleanBorderRunProblems({
547
924
  relations: arch.connections,
548
925
  endpointIds: new Set(components.keys()),
@@ -562,26 +939,47 @@ function validateArchitecture() {
562
939
  profile: arch.meta?.quality_profile,
563
940
  routeHint: 'move route/via points into a wider corridor or move the component so every turn has room to read'
564
941
  }));
565
-
942
+ problems.push(...cleanRouteDetourProblems({
943
+ relations: arch.connections,
944
+ obstacles: components.values(),
945
+ contentRects: [...components.values(), ...boundaries],
946
+ endpointIds: new Set(components.keys()),
947
+ pathFor,
948
+ fromSideFor: (conn) => connectionEndpointSide(conn, 'source'),
949
+ toSideFor: (conn) => connectionEndpointSide(conn, 'target'),
950
+ diagramType: 'architecture',
951
+ relationCollection: 'connections',
952
+ profile: arch.meta?.quality_profile,
953
+ }));
566
954
  // Connection labels must not land on top of components.
567
- const labelRects = [];
568
- for (const [connectionIndex, conn] of asArray(arch.connections).entries()) {
569
- if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
570
- const [lx, ly] = labelPoint(conn, pathFor(conn).points);
571
- const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
572
- labelRects.push({ relation: conn, relationIndex: connectionIndex, label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly });
573
- }
955
+ const labelRects = connectionLabels;
574
956
  for (const rect of labelRects) {
575
- for (const c of components.values()) {
576
- if (rectsOverlap(rect, c, -2)) {
577
- problems.push(`Label "${rect.label}" overlaps component "${c.id}" - adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c)}`);
957
+ const blockedComponents = [...components.values()].filter(c => rectsOverlap(rect, c, -2));
958
+ const labelPinned = ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => rect.relation[key] !== undefined);
959
+ const points = pathFor(rect.relation).points;
960
+ const shortHorizontalGap = points.length === 2 && Math.abs(points[0][1] - points[1][1]) < 0.0001
961
+ ? Math.abs(points[1][0] - points[0][0]) : null;
962
+ const requiredGap = Math.ceil(rect.width + 16);
963
+ if (arch.meta?.quality_profile === 'showcase' && !labelPinned && blockedComponents.length
964
+ && shortHorizontalGap != null && shortHorizontalGap < requiredGap) {
965
+ const message = `Label "${rect.label}" has only ${Math.round(shortHorizontalGap)}px between "${rect.relation.from}" and "${rect.relation.to}"; it needs at least ${requiredGap}px to stay beside its route — increase that clear gap or place the connected nodes on another readable row, preserving the label.`;
966
+ problems.push(message);
967
+ diagnostics.push({
968
+ code: 'composition/label-gap', severity: 'error', message,
969
+ subject: { diagramType: 'architecture', collection: 'connections', id: rect.relation.id, from: rect.relation.from, to: rect.relation.to },
970
+ evidence: { clearGapPx: shortHorizontalGap, minimumGapPx: requiredGap, labelWidthPx: rect.width, obstacles: blockedComponents.map(c => c.id) },
971
+ supportedFixes: [`increase the clear gap between the connected nodes to at least ${requiredGap}px`, 'reposition the connected nodes together while preserving the full relationship label'],
972
+ });
973
+ } else {
974
+ for (const c of blockedComponents) {
975
+ problems.push(`Label "${rect.label}" overlaps component "${c.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c, 'component', viewBox, components.values())}`);
578
976
  }
579
977
  }
580
978
  if (enforcesBoundaryTitleComposition) {
581
979
  for (const boundary of boundaries) {
582
980
  if (!rectsOverlap(boundary.title, rect)) continue;
583
981
  problems.push(
584
- `Boundary label "${boundary.label}" overlaps connection label "${rect.label}" - move the boundary title rail by adjusting wrapped component positions, or move the connection label with labelAt/labelDx/labelDy/labelSegment.`,
982
+ `Boundary label "${boundary.label}" overlaps connection label "${rect.label}" — move the boundary title rail by adjusting wrapped component positions, or move the connection label with labelAt/labelDx/labelDy/labelSegment.`,
585
983
  );
586
984
  }
587
985
  }
@@ -595,29 +993,34 @@ function validateArchitecture() {
595
993
  relationCollection: 'connections',
596
994
  profile: arch.meta?.quality_profile,
597
995
  }));
996
+ // See collectLabelCanvasOverflow in shared/geometry.mjs. An auto canvas now
997
+ // covers these rects, so this reports authored viewBoxes and the origin side,
998
+ // which growth cannot reach.
999
+ problems.push(...cleanLabelCanvasContainmentProblems({
1000
+ labels: labelRects,
1001
+ viewBox,
1002
+ diagramType: 'architecture',
1003
+ relationCollection: 'connections',
1004
+ profile: arch.meta?.quality_profile,
1005
+ }));
598
1006
 
599
1007
  if (problems.length) {
600
1008
  throwDiagnosticProblems('Architecture layout validation failed', problems, {
601
1009
  subject: { diagramType: 'architecture' },
1010
+ diagnostics,
602
1011
  });
603
1012
  }
604
1013
  }
605
1014
 
606
1015
  function buildLayoutReport() {
607
- const labels = [];
608
- for (const conn of asArray(arch.connections)) {
609
- if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
610
- const [lx, ly] = labelPoint(conn, pathFor(conn).points);
611
- const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
612
- labels.push({
613
- text: conn.label,
614
- x: Math.round(lx - w / 2),
615
- y: Math.round(ly - 10),
616
- width: Math.round(w),
617
- height: 14,
618
- labelAt: [Math.round(lx), Math.round(ly)],
619
- });
620
- }
1016
+ const labels = connectionLabels.map((rect) => ({
1017
+ text: rect.label,
1018
+ x: Math.round(rect.x),
1019
+ y: Math.round(rect.y),
1020
+ width: Math.round(rect.width),
1021
+ height: 14,
1022
+ labelAt: [Math.round(rect.lx), Math.round(rect.ly)],
1023
+ }));
621
1024
  return {
622
1025
  ok: true,
623
1026
  diagram_type: 'architecture',
@@ -629,323 +1032,13 @@ function buildLayoutReport() {
629
1032
  .filter((conn) => components.has(conn.from) && components.has(conn.to))
630
1033
  .map((conn) => {
631
1034
  const routed = pathFor(conn);
632
- const labelAt = conn.label ? labelPoint(conn, routed.points) : null;
1035
+ const labelAt = conn.label ? resolvedLabelPoints.get(conn) || labelPoint(conn, routed.points) : null;
633
1036
  return connectionPath(conn, routed, labelAt);
634
1037
  }),
635
1038
  labels,
636
1039
  };
637
1040
  }
638
1041
 
639
- // ---- Connection routing ------------------------------------------------------
640
- function routeClearsComponents(conn, points, clearance = 2) {
641
- const endpointIds = new Set([conn.from, conn.to]);
642
- for (const component of components.values()) {
643
- if (endpointIds.has(component.id)) continue;
644
- for (let index = 0; index < points.length - 1; index += 1) {
645
- if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) {
646
- return false;
647
- }
648
- }
649
- }
650
- return true;
651
- }
652
-
653
- function routeClearsEndpointComponents(points, from, to) {
654
- const lastSegment = points.length - 2;
655
- for (let index = 0; index <= lastSegment; index += 1) {
656
- const segment = { start: points[index], end: points[index + 1] };
657
- if (index > 0 && segmentIntersectsRect(segment, from)) return false;
658
- if (index < lastSegment && segmentIntersectsRect(segment, to)) return false;
659
- }
660
- return true;
661
- }
662
-
663
- const OUTWARD_SIDE_VECTOR = {
664
- left: [-1, 0],
665
- right: [1, 0],
666
- top: [0, -1],
667
- bottom: [0, 1],
668
- };
669
-
670
- function outwardStub(point, side, distance = 24) {
671
- const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0];
672
- return [point[0] + dx * distance, point[1] + dy * distance];
673
- }
674
-
675
- function collinearBacktrack(a, b, c) {
676
- const first = [b[0] - a[0], b[1] - a[1]];
677
- const second = [c[0] - b[0], c[1] - b[1]];
678
- const cross = first[0] * second[1] - first[1] * second[0];
679
- const dot = first[0] * second[0] + first[1] * second[1];
680
- return Math.abs(cross) <= 0.0001 && dot < -0.0001;
681
- }
682
-
683
- function sideAwareBridgeCandidates(start, end, fromSide, toSide) {
684
- const startStub = outwardStub(start, fromSide);
685
- const endStub = outwardStub(end, toSide);
686
- const rawCandidates = [];
687
- const minimumBridge = 16;
688
- const verticalSides = new Set(['top', 'bottom']);
689
- const horizontalSides = new Set(['left', 'right']);
690
-
691
- // Port spreading can leave parallel-side anchors only a few pixels apart.
692
- // Route through a bounded outside channel so we keep both endpoint normals
693
- // without introducing a tiny, noisy connector between the two stubs.
694
- if (verticalSides.has(fromSide) && verticalSides.has(toSide)
695
- && Math.abs(start[0] - end[0]) < minimumBridge) {
696
- for (const channelX of [
697
- Math.max(start[0], end[0]) + minimumBridge,
698
- Math.min(start[0], end[0]) - minimumBridge,
699
- ]) {
700
- rawCandidates.push([
701
- startStub,
702
- [channelX, startStub[1]],
703
- [channelX, endStub[1]],
704
- endStub,
705
- ]);
706
- }
707
- }
708
- if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
709
- && Math.abs(start[1] - end[1]) < minimumBridge) {
710
- for (const channelY of [
711
- Math.max(start[1], end[1]) + minimumBridge,
712
- Math.min(start[1], end[1]) - minimumBridge,
713
- ]) {
714
- rawCandidates.push([
715
- startStub,
716
- [startStub[0], channelY],
717
- [endStub[0], channelY],
718
- endStub,
719
- ]);
720
- }
721
- }
722
-
723
- rawCandidates.push(
724
- [startStub, [endStub[0], startStub[1]], endStub],
725
- [startStub, [startStub[0], endStub[1]], endStub],
726
- );
727
- return rawCandidates.map((candidate) => normalizeRoutePoints([start, ...candidate, end]))
728
- .filter((points) => points.length >= 2)
729
- .filter((points) => !collinearBacktrack(points[0], points[1], points[2] || points[1]))
730
- .filter((points) => !collinearBacktrack(points.at(-3) || points.at(-2), points.at(-2), points.at(-1)))
731
- .filter((points) => routeHonorsEndpointSides(points, fromSide, toSide))
732
- .map((points) => points.slice(1, -1));
733
- }
734
-
735
- const AUTOMATIC_PORT_CORNER_GUTTER = 16;
736
- const AUTOMATIC_PORT_ALIGNMENT_DELTA = 16;
737
-
738
- function portHasCornerClearance(rect, side, point) {
739
- if (side === 'left' || side === 'right') {
740
- const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.height / 2);
741
- return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset;
742
- }
743
- if (side === 'top' || side === 'bottom') {
744
- const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.width / 2);
745
- return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset;
746
- }
747
- return false;
748
- }
749
-
750
- function alignFacingPorts(conn, from, to, start, end, fromSide, toSide, ports) {
751
- const hasExplicitGeometry = (
752
- conn.via
753
- || (conn.route && conn.route !== 'auto')
754
- || conn.channelX !== undefined
755
- || conn.channelY !== undefined
756
- || conn.labelAt
757
- );
758
- const horizontallyFacing = (
759
- (fromSide === 'right' && toSide === 'left')
760
- || (fromSide === 'left' && toSide === 'right')
761
- );
762
- const verticallyFacing = (
763
- (fromSide === 'bottom' && toSide === 'top')
764
- || (fromSide === 'top' && toSide === 'bottom')
765
- );
766
- if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end };
767
-
768
- const fromSpread = Boolean(ports?.from);
769
- const toSpread = Boolean(ports?.to);
770
- if (fromSpread && toSpread) return { start, end };
771
- const hasExplicitSides = (
772
- (conn.fromSide && conn.fromSide !== 'auto')
773
- || (conn.toSide && conn.toSide !== 'auto')
774
- );
775
- if (!fromSpread && !toSpread && hasExplicitSides) return { start, end };
776
-
777
- const alignmentDelta = horizontallyFacing
778
- ? Math.abs(start[1] - end[1])
779
- : Math.abs(start[0] - end[0]);
780
- if (alignmentDelta >= AUTOMATIC_PORT_ALIGNMENT_DELTA) return { start, end };
781
-
782
- // Keep the shared endpoint's distinct spread slot and move only the
783
- // relationship's unshared endpoint onto that axis. With no spread endpoint,
784
- // retain the existing least-movement choice between the two facing sides.
785
- // If both endpoints are shared, preserve the outside bridge so no competing
786
- // port is silently collapsed.
787
- const alignEndToStart = horizontallyFacing
788
- ? { start, end: [end[0], start[1]] }
789
- : { start, end: [start[0], end[1]] };
790
- const alignStartToEnd = horizontallyFacing
791
- ? { start: [start[0], end[1]], end }
792
- : { start: [end[0], start[1]], end };
793
- const candidates = fromSpread
794
- ? [alignEndToStart]
795
- : toSpread
796
- ? [alignStartToEnd]
797
- : [alignEndToStart, alignStartToEnd];
798
- for (const candidate of candidates) {
799
- const points = [candidate.start, candidate.end];
800
- if (portHasCornerClearance(from, fromSide, candidate.start)
801
- && portHasCornerClearance(to, toSide, candidate.end)
802
- && routeHonorsEndpointSides(points, fromSide, toSide)
803
- && routeClearsEndpointComponents(points, from, to)
804
- && routeClearsComponents(conn, points)) {
805
- return candidate;
806
- }
807
- }
808
- return { start, end };
809
- }
810
-
811
- function routeVia(conn, from, to, start, end, fromSide, toSide) {
812
- if (conn.via) return conn.via;
813
- switch (conn.route || 'auto') {
814
- case 'straight':
815
- return [];
816
- case 'orthogonal-h': {
817
- const midX = (start[0] + end[0]) / 2;
818
- return [[midX, start[1]], [midX, end[1]]];
819
- }
820
- case 'orthogonal-v': {
821
- const midY = (start[1] + end[1]) / 2;
822
- return [[start[0], midY], [end[0], midY]];
823
- }
824
- case 'auto':
825
- default: {
826
- // Direct line unless the anchors are clearly orthogonal-friendly.
827
- const deltaX = Math.abs(start[0] - end[0]);
828
- const deltaY = Math.abs(start[1] - end[1]);
829
- if ((deltaX < 4 || deltaY < 4) && routeHonorsEndpointSides([start, end], fromSide, toSide)) return [];
830
-
831
- const rhythmBridge = automaticPortRhythmBridge(start, end, fromSide, toSide, {
832
- accept: (points) => (
833
- routeClearsEndpointComponents(points, from, to)
834
- && routeClearsComponents(conn, points)
835
- ),
836
- });
837
- if (rhythmBridge) return rhythmBridge.slice(1, -1);
838
-
839
- // Automatic port spreading can leave otherwise aligned endpoints only a
840
- // few pixels apart. A midpoint route would split that tiny difference
841
- // into two unreadable endpoint stubs, so take a bounded outside channel
842
- // when both anchors sit on parallel component sides.
843
- const minimumStub = 8;
844
- const fromVerticalSide = start[1] === from.y || start[1] === from.y + from.height;
845
- const toVerticalSide = end[1] === to.y || end[1] === to.y + to.height;
846
- if (fromVerticalSide && toVerticalSide && deltaX < minimumStub * 2) {
847
- const outsideChannels = [
848
- Math.max(start[0], end[0]) + minimumStub * 2,
849
- Math.min(start[0], end[0]) - minimumStub * 2,
850
- ];
851
- for (const channelX of outsideChannels) {
852
- const candidate = [[channelX, start[1]], [channelX, end[1]]];
853
- const points = [start, ...candidate, end];
854
- if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate;
855
- }
856
- }
857
-
858
- const fromHorizontalSide = start[0] === from.x || start[0] === from.x + from.width;
859
- const toHorizontalSide = end[0] === to.x || end[0] === to.x + to.width;
860
- if (fromHorizontalSide && toHorizontalSide && deltaY < minimumStub * 2) {
861
- const outsideChannels = [
862
- Math.max(start[1], end[1]) + minimumStub * 2,
863
- Math.min(start[1], end[1]) - minimumStub * 2,
864
- ];
865
- for (const channelY of outsideChannels) {
866
- const candidate = [[start[0], channelY], [end[0], channelY]];
867
- const points = [start, ...candidate, end];
868
- if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate;
869
- }
870
- }
871
-
872
- const midX = (start[0] + end[0]) / 2;
873
- const horizontalFirst = [[midX, start[1]], [midX, end[1]]];
874
- const midY = (start[1] + end[1]) / 2;
875
- const verticalFirst = [[start[0], midY], [end[0], midY]];
876
- const candidates = [horizontalFirst, verticalFirst];
877
- const sideSafe = candidates.filter((candidate) => (
878
- routeHonorsEndpointSides([start, ...candidate, end], fromSide, toSide)
879
- ));
880
- const sideAware = sideAwareBridgeCandidates(start, end, fromSide, toSide);
881
- const nearParallelPorts = (
882
- ((fromSide === 'top' || fromSide === 'bottom')
883
- && (toSide === 'top' || toSide === 'bottom')
884
- && deltaX < minimumStub * 2)
885
- || ((fromSide === 'left' || fromSide === 'right')
886
- && (toSide === 'left' || toSide === 'right')
887
- && deltaY < minimumStub * 2)
888
- );
889
- const ordered = [
890
- ...(nearParallelPorts ? sideAware : sideSafe),
891
- ...(nearParallelPorts ? sideSafe : sideAware),
892
- ...candidates.filter((candidate) => !sideSafe.includes(candidate)),
893
- ];
894
- for (const candidate of ordered) {
895
- const points = [start, ...candidate, end];
896
- if (routeClearsEndpointComponents(points, from, to) && routeClearsComponents(conn, points)) return candidate;
897
- }
898
-
899
- // Both bounded doglegs are blocked. Keep the best endpoint-safe route
900
- // when one exists so the universal Clean Flow gate reports the actual
901
- // obstacle; otherwise preserve the historical deterministic fallback
902
- // and let the endpoint-direction gate explain the side mismatch.
903
- return sideSafe[0] || sideAware[0] || horizontalFirst;
904
- }
905
- }
906
- }
907
-
908
- const pathCache = new Map();
909
- const automaticPorts = automaticPortSpread(arch.connections, components);
910
- function connectionSides(conn) {
911
- const from = components.get(conn.from);
912
- const to = components.get(conn.to);
913
- return {
914
- fromSide: chosenSide(conn.fromSide, defaultFromSide(from, to)),
915
- toSide: chosenSide(conn.toSide, defaultToSide(from, to)),
916
- };
917
- }
918
-
919
- function connectionEndpointSide(conn, endpoint) {
920
- const field = endpoint === 'source' ? 'fromSide' : 'toSide';
921
- if (conn[field] && conn[field] !== 'auto') return conn[field];
922
- return connectionSides(conn)[field];
923
- }
924
-
925
- function pathFor(conn) {
926
- if (pathCache.has(conn)) return pathCache.get(conn);
927
- const from = components.get(conn.from);
928
- const to = components.get(conn.to);
929
- const ports = automaticPorts.get(conn);
930
- const { fromSide, toSide } = connectionSides(conn);
931
- const baseStart = ports?.from || anchor(from, fromSide);
932
- const baseEnd = ports?.to || anchor(to, toSide);
933
- const { start, end } = alignFacingPorts(
934
- conn,
935
- from,
936
- to,
937
- baseStart,
938
- baseEnd,
939
- fromSide,
940
- toSide,
941
- ports,
942
- );
943
- const points = [start, ...routeVia(conn, from, to, start, end, fromSide, toSide), end];
944
- const routed = { d: roundedPath(points, 8), points };
945
- pathCache.set(conn, routed);
946
- return routed;
947
- }
948
-
949
1042
  // ---- Rendering ---------------------------------------------------------------
950
1043
  function renderBoundaryFrame(b, index) {
951
1044
  const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region';
@@ -965,16 +1058,25 @@ function renderConnectionPath(conn, index) {
965
1058
  const [cls, marker] = arrowClassMap[conn.variant || 'default'] || arrowClassMap.default;
966
1059
  const routed = pathFor(conn);
967
1060
  const strokeWidth = conn.width || (conn.variant === 'emphasis' ? 1.8 : 1.5);
968
- return ` <path ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(arch.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
1061
+ const automaticRoute = hasAutomaticRouteGeometry(conn);
1062
+ const underlay = automaticRoute
1063
+ ? ` <path data-graph-role="automatic-crossover-underlay" d="${routed.d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>\n`
1064
+ : '';
1065
+ const crossover = automaticRoute
1066
+ ? ` data-composition-crossover="halo"${conn.labelAt ? '' : ' data-composition-independent="true"'}` : '';
1067
+ const edge = ` <path ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)} data-composition-points="${routePointsValue(routed.points)}"${crossover}${authoredStraightRouteAttrs(conn, routed.points)} d="${routed.d}" class="${cls}"${animateAttr(arch.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
1068
+ if (!automaticRoute) return edge;
1069
+ // The wrapper is presentation-only: viewer state remains on the one semantic
1070
+ // edge, while CSS can keep its preceding mask underlay at the same opacity.
1071
+ return ` <g data-graph-role="automatic-crossover" style="--step:${index}">\n${underlay}${edge.replace(/^ /, ' ')}\n </g>`;
969
1072
  }
970
1073
 
971
1074
  function renderConnectionLabel(conn, index) {
972
- if (!conn.label) return '';
973
- const [lx, ly] = labelPoint(conn, pathFor(conn).points);
974
- const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
1075
+ const box = connectionLabelBox(conn);
1076
+ if (!box) return '';
975
1077
  return ` <g data-detail="context" ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)}>
976
- <rect x="${lx - w / 2}" y="${ly - 10}" width="${w}" height="14" rx="3" class="c-mask"/>
977
- <text x="${lx}" y="${ly}" class="${variantAccent(conn.variant)}" font-size="8" text-anchor="middle">${esc(conn.label)}</text>
1078
+ <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="3" class="c-mask"/>
1079
+ <text x="${box.lx}" y="${box.ly}" class="${edgeLabelAccent(conn.variant)}" font-size="8" text-anchor="middle">${esc(conn.label)}</text>
978
1080
  </g>`;
979
1081
  }
980
1082
 
@@ -995,9 +1097,9 @@ function renderComponent(c) {
995
1097
  const passport = { kind: c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c), ...brandMetadataFor(c) };
996
1098
  return ` <g ${focusNodeAttrs(c.id, c.label, passport, arch.meta.locale)}>
997
1099
  ${focusNodeTitle(c.label, passport)}
998
- <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="6" class="c-mask"/>
999
- <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="6" class="${fill}"${animateAttr(arch.meta, 'node', componentSteps.get(c.id))} stroke-width="1.5"/>
1000
- ${renderSemanticSigil(c.type, { x: c.x + 6, y: c.y + 6 })}${brand ? `\n ${brand}` : ''}
1100
+ <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="8" class="c-mask"/>
1101
+ <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="8" class="${fill}"${animateAttr(arch.meta, 'node', componentSteps.get(c.id))} stroke-width="1.5"/>
1102
+ ${renderSemanticSigil(c.type, { icon: c.icon, x: c.x + 6, y: c.y + 6 })}${brand ? `\n ${brand}` : ''}
1001
1103
  <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${cx}" y="${labelY}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(c.label)}</text>${sub}${tag}
1002
1104
  </g>`;
1003
1105
  }
@@ -1006,18 +1108,15 @@ function renderLegend() {
1006
1108
  const entries = architectureLegendEntries;
1007
1109
  const relationshipObstacles = relationshipLegendObstacles(arch.connections, {
1008
1110
  pointsFor: (connection) => pathFor(connection).points,
1009
- labelRectFor: (connection) => {
1010
- if (!connection.label) return null;
1011
- const [x, y] = labelPoint(connection, pathFor(connection).points);
1012
- const width = Math.max(30, textUnits(connection.label) * 4.8 + 10);
1013
- return { x: x - width / 2, y: y - 10, width, height: 14 };
1014
- },
1111
+ labelRectFor: connectionLabelBox,
1015
1112
  });
1016
- const contentBottom = Math.max(
1017
- 0,
1018
- ...[...components.values()].map((component) => component.y + component.height),
1019
- ...boundaries.map((boundary) => boundary.y + boundary.height),
1020
- );
1113
+ let contentBottom = 0;
1114
+ for (const component of components.values()) {
1115
+ contentBottom = Math.max(contentBottom, component.y + component.height);
1116
+ }
1117
+ for (const boundary of boundaries) {
1118
+ contentBottom = Math.max(contentBottom, boundary.y + boundary.height);
1119
+ }
1021
1120
  return renderResolvedLegend({
1022
1121
  entries,
1023
1122
  locale: arch.meta.locale,
@@ -1035,9 +1134,23 @@ function renderLegend() {
1035
1134
  }
1036
1135
 
1037
1136
  function renderSvg() {
1038
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta)}>
1137
+ // An automatic architecture canvas is compiler-measured geometry. Let the
1138
+ // Reader spend the real desktop height budget on it, including when an
1139
+ // outer route makes the canvas taller than the ordinary wide-diagram
1140
+ // threshold. Authored viewBoxes keep their geometry and existing Reader width policy;
1141
+ // their declared height may use readable document scrolling without reflow.
1142
+ const readerFit = arch.meta?.viewBox
1143
+ ? ' data-diagram-type="architecture" data-reader-fit="authored-height"'
1144
+ : ' data-reader-fit="intrinsic-height"';
1145
+ // A complete repository architecture is allowed to use normal page scroll;
1146
+ // keep its common-desktop text at a comfortable reading size instead of
1147
+ // shrinking a semantically rich graph to the universal emergency floor.
1148
+ const readerMinimumText = arch.meta?.viewBox ? '' : ' data-reader-min-text="7.5"';
1149
+ const readerPrimaryText = arch.meta?.viewBox ? '' : ' data-reader-primary-text="14"';
1150
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta)} ${NODE_CARD_ATTR}${readerFit}${readerMinimumText}${readerPrimaryText}>
1039
1151
  ${svgAccessibleText(arch.meta, 'architecture')}
1040
1152
  ${renderDefinitions()}
1153
+ ${nodeCardStyle()}
1041
1154
 
1042
1155
  <!-- Background Grid -->
1043
1156
  <rect width="100%" height="100%" fill="url(#grid)" />
@@ -1062,17 +1175,30 @@ ${renderLegend()}
1062
1175
  </svg>`;
1063
1176
  }
1064
1177
 
1065
- validateArchitecture();
1066
1178
  if (layoutJsonMode) {
1067
- console.log(JSON.stringify(buildLayoutReport(), null, 2));
1068
- process.exit(0);
1179
+ try {
1180
+ validateArchitecture();
1181
+ } catch (error) {
1182
+ // A rejected layout is still useful repair evidence. Input/implementation
1183
+ // failures must retain their existing failure boundary, not partial geometry.
1184
+ if (!error.archifyDiagnostics?.length) throw error;
1185
+ console.log(JSON.stringify({
1186
+ ...buildLayoutReport(),
1187
+ ...rendererFailure(error),
1188
+ contract: 'archify-architecture-layout-v1',
1189
+ }, null, 2));
1190
+ process.exitCode = 1;
1191
+ }
1192
+ if (!process.exitCode) console.log(JSON.stringify(buildLayoutReport(), null, 2));
1193
+ } else {
1194
+ validateArchitecture();
1195
+ writeDiagram({
1196
+ outPath,
1197
+ template,
1198
+ diagramType: 'architecture',
1199
+ meta: arch.meta,
1200
+ svg: renderSvg(),
1201
+ cards: arch.cards,
1202
+ sourceEvidence,
1203
+ });
1069
1204
  }
1070
- writeDiagram({
1071
- outPath,
1072
- template,
1073
- diagramType: 'architecture',
1074
- meta: arch.meta,
1075
- svg: renderSvg(),
1076
- cards: arch.cards,
1077
- sourceEvidence,
1078
- });