@diagrammo/dgmo 0.32.1 → 0.34.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 (60) 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 +1434 -443
  6. package/dist/advanced.d.cts +41 -1
  7. package/dist/advanced.d.ts +41 -1
  8. package/dist/advanced.js +1433 -443
  9. package/dist/auto.cjs +1242 -318
  10. package/dist/auto.js +91 -91
  11. package/dist/auto.mjs +1242 -318
  12. package/dist/cli.cjs +138 -136
  13. package/dist/index.cjs +1308 -317
  14. package/dist/index.d.cts +35 -1
  15. package/dist/index.d.ts +35 -1
  16. package/dist/index.js +1307 -317
  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/label-placement.ts +339 -0
  22. package/src/boxes-and-lines/layout-grouped.ts +909 -0
  23. package/src/boxes-and-lines/layout-search.ts +34 -3
  24. package/src/boxes-and-lines/layout.ts +32 -7
  25. package/src/boxes-and-lines/parser.ts +3 -2
  26. package/src/boxes-and-lines/renderer.ts +67 -139
  27. package/src/c4/layout.ts +2 -1
  28. package/src/c4/parser.ts +3 -2
  29. package/src/c4/renderer.ts +3 -2
  30. package/src/cli.ts +68 -0
  31. package/src/er/parser.ts +2 -1
  32. package/src/er/renderer.ts +2 -1
  33. package/src/gantt/parser.ts +3 -5
  34. package/src/gantt/renderer.ts +7 -6
  35. package/src/index.ts +6 -1
  36. package/src/infra/parser.ts +2 -1
  37. package/src/infra/renderer.ts +4 -3
  38. package/src/journey-map/parser.ts +27 -8
  39. package/src/journey-map/renderer.ts +26 -9
  40. package/src/kanban/parser.ts +4 -3
  41. package/src/kanban/renderer.ts +6 -5
  42. package/src/map/completion.ts +116 -0
  43. package/src/map/layout.ts +4 -3
  44. package/src/map/parser.ts +3 -2
  45. package/src/mindmap/parser.ts +4 -3
  46. package/src/mindmap/renderer.ts +2 -1
  47. package/src/org/layout.ts +3 -2
  48. package/src/org/parser.ts +3 -2
  49. package/src/org/renderer.ts +5 -4
  50. package/src/pert/parser.ts +3 -2
  51. package/src/sequence/renderer.ts +10 -12
  52. package/src/sequence/tag-resolution.ts +2 -1
  53. package/src/sitemap/layout.ts +3 -2
  54. package/src/sitemap/parser.ts +36 -2
  55. package/src/sitemap/renderer.ts +12 -6
  56. package/src/timeline/renderer.ts +4 -3
  57. package/src/utils/legend-d3.ts +4 -3
  58. package/src/utils/legend-svg.ts +3 -2
  59. package/src/utils/tag-groups.ts +28 -7
  60. package/src/visualizations/parse.ts +2 -1
@@ -2,6 +2,7 @@
2
2
  // Org 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 { FONT_FAMILY } from '../fonts';
7
8
  import {
@@ -269,7 +270,7 @@ export function renderOrg(
269
270
  // Build display name map from tag groups (lowercase key → original casing)
270
271
  const displayNames = new Map<string, string>();
271
272
  for (const group of parsed.tagGroups) {
272
- displayNames.set(group.name.toLowerCase(), group.name);
273
+ displayNames.set(tagAttrKey(group.name), group.name);
273
274
  }
274
275
 
275
276
  // Root node IDs — focus icon is suppressed on these (already the tree root)
@@ -288,7 +289,7 @@ export function renderOrg(
288
289
  // Use tagMetadata (unfiltered) so hover-highlight works even when the
289
290
  // active tag group is hidden from the visible card body via the eye toggle.
290
291
  if (activeTagGroup) {
291
- const tagKey = activeTagGroup.toLowerCase();
292
+ const tagKey = tagAttrKey(activeTagGroup);
292
293
  const metaValue = c.tagMetadata[tagKey];
293
294
  if (metaValue) {
294
295
  cG.attr(`data-tag-${tagKey}`, metaValue.toLowerCase());
@@ -466,7 +467,7 @@ export function renderOrg(
466
467
  // Use tagMetadata (unfiltered) so hover-highlight works even when the
467
468
  // active tag group is hidden from the visible card body via the eye toggle.
468
469
  if (activeTagGroup) {
469
- const tagKey = activeTagGroup.toLowerCase();
470
+ const tagKey = tagAttrKey(activeTagGroup);
470
471
  const metaValue = node.tagMetadata[tagKey];
471
472
  if (metaValue) {
472
473
  nodeG.attr(`data-tag-${tagKey}`, metaValue.toLowerCase());
@@ -781,7 +782,7 @@ export function renderOrg(
781
782
  const computedLayout = legendHandle.getLayout();
782
783
  if (computedLayout.activeCapsule?.addonX != null) {
783
784
  const capsule = computedLayout.activeCapsule;
784
- const groupKey = capsule.groupName.toLowerCase();
785
+ const groupKey = tagAttrKey(capsule.groupName);
785
786
  const isHidden = hiddenAttributes?.has(groupKey) ?? false;
786
787
 
787
788
  // Find the rendered active group <g> and append eye icon
@@ -36,6 +36,7 @@ import {
36
36
  finalizeAutoTagColors,
37
37
  AUTO_TAG_COLOR_SENTINEL,
38
38
  type TagGroup,
39
+ tagAttrKey,
39
40
  } from '../utils/tag-groups';
40
41
  import type { Writable } from '../utils/brand';
41
42
  import type { Duration, DurationUnit } from '../gantt/types';
@@ -695,12 +696,12 @@ export function parsePert(
695
696
  if (tagBlockMatch.alias) {
696
697
  metaAliasMap.set(
697
698
  tagBlockMatch.alias.toLowerCase(),
698
- tagBlockMatch.name.toLowerCase()
699
+ tagAttrKey(tagBlockMatch.name)
699
700
  );
700
701
  }
701
702
  metaAliasMap.set(
702
703
  normalizeName(tagBlockMatch.name),
703
- tagBlockMatch.name.toLowerCase()
704
+ tagAttrKey(tagBlockMatch.name)
704
705
  );
705
706
  tagGroups.push(currentTagGroup);
706
707
  // Inline values (e.g. `tag Priority as p Low green, High red`).
@@ -2,9 +2,15 @@
2
2
  // Sequence 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 type { PaletteColors } from '../palettes';
7
- import { contrastText, mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
8
+ import {
9
+ contrastText,
10
+ mix,
11
+ shapeFill,
12
+ themeBaseBg,
13
+ } from '../palettes/color-utils';
8
14
  import {
9
15
  parseInlineMarkdown,
10
16
  truncateBareUrl,
@@ -1062,7 +1068,7 @@ export function renderSequenceDiagram(
1062
1068
  }
1063
1069
  const getTagColor = (value: string | undefined): string | undefined =>
1064
1070
  value ? tagValueToColor.get(value.toLowerCase()) : undefined;
1065
- const tagKey = activeTagGroup?.toLowerCase();
1071
+ const tagKey = activeTagGroup ? tagAttrKey(activeTagGroup) : undefined;
1066
1072
 
1067
1073
  // Build hidden message set for collapse support
1068
1074
  const hiddenMsgIndices = new Set<number>();
@@ -2008,11 +2014,7 @@ export function renderSequenceDiagram(
2008
2014
  const groupTagValue = tagKey && group.metadata?.[tagKey];
2009
2015
  const groupTagColor = getTagColor(groupTagValue || undefined);
2010
2016
  const fillColor = groupTagColor
2011
- ? mix(
2012
- groupTagColor,
2013
- themeBaseBg(palette, isDark),
2014
- isDark ? 15 : 20
2015
- )
2017
+ ? mix(groupTagColor, themeBaseBg(palette, isDark), isDark ? 15 : 20)
2016
2018
  : isDark
2017
2019
  ? palette.surface
2018
2020
  : palette.bg;
@@ -2137,11 +2139,7 @@ export function renderSequenceDiagram(
2137
2139
 
2138
2140
  // Overlay a taller rect to replace the standard participant box
2139
2141
  const pFill = effectiveTagColor
2140
- ? mix(
2141
- effectiveTagColor,
2142
- themeBaseBg(palette, isDark),
2143
- isDark ? 30 : 40
2144
- )
2142
+ ? mix(effectiveTagColor, themeBaseBg(palette, isDark), isDark ? 30 : 40)
2145
2143
  : isDark
2146
2144
  ? mix(palette.overlay, palette.surface, 50)
2147
2145
  : mix(palette.bg, palette.surface, 50);
@@ -5,6 +5,7 @@
5
5
  // Resolves effective tag values for participants and messages
6
6
  // using the priority chain: explicit > group > receiver-inherit > default > neutral
7
7
 
8
+ import { tagAttrKey } from '../utils/tag-groups';
8
9
  import type {
9
10
  ParsedSequenceDgmo,
10
11
  SequenceParticipant,
@@ -116,7 +117,7 @@ export function resolveSequenceTags(
116
117
  return result;
117
118
  }
118
119
 
119
- const groupKey = group.name.toLowerCase();
120
+ const groupKey = tagAttrKey(group.name);
120
121
 
121
122
  // Clone participant metadata (don't mutate parsed data)
122
123
  const participantMeta = new Map<string, Record<string, string>>();
@@ -2,6 +2,7 @@
2
2
  // Sitemap Diagram Layout Engine (Dagre flat graph)
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import dagre from '@dagrejs/dagre';
6
7
  import type { Writable } from '../utils/brand';
7
8
  import type { ParsedSitemap, SitemapNode } from './types';
@@ -232,7 +233,7 @@ function computeLegendGroups(
232
233
  for (const group of tagGroups) {
233
234
  if (group.entries.length === 0) continue;
234
235
 
235
- const usedValues = usedValuesByGroup?.get(group.name.toLowerCase());
236
+ const usedValues = usedValuesByGroup?.get(tagAttrKey(group.name));
236
237
  const visibleEntries = usedValues
237
238
  ? group.entries.filter((e) => usedValues.has(e.value.toLowerCase()))
238
239
  : group.entries;
@@ -866,7 +867,7 @@ export function layoutSitemap(
866
867
  // Collect used tag values
867
868
  const usedValuesByGroup = new Map<string, Set<string>>();
868
869
  for (const group of parsed.tagGroups) {
869
- const key = group.name.toLowerCase();
870
+ const key = tagAttrKey(group.name);
870
871
  const used = new Set<string>();
871
872
  const walk = (node: SitemapNode) => {
872
873
  if (!node.isContainer && node.metadata[key]) {
@@ -29,6 +29,7 @@ import {
29
29
  stripDefaultModifier,
30
30
  finalizeAutoTagColors,
31
31
  AUTO_TAG_COLOR_SENTINEL,
32
+ tagAttrKey,
32
33
  } from '../utils/tag-groups';
33
34
  import {
34
35
  measureIndent,
@@ -273,12 +274,12 @@ export function parseSitemap(
273
274
  if (tagBlockMatch.alias) {
274
275
  metaAliasMap.set(
275
276
  normalizeName(tagBlockMatch.alias),
276
- tagBlockMatch.name.toLowerCase()
277
+ tagAttrKey(tagBlockMatch.name)
277
278
  );
278
279
  }
279
280
  metaAliasMap.set(
280
281
  normalizeName(tagBlockMatch.name),
281
- tagBlockMatch.name.toLowerCase()
282
+ tagAttrKey(tagBlockMatch.name)
282
283
  );
283
284
  result.tagGroups.push(newTagGroup);
284
285
  continue;
@@ -501,6 +502,16 @@ export function parseSitemap(
501
502
  }
502
503
  }
503
504
 
505
+ // §12: reject sub-pages nested under a page inside a container.
506
+ const enclosingContainer = nestedPageInsideContainer(indent, indentStack);
507
+ if (enclosingContainer) {
508
+ pushError(
509
+ lineNumber,
510
+ `Pages inside container '[${enclosingContainer}]' cannot have indented sub-pages. Containers hold a flat list of pages.`
511
+ );
512
+ continue;
513
+ }
514
+
504
515
  // Node label — possibly with pipe-delimited metadata
505
516
  const node = parseNodeLabel(
506
517
  trimmed,
@@ -702,6 +713,29 @@ function attachNode(
702
713
  indentStack.push({ node, indent });
703
714
  }
704
715
 
716
+ /**
717
+ * §12: containers hold a flat list of pages — a page inside a `[Container]`
718
+ * may not have indented sub-pages. Returns the enclosing container's label
719
+ * when attaching a page at `indent` would nest it under a page that itself
720
+ * lives inside a container; otherwise null. Non-destructive (mirrors
721
+ * `attachNode`'s pop logic without mutating the stack).
722
+ */
723
+ function nestedPageInsideContainer(
724
+ indent: number,
725
+ indentStack: { node: Writable<SitemapNode>; indent: number }[]
726
+ ): string | null {
727
+ let idx = indentStack.length - 1;
728
+ while (idx >= 0 && indentStack[idx]!.indent >= indent) idx--;
729
+ if (idx < 0) return null;
730
+ // Immediate parent. A container parent → flat member, allowed.
731
+ if (indentStack[idx]!.node.isContainer) return null;
732
+ // Parent is a page; flag only when an ancestor is a container.
733
+ for (let k = idx - 1; k >= 0; k--) {
734
+ if (indentStack[k]!.node.isContainer) return indentStack[k]!.node.label;
735
+ }
736
+ return null;
737
+ }
738
+
705
739
  function findParentNode(
706
740
  indent: number,
707
741
  indentStack: { node: Writable<SitemapNode>; indent: number }[]
@@ -2,11 +2,17 @@
2
2
  // Sitemap 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';
8
9
  import type { PaletteColors } from '../palettes';
9
- import { contrastText, mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
10
+ import {
11
+ contrastText,
12
+ mix,
13
+ shapeFill,
14
+ themeBaseBg,
15
+ } from '../palettes/color-utils';
10
16
  import type { ParsedSitemap } from './types';
11
17
  import type { SitemapLayoutResult, SitemapLegendGroup } from './layout';
12
18
  import { renderInlineText } from '../utils/inline-markdown';
@@ -284,10 +290,10 @@ export function renderSitemap(
284
290
  // tagColors: "groupkey:valueLower" → hex color
285
291
  const tagColors = new Map<string, string>();
286
292
  for (const group of parsed.tagGroups) {
287
- displayNames.set(group.name.toLowerCase(), group.name);
293
+ displayNames.set(tagAttrKey(group.name), group.name);
288
294
  for (const entry of group.entries) {
289
295
  tagColors.set(
290
- `${group.name.toLowerCase()}:${entry.value.toLowerCase()}`,
296
+ `${tagAttrKey(group.name)}:${entry.value.toLowerCase()}`,
291
297
  entry.color
292
298
  );
293
299
  }
@@ -317,7 +323,7 @@ export function renderSitemap(
317
323
 
318
324
  // Tag metadata for legend hover dimming
319
325
  if (activeTagGroup) {
320
- const tagKey = activeTagGroup.toLowerCase();
326
+ const tagKey = tagAttrKey(activeTagGroup);
321
327
  const tagVal = c.tagMetadata[tagKey];
322
328
  if (tagVal) cG.attr(`data-tag-${tagKey}`, tagVal.toLowerCase());
323
329
  }
@@ -486,7 +492,7 @@ export function renderSitemap(
486
492
 
487
493
  // Tag metadata for legend hover dimming
488
494
  if (activeTagGroup) {
489
- const tagKey = activeTagGroup.toLowerCase();
495
+ const tagKey = tagAttrKey(activeTagGroup);
490
496
  const tagVal = node.tagMetadata[tagKey];
491
497
  if (tagVal) nodeG.attr(`data-tag-${tagKey}`, tagVal.toLowerCase());
492
498
  }
@@ -721,7 +727,7 @@ function renderLegend(
721
727
  const computedLayout = legendHandle.getLayout();
722
728
  if (computedLayout.activeCapsule?.addonX != null) {
723
729
  const capsule = computedLayout.activeCapsule;
724
- const groupKey = capsule.groupName.toLowerCase();
730
+ const groupKey = tagAttrKey(capsule.groupName);
725
731
  const isHidden = hiddenAttributes?.has(groupKey) ?? false;
726
732
 
727
733
  const activeGroupEl = parent.select(`[data-legend-group="${groupKey}"]`);
@@ -2,6 +2,7 @@
2
2
  // Timeline renderer — Story 109.2 (arch-review). Extracted from d3.ts.
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';
@@ -820,7 +821,7 @@ function setupTimeline(
820
821
  let tagLanes: Lane[] | null = null;
821
822
 
822
823
  if (resolvedSwimlaneTG) {
823
- const tagKey = resolvedSwimlaneTG.toLowerCase();
824
+ const tagKey = tagAttrKey(resolvedSwimlaneTG);
824
825
  const tagGroup = parsed.timelineTagGroups.find(
825
826
  (g) => g.name.toLowerCase() === tagKey
826
827
  );
@@ -1322,7 +1323,7 @@ function renderTimelineTagLegendOverlay(
1322
1323
  onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
1323
1324
  },
1324
1325
  onEntryHover: (groupName, entryValue) => {
1325
- const tagKey = groupName.toLowerCase();
1326
+ const tagKey = tagAttrKey(groupName);
1326
1327
  if (entryValue) {
1327
1328
  const tagVal = entryValue.toLowerCase();
1328
1329
  fadeToTagValue(mainG, tagKey, tagVal);
@@ -1349,7 +1350,7 @@ function renderTimelineTagLegendOverlay(
1349
1350
  }
1350
1351
  },
1351
1352
  onGroupRendered: (groupName, groupEl, isActive) => {
1352
- const groupKey = groupName.toLowerCase();
1353
+ const groupKey = tagAttrKey(groupName);
1353
1354
  groupEl.attr('data-tag-group', groupKey);
1354
1355
  if (isActive && !viewMode) {
1355
1356
  const isSwimActive =
@@ -3,6 +3,7 @@
3
3
  // Thin adapter: layout engine → D3 DOM
4
4
  // ============================================================
5
5
 
6
+ import { tagAttrKey } from './tag-groups';
6
7
  import {
7
8
  LEGEND_HEIGHT,
8
9
  LEGEND_PILL_FONT_SIZE,
@@ -164,7 +165,7 @@ function renderCapsule(
164
165
  const g = parent
165
166
  .append('g')
166
167
  .attr('transform', `translate(${capsule.x},${capsule.y})`)
167
- .attr('data-legend-group', capsule.groupName.toLowerCase())
168
+ .attr('data-legend-group', tagAttrKey(capsule.groupName))
168
169
  .style('cursor', 'pointer');
169
170
 
170
171
  // Outer capsule background
@@ -211,7 +212,7 @@ function renderCapsule(
211
212
  // Continuous ramp (choropleth group): min | gradient | max, in place of dots.
212
213
  if (capsule.gradient) {
213
214
  const gr = capsule.gradient;
214
- const gradId = `dgmo-legend-ramp-${capsule.groupName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
215
+ const gradId = `dgmo-legend-ramp-${tagAttrKey(capsule.groupName).replace(/[^a-z0-9]+/g, '-')}`;
215
216
  const def = g.append('defs').append('linearGradient').attr('id', gradId);
216
217
  // Sample the SAME ramp the fills use (direct = 2 endpoints; diverging =
217
218
  // stops through the neutral midpoint), so the legend matches the basemap.
@@ -318,7 +319,7 @@ function renderPill(
318
319
  const g = parent
319
320
  .append('g')
320
321
  .attr('transform', `translate(${pill.x},${pill.y})`)
321
- .attr('data-legend-group', pill.groupName.toLowerCase())
322
+ .attr('data-legend-group', tagAttrKey(pill.groupName))
322
323
  .style('cursor', 'pointer');
323
324
 
324
325
  g.append('rect')
@@ -12,6 +12,7 @@
12
12
  // (not dominant-baseline) so resvg renders it correctly in PNG export.
13
13
  // ============================================================
14
14
 
15
+ import { tagAttrKey } from './tag-groups';
15
16
  import type {
16
17
  LegendConfig,
17
18
  LegendState,
@@ -99,7 +100,7 @@ function emitCapsule(
99
100
  );
100
101
  }
101
102
 
102
- return `<g transform="translate(${capsule.x},${capsule.y})" data-legend-group="${esc(capsule.groupName.toLowerCase())}" style="cursor:pointer">${inner.join('')}</g>`;
103
+ return `<g transform="translate(${capsule.x},${capsule.y})" data-legend-group="${esc(tagAttrKey(capsule.groupName))}" style="cursor:pointer">${inner.join('')}</g>`;
103
104
  }
104
105
 
105
106
  /** Collapsed group pill (no active group) → SVG string. */
@@ -109,7 +110,7 @@ function emitPill(
109
110
  groupBg: string
110
111
  ): string {
111
112
  return (
112
- `<g transform="translate(${pill.x},${pill.y})" data-legend-group="${esc(pill.groupName.toLowerCase())}" style="cursor:pointer">` +
113
+ `<g transform="translate(${pill.x},${pill.y})" data-legend-group="${esc(tagAttrKey(pill.groupName))}" style="cursor:pointer">` +
113
114
  `<rect width="${pill.width}" height="${pill.height}" rx="${pill.height / 2}" fill="${esc(groupBg)}"/>` +
114
115
  `<text x="${pill.width / 2}" y="${pill.height / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2}" font-size="${LEGEND_PILL_FONT_SIZE}" font-weight="500" fill="${esc(palette.textMuted)}" text-anchor="middle" font-family="${esc(FONT_FAMILY)}">${esc(pill.groupName)}</text>` +
115
116
  `</g>`
@@ -39,6 +39,23 @@ export interface TagGroup {
39
39
  readonly lineNumber: number;
40
40
  }
41
41
 
42
+ /**
43
+ * DOM-safe key for a tag group — used wherever the group name becomes a
44
+ * `data-tag-*` attribute suffix, an entity metadata key, or an `active-tag`
45
+ * match target. For a single-identifier name (the only form the parser used to
46
+ * accept) this is exactly `name.toLowerCase()`, so swapping it in for existing
47
+ * diagrams is byte-identical. A quoted multi-word name (`tag "Trust Zone" as tz`)
48
+ * slugs to a hyphenated identifier (`trust-zone`) so it never produces an
49
+ * invalid DOM attribute name; the original `name` is kept for the legend label.
50
+ */
51
+ export function tagAttrKey(name: string): string {
52
+ return name
53
+ .trim()
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9_-]+/g, '-')
56
+ .replace(/^-+|-+$/g, '');
57
+ }
58
+
42
59
  /** Result of matching a tag block heading */
43
60
  interface TagBlockMatch {
44
61
  name: string;
@@ -450,12 +467,12 @@ export function resolveTagColor(
450
467
  if (!activeGroupName) return undefined;
451
468
 
452
469
  const group = tagGroups.find(
453
- (g) => g.name.toLowerCase() === activeGroupName.toLowerCase()
470
+ (g) => tagAttrKey(g.name) === tagAttrKey(activeGroupName)
454
471
  );
455
472
  if (!group) return undefined;
456
473
 
457
474
  const metaValue =
458
- metadata[group.name.toLowerCase()] ??
475
+ metadata[tagAttrKey(group.name)] ??
459
476
  (isContainer ? undefined : group.defaultValue);
460
477
  if (!metaValue) return '#999999';
461
478
 
@@ -489,7 +506,7 @@ export function validateTagValues(
489
506
  if (tagGroups.length === 0) return;
490
507
 
491
508
  const groupMap = new Map<string, TagGroup>();
492
- for (const g of tagGroups) groupMap.set(g.name.toLowerCase(), g);
509
+ for (const g of tagGroups) groupMap.set(tagAttrKey(g.name), g);
493
510
 
494
511
  for (const entity of entities) {
495
512
  for (const [key, value] of Object.entries(entity.metadata)) {
@@ -557,10 +574,14 @@ export function validateTagGroupNames(
557
574
  `'none' is a reserved keyword and cannot be used as a tag group name`
558
575
  );
559
576
  }
560
- if (!VALID_TAG_IDENT_RE.test(group.name)) {
577
+ // A single-identifier name is valid as-is; a multi-word name is valid when
578
+ // quoted (`tag "Trust Zone" as tz`) — it slugs to a DOM-safe key while the
579
+ // original text stays the legend label. Reject only names whose slug is
580
+ // not a usable identifier (e.g. all-punctuation, or leading digit).
581
+ if (!VALID_TAG_IDENT_RE.test(tagAttrKey(group.name))) {
561
582
  report(
562
583
  group.lineNumber,
563
- `Tag group name "${group.name}" contains invalid characters — use a single identifier (letters, digits, underscore, hyphen)`
584
+ `Tag group name "${group.name}" can't form a valid key — use letters, digits, underscore, or hyphen, and quote a multi-word name (e.g. tag "Trust Zone" as tz)`
564
585
  );
565
586
  }
566
587
  if (group.alias != null && !VALID_TAG_IDENT_RE.test(group.alias)) {
@@ -591,7 +612,7 @@ export function validateTagGroupNames(
591
612
  export function cascadeTagMetadata<
592
613
  T extends { metadata: Record<string, string>; children: readonly T[] },
593
614
  >(roots: readonly T[], tagGroups: ReadonlyArray<{ name: string }>): void {
594
- const keys = tagGroups.map((g) => g.name.toLowerCase());
615
+ const keys = tagGroups.map((g) => tagAttrKey(g.name));
595
616
  if (keys.length === 0) return;
596
617
 
597
618
  const walk = (node: T, inherited: Record<string, string>): void => {
@@ -629,7 +650,7 @@ export function injectDefaultTagMetadata(
629
650
  for (const group of tagGroups) {
630
651
  if (group.defaultValue) {
631
652
  defaults.push({
632
- key: group.name.toLowerCase(),
653
+ key: tagAttrKey(group.name),
633
654
  value: group.defaultValue,
634
655
  });
635
656
  }
@@ -36,6 +36,7 @@ import {
36
36
  stripDefaultModifier,
37
37
  validateTagGroupNames,
38
38
  validateTagValues,
39
+ tagAttrKey,
39
40
  } from '../utils/tag-groups';
40
41
  import type { TagGroup } from '../utils/tag-groups';
41
42
  import type { Writable } from '../utils/brand';
@@ -1364,7 +1365,7 @@ function parseVisualizationFull(
1364
1365
  );
1365
1366
  for (const group of result.timelineTagGroups) {
1366
1367
  if (!group.defaultValue) continue;
1367
- const key = group.name.toLowerCase();
1368
+ const key = tagAttrKey(group.name);
1368
1369
  for (const event of result.timelineEvents) {
1369
1370
  if (!event.metadata[key]) {
1370
1371
  event.metadata[key] = group.defaultValue;