@diagrammo/dgmo 0.7.3 → 0.8.0

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 (62) hide show
  1. package/AGENTS.md +15 -20
  2. package/README.md +56 -58
  3. package/dist/cli.cjs +188 -181
  4. package/dist/index.cjs +3506 -1057
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +196 -43
  7. package/dist/index.d.ts +196 -43
  8. package/dist/index.js +3493 -1057
  9. package/dist/index.js.map +1 -1
  10. package/docs/language-reference.md +629 -289
  11. package/package.json +1 -1
  12. package/src/c4/layout.ts +6 -9
  13. package/src/c4/parser.ts +189 -83
  14. package/src/c4/renderer.ts +8 -9
  15. package/src/chart.ts +296 -83
  16. package/src/class/parser.ts +54 -37
  17. package/src/class/renderer.ts +8 -8
  18. package/src/cli.ts +8 -8
  19. package/src/colors.ts +4 -1
  20. package/src/completion.ts +757 -10
  21. package/src/d3.ts +310 -73
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +726 -231
  24. package/src/er/parser.ts +94 -76
  25. package/src/er/renderer.ts +6 -5
  26. package/src/gantt/parser.ts +144 -69
  27. package/src/gantt/renderer.ts +50 -14
  28. package/src/gantt/types.ts +3 -3
  29. package/src/graph/flowchart-parser.ts +97 -37
  30. package/src/graph/flowchart-renderer.ts +4 -3
  31. package/src/graph/state-parser.ts +50 -31
  32. package/src/graph/state-renderer.ts +4 -3
  33. package/src/index.ts +14 -5
  34. package/src/infra/compute.ts +1 -0
  35. package/src/infra/layout.ts +3 -0
  36. package/src/infra/parser.ts +291 -92
  37. package/src/infra/renderer.ts +172 -30
  38. package/src/infra/types.ts +5 -0
  39. package/src/initiative-status/layout.ts +1 -1
  40. package/src/initiative-status/parser.ts +121 -47
  41. package/src/initiative-status/renderer.ts +42 -23
  42. package/src/initiative-status/types.ts +10 -2
  43. package/src/kanban/parser.ts +60 -37
  44. package/src/kanban/renderer.ts +2 -2
  45. package/src/kanban/types.ts +1 -0
  46. package/src/org/layout.ts +9 -9
  47. package/src/org/parser.ts +39 -40
  48. package/src/org/renderer.ts +5 -6
  49. package/src/org/resolver.ts +26 -19
  50. package/src/render.ts +1 -1
  51. package/src/sequence/parser.ts +304 -95
  52. package/src/sequence/renderer.ts +9 -9
  53. package/src/sitemap/layout.ts +3 -4
  54. package/src/sitemap/parser.ts +57 -49
  55. package/src/sitemap/renderer.ts +6 -7
  56. package/src/utils/arrows.ts +25 -6
  57. package/src/utils/duration.ts +43 -7
  58. package/src/utils/legend-constants.ts +26 -0
  59. package/src/utils/legend-svg.ts +167 -0
  60. package/src/utils/parsing.ts +247 -7
  61. package/src/utils/tag-groups.ts +160 -15
  62. package/src/utils/title-constants.ts +9 -0
@@ -6,10 +6,16 @@ import { inferParticipantType } from './participant-inference';
6
6
  import type { DgmoError } from '../diagnostics';
7
7
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
8
8
  import { parseArrow } from '../utils/arrows';
9
- import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from '../utils/parsing';
9
+ import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING, parseFirstLine, OPTION_NOCOLON_RE } from '../utils/parsing';
10
10
  import type { TagGroup } from '../utils/tag-groups';
11
11
  import { matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
12
12
 
13
+ /** Known sequence-diagram options that take a value (space-separated). */
14
+ const KNOWN_SEQ_OPTIONS = new Set(['active-tag']);
15
+
16
+ /** Known sequence-diagram boolean options (bare keyword or `no-` prefix). */
17
+ const KNOWN_SEQ_BOOLEANS = new Set(['activations', 'collapse-notes']);
18
+
13
19
  /**
14
20
  * Participant types that can be declared via "Name is a type" syntax.
15
21
  */
@@ -165,8 +171,11 @@ const POSITION_ONLY_PATTERN = /^([^:]+?)\s+position\s+(-?\d+)$/i;
165
171
  // Colored participant declaration — e.g. "Tapin2(green)", "API(blue)"
166
172
  const COLORED_PARTICIPANT_PATTERN = /^(\S+?)\(([^)]+)\)\s*$/;
167
173
 
168
- // Group heading pattern — "[Backend]", "[API Services(blue)]", "[Backend(#hex)]"
169
- const GROUP_HEADING_PATTERN = /^\[(.+?)(?:\(([^)]+)\))?\]\s*$/;
174
+ // Group heading pattern — "[Backend]", "[Backend] | t: Product"
175
+ // Group 1: name (no ] or | inside brackets), Group 2: color in parens, Group 3: after-bracket text
176
+ const GROUP_HEADING_PATTERN = /^\[([^\]|]+?)(?:\(([^)]+)\))?\]\s*(.*)$/;
177
+ // Fallback: allows anything inside brackets (used to detect pipe-inside-brackets error)
178
+ const GROUP_HEADING_FALLBACK = /^\[([^\]]+)\]\s*(.*)$/;
170
179
  // Legacy ## syntax — detect and emit migration error
171
180
  const LEGACY_GROUP_PATTERN = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
172
181
 
@@ -176,10 +185,169 @@ const SECTION_PATTERN = /^==\s+(.+?)(?:\s*==)?\s*$/;
176
185
  // Arrow pattern for sequence inference — detects any arrow form
177
186
  const ARROW_PATTERN = /\S+\s*(?:<-\S+-|<~\S+~|-\S+->|~\S+~>|->|~>|<-|<~)\s*\S+/;
178
187
 
179
- // Note patterns — "note: text", "note right of Auth Server: text"
180
- // Participant names may contain spaces; the colon acts as the delimiter.
181
- const NOTE_SINGLE = /^note(?:\s+(right|left)\s+of\s+(.+?))?\s*:\s*(.+)$/i;
182
- const NOTE_MULTI = /^note(?:\s+(right|left)\s+of\s+(.+?))?\s*:?\s*$/i;
188
+ // Note patterns — colon-free syntax
189
+ // Single-line: "note text", "note left text", "note right of X text", "note left X text"
190
+ // Multi-line: "note", "note right", "note right of X", "note left X" (body indented below)
191
+ // Also supports legacy colon syntax: "note: text", "note right of X: text"
192
+ //
193
+ // The colon-free positioned form requires participant resolution — the parser
194
+ // already has participant collection infrastructure, so we match the general
195
+ // structure here and resolve participant vs text in the parsing logic.
196
+ const NOTE_SINGLE_COLON = /^note(?:\s+(right|left)(?:\s+(?:of\s+)?(.+?))?)?\s*:\s*(.+)$/i;
197
+ const NOTE_BARE = /^note\s+(.+)$/i;
198
+ const NOTE_MULTI = /^note(?:\s+(right|left)(?:\s+(?:of\s+)?(.+?))?)?\s*:?\s*$/i;
199
+
200
+ /** Result of parseNoteLine — indicates what the parser should do. */
201
+ type NoteParseResult =
202
+ | { kind: 'single'; position: 'right' | 'left'; participantId: string; text: string }
203
+ | { kind: 'multi-head'; position: 'right' | 'left'; participantId: string }
204
+ | { kind: 'skip' }
205
+ | null; // not a note line at all
206
+
207
+ /**
208
+ * Parse a note line, resolving participant names from the known participants list.
209
+ *
210
+ * Supports:
211
+ * - `note: text` / `note text` — default position (right), last msg sender
212
+ * - `note left of X: text` / `note left of X text` / `note left X text`
213
+ * - `note right:` / `note right` — multi-line head
214
+ * - `note right of X:` / `note right of X` / `note left X` — multi-line head
215
+ * - Quoted participant: `note left "Auth Service" text`
216
+ */
217
+ function parseNoteLine(
218
+ trimmed: string,
219
+ participants: SequenceParticipant[],
220
+ lastMsgFrom: string | null,
221
+ ): NoteParseResult {
222
+ const lower = trimmed.toLowerCase();
223
+ if (!lower.startsWith('note')) return null;
224
+ // Must be exactly "note" or "note " — not "notebook" etc.
225
+ if (trimmed.length > 4 && trimmed[4] !== ' ' && trimmed[4] !== ':') return null;
226
+
227
+ // 1. Try legacy colon-based syntax first
228
+ const colonMatch = trimmed.match(NOTE_SINGLE_COLON);
229
+ if (colonMatch) {
230
+ const position = (colonMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
231
+ let participantId = colonMatch[2] || null;
232
+ if (!participantId) {
233
+ if (!lastMsgFrom) return { kind: 'skip' };
234
+ participantId = lastMsgFrom;
235
+ }
236
+ if (!participants.some((p) => p.id === participantId)) return { kind: 'skip' };
237
+ return { kind: 'single', position, participantId, text: colonMatch[3].trim() };
238
+ }
239
+
240
+ // 2. Try multi-line head (no text after note): `note`, `note right`, `note right of X`, `note left X`
241
+ // NOTE: NOTE_MULTI's (.+?) can greedily capture "participant text" as one group.
242
+ // Only trust this match if the captured participant actually exists. Otherwise, fall
243
+ // through to the bare-note handler which does proper participant-aware splitting.
244
+ const multiMatch = trimmed.match(NOTE_MULTI);
245
+ if (multiMatch) {
246
+ const position = (multiMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
247
+ let participantId = multiMatch[2] || null;
248
+ if (!participantId) {
249
+ if (!lastMsgFrom) return { kind: 'skip' };
250
+ participantId = lastMsgFrom;
251
+ }
252
+ if (participants.some((p) => p.id === participantId)) {
253
+ return { kind: 'multi-head', position, participantId };
254
+ }
255
+ // Participant not found — fall through to bare-note handler for proper resolution
256
+ }
257
+
258
+ // 3. Bare note (colon-free): `note text` or `note left [of] X text`
259
+ const bareMatch = trimmed.match(NOTE_BARE);
260
+ if (bareMatch) {
261
+ const rest = bareMatch[1].trim();
262
+ const restLower = rest.toLowerCase();
263
+
264
+ // Check for positioned note: `note left/right ...`
265
+ if (restLower.startsWith('left') || restLower.startsWith('right')) {
266
+ const posWord = restLower.startsWith('left') ? 'left' : 'right';
267
+ const position = posWord as 'right' | 'left';
268
+ let afterPos = rest.substring(posWord.length).trim();
269
+
270
+ // Strip optional `of` keyword — track whether it was present
271
+ let hadOf = false;
272
+ if (afterPos.toLowerCase().startsWith('of ')) {
273
+ afterPos = afterPos.substring(3).trim();
274
+ hadOf = true;
275
+ }
276
+
277
+ if (!afterPos) {
278
+ // Just `note left` or `note right` — multi-line head
279
+ if (!lastMsgFrom) return { kind: 'skip' };
280
+ if (!participants.some((p) => p.id === lastMsgFrom)) return { kind: 'skip' };
281
+ return { kind: 'multi-head', position, participantId: lastMsgFrom };
282
+ }
283
+
284
+ // Try to match a known participant at the start of afterPos
285
+ const resolved = resolveParticipantAndText(afterPos, participants);
286
+ if (resolved) {
287
+ if (resolved.text) {
288
+ return { kind: 'single', position, participantId: resolved.participantId, text: resolved.text };
289
+ } else {
290
+ // No text after participant — multi-line head
291
+ return { kind: 'multi-head', position, participantId: resolved.participantId };
292
+ }
293
+ }
294
+
295
+ // No known participant matched.
296
+ // If `of` was explicit (`note right of Z ...`), the user intended a specific
297
+ // participant — skip when it doesn't exist rather than defaulting.
298
+ if (hadOf) return { kind: 'skip' };
299
+
300
+ // Without `of`, treat remaining text as note content on the last-msg sender
301
+ if (!lastMsgFrom) return { kind: 'skip' };
302
+ if (!participants.some((p) => p.id === lastMsgFrom)) return { kind: 'skip' };
303
+ return { kind: 'single', position, participantId: lastMsgFrom, text: afterPos };
304
+ }
305
+
306
+ // Plain `note text` — default position, last msg sender
307
+ if (!lastMsgFrom) return { kind: 'skip' };
308
+ if (!participants.some((p) => p.id === lastMsgFrom)) return { kind: 'skip' };
309
+ return { kind: 'single', position: 'right', participantId: lastMsgFrom, text: rest };
310
+ }
311
+
312
+ return null;
313
+ }
314
+
315
+ /**
316
+ * Try to match a known participant name at the start of a string.
317
+ * Returns the matched participant and remaining text, or null if no match.
318
+ * Tries longest match first (multi-word participant names).
319
+ */
320
+ function resolveParticipantAndText(
321
+ input: string,
322
+ participants: SequenceParticipant[],
323
+ ): { participantId: string; text: string } | null {
324
+ // Handle quoted participant: `"Auth Service" text`
325
+ if (input.startsWith('"') || input.startsWith("'")) {
326
+ const quote = input[0];
327
+ const endQuote = input.indexOf(quote, 1);
328
+ if (endQuote > 0) {
329
+ const name = input.substring(1, endQuote);
330
+ if (participants.some((p) => p.id === name)) {
331
+ const text = input.substring(endQuote + 1).trim();
332
+ return { participantId: name, text };
333
+ }
334
+ }
335
+ return null;
336
+ }
337
+
338
+ // Sort participants by name length (longest first) for greedy matching
339
+ const sorted = [...participants].sort((a, b) => b.id.length - a.id.length);
340
+ for (const p of sorted) {
341
+ if (input.startsWith(p.id)) {
342
+ const remaining = input.substring(p.id.length);
343
+ // Must be followed by whitespace, end of string, or nothing
344
+ if (remaining === '' || remaining[0] === ' ' || remaining[0] === '\t') {
345
+ return { participantId: p.id, text: remaining.trim() };
346
+ }
347
+ }
348
+ }
349
+ return null;
350
+ }
183
351
 
184
352
  /**
185
353
  * Parse a .dgmo file with `chart: sequence` into a structured representation.
@@ -225,6 +393,23 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
225
393
  const lines = content.split('\n');
226
394
  let hasExplicitChart = false;
227
395
  let contentStarted = false;
396
+ let firstLineIndex = -1; // line index of the `sequence [Title]` first line (to skip in main loop)
397
+
398
+ // Handle first non-empty, non-comment line for `sequence Title` syntax
399
+ for (let fi = 0; fi < lines.length; fi++) {
400
+ const fl = lines[fi].trim();
401
+ if (!fl || fl.startsWith('//')) continue;
402
+ const parsed = parseFirstLine(fl);
403
+ if (parsed && parsed.chartType === 'sequence') {
404
+ hasExplicitChart = true;
405
+ firstLineIndex = fi;
406
+ if (parsed.title) {
407
+ result.title = parsed.title;
408
+ result.titleLineNumber = fi + 1;
409
+ }
410
+ }
411
+ break;
412
+ }
228
413
 
229
414
  // Group parsing state — tracks the active [Group] heading
230
415
  let activeGroup: SequenceGroup | null = null;
@@ -276,28 +461,22 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
276
461
  continue;
277
462
  }
278
463
 
279
- // Parse group heading [Group Name] or [Group Name(color)] or [Group | k: v]
464
+ // Skip first line already handled as `sequence [Title]`
465
+ if (i === firstLineIndex) continue;
466
+
467
+ // Parse group heading — [Group Name] or [Group Name] | k: v
280
468
  const groupMatch = trimmed.match(GROUP_HEADING_PATTERN);
281
469
  if (groupMatch) {
282
- let groupName = groupMatch[1].trim();
283
- let groupColor = groupMatch[2]?.trim();
470
+ const groupName = groupMatch[1].trim();
471
+ const groupColor = groupMatch[2]?.trim();
284
472
  let groupMeta: Record<string, string> | undefined;
285
473
 
286
- // Check for pipe metadata inside brackets
287
- const gpipeIdx = groupName.indexOf('|');
288
- if (gpipeIdx >= 0) {
289
- const nameAndColor = groupName.substring(0, gpipeIdx).trimEnd();
290
- const segments = groupName.substring(gpipeIdx).split('|');
474
+ // Parse pipe metadata AFTER the closing bracket
475
+ const afterBracket = groupMatch[3]?.trim() || '';
476
+ if (afterBracket.startsWith('|')) {
477
+ const segments = afterBracket.split('|');
291
478
  const meta = parsePipeMetadata(segments, aliasMap, () => pushWarning(lineNumber, MULTIPLE_PIPE_WARNING));
292
479
  if (Object.keys(meta).length > 0) groupMeta = meta;
293
- // Re-extract color from name part
294
- const colorSuffix = nameAndColor.match(/^(.+?)\(([^)]+)\)$/);
295
- if (colorSuffix) {
296
- groupName = colorSuffix[1].trim();
297
- groupColor = colorSuffix[2].trim();
298
- } else {
299
- groupName = nameAndColor;
300
- }
301
480
  }
302
481
 
303
482
  if (groupColor) {
@@ -314,6 +493,19 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
314
493
  continue;
315
494
  }
316
495
 
496
+ // Detect pipe-inside-brackets error: [Name | meta] → suggest [Name] | meta
497
+ if (trimmed.startsWith('[')) {
498
+ const fallbackMatch = trimmed.match(GROUP_HEADING_FALLBACK);
499
+ if (fallbackMatch && fallbackMatch[1].includes('|')) {
500
+ const rawInside = fallbackMatch[1];
501
+ const pipeIdx = rawInside.indexOf('|');
502
+ const cleanName = rawInside.substring(0, pipeIdx).trim().replace(/\([^)]*\)$/, '').trim();
503
+ const metaPart = rawInside.substring(pipeIdx).trim();
504
+ pushError(lineNumber, `Pipe metadata must go outside brackets — use '[${cleanName}] ${metaPart}' instead of '[${rawInside.trim()}]'`);
505
+ continue;
506
+ }
507
+ }
508
+
317
509
  // Reject legacy ## group syntax with migration hint
318
510
  if (trimmed.match(LEGACY_GROUP_PATTERN)) {
319
511
  const legacyMatch = trimmed.match(LEGACY_GROUP_PATTERN)!;
@@ -359,18 +551,16 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
359
551
  continue;
360
552
  }
361
553
 
362
- // Tag group entries (indented Value(color) [default] under tag: heading)
554
+ // Tag group entries (indented Value(color) under tag heading)
555
+ // First entry is automatically the default (no `default` keyword needed)
363
556
  if (currentTagGroup && !contentStarted && measureIndent(raw) > 0) {
364
- const isDefault = /\bdefault\s*$/.test(trimmed);
365
- const entryText = isDefault
366
- ? trimmed.replace(/\s+default\s*$/, '').trim()
367
- : trimmed;
368
- const { label, color } = extractColor(entryText);
557
+ const { label, color } = extractColor(trimmed);
369
558
  if (!color) {
370
559
  pushError(lineNumber, `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`);
371
560
  continue;
372
561
  }
373
- if (isDefault) {
562
+ // First entry is the default
563
+ if (currentTagGroup.entries.length === 0) {
374
564
  currentTagGroup.defaultValue = label;
375
565
  }
376
566
  currentTagGroup.entries.push({ value: label, color, lineNumber });
@@ -444,6 +634,37 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
444
634
  }
445
635
  }
446
636
 
637
+ // Parse space-separated options (no colon): `activations off`, `no-activations`, `active-tag Priority`
638
+ {
639
+ const optLower = trimmed.toLowerCase();
640
+ // Negated boolean: `no-activations` → options.activations = 'off'
641
+ if (optLower.startsWith('no-')) {
642
+ const base = optLower.substring(3);
643
+ if (KNOWN_SEQ_BOOLEANS.has(base)) {
644
+ if (contentStarted) {
645
+ pushError(lineNumber, `Options like '${trimmed}' must appear before the first message or declaration`);
646
+ continue;
647
+ }
648
+ result.options[base] = 'off';
649
+ continue;
650
+ }
651
+ }
652
+ // Key-value option: `active-tag Priority`
653
+ const spaceMatch = trimmed.match(OPTION_NOCOLON_RE);
654
+ if (spaceMatch) {
655
+ const optKey = spaceMatch[1].toLowerCase();
656
+ const optVal = spaceMatch[2].trim();
657
+ if (KNOWN_SEQ_OPTIONS.has(optKey) || KNOWN_SEQ_BOOLEANS.has(optKey)) {
658
+ if (contentStarted) {
659
+ pushError(lineNumber, `Options like '${trimmed}' must appear before the first message or declaration`);
660
+ continue;
661
+ }
662
+ result.options[optKey] = optVal;
663
+ continue;
664
+ }
665
+ }
666
+ }
667
+
447
668
  // Parse "Name is a type [aka Alias]" declarations (always top-level)
448
669
  const { core: isACore, meta: isAMeta } = splitPipe(trimmed, lineNumber);
449
670
  const isAMatch = isACore.match(IS_A_PATTERN);
@@ -529,7 +750,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
529
750
  if (coloredMatch && !ARROW_PATTERN.test(colorCore)) {
530
751
  const id = coloredMatch[1];
531
752
  const color = coloredMatch[2].trim();
532
- pushWarning(lineNumber, `(${color}) color syntax removed from sequence diagrams — use 'tag:' groups for coloring`);
753
+ pushError(lineNumber, `'${id}(${color})' syntax is no longer supported — use 'tag:' groups for coloring`);
533
754
  contentStarted = true;
534
755
  if (!result.participants.some((p) => p.id === id)) {
535
756
  result.participants.push({
@@ -688,8 +909,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
688
909
  }
689
910
 
690
911
  // ---- Deprecated bare return arrows: A <- B, A <~ B ----
691
- const bareReturnSync = arrowCore.match(/^(.+?)\s+<-\s+(.+)$/);
692
- const bareReturnAsync = arrowCore.match(/^(.+?)\s+<~\s+(.+)$/);
912
+ const bareReturnSync = arrowCore.match(/^(.+?)\s*<-\s*(.+)$/);
913
+ const bareReturnAsync = arrowCore.match(/^(.+?)\s*<~\s*(.+)$/);
693
914
  const bareReturn = bareReturnSync || bareReturnAsync;
694
915
  if (bareReturn) {
695
916
  const to = bareReturn[1];
@@ -828,66 +1049,54 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
828
1049
  continue;
829
1050
  }
830
1051
 
831
- // Parse single-line note "note: text" or "note right of API: text"
832
- const noteSingleMatch = trimmed.match(NOTE_SINGLE);
833
- if (noteSingleMatch) {
834
- const notePosition =
835
- (noteSingleMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
836
- let noteParticipant = noteSingleMatch[2] || null;
837
- if (!noteParticipant) {
838
- if (!lastMsgFrom) continue; // incomplete — skip during live typing
839
- noteParticipant = lastMsgFrom;
840
- }
841
- if (!result.participants.some((p) => p.id === noteParticipant)) {
842
- continue; // unknown participant — skip during live typing
843
- }
844
- const note: SequenceNote = {
845
- kind: 'note',
846
- text: noteSingleMatch[3].trim(),
847
- position: notePosition,
848
- participantId: noteParticipant,
849
- lineNumber,
850
- endLineNumber: lineNumber,
851
- };
852
- currentContainer().push(note);
853
- continue;
854
- }
855
-
856
- // Parse multi-line note "note" or "note right of API" (no colon, body indented below)
857
- const noteMultiMatch = trimmed.match(NOTE_MULTI);
858
- if (noteMultiMatch) {
859
- const notePosition =
860
- (noteMultiMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
861
- let noteParticipant = noteMultiMatch[2] || null;
862
- if (!noteParticipant) {
863
- if (!lastMsgFrom) continue; // incomplete — skip during live typing
864
- noteParticipant = lastMsgFrom;
865
- }
866
- if (!result.participants.some((p) => p.id === noteParticipant)) {
867
- continue; // unknown participant — skip during live typing
868
- }
869
- // Collect indented body lines
870
- const noteLines: string[] = [];
871
- while (i + 1 < lines.length) {
872
- const nextRaw = lines[i + 1];
873
- const nextTrimmed = nextRaw.trim();
874
- if (!nextTrimmed) break;
875
- const nextIndent = measureIndent(nextRaw);
876
- if (nextIndent <= indent) break;
877
- noteLines.push(nextTrimmed);
878
- i++;
879
- }
880
- if (noteLines.length === 0) continue; // no body yet — skip during live typing
881
- const note: SequenceNote = {
882
- kind: 'note',
883
- text: noteLines.join('\n'),
884
- position: notePosition,
885
- participantId: noteParticipant,
886
- lineNumber,
887
- endLineNumber: i + 1, // i has advanced past the body lines (1-based)
888
- };
889
- currentContainer().push(note);
890
- continue;
1052
+ // ---- Note parsing (colon-free + legacy colon syntax) ----
1053
+ // Strategy:
1054
+ // 1. Try colon-based syntax: `note right of X: text` (legacy, still supported)
1055
+ // 2. Try bare note: `note text` — position defaults, text is everything after `note`
1056
+ // 3. For positioned: `note left [of] X text` needs participant lookup to split name vs text
1057
+ // 4. Multi-line: `note`, `note right`, `note right [of] X` (body indented below)
1058
+ {
1059
+ const noteParsed = parseNoteLine(trimmed, result.participants, lastMsgFrom);
1060
+ if (noteParsed) {
1061
+ if (noteParsed.kind === 'single') {
1062
+ const note: SequenceNote = {
1063
+ kind: 'note',
1064
+ text: noteParsed.text,
1065
+ position: noteParsed.position,
1066
+ participantId: noteParsed.participantId,
1067
+ lineNumber,
1068
+ endLineNumber: lineNumber,
1069
+ };
1070
+ currentContainer().push(note);
1071
+ continue;
1072
+ }
1073
+ if (noteParsed.kind === 'multi-head') {
1074
+ // Collect indented body lines
1075
+ const noteLines: string[] = [];
1076
+ while (i + 1 < lines.length) {
1077
+ const nextRaw = lines[i + 1];
1078
+ const nextTrimmed = nextRaw.trim();
1079
+ if (!nextTrimmed) break;
1080
+ const nextIndent = measureIndent(nextRaw);
1081
+ if (nextIndent <= indent) break;
1082
+ noteLines.push(nextTrimmed);
1083
+ i++;
1084
+ }
1085
+ if (noteLines.length === 0) continue; // no body yet — skip during live typing
1086
+ const note: SequenceNote = {
1087
+ kind: 'note',
1088
+ text: noteLines.join('\n'),
1089
+ position: noteParsed.position,
1090
+ participantId: noteParsed.participantId,
1091
+ lineNumber,
1092
+ endLineNumber: i + 1, // i has advanced past the body lines (1-based)
1093
+ };
1094
+ currentContainer().push(note);
1095
+ continue;
1096
+ }
1097
+ // 'skip' — note was incomplete (no preceding message, unknown participant)
1098
+ continue;
1099
+ }
891
1100
  }
892
1101
  }
893
1102
 
@@ -896,7 +1105,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
896
1105
  // Check if raw content has arrow patterns for inference
897
1106
  const hasArrows = lines.some((line) => ARROW_PATTERN.test(line.trim()));
898
1107
  if (!hasArrows) {
899
- return fail(1, 'No "chart: sequence" header and no sequence content detected');
1108
+ return fail(1, 'No "sequence" header and no sequence content detected');
900
1109
  }
901
1110
  }
902
1111
 
@@ -935,7 +1144,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
935
1144
  // Warn about empty groups
936
1145
  for (const group of result.groups) {
937
1146
  if (group.participantIds.length === 0) {
938
- pushWarning(group.lineNumber, `Group "${group.name}" has no participants`);
1147
+ pushWarning(group.lineNumber, `Empty group '${group.name}' did you mean '== ${group.name} ==' for a section divider?`);
939
1148
  }
940
1149
  }
941
1150
 
@@ -28,15 +28,15 @@ import {
28
28
  LEGEND_HEIGHT,
29
29
  LEGEND_PILL_PAD,
30
30
  LEGEND_PILL_FONT_SIZE,
31
- LEGEND_PILL_FONT_W,
32
31
  LEGEND_CAPSULE_PAD,
33
32
  LEGEND_DOT_R,
34
33
  LEGEND_ENTRY_FONT_SIZE,
35
- LEGEND_ENTRY_FONT_W,
36
34
  LEGEND_ENTRY_DOT_GAP,
37
35
  LEGEND_ENTRY_TRAIL,
38
36
  LEGEND_GROUP_GAP,
37
+ measureLegendText,
39
38
  } from '../utils/legend-constants';
39
+ import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
40
40
 
41
41
  // ============================================================
42
42
  // Layout Constants
@@ -932,7 +932,7 @@ export function renderSequenceDiagram(
932
932
  );
933
933
  if (tg) {
934
934
  for (const entry of tg.entries) {
935
- tagValueToColor.set(entry.value.toLowerCase(), resolveColor(entry.color));
935
+ tagValueToColor.set(entry.value.toLowerCase(), resolveColor(entry.color) ?? entry.color);
936
936
  }
937
937
  }
938
938
  }
@@ -1560,8 +1560,8 @@ export function renderSequenceDiagram(
1560
1560
  .attr('y', 30)
1561
1561
  .attr('text-anchor', 'middle')
1562
1562
  .attr('fill', palette.text)
1563
- .attr('font-size', 20)
1564
- .attr('font-weight', 'bold')
1563
+ .attr('font-size', TITLE_FONT_SIZE)
1564
+ .attr('font-weight', TITLE_FONT_WEIGHT)
1565
1565
  .text(title);
1566
1566
 
1567
1567
  if (parsed.titleLineNumber) {
@@ -1589,10 +1589,10 @@ export function renderSequenceDiagram(
1589
1589
  const isActive =
1590
1590
  !!activeTagGroup &&
1591
1591
  tg.name.toLowerCase() === activeTagGroup.toLowerCase();
1592
- const pillWidth = tg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1592
+ const pillWidth = measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1593
1593
  const entries = tg.entries.map((e) => ({
1594
1594
  value: e.value,
1595
- color: resolveColor(e.color),
1595
+ color: resolveColor(e.color) ?? e.color,
1596
1596
  }));
1597
1597
  let totalWidth = pillWidth;
1598
1598
  if (isActive) {
@@ -1601,7 +1601,7 @@ export function renderSequenceDiagram(
1601
1601
  entriesWidth +=
1602
1602
  LEGEND_DOT_R * 2 +
1603
1603
  LEGEND_ENTRY_DOT_GAP +
1604
- entry.value.length * LEGEND_ENTRY_FONT_W +
1604
+ measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
1605
1605
  LEGEND_ENTRY_TRAIL;
1606
1606
  }
1607
1607
  totalWidth = LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth;
@@ -1702,7 +1702,7 @@ export function renderSequenceDiagram(
1702
1702
  .attr('fill', palette.textMuted)
1703
1703
  .text(entry.value);
1704
1704
 
1705
- entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1705
+ entryX = textX + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
1706
1706
  }
1707
1707
  }
1708
1708
 
@@ -6,6 +6,7 @@ import dagre from '@dagrejs/dagre';
6
6
  import type { ParsedSitemap, SitemapNode } from './types';
7
7
  import type { TagGroup } from '../utils/tag-groups';
8
8
  import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
9
+ import { LEGEND_PILL_FONT_SIZE, LEGEND_ENTRY_FONT_SIZE, measureLegendText } from '../utils/legend-constants';
9
10
 
10
11
  // ============================================================
11
12
  // Types
@@ -105,10 +106,8 @@ const CONTAINER_META_LINE_HEIGHT = 16;
105
106
  // Legend (kanban-style pills)
106
107
  const LEGEND_HEIGHT = 28;
107
108
  const LEGEND_PILL_PAD = 16;
108
- const LEGEND_PILL_FONT_W = 11 * 0.6;
109
109
  const LEGEND_CAPSULE_PAD = 4;
110
110
  const LEGEND_DOT_R = 4;
111
- const LEGEND_ENTRY_FONT_W = 10 * 0.6;
112
111
  const LEGEND_ENTRY_DOT_GAP = 4;
113
112
  const LEGEND_ENTRY_TRAIL = 8;
114
113
  const LEGEND_GROUP_GAP = 12;
@@ -178,7 +177,7 @@ function computeLegendGroups(
178
177
  : group.entries;
179
178
  if (visibleEntries.length === 0) continue;
180
179
 
181
- const pillWidth = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
180
+ const pillWidth = measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
182
181
  const minPillWidth = pillWidth;
183
182
 
184
183
  let entriesWidth = 0;
@@ -186,7 +185,7 @@ function computeLegendGroups(
186
185
  entriesWidth +=
187
186
  LEGEND_DOT_R * 2 +
188
187
  LEGEND_ENTRY_DOT_GAP +
189
- entry.value.length * LEGEND_ENTRY_FONT_W +
188
+ measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
190
189
  LEGEND_ENTRY_TRAIL;
191
190
  }
192
191
  const eyeSpace = LEGEND_EYE_SIZE + LEGEND_EYE_GAP;