@diagrammo/dgmo 0.30.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 (85) hide show
  1. package/.cursorrules +4 -1
  2. package/.github/copilot-instructions.md +4 -1
  3. package/.windsurfrules +4 -1
  4. package/README.md +21 -3
  5. package/SKILL.md +4 -1
  6. package/dist/advanced.cjs +1853 -623
  7. package/dist/advanced.d.cts +143 -16
  8. package/dist/advanced.d.ts +143 -16
  9. package/dist/advanced.js +1846 -623
  10. package/dist/auto.cjs +1640 -581
  11. package/dist/auto.js +99 -99
  12. package/dist/auto.mjs +1640 -581
  13. package/dist/cli.cjs +148 -147
  14. package/dist/index.cjs +1643 -662
  15. package/dist/index.js +1643 -662
  16. package/docs/ai-integration.md +4 -1
  17. package/docs/language-reference.md +282 -27
  18. package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
  19. package/gallery/fixtures/c4-full.dgmo +4 -5
  20. package/gallery/fixtures/c4.dgmo +2 -3
  21. package/package.json +7 -1
  22. package/src/advanced.ts +10 -0
  23. package/src/boxes-and-lines/focus.ts +257 -0
  24. package/src/boxes-and-lines/layout-search.ts +345 -65
  25. package/src/boxes-and-lines/layout.ts +11 -1
  26. package/src/boxes-and-lines/parser.ts +97 -4
  27. package/src/boxes-and-lines/renderer.ts +111 -8
  28. package/src/boxes-and-lines/types.ts +9 -0
  29. package/src/c4/parser.ts +8 -7
  30. package/src/c4/renderer.ts +7 -5
  31. package/src/chart-type-registry.ts +129 -4
  32. package/src/chart-types.ts +3 -3
  33. package/src/chart.ts +18 -1
  34. package/src/class/renderer.ts +4 -2
  35. package/src/cli-banner.ts +107 -0
  36. package/src/cli.ts +13 -0
  37. package/src/colors.ts +247 -2
  38. package/src/cycle/parser.ts +2 -7
  39. package/src/d3.ts +67 -54
  40. package/src/diagnostics.ts +17 -0
  41. package/src/dimensions.ts +9 -13
  42. package/src/echarts.ts +42 -14
  43. package/src/er/parser.ts +6 -1
  44. package/src/er/renderer.ts +4 -2
  45. package/src/gantt/parser.ts +44 -7
  46. package/src/graph/flowchart-parser.ts +77 -3
  47. package/src/graph/flowchart-renderer.ts +4 -2
  48. package/src/graph/state-renderer.ts +6 -4
  49. package/src/infra/parser.ts +80 -0
  50. package/src/infra/renderer.ts +8 -4
  51. package/src/journey-map/parser.ts +23 -8
  52. package/src/journey-map/renderer.ts +1 -1
  53. package/src/kanban/parser.ts +8 -7
  54. package/src/kanban/renderer.ts +1 -1
  55. package/src/map/context-labels.ts +134 -27
  56. package/src/map/geo.ts +10 -2
  57. package/src/map/layout.ts +259 -4
  58. package/src/map/parser.ts +2 -0
  59. package/src/map/renderer.ts +49 -25
  60. package/src/map/resolver.ts +68 -19
  61. package/src/mindmap/parser.ts +15 -7
  62. package/src/mindmap/renderer.ts +55 -15
  63. package/src/org/parser.ts +8 -7
  64. package/src/org/renderer.ts +89 -127
  65. package/src/palettes/color-utils.ts +19 -4
  66. package/src/palettes/index.ts +1 -0
  67. package/src/pert/renderer.ts +15 -10
  68. package/src/pyramid/parser.ts +2 -7
  69. package/src/quadrant/renderer.ts +2 -2
  70. package/src/raci/parser.ts +2 -7
  71. package/src/raci/renderer.ts +5 -5
  72. package/src/ring/parser.ts +2 -7
  73. package/src/sequence/parser.ts +18 -7
  74. package/src/sequence/renderer.ts +4 -4
  75. package/src/sitemap/parser.ts +8 -7
  76. package/src/sitemap/renderer.ts +37 -39
  77. package/src/tech-radar/parser.ts +2 -7
  78. package/src/timeline/renderer.ts +15 -5
  79. package/src/utils/card.ts +183 -0
  80. package/src/utils/parsing.ts +13 -1
  81. package/src/utils/scaling.ts +38 -81
  82. package/src/utils/tag-groups.ts +48 -10
  83. package/src/utils/visual-conventions.ts +61 -0
  84. package/src/visualizations/parse.ts +6 -1
  85. package/src/wireframe/parser.ts +6 -1
@@ -42,6 +42,11 @@ import type { PaletteColors } from '../palettes';
42
42
 
43
43
  const MAX_GROUP_DEPTH = 2;
44
44
 
45
+ // §1.4/§1.10 legacy-pipe detection — module-scope so they aren't re-created per
46
+ // line. `|` inside a directed (`->`) or undirected (`~>`) arrow label is valid.
47
+ const ARROW_LABEL_PIPE_DIRECTED_RE = /-\S*\|\S*->/;
48
+ const ARROW_LABEL_PIPE_UNDIRECTED_RE = /~\S*\|\S*~>/;
49
+
45
50
  /** Boxes-and-lines requires explicit first line — no heuristic detection. */
46
51
  export function looksLikeBoxesAndLines(_content: string): boolean {
47
52
  return false;
@@ -127,6 +132,8 @@ export function parseBoxesAndLines(
127
132
  const nodes: MutBLNode[] = [];
128
133
  const edges: MutBLEdge[] = [];
129
134
  const groups: MutBLGroup[] = [];
135
+ // Trailing `layout` block (Canvas Editor spike): node-id → absolute {x,y}.
136
+ const nodePositions = new Map<string, { x: number; y: number }>();
130
137
  const result: Writable<ParsedBoxesAndLines> = {
131
138
  type: 'boxes-and-lines',
132
139
  title: null,
@@ -178,6 +185,11 @@ export function parseBoxesAndLines(
178
185
 
179
186
  // Tag block state
180
187
  let contentStarted = false;
188
+ // `layout` coordinate-block state (Canvas Editor spike). Unlike tag blocks,
189
+ // this is a TRAILING appendix — it may appear after diagram content.
190
+ let inLayoutBlock = false;
191
+ const LAYOUT_ENTRY_RE =
192
+ /^(.+?):\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/;
181
193
  let currentTagGroup: Writable<TagGroup> | null = null;
182
194
  // metaAliasMap: tag-group metadata-key aliases (per A1).
183
195
  const metaAliasMap = new Map<string, string>();
@@ -240,8 +252,8 @@ export function parseBoxesAndLines(
240
252
  // regions.
241
253
  if (
242
254
  trimmed.includes('|') &&
243
- !/-\S*\|\S*->/.test(trimmed) &&
244
- !/~\S*\|\S*~>/.test(trimmed)
255
+ !ARROW_LABEL_PIPE_DIRECTED_RE.test(trimmed) &&
256
+ !ARROW_LABEL_PIPE_UNDIRECTED_RE.test(trimmed)
245
257
  ) {
246
258
  result.diagnostics.push(
247
259
  makeDgmoError(
@@ -397,7 +409,12 @@ export function parseBoxesAndLines(
397
409
  if (tagBlockMatch.inlineValues) {
398
410
  for (const rawVal of tagBlockMatch.inlineValues) {
399
411
  const { text: cleanVal, isDefault } = stripDefaultModifier(rawVal);
400
- const { label, color } = extractColor(cleanVal);
412
+ const { label, color } = extractColor(
413
+ cleanVal,
414
+ palette,
415
+ result.diagnostics,
416
+ lineNum
417
+ );
401
418
  newTagGroup.entries.push({
402
419
  value: label,
403
420
  color: color ?? AUTO_TAG_COLOR_SENTINEL,
@@ -417,7 +434,12 @@ export function parseBoxesAndLines(
417
434
  // Tag group entries (indented under tag heading)
418
435
  if (currentTagGroup && !contentStarted && indent > 0) {
419
436
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
420
- const { label, color } = extractColor(cleanEntry);
437
+ const { label, color } = extractColor(
438
+ cleanEntry,
439
+ palette,
440
+ result.diagnostics,
441
+ lineNum
442
+ );
421
443
  currentTagGroup.entries.push({
422
444
  value: label,
423
445
  color: color ?? AUTO_TAG_COLOR_SENTINEL,
@@ -436,6 +458,52 @@ export function parseBoxesAndLines(
436
458
  currentTagGroup = null;
437
459
  }
438
460
 
461
+ // `layout` coordinate block (Canvas Editor spike). A bare `layout` heading
462
+ // at indent 0 opens the block; indented `<node-id>: <x>, <y>` entries map a
463
+ // node to an absolute position. Any non-indented line closes it. Quarantined
464
+ // before group/node/edge matching so the entries don't parse as nodes.
465
+ if (!inLayoutBlock && indent === 0 && trimmed === 'layout') {
466
+ // Disambiguate from a node legitimately NAMED `layout`: only treat this as
467
+ // the coordinate appendix when the next non-blank line is an indented
468
+ // `<id>: <x>, <y>` entry. Otherwise fall through and parse `layout` as a
469
+ // normal node (no silent data loss).
470
+ let isBlock = false;
471
+ for (let j = i + 1; j < lines.length; j++) {
472
+ const peek = lines[j]!;
473
+ if (!peek.trim()) continue;
474
+ isBlock = measureIndent(peek) > 0 && LAYOUT_ENTRY_RE.test(peek.trim());
475
+ break;
476
+ }
477
+ if (isBlock) {
478
+ flushDescription();
479
+ closeGroupsToIndent(0);
480
+ inLayoutBlock = true;
481
+ continue;
482
+ }
483
+ }
484
+ if (inLayoutBlock) {
485
+ if (indent > 0) {
486
+ const lm = trimmed.match(LAYOUT_ENTRY_RE);
487
+ if (lm) {
488
+ nodePositions.set(lm[1]!.trim(), {
489
+ x: Number(lm[2]),
490
+ y: Number(lm[3]),
491
+ });
492
+ } else {
493
+ result.diagnostics.push(
494
+ makeDgmoError(
495
+ lineNum,
496
+ `Invalid layout entry "${trimmed}" — expected "<node-id>: <x>, <y>"`,
497
+ 'warning'
498
+ )
499
+ );
500
+ }
501
+ continue;
502
+ }
503
+ // indent 0 → block ends; fall through to process this line normally.
504
+ inLayoutBlock = false;
505
+ }
506
+
439
507
  // Description collection: indented non-edge lines under a node
440
508
  if (descState !== null) {
441
509
  if (indent > descState.indent) {
@@ -762,6 +830,31 @@ export function parseBoxesAndLines(
762
830
  // passed to extractColor above), so auto colors match for consistency.
763
831
  finalizeAutoTagColors(result.tagGroups as Writable<TagGroup>[]);
764
832
 
833
+ // Attach parsed `layout` positions. Validate coverage: unknown ids warn; a
834
+ // PARTIAL block (some nodes unpositioned) is honored by neither pin nor seed —
835
+ // the layout engine ignores it and auto-lays-out (Decision 3, AC12), so emit a
836
+ // single diagnostic naming the gap.
837
+ if (nodePositions.size > 0) {
838
+ const nodeLabelSet = new Set(result.nodes.map((n) => n.label));
839
+ for (const id of nodePositions.keys()) {
840
+ if (!nodeLabelSet.has(id)) {
841
+ pushWarning(0, `layout entry for unknown node "${id}" (ignored)`);
842
+ }
843
+ }
844
+ const unpositioned = result.nodes
845
+ .filter((n) => !nodePositions.has(n.label))
846
+ .map((n) => n.label);
847
+ if (unpositioned.length > 0) {
848
+ pushWarning(
849
+ 0,
850
+ `layout block is partial — ${unpositioned.length} node(s) without coordinates ` +
851
+ `(${unpositioned.slice(0, 5).join(', ')}${unpositioned.length > 5 ? '…' : ''}); ` +
852
+ `ignoring the block and auto-laying-out`
853
+ );
854
+ }
855
+ result.nodePositions = nodePositions;
856
+ }
857
+
765
858
  // Post-parse: inject default tag metadata and validate tag values
766
859
  if (result.tagGroups.length > 0) {
767
860
  injectDefaultTagMetadata(result.nodes, result.tagGroups);
@@ -30,6 +30,7 @@ import {
30
30
  relativeLuminance,
31
31
  shapeFill,
32
32
  valueRampColor,
33
+ themeBaseBg,
33
34
  } from '../palettes/color-utils';
34
35
  import { resolveColor } from '../colors';
35
36
  import { resolveTagColor } from '../utils/tag-groups';
@@ -56,9 +57,13 @@ const DIAGRAM_PADDING = 20;
56
57
  const NODE_FONT_SIZE = 11;
57
58
  const MIN_NODE_FONT_SIZE = 9;
58
59
  const EDGE_LABEL_FONT_SIZE = 11;
59
- const EDGE_STROKE_WIDTH = 1.5;
60
- const NODE_STROKE_WIDTH = 1.5;
60
+ import {
61
+ EDGE_STROKE_WIDTH,
62
+ NODE_STROKE_WIDTH,
63
+ } from '../utils/visual-conventions'; // shared (Story 111.1)
61
64
  const NODE_RX = 8;
65
+ // Intentional deviation (conventions §3): boxes-and-lines uses a 4px collapse
66
+ // bar (and 4px separator gap in layout.ts) — denser than the 6px default.
62
67
  const COLLAPSE_BAR_HEIGHT = 4;
63
68
  const ARROWHEAD_W = 5;
64
69
  const ARROWHEAD_H = 4;
@@ -89,6 +94,15 @@ const lineGeneratorTB = d3Shape
89
94
  .y((d) => d.y)
90
95
  .curve(d3Shape.curveBasis);
91
96
 
97
+ // Straight (linear) generator for pinned-layout connectors (Canvas Editor
98
+ // spike). curveBasis collapses a 2-point polyline, so border-clipped straight
99
+ // edges must draw linearly.
100
+ const lineGeneratorStraight = d3Shape
101
+ .line<{ x: number; y: number }>()
102
+ .x((d) => d.x)
103
+ .y((d) => d.y)
104
+ .curve(d3Shape.curveLinear);
105
+
92
106
  // ── Text fitting ───────────────────────────────────────────
93
107
 
94
108
  function splitCamelCase(word: string): string[] {
@@ -425,6 +439,11 @@ interface BLRenderOptions {
425
439
  /** When 'app', the description toggle is hosted by the app overlay strip
426
440
  * (inline gear suppressed, controls row + anchor reserved). */
427
441
  controlsHost?: 'app' | 'inline';
442
+ /** Explicit value-ramp domain override. When provided, the choropleth ramp
443
+ * uses these endpoints instead of computing min/max from `parsed.nodes`.
444
+ * Focus mode passes the GLOBAL (pre-filter) domain so neighbor colours stay
445
+ * stable when only a subset is rendered (Decision 20 / FM1). */
446
+ rampDomain?: { min: number; max: number };
428
447
  }
429
448
 
430
449
  export function renderBoxesAndLines(
@@ -446,6 +465,7 @@ export function renderBoxesAndLines(
446
465
  onToggleControlsExpand,
447
466
  exportMode = false,
448
467
  controlsHost,
468
+ rampDomain,
449
469
  } = options ?? {};
450
470
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
451
471
 
@@ -479,8 +499,10 @@ export function renderBoxesAndLines(
479
499
  // Anchor the low end at the lowest value (not 0) to maximise within-diagram
480
500
  // dynamic range; mirrors the map's region-metric ramp. Equal-value data
481
501
  // (rampMin === rampMax) falls back to t = 1 in fillForValue below.
482
- const rampMin = hasRamp ? Math.min(...nodeValues) : 0;
483
- const rampMax = Math.max(...nodeValues);
502
+ // A caller-supplied domain (focus mode) wins so colours don't shift when a
503
+ // subset is rendered; otherwise derive from the nodes on screen.
504
+ const rampMin = rampDomain?.min ?? (hasRamp ? Math.min(...nodeValues) : 0);
505
+ const rampMax = rampDomain?.max ?? Math.max(...nodeValues);
484
506
  // Default hue = palette.primary (NOT red like the map — boxes have no water to
485
507
  // stand out against, and red reads as alarm on a neutral metric). A trailing
486
508
  // color on `box-metric` overrides.
@@ -614,8 +636,43 @@ export function renderBoxesAndLines(
614
636
  const scaleY = height / (contentH + sDiagramPadding * 2);
615
637
  const scale = Math.min(scaleX, scaleY, 3);
616
638
 
617
- const offsetX = (width - contentW * scale) / 2;
618
- const offsetY = sDiagramPadding + titleOffset + legendH;
639
+ // Pinned (`layout`-block) coordinates can leave the content's real extent
640
+ // sitting off-centre inside layout.width/height e.g. nodes pinned far from
641
+ // the origin bake a wide gap on one side. Re-centre the content within its
642
+ // own box by equalizing opposite margins. Gated to pinned diagrams so the
643
+ // auto-layout path (and its snapshots) stays byte-identical: that path
644
+ // already produces symmetric margins.
645
+ let centerShiftX = 0;
646
+ let centerShiftY = 0;
647
+ if (parsed.nodePositions && parsed.nodePositions.size > 0) {
648
+ let bMinX = Infinity,
649
+ bMinY = Infinity,
650
+ bMaxX = -Infinity,
651
+ bMaxY = -Infinity;
652
+ const accB = (x: number, y: number) => {
653
+ if (x < bMinX) bMinX = x;
654
+ if (x > bMaxX) bMaxX = x;
655
+ if (y < bMinY) bMinY = y;
656
+ if (y > bMaxY) bMaxY = y;
657
+ };
658
+ for (const n of layout.nodes) {
659
+ accB(n.x - n.width / 2, n.y - n.height / 2);
660
+ accB(n.x + n.width / 2, n.y + n.height / 2);
661
+ }
662
+ for (const g of layout.groups) {
663
+ accB(g.x - g.width / 2, g.y - g.height / 2);
664
+ accB(g.x + g.width / 2, g.y + g.height / 2);
665
+ }
666
+ for (const e of layout.edges) for (const p of e.points) accB(p.x, p.y);
667
+ if (Number.isFinite(bMinX)) {
668
+ centerShiftX = (layout.width - bMaxX - bMinX) / 2;
669
+ centerShiftY = (layout.height - bMaxY - bMinY) / 2;
670
+ }
671
+ }
672
+
673
+ const offsetX = (width - contentW * scale) / 2 + centerShiftX * scale;
674
+ const offsetY =
675
+ sDiagramPadding + titleOffset + legendH + centerShiftY * scale;
619
676
 
620
677
  const svg: D3Svg = d3Selection
621
678
  .select(container)
@@ -703,7 +760,7 @@ export function renderBoxesAndLines(
703
760
 
704
761
  if (group.collapsed) {
705
762
  // Collapsed: solid rounded rect matching node style + 6px collapse bar
706
- const fillColor = isDark ? palette.surface : palette.bg;
763
+ const fillColor = themeBaseBg(palette, isDark);
707
764
  const strokeColor = palette.border;
708
765
 
709
766
  groupG
@@ -896,7 +953,11 @@ export function renderBoxesAndLines(
896
953
  edgeGroups.set(i, edgeG as unknown as D3G);
897
954
 
898
955
  const markerId = `bl-arrow-${color.replace('#', '')}`;
899
- const gen = parsed.direction === 'TB' ? lineGeneratorTB : lineGeneratorLR;
956
+ const gen = le.straight
957
+ ? lineGeneratorStraight
958
+ : parsed.direction === 'TB'
959
+ ? lineGeneratorTB
960
+ : lineGeneratorLR;
900
961
  const path = edgeG
901
962
  .append('path')
902
963
  .attr('class', 'bl-edge')
@@ -1394,6 +1455,48 @@ export function renderBoxesAndLines(
1394
1455
  });
1395
1456
  legendG.selectAll('[data-legend-group]').classed('bl-legend-group', true);
1396
1457
  }
1458
+
1459
+ // ── Focus mode: one reusable hover-reveal icon (interactive only) ──
1460
+ // A single hidden icon the app repositions over the hovered box/group and
1461
+ // stamps `data-focus-id`/`data-focus-kind` on (Decision 22 / ADR-4) — NOT one
1462
+ // per node (~4k elements on a large graph). Appended to the SVG root so the
1463
+ // app positions it in root (screen-mapped) coordinates, counter-scaled to a
1464
+ // constant size regardless of fit. Excluded from export like org's icon.
1465
+ if (!exportDims && !exportMode) {
1466
+ const iconSize = 14;
1467
+ const focusG = svg
1468
+ .append('g')
1469
+ .attr('class', 'bl-focus-icon')
1470
+ .attr('data-export-ignore', 'true')
1471
+ .style('display', 'none')
1472
+ .style('pointer-events', 'auto')
1473
+ .style('cursor', 'pointer');
1474
+ // Hit area
1475
+ focusG
1476
+ .append('rect')
1477
+ .attr('x', -3)
1478
+ .attr('y', -3)
1479
+ .attr('width', iconSize + 6)
1480
+ .attr('height', iconSize + 6)
1481
+ .attr('fill', 'transparent');
1482
+ // Scope/target icon: outer circle + inner dot (mirrors org-focus-icon)
1483
+ const cx = iconSize / 2;
1484
+ const cy = iconSize / 2;
1485
+ focusG
1486
+ .append('circle')
1487
+ .attr('cx', cx)
1488
+ .attr('cy', cy)
1489
+ .attr('r', iconSize / 2 - 1)
1490
+ .attr('fill', palette.bg)
1491
+ .attr('stroke', palette.textMuted)
1492
+ .attr('stroke-width', 1.5);
1493
+ focusG
1494
+ .append('circle')
1495
+ .attr('cx', cx)
1496
+ .attr('cy', cy)
1497
+ .attr('r', 2)
1498
+ .attr('fill', palette.textMuted);
1499
+ }
1397
1500
  }
1398
1501
 
1399
1502
  // ── Export helper ──────────────────────────────────────────
@@ -42,6 +42,15 @@ export interface ParsedBoxesAndLines {
42
42
  readonly notes?: readonly DiagramNote[];
43
43
  readonly initialHiddenTagValues: ReadonlyMap<string, ReadonlySet<string>>;
44
44
  readonly direction: 'LR' | 'TB';
45
+ /** Optional per-node absolute positions, parsed from a trailing `layout`
46
+ * block (`<node-id>: <x>, <y>`). Diagram-space coordinates. When present and
47
+ * covering EVERY node, the layout engine bypasses auto-placement and pins
48
+ * nodes here (see Decision 3 — two clean modes). A partial block is ignored
49
+ * with a diagnostic (AC12). Experimental — Canvas Editor spike. */
50
+ readonly nodePositions?: ReadonlyMap<
51
+ string,
52
+ { readonly x: number; readonly y: number }
53
+ >;
45
54
  /** `box-metric <label> [low] [high]` — names the value-ramp dimension and
46
55
  * optionally sets its endpoint colours. One color = high hue over a neutral
47
56
  * low; two = explicit `low high`. Mirror of map's `region-metric`. */
package/src/c4/parser.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  descriptionBareRemovedMessage,
9
9
  formatDgmoError,
10
10
  makeDgmoError,
11
+ makeFail,
11
12
  METADATA_DIAGNOSTIC_CODES,
12
13
  pipeOperatorRemovedMessage,
13
14
  suggest,
@@ -296,12 +297,7 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
296
297
  result.error = formatDgmoError(diag);
297
298
  };
298
299
 
299
- const fail = (line: number, message: string): ParsedC4 => {
300
- const diag = makeDgmoError(line, message);
301
- result.diagnostics.push(diag);
302
- result.error = formatDgmoError(diag);
303
- return result;
304
- };
300
+ const fail = makeFail(result);
305
301
 
306
302
  if (!content?.trim()) {
307
303
  return fail(0, 'No content provided');
@@ -455,7 +451,12 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
455
451
  const indent = measureIndent(line);
456
452
  if (indent > 0) {
457
453
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
458
- const { label, color } = extractColor(cleanEntry, palette);
454
+ const { label, color } = extractColor(
455
+ cleanEntry,
456
+ palette,
457
+ result.diagnostics,
458
+ lineNumber
459
+ );
459
460
  // Bare value (no explicit color) → keep it; the post-parse
460
461
  // finalize pass assigns a deterministic palette color.
461
462
  if (isDefault) {
@@ -37,16 +37,18 @@ const DESC_FONT_SIZE = 11;
37
37
  const DESC_LINE_HEIGHT = 16;
38
38
  const EDGE_LABEL_FONT_SIZE = 11;
39
39
  const TECH_FONT_SIZE = 10;
40
- const EDGE_STROKE_WIDTH = 1.5;
41
- const NODE_STROKE_WIDTH = 1.5;
42
- const CARD_RADIUS = 6;
40
+ import {
41
+ EDGE_STROKE_WIDTH,
42
+ NODE_STROKE_WIDTH,
43
+ CARD_RADIUS,
44
+ META_FONT_SIZE,
45
+ META_LINE_HEIGHT,
46
+ } from '../utils/visual-conventions'; // shared (Story 111.1)
43
47
  const CARD_H_PAD = 20;
44
48
  const CARD_V_PAD = 14;
45
49
  const TYPE_LABEL_HEIGHT = 18;
46
50
  const DIVIDER_GAP = 6;
47
51
  const NAME_HEIGHT = 20;
48
- const META_FONT_SIZE = 11;
49
- const META_LINE_HEIGHT = 16;
50
52
  const BOUNDARY_LABEL_FONT_SIZE = 12;
51
53
  const BOUNDARY_STROKE_WIDTH = 1.5;
52
54
  const BOUNDARY_RADIUS = 8;
@@ -87,6 +87,10 @@ export interface ChartTypeDescriptor {
87
87
  readonly category: RenderCategory;
88
88
  readonly parse: ParseFn;
89
89
  readonly measure?: (content: string) => ContentCounts;
90
+ readonly minDims?: (counts: ContentCounts) => {
91
+ width: number;
92
+ height: number;
93
+ };
90
94
  }
91
95
 
92
96
  // ============================================================
@@ -210,6 +214,92 @@ function measureInfra(content: string): ContentCounts {
210
214
  return { nodes: parsed.nodes.length };
211
215
  }
212
216
 
217
+ // ============================================================
218
+ // minDims() implementations — relocated verbatim from computeMinDimensions() in
219
+ // utils/scaling.ts so the registry owns per-type minimum-dimension formulas
220
+ // alongside measure(). Each maps ContentCounts → {width,height}. Types without a
221
+ // minDims fall back to {300,200} (the old switch `default`) via the
222
+ // REGISTRY_BY_ID lookup in dimensions.ts.
223
+ // ============================================================
224
+
225
+ function minDimsSequence(c: ContentCounts): { width: number; height: number } {
226
+ return {
227
+ width: Math.max((c.participants ?? 2) * 80, 320),
228
+ height: Math.max((c.messages ?? 1) * 20 + 120, 200),
229
+ };
230
+ }
231
+ function minDimsRaci(c: ContentCounts): { width: number; height: number } {
232
+ return {
233
+ width: Math.max((c.roles ?? 2) * 50 + 180, 300),
234
+ height: Math.max((c.tasks ?? 1) * 28 + 80, 200),
235
+ };
236
+ }
237
+ function minDimsMindmap(c: ContentCounts): { width: number; height: number } {
238
+ return {
239
+ width: Math.max((c.nodes ?? 3) * 30, 300),
240
+ height: Math.max((c.depth ?? 2) * 60, 200),
241
+ };
242
+ }
243
+ function minDimsTechRadar(): { width: number; height: number } {
244
+ return { width: 360, height: 400 };
245
+ }
246
+ function minDimsHeatmap(c: ContentCounts): { width: number; height: number } {
247
+ return {
248
+ width: Math.max((c.columns ?? 3) * 40, 300),
249
+ height: Math.max((c.rows ?? 3) * 30 + 60, 200),
250
+ };
251
+ }
252
+ function minDimsArc(c: ContentCounts): { width: number; height: number } {
253
+ return {
254
+ width: 300,
255
+ height: Math.max((c.nodes ?? 3) * 20 + 120, 200),
256
+ };
257
+ }
258
+ function minDimsOrg(c: ContentCounts): { width: number; height: number } {
259
+ return {
260
+ width: Math.max((c.nodes ?? 3) * 60, 300),
261
+ height: Math.max((c.depth ?? 2) * 80, 200),
262
+ };
263
+ }
264
+ function minDimsGantt(c: ContentCounts): { width: number; height: number } {
265
+ return {
266
+ width: 400,
267
+ height: Math.max((c.tasks ?? 3) * 24 + 80, 200),
268
+ };
269
+ }
270
+ function minDimsKanban(c: ContentCounts): { width: number; height: number } {
271
+ return {
272
+ width: Math.max((c.columns ?? 3) * 120, 360),
273
+ height: 300,
274
+ };
275
+ }
276
+ // er + class share this formula.
277
+ function minDimsEntities(c: ContentCounts): { width: number; height: number } {
278
+ return {
279
+ width: Math.max((c.nodes ?? 2) * 140, 300),
280
+ height: Math.max((c.nodes ?? 2) * 80, 200),
281
+ };
282
+ }
283
+ // flowchart + state share this formula.
284
+ function minDimsGraph(c: ContentCounts): { width: number; height: number } {
285
+ return {
286
+ width: Math.max((c.nodes ?? 3) * 60, 300),
287
+ height: Math.max((c.nodes ?? 3) * 50, 200),
288
+ };
289
+ }
290
+ function minDimsPert(c: ContentCounts): { width: number; height: number } {
291
+ return {
292
+ width: Math.max((c.tasks ?? 3) * 80, 340),
293
+ height: Math.max((c.tasks ?? 3) * 40 + 80, 200),
294
+ };
295
+ }
296
+ function minDimsInfra(c: ContentCounts): { width: number; height: number } {
297
+ return {
298
+ width: Math.max((c.nodes ?? 3) * 80, 300),
299
+ height: Math.max((c.nodes ?? 3) * 60, 200),
300
+ };
301
+ }
302
+
213
303
  // ============================================================
214
304
  // THE REGISTRY — ordered to match the previous chartTypeParsers grouping
215
305
  // (structured diagrams, standard ECharts, extended ECharts, D3 visualizations,
@@ -224,32 +314,49 @@ export const CHART_TYPE_REGISTRY: readonly ChartTypeDescriptor[] = [
224
314
  category: 'diagram',
225
315
  parse: parseSequenceDgmo,
226
316
  measure: measureSequence,
317
+ minDims: minDimsSequence,
227
318
  },
228
319
  {
229
320
  id: 'flowchart',
230
321
  category: 'diagram',
231
322
  parse: parseFlowchart,
232
323
  measure: measureFlowchart,
324
+ minDims: minDimsGraph,
233
325
  },
234
326
  {
235
327
  id: 'class',
236
328
  category: 'diagram',
237
329
  parse: parseClassDiagram,
238
330
  measure: measureClass,
331
+ minDims: minDimsEntities,
332
+ },
333
+ {
334
+ id: 'er',
335
+ category: 'diagram',
336
+ parse: parseERDiagram,
337
+ measure: measureER,
338
+ minDims: minDimsEntities,
239
339
  },
240
- { id: 'er', category: 'diagram', parse: parseERDiagram, measure: measureER },
241
340
  {
242
341
  id: 'state',
243
342
  category: 'diagram',
244
343
  parse: parseState,
245
344
  measure: measureStateGraph,
345
+ minDims: minDimsGraph,
346
+ },
347
+ {
348
+ id: 'org',
349
+ category: 'diagram',
350
+ parse: parseOrg,
351
+ measure: measureOrg,
352
+ minDims: minDimsOrg,
246
353
  },
247
- { id: 'org', category: 'diagram', parse: parseOrg, measure: measureOrg },
248
354
  {
249
355
  id: 'kanban',
250
356
  category: 'diagram',
251
357
  parse: parseKanban,
252
358
  measure: measureKanban,
359
+ minDims: minDimsKanban,
253
360
  },
254
361
  { id: 'c4', category: 'diagram', parse: parseC4 },
255
362
  { id: 'sitemap', category: 'diagram', parse: parseSitemap },
@@ -258,24 +365,39 @@ export const CHART_TYPE_REGISTRY: readonly ChartTypeDescriptor[] = [
258
365
  category: 'diagram',
259
366
  parse: parseInfra,
260
367
  measure: measureInfra,
368
+ minDims: minDimsInfra,
261
369
  },
262
370
  {
263
371
  id: 'gantt',
264
372
  category: 'diagram',
265
373
  parse: parseGantt,
266
374
  measure: measureGantt,
375
+ minDims: minDimsGantt,
376
+ },
377
+ {
378
+ id: 'pert',
379
+ category: 'diagram',
380
+ parse: parsePert,
381
+ measure: measurePert,
382
+ minDims: minDimsPert,
267
383
  },
268
- { id: 'pert', category: 'diagram', parse: parsePert, measure: measurePert },
269
384
  { id: 'boxes-and-lines', category: 'diagram', parse: parseBoxesAndLines },
270
385
  {
271
386
  id: 'mindmap',
272
387
  category: 'diagram',
273
388
  parse: parseMindmap,
274
389
  measure: measureMindmap,
390
+ minDims: minDimsMindmap,
275
391
  },
276
392
  { id: 'wireframe', category: 'diagram', parse: parseWireframe },
277
393
  { id: 'journey-map', category: 'diagram', parse: parseJourneyMap },
278
- { id: 'raci', category: 'diagram', parse: parseRaci, measure: measureRaci },
394
+ {
395
+ id: 'raci',
396
+ category: 'diagram',
397
+ parse: parseRaci,
398
+ measure: measureRaci,
399
+ minDims: minDimsRaci,
400
+ },
279
401
  { id: 'rasci', category: 'diagram', parse: parseRaci, measure: measureRaci },
280
402
  { id: 'daci', category: 'diagram', parse: parseRaci, measure: measureRaci },
281
403
 
@@ -300,6 +422,7 @@ export const CHART_TYPE_REGISTRY: readonly ChartTypeDescriptor[] = [
300
422
  category: 'data-chart',
301
423
  parse: parseHeatmap,
302
424
  measure: measureHeatmap,
425
+ minDims: minDimsHeatmap,
303
426
  },
304
427
  { id: 'funnel', category: 'data-chart', parse: parseFunnel },
305
428
 
@@ -311,6 +434,7 @@ export const CHART_TYPE_REGISTRY: readonly ChartTypeDescriptor[] = [
311
434
  category: 'visualization',
312
435
  parse: parseArc,
313
436
  measure: measureArc,
437
+ minDims: minDimsArc,
314
438
  },
315
439
  { id: 'timeline', category: 'visualization', parse: parseTimeline },
316
440
  { id: 'venn', category: 'visualization', parse: parseVenn },
@@ -322,6 +446,7 @@ export const CHART_TYPE_REGISTRY: readonly ChartTypeDescriptor[] = [
322
446
  category: 'visualization',
323
447
  parse: parseTechRadar,
324
448
  measure: measureTechRadar,
449
+ minDims: minDimsTechRadar,
325
450
  },
326
451
  { id: 'cycle', category: 'visualization', parse: parseCycle },
327
452
  { id: 'pyramid', category: 'visualization', parse: parsePyramid },
@@ -46,7 +46,7 @@ export const chartTypes: readonly ChartTypeMeta[] = [
46
46
  },
47
47
  {
48
48
  id: 'sequence',
49
- description: 'Message / interaction flows',
49
+ description: 'Message request and response interaction flows',
50
50
  fallback: true,
51
51
  },
52
52
  {
@@ -143,7 +143,7 @@ export const chartTypes: readonly ChartTypeMeta[] = [
143
143
  },
144
144
  {
145
145
  id: 'slope',
146
- description: 'Change between two periods',
146
+ description: 'Change between 2 time periods',
147
147
  },
148
148
  {
149
149
  id: 'sankey',
@@ -173,7 +173,7 @@ export const chartTypes: readonly ChartTypeMeta[] = [
173
173
  // ── Tier 4 — General-purpose data charts ──────────────────
174
174
  {
175
175
  id: 'bar',
176
- description: 'Categorical comparisons',
176
+ description: 'Categorical comparisons for 3 - 5 figures',
177
177
  fallback: true,
178
178
  },
179
179
  {