@stacksjs/defaults 0.74.41 → 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.
- package/ai/skills/stacks-cms/SKILL.md +4 -4
- package/ai/skills/stacks-config/SKILL.md +1 -1
- package/ai/skills/stacks-technical-diagrams/LICENSE +1 -1
- package/ai/skills/stacks-technical-diagrams/SKILL.md +257 -229
- package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +69 -0
- package/ai/skills/stacks-technical-diagrams/assets/JetBrainsMono-OFL.txt +93 -0
- package/ai/skills/stacks-technical-diagrams/assets/template.html +14588 -929
- package/ai/skills/stacks-technical-diagrams/bin/diagrams +10 -0
- package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +86 -0
- package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +654 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +1898 -89
- package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +829 -0
- package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +31 -0
- package/ai/skills/stacks-technical-diagrams/brand-marks/catalog.json +131 -0
- package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +1221 -0
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +18 -22
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +58 -52
- package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +61 -0
- package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +47 -0
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +30 -23
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +31 -0
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +31 -0
- package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +49 -0
- package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +57 -0
- package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +64 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +22 -16
- package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +71 -0
- package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +62 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +16 -11
- package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +279 -0
- package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +391 -0
- package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +243 -0
- package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +65 -0
- package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +120 -0
- package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +45 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +780 -73
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +25 -3
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +240 -52
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +31 -7
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +227 -50
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +36 -6
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +270 -63
- package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +563 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/bun-runtime.mjs +20 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +186 -10
- package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +26 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +127 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/engineering-profiles.mjs +157 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +3 -3
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +1195 -2
- package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +595 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +217 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +340 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +238 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +58 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +49 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +163 -19
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +51 -5
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -17
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +24 -470
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +4400 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +154 -11
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +61 -4
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +72 -0
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +40 -18
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +43 -18
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +41 -4
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +97 -1
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +551 -12
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +3 -4
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -4,7 +4,7 @@ Render `diagram_type: "dataflow"` JSON files into the standalone technical diagr
|
|
|
4
4
|
template.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
|
-
|
|
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,
|
|
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,
|
|
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 } =
|
|
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
|
|
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
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
203
|
-
const
|
|
204
|
-
const
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
60
|
-
| State area | x within `[32, width − 32]`;
|
|
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 −
|
|
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,
|
|
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.
|