@diagrammo/dgmo 0.32.1 → 0.33.0

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 (57) hide show
  1. package/.cursorrules +7 -2
  2. package/.github/copilot-instructions.md +7 -2
  3. package/.windsurfrules +7 -2
  4. package/SKILL.md +7 -2
  5. package/dist/advanced.cjs +981 -170
  6. package/dist/advanced.d.cts +35 -1
  7. package/dist/advanced.d.ts +35 -1
  8. package/dist/advanced.js +980 -170
  9. package/dist/auto.cjs +915 -171
  10. package/dist/auto.js +93 -93
  11. package/dist/auto.mjs +915 -171
  12. package/dist/cli.cjs +136 -134
  13. package/dist/index.cjs +981 -170
  14. package/dist/index.d.cts +35 -1
  15. package/dist/index.d.ts +35 -1
  16. package/dist/index.js +980 -170
  17. package/docs/ai-integration.md +7 -2
  18. package/docs/language-reference.md +39 -8
  19. package/package.json +2 -2
  20. package/src/advanced.ts +6 -1
  21. package/src/boxes-and-lines/layout-grouped.ts +909 -0
  22. package/src/boxes-and-lines/layout-search.ts +18 -1
  23. package/src/boxes-and-lines/parser.ts +3 -2
  24. package/src/c4/layout.ts +2 -1
  25. package/src/c4/parser.ts +3 -2
  26. package/src/c4/renderer.ts +3 -2
  27. package/src/cli.ts +68 -0
  28. package/src/er/parser.ts +2 -1
  29. package/src/er/renderer.ts +2 -1
  30. package/src/gantt/parser.ts +3 -5
  31. package/src/gantt/renderer.ts +7 -6
  32. package/src/index.ts +6 -1
  33. package/src/infra/parser.ts +2 -1
  34. package/src/infra/renderer.ts +4 -3
  35. package/src/journey-map/parser.ts +27 -8
  36. package/src/journey-map/renderer.ts +26 -9
  37. package/src/kanban/parser.ts +4 -3
  38. package/src/kanban/renderer.ts +6 -5
  39. package/src/map/completion.ts +116 -0
  40. package/src/map/layout.ts +4 -3
  41. package/src/map/parser.ts +3 -2
  42. package/src/mindmap/parser.ts +4 -3
  43. package/src/mindmap/renderer.ts +2 -1
  44. package/src/org/layout.ts +3 -2
  45. package/src/org/parser.ts +3 -2
  46. package/src/org/renderer.ts +5 -4
  47. package/src/pert/parser.ts +3 -2
  48. package/src/sequence/renderer.ts +10 -12
  49. package/src/sequence/tag-resolution.ts +2 -1
  50. package/src/sitemap/layout.ts +3 -2
  51. package/src/sitemap/parser.ts +36 -2
  52. package/src/sitemap/renderer.ts +12 -6
  53. package/src/timeline/renderer.ts +4 -3
  54. package/src/utils/legend-d3.ts +4 -3
  55. package/src/utils/legend-svg.ts +3 -2
  56. package/src/utils/tag-groups.ts +28 -7
  57. package/src/visualizations/parse.ts +2 -1
@@ -22,6 +22,7 @@ import {
22
22
  type BLLayoutEdge,
23
23
  } from './layout';
24
24
  import { layeredCandidates } from './layout-layered';
25
+ import { groupedTierCandidates } from './layout-grouped';
25
26
 
26
27
  type Pt = { x: number; y: number };
27
28
 
@@ -1528,6 +1529,21 @@ export async function layoutBoxesAndLinesSearch(
1528
1529
  /* ignore */
1529
1530
  }
1530
1531
 
1532
+ // Tier-banded grouped candidates. The dagre path treats `[Group]` containers
1533
+ // as compound clusters with no tier ORDER and no cycle-aware routing, so
1534
+ // tiered architecture diagrams collide bands side-by-side and balloon on
1535
+ // back-edges. This generator orders the groups into disjoint rank bands and
1536
+ // routes against-tier edges around the periphery. Additive — kept only on a
1537
+ // strict badness win. Skipped when any group is collapsed (the generator
1538
+ // models fully-expanded groups only).
1539
+ if (parsed.groups.length > 0 && collapsedGroupLabels.size === 0) {
1540
+ try {
1541
+ layered = layered.concat(groupedTierCandidates(parsed, sizes));
1542
+ } catch {
1543
+ /* ignore */
1544
+ }
1545
+ }
1546
+
1531
1547
  // Stage 1: rank the dagre pool with the cheap straight-segment counter — a
1532
1548
  // cheap proxy to pick which candidates are worth the expensive exact scoring.
1533
1549
  // Widen REFINE_K a little since the proxy only sees crossings, not O/P.
@@ -1536,7 +1552,8 @@ export async function layoutBoxesAndLinesSearch(
1536
1552
  // for both operands on every comparison (O(C log C) calls vs C). The score is a
1537
1553
  // pure function of the layout, so the resulting order is identical.
1538
1554
  const fastKey = new Map<BLLayoutResult, number>();
1539
- for (const lay of pool) fastKey.set(lay, objective(lay, countCrossingsFast(lay)));
1555
+ for (const lay of pool)
1556
+ fastKey.set(lay, objective(lay, countCrossingsFast(lay)));
1540
1557
  pool.sort((a, b) => fastKey.get(a)! - fastKey.get(b)!);
1541
1558
  const refineK = Math.min(REFINE_K, pool.length);
1542
1559
 
@@ -21,6 +21,7 @@ import {
21
21
  stripDefaultModifier,
22
22
  finalizeAutoTagColors,
23
23
  AUTO_TAG_COLOR_SENTINEL,
24
+ tagAttrKey,
24
25
  } from '../utils/tag-groups';
25
26
  import type { TagGroup } from '../utils/tag-groups';
26
27
  import type { Writable } from '../utils/brand';
@@ -399,12 +400,12 @@ export function parseBoxesAndLines(
399
400
  if (tagBlockMatch.alias) {
400
401
  metaAliasMap.set(
401
402
  normalizeName(tagBlockMatch.alias),
402
- tagBlockMatch.name.toLowerCase()
403
+ tagAttrKey(tagBlockMatch.name)
403
404
  );
404
405
  }
405
406
  metaAliasMap.set(
406
407
  normalizeName(tagBlockMatch.name),
407
- tagBlockMatch.name.toLowerCase()
408
+ tagAttrKey(tagBlockMatch.name)
408
409
  );
409
410
  if (tagBlockMatch.inlineValues) {
410
411
  for (const rawVal of tagBlockMatch.inlineValues) {
package/src/c4/layout.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // C4 Context Diagram Layout Engine (dagre)
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import dagre from '@dagrejs/dagre';
6
7
  import type {
7
8
  ParsedC4,
@@ -619,7 +620,7 @@ function resolveNodeColor(
619
620
  (g) => g.name.toLowerCase() === activeGroupName.toLowerCase()
620
621
  );
621
622
  if (!group) return undefined;
622
- const key = group.name.toLowerCase();
623
+ const key = tagAttrKey(group.name);
623
624
  // Walk inheritance chain: element → ancestors (container → system)
624
625
  let metaValue: string | undefined = el.metadata[key];
625
626
  if (!metaValue && ancestors) {
package/src/c4/parser.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  validateTagGroupNames,
25
25
  finalizeAutoTagColors,
26
26
  AUTO_TAG_COLOR_SENTINEL,
27
+ tagAttrKey,
27
28
  } from '../utils/tag-groups';
28
29
  import { inferParticipantType } from '../sequence/participant-inference';
29
30
  import {
@@ -416,12 +417,12 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
416
417
  if (tagBlockMatch.alias) {
417
418
  metaAliasMap.set(
418
419
  normalizeName(tagBlockMatch.alias),
419
- tagBlockMatch.name.toLowerCase()
420
+ tagAttrKey(tagBlockMatch.name)
420
421
  );
421
422
  }
422
423
  metaAliasMap.set(
423
424
  normalizeName(tagBlockMatch.name),
424
- tagBlockMatch.name.toLowerCase()
425
+ tagAttrKey(tagBlockMatch.name)
425
426
  );
426
427
  result.tagGroups.push(currentTagGroup);
427
428
  continue;
@@ -2,6 +2,7 @@
2
2
  // C4 Context Diagram SVG Renderer
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import * as d3Selection from 'd3-selection';
6
7
  import * as d3Shape from 'd3-shape';
7
8
  import { FONT_FAMILY } from '../fonts';
@@ -432,7 +433,7 @@ export function renderC4Context(
432
433
  .attr('data-node-id', node.id);
433
434
 
434
435
  if (activeTagGroup) {
435
- const tagKey = activeTagGroup.toLowerCase();
436
+ const tagKey = tagAttrKey(activeTagGroup);
436
437
  const tagValue = node.metadata[tagKey];
437
438
  if (tagValue) {
438
439
  nodeG.attr(`data-tag-${tagKey}`, tagValue.toLowerCase());
@@ -1517,7 +1518,7 @@ export function renderC4Containers(
1517
1518
  .attr('data-node-id', node.id);
1518
1519
 
1519
1520
  if (activeTagGroup) {
1520
- const tagKey = activeTagGroup.toLowerCase();
1521
+ const tagKey = tagAttrKey(activeTagGroup);
1521
1522
  const tagValue = node.metadata[tagKey];
1522
1523
  if (tagValue) {
1523
1524
  nodeG.attr(`data-tag-${tagKey}`, tagValue.toLowerCase());
package/src/cli.ts CHANGED
@@ -26,6 +26,8 @@ import {
26
26
  } from './migrate';
27
27
  import { migrateEmbedded } from './migrate/embedded';
28
28
  import { renderBanner } from './cli-banner';
29
+ import { searchMapLocations } from './map/completion';
30
+ import { loadMapData } from './map/load-data';
29
31
 
30
32
  // Derived from the palette registry so new palettes are auto-included.
31
33
  const PALETTES = getAvailablePalettes().map((p) => p.id);
@@ -470,6 +472,7 @@ function printHelp(): void {
470
472
  cat input.dgmo | dgmo [options]
471
473
  dgmo cat <file> Display file with syntax highlighting
472
474
  dgmo migrate <path> Convert legacy "|" metadata to §1.4 grammar
475
+ dgmo map search <query> Find the map place token (city or IATA code)
473
476
 
474
477
  Render a .dgmo file to PNG (default) or SVG.
475
478
 
@@ -859,6 +862,66 @@ async function runMigrateCommand(args: string[]): Promise<void> {
859
862
  }
860
863
  }
861
864
 
865
+ // `dgmo map search <query>` — discover the exact place token the map resolver
866
+ // expects (city name or bundled IATA airport code), so authors never guess
867
+ // (e.g. "New York" → `New York City`; "kennedy" → `JFK`). Searches cities +
868
+ // the bundled airport set; `--json` for machine output, `--limit N` to widen.
869
+ async function runMapCommand(args: string[]): Promise<void> {
870
+ const sub = args[0];
871
+ const json = args.includes('--json');
872
+ const limFlag = args.indexOf('--limit');
873
+ const limit =
874
+ limFlag >= 0 ? Math.max(1, Number(args[limFlag + 1]) || 20) : 20;
875
+ // The query is the positional args (allow unquoted multi-word: `... New York`).
876
+ const query = args
877
+ .slice(1)
878
+ .filter(
879
+ (a, i, arr) =>
880
+ a !== '--json' && a !== '--limit' && arr[i - 1] !== '--limit'
881
+ )
882
+ .join(' ')
883
+ .trim();
884
+
885
+ if (sub !== 'search' || !query) {
886
+ console.log('Usage: dgmo map search <query> [--json] [--limit N]');
887
+ console.log(
888
+ ' Find the place token the map resolver expects (city or IATA airport code).'
889
+ );
890
+ console.log(' Examples:');
891
+ console.log(
892
+ ' dgmo map search "new york" # → New York City (+ JFK/LGA/EWR airports)'
893
+ );
894
+ console.log(' dgmo map search kennedy # → JFK by airport name');
895
+ console.log(' dgmo map search heathrow --json');
896
+ if (sub === 'search' && !query) process.exitCode = 1;
897
+ return;
898
+ }
899
+
900
+ const data = await loadMapData();
901
+ const results = searchMapLocations(query, data.gazetteer, {
902
+ limit,
903
+ ...(data.airports && { airports: data.airports }),
904
+ });
905
+
906
+ if (json) {
907
+ process.stdout.write(JSON.stringify({ query, results }, null, 2) + '\n');
908
+ return;
909
+ }
910
+ if (!results.length) {
911
+ console.log(
912
+ `No cities or airports match "${query}". Use coordinates: poi Name as <lat,lng>`
913
+ );
914
+ return;
915
+ }
916
+ console.log(
917
+ `Places matching "${query}" — use the token column in your DGMO:`
918
+ );
919
+ for (const r of results) {
920
+ const tag = r.kind === 'airport' ? '✈' : '•';
921
+ console.log(` ${tag} ${r.token.padEnd(22)} ${r.detail}`);
922
+ }
923
+ }
924
+
862
925
  async function main(): Promise<void> {
863
926
  // Subcommand dispatch — `dgmo migrate` is parsed independently from
864
927
  // the rendering flags so its surface doesn't pollute `parseArgs`.
@@ -867,6 +930,11 @@ async function main(): Promise<void> {
867
930
  return;
868
931
  }
869
932
 
933
+ if (process.argv[2] === 'map') {
934
+ await runMapCommand(process.argv.slice(3));
935
+ return;
936
+ }
937
+
870
938
  const opts = parseArgs(process.argv);
871
939
 
872
940
  if (opts.help) {
package/src/er/parser.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  stripDefaultModifier,
32
32
  finalizeAutoTagColors,
33
33
  AUTO_TAG_COLOR_SENTINEL,
34
+ tagAttrKey,
34
35
  } from '../utils/tag-groups';
35
36
  import type { TagGroup } from '../utils/tag-groups';
36
37
  import { tryCollectNote, resolveNotes, type DiagramNote } from '../utils/notes';
@@ -608,7 +609,7 @@ export function parseERDiagram(
608
609
  // Inject defaults for tables without explicit tags
609
610
  for (const group of result.tagGroups) {
610
611
  if (!group.defaultValue) continue;
611
- const key = group.name.toLowerCase();
612
+ const key = tagAttrKey(group.name);
612
613
  for (const table of result.tables) {
613
614
  if (!table.metadata[key]) {
614
615
  const mutableMeta = tableMetadataMap.get(table.id);
@@ -2,6 +2,7 @@
2
2
  // ER Diagram SVG Renderer
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import * as d3Selection from 'd3-selection';
6
7
  import * as d3Shape from 'd3-shape';
7
8
  import { FONT_FAMILY } from '../fonts';
@@ -455,7 +456,7 @@ export function renderERDiagram(
455
456
 
456
457
  // Set data-tag-* attributes for legend hover
457
458
  if (activeTagGroup) {
458
- const tagKey = activeTagGroup.toLowerCase();
459
+ const tagKey = tagAttrKey(activeTagGroup);
459
460
  const tagValue = node.metadata[tagKey];
460
461
  if (tagValue) {
461
462
  nodeG.attr(`data-tag-${tagKey}`, tagValue.toLowerCase());
@@ -25,6 +25,7 @@ import {
25
25
  emitTagLegacyDiagnostic,
26
26
  stripDefaultModifier,
27
27
  validateTagGroupNames,
28
+ tagAttrKey,
28
29
  } from '../utils/tag-groups';
29
30
  import {
30
31
  measureIndent,
@@ -814,13 +815,10 @@ export function parseGantt(
814
815
  if (tagMatch.alias) {
815
816
  metaAliasMap.set(
816
817
  normalizeName(tagMatch.alias),
817
- tagMatch.name.toLowerCase()
818
+ tagAttrKey(tagMatch.name)
818
819
  );
819
820
  }
820
- metaAliasMap.set(
821
- normalizeName(tagMatch.name),
822
- tagMatch.name.toLowerCase()
823
- );
821
+ metaAliasMap.set(normalizeName(tagMatch.name), tagAttrKey(tagMatch.name));
824
822
  continue;
825
823
  }
826
824
 
@@ -2,6 +2,7 @@
2
2
  // Gantt Chart Renderer
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import * as d3Scale from 'd3-scale';
6
7
  import * as d3Selection from 'd3-selection';
7
8
  import { FONT_FAMILY } from '../fonts';
@@ -660,7 +661,7 @@ export function renderGantt(
660
661
  (tg) => tg.name.toLowerCase() === currentSwimlaneGroup.toLowerCase()
661
662
  );
662
663
  if (tagGroup) {
663
- const tagKey = tagGroup.name.toLowerCase();
664
+ const tagKey = tagAttrKey(tagGroup.name);
664
665
  for (const rt of resolved.tasks) {
665
666
  let value = rt.effectiveMetadata[tagKey];
666
667
  if (!value && tagGroup.defaultValue) value = tagGroup.defaultValue;
@@ -2091,7 +2092,7 @@ function renderTagLegend(
2091
2092
  const usedValues = new Map<string, Set<string>>();
2092
2093
  if (resolvedTasks) {
2093
2094
  for (const group of visibleGroups) {
2094
- const key = group.name.toLowerCase();
2095
+ const key = tagAttrKey(group.name);
2095
2096
  const used = new Set<string>();
2096
2097
  for (const rt of resolvedTasks) {
2097
2098
  const val = rt.effectiveMetadata[key];
@@ -2104,7 +2105,7 @@ function renderTagLegend(
2104
2105
  // Filter entries to only those used in the current view
2105
2106
  const filteredEntries = new Map<string, readonly TagEntry[]>();
2106
2107
  for (const group of visibleGroups) {
2107
- const key = group.name.toLowerCase();
2108
+ const key = tagAttrKey(group.name);
2108
2109
  const used = usedValues.get(key);
2109
2110
  if (used && used.size > 0) {
2110
2111
  filteredEntries.set(
@@ -2133,7 +2134,7 @@ function renderTagLegend(
2133
2134
  let groupW = pillW;
2134
2135
  if (isActive) {
2135
2136
  const entries =
2136
- filteredEntries.get(group.name.toLowerCase()) ?? group.entries;
2137
+ filteredEntries.get(tagAttrKey(group.name)) ?? group.entries;
2137
2138
  let entriesW = 0;
2138
2139
  for (const entry of entries) {
2139
2140
  entriesW +=
@@ -2215,7 +2216,7 @@ function renderTagLegend(
2215
2216
  }),
2216
2217
  ...(onControlsToggle !== undefined && { onControlsToggle }),
2217
2218
  onEntryHover: (groupName, entryValue) => {
2218
- const tagKey = groupName.toLowerCase();
2219
+ const tagKey = tagAttrKey(groupName);
2219
2220
  if (entryValue) {
2220
2221
  const ev = entryValue.toLowerCase();
2221
2222
  chartG
@@ -3388,7 +3389,7 @@ export function buildTagLaneRowList(
3388
3389
  );
3389
3390
  if (!tagGroup) return null;
3390
3391
 
3391
- const tagKey = tagGroup.name.toLowerCase();
3392
+ const tagKey = tagAttrKey(tagGroup.name);
3392
3393
  const rows: Row[] = [];
3393
3394
 
3394
3395
  // Bucket tasks by tag value
package/src/index.ts CHANGED
@@ -226,11 +226,16 @@ export { normalizeSvgForEmbed, getEmbedSvgViewBox } from './utils/svg-embed';
226
226
  // ============================================================
227
227
  // Pure + dependency-injected: the caller supplies the `Gazetteer` asset. Safe
228
228
  // to export from the main index — no d3-geo/topojson runtime imports.
229
- export { completeMapPlaces, completeMapRegions } from './map/completion';
229
+ export {
230
+ completeMapPlaces,
231
+ completeMapRegions,
232
+ searchMapLocations,
233
+ } from './map/completion';
230
234
  export type {
231
235
  MapPlaceCompletion,
232
236
  MapRegionCompletion,
233
237
  MapCompletionOptions,
238
+ MapLocationMatch,
234
239
  } from './map/completion';
235
240
  export type {
236
241
  Gazetteer,
@@ -6,6 +6,7 @@
6
6
  // Handles: chart metadata, component blocks with indented properties
7
7
  // and connections, [Group] containers, tag groups, pipe metadata.
8
8
 
9
+ import { tagAttrKey } from '../utils/tag-groups';
9
10
  import {
10
11
  descriptionBareRemovedMessage,
11
12
  formatDgmoError,
@@ -1054,7 +1055,7 @@ export function parseInfra(content: string): ParsedInfra {
1054
1055
  // Inject default tag values into nodes that don't have one
1055
1056
  for (const tg of result.tagGroups) {
1056
1057
  if (!tg.defaultValue) continue;
1057
- const key = (tg.alias ?? tg.name).toLowerCase();
1058
+ const key = tagAttrKey(tg.alias ?? tg.name);
1058
1059
  for (const node of result.nodes) {
1059
1060
  if (node.isEdge) continue;
1060
1061
  if (!(key in node.tags)) {
@@ -2,6 +2,7 @@
2
2
  // Infra Chart SVG Renderer
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import * as d3Selection from 'd3-selection';
6
7
  import * as d3Shape from 'd3-shape';
7
8
  import { FONT_FAMILY } from '../fonts';
@@ -1388,7 +1389,7 @@ function resolveActiveTagStroke(
1388
1389
  (t) => t.name.toLowerCase() === activeGroup.toLowerCase()
1389
1390
  );
1390
1391
  if (!tg) return null;
1391
- const tagKey = (tg.alias ?? tg.name).toLowerCase();
1392
+ const tagKey = tagAttrKey(tg.alias ?? tg.name);
1392
1393
  const tagVal = node.tags[tagKey];
1393
1394
  if (!tagVal) return null;
1394
1395
  const tv = tg.values.find(
@@ -2073,7 +2074,7 @@ export function computeInfraLegendGroups(
2073
2074
  groups.push({
2074
2075
  name: tg.name,
2075
2076
  type: 'tag',
2076
- tagKey: (tg.alias ?? tg.name).toLowerCase(),
2077
+ tagKey: tagAttrKey(tg.alias ?? tg.name),
2077
2078
  entries,
2078
2079
  width: LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth,
2079
2080
  minifiedWidth: pillWidth,
@@ -2146,7 +2147,7 @@ function renderLegend(
2146
2147
  // Add infra-specific classes and data attributes for app interactivity
2147
2148
  legendG.selectAll('[data-legend-group]').classed('infra-legend-group', true);
2148
2149
  for (const group of legendGroups) {
2149
- const groupKey = group.name.toLowerCase();
2150
+ const groupKey = tagAttrKey(group.name);
2150
2151
  for (const entry of group.entries) {
2151
2152
  const entryEl = legendG.select(
2152
2153
  `[data-legend-group="${groupKey}"] [data-legend-entry="${entry.value.toLowerCase()}"]`
@@ -14,7 +14,11 @@ import {
14
14
  JOURNEY_MAP_REGISTRY,
15
15
  withTagAliases,
16
16
  } from '../utils/reserved-key-registry';
17
- import { splitNameAndMeta, warnUnknownMetaKeys } from '../utils/parsing';
17
+ import {
18
+ splitNameAndMeta,
19
+ warnUnknownMetaKeys,
20
+ peelTrailingColorName,
21
+ } from '../utils/parsing';
18
22
  import {
19
23
  matchTagBlockHeading,
20
24
  emitTagLegacyDiagnostic,
@@ -22,6 +26,7 @@ import {
22
26
  validateTagGroupNames,
23
27
  finalizeAutoTagColors,
24
28
  AUTO_TAG_COLOR_SENTINEL,
29
+ tagAttrKey,
25
30
  } from '../utils/tag-groups';
26
31
  import {
27
32
  measureIndent,
@@ -180,8 +185,10 @@ export function parseJourneyMap(
180
185
  }
181
186
  }
182
187
  } else {
183
- // Same-line form (pipes removed in 0.18.0): peel a trailing
184
- // `color: <token>` off the rest-of-line persona name.
188
+ // Same-line form (pipes removed in 0.18.0). Two ways to set color:
189
+ // 1. explicit `persona <name> color: <token>` (long form)
190
+ // 2. universal §1.5 trailing-token color (`persona <name> green`);
191
+ // capitalize the word (`Green`) to keep it as literal name text.
185
192
  const colorMatch = afterKeyword.match(/^(.+?)\s+color:\s*(\S+)$/i);
186
193
  if (colorMatch) {
187
194
  personaName = colorMatch[1]!.trim();
@@ -193,7 +200,19 @@ export function parseJourneyMap(
193
200
  palette
194
201
  ) ?? undefined;
195
202
  } else {
196
- personaName = afterKeyword;
203
+ const { label, colorName } = peelTrailingColorName(afterKeyword);
204
+ if (colorName) {
205
+ personaName = label;
206
+ personaColor =
207
+ resolveColorWithDiagnostic(
208
+ colorName,
209
+ lineNumber,
210
+ result.diagnostics,
211
+ palette
212
+ ) ?? undefined;
213
+ } else {
214
+ personaName = afterKeyword;
215
+ }
197
216
  }
198
217
  }
199
218
 
@@ -246,12 +265,12 @@ export function parseJourneyMap(
246
265
  if (tagBlockMatch.alias) {
247
266
  aliasMap.set(
248
267
  tagBlockMatch.alias.toLowerCase(),
249
- tagBlockMatch.name.toLowerCase()
268
+ tagAttrKey(tagBlockMatch.name)
250
269
  );
251
270
  }
252
271
  aliasMap.set(
253
- tagBlockMatch.name.toLowerCase(),
254
- tagBlockMatch.name.toLowerCase()
272
+ tagAttrKey(tagBlockMatch.name),
273
+ tagAttrKey(tagBlockMatch.name)
255
274
  );
256
275
  result.tagGroups.push(currentTagGroup);
257
276
  continue;
@@ -445,7 +464,7 @@ export function parseJourneyMap(
445
464
  // Build tag value sets
446
465
  for (const group of result.tagGroups) {
447
466
  const values = new Set(group.entries.map((e) => e.value.toLowerCase()));
448
- tagValueSets.set(group.name.toLowerCase(), values);
467
+ tagValueSets.set(tagAttrKey(group.name), values);
449
468
  }
450
469
 
451
470
  // Validate tag values on all steps
@@ -1,3 +1,4 @@
1
+ import { tagAttrKey } from '../utils/tag-groups';
1
2
  import * as d3 from 'd3-selection';
2
3
  import * as d3Shape from 'd3-shape';
3
4
  import type { PaletteColors } from '../palettes';
@@ -18,6 +19,7 @@ import type {
18
19
  JourneyMapStep,
19
20
  } from './types';
20
21
  import { renderInlineText } from '../utils/inline-markdown';
22
+ import { wrapNoteBody, NOTE_BULLET_INDENT } from '../utils/note-box';
21
23
  import { renderIntegratedLegend } from '../utils/legend-integration';
22
24
  import { resolveActiveTagGroup } from '../utils/tag-groups';
23
25
  import { ScaleContext } from '../utils/scaling';
@@ -194,9 +196,10 @@ export function renderJourneyMap(
194
196
  const textAreaWidth = panelWidth - silhouetteZone - CARD_PADDING_X;
195
197
  const descLineH = 14;
196
198
 
197
- // Wrap description text into lines
199
+ // Wrap description text into lines — bullet-aware (`- `/`* ` → hanging
200
+ // "•") + inline markdown, matching how note boxes render rich bodies.
198
201
  const descLines = parsed.persona.description
199
- ? wrapText(parsed.persona.description, textAreaWidth, FONT_SIZE_META)
202
+ ? wrapNoteBody(parsed.persona.description, textAreaWidth, FONT_SIZE_META)
200
203
  : [];
201
204
  const descRowH =
202
205
  descLines.length > 0 ? descLines.length * descLineH + 8 : 0;
@@ -276,16 +279,30 @@ export function renderJourneyMap(
276
279
  .attr('fill', onPersonaText)
277
280
  .text(parsed.persona.name);
278
281
 
279
- // Description — wrapped lines below divider, with inline markdown
282
+ // Description — wrapped lines below divider, with inline markdown.
283
+ // Bullet first-lines get a "•" glyph at the left edge with the body
284
+ // hanging-indented; continuation lines align under the body.
280
285
  for (let li = 0; li < descLines.length; li++) {
286
+ // In-bounds by loop guard.
287
+ const line = descLines[li]!;
288
+ const indent = line.kind === 'plain' ? 0 : NOTE_BULLET_INDENT;
289
+ const lineY = panelY + titleRowH + descLineH * (li + 1);
290
+ if (line.kind === 'bullet-first') {
291
+ personaG
292
+ .append('text')
293
+ .attr('x', textX)
294
+ .attr('y', lineY)
295
+ .attr('font-size', FONT_SIZE_META)
296
+ .attr('fill', onPersonaText)
297
+ .text('•');
298
+ }
281
299
  const lineEl = personaG
282
300
  .append('text')
283
- .attr('x', textX)
284
- .attr('y', panelY + titleRowH + descLineH * (li + 1))
301
+ .attr('x', textX + indent)
302
+ .attr('y', lineY)
285
303
  .attr('font-size', FONT_SIZE_META)
286
304
  .attr('fill', onPersonaText);
287
- // In-bounds by loop guard.
288
- renderInlineText(lineEl, descLines[li]!, palette, FONT_SIZE_META);
305
+ renderInlineText(lineEl, line.text, palette, FONT_SIZE_META);
289
306
  }
290
307
 
291
308
  if (onNavigateToLine) {
@@ -346,7 +363,7 @@ export function renderJourneyMap(
346
363
  const isScore = groupName === 'Score';
347
364
  const attrName = isScore
348
365
  ? 'data-score'
349
- : `data-tag-${groupName.toLowerCase()}`;
366
+ : `data-tag-${tagAttrKey(groupName)}`;
350
367
 
351
368
  const matches = (el: Element) =>
352
369
  el.getAttribute(attrName) === entryValue;
@@ -1094,7 +1111,7 @@ function resolveStepColor(
1094
1111
  );
1095
1112
  if (!group) return undefined;
1096
1113
 
1097
- const tagValue = step.tags[group.name.toLowerCase()];
1114
+ const tagValue = step.tags[tagAttrKey(group.name)];
1098
1115
  if (!tagValue) return undefined;
1099
1116
 
1100
1117
  const entry = group.entries.find(
@@ -19,6 +19,7 @@ import {
19
19
  validateTagGroupNames,
20
20
  finalizeAutoTagColors,
21
21
  AUTO_TAG_COLOR_SENTINEL,
22
+ tagAttrKey,
22
23
  } from '../utils/tag-groups';
23
24
  import {
24
25
  measureIndent,
@@ -168,14 +169,14 @@ export function parseKanban(
168
169
  if (tagBlockMatch.alias) {
169
170
  metaAliasMap.set(
170
171
  normalizeName(tagBlockMatch.alias),
171
- tagBlockMatch.name.toLowerCase()
172
+ tagAttrKey(tagBlockMatch.name)
172
173
  );
173
174
  }
174
175
  // §1.4 dispatch: register the canonical tag name so `priority: X`
175
176
  // also dispatches as metadata (not just the optional `p: X` alias).
176
177
  metaAliasMap.set(
177
178
  normalizeName(tagBlockMatch.name),
178
- tagBlockMatch.name.toLowerCase()
179
+ tagAttrKey(tagBlockMatch.name)
179
180
  );
180
181
  result.tagGroups.push(currentTagGroup);
181
182
  continue;
@@ -419,7 +420,7 @@ export function parseKanban(
419
420
  // Build tag value sets for validation
420
421
  for (const group of result.tagGroups) {
421
422
  const values = new Set(group.entries.map((e) => e.value.toLowerCase()));
422
- tagValueSets.set(group.name.toLowerCase(), values);
423
+ tagValueSets.set(tagAttrKey(group.name), values);
423
424
  }
424
425
 
425
426
  // Validate WIP limits
@@ -2,6 +2,7 @@
2
2
  // Kanban Board SVG Renderer
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import * as d3Selection from 'd3-selection';
6
7
  import { FONT_FAMILY } from '../fonts';
7
8
  import type { PaletteColors } from '../palettes';
@@ -82,7 +83,7 @@ function resolveCardTagMeta(
82
83
  const meta: { label: string; value: string; color?: string }[] = [];
83
84
  for (const group of tagGroups) {
84
85
  if (hiddenMetaGroups?.includes(group.name.toLowerCase())) continue;
85
- const tagValue = card.tags[group.name.toLowerCase()];
86
+ const tagValue = card.tags[tagAttrKey(group.name)];
86
87
  const value = tagValue ?? group.defaultValue;
87
88
  if (!value) continue;
88
89
  const entry = group.entries.find(
@@ -107,7 +108,7 @@ function resolveCardTagColor(
107
108
  (g) => g.name.toLowerCase() === activeTagGroup.toLowerCase()
108
109
  );
109
110
  if (!group) return card.color;
110
- const tagValue = card.tags[group.name.toLowerCase()];
111
+ const tagValue = card.tags[tagAttrKey(group.name)];
111
112
  const value = tagValue ?? group.defaultValue;
112
113
  if (!value) return undefined;
113
114
  const entry = group.entries.find(
@@ -639,7 +640,7 @@ export function renderKanban(
639
640
  .attr('data-line-number', card.lineNumber);
640
641
 
641
642
  if (activeTagGroup) {
642
- const tagKey = activeTagGroup.toLowerCase();
643
+ const tagKey = tagAttrKey(activeTagGroup);
643
644
  const tagValue = card.tags[tagKey];
644
645
  const group = parsed.tagGroups.find(
645
646
  (tg) => tg.name.toLowerCase() === tagKey
@@ -806,7 +807,7 @@ function bucketCardsBySwimlane(
806
807
  columns: KanbanColumn[],
807
808
  swimlaneGroup: KanbanTagGroup
808
809
  ): SwimlaneBucket[] {
809
- const tagKey = swimlaneGroup.name.toLowerCase();
810
+ const tagKey = tagAttrKey(swimlaneGroup.name);
810
811
  const buckets = new Map<string, SwimlaneBucket>();
811
812
 
812
813
  // Seed lanes from declaration order
@@ -1369,7 +1370,7 @@ function renderSwimlaneCard(
1369
1370
  .attr('data-line-number', card.lineNumber);
1370
1371
 
1371
1372
  if (activeTagGroup) {
1372
- const tagKey = activeTagGroup.toLowerCase();
1373
+ const tagKey = tagAttrKey(activeTagGroup);
1373
1374
  const group = tagGroups.find((tg) => tg.name.toLowerCase() === tagKey);
1374
1375
  const value = card.tags[tagKey] ?? group?.defaultValue;
1375
1376
  if (value) {