@diagrammo/dgmo 0.8.18 → 0.8.20

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 (42) hide show
  1. package/dist/cli.cjs +89 -130
  2. package/dist/index.cjs +1202 -993
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +216 -114
  5. package/dist/index.d.ts +216 -114
  6. package/dist/index.js +1211 -985
  7. package/dist/index.js.map +1 -1
  8. package/docs/language-reference.md +73 -0
  9. package/package.json +22 -9
  10. package/src/boxes-and-lines/parser.ts +8 -3
  11. package/src/c4/parser.ts +8 -7
  12. package/src/class/parser.ts +6 -0
  13. package/src/cli.ts +1 -9
  14. package/src/d3.ts +16 -234
  15. package/src/dgmo-router.ts +97 -5
  16. package/src/diagnostics.ts +16 -6
  17. package/src/echarts.ts +43 -10
  18. package/src/er/parser.ts +22 -2
  19. package/src/gantt/renderer.ts +153 -91
  20. package/src/graph/flowchart-parser.ts +89 -52
  21. package/src/graph/state-parser.ts +60 -35
  22. package/src/index.ts +23 -18
  23. package/src/infra/parser.ts +9 -2
  24. package/src/kanban/renderer.ts +2 -2
  25. package/src/palettes/color-utils.ts +4 -12
  26. package/src/palettes/index.ts +0 -4
  27. package/src/render.ts +30 -16
  28. package/src/sequence/collapse.ts +169 -0
  29. package/src/sequence/parser.ts +21 -4
  30. package/src/sequence/renderer.ts +198 -52
  31. package/src/sharing.ts +86 -49
  32. package/src/sitemap/renderer.ts +1 -6
  33. package/src/utils/arrows.ts +180 -11
  34. package/src/utils/d3-types.ts +4 -0
  35. package/src/utils/legend-constants.ts +11 -4
  36. package/src/utils/legend-d3.ts +171 -0
  37. package/src/utils/legend-layout.ts +140 -13
  38. package/src/utils/legend-types.ts +45 -0
  39. package/src/utils/time-ticks.ts +213 -0
  40. package/src/branding.ts +0 -67
  41. package/src/dgmo-mermaid.ts +0 -262
  42. package/src/palettes/mermaid-bridge.ts +0 -220
@@ -8,7 +8,7 @@ import { FONT_FAMILY } from '../fonts';
8
8
  import { getSeriesColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import { resolveTagColor, resolveActiveTagGroup } from '../utils/tag-groups';
11
- import { computeTimeTicks } from '../d3';
11
+ import { computeTimeTicks } from '../utils/time-ticks';
12
12
  import {
13
13
  LEGEND_HEIGHT,
14
14
  LEGEND_PILL_PAD,
@@ -20,9 +20,11 @@ import {
20
20
  LEGEND_ENTRY_TRAIL,
21
21
  LEGEND_GROUP_GAP,
22
22
  LEGEND_ICON_W,
23
+ LEGEND_GEAR_PILL_W,
23
24
  measureLegendText,
24
25
  } from '../utils/legend-constants';
25
26
  import { renderLegendD3 } from '../utils/legend-d3';
27
+ import { controlsGroupCapsuleWidth } from '../utils/legend-layout';
26
28
  import type {
27
29
  LegendConfig,
28
30
  LegendState,
@@ -34,7 +36,7 @@ import {
34
36
  TITLE_Y,
35
37
  } from '../utils/title-constants';
36
38
  import type { PaletteColors } from '../palettes';
37
- import type { D3ExportDimensions } from '../d3';
39
+ import type { D3ExportDimensions } from '../utils/d3-types';
38
40
  import type {
39
41
  ResolvedSchedule,
40
42
  ResolvedTask,
@@ -233,6 +235,8 @@ export function renderGantt(
233
235
  options?.currentActiveGroup
234
236
  );
235
237
  let criticalPathActive = false;
238
+ let dependenciesActive = !!resolved.options.dependencies;
239
+ let controlsExpanded = false;
236
240
 
237
241
  // ── Build row list (structural vs tag mode) ─────────────
238
242
 
@@ -264,11 +268,21 @@ export function renderGantt(
264
268
 
265
269
  const totalRows = rows.length;
266
270
 
271
+ // Pre-compute critical path / dependency flags (needed for legend height reservation)
272
+ const hasCriticalPath =
273
+ resolved.options.criticalPath &&
274
+ resolved.tasks.some((t) => t.isCriticalPath);
275
+ const hasDependencies =
276
+ resolved.options.dependencies &&
277
+ resolved.tasks.some((t) => t.task.dependencies.length > 0);
278
+
267
279
  // Vertical layout — matches timeline pattern (d3.ts:3649-3655)
268
280
  const title = resolved.options.title;
269
281
  const titleHeight = title ? 50 : 20;
270
282
  const tagLegendReserve =
271
- resolved.tagGroups.length > 0 ? LEGEND_HEIGHT + 8 : 0;
283
+ resolved.tagGroups.length > 0 || hasCriticalPath || hasDependencies
284
+ ? LEGEND_HEIGHT + 8
285
+ : 0;
272
286
  const topDateLabelReserve = 22; // tick (6) + gap (4) + label height (~12)
273
287
  const hasOverheadLabels =
274
288
  resolved.markers.length > 0 || resolved.eras.length > 0;
@@ -327,13 +341,9 @@ export function renderGantt(
327
341
 
328
342
  // ── Tag legend (interactive) ────────────────────────────
329
343
 
330
- const hasCriticalPath =
331
- resolved.options.criticalPath &&
332
- resolved.tasks.some((t) => t.isCriticalPath);
333
-
334
344
  function drawLegend() {
335
345
  svg.selectAll('.gantt-tag-legend-container').remove();
336
- if (resolved.tagGroups.length > 0 || hasCriticalPath) {
346
+ if (resolved.tagGroups.length > 0 || hasCriticalPath || hasDependencies) {
337
347
  const legendY = titleHeight;
338
348
  renderTagLegend(
339
349
  svg,
@@ -359,17 +369,41 @@ export function renderGantt(
359
369
  recolorBars();
360
370
  },
361
371
  () => {
362
- criticalPathActive = !criticalPathActive;
372
+ controlsExpanded = !controlsExpanded;
363
373
  drawLegend();
364
374
  },
365
375
  currentSwimlaneGroup,
366
376
  onSwimlaneChange,
367
377
  viewMode,
368
- resolved.tasks
378
+ resolved.tasks,
379
+ controlsExpanded,
380
+ hasDependencies,
381
+ dependenciesActive,
382
+ (toggleId, active) => {
383
+ if (toggleId === 'critical-path') {
384
+ criticalPathActive = active;
385
+ } else if (toggleId === 'dependencies') {
386
+ dependenciesActive = active;
387
+ // Show/hide dependency arrows
388
+ g.selectAll<SVGElement, unknown>(
389
+ '.gantt-dep-arrow, .gantt-dep-arrowhead, .gantt-dep-label'
390
+ ).attr('display', active ? null : 'none');
391
+ }
392
+ drawLegend();
393
+ }
369
394
  );
370
395
  }
371
396
  }
372
397
 
398
+ function restoreHighlight() {
399
+ if (criticalPathActive) {
400
+ applyCriticalPathHighlight(svg, g);
401
+ } else {
402
+ svg.attr('data-critical-path-active', null);
403
+ resetHighlight(g, svg);
404
+ }
405
+ }
406
+
373
407
  function recolorBars() {
374
408
  g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
375
409
  const el = d3Selection.select(this);
@@ -568,7 +602,7 @@ export function renderGantt(
568
602
  }
569
603
  })
570
604
  .on('mouseleave', () => {
571
- resetHighlight(g, svg);
605
+ restoreHighlight();
572
606
  hideGanttDateIndicators(g);
573
607
  });
574
608
 
@@ -979,7 +1013,7 @@ export function renderGantt(
979
1013
  .text(task.label);
980
1014
  })
981
1015
  .on('mouseleave', () => {
982
- resetHighlight(g, svg);
1016
+ restoreHighlight();
983
1017
  hideGanttDateIndicators(g);
984
1018
  g.selectAll('.gantt-milestone-hover-label').remove();
985
1019
  });
@@ -1023,11 +1057,7 @@ export function renderGantt(
1023
1057
  })
1024
1058
  .on('mouseleave', () => {
1025
1059
  if (resolved.options.dependencies) {
1026
- if (criticalPathActive) {
1027
- applyCriticalPathHighlight(svg, g);
1028
- } else {
1029
- resetHighlight(g, svg);
1030
- }
1060
+ restoreHighlight();
1031
1061
  }
1032
1062
  resetTaskLabels(svg);
1033
1063
  hideGanttDateIndicators(g);
@@ -1704,6 +1734,7 @@ function applyCriticalPathHighlight(
1704
1734
  svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1705
1735
  chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>
1706
1736
  ) {
1737
+ svg.attr('data-critical-path-active', 'true');
1707
1738
  chartG.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
1708
1739
  const el = d3Selection.select(this);
1709
1740
  el.attr(
@@ -1827,6 +1858,34 @@ function drawSwimlaneIcon(
1827
1858
  return iconG;
1828
1859
  }
1829
1860
 
1861
+ function buildControlsToggles(
1862
+ hasCriticalPath: boolean,
1863
+ criticalPathActive: boolean,
1864
+ hasDependencies: boolean,
1865
+ dependenciesActive: boolean
1866
+ ): import('../utils/legend-types').ControlsGroupToggle[] {
1867
+ const toggles: import('../utils/legend-types').ControlsGroupToggle[] = [];
1868
+ if (hasCriticalPath) {
1869
+ toggles.push({
1870
+ id: 'critical-path',
1871
+ type: 'toggle',
1872
+ label: 'Critical Path',
1873
+ active: criticalPathActive,
1874
+ onToggle: () => {},
1875
+ });
1876
+ }
1877
+ if (hasDependencies) {
1878
+ toggles.push({
1879
+ id: 'dependencies',
1880
+ type: 'toggle',
1881
+ label: 'Dependencies',
1882
+ active: dependenciesActive,
1883
+ onToggle: () => {},
1884
+ });
1885
+ }
1886
+ return toggles;
1887
+ }
1888
+
1830
1889
  function renderTagLegend(
1831
1890
  svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1832
1891
  chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
@@ -1841,16 +1900,16 @@ function renderTagLegend(
1841
1900
  criticalPathActive: boolean,
1842
1901
  optionLineNumbers: Record<string, number>,
1843
1902
  onToggle?: (groupName: string) => void,
1844
- onToggleCriticalPath?: () => void,
1903
+ onToggleControlsExpand?: () => void,
1845
1904
  currentSwimlaneGroup?: string | null,
1846
1905
  onSwimlaneChange?: (group: string | null) => void,
1847
1906
  legendViewMode?: boolean,
1848
- resolvedTasks?: ResolvedTask[]
1907
+ resolvedTasks?: ResolvedTask[],
1908
+ controlsExpanded = false,
1909
+ hasDependencies = false,
1910
+ dependenciesActive = false,
1911
+ onControlsToggle?: (toggleId: string, active: boolean) => void
1849
1912
  ): void {
1850
- const groupBg = isDark
1851
- ? mix(palette.surface, palette.bg, 50)
1852
- : mix(palette.surface, palette.bg, 30);
1853
-
1854
1913
  // Build visible groups: active group expanded + swimlane group as compact pill
1855
1914
  let visibleGroups: TagGroup[];
1856
1915
  if (activeGroupName) {
@@ -1934,13 +1993,16 @@ function renderTagLegend(
1934
1993
  }
1935
1994
  totalW += Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
1936
1995
 
1937
- // Critical Path pill width
1938
- const cpLabel = 'Critical Path';
1939
- const cpPillW =
1940
- measureLegendText(cpLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1941
- if (hasCriticalPath) {
1996
+ // Controls group width — replaces standalone critical path pill
1997
+ const hasControls = hasCriticalPath || hasDependencies;
1998
+ const controlsToggleLabels: Array<{ label: string }> = [];
1999
+ if (hasCriticalPath) controlsToggleLabels.push({ label: 'Critical Path' });
2000
+ if (hasDependencies) controlsToggleLabels.push({ label: 'Dependencies' });
2001
+ if (hasControls) {
1942
2002
  if (visibleGroups.length > 0) totalW += LEGEND_GROUP_GAP;
1943
- totalW += cpPillW;
2003
+ totalW += controlsExpanded
2004
+ ? controlsGroupCapsuleWidth(controlsToggleLabels)
2005
+ : LEGEND_GEAR_PILL_W;
1944
2006
  }
1945
2007
 
1946
2008
  // Center over full container (matching title centering)
@@ -1952,8 +2014,6 @@ function renderTagLegend(
1952
2014
  .attr('class', 'gantt-tag-legend-container')
1953
2015
  .attr('transform', `translate(${legendX}, ${legendY})`);
1954
2016
 
1955
- let cursorX = 0;
1956
-
1957
2017
  // Render tag groups via centralized legend system
1958
2018
  if (visibleGroups.length > 0) {
1959
2019
  const showIcon = !legendViewMode && tagGroups.length > 0;
@@ -1969,6 +2029,13 @@ function renderTagLegend(
1969
2029
  };
1970
2030
  });
1971
2031
 
2032
+ const controlsToggles = buildControlsToggles(
2033
+ hasCriticalPath,
2034
+ criticalPathActive,
2035
+ hasDependencies,
2036
+ dependenciesActive
2037
+ );
2038
+
1972
2039
  const legendConfig: LegendConfig = {
1973
2040
  groups: legendGroups,
1974
2041
  position: {
@@ -1977,16 +2044,30 @@ function renderTagLegend(
1977
2044
  },
1978
2045
  mode: 'fixed' as const,
1979
2046
  capsulePillAddonWidth: iconReserve,
2047
+ controlsGroup:
2048
+ controlsToggles.length > 0 ? { toggles: controlsToggles } : undefined,
2049
+ };
2050
+ const legendState: LegendState = {
2051
+ activeGroup: activeGroupName,
2052
+ controlsExpanded,
1980
2053
  };
1981
- const legendState: LegendState = { activeGroup: activeGroupName };
1982
2054
 
1983
- const tagGroupsW =
2055
+ let tagGroupsW =
1984
2056
  visibleGroups.reduce((s, _, i) => s + groupWidths[i], 0) +
1985
2057
  Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
2058
+ // Add controls group space to the renderLegendD3 container width
2059
+ if (hasControls) {
2060
+ if (visibleGroups.length > 0) tagGroupsW += LEGEND_GROUP_GAP;
2061
+ tagGroupsW += controlsExpanded
2062
+ ? controlsGroupCapsuleWidth(controlsToggleLabels)
2063
+ : LEGEND_GEAR_PILL_W;
2064
+ }
1986
2065
  const tagGroupG = legendRow.append('g');
1987
2066
 
1988
2067
  const legendCallbacks: LegendCallbacks = {
1989
2068
  onGroupToggle: onToggle,
2069
+ onControlsExpand: onToggleControlsExpand,
2070
+ onControlsToggle,
1990
2071
  onEntryHover: (groupName, entryValue) => {
1991
2072
  const tagKey = groupName.toLowerCase();
1992
2073
  if (entryValue) {
@@ -2092,67 +2173,43 @@ function renderTagLegend(
2092
2173
  legendCallbacks,
2093
2174
  tagGroupsW
2094
2175
  );
2176
+ } else if (hasControls) {
2177
+ // No tag groups, but controls group needs rendering
2178
+ const controlsToggles = buildControlsToggles(
2179
+ hasCriticalPath,
2180
+ criticalPathActive,
2181
+ hasDependencies,
2182
+ dependenciesActive
2183
+ );
2095
2184
 
2096
- for (let i = 0; i < visibleGroups.length; i++) {
2097
- cursorX += groupWidths[i] + LEGEND_GROUP_GAP;
2098
- }
2099
- }
2100
-
2101
- // Critical Path pill
2102
- if (hasCriticalPath) {
2103
- const cpLineNum = optionLineNumbers['critical-path'];
2104
- const cpG = legendRow
2105
- .append('g')
2106
- .attr('transform', `translate(${cursorX}, 0)`)
2107
- .attr('class', 'gantt-legend-critical-path')
2108
- .style('cursor', 'pointer')
2109
- .on('click', () => {
2110
- if (onToggleCriticalPath) onToggleCriticalPath();
2111
- });
2112
- if (cpLineNum) cpG.attr('data-line-number', String(cpLineNum));
2113
-
2114
- cpG
2115
- .append('rect')
2116
- .attr('width', cpPillW)
2117
- .attr('height', LEGEND_HEIGHT)
2118
- .attr('rx', LEGEND_HEIGHT / 2)
2119
- .attr('fill', criticalPathActive ? palette.bg : groupBg);
2120
-
2121
- if (criticalPathActive) {
2122
- cpG
2123
- .append('rect')
2124
- .attr('width', cpPillW)
2125
- .attr('height', LEGEND_HEIGHT)
2126
- .attr('rx', LEGEND_HEIGHT / 2)
2127
- .attr('fill', 'none')
2128
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
2129
- .attr('stroke-width', 0.75);
2130
- }
2131
-
2132
- cpG
2133
- .append('text')
2134
- .attr('x', cpPillW / 2)
2135
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
2136
- .attr('text-anchor', 'middle')
2137
- .attr('font-size', `${LEGEND_PILL_FONT_SIZE}px`)
2138
- .attr('font-weight', '500')
2139
- .attr('fill', criticalPathActive ? palette.text : palette.textMuted)
2140
- .text(cpLabel);
2185
+ const legendConfig: LegendConfig = {
2186
+ groups: [],
2187
+ position: {
2188
+ placement: 'top-center' as const,
2189
+ titleRelation: 'below-title' as const,
2190
+ },
2191
+ mode: 'fixed' as const,
2192
+ controlsGroup: { toggles: controlsToggles },
2193
+ };
2141
2194
 
2142
- // Apply persistent highlighting when active
2143
- if (criticalPathActive) {
2144
- applyCriticalPathHighlight(svg, chartG);
2145
- }
2195
+ const tagGroupG = legendRow.append('g');
2196
+ renderLegendD3(
2197
+ tagGroupG,
2198
+ legendConfig,
2199
+ { activeGroup: null, controlsExpanded },
2200
+ palette,
2201
+ isDark,
2202
+ {
2203
+ onControlsExpand: onToggleControlsExpand,
2204
+ onControlsToggle,
2205
+ },
2206
+ totalW
2207
+ );
2208
+ }
2146
2209
 
2147
- cpG
2148
- .on('mouseenter', () => {
2149
- applyCriticalPathHighlight(svg, chartG);
2150
- })
2151
- .on('mouseleave', () => {
2152
- if (!criticalPathActive) {
2153
- resetHighlightAll(svg, chartG);
2154
- }
2155
- });
2210
+ // Apply persistent critical path highlighting when active
2211
+ if (criticalPathActive) {
2212
+ applyCriticalPathHighlight(svg, chartG);
2156
2213
  }
2157
2214
  }
2158
2215
 
@@ -2990,6 +3047,11 @@ function resetHighlight(
2990
3047
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2991
3048
  svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>
2992
3049
  ): void {
3050
+ // If critical path is actively toggled ON, restore its highlight instead of full reset
3051
+ if (svg.attr('data-critical-path-active') === 'true') {
3052
+ applyCriticalPathHighlight(svg, g);
3053
+ return;
3054
+ }
2993
3055
  g.selectAll<SVGGElement, unknown>('.gantt-task, .gantt-milestone').attr(
2994
3056
  'opacity',
2995
3057
  1
@@ -2,6 +2,7 @@ import { resolveColorWithDiagnostic } from '../colors';
2
2
  import type { DgmoError } from '../diagnostics';
3
3
  import type { PaletteColors } from '../palettes';
4
4
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
5
+ import { parseInArrowLabel, matchColorParens } from '../utils/arrows';
5
6
  import {
6
7
  measureIndent,
7
8
  extractColor,
@@ -87,15 +88,17 @@ function parseNodeRef(text: string, palette?: PaletteColors): NodeRef | null {
87
88
 
88
89
  /**
89
90
  * Split a line into segments around arrow tokens.
90
- * Arrows: `->`, `-label->`, `-(color)->`, `-label(color)->`
91
+ * Arrows: `->`, `-label->`, `-(color)->`, `-label(color)->`, and long-dash
92
+ * variants like `-->`, `--->`, `--foo--->` (TD-9 longest-match: the arrow
93
+ * token is the maximal run of `-+>`).
91
94
  *
92
95
  * Returns alternating: [nodeText, arrowText, nodeText, arrowText, nodeText, ...]
93
- * Where arrowText is the full arrow token like `-yes->` or `->`.
96
+ * Where arrowText is the synthesized full arrow token like `-yes->` or `->`
97
+ * (with visual dash-run length collapsed to the minimal `-...->` form —
98
+ * edge styling is not yet differentiated by arrow length).
94
99
  */
95
100
  function splitArrows(line: string): string[] {
96
101
  const segments: string[] = [];
97
- let lastIndex = 0;
98
- // Simpler approach: find all `->` positions, then determine if there's a label prefix
99
102
  const arrowPositions: {
100
103
  start: number;
101
104
  end: number;
@@ -103,60 +106,84 @@ function splitArrows(line: string): string[] {
103
106
  color?: string;
104
107
  }[] = [];
105
108
 
106
- // Find all -> occurrences
109
+ // Find all arrow tokens. A token is a maximal run of `-+>` (one-or-more
110
+ // dashes followed by `>`). We scan for `->` and then expand leftward across
111
+ // adjacent dashes to absorb longer forms. `scanFloor` marks the lower
112
+ // bound for the next opening-dash search so an arrow's opening cannot
113
+ // reach back into the territory of a previously consumed arrow.
107
114
  let searchFrom = 0;
115
+ let scanFloor = 0;
108
116
  while (searchFrom < line.length) {
109
117
  const idx = line.indexOf('->', searchFrom);
110
118
  if (idx === -1) break;
111
119
 
112
- // Look backwards from idx to find the start of the arrow (the `-` that starts the label)
113
- let arrowStart = idx;
120
+ // TD-9: absorb the full arrow run leftward from idx, but not past the
121
+ // scanFloor (which is right after the previous arrow).
122
+ let runStart = idx;
123
+ while (runStart > scanFloor && line[runStart - 1] === '-') runStart--;
124
+ const arrowEnd = idx + 2; // position after `>`
125
+
126
+ // Look for an opening dash run before the arrow. The opening is the
127
+ // LEFTMOST `-` in the region `[scanFloor, runStart)` that is preceded by
128
+ // whitespace or start-of-line. Any dashes to its right up to the first
129
+ // non-dash character are part of the opening run; content after that is
130
+ // the label; the full arrow token runs from opening through `>`.
131
+ let arrowStart: number;
114
132
  let label: string | undefined;
115
133
  let color: string | undefined;
116
134
 
117
- // Check if there's content between a preceding `-` and this `->` (e.g., `-yes->`)
118
- // Walk backwards from idx-1 to find another `-` that could be the arrow start
119
- if (idx > 0 && line[idx - 1] !== ' ' && line[idx - 1] !== '\t') {
120
- // There might be label/color content attached: e.g. `-yes->` or `-(blue)->`
121
- // The arrow token starts with `-` followed by optional label, optional (color), then `->`
122
- // We need to find the opening `-` before any label text
123
- // Scan backwards to find a `-` preceded by whitespace or start-of-line
124
- let scanBack = idx - 1;
125
- while (scanBack > 0 && line[scanBack] !== '-') {
126
- scanBack--;
135
+ let openingStart = -1;
136
+ for (let i = scanFloor; i < runStart; i++) {
137
+ if (line[i] !== '-') continue;
138
+ const prevIsWsOrFloor =
139
+ i === 0 || i === scanFloor || /\s/.test(line[i - 1]);
140
+ if (prevIsWsOrFloor) {
141
+ openingStart = i;
142
+ break;
127
143
  }
128
- // Check if this `-` could be the start of the arrow
129
- if (
130
- line[scanBack] === '-' &&
131
- (scanBack === 0 || /\s/.test(line[scanBack - 1]))
132
- ) {
133
- // Content between opening `-` and `->` (strip trailing `-` that is part of `->`)
134
- let arrowContent = line.substring(scanBack + 1, idx);
135
- if (arrowContent.endsWith('-'))
136
- arrowContent = arrowContent.slice(0, -1);
137
- // Parse label and color from arrow content
138
- const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
139
- if (colorMatch) {
140
- color = colorMatch[1].trim();
141
- const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
142
- if (labelPart) label = labelPart;
143
- } else {
144
- const labelPart = arrowContent.trim();
145
- if (labelPart) label = labelPart;
146
- }
147
- arrowStart = scanBack;
144
+ }
145
+
146
+ if (openingStart !== -1) {
147
+ // End of opening run: consume consecutive dashes after openingStart.
148
+ let openingEnd = openingStart;
149
+ while (openingEnd < runStart && line[openingEnd] === '-') openingEnd++;
150
+
151
+ // Label content = everything between opening run and the arrow run.
152
+ const arrowContent = line.substring(openingEnd, runStart);
153
+ const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
154
+ if (colorMatch) {
155
+ color = colorMatch[1].trim();
156
+ const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
157
+ if (labelPart) label = labelPart;
158
+ } else {
159
+ const labelPart = arrowContent.trim();
160
+ if (labelPart) label = labelPart;
148
161
  }
162
+ arrowStart = openingStart;
163
+ } else {
164
+ // No opening dash run found. All absorbed leftward dashes belong to
165
+ // the arrow token itself (e.g. `A --> B` → arrow is `-->`, no label).
166
+ arrowStart = runStart;
149
167
  }
150
168
 
151
- arrowPositions.push({ start: arrowStart, end: idx + 2, label, color });
152
- searchFrom = idx + 2;
169
+ arrowPositions.push({ start: arrowStart, end: arrowEnd, label, color });
170
+ searchFrom = arrowEnd;
171
+ scanFloor = arrowEnd;
153
172
  }
154
173
 
155
174
  if (arrowPositions.length === 0) {
156
175
  return [line];
157
176
  }
158
177
 
159
- // Build segments
178
+ // Build segments.
179
+ //
180
+ // NOTE: the synthesized arrow token is always the short form (`->`,
181
+ // `-label->`, `-(color)->`). The actual dash run-length (`-->`, `--->`,
182
+ // `---->`) seen in the source is collapsed here. If we ever add
183
+ // dash-length-sensitive edge styling (e.g. Mermaid-style "long arrow"
184
+ // emphasis), thread `arrow.end - arrow.start - label?.length - color?.length`
185
+ // through to ArrowInfo so downstream renderers can honor it.
186
+ let lastIndex = 0;
160
187
  for (let i = 0; i < arrowPositions.length; i++) {
161
188
  const arrow = arrowPositions[i];
162
189
  const beforeText = line.substring(lastIndex, arrow.start).trim();
@@ -193,22 +220,32 @@ function parseArrowToken(
193
220
  diagnostics: DgmoError[]
194
221
  ): ArrowInfo {
195
222
  if (token === '->') return {};
196
- // Color-only: -(color)->
197
- const colorOnly = token.match(/^-\(([^)]+)\)->$/);
198
- if (colorOnly) {
199
- return {
200
- color: resolveColorWithDiagnostic(
201
- colorOnly[1].trim(),
202
- lineNumber,
203
- diagnostics,
204
- palette
205
- ),
206
- };
223
+ // TD-11: `-(X)->` is a color if and only if `X` is one of the 11 recognized
224
+ // palette color names. Otherwise the entire `(X)` becomes the label.
225
+ // Delegate the recognition rule to the shared `matchColorParens` helper.
226
+ const bareParen = token.match(/^-(\([A-Za-z]+\))->$/);
227
+ if (bareParen) {
228
+ const colorName = matchColorParens(bareParen[1]);
229
+ if (colorName) {
230
+ return {
231
+ color: resolveColorWithDiagnostic(
232
+ colorName,
233
+ lineNumber,
234
+ diagnostics,
235
+ palette
236
+ ),
237
+ };
238
+ }
239
+ // Unrecognized color name → whole `(X)` is the label (fall through).
207
240
  }
208
241
  // -label(color)-> or -label->
209
242
  const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
210
243
  if (m) {
211
- const label = m[1]?.trim() || undefined;
244
+ const rawLabel = m[1] ?? '';
245
+ // Route label through TD-13/TD-14 validator.
246
+ const labelResult = parseInArrowLabel(rawLabel, lineNumber);
247
+ diagnostics.push(...labelResult.diagnostics);
248
+ const label = labelResult.label;
212
249
  let color = m[2]
213
250
  ? resolveColorWithDiagnostic(
214
251
  m[2].trim(),