@stacksjs/defaults 0.74.40 → 0.74.42

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 (78) hide show
  1. package/ai/skills/stacks-cms/SKILL.md +4 -4
  2. package/ai/skills/stacks-composables/SKILL.md +1 -1
  3. package/ai/skills/stacks-config/SKILL.md +1 -1
  4. package/ai/skills/stacks-dashboard/SKILL.md +1 -1
  5. package/ai/skills/stacks-technical-diagrams/LICENSE +1 -1
  6. package/ai/skills/stacks-technical-diagrams/SKILL.md +257 -229
  7. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +69 -0
  8. package/ai/skills/stacks-technical-diagrams/assets/JetBrainsMono-OFL.txt +93 -0
  9. package/ai/skills/stacks-technical-diagrams/assets/template.html +14588 -929
  10. package/ai/skills/stacks-technical-diagrams/bin/diagrams +10 -0
  11. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +86 -0
  12. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +654 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +1898 -89
  14. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +829 -0
  15. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +31 -0
  16. package/ai/skills/stacks-technical-diagrams/brand-marks/catalog.json +131 -0
  17. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +1221 -0
  18. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +18 -22
  19. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +58 -52
  20. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +61 -0
  21. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +47 -0
  22. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +30 -23
  23. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +31 -0
  24. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +31 -0
  25. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +49 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +57 -0
  27. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +64 -0
  28. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +22 -16
  29. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +71 -0
  30. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +62 -0
  31. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +16 -11
  32. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +279 -0
  33. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +391 -0
  34. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +243 -0
  35. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +65 -0
  36. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +120 -0
  37. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +45 -0
  38. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +780 -73
  39. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +25 -3
  40. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +240 -52
  41. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +31 -7
  42. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +227 -50
  43. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +36 -6
  44. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +270 -63
  45. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +563 -0
  46. package/ai/skills/stacks-technical-diagrams/renderers/shared/bun-runtime.mjs +20 -0
  47. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +186 -10
  48. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +26 -0
  49. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +127 -0
  50. package/ai/skills/stacks-technical-diagrams/renderers/shared/engineering-profiles.mjs +157 -0
  51. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-brand-marks.mjs +2003 -0
  52. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +3 -3
  53. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +1195 -2
  54. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +595 -0
  55. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +217 -0
  56. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +340 -0
  57. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +238 -0
  58. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +58 -0
  59. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +49 -0
  60. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +163 -19
  61. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +51 -5
  62. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -17
  63. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +24 -470
  64. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +4400 -0
  65. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  66. package/ai/skills/stacks-technical-diagrams/schemas/README.md +154 -11
  67. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +61 -4
  68. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +72 -0
  69. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +40 -18
  70. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +43 -18
  71. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +41 -4
  72. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +97 -1
  73. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +551 -12
  74. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +3 -4
  75. package/ide/vscode/package.json +1 -1
  76. package/package.json +2 -2
  77. package/resources/components/CookieConsent.stx +146 -0
  78. package/resources/components/CookieConsent.test.ts +79 -0
@@ -1,11 +1,21 @@
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';
5
- import { componentFill, arrowClassMap, rectsOverlap, asArray, isFinitePoint } from '../shared/geometry.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
+ import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
6
+ import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
7
+ import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, routePointsValue, asArray, isFinitePoint } from '../shared/geometry.mjs';
8
+ import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
9
+ import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
10
+ import { translateMessage as i18nText } from '../shared/i18n.mjs';
11
+
12
+ const participantTextFit = {
13
+ sublabelPreferred: 7,
14
+ sublabelMinimum: 6,
15
+ };
6
16
 
7
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
- const { diagram: sequence, template, outPath } = loadDiagram({
18
+ const { diagram: sequence, template, outPath } = await loadDiagramWithBrandMarks({
9
19
  rendererDir: __dirname,
10
20
  diagramType: 'sequence',
11
21
  defaultExample: 'cache-miss-request.sequence.json'
@@ -14,18 +24,36 @@ const { diagram: sequence, template, outPath } = loadDiagram({
14
24
  const viewBox = sequence.meta?.viewBox || [920, 760];
15
25
  // The timeline scales with viewBox height: a taller viewBox gains message room,
16
26
  // a shorter one shrinks the readable band (validated below) instead of clipping.
27
+ // `column_fit: "spread"` widens the lanes with the viewBox instead of keeping
28
+ // the fixed 108px gap, so a wide canvas gains column distance and label room
29
+ // rather than dead space on the right. The default stays "fixed" so existing
30
+ // diagrams keep their coordinates.
31
+ const columnFit = sequence.meta?.column_fit === 'spread' ? 'spread' : 'fixed';
32
+ const participantCount = Math.max(1, asArray(sequence.participants).length);
33
+ const sideMargin = 62;
34
+ const participantW = columnFit === 'spread'
35
+ ? Math.max(86, Math.min(190, Math.round((viewBox[0] - sideMargin * 2) / participantCount) - 24))
36
+ : 86;
37
+ const colGap = columnFit === 'spread' && participantCount > 1
38
+ ? Math.max(108, (viewBox[0] - 40 - sideMargin - participantW) / (participantCount - 1))
39
+ : 108;
40
+
17
41
  const layout = {
18
42
  topY: 72,
19
- participantW: 86,
43
+ participantW,
20
44
  participantH: 54,
21
45
  lifelineTop: 142,
22
46
  lifelineBottom: viewBox[1] - 65,
23
47
  legendY: viewBox[1] - 54,
24
- leftX: 62,
25
- colGap: 108,
48
+ leftX: columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin,
49
+ colGap,
26
50
  labelH: 16
27
51
  };
28
52
 
53
+ const participantBoxWidthNote = columnFit === 'spread'
54
+ ? `participant boxes are ${participantW}px for this viewBox width and ${participantCount} participants`
55
+ : `participant boxes are a fixed ${participantW}px unless meta.column_fit is "spread"`;
56
+
29
57
  const arrowClass = {
30
58
  ...arrowClassMap,
31
59
  return: ['a-default', 'arrowhead']
@@ -41,26 +69,85 @@ const participants = new Map(asArray(sequence.participants).map((participant, in
41
69
  ...participant,
42
70
  index,
43
71
  cx: participantX(index),
44
- x: participantX(index) - layout.participantW / 2
72
+ x: participantX(index) - layout.participantW / 2,
73
+ y: layout.topY,
74
+ width: layout.participantW,
75
+ height: layout.participantH,
76
+ cy: layout.topY + layout.participantH / 2
45
77
  }
46
78
  ]));
47
79
 
80
+ function messageGeometry(message) {
81
+ const from = participants.get(message.from);
82
+ const to = participants.get(message.to);
83
+ if (!from || !to || typeof message.y !== 'number') return null;
84
+ const direction = to.cx > from.cx ? 1 : -1;
85
+ const start = from.cx + direction * 7;
86
+ const end = to.cx - direction * 7;
87
+ return { start, end, center: (start + end) / 2 };
88
+ }
89
+
90
+ function messageLabelBox(message, relationIndex = null) {
91
+ const geometry = messageGeometry(message);
92
+ if (!geometry) return null;
93
+ const width = Math.max(34, textUnits(message.label) * 5.2 + 12);
94
+ return {
95
+ relation: message,
96
+ relationIndex,
97
+ label: message.label,
98
+ x: geometry.center - width / 2,
99
+ y: message.y - 20,
100
+ width,
101
+ height: layout.labelH,
102
+ };
103
+ }
104
+
105
+ function messageRouteBox(message) {
106
+ const geometry = messageGeometry(message);
107
+ if (!geometry) return null;
108
+ return {
109
+ x: Math.min(geometry.start, geometry.end),
110
+ y: message.y - 2,
111
+ width: Math.abs(geometry.end - geometry.start),
112
+ height: 4,
113
+ };
114
+ }
115
+
116
+ function segmentLabelBox(segment) {
117
+ const labelW = Math.max(42, textUnits(segment.label) * 5.2 + 14);
118
+ const occupied = asArray(sequence.messages)
119
+ .flatMap((message) => [messageLabelBox(message), messageRouteBox(message)])
120
+ .filter(Boolean);
121
+ const label = { x: 56, y: segment.from - 22, width: labelW, height: 18 };
122
+ for (let attempt = 0; attempt < 4; attempt += 1) {
123
+ if (!occupied.some((rect) => rectsOverlap(label, rect, 2))) break;
124
+ label.y -= 22;
125
+ }
126
+ return label;
127
+ }
128
+
129
+ const compositionFrames = asArray(sequence.segments).map((segment, index) => ({
130
+ id: index,
131
+ label: segment.label,
132
+ kind: 'segment',
133
+ x: 48,
134
+ y: segment.from,
135
+ width: viewBox[0] - 96,
136
+ height: segment.to - segment.from,
137
+ radius: 10,
138
+ }));
139
+
140
+ function messagePath(message) {
141
+ return {
142
+ points: participants.has(message.from) && participants.has(message.to)
143
+ ? [[participants.get(message.from).cx, message.y], [participants.get(message.to).cx, message.y]]
144
+ : []
145
+ };
146
+ }
147
+
48
148
  function validateSequence() {
49
149
  const problems = [];
50
- if (sequence.schema_version !== 1) problems.push('Sequence files must set "schema_version": 1.');
51
- if (sequence.diagram_type !== 'sequence') problems.push('Sequence files must set "diagram_type": "sequence".');
52
- if (!sequence.meta?.title) problems.push('Sequence files must include meta.title.');
53
- if (!Array.isArray(sequence.participants) || sequence.participants.length < 2) {
54
- problems.push('Sequence diagrams need at least two participants.');
55
- }
56
150
  if (participants.size !== asArray(sequence.participants).length) problems.push('Participant ids must be unique.');
57
- if (!Array.isArray(sequence.messages) || sequence.messages.length < 1) {
58
- problems.push('Sequence diagrams need at least one message.');
59
- }
60
- if (sequence.cards !== undefined && !Array.isArray(sequence.cards)) problems.push('Sequence "cards" must be an array.');
61
- for (const arr of ['segments', 'activations']) {
62
- if (sequence[arr] !== undefined && !Array.isArray(sequence[arr])) problems.push(`Sequence "${arr}" must be an array.`);
63
- }
64
151
 
65
152
  if (layout.lifelineBottom - layout.lifelineTop < 120) {
66
153
  problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline - set meta.viewBox[1] to at least ${layout.lifelineTop + 120 + 65}.`);
@@ -69,7 +156,18 @@ function validateSequence() {
69
156
  for (const participant of participants.values()) {
70
157
  const estLabelW = textUnits(participant.label) * 6.8;
71
158
  if (estLabelW > layout.participantW + 6) {
72
- problems.push(`Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than the ${layout.participantW}px participant box - shorten it or move detail to sublabel.`);
159
+ problems.push(`Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than the ${layout.participantW}px participant box - shorten it.`);
160
+ }
161
+ const brandRailProblem = brandTopRailProblem(participant, layout.participantW, 8, 'Participant');
162
+ if (brandRailProblem) problems.push(brandRailProblem);
163
+ // sublabel renders as a single unwrapped <text>; shrink-to-fit handles the
164
+ // ordinary case, this rejects what it cannot rescue.
165
+ if (participant.sublabel) {
166
+ const availableTextW = availableNodeTextWidth(layout.participantW);
167
+ const minimumW = minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelMinimum);
168
+ if (minimumW > availableTextW) {
169
+ problems.push(`Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${participantTextFit.sublabelMinimum}px legible minimum, but participant "${participant.id}" provides ${availableTextW}px - shorten the sublabel (${participantBoxWidthNote}).`);
170
+ }
73
171
  }
74
172
  }
75
173
 
@@ -86,6 +184,56 @@ function validateSequence() {
86
184
  }
87
185
  }
88
186
 
187
+ // Participant headers are opaque nodes. Lifelines, activation bars, and
188
+ // segment bands remain intentional pass-through geometry and are excluded.
189
+ problems.push(...cleanFlowProblems({
190
+ relations: sequence.messages,
191
+ obstacles: participants.values(),
192
+ pathFor: messagePath,
193
+ diagramType: 'sequence',
194
+ relationCollection: 'messages',
195
+ obstacleKind: 'participant header',
196
+ clearance: 0,
197
+ routeHint: 'move the message y below the participant headers or reorder participants'
198
+ }));
199
+ problems.push(...cleanCrossingProblems({
200
+ relations: sequence.messages,
201
+ endpointIds: new Set(participants.keys()),
202
+ pathFor: messagePath,
203
+ diagramType: 'sequence',
204
+ relationCollection: 'messages',
205
+ profile: sequence.meta?.quality_profile,
206
+ routeHint: 'separate the message y values; lifeline crossings remain allowed'
207
+ }));
208
+ problems.push(...cleanAmbiguousCorridorProblems({
209
+ relations: sequence.messages,
210
+ endpointIds: new Set(participants.keys()),
211
+ pathFor: messagePath,
212
+ diagramType: 'sequence',
213
+ relationCollection: 'messages',
214
+ profile: sequence.meta?.quality_profile,
215
+ routeHint: 'separate the message y values so unrelated messages do not visually merge'
216
+ }));
217
+ problems.push(...cleanBorderRunProblems({
218
+ relations: sequence.messages,
219
+ endpointIds: new Set(participants.keys()),
220
+ frames: compositionFrames,
221
+ pathFor: messagePath,
222
+ diagramType: 'sequence',
223
+ relationCollection: 'messages',
224
+ profile: sequence.meta?.quality_profile,
225
+ routeHint: 'move the message y so it crosses a segment boundary perpendicularly or stays clearly inside the segment'
226
+ }));
227
+ problems.push(...cleanRouteRhythmProblems({
228
+ relations: sequence.messages,
229
+ endpointIds: new Set(participants.keys()),
230
+ pathFor: messagePath,
231
+ diagramType: 'sequence',
232
+ relationCollection: 'messages',
233
+ profile: sequence.meta?.quality_profile,
234
+ routeHint: 'increase participant spacing or simplify message routing so every turn has room to read'
235
+ }));
236
+
89
237
  // Vertical crowding only matters when the arrows share horizontal space;
90
238
  // disjoint arrows may legitimately run in parallel rows.
91
239
  const placed = asArray(sequence.messages)
@@ -108,13 +256,8 @@ function validateSequence() {
108
256
  // Label masks can extend well past the arrow span, so check the actual
109
257
  // label rectangles too - tangent arrows with long labels still collide.
110
258
  const labelRects = asArray(sequence.messages)
111
- .filter((m) => participants.has(m.from) && participants.has(m.to) && typeof m.y === 'number')
112
- .map((m) => {
113
- const x1 = participants.get(m.from).cx;
114
- const x2 = participants.get(m.to).cx;
115
- const width = Math.max(34, textUnits(m.label) * 5.2 + 12);
116
- return { label: m.label, x: (x1 + x2) / 2 - width / 2, y: m.y - 20, width, height: layout.labelH };
117
- });
259
+ .map((m, messageIndex) => messageLabelBox(m, messageIndex))
260
+ .filter(Boolean);
118
261
  for (let i = 0; i < labelRects.length; i += 1) {
119
262
  for (let j = i + 1; j < labelRects.length; j += 1) {
120
263
  if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
@@ -122,6 +265,16 @@ function validateSequence() {
122
265
  }
123
266
  }
124
267
  }
268
+ problems.push(...cleanLabelRouteClearanceProblems({
269
+ relations: sequence.messages,
270
+ labels: labelRects,
271
+ endpointIds: new Set(participants.keys()),
272
+ pathFor: messagePath,
273
+ diagramType: 'sequence',
274
+ relationCollection: 'messages',
275
+ profile: sequence.meta?.quality_profile,
276
+ routeHint: 'spread the message y values, shorten the label, or reorder participants so the adjacent route stays visible'
277
+ }));
125
278
 
126
279
  for (const segment of asArray(sequence.segments)) {
127
280
  if (segment.to <= segment.from) {
@@ -130,6 +283,12 @@ function validateSequence() {
130
283
  if (segment.from < layout.topY || segment.to > layout.lifelineBottom + 20) {
131
284
  problems.push(`Segment "${segment.label}" extends outside the canvas - keep its y range between ${layout.topY} and ${layout.lifelineBottom + 20}.`);
132
285
  }
286
+ const labelBox = segmentLabelBox(segment);
287
+ const availableWidth = Math.max(0, viewBox[0] - 48 - labelBox.x);
288
+ if (labelBox.x + labelBox.width > viewBox[0] - 48) {
289
+ const requiredWidth = Math.ceil(labelBox.x + labelBox.width + 48);
290
+ problems.push(`Segment "${segment.label}" label (~${Math.round(labelBox.width)}px) exceeds the segment frame's available width (${availableWidth}px) - shorten the label or increase meta.viewBox[0] to at least ${requiredWidth}.`);
291
+ }
133
292
  }
134
293
 
135
294
  for (const activation of asArray(sequence.activations)) {
@@ -144,25 +303,49 @@ function validateSequence() {
144
303
  }
145
304
 
146
305
  if (problems.length) {
147
- throw new Error(`Sequence layout validation failed:\n- ${problems.join('\n- ')}`);
306
+ throwDiagnosticProblems('Sequence layout validation failed', problems, {
307
+ subject: { diagramType: 'sequence' },
308
+ });
148
309
  }
149
310
  }
150
311
 
151
312
  function renderParticipant(participant) {
152
313
  const fill = componentFill[participant.type] || 'c-external';
153
- return ` <rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="c-mask"/>
154
- <rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="${fill}"${animateAttr(sequence.meta, 'node', participant.index)} stroke-width="1.5"/>
155
- <text x="${participant.cx}" y="${layout.topY + 22}" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>
156
- <text x="${participant.cx}" y="${layout.topY + 39}" class="t-muted" font-size="7" text-anchor="middle">${esc(participant.sublabel)}</text>`;
314
+ const hasSub = participant.sublabel != null && participant.sublabel !== '';
315
+ const sub = hasSub
316
+ ? `\n <text data-detail="context" x="${participant.cx}" y="${layout.topY + 39}" class="t-muted" font-size="${fittedNodeFontSize(participant.sublabel, layout.participantW, participantTextFit.sublabelPreferred, participantTextFit.sublabelMinimum)}" text-anchor="middle">${esc(participant.sublabel)}</text>`
317
+ : '';
318
+ const brand = renderBrandMark(participant, { x: participant.x + layout.participantW - 22, y: layout.topY + 6 });
319
+ const labelFontSize = fittedNodeFontSize(participant.label, brandLabelFitWidth(participant, layout.participantW), 11, 8);
320
+ const passport = {
321
+ kind: participant.type,
322
+ sublabel: participant.sublabel,
323
+ context: i18nText(sequence.meta.locale, 'node.context.sequence'),
324
+ ...brandMetadataFor(participant),
325
+ };
326
+ return ` <g ${focusNodeAttrs(participant.id, participant.label, passport, sequence.meta.locale)}>
327
+ ${focusNodeTitle(participant.label, passport)}
328
+ <rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="c-mask"/>
329
+ <rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="${fill}"${animateAttr(sequence.meta, 'node', participant.index)} stroke-width="1.5"/>
330
+ ${renderSemanticSigil(participant.type, { x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''}
331
+ <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${participant.cx}" y="${layout.topY + 22}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>${sub}
332
+ </g>`;
157
333
  }
158
334
 
159
335
  function renderLifeline(participant) {
160
336
  return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${layout.lifelineBottom}" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>`;
161
337
  }
162
338
 
163
- function renderSegment(segment) {
164
- return ` <rect x="48" y="${segment.from}" width="${viewBox[0] - 96}" height="${segment.to - segment.from}" rx="10" class="c-lane" stroke-width="1"/>
165
- <text x="62" y="${segment.from + 18}" class="t-dim" font-size="9" font-weight="600">${esc(segment.label)}</text>`;
339
+ function renderSegment(segment, index) {
340
+ return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="segment" data-composition-frame-id="${index}" x="48" y="${segment.from}" width="${viewBox[0] - 96}" height="${segment.to - segment.from}" rx="10" class="c-lane" stroke-width="1"/>`;
341
+ }
342
+
343
+ function renderSegmentLabel(segment, index) {
344
+ const label = segmentLabelBox(segment);
345
+ return ` <g data-graph-role="segment-label" data-segment-id="${index}">
346
+ <rect x="${label.x}" y="${label.y}" width="${label.width}" height="${label.height}" rx="3" class="c-mask"/>
347
+ <text x="${label.x + 6}" y="${label.y + 13}" class="t-dim" font-size="9" font-weight="600">${esc(segment.label)}</text>
348
+ </g>`;
166
349
  }
167
350
 
168
351
  function renderActivation(activation) {
@@ -175,9 +358,10 @@ function renderActivation(activation) {
175
358
  }
176
359
 
177
360
  function messageLabel(message, x1, x2) {
178
- const center = (x1 + x2) / 2;
361
+ const box = messageLabelBox(message);
362
+ const center = box ? box.x + box.width / 2 : (x1 + x2) / 2;
179
363
  const y = message.y - 10;
180
- const labelW = Math.max(34, textUnits(message.label) * 5.2 + 12);
364
+ const labelW = box?.width || Math.max(34, textUnits(message.label) * 5.2 + 12);
181
365
  const accent = message.variant === 'security'
182
366
  ? 't-security'
183
367
  : message.variant === 'dashed'
@@ -185,42 +369,62 @@ function messageLabel(message, x1, x2) {
185
369
  : message.variant === 'return'
186
370
  ? 't-muted'
187
371
  : 't-backend';
188
- return ` <rect x="${center - labelW / 2}" y="${y - 10}" width="${labelW}" height="${layout.labelH}" rx="3" class="c-mask"/>
189
- <text x="${center}" y="${y}" class="${accent}" font-size="9" text-anchor="middle">${esc(message.label)}</text>`;
372
+ return ` <g data-detail="context">
373
+ <rect x="${center - labelW / 2}" y="${y - 10}" width="${labelW}" height="${layout.labelH}" rx="3" class="c-mask"/>
374
+ <text x="${center}" y="${y}" class="${accent}" font-size="9" text-anchor="middle">${esc(message.label)}</text>
375
+ </g>`;
190
376
  }
191
377
 
192
378
  function renderMessage(message, index) {
193
- const from = participants.get(message.from);
194
- const to = participants.get(message.to);
195
- const direction = to.cx > from.cx ? 1 : -1;
196
- const start = from.cx + direction * 7;
197
- const end = to.cx - direction * 7;
379
+ const { start, end } = messageGeometry(message);
198
380
  const [cls, marker] = arrowClass[message.variant || 'default'] || arrowClass.default;
199
381
  const strokeWidth = message.variant === 'emphasis' ? 1.8 : 1.4;
200
382
  const dash = message.variant === 'return' ? ' stroke-dasharray="3,5"' : '';
201
383
  const note = message.note
202
- ? `\n <text x="${Math.min(start, end) + 12}" y="${message.y + 18}" class="t-dim" font-size="7">${esc(message.note)}</text>`
384
+ ? `\n <text data-detail="fine" x="${Math.min(start, end) + 12}" y="${message.y + 18}" class="t-dim" font-size="7">${esc(message.note)}</text>`
203
385
  : '';
204
- return ` <path d="M ${start} ${message.y} L ${end} ${message.y}" class="${cls}"${animateAttr(sequence.meta, 'edge', index)} stroke-width="${strokeWidth}"${dash} marker-end="url(#${marker})"/>
205
- ${messageLabel(message, start, end)}${note}`;
386
+ return ` <g ${focusEdgeAttrs(message.from, message.to, message.label, index, message.id)}>
387
+ <path data-composition-edge-from="${esc(message.from)}" data-composition-edge-to="${esc(message.to)}"${message.id ? ` data-composition-edge-id="${esc(message.id)}"` : ''} data-composition-points="${routePointsValue([[start, message.y], [end, message.y]])}" d="M ${start} ${message.y} L ${end} ${message.y}" class="${cls}"${animateAttr(sequence.meta, 'edge', index)} stroke-width="${strokeWidth}"${dash} marker-end="url(#${marker})"/>
388
+ ${messageLabel(message, start, end)}${note}
389
+ </g>`;
206
390
  }
207
391
 
392
+ const LEGEND_CATALOG = [
393
+ { kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8 },
394
+ { kind: 'return', className: 'a-default', marker: 'arrowhead', dash: '3,5' },
395
+ { kind: 'security', className: 'a-security', marker: 'arrowhead-security' },
396
+ { kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed' },
397
+ { kind: 'default', className: 'a-default', marker: 'arrowhead' },
398
+ ].map((entry) => ({
399
+ ...entry,
400
+ interactive: false,
401
+ swatchWidth: 34,
402
+ swatchGap: 9,
403
+ label: i18nText(sequence.meta.locale, `legend.sequence.${entry.kind}`),
404
+ }));
405
+
208
406
  function renderLegend() {
209
- const y = layout.legendY;
210
- return ` <text x="150" y="${y - 20}" class="t-primary" font-size="10" font-weight="600">Legend</text>
211
- <path d="M 150 ${y} L 184 ${y}" class="a-emphasis" stroke-width="1.8" marker-end="url(#arrowhead-emphasis)"/>
212
- <text x="193" y="${y + 3}" class="t-muted" font-size="8">request</text>
213
- <path d="M 270 ${y} L 304 ${y}" class="a-default" stroke-width="1.4" stroke-dasharray="3,5" marker-end="url(#arrowhead)"/>
214
- <text x="313" y="${y + 3}" class="t-muted" font-size="8">return</text>
215
- <path d="M 385 ${y} L 419 ${y}" class="a-security" stroke-width="1.4" marker-end="url(#arrowhead-security)"/>
216
- <text x="428" y="${y + 3}" class="t-muted" font-size="8">security</text>
217
- <path d="M 530 ${y} L 564 ${y}" class="a-dashed" stroke-width="1.4" marker-end="url(#arrowhead-dashed)"/>
218
- <text x="573" y="${y + 3}" class="t-muted" font-size="8">async trace</text>`;
407
+ const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
408
+ const entries = resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
409
+ return renderResolvedLegend({
410
+ entries,
411
+ locale: sequence.meta.locale,
412
+ layout: {
413
+ x: 40,
414
+ baselineY: layout.legendY,
415
+ width: viewBox[0] - 80,
416
+ minTitleY: layout.legendY - 30,
417
+ unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
418
+ diagramType: 'sequence',
419
+ },
420
+ renderSwatch: (entry) => `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}"${entry.dash ? ` stroke-dasharray="${entry.dash}"` : ''} marker-end="url(#${entry.marker})"/>`,
421
+ });
219
422
  }
220
423
 
221
424
  function renderSvg() {
222
425
  const participantList = [...participants.values()];
223
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(sequence.meta, 'sequence diagram')}>
426
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(sequence.meta)}>
427
+ ${svgAccessibleText(sequence.meta, 'sequence')}
224
428
  ${renderDefinitions()}
225
429
 
226
430
  <!-- Background Grid -->
@@ -232,11 +436,14 @@ ${asArray(sequence.segments).map(renderSegment).join('\n\n')}
232
436
  <!-- Lifelines -->
233
437
  ${participantList.map(renderLifeline).join('\n')}
234
438
 
439
+ <!-- Activations -->
440
+ ${asArray(sequence.activations).map(renderActivation).join('\n')}
441
+
235
442
  <!-- Messages -->
236
443
  ${asArray(sequence.messages).map(renderMessage).join('\n\n')}
237
444
 
238
- <!-- Activations -->
239
- ${asArray(sequence.activations).map(renderActivation).join('\n')}
445
+ <!-- Segment Labels -->
446
+ ${asArray(sequence.segments).map(renderSegmentLabel).join('\n')}
240
447
 
241
448
  <!-- Participants -->
242
449
  ${participantList.map(renderParticipant).join('\n\n')}
@@ -250,8 +457,8 @@ validateSequence();
250
457
  writeDiagram({
251
458
  outPath,
252
459
  template,
460
+ diagramType: 'sequence',
253
461
  meta: sequence.meta,
254
- footerLabel: 'Sequence diagram',
255
462
  svg: renderSvg(),
256
463
  cards: sequence.cards,
257
464
  });