@diagrammo/dgmo 0.8.21 → 0.8.23

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 (114) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +145 -93
  4. package/dist/editor.cjs +20 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +20 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +15 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +15 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +20843 -14937
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +426 -17
  15. package/dist/index.d.ts +426 -17
  16. package/dist/index.js +20795 -14912
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal.cjs +380 -0
  19. package/dist/internal.cjs.map +1 -0
  20. package/dist/internal.d.cts +179 -0
  21. package/dist/internal.d.ts +179 -0
  22. package/dist/internal.js +337 -0
  23. package/dist/internal.js.map +1 -0
  24. package/docs/guide/chart-cycle.md +156 -0
  25. package/docs/guide/chart-journey-map.md +179 -0
  26. package/docs/guide/chart-pyramid.md +111 -0
  27. package/docs/guide/chart-sitemap.md +18 -1
  28. package/docs/guide/chart-tech-radar.md +219 -0
  29. package/docs/guide/registry.json +6 -0
  30. package/docs/language-reference.md +177 -6
  31. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  32. package/gallery/fixtures/c4-full.dgmo +2 -2
  33. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  34. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  35. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  36. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  37. package/gallery/fixtures/gantt-full.dgmo +2 -2
  38. package/gallery/fixtures/gantt.dgmo +2 -2
  39. package/gallery/fixtures/infra-full.dgmo +2 -2
  40. package/gallery/fixtures/infra.dgmo +1 -1
  41. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  42. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  43. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  44. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  45. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  46. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  47. package/gallery/fixtures/tech-radar.dgmo +36 -0
  48. package/gallery/fixtures/timeline.dgmo +1 -1
  49. package/package.json +11 -1
  50. package/src/boxes-and-lines/layout.ts +309 -33
  51. package/src/boxes-and-lines/parser.ts +86 -10
  52. package/src/boxes-and-lines/renderer.ts +250 -91
  53. package/src/boxes-and-lines/types.ts +1 -1
  54. package/src/c4/layout.ts +8 -8
  55. package/src/c4/parser.ts +35 -2
  56. package/src/c4/renderer.ts +19 -3
  57. package/src/c4/types.ts +1 -0
  58. package/src/chart.ts +14 -7
  59. package/src/cli.ts +5 -35
  60. package/src/completion.ts +233 -41
  61. package/src/cycle/layout.ts +723 -0
  62. package/src/cycle/parser.ts +352 -0
  63. package/src/cycle/renderer.ts +566 -0
  64. package/src/cycle/types.ts +98 -0
  65. package/src/d3.ts +107 -8
  66. package/src/dgmo-router.ts +82 -3
  67. package/src/echarts.ts +8 -5
  68. package/src/editor/dgmo.grammar +5 -1
  69. package/src/editor/dgmo.grammar.js +1 -1
  70. package/src/editor/keywords.ts +17 -0
  71. package/src/gantt/parser.ts +2 -8
  72. package/src/graph/flowchart-parser.ts +15 -21
  73. package/src/graph/state-parser.ts +5 -10
  74. package/src/index.ts +63 -2
  75. package/src/infra/layout.ts +218 -74
  76. package/src/infra/parser.ts +32 -8
  77. package/src/infra/renderer.ts +14 -8
  78. package/src/infra/types.ts +10 -3
  79. package/src/internal.ts +16 -0
  80. package/src/journey-map/layout.ts +386 -0
  81. package/src/journey-map/parser.ts +540 -0
  82. package/src/journey-map/renderer.ts +1521 -0
  83. package/src/journey-map/types.ts +47 -0
  84. package/src/kanban/parser.ts +3 -10
  85. package/src/kanban/renderer.ts +31 -15
  86. package/src/mindmap/parser.ts +12 -18
  87. package/src/mindmap/renderer.ts +14 -13
  88. package/src/mindmap/text-wrap.ts +22 -12
  89. package/src/mindmap/types.ts +2 -2
  90. package/src/org/collapse.ts +81 -0
  91. package/src/org/parser.ts +2 -6
  92. package/src/org/renderer.ts +212 -4
  93. package/src/pyramid/parser.ts +172 -0
  94. package/src/pyramid/renderer.ts +684 -0
  95. package/src/pyramid/types.ts +28 -0
  96. package/src/render.ts +2 -8
  97. package/src/sequence/parser.ts +62 -20
  98. package/src/sequence/renderer.ts +146 -40
  99. package/src/sharing.ts +1 -0
  100. package/src/sitemap/layout.ts +21 -6
  101. package/src/sitemap/parser.ts +26 -17
  102. package/src/sitemap/renderer.ts +34 -0
  103. package/src/sitemap/types.ts +1 -0
  104. package/src/tech-radar/index.ts +14 -0
  105. package/src/tech-radar/interactive.ts +1112 -0
  106. package/src/tech-radar/layout.ts +190 -0
  107. package/src/tech-radar/parser.ts +385 -0
  108. package/src/tech-radar/renderer.ts +1159 -0
  109. package/src/tech-radar/shared.ts +187 -0
  110. package/src/tech-radar/types.ts +81 -0
  111. package/src/utils/description-helpers.ts +33 -0
  112. package/src/utils/legend-layout.ts +3 -1
  113. package/src/utils/parsing.ts +47 -7
  114. package/src/utils/tag-groups.ts +46 -60
@@ -7,7 +7,12 @@ import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
8
  import { LEGEND_HEIGHT } from '../utils/legend-constants';
9
9
  import { renderLegendD3 } from '../utils/legend-d3';
10
- import type { LegendConfig, LegendState } from '../utils/legend-types';
10
+ import type {
11
+ LegendConfig,
12
+ LegendState,
13
+ LegendCallbacks,
14
+ ControlsGroupToggle,
15
+ } from '../utils/legend-types';
11
16
  import {
12
17
  TITLE_FONT_SIZE,
13
18
  TITLE_FONT_WEIGHT,
@@ -17,6 +22,7 @@ import { contrastText, mix } from '../palettes/color-utils';
17
22
  import { resolveTagColor, resolveActiveTagGroup } from '../utils/tag-groups';
18
23
  import type { TagGroup } from '../utils/tag-groups';
19
24
  import type { PaletteColors } from '../palettes';
25
+ import { renderInlineText } from '../utils/inline-markdown';
20
26
  import type { ParsedBoxesAndLines, BLNode } from './types';
21
27
  import type { BLLayoutResult, BLLayoutNode, BLLayoutEdge } from './layout';
22
28
 
@@ -24,7 +30,6 @@ import type { BLLayoutResult, BLLayoutNode, BLLayoutEdge } from './layout';
24
30
  const DIAGRAM_PADDING = 20;
25
31
  const NODE_FONT_SIZE = 13;
26
32
  const MIN_NODE_FONT_SIZE = 9;
27
- const META_FONT_SIZE = 10;
28
33
  const EDGE_LABEL_FONT_SIZE = 11;
29
34
  const EDGE_STROKE_WIDTH = 1.5;
30
35
  const NODE_STROKE_WIDTH = 1.5;
@@ -32,6 +37,9 @@ const NODE_RX = 8;
32
37
  const COLLAPSE_BAR_HEIGHT = 4;
33
38
  const ARROWHEAD_W = 5;
34
39
  const ARROWHEAD_H = 4;
40
+ const DESC_FONT_SIZE = 10; // matches infra META_FONT_SIZE
41
+ const DESC_LINE_HEIGHT = 1.4; // 14px row height at 10px font (matches infra META_LINE_HEIGHT)
42
+ const MAX_DESC_LINES = 6;
35
43
  const CHAR_WIDTH_RATIO = 0.6;
36
44
  const NODE_TEXT_PADDING = 12;
37
45
  const GROUP_RX = 8;
@@ -81,13 +89,25 @@ function splitCamelCase(word: string): string[] {
81
89
  return parts.length > 1 ? parts : [word];
82
90
  }
83
91
 
84
- function fitTextToNode(
92
+ /**
93
+ * Fit a label into a header zone for described nodes.
94
+ * Strategy: split first (spaces, dashes, camelCase), wrap into lines,
95
+ * shrink font if needed, truncate individual lines with "…" — never hard-break.
96
+ */
97
+ function fitLabelToHeader(
85
98
  label: string,
86
99
  nodeWidth: number,
87
- nodeHeight: number
100
+ maxLines: number
88
101
  ): { lines: string[]; fontSize: number } {
89
102
  const maxTextWidth = nodeWidth - NODE_TEXT_PADDING * 2;
90
- const lineHeight = 1.3;
103
+
104
+ // Split on spaces and dashes, then camelCase split each part
105
+ const rawParts = label.split(/(\s+|-)/);
106
+ const words: string[] = [];
107
+ for (const part of rawParts) {
108
+ if (!part || /^\s+$/.test(part) || part === '-') continue;
109
+ words.push(...splitCamelCase(part));
110
+ }
91
111
 
92
112
  for (
93
113
  let fontSize = NODE_FONT_SIZE;
@@ -95,17 +115,15 @@ function fitTextToNode(
95
115
  fontSize--
96
116
  ) {
97
117
  const charWidth = fontSize * CHAR_WIDTH_RATIO;
98
- const maxCharsPerLine = Math.floor(maxTextWidth / charWidth);
99
- const maxLines = Math.floor((nodeHeight - 8) / (fontSize * lineHeight));
100
- if (maxCharsPerLine < 2 || maxLines < 1) continue;
101
- if (label.length <= maxCharsPerLine) return { lines: [label], fontSize };
118
+ const maxChars = Math.floor(maxTextWidth / charWidth);
119
+ if (maxChars < 2) continue;
102
120
 
103
- const words = label.split(/\s+/);
121
+ // Wrap words into lines
104
122
  const lines: string[] = [];
105
123
  let current = '';
106
124
  for (const word of words) {
107
125
  const test = current ? `${current} ${word}` : word;
108
- if (test.length <= maxCharsPerLine) {
126
+ if (test.length <= maxChars) {
109
127
  current = test;
110
128
  } else {
111
129
  if (current) lines.push(current);
@@ -113,54 +131,39 @@ function fitTextToNode(
113
131
  }
114
132
  }
115
133
  if (current) lines.push(current);
116
- if (
117
- lines.length <= maxLines &&
118
- lines.every((l) => l.length <= maxCharsPerLine)
119
- ) {
134
+
135
+ // All lines fit at this font? Done.
136
+ if (lines.length <= maxLines && lines.every((l) => l.length <= maxChars)) {
120
137
  return { lines, fontSize };
121
138
  }
122
139
 
123
- // CamelCase split
124
- const camelWords: string[] = [];
125
- for (const word of words) {
126
- if (word.length > maxCharsPerLine)
127
- camelWords.push(...splitCamelCase(word));
128
- else camelWords.push(word);
129
- }
130
- const camelLines: string[] = [];
131
- let cc = '';
132
- for (const word of camelWords) {
133
- const test = cc ? `${cc} ${word}` : word;
134
- if (test.length <= maxCharsPerLine) {
135
- cc = test;
136
- } else {
137
- if (cc) camelLines.push(cc);
138
- cc = word;
139
- }
140
- }
141
- if (cc) camelLines.push(cc);
142
- if (
143
- camelLines.length <= maxLines &&
144
- camelLines.every((l) => l.length <= maxCharsPerLine)
145
- ) {
146
- return { lines: camelLines, fontSize };
140
+ // Lines fit in count but some are too wide? Truncate those lines.
141
+ if (lines.length <= maxLines) {
142
+ const result = lines.map((l) =>
143
+ l.length > maxChars ? l.slice(0, maxChars - 1) + '\u2026' : l
144
+ );
145
+ return { lines: result, fontSize };
147
146
  }
148
147
 
149
- if (fontSize > MIN_NODE_FONT_SIZE) continue;
150
-
151
- // Hard-break
152
- const hardLines: string[] = [];
153
- for (const line of camelLines) {
154
- if (line.length <= maxCharsPerLine) hardLines.push(line);
155
- else
156
- for (let i = 0; i < line.length; i += maxCharsPerLine)
157
- hardLines.push(line.slice(i, i + maxCharsPerLine));
148
+ // Too many lines — take first maxLines, truncate last + any oversized
149
+ const result = lines
150
+ .slice(0, maxLines)
151
+ .map((l) =>
152
+ l.length > maxChars ? l.slice(0, maxChars - 1) + '\u2026' : l
153
+ );
154
+ const last = result[maxLines - 1];
155
+ if (!last.endsWith('\u2026')) {
156
+ result[maxLines - 1] =
157
+ last.length >= maxChars
158
+ ? last.slice(0, maxChars - 1) + '\u2026'
159
+ : last + '\u2026';
158
160
  }
159
- if (hardLines.length <= maxLines) return { lines: hardLines, fontSize };
161
+ return { lines: result, fontSize };
160
162
  }
161
163
 
164
+ // Fallback at min font
162
165
  const charWidth = MIN_NODE_FONT_SIZE * CHAR_WIDTH_RATIO;
163
- const maxChars = Math.floor((nodeWidth - NODE_TEXT_PADDING * 2) / charWidth);
166
+ const maxChars = Math.floor(maxTextWidth / charWidth);
164
167
  const truncated =
165
168
  label.length > maxChars ? label.slice(0, maxChars - 1) + '\u2026' : label;
166
169
  return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
@@ -292,6 +295,10 @@ interface BLRenderOptions {
292
295
  exportDims?: { width?: number; height?: number };
293
296
  activeTagGroup?: string | null;
294
297
  hiddenTagValues?: Map<string, Set<string>>;
298
+ hideDescriptions?: boolean;
299
+ controlsExpanded?: boolean;
300
+ onToggleDescriptions?: (active: boolean) => void;
301
+ onToggleControlsExpand?: () => void;
295
302
  }
296
303
 
297
304
  export function renderBoxesAndLines(
@@ -302,8 +309,16 @@ export function renderBoxesAndLines(
302
309
  isDark: boolean,
303
310
  options?: BLRenderOptions
304
311
  ): void {
305
- const { onClickItem, exportDims, activeTagGroup, hiddenTagValues } =
306
- options ?? {};
312
+ const {
313
+ onClickItem,
314
+ exportDims,
315
+ activeTagGroup,
316
+ hiddenTagValues,
317
+ hideDescriptions,
318
+ controlsExpanded,
319
+ onToggleDescriptions,
320
+ onToggleControlsExpand,
321
+ } = options ?? {};
307
322
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
308
323
 
309
324
  const width = exportDims?.width ?? container.clientWidth;
@@ -330,7 +345,12 @@ export function renderBoxesAndLines(
330
345
 
331
346
  // Compute diagram bounds for scaling
332
347
  const titleOffset = parsed.title ? 40 : 0;
333
- const legendH = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + 8 : 0;
348
+ const hasAnyDescriptions = parsed.nodes.some(
349
+ (n) => n.description && n.description.length > 0
350
+ );
351
+ const needsLegend =
352
+ parsed.tagGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
353
+ const legendH = needsLegend ? LEGEND_HEIGHT + 8 : 0;
334
354
 
335
355
  // Account for group label zone extensions (renderer-only, not in layout.height)
336
356
  const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
@@ -727,7 +747,12 @@ export function renderBoxesAndLines(
727
747
  }
728
748
 
729
749
  if (onClickItem) {
730
- nodeG.on('click', () => onClickItem(node.lineNumber));
750
+ nodeG.on('click', (event: Event) => {
751
+ // Don't intercept clicks on links in description text
752
+ const target = event.target as Element | null;
753
+ if (target?.closest('a')) return;
754
+ onClickItem(node.lineNumber);
755
+ });
731
756
  }
732
757
 
733
758
  // Rectangle card
@@ -748,45 +773,146 @@ export function renderBoxesAndLines(
748
773
  .attr('stroke-width', NODE_STROKE_WIDTH);
749
774
 
750
775
  // All text centered vertically using dominant-baseline: central
751
- if (node.description) {
752
- const lineH = NODE_FONT_SIZE * 1.3;
753
- const gap = 2;
754
- const totalH = lineH + gap + META_FONT_SIZE;
755
- const labelY = -totalH / 2 + lineH / 2;
756
- const descY = labelY + lineH / 2 + gap + META_FONT_SIZE / 2;
776
+ const desc = node.description;
777
+ if (desc && desc.length > 0 && !hideDescriptions) {
778
+ // Label in header zone — split on spaces/dashes/camelCase, up to 3 lines
779
+ const MAX_LABEL_LINES = 3;
780
+ const fitted = fitLabelToHeader(node.label, ln.width, MAX_LABEL_LINES);
781
+ const labelLines = fitted.lines;
782
+ const labelLineH = fitted.fontSize * 1.3;
783
+ const labelTotalH = labelLines.length * labelLineH;
784
+ const headerH = labelTotalH + 12; // 12px padding
785
+ const headerCenterY = -ln.height / 2 + headerH / 2;
786
+ for (let li = 0; li < labelLines.length; li++) {
787
+ nodeG
788
+ .append('text')
789
+ .attr('x', 0)
790
+ .attr(
791
+ 'y',
792
+ headerCenterY - labelTotalH / 2 + labelLineH / 2 + li * labelLineH
793
+ )
794
+ .attr('text-anchor', 'middle')
795
+ .attr('dominant-baseline', 'central')
796
+ .attr('font-size', fitted.fontSize)
797
+ .attr('font-weight', '600')
798
+ .attr('fill', colors.text)
799
+ .text(labelLines[li]);
800
+ }
757
801
 
802
+ // Separator line (full width, matches infra style)
803
+ const sepY = -ln.height / 2 + headerH;
758
804
  nodeG
759
- .append('text')
760
- .attr('x', 0)
761
- .attr('y', labelY)
762
- .attr('text-anchor', 'middle')
763
- .attr('dominant-baseline', 'central')
764
- .attr('font-size', NODE_FONT_SIZE)
765
- .attr('font-weight', '600')
766
- .attr('fill', colors.text)
767
- .text(node.label);
768
-
769
- const maxChars = Math.floor(
770
- (ln.width - NODE_TEXT_PADDING * 2) / (META_FONT_SIZE * CHAR_WIDTH_RATIO)
805
+ .append('line')
806
+ .attr('x1', -ln.width / 2)
807
+ .attr('y1', sepY)
808
+ .attr('x2', ln.width / 2)
809
+ .attr('y2', sepY)
810
+ .attr('stroke', colors.stroke)
811
+ .attr('stroke-opacity', 0.3)
812
+ .attr('stroke-width', 1);
813
+
814
+ // Description lines with word wrapping and inline markdown
815
+ const descStartY = sepY + 4 + DESC_FONT_SIZE;
816
+ const maxTextWidth = ln.width - NODE_TEXT_PADDING * 2;
817
+ const charsPerLine = Math.floor(
818
+ maxTextWidth / (DESC_FONT_SIZE * CHAR_WIDTH_RATIO)
771
819
  );
772
- const desc =
773
- node.description.length > maxChars
774
- ? node.description.slice(0, maxChars - 1) + '\u2026'
775
- : node.description;
776
- const descEl = nodeG
777
- .append('text')
778
- .attr('x', 0)
779
- .attr('y', descY)
780
- .attr('text-anchor', 'middle')
781
- .attr('dominant-baseline', 'central')
782
- .attr('font-size', META_FONT_SIZE)
783
- .attr('fill', palette.textMuted)
784
- .text(desc);
785
- if (desc !== node.description) {
786
- descEl.append('title').text(node.description);
820
+ const descLineH = DESC_FONT_SIZE * DESC_LINE_HEIGHT;
821
+
822
+ // Estimate display length strip markdown syntax for measurement
823
+ const displayLen = (text: string): number =>
824
+ text
825
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
826
+ .replace(/\*\*(.+?)\*\*/g, '$1') // **bold** → bold
827
+ .replace(/\*(.+?)\*/g, '$1') // *italic* → italic
828
+ .replace(/`(.+?)`/g, '$1') // `code` → code
829
+ .replace(/https?:\/\/\S+/g, (u) => u.slice(0, 20)).length; // bare URLs shortened
830
+ const hasMarkdown = (text: string): boolean =>
831
+ /\[.+?\]\(.+?\)|https?:\/\/|www\./.test(text);
832
+
833
+ // Build wrapped lines from description
834
+ const wrappedLines: string[] = [];
835
+ for (let descLine of desc) {
836
+ // Render `- ` as bullet
837
+ if (descLine.startsWith('- ')) descLine = '\u2022 ' + descLine.slice(2);
838
+ // Normalize bare URLs: `http example.com` → `http://example.com`
839
+ descLine = descLine.replace(
840
+ /\bhttps?\s+([\w][\w.-]+\.[a-z]{2,}(?:\/\S*)?)/gi,
841
+ (_, domain) => `https://${domain}`
842
+ );
843
+ if (displayLen(descLine) <= charsPerLine) {
844
+ wrappedLines.push(descLine);
845
+ } else {
846
+ // Word wrap using display lengths
847
+ // Keep bullet attached to first word
848
+ let words: string[];
849
+ if (descLine.startsWith('\u2022 ')) {
850
+ const rest = descLine.slice(2);
851
+ const restWords = rest.split(/\s+/);
852
+ words = [`\u2022 ${restWords[0]}`, ...restWords.slice(1)];
853
+ } else {
854
+ words = descLine.split(/\s+/);
855
+ }
856
+ let current = '';
857
+ for (const word of words) {
858
+ const test = current ? `${current} ${word}` : word;
859
+ if (displayLen(test) <= charsPerLine) {
860
+ current = test;
861
+ } else {
862
+ if (current) wrappedLines.push(current);
863
+ // Don't truncate words containing markdown/links
864
+ current =
865
+ !hasMarkdown(word) && word.length > charsPerLine
866
+ ? word.slice(0, charsPerLine - 1) + '\u2026'
867
+ : word;
868
+ }
869
+ }
870
+ if (current) wrappedLines.push(current);
871
+ }
872
+ }
873
+
874
+ const truncated = wrappedLines.length > MAX_DESC_LINES;
875
+ const visibleLines = truncated
876
+ ? wrappedLines.slice(0, MAX_DESC_LINES)
877
+ : wrappedLines;
878
+
879
+ for (let li = 0; li < visibleLines.length; li++) {
880
+ let lineText = visibleLines[li];
881
+ // Truncate last line if there are more lines beyond the cap
882
+ if (truncated && li === visibleLines.length - 1) {
883
+ lineText =
884
+ lineText.length >= charsPerLine
885
+ ? lineText.slice(0, charsPerLine - 1) + '\u2026'
886
+ : lineText + '\u2026';
887
+ }
888
+ // Bulleted lines left-align, plain lines center
889
+ const isBullet = lineText.startsWith('\u2022');
890
+ const textEl = nodeG
891
+ .append('text')
892
+ .attr('x', isBullet ? -ln.width / 2 + 6 : 0)
893
+ .attr('y', descStartY + li * descLineH)
894
+ .attr('text-anchor', isBullet ? 'start' : 'middle')
895
+ .attr('dominant-baseline', 'central')
896
+ .attr('font-size', DESC_FONT_SIZE)
897
+ .attr('fill', palette.textMuted);
898
+ renderInlineText(textEl, lineText, palette, DESC_FONT_SIZE);
899
+ }
900
+
901
+ // Tooltip when truncated
902
+ if (truncated) {
903
+ const fullText = desc.join(' ');
904
+ const tooltipText =
905
+ fullText.length > 200 ? fullText.slice(0, 199) + '\u2026' : fullText;
906
+ nodeG.append('title').text(tooltipText);
787
907
  }
788
908
  } else {
789
- const fitted = fitTextToNode(node.label, ln.width - 16, ln.height);
909
+ // Compact label use same split-first algorithm (camelCase, no hard-break)
910
+ // 16px vertical padding (8 top + 8 bottom) to keep text off borders
911
+ const maxLabelLines = Math.max(
912
+ 2,
913
+ Math.floor((ln.height - 16) / (MIN_NODE_FONT_SIZE * 1.3))
914
+ );
915
+ const fitted = fitLabelToHeader(node.label, ln.width, maxLabelLines);
790
916
  const lineH = fitted.fontSize * 1.3;
791
917
  const totalH = fitted.lines.length * lineH;
792
918
  for (let li = 0; li < fitted.lines.length; li++) {
@@ -805,13 +931,46 @@ export function renderBoxesAndLines(
805
931
  }
806
932
 
807
933
  // ── Render legend ──────────────────────────────────────
808
- if (parsed.tagGroups.length > 0) {
934
+ const hasDescriptions = parsed.nodes.some(
935
+ (n) => n.description && n.description.length > 0
936
+ );
937
+ const hasLegend = parsed.tagGroups.length > 0 || hasDescriptions;
938
+
939
+ if (hasLegend) {
940
+ // Build controls group for description toggle
941
+ let controlsGroup: { toggles: ControlsGroupToggle[] } | undefined;
942
+ if (hasDescriptions && onToggleDescriptions) {
943
+ controlsGroup = {
944
+ toggles: [
945
+ {
946
+ id: 'descriptions',
947
+ type: 'toggle',
948
+ label: 'Descriptions',
949
+ active: !hideDescriptions,
950
+ onToggle: () => {},
951
+ },
952
+ ],
953
+ };
954
+ }
955
+
809
956
  const legendConfig: LegendConfig = {
810
957
  groups: parsed.tagGroups,
811
958
  position: { placement: 'top-center', titleRelation: 'below-title' },
812
959
  mode: 'fixed',
960
+ controlsGroup,
961
+ };
962
+ const legendState: LegendState = {
963
+ activeGroup,
964
+ controlsExpanded,
965
+ };
966
+ const legendCallbacks: LegendCallbacks = {
967
+ onControlsExpand: onToggleControlsExpand,
968
+ onControlsToggle: (toggleId, active) => {
969
+ if (toggleId === 'descriptions' && onToggleDescriptions) {
970
+ onToggleDescriptions(active);
971
+ }
972
+ },
813
973
  };
814
- const legendState: LegendState = { activeGroup };
815
974
  const legendG = svg
816
975
  .append('g')
817
976
  .attr('transform', `translate(0,${titleOffset + 4})`);
@@ -821,7 +980,7 @@ export function renderBoxesAndLines(
821
980
  legendState,
822
981
  palette,
823
982
  isDark,
824
- undefined,
983
+ legendCallbacks,
825
984
  width
826
985
  );
827
986
  legendG.selectAll('[data-legend-group]').classed('bl-legend-group', true);
@@ -5,7 +5,7 @@ export interface BLNode {
5
5
  label: string;
6
6
  lineNumber: number;
7
7
  metadata: Record<string, string>;
8
- description?: string;
8
+ description?: string[];
9
9
  }
10
10
 
11
11
  export interface BLEdge {
package/src/c4/layout.ts CHANGED
@@ -689,7 +689,7 @@ export function computeC4NodeDimensions(
689
689
  // (no type label — containers are the default in container view)
690
690
  let height = CARD_V_PAD + NAME_HEIGHT;
691
691
 
692
- const desc = el.metadata['description'];
692
+ const desc = el.description?.join('\n');
693
693
  if (desc) {
694
694
  const contentWidth = width - CARD_H_PAD * 2;
695
695
  const lines = wrapText(desc, contentWidth, DESC_CHAR_WIDTH);
@@ -719,7 +719,7 @@ export function computeC4NodeDimensions(
719
719
  // Context card layout: type + name | divider | description
720
720
  let height = CARD_V_PAD + TYPE_LABEL_HEIGHT + DIVIDER_GAP + NAME_HEIGHT;
721
721
 
722
- const desc = el.metadata['description'];
722
+ const desc = el.description?.join('\n');
723
723
  if (desc) {
724
724
  const contentWidth = width - CARD_H_PAD * 2;
725
725
  const lines = wrapText(desc, contentWidth, DESC_CHAR_WIDTH);
@@ -887,7 +887,7 @@ export function layoutC4Context(
887
887
  id: el.name,
888
888
  name: el.name,
889
889
  type: el.type as 'person' | 'system',
890
- description: el.metadata['description'],
890
+ description: el.description?.join('\n'),
891
891
  metadata: el.metadata,
892
892
  lineNumber: el.lineNumber,
893
893
  color,
@@ -1241,7 +1241,7 @@ export function layoutC4Containers(
1241
1241
  id: el.name,
1242
1242
  name: el.name,
1243
1243
  type: 'container',
1244
- description: el.metadata['description'],
1244
+ description: el.description?.join('\n'),
1245
1245
  metadata: el.metadata,
1246
1246
  lineNumber: el.lineNumber,
1247
1247
  color,
@@ -1267,7 +1267,7 @@ export function layoutC4Containers(
1267
1267
  id: el.name,
1268
1268
  name: el.name,
1269
1269
  type: el.type as 'person' | 'system',
1270
- description: el.metadata['description'],
1270
+ description: el.description?.join('\n'),
1271
1271
  metadata: el.metadata,
1272
1272
  lineNumber: el.lineNumber,
1273
1273
  color,
@@ -1787,7 +1787,7 @@ export function layoutC4Components(
1787
1787
  id: el.name,
1788
1788
  name: el.name,
1789
1789
  type: 'component',
1790
- description: el.metadata['description'],
1790
+ description: el.description?.join('\n'),
1791
1791
  metadata: el.metadata,
1792
1792
  lineNumber: el.lineNumber,
1793
1793
  color,
@@ -1813,7 +1813,7 @@ export function layoutC4Components(
1813
1813
  id: el.name,
1814
1814
  name: el.name,
1815
1815
  type: el.type as 'person' | 'system' | 'container',
1816
- description: el.metadata['description'],
1816
+ description: el.description?.join('\n'),
1817
1817
  metadata: el.metadata,
1818
1818
  lineNumber: el.lineNumber,
1819
1819
  color,
@@ -2236,7 +2236,7 @@ export function layoutC4Deployment(
2236
2236
  id: r.element.name,
2237
2237
  name: r.element.name,
2238
2238
  type: 'container',
2239
- description: r.element.metadata['description'],
2239
+ description: r.element.description?.join('\n'),
2240
2240
  metadata: r.element.metadata,
2241
2241
  lineNumber: r.element.lineNumber,
2242
2242
  color,
package/src/c4/parser.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  parseFirstLine,
21
21
  OPTION_NOCOLON_RE,
22
22
  } from '../utils/parsing';
23
+ import { tryStripDescriptionKeyword } from '../utils/description-helpers';
23
24
  import type {
24
25
  ParsedC4,
25
26
  C4Element,
@@ -695,11 +696,20 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
695
696
  explicitShape ??
696
697
  inferC4Shape(namePart, metadata.tech ?? metadata.technology);
697
698
 
699
+ // Extract description from pipe metadata into dedicated field
700
+ let isADescription: string[] | undefined;
701
+ if ('description' in metadata) {
702
+ const descVal = metadata['description'].trim();
703
+ if (descVal) isADescription = [descVal];
704
+ delete metadata['description'];
705
+ }
706
+
698
707
  const element: C4Element = {
699
708
  name: namePart,
700
709
  type: elementType,
701
710
  shape,
702
711
  metadata,
712
+ description: isADescription,
703
713
  children: [],
704
714
  groups: [],
705
715
  relationships: [],
@@ -762,11 +772,20 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
762
772
  explicitShape ??
763
773
  inferC4Shape(namePart, metadata.tech ?? metadata.technology);
764
774
 
775
+ // Extract description from pipe metadata into dedicated field
776
+ let prefixDescription: string[] | undefined;
777
+ if ('description' in metadata) {
778
+ const descVal = metadata['description'].trim();
779
+ if (descVal) prefixDescription = [descVal];
780
+ delete metadata['description'];
781
+ }
782
+
765
783
  const element: C4Element = {
766
784
  name: namePart,
767
785
  type: elementType,
768
786
  shape,
769
787
  metadata,
788
+ description: prefixDescription,
770
789
  children: [],
771
790
  groups: [],
772
791
  relationships: [],
@@ -805,6 +824,15 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
805
824
 
806
825
  const key = aliasMap.get(rawKey) ?? rawKey;
807
826
  const value = metadataMatch[2].trim();
827
+
828
+ // Extract description into dedicated field
829
+ if (key === 'description') {
830
+ if (!parentEntry.element.description)
831
+ parentEntry.element.description = [];
832
+ parentEntry.element.description.push(value);
833
+ continue;
834
+ }
835
+
808
836
  parentEntry.element.metadata[key] = value;
809
837
  continue;
810
838
  }
@@ -821,9 +849,14 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
821
849
  }
822
850
  }
823
851
 
824
- // If inside a parent, could be an unkeyed description or misc text — ignore gracefully
852
+ // If inside a parent, try as keyword-based or keywordless description
825
853
  const parent = findParentElement(indent, stack);
826
- if (!parent) {
854
+ if (parent) {
855
+ const descResult = tryStripDescriptionKeyword(trimmed);
856
+ const descText = descResult.isKeyword ? descResult.text : trimmed;
857
+ if (!parent.element.description) parent.element.description = [];
858
+ parent.element.description.push(descText);
859
+ } else {
827
860
  pushError(lineNumber, `Unexpected content: "${trimmed}"`);
828
861
  }
829
862
  }
@@ -8,6 +8,7 @@ import { FONT_FAMILY } from '../fonts';
8
8
  import type { PaletteColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import { renderInlineText } from '../utils/inline-markdown';
11
+ import { preprocessDescriptionLine } from '../utils/description-helpers';
11
12
  import type { ParsedC4 } from './types';
12
13
  import type { C4LayoutResult, C4LayoutEdge } from './layout';
13
14
  import { parseC4 } from './parser';
@@ -572,7 +573,12 @@ export function renderC4Context(
572
573
  .attr('dominant-baseline', 'central')
573
574
  .attr('fill', palette.textMuted)
574
575
  .attr('font-size', DESC_FONT_SIZE);
575
- renderInlineText(textEl, line, palette, DESC_FONT_SIZE);
576
+ renderInlineText(
577
+ textEl,
578
+ preprocessDescriptionLine(line),
579
+ palette,
580
+ DESC_FONT_SIZE
581
+ );
576
582
  yPos += DESC_LINE_HEIGHT;
577
583
  }
578
584
  }
@@ -1641,7 +1647,12 @@ export function renderC4Containers(
1641
1647
  .attr('dominant-baseline', 'central')
1642
1648
  .attr('fill', palette.textMuted)
1643
1649
  .attr('font-size', DESC_FONT_SIZE);
1644
- renderInlineText(textEl, line, palette, DESC_FONT_SIZE);
1650
+ renderInlineText(
1651
+ textEl,
1652
+ preprocessDescriptionLine(line),
1653
+ palette,
1654
+ DESC_FONT_SIZE
1655
+ );
1645
1656
  yPos += DESC_LINE_HEIGHT;
1646
1657
  }
1647
1658
  }
@@ -1720,7 +1731,12 @@ export function renderC4Containers(
1720
1731
  .attr('dominant-baseline', 'central')
1721
1732
  .attr('fill', palette.textMuted)
1722
1733
  .attr('font-size', DESC_FONT_SIZE);
1723
- renderInlineText(textEl, line, palette, DESC_FONT_SIZE);
1734
+ renderInlineText(
1735
+ textEl,
1736
+ preprocessDescriptionLine(line),
1737
+ palette,
1738
+ DESC_FONT_SIZE
1739
+ );
1724
1740
  yPos += DESC_LINE_HEIGHT;
1725
1741
  }
1726
1742
  }