@stacksjs/defaults 0.74.41 → 0.74.43

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 (81) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-cms/SKILL.md +4 -4
  3. package/ai/skills/stacks-composables/SKILL.md +51 -2
  4. package/ai/skills/stacks-config/SKILL.md +1 -1
  5. package/ai/skills/stacks-dashboard/SKILL.md +1 -1
  6. package/ai/skills/stacks-database/SKILL.md +1 -1
  7. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  8. package/ai/skills/stacks-models/SKILL.md +16 -2
  9. package/ai/skills/stacks-orm/SKILL.md +1 -1
  10. package/ai/skills/stacks-technical-diagrams/LICENSE +1 -1
  11. package/ai/skills/stacks-technical-diagrams/SKILL.md +257 -229
  12. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +69 -0
  13. package/ai/skills/stacks-technical-diagrams/assets/JetBrainsMono-OFL.txt +93 -0
  14. package/ai/skills/stacks-technical-diagrams/assets/template.html +14588 -929
  15. package/ai/skills/stacks-technical-diagrams/bin/diagrams +10 -0
  16. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +86 -0
  17. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +654 -0
  18. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +1898 -89
  19. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +829 -0
  20. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +31 -0
  21. package/ai/skills/stacks-technical-diagrams/brand-marks/catalog.json +131 -0
  22. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +1221 -0
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +18 -22
  24. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +58 -52
  25. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +61 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +47 -0
  27. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +30 -23
  28. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +31 -0
  29. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +31 -0
  30. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +49 -0
  31. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +57 -0
  32. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +64 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +22 -16
  34. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +71 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +62 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +16 -11
  37. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +279 -0
  38. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +391 -0
  39. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +243 -0
  40. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +65 -0
  41. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +120 -0
  42. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +45 -0
  43. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +780 -73
  44. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +25 -3
  45. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +240 -52
  46. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +31 -7
  47. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +227 -50
  48. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +36 -6
  49. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +270 -63
  50. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +563 -0
  51. package/ai/skills/stacks-technical-diagrams/renderers/shared/bun-runtime.mjs +20 -0
  52. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +186 -10
  53. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +26 -0
  54. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +127 -0
  55. package/ai/skills/stacks-technical-diagrams/renderers/shared/engineering-profiles.mjs +157 -0
  56. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-brand-marks.mjs +2003 -0
  57. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +3 -3
  58. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +1195 -2
  59. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +595 -0
  60. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +217 -0
  61. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +340 -0
  62. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +238 -0
  63. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +58 -0
  64. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +49 -0
  65. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +163 -19
  66. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +51 -5
  67. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -17
  68. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +24 -470
  69. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +4400 -0
  70. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  71. package/ai/skills/stacks-technical-diagrams/schemas/README.md +154 -11
  72. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +61 -4
  73. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +72 -0
  74. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +40 -18
  75. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +43 -18
  76. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +41 -4
  77. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +97 -1
  78. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +551 -12
  79. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +3 -4
  80. package/ide/vscode/package.json +1 -1
  81. package/package.json +2 -2
@@ -1,20 +1,39 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { esc, renderDefinitions, textUnits } from '../shared/utils.mjs';
4
- import { animateAttr, loadDiagram, writeDiagram, svgRootAttrs } from '../shared/cli.mjs';
3
+ import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
4
+ import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
5
5
  import { componentBox, boundaryBox, connectionPath } from '../shared/layout-report.mjs';
6
+ import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
7
+ import { legendFootprint, relationshipLegendObstacles, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
8
+ import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
9
+ import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
10
+ import { minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs';
11
+ import { translateMessage as i18nText } from '../shared/i18n.mjs';
6
12
  import { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs';
7
13
  import {
8
14
  asArray,
9
15
  isFinitePoint,
10
16
  rectsOverlap,
17
+ segmentIntersectsRect,
18
+ cleanEndpointSideProblems,
19
+ cleanFlowProblems,
20
+ cleanCrossingProblems,
21
+ cleanAmbiguousCorridorProblems,
22
+ cleanBorderRunProblems,
23
+ cleanRouteRhythmProblems,
24
+ cleanLabelRouteClearanceProblems,
11
25
  suggestLabelObstacleFix,
12
26
  suggestComponentSeparation,
13
27
  anchor,
28
+ automaticPortSpread,
29
+ automaticPortRhythmBridge,
14
30
  defaultFromSide,
15
31
  defaultToSide,
16
32
  chosenSide,
33
+ routeHonorsEndpointSides,
34
+ normalizeRoutePoints,
17
35
  polylinePath,
36
+ routePointsValue,
18
37
  roundedPath,
19
38
  labelPoint,
20
39
  componentFill,
@@ -23,10 +42,17 @@ import {
23
42
  variantAccent,
24
43
  } from '../shared/geometry.mjs';
25
44
 
45
+ const componentTextFit = {
46
+ sublabelPreferred: 9,
47
+ sublabelMinimum: 6,
48
+ tagPreferred: 7,
49
+ tagMinimum: 6,
50
+ };
51
+
26
52
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
53
  const layoutJsonMode = process.argv.includes('--layout-json');
28
54
  const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
29
- const { diagram: arch, template, outPath } = loadDiagram({
55
+ const { diagram: arch, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
30
56
  rendererDir: __dirname,
31
57
  diagramType: 'architecture',
32
58
  defaultExample: 'web-app.architecture.json',
@@ -43,9 +69,26 @@ const layout = {
43
69
  // (CHANGELOG v2.2.1): 30px on top/left/right, plus 20px extra at the bottom.
44
70
  boundaryPad: 30,
45
71
  boundaryExtraBottom: 20,
72
+ boundaryLabelBaseline: 18,
73
+ boundaryLabelClearance: 4,
74
+ boundaryLabelFontPreferred: 9,
75
+ boundaryLabelFontMinimum: 6,
76
+ boundaryLabelMaskHeight: 16,
77
+ boundaryLabelRailGap: 2,
78
+ boundaryLabelFrameInset: 4,
46
79
  legendH: 28,
47
80
  };
48
81
 
82
+ const LEGEND_CATALOG = [
83
+ 'frontend',
84
+ 'backend',
85
+ 'database',
86
+ 'cloud',
87
+ 'security',
88
+ 'messagebus',
89
+ 'external',
90
+ ].map((kind) => ({ kind, label: i18nText(arch.meta.locale, `legend.architecture.${kind}`) }));
91
+
49
92
  // ---- Measure components from free coordinates --------------------------------
50
93
  function measureComponent(c) {
51
94
  const [x, y] = resolveComponentPos(c, grid);
@@ -54,6 +97,7 @@ function measureComponent(c) {
54
97
  }
55
98
 
56
99
  const components = new Map(asArray(arch.components).map((c) => [c.id, measureComponent(c)]));
100
+ const enforcesBoundaryTitleComposition = Boolean(arch.meta?.quality_profile);
57
101
  const componentSteps = new Map();
58
102
  for (const [index, conn] of asArray(arch.connections).entries()) {
59
103
  if (!componentSteps.has(conn.from)) componentSteps.set(conn.from, index);
@@ -72,50 +116,230 @@ function boundaryRect(boundary) {
72
116
  const maxX = Math.max(...members.map((m) => m.x + m.width));
73
117
  const maxY = Math.max(...members.map((m) => m.y + m.height));
74
118
  const pad = boundary.pad ?? layout.boundaryPad;
119
+ const topPad = Math.max(
120
+ pad,
121
+ layout.boundaryLabelBaseline + layout.boundaryLabelClearance,
122
+ );
75
123
  return {
76
124
  ...boundary,
77
125
  x: minX - pad,
78
- y: minY - pad,
126
+ y: minY - topPad,
79
127
  width: maxX - minX + pad * 2,
80
- height: maxY - minY + pad + layout.boundaryExtraBottom,
128
+ height: maxY - minY + topPad + layout.boundaryExtraBottom,
129
+ memberTop: minY,
81
130
  };
82
131
  }
83
132
 
84
- const boundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean);
133
+ function rectContains(outer, inner) {
134
+ const epsilon = 1e-9;
135
+ return outer.x <= inner.x + epsilon
136
+ && outer.y <= inner.y + epsilon
137
+ && outer.x + outer.width + epsilon >= inner.x + inner.width
138
+ && outer.y + outer.height + epsilon >= inner.y + inner.height;
139
+ }
85
140
 
86
- // ---- Auto viewBox: fit all geometry + a legend row --------------------------
87
- function autoViewBox() {
88
- let maxX = 0;
89
- let maxY = 0;
90
- for (const c of components.values()) {
91
- maxX = Math.max(maxX, c.x + c.width);
92
- maxY = Math.max(maxY, c.y + c.height);
93
- }
94
- for (const b of boundaries) {
95
- maxX = Math.max(maxX, b.x + b.width);
96
- maxY = Math.max(maxY, b.y + b.height);
141
+ function boundaryLabelWidth(label, fontSize) {
142
+ return Math.max(30, textUnits(label) * fontSize * 0.6 + 10);
143
+ }
144
+
145
+ const architectureLegendEntries = resolveLegend(
146
+ arch.meta?.legend,
147
+ LEGEND_CATALOG,
148
+ new Set([...components.values()].map((component) => component.type)),
149
+ );
150
+
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
+ );
162
+ let width = Math.ceil(maxX + layout.margin);
163
+ let footprint = legendFootprint(architectureLegendEntries, {
164
+ width: Math.max(1, width - layout.margin * 2),
165
+ });
166
+ if (footprint.minWidth > width - layout.margin * 2) {
167
+ width = Math.ceil(footprint.minWidth + layout.margin * 2);
168
+ footprint = legendFootprint(architectureLegendEntries, {
169
+ width: width - layout.margin * 2,
170
+ });
97
171
  }
98
172
  return [
99
- Math.ceil(maxX + layout.margin),
100
- Math.ceil(maxY + layout.margin + layout.legendH),
173
+ width,
174
+ Math.ceil(maxY + layout.margin + layout.legendH + footprint.extraHeight),
101
175
  ];
102
176
  }
103
177
 
104
- const viewBox = arch.meta?.viewBox || autoViewBox();
178
+ function resolvedViewBoxWidth(candidateBoundaries) {
179
+ if (Array.isArray(arch.meta?.viewBox) && Number.isFinite(arch.meta.viewBox[0])) {
180
+ return arch.meta.viewBox[0];
181
+ }
182
+ return autoViewBoxFor(candidateBoundaries)[0];
183
+ }
184
+
185
+ function expandBoundaryForReadableTitle(boundary, minimumFontSize) {
186
+ if (!enforcesBoundaryTitleComposition) return boundary;
187
+ const requiredWidth = boundaryLabelWidth(boundary.label, minimumFontSize)
188
+ + layout.boundaryLabelFrameInset * 2;
189
+ const extra = Math.max(0, requiredWidth - boundary.width);
190
+ if (!extra) return boundary;
191
+ return {
192
+ ...boundary,
193
+ x: boundary.x - extra / 2,
194
+ width: boundary.width + extra,
195
+ };
196
+ }
197
+
198
+ function measureBoundaryTitle(boundary, minimumFontSize) {
199
+ const availableWidth = Math.max(0, boundary.width - layout.boundaryLabelFrameInset * 2);
200
+ const units = textUnits(boundary.label);
201
+ const fitted = units > 0
202
+ ? (availableWidth - 10) / (units * 0.6)
203
+ : layout.boundaryLabelFontPreferred;
204
+ const preferredFontSize = Math.max(layout.boundaryLabelFontPreferred, minimumFontSize);
205
+ const fontSize = Math.max(
206
+ minimumFontSize,
207
+ Math.min(preferredFontSize, fitted),
208
+ );
209
+ const desiredWidth = boundaryLabelWidth(boundary.label, fontSize);
210
+ const height = Math.max(layout.boundaryLabelMaskHeight, Math.ceil(fontSize + 7));
211
+ return {
212
+ x: boundary.x + layout.boundaryLabelFrameInset,
213
+ y: boundary.memberTop
214
+ - layout.boundaryLabelClearance
215
+ - height,
216
+ width: Math.min(availableWidth, desiredWidth),
217
+ height,
218
+ fontSize,
219
+ minimumFontSize,
220
+ baselineOffset: fontSize + 4,
221
+ availableWidth,
222
+ minimumWidth: boundaryLabelWidth(boundary.label, minimumFontSize),
223
+ };
224
+ }
225
+
226
+ function horizontalOverlap(left, right) {
227
+ return left.x < right.x + right.width && left.x + left.width > right.x;
228
+ }
229
+
230
+ function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
231
+ const placedTitles = [];
232
+ const measured = new Map();
233
+ const ordered = rawBoundaries
234
+ .map((boundary, index) => ({ boundary, index }))
235
+ .sort((left, right) => {
236
+ const areaDelta = left.boundary.width * left.boundary.height
237
+ - right.boundary.width * right.boundary.height;
238
+ return areaDelta || left.index - right.index;
239
+ });
240
+
241
+ for (const entry of ordered) {
242
+ const { index } = entry;
243
+ const boundary = expandBoundaryForReadableTitle(entry.boundary, minimumFontSize);
244
+ const title = measureBoundaryTitle(boundary, minimumFontSize);
245
+ let guard = 0;
246
+ while (guard < rawBoundaries.length + components.size + 1) {
247
+ guard += 1;
248
+ const blockers = [
249
+ ...placedTitles,
250
+ ...components.values(),
251
+ ].filter((candidate) => horizontalOverlap(title, candidate) && rectsOverlap(title, candidate));
252
+ if (!blockers.length) break;
253
+ title.y = Math.min(
254
+ ...blockers.map((blocker) => blocker.y - layout.boundaryLabelRailGap - title.height),
255
+ );
256
+ }
257
+ placedTitles.push(title);
258
+ measured.set(index, { boundary, title });
259
+ }
260
+
261
+ return rawBoundaries.map((_boundary, index) => {
262
+ const { boundary, title } = measured.get(index);
263
+ const bottom = boundary.y + boundary.height;
264
+ // Profile-less schema-v1 inputs keep their legacy boundary geometry. A
265
+ // quality profile opts into the stricter title-composition contract and
266
+ // may expand the frame to contain an adapted title rail.
267
+ const y = enforcesBoundaryTitleComposition
268
+ ? Math.min(boundary.y, title.y - layout.boundaryLabelFrameInset)
269
+ : boundary.y;
270
+ return {
271
+ ...boundary,
272
+ y,
273
+ height: bottom - y,
274
+ title,
275
+ };
276
+ });
277
+ }
278
+
279
+ const rawBoundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean);
280
+ function resolveBoundaryTitles() {
281
+ if (!enforcesBoundaryTitleComposition || rawBoundaries.length === 0) {
282
+ return {
283
+ boundaries: layoutBoundaryTitles(rawBoundaries, layout.boundaryLabelFontMinimum),
284
+ readabilityProblem: null,
285
+ };
286
+ }
287
+
288
+ const maximumIterations = 32;
289
+ let candidateBoundaries = rawBoundaries;
290
+ for (let iteration = 0; iteration < maximumIterations; iteration += 1) {
291
+ const budgetViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
292
+ const minimumFontSize = Math.max(
293
+ layout.boundaryLabelFontMinimum,
294
+ minimumReadableSourceTextPx(budgetViewBoxWidth) + 1e-6,
295
+ );
296
+ const nextBoundaries = layoutBoundaryTitles(rawBoundaries, minimumFontSize);
297
+ const finalViewBoxWidth = resolvedViewBoxWidth(nextBoundaries);
298
+ const finalMinimumFontSize = Math.max(
299
+ layout.boundaryLabelFontMinimum,
300
+ minimumReadableSourceTextPx(finalViewBoxWidth),
301
+ );
302
+ if (minimumFontSize >= finalMinimumFontSize) {
303
+ return { boundaries: nextBoundaries, readabilityProblem: null };
304
+ }
305
+ candidateBoundaries = nextBoundaries;
306
+ }
307
+
308
+ const finalViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
309
+ return {
310
+ 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.`,
312
+ };
313
+ }
314
+
315
+ const resolvedBoundaryTitles = resolveBoundaryTitles();
316
+ const boundaries = resolvedBoundaryTitles.boundaries;
317
+ const compositionFrames = boundaries.map((boundary, index) => ({
318
+ ...boundary,
319
+ id: boundary.id || index,
320
+ kind: boundary.kind || 'boundary',
321
+ radius: boundary.kind === 'security-group' ? 8 : 12,
322
+ }));
323
+
324
+ function componentContext(component) {
325
+ const scopes = boundaries
326
+ .filter((boundary) => asArray(boundary.wraps).includes(component.id))
327
+ .sort((a, b) => (b.width * b.height) - (a.width * a.height))
328
+ .map((boundary) => boundary.label);
329
+ return scopes.length ? scopes.join(' › ') : i18nText(arch.meta.locale, 'node.context.architecture');
330
+ }
331
+
332
+ // ---- Auto viewBox: fit all geometry + the measured resolved legend ----------
333
+ const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries);
105
334
  const legendY = () => viewBox[1] - 16;
106
335
 
107
336
  // ---- Validation: mechanical correctness, never layout taste -----------------
108
337
  function validateArchitecture() {
109
338
  const problems = [];
110
- if (arch.schema_version !== 1) problems.push('Architecture files must set "schema_version": 1.');
111
- if (arch.diagram_type !== 'architecture') problems.push('Architecture files must set "diagram_type": "architecture".');
112
- if (!arch.meta?.title) problems.push('Architecture files must include meta.title.');
113
- if (!Array.isArray(arch.components) || arch.components.length < 1) {
114
- problems.push('Architecture diagrams need at least one component.');
339
+ if (resolvedBoundaryTitles.readabilityProblem) {
340
+ problems.push(resolvedBoundaryTitles.readabilityProblem);
115
341
  }
116
- if (arch.connections !== undefined && !Array.isArray(arch.connections)) problems.push('Architecture "connections" must be an array.');
117
- if (arch.boundaries !== undefined && !Array.isArray(arch.boundaries)) problems.push('Architecture "boundaries" must be an array.');
118
- if (arch.cards !== undefined && !Array.isArray(arch.cards)) problems.push('Architecture "cards" must be an array.');
342
+ const requiresNestedBoundaryMembership = arch.meta?.engineering_profile === 'deployment-ownership';
119
343
  if (components.size !== asArray(arch.components).length) problems.push('Component ids must be unique.');
120
344
  if (grid) {
121
345
  validateGridPlacement(arch, grid, problems);
@@ -141,7 +365,22 @@ function validateArchitecture() {
141
365
  }
142
366
  const estLabelW = textUnits(c.label) * 6.6;
143
367
  if (estLabelW > c.width + 8) {
144
- problems.push(`Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) - shorten the label, move detail to sublabel, or widen size.`);
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.`);
369
+ }
370
+ const brandRailProblem = brandTopRailProblem(c, c.width, 8, 'Component');
371
+ if (brandRailProblem) problems.push(brandRailProblem);
372
+ // sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
373
+ // handles the ordinary case, this rejects what it cannot rescue.
374
+ const availableTextW = availableNodeTextWidth(c.width);
375
+ for (const [field, value, minimum] of [
376
+ ['Sublabel', c.sublabel, componentTextFit.sublabelMinimum],
377
+ ['Tag', c.tag, componentTextFit.tagMinimum],
378
+ ]) {
379
+ if (!value) continue;
380
+ const minimumW = minimumNodeTextWidth(value, minimum);
381
+ 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.`);
383
+ }
145
384
  }
146
385
  }
147
386
 
@@ -161,6 +400,95 @@ function validateArchitecture() {
161
400
  if (!components.has(id)) problems.push(`Boundary "${boundary.label}" wraps unknown component "${id}".`);
162
401
  }
163
402
  }
403
+ const viewBoxRect = { x: 0, y: 0, width: viewBox[0], height: viewBox[1] };
404
+ for (const boundary of boundaries) {
405
+ if (!enforcesBoundaryTitleComposition) continue;
406
+ if (boundary.title.minimumWidth > boundary.title.availableWidth) {
407
+ problems.push(
408
+ `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 - `
410
+ + 'shorten the boundary label, increase pad, or widen the wrapped component layout.',
411
+ );
412
+ }
413
+ if (!rectContains(boundary, boundary.title)) {
414
+ problems.push(
415
+ `Boundary label "${boundary.label}" extends outside its final frame - shorten the label or increase boundary pad.`,
416
+ );
417
+ }
418
+ if (!rectContains(viewBoxRect, boundary.title)) {
419
+ 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.`,
421
+ );
422
+ }
423
+ for (const component of components.values()) {
424
+ if (!rectsOverlap(boundary.title, component)) continue;
425
+ problems.push(
426
+ `Boundary label "${boundary.label}" overlaps component "${component.id}" - move the component, increase boundary title space, or shorten the label.`,
427
+ );
428
+ }
429
+ }
430
+ for (let leftIndex = 0; leftIndex < boundaries.length; leftIndex += 1) {
431
+ const left = boundaries[leftIndex];
432
+ const leftMembers = new Set(asArray(left.wraps));
433
+ for (let rightIndex = leftIndex + 1; rightIndex < boundaries.length; rightIndex += 1) {
434
+ const right = boundaries[rightIndex];
435
+ if (enforcesBoundaryTitleComposition && rectsOverlap(left.title, right.title)) {
436
+ problems.push(
437
+ `Boundary labels "${left.label}" and "${right.label}" overlap - shorten a label or increase boundary title space.`,
438
+ );
439
+ }
440
+ // Ordinary architecture boundaries are sets, not an implied ownership
441
+ // tree: orthogonal scopes such as runtime and compliance may share some
442
+ // components while each contains others. The opt-in deployment profile
443
+ // does promise hierarchical region/private-scope membership, so only it
444
+ // receives the stricter membership-to-frame containment contract.
445
+ if (!requiresNestedBoundaryMembership) continue;
446
+ const rightMembers = new Set(asArray(right.wraps));
447
+ const shared = [...leftMembers].filter((id) => rightMembers.has(id));
448
+ const leftNested = [...leftMembers].every((id) => rightMembers.has(id));
449
+ const rightNested = [...rightMembers].every((id) => leftMembers.has(id));
450
+ if (shared.length && !leftNested && !rightNested) {
451
+ const leftOnly = [...leftMembers].filter((id) => !rightMembers.has(id));
452
+ const rightOnly = [...rightMembers].filter((id) => !leftMembers.has(id));
453
+ problems.push(
454
+ `Boundary "${left.label}" crosses boundary "${right.label}" because their memberships partially overlap `
455
+ + `(shared: ${shared.map((id) => `"${id}"`).join(', ')}; `
456
+ + `only in "${left.label}": ${leftOnly.map((id) => `"${id}"`).join(', ')}; `
457
+ + `only in "${right.label}": ${rightOnly.map((id) => `"${id}"`).join(', ')}) - `
458
+ + 'keep one boundary fully nested by removing outside members, or split the boundary.',
459
+ );
460
+ continue;
461
+ }
462
+
463
+ if (!rectsOverlap(left, right)) continue;
464
+ const leftContainsRight = rectContains(left, right);
465
+ const rightContainsLeft = rectContains(right, left);
466
+ if (!leftContainsRight && !rightContainsLeft) {
467
+ problems.push(
468
+ `Boundary "${left.label}" and boundary "${right.label}" final frames partially overlap - `
469
+ + 'adjust wraps, pad, or component positions so the frames are disjoint or one fully contains the other.',
470
+ );
471
+ continue;
472
+ }
473
+
474
+ if (!shared.length) {
475
+ problems.push(
476
+ `Boundary "${left.label}" and boundary "${right.label}" final frames overlap even though their memberships are disjoint - `
477
+ + 'adjust pad or component positions so the frames are disjoint, or make wraps express the intended nesting.',
478
+ );
479
+ continue;
480
+ }
481
+
482
+ const containmentMatchesMembership = (leftNested && rightContainsLeft)
483
+ || (rightNested && leftContainsRight);
484
+ if (!containmentMatchesMembership) {
485
+ problems.push(
486
+ `Boundary "${left.label}" and boundary "${right.label}" final frame containment contradicts their wraps membership - `
487
+ + 'reduce the inner boundary pad, move its components, or correct wraps so geometry and nesting agree.',
488
+ );
489
+ }
490
+ }
491
+ }
164
492
  for (const b of boundaries) {
165
493
  if (b.x < 0 || b.y < 0 || b.x + b.width > viewBox[0] || b.y + b.height > viewBox[1]) {
166
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.`);
@@ -178,13 +506,70 @@ function validateArchitecture() {
178
506
  }
179
507
  }
180
508
 
509
+ problems.push(...cleanEndpointSideProblems({
510
+ relations: arch.connections,
511
+ endpointIds: new Set(components.keys()),
512
+ pathFor,
513
+ diagramType: 'architecture',
514
+ relationCollection: 'connections',
515
+ fromSideFor: (conn) => connectionEndpointSide(conn, 'source'),
516
+ toSideFor: (conn) => connectionEndpointSide(conn, 'target'),
517
+ routeHint: 'keep automatic routing so the renderer can use a side-aware bridge, or set truthful fromSide/toSide with perpendicular via segments',
518
+ }));
519
+ problems.push(...cleanFlowProblems({
520
+ relations: arch.connections,
521
+ obstacles: components.values(),
522
+ pathFor,
523
+ diagramType: 'architecture',
524
+ relationCollection: 'connections',
525
+ obstacleKind: 'component',
526
+ routeHint: 'adjust fromSide/toSide, set route/via, or move the component'
527
+ }));
528
+ problems.push(...cleanCrossingProblems({
529
+ relations: arch.connections,
530
+ endpointIds: new Set(components.keys()),
531
+ pathFor,
532
+ diagramType: 'architecture',
533
+ relationCollection: 'connections',
534
+ profile: arch.meta?.quality_profile,
535
+ routeHint: 'adjust route/via or fromSide/toSide so the connections use separate corridors'
536
+ }));
537
+ problems.push(...cleanAmbiguousCorridorProblems({
538
+ relations: arch.connections,
539
+ endpointIds: new Set(components.keys()),
540
+ pathFor,
541
+ diagramType: 'architecture',
542
+ relationCollection: 'connections',
543
+ profile: arch.meta?.quality_profile,
544
+ routeHint: 'adjust route/via or fromSide/toSide so unrelated connections do not visually merge'
545
+ }));
546
+ problems.push(...cleanBorderRunProblems({
547
+ relations: arch.connections,
548
+ endpointIds: new Set(components.keys()),
549
+ frames: compositionFrames,
550
+ pathFor,
551
+ diagramType: 'architecture',
552
+ relationCollection: 'connections',
553
+ profile: arch.meta?.quality_profile,
554
+ routeHint: 'adjust route/via or fromSide/toSide so the connection crosses the boundary perpendicularly instead of following its border'
555
+ }));
556
+ problems.push(...cleanRouteRhythmProblems({
557
+ relations: arch.connections,
558
+ endpointIds: new Set(components.keys()),
559
+ pathFor,
560
+ diagramType: 'architecture',
561
+ relationCollection: 'connections',
562
+ profile: arch.meta?.quality_profile,
563
+ routeHint: 'move route/via points into a wider corridor or move the component so every turn has room to read'
564
+ }));
565
+
181
566
  // Connection labels must not land on top of components.
182
567
  const labelRects = [];
183
- for (const conn of asArray(arch.connections)) {
568
+ for (const [connectionIndex, conn] of asArray(arch.connections).entries()) {
184
569
  if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
185
570
  const [lx, ly] = labelPoint(conn, pathFor(conn).points);
186
571
  const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
187
- labelRects.push({ label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly });
572
+ labelRects.push({ relation: conn, relationIndex: connectionIndex, label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly });
188
573
  }
189
574
  for (const rect of labelRects) {
190
575
  for (const c of components.values()) {
@@ -192,10 +577,29 @@ function validateArchitecture() {
192
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)}`);
193
578
  }
194
579
  }
580
+ if (enforcesBoundaryTitleComposition) {
581
+ for (const boundary of boundaries) {
582
+ if (!rectsOverlap(boundary.title, rect)) continue;
583
+ 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.`,
585
+ );
586
+ }
587
+ }
195
588
  }
589
+ problems.push(...cleanLabelRouteClearanceProblems({
590
+ relations: arch.connections,
591
+ labels: labelRects,
592
+ endpointIds: new Set(components.keys()),
593
+ pathFor,
594
+ diagramType: 'architecture',
595
+ relationCollection: 'connections',
596
+ profile: arch.meta?.quality_profile,
597
+ }));
196
598
 
197
599
  if (problems.length) {
198
- throw new Error(`Architecture layout validation failed:\n- ${problems.join('\n- ')}`);
600
+ throwDiagnosticProblems('Architecture layout validation failed', problems, {
601
+ subject: { diagramType: 'architecture' },
602
+ });
199
603
  }
200
604
  }
201
605
 
@@ -233,7 +637,178 @@ function buildLayoutReport() {
233
637
  }
234
638
 
235
639
  // ---- Connection routing ------------------------------------------------------
236
- function routeVia(conn, from, to, start, end) {
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) {
237
812
  if (conn.via) return conn.via;
238
813
  switch (conn.route || 'auto') {
239
814
  case 'straight':
@@ -249,48 +824,158 @@ function routeVia(conn, from, to, start, end) {
249
824
  case 'auto':
250
825
  default: {
251
826
  // Direct line unless the anchors are clearly orthogonal-friendly.
252
- if (Math.abs(start[0] - end[0]) < 4 || Math.abs(start[1] - end[1]) < 4) return [];
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
+
253
872
  const midX = (start[0] + end[0]) / 2;
254
- return [[midX, start[1]], [midX, end[1]]];
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;
255
904
  }
256
905
  }
257
906
  }
258
907
 
259
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
+
260
925
  function pathFor(conn) {
261
926
  if (pathCache.has(conn)) return pathCache.get(conn);
262
927
  const from = components.get(conn.from);
263
928
  const to = components.get(conn.to);
264
- const start = anchor(from, chosenSide(conn.fromSide, defaultFromSide(from, to)));
265
- const end = anchor(to, chosenSide(conn.toSide, defaultToSide(from, to)));
266
- const points = [start, ...routeVia(conn, from, to, start, end), end];
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];
267
944
  const routed = { d: roundedPath(points, 8), points };
268
945
  pathCache.set(conn, routed);
269
946
  return routed;
270
947
  }
271
948
 
272
949
  // ---- Rendering ---------------------------------------------------------------
273
- function renderBoundary(b) {
950
+ function renderBoundaryFrame(b, index) {
274
951
  const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region';
275
- const labelCls = b.kind === 'security-group' ? 't-security' : 't-cloud';
276
952
  const rx = b.kind === 'security-group' ? 8 : 12;
277
- return ` <rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="${rx}" class="${cls}" stroke-width="1"/>
278
- <text x="${b.x + 8}" y="${b.y + 18}" class="${labelCls}" font-size="9" font-weight="600">${esc(b.label)}</text>`;
953
+ return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="${esc(b.kind || 'boundary')}" data-composition-frame-id="${index}" data-composition-frame-label="${esc(b.label)}" x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="${rx}" class="${cls}" stroke-width="1"/>`;
954
+ }
955
+
956
+ function renderBoundaryLabel(b, index) {
957
+ const labelCls = b.kind === 'security-group' ? 't-security' : 't-cloud';
958
+ return ` <g data-graph-role="structural-frame-label" data-composition-frame-id="${index}" data-composition-frame-kind="${esc(b.kind || 'boundary')}" data-composition-frame-label="${esc(b.label)}">
959
+ <rect data-graph-role="structural-frame-label-mask" x="${b.title.x}" y="${b.title.y}" width="${b.title.width}" height="${b.title.height}" rx="3" class="c-mask"/>
960
+ <text data-boundary-label="" x="${b.title.x + 4}" y="${b.title.y + b.title.baselineOffset}" class="${labelCls}" font-size="${b.title.fontSize}" font-weight="600">${esc(b.label)}</text>
961
+ </g>`;
279
962
  }
280
963
 
281
964
  function renderConnectionPath(conn, index) {
282
965
  const [cls, marker] = arrowClassMap[conn.variant || 'default'] || arrowClassMap.default;
283
966
  const routed = pathFor(conn);
284
967
  const strokeWidth = conn.width || (conn.variant === 'emphasis' ? 1.8 : 1.5);
285
- return ` <path d="${routed.d}" class="${cls}"${animateAttr(arch.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
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})"/>`;
286
969
  }
287
970
 
288
- function renderConnectionLabel(conn) {
971
+ function renderConnectionLabel(conn, index) {
289
972
  if (!conn.label) return '';
290
973
  const [lx, ly] = labelPoint(conn, pathFor(conn).points);
291
974
  const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
292
- return ` <rect x="${lx - w / 2}" y="${ly - 10}" width="${w}" height="14" rx="3" class="c-mask"/>
293
- <text x="${lx}" y="${ly}" class="${variantAccent(conn.variant)}" font-size="8" text-anchor="middle">${esc(conn.label)}</text>`;
975
+ 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>
978
+ </g>`;
294
979
  }
295
980
 
296
981
  function renderComponent(c) {
@@ -300,47 +985,65 @@ function renderComponent(c) {
300
985
  const hasSub = c.sublabel != null && c.sublabel !== '';
301
986
  const labelY = hasSub ? c.y + c.height / 2 - 2 : c.y + c.height / 2 + 4;
302
987
  const sub = hasSub
303
- ? `\n <text x="${cx}" y="${c.y + c.height / 2 + 14}" class="t-muted" font-size="9" text-anchor="middle">${esc(c.sublabel)}</text>`
988
+ ? `\n <text data-detail="context" x="${cx}" y="${c.y + c.height / 2 + 14}" class="t-muted" font-size="${fittedNodeFontSize(c.sublabel, c.width, componentTextFit.sublabelPreferred, componentTextFit.sublabelMinimum)}" text-anchor="middle">${esc(c.sublabel)}</text>`
304
989
  : '';
305
990
  const tag = c.tag
306
- ? `\n <text x="${cx}" y="${c.y + c.height - 8}" class="${accent}" font-size="7" text-anchor="middle">${esc(c.tag)}</text>`
991
+ ? `\n <text data-detail="fine" x="${cx}" y="${c.y + c.height - 8}" class="${accent}" font-size="${fittedNodeFontSize(c.tag, c.width, componentTextFit.tagPreferred, componentTextFit.tagMinimum)}" text-anchor="middle">${esc(c.tag)}</text>`
307
992
  : '';
308
- return ` <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="6" class="c-mask"/>
309
- <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"/>
310
- <text x="${cx}" y="${labelY}" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">${esc(c.label)}</text>${sub}${tag}`;
993
+ const brand = renderBrandMark(c, { x: c.x + c.width - 22, y: c.y + 6 });
994
+ const labelFontSize = fittedNodeFontSize(c.label, brandLabelFitWidth(c, c.width), 11, 8);
995
+ const passport = { kind: c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c), ...brandMetadataFor(c) };
996
+ return ` <g ${focusNodeAttrs(c.id, c.label, passport, arch.meta.locale)}>
997
+ ${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}` : ''}
1001
+ <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
+ </g>`;
311
1003
  }
312
1004
 
313
- // Auto legend: one swatch per component type actually used, left to right.
314
- const TYPE_LABELS = {
315
- frontend: 'Frontend', backend: 'Backend', database: 'Database', cloud: 'Cloud',
316
- security: 'Security', messagebus: 'Message bus', external: 'External',
317
- };
318
1005
  function renderLegend() {
319
- const used = [];
320
- const seen = new Set();
321
- for (const c of components.values()) {
322
- if (!seen.has(c.type)) { seen.add(c.type); used.push(c.type); }
323
- }
324
- const y = legendY();
325
- let x = layout.margin;
326
- const parts = [` <text x="${x}" y="${y - 13}" class="t-primary" font-size="9" font-weight="600">Legend</text>`];
327
- for (const type of used) {
328
- parts.push(` <rect x="${x}" y="${y - 8}" width="14" height="9" rx="2" class="${componentFill[type] || 'c-external'}" stroke-width="1"/>`);
329
- parts.push(` <text x="${x + 20}" y="${y}" class="t-muted" font-size="8">${TYPE_LABELS[type] || type}</text>`);
330
- x += 30 + (textUnits(TYPE_LABELS[type] || type) * 5 + 28);
331
- }
332
- return parts.join('\n');
1006
+ const entries = architectureLegendEntries;
1007
+ const relationshipObstacles = relationshipLegendObstacles(arch.connections, {
1008
+ 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
+ },
1015
+ });
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
+ );
1021
+ return renderResolvedLegend({
1022
+ entries,
1023
+ locale: arch.meta.locale,
1024
+ layout: {
1025
+ x: layout.margin,
1026
+ baselineY: legendY(),
1027
+ width: viewBox[0] - layout.margin * 2,
1028
+ minTitleY: contentBottom + 8,
1029
+ obstacles: relationshipObstacles,
1030
+ unfit: arch.meta?.legend === undefined ? 'hide' : 'error',
1031
+ diagramType: 'architecture',
1032
+ },
1033
+ renderSwatch: (entry) => `<rect x="${entry.x}" y="${entry.baseline - 9}" width="16" height="10" rx="2.5" class="${componentFill[entry.kind] || 'c-external'}" stroke-width="1"/>`,
1034
+ });
333
1035
  }
334
1036
 
335
1037
  function renderSvg() {
336
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta, 'architecture diagram')}>
1038
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta)}>
1039
+ ${svgAccessibleText(arch.meta, 'architecture')}
337
1040
  ${renderDefinitions()}
338
1041
 
339
1042
  <!-- Background Grid -->
340
1043
  <rect width="100%" height="100%" fill="url(#grid)" />
341
1044
 
342
1045
  <!-- Boundaries (behind everything) -->
343
- ${boundaries.map(renderBoundary).join('\n\n')}
1046
+ ${boundaries.map(renderBoundaryFrame).join('\n\n')}
344
1047
 
345
1048
  <!-- Connection paths (before components for correct z-order) -->
346
1049
  ${asArray(arch.connections).map(renderConnectionPath).join('\n')}
@@ -351,6 +1054,9 @@ ${[...components.values()].map(renderComponent).join('\n\n')}
351
1054
  <!-- Connection labels -->
352
1055
  ${asArray(arch.connections).map(renderConnectionLabel).join('\n')}
353
1056
 
1057
+ <!-- Boundary labels (foreground masks keep routes out of titles) -->
1058
+ ${boundaries.map(renderBoundaryLabel).join('\n\n')}
1059
+
354
1060
  <!-- Legend -->
355
1061
  ${renderLegend()}
356
1062
  </svg>`;
@@ -364,8 +1070,9 @@ if (layoutJsonMode) {
364
1070
  writeDiagram({
365
1071
  outPath,
366
1072
  template,
1073
+ diagramType: 'architecture',
367
1074
  meta: arch.meta,
368
- footerLabel: 'Architecture diagram',
369
1075
  svg: renderSvg(),
370
1076
  cards: arch.cards,
1077
+ sourceEvidence,
371
1078
  });