@stacksjs/defaults 0.74.40 → 0.74.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/ai/skills/stacks-cms/SKILL.md +4 -4
  2. package/ai/skills/stacks-composables/SKILL.md +1 -1
  3. package/ai/skills/stacks-config/SKILL.md +1 -1
  4. package/ai/skills/stacks-dashboard/SKILL.md +1 -1
  5. package/ai/skills/stacks-technical-diagrams/LICENSE +1 -1
  6. package/ai/skills/stacks-technical-diagrams/SKILL.md +257 -229
  7. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +69 -0
  8. package/ai/skills/stacks-technical-diagrams/assets/JetBrainsMono-OFL.txt +93 -0
  9. package/ai/skills/stacks-technical-diagrams/assets/template.html +14588 -929
  10. package/ai/skills/stacks-technical-diagrams/bin/diagrams +10 -0
  11. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +86 -0
  12. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +654 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +1898 -89
  14. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +829 -0
  15. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +31 -0
  16. package/ai/skills/stacks-technical-diagrams/brand-marks/catalog.json +131 -0
  17. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +1221 -0
  18. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +18 -22
  19. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +58 -52
  20. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +61 -0
  21. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +47 -0
  22. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +30 -23
  23. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +31 -0
  24. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +31 -0
  25. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +49 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +57 -0
  27. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +64 -0
  28. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +22 -16
  29. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +71 -0
  30. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +62 -0
  31. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +16 -11
  32. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +279 -0
  33. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +391 -0
  34. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +243 -0
  35. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +65 -0
  36. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +120 -0
  37. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +45 -0
  38. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +780 -73
  39. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +25 -3
  40. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +240 -52
  41. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +31 -7
  42. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +227 -50
  43. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +36 -6
  44. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +270 -63
  45. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +563 -0
  46. package/ai/skills/stacks-technical-diagrams/renderers/shared/bun-runtime.mjs +20 -0
  47. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +186 -10
  48. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +26 -0
  49. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +127 -0
  50. package/ai/skills/stacks-technical-diagrams/renderers/shared/engineering-profiles.mjs +157 -0
  51. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-brand-marks.mjs +2003 -0
  52. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +3 -3
  53. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +1195 -2
  54. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +595 -0
  55. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +217 -0
  56. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +340 -0
  57. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +238 -0
  58. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +58 -0
  59. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +49 -0
  60. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +163 -19
  61. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +51 -5
  62. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -17
  63. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +24 -470
  64. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +4400 -0
  65. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  66. package/ai/skills/stacks-technical-diagrams/schemas/README.md +154 -11
  67. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +61 -4
  68. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +72 -0
  69. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +40 -18
  70. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +43 -18
  71. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +41 -4
  72. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +97 -1
  73. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +551 -12
  74. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +3 -4
  75. package/ide/vscode/package.json +1 -1
  76. package/package.json +2 -2
  77. package/resources/components/CookieConsent.stx +146 -0
  78. package/resources/components/CookieConsent.test.ts +79 -0
@@ -1,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, 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, 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 } = loadDiagram({
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] - 98;
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; the legend (viewBox[1] - 98)
124
- // must clear the outcome band's header zone.
125
- if (legendY() - 20 < 448) {
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 > legendY() - 24) {
158
- problems.push(`State "${state.id}" exceeds the vertical lifecycle area - keep y between 64 and ${legendY() - 24} (adjust yOffset or increase meta.viewBox[1]).`);
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, move detail to sublabel, or increase state.width.`);
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
- throw new Error(`Lifecycle layout validation failed:\n- ${problems.join('\n- ')}`);
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 (from.lane === to.lane) return [];
245
- const y = transition.channelY ?? (start[1] + end[1]) / 2;
246
- return [[start[0], y], [end[0], y]];
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 start = anchor(from, chosenSide(transition.fromSide, defaultFromSide(from, to)));
258
- const end = anchor(to, chosenSide(transition.toSide, defaultToSide(from, to)));
259
- const points = [start, ...routeVia(transition, from, to, start, end), end];
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="7" text-anchor="middle">${esc(state.tag)}</text>`
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
- return ` <rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="c-mask"/>
301
- <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"/>${step}
302
- <text x="${state.cx}" y="${state.y + 21}" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">${esc(state.label)}</text>
303
- <text x="${state.cx}" y="${state.y + 37}" class="t-muted" font-size="7" text-anchor="middle">${esc(state.sublabel || '')}</text>${tag}`;
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 ` <rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
324
- <text x="${lx}" y="${ly}" class="${variantAccent(transition.variant)}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>${note}`;
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 y = legendY();
329
- return ` <text x="220" y="${y - 20}" class="t-primary" font-size="10" font-weight="600">Legend</text>
330
- <rect x="220" y="${y - 8}" width="14" height="9" rx="2" class="c-backend" stroke-width="1"/>
331
- <text x="240" y="${y}" class="t-muted" font-size="7">active state</text>
332
- <rect x="325" y="${y - 8}" width="14" height="9" rx="2" class="c-cloud" stroke-width="1"/>
333
- <text x="345" y="${y}" class="t-muted" font-size="7">waiting</text>
334
- <rect x="415" y="${y - 8}" width="14" height="9" rx="2" class="c-database" stroke-width="1"/>
335
- <text x="435" y="${y}" class="t-muted" font-size="7">terminal success</text>
336
- <rect x="560" y="${y - 8}" width="14" height="9" rx="2" class="c-security" stroke-width="1"/>
337
- <text x="580" y="${y}" class="t-muted" font-size="7">failure / exit</text>`;
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, 'lifecycle diagram')}>
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
- bun --config=.claude/skills/stacks-technical-diagrams/bunfig.toml --no-env-file .claude/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs input.sequence.json output.html
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; centers at x = 62 + index×108 |
54
- | Participant count | last center + 43 must be ≤ width − 40 (8 fit at width 920) |
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 short enough to fit in narrow previews.
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. Text width is estimated CJK-aware:
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.