@diagrammo/dgmo 0.15.1 → 0.17.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 (122) hide show
  1. package/README.md +9 -9
  2. package/dist/advanced.cjs +612 -734
  3. package/dist/advanced.d.cts +42 -36
  4. package/dist/advanced.d.ts +42 -36
  5. package/dist/advanced.js +612 -733
  6. package/dist/auto.cjs +508 -620
  7. package/dist/auto.js +105 -105
  8. package/dist/auto.mjs +508 -620
  9. package/dist/cli.cjs +144 -144
  10. package/dist/editor.cjs +8 -9
  11. package/dist/editor.js +8 -9
  12. package/dist/highlight.cjs +8 -9
  13. package/dist/highlight.js +8 -9
  14. package/dist/index.cjs +497 -608
  15. package/dist/index.js +497 -608
  16. package/dist/internal.cjs +612 -734
  17. package/dist/internal.d.cts +42 -36
  18. package/dist/internal.d.ts +42 -36
  19. package/dist/internal.js +612 -733
  20. package/dist/pert.d.cts +2 -2
  21. package/dist/pert.d.ts +2 -2
  22. package/docs/language-reference.md +97 -84
  23. package/docs/migration-sequence-color-to-tags.md +1 -1
  24. package/gallery/fixtures/area.dgmo +3 -3
  25. package/gallery/fixtures/bar-stacked.dgmo +5 -5
  26. package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
  27. package/gallery/fixtures/c4-full.dgmo +8 -8
  28. package/gallery/fixtures/class-full.dgmo +2 -2
  29. package/gallery/fixtures/doughnut.dgmo +6 -6
  30. package/gallery/fixtures/flowchart-colors.dgmo +3 -3
  31. package/gallery/fixtures/function.dgmo +3 -3
  32. package/gallery/fixtures/gantt-full.dgmo +9 -9
  33. package/gallery/fixtures/gantt.dgmo +7 -7
  34. package/gallery/fixtures/infra-full.dgmo +6 -6
  35. package/gallery/fixtures/infra.dgmo +2 -2
  36. package/gallery/fixtures/kanban.dgmo +9 -9
  37. package/gallery/fixtures/line.dgmo +2 -2
  38. package/gallery/fixtures/multi-line.dgmo +3 -3
  39. package/gallery/fixtures/org-full.dgmo +6 -6
  40. package/gallery/fixtures/quadrant.dgmo +2 -2
  41. package/gallery/fixtures/sankey.dgmo +9 -9
  42. package/gallery/fixtures/scatter.dgmo +3 -3
  43. package/gallery/fixtures/sequence-tags-protocols.dgmo +11 -11
  44. package/gallery/fixtures/sequence-tags.dgmo +10 -10
  45. package/gallery/fixtures/sequence.dgmo +4 -4
  46. package/gallery/fixtures/sitemap-full.dgmo +7 -7
  47. package/gallery/fixtures/slope.dgmo +5 -5
  48. package/gallery/fixtures/spr-eras.dgmo +9 -9
  49. package/gallery/fixtures/timeline.dgmo +3 -3
  50. package/gallery/fixtures/venn.dgmo +3 -3
  51. package/package.json +7 -3
  52. package/src/advanced.ts +0 -1
  53. package/src/auto/index.ts +2 -2
  54. package/src/boxes-and-lines/layout.ts +1 -2
  55. package/src/boxes-and-lines/renderer.ts +5 -1
  56. package/src/c4/parser.ts +2 -2
  57. package/src/c4/renderer.ts +15 -8
  58. package/src/chart.ts +18 -9
  59. package/src/class/parser.ts +8 -7
  60. package/src/class/renderer.ts +17 -6
  61. package/src/cli.ts +8 -8
  62. package/src/completion.ts +14 -17
  63. package/src/cycle/parser.ts +15 -1
  64. package/src/cycle/renderer.ts +6 -3
  65. package/src/d3.ts +88 -49
  66. package/src/diagnostics.ts +20 -0
  67. package/src/echarts.ts +28 -11
  68. package/src/editor/dgmo.grammar +1 -3
  69. package/src/editor/dgmo.grammar.d.ts +1 -1
  70. package/src/editor/dgmo.grammar.js +8 -8
  71. package/src/editor/dgmo.grammar.terms.js +11 -12
  72. package/src/editor/highlight-api.ts +0 -1
  73. package/src/editor/highlight.ts +0 -1
  74. package/src/er/parser.ts +19 -12
  75. package/src/er/renderer.ts +19 -7
  76. package/src/gantt/parser.ts +1 -1
  77. package/src/gantt/renderer.ts +7 -4
  78. package/src/graph/flowchart-parser.ts +18 -84
  79. package/src/graph/flowchart-renderer.ts +6 -8
  80. package/src/graph/layout.ts +0 -2
  81. package/src/graph/state-parser.ts +17 -62
  82. package/src/graph/state-renderer.ts +3 -8
  83. package/src/infra/parser.ts +21 -11
  84. package/src/infra/renderer.ts +8 -6
  85. package/src/journey-map/parser.ts +11 -4
  86. package/src/journey-map/renderer.ts +3 -1
  87. package/src/kanban/parser.ts +11 -7
  88. package/src/kanban/renderer.ts +3 -1
  89. package/src/mindmap/parser.ts +4 -5
  90. package/src/mindmap/renderer.ts +2 -1
  91. package/src/org/parser.ts +3 -3
  92. package/src/org/renderer.ts +4 -3
  93. package/src/pert/analyzer.ts +10 -10
  94. package/src/pert/layout.ts +1 -1
  95. package/src/pert/parser.ts +8 -8
  96. package/src/pert/renderer.ts +7 -2
  97. package/src/pert/types.ts +1 -1
  98. package/src/pyramid/parser.ts +13 -1
  99. package/src/raci/parser.ts +42 -12
  100. package/src/raci/renderer.ts +2 -1
  101. package/src/raci/types.ts +4 -3
  102. package/src/ring/parser.ts +13 -1
  103. package/src/sequence/parser.ts +81 -23
  104. package/src/sequence/participant-inference.ts +18 -181
  105. package/src/sequence/renderer.ts +48 -137
  106. package/src/sitemap/layout.ts +0 -2
  107. package/src/sitemap/parser.ts +12 -38
  108. package/src/sitemap/renderer.ts +13 -13
  109. package/src/sitemap/types.ts +0 -1
  110. package/src/tech-radar/parser.ts +2 -2
  111. package/src/tech-radar/renderer.ts +5 -3
  112. package/src/tech-radar/types.ts +2 -0
  113. package/src/utils/arrows.ts +3 -28
  114. package/src/utils/extract-alias.ts +1 -1
  115. package/src/utils/inline-markdown.ts +1 -1
  116. package/src/utils/legend-d3.ts +12 -6
  117. package/src/utils/legend-layout.ts +1 -1
  118. package/src/utils/legend-types.ts +1 -1
  119. package/src/utils/parsing.ts +64 -35
  120. package/src/utils/tag-groups.ts +98 -18
  121. package/src/utils/time-ticks.ts +1 -1
  122. package/src/wireframe/parser.ts +3 -3
@@ -14,7 +14,6 @@ import {
14
14
  nameMergedMessage,
15
15
  } from '../diagnostics';
16
16
  import { tryStripDescriptionKeyword } from '../utils/description-helpers';
17
- import { resolveColorWithDiagnostic } from '../colors';
18
17
  import { parseInArrowLabel } from '../utils/arrows';
19
18
  import {
20
19
  measureIndent,
@@ -22,6 +21,7 @@ import {
22
21
  OPTION_NOCOLON_RE,
23
22
  tryParseSharedOption,
24
23
  } from '../utils/parsing';
24
+ import { isRecognizedColorName } from '../colors';
25
25
  import { normalizeName, displayName } from '../utils/name-normalize';
26
26
  import {
27
27
  matchTagBlockHeading,
@@ -62,9 +62,11 @@ const DEPRECATED_FANOUT_RE = /\bx(\d+)\s*$/;
62
62
  const GROUP_RE =
63
63
  /^\[([^\]]+)\]\s*(?:as\s+([A-Za-z][A-Za-z0-9_]{0,11})\s*)?(?:\|\s*(.+))?$/;
64
64
 
65
- // Tag value: Name or Name(color)
66
- // Note: `default` keyword removed first value is the default.
67
- const TAG_VALUE_RE = /^(\w[\w\s]*?)(?:\(([^)]+)\))?\s*$/;
65
+ // Tag value: `Name` or `Name color` (trailing-token color form). Color is
66
+ // extracted via the shared `extractColor` helper at use-site (see
67
+ // `dgmo/src/utils/parsing.ts:extractColor`), not via this regex. This regex
68
+ // just confirms the line shape is a valid tag value (no reserved sigils).
69
+ const TAG_VALUE_RE = /^(\w[\w\s]+?)\s*$/;
68
70
 
69
71
  // Component line. Accepts either a quoted name ("name with | : reserved chars")
70
72
  // or a bare name (multi-word allowed; must start with letter/underscore so digit-
@@ -445,14 +447,22 @@ export function parseInfra(content: string): ParsedInfra {
445
447
  // Tag value inside tag group — first value is the default unless another is marked `default`
446
448
  if (currentTagGroup && indent > 0) {
447
449
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
448
- const tvMatch = cleanEntry.match(TAG_VALUE_RE);
449
- if (tvMatch) {
450
- const valueName = tvMatch[1].trim();
451
- const rawColor = tvMatch[2]?.trim();
452
- if (rawColor) {
453
- // Validate the color name; emit diagnostic if invalid
454
- resolveColorWithDiagnostic(rawColor, lineNumber, result.diagnostics);
450
+ // Trailing-token color (universal rule, §1.5): peel off a lowercase
451
+ // recognized color word from the end of the line. Downstream stores
452
+ // the raw color NAME (not the palette hex) so the renderer can resolve
453
+ // against whichever theme/palette is active at render time.
454
+ const lastSpaceIdx = cleanEntry.lastIndexOf(' ');
455
+ let valueName = cleanEntry;
456
+ let rawColor: string | undefined;
457
+ if (lastSpaceIdx > 0) {
458
+ const trailing = cleanEntry.substring(lastSpaceIdx + 1);
459
+ if (isRecognizedColorName(trailing)) {
460
+ rawColor = trailing;
461
+ valueName = cleanEntry.substring(0, lastSpaceIdx).trimEnd();
455
462
  }
463
+ }
464
+ const tvMatch = valueName.match(TAG_VALUE_RE);
465
+ if (tvMatch || /^\w+$/.test(valueName)) {
456
466
  currentTagGroup.values.push({
457
467
  name: valueName,
458
468
  color: rawColor,
@@ -1733,8 +1733,7 @@ function renderNodes(
1733
1733
  }
1734
1734
 
1735
1735
  // Role badge dots — only shown when Capabilities legend is expanded
1736
- const showDots =
1737
- activeGroup != null && activeGroup.toLowerCase() === 'capabilities';
1736
+ const showDots = activeGroup?.toLowerCase() === 'capabilities';
1738
1737
  const roles = showDots && !node.isEdge ? inferRoles(node.properties) : [];
1739
1738
  if (roles.length > 0) {
1740
1739
  // Move dots up above the collapse bar for collapsed groups
@@ -2024,7 +2023,8 @@ function renderLegend(
2024
2023
  palette: PaletteColors,
2025
2024
  isDark: boolean,
2026
2025
  activeGroup: string | null,
2027
- playback?: InfraPlaybackState
2026
+ playback?: InfraPlaybackState,
2027
+ exportMode = false
2028
2028
  ) {
2029
2029
  if (legendGroups.length === 0 && !playback) return;
2030
2030
 
@@ -2049,7 +2049,7 @@ function renderLegend(
2049
2049
  const legendConfig: LegendConfig = {
2050
2050
  groups: allGroups,
2051
2051
  position: { placement: 'top-center', titleRelation: 'below-title' },
2052
- mode: 'fixed',
2052
+ mode: exportMode ? 'export' : 'preview',
2053
2053
  showEmptyGroups: true,
2054
2054
  };
2055
2055
  const legendState: LegendState = { activeGroup };
@@ -2394,7 +2394,8 @@ export function renderInfra(
2394
2394
  palette,
2395
2395
  isDark,
2396
2396
  activeGroup ?? null,
2397
- playback ?? undefined
2397
+ playback ?? undefined,
2398
+ exportMode
2398
2399
  );
2399
2400
  // Re-enable pointer events on interactive legend elements
2400
2401
  legendSvg
@@ -2410,7 +2411,8 @@ export function renderInfra(
2410
2411
  palette,
2411
2412
  isDark,
2412
2413
  activeGroup ?? null,
2413
- playback ?? undefined
2414
+ playback ?? undefined,
2415
+ exportMode
2414
2416
  );
2415
2417
  }
2416
2418
  }
@@ -1,4 +1,5 @@
1
1
  import type { PaletteColors } from '../palettes';
2
+ import { resolveColorWithDiagnostic } from '../colors';
2
3
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
3
4
  import {
4
5
  matchTagBlockHeading,
@@ -63,7 +64,7 @@ export function parseJourneyMap(
63
64
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
64
65
  };
65
66
 
66
- if (!content || !content.trim()) {
67
+ if (!content?.trim()) {
67
68
  return fail(0, 'No content provided');
68
69
  }
69
70
 
@@ -138,8 +139,14 @@ export function parseJourneyMap(
138
139
  const key = part.substring(0, colonIdx).trim().toLowerCase();
139
140
  const value = part.substring(colonIdx + 1).trim();
140
141
  if (key === 'color') {
141
- const resolved = extractColor(`x(${value})`, palette);
142
- personaColor = resolved.color;
142
+ // Resolve the color name directly (no synthetic parens wrap).
143
+ personaColor =
144
+ resolveColorWithDiagnostic(
145
+ value,
146
+ lineNumber,
147
+ result.diagnostics,
148
+ palette
149
+ ) ?? undefined;
143
150
  }
144
151
  }
145
152
  }
@@ -209,7 +216,7 @@ export function parseJourneyMap(
209
216
  if (!color) {
210
217
  warn(
211
218
  lineNumber,
212
- `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`
219
+ `Expected 'Value color' in tag group '${currentTagGroup.name}'`
213
220
  );
214
221
  continue;
215
222
  }
@@ -36,6 +36,7 @@ export interface JourneyMapInteractiveOptions {
36
36
  collapsedPhases?: Set<string>;
37
37
  /** Called when a phase is toggled */
38
38
  onPhaseToggle?: (phaseName: string) => void;
39
+ exportMode?: boolean;
39
40
  }
40
41
 
41
42
  // ============================================================
@@ -313,7 +314,7 @@ export function renderJourneyMap(
313
314
  titleRelation: 'inline-with-title',
314
315
  },
315
316
  titleWidth: 0,
316
- mode: exportDims ? 'inline' : 'fixed',
317
+ mode: options?.exportMode ? 'export' : 'preview',
317
318
  };
318
319
 
319
320
  const legendState: LegendState = { activeGroup: effectiveActiveGroup };
@@ -1559,6 +1560,7 @@ export function renderJourneyMapForExport(
1559
1560
  const container = document.createElement('div');
1560
1561
  renderJourneyMap(container, parsed, palette, isDark, {
1561
1562
  exportDims: { width: layout.totalWidth, height: layout.totalHeight },
1563
+ exportMode: true,
1562
1564
  });
1563
1565
 
1564
1566
  const svgEl = container.querySelector('svg');
@@ -26,10 +26,11 @@ import type {
26
26
  // Regex patterns
27
27
  // ============================================================
28
28
 
29
- // [Column Name], [Column Name](color), [Column Name] as <alias>, [Column Name] | wip: 3, etc.
29
+ // [Column Name], [Column Name] color, [Column Name] as <alias>, [Column Name] | wip: 3, etc.
30
+ // Universal §1.5 trailing-token: color is a bare token after `]`.
30
31
  // Captures: [1]=label [2]=color [3]=alias (TD-18) [4]=pipe meta
31
32
  const COLUMN_RE =
32
- /^\[(.+?)\](?:\s*\(([^)]+)\))?(?:\s+as\s+([A-Za-z][A-Za-z0-9_]{0,11}))?\s*(?:\|\s*(.+))?$/;
33
+ /^\[(.+?)\](?:\s+(\S+))?(?:\s+as\s+([A-Za-z][A-Za-z0-9_]{0,11}))?\s*(?:\|\s*(.+))?$/;
33
34
  // Legacy delimiter
34
35
  const LEGACY_COLUMN_RE = /^==\s+(.+?)\s*(?:\[wip:\s*(\d+)\])?\s*==$/;
35
36
 
@@ -70,7 +71,7 @@ export function parseKanban(
70
71
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
71
72
  };
72
73
 
73
- if (!content || !content.trim()) {
74
+ if (!content?.trim()) {
74
75
  return fail(0, 'No content provided');
75
76
  }
76
77
 
@@ -180,7 +181,7 @@ export function parseKanban(
180
181
  }
181
182
  }
182
183
 
183
- // Tag group entries (indented Value(color) under tag heading)
184
+ // Tag group entries (indented Value color under tag heading)
184
185
  // First entry is the default unless another is marked `default`
185
186
  if (currentTagGroup && !contentStarted) {
186
187
  const indent = measureIndent(line);
@@ -190,7 +191,7 @@ export function parseKanban(
190
191
  if (!color) {
191
192
  warn(
192
193
  lineNumber,
193
- `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`
194
+ `Expected 'Value color' in tag group '${currentTagGroup.name}'`
194
195
  );
195
196
  continue;
196
197
  }
@@ -247,9 +248,12 @@ export function parseKanban(
247
248
 
248
249
  columnCounter++;
249
250
  const colName = columnMatch[1].trim();
250
- const colColor = columnMatch[2]
251
+ // Trailing token after `]` must be a recognized color word (§1.5).
252
+ // If it isn't, the line is malformed — emit the standard diagnostic.
253
+ const rawTrailing = columnMatch[2]?.trim();
254
+ const colColor = rawTrailing
251
255
  ? resolveColorWithDiagnostic(
252
- columnMatch[2].trim(),
256
+ rawTrailing,
253
257
  lineNumber,
254
258
  result.diagnostics,
255
259
  palette
@@ -37,6 +37,7 @@ interface KanbanInteractiveOptions {
37
37
  collapsedLanes?: Set<string>;
38
38
  collapsedColumns?: Set<string>;
39
39
  compactMeta?: boolean;
40
+ exportMode?: boolean;
40
41
  }
41
42
 
42
43
  // ============================================================
@@ -330,7 +331,7 @@ export function renderKanban(
330
331
  const legendConfig: LegendConfig = {
331
332
  groups: parsed.tagGroups,
332
333
  position: { placement: 'top-center', titleRelation: 'inline-with-title' },
333
- mode: exportDims ? 'inline' : 'fixed',
334
+ mode: options?.exportMode ? 'export' : 'preview',
334
335
  };
335
336
  const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
336
337
  const legendG = svg
@@ -682,6 +683,7 @@ export function renderKanbanForExport(
682
683
  const container = document.createElement('div');
683
684
  renderKanban(container, parsed, palette, isDark, {
684
685
  exportDims: { width: layout.totalWidth, height: layout.totalHeight },
686
+ exportMode: true,
685
687
  });
686
688
 
687
689
  const svgEl = container.querySelector('svg');
@@ -62,7 +62,7 @@ export function parseMindmap(
62
62
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
63
63
  };
64
64
 
65
- if (!content || !content.trim()) {
65
+ if (!content?.trim()) {
66
66
  return fail(0, 'No content provided');
67
67
  }
68
68
 
@@ -179,7 +179,7 @@ export function parseMindmap(
179
179
  }
180
180
  }
181
181
 
182
- // Tag group entries (indented Value(color) under tag heading)
182
+ // Tag group entries (indented Value color under tag heading)
183
183
  if (currentTagGroup && !contentStarted) {
184
184
  const indent = measureIndent(line);
185
185
  if (indent > 0) {
@@ -188,7 +188,7 @@ export function parseMindmap(
188
188
  if (!color) {
189
189
  pushError(
190
190
  lineNumber,
191
- `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`
191
+ `Expected 'Value color' in tag group '${currentTagGroup.name}'`
192
192
  );
193
193
  continue;
194
194
  }
@@ -278,8 +278,7 @@ export function parseMindmap(
278
278
  result.diagnostics.push(diag);
279
279
  result.error = formatDgmoError(diag);
280
280
  } else if (
281
- titleRoot &&
282
- titleRoot.children.length === 0 &&
281
+ titleRoot?.children.length === 0 &&
283
282
  result.roots.length === 1 &&
284
283
  !result.error
285
284
  ) {
@@ -95,6 +95,7 @@ export function renderMindmap(
95
95
  onToggleDescriptions?: (active: boolean) => void;
96
96
  controlsExpanded?: boolean;
97
97
  onToggleControlsExpand?: () => void;
98
+ exportMode?: boolean;
98
99
  }
99
100
  ): void {
100
101
  const isExport = !!exportDims;
@@ -234,7 +235,7 @@ export function renderMindmap(
234
235
  };
235
236
  }),
236
237
  position: { placement: 'top-center', titleRelation: 'below-title' },
237
- mode: 'fixed',
238
+ mode: options?.exportMode ? 'export' : 'preview',
238
239
  controlsGroup: controlsToggles,
239
240
  };
240
241
  const legendState: LegendState = {
package/src/org/parser.ts CHANGED
@@ -115,7 +115,7 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
115
115
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
116
116
  };
117
117
 
118
- if (!content || !content.trim()) {
118
+ if (!content?.trim()) {
119
119
  return fail(0, 'No content provided');
120
120
  }
121
121
 
@@ -235,7 +235,7 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
235
235
  }
236
236
  }
237
237
 
238
- // Tag group entries (indented Value(color) under tag heading)
238
+ // Tag group entries (indented Value color under tag heading)
239
239
  // First entry is the default unless another is marked `default`
240
240
  if (currentTagGroup && !contentStarted) {
241
241
  const indent = measureIndent(line);
@@ -245,7 +245,7 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
245
245
  if (!color) {
246
246
  pushError(
247
247
  lineNumber,
248
- `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`
248
+ `Expected 'Value color' in tag group '${currentTagGroup.name}'`
249
249
  );
250
250
  continue;
251
251
  }
@@ -110,7 +110,8 @@ export function renderOrg(
110
110
  exportDims?: { width?: number; height?: number },
111
111
  activeTagGroup?: string | null,
112
112
  hiddenAttributes?: Set<string>,
113
- ancestorPath?: AncestorInfo[]
113
+ ancestorPath?: AncestorInfo[],
114
+ exportMode?: boolean
114
115
  ): void {
115
116
  // Clear existing content
116
117
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
@@ -759,7 +760,7 @@ export function renderOrg(
759
760
  },
760
761
  ],
761
762
  position: { placement: 'top-center', titleRelation: 'below-title' },
762
- mode: 'fixed',
763
+ mode: exportMode ? 'export' : 'preview',
763
764
  };
764
765
  const singleState: LegendState = { activeGroup: lg.name };
765
766
  const groupG = legendParentBase
@@ -783,7 +784,7 @@ export function renderOrg(
783
784
  const legendConfig: LegendConfig = {
784
785
  groups,
785
786
  position: { placement: 'top-center', titleRelation: 'below-title' },
786
- mode: 'fixed',
787
+ mode: exportMode ? 'export' : 'preview',
787
788
  capsulePillAddonWidth: eyeAddonWidth,
788
789
  };
789
790
  const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
@@ -180,7 +180,7 @@ export function analyzePert(parsed: ParsedPert): ResolvedPert {
180
180
  for (const e of edges) {
181
181
  if (!e.lag || e.lag.amount >= 0) continue;
182
182
  const src = activities.find((a) => a.id === e.source);
183
- if (!src || !src.duration) continue;
183
+ if (!src?.duration) continue;
184
184
  const leadDays = -toDays(e.lag, sprintDays);
185
185
  const srcDurDays = toDays(src.duration.m, sprintDays);
186
186
  if (e.type === 'FS' && leadDays > srcDurDays) {
@@ -923,7 +923,7 @@ export function buildSummary(input: BuildSummaryInput): CaptionRow[] | null {
923
923
  // Expected duration AND each percentile latest-safe start so the
924
924
  // caption shape stays parallel to the feasible case (one top row +
925
925
  // three percentile sub-rows).
926
- if (anchor && anchor.kind === 'backward') {
926
+ if (anchor?.kind === 'backward') {
927
927
  return [
928
928
  { text: 'Expected duration: ?', level: 0 },
929
929
  { text: 'P50 latest-safe start: ?', level: 0 },
@@ -958,13 +958,13 @@ export function buildSummary(input: BuildSummaryInput): CaptionRow[] | null {
958
958
  const sigmaParen = showMcDetail
959
959
  ? ` (± ${roundForCaption(projectSigma!)} ${pluralizeUnit(projectSigma!, unit)})`
960
960
  : '';
961
- if (anchor && anchor.kind === 'forward') {
961
+ if (anchor?.kind === 'forward') {
962
962
  const projectMuDays = projectMu * unitToDays(unit);
963
963
  rows.push({
964
964
  text: `Expected finish: ${addCalendarDays(anchor.date, projectMuDays)}${sigmaParen}.`,
965
965
  level: 0,
966
966
  });
967
- } else if (anchor && anchor.kind === 'backward') {
967
+ } else if (anchor?.kind === 'backward') {
968
968
  const projectMuDays = projectMu * unitToDays(unit);
969
969
  rows.push({
970
970
  text: `Expected start: ${addCalendarDays(anchor.date, -projectMuDays)}${sigmaParen}.`,
@@ -990,13 +990,13 @@ export function buildSummary(input: BuildSummaryInput): CaptionRow[] | null {
990
990
  { pct: 80, days: monteCarloResult!.p80 },
991
991
  { pct: 95, days: monteCarloResult!.p95 },
992
992
  ];
993
- if (anchor && anchor.kind === 'forward') {
993
+ if (anchor?.kind === 'forward') {
994
994
  for (const { pct, days } of percentiles) {
995
995
  const offsetDays = roundConservative(days, 'forward');
996
996
  const date = addCalendarDays(anchor.date, offsetDays);
997
997
  rows.push({ text: `P${pct} finish: ${date}.`, level: 1 });
998
998
  }
999
- } else if (anchor && anchor.kind === 'backward') {
999
+ } else if (anchor?.kind === 'backward') {
1000
1000
  for (const { pct, days } of percentiles) {
1001
1001
  const offsetDays = roundConservative(days, 'backward');
1002
1002
  const date = addCalendarDays(anchor.date, -offsetDays);
@@ -1074,10 +1074,10 @@ export function buildProjectSubtitle(input: {
1074
1074
 
1075
1075
  if (projectMu === null) {
1076
1076
  // Anchored + TBD: keep the framing prefix, mark the math as ?.
1077
- if (anchor && anchor.kind === 'forward') {
1077
+ if (anchor?.kind === 'forward') {
1078
1078
  return `Expected finish: ? · ≈ ? ${pluralizeUnit(2, unit)} of work`;
1079
1079
  }
1080
- if (anchor && anchor.kind === 'backward') {
1080
+ if (anchor?.kind === 'backward') {
1081
1081
  return `Expected start: ? · ≈ ? ${pluralizeUnit(2, unit)} lead time`;
1082
1082
  }
1083
1083
  // Unanchored + TBD: surface that the total is unknown. The per-node
@@ -1087,11 +1087,11 @@ export function buildProjectSubtitle(input: {
1087
1087
 
1088
1088
  const muStr = `${roundForCaption(projectMu)} ${pluralizeUnit(projectMu, unit)}`;
1089
1089
 
1090
- if (anchor && anchor.kind === 'forward') {
1090
+ if (anchor?.kind === 'forward') {
1091
1091
  const projectMuDays = projectMu * unitToDays(unit);
1092
1092
  return `Expected finish: ${addCalendarDays(anchor.date, projectMuDays)} · ≈ ${muStr} of work${sigmaParen}`;
1093
1093
  }
1094
- if (anchor && anchor.kind === 'backward') {
1094
+ if (anchor?.kind === 'backward') {
1095
1095
  const projectMuDays = projectMu * unitToDays(unit);
1096
1096
  return `Expected start: ${addCalendarDays(anchor.date, -projectMuDays)} · ≈ ${muStr} lead time${sigmaParen}`;
1097
1097
  }
@@ -208,7 +208,7 @@ function nodeDimensions(
208
208
  sizing: NodeSizing,
209
209
  overrides?: LayoutOverrides
210
210
  ): { width: number; height: number } {
211
- if (overrides && overrides[id]) {
211
+ if (overrides?.[id]) {
212
212
  return { width: overrides[id].width, height: overrides[id].height };
213
213
  }
214
214
  const r = resolved.activities.find((a) => a.activity.id === id);
@@ -448,7 +448,7 @@ export interface ParsePertOptions {
448
448
  now?: Date;
449
449
  /**
450
450
  * Active palette — used when resolving color names on `tag` entries
451
- * (e.g. `High(red)` → palette.colors.red). Optional; when omitted the
451
+ * (e.g. `High red` → palette.colors.red). Optional; when omitted the
452
452
  * universal default color map is used.
453
453
  */
454
454
  palette?: PaletteColors;
@@ -503,7 +503,7 @@ export function parsePert(
503
503
 
504
504
  /**
505
505
  * Tag groups declared at the top of the diagram. A `tag …` heading
506
- * opens a block; entries (indented `Value(color)` lines) accumulate
506
+ * opens a block; entries (indented `Value color` lines) accumulate
507
507
  * until the first non-tag content line closes it.
508
508
  */
509
509
  const tagGroups: TagGroup[] = [];
@@ -576,7 +576,7 @@ export function parsePert(
576
576
  // layer is responsible for routing.
577
577
  }
578
578
 
579
- // ── Tag-block phase. `tag Priority as p\n High(red)\n Low(green)`
579
+ // ── Tag-block phase. `tag Priority as p\n High red\n Low green`
580
580
  // lives BEFORE diagram content; once any group / activity / arrow
581
581
  // is seen, `contentStarted` flips and further `tag …` headings
582
582
  // emit an error.
@@ -599,7 +599,7 @@ export function parsePert(
599
599
  );
600
600
  }
601
601
  tagGroups.push(currentTagGroup);
602
- // Inline values (e.g. `tag Priority as p Low(green), High(red)`).
602
+ // Inline values (e.g. `tag Priority as p Low green, High red`).
603
603
  if (tagBlockMatch.inlineValues) {
604
604
  for (const raw of tagBlockMatch.inlineValues) {
605
605
  const { text, isDefault } = stripDefaultModifier(raw);
@@ -612,7 +612,7 @@ export function parsePert(
612
612
  if (!color) {
613
613
  warn(
614
614
  lineNumber,
615
- `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`
615
+ `Expected 'Value color' in tag group '${currentTagGroup.name}'`
616
616
  );
617
617
  continue;
618
618
  }
@@ -624,7 +624,7 @@ export function parsePert(
624
624
  }
625
625
  continue;
626
626
  }
627
- // Indented `Value(color)` entry under an open tag block.
627
+ // Indented `Value color` entry under an open tag block.
628
628
  if (currentTagGroup && indent > 0) {
629
629
  const { text, isDefault } = stripDefaultModifier(trimmed);
630
630
  const { label, color } = extractColor(
@@ -636,7 +636,7 @@ export function parsePert(
636
636
  if (!color) {
637
637
  warn(
638
638
  lineNumber,
639
- `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`
639
+ `Expected 'Value color' in tag group '${currentTagGroup.name}'`
640
640
  );
641
641
  continue;
642
642
  }
@@ -823,7 +823,7 @@ export function parsePert(
823
823
  const head = trimmed.slice(0, firstSpace).toLowerCase();
824
824
  const value = trimmed.slice(firstSpace + 1).trim();
825
825
  const hint = NEAR_DIRECTIVE_HINTS.find((h) => h.stem === head);
826
- if (hint && hint.matches.test(value)) {
826
+ if (hint?.matches.test(value)) {
827
827
  error(
828
828
  lineNumber,
829
829
  `Unknown directive '${head}'. Did you mean '${hint.canonical}'?`,
@@ -388,6 +388,8 @@ export interface PertRenderOptions {
388
388
  * through to the parsed `active-tag` directive.
389
389
  */
390
390
  activeTagOverride?: string | null;
391
+ /** True when rendering for export — strips collapsed pills and cog from legend. */
392
+ exportMode?: boolean;
391
393
  }
392
394
 
393
395
  export function renderPert(
@@ -568,6 +570,7 @@ export function renderPert(
568
570
  y: tagLegendY,
569
571
  width: exportWidth,
570
572
  activeGroup: tagLegendActive,
573
+ exportMode: options.exportMode,
571
574
  });
572
575
  }
573
576
 
@@ -681,6 +684,7 @@ export function renderPertForExport(
681
684
  title: hasTitle ? parsed.title : null,
682
685
  subtitle: resolved.projectSubtitle,
683
686
  exportDims: { width: exportWidth, height: exportHeight },
687
+ exportMode: true,
684
688
  });
685
689
  const svgEl = container.querySelector('svg');
686
690
  if (!svgEl) return '';
@@ -2990,6 +2994,7 @@ interface TagLegendArgs {
2990
2994
  y: number;
2991
2995
  width: number;
2992
2996
  activeGroup: string | null;
2997
+ exportMode?: boolean;
2993
2998
  }
2994
2999
 
2995
3000
  /**
@@ -3008,7 +3013,7 @@ function renderTagLegendRow(
3008
3013
  ): void {
3009
3014
  if (resolved.tagGroups.length === 0) return;
3010
3015
 
3011
- const { x, y, width, activeGroup } = args;
3016
+ const { x, y, width, activeGroup, exportMode } = args;
3012
3017
  const groups = resolved.tagGroups.map((g) => ({
3013
3018
  name: g.name,
3014
3019
  entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
@@ -3024,7 +3029,7 @@ function renderTagLegendRow(
3024
3029
  {
3025
3030
  groups,
3026
3031
  position: { placement: 'top-center', titleRelation: 'below-title' },
3027
- mode: 'fixed',
3032
+ mode: exportMode ? 'export' : 'preview',
3028
3033
  },
3029
3034
  { activeGroup },
3030
3035
  palette,
package/src/pert/types.ts CHANGED
@@ -215,7 +215,7 @@ export interface ParsedPert {
215
215
  groups: PertGroup[];
216
216
  /**
217
217
  * Tag groups declared at the top of the diagram (`tag Priority as p
218
- * High(red), Low(green)`). Drive node fill via `resolveTagColor()`.
218
+ * High red, Low green`). Drive node fill via `resolveTagColor()`.
219
219
  * Empty when no `tag` blocks are declared.
220
220
  */
221
221
  tagGroups: TagGroup[];
@@ -7,6 +7,7 @@ import {
7
7
  measureIndent,
8
8
  parseFirstLine,
9
9
  parsePipeMetadata,
10
+ peelTrailingColorName,
10
11
  tryParseSharedOption,
11
12
  } from '../utils/parsing';
12
13
  import type { ParsedPyramid, PyramidLayer } from './types';
@@ -88,7 +89,7 @@ export function parsePyramid(content: string): ParsedPyramid {
88
89
  // ── First line: chart type declaration ──
89
90
  if (!headerParsed) {
90
91
  const firstLineResult = parseFirstLine(trimmed);
91
- if (firstLineResult && firstLineResult.chartType === 'pyramid') {
92
+ if (firstLineResult?.chartType === 'pyramid') {
92
93
  result.title = firstLineResult.title ?? '';
93
94
  result.titleLineNumber = lineNum;
94
95
  headerParsed = true;
@@ -146,6 +147,17 @@ export function parsePyramid(content: string): ParsedPyramid {
146
147
  continue;
147
148
  }
148
149
 
150
+ // Universal trailing-token shortcut: `Label color` is equivalent to
151
+ // `Label | color: <name>` when color is the only metadata key (§1.5).
152
+ if (!color) {
153
+ const { label: stripped, colorName: shortcutColor } =
154
+ peelTrailingColorName(label);
155
+ if (shortcutColor) {
156
+ color = shortcutColor;
157
+ label = stripped;
158
+ }
159
+ }
160
+
149
161
  currentLayer = {
150
162
  label,
151
163
  lineNumber: lineNum,