@diagrammo/dgmo 0.25.5 → 0.27.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 (139) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +4255 -2756
  3. package/dist/advanced.d.cts +285 -59
  4. package/dist/advanced.d.ts +285 -59
  5. package/dist/advanced.js +4253 -2750
  6. package/dist/auto.cjs +4051 -2589
  7. package/dist/auto.js +124 -122
  8. package/dist/auto.mjs +4051 -2589
  9. package/dist/cli.cjs +172 -170
  10. package/dist/editor.cjs +4 -0
  11. package/dist/editor.js +4 -0
  12. package/dist/highlight.cjs +4 -0
  13. package/dist/highlight.js +4 -0
  14. package/dist/index.cjs +4076 -2591
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +4076 -2591
  18. package/dist/internal.cjs +4255 -2756
  19. package/dist/internal.d.cts +285 -59
  20. package/dist/internal.d.ts +285 -59
  21. package/dist/internal.js +4253 -2750
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/airport-collisions.json +1 -0
  24. package/dist/map-data/airports.json +1 -0
  25. package/docs/language-reference.md +68 -18
  26. package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
  27. package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
  28. package/gallery/fixtures/map-region-values.dgmo +13 -0
  29. package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
  30. package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
  31. package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
  32. package/package.json +1 -1
  33. package/src/advanced.ts +3 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout.ts +146 -26
  36. package/src/boxes-and-lines/parser.ts +43 -8
  37. package/src/boxes-and-lines/renderer.ts +223 -96
  38. package/src/boxes-and-lines/types.ts +9 -2
  39. package/src/c4/layout.ts +14 -32
  40. package/src/c4/parser.ts +9 -5
  41. package/src/c4/renderer.ts +34 -39
  42. package/src/class/layout.ts +118 -18
  43. package/src/class/parser.ts +35 -1
  44. package/src/class/renderer.ts +58 -2
  45. package/src/class/types.ts +3 -0
  46. package/src/cli.ts +4 -4
  47. package/src/completion-types.ts +0 -1
  48. package/src/completion.ts +106 -51
  49. package/src/cycle/layout.ts +55 -72
  50. package/src/cycle/renderer.ts +11 -6
  51. package/src/d3.ts +78 -117
  52. package/src/diagnostics.ts +16 -0
  53. package/src/echarts.ts +46 -33
  54. package/src/editor/keywords.ts +4 -0
  55. package/src/er/layout.ts +114 -22
  56. package/src/er/parser.ts +28 -1
  57. package/src/er/renderer.ts +55 -2
  58. package/src/er/types.ts +3 -0
  59. package/src/gantt/renderer.ts +46 -38
  60. package/src/gantt/resolver.ts +9 -2
  61. package/src/graph/edge-spline.ts +29 -0
  62. package/src/graph/flowchart-parser.ts +35 -2
  63. package/src/graph/flowchart-renderer.ts +80 -52
  64. package/src/graph/layout.ts +206 -23
  65. package/src/graph/notes.ts +21 -0
  66. package/src/graph/state-parser.ts +26 -1
  67. package/src/graph/state-renderer.ts +80 -52
  68. package/src/graph/types.ts +13 -0
  69. package/src/index.ts +1 -1
  70. package/src/infra/layout.ts +46 -26
  71. package/src/infra/parser.ts +1 -1
  72. package/src/infra/renderer.ts +16 -7
  73. package/src/journey-map/layout.ts +38 -49
  74. package/src/journey-map/renderer.ts +22 -45
  75. package/src/kanban/renderer.ts +15 -6
  76. package/src/label-layout.ts +3 -3
  77. package/src/map/completion.ts +77 -22
  78. package/src/map/context-labels.ts +57 -12
  79. package/src/map/data/PROVENANCE.json +1 -1
  80. package/src/map/data/airport-collisions.json +1 -0
  81. package/src/map/data/airports.json +1 -0
  82. package/src/map/data/types.ts +19 -0
  83. package/src/map/layout.ts +1196 -90
  84. package/src/map/legend-band.ts +2 -2
  85. package/src/map/load-data.ts +10 -1
  86. package/src/map/parser.ts +61 -32
  87. package/src/map/renderer.ts +284 -12
  88. package/src/map/resolved-types.ts +15 -1
  89. package/src/map/resolver.ts +132 -12
  90. package/src/map/types.ts +28 -8
  91. package/src/migrate/embedded.ts +9 -7
  92. package/src/mindmap/text-wrap.ts +13 -14
  93. package/src/org/layout.ts +19 -17
  94. package/src/org/renderer.ts +11 -4
  95. package/src/palettes/color-utils.ts +82 -21
  96. package/src/palettes/index.ts +0 -19
  97. package/src/palettes/registry.ts +1 -1
  98. package/src/palettes/types.ts +2 -2
  99. package/src/pert/layout.ts +48 -40
  100. package/src/pert/parser.ts +0 -14
  101. package/src/pert/renderer.ts +30 -43
  102. package/src/pyramid/renderer.ts +4 -5
  103. package/src/raci/renderer.ts +42 -70
  104. package/src/render.ts +1 -1
  105. package/src/ring/renderer.ts +1 -2
  106. package/src/sequence/parser.ts +100 -22
  107. package/src/sequence/renderer.ts +75 -50
  108. package/src/sitemap/layout.ts +27 -19
  109. package/src/sitemap/renderer.ts +12 -5
  110. package/src/tech-radar/renderer.ts +11 -35
  111. package/src/utils/arrow-markers.ts +51 -0
  112. package/src/utils/fit-canvas.ts +64 -0
  113. package/src/utils/legend-constants.ts +8 -54
  114. package/src/utils/legend-d3.ts +10 -7
  115. package/src/utils/legend-layout.ts +7 -4
  116. package/src/utils/legend-types.ts +10 -4
  117. package/src/utils/note-box/constants.ts +25 -0
  118. package/src/utils/note-box/index.ts +11 -0
  119. package/src/utils/note-box/metrics.ts +90 -0
  120. package/src/utils/note-box/svg.ts +331 -0
  121. package/src/utils/notes/bounds.ts +30 -0
  122. package/src/utils/notes/build.ts +131 -0
  123. package/src/utils/notes/index.ts +18 -0
  124. package/src/utils/notes/model.ts +19 -0
  125. package/src/utils/notes/parse.ts +131 -0
  126. package/src/utils/notes/place.ts +177 -0
  127. package/src/utils/notes/resolve.ts +88 -0
  128. package/src/utils/number-format.ts +36 -0
  129. package/src/utils/parsing.ts +41 -0
  130. package/src/utils/reserved-key-registry.ts +4 -0
  131. package/src/utils/text-measure.ts +122 -0
  132. package/src/wireframe/layout.ts +4 -2
  133. package/src/wireframe/renderer.ts +8 -6
  134. package/src/palettes/dracula.ts +0 -68
  135. package/src/palettes/gruvbox.ts +0 -85
  136. package/src/palettes/monokai.ts +0 -68
  137. package/src/palettes/one-dark.ts +0 -70
  138. package/src/palettes/rose-pine.ts +0 -84
  139. package/src/palettes/solarized.ts +0 -77
@@ -43,6 +43,11 @@ import type {
43
43
  import { allTasks } from './parser';
44
44
  import { VARIANTS } from './variants';
45
45
  import { ScaleContext } from '../utils/scaling';
46
+ import {
47
+ measureText,
48
+ truncateText,
49
+ wrapTextToWidth,
50
+ } from '../utils/text-measure';
46
51
 
47
52
  /**
48
53
  * Group `parsed.diagnostics` per task, so the renderer can paint a
@@ -253,7 +258,8 @@ const ROLE_HEADER_FONT = 12;
253
258
  const PHASE_FONT = 13;
254
259
 
255
260
  // Cell-fill tint percentage of the marker color over surface bg.
256
- const TINT_PCT = 28;
261
+ // 25 = the canonical `shapeFill` tint used by every other chart type.
262
+ const TINT_PCT = 25;
257
263
  /**
258
264
  * Marker rect stroke + radius — matches the codebase's node styling
259
265
  * convention (flowchart `NODE_STROKE_WIDTH = 1.5`, kanban
@@ -515,6 +521,12 @@ export function renderRaci(
515
521
  const colBottomY = cursorY + sColumnBottomPad;
516
522
  const totalHeight = colBottomY + sVMargin;
517
523
 
524
+ // Export renders a fixed canvas (e.g. 1200×800); a short matrix would
525
+ // otherwise reserve a tall band of dead space below the last row. Size the
526
+ // export canvas to the content height. The interactive preview keeps the
527
+ // `max(pane, content)` behaviour so a short matrix still fills the pane.
528
+ const svgHeight = exportDims ? totalHeight : Math.max(height, totalHeight);
529
+
518
530
  // ── SVG root ───────────────────────────────────────────────
519
531
 
520
532
  const svg = d3Selection
@@ -522,8 +534,8 @@ export function renderRaci(
522
534
  .append('svg')
523
535
  .attr('xmlns', 'http://www.w3.org/2000/svg')
524
536
  .attr('width', width)
525
- .attr('height', Math.max(height, totalHeight))
526
- .attr('viewBox', `0 0 ${width} ${Math.max(height, totalHeight)}`)
537
+ .attr('height', svgHeight)
538
+ .attr('viewBox', `0 0 ${width} ${svgHeight}`)
527
539
  .attr('preserveAspectRatio', 'xMidYMin meet')
528
540
  .attr('font-family', FONT_FAMILY)
529
541
  .style('background', 'transparent');
@@ -544,20 +556,21 @@ export function renderRaci(
544
556
 
545
557
  // Size full-mode chips to actually fit the longest label without truncation.
546
558
  // chip = 4 pad + 24 slab + 8 gap + label + 8 right pad.
547
- const longestLabel = legendMarkers.reduce(
548
- (n, m) => Math.max(n, (variantLabels[m] ?? '').length),
559
+ const longestLabelPx = legendMarkers.reduce(
560
+ (n, m) =>
561
+ Math.max(n, measureText(variantLabels[m] ?? '', sLegendLabelFont)),
549
562
  0
550
563
  );
551
564
  const fullChipW = Math.max(
552
565
  sLegendChipLabelMin,
553
- Math.ceil(4 + 24 + 8 + longestLabel * sLegendLabelFont * 0.6 + 8)
566
+ Math.ceil(4 + 24 + 8 + longestLabelPx + 8)
554
567
  );
555
568
  const fullLegendW = numChips * fullChipW + chipGapTotal;
556
569
  const letterLegendW = numChips * sLegendLetterChipW + chipGapTotal;
557
570
 
558
- // Estimate title pixel width with the same heuristic used in truncateForWidth.
571
+ // Estimate title pixel width using the shared glyph measurer.
559
572
  const titleEstW =
560
- parsed.title && !hideTitle ? parsed.title.length * sTitleFontSize * 0.6 : 0;
573
+ parsed.title && !hideTitle ? measureText(parsed.title, sTitleFontSize) : 0;
561
574
 
562
575
  // Pick legend mode: full chips if everything fits, else letter-only.
563
576
  // If even letters won't fit beside the title, the title is truncated below.
@@ -590,7 +603,7 @@ export function renderRaci(
590
603
  .attr('font-size', sTitleFontSize)
591
604
  .attr('font-weight', TITLE_FONT_WEIGHT)
592
605
  .attr('fill', palette.text)
593
- .text(truncateForWidth(parsed.title, titleMaxW, sTitleFontSize));
606
+ .text(truncateText(parsed.title, sTitleFontSize, titleMaxW));
594
607
  }
595
608
 
596
609
  // Legend — right-aligned in the same row.
@@ -708,10 +721,10 @@ export function renderRaci(
708
721
  .attr('font-weight', 600)
709
722
  .attr('fill', palette.text)
710
723
  .text(
711
- truncateForWidth(
724
+ truncateText(
712
725
  parsed.roleDisplayNames[i] ?? '',
713
- roleColW - 2 * sCellPad,
714
- sRoleHeaderFont
726
+ sRoleHeaderFont,
727
+ roleColW - 2 * sCellPad
715
728
  )
716
729
  );
717
730
  });
@@ -925,7 +938,7 @@ function renderLegend(
925
938
  if (mode === 'letters') {
926
939
  // Compact: a single colored pill with just the marker letter.
927
940
  // The full label moves to a native tooltip so hover still teaches it.
928
- const fill = solid ? rawColor : mix(rawColor, surfaceBg, 28);
941
+ const fill = solid ? rawColor : mix(rawColor, surfaceBg, TINT_PCT);
929
942
  const stroke = solid ? mix(rawColor, surfaceBg, 70) : rawColor;
930
943
  chipG
931
944
  .append('rect')
@@ -960,7 +973,7 @@ function renderLegend(
960
973
  }
961
974
 
962
975
  // Full mode: bordered chip with a letter slab on the left and label text.
963
- const fill = solid ? rawColor : mix(rawColor, surfaceBg, 28);
976
+ const fill = solid ? rawColor : mix(rawColor, surfaceBg, TINT_PCT);
964
977
  const stroke = mix(rawColor, surfaceBg, 70);
965
978
 
966
979
  chipG
@@ -1020,10 +1033,10 @@ function renderLegend(
1020
1033
  .attr('font-weight', 600)
1021
1034
  .attr('fill', palette.text)
1022
1035
  .text(
1023
- truncateForWidth(
1036
+ truncateText(
1024
1037
  labelText,
1025
- chipW - slabW - slabPad * 2 - 12,
1026
- sLegendLabelFont
1038
+ sLegendLabelFont,
1039
+ chipW - slabW - slabPad * 2 - 12
1027
1040
  )
1028
1041
  );
1029
1042
  });
@@ -1117,7 +1130,7 @@ function renderPhaseBar(
1117
1130
  // can see at a glance what's in the rolled-up phase.
1118
1131
  const taskCount = phase.tasks.length;
1119
1132
  if (taskCount > 0) {
1120
- const labelTextWidth = phase.displayName.length * sPhaseFont * 0.6;
1133
+ const labelTextWidth = measureText(phase.displayName, sPhaseFont);
1121
1134
  phaseG
1122
1135
  .append('text')
1123
1136
  .attr('x', x + 26 + labelTextWidth + 10)
@@ -1166,7 +1179,7 @@ function renderPhaseBar(
1166
1179
  // corner radius. Stroke width is a touch thinner than
1167
1180
  // NODE_STROKE_WIDTH because at the smaller summary scale the
1168
1181
  // full 1.5 reads as too heavy.
1169
- const fill = solid ? rawColor : mix(rawColor, surfaceBg, 28);
1182
+ const fill = solid ? rawColor : mix(rawColor, surfaceBg, TINT_PCT);
1170
1183
  const stroke = solid ? mix(rawColor, surfaceBg, 70) : rawColor;
1171
1184
  const chipG = phaseG.append('g').attr('class', 'raci-phase-summary');
1172
1185
  chipG
@@ -1452,7 +1465,7 @@ function renderTaskRow(
1452
1465
  // ("Responsible") instead of the bare letter. Same primitive as the
1453
1466
  // legend chip, so cells and legend read as the same UI element.
1454
1467
  const fullLabel = variantLabels[m] ?? m;
1455
- const labelPx = fullLabel.length * sLegendLabelFont * 0.6;
1468
+ const labelPx = measureText(fullLabel, sLegendLabelFont);
1456
1469
  const showFullLabel = labelPx + 16 <= sliceW;
1457
1470
  const textContent = showFullLabel ? fullLabel : m;
1458
1471
  const textFont = showFullLabel ? sLegendLabelFont : sMarkerFont;
@@ -1505,45 +1518,6 @@ function renderTaskRow(
1505
1518
 
1506
1519
  // ── Helpers ──────────────────────────────────────────────────
1507
1520
 
1508
- /**
1509
- * Greedy word-wrap to a per-line character cap. Whitespace runs that
1510
- * happen to fall at a wrap point are dropped (no leading-space lines).
1511
- * Words longer than the cap are hard-split so the output never exceeds.
1512
- */
1513
- function wordWrap(s: string, charsPerLine: number): string[] {
1514
- if (charsPerLine <= 0 || s.length <= charsPerLine) return [s];
1515
- const out: string[] = [];
1516
- const tokens = s.split(/(\s+)/);
1517
- let cur = '';
1518
- for (const tok of tokens) {
1519
- if (!tok) continue;
1520
- const isSpace = /^\s+$/.test(tok);
1521
- if (cur.length + tok.length <= charsPerLine) {
1522
- cur += tok;
1523
- continue;
1524
- }
1525
- if (cur.trimEnd().length > 0) out.push(cur.trimEnd());
1526
- if (!isSpace && tok.length > charsPerLine) {
1527
- let chunk = tok;
1528
- while (chunk.length > charsPerLine) {
1529
- out.push(chunk.slice(0, charsPerLine));
1530
- chunk = chunk.slice(charsPerLine);
1531
- }
1532
- cur = chunk;
1533
- } else {
1534
- cur = isSpace ? '' : tok;
1535
- }
1536
- }
1537
- if (cur.trimEnd().length > 0) out.push(cur.trimEnd());
1538
- return out.length > 0 ? out : [''];
1539
- }
1540
-
1541
- function wrapText(s: string, maxPx: number, fontSize: number): string[] {
1542
- const charPx = fontSize * 0.6;
1543
- const cap = Math.max(8, Math.floor(maxPx / charPx));
1544
- return wordWrap(s, cap);
1545
- }
1546
-
1547
1521
  /**
1548
1522
  * Wrapped text content for a task row plus the height that fits it.
1549
1523
  * Pre-computed once per task and reused by both the y-cursor layout
@@ -1575,13 +1549,17 @@ function prepareRowContent(
1575
1549
  sStackTopGap = STACK_TOP_GAP,
1576
1550
  sViolationLineHeight = VIOLATION_LINE_HEIGHT
1577
1551
  ): RowContent {
1578
- const nameLines = wrapText(task.displayName, labelMaxW, sLabelFont);
1552
+ const nameLines = wrapTextToWidth(task.displayName, sLabelFont, labelMaxW, {
1553
+ hardBreak: true,
1554
+ });
1579
1555
  const description = task.description?.trim() ?? '';
1580
1556
  const descLines =
1581
1557
  description.length > 0
1582
1558
  ? description
1583
1559
  .split('\n')
1584
- .flatMap((line) => wrapText(line, labelMaxW, sDescFont))
1560
+ .flatMap((line) =>
1561
+ wrapTextToWidth(line, sDescFont, labelMaxW, { hardBreak: true })
1562
+ )
1585
1563
  : [];
1586
1564
  const violations: RowContent['violations'] = [];
1587
1565
  if (bucket) {
@@ -1595,7 +1573,9 @@ function prepareRowContent(
1595
1573
  severity,
1596
1574
  sourceLine: e.line,
1597
1575
  text,
1598
- lines: wrapText(text, labelMaxW, sLabelFont - 2),
1576
+ lines: wrapTextToWidth(text, sLabelFont - 2, labelMaxW, {
1577
+ hardBreak: true,
1578
+ }),
1599
1579
  });
1600
1580
  }
1601
1581
  };
@@ -1621,14 +1601,6 @@ function prepareRowContent(
1621
1601
  return { nameLines, descLines, violations, rowHeight };
1622
1602
  }
1623
1603
 
1624
- function truncateForWidth(s: string, maxPx: number, fontSize: number): string {
1625
- // Conservative: 0.6 em per char for sans-serif at this weight.
1626
- const charPx = fontSize * 0.6;
1627
- const cap = Math.max(3, Math.floor(maxPx / charPx));
1628
- if (s.length <= cap) return s;
1629
- return s.substring(0, cap - 1) + '…';
1630
- }
1631
-
1632
1604
  /**
1633
1605
  * Split a diagnostic message at single-quoted spans so the renderer can
1634
1606
  * paint the quoted name (role / task) as a bold tspan with no quotes.
package/src/render.ts CHANGED
@@ -130,7 +130,7 @@ export async function render(
130
130
  }
131
131
  ): Promise<{ svg: string; diagnostics: DgmoError[] }> {
132
132
  const theme = options?.theme ?? 'light';
133
- const paletteName = options?.palette ?? 'nord';
133
+ const paletteName = options?.palette ?? 'slate';
134
134
 
135
135
  const paletteColors =
136
136
  getPalette(paletteName)[theme === 'dark' ? 'dark' : 'light'];
@@ -16,6 +16,7 @@ import {
16
16
  } from '../palettes/color-utils';
17
17
  import { resolveColor } from '../colors';
18
18
  import { renderInlineText } from '../utils/inline-markdown';
19
+ import { CHAR_WIDTH_RATIO } from '../utils/text-measure';
19
20
  import {
20
21
  wrapDescriptionLines,
21
22
  type WrappedDescLine,
@@ -41,8 +42,6 @@ const DESC_GAP = 28;
41
42
  const DESC_ACCENT_WIDTH = 3;
42
43
  /** Gap between accent bar and description text. */
43
44
  const DESC_ACCENT_GAP = 12;
44
- /** Approximate ratio of average glyph width to font size (sans-serif). */
45
- const CHAR_WIDTH_RATIO = 0.55;
46
45
  /** Pixel offset between bullet glyph column and body-text column. */
47
46
  const BULLET_BODY_INDENT = 10;
48
47
  /** Outer-edge stroke width per ring (Decision 14 — adjacent-ring contrast). */
@@ -5,6 +5,7 @@
5
5
  import { inferParticipantType } from './participant-inference';
6
6
  import type { Brand, Writable } from '../utils/brand';
7
7
  import type { DgmoError } from '../diagnostics';
8
+ import type { PaletteColors } from '../palettes/types';
8
9
  import {
9
10
  akaRemovedMessage,
10
11
  formatDgmoError,
@@ -14,6 +15,7 @@ import {
14
15
  nameMergedMessage,
15
16
  participantTypeRemovedMessage,
16
17
  pipeOperatorRemovedMessage,
18
+ sequenceBarePositionRemovedMessage,
17
19
  suggest,
18
20
  } from '../diagnostics';
19
21
  import { normalizeName, displayName } from '../utils/name-normalize';
@@ -245,11 +247,14 @@ export interface ParsedSequenceDgmo {
245
247
  // "Name is a type" pattern — e.g. "Auth Server is a database"
246
248
  // Participant names may contain spaces; [^:]+? stops at colons so that
247
249
  // note lines like "note right of A: this is an actor" are not falsely matched.
248
- // Remainder after type is parsed separately for `position N` modifier.
250
+ // Remainder after type carries the optional `as <alias>` modifier.
249
251
  const IS_A_PATTERN = /^([^:]+?)\s+is\s+an?\s+(\w+)(?:\s+(.+))?$/i;
250
252
 
251
- // Standalone "Name position N" pattern — e.g. "DB position -1"
252
- const POSITION_ONLY_PATTERN = /^([^:]+?)\s+position\s+(-?\d+)$/i;
253
+ // Legacy bare-keyword "Name position N" detector — e.g. "DB position -1".
254
+ // Position is now colon-keyed metadata (`position: N`, §2.2); a surviving
255
+ // bare `position N` raises E_SEQUENCE_BARE_POSITION_REMOVED. Group 1 is the
256
+ // participant name (so it can still register), group 2 the offending number.
257
+ const BARE_POSITION_PATTERN = /^([^:]+?)\s+position\s+(-?\d+)$/i;
253
258
 
254
259
  // Colored participant declaration — e.g. "Tapin2(green)", "API(blue)"
255
260
  // Scoped to recognized 11-name palette colors only (§1.5) so legitimate
@@ -488,7 +493,10 @@ function resolveParticipantAndText(
488
493
  /**
489
494
  * Parse a .dgmo file with `chart: sequence` into a structured representation.
490
495
  */
491
- export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
496
+ export function parseSequenceDgmo(
497
+ content: string,
498
+ palette?: PaletteColors
499
+ ): ParsedSequenceDgmo {
492
500
  // Diagram-level options accumulator (Readonly<Record<...>> on the public
493
501
  // type; mutated locally during parse and assigned back via `result.options`).
494
502
  const optionsAccumulator: Record<string, string> = {};
@@ -637,7 +645,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
637
645
  type: extras?.type ?? inferParticipantType(name),
638
646
  lineNumber,
639
647
  ...(extras?.position !== undefined ? { position: extras.position } : {}),
640
- ...(extras?.metadata ? { metadata: extras.metadata } : {}),
648
+ // takePosition() may have emptied the metadata record (position was
649
+ // its only key) — don't attach an empty object.
650
+ ...(extras?.metadata && Object.keys(extras.metadata).length > 0
651
+ ? { metadata: extras.metadata }
652
+ : {}),
641
653
  });
642
654
  return trimmed;
643
655
  };
@@ -674,7 +686,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
674
686
  const splitPipe = (
675
687
  text: string,
676
688
  ln?: number
677
- ): { core: string; meta?: Record<string, string> } => {
689
+ ): { core: string; meta?: Record<string, string>; alias?: string } => {
678
690
  const idx = text.indexOf('|');
679
691
  if (idx >= 0) {
680
692
  if (ln !== undefined) {
@@ -709,11 +721,41 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
709
721
  );
710
722
  }
711
723
  if (Object.keys(split.meta).length > 0) {
712
- return { core: split.name, meta: split.meta };
724
+ // splitNameAndMeta peels a trailing `as <alias>` off the name region
725
+ // when same-line metadata is present (the alias must precede the meta,
726
+ // which runs to EOL). Propagate it so callers can register the alias —
727
+ // the no-meta branch below leaves `as <alias>` in `core` for the
728
+ // caller's own peeling.
729
+ return {
730
+ core: split.name,
731
+ meta: split.meta,
732
+ ...(split.alias !== undefined && { alias: split.alias }),
733
+ };
713
734
  }
714
735
  return { core: text };
715
736
  };
716
737
 
738
+ /**
739
+ * Pull the layout-order `position:` key out of a participant's
740
+ * parsed metadata (§2.2). Position is a layout directive, not display
741
+ * metadata, so it is *removed* from the record after extraction. A
742
+ * non-integer value raises an error and yields no position.
743
+ */
744
+ const takePosition = (
745
+ meta: Record<string, string> | undefined,
746
+ ln: number
747
+ ): number | undefined => {
748
+ if (meta?.['position'] === undefined) return undefined;
749
+ const raw = meta['position'];
750
+ delete meta['position'];
751
+ const n = parseInt(raw, 10);
752
+ if (!/^-?\d+$/.test(raw.trim()) || Number.isNaN(n)) {
753
+ pushError(ln, `'position: ${raw}' must be an integer slot index`);
754
+ return undefined;
755
+ }
756
+ return n;
757
+ };
758
+
717
759
  // Block parsing state — blocks are built mutably during parse, then
718
760
  // assigned back into the readonly-typed `ParsedSequenceDgmo`.
719
761
  const blockStack: {
@@ -906,7 +948,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
906
948
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
907
949
  const { label, color } = extractColor(
908
950
  cleanEntry,
909
- undefined,
951
+ palette,
910
952
  result.diagnostics,
911
953
  lineNumber
912
954
  );
@@ -1070,7 +1112,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1070
1112
  // Handling makes aliasing unnecessary because forgiving normalization
1071
1113
  // handles casing/whitespace differences automatically.
1072
1114
  // Skip lines starting with 'note' — handled by note parsing below.
1073
- const { core: isACore, meta: isAMeta } = splitPipe(trimmed, lineNumber);
1115
+ const {
1116
+ core: isACore,
1117
+ meta: isAMeta,
1118
+ alias: isAAlias,
1119
+ } = splitPipe(trimmed, lineNumber);
1074
1120
  const isAMatch = !/^note(\s|$)/i.test(trimmed)
1075
1121
  ? isACore.match(IS_A_PATTERN)
1076
1122
  : null;
@@ -1115,11 +1161,14 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1115
1161
  continue;
1116
1162
  }
1117
1163
 
1118
- // TD-18: extract trailing `as <alias>` from the remainder and
1119
- // register it. Order on the line is `Name is a TYPE [position N] as <alias>`.
1120
- // The leading `(.*?)\s*\b` allows the remainder to be just
1121
- // `as <alias>` (empty prefix) the canonical example writes
1122
- // `Alice is an actor as a` where the entire remainder is `as a`.
1164
+ // TD-18: extract trailing `as <alias>` and register it. Order on the
1165
+ // line is `Name is a TYPE as <alias> [position: N]`. When same-line
1166
+ // metadata is present, splitPipe already peeled the alias (it must
1167
+ // precede the metadata) and handed it back as `isAAlias`; otherwise
1168
+ // the alias is still in `remainder` for us to peel here.
1169
+ if (isAAlias !== undefined) {
1170
+ nameAliasMap.set(isAAlias, id);
1171
+ }
1123
1172
  const asInRemainder = remainder.match(
1124
1173
  /^(.*?)\s*\bas\s+([A-Za-z][A-Za-z0-9_]{0,11})\s*$/
1125
1174
  );
@@ -1129,9 +1178,21 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1129
1178
  remainder = asInRemainder[1]!.trim();
1130
1179
  }
1131
1180
 
1132
- const posMatch = remainder.match(/\bposition\s+(-?\d+)/i);
1133
- // Capture group 1 guaranteed present after successful match.
1134
- const position = posMatch ? parseInt(posMatch[1]!, 10) : undefined;
1181
+ // Position is colon-keyed metadata (§2.2) — pull it from the parsed
1182
+ // meta. A bare `position N` surviving in the remainder is the retired
1183
+ // form: flag it and drop the token (participant still registers).
1184
+ const position = takePosition(isAMeta, lineNumber);
1185
+ const baretail = remainder.match(/^position\s+(-?\d+)$/i);
1186
+ if (baretail) {
1187
+ result.diagnostics.push(
1188
+ makeDgmoError(
1189
+ lineNumber,
1190
+ sequenceBarePositionRemovedMessage(baretail[1]!),
1191
+ 'error',
1192
+ METADATA_DIAGNOSTIC_CODES.SEQUENCE_BARE_POSITION_REMOVED
1193
+ )
1194
+ );
1195
+ }
1135
1196
 
1136
1197
  // Avoid duplicate participant declarations
1137
1198
  const key = addParticipant(id, lineNumber, {
@@ -1157,17 +1218,26 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1157
1218
  continue;
1158
1219
  }
1159
1220
 
1160
- // Parse standalone "Name position N" (no "is a" type)
1221
+ // Reject the retired standalone bare-keyword "Name position N"
1222
+ // (no "is a" type). Position is now colon-keyed `position: N` (§2.2),
1223
+ // which flows through the bare-participant path below. The participant
1224
+ // still registers (without an order override) so message refs resolve.
1161
1225
  const { core: posCore, meta: posMeta } = splitPipe(trimmed, lineNumber);
1162
- const posOnlyMatch = posCore.match(POSITION_ONLY_PATTERN);
1226
+ const posOnlyMatch = posCore.match(BARE_POSITION_PATTERN);
1163
1227
  if (posOnlyMatch) {
1164
1228
  contentStarted = true;
1165
1229
  // Capture groups 1 and 2 guaranteed present after successful match.
1166
1230
  const id = posOnlyMatch[1]!;
1167
- const position = parseInt(posOnlyMatch[2]!, 10);
1231
+ result.diagnostics.push(
1232
+ makeDgmoError(
1233
+ lineNumber,
1234
+ sequenceBarePositionRemovedMessage(posOnlyMatch[2]!),
1235
+ 'error',
1236
+ METADATA_DIAGNOSTIC_CODES.SEQUENCE_BARE_POSITION_REMOVED
1237
+ )
1238
+ );
1168
1239
 
1169
1240
  const key = addParticipant(id, lineNumber, {
1170
- position,
1171
1241
  ...(posMeta !== undefined && { metadata: posMeta }),
1172
1242
  });
1173
1243
  // Track group membership
@@ -1222,7 +1292,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1222
1292
  // Bare participant name — either inside an active group (indented) or top-level declaration
1223
1293
  // Supports pipe metadata: " API | c: Gateway" or "Tapin2 | l:Park"
1224
1294
  {
1225
- const { core: bareCore, meta: bareMeta } = splitPipe(trimmed, lineNumber);
1295
+ const {
1296
+ core: bareCore,
1297
+ meta: bareMeta,
1298
+ alias: bareAlias,
1299
+ } = splitPipe(trimmed, lineNumber);
1226
1300
  const inGroup = activeGroup && measureIndent(raw) > 0;
1227
1301
  if (
1228
1302
  /^\S+$/.test(bareCore) &&
@@ -1231,7 +1305,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1231
1305
  ) {
1232
1306
  contentStarted = true;
1233
1307
  const id = bareCore;
1308
+ if (bareAlias !== undefined) nameAliasMap.set(bareAlias, id);
1309
+ // Colon-keyed `position: N` (§2.2) arrives as metadata here.
1310
+ const position = takePosition(bareMeta, lineNumber);
1234
1311
  const key = addParticipant(id, lineNumber, {
1312
+ ...(position !== undefined && { position }),
1235
1313
  ...(bareMeta !== undefined && { metadata: bareMeta }),
1236
1314
  });
1237
1315
  if (activeGroup && !activeGroup.participantIds.includes(key)) {