@diagrammo/dgmo 0.8.20 → 0.8.22

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 (110) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +142 -90
  4. package/dist/editor.cjs +30 -4
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +30 -4
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +25 -3
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +25 -3
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +21201 -12886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +646 -89
  15. package/dist/index.d.ts +646 -89
  16. package/dist/index.js +21178 -12889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-mindmap.md +198 -0
  19. package/docs/guide/chart-sequence.md +23 -1
  20. package/docs/guide/chart-sitemap.md +18 -1
  21. package/docs/guide/chart-tech-radar.md +219 -0
  22. package/docs/guide/chart-wireframe.md +100 -0
  23. package/docs/guide/index.md +8 -0
  24. package/docs/guide/registry.json +1 -0
  25. package/docs/language-reference.md +249 -4
  26. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  27. package/gallery/fixtures/c4-full.dgmo +2 -2
  28. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  29. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  30. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  31. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  32. package/gallery/fixtures/gantt-full.dgmo +2 -2
  33. package/gallery/fixtures/gantt.dgmo +2 -2
  34. package/gallery/fixtures/infra-full.dgmo +2 -2
  35. package/gallery/fixtures/infra.dgmo +1 -1
  36. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  37. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  38. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  39. package/gallery/fixtures/tech-radar.dgmo +36 -0
  40. package/gallery/fixtures/timeline.dgmo +1 -1
  41. package/package.json +1 -1
  42. package/src/boxes-and-lines/collapse.ts +21 -3
  43. package/src/boxes-and-lines/layout.ts +360 -42
  44. package/src/boxes-and-lines/parser.ts +94 -11
  45. package/src/boxes-and-lines/renderer.ts +371 -114
  46. package/src/boxes-and-lines/types.ts +2 -1
  47. package/src/c4/layout.ts +8 -8
  48. package/src/c4/parser.ts +35 -2
  49. package/src/c4/renderer.ts +19 -3
  50. package/src/c4/types.ts +1 -0
  51. package/src/chart.ts +14 -7
  52. package/src/completion.ts +253 -0
  53. package/src/cycle/layout.ts +732 -0
  54. package/src/cycle/parser.ts +352 -0
  55. package/src/cycle/renderer.ts +539 -0
  56. package/src/cycle/types.ts +77 -0
  57. package/src/d3.ts +240 -40
  58. package/src/dgmo-router.ts +15 -0
  59. package/src/echarts.ts +7 -4
  60. package/src/editor/dgmo.grammar +5 -1
  61. package/src/editor/dgmo.grammar.js +1 -1
  62. package/src/editor/keywords.ts +26 -0
  63. package/src/gantt/parser.ts +2 -8
  64. package/src/graph/flowchart-parser.ts +15 -21
  65. package/src/graph/layout.ts +73 -9
  66. package/src/graph/state-collapse.ts +78 -0
  67. package/src/graph/state-parser.ts +5 -10
  68. package/src/graph/state-renderer.ts +139 -34
  69. package/src/index.ts +78 -0
  70. package/src/infra/layout.ts +218 -74
  71. package/src/infra/parser.ts +30 -6
  72. package/src/infra/renderer.ts +14 -8
  73. package/src/infra/types.ts +10 -3
  74. package/src/journey-map/layout.ts +386 -0
  75. package/src/journey-map/parser.ts +540 -0
  76. package/src/journey-map/renderer.ts +1456 -0
  77. package/src/journey-map/types.ts +47 -0
  78. package/src/kanban/parser.ts +3 -10
  79. package/src/kanban/renderer.ts +325 -63
  80. package/src/mindmap/collapse.ts +88 -0
  81. package/src/mindmap/layout.ts +605 -0
  82. package/src/mindmap/parser.ts +373 -0
  83. package/src/mindmap/renderer.ts +544 -0
  84. package/src/mindmap/text-wrap.ts +217 -0
  85. package/src/mindmap/types.ts +55 -0
  86. package/src/org/parser.ts +2 -6
  87. package/src/render.ts +18 -21
  88. package/src/sequence/renderer.ts +273 -56
  89. package/src/sharing.ts +3 -0
  90. package/src/sitemap/layout.ts +56 -18
  91. package/src/sitemap/parser.ts +26 -17
  92. package/src/sitemap/renderer.ts +34 -0
  93. package/src/sitemap/types.ts +1 -0
  94. package/src/tech-radar/index.ts +14 -0
  95. package/src/tech-radar/interactive.ts +1058 -0
  96. package/src/tech-radar/layout.ts +190 -0
  97. package/src/tech-radar/parser.ts +385 -0
  98. package/src/tech-radar/renderer.ts +1159 -0
  99. package/src/tech-radar/shared.ts +187 -0
  100. package/src/tech-radar/types.ts +81 -0
  101. package/src/utils/description-helpers.ts +33 -0
  102. package/src/utils/export-container.ts +3 -2
  103. package/src/utils/legend-d3.ts +1 -0
  104. package/src/utils/legend-layout.ts +5 -3
  105. package/src/utils/parsing.ts +48 -7
  106. package/src/utils/tag-groups.ts +46 -60
  107. package/src/wireframe/layout.ts +460 -0
  108. package/src/wireframe/parser.ts +956 -0
  109. package/src/wireframe/renderer.ts +1293 -0
  110. package/src/wireframe/types.ts +110 -0
@@ -0,0 +1,47 @@
1
+ import type { DgmoError } from '../diagnostics';
2
+ import type { TagGroup } from '../utils/tag-groups';
3
+
4
+ export interface JourneyMapAnnotation {
5
+ type: 'pain' | 'opportunity' | 'thought';
6
+ text: string;
7
+ }
8
+
9
+ export interface JourneyMapStep {
10
+ id: string;
11
+ title: string;
12
+ score?: number;
13
+ emotionLabel?: string;
14
+ tags: Record<string, string>;
15
+ annotations: JourneyMapAnnotation[];
16
+ description?: string;
17
+ lineNumber: number;
18
+ endLineNumber: number;
19
+ }
20
+
21
+ export interface JourneyMapPhase {
22
+ id: string;
23
+ name: string;
24
+ steps: JourneyMapStep[];
25
+ lineNumber: number;
26
+ }
27
+
28
+ export interface JourneyMapPersona {
29
+ name: string;
30
+ description?: string;
31
+ color?: string;
32
+ lineNumber: number;
33
+ }
34
+
35
+ export interface ParsedJourneyMap {
36
+ type: 'journey-map';
37
+ title?: string;
38
+ titleLineNumber?: number;
39
+ persona?: JourneyMapPersona;
40
+ phases: JourneyMapPhase[];
41
+ /** Flat-mode steps (not inside any phase) */
42
+ steps: JourneyMapStep[];
43
+ tagGroups: TagGroup[];
44
+ options: Record<string, string>;
45
+ diagnostics: DgmoError[];
46
+ error: string | null;
47
+ }
@@ -387,8 +387,8 @@ function parseCardLine(
387
387
  lineNumber: number,
388
388
  counter: number,
389
389
  aliasMap: Map<string, string>,
390
- palette?: PaletteColors,
391
- diagnostics?: import('../diagnostics').DgmoError[]
390
+ _palette?: PaletteColors,
391
+ _diagnostics?: import('../diagnostics').DgmoError[]
392
392
  ): KanbanCard {
393
393
  // Split on first pipe: Title | tag: value, tag: value
394
394
  const pipeIdx = trimmed.indexOf('|');
@@ -402,13 +402,7 @@ function parseCardLine(
402
402
  rawTitle = trimmed;
403
403
  }
404
404
 
405
- // Extract optional color suffix from title
406
- const { label: title, color } = extractColor(
407
- rawTitle,
408
- palette,
409
- diagnostics,
410
- lineNumber
411
- );
405
+ const title = rawTitle;
412
406
 
413
407
  // Parse tags: comma-separated key: value pairs
414
408
  const tags: Record<string, string> = {};
@@ -431,6 +425,5 @@ function parseCardLine(
431
425
  details: [],
432
426
  lineNumber,
433
427
  endLineNumber: lineNumber,
434
- color,
435
428
  };
436
429
  }
@@ -34,6 +34,9 @@ interface KanbanInteractiveOptions {
34
34
  activeTagGroup?: string | null;
35
35
  currentSwimlaneGroup?: string | null;
36
36
  onSwimlaneChange?: (group: string | null) => void;
37
+ collapsedLanes?: Set<string>;
38
+ collapsedColumns?: Set<string>;
39
+ compactMeta?: boolean;
37
40
  }
38
41
 
39
42
  // ============================================================
@@ -61,6 +64,8 @@ const CARD_META_FONT_SIZE = 10;
61
64
  const WIP_FONT_SIZE = 10;
62
65
  const COLUMN_RADIUS = 8;
63
66
  const COLUMN_HEADER_RADIUS = 8;
67
+ const COLLAPSED_COLUMN_WIDTH = 40;
68
+ const COLLAPSED_LANE_HEIGHT = 26;
64
69
 
65
70
  // ============================================================
66
71
  // Tag color resolution
@@ -68,10 +73,12 @@ const COLUMN_HEADER_RADIUS = 8;
68
73
 
69
74
  function resolveCardTagMeta(
70
75
  card: KanbanCard,
71
- tagGroups: KanbanTagGroup[]
76
+ tagGroups: KanbanTagGroup[],
77
+ hiddenMetaGroups?: string[]
72
78
  ): { label: string; value: string; color?: string }[] {
73
79
  const meta: { label: string; value: string; color?: string }[] = [];
74
80
  for (const group of tagGroups) {
81
+ if (hiddenMetaGroups?.includes(group.name.toLowerCase())) continue;
75
82
  const tagValue = card.tags[group.name.toLowerCase()];
76
83
  const value = tagValue ?? group.defaultValue;
77
84
  if (!value) continue;
@@ -125,7 +132,9 @@ interface CardLayout {
125
132
 
126
133
  function computeLayout(
127
134
  parsed: ParsedKanban,
128
- _palette: PaletteColors
135
+ _palette: PaletteColors,
136
+ collapsedColumns?: Set<string>,
137
+ hiddenMetaGroups?: string[]
129
138
  ): { columns: ColumnLayout[]; totalWidth: number; totalHeight: number } {
130
139
  // Title row
131
140
  const headerHeight = parsed.title ? TITLE_HEIGHT + 8 : 0;
@@ -141,6 +150,20 @@ function computeLayout(
141
150
  const visibleColumns = parsed.columns.filter((c) => !isArchiveColumn(c.name));
142
151
 
143
152
  for (const col of visibleColumns) {
153
+ const isCollapsed = collapsedColumns?.has(col.id) ?? false;
154
+
155
+ if (isCollapsed) {
156
+ columnLayouts.push({
157
+ x: 0,
158
+ y: startY,
159
+ width: COLLAPSED_COLUMN_WIDTH,
160
+ height: 0, // normalized below
161
+ column: col,
162
+ cardLayouts: [],
163
+ });
164
+ continue;
165
+ }
166
+
144
167
  // Compute card heights and column width
145
168
  let maxCardTextWidth = col.name.length * (COLUMN_HEADER_FONT_SIZE * 0.65);
146
169
 
@@ -155,7 +178,11 @@ function computeLayout(
155
178
  );
156
179
 
157
180
  // Count metadata rows (tag groups + detail lines)
158
- const tagMeta = resolveCardTagMeta(card, parsed.tagGroups);
181
+ const tagMeta = resolveCardTagMeta(
182
+ card,
183
+ parsed.tagGroups,
184
+ hiddenMetaGroups
185
+ );
159
186
  const metaCount = tagMeta.length + card.details.length;
160
187
  const metaHeight =
161
188
  metaCount > 0
@@ -236,6 +263,9 @@ export function renderKanban(
236
263
  const exportDims = options?.exportDims;
237
264
  const activeTagGroup = options?.activeTagGroup ?? null;
238
265
  const onSwimlaneChange = options?.onSwimlaneChange;
266
+ const collapsedLanes = options?.collapsedLanes;
267
+ const collapsedColumns = options?.collapsedColumns;
268
+ const compactMeta = options?.compactMeta ?? false;
239
269
  // Resolve current swimlane group: must match an existing tag group, else ignore.
240
270
  const requestedSwimlane = options?.currentSwimlaneGroup ?? null;
241
271
  const swimlaneGroup = requestedSwimlane
@@ -244,7 +274,20 @@ export function renderKanban(
244
274
  ) ?? null)
245
275
  : null;
246
276
 
247
- const layout = computeLayout(parsed, palette);
277
+ // Compute hidden meta groups for compact mode
278
+ const hiddenMetaGroups: string[] = [];
279
+ if (compactMeta) {
280
+ if (activeTagGroup) hiddenMetaGroups.push(activeTagGroup.toLowerCase());
281
+ if (requestedSwimlane)
282
+ hiddenMetaGroups.push(requestedSwimlane.toLowerCase());
283
+ }
284
+
285
+ const layout = computeLayout(
286
+ parsed,
287
+ palette,
288
+ collapsedColumns,
289
+ hiddenMetaGroups
290
+ );
248
291
 
249
292
  const width = exportDims?.width ?? layout.totalWidth;
250
293
  const height = exportDims?.height ?? layout.totalHeight;
@@ -347,7 +390,10 @@ export function renderKanban(
347
390
  swimlaneGroup,
348
391
  palette,
349
392
  isDark,
350
- activeTagGroup
393
+ activeTagGroup,
394
+ collapsedLanes,
395
+ collapsedColumns,
396
+ hiddenMetaGroups
351
397
  );
352
398
  return;
353
399
  }
@@ -364,6 +410,7 @@ export function renderKanban(
364
410
 
365
411
  for (const colLayout of layout.columns) {
366
412
  const col = colLayout.column;
413
+ const isColCollapsed = collapsedColumns?.has(col.id) ?? false;
367
414
  const g = svg
368
415
  .append('g')
369
416
  .attr('class', 'kanban-column')
@@ -377,6 +424,51 @@ export function renderKanban(
377
424
  ? mix(col.color, palette.bg, 25)
378
425
  : defaultColHeaderBg;
379
426
 
427
+ if (isColCollapsed) {
428
+ // Collapsed column: narrow strip with count + vertical name
429
+ g.append('rect')
430
+ .attr('x', colLayout.x)
431
+ .attr('y', colLayout.y)
432
+ .attr('width', COLLAPSED_COLUMN_WIDTH)
433
+ .attr('height', colLayout.height)
434
+ .attr('rx', COLUMN_RADIUS)
435
+ .attr('fill', thisColBg);
436
+
437
+ g.append('rect')
438
+ .attr('x', colLayout.x)
439
+ .attr('y', colLayout.y)
440
+ .attr('width', COLLAPSED_COLUMN_WIDTH)
441
+ .attr('height', COLUMN_HEADER_HEIGHT)
442
+ .attr('rx', COLUMN_HEADER_RADIUS)
443
+ .attr('fill', thisColHeaderBg);
444
+
445
+ // Card count
446
+ g.append('text')
447
+ .attr('x', colLayout.x + COLLAPSED_COLUMN_WIDTH / 2)
448
+ .attr(
449
+ 'y',
450
+ colLayout.y + COLUMN_HEADER_HEIGHT / 2 + WIP_FONT_SIZE / 2 - 1
451
+ )
452
+ .attr('font-size', WIP_FONT_SIZE)
453
+ .attr('font-weight', 'bold')
454
+ .attr('fill', palette.textMuted)
455
+ .attr('text-anchor', 'middle')
456
+ .text(String(col.cards.length));
457
+
458
+ // Vertical column name
459
+ g.append('text')
460
+ .attr('x', colLayout.x + COLLAPSED_COLUMN_WIDTH / 2)
461
+ .attr('y', colLayout.y + COLUMN_HEADER_HEIGHT + COLUMN_PADDING)
462
+ .attr('font-size', CARD_TITLE_FONT_SIZE)
463
+ .attr('font-weight', 'bold')
464
+ .attr('fill', palette.text)
465
+ .attr('text-anchor', 'middle')
466
+ .attr('writing-mode', 'tb')
467
+ .text(col.name);
468
+
469
+ continue;
470
+ }
471
+
380
472
  // Column background
381
473
  g.append('rect')
382
474
  .attr('x', colLayout.x)
@@ -407,17 +499,21 @@ export function renderKanban(
407
499
  .attr('fill', palette.text)
408
500
  .text(col.name);
409
501
 
410
- // WIP limit badge
411
- if (col.wipLimit != null) {
412
- const wipExceeded = col.cards.length > col.wipLimit;
413
- const badgeText = `${col.cards.length}/${col.wipLimit}`;
414
- const nameWidth = col.name.length * COLUMN_HEADER_FONT_SIZE * 0.65;
502
+ // Card count / WIP limit badge (right-aligned)
503
+ {
504
+ const wipExceeded =
505
+ col.wipLimit != null && col.cards.length > col.wipLimit;
506
+ const badgeText =
507
+ col.wipLimit != null
508
+ ? `${col.cards.length}/${col.wipLimit}`
509
+ : String(col.cards.length);
415
510
  g.append('text')
416
- .attr('x', colLayout.x + COLUMN_PADDING + nameWidth + 8)
511
+ .attr('x', colLayout.x + colLayout.width - COLUMN_PADDING)
417
512
  .attr(
418
513
  'y',
419
514
  colLayout.y + COLUMN_HEADER_HEIGHT / 2 + WIP_FONT_SIZE / 2 - 1
420
515
  )
516
+ .attr('text-anchor', 'end')
421
517
  .attr('font-size', WIP_FONT_SIZE)
422
518
  .attr('fill', wipExceeded ? palette.colors.red : palette.textMuted)
423
519
  .attr('font-weight', wipExceeded ? 'bold' : 'normal')
@@ -432,7 +528,11 @@ export function renderKanban(
432
528
  parsed.tagGroups,
433
529
  activeTagGroup ?? null
434
530
  );
435
- const tagMeta = resolveCardTagMeta(card, parsed.tagGroups);
531
+ const tagMeta = resolveCardTagMeta(
532
+ card,
533
+ parsed.tagGroups,
534
+ hiddenMetaGroups
535
+ );
436
536
  const hasMeta = tagMeta.length > 0 || card.details.length > 0;
437
537
 
438
538
  // Org-chart-style fill: 15% blend of color into bg
@@ -674,8 +774,12 @@ interface SwimlaneBoardLayout {
674
774
  startY: number;
675
775
  }
676
776
 
677
- function computeCardHeight(card: KanbanCard, tagGroups: KanbanTagGroup[]) {
678
- const tagMeta = resolveCardTagMeta(card, tagGroups);
777
+ function computeCardHeight(
778
+ card: KanbanCard,
779
+ tagGroups: KanbanTagGroup[],
780
+ hiddenMetaGroups?: string[]
781
+ ) {
782
+ const tagMeta = resolveCardTagMeta(card, tagGroups, hiddenMetaGroups);
679
783
  const metaCount = tagMeta.length + card.details.length;
680
784
  const metaHeight =
681
785
  metaCount > 0
@@ -690,7 +794,10 @@ function computeCardHeight(card: KanbanCard, tagGroups: KanbanTagGroup[]) {
690
794
  function computeSwimlaneLayout(
691
795
  parsed: ParsedKanban,
692
796
  buckets: SwimlaneBucket[],
693
- baseLayout: { columns: ColumnLayout[] }
797
+ baseLayout: { columns: ColumnLayout[] },
798
+ collapsedLanes?: Set<string>,
799
+ collapsedColumns?: Set<string>,
800
+ hiddenMetaGroups?: string[]
694
801
  ): SwimlaneBoardLayout {
695
802
  const headerHeight = parsed.title ? TITLE_HEIGHT + 8 : 0;
696
803
  const startY = DIAGRAM_PADDING + headerHeight;
@@ -699,8 +806,10 @@ function computeSwimlaneLayout(
699
806
  const columnXs: SwimlaneBoardLayout['columnXs'] = [];
700
807
  let currentX = DIAGRAM_PADDING + LANE_HEADER_WIDTH;
701
808
  for (const col of baseLayout.columns) {
702
- columnXs.push({ column: col.column, x: currentX, width: col.width });
703
- currentX += col.width + COLUMN_GAP;
809
+ const isColCollapsed = collapsedColumns?.has(col.column.id) ?? false;
810
+ const w = isColCollapsed ? COLLAPSED_COLUMN_WIDTH : col.width;
811
+ columnXs.push({ column: col.column, x: currentX, width: w });
812
+ currentX += w + COLUMN_GAP;
704
813
  }
705
814
  const totalWidth = currentX - COLUMN_GAP + DIAGRAM_PADDING;
706
815
 
@@ -710,15 +819,36 @@ function computeSwimlaneLayout(
710
819
  const minCellH = CARD_HEADER_HEIGHT + CARD_PADDING_Y + CARD_GAP;
711
820
 
712
821
  for (const bucket of buckets) {
822
+ const isLaneCollapsed = collapsedLanes?.has(bucket.laneName) ?? false;
823
+
824
+ if (isLaneCollapsed) {
825
+ // Collapsed lane: minimal height, no card layouts
826
+ const cells: SwimlaneCellLayout[] = columnXs.map((colInfo) => ({
827
+ column: colInfo.column,
828
+ cards: bucket.cellsByColumn[colInfo.column.id] ?? [],
829
+ cardLayouts: [],
830
+ }));
831
+ lanes.push({ bucket, y: laneY, height: COLLAPSED_LANE_HEIGHT, cells });
832
+ laneY += COLLAPSED_LANE_HEIGHT + LANE_GAP;
833
+ continue;
834
+ }
835
+
713
836
  let maxCellH = minCellH;
714
837
  const cellsTmp: { column: KanbanColumn; cards: KanbanCard[]; h: number }[] =
715
838
  [];
716
839
 
717
840
  for (const colInfo of columnXs) {
841
+ const isColCollapsed = collapsedColumns?.has(colInfo.column.id) ?? false;
718
842
  const cards = bucket.cellsByColumn[colInfo.column.id] ?? [];
843
+ if (isColCollapsed) {
844
+ // Collapsed column cells get minimal height
845
+ cellsTmp.push({ column: colInfo.column, cards, h: 0 });
846
+ continue;
847
+ }
719
848
  let h = 0;
720
849
  for (const c of cards) {
721
- h += computeCardHeight(c, parsed.tagGroups) + CARD_GAP;
850
+ h +=
851
+ computeCardHeight(c, parsed.tagGroups, hiddenMetaGroups) + CARD_GAP;
722
852
  }
723
853
  h = Math.max(h - (cards.length > 0 ? CARD_GAP : 0), 0);
724
854
  cellsTmp.push({ column: colInfo.column, cards, h });
@@ -730,10 +860,14 @@ function computeSwimlaneLayout(
730
860
  // Build cell layouts with card x/y offsets relative to (cell.x, laneY)
731
861
  const cells: SwimlaneCellLayout[] = cellsTmp.map((tmp, i) => {
732
862
  const colInfo = columnXs[i]!;
863
+ const isColCollapsed = collapsedColumns?.has(colInfo.column.id) ?? false;
864
+ if (isColCollapsed) {
865
+ return { column: tmp.column, cards: tmp.cards, cardLayouts: [] };
866
+ }
733
867
  const cardLayouts: CardLayout[] = [];
734
868
  let cy = 0;
735
869
  for (const card of tmp.cards) {
736
- const ch = computeCardHeight(card, parsed.tagGroups);
870
+ const ch = computeCardHeight(card, parsed.tagGroups, hiddenMetaGroups);
737
871
  cardLayouts.push({
738
872
  x: colInfo.x + COLUMN_PADDING,
739
873
  y: laneY + cy,
@@ -766,11 +900,21 @@ function renderSwimlaneBoard(
766
900
  swimlaneGroup: KanbanTagGroup,
767
901
  palette: PaletteColors,
768
902
  isDark: boolean,
769
- activeTagGroup: string | null
903
+ activeTagGroup: string | null,
904
+ collapsedLanes?: Set<string>,
905
+ collapsedColumns?: Set<string>,
906
+ hiddenMetaGroups?: string[]
770
907
  ): void {
771
908
  const visibleColumns = parsed.columns.filter((c) => !isArchiveColumn(c.name));
772
909
  const buckets = bucketCardsBySwimlane(visibleColumns, swimlaneGroup);
773
- const grid = computeSwimlaneLayout(parsed, buckets, baseLayout);
910
+ const grid = computeSwimlaneLayout(
911
+ parsed,
912
+ buckets,
913
+ baseLayout,
914
+ collapsedLanes,
915
+ collapsedColumns,
916
+ hiddenMetaGroups
917
+ );
774
918
 
775
919
  // Resize the svg to fit the grid (only when not using exportDims).
776
920
  const currentW = parseFloat(svg.attr('width') || '0');
@@ -789,6 +933,7 @@ function renderSwimlaneBoard(
789
933
  // Column header row spanning all lanes
790
934
  for (const colInfo of grid.columnXs) {
791
935
  const col = colInfo.column;
936
+ const isColCollapsed = collapsedColumns?.has(col.id) ?? false;
792
937
  const headerG = svg
793
938
  .append('g')
794
939
  .attr('class', 'kanban-column kanban-column-header')
@@ -808,21 +953,61 @@ function renderSwimlaneBoard(
808
953
  .attr('rx', COLUMN_HEADER_RADIUS)
809
954
  .attr('fill', colHeaderBg);
810
955
 
811
- headerG
812
- .append('text')
813
- .attr('x', colInfo.x + COLUMN_PADDING)
814
- .attr(
815
- 'y',
816
- grid.startY + COLUMN_HEADER_HEIGHT / 2 + COLUMN_HEADER_FONT_SIZE / 2 - 2
817
- )
818
- .attr('font-size', COLUMN_HEADER_FONT_SIZE)
819
- .attr('font-weight', 'bold')
820
- .attr('fill', palette.text)
821
- .text(col.name);
956
+ if (isColCollapsed) {
957
+ // Collapsed: show count + vertical name below header
958
+ headerG
959
+ .append('text')
960
+ .attr('x', colInfo.x + colInfo.width / 2)
961
+ .attr(
962
+ 'y',
963
+ grid.startY + COLUMN_HEADER_HEIGHT / 2 + WIP_FONT_SIZE / 2 - 1
964
+ )
965
+ .attr('font-size', WIP_FONT_SIZE)
966
+ .attr('font-weight', 'bold')
967
+ .attr('fill', palette.textMuted)
968
+ .attr('text-anchor', 'middle')
969
+ .text(String(col.cards.length));
970
+ } else {
971
+ headerG
972
+ .append('text')
973
+ .attr('x', colInfo.x + COLUMN_PADDING)
974
+ .attr(
975
+ 'y',
976
+ grid.startY +
977
+ COLUMN_HEADER_HEIGHT / 2 +
978
+ COLUMN_HEADER_FONT_SIZE / 2 -
979
+ 2
980
+ )
981
+ .attr('font-size', COLUMN_HEADER_FONT_SIZE)
982
+ .attr('font-weight', 'bold')
983
+ .attr('fill', palette.text)
984
+ .text(col.name);
985
+
986
+ // Card count (right-aligned)
987
+ const wipExceeded =
988
+ col.wipLimit != null && col.cards.length > col.wipLimit;
989
+ const badgeText =
990
+ col.wipLimit != null
991
+ ? `${col.cards.length}/${col.wipLimit}`
992
+ : String(col.cards.length);
993
+ headerG
994
+ .append('text')
995
+ .attr('x', colInfo.x + colInfo.width - COLUMN_PADDING)
996
+ .attr(
997
+ 'y',
998
+ grid.startY + COLUMN_HEADER_HEIGHT / 2 + WIP_FONT_SIZE / 2 - 1
999
+ )
1000
+ .attr('text-anchor', 'end')
1001
+ .attr('font-size', WIP_FONT_SIZE)
1002
+ .attr('fill', wipExceeded ? palette.colors.red : palette.textMuted)
1003
+ .attr('font-weight', wipExceeded ? 'bold' : 'normal')
1004
+ .text(badgeText);
1005
+ }
822
1006
  }
823
1007
 
824
1008
  // Lanes
825
1009
  for (const lane of grid.lanes) {
1010
+ const isLaneCollapsed = collapsedLanes?.has(lane.bucket.laneName) ?? false;
826
1011
  const laneG = svg
827
1012
  .append('g')
828
1013
  .attr('class', 'kanban-lane')
@@ -847,8 +1032,9 @@ function renderSwimlaneBoard(
847
1032
  .attr('rx', COLUMN_RADIUS)
848
1033
  .attr('fill', defaultColBg);
849
1034
 
850
- // Lane label
1035
+ // Lane label + count
851
1036
  let labelX = 10;
1037
+ const totalCards = lane.cells.reduce((s, c) => s + c.cards.length, 0);
852
1038
  if (lane.bucket.laneColor) {
853
1039
  headerG
854
1040
  .append('circle')
@@ -858,24 +1044,66 @@ function renderSwimlaneBoard(
858
1044
  .attr('fill', lane.bucket.laneColor);
859
1045
  labelX += 14;
860
1046
  }
861
- headerG
862
- .append('text')
863
- .attr('x', labelX)
864
- .attr('y', 20)
865
- .attr('font-size', 12)
866
- .attr('font-weight', 'bold')
867
- .attr('fill', lane.bucket.isFallback ? palette.textMuted : palette.text)
868
- .text(lane.bucket.laneName);
869
1047
 
870
- // Card count
871
- const totalCards = lane.cells.reduce((s, c) => s + c.cards.length, 0);
872
- headerG
873
- .append('text')
874
- .attr('x', labelX)
875
- .attr('y', 36)
876
- .attr('font-size', 10)
877
- .attr('fill', palette.textMuted)
878
- .text(`(${totalCards})`);
1048
+ if (isLaneCollapsed) {
1049
+ // Collapsed: single line with name + count
1050
+ headerG
1051
+ .append('text')
1052
+ .attr('x', labelX)
1053
+ .attr('y', 20)
1054
+ .attr('font-size', 10)
1055
+ .attr('fill', palette.textMuted)
1056
+ .text(`${lane.bucket.laneName} (${totalCards})`);
1057
+ } else {
1058
+ // Expanded: name only (count omitted to match app view)
1059
+ headerG
1060
+ .append('text')
1061
+ .attr('x', labelX)
1062
+ .attr('y', 20)
1063
+ .attr('font-size', 12)
1064
+ .attr('font-weight', 'bold')
1065
+ .attr('fill', lane.bucket.isFallback ? palette.textMuted : palette.text)
1066
+ .text(lane.bucket.laneName);
1067
+ }
1068
+
1069
+ if (isLaneCollapsed) {
1070
+ // Render count placeholders in each cell
1071
+ for (const cell of lane.cells) {
1072
+ const isColCollapsed = collapsedColumns?.has(cell.column.id) ?? false;
1073
+ if (cell.cards.length > 0) {
1074
+ const colInfo = grid.columnXs.find(
1075
+ (c) => c.column.id === cell.column.id
1076
+ );
1077
+ if (!colInfo) continue;
1078
+ const placeholderBg = lane.bucket.laneColor
1079
+ ? mix(lane.bucket.laneColor, palette.bg, isDark ? 40 : 28)
1080
+ : mix(palette.textMuted, palette.bg, isDark ? 28 : 22);
1081
+ const pw = isColCollapsed
1082
+ ? COLLAPSED_COLUMN_WIDTH - 8
1083
+ : colInfo.width - COLUMN_PADDING * 2;
1084
+ laneG
1085
+ .append('rect')
1086
+ .attr('x', colInfo.x + (isColCollapsed ? 4 : COLUMN_PADDING))
1087
+ .attr('y', lane.y)
1088
+ .attr('width', pw)
1089
+ .attr('height', 18)
1090
+ .attr('rx', 4)
1091
+ .attr('fill', placeholderBg);
1092
+ laneG
1093
+ .append('text')
1094
+ .attr(
1095
+ 'x',
1096
+ colInfo.x + (isColCollapsed ? 4 : COLUMN_PADDING) + pw / 2
1097
+ )
1098
+ .attr('y', lane.y + 13)
1099
+ .attr('font-size', WIP_FONT_SIZE)
1100
+ .attr('font-weight', 'bold')
1101
+ .attr('fill', palette.textMuted)
1102
+ .attr('text-anchor', 'middle')
1103
+ .text(String(cell.cards.length));
1104
+ }
1105
+ }
1106
+ }
879
1107
 
880
1108
  // Lane divider line below the row
881
1109
  laneG
@@ -888,17 +1116,50 @@ function renderSwimlaneBoard(
888
1116
  .attr('stroke-opacity', 0.4)
889
1117
  .attr('stroke-width', 1);
890
1118
 
891
- // Cards inside cells
892
- for (const cell of lane.cells) {
893
- for (const cardLayout of cell.cardLayouts) {
894
- renderSwimlaneCard(
895
- laneG,
896
- cardLayout,
897
- parsed.tagGroups,
898
- activeTagGroup,
899
- palette,
900
- cardBaseBg
901
- );
1119
+ // Cards inside cells (skip for collapsed lanes — they show count placeholders instead)
1120
+ if (!isLaneCollapsed) {
1121
+ for (const cell of lane.cells) {
1122
+ const isColCollapsed = collapsedColumns?.has(cell.column.id) ?? false;
1123
+ if (isColCollapsed && cell.cards.length > 0) {
1124
+ // Collapsed column in non-collapsed lane: show count placeholder
1125
+ const colInfo = grid.columnXs.find(
1126
+ (c) => c.column.id === cell.column.id
1127
+ );
1128
+ if (colInfo) {
1129
+ const placeholderBg = lane.bucket.laneColor
1130
+ ? mix(lane.bucket.laneColor, palette.bg, isDark ? 40 : 28)
1131
+ : mix(palette.textMuted, palette.bg, isDark ? 28 : 22);
1132
+ laneG
1133
+ .append('rect')
1134
+ .attr('x', colInfo.x + 4)
1135
+ .attr('y', lane.y)
1136
+ .attr('width', COLLAPSED_COLUMN_WIDTH - 8)
1137
+ .attr('height', 22)
1138
+ .attr('rx', 4)
1139
+ .attr('fill', placeholderBg);
1140
+ laneG
1141
+ .append('text')
1142
+ .attr('x', colInfo.x + COLLAPSED_COLUMN_WIDTH / 2)
1143
+ .attr('y', lane.y + 16)
1144
+ .attr('font-size', WIP_FONT_SIZE)
1145
+ .attr('font-weight', 'bold')
1146
+ .attr('fill', palette.textMuted)
1147
+ .attr('text-anchor', 'middle')
1148
+ .text(String(cell.cards.length));
1149
+ }
1150
+ continue;
1151
+ }
1152
+ for (const cardLayout of cell.cardLayouts) {
1153
+ renderSwimlaneCard(
1154
+ laneG,
1155
+ cardLayout,
1156
+ parsed.tagGroups,
1157
+ activeTagGroup,
1158
+ palette,
1159
+ cardBaseBg,
1160
+ hiddenMetaGroups
1161
+ );
1162
+ }
902
1163
  }
903
1164
  }
904
1165
  }
@@ -910,11 +1171,12 @@ function renderSwimlaneCard(
910
1171
  tagGroups: KanbanTagGroup[],
911
1172
  activeTagGroup: string | null,
912
1173
  palette: PaletteColors,
913
- cardBaseBg: string
1174
+ cardBaseBg: string,
1175
+ hiddenMetaGroups?: string[]
914
1176
  ): void {
915
1177
  const card = cardLayout.card;
916
1178
  const resolvedColor = resolveCardTagColor(card, tagGroups, activeTagGroup);
917
- const tagMeta = resolveCardTagMeta(card, tagGroups);
1179
+ const tagMeta = resolveCardTagMeta(card, tagGroups, hiddenMetaGroups);
918
1180
  const hasMeta = tagMeta.length > 0 || card.details.length > 0;
919
1181
 
920
1182
  const cardFill = resolvedColor