@diagrammo/dgmo 0.7.3 → 0.8.1

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 (62) hide show
  1. package/AGENTS.md +15 -20
  2. package/README.md +56 -58
  3. package/dist/cli.cjs +188 -181
  4. package/dist/index.cjs +3522 -1072
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +196 -43
  7. package/dist/index.d.ts +196 -43
  8. package/dist/index.js +3509 -1072
  9. package/dist/index.js.map +1 -1
  10. package/docs/language-reference.md +629 -289
  11. package/package.json +1 -1
  12. package/src/c4/layout.ts +6 -9
  13. package/src/c4/parser.ts +189 -83
  14. package/src/c4/renderer.ts +8 -9
  15. package/src/chart.ts +296 -83
  16. package/src/class/parser.ts +54 -37
  17. package/src/class/renderer.ts +8 -8
  18. package/src/cli.ts +8 -8
  19. package/src/colors.ts +4 -1
  20. package/src/completion.ts +757 -10
  21. package/src/d3.ts +324 -78
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +735 -241
  24. package/src/er/parser.ts +94 -76
  25. package/src/er/renderer.ts +6 -5
  26. package/src/gantt/parser.ts +144 -69
  27. package/src/gantt/renderer.ts +50 -14
  28. package/src/gantt/types.ts +3 -3
  29. package/src/graph/flowchart-parser.ts +97 -37
  30. package/src/graph/flowchart-renderer.ts +4 -3
  31. package/src/graph/state-parser.ts +50 -31
  32. package/src/graph/state-renderer.ts +4 -3
  33. package/src/index.ts +14 -5
  34. package/src/infra/compute.ts +1 -0
  35. package/src/infra/layout.ts +3 -0
  36. package/src/infra/parser.ts +291 -92
  37. package/src/infra/renderer.ts +172 -30
  38. package/src/infra/types.ts +5 -0
  39. package/src/initiative-status/layout.ts +1 -1
  40. package/src/initiative-status/parser.ts +121 -47
  41. package/src/initiative-status/renderer.ts +42 -23
  42. package/src/initiative-status/types.ts +10 -2
  43. package/src/kanban/parser.ts +60 -37
  44. package/src/kanban/renderer.ts +2 -2
  45. package/src/kanban/types.ts +1 -0
  46. package/src/org/layout.ts +9 -9
  47. package/src/org/parser.ts +39 -40
  48. package/src/org/renderer.ts +5 -6
  49. package/src/org/resolver.ts +26 -19
  50. package/src/render.ts +1 -1
  51. package/src/sequence/parser.ts +304 -95
  52. package/src/sequence/renderer.ts +9 -9
  53. package/src/sitemap/layout.ts +3 -4
  54. package/src/sitemap/parser.ts +57 -49
  55. package/src/sitemap/renderer.ts +6 -7
  56. package/src/utils/arrows.ts +25 -6
  57. package/src/utils/duration.ts +43 -7
  58. package/src/utils/legend-constants.ts +26 -0
  59. package/src/utils/legend-svg.ts +167 -0
  60. package/src/utils/parsing.ts +247 -7
  61. package/src/utils/tag-groups.ts +160 -15
  62. package/src/utils/title-constants.ts +9 -0
@@ -11,11 +11,11 @@ import type {
11
11
  ISGroup,
12
12
  InitiativeStatus,
13
13
  } from './types';
14
- import { VALID_STATUSES } from './types';
14
+ import { VALID_STATUSES, STATUS_ALIASES } from './types';
15
15
  import { inferParticipantType } from '../sequence/participant-inference';
16
16
  import { matchTagBlockHeading, injectDefaultTagMetadata, validateTagValues } from '../utils/tag-groups';
17
17
  import type { TagGroup } from '../utils/tag-groups';
18
- import { extractColor } from '../utils/parsing';
18
+ import { extractColor, parseFirstLine, ALL_CHART_TYPES, OPTION_NOCOLON_RE } from '../utils/parsing';
19
19
 
20
20
  // ============================================================
21
21
  // Heuristic — does this content look like an initiative-status diagram?
@@ -35,8 +35,10 @@ export function looksLikeInitiativeStatus(content: string): boolean {
35
35
  if (!trimmed || trimmed.startsWith('//')) continue;
36
36
  if (trimmed.match(/^chart\s*:/i)) continue;
37
37
  if (trimmed.match(/^title\s*:/i)) continue;
38
+ // Skip new-style first line (bare chart type name)
39
+ if (parseFirstLine(trimmed)) continue;
38
40
  if (trimmed.includes('->')) hasArrow = true;
39
- if (/\|\s*(done|wip|todo|na)\s*$/i.test(trimmed)) hasStatus = true;
41
+ if (/\|\s*(done|doing|wip|blocked|paused|waiting|todo|na)\s*$/i.test(trimmed)) hasStatus = true;
40
42
  // Indented arrow is a strong signal — only initiative-status uses this
41
43
  const isIndented = line.length > 0 && line !== trimmed && /^\s/.test(line);
42
44
  if (isIndented && (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed))) hasIndentedArrow = true;
@@ -80,18 +82,36 @@ export function parseNodeMetadata(
80
82
  // key: value pair
81
83
  const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
82
84
  const value = trimmed.slice(colonIdx + 1).trim();
83
- // Resolve alias to group name
84
- const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
85
- metadata[resolvedKey] = value;
85
+
86
+ // Handle explicit `status: keyword` form
87
+ if (rawKey === 'status') {
88
+ hadStatusWord = true;
89
+ const lower = value.toLowerCase();
90
+ const canonical = STATUS_ALIASES[lower] ?? lower;
91
+ if (VALID_STATUSES.includes(canonical)) {
92
+ status = canonical as InitiativeStatus;
93
+ } else if (lineNum !== undefined && diagnostics) {
94
+ const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
95
+ const hint = suggest(lower, allKnown);
96
+ const msg = `Unknown status "${value}"${hint ? `. ${hint}` : ''}`;
97
+ diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
98
+ }
99
+ } else {
100
+ // Resolve alias to group name
101
+ const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
102
+ metadata[resolvedKey] = value;
103
+ }
86
104
  } else {
87
- // Bare word — check if it's a status keyword
105
+ // Bare word — check if it's a status keyword (or alias)
88
106
  hadStatusWord = true;
89
107
  const lower = trimmed.toLowerCase();
90
- if (VALID_STATUSES.includes(lower)) {
91
- status = lower as InitiativeStatus;
108
+ const canonical = STATUS_ALIASES[lower] ?? lower;
109
+ if (VALID_STATUSES.includes(canonical)) {
110
+ status = canonical as InitiativeStatus;
92
111
  } else if (lineNum !== undefined && diagnostics) {
93
112
  // Unknown bare word — likely a status typo, emit warning
94
- const hint = suggest(lower, VALID_STATUSES);
113
+ const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
114
+ const hint = suggest(lower, allKnown);
95
115
  const msg = `Unknown status "${trimmed}"${hint ? `. ${hint}` : ''}`;
96
116
  diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
97
117
  }
@@ -108,10 +128,12 @@ export function parseNodeMetadata(
108
128
  function parseStatus(raw: string, line: number, diagnostics: DgmoError[]): InitiativeStatus {
109
129
  const trimmed = raw.trim().toLowerCase();
110
130
  if (!trimmed) return 'na';
111
- if (VALID_STATUSES.includes(trimmed)) return trimmed as InitiativeStatus;
131
+ const canonical = STATUS_ALIASES[trimmed] ?? trimmed;
132
+ if (VALID_STATUSES.includes(canonical)) return canonical as InitiativeStatus;
112
133
 
113
134
  // Unknown status — emit warning with suggestion
114
- const hint = suggest(trimmed, VALID_STATUSES);
135
+ const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
136
+ const hint = suggest(trimmed, allKnown);
115
137
  const msg = `Unknown status "${raw.trim()}"${hint ? `. ${hint}` : ''}`;
116
138
  diagnostics.push(makeDgmoError(line, msg, 'warning'));
117
139
  return null;
@@ -165,36 +187,32 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
165
187
  // Skip blanks and comments
166
188
  if (!trimmed || trimmed.startsWith('//')) continue;
167
189
 
168
- // chart: header
169
- const chartMatch = trimmed.match(/^chart\s*:\s*(.+)/i);
170
- if (chartMatch) {
171
- const chartType = chartMatch[1].trim().toLowerCase();
172
- if (chartType !== 'initiative-status') {
173
- const diag = makeDgmoError(lineNum, `Expected chart type "initiative-status", got "${chartType}"`);
190
+ // First line: chart type + optional title (new syntax: `initiative-status My Dashboard`)
191
+ const firstLineResult = parseFirstLine(trimmed);
192
+ if (firstLineResult && !contentStarted) {
193
+ if (firstLineResult.chartType !== 'initiative-status') {
194
+ const diag = makeDgmoError(lineNum, `Expected chart type "initiative-status", got "${firstLineResult.chartType}"`);
174
195
  result.diagnostics.push(diag);
175
196
  result.error = formatDgmoError(diag);
176
197
  return result;
177
198
  }
199
+ if (firstLineResult.title) {
200
+ result.title = firstLineResult.title;
201
+ result.titleLineNumber = lineNum;
202
+ }
178
203
  continue;
179
204
  }
180
205
 
181
- // title: header
182
- const titleMatch = trimmed.match(/^title\s*:\s*(.+)/i);
183
- if (titleMatch) {
184
- result.title = titleMatch[1].trim();
185
- result.titleLineNumber = lineNum;
186
- continue;
187
- }
188
-
189
- // hide: directive — parse before tag blocks and content
190
- const hideMatch = trimmed.match(/^hide\s*:\s*(.+)/i);
191
- if (hideMatch) {
206
+ // hide directive (no colon): `hide phase Planning, phase Review`
207
+ const hideMatch = trimmed.match(/^hide\s+(.+)/i);
208
+ if (hideMatch && !trimmed.match(/^hide\s*\|/)) {
209
+ // Parse comma-separated tag-value pairs: `phase Planning, phase Review`
192
210
  const pairs = hideMatch[1].split(',');
193
211
  for (const pair of pairs) {
194
- const colonIdx = pair.indexOf(':');
195
- if (colonIdx >= 0) {
196
- const groupKey = pair.slice(0, colonIdx).trim().toLowerCase();
197
- const value = pair.slice(colonIdx + 1).trim().toLowerCase();
212
+ const tokens = pair.trim().split(/\s+/);
213
+ if (tokens.length >= 2) {
214
+ const groupKey = tokens[0].toLowerCase();
215
+ const value = tokens.slice(1).join(' ').toLowerCase();
198
216
  if (groupKey && value) {
199
217
  if (!result.initialHiddenTagValues.has(groupKey)) {
200
218
  result.initialHiddenTagValues.set(groupKey, new Set());
@@ -206,6 +224,20 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
206
224
  continue;
207
225
  }
208
226
 
227
+ // Options (space-separated, non-indented): `active-tag Priority`
228
+ if (!contentStarted && measureIndent(raw) === 0) {
229
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
230
+ if (optMatch) {
231
+ const key = optMatch[1].toLowerCase();
232
+ const value = optMatch[2].trim();
233
+ // Only recognize known option keys (not node content)
234
+ if (key === 'active-tag' || key === 'sort') {
235
+ result.options[key] = value;
236
+ continue;
237
+ }
238
+ }
239
+ }
240
+
209
241
  // Tag group heading — must be checked BEFORE group/node/edge matching
210
242
  const tagBlockMatch = matchTagBlockHeading(trimmed);
211
243
  if (tagBlockMatch) {
@@ -216,7 +248,10 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
216
248
  continue;
217
249
  }
218
250
  if (tagBlockMatch.deprecated) {
219
- pushWarning(lineNum, `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`);
251
+ result.diagnostics.push(
252
+ makeDgmoError(lineNum, `'## ${tagBlockMatch.name}' is no longer supported — use 'tag ${tagBlockMatch.name}' instead`)
253
+ );
254
+ continue;
220
255
  }
221
256
  currentTagGroup = {
222
257
  name: tagBlockMatch.name,
@@ -227,35 +262,47 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
227
262
  if (tagBlockMatch.alias) {
228
263
  aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
229
264
  }
265
+ // Handle inline values from single-line tag declaration
266
+ if (tagBlockMatch.inlineValues) {
267
+ for (const rawVal of tagBlockMatch.inlineValues) {
268
+ const { label, color } = extractColor(rawVal);
269
+ currentTagGroup.entries.push({
270
+ value: label,
271
+ color: color ?? '',
272
+ lineNumber: lineNum,
273
+ });
274
+ }
275
+ // First entry is the default
276
+ if (currentTagGroup.entries.length > 0) {
277
+ currentTagGroup.defaultValue = currentTagGroup.entries[0].value;
278
+ }
279
+ }
230
280
  result.tagGroups.push(currentTagGroup);
231
281
  continue;
232
282
  }
233
283
 
234
- // Tag group entries (indented Value(color) [default] under tag heading)
284
+ // Tag group entries (indented Value(color) under tag heading — first value is the default)
235
285
  if (currentTagGroup && !contentStarted) {
236
286
  const indent = measureIndent(raw);
237
287
  if (indent > 0) {
238
- const isDefault = /\bdefault\s*$/i.test(trimmed);
239
- const entryText = isDefault
240
- ? trimmed.replace(/\s+default\s*$/i, '').trim()
241
- : trimmed;
242
- const { label, color } = extractColor(entryText);
243
- if (isDefault) {
244
- currentTagGroup.defaultValue = label;
245
- }
288
+ const { label, color } = extractColor(trimmed);
246
289
  currentTagGroup.entries.push({
247
290
  value: label,
248
291
  color: color ?? '',
249
292
  lineNumber: lineNum,
250
293
  });
294
+ // First entry is the default
295
+ if (currentTagGroup.entries.length === 1) {
296
+ currentTagGroup.defaultValue = label;
297
+ }
251
298
  continue;
252
299
  }
253
300
  // Non-indented line after tag group — close and fall through
254
301
  currentTagGroup = null;
255
302
  }
256
303
 
257
- // Group header: [Group Name]
258
- const groupMatch = trimmed.match(/^\[(.+)\]\s*$/);
304
+ // Group header: [Group Name] or [Group Name] | metadata
305
+ const groupMatch = trimmed.match(/^\[(.+?)\]\s*(?:\|\s*(.+))?$/);
259
306
  if (groupMatch) {
260
307
  contentStarted = true;
261
308
  currentTagGroup = null;
@@ -263,7 +310,26 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
263
310
  if (currentGroup) {
264
311
  result.groups.push(currentGroup);
265
312
  }
266
- currentGroup = { label: groupMatch[1], nodeLabels: [], lineNumber: lineNum };
313
+ const groupMeta: Record<string, string> = {};
314
+ if (groupMatch[2]) {
315
+ // Parse pipe metadata for group (only key:value pairs, no status)
316
+ const items = groupMatch[2].split(',');
317
+ for (const item of items) {
318
+ const ci = item.indexOf(':');
319
+ if (ci >= 0) {
320
+ const rawKey = item.slice(0, ci).trim().toLowerCase();
321
+ const value = item.slice(ci + 1).trim();
322
+ const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
323
+ groupMeta[resolvedKey] = value;
324
+ }
325
+ }
326
+ }
327
+ currentGroup = {
328
+ label: groupMatch[1],
329
+ nodeLabels: [],
330
+ lineNumber: lineNum,
331
+ metadata: Object.keys(groupMeta).length > 0 ? groupMeta : undefined,
332
+ };
267
333
  continue;
268
334
  }
269
335
 
@@ -307,6 +373,14 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
307
373
  } else {
308
374
  nodeLabels.add(node.label);
309
375
  }
376
+ // Cascade group metadata into node (group provides defaults, node overrides)
377
+ if (currentGroup && isIndented && currentGroup.metadata) {
378
+ for (const [key, val] of Object.entries(currentGroup.metadata)) {
379
+ if (!(key in node.metadata)) {
380
+ node.metadata[key] = val;
381
+ }
382
+ }
383
+ }
310
384
  result.nodes.push(node);
311
385
  // Add to current group if indented
312
386
  if (currentGroup && isIndented) {
@@ -395,7 +469,7 @@ function parseEdgeLine(
395
469
  // or: <source> -<label>-> <target> [| <status>]
396
470
 
397
471
  // Check for labeled arrow form: SOURCE -LABEL-> TARGET [| status]
398
- const labeledMatch = trimmed.match(/^(\S+)\s+-(.+)->\s+(.+)$/);
472
+ const labeledMatch = trimmed.match(/^(\S+)\s*-(.+)->\s*(.+)$/);
399
473
  if (labeledMatch) {
400
474
  const source = labeledMatch[1];
401
475
  const label = labeledMatch[2].trim();
@@ -10,15 +10,15 @@ import {
10
10
  LEGEND_HEIGHT,
11
11
  LEGEND_PILL_PAD,
12
12
  LEGEND_PILL_FONT_SIZE,
13
- LEGEND_PILL_FONT_W,
14
13
  LEGEND_CAPSULE_PAD,
15
14
  LEGEND_DOT_R,
16
15
  LEGEND_ENTRY_FONT_SIZE,
17
- LEGEND_ENTRY_FONT_W,
18
16
  LEGEND_ENTRY_DOT_GAP,
19
17
  LEGEND_ENTRY_TRAIL,
20
18
  LEGEND_GROUP_GAP,
19
+ measureLegendText,
21
20
  } from '../utils/legend-constants';
21
+ import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y } from '../utils/title-constants';
22
22
  import { contrastText, mix } from '../palettes/color-utils';
23
23
  import type { TagGroup } from '../utils/tag-groups';
24
24
  import type { PaletteColors } from '../palettes';
@@ -55,11 +55,12 @@ const COLLAPSE_BAR_HEIGHT = 6;
55
55
 
56
56
  function statusColor(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
57
57
  switch (status) {
58
- case 'done': return palette.colors.green;
59
- case 'wip': return palette.colors.yellow;
60
- case 'todo': return palette.colors.red;
61
- case 'na': return isDark ? palette.colors.gray : '#2e3440';
62
- default: return palette.textMuted;
58
+ case 'done': return palette.colors.green;
59
+ case 'doing': return palette.colors.blue;
60
+ case 'blocked': return palette.colors.orange;
61
+ case 'todo': return palette.colors.red;
62
+ case 'na': return isDark ? palette.colors.gray : '#2e3440';
63
+ default: return palette.textMuted;
63
64
  }
64
65
  }
65
66
 
@@ -91,13 +92,14 @@ interface ISLegendEntry {
91
92
  }
92
93
 
93
94
  const IS_STATUS_LABELS: Record<string, string> = {
94
- done: 'Done',
95
- wip: 'In Progress',
96
- todo: 'To Do',
97
- na: 'N/A',
95
+ done: 'Done',
96
+ doing: 'In Progress',
97
+ blocked: 'Blocked',
98
+ todo: 'To Do',
99
+ na: 'N/A',
98
100
  };
99
101
 
100
- const IS_STATUS_ORDER: InitiativeStatus[] = ['todo', 'wip', 'done', 'na'];
102
+ const IS_STATUS_ORDER: InitiativeStatus[] = ['todo', 'blocked', 'doing', 'done', 'na'];
101
103
 
102
104
  function collectStatuses(parsed: ParsedInitiativeStatus): ISLegendEntry[] {
103
105
  const present = new Set<string>();
@@ -114,7 +116,7 @@ const LEGEND_GROUP_NAME = 'Status';
114
116
  function legendEntriesWidth(entries: ISLegendEntry[]): number {
115
117
  let w = 0;
116
118
  for (const e of entries) {
117
- w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
119
+ w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.label, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
118
120
  }
119
121
  return w;
120
122
  }
@@ -556,11 +558,11 @@ export function renderInitiativeStatus(
556
558
  .append('text')
557
559
  .attr('class', 'chart-title')
558
560
  .attr('x', width / 2)
559
- .attr('y', 30)
561
+ .attr('y', TITLE_Y)
560
562
  .attr('text-anchor', 'middle')
561
563
  .attr('fill', palette.text)
562
- .attr('font-size', '20px')
563
- .attr('font-weight', '700')
564
+ .attr('font-size', TITLE_FONT_SIZE)
565
+ .attr('font-weight', TITLE_FONT_WEIGHT)
564
566
  .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
565
567
  .text(parsed.title);
566
568
 
@@ -599,7 +601,7 @@ export function renderInitiativeStatus(
599
601
  color: statusColor(e.statusKey, palette, isDark),
600
602
  value: e.statusKey ?? 'na',
601
603
  }));
602
- const pillW = LEGEND_GROUP_NAME.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
604
+ const pillW = measureLegendText(LEGEND_GROUP_NAME, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
603
605
  const entrW = legendEntriesWidth(legendEntries);
604
606
  legendGroups.push({
605
607
  name: LEGEND_GROUP_NAME,
@@ -617,10 +619,10 @@ export function renderInitiativeStatus(
617
619
  color: e.color || palette.textMuted,
618
620
  value: e.value.toLowerCase(),
619
621
  }));
620
- const pillW = tg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
622
+ const pillW = measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
621
623
  let entrW = 0;
622
624
  for (const e of entries) {
623
- entrW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
625
+ entrW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.label, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
624
626
  }
625
627
  legendGroups.push({
626
628
  name: tg.name,
@@ -645,7 +647,7 @@ export function renderInitiativeStatus(
645
647
  let totalLegendW = 0;
646
648
  for (const lg of visibleLegendGroups) {
647
649
  const isActive = lg.isStatus ? isStatusExpanded : (activeKey === lg.key);
648
- const pillW = lg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
650
+ const pillW = measureLegendText(lg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
649
651
  totalLegendW += isActive ? lg.width : pillW;
650
652
  totalLegendW += LEGEND_GROUP_GAP;
651
653
  }
@@ -663,7 +665,7 @@ export function renderInitiativeStatus(
663
665
 
664
666
  for (const lg of visibleLegendGroups) {
665
667
  const isActive = lg.isStatus ? isStatusExpanded : (activeKey === lg.key);
666
- const pillW = lg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
668
+ const pillW = measureLegendText(lg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
667
669
  const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
668
670
  const groupW = isActive ? lg.width : pillW;
669
671
 
@@ -737,7 +739,7 @@ export function renderInitiativeStatus(
737
739
 
738
740
  for (const entry of lg.entries) {
739
741
  const isHidden = hiddenSet?.has(entry.value) ?? false;
740
- const estimatedTextW = entry.label.length * LEGEND_ENTRY_FONT_W;
742
+ const estimatedTextW = measureLegendText(entry.label, LEGEND_ENTRY_FONT_SIZE);
741
743
 
742
744
  const entryG = gEl.append('g')
743
745
  .attr('data-legend-entry', entry.value)
@@ -1024,7 +1026,7 @@ export function renderInitiativeStatus(
1024
1026
  .attr('stroke', 'transparent')
1025
1027
  .attr('stroke-width', Math.max(6, Math.round(16 / (edge.parallelCount ?? 1))));
1026
1028
 
1027
- edgeG
1029
+ const edgePath = edgeG
1028
1030
  .append('path')
1029
1031
  .attr('d', pathD)
1030
1032
  .attr('fill', 'none')
@@ -1032,6 +1034,11 @@ export function renderInitiativeStatus(
1032
1034
  .attr('stroke-width', EDGE_STROKE_WIDTH)
1033
1035
  .attr('marker-end', `url(#${markerId})`)
1034
1036
  .attr('class', 'is-edge');
1037
+
1038
+ // Dashed stroke for 'todo' edges
1039
+ if (edge.status === 'todo') {
1040
+ edgePath.attr('stroke-dasharray', '6 3');
1041
+ }
1035
1042
  }
1036
1043
 
1037
1044
  // Edge label placed on its own path
@@ -1104,6 +1111,18 @@ export function renderInitiativeStatus(
1104
1111
  const stroke = nodeStroke(node.status, palette, isDark);
1105
1112
  renderNodeShape(nodeG, node.shape, node.width, node.height, fill, stroke);
1106
1113
 
1114
+ // Apply dashed border for 'todo' status
1115
+ if (node.status === 'todo') {
1116
+ nodeG.selectAll('rect, ellipse, polygon, circle')
1117
+ .each(function () {
1118
+ const el = d3Selection.select(this);
1119
+ // Only dash stroked elements (not fills or transparent hit areas)
1120
+ if (el.attr('stroke') && el.attr('stroke') !== 'none' && el.attr('stroke') !== 'transparent') {
1121
+ el.attr('stroke-dasharray', '6 3');
1122
+ }
1123
+ });
1124
+ }
1125
+
1107
1126
  const textColor = contrastText(fill, '#eceff4', '#2e3440');
1108
1127
 
1109
1128
  // Label placement: actors put label below the figure, others center inside
@@ -6,9 +6,16 @@ import type { DgmoError } from '../diagnostics';
6
6
  import type { ParticipantType } from '../sequence/parser';
7
7
  import type { TagGroup } from '../utils/tag-groups';
8
8
 
9
- export type InitiativeStatus = 'done' | 'wip' | 'todo' | 'na' | null;
9
+ export type InitiativeStatus = 'done' | 'doing' | 'blocked' | 'todo' | 'na' | null;
10
10
 
11
- export const VALID_STATUSES: readonly string[] = ['done', 'wip', 'todo', 'na'];
11
+ export const VALID_STATUSES: readonly string[] = ['done', 'doing', 'blocked', 'todo', 'na'];
12
+
13
+ /** Aliases that map to canonical status values during parsing. */
14
+ export const STATUS_ALIASES: Record<string, string> = {
15
+ wip: 'doing',
16
+ paused: 'blocked',
17
+ waiting: 'blocked',
18
+ };
12
19
 
13
20
  export interface ISNode {
14
21
  label: string;
@@ -31,6 +38,7 @@ export interface ISGroup {
31
38
  label: string;
32
39
  nodeLabels: string[];
33
40
  lineNumber: number;
41
+ metadata?: Record<string, string>;
34
42
  }
35
43
 
36
44
  export interface ParsedInitiativeStatus {
@@ -5,9 +5,9 @@ import { matchTagBlockHeading } from '../utils/tag-groups';
5
5
  import {
6
6
  measureIndent,
7
7
  extractColor,
8
- CHART_TYPE_RE,
9
- TITLE_RE,
10
- OPTION_RE,
8
+ parsePipeMetadata,
9
+ parseFirstLine,
10
+ OPTION_NOCOLON_RE,
11
11
  } from '../utils/parsing';
12
12
  import type {
13
13
  ParsedKanban,
@@ -25,6 +25,14 @@ const COLUMN_RE = /^\[(.+?)\](?:\s*\(([^)]+)\))?\s*(?:\|\s*(.+))?$/;
25
25
  // Legacy delimiter
26
26
  const LEGACY_COLUMN_RE = /^==\s+(.+?)\s*(?:\[wip:\s*(\d+)\])?\s*==$/;
27
27
 
28
+ /** Known kanban options (key-value). */
29
+ const KNOWN_OPTIONS = new Set([
30
+ 'color-off', 'hide',
31
+ ]);
32
+ /** Known kanban boolean options (bare keyword = on). */
33
+ const KNOWN_BOOLEANS = new Set<string>([
34
+ ]);
35
+
28
36
  // ============================================================
29
37
  // Parser
30
38
  // ============================================================
@@ -88,12 +96,11 @@ export function parseKanban(
88
96
 
89
97
  // --- Header phase ---
90
98
 
91
- // chart: type
99
+ // Extract chart type + title from first line (e.g. `kanban Sprint 12`)
92
100
  if (!contentStarted && !currentTagGroup) {
93
- const chartMatch = trimmed.match(CHART_TYPE_RE);
94
- if (chartMatch) {
95
- const chartType = chartMatch[1].trim().toLowerCase();
96
- if (chartType !== 'kanban') {
101
+ const firstLine = parseFirstLine(trimmed);
102
+ if (firstLine) {
103
+ if (firstLine.chartType !== 'kanban') {
97
104
  const allTypes = [
98
105
  'kanban',
99
106
  'org',
@@ -105,21 +112,15 @@ export function parseKanban(
105
112
  'line',
106
113
  'pie',
107
114
  ];
108
- let msg = `Expected chart type "kanban", got "${chartType}"`;
109
- const hint = suggest(chartType, allTypes);
115
+ let msg = `Expected chart type "kanban", got "${firstLine.chartType}"`;
116
+ const hint = suggest(firstLine.chartType, allTypes);
110
117
  if (hint) msg += `. ${hint}`;
111
118
  return fail(lineNumber, msg);
112
119
  }
113
- continue;
114
- }
115
- }
116
-
117
- // title: value
118
- if (!contentStarted && !currentTagGroup) {
119
- const titleMatch = trimmed.match(TITLE_RE);
120
- if (titleMatch) {
121
- result.title = titleMatch[1].trim();
122
- result.titleLineNumber = lineNumber;
120
+ if (firstLine.title) {
121
+ result.title = firstLine.title;
122
+ result.titleLineNumber = lineNumber;
123
+ }
123
124
  continue;
124
125
  }
125
126
  }
@@ -130,7 +131,8 @@ export function parseKanban(
130
131
  const tagBlockMatch = matchTagBlockHeading(trimmed);
131
132
  if (tagBlockMatch) {
132
133
  if (tagBlockMatch.deprecated) {
133
- warn(lineNumber, `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`);
134
+ result.diagnostics.push(makeDgmoError(lineNumber, `'## ${tagBlockMatch.name}' is no longer supported — use 'tag: ${tagBlockMatch.name}' instead`));
135
+ continue;
134
136
  }
135
137
  currentTagGroup = {
136
138
  name: tagBlockMatch.name,
@@ -146,27 +148,30 @@ export function parseKanban(
146
148
  }
147
149
  }
148
150
 
149
- // Generic header options (key: value before content/tag groups)
151
+ // Generic header options (space-separated: `key value` or bare boolean `key`)
152
+ // Only match known option keys to avoid swallowing content lines
150
153
  if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
151
- const optMatch = trimmed.match(OPTION_RE);
154
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
152
155
  if (optMatch && !COLUMN_RE.test(trimmed)) {
153
156
  const key = optMatch[1].trim().toLowerCase();
154
- if (key !== 'chart' && key !== 'title') {
157
+ if (KNOWN_OPTIONS.has(key)) {
155
158
  result.options[key] = optMatch[2].trim();
156
159
  continue;
157
160
  }
158
161
  }
162
+ // Bare boolean option (single keyword, no value)
163
+ if (KNOWN_BOOLEANS.has(trimmed.toLowerCase()) && !COLUMN_RE.test(trimmed)) {
164
+ result.options[trimmed.toLowerCase()] = 'on';
165
+ continue;
166
+ }
159
167
  }
160
168
 
161
- // Tag group entries (indented Value(color) [default] under tag: heading)
169
+ // Tag group entries (indented Value(color) under tag heading)
170
+ // First entry is implicitly the default.
162
171
  if (currentTagGroup && !contentStarted) {
163
172
  const indent = measureIndent(line);
164
173
  if (indent > 0) {
165
- const isDefault = /\bdefault\s*$/.test(trimmed);
166
- const entryText = isDefault
167
- ? trimmed.replace(/\s+default\s*$/, '').trim()
168
- : trimmed;
169
- const { label, color } = extractColor(entryText, palette);
174
+ const { label, color } = extractColor(trimmed, palette);
170
175
  if (!color) {
171
176
  warn(
172
177
  lineNumber,
@@ -174,7 +179,8 @@ export function parseKanban(
174
179
  );
175
180
  continue;
176
181
  }
177
- if (isDefault) {
182
+ // First entry is the default
183
+ if (currentTagGroup.entries.length === 0) {
178
184
  currentTagGroup.defaultValue = label;
179
185
  }
180
186
  currentTagGroup.entries.push({
@@ -196,7 +202,7 @@ export function parseKanban(
196
202
  if (LEGACY_COLUMN_RE.test(trimmed)) {
197
203
  const legacyMatch = trimmed.match(LEGACY_COLUMN_RE)!;
198
204
  const name = legacyMatch[1].replace(/\s*\(.*\)\s*$/, '').trim();
199
- warn(lineNumber, `'== ${name} ==' is no longer supported. Use '[${name}]' instead`);
205
+ result.diagnostics.push(makeDgmoError(lineNumber, `'== ${name} ==' is no longer supported. Use '[${name}]' instead`));
200
206
  continue;
201
207
  }
202
208
 
@@ -221,16 +227,22 @@ export function parseKanban(
221
227
  columnCounter++;
222
228
  const colName = columnMatch[1].trim();
223
229
  const colColor = columnMatch[2]
224
- ? resolveColor(columnMatch[2].trim(), palette)
230
+ ? resolveColor(columnMatch[2].trim(), palette) ?? undefined
225
231
  : undefined;
226
232
 
227
- // Parse WIP limit from pipe metadata (e.g., "| wip: 3")
233
+ // Parse pipe metadata (e.g., "| wip: 3, t: Sprint1")
228
234
  let wipLimit: number | undefined;
235
+ const columnMetadata: Record<string, string> = {};
229
236
  const pipeStr = columnMatch[3];
230
237
  if (pipeStr) {
231
- const wipMatch = pipeStr.match(/\bwip\s*:\s*(\d+)\b/i);
232
- if (wipMatch) {
233
- wipLimit = parseInt(wipMatch[1], 10);
238
+ const pipeSegments = ['', pipeStr];
239
+ Object.assign(columnMetadata, parsePipeMetadata(pipeSegments, aliasMap));
240
+ // Extract wip from metadata
241
+ if (columnMetadata.wip) {
242
+ const wipVal = parseInt(columnMetadata.wip, 10);
243
+ if (!isNaN(wipVal)) {
244
+ wipLimit = wipVal;
245
+ }
234
246
  }
235
247
  }
236
248
 
@@ -241,6 +253,7 @@ export function parseKanban(
241
253
  color: colColor,
242
254
  cards: [],
243
255
  lineNumber,
256
+ metadata: columnMetadata,
244
257
  };
245
258
  result.columns.push(currentColumn);
246
259
  continue;
@@ -274,6 +287,16 @@ export function parseKanban(
274
287
  aliasMap,
275
288
  palette
276
289
  );
290
+ // Cascade column metadata to card tags (card overrides on conflict)
291
+ // Exclude 'wip' from cascading — it's a column-level property, not a card tag
292
+ if (currentColumn.metadata) {
293
+ for (const [key, value] of Object.entries(currentColumn.metadata)) {
294
+ if (key === 'wip') continue;
295
+ if (!(key in card.tags)) {
296
+ card.tags[key] = value;
297
+ }
298
+ }
299
+ }
277
300
  cardBaseIndent = indent;
278
301
  currentCard = card;
279
302
  currentColumn.cards.push(card);