@diagrammo/dgmo 0.8.21 → 0.8.22

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 (93) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +143 -93
  4. package/dist/editor.cjs +17 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +17 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +12 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +12 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +19997 -14886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +331 -8
  15. package/dist/index.d.ts +331 -8
  16. package/dist/index.js +19984 -14889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-sitemap.md +18 -1
  19. package/docs/guide/chart-tech-radar.md +219 -0
  20. package/docs/guide/registry.json +1 -0
  21. package/docs/language-reference.md +116 -6
  22. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  23. package/gallery/fixtures/c4-full.dgmo +2 -2
  24. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  25. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  26. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  27. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  28. package/gallery/fixtures/gantt-full.dgmo +2 -2
  29. package/gallery/fixtures/gantt.dgmo +2 -2
  30. package/gallery/fixtures/infra-full.dgmo +2 -2
  31. package/gallery/fixtures/infra.dgmo +1 -1
  32. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  33. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  34. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  35. package/gallery/fixtures/tech-radar.dgmo +36 -0
  36. package/gallery/fixtures/timeline.dgmo +1 -1
  37. package/package.json +1 -1
  38. package/src/boxes-and-lines/layout.ts +309 -33
  39. package/src/boxes-and-lines/parser.ts +86 -10
  40. package/src/boxes-and-lines/renderer.ts +250 -91
  41. package/src/boxes-and-lines/types.ts +1 -1
  42. package/src/c4/layout.ts +8 -8
  43. package/src/c4/parser.ts +35 -2
  44. package/src/c4/renderer.ts +19 -3
  45. package/src/c4/types.ts +1 -0
  46. package/src/chart.ts +14 -7
  47. package/src/completion.ts +227 -0
  48. package/src/cycle/layout.ts +732 -0
  49. package/src/cycle/parser.ts +352 -0
  50. package/src/cycle/renderer.ts +539 -0
  51. package/src/cycle/types.ts +77 -0
  52. package/src/d3.ts +87 -8
  53. package/src/dgmo-router.ts +9 -0
  54. package/src/echarts.ts +7 -4
  55. package/src/editor/dgmo.grammar +5 -1
  56. package/src/editor/dgmo.grammar.js +1 -1
  57. package/src/editor/keywords.ts +14 -0
  58. package/src/gantt/parser.ts +2 -8
  59. package/src/graph/flowchart-parser.ts +15 -21
  60. package/src/graph/state-parser.ts +5 -10
  61. package/src/index.ts +50 -0
  62. package/src/infra/layout.ts +218 -74
  63. package/src/infra/parser.ts +30 -6
  64. package/src/infra/renderer.ts +14 -8
  65. package/src/infra/types.ts +10 -3
  66. package/src/journey-map/layout.ts +386 -0
  67. package/src/journey-map/parser.ts +540 -0
  68. package/src/journey-map/renderer.ts +1456 -0
  69. package/src/journey-map/types.ts +47 -0
  70. package/src/kanban/parser.ts +3 -10
  71. package/src/kanban/renderer.ts +31 -15
  72. package/src/mindmap/parser.ts +12 -18
  73. package/src/mindmap/renderer.ts +14 -13
  74. package/src/mindmap/text-wrap.ts +22 -12
  75. package/src/mindmap/types.ts +2 -2
  76. package/src/org/parser.ts +2 -6
  77. package/src/sequence/renderer.ts +144 -38
  78. package/src/sharing.ts +1 -0
  79. package/src/sitemap/layout.ts +21 -6
  80. package/src/sitemap/parser.ts +26 -17
  81. package/src/sitemap/renderer.ts +34 -0
  82. package/src/sitemap/types.ts +1 -0
  83. package/src/tech-radar/index.ts +14 -0
  84. package/src/tech-radar/interactive.ts +1058 -0
  85. package/src/tech-radar/layout.ts +190 -0
  86. package/src/tech-radar/parser.ts +385 -0
  87. package/src/tech-radar/renderer.ts +1159 -0
  88. package/src/tech-radar/shared.ts +187 -0
  89. package/src/tech-radar/types.ts +81 -0
  90. package/src/utils/description-helpers.ts +33 -0
  91. package/src/utils/legend-layout.ts +3 -1
  92. package/src/utils/parsing.ts +46 -7
  93. package/src/utils/tag-groups.ts +46 -60
@@ -0,0 +1,47 @@
1
+ import type { DgmoError } from '../diagnostics';
2
+ import type { TagGroup } from '../utils/tag-groups';
3
+
4
+ export interface JourneyMapAnnotation {
5
+ type: 'pain' | 'opportunity' | 'thought';
6
+ text: string;
7
+ }
8
+
9
+ export interface JourneyMapStep {
10
+ id: string;
11
+ title: string;
12
+ score?: number;
13
+ emotionLabel?: string;
14
+ tags: Record<string, string>;
15
+ annotations: JourneyMapAnnotation[];
16
+ description?: string;
17
+ lineNumber: number;
18
+ endLineNumber: number;
19
+ }
20
+
21
+ export interface JourneyMapPhase {
22
+ id: string;
23
+ name: string;
24
+ steps: JourneyMapStep[];
25
+ lineNumber: number;
26
+ }
27
+
28
+ export interface JourneyMapPersona {
29
+ name: string;
30
+ description?: string;
31
+ color?: string;
32
+ lineNumber: number;
33
+ }
34
+
35
+ export interface ParsedJourneyMap {
36
+ type: 'journey-map';
37
+ title?: string;
38
+ titleLineNumber?: number;
39
+ persona?: JourneyMapPersona;
40
+ phases: JourneyMapPhase[];
41
+ /** Flat-mode steps (not inside any phase) */
42
+ steps: JourneyMapStep[];
43
+ tagGroups: TagGroup[];
44
+ options: Record<string, string>;
45
+ diagnostics: DgmoError[];
46
+ error: string | null;
47
+ }
@@ -387,8 +387,8 @@ function parseCardLine(
387
387
  lineNumber: number,
388
388
  counter: number,
389
389
  aliasMap: Map<string, string>,
390
- palette?: PaletteColors,
391
- diagnostics?: import('../diagnostics').DgmoError[]
390
+ _palette?: PaletteColors,
391
+ _diagnostics?: import('../diagnostics').DgmoError[]
392
392
  ): KanbanCard {
393
393
  // Split on first pipe: Title | tag: value, tag: value
394
394
  const pipeIdx = trimmed.indexOf('|');
@@ -402,13 +402,7 @@ function parseCardLine(
402
402
  rawTitle = trimmed;
403
403
  }
404
404
 
405
- // Extract optional color suffix from title
406
- const { label: title, color } = extractColor(
407
- rawTitle,
408
- palette,
409
- diagnostics,
410
- lineNumber
411
- );
405
+ const title = rawTitle;
412
406
 
413
407
  // Parse tags: comma-separated key: value pairs
414
408
  const tags: Record<string, string> = {};
@@ -431,6 +425,5 @@ function parseCardLine(
431
425
  details: [],
432
426
  lineNumber,
433
427
  endLineNumber: lineNumber,
434
- color,
435
428
  };
436
429
  }
@@ -499,17 +499,21 @@ export function renderKanban(
499
499
  .attr('fill', palette.text)
500
500
  .text(col.name);
501
501
 
502
- // WIP limit badge
503
- if (col.wipLimit != null) {
504
- const wipExceeded = col.cards.length > col.wipLimit;
505
- const badgeText = `${col.cards.length}/${col.wipLimit}`;
506
- const nameWidth = col.name.length * COLUMN_HEADER_FONT_SIZE * 0.65;
502
+ // Card count / WIP limit badge (right-aligned)
503
+ {
504
+ const wipExceeded =
505
+ col.wipLimit != null && col.cards.length > col.wipLimit;
506
+ const badgeText =
507
+ col.wipLimit != null
508
+ ? `${col.cards.length}/${col.wipLimit}`
509
+ : String(col.cards.length);
507
510
  g.append('text')
508
- .attr('x', colLayout.x + COLUMN_PADDING + nameWidth + 8)
511
+ .attr('x', colLayout.x + colLayout.width - COLUMN_PADDING)
509
512
  .attr(
510
513
  'y',
511
514
  colLayout.y + COLUMN_HEADER_HEIGHT / 2 + WIP_FONT_SIZE / 2 - 1
512
515
  )
516
+ .attr('text-anchor', 'end')
513
517
  .attr('font-size', WIP_FONT_SIZE)
514
518
  .attr('fill', wipExceeded ? palette.colors.red : palette.textMuted)
515
519
  .attr('font-weight', wipExceeded ? 'bold' : 'normal')
@@ -978,6 +982,26 @@ function renderSwimlaneBoard(
978
982
  .attr('font-weight', 'bold')
979
983
  .attr('fill', palette.text)
980
984
  .text(col.name);
985
+
986
+ // Card count (right-aligned)
987
+ const wipExceeded =
988
+ col.wipLimit != null && col.cards.length > col.wipLimit;
989
+ const badgeText =
990
+ col.wipLimit != null
991
+ ? `${col.cards.length}/${col.wipLimit}`
992
+ : String(col.cards.length);
993
+ headerG
994
+ .append('text')
995
+ .attr('x', colInfo.x + colInfo.width - COLUMN_PADDING)
996
+ .attr(
997
+ 'y',
998
+ grid.startY + COLUMN_HEADER_HEIGHT / 2 + WIP_FONT_SIZE / 2 - 1
999
+ )
1000
+ .attr('text-anchor', 'end')
1001
+ .attr('font-size', WIP_FONT_SIZE)
1002
+ .attr('fill', wipExceeded ? palette.colors.red : palette.textMuted)
1003
+ .attr('font-weight', wipExceeded ? 'bold' : 'normal')
1004
+ .text(badgeText);
981
1005
  }
982
1006
  }
983
1007
 
@@ -1031,7 +1055,7 @@ function renderSwimlaneBoard(
1031
1055
  .attr('fill', palette.textMuted)
1032
1056
  .text(`${lane.bucket.laneName} (${totalCards})`);
1033
1057
  } else {
1034
- // Expanded: name on first line, count on second
1058
+ // Expanded: name only (count omitted to match app view)
1035
1059
  headerG
1036
1060
  .append('text')
1037
1061
  .attr('x', labelX)
@@ -1040,14 +1064,6 @@ function renderSwimlaneBoard(
1040
1064
  .attr('font-weight', 'bold')
1041
1065
  .attr('fill', lane.bucket.isFallback ? palette.textMuted : palette.text)
1042
1066
  .text(lane.bucket.laneName);
1043
-
1044
- headerG
1045
- .append('text')
1046
- .attr('x', labelX)
1047
- .attr('y', 36)
1048
- .attr('font-size', 10)
1049
- .attr('fill', palette.textMuted)
1050
- .text(`(${totalCards})`);
1051
1067
  }
1052
1068
 
1053
1069
  if (isLaneCollapsed) {
@@ -16,13 +16,12 @@ import {
16
16
  OPTION_NOCOLON_RE,
17
17
  } from '../utils/parsing';
18
18
  import type { MindmapNode, ParsedMindmap } from './types';
19
+ import { tryStripDescriptionKeyword } from '../utils/description-helpers';
19
20
 
20
21
  // ============================================================
21
22
  // Constants
22
23
  // ============================================================
23
24
 
24
- const DESCRIPTION_RE = /^description:\s*(.*)$/i;
25
-
26
25
  /** Known mindmap options (key-value). */
27
26
  const KNOWN_OPTIONS = new Set(['active-tag']);
28
27
 
@@ -111,8 +110,8 @@ export function parseMindmap(
111
110
  return fail(lineNumber, msg);
112
111
  }
113
112
  if (firstLine.title) {
114
- // Title IS the root: extract color from title
115
- const { label, color } = extractColor(firstLine.title, palette);
113
+ // Title IS the root
114
+ const label = firstLine.title;
116
115
  result.title = label;
117
116
  result.titleLineNumber = lineNumber;
118
117
 
@@ -124,7 +123,6 @@ export function parseMindmap(
124
123
  children: [],
125
124
  parentId: null,
126
125
  lineNumber,
127
- color,
128
126
  };
129
127
  result.roots.push(titleRoot);
130
128
  // Push title root onto indent stack at indent -1 so all indent-0 lines become children
@@ -208,14 +206,14 @@ export function parseMindmap(
208
206
 
209
207
  const indent = measureIndent(line);
210
208
 
211
- // Check for indented `description: text` metadata
209
+ // Check for indented `description: text` or `description text` metadata
212
210
  if (indent > 0) {
213
- const descMatch = trimmed.match(DESCRIPTION_RE);
214
- if (descMatch) {
211
+ const descResult = tryStripDescriptionKeyword(trimmed);
212
+ if (descResult.isKeyword) {
215
213
  // Find parent node from indent stack
216
214
  const parent = findMetadataParent(indent, indentStack);
217
215
  if (parent) {
218
- const descValue = descMatch[1].trim();
216
+ const descValue = descResult.text.trim();
219
217
  if (!descValue) {
220
218
  // Empty description: silently skip
221
219
  continue;
@@ -228,10 +226,8 @@ export function parseMindmap(
228
226
  );
229
227
  continue;
230
228
  }
231
- // Only set if pipe description didn't already set it
232
- if (parent.description === undefined) {
233
- parent.description = descValue;
234
- }
229
+ if (!parent.description) parent.description = [];
230
+ parent.description.push(descValue);
235
231
  continue;
236
232
  }
237
233
  }
@@ -300,19 +296,18 @@ function parseNodeLine(
300
296
  warnFn: (line: number, msg: string) => void
301
297
  ): MindmapNode {
302
298
  const segments = trimmed.split('|').map((s) => s.trim());
303
- const rawLabel = segments[0];
304
- const { label, color } = extractColor(rawLabel, palette);
299
+ const label = segments[0];
305
300
 
306
301
  const metadata = parsePipeMetadata(segments, aliasMap, () =>
307
302
  warnFn(lineNumber, MULTIPLE_PIPE_ERROR)
308
303
  );
309
304
 
310
305
  // Extract description from pipe metadata as a dedicated field
311
- let description: string | undefined;
306
+ let description: string[] | undefined;
312
307
  if ('description' in metadata) {
313
308
  const descVal = metadata['description'].trim();
314
309
  if (descVal) {
315
- description = descVal;
310
+ description = [descVal];
316
311
  }
317
312
  delete metadata['description'];
318
313
  }
@@ -332,7 +327,6 @@ function parseNodeLine(
332
327
  children: [],
333
328
  parentId: null,
334
329
  lineNumber,
335
- color,
336
330
  collapsed,
337
331
  };
338
332
  }
@@ -15,6 +15,8 @@ import type { MindmapLayoutResult } from './types';
15
15
  import { parseMindmap } from './parser';
16
16
  import { layoutMindmap } from './layout';
17
17
  import { computeNodeText } from './text-wrap';
18
+ import { renderInlineText } from '../utils/inline-markdown';
19
+ import { preprocessDescriptionLine } from '../utils/description-helpers';
18
20
  import { renderLegendD3 } from '../utils/legend-d3';
19
21
  import type { LegendConfig, LegendState } from '../utils/legend-types';
20
22
  import { LEGEND_HEIGHT, LEGEND_GROUP_GAP } from '../utils/legend-constants';
@@ -419,31 +421,30 @@ export function renderMindmap(
419
421
  .attr('stroke-opacity', 0.3)
420
422
  .attr('stroke-width', 1);
421
423
 
422
- // Description text
424
+ // Description text (with inline markdown + preprocessing)
423
425
  if (descLines.length <= 1) {
424
426
  const descY = separatorY + 4 + descFontSize;
425
- nodeG
427
+ const processed = preprocessDescriptionLine(descLines[0]);
428
+ const textEl = nodeG
426
429
  .append('text')
427
430
  .attr('x', centerX)
428
431
  .attr('y', descY)
429
432
  .attr('text-anchor', 'middle')
430
433
  .attr('font-size', descFontSize)
431
- .attr('fill', palette.textMuted)
432
- .text(descLines[0]);
434
+ .attr('fill', palette.textMuted);
435
+ renderInlineText(textEl, processed, palette, descFontSize);
433
436
  } else {
434
437
  const descStartY = separatorY + 4 + descFontSize;
435
- const descTextEl = nodeG
436
- .append('text')
437
- .attr('x', centerX)
438
- .attr('text-anchor', 'middle')
439
- .attr('font-size', descFontSize)
440
- .attr('fill', palette.textMuted);
441
438
  for (let i = 0; i < descLines.length; i++) {
442
- descTextEl
443
- .append('tspan')
439
+ const processed = preprocessDescriptionLine(descLines[i]);
440
+ const textEl = nodeG
441
+ .append('text')
444
442
  .attr('x', centerX)
445
443
  .attr('y', descStartY + i * DESC_LINE_HEIGHT)
446
- .text(descLines[i]);
444
+ .attr('text-anchor', 'middle')
445
+ .attr('font-size', descFontSize)
446
+ .attr('fill', palette.textMuted);
447
+ renderInlineText(textEl, processed, palette, descFontSize);
447
448
  }
448
449
  }
449
450
  }
@@ -6,6 +6,8 @@
6
6
  // multiple lines. Used by both layout (for sizing) and renderer
7
7
  // (for drawing). Ensures both agree on line breaks and font size.
8
8
 
9
+ import { preprocessDescriptionLine } from '../utils/description-helpers';
10
+
9
11
  const CHAR_WIDTH_RATIO = 0.58; // avg char width / fontSize for Helvetica
10
12
  const H_PAD = 16; // 8px padding each side
11
13
  const MAX_LABEL_LINES = 3;
@@ -170,7 +172,7 @@ interface NodeTextLayout {
170
172
  */
171
173
  export function computeNodeText(
172
174
  label: string,
173
- description: string | undefined,
175
+ description: string[] | undefined,
174
176
  depth: number,
175
177
  nodeWidth: number,
176
178
  hideDescriptions: boolean
@@ -184,18 +186,26 @@ export function computeNodeText(
184
186
  MAX_LABEL_LINES
185
187
  );
186
188
 
187
- let descLines: string[] = [];
189
+ const descLines: string[] = [];
188
190
  let descFontSize = DESC_FONT_SIZE;
189
- if (!hideDescriptions && description) {
190
- const descResult = wrapText(
191
- description,
192
- nodeWidth,
193
- DESC_FONT_SIZE,
194
- DESC_FONT_SIZE, // don't shrink descriptions
195
- MAX_DESC_LINES
196
- );
197
- descLines = descResult.lines;
198
- descFontSize = descResult.fontSize;
191
+ if (!hideDescriptions && description && description.length > 0) {
192
+ // Wrap each description line independently so bullets and line breaks are preserved.
193
+ // Cap total output lines at MAX_DESC_LINES across all input lines.
194
+ let remaining = MAX_DESC_LINES;
195
+ for (const rawLine of description) {
196
+ if (remaining <= 0) break;
197
+ const line = preprocessDescriptionLine(rawLine);
198
+ const lineResult = wrapText(
199
+ line,
200
+ nodeWidth,
201
+ DESC_FONT_SIZE,
202
+ DESC_FONT_SIZE,
203
+ remaining
204
+ );
205
+ descLines.push(...lineResult.lines);
206
+ remaining -= lineResult.lines.length;
207
+ descFontSize = lineResult.fontSize;
208
+ }
199
209
  }
200
210
 
201
211
  return {
@@ -4,7 +4,7 @@ import type { TagGroup } from '../utils/tag-groups.js';
4
4
  export interface MindmapNode {
5
5
  id: string;
6
6
  label: string;
7
- description?: string;
7
+ description?: string[];
8
8
  metadata: Record<string, string>;
9
9
  children: MindmapNode[];
10
10
  parentId: string | null;
@@ -26,7 +26,7 @@ export interface ParsedMindmap {
26
26
  export interface MindmapLayoutNode {
27
27
  id: string;
28
28
  label: string;
29
- description?: string;
29
+ description?: string[];
30
30
  metadata: Record<string, string>;
31
31
  lineNumber: number;
32
32
  color?: string;
package/src/org/parser.ts CHANGED
@@ -270,8 +270,7 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
270
270
 
271
271
  if (containerMatch) {
272
272
  // It's a container node
273
- const rawLabel = containerMatch[1].trim();
274
- const { label, color } = extractColor(rawLabel, palette);
273
+ const label = containerMatch[1].trim();
275
274
 
276
275
  containerCounter++;
277
276
  const node: OrgNode = {
@@ -282,7 +281,6 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
282
281
  parentId: null,
283
282
  isContainer: true,
284
283
  lineNumber,
285
- color,
286
284
  };
287
285
 
288
286
  attachNode(node, indent, indentStack, result);
@@ -378,8 +376,7 @@ function parseNodeLabel(
378
376
  // Check for single-line compact metadata: "Alice Park | role: Senior, location: NY"
379
377
  const segments = trimmed.split('|').map((s) => s.trim());
380
378
 
381
- const rawLabel = segments[0];
382
- const { label, color } = extractColor(rawLabel, palette);
379
+ const label = segments[0];
383
380
 
384
381
  const metadata = parsePipeMetadata(
385
382
  segments,
@@ -395,7 +392,6 @@ function parseNodeLabel(
395
392
  parentId: null,
396
393
  isContainer: false,
397
394
  lineNumber,
398
- color,
399
395
  };
400
396
  }
401
397
 
@@ -28,7 +28,11 @@ import type { ResolvedTagMap } from './tag-resolution';
28
28
  import { resolveActiveTagGroup } from '../utils/tag-groups';
29
29
  import { LEGEND_HEIGHT } from '../utils/legend-constants';
30
30
  import { renderLegendD3 } from '../utils/legend-d3';
31
- import type { LegendConfig, LegendState } from '../utils/legend-types';
31
+ import type {
32
+ LegendCallbacks,
33
+ LegendConfig,
34
+ LegendState,
35
+ } from '../utils/legend-types';
32
36
  import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
33
37
 
34
38
  // ============================================================
@@ -73,14 +77,26 @@ function wrapTextLines(text: string, maxChars: number): string[] {
73
77
  if (line.length <= maxChars) {
74
78
  wrapped.push(line);
75
79
  } else {
76
- const words = line.split(' ');
77
- let current = '';
80
+ // Preserve bullet prefix: keep "- " glued to the first content word
81
+ // so wrapping never produces a bare "-" line.
82
+ const bulletPrefix = line.startsWith('- ') ? '- ' : '';
83
+ const content = bulletPrefix ? line.slice(2) : line;
84
+ const words = content.split(' ');
85
+ let current = bulletPrefix;
78
86
  for (const word of words) {
79
- if (current && (current + ' ' + word).length > maxChars) {
87
+ const candidate = current ? current + ' ' + word : word;
88
+ if (
89
+ current &&
90
+ current !== bulletPrefix &&
91
+ candidate.length > maxChars
92
+ ) {
80
93
  wrapped.push(current);
81
94
  current = word;
82
95
  } else {
83
- current = current ? current + ' ' + word : word;
96
+ current =
97
+ current && current !== bulletPrefix
98
+ ? current + ' ' + word
99
+ : current + word;
84
100
  }
85
101
  }
86
102
  if (current) wrapped.push(current);
@@ -544,6 +560,10 @@ export interface SequenceRenderOptions {
544
560
  expandedNoteLines?: Set<number>; // keyed by note lineNumber; undefined = all expanded (CLI default)
545
561
  exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)
546
562
  activeTagGroup?: string | null; // Active tag group name for tag-driven recoloring; null = explicitly none
563
+ expandAllNotes?: boolean; // Whether the "Expand Notes" toggle is active
564
+ onExpandAllNotes?: (expand: boolean) => void; // Toggle all notes expanded/collapsed
565
+ controlsExpanded?: boolean; // Controls group expanded state (managed by React)
566
+ onToggleControlsExpand?: () => void; // Callback to toggle controls group
547
567
  }
548
568
 
549
569
  /**
@@ -1716,39 +1736,33 @@ export function renderSequenceDiagram(
1716
1736
  }
1717
1737
  }
1718
1738
 
1719
- // Render legend pills for tag groups
1720
- if (parsed.tagGroups.length > 0) {
1721
- const legendY = TOP_MARGIN + titleOffset;
1722
- // Resolve tag colors for legend entries
1723
- const resolvedGroups = parsed.tagGroups
1724
- .filter((tg) => tg.entries.length > 0)
1725
- .map((tg) => ({
1726
- name: tg.name,
1727
- entries: tg.entries.map((e) => ({
1728
- value: e.value,
1729
- color: e.color,
1730
- })),
1731
- }));
1732
- const legendConfig: LegendConfig = {
1733
- groups: resolvedGroups,
1734
- position: { placement: 'top-center', titleRelation: 'below-title' },
1735
- mode: 'fixed',
1736
- };
1737
- const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
1738
- const legendG = svg
1739
- .append('g')
1740
- .attr('class', 'sequence-legend')
1741
- .attr('transform', `translate(0,${legendY})`);
1742
- renderLegendD3(
1743
- legendG,
1744
- legendConfig,
1745
- legendState,
1746
- palette,
1747
- isDark,
1748
- undefined,
1749
- svgWidth
1750
- );
1751
- }
1739
+ // Collect all note line numbers (for controls group visibility + "all expanded" check)
1740
+ const allNoteLineNumbers: number[] = [];
1741
+ const collectNoteLines = (els: SequenceElement[]): void => {
1742
+ for (const el of els) {
1743
+ if (isSequenceNote(el)) {
1744
+ allNoteLineNumbers.push(el.lineNumber);
1745
+ } else if (isSequenceBlock(el)) {
1746
+ collectNoteLines(el.children);
1747
+ if ('elseChildren' in el) collectNoteLines(el.elseChildren);
1748
+ if ('branches' in el && Array.isArray(el.branches)) {
1749
+ for (const branch of el.branches) {
1750
+ collectNoteLines(branch.children);
1751
+ }
1752
+ }
1753
+ }
1754
+ }
1755
+ };
1756
+ collectNoteLines(elements);
1757
+
1758
+ // Show controls group only in interactive mode (expandedNoteLines defined)
1759
+ // when notes exist and collapse-notes is not disabled
1760
+ const showNotesControl =
1761
+ allNoteLineNumbers.length > 0 &&
1762
+ !collapseNotesDisabled &&
1763
+ expandedNoteLines !== undefined;
1764
+
1765
+ const hasTagGroups = parsed.tagGroups.length > 0;
1752
1766
 
1753
1767
  // Build set of collapsed group names for drill-bar rendering
1754
1768
  const collapsedGroupNames = new Set<string>();
@@ -2805,6 +2819,73 @@ export function renderSequenceDiagram(
2805
2819
  if (elements && elements.length > 0) {
2806
2820
  renderNoteElements(elements);
2807
2821
  }
2822
+
2823
+ // Render legend LAST so it sits on top of all other SVG elements
2824
+ // (group boxes, lifelines, participants, etc.) and can receive clicks.
2825
+ if (hasTagGroups || showNotesControl) {
2826
+ const controlsExpanded = options?.controlsExpanded ?? false;
2827
+
2828
+ const legendY = TOP_MARGIN + titleOffset;
2829
+ const resolvedGroups = parsed.tagGroups
2830
+ .filter((tg) => tg.entries.length > 0)
2831
+ .map((tg) => ({
2832
+ name: tg.name,
2833
+ entries: tg.entries.map((e) => ({
2834
+ value: e.value,
2835
+ color: e.color,
2836
+ })),
2837
+ }));
2838
+
2839
+ const allExpanded = showNotesControl && (options?.expandAllNotes ?? false);
2840
+
2841
+ const controlsGroup = showNotesControl
2842
+ ? {
2843
+ toggles: [
2844
+ {
2845
+ id: 'expand-all-notes',
2846
+ type: 'toggle' as const,
2847
+ label: 'Expand Notes',
2848
+ active: allExpanded,
2849
+ onToggle: () => {},
2850
+ },
2851
+ ],
2852
+ }
2853
+ : undefined;
2854
+
2855
+ const legendConfig: LegendConfig = {
2856
+ groups: resolvedGroups,
2857
+ position: { placement: 'top-center', titleRelation: 'below-title' },
2858
+ mode: 'fixed',
2859
+ controlsGroup,
2860
+ };
2861
+ const legendState: LegendState = {
2862
+ activeGroup: activeTagGroup ?? null,
2863
+ controlsExpanded,
2864
+ };
2865
+
2866
+ const legendCallbacks: LegendCallbacks = {
2867
+ onControlsExpand: () => {
2868
+ options?.onToggleControlsExpand?.();
2869
+ },
2870
+ onControlsToggle: (_toggleId: string, active: boolean) => {
2871
+ options?.onExpandAllNotes?.(active);
2872
+ },
2873
+ };
2874
+
2875
+ const legendG = svg
2876
+ .append('g')
2877
+ .attr('class', 'sequence-legend')
2878
+ .attr('transform', `translate(0,${legendY})`);
2879
+ renderLegendD3(
2880
+ legendG,
2881
+ legendConfig,
2882
+ legendState,
2883
+ palette,
2884
+ isDark,
2885
+ legendCallbacks,
2886
+ svgWidth
2887
+ );
2888
+ }
2808
2889
  }
2809
2890
 
2810
2891
  /**
@@ -2843,6 +2924,31 @@ export function buildNoteMessageMap(
2843
2924
  return map;
2844
2925
  }
2845
2926
 
2927
+ /**
2928
+ * Collect all note line numbers from a sequence diagram's elements.
2929
+ * Used by the app to compute the "expand all" set.
2930
+ */
2931
+ export function collectNoteLineNumbers(elements: SequenceElement[]): number[] {
2932
+ const result: number[] = [];
2933
+ const walk = (els: SequenceElement[]): void => {
2934
+ for (const el of els) {
2935
+ if (isSequenceNote(el)) {
2936
+ result.push(el.lineNumber);
2937
+ } else if (isSequenceBlock(el)) {
2938
+ walk(el.children);
2939
+ if (el.elseIfBranches) {
2940
+ for (const branch of el.elseIfBranches) {
2941
+ walk(branch.children);
2942
+ }
2943
+ }
2944
+ walk(el.elseChildren);
2945
+ }
2946
+ }
2947
+ };
2948
+ walk(elements);
2949
+ return result;
2950
+ }
2951
+
2846
2952
  function renderParticipant(
2847
2953
  svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2848
2954
  participant: SequenceParticipant,
package/src/sharing.ts CHANGED
@@ -32,6 +32,7 @@ export interface CompactViewState {
32
32
  io?: Record<string, number>; // instance overrides (infra)
33
33
  hd?: boolean; // hide descriptions (mindmap)
34
34
  cbd?: boolean; // color by depth (mindmap)
35
+ rq?: string; // radar quadrant focus (tech-radar position)
35
36
  }
36
37
 
37
38
  export interface DecodedDiagramUrl {