@diagrammo/dgmo 0.26.0 → 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 (136) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +4182 -2704
  3. package/dist/advanced.d.cts +266 -58
  4. package/dist/advanced.d.ts +266 -58
  5. package/dist/advanced.js +4182 -2698
  6. package/dist/auto.cjs +4042 -2581
  7. package/dist/auto.js +124 -122
  8. package/dist/auto.mjs +4042 -2581
  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 +4067 -2583
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +4067 -2583
  18. package/dist/internal.cjs +4182 -2704
  19. package/dist/internal.d.cts +266 -58
  20. package/dist/internal.d.ts +266 -58
  21. package/dist/internal.js +4182 -2698
  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 +1 -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 -0
  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.ts +26 -12
  48. package/src/cycle/layout.ts +55 -72
  49. package/src/cycle/renderer.ts +11 -6
  50. package/src/d3.ts +78 -117
  51. package/src/diagnostics.ts +16 -0
  52. package/src/echarts.ts +46 -33
  53. package/src/editor/keywords.ts +4 -0
  54. package/src/er/layout.ts +114 -22
  55. package/src/er/parser.ts +28 -0
  56. package/src/er/renderer.ts +55 -2
  57. package/src/er/types.ts +3 -0
  58. package/src/gantt/renderer.ts +46 -38
  59. package/src/gantt/resolver.ts +9 -2
  60. package/src/graph/edge-spline.ts +29 -0
  61. package/src/graph/flowchart-parser.ts +34 -1
  62. package/src/graph/flowchart-renderer.ts +78 -64
  63. package/src/graph/layout.ts +206 -23
  64. package/src/graph/notes.ts +21 -0
  65. package/src/graph/state-parser.ts +26 -1
  66. package/src/graph/state-renderer.ts +78 -64
  67. package/src/graph/types.ts +13 -0
  68. package/src/index.ts +1 -1
  69. package/src/infra/layout.ts +46 -26
  70. package/src/infra/renderer.ts +16 -7
  71. package/src/journey-map/layout.ts +38 -49
  72. package/src/journey-map/renderer.ts +22 -45
  73. package/src/kanban/renderer.ts +15 -6
  74. package/src/label-layout.ts +3 -3
  75. package/src/map/completion.ts +77 -22
  76. package/src/map/context-labels.ts +57 -12
  77. package/src/map/data/PROVENANCE.json +1 -1
  78. package/src/map/data/airport-collisions.json +1 -0
  79. package/src/map/data/airports.json +1 -0
  80. package/src/map/data/types.ts +19 -0
  81. package/src/map/layout.ts +1196 -90
  82. package/src/map/legend-band.ts +2 -2
  83. package/src/map/load-data.ts +10 -1
  84. package/src/map/parser.ts +61 -32
  85. package/src/map/renderer.ts +284 -12
  86. package/src/map/resolved-types.ts +15 -1
  87. package/src/map/resolver.ts +132 -12
  88. package/src/map/types.ts +28 -8
  89. package/src/migrate/embedded.ts +9 -7
  90. package/src/mindmap/text-wrap.ts +13 -14
  91. package/src/org/layout.ts +19 -17
  92. package/src/org/renderer.ts +11 -4
  93. package/src/palettes/color-utils.ts +82 -21
  94. package/src/palettes/index.ts +0 -19
  95. package/src/palettes/registry.ts +1 -1
  96. package/src/palettes/types.ts +2 -2
  97. package/src/pert/layout.ts +48 -40
  98. package/src/pert/renderer.ts +30 -43
  99. package/src/pyramid/renderer.ts +4 -5
  100. package/src/raci/renderer.ts +34 -68
  101. package/src/render.ts +1 -1
  102. package/src/ring/renderer.ts +1 -2
  103. package/src/sequence/parser.ts +100 -22
  104. package/src/sequence/renderer.ts +75 -50
  105. package/src/sitemap/layout.ts +27 -19
  106. package/src/sitemap/renderer.ts +12 -5
  107. package/src/tech-radar/renderer.ts +11 -35
  108. package/src/utils/arrow-markers.ts +51 -0
  109. package/src/utils/fit-canvas.ts +64 -0
  110. package/src/utils/legend-constants.ts +8 -54
  111. package/src/utils/legend-d3.ts +10 -7
  112. package/src/utils/legend-layout.ts +7 -4
  113. package/src/utils/legend-types.ts +10 -4
  114. package/src/utils/note-box/constants.ts +25 -0
  115. package/src/utils/note-box/index.ts +11 -0
  116. package/src/utils/note-box/metrics.ts +90 -0
  117. package/src/utils/note-box/svg.ts +331 -0
  118. package/src/utils/notes/bounds.ts +30 -0
  119. package/src/utils/notes/build.ts +131 -0
  120. package/src/utils/notes/index.ts +18 -0
  121. package/src/utils/notes/model.ts +19 -0
  122. package/src/utils/notes/parse.ts +131 -0
  123. package/src/utils/notes/place.ts +177 -0
  124. package/src/utils/notes/resolve.ts +88 -0
  125. package/src/utils/number-format.ts +36 -0
  126. package/src/utils/parsing.ts +41 -0
  127. package/src/utils/reserved-key-registry.ts +4 -0
  128. package/src/utils/text-measure.ts +122 -0
  129. package/src/wireframe/layout.ts +4 -2
  130. package/src/wireframe/renderer.ts +8 -6
  131. package/src/palettes/dracula.ts +0 -68
  132. package/src/palettes/gruvbox.ts +0 -85
  133. package/src/palettes/monokai.ts +0 -68
  134. package/src/palettes/one-dark.ts +0 -70
  135. package/src/palettes/rose-pine.ts +0 -84
  136. 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
@@ -550,20 +556,21 @@ export function renderRaci(
550
556
 
551
557
  // Size full-mode chips to actually fit the longest label without truncation.
552
558
  // chip = 4 pad + 24 slab + 8 gap + label + 8 right pad.
553
- const longestLabel = legendMarkers.reduce(
554
- (n, m) => Math.max(n, (variantLabels[m] ?? '').length),
559
+ const longestLabelPx = legendMarkers.reduce(
560
+ (n, m) =>
561
+ Math.max(n, measureText(variantLabels[m] ?? '', sLegendLabelFont)),
555
562
  0
556
563
  );
557
564
  const fullChipW = Math.max(
558
565
  sLegendChipLabelMin,
559
- Math.ceil(4 + 24 + 8 + longestLabel * sLegendLabelFont * 0.6 + 8)
566
+ Math.ceil(4 + 24 + 8 + longestLabelPx + 8)
560
567
  );
561
568
  const fullLegendW = numChips * fullChipW + chipGapTotal;
562
569
  const letterLegendW = numChips * sLegendLetterChipW + chipGapTotal;
563
570
 
564
- // Estimate title pixel width with the same heuristic used in truncateForWidth.
571
+ // Estimate title pixel width using the shared glyph measurer.
565
572
  const titleEstW =
566
- parsed.title && !hideTitle ? parsed.title.length * sTitleFontSize * 0.6 : 0;
573
+ parsed.title && !hideTitle ? measureText(parsed.title, sTitleFontSize) : 0;
567
574
 
568
575
  // Pick legend mode: full chips if everything fits, else letter-only.
569
576
  // If even letters won't fit beside the title, the title is truncated below.
@@ -596,7 +603,7 @@ export function renderRaci(
596
603
  .attr('font-size', sTitleFontSize)
597
604
  .attr('font-weight', TITLE_FONT_WEIGHT)
598
605
  .attr('fill', palette.text)
599
- .text(truncateForWidth(parsed.title, titleMaxW, sTitleFontSize));
606
+ .text(truncateText(parsed.title, sTitleFontSize, titleMaxW));
600
607
  }
601
608
 
602
609
  // Legend — right-aligned in the same row.
@@ -714,10 +721,10 @@ export function renderRaci(
714
721
  .attr('font-weight', 600)
715
722
  .attr('fill', palette.text)
716
723
  .text(
717
- truncateForWidth(
724
+ truncateText(
718
725
  parsed.roleDisplayNames[i] ?? '',
719
- roleColW - 2 * sCellPad,
720
- sRoleHeaderFont
726
+ sRoleHeaderFont,
727
+ roleColW - 2 * sCellPad
721
728
  )
722
729
  );
723
730
  });
@@ -931,7 +938,7 @@ function renderLegend(
931
938
  if (mode === 'letters') {
932
939
  // Compact: a single colored pill with just the marker letter.
933
940
  // The full label moves to a native tooltip so hover still teaches it.
934
- const fill = solid ? rawColor : mix(rawColor, surfaceBg, 28);
941
+ const fill = solid ? rawColor : mix(rawColor, surfaceBg, TINT_PCT);
935
942
  const stroke = solid ? mix(rawColor, surfaceBg, 70) : rawColor;
936
943
  chipG
937
944
  .append('rect')
@@ -966,7 +973,7 @@ function renderLegend(
966
973
  }
967
974
 
968
975
  // Full mode: bordered chip with a letter slab on the left and label text.
969
- const fill = solid ? rawColor : mix(rawColor, surfaceBg, 28);
976
+ const fill = solid ? rawColor : mix(rawColor, surfaceBg, TINT_PCT);
970
977
  const stroke = mix(rawColor, surfaceBg, 70);
971
978
 
972
979
  chipG
@@ -1026,10 +1033,10 @@ function renderLegend(
1026
1033
  .attr('font-weight', 600)
1027
1034
  .attr('fill', palette.text)
1028
1035
  .text(
1029
- truncateForWidth(
1036
+ truncateText(
1030
1037
  labelText,
1031
- chipW - slabW - slabPad * 2 - 12,
1032
- sLegendLabelFont
1038
+ sLegendLabelFont,
1039
+ chipW - slabW - slabPad * 2 - 12
1033
1040
  )
1034
1041
  );
1035
1042
  });
@@ -1123,7 +1130,7 @@ function renderPhaseBar(
1123
1130
  // can see at a glance what's in the rolled-up phase.
1124
1131
  const taskCount = phase.tasks.length;
1125
1132
  if (taskCount > 0) {
1126
- const labelTextWidth = phase.displayName.length * sPhaseFont * 0.6;
1133
+ const labelTextWidth = measureText(phase.displayName, sPhaseFont);
1127
1134
  phaseG
1128
1135
  .append('text')
1129
1136
  .attr('x', x + 26 + labelTextWidth + 10)
@@ -1172,7 +1179,7 @@ function renderPhaseBar(
1172
1179
  // corner radius. Stroke width is a touch thinner than
1173
1180
  // NODE_STROKE_WIDTH because at the smaller summary scale the
1174
1181
  // full 1.5 reads as too heavy.
1175
- const fill = solid ? rawColor : mix(rawColor, surfaceBg, 28);
1182
+ const fill = solid ? rawColor : mix(rawColor, surfaceBg, TINT_PCT);
1176
1183
  const stroke = solid ? mix(rawColor, surfaceBg, 70) : rawColor;
1177
1184
  const chipG = phaseG.append('g').attr('class', 'raci-phase-summary');
1178
1185
  chipG
@@ -1458,7 +1465,7 @@ function renderTaskRow(
1458
1465
  // ("Responsible") instead of the bare letter. Same primitive as the
1459
1466
  // legend chip, so cells and legend read as the same UI element.
1460
1467
  const fullLabel = variantLabels[m] ?? m;
1461
- const labelPx = fullLabel.length * sLegendLabelFont * 0.6;
1468
+ const labelPx = measureText(fullLabel, sLegendLabelFont);
1462
1469
  const showFullLabel = labelPx + 16 <= sliceW;
1463
1470
  const textContent = showFullLabel ? fullLabel : m;
1464
1471
  const textFont = showFullLabel ? sLegendLabelFont : sMarkerFont;
@@ -1511,45 +1518,6 @@ function renderTaskRow(
1511
1518
 
1512
1519
  // ── Helpers ──────────────────────────────────────────────────
1513
1520
 
1514
- /**
1515
- * Greedy word-wrap to a per-line character cap. Whitespace runs that
1516
- * happen to fall at a wrap point are dropped (no leading-space lines).
1517
- * Words longer than the cap are hard-split so the output never exceeds.
1518
- */
1519
- function wordWrap(s: string, charsPerLine: number): string[] {
1520
- if (charsPerLine <= 0 || s.length <= charsPerLine) return [s];
1521
- const out: string[] = [];
1522
- const tokens = s.split(/(\s+)/);
1523
- let cur = '';
1524
- for (const tok of tokens) {
1525
- if (!tok) continue;
1526
- const isSpace = /^\s+$/.test(tok);
1527
- if (cur.length + tok.length <= charsPerLine) {
1528
- cur += tok;
1529
- continue;
1530
- }
1531
- if (cur.trimEnd().length > 0) out.push(cur.trimEnd());
1532
- if (!isSpace && tok.length > charsPerLine) {
1533
- let chunk = tok;
1534
- while (chunk.length > charsPerLine) {
1535
- out.push(chunk.slice(0, charsPerLine));
1536
- chunk = chunk.slice(charsPerLine);
1537
- }
1538
- cur = chunk;
1539
- } else {
1540
- cur = isSpace ? '' : tok;
1541
- }
1542
- }
1543
- if (cur.trimEnd().length > 0) out.push(cur.trimEnd());
1544
- return out.length > 0 ? out : [''];
1545
- }
1546
-
1547
- function wrapText(s: string, maxPx: number, fontSize: number): string[] {
1548
- const charPx = fontSize * 0.6;
1549
- const cap = Math.max(8, Math.floor(maxPx / charPx));
1550
- return wordWrap(s, cap);
1551
- }
1552
-
1553
1521
  /**
1554
1522
  * Wrapped text content for a task row plus the height that fits it.
1555
1523
  * Pre-computed once per task and reused by both the y-cursor layout
@@ -1581,13 +1549,17 @@ function prepareRowContent(
1581
1549
  sStackTopGap = STACK_TOP_GAP,
1582
1550
  sViolationLineHeight = VIOLATION_LINE_HEIGHT
1583
1551
  ): RowContent {
1584
- const nameLines = wrapText(task.displayName, labelMaxW, sLabelFont);
1552
+ const nameLines = wrapTextToWidth(task.displayName, sLabelFont, labelMaxW, {
1553
+ hardBreak: true,
1554
+ });
1585
1555
  const description = task.description?.trim() ?? '';
1586
1556
  const descLines =
1587
1557
  description.length > 0
1588
1558
  ? description
1589
1559
  .split('\n')
1590
- .flatMap((line) => wrapText(line, labelMaxW, sDescFont))
1560
+ .flatMap((line) =>
1561
+ wrapTextToWidth(line, sDescFont, labelMaxW, { hardBreak: true })
1562
+ )
1591
1563
  : [];
1592
1564
  const violations: RowContent['violations'] = [];
1593
1565
  if (bucket) {
@@ -1601,7 +1573,9 @@ function prepareRowContent(
1601
1573
  severity,
1602
1574
  sourceLine: e.line,
1603
1575
  text,
1604
- lines: wrapText(text, labelMaxW, sLabelFont - 2),
1576
+ lines: wrapTextToWidth(text, sLabelFont - 2, labelMaxW, {
1577
+ hardBreak: true,
1578
+ }),
1605
1579
  });
1606
1580
  }
1607
1581
  };
@@ -1627,14 +1601,6 @@ function prepareRowContent(
1627
1601
  return { nameLines, descLines, violations, rowHeight };
1628
1602
  }
1629
1603
 
1630
- function truncateForWidth(s: string, maxPx: number, fontSize: number): string {
1631
- // Conservative: 0.6 em per char for sans-serif at this weight.
1632
- const charPx = fontSize * 0.6;
1633
- const cap = Math.max(3, Math.floor(maxPx / charPx));
1634
- if (s.length <= cap) return s;
1635
- return s.substring(0, cap - 1) + '…';
1636
- }
1637
-
1638
1604
  /**
1639
1605
  * Split a diagnostic message at single-quoted spans so the renderer can
1640
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)) {