@diagrammo/dgmo 0.2.20 → 0.2.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.
@@ -0,0 +1,566 @@
1
+ // ============================================================
2
+ // Kanban Board SVG Renderer
3
+ // ============================================================
4
+
5
+ import * as d3Selection from 'd3-selection';
6
+ import { FONT_FAMILY } from '../fonts';
7
+ import type { PaletteColors } from '../palettes';
8
+ import type { ParsedKanban, KanbanColumn, KanbanCard, KanbanTagGroup } from './types';
9
+ import { parseKanban } from './parser';
10
+ import { isArchiveColumn } from './mutations';
11
+
12
+ // ============================================================
13
+ // Constants
14
+ // ============================================================
15
+
16
+ const DIAGRAM_PADDING = 20;
17
+ const COLUMN_GAP = 16;
18
+ const COLUMN_HEADER_HEIGHT = 36;
19
+ const COLUMN_PADDING = 12;
20
+ const COLUMN_MIN_WIDTH = 200;
21
+ const CARD_HEADER_HEIGHT = 24;
22
+ const CARD_META_LINE_HEIGHT = 14;
23
+ const CARD_SEPARATOR_GAP = 4;
24
+ const CARD_GAP = 8;
25
+ const CARD_RADIUS = 6;
26
+ const CARD_PADDING_X = 10;
27
+ const CARD_PADDING_Y = 6;
28
+ const CARD_STROKE_WIDTH = 1.5;
29
+ const TITLE_HEIGHT = 30;
30
+ const TITLE_FONT_SIZE = 18;
31
+ const COLUMN_HEADER_FONT_SIZE = 13;
32
+ const CARD_TITLE_FONT_SIZE = 12;
33
+ const CARD_META_FONT_SIZE = 10;
34
+ const WIP_FONT_SIZE = 10;
35
+ const COLUMN_RADIUS = 8;
36
+ const COLUMN_HEADER_RADIUS = 8;
37
+ const LEGEND_HEIGHT = 28;
38
+ const LEGEND_FONT_SIZE = 11;
39
+ const LEGEND_DOT_R = 4;
40
+ const LEGEND_ENTRY_FONT_SIZE = 10;
41
+
42
+ // ============================================================
43
+ // Color helpers
44
+ // ============================================================
45
+
46
+ function mix(a: string, b: string, pct: number): string {
47
+ const parse = (h: string) => {
48
+ const r = h.replace('#', '');
49
+ const f = r.length === 3 ? r[0] + r[0] + r[1] + r[1] + r[2] + r[2] : r;
50
+ return [
51
+ parseInt(f.substring(0, 2), 16),
52
+ parseInt(f.substring(2, 4), 16),
53
+ parseInt(f.substring(4, 6), 16),
54
+ ];
55
+ };
56
+ const [ar, ag, ab] = parse(a);
57
+ const [br, bg, bb] = parse(b);
58
+ const t = pct / 100;
59
+ const c = (x: number, y: number) =>
60
+ Math.round(x * t + y * (1 - t))
61
+ .toString(16)
62
+ .padStart(2, '0');
63
+ return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;
64
+ }
65
+
66
+ // ============================================================
67
+ // Tag color resolution
68
+ // ============================================================
69
+
70
+ function resolveCardTagMeta(
71
+ card: KanbanCard,
72
+ tagGroups: KanbanTagGroup[]
73
+ ): { label: string; value: string; color?: string }[] {
74
+ const meta: { label: string; value: string; color?: string }[] = [];
75
+ for (const group of tagGroups) {
76
+ const tagValue = card.tags[group.name.toLowerCase()];
77
+ const value = tagValue ?? group.defaultValue;
78
+ if (!value) continue;
79
+ const entry = group.entries.find(
80
+ (e) => e.value.toLowerCase() === value.toLowerCase()
81
+ );
82
+ meta.push({ label: group.name, value, color: entry?.color });
83
+ }
84
+ return meta;
85
+ }
86
+
87
+ function resolveCardTagColor(
88
+ card: KanbanCard,
89
+ tagGroups: KanbanTagGroup[],
90
+ activeTagGroup: string | null
91
+ ): string | undefined {
92
+ if (!activeTagGroup) return card.color;
93
+ const group = tagGroups.find(
94
+ (g) => g.name.toLowerCase() === activeTagGroup.toLowerCase()
95
+ );
96
+ if (!group) return card.color;
97
+ const tagValue = card.tags[group.name.toLowerCase()];
98
+ const value = tagValue ?? group.defaultValue;
99
+ if (!value) return undefined;
100
+ const entry = group.entries.find(
101
+ (e) => e.value.toLowerCase() === value.toLowerCase()
102
+ );
103
+ return entry?.color;
104
+ }
105
+
106
+ // ============================================================
107
+ // Layout computation
108
+ // ============================================================
109
+
110
+ interface ColumnLayout {
111
+ x: number;
112
+ y: number;
113
+ width: number;
114
+ height: number;
115
+ column: KanbanColumn;
116
+ cardLayouts: CardLayout[];
117
+ }
118
+
119
+ interface CardLayout {
120
+ x: number;
121
+ y: number;
122
+ width: number;
123
+ height: number;
124
+ card: KanbanCard;
125
+ }
126
+
127
+ function computeLayout(
128
+ parsed: ParsedKanban,
129
+ _palette: PaletteColors
130
+ ): { columns: ColumnLayout[]; totalWidth: number; totalHeight: number } {
131
+ // Title and legend share one row
132
+ const hasHeader = !!parsed.title || parsed.tagGroups.length > 0;
133
+ const headerHeight = hasHeader ? Math.max(TITLE_HEIGHT, LEGEND_HEIGHT) + 8 : 0;
134
+ const startY = DIAGRAM_PADDING + headerHeight;
135
+
136
+ // Estimate column widths based on content
137
+ const charWidth = CARD_TITLE_FONT_SIZE * 0.6;
138
+ const columnLayouts: ColumnLayout[] = [];
139
+
140
+ let maxColumnHeight = 0;
141
+
142
+ // Filter out the archive column — it's a drop target only, not rendered
143
+ const visibleColumns = parsed.columns.filter((c) => !isArchiveColumn(c.name));
144
+
145
+ for (const col of visibleColumns) {
146
+ // Compute card heights and column width
147
+ let maxCardTextWidth = col.name.length * (COLUMN_HEADER_FONT_SIZE * 0.65);
148
+
149
+ const cardLayouts: CardLayout[] = [];
150
+ let cardY = COLUMN_HEADER_HEIGHT + COLUMN_PADDING;
151
+
152
+ for (const card of col.cards) {
153
+ const titleWidth = card.title.length * charWidth;
154
+ maxCardTextWidth = Math.max(
155
+ maxCardTextWidth,
156
+ titleWidth + CARD_PADDING_X * 2
157
+ );
158
+
159
+ // Count metadata rows (tag groups + detail lines)
160
+ const tagMeta = resolveCardTagMeta(card, parsed.tagGroups);
161
+ const metaCount = tagMeta.length + card.details.length;
162
+ const metaHeight =
163
+ metaCount > 0
164
+ ? CARD_SEPARATOR_GAP + 1 + CARD_PADDING_Y + metaCount * CARD_META_LINE_HEIGHT
165
+ : 0;
166
+ const cardHeight = CARD_HEADER_HEIGHT + CARD_PADDING_Y + metaHeight;
167
+
168
+ // Account for meta label widths
169
+ for (const m of tagMeta) {
170
+ const metaW = (m.label.length + 2 + m.value.length) * CARD_META_FONT_SIZE * 0.6 + CARD_PADDING_X * 2;
171
+ maxCardTextWidth = Math.max(maxCardTextWidth, metaW);
172
+ }
173
+
174
+ cardLayouts.push({
175
+ x: COLUMN_PADDING,
176
+ y: cardY,
177
+ width: 0, // set after column width computed
178
+ height: cardHeight,
179
+ card,
180
+ });
181
+
182
+ cardY += cardHeight + CARD_GAP;
183
+ }
184
+
185
+ const colWidth = Math.max(COLUMN_MIN_WIDTH, maxCardTextWidth + COLUMN_PADDING * 2);
186
+
187
+ // Set card widths
188
+ for (const cl of cardLayouts) {
189
+ cl.width = colWidth - COLUMN_PADDING * 2;
190
+ }
191
+
192
+ const colHeight = cardY + COLUMN_PADDING;
193
+ maxColumnHeight = Math.max(maxColumnHeight, colHeight);
194
+
195
+ columnLayouts.push({
196
+ x: 0, // set below
197
+ y: startY,
198
+ width: colWidth,
199
+ height: colHeight,
200
+ column: col,
201
+ cardLayouts,
202
+ });
203
+ }
204
+
205
+ // Normalize column heights and compute x positions
206
+ let currentX = DIAGRAM_PADDING;
207
+ for (const cl of columnLayouts) {
208
+ cl.x = currentX;
209
+ cl.height = maxColumnHeight;
210
+ currentX += cl.width + COLUMN_GAP;
211
+ }
212
+
213
+ const totalWidth = currentX - COLUMN_GAP + DIAGRAM_PADDING;
214
+ const totalHeight = startY + maxColumnHeight + DIAGRAM_PADDING;
215
+
216
+ return { columns: columnLayouts, totalWidth, totalHeight };
217
+ }
218
+
219
+ // ============================================================
220
+ // Render function
221
+ // ============================================================
222
+
223
+ export function renderKanban(
224
+ container: HTMLElement,
225
+ parsed: ParsedKanban,
226
+ palette: PaletteColors,
227
+ isDark: boolean,
228
+ _onNavigateToLine?: (line: number) => void,
229
+ exportDims?: { width: number; height: number },
230
+ activeTagGroup?: string | null
231
+ ): void {
232
+ const layout = computeLayout(parsed, palette);
233
+
234
+ const width = exportDims?.width ?? layout.totalWidth;
235
+ const height = exportDims?.height ?? layout.totalHeight;
236
+
237
+ container.innerHTML = '';
238
+
239
+ const svg = d3Selection
240
+ .select(container)
241
+ .append('svg')
242
+ .attr('xmlns', 'http://www.w3.org/2000/svg')
243
+ .attr('width', width)
244
+ .attr('height', height)
245
+ .attr('font-family', FONT_FAMILY)
246
+ .style('background', palette.bg);
247
+
248
+ // Title
249
+ if (parsed.title) {
250
+ svg
251
+ .append('text')
252
+ .attr('class', 'chart-title')
253
+ .attr('data-line-number', parsed.titleLineNumber ?? 0)
254
+ .attr('x', DIAGRAM_PADDING)
255
+ .attr('y', DIAGRAM_PADDING + TITLE_FONT_SIZE)
256
+ .attr('font-size', TITLE_FONT_SIZE)
257
+ .attr('font-weight', 'bold')
258
+ .attr('fill', palette.text)
259
+ .text(parsed.title);
260
+ }
261
+
262
+ // Legend (same row as title)
263
+ if (parsed.tagGroups.length > 0) {
264
+ const legendY = DIAGRAM_PADDING;
265
+ // Start legend after title text
266
+ const titleTextWidth = parsed.title
267
+ ? parsed.title.length * TITLE_FONT_SIZE * 0.6 + 16
268
+ : 0;
269
+ let legendX = DIAGRAM_PADDING + titleTextWidth;
270
+ const groupBg = isDark
271
+ ? mix(palette.surface, palette.bg, 50)
272
+ : mix(palette.surface, palette.bg, 30);
273
+ const capsulePad = 4;
274
+
275
+ for (const group of parsed.tagGroups) {
276
+ const isActive =
277
+ activeTagGroup?.toLowerCase() === group.name.toLowerCase();
278
+
279
+ // When a group is active, skip all other groups entirely
280
+ if (activeTagGroup != null && !isActive) continue;
281
+
282
+ const pillTextWidth = group.name.length * LEGEND_FONT_SIZE * 0.6;
283
+ const pillWidth = pillTextWidth + 16;
284
+
285
+ // Measure total capsule width for active groups (pill + entries)
286
+ let capsuleContentWidth = pillWidth;
287
+ if (isActive) {
288
+ capsuleContentWidth += 4; // gap after pill
289
+ for (const entry of group.entries) {
290
+ capsuleContentWidth += LEGEND_DOT_R * 2 + 4 + entry.value.length * LEGEND_ENTRY_FONT_SIZE * 0.6 + 8;
291
+ }
292
+ }
293
+ const capsuleWidth = capsuleContentWidth + capsulePad * 2;
294
+
295
+ // Outer capsule background for active group
296
+ if (isActive) {
297
+ svg
298
+ .append('rect')
299
+ .attr('x', legendX)
300
+ .attr('y', legendY)
301
+ .attr('width', capsuleWidth)
302
+ .attr('height', LEGEND_HEIGHT)
303
+ .attr('rx', LEGEND_HEIGHT / 2)
304
+ .attr('fill', groupBg);
305
+ }
306
+
307
+ const pillX = legendX + (isActive ? capsulePad : 0);
308
+
309
+ // Pill background
310
+ const pillBg = isActive ? palette.bg : groupBg;
311
+ svg
312
+ .append('rect')
313
+ .attr('x', pillX)
314
+ .attr('y', legendY + (isActive ? capsulePad : 0))
315
+ .attr('width', pillWidth)
316
+ .attr('height', LEGEND_HEIGHT - (isActive ? capsulePad * 2 : 0))
317
+ .attr('rx', (LEGEND_HEIGHT - (isActive ? capsulePad * 2 : 0)) / 2)
318
+ .attr('fill', pillBg)
319
+ .attr('class', 'kanban-legend-group')
320
+ .attr('data-legend-group', group.name.toLowerCase());
321
+
322
+ if (isActive) {
323
+ svg
324
+ .append('rect')
325
+ .attr('x', pillX)
326
+ .attr('y', legendY + capsulePad)
327
+ .attr('width', pillWidth)
328
+ .attr('height', LEGEND_HEIGHT - capsulePad * 2)
329
+ .attr('rx', (LEGEND_HEIGHT - capsulePad * 2) / 2)
330
+ .attr('fill', 'none')
331
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
332
+ .attr('stroke-width', 0.75);
333
+ }
334
+
335
+ // Pill text
336
+ svg
337
+ .append('text')
338
+ .attr('x', pillX + pillWidth / 2)
339
+ .attr('y', legendY + LEGEND_HEIGHT / 2 + LEGEND_FONT_SIZE / 2 - 2)
340
+ .attr('font-size', LEGEND_FONT_SIZE)
341
+ .attr('font-weight', '500')
342
+ .attr('fill', isActive ? palette.text : palette.textMuted)
343
+ .attr('text-anchor', 'middle')
344
+ .text(group.name);
345
+
346
+ // Show entries inside capsule when active
347
+ if (isActive) {
348
+ let entryX = pillX + pillWidth + 4;
349
+ for (const entry of group.entries) {
350
+ svg
351
+ .append('circle')
352
+ .attr('cx', entryX + LEGEND_DOT_R)
353
+ .attr('cy', legendY + LEGEND_HEIGHT / 2)
354
+ .attr('r', LEGEND_DOT_R)
355
+ .attr('fill', entry.color);
356
+
357
+ const entryTextX = entryX + LEGEND_DOT_R * 2 + 4;
358
+ svg
359
+ .append('text')
360
+ .attr('x', entryTextX)
361
+ .attr('y', legendY + LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
362
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
363
+ .attr('fill', palette.textMuted)
364
+ .text(entry.value);
365
+
366
+ entryX = entryTextX + entry.value.length * LEGEND_ENTRY_FONT_SIZE * 0.6 + 8;
367
+ }
368
+ legendX += capsuleWidth + 12;
369
+ } else {
370
+ legendX += pillWidth + 12;
371
+ }
372
+ }
373
+ }
374
+
375
+ // Columns
376
+ const defaultColBg = isDark
377
+ ? mix(palette.surface, palette.bg, 50)
378
+ : mix(palette.surface, palette.bg, 30);
379
+ const defaultColHeaderBg = isDark
380
+ ? mix(palette.surface, palette.bg, 70)
381
+ : mix(palette.surface, palette.bg, 50);
382
+
383
+ const cardBaseBg = isDark ? palette.surface : palette.bg;
384
+
385
+ for (const colLayout of layout.columns) {
386
+ const col = colLayout.column;
387
+ const g = svg
388
+ .append('g')
389
+ .attr('class', 'kanban-column')
390
+ .attr('data-column-id', col.id)
391
+ .attr('data-line-number', col.lineNumber);
392
+
393
+ // Column body: always neutral
394
+ const thisColBg = defaultColBg;
395
+ // Column header: tinted if column has explicit color
396
+ const thisColHeaderBg = col.color
397
+ ? mix(col.color, palette.bg, 25)
398
+ : defaultColHeaderBg;
399
+
400
+ // Column background
401
+ g.append('rect')
402
+ .attr('x', colLayout.x)
403
+ .attr('y', colLayout.y)
404
+ .attr('width', colLayout.width)
405
+ .attr('height', colLayout.height)
406
+ .attr('rx', COLUMN_RADIUS)
407
+ .attr('fill', thisColBg);
408
+
409
+ // Column header background
410
+ g.append('rect')
411
+ .attr('x', colLayout.x)
412
+ .attr('y', colLayout.y)
413
+ .attr('width', colLayout.width)
414
+ .attr('height', COLUMN_HEADER_HEIGHT)
415
+ .attr('rx', COLUMN_HEADER_RADIUS)
416
+ .attr('fill', thisColHeaderBg);
417
+
418
+ // Column title
419
+ g.append('text')
420
+ .attr('x', colLayout.x + COLUMN_PADDING)
421
+ .attr(
422
+ 'y',
423
+ colLayout.y + COLUMN_HEADER_HEIGHT / 2 + COLUMN_HEADER_FONT_SIZE / 2 - 2
424
+ )
425
+ .attr('font-size', COLUMN_HEADER_FONT_SIZE)
426
+ .attr('font-weight', 'bold')
427
+ .attr('fill', palette.text)
428
+ .text(col.name);
429
+
430
+ // WIP limit badge
431
+ if (col.wipLimit != null) {
432
+ const wipExceeded = col.cards.length > col.wipLimit;
433
+ const badgeText = `${col.cards.length}/${col.wipLimit}`;
434
+ const nameWidth = col.name.length * COLUMN_HEADER_FONT_SIZE * 0.65;
435
+ g.append('text')
436
+ .attr('x', colLayout.x + COLUMN_PADDING + nameWidth + 8)
437
+ .attr(
438
+ 'y',
439
+ colLayout.y +
440
+ COLUMN_HEADER_HEIGHT / 2 +
441
+ WIP_FONT_SIZE / 2 -
442
+ 1
443
+ )
444
+ .attr('font-size', WIP_FONT_SIZE)
445
+ .attr('fill', wipExceeded ? palette.colors.red : palette.textMuted)
446
+ .attr('font-weight', wipExceeded ? 'bold' : 'normal')
447
+ .text(badgeText);
448
+ }
449
+
450
+ // Cards
451
+ for (const cardLayout of colLayout.cardLayouts) {
452
+ const card = cardLayout.card;
453
+ const resolvedColor = resolveCardTagColor(card, parsed.tagGroups, activeTagGroup ?? null);
454
+ const tagMeta = resolveCardTagMeta(card, parsed.tagGroups);
455
+ const hasMeta = tagMeta.length > 0 || card.details.length > 0;
456
+
457
+ // Org-chart-style fill: 15% blend of color into bg
458
+ const cardFill = resolvedColor
459
+ ? mix(resolvedColor, cardBaseBg, 15)
460
+ : mix(palette.primary, cardBaseBg, 15);
461
+ const cardStroke = resolvedColor ?? palette.textMuted;
462
+
463
+ const cg = g
464
+ .append('g')
465
+ .attr('class', 'kanban-card')
466
+ .attr('data-card-id', card.id)
467
+ .attr('data-line-number', card.lineNumber);
468
+
469
+ const cx = colLayout.x + cardLayout.x;
470
+ const cy = colLayout.y + cardLayout.y;
471
+
472
+ // Card background
473
+ cg.append('rect')
474
+ .attr('x', cx)
475
+ .attr('y', cy)
476
+ .attr('width', cardLayout.width)
477
+ .attr('height', cardLayout.height)
478
+ .attr('rx', CARD_RADIUS)
479
+ .attr('fill', cardFill)
480
+ .attr('stroke', cardStroke)
481
+ .attr('stroke-width', CARD_STROKE_WIDTH);
482
+
483
+ // Card title
484
+ cg.append('text')
485
+ .attr('x', cx + CARD_PADDING_X)
486
+ .attr('y', cy + CARD_PADDING_Y + CARD_TITLE_FONT_SIZE)
487
+ .attr('font-size', CARD_TITLE_FONT_SIZE)
488
+ .attr('font-weight', '500')
489
+ .attr('fill', palette.text)
490
+ .text(card.title);
491
+
492
+ // Separator + metadata
493
+ if (hasMeta) {
494
+ const separatorY = cy + CARD_HEADER_HEIGHT;
495
+
496
+ cg.append('line')
497
+ .attr('x1', cx)
498
+ .attr('y1', separatorY)
499
+ .attr('x2', cx + cardLayout.width)
500
+ .attr('y2', separatorY)
501
+ .attr('stroke', cardStroke)
502
+ .attr('stroke-opacity', 0.3)
503
+ .attr('stroke-width', 1);
504
+
505
+ let metaY = separatorY + CARD_SEPARATOR_GAP + CARD_META_FONT_SIZE;
506
+
507
+ // Tag metadata rows
508
+ for (const meta of tagMeta) {
509
+ cg.append('text')
510
+ .attr('x', cx + CARD_PADDING_X)
511
+ .attr('y', metaY)
512
+ .attr('font-size', CARD_META_FONT_SIZE)
513
+ .attr('fill', palette.textMuted)
514
+ .text(`${meta.label}: `);
515
+
516
+ const labelWidth = (meta.label.length + 2) * CARD_META_FONT_SIZE * 0.6;
517
+ cg.append('text')
518
+ .attr('x', cx + CARD_PADDING_X + labelWidth)
519
+ .attr('y', metaY)
520
+ .attr('font-size', CARD_META_FONT_SIZE)
521
+ .attr('fill', palette.text)
522
+ .text(meta.value);
523
+
524
+ metaY += CARD_META_LINE_HEIGHT;
525
+ }
526
+
527
+ // Detail lines
528
+ for (const detail of card.details) {
529
+ cg.append('text')
530
+ .attr('x', cx + CARD_PADDING_X)
531
+ .attr('y', metaY)
532
+ .attr('font-size', CARD_META_FONT_SIZE)
533
+ .attr('fill', palette.textMuted)
534
+ .text(detail);
535
+
536
+ metaY += CARD_META_LINE_HEIGHT;
537
+ }
538
+ }
539
+ }
540
+ }
541
+ }
542
+
543
+ // ============================================================
544
+ // Export convenience function
545
+ // ============================================================
546
+
547
+ export function renderKanbanForExport(
548
+ content: string,
549
+ theme: 'light' | 'dark' | 'transparent',
550
+ palette: PaletteColors
551
+ ): string {
552
+ const parsed = parseKanban(content, palette);
553
+ if (parsed.error || parsed.columns.length === 0) return '';
554
+
555
+ const isDark = theme === 'dark';
556
+ const layout = computeLayout(parsed, palette);
557
+
558
+ const container = document.createElement('div');
559
+ renderKanban(container, parsed, palette, isDark, undefined, {
560
+ width: layout.totalWidth,
561
+ height: layout.totalHeight,
562
+ });
563
+
564
+ const svgEl = container.querySelector('svg');
565
+ return svgEl?.outerHTML ?? '';
566
+ }
@@ -0,0 +1,45 @@
1
+ import type { DgmoError } from '../diagnostics';
2
+
3
+ export interface KanbanTagEntry {
4
+ value: string;
5
+ color: string;
6
+ lineNumber: number;
7
+ }
8
+
9
+ export interface KanbanTagGroup {
10
+ name: string;
11
+ alias?: string;
12
+ entries: KanbanTagEntry[];
13
+ defaultValue?: string;
14
+ lineNumber: number;
15
+ }
16
+
17
+ export interface KanbanCard {
18
+ id: string;
19
+ title: string;
20
+ tags: Record<string, string>; // groupName → value
21
+ details: string[]; // freeform indented lines
22
+ lineNumber: number; // first line of the card (1-based)
23
+ endLineNumber: number; // last line inclusive (1-based)
24
+ color?: string; // explicit color override via (color) suffix
25
+ }
26
+
27
+ export interface KanbanColumn {
28
+ id: string;
29
+ name: string;
30
+ wipLimit?: number;
31
+ color?: string;
32
+ cards: KanbanCard[];
33
+ lineNumber: number;
34
+ }
35
+
36
+ export interface ParsedKanban {
37
+ type: 'kanban';
38
+ title?: string;
39
+ titleLineNumber?: number;
40
+ columns: KanbanColumn[];
41
+ tagGroups: KanbanTagGroup[];
42
+ options: Record<string, string>;
43
+ diagnostics: DgmoError[];
44
+ error?: string;
45
+ }