astro-archify 0.3.4

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 (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/astro-archify-integration.d.ts +100 -0
  4. package/astro-archify-integration.js +0 -0
  5. package/package.json +64 -0
  6. package/vendor/archify/LICENSE +22 -0
  7. package/vendor/archify/NOTICE.md +48 -0
  8. package/vendor/archify/assets/template.html +14787 -0
  9. package/vendor/archify/renderers/architecture/grid.mjs +62 -0
  10. package/vendor/archify/renderers/architecture/render-architecture.mjs +1089 -0
  11. package/vendor/archify/renderers/dataflow/render-dataflow.mjs +482 -0
  12. package/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +570 -0
  13. package/vendor/archify/renderers/sequence/render-sequence.mjs +468 -0
  14. package/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  15. package/vendor/archify/renderers/shared/cli.mjs +220 -0
  16. package/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  17. package/vendor/archify/renderers/shared/diagnostics.mjs +116 -0
  18. package/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  19. package/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  20. package/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  21. package/vendor/archify/renderers/shared/geometry.mjs +1334 -0
  22. package/vendor/archify/renderers/shared/i18n.mjs +594 -0
  23. package/vendor/archify/renderers/shared/layout-report.mjs +40 -0
  24. package/vendor/archify/renderers/shared/legend.mjs +217 -0
  25. package/vendor/archify/renderers/shared/output-path.mjs +321 -0
  26. package/vendor/archify/renderers/shared/repository-evidence.mjs +235 -0
  27. package/vendor/archify/renderers/shared/text-fit.mjs +49 -0
  28. package/vendor/archify/renderers/shared/utils.mjs +232 -0
  29. package/vendor/archify/renderers/shared/validator.mjs +86 -0
  30. package/vendor/archify/renderers/workflow/render-workflow.mjs +749 -0
@@ -0,0 +1,570 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
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';
10
+ import {
11
+ asArray,
12
+ isFinitePoint,
13
+ rectsOverlap,
14
+ cleanEndpointSideProblems,
15
+ cleanFlowProblems,
16
+ cleanCrossingProblems,
17
+ cleanAmbiguousCorridorProblems,
18
+ cleanBorderRunProblems,
19
+ cleanRouteRhythmProblems,
20
+ cleanLabelRouteClearanceProblems,
21
+ suggestLabelObstacleFix,
22
+ suggestLabelPairFix,
23
+ anchor,
24
+ automaticPortSpread,
25
+ defaultFromSide,
26
+ defaultToSide,
27
+ chosenSide,
28
+ roundedPath,
29
+ routePointsValue,
30
+ labelPoint,
31
+ arrowClassMap,
32
+ variantAccent
33
+ } from '../shared/geometry.mjs';
34
+
35
+ const stateTextFit = {
36
+ sublabelPreferred: 7,
37
+ sublabelMinimum: 6,
38
+ tagPreferred: 7,
39
+ tagMinimum: 6,
40
+ };
41
+
42
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
43
+ const { diagram: lifecycle, template, outPath } = await loadDiagramWithBrandMarks({
44
+ rendererDir: __dirname,
45
+ diagramType: 'lifecycle',
46
+ defaultExample: 'agent-run.lifecycle.json'
47
+ });
48
+
49
+ const viewBox = lifecycle.meta?.viewBox || [980, 660];
50
+ const layout = {
51
+ phaseY: 126,
52
+ eventY: 278,
53
+ outcomeY: 450,
54
+ phaseW: 118,
55
+ phaseH: 62,
56
+ eventW: 126,
57
+ eventH: 58,
58
+ outcomeW: 118,
59
+ outcomeH: 58,
60
+ phaseXs: [94, 248, 402, 556, 710],
61
+ eventXs: [402, 556, 710],
62
+ outcomeXs: [402, 556, 710]
63
+ };
64
+
65
+ const typeClass = {
66
+ start: 'c-frontend',
67
+ active: 'c-backend',
68
+ waiting: 'c-cloud',
69
+ decision: 'c-security',
70
+ success: 'c-database',
71
+ failure: 'c-security',
72
+ neutral: 'c-external',
73
+ external: 'c-external'
74
+ };
75
+
76
+ const textClass = {
77
+ start: 't-frontend',
78
+ active: 't-backend',
79
+ waiting: 't-cloud',
80
+ decision: 't-security',
81
+ success: 't-database',
82
+ failure: 't-security',
83
+ neutral: 't-muted',
84
+ external: 't-muted'
85
+ };
86
+
87
+ function legendY() {
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;
96
+ }
97
+
98
+ // Lane semantics are fixed: lane id "main" maps to the top phase band, lane id
99
+ // "terminal" maps to the bottom outcome band, and every other lane shares the
100
+ // middle event band (separated visually via yOffset).
101
+ function bandFor(lane) {
102
+ if (lane === 'main') return 'phase';
103
+ if (lane === 'terminal') return 'outcome';
104
+ return 'event';
105
+ }
106
+
107
+ function measureState(state) {
108
+ const isPhase = bandFor(state.lane) === 'phase';
109
+ const isOutcome = bandFor(state.lane) === 'outcome';
110
+ const width = state.width || (isPhase ? layout.phaseW : isOutcome ? layout.outcomeW : layout.eventW);
111
+ const height = state.height || (isPhase ? layout.phaseH : isOutcome ? layout.outcomeH : layout.eventH);
112
+ const xs = isPhase ? layout.phaseXs : isOutcome ? layout.outcomeXs : layout.eventXs;
113
+ const cx = xs[state.col] ?? xs[xs.length - 1];
114
+ const y = (
115
+ isPhase ? layout.phaseY :
116
+ isOutcome ? layout.outcomeY :
117
+ layout.eventY
118
+ ) + (state.yOffset || 0);
119
+ return {
120
+ ...state,
121
+ width,
122
+ height,
123
+ x: cx - width / 2,
124
+ y,
125
+ cx,
126
+ cy: y + height / 2
127
+ };
128
+ }
129
+
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]));
132
+ const stateSteps = new Map();
133
+ for (const [index, transition] of asArray(lifecycle.transitions).entries()) {
134
+ if (!stateSteps.has(transition.from)) stateSteps.set(transition.from, index);
135
+ if (!stateSteps.has(transition.to)) stateSteps.set(transition.to, index + 1);
136
+ }
137
+ for (const [index, state] of asArray(lifecycle.states).entries()) {
138
+ if (!stateSteps.has(state.id)) stateSteps.set(state.id, index);
139
+ }
140
+
141
+ function validateLifecycle() {
142
+ const problems = [];
143
+ if (lifecycle.schema_version !== 1) problems.push('Lifecycle files must set "schema_version": 1.');
144
+ if (lifecycle.diagram_type !== 'lifecycle') problems.push('Lifecycle files must set "diagram_type": "lifecycle".');
145
+ if (!lifecycle.meta?.title) problems.push('Lifecycle files must include meta.title.');
146
+ if (!Array.isArray(lifecycle.lanes) || lifecycle.lanes.length < 1) problems.push('Lifecycle diagrams need at least one lane.');
147
+ if (!Array.isArray(lifecycle.states) || lifecycle.states.length < 2) problems.push('Lifecycle diagrams need at least two states.');
148
+ if (!Array.isArray(lifecycle.transitions)) problems.push('Lifecycle diagrams must include a transitions array.');
149
+ if (lifecycle.cards !== undefined && !Array.isArray(lifecycle.cards)) problems.push('Lifecycle "cards" must be an array.');
150
+ if (states.size !== asArray(lifecycle.states).length) problems.push('State ids must be unique.');
151
+
152
+ // The three bands are fixed at y=112/264/436. Preserve the original
153
+ // outcome/legend reserve even though measured legend rows now sit lower.
154
+ if (lifecycleAreaBottom() + 4 < 448) {
155
+ problems.push(`viewBox height ${viewBox[1]} is too short for the fixed band layout — set meta.viewBox[1] to at least 566.`);
156
+ }
157
+
158
+ const laneIds = new Set(asArray(lifecycle.lanes).map((lane) => lane.id));
159
+ if (laneIds.size !== asArray(lifecycle.lanes).length) problems.push('Lane ids must be unique.');
160
+ if (!laneIds.has('main')) {
161
+ problems.push('Lifecycle diagrams need a lane with id "main" (the phase rail). Lane ids "main" and "terminal" are reserved: "main" maps to the top phase band, "terminal" to the bottom outcome band, and all other lanes share the middle event band.');
162
+ }
163
+
164
+ for (const state of states.values()) {
165
+ if (!laneIds.has(state.lane)) {
166
+ problems.push(`State "${state.id}" uses unknown lane "${state.lane}".`);
167
+ continue;
168
+ }
169
+ const band = bandFor(state.lane);
170
+ const maxCol = band === 'phase'
171
+ ? layout.phaseXs.length
172
+ : band === 'outcome'
173
+ ? layout.outcomeXs.length
174
+ : layout.eventXs.length;
175
+ if (!Number.isInteger(state.col) || state.col < 0 || state.col >= maxCol) {
176
+ problems.push(`State "${state.id}" uses invalid column ${state.col} — the ${band} band has integer columns 0..${maxCol - 1}.`);
177
+ continue;
178
+ }
179
+ if (!isFinitePoint(state.x, state.y, state.cx, state.cy)) {
180
+ problems.push(`State "${state.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`);
181
+ continue;
182
+ }
183
+ if (state.x < 32 || state.x + state.width > viewBox[0] - 32) {
184
+ problems.push(`State "${state.id}" exceeds the horizontal bounds of the diagram — reduce state.width or increase meta.viewBox[0].`);
185
+ }
186
+ if (state.y < 64 || state.y + state.height > lifecycleAreaBottom()) {
187
+ problems.push(`State "${state.id}" exceeds the vertical lifecycle area — keep y between 64 and ${lifecycleAreaBottom()} (adjust yOffset or increase meta.viewBox[1]).`);
188
+ }
189
+ const estLabelW = textUnits(state.label) * 6.2;
190
+ if (estLabelW > state.width + 6) {
191
+ 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.`);
192
+ }
193
+ const brandRailProblem = brandTopRailProblem(state, state.width, 8, 'State');
194
+ if (brandRailProblem) problems.push(brandRailProblem);
195
+ // sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
196
+ // handles the ordinary case, this rejects what it cannot rescue.
197
+ const availableTextW = availableNodeTextWidth(state.width);
198
+ for (const [field, value, minimum] of [
199
+ ['Sublabel', state.sublabel, stateTextFit.sublabelMinimum],
200
+ ['Tag', state.tag, stateTextFit.tagMinimum],
201
+ ]) {
202
+ if (!value) continue;
203
+ const minimumW = minimumNodeTextWidth(value, minimum);
204
+ if (minimumW > availableTextW) {
205
+ 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.`);
206
+ }
207
+ }
208
+ }
209
+
210
+ // All non-main/non-terminal lanes share the same y band, so the overlap
211
+ // check must run across lanes — not per-lane.
212
+ const allStates = [...states.values()];
213
+ for (let i = 0; i < allStates.length; i += 1) {
214
+ for (let j = i + 1; j < allStates.length; j += 1) {
215
+ if (rectsOverlap(allStates[i], allStates[j], 10)) {
216
+ problems.push(`States "${allStates[i].id}" and "${allStates[j].id}" are less than 10px apart — move one to another col or separate them with yOffset (lanes other than "main"/"terminal" share one band).`);
217
+ }
218
+ }
219
+ }
220
+
221
+ for (const transition of asArray(lifecycle.transitions)) {
222
+ if (!states.has(transition.from)) problems.push(`Transition "${transition.label || transition.from}" references unknown source "${transition.from}".`);
223
+ if (!states.has(transition.to)) problems.push(`Transition "${transition.label || transition.to}" references unknown target "${transition.to}".`);
224
+ if (states.has(transition.from) && states.has(transition.to)) {
225
+ const routed = pathFor(transition);
226
+ const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
227
+ const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
228
+ if (distance < 32) problems.push(`Transition "${transition.label || `${transition.from}->${transition.to}`}" is too short (${Math.round(distance)}px; minimum 32px) — route it through a channel or drop its label.`);
229
+ }
230
+ }
231
+
232
+ // Authored via points are authoritative in schema v1, including under a
233
+ // quality profile. Preserve and render them exactly: applying the endpoint
234
+ // gate would either reject an existing typed input or require silently
235
+ // falsifying its geometry. Automatic routes still receive the side gate.
236
+ problems.push(...cleanEndpointSideProblems({
237
+ relations: lifecycle.transitions,
238
+ endpointIds: new Set(states.keys()),
239
+ pathFor,
240
+ diagramType: 'lifecycle',
241
+ relationCollection: 'transitions',
242
+ fromSideFor: (transition) => transitionSides(transition).fromSide,
243
+ toSideFor: (transition) => transitionSides(transition).toSide,
244
+ shouldCheckRelation: (transition) => !Array.isArray(transition.via),
245
+ routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross state borders perpendicularly',
246
+ }));
247
+ problems.push(...cleanFlowProblems({
248
+ relations: lifecycle.transitions,
249
+ endpointIds: new Set(states.keys()),
250
+ obstacles: states.values(),
251
+ pathFor,
252
+ diagramType: 'lifecycle',
253
+ relationCollection: 'transitions',
254
+ obstacleKind: 'state',
255
+ profile: lifecycle.meta?.quality_profile,
256
+ routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the state with col/yOffset'
257
+ }));
258
+ problems.push(...cleanCrossingProblems({
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 the transitions use separate lifecycle corridors'
266
+ }));
267
+ problems.push(...cleanAmbiguousCorridorProblems({
268
+ relations: lifecycle.transitions,
269
+ endpointIds: new Set(states.keys()),
270
+ pathFor,
271
+ diagramType: 'lifecycle',
272
+ relationCollection: 'transitions',
273
+ profile: lifecycle.meta?.quality_profile,
274
+ routeHint: 'adjust route/via or channelX/channelY so unrelated transitions do not visually merge'
275
+ }));
276
+ // Lifecycle bands are dashed reading guides, not closed containers. Keep the
277
+ // shared contract wired with an explicit empty frame set so future typed
278
+ // lifecycle containers cannot accidentally inherit presentation geometry.
279
+ problems.push(...cleanBorderRunProblems({
280
+ relations: lifecycle.transitions,
281
+ endpointIds: new Set(states.keys()),
282
+ frames: [],
283
+ pathFor,
284
+ diagramType: 'lifecycle',
285
+ relationCollection: 'transitions',
286
+ profile: lifecycle.meta?.quality_profile
287
+ }));
288
+ problems.push(...cleanRouteRhythmProblems({
289
+ relations: lifecycle.transitions,
290
+ endpointIds: new Set(states.keys()),
291
+ pathFor,
292
+ diagramType: 'lifecycle',
293
+ relationCollection: 'transitions',
294
+ profile: lifecycle.meta?.quality_profile,
295
+ routeHint: 'move route/via or channel coordinates so each lifecycle turn has a readable run-up'
296
+ }));
297
+
298
+ const labelRects = [];
299
+ for (const [transitionIndex, transition] of asArray(lifecycle.transitions).entries()) {
300
+ if (!transition.label || !states.has(transition.from) || !states.has(transition.to)) continue;
301
+ const [lx, ly] = labelPoint(transition, pathFor(transition).points);
302
+ const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
303
+ const width = Math.max(32, longestLine * 4.9 + 12);
304
+ const height = transition.note ? 27 : 16;
305
+ labelRects.push({ relation: transition, relationIndex: transitionIndex, label: transition.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
306
+ }
307
+ for (const rect of labelRects) {
308
+ for (const state of states.values()) {
309
+ if (rectsOverlap(rect, state, -2)) {
310
+ problems.push(`Label "${rect.label}" overlaps state "${state.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, state, 'state')}`);
311
+ }
312
+ }
313
+ }
314
+ for (let i = 0; i < labelRects.length; i += 1) {
315
+ for (let j = i + 1; j < labelRects.length; j += 1) {
316
+ if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
317
+ problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
318
+ }
319
+ }
320
+ }
321
+ problems.push(...cleanLabelRouteClearanceProblems({
322
+ relations: lifecycle.transitions,
323
+ labels: labelRects,
324
+ endpointIds: new Set(states.keys()),
325
+ pathFor,
326
+ diagramType: 'lifecycle',
327
+ relationCollection: 'transitions',
328
+ profile: lifecycle.meta?.quality_profile,
329
+ }));
330
+
331
+ if (problems.length) {
332
+ throwDiagnosticProblems('Lifecycle layout validation failed', problems, {
333
+ subject: { diagramType: 'lifecycle' },
334
+ });
335
+ }
336
+ }
337
+
338
+ function routeVia(transition, from, to, start, end, fromSide, toSide) {
339
+ if (transition.via) return transition.via;
340
+ switch (transition.route || 'auto') {
341
+ case 'straight':
342
+ return [];
343
+ case 'drop': {
344
+ const y = transition.channelY ?? (start[1] + end[1]) / 2;
345
+ return [[start[0], y], [end[0], y]];
346
+ }
347
+ case 'bottom-channel': {
348
+ const y = transition.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 34;
349
+ return [[start[0], y], [end[0], y]];
350
+ }
351
+ case 'top-channel': {
352
+ const y = transition.channelY ?? Math.min(from.y, to.y) - 28;
353
+ return [[start[0], y], [end[0], y]];
354
+ }
355
+ case 'right-channel': {
356
+ const x = transition.channelX ?? Math.max(from.x + from.width, to.x + to.width) + 36;
357
+ return [[x, start[1]], [x, end[1]]];
358
+ }
359
+ case 'left-channel': {
360
+ const x = transition.channelX ?? Math.min(from.x, to.x) - 36;
361
+ return [[x, start[1]], [x, end[1]]];
362
+ }
363
+ case 'auto':
364
+ default: {
365
+ if (start[0] === end[0] || start[1] === end[1]) return [];
366
+ const fromVertical = fromSide === 'top' || fromSide === 'bottom';
367
+ const toVertical = toSide === 'top' || toSide === 'bottom';
368
+ if (fromVertical !== toVertical) {
369
+ return [fromVertical ? [start[0], end[1]] : [end[0], start[1]]];
370
+ }
371
+ if (fromVertical) {
372
+ const y = transition.channelY ?? (start[1] + end[1]) / 2;
373
+ return [[start[0], y], [end[0], y]];
374
+ }
375
+ const x = transition.channelX ?? (start[0] + end[0]) / 2;
376
+ return [[x, start[1]], [x, end[1]]];
377
+ }
378
+ }
379
+ }
380
+
381
+ const pathCache = new Map();
382
+
383
+ function transitionSides(transition) {
384
+ const from = states.get(transition.from);
385
+ const to = states.get(transition.to);
386
+ return {
387
+ fromSide: chosenSide(transition.fromSide, defaultFromSide(from, to)),
388
+ toSide: chosenSide(transition.toSide, defaultToSide(from, to)),
389
+ };
390
+ }
391
+
392
+ const automaticPorts = automaticPortSpread(lifecycle.transitions, states, {
393
+ sideFor: (transition, endpoint) => transitionSides(transition)[endpoint === 'source' ? 'fromSide' : 'toSide'],
394
+ });
395
+
396
+ function pathFor(transition) {
397
+ if (pathCache.has(transition)) return pathCache.get(transition);
398
+ const from = states.get(transition.from);
399
+ const to = states.get(transition.to);
400
+ const ports = automaticPorts.get(transition);
401
+ const { fromSide, toSide } = transitionSides(transition);
402
+ const start = ports?.from || anchor(from, fromSide);
403
+ const end = ports?.to || anchor(to, toSide);
404
+ let via = routeVia(transition, from, to, start, end, fromSide, toSide);
405
+ if (ports && !via.length && Math.abs(start[0] - end[0]) >= 4 && Math.abs(start[1] - end[1]) >= 4) {
406
+ const midX = (start[0] + end[0]) / 2;
407
+ via = [[midX, start[1]], [midX, end[1]]];
408
+ }
409
+ const points = [start, ...via, end];
410
+ const routed = {
411
+ d: roundedPath(points, transition.cornerRadius ?? 10),
412
+ points
413
+ };
414
+ pathCache.set(transition, routed);
415
+ return routed;
416
+ }
417
+
418
+ function bandTitles() {
419
+ const lanes = asArray(lifecycle.lanes);
420
+ const mainLane = lanes.find((lane) => lane.id === 'main');
421
+ const terminalLane = lanes.find((lane) => lane.id === 'terminal');
422
+ const eventLanes = lanes.filter((lane) => lane.id !== 'main' && lane.id !== 'terminal');
423
+ return [
424
+ mainLane?.label || 'Lifecycle phases',
425
+ eventLanes.length ? eventLanes.map((lane) => lane.label).join(' + ') : 'Interruptions + recovery',
426
+ terminalLane?.label || 'Outcomes'
427
+ ];
428
+ }
429
+
430
+ function renderBands() {
431
+ const right = viewBox[0] - 72;
432
+ const titles = bandTitles();
433
+ return ` <path d="M 72 112 L ${right} 112" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
434
+ <text x="72" y="100" class="t-dim" font-size="10" font-weight="600">01 / ${esc(titles[0])}</text>
435
+ <path d="M 72 264 L ${right} 264" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
436
+ <text x="72" y="252" class="t-dim" font-size="10" font-weight="600">02 / ${esc(titles[1])}</text>
437
+ <path d="M 72 436 L ${right} 436" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
438
+ <text x="72" y="424" class="t-dim" font-size="10" font-weight="600">03 / ${esc(titles[2])}</text>`;
439
+ }
440
+
441
+ function renderState(state) {
442
+ const fill = typeClass[state.type] || typeClass.neutral;
443
+ const accent = textClass[state.type] || 't-muted';
444
+ const hasSub = state.sublabel != null && state.sublabel !== '';
445
+ const sub = hasSub
446
+ ? `\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>`
447
+ : '';
448
+ const tag = state.tag
449
+ ? `\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>`
450
+ : '';
451
+ const hasBrand = Boolean(brandMarkFor(state));
452
+ const step = state.step
453
+ ? `\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>`
454
+ : '';
455
+ const brand = renderBrandMark(state, { x: state.x + state.width - 22, y: state.y + 6 });
456
+ const labelFontSize = fittedNodeFontSize(state.label, brandLabelFitWidth(state, state.width), 10, 8);
457
+ const passport = {
458
+ kind: state.type,
459
+ sublabel: state.sublabel,
460
+ tag: state.tag,
461
+ context: laneLabels.get(state.lane) || i18nText(lifecycle.meta.locale, 'node.context.lifecycle'),
462
+ ...brandMetadataFor(state),
463
+ };
464
+ return ` <g ${focusNodeAttrs(state.id, state.label, passport, lifecycle.meta.locale)}>
465
+ ${focusNodeTitle(state.label, passport)}
466
+ <rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="c-mask"/>
467
+ <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"/>
468
+ ${renderSemanticSigil(state.type, { x: hasBrand ? state.x + 6 : state.x + state.width - 17, y: state.y + 6 })}${brand ? `\n ${brand}` : ''}${step}
469
+ <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}
470
+ </g>`;
471
+ }
472
+
473
+ function renderTransitionPath(transition, index) {
474
+ const [cls, marker] = arrowClassMap[transition.variant || 'default'] || arrowClassMap.default;
475
+ const routed = pathFor(transition);
476
+ const strokeWidth = transition.width || (transition.variant === 'emphasis' ? 2 : 1.1);
477
+ 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})"/>`;
478
+ }
479
+
480
+ function renderTransitionLabel(transition, index) {
481
+ if (!transition.label) return '';
482
+ const routed = pathFor(transition);
483
+ const [lx, ly] = labelPoint(transition, routed.points);
484
+ const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
485
+ const labelW = Math.max(32, longestLine * 4.9 + 12);
486
+ const labelH = transition.note ? 27 : 16;
487
+ const note = transition.note
488
+ ? `\n <text data-detail="fine" x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(transition.note)}</text>`
489
+ : '';
490
+ return ` <g data-detail="context" ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)}>
491
+ <rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
492
+ <text x="${lx}" y="${ly}" class="${variantAccent(transition.variant)}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>${note}
493
+ </g>`;
494
+ }
495
+
496
+ const LEGEND_CATALOG = [
497
+ 'start',
498
+ 'active',
499
+ 'waiting',
500
+ 'decision',
501
+ 'success',
502
+ 'failure',
503
+ 'neutral',
504
+ 'external',
505
+ ].map((kind) => ({ kind, label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`) }));
506
+
507
+ function renderLegend() {
508
+ const presentKinds = new Set([...states.values()].map((state) => state.type));
509
+ const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
510
+ return renderResolvedLegend({
511
+ entries,
512
+ locale: lifecycle.meta.locale,
513
+ layout: {
514
+ x: 40,
515
+ baselineY: legendY(),
516
+ width: viewBox[0] - 80,
517
+ minTitleY: lifecycleAreaBottom() + 8,
518
+ unfit: lifecycle.meta?.legend === undefined ? 'hide' : 'error',
519
+ diagramType: 'lifecycle',
520
+ },
521
+ 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"/>`,
522
+ });
523
+ }
524
+
525
+ function renderLifecycleRail() {
526
+ const mainCols = [...states.values()]
527
+ .filter((state) => bandFor(state.lane) === 'phase')
528
+ .map((state) => state.col);
529
+ if (!mainCols.length) return '';
530
+ const railEnd = layout.phaseXs[Math.max(...mainCols)] + 38;
531
+ return ` <path d="M 154 ${layout.phaseY + 31} L ${railEnd} ${layout.phaseY + 31}" class="a-emphasis" stroke-width="2.2" marker-end="url(#arrowhead-emphasis)"/>`;
532
+ }
533
+
534
+ function renderSvg() {
535
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(lifecycle.meta, 'lifecycle diagram')}>
536
+ ${svgAccessibleText(lifecycle.meta, 'lifecycle')}
537
+ ${renderDefinitions()}
538
+
539
+ <!-- Background Grid -->
540
+ <rect width="100%" height="100%" fill="url(#grid)" />
541
+
542
+ <!-- Lifecycle bands -->
543
+ ${renderBands()}
544
+
545
+ <!-- Primary lifecycle rail -->
546
+ ${renderLifecycleRail()}
547
+
548
+ <!-- Transition paths -->
549
+ ${asArray(lifecycle.transitions).map(renderTransitionPath).join('\n')}
550
+
551
+ <!-- States -->
552
+ ${[...states.values()].map(renderState).join('\n\n')}
553
+
554
+ <!-- Transition labels -->
555
+ ${asArray(lifecycle.transitions).map(renderTransitionLabel).join('\n')}
556
+
557
+ <!-- Legend -->
558
+ ${renderLegend()}
559
+ </svg>`;
560
+ }
561
+
562
+ validateLifecycle();
563
+ writeDiagram({
564
+ outPath,
565
+ template,
566
+ diagramType: 'lifecycle',
567
+ meta: lifecycle.meta,
568
+ svg: renderSvg(),
569
+ cards: lifecycle.cards,
570
+ });