@diagrammo/dgmo 0.24.0 → 0.25.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.
@@ -46,13 +46,7 @@ import {
46
46
  CAPTION_BOX_PADDING_X,
47
47
  CAPTION_BOX_PADDING_Y,
48
48
  } from '../utils/title-constants';
49
- import {
50
- LEGEND_HEIGHT as LEGEND_HEIGHT_CONST,
51
- LEGEND_PILL_FONT_SIZE as LEGEND_PILL_FONT_SIZE_CONST,
52
- LEGEND_ENTRY_DOT_GAP as LEGEND_ENTRY_DOT_GAP_CONST,
53
- LEGEND_DOT_R as LEGEND_DOT_R_CONST,
54
- measureLegendText,
55
- } from '../utils/legend-constants';
49
+ import { LEGEND_HEIGHT as LEGEND_HEIGHT_CONST } from '../utils/legend-constants';
56
50
  import { resolveActiveTagGroup, resolveTagColor } from '../utils/tag-groups';
57
51
  import { renderLegendD3 } from '../utils/legend-d3';
58
52
  import type {
@@ -126,10 +120,6 @@ const CONTAINER_HEADER_HEIGHT = 28;
126
120
  // Collapse-bar height — see conventions doc §3 Pattern A/B (matches
127
121
  // org's `COLLAPSE_BAR_HEIGHT`). Universal "this is collapsed" signal.
128
122
  const COLLAPSE_BAR_HEIGHT = 6;
129
- // Fade applied to non-critical elements when the Critical Path toggle
130
- // is on. Matches gantt's `FADE_OPACITY` (renderer.ts:1815) so the same
131
- // "spotlight" effect reads consistently across diagrams.
132
- const FADE_OPACITY = 0.15;
133
123
  // Always-on fade applied to bottom-20% (by duration) activity nodes
134
124
  // so the eye is drawn to the longer, schedule-dominating work first.
135
125
  // Less aggressive than FADE_OPACITY because these cards still need to
@@ -141,16 +131,11 @@ const DURATION_FADE_OPACITY = 0.55;
141
131
  const PIN_ICON_W = 13;
142
132
  const PIN_ICON_H = 13;
143
133
 
144
- // Top legenda horizontal row of pills (Critical Path, Anchor,
145
- // Milestone) that sits between the title and the diagram body. Pills
146
- // match the visual conventions of the shared `renderLegendD3` legend
147
- // used by Cycle/Mindmap/BoxesAndLines (see `utils/legend-constants.ts`):
148
- // 28px tall, 11pt label, fully-rounded rx, mix-fill against surface.
134
+ // Legend pill height matches the shared `renderLegendD3` legend used
135
+ // by Cycle/Mindmap/BoxesAndLines (see `utils/legend-constants.ts`):
136
+ // 28px tall, fully-rounded rx, mix-fill against surface. Drives the
137
+ // tag-group legend block's reserved height.
149
138
  const LEGEND_PILL_HEIGHT = LEGEND_HEIGHT_CONST;
150
- const LEGEND_PILL_PADDING_X = 8;
151
- const LEGEND_PILL_GAP = 8;
152
- const LEGEND_SWATCH_GAP = LEGEND_ENTRY_DOT_GAP_CONST;
153
- const LEGEND_FONT_SIZE = LEGEND_PILL_FONT_SIZE_CONST;
154
139
  // Top gap is the breathing room between the title baseline (or canvas
155
140
  // top when there's no title) and the pill row. Bottom gap separates
156
141
  // the pills from the diagram body. Together with LEGEND_PILL_HEIGHT
@@ -371,14 +356,14 @@ export interface PertRenderOptions {
371
356
  */
372
357
  showFieldLegend?: boolean;
373
358
  /**
374
- * Render the top legend (Critical Path / Anchor / Milestone pills)
375
- * inside the SVG, between the title and the diagram. Defaults to
376
- * true so CLI exports and share-link images include the legend; the
377
- * desktop preview flips it off and renders the legend in a sibling
378
- * native-pixel SVG instead, so the pill text stays at intended size
379
- * even when the diagram SVG gets scale-to-fit'd into the panel.
359
+ * Render the tag-group legend inside the SVG, between the title and
360
+ * the diagram. Defaults to true so CLI exports and share-link images
361
+ * include it; the desktop preview flips it off and renders the legend
362
+ * in a sibling native-pixel SVG instead, so the pill text stays at
363
+ * intended size even when the diagram SVG gets scale-to-fit'd into the
364
+ * panel.
380
365
  */
381
- showTopLegend?: boolean;
366
+ showLegend?: boolean;
382
367
  /**
383
368
  * Render the project-stats Summary box below the diagram. Defaults
384
369
  * to true so CLI exports / share-link images keep showing it; the
@@ -460,21 +445,16 @@ export function renderPert(
460
445
  ? CAPTION_TOP_GAP +
461
446
  fieldLegendHeightFor(standaloneFieldLegendWidthForExport)
462
447
  : 0;
463
- const showTopLegend = options.showTopLegend ?? true;
464
- const legendEntries = showTopLegend ? pertLegendEntries(resolved) : [];
448
+ const showLegend = options.showLegend ?? true;
465
449
  const tagLegendActive = resolveActiveTagGroup(
466
450
  resolved.tagGroups,
467
451
  resolved.options.activeTag,
468
452
  options.activeTagOverride
469
453
  );
470
- const showTagLegend = showTopLegend && resolved.tagGroups.length > 0;
471
- const tagLegendBlockHeight = showTagLegend
472
- ? LEGEND_PILL_HEIGHT + LEGEND_BOTTOM_GAP
454
+ const showTagLegend = showLegend && resolved.tagGroups.length > 0;
455
+ const legendBlockHeight = showTagLegend
456
+ ? LEGEND_TOP_GAP + LEGEND_PILL_HEIGHT + LEGEND_BOTTOM_GAP
473
457
  : 0;
474
- const legendBlockHeight =
475
- (legendEntries.length > 0
476
- ? LEGEND_TOP_GAP + LEGEND_PILL_HEIGHT + LEGEND_BOTTOM_GAP
477
- : 0) + tagLegendBlockHeight;
478
458
 
479
459
  const naturalChartWidth = layout.width + DIAGRAM_PADDING * 2;
480
460
  const minAnalysisRowW = analysisLayer.analysisHasContent
@@ -512,13 +492,9 @@ export function renderPert(
512
492
  const sLegendTopGap = ctx.aesthetic(LEGEND_TOP_GAP);
513
493
  const sLegendBottomGap = ctx.aesthetic(LEGEND_BOTTOM_GAP);
514
494
  const sLegendPillHeight = ctx.structural(LEGEND_PILL_HEIGHT);
515
- const sTagLegendBlockHeight = showTagLegend
516
- ? sLegendPillHeight + sLegendBottomGap
495
+ const sLegendBlockHeight = showTagLegend
496
+ ? sLegendTopGap + sLegendPillHeight + sLegendBottomGap
517
497
  : 0;
518
- const sLegendBlockHeight =
519
- (legendEntries.length > 0
520
- ? sLegendTopGap + sLegendPillHeight + sLegendBottomGap
521
- : 0) + sTagLegendBlockHeight;
522
498
  const sNodeRadius = ctx.structural(NODE_RADIUS);
523
499
  const sNodeStrokeWidth = ctx.structural(NODE_STROKE_WIDTH);
524
500
  const sNodeFontSize = ctx.text(NODE_FONT_SIZE);
@@ -587,22 +563,8 @@ export function renderPert(
587
563
  const offsetX = Math.max(sDiagramPad, (svgW - layout.width) / 2);
588
564
  const offsetY = sDiagramPad + sTitleHeight + sLegendBlockHeight;
589
565
 
590
- if (legendEntries.length > 0) {
591
- renderLegendBlock(svg, legendEntries, {
592
- x: 0,
593
- y: sDiagramPad + sTitleHeight + sLegendTopGap,
594
- width: svgW,
595
- palette,
596
- isDark,
597
- });
598
- }
599
566
  if (showTagLegend) {
600
- const tagLegendY =
601
- sDiagramPad +
602
- sTitleHeight +
603
- (legendEntries.length > 0
604
- ? sLegendTopGap + sLegendPillHeight
605
- : sLegendTopGap);
567
+ const tagLegendY = sDiagramPad + sTitleHeight + sLegendTopGap;
606
568
  renderTagLegendRow(svg, resolved, palette, isDark, {
607
569
  x: 0,
608
570
  y: tagLegendY,
@@ -720,11 +682,10 @@ export function renderPertForExport(
720
682
  : 0;
721
683
  const captionBlockHeight =
722
684
  captionBullets.length > 0 ? CAPTION_TOP_GAP + captionBoxHeight : 0;
723
- // Mirror renderPert's top-legend reservation so the offscreen
685
+ // Mirror renderPert's tag-legend reservation so the offscreen
724
686
  // container matches the natural canvas height.
725
- const legendEntries = pertLegendEntries(resolved);
726
687
  const legendBlockHeight =
727
- legendEntries.length > 0
688
+ resolved.tagGroups.length > 0
728
689
  ? LEGEND_TOP_GAP + LEGEND_PILL_HEIGHT + LEGEND_BOTTOM_GAP
729
690
  : 0;
730
691
  const exportWidth = layout.width + DIAGRAM_PADDING * 2;
@@ -2502,131 +2463,6 @@ function computeAnchorPinSet(resolved: ResolvedPert): Set<string> {
2502
2463
  return pinned;
2503
2464
  }
2504
2465
 
2505
- // ============================================================
2506
- // Section: critical-path highlight (React-callable)
2507
- // ============================================================
2508
- //
2509
- // Helpers that React (PertPreview) calls when the user toggles the
2510
- // "Highlight Critical Path" entry inside the cog dropdown. Operates
2511
- // on the diagram's container element — finds the SVG inside, fades
2512
- // non-critical nodes/edges/groups to 15%.
2513
- //
2514
- // In Monte-Carlo mode the binary critical chain is a misleading lens
2515
- // (every activity has some criticality), so the highlight rule keeps
2516
- // the high-band activities (red / orange / yellow) visible and fades
2517
- // the rest. In analytical mode it falls back to the binary path.
2518
-
2519
- const HIGHLIGHT_BANDS = new Set<string>(['red', 'orange', 'yellow']);
2520
-
2521
- function isCritical(el: Element, mcOn: boolean): boolean {
2522
- if (mcOn) {
2523
- return HIGHLIGHT_BANDS.has(el.getAttribute('data-criticality-band') ?? '');
2524
- }
2525
- return el.getAttribute('data-critical-path') === 'true';
2526
- }
2527
-
2528
- /**
2529
- * Predicate for whether a node/edge belongs to the highlighted set
2530
- * for a given legend entry. Edges only apply to the critical kind —
2531
- * anchor and milestone are node-only properties.
2532
- */
2533
- function isInHighlightSet(
2534
- el: Element,
2535
- kind: LegendKind,
2536
- mcOn: boolean
2537
- ): boolean {
2538
- if (kind === 'critical') return isCritical(el, mcOn);
2539
- if (kind === 'milestone') {
2540
- return el.getAttribute('data-milestone') === 'true';
2541
- }
2542
- // anchor
2543
- return el.hasAttribute('data-anchor');
2544
- }
2545
-
2546
- /**
2547
- * Fade everything in the diagram that doesn't belong to the given
2548
- * legend set (`'critical'`, `'anchor'`, or `'milestone'`). Auto-detects
2549
- * MC vs analytical mode for the critical-path rule.
2550
- *
2551
- * No-op when nothing qualifies (e.g. hovering Anchor on a diagram with
2552
- * no anchor — shouldn't happen because the pill wouldn't render, but
2553
- * defensive). The React layer is responsible for resetting via
2554
- * `resetPertHighlight` when hover/click goes away.
2555
- */
2556
- export function highlightPertSet(container: Element, kind: LegendKind): void {
2557
- const svg = container.querySelector('svg');
2558
- if (!svg) return;
2559
- // Detect MC mode: edges in MC mode have non-empty data-criticality-band
2560
- // bands like 'red'/'orange'/etc. Analytical mode uses only 'red' or ''.
2561
- const mcOn = Array.from(svg.querySelectorAll('.pert-edge')).some((e) => {
2562
- const b = e.getAttribute('data-criticality-band');
2563
- return b !== null && b !== '' && b !== 'red';
2564
- });
2565
-
2566
- const candidates = svg.querySelectorAll(
2567
- '.pert-node, .pert-edge, .pert-group-collapsed'
2568
- );
2569
- let anyMatch = false;
2570
- for (const el of candidates) {
2571
- if (isInHighlightSet(el, kind, mcOn)) {
2572
- anyMatch = true;
2573
- break;
2574
- }
2575
- }
2576
- if (!anyMatch) return;
2577
-
2578
- svg.setAttribute('data-pert-highlight-active', kind);
2579
- for (const el of svg.querySelectorAll('.pert-node, .pert-edge')) {
2580
- (el as SVGElement).setAttribute(
2581
- 'opacity',
2582
- isInHighlightSet(el, kind, mcOn) ? '1' : String(FADE_OPACITY)
2583
- );
2584
- }
2585
- // Group containers always dim to scenery; collapsed group cards
2586
- // behave like nodes and follow the membership rule.
2587
- for (const el of svg.querySelectorAll('.pert-group')) {
2588
- const inSet =
2589
- el.classList.contains('pert-group-collapsed') &&
2590
- isInHighlightSet(el, kind, mcOn);
2591
- (el as SVGElement).setAttribute(
2592
- 'opacity',
2593
- inSet ? '1' : String(FADE_OPACITY)
2594
- );
2595
- }
2596
- }
2597
-
2598
- /**
2599
- * Critical-path-specific shorthand for `highlightPertSet(container,
2600
- * 'critical')`. Kept for backwards compatibility with existing callers.
2601
- */
2602
- export function highlightPertCriticalPath(container: Element): void {
2603
- highlightPertSet(container, 'critical');
2604
- }
2605
-
2606
- /**
2607
- * Reset opacities applied by `highlightPertSet`. Safe to call when no
2608
- * highlight is active.
2609
- */
2610
- export function resetPertHighlight(container: Element): void {
2611
- const svg = container.querySelector('svg');
2612
- if (!svg) return;
2613
- svg.removeAttribute('data-pert-highlight-active');
2614
- // Drop the legacy attribute too in case an older bundle wrote it.
2615
- svg.removeAttribute('data-critical-path-active');
2616
- for (const el of svg.querySelectorAll(
2617
- '.pert-node, .pert-edge, .pert-group'
2618
- )) {
2619
- (el as SVGElement).removeAttribute('opacity');
2620
- }
2621
- }
2622
-
2623
- /**
2624
- * Backwards-compatible alias for `resetPertHighlight`.
2625
- */
2626
- export function resetPertCriticalPath(container: Element): void {
2627
- resetPertHighlight(container);
2628
- }
2629
-
2630
2466
  /**
2631
2467
  * Build the anchor framing bullet, or `null` when no anchor is set.
2632
2468
  * Surfaces the user-pinned date in plain language; the start/finish
@@ -2854,179 +2690,6 @@ interface FieldLegendArgs {
2854
2690
  * top: [ Early Start | Duration | Early Finish ]
2855
2691
  * bottom: [ Late Start | Slack | Late Finish ]
2856
2692
  */
2857
- // ============================================================
2858
- // Section: top legend (Critical Path / Anchor / Milestone pills)
2859
- // ============================================================
2860
-
2861
- type LegendKind = 'critical' | 'anchor' | 'milestone';
2862
-
2863
- interface LegendEntry {
2864
- kind: LegendKind;
2865
- label: string;
2866
- }
2867
-
2868
- /**
2869
- * Returns the PERT-specific legend entries (Critical Path / Anchor /
2870
- * Milestone). Tag groups are rendered separately via the shared
2871
- * `renderLegendD3` helper so they get the standard collapsible-capsule
2872
- * treatment used by org / kanban / gantt.
2873
- */
2874
- export function pertLegendEntries(resolved: ResolvedPert): LegendEntry[] {
2875
- const entries: LegendEntry[] = [];
2876
- if (resolved.activities.length > 0) {
2877
- entries.push({ kind: 'critical', label: 'Critical Path' });
2878
- }
2879
- if (resolved.options.anchor !== null) {
2880
- entries.push({ kind: 'anchor', label: 'Anchor' });
2881
- }
2882
- if (resolved.activities.some((a) => a.activity.isMilestone)) {
2883
- entries.push({ kind: 'milestone', label: 'Milestone' });
2884
- }
2885
- return entries;
2886
- }
2887
-
2888
- // Visual swatch widths per kind. Critical = a small filled circle the
2889
- // same size as the entry-dot used by the shared legend (renders as a
2890
- // crisp 8px dot). Anchor = anchor icon at PIN_ICON_W. Milestone = ◆
2891
- // glyph rendered at the pill font size.
2892
- function legendSwatchWidth(kind: LegendKind): number {
2893
- if (kind === 'critical') return LEGEND_DOT_R_CONST * 2;
2894
- if (kind === 'anchor') return PIN_ICON_W;
2895
- return LEGEND_FONT_SIZE; // ◆ glyph width approx = font size
2896
- }
2897
-
2898
- function legendPillWidth(entry: LegendEntry): number {
2899
- const labelW = measureLegendText(entry.label, LEGEND_FONT_SIZE);
2900
- return Math.ceil(
2901
- LEGEND_PILL_PADDING_X +
2902
- legendSwatchWidth(entry.kind) +
2903
- LEGEND_SWATCH_GAP +
2904
- labelW +
2905
- LEGEND_PILL_PADDING_X
2906
- );
2907
- }
2908
-
2909
- function legendNaturalWidth(entries: LegendEntry[]): number {
2910
- if (entries.length === 0) return 0;
2911
- let total = 0;
2912
- for (const e of entries) total += legendPillWidth(e);
2913
- total += (entries.length - 1) * LEGEND_PILL_GAP;
2914
- return total;
2915
- }
2916
-
2917
- interface LegendBlockArgs {
2918
- x: number;
2919
- y: number;
2920
- width: number;
2921
- palette: PaletteColors;
2922
- isDark: boolean;
2923
- }
2924
-
2925
- /**
2926
- * Render the top-legend pill row. Each pill carries
2927
- * `data-legend-entry="critical|anchor|milestone"` so the React layer
2928
- * can attach hover/click wiring to fade the matching set.
2929
- *
2930
- * Visual style mirrors the shared `renderLegendD3` pill convention so
2931
- * PERT looks consistent with Cycle / Mindmap / BoxesAndLines: 28px tall,
2932
- * fully-rounded rx, mix-fill against surface, no stroke, 11pt label.
2933
- */
2934
- export const PERT_LEGEND_PILL_HEIGHT = LEGEND_PILL_HEIGHT;
2935
-
2936
- export function pertLegendBlockWidth(entries: LegendEntry[]): number {
2937
- return legendNaturalWidth(entries);
2938
- }
2939
-
2940
- export function renderLegendBlock(
2941
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2942
- entries: LegendEntry[],
2943
- args: LegendBlockArgs
2944
- ): void {
2945
- if (entries.length === 0) return;
2946
- const { x, y, width, palette, isDark } = args;
2947
- // Same fill recipe as the shared legend pills.
2948
- const groupBg = isDark
2949
- ? mix(palette.surface, palette.bg, 50)
2950
- : mix(palette.surface, palette.bg, 30);
2951
-
2952
- const block = svg
2953
- .append('g')
2954
- .attr('class', 'pert-legend')
2955
- .attr('data-pert-legend', '');
2956
-
2957
- // Pill row centered horizontally inside [x, x+width].
2958
- const totalW = legendNaturalWidth(entries);
2959
- let pillX = x + (width - totalW) / 2;
2960
-
2961
- for (const entry of entries) {
2962
- const pillW = legendPillWidth(entry);
2963
- const pill = block
2964
- .append('g')
2965
- .attr('class', 'pert-legend-entry')
2966
- .attr('data-legend-entry', entry.kind)
2967
- .attr('transform', `translate(${pillX}, ${y})`)
2968
- .style('cursor', 'pointer');
2969
-
2970
- pill
2971
- .append('rect')
2972
- .attr('class', 'pert-legend-pill')
2973
- .attr('width', pillW)
2974
- .attr('height', LEGEND_PILL_HEIGHT)
2975
- .attr('rx', LEGEND_PILL_HEIGHT / 2)
2976
- .attr('ry', LEGEND_PILL_HEIGHT / 2)
2977
- .attr('fill', groupBg);
2978
-
2979
- // Swatch — small inline mark for the kind. Critical = filled dot
2980
- // matching the shared-legend entry-dot style; Anchor = anchor icon;
2981
- // Milestone = ◆ glyph.
2982
- const swatchW = legendSwatchWidth(entry.kind);
2983
- const swatchCx = LEGEND_PILL_PADDING_X + swatchW / 2;
2984
- const swatchCy = LEGEND_PILL_HEIGHT / 2;
2985
- if (entry.kind === 'critical') {
2986
- pill
2987
- .append('circle')
2988
- .attr('class', 'pert-legend-swatch')
2989
- .attr('cx', swatchCx)
2990
- .attr('cy', swatchCy)
2991
- .attr('r', LEGEND_DOT_R_CONST)
2992
- .attr('fill', palette.colors.red);
2993
- } else if (entry.kind === 'anchor') {
2994
- drawAnchorPin(
2995
- pill,
2996
- swatchCx - PIN_ICON_W / 2,
2997
- swatchCy,
2998
- palette.textMuted
2999
- );
3000
- } else {
3001
- pill
3002
- .append('text')
3003
- .attr('class', 'pert-legend-swatch')
3004
- .attr('x', swatchCx)
3005
- .attr('y', swatchCy)
3006
- .attr('text-anchor', 'middle')
3007
- .attr('dominant-baseline', 'central')
3008
- .attr('font-family', FONT_FAMILY)
3009
- .attr('font-size', LEGEND_FONT_SIZE + 1)
3010
- .attr('fill', palette.textMuted)
3011
- .text('◆');
3012
- }
3013
-
3014
- pill
3015
- .append('text')
3016
- .attr('class', 'pert-legend-label')
3017
- .attr('x', LEGEND_PILL_PADDING_X + swatchW + LEGEND_SWATCH_GAP)
3018
- .attr('y', swatchCy)
3019
- .attr('dominant-baseline', 'central')
3020
- .attr('font-family', FONT_FAMILY)
3021
- .attr('font-size', LEGEND_FONT_SIZE)
3022
- .attr('font-weight', 500)
3023
- .attr('fill', palette.textMuted)
3024
- .text(entry.label);
3025
-
3026
- pillX += pillW + LEGEND_PILL_GAP;
3027
- }
3028
- }
3029
-
3030
2693
  interface TagLegendArgs {
3031
2694
  x: number;
3032
2695
  y: number;
@@ -200,10 +200,6 @@ export type SequenceElement =
200
200
  | SequenceSection
201
201
  | SequenceNote;
202
202
 
203
- export function isSequenceMessage(el: SequenceElement): el is SequenceMessage {
204
- return el.kind === 'message';
205
- }
206
-
207
203
  export function isSequenceBlock(el: SequenceElement): el is SequenceBlock {
208
204
  return el.kind === 'block';
209
205
  }
@@ -2006,9 +2006,15 @@ export function renderSequenceDiagram(
2006
2006
  .attr('class', 'group-box-wrapper')
2007
2007
  .attr('data-group-toggle', '')
2008
2008
  .attr('data-group-line', String(group.lineNumber))
2009
+ .attr('tabindex', '0')
2010
+ .attr('role', 'button')
2011
+ .attr('aria-expanded', 'true')
2009
2012
  .attr('cursor', 'pointer');
2010
2013
  groupG.append('title').text('Click to collapse');
2011
2014
 
2015
+ // Visual group frame — pointer-events:none so it never intercepts clicks.
2016
+ // The box is rendered behind the participant shapes, so the only reliable
2017
+ // toggle target is the header strip above the participants (hit area below).
2012
2018
  groupG
2013
2019
  .append('rect')
2014
2020
  .attr('x', minX)
@@ -2020,17 +2026,43 @@ export function renderSequenceDiagram(
2020
2026
  .attr('stroke', strokeColor)
2021
2027
  .attr('stroke-width', 1)
2022
2028
  .attr('stroke-opacity', 0.5)
2029
+ .attr('pointer-events', 'none')
2023
2030
  .attr('class', 'group-box');
2024
2031
 
2025
- // Group label
2032
+ // Transparent hit area over the header strip (above the participant boxes,
2033
+ // so it is never occluded). Gives the toggle a generous, discoverable
2034
+ // click target — mirrors the section-label-hit pattern.
2035
+ groupG
2036
+ .append('rect')
2037
+ .attr('x', minX)
2038
+ .attr('y', boxY)
2039
+ .attr('width', maxX - minX)
2040
+ .attr('height', participantStartY - boxY)
2041
+ .attr('fill', 'transparent')
2042
+ .attr('class', 'group-label-hit');
2043
+
2044
+ // Collapse chevron — visual affordance signalling the header is clickable.
2026
2045
  groupG
2027
2046
  .append('text')
2028
2047
  .attr('x', minX + 8)
2029
2048
  .attr('y', boxY + GROUP_LABEL_SIZE + 4)
2030
2049
  .attr('fill', strokeColor)
2031
2050
  .attr('font-size', GROUP_LABEL_SIZE)
2051
+ .attr('opacity', 0.7)
2052
+ .attr('pointer-events', 'none')
2053
+ .attr('class', 'group-chevron')
2054
+ .text('▾'); // ▾ expanded
2055
+
2056
+ // Group label
2057
+ groupG
2058
+ .append('text')
2059
+ .attr('x', minX + 8 + GROUP_LABEL_SIZE + 2)
2060
+ .attr('y', boxY + GROUP_LABEL_SIZE + 4)
2061
+ .attr('fill', strokeColor)
2062
+ .attr('font-size', GROUP_LABEL_SIZE)
2032
2063
  .attr('font-weight', 'bold')
2033
2064
  .attr('opacity', 0.7)
2065
+ .attr('pointer-events', 'none')
2034
2066
  .attr('class', 'group-label')
2035
2067
  .text(group.name);
2036
2068
  }
@@ -2091,6 +2123,9 @@ export function renderSequenceDiagram(
2091
2123
  participantG
2092
2124
  .attr('data-group-toggle', '')
2093
2125
  .attr('data-group-line', String(meta.lineNumber))
2126
+ .attr('tabindex', '0')
2127
+ .attr('role', 'button')
2128
+ .attr('aria-expanded', 'false')
2094
2129
  .attr('cursor', 'pointer');
2095
2130
  participantG.append('title').text('Click to expand');
2096
2131
 
@@ -27,23 +27,6 @@
27
27
  */
28
28
  export type Brand<T, B extends string> = T & { readonly __brand: B };
29
29
 
30
- /**
31
- * Cast a raw value to a branded type. The only legal "mint" point —
32
- * call this at the boundary where unbranded data (parser input,
33
- * external API) enters branded territory.
34
- *
35
- * const id = asBrand<NodeId>(rawString);
36
- *
37
- * Inverts trivially: a `Brand<T, B>` is assignable to `T` without a
38
- * cast, so consumers that want the underlying primitive lose the
39
- * brand naturally.
40
- */
41
- export function asBrand<B>(
42
- value: B extends Brand<infer T, string> ? T : never
43
- ): B {
44
- return value as B;
45
- }
46
-
47
30
  // ============================================================
48
31
  // Writable<T> — escape hatch for parsers that need a mutable
49
32
  // construction phase before returning a `readonly`-typed value.
@@ -31,6 +31,16 @@ import type {
31
31
  D3Sel,
32
32
  } from './legend-types';
33
33
 
34
+ // Vertically center an SVG <text> across engines. WebKit drops
35
+ // `dominant-baseline` on <text>, and resvg has limited support too
36
+ // (see legend-svg.ts), so we use the alphabetic baseline (the shared
37
+ // default) plus an em-relative dy. 0.32em matches legend-svg.ts's
38
+ // proven pill offset (fontSize/2 - 2 = 0.318em at 11px).
39
+ const LEGEND_TEXT_DY = '0.32em';
40
+ function centerText(sel: D3Sel): D3Sel {
41
+ return sel.attr('dy', LEGEND_TEXT_DY);
42
+ }
43
+
34
44
  // ── Main renderer ───────────────────────────────────────────
35
45
 
36
46
  export function renderLegendD3(
@@ -190,7 +200,7 @@ function renderCapsule(
190
200
  .attr('x', pill.x + pill.width / 2)
191
201
  .attr('y', LEGEND_HEIGHT / 2)
192
202
  .attr('text-anchor', 'middle')
193
- .attr('dominant-baseline', 'central')
203
+ .call(centerText)
194
204
  .attr('font-size', LEGEND_PILL_FONT_SIZE)
195
205
  .attr('font-weight', 500)
196
206
  .attr('fill', palette.text)
@@ -211,7 +221,7 @@ function renderCapsule(
211
221
  g.append('text')
212
222
  .attr('x', gr.minX)
213
223
  .attr('y', gr.textY)
214
- .attr('dominant-baseline', 'central')
224
+ .call(centerText)
215
225
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
216
226
  .attr('fill', palette.textMuted)
217
227
  .attr('pointer-events', 'none')
@@ -232,7 +242,7 @@ function renderCapsule(
232
242
  g.append('text')
233
243
  .attr('x', gr.maxX)
234
244
  .attr('y', gr.textY)
235
- .attr('dominant-baseline', 'central')
245
+ .call(centerText)
236
246
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
237
247
  .attr('fill', palette.textMuted)
238
248
  .attr('pointer-events', 'none')
@@ -259,7 +269,7 @@ function renderCapsule(
259
269
  .append('text')
260
270
  .attr('x', entry.textX)
261
271
  .attr('y', entry.textY)
262
- .attr('dominant-baseline', 'central')
272
+ .call(centerText)
263
273
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
264
274
  .attr('fill', palette.textMuted)
265
275
  .attr('font-family', FONT_FAMILY)
@@ -318,7 +328,7 @@ function renderPill(
318
328
  .attr('x', pill.width / 2)
319
329
  .attr('y', pill.height / 2)
320
330
  .attr('text-anchor', 'middle')
321
- .attr('dominant-baseline', 'central')
331
+ .call(centerText)
322
332
  .attr('font-size', LEGEND_PILL_FONT_SIZE)
323
333
  .attr('font-weight', 500)
324
334
  .attr('fill', palette.textMuted)
@@ -387,7 +397,7 @@ function renderControl(
387
397
  .attr('x', textX)
388
398
  .attr('y', ctrl.height / 2)
389
399
  .attr('text-anchor', 'middle')
390
- .attr('dominant-baseline', 'central')
400
+ .call(centerText)
391
401
  .attr('font-size', LEGEND_PILL_FONT_SIZE)
392
402
  .attr('font-weight', 500)
393
403
  .attr('fill', palette.textMuted)
@@ -422,7 +432,7 @@ function renderControl(
422
432
  .attr('x', child.width / 2)
423
433
  .attr('y', ctrl.height / 2)
424
434
  .attr('text-anchor', 'middle')
425
- .attr('dominant-baseline', 'central')
435
+ .call(centerText)
426
436
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
427
437
  .attr('fill', child.isActive ? palette.bg : palette.textMuted)
428
438
  .attr('font-family', FONT_FAMILY)
@@ -585,7 +595,7 @@ function renderControlsGroup(
585
595
  .append('text')
586
596
  .attr('x', tl.textX)
587
597
  .attr('y', tl.textY)
588
- .attr('dominant-baseline', 'central')
598
+ .call(centerText)
589
599
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
590
600
  .attr('fill', palette.textMuted)
591
601
  .attr('opacity', tl.active ? 1 : LEGEND_TOGGLE_OFF_OPACITY)
@@ -79,22 +79,6 @@ export const ALL_CHART_TYPES = new Set([
79
79
  'map',
80
80
  ]);
81
81
 
82
- /**
83
- * Heuristic: pipe-metadata content is structured `key: value, …` form when
84
- * the first token is a bare identifier followed by `:`. Used by parsers that
85
- * accept both shorthand-description-after-pipe and structured key-value
86
- * (pyramid, ring) to disambiguate the two.
87
- */
88
- export const PIPE_KEY_VALUE_PREFIX_RE = /^\s*[A-Za-z][A-Za-z0-9_-]*\s*:/;
89
-
90
- /**
91
- * Heuristic to detect a likely-structured tail inside an otherwise-shorthand
92
- * pipe: `, key:` somewhere in the string. Used to flag user errors like
93
- * `Inner | bare desc, color: blue` where `color: blue` is silently swallowed
94
- * into the description.
95
- */
96
- export const PIPE_LIKELY_STRUCTURED_TAIL_RE = /,\s*[A-Za-z][A-Za-z0-9_-]*\s*:/;
97
-
98
82
  /** Measure leading whitespace of a line, normalizing tabs to 4 spaces. */
99
83
  export function measureIndent(line: string): number {
100
84
  let indent = 0;