@diagrammo/dgmo 0.4.2 → 0.4.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 (60) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +28 -0
  2. package/.claude/skills/dgmo-generate/SKILL.md +1 -0
  3. package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
  4. package/.cursorrules +27 -2
  5. package/.github/copilot-instructions.md +36 -3
  6. package/.windsurfrules +27 -2
  7. package/README.md +12 -3
  8. package/dist/cli.cjs +197 -154
  9. package/dist/index.cjs +8647 -3447
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +503 -58
  12. package/dist/index.d.ts +503 -58
  13. package/dist/index.js +8379 -3200
  14. package/dist/index.js.map +1 -1
  15. package/docs/ai-integration.md +1 -1
  16. package/docs/language-reference.md +336 -17
  17. package/docs/migration-sequence-color-to-tags.md +98 -0
  18. package/package.json +1 -1
  19. package/src/c4/renderer.ts +1 -20
  20. package/src/class/renderer.ts +1 -11
  21. package/src/cli.ts +40 -0
  22. package/src/d3.ts +92 -2
  23. package/src/dgmo-router.ts +11 -0
  24. package/src/echarts.ts +74 -8
  25. package/src/er/parser.ts +29 -3
  26. package/src/er/renderer.ts +1 -15
  27. package/src/graph/flowchart-parser.ts +7 -30
  28. package/src/graph/flowchart-renderer.ts +62 -69
  29. package/src/graph/layout.ts +5 -0
  30. package/src/graph/state-parser.ts +388 -0
  31. package/src/graph/state-renderer.ts +496 -0
  32. package/src/graph/types.ts +4 -2
  33. package/src/index.ts +42 -1
  34. package/src/infra/compute.ts +1113 -0
  35. package/src/infra/layout.ts +578 -0
  36. package/src/infra/parser.ts +559 -0
  37. package/src/infra/renderer.ts +1553 -0
  38. package/src/infra/roles.ts +60 -0
  39. package/src/infra/serialize.ts +67 -0
  40. package/src/infra/types.ts +221 -0
  41. package/src/infra/validation.ts +192 -0
  42. package/src/initiative-status/layout.ts +56 -61
  43. package/src/initiative-status/renderer.ts +13 -13
  44. package/src/kanban/renderer.ts +1 -24
  45. package/src/org/layout.ts +28 -37
  46. package/src/org/parser.ts +16 -1
  47. package/src/org/renderer.ts +159 -121
  48. package/src/org/resolver.ts +90 -23
  49. package/src/palettes/color-utils.ts +30 -0
  50. package/src/render.ts +2 -0
  51. package/src/sequence/parser.ts +202 -42
  52. package/src/sequence/renderer.ts +576 -113
  53. package/src/sequence/tag-resolution.ts +163 -0
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/collapse.ts +187 -0
  56. package/src/sitemap/layout.ts +738 -0
  57. package/src/sitemap/parser.ts +489 -0
  58. package/src/sitemap/renderer.ts +774 -0
  59. package/src/sitemap/types.ts +42 -0
  60. package/src/utils/tag-groups.ts +119 -0
@@ -4,7 +4,7 @@
4
4
 
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import type { PaletteColors } from '../palettes';
7
- import { resolveColor } from '../colors';
7
+ import { mix } from '../palettes/color-utils';
8
8
  import {
9
9
  parseInlineMarkdown,
10
10
  truncateBareUrl,
@@ -13,6 +13,7 @@ import {
13
13
  export type { InlineSpan } from '../utils/inline-markdown';
14
14
  export { parseInlineMarkdown, truncateBareUrl };
15
15
  import { FONT_FAMILY } from '../fonts';
16
+ import { resolveColor } from '../colors';
16
17
  import type {
17
18
  ParsedSequenceDgmo,
18
19
  SequenceElement,
@@ -22,6 +23,8 @@ import type {
22
23
  SequenceParticipant,
23
24
  } from './parser';
24
25
  import { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';
26
+ import { resolveSequenceTags } from './tag-resolution';
27
+ import type { ResolvedTagMap } from './tag-resolution';
25
28
 
26
29
  // ============================================================
27
30
  // Layout Constants
@@ -51,6 +54,20 @@ const NOTE_CHARS_PER_LINE = Math.floor((NOTE_MAX_W - NOTE_PAD_H * 2 - NOTE_FOLD)
51
54
  const COLLAPSED_NOTE_H = 20;
52
55
  const COLLAPSED_NOTE_W = 40;
53
56
 
57
+ // Legend rendering constants (consistent with org chart legend)
58
+ const LEGEND_HEIGHT = 28;
59
+ const LEGEND_PILL_PAD = 16;
60
+ const LEGEND_PILL_FONT_SIZE = 11;
61
+ const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
62
+ const LEGEND_CAPSULE_PAD = 4;
63
+ const LEGEND_DOT_R = 4;
64
+ const LEGEND_ENTRY_FONT_SIZE = 10;
65
+ const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
66
+ const LEGEND_ENTRY_DOT_GAP = 4;
67
+ const LEGEND_ENTRY_TRAIL = 8;
68
+ const LEGEND_GROUP_GAP = 12;
69
+ const LEGEND_BOTTOM_GAP = 8;
70
+
54
71
 
55
72
  function wrapTextLines(text: string, maxChars: number): string[] {
56
73
  const rawLines = text.split('\n');
@@ -75,22 +92,64 @@ function wrapTextLines(text: string, maxChars: number): string[] {
75
92
  return wrapped;
76
93
  }
77
94
 
78
- // Mix two hex colors in sRGB: pct% of a, rest of b
79
- function mix(a: string, b: string, pct: number): string {
80
- const parse = (h: string) => {
81
- const r = h.replace('#', '');
82
- const f = r.length === 3 ? r[0]+r[0]+r[1]+r[1]+r[2]+r[2] : r;
83
- return [parseInt(f.substring(0,2),16), parseInt(f.substring(2,4),16), parseInt(f.substring(4,6),16)];
84
- };
85
- const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b), t = pct/100;
86
- const c = (x: number, y: number) => Math.round(x*t + y*(1-t)).toString(16).padStart(2,'0');
87
- return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
95
+ /**
96
+ * Split a participant label into multiple lines if it exceeds the box width.
97
+ * Splits on spaces first, then dashes, then camelCase boundaries.
98
+ * Approximate max chars based on font-size 13 (~7.5px per char average).
99
+ */
100
+ const LABEL_CHAR_WIDTH = 7.5;
101
+ const LABEL_MAX_CHARS = Math.floor((PARTICIPANT_BOX_WIDTH - 10) / LABEL_CHAR_WIDTH); // ~14 chars
102
+
103
+ function splitParticipantLabel(label: string): string[] {
104
+ if (label.length <= LABEL_MAX_CHARS) return [label];
105
+
106
+ // Split on spaces
107
+ if (label.includes(' ')) {
108
+ return wrapLabelWords(label.split(' '));
109
+ }
110
+
111
+ // Split on dashes/underscores
112
+ if (/[-_]/.test(label)) {
113
+ const parts = label.split(/[-_]/);
114
+ return wrapLabelWords(parts);
115
+ }
116
+
117
+ // Split on camelCase boundaries: "UserLookupCloudFx" → ["User", "Lookup", "Cloud", "Fx"]
118
+ const camelParts = label.replace(/([a-z])([A-Z])/g, '$1\x00$2')
119
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1\x00$2')
120
+ .split('\x00');
121
+ if (camelParts.length > 1) {
122
+ return wrapLabelWords(camelParts);
123
+ }
124
+
125
+ return [label];
88
126
  }
89
127
 
90
- // Shared fill/stroke helpers
91
- const fill = (palette: PaletteColors, isDark: boolean): string =>
92
- mix(palette.primary, isDark ? palette.surface : palette.bg, isDark ? 15 : 30);
93
- const stroke = (palette: PaletteColors): string => palette.textMuted;
128
+ /** Greedily join word parts into lines that fit within LABEL_MAX_CHARS. */
129
+ function wrapLabelWords(words: string[]): string[] {
130
+ const lines: string[] = [];
131
+ let current = '';
132
+ for (const word of words) {
133
+ const test = current ? current + word : word;
134
+ if (test.length > LABEL_MAX_CHARS && current) {
135
+ lines.push(current);
136
+ current = word;
137
+ } else {
138
+ current = test;
139
+ }
140
+ }
141
+ if (current) lines.push(current);
142
+ return lines;
143
+ }
144
+
145
+ // Shared fill/stroke helpers — accept optional color override for per-participant coloring
146
+ const fill = (palette: PaletteColors, isDark: boolean, color?: string): string =>
147
+ color
148
+ ? mix(color, isDark ? palette.surface : palette.bg, isDark ? 30 : 40)
149
+ : isDark
150
+ ? mix(palette.overlay, palette.surface, 50)
151
+ : mix(palette.bg, palette.surface, 50);
152
+ const stroke = (palette: PaletteColors, color?: string): string => color || palette.border;
94
153
  const SW = 1.5;
95
154
  const W = PARTICIPANT_BOX_WIDTH;
96
155
  const H = PARTICIPANT_BOX_HEIGHT;
@@ -102,7 +161,8 @@ const H = PARTICIPANT_BOX_HEIGHT;
102
161
  function renderRectParticipant(
103
162
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
104
163
  palette: PaletteColors,
105
- isDark: boolean
164
+ isDark: boolean,
165
+ color?: string
106
166
  ): void {
107
167
  g.append('rect')
108
168
  .attr('x', -W / 2)
@@ -111,15 +171,16 @@ function renderRectParticipant(
111
171
  .attr('height', H)
112
172
  .attr('rx', 2)
113
173
  .attr('ry', 2)
114
- .attr('fill', fill(palette, isDark))
115
- .attr('stroke', stroke(palette))
174
+ .attr('fill', fill(palette, isDark, color))
175
+ .attr('stroke', stroke(palette, color))
116
176
  .attr('stroke-width', SW);
117
177
  }
118
178
 
119
179
  function renderServiceParticipant(
120
180
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
121
181
  palette: PaletteColors,
122
- isDark: boolean
182
+ isDark: boolean,
183
+ color?: string
123
184
  ): void {
124
185
  g.append('rect')
125
186
  .attr('x', -W / 2)
@@ -128,14 +189,15 @@ function renderServiceParticipant(
128
189
  .attr('height', H)
129
190
  .attr('rx', SERVICE_BORDER_RADIUS)
130
191
  .attr('ry', SERVICE_BORDER_RADIUS)
131
- .attr('fill', fill(palette, isDark))
132
- .attr('stroke', stroke(palette))
192
+ .attr('fill', fill(palette, isDark, color))
193
+ .attr('stroke', stroke(palette, color))
133
194
  .attr('stroke-width', SW);
134
195
  }
135
196
 
136
197
  function renderActorParticipant(
137
198
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
138
- palette: PaletteColors
199
+ palette: PaletteColors,
200
+ color?: string
139
201
  ): void {
140
202
  // Stick figure — no background, natural proportions
141
203
  const headR = 8;
@@ -146,7 +208,7 @@ function renderActorParticipant(
146
208
  const legY = H - 2;
147
209
  const armSpan = 16;
148
210
  const legSpan = 12;
149
- const s = stroke(palette);
211
+ const s = stroke(palette, color);
150
212
  const actorSW = 2.5;
151
213
 
152
214
  g.append('circle')
@@ -193,14 +255,15 @@ function renderActorParticipant(
193
255
  function renderDatabaseParticipant(
194
256
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
195
257
  palette: PaletteColors,
196
- isDark: boolean
258
+ isDark: boolean,
259
+ color?: string
197
260
  ): void {
198
261
  // Cylinder fitting within W x H
199
262
  const ry = 7;
200
263
  const topY = ry;
201
264
  const bodyH = H - ry * 2;
202
- const f = fill(palette, isDark);
203
- const s = stroke(palette);
265
+ const f = fill(palette, isDark, color);
266
+ const s = stroke(palette, color);
204
267
 
205
268
  // Bottom ellipse (drawn first — rect will cover its top arc)
206
269
  g.append('ellipse')
@@ -252,14 +315,15 @@ function renderDatabaseParticipant(
252
315
  function renderQueueParticipant(
253
316
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
254
317
  palette: PaletteColors,
255
- isDark: boolean
318
+ isDark: boolean,
319
+ color?: string
256
320
  ): void {
257
321
  // Horizontal cylinder (pipe) — like database rotated 90 degrees
258
322
  const rx = 10;
259
323
  const leftX = -W / 2 + rx;
260
324
  const bodyW = W - rx * 2;
261
- const f = fill(palette, isDark);
262
- const s = stroke(palette);
325
+ const f = fill(palette, isDark, color);
326
+ const s = stroke(palette, color);
263
327
 
264
328
  // Right ellipse (back face, drawn first — rect will cover its left arc)
265
329
  g.append('ellipse')
@@ -311,14 +375,15 @@ function renderQueueParticipant(
311
375
  function renderCacheParticipant(
312
376
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
313
377
  palette: PaletteColors,
314
- isDark: boolean
378
+ isDark: boolean,
379
+ color?: string
315
380
  ): void {
316
381
  // Dashed cylinder — variation of database to convey ephemeral storage
317
382
  const ry = 7;
318
383
  const topY = ry;
319
384
  const bodyH = H - ry * 2;
320
- const f = fill(palette, isDark);
321
- const s = stroke(palette);
385
+ const f = fill(palette, isDark, color);
386
+ const s = stroke(palette, color);
322
387
  const dash = '4 3';
323
388
 
324
389
  // Bottom ellipse (back face)
@@ -373,7 +438,8 @@ function renderCacheParticipant(
373
438
  function renderNetworkingParticipant(
374
439
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
375
440
  palette: PaletteColors,
376
- isDark: boolean
441
+ isDark: boolean,
442
+ color?: string
377
443
  ): void {
378
444
  // Hexagon fitting within W x H
379
445
  const inset = 16;
@@ -387,19 +453,20 @@ function renderNetworkingParticipant(
387
453
  ].join(' ');
388
454
  g.append('polygon')
389
455
  .attr('points', points)
390
- .attr('fill', fill(palette, isDark))
391
- .attr('stroke', stroke(palette))
456
+ .attr('fill', fill(palette, isDark, color))
457
+ .attr('stroke', stroke(palette, color))
392
458
  .attr('stroke-width', SW);
393
459
  }
394
460
 
395
461
  function renderFrontendParticipant(
396
462
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
397
463
  palette: PaletteColors,
398
- isDark: boolean
464
+ isDark: boolean,
465
+ color?: string
399
466
  ): void {
400
467
  // Monitor shape fitting within W x H
401
468
  const screenH = H - 10;
402
- const s = stroke(palette);
469
+ const s = stroke(palette, color);
403
470
  g.append('rect')
404
471
  .attr('x', -W / 2)
405
472
  .attr('y', 0)
@@ -407,7 +474,7 @@ function renderFrontendParticipant(
407
474
  .attr('height', screenH)
408
475
  .attr('rx', 3)
409
476
  .attr('ry', 3)
410
- .attr('fill', fill(palette, isDark))
477
+ .attr('fill', fill(palette, isDark, color))
411
478
  .attr('stroke', s)
412
479
  .attr('stroke-width', SW);
413
480
  // Stand
@@ -431,7 +498,8 @@ function renderFrontendParticipant(
431
498
  function renderExternalParticipant(
432
499
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
433
500
  palette: PaletteColors,
434
- isDark: boolean
501
+ isDark: boolean,
502
+ color?: string
435
503
  ): void {
436
504
  // Dashed border rectangle
437
505
  g.append('rect')
@@ -441,8 +509,8 @@ function renderExternalParticipant(
441
509
  .attr('height', H)
442
510
  .attr('rx', 2)
443
511
  .attr('ry', 2)
444
- .attr('fill', fill(palette, isDark))
445
- .attr('stroke', stroke(palette))
512
+ .attr('fill', fill(palette, isDark, color))
513
+ .attr('stroke', stroke(palette, color))
446
514
  .attr('stroke-width', SW)
447
515
  .attr('stroke-dasharray', '6 3');
448
516
  }
@@ -450,9 +518,10 @@ function renderExternalParticipant(
450
518
  function renderGatewayParticipant(
451
519
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
452
520
  palette: PaletteColors,
453
- isDark: boolean
521
+ isDark: boolean,
522
+ color?: string
454
523
  ): void {
455
- renderRectParticipant(g, palette, isDark);
524
+ renderRectParticipant(g, palette, isDark, color);
456
525
  }
457
526
 
458
527
  // ============================================================
@@ -468,6 +537,7 @@ export interface SequenceRenderOptions {
468
537
  collapsedSections?: Set<number>; // keyed by section lineNumber
469
538
  expandedNoteLines?: Set<number>; // keyed by note lineNumber; undefined = all expanded (CLI default)
470
539
  exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)
540
+ activeTagGroup?: string | null; // Active tag group name for tag-driven recoloring; null = explicitly none
471
541
  }
472
542
 
473
543
  /**
@@ -738,37 +808,80 @@ export function applyPositionOverrides(
738
808
 
739
809
  /**
740
810
  * Reorder participants so that members of the same group are adjacent.
741
- * Groups appear in declaration order, followed by ungrouped participants.
811
+ * Groups are positioned at the point where their first member would naturally
812
+ * appear based on message order (first-occurrence positioning). This prevents
813
+ * groups declared at the top of the file from being placed before participants
814
+ * that appear in messages earlier.
815
+ *
816
+ * Explicit `position` overrides are handled separately by `applyPositionOverrides`.
742
817
  */
743
818
  export function applyGroupOrdering(
744
819
  participants: SequenceParticipant[],
745
- groups: SequenceGroup[]
820
+ groups: SequenceGroup[],
821
+ messages: SequenceMessage[] = []
746
822
  ): SequenceParticipant[] {
747
823
  if (groups.length === 0) return participants;
748
824
 
749
- const groupedIds = new Set(groups.flatMap((g) => g.participantIds));
750
- const result: SequenceParticipant[] = [];
751
- const placed = new Set<string>();
752
-
753
- // Place grouped participants in group declaration order
825
+ // Build a map: participantId group
826
+ const idToGroup = new Map<string, SequenceGroup>();
754
827
  for (const group of groups) {
755
828
  for (const id of group.participantIds) {
756
- const p = participants.find((pp) => pp.id === id);
757
- if (p && !placed.has(id)) {
758
- result.push(p);
759
- placed.add(id);
760
- }
829
+ idToGroup.set(id, group);
761
830
  }
762
831
  }
763
832
 
764
- // Append ungrouped participants in their original order
833
+ // Build first-appearance index from messages (order in which participants
834
+ // are first referenced). Participants not in any message keep their
835
+ // declaration order from the participants array.
836
+ const appearanceOrder: string[] = [];
837
+ const seen = new Set<string>();
838
+ for (const msg of messages) {
839
+ for (const id of [msg.from, msg.to]) {
840
+ if (!seen.has(id)) {
841
+ seen.add(id);
842
+ appearanceOrder.push(id);
843
+ }
844
+ }
845
+ }
846
+ // Append any participants not referenced in messages (declaration-only)
765
847
  for (const p of participants) {
766
- if (!groupedIds.has(p.id) && !placed.has(p.id)) {
767
- result.push(p);
768
- placed.add(p.id);
848
+ if (!seen.has(p.id)) {
849
+ seen.add(p.id);
850
+ appearanceOrder.push(p.id);
769
851
  }
770
852
  }
771
853
 
854
+ // Walk appearance order; when we encounter a grouped participant,
855
+ // insert the entire group at that position (if not already placed).
856
+ const result: SequenceParticipant[] = [];
857
+ const placed = new Set<string>();
858
+ const placedGroups = new Set<SequenceGroup>();
859
+
860
+ for (const id of appearanceOrder) {
861
+ if (placed.has(id)) continue;
862
+
863
+ const group = idToGroup.get(id);
864
+ if (group && !placedGroups.has(group)) {
865
+ // Place entire group here
866
+ placedGroups.add(group);
867
+ for (const gid of group.participantIds) {
868
+ const p = participants.find((pp) => pp.id === gid);
869
+ if (p && !placed.has(gid)) {
870
+ result.push(p);
871
+ placed.add(gid);
872
+ }
873
+ }
874
+ } else if (!group) {
875
+ // Ungrouped participant
876
+ const p = participants.find((pp) => pp.id === id);
877
+ if (p) {
878
+ result.push(p);
879
+ placed.add(id);
880
+ }
881
+ }
882
+ // If group already placed, skip (member already included)
883
+ }
884
+
772
885
  return result;
773
886
  }
774
887
 
@@ -798,12 +911,36 @@ export function renderSequenceDiagram(
798
911
  const isNoteExpanded = (note: SequenceNote): boolean =>
799
912
  expandedNoteLines === undefined || collapseNotesDisabled || expandedNoteLines.has(note.lineNumber);
800
913
  const participants = applyPositionOverrides(
801
- applyGroupOrdering(parsed.participants, groups)
914
+ applyGroupOrdering(parsed.participants, groups, messages)
802
915
  );
803
916
  if (participants.length === 0) return;
804
917
 
805
918
  const activationsOff = parsedOptions.activations?.toLowerCase() === 'off';
806
919
 
920
+ // Tag resolution — compute resolved tag values and build color lookup
921
+ // Explicit render option wins (including null = "no active group"),
922
+ // then fall back to diagram-level `active-tag: Name` option for CLI/export
923
+ const activeTagGroup =
924
+ options?.activeTagGroup !== undefined
925
+ ? options.activeTagGroup || undefined
926
+ : parsedOptions['active-tag'] || undefined;
927
+ let tagMap: ResolvedTagMap | undefined;
928
+ const tagValueToColor = new Map<string, string>();
929
+ if (activeTagGroup) {
930
+ tagMap = resolveSequenceTags(parsed, activeTagGroup);
931
+ const tg = parsed.tagGroups.find(
932
+ (g) => g.name.toLowerCase() === activeTagGroup.toLowerCase(),
933
+ );
934
+ if (tg) {
935
+ for (const entry of tg.entries) {
936
+ tagValueToColor.set(entry.value.toLowerCase(), resolveColor(entry.color));
937
+ }
938
+ }
939
+ }
940
+ const getTagColor = (value: string | undefined): string | undefined =>
941
+ value ? tagValueToColor.get(value.toLowerCase()) : undefined;
942
+ const tagKey = activeTagGroup?.toLowerCase();
943
+
807
944
  // Build hidden message set for collapse support
808
945
  const hiddenMsgIndices = new Set<number>();
809
946
  if (collapsedSections && collapsedSections.size > 0) {
@@ -1145,10 +1282,12 @@ export function renderSequenceDiagram(
1145
1282
 
1146
1283
  // Compute cumulative Y positions for each step, with section dividers as stable anchors
1147
1284
  const titleOffset = title ? TITLE_HEIGHT : 0;
1285
+ const legendOffset =
1286
+ parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + LEGEND_BOTTOM_GAP : 0;
1148
1287
  const groupOffset =
1149
1288
  groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
1150
1289
  const participantStartY =
1151
- TOP_MARGIN + titleOffset + PARTICIPANT_Y_OFFSET + groupOffset;
1290
+ TOP_MARGIN + titleOffset + legendOffset + PARTICIPANT_Y_OFFSET + groupOffset;
1152
1291
  const lifelineStartY0 = participantStartY + PARTICIPANT_BOX_HEIGHT;
1153
1292
  const hasActors = participants.some((p) => p.type === 'actor');
1154
1293
  const messageStartOffset = MESSAGE_START_OFFSET + (hasActors ? 20 : 0);
@@ -1337,6 +1476,81 @@ export function renderSequenceDiagram(
1337
1476
  .attr('stroke', palette.text)
1338
1477
  .attr('stroke-width', 1.2);
1339
1478
 
1479
+ // Per-color arrowhead markers for tag-driven coloring
1480
+ const arrowPoints = `0,0 ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE / 2} 0,${ARROWHEAD_SIZE}`;
1481
+ for (const [, color] of tagValueToColor) {
1482
+ const hex = color.replace('#', '');
1483
+ // Filled arrowhead (call arrows)
1484
+ defs
1485
+ .append('marker')
1486
+ .attr('id', `seq-arrowhead-c${hex}`)
1487
+ .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
1488
+ .attr('refX', ARROWHEAD_SIZE)
1489
+ .attr('refY', ARROWHEAD_SIZE / 2)
1490
+ .attr('markerWidth', ARROWHEAD_SIZE)
1491
+ .attr('markerHeight', ARROWHEAD_SIZE)
1492
+ .attr('orient', 'auto')
1493
+ .append('polygon')
1494
+ .attr('points', arrowPoints)
1495
+ .attr('fill', color);
1496
+ // Open arrowhead (async arrows)
1497
+ defs
1498
+ .append('marker')
1499
+ .attr('id', `seq-arrowhead-async-c${hex}`)
1500
+ .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
1501
+ .attr('refX', ARROWHEAD_SIZE)
1502
+ .attr('refY', ARROWHEAD_SIZE / 2)
1503
+ .attr('markerWidth', ARROWHEAD_SIZE)
1504
+ .attr('markerHeight', ARROWHEAD_SIZE)
1505
+ .attr('orient', 'auto')
1506
+ .append('polyline')
1507
+ .attr('points', arrowPoints)
1508
+ .attr('fill', 'none')
1509
+ .attr('stroke', color)
1510
+ .attr('stroke-width', 1.2);
1511
+ // Open arrowhead (return arrows)
1512
+ defs
1513
+ .append('marker')
1514
+ .attr('id', `seq-arrowhead-open-c${hex}`)
1515
+ .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
1516
+ .attr('refX', ARROWHEAD_SIZE)
1517
+ .attr('refY', ARROWHEAD_SIZE / 2)
1518
+ .attr('markerWidth', ARROWHEAD_SIZE)
1519
+ .attr('markerHeight', ARROWHEAD_SIZE)
1520
+ .attr('orient', 'auto')
1521
+ .append('polyline')
1522
+ .attr('points', arrowPoints)
1523
+ .attr('fill', 'none')
1524
+ .attr('stroke', color)
1525
+ .attr('stroke-width', 1.2);
1526
+ }
1527
+
1528
+ // Helper: resolve marker ref for tag-colored arrows
1529
+ const coloredMarker = (
1530
+ type: 'call' | 'async' | 'return',
1531
+ tagColor?: string,
1532
+ ): string => {
1533
+ if (tagColor) {
1534
+ const hex = tagColor.replace('#', '');
1535
+ switch (type) {
1536
+ case 'call':
1537
+ return `url(#seq-arrowhead-c${hex})`;
1538
+ case 'async':
1539
+ return `url(#seq-arrowhead-async-c${hex})`;
1540
+ case 'return':
1541
+ return `url(#seq-arrowhead-open-c${hex})`;
1542
+ }
1543
+ }
1544
+ switch (type) {
1545
+ case 'call':
1546
+ return 'url(#seq-arrowhead)';
1547
+ case 'async':
1548
+ return 'url(#seq-arrowhead-async)';
1549
+ case 'return':
1550
+ return 'url(#seq-arrowhead-open)';
1551
+ }
1552
+ };
1553
+
1340
1554
  // Render title
1341
1555
  if (title) {
1342
1556
  const titleEl = svg
@@ -1355,6 +1569,142 @@ export function renderSequenceDiagram(
1355
1569
  }
1356
1570
  }
1357
1571
 
1572
+ // Render legend pills for tag groups
1573
+ if (parsed.tagGroups.length > 0) {
1574
+ const legendY = TOP_MARGIN + titleOffset;
1575
+ const groupBg = isDark
1576
+ ? mix(palette.surface, palette.bg, 50)
1577
+ : mix(palette.surface, palette.bg, 30);
1578
+
1579
+ // Pre-compute pill/capsule widths for centering
1580
+ const legendItems: Array<{
1581
+ group: typeof parsed.tagGroups[0];
1582
+ isActive: boolean;
1583
+ pillWidth: number;
1584
+ totalWidth: number;
1585
+ entries: Array<{ value: string; color: string }>;
1586
+ }> = [];
1587
+ for (const tg of parsed.tagGroups) {
1588
+ if (tg.entries.length === 0) continue;
1589
+ const isActive =
1590
+ !!activeTagGroup &&
1591
+ tg.name.toLowerCase() === activeTagGroup.toLowerCase();
1592
+ const pillWidth = tg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1593
+ const entries = tg.entries.map((e) => ({
1594
+ value: e.value,
1595
+ color: resolveColor(e.color),
1596
+ }));
1597
+ let totalWidth = pillWidth;
1598
+ if (isActive) {
1599
+ let entriesWidth = 0;
1600
+ for (const entry of entries) {
1601
+ entriesWidth +=
1602
+ LEGEND_DOT_R * 2 +
1603
+ LEGEND_ENTRY_DOT_GAP +
1604
+ entry.value.length * LEGEND_ENTRY_FONT_W +
1605
+ LEGEND_ENTRY_TRAIL;
1606
+ }
1607
+ totalWidth = LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth;
1608
+ }
1609
+ legendItems.push({ group: tg, isActive, pillWidth, totalWidth, entries });
1610
+ }
1611
+
1612
+ // Center legend horizontally
1613
+ const totalLegendWidth =
1614
+ legendItems.reduce((s, item) => s + item.totalWidth, 0) +
1615
+ (legendItems.length - 1) * LEGEND_GROUP_GAP;
1616
+ let legendX = (svgWidth - totalLegendWidth) / 2;
1617
+
1618
+ for (const item of legendItems) {
1619
+ const gEl = svg
1620
+ .append('g')
1621
+ .attr('transform', `translate(${legendX}, ${legendY})`)
1622
+ .attr('class', 'sequence-legend-group')
1623
+ .attr('data-legend-group', item.group.name.toLowerCase())
1624
+ .style('cursor', 'pointer');
1625
+
1626
+ // Outer capsule background (active only)
1627
+ if (item.isActive) {
1628
+ gEl
1629
+ .append('rect')
1630
+ .attr('width', item.totalWidth)
1631
+ .attr('height', LEGEND_HEIGHT)
1632
+ .attr('rx', LEGEND_HEIGHT / 2)
1633
+ .attr('fill', groupBg);
1634
+ }
1635
+
1636
+ const pillXOff = item.isActive ? LEGEND_CAPSULE_PAD : 0;
1637
+ const pillYOff = item.isActive ? LEGEND_CAPSULE_PAD : 0;
1638
+ const pillH = LEGEND_HEIGHT - (item.isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
1639
+
1640
+ // Pill background
1641
+ gEl
1642
+ .append('rect')
1643
+ .attr('x', pillXOff)
1644
+ .attr('y', pillYOff)
1645
+ .attr('width', item.pillWidth)
1646
+ .attr('height', pillH)
1647
+ .attr('rx', pillH / 2)
1648
+ .attr('fill', item.isActive ? palette.bg : groupBg);
1649
+
1650
+ // Active pill border
1651
+ if (item.isActive) {
1652
+ gEl
1653
+ .append('rect')
1654
+ .attr('x', pillXOff)
1655
+ .attr('y', pillYOff)
1656
+ .attr('width', item.pillWidth)
1657
+ .attr('height', pillH)
1658
+ .attr('rx', pillH / 2)
1659
+ .attr('fill', 'none')
1660
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1661
+ .attr('stroke-width', 0.75);
1662
+ }
1663
+
1664
+ // Pill text
1665
+ gEl
1666
+ .append('text')
1667
+ .attr('x', pillXOff + item.pillWidth / 2)
1668
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1669
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
1670
+ .attr('font-weight', '500')
1671
+ .attr('fill', item.isActive ? palette.text : palette.textMuted)
1672
+ .attr('text-anchor', 'middle')
1673
+ .text(item.group.name);
1674
+
1675
+ // Entries inside capsule (active only)
1676
+ if (item.isActive) {
1677
+ let entryX = pillXOff + item.pillWidth + 4;
1678
+ for (const entry of item.entries) {
1679
+ const entryG = gEl
1680
+ .append('g')
1681
+ .attr('data-legend-entry', entry.value.toLowerCase())
1682
+ .style('cursor', 'pointer');
1683
+
1684
+ entryG
1685
+ .append('circle')
1686
+ .attr('cx', entryX + LEGEND_DOT_R)
1687
+ .attr('cy', LEGEND_HEIGHT / 2)
1688
+ .attr('r', LEGEND_DOT_R)
1689
+ .attr('fill', entry.color);
1690
+
1691
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
1692
+ entryG
1693
+ .append('text')
1694
+ .attr('x', textX)
1695
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
1696
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
1697
+ .attr('fill', palette.textMuted)
1698
+ .text(entry.value);
1699
+
1700
+ entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1701
+ }
1702
+ }
1703
+
1704
+ legendX += item.totalWidth + LEGEND_GROUP_GAP;
1705
+ }
1706
+ }
1707
+
1358
1708
  // Render group boxes (behind participant shapes)
1359
1709
  for (const group of groups) {
1360
1710
  if (group.participantIds.length === 0) continue;
@@ -1373,16 +1723,15 @@ export function renderSequenceDiagram(
1373
1723
  const boxH =
1374
1724
  PARTICIPANT_BOX_HEIGHT + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;
1375
1725
 
1376
- // Group box background
1377
- const resolvedGroupColor = group.color
1378
- ? resolveColor(group.color, palette)
1379
- : undefined;
1380
- const fillColor = resolvedGroupColor
1381
- ? mix(resolvedGroupColor, isDark ? palette.surface : palette.bg, 10)
1726
+ // Group box background — use tag color if group has metadata for the active tag group
1727
+ const groupTagValue = tagKey && group.metadata?.[tagKey];
1728
+ const groupTagColor = getTagColor(groupTagValue || undefined);
1729
+ const fillColor = groupTagColor
1730
+ ? mix(groupTagColor, isDark ? palette.surface : palette.bg, isDark ? 15 : 20)
1382
1731
  : isDark
1383
1732
  ? palette.surface
1384
1733
  : palette.bg;
1385
- const strokeColor = resolvedGroupColor || palette.textMuted;
1734
+ const strokeColor = groupTagColor || palette.textMuted;
1386
1735
 
1387
1736
  svg
1388
1737
  .append('rect')
@@ -1418,19 +1767,29 @@ export function renderSequenceDiagram(
1418
1767
  const cx = offsetX + index * PARTICIPANT_GAP;
1419
1768
  const cy = participantStartY;
1420
1769
 
1421
- renderParticipant(svg, participant, cx, cy, palette, isDark);
1770
+ const pTagValue = tagMap?.participants.get(participant.id);
1771
+ const pTagColor = getTagColor(pTagValue);
1772
+ const pTagAttr =
1773
+ tagKey && pTagValue
1774
+ ? { key: tagKey, value: pTagValue.toLowerCase() }
1775
+ : undefined;
1776
+ renderParticipant(svg, participant, cx, cy, palette, isDark, pTagColor, pTagAttr);
1422
1777
 
1423
1778
  // Render lifeline
1424
- svg
1779
+ const lifelineEl = svg
1425
1780
  .append('line')
1426
1781
  .attr('x1', cx)
1427
1782
  .attr('y1', lifelineStartY)
1428
1783
  .attr('x2', cx)
1429
1784
  .attr('y2', lifelineStartY + lifelineLength)
1430
- .attr('stroke', palette.textMuted)
1785
+ .attr('stroke', pTagColor || palette.textMuted)
1431
1786
  .attr('stroke-width', 1)
1432
1787
  .attr('stroke-dasharray', '6 4')
1433
- .attr('class', 'lifeline');
1788
+ .attr('class', 'lifeline')
1789
+ .attr('data-participant-id', participant.id);
1790
+ if (tagKey && pTagValue) {
1791
+ lifelineEl.attr(`data-tag-${tagKey}`, pTagValue.toLowerCase());
1792
+ }
1434
1793
  });
1435
1794
 
1436
1795
  // Render block frames (behind everything else)
@@ -1650,6 +2009,14 @@ export function renderSequenceDiagram(
1650
2009
  if (msg) coveredLines.push(msg.lineNumber);
1651
2010
  }
1652
2011
 
2012
+ // Determine activation color from triggering message's tag
2013
+ const triggerMsg = messages[renderSteps[act.startStep]?.messageIndex];
2014
+ const actTagValue = triggerMsg
2015
+ ? tagMap?.messages.get(triggerMsg.lineNumber)
2016
+ : undefined;
2017
+ const actTagColor = getTagColor(actTagValue);
2018
+ const actBaseColor = actTagColor || palette.primary;
2019
+
1653
2020
  // Opaque background to mask the lifeline
1654
2021
  svg
1655
2022
  .append('rect')
@@ -1659,21 +2026,24 @@ export function renderSequenceDiagram(
1659
2026
  .attr('height', y2 - y1)
1660
2027
  .attr('fill', isDark ? palette.surface : palette.bg);
1661
2028
 
1662
- const actFill = mix(palette.primary, isDark ? palette.surface : palette.bg, isDark ? 15 : 30);
1663
- svg
2029
+ const actFill = mix(actBaseColor, isDark ? palette.surface : palette.bg, isDark ? 15 : 30);
2030
+ const actRect = svg
1664
2031
  .append('rect')
1665
2032
  .attr('x', x)
1666
2033
  .attr('y', y1)
1667
2034
  .attr('width', ACTIVATION_WIDTH)
1668
2035
  .attr('height', y2 - y1)
1669
2036
  .attr('fill', actFill)
1670
- .attr('stroke', palette.primary)
2037
+ .attr('stroke', actBaseColor)
1671
2038
  .attr('stroke-width', 1)
1672
2039
  .attr('stroke-opacity', 0.5)
1673
2040
  .attr('data-participant-id', act.participantId)
1674
2041
  .attr('data-msg-lines', coveredLines.join(','))
1675
2042
  .attr('data-line-number', coveredLines[0] ?? '')
1676
2043
  .attr('class', 'activation');
2044
+ if (tagKey && actTagValue) {
2045
+ actRect.attr(`data-tag-${tagKey}`, actTagValue.toLowerCase());
2046
+ }
1677
2047
  });
1678
2048
 
1679
2049
  // Render deferred else dividers (on top of activations)
@@ -1748,9 +2118,7 @@ export function renderSequenceDiagram(
1748
2118
  if (secY === undefined) continue;
1749
2119
 
1750
2120
  const isCollapsed = collapsedSections?.has(sec.lineNumber) ?? false;
1751
- const lineColor = sec.color
1752
- ? resolveColor(sec.color, palette)
1753
- : palette.textMuted;
2121
+ const lineColor = palette.textMuted;
1754
2122
 
1755
2123
  // Wrap section elements in a <g> for toggle.
1756
2124
  // IMPORTANT: only the <g> carries data-line-number / data-section —
@@ -1843,27 +2211,54 @@ export function renderSequenceDiagram(
1843
2211
 
1844
2212
  const y = stepY(i);
1845
2213
 
2214
+ const HIT_H = 20; // transparent hit area height (10px above + below arrow)
2215
+
2216
+ // Resolve tag color for this message
2217
+ const msg = messages[step.messageIndex];
2218
+ const msgTagValue = msg ? tagMap?.messages.get(msg.lineNumber) : undefined;
2219
+ const msgTagColor = getTagColor(msgTagValue);
2220
+
1846
2221
  if (step.type === 'call') {
2222
+ const arrowColor = msgTagColor || palette.text;
2223
+
1847
2224
  if (step.from === step.to) {
1848
2225
  // Self-call: loopback arrow from right edge of activation
1849
2226
  const x = arrowEdgeX(step.from, i, 'right');
1850
- svg
2227
+
2228
+ // Hit area for self-call
2229
+ svg.append('rect')
2230
+ .attr('x', x)
2231
+ .attr('y', y - 5)
2232
+ .attr('width', SELF_CALL_WIDTH)
2233
+ .attr('height', SELF_CALL_HEIGHT + 10)
2234
+ .attr('fill', 'transparent')
2235
+ .attr('class', 'message-hit-area')
2236
+ .attr('data-line-number', String(messages[step.messageIndex].lineNumber))
2237
+ .attr('data-msg-index', String(step.messageIndex))
2238
+ .attr('data-step-index', String(i));
2239
+
2240
+ const selfCallEl = svg
1851
2241
  .append('path')
1852
2242
  .attr(
1853
2243
  'd',
1854
2244
  `M ${x} ${y} H ${x + SELF_CALL_WIDTH} V ${y + SELF_CALL_HEIGHT} H ${x}`
1855
2245
  )
1856
2246
  .attr('fill', 'none')
1857
- .attr('stroke', palette.text)
2247
+ .attr('stroke', arrowColor)
1858
2248
  .attr('stroke-width', 1.2)
1859
- .attr('marker-end', 'url(#seq-arrowhead)')
2249
+ .attr('marker-end', coloredMarker('call', msgTagColor))
1860
2250
  .attr('class', 'message-arrow self-call')
1861
2251
  .attr(
1862
2252
  'data-line-number',
1863
2253
  String(messages[step.messageIndex].lineNumber)
1864
2254
  )
1865
2255
  .attr('data-msg-index', String(step.messageIndex))
1866
- .attr('data-step-index', String(i));
2256
+ .attr('data-step-index', String(i))
2257
+ .attr('data-from', step.from)
2258
+ .attr('data-to', step.to);
2259
+ if (tagKey && msgTagValue) {
2260
+ selfCallEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2261
+ }
1867
2262
 
1868
2263
  if (step.label) {
1869
2264
  const labelEl = svg
@@ -1871,7 +2266,7 @@ export function renderSequenceDiagram(
1871
2266
  .attr('x', x + SELF_CALL_WIDTH + 5)
1872
2267
  .attr('y', y + SELF_CALL_HEIGHT / 2 + 4)
1873
2268
  .attr('text-anchor', 'start')
1874
- .attr('fill', palette.text)
2269
+ .attr('fill', arrowColor)
1875
2270
  .attr('font-size', 12)
1876
2271
  .attr('class', 'message-label')
1877
2272
  .attr(
@@ -1880,6 +2275,9 @@ export function renderSequenceDiagram(
1880
2275
  )
1881
2276
  .attr('data-msg-index', String(step.messageIndex))
1882
2277
  .attr('data-step-index', String(i));
2278
+ if (tagKey && msgTagValue) {
2279
+ labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2280
+ }
1883
2281
  renderInlineText(labelEl, step.label, palette);
1884
2282
  }
1885
2283
  } else {
@@ -1888,16 +2286,28 @@ export function renderSequenceDiagram(
1888
2286
  const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');
1889
2287
  const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');
1890
2288
 
2289
+ // Hit area for call arrow
2290
+ svg.append('rect')
2291
+ .attr('x', Math.min(x1, x2))
2292
+ .attr('y', y - HIT_H / 2)
2293
+ .attr('width', Math.abs(x2 - x1))
2294
+ .attr('height', HIT_H)
2295
+ .attr('fill', 'transparent')
2296
+ .attr('class', 'message-hit-area')
2297
+ .attr('data-line-number', String(messages[step.messageIndex].lineNumber))
2298
+ .attr('data-msg-index', String(step.messageIndex))
2299
+ .attr('data-step-index', String(i));
2300
+
1891
2301
  const markerRef = step.async
1892
- ? 'url(#seq-arrowhead-async)'
1893
- : 'url(#seq-arrowhead)';
1894
- svg
2302
+ ? coloredMarker('async', msgTagColor)
2303
+ : coloredMarker('call', msgTagColor);
2304
+ const arrowEl = svg
1895
2305
  .append('line')
1896
2306
  .attr('x1', x1)
1897
2307
  .attr('y1', y)
1898
2308
  .attr('x2', x2)
1899
2309
  .attr('y2', y)
1900
- .attr('stroke', palette.text)
2310
+ .attr('stroke', arrowColor)
1901
2311
  .attr('stroke-width', 1.2)
1902
2312
  .attr('marker-end', markerRef)
1903
2313
  .attr('class', 'message-arrow')
@@ -1906,7 +2316,12 @@ export function renderSequenceDiagram(
1906
2316
  String(messages[step.messageIndex].lineNumber)
1907
2317
  )
1908
2318
  .attr('data-msg-index', String(step.messageIndex))
1909
- .attr('data-step-index', String(i));
2319
+ .attr('data-step-index', String(i))
2320
+ .attr('data-from', step.from)
2321
+ .attr('data-to', step.to);
2322
+ if (tagKey && msgTagValue) {
2323
+ arrowEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2324
+ }
1910
2325
 
1911
2326
  if (step.label) {
1912
2327
  const midX = (x1 + x2) / 2;
@@ -1915,7 +2330,7 @@ export function renderSequenceDiagram(
1915
2330
  .attr('x', midX)
1916
2331
  .attr('y', y - 8)
1917
2332
  .attr('text-anchor', 'middle')
1918
- .attr('fill', palette.text)
2333
+ .attr('fill', arrowColor)
1919
2334
  .attr('font-size', 12)
1920
2335
  .attr('class', 'message-label')
1921
2336
  .attr(
@@ -1924,6 +2339,9 @@ export function renderSequenceDiagram(
1924
2339
  )
1925
2340
  .attr('data-msg-index', String(step.messageIndex))
1926
2341
  .attr('data-step-index', String(i));
2342
+ if (tagKey && msgTagValue) {
2343
+ labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2344
+ }
1927
2345
  renderInlineText(labelEl, step.label, palette);
1928
2346
  }
1929
2347
  }
@@ -1936,24 +2354,42 @@ export function renderSequenceDiagram(
1936
2354
  const goingRight = fromX < toX;
1937
2355
  const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');
1938
2356
  const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');
2357
+ const returnColor = msgTagColor || palette.textMuted;
2358
+
2359
+ // Hit area for return arrow
2360
+ svg.append('rect')
2361
+ .attr('x', Math.min(x1, x2))
2362
+ .attr('y', y - HIT_H / 2)
2363
+ .attr('width', Math.abs(x2 - x1))
2364
+ .attr('height', HIT_H)
2365
+ .attr('fill', 'transparent')
2366
+ .attr('class', 'message-hit-area')
2367
+ .attr('data-line-number', String(messages[step.messageIndex].lineNumber))
2368
+ .attr('data-msg-index', String(step.messageIndex))
2369
+ .attr('data-step-index', String(i));
1939
2370
 
1940
- svg
2371
+ const returnEl = svg
1941
2372
  .append('line')
1942
2373
  .attr('x1', x1)
1943
2374
  .attr('y1', y)
1944
2375
  .attr('x2', x2)
1945
2376
  .attr('y2', y)
1946
- .attr('stroke', palette.textMuted)
2377
+ .attr('stroke', returnColor)
1947
2378
  .attr('stroke-width', 1)
1948
2379
  .attr('stroke-dasharray', '6 4')
1949
- .attr('marker-end', 'url(#seq-arrowhead-open)')
2380
+ .attr('marker-end', coloredMarker('return', msgTagColor))
1950
2381
  .attr('class', 'return-arrow')
1951
2382
  .attr(
1952
2383
  'data-line-number',
1953
2384
  String(messages[step.messageIndex].lineNumber)
1954
2385
  )
1955
2386
  .attr('data-msg-index', String(step.messageIndex))
1956
- .attr('data-step-index', String(i));
2387
+ .attr('data-step-index', String(i))
2388
+ .attr('data-from', step.from)
2389
+ .attr('data-to', step.to);
2390
+ if (tagKey && msgTagValue) {
2391
+ returnEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2392
+ }
1957
2393
 
1958
2394
  if (step.label) {
1959
2395
  const midX = (x1 + x2) / 2;
@@ -1962,7 +2398,7 @@ export function renderSequenceDiagram(
1962
2398
  .attr('x', midX)
1963
2399
  .attr('y', y - 6)
1964
2400
  .attr('text-anchor', 'middle')
1965
- .attr('fill', palette.textMuted)
2401
+ .attr('fill', returnColor)
1966
2402
  .attr('font-size', 11)
1967
2403
  .attr('class', 'message-label')
1968
2404
  .attr(
@@ -1971,6 +2407,9 @@ export function renderSequenceDiagram(
1971
2407
  )
1972
2408
  .attr('data-msg-index', String(step.messageIndex))
1973
2409
  .attr('data-step-index', String(i));
2410
+ if (tagKey && msgTagValue) {
2411
+ labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2412
+ }
1974
2413
  renderInlineText(labelEl, step.label, palette);
1975
2414
  }
1976
2415
  }
@@ -2194,7 +2633,9 @@ function renderParticipant(
2194
2633
  cx: number,
2195
2634
  cy: number,
2196
2635
  palette: PaletteColors,
2197
- isDark: boolean
2636
+ isDark: boolean,
2637
+ color?: string,
2638
+ tagAttr?: { key: string; value: string },
2198
2639
  ): void {
2199
2640
  const g = svg
2200
2641
  .append('g')
@@ -2202,51 +2643,73 @@ function renderParticipant(
2202
2643
  .attr('class', 'participant')
2203
2644
  .attr('data-participant-id', participant.id);
2204
2645
 
2646
+ // Set data-tag attribute for legend hover dimming
2647
+ if (tagAttr) {
2648
+ g.attr(`data-tag-${tagAttr.key}`, tagAttr.value);
2649
+ }
2650
+
2205
2651
  // Render shape based on type
2206
2652
  switch (participant.type) {
2207
2653
  case 'actor':
2208
- renderActorParticipant(g, palette);
2654
+ renderActorParticipant(g, palette, color);
2209
2655
  break;
2210
2656
  case 'database':
2211
- renderDatabaseParticipant(g, palette, isDark);
2657
+ renderDatabaseParticipant(g, palette, isDark, color);
2212
2658
  break;
2213
2659
  case 'service':
2214
- renderServiceParticipant(g, palette, isDark);
2660
+ renderServiceParticipant(g, palette, isDark, color);
2215
2661
  break;
2216
2662
  case 'queue':
2217
- renderQueueParticipant(g, palette, isDark);
2663
+ renderQueueParticipant(g, palette, isDark, color);
2218
2664
  break;
2219
2665
  case 'cache':
2220
- renderCacheParticipant(g, palette, isDark);
2666
+ renderCacheParticipant(g, palette, isDark, color);
2221
2667
  break;
2222
2668
  case 'networking':
2223
- renderNetworkingParticipant(g, palette, isDark);
2669
+ renderNetworkingParticipant(g, palette, isDark, color);
2224
2670
  break;
2225
2671
  case 'frontend':
2226
- renderFrontendParticipant(g, palette, isDark);
2672
+ renderFrontendParticipant(g, palette, isDark, color);
2227
2673
  break;
2228
2674
  case 'external':
2229
- renderExternalParticipant(g, palette, isDark);
2675
+ renderExternalParticipant(g, palette, isDark, color);
2230
2676
  break;
2231
2677
  case 'gateway':
2232
- renderGatewayParticipant(g, palette, isDark);
2678
+ renderGatewayParticipant(g, palette, isDark, color);
2233
2679
  break;
2234
2680
  default:
2235
- renderRectParticipant(g, palette, isDark);
2681
+ renderRectParticipant(g, palette, isDark, color);
2236
2682
  break;
2237
2683
  }
2238
2684
 
2239
2685
  // Render label — below the shape for actors, centered inside for others
2240
2686
  const isActor = participant.type === 'actor';
2241
- g.append('text')
2687
+ const labelLines = splitParticipantLabel(participant.label);
2688
+ const fontSize = 13;
2689
+ const lineHeight = fontSize + 2;
2690
+ const textEl = g.append('text')
2242
2691
  .attr('x', 0)
2243
- .attr(
2244
- 'y',
2245
- isActor ? PARTICIPANT_BOX_HEIGHT + 14 : PARTICIPANT_BOX_HEIGHT / 2 + 5
2246
- )
2247
2692
  .attr('text-anchor', 'middle')
2248
2693
  .attr('fill', palette.text)
2249
- .attr('font-size', 13)
2250
- .attr('font-weight', 500)
2251
- .text(participant.label);
2694
+ .attr('font-size', fontSize)
2695
+ .attr('font-weight', 500);
2696
+
2697
+ if (labelLines.length === 1) {
2698
+ textEl
2699
+ .attr('y', isActor ? PARTICIPANT_BOX_HEIGHT + 14 : PARTICIPANT_BOX_HEIGHT / 2 + 5)
2700
+ .text(participant.label);
2701
+ } else {
2702
+ // Multi-line: vertically center the lines within the box (or below for actors)
2703
+ const totalHeight = labelLines.length * lineHeight;
2704
+ const baseY = isActor
2705
+ ? PARTICIPANT_BOX_HEIGHT + 14 - ((labelLines.length - 1) * lineHeight) / 2
2706
+ : PARTICIPANT_BOX_HEIGHT / 2 + 5 - (totalHeight - lineHeight) / 2;
2707
+
2708
+ labelLines.forEach((line, i) => {
2709
+ textEl.append('tspan')
2710
+ .attr('x', 0)
2711
+ .attr('dy', i === 0 ? `${baseY}px` : `${lineHeight}px`)
2712
+ .text(line);
2713
+ });
2714
+ }
2252
2715
  }