privateboard 0.1.4 → 0.1.6

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.
@@ -54,6 +54,83 @@
54
54
  checkbox and produces a PDF that's universally readable on
55
55
  screen and on physical paper. Other spines are already light
56
56
  and pass through unchanged. */
57
+
58
+ /* ─── CJK font dispatch · GLOBAL (screen AND print) ───────────
59
+ Chrome does NOT do per-glyph cascade walking reliably in the
60
+ print pipeline: it picks the first font that "looks right for
61
+ the script" and stays there. With a cascade of
62
+ "Helvetica Neue, Inter, Arial, PingFang SC, ..." Chrome lands
63
+ on Arial (advertises minimal CJK coverage), renders every
64
+ Chinese ideograph as `.notdef` (invisible / tofu), and the
65
+ PingFang SC entry NEVER gets tried. Result: empty Chinese
66
+ text in the saved PDF, even on a Mac with PingFang installed
67
+ system-wide.
68
+
69
+ Fix: register three named handles — `cjk-sans`, `cjk-serif`,
70
+ `cjk-mono` — each scoped to the CJK Unicode ranges via
71
+ `unicode-range`. When a CJK code point hits the cascade,
72
+ Chrome is FORCED to dispatch to the CJK handle (only that
73
+ handle covers the range), which uses `local()` to pick
74
+ PingFang / Hiragino / Songti / STSong from the OS. Latin
75
+ code points fall through to the next font as before, so
76
+ Latin display still renders in Helvetica / Charter / Menlo.
77
+
78
+ OUTSIDE @media print so it applies on both screen + print.
79
+ Some CJK selectors live outside @media print (e.g.
80
+ body.is-cjk .cover-title) and need the same dispatch. */
81
+ /* Why STSong leads each src list:
82
+ - macOS regular Chrome resolves PingFang SC fine — but headless
83
+ Chrome (and the same user-action Save-as-PDF on some configs)
84
+ walks `local()` and gives up after the first miss instead of
85
+ trying the rest. Putting a guaranteed-available font (STSong
86
+ ships with macOS since 10.0) first means we always have at
87
+ least ONE working dispatcher; the more aesthetic faces sit
88
+ behind it for environments that DO resolve them.
89
+ - Empirically: cjk-serif (Songti SC/STSong leading) worked while
90
+ cjk-sans (PingFang SC leading) didn't — same chain otherwise,
91
+ only the lead changed. So the FIRST local() is what matters
92
+ most; later entries are best-effort improvements. */
93
+ @font-face {
94
+ font-family: "cjk-sans";
95
+ src: local("STSong"),
96
+ local("PingFang SC"), local("PingFangSC-Regular"),
97
+ local("Hiragino Sans GB"), local("HiraginoSansGB-W3"),
98
+ local("Source Han Sans CN"), local("Noto Sans CJK SC"),
99
+ local("Songti SC"), local("STHeiti");
100
+ unicode-range: U+1100-11FF, U+2E80-2FFF, U+3000-303F,
101
+ U+3040-309F, U+30A0-30FF, U+3100-312F,
102
+ U+3130-318F, U+3190-319F, U+31F0-31FF,
103
+ U+3200-32FF, U+3300-33FF, U+3400-4DBF,
104
+ U+4E00-9FFF, U+A000-A4CF, U+A960-A97F,
105
+ U+AC00-D7AF, U+F900-FAFF, U+FE30-FE4F,
106
+ U+FF00-FFEF;
107
+ }
108
+ @font-face {
109
+ font-family: "cjk-serif";
110
+ src: local("STSong"), local("Songti SC"),
111
+ local("Source Han Serif CN"), local("Noto Serif CJK SC"),
112
+ local("PingFang SC"), local("Hiragino Sans GB");
113
+ unicode-range: U+1100-11FF, U+2E80-2FFF, U+3000-303F,
114
+ U+3040-309F, U+30A0-30FF, U+3100-312F,
115
+ U+3130-318F, U+3190-319F, U+31F0-31FF,
116
+ U+3200-32FF, U+3300-33FF, U+3400-4DBF,
117
+ U+4E00-9FFF, U+A000-A4CF, U+A960-A97F,
118
+ U+AC00-D7AF, U+F900-FAFF, U+FE30-FE4F,
119
+ U+FF00-FFEF;
120
+ }
121
+ @font-face {
122
+ font-family: "cjk-mono";
123
+ src: local("STSong"), local("PingFang SC"),
124
+ local("Hiragino Sans GB"), local("Songti SC");
125
+ unicode-range: U+1100-11FF, U+2E80-2FFF, U+3000-303F,
126
+ U+3040-309F, U+30A0-30FF, U+3100-312F,
127
+ U+3130-318F, U+3190-319F, U+31F0-31FF,
128
+ U+3200-32FF, U+3300-33FF, U+3400-4DBF,
129
+ U+4E00-9FFF, U+A000-A4CF, U+A960-A97F,
130
+ U+AC00-D7AF, U+F900-FAFF, U+FE30-FE4F,
131
+ U+FF00-FFEF;
132
+ }
133
+
57
134
  @media print {
58
135
  *, *::before, *::after {
59
136
  print-color-adjust: exact !important;
@@ -179,28 +256,26 @@
179
256
  border-color: #DDD5C8 !important;
180
257
  }
181
258
 
182
- /* ─── CJK font fallback for print ────────────────────────────
183
- PingFang SC anchors every cascade so Chinese glyphs render as
184
- PingFang globally (user preference). Songti SC sits deep as
185
- a last-resort fallback for the rare headless Chrome PDF
186
- contexts where PingFang's font directory isn't reachable
187
- the browser falls through to it automatically when PingFang
188
- can't be loaded. Latin text still renders in the spine's
189
- preferred face; only CJK falls through to PingFang. */
259
+ /* CJK dispatch handles (cjk-sans / cjk-serif / cjk-mono) are
260
+ declared OUTSIDE this @media block see the @font-face
261
+ trio at the top of <style>. Per-element font-family rules
262
+ below reference those handles so CJK glyphs route to
263
+ PingFang / Songti / etc. via `local()`. */
190
264
 
191
- /* Sans-anchored body text · Latin in Helvetica/Inter, CJK in PingFang SC. */
265
+ /* Sans-anchored body text · Latin in Helvetica/Inter, CJK
266
+ routed through cjk-sans (which `local()`s to PingFang SC). */
192
267
  body, .body, .body p, .body li,
193
268
  .body td, .body th,
194
269
  .rec-rationale, .rec-risk, .rec-risk-text, .rec-meta-value, .rec-action,
195
270
  .nq-why, .cover-deck {
196
- font-family: "Helvetica Neue", "Inter", "Arial",
197
- "PingFang SC", "PingFang TC", "Hiragino Sans GB",
198
- "Source Han Sans CN", "Noto Sans CJK SC",
199
- "Songti SC", "STSong",
271
+ font-family: "cjk-sans",
272
+ "Helvetica Neue", "Inter", "Arial",
200
273
  sans-serif !important;
201
274
  }
202
275
  /* Serif-anchored display copy · headlines, italic claims, big-idea
203
- kickers — the typographic moments worth preserving in serif. */
276
+ kickers — the typographic moments worth preserving in serif.
277
+ CJK ranges route to cjk-serif (which prefers Songti for the
278
+ serif voice, falling back to PingFang). */
204
279
  .body h1, .body h2, .body h3, .body h4,
205
280
  .body section.section-bottom-line p,
206
281
  .body section.section-thesis p,
@@ -208,15 +283,15 @@
208
283
  .body section.section-headline-findings .pillar h3,
209
284
  .body section.section-big-ideas ol > li p:first-child strong,
210
285
  .nq-question, .cover-title {
211
- font-family: "Charter", "Source Serif Pro", "Iowan Old Style",
286
+ font-family: "cjk-serif",
287
+ "Charter", "Source Serif Pro", "Iowan Old Style",
212
288
  "Georgia",
213
- "PingFang SC", "PingFang TC", "Hiragino Sans GB",
214
- "Source Han Sans CN", "Noto Sans CJK SC",
215
- "Songti SC", "STSong",
216
- "Source Han Serif CN", "Noto Serif CJK SC",
217
289
  serif !important;
218
290
  }
219
- /* Mono kickers / labels stay mono with PingFang SC as CJK fallback. */
291
+ /* Mono kickers / labels · CJK chars in mono context don't really
292
+ have a mono face, so cjk-mono falls back to PingFang SC. The
293
+ result is mono-styled Latin labels with PingFang Han for the
294
+ rare CJK character that lands in a kicker. */
220
295
  .top-rule .crumb,
221
296
  .body .chapter-num,
222
297
  .cover-tag, .cover-tag .secondary,
@@ -231,10 +306,9 @@
231
306
  .body section.section-methodology strong,
232
307
  .body code,
233
308
  .body table.md-table th {
234
- font-family: "SF Mono", "JetBrains Mono", "Menlo",
309
+ font-family: "cjk-mono",
310
+ "SF Mono", "JetBrains Mono", "Menlo",
235
311
  "Helvetica Neue", "Arial",
236
- "PingFang SC", "PingFang TC", "Hiragino Sans GB",
237
- "Source Han Sans CN", "Songti SC", "STSong",
238
312
  monospace !important;
239
313
  }
240
314
 
@@ -1450,15 +1524,16 @@
1450
1524
  }
1451
1525
  .colophon-cta-url {
1452
1526
  display: inline-block;
1453
- color: var(--text, #1A1A1A);
1527
+ color: var(--text-soft, #6B6B6B);
1454
1528
  font-family: var(--mono);
1455
- font-size: 14px;
1456
- font-weight: 600;
1457
- letter-spacing: 0.01em;
1529
+ font-size: 11px;
1530
+ font-weight: 400;
1531
+ letter-spacing: 0.02em;
1458
1532
  text-decoration: none;
1459
1533
  margin-top: 2px;
1534
+ opacity: 0.78;
1460
1535
  }
1461
- .colophon-cta-url:hover { text-decoration: underline; }
1536
+ .colophon-cta-url:hover { text-decoration: underline; opacity: 1; }
1462
1537
  /* Backwards-compat · the "·" separator from the prior horizontal
1463
1538
  layout no longer renders meaningfully in a vertical stack. Keep
1464
1539
  the selector defined so old markup doesn't error if rendered. */
@@ -2229,9 +2304,14 @@
2229
2304
  body.is-cjk .nq-why,
2230
2305
  body.is-cjk .cover-deck,
2231
2306
  body.is-cjk .cover-title {
2232
- font-family: "Söhne", "Inter", "Helvetica Neue", "Arial",
2233
- "PingFang SC", "PingFang TC", "Hiragino Sans GB",
2234
- "Source Han Sans CN", "Noto Sans CJK SC",
2307
+ /* cjk-sans handle dispatches CJK ranges to PingFang/Songti via
2308
+ local(). Listing it FIRST guarantees Chrome consults it for
2309
+ any CJK code point — without that, Chrome lands on Arial
2310
+ (next in the cascade, partial CJK), renders ideographs as
2311
+ .notdef, and the PingFang entries below NEVER get tried.
2312
+ That's the empty-cover-title-in-PDF bug. */
2313
+ font-family: "cjk-sans",
2314
+ "Söhne", "Inter", "Helvetica Neue", "Arial",
2235
2315
  sans-serif !important;
2236
2316
  }
2237
2317
 
@@ -3172,7 +3252,8 @@
3172
3252
  // ```metric-strip / ```chart-block style fences regularly.
3173
3253
  const fence = /^```([\w-]*)\s*$/.exec(lines[i]);
3174
3254
  if (fence) {
3175
- const lang = (fence[1] || "").toLowerCase();
3255
+ const langRaw = fence[1] || "";
3256
+ const lang = langRaw.toLowerCase();
3176
3257
  const start = i + 1;
3177
3258
  let end = lines.length;
3178
3259
  for (let j = start; j < lines.length; j++) {
@@ -3180,8 +3261,48 @@
3180
3261
  }
3181
3262
  const body = lines.slice(start, end).join("\n");
3182
3263
  const idx = placeholders.length;
3183
- if (lang === "mermaid") {
3184
- placeholders.push(`<pre class="mermaid">${escape(sanitizeMermaid(body))}</pre>`);
3264
+ // Mermaid acceptance · the canonical fence is ```mermaid,
3265
+ // but writers regularly emit ```quadrantChart /
3266
+ // ```xychart-beta / ```flowchart / ```sequenceDiagram /
3267
+ // etc. directly (the chart-type IS the lang in their head).
3268
+ // Accept those bare type tags as mermaid too — otherwise
3269
+ // they fall through to a plain `<pre class="codeblock">`
3270
+ // and ship to the user as raw mermaid source. Catalog
3271
+ // matches mermaid 10's diagram registry. The fenced body's
3272
+ // first non-empty line is the actual mermaid type, so
3273
+ // sanitizeMermaid handles the rest as before.
3274
+ const MERMAID_TYPES = new Set([
3275
+ "mermaid",
3276
+ "quadrantchart", "xychart", "xychart-beta",
3277
+ "flowchart", "graph",
3278
+ "sequencediagram", "sequence",
3279
+ "classdiagram", "class",
3280
+ "statediagram", "statediagram-v2", "state",
3281
+ "erdiagram", "er",
3282
+ "journey", "userjourney",
3283
+ "gantt",
3284
+ "pie",
3285
+ "mindmap",
3286
+ "timeline",
3287
+ "gitgraph",
3288
+ "requirementdiagram", "requirement",
3289
+ "c4context", "c4container", "c4component", "c4dynamic",
3290
+ // newer / -beta types in mermaid 10+
3291
+ "sankey-beta", "sankey",
3292
+ "block-beta", "block",
3293
+ "info",
3294
+ "packet-beta", "packet",
3295
+ "architecture-beta", "architecture",
3296
+ ]);
3297
+ if (MERMAID_TYPES.has(lang)) {
3298
+ // For non-"mermaid" lang tags, the fence didn't include
3299
+ // the type-as-first-line — prepend the ORIGINAL-cased
3300
+ // lang token so mermaid's case-sensitive parser accepts
3301
+ // it (e.g. "quadrantChart", not "quadrantchart").
3302
+ const mermaidBody = (lang === "mermaid")
3303
+ ? body
3304
+ : `${langRaw}\n${body}`;
3305
+ placeholders.push(`<pre class="mermaid">${escape(sanitizeMermaid(mermaidBody))}</pre>`);
3185
3306
  } else if (lang === "metric-strip") {
3186
3307
  placeholders.push(renderMetricStrip(body));
3187
3308
  } else if (lang === "path-comparison") {
@@ -4511,117 +4632,174 @@
4511
4632
  // backgrounds) so flowchart / mindmap / gantt / sequence /
4512
4633
  // state diagrams pick up spine-coherent colours instead of
4513
4634
  // mermaid's rainbow defaults.
4635
+ // Per-spine mermaid theme · every value here is taken from
4636
+ // the spine's :root tokens (boardroom-dark.css / a16z-thesis.css /
4637
+ // etc.) so charts feel like a native exhibit of the spine
4638
+ // rather than a foreign visualization with its own palette.
4639
+ // Audit cadence: when a spine adds / renames a token, mirror
4640
+ // it here in the same change. Drift between spine tokens and
4641
+ // these `vars` is what makes charts read as "off-brand".
4642
+ //
4643
+ // Mapping rules (consistent across spines):
4644
+ // background → spine main bg (so chart canvas blends with frame)
4645
+ // quadrantFill→ spine paper-soft / panel / bg-soft (slight contrast for inner zones)
4646
+ // pointFill → primary brand/accent (data dots = load-bearing)
4647
+ // titleFill → --ink / --text (max-contrast text)
4648
+ // axisText → --ink-faint / --text-faint (subdued axis labels)
4649
+ // border → --rule (NOT rule-strong) (matches body hairlines)
4650
+ // inner → --rule-soft (lightest separator)
4651
+ // accent → spine accent token (general fill)
4652
+ // accentSoft → spine pale variant (washed accent for backgrounds)
4653
+ // palette → 4-step monochrome ramp from accent (cycled across pie/journey/git slices)
4514
4654
  const themes = {
4515
4655
  "boardroom-dark": {
4516
- base: "dark",
4656
+ // theme: "base" + darkMode flag, NOT "dark". Mermaid 10's
4657
+ // built-in "dark" preset injects a violet-tinged primary
4658
+ // colour that bleeds into quadrant1Fill / quadrantPointFill
4659
+ // and ALSO overrides our themeVariables in some code paths
4660
+ // (the "Platform Strategy chart on boardroom-dark renders
4661
+ // purple" bug). "base" uses minimal defaults so our
4662
+ // explicit `vars` below are the only colour source.
4663
+ base: "base",
4664
+ darkMode: true,
4665
+ // Frame bg: pre.mermaid uses --panel-2 (#1A1A18). Match it
4666
+ // for the canvas so the SVG doesn't sit "inside" a darker
4667
+ // tray. Quadrant inner boxes go to --bg (#0A0A0A) so the
4668
+ // four boxes read as cut-outs against the canvas.
4517
4669
  vars: {
4518
- background: "#131312",
4519
- quadrantFill: "#1A1A18",
4520
- quadrantText: "#B6B0A2",
4521
- pointFill: "#C8C5BE",
4522
- pointText: "#8E8B83",
4523
- titleFill: "#C8C5BE",
4524
- axisText: "#8E8B83",
4525
- border: "#3A3A35",
4526
- inner: "#2A2A26",
4527
- accent: "#C9A46B",
4528
- accentSoft: "#3A3025",
4529
- accentText: "#131312",
4670
+ background: "#1A1A18", // --panel-2 (matches pre.mermaid frame)
4671
+ quadrantFill: "#0A0A0A", // --bg (cut-out zones inside chart)
4672
+ quadrantText: "#8E8B83", // --text-soft
4673
+ pointFill: "#C9A46B", // --em / --lime · load-bearing gold for data
4674
+ pointText: "#C8C5BE", // --text
4675
+ titleFill: "#C8C5BE", // --text
4676
+ axisText: "#5C5A4D", // --text-faint (was too bright before)
4677
+ border: "#3A3934", // --line-bright
4678
+ inner: "#21211E", // --panel-3
4679
+ accent: "#C9A46B", // --em (load-bearing gold)
4680
+ accentSoft: "#5C4422", // --lime-dim
4681
+ accentText: "#0A0A0A", // --bg for contrast on accent fills
4530
4682
  },
4683
+ // Gold for the lead, then beige-neutrals — multi-bar
4684
+ // charts get one bold lead + 3 supporting tones rather
4685
+ // than 4 competing golds.
4686
+ palette: ["#C9A46B", "#B6B0A2", "#8E8B83", "#5C5A52"],
4531
4687
  fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
4532
4688
  },
4533
4689
  "a16z-thesis": {
4534
4690
  base: "default",
4535
4691
  vars: {
4536
- background: "#F7F3E8",
4537
- quadrantFill: "#F2EAD8",
4538
- quadrantText: "#57503F",
4539
- pointFill: "#876C2A",
4540
- pointText: "#14110C",
4541
- titleFill: "#14110C",
4542
- axisText: "#847B65",
4543
- border: "#BCB39A",
4544
- inner: "#DCD3BD",
4545
- accent: "#876C2A",
4546
- accentSoft: "#E8D9B5",
4692
+ background: "#F7F3E8", // --bg / --paper
4693
+ quadrantFill: "#F2EAD8", // soft panel tone (between paper and rule-soft)
4694
+ quadrantText: "#57503F", // --ink-mid
4695
+ pointFill: "#8B6A2E", // --gold (precise spine value, was #876C2A)
4696
+ pointText: "#14110C", // --ink
4697
+ titleFill: "#14110C", // --ink
4698
+ axisText: "#847B65", // --ink-faint
4699
+ border: "#DCD3BD", // --rule (not --rule-strong)
4700
+ inner: "#E5DECC", // --rule-soft
4701
+ accent: "#8B6A2E", // --gold
4702
+ accentSoft: "#C4A865", // --gold-pale
4547
4703
  accentText: "#FFFFFF",
4548
4704
  },
4705
+ palette: ["#8B6A2E", "#A8843A", "#C4A865", "#E2CFA0"],
4549
4706
  fontFamily: '"Inter", "Helvetica Neue", -apple-system, system-ui, sans-serif',
4550
4707
  },
4551
4708
  "anthropic-essay": {
4552
4709
  base: "default",
4553
4710
  vars: {
4554
- background: "#FAF7F0",
4555
- quadrantFill: "#F4F0E8",
4556
- quadrantText: "#6B6359",
4557
- pointFill: "#A85A41",
4558
- pointText: "#4A4338",
4559
- titleFill: "#1A1814",
4560
- axisText: "#978C7E",
4561
- border: "#DDD5C8",
4562
- inner: "#E8E1D2",
4563
- accent: "#A85A41",
4564
- accentSoft: "#F2E0D5",
4711
+ background: "#F4F0E8", // --paper (was --surface, slight off)
4712
+ quadrantFill: "#EDE6D6", // --paper-soft
4713
+ quadrantText: "#6B6359", // --ink-mid
4714
+ pointFill: "#A85A41", // --clay-deep · stronger contrast for data
4715
+ pointText: "#1A1814", // --ink (was --ink-soft, low contrast)
4716
+ titleFill: "#1A1814", // --ink
4717
+ axisText: "#978C7E", // --ink-faint
4718
+ border: "#DDD5C8", // --rule
4719
+ inner: "#E8E1D2", // --rule-soft
4720
+ accent: "#CC785C", // --clay (spine's --accent resolves here, NOT clay-deep)
4721
+ accentSoft: "#F2E0D5", // --clay-pale
4565
4722
  accentText: "#FFFFFF",
4566
4723
  },
4724
+ palette: ["#CC785C", "#A85A41", "#B65A3A", "#F2E0D5"],
4567
4725
  fontFamily: '"Charter", "Source Serif Pro", "Iowan Old Style", Georgia, serif',
4568
4726
  },
4569
4727
  "gartner-note": {
4570
4728
  base: "default",
4571
4729
  vars: {
4572
- background: "#FFFFFF",
4573
- quadrantFill: "#FAFBFC",
4574
- quadrantText: "#455364",
4575
- pointFill: "#0A4DA1",
4576
- pointText: "#1A2332",
4577
- titleFill: "#1A2332",
4578
- axisText: "#6B7785",
4579
- border: "#BFC8D3",
4580
- inner: "#DDE2E8",
4581
- accent: "#0A4DA1",
4582
- accentSoft: "#E8EFF8",
4730
+ background: "#FFFFFF", // --bg
4731
+ quadrantFill: "#F5F7FA", // --bg-soft (was --bg-emphasis #FAFBFC)
4732
+ quadrantText: "#455364", // --ink-soft
4733
+ pointFill: "#0A4DA1", // --brand
4734
+ pointText: "#1A2332", // --ink
4735
+ titleFill: "#1A2332", // --ink
4736
+ axisText: "#6B7785", // --ink-faint
4737
+ border: "#DDE2E8", // --rule (was --rule-strong, too heavy)
4738
+ inner: "#E8ECF1", // --rule-soft
4739
+ accent: "#0A4DA1", // --brand
4740
+ accentSoft: "#E8F1FB", // --brand-pale
4583
4741
  accentText: "#FFFFFF",
4584
4742
  },
4743
+ palette: ["#0A4DA1", "#1A65BD", "#3F73B8", "#7AA0CF"],
4585
4744
  fontFamily: '"Inter", "Helvetica Neue", Arial, sans-serif',
4586
4745
  },
4587
4746
  "mckinsey-deck": {
4588
4747
  base: "default",
4589
4748
  vars: {
4590
- background: "#FFFFFF",
4591
- quadrantFill: "#FBFCFD",
4592
- quadrantText: "#4A5870",
4593
- pointFill: "#2251FF",
4594
- pointText: "#051C2C",
4595
- titleFill: "#051C2C",
4596
- axisText: "#758296",
4597
- border: "#B8C2D0",
4598
- inner: "#D5DCE4",
4599
- accent: "#2251FF",
4600
- accentSoft: "#E6ECFB",
4749
+ background: "#FFFFFF", // --bg
4750
+ quadrantFill: "#F8FAFD", // soft panel (slightly tinted)
4751
+ quadrantText: "#4A5870", // --ink-soft
4752
+ pointFill: "#2251FF", // --blue
4753
+ pointText: "#051C2C", // --ink / --navy
4754
+ titleFill: "#051C2C", // --navy
4755
+ axisText: "#758296", // --ink-faint
4756
+ border: "#D5DCE4", // --rule (was --rule-strong)
4757
+ inner: "#E5EAF1", // --rule-soft
4758
+ accent: "#2251FF", // --blue
4759
+ accentSoft: "#E5EDFA", // --blue-pale
4601
4760
  accentText: "#FFFFFF",
4602
4761
  },
4762
+ palette: ["#2251FF", "#1A3FBD", "#6285FF", "#B8C2D0"],
4603
4763
  fontFamily: '"Inter", "Helvetica Neue", Arial, sans-serif',
4604
4764
  },
4605
4765
  "openai-paper": {
4606
4766
  base: "default",
4607
4767
  vars: {
4608
- background: "#FFFFFF",
4609
- quadrantFill: "#FAFAFA",
4610
- quadrantText: "#404040",
4611
- pointFill: "#10A37F",
4612
- pointText: "#0D0D0D",
4613
- titleFill: "#0D0D0D",
4614
- axisText: "#6E6E80",
4615
- border: "#E5E5E5",
4616
- inner: "#EFEFEF",
4617
- accent: "#10A37F",
4618
- accentSoft: "#E6F6F0",
4768
+ background: "#FFFFFF", // --bg
4769
+ quadrantFill: "#FAFAFA", // --paper
4770
+ quadrantText: "#404040", // --ink-soft
4771
+ pointFill: "#10A37F", // --teal
4772
+ pointText: "#0D0D0D", // --ink
4773
+ titleFill: "#0D0D0D", // --ink
4774
+ axisText: "#6E6E80", // --ink-faint
4775
+ border: "#E5E5E5", // --rule
4776
+ inner: "#EFEFEF", // --rule-soft
4777
+ accent: "#10A37F", // --teal
4778
+ accentSoft: "#E6F6F0", // --teal-pale
4619
4779
  accentText: "#FFFFFF",
4620
4780
  },
4781
+ palette: ["#10A37F", "#0E8C6D", "#52B89A", "#8FCDB7"],
4621
4782
  fontFamily: '"Söhne", "Inter", -apple-system, system-ui, sans-serif',
4622
4783
  },
4623
4784
  };
4624
4785
  const t = themes[spineKey];
4786
+ // Build pie / journey / state / git slice-color overrides
4787
+ // by cycling through the spine's monochrome `palette`.
4788
+ // Without these, mermaid 11+ paints pie1..pie12 +
4789
+ // fillType0..7 + git0..7 with its built-in rainbow set
4790
+ // (violet / teal / orange) — that's the source of the
4791
+ // "white + purple" charts on the dark spine.
4792
+ const sliceColors = {};
4793
+ for (let i = 0; i < 12; i++) {
4794
+ const c = t.palette[i % t.palette.length];
4795
+ sliceColors[`pie${i + 1}`] = c;
4796
+ }
4797
+ for (let i = 0; i < 8; i++) {
4798
+ const c = t.palette[i % t.palette.length];
4799
+ sliceColors[`fillType${i}`] = c;
4800
+ sliceColors[`git${i}`] = c;
4801
+ sliceColors[`gitInv${i}`] = c;
4802
+ }
4625
4803
  // themeCSS shapes the rendered SVG beyond what themeVariables
4626
4804
  // expose. The chart title is hidden because the markdown
4627
4805
  // already renders an H3 caption directly above each chart —
@@ -4631,6 +4809,13 @@
4631
4809
  // showing it twice is mermaid's single ugliest default. Apply
4632
4810
  // across quadrant / xychart / pie / timeline.
4633
4811
  const themeCSS = `
4812
+ /* ── SVG canvas · paint the theme bg explicitly. Without
4813
+ this, mermaid leaves the SVG transparent and the parent
4814
+ <pre>'s background bleeds through inconsistently across
4815
+ diagram types — most visibly on dark spines where the
4816
+ browser default white shows through pie/journey gaps. */
4817
+ svg { background: ${t.vars.background}; }
4818
+ .pieOuterCircle { stroke: ${t.vars.border} !important; }
4634
4819
  /* ── Quadrant chart ── */
4635
4820
  g.quadrant-chart text { font-family: ${t.fontFamily}; }
4636
4821
  g.quadrant-point > circle, .quadrant-point circle {
@@ -4643,8 +4828,73 @@
4643
4828
  .timeline .title-text,
4644
4829
  .timeline-title { display: none !important; }
4645
4830
 
4646
- /* ── xychart-beta · bars ── */
4647
- .xy-chart .bar { stroke: ${t.vars.background}; stroke-width: 1px; }
4831
+ /* ── xychart-beta · refined bars ──
4832
+ Bar WIDTH is slimmed by the post-render JS mutation
4833
+ (see "xychart bar slimming" block after mermaid.run) --
4834
+ CSS scaleX on SVG rects is unreliable across browsers,
4835
+ so we mutate the rect attributes directly. Here we
4836
+ only handle the COLOUR pass: solid accent fill, no
4837
+ stroke, with per-position cycling through the spine's
4838
+ 4-shade monochrome palette so multi-bar charts stagger
4839
+ tones rather than reading as one flat block. */
4840
+ .xy-chart .bar,
4841
+ g.bar-plot .bar,
4842
+ g.bar-plot rect,
4843
+ g.plot > rect {
4844
+ fill: ${t.vars.accent} !important;
4845
+ stroke: none !important;
4846
+ }
4847
+ .xy-chart .bar:nth-child(2n),
4848
+ g.bar-plot .bar:nth-child(2n),
4849
+ g.bar-plot rect:nth-child(2n),
4850
+ g.plot > rect:nth-child(2n) { fill: ${t.palette[1]} !important; }
4851
+ .xy-chart .bar:nth-child(3n),
4852
+ g.bar-plot .bar:nth-child(3n),
4853
+ g.bar-plot rect:nth-child(3n),
4854
+ g.plot > rect:nth-child(3n) { fill: ${t.palette[2]} !important; }
4855
+ .xy-chart .bar:nth-child(4n),
4856
+ g.bar-plot .bar:nth-child(4n),
4857
+ g.bar-plot rect:nth-child(4n),
4858
+ g.plot > rect:nth-child(4n) { fill: ${t.palette[3]} !important; }
4859
+ /* Axis / grid refinement · 0.5px-feel hairlines, label
4860
+ typography in the mono kicker register so the chart's
4861
+ typography aligns with the rest of the doc. */
4862
+ .xy-chart .background { fill: transparent !important; }
4863
+ .xy-chart .x-axis line,
4864
+ .xy-chart .y-axis line,
4865
+ .xy-chart line.tick {
4866
+ stroke: ${t.vars.border} !important;
4867
+ stroke-width: 1px !important;
4868
+ }
4869
+ .xy-chart .x-axis path,
4870
+ .xy-chart .y-axis path,
4871
+ .xy-chart path.domain {
4872
+ stroke: ${t.vars.titleFill} !important;
4873
+ stroke-width: 1px !important;
4874
+ fill: none !important;
4875
+ }
4876
+ .xy-chart .grid line,
4877
+ .xy-chart .gridline,
4878
+ .xy-chart line.grid {
4879
+ stroke: ${t.vars.inner} !important;
4880
+ stroke-width: 1px !important;
4881
+ stroke-dasharray: 2 3 !important;
4882
+ }
4883
+ .xy-chart text,
4884
+ .xy-chart .x-axis text,
4885
+ .xy-chart .y-axis text {
4886
+ font-family: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace !important;
4887
+ font-size: 10px !important;
4888
+ fill: ${t.vars.axisText} !important;
4889
+ letter-spacing: 0.06em !important;
4890
+ }
4891
+ /* Data labels (showDataLabel) in the kicker register too. */
4892
+ .xy-chart .data-label,
4893
+ .xy-chart text.data-label {
4894
+ font-family: "SF Mono", "JetBrains Mono", Menlo, monospace !important;
4895
+ font-size: 10px !important;
4896
+ fill: ${t.vars.titleFill} !important;
4897
+ }
4648
4898
 
4649
4899
  /* ── Pie ── */
4650
4900
  .pieCircle { stroke: ${t.vars.background}; stroke-width: 2px; }
@@ -4878,8 +5128,11 @@
4878
5128
  fontFamily: t.fontFamily,
4879
5129
  themeCSS,
4880
5130
  quadrantChart: {
4881
- chartWidth: 640,
4882
- chartHeight: 480,
5131
+ // Refined-compact · was 640×480; the chart fits inside a
5132
+ // 740–880px article column, and 480px tall reads as a
5133
+ // presentation slide rather than an inline exhibit.
5134
+ chartWidth: 460,
5135
+ chartHeight: 320,
4883
5136
  // Hide mermaid's own title slot — the H3 above the chart
4884
5137
  // is the caption. Setting padding/size to 0 keeps the
4885
5138
  // layout from leaving an empty band at the top.
@@ -4889,15 +5142,15 @@
4889
5142
  quadrantPadding: 8,
4890
5143
  quadrantInternalBorderStrokeWidth: 0.5,
4891
5144
  quadrantExternalBorderStrokeWidth: 1,
4892
- quadrantLabelFontSize: 12,
4893
- quadrantTextTopPadding: 12,
4894
- pointRadius: 6,
4895
- pointLabelFontSize: 12,
4896
- pointTextPadding: 8,
4897
- xAxisLabelFontSize: 13,
4898
- xAxisLabelPadding: 8,
4899
- yAxisLabelFontSize: 13,
4900
- yAxisLabelPadding: 8,
5145
+ quadrantLabelFontSize: 11,
5146
+ quadrantTextTopPadding: 10,
5147
+ pointRadius: 5,
5148
+ pointLabelFontSize: 11,
5149
+ pointTextPadding: 6,
5150
+ xAxisLabelFontSize: 11,
5151
+ xAxisLabelPadding: 6,
5152
+ yAxisLabelFontSize: 11,
5153
+ yAxisLabelPadding: 6,
4901
5154
  xAxisPosition: "bottom",
4902
5155
  yAxisPosition: "left",
4903
5156
  },
@@ -4910,13 +5163,25 @@
4910
5163
  // when the CSS forced 12px after mermaid measured at
4911
5164
  // its 16px default).
4912
5165
  fontSize: "12.5px",
5166
+ // darkMode flag tells mermaid whether to invert
5167
+ // computed contrast pairs — required when we feed it
5168
+ // dark surface colors via the "base" theme. Without
5169
+ // this, mermaid computes text colors assuming a light
5170
+ // canvas and ends up with low-contrast labels.
5171
+ darkMode: t.darkMode === true,
4913
5172
  background: t.vars.background,
4914
- primaryColor: t.vars.quadrantFill,
5173
+ // primaryColor MUST NOT be the quadrantFill (which is
5174
+ // very dark on dark spines). Mermaid uses primaryColor
5175
+ // as the seed for many derived colours (cScale shades,
5176
+ // pie defaults, accent variations) — when it's near-
5177
+ // black the derivations come out muddy. Use the spine's
5178
+ // accent so derivations land in the brand family.
5179
+ primaryColor: t.vars.accent,
4915
5180
  primaryTextColor: t.vars.titleFill,
4916
5181
  primaryBorderColor: t.vars.border,
4917
5182
  lineColor: t.vars.border,
4918
- secondaryColor: t.vars.quadrantFill,
4919
- tertiaryColor: t.vars.background,
5183
+ secondaryColor: t.vars.accentSoft,
5184
+ tertiaryColor: t.vars.quadrantFill,
4920
5185
  // All 4 quadrants share the same fill — clean matrix.
4921
5186
  quadrant1Fill: t.vars.quadrantFill,
4922
5187
  quadrant2Fill: t.vars.quadrantFill,
@@ -4987,6 +5252,42 @@
4987
5252
  altSectionBkgColor: t.vars.quadrantFill,
4988
5253
  sectionBkgColor2: t.vars.background,
4989
5254
  todayLineColor: t.vars.accent,
5255
+ // ── Pie / journey / state / git · monochrome palette
5256
+ // Cycles t.palette across mermaid's per-slice color
5257
+ // slots so these chart types stop falling back to
5258
+ // mermaid's built-in violet/teal rainbow defaults
5259
+ // (the white+purple leak on dark spines).
5260
+ ...sliceColors,
5261
+ // ── xychart-beta · single-accent bars + matched chrome.
5262
+ // Mermaid generates xychart SVG using these tokens BEFORE
5263
+ // our themeCSS overrides apply, so they have to be set
5264
+ // here to get the right fill at render time (not just
5265
+ // post-paint via CSS). Palette joined as comma-separated
5266
+ // string per mermaid's plotColorPalette contract.
5267
+ xyChart: {
5268
+ backgroundColor: "transparent",
5269
+ titleColor: t.vars.titleFill,
5270
+ xAxisLabelColor: t.vars.axisText,
5271
+ xAxisTitleColor: t.vars.titleFill,
5272
+ xAxisTickColor: t.vars.border,
5273
+ xAxisLineColor: t.vars.titleFill,
5274
+ yAxisLabelColor: t.vars.axisText,
5275
+ yAxisTitleColor: t.vars.titleFill,
5276
+ yAxisTickColor: t.vars.border,
5277
+ yAxisLineColor: t.vars.titleFill,
5278
+ plotColorPalette: t.palette.join(","),
5279
+ },
5280
+ pieTitleTextSize: "0px",
5281
+ pieTitleTextColor: t.vars.titleFill,
5282
+ pieSectionTextColor: t.vars.titleFill,
5283
+ pieSectionTextSize: "11px",
5284
+ pieLegendTextColor: t.vars.titleFill,
5285
+ pieLegendTextSize: "11px",
5286
+ pieStrokeColor: t.vars.background,
5287
+ pieStrokeWidth: "1px",
5288
+ pieOuterStrokeColor: t.vars.border,
5289
+ pieOuterStrokeWidth: "1px",
5290
+ pieOpacity: "1",
4990
5291
  },
4991
5292
  flowchart: {
4992
5293
  useMaxWidth: true,
@@ -5004,30 +5305,30 @@
5004
5305
  },
5005
5306
  sequence: {
5006
5307
  useMaxWidth: true,
5007
- diagramMarginX: 16,
5008
- diagramMarginY: 8,
5009
- actorMargin: 32,
5010
- boxMargin: 6,
5011
- boxTextMargin: 4,
5012
- noteMargin: 8,
5013
- messageMargin: 22,
5308
+ diagramMarginX: 12,
5309
+ diagramMarginY: 6,
5310
+ actorMargin: 24,
5311
+ boxMargin: 5,
5312
+ boxTextMargin: 3,
5313
+ noteMargin: 6,
5314
+ messageMargin: 18,
5014
5315
  mirrorActors: false,
5015
5316
  bottomMarginAdj: 0,
5016
- actorFontSize: 12,
5017
- messageFontSize: 11,
5018
- noteFontSize: 11,
5317
+ actorFontSize: 11,
5318
+ messageFontSize: 10,
5319
+ noteFontSize: 10,
5019
5320
  },
5020
5321
  gantt: {
5021
5322
  useMaxWidth: true,
5022
- fontSize: 11,
5023
- sectionFontSize: 11,
5323
+ fontSize: 10,
5324
+ sectionFontSize: 10,
5024
5325
  numberSectionStyles: 3,
5025
- leftPadding: 80,
5026
- topPadding: 24,
5027
- rightPadding: 24,
5028
- barGap: 4,
5029
- barHeight: 18,
5030
- gridLineStartPadding: 36,
5326
+ leftPadding: 64,
5327
+ topPadding: 18,
5328
+ rightPadding: 18,
5329
+ barGap: 3,
5330
+ barHeight: 14,
5331
+ gridLineStartPadding: 28,
5031
5332
  },
5032
5333
  mindmap: {
5033
5334
  useMaxWidth: true,
@@ -5058,15 +5359,28 @@
5058
5359
  // `xychart` — keep BOTH so the config isn't fragile across
5059
5360
  // CDN bumps).
5060
5361
  xyChart: {
5061
- width: 720,
5062
- height: 360,
5362
+ // Was 720×360 · oversized for an inline exhibit. 560×260
5363
+ // sits comfortably inside a 740px article column without
5364
+ // pretending to be a presentation slide. Slightly shorter
5365
+ // than the 320px we tried first — squat-ish proportions
5366
+ // read as "data exhibit" while taller bars look like a
5367
+ // pitch slide.
5368
+ width: 560,
5369
+ height: 260,
5063
5370
  titlePadding: 0,
5064
5371
  titleFontSize: 0,
5065
5372
  showTitle: false,
5066
5373
  showDataLabel: true,
5067
- plotReservedSpacePercent: 50,
5068
- xAxis: { labelFontSize: 12, titleFontSize: 0, showTitle: false },
5069
- yAxis: { labelFontSize: 12, titleFontSize: 13 },
5374
+ // Refined bars · 50 → 32 → 20. mermaid xychart's bar
5375
+ // width is `slotWidth × reservedPercent / 100`, where
5376
+ // slotWidth scales inversely with bar count — so a value
5377
+ // tuned for 8 bars looks chunky on 3 bars (each slot
5378
+ // is ~3× wider). 20 keeps 8-12-bar charts legible while
5379
+ // 3-bar charts come out as ~36px-wide thin bars rather
5380
+ // than 60-90px filler blocks.
5381
+ plotReservedSpacePercent: 20,
5382
+ xAxis: { labelFontSize: 10, titleFontSize: 0, showTitle: false },
5383
+ yAxis: { labelFontSize: 10, titleFontSize: 10 },
5070
5384
  },
5071
5385
  // Pie · legend on the right, slice labels disabled so wide
5072
5386
  // labels don't push the chart off-screen. The legend
@@ -5082,6 +5396,121 @@
5082
5396
  },
5083
5397
  });
5084
5398
  await mermaid.run({ querySelector: ".mermaid" });
5399
+ // ── Post-render colour forcing ───────────────────────────
5400
+ // Mermaid 10's themeVariables don't fully replace built-in
5401
+ // colour defaults on every chart type — quadrant fills,
5402
+ // pie slices, journey faces and similar can leak the
5403
+ // theme preset's primary colour (purple/violet on
5404
+ // theme:"dark"). Walk every rendered SVG and force-set
5405
+ // fills directly on the elements we know about. Bulletproof
5406
+ // because it runs LAST, after mermaid's own paint.
5407
+ try {
5408
+ document.querySelectorAll('.mermaid svg').forEach((svg) => {
5409
+ // ── Quadrant chart ──
5410
+ // The four quadrant rects + internal/external borders +
5411
+ // point dots. Mermaid 10 uses class `.quadrant-fill` on
5412
+ // each filled rect inside the chart group.
5413
+ svg.querySelectorAll('rect.quadrant-fill, .quadrant-fill').forEach((el) => {
5414
+ el.setAttribute('fill', t.vars.quadrantFill);
5415
+ });
5416
+ // Defense for any rect inside .quadrant-chart that wasn't
5417
+ // class-tagged (mermaid version drift). Skip the chart's
5418
+ // outer container rect (full-svg-width).
5419
+ const chartG = svg.querySelector('g.quadrant-chart, .quadrant-chart');
5420
+ if (chartG) {
5421
+ const svgWidth = parseFloat(svg.getAttribute('width') || '0');
5422
+ chartG.querySelectorAll(':scope > g > rect, :scope > rect').forEach((el) => {
5423
+ const w = parseFloat(el.getAttribute('width') || '0');
5424
+ if (w > 0 && (svgWidth === 0 || w < svgWidth * 0.95)) {
5425
+ el.setAttribute('fill', t.vars.quadrantFill);
5426
+ }
5427
+ });
5428
+ }
5429
+ // Quadrant data points · pointFill (the spine's
5430
+ // strongest brand colour for marker-style data) with a
5431
+ // frame-bg stroke for separation from the quadrant fill.
5432
+ svg.querySelectorAll('circle.quadrant-point, .quadrant-point circle, g.quadrant-point > circle').forEach((el) => {
5433
+ el.setAttribute('fill', t.vars.pointFill);
5434
+ el.setAttribute('stroke', t.vars.background);
5435
+ });
5436
+ // ── Pie chart slices · cycle palette, force stroke ──
5437
+ svg.querySelectorAll('path.pieCircle').forEach((el, i) => {
5438
+ el.setAttribute('fill', t.palette[i % t.palette.length]);
5439
+ el.setAttribute('stroke', t.vars.background);
5440
+ });
5441
+ // Pie outer ring border.
5442
+ svg.querySelectorAll('.pieOuterCircle, circle.pieOuterCircle').forEach((el) => {
5443
+ el.setAttribute('stroke', t.vars.border);
5444
+ el.setAttribute('fill', 'none');
5445
+ });
5446
+ // ── Journey · face circles cycle palette ──
5447
+ svg.querySelectorAll('circle.face-circle, .face-circle').forEach((el, i) => {
5448
+ el.setAttribute('fill', t.palette[i % t.palette.length]);
5449
+ });
5450
+ // ── Generic SVG canvas guard · any rect that fills the
5451
+ // entire SVG and was painted with mermaid's default
5452
+ // primaryColor (could be off-brand on dark spines). Cap
5453
+ // its fill to t.vars.background.
5454
+ const root = svg.querySelector(':scope > rect, :scope > g > rect.background');
5455
+ if (root) {
5456
+ const w = parseFloat(root.getAttribute('width') || '0');
5457
+ const sw = parseFloat(svg.getAttribute('width') || '0');
5458
+ if (w > 0 && sw > 0 && w >= sw * 0.95) {
5459
+ root.setAttribute('fill', t.vars.background);
5460
+ }
5461
+ }
5462
+ });
5463
+ } catch (forceColorErr) {
5464
+ console.warn('[mermaid] post-render colour force failed:', forceColorErr);
5465
+ }
5466
+ // ── Post-render xychart bar slimming ─────────────────────
5467
+ // mermaid xychart-beta renders bars at near-full slot width
5468
+ // regardless of `plotReservedSpacePercent` in some 11.x
5469
+ // versions, and CSS transform: scaleX(...) on the bar rects
5470
+ // is unreliable across browsers (transform-box: fill-box
5471
+ // has Chrome/Safari gaps for SVG rect). Direct attribute
5472
+ // mutation is bulletproof: we walk every rendered xychart
5473
+ // SVG, identify the bar rects via class + geometric
5474
+ // heuristic (rect inside g.plot, height > width, not full-
5475
+ // plot wide), and shrink each rect's `width` to ~50% with
5476
+ // a matching `x` shift so it stays centered in its slot.
5477
+ // Result: a 3-bar chart goes from 3 chunky filler blocks
5478
+ // to 3 thin accents on a wide whitespace canvas — refined-
5479
+ // exhibit, not slide. */
5480
+ try {
5481
+ const xyHosts = document.querySelectorAll('.mermaid');
5482
+ xyHosts.forEach((host) => {
5483
+ const svg = host.querySelector('svg');
5484
+ if (!svg) return;
5485
+ // Only xychart-beta SVGs · skip quadrant / pie / etc.
5486
+ if (!svg.matches('[id^="xychart"], [aria-roledescription="xychart"]')
5487
+ && !svg.querySelector('g.plot, g.bar-plot, g[class*="-plot"], rect.bar')) {
5488
+ return;
5489
+ }
5490
+ // Catch rects across mermaid 11.x naming conventions.
5491
+ const candidates = svg.querySelectorAll(
5492
+ 'rect.bar, g.bar-plot rect, g.plot rect, g[class*="plot"] > rect'
5493
+ );
5494
+ candidates.forEach((rect) => {
5495
+ const x = parseFloat(rect.getAttribute('x'));
5496
+ const w = parseFloat(rect.getAttribute('width'));
5497
+ const h = parseFloat(rect.getAttribute('height'));
5498
+ if (!Number.isFinite(x) || !Number.isFinite(w) || !Number.isFinite(h)) return;
5499
+ // Skip the plot-background rect (full plot width).
5500
+ // Don't filter on h-vs-w ratio — that catches short
5501
+ // bars (small data values) and leaves them at full
5502
+ // width while their taller siblings shrink, giving
5503
+ // a mismatched chart.
5504
+ if (w >= 200) return;
5505
+ const scale = 0.5;
5506
+ const newW = w * scale;
5507
+ rect.setAttribute('width', newW.toFixed(2));
5508
+ rect.setAttribute('x', (x + (w - newW) / 2).toFixed(2));
5509
+ });
5510
+ });
5511
+ } catch (slimErr) {
5512
+ console.warn('[mermaid] xychart bar slimming failed:', slimErr);
5513
+ }
5085
5514
  } catch (e) {
5086
5515
  // Per-diagram failures already render as inline error overlays —
5087
5516
  // log and move on.