@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
@@ -4,7 +4,7 @@ Render `diagram_type: "dataflow"` JSON files into the standalone technical diagr
4
4
  template.
5
5
 
6
6
  ```bash
7
- bun --config=.claude/skills/stacks-technical-diagrams/bunfig.toml --no-env-file .claude/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs input.dataflow.json output.html
7
+ node .claude/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs input.dataflow.json output.html
8
8
  ```
9
9
 
10
10
  The renderer validates input against `.claude/skills/stacks-technical-diagrams/schemas/dataflow.schema.json`
@@ -23,7 +23,6 @@ Data-flow JSON files must set:
23
23
  "diagram_type": "dataflow",
24
24
  "meta": {
25
25
  "title": "Product Analytics Data Flow",
26
- "subtitle": "Events, consent, PII isolation, warehouse sync, and consumers",
27
26
  "viewBox": [940, 720]
28
27
  },
29
28
  "stages": [],
@@ -42,6 +41,18 @@ The schema lives at:
42
41
  .claude/skills/stacks-technical-diagrams/schemas/dataflow.schema.json
43
42
  ```
44
43
 
44
+ ## Legend
45
+
46
+ The default visual legend derives kinds from `flows[].variant` (omitting
47
+ `variant` means `default`) and adds `database` only when a database node exists.
48
+ Supported `meta.legend.entries` keys, in stable order, are `emphasis`,
49
+ `security`, `dashed`, `database`, and `default`. Flow variants remain
50
+ visual-only because the renderer has no compiled edge-kind facts in this slice. A
51
+ present `database` entry is different: it comes from exact
52
+ `nodes[].type: "database"` facts, so it publishes the normal Semantic Legend
53
+ count, accessible name, and keyboard interaction. Forcing `database` visible
54
+ without a database node keeps it visual-only.
55
+
45
56
  ## Layout budget
46
57
 
47
58
  | Constant | Value |
@@ -78,5 +89,16 @@ element's id or label. The renderer additionally fails when it can detect
78
89
  layout problems, including missing stages, duplicate node IDs, nodes outside
79
90
  the readable diagram area, node overlap, labels colliding with nodes or other
80
91
  labels, labels wider than their node, unknown flow endpoints, missing flow
81
- labels, unreadably short flows, or stages that exceed the viewBox. Text width
92
+ labels, unreadably short flows, flows crossing unrelated nodes (2px Clean Flow
93
+ clearance), or stages that exceed the viewBox. Stage frames remain intentional
94
+ pass-through containers. Text width
82
95
  is estimated CJK-aware: fullwidth glyphs count as two units.
96
+
97
+ Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
98
+ X crossings then fail with `composition/proper-crossing`; default `standard`
99
+ keeps them as artifact-receipt warnings. Collinear stage corridors are outside
100
+ the proper-X rule, but a separate gate warns in `standard` and fails in
101
+ `showcase` when unrelated flows overlap for at least 8px. Shared semantic
102
+ endpoints, point touches, and shorter overlaps remain valid. Showcase also
103
+ rejects any route segment below 8px and any interior turn segment below 16px;
104
+ ordinary 8-15px endpoint stubs remain valid.
@@ -1,18 +1,32 @@
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
+ import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
6
+ import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
7
+ import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
8
+ import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
9
+ import { translateMessage as i18nText } from '../shared/i18n.mjs';
5
10
  import {
6
11
  asArray,
7
12
  isFinitePoint,
8
13
  rectsOverlap,
14
+ cleanEndpointSideProblems,
15
+ cleanFlowProblems,
16
+ cleanCrossingProblems,
17
+ cleanAmbiguousCorridorProblems,
18
+ cleanBorderRunProblems,
19
+ cleanRouteRhythmProblems,
20
+ cleanLabelRouteClearanceProblems,
9
21
  suggestLabelObstacleFix,
10
22
  suggestLabelPairFix,
11
23
  anchor,
24
+ automaticPortSpread,
12
25
  defaultFromSide,
13
26
  defaultToSide,
14
27
  chosenSide,
15
28
  polylinePath,
29
+ routePointsValue,
16
30
  labelPoint,
17
31
  componentFill,
18
32
  componentText,
@@ -20,8 +34,15 @@ import {
20
34
  variantAccent
21
35
  } from '../shared/geometry.mjs';
22
36
 
37
+ const nodeTextFit = {
38
+ sublabelPreferred: 7,
39
+ sublabelMinimum: 6,
40
+ tagPreferred: 7,
41
+ tagMinimum: 6,
42
+ };
43
+
23
44
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
- const { diagram: dataflow, template, outPath } = loadDiagram({
45
+ const { diagram: dataflow, template, outPath } = await loadDiagramWithBrandMarks({
25
46
  rendererDir: __dirname,
26
47
  diagramType: 'dataflow',
27
48
  defaultExample: 'product-analytics.dataflow.json'
@@ -41,10 +62,33 @@ const layout = {
41
62
  labelH: 16
42
63
  };
43
64
 
65
+ function flowLabelSize(flow) {
66
+ const longestLine = Math.max(textUnits(flow.label), textUnits(flow.classification || ''));
67
+ return {
68
+ width: Math.round(Math.max(34, longestLine * 4.9 + 12) * 10) / 10,
69
+ height: flow.classification ? 27 : layout.labelH,
70
+ };
71
+ }
72
+
44
73
  function stageX(index) {
45
74
  return layout.leftX + index * layout.colGap;
46
75
  }
47
76
 
77
+ function stageFrame(stage, index) {
78
+ return {
79
+ id: index,
80
+ label: stage.label,
81
+ kind: 'stage',
82
+ x: stageX(index) - layout.stageW / 2,
83
+ y: layout.stageY,
84
+ width: layout.stageW,
85
+ height: viewBox[1] - layout.stageY - layout.stageBottomPad,
86
+ radius: 10,
87
+ };
88
+ }
89
+
90
+ const compositionFrames = asArray(dataflow.stages).map(stageFrame);
91
+
48
92
  function measureNode(node) {
49
93
  const width = node.width || layout.nodeW;
50
94
  const height = node.height || layout.nodeH;
@@ -73,17 +117,6 @@ for (const [index, node] of asArray(dataflow.nodes).entries()) {
73
117
 
74
118
  function validateDataflow() {
75
119
  const problems = [];
76
- if (dataflow.schema_version !== 1) problems.push('Data-flow files must set "schema_version": 1.');
77
- if (dataflow.diagram_type !== 'dataflow') problems.push('Data-flow files must set "diagram_type": "dataflow".');
78
- if (!dataflow.meta?.title) problems.push('Data-flow files must include meta.title.');
79
- if (!Array.isArray(dataflow.stages) || dataflow.stages.length < 2) {
80
- problems.push('Data-flow diagrams need at least two stages.');
81
- }
82
- if (!Array.isArray(dataflow.nodes) || dataflow.nodes.length < 2) {
83
- problems.push('Data-flow diagrams need at least two nodes.');
84
- }
85
- if (!Array.isArray(dataflow.flows)) problems.push('Data-flow diagrams must include a flows array.');
86
- if (dataflow.cards !== undefined && !Array.isArray(dataflow.cards)) problems.push('Data-flow "cards" must be an array.');
87
120
  if (nodes.size !== asArray(dataflow.nodes).length) problems.push('Node ids must be unique.');
88
121
 
89
122
  const stageCount = asArray(dataflow.stages).length;
@@ -106,7 +139,22 @@ function validateDataflow() {
106
139
  }
107
140
  const estLabelW = textUnits(node.label) * 6.2;
108
141
  if (estLabelW > node.width + 6) {
109
- problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than node "${node.id}" (${node.width}px) - shorten the label, move detail to sublabel, or increase node.width.`);
142
+ problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than node "${node.id}" (${node.width}px) - shorten the label or increase node.width.`);
143
+ }
144
+ const brandRailProblem = brandTopRailProblem(node, node.width, 8);
145
+ if (brandRailProblem) problems.push(brandRailProblem);
146
+ // sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
147
+ // handles the ordinary case, this rejects what it cannot rescue.
148
+ const availableTextW = availableNodeTextWidth(node.width);
149
+ for (const [field, value, minimum] of [
150
+ ['Sublabel', node.sublabel, nodeTextFit.sublabelMinimum],
151
+ ['Tag', node.tag, nodeTextFit.tagMinimum],
152
+ ]) {
153
+ if (!value) continue;
154
+ const minimumW = minimumNodeTextWidth(value, minimum);
155
+ if (minimumW > availableTextW) {
156
+ problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but node "${node.id}" provides ${availableTextW}px - shorten the ${field.toLowerCase()} or increase node.width.`);
157
+ }
110
158
  }
111
159
  }
112
160
 
@@ -130,17 +178,83 @@ function validateDataflow() {
130
178
  const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
131
179
  const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
132
180
  if (distance < 34) problems.push(`Flow "${flow.label}" is too short (${Math.round(distance)}px; minimum 34px) - route it through a channel or spread its nodes.`);
181
+ if (Array.isArray(flow.via)) {
182
+ for (let segmentIndex = 0; segmentIndex < routed.points.length - 1; segmentIndex += 1) {
183
+ const segmentStart = routed.points[segmentIndex];
184
+ const segmentEnd = routed.points[segmentIndex + 1];
185
+ const isDiagonal = Math.abs(segmentStart[0] - segmentEnd[0]) > 0.01
186
+ && Math.abs(segmentStart[1] - segmentEnd[1]) > 0.01;
187
+ if (!isDiagonal) continue;
188
+ const viaIndex = Math.min(segmentIndex, flow.via.length - 1);
189
+ problems.push(`Flow "${flow.label}" has a diagonal segment from (${segmentStart.join(', ')}) to (${segmentEnd.join(', ')}) - align via[${viaIndex}] with its adjacent point by sharing the same x or y coordinate.`);
190
+ }
191
+ }
133
192
  }
134
193
  }
135
194
 
195
+ problems.push(...cleanEndpointSideProblems({
196
+ relations: dataflow.flows,
197
+ endpointIds: new Set(nodes.keys()),
198
+ pathFor,
199
+ diagramType: 'dataflow',
200
+ relationCollection: 'flows',
201
+ fromSideFor: (flow) => flowSides(flow).fromSide,
202
+ toSideFor: (flow) => flowSides(flow).toSide,
203
+ routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross node borders perpendicularly',
204
+ }));
205
+ problems.push(...cleanFlowProblems({
206
+ relations: dataflow.flows,
207
+ obstacles: nodes.values(),
208
+ pathFor,
209
+ diagramType: 'dataflow',
210
+ relationCollection: 'flows',
211
+ obstacleKind: 'node',
212
+ routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the node to another stage/row'
213
+ }));
214
+ problems.push(...cleanCrossingProblems({
215
+ relations: dataflow.flows,
216
+ endpointIds: new Set(nodes.keys()),
217
+ pathFor,
218
+ diagramType: 'dataflow',
219
+ relationCollection: 'flows',
220
+ profile: dataflow.meta?.quality_profile,
221
+ routeHint: 'adjust route/via or channelX/channelY so the flows use separate stage corridors'
222
+ }));
223
+ problems.push(...cleanAmbiguousCorridorProblems({
224
+ relations: dataflow.flows,
225
+ endpointIds: new Set(nodes.keys()),
226
+ pathFor,
227
+ diagramType: 'dataflow',
228
+ relationCollection: 'flows',
229
+ profile: dataflow.meta?.quality_profile,
230
+ routeHint: 'adjust route/via or channelX/channelY so unrelated flows do not visually merge'
231
+ }));
232
+ problems.push(...cleanBorderRunProblems({
233
+ relations: dataflow.flows,
234
+ endpointIds: new Set(nodes.keys()),
235
+ frames: compositionFrames,
236
+ pathFor,
237
+ diagramType: 'dataflow',
238
+ relationCollection: 'flows',
239
+ profile: dataflow.meta?.quality_profile,
240
+ routeHint: 'adjust route/via or channelX/channelY so the flow crosses the stage perpendicularly instead of following its border'
241
+ }));
242
+ problems.push(...cleanRouteRhythmProblems({
243
+ relations: dataflow.flows,
244
+ endpointIds: new Set(nodes.keys()),
245
+ pathFor,
246
+ diagramType: 'dataflow',
247
+ relationCollection: 'flows',
248
+ profile: dataflow.meta?.quality_profile,
249
+ routeHint: 'adjust route/via or channelX/channelY so each turn uses a clear inter-stage corridor'
250
+ }));
251
+
136
252
  const labelRects = [];
137
- for (const flow of asArray(dataflow.flows)) {
253
+ for (const [flowIndex, flow] of asArray(dataflow.flows).entries()) {
138
254
  if (!flow.label || !nodes.has(flow.from) || !nodes.has(flow.to)) continue;
139
255
  const [lx, ly] = labelPoint(flow, pathFor(flow).points);
140
- const longestLine = Math.max(textUnits(flow.label), textUnits(flow.classification || ''));
141
- const width = Math.max(34, longestLine * 4.9 + 12);
142
- const height = flow.classification ? 27 : layout.labelH;
143
- labelRects.push({ label: flow.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
256
+ const { width, height } = flowLabelSize(flow);
257
+ labelRects.push({ relation: flow, relationIndex: flowIndex, label: flow.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
144
258
  }
145
259
  for (const rect of labelRects) {
146
260
  for (const node of nodes.values()) {
@@ -156,6 +270,16 @@ function validateDataflow() {
156
270
  }
157
271
  }
158
272
  }
273
+ problems.push(...cleanLabelRouteClearanceProblems({
274
+ relations: dataflow.flows,
275
+ labels: labelRects,
276
+ endpointIds: new Set(nodes.keys()),
277
+ pathFor,
278
+ diagramType: 'dataflow',
279
+ relationCollection: 'flows',
280
+ profile: dataflow.meta?.quality_profile,
281
+ routeHint: 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other flow route/via/channelX/channelY'
282
+ }));
159
283
 
160
284
  const lastStageX = stageX(asArray(dataflow.stages).length - 1);
161
285
  if (lastStageX + layout.stageW / 2 > viewBox[0] - 24) {
@@ -163,7 +287,9 @@ function validateDataflow() {
163
287
  }
164
288
 
165
289
  if (problems.length) {
166
- throw new Error(`Data-flow layout validation failed:\n- ${problems.join('\n- ')}`);
290
+ throwDiagnosticProblems('Data-flow layout validation failed', problems, {
291
+ subject: { diagramType: 'dataflow' },
292
+ });
167
293
  }
168
294
  }
169
295
 
@@ -195,73 +321,135 @@ function routeVia(flow, from, to, start, end) {
195
321
 
196
322
  const pathCache = new Map();
197
323
 
324
+ function flowSides(flow) {
325
+ const from = nodes.get(flow.from);
326
+ const to = nodes.get(flow.to);
327
+ return {
328
+ fromSide: chosenSide(flow.fromSide, defaultFromSide(from, to)),
329
+ toSide: chosenSide(flow.toSide, defaultToSide(from, to)),
330
+ };
331
+ }
332
+
333
+ const automaticPorts = automaticPortSpread(dataflow.flows, nodes, {
334
+ sideFor: (flow, endpoint) => flowSides(flow)[endpoint === 'source' ? 'fromSide' : 'toSide'],
335
+ });
336
+
198
337
  function pathFor(flow) {
199
338
  if (pathCache.has(flow)) return pathCache.get(flow);
200
339
  const from = nodes.get(flow.from);
201
340
  const to = nodes.get(flow.to);
202
- const start = anchor(from, chosenSide(flow.fromSide, defaultFromSide(from, to)));
203
- const end = anchor(to, chosenSide(flow.toSide, defaultToSide(from, to)));
204
- const points = [start, ...routeVia(flow, from, to, start, end), end];
341
+ const ports = automaticPorts.get(flow);
342
+ const { fromSide, toSide } = flowSides(flow);
343
+ const start = ports?.from || anchor(from, fromSide);
344
+ const end = ports?.to || anchor(to, toSide);
345
+ // Drop consecutive duplicate points so a purely vertical (or horizontal)
346
+ // auto-route never emits a zero-length final segment - SVG derives
347
+ // marker-end orientation from the last segment, and a degenerate segment
348
+ // leaves the arrowhead angle undefined (see #169).
349
+ const rawPoints = [start, ...routeVia(flow, from, to, start, end), end];
350
+ const points = [];
351
+ for (const p of rawPoints) {
352
+ const prev = points.at(-1);
353
+ if (!prev || Math.abs(p[0] - prev[0]) > 0.0001 || Math.abs(p[1] - prev[1]) > 0.0001) {
354
+ points.push(p);
355
+ }
356
+ }
357
+ // Guard against an all-degenerate route (e.g. start === end): keep both
358
+ // endpoints so the path is still well-formed even if the marker is hidden.
359
+ if (points.length < 2) points.push(end);
205
360
  const routed = { d: polylinePath(points), points };
206
361
  pathCache.set(flow, routed);
207
362
  return routed;
208
363
  }
209
364
 
210
365
  function renderStage(stage, index) {
366
+ const frame = compositionFrames[index];
211
367
  const cx = stageX(index);
212
- const x = cx - layout.stageW / 2;
213
- const h = viewBox[1] - layout.stageY - layout.stageBottomPad;
214
- return ` <rect x="${x}" y="${layout.stageY}" width="${layout.stageW}" height="${h}" rx="10" class="c-lane" stroke-width="1"/>
368
+ return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="${index}" x="${frame.x}" y="${frame.y}" width="${frame.width}" height="${frame.height}" rx="${frame.radius}" class="c-lane" stroke-width="1"/>
215
369
  <text x="${cx}" y="${layout.stageY + 22}" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">${String(index + 1).padStart(2, '0')} / ${esc(stage.label)}</text>`;
216
370
  }
217
371
 
218
372
  function renderNode(node) {
219
373
  const fill = componentFill[node.type] || 'c-external';
220
374
  const accent = componentText[node.type] || 't-muted';
375
+ const hasSub = node.sublabel != null && node.sublabel !== '';
376
+ const sub = hasSub
377
+ ? `\n <text data-detail="context" x="${node.cx}" y="${node.y + 37}" class="t-muted" font-size="${fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, nodeTextFit.sublabelMinimum)}" text-anchor="middle">${esc(node.sublabel)}</text>`
378
+ : '';
221
379
  const tag = node.tag
222
- ? `\n <text x="${node.cx}" y="${node.y + node.height - 11}" class="${accent}" font-size="7" text-anchor="middle">${esc(node.tag)}</text>`
380
+ ? `\n <text data-detail="fine" x="${node.cx}" y="${node.y + node.height - 11}" class="${accent}" font-size="${fittedNodeFontSize(node.tag, node.width, nodeTextFit.tagPreferred, nodeTextFit.tagMinimum)}" text-anchor="middle">${esc(node.tag)}</text>`
223
381
  : '';
224
- return ` <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
225
- <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="${fill}"${animateAttr(dataflow.meta, 'node', nodeSteps.get(node.id))} stroke-width="1.5"/>
226
- <text x="${node.cx}" y="${node.y + 21}" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">${esc(node.label)}</text>
227
- <text x="${node.cx}" y="${node.y + 37}" class="t-muted" font-size="7" text-anchor="middle">${esc(node.sublabel || '')}</text>${tag}`;
382
+ const stage = asArray(dataflow.stages)[node.stage];
383
+ const context = stage
384
+ ? `${String(node.stage + 1).padStart(2, '0')} / ${stage.label}`
385
+ : i18nText(dataflow.meta.locale, 'node.context.dataflow');
386
+ const brand = renderBrandMark(node, { x: node.x + node.width - 22, y: node.y + 6 });
387
+ const labelFontSize = fittedNodeFontSize(node.label, brandLabelFitWidth(node, node.width), 10, 8);
388
+ const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context, ...brandMetadataFor(node) };
389
+ return ` <g ${focusNodeAttrs(node.id, node.label, passport, dataflow.meta.locale)}>
390
+ ${focusNodeTitle(node.label, passport)}
391
+ <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
392
+ <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="${fill}"${animateAttr(dataflow.meta, 'node', nodeSteps.get(node.id))} stroke-width="1.5"/>
393
+ ${renderSemanticSigil(node.type, { x: node.x + 6, y: node.y + 6 })}${brand ? `\n ${brand}` : ''}
394
+ <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${node.cx}" y="${node.y + 21}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(node.label)}</text>${sub}${tag}
395
+ </g>`;
228
396
  }
229
397
 
230
398
  function renderFlowPath(flow, index) {
231
399
  const [cls, marker] = arrowClassMap[flow.variant || 'default'] || arrowClassMap.default;
232
400
  const routed = pathFor(flow);
233
401
  const strokeWidth = flow.width || (flow.variant === 'emphasis' ? 1.8 : 1.4);
234
- return ` <path d="${routed.d}" class="${cls}"${animateAttr(dataflow.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
402
+ return ` <path ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(dataflow.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
235
403
  }
236
404
 
237
- function renderFlowLabel(flow) {
405
+ function renderFlowLabel(flow, index) {
238
406
  const routed = pathFor(flow);
239
407
  const [lx, ly] = labelPoint(flow, routed.points);
240
- const longestLine = Math.max(textUnits(flow.label), textUnits(flow.classification || ''));
241
- const labelW = Math.max(34, longestLine * 4.9 + 12);
408
+ const { width: labelW, height: labelH } = flowLabelSize(flow);
242
409
  const classification = flow.classification
243
- ? `\n <text x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(flow.classification)}</text>`
410
+ ? `\n <text data-detail="fine" x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(flow.classification)}</text>`
244
411
  : '';
245
- const labelH = flow.classification ? 27 : layout.labelH;
246
- return ` <rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
247
- <text x="${lx}" y="${ly}" class="${variantAccent(flow.variant)}" font-size="8" text-anchor="middle">${esc(flow.label)}</text>${classification}`;
412
+ return ` <g data-detail="context" ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)}>
413
+ <rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
414
+ <text x="${lx}" y="${ly}" class="${variantAccent(flow.variant)}" font-size="8" text-anchor="middle">${esc(flow.label)}</text>${classification}
415
+ </g>`;
248
416
  }
249
417
 
418
+ const LEGEND_CATALOG = [
419
+ { kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8, swatchWidth: 34, swatchGap: 9, interactive: false },
420
+ { kind: 'security', className: 'a-security', marker: 'arrowhead-security', swatchWidth: 34, swatchGap: 9, interactive: false },
421
+ { kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed', swatchWidth: 34, swatchGap: 9, interactive: false },
422
+ { kind: 'database' },
423
+ { kind: 'default', className: 'a-default', marker: 'arrowhead', swatchWidth: 34, swatchGap: 9, interactive: false },
424
+ ].map((entry) => ({
425
+ ...entry,
426
+ label: i18nText(dataflow.meta.locale, `legend.dataflow.${entry.kind}`),
427
+ }));
428
+
250
429
  function renderLegend() {
251
- const y = viewBox[1] - 36;
252
- return ` <text x="214" y="${y - 20}" class="t-primary" font-size="10" font-weight="600">Legend</text>
253
- <path d="M 214 ${y} L 248 ${y}" class="a-emphasis" stroke-width="1.8" marker-end="url(#arrowhead-emphasis)"/>
254
- <text x="257" y="${y + 3}" class="t-muted" font-size="8">primary data</text>
255
- <path d="M 340 ${y} L 374 ${y}" class="a-security" stroke-width="1.4" marker-end="url(#arrowhead-security)"/>
256
- <text x="383" y="${y + 3}" class="t-muted" font-size="8">policy / PII</text>
257
- <path d="M 480 ${y} L 514 ${y}" class="a-dashed" stroke-width="1.4" marker-end="url(#arrowhead-dashed)"/>
258
- <text x="523" y="${y + 3}" class="t-muted" font-size="8">async batch</text>
259
- <rect x="625" y="${y - 8}" width="14" height="9" rx="2" class="c-database" stroke-width="1"/>
260
- <text x="646" y="${y}" class="t-muted" font-size="8">data store</text>`;
430
+ const presentKinds = new Set(asArray(dataflow.flows).map((flow) => flow.variant || 'default'));
431
+ if ([...nodes.values()].some((node) => node.type === 'database')) presentKinds.add('database');
432
+ const entries = resolveLegend(dataflow.meta?.legend, LEGEND_CATALOG, presentKinds);
433
+ return renderResolvedLegend({
434
+ entries,
435
+ locale: dataflow.meta.locale,
436
+ layout: {
437
+ x: 40,
438
+ baselineY: viewBox[1] - 36,
439
+ width: viewBox[0] - 80,
440
+ minTitleY: viewBox[1] - 66,
441
+ unfit: dataflow.meta?.legend === undefined ? 'hide' : 'error',
442
+ diagramType: 'dataflow',
443
+ },
444
+ renderSwatch: (entry) => entry.kind === 'database'
445
+ ? `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-database" stroke-width="1"/>`
446
+ : `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}" marker-end="url(#${entry.marker})"/>`,
447
+ });
261
448
  }
262
449
 
263
450
  function renderSvg() {
264
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(dataflow.meta, 'data-flow diagram')}>
451
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(dataflow.meta)}>
452
+ ${svgAccessibleText(dataflow.meta, 'dataflow')}
265
453
  ${renderDefinitions()}
266
454
 
267
455
  <!-- Background Grid -->
@@ -288,8 +476,8 @@ validateDataflow();
288
476
  writeDiagram({
289
477
  outPath,
290
478
  template,
479
+ diagramType: 'dataflow',
291
480
  meta: dataflow.meta,
292
- footerLabel: 'Data-flow diagram',
293
481
  svg: renderSvg(),
294
482
  cards: dataflow.cards,
295
483
  });
@@ -4,7 +4,7 @@ Render `diagram_type: "lifecycle"` JSON files into the standalone technical diag
4
4
  template.
5
5
 
6
6
  ```bash
7
- bun --config=.claude/skills/stacks-technical-diagrams/bunfig.toml --no-env-file .claude/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs input.lifecycle.json output.html
7
+ node .claude/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs input.lifecycle.json output.html
8
8
  ```
9
9
 
10
10
  The renderer validates input against `.claude/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json`
@@ -23,7 +23,6 @@ Lifecycle JSON files must set:
23
23
  "diagram_type": "lifecycle",
24
24
  "meta": {
25
25
  "title": "Agent Run Lifecycle",
26
- "subtitle": "Lifecycle phases, interruptions, recovery, and terminal exits",
27
26
  "viewBox": [980, 660]
28
27
  },
29
28
  "lanes": [],
@@ -37,7 +36,7 @@ Lane ids are semantic and reserved: a lane with id `main` is required and maps
37
36
  to the top phase band; `terminal` maps to the bottom outcome band; every other
38
37
  lane id (up to 4 lanes total) shares the single middle event band. The three
39
38
  band headers render from your lane labels - the middle band joins the labels of
40
- all event lanes with `+`. A complete worked example lives at
39
+ all event lanes with a `+` separator. A complete worked example lives at
41
40
  `.claude/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json`.
42
41
 
43
42
  The schema lives at:
@@ -46,6 +45,14 @@ The schema lives at:
46
45
  .claude/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json
47
46
  ```
48
47
 
48
+ ## Legend
49
+
50
+ The default legend derives kinds from `states[].type`. Supported
51
+ `meta.legend.entries` keys, in stable order, are `start`, `active`, `waiting`,
52
+ `decision`, `success`, `failure`, `neutral`, and `external`. Labels and
53
+ visibility may be overridden through the shared legend contract; only kinds
54
+ backed by rendered states receive Semantic Legend controls.
55
+
49
56
  ## Layout budget
50
57
 
51
58
  | Band | Lane id | Top y | Column centers | Default state |
@@ -54,13 +61,18 @@ The schema lives at:
54
61
  | Event | any other id | 278 | `col` 0-2 → x = 402, 556, 710 | 126×58 |
55
62
  | Outcome | `terminal` | 450 | `col` 0-2 → x = 402, 556, 710 | 118×58 |
56
63
 
64
+ Event and terminal columns are intentionally offset from the main rail:
65
+ event/terminal `col: N` uses the same x coordinate as main `col: N + 2`.
66
+ For example, lower-band columns 0, 1, and 2 align beneath main columns 2, 3,
67
+ and 4 respectively.
68
+
57
69
  | Constant | Value |
58
70
  |----------|-------|
59
- | viewBox | default `[980, 660]`; schema minimum `[420, 360]` |
60
- | State area | x within `[32, width − 32]`; y within `[64, legend y − 24]` |
71
+ | viewBox | default `[980, 660]`; schema minimum `[420, 566]` |
72
+ | State area | x within `[32, width − 32]`; state bottom at or above `height − 122` |
61
73
  | State spacing | ≥10px between any two states - checked across lanes, because all event lanes share one band; separate same-band states with `col` or `yOffset` |
62
74
  | Transition length | ≥32px between endpoints |
63
- | Legend row | y = height − 98 |
75
+ | Legend row | final baseline y = height − 36; extra measured rows wrap upward |
64
76
 
65
77
  The primary lifecycle rail runs along the phase band and extends to the
66
78
  furthest occupied phase column. Route presets for transitions: `straight`,
@@ -87,5 +99,17 @@ element's id or label. The renderer additionally fails when it can detect
87
99
  layout problems, including a missing `main` lane, duplicate state IDs, unknown
88
100
  lanes, unknown transition endpoints, states outside the lifecycle area,
89
101
  overlapping states (including across lanes), labels colliding with states or
90
- other labels, labels wider than their state, or unreadably short transitions.
102
+ other labels, labels wider than their state, unreadably short transitions, or
103
+ transitions crossing unrelated states (2px Clean Flow clearance). Lifecycle
104
+ bands remain intentional pass-through containers.
91
105
  Text width is estimated CJK-aware: fullwidth glyphs count as two units.
106
+
107
+ Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
108
+ X crossings then fail with `composition/proper-crossing`; default `standard`
109
+ keeps them as artifact-receipt warnings. The final artifact check samples
110
+ rounded `Q` corners. Collinear corridors remain outside the proper-X rule, but
111
+ a separate gate warns in `standard` and fails in `showcase` when unrelated
112
+ transitions overlap for at least 8px. Shared semantic endpoints, point touches,
113
+ and shorter overlaps remain valid. Showcase also rejects any route segment
114
+ below 8px and any interior turn segment below 16px; ordinary 8-15px endpoint
115
+ stubs remain valid.