@diagrammo/dgmo 0.2.20 → 0.2.21

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,564 @@
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 = mix(palette.surface, palette.bg, isDark ? 35 : 20);
271
+ const capsulePad = 4;
272
+
273
+ for (const group of parsed.tagGroups) {
274
+ const isActive =
275
+ activeTagGroup?.toLowerCase() === group.name.toLowerCase();
276
+
277
+ // When a group is active, skip all other groups entirely
278
+ if (activeTagGroup != null && !isActive) continue;
279
+
280
+ const pillTextWidth = group.name.length * LEGEND_FONT_SIZE * 0.6;
281
+ const pillWidth = pillTextWidth + 16;
282
+
283
+ // Measure total capsule width for active groups (pill + entries)
284
+ let capsuleContentWidth = pillWidth;
285
+ if (isActive) {
286
+ capsuleContentWidth += 4; // gap after pill
287
+ for (const entry of group.entries) {
288
+ capsuleContentWidth += LEGEND_DOT_R * 2 + 4 + entry.value.length * LEGEND_ENTRY_FONT_SIZE * 0.6 + 8;
289
+ }
290
+ }
291
+ const capsuleWidth = capsuleContentWidth + capsulePad * 2;
292
+
293
+ // Outer capsule background for active group
294
+ if (isActive) {
295
+ svg
296
+ .append('rect')
297
+ .attr('x', legendX)
298
+ .attr('y', legendY)
299
+ .attr('width', capsuleWidth)
300
+ .attr('height', LEGEND_HEIGHT)
301
+ .attr('rx', LEGEND_HEIGHT / 2)
302
+ .attr('fill', groupBg);
303
+ }
304
+
305
+ const pillX = legendX + (isActive ? capsulePad : 0);
306
+
307
+ // Pill background
308
+ const pillBg = isActive ? palette.bg : groupBg;
309
+ svg
310
+ .append('rect')
311
+ .attr('x', pillX)
312
+ .attr('y', legendY + (isActive ? capsulePad : 0))
313
+ .attr('width', pillWidth)
314
+ .attr('height', LEGEND_HEIGHT - (isActive ? capsulePad * 2 : 0))
315
+ .attr('rx', (LEGEND_HEIGHT - (isActive ? capsulePad * 2 : 0)) / 2)
316
+ .attr('fill', pillBg)
317
+ .attr('class', 'kanban-legend-group')
318
+ .attr('data-legend-group', group.name.toLowerCase());
319
+
320
+ if (isActive) {
321
+ svg
322
+ .append('rect')
323
+ .attr('x', pillX)
324
+ .attr('y', legendY + capsulePad)
325
+ .attr('width', pillWidth)
326
+ .attr('height', LEGEND_HEIGHT - capsulePad * 2)
327
+ .attr('rx', (LEGEND_HEIGHT - capsulePad * 2) / 2)
328
+ .attr('fill', 'none')
329
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
330
+ .attr('stroke-width', 0.75);
331
+ }
332
+
333
+ // Pill text
334
+ svg
335
+ .append('text')
336
+ .attr('x', pillX + pillWidth / 2)
337
+ .attr('y', legendY + LEGEND_HEIGHT / 2 + LEGEND_FONT_SIZE / 2 - 2)
338
+ .attr('font-size', LEGEND_FONT_SIZE)
339
+ .attr('font-weight', '500')
340
+ .attr('fill', isActive ? palette.text : palette.textMuted)
341
+ .attr('text-anchor', 'middle')
342
+ .text(group.name);
343
+
344
+ // Show entries inside capsule when active
345
+ if (isActive) {
346
+ let entryX = pillX + pillWidth + 4;
347
+ for (const entry of group.entries) {
348
+ svg
349
+ .append('circle')
350
+ .attr('cx', entryX + LEGEND_DOT_R)
351
+ .attr('cy', legendY + LEGEND_HEIGHT / 2)
352
+ .attr('r', LEGEND_DOT_R)
353
+ .attr('fill', entry.color);
354
+
355
+ const entryTextX = entryX + LEGEND_DOT_R * 2 + 4;
356
+ svg
357
+ .append('text')
358
+ .attr('x', entryTextX)
359
+ .attr('y', legendY + LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
360
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
361
+ .attr('fill', palette.textMuted)
362
+ .text(entry.value);
363
+
364
+ entryX = entryTextX + entry.value.length * LEGEND_ENTRY_FONT_SIZE * 0.6 + 8;
365
+ }
366
+ legendX += capsuleWidth + 12;
367
+ } else {
368
+ legendX += pillWidth + 12;
369
+ }
370
+ }
371
+ }
372
+
373
+ // Columns
374
+ const defaultColBg = isDark
375
+ ? mix(palette.surface, palette.bg, 50)
376
+ : mix(palette.surface, palette.bg, 30);
377
+ const defaultColHeaderBg = isDark
378
+ ? mix(palette.surface, palette.bg, 70)
379
+ : mix(palette.surface, palette.bg, 50);
380
+
381
+ const cardBaseBg = isDark ? palette.surface : palette.bg;
382
+
383
+ for (const colLayout of layout.columns) {
384
+ const col = colLayout.column;
385
+ const g = svg
386
+ .append('g')
387
+ .attr('class', 'kanban-column')
388
+ .attr('data-column-id', col.id)
389
+ .attr('data-line-number', col.lineNumber);
390
+
391
+ // Column body: always neutral
392
+ const thisColBg = defaultColBg;
393
+ // Column header: tinted if column has explicit color
394
+ const thisColHeaderBg = col.color
395
+ ? mix(col.color, palette.bg, 25)
396
+ : defaultColHeaderBg;
397
+
398
+ // Column background
399
+ g.append('rect')
400
+ .attr('x', colLayout.x)
401
+ .attr('y', colLayout.y)
402
+ .attr('width', colLayout.width)
403
+ .attr('height', colLayout.height)
404
+ .attr('rx', COLUMN_RADIUS)
405
+ .attr('fill', thisColBg);
406
+
407
+ // Column header background
408
+ g.append('rect')
409
+ .attr('x', colLayout.x)
410
+ .attr('y', colLayout.y)
411
+ .attr('width', colLayout.width)
412
+ .attr('height', COLUMN_HEADER_HEIGHT)
413
+ .attr('rx', COLUMN_HEADER_RADIUS)
414
+ .attr('fill', thisColHeaderBg);
415
+
416
+ // Column title
417
+ g.append('text')
418
+ .attr('x', colLayout.x + COLUMN_PADDING)
419
+ .attr(
420
+ 'y',
421
+ colLayout.y + COLUMN_HEADER_HEIGHT / 2 + COLUMN_HEADER_FONT_SIZE / 2 - 2
422
+ )
423
+ .attr('font-size', COLUMN_HEADER_FONT_SIZE)
424
+ .attr('font-weight', 'bold')
425
+ .attr('fill', palette.text)
426
+ .text(col.name);
427
+
428
+ // WIP limit badge
429
+ if (col.wipLimit != null) {
430
+ const wipExceeded = col.cards.length > col.wipLimit;
431
+ const badgeText = `${col.cards.length}/${col.wipLimit}`;
432
+ const nameWidth = col.name.length * COLUMN_HEADER_FONT_SIZE * 0.65;
433
+ g.append('text')
434
+ .attr('x', colLayout.x + COLUMN_PADDING + nameWidth + 8)
435
+ .attr(
436
+ 'y',
437
+ colLayout.y +
438
+ COLUMN_HEADER_HEIGHT / 2 +
439
+ WIP_FONT_SIZE / 2 -
440
+ 1
441
+ )
442
+ .attr('font-size', WIP_FONT_SIZE)
443
+ .attr('fill', wipExceeded ? palette.colors.red : palette.textMuted)
444
+ .attr('font-weight', wipExceeded ? 'bold' : 'normal')
445
+ .text(badgeText);
446
+ }
447
+
448
+ // Cards
449
+ for (const cardLayout of colLayout.cardLayouts) {
450
+ const card = cardLayout.card;
451
+ const resolvedColor = resolveCardTagColor(card, parsed.tagGroups, activeTagGroup ?? null);
452
+ const tagMeta = resolveCardTagMeta(card, parsed.tagGroups);
453
+ const hasMeta = tagMeta.length > 0 || card.details.length > 0;
454
+
455
+ // Org-chart-style fill: 15% blend of color into bg
456
+ const cardFill = resolvedColor
457
+ ? mix(resolvedColor, cardBaseBg, 15)
458
+ : mix(palette.primary, cardBaseBg, 15);
459
+ const cardStroke = resolvedColor ?? palette.textMuted;
460
+
461
+ const cg = g
462
+ .append('g')
463
+ .attr('class', 'kanban-card')
464
+ .attr('data-card-id', card.id)
465
+ .attr('data-line-number', card.lineNumber);
466
+
467
+ const cx = colLayout.x + cardLayout.x;
468
+ const cy = colLayout.y + cardLayout.y;
469
+
470
+ // Card background
471
+ cg.append('rect')
472
+ .attr('x', cx)
473
+ .attr('y', cy)
474
+ .attr('width', cardLayout.width)
475
+ .attr('height', cardLayout.height)
476
+ .attr('rx', CARD_RADIUS)
477
+ .attr('fill', cardFill)
478
+ .attr('stroke', cardStroke)
479
+ .attr('stroke-width', CARD_STROKE_WIDTH);
480
+
481
+ // Card title
482
+ cg.append('text')
483
+ .attr('x', cx + CARD_PADDING_X)
484
+ .attr('y', cy + CARD_PADDING_Y + CARD_TITLE_FONT_SIZE)
485
+ .attr('font-size', CARD_TITLE_FONT_SIZE)
486
+ .attr('font-weight', '500')
487
+ .attr('fill', palette.text)
488
+ .text(card.title);
489
+
490
+ // Separator + metadata
491
+ if (hasMeta) {
492
+ const separatorY = cy + CARD_HEADER_HEIGHT;
493
+
494
+ cg.append('line')
495
+ .attr('x1', cx)
496
+ .attr('y1', separatorY)
497
+ .attr('x2', cx + cardLayout.width)
498
+ .attr('y2', separatorY)
499
+ .attr('stroke', cardStroke)
500
+ .attr('stroke-opacity', 0.3)
501
+ .attr('stroke-width', 1);
502
+
503
+ let metaY = separatorY + CARD_SEPARATOR_GAP + CARD_META_FONT_SIZE;
504
+
505
+ // Tag metadata rows
506
+ for (const meta of tagMeta) {
507
+ cg.append('text')
508
+ .attr('x', cx + CARD_PADDING_X)
509
+ .attr('y', metaY)
510
+ .attr('font-size', CARD_META_FONT_SIZE)
511
+ .attr('fill', palette.textMuted)
512
+ .text(`${meta.label}: `);
513
+
514
+ const labelWidth = (meta.label.length + 2) * CARD_META_FONT_SIZE * 0.6;
515
+ cg.append('text')
516
+ .attr('x', cx + CARD_PADDING_X + labelWidth)
517
+ .attr('y', metaY)
518
+ .attr('font-size', CARD_META_FONT_SIZE)
519
+ .attr('fill', palette.text)
520
+ .text(meta.value);
521
+
522
+ metaY += CARD_META_LINE_HEIGHT;
523
+ }
524
+
525
+ // Detail lines
526
+ for (const detail of card.details) {
527
+ cg.append('text')
528
+ .attr('x', cx + CARD_PADDING_X)
529
+ .attr('y', metaY)
530
+ .attr('font-size', CARD_META_FONT_SIZE)
531
+ .attr('fill', palette.textMuted)
532
+ .text(detail);
533
+
534
+ metaY += CARD_META_LINE_HEIGHT;
535
+ }
536
+ }
537
+ }
538
+ }
539
+ }
540
+
541
+ // ============================================================
542
+ // Export convenience function
543
+ // ============================================================
544
+
545
+ export function renderKanbanForExport(
546
+ content: string,
547
+ theme: 'light' | 'dark' | 'transparent',
548
+ palette: PaletteColors
549
+ ): string {
550
+ const parsed = parseKanban(content, palette);
551
+ if (parsed.error || parsed.columns.length === 0) return '';
552
+
553
+ const isDark = theme === 'dark';
554
+ const layout = computeLayout(parsed, palette);
555
+
556
+ const container = document.createElement('div');
557
+ renderKanban(container, parsed, palette, isDark, undefined, {
558
+ width: layout.totalWidth,
559
+ height: layout.totalHeight,
560
+ });
561
+
562
+ const svgEl = container.querySelector('svg');
563
+ return svgEl?.outerHTML ?? '';
564
+ }
@@ -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
+ }