@diagrammo/dgmo 0.8.17 → 0.8.19

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.
@@ -7,8 +7,17 @@ import type { TagGroup } from '../utils/tag-groups';
7
7
 
8
8
  // ── Duration ────────────────────────────────────────────────
9
9
 
10
- /** Calendar units: d (days), w (weeks), m (months), q (quarters), y (years), h (hours), min (minutes). bd = business days. */
11
- export type DurationUnit = 'd' | 'bd' | 'w' | 'm' | 'q' | 'y' | 'h' | 'min';
10
+ /** Calendar units: d (days), w (weeks), m (months), q (quarters), y (years), h (hours), min (minutes). bd = business days. s = sprints. */
11
+ export type DurationUnit =
12
+ | 'd'
13
+ | 'bd'
14
+ | 'w'
15
+ | 'm'
16
+ | 'q'
17
+ | 'y'
18
+ | 'h'
19
+ | 'min'
20
+ | 's';
12
21
 
13
22
  export interface Duration {
14
23
  amount: number;
@@ -119,6 +128,11 @@ export interface GanttOptions {
119
128
  /** Line numbers for option/block keywords — maps key to source line */
120
129
  optionLineNumbers: Record<string, number>;
121
130
  holidaysLineNumber: number | null;
131
+ // ── Sprint options ─────────────────────────────────────────
132
+ sprintLength: Duration | null; // default { amount: 2, unit: 'w' } when sprint mode active
133
+ sprintNumber: number | null; // which sprint the chart starts at (default 1)
134
+ sprintStart: string | null; // YYYY-MM-DD — date that sprintNumber begins
135
+ sprintMode: 'auto' | 'explicit' | null; // auto = activated by `s` unit, explicit = sprint-* option present
122
136
  }
123
137
 
124
138
  // ── Parsed Result ───────────────────────────────────────────
@@ -158,6 +172,12 @@ export interface ResolvedGroup {
158
172
  depth: number;
159
173
  }
160
174
 
175
+ export interface ResolvedSprint {
176
+ number: number;
177
+ startDate: Date;
178
+ endDate: Date;
179
+ }
180
+
161
181
  export interface ResolvedSchedule {
162
182
  tasks: ResolvedTask[];
163
183
  groups: ResolvedGroup[];
@@ -167,6 +187,7 @@ export interface ResolvedSchedule {
167
187
  tagGroups: TagGroup[];
168
188
  eras: GanttEra[];
169
189
  markers: GanttMarker[];
190
+ sprints: ResolvedSprint[];
170
191
  options: GanttOptions;
171
192
  diagnostics: DgmoError[];
172
193
  error: string | null;
package/src/index.ts CHANGED
@@ -410,6 +410,9 @@ export type {
410
410
  SequenceRenderOptions,
411
411
  } from './sequence/renderer';
412
412
 
413
+ export { applyCollapseProjection } from './sequence/collapse';
414
+ export type { CollapsedView } from './sequence/collapse';
415
+
413
416
  // ============================================================
414
417
  // Colors & Palettes
415
418
  // ============================================================
@@ -461,11 +464,16 @@ export type { PaletteConfig, PaletteColors } from './palettes';
461
464
  // Sharing (URL encoding/decoding)
462
465
  // ============================================================
463
466
 
464
- export { encodeDiagramUrl, decodeDiagramUrl } from './sharing';
467
+ export {
468
+ encodeDiagramUrl,
469
+ decodeDiagramUrl,
470
+ encodeViewState,
471
+ decodeViewState,
472
+ } from './sharing';
465
473
  export type {
466
474
  EncodeDiagramUrlOptions,
467
475
  EncodeDiagramUrlResult,
468
- DiagramViewState,
476
+ CompactViewState,
469
477
  DecodedDiagramUrl,
470
478
  } from './sharing';
471
479
 
@@ -0,0 +1,169 @@
1
+ // ============================================================
2
+ // Collapse Projection for Sequence Diagram Groups
3
+ // ============================================================
4
+ //
5
+ // Pure projection function that transforms a parsed sequence diagram
6
+ // by collapsing specified groups into single virtual participants.
7
+ // The parsed AST (ParsedSequenceDgmo) stays immutable.
8
+
9
+ import type {
10
+ ParsedSequenceDgmo,
11
+ SequenceElement,
12
+ SequenceGroup,
13
+ SequenceMessage,
14
+ SequenceParticipant,
15
+ } from './parser';
16
+ import { isSequenceBlock, isSequenceNote, isSequenceSection } from './parser';
17
+
18
+ export interface CollapsedView {
19
+ participants: SequenceParticipant[];
20
+ messages: SequenceMessage[];
21
+ elements: SequenceElement[];
22
+ groups: SequenceGroup[];
23
+ /** Maps member participant ID → collapsed group name */
24
+ collapsedGroupIds: Map<string, string>;
25
+ }
26
+
27
+ /**
28
+ * Project a parsed sequence diagram into a collapsed view.
29
+ *
30
+ * @param parsed - The immutable parsed sequence diagram
31
+ * @param collapsedGroups - Set of group lineNumbers that should be collapsed
32
+ * @returns A new CollapsedView with remapped participants, messages, elements, and groups
33
+ */
34
+ export function applyCollapseProjection(
35
+ parsed: ParsedSequenceDgmo,
36
+ collapsedGroups: Set<number>
37
+ ): CollapsedView {
38
+ if (collapsedGroups.size === 0) {
39
+ return {
40
+ participants: parsed.participants,
41
+ messages: parsed.messages,
42
+ elements: parsed.elements,
43
+ groups: parsed.groups,
44
+ collapsedGroupIds: new Map(),
45
+ };
46
+ }
47
+
48
+ // Build memberToGroup map: participantId → group name
49
+ const memberToGroup = new Map<string, string>();
50
+ const collapsedGroupNames = new Set<string>();
51
+ for (const group of parsed.groups) {
52
+ if (collapsedGroups.has(group.lineNumber)) {
53
+ collapsedGroupNames.add(group.name);
54
+ for (const memberId of group.participantIds) {
55
+ memberToGroup.set(memberId, group.name);
56
+ }
57
+ }
58
+ }
59
+
60
+ // Participants: remove members of collapsed groups, insert virtual participant per group
61
+ // Skip non-member participants that collide with a collapsed group name
62
+ const participants: SequenceParticipant[] = [];
63
+ const insertedGroups = new Set<string>();
64
+
65
+ for (const p of parsed.participants) {
66
+ const groupName = memberToGroup.get(p.id);
67
+ if (groupName) {
68
+ // Replace first occurrence with virtual group participant
69
+ if (!insertedGroups.has(groupName)) {
70
+ insertedGroups.add(groupName);
71
+ const group = parsed.groups.find(
72
+ (g) => g.name === groupName && collapsedGroups.has(g.lineNumber)
73
+ )!;
74
+ participants.push({
75
+ id: groupName,
76
+ label: groupName,
77
+ type: 'default',
78
+ lineNumber: group.lineNumber,
79
+ });
80
+ }
81
+ // Skip member — it's absorbed into the group
82
+ } else if (collapsedGroupNames.has(p.id)) {
83
+ // Skip — participant name collides with a collapsed group name;
84
+ // the virtual group participant takes precedence
85
+ } else {
86
+ participants.push(p);
87
+ }
88
+ }
89
+
90
+ // Remap helper
91
+ const remap = (id: string): string => memberToGroup.get(id) ?? id;
92
+
93
+ // Messages: remap from/to, preserving order
94
+ const messages: SequenceMessage[] = parsed.messages.map((msg) => ({
95
+ ...msg,
96
+ from: remap(msg.from),
97
+ to: remap(msg.to),
98
+ }));
99
+
100
+ // Elements: deep clone with remapping and internal return suppression
101
+ const elements = remapElements(parsed.elements, memberToGroup);
102
+
103
+ // Groups: remove collapsed groups (they're now virtual participants)
104
+ const groups = parsed.groups.filter(
105
+ (g) => !collapsedGroups.has(g.lineNumber)
106
+ );
107
+
108
+ return {
109
+ participants,
110
+ messages,
111
+ elements,
112
+ groups,
113
+ collapsedGroupIds: memberToGroup,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Deep clone and remap elements, suppressing internal returns within collapsed groups.
119
+ */
120
+ function remapElements(
121
+ elements: SequenceElement[],
122
+ memberToGroup: Map<string, string>
123
+ ): SequenceElement[] {
124
+ const remap = (id: string): string => memberToGroup.get(id) ?? id;
125
+ const result: SequenceElement[] = [];
126
+
127
+ for (const el of elements) {
128
+ if (isSequenceSection(el)) {
129
+ // Sections have no participant references — pass through unchanged
130
+ result.push(el);
131
+ } else if (isSequenceNote(el)) {
132
+ // Remap note participant
133
+ result.push({
134
+ ...el,
135
+ participantId: remap(el.participantId),
136
+ });
137
+ } else if (isSequenceBlock(el)) {
138
+ // Recurse into block children
139
+ result.push({
140
+ ...el,
141
+ children: remapElements(el.children, memberToGroup),
142
+ elseChildren: remapElements(el.elseChildren, memberToGroup),
143
+ ...(el.elseIfBranches
144
+ ? {
145
+ elseIfBranches: el.elseIfBranches.map((branch) => ({
146
+ ...branch,
147
+ children: remapElements(branch.children, memberToGroup),
148
+ })),
149
+ }
150
+ : {}),
151
+ });
152
+ } else {
153
+ // Message element
154
+ const msg = el as SequenceMessage;
155
+ const from = remap(msg.from);
156
+ const to = remap(msg.to);
157
+
158
+ // Suppress internal return: both endpoints in same collapsed group
159
+ // and this is a return message (unlabeled response)
160
+ if (from === to && from !== msg.from && !msg.label) {
161
+ continue; // internal return suppressed
162
+ }
163
+
164
+ result.push({ ...msg, from, to });
165
+ }
166
+ }
167
+
168
+ return result;
169
+ }
@@ -154,6 +154,8 @@ export interface SequenceGroup {
154
154
  lineNumber: number;
155
155
  /** Pipe-delimited tag metadata (e.g. `[Backend | t: Product]`) */
156
156
  metadata?: Record<string, string>;
157
+ /** Whether this group is collapsed by default */
158
+ collapsed?: boolean;
157
159
  }
158
160
 
159
161
  /**
@@ -502,8 +504,17 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
502
504
  const groupColor = groupMatch[2]?.trim();
503
505
  let groupMeta: Record<string, string> | undefined;
504
506
 
505
- // Parse pipe metadata AFTER the closing bracket
506
- const afterBracket = groupMatch[3]?.trim() || '';
507
+ // Parse collapse keyword and pipe metadata AFTER the closing bracket
508
+ let afterBracket = groupMatch[3]?.trim() || '';
509
+ let isCollapsed = false;
510
+
511
+ // Extract `collapse` keyword (before any pipe metadata)
512
+ const collapseMatch = afterBracket.match(/^collapse\b/i);
513
+ if (collapseMatch) {
514
+ isCollapsed = true;
515
+ afterBracket = afterBracket.slice(collapseMatch[0].length).trim();
516
+ }
517
+
507
518
  if (afterBracket.startsWith('|')) {
508
519
  const segments = afterBracket.split('|');
509
520
  const meta = parsePipeMetadata(segments, aliasMap, () =>
@@ -524,6 +535,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
524
535
  participantIds: [],
525
536
  lineNumber,
526
537
  ...(groupMeta ? { metadata: groupMeta } : {}),
538
+ ...(isCollapsed ? { collapsed: true } : {}),
527
539
  };
528
540
  result.groups.push(activeGroup);
529
541
  continue;
@@ -21,6 +21,8 @@ import type {
21
21
  SequenceParticipant,
22
22
  } from './parser';
23
23
  import { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';
24
+ import { applyCollapseProjection } from './collapse';
25
+ import type { CollapsedView } from './collapse';
24
26
  import { resolveSequenceTags } from './tag-resolution';
25
27
  import type { ResolvedTagMap } from './tag-resolution';
26
28
  import { resolveActiveTagGroup } from '../utils/tag-groups';
@@ -533,6 +535,7 @@ export interface SectionMessageGroup {
533
535
 
534
536
  export interface SequenceRenderOptions {
535
537
  collapsedSections?: Set<number>; // keyed by section lineNumber
538
+ collapsedGroups?: Set<number>; // keyed by group lineNumber
536
539
  expandedNoteLines?: Set<number>; // keyed by note lineNumber; undefined = all expanded (CLI default)
537
540
  exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)
538
541
  activeTagGroup?: string | null; // Active tag group name for tag-driven recoloring; null = explicitly none
@@ -900,7 +903,37 @@ export function renderSequenceDiagram(
900
903
  // Clear previous content
901
904
  d3Selection.select(container).selectAll('*').remove();
902
905
 
903
- const { title, messages, elements, groups, options: parsedOptions } = parsed;
906
+ const { title, options: parsedOptions } = parsed;
907
+
908
+ // Compute effective collapsed groups: union of syntax-declared and runtime-toggled
909
+ const effectiveCollapsedGroups = new Set<number>();
910
+ for (const group of parsed.groups) {
911
+ if (group.collapsed) effectiveCollapsedGroups.add(group.lineNumber);
912
+ }
913
+ if (options?.collapsedGroups) {
914
+ for (const ln of options.collapsedGroups) {
915
+ // Toggle: if already in the set (from syntax), remove it (user expanded);
916
+ // if not in the set, add it (user collapsed)
917
+ if (effectiveCollapsedGroups.has(ln)) {
918
+ effectiveCollapsedGroups.delete(ln);
919
+ } else {
920
+ effectiveCollapsedGroups.add(ln);
921
+ }
922
+ }
923
+ }
924
+
925
+ // Apply collapse projection before participant ordering
926
+ const collapsed: CollapsedView | null =
927
+ effectiveCollapsedGroups.size > 0
928
+ ? applyCollapseProjection(parsed, effectiveCollapsedGroups)
929
+ : null;
930
+
931
+ const messages = collapsed ? collapsed.messages : parsed.messages;
932
+ const elements = collapsed ? collapsed.elements : parsed.elements;
933
+ const groups = collapsed ? collapsed.groups : parsed.groups;
934
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
935
+ const collapsedGroupIds = collapsed?.collapsedGroupIds ?? new Map();
936
+
904
937
  const collapsedSections = options?.collapsedSections;
905
938
  const expandedNoteLines = options?.expandedNoteLines;
906
939
  const collapseNotesDisabled =
@@ -911,8 +944,12 @@ export function renderSequenceDiagram(
911
944
  expandedNoteLines === undefined ||
912
945
  collapseNotesDisabled ||
913
946
  expandedNoteLines.has(note.lineNumber);
947
+
948
+ const sourceParticipants = collapsed
949
+ ? collapsed.participants
950
+ : parsed.participants;
914
951
  const participants = applyPositionOverrides(
915
- applyGroupOrdering(parsed.participants, groups, messages)
952
+ applyGroupOrdering(sourceParticipants, groups, messages)
916
953
  );
917
954
  if (participants.length === 0) return;
918
955
 
@@ -1158,6 +1195,15 @@ export function renderSequenceDiagram(
1158
1195
  const preSectionMsgIndices: number[] = [];
1159
1196
  const sectionRegions: SectionRegion[] = [];
1160
1197
  {
1198
+ // Build lineNumber → message index lookup. This is used instead of
1199
+ // messages.indexOf() because collapse projection creates spread copies
1200
+ // of messages, breaking reference equality.
1201
+ const msgLineToIndex = new Map<number, number>();
1202
+ messages.forEach((m, i) => msgLineToIndex.set(m.lineNumber, i));
1203
+
1204
+ const findMsgIndex = (child: SequenceElement): number =>
1205
+ msgLineToIndex.get(child.lineNumber) ?? -1;
1206
+
1161
1207
  const collectMsgIndicesFromBlock = (
1162
1208
  block: import('./parser').SequenceBlock
1163
1209
  ): number[] => {
@@ -1166,7 +1212,7 @@ export function renderSequenceDiagram(
1166
1212
  if (isSequenceBlock(child)) {
1167
1213
  indices.push(...collectMsgIndicesFromBlock(child));
1168
1214
  } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
1169
- const idx = messages.indexOf(child as SequenceMessage);
1215
+ const idx = findMsgIndex(child);
1170
1216
  if (idx >= 0) indices.push(idx);
1171
1217
  }
1172
1218
  }
@@ -1176,7 +1222,7 @@ export function renderSequenceDiagram(
1176
1222
  if (isSequenceBlock(child)) {
1177
1223
  indices.push(...collectMsgIndicesFromBlock(child));
1178
1224
  } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
1179
- const idx = messages.indexOf(child as SequenceMessage);
1225
+ const idx = findMsgIndex(child);
1180
1226
  if (idx >= 0) indices.push(idx);
1181
1227
  }
1182
1228
  }
@@ -1186,7 +1232,7 @@ export function renderSequenceDiagram(
1186
1232
  if (isSequenceBlock(child)) {
1187
1233
  indices.push(...collectMsgIndicesFromBlock(child));
1188
1234
  } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
1189
- const idx = messages.indexOf(child as SequenceMessage);
1235
+ const idx = findMsgIndex(child);
1190
1236
  if (idx >= 0) indices.push(idx);
1191
1237
  }
1192
1238
  }
@@ -1202,7 +1248,7 @@ export function renderSequenceDiagram(
1202
1248
  } else if (isSequenceBlock(el)) {
1203
1249
  currentTarget.push(...collectMsgIndicesFromBlock(el));
1204
1250
  } else {
1205
- const idx = messages.indexOf(el as SequenceMessage);
1251
+ const idx = findMsgIndex(el);
1206
1252
  if (idx >= 0) currentTarget.push(idx);
1207
1253
  }
1208
1254
  }
@@ -1285,8 +1331,10 @@ export function renderSequenceDiagram(
1285
1331
  const LEGEND_FIXED_GAP = 8;
1286
1332
  const legendTopSpace =
1287
1333
  parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
1334
+ // Use parsed.groups (not projected groups) to keep vertical space consistent
1335
+ // even when all groups are collapsed into virtual participants
1288
1336
  const groupOffset =
1289
- groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
1337
+ parsed.groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
1290
1338
  const participantStartY =
1291
1339
  TOP_MARGIN +
1292
1340
  titleOffset +
@@ -1618,7 +1666,23 @@ export function renderSequenceDiagram(
1618
1666
  );
1619
1667
  }
1620
1668
 
1621
- // Render group boxes (behind participant shapes)
1669
+ // Build set of collapsed group names for drill-bar rendering
1670
+ const collapsedGroupNames = new Set<string>();
1671
+ const collapsedGroupMeta = new Map<
1672
+ string,
1673
+ { lineNumber: number; metadata?: Record<string, string> }
1674
+ >();
1675
+ for (const group of parsed.groups) {
1676
+ if (effectiveCollapsedGroups.has(group.lineNumber)) {
1677
+ collapsedGroupNames.add(group.name);
1678
+ collapsedGroupMeta.set(group.name, {
1679
+ lineNumber: group.lineNumber,
1680
+ metadata: group.metadata,
1681
+ });
1682
+ }
1683
+ }
1684
+
1685
+ // Render group boxes (behind participant shapes) — skip collapsed groups
1622
1686
  for (const group of groups) {
1623
1687
  if (group.participantIds.length === 0) continue;
1624
1688
 
@@ -1650,7 +1714,15 @@ export function renderSequenceDiagram(
1650
1714
  : palette.bg;
1651
1715
  const strokeColor = groupTagColor || palette.textMuted;
1652
1716
 
1653
- svg
1717
+ const groupG = svg
1718
+ .append('g')
1719
+ .attr('class', 'group-box-wrapper')
1720
+ .attr('data-group-toggle', '')
1721
+ .attr('data-group-line', String(group.lineNumber))
1722
+ .attr('cursor', 'pointer');
1723
+ groupG.append('title').text('Click to collapse');
1724
+
1725
+ groupG
1654
1726
  .append('rect')
1655
1727
  .attr('x', minX)
1656
1728
  .attr('y', boxY)
@@ -1661,11 +1733,10 @@ export function renderSequenceDiagram(
1661
1733
  .attr('stroke', strokeColor)
1662
1734
  .attr('stroke-width', 1)
1663
1735
  .attr('stroke-opacity', 0.5)
1664
- .attr('class', 'group-box')
1665
- .attr('data-group-line', String(group.lineNumber));
1736
+ .attr('class', 'group-box');
1666
1737
 
1667
1738
  // Group label
1668
- svg
1739
+ groupG
1669
1740
  .append('text')
1670
1741
  .attr('x', minX + 8)
1671
1742
  .attr('y', boxY + GROUP_LABEL_SIZE + 4)
@@ -1674,7 +1745,6 @@ export function renderSequenceDiagram(
1674
1745
  .attr('font-weight', 'bold')
1675
1746
  .attr('opacity', 0.7)
1676
1747
  .attr('class', 'group-label')
1677
- .attr('data-group-line', String(group.lineNumber))
1678
1748
  .text(group.name);
1679
1749
  }
1680
1750
 
@@ -1690,6 +1760,16 @@ export function renderSequenceDiagram(
1690
1760
  tagKey && pTagValue
1691
1761
  ? { key: tagKey, value: pTagValue.toLowerCase() }
1692
1762
  : undefined;
1763
+ // For collapsed group participants, resolve tag color from group metadata
1764
+ const isCollapsedGroup = collapsedGroupNames.has(participant.id);
1765
+ let effectiveTagColor = pTagColor;
1766
+ if (isCollapsedGroup && !effectiveTagColor) {
1767
+ const meta = collapsedGroupMeta.get(participant.id);
1768
+ if (meta?.metadata && tagKey) {
1769
+ effectiveTagColor = getTagColor(meta.metadata[tagKey]);
1770
+ }
1771
+ }
1772
+
1693
1773
  renderParticipant(
1694
1774
  svg,
1695
1775
  participant,
@@ -1697,18 +1777,104 @@ export function renderSequenceDiagram(
1697
1777
  cy,
1698
1778
  palette,
1699
1779
  isDark,
1700
- pTagColor,
1780
+ effectiveTagColor,
1701
1781
  pTagAttr
1702
1782
  );
1703
1783
 
1704
- // Render lifeline
1784
+ // Collapsed group: re-render participant box at full group height + drill-bar
1785
+ if (isCollapsedGroup) {
1786
+ const meta = collapsedGroupMeta.get(participant.id)!;
1787
+ const drillColor = effectiveTagColor || palette.textMuted;
1788
+ const drillBarH = 6;
1789
+ const boxW = PARTICIPANT_BOX_WIDTH;
1790
+ // Match the group box dimensions
1791
+ const fullH =
1792
+ PARTICIPANT_BOX_HEIGHT + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;
1793
+ const clipId = `clip-drill-group-${participant.id.replace(/[^a-zA-Z0-9-]/g, '-')}`;
1794
+
1795
+ // Add toggle attributes to the participant <g> so any click on it
1796
+ // (overlay rect, label, drill-bar) walks up and triggers the toggle
1797
+ const participantG = svg.select<SVGGElement>(
1798
+ `.participant[data-participant-id="${participant.id}"]`
1799
+ );
1800
+ participantG
1801
+ .attr('data-group-toggle', '')
1802
+ .attr('data-group-line', String(meta.lineNumber))
1803
+ .attr('cursor', 'pointer');
1804
+ participantG.append('title').text('Click to expand');
1805
+
1806
+ // Overlay a taller rect to replace the standard participant box
1807
+ const pFill = effectiveTagColor
1808
+ ? mix(
1809
+ effectiveTagColor,
1810
+ isDark ? palette.surface : palette.bg,
1811
+ isDark ? 30 : 40
1812
+ )
1813
+ : isDark
1814
+ ? mix(palette.overlay, palette.surface, 50)
1815
+ : mix(palette.bg, palette.surface, 50);
1816
+ const pStroke = effectiveTagColor || palette.border;
1817
+
1818
+ // Taller box inside the participant <g> (local coords, y=0 is participant cy)
1819
+ participantG
1820
+ .append('rect')
1821
+ .attr('x', -boxW / 2)
1822
+ .attr('y', -GROUP_PADDING_TOP)
1823
+ .attr('width', boxW)
1824
+ .attr('height', fullH)
1825
+ .attr('rx', 6)
1826
+ .attr('fill', pFill)
1827
+ .attr('stroke', pStroke)
1828
+ .attr('stroke-width', 1.5);
1829
+
1830
+ // Re-render label centered in the taller box (local coords)
1831
+ participantG
1832
+ .append('text')
1833
+ .attr('x', 0)
1834
+ .attr('y', -GROUP_PADDING_TOP + fullH / 2)
1835
+ .attr('text-anchor', 'middle')
1836
+ .attr('dominant-baseline', 'central')
1837
+ .attr('fill', palette.text)
1838
+ .attr('font-size', 13)
1839
+ .attr('font-weight', 500)
1840
+ .text(participant.label);
1841
+
1842
+ // Drill-bar at bottom (local coords)
1843
+ participantG
1844
+ .append('clipPath')
1845
+ .attr('id', clipId)
1846
+ .append('rect')
1847
+ .attr('x', -boxW / 2)
1848
+ .attr('y', -GROUP_PADDING_TOP)
1849
+ .attr('width', boxW)
1850
+ .attr('height', fullH)
1851
+ .attr('rx', 6);
1852
+
1853
+ participantG
1854
+ .append('rect')
1855
+ .attr('class', 'sequence-drill-bar')
1856
+ .attr('x', -boxW / 2)
1857
+ .attr('y', -GROUP_PADDING_TOP + fullH - drillBarH)
1858
+ .attr('width', boxW)
1859
+ .attr('height', drillBarH)
1860
+ .attr('fill', drillColor)
1861
+ .attr('clip-path', `url(#${clipId})`);
1862
+ }
1863
+
1864
+ // Render lifeline — collapsed groups start below the taller box
1865
+ const llY = isCollapsedGroup
1866
+ ? lifelineStartY + GROUP_PADDING_BOTTOM
1867
+ : lifelineStartY;
1868
+ const llColor = isCollapsedGroup
1869
+ ? effectiveTagColor || palette.textMuted
1870
+ : pTagColor || palette.textMuted;
1705
1871
  const lifelineEl = svg
1706
1872
  .append('line')
1707
1873
  .attr('x1', cx)
1708
- .attr('y1', lifelineStartY)
1874
+ .attr('y1', llY)
1709
1875
  .attr('x2', cx)
1710
1876
  .attr('y2', lifelineStartY + lifelineLength)
1711
- .attr('stroke', pTagColor || palette.textMuted)
1877
+ .attr('stroke', llColor)
1712
1878
  .attr('stroke-width', 1)
1713
1879
  .attr('stroke-dasharray', '6 4')
1714
1880
  .attr('class', 'lifeline')
@@ -2104,43 +2270,14 @@ export function renderSequenceDiagram(
2104
2270
  ? `${sec.label} (${msgCount} ${msgCount === 1 ? 'message' : 'messages'})`
2105
2271
  : sec.label;
2106
2272
 
2107
- // Collapsed sections use white text for contrast against the darker band
2108
- const labelColor = isCollapsed ? '#ffffff' : lineColor;
2109
-
2110
- // Chevron indicator
2111
- const chevronSpace = 14;
2112
- const labelX = (sectionLineX1 + sectionLineX2) / 2;
2113
- const chevronX = labelX - (labelText.length * 3.5 + 8 + chevronSpace / 2);
2114
- const chevronY = secY;
2115
- if (isCollapsed) {
2116
- // Right-pointing triangle ▶
2117
- sectionG
2118
- .append('path')
2119
- .attr(
2120
- 'd',
2121
- `M ${chevronX} ${chevronY - 4} L ${chevronX + 6} ${chevronY} L ${chevronX} ${chevronY + 4} Z`
2122
- )
2123
- .attr('fill', labelColor)
2124
- .attr('class', 'section-chevron');
2125
- } else {
2126
- // Down-pointing triangle ▼
2127
- sectionG
2128
- .append('path')
2129
- .attr(
2130
- 'd',
2131
- `M ${chevronX - 1} ${chevronY - 3} L ${chevronX + 7} ${chevronY - 3} L ${chevronX + 3} ${chevronY + 3} Z`
2132
- )
2133
- .attr('fill', labelColor)
2134
- .attr('class', 'section-chevron');
2135
- }
2136
-
2137
2273
  // Centered label text
2274
+ const labelX = (sectionLineX1 + sectionLineX2) / 2;
2138
2275
  sectionG
2139
2276
  .append('text')
2140
- .attr('x', labelX + chevronSpace / 2)
2277
+ .attr('x', labelX)
2141
2278
  .attr('y', secY + 4)
2142
2279
  .attr('text-anchor', 'middle')
2143
- .attr('fill', labelColor)
2280
+ .attr('fill', lineColor)
2144
2281
  .attr('font-size', 11)
2145
2282
  .attr('font-weight', 'bold')
2146
2283
  .attr('class', 'section-label')