@diagrammo/dgmo 0.31.0 → 0.32.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 (72) hide show
  1. package/.cursorrules +4 -1
  2. package/.github/copilot-instructions.md +4 -1
  3. package/.windsurfrules +4 -1
  4. package/SKILL.md +4 -1
  5. package/dist/advanced.cjs +1297 -358
  6. package/dist/advanced.d.cts +117 -15
  7. package/dist/advanced.d.ts +117 -15
  8. package/dist/advanced.js +1291 -358
  9. package/dist/auto.cjs +1087 -316
  10. package/dist/auto.js +98 -98
  11. package/dist/auto.mjs +1087 -316
  12. package/dist/cli.cjs +140 -140
  13. package/dist/index.cjs +1090 -397
  14. package/dist/index.js +1090 -397
  15. package/docs/ai-integration.md +4 -1
  16. package/docs/language-reference.md +282 -27
  17. package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
  18. package/gallery/fixtures/c4-full.dgmo +4 -5
  19. package/gallery/fixtures/c4.dgmo +2 -3
  20. package/package.json +7 -1
  21. package/src/advanced.ts +7 -0
  22. package/src/boxes-and-lines/focus.ts +257 -0
  23. package/src/boxes-and-lines/layout-search.ts +131 -65
  24. package/src/boxes-and-lines/layout.ts +7 -1
  25. package/src/boxes-and-lines/parser.ts +19 -4
  26. package/src/boxes-and-lines/renderer.ts +54 -3
  27. package/src/c4/parser.ts +8 -7
  28. package/src/chart-type-registry.ts +129 -4
  29. package/src/chart-types.ts +4 -4
  30. package/src/chart.ts +18 -1
  31. package/src/colors.ts +225 -2
  32. package/src/cycle/parser.ts +2 -7
  33. package/src/d3.ts +67 -54
  34. package/src/diagnostics.ts +17 -0
  35. package/src/dimensions.ts +9 -13
  36. package/src/echarts.ts +42 -14
  37. package/src/er/parser.ts +6 -1
  38. package/src/gantt/parser.ts +44 -7
  39. package/src/graph/flowchart-parser.ts +77 -3
  40. package/src/graph/state-renderer.ts +2 -2
  41. package/src/infra/parser.ts +80 -0
  42. package/src/journey-map/parser.ts +8 -7
  43. package/src/kanban/parser.ts +8 -7
  44. package/src/map/context-labels.ts +134 -27
  45. package/src/map/geo.ts +10 -2
  46. package/src/map/layout.ts +259 -4
  47. package/src/map/parser.ts +2 -0
  48. package/src/map/renderer.ts +22 -11
  49. package/src/map/resolver.ts +68 -19
  50. package/src/mindmap/parser.ts +15 -7
  51. package/src/mindmap/renderer.ts +50 -12
  52. package/src/org/parser.ts +8 -7
  53. package/src/org/renderer.ts +22 -7
  54. package/src/palettes/color-utils.ts +12 -2
  55. package/src/palettes/index.ts +1 -0
  56. package/src/pert/renderer.ts +2 -2
  57. package/src/pyramid/parser.ts +2 -7
  58. package/src/quadrant/renderer.ts +2 -2
  59. package/src/raci/parser.ts +2 -7
  60. package/src/raci/renderer.ts +4 -4
  61. package/src/ring/parser.ts +2 -7
  62. package/src/sequence/parser.ts +18 -7
  63. package/src/sequence/renderer.ts +4 -4
  64. package/src/sitemap/parser.ts +8 -7
  65. package/src/sitemap/renderer.ts +2 -2
  66. package/src/tech-radar/parser.ts +2 -7
  67. package/src/timeline/renderer.ts +15 -5
  68. package/src/utils/parsing.ts +13 -1
  69. package/src/utils/scaling.ts +38 -81
  70. package/src/utils/tag-groups.ts +38 -0
  71. package/src/visualizations/parse.ts +6 -1
  72. package/src/wireframe/parser.ts +6 -1
@@ -68,12 +68,44 @@ const POI_ZOOM_FLOOR_DEG = 7;
68
68
  // single POI near the edge of a tall/wide country (e.g. Cartagena at the north
69
69
  // tip of Colombia) would otherwise drag the frame to that country's far edge —
70
70
  // all the way to the Amazon, ~15° below the southernmost dot. Clamp the container
71
- // union so it reveals at most this many degrees of container BEYOND the POI
72
- // cluster on each side: northern Colombia stays for orientation, the empty
73
- // interior is cropped. Sized so an edge cluster still reaches across a US
74
- // state-scale container (a Bay-Area cluster sits on the coast, ~8° from the
75
- // Nevada border, and must still show the whole of California). Tunable.
76
- const CONTAINER_OVERSHOOT_DEG = 8;
71
+ // union so it reveals at most `containerOvershoot(cluster)` degrees of container
72
+ // BEYOND the POI cluster on each side: northern Colombia stays for orientation,
73
+ // the empty interior is cropped.
74
+ //
75
+ // For NON-US clusters the overshoot SHRINKS as the cluster grows (tuned
76
+ // 2026-06-19). A tiny cluster has no context of its own, so it keeps the full MAX
77
+ // (8°) — enough to reveal its whole modest container (e.g. a small European
78
+ // country). A LARGE cluster already supplies its own context, so a fixed 8° just
79
+ // padded a huge container with empty land (a Ukraine/Russia strike map framed
80
+ // ~2.4× the cluster, a wide dead band above the dots). Linearly decaying the
81
+ // overshoot to MIN (3°) tightens big clusters (~1.7× cluster) without cropping
82
+ // small ones. The POI_ZOOM_FLOOR_DEG floor still guards the lower bound.
83
+ //
84
+ // US-ORIENTED maps are EXEMPT (keep the flat MAX): the national-vs-regional
85
+ // projection gate (US_NATIONAL_LON_SPAN, albers-usa vs regional Mercator) is
86
+ // calibrated against the 8°-overshoot frame span — a coast-to-Caribbean US cruise
87
+ // route clears the national threshold only because the west overshoot reaches ~8°
88
+ // past Denver. Shrinking it there would silently flip such maps off albers-usa.
89
+ // US containers (a state, CONUS) aren't the huge-empty-container problem anyway.
90
+ const CONTAINER_OVERSHOOT_MAX = 8;
91
+ const CONTAINER_OVERSHOOT_MIN = 3;
92
+ // Degrees of overshoot shed per degree of cluster span (larger span ⇒ less slack).
93
+ const CONTAINER_OVERSHOOT_DECAY = 0.3;
94
+
95
+ /** Per-cluster container overshoot (deg). US-oriented maps keep the flat MAX (the
96
+ * albers-usa national gate is calibrated to it); other maps get full MAX for a
97
+ * tight cluster, decaying to MIN for a large one. `span` = the cluster's larger
98
+ * lon/lat extent. */
99
+ function containerOvershoot(span: number, usOriented: boolean): number {
100
+ if (usOriented) return CONTAINER_OVERSHOOT_MAX;
101
+ return Math.max(
102
+ CONTAINER_OVERSHOOT_MIN,
103
+ Math.min(
104
+ CONTAINER_OVERSHOOT_MAX,
105
+ CONTAINER_OVERSHOOT_MAX - CONTAINER_OVERSHOOT_DECAY * span
106
+ )
107
+ );
108
+ }
77
109
  // Above this longitudinal span a US POI-only extent is "national" — use the
78
110
  // albers-usa composite (CONUS conic + AK/HI insets) instead of regional Mercator.
79
111
  // CONUS spans ≈58° lon; 48° is "most of the country". Tunable.
@@ -907,7 +939,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
907
939
  const containerUnion = unionExtent(containerBoxes, points);
908
940
  if (containerUnion)
909
941
  extent = pad(
910
- clampContainerToCluster(containerUnion, points),
942
+ clampContainerToCluster(containerUnion, points, usOriented),
911
943
  PAD_FRACTION
912
944
  );
913
945
  }
@@ -1079,25 +1111,42 @@ function mostCommonCountry(
1079
1111
  /** Asymmetric container clamp (R-poi-region overshoot guard). Container framing
1080
1112
  * reveals the region(s) holding the POIs, but one POI at the edge of a tall/wide
1081
1113
  * country drags the frame to that country's far edge. Cap how far the frame
1082
- * extends BEYOND the POI cluster on each side at CONTAINER_OVERSHOOT_DEG. Latitude
1083
- * always clamps; longitude clamps only when neither extent crosses the
1084
- * antimeridian seam (a wrapped extent carries east > 180), where naive min/max
1085
- * would be wrong. Never tightens past the cluster itself, so the dots stay
1086
- * framed, and never widens it the container edge is still the outer bound. */
1114
+ * extends BEYOND the POI cluster on each side at CONTAINER_OVERSHOOT_DEG, while
1115
+ * letting a genuinely tighter container edge still bound the frame (so a small
1116
+ * country shows whole, but a cluster inside a giant one stays on the cluster).
1117
+ *
1118
+ * Each longitude side clamps independently. A container edge is a usable outer
1119
+ * bound only when it sits within the normal [-180, 180] range AND on the correct
1120
+ * side of the cluster; an antimeridian-crossing container (Russia, Fiji, NZ, the
1121
+ * US via the Aleutians) reports a degenerate east (> 180, or numerically < its
1122
+ * own west), so that side falls back to cluster ± overshoot instead of skipping
1123
+ * the clamp entirely (which previously blew a western-Russia cluster out to a
1124
+ * world frame). Latitude never wraps, so it always clamps. Assumes the POI
1125
+ * cluster itself does not straddle the seam — true for any regional cluster. */
1087
1126
  function clampContainerToCluster(
1088
1127
  container: GeoExtent,
1089
- points: Array<[number, number]>
1128
+ points: Array<[number, number]>,
1129
+ usOriented: boolean
1090
1130
  ): GeoExtent {
1091
1131
  const poi = unionExtent([], points);
1092
1132
  if (!poi) return container;
1093
1133
  let [[west, south], [east, north]] = container;
1094
1134
  const [[pWest, pSouth], [pEast, pNorth]] = poi;
1095
- south = Math.max(south, pSouth - CONTAINER_OVERSHOOT_DEG);
1096
- north = Math.min(north, pNorth + CONTAINER_OVERSHOOT_DEG);
1097
- if (east <= 180 && pEast <= 180) {
1098
- west = Math.max(west, pWest - CONTAINER_OVERSHOOT_DEG);
1099
- east = Math.min(east, pEast + CONTAINER_OVERSHOOT_DEG);
1100
- }
1135
+ // Overshoot shrinks with cluster size for non-US maps (see containerOvershoot):
1136
+ // a big cluster already orients itself, so it gets less surrounding slack than a
1137
+ // tiny one. US-oriented maps keep the flat MAX (the albers-usa gate needs it).
1138
+ const over = containerOvershoot(
1139
+ Math.max(pEast - pWest, pNorth - pSouth),
1140
+ usOriented
1141
+ );
1142
+ south = Math.max(south, pSouth - over);
1143
+ north = Math.min(north, pNorth + over);
1144
+ const wOver = pWest - over;
1145
+ const eOver = pEast + over;
1146
+ // West edge usable iff in range and not east of the cluster's west.
1147
+ west = west >= -180 && west <= pWest ? Math.max(west, wOver) : wOver;
1148
+ // East edge usable iff in range and not west of the cluster's east.
1149
+ east = east <= 180 && east >= pEast ? Math.min(east, eOver) : eOver;
1101
1150
  return [
1102
1151
  [west, south],
1103
1152
  [east, north],
@@ -3,6 +3,7 @@ import {
3
3
  descriptionBareRemovedMessage,
4
4
  formatDgmoError,
5
5
  makeDgmoError,
6
+ makeFail,
6
7
  METADATA_DIAGNOSTIC_CODES,
7
8
  pipeOperatorRemovedMessage,
8
9
  suggest,
@@ -16,6 +17,7 @@ import {
16
17
  validateTagGroupNames,
17
18
  stripDefaultModifier,
18
19
  finalizeAutoTagColors,
20
+ cascadeTagMetadata,
19
21
  AUTO_TAG_COLOR_SENTINEL,
20
22
  } from '../utils/tag-groups';
21
23
  import {
@@ -60,12 +62,7 @@ export function parseMindmap(
60
62
  error: null,
61
63
  };
62
64
 
63
- const fail = (line: number, message: string): ParsedMindmap => {
64
- const diag = makeDgmoError(line, message);
65
- result.diagnostics.push(diag);
66
- result.error = formatDgmoError(diag);
67
- return result;
68
- };
65
+ const fail = makeFail(result);
69
66
 
70
67
  const pushError = (line: number, message: string): void => {
71
68
  const diag = makeDgmoError(line, message);
@@ -210,7 +207,12 @@ export function parseMindmap(
210
207
  const indent = measureIndent(line);
211
208
  if (indent > 0) {
212
209
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
213
- const { label, color } = extractColor(cleanEntry, palette);
210
+ const { label, color } = extractColor(
211
+ cleanEntry,
212
+ palette,
213
+ result.diagnostics,
214
+ lineNumber
215
+ );
214
216
  // Bare value (no explicit color) → keep it; finalized below.
215
217
  if (isDefault) {
216
218
  currentTagGroup.defaultValue = label;
@@ -307,6 +309,12 @@ export function parseMindmap(
307
309
  collectAll(result.roots);
308
310
  validateTagValues(allNodes, result.tagGroups, pushWarning, suggest);
309
311
  validateTagGroupNames(result.tagGroups, pushWarning);
312
+
313
+ // Cascade explicit tag values down the tree so sub-nodes inherit a tagged
314
+ // ancestor's value (overridable per-node). Runs after validation (so we
315
+ // don't double-warn on inherited values) and before the layout's
316
+ // global-default injection (so an inherited value wins over the default).
317
+ cascadeTagMetadata(result.roots, result.tagGroups);
310
318
  }
311
319
 
312
320
  // Check for empty mindmap
@@ -149,9 +149,16 @@ export function renderMindmap(
149
149
  const availHeight =
150
150
  containerHeight - DIAGRAM_PADDING * 2 - legendReserve - titleReserve;
151
151
 
152
- const ctx = isExport
152
+ // Fit to BOTH axes so a tall tree shrinks to fit a short canvas instead of
153
+ // overflowing vertically (export sizes its own canvas, so it stays identity).
154
+ let ctx = isExport
153
155
  ? ScaleContext.identity()
154
- : ScaleContext.from(availWidth, layout.width);
156
+ : ScaleContext.fromBox(
157
+ availWidth,
158
+ layout.width,
159
+ availHeight,
160
+ layout.height
161
+ );
155
162
 
156
163
  let renderLayout = layout;
157
164
  if (ctx.factor < 1) {
@@ -161,25 +168,56 @@ export function renderMindmap(
161
168
  hiddenCounts.set(n.id, n.hiddenCount);
162
169
  }
163
170
  }
164
- renderLayout = layoutMindmap(parsed, palette, {
165
- interactive: !isExport,
166
- ...(hiddenCounts.size > 0 && { hiddenCounts }),
167
- activeTagGroup: activeTagGroup ?? null,
168
- ...(hideDescriptions !== undefined && { hideDescriptions }),
169
- ctx,
170
- });
171
+ const relayout = (c: ScaleContext): MindmapLayoutResult =>
172
+ layoutMindmap(parsed, palette, {
173
+ interactive: !isExport,
174
+ ...(hiddenCounts.size > 0 && { hiddenCounts }),
175
+ activeTagGroup: activeTagGroup ?? null,
176
+ ...(hideDescriptions !== undefined && { hideDescriptions }),
177
+ ctx: c,
178
+ });
179
+ renderLayout = relayout(ctx);
180
+ // Scaling is non-linear, so one pass can still overflow. Re-measure the
181
+ // laid-out result and tighten until it fits both axes or hits the floor.
182
+ for (let i = 0; i < 3 && !ctx.isBelowFloor; i++) {
183
+ const refit = Math.min(
184
+ availWidth / renderLayout.width,
185
+ availHeight / renderLayout.height
186
+ );
187
+ if (refit >= 0.999) break; // already fits
188
+ ctx = ScaleContext.fromFactor(ctx.factor * refit);
189
+ renderLayout = relayout(ctx);
190
+ }
171
191
  }
172
192
 
173
- const offsetX = Math.max(0, (availWidth - renderLayout.width) / 2);
193
+ // Re-layout keeps text readable but is floor-limited, so a dense tree can
194
+ // still exceed the canvas. Apply a final uniform scale as a hard guarantee
195
+ // that the diagram always fits within the canvas (no overflow), regardless
196
+ // of how small the canvas is. Export sizes its own canvas, so this is a no-op
197
+ // there (fitScale === 1).
198
+ const fitScale = isExport
199
+ ? 1
200
+ : Math.min(
201
+ 1,
202
+ renderLayout.width > 0 ? availWidth / renderLayout.width : 1,
203
+ renderLayout.height > 0 ? availHeight / renderLayout.height : 1
204
+ );
205
+ const scaledWidth = renderLayout.width * fitScale;
206
+ const scaledHeight = renderLayout.height * fitScale;
207
+
208
+ const offsetX = Math.max(0, (availWidth - scaledWidth) / 2);
174
209
  const offsetY =
175
210
  DIAGRAM_PADDING +
176
211
  legendReserve +
177
212
  titleReserve +
178
- Math.max(0, (availHeight - renderLayout.height) / 2);
213
+ Math.max(0, (availHeight - scaledHeight) / 2);
179
214
 
180
215
  const mainG = svg
181
216
  .append('g')
182
- .attr('transform', `translate(${offsetX}, ${offsetY})`);
217
+ .attr(
218
+ 'transform',
219
+ `translate(${offsetX}, ${offsetY})${fitScale < 1 ? ` scale(${fitScale})` : ''}`
220
+ );
183
221
 
184
222
  if (ctx.isBelowFloor) {
185
223
  svg.attr('width', '100%');
package/src/org/parser.ts CHANGED
@@ -3,6 +3,7 @@ import type { DgmoError } from '../diagnostics';
3
3
  import {
4
4
  formatDgmoError,
5
5
  makeDgmoError,
6
+ makeFail,
6
7
  METADATA_DIAGNOSTIC_CODES,
7
8
  pipeOperatorRemovedMessage,
8
9
  suggest,
@@ -107,12 +108,7 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
107
108
  error: null,
108
109
  };
109
110
 
110
- const fail = (line: number, message: string): ParsedOrg => {
111
- const diag = makeDgmoError(line, message);
112
- result.diagnostics.push(diag);
113
- result.error = formatDgmoError(diag);
114
- return result;
115
- };
111
+ const fail = makeFail(result);
116
112
 
117
113
  /** Push a recoverable error and continue parsing. */
118
114
  const pushError = (line: number, message: string): void => {
@@ -261,7 +257,12 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
261
257
  const indent = measureIndent(line);
262
258
  if (indent > 0) {
263
259
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
264
- const { label, color } = extractColor(cleanEntry, palette);
260
+ const { label, color } = extractColor(
261
+ cleanEntry,
262
+ palette,
263
+ result.diagnostics,
264
+ lineNumber
265
+ );
265
266
  // Bare value (no explicit color) → keep it; the post-parse
266
267
  // finalize pass assigns a deterministic palette color.
267
268
  if (isDefault) {
@@ -10,7 +10,12 @@ import {
10
10
  } from '../utils/export-container';
11
11
  import { ScaleContext } from '../utils/scaling';
12
12
  import type { PaletteColors } from '../palettes';
13
- import { contrastText, mix, shapeFill } from '../palettes/color-utils';
13
+ import {
14
+ contrastText,
15
+ mix,
16
+ shapeFill,
17
+ themeBaseBg,
18
+ } from '../palettes/color-utils';
14
19
  import { resolveTagColor } from '../utils/tag-groups';
15
20
  import type { ParsedOrg } from './parser';
16
21
  import type { OrgLayoutResult } from './layout';
@@ -93,7 +98,7 @@ function containerFill(
93
98
  nodeColor?: string
94
99
  ): string {
95
100
  if (nodeColor) {
96
- return mix(nodeColor, isDark ? palette.surface : palette.bg, 10);
101
+ return mix(nodeColor, themeBaseBg(palette, isDark), 10);
97
102
  }
98
103
  return mix(palette.surface, palette.bg, 40);
99
104
  }
@@ -397,6 +402,13 @@ export function renderOrg(
397
402
  .attr('height', iconSize + 6)
398
403
  .attr('fill', 'transparent');
399
404
 
405
+ // Use the container's contrast text color so it stays legible on darker
406
+ // fills, mirroring the node icon.
407
+ const iconColor = contrastText(
408
+ fill,
409
+ palette.textOnFillLight,
410
+ palette.textOnFillDark
411
+ );
400
412
  const cx = iconSize / 2;
401
413
  const cy = iconSize / 2;
402
414
  focusG
@@ -405,14 +417,14 @@ export function renderOrg(
405
417
  .attr('cy', cy)
406
418
  .attr('r', iconSize / 2 - 1)
407
419
  .attr('fill', 'none')
408
- .attr('stroke', palette.textMuted)
420
+ .attr('stroke', iconColor)
409
421
  .attr('stroke-width', 1.5);
410
422
  focusG
411
423
  .append('circle')
412
424
  .attr('cx', cx)
413
425
  .attr('cy', cy)
414
426
  .attr('r', 2)
415
- .attr('fill', palette.textMuted);
427
+ .attr('fill', iconColor);
416
428
  }
417
429
  }
418
430
 
@@ -558,7 +570,10 @@ export function renderOrg(
558
570
  .attr('height', iconSize + 6)
559
571
  .attr('fill', 'transparent');
560
572
 
561
- // Scope/target icon: outer circle + inner dot
573
+ // Scope/target icon: outer circle + inner dot. Use the card's contrast
574
+ // text color so it stays legible on solid-fill dark cards, not just the
575
+ // light default surface.
576
+ const iconColor = labelColor;
562
577
  const cx = iconSize / 2;
563
578
  const cy = iconSize / 2;
564
579
  focusG
@@ -567,14 +582,14 @@ export function renderOrg(
567
582
  .attr('cy', cy)
568
583
  .attr('r', iconSize / 2 - 1)
569
584
  .attr('fill', 'none')
570
- .attr('stroke', palette.textMuted)
585
+ .attr('stroke', iconColor)
571
586
  .attr('stroke-width', 1.5);
572
587
  focusG
573
588
  .append('circle')
574
589
  .attr('cx', cx)
575
590
  .attr('cy', cy)
576
591
  .attr('r', 2)
577
- .attr('fill', palette.textMuted);
592
+ .attr('fill', iconColor);
578
593
  }
579
594
  }
580
595
 
@@ -305,6 +305,16 @@ export function contrastText(
305
305
  * `opts.solid` (per `option solid-fill`): bypass the 25% tint and return
306
306
  * the raw intent. Opt-in only; default behavior unchanged.
307
307
  */
308
+ /**
309
+ * The theme-aware base background a diagram's tinted shapes blend toward:
310
+ * `surface` in dark, page `bg` in light. Concentrates the
311
+ * `isDark ? palette.surface : palette.bg` pick repeated across ~20 renderers
312
+ * (Story 111.3).
313
+ */
314
+ export function themeBaseBg(palette: PaletteColors, isDark: boolean): string {
315
+ return isDark ? palette.surface : palette.bg;
316
+ }
317
+
308
318
  export function shapeFill(
309
319
  palette: PaletteColors,
310
320
  intent: string,
@@ -312,7 +322,7 @@ export function shapeFill(
312
322
  opts?: { solid?: boolean }
313
323
  ): string {
314
324
  if (opts?.solid) return intent;
315
- return mix(intent, isDark ? palette.surface : palette.bg, 25);
325
+ return mix(intent, themeBaseBg(palette, isDark), 25);
316
326
  }
317
327
 
318
328
  // ============================================================
@@ -408,7 +418,7 @@ export function politicalTints(
408
418
  isDark: boolean
409
419
  ): string[] {
410
420
  if (count <= 0) return [];
411
- const base = isDark ? palette.surface : palette.bg;
421
+ const base = themeBaseBg(palette, isDark);
412
422
  const c = palette.colors;
413
423
  // Land-first: greens/earth tones lead; water-like blue & cyan trail.
414
424
  const swatches = [
@@ -21,6 +21,7 @@ export {
21
21
  getSegmentColors,
22
22
  contrastText,
23
23
  shapeFill,
24
+ themeBaseBg,
24
25
  } from './color-utils';
25
26
 
26
27
  // Re-export palette definitions (alphabetical)
@@ -33,7 +33,7 @@ import * as d3Selection from 'd3-selection';
33
33
  import * as d3Shape from 'd3-shape';
34
34
  import { FONT_FAMILY } from '../fonts';
35
35
  import type { PaletteColors } from '../palettes';
36
- import { contrastText, mix, shapeFill } from '../palettes/color-utils';
36
+ import { contrastText, mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
37
37
  import { ScaleContext } from '../utils/scaling';
38
38
  import {
39
39
  measureText,
@@ -108,7 +108,7 @@ function analysisBlockChrome(
108
108
  palette: PaletteColors,
109
109
  isDark: boolean
110
110
  ): { fill: string; stroke: string } {
111
- const surfaceBg = isDark ? palette.surface : palette.bg;
111
+ const surfaceBg = themeBaseBg(palette, isDark);
112
112
  return {
113
113
  fill: mix(palette.surface, palette.bg, 40),
114
114
  stroke: mix(palette.textMuted, surfaceBg, 35),
@@ -4,8 +4,8 @@
4
4
 
5
5
  import {
6
6
  bareDescriptionRemovedMessage,
7
- formatDgmoError,
8
7
  makeDgmoError,
8
+ makeFail,
9
9
  METADATA_DIAGNOSTIC_CODES,
10
10
  pipeOperatorRemovedMessage,
11
11
  } from '../diagnostics';
@@ -61,12 +61,7 @@ export function parsePyramid(content: string): ParsedPyramid {
61
61
  let headerParsed = false;
62
62
  let currentLayer: Writable<PyramidLayer> | null = null;
63
63
 
64
- const fail = (line: number, message: string): ParsedPyramid => {
65
- const diag = makeDgmoError(line, message);
66
- result.diagnostics.push(diag);
67
- result.error = formatDgmoError(diag);
68
- return result;
69
- };
64
+ const fail = makeFail(result);
70
65
 
71
66
  const warn = (
72
67
  line: number,
@@ -11,7 +11,7 @@ import { ScaleContext } from '../utils/scaling';
11
11
  import { initD3Chart, renderChartTitle } from '../utils/d3-helpers';
12
12
  import type { ParsedQuadrant, QuadrantLabel } from '../visualizations/types';
13
13
  import type { PaletteColors } from '../palettes';
14
- import { mix } from '../palettes/color-utils';
14
+ import { mix, themeBaseBg } from '../palettes/color-utils';
15
15
 
16
16
  // Quadrant Chart Renderer
17
17
  // ============================================================
@@ -99,7 +99,7 @@ export function renderQuadrant(
99
99
  .append('g')
100
100
  .attr('transform', `translate(${margin.left}, ${margin.top})`);
101
101
 
102
- const bg = isDark ? palette.surface : palette.bg;
102
+ const bg = themeBaseBg(palette, isDark);
103
103
 
104
104
  // Full palette color for a quadrant (used for border and label tinting)
105
105
  const getQuadrantColor = (
@@ -19,8 +19,8 @@
19
19
  // See `docs/dgmo-language-spec.md` § "RACI Matrix".
20
20
 
21
21
  import {
22
- formatDgmoError,
23
22
  makeDgmoError,
23
+ makeFail,
24
24
  METADATA_DIAGNOSTIC_CODES,
25
25
  pipeOperatorRemovedMessage,
26
26
  suggest,
@@ -181,12 +181,7 @@ export function parseRaci(
181
181
  error: null,
182
182
  };
183
183
 
184
- const fail = (line: number, message: string): ParsedRaci => {
185
- const diag = makeDgmoError(line, message);
186
- result.diagnostics.push(diag);
187
- result.error = formatDgmoError(diag);
188
- return result;
189
- };
184
+ const fail = makeFail(result);
190
185
 
191
186
  const warn = (line: number, message: string, code?: string): void => {
192
187
  result.diagnostics.push(makeDgmoError(line, message, 'warning', code));
@@ -30,7 +30,7 @@ import {
30
30
  TITLE_FONT_WEIGHT,
31
31
  TITLE_Y,
32
32
  } from '../utils/title-constants';
33
- import { contrastText, mix } from '../palettes/color-utils';
33
+ import { contrastText, mix, themeBaseBg } from '../palettes/color-utils';
34
34
  import type { PaletteColors } from '../palettes';
35
35
  import type { D3ExportDimensions } from '../utils/d3-types';
36
36
  import type {
@@ -394,7 +394,7 @@ export function renderRaci(
394
394
  if (tasksAll.length === 0 && parsed.phases.length === 0) return;
395
395
 
396
396
  const solid = parsed.options['solid-fill'] === 'on';
397
- const surfaceBg = isDark ? palette.surface : palette.bg;
397
+ const surfaceBg = themeBaseBg(palette, isDark);
398
398
 
399
399
  // --- ScaleContext: differential scaling ---
400
400
  const roleCount = Math.max(1, parsed.roles.length);
@@ -679,10 +679,10 @@ export function renderRaci(
679
679
  // each column has a subtle visual identity instead of every column
680
680
  // reading as the same neutral gray.
681
681
  const roleColor = parsed.roleColors[i] ?? autoAccent(i, palette);
682
- const bodyFill = mix(roleColor, isDark ? palette.surface : palette.bg, 16);
682
+ const bodyFill = mix(roleColor, themeBaseBg(palette, isDark), 16);
683
683
  const headerFill = mix(
684
684
  roleColor,
685
- isDark ? palette.surface : palette.bg,
685
+ themeBaseBg(palette, isDark),
686
686
  30
687
687
  );
688
688
  const colG = columnsG
@@ -4,8 +4,8 @@
4
4
 
5
5
  import {
6
6
  bareDescriptionRemovedMessage,
7
- formatDgmoError,
8
7
  makeDgmoError,
8
+ makeFail,
9
9
  METADATA_DIAGNOSTIC_CODES,
10
10
  pipeOperatorRemovedMessage,
11
11
  suggest,
@@ -48,12 +48,7 @@ export function parseRing(content: string): ParsedRing {
48
48
  let headerParsed = false;
49
49
  let currentLayer: Writable<RingLayer> | null = null;
50
50
 
51
- const fail = (line: number, message: string): ParsedRing => {
52
- const diag = makeDgmoError(line, message);
53
- result.diagnostics.push(diag);
54
- result.error = formatDgmoError(diag);
55
- return result;
56
- };
51
+ const fail = makeFail(result);
57
52
 
58
53
  const warn = (
59
54
  line: number,
@@ -10,6 +10,7 @@ import {
10
10
  akaRemovedMessage,
11
11
  formatDgmoError,
12
12
  makeDgmoError,
13
+ makeFail,
13
14
  METADATA_DIAGNOSTIC_CODES,
14
15
  NAME_DIAGNOSTIC_CODES,
15
16
  nameMergedMessage,
@@ -528,12 +529,7 @@ export function parseSequenceDgmo(
528
529
  return nameAliasMap.get(trimmed) ?? trimmed;
529
530
  };
530
531
 
531
- const fail = (line: number, message: string): ParsedSequenceDgmo => {
532
- const diag = makeDgmoError(line, message);
533
- result.diagnostics.push(diag);
534
- result.error = formatDgmoError(diag);
535
- return result;
536
- };
532
+ const fail = makeFail(result);
537
533
 
538
534
  /** Push a recoverable error and continue parsing. */
539
535
  const pushError = (line: number, message: string): void => {
@@ -554,6 +550,13 @@ export function parseSequenceDgmo(
554
550
  const lines = content.split('\n');
555
551
  let hasExplicitChart = false;
556
552
  let contentStarted = false;
553
+ // Whether the message body has begun (first message, section, block, or note).
554
+ // Unlike `contentStarted` — which any declaration trips to close the
555
+ // options/tag-group "headers first" window — `bodyStarted` stays false through
556
+ // the entire declaration preamble so bare and typed participant declarations
557
+ // can be freely interleaved. It gates bare-name declarations: once real body
558
+ // content appears, a bare word is treated as a stray line, not a participant.
559
+ let bodyStarted = false;
557
560
  let firstLineIndex = -1; // line index of the `sequence [Title]` first line (to skip in main loop)
558
561
 
559
562
  // Handle first non-empty, non-comment line for `sequence Title` syntax
@@ -997,6 +1000,7 @@ export function parseSequenceDgmo(
997
1000
  );
998
1001
  }
999
1002
  contentStarted = true;
1003
+ bodyStarted = true;
1000
1004
  const section: SequenceSection = {
1001
1005
  kind: 'section',
1002
1006
  // Capture group 1 guaranteed present after successful match.
@@ -1301,7 +1305,7 @@ export function parseSequenceDgmo(
1301
1305
  if (
1302
1306
  /^\S+$/.test(bareCore) &&
1303
1307
  !ARROW_PATTERN.test(bareCore) &&
1304
- (inGroup || !contentStarted || bareMeta)
1308
+ (inGroup || !bodyStarted || bareMeta)
1305
1309
  ) {
1306
1310
  contentStarted = true;
1307
1311
  const id = bareCore;
@@ -1369,6 +1373,7 @@ export function parseSequenceDgmo(
1369
1373
  }
1370
1374
  if (labeledArrow) {
1371
1375
  contentStarted = true;
1376
+ bodyStarted = true;
1372
1377
  const { from, to, label: rawLabel, async: isAsync } = labeledArrow;
1373
1378
  const fromKey = addParticipant(resolveAlias(from), lineNumber);
1374
1379
  const toKey = addParticipant(resolveAlias(to), lineNumber);
@@ -1445,6 +1450,7 @@ export function parseSequenceDgmo(
1445
1450
  const bareCall = bareCallSync || bareCallAsync;
1446
1451
  if (bareCall) {
1447
1452
  contentStarted = true;
1453
+ bodyStarted = true;
1448
1454
  // Capture groups 1 and 2 guaranteed present after successful match.
1449
1455
  const from = bareCall[1]!;
1450
1456
  const to = bareCall[2]!;
@@ -1470,6 +1476,7 @@ export function parseSequenceDgmo(
1470
1476
  const ifMatch = trimmed.match(/^if\s+(.+)$/i);
1471
1477
  if (ifMatch) {
1472
1478
  contentStarted = true;
1479
+ bodyStarted = true;
1473
1480
  const block: Writable<SequenceBlock> = {
1474
1481
  kind: 'block',
1475
1482
  type: 'if',
@@ -1488,6 +1495,7 @@ export function parseSequenceDgmo(
1488
1495
  const loopMatch = trimmed.match(/^loop\s+(.+)$/i);
1489
1496
  if (loopMatch) {
1490
1497
  contentStarted = true;
1498
+ bodyStarted = true;
1491
1499
  const block: Writable<SequenceBlock> = {
1492
1500
  kind: 'block',
1493
1501
  type: 'loop',
@@ -1506,6 +1514,7 @@ export function parseSequenceDgmo(
1506
1514
  const parallelMatch = trimmed.match(/^parallel(?:\s+(.+))?$/i);
1507
1515
  if (parallelMatch) {
1508
1516
  contentStarted = true;
1517
+ bodyStarted = true;
1509
1518
  const block: Writable<SequenceBlock> = {
1510
1519
  kind: 'block',
1511
1520
  type: 'parallel',
@@ -1629,6 +1638,7 @@ export function parseSequenceDgmo(
1629
1638
  lineNumber,
1630
1639
  endLineNumber: lineNumber,
1631
1640
  };
1641
+ bodyStarted = true;
1632
1642
  currentContainer().push(note);
1633
1643
  continue;
1634
1644
  }
@@ -1654,6 +1664,7 @@ export function parseSequenceDgmo(
1654
1664
  lineNumber,
1655
1665
  endLineNumber: i + 1, // i has advanced past the body lines (1-based)
1656
1666
  };
1667
+ bodyStarted = true;
1657
1668
  currentContainer().push(note);
1658
1669
  continue;
1659
1670
  }