@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
|
@@ -1,25 +1,46 @@
|
|
|
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, brandMarkFor, 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
|
roundedPath,
|
|
29
|
+
routePointsValue,
|
|
16
30
|
labelPoint,
|
|
17
31
|
arrowClassMap,
|
|
18
32
|
variantAccent
|
|
19
33
|
} from '../shared/geometry.mjs';
|
|
20
34
|
|
|
35
|
+
const stateTextFit = {
|
|
36
|
+
sublabelPreferred: 7,
|
|
37
|
+
sublabelMinimum: 6,
|
|
38
|
+
tagPreferred: 7,
|
|
39
|
+
tagMinimum: 6,
|
|
40
|
+
};
|
|
41
|
+
|
|
21
42
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
-
const { diagram: lifecycle, template, outPath } =
|
|
43
|
+
const { diagram: lifecycle, template, outPath } = await loadDiagramWithBrandMarks({
|
|
23
44
|
rendererDir: __dirname,
|
|
24
45
|
diagramType: 'lifecycle',
|
|
25
46
|
defaultExample: 'agent-run.lifecycle.json'
|
|
@@ -64,7 +85,14 @@ const textClass = {
|
|
|
64
85
|
};
|
|
65
86
|
|
|
66
87
|
function legendY() {
|
|
67
|
-
return viewBox[1] -
|
|
88
|
+
return viewBox[1] - 36;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Keep the authored state-placement contract independent from the measured
|
|
92
|
+
// legend's lower baseline. Moving legend chrome must not admit new state
|
|
93
|
+
// geometry into the reserved outcome/legend band.
|
|
94
|
+
function lifecycleAreaBottom() {
|
|
95
|
+
return viewBox[1] - 122;
|
|
68
96
|
}
|
|
69
97
|
|
|
70
98
|
// Lane semantics are fixed: lane id "main" maps to the top phase band, lane id
|
|
@@ -100,6 +128,7 @@ function measureState(state) {
|
|
|
100
128
|
}
|
|
101
129
|
|
|
102
130
|
const states = new Map(asArray(lifecycle.states).map((state) => [state.id, measureState(state)]));
|
|
131
|
+
const laneLabels = new Map(asArray(lifecycle.lanes).map((lane) => [lane.id, lane.label]));
|
|
103
132
|
const stateSteps = new Map();
|
|
104
133
|
for (const [index, transition] of asArray(lifecycle.transitions).entries()) {
|
|
105
134
|
if (!stateSteps.has(transition.from)) stateSteps.set(transition.from, index);
|
|
@@ -111,18 +140,11 @@ for (const [index, state] of asArray(lifecycle.states).entries()) {
|
|
|
111
140
|
|
|
112
141
|
function validateLifecycle() {
|
|
113
142
|
const problems = [];
|
|
114
|
-
if (lifecycle.schema_version !== 1) problems.push('Lifecycle files must set "schema_version": 1.');
|
|
115
|
-
if (lifecycle.diagram_type !== 'lifecycle') problems.push('Lifecycle files must set "diagram_type": "lifecycle".');
|
|
116
|
-
if (!lifecycle.meta?.title) problems.push('Lifecycle files must include meta.title.');
|
|
117
|
-
if (!Array.isArray(lifecycle.lanes) || lifecycle.lanes.length < 1) problems.push('Lifecycle diagrams need at least one lane.');
|
|
118
|
-
if (!Array.isArray(lifecycle.states) || lifecycle.states.length < 2) problems.push('Lifecycle diagrams need at least two states.');
|
|
119
|
-
if (!Array.isArray(lifecycle.transitions)) problems.push('Lifecycle diagrams must include a transitions array.');
|
|
120
|
-
if (lifecycle.cards !== undefined && !Array.isArray(lifecycle.cards)) problems.push('Lifecycle "cards" must be an array.');
|
|
121
143
|
if (states.size !== asArray(lifecycle.states).length) problems.push('State ids must be unique.');
|
|
122
144
|
|
|
123
|
-
// The three bands are fixed at y=112/264/436
|
|
124
|
-
//
|
|
125
|
-
if (
|
|
145
|
+
// The three bands are fixed at y=112/264/436. Preserve the original
|
|
146
|
+
// outcome/legend reserve even though measured legend rows now sit lower.
|
|
147
|
+
if (lifecycleAreaBottom() + 4 < 448) {
|
|
126
148
|
problems.push(`viewBox height ${viewBox[1]} is too short for the fixed band layout - set meta.viewBox[1] to at least 566.`);
|
|
127
149
|
}
|
|
128
150
|
|
|
@@ -154,12 +176,27 @@ function validateLifecycle() {
|
|
|
154
176
|
if (state.x < 32 || state.x + state.width > viewBox[0] - 32) {
|
|
155
177
|
problems.push(`State "${state.id}" exceeds the horizontal bounds of the diagram - reduce state.width or increase meta.viewBox[0].`);
|
|
156
178
|
}
|
|
157
|
-
if (state.y < 64 || state.y + state.height >
|
|
158
|
-
problems.push(`State "${state.id}" exceeds the vertical lifecycle area - keep y between 64 and ${
|
|
179
|
+
if (state.y < 64 || state.y + state.height > lifecycleAreaBottom()) {
|
|
180
|
+
problems.push(`State "${state.id}" exceeds the vertical lifecycle area - keep y between 64 and ${lifecycleAreaBottom()} (adjust yOffset or increase meta.viewBox[1]).`);
|
|
159
181
|
}
|
|
160
182
|
const estLabelW = textUnits(state.label) * 6.2;
|
|
161
183
|
if (estLabelW > state.width + 6) {
|
|
162
|
-
problems.push(`Label "${state.label}" (~${Math.round(estLabelW)}px) is wider than state "${state.id}" (${state.width}px) - shorten the label
|
|
184
|
+
problems.push(`Label "${state.label}" (~${Math.round(estLabelW)}px) is wider than state "${state.id}" (${state.width}px) - shorten the label or increase state.width.`);
|
|
185
|
+
}
|
|
186
|
+
const brandRailProblem = brandTopRailProblem(state, state.width, 8, 'State');
|
|
187
|
+
if (brandRailProblem) problems.push(brandRailProblem);
|
|
188
|
+
// sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
|
|
189
|
+
// handles the ordinary case, this rejects what it cannot rescue.
|
|
190
|
+
const availableTextW = availableNodeTextWidth(state.width);
|
|
191
|
+
for (const [field, value, minimum] of [
|
|
192
|
+
['Sublabel', state.sublabel, stateTextFit.sublabelMinimum],
|
|
193
|
+
['Tag', state.tag, stateTextFit.tagMinimum],
|
|
194
|
+
]) {
|
|
195
|
+
if (!value) continue;
|
|
196
|
+
const minimumW = minimumNodeTextWidth(value, minimum);
|
|
197
|
+
if (minimumW > availableTextW) {
|
|
198
|
+
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but state "${state.id}" provides ${availableTextW}px - shorten the ${field.toLowerCase()} or increase state.width.`);
|
|
199
|
+
}
|
|
163
200
|
}
|
|
164
201
|
}
|
|
165
202
|
|
|
@@ -185,14 +222,78 @@ function validateLifecycle() {
|
|
|
185
222
|
}
|
|
186
223
|
}
|
|
187
224
|
|
|
225
|
+
// Authored via points are authoritative in schema v1, including under a
|
|
226
|
+
// quality profile. Preserve and render them exactly: applying the endpoint
|
|
227
|
+
// gate would either reject an existing typed input or require silently
|
|
228
|
+
// falsifying its geometry. Automatic routes still receive the side gate.
|
|
229
|
+
problems.push(...cleanEndpointSideProblems({
|
|
230
|
+
relations: lifecycle.transitions,
|
|
231
|
+
endpointIds: new Set(states.keys()),
|
|
232
|
+
pathFor,
|
|
233
|
+
diagramType: 'lifecycle',
|
|
234
|
+
relationCollection: 'transitions',
|
|
235
|
+
fromSideFor: (transition) => transitionSides(transition).fromSide,
|
|
236
|
+
toSideFor: (transition) => transitionSides(transition).toSide,
|
|
237
|
+
shouldCheckRelation: (transition) => !Array.isArray(transition.via),
|
|
238
|
+
routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross state borders perpendicularly',
|
|
239
|
+
}));
|
|
240
|
+
problems.push(...cleanFlowProblems({
|
|
241
|
+
relations: lifecycle.transitions,
|
|
242
|
+
obstacles: states.values(),
|
|
243
|
+
pathFor,
|
|
244
|
+
diagramType: 'lifecycle',
|
|
245
|
+
relationCollection: 'transitions',
|
|
246
|
+
obstacleKind: 'state',
|
|
247
|
+
routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the state with col/yOffset'
|
|
248
|
+
}));
|
|
249
|
+
problems.push(...cleanCrossingProblems({
|
|
250
|
+
relations: lifecycle.transitions,
|
|
251
|
+
endpointIds: new Set(states.keys()),
|
|
252
|
+
pathFor,
|
|
253
|
+
diagramType: 'lifecycle',
|
|
254
|
+
relationCollection: 'transitions',
|
|
255
|
+
profile: lifecycle.meta?.quality_profile,
|
|
256
|
+
routeHint: 'adjust route/via or channelX/channelY so the transitions use separate lifecycle corridors'
|
|
257
|
+
}));
|
|
258
|
+
problems.push(...cleanAmbiguousCorridorProblems({
|
|
259
|
+
relations: lifecycle.transitions,
|
|
260
|
+
endpointIds: new Set(states.keys()),
|
|
261
|
+
pathFor,
|
|
262
|
+
diagramType: 'lifecycle',
|
|
263
|
+
relationCollection: 'transitions',
|
|
264
|
+
profile: lifecycle.meta?.quality_profile,
|
|
265
|
+
routeHint: 'adjust route/via or channelX/channelY so unrelated transitions do not visually merge'
|
|
266
|
+
}));
|
|
267
|
+
// Lifecycle bands are dashed reading guides, not closed containers. Keep the
|
|
268
|
+
// shared contract wired with an explicit empty frame set so future typed
|
|
269
|
+
// lifecycle containers cannot accidentally inherit presentation geometry.
|
|
270
|
+
problems.push(...cleanBorderRunProblems({
|
|
271
|
+
relations: lifecycle.transitions,
|
|
272
|
+
endpointIds: new Set(states.keys()),
|
|
273
|
+
frames: [],
|
|
274
|
+
pathFor,
|
|
275
|
+
diagramType: 'lifecycle',
|
|
276
|
+
relationCollection: 'transitions',
|
|
277
|
+
profile: lifecycle.meta?.quality_profile
|
|
278
|
+
}));
|
|
279
|
+
problems.push(...cleanRouteRhythmProblems({
|
|
280
|
+
relations: lifecycle.transitions,
|
|
281
|
+
endpointIds: new Set(states.keys()),
|
|
282
|
+
pathFor,
|
|
283
|
+
diagramType: 'lifecycle',
|
|
284
|
+
relationCollection: 'transitions',
|
|
285
|
+
profile: lifecycle.meta?.quality_profile,
|
|
286
|
+
routeHint: 'move route/via or channel coordinates so each lifecycle turn has a readable run-up'
|
|
287
|
+
}));
|
|
288
|
+
|
|
188
289
|
const labelRects = [];
|
|
189
|
-
for (const transition of asArray(lifecycle.transitions)) {
|
|
290
|
+
for (const [transitionIndex, transition] of asArray(lifecycle.transitions).entries()) {
|
|
190
291
|
if (!transition.label || !states.has(transition.from) || !states.has(transition.to)) continue;
|
|
191
292
|
const [lx, ly] = labelPoint(transition, pathFor(transition).points);
|
|
192
293
|
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
|
|
193
294
|
const width = Math.max(32, longestLine * 4.9 + 12);
|
|
194
295
|
const height = transition.note ? 27 : 16;
|
|
195
|
-
labelRects.push({ label: transition.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
|
|
296
|
+
labelRects.push({ relation: transition, relationIndex: transitionIndex, label: transition.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
|
|
196
297
|
}
|
|
197
298
|
for (const rect of labelRects) {
|
|
198
299
|
for (const state of states.values()) {
|
|
@@ -208,13 +309,24 @@ function validateLifecycle() {
|
|
|
208
309
|
}
|
|
209
310
|
}
|
|
210
311
|
}
|
|
312
|
+
problems.push(...cleanLabelRouteClearanceProblems({
|
|
313
|
+
relations: lifecycle.transitions,
|
|
314
|
+
labels: labelRects,
|
|
315
|
+
endpointIds: new Set(states.keys()),
|
|
316
|
+
pathFor,
|
|
317
|
+
diagramType: 'lifecycle',
|
|
318
|
+
relationCollection: 'transitions',
|
|
319
|
+
profile: lifecycle.meta?.quality_profile,
|
|
320
|
+
}));
|
|
211
321
|
|
|
212
322
|
if (problems.length) {
|
|
213
|
-
|
|
323
|
+
throwDiagnosticProblems('Lifecycle layout validation failed', problems, {
|
|
324
|
+
subject: { diagramType: 'lifecycle' },
|
|
325
|
+
});
|
|
214
326
|
}
|
|
215
327
|
}
|
|
216
328
|
|
|
217
|
-
function routeVia(transition, from, to, start, end) {
|
|
329
|
+
function routeVia(transition, from, to, start, end, fromSide, toSide) {
|
|
218
330
|
if (transition.via) return transition.via;
|
|
219
331
|
switch (transition.route || 'auto') {
|
|
220
332
|
case 'straight':
|
|
@@ -241,22 +353,51 @@ function routeVia(transition, from, to, start, end) {
|
|
|
241
353
|
}
|
|
242
354
|
case 'auto':
|
|
243
355
|
default: {
|
|
244
|
-
if (
|
|
245
|
-
const
|
|
246
|
-
|
|
356
|
+
if (start[0] === end[0] || start[1] === end[1]) return [];
|
|
357
|
+
const fromVertical = fromSide === 'top' || fromSide === 'bottom';
|
|
358
|
+
const toVertical = toSide === 'top' || toSide === 'bottom';
|
|
359
|
+
if (fromVertical !== toVertical) {
|
|
360
|
+
return [fromVertical ? [start[0], end[1]] : [end[0], start[1]]];
|
|
361
|
+
}
|
|
362
|
+
if (fromVertical) {
|
|
363
|
+
const y = transition.channelY ?? (start[1] + end[1]) / 2;
|
|
364
|
+
return [[start[0], y], [end[0], y]];
|
|
365
|
+
}
|
|
366
|
+
const x = transition.channelX ?? (start[0] + end[0]) / 2;
|
|
367
|
+
return [[x, start[1]], [x, end[1]]];
|
|
247
368
|
}
|
|
248
369
|
}
|
|
249
370
|
}
|
|
250
371
|
|
|
251
372
|
const pathCache = new Map();
|
|
252
373
|
|
|
374
|
+
function transitionSides(transition) {
|
|
375
|
+
const from = states.get(transition.from);
|
|
376
|
+
const to = states.get(transition.to);
|
|
377
|
+
return {
|
|
378
|
+
fromSide: chosenSide(transition.fromSide, defaultFromSide(from, to)),
|
|
379
|
+
toSide: chosenSide(transition.toSide, defaultToSide(from, to)),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const automaticPorts = automaticPortSpread(lifecycle.transitions, states, {
|
|
384
|
+
sideFor: (transition, endpoint) => transitionSides(transition)[endpoint === 'source' ? 'fromSide' : 'toSide'],
|
|
385
|
+
});
|
|
386
|
+
|
|
253
387
|
function pathFor(transition) {
|
|
254
388
|
if (pathCache.has(transition)) return pathCache.get(transition);
|
|
255
389
|
const from = states.get(transition.from);
|
|
256
390
|
const to = states.get(transition.to);
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
const
|
|
391
|
+
const ports = automaticPorts.get(transition);
|
|
392
|
+
const { fromSide, toSide } = transitionSides(transition);
|
|
393
|
+
const start = ports?.from || anchor(from, fromSide);
|
|
394
|
+
const end = ports?.to || anchor(to, toSide);
|
|
395
|
+
let via = routeVia(transition, from, to, start, end, fromSide, toSide);
|
|
396
|
+
if (ports && !via.length && Math.abs(start[0] - end[0]) >= 4 && Math.abs(start[1] - end[1]) >= 4) {
|
|
397
|
+
const midX = (start[0] + end[0]) / 2;
|
|
398
|
+
via = [[midX, start[1]], [midX, end[1]]];
|
|
399
|
+
}
|
|
400
|
+
const points = [start, ...via, end];
|
|
260
401
|
const routed = {
|
|
261
402
|
d: roundedPath(points, transition.cornerRadius ?? 10),
|
|
262
403
|
points
|
|
@@ -291,26 +432,43 @@ function renderBands() {
|
|
|
291
432
|
function renderState(state) {
|
|
292
433
|
const fill = typeClass[state.type] || typeClass.neutral;
|
|
293
434
|
const accent = textClass[state.type] || 't-muted';
|
|
435
|
+
const hasSub = state.sublabel != null && state.sublabel !== '';
|
|
436
|
+
const sub = hasSub
|
|
437
|
+
? `\n <text data-detail="context" x="${state.cx}" y="${state.y + 37}" class="t-muted" font-size="${fittedNodeFontSize(state.sublabel, state.width, stateTextFit.sublabelPreferred, stateTextFit.sublabelMinimum)}" text-anchor="middle">${esc(state.sublabel)}</text>`
|
|
438
|
+
: '';
|
|
294
439
|
const tag = state.tag
|
|
295
|
-
? `\n <text x="${state.cx}" y="${state.y + state.height - 11}" class="${accent}" font-size="
|
|
440
|
+
? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + state.height - 11}" class="${accent}" font-size="${fittedNodeFontSize(state.tag, state.width, stateTextFit.tagPreferred, stateTextFit.tagMinimum)}" text-anchor="middle">${esc(state.tag)}</text>`
|
|
296
441
|
: '';
|
|
442
|
+
const hasBrand = Boolean(brandMarkFor(state));
|
|
297
443
|
const step = state.step
|
|
298
|
-
? `\n <text x="${state.x + 10}" y="${state.y + 14}" class="${accent}" font-size="7" font-weight="700">${esc(state.step)}</text>`
|
|
444
|
+
? `\n <text data-detail="fine" x="${state.x + (hasBrand ? 23 : 10)}" y="${state.y + 14}" class="${accent}" font-size="7" font-weight="700">${esc(state.step)}</text>`
|
|
299
445
|
: '';
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
446
|
+
const brand = renderBrandMark(state, { x: state.x + state.width - 22, y: state.y + 6 });
|
|
447
|
+
const labelFontSize = fittedNodeFontSize(state.label, brandLabelFitWidth(state, state.width), 10, 8);
|
|
448
|
+
const passport = {
|
|
449
|
+
kind: state.type,
|
|
450
|
+
sublabel: state.sublabel,
|
|
451
|
+
tag: state.tag,
|
|
452
|
+
context: laneLabels.get(state.lane) || i18nText(lifecycle.meta.locale, 'node.context.lifecycle'),
|
|
453
|
+
...brandMetadataFor(state),
|
|
454
|
+
};
|
|
455
|
+
return ` <g ${focusNodeAttrs(state.id, state.label, passport, lifecycle.meta.locale)}>
|
|
456
|
+
${focusNodeTitle(state.label, passport)}
|
|
457
|
+
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="c-mask"/>
|
|
458
|
+
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="${fill}"${animateAttr(lifecycle.meta, 'node', stateSteps.get(state.id))} stroke-width="1.5"/>
|
|
459
|
+
${renderSemanticSigil(state.type, { x: hasBrand ? state.x + 6 : state.x + state.width - 17, y: state.y + 6 })}${brand ? `\n ${brand}` : ''}${step}
|
|
460
|
+
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.cx}" y="${state.y + 21}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
|
|
461
|
+
</g>`;
|
|
304
462
|
}
|
|
305
463
|
|
|
306
464
|
function renderTransitionPath(transition, index) {
|
|
307
465
|
const [cls, marker] = arrowClassMap[transition.variant || 'default'] || arrowClassMap.default;
|
|
308
466
|
const routed = pathFor(transition);
|
|
309
467
|
const strokeWidth = transition.width || (transition.variant === 'emphasis' ? 2 : 1.1);
|
|
310
|
-
return ` <path d="${routed.d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
|
468
|
+
return ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
|
311
469
|
}
|
|
312
470
|
|
|
313
|
-
function renderTransitionLabel(transition) {
|
|
471
|
+
function renderTransitionLabel(transition, index) {
|
|
314
472
|
if (!transition.label) return '';
|
|
315
473
|
const routed = pathFor(transition);
|
|
316
474
|
const [lx, ly] = labelPoint(transition, routed.points);
|
|
@@ -318,23 +476,41 @@ function renderTransitionLabel(transition) {
|
|
|
318
476
|
const labelW = Math.max(32, longestLine * 4.9 + 12);
|
|
319
477
|
const labelH = transition.note ? 27 : 16;
|
|
320
478
|
const note = transition.note
|
|
321
|
-
? `\n <text x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(transition.note)}</text>`
|
|
479
|
+
? `\n <text data-detail="fine" x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(transition.note)}</text>`
|
|
322
480
|
: '';
|
|
323
|
-
return ` <
|
|
324
|
-
|
|
481
|
+
return ` <g data-detail="context" ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)}>
|
|
482
|
+
<rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
|
|
483
|
+
<text x="${lx}" y="${ly}" class="${variantAccent(transition.variant)}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>${note}
|
|
484
|
+
</g>`;
|
|
325
485
|
}
|
|
326
486
|
|
|
487
|
+
const LEGEND_CATALOG = [
|
|
488
|
+
'start',
|
|
489
|
+
'active',
|
|
490
|
+
'waiting',
|
|
491
|
+
'decision',
|
|
492
|
+
'success',
|
|
493
|
+
'failure',
|
|
494
|
+
'neutral',
|
|
495
|
+
'external',
|
|
496
|
+
].map((kind) => ({ kind, label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`) }));
|
|
497
|
+
|
|
327
498
|
function renderLegend() {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
499
|
+
const presentKinds = new Set([...states.values()].map((state) => state.type));
|
|
500
|
+
const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
|
|
501
|
+
return renderResolvedLegend({
|
|
502
|
+
entries,
|
|
503
|
+
locale: lifecycle.meta.locale,
|
|
504
|
+
layout: {
|
|
505
|
+
x: 40,
|
|
506
|
+
baselineY: legendY(),
|
|
507
|
+
width: viewBox[0] - 80,
|
|
508
|
+
minTitleY: lifecycleAreaBottom() + 8,
|
|
509
|
+
unfit: lifecycle.meta?.legend === undefined ? 'hide' : 'error',
|
|
510
|
+
diagramType: 'lifecycle',
|
|
511
|
+
},
|
|
512
|
+
renderSwatch: (entry) => `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${typeClass[entry.kind] || 'c-external'}" stroke-width="1"/>`,
|
|
513
|
+
});
|
|
338
514
|
}
|
|
339
515
|
|
|
340
516
|
function renderLifecycleRail() {
|
|
@@ -347,7 +523,8 @@ function renderLifecycleRail() {
|
|
|
347
523
|
}
|
|
348
524
|
|
|
349
525
|
function renderSvg() {
|
|
350
|
-
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(lifecycle.meta
|
|
526
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(lifecycle.meta)}>
|
|
527
|
+
${svgAccessibleText(lifecycle.meta, 'lifecycle')}
|
|
351
528
|
${renderDefinitions()}
|
|
352
529
|
|
|
353
530
|
<!-- Background Grid -->
|
|
@@ -377,8 +554,8 @@ validateLifecycle();
|
|
|
377
554
|
writeDiagram({
|
|
378
555
|
outPath,
|
|
379
556
|
template,
|
|
557
|
+
diagramType: 'lifecycle',
|
|
380
558
|
meta: lifecycle.meta,
|
|
381
|
-
footerLabel: 'Lifecycle diagram',
|
|
382
559
|
svg: renderSvg(),
|
|
383
560
|
cards: lifecycle.cards,
|
|
384
561
|
});
|
|
@@ -4,7 +4,7 @@ Render `diagram_type: "sequence"` JSON files into the standalone technical diagr
|
|
|
4
4
|
template.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
|
-
|
|
7
|
+
node .claude/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs input.sequence.json output.html
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
The renderer validates input against `.claude/skills/stacks-technical-diagrams/schemas/sequence.schema.json`
|
|
@@ -23,7 +23,6 @@ Sequence JSON files must set:
|
|
|
23
23
|
"diagram_type": "sequence",
|
|
24
24
|
"meta": {
|
|
25
25
|
"title": "Cache Miss Request Sequence",
|
|
26
|
-
"subtitle": "Frontend request path with auth and cache fallback",
|
|
27
26
|
"viewBox": [920, 760]
|
|
28
27
|
},
|
|
29
28
|
"participants": [],
|
|
@@ -45,13 +44,22 @@ The schema lives at:
|
|
|
45
44
|
.claude/skills/stacks-technical-diagrams/schemas/sequence.schema.json
|
|
46
45
|
```
|
|
47
46
|
|
|
47
|
+
## Legend
|
|
48
|
+
|
|
49
|
+
The default visual legend derives kinds from `messages[].variant` (omitting
|
|
50
|
+
`variant` means `default`). Supported `meta.legend.entries` keys, in stable
|
|
51
|
+
order, are `emphasis`, `return`, `security`, `dashed`, and `default`. These are
|
|
52
|
+
visual message keys, not Semantic Lens controls; label/visibility overrides do
|
|
53
|
+
not create edge facts.
|
|
54
|
+
|
|
48
55
|
## Layout budget
|
|
49
56
|
|
|
50
57
|
| Constant | Value |
|
|
51
58
|
|----------|-------|
|
|
52
59
|
| viewBox | default `[920, 760]`; schema minimum `[480, 480]` |
|
|
53
|
-
| Participant boxes | 86×54 at y 72;
|
|
54
|
-
| Participant
|
|
60
|
+
| Participant boxes | `fixed` (default): 86×54 at y 72; `spread`: viewBox-relative width from 86px up to 190px |
|
|
61
|
+
| Participant columns | `fixed`: centers at x = 62 + index×108; `spread`: columns distribute across the available viewBox width |
|
|
62
|
+
| Participant count | the last box must end at or before width − 40; layouts that cannot fit fail closed |
|
|
55
63
|
| Lifelines | from y 142 down to height − 65; band must be ≥120px tall |
|
|
56
64
|
| Message `y` range | `[160, height − 83]` |
|
|
57
65
|
| Message spacing | ≥28px vertical between messages that share horizontal space |
|
|
@@ -62,6 +70,15 @@ The schema lives at:
|
|
|
62
70
|
`segments[].from/to` and `activations[].from/to` are y pixel coordinates, not
|
|
63
71
|
participant ids; activations also require `to > from`.
|
|
64
72
|
|
|
73
|
+
### Column fit
|
|
74
|
+
|
|
75
|
+
Sequence diagrams use `meta.column_fit: "fixed"` by default so existing
|
|
76
|
+
documents keep their historical coordinates. Use `"spread"` when a wide
|
|
77
|
+
viewBox would otherwise leave empty space on the right or when meaningful
|
|
78
|
+
participant labels do not fit the fixed 86px boxes. Spread derives box width
|
|
79
|
+
and column distance from the viewBox while preserving participant order,
|
|
80
|
+
lifelines, and message semantics.
|
|
81
|
+
|
|
65
82
|
## Design Rules
|
|
66
83
|
|
|
67
84
|
- Put participants across the top, ordered by the story the reader should
|
|
@@ -72,7 +89,8 @@ participant ids; activations also require `to > from`.
|
|
|
72
89
|
- Use `return` for quiet response messages.
|
|
73
90
|
- Use `dashed` for async trace, event, logging, and non-blocking work.
|
|
74
91
|
- Use segments as light background guides; keep segment labels short.
|
|
75
|
-
- Keep labels
|
|
92
|
+
- Keep labels concise, but try `meta.column_fit: "spread"` before shortening a
|
|
93
|
+
meaningful participant label just to fit the fixed boxes.
|
|
76
94
|
|
|
77
95
|
Schema violations exit non-zero with path-prefixed messages annotated with the
|
|
78
96
|
element's id or label. The renderer additionally fails when it can detect
|
|
@@ -80,5 +98,17 @@ layout problems, including missing participants, duplicate participant IDs,
|
|
|
80
98
|
participant labels wider than their box, unknown message endpoints, messages
|
|
81
99
|
outside the readable timeline, overly tight vertical spacing between messages
|
|
82
100
|
that overlap horizontally, invalid segment or activation ranges, or
|
|
83
|
-
participants that exceed the viewBox.
|
|
101
|
+
participants that exceed the viewBox. The shared Clean Flow contract treats
|
|
102
|
+
participant headers as semantic boxes while explicitly allowing messages to
|
|
103
|
+
cross intermediate lifelines, activation bars, and segment frames. Text width is estimated CJK-aware:
|
|
84
104
|
fullwidth glyphs count as two units.
|
|
105
|
+
|
|
106
|
+
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
|
107
|
+
message X crossings then fail with `composition/proper-crossing`; default
|
|
108
|
+
`standard` keeps them as artifact-receipt warnings. Messages may still cross
|
|
109
|
+
intermediate lifelines. Collinear corridors remain outside the proper-X rule,
|
|
110
|
+
but a separate gate warns in `standard` and fails in `showcase` when unrelated
|
|
111
|
+
messages overlap for at least 8px. Shared semantic endpoints, point touches,
|
|
112
|
+
and shorter overlaps remain valid. Showcase also rejects any route segment
|
|
113
|
+
below 8px and any interior turn segment below 16px; ordinary 8-15px endpoint
|
|
114
|
+
stubs remain valid.
|