@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
@@ -4,7 +4,7 @@
4
4
 
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import type { PaletteColors } from '../palettes';
7
- import { contrastText, mix, shapeFill } from '../palettes/color-utils';
7
+ import { contrastText, mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
8
8
  import {
9
9
  parseInlineMarkdown,
10
10
  truncateBareUrl,
@@ -2010,7 +2010,7 @@ export function renderSequenceDiagram(
2010
2010
  const fillColor = groupTagColor
2011
2011
  ? mix(
2012
2012
  groupTagColor,
2013
- isDark ? palette.surface : palette.bg,
2013
+ themeBaseBg(palette, isDark),
2014
2014
  isDark ? 15 : 20
2015
2015
  )
2016
2016
  : isDark
@@ -2139,7 +2139,7 @@ export function renderSequenceDiagram(
2139
2139
  const pFill = effectiveTagColor
2140
2140
  ? mix(
2141
2141
  effectiveTagColor,
2142
- isDark ? palette.surface : palette.bg,
2142
+ themeBaseBg(palette, isDark),
2143
2143
  isDark ? 30 : 40
2144
2144
  )
2145
2145
  : isDark
@@ -2507,7 +2507,7 @@ export function renderSequenceDiagram(
2507
2507
  .attr('y', y1)
2508
2508
  .attr('width', sActivationWidth)
2509
2509
  .attr('height', y2 - y1)
2510
- .attr('fill', isDark ? palette.surface : palette.bg);
2510
+ .attr('fill', themeBaseBg(palette, isDark));
2511
2511
 
2512
2512
  // Canonical 25% tint via shapeFill() (or full intent when solid-fill is on).
2513
2513
  const actFill = shapeFill(palette, actBaseColor, isDark, { solid });
@@ -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,
@@ -165,12 +166,7 @@ export function parseSitemap(
165
166
  error: null,
166
167
  };
167
168
 
168
- const fail = (line: number, message: string): ParsedSitemap => {
169
- const diag = makeDgmoError(line, message);
170
- result.diagnostics.push(diag);
171
- result.error = formatDgmoError(diag);
172
- return result;
173
- };
169
+ const fail = makeFail(result);
174
170
 
175
171
  const pushError = (line: number, message: string): void => {
176
172
  const diag = makeDgmoError(line, message);
@@ -324,7 +320,12 @@ export function parseSitemap(
324
320
  const indent = measureIndent(line);
325
321
  if (indent > 0) {
326
322
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
327
- const { label, color } = extractColor(cleanEntry, palette);
323
+ const { label, color } = extractColor(
324
+ cleanEntry,
325
+ palette,
326
+ result.diagnostics,
327
+ lineNumber
328
+ );
328
329
  // Bare value (no explicit color) → keep it; finalized below.
329
330
  currentTagGroup.entries.push({
330
331
  value: label,
@@ -6,7 +6,7 @@ import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
8
  import type { PaletteColors } from '../palettes';
9
- import { contrastText, mix, shapeFill } from '../palettes/color-utils';
9
+ import { contrastText, mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
10
10
  import type { ParsedSitemap } from './types';
11
11
  import type { SitemapLayoutResult, SitemapLegendGroup } from './layout';
12
12
  import { renderInlineText } from '../utils/inline-markdown';
@@ -23,6 +23,7 @@ import {
23
23
  import { renderIntegratedLegend } from '../utils/legend-integration';
24
24
  import { getMaxLegendReservedHeight } from '../utils/legend-layout';
25
25
  import { ScaleContext } from '../utils/scaling';
26
+ import { renderNodeCard } from '../utils/card';
26
27
 
27
28
  // ============================================================
28
29
  // Constants
@@ -32,26 +33,28 @@ const DIAGRAM_PADDING = 20;
32
33
  const MAX_SCALE = 3;
33
34
  import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
34
35
  const TITLE_HEIGHT = 30;
35
- const LABEL_FONT_SIZE = 13;
36
- const META_FONT_SIZE = 11;
37
- const META_LINE_HEIGHT = 16;
38
- const HEADER_HEIGHT = 28;
39
- const SEPARATOR_GAP = 6;
40
- const EDGE_STROKE_WIDTH = 1.5;
41
- const NODE_STROKE_WIDTH = 1.5;
42
- const CARD_RADIUS = 6;
43
- const CONTAINER_RADIUS = 8;
44
- const CONTAINER_LABEL_FONT_SIZE = 13;
45
- const CONTAINER_META_FONT_SIZE = 11;
46
- const CONTAINER_META_LINE_HEIGHT = 16;
47
- const CONTAINER_HEADER_HEIGHT = 28;
36
+ // Shared card / group / collapse constants (Story 111.1). sitemap matches every
37
+ // convention default, so it imports the full set.
38
+ import {
39
+ LABEL_FONT_SIZE,
40
+ META_FONT_SIZE,
41
+ META_LINE_HEIGHT,
42
+ HEADER_HEIGHT,
43
+ SEPARATOR_GAP,
44
+ EDGE_STROKE_WIDTH,
45
+ NODE_STROKE_WIDTH,
46
+ CARD_RADIUS,
47
+ CONTAINER_RADIUS,
48
+ CONTAINER_LABEL_FONT_SIZE,
49
+ CONTAINER_META_FONT_SIZE,
50
+ CONTAINER_META_LINE_HEIGHT,
51
+ CONTAINER_HEADER_HEIGHT,
52
+ COLLAPSE_BAR_HEIGHT,
53
+ } from '../utils/visual-conventions';
48
54
  const ARROWHEAD_W = 10;
49
55
  const ARROWHEAD_H = 7;
50
56
  const EDGE_LABEL_FONT_SIZE = 11;
51
57
 
52
- // Collapsed-node accent bar
53
- const COLLAPSE_BAR_HEIGHT = 6;
54
-
55
58
  const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram — local, not shared
56
59
 
57
60
  // ============================================================
@@ -80,7 +83,7 @@ function containerFill(
80
83
  nodeColor?: string
81
84
  ): string {
82
85
  if (nodeColor) {
83
- return mix(nodeColor, isDark ? palette.surface : palette.bg, 10);
86
+ return mix(nodeColor, themeBaseBg(palette, isDark), 10);
84
87
  }
85
88
  return mix(palette.surface, palette.bg, 40);
86
89
  }
@@ -492,32 +495,27 @@ export function renderSitemap(
492
495
  const fill = nodeFill(palette, isDark, node.color, solid);
493
496
  const stroke = nodeStroke(palette, node.color);
494
497
 
495
- // Card background
496
- nodeG
497
- .append('rect')
498
- .attr('x', 0)
499
- .attr('y', 0)
500
- .attr('width', node.width)
501
- .attr('height', node.height)
502
- .attr('rx', CARD_RADIUS)
503
- .attr('fill', fill)
504
- .attr('stroke', stroke)
505
- .attr('stroke-width', sNodeStrokeWidth);
506
-
498
+ // Card background + label via the shared card door (Story 111.1). sitemap's
499
+ // metadata, description lines, and collapse bar diverge from the convention
500
+ // (different meta baseline; semi-transparent collapse bar + "+N" badge), so
501
+ // they stay inline below — see conventions §1/§3 deviations log.
507
502
  const labelColor = contrastText(
508
503
  fill,
509
504
  palette.textOnFillLight,
510
505
  palette.textOnFillDark
511
506
  );
512
- nodeG
513
- .append('text')
514
- .attr('x', node.width / 2)
515
- .attr('y', sHeaderHeight / 2 + sLabelFontSize / 2 - 2)
516
- .attr('text-anchor', 'middle')
517
- .attr('fill', labelColor)
518
- .attr('font-size', sLabelFontSize)
519
- .attr('font-weight', 'bold')
520
- .text(node.label);
507
+ renderNodeCard(nodeG, {
508
+ width: node.width,
509
+ height: node.height,
510
+ rx: CARD_RADIUS,
511
+ fill,
512
+ stroke,
513
+ strokeWidth: sNodeStrokeWidth,
514
+ label: node.label,
515
+ labelColor,
516
+ labelFontSize: sLabelFontSize,
517
+ headerHeight: sHeaderHeight,
518
+ });
521
519
 
522
520
  const metaEntries = Object.entries(node.metadata);
523
521
  if (metaEntries.length > 0) {
@@ -1,6 +1,6 @@
1
1
  import {
2
- formatDgmoError,
3
2
  makeDgmoError,
3
+ makeFail,
4
4
  METADATA_DIAGNOSTIC_CODES,
5
5
  pipeOperatorRemovedMessage,
6
6
  suggest,
@@ -72,12 +72,7 @@ export function parseTechRadar(content: string): ParsedTechRadar {
72
72
  error: null,
73
73
  };
74
74
 
75
- const fail = (line: number, message: string): ParsedTechRadar => {
76
- const diag = makeDgmoError(line, message);
77
- result.diagnostics.push(diag);
78
- result.error = formatDgmoError(diag);
79
- return result;
80
- };
75
+ const fail = makeFail(result);
81
76
 
82
77
  const warn = (line: number, message: string): void => {
83
78
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
@@ -24,7 +24,7 @@ import type {
24
24
  import { parseTimelineDate } from '../timeline/parser';
25
25
  import type { PaletteColors } from '../palettes';
26
26
  import { getSeriesColors } from '../palettes';
27
- import { mix, shapeFill } from '../palettes/color-utils';
27
+ import { mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
28
28
  import { resolveTagColor } from '../utils/tag-groups';
29
29
  import type { TagGroup } from '../utils/tag-groups';
30
30
  import {
@@ -808,7 +808,7 @@ function setupTimeline(
808
808
  const textColor = palette.text;
809
809
  const mutedColor = palette.border;
810
810
  const bgColor = palette.bg;
811
- const bg = isDark ? palette.surface : palette.bg;
811
+ const bg = themeBaseBg(palette, isDark);
812
812
  const colors = getSeriesColors(palette);
813
813
 
814
814
  const groupColorMap = new Map<string, string>();
@@ -1451,7 +1451,7 @@ function renderTimelineHorizontalTimeSort(
1451
1451
  setup: TimelineSetup,
1452
1452
  hovers: TimelineHoverHelpers,
1453
1453
  onClickItem: ((lineNumber: number) => void) | undefined,
1454
- _exportDims: D3ExportDimensions | undefined,
1454
+ exportDims: D3ExportDimensions | undefined,
1455
1455
  _swimlaneTagGroup: string | null | undefined,
1456
1456
  _activeTagGroup: string | null | undefined,
1457
1457
  _onTagStateChange:
@@ -1461,6 +1461,7 @@ function renderTimelineHorizontalTimeSort(
1461
1461
  ): void {
1462
1462
  const {
1463
1463
  width,
1464
+ height,
1464
1465
  tooltip,
1465
1466
  solid,
1466
1467
  textColor,
@@ -1538,6 +1539,15 @@ function renderTimelineHorizontalTimeSort(
1538
1539
  const innerHeight = rowH * sorted.length;
1539
1540
  const usedHeight = margin.top + innerHeight + margin.bottom;
1540
1541
 
1542
+ // On-screen (non-export) the content can be taller than the host pane. Rather
1543
+ // than overflow it (forcing the user to scroll), scale the whole SVG down to
1544
+ // fit the available height: the viewBox keeps the natural content geometry
1545
+ // while the rendered height is clamped to the container, and
1546
+ // preserveAspectRatio uniformly shrinks + centers it. Export keeps the full
1547
+ // natural height so the image is never compressed.
1548
+ const fitToContainer = !exportDims && height > 0 && usedHeight > height;
1549
+ const svgHeight = fitToContainer ? height : usedHeight;
1550
+
1541
1551
  const xScale = d3Scale
1542
1552
  .scaleLinear()
1543
1553
  .domain([minDate - datePadding, maxDate + datePadding])
@@ -1547,12 +1557,12 @@ function renderTimelineHorizontalTimeSort(
1547
1557
  .select(container)
1548
1558
  .append('svg')
1549
1559
  .attr('width', width)
1550
- .attr('height', usedHeight)
1560
+ .attr('height', svgHeight)
1551
1561
  .attr('viewBox', `0 0 ${width} ${usedHeight}`)
1552
1562
  .attr('preserveAspectRatio', 'xMidYMin meet')
1553
1563
  .style('background', bgColor);
1554
1564
 
1555
- if (ctx.isBelowFloor) {
1565
+ if (ctx.isBelowFloor && !fitToContainer) {
1556
1566
  svg.attr('width', '100%');
1557
1567
  }
1558
1568
 
@@ -0,0 +1,183 @@
1
+ // ============================================================
2
+ // Node-card door (Story 111.1)
3
+ // ============================================================
4
+ //
5
+ // The node card described in docs/architecture/diagram-visual-conventions.md §1
6
+ // (rect → label → header separator → metadata rows) and the collapse accent bar
7
+ // (§3) were hand-rolled inline in every structured-diagram renderer. This module
8
+ // owns that drawing behind one interface, mirroring the `renderIntegratedLegend`
9
+ // door (utils/legend-integration.ts): the renderer creates its wrapper `<g>` and
10
+ // resolves geometry + colors (scaling and palette stay the renderer's job — see
11
+ // ScaleContext / 111.3), then makes one call.
12
+ //
13
+ // Output is byte-identical to the prior inline code: same element order, same
14
+ // attributes, same values. Scaled numbers are passed IN (the door never calls
15
+ // ScaleContext), so rounding order is unchanged.
16
+
17
+ import type { D3Sel } from './legend-types';
18
+ import { measureText } from './text-measure';
19
+
20
+ /** One resolved metadata row: a display key (already mapped) and its value. */
21
+ export type CardMetaRow = readonly [displayKey: string, value: string];
22
+
23
+ export interface CardMetaOptions {
24
+ /** Rows in display order. */
25
+ rows: readonly CardMetaRow[];
26
+ /** Scaled metadata font size. */
27
+ fontSize: number;
28
+ /** Scaled metadata line height. */
29
+ lineHeight: number;
30
+ /** Scaled gap between the header separator and the first row baseline. */
31
+ separatorGap: number;
32
+ /** Header separator stroke color (callers pass `solid ? labelColor : stroke`). */
33
+ separatorColor: string;
34
+ /** Fill for both key and value text (node cards use the label color for both). */
35
+ textColor: string;
36
+ /** Left inset for the key column (defaults to 10, the convention value). */
37
+ keyX?: number;
38
+ }
39
+
40
+ export interface NodeCardOptions {
41
+ /** Card width (already laid out). */
42
+ width: number;
43
+ /** Card height (already laid out). */
44
+ height: number;
45
+ /** Scaled corner radius. */
46
+ rx: number;
47
+ /** Resolved card fill. */
48
+ fill: string;
49
+ /** Resolved card stroke. */
50
+ stroke: string;
51
+ /** Scaled stroke width. */
52
+ strokeWidth: number;
53
+ /** Dashed border (e.g. org `isContainer`); uses the `'6 3'` convention pattern. */
54
+ dashed?: boolean;
55
+ /** Label text. */
56
+ label: string;
57
+ /** Resolved label color (also used for metadata text). */
58
+ labelColor: string;
59
+ /** Scaled label font size. */
60
+ labelFontSize: number;
61
+ /** Scaled header band height (positions the label and the separator). */
62
+ headerHeight: number;
63
+ /** Metadata block; omit (or pass empty rows) for a card with no separator/meta. */
64
+ meta?: CardMetaOptions;
65
+ }
66
+
67
+ /**
68
+ * Draw a node card (rect → label → optional separator + metadata rows) into
69
+ * `container` (the renderer's wrapper `<g>`). Reproduces conventions §1 exactly.
70
+ */
71
+ export function renderNodeCard(container: D3Sel, opts: NodeCardOptions): void {
72
+ const rect = container
73
+ .append('rect')
74
+ .attr('x', 0)
75
+ .attr('y', 0)
76
+ .attr('width', opts.width)
77
+ .attr('height', opts.height)
78
+ .attr('rx', opts.rx)
79
+ .attr('fill', opts.fill)
80
+ .attr('stroke', opts.stroke)
81
+ .attr('stroke-width', opts.strokeWidth);
82
+
83
+ if (opts.dashed) {
84
+ rect.attr('stroke-dasharray', '6 3');
85
+ }
86
+
87
+ container
88
+ .append('text')
89
+ .attr('x', opts.width / 2)
90
+ .attr('y', opts.headerHeight / 2 + opts.labelFontSize / 2 - 2)
91
+ .attr('text-anchor', 'middle')
92
+ .attr('fill', opts.labelColor)
93
+ .attr('font-size', opts.labelFontSize)
94
+ .attr('font-weight', 'bold')
95
+ .text(opts.label);
96
+
97
+ const meta = opts.meta;
98
+ if (!meta || meta.rows.length === 0) return;
99
+
100
+ container
101
+ .append('line')
102
+ .attr('x1', 0)
103
+ .attr('y1', opts.headerHeight)
104
+ .attr('x2', opts.width)
105
+ .attr('y2', opts.headerHeight)
106
+ .attr('stroke', meta.separatorColor)
107
+ .attr('stroke-opacity', 0.3)
108
+ .attr('stroke-width', 1);
109
+
110
+ const keyX = meta.keyX ?? 10;
111
+ const maxKeyWidth = Math.max(
112
+ ...meta.rows.map(([key]) => measureText(`${key}: `, meta.fontSize))
113
+ );
114
+ const valueX = keyX + maxKeyWidth;
115
+ const metaStartY = opts.headerHeight + meta.separatorGap + meta.fontSize;
116
+
117
+ for (let i = 0; i < meta.rows.length; i++) {
118
+ const [displayKey, value] = meta.rows[i]!;
119
+ const rowY = metaStartY + i * meta.lineHeight;
120
+
121
+ container
122
+ .append('text')
123
+ .attr('x', keyX)
124
+ .attr('y', rowY)
125
+ .attr('fill', meta.textColor)
126
+ .attr('font-size', meta.fontSize)
127
+ .text(`${displayKey}: `);
128
+
129
+ container
130
+ .append('text')
131
+ .attr('x', valueX)
132
+ .attr('y', rowY)
133
+ .attr('fill', meta.textColor)
134
+ .attr('font-size', meta.fontSize)
135
+ .text(value);
136
+ }
137
+ }
138
+
139
+ export interface CollapseBarOptions {
140
+ /** Card width. */
141
+ width: number;
142
+ /** Card height (the bar sits at the bottom). */
143
+ height: number;
144
+ /** Scaled bar height. */
145
+ barHeight: number;
146
+ /** Scaled horizontal inset from each edge (usually 0). */
147
+ inset: number;
148
+ /** Scaled corner radius for the clip rect (matches the card). */
149
+ rx: number;
150
+ /** Bar fill (callers pass `solid ? labelColor : stroke`). */
151
+ fill: string;
152
+ /** Unique clip-path id (kept renderer-local to avoid collisions). */
153
+ clipId: string;
154
+ /** Wrapper class for the bar rect (e.g. `org-collapse-bar`). */
155
+ className: string;
156
+ }
157
+
158
+ /**
159
+ * Draw the collapse accent bar (conventions §3) at the bottom of a card,
160
+ * clipped to the card's rounded corners. Reproduces the prior inline code.
161
+ */
162
+ export function renderCollapseBar(
163
+ container: D3Sel,
164
+ opts: CollapseBarOptions
165
+ ): void {
166
+ container
167
+ .append('clipPath')
168
+ .attr('id', opts.clipId)
169
+ .append('rect')
170
+ .attr('width', opts.width)
171
+ .attr('height', opts.height)
172
+ .attr('rx', opts.rx);
173
+
174
+ container
175
+ .append('rect')
176
+ .attr('x', opts.inset)
177
+ .attr('y', opts.height - opts.barHeight)
178
+ .attr('width', opts.width - opts.inset * 2)
179
+ .attr('height', opts.barHeight)
180
+ .attr('fill', opts.fill)
181
+ .attr('clip-path', `url(#${opts.clipId})`)
182
+ .attr('class', opts.className);
183
+ }
@@ -8,6 +8,7 @@ import {
8
8
  RECOGNIZED_COLOR_NAMES,
9
9
  resolveColor,
10
10
  resolveColorWithDiagnostic,
11
+ invalidColorDiagnostic,
11
12
  } from '../colors';
12
13
  import {
13
14
  emptyMetadataValueMessage,
@@ -122,7 +123,18 @@ export function extractColor(
122
123
  if (lastSpaceIdx < 0) return { label };
123
124
  const trailing = label.substring(lastSpaceIdx + 1);
124
125
  // Case-sensitive lowercase match against the closed 11-name palette.
125
- if (!RECOGNIZED_COLOR_SET.has(trailing)) return { label };
126
+ if (!RECOGNIZED_COLOR_SET.has(trailing)) {
127
+ // Not a valid color. If it nonetheless LOOKS like an intended color (a hex
128
+ // literal or a CSS color name like `pink`), flag it — otherwise the
129
+ // trailing-token rule would silently swallow it into the label with no
130
+ // diagnostic, and the MCP color gate would have nothing to block on. A
131
+ // genuine label word (`Zinfandel`) is not color-like, so it stays as-is.
132
+ if (diagnostics && line !== undefined) {
133
+ const diag = invalidColorDiagnostic(trailing, line);
134
+ if (diag) diagnostics.push(diag);
135
+ }
136
+ return { label };
137
+ }
126
138
  let color: string | undefined;
127
139
  if (diagnostics && line !== undefined) {
128
140
  color = resolveColorWithDiagnostic(trailing, line, diagnostics, palette);
@@ -21,6 +21,41 @@ export class ScaleContext {
21
21
  return new ScaleContext(clamped, minScaleFactor);
22
22
  }
23
23
 
24
+ /**
25
+ * Fit content into a bounding box, scaling by whichever dimension is more
26
+ * constraining (the smaller of the width- and height-fit ratios) so the
27
+ * diagram never overflows the canvas in either axis. Like {@link from}, the
28
+ * factor is clamped to `[minScaleFactor, 1]` (content is never enlarged, and
29
+ * never shrunk past the readability floor).
30
+ */
31
+ static fromBox(
32
+ containerWidth: number,
33
+ idealWidth: number,
34
+ containerHeight: number,
35
+ idealHeight: number,
36
+ minScaleFactor = DEFAULT_MIN_SCALE_FACTOR
37
+ ): ScaleContext {
38
+ const wRaw = idealWidth > 0 ? containerWidth / idealWidth : 1;
39
+ const hRaw = idealHeight > 0 ? containerHeight / idealHeight : 1;
40
+ const raw = Math.min(wRaw, hRaw);
41
+ const clamped = Math.max(Math.min(raw, 1), minScaleFactor);
42
+ return new ScaleContext(clamped, minScaleFactor);
43
+ }
44
+
45
+ /**
46
+ * Build a context from an explicit raw factor (clamped to
47
+ * `[minScaleFactor, 1]`). Used to refine a fit iteratively: layout scaling is
48
+ * non-linear (gaps shrink faster than floored text), so the first-pass factor
49
+ * can still overflow — re-measure the laid-out result and tighten.
50
+ */
51
+ static fromFactor(
52
+ rawFactor: number,
53
+ minScaleFactor = DEFAULT_MIN_SCALE_FACTOR
54
+ ): ScaleContext {
55
+ const clamped = Math.max(Math.min(rawFactor, 1), minScaleFactor);
56
+ return new ScaleContext(clamped, minScaleFactor);
57
+ }
58
+
24
59
  static identity(): ScaleContext {
25
60
  return new ScaleContext(1, DEFAULT_MIN_SCALE_FACTOR);
26
61
  }
@@ -42,7 +77,9 @@ export class ScaleContext {
42
77
  }
43
78
 
44
79
  // ============================================================
45
- // Minimum dimensions formula table
80
+ // ContentCounts per-chart-type content tallies that feed the registry's
81
+ // `minDims` formulas (chart-type-registry.ts). The formulas themselves moved
82
+ // there (Story 111.5) so a chart type's sizing is defined in one descriptor.
46
83
  // ============================================================
47
84
 
48
85
  export interface ContentCounts {
@@ -57,83 +94,3 @@ export interface ContentCounts {
57
94
  roles?: number;
58
95
  blips?: number;
59
96
  }
60
-
61
- const DEFAULT_MIN = { width: 300, height: 200 };
62
-
63
- export function computeMinDimensions(
64
- chartType: string,
65
- counts: ContentCounts
66
- ): { width: number; height: number } {
67
- switch (chartType) {
68
- case 'sequence':
69
- return {
70
- width: Math.max((counts.participants ?? 2) * 80, 320),
71
- height: Math.max((counts.messages ?? 1) * 20 + 120, 200),
72
- };
73
- case 'raci':
74
- return {
75
- width: Math.max((counts.roles ?? 2) * 50 + 180, 300),
76
- height: Math.max((counts.tasks ?? 1) * 28 + 80, 200),
77
- };
78
- case 'mindmap':
79
- return {
80
- width: Math.max((counts.nodes ?? 3) * 30, 300),
81
- height: Math.max((counts.depth ?? 2) * 60, 200),
82
- };
83
- case 'tech-radar':
84
- return { width: 360, height: 400 };
85
- case 'heatmap':
86
- return {
87
- width: Math.max((counts.columns ?? 3) * 40, 300),
88
- height: Math.max((counts.rows ?? 3) * 30 + 60, 200),
89
- };
90
- case 'arc':
91
- return {
92
- width: 300,
93
- height: Math.max((counts.nodes ?? 3) * 20 + 120, 200),
94
- };
95
- case 'org':
96
- return {
97
- width: Math.max((counts.nodes ?? 3) * 60, 300),
98
- height: Math.max((counts.depth ?? 2) * 80, 200),
99
- };
100
- case 'gantt':
101
- return {
102
- width: 400,
103
- height: Math.max((counts.tasks ?? 3) * 24 + 80, 200),
104
- };
105
- case 'kanban':
106
- return {
107
- width: Math.max((counts.columns ?? 3) * 120, 360),
108
- height: 300,
109
- };
110
- case 'er':
111
- return {
112
- width: Math.max((counts.nodes ?? 2) * 140, 300),
113
- height: Math.max((counts.nodes ?? 2) * 80, 200),
114
- };
115
- case 'class':
116
- return {
117
- width: Math.max((counts.nodes ?? 2) * 140, 300),
118
- height: Math.max((counts.nodes ?? 2) * 80, 200),
119
- };
120
- case 'flowchart':
121
- case 'state':
122
- return {
123
- width: Math.max((counts.nodes ?? 3) * 60, 300),
124
- height: Math.max((counts.nodes ?? 3) * 50, 200),
125
- };
126
- case 'pert':
127
- return {
128
- width: Math.max((counts.tasks ?? 3) * 80, 340),
129
- height: Math.max((counts.tasks ?? 3) * 40 + 80, 200),
130
- };
131
- case 'infra':
132
- return {
133
- width: Math.max((counts.nodes ?? 3) * 80, 300),
134
- height: Math.max((counts.nodes ?? 3) * 60, 200),
135
- };
136
- default:
137
- return { ...DEFAULT_MIN };
138
- }
139
- }