privateboard 0.1.13 → 0.1.16

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.
@@ -277,12 +277,6 @@
277
277
  background: transparent !important;
278
278
  border-top-color: #1A1814 !important;
279
279
  }
280
- /* Dark spine's mermaid frame — give it a paper bg in print. */
281
- body[data-spine="boardroom-dark"] .body pre.mermaid {
282
- background: #FFFFFF !important;
283
- border-color: #DDD5C8 !important;
284
- }
285
-
286
280
  /* CJK dispatch handles (cjk-sans / cjk-serif / cjk-mono) are
287
281
  declared OUTSIDE this @media block — see the @font-face
288
282
  trio at the top of <style>. Per-element font-family rules
@@ -353,7 +347,7 @@
353
347
  .body section.section-considerations li.rec-item,
354
348
  .body section.section-the-bet > ol > li,
355
349
  .body section.section-new-questions li.nq-item,
356
- .body pre.mermaid,
350
+ .body figure.kami-chart,
357
351
  .body table.md-table,
358
352
  /* Tier A additions · the units that were still getting sliced
359
353
  between pages despite the legacy ruleset. Each one is a small
@@ -406,7 +400,7 @@
406
400
  .body section > h2 + ul,
407
401
  .body section > h2 + .pillars-grid,
408
402
  .body section > h2 + h3,
409
- .body section > h2 + pre.mermaid,
403
+ .body section > h2 + figure.kami-chart,
410
404
  .body section > h3 + p,
411
405
  .body section > h3 + blockquote,
412
406
  .body section > h3 + table.md-table,
@@ -526,10 +520,10 @@
526
520
  break-inside: avoid;
527
521
  page-break-inside: avoid;
528
522
  }
529
- /* Mermaid charts shouldn't reserve big empty boxes when the SVG
530
- is small. */
531
- .body pre.mermaid { min-height: auto !important; padding: 14px !important; }
532
- .body pre.mermaid svg { max-width: 100% !important; height: auto !important; }
523
+ /* Kami chart figures shouldn't reserve big empty boxes when the
524
+ inline SVG is small. */
525
+ .body figure.kami-chart { padding: 14px !important; }
526
+ .body figure.kami-chart svg.kc-svg { max-width: 100% !important; height: auto !important; }
533
527
  }
534
528
 
535
529
  /* ─── Author byline · name list (spine-agnostic) ───────────────
@@ -598,185 +592,6 @@
598
592
  border-top: none;
599
593
  }
600
594
 
601
- /* ─── Mermaid + adjacent table = doubled hairline ───────────────
602
- Every spine's `pre.mermaid` carries a 1px `border-bottom` (the
603
- mermaid frame's closing rule). A pipe-table directly underneath
604
- carries its own 1.5–2px `border-top`. With only a paragraph
605
- break between, the two parallel rules read as a doubled frame.
606
- Drop the table's top border in that exact arrangement — the
607
- mermaid's bottom rule already separates the diagram from the
608
- table cleanly. Same reasoning works for the reverse order: a
609
- mermaid right after a table inherits the table's last-row
610
- border-bottom, so suppress the mermaid's top rule there too. */
611
- .body pre.mermaid + table.md-table {
612
- border-top: none;
613
- }
614
- .body table.md-table + pre.mermaid {
615
- border-top-width: 0;
616
- }
617
-
618
- /* ─── Hide mermaid's internal render scratchpad ──────────────
619
- During `mermaid.render(id, src)`, mermaid 10 briefly appends a
620
- `<div id="dmermaid-...">` to document.body to measure layout +
621
- produce the SVG. The div is removed once render() resolves,
622
- but in the meantime it can flash visibly. Force it offscreen
623
- so the user never sees mermaid's pre-takeover render at all.
624
- The element still has natural dimensions for mermaid's layout
625
- measurement — only its position + visibility is constrained. */
626
- body > [id^="dmermaid-"] {
627
- position: absolute !important;
628
- left: -99999px !important;
629
- top: 0 !important;
630
- visibility: hidden !important;
631
- pointer-events: none !important;
632
- }
633
-
634
- /* ─── Hide mermaid SVG until spine palette is applied ─────────
635
- Pattern from user reports: "first time wrong, refresh OK" /
636
- strict alternation (1st wrong, 2nd right, 3rd wrong, 4th
637
- right). Cause: even with our continuous post-render takeover
638
- (see report.html post-mermaid.run block), mermaid paints at
639
- least once with its default palette BEFORE our takeover lands
640
- — and the user sees that first paint. The takeover then
641
- corrects it, but the user's already perceived "wrong colors."
642
- On refresh, cached rendering may finish faster and the first
643
- paint happens AFTER our takeover, which they perceive as
644
- correct.
645
- Defense: hide the SVG inside each pre.mermaid until we add
646
- the `is-painted` class. The post-render takeover adds the
647
- class as its last step. Until then, the user sees the frame
648
- (matching the spine) with no SVG inside — no off-brand
649
- colors flash at any point. */
650
- .body pre.mermaid svg {
651
- visibility: hidden;
652
- }
653
- .body pre.mermaid.is-painted svg {
654
- visibility: visible;
655
- }
656
-
657
- /* ─── CSS-variable mermaid palette · timing-race-immune ──────
658
- Final defense layer for the recurring "first time wrong,
659
- refresh OK" bug. JS takeover fights timing with mermaid's
660
- deferred render work; CSS doesn't have that problem — as
661
- soon as the SVG is in the DOM, these rules apply. The
662
- spine-specific colours are injected as CSS variables on
663
- `:root` from JS in the swapSpine flow (see swapSpine /
664
- mermaid.initialize block). Variables update when the spine
665
- changes; CSS rules are static.
666
-
667
- Specificity: each rule uses a 3-class+ chain plus
668
- !important, beating mermaid's `.quadrant-fill { fill: ... }`
669
- and any `fill="..."` presentation attribute. The element
670
- selectors target every mermaid 10 chart-type's known
671
- visual elements. */
672
-
673
- /* Quadrant chart · 4 quadrant rects + data points */
674
- .body pre.mermaid svg .quadrants .quadrant rect,
675
- .body pre.mermaid svg g.quadrant > rect,
676
- .body pre.mermaid svg [aria-roledescription="quadrantChart"] g.quadrant rect {
677
- fill: var(--mm-q-fill, #1A1A18) !important;
678
- }
679
- .body pre.mermaid svg .data-points circle,
680
- .body pre.mermaid svg circle.data-point,
681
- .body pre.mermaid svg [aria-roledescription="quadrantChart"] .data-points circle {
682
- fill: var(--mm-q-point, #C9A46B) !important;
683
- stroke: var(--mm-q-bg, #1A1A18) !important;
684
- }
685
- .body pre.mermaid svg .quadrants line,
686
- .body pre.mermaid svg .axis-line,
687
- .body pre.mermaid svg .axisl-line,
688
- .body pre.mermaid svg g.border line,
689
- .body pre.mermaid svg [aria-roledescription="quadrantChart"] line,
690
- .body pre.mermaid svg [aria-roledescription="quadrantChart"] g.border line {
691
- stroke: var(--mm-q-border, #3A3934) !important;
692
- }
693
- .body pre.mermaid svg [aria-roledescription="quadrantChart"] text {
694
- fill: var(--mm-q-text, #C8C5BE) !important;
695
- }
696
- .body pre.mermaid svg [aria-roledescription="quadrantChart"] .bottom-axis text,
697
- .body pre.mermaid svg [aria-roledescription="quadrantChart"] .left-axis text,
698
- .body pre.mermaid svg [aria-roledescription="quadrantChart"] .top-axis text,
699
- .body pre.mermaid svg [aria-roledescription="quadrantChart"] .right-axis text {
700
- fill: var(--mm-q-axis-text, #5C5A4D) !important;
701
- }
702
- .body pre.mermaid svg [aria-roledescription="quadrantChart"] .quadrant text {
703
- fill: var(--mm-q-quad-text, #8E8B83) !important;
704
- }
705
-
706
- /* xychart-beta · bars cycle through palette via :nth-child */
707
- .body pre.mermaid svg [aria-roledescription="xychart"] g[class*="bar-plot"] rect,
708
- .body pre.mermaid svg [aria-roledescription="xychart"] g.plot rect {
709
- fill: var(--mm-pal-0, #C9A46B) !important;
710
- stroke: none !important;
711
- }
712
- .body pre.mermaid svg [aria-roledescription="xychart"] g[class*="bar-plot"] rect:nth-child(2),
713
- .body pre.mermaid svg [aria-roledescription="xychart"] g.plot rect:nth-child(2) {
714
- fill: var(--mm-pal-1, #B6B0A2) !important;
715
- }
716
- .body pre.mermaid svg [aria-roledescription="xychart"] g[class*="bar-plot"] rect:nth-child(3),
717
- .body pre.mermaid svg [aria-roledescription="xychart"] g.plot rect:nth-child(3) {
718
- fill: var(--mm-pal-2, #8E8B83) !important;
719
- }
720
- .body pre.mermaid svg [aria-roledescription="xychart"] g[class*="bar-plot"] rect:nth-child(n+4),
721
- .body pre.mermaid svg [aria-roledescription="xychart"] g.plot rect:nth-child(n+4) {
722
- fill: var(--mm-pal-3, #5C5A52) !important;
723
- }
724
- .body pre.mermaid svg [aria-roledescription="xychart"] text {
725
- fill: var(--mm-axis-text, #5C5A4D) !important;
726
- }
727
- .body pre.mermaid svg [aria-roledescription="xychart"] .background,
728
- .body pre.mermaid svg [aria-roledescription="xychart"] rect.background {
729
- fill: transparent !important;
730
- }
731
-
732
- /* Pie · slice palette */
733
- .body pre.mermaid svg path.pieCircle:nth-of-type(1) { fill: var(--mm-pal-0, #C9A46B) !important; stroke: var(--mm-q-bg, #1A1A18) !important; }
734
- .body pre.mermaid svg path.pieCircle:nth-of-type(2) { fill: var(--mm-pal-1, #B6B0A2) !important; }
735
- .body pre.mermaid svg path.pieCircle:nth-of-type(3) { fill: var(--mm-pal-2, #8E8B83) !important; }
736
- .body pre.mermaid svg path.pieCircle:nth-of-type(n+4) { fill: var(--mm-pal-3, #5C5A52) !important; }
737
- .body pre.mermaid svg circle.pieOuterCircle {
738
- stroke: var(--mm-q-border, #3A3934) !important;
739
- fill: none !important;
740
- }
741
-
742
- /* Universal text safety net inside any mermaid SVG · titleFill
743
- for anything that escaped the per-type rules above. Lower
744
- specificity than the type-specific rules so they still win. */
745
- .body pre.mermaid svg text {
746
- fill: var(--mm-text, #C8C5BE); /* no !important · type-specific rules above WIN */
747
- }
748
-
749
- /* ─── Chart display sizes · CSS-enforced ─────────────────────
750
- Mermaid 10.9.5's chartWidth/chartHeight config keys for
751
- quadrantChart and xychart are silently ignored by the
752
- bundled CDN build (verified via DOM dump showing default
753
- 500×500 viewBox even when chartWidth: 460 is configured).
754
- CSS-enforced sizes via `max-width` on the rendered SVG let
755
- us get refined-compact dimensions regardless of what
756
- mermaid's config validator does. The viewBox stays at
757
- mermaid's internal coordinate space (500×500 etc.) but the
758
- displayed SVG scales down to our target size. Centered with
759
- margin: 0 auto so the chart sits flush in the article column
760
- instead of stretching to full width. */
761
- .body pre.mermaid svg[aria-roledescription="quadrantChart"] {
762
- max-width: 460px !important;
763
- height: auto !important;
764
- margin: 0 auto !important;
765
- display: block !important;
766
- }
767
- .body pre.mermaid svg[aria-roledescription="xychart"] {
768
- max-width: 560px !important;
769
- height: auto !important;
770
- margin: 0 auto !important;
771
- display: block !important;
772
- }
773
- .body pre.mermaid svg[aria-roledescription="pie"] {
774
- max-width: 420px !important;
775
- height: auto !important;
776
- margin: 0 auto !important;
777
- display: block !important;
778
- }
779
-
780
595
  /* ─── Metric-strip · spine-agnostic baseline ─────────────────────
781
596
  Editorial / Swiss register: oversized thin numerals top-left, mute
782
597
  mono label bottom-left, hairline grid (no card backgrounds, no
@@ -1131,6 +946,487 @@
1131
946
  color: var(--red, #B5706A);
1132
947
  }
1133
948
 
949
+ /* ─── Kami-chart · spine-agnostic inline-SVG charts ──────────────
950
+ Replaces mermaid as the report's chart-rendering pipeline. Each
951
+ chart is emitted by the writer as a fenced ```kami-chart block
952
+ containing strict JSON; renderKamiChart() in JS turns it into
953
+ inline SVG. Styling is driven entirely by spine CSS variables
954
+ so each spine paints the chart in its own palette — no JS
955
+ palette takeover, no race conditions, no scratchpad div. */
956
+ .body .kami-chart {
957
+ margin: 28px 0 32px;
958
+ padding: 32px 36px 28px;
959
+ background: var(--paper-soft, #ECE6DA);
960
+ border: 1px solid var(--rule, rgba(0,0,0,0.14));
961
+ }
962
+ .body .kami-chart .kc-figheader {
963
+ display: flex; justify-content: space-between; align-items: baseline;
964
+ padding-bottom: 12px;
965
+ margin-bottom: 18px;
966
+ border-bottom: 1px solid var(--rule, rgba(0,0,0,0.14));
967
+ }
968
+ .body .kami-chart .kc-fignum {
969
+ font-family: var(--mono);
970
+ font-size: 11px;
971
+ text-transform: uppercase;
972
+ letter-spacing: 0.18em;
973
+ color: var(--accent, var(--em, #6B6660));
974
+ font-weight: 600;
975
+ }
976
+ .body .kami-chart .kc-figtitle {
977
+ font-family: var(--mono);
978
+ font-size: 11px;
979
+ text-transform: uppercase;
980
+ letter-spacing: 0.18em;
981
+ color: var(--ink-mid, #6A655D);
982
+ }
983
+ .body .kami-chart svg.kc-svg {
984
+ display: block;
985
+ width: 100%;
986
+ height: auto;
987
+ overflow: visible;
988
+ }
989
+
990
+ /* Canvas + grid + axis */
991
+ .body .kami-chart .kc-canvas { fill: var(--paper-soft, #ECE6DA); }
992
+ .body .kami-chart .kc-grid { stroke: var(--rule-soft, rgba(0,0,0,0.08)); stroke-width: 0.8; }
993
+ .body .kami-chart .kc-baseline { stroke: var(--ink, #2A2724); stroke-width: 0.8; }
994
+ .body .kami-chart .kc-y-tick {
995
+ fill: var(--ink-soft, #978C7E);
996
+ font: 10px var(--sans);
997
+ }
998
+ .body .kami-chart .kc-y-unit {
999
+ fill: var(--ink-soft, #978C7E);
1000
+ font: 9px var(--mono);
1001
+ letter-spacing: 0.12em;
1002
+ text-transform: uppercase;
1003
+ }
1004
+
1005
+ /* Bar series · spine accent for primary; neutrals for secondary/tertiary.
1006
+ Single-focal rule from kami's design guide: at most one series carries
1007
+ the accent. Multi-series charts use accent + ink-mid (+ ink-soft). */
1008
+ .body .kami-chart .kc-bar.kc-series-primary { fill: var(--accent, var(--em, #6B6660)); }
1009
+ .body .kami-chart .kc-bar.kc-series-secondary { fill: var(--ink-mid, #6A655D); }
1010
+ .body .kami-chart .kc-bar.kc-series-tertiary { fill: var(--ink-soft, #978C7E); }
1011
+
1012
+ .body .kami-chart .kc-data-label {
1013
+ fill: var(--ink, #2A2724);
1014
+ font: 10px var(--sans);
1015
+ }
1016
+ .body .kami-chart .kc-data-label.kc-primary { font-weight: 600; }
1017
+ .body .kami-chart .kc-category-label {
1018
+ fill: var(--ink, #2A2724);
1019
+ font: 11px var(--sans);
1020
+ }
1021
+
1022
+ /* Legend */
1023
+ .body .kami-chart .kc-legend-divider { stroke: var(--rule, rgba(0,0,0,0.14)); stroke-width: 0.8; }
1024
+ .body .kami-chart .kc-legend-swatch.kc-series-primary { fill: var(--accent, var(--em, #6B6660)); }
1025
+ .body .kami-chart .kc-legend-swatch.kc-series-secondary { fill: var(--ink-mid, #6A655D); }
1026
+ .body .kami-chart .kc-legend-swatch.kc-series-tertiary { fill: var(--ink-soft, #978C7E); }
1027
+ .body .kami-chart .kc-legend-text {
1028
+ fill: var(--ink-mid, #6A655D);
1029
+ font: 10px var(--sans);
1030
+ }
1031
+
1032
+ /* Caption · editorial gloss below the chart. Serif italic <em> in accent. */
1033
+ .body .kami-chart .kc-caption {
1034
+ font-family: var(--serif);
1035
+ font-size: 14px;
1036
+ color: var(--ink-mid, #6A655D);
1037
+ line-height: 1.7;
1038
+ margin-top: 18px;
1039
+ padding-top: 14px;
1040
+ border-top: 1px solid var(--rule, rgba(0,0,0,0.14));
1041
+ max-width: 60ch;
1042
+ }
1043
+ .body .kami-chart .kc-caption em {
1044
+ color: var(--accent, var(--em, #B65A3A));
1045
+ font-style: italic;
1046
+ }
1047
+
1048
+ /* Error fallback · mirrors the metric-strip-error register so a
1049
+ malformed chart degrades visibly without blowing up the report. */
1050
+ .body .kami-chart-error {
1051
+ margin: 28px 0;
1052
+ padding: 16px 18px;
1053
+ background: var(--paper-soft, #ECE6DA);
1054
+ border: 1px dashed var(--rule, rgba(0,0,0,0.14));
1055
+ font-family: var(--mono);
1056
+ font-size: 11px;
1057
+ color: var(--ink-soft, #978C7E);
1058
+ text-transform: uppercase;
1059
+ letter-spacing: 0.06em;
1060
+ }
1061
+ .body .kami-chart-error::before {
1062
+ content: "// kami-chart · malformed";
1063
+ display: block;
1064
+ margin-bottom: 6px;
1065
+ color: var(--red, #B5706A);
1066
+ }
1067
+
1068
+ /* ─── Kami-chart · quadrant ──────────────────────────────────────
1069
+ 2-axis plot · plot area framed; preferred (top-right) quadrant
1070
+ gets a subtle accent-tinted fill; crosshair at the midpoint;
1071
+ axis-end arrows; quadrant labels in the four corners. */
1072
+ .body .kami-chart .kc-q-frame { fill: none; stroke: var(--rule, rgba(0,0,0,0.14)); stroke-width: 0.8; }
1073
+ .body .kami-chart .kc-q-axis { stroke: var(--rule-soft, rgba(0,0,0,0.08)); stroke-width: 0.8; }
1074
+ .body .kami-chart .kc-q-tint { fill: var(--paper-deep, rgba(0,0,0,0.03)); }
1075
+ .body .kami-chart .kc-q-arrow { stroke: var(--ink-mid, #6A655D); stroke-width: 1.2; fill: none; }
1076
+ .body .kami-chart .kc-q-axis-label {
1077
+ fill: var(--ink, #2A2724); font: 11px var(--sans);
1078
+ letter-spacing: 0.08em;
1079
+ }
1080
+ .body .kami-chart .kc-q-quad-kicker {
1081
+ fill: var(--ink-soft, #978C7E);
1082
+ font: 9px var(--mono);
1083
+ text-transform: uppercase;
1084
+ letter-spacing: 0.22em;
1085
+ }
1086
+ .body .kami-chart .kc-q-quad-kicker.kc-q-pref { fill: var(--accent, #6B6660); }
1087
+ .body .kami-chart .kc-q-quad-name {
1088
+ fill: var(--ink-mid, #6A655D); font: 10px var(--sans);
1089
+ }
1090
+ .body .kami-chart .kc-q-quad-name.kc-q-pref { fill: var(--accent, #6B6660); font-weight: 500; }
1091
+ .body .kami-chart .kc-point { fill: var(--paper-soft, #ECE6DA); stroke: var(--ink, #2A2724); stroke-width: 1; }
1092
+ .body .kami-chart .kc-point-focal { fill: var(--accent, #6B6660); stroke: var(--accent, #6B6660); stroke-width: 1; }
1093
+ .body .kami-chart .kc-point-tinted { fill: var(--paper-deep, rgba(0,0,0,0.03)); stroke: var(--accent, #6B6660); stroke-width: 1; }
1094
+ .body .kami-chart .kc-point-faint { fill: var(--paper-deep, rgba(0,0,0,0.03)); stroke: var(--ink-muted, #B5AB9B); stroke-width: 1; }
1095
+ .body .kami-chart .kc-point-label {
1096
+ fill: var(--ink, #2A2724); font: 10px var(--sans);
1097
+ }
1098
+ .body .kami-chart .kc-point-label.kc-primary { fill: var(--accent, #6B6660); font-weight: 500; }
1099
+ .body .kami-chart .kc-point-label.kc-faint { fill: var(--ink-mid, #6A655D); }
1100
+
1101
+ /* ─── Kami-chart · timeline ──────────────────────────────────────
1102
+ Horizontal time axis · events alternating above/below · each
1103
+ event = dot on axis + dashed connector + rect card with period
1104
+ (mono) + label (sans). Focal event renders in accent. */
1105
+ .body .kami-chart .kc-t-axis { stroke: var(--ink-muted, #B5AB9B); stroke-width: 1.4; }
1106
+ .body .kami-chart .kc-t-arrow { stroke: var(--ink-muted, #B5AB9B); stroke-width: 1.5; fill: none; stroke-linecap: round; }
1107
+ .body .kami-chart .kc-t-dot { fill: var(--ink-mid, #6A655D); stroke: var(--paper-soft, #ECE6DA); stroke-width: 1.5; }
1108
+ .body .kami-chart .kc-t-dot-focal { fill: var(--accent, #6B6660); stroke: var(--paper-soft, #ECE6DA); stroke-width: 1.5; }
1109
+ .body .kami-chart .kc-t-connector {
1110
+ stroke: var(--ink-soft, #978C7E); stroke-width: 1;
1111
+ stroke-dasharray: 3 3;
1112
+ }
1113
+ .body .kami-chart .kc-t-connector-focal {
1114
+ stroke: var(--accent, #6B6660); stroke-width: 1.2;
1115
+ }
1116
+ .body .kami-chart .kc-t-card {
1117
+ fill: var(--paper-soft, #ECE6DA);
1118
+ stroke: var(--rule, rgba(0,0,0,0.14));
1119
+ stroke-width: 0.8;
1120
+ }
1121
+ .body .kami-chart .kc-t-card-focal {
1122
+ fill: var(--paper, #F4EFE6);
1123
+ stroke: var(--accent, #6B6660);
1124
+ stroke-width: 1;
1125
+ }
1126
+ .body .kami-chart .kc-t-period {
1127
+ fill: var(--ink-soft, #978C7E);
1128
+ font: 8px var(--mono);
1129
+ letter-spacing: 0.15em;
1130
+ text-transform: uppercase;
1131
+ }
1132
+ .body .kami-chart .kc-t-period-focal {
1133
+ fill: var(--accent, #6B6660); font-weight: 600;
1134
+ }
1135
+ .body .kami-chart .kc-t-label {
1136
+ fill: var(--ink, #2A2724);
1137
+ font: 11px var(--sans); font-weight: 500;
1138
+ }
1139
+ .body .kami-chart .kc-t-label-focal { font-size: 12px; }
1140
+
1141
+ /* ─── Kami-chart · donut ─────────────────────────────────────────
1142
+ 6-slice maximum · cx=300 cy=200 R=136 r=76 · clockwise from top.
1143
+ Slices declared in fill order; the largest (slice 1) carries the
1144
+ accent, the rest cascade down the neutral ramp. Hollow centre
1145
+ shows a single value + caption. Legend on the right. */
1146
+ .body .kami-chart .kc-slice-1 { fill: var(--accent, #6B6660); }
1147
+ .body .kami-chart .kc-slice-2 { fill: var(--ink-mid, #6A655D); }
1148
+ .body .kami-chart .kc-slice-3 { fill: var(--ink-soft, #978C7E); }
1149
+ .body .kami-chart .kc-slice-4 { fill: var(--ink-muted, #B5AB9B); }
1150
+ .body .kami-chart .kc-slice-5 { fill: var(--paper-deep, #E2D9C4); }
1151
+ .body .kami-chart .kc-slice-6 {
1152
+ fill: var(--paper-soft, #ECE6DA);
1153
+ stroke: var(--rule, rgba(0,0,0,0.14));
1154
+ stroke-width: 0.8;
1155
+ }
1156
+ .body .kami-chart .kc-donut-separator {
1157
+ fill: none;
1158
+ stroke: var(--paper-soft, #ECE6DA);
1159
+ stroke-width: 1.5;
1160
+ }
1161
+ .body .kami-chart .kc-donut-hole {
1162
+ fill: var(--paper-soft, #ECE6DA);
1163
+ }
1164
+ .body .kami-chart .kc-donut-center-value {
1165
+ fill: var(--accent, #6B6660);
1166
+ font: 26px var(--serif);
1167
+ font-weight: 500;
1168
+ }
1169
+ .body .kami-chart .kc-donut-center-label {
1170
+ fill: var(--ink-soft, #978C7E);
1171
+ font: 10px var(--sans);
1172
+ letter-spacing: 0.10em;
1173
+ }
1174
+ .body .kami-chart .kc-donut-legend-divider {
1175
+ stroke: var(--rule, rgba(0,0,0,0.14));
1176
+ stroke-width: 0.8;
1177
+ }
1178
+ .body .kami-chart .kc-donut-legend-swatch.kc-slice-1 { fill: var(--accent, #6B6660); }
1179
+ .body .kami-chart .kc-donut-legend-swatch.kc-slice-2 { fill: var(--ink-mid, #6A655D); }
1180
+ .body .kami-chart .kc-donut-legend-swatch.kc-slice-3 { fill: var(--ink-soft, #978C7E); }
1181
+ .body .kami-chart .kc-donut-legend-swatch.kc-slice-4 { fill: var(--ink-muted, #B5AB9B); }
1182
+ .body .kami-chart .kc-donut-legend-swatch.kc-slice-5 { fill: var(--paper-deep, #E2D9C4); }
1183
+ .body .kami-chart .kc-donut-legend-swatch.kc-slice-6 {
1184
+ fill: var(--paper-soft, #ECE6DA);
1185
+ stroke: var(--rule, rgba(0,0,0,0.14)); stroke-width: 0.8;
1186
+ }
1187
+ .body .kami-chart .kc-donut-legend-value {
1188
+ fill: var(--ink, #2A2724);
1189
+ font: 9.5px var(--sans);
1190
+ }
1191
+ .body .kami-chart .kc-donut-legend-value.kc-primary { font-weight: 600; }
1192
+ .body .kami-chart .kc-donut-legend-label {
1193
+ fill: var(--ink-mid, #6A655D);
1194
+ font: 9px var(--sans);
1195
+ }
1196
+
1197
+ /* ─── Kami-chart · gantt (replaces inline mermaid gantt) ──────────
1198
+ Horizontal time axis with one bar per phase, optionally grouped
1199
+ into named sections. Time scale auto-fits the longest end value.
1200
+ Focal phase renders in accent. */
1201
+ .body .kami-chart .kc-g-section-bg { fill: var(--paper-deep, rgba(0,0,0,0.03)); }
1202
+ .body .kami-chart .kc-g-section-label {
1203
+ fill: var(--ink-soft, #978C7E);
1204
+ font: 9px var(--mono);
1205
+ letter-spacing: 0.16em;
1206
+ text-transform: uppercase;
1207
+ }
1208
+ .body .kami-chart .kc-g-row-rule { stroke: var(--rule-soft, rgba(0,0,0,0.08)); stroke-width: 0.8; }
1209
+ .body .kami-chart .kc-g-task-label {
1210
+ fill: var(--ink, #2A2724);
1211
+ font: 10px var(--sans);
1212
+ }
1213
+ .body .kami-chart .kc-g-task-label.kc-primary { font-weight: 600; }
1214
+ .body .kami-chart .kc-g-bar { fill: var(--ink-mid, #6A655D); }
1215
+ .body .kami-chart .kc-g-bar.kc-primary { fill: var(--accent, #6B6660); }
1216
+ .body .kami-chart .kc-g-bar-label {
1217
+ fill: var(--paper-soft, #ECE6DA);
1218
+ font: 9px var(--sans);
1219
+ font-weight: 600;
1220
+ }
1221
+ .body .kami-chart .kc-g-bar-label.kc-outside {
1222
+ fill: var(--ink-mid, #6A655D);
1223
+ }
1224
+ .body .kami-chart .kc-g-time-tick {
1225
+ fill: var(--ink-soft, #978C7E);
1226
+ font: 9px var(--mono);
1227
+ letter-spacing: 0.12em;
1228
+ }
1229
+ .body .kami-chart .kc-g-time-axis { stroke: var(--ink, #2A2724); stroke-width: 0.8; }
1230
+
1231
+ /* ─── Kami-chart · tree (replaces inline mermaid mindmap) ─────────
1232
+ 2-deep hierarchy · root + N branches + M leaves per branch.
1233
+ Connectors are right-angle paths with chevron arrows at child tops. */
1234
+ .body .kami-chart .kc-tree-node-bg { fill: var(--paper, #F4EFE6); }
1235
+ .body .kami-chart .kc-tree-root {
1236
+ fill: var(--paper-soft, #ECE6DA);
1237
+ stroke: var(--ink, #2A2724);
1238
+ stroke-width: 1;
1239
+ }
1240
+ .body .kami-chart .kc-tree-branch {
1241
+ fill: var(--paper-deep, rgba(0,0,0,0.03));
1242
+ stroke: var(--ink-mid, #6A655D);
1243
+ stroke-width: 1;
1244
+ }
1245
+ .body .kami-chart .kc-tree-branch-focal {
1246
+ fill: var(--paper-deep, rgba(0,0,0,0.03));
1247
+ stroke: var(--accent, #6B6660);
1248
+ stroke-width: 1;
1249
+ }
1250
+ .body .kami-chart .kc-tree-leaf {
1251
+ fill: var(--paper-deep, rgba(0,0,0,0.03));
1252
+ stroke: var(--ink-mid, #6A655D);
1253
+ stroke-width: 1;
1254
+ }
1255
+ .body .kami-chart .kc-tree-leaf-focal {
1256
+ fill: var(--paper-deep, rgba(0,0,0,0.03));
1257
+ stroke: var(--accent, #6B6660);
1258
+ stroke-width: 0.8;
1259
+ }
1260
+ .body .kami-chart .kc-tree-kicker {
1261
+ fill: var(--ink-soft, #978C7E);
1262
+ font: 8px var(--mono);
1263
+ letter-spacing: 0.15em;
1264
+ text-transform: uppercase;
1265
+ }
1266
+ .body .kami-chart .kc-tree-kicker-focal { fill: var(--accent, #6B6660); }
1267
+ .body .kami-chart .kc-tree-text {
1268
+ fill: var(--ink, #2A2724);
1269
+ font: 11px var(--sans);
1270
+ font-weight: 500;
1271
+ }
1272
+ .body .kami-chart .kc-tree-connector { stroke: var(--ink-mid, #6A655D); stroke-width: 1.2; fill: none; }
1273
+ .body .kami-chart .kc-tree-connector-focal { stroke: var(--accent, #6B6660); stroke-width: 1.4; fill: none; }
1274
+ .body .kami-chart .kc-tree-chevron { stroke: var(--ink-mid, #6A655D); stroke-width: 1.5; fill: none; stroke-linecap: round; }
1275
+ .body .kami-chart .kc-tree-chevron-focal { stroke: var(--accent, #6B6660); stroke-width: 1.5; fill: none; stroke-linecap: round; }
1276
+
1277
+ /* ─── Kami-chart · flowchart (replaces inline mermaid flowchart) ──
1278
+ Two layouts:
1279
+ · linear-v · vertical chain of 2–5 nodes
1280
+ · y-decision · root (decision diamond) → 2 branches → optional join
1281
+ Node kinds: start | step | decision | outcome | end. */
1282
+ .body .kami-chart .kc-fl-node {
1283
+ fill: var(--paper, #F4EFE6);
1284
+ stroke: var(--ink, #2A2724);
1285
+ stroke-width: 1;
1286
+ }
1287
+ .body .kami-chart .kc-fl-node-focal {
1288
+ fill: var(--paper-deep, rgba(0,0,0,0.03));
1289
+ stroke: var(--accent, #6B6660);
1290
+ stroke-width: 1.2;
1291
+ }
1292
+ .body .kami-chart .kc-fl-node-pill {
1293
+ fill: var(--paper-soft, #ECE6DA);
1294
+ stroke: var(--ink-mid, #6A655D);
1295
+ stroke-width: 1;
1296
+ }
1297
+ .body .kami-chart .kc-fl-node-faint {
1298
+ fill: var(--paper-deep, rgba(0,0,0,0.03));
1299
+ stroke: var(--ink-muted, #B5AB9B);
1300
+ stroke-width: 1;
1301
+ }
1302
+ .body .kami-chart .kc-fl-kicker {
1303
+ fill: var(--ink-soft, #978C7E);
1304
+ font: 7px var(--mono);
1305
+ letter-spacing: 0.15em;
1306
+ text-transform: uppercase;
1307
+ }
1308
+ .body .kami-chart .kc-fl-kicker-focal { fill: var(--accent, #6B6660); }
1309
+ .body .kami-chart .kc-fl-text {
1310
+ fill: var(--ink, #2A2724);
1311
+ font: 12px var(--sans);
1312
+ font-weight: 500;
1313
+ }
1314
+ .body .kami-chart .kc-fl-text-faint { fill: var(--ink-mid, #6A655D); }
1315
+ .body .kami-chart .kc-fl-arrow { stroke: var(--ink-mid, #6A655D); stroke-width: 1.2; fill: none; }
1316
+ .body .kami-chart .kc-fl-arrow-focal { stroke: var(--accent, #6B6660); stroke-width: 1.4; fill: none; }
1317
+ .body .kami-chart .kc-fl-arrowhead { stroke: var(--ink-mid, #6A655D); stroke-width: 1.4; fill: none; stroke-linecap: round; }
1318
+ .body .kami-chart .kc-fl-arrowhead-focal { stroke: var(--accent, #6B6660); stroke-width: 1.5; fill: none; stroke-linecap: round; }
1319
+ .body .kami-chart .kc-fl-edge-label-bg {
1320
+ fill: var(--paper-soft, #ECE6DA);
1321
+ }
1322
+ .body .kami-chart .kc-fl-edge-label {
1323
+ fill: var(--ink-mid, #6A655D);
1324
+ font: 8px var(--mono);
1325
+ letter-spacing: 0.14em;
1326
+ text-transform: uppercase;
1327
+ }
1328
+ .body .kami-chart .kc-fl-edge-label.kc-primary {
1329
+ fill: var(--accent, #6B6660);
1330
+ font-weight: 600;
1331
+ }
1332
+
1333
+ /* ─── Kami-chart · swimlane (replaces sequence + journey) ────────
1334
+ N lanes stacked vertically · steps positioned per lane in left-
1335
+ to-right order · elbow connectors between consecutive steps with
1336
+ chevron arrowheads. Focal lane gets a tinted background; focal
1337
+ step gets accent border. Reuses kc-fl-* classes for nodes. */
1338
+ .body .kami-chart .kc-sw-lane-bg { fill: var(--paper-deep, rgba(0,0,0,0.03)); }
1339
+ .body .kami-chart .kc-sw-lane-bg-alt { fill: var(--paper-soft, #ECE6DA); }
1340
+ .body .kami-chart .kc-sw-lane-bg-focal { fill: var(--paper-deep, rgba(0,0,0,0.05)); }
1341
+ .body .kami-chart .kc-sw-lane-divider { stroke: var(--rule-soft, rgba(0,0,0,0.08)); stroke-width: 0.8; }
1342
+ .body .kami-chart .kc-sw-lane-divider-focal {
1343
+ stroke: var(--accent, #6B6660);
1344
+ stroke-width: 0.8;
1345
+ stroke-dasharray: 4 3;
1346
+ }
1347
+ .body .kami-chart .kc-sw-lane-label {
1348
+ fill: var(--ink-soft, #978C7E);
1349
+ font: 9px var(--mono);
1350
+ letter-spacing: 0.15em;
1351
+ text-transform: uppercase;
1352
+ }
1353
+ .body .kami-chart .kc-sw-lane-label-focal { fill: var(--accent, #6B6660); }
1354
+ .body .kami-chart .kc-sw-label-col {
1355
+ fill: var(--paper, #F4EFE6);
1356
+ stroke: var(--rule, rgba(0,0,0,0.14));
1357
+ stroke-width: 0.8;
1358
+ }
1359
+ .body .kami-chart .kc-sw-arrow { stroke: var(--ink-mid, #6A655D); stroke-width: 1.2; fill: none; }
1360
+ .body .kami-chart .kc-sw-arrow-focal { stroke: var(--accent, #6B6660); stroke-width: 1.4; fill: none; }
1361
+ .body .kami-chart .kc-sw-arrowhead { stroke: var(--ink-mid, #6A655D); stroke-width: 1.5; fill: none; stroke-linecap: round; }
1362
+ .body .kami-chart .kc-sw-arrowhead-focal { stroke: var(--accent, #6B6660); stroke-width: 1.5; fill: none; stroke-linecap: round; }
1363
+ .body .kami-chart .kc-sw-score-dot {
1364
+ fill: var(--accent, #6B6660);
1365
+ }
1366
+ .body .kami-chart .kc-sw-score-dot-faint {
1367
+ fill: var(--ink-muted, #B5AB9B);
1368
+ }
1369
+
1370
+ /* ─── Kami-chart · state-machine ──────────────────────────────────
1371
+ Linear arrangement of state nodes · forward arrows (with optional
1372
+ event labels) · optional back-transitions drawn as dashed arcs
1373
+ above. Start dot + end double-circle markers are optional. */
1374
+ .body .kami-chart .kc-sm-state {
1375
+ fill: var(--paper, #F4EFE6);
1376
+ stroke: var(--ink-mid, #6A655D);
1377
+ stroke-width: 1;
1378
+ }
1379
+ .body .kami-chart .kc-sm-state-focal {
1380
+ fill: var(--paper-deep, rgba(0,0,0,0.03));
1381
+ stroke: var(--accent, #6B6660);
1382
+ stroke-width: 1.2;
1383
+ }
1384
+ .body .kami-chart .kc-sm-state-terminal {
1385
+ fill: var(--paper-soft, #ECE6DA);
1386
+ stroke: var(--ink, #2A2724);
1387
+ stroke-width: 1;
1388
+ }
1389
+ .body .kami-chart .kc-sm-kicker {
1390
+ fill: var(--ink-soft, #978C7E);
1391
+ font: 7px var(--mono);
1392
+ letter-spacing: 0.15em;
1393
+ text-transform: uppercase;
1394
+ }
1395
+ .body .kami-chart .kc-sm-kicker-focal { fill: var(--accent, #6B6660); }
1396
+ .body .kami-chart .kc-sm-state-label {
1397
+ fill: var(--ink, #2A2724);
1398
+ font: 12px var(--sans);
1399
+ font-weight: 500;
1400
+ }
1401
+ .body .kami-chart .kc-sm-state-hint {
1402
+ fill: var(--ink-mid, #6A655D);
1403
+ font: 9px var(--mono);
1404
+ }
1405
+ .body .kami-chart .kc-sm-forward { stroke: var(--ink-mid, #6A655D); stroke-width: 1.2; fill: none; }
1406
+ .body .kami-chart .kc-sm-forward-focal { stroke: var(--accent, #6B6660); stroke-width: 1.4; fill: none; }
1407
+ .body .kami-chart .kc-sm-back {
1408
+ stroke: var(--ink-mid, #6A655D);
1409
+ stroke-width: 1.2;
1410
+ fill: none;
1411
+ stroke-dasharray: 4 3;
1412
+ }
1413
+ .body .kami-chart .kc-sm-arrowhead { stroke: var(--ink-mid, #6A655D); stroke-width: 1.5; fill: none; stroke-linecap: round; }
1414
+ .body .kami-chart .kc-sm-arrowhead-focal { stroke: var(--accent, #6B6660); stroke-width: 1.5; fill: none; stroke-linecap: round; }
1415
+ .body .kami-chart .kc-sm-transition-label-bg { fill: var(--paper-soft, #ECE6DA); }
1416
+ .body .kami-chart .kc-sm-transition-label {
1417
+ fill: var(--ink-mid, #6A655D);
1418
+ font: 8px var(--mono);
1419
+ letter-spacing: 0.10em;
1420
+ text-transform: uppercase;
1421
+ }
1422
+ .body .kami-chart .kc-sm-transition-label.kc-primary {
1423
+ fill: var(--accent, #6B6660);
1424
+ font-weight: 600;
1425
+ }
1426
+ .body .kami-chart .kc-sm-marker-start { fill: var(--ink, #2A2724); }
1427
+ .body .kami-chart .kc-sm-marker-end-outer { fill: none; stroke: var(--ink, #2A2724); stroke-width: 1.4; }
1428
+ .body .kami-chart .kc-sm-marker-end-inner { fill: var(--ink, #2A2724); }
1429
+
1134
1430
  /* ─── Views-compared · spine-agnostic baseline ──────────────────
1135
1431
  The room's social map · always rendered when ≥ 2 directors.
1136
1432
  Layout: intro → 2-column compare grid (alignment | divergence)
@@ -1768,39 +2064,105 @@
1768
2064
  `body[data-spine="…"]` for higher specificity. */
1769
2065
 
1770
2066
  :root {
1771
- /* Type scale · 1 source of truth across all spines.
1772
- All sizes in px; line-heights unitless; tracking in em. */
1773
- --rep-display: 44px; /* cover title */
1774
- --rep-h2: 24px; /* section headings */
1775
- --rep-h3: 18px; /* sub-section + recommendation action */
1776
- --rep-h4: 15px; /* small headings */
1777
- --rep-body: 16px; /* primary prose */
1778
- --rep-rationale: 16px; /* secondary prose (rationale, item body) */
1779
- --rep-meta: 13px; /* meta strips, metadata rows */
1780
- --rep-label: 10px; /* mono uppercase labels */
1781
- --rep-caption: 11px; /* footer captions */
1782
- --rep-pullquote: 19px; /* italic pull-quotes inside cards */
1783
-
1784
- --rep-leading-display: 1.18;
1785
- --rep-leading-heading: 1.4;
1786
- --rep-leading-body: 1.7;
1787
- --rep-leading-tight: 1.55;
1788
-
1789
- --rep-tracking-display: -0.012em;
1790
- --rep-tracking-body: -0.005em;
1791
- --rep-tracking-mono: 0.04em;
1792
- --rep-tracking-label: 0.2em;
1793
-
1794
- /* Spacing scale */
1795
- --rep-section-gap: 56px; /* before each H2 */
1796
- --rep-item-gap: 44px; /* between rich list items */
1797
- --rep-para-gap: 14px; /* between paragraphs */
1798
- --rep-row-gap: 10px; /* tight rows inside a card */
2067
+ /* ─── Type scale · 1 source of truth across all spines ─────────
2068
+ All sizes in px; line-heights unitless; tracking in em.
2069
+ Adapted from kami's pt-based ladder (px ≈ pt × 1.33). Spines
2070
+ MAY scale globally (e.g. dense vs lush) but the relative
2071
+ proportions and the line-height/tracking pairings stay locked. */
2072
+ --rep-display: 44px; /* cover title · pt-equiv 33 */
2073
+ --rep-h1: 30px; /* chapter / part divider · pt-equiv 22 */
2074
+ --rep-h2: 24px; /* section headings · pt-equiv 18 */
2075
+ --rep-h3: 18px; /* sub-section + recommendation action · pt-equiv 13 */
2076
+ --rep-h4: 15px; /* small headings · pt-equiv 11 */
2077
+ --rep-lede: 17px; /* intro paragraphs (first ¶ after H1/H2) · pt-equiv 13 */
2078
+ --rep-body: 16px; /* reading prose · pt-equiv 12 */
2079
+ --rep-body-dense: 14px; /* compact body (sidebars, dense lists) · pt-equiv 10.5 */
2080
+ --rep-rationale: 16px; /* secondary prose · matches body */
2081
+ --rep-meta: 13px; /* meta strips, metadata rows · pt-equiv 10 */
2082
+ --rep-pullquote: 19px; /* italic pull-quotes inside cards · pt-equiv 14 */
2083
+ --rep-caption: 11px; /* footer captions · pt-equiv 8 */
2084
+ --rep-label: 10px; /* mono uppercase labels (kickers) · pt-equiv 7.5 */
2085
+ --rep-tiny: 9px; /* footer meta / colophon / minor numerals · pt-equiv 7 */
2086
+
2087
+ /* ─── Line-height · 5 tiers, indexed by content type ───────────
2088
+ Kami's invariant: print is tighter than web body. ≥ 1.6 floats;
2089
+ ≤ 1.10 collides. Body prose 1.55-1.70; dense rhythm 1.42-1.45;
2090
+ headlines 1.10-1.30. */
2091
+ --rep-leading-display: 1.18; /* display, H1, H2 */
2092
+ --rep-leading-heading: 1.40; /* H3, H4, lede */
2093
+ --rep-leading-body: 1.70; /* reading prose (loose, English-leaning) */
2094
+ --rep-leading-tight: 1.55; /* secondary prose, captions */
2095
+ --rep-leading-dense: 1.42; /* compact lists, sidebars · kami's "dense body" tier */
2096
+
2097
+ /* ─── Letter-spacing (tracking) · per-role, with CJK comp ──────
2098
+ Latin body sits at 0. CJK body (especially TsangerJinKai02 /
2099
+ PingFang) wants 0.03-0.06em to open up density. Display CJK
2100
+ takes a smaller bump (the glyphs are already large). Mono
2101
+ labels get 0.18-0.20em uppercase tracking. */
2102
+ --rep-tracking-display: -0.012em;
2103
+ --rep-tracking-body: -0.005em;
2104
+ --rep-tracking-cjk-body: 0.04em; /* applied via body.is-cjk */
2105
+ --rep-tracking-cjk-display: 0.02em; /* applied via body.is-cjk to display */
2106
+ --rep-tracking-mono: 0.04em;
2107
+ --rep-tracking-label: 0.20em; /* uppercase kickers / overlines */
2108
+
2109
+ /* ─── Spacing scale · 7-tier, base 4px ─────────────────────────
2110
+ Mirrors kami's xs/sm/md/lg/xl/2xl/3xl. Semantic aliases
2111
+ (--rep-section-gap etc.) below resolve to specific tiers — use
2112
+ the aliases in component CSS, never the bare tier number, so
2113
+ the rhythm stays semantic not arithmetic. */
2114
+ --rep-space-xs: 4px; /* inline adjacent · row internal padding */
2115
+ --rep-space-sm: 8px; /* tag padding · dense layout · row gap */
2116
+ --rep-space-md: 12px; /* component interior · paragraph gap */
2117
+ --rep-space-lg: 20px; /* between components · card padding */
2118
+ --rep-space-xl: 32px; /* section-title margins · between rich items */
2119
+ --rep-space-2xl: 56px; /* between major sections (H2 zones) */
2120
+ --rep-space-3xl: 96px; /* between chapters in long-doc / part covers */
2121
+
2122
+ /* Semantic aliases · use these in component CSS so the rhythm
2123
+ stays intention-named. Add new aliases when a new semantic
2124
+ use emerges; never invent new numeric values. */
2125
+ --rep-section-gap: var(--rep-space-2xl); /* before each H2 */
2126
+ --rep-item-gap: var(--rep-space-xl); /* between rich list items */
2127
+ --rep-para-gap: var(--rep-space-md); /* between paragraphs · 12px */
2128
+ --rep-row-gap: var(--rep-space-sm); /* tight rows inside a card */
2129
+
2130
+ /* ─── Radius scale · capped at 10px ────────────────────────────
2131
+ Per project rule (CLAUDE.md memory · feedback_no_emoji_in_designs
2132
+ adjacency): max 10px. Beyond, surfaces start to feel like App
2133
+ Store chrome. Reserve 16px exclusively for hero / cover cards. */
2134
+ --rep-radius-tight: 2px; /* swatches, mini-tags, inline pills */
2135
+ --rep-radius-card: 4px; /* tag pads, code blocks */
2136
+ --rep-radius-container: 6px; /* card edges, callout aside */
2137
+ --rep-radius-feature: 10px; /* feature cards, lockup tiles */
2138
+ --rep-radius-hero: 16px; /* RESERVED · cover hero only */
2139
+
2140
+ /* ─── Depth · three methods, no hard drop shadows ──────────────
2141
+ Kami: ring shadow / whisper shadow / light-dark surface
2142
+ alternation. Never `0 6px 12px rgba(0,0,0,0.4)` style drops —
2143
+ reads as 2010s material-design and clashes with the editorial
2144
+ register. */
2145
+ --rep-shadow-ring: 0 0 0 1px var(--rule, rgba(0,0,0,0.14));
2146
+ --rep-shadow-whisper: 0 4px 24px rgba(0,0,0,0.05);
1799
2147
 
1800
2148
  /* Reading column */
1801
2149
  --rep-prose-width: 64ch;
1802
2150
  }
1803
2151
 
2152
+ /* ─── CJK body letter-spacing application ──────────────────────
2153
+ The CJK tracking tokens above are inert until we apply them.
2154
+ `body.is-cjk` is set by report.js when the brief's locale is
2155
+ zh/ja, so non-CJK reports remain untouched. */
2156
+ body.is-cjk .body p,
2157
+ body.is-cjk .body li,
2158
+ body.is-cjk .body blockquote,
2159
+ body.is-cjk .body td,
2160
+ body.is-cjk .body th { letter-spacing: var(--rep-tracking-cjk-body); }
2161
+ body.is-cjk .body h1,
2162
+ body.is-cjk .body h2,
2163
+ body.is-cjk .body h3,
2164
+ body.is-cjk .body .cover-title { letter-spacing: var(--rep-tracking-cjk-display); }
2165
+
1804
2166
  /* ── Section break + heading rhythm · cross-spine ────────────────
1805
2167
  Each numbered section is preceded by `<div class="chapter-num">
1806
2168
  Section NN</div>` (injected client-side). Together they form the
@@ -1994,7 +2356,7 @@
1994
2356
  }
1995
2357
  .rec-num {
1996
2358
  font-family: var(--mono);
1997
- font-size: 10.5px;
2359
+ font-size: 10px;
1998
2360
  font-weight: 400;
1999
2361
  color: var(--ink-faint, var(--text-faint));
2000
2362
  letter-spacing: 0.2em;
@@ -3020,73 +3382,6 @@
3020
3382
  .replace(/`([^`]+)`/g, "<code>$1</code>");
3021
3383
  }
3022
3384
 
3023
- /** Mermaid 10.9.5 quadrantChart lexer is strict: anything beyond ASCII
3024
- * alphanumerics + spaces breaks UNQUOTED axis/quadrant/item labels
3025
- * (CJK, parens, `+`, etc. all fail with "Unrecognized text"). The
3026
- * fix is to ALWAYS wrap those labels in double quotes; titles are
3027
- * exempt. Mirrors src/utils/mermaid-sanitize.ts — keep in sync. */
3028
- function sanitizeMermaid(src) {
3029
- if (!src) return src;
3030
- if (!/^\s*quadrantChart\b/i.test(src)) return src;
3031
-
3032
- const clamp01 = (n) => {
3033
- if (!Number.isFinite(n)) return 0.5;
3034
- return Math.max(0.02, Math.min(0.98, n));
3035
- };
3036
- const cleanLabel = (s) => s
3037
- .replace(/(/g, "(")
3038
- .replace(/)/g, ")")
3039
- .replace(/,/g, " ")
3040
- .replace(/:/g, " ")
3041
- .replace(/、/g, " ")
3042
- .replace(/。/g, " ")
3043
- .replace(/;/g, " ")
3044
- .replace(/["'`\[\]:]+/g, " ")
3045
- .replace(/\s+/g, " ")
3046
- .trim();
3047
-
3048
- return src.split("\n").map((line) => {
3049
- const indentMatch = /^(\s*)/.exec(line);
3050
- const indent = indentMatch ? indentMatch[1] || " " : " ";
3051
- const t = line.trim();
3052
- if (!t) return line;
3053
-
3054
- // Title · cleaned but unquoted.
3055
- const titleM = /^title\s+(.+)$/i.exec(t);
3056
- if (titleM) return `${indent}title ${cleanLabel(titleM[1])}`;
3057
-
3058
- // Axis lines · always quoted both-ends.
3059
- const ax = /^(x-axis|y-axis)\s+(.+)$/i.exec(t);
3060
- if (ax) {
3061
- const which = ax[1].toLowerCase();
3062
- const rest = ax[2].trim();
3063
- if (rest.includes("-->")) {
3064
- const parts = rest.split("-->").map((s) => cleanLabel(s));
3065
- if (parts.length === 2 && parts[0] && parts[1]) {
3066
- return `${indent}${which} "${parts[0]}" --> "${parts[1]}"`;
3067
- }
3068
- }
3069
- const cleaned = cleanLabel(rest);
3070
- return `${indent}${which} "Low ${cleaned}" --> "High ${cleaned}"`;
3071
- }
3072
-
3073
- // Quadrant labels · always quoted.
3074
- const qM = /^(quadrant-[1-4])\s+(.+)$/i.exec(t);
3075
- if (qM) return `${indent}${qM[1]} "${cleanLabel(qM[2])}"`;
3076
-
3077
- // Item lines · always quoted, coords clamped.
3078
- const item = /^"?([^"\[\]]+?)"?\s*:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]\s*$/.exec(t);
3079
- if (item) {
3080
- const label = cleanLabel(item[1]);
3081
- const x = clamp01(parseFloat(item[2]));
3082
- const y = clamp01(parseFloat(item[3]));
3083
- return `${indent}"${label}": [${x.toFixed(2)}, ${y.toFixed(2)}]`;
3084
- }
3085
-
3086
- return line;
3087
- }).join("\n");
3088
- }
3089
-
3090
3385
  /** Parse a fenced ```metric-strip block body (strict JSON) and emit
3091
3386
  * the dashboard card grid. Mirrors how ```mermaid is handled — the
3092
3387
  * block is pulled out at the placeholder layer before block parsing,
@@ -3225,6 +3520,1415 @@
3225
3520
  `</div>`;
3226
3521
  }
3227
3522
 
3523
+ /** Parse a fenced ```kami-chart block (strict JSON) and emit inline
3524
+ * SVG. Replaces the mermaid pipeline for typed visuals. Dispatch by
3525
+ * `type` field; each generator owns its own SVG coordinate math and
3526
+ * uses spine CSS variables for colour so per-spine palettes apply
3527
+ * automatically. Bad JSON / unknown type degrades to a quiet error
3528
+ * block · never throws. */
3529
+ function renderKamiChart(body) {
3530
+ let parsed;
3531
+ try { parsed = JSON.parse(body); } catch (e) {
3532
+ return `<div class="kami-chart-error" data-err="parse">${escape(body)}</div>`;
3533
+ }
3534
+ if (!parsed || typeof parsed !== "object") {
3535
+ return `<div class="kami-chart-error" data-err="shape"></div>`;
3536
+ }
3537
+ const type = typeof parsed.type === "string" ? parsed.type.trim() : "";
3538
+ switch (type) {
3539
+ case "bar":
3540
+ case "bar-chart":
3541
+ return renderKamiBar(parsed);
3542
+ case "quadrant":
3543
+ case "quadrant-chart":
3544
+ return renderKamiQuadrant(parsed);
3545
+ case "timeline":
3546
+ return renderKamiTimeline(parsed);
3547
+ case "donut":
3548
+ case "donut-chart":
3549
+ case "pie":
3550
+ case "pie-chart":
3551
+ return renderKamiDonut(parsed);
3552
+ case "gantt":
3553
+ return renderKamiGantt(parsed);
3554
+ case "tree":
3555
+ case "mindmap":
3556
+ return renderKamiTree(parsed);
3557
+ case "flowchart":
3558
+ case "flow":
3559
+ return renderKamiFlowchart(parsed);
3560
+ case "swimlane":
3561
+ case "sequence":
3562
+ case "journey":
3563
+ return renderKamiSwimlane(parsed);
3564
+ case "state":
3565
+ case "state-machine":
3566
+ case "statediagram":
3567
+ case "statediagram-v2":
3568
+ return renderKamiStateMachine(parsed);
3569
+ default:
3570
+ return `<div class="kami-chart-error" data-err="type">unsupported chart type: ${escape(type || "<empty>")}</div>`;
3571
+ }
3572
+ }
3573
+
3574
+ /** Pick the next "nice" round number ≥ v · 1, 1.5, 2, 2.5, 3, 4, 5,
3575
+ * 7.5, 10 × 10^N. Used to round the bar-chart Y-axis maximum so
3576
+ * gridlines hit even values regardless of the underlying data. */
3577
+ function kcNiceMax(v) {
3578
+ if (!isFinite(v) || v <= 0) return 10;
3579
+ const exp = Math.floor(Math.log10(v));
3580
+ const pow = Math.pow(10, exp);
3581
+ const norm = v / pow;
3582
+ let nice;
3583
+ if (norm <= 1) nice = 1;
3584
+ else if (norm <= 1.5) nice = 1.5;
3585
+ else if (norm <= 2) nice = 2;
3586
+ else if (norm <= 2.5) nice = 2.5;
3587
+ else if (norm <= 3) nice = 3;
3588
+ else if (norm <= 4) nice = 4;
3589
+ else if (norm <= 5) nice = 5;
3590
+ else if (norm <= 7.5) nice = 7.5;
3591
+ else nice = 10;
3592
+ return nice * pow;
3593
+ }
3594
+
3595
+ /** Format a Y-axis tick value · drop trailing zeros, keep ≤ 2
3596
+ * decimals so labels read cleanly at 10px. */
3597
+ function kcFmt(v) {
3598
+ if (!isFinite(v)) return "";
3599
+ if (Number.isInteger(v)) return String(v);
3600
+ return Number(v.toFixed(2)).toString();
3601
+ }
3602
+
3603
+ /** Bar chart · 2–8 categories, single series. Optional `focal` label
3604
+ * picks one bar to render with the spine accent (others go neutral).
3605
+ * Without `focal`, every bar is primary. */
3606
+ function renderKamiBar(data) {
3607
+ const title = typeof data.title === "string" ? data.title.trim() : "";
3608
+ const yLabel = typeof data.yLabel === "string" ? data.yLabel.trim() : "";
3609
+ const unit = typeof data.unit === "string" ? data.unit.trim() : "";
3610
+ const focal = typeof data.focal === "string" ? data.focal.trim() : "";
3611
+ const caption = typeof data.caption === "string" ? data.caption.trim() : "";
3612
+ const figNum = typeof data.figNum === "string" || typeof data.figNum === "number"
3613
+ ? String(data.figNum) : "";
3614
+ const barsRaw = Array.isArray(data.bars) ? data.bars : [];
3615
+ const bars = [];
3616
+ for (const b of barsRaw) {
3617
+ if (!b || typeof b !== "object") continue;
3618
+ const label = typeof b.label === "string" ? b.label.trim() : "";
3619
+ const value = Number(b.value);
3620
+ if (!label || !isFinite(value)) continue;
3621
+ bars.push({ label, value });
3622
+ if (bars.length >= 8) break;
3623
+ }
3624
+ if (bars.length < 2) {
3625
+ return `<div class="kami-chart-error" data-err="too-few-bars"></div>`;
3626
+ }
3627
+
3628
+ // Geometry · viewBox 680 × 420, chart area x∈[60,620], y∈[40,320].
3629
+ const X0 = 60, X1 = 620, Y0 = 40, Y1 = 320;
3630
+ const W = X1 - X0; // 560
3631
+ const H = Y1 - Y0; // 280
3632
+ const N = bars.length;
3633
+ const groupW = W / N;
3634
+ const barW = Math.min(48, Math.round(groupW * 0.55));
3635
+ const maxV = Math.max(...bars.map((b) => b.value), 0);
3636
+ const yMax = kcNiceMax(maxV);
3637
+
3638
+ // Five gridlines at 0 / 25% / 50% / 75% / 100% of yMax.
3639
+ const gridlines = [0, 0.25, 0.5, 0.75, 1].map((p) => ({
3640
+ v: yMax * p,
3641
+ y: Y1 - (Y1 - Y0) * p,
3642
+ }));
3643
+
3644
+ // Per-bar layout
3645
+ const barEls = [];
3646
+ const labelEls = [];
3647
+ const dataLabelEls = [];
3648
+ bars.forEach((b, i) => {
3649
+ const cx = X0 + groupW * i + groupW / 2;
3650
+ const bx = Math.round(cx - barW / 2);
3651
+ const bh = Math.round((b.value / yMax) * H);
3652
+ const by = Y1 - bh;
3653
+ const isFocal = focal ? (b.label === focal) : true;
3654
+ const seriesCls = isFocal ? "kc-series-primary" : "kc-series-secondary";
3655
+ barEls.push(
3656
+ `<rect class="kc-bar ${seriesCls}" x="${bx}" y="${by}" width="${barW}" height="${bh}" rx="2"/>`,
3657
+ );
3658
+ // Number above bar
3659
+ const labelTxt = unit
3660
+ ? `${kcFmt(b.value)}${unit}`
3661
+ : kcFmt(b.value);
3662
+ dataLabelEls.push(
3663
+ `<text class="kc-data-label${isFocal ? " kc-primary" : ""}" x="${Math.round(cx)}" y="${by - 4}" text-anchor="middle">${escape(labelTxt)}</text>`,
3664
+ );
3665
+ // Category label · truncate to 18 chars to keep packing safe
3666
+ const catLabel = b.label.length > 18 ? b.label.slice(0, 17) + "…" : b.label;
3667
+ labelEls.push(
3668
+ `<text class="kc-category-label" x="${Math.round(cx)}" y="${Y1 + 20}" text-anchor="middle">${escape(catLabel)}</text>`,
3669
+ );
3670
+ });
3671
+
3672
+ // Gridlines + Y-axis ticks
3673
+ const gridEls = gridlines.map((g) =>
3674
+ `<line class="kc-grid" x1="${X0}" y1="${g.y}" x2="${X1}" y2="${g.y}"/>`,
3675
+ ).join("");
3676
+ const tickEls = gridlines.map((g) =>
3677
+ `<text class="kc-y-tick" x="${X0 - 8}" y="${g.y + 4}" text-anchor="end">${escape(kcFmt(g.v))}</text>`,
3678
+ ).join("");
3679
+
3680
+ // Y-axis unit (rotated)
3681
+ const yUnitTxt = yLabel || unit;
3682
+ const yUnitEl = yUnitTxt
3683
+ ? `<text class="kc-y-unit" x="16" y="${(Y0 + Y1) / 2}" transform="rotate(-90 16 ${(Y0 + Y1) / 2})" text-anchor="middle">${escape(yUnitTxt.toUpperCase())}</text>`
3684
+ : "";
3685
+
3686
+ // Figure header
3687
+ const figHeader =
3688
+ `<div class="kc-figheader">` +
3689
+ `<span class="kc-fignum">Figure${figNum ? " " + escape(figNum) : ""}</span>` +
3690
+ (title ? `<span class="kc-figtitle">${escape(title)}</span>` : "") +
3691
+ `</div>`;
3692
+
3693
+ // Caption
3694
+ const captionEl = caption
3695
+ ? `<p class="kc-caption">${inline(escape(caption))}</p>`
3696
+ : "";
3697
+
3698
+ return (
3699
+ `<figure class="kami-chart">` +
3700
+ figHeader +
3701
+ `<svg class="kc-svg" viewBox="0 0 680 380" xmlns="http://www.w3.org/2000/svg" aria-label="${escape(title || "Bar chart")}">` +
3702
+ `<rect class="kc-canvas" width="100%" height="100%"/>` +
3703
+ gridEls +
3704
+ tickEls +
3705
+ yUnitEl +
3706
+ `<line class="kc-baseline" x1="${X0}" y1="${Y1}" x2="${X1}" y2="${Y1}"/>` +
3707
+ barEls.join("") +
3708
+ dataLabelEls.join("") +
3709
+ labelEls.join("") +
3710
+ `</svg>` +
3711
+ captionEl +
3712
+ `</figure>`
3713
+ );
3714
+ }
3715
+
3716
+ /** Quadrant chart · 2-axis plot, labelled items, optional `focal`.
3717
+ * Items use {label, x, y} where x and y are 0..1 (0 = low, 1 = high).
3718
+ * Top-right quadrant is the "preferred" zone (accent-tinted background
3719
+ * + accent quad-label). Focal item renders solid accent; everything
3720
+ * else cascades from outline-ink to faint-muted by `tier` (1..4) if
3721
+ * provided, otherwise everyone shares the standard outline. */
3722
+ function renderKamiQuadrant(data) {
3723
+ const title = typeof data.title === "string" ? data.title.trim() : "";
3724
+ const xLabel = typeof data.xLabel === "string" ? data.xLabel.trim() : "";
3725
+ const yLabel = typeof data.yLabel === "string" ? data.yLabel.trim() : "";
3726
+ const q1 = typeof data.q1 === "string" ? data.q1.trim() : "";
3727
+ const q2 = typeof data.q2 === "string" ? data.q2.trim() : "";
3728
+ const q3 = typeof data.q3 === "string" ? data.q3.trim() : "";
3729
+ const q4 = typeof data.q4 === "string" ? data.q4.trim() : "";
3730
+ const focal = typeof data.focal === "string" ? data.focal.trim() : "";
3731
+ const caption = typeof data.caption === "string" ? data.caption.trim() : "";
3732
+ const figNum = typeof data.figNum === "string" || typeof data.figNum === "number"
3733
+ ? String(data.figNum) : "";
3734
+ const itemsRaw = Array.isArray(data.items) ? data.items : [];
3735
+ const items = [];
3736
+ for (const it of itemsRaw) {
3737
+ if (!it || typeof it !== "object") continue;
3738
+ const label = typeof it.label === "string" ? it.label.trim() : "";
3739
+ const x = Number(it.x);
3740
+ const y = Number(it.y);
3741
+ if (!label || !isFinite(x) || !isFinite(y)) continue;
3742
+ const tier = Number.isInteger(it.tier) ? Math.max(1, Math.min(4, it.tier)) : 0;
3743
+ items.push({ label, x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)), tier });
3744
+ if (items.length >= 16) break;
3745
+ }
3746
+ if (items.length < 2) {
3747
+ return `<div class="kami-chart-error" data-err="too-few-items"></div>`;
3748
+ }
3749
+
3750
+ // Geometry · viewBox 820 × 540, plot area x∈[80, 740], y∈[60, 440].
3751
+ // y-up in data terms (y=0 bottom, y=1 top) → svg y flipped.
3752
+ const X0 = 80, X1 = 740, Y0 = 60, Y1 = 440;
3753
+ const CX = (X0 + X1) / 2, CY = (Y0 + Y1) / 2;
3754
+ const toSvgX = (x) => X0 + x * (X1 - X0);
3755
+ const toSvgY = (y) => Y1 - y * (Y1 - Y0);
3756
+
3757
+ // Quadrant text positions (offset from each quadrant centre)
3758
+ const quadEls = [
3759
+ // Q2 · top-left · high-y, low-x
3760
+ q2 ? `<text class="kc-q-quad-kicker" x="${X0 + 164}" y="${Y0 + 26}" text-anchor="middle">HIGH-Y · LOW-X</text>
3761
+ <text class="kc-q-quad-name" x="${X0 + 164}" y="${Y0 + 42}" text-anchor="middle">${escape(q2)}</text>` : "",
3762
+ // Q1 · top-right · high-y, high-x (PREFERRED)
3763
+ q1 ? `<text class="kc-q-quad-kicker kc-q-pref" x="${X1 - 164}" y="${Y0 + 26}" text-anchor="middle">HIGH-Y · HIGH-X</text>
3764
+ <text class="kc-q-quad-name kc-q-pref" x="${X1 - 164}" y="${Y0 + 42}" text-anchor="middle">${escape(q1)}</text>` : "",
3765
+ // Q3 · bottom-left · low-y, low-x
3766
+ q3 ? `<text class="kc-q-quad-kicker" x="${X0 + 164}" y="${Y1 - 28}" text-anchor="middle">LOW-Y · LOW-X</text>
3767
+ <text class="kc-q-quad-name" x="${X0 + 164}" y="${Y1 - 12}" text-anchor="middle">${escape(q3)}</text>` : "",
3768
+ // Q4 · bottom-right · low-y, high-x
3769
+ q4 ? `<text class="kc-q-quad-kicker" x="${X1 - 164}" y="${Y1 - 28}" text-anchor="middle">LOW-Y · HIGH-X</text>
3770
+ <text class="kc-q-quad-name" x="${X1 - 164}" y="${Y1 - 12}" text-anchor="middle">${escape(q4)}</text>` : "",
3771
+ ].join("");
3772
+
3773
+ // Items · render in reverse z-order so focal lands on top
3774
+ const pointEls = items.map((it) => {
3775
+ const cx = Math.round(toSvgX(it.x));
3776
+ const cy = Math.round(toSvgY(it.y));
3777
+ const isFocal = focal && it.label === focal;
3778
+ let pointCls = "kc-point";
3779
+ let labelCls = "kc-point-label";
3780
+ let r = 5;
3781
+ if (isFocal) {
3782
+ pointCls = "kc-point-focal";
3783
+ labelCls = "kc-point-label kc-primary";
3784
+ r = 6.5;
3785
+ } else if (it.tier === 2) {
3786
+ pointCls = "kc-point-tinted";
3787
+ } else if (it.tier === 3) {
3788
+ pointCls = "kc-point";
3789
+ } else if (it.tier === 4) {
3790
+ pointCls = "kc-point-faint";
3791
+ labelCls = "kc-point-label kc-faint";
3792
+ r = 4;
3793
+ }
3794
+ // Place label to the right of the point; if point is in the right
3795
+ // half (cx > CX), pull label to the left so it doesn't overflow.
3796
+ const labelOnLeft = cx > X1 - 80;
3797
+ const lx = labelOnLeft ? cx - 10 : cx + 10;
3798
+ const ly = cy + 4;
3799
+ const anchor = labelOnLeft ? "end" : "start";
3800
+ return (
3801
+ `<circle class="${pointCls}" cx="${cx}" cy="${cy}" r="${r}"/>` +
3802
+ `<text class="${labelCls}" x="${lx}" y="${ly}" text-anchor="${anchor}">${escape(it.label)}</text>`
3803
+ );
3804
+ }).join("");
3805
+
3806
+ const xLabelEl = xLabel
3807
+ ? `<text class="kc-q-axis-label" x="${CX}" y="${Y1 + 40}" text-anchor="middle">${escape(xLabel)}</text>`
3808
+ : "";
3809
+ const yLabelEl = yLabel
3810
+ ? `<text class="kc-q-axis-label" x="30" y="${CY}" text-anchor="middle" transform="rotate(-90 30 ${CY})">${escape(yLabel)}</text>`
3811
+ : "";
3812
+
3813
+ const figHeader =
3814
+ `<div class="kc-figheader">` +
3815
+ `<span class="kc-fignum">Figure${figNum ? " " + escape(figNum) : ""}</span>` +
3816
+ (title ? `<span class="kc-figtitle">${escape(title)}</span>` : "") +
3817
+ `</div>`;
3818
+
3819
+ const captionEl = caption
3820
+ ? `<p class="kc-caption">${inline(escape(caption))}</p>`
3821
+ : "";
3822
+
3823
+ return (
3824
+ `<figure class="kami-chart">` +
3825
+ figHeader +
3826
+ `<svg class="kc-svg" viewBox="0 0 820 500" xmlns="http://www.w3.org/2000/svg" aria-label="${escape(title || "Quadrant chart")}">` +
3827
+ `<rect class="kc-canvas" width="100%" height="100%"/>` +
3828
+ `<rect class="kc-q-frame" x="${X0}" y="${Y0}" width="${X1 - X0}" height="${Y1 - Y0}"/>` +
3829
+ `<rect class="kc-q-tint" x="${CX}" y="${Y0}" width="${X1 - CX}" height="${CY - Y0}"/>` +
3830
+ `<line class="kc-q-axis" x1="${X0}" y1="${CY}" x2="${X1}" y2="${CY}"/>` +
3831
+ `<line class="kc-q-axis" x1="${CX}" y1="${Y0}" x2="${CX}" y2="${Y1}"/>` +
3832
+ `<line class="kc-q-arrow" x1="${X0}" y1="${Y1 + 16}" x2="${X1}" y2="${Y1 + 16}"/>` +
3833
+ `<path class="kc-q-arrow" d="M${X1 - 6},${Y1 + 12} L${X1},${Y1 + 16} L${X1 - 6},${Y1 + 20}"/>` +
3834
+ `<line class="kc-q-arrow" x1="60" y1="${Y1}" x2="60" y2="${Y0}"/>` +
3835
+ `<path class="kc-q-arrow" d="M56,${Y0 + 6} L60,${Y0} L64,${Y0 + 6}"/>` +
3836
+ quadEls +
3837
+ pointEls +
3838
+ xLabelEl +
3839
+ yLabelEl +
3840
+ `</svg>` +
3841
+ captionEl +
3842
+ `</figure>`
3843
+ );
3844
+ }
3845
+
3846
+ /** Timeline · 3–8 dated events on a horizontal axis. Events alternate
3847
+ * above / below the axis. `focal` (period of the load-bearing event)
3848
+ * renders the dot, connector, and card in the spine accent. */
3849
+ function renderKamiTimeline(data) {
3850
+ const title = typeof data.title === "string" ? data.title.trim() : "";
3851
+ const focal = typeof data.focal === "string" ? data.focal.trim() : "";
3852
+ const caption = typeof data.caption === "string" ? data.caption.trim() : "";
3853
+ const figNum = typeof data.figNum === "string" || typeof data.figNum === "number"
3854
+ ? String(data.figNum) : "";
3855
+ const pointsRaw = Array.isArray(data.points) ? data.points : [];
3856
+ const points = [];
3857
+ for (const p of pointsRaw) {
3858
+ if (!p || typeof p !== "object") continue;
3859
+ const period = typeof p.period === "string" ? p.period.trim() : "";
3860
+ const label = typeof p.label === "string" ? p.label.trim() : "";
3861
+ const description = typeof p.description === "string" ? p.description.trim() : "";
3862
+ if (!period || !label) continue;
3863
+ points.push({ period, label, description });
3864
+ if (points.length >= 8) break;
3865
+ }
3866
+ if (points.length < 3) {
3867
+ return `<div class="kami-chart-error" data-err="too-few-points"></div>`;
3868
+ }
3869
+
3870
+ // Geometry · viewBox 960 × 380. Axis at y=200; cards above y∈[100,168],
3871
+ // below y∈[232,300]. Events evenly distributed across x∈[100,860].
3872
+ const X0 = 100, X1 = 860, AY = 200;
3873
+ const N = points.length;
3874
+ const step = (X1 - X0) / (N - 1);
3875
+
3876
+ const els = [];
3877
+ points.forEach((pt, i) => {
3878
+ const cx = Math.round(X0 + step * i);
3879
+ const above = i % 2 === 0;
3880
+ const isFocal = focal && pt.period === focal;
3881
+
3882
+ const dotCls = isFocal ? "kc-t-dot-focal" : "kc-t-dot";
3883
+ const conCls = isFocal ? "kc-t-connector-focal" : "kc-t-connector";
3884
+ const cardCls = isFocal ? "kc-t-card-focal" : "kc-t-card";
3885
+ const periodCls = isFocal ? "kc-t-period kc-t-period-focal" : "kc-t-period";
3886
+ const labelCls = isFocal ? "kc-t-label kc-t-label-focal" : "kc-t-label";
3887
+ const r = isFocal ? 6 : 5;
3888
+
3889
+ // Card geometry
3890
+ const cardW = 132, cardH = pt.description ? 56 : 40;
3891
+ const cardX = cx - cardW / 2;
3892
+ const cardY = above ? (AY - 32 - cardH) : (AY + 32);
3893
+ const conX = cx;
3894
+ const conY1 = above ? AY - 8 : AY + 8;
3895
+ const conY2 = above ? cardY + cardH : cardY;
3896
+
3897
+ const periodY = above ? cardY + 16 : cardY + 16;
3898
+ const labelY = above ? cardY + 32 : cardY + 32;
3899
+ const descY = above ? cardY + 48 : cardY + 48;
3900
+
3901
+ els.push(
3902
+ `<line class="${conCls}" x1="${conX}" y1="${conY1}" x2="${conX}" y2="${conY2}"/>` +
3903
+ `<circle class="${dotCls}" cx="${cx}" cy="${AY}" r="${r}"/>` +
3904
+ `<rect class="${cardCls}" x="${cardX}" y="${cardY}" width="${cardW}" height="${cardH}" rx="4"/>` +
3905
+ `<text class="${periodCls}" x="${cx}" y="${periodY}" text-anchor="middle">${escape(pt.period)}</text>` +
3906
+ `<text class="${labelCls}" x="${cx}" y="${labelY}" text-anchor="middle">${escape(pt.label)}</text>` +
3907
+ (pt.description
3908
+ ? `<text class="kc-t-period" x="${cx}" y="${descY}" text-anchor="middle" style="text-transform:none;letter-spacing:0;font-size:9px">${escape(pt.description.length > 40 ? pt.description.slice(0, 39) + "…" : pt.description)}</text>`
3909
+ : ""),
3910
+ );
3911
+ });
3912
+
3913
+ const figHeader =
3914
+ `<div class="kc-figheader">` +
3915
+ `<span class="kc-fignum">Figure${figNum ? " " + escape(figNum) : ""}</span>` +
3916
+ (title ? `<span class="kc-figtitle">${escape(title)}</span>` : "") +
3917
+ `</div>`;
3918
+
3919
+ const captionEl = caption
3920
+ ? `<p class="kc-caption">${inline(escape(caption))}</p>`
3921
+ : "";
3922
+
3923
+ return (
3924
+ `<figure class="kami-chart">` +
3925
+ figHeader +
3926
+ `<svg class="kc-svg" viewBox="0 0 960 380" xmlns="http://www.w3.org/2000/svg" aria-label="${escape(title || "Timeline")}">` +
3927
+ `<rect class="kc-canvas" width="100%" height="100%"/>` +
3928
+ `<line class="kc-t-axis" x1="${X0 - 40}" y1="${AY}" x2="${X1 + 40}" y2="${AY}"/>` +
3929
+ `<path class="kc-t-arrow" d="M${X1 + 34},${AY - 4} L${X1 + 40},${AY} L${X1 + 34},${AY + 4}"/>` +
3930
+ els.join("") +
3931
+ `</svg>` +
3932
+ captionEl +
3933
+ `</figure>`
3934
+ );
3935
+ }
3936
+
3937
+ /** Donut chart · 2–6 slices summing to 100 (values may be raw, the
3938
+ * renderer normalises). Largest slice carries the accent; smaller
3939
+ * slices cascade down the neutral ramp. The hole shows the largest
3940
+ * slice's value + caption. */
3941
+ function renderKamiDonut(data) {
3942
+ const title = typeof data.title === "string" ? data.title.trim() : "";
3943
+ const centerLabel = typeof data.centerLabel === "string" ? data.centerLabel.trim() : "";
3944
+ const caption = typeof data.caption === "string" ? data.caption.trim() : "";
3945
+ const figNum = typeof data.figNum === "string" || typeof data.figNum === "number"
3946
+ ? String(data.figNum) : "";
3947
+ const slicesRaw = Array.isArray(data.slices) ? data.slices : [];
3948
+ const slices = [];
3949
+ for (const s of slicesRaw) {
3950
+ if (!s || typeof s !== "object") continue;
3951
+ const label = typeof s.label === "string" ? s.label.trim() : "";
3952
+ const value = Number(s.value);
3953
+ if (!label || !isFinite(value) || value <= 0) continue;
3954
+ slices.push({ label, value });
3955
+ if (slices.length >= 6) break;
3956
+ }
3957
+ if (slices.length < 2) {
3958
+ return `<div class="kami-chart-error" data-err="too-few-slices"></div>`;
3959
+ }
3960
+
3961
+ // Sort largest-first so slice 1 = largest = accent
3962
+ slices.sort((a, b) => b.value - a.value);
3963
+ const total = slices.reduce((s, x) => s + x.value, 0);
3964
+ slices.forEach((s) => { s.pct = (s.value / total) * 100; });
3965
+
3966
+ // Geometry · viewBox 680 × 420 · donut centre (300, 200), R=136, r=76
3967
+ const CX = 300, CY = 200, R = 136, IR = 76;
3968
+
3969
+ function polar(r, deg) {
3970
+ const rad = (deg - 90) * Math.PI / 180; // -90 puts 0° at top
3971
+ return { x: CX + r * Math.cos(rad), y: CY + r * Math.sin(rad) };
3972
+ }
3973
+ function round1(v) { return Math.round(v * 10) / 10; }
3974
+
3975
+ let cursor = 0;
3976
+ const sliceEls = slices.map((s, i) => {
3977
+ const span = (s.value / total) * 360;
3978
+ const a0 = cursor;
3979
+ const a1 = cursor + span;
3980
+ cursor = a1;
3981
+ const large = span > 180 ? 1 : 0;
3982
+ const o0 = polar(R, a0);
3983
+ const o1 = polar(R, a1);
3984
+ const iEnd = polar(IR, a1);
3985
+ const iStart = polar(IR, a0);
3986
+ const cls = `kc-slice-${Math.min(6, i + 1)}`;
3987
+ const d = `M ${round1(o0.x)} ${round1(o0.y)} ` +
3988
+ `A ${R} ${R} 0 ${large} 1 ${round1(o1.x)} ${round1(o1.y)} ` +
3989
+ `L ${round1(iEnd.x)} ${round1(iEnd.y)} ` +
3990
+ `A ${IR} ${IR} 0 ${large} 0 ${round1(iStart.x)} ${round1(iStart.y)} Z`;
3991
+ return `<path class="${cls}" d="${d}"/>`;
3992
+ }).join("");
3993
+
3994
+ // Centre · largest slice's value + supplied label
3995
+ const lead = slices[0];
3996
+ const centerValue = Number.isInteger(lead.pct)
3997
+ ? `${lead.pct}%`
3998
+ : `${lead.pct.toFixed(1)}%`;
3999
+ const centerLabelTxt = (centerLabel || lead.label).toUpperCase();
4000
+
4001
+ // Legend · vertical list right of donut. x=472 swatch, x=492 pct, x=524 label.
4002
+ const legendRow = (s, i) => {
4003
+ const y = 136 + i * 26;
4004
+ const cls = `kc-slice-${Math.min(6, i + 1)}`;
4005
+ const pctTxt = Number.isInteger(s.pct) ? `${s.pct}%` : `${s.pct.toFixed(1)}%`;
4006
+ const isPrimary = i === 0 ? " kc-primary" : "";
4007
+ return (
4008
+ `<rect class="kc-donut-legend-swatch ${cls}" x="472" y="${y}" width="12" height="12" rx="2"/>` +
4009
+ `<text class="kc-donut-legend-value${isPrimary}" x="492" y="${y + 10}">${escape(pctTxt)}</text>` +
4010
+ `<text class="kc-donut-legend-label" x="528" y="${y + 10}">${escape(s.label)}</text>`
4011
+ );
4012
+ };
4013
+ const legendEls = slices.map(legendRow).join("");
4014
+ const legendTopY = 124;
4015
+ const legendBotY = 136 + slices.length * 26;
4016
+
4017
+ const figHeader =
4018
+ `<div class="kc-figheader">` +
4019
+ `<span class="kc-fignum">Figure${figNum ? " " + escape(figNum) : ""}</span>` +
4020
+ (title ? `<span class="kc-figtitle">${escape(title)}</span>` : "") +
4021
+ `</div>`;
4022
+
4023
+ const captionEl = caption
4024
+ ? `<p class="kc-caption">${inline(escape(caption))}</p>`
4025
+ : "";
4026
+
4027
+ return (
4028
+ `<figure class="kami-chart">` +
4029
+ figHeader +
4030
+ `<svg class="kc-svg" viewBox="0 0 680 400" xmlns="http://www.w3.org/2000/svg" aria-label="${escape(title || "Donut chart")}">` +
4031
+ `<rect class="kc-canvas" width="100%" height="100%"/>` +
4032
+ sliceEls +
4033
+ `<circle class="kc-donut-separator" cx="${CX}" cy="${CY}" r="${R}"/>` +
4034
+ `<circle class="kc-donut-separator" cx="${CX}" cy="${CY}" r="${IR}"/>` +
4035
+ `<circle class="kc-donut-hole" cx="${CX}" cy="${CY}" r="${IR - 1}"/>` +
4036
+ `<text class="kc-donut-center-value" x="${CX}" y="${CY - 7}" text-anchor="middle">${escape(centerValue)}</text>` +
4037
+ `<text class="kc-donut-center-label" x="${CX}" y="${CY + 14}" text-anchor="middle">${escape(centerLabelTxt)}</text>` +
4038
+ `<line class="kc-donut-legend-divider" x1="460" y1="${legendTopY}" x2="460" y2="${legendBotY}"/>` +
4039
+ legendEls +
4040
+ `</svg>` +
4041
+ captionEl +
4042
+ `</figure>`
4043
+ );
4044
+ }
4045
+
4046
+ /** Gantt chart · phases as horizontal bars over a shared time axis,
4047
+ * grouped by optional `section`. Replaces inline mermaid gantt blocks. */
4048
+ function renderKamiGantt(data) {
4049
+ const title = typeof data.title === "string" ? data.title.trim() : "";
4050
+ const unit = typeof data.unit === "string" ? data.unit.trim() : "";
4051
+ const focal = typeof data.focal === "string" ? data.focal.trim() : "";
4052
+ const caption = typeof data.caption === "string" ? data.caption.trim() : "";
4053
+ const figNum = typeof data.figNum === "string" || typeof data.figNum === "number"
4054
+ ? String(data.figNum) : "";
4055
+ const phasesRaw = Array.isArray(data.phases) ? data.phases : [];
4056
+ const phases = [];
4057
+ for (const p of phasesRaw) {
4058
+ if (!p || typeof p !== "object") continue;
4059
+ const label = typeof p.label === "string" ? p.label.trim() : "";
4060
+ const section = typeof p.section === "string" ? p.section.trim() : "";
4061
+ const start = Number(p.start);
4062
+ const end = Number(p.end);
4063
+ if (!label || !isFinite(start) || !isFinite(end) || end <= start) continue;
4064
+ phases.push({ label, section, start, end });
4065
+ if (phases.length >= 10) break;
4066
+ }
4067
+ if (phases.length < 2) {
4068
+ return `<div class="kami-chart-error" data-err="too-few-phases"></div>`;
4069
+ }
4070
+
4071
+ // Group by section preserving order of first appearance.
4072
+ const sectionOrder = [];
4073
+ const sectionMap = new Map();
4074
+ phases.forEach((p) => {
4075
+ const key = p.section || "";
4076
+ if (!sectionMap.has(key)) {
4077
+ sectionOrder.push(key);
4078
+ sectionMap.set(key, []);
4079
+ }
4080
+ sectionMap.get(key).push(p);
4081
+ });
4082
+ const hasSections = sectionOrder.some((s) => s !== "");
4083
+
4084
+ // Geometry · viewBox: variable width, fixed by row count.
4085
+ // Left column for labels: 220 wide. Time axis: x∈[220, 920].
4086
+ const X0 = 220, X1 = 920;
4087
+ const rowH = 32;
4088
+ const sectionGap = hasSections ? 8 : 0;
4089
+ const sectionHeaderH = hasSections ? 22 : 0;
4090
+ let cursorY = 80; // start below the time axis
4091
+ const yByPhase = new Map();
4092
+ const sectionBlocks = [];
4093
+ sectionOrder.forEach((sec, sIdx) => {
4094
+ const tasks = sectionMap.get(sec);
4095
+ const headerY = cursorY;
4096
+ tasks.forEach((p) => {
4097
+ yByPhase.set(p, cursorY + sectionHeaderH);
4098
+ cursorY += rowH;
4099
+ });
4100
+ cursorY += sectionGap;
4101
+ sectionBlocks.push({ name: sec, headerY, taskCount: tasks.length });
4102
+ });
4103
+ cursorY += 20;
4104
+ const totalH = cursorY;
4105
+ const viewBoxW = 960;
4106
+ const viewBoxH = totalH + (caption ? 0 : 12);
4107
+
4108
+ // Time scale · axis goes 0..maxEnd
4109
+ const maxEnd = Math.max(...phases.map((p) => p.end), 0);
4110
+ const niceMax = kcNiceMax(maxEnd);
4111
+ const toX = (t) => X0 + (t / niceMax) * (X1 - X0);
4112
+
4113
+ // Time ticks · 5 evenly-spaced
4114
+ const tickCount = 5;
4115
+ const tickEls = [];
4116
+ for (let i = 0; i <= tickCount; i++) {
4117
+ const v = (niceMax / tickCount) * i;
4118
+ const x = toX(v);
4119
+ const ticked = `${kcFmt(v)}${unit ? " " + unit : ""}`;
4120
+ tickEls.push(`<text class="kc-g-time-tick" x="${x}" y="64" text-anchor="middle">${escape(ticked)}</text>`);
4121
+ }
4122
+
4123
+ // Section headers + task rows
4124
+ let secCursor = 80;
4125
+ const sectionEls = [];
4126
+ const taskEls = [];
4127
+ sectionBlocks.forEach((blk, sIdx) => {
4128
+ const startY = secCursor + (hasSections ? sectionHeaderH : 0);
4129
+ const blockH = blk.taskCount * rowH;
4130
+ const endY = startY + blockH;
4131
+ if (sIdx % 2 === 0) {
4132
+ sectionEls.push(`<rect class="kc-g-section-bg" x="20" y="${startY}" width="${viewBoxW - 40}" height="${blockH}"/>`);
4133
+ }
4134
+ if (hasSections && blk.name) {
4135
+ sectionEls.push(`<text class="kc-g-section-label" x="40" y="${secCursor + 16}">${escape(blk.name)}</text>`);
4136
+ }
4137
+ secCursor = endY + sectionGap;
4138
+ });
4139
+
4140
+ phases.forEach((p) => {
4141
+ const y = yByPhase.get(p);
4142
+ const barY = y + 8;
4143
+ const barX = toX(p.start);
4144
+ const barW = toX(p.end) - barX;
4145
+ const isFocal = focal && p.label === focal;
4146
+ const barCls = isFocal ? "kc-g-bar kc-primary" : "kc-g-bar";
4147
+ const labelCls = isFocal ? "kc-g-task-label kc-primary" : "kc-g-task-label";
4148
+ const labelTxt = p.label.length > 24 ? p.label.slice(0, 23) + "…" : p.label;
4149
+ // Show bar duration inside the bar if it fits, else outside.
4150
+ const span = p.end - p.start;
4151
+ const spanTxt = `${kcFmt(span)}${unit ? unit.slice(0, 1) : ""}`;
4152
+ const spanX = barW > 36 ? barX + barW / 2 : barX + barW + 6;
4153
+ const spanCls = barW > 36 ? "kc-g-bar-label" : "kc-g-bar-label kc-outside";
4154
+ const spanAnchor = barW > 36 ? "middle" : "start";
4155
+ taskEls.push(
4156
+ `<text class="${labelCls}" x="200" y="${barY + 13}" text-anchor="end">${escape(labelTxt)}</text>` +
4157
+ `<rect class="${barCls}" x="${barX}" y="${barY}" width="${Math.max(2, barW)}" height="16" rx="2"/>` +
4158
+ `<text class="${spanCls}" x="${spanX}" y="${barY + 11}" text-anchor="${spanAnchor}">${escape(spanTxt)}</text>` +
4159
+ `<line class="kc-g-row-rule" x1="20" y1="${y + rowH}" x2="${viewBoxW - 20}" y2="${y + rowH}"/>`,
4160
+ );
4161
+ });
4162
+
4163
+ const figHeader =
4164
+ `<div class="kc-figheader">` +
4165
+ `<span class="kc-fignum">Figure${figNum ? " " + escape(figNum) : ""}</span>` +
4166
+ (title ? `<span class="kc-figtitle">${escape(title)}</span>` : "") +
4167
+ `</div>`;
4168
+
4169
+ const captionEl = caption
4170
+ ? `<p class="kc-caption">${inline(escape(caption))}</p>`
4171
+ : "";
4172
+
4173
+ return (
4174
+ `<figure class="kami-chart">` +
4175
+ figHeader +
4176
+ `<svg class="kc-svg" viewBox="0 0 ${viewBoxW} ${viewBoxH}" xmlns="http://www.w3.org/2000/svg" aria-label="${escape(title || "Gantt chart")}">` +
4177
+ `<rect class="kc-canvas" width="100%" height="100%"/>` +
4178
+ sectionEls.join("") +
4179
+ `<line class="kc-g-time-axis" x1="${X0}" y1="72" x2="${X1}" y2="72"/>` +
4180
+ tickEls.join("") +
4181
+ taskEls.join("") +
4182
+ `</svg>` +
4183
+ captionEl +
4184
+ `</figure>`
4185
+ );
4186
+ }
4187
+
4188
+ /** Tree (mindmap) · 1 root + 2–6 branches × 0–4 leaves per branch.
4189
+ * Replaces inline mermaid mindmap blocks. Layout: root centred on
4190
+ * top, branches spread evenly on a horizontal band, leaves drop
4191
+ * beneath each branch with even horizontal spacing. */
4192
+ function renderKamiTree(data) {
4193
+ const rootRaw = typeof data.root === "string" ? data.root.trim() : "";
4194
+ const focal = typeof data.focal === "string" ? data.focal.trim() : "";
4195
+ const caption = typeof data.caption === "string" ? data.caption.trim() : "";
4196
+ const figNum = typeof data.figNum === "string" || typeof data.figNum === "number"
4197
+ ? String(data.figNum) : "";
4198
+ const branchesRaw = Array.isArray(data.branches) ? data.branches : [];
4199
+ const branches = [];
4200
+ for (const b of branchesRaw) {
4201
+ if (!b || typeof b !== "object") continue;
4202
+ const label = typeof b.label === "string" ? b.label.trim() : "";
4203
+ if (!label) continue;
4204
+ const leaves = Array.isArray(b.leaves) ? b.leaves
4205
+ .map((l) => typeof l === "string" ? l.trim() : "")
4206
+ .filter(Boolean)
4207
+ .slice(0, 4) : [];
4208
+ branches.push({ label, leaves });
4209
+ if (branches.length >= 6) break;
4210
+ }
4211
+ if (!rootRaw || branches.length < 2) {
4212
+ return `<div class="kami-chart-error" data-err="too-small-tree"></div>`;
4213
+ }
4214
+
4215
+ // Layout · viewBox 960 × 460.
4216
+ const VBW = 960, VBH = 460;
4217
+ const ROOT_Y = 64, ROOT_H = 48, ROOT_W = 168;
4218
+ const BRANCH_Y = 160, BRANCH_H = 48, BRANCH_W = 144;
4219
+ const LEAF_Y = 290, LEAF_H = 48, LEAF_W = 128;
4220
+ const N = branches.length;
4221
+ const totalLeafCount = branches.reduce((s, b) => s + b.leaves.length, 0);
4222
+ const hasLeaves = totalLeafCount > 0;
4223
+ const SVG_H = hasLeaves ? 380 : 240;
4224
+
4225
+ // Spread branches evenly across plot width [80, 880]
4226
+ const plotW = VBW - 160;
4227
+ const branchX = (i) => Math.round(80 + (plotW / N) * i + (plotW / N) / 2);
4228
+
4229
+ // Root centre
4230
+ const rootCX = VBW / 2;
4231
+
4232
+ const els = [];
4233
+
4234
+ // Connectors · root → each branch top
4235
+ branches.forEach((b, i) => {
4236
+ const isFocal = focal && b.label === focal;
4237
+ const conCls = isFocal ? "kc-tree-connector-focal" : "kc-tree-connector";
4238
+ const chevCls = isFocal ? "kc-tree-chevron-focal" : "kc-tree-chevron";
4239
+ const bx = branchX(i);
4240
+ const midY = (ROOT_Y + ROOT_H + BRANCH_Y) / 2;
4241
+ // Path: from root bottom-center → drop to midY → horizontal to branch x → drop to branch top
4242
+ els.push(
4243
+ `<path class="${conCls}" d="M ${rootCX} ${ROOT_Y + ROOT_H} L ${rootCX} ${midY} L ${bx} ${midY} L ${bx} ${BRANCH_Y}"/>` +
4244
+ `<path class="${chevCls}" d="M ${bx - 5} ${BRANCH_Y - 4} L ${bx} ${BRANCH_Y} L ${bx + 5} ${BRANCH_Y - 4}"/>`,
4245
+ );
4246
+ });
4247
+
4248
+ // Branch nodes
4249
+ branches.forEach((b, i) => {
4250
+ const isFocal = focal && b.label === focal;
4251
+ const bx = branchX(i);
4252
+ const rectCls = isFocal ? "kc-tree-branch-focal" : "kc-tree-branch";
4253
+ const kickerCls = isFocal ? "kc-tree-kicker kc-tree-kicker-focal" : "kc-tree-kicker";
4254
+ const kickerTxt = isFocal ? "FOCAL" : `BRANCH ${String(i + 1).padStart(2, "0")}`;
4255
+ const labelTxt = b.label.length > 18 ? b.label.slice(0, 17) + "…" : b.label;
4256
+ els.push(
4257
+ `<rect class="${rectCls}" x="${bx - BRANCH_W / 2}" y="${BRANCH_Y}" width="${BRANCH_W}" height="${BRANCH_H}" rx="6"/>` +
4258
+ `<text class="${kickerCls}" x="${bx - BRANCH_W / 2 + 16}" y="${BRANCH_Y + 16}">${escape(kickerTxt)}</text>` +
4259
+ `<text class="kc-tree-text" x="${bx}" y="${BRANCH_Y + 34}" text-anchor="middle">${escape(labelTxt)}</text>`,
4260
+ );
4261
+ });
4262
+
4263
+ // Leaves per branch · spread under each branch
4264
+ if (hasLeaves) {
4265
+ branches.forEach((b, i) => {
4266
+ if (b.leaves.length === 0) return;
4267
+ const isFocalBranch = focal && b.label === focal;
4268
+ const conCls = isFocalBranch ? "kc-tree-connector-focal" : "kc-tree-connector";
4269
+ const chevCls = isFocalBranch ? "kc-tree-chevron-focal" : "kc-tree-chevron";
4270
+ const leafCls = isFocalBranch ? "kc-tree-leaf-focal" : "kc-tree-leaf";
4271
+ const bx = branchX(i);
4272
+ const slotW = Math.min(LEAF_W + 8, (plotW / N) - 4);
4273
+ const leafCount = b.leaves.length;
4274
+ const totalSpan = slotW * leafCount;
4275
+ const startX = bx - totalSpan / 2 + slotW / 2;
4276
+ const midY = (BRANCH_Y + BRANCH_H + LEAF_Y) / 2;
4277
+
4278
+ b.leaves.forEach((leaf, k) => {
4279
+ const lx = Math.round(startX + slotW * k);
4280
+ // Connector from branch bottom → mid → leaf top
4281
+ els.push(
4282
+ `<path class="${conCls}" d="M ${bx} ${BRANCH_Y + BRANCH_H} L ${bx} ${midY} L ${lx} ${midY} L ${lx} ${LEAF_Y}"/>` +
4283
+ `<path class="${chevCls}" d="M ${lx - 5} ${LEAF_Y - 4} L ${lx} ${LEAF_Y} L ${lx + 5} ${LEAF_Y - 4}"/>`,
4284
+ );
4285
+ const leafTxt = leaf.length > 16 ? leaf.slice(0, 15) + "…" : leaf;
4286
+ // Leaf width based on slot
4287
+ const lw = Math.min(LEAF_W, slotW - 8);
4288
+ els.push(
4289
+ `<rect class="${leafCls}" x="${lx - lw / 2}" y="${LEAF_Y}" width="${lw}" height="${LEAF_H}" rx="6"/>` +
4290
+ `<text class="kc-tree-text" x="${lx}" y="${LEAF_Y + 28}" text-anchor="middle" style="font-size:10px">${escape(leafTxt)}</text>`,
4291
+ );
4292
+ });
4293
+ });
4294
+ }
4295
+
4296
+ // Root node (drawn last so it sits on top of any connectors at the top)
4297
+ const rootLabel = rootRaw.length > 22 ? rootRaw.slice(0, 21) + "…" : rootRaw;
4298
+ els.unshift(
4299
+ `<rect class="kc-tree-root" x="${rootCX - ROOT_W / 2}" y="${ROOT_Y}" width="${ROOT_W}" height="${ROOT_H}" rx="6"/>` +
4300
+ `<text class="kc-tree-kicker" x="${rootCX - ROOT_W / 2 + 16}" y="${ROOT_Y + 16}">ROOT</text>` +
4301
+ `<text class="kc-tree-text" x="${rootCX}" y="${ROOT_Y + 34}" text-anchor="middle">${escape(rootLabel)}</text>`,
4302
+ );
4303
+
4304
+ const figHeader =
4305
+ `<div class="kc-figheader">` +
4306
+ `<span class="kc-fignum">Figure${figNum ? " " + escape(figNum) : ""}</span>` +
4307
+ (data.title ? `<span class="kc-figtitle">${escape(String(data.title).trim())}</span>` : "") +
4308
+ `</div>`;
4309
+
4310
+ const captionEl = caption
4311
+ ? `<p class="kc-caption">${inline(escape(caption))}</p>`
4312
+ : "";
4313
+
4314
+ return (
4315
+ `<figure class="kami-chart">` +
4316
+ figHeader +
4317
+ `<svg class="kc-svg" viewBox="0 0 ${VBW} ${SVG_H}" xmlns="http://www.w3.org/2000/svg" aria-label="${escape(data.title || "Tree")}">` +
4318
+ `<rect class="kc-canvas" width="100%" height="100%"/>` +
4319
+ els.join("") +
4320
+ `</svg>` +
4321
+ captionEl +
4322
+ `</figure>`
4323
+ );
4324
+ }
4325
+
4326
+ /** Flowchart · two layout templates:
4327
+ * · linear-v · 2–5 nodes top-to-bottom, single column with arrows
4328
+ * · y-decision · root (top) → 2 branches (left/right) → optional join.
4329
+ * Replaces inline mermaid flowchart blocks. */
4330
+ function renderKamiFlowchart(data) {
4331
+ const layout = typeof data.layout === "string" ? data.layout.trim() : "linear-v";
4332
+ const focal = typeof data.focal === "string" ? data.focal.trim() : "";
4333
+ const caption = typeof data.caption === "string" ? data.caption.trim() : "";
4334
+ const figNum = typeof data.figNum === "string" || typeof data.figNum === "number"
4335
+ ? String(data.figNum) : "";
4336
+ const nodesRaw = Array.isArray(data.nodes) ? data.nodes : [];
4337
+ const nodes = [];
4338
+ for (const n of nodesRaw) {
4339
+ if (!n || typeof n !== "object") continue;
4340
+ const label = typeof n.label === "string" ? n.label.trim() : "";
4341
+ if (!label) continue;
4342
+ const kind = typeof n.kind === "string" ? n.kind.trim() : "step";
4343
+ const hint = typeof n.hint === "string" ? n.hint.trim() : "";
4344
+ nodes.push({ label, kind, hint });
4345
+ }
4346
+ if (nodes.length < 2) {
4347
+ return `<div class="kami-chart-error" data-err="too-few-nodes"></div>`;
4348
+ }
4349
+
4350
+ if (layout === "y-decision") {
4351
+ return renderKamiFlowchartYDecision(nodes, focal, caption, figNum, data);
4352
+ }
4353
+ return renderKamiFlowchartLinearV(nodes, focal, caption, figNum, data);
4354
+ }
4355
+
4356
+ /** Render a flowchart node (returns SVG string). Kind controls shape:
4357
+ * · start / end · rounded pill, paper-soft fill
4358
+ * · decision · diamond, accent border + paper-deep fill
4359
+ * · outcome · square box, paper-deep fill, muted border (the
4360
+ * "deferred" branch reads as receded)
4361
+ * · step (default) · square box, paper fill, ink border
4362
+ * Focal nodes get the accent treatment regardless of kind. */
4363
+ function kcFlowNode(opts) {
4364
+ const { x, y, w, h, kind, label, hint, isFocal } = opts;
4365
+ const labelClipped = label.length > 18 ? label.slice(0, 17) + "…" : label;
4366
+ const cx = x + w / 2;
4367
+ const cy = y + h / 2;
4368
+
4369
+ if (kind === "decision") {
4370
+ // Diamond · 6-point polygon
4371
+ const points = [
4372
+ `${cx},${y}`,
4373
+ `${x + w},${cy - h * 0.18}`,
4374
+ `${x + w},${cy + h * 0.18}`,
4375
+ `${cx},${y + h}`,
4376
+ `${x},${cy + h * 0.18}`,
4377
+ `${x},${cy - h * 0.18}`,
4378
+ ].join(" ");
4379
+ const cls = isFocal ? "kc-fl-node-focal" : "kc-fl-node";
4380
+ const kickerCls = isFocal ? "kc-fl-kicker kc-fl-kicker-focal" : "kc-fl-kicker";
4381
+ return (
4382
+ `<polygon class="${cls}" points="${points}"/>` +
4383
+ `<text class="${kickerCls}" x="${cx}" y="${cy - 8}" text-anchor="middle">DECISION</text>` +
4384
+ `<text class="kc-fl-text" x="${cx}" y="${cy + 6}" text-anchor="middle">${escape(labelClipped)}</text>` +
4385
+ (hint ? `<text class="kc-fl-edge-label" x="${cx}" y="${cy + 18}" text-anchor="middle">${escape(hint.toUpperCase())}</text>` : "")
4386
+ );
4387
+ }
4388
+
4389
+ if (kind === "start" || kind === "end") {
4390
+ // Pill · rounded rect, paper-soft
4391
+ const cls = "kc-fl-node-pill";
4392
+ const labelTxt = label.length > 16 ? label.slice(0, 15) + "…" : label;
4393
+ return (
4394
+ `<rect class="${cls}" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
4395
+ `<text class="kc-fl-text" x="${cx}" y="${cy + 4}" text-anchor="middle">${escape(labelTxt)}</text>`
4396
+ );
4397
+ }
4398
+
4399
+ if (kind === "outcome") {
4400
+ // Square, faint
4401
+ const cls = isFocal ? "kc-fl-node-focal" : "kc-fl-node-faint";
4402
+ const kickerCls = isFocal ? "kc-fl-kicker kc-fl-kicker-focal" : "kc-fl-kicker";
4403
+ const kicker = (opts.kicker || "OUTCOME").toUpperCase();
4404
+ const textCls = isFocal ? "kc-fl-text" : "kc-fl-text kc-fl-text-faint";
4405
+ return (
4406
+ `<rect class="${cls}" x="${x}" y="${y}" width="${w}" height="${h}" rx="6"/>` +
4407
+ `<text class="${kickerCls}" x="${x + 16}" y="${y + 16}">${escape(kicker)}</text>` +
4408
+ `<text class="${textCls}" x="${cx}" y="${cy + 8}" text-anchor="middle">${escape(labelClipped)}</text>` +
4409
+ (hint ? `<text class="kc-fl-kicker" x="${cx}" y="${y + h - 8}" text-anchor="middle">${escape(hint)}</text>` : "")
4410
+ );
4411
+ }
4412
+
4413
+ // step (default) · square box
4414
+ const cls = isFocal ? "kc-fl-node-focal" : "kc-fl-node";
4415
+ const kickerCls = isFocal ? "kc-fl-kicker kc-fl-kicker-focal" : "kc-fl-kicker";
4416
+ const kicker = (opts.kicker || "STEP").toUpperCase();
4417
+ return (
4418
+ `<rect class="${cls}" x="${x}" y="${y}" width="${w}" height="${h}" rx="6"/>` +
4419
+ `<text class="${kickerCls}" x="${x + 16}" y="${y + 16}">${escape(kicker)}</text>` +
4420
+ `<text class="kc-fl-text" x="${cx}" y="${cy + 8}" text-anchor="middle">${escape(labelClipped)}</text>` +
4421
+ (hint ? `<text class="kc-fl-kicker" x="${cx}" y="${y + h - 8}" text-anchor="middle">${escape(hint)}</text>` : "")
4422
+ );
4423
+ }
4424
+
4425
+ function renderKamiFlowchartLinearV(nodes, focal, caption, figNum, data) {
4426
+ // Up to 5 nodes vertical · viewBox 600 × dynamic
4427
+ const N = Math.min(nodes.length, 5);
4428
+ const arr = nodes.slice(0, N);
4429
+ const W = 600;
4430
+ const nodeW = 220, nodeH = 60;
4431
+ const gap = 36;
4432
+ const startY = 30;
4433
+ const totalH = startY + N * nodeH + (N - 1) * gap + 30;
4434
+
4435
+ const cx = W / 2;
4436
+ const els = [];
4437
+
4438
+ arr.forEach((n, i) => {
4439
+ const isFocal = focal && n.label === focal;
4440
+ const y = startY + i * (nodeH + gap);
4441
+ const x = cx - nodeW / 2;
4442
+ // Connector from previous to this
4443
+ if (i > 0) {
4444
+ const prevY = startY + (i - 1) * (nodeH + gap) + nodeH;
4445
+ const arrowCls = (focal && (arr[i - 1].label === focal || n.label === focal))
4446
+ ? "kc-fl-arrow-focal" : "kc-fl-arrow";
4447
+ const headCls = (focal && (arr[i - 1].label === focal || n.label === focal))
4448
+ ? "kc-fl-arrowhead-focal" : "kc-fl-arrowhead";
4449
+ els.push(
4450
+ `<line class="${arrowCls}" x1="${cx}" y1="${prevY}" x2="${cx}" y2="${y}"/>` +
4451
+ `<path class="${headCls}" d="M ${cx - 5} ${y - 6} L ${cx} ${y} L ${cx + 5} ${y - 6}"/>`,
4452
+ );
4453
+ }
4454
+ els.push(kcFlowNode({
4455
+ x, y, w: nodeW, h: nodeH,
4456
+ kind: n.kind, label: n.label, hint: n.hint, isFocal,
4457
+ kicker: n.kind === "start" ? "" : (n.kind === "end" ? "" : `STEP ${String(i + 1).padStart(2, "0")}`),
4458
+ }));
4459
+ });
4460
+
4461
+ const figHeader =
4462
+ `<div class="kc-figheader">` +
4463
+ `<span class="kc-fignum">Figure${figNum ? " " + escape(figNum) : ""}</span>` +
4464
+ (data.title ? `<span class="kc-figtitle">${escape(String(data.title).trim())}</span>` : "") +
4465
+ `</div>`;
4466
+
4467
+ const captionEl = caption
4468
+ ? `<p class="kc-caption">${inline(escape(caption))}</p>`
4469
+ : "";
4470
+
4471
+ return (
4472
+ `<figure class="kami-chart">` +
4473
+ figHeader +
4474
+ `<svg class="kc-svg" viewBox="0 0 ${W} ${totalH}" xmlns="http://www.w3.org/2000/svg" aria-label="${escape(data.title || "Flowchart")}">` +
4475
+ `<rect class="kc-canvas" width="100%" height="100%"/>` +
4476
+ els.join("") +
4477
+ `</svg>` +
4478
+ captionEl +
4479
+ `</figure>`
4480
+ );
4481
+ }
4482
+
4483
+ function renderKamiFlowchartYDecision(nodes, focal, caption, figNum, data) {
4484
+ // y-decision · expects nodes in this canonical order:
4485
+ // [0] root (decision diamond)
4486
+ // [1] left branch outcome
4487
+ // [2] right branch outcome
4488
+ // [3] optional join / end
4489
+ // Edge labels come from each branch node's `hint` field (e.g. "YES", "NO").
4490
+ const arr = nodes.slice(0, 4);
4491
+ if (arr.length < 3) {
4492
+ return `<div class="kami-chart-error" data-err="y-decision needs 3-4 nodes"></div>`;
4493
+ }
4494
+ const hasJoin = arr.length === 4;
4495
+ const W = 720;
4496
+ const totalH = hasJoin ? 380 : 280;
4497
+ const rootY = 30;
4498
+ const branchY = 160;
4499
+ const joinY = 280;
4500
+ const rootW = 200, rootH = 80;
4501
+ const branchW = 180, branchH = 70;
4502
+ const joinW = 140, joinH = 50;
4503
+
4504
+ const cx = W / 2;
4505
+ const leftCX = cx - 180;
4506
+ const rightCX = cx + 180;
4507
+
4508
+ const root = arr[0];
4509
+ const left = arr[1];
4510
+ const right = arr[2];
4511
+ const join = arr[3];
4512
+
4513
+ const isRootFocal = focal && root.label === focal;
4514
+ const isLeftFocal = focal && left.label === focal;
4515
+ const isRightFocal = focal && right.label === focal;
4516
+ const isJoinFocal = hasJoin && focal && join.label === focal;
4517
+
4518
+ const els = [];
4519
+
4520
+ // Root → Left branch
4521
+ const leftArrowCls = (isRootFocal || isLeftFocal) ? "kc-fl-arrow-focal" : "kc-fl-arrow";
4522
+ const leftHeadCls = (isRootFocal || isLeftFocal) ? "kc-fl-arrowhead-focal" : "kc-fl-arrowhead";
4523
+ const leftPath = `M ${cx - 30} ${rootY + rootH - 10} L ${leftCX} ${rootY + rootH + 30} L ${leftCX} ${branchY}`;
4524
+ els.push(`<path class="${leftArrowCls}" d="${leftPath}"/>`);
4525
+ els.push(`<path class="${leftHeadCls}" d="M ${leftCX - 5} ${branchY - 6} L ${leftCX} ${branchY} L ${leftCX + 5} ${branchY - 6}"/>`);
4526
+ // Edge label for left branch (from left.hint)
4527
+ if (left.hint) {
4528
+ const labelMidX = leftCX + 40;
4529
+ const labelMidY = (rootY + rootH + branchY) / 2 - 4;
4530
+ const labelTxt = left.hint.toUpperCase();
4531
+ const labelW = labelTxt.length * 6 + 14;
4532
+ const labelCls = isLeftFocal ? "kc-fl-edge-label kc-primary" : "kc-fl-edge-label";
4533
+ els.push(
4534
+ `<rect class="kc-fl-edge-label-bg" x="${labelMidX - labelW / 2}" y="${labelMidY - 8}" width="${labelW}" height="14" rx="2"/>` +
4535
+ `<text class="${labelCls}" x="${labelMidX}" y="${labelMidY + 3}" text-anchor="middle">${escape(labelTxt)}</text>`,
4536
+ );
4537
+ }
4538
+
4539
+ // Root → Right branch
4540
+ const rightArrowCls = (isRootFocal || isRightFocal) ? "kc-fl-arrow-focal" : "kc-fl-arrow";
4541
+ const rightHeadCls = (isRootFocal || isRightFocal) ? "kc-fl-arrowhead-focal" : "kc-fl-arrowhead";
4542
+ const rightPath = `M ${cx + 30} ${rootY + rootH - 10} L ${rightCX} ${rootY + rootH + 30} L ${rightCX} ${branchY}`;
4543
+ els.push(`<path class="${rightArrowCls}" d="${rightPath}"/>`);
4544
+ els.push(`<path class="${rightHeadCls}" d="M ${rightCX - 5} ${branchY - 6} L ${rightCX} ${branchY} L ${rightCX + 5} ${branchY - 6}"/>`);
4545
+ if (right.hint) {
4546
+ const labelMidX = rightCX - 40;
4547
+ const labelMidY = (rootY + rootH + branchY) / 2 - 4;
4548
+ const labelTxt = right.hint.toUpperCase();
4549
+ const labelW = labelTxt.length * 6 + 14;
4550
+ const labelCls = isRightFocal ? "kc-fl-edge-label kc-primary" : "kc-fl-edge-label";
4551
+ els.push(
4552
+ `<rect class="kc-fl-edge-label-bg" x="${labelMidX - labelW / 2}" y="${labelMidY - 8}" width="${labelW}" height="14" rx="2"/>` +
4553
+ `<text class="${labelCls}" x="${labelMidX}" y="${labelMidY + 3}" text-anchor="middle">${escape(labelTxt)}</text>`,
4554
+ );
4555
+ }
4556
+
4557
+ // Branches → join (if exists)
4558
+ if (hasJoin) {
4559
+ const joinArrowCls = (isJoinFocal) ? "kc-fl-arrow-focal" : "kc-fl-arrow";
4560
+ const joinHeadCls = (isJoinFocal) ? "kc-fl-arrowhead-focal" : "kc-fl-arrowhead";
4561
+ const leftJoinPath = `M ${leftCX} ${branchY + branchH} L ${leftCX} ${joinY + joinH / 2 - 20} L ${cx - joinW / 2} ${joinY + joinH / 2 - 8}`;
4562
+ const rightJoinPath = `M ${rightCX} ${branchY + branchH} L ${rightCX} ${joinY + joinH / 2 - 20} L ${cx + joinW / 2} ${joinY + joinH / 2 - 8}`;
4563
+ els.push(`<path class="${joinArrowCls}" d="${leftJoinPath}"/>`);
4564
+ els.push(`<path class="${joinHeadCls}" d="M ${cx - joinW / 2 - 5} ${joinY + joinH / 2 - 14} L ${cx - joinW / 2} ${joinY + joinH / 2 - 8} L ${cx - joinW / 2 - 5} ${joinY + joinH / 2 - 2}"/>`);
4565
+ els.push(`<path class="${joinArrowCls}" d="${rightJoinPath}"/>`);
4566
+ els.push(`<path class="${joinHeadCls}" d="M ${cx + joinW / 2 + 5} ${joinY + joinH / 2 - 14} L ${cx + joinW / 2} ${joinY + joinH / 2 - 8} L ${cx + joinW / 2 + 5} ${joinY + joinH / 2 - 2}"/>`);
4567
+ }
4568
+
4569
+ // Root node (decision diamond by default; respect explicit kind)
4570
+ const rootKind = root.kind || "decision";
4571
+ els.push(kcFlowNode({
4572
+ x: cx - rootW / 2, y: rootY, w: rootW, h: rootH,
4573
+ kind: rootKind, label: root.label, hint: root.hint, isFocal: isRootFocal,
4574
+ }));
4575
+
4576
+ // Left branch (outcome by default)
4577
+ els.push(kcFlowNode({
4578
+ x: leftCX - branchW / 2, y: branchY, w: branchW, h: branchH,
4579
+ kind: left.kind || "outcome", label: left.label, hint: left.hint, isFocal: isLeftFocal,
4580
+ kicker: "OUTCOME A",
4581
+ }));
4582
+
4583
+ // Right branch (outcome by default)
4584
+ els.push(kcFlowNode({
4585
+ x: rightCX - branchW / 2, y: branchY, w: branchW, h: branchH,
4586
+ kind: right.kind || "outcome", label: right.label, hint: right.hint, isFocal: isRightFocal,
4587
+ kicker: "OUTCOME B",
4588
+ }));
4589
+
4590
+ // Join (end pill by default)
4591
+ if (hasJoin) {
4592
+ els.push(kcFlowNode({
4593
+ x: cx - joinW / 2, y: joinY, w: joinW, h: joinH,
4594
+ kind: join.kind || "end", label: join.label, hint: join.hint, isFocal: isJoinFocal,
4595
+ }));
4596
+ }
4597
+
4598
+ const figHeader =
4599
+ `<div class="kc-figheader">` +
4600
+ `<span class="kc-fignum">Figure${figNum ? " " + escape(figNum) : ""}</span>` +
4601
+ (data.title ? `<span class="kc-figtitle">${escape(String(data.title).trim())}</span>` : "") +
4602
+ `</div>`;
4603
+
4604
+ const captionEl = caption
4605
+ ? `<p class="kc-caption">${inline(escape(caption))}</p>`
4606
+ : "";
4607
+
4608
+ return (
4609
+ `<figure class="kami-chart">` +
4610
+ figHeader +
4611
+ `<svg class="kc-svg" viewBox="0 0 ${W} ${totalH}" xmlns="http://www.w3.org/2000/svg" aria-label="${escape(data.title || "Flowchart")}">` +
4612
+ `<rect class="kc-canvas" width="100%" height="100%"/>` +
4613
+ els.join("") +
4614
+ `</svg>` +
4615
+ captionEl +
4616
+ `</figure>`
4617
+ );
4618
+ }
4619
+
4620
+ /** Swimlane · 2–4 horizontal lanes × 4–10 steps. Each step assigned
4621
+ * to a lane via `laneIdx`; rendered left-to-right in step order.
4622
+ * Connectors elbow when consecutive steps change lanes. Replaces
4623
+ * inline mermaid `sequenceDiagram` AND `journey`. */
4624
+ function renderKamiSwimlane(data) {
4625
+ const title = typeof data.title === "string" ? data.title.trim() : "";
4626
+ const focal = typeof data.focal === "string" ? data.focal.trim() : "";
4627
+ const focalLane = typeof data.focalLane === "string" ? data.focalLane.trim() : "";
4628
+ const caption = typeof data.caption === "string" ? data.caption.trim() : "";
4629
+ const figNum = typeof data.figNum === "string" || typeof data.figNum === "number"
4630
+ ? String(data.figNum) : "";
4631
+ const lanesRaw = Array.isArray(data.lanes) ? data.lanes : [];
4632
+ const lanes = lanesRaw
4633
+ .map((l) => typeof l === "string" ? l.trim() : "")
4634
+ .filter(Boolean)
4635
+ .slice(0, 4);
4636
+ if (lanes.length < 2) {
4637
+ return `<div class="kami-chart-error" data-err="too-few-lanes"></div>`;
4638
+ }
4639
+ const stepsRaw = Array.isArray(data.steps) ? data.steps : [];
4640
+ const steps = [];
4641
+ for (const s of stepsRaw) {
4642
+ if (!s || typeof s !== "object") continue;
4643
+ const label = typeof s.label === "string" ? s.label.trim() : "";
4644
+ const laneIdx = Number.isInteger(s.laneIdx) ? s.laneIdx : -1;
4645
+ if (!label || laneIdx < 0 || laneIdx >= lanes.length) continue;
4646
+ const kicker = typeof s.kicker === "string" ? s.kicker.trim() : "";
4647
+ const score = Number.isInteger(s.score) ? Math.max(1, Math.min(5, s.score)) : 0;
4648
+ steps.push({ label, laneIdx, kicker, score });
4649
+ if (steps.length >= 10) break;
4650
+ }
4651
+ if (steps.length < 3) {
4652
+ return `<div class="kami-chart-error" data-err="too-few-steps"></div>`;
4653
+ }
4654
+
4655
+ // Geometry · viewBox 960 × variable. Lane label column on left (64w).
4656
+ const VBW = 960;
4657
+ const LANE_COL_W = 72;
4658
+ const LANE_H = 110;
4659
+ const PAD_TOP = 40;
4660
+ const PAD_BOTTOM = 30;
4661
+ const NLanes = lanes.length;
4662
+ const totalH = PAD_TOP + NLanes * LANE_H + PAD_BOTTOM;
4663
+ const PLOT_X0 = LANE_COL_W + 16, PLOT_X1 = VBW - 24;
4664
+ const PLOT_W = PLOT_X1 - PLOT_X0;
4665
+ const N = steps.length;
4666
+ const stepGap = PLOT_W / N;
4667
+ const NODE_W = Math.min(140, Math.round(stepGap * 0.78));
4668
+ const NODE_H = 48;
4669
+
4670
+ // Lane backgrounds + labels
4671
+ const laneEls = [];
4672
+ for (let i = 0; i < NLanes; i++) {
4673
+ const y = PAD_TOP + i * LANE_H;
4674
+ const isFocalLane = focalLane && lanes[i] === focalLane;
4675
+ const bgCls = isFocalLane ? "kc-sw-lane-bg-focal" : (i % 2 === 0 ? "kc-sw-lane-bg" : "kc-sw-lane-bg-alt");
4676
+ laneEls.push(`<rect class="${bgCls}" x="${LANE_COL_W}" y="${y}" width="${VBW - LANE_COL_W}" height="${LANE_H}"/>`);
4677
+ if (i > 0) {
4678
+ const dCls = isFocalLane || (focalLane && lanes[i - 1] === focalLane)
4679
+ ? "kc-sw-lane-divider-focal" : "kc-sw-lane-divider";
4680
+ laneEls.push(`<line class="${dCls}" x1="${LANE_COL_W}" y1="${y}" x2="${VBW}" y2="${y}"/>`);
4681
+ }
4682
+ const labelCls = isFocalLane ? "kc-sw-lane-label kc-sw-lane-label-focal" : "kc-sw-lane-label";
4683
+ const laneTxt = lanes[i].length > 14 ? lanes[i].slice(0, 13) + "…" : lanes[i];
4684
+ laneEls.push(`<text class="${labelCls}" x="${LANE_COL_W / 2}" y="${y + LANE_H / 2 + 4}" text-anchor="middle" transform="rotate(-90 ${LANE_COL_W / 2} ${y + LANE_H / 2})">${escape(laneTxt.toUpperCase())}</text>`);
4685
+ }
4686
+ // Label column frame
4687
+ laneEls.unshift(`<rect class="kc-sw-label-col" x="0" y="${PAD_TOP}" width="${LANE_COL_W}" height="${NLanes * LANE_H}"/>`);
4688
+
4689
+ // Step positions
4690
+ const stepGeo = steps.map((s, i) => {
4691
+ const cx = Math.round(PLOT_X0 + stepGap * i + stepGap / 2);
4692
+ const cy = PAD_TOP + s.laneIdx * LANE_H + LANE_H / 2;
4693
+ return { cx, cy, x: cx - NODE_W / 2, y: cy - NODE_H / 2, ...s };
4694
+ });
4695
+
4696
+ // Connectors (drawn first so nodes overlap them)
4697
+ const connectorEls = [];
4698
+ for (let i = 0; i < stepGeo.length - 1; i++) {
4699
+ const a = stepGeo[i];
4700
+ const b = stepGeo[i + 1];
4701
+ const isFocal = focal && (a.label === focal || b.label === focal);
4702
+ const arrowCls = isFocal ? "kc-sw-arrow-focal" : "kc-sw-arrow";
4703
+ const headCls = isFocal ? "kc-sw-arrowhead-focal" : "kc-sw-arrowhead";
4704
+ if (a.laneIdx === b.laneIdx) {
4705
+ // Horizontal · from right edge of a to left edge of b
4706
+ const x1 = a.x + NODE_W;
4707
+ const x2 = b.x;
4708
+ const y = a.cy;
4709
+ connectorEls.push(`<line class="${arrowCls}" x1="${x1}" y1="${y}" x2="${x2}" y2="${y}"/>`);
4710
+ connectorEls.push(`<path class="${headCls}" d="M ${x2 - 5} ${y - 4} L ${x2} ${y} L ${x2 - 5} ${y + 4}"/>`);
4711
+ } else {
4712
+ // Elbow · right edge of a → down/up to b's lane → left edge of b
4713
+ const x1 = a.x + NODE_W;
4714
+ const x2 = b.x;
4715
+ const midX = Math.round((x1 + x2) / 2);
4716
+ const path = `M ${x1} ${a.cy} L ${midX} ${a.cy} L ${midX} ${b.cy} L ${x2} ${b.cy}`;
4717
+ connectorEls.push(`<path class="${arrowCls}" d="${path}"/>`);
4718
+ connectorEls.push(`<path class="${headCls}" d="M ${x2 - 5} ${b.cy - 4} L ${x2} ${b.cy} L ${x2 - 5} ${b.cy + 4}"/>`);
4719
+ }
4720
+ }
4721
+
4722
+ // Step nodes (reuse kc-fl-* classes for consistency)
4723
+ const nodeEls = stepGeo.map((s) => {
4724
+ const isFocal = focal && s.label === focal;
4725
+ const cls = isFocal ? "kc-fl-node-focal" : "kc-fl-node";
4726
+ const kickerCls = isFocal ? "kc-fl-kicker kc-fl-kicker-focal" : "kc-fl-kicker";
4727
+ const kicker = s.kicker || `STEP ${String(stepGeo.indexOf(s) + 1).padStart(2, "0")}`;
4728
+ const labelTxt = s.label.length > 18 ? s.label.slice(0, 17) + "…" : s.label;
4729
+ // Optional journey score · 5 dots (filled up to score)
4730
+ const scoreEls = s.score > 0
4731
+ ? [1, 2, 3, 4, 5].map((n) =>
4732
+ `<circle class="${n <= s.score ? "kc-sw-score-dot" : "kc-sw-score-dot-faint"}" cx="${s.cx - 16 + (n - 1) * 8}" cy="${s.y + NODE_H + 12}" r="2.4"/>`,
4733
+ ).join("")
4734
+ : "";
4735
+ return (
4736
+ `<rect class="${cls}" x="${s.x}" y="${s.y}" width="${NODE_W}" height="${NODE_H}" rx="6"/>` +
4737
+ `<text class="${kickerCls}" x="${s.x + 16}" y="${s.y + 16}">${escape(kicker.toUpperCase())}</text>` +
4738
+ `<text class="kc-fl-text" x="${s.cx}" y="${s.cy + 8}" text-anchor="middle">${escape(labelTxt)}</text>` +
4739
+ scoreEls
4740
+ );
4741
+ }).join("");
4742
+
4743
+ const figHeader =
4744
+ `<div class="kc-figheader">` +
4745
+ `<span class="kc-fignum">Figure${figNum ? " " + escape(figNum) : ""}</span>` +
4746
+ (title ? `<span class="kc-figtitle">${escape(title)}</span>` : "") +
4747
+ `</div>`;
4748
+
4749
+ const captionEl = caption
4750
+ ? `<p class="kc-caption">${inline(escape(caption))}</p>`
4751
+ : "";
4752
+
4753
+ return (
4754
+ `<figure class="kami-chart">` +
4755
+ figHeader +
4756
+ `<svg class="kc-svg" viewBox="0 0 ${VBW} ${totalH}" xmlns="http://www.w3.org/2000/svg" aria-label="${escape(title || "Swimlane")}">` +
4757
+ `<rect class="kc-canvas" width="100%" height="100%"/>` +
4758
+ laneEls.join("") +
4759
+ connectorEls.join("") +
4760
+ nodeEls +
4761
+ `</svg>` +
4762
+ captionEl +
4763
+ `</figure>`
4764
+ );
4765
+ }
4766
+
4767
+ /** State machine · 2–6 states in a linear row · forward arrows
4768
+ * auto-drawn between consecutive states (labels from `forwardLabels`
4769
+ * array, indexed by source position) · optional dashed back-arcs
4770
+ * drawn above for cycles. Replaces inline mermaid `stateDiagram-v2`. */
4771
+ function renderKamiStateMachine(data) {
4772
+ const title = typeof data.title === "string" ? data.title.trim() : "";
4773
+ const focal = typeof data.focal === "string" ? data.focal.trim() : "";
4774
+ const caption = typeof data.caption === "string" ? data.caption.trim() : "";
4775
+ const figNum = typeof data.figNum === "string" || typeof data.figNum === "number"
4776
+ ? String(data.figNum) : "";
4777
+ const statesRaw = Array.isArray(data.states) ? data.states : [];
4778
+ const states = [];
4779
+ for (const st of statesRaw) {
4780
+ if (!st || typeof st !== "object") continue;
4781
+ const label = typeof st.label === "string" ? st.label.trim() : "";
4782
+ if (!label) continue;
4783
+ const hint = typeof st.hint === "string" ? st.hint.trim() : "";
4784
+ const terminal = !!st.terminal;
4785
+ states.push({ label, hint, terminal });
4786
+ if (states.length >= 6) break;
4787
+ }
4788
+ if (states.length < 2) {
4789
+ return `<div class="kami-chart-error" data-err="too-few-states"></div>`;
4790
+ }
4791
+ const forwardLabels = Array.isArray(data.forwardLabels) ? data.forwardLabels : [];
4792
+ const backsRaw = Array.isArray(data.backTransitions) ? data.backTransitions : [];
4793
+ const backs = [];
4794
+ for (const b of backsRaw) {
4795
+ if (!b || typeof b !== "object") continue;
4796
+ const fromIdx = Number.isInteger(b.fromIdx) ? b.fromIdx : -1;
4797
+ const toIdx = Number.isInteger(b.toIdx) ? b.toIdx : -1;
4798
+ if (fromIdx < 0 || toIdx < 0 || fromIdx >= states.length || toIdx >= states.length) continue;
4799
+ if (fromIdx <= toIdx) continue; // back arcs must go right-to-left
4800
+ const label = typeof b.label === "string" ? b.label.trim() : "";
4801
+ backs.push({ fromIdx, toIdx, label });
4802
+ if (backs.length >= 4) break;
4803
+ }
4804
+ const showStart = !!data.showStart;
4805
+ const showEnd = !!data.showEnd;
4806
+
4807
+ // Geometry · viewBox 960 × 460, states arranged left-to-right.
4808
+ const VBW = 960, VBH = 380;
4809
+ const STATE_W = 140, STATE_H = 64;
4810
+ const ROW_Y = backs.length > 0 ? 184 : 140;
4811
+ const N = states.length;
4812
+
4813
+ // Compute total horizontal space (including optional start dot + end marker)
4814
+ const startSpace = showStart ? 60 : 0;
4815
+ const endSpace = showEnd ? 60 : 0;
4816
+ const innerSpace = VBW - 48 - startSpace - endSpace;
4817
+ const slotW = innerSpace / N;
4818
+ const stateX = (i) => 24 + startSpace + slotW * i + (slotW - STATE_W) / 2;
4819
+
4820
+ const stateEls = [];
4821
+ const arrowEls = [];
4822
+
4823
+ // States
4824
+ states.forEach((s, i) => {
4825
+ const x = stateX(i);
4826
+ const y = ROW_Y;
4827
+ const isFocal = focal && s.label === focal;
4828
+ const cls = s.terminal
4829
+ ? "kc-sm-state-terminal"
4830
+ : (isFocal ? "kc-sm-state-focal" : "kc-sm-state");
4831
+ const kickerCls = isFocal ? "kc-sm-kicker kc-sm-kicker-focal" : "kc-sm-kicker";
4832
+ const kickerTxt = s.terminal ? "TERMINAL" : (isFocal ? "FOCAL" : "STATE");
4833
+ const labelTxt = s.label.length > 16 ? s.label.slice(0, 15) + "…" : s.label;
4834
+ const hintTxt = s.hint && s.hint.length > 14 ? s.hint.slice(0, 13) + "…" : s.hint;
4835
+ stateEls.push(
4836
+ `<rect class="${cls}" x="${x}" y="${y}" width="${STATE_W}" height="${STATE_H}" rx="6"/>` +
4837
+ `<text class="${kickerCls}" x="${x + 16}" y="${y + 16}">${escape(kickerTxt)}</text>` +
4838
+ `<text class="kc-sm-state-label" x="${x + STATE_W / 2}" y="${y + 36}" text-anchor="middle">${escape(labelTxt)}</text>` +
4839
+ (hintTxt ? `<text class="kc-sm-state-hint" x="${x + STATE_W / 2}" y="${y + 52}" text-anchor="middle">${escape(hintTxt)}</text>` : ""),
4840
+ );
4841
+ });
4842
+
4843
+ // Forward arrows (consecutive)
4844
+ for (let i = 0; i < N - 1; i++) {
4845
+ const x1 = stateX(i) + STATE_W;
4846
+ const x2 = stateX(i + 1);
4847
+ const y = ROW_Y + STATE_H / 2;
4848
+ const a = states[i], b = states[i + 1];
4849
+ const isFocal = focal && (a.label === focal || b.label === focal);
4850
+ const arrowCls = isFocal ? "kc-sm-forward-focal" : "kc-sm-forward";
4851
+ const headCls = isFocal ? "kc-sm-arrowhead-focal" : "kc-sm-arrowhead";
4852
+ arrowEls.push(`<line class="${arrowCls}" x1="${x1}" y1="${y}" x2="${x2}" y2="${y}"/>`);
4853
+ arrowEls.push(`<path class="${headCls}" d="M ${x2 - 6} ${y - 4} L ${x2} ${y} L ${x2 - 6} ${y + 4}"/>`);
4854
+ const lbl = forwardLabels[i] && typeof forwardLabels[i] === "string" ? forwardLabels[i].trim() : "";
4855
+ if (lbl) {
4856
+ const midX = (x1 + x2) / 2;
4857
+ const lblTxt = lbl.toUpperCase();
4858
+ const w = lblTxt.length * 5 + 12;
4859
+ const labelCls = isFocal ? "kc-sm-transition-label kc-primary" : "kc-sm-transition-label";
4860
+ arrowEls.push(
4861
+ `<rect class="kc-sm-transition-label-bg" x="${midX - w / 2}" y="${y - 6}" width="${w}" height="12" rx="2"/>` +
4862
+ `<text class="${labelCls}" x="${midX}" y="${y + 3}" text-anchor="middle">${escape(lblTxt)}</text>`,
4863
+ );
4864
+ }
4865
+ }
4866
+
4867
+ // Back-arcs (dashed)
4868
+ backs.forEach((bk) => {
4869
+ const fromX = stateX(bk.fromIdx) + STATE_W / 2;
4870
+ const toX = stateX(bk.toIdx) + STATE_W / 2;
4871
+ const startX = fromX - STATE_W / 4;
4872
+ const endX = toX + STATE_W / 4;
4873
+ const apexY = ROW_Y - 56;
4874
+ const path = `M ${startX} ${ROW_Y} Q ${(startX + endX) / 2} ${apexY} ${endX} ${ROW_Y}`;
4875
+ arrowEls.push(`<path class="kc-sm-back" d="${path}"/>`);
4876
+ arrowEls.push(`<path class="kc-sm-arrowhead" d="M ${endX + 6} ${ROW_Y - 5} L ${endX} ${ROW_Y} L ${endX + 6} ${ROW_Y + 5}"/>`);
4877
+ if (bk.label) {
4878
+ const midX = (startX + endX) / 2;
4879
+ const lblTxt = bk.label.toUpperCase();
4880
+ const w = lblTxt.length * 5 + 14;
4881
+ arrowEls.push(
4882
+ `<rect class="kc-sm-transition-label-bg" x="${midX - w / 2}" y="${apexY + 8}" width="${w}" height="12" rx="2"/>` +
4883
+ `<text class="kc-sm-transition-label" x="${midX}" y="${apexY + 17}" text-anchor="middle">${escape(lblTxt)}</text>`,
4884
+ );
4885
+ }
4886
+ });
4887
+
4888
+ // Start dot
4889
+ if (showStart) {
4890
+ const cx = 24 + startSpace - 30;
4891
+ const cy = ROW_Y + STATE_H / 2;
4892
+ const firstX = stateX(0);
4893
+ arrowEls.push(`<circle class="kc-sm-marker-start" cx="${cx}" cy="${cy}" r="6"/>`);
4894
+ arrowEls.push(`<line class="kc-sm-forward" x1="${cx + 6}" y1="${cy}" x2="${firstX}" y2="${cy}"/>`);
4895
+ arrowEls.push(`<path class="kc-sm-arrowhead" d="M ${firstX - 6} ${cy - 4} L ${firstX} ${cy} L ${firstX - 6} ${cy + 4}"/>`);
4896
+ }
4897
+
4898
+ // End marker
4899
+ if (showEnd) {
4900
+ const cx = VBW - 24 - endSpace + 30;
4901
+ const cy = ROW_Y + STATE_H / 2;
4902
+ const lastX = stateX(N - 1) + STATE_W;
4903
+ arrowEls.push(`<line class="kc-sm-forward" x1="${lastX}" y1="${cy}" x2="${cx - 10}" y2="${cy}"/>`);
4904
+ arrowEls.push(`<path class="kc-sm-arrowhead" d="M ${cx - 16} ${cy - 4} L ${cx - 10} ${cy} L ${cx - 16} ${cy + 4}"/>`);
4905
+ arrowEls.push(`<circle class="kc-sm-marker-end-outer" cx="${cx}" cy="${cy}" r="8"/>`);
4906
+ arrowEls.push(`<circle class="kc-sm-marker-end-inner" cx="${cx}" cy="${cy}" r="4"/>`);
4907
+ }
4908
+
4909
+ const figHeader =
4910
+ `<div class="kc-figheader">` +
4911
+ `<span class="kc-fignum">Figure${figNum ? " " + escape(figNum) : ""}</span>` +
4912
+ (title ? `<span class="kc-figtitle">${escape(title)}</span>` : "") +
4913
+ `</div>`;
4914
+
4915
+ const captionEl = caption
4916
+ ? `<p class="kc-caption">${inline(escape(caption))}</p>`
4917
+ : "";
4918
+
4919
+ return (
4920
+ `<figure class="kami-chart">` +
4921
+ figHeader +
4922
+ `<svg class="kc-svg" viewBox="0 0 ${VBW} ${VBH}" xmlns="http://www.w3.org/2000/svg" aria-label="${escape(title || "State machine")}">` +
4923
+ `<rect class="kc-canvas" width="100%" height="100%"/>` +
4924
+ arrowEls.join("") +
4925
+ stateEls.join("") +
4926
+ `</svg>` +
4927
+ captionEl +
4928
+ `</figure>`
4929
+ );
4930
+ }
4931
+
3228
4932
  /** Parse a fenced ```path-comparison block (strict JSON) and emit
3229
4933
  * the side-by-side binary comparison: 2 columns, each with a mono
3230
4934
  * verdict tag, serif path name, and bullet characteristics. Stance
@@ -3520,17 +5224,15 @@
3520
5224
  }
3521
5225
  const body = lines.slice(start, end).join("\n");
3522
5226
  const idx = placeholders.length;
3523
- // Mermaid acceptance · the canonical fence is ```mermaid,
3524
- // but writers regularly emit ```quadrantChart /
3525
- // ```xychart-beta / ```flowchart / ```sequenceDiagram /
3526
- // etc. directly (the chart-type IS the lang in their head).
3527
- // Accept those bare type tags as mermaid too — otherwise
3528
- // they fall through to a plain `<pre class="codeblock">`
3529
- // and ship to the user as raw mermaid source. Catalog
3530
- // matches mermaid 10's diagram registry. The fenced body's
3531
- // first non-empty line is the actual mermaid type, so
3532
- // sanitizeMermaid handles the rest as before.
3533
- const MERMAID_TYPES = new Set([
5227
+ // Legacy mermaid blocks · all chart types migrated to the
5228
+ // `kami-chart` pipeline in v0.16. Old briefs in the DB still
5229
+ // carry ```mermaid (or bare ```quadrantChart / ```flowchart /
5230
+ // ```sequenceDiagram / etc.) render those as a clear
5231
+ // "legacy" placeholder so the section stays readable instead
5232
+ // of leaking raw mermaid source through the codeblock pre.
5233
+ // Catalog matches the mermaid 10 diagram registry the writer
5234
+ // used to emit.
5235
+ const LEGACY_MERMAID_TYPES = new Set([
3534
5236
  "mermaid",
3535
5237
  "quadrantchart", "xychart", "xychart-beta",
3536
5238
  "flowchart", "graph",
@@ -3546,24 +5248,22 @@
3546
5248
  "gitgraph",
3547
5249
  "requirementdiagram", "requirement",
3548
5250
  "c4context", "c4container", "c4component", "c4dynamic",
3549
- // newer / -beta types in mermaid 10+
3550
5251
  "sankey-beta", "sankey",
3551
5252
  "block-beta", "block",
3552
5253
  "info",
3553
5254
  "packet-beta", "packet",
3554
5255
  "architecture-beta", "architecture",
3555
5256
  ]);
3556
- if (MERMAID_TYPES.has(lang)) {
3557
- // For non-"mermaid" lang tags, the fence didn't include
3558
- // the type-as-first-line — prepend the ORIGINAL-cased
3559
- // lang token so mermaid's case-sensitive parser accepts
3560
- // it (e.g. "quadrantChart", not "quadrantchart").
3561
- const mermaidBody = (lang === "mermaid")
3562
- ? body
3563
- : `${langRaw}\n${body}`;
3564
- placeholders.push(`<pre class="mermaid">${escape(sanitizeMermaid(mermaidBody))}</pre>`);
5257
+ if (LEGACY_MERMAID_TYPES.has(lang)) {
5258
+ placeholders.push(
5259
+ `<div class="kami-chart-error" data-err="legacy-mermaid">` +
5260
+ `// legacy mermaid block · re-generate this brief to render in the current pipeline` +
5261
+ `</div>`,
5262
+ );
3565
5263
  } else if (lang === "metric-strip") {
3566
5264
  placeholders.push(renderMetricStrip(body));
5265
+ } else if (lang === "kami-chart") {
5266
+ placeholders.push(renderKamiChart(body));
3567
5267
  } else if (lang === "path-comparison") {
3568
5268
  placeholders.push(renderPathComparison(body));
3569
5269
  } else if (lang === "views-compared") {
@@ -4862,1370 +6562,6 @@
4862
6562
  // to missing sections (a report may skip Frame Shift, etc.).
4863
6563
  decorateReport(document.querySelector("[data-report-body]"));
4864
6564
 
4865
- // Render any mermaid blocks. Wrapped in try/catch — a syntax error in
4866
- // one diagram should not blank the whole report. The mermaid script is
4867
- // deferred, so it may not be available the instant `load()` resolves;
4868
- // poll up to 5s before giving up and leaving the raw fenced text in
4869
- // place as a graceful fallback.
4870
- if (document.querySelector(".mermaid")) {
4871
- const mermaid = await new Promise((resolve) => {
4872
- if (window.mermaid) return resolve(window.mermaid);
4873
- let elapsed = 0;
4874
- const t = setInterval(() => {
4875
- if (window.mermaid) { clearInterval(t); resolve(window.mermaid); }
4876
- else if ((elapsed += 50) > 5000) { clearInterval(t); resolve(null); }
4877
- }, 50);
4878
- });
4879
- if (mermaid) {
4880
- try {
4881
- // Pick mermaid theme variables based on the active spine.
4882
- // Each spine uses neutral quadrant fills (no colored tints —
4883
- // all four quadrants share one fill for a clean Gartner / BCG
4884
- // matrix look) and the spine's accent for plotted points.
4885
- const spineKey = (brief.spine && SPINES.has(brief.spine)) ? brief.spine : "boardroom-dark";
4886
- // Per-spine palette · `vars` carries every colour the
4887
- // mermaid theme block touches. Extended with `accent` /
4888
- // `accentSoft` (single accent for monochrome mindmap +
4889
- // gantt bars + sequence notes), `accentText` (legible text
4890
- // on accent fills), and `linkText` (faint text on link
4891
- // backgrounds) so flowchart / mindmap / gantt / sequence /
4892
- // state diagrams pick up spine-coherent colours instead of
4893
- // mermaid's rainbow defaults.
4894
- // Per-spine mermaid theme · every value here is taken from
4895
- // the spine's :root tokens (boardroom-dark.css / a16z-thesis.css /
4896
- // etc.) so charts feel like a native exhibit of the spine
4897
- // rather than a foreign visualization with its own palette.
4898
- // Audit cadence: when a spine adds / renames a token, mirror
4899
- // it here in the same change. Drift between spine tokens and
4900
- // these `vars` is what makes charts read as "off-brand".
4901
- //
4902
- // Mapping rules (consistent across spines):
4903
- // background → spine main bg (so chart canvas blends with frame)
4904
- // quadrantFill→ spine paper-soft / panel / bg-soft (slight contrast for inner zones)
4905
- // pointFill → primary brand/accent (data dots = load-bearing)
4906
- // titleFill → --ink / --text (max-contrast text)
4907
- // axisText → --ink-faint / --text-faint (subdued axis labels)
4908
- // border → --rule (NOT rule-strong) (matches body hairlines)
4909
- // inner → --rule-soft (lightest separator)
4910
- // accent → spine accent token (general fill)
4911
- // accentSoft → spine pale variant (washed accent for backgrounds)
4912
- // palette → 4-step monochrome ramp from accent (cycled across pie/journey/git slices)
4913
- const themes = {
4914
- "boardroom-dark": {
4915
- // theme: "base" + darkMode flag, NOT "dark". Mermaid 10's
4916
- // built-in "dark" preset injects a violet-tinged primary
4917
- // colour that bleeds into quadrant1Fill / quadrantPointFill
4918
- // and ALSO overrides our themeVariables in some code paths
4919
- // (the "Platform Strategy chart on boardroom-dark renders
4920
- // purple" bug). "base" uses minimal defaults so our
4921
- // explicit `vars` below are the only colour source.
4922
- base: "base",
4923
- darkMode: true,
4924
- // Frame bg: pre.mermaid uses --panel-2 (#1A1A18). Match it
4925
- // for the canvas so the SVG doesn't sit "inside" a darker
4926
- // tray. Quadrant inner boxes go to --bg (#0A0A0A) so the
4927
- // four boxes read as cut-outs against the canvas.
4928
- vars: {
4929
- background: "#1A1A18", // --panel-2 (matches pre.mermaid frame)
4930
- quadrantFill: "#0A0A0A", // --bg (cut-out zones inside chart)
4931
- quadrantText: "#8E8B83", // --text-soft
4932
- pointFill: "#C9A46B", // --em / --lime · load-bearing gold for data
4933
- pointText: "#C8C5BE", // --text
4934
- titleFill: "#C8C5BE", // --text
4935
- axisText: "#5C5A4D", // --text-faint (was too bright before)
4936
- border: "#3A3934", // --line-bright
4937
- inner: "#21211E", // --panel-3
4938
- accent: "#C9A46B", // --em (load-bearing gold)
4939
- accentSoft: "#5C4422", // --lime-dim
4940
- accentText: "#0A0A0A", // --bg for contrast on accent fills
4941
- },
4942
- // Gold for the lead, then beige-neutrals — multi-bar
4943
- // charts get one bold lead + 3 supporting tones rather
4944
- // than 4 competing golds.
4945
- palette: ["#C9A46B", "#B6B0A2", "#8E8B83", "#5C5A52"],
4946
- fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
4947
- },
4948
- "a16z-thesis": {
4949
- base: "default",
4950
- vars: {
4951
- background: "#F7F3E8", // --bg / --paper
4952
- quadrantFill: "#F2EAD8", // soft panel tone (between paper and rule-soft)
4953
- quadrantText: "#57503F", // --ink-mid
4954
- pointFill: "#8B6A2E", // --gold (precise spine value, was #876C2A)
4955
- pointText: "#14110C", // --ink
4956
- titleFill: "#14110C", // --ink
4957
- axisText: "#847B65", // --ink-faint
4958
- border: "#DCD3BD", // --rule (not --rule-strong)
4959
- inner: "#E5DECC", // --rule-soft
4960
- accent: "#8B6A2E", // --gold
4961
- accentSoft: "#C4A865", // --gold-pale
4962
- accentText: "#FFFFFF",
4963
- },
4964
- palette: ["#8B6A2E", "#A8843A", "#C4A865", "#E2CFA0"],
4965
- fontFamily: '"Inter", "Helvetica Neue", -apple-system, system-ui, sans-serif',
4966
- },
4967
- "anthropic-essay": {
4968
- base: "default",
4969
- vars: {
4970
- background: "#F4F0E8", // --paper (was --surface, slight off)
4971
- quadrantFill: "#EDE6D6", // --paper-soft
4972
- quadrantText: "#6B6359", // --ink-mid
4973
- pointFill: "#A85A41", // --clay-deep · stronger contrast for data
4974
- pointText: "#1A1814", // --ink (was --ink-soft, low contrast)
4975
- titleFill: "#1A1814", // --ink
4976
- axisText: "#978C7E", // --ink-faint
4977
- border: "#DDD5C8", // --rule
4978
- inner: "#E8E1D2", // --rule-soft
4979
- accent: "#CC785C", // --clay (spine's --accent resolves here, NOT clay-deep)
4980
- accentSoft: "#F2E0D5", // --clay-pale
4981
- accentText: "#FFFFFF",
4982
- },
4983
- palette: ["#CC785C", "#A85A41", "#B65A3A", "#F2E0D5"],
4984
- fontFamily: '"Charter", "Source Serif Pro", "Iowan Old Style", Georgia, serif',
4985
- },
4986
- "gartner-note": {
4987
- base: "default",
4988
- vars: {
4989
- background: "#FFFFFF", // --bg
4990
- quadrantFill: "#F5F7FA", // --bg-soft (was --bg-emphasis #FAFBFC)
4991
- quadrantText: "#455364", // --ink-soft
4992
- pointFill: "#0A4DA1", // --brand
4993
- pointText: "#1A2332", // --ink
4994
- titleFill: "#1A2332", // --ink
4995
- axisText: "#6B7785", // --ink-faint
4996
- border: "#DDE2E8", // --rule (was --rule-strong, too heavy)
4997
- inner: "#E8ECF1", // --rule-soft
4998
- accent: "#0A4DA1", // --brand
4999
- accentSoft: "#E8F1FB", // --brand-pale
5000
- accentText: "#FFFFFF",
5001
- },
5002
- palette: ["#0A4DA1", "#1A65BD", "#3F73B8", "#7AA0CF"],
5003
- fontFamily: '"Inter", "Helvetica Neue", Arial, sans-serif',
5004
- },
5005
- "mckinsey-deck": {
5006
- base: "default",
5007
- vars: {
5008
- background: "#FFFFFF", // --bg
5009
- quadrantFill: "#F8FAFD", // soft panel (slightly tinted)
5010
- quadrantText: "#4A5870", // --ink-soft
5011
- pointFill: "#2251FF", // --blue
5012
- pointText: "#051C2C", // --ink / --navy
5013
- titleFill: "#051C2C", // --navy
5014
- axisText: "#758296", // --ink-faint
5015
- border: "#D5DCE4", // --rule (was --rule-strong)
5016
- inner: "#E5EAF1", // --rule-soft
5017
- accent: "#2251FF", // --blue
5018
- accentSoft: "#E5EDFA", // --blue-pale
5019
- accentText: "#FFFFFF",
5020
- },
5021
- palette: ["#2251FF", "#1A3FBD", "#6285FF", "#B8C2D0"],
5022
- fontFamily: '"Inter", "Helvetica Neue", Arial, sans-serif',
5023
- },
5024
- "openai-paper": {
5025
- base: "default",
5026
- vars: {
5027
- background: "#FFFFFF", // --bg
5028
- quadrantFill: "#FAFAFA", // --paper
5029
- quadrantText: "#404040", // --ink-soft
5030
- pointFill: "#10A37F", // --teal
5031
- pointText: "#0D0D0D", // --ink
5032
- titleFill: "#0D0D0D", // --ink
5033
- axisText: "#6E6E80", // --ink-faint
5034
- border: "#E5E5E5", // --rule
5035
- inner: "#EFEFEF", // --rule-soft
5036
- accent: "#10A37F", // --teal
5037
- accentSoft: "#E6F6F0", // --teal-pale
5038
- accentText: "#FFFFFF",
5039
- },
5040
- palette: ["#10A37F", "#0E8C6D", "#52B89A", "#8FCDB7"],
5041
- fontFamily: '"Söhne", "Inter", -apple-system, system-ui, sans-serif',
5042
- },
5043
- };
5044
- const t = themes[spineKey];
5045
-
5046
- // Inject spine palette as CSS variables on :root so the
5047
- // CSS rules in the global <style> block (mermaid spine
5048
- // CSS) resolve to the right colours. This is the
5049
- // PRIMARY mechanism — CSS applies on every paint, no JS
5050
- // timing race possible. The JS takeover below stays as
5051
- // belt-and-suspenders for chart elements not covered by
5052
- // the CSS rules.
5053
- try {
5054
- const root = document.documentElement.style;
5055
- root.setProperty('--mm-q-fill', t.vars.quadrantFill);
5056
- root.setProperty('--mm-q-bg', t.vars.background);
5057
- root.setProperty('--mm-q-point', t.vars.pointFill);
5058
- root.setProperty('--mm-q-border', t.vars.border);
5059
- root.setProperty('--mm-q-text', t.vars.titleFill);
5060
- root.setProperty('--mm-q-axis-text', t.vars.axisText);
5061
- root.setProperty('--mm-q-quad-text', t.vars.quadrantText);
5062
- root.setProperty('--mm-text', t.vars.titleFill);
5063
- root.setProperty('--mm-axis-text', t.vars.axisText);
5064
- root.setProperty('--mm-pal-0', t.palette[0]);
5065
- root.setProperty('--mm-pal-1', t.palette[1]);
5066
- root.setProperty('--mm-pal-2', t.palette[2]);
5067
- root.setProperty('--mm-pal-3', t.palette[3]);
5068
- } catch (_) {}
5069
-
5070
- // Build pie / journey / state / git slice-color overrides
5071
- // by cycling through the spine's monochrome `palette`.
5072
- // Without these, mermaid 11+ paints pie1..pie12 +
5073
- // fillType0..7 + git0..7 with its built-in rainbow set
5074
- // (violet / teal / orange) — that's the source of the
5075
- // "white + purple" charts on the dark spine.
5076
- const sliceColors = {};
5077
- for (let i = 0; i < 12; i++) {
5078
- const c = t.palette[i % t.palette.length];
5079
- sliceColors[`pie${i + 1}`] = c;
5080
- }
5081
- for (let i = 0; i < 8; i++) {
5082
- const c = t.palette[i % t.palette.length];
5083
- sliceColors[`fillType${i}`] = c;
5084
- sliceColors[`git${i}`] = c;
5085
- sliceColors[`gitInv${i}`] = c;
5086
- }
5087
- // themeCSS shapes the rendered SVG beyond what themeVariables
5088
- // expose. The chart title is hidden because the markdown
5089
- // already renders an H3 caption directly above each chart —
5090
- // showing it twice is the single ugliest mermaid default.
5091
- // Hide every mermaid chart's built-in <text> title — the markdown
5092
- // already prints an H3 caption right above each diagram, and
5093
- // showing it twice is mermaid's single ugliest default. Apply
5094
- // across quadrant / xychart / pie / timeline.
5095
- const themeCSS = `
5096
- /* ── SVG canvas · paint the theme bg explicitly. Without
5097
- this, mermaid leaves the SVG transparent and the parent
5098
- <pre>'s background bleeds through inconsistently across
5099
- diagram types — most visibly on dark spines where the
5100
- browser default white shows through pie/journey gaps. */
5101
- svg { background: ${t.vars.background}; }
5102
- .pieOuterCircle { stroke: ${t.vars.border} !important; }
5103
- /* ── Quadrant chart ── */
5104
- g.quadrant-chart text { font-family: ${t.fontFamily}; }
5105
- g.quadrant-point > circle, .quadrant-point circle {
5106
- stroke: ${t.vars.background};
5107
- stroke-width: 2px;
5108
- }
5109
- text.quadrant-title,
5110
- .pieTitleText,
5111
- .xy-chart .title-text,
5112
- .timeline .title-text,
5113
- .timeline-title { display: none !important; }
5114
-
5115
- /* ── xychart-beta · refined bars ──
5116
- Bar WIDTH is slimmed by the post-render JS mutation
5117
- (see "xychart bar slimming" block after mermaid.run) --
5118
- CSS scaleX on SVG rects is unreliable across browsers,
5119
- so we mutate the rect attributes directly. Here we
5120
- only handle the COLOUR pass: solid accent fill, no
5121
- stroke, with per-position cycling through the spine's
5122
- 4-shade monochrome palette so multi-bar charts stagger
5123
- tones rather than reading as one flat block. */
5124
- .xy-chart .bar,
5125
- g.bar-plot .bar,
5126
- g.bar-plot rect,
5127
- g.plot > rect {
5128
- fill: ${t.vars.accent} !important;
5129
- stroke: none !important;
5130
- }
5131
- .xy-chart .bar:nth-child(2n),
5132
- g.bar-plot .bar:nth-child(2n),
5133
- g.bar-plot rect:nth-child(2n),
5134
- g.plot > rect:nth-child(2n) { fill: ${t.palette[1]} !important; }
5135
- .xy-chart .bar:nth-child(3n),
5136
- g.bar-plot .bar:nth-child(3n),
5137
- g.bar-plot rect:nth-child(3n),
5138
- g.plot > rect:nth-child(3n) { fill: ${t.palette[2]} !important; }
5139
- .xy-chart .bar:nth-child(4n),
5140
- g.bar-plot .bar:nth-child(4n),
5141
- g.bar-plot rect:nth-child(4n),
5142
- g.plot > rect:nth-child(4n) { fill: ${t.palette[3]} !important; }
5143
- /* Axis / grid refinement · 0.5px-feel hairlines, label
5144
- typography in the mono kicker register so the chart's
5145
- typography aligns with the rest of the doc. */
5146
- .xy-chart .background { fill: transparent !important; }
5147
- .xy-chart .x-axis line,
5148
- .xy-chart .y-axis line,
5149
- .xy-chart line.tick {
5150
- stroke: ${t.vars.border} !important;
5151
- stroke-width: 1px !important;
5152
- }
5153
- .xy-chart .x-axis path,
5154
- .xy-chart .y-axis path,
5155
- .xy-chart path.domain {
5156
- stroke: ${t.vars.titleFill} !important;
5157
- stroke-width: 1px !important;
5158
- fill: none !important;
5159
- }
5160
- .xy-chart .grid line,
5161
- .xy-chart .gridline,
5162
- .xy-chart line.grid {
5163
- stroke: ${t.vars.inner} !important;
5164
- stroke-width: 1px !important;
5165
- stroke-dasharray: 2 3 !important;
5166
- }
5167
- .xy-chart text,
5168
- .xy-chart .x-axis text,
5169
- .xy-chart .y-axis text {
5170
- font-family: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace !important;
5171
- font-size: 10px !important;
5172
- fill: ${t.vars.axisText} !important;
5173
- letter-spacing: 0.06em !important;
5174
- }
5175
- /* Data labels (showDataLabel) in the kicker register too. */
5176
- .xy-chart .data-label,
5177
- .xy-chart text.data-label {
5178
- font-family: "SF Mono", "JetBrains Mono", Menlo, monospace !important;
5179
- font-size: 10px !important;
5180
- fill: ${t.vars.titleFill} !important;
5181
- }
5182
-
5183
- /* ── Pie ── */
5184
- .pieCircle { stroke: ${t.vars.background}; stroke-width: 2px; }
5185
- text.slice, .pieLegendText { font-family: ${t.fontFamily}; }
5186
-
5187
- /* ── Timeline ── */
5188
- .timeline text { font-family: ${t.fontFamily}; }
5189
-
5190
- /* ── Flowchart · compact nodes + 1px hairline edges ──
5191
- Default flowchart text is 16px, padding generous, edges
5192
- heavy. Push to 12.5px / 1px / tighter so the diagram
5193
- reads as a precise schematic, not a presentation slide.
5194
- IMPORTANT: do NOT force a line-height larger than 1.2
5195
- on .nodeLabel — mermaid's foreignObject is sized at
5196
- measurement time using the inherited font-size at that
5197
- moment. If we then forcibly stretch line-height, the
5198
- actually-rendered text exceeds the foreignObject height
5199
- and the bottom of multi-line node labels gets clipped.
5200
- Also zero out the wrapper div / paragraph margins so
5201
- the inline-block measurement doesn't include browser
5202
- default <p> margins that mermaid won't account for. */
5203
- .flowchart foreignObject > div,
5204
- .label > foreignObject > div {
5205
- line-height: 1.2 !important;
5206
- margin: 0 !important;
5207
- padding: 0 !important;
5208
- }
5209
- .flowchart .nodeLabel,
5210
- .flowchart-label foreignObject div,
5211
- .label foreignObject div,
5212
- .label foreignObject p,
5213
- .label foreignObject span,
5214
- .nodeLabel,
5215
- .nodeLabel p,
5216
- .nodeLabel span {
5217
- font-family: ${t.fontFamily} !important;
5218
- font-size: 12.5px !important;
5219
- line-height: 1.2 !important;
5220
- margin: 0 !important;
5221
- padding: 0 !important;
5222
- color: ${t.vars.titleFill} !important;
5223
- }
5224
- .flowchart .edgeLabel,
5225
- .flowchart .edgeLabel p,
5226
- .edgeLabel foreignObject div,
5227
- .edgeLabel p,
5228
- .edgeLabel,
5229
- .edgeLabel span {
5230
- font-family: ${t.fontFamily} !important;
5231
- font-size: 11px !important;
5232
- line-height: 1.2 !important;
5233
- margin: 0 !important;
5234
- padding: 0 !important;
5235
- color: ${t.vars.axisText} !important;
5236
- background: ${t.vars.background} !important;
5237
- }
5238
- .flowchart .node rect,
5239
- .flowchart .node circle,
5240
- .flowchart .node ellipse,
5241
- .flowchart .node polygon,
5242
- .node rect,
5243
- .node circle,
5244
- .node polygon,
5245
- .node ellipse {
5246
- stroke-width: 1px !important;
5247
- stroke: ${t.vars.border} !important;
5248
- fill: ${t.vars.quadrantFill} !important;
5249
- }
5250
- .flowchart .edgePath path,
5251
- .flowchart-link,
5252
- .edgePath .path,
5253
- .flowchart-link path {
5254
- stroke: ${t.vars.border} !important;
5255
- stroke-width: 1px !important;
5256
- fill: none !important;
5257
- }
5258
- .flowchart .marker,
5259
- .flowchart .arrowheadPath,
5260
- #arrowhead, marker#arrowhead path {
5261
- fill: ${t.vars.border} !important;
5262
- stroke: ${t.vars.border} !important;
5263
- }
5264
- .cluster rect {
5265
- stroke-width: 1px !important;
5266
- stroke: ${t.vars.border} !important;
5267
- fill: ${t.vars.background} !important;
5268
- }
5269
- .cluster text { font-size: 11px !important; font-family: ${t.fontFamily} !important; fill: ${t.vars.axisText} !important; }
5270
-
5271
- /* ── Mindmap · monochrome accent (override rainbow) ── */
5272
- .mindmap g.node text,
5273
- .mindmap g.section text,
5274
- .mindmap g.section foreignObject div,
5275
- .mindmap-node text,
5276
- .mindmap-node .nodeLabel {
5277
- font-family: ${t.fontFamily} !important;
5278
- font-size: 12px !important;
5279
- fill: ${t.vars.titleFill} !important;
5280
- color: ${t.vars.titleFill} !important;
5281
- }
5282
- .mindmap-node circle,
5283
- .mindmap-node rect,
5284
- .mindmap-node polygon {
5285
- stroke-width: 1px !important;
5286
- stroke: ${t.vars.accent} !important;
5287
- }
5288
- .mindmap-edge,
5289
- .mindmap-edges path {
5290
- stroke: ${t.vars.border} !important;
5291
- stroke-width: 1px !important;
5292
- fill: none !important;
5293
- }
5294
-
5295
- /* ── Sequence diagram · compact actor boxes + thin arrows ── */
5296
- text.actor,
5297
- text.actor > tspan,
5298
- .actor-box-text {
5299
- font-family: ${t.fontFamily} !important;
5300
- font-size: 12px !important;
5301
- fill: ${t.vars.titleFill} !important;
5302
- }
5303
- .actor {
5304
- stroke-width: 1px !important;
5305
- stroke: ${t.vars.border} !important;
5306
- fill: ${t.vars.quadrantFill} !important;
5307
- }
5308
- .actor-line,
5309
- line.actor-line {
5310
- stroke: ${t.vars.border} !important;
5311
- stroke-width: 1px !important;
5312
- }
5313
- .messageText {
5314
- font-family: ${t.fontFamily} !important;
5315
- font-size: 11px !important;
5316
- fill: ${t.vars.titleFill} !important;
5317
- }
5318
- .messageLine0, .messageLine1 {
5319
- stroke-width: 1px !important;
5320
- stroke: ${t.vars.titleFill} !important;
5321
- }
5322
- .note,
5323
- rect.note {
5324
- stroke: ${t.vars.border} !important;
5325
- fill: ${t.vars.accentSoft} !important;
5326
- stroke-width: 1px !important;
5327
- }
5328
- .noteText,
5329
- text.noteText,
5330
- .note text {
5331
- font-family: ${t.fontFamily} !important;
5332
- font-size: 11px !important;
5333
- fill: ${t.vars.titleFill} !important;
5334
- }
5335
- .activation0, .activation1, .activation2 {
5336
- stroke: ${t.vars.border} !important;
5337
- fill: ${t.vars.inner} !important;
5338
- }
5339
-
5340
- /* ── State diagram (v2) ── */
5341
- .stateGroup rect,
5342
- .stateGroup circle,
5343
- .stateGroup line {
5344
- stroke-width: 1px !important;
5345
- }
5346
- .stateLabel,
5347
- .state-description,
5348
- .stateGroup text {
5349
- font-family: ${t.fontFamily} !important;
5350
- font-size: 11.5px !important;
5351
- fill: ${t.vars.titleFill} !important;
5352
- }
5353
- .transition {
5354
- stroke: ${t.vars.border} !important;
5355
- stroke-width: 1px !important;
5356
- fill: none !important;
5357
- }
5358
- .compositeBackground,
5359
- .composit {
5360
- fill: ${t.vars.background} !important;
5361
- stroke: ${t.vars.border} !important;
5362
- }
5363
- .stateGroup .innerCircle {
5364
- fill: ${t.vars.titleFill} !important;
5365
- }
5366
-
5367
- /* ── Gantt · tight bars + small grid ── */
5368
- .taskText,
5369
- .taskTextOutsideRight,
5370
- .taskTextOutsideLeft,
5371
- text.taskText {
5372
- font-family: ${t.fontFamily} !important;
5373
- font-size: 11px !important;
5374
- }
5375
- .tick text,
5376
- g.tick text {
5377
- font-family: ${t.fontFamily} !important;
5378
- font-size: 10px !important;
5379
- fill: ${t.vars.axisText} !important;
5380
- }
5381
- .grid .tick line,
5382
- g.grid line {
5383
- stroke: ${t.vars.inner} !important;
5384
- stroke-width: 0.5px !important;
5385
- }
5386
- .grid path { stroke-width: 0 !important; }
5387
- .titleText { display: none !important; }
5388
- .section0 text, .section1 text, .section2 text, .section3 text,
5389
- .sectionTitle0, .sectionTitle1, .sectionTitle2, .sectionTitle3 {
5390
- font-family: ${t.fontFamily} !important;
5391
- font-size: 11px !important;
5392
- fill: ${t.vars.axisText} !important;
5393
- }
5394
- rect.task,
5395
- rect.task0, rect.task1, rect.task2, rect.task3,
5396
- rect.activeTask0, rect.activeTask1, rect.activeTask2, rect.activeTask3 {
5397
- stroke-width: 0 !important;
5398
- }
5399
-
5400
- /* ── Journey · compact ── */
5401
- .journey-section text,
5402
- .journey-section foreignObject div {
5403
- font-family: ${t.fontFamily} !important;
5404
- font-size: 11px !important;
5405
- fill: ${t.vars.titleFill} !important;
5406
- }
5407
- .face-rect { stroke-width: 1px !important; }
5408
- `;
5409
- mermaid.initialize({
5410
- startOnLoad: false,
5411
- theme: t.base,
5412
- fontFamily: t.fontFamily,
5413
- themeCSS,
5414
- quadrantChart: {
5415
- // Refined-compact · was 640×480; the chart fits inside a
5416
- // 740–880px article column, and 480px tall reads as a
5417
- // presentation slide rather than an inline exhibit.
5418
- chartWidth: 460,
5419
- chartHeight: 320,
5420
- // Hide mermaid's own title slot — the H3 above the chart
5421
- // is the caption. Setting padding/size to 0 keeps the
5422
- // layout from leaving an empty band at the top.
5423
- titlePadding: 0,
5424
- titleFontSize: 0,
5425
- titleTextMargin: 0,
5426
- quadrantPadding: 8,
5427
- quadrantInternalBorderStrokeWidth: 0.5,
5428
- quadrantExternalBorderStrokeWidth: 1,
5429
- quadrantLabelFontSize: 11,
5430
- quadrantTextTopPadding: 10,
5431
- pointRadius: 5,
5432
- pointLabelFontSize: 11,
5433
- pointTextPadding: 6,
5434
- xAxisLabelFontSize: 11,
5435
- xAxisLabelPadding: 6,
5436
- yAxisLabelFontSize: 11,
5437
- yAxisLabelPadding: 6,
5438
- xAxisPosition: "bottom",
5439
- yAxisPosition: "left",
5440
- },
5441
- themeVariables: {
5442
- // Global default · drives mermaid's INTERNAL label
5443
- // measurement before SVG/foreignObject sizing. Setting
5444
- // it here keeps the box dimensions in sync with the
5445
- // CSS-rendered font-size, so multi-line flowchart node
5446
- // labels don't get vertically clipped (which they did
5447
- // when the CSS forced 12px after mermaid measured at
5448
- // its 16px default).
5449
- fontSize: "12.5px",
5450
- // darkMode flag tells mermaid whether to invert
5451
- // computed contrast pairs — required when we feed it
5452
- // dark surface colors via the "base" theme. Without
5453
- // this, mermaid computes text colors assuming a light
5454
- // canvas and ends up with low-contrast labels.
5455
- darkMode: t.darkMode === true,
5456
- background: t.vars.background,
5457
- // primaryColor MUST NOT be the quadrantFill (which is
5458
- // very dark on dark spines). Mermaid uses primaryColor
5459
- // as the seed for many derived colours (cScale shades,
5460
- // pie defaults, accent variations) — when it's near-
5461
- // black the derivations come out muddy. Use the spine's
5462
- // accent so derivations land in the brand family.
5463
- primaryColor: t.vars.accent,
5464
- primaryTextColor: t.vars.titleFill,
5465
- primaryBorderColor: t.vars.border,
5466
- lineColor: t.vars.border,
5467
- secondaryColor: t.vars.accentSoft,
5468
- tertiaryColor: t.vars.quadrantFill,
5469
- // All 4 quadrants share the same fill — clean matrix.
5470
- quadrant1Fill: t.vars.quadrantFill,
5471
- quadrant2Fill: t.vars.quadrantFill,
5472
- quadrant3Fill: t.vars.quadrantFill,
5473
- quadrant4Fill: t.vars.quadrantFill,
5474
- quadrant1TextFill: t.vars.quadrantText,
5475
- quadrant2TextFill: t.vars.quadrantText,
5476
- quadrant3TextFill: t.vars.quadrantText,
5477
- quadrant4TextFill: t.vars.quadrantText,
5478
- quadrantPointFill: t.vars.pointFill,
5479
- quadrantPointTextFill: t.vars.pointText,
5480
- quadrantTitleFill: t.vars.titleFill,
5481
- quadrantXAxisTextFill: t.vars.axisText,
5482
- quadrantYAxisTextFill: t.vars.axisText,
5483
- quadrantInternalBorderStrokeFill: t.vars.inner,
5484
- quadrantExternalBorderStrokeFill: t.vars.border,
5485
- // ── Flowchart / generic node-graph ──
5486
- mainBkg: t.vars.quadrantFill,
5487
- nodeBorder: t.vars.border,
5488
- clusterBkg: t.vars.background,
5489
- clusterBorder: t.vars.border,
5490
- defaultLinkColor: t.vars.border,
5491
- edgeLabelBackground: t.vars.background,
5492
- titleColor: t.vars.titleFill,
5493
- // ── Mindmap · single accent everywhere (override rainbow) ──
5494
- cScale0: t.vars.accent,
5495
- cScale1: t.vars.accent,
5496
- cScale2: t.vars.accent,
5497
- cScale3: t.vars.accent,
5498
- cScale4: t.vars.accent,
5499
- cScale5: t.vars.accent,
5500
- cScale6: t.vars.accent,
5501
- cScale7: t.vars.accent,
5502
- cScale8: t.vars.accent,
5503
- cScale9: t.vars.accent,
5504
- cScale10: t.vars.accent,
5505
- cScale11: t.vars.accent,
5506
- // ── Sequence diagram ──
5507
- actorBkg: t.vars.quadrantFill,
5508
- actorBorder: t.vars.border,
5509
- actorTextColor: t.vars.titleFill,
5510
- actorLineColor: t.vars.border,
5511
- signalColor: t.vars.titleFill,
5512
- signalTextColor: t.vars.titleFill,
5513
- labelBoxBkgColor: t.vars.quadrantFill,
5514
- labelBoxBorderColor: t.vars.border,
5515
- labelTextColor: t.vars.titleFill,
5516
- loopTextColor: t.vars.titleFill,
5517
- noteBkgColor: t.vars.accentSoft,
5518
- noteTextColor: t.vars.titleFill,
5519
- noteBorderColor: t.vars.border,
5520
- activationBorderColor: t.vars.border,
5521
- activationBkgColor: t.vars.inner,
5522
- // ── Gantt · single-accent bars + neutral grid ──
5523
- taskBkgColor: t.vars.accent,
5524
- taskTextColor: t.vars.accentText,
5525
- taskTextDarkColor: t.vars.titleFill,
5526
- taskTextLightColor: t.vars.accentText,
5527
- taskTextOutsideColor: t.vars.titleFill,
5528
- activeTaskBkgColor: t.vars.accent,
5529
- activeTaskBorderColor: t.vars.accent,
5530
- doneTaskBkgColor: t.vars.inner,
5531
- doneTaskBorderColor: t.vars.border,
5532
- critBkgColor: t.vars.accent,
5533
- critBorderColor: t.vars.accent,
5534
- gridColor: t.vars.inner,
5535
- sectionBkgColor: t.vars.background,
5536
- altSectionBkgColor: t.vars.quadrantFill,
5537
- sectionBkgColor2: t.vars.background,
5538
- todayLineColor: t.vars.accent,
5539
- // ── Pie / journey / state / git · monochrome palette
5540
- // Cycles t.palette across mermaid's per-slice color
5541
- // slots so these chart types stop falling back to
5542
- // mermaid's built-in violet/teal rainbow defaults
5543
- // (the white+purple leak on dark spines).
5544
- ...sliceColors,
5545
- // ── xychart-beta · single-accent bars + matched chrome.
5546
- // Mermaid generates xychart SVG using these tokens BEFORE
5547
- // our themeCSS overrides apply, so they have to be set
5548
- // here to get the right fill at render time (not just
5549
- // post-paint via CSS). Palette joined as comma-separated
5550
- // string per mermaid's plotColorPalette contract.
5551
- xyChart: {
5552
- backgroundColor: "transparent",
5553
- titleColor: t.vars.titleFill,
5554
- xAxisLabelColor: t.vars.axisText,
5555
- xAxisTitleColor: t.vars.titleFill,
5556
- xAxisTickColor: t.vars.border,
5557
- xAxisLineColor: t.vars.titleFill,
5558
- yAxisLabelColor: t.vars.axisText,
5559
- yAxisTitleColor: t.vars.titleFill,
5560
- yAxisTickColor: t.vars.border,
5561
- yAxisLineColor: t.vars.titleFill,
5562
- plotColorPalette: t.palette.join(","),
5563
- },
5564
- pieTitleTextSize: "0px",
5565
- pieTitleTextColor: t.vars.titleFill,
5566
- pieSectionTextColor: t.vars.titleFill,
5567
- pieSectionTextSize: "11px",
5568
- pieLegendTextColor: t.vars.titleFill,
5569
- pieLegendTextSize: "11px",
5570
- pieStrokeColor: t.vars.background,
5571
- pieStrokeWidth: "1px",
5572
- pieOuterStrokeColor: t.vars.border,
5573
- pieOuterStrokeWidth: "1px",
5574
- pieOpacity: "1",
5575
- },
5576
- flowchart: {
5577
- useMaxWidth: true,
5578
- htmlLabels: true,
5579
- // Tighter spacing · default 50/50/15 reads as a slide,
5580
- // these values render as a precise schematic. `padding`
5581
- // is the cluster (subgraph) padding, NOT node padding —
5582
- // mermaid sizes node boxes from text dimensions + an
5583
- // internal margin, so this value alone won't squeeze
5584
- // node labels. Keep at 12 for readable subgraph titles.
5585
- nodeSpacing: 36,
5586
- rankSpacing: 42,
5587
- padding: 12,
5588
- curve: "basis",
5589
- },
5590
- sequence: {
5591
- useMaxWidth: true,
5592
- diagramMarginX: 12,
5593
- diagramMarginY: 6,
5594
- actorMargin: 24,
5595
- boxMargin: 5,
5596
- boxTextMargin: 3,
5597
- noteMargin: 6,
5598
- messageMargin: 18,
5599
- mirrorActors: false,
5600
- bottomMarginAdj: 0,
5601
- actorFontSize: 11,
5602
- messageFontSize: 10,
5603
- noteFontSize: 10,
5604
- },
5605
- gantt: {
5606
- useMaxWidth: true,
5607
- fontSize: 10,
5608
- sectionFontSize: 10,
5609
- numberSectionStyles: 3,
5610
- leftPadding: 64,
5611
- topPadding: 18,
5612
- rightPadding: 18,
5613
- barGap: 3,
5614
- barHeight: 14,
5615
- gridLineStartPadding: 28,
5616
- },
5617
- mindmap: {
5618
- useMaxWidth: true,
5619
- padding: 6,
5620
- },
5621
- stateDiagram: {
5622
- useMaxWidth: true,
5623
- fontSize: 12,
5624
- titleTopMargin: 0,
5625
- padding: 6,
5626
- miniPadding: 4,
5627
- },
5628
- state: {
5629
- useMaxWidth: true,
5630
- fontSize: 12,
5631
- },
5632
- journey: {
5633
- useMaxWidth: true,
5634
- fontSize: 11,
5635
- taskFontSize: 11,
5636
- sectionFontSize: 12,
5637
- },
5638
- // ── xychart-beta · bar charts ──────────────────────────
5639
- // Tight margins, label hidden (caption above), and a single
5640
- // colour palette derived from the spine's pointFill so
5641
- // every bar in a chart shares a coherent voice. Mermaid
5642
- // 11+ accepts the `xyChart` key (older releases tolerated
5643
- // `xychart` — keep BOTH so the config isn't fragile across
5644
- // CDN bumps).
5645
- xyChart: {
5646
- // Was 720×360 · oversized for an inline exhibit. 560×260
5647
- // sits comfortably inside a 740px article column without
5648
- // pretending to be a presentation slide. Slightly shorter
5649
- // than the 320px we tried first — squat-ish proportions
5650
- // read as "data exhibit" while taller bars look like a
5651
- // pitch slide.
5652
- width: 560,
5653
- height: 260,
5654
- titlePadding: 0,
5655
- titleFontSize: 0,
5656
- showTitle: false,
5657
- showDataLabel: true,
5658
- // Refined bars · 50 → 32 → 20. mermaid xychart's bar
5659
- // width is `slotWidth × reservedPercent / 100`, where
5660
- // slotWidth scales inversely with bar count — so a value
5661
- // tuned for 8 bars looks chunky on 3 bars (each slot
5662
- // is ~3× wider). 20 keeps 8-12-bar charts legible while
5663
- // 3-bar charts come out as ~36px-wide thin bars rather
5664
- // than 60-90px filler blocks.
5665
- plotReservedSpacePercent: 20,
5666
- xAxis: { labelFontSize: 10, titleFontSize: 0, showTitle: false },
5667
- yAxis: { labelFontSize: 10, titleFontSize: 10 },
5668
- },
5669
- // Pie · legend on the right, slice labels disabled so wide
5670
- // labels don't push the chart off-screen. The legend
5671
- // already carries label + value (showData: true above).
5672
- pie: {
5673
- textPosition: 0.7,
5674
- useMaxWidth: true,
5675
- },
5676
- // Timeline · cards-on-rail; title suppressed (caption above).
5677
- timeline: {
5678
- padding: 16,
5679
- useMaxWidth: true,
5680
- },
5681
- });
5682
- // ── Off-screen render + paint-then-insert ────────────────
5683
- // DEFINITIVE fix for "first time wrong, refresh OK" /
5684
- // strict alternation pattern. Earlier strategies all let
5685
- // mermaid render in-place, then patched colors after —
5686
- // which gave the user a visible window of mermaid-default
5687
- // colors before our takeover landed.
5688
- //
5689
- // The only race-free approach: render OFFSCREEN, paint our
5690
- // spine palette onto the SVG while it's still detached,
5691
- // THEN insert into the visible DOM. The user's first paint
5692
- // sees the spine-colored SVG; there is no intermediate
5693
- // mermaid-default state to perceive.
5694
- //
5695
- // mermaid 10's `render(id, src)` returns the SVG as a
5696
- // string (not in-DOM) — perfect for this flow. We loop
5697
- // through every `pre.mermaid`, render its source via
5698
- // `mermaid.render`, parse the resulting string, apply the
5699
- // takeover to the parsed (still-detached) SVG, then swap
5700
- // the corrected SVG into the visible container.
5701
- // Belt-and-suspenders fill setter · sets BOTH the inline
5702
- // `style="fill: X !important"` AND the SVG `fill="X"`
5703
- // presentation attribute. With mermaid's embedded <style>
5704
- // already wiped, either layer is sufficient — but setting
5705
- // both means even if one is somehow stripped/overridden
5706
- // (browser quirk, late mermaid post-processing, CSS
5707
- // cascade edge case), the chart still renders in the
5708
- // spine palette.
5709
- const setFill = (el, c) => {
5710
- if (!c) return;
5711
- try { el.style.setProperty('fill', c, 'important'); } catch (_) {}
5712
- try { if (el.setAttribute) el.setAttribute('fill', c); } catch (_) {}
5713
- };
5714
- const setStroke = (el, c) => {
5715
- if (c === undefined || c === null) return;
5716
- try { el.style.setProperty('stroke', c, 'important'); } catch (_) {}
5717
- try { if (el.setAttribute) el.setAttribute('stroke', c); } catch (_) {}
5718
- };
5719
- const setColor = (el, c) => { if (c) el.style.setProperty('color', c, 'important'); };
5720
- const cyclePalette = (i) => t.palette[i % t.palette.length];
5721
-
5722
- // Apply the spine palette to ONE SVG element (offscreen or
5723
- // onscreen — works either way since every fill/stroke is
5724
- // forced via inline `!important`). Pure function: no
5725
- // global queries, only operates on the passed element.
5726
- const applySpineToSvg = (svg) => {
5727
- try {
5728
- // ── 0. Pin the SVG max-width per chart type ──
5729
- // Mermaid 10's chartWidth/chartHeight config is silently
5730
- // ignored by the bundled CDN build, so the SVG ships at
5731
- // its default size with `style="max-width: 500px"`
5732
- // inline. CSS in <style> overrides it at paint time, but
5733
- // there's a one-frame race on cold loads where the
5734
- // user can perceive the unconstrained size before CSS
5735
- // applies. Pin via inline style with !important here
5736
- // for refined-compact dimensions on every first paint.
5737
- const role = svg.getAttribute('aria-roledescription') || '';
5738
- const chartMaxWidth = (
5739
- role === 'quadrantChart' ? '460px' :
5740
- role === 'xychart' ? '560px' :
5741
- role === 'pie' ? '420px' :
5742
- ''
5743
- );
5744
- if (chartMaxWidth) {
5745
- svg.style.setProperty('max-width', chartMaxWidth, 'important');
5746
- svg.style.setProperty('height', 'auto', 'important');
5747
- svg.style.setProperty('margin', '0 auto', 'important');
5748
- svg.style.setProperty('display', 'block', 'important');
5749
- }
5750
- // ── 1. Wipe mermaid's embedded <style> block ──
5751
- // Mermaid generates `<style>.quadrant-chart > .quadrant-
5752
- // point > circle { fill: #...; }` etc. inside each SVG;
5753
- // those CSS rules beat our setAttribute-set fills. After
5754
- // removal, our inline-style fills below have no rivals.
5755
- svg.querySelectorAll(':scope > style, defs > style').forEach((s) => s.remove());
5756
-
5757
- // ── 2. SVG canvas / background ──
5758
- // Any rect that fills the whole SVG is the chart canvas;
5759
- // pin to t.vars.background so the chart blends with its
5760
- // spine frame instead of showing mermaid's default white.
5761
- const sw = parseFloat(svg.getAttribute('width') || '0');
5762
- svg.querySelectorAll(':scope > rect, :scope > g > rect.background, rect.main-bkg').forEach((el) => {
5763
- const w = parseFloat(el.getAttribute('width') || '0');
5764
- if (w > 0 && sw > 0 && w >= sw * 0.95) setFill(el, t.vars.background);
5765
- });
5766
-
5767
- // ── 3. Quadrant chart ──
5768
- // Mermaid 10's actual class names (verified against rendered DOM):
5769
- // `<g class="quadrant">` — group; each contains a child <rect>
5770
- // `.quadrants` — outer parent group
5771
- // `<circle class="data-point">` — each data circle
5772
- // `.data-points` — parent group
5773
- // `.axis-line` / `.axisl-line` — axis lines
5774
- // `.bottom-axis`, `.left-axis` — axis text containers
5775
- // CRITICAL: the `.quadrant` class is on a <g>, but the
5776
- // actual fillable rect is INSIDE the group. Selecting
5777
- // `.quadrants > .quadrant` matches the <g> only;
5778
- // setting style.fill on a <g> does NOT override the
5779
- // child <rect>'s `fill="..."` attribute in SVG. We
5780
- // must select the inner rect directly.
5781
- //
5782
- // Belt-and-suspenders: also match ALL <rect> inside
5783
- // any quadrantChart SVG (excluding the canvas-bg rect
5784
- // we already handled via dimensions). Catches any
5785
- // mermaid version variation in class naming.
5786
- svg.querySelectorAll('.quadrants .quadrant rect, g.quadrant > rect, rect.quadrant, rect.quadrant-fill, .quadrant-fill').forEach((el) => setFill(el, t.vars.quadrantFill));
5787
- if (svg.getAttribute('aria-roledescription') === 'quadrantChart') {
5788
- const qsw = parseFloat(svg.getAttribute('width') || '0');
5789
- svg.querySelectorAll('rect').forEach((el) => {
5790
- // Skip the canvas-bg rect (full-width).
5791
- const w = parseFloat(el.getAttribute('width') || '0');
5792
- if (qsw > 0 && w >= qsw * 0.95) return;
5793
- setFill(el, t.vars.quadrantFill);
5794
- });
5795
- }
5796
- // Quadrant border lines · mermaid 10 puts the outer
5797
- // rectangle + cross-divider lines inside `<g class="border">`,
5798
- // a sibling of `.quadrants` (NOT a descendant). Catch
5799
- // both the inside-quadrants axis-line case AND the
5800
- // border group case.
5801
- svg.querySelectorAll('.axis-line, .axisl-line, .quadrants line, g.border line, .border line').forEach((el) => setStroke(el, t.vars.border));
5802
- svg.querySelectorAll('.quadrant-internal-border, line.quadrant-internal-border').forEach((el) => setStroke(el, t.vars.inner));
5803
- svg.querySelectorAll('.quadrant-external-border, rect.quadrant-external-border').forEach((el) => {
5804
- setStroke(el, t.vars.border);
5805
- setFill(el, 'none');
5806
- });
5807
- svg.querySelectorAll('text.quadrant-title, .quadrant-title, .chart-title').forEach((el) => setFill(el, t.vars.titleFill));
5808
- // Quadrant labels (the 4 corner labels). Mermaid 10
5809
- // doesn't put them in a class; they're plain `<text>` inside
5810
- // each `.quadrant` group. Walk children of `.quadrant` text.
5811
- svg.querySelectorAll('.quadrant text, .quadrants text:not(.chart-title)').forEach((el) => setFill(el, t.vars.quadrantText));
5812
- svg.querySelectorAll('text.quadrant-text, text.quadrant-quadrant-1-text-fill, text.quadrant-quadrant-2-text-fill, text.quadrant-quadrant-3-text-fill, text.quadrant-quadrant-4-text-fill').forEach((el) => setFill(el, t.vars.quadrantText));
5813
- // Axis labels · "Low likelihood / High likelihood" etc.
5814
- svg.querySelectorAll('.bottom-axis text, .left-axis text, .top-axis text, .right-axis text, text.x-axis-text-label, text.y-axis-text-label, .quadrant-x-axis-label, .quadrant-y-axis-label').forEach((el) => setFill(el, t.vars.axisText));
5815
- // Data points · the load-bearing accent.
5816
- svg.querySelectorAll('.data-point, .data-points circle, circle.data-point, .quadrant-point > circle, g.quadrant-point > circle, circle.quadrant-point').forEach((el) => {
5817
- setFill(el, t.vars.pointFill);
5818
- setStroke(el, t.vars.background);
5819
- });
5820
- svg.querySelectorAll('.data-point text, .data-points text, text.point-text, .quadrant-point text').forEach((el) => setFill(el, t.vars.titleFill));
5821
-
5822
- // ── 4. Pie chart ──
5823
- svg.querySelectorAll('path.pieCircle, g.pieCircle path, .slice').forEach((el, i) => {
5824
- setFill(el, cyclePalette(i));
5825
- setStroke(el, t.vars.background);
5826
- });
5827
- svg.querySelectorAll('.pieOuterCircle, circle.pieOuterCircle').forEach((el) => {
5828
- setStroke(el, t.vars.border);
5829
- setFill(el, 'none');
5830
- });
5831
- svg.querySelectorAll('text.slice, text.pieLegendText, .legend text, g.legend text').forEach((el) => setFill(el, t.vars.titleFill));
5832
- svg.querySelectorAll('.pieTitleText').forEach((el) => setFill(el, t.vars.titleFill));
5833
-
5834
- // ── 5. xychart-beta ──
5835
- // Bars cycle through palette; axes + grid + labels match
5836
- // the spine's neutrals.
5837
- svg.querySelectorAll('g.plot rect, g.bar-plot rect, g[class*="-plot"] rect, rect.bar').forEach((el, i) => {
5838
- // Skip the plot-background rect (full plot width).
5839
- const w = parseFloat(el.getAttribute('width') || '0');
5840
- if (w >= 200) return;
5841
- setFill(el, cyclePalette(i));
5842
- setStroke(el, 'none');
5843
- });
5844
- svg.querySelectorAll('.xy-chart .background, rect.background').forEach((el) => setFill(el, 'transparent'));
5845
- svg.querySelectorAll('.xy-chart text, g.xy-chart text, .xy-chart .x-axis text, .xy-chart .y-axis text').forEach((el) => setFill(el, t.vars.axisText));
5846
- svg.querySelectorAll('.xy-chart .x-axis path.domain, .xy-chart .y-axis path.domain, .xy-chart .x-axis line, .xy-chart .y-axis line').forEach((el) => setStroke(el, t.vars.titleFill));
5847
- svg.querySelectorAll('.xy-chart .grid line, .xy-chart line.grid').forEach((el) => setStroke(el, t.vars.inner));
5848
-
5849
- // ── 6. Flowchart ──
5850
- svg.querySelectorAll('.flowchart .node rect, .flowchart .node circle, .flowchart .node ellipse, .flowchart .node polygon, .node rect, .node circle, .node polygon, .node ellipse').forEach((el) => {
5851
- setFill(el, t.vars.quadrantFill);
5852
- setStroke(el, t.vars.border);
5853
- });
5854
- svg.querySelectorAll('.flowchart-link, .edgePath path, .edgePath .path, path.flowchart-link').forEach((el) => {
5855
- setStroke(el, t.vars.border);
5856
- setFill(el, 'none');
5857
- });
5858
- svg.querySelectorAll('marker path, #arrowhead path, marker#arrowhead path').forEach((el) => {
5859
- setFill(el, t.vars.border);
5860
- setStroke(el, t.vars.border);
5861
- });
5862
- svg.querySelectorAll('.cluster rect').forEach((el) => {
5863
- setFill(el, t.vars.background);
5864
- setStroke(el, t.vars.border);
5865
- });
5866
- svg.querySelectorAll('.cluster text, .nodeLabel, .label foreignObject *').forEach((el) => {
5867
- setFill(el, t.vars.titleFill);
5868
- setColor(el, t.vars.titleFill);
5869
- });
5870
- svg.querySelectorAll('.edgeLabel, .edgeLabel foreignObject *').forEach((el) => {
5871
- setFill(el, t.vars.axisText);
5872
- setColor(el, t.vars.axisText);
5873
- el.style.setProperty('background-color', t.vars.background, 'important');
5874
- });
5875
-
5876
- // ── 7. Mindmap ──
5877
- svg.querySelectorAll('.mindmap g.section circle, .mindmap-node circle, g.mindmap-node circle').forEach((el, i) => {
5878
- setFill(el, cyclePalette(i));
5879
- setStroke(el, t.vars.border);
5880
- });
5881
- svg.querySelectorAll('.mindmap g.section rect, .mindmap-node rect, g.mindmap-node rect').forEach((el, i) => {
5882
- setFill(el, cyclePalette(i));
5883
- setStroke(el, t.vars.border);
5884
- });
5885
- svg.querySelectorAll('.mindmap g.section polygon, .mindmap-node polygon, g.mindmap-node polygon').forEach((el, i) => {
5886
- setFill(el, cyclePalette(i));
5887
- setStroke(el, t.vars.border);
5888
- });
5889
- svg.querySelectorAll('.mindmap-edges path, .mindmap path.edge').forEach((el) => {
5890
- setStroke(el, t.vars.border);
5891
- setFill(el, 'none');
5892
- });
5893
- svg.querySelectorAll('.mindmap text, .mindmap-node text, g.mindmap-node text, .mindmap foreignObject *').forEach((el) => {
5894
- setFill(el, t.vars.titleFill);
5895
- setColor(el, t.vars.titleFill);
5896
- });
5897
-
5898
- // ── 8. Sequence diagram ──
5899
- svg.querySelectorAll('rect.actor, .actor').forEach((el) => {
5900
- setFill(el, t.vars.quadrantFill);
5901
- setStroke(el, t.vars.border);
5902
- });
5903
- svg.querySelectorAll('line.actor-line, .actor-line').forEach((el) => setStroke(el, t.vars.border));
5904
- svg.querySelectorAll('text.actor, text.actor tspan, .actor-box-text, .messageText, text.messageText, .noteText, text.noteText, .note text').forEach((el) => setFill(el, t.vars.titleFill));
5905
- svg.querySelectorAll('.messageLine0, .messageLine1, line.messageLine0, line.messageLine1').forEach((el) => setStroke(el, t.vars.titleFill));
5906
- svg.querySelectorAll('rect.note, .note').forEach((el) => {
5907
- setFill(el, t.vars.accentSoft);
5908
- setStroke(el, t.vars.border);
5909
- });
5910
- svg.querySelectorAll('.activation0, .activation1, .activation2, rect.activation0, rect.activation1, rect.activation2').forEach((el) => {
5911
- setFill(el, t.vars.inner);
5912
- setStroke(el, t.vars.border);
5913
- });
5914
- svg.querySelectorAll('.labelBox, rect.labelBox').forEach((el) => {
5915
- setFill(el, t.vars.quadrantFill);
5916
- setStroke(el, t.vars.border);
5917
- });
5918
-
5919
- // ── 9. State diagram ──
5920
- svg.querySelectorAll('.stateGroup rect, g.stateGroup rect, rect.state').forEach((el) => {
5921
- setFill(el, t.vars.quadrantFill);
5922
- setStroke(el, t.vars.border);
5923
- });
5924
- svg.querySelectorAll('.stateGroup circle, g.stateGroup circle').forEach((el) => {
5925
- setFill(el, t.vars.titleFill);
5926
- setStroke(el, t.vars.titleFill);
5927
- });
5928
- svg.querySelectorAll('.stateGroup line, line.transition, path.transition').forEach((el) => {
5929
- setStroke(el, t.vars.border);
5930
- setFill(el, 'none');
5931
- });
5932
- svg.querySelectorAll('.stateLabel, .state-description, .stateGroup text').forEach((el) => setFill(el, t.vars.titleFill));
5933
- svg.querySelectorAll('.compositeBackground, rect.compositeBackground').forEach((el) => {
5934
- setFill(el, t.vars.background);
5935
- setStroke(el, t.vars.border);
5936
- });
5937
-
5938
- // ── 10. Journey ──
5939
- svg.querySelectorAll('.face-circle, circle.face-circle, .journey-section .face').forEach((el, i) => {
5940
- setFill(el, cyclePalette(i));
5941
- setStroke(el, t.vars.background);
5942
- });
5943
- svg.querySelectorAll('.journey-section text, .section text, .journey-section foreignObject *').forEach((el) => {
5944
- setFill(el, t.vars.titleFill);
5945
- setColor(el, t.vars.titleFill);
5946
- });
5947
- svg.querySelectorAll('rect.journey, .journey-section rect').forEach((el) => {
5948
- setFill(el, t.vars.quadrantFill);
5949
- setStroke(el, t.vars.border);
5950
- });
5951
-
5952
- // ── 11. Gantt ──
5953
- svg.querySelectorAll('rect.task, rect.task0, rect.task1, rect.task2, rect.task3, rect.activeTask0, rect.activeTask1, rect.activeTask2, rect.activeTask3').forEach((el, i) => {
5954
- setFill(el, cyclePalette(i));
5955
- setStroke(el, 'none');
5956
- });
5957
- svg.querySelectorAll('rect.done, rect.done0, rect.done1, rect.done2, rect.done3, rect.doneTask').forEach((el) => {
5958
- setFill(el, t.vars.inner);
5959
- setStroke(el, t.vars.border);
5960
- });
5961
- svg.querySelectorAll('rect.crit, rect.crit0, rect.crit1, rect.crit2, rect.crit3').forEach((el) => {
5962
- setFill(el, t.vars.accent);
5963
- setStroke(el, t.vars.accent);
5964
- });
5965
- svg.querySelectorAll('.taskText, .taskTextOutsideRight, .taskTextOutsideLeft, text.taskText').forEach((el) => setFill(el, t.vars.titleFill));
5966
- svg.querySelectorAll('.tick text, g.tick text').forEach((el) => setFill(el, t.vars.axisText));
5967
- svg.querySelectorAll('.tick line, .grid line, g.tick line, g.grid line').forEach((el) => setStroke(el, t.vars.inner));
5968
- svg.querySelectorAll('.section0 text, .section1 text, .section2 text, .section3 text, .sectionTitle0, .sectionTitle1, .sectionTitle2, .sectionTitle3').forEach((el) => setFill(el, t.vars.axisText));
5969
- svg.querySelectorAll('line.today, .today').forEach((el) => setStroke(el, t.vars.accent));
5970
-
5971
- // ── 12. Timeline ──
5972
- svg.querySelectorAll('.timeline rect, .timeline-section rect').forEach((el, i) => {
5973
- setFill(el, cyclePalette(i));
5974
- setStroke(el, t.vars.border);
5975
- });
5976
- svg.querySelectorAll('.timeline text, .timeline foreignObject *').forEach((el) => {
5977
- setFill(el, t.vars.titleFill);
5978
- setColor(el, t.vars.titleFill);
5979
- });
5980
- svg.querySelectorAll('.timeline path, .timeline-line').forEach((el) => {
5981
- setStroke(el, t.vars.border);
5982
- setFill(el, 'none');
5983
- });
5984
-
5985
- // ── 13. Git graph ──
5986
- svg.querySelectorAll('.commit-bullets circle, circle.commit, .commit').forEach((el, i) => {
5987
- setFill(el, cyclePalette(i));
5988
- setStroke(el, t.vars.border);
5989
- });
5990
- svg.querySelectorAll('.commit-arrows path, path.commit-arrow, .commit-arrow').forEach((el) => {
5991
- setStroke(el, t.vars.border);
5992
- setFill(el, 'none');
5993
- });
5994
- svg.querySelectorAll('text.commit-label, .commit-label, .branch-label').forEach((el) => setFill(el, t.vars.titleFill));
5995
-
5996
- // ── 14. Generic title (every chart type) ──
5997
- svg.querySelectorAll('text.titleText, .titleText, text.chart-title').forEach((el) => setFill(el, t.vars.titleFill));
5998
-
5999
- // ── 15. Universal text safety net ──
6000
- // Any text element that survived the per-type passes
6001
- // without an inline style fill — pin to titleFill so no
6002
- // text leaks the mermaid-default purple/blue.
6003
- svg.querySelectorAll('text').forEach((el) => {
6004
- if (!el.style.fill) setFill(el, t.vars.titleFill);
6005
- });
6006
-
6007
- // ── 16. Mark the parent pre.mermaid as painted ──
6008
- // CSS hides `.body pre.mermaid svg` until `.is-painted`
6009
- // is added on the parent. We add it here so that even
6010
- // when the SVG is later inserted into the live DOM, the
6011
- // CSS-based hide is already lifted (no flash).
6012
- const host = svg.closest('pre.mermaid');
6013
- if (host && !host.classList.contains('is-painted')) {
6014
- host.classList.add('is-painted');
6015
- }
6016
- } catch (forceColorErr) {
6017
- console.warn('[mermaid] palette takeover failed for svg:', forceColorErr);
6018
- }
6019
- }; // applySpineToSvg
6020
-
6021
- // Wrapper · walks every onscreen SVG and applies the
6022
- // takeover. Used by the safety polling + MutationObserver
6023
- // (defense-in-depth) for any SVGs that were already
6024
- // inserted before the offscreen-render loop took over.
6025
- const applySpinePaletteOnce = () => {
6026
- document.querySelectorAll('.mermaid svg').forEach(applySpineToSvg);
6027
- };
6028
-
6029
- // ── Offscreen render via mermaid.run + DOM relocation ─
6030
- // DEFINITIVE fix for "first load wrong / strict
6031
- // alternation." Keep mermaid.run (it handles lazy module
6032
- // loading for diagram types correctly — mermaid.render's
6033
- // first call races with that loading on cold cache),
6034
- // but move every pre.mermaid INTO AN OFFSCREEN CONTAINER
6035
- // before running. mermaid renders inside the offscreen
6036
- // container (user can't see it), we apply the spine
6037
- // takeover to every resulting SVG, then move each
6038
- // pre.mermaid back to its original DOM position. The
6039
- // user's first paint of each chart shows the
6040
- // spine-coloured SVG; no race possible.
6041
- //
6042
- // Diagnostic logs (console.info) so the user can verify
6043
- // which path was taken and at what timing — open DevTools
6044
- // and refresh; the log narrates the flow.
6045
- const _mLog = (msg, ...rest) => {
6046
- try { console.info(`[mermaid-spine] ${msg}`, ...rest); } catch (_) {}
6047
- };
6048
- _mLog('flow start · spine =', spineKey);
6049
- const charts = Array.from(document.querySelectorAll('pre.mermaid'));
6050
- _mLog('charts found:', charts.length);
6051
- if (charts.length > 0) {
6052
- // Capture original DOM positions so we can restore each
6053
- // pre.mermaid exactly where it was.
6054
- const anchors = charts.map((el) => ({
6055
- el,
6056
- parent: el.parentNode,
6057
- nextSibling: el.nextSibling,
6058
- }));
6059
- // Build the offscreen host. Width matches a typical
6060
- // article column so mermaid sizes the SVG correctly
6061
- // (useMaxWidth: true uses the offscreen container's
6062
- // dimensions for layout). Height: 0 + overflow: visible
6063
- // so mermaid can compute proper SVG sizes.
6064
- const offscreen = document.createElement('div');
6065
- offscreen.id = 'mermaid-offscreen-host';
6066
- offscreen.style.cssText = [
6067
- 'position: absolute',
6068
- 'left: -99999px',
6069
- 'top: 0',
6070
- 'width: 880px',
6071
- 'visibility: hidden',
6072
- 'pointer-events: none',
6073
- 'z-index: -1',
6074
- ].join('; ');
6075
- document.body.appendChild(offscreen);
6076
- // Move each pre.mermaid into the offscreen host.
6077
- anchors.forEach((a) => offscreen.appendChild(a.el));
6078
- _mLog('moved offscreen, calling mermaid.run');
6079
- // Run mermaid · scoped to the offscreen host so it only
6080
- // touches elements we just moved. mermaid handles lazy
6081
- // module registration internally.
6082
- try {
6083
- await mermaid.run({ querySelector: '#mermaid-offscreen-host pre.mermaid' });
6084
- _mLog('mermaid.run resolved');
6085
- } catch (runErr) {
6086
- console.warn('[mermaid-spine] offscreen run failed:', runErr);
6087
- }
6088
- // Wait for mermaid's deferred post-render work to land.
6089
- // Quadrant chart (specifically) does d3-based label
6090
- // positioning in a setTimeout(0) AFTER mermaid.run
6091
- // resolves — if we apply takeover before that fires,
6092
- // mermaid overwrites our fills. Two rAF + setTimeout(0)
6093
- // cycles guarantee deferred work has settled. Cheap
6094
- // (~32ms) and only runs once per page load.
6095
- await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 0))));
6096
- // Apply takeover to all SVGs while still offscreen.
6097
- const svgsOffscreen = offscreen.querySelectorAll('svg');
6098
- _mLog('SVGs rendered offscreen:', svgsOffscreen.length);
6099
- svgsOffscreen.forEach(applySpineToSvg);
6100
- _mLog('takeover applied offscreen');
6101
- // Move each pre.mermaid back to its original position
6102
- // and mark as painted (CSS reveal rule). Single DOM
6103
- // mutation per chart → single paint frame with the
6104
- // corrected SVG already in place.
6105
- anchors.forEach((a) => {
6106
- a.el.classList.add('is-painted');
6107
- if (a.parent) {
6108
- if (a.nextSibling && a.nextSibling.parentNode === a.parent) {
6109
- a.parent.insertBefore(a.el, a.nextSibling);
6110
- } else {
6111
- a.parent.appendChild(a.el);
6112
- }
6113
- }
6114
- // Re-apply takeover immediately after move · catches
6115
- // any post-attach mermaid behaviour (ResizeObserver
6116
- // re-renders, deferred rAF) that might restyle.
6117
- a.el.querySelectorAll('svg').forEach(applySpineToSvg);
6118
- });
6119
- _mLog('moved back onscreen + reapplied takeover');
6120
- // Cleanup offscreen host.
6121
- offscreen.remove();
6122
- }
6123
-
6124
- // Safety net · if the takeover errors out for some reason
6125
- // (script bug, exotic chart type), make sure every chart
6126
- // becomes visible within 6s so the user at least sees the
6127
- // chart (even if colours leak) instead of an empty frame.
6128
- // Normal flow: each successful takeover pass marks its
6129
- // pre.mermaid as `.is-painted` immediately, so the chart
6130
- // appears as soon as the first pass succeeds (~16ms).
6131
- setTimeout(() => {
6132
- document.querySelectorAll('pre.mermaid:not(.is-painted)').forEach((el) => {
6133
- el.classList.add('is-painted');
6134
- });
6135
- }, 6000);
6136
-
6137
- // ── Continuous re-apply for the first 5 seconds ──
6138
- // The user reported strict alternation ("1st wrong, 2nd
6139
- // right, 3rd wrong, 4th right…") which means a single
6140
- // setTimeout chain wasn't enough — mermaid's deferred
6141
- // work was landing in different timing windows on
6142
- // alternate loads, and my chain was missing odd-numbered
6143
- // ones.
6144
- //
6145
- // Defense in depth: re-apply EVERY 60ms for the first 5
6146
- // seconds. By the time the user perceives the chart, our
6147
- // styles have been pinned 80+ times across every possible
6148
- // mermaid post-render window. After 5s, switch to a pure
6149
- // MutationObserver (only fires on real DOM changes) so we
6150
- // keep up with future mutations without the polling cost.
6151
- //
6152
- // Each call is idempotent — same selectors set the same
6153
- // inline-style values, so re-running is a cheap no-op
6154
- // when nothing changed.
6155
- applySpinePaletteOnce(); // sync, before next paint
6156
- let pollCount = 0;
6157
- const pollInterval = setInterval(() => {
6158
- applySpinePaletteOnce();
6159
- if (++pollCount > 80) clearInterval(pollInterval); // 80 × 60ms ≈ 4.8s
6160
- }, 60);
6161
-
6162
- // Persistent MutationObserver · catches any post-poll
6163
- // mutations (container resize, font swap, late SSE
6164
- // re-render). Watches structural changes only — our own
6165
- // setProperty calls modify the `style` attribute, which
6166
- // is excluded by `attributes: false` (default), so this
6167
- // can't loop on itself.
6168
- document.querySelectorAll('.mermaid svg').forEach((svg) => {
6169
- const obs = new MutationObserver(() => {
6170
- requestAnimationFrame(applySpinePaletteOnce);
6171
- });
6172
- obs.observe(svg, { childList: true, subtree: true });
6173
- });
6174
- // ── Post-render xychart bar slimming ─────────────────────
6175
- // mermaid xychart-beta renders bars at near-full slot width
6176
- // regardless of `plotReservedSpacePercent` in some 11.x
6177
- // versions, and CSS transform: scaleX(...) on the bar rects
6178
- // is unreliable across browsers (transform-box: fill-box
6179
- // has Chrome/Safari gaps for SVG rect). Direct attribute
6180
- // mutation is bulletproof: we walk every rendered xychart
6181
- // SVG, identify the bar rects via class + geometric
6182
- // heuristic (rect inside g.plot, height > width, not full-
6183
- // plot wide), and shrink each rect's `width` to ~50% with
6184
- // a matching `x` shift so it stays centered in its slot.
6185
- // Result: a 3-bar chart goes from 3 chunky filler blocks
6186
- // to 3 thin accents on a wide whitespace canvas — refined-
6187
- // exhibit, not slide. */
6188
- try {
6189
- const xyHosts = document.querySelectorAll('.mermaid');
6190
- xyHosts.forEach((host) => {
6191
- const svg = host.querySelector('svg');
6192
- if (!svg) return;
6193
- // Only xychart-beta SVGs · skip quadrant / pie / etc.
6194
- if (!svg.matches('[id^="xychart"], [aria-roledescription="xychart"]')
6195
- && !svg.querySelector('g.plot, g.bar-plot, g[class*="-plot"], rect.bar')) {
6196
- return;
6197
- }
6198
- // Catch rects across mermaid 11.x naming conventions.
6199
- const candidates = svg.querySelectorAll(
6200
- 'rect.bar, g.bar-plot rect, g.plot rect, g[class*="plot"] > rect'
6201
- );
6202
- candidates.forEach((rect) => {
6203
- const x = parseFloat(rect.getAttribute('x'));
6204
- const w = parseFloat(rect.getAttribute('width'));
6205
- const h = parseFloat(rect.getAttribute('height'));
6206
- if (!Number.isFinite(x) || !Number.isFinite(w) || !Number.isFinite(h)) return;
6207
- // Skip the plot-background rect (full plot width).
6208
- // Don't filter on h-vs-w ratio — that catches short
6209
- // bars (small data values) and leaves them at full
6210
- // width while their taller siblings shrink, giving
6211
- // a mismatched chart.
6212
- if (w >= 200) return;
6213
- const scale = 0.5;
6214
- const newW = w * scale;
6215
- rect.setAttribute('width', newW.toFixed(2));
6216
- rect.setAttribute('x', (x + (w - newW) / 2).toFixed(2));
6217
- });
6218
- });
6219
- } catch (slimErr) {
6220
- console.warn('[mermaid] xychart bar slimming failed:', slimErr);
6221
- }
6222
- } catch (e) {
6223
- // Per-diagram failures already render as inline error overlays —
6224
- // log and move on.
6225
- console.warn("[mermaid] render failed:", e);
6226
- }
6227
- }
6228
- }
6229
6565
  }
6230
6566
 
6231
6567
  /** Subscribe to room SSE so the title live-updates when the brief
@@ -6282,7 +6618,6 @@
6282
6618
  });
6283
6619
  })();
6284
6620
  </script>
6285
- <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js" defer></script>
6286
6621
 
6287
6622
  </body>
6288
6623
  </html>