@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
@@ -151,6 +151,122 @@ export function completeMapPlaces(
151
151
  return [...cityItems, ...airportItems].slice(0, limit);
152
152
  }
153
153
 
154
+ /** A POI search hit — a city or airport the resolver can resolve, with the
155
+ * exact `token` to paste into DGMO (a `poi`/route endpoint). Powers the CLI
156
+ * `dgmo map search` command and the MCP `lookup_map_location` tool, so authors
157
+ * (and AI agents) can DISCOVER valid place tokens instead of guessing — e.g.
158
+ * learn that "New York" must be `New York City` and that `JFK` is bundled. */
159
+ export interface MapLocationMatch {
160
+ /** Canonical display name (cities) or full airport name. */
161
+ readonly name: string;
162
+ /** Exact text to use in DGMO. Cities: the name, ISO-qualified when ambiguous
163
+ * (`Portland US-OR`). Airports: the upper-case IATA code (`JFK`). */
164
+ readonly token: string;
165
+ readonly kind: 'city' | 'airport';
166
+ readonly iso: string;
167
+ readonly sub?: string;
168
+ /** City population (0 for airports). */
169
+ readonly pop: number;
170
+ /** Human detail, e.g. `US-OR · 652,503` or `Airport · John F Kennedy Intl`. */
171
+ readonly detail: string;
172
+ }
173
+
174
+ /**
175
+ * Substring-search cities + airports for a discovery surface (CLI / MCP), so an
176
+ * author can find the exact token the resolver expects. Unlike
177
+ * {@link completeMapPlaces} (prefix-only, editor type-ahead), this matches
178
+ * anywhere in a city name OR an airport code/name, so "york" finds
179
+ * `New York City` and "kennedy" finds `JFK`.
180
+ *
181
+ * Ranking: exact-name (or exact-IATA) → prefix → substring; within a tier,
182
+ * cities by population desc (deterministic index tie-break), cities before
183
+ * airports. Pure + deterministic. Empty query → `[]`.
184
+ */
185
+ export function searchMapLocations(
186
+ query: string,
187
+ gazetteer: Gazetteer,
188
+ opts?: { readonly limit?: number; readonly airports?: AirportData }
189
+ ): MapLocationMatch[] {
190
+ const q = fold(query);
191
+ if (!q) return [];
192
+ const limit = opts?.limit ?? 20;
193
+
194
+ // rank: 0 exact, 1 prefix, 2 substring; -1 = no match.
195
+ const rankOf = (hay: string): number =>
196
+ hay === q ? 0 : hay.startsWith(q) ? 1 : hay.includes(q) ? 2 : -1;
197
+
198
+ // Cities — match on folded display name (via byName keys) + aliases.
199
+ const cityRank = new Map<number, number>(); // city index → best rank
200
+ const consider = (idx: number, rank: number): void => {
201
+ if (rank < 0) return;
202
+ const prev = cityRank.get(idx);
203
+ if (prev === undefined || rank < prev) cityRank.set(idx, rank);
204
+ };
205
+ for (const [key, list] of Object.entries(gazetteer.byName)) {
206
+ const r = rankOf(key);
207
+ if (r >= 0) for (const i of list) consider(i, r);
208
+ }
209
+ for (const [key, idx] of Object.entries(gazetteer.alt)) {
210
+ consider(idx, rankOf(key));
211
+ }
212
+
213
+ const cityItems: Array<MapLocationMatch & { _rank: number }> = [...cityRank]
214
+ .filter(([i]) => gazetteer.cities[i] !== undefined)
215
+ .map(([i, rank]) => {
216
+ const c = gazetteer.cities[i]!;
217
+ const [, , iso, pop, name, sub] = c;
218
+ const ambiguous = (gazetteer.byName[fold(name)]?.length ?? 0) > 1;
219
+ const qualifier = sub ?? iso;
220
+ return {
221
+ name,
222
+ token: ambiguous ? `${name} ${qualifier}` : name,
223
+ kind: 'city' as const,
224
+ iso,
225
+ ...(sub !== undefined && { sub }),
226
+ pop,
227
+ detail: `${qualifier} · ${groupThousands(pop)}`,
228
+ _rank: rank,
229
+ };
230
+ });
231
+
232
+ // Airports — match on folded IATA code OR folded airport name.
233
+ const airports = opts?.airports;
234
+ const airportItems: Array<MapLocationMatch & { _rank: number }> = [];
235
+ if (airports?.airportIata) {
236
+ for (const [code, idx] of Object.entries(airports.airportIata)) {
237
+ const a = airports.airports[idx];
238
+ if (!a) continue;
239
+ const codeRank = rankOf(code);
240
+ const nameRank = rankOf(fold(a[4]));
241
+ const rank = Math.min(
242
+ codeRank < 0 ? 99 : codeRank,
243
+ nameRank < 0 ? 99 : nameRank
244
+ );
245
+ if (rank > 2) continue;
246
+ airportItems.push({
247
+ name: a[4],
248
+ token: code.toUpperCase(),
249
+ kind: 'airport',
250
+ iso: a[2],
251
+ pop: 0,
252
+ detail: `Airport · ${a[4]}`,
253
+ _rank: rank,
254
+ });
255
+ }
256
+ }
257
+
258
+ return [...cityItems, ...airportItems]
259
+ .sort(
260
+ (x, y) =>
261
+ x._rank - y._rank || // best match tier first
262
+ (x.kind === y.kind ? 0 : x.kind === 'city' ? -1 : 1) || // city before airport
263
+ y.pop - x.pop || // populous city first
264
+ (x.token < y.token ? -1 : x.token > y.token ? 1 : 0)
265
+ )
266
+ .slice(0, limit)
267
+ .map(({ _rank, ...rest }) => rest);
268
+ }
269
+
154
270
  export interface MapRegionCompletion {
155
271
  /** Display name = insert text (the resolver disambiguates cross-layer
156
272
  * collisions like Georgia by map scope, §24B.8). */
package/src/map/layout.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // POI radii + edge widths, and places labels with per-cluster collision
6
6
  // escalation. The SVG emission is renderer.ts (it only draws what we compute).
7
7
  // See spec section 24B.3-.7/.11 + the tech-spec Adversarial Review Resolutions AR1-AR9.
8
+ import { tagAttrKey } from '../utils/tag-groups';
8
9
  import {
9
10
  geoPath,
10
11
  geoNaturalEarth1,
@@ -1582,7 +1583,7 @@ export function layoutMap(
1582
1583
  (g) => g.name.toLowerCase() === groupName.toLowerCase()
1583
1584
  );
1584
1585
  if (!group) return null;
1585
- const val = tags[group.name.toLowerCase()] ?? group.defaultValue;
1586
+ const val = tags[tagAttrKey(group.name)] ?? group.defaultValue;
1586
1587
  if (!val) return null;
1587
1588
  const entry = group.entries.find(
1588
1589
  (e) => e.value.toLowerCase() === val.toLowerCase()
@@ -2656,7 +2657,7 @@ export function layoutMap(
2656
2657
  if (directHex)
2657
2658
  return { fill: directHex, stroke: mix(directHex, palette.text, 18) };
2658
2659
  for (const group of resolved.tagGroups) {
2659
- const val = p.tags[group.name.toLowerCase()];
2660
+ const val = p.tags[tagAttrKey(group.name)];
2660
2661
  if (!val) continue;
2661
2662
  const entry = group.entries.find(
2662
2663
  (e) => e.value.toLowerCase() === val.toLowerCase()
@@ -2678,7 +2679,7 @@ export function layoutMap(
2678
2679
  // or null → caller falls back to the neutral connector mix.
2679
2680
  const lineColor = (tags: Readonly<Record<string, string>>): string | null => {
2680
2681
  for (const group of resolved.tagGroups) {
2681
- const val = tags[group.name.toLowerCase()];
2682
+ const val = tags[tagAttrKey(group.name)];
2682
2683
  if (!val) continue;
2683
2684
  const entry = group.entries.find(
2684
2685
  (e) => e.value.toLowerCase() === val.toLowerCase()
package/src/map/parser.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  validateTagGroupNames,
26
26
  finalizeAutoTagColors,
27
27
  AUTO_TAG_COLOR_SENTINEL,
28
+ tagAttrKey,
28
29
  } from '../utils/tag-groups';
29
30
  import { parseInArrowLabel } from '../utils/arrows';
30
31
  import type {
@@ -157,7 +158,7 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
157
158
  // Declared tag-group names (lowercased) — the keys `partitionMeta` treats as
158
159
  // tag values (resolved from aliases). Recomputed as groups are declared.
159
160
  const tagGroupNames = (): ReadonlySet<string> =>
160
- new Set(tagGroups.map((g) => g.name.toLowerCase()));
161
+ new Set(tagGroups.map((g) => tagAttrKey(g.name)));
161
162
 
162
163
  const closeBlocks = (): void => {
163
164
  open.tag = null;
@@ -378,7 +379,7 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
378
379
  entries: [],
379
380
  lineNumber: line,
380
381
  };
381
- if (m.alias) aliasMap.set(m.alias.toLowerCase(), m.name.toLowerCase());
382
+ if (m.alias) aliasMap.set(m.alias.toLowerCase(), tagAttrKey(m.name));
382
383
  tagGroups.push(group);
383
384
  open.tag = group;
384
385
  // Inline form: `tag Market as m HQ indigo, Region teal` (R4).
@@ -19,6 +19,7 @@ import {
19
19
  finalizeAutoTagColors,
20
20
  cascadeTagMetadata,
21
21
  AUTO_TAG_COLOR_SENTINEL,
22
+ tagAttrKey,
22
23
  } from '../utils/tag-groups';
23
24
  import {
24
25
  measureIndent,
@@ -167,12 +168,12 @@ export function parseMindmap(
167
168
  if (tagBlockMatch.alias) {
168
169
  aliasMap.set(
169
170
  tagBlockMatch.alias.toLowerCase(),
170
- tagBlockMatch.name.toLowerCase()
171
+ tagAttrKey(tagBlockMatch.name)
171
172
  );
172
173
  }
173
174
  aliasMap.set(
174
- tagBlockMatch.name.toLowerCase(),
175
- tagBlockMatch.name.toLowerCase()
175
+ tagAttrKey(tagBlockMatch.name),
176
+ tagAttrKey(tagBlockMatch.name)
176
177
  );
177
178
  result.tagGroups.push(currentTagGroup);
178
179
  continue;
@@ -2,6 +2,7 @@
2
2
  // Mindmap 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 {
@@ -389,7 +390,7 @@ export function renderMindmap(
389
390
 
390
391
  // Expose active tag group value for legend-entry hover dimming
391
392
  if (activeTagGroup) {
392
- const tagKey = activeTagGroup.toLowerCase();
393
+ const tagKey = tagAttrKey(activeTagGroup);
393
394
  const metaValue = node.metadata[tagKey];
394
395
  if (metaValue) {
395
396
  nodeG.attr(`data-tag-${tagKey}`, metaValue.toLowerCase());
package/src/org/layout.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Org Chart Tree Layout Engine (d3-hierarchy)
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import { hierarchy, tree } from 'd3-hierarchy';
6
7
  import type { ParsedOrg, OrgNode } from './parser';
7
8
  import type { Writable } from '../utils/brand';
@@ -303,7 +304,7 @@ function computeLegendGroups(
303
304
  if (group.entries.length === 0) continue;
304
305
 
305
306
  // Filter entries to only values actually used by nodes (if provided)
306
- const usedValues = usedValuesByGroup?.get(group.name.toLowerCase());
307
+ const usedValues = usedValuesByGroup?.get(tagAttrKey(group.name));
307
308
  const visibleEntries = usedValues
308
309
  ? group.entries.filter((e) => usedValues.has(e.value.toLowerCase()))
309
310
  : group.entries;
@@ -1185,7 +1186,7 @@ export function layoutOrg(
1185
1186
  // Collect which tag group values are actually used by nodes
1186
1187
  const usedValuesByGroup = new Map<string, Set<string>>();
1187
1188
  for (const group of parsed.tagGroups) {
1188
- const key = group.name.toLowerCase();
1189
+ const key = tagAttrKey(group.name);
1189
1190
  const used = new Set<string>();
1190
1191
  const walk = (node: OrgNode) => {
1191
1192
  if (!node.isContainer && node.metadata[key]) {
package/src/org/parser.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  stripDefaultModifier,
21
21
  finalizeAutoTagColors,
22
22
  AUTO_TAG_COLOR_SENTINEL,
23
+ tagAttrKey,
23
24
  } from '../utils/tag-groups';
24
25
  import {
25
26
  measureIndent,
@@ -219,14 +220,14 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
219
220
  if (tagBlockMatch.alias) {
220
221
  metaAliasMap.set(
221
222
  normalizeName(tagBlockMatch.alias),
222
- tagBlockMatch.name.toLowerCase()
223
+ tagAttrKey(tagBlockMatch.name)
223
224
  );
224
225
  }
225
226
  // §1.4 dispatch: register canonical name so `<name>:` triggers
226
227
  // the metadata cut even without an explicit `as <alias>`.
227
228
  metaAliasMap.set(
228
229
  normalizeName(tagBlockMatch.name),
229
- tagBlockMatch.name.toLowerCase()
230
+ tagAttrKey(tagBlockMatch.name)
230
231
  );
231
232
  result.tagGroups.push(currentTagGroup);
232
233
  continue;
@@ -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>`