privateboard 0.1.2 → 0.1.4

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.
@@ -61,25 +61,57 @@
61
61
  box-shadow: none !important;
62
62
  }
63
63
 
64
- /* Edge-to-edge backgrounds in PDF · zero @page margin so the
65
- body's paper colour fills every pixel of the page. The visible
66
- gutter is recreated via body padding so existing per-section
67
- paddings (cover / chapters / methodology) keep working
68
- unchanged. Without this, A4 PDFs of light-paper spines
69
- (anthropic-essay #F4F0E8 / a16z-thesis #F7F3E8 / cream-printed
70
- boardroom-dark #FAF7F0) showed a jarring white perimeter
71
- outside a tan content rectangle the user's "why is there
72
- white around my yellow report" complaint. */
73
- @page { size: A4; margin: 0; }
74
- body { padding: 14mm 12mm !important; }
64
+ /* Per-page safe area · @page margin gives EVERY page a
65
+ consistent 14mm top/bottom + 12mm side gutter. Edge-to-edge
66
+ paper colour is painted via a JS-injected @page { background }
67
+ rule (see swapSpine in the script block). The bottom-center
68
+ margin box carries the page counter that's the ONLY thing
69
+ in the bleed area; no document title, no logo, no "page X of
70
+ Y", no spine name. The cover page (`@page :first`) suppresses
71
+ the counter so the title sheet stays clean. */
72
+ @page {
73
+ size: A4;
74
+ margin: 14mm 12mm;
75
+ /* Explicitly claim every margin box and leave the top + side
76
+ ones empty. Chrome's print engine inserts its own
77
+ title / url / date in the top-* boxes by default; declaring
78
+ them in CSS (even with empty content) tells Chrome we're
79
+ managing this margin area, suppressing the auto-headers.
80
+ The bottom-center carries the page counter — that's the
81
+ only thing in the bleed area. The bottom-left / bottom-right
82
+ are also explicitly emptied so the page number sits alone.
83
+ Note: a user who manually re-enables "Headers and footers"
84
+ in the print preview dialog can still override CSS — that
85
+ toggle is browser chrome, not document CSS. */
86
+ @top-left { content: ""; }
87
+ @top-center { content: ""; }
88
+ @top-right { content: ""; }
89
+ @bottom-left { content: ""; }
90
+ @bottom-right { content: ""; }
91
+ @bottom-center {
92
+ content: counter(page);
93
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
94
+ font-size: 9px;
95
+ letter-spacing: 0.14em;
96
+ color: #978C7E;
97
+ vertical-align: top;
98
+ padding-top: 4mm;
99
+ }
100
+ }
101
+ @page :first {
102
+ @bottom-center { content: none; }
103
+ }
104
+ body { padding: 0 !important; }
75
105
 
76
106
  /* Strip browser chrome from the PDF. */
77
107
  .top-rule,
78
108
  .foot-rule,
79
109
  .cover-versions,
110
+ .reading-nav,
80
111
  [data-back],
81
112
  [data-download],
82
- .top-actions {
113
+ .top-actions,
114
+ .colophon-share {
83
115
  display: none !important;
84
116
  }
85
117
 
@@ -148,22 +180,23 @@
148
180
  }
149
181
 
150
182
  /* ─── CJK font fallback for print ────────────────────────────
151
- In headless Chrome's PDF render path, "PingFang SC" and
152
- "Hiragino Sans GB" are often unreachable they live outside
153
- the standard font directories. "Songti SC" (at /System/Library
154
- /Fonts/Supplemental/Songti.ttc) IS reachable, so it anchors
155
- every cascade as the dependable CJK fallback. Latin text still
156
- renders in the spine's preferred face; only CJK falls through. */
157
-
158
- /* Sans-anchored body text · Latin in Helvetica/Inter, CJK in Songti SC */
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. */
190
+
191
+ /* Sans-anchored body text · Latin in Helvetica/Inter, CJK in PingFang SC. */
159
192
  body, .body, .body p, .body li,
160
193
  .body td, .body th,
161
194
  .rec-rationale, .rec-risk, .rec-risk-text, .rec-meta-value, .rec-action,
162
195
  .nq-why, .cover-deck {
163
196
  font-family: "Helvetica Neue", "Inter", "Arial",
164
- "Songti SC", "STSong",
165
- "Hiragino Sans GB", "PingFang SC",
197
+ "PingFang SC", "PingFang TC", "Hiragino Sans GB",
166
198
  "Source Han Sans CN", "Noto Sans CJK SC",
199
+ "Songti SC", "STSong",
167
200
  sans-serif !important;
168
201
  }
169
202
  /* Serif-anchored display copy · headlines, italic claims, big-idea
@@ -177,11 +210,13 @@
177
210
  .nq-question, .cover-title {
178
211
  font-family: "Charter", "Source Serif Pro", "Iowan Old Style",
179
212
  "Georgia",
213
+ "PingFang SC", "PingFang TC", "Hiragino Sans GB",
214
+ "Source Han Sans CN", "Noto Sans CJK SC",
180
215
  "Songti SC", "STSong",
181
216
  "Source Han Serif CN", "Noto Serif CJK SC",
182
- "PingFang SC", "Hiragino Sans GB", serif !important;
217
+ serif !important;
183
218
  }
184
- /* Mono kickers / labels stay mono with Songti SC as CJK fallback. */
219
+ /* Mono kickers / labels stay mono with PingFang SC as CJK fallback. */
185
220
  .top-rule .crumb,
186
221
  .body .chapter-num,
187
222
  .cover-tag, .cover-tag .secondary,
@@ -198,7 +233,8 @@
198
233
  .body table.md-table th {
199
234
  font-family: "SF Mono", "JetBrains Mono", "Menlo",
200
235
  "Helvetica Neue", "Arial",
201
- "Songti SC", "STSong", "Hiragino Sans GB", "PingFang SC",
236
+ "PingFang SC", "PingFang TC", "Hiragino Sans GB",
237
+ "Source Han Sans CN", "Songti SC", "STSong",
202
238
  monospace !important;
203
239
  }
204
240
 
@@ -250,25 +286,82 @@
250
286
  page-break-after: avoid;
251
287
  }
252
288
  /* Don't let a heading land alone in the page footer with its
253
- paragraph / table on the next page. The adjacent-sibling
254
- selector matches the FIRST element after the heading and
255
- refuses to break before it · effectively keeps "heading +
256
- opener" as one unit. */
289
+ opener on the next page. The adjacent-sibling selector matches
290
+ the FIRST element after the heading and refuses to break
291
+ before it · effectively keeps "heading + opener" as one unit.
292
+ Coverage extended past the original p / blockquote / table /
293
+ metric-strip set to also include ol / ul / pillars-grid /
294
+ mermaid / h3 — these were the silent culprits behind sections
295
+ that opened with a list (very common) or a sub-heading
296
+ getting their h2 stranded at the bottom of a page. Same set
297
+ applied at the top-of-body level (no <section> wrap, e.g. a
298
+ model-invented heading like "共识忽视的三件事") so the glue
299
+ holds even when the H2 didn't match a section classifier. */
257
300
  .body section > h2 + p,
258
301
  .body section > h2 + blockquote,
259
302
  .body section > h2 + table.md-table,
260
303
  .body section > h2 + .metric-strip,
304
+ .body section > h2 + ol,
305
+ .body section > h2 + ul,
306
+ .body section > h2 + .pillars-grid,
307
+ .body section > h2 + h3,
308
+ .body section > h2 + pre.mermaid,
261
309
  .body section > h3 + p,
262
310
  .body section > h3 + blockquote,
263
- .body section > h3 + table.md-table {
311
+ .body section > h3 + table.md-table,
312
+ .body section > h3 + ol,
313
+ .body section > h3 + ul,
314
+ .body section > h3 + h4,
315
+ .body > h2 + p,
316
+ .body > h2 + blockquote,
317
+ .body > h2 + table.md-table,
318
+ .body > h2 + ol,
319
+ .body > h2 + ul,
320
+ .body > h2 + .pillars-grid,
321
+ .body > h3 + p,
322
+ .body > h3 + ol,
323
+ .body > h3 + ul {
264
324
  break-before: avoid;
265
325
  page-break-before: avoid;
266
326
  }
267
327
  /* Paragraph-level orphan / widow control · a paragraph isn't
268
- allowed to leave only 1-2 lines on either side of a page
269
- break. Cheapest typographic discipline; massive effect on the
270
- feel of multi-page reports. */
271
- .body { orphans: 3; widows: 3; }
328
+ allowed to leave only 1-3 lines on either side of a page
329
+ break. Bumped from 3 4 at typical line height + heading
330
+ cadence, 3 still let a 4-line para split 1+3 which reads as
331
+ a stranded line. 4 forces moves like "pull whole paragraph
332
+ to next page" which is the desired behaviour. Cheapest
333
+ typographic discipline; massive effect on multi-page feel. */
334
+ .body { orphans: 4; widows: 4; }
335
+ /* H3 sub-section heading · same anti-orphan rule as h2. The
336
+ prior block only set this on h2 / h3 / h4, but break-after
337
+ on h3 still allowed h3 to slip to the bottom of the page
338
+ with content on the next. The companion break-before: avoid
339
+ on h3 + (next sibling) above completes the pair. */
340
+ .body section > h3,
341
+ .body > h3 {
342
+ break-after: avoid;
343
+ page-break-after: avoid;
344
+ }
345
+ /* Bottom-line / convergence callouts shouldn't split. They're
346
+ short by design (1-3 paragraphs), so forcing them whole is
347
+ cheap and a split mid-callout reads as a layout bug. */
348
+ .body section.section-bottom-line,
349
+ .body section.section-the-bet > blockquote,
350
+ .body blockquote.callout {
351
+ break-inside: avoid;
352
+ page-break-inside: avoid;
353
+ }
354
+ /* The "▼ session output ▼" / "// end of session" frame around
355
+ brief output is not part of the report PDF (it's a chat-side
356
+ UI), but a sibling rule — the brief title block (h1 / kicker /
357
+ byline rows together) MUST stay glued. Same pattern. */
358
+ .body .brief-info,
359
+ .body .brief-kicker,
360
+ .body .brief-meta-row {
361
+ break-inside: avoid;
362
+ page-break-inside: avoid;
363
+ }
364
+ .body .brief-kicker { break-after: avoid; page-break-after: avoid; }
272
365
 
273
366
  /* ─── Tier B · CSS Grid → flex-wrap in print ────────────────
274
367
  Chrome's PDF engine notoriously ignores `break-inside: avoid`
@@ -299,6 +392,11 @@
299
392
  max-width: 100% !important;
300
393
  margin-bottom: 0 !important;
301
394
  }
395
+ /* Drop the cell's vertical hairline in print · with flex-wrap +
396
+ 10px gap, the inter-cell rule is replaced by whitespace. Leaving
397
+ the border-right in produces stray vertical strokes on the
398
+ trailing edge of each row when cards wrap to a new row. */
399
+ .body .metric-strip-grid > .metric-card { border-right: 0 !important; }
302
400
 
303
401
  /* Glue chapter-num to its h2 · the "Section 0X" kicker is a
304
402
  sibling element inserted before the heading (not nested), so
@@ -333,6 +431,27 @@
333
431
  .body pre.mermaid svg { max-width: 100% !important; height: auto !important; }
334
432
  }
335
433
 
434
+ /* ─── Author byline · name list (spine-agnostic) ───────────────
435
+ The cover used to render circular avatar imgs alongside an
436
+ "{N} directors" count. Both got dropped in favour of the names
437
+ themselves rendered in the spine's main typography — reads as a
438
+ proper editorial byline ("by Socrates · Marc Andreessen · Peter
439
+ Thiel") rather than an app-style avatar row.
440
+
441
+ The middot separator is a subtler weight than the names so the
442
+ eye walks the list of contributors directly. Line-height stays
443
+ tight so 5+ names wrap without bloating the cover. */
444
+ .byline-block.byline-authors .value.author-list {
445
+ line-height: 1.45;
446
+ letter-spacing: 0.005em;
447
+ }
448
+ .byline-block.byline-authors .author-sep {
449
+ color: var(--rule, currentColor);
450
+ opacity: 0.45;
451
+ margin: 0 1px;
452
+ font-weight: 400;
453
+ }
454
+
336
455
  /* ─── Suppress duplicate borders at section boundaries ──────────
337
456
  When a numbered-list section (The Bet, Big Ideas) is immediately
338
457
  followed by a .chapter-num divider for the next chapter, the
@@ -368,7 +487,7 @@
368
487
  Pattern hit hard by Risks-to-Manage / Pre-Mortem (and any other
369
488
  table-first section): the chapter-num kicker above the H2
370
489
  already carries an underline (its border-bottom hairline), and
371
- the table immediately below the H2 carries its own 1.5px
490
+ the table immediately below the H2 carries its own 2px
372
491
  border-top. With only the H2 title between them, those two
373
492
  parallel rules read as a doubled frame around the heading. The
374
493
  table's row separators keep its internal structure clear without
@@ -378,111 +497,2084 @@
378
497
  border-top: none;
379
498
  }
380
499
 
500
+ /* ─── Mermaid + adjacent table = doubled hairline ───────────────
501
+ Every spine's `pre.mermaid` carries a 1px `border-bottom` (the
502
+ mermaid frame's closing rule). A pipe-table directly underneath
503
+ carries its own 1.5–2px `border-top`. With only a paragraph
504
+ break between, the two parallel rules read as a doubled frame.
505
+ Drop the table's top border in that exact arrangement — the
506
+ mermaid's bottom rule already separates the diagram from the
507
+ table cleanly. Same reasoning works for the reverse order: a
508
+ mermaid right after a table inherits the table's last-row
509
+ border-bottom, so suppress the mermaid's top rule there too. */
510
+ .body pre.mermaid + table.md-table {
511
+ border-top: none;
512
+ }
513
+ .body table.md-table + pre.mermaid {
514
+ border-top-width: 0;
515
+ }
516
+
381
517
  /* ─── Metric-strip · spine-agnostic baseline ─────────────────────
382
- A row of KPI cards generated by the ```metric-strip fenced block.
383
- Per-spine CSS (public/report/spines/*.css) overrides surfaces +
384
- typography for visual personality; this baseline is what every
385
- spine inherits before its overrides land keeps the strip
386
- readable even for spines that haven't shipped a treatment yet.
518
+ Editorial / Swiss register: oversized thin numerals top-left, mute
519
+ mono label bottom-left, hairline grid (no card backgrounds, no
520
+ surrounding box). Whitespace + typography do the work. Per-spine
521
+ CSS (public/report/spines/*.css) only swaps font tokens + accent
522
+ colours; the layout and rhythm are constant across spines so a
523
+ "key metrics" block reads the same shape no matter which voice
524
+ the report is in.
387
525
 
388
526
  Discipline:
389
- · No `border-left` as a callout (project rule · feedback memo).
390
- · Card boundaries are surface + spacing, not double parallel
391
- borders. The grid sits on the section background; cards lift
392
- via a tiny background tint.
393
- · Metric value is the eye-catch · big tabular numerals.
394
- · Trend glyph is small and doesn't compete with the value. */
527
+ · No `border-left` callout treatment. The grid uses border-right
528
+ on cells purely as a divider not as an accent rail.
529
+ · Single hairline weight (1px var(--rule)) wraps the grid top +
530
+ bottom and slices between cells. No double parallel borders
531
+ against adjacent .chapter-num / section h2 (those only carry
532
+ underlines on the heading itself, not on this block).
533
+ · The big value is the eye-catch · thin sans, large size.
534
+ · Trend glyph rides as a small superscript so it never competes
535
+ with the value. */
395
536
  .body .metric-strip {
396
- margin: 18px 0 22px;
537
+ margin: 30px 0 36px;
397
538
  }
539
+ /* Intro kicker · small mono caption with the Dribbble-style solid
540
+ square bullet to anchor the line. Optional · only renders when
541
+ the metric-strip JSON carries `intro`. */
398
542
  .body .metric-strip-intro {
399
543
  font-family: var(--mono);
400
544
  font-size: 11px;
401
- letter-spacing: 0.06em;
545
+ font-weight: 600;
546
+ letter-spacing: 0.14em;
402
547
  text-transform: uppercase;
403
548
  color: var(--text-soft, #8E8B83);
404
- margin: 0 0 10px;
549
+ margin: 0 0 22px;
550
+ display: flex;
551
+ align-items: center;
552
+ gap: 11px;
553
+ }
554
+ .body .metric-strip-intro::before {
555
+ content: "";
556
+ display: inline-block;
557
+ width: 7px;
558
+ height: 7px;
559
+ background: currentColor;
560
+ flex-shrink: 0;
405
561
  }
562
+ /* Grid · explicit columns per card count. Top + bottom hairlines
563
+ bracket the block, vertical hairlines slice between cells. */
406
564
  .body .metric-strip-grid {
407
565
  display: grid;
408
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
409
- gap: 10px;
566
+ grid-template-columns: repeat(3, 1fr);
410
567
  align-items: stretch;
568
+ border-top: 1px solid var(--rule, var(--line-bright, rgba(255, 255, 255, 0.08)));
569
+ border-bottom: 1px solid var(--rule, var(--line-bright, rgba(255, 255, 255, 0.08)));
570
+ }
571
+ .body .metric-strip-grid[data-cards="2"] { grid-template-columns: repeat(2, 1fr); }
572
+ .body .metric-strip-grid[data-cards="3"] { grid-template-columns: repeat(3, 1fr); }
573
+ .body .metric-strip-grid[data-cards="4"] { grid-template-columns: repeat(4, 1fr); }
574
+ .body .metric-strip-grid[data-cards="5"] { grid-template-columns: repeat(5, 1fr); }
575
+ /* Narrow viewports · collapse to a 2×N grid so cells stay readable.
576
+ Cards on row 2+ pick up a top border so the horizontal rules slot
577
+ in correctly. Print uses `display: block` from the page-break
578
+ rules above, so this only affects on-screen at tablet / phone. */
579
+ @media (max-width: 720px) {
580
+ .body .metric-strip-grid,
581
+ .body .metric-strip-grid[data-cards="3"],
582
+ .body .metric-strip-grid[data-cards="4"],
583
+ .body .metric-strip-grid[data-cards="5"] {
584
+ grid-template-columns: repeat(2, 1fr);
585
+ }
586
+ .body .metric-strip-grid > .metric-card { border-right: 1px solid var(--rule); }
587
+ .body .metric-strip-grid > .metric-card:nth-child(2n) { border-right: 0; }
411
588
  }
589
+ /* Cell · pure padded box, no surrounding border, no fill. Vertical
590
+ hairline lives on the right edge; the LAST column drops it. */
412
591
  .body .metric-card {
413
592
  display: flex;
414
593
  flex-direction: column;
415
- gap: 6px;
416
- padding: 14px 16px;
417
- background: var(--panel-2, rgba(255, 255, 255, 0.025));
418
- border: 0.5px solid var(--line-bright, rgba(255, 255, 255, 0.08));
419
- min-height: 92px;
594
+ padding: 22px 20px 18px;
595
+ background: transparent;
596
+ border: 0;
597
+ border-right: 1px solid var(--rule, var(--line-bright, rgba(255, 255, 255, 0.08)));
598
+ min-height: 144px;
599
+ overflow: hidden;
600
+ position: relative;
420
601
  }
421
- .body .metric-label {
422
- font-family: var(--mono);
423
- font-size: 10px;
424
- letter-spacing: 0.1em;
425
- text-transform: uppercase;
426
- color: var(--text-soft, #8E8B83);
427
- line-height: 1.3;
602
+ .body .metric-strip-grid > .metric-card:last-child { border-right: 0; }
603
+ /* Last column drop · works for any data-cards value. */
604
+ .body .metric-strip-grid[data-cards="2"] > .metric-card:nth-child(2n),
605
+ .body .metric-strip-grid[data-cards="3"] > .metric-card:nth-child(3n),
606
+ .body .metric-strip-grid[data-cards="4"] > .metric-card:nth-child(4n),
607
+ .body .metric-strip-grid[data-cards="5"] > .metric-card:nth-child(5n) {
608
+ border-right: 0;
428
609
  }
610
+ /* Visual order · value at top, label at bottom, qualifier +
611
+ attribution stack just above the label. Done with flex `order`
612
+ so the renderer's HTML stays in author order (label, value-row,
613
+ qualifier, attribution) and screen readers read label-first. */
614
+ .body .metric-card > .metric-value-row { order: 1; margin-bottom: auto; }
615
+ .body .metric-card > .metric-qualifier { order: 2; margin-top: 16px; }
616
+ .body .metric-card > .metric-attribution { order: 3; margin-top: 6px; }
617
+ .body .metric-card > .metric-label { order: 4; margin-top: 12px; }
618
+ /* Big number · sans light, oversized. Tabular nums so digits land
619
+ on the same column inside one card (e.g. 1,243.5 stays aligned
620
+ when the card is multi-line). */
429
621
  .body .metric-value-row {
430
622
  display: flex;
431
623
  align-items: baseline;
432
624
  gap: 8px;
433
625
  line-height: 1;
626
+ margin: 0;
434
627
  }
435
628
  .body .metric-value {
436
- font-family: "SF Mono", "JetBrains Mono", "Menlo", "Helvetica Neue",
437
- "Songti SC", monospace;
438
- font-size: 26px;
439
- font-weight: 700;
440
- font-variant-numeric: tabular-nums lining-nums;
441
- color: var(--text, #C8C5BE);
442
- letter-spacing: -0.01em;
629
+ font-family: var(--sans, "Söhne", "Inter", "Helvetica Neue", "Helvetica",
630
+ "Arial", "PingFang SC", sans-serif);
631
+ font-size: 40px;
632
+ font-weight: 300;
633
+ font-variant-numeric: lining-nums tabular-nums;
634
+ color: var(--ink, var(--text, #C8C5BE));
635
+ letter-spacing: -0.022em;
636
+ line-height: 0.95;
637
+ /* Long values shouldn't break across lines or shrink the card —
638
+ overflow with ellipsis instead, which is rare in practice (most
639
+ metrics are 4-7 chars). Letter-spacing already pulls them tight. */
640
+ white-space: nowrap;
641
+ overflow: hidden;
642
+ text-overflow: ellipsis;
643
+ min-width: 0;
443
644
  }
645
+ /* Per-card-count value sizing · denser layouts get smaller numerals
646
+ so 5-card rows on a 940px content width don't squash. The baseline
647
+ 40px is the 3-card default; 4 / 5 cards step down, 2 cards step
648
+ up since each cell has more horizontal room. */
649
+ .body .metric-strip-grid[data-cards="2"] .metric-value { font-size: 52px; }
650
+ .body .metric-strip-grid[data-cards="3"] .metric-value { font-size: 44px; }
651
+ .body .metric-strip-grid[data-cards="4"] .metric-value { font-size: 36px; }
652
+ .body .metric-strip-grid[data-cards="5"] .metric-value { font-size: 30px; }
653
+ @media (max-width: 720px) {
654
+ /* Narrow viewport collapses to 2-column grid (see below) — value
655
+ size returns to the 2-card baseline so cells with more room
656
+ use it instead of staying in 4/5-card density. */
657
+ .body .metric-strip-grid[data-cards="3"] .metric-value,
658
+ .body .metric-strip-grid[data-cards="4"] .metric-value,
659
+ .body .metric-strip-grid[data-cards="5"] .metric-value { font-size: 44px; }
660
+ }
661
+ /* Trend glyph · rides like a small annotation next to the number.
662
+ Mono so it reads as a chip, not as part of the value. The negative
663
+ `top` lifts it to align with the number's cap height. */
444
664
  .body .metric-trend {
445
- font-family: "SF Mono", "JetBrains Mono", monospace;
446
- font-size: 14px;
665
+ font-family: var(--mono);
666
+ font-size: 11px;
447
667
  line-height: 1;
448
668
  color: var(--text-dim, #5C5A52);
669
+ letter-spacing: 0.04em;
670
+ position: relative;
671
+ top: -14px;
672
+ flex-shrink: 0;
449
673
  }
450
674
  .body .metric-trend[data-trend="up"] { color: var(--em, var(--lime, #6FB572)); }
451
675
  .body .metric-trend[data-trend="down"] { color: var(--red, #B5706A); }
452
676
  .body .metric-trend[data-trend="flat"] { color: var(--text-dim, #5C5A52); }
677
+ /* Label · mono caps caption pinned to the bottom of the card.
678
+ Allowed to wrap to 2 lines · long-form labels (e.g. "Time to first
679
+ production deploy") shouldn't truncate to two letters when there's
680
+ room on a second line. */
681
+ .body .metric-label {
682
+ font-family: var(--mono);
683
+ font-size: 10px;
684
+ font-weight: 600;
685
+ letter-spacing: 0.14em;
686
+ text-transform: uppercase;
687
+ color: var(--text-soft, #8E8B83);
688
+ line-height: 1.4;
689
+ display: -webkit-box;
690
+ -webkit-line-clamp: 2;
691
+ -webkit-box-orient: vertical;
692
+ overflow: hidden;
693
+ }
694
+ /* Qualifier · sans, small, sits just above the attribution / label.
695
+ Capped to 2 lines so a chatty qualifier can't push the value
696
+ off-screen. */
453
697
  .body .metric-qualifier {
454
698
  font-family: var(--sans);
455
- font-size: 11.5px;
456
- line-height: 1.4;
699
+ font-size: 12px;
700
+ line-height: 1.45;
457
701
  color: var(--text-soft, #8E8B83);
458
702
  letter-spacing: -0.003em;
703
+ display: -webkit-box;
704
+ -webkit-line-clamp: 2;
705
+ -webkit-box-orient: vertical;
706
+ overflow: hidden;
459
707
  }
708
+ /* Attribution · faint mono caption between qualifier and label. */
460
709
  .body .metric-attribution {
461
710
  font-family: var(--mono);
462
- font-size: 9.5px;
711
+ font-size: 9px;
712
+ font-weight: 600;
713
+ letter-spacing: 0.1em;
714
+ text-transform: uppercase;
715
+ color: var(--text-faint, #5C5A4D);
716
+ overflow: hidden;
717
+ text-overflow: ellipsis;
718
+ white-space: nowrap;
719
+ }
720
+ /* Print · keep cells intact across page breaks. */
721
+ @media print {
722
+ .body .metric-card { break-inside: avoid; page-break-inside: avoid; }
723
+ }
724
+ /* Defensive · malformed strips render as a quiet placeholder. */
725
+ .body .metric-strip-error {
726
+ padding: 12px 14px;
727
+ background: var(--panel-3, rgba(255, 255, 255, 0.04));
728
+ color: var(--text-dim, #5C5A52);
729
+ font-family: var(--mono);
730
+ font-size: 11px;
731
+ letter-spacing: 0.06em;
732
+ }
733
+ .body .metric-strip-error::before {
734
+ content: "// metric-strip · malformed";
735
+ display: block;
736
+ margin-bottom: 6px;
737
+ color: var(--red, #B5706A);
738
+ }
739
+
740
+ /* ─── Path-comparison · spine-agnostic baseline ──────────────────
741
+ Side-by-side binary comparison · 2 columns, verdict-tagged.
742
+ Reference: mvp/screen-7-report-anthropic.html "03 — A Comparison"
743
+ block. Per-spine CSS overrides accent colours; this baseline
744
+ gives every spine a working render before its overrides ship.
745
+
746
+ Discipline:
747
+ · NO border-left as accent (project rule). Top accent rule
748
+ encodes stance instead.
749
+ · 2px stance accent on top edge — matches the report's
750
+ heaviest-rule reservation tier.
751
+ · 1px outer rule on each card — single ring, no doubled frames. */
752
+ .body .path-comparison { margin: 28px 0 32px; }
753
+ .body .path-comparison-intro {
754
+ font-family: var(--sans);
755
+ font-size: 14px;
756
+ line-height: 1.65;
757
+ color: var(--text-soft, var(--ink-soft, #5C5A52));
758
+ margin: 0 0 20px;
759
+ max-width: 740px;
760
+ }
761
+ .body .path-comparison .paths {
762
+ display: grid;
763
+ grid-template-columns: 1fr 1fr;
764
+ gap: 24px;
765
+ align-items: stretch;
766
+ }
767
+ @media (max-width: 720px) {
768
+ .body .path-comparison .paths { grid-template-columns: 1fr; }
769
+ }
770
+ .body .path-comparison .path {
771
+ padding: 28px;
772
+ background: var(--paper-soft, var(--panel-2, rgba(255, 255, 255, 0.025)));
773
+ border: 1px solid var(--rule, var(--line-bright, rgba(255, 255, 255, 0.08)));
774
+ border-top-width: 2px;
775
+ border-top-color: var(--rule, var(--line-bright, rgba(255, 255, 255, 0.08)));
776
+ display: flex;
777
+ flex-direction: column;
778
+ }
779
+ /* Stance accents · weak / strong / neutral. Each spine remaps
780
+ these to its palette in its own CSS file. */
781
+ .body .path-comparison .path-weak { border-top-color: var(--red, #B5706A); }
782
+ .body .path-comparison .path-strong { border-top-color: var(--em, #6FB572); }
783
+ /* path-neutral keeps the default rule colour. */
784
+
785
+ .body .path-comparison .path-tag {
786
+ font-family: var(--mono);
787
+ font-size: 11px;
788
+ font-weight: 600;
789
+ letter-spacing: 0.16em;
790
+ text-transform: uppercase;
791
+ color: var(--text-soft, var(--ink-soft, #8E8B83));
792
+ margin-bottom: 14px;
793
+ }
794
+ .body .path-comparison .path-weak .path-tag { color: var(--red, #B5706A); }
795
+ .body .path-comparison .path-strong .path-tag { color: var(--em, #6FB572); }
796
+
797
+ .body .path-comparison .path-name {
798
+ font-family: var(--font-headline, var(--serif, "Tiempos Headline", "Charter", Georgia, serif));
799
+ font-size: 20px;
800
+ font-weight: 500;
801
+ line-height: 1.35;
802
+ color: var(--ink, var(--text, #C8C5BE));
803
+ margin-bottom: 18px;
804
+ letter-spacing: -0.005em;
805
+ }
806
+ .body .path-comparison .path-name-line + .path-name-sub {
807
+ margin-top: 4px;
808
+ font-size: 14px;
809
+ font-weight: 400;
810
+ color: var(--text-soft, var(--ink-soft, #8E8B83));
811
+ line-height: 1.45;
812
+ letter-spacing: 0;
813
+ }
814
+ .body .path-comparison .path ul {
815
+ list-style: none;
816
+ padding: 0;
817
+ margin: 0 0 auto;
818
+ }
819
+ .body .path-comparison .path li {
820
+ position: relative;
821
+ padding: 10px 0 10px 22px;
822
+ font-family: var(--sans);
823
+ font-size: 14px;
824
+ line-height: 1.6;
825
+ color: var(--text-soft, var(--ink-soft, #5C5A52));
826
+ border-bottom: 1px solid var(--rule-soft, var(--rule, rgba(0, 0, 0, 0.08)));
827
+ }
828
+ .body .path-comparison .path li:last-child { border-bottom: 0; }
829
+ .body .path-comparison .path li::before {
830
+ content: "";
831
+ position: absolute;
832
+ left: 0;
833
+ top: 16px;
834
+ width: 6px;
835
+ height: 6px;
836
+ border-radius: 50%;
837
+ background: var(--text-faint, var(--ink-muted, #5C5A4D));
838
+ }
839
+ .body .path-comparison .path-weak li::before { background: var(--red, #B5706A); }
840
+ .body .path-comparison .path-strong li::before { background: var(--em, #6FB572); }
841
+ .body .path-comparison-implication {
842
+ margin: 24px 0 0;
843
+ font-family: var(--font-headline, var(--serif, "Tiempos", "Charter", Georgia, serif));
844
+ font-size: 16px;
845
+ line-height: 1.6;
846
+ color: var(--ink-soft, var(--text-soft, #4A4338));
847
+ font-style: italic;
848
+ max-width: 740px;
849
+ }
850
+ /* Print · keep both columns on the same page when possible. */
851
+ @media print {
852
+ .body .path-comparison { break-inside: avoid; page-break-inside: avoid; }
853
+ .body .path-comparison .path { break-inside: avoid; page-break-inside: avoid; }
854
+ }
855
+ /* Defensive · malformed path-comparison renders as a quiet placeholder. */
856
+ .body .path-comparison-error {
857
+ padding: 12px 14px;
858
+ background: var(--panel-3, rgba(255, 255, 255, 0.04));
859
+ color: var(--text-dim, #5C5A52);
860
+ font-family: var(--mono);
861
+ font-size: 11px;
862
+ letter-spacing: 0.06em;
863
+ }
864
+ .body .path-comparison-error::before {
865
+ content: "// path-comparison · malformed";
866
+ display: block;
867
+ margin-bottom: 6px;
868
+ color: var(--red, #B5706A);
869
+ }
870
+
871
+ /* ─── Views-compared · spine-agnostic baseline ──────────────────
872
+ The room's social map · always rendered when ≥ 2 directors.
873
+ Layout: intro → 2-column compare grid (alignment | divergence)
874
+ → per-director rows (full width) → chair synthesis (full width).
875
+ Per-spine CSS swaps accent + surface tokens; layout is constant. */
876
+ .body .views-compared { margin: 28px 0 32px; }
877
+ .body .views-compared-intro {
878
+ font-family: var(--sans);
879
+ font-size: 14px;
880
+ line-height: 1.65;
881
+ color: var(--text-soft, var(--ink-soft, #5C5A52));
882
+ margin: 0 0 24px;
883
+ max-width: 740px;
884
+ }
885
+ .body .vc-section-label {
886
+ font-family: var(--mono);
887
+ font-size: 10px;
888
+ font-weight: 600;
889
+ letter-spacing: 0.2em;
890
+ text-transform: uppercase;
891
+ color: var(--text-soft, var(--ink-faint, #978C7E));
892
+ margin: 0 0 12px;
893
+ padding: 0 0 8px;
894
+ border-bottom: 1px solid var(--rule, var(--line-bright, rgba(255, 255, 255, 0.08)));
895
+ }
896
+
897
+ /* ── 2-column compare grid · agreement (left) | disagreement (right) ──
898
+ Side-by-side reading is the whole point — the contrast lands in
899
+ one glance instead of after scrolling. Below 760px the columns
900
+ stack so the per-card content doesn't crush. */
901
+ .body .vc-compare-grid {
902
+ display: grid;
903
+ grid-template-columns: 1fr 1fr;
904
+ gap: 28px;
905
+ margin: 0 0 32px;
906
+ align-items: start;
907
+ }
908
+ @media (max-width: 760px) {
909
+ .body .vc-compare-grid { grid-template-columns: 1fr; gap: 24px; }
910
+ }
911
+ .body .vc-compare-col { min-width: 0; }
912
+ .body .vc-compare-empty {
913
+ font-family: var(--mono);
914
+ font-size: 11px;
915
+ letter-spacing: 0.04em;
916
+ color: var(--text-faint, var(--ink-faint, #978C7E));
917
+ font-style: italic;
918
+ padding: 18px 0;
919
+ line-height: 1.55;
920
+ }
921
+
922
+ /* ── Alignment cards · refined editorial register ──
923
+ Hairline frame with the accent stripe on TOP (per project rule
924
+ — left-borders are forbidden as callout treatments). 12px gap
925
+ between cards keeps each card's accent-rule clearly its own. No
926
+ surface fill — the spine's paper bg shows through. */
927
+ .body .views-compared-alignment .vc-card {
928
+ background: transparent;
929
+ border: 1px solid var(--rule, var(--line-bright, rgba(255, 255, 255, 0.08)));
930
+ border-top: 2px solid var(--em, var(--lime, #6FB572));
931
+ padding: 14px 16px;
932
+ margin: 0 0 12px;
933
+ }
934
+ .body .views-compared-alignment .vc-card:last-child { margin-bottom: 0; }
935
+ .body .vc-card-headline {
936
+ font-family: var(--font-headline, var(--serif, "Tiempos Headline", "Charter", Georgia, serif));
937
+ font-size: 16px;
938
+ font-weight: 500;
939
+ line-height: 1.35;
940
+ color: var(--ink, var(--text, #C8C5BE));
941
+ margin: 0 0 8px;
942
+ letter-spacing: -0.005em;
943
+ }
944
+ .body .vc-card-directors,
945
+ .body .vc-side-directors {
946
+ display: flex;
947
+ flex-wrap: wrap;
948
+ gap: 5px;
949
+ margin: 0 0 8px;
950
+ }
951
+ .body .vc-chip {
952
+ display: inline-flex;
953
+ align-items: center;
954
+ padding: 2px 8px;
955
+ font-family: var(--mono);
956
+ font-size: 10px;
957
+ letter-spacing: 0.05em;
958
+ color: var(--text-soft, var(--ink-mid, #6B6359));
959
+ background: transparent;
960
+ border: 1px solid var(--rule, var(--line-bright, rgba(255, 255, 255, 0.08)));
961
+ border-radius: 10px;
962
+ line-height: 1.45;
963
+ }
964
+ .body .vc-card-note {
965
+ font-family: var(--sans);
966
+ font-size: 13px;
967
+ line-height: 1.55;
968
+ color: var(--text-soft, var(--ink-soft, #5C5A52));
969
+ margin: 0;
970
+ }
971
+
972
+ /* ── Divergence forks · same hairline-frame register, but the
973
+ top-accent stripe is in WARN/red instead of agreement-green.
974
+ Compact side-by-side panels stack on narrow viewports. ── */
975
+ .body .vc-fork {
976
+ border: 1px solid var(--rule, var(--line-bright, rgba(255, 255, 255, 0.08)));
977
+ border-top: 2px solid var(--warn, var(--clay-deep, var(--red, #B5706A)));
978
+ padding: 14px 16px;
979
+ margin: 0 0 12px;
980
+ }
981
+ .body .vc-fork:last-child { margin-bottom: 0; }
982
+ .body .vc-fork-hinge {
983
+ font-family: var(--font-headline, var(--serif, Georgia, serif));
984
+ font-style: italic;
985
+ font-size: 15px;
986
+ line-height: 1.45;
987
+ color: var(--ink, var(--text, #C8C5BE));
988
+ margin: 0 0 14px;
989
+ }
990
+ .body .vc-fork-sides {
991
+ display: grid;
992
+ grid-template-columns: 1fr 1fr;
993
+ gap: 12px;
994
+ margin: 0 0 10px;
995
+ }
996
+ .body .vc-fork-sides[data-sides="3"] { grid-template-columns: 1fr 1fr 1fr; }
997
+ @media (max-width: 920px) {
998
+ /* In the 2-col compare-grid container, fork-sides is already
999
+ cramped — collapse to single column so each side breathes. */
1000
+ .body .vc-fork-sides,
1001
+ .body .vc-fork-sides[data-sides="3"] { grid-template-columns: 1fr; }
1002
+ }
1003
+ .body .vc-side {
1004
+ padding: 10px 12px;
1005
+ background: transparent;
1006
+ border-top: 1px solid var(--rule-soft, var(--rule, rgba(0, 0, 0, 0.06)));
1007
+ }
1008
+ .body .vc-side-label {
1009
+ font-family: var(--mono);
1010
+ font-size: 10px;
1011
+ font-weight: 600;
1012
+ letter-spacing: 0.16em;
1013
+ text-transform: uppercase;
1014
+ color: var(--warn, var(--clay-deep, var(--red, #B5706A)));
1015
+ margin: 0 0 8px;
1016
+ }
1017
+ .body .vc-side-stance {
1018
+ font-family: var(--sans);
1019
+ font-size: 13px;
1020
+ line-height: 1.55;
1021
+ color: var(--text-soft, var(--ink-soft, #5C5A52));
1022
+ margin: 0;
1023
+ }
1024
+ .body .vc-fork-resolution {
1025
+ font-family: var(--sans);
1026
+ font-size: 13px;
1027
+ line-height: 1.55;
1028
+ color: var(--text-soft, var(--ink-mid, #6B6359));
1029
+ margin: 6px 0 0;
1030
+ padding: 8px 0 0;
1031
+ border-top: 1px dashed var(--rule-soft, var(--rule, rgba(0, 0, 0, 0.06)));
1032
+ }
1033
+ .body .vc-resolution-label {
1034
+ font-family: var(--mono);
1035
+ font-size: 10px;
1036
+ font-weight: 600;
1037
+ letter-spacing: 0.16em;
1038
+ text-transform: uppercase;
1039
+ color: var(--text-soft, var(--ink-faint, #978C7E));
1040
+ margin-right: 4px;
1041
+ }
1042
+
1043
+ /* ── Per-director rows · tighter editorial rhythm ── */
1044
+ .body .views-compared-perspectives { margin: 0 0 24px; max-width: 820px; }
1045
+ .body .vc-row {
1046
+ border-top: 1px solid var(--rule, var(--line-bright, rgba(255, 255, 255, 0.08)));
1047
+ padding: 14px 0 13px;
1048
+ }
1049
+ /* Suppress the first row's top border when it sits directly under
1050
+ the section label · the label already carries a 1px border-bottom
1051
+ and a matching adjacent rule reads as a doubled frame around the
1052
+ heading (per CLAUDE.md no-double-parallel-borders rule). The
1053
+ label's underline opens the row series; the first row inherits
1054
+ its boundary from above. */
1055
+ .body .views-compared-perspectives > .vc-section-label + .vc-row {
1056
+ border-top: none;
1057
+ }
1058
+ .body .vc-row:last-child { border-bottom: 1px solid var(--rule, rgba(0, 0, 0, 0.08)); }
1059
+ .body .vc-row-head {
1060
+ display: flex;
1061
+ align-items: baseline;
1062
+ justify-content: space-between;
1063
+ gap: 12px;
1064
+ margin: 0 0 5px;
1065
+ }
1066
+ .body .vc-row-name {
1067
+ font-family: var(--font-headline, var(--serif, Georgia, serif));
1068
+ font-size: 16px;
1069
+ font-weight: 500;
1070
+ color: var(--ink, var(--text, #C8C5BE));
1071
+ letter-spacing: -0.005em;
1072
+ }
1073
+ .body .vc-row-lens {
1074
+ font-family: var(--mono);
1075
+ font-size: 10px;
1076
+ font-weight: 600;
1077
+ letter-spacing: 0.18em;
1078
+ text-transform: uppercase;
1079
+ color: var(--text-faint, var(--ink-faint, #978C7E));
1080
+ }
1081
+ .body .vc-row-stance {
1082
+ font-family: var(--sans);
1083
+ font-style: italic;
1084
+ font-size: 13px;
1085
+ line-height: 1.45;
1086
+ color: var(--em, var(--clay-deep, var(--lime, #6FB572)));
1087
+ margin: 0 0 6px;
1088
+ }
1089
+ .body .vc-row-position {
1090
+ font-family: var(--sans);
1091
+ font-size: 14px;
1092
+ line-height: 1.7;
1093
+ color: var(--text-soft, var(--ink-soft, #5C5A52));
1094
+ margin: 0;
1095
+ max-width: 720px;
1096
+ }
1097
+ /* Director quote · italic serif at slightly smaller size. The
1098
+ italic + serif + colour-shift carries the "this is the spoken
1099
+ fragment" register; no left-border ornament (project rule). */
1100
+ .body .vc-row-quote {
1101
+ font-family: var(--font-headline, var(--serif, Georgia, serif));
1102
+ font-style: italic;
1103
+ font-size: 14px;
1104
+ line-height: 1.55;
1105
+ color: var(--text, var(--ink-mid, #6B6359));
1106
+ margin: 7px 0 0;
1107
+ max-width: 700px;
1108
+ }
1109
+ .body .vc-row-quote::before { content: "“"; }
1110
+ .body .vc-row-quote::after { content: "”"; }
1111
+
1112
+ /* ── Chair synthesis · closing block · italic serif framed by a
1113
+ thin top accent (mute ink, not a spine accent), so it reads as
1114
+ a closing remark rather than another argument. ── */
1115
+ .body .views-compared-synthesis {
1116
+ margin: 4px 0 0;
1117
+ padding: 14px 18px 16px;
1118
+ background: transparent;
1119
+ border: 1px solid var(--rule, rgba(0, 0, 0, 0.08));
1120
+ border-top: 2px solid var(--ink-faint, var(--text-faint, #978C7E));
1121
+ }
1122
+ .body .views-compared-synthesis .vc-section-label { margin-bottom: 8px; padding-bottom: 6px; }
1123
+ .body .vc-synthesis-body {
1124
+ font-family: var(--font-headline, var(--serif, Georgia, serif));
1125
+ font-style: italic;
1126
+ font-size: 14px;
1127
+ line-height: 1.6;
1128
+ color: var(--ink, var(--text, #C8C5BE));
1129
+ margin: 0;
1130
+ max-width: 720px;
1131
+ }
1132
+
1133
+ /* Print · the Views-Compared module needs three distinct fixes:
1134
+ (a) page-break protection so the layout doesn't fragment across
1135
+ pages,
1136
+ (b) the NESTED divergence-fork grid (.vc-fork-sides, 2 or 3
1137
+ cols) must collapse to a single column — at PDF width the
1138
+ parent compare-grid already chops each side to ~314px, and
1139
+ a nested 2-col inside that crushes text to ~150px columns
1140
+ which read as "broken layout",
1141
+ (c) explicit ink-on-paper colors for everything whose default
1142
+ tokens resolve to translucent-white on dark spines (e.g.
1143
+ `--line-bright: rgba(255, 255, 255, 0.08)`, the various
1144
+ `--text-*` tokens in dark themes). On screen those work
1145
+ against the dark panel; in PDF the page background is
1146
+ paper, and translucent-white on white is invisible — which
1147
+ is why "section labels disappeared", "chip borders
1148
+ vanished", and "fork accent stripes were missing".
1149
+ Side-by-side comparison (alignment | divergence) at the OUTER
1150
+ compare-grid level IS preserved in PDF — that contrast is the
1151
+ whole point of the module. Only the nested fork-sides stack. */
1152
+ @media print {
1153
+ .body .views-compared {
1154
+ break-inside: avoid-page;
1155
+ }
1156
+ .body .views-compared-intro,
1157
+ .body .vc-compare-grid,
1158
+ .body .vc-compare-col,
1159
+ .body .vc-card,
1160
+ .body .vc-fork,
1161
+ .body .vc-side,
1162
+ .body .vc-row,
1163
+ .body .views-compared-synthesis {
1164
+ break-inside: avoid;
1165
+ page-break-inside: avoid;
1166
+ }
1167
+
1168
+ /* ── Layout fix · keep alignment | divergence side-by-side in PDF ──
1169
+ The screen-only `@media (max-width: 760px)` rule fires in Chrome's
1170
+ print pipeline at A4 portrait (printable area is ~688px) and
1171
+ stacks the columns, killing the contrast that's the whole point
1172
+ of the module. Force 2-col here with explicit `!important` so the
1173
+ responsive breakpoint can't override us in print. */
1174
+ .body .vc-compare-grid {
1175
+ display: grid !important;
1176
+ grid-template-columns: 1fr 1fr !important;
1177
+ gap: 18px !important;
1178
+ align-items: start !important;
1179
+ }
1180
+
1181
+ /* Stack the NESTED fork sides · at PDF width each outer column is
1182
+ ~314px and a nested 2-col would crush each side to ~150px. */
1183
+ .body .vc-fork-sides,
1184
+ .body .vc-fork-sides[data-sides="3"] {
1185
+ grid-template-columns: 1fr !important;
1186
+ gap: 8px !important;
1187
+ }
1188
+ /* Side dividers as horizontal hairlines once stacked · matches
1189
+ the on-screen narrow-width breakpoint behaviour. */
1190
+ .body .vc-side {
1191
+ border-top: 1px solid #DCD3BD !important;
1192
+ padding: 8px 12px !important;
1193
+ }
1194
+ .body .vc-side:first-child { border-top: none !important; padding-top: 0 !important; }
1195
+
1196
+ /* Cap content widths · stop overflow regardless of spine padding. */
1197
+ .body .vc-card,
1198
+ .body .vc-fork,
1199
+ .body .vc-side,
1200
+ .body .vc-row,
1201
+ .body .views-compared-synthesis,
1202
+ .body .vc-card-headline,
1203
+ .body .vc-card-note,
1204
+ .body .vc-row-position,
1205
+ .body .vc-row-quote,
1206
+ .body .vc-synthesis-body,
1207
+ .body .vc-fork-hinge,
1208
+ .body .vc-side-stance,
1209
+ .body .vc-fork-resolution {
1210
+ max-width: 100% !important;
1211
+ }
1212
+
1213
+ /* ── Visibility · force ink-on-paper colors ──
1214
+ Elements with translucent / dark-spine tokens get explicit
1215
+ dark values that print legibly. */
1216
+ .body .vc-section-label {
1217
+ color: #38332B !important;
1218
+ border-bottom-color: #DCD3BD !important;
1219
+ }
1220
+ /* Director chips · the on-screen `display: inline-flex` +
1221
+ `align-items: center` + `font-family: var(--mono)` combo
1222
+ ships text that prints blank in Chrome's PDF pipeline when
1223
+ the var-resolved font isn't in the headless Chrome font path
1224
+ (PingFang / SF Mono fall through to Songti, etc., which the
1225
+ print engine sometimes drops at 10px line-height: 1.45 with
1226
+ inline-flex containing). Reset to a print-safe shape:
1227
+ inline-block, explicit monospace stack with system fallbacks
1228
+ Chrome's PDF engine always has, slightly larger font for
1229
+ legibility, padding tuned so the small text doesn't get
1230
+ clipped by border-radius. */
1231
+ .body .vc-chip {
1232
+ display: inline-block !important;
1233
+ padding: 3px 10px !important;
1234
+ margin: 0 !important;
1235
+ font-family: "Helvetica", "Arial", "Songti SC", "STSong", sans-serif !important;
1236
+ font-size: 11px !important;
1237
+ font-weight: 500 !important;
1238
+ letter-spacing: 0.02em !important;
1239
+ line-height: 1.4 !important;
1240
+ color: #38332B !important;
1241
+ background: transparent !important;
1242
+ border: 1px solid #BCB39A !important;
1243
+ border-radius: 10px !important;
1244
+ vertical-align: baseline !important;
1245
+ -webkit-print-color-adjust: exact;
1246
+ print-color-adjust: exact;
1247
+ }
1248
+ /* Alignment card · clay-deep top accent, hairline frame. */
1249
+ .body .views-compared-alignment .vc-card {
1250
+ border: 1px solid #DCD3BD !important;
1251
+ border-top: 2px solid #A85A41 !important;
1252
+ -webkit-print-color-adjust: exact;
1253
+ print-color-adjust: exact;
1254
+ }
1255
+ /* Divergence fork · same chrome but the accent reads as the
1256
+ "split" marker. Same clay-deep so the module reads consistent
1257
+ in print; the contrast comes from the section labels above
1258
+ each column, not from competing accent colours. */
1259
+ .body .vc-fork {
1260
+ border: 1px solid #DCD3BD !important;
1261
+ border-top: 2px solid #A85A41 !important;
1262
+ -webkit-print-color-adjust: exact;
1263
+ print-color-adjust: exact;
1264
+ }
1265
+ .body .vc-fork-hinge {
1266
+ color: #14110C !important;
1267
+ }
1268
+ .body .vc-side-label {
1269
+ color: #A85A41 !important;
1270
+ }
1271
+ .body .vc-card-headline,
1272
+ .body .vc-row-name {
1273
+ color: #14110C !important;
1274
+ }
1275
+ .body .vc-card-note,
1276
+ .body .vc-row-position,
1277
+ .body .vc-side-stance,
1278
+ .body .vc-fork-resolution,
1279
+ .body .vc-synthesis-body {
1280
+ color: #38332B !important;
1281
+ }
1282
+ .body .vc-resolution-label {
1283
+ color: #847B65 !important;
1284
+ }
1285
+ .body .vc-row-lens {
1286
+ color: #847B65 !important;
1287
+ }
1288
+ .body .vc-row-quote {
1289
+ color: #57503F !important;
1290
+ }
1291
+ .body .vc-row {
1292
+ border-top-color: #DCD3BD !important;
1293
+ }
1294
+ .body .vc-row:last-child {
1295
+ border-bottom-color: #DCD3BD !important;
1296
+ }
1297
+ .body .views-compared-synthesis {
1298
+ border: 1px solid #DCD3BD !important;
1299
+ border-top: 2px solid #847B65 !important;
1300
+ -webkit-print-color-adjust: exact;
1301
+ print-color-adjust: exact;
1302
+ }
1303
+
1304
+ /* Heading sticks to the intro that follows · no orphaned title. */
1305
+ .body section.section-views-compared > h2,
1306
+ .body h2.section-views-compared {
1307
+ break-after: avoid;
1308
+ page-break-after: avoid;
1309
+ }
1310
+ }
1311
+
1312
+ /* Defensive · malformed views-compared renders as a quiet placeholder */
1313
+ .body .views-compared-error {
1314
+ padding: 12px 14px;
1315
+ background: var(--panel-3, rgba(255, 255, 255, 0.04));
1316
+ color: var(--text-dim, #5C5A52);
1317
+ font-family: var(--mono);
1318
+ font-size: 11px;
1319
+ letter-spacing: 0.06em;
1320
+ }
1321
+ .body .views-compared-error::before {
1322
+ content: "// views-compared · malformed";
1323
+ display: block;
1324
+ margin-bottom: 6px;
1325
+ color: var(--red, #B5706A);
1326
+ }
1327
+
1328
+ /* ─── Colophon · two-element footer with split visibility.
1329
+ ─────────────────────────────────────────────────────────────────
1330
+ 1. `.colophon-share` · SCREEN ONLY. A [↓ Download PDF] button
1331
+ + hint copy that tells the current reader the PDF carries
1332
+ the full styled brief. Hidden in print.
1333
+ 2. `.colophon-card` · PRINT ONLY. A quiet single-line "made
1334
+ with" credit that travels at the foot of the PDF. Hidden
1335
+ on screen so the in-app report doesn't read as marketing
1336
+ surface; appears only when the PDF gets shared.
1337
+ Visually muted on purpose — small mono micro-type, no
1338
+ card frame, no corner brackets, no marketing deck. The
1339
+ goal is "tasteful credit line," not "promotional card,"
1340
+ so the sender doesn't feel like they're forwarding an ad.
1341
+ Spine-agnostic baseline via CSS variables — each spine's
1342
+ palette flows in naturally. Horizontal margin matches `.body`'s
1343
+ 32px padding so the footer aligns with the report content above
1344
+ it (a16z-thesis overrides to 56px to match its wider gutter ·
1345
+ see its spine CSS). `.foot-rule` follows the same pattern. */
1346
+ .colophon {
1347
+ margin: 32px 32px 8px;
1348
+ }
1349
+
1350
+ .colophon-share {
1351
+ display: flex;
1352
+ align-items: center;
1353
+ gap: 16px;
1354
+ padding: 18px 20px;
1355
+ background: var(--panel-2, rgba(255, 255, 255, 0.025));
1356
+ border: 1px solid var(--line-bright, rgba(0, 0, 0, 0.08));
1357
+ flex-wrap: wrap;
1358
+ }
1359
+ .colophon-share-btn {
1360
+ display: inline-flex;
1361
+ align-items: center;
1362
+ gap: 8px;
1363
+ padding: 10px 16px;
1364
+ background: var(--lime, #6FB572);
1365
+ color: var(--bg, #FFFFFF);
1366
+ border: 1px solid var(--lime, #6FB572);
1367
+ font-family: var(--mono);
1368
+ font-size: 11px;
1369
+ font-weight: 700;
1370
+ letter-spacing: 0.14em;
1371
+ text-transform: uppercase;
1372
+ cursor: pointer;
1373
+ transition: background 0.12s, color 0.12s;
1374
+ flex-shrink: 0;
1375
+ }
1376
+ .colophon-share-btn:hover {
1377
+ background: transparent;
1378
+ color: var(--lime, #6FB572);
1379
+ }
1380
+ .colophon-share-icon {
1381
+ font-size: 13px;
1382
+ line-height: 1;
1383
+ }
1384
+ .colophon-share-hint {
1385
+ flex: 1;
1386
+ min-width: 220px;
1387
+ margin: 0;
1388
+ font-family: var(--sans, var(--font-human));
1389
+ font-size: 12px;
1390
+ line-height: 1.5;
1391
+ color: var(--text-soft, #6B6B6B);
1392
+ letter-spacing: -0.003em;
1393
+ }
1394
+
1395
+ /* PDF-only credit · default hidden, flipped to a center-stacked
1396
+ letterpress-style sign-off in the @media print block below. Reads
1397
+ as a typographic colophon (think hardcover endmatter, academic
1398
+ journal, or O'Reilly book), not a marketing footer. The vertical
1399
+ stack is: short top rule (centered hairline) → tiny uppercase
1400
+ eyebrow → pixel mark + product name on one line → muted url. No
1401
+ border around it, no panel fill, no full-width edge — the
1402
+ whitespace is the framing. */
1403
+ .colophon-card {
1404
+ display: none;
1405
+ flex-direction: column;
1406
+ align-items: center;
1407
+ gap: 14px;
1408
+ padding: 36px 12px 18px;
1409
+ margin-top: 36px;
1410
+ color: var(--text-soft, #6B6B6B);
1411
+ }
1412
+ .colophon-rule {
1413
+ width: 64px;
1414
+ height: 1px;
1415
+ background: var(--text-faint, #978C7E);
1416
+ opacity: 0.6;
1417
+ }
1418
+ .colophon-eyebrow {
1419
+ color: var(--text-faint, var(--text-dim, #8A8780));
1420
+ text-transform: uppercase;
1421
+ letter-spacing: 0.34em;
1422
+ font-size: 9px;
1423
+ font-weight: 600;
1424
+ font-family: var(--mono);
1425
+ /* Optical centering · letter-spacing pushes content right; nudge
1426
+ the whole eyebrow back so it reads centered under the rule. */
1427
+ padding-left: 0.34em;
1428
+ }
1429
+ .colophon-brand {
1430
+ display: inline-flex;
1431
+ align-items: center;
1432
+ gap: 10px;
1433
+ }
1434
+ .colophon-mark {
1435
+ width: 14px;
1436
+ height: 14px;
1437
+ flex-shrink: 0;
1438
+ opacity: 0.85;
1439
+ image-rendering: pixelated;
1440
+ image-rendering: crisp-edges;
1441
+ }
1442
+ .colophon-mark-bg { fill: var(--lime, #6FB572); }
1443
+ .colophon-mark-fg { fill: var(--bg, #FFFFFF); }
1444
+ .colophon-name {
1445
+ color: var(--text, #1A1A1A);
1446
+ font-family: var(--serif, var(--font-human, Georgia, serif));
1447
+ font-size: 17px;
1448
+ font-weight: 600;
1449
+ letter-spacing: -0.005em;
1450
+ }
1451
+ .colophon-cta-url {
1452
+ display: inline-block;
1453
+ color: var(--text, #1A1A1A);
1454
+ font-family: var(--mono);
1455
+ font-size: 14px;
1456
+ font-weight: 600;
1457
+ letter-spacing: 0.01em;
1458
+ text-decoration: none;
1459
+ margin-top: 2px;
1460
+ }
1461
+ .colophon-cta-url:hover { text-decoration: underline; }
1462
+ /* Backwards-compat · the "·" separator from the prior horizontal
1463
+ layout no longer renders meaningfully in a vertical stack. Keep
1464
+ the selector defined so old markup doesn't error if rendered. */
1465
+ .colophon-sep { display: none; }
1466
+
1467
+ /* Print discipline · the share strip disappears, the credit line
1468
+ materialises, and both stay whole on whatever final page they
1469
+ land on. */
1470
+ @media print {
1471
+ .colophon {
1472
+ break-inside: avoid;
1473
+ page-break-inside: avoid;
1474
+ }
1475
+ .colophon-share { display: none !important; }
1476
+ .colophon-card {
1477
+ display: flex !important;
1478
+ flex-direction: column !important;
1479
+ break-inside: avoid;
1480
+ page-break-inside: avoid;
1481
+ /* If the colophon would orphan onto a near-empty last page,
1482
+ pull it up so it lands at the bottom of the prior page
1483
+ instead. The avoid hint is honoured by Chrome's PDF
1484
+ engine when there's any room at all. */
1485
+ break-before: avoid;
1486
+ page-break-before: avoid;
1487
+ }
1488
+ }
1489
+
1490
+ /* ════════════════════════════════════════════════════════════════
1491
+ UNIFIED DESIGN SYSTEM · cross-spine baseline
1492
+ ════════════════════════════════════════════════════════════════
1493
+ One typography scale, one spacing scale, one component vocabulary
1494
+ applied to every spine. Spines (a16z / anthropic / boardroom-dark
1495
+ / gartner / mckinsey / openai) provide:
1496
+ · color palette (--ink, --paper, --rule, --accent, --warn, …)
1497
+ · font choices (--serif, --sans, --mono)
1498
+ · genuinely distinctive flourishes (a16z gold P0, mckinsey
1499
+ navy thesis, gartner brand red, etc. — opt in via
1500
+ `body[data-spine="X"]` selectors)
1501
+ Everything else — sizes, spacing, component structure, treatment
1502
+ patterns — comes from this block. Loaded AFTER the spine CSS so
1503
+ it wins on specificity ties. Spines that want to override use
1504
+ `body[data-spine="…"]` for higher specificity. */
1505
+
1506
+ :root {
1507
+ /* Type scale · 1 source of truth across all spines.
1508
+ All sizes in px; line-heights unitless; tracking in em. */
1509
+ --rep-display: 44px; /* cover title */
1510
+ --rep-h2: 24px; /* section headings */
1511
+ --rep-h3: 18px; /* sub-section + recommendation action */
1512
+ --rep-h4: 15px; /* small headings */
1513
+ --rep-body: 16px; /* primary prose */
1514
+ --rep-rationale: 16px; /* secondary prose (rationale, item body) */
1515
+ --rep-meta: 13px; /* meta strips, metadata rows */
1516
+ --rep-label: 10px; /* mono uppercase labels */
1517
+ --rep-caption: 11px; /* footer captions */
1518
+ --rep-pullquote: 19px; /* italic pull-quotes inside cards */
1519
+
1520
+ --rep-leading-display: 1.18;
1521
+ --rep-leading-heading: 1.4;
1522
+ --rep-leading-body: 1.7;
1523
+ --rep-leading-tight: 1.55;
1524
+
1525
+ --rep-tracking-display: -0.012em;
1526
+ --rep-tracking-body: -0.005em;
1527
+ --rep-tracking-mono: 0.04em;
1528
+ --rep-tracking-label: 0.2em;
1529
+
1530
+ /* Spacing scale */
1531
+ --rep-section-gap: 56px; /* before each H2 */
1532
+ --rep-item-gap: 44px; /* between rich list items */
1533
+ --rep-para-gap: 14px; /* between paragraphs */
1534
+ --rep-row-gap: 10px; /* tight rows inside a card */
1535
+
1536
+ /* Reading column */
1537
+ --rep-prose-width: 64ch;
1538
+ }
1539
+
1540
+ /* ── Section break + heading rhythm · cross-spine ────────────────
1541
+ Each numbered section is preceded by `<div class="chapter-num">
1542
+ Section NN</div>` (injected client-side). Together they form the
1543
+ section break — designed as a single intentional unit:
1544
+ 1. Chapter-num gets the big top margin (64px) so it acts as
1545
+ the section divider; previous content's bottom margin
1546
+ collapses naturally with this gap.
1547
+ 2. Chapter-num is a flex row · `Section NN` mono uppercase on
1548
+ the left, a hairline filling the rest. Reads as a labelled
1549
+ divider rather than a stranded eyebrow.
1550
+ 3. H2 IMMEDIATELY follows the chapter-num · 0 top margin so
1551
+ the title sits snugly under the divider; small bottom
1552
+ margin (10px) so the title is visually attached to its
1553
+ first paragraph.
1554
+ 4. Standalone H2s (no chapter-num · bottom-line / thesis /
1555
+ methodology slots) keep the 56px top margin so they still
1556
+ read as section breaks.
1557
+ Net effect: section break feels like a designed unit with a
1558
+ real divider line, and title sits close to its content.
1559
+ Spines override colour via --accent / --ink / --rule and font
1560
+ via --mono / --serif. */
1561
+ .body .chapter-num {
1562
+ display: block;
1563
+ font-family: var(--mono);
1564
+ font-size: var(--rep-label);
1565
+ font-weight: 500;
1566
+ color: var(--ink-faint, var(--text-faint));
1567
+ letter-spacing: 0.24em;
1568
+ text-transform: uppercase;
1569
+ margin: 64px 0 14px;
1570
+ padding: 0;
1571
+ border: none;
1572
+ }
1573
+ /* Spine-specific accent colour for the chapter label · uses
1574
+ --accent which each spine maps to its brand colour. */
1575
+ body[data-spine="a16z-thesis"] .body .chapter-num,
1576
+ body[data-spine="anthropic-essay"] .body .chapter-num,
1577
+ body[data-spine="boardroom-dark"] .body .chapter-num,
1578
+ body[data-spine="gartner-note"] .body .chapter-num,
1579
+ body[data-spine="mckinsey-deck"] .body .chapter-num,
1580
+ body[data-spine="openai-paper"] .body .chapter-num {
1581
+ color: var(--accent);
1582
+ }
1583
+ /* First chapter-num after the cover or a still-anchored section
1584
+ (bottom-line / thesis) needs a touch more space because there's
1585
+ no preceding content to collapse with. */
1586
+ .cover + .chapter-num { margin-top: 24px; }
1587
+
1588
+ .body h2 {
1589
+ font-size: var(--rep-h2);
1590
+ line-height: var(--rep-leading-heading);
1591
+ letter-spacing: var(--rep-tracking-display);
1592
+ margin: var(--rep-section-gap) 0 12px;
1593
+ color: var(--ink, var(--text));
1594
+ font-weight: 600;
1595
+ }
1596
+ /* H2 directly under a chapter-num · drop the top margin so the
1597
+ title sits snugly under the divider. The chapter-num's 64px
1598
+ top margin already provides the section break. */
1599
+ .body .chapter-num + h2 {
1600
+ margin-top: 0;
1601
+ }
1602
+ .body h2:first-child { margin-top: 0; }
1603
+ .body h3 {
1604
+ font-size: var(--rep-h3);
1605
+ line-height: var(--rep-leading-heading);
1606
+ letter-spacing: var(--rep-tracking-display);
1607
+ margin: 28px 0 8px;
1608
+ color: var(--ink, var(--text));
1609
+ font-weight: 600;
1610
+ }
1611
+ .body h4 {
1612
+ font-size: var(--rep-h4);
1613
+ line-height: var(--rep-leading-heading);
1614
+ margin: 18px 0 6px;
1615
+ color: var(--ink, var(--text));
1616
+ font-weight: 600;
1617
+ }
1618
+
1619
+ /* ── Body prose ──────────────────────────────────────────────── */
1620
+ .body p {
1621
+ font-size: var(--rep-body);
1622
+ line-height: var(--rep-leading-body);
1623
+ letter-spacing: var(--rep-tracking-body);
1624
+ color: var(--ink-soft, var(--text-soft));
1625
+ margin: var(--rep-para-gap) 0;
1626
+ }
1627
+ .body p strong {
1628
+ color: var(--ink, var(--text));
1629
+ font-weight: 600;
1630
+ }
1631
+
1632
+ /* ── Lists · default register, gets richer treatment via the
1633
+ :has(p ~ p) baseline below for multi-paragraph items. ───── */
1634
+ .body ul, .body ol {
1635
+ margin: 14px 0 18px 22px;
1636
+ }
1637
+ .body li {
1638
+ font-size: var(--rep-body);
1639
+ line-height: var(--rep-leading-body);
1640
+ color: var(--ink-soft, var(--text-soft));
1641
+ margin: 6px 0;
1642
+ }
1643
+
1644
+ /* ── Tables · compact, all spines ─────────────────────────────── */
1645
+ .body table.md-table {
1646
+ width: 100%;
1647
+ border-collapse: collapse;
1648
+ margin: 22px 0 26px;
1649
+ font-size: 14px;
1650
+ border-top: 2px solid var(--ink, var(--text));
1651
+ }
1652
+ .body table.md-table th,
1653
+ .body table.md-table td {
1654
+ padding: 11px 14px;
1655
+ border-bottom: 1px solid var(--rule, var(--line));
1656
+ text-align: left;
1657
+ vertical-align: top;
1658
+ line-height: 1.55;
1659
+ }
1660
+ .body table.md-table th {
1661
+ font-family: var(--mono);
1662
+ font-size: 12px;
1663
+ font-weight: 500;
1664
+ letter-spacing: 0.2em;
1665
+ text-transform: uppercase;
1666
+ color: var(--ink-faint, var(--text-faint));
1667
+ background: transparent;
1668
+ }
1669
+ .body table.md-table td:first-child {
1670
+ font-weight: 600;
1671
+ color: var(--ink, var(--text));
1672
+ font-size: 14px;
1673
+ }
1674
+
1675
+ /* ── Blockquotes · pull-quote register ────────────────────────── */
1676
+ .body blockquote {
1677
+ font-style: italic;
1678
+ font-size: var(--rep-pullquote);
1679
+ line-height: var(--rep-leading-tight);
1680
+ color: var(--ink, var(--text));
1681
+ margin: 24px 0 28px;
1682
+ padding: 0;
1683
+ background: none;
1684
+ border: none;
1685
+ font-family: var(--serif, var(--font-human));
1686
+ letter-spacing: var(--rep-tracking-body);
1687
+ max-width: var(--rep-prose-width);
1688
+ }
1689
+ .body blockquote em { font-style: italic; }
1690
+ .body blockquote strong {
1691
+ color: var(--ink, var(--text));
1692
+ font-weight: 600;
1693
+ font-style: normal;
1694
+ }
1695
+
1696
+ /* ── Code (inline) · uniform register ─────────────────────────── */
1697
+ .body code {
1698
+ font-family: var(--mono);
1699
+ font-size: 0.88em;
1700
+ color: var(--ink, var(--text));
1701
+ background: var(--paper-soft, var(--panel-2, rgba(0, 0, 0, 0.04)));
1702
+ padding: 1px 6px;
1703
+ border-radius: 2px;
1704
+ letter-spacing: var(--rep-tracking-mono);
1705
+ }
1706
+
1707
+ /* ── Recommendation cards · cross-spine consolidated.
1708
+ Each spine still tints via --accent / --warn / --gold etc.,
1709
+ but sizes, weights, and spacing are uniform. ─────────────── */
1710
+ .body section.section-recommendations li.rec-item,
1711
+ .body section.section-considerations li.rec-item {
1712
+ padding: 0;
1713
+ margin: var(--rep-item-gap) 0 0;
1714
+ list-style: none;
1715
+ border: none;
1716
+ }
1717
+ .body section.section-recommendations li.rec-item:first-child,
1718
+ .body section.section-considerations li.rec-item:first-child {
1719
+ margin-top: 22px;
1720
+ }
1721
+ .body section.section-recommendations li.rec-item::before,
1722
+ .body section.section-considerations li.rec-item::before {
1723
+ display: none;
1724
+ }
1725
+ .rec-rule {
1726
+ display: flex;
1727
+ align-items: center;
1728
+ gap: 14px;
1729
+ margin-bottom: 22px;
1730
+ }
1731
+ .rec-num {
1732
+ font-family: var(--mono);
1733
+ font-size: 10.5px;
1734
+ font-weight: 400;
1735
+ color: var(--ink-faint, var(--text-faint));
1736
+ letter-spacing: 0.2em;
1737
+ flex: 0 0 auto;
1738
+ font-variant: tabular-nums;
1739
+ }
1740
+ /* Priority badge baseline · pure typography · NO fill, NO border,
1741
+ NO padding. Spine-specific colour is the only differentiator
1742
+ between P0 / P1 / P2. The earlier filled-pill treatment read as
1743
+ "shouty status tag"; this register reads as a refined editorial
1744
+ marker (matches the mono kicker pattern used elsewhere in the
1745
+ report). */
1746
+ .rec-rule code {
1747
+ flex: 0 0 auto;
1748
+ font-family: var(--mono);
1749
+ font-size: 10px;
1750
+ font-weight: 600;
1751
+ letter-spacing: 0.22em;
1752
+ text-transform: uppercase;
1753
+ background: transparent !important;
1754
+ border: 0 !important;
1755
+ padding: 0 !important;
1756
+ line-height: 1;
1757
+ }
1758
+ .rec-rule-line {
1759
+ flex: 1 1 auto;
1760
+ height: 1px;
1761
+ background: var(--rule, var(--line));
1762
+ display: block;
1763
+ opacity: 0.45;
1764
+ }
1765
+ .rec-action {
1766
+ font-family: var(--serif, var(--font-human));
1767
+ font-size: var(--rep-h3);
1768
+ line-height: 1.35;
1769
+ color: var(--ink, var(--text));
1770
+ font-weight: 600;
1771
+ letter-spacing: var(--rep-tracking-display);
1772
+ margin: 0 0 14px;
1773
+ max-width: var(--rep-prose-width);
1774
+ }
1775
+ .rec-rationale {
1776
+ font-size: var(--rep-rationale);
1777
+ line-height: var(--rep-leading-body);
1778
+ color: var(--ink-soft, var(--text-soft));
1779
+ margin: 0 0 22px;
1780
+ max-width: var(--rep-prose-width);
1781
+ }
1782
+ .rec-meta {
1783
+ display: flex;
1784
+ flex-wrap: wrap;
1785
+ align-items: baseline;
1786
+ gap: 8px 18px;
1787
+ margin: 0 0 18px;
1788
+ font-size: var(--rep-meta);
1789
+ line-height: 1.75;
1790
+ color: var(--ink, var(--text));
1791
+ }
1792
+ .rec-meta-pair {
1793
+ display: inline-flex;
1794
+ align-items: baseline;
1795
+ gap: 8px;
1796
+ min-width: 0;
1797
+ }
1798
+ /* Meta-row labels · softened from heavy mono-uppercase shouty caps
1799
+ (letter-spacing 0.22em + weight 500 + text-transform uppercase)
1800
+ to a lighter editorial register: lowercase, lighter tracking,
1801
+ regular weight. Keeps the mono register so the label still reads
1802
+ as metadata, but loses the "shouty status tag" pressure that
1803
+ stacking 3-5 of these on a single rec card creates. */
1804
+ .rec-meta-label {
1805
+ font-family: var(--mono);
1806
+ font-size: var(--rep-label);
1807
+ color: var(--ink-faint, var(--text-faint));
1808
+ letter-spacing: 0.04em;
1809
+ text-transform: none;
1810
+ font-weight: 400;
1811
+ font-variant: small-caps;
1812
+ }
1813
+ .rec-meta-value {
1814
+ color: var(--ink, var(--text));
1815
+ font-weight: 500;
1816
+ }
1817
+ .rec-meta-sep {
1818
+ color: var(--ink-muted, var(--text-faint));
1819
+ opacity: 0.5;
1820
+ }
1821
+ /* Dependency + Risk rows · prefix + em-dash + body. Uniform shape.
1822
+ Same softening as meta labels: lowercase + small-caps, lighter
1823
+ tracking, regular weight. The risk row stays italic warn-coloured
1824
+ so it still reads as a flag, just less aggressively. */
1825
+ .rec-dependency, .rec-risk {
1826
+ display: flex;
1827
+ align-items: flex-start;
1828
+ gap: 8px;
1829
+ font-size: var(--rep-meta);
1830
+ line-height: 1.75;
1831
+ margin: 0 0 10px;
1832
+ padding: 0;
1833
+ background: transparent;
1834
+ }
1835
+ .rec-risk {
1836
+ margin: 0;
1837
+ font-style: italic;
1838
+ color: var(--warn, var(--oxblood-deep, var(--red, #B5706A)));
1839
+ }
1840
+ .rec-dependency {
1841
+ color: var(--ink-mid, var(--text-soft));
1842
+ }
1843
+ .rec-dependency-prefix, .rec-risk-prefix {
1844
+ font-family: var(--mono);
1845
+ font-style: normal;
1846
+ font-size: var(--rep-label);
1847
+ letter-spacing: 0.05em;
1848
+ text-transform: none;
1849
+ font-variant: small-caps;
1850
+ font-weight: 400;
1851
+ flex: 0 0 auto;
1852
+ }
1853
+ .rec-dependency-prefix { color: var(--ink-faint, var(--text-faint)); }
1854
+ .rec-risk-prefix { color: var(--warn, var(--oxblood, var(--red))); }
1855
+ .rec-dependency-sep, .rec-risk-sep {
1856
+ margin: 0 2px;
1857
+ flex: 0 0 auto;
1858
+ }
1859
+ .rec-dependency-sep { color: var(--ink-faint, var(--text-faint)); }
1860
+ .rec-risk-sep { color: var(--warn, var(--oxblood, var(--red))); }
1861
+ .rec-dependency-text, .rec-risk-text { flex: 1 1 auto; }
1862
+
1863
+ /* ── Methodology footer · subdued mono caption strip ────────── */
1864
+ .body h2.section-methodology {
1865
+ margin-top: 80px;
1866
+ padding-top: 22px;
1867
+ font-size: var(--rep-label);
1868
+ color: var(--ink-faint, var(--text-faint));
1869
+ letter-spacing: 0.26em;
1870
+ text-transform: uppercase;
1871
+ border-top: 1px solid var(--rule, var(--line));
1872
+ font-family: var(--mono);
1873
+ font-weight: 500;
1874
+ }
1875
+ .body section.section-methodology p {
1876
+ font-size: 12px;
1877
+ line-height: 1.65;
1878
+ color: var(--ink-faint, var(--text-faint));
1879
+ font-family: var(--mono);
1880
+ letter-spacing: 0.04em;
1881
+ }
1882
+
1883
+ /* ── Appendix · supplementary detail (rare) ───────────────────
1884
+ Renders AFTER all body sections, BEFORE Methodology. Each
1885
+ appendix gets its own H2 (`Appendix A: ...`). Treatment is
1886
+ quieter than a chapter — a reader who skipped here is opting in
1887
+ to depth. In print, appendices push to a fresh page. */
1888
+ .body h2.section-appendix {
1889
+ margin-top: 64px;
1890
+ padding-top: 22px;
1891
+ border-top: 1px solid var(--rule, var(--line));
1892
+ font-family: var(--serif, var(--font-human));
1893
+ font-size: 22px;
1894
+ font-weight: 500;
1895
+ line-height: 1.3;
1896
+ color: var(--ink, var(--text));
1897
+ letter-spacing: -0.005em;
1898
+ }
1899
+ .body section.section-appendix {
1900
+ color: var(--ink-mid, var(--text-soft));
1901
+ }
1902
+ .body section.section-appendix p,
1903
+ .body section.section-appendix li {
1904
+ font-size: 14px;
1905
+ line-height: 1.7;
1906
+ }
1907
+ .body section.section-appendix blockquote {
1908
+ font-size: 15px;
1909
+ margin: 18px 0;
1910
+ padding: 0 16px;
1911
+ color: var(--ink-mid, var(--text-soft));
1912
+ font-style: italic;
1913
+ }
1914
+ @media print {
1915
+ .body h2.section-appendix {
1916
+ page-break-before: always;
1917
+ break-before: page;
1918
+ }
1919
+ }
1920
+
1921
+ /* ── Foot rule · "// end of brief" sign-off ──────────────────── */
1922
+ .foot-rule {
1923
+ text-align: center;
1924
+ color: var(--ink-faint, var(--text-faint));
1925
+ font-family: var(--mono);
1926
+ font-size: var(--rep-label);
1927
+ letter-spacing: 0.24em;
1928
+ text-transform: uppercase;
1929
+ margin: 72px 0 24px;
1930
+ padding-top: 24px;
1931
+ border-top: 1px solid var(--rule, var(--line));
1932
+ opacity: 0.7;
1933
+ }
1934
+
1935
+ /* ── Cover · type scale (colours via spine palette) ────────── */
1936
+ .cover-title {
1937
+ font-size: var(--rep-display);
1938
+ line-height: var(--rep-leading-display);
1939
+ letter-spacing: var(--rep-tracking-display);
1940
+ color: var(--ink, var(--text));
1941
+ font-weight: 600;
1942
+ font-family: var(--serif, var(--font-human));
1943
+ margin: 0 0 16px;
1944
+ }
1945
+ .cover-deck {
1946
+ font-size: 17px;
1947
+ line-height: 1.55;
1948
+ color: var(--ink-soft, var(--text-soft));
1949
+ font-family: var(--serif, var(--font-human));
1950
+ margin: 0 0 32px;
1951
+ max-width: 56ch;
1952
+ }
1953
+ .cover-tag {
1954
+ font-family: var(--mono);
1955
+ font-size: var(--rep-label);
1956
+ letter-spacing: 0.24em;
1957
+ text-transform: uppercase;
1958
+ color: var(--ink-faint, var(--text-faint));
1959
+ margin-bottom: 18px;
1960
+ display: flex;
1961
+ align-items: center;
1962
+ gap: 10px;
1963
+ flex-wrap: wrap;
1964
+ }
1965
+ .cover-tag .secondary { color: var(--ink-muted, var(--text-faint)); }
1966
+ .cover-tag .pipe {
1967
+ width: 2px;
1968
+ height: 2px;
1969
+ background: var(--ink-muted, var(--text-faint));
1970
+ border-radius: 50%;
1971
+ flex-shrink: 0;
1972
+ }
1973
+
1974
+ /* ── Divider unification · cross-spine ──────────────────────────
1975
+ Spines historically diverged on rule widths (1px / 2px / 2px /
1976
+ 3px / 4px) and colours (var(--rule) / accent brand / heavy ink).
1977
+ Mckinsey and gartner especially over-decorated section openers
1978
+ with 2-4px brand-color underlines, which broke visual unity once
1979
+ the design system normalised everything else. This block caps
1980
+ every divider to one of three weights and colours so the report
1981
+ reads as a coherent typographic system across all 6 spines:
1982
+ · soft · 1px var(--rule-soft) — subtle in-content rows (uses lighter tone, not lighter weight)
1983
+ · rule · 1px var(--rule) — section dividers, card edges (default)
1984
+ · strong · 2px var(--ink) — anchor / table top / brand cover-bottom
1985
+ NO sub-pixel widths anywhere — 0.5px hairlines render fuzzy and
1986
+ mix-and-match with 1px borders fights the visual system. Anything
1987
+ heavier than 2px gets capped here. Spine-specific
1988
+ accent colours on heavy borders (4px navy / 4px brand) get
1989
+ swapped to the unified --rule with --accent reserved for badges
1990
+ and chapter-num labels (carried colour, not weight). */
1991
+
1992
+ /* Cover bottom rule · single neutral rule (was spine accent like
1993
+ gold / navy / brand-blue, which read as "different colour than
1994
+ the rest of the report's dividers"). Now uses --rule so it
1995
+ matches every other framing divider in the report.
1996
+
1997
+ Implementation note · the rule is rendered as a `.cover::after`
1998
+ block pseudo so it sits INSIDE the cover's horizontal padding,
1999
+ making its visual width match the `.cover-byline` border-top
2000
+ above it (both span the cover's content-box, not the full
2001
+ box-edge). The user reported "cover bottom 太宽了" because a
2002
+ plain `border-bottom` on `.cover` paints at the box-edge, which
2003
+ extends past the byline top divider on each side by the cover's
2004
+ padding (32-56px per spine). Pseudo + display:block matches it. */
2005
+ .cover {
2006
+ border-bottom: none !important;
2007
+ }
2008
+ .cover::after {
2009
+ content: "";
2010
+ display: block;
2011
+ height: 1px;
2012
+ background: var(--rule, var(--line));
2013
+ margin-top: 20px;
2014
+ }
2015
+ /* Section opener heavy borders · cap and unify colour. Was a mix of
2016
+ var(--accent) / var(--rule) / var(--rule-strong) / brand colours
2017
+ across spines; now all section bottom rules use the neutral
2018
+ --rule for one consistent divider colour across the report. */
2019
+ .body section.section-thesis,
2020
+ .body section.section-bottom-line,
2021
+ .body section.section-working-hypothesis {
2022
+ border-bottom: 1px solid var(--rule, var(--line)) !important;
2023
+ }
2024
+ /* Why Now · top + bottom rules unified to --rule. Was --gold (a16z)
2025
+ / --brand (gartner) / --orange (gartner option-board) on the top
2026
+ rule, breaking the "one colour" rule. Now both rules are the
2027
+ neutral --rule. */
2028
+ .body section.section-why-now {
2029
+ border-top: 1px solid var(--rule, var(--line)) !important;
2030
+ border-bottom: 1px solid var(--rule, var(--line)) !important;
2031
+ }
2032
+ /* Cover byline top rule · the divider above Authors / Filed /
2033
+ Subject / Doc-ID grid. Some spines had this at 1px, boardroom
2034
+ at 1px; standardise to 1px var(--rule) to match the cover
2035
+ bottom and other section-level dividers. Also cap any spine
2036
+ that uses a different colour (gold / accent) to --rule. */
2037
+ .cover-byline {
2038
+ border-top: 1px solid var(--rule, var(--line)) !important;
2039
+ }
2040
+ /* Chapter-num right rule · removed entirely. The kicker stands on
2041
+ its own typographic weight (mono uppercase 11px in spine accent
2042
+ colour); the trailing horizontal hairline read as fussy. The
2043
+ pseudo is force-suppressed here in case any spine still declares
2044
+ a `.chapter-num::after { content: "" }` rule (gartner-note has
2045
+ a `content: "."` override; this `display: none` wins). */
2046
+ .body .chapter-num::after {
2047
+ content: none !important;
2048
+ display: none !important;
2049
+ }
2050
+ /* Drop H2 border-bottom · some spines (mckinsey 2px navy under
2051
+ every H2, gartner thin line) layered an underline on every
2052
+ H2. Now redundant — the chapter-num divider above the H2
2053
+ already provides the section break visual. Two parallel lines
2054
+ read as a frame around the title. */
2055
+ .body h2 {
2056
+ border-bottom: none !important;
2057
+ padding-bottom: 0 !important;
2058
+ }
2059
+ /* Table top rule · uniformly 2px ink across all spines (was
2060
+ 2px ink on most, 2px brand on gartner/mckinsey). The single
2061
+ heavy rule sets the table apart; row dividers below stay at
2062
+ 1px hairlines. */
2063
+ .body table.md-table {
2064
+ border-top: 2px solid var(--ink, var(--text)) !important;
2065
+ }
2066
+ .body table.md-table th,
2067
+ .body table.md-table td {
2068
+ border-bottom: 1px solid var(--rule-soft, var(--rule, var(--line))) !important;
2069
+ }
2070
+ /* Pillars / metric strips / leading-indicators / scenario tree
2071
+ section openers · cap their decorative top borders. */
2072
+ .body section.section-leading-indicators,
2073
+ .body section.section-scenario-tree,
2074
+ .body section.section-strategic-outlook,
2075
+ .body section.section-headline-findings,
2076
+ .body section.section-recommendations,
2077
+ .body section.section-considerations,
2078
+ .body section.section-the-bet,
2079
+ .body section.section-pre-mortem,
2080
+ .body section.section-critical-assumptions,
2081
+ .body section.section-threats-to-validity,
2082
+ .body section.section-risk-register,
2083
+ .body section.section-decision-options,
2084
+ .body section.section-path-comparison,
2085
+ .body section.section-views-compared {
2086
+ border-top-width: 0 !important;
2087
+ padding-top: 0 !important;
2088
+ }
2089
+ /* Methodology footer · single 1px hairline above the deterministic
2090
+ credits block (already in the unified .body h2.section-methodology
2091
+ rule above; restate here for the unification audit). */
2092
+ .body h2.section-methodology {
2093
+ border-top-width: 1px !important;
2094
+ }
2095
+ /* Foot rule · "// end of brief" sign-off · explicit 1px so it
2096
+ matches the methodology footer. */
2097
+ .foot-rule {
2098
+ border-top-width: 1px !important;
2099
+ }
2100
+
2101
+ /* ── CJK normalisation · spine-agnostic ────────────────────────────
2102
+ When the brief is in Chinese / Japanese / Korean, suppress any
2103
+ font-style: italic that the spine declares on headings, pull
2104
+ quotes, and emphasis. CJK font families (Songti SC, PingFang SC,
2105
+ Noto Serif/Sans CJK, Source Han) carry no italic glyph set, so
2106
+ the browser falls back to a synthetic 12° skew that visibly
2107
+ warps every character — reading as a layout bug, not as
2108
+ emphasis. Latin reports are unaffected (the `body.is-cjk` gate
2109
+ is JS-set only when CJK is detected). */
2110
+ body.is-cjk .body h1,
2111
+ body.is-cjk .body h2,
2112
+ body.is-cjk .body h3,
2113
+ body.is-cjk .body h4,
2114
+ body.is-cjk .body section.section-bottom-line p,
2115
+ body.is-cjk .body section.section-thesis p:first-child,
2116
+ body.is-cjk .body section.section-frame-shift p:first-child,
2117
+ body.is-cjk .body section.section-working-hypothesis p,
2118
+ body.is-cjk .body blockquote,
2119
+ body.is-cjk .body blockquote em,
2120
+ body.is-cjk .body em,
2121
+ body.is-cjk .cover-deck,
2122
+ body.is-cjk .cover-deck strong {
2123
+ font-style: normal !important;
2124
+ }
2125
+ /* Body density · CJK reports need different treatment per spine.
2126
+ The earlier blanket `body.is-cjk .body p { font-size: 17px }`
2127
+ was a regression in disguise — it brought down anthropic's 18px
2128
+ to 16.5 (good), but BUMPED UP a16z's 14.5 to 16.5 (bad), and
2129
+ similarly inflated mckinsey 15→16.5, gartner 15→16.5, etc.
2130
+ Spines whose Latin body p is already 15px or smaller already
2131
+ read fine in CJK; only the heavy book-paper spines (anthropic
2132
+ 18px, openai 16, boardroom-dark 16) need a downward nudge.
2133
+ Pinning by `body[data-spine]` selector so only the heavy spines
2134
+ compact. */
2135
+ body.is-cjk[data-spine="anthropic-essay"] .body p {
2136
+ font-size: 15px;
2137
+ }
2138
+ body.is-cjk[data-spine="openai-paper"] .body p,
2139
+ body.is-cjk[data-spine="boardroom-dark"] .body p {
2140
+ font-size: 15px;
2141
+ }
2142
+ body.is-cjk[data-spine="gartner-note"] .body p,
2143
+ body.is-cjk[data-spine="mckinsey-deck"] .body p {
2144
+ font-size: 14px;
2145
+ }
2146
+
2147
+ /* Rich list items · decompression for "consensus overlooks" / "the
2148
+ three things" / similar sections where each numbered item is a
2149
+ full mini-essay (claim line + italic pull-quote + 2-3 elaboration
2150
+ paragraphs). Triggers via :has(p ~ p) so SIMPLE lists stay
2151
+ compact.
2152
+ Pure typographic hierarchy. Earlier iterations had a dotted
2153
+ hairline between items, paper-tint callout box on the
2154
+ pull-quote, and 「」 leading marks · all of it read as fussy
2155
+ decoration. Stripped to whitespace + weight + colour shift:
2156
+ · Big inter-item margin — each item is its own block.
2157
+ · Claim line · semibold + slight size bump · reads as a heading.
2158
+ · Italic pull-quote · italic + muted colour + small left
2159
+ indent · NO box, NO border, NO brackets.
2160
+ · Elaboration paragraphs · normal body register, slightly
2161
+ tighter so the item feels like one connected argument. */
2162
+ .body ol > li:has(> p ~ p) {
2163
+ margin: 0 0 40px;
2164
+ }
2165
+ .body ol > li:has(> p ~ p):last-child {
2166
+ margin-bottom: 8px;
2167
+ }
2168
+ /* Claim row · semibold heading */
2169
+ .body ol > li:has(> p ~ p) > p:first-of-type {
2170
+ font-size: 1.04em;
2171
+ font-weight: 600;
2172
+ line-height: 1.5;
2173
+ color: var(--ink, var(--text));
2174
+ margin: 0 0 12px;
2175
+ }
2176
+ /* Italic pull-quote · `<p><em>…</em></p>`. Picked by :has(em:only-child).
2177
+ Treated as a confident pull-quote in the magazine sense — sits
2178
+ between the claim and the elaboration as the item's thesis-line
2179
+ that the body argues. Earlier this lived as muted small italic
2180
+ with a left indent and decorative brackets / a tinted box; user
2181
+ called that ugly + cramped. New version: full body width, larger
2182
+ than body prose (1.12em), spine serif, ink-tone (not muted),
2183
+ generous vertical room. The italic style alone — combined with
2184
+ the size + spacing differential — does the visual differentiation
2185
+ work. NO border, NO box, NO leading marks. */
2186
+ .body ol > li:has(> p ~ p) > p:has(> em:only-child) {
2187
+ font-style: italic;
2188
+ font-size: 1.12em;
2189
+ line-height: 1.55;
2190
+ color: var(--ink, var(--text));
2191
+ margin: 18px 0 24px;
2192
+ padding: 0;
2193
+ background: none;
2194
+ /* Use serif for pull-quote when spine has one (a16z / anthropic /
2195
+ openai). Falls back to spine's body face — the italic
2196
+ treatment carries it visually either way. */
2197
+ font-family: var(--serif, var(--font-human));
2198
+ letter-spacing: -0.005em;
2199
+ }
2200
+ .body ol > li:has(> p ~ p) > p:has(> em:only-child) em {
2201
+ font-style: italic;
2202
+ }
2203
+ /* Elaboration paragraphs · same register as top-level body prose.
2204
+ Earlier they were tightened (10px / 1.62) to make the item feel
2205
+ coherent; user asked NOT to constrain. Just normal body. */
2206
+ .body ol > li:has(> p ~ p) > p:not(:first-of-type):not(:has(> em:only-child)) {
2207
+ margin: 14px 0;
2208
+ line-height: 1.72;
2209
+ }
2210
+ /* Body Chinese font · explicit PingFang SC anchor for CJK body
2211
+ prose. Without this, the spine's --sans stack falls through Latin
2212
+ fonts and lands on `system-ui`, which maps to PingFang on macOS
2213
+ but to Microsoft YaHei / Source Han / something else on other
2214
+ platforms. Anchoring on PingFang SC by name makes the default
2215
+ reliable. Latin fonts stay FIRST in the stack so mixed Chinese +
2216
+ English prose still renders Latin in Söhne / Inter / Helvetica
2217
+ per the spine's voice — only Chinese glyphs land on PingFang via
2218
+ per-glyph fallback. Applies to body p/li/td/th + the rich-list
2219
+ pull-quote + recommendation cards (anything that holds prose). */
2220
+ body.is-cjk .body p,
2221
+ body.is-cjk .body li,
2222
+ body.is-cjk .body td,
2223
+ body.is-cjk .body th,
2224
+ body.is-cjk .body blockquote,
2225
+ body.is-cjk .rec-rationale,
2226
+ body.is-cjk .rec-action,
2227
+ body.is-cjk .rec-risk-text,
2228
+ body.is-cjk .nq-question,
2229
+ body.is-cjk .nq-why,
2230
+ body.is-cjk .cover-deck,
2231
+ 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",
2235
+ sans-serif !important;
2236
+ }
2237
+
2238
+ /* CJK table compaction · spine table cells default to 13.5–15px
2239
+ which is fine for English but reads as oversized + squeezed in
2240
+ dense Chinese tables (each cell often holds a noun phrase or a
2241
+ short clause that fights cell width). Tighten cells + labels +
2242
+ padding for CJK content. Spine values still drive English.
2243
+ Applied to ALL spines via `body.is-cjk` scope. */
2244
+ body.is-cjk .body table.md-table th,
2245
+ body.is-cjk .body table.md-table td {
2246
+ font-size: 14px !important;
2247
+ padding: 8px 10px !important;
2248
+ line-height: 1.5 !important;
2249
+ }
2250
+ body.is-cjk .body table.md-table th {
2251
+ font-size: 12px !important;
2252
+ letter-spacing: 0.12em !important;
2253
+ }
2254
+
2255
+ /* ── Reading-position strip ────────────────────────────────────
2256
+ Sticky chapter nav below the top-rule, modeled on editorial
2257
+ reports that show "Introduction · Shift 01 · Shift 02 · Shift 03
2258
+ · Endnotes" across the spread top so the reader knows where they
2259
+ are. We auto-generate it from the H2s in the body — each H2 with
2260
+ a `chapter-num` (or anchor sections like Thesis / Methodology)
2261
+ becomes a clickable item. The current section is marked
2262
+ `is-current` via IntersectionObserver. Spine accent colours are
2263
+ inherited; layout + behaviour are spine-agnostic. */
2264
+ .reading-nav {
2265
+ position: sticky;
2266
+ top: 0;
2267
+ z-index: 30;
2268
+ background: var(--paper, var(--bg, #FFFFFF));
2269
+ border-bottom: 1px solid var(--rule, var(--line));
2270
+ padding: 10px 56px;
2271
+ overflow-x: auto;
2272
+ -webkit-overflow-scrolling: touch;
2273
+ scrollbar-width: none;
2274
+ }
2275
+ .reading-nav::-webkit-scrollbar { display: none; }
2276
+ .reading-nav-list {
2277
+ display: flex;
2278
+ align-items: center;
2279
+ gap: 0;
2280
+ list-style: none;
2281
+ padding: 0;
2282
+ margin: 0;
2283
+ white-space: nowrap;
2284
+ }
2285
+ .reading-nav-list li {
2286
+ display: inline-flex;
2287
+ align-items: center;
2288
+ margin: 0;
2289
+ }
2290
+ .reading-nav-list li:not(:last-child)::after {
2291
+ content: "·";
2292
+ color: var(--ink-muted, var(--text-faint));
2293
+ margin: 0 14px;
2294
+ opacity: 0.6;
2295
+ }
2296
+ .reading-nav-item {
2297
+ font-family: var(--mono);
2298
+ font-size: 11px;
2299
+ letter-spacing: 0.16em;
2300
+ text-transform: uppercase;
2301
+ color: var(--ink-faint, var(--text-faint));
2302
+ text-decoration: none;
2303
+ padding: 4px 0;
2304
+ transition: color 120ms ease, border-color 120ms ease;
2305
+ border-bottom: 1px solid transparent;
2306
+ }
2307
+ .reading-nav-item:hover {
2308
+ color: var(--ink, var(--text));
2309
+ }
2310
+ .reading-nav-item.is-current {
2311
+ color: var(--accent, var(--ink, var(--text)));
2312
+ border-bottom-color: var(--accent, var(--ink, var(--text)));
2313
+ }
2314
+ /* Hide the strip until JS populates it — no flash of empty nav. */
2315
+ .reading-nav:empty,
2316
+ .reading-nav:not(.is-ready) { display: none; }
2317
+
2318
+ /* ── Sidebar callouts ──────────────────────────────────────────
2319
+ A boxed-interlude card pattern, written in markdown as a fenced
2320
+ block:
2321
+ ```callout-case-study
2322
+ Sudan Case Study
2323
+ In April 2022, war erupted...
2324
+ ```
2325
+ The first non-empty line becomes the title; the rest renders as
2326
+ paragraph(s). Variants: case-study, quote, counterpoint, note.
2327
+ Each variant gets its own kicker label via ::before content.
2328
+ Visual: paper-soft surface card with a mono kicker prefix and
2329
+ serif title — same family as the methodology / acknowledgments
2330
+ card, but inline within body flow (not at the end). */
2331
+ .body .callout {
2332
+ background: var(--paper-soft, var(--surface, var(--panel-2)));
2333
+ border: 1px solid var(--rule, var(--line));
2334
+ padding: 24px 28px;
2335
+ margin: 32px 0;
2336
+ border-radius: 0;
2337
+ }
2338
+ .body .callout::before {
2339
+ content: "— NOTE";
2340
+ display: block;
2341
+ font-family: var(--mono);
2342
+ font-size: 11px;
2343
+ color: var(--accent, var(--clay-deep, var(--text-faint)));
2344
+ letter-spacing: 0.18em;
2345
+ text-transform: uppercase;
2346
+ margin-bottom: 14px;
2347
+ font-weight: 500;
2348
+ }
2349
+ .body .callout.callout-case-study::before { content: "— CASE STUDY"; }
2350
+ .body .callout.callout-quote::before { content: "— DIRECTOR VOICE"; }
2351
+ .body .callout.callout-counterpoint::before { content: "— COUNTERPOINT"; }
2352
+ .body .callout.callout-note::before { content: "— NOTE"; }
2353
+ .body .callout.callout-case-study:lang(zh)::before { content: "— 案例研究"; }
2354
+ .body .callout.callout-quote:lang(zh)::before { content: "— 董事原话"; }
2355
+ .body .callout.callout-counterpoint:lang(zh)::before { content: "— 反方意见"; }
2356
+ .body .callout.callout-note:lang(zh)::before { content: "— 备注"; }
2357
+ body.is-cjk .body .callout.callout-case-study::before { content: "— 案例研究"; }
2358
+ body.is-cjk .body .callout.callout-quote::before { content: "— 董事原话"; }
2359
+ body.is-cjk .body .callout.callout-counterpoint::before { content: "— 反方意见"; }
2360
+ body.is-cjk .body .callout.callout-note::before { content: "— 备注"; }
2361
+ .body .callout-title {
2362
+ font-family: var(--serif);
2363
+ font-size: 18px;
2364
+ font-weight: 500;
2365
+ line-height: 1.3;
2366
+ color: var(--ink, var(--text));
2367
+ margin: 0 0 12px;
2368
+ letter-spacing: -0.005em;
2369
+ }
2370
+ .body .callout-title em { font-style: italic; color: var(--em, var(--clay-deep, var(--accent))); }
2371
+ .body .callout-body p {
2372
+ font-family: var(--sans);
2373
+ font-size: 15px;
2374
+ line-height: 1.7;
2375
+ color: var(--ink-mid, var(--text-soft));
2376
+ margin: 0 0 12px;
2377
+ max-width: 60ch;
2378
+ }
2379
+ .body .callout-body p:last-child { margin-bottom: 0; }
2380
+ .body .callout-body p em { font-style: italic; color: var(--em, var(--clay-deep, var(--accent))); }
2381
+ .body .callout-body p strong { color: var(--ink, var(--text)); font-weight: 600; }
2382
+ /* Quote variant · italic serif body for the in-quote register. */
2383
+ .body .callout.callout-quote .callout-body p {
2384
+ font-family: var(--serif);
2385
+ font-style: italic;
2386
+ font-size: 17px;
2387
+ color: var(--ink, var(--text));
2388
+ }
2389
+
2390
+ /* ── Table of contents · print-only ─────────────────────────────
2391
+ Auto-emitted between cover and body on long briefs (≥ 6 H2s).
2392
+ Hidden on screen — the sticky reading-nav covers wayfinding
2393
+ there. In print, occupies its own page (page-break-before
2394
+ forces a clean start, page-break-after pushes the body to a
2395
+ fresh page so the TOC stands alone). Anchor links inside the
2396
+ PDF stay clickable in most viewers. */
2397
+ .toc-print { display: none; }
2398
+ @media print {
2399
+ .toc-print.is-ready {
2400
+ display: block;
2401
+ padding: 64px 56px 48px;
2402
+ page-break-before: always;
2403
+ page-break-after: always;
2404
+ break-before: page;
2405
+ break-after: page;
2406
+ }
2407
+ .toc-print-title {
2408
+ font-family: var(--mono);
2409
+ font-size: 11px;
2410
+ letter-spacing: 0.18em;
2411
+ text-transform: uppercase;
2412
+ color: var(--accent, var(--clay-deep, var(--ink-faint)));
2413
+ font-weight: 500;
2414
+ margin: 0 0 32px;
2415
+ padding: 0;
2416
+ border: none;
2417
+ }
2418
+ .toc-print-list {
2419
+ list-style: none;
2420
+ padding: 0;
2421
+ margin: 0;
2422
+ counter-reset: toc;
2423
+ }
2424
+ .toc-print-item {
2425
+ counter-increment: toc;
2426
+ padding: 14px 0;
2427
+ border-top: 1px solid var(--rule, var(--line));
2428
+ margin: 0;
2429
+ }
2430
+ .toc-print-item:last-child { border-bottom: 1px solid var(--rule, var(--line)); }
2431
+ .toc-print-link {
2432
+ display: flex;
2433
+ align-items: baseline;
2434
+ gap: 18px;
2435
+ text-decoration: none;
2436
+ color: var(--ink, var(--text));
2437
+ }
2438
+ .toc-print-ordinal {
2439
+ flex: 0 0 auto;
2440
+ font-family: var(--mono);
2441
+ font-size: 11px;
2442
+ letter-spacing: 0.08em;
2443
+ color: var(--ink-faint, var(--text-faint));
2444
+ font-weight: 500;
2445
+ min-width: 24px;
2446
+ font-variant-numeric: tabular-nums;
2447
+ }
2448
+ .toc-print-kicker {
2449
+ flex: 0 0 auto;
2450
+ font-family: var(--mono);
2451
+ font-size: 11px;
2452
+ letter-spacing: 0.16em;
2453
+ text-transform: uppercase;
2454
+ color: var(--accent, var(--clay-deep, var(--ink-faint)));
2455
+ font-weight: 500;
2456
+ min-width: 110px;
2457
+ }
2458
+ .toc-print-label {
2459
+ flex: 1 1 auto;
2460
+ font-family: var(--serif, var(--font-human));
2461
+ font-size: 17px;
2462
+ line-height: 1.4;
2463
+ color: var(--ink, var(--text));
2464
+ letter-spacing: -0.005em;
2465
+ }
2466
+ .toc-print-label em {
2467
+ font-style: italic;
2468
+ color: var(--em, var(--clay-deep, var(--accent)));
2469
+ }
2470
+ }
2471
+
2472
+ /* ── Display pull-quote · `\`\`\`callout-display-quote` ──────────
2473
+ The room's signature-line treatment, modeled on editorial
2474
+ pull-quotes that anchor a magazine spread. A giant decorative `"`
2475
+ mark in the spine accent sits top-left; the quote body is large
2476
+ italic serif (24px), anchored to the right of the mark; optional
2477
+ attribution sits below in a mono caption. No kicker label
2478
+ (overrides the generic `.callout::before`); no boxed border —
2479
+ just paper-soft surface + interior padding. */
2480
+ .body .callout.callout-display-quote {
2481
+ background: var(--paper-soft, var(--surface, var(--panel-2)));
2482
+ border: 1px solid var(--rule, var(--line));
2483
+ padding: 56px 44px 36px;
2484
+ margin: 40px 0;
2485
+ position: relative;
2486
+ border-radius: 0;
2487
+ }
2488
+ .body .callout.callout-display-quote::before {
2489
+ content: none;
2490
+ display: none;
2491
+ }
2492
+ .body .callout-mark {
2493
+ position: absolute;
2494
+ top: 8px;
2495
+ left: 32px;
2496
+ font-family: var(--serif);
2497
+ font-size: 88px;
2498
+ line-height: 1;
2499
+ color: var(--accent, var(--clay-deep, var(--text-faint)));
2500
+ font-style: normal;
2501
+ font-weight: 400;
2502
+ user-select: none;
2503
+ pointer-events: none;
2504
+ }
2505
+ .body .callout-display-body p {
2506
+ font-family: var(--serif);
2507
+ font-size: 24px;
2508
+ line-height: 1.4;
2509
+ font-style: italic;
2510
+ font-weight: 400;
2511
+ color: var(--ink, var(--text));
2512
+ letter-spacing: -0.005em;
2513
+ margin: 0 0 18px;
2514
+ max-width: 56ch;
2515
+ }
2516
+ .body .callout-display-body p:last-child { margin-bottom: 0; }
2517
+ .body .callout-display-body p em {
2518
+ font-style: italic;
2519
+ color: var(--em, var(--clay-deep, var(--accent)));
2520
+ }
2521
+ .body .callout-display-body p strong {
2522
+ color: var(--ink, var(--text));
2523
+ font-weight: 600;
2524
+ }
2525
+ .body .callout-display-quote .callout-attribution {
2526
+ margin-top: 22px;
2527
+ font-family: var(--mono);
2528
+ font-size: 11px;
463
2529
  letter-spacing: 0.06em;
464
2530
  text-transform: uppercase;
465
- color: var(--text-faint, #5C5A4D);
466
- margin-top: 2px;
2531
+ color: var(--ink-faint, var(--text-faint));
467
2532
  }
468
- /* Print · keep cards intact across page breaks. */
469
- @media print {
470
- .body .metric-card { break-inside: avoid; page-break-inside: avoid; }
2533
+
2534
+ /* ── Part-cover divider · `\`\`\`part-cover` ─────────────────────
2535
+ Full-width banner used to break a long brief into named parts
2536
+ (Part One / Part Two / Coda etc.). Mono kicker label + serif
2537
+ title, paper-soft surface, top + bottom 1px rules. In print the
2538
+ banner forces a page break before so each part opens cleanly on
2539
+ a fresh page. */
2540
+ .body .part-cover {
2541
+ margin: 56px -32px;
2542
+ padding: 64px 32px 60px;
2543
+ background: var(--paper-soft, var(--surface, var(--panel-2)));
2544
+ border-top: 1px solid var(--rule, var(--line));
2545
+ border-bottom: 1px solid var(--rule, var(--line));
2546
+ text-align: center;
471
2547
  }
472
- /* Defensive · malformed strips render as a quiet placeholder. */
473
- .body .metric-strip-error {
474
- padding: 12px 14px;
475
- background: var(--panel-3, rgba(255, 255, 255, 0.04));
476
- color: var(--text-dim, #5C5A52);
2548
+ .body .part-cover-label {
2549
+ display: block;
477
2550
  font-family: var(--mono);
478
2551
  font-size: 11px;
479
- letter-spacing: 0.06em;
2552
+ letter-spacing: 0.18em;
2553
+ text-transform: uppercase;
2554
+ color: var(--accent, var(--clay-deep, var(--ink-faint)));
2555
+ margin-bottom: 16px;
2556
+ font-weight: 500;
480
2557
  }
481
- .body .metric-strip-error::before {
482
- content: "// metric-strip · malformed";
483
- display: block;
484
- margin-bottom: 6px;
485
- color: var(--red, #B5706A);
2558
+ .body .part-cover-title {
2559
+ font-family: var(--serif);
2560
+ font-size: 36px;
2561
+ font-weight: 400;
2562
+ line-height: 1.18;
2563
+ letter-spacing: -0.018em;
2564
+ color: var(--ink, var(--text));
2565
+ margin: 0 auto;
2566
+ max-width: 28ch;
2567
+ font-style: normal;
2568
+ }
2569
+ .body .part-cover-title em {
2570
+ font-style: italic;
2571
+ color: var(--em, var(--clay-deep, var(--accent)));
2572
+ }
2573
+ @media print {
2574
+ .body .part-cover {
2575
+ page-break-before: always;
2576
+ break-before: page;
2577
+ }
486
2578
  }
487
2579
  </style>
488
2580
  </head>
@@ -496,6 +2588,8 @@
496
2588
  </div>
497
2589
  </div>
498
2590
 
2591
+ <nav class="reading-nav" data-reading-nav aria-label="Chapter navigation"></nav>
2592
+
499
2593
  <main class="doc">
500
2594
  <div data-report-content>
501
2595
  <div class="placeholder">loading the brief…</div>
@@ -514,11 +2608,14 @@
514
2608
  /** Save as PDF · just trigger the browser's print dialog. The
515
2609
  * @media print rules below hide chrome and tighten margins so the
516
2610
  * output reads as a filed deliverable. The user picks "Save as
517
- * PDF" as the destination — every modern browser supports it. */
518
- const downloadBtn = document.querySelector("[data-download]");
519
- if (downloadBtn) downloadBtn.addEventListener("click", () => {
520
- // Render the brief's title as the suggested filename (most
521
- // browsers default to document.title when saving a print-PDF).
2611
+ * PDF" as the destination — every modern browser supports it.
2612
+ * Delegated so the top-of-page button AND the bottom colophon
2613
+ * button (rendered later, after the brief loads) both trigger it
2614
+ * without re-binding on every render. */
2615
+ document.addEventListener("click", (e) => {
2616
+ const btn = e.target.closest("[data-download]");
2617
+ if (!btn) return;
2618
+ e.preventDefault();
522
2619
  window.print();
523
2620
  });
524
2621
 
@@ -533,13 +2630,55 @@
533
2630
  "mckinsey-deck",
534
2631
  "openai-paper",
535
2632
  ]);
536
- function swapSpine(spine) {
2633
+ /** Per-spine paper colour · used both as a CSS variable on the
2634
+ * document AND injected into @page { background } so the PDF
2635
+ * margin area (the safe-zone gutter outside body) gets painted
2636
+ * with the same paper colour as the body itself. Without the
2637
+ * @page background, Chrome leaves @page margin transparent and
2638
+ * it falls back to white — sections sit inside cream paper but
2639
+ * the 14mm bleed around them shows white, reading as a print
2640
+ * bug. CSS @page can't be selected by spine class, hence the
2641
+ * JS-driven inline rule below. */
2642
+ const SPINE_PAPER = {
2643
+ "boardroom-dark": "#FAF7F0",
2644
+ "anthropic-essay": "#F4F0E8",
2645
+ "a16z-thesis": "#F7F3E8",
2646
+ "mckinsey-deck": "#FFFFFF",
2647
+ "gartner-note": "#FFFFFF",
2648
+ "openai-paper": "#FFFFFF",
2649
+ };
2650
+
2651
+ function swapSpine(spine, brief) {
537
2652
  const link = document.getElementById("spine-css");
538
2653
  if (!link) return;
539
2654
  const safe = SPINES.has(spine) ? spine : "boardroom-dark";
540
2655
  const href = `report/spines/${safe}.css`;
541
2656
  if (link.getAttribute("href") !== href) link.setAttribute("href", href);
542
2657
  document.body.setAttribute("data-spine", safe);
2658
+ // Mark the document as CJK when the brief is in Chinese (or any
2659
+ // CJK script). Several spines — anthropic-essay, a16z-thesis,
2660
+ // boardroom-dark in the bottom-line callout — use `font-style:
2661
+ // italic` heavily on H2 / H3 / pull quotes. CJK fonts (Songti SC,
2662
+ // PingFang, etc.) carry NO italic glyph set, so the browser fakes
2663
+ // italic by shearing glyphs ~12° — which on Chinese text reads as
2664
+ // a layout bug, not as emphasis. The `is-cjk` class below scopes
2665
+ // a small override block (further down in this file's <style>)
2666
+ // that resets these to upright.
2667
+ const sample = ((brief && brief.title) || "") + " " + ((brief && brief.bodyMd) || "");
2668
+ const isCjk = /[㐀-鿿぀-ヿ가-힯]/.test(sample);
2669
+ document.body.classList.toggle("is-cjk", isCjk);
2670
+ // Inject (or replace) the per-spine @page background rule so the
2671
+ // PDF page bleed matches the paper colour. Replaces any prior
2672
+ // injected rule for this same id, so swapping spines repaints
2673
+ // cleanly without stacking style nodes.
2674
+ const paper = SPINE_PAPER[safe] || "#FFFFFF";
2675
+ let pageStyle = document.getElementById("spine-page-bg");
2676
+ if (!pageStyle) {
2677
+ pageStyle = document.createElement("style");
2678
+ pageStyle.id = "spine-page-bg";
2679
+ document.head.appendChild(pageStyle);
2680
+ }
2681
+ pageStyle.textContent = `@media print { @page { background: ${paper}; } }`;
543
2682
  }
544
2683
 
545
2684
  function escape(s) {
@@ -632,6 +2771,84 @@
632
2771
  * so the structured HTML survives the renderer's escape pass.
633
2772
  * Defensive: bad JSON / missing fields render as a quiet fallback
634
2773
  * block so a malformed strip doesn't break the rest of the report. */
2774
+ /** Sidebar callout · `\`\`\`callout-{variant}` fenced block with the
2775
+ * first non-empty line as the title and the rest as paragraph body
2776
+ * (split by blank lines). Variants set the kicker label via CSS
2777
+ * ::before content (case-study / quote / counterpoint / note);
2778
+ * unknown variants fall back to the bare `callout` styling.
2779
+ * Special variants:
2780
+ * · display-quote / pullquote · large editorial pull-quote with
2781
+ * a decorative " mark, optional `— Attribution` line at end. */
2782
+ function renderCallout(lang, body) {
2783
+ const variant = lang === "callout" ? "" : lang.slice("callout-".length).trim();
2784
+ if (variant === "display-quote" || variant === "pullquote") {
2785
+ return renderDisplayQuote(body);
2786
+ }
2787
+ const lines = body.split("\n");
2788
+ let titleIdx = -1;
2789
+ for (let i = 0; i < lines.length; i++) {
2790
+ if (lines[i].trim().length > 0) { titleIdx = i; break; }
2791
+ }
2792
+ if (titleIdx === -1) return "";
2793
+ const titleRaw = lines[titleIdx].trim();
2794
+ const bodyLines = lines.slice(titleIdx + 1).join("\n").trim();
2795
+ const paragraphs = bodyLines
2796
+ .split(/\n\s*\n/)
2797
+ .map((p) => p.replace(/\n/g, " ").trim())
2798
+ .filter(Boolean);
2799
+ const titleHtml = inline(escape(titleRaw));
2800
+ const bodyHtml = paragraphs.map((p) => `<p>${inline(escape(p))}</p>`).join("");
2801
+ const cls = variant ? `callout callout-${escape(variant)}` : "callout";
2802
+ return `<aside class="${cls}"><h4 class="callout-title">${titleHtml}</h4><div class="callout-body">${bodyHtml}</div></aside>`;
2803
+ }
2804
+
2805
+ /** Display pull-quote · the room's signature line treatment. Quote
2806
+ * body is serif italic, anchored to a giant " mark in the spine
2807
+ * accent. If the last paragraph starts with em-dash / hyphen, it's
2808
+ * treated as attribution and rendered in mono caption below. */
2809
+ function renderDisplayQuote(body) {
2810
+ const paragraphs = body
2811
+ .split(/\n\s*\n/)
2812
+ .map((p) => p.replace(/\n/g, " ").trim())
2813
+ .filter(Boolean);
2814
+ if (paragraphs.length === 0) return "";
2815
+ let attrib = "";
2816
+ let quoteParas = paragraphs;
2817
+ const last = paragraphs[paragraphs.length - 1];
2818
+ if (paragraphs.length > 1 && /^[—–-]\s*\S/.test(last)) {
2819
+ attrib = last.replace(/^[—–-]\s*/, "").trim();
2820
+ quoteParas = paragraphs.slice(0, -1);
2821
+ }
2822
+ const quoteHtml = quoteParas.map((p) => `<p>${inline(escape(p))}</p>`).join("");
2823
+ const attribHtml = attrib
2824
+ ? `<div class="callout-attribution">— ${inline(escape(attrib))}</div>`
2825
+ : "";
2826
+ return (
2827
+ `<aside class="callout callout-display-quote">` +
2828
+ `<span class="callout-mark" aria-hidden="true">&ldquo;</span>` +
2829
+ `<div class="callout-display-body">${quoteHtml}</div>` +
2830
+ attribHtml +
2831
+ `</aside>`
2832
+ );
2833
+ }
2834
+
2835
+ /** Part-cover divider · ```part-cover fenced block. First non-empty
2836
+ * line is the kicker label (e.g. "Part Two"), second is the part
2837
+ * title (serif headline). Renders as a full-width banner with a
2838
+ * page-break-before in print so each part starts on a fresh page. */
2839
+ function renderPartCover(body) {
2840
+ const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
2841
+ if (lines.length === 0) return "";
2842
+ const label = lines[0];
2843
+ const title = lines.slice(1).join(" ").trim();
2844
+ return (
2845
+ `<section class="part-cover">` +
2846
+ `<span class="part-cover-label">${inline(escape(label))}</span>` +
2847
+ (title ? `<h2 class="part-cover-title">${inline(escape(title))}</h2>` : "") +
2848
+ `</section>`
2849
+ );
2850
+ }
2851
+
635
2852
  function renderMetricStrip(body) {
636
2853
  let parsed;
637
2854
  try { parsed = JSON.parse(body); } catch (e) {
@@ -686,6 +2903,257 @@
686
2903
  `</div>`;
687
2904
  }
688
2905
 
2906
+ /** Parse a fenced ```path-comparison block (strict JSON) and emit
2907
+ * the side-by-side binary comparison: 2 columns, each with a mono
2908
+ * verdict tag, serif path name, and bullet characteristics. Stance
2909
+ * drives an accent classname (path-weak / path-strong / path-neutral)
2910
+ * that the per-spine CSS uses for colour. Mirrors how renderMetricStrip
2911
+ * handles its own fenced JSON block · pulled out at the placeholder
2912
+ * layer before block parsing so the structured HTML survives the
2913
+ * renderer's escape pass. Defensive: bad JSON / wrong shape / fewer
2914
+ * than 2 valid paths render as a quiet error placeholder. */
2915
+ function renderPathComparison(body) {
2916
+ let parsed;
2917
+ try { parsed = JSON.parse(body); } catch (e) {
2918
+ return `<div class="path-comparison path-comparison-error" data-err="parse">${escape(body)}</div>`;
2919
+ }
2920
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.paths)) {
2921
+ return `<div class="path-comparison path-comparison-error" data-err="shape"></div>`;
2922
+ }
2923
+ const intro = typeof parsed.intro === "string" ? parsed.intro.trim() : "";
2924
+ const implication = typeof parsed.implication === "string" ? parsed.implication.trim() : "";
2925
+ const out = [];
2926
+ for (const p of parsed.paths) {
2927
+ if (!p || typeof p !== "object") continue;
2928
+ const verdict = typeof p.verdict === "string" ? p.verdict.trim() : "";
2929
+ const name = typeof p.name === "string" ? p.name.trim() : "";
2930
+ if (!verdict || !name) continue;
2931
+ const stRaw = typeof p.stance === "string" ? p.stance.trim().toLowerCase() : "";
2932
+ const stance = (stRaw === "strong" || stRaw === "weak") ? stRaw : "neutral";
2933
+ const characteristics = Array.isArray(p.characteristics)
2934
+ ? p.characteristics
2935
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
2936
+ .map((s) => s.trim())
2937
+ .slice(0, 6)
2938
+ : [];
2939
+ if (characteristics.length < 2) continue;
2940
+ out.push({ verdict, stance, name, characteristics });
2941
+ if (out.length >= 2) break;
2942
+ }
2943
+ if (out.length !== 2) {
2944
+ return `<div class="path-comparison path-comparison-error" data-err="empty"></div>`;
2945
+ }
2946
+
2947
+ // Path name supports a `\n` separator → headline + sublabel · render
2948
+ // as two lines (sublabel in a smaller faint colour via CSS).
2949
+ const renderName = (raw) => {
2950
+ const parts = raw.split("\n").map((s) => s.trim()).filter(Boolean);
2951
+ if (parts.length === 1) return `<div class="path-name-line">${escape(parts[0])}</div>`;
2952
+ return parts
2953
+ .map((p, i) => i === 0
2954
+ ? `<div class="path-name-line">${escape(p)}</div>`
2955
+ : `<div class="path-name-sub">${escape(p)}</div>`)
2956
+ .join("");
2957
+ };
2958
+
2959
+ const introHtml = intro
2960
+ ? `<p class="path-comparison-intro">${inline(escape(intro))}</p>`
2961
+ : "";
2962
+ const implicationHtml = implication
2963
+ ? `<p class="path-comparison-implication">${inline(escape(implication))}</p>`
2964
+ : "";
2965
+ const pathsHtml = out.map((p) => {
2966
+ const bullets = p.characteristics.map((c) => `<li>${escape(c)}</li>`).join("");
2967
+ return `<div class="path path-${escape(p.stance)}">` +
2968
+ `<div class="path-tag">${escape(p.verdict)}</div>` +
2969
+ `<div class="path-name">${renderName(p.name)}</div>` +
2970
+ `<ul>${bullets}</ul>` +
2971
+ `</div>`;
2972
+ }).join("");
2973
+ return `<div class="path-comparison">` +
2974
+ introHtml +
2975
+ `<div class="paths">${pathsHtml}</div>` +
2976
+ implicationHtml +
2977
+ `</div>`;
2978
+ }
2979
+
2980
+ /** Parse a fenced ```views-compared block (strict JSON) and emit
2981
+ * the room's social-map: alignment cards (where directors agreed),
2982
+ * divergence panels (where they split), per-director rows (every
2983
+ * active director's stance / position / lens / optional quote),
2984
+ * closing chair-synthesis card. Mirrors path-comparison's fenced
2985
+ * JSON dispatch · pulled out at the placeholder layer so structured
2986
+ * HTML survives the renderer's escape pass. Defensive: bad JSON /
2987
+ * fewer than 2 director perspectives renders as a quiet error. */
2988
+ function renderViewsCompared(body) {
2989
+ let parsed;
2990
+ try { parsed = JSON.parse(body); } catch (e) {
2991
+ return `<div class="views-compared views-compared-error" data-err="parse">${escape(body)}</div>`;
2992
+ }
2993
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.perspectives)) {
2994
+ return `<div class="views-compared views-compared-error" data-err="shape"></div>`;
2995
+ }
2996
+
2997
+ const intro = typeof parsed.intro === "string" ? parsed.intro.trim() : "";
2998
+ const chairSynthesis = typeof parsed.chairSynthesis === "string" ? parsed.chairSynthesis.trim() : "";
2999
+
3000
+ // Resolve director id → display name from the rendered member
3001
+ // list above (the brief renderer attaches `data-director-id` to
3002
+ // each cover-byline author chip; fall back to the id when no
3003
+ // mapping is found).
3004
+ const directorNameById = (() => {
3005
+ const out = {};
3006
+ try {
3007
+ const chips = document.querySelectorAll("[data-director-id]");
3008
+ chips.forEach((el) => {
3009
+ const id = el.getAttribute("data-director-id");
3010
+ if (id && el.textContent) out[id] = el.textContent.trim();
3011
+ });
3012
+ } catch (_) { /* during pre-paint, no chips yet — fall through */ }
3013
+ return out;
3014
+ })();
3015
+ const dn = (id) => directorNameById[id] || id || "—";
3016
+
3017
+ // Per-director rows · the meat. Drop entries missing directorId
3018
+ // or position; empty array short-circuits the whole component.
3019
+ const rows = [];
3020
+ const VALID_LENSES = new Set(["data", "dissent", "narrative", "structural", "first-principle"]);
3021
+ for (const p of parsed.perspectives) {
3022
+ if (!p || typeof p !== "object") continue;
3023
+ const directorId = typeof p.directorId === "string" ? p.directorId.trim() : "";
3024
+ const position = typeof p.position === "string" ? p.position.trim() : "";
3025
+ if (!directorId || !position) continue;
3026
+ const stance = typeof p.stance === "string" ? p.stance.trim() : "";
3027
+ const quote = typeof p.quote === "string" ? p.quote.trim() : "";
3028
+ const lensRaw = typeof p.lens === "string" ? p.lens.trim() : "";
3029
+ const lens = VALID_LENSES.has(lensRaw) ? lensRaw : "structural";
3030
+ rows.push({ directorId, stance, position, quote, lens });
3031
+ }
3032
+ if (rows.length < 2) {
3033
+ return `<div class="views-compared views-compared-error" data-err="empty"></div>`;
3034
+ }
3035
+
3036
+ // Alignment cards · groups need ≥ 2 directors AND a name.
3037
+ const alignment = [];
3038
+ if (Array.isArray(parsed.alignment)) {
3039
+ for (const a of parsed.alignment) {
3040
+ if (!a || typeof a !== "object") continue;
3041
+ const pointOfAgreement = typeof a.pointOfAgreement === "string" ? a.pointOfAgreement.trim() : "";
3042
+ const note = typeof a.note === "string" ? a.note.trim() : "";
3043
+ const directorIds = Array.isArray(a.directorIds)
3044
+ ? a.directorIds.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => s.trim())
3045
+ : [];
3046
+ if (!pointOfAgreement || directorIds.length < 2) continue;
3047
+ alignment.push({ pointOfAgreement, directorIds, note });
3048
+ }
3049
+ }
3050
+
3051
+ // Divergence panels · each entry needs ≥ 2 sides + a hinge.
3052
+ const divergence = [];
3053
+ if (Array.isArray(parsed.divergence)) {
3054
+ for (const d of parsed.divergence) {
3055
+ if (!d || typeof d !== "object") continue;
3056
+ const hinge = typeof d.hinge === "string" ? d.hinge.trim() : "";
3057
+ const resolution = typeof d.resolution === "string" ? d.resolution.trim() : "";
3058
+ const sides = [];
3059
+ if (Array.isArray(d.sides)) {
3060
+ for (const s of d.sides) {
3061
+ if (!s || typeof s !== "object") continue;
3062
+ const label = typeof s.label === "string" ? s.label.trim() : "";
3063
+ const stance = typeof s.stance === "string" ? s.stance.trim() : "";
3064
+ const ids = Array.isArray(s.directorIds)
3065
+ ? s.directorIds.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim())
3066
+ : [];
3067
+ if (!label || !stance || ids.length === 0) continue;
3068
+ sides.push({ label, directorIds: ids, stance });
3069
+ }
3070
+ }
3071
+ if (!hinge || sides.length < 2) continue;
3072
+ divergence.push({ hinge, sides, resolution });
3073
+ }
3074
+ }
3075
+
3076
+ const introHtml = intro
3077
+ ? `<p class="views-compared-intro">${inline(escape(intro))}</p>`
3078
+ : "";
3079
+
3080
+ // Alignment + Divergence render side-by-side in a 2-column grid so
3081
+ // the contrast is immediate · agreement on the left, disagreement
3082
+ // on the right. Per CSS, the grid stacks below 760px viewport.
3083
+ // When one side is empty (e.g. no real alignment) we still emit
3084
+ // a placeholder so the columns stay balanced and the reader sees
3085
+ // explicitly that the room had no convergence (or no fork).
3086
+ const alignmentColHtml = `<div class="views-compared-alignment vc-compare-col">` +
3087
+ `<div class="vc-section-label">Where they aligned</div>` +
3088
+ (alignment.length
3089
+ ? alignment.map((a) =>
3090
+ `<div class="vc-card vc-card-align">` +
3091
+ `<div class="vc-card-headline">${escape(a.pointOfAgreement)}</div>` +
3092
+ `<div class="vc-card-directors">${a.directorIds.map((id) => `<span class="vc-chip">${escape(dn(id))}</span>`).join("")}</div>` +
3093
+ (a.note ? `<div class="vc-card-note">${inline(escape(a.note))}</div>` : "") +
3094
+ `</div>`
3095
+ ).join("")
3096
+ : `<div class="vc-compare-empty">No clear convergence — the room argued from different bases throughout.</div>`) +
3097
+ `</div>`;
3098
+
3099
+ const divergenceColHtml = `<div class="views-compared-divergence vc-compare-col">` +
3100
+ `<div class="vc-section-label">Where they split</div>` +
3101
+ (divergence.length
3102
+ ? divergence.map((d) =>
3103
+ `<div class="vc-fork">` +
3104
+ `<div class="vc-fork-hinge">${inline(escape(d.hinge))}</div>` +
3105
+ `<div class="vc-fork-sides" data-sides="${d.sides.length}">` +
3106
+ d.sides.map((s) =>
3107
+ `<div class="vc-side">` +
3108
+ `<div class="vc-side-label">${escape(s.label)}</div>` +
3109
+ `<div class="vc-side-directors">${s.directorIds.map((id) => `<span class="vc-chip">${escape(dn(id))}</span>`).join("")}</div>` +
3110
+ `<div class="vc-side-stance">${inline(escape(s.stance))}</div>` +
3111
+ `</div>`
3112
+ ).join("") +
3113
+ `</div>` +
3114
+ (d.resolution
3115
+ ? `<div class="vc-fork-resolution"><span class="vc-resolution-label">What would resolve it</span> · ${inline(escape(d.resolution))}</div>`
3116
+ : "") +
3117
+ `</div>`
3118
+ ).join("")
3119
+ : `<div class="vc-compare-empty">No central fork — the room held a coherent line of thought.</div>`) +
3120
+ `</div>`;
3121
+
3122
+ // Both empty? Skip the whole compare grid (rare).
3123
+ const compareGridHtml = (alignment.length || divergence.length)
3124
+ ? `<div class="vc-compare-grid">${alignmentColHtml}${divergenceColHtml}</div>`
3125
+ : "";
3126
+
3127
+ const perspectivesHtml = `<div class="views-compared-perspectives">` +
3128
+ `<div class="vc-section-label">Every director on the table</div>` +
3129
+ rows.map((r) =>
3130
+ `<div class="vc-row" data-lens="${escape(r.lens)}">` +
3131
+ `<div class="vc-row-head">` +
3132
+ `<span class="vc-row-name">${escape(dn(r.directorId))}</span>` +
3133
+ `<span class="vc-row-lens">${escape(r.lens)}</span>` +
3134
+ `</div>` +
3135
+ (r.stance ? `<div class="vc-row-stance">${inline(escape(r.stance))}</div>` : "") +
3136
+ `<div class="vc-row-position">${inline(escape(r.position))}</div>` +
3137
+ (r.quote ? `<div class="vc-row-quote">${inline(escape(r.quote))}</div>` : "") +
3138
+ `</div>`
3139
+ ).join("") +
3140
+ `</div>`;
3141
+
3142
+ const synthesisHtml = chairSynthesis
3143
+ ? `<div class="views-compared-synthesis">` +
3144
+ `<div class="vc-section-label">Chair's read on the comparison</div>` +
3145
+ `<p class="vc-synthesis-body">${inline(escape(chairSynthesis))}</p>` +
3146
+ `</div>`
3147
+ : "";
3148
+
3149
+ return `<div class="views-compared">` +
3150
+ introHtml +
3151
+ compareGridHtml +
3152
+ perspectivesHtml +
3153
+ synthesisHtml +
3154
+ `</div>`;
3155
+ }
3156
+
689
3157
  // Slightly richer markdown than the in-room renderer — supports h1–h4,
690
3158
  // bullets, ordered lists, paragraphs, blockquotes, fenced code blocks
691
3159
  // (incl. ```mermaid for diagrams), and pipe tables.
@@ -716,6 +3184,14 @@
716
3184
  placeholders.push(`<pre class="mermaid">${escape(sanitizeMermaid(body))}</pre>`);
717
3185
  } else if (lang === "metric-strip") {
718
3186
  placeholders.push(renderMetricStrip(body));
3187
+ } else if (lang === "path-comparison") {
3188
+ placeholders.push(renderPathComparison(body));
3189
+ } else if (lang === "views-compared") {
3190
+ placeholders.push(renderViewsCompared(body));
3191
+ } else if (lang.startsWith("callout-") || lang === "callout") {
3192
+ placeholders.push(renderCallout(lang, body));
3193
+ } else if (lang === "part-cover") {
3194
+ placeholders.push(renderPartCover(body));
719
3195
  } else {
720
3196
  placeholders.push(
721
3197
  `<pre class="codeblock${lang ? ` lang-${escape(lang)}` : ""}"><code>${escape(body)}</code></pre>`,
@@ -748,42 +3224,77 @@
748
3224
  // Heading · also handles the "## Title\nbody" case (no blank line
749
3225
  // between heading and content). Some prompts produce that pattern
750
3226
  // — without the multi-line branch the H2 leaks as literal `## …`
751
- // text into a paragraph fallback. The trailing lines are processed
752
- // as a separate paragraph block so we don't lose them.
3227
+ // text into a paragraph fallback. We render the heading first,
3228
+ // then SHIFT it off `lines` and fall through to the rest of the
3229
+ // block-detection branches so tables / lists / blockquotes
3230
+ // glued directly under the heading still parse structurally.
3231
+ // Without the fall-through (earlier code joined the trailing
3232
+ // lines into a single paragraph), an LLM-emitted "## How This
3233
+ // Goes Wrong\n| Failure | Indicator | Mitigation |\n|---|---|---|"
3234
+ // pattern leaked the pipe-table syntax as raw text — line breaks
3235
+ // collapsed to spaces, table parser never saw the separator row.
753
3236
  {
754
3237
  const m = /^(#{1,4})\s+(.+)$/.exec(lines[0]);
755
3238
  if (m) {
756
3239
  const level = Math.min(m[1].length, 4);
757
3240
  out.push(`<h${level}>${inline(escape(m[2]))}</h${level}>`);
758
3241
  if (lines.length > 1) {
759
- const rest = lines.slice(1).join(" ").trim();
760
- if (rest) out.push(`<p>${inline(escape(rest))}</p>`);
3242
+ lines.shift();
3243
+ // Fall through · subsequent branches (table / blockquote /
3244
+ // list / paragraph) run on the trailing content.
3245
+ } else {
3246
+ continue;
761
3247
  }
762
- continue;
763
3248
  }
764
3249
  }
765
3250
 
766
3251
  // Pipe table · header row · separator · data rows.
767
- // Detect: ≥ 2 lines, first looks like `| h1 | h2 |`, second is `|---|---|`.
768
- if (
769
- lines.length >= 2 &&
770
- /\|/.test(lines[0]) &&
771
- /^\s*\|?[\s:|\-]+\|?\s*$/.test(lines[1]) &&
772
- /-/.test(lines[1])
773
- ) {
3252
+ // Detect: ≥ 2 consecutive lines where lineN looks like `| h1 | h2 |`
3253
+ // and lineN+1 is the separator `|---|---|`. Scans the WHOLE block
3254
+ // (not just lines[0,1]) so a table embedded after a prose intro
3255
+ // — common when the LLM forgets to leave a blank line before
3256
+ // the table — still gets parsed as a table instead of falling
3257
+ // through to `<p>` and leaking pipe syntax into the rendered
3258
+ // text. Anything before the table boundary renders as prose;
3259
+ // anything after the data rows continues block parsing.
3260
+ const isSeparatorLine = (l) => /^\s*\|?[\s:|\-]+\|?\s*$/.test(l) && /-/.test(l);
3261
+ let tableHeadIdx = -1;
3262
+ for (let i = 0; i < lines.length - 1; i++) {
3263
+ if (/\|/.test(lines[i]) && isSeparatorLine(lines[i + 1])) {
3264
+ tableHeadIdx = i;
3265
+ break;
3266
+ }
3267
+ }
3268
+ if (tableHeadIdx !== -1) {
774
3269
  const splitRow = (l) =>
775
3270
  l
776
3271
  .replace(/^\s*\|/, "")
777
3272
  .replace(/\|\s*$/, "")
778
3273
  .split("|")
779
3274
  .map((c) => c.trim());
780
- const headers = splitRow(lines[0]);
781
- const rows = lines.slice(2).map(splitRow);
3275
+ // Prose before the table — emit as a paragraph (joined to a
3276
+ // single line · markdown wraps lines within a paragraph).
3277
+ if (tableHeadIdx > 0) {
3278
+ const preProse = lines.slice(0, tableHeadIdx).join(" ").trim();
3279
+ if (preProse) out.push(`<p>${inline(escape(preProse))}</p>`);
3280
+ }
3281
+ const headers = splitRow(lines[tableHeadIdx]);
3282
+ // Data rows continue as long as they contain a `|`. Anything
3283
+ // after that (e.g. trailing prose with no pipes) goes to a
3284
+ // separate paragraph.
3285
+ const dataStart = tableHeadIdx + 2;
3286
+ let dataEnd = dataStart;
3287
+ while (dataEnd < lines.length && /\|/.test(lines[dataEnd])) dataEnd++;
3288
+ const rows = lines.slice(dataStart, dataEnd).map(splitRow);
782
3289
  const thead = `<thead><tr>${headers.map((h) => `<th>${inline(escape(h))}</th>`).join("")}</tr></thead>`;
783
3290
  const tbody = `<tbody>${rows
784
3291
  .map((r) => `<tr>${r.map((c) => `<td>${inline(escape(c))}</td>`).join("")}</tr>`)
785
3292
  .join("")}</tbody>`;
786
3293
  out.push(`<table class="md-table">${thead}${tbody}</table>`);
3294
+ if (dataEnd < lines.length) {
3295
+ const tailProse = lines.slice(dataEnd).join(" ").trim();
3296
+ if (tailProse) out.push(`<p>${inline(escape(tailProse))}</p>`);
3297
+ }
787
3298
  continue;
788
3299
  }
789
3300
 
@@ -848,39 +3359,45 @@
848
3359
  * comparison so the report writer can produce minor variations (e.g.
849
3360
  * "Where We Diverged · The Crux") and the wrapper still applies. */
850
3361
  const SECTION_MATCHERS = [
851
- { re: /^bottom\s*line/i, cls: "section-bottom-line" },
852
- { re: /^the\s*thesis|^thesis$/i, cls: "section-thesis" },
853
- { re: /^a\s+working\s+hypothesis|^working\s*hypothesis/i, cls: "section-working-hypothesis" },
3362
+ { re: /^bottom\s*line|^我们的判断|^备忘要点|^战略要务|^战略判断|^对董事会的判断|^我们会持的立场|^立住了的那个想法|^我们最后落到哪里|^我们带走的东西|^我们逐渐相信的事|^我们目前对它的理解/i, cls: "section-bottom-line" },
3363
+ { re: /^the\s*thesis|^thesis$|^反共识判断|^核心论点|^核心主张|^判断要点|^分析师判断|^分析师立场|^中央主张|^the\s*counter[-\s]?consensus/i, cls: "section-thesis" },
3364
+ { re: /^a\s+working\s+hypothesis|^working\s*hypothesis|^工作假设|^当前判断|^当前工作判断|^初步立场|^摘要|^研究综述/i, cls: "section-working-hypothesis" },
854
3365
  { re: /^strategic\s*outlook|^战略前景|^战略展望/i, cls: "section-strategic-outlook" },
855
- { re: /^frame\s*shift/i, cls: "section-frame-shift" },
856
- { re: /^headline\s*findings?/i, cls: "section-headline-findings" },
857
- { re: /^three\s*big\s*ideas|^big\s*ideas/i, cls: "section-big-ideas" },
858
- { re: /^(where\s*we\s*)?converge[d]?/i, cls: "section-convergence" },
859
- { re: /^(where\s*we\s*)?diverge[d]?|crux/i, cls: "section-divergence" },
860
- { re: /^positions?/i, cls: "section-positions" },
861
- { re: /^options?\s*analysis/i, cls: "section-options-analysis" },
862
- { re: /^two\s*paths|^two\s*futures|^two\s*scenarios/i, cls: "section-two-paths" },
863
- { re: /^why\s*now/i, cls: "section-why-now" },
3366
+ { re: /^frame\s*shift|^问题如何转移|^问题如何被磨锋利|^问题如何被重新定义|^对研究问题的重新框定|^对问题的重新定义|^对决策框架的重置|^问题如何反过来改造了我们|^how\s+the\s+question\s+(moved|sharpened|was\s+reframed|changed\s+us)|^reframing\s+the/i, cls: "section-frame-shift" },
3367
+ { re: /^headline\s*findings?|^关键发现|^主要发现|^研究发现|^驱动判断的发现|^分析所揭示的内容|^findings?\s+that\s+drive|^principal\s*findings?/i, cls: "section-headline-findings" },
3368
+ { re: /^three\s*big\s*ideas|^big\s*ideas|^三个大想法|^共识忽视的三件事|^三大战略洞察|^支撑判断的三项发现|^三个战略主题|^反复出现的三个主题|^三点观察|^三个值得命名的模式|^我们入局的三个理由|^最显眼的三件事|^我们看到的三件事|^反复回到桌上的三件事|^我们注意到的三件事|^值得说出来的三个模式|^三个支撑|^三个主题|^数据支持的三个模式|^the\s*pillars$/i, cls: "section-big-ideas" },
3369
+ { re: /^(where\s*we\s*)?converge[d]?|^合伙人共识所在|^我们达成的共识|^全场逐渐明确的事|^我们彼此同意的地方|^独立路径上的趋同|^分析的共识所在|^我们彼此同意/i, cls: "section-convergence" },
3370
+ { re: /^(where\s*we\s*)?diverge[d]?|crux|^我们当前的分歧|^我们之间的分歧|^我们彼此无法说服的地方|^理性视角下的分歧|^战略层面的张力|^the\s+open\s+disagreement|^the\s+open\s+question\s+inside/i, cls: "section-divergence" },
3371
+ { re: /^positions?|^桌上的几派立场|^几派的位置|^战略阵营|^房间分成了哪几派|^几种思想流派/i, cls: "section-positions" },
3372
+ { re: /^options?\s*analysis|^房间里的草图|^sketches\s+from\s+the\s+room/i, cls: "section-options-analysis" },
3373
+ { re: /^two\s*paths|^two\s*futures|^two\s*scenarios|^two\s*roads|^two\s*lines\s+of\s+inquiry|^两条路线|^两条路|^两条研究路径|^平台路\s*vs|^战略选项对照/i, cls: "section-two-paths" },
3374
+ { re: /^why\s*now|^why\s+the\s+window\s+is\s+open|^why\s+this\s+mattered\s+today|^temporal\s*window|^为什么是现在|^刚刚打开的窗口|^为什么这扇窗户现在开着|^为何窗口现在打开|^为什么这事此刻要紧|^时间窗口/i, cls: "section-why-now" },
864
3375
  // Gartner-density blocks · placed in classifier order so a brief
865
3376
  // with all four reads top-to-bottom: outlook → assumptions → tree
866
3377
  // → indicators. The matchers tolerate light wording variation
867
3378
  // (e.g. "Critical Assumptions Log").
868
3379
  { re: /^critical\s*assumptions?|^load[-\s]bearing\s*assumptions?|^承重假设|^关键假设/i, cls: "section-critical-assumptions" },
869
- { re: /^scenario\s*tree|^scenarios?$|^情景树|^情景分析|^命名情景/i, cls: "section-scenario-tree" },
3380
+ { re: /^scenario\s*tree|^scenarios?$|^情景树|^情景分析|^命名情景|^世界可能落|^可能的演化路径|^战略情景|^可能的未来情景|^这事可能怎么演|^how\s+this\s+(?:could|might)\s+(?:play\s+out|unfold)|^plausible\s+futures|^where\s+the\s+world\s+could\s+land|^strategic\s*scenarios/i, cls: "section-scenario-tree" },
870
3381
  { re: /^leading\s*indicators?|^watch[-\s]list|^先行指标|^监测指标|^监控信号/i, cls: "section-leading-indicators" },
871
3382
  // Dashboard-style KPI strip · catches the house-style label
872
3383
  // variants (By the Numbers / The Underwrite / Three Numbers Worth
873
3384
  // Pricing In / Strategic Indicators / Quantitative Reads / 用数字说话 /
874
3385
  // 关键指标 / 指标看板 / 实证锚点 / 战略指标 / etc.) plus the literal default.
875
3386
  { re: /^by\s*the\s*numbers|^the\s*underwrite|^(three|key|strategic|quantitative)\s*(numbers?|metrics?|indicators?|reads?|anchors?)|^numbers?\s*(that|worth)|^the\s*numbers$|^a\s+few\s+numbers|^what\s+the\s+(numbers|data|math)|^why\s+the\s+math|^the\s*diagnostic\s*at\s*a\s*glance|^indicator\s*dashboard|^empirical\s*anchors|^metric\s*strip|^用数字说话|^支撑数据|^值得定价的|^数字$|^数字告诉我们|^几个浮上来的数字|^数据怎么说|^为什么数算得过来|^战略指标|^关键指标|^诊断速览|^驱动判断的数字|^定量结果|^实证锚点|^指标看板|^定量判读/i, cls: "section-metric-strip" },
876
- { re: /^recommendations?/i, cls: "section-recommendations" },
877
- { re: /^the\s*bet$|^the\s*bet[\s·:]/i, cls: "section-the-bet" },
878
- { re: /^considerations?/i, cls: "section-considerations" },
879
- { re: /^pre[-\s]?mortem|risks?/i, cls: "section-pre-mortem" },
880
- { re: /^new\s*questions?/i, cls: "section-new-questions" },
881
- { re: /^strategic\s*planning\s*assumption|^planning\s*assumption/i, cls: "section-planning-assumption" },
882
- { re: /^open\s*questions?/i, cls: "section-open-questions" },
3387
+ { re: /^recommendations?|^how\s+we[''']?d\s+move|^我们会怎么做|^让它成立需要做的事|^应当采取的行动|^动作|^战略要务|^我们会怎么用这个|^我们会怎么把这事落下来|^如何执行/i, cls: "section-recommendations" },
3388
+ { re: /^the\s*bet$|^the\s*bet[\s·:]|^the\s*bet\s+(on\s+the\s+table|we[''']?d\s+take)|^conditions?\s+for\s+(the\s+claim|commitment)|^我们会下的注|^我们愿意下的注|^桌上的这笔下注|^怎么打这局|^在哪发力|^做出承诺的前提条件|^支撑该主张的条件|^要行动需要相信什么|^what\s+we[''']?d\s+bet|^how\s+to\s+play\s+it|^where\s+to\s+push/i, cls: "section-the-bet" },
3389
+ { re: /^considerations?|^things?\s+to\s+watch|^things?\s+we[''']?d\s+(stress[-\s]?test|sit\s+with)|^trade[-\s]?offs?\s+to\s+weigh|^things?\s+worth\s+(considering|thinking\s+about)|^practical\s+implications?|^implications?$|^需要压力测试的点|^需要权衡的取舍|^值得留意的点|^值得考虑的事项|^实践层面的(?:含义|影响)|^由此引出的影响|^值得想想的事|^我们会陪着的问题/i, cls: "section-considerations" },
3390
+ { re: /^pre[-\s]?mortem|risks?|^how\s+this\s+goes\s+wrong|^where\s+(?:it|things|execution)\s+(?:could\s+(?:break|fail)|tend\s+to\s+fall\s+apart)|^the\s+failure\s+modes|^how\s+the\s+argument\s+could\s+fail|^我们在替谁兜底的失败模式|^这事会怎么搞砸|^可能崩盘之处|^执行可能崩盘之处|^我们大概会卡在哪|^通常会崩在哪|^已考虑的失败模式|^论点可能如何失败|^需管理的风险/i, cls: "section-pre-mortem" },
3391
+ { re: /^new\s*questions?|^the\s*next\s*set\s*of\s*questions|^questions?\s+(?:for\s+the\s+next\s+phase|we[''']?d\s+sit\s+with|we\s+walked\s+out\s+with|worth\s+(?:sitting\s+with|chasing))|^what[''']?s\s+still\s+unclear|^what\s+we[''']?d\s+want\s+to\s+know\s+next|^what\s+this\s+opens\s+up|^what\s+the\s+next\s+diagnostic\s+should\s+probe|^下一组问题|^由此打开的问题|^下一步想搞清楚的事|^仍未明确的问题|^走出会议室时带着的新问题|^我们打包带回家的问题|^下一阶段需要回答的问题|^下一次诊断该深入的问题|^future\s+work|^open\s+lines\s+of\s+inquiry|^尚开放的研究方向|^后续工作|^we[''']?re\s+going\s+home\s+with/i, cls: "section-new-questions" },
3392
+ { re: /^strategic\s*planning\s*assumption|^planning\s*assumption|^the\s+bet\s+behind\s+the\s+bet|^the\s+forecast|^forecasting\s+assumption|^战略规划假设|^背后的下注假设|^预判|^预测假设|^我们认为会成立的判断|^what\s+we\s+think\s+will\s+hold/i, cls: "section-planning-assumption" },
3393
+ { re: /^open\s*questions?|^outstanding\s+items|^still\s+(?:on\s+the\s+table|unresolved)|^open\s+items|^limitations|^待办事项|^尚在桌上|^待解决事项|^仍未解决|^研究局限/i, cls: "section-open-questions" },
883
3394
  { re: /^methodology|^方法论|^methods?$/i, cls: "section-methodology" },
3395
+ { re: /^appendix\b|^appendices$|^附录|^附件/i, cls: "section-appendix" },
3396
+ // Phase 2B · structural components
3397
+ { re: /^risk\s*register|^standing\s+risks?|^risks?\s+we[''']?re\s+carrying|^风险登记|^风险清单|^我们正在背的风险|^标准风险簿|^运行风险/i, cls: "section-risk-register" },
3398
+ { re: /^decision\s+options?|^options?\s+we\s+weighed|^the\s+path\s+we[''']?re\s+recommending|^candidate\s+options?|^选项对比|^我们权衡过的选项|^建议采取的路径|^候选方案/i, cls: "section-decision-options" },
3399
+ { re: /^a\s+comparison$|^two\s+trajectories|^side\s+by\s+side|^两条路径|^对比$|^A\s*Comparison/i, cls: "section-path-comparison" },
3400
+ { re: /^views?\s+compared|^where\s+each\s+director\s+stood|^how\s+the\s+room\s+read\s+this|^each\s+director.{0,4}view|^director\s+perspectives?|^观点对比|^每位.{0,3}观点|^房间是怎样读这件事的/i, cls: "section-views-compared" },
884
3401
  ];
885
3402
 
886
3403
  function classifyHeading(text) {
@@ -889,6 +3406,52 @@
889
3406
  return null;
890
3407
  }
891
3408
 
3409
+ /** Strip H2s that have no real content between them and the next H2.
3410
+ *
3411
+ * This is the permanent fix for the recurring "Section 01 has only a
3412
+ * title with no body" bug. Stage 3's system prompt requires the
3413
+ * writer to "Start with a single H2 title" while the cover page
3414
+ * ALSO renders the brief title as <h1 class="cover-title">, so the
3415
+ * body's first H2 (the title) is always followed immediately by the
3416
+ * next real H2 (`## Bottom Line` or similar). injectChapterNumbers
3417
+ * would then label the empty title H2 as "Section 01" and the user
3418
+ * sees a numbered chapter with no content.
3419
+ *
3420
+ * Beyond the title case, this also catches any other Stage 3 emission
3421
+ * where the model writes two H2s in a row (or an H2 followed only by
3422
+ * whitespace / empty <p></p>). Empty headings should never render —
3423
+ * they confuse the chapter-num pass, the section-class wrapper, and
3424
+ * the reading-nav.
3425
+ *
3426
+ * "Real content" = an element with non-whitespace text, OR an
3427
+ * element-of-substance (img / table / pre / hr / blockquote / figure
3428
+ * / fenced-block container). Whitespace text nodes and empty <p>s
3429
+ * do not count.
3430
+ */
3431
+ function dropEmptyHeadings(body) {
3432
+ const headings = Array.from(body.querySelectorAll("h2"));
3433
+ for (const h of headings) {
3434
+ let n = h.nextSibling;
3435
+ let hasContent = false;
3436
+ while (n && !(n.nodeType === 1 && n.tagName === "H2")) {
3437
+ if (n.nodeType === 1) {
3438
+ const txt = (n.textContent || "").trim();
3439
+ if (txt) { hasContent = true; break; }
3440
+ // Empty element with no text — but might be a structural
3441
+ // element (img, hr, table with no caption). Check tag.
3442
+ if (/^(IMG|HR|TABLE|FIGURE|PRE|BLOCKQUOTE|DIV)$/.test(n.tagName)) {
3443
+ hasContent = true;
3444
+ break;
3445
+ }
3446
+ } else if (n.nodeType === 3) {
3447
+ if ((n.nodeValue || "").trim()) { hasContent = true; break; }
3448
+ }
3449
+ n = n.nextSibling;
3450
+ }
3451
+ if (!hasContent) h.remove();
3452
+ }
3453
+ }
3454
+
892
3455
  /** Walk the body, tag each H2 + wrap its following content (until the
893
3456
  * next H2 or end of body) in a <section> with the matching class. */
894
3457
  function wrapSections(body) {
@@ -942,6 +3505,7 @@
942
3505
  if (h2.classList.contains("section-thesis")) continue;
943
3506
  if (h2.classList.contains("section-working-hypothesis")) continue;
944
3507
  if (h2.classList.contains("section-methodology")) continue;
3508
+ if (h2.classList.contains("section-appendix")) continue;
945
3509
  n += 1;
946
3510
  const num = String(n).padStart(2, "0");
947
3511
  const label = document.createElement("div");
@@ -1030,11 +3594,17 @@
1030
3594
  // colons stripped). Considerations uses different label words for the
1031
3595
  // same fields — both map here.
1032
3596
  const REC_LABEL_MAP = [
1033
- { key: "rationale", patterns: ["rationale", "worth thinking about because"] },
1034
- { key: "owner", patterns: ["owner", "who'd own it", "who would own it"] },
1035
- { key: "horizon", patterns: ["horizon", "on what horizon"] },
1036
- { key: "metric", patterns: ["success metric", "what you'd watch", "what you would watch"] },
1037
- { key: "risk", patterns: ["risk if skipped", "what you'd give up by not doing this", "what you'd give up"] },
3597
+ { key: "rationale", patterns: ["rationale", "worth thinking about because"] },
3598
+ { key: "owner", patterns: ["owner", "who'd own it", "who would own it"] },
3599
+ { key: "horizon", patterns: ["horizon", "on what horizon"] },
3600
+ { key: "metric", patterns: ["success metric", "what you'd watch", "what you would watch"] },
3601
+ // Critical dependency · the writer prompt produces this as a
3602
+ // first-class field but it was previously silently dropped (no
3603
+ // entry in this map → recLabelKey returns null → field skipped),
3604
+ // so users who put real dependency content saw it vanish. Now
3605
+ // routed to the rendered card as its own row.
3606
+ { key: "dependency", patterns: ["critical dependency", "depends on", "dependency"] },
3607
+ { key: "risk", patterns: ["risk if skipped", "what you'd give up by not doing this", "what you'd give up"] },
1038
3608
  ];
1039
3609
  function recLabelKey(rawLabel) {
1040
3610
  const t = String(rawLabel || "").toLowerCase().replace(/[::]\s*$/, "").trim();
@@ -1189,6 +3759,29 @@
1189
3759
  newLi.appendChild(meta);
1190
3760
  }
1191
3761
 
3762
+ // Critical dependency · separate row before risk. Same prefix +
3763
+ // body shape as the risk row (mono uppercase prefix → em-dash →
3764
+ // body) but in a neutral colour, so the card still has a clear
3765
+ // sequence: claim → rationale → meta → depends-on → risk.
3766
+ if (fields.dependency) {
3767
+ const dep = document.createElement("p");
3768
+ dep.className = "rec-dependency";
3769
+ const prefix = document.createElement("span");
3770
+ prefix.className = "rec-dependency-prefix";
3771
+ prefix.textContent = "Depends on";
3772
+ const sep = document.createElement("span");
3773
+ sep.className = "rec-dependency-sep";
3774
+ sep.setAttribute("aria-hidden", "true");
3775
+ sep.textContent = "—";
3776
+ const text = document.createElement("span");
3777
+ text.className = "rec-dependency-text";
3778
+ text.innerHTML = fields.dependency;
3779
+ dep.appendChild(prefix);
3780
+ dep.appendChild(sep);
3781
+ dep.appendChild(text);
3782
+ newLi.appendChild(dep);
3783
+ }
3784
+
1192
3785
  if (fields.risk) {
1193
3786
  const risk = document.createElement("p");
1194
3787
  risk.className = "rec-risk";
@@ -1260,14 +3853,20 @@
1260
3853
  return null;
1261
3854
  }
1262
3855
  function parseNqMetaParagraph(p) {
1263
- const ems = Array.from(p.querySelectorAll(":scope > em, :scope > strong > em"));
3856
+ /* Match labels wrapped in `<em>`, `<strong>`, or `<strong><em>`.
3857
+ * The brief LLM is inconsistent about whether to bold ("**Why it
3858
+ * matters:**") or italic ("*Why it matters:*") the label, so we
3859
+ * accept both — otherwise the prefix leaks into the body prose
3860
+ * and the rendered card looks like "Why it matters: ..." with
3861
+ * the label visible alongside the why-text. */
3862
+ const labels = Array.from(p.querySelectorAll(":scope > em, :scope > strong, :scope > strong > em"));
1264
3863
  const out = [];
1265
- if (ems.length === 0) return out;
1266
- for (let i = 0; i < ems.length; i++) {
1267
- const em = ems[i];
3864
+ if (labels.length === 0) return out;
3865
+ for (let i = 0; i < labels.length; i++) {
3866
+ const em = labels[i];
1268
3867
  const key = nqLabelKey(em.textContent);
1269
3868
  if (!key) continue;
1270
- const stop = ems[i + 1] || null;
3869
+ const stop = labels[i + 1] || null;
1271
3870
  let value = "";
1272
3871
  let n = em.nextSibling;
1273
3872
  while (n && n !== stop) {
@@ -1373,11 +3972,86 @@
1373
3972
  function mergeAllOlsInSections(body) {
1374
3973
  const sections = body.querySelectorAll("section");
1375
3974
  for (const s of sections) mergeConsecutiveOls(s);
3975
+ // Body-wide groupwise pass · catches H2s that didn't match a known
3976
+ // section classifier (e.g. model-invented "共识忽视的三件事" /
3977
+ // "What we're missing"). Their ordered-list content sits as loose
3978
+ // body children between H2s and was never wrapped in a <section>,
3979
+ // so the per-section merge above never reached it — every
3980
+ // blank-line-separated `1. …` item rendered as its own <ol>
3981
+ // restarting at "1". Walks direct body children groupwise: H2 (or
3982
+ // any non-OL sibling) breaks the run so we never merge across
3983
+ // unrelated headings.
3984
+ mergeAdjacentOlSiblings(body);
3985
+ }
3986
+
3987
+ /** Merge each run of consecutive sibling `<ol>` elements (with
3988
+ * optional `<p>` paragraphs in between) into a single `<ol>`.
3989
+ *
3990
+ * The harder case it handles: "rich" list items where each item's
3991
+ * body is multiple prose paragraphs. The brief writer emits
3992
+ *
3993
+ * 1. **claim**
3994
+ * *"quote"*
3995
+ *
3996
+ * elaboration paragraph A
3997
+ *
3998
+ * elaboration paragraph B
3999
+ *
4000
+ * 2. **claim 2**
4001
+ * …
4002
+ *
4003
+ * → blank-line-separated blocks → markdown parser produces
4004
+ *
4005
+ * <ol> (item 1) <p>elab A</p> <p>elab B</p> <ol> (item 2) <p>…</p> <ol>(item 3)
4006
+ *
4007
+ * The earlier "consecutive only" merger broke at the first <p>,
4008
+ * leaving three separate `<ol>` blocks each numbered "1." — which
4009
+ * is exactly the "1. 1. 1." bug the user kept hitting. Now: <p>s
4010
+ * between two `<ol>`s get appended INTO the last `<li>` of the
4011
+ * merged ol so they read as continuation of the prior item.
4012
+ *
4013
+ * H2 / H3 / table / blockquote / hr break the run (they're either
4014
+ * section boundaries or genuinely standalone content). */
4015
+ function mergeAdjacentOlSiblings(parent) {
4016
+ const children = Array.from(parent.children);
4017
+ let mergedOl = null;
4018
+ let lastLi = null;
4019
+ for (const child of children) {
4020
+ if (child.tagName === "OL") {
4021
+ const newLis = Array.from(child.children).filter((c) => c.tagName === "LI");
4022
+ if (mergedOl === null) {
4023
+ // First ol of a new run · become the merged head.
4024
+ mergedOl = child;
4025
+ lastLi = newLis[newLis.length - 1] || null;
4026
+ } else {
4027
+ // Subsequent ol · move its li children into the merged ol
4028
+ // and drop the now-empty wrapper.
4029
+ for (const li of newLis) mergedOl.appendChild(li);
4030
+ child.remove();
4031
+ if (newLis.length > 0) lastLi = newLis[newLis.length - 1];
4032
+ }
4033
+ } else if (child.tagName === "P" && mergedOl !== null && lastLi !== null) {
4034
+ // Paragraph BETWEEN two ols (or after the last ol of a run)
4035
+ // · attach it to the prior item's <li>. The interleaving
4036
+ // paragraphs in the source markdown belong to the item that
4037
+ // precedes them, not to a new top-level paragraph block.
4038
+ lastLi.appendChild(child);
4039
+ } else {
4040
+ // Anything else (H2/H3/table/blockquote/hr) breaks the run.
4041
+ mergedOl = null;
4042
+ lastLi = null;
4043
+ }
4044
+ }
1376
4045
  }
1377
4046
 
1378
4047
  function decorateReport(body) {
1379
4048
  if (!body) return;
1380
4049
  try {
4050
+ // Strip empty H2s BEFORE any structural pass — the title H2
4051
+ // emitted by Stage 3 is empty (cover page carries the title),
4052
+ // and any other model misemission of an empty heading would
4053
+ // confuse wrapSections + injectChapterNumbers downstream.
4054
+ dropEmptyHeadings(body);
1381
4055
  wrapSections(body);
1382
4056
  mergeAllOlsInSections(body);
1383
4057
  tagBadges(body);
@@ -1386,6 +4060,8 @@
1386
4060
  injectExhibitLabels(body);
1387
4061
  restructureRecommendations(body);
1388
4062
  restructureNewQuestions(body);
4063
+ buildReadingNav(body);
4064
+ buildPrintToc(body);
1389
4065
  } catch (e) {
1390
4066
  // Decoration is purely visual — log and leave the raw markdown
1391
4067
  // standing if anything goes sideways.
@@ -1393,6 +4069,231 @@
1393
4069
  }
1394
4070
  }
1395
4071
 
4072
+ /** Reading-position strip · scan H2 sections in body, generate a
4073
+ * sticky chapter nav at the top, and wire IntersectionObserver so
4074
+ * the active section's nav item gets `is-current`. */
4075
+ function buildReadingNav(body) {
4076
+ const nav = document.querySelector("[data-reading-nav]");
4077
+ if (!nav || !body) return;
4078
+ nav.innerHTML = "";
4079
+ // Skip nav for very short reports (<= 2 sections) — adds noise
4080
+ // without value when there's barely anything to navigate.
4081
+ const h2s = Array.from(body.querySelectorAll("h2"));
4082
+ if (h2s.length < 3) {
4083
+ nav.classList.remove("is-ready");
4084
+ return;
4085
+ }
4086
+
4087
+ // Anchor sections (Thesis / Working Hypothesis / Bottom Line) get
4088
+ // a short label like "Thesis"; methodology gets "Methodology";
4089
+ // numbered chapters use the kicker text from the preceding
4090
+ // .chapter-num + a short H2 fragment.
4091
+ const ANCHOR_LABELS = {
4092
+ "section-bottom-line": { en: "Bottom Line", zh: "判断" },
4093
+ "section-thesis": { en: "Thesis", zh: "判断" },
4094
+ "section-working-hypothesis": { en: "Hypothesis", zh: "假设" },
4095
+ "section-methodology": { en: "Methodology", zh: "方法论" },
4096
+ };
4097
+ const isCjk = document.body.classList.contains("is-cjk");
4098
+
4099
+ const items = [];
4100
+ for (let i = 0; i < h2s.length; i++) {
4101
+ const h2 = h2s[i];
4102
+ const id = h2.id || `sec-${i + 1}`;
4103
+ h2.id = id;
4104
+
4105
+ // Anchor sections use a short fixed label.
4106
+ let label = "";
4107
+ for (const cls of Object.keys(ANCHOR_LABELS)) {
4108
+ if (h2.classList.contains(cls)) {
4109
+ label = isCjk ? ANCHOR_LABELS[cls].zh : ANCHOR_LABELS[cls].en;
4110
+ break;
4111
+ }
4112
+ }
4113
+
4114
+ // Numbered chapter · use the kicker text ("Section 0X") if
4115
+ // present. This sits in a <div class="chapter-num"> that
4116
+ // injectChapterNumbers placed BEFORE the H2.
4117
+ if (!label) {
4118
+ const prev = h2.previousElementSibling;
4119
+ if (prev && prev.classList.contains("chapter-num")) {
4120
+ const kicker = prev.textContent.trim();
4121
+ // Append a 2–3 word excerpt of the H2 to disambiguate.
4122
+ const h2Text = (h2.textContent || "").trim();
4123
+ const excerpt = h2Text.length > 26 ? h2Text.slice(0, 26).replace(/\s+\S*$/, "") + "…" : h2Text;
4124
+ label = `${kicker} — ${excerpt}`;
4125
+ } else {
4126
+ // Bare H2 (no chapter-num, no anchor class) · use H2 text.
4127
+ const h2Text = (h2.textContent || "").trim();
4128
+ label = h2Text.length > 32 ? h2Text.slice(0, 32).replace(/\s+\S*$/, "") + "…" : h2Text;
4129
+ }
4130
+ }
4131
+ items.push({ id, label });
4132
+ }
4133
+
4134
+ if (items.length === 0) {
4135
+ nav.classList.remove("is-ready");
4136
+ return;
4137
+ }
4138
+
4139
+ const list = document.createElement("ol");
4140
+ list.className = "reading-nav-list";
4141
+ for (const it of items) {
4142
+ const li = document.createElement("li");
4143
+ const a = document.createElement("a");
4144
+ a.className = "reading-nav-item";
4145
+ a.href = `#${it.id}`;
4146
+ a.textContent = it.label;
4147
+ a.setAttribute("data-target", it.id);
4148
+ a.addEventListener("click", (e) => {
4149
+ e.preventDefault();
4150
+ const target = document.getElementById(it.id);
4151
+ if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
4152
+ try { history.replaceState(null, "", `#${it.id}`); } catch (_) {}
4153
+ });
4154
+ li.appendChild(a);
4155
+ list.appendChild(li);
4156
+ }
4157
+ nav.appendChild(list);
4158
+ nav.classList.add("is-ready");
4159
+
4160
+ // IntersectionObserver — mark the section nearest the viewport
4161
+ // top as `is-current`. We use a top-anchored rootMargin so a
4162
+ // section becomes current when its top crosses ~25% from top.
4163
+ if (typeof IntersectionObserver === "undefined") return;
4164
+ const navItems = Array.from(nav.querySelectorAll(".reading-nav-item"));
4165
+ const idMap = new Map();
4166
+ for (const item of navItems) idMap.set(item.dataset.target, item);
4167
+ const visible = new Map();
4168
+ const setCurrent = (id) => {
4169
+ for (const item of navItems) item.classList.toggle("is-current", item.dataset.target === id);
4170
+ // Scroll the active item into view inside the horizontal strip
4171
+ // so it's never clipped off-screen on small widths.
4172
+ const item = idMap.get(id);
4173
+ if (item && nav.scrollWidth > nav.clientWidth) {
4174
+ const offsetLeft = item.offsetLeft;
4175
+ const itemWidth = item.offsetWidth;
4176
+ const navWidth = nav.clientWidth;
4177
+ const desired = offsetLeft - (navWidth - itemWidth) / 2;
4178
+ nav.scrollTo({ left: Math.max(0, desired), behavior: "smooth" });
4179
+ }
4180
+ };
4181
+ const observer = new IntersectionObserver((entries) => {
4182
+ for (const e of entries) {
4183
+ if (e.isIntersecting) visible.set(e.target.id, e.boundingClientRect.top);
4184
+ else visible.delete(e.target.id);
4185
+ }
4186
+ // Pick the visible H2 with the smallest non-negative top (the
4187
+ // one closest to viewport top from below); if none, pick the
4188
+ // most-recently-passed (largest negative top).
4189
+ let bestId = null; let bestTop = -Infinity;
4190
+ for (const [id, top] of visible.entries()) {
4191
+ if (top <= 80 && top > bestTop) { bestTop = top; bestId = id; }
4192
+ }
4193
+ if (!bestId && visible.size > 0) {
4194
+ // Fallback: first visible
4195
+ bestId = visible.keys().next().value;
4196
+ }
4197
+ if (bestId) setCurrent(bestId);
4198
+ }, {
4199
+ rootMargin: "-60px 0px -70% 0px",
4200
+ threshold: [0, 1],
4201
+ });
4202
+ for (const h2 of h2s) observer.observe(h2);
4203
+ // Initial state · whatever H2 is closest to top on load.
4204
+ if (h2s[0]) setCurrent(h2s[0].id);
4205
+ }
4206
+
4207
+ /** Table of contents · print-only. The reading-nav handles screen
4208
+ * wayfinding; PDF readers have no equivalent affordance, so for
4209
+ * long briefs we emit a TOC page between the cover and the body
4210
+ * that lists every H2 + its label. Skipped on short briefs (< 6
4211
+ * H2s) where it would just add a near-empty page. Anchor ids on
4212
+ * the H2s are set by `buildReadingNav` (which runs first); we
4213
+ * reuse them here so the TOC items are clickable in the on-screen
4214
+ * preview too — though `display: none` hides the whole element
4215
+ * outside print. */
4216
+ function buildPrintToc(body) {
4217
+ const toc = document.querySelector("[data-print-toc]");
4218
+ if (!toc || !body) return;
4219
+ toc.innerHTML = "";
4220
+ const h2s = Array.from(body.querySelectorAll("h2"));
4221
+ if (h2s.length < 6) return;
4222
+
4223
+ const ANCHOR_LABELS = {
4224
+ "section-bottom-line": { en: "Bottom Line", zh: "判断" },
4225
+ "section-thesis": { en: "Thesis", zh: "论点" },
4226
+ "section-working-hypothesis": { en: "Working Hypothesis", zh: "工作假设" },
4227
+ "section-methodology": { en: "Methodology", zh: "方法论" },
4228
+ "section-opening-hook": { en: "Opening", zh: "开篇" },
4229
+ "section-deliverable-summary": { en: "Under Review", zh: "审查范围" },
4230
+ };
4231
+ const isCjk = document.body.classList.contains("is-cjk");
4232
+ const headerText = isCjk ? "目录" : "In this brief";
4233
+
4234
+ const items = [];
4235
+ for (let i = 0; i < h2s.length; i++) {
4236
+ const h2 = h2s[i];
4237
+ const id = h2.id || `sec-${i + 1}`;
4238
+ h2.id = id;
4239
+ let kicker = "";
4240
+ let label = (h2.textContent || "").trim();
4241
+ for (const cls of Object.keys(ANCHOR_LABELS)) {
4242
+ if (h2.classList.contains(cls)) {
4243
+ kicker = isCjk ? ANCHOR_LABELS[cls].zh : ANCHOR_LABELS[cls].en;
4244
+ label = "";
4245
+ break;
4246
+ }
4247
+ }
4248
+ if (!kicker) {
4249
+ const prev = h2.previousElementSibling;
4250
+ if (prev && prev.classList.contains("chapter-num")) {
4251
+ kicker = prev.textContent.trim();
4252
+ }
4253
+ }
4254
+ items.push({ id, kicker, label });
4255
+ }
4256
+
4257
+ const heading = document.createElement("h2");
4258
+ heading.className = "toc-print-title";
4259
+ heading.textContent = headerText;
4260
+ toc.appendChild(heading);
4261
+
4262
+ const list = document.createElement("ol");
4263
+ list.className = "toc-print-list";
4264
+ items.forEach((it, idx) => {
4265
+ const li = document.createElement("li");
4266
+ li.className = "toc-print-item";
4267
+ const a = document.createElement("a");
4268
+ a.className = "toc-print-link";
4269
+ a.href = `#${it.id}`;
4270
+ // Sequential ordinal · 1, 2, 3 … so the TOC reads as a numbered
4271
+ // index even when individual sections (Bottom Line / Methodology)
4272
+ // don't carry a chapter number of their own. Two-digit zero-pad
4273
+ // for visual alignment in the PDF layout. Headless Chrome's
4274
+ // print engine is inconsistent about CSS counter() in
4275
+ // ::before content, so we inject the digits explicitly.
4276
+ const ordinalSpan = document.createElement("span");
4277
+ ordinalSpan.className = "toc-print-ordinal";
4278
+ ordinalSpan.textContent = String(idx + 1).padStart(2, "0");
4279
+ a.appendChild(ordinalSpan);
4280
+ const kickerSpan = document.createElement("span");
4281
+ kickerSpan.className = "toc-print-kicker";
4282
+ kickerSpan.textContent = it.kicker || "—";
4283
+ a.appendChild(kickerSpan);
4284
+ if (it.label) {
4285
+ const labelSpan = document.createElement("span");
4286
+ labelSpan.className = "toc-print-label";
4287
+ labelSpan.textContent = it.label;
4288
+ a.appendChild(labelSpan);
4289
+ }
4290
+ li.appendChild(a);
4291
+ list.appendChild(li);
4292
+ });
4293
+ toc.appendChild(list);
4294
+ toc.classList.add("is-ready");
4295
+ }
4296
+
1396
4297
  function showError(msg) {
1397
4298
  root.innerHTML = `<div class="placeholder error">${escape(msg)}</div>`;
1398
4299
  }
@@ -1402,18 +4303,15 @@
1402
4303
  showError("no room id in url");
1403
4304
  return;
1404
4305
  }
1405
- let room, brief, members, briefs;
4306
+ let room, brief, members;
1406
4307
  try {
1407
- // Load room state + ALL briefs for this room in parallel. The
1408
- // viewer picks the requested brief (when ?b= present) or the
1409
- // newest, then renders the version-tab strip if there are ≥ 2.
4308
+ // Load room state + the requested brief in parallel.
1410
4309
  const briefEndpoint = briefId
1411
4310
  ? "/api/briefs/" + encodeURIComponent(briefId)
1412
4311
  : "/api/rooms/" + encodeURIComponent(roomId) + "/brief";
1413
- const [stateRes, briefRes, listRes] = await Promise.all([
4312
+ const [stateRes, briefRes] = await Promise.all([
1414
4313
  fetch("/api/rooms/" + encodeURIComponent(roomId)),
1415
4314
  fetch(briefEndpoint),
1416
- fetch("/api/rooms/" + encodeURIComponent(roomId) + "/briefs"),
1417
4315
  ]);
1418
4316
  if (!briefRes.ok) {
1419
4317
  showError(briefRes.status === 404 ? "no brief filed for this room yet" : "failed to load brief");
@@ -1427,23 +4325,24 @@
1427
4325
  } else {
1428
4326
  members = [];
1429
4327
  }
1430
- if (listRes.ok) {
1431
- const j = await listRes.json();
1432
- briefs = Array.isArray(j.briefs) ? j.briefs : [];
1433
- } else {
1434
- briefs = [];
1435
- }
1436
4328
  } catch (e) {
1437
4329
  showError("network error: " + (e && e.message ? e.message : String(e)));
1438
4330
  return;
1439
4331
  }
1440
4332
 
1441
4333
  document.title = "BOARDROOM // " + (brief.title || "brief");
1442
- swapSpine(brief.spine || "boardroom-dark");
4334
+ swapSpine(brief.spine || "boardroom-dark", brief);
1443
4335
 
1444
- const signed = members
1445
- .map((a) => `<img src="${escape(a.avatarPath)}" alt="${escape(a.name)}" title="${escape(a.name)}">`)
1446
- .join("");
4336
+ // Author byline · name list with a thin middot separator. Earlier
4337
+ // we rendered avatar imgs here, but the cover already speaks in
4338
+ // typography (serif H1 + label/value pairs) — a row of circular
4339
+ // portrait avatars read as a casual social-app affordance against
4340
+ // an editorial document. Names alone match the register and let
4341
+ // the eye scan the contributors as a list of authorities, the
4342
+ // way a byline on a research note normally reads.
4343
+ const signedNames = members
4344
+ .map((a) => escape(a.name))
4345
+ .join(" <span class=\"author-sep\" aria-hidden=\"true\">·</span> ");
1447
4346
 
1448
4347
  const filedDate = brief.createdAt
1449
4348
  ? new Date(brief.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
@@ -1451,29 +4350,52 @@
1451
4350
  const docId = (brief.id || "").slice(0, 12).toUpperCase();
1452
4351
  const subjectShort = room?.subject ? escape(room.subject.length > 64 ? room.subject.slice(0, 64) + "…" : room.subject) : "—";
1453
4352
 
1454
- // Version strip · only when 2 briefs filed for this room. Lets
1455
- // the reader see they're holding "version 02 of 03" of this room's
1456
- // research note, with siblings linkable. Sorted oldest newest so
1457
- // "01" reads as the original.
1458
- const sortedBriefs = (briefs || []).slice().sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1459
- const showVersions = sortedBriefs.length > 1;
1460
- const versionsHtml = showVersions ? `
1461
- <div class="cover-versions">
1462
- <span class="cover-versions-label">Versions</span>
1463
- ${sortedBriefs.map((bf, i) => {
1464
- const num = String(i + 1).padStart(2, "0");
1465
- const isActive = bf.id === brief.id;
1466
- const isInitial = i === 0;
1467
- const hint = isInitial
1468
- ? "Initial brief"
1469
- : (bf.supplement && bf.supplement.trim()
1470
- ? `Supplement: ${bf.supplement.trim().slice(0, 120)}${bf.supplement.trim().length > 120 ? "…" : ""}`
1471
- : "Regenerated brief");
1472
- const href = `/report.html?r=${encodeURIComponent(roomId)}&b=${encodeURIComponent(bf.id)}`;
1473
- return `<a class="cover-version${isActive ? " active" : ""}" href="${href}" title="${escape(hint)}"><span class="num">${num}</span><span class="sep">/</span><span class="hint">${escape(isInitial ? "Initial" : (bf.supplement || "—").trim().slice(0, 22))}${(!isInitial && bf.supplement && bf.supplement.length > 22) ? "…" : ""}</span></a>`;
1474
- }).join("")}
1475
- </div>
1476
- ` : "";
4353
+ // Cover deck · the subtitle under the H1. Derived from the brief's
4354
+ // own opening prose (the lede) instead of a separate column
4355
+ // writers naturally put their punchiest framing in the first
4356
+ // paragraph, so it reads as a written subtitle. Skips leading
4357
+ // headings, fenced blocks, lists, blockquotes, and HTML so we land
4358
+ // on real paragraph text. Strips inline markdown decoration and
4359
+ // caps length so it fits in 2-3 lines under the title.
4360
+ const deckText = (() => {
4361
+ const md = (brief.bodyMd || "").trim();
4362
+ if (!md) return "";
4363
+ const lines = md.split(/\r?\n/);
4364
+ const para = [];
4365
+ let inFence = false;
4366
+ for (const raw of lines) {
4367
+ const line = raw.trimEnd();
4368
+ if (/^```/.test(line)) { inFence = !inFence; if (para.length) break; continue; }
4369
+ if (inFence) continue;
4370
+ if (!line.trim()) { if (para.length) break; continue; }
4371
+ if (/^#{1,6}\s/.test(line)) continue;
4372
+ if (/^[-*+]\s|^\d+\.\s/.test(line)) { if (para.length) break; continue; }
4373
+ if (/^>\s?/.test(line)) { if (para.length) break; continue; }
4374
+ if (/^<[a-z!/]/i.test(line)) { if (para.length) break; continue; }
4375
+ if (/^[-=*_]{3,}\s*$/.test(line)) { if (para.length) break; continue; }
4376
+ if (/^\|/.test(line)) { if (para.length) break; continue; }
4377
+ para.push(line.trim());
4378
+ }
4379
+ let text = para.join(" ");
4380
+ text = text
4381
+ .replace(/`([^`]+)`/g, "$1")
4382
+ .replace(/!\[[^\]]*]\([^)]*\)/g, "")
4383
+ .replace(/\[([^\]]+)]\([^)]*\)/g, "$1")
4384
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
4385
+ .replace(/__([^_]+)__/g, "$1")
4386
+ .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1")
4387
+ .replace(/(?<!_)_([^_]+)_(?!_)/g, "$1")
4388
+ .replace(/<[^>]+>/g, "")
4389
+ .replace(/\s+/g, " ")
4390
+ .trim();
4391
+ const CAP = 220;
4392
+ if (text.length > CAP) {
4393
+ const cut = text.slice(0, CAP);
4394
+ const lastSpace = cut.lastIndexOf(" ");
4395
+ text = (lastSpace > CAP * 0.7 ? cut.slice(0, lastSpace) : cut).trim() + "…";
4396
+ }
4397
+ return text;
4398
+ })();
1477
4399
 
1478
4400
  // Masthead: the dossier strip at the top of the cover. Reads like
1479
4401
  // a research-note dateline rather than a marketing eyebrow.
@@ -1490,17 +4412,16 @@
1490
4412
  <span class="secondary">Filed ${escape(isoFiled)}</span>
1491
4413
  </div>
1492
4414
  <h1 class="cover-title">${escape(brief.title || "(untitled)")}</h1>
1493
- ${room?.subject ? `<div class="cover-deck">${escape(room.subject)}</div>` : ""}
4415
+ ${deckText ? `<div class="cover-deck">${escape(deckText)}</div>` : ""}
1494
4416
  <div class="cover-byline">
1495
4417
  <div class="byline-block">
1496
4418
  <div class="label">Filed</div>
1497
4419
  <div class="value">${escape(filedDate)}</div>
1498
4420
  </div>
1499
- <div class="byline-block">
4421
+ <div class="byline-block byline-authors">
1500
4422
  <div class="label">Authors</div>
1501
- <div class="value">
1502
- ${members.length} director${members.length === 1 ? "" : "s"}
1503
- ${members.length ? `<div class="signed-avatars">${signed}</div>` : ""}
4423
+ <div class="value author-list">
4424
+ ${members.length ? signedNames : ""}
1504
4425
  </div>
1505
4426
  </div>
1506
4427
  <div class="byline-block">
@@ -1512,12 +4433,48 @@
1512
4433
  <div class="value" style="font-family: var(--mono); font-size: 12px; letter-spacing: 0.04em;">${escape(docId || "—")}</div>
1513
4434
  </div>
1514
4435
  </div>
1515
- ${versionsHtml}
1516
4436
  </header>
1517
4437
 
4438
+ <section class="toc-print" data-print-toc aria-label="Table of contents"></section>
4439
+
1518
4440
  <article class="body" data-report-body>${renderMarkdown(brief.bodyMd || "_(empty brief)_")}</article>
1519
4441
 
1520
4442
  <div class="foot-rule">// end of brief · boardroom</div>
4443
+
4444
+ <!-- Colophon · the "growth" surface. Always rendered. The card
4445
+ inside (brand mark + tagline + URL) appears in both screen
4446
+ and PDF — that's the part designed to travel with a shared
4447
+ PDF and tell the recipient where to try the product. The
4448
+ share-CTA strip appears only on screen, prompting the
4449
+ current reader to download + share the PDF. -->
4450
+ <aside class="colophon" aria-label="About PrivateBoard">
4451
+ <div class="colophon-share">
4452
+ <button type="button" class="colophon-share-btn" data-download data-download-bottom>
4453
+ <span class="colophon-share-icon" aria-hidden="true">↓</span>
4454
+ <span class="colophon-share-label">Download PDF · share this brief</span>
4455
+ </button>
4456
+ <p class="colophon-share-hint">Send the PDF to anyone — they'll see the full styled report and a footer with where to try PrivateBoard themselves.</p>
4457
+ </div>
4458
+
4459
+ <div class="colophon-card" aria-hidden="true">
4460
+ <span class="colophon-rule" aria-hidden="true"></span>
4461
+ <span class="colophon-eyebrow">a brief generated with</span>
4462
+ <span class="colophon-brand">
4463
+ <svg class="colophon-mark" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges" role="img" aria-label="PrivateBoard">
4464
+ <rect class="colophon-mark-bg" width="16" height="16"/>
4465
+ <rect class="colophon-mark-fg" x="3" y="2" width="2" height="2"/>
4466
+ <rect class="colophon-mark-fg" x="7" y="2" width="2" height="2"/>
4467
+ <rect class="colophon-mark-fg" x="11" y="2" width="2" height="2"/>
4468
+ <rect class="colophon-mark-fg" x="2" y="4" width="12" height="2"/>
4469
+ <rect class="colophon-mark-fg" x="1" y="7" width="14" height="2"/>
4470
+ <rect class="colophon-mark-fg" x="2" y="10" width="1" height="4"/>
4471
+ <rect class="colophon-mark-fg" x="13" y="10" width="1" height="4"/>
4472
+ </svg>
4473
+ <span class="colophon-name">PrivateBoard</span>
4474
+ </span>
4475
+ <a class="colophon-cta-url" href="https://privateboard.ai">privateboard.ai →</a>
4476
+ </div>
4477
+ </aside>
1521
4478
  `;
1522
4479
 
1523
4480
  // ── Post-render pass · tag specific sections + inline badges so
@@ -1546,6 +4503,14 @@
1546
4503
  // all four quadrants share one fill for a clean Gartner / BCG
1547
4504
  // matrix look) and the spine's accent for plotted points.
1548
4505
  const spineKey = (brief.spine && SPINES.has(brief.spine)) ? brief.spine : "boardroom-dark";
4506
+ // Per-spine palette · `vars` carries every colour the
4507
+ // mermaid theme block touches. Extended with `accent` /
4508
+ // `accentSoft` (single accent for monochrome mindmap +
4509
+ // gantt bars + sequence notes), `accentText` (legible text
4510
+ // on accent fills), and `linkText` (faint text on link
4511
+ // backgrounds) so flowchart / mindmap / gantt / sequence /
4512
+ // state diagrams pick up spine-coherent colours instead of
4513
+ // mermaid's rainbow defaults.
1549
4514
  const themes = {
1550
4515
  "boardroom-dark": {
1551
4516
  base: "dark",
@@ -1559,6 +4524,9 @@
1559
4524
  axisText: "#8E8B83",
1560
4525
  border: "#3A3A35",
1561
4526
  inner: "#2A2A26",
4527
+ accent: "#C9A46B",
4528
+ accentSoft: "#3A3025",
4529
+ accentText: "#131312",
1562
4530
  },
1563
4531
  fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
1564
4532
  },
@@ -1566,14 +4534,17 @@
1566
4534
  base: "default",
1567
4535
  vars: {
1568
4536
  background: "#F7F3E8",
1569
- quadrantFill: "#F7F3E8",
4537
+ quadrantFill: "#F2EAD8",
1570
4538
  quadrantText: "#57503F",
1571
- pointFill: "#14110C",
4539
+ pointFill: "#876C2A",
1572
4540
  pointText: "#14110C",
1573
4541
  titleFill: "#14110C",
1574
4542
  axisText: "#847B65",
1575
4543
  border: "#BCB39A",
1576
4544
  inner: "#DCD3BD",
4545
+ accent: "#876C2A",
4546
+ accentSoft: "#E8D9B5",
4547
+ accentText: "#FFFFFF",
1577
4548
  },
1578
4549
  fontFamily: '"Inter", "Helvetica Neue", -apple-system, system-ui, sans-serif',
1579
4550
  },
@@ -1583,12 +4554,15 @@
1583
4554
  background: "#FAF7F0",
1584
4555
  quadrantFill: "#F4F0E8",
1585
4556
  quadrantText: "#6B6359",
1586
- pointFill: "#1A1814",
4557
+ pointFill: "#A85A41",
1587
4558
  pointText: "#4A4338",
1588
4559
  titleFill: "#1A1814",
1589
4560
  axisText: "#978C7E",
1590
4561
  border: "#DDD5C8",
1591
4562
  inner: "#E8E1D2",
4563
+ accent: "#A85A41",
4564
+ accentSoft: "#F2E0D5",
4565
+ accentText: "#FFFFFF",
1592
4566
  },
1593
4567
  fontFamily: '"Charter", "Source Serif Pro", "Iowan Old Style", Georgia, serif',
1594
4568
  },
@@ -1604,6 +4578,9 @@
1604
4578
  axisText: "#6B7785",
1605
4579
  border: "#BFC8D3",
1606
4580
  inner: "#DDE2E8",
4581
+ accent: "#0A4DA1",
4582
+ accentSoft: "#E8EFF8",
4583
+ accentText: "#FFFFFF",
1607
4584
  },
1608
4585
  fontFamily: '"Inter", "Helvetica Neue", Arial, sans-serif',
1609
4586
  },
@@ -1619,6 +4596,9 @@
1619
4596
  axisText: "#758296",
1620
4597
  border: "#B8C2D0",
1621
4598
  inner: "#D5DCE4",
4599
+ accent: "#2251FF",
4600
+ accentSoft: "#E6ECFB",
4601
+ accentText: "#FFFFFF",
1622
4602
  },
1623
4603
  fontFamily: '"Inter", "Helvetica Neue", Arial, sans-serif',
1624
4604
  },
@@ -1634,6 +4614,9 @@
1634
4614
  axisText: "#6E6E80",
1635
4615
  border: "#E5E5E5",
1636
4616
  inner: "#EFEFEF",
4617
+ accent: "#10A37F",
4618
+ accentSoft: "#E6F6F0",
4619
+ accentText: "#FFFFFF",
1637
4620
  },
1638
4621
  fontFamily: '"Söhne", "Inter", -apple-system, system-ui, sans-serif',
1639
4622
  },
@@ -1648,24 +4631,246 @@
1648
4631
  // showing it twice is mermaid's single ugliest default. Apply
1649
4632
  // across quadrant / xychart / pie / timeline.
1650
4633
  const themeCSS = `
4634
+ /* ── Quadrant chart ── */
1651
4635
  g.quadrant-chart text { font-family: ${t.fontFamily}; }
1652
4636
  g.quadrant-point > circle, .quadrant-point circle {
1653
4637
  stroke: ${t.vars.background};
1654
- stroke-width: 1.5px;
4638
+ stroke-width: 2px;
1655
4639
  }
1656
4640
  text.quadrant-title,
1657
4641
  .pieTitleText,
1658
4642
  .xy-chart .title-text,
1659
4643
  .timeline .title-text,
1660
4644
  .timeline-title { display: none !important; }
1661
- /* xychart bars: thin stroke against the background so adjacent
1662
- bars don't merge visually on dense datasets. */
4645
+
4646
+ /* ── xychart-beta · bars ── */
1663
4647
  .xy-chart .bar { stroke: ${t.vars.background}; stroke-width: 1px; }
1664
- /* Pie slice text: legibility on the dark / light spines. */
1665
- .pieCircle { stroke: ${t.vars.background}; stroke-width: 1.5px; }
4648
+
4649
+ /* ── Pie ── */
4650
+ .pieCircle { stroke: ${t.vars.background}; stroke-width: 2px; }
1666
4651
  text.slice, .pieLegendText { font-family: ${t.fontFamily}; }
1667
- /* Timeline · period/label fonts inherit the spine. */
4652
+
4653
+ /* ── Timeline ── */
1668
4654
  .timeline text { font-family: ${t.fontFamily}; }
4655
+
4656
+ /* ── Flowchart · compact nodes + 1px hairline edges ──
4657
+ Default flowchart text is 16px, padding generous, edges
4658
+ heavy. Push to 12.5px / 1px / tighter so the diagram
4659
+ reads as a precise schematic, not a presentation slide.
4660
+ IMPORTANT: do NOT force a line-height larger than 1.2
4661
+ on .nodeLabel — mermaid's foreignObject is sized at
4662
+ measurement time using the inherited font-size at that
4663
+ moment. If we then forcibly stretch line-height, the
4664
+ actually-rendered text exceeds the foreignObject height
4665
+ and the bottom of multi-line node labels gets clipped.
4666
+ Also zero out the wrapper div / paragraph margins so
4667
+ the inline-block measurement doesn't include browser
4668
+ default <p> margins that mermaid won't account for. */
4669
+ .flowchart foreignObject > div,
4670
+ .label > foreignObject > div {
4671
+ line-height: 1.2 !important;
4672
+ margin: 0 !important;
4673
+ padding: 0 !important;
4674
+ }
4675
+ .flowchart .nodeLabel,
4676
+ .flowchart-label foreignObject div,
4677
+ .label foreignObject div,
4678
+ .label foreignObject p,
4679
+ .label foreignObject span,
4680
+ .nodeLabel,
4681
+ .nodeLabel p,
4682
+ .nodeLabel span {
4683
+ font-family: ${t.fontFamily} !important;
4684
+ font-size: 12.5px !important;
4685
+ line-height: 1.2 !important;
4686
+ margin: 0 !important;
4687
+ padding: 0 !important;
4688
+ color: ${t.vars.titleFill} !important;
4689
+ }
4690
+ .flowchart .edgeLabel,
4691
+ .flowchart .edgeLabel p,
4692
+ .edgeLabel foreignObject div,
4693
+ .edgeLabel p,
4694
+ .edgeLabel,
4695
+ .edgeLabel span {
4696
+ font-family: ${t.fontFamily} !important;
4697
+ font-size: 11px !important;
4698
+ line-height: 1.2 !important;
4699
+ margin: 0 !important;
4700
+ padding: 0 !important;
4701
+ color: ${t.vars.axisText} !important;
4702
+ background: ${t.vars.background} !important;
4703
+ }
4704
+ .flowchart .node rect,
4705
+ .flowchart .node circle,
4706
+ .flowchart .node ellipse,
4707
+ .flowchart .node polygon,
4708
+ .node rect,
4709
+ .node circle,
4710
+ .node polygon,
4711
+ .node ellipse {
4712
+ stroke-width: 1px !important;
4713
+ stroke: ${t.vars.border} !important;
4714
+ fill: ${t.vars.quadrantFill} !important;
4715
+ }
4716
+ .flowchart .edgePath path,
4717
+ .flowchart-link,
4718
+ .edgePath .path,
4719
+ .flowchart-link path {
4720
+ stroke: ${t.vars.border} !important;
4721
+ stroke-width: 1px !important;
4722
+ fill: none !important;
4723
+ }
4724
+ .flowchart .marker,
4725
+ .flowchart .arrowheadPath,
4726
+ #arrowhead, marker#arrowhead path {
4727
+ fill: ${t.vars.border} !important;
4728
+ stroke: ${t.vars.border} !important;
4729
+ }
4730
+ .cluster rect {
4731
+ stroke-width: 1px !important;
4732
+ stroke: ${t.vars.border} !important;
4733
+ fill: ${t.vars.background} !important;
4734
+ }
4735
+ .cluster text { font-size: 11px !important; font-family: ${t.fontFamily} !important; fill: ${t.vars.axisText} !important; }
4736
+
4737
+ /* ── Mindmap · monochrome accent (override rainbow) ── */
4738
+ .mindmap g.node text,
4739
+ .mindmap g.section text,
4740
+ .mindmap g.section foreignObject div,
4741
+ .mindmap-node text,
4742
+ .mindmap-node .nodeLabel {
4743
+ font-family: ${t.fontFamily} !important;
4744
+ font-size: 12px !important;
4745
+ fill: ${t.vars.titleFill} !important;
4746
+ color: ${t.vars.titleFill} !important;
4747
+ }
4748
+ .mindmap-node circle,
4749
+ .mindmap-node rect,
4750
+ .mindmap-node polygon {
4751
+ stroke-width: 1px !important;
4752
+ stroke: ${t.vars.accent} !important;
4753
+ }
4754
+ .mindmap-edge,
4755
+ .mindmap-edges path {
4756
+ stroke: ${t.vars.border} !important;
4757
+ stroke-width: 1px !important;
4758
+ fill: none !important;
4759
+ }
4760
+
4761
+ /* ── Sequence diagram · compact actor boxes + thin arrows ── */
4762
+ text.actor,
4763
+ text.actor > tspan,
4764
+ .actor-box-text {
4765
+ font-family: ${t.fontFamily} !important;
4766
+ font-size: 12px !important;
4767
+ fill: ${t.vars.titleFill} !important;
4768
+ }
4769
+ .actor {
4770
+ stroke-width: 1px !important;
4771
+ stroke: ${t.vars.border} !important;
4772
+ fill: ${t.vars.quadrantFill} !important;
4773
+ }
4774
+ .actor-line,
4775
+ line.actor-line {
4776
+ stroke: ${t.vars.border} !important;
4777
+ stroke-width: 1px !important;
4778
+ }
4779
+ .messageText {
4780
+ font-family: ${t.fontFamily} !important;
4781
+ font-size: 11px !important;
4782
+ fill: ${t.vars.titleFill} !important;
4783
+ }
4784
+ .messageLine0, .messageLine1 {
4785
+ stroke-width: 1px !important;
4786
+ stroke: ${t.vars.titleFill} !important;
4787
+ }
4788
+ .note,
4789
+ rect.note {
4790
+ stroke: ${t.vars.border} !important;
4791
+ fill: ${t.vars.accentSoft} !important;
4792
+ stroke-width: 1px !important;
4793
+ }
4794
+ .noteText,
4795
+ text.noteText,
4796
+ .note text {
4797
+ font-family: ${t.fontFamily} !important;
4798
+ font-size: 11px !important;
4799
+ fill: ${t.vars.titleFill} !important;
4800
+ }
4801
+ .activation0, .activation1, .activation2 {
4802
+ stroke: ${t.vars.border} !important;
4803
+ fill: ${t.vars.inner} !important;
4804
+ }
4805
+
4806
+ /* ── State diagram (v2) ── */
4807
+ .stateGroup rect,
4808
+ .stateGroup circle,
4809
+ .stateGroup line {
4810
+ stroke-width: 1px !important;
4811
+ }
4812
+ .stateLabel,
4813
+ .state-description,
4814
+ .stateGroup text {
4815
+ font-family: ${t.fontFamily} !important;
4816
+ font-size: 11.5px !important;
4817
+ fill: ${t.vars.titleFill} !important;
4818
+ }
4819
+ .transition {
4820
+ stroke: ${t.vars.border} !important;
4821
+ stroke-width: 1px !important;
4822
+ fill: none !important;
4823
+ }
4824
+ .compositeBackground,
4825
+ .composit {
4826
+ fill: ${t.vars.background} !important;
4827
+ stroke: ${t.vars.border} !important;
4828
+ }
4829
+ .stateGroup .innerCircle {
4830
+ fill: ${t.vars.titleFill} !important;
4831
+ }
4832
+
4833
+ /* ── Gantt · tight bars + small grid ── */
4834
+ .taskText,
4835
+ .taskTextOutsideRight,
4836
+ .taskTextOutsideLeft,
4837
+ text.taskText {
4838
+ font-family: ${t.fontFamily} !important;
4839
+ font-size: 11px !important;
4840
+ }
4841
+ .tick text,
4842
+ g.tick text {
4843
+ font-family: ${t.fontFamily} !important;
4844
+ font-size: 10px !important;
4845
+ fill: ${t.vars.axisText} !important;
4846
+ }
4847
+ .grid .tick line,
4848
+ g.grid line {
4849
+ stroke: ${t.vars.inner} !important;
4850
+ stroke-width: 0.5px !important;
4851
+ }
4852
+ .grid path { stroke-width: 0 !important; }
4853
+ .titleText { display: none !important; }
4854
+ .section0 text, .section1 text, .section2 text, .section3 text,
4855
+ .sectionTitle0, .sectionTitle1, .sectionTitle2, .sectionTitle3 {
4856
+ font-family: ${t.fontFamily} !important;
4857
+ font-size: 11px !important;
4858
+ fill: ${t.vars.axisText} !important;
4859
+ }
4860
+ rect.task,
4861
+ rect.task0, rect.task1, rect.task2, rect.task3,
4862
+ rect.activeTask0, rect.activeTask1, rect.activeTask2, rect.activeTask3 {
4863
+ stroke-width: 0 !important;
4864
+ }
4865
+
4866
+ /* ── Journey · compact ── */
4867
+ .journey-section text,
4868
+ .journey-section foreignObject div {
4869
+ font-family: ${t.fontFamily} !important;
4870
+ font-size: 11px !important;
4871
+ fill: ${t.vars.titleFill} !important;
4872
+ }
4873
+ .face-rect { stroke-width: 1px !important; }
1669
4874
  `;
1670
4875
  mermaid.initialize({
1671
4876
  startOnLoad: false,
@@ -1697,6 +4902,14 @@
1697
4902
  yAxisPosition: "left",
1698
4903
  },
1699
4904
  themeVariables: {
4905
+ // Global default · drives mermaid's INTERNAL label
4906
+ // measurement before SVG/foreignObject sizing. Setting
4907
+ // it here keeps the box dimensions in sync with the
4908
+ // CSS-rendered font-size, so multi-line flowchart node
4909
+ // labels don't get vertically clipped (which they did
4910
+ // when the CSS forced 12px after mermaid measured at
4911
+ // its 16px default).
4912
+ fontSize: "12.5px",
1700
4913
  background: t.vars.background,
1701
4914
  primaryColor: t.vars.quadrantFill,
1702
4915
  primaryTextColor: t.vars.titleFill,
@@ -1720,8 +4933,123 @@
1720
4933
  quadrantYAxisTextFill: t.vars.axisText,
1721
4934
  quadrantInternalBorderStrokeFill: t.vars.inner,
1722
4935
  quadrantExternalBorderStrokeFill: t.vars.border,
4936
+ // ── Flowchart / generic node-graph ──
4937
+ mainBkg: t.vars.quadrantFill,
4938
+ nodeBorder: t.vars.border,
4939
+ clusterBkg: t.vars.background,
4940
+ clusterBorder: t.vars.border,
4941
+ defaultLinkColor: t.vars.border,
4942
+ edgeLabelBackground: t.vars.background,
4943
+ titleColor: t.vars.titleFill,
4944
+ // ── Mindmap · single accent everywhere (override rainbow) ──
4945
+ cScale0: t.vars.accent,
4946
+ cScale1: t.vars.accent,
4947
+ cScale2: t.vars.accent,
4948
+ cScale3: t.vars.accent,
4949
+ cScale4: t.vars.accent,
4950
+ cScale5: t.vars.accent,
4951
+ cScale6: t.vars.accent,
4952
+ cScale7: t.vars.accent,
4953
+ cScale8: t.vars.accent,
4954
+ cScale9: t.vars.accent,
4955
+ cScale10: t.vars.accent,
4956
+ cScale11: t.vars.accent,
4957
+ // ── Sequence diagram ──
4958
+ actorBkg: t.vars.quadrantFill,
4959
+ actorBorder: t.vars.border,
4960
+ actorTextColor: t.vars.titleFill,
4961
+ actorLineColor: t.vars.border,
4962
+ signalColor: t.vars.titleFill,
4963
+ signalTextColor: t.vars.titleFill,
4964
+ labelBoxBkgColor: t.vars.quadrantFill,
4965
+ labelBoxBorderColor: t.vars.border,
4966
+ labelTextColor: t.vars.titleFill,
4967
+ loopTextColor: t.vars.titleFill,
4968
+ noteBkgColor: t.vars.accentSoft,
4969
+ noteTextColor: t.vars.titleFill,
4970
+ noteBorderColor: t.vars.border,
4971
+ activationBorderColor: t.vars.border,
4972
+ activationBkgColor: t.vars.inner,
4973
+ // ── Gantt · single-accent bars + neutral grid ──
4974
+ taskBkgColor: t.vars.accent,
4975
+ taskTextColor: t.vars.accentText,
4976
+ taskTextDarkColor: t.vars.titleFill,
4977
+ taskTextLightColor: t.vars.accentText,
4978
+ taskTextOutsideColor: t.vars.titleFill,
4979
+ activeTaskBkgColor: t.vars.accent,
4980
+ activeTaskBorderColor: t.vars.accent,
4981
+ doneTaskBkgColor: t.vars.inner,
4982
+ doneTaskBorderColor: t.vars.border,
4983
+ critBkgColor: t.vars.accent,
4984
+ critBorderColor: t.vars.accent,
4985
+ gridColor: t.vars.inner,
4986
+ sectionBkgColor: t.vars.background,
4987
+ altSectionBkgColor: t.vars.quadrantFill,
4988
+ sectionBkgColor2: t.vars.background,
4989
+ todayLineColor: t.vars.accent,
4990
+ },
4991
+ flowchart: {
4992
+ useMaxWidth: true,
4993
+ htmlLabels: true,
4994
+ // Tighter spacing · default 50/50/15 reads as a slide,
4995
+ // these values render as a precise schematic. `padding`
4996
+ // is the cluster (subgraph) padding, NOT node padding —
4997
+ // mermaid sizes node boxes from text dimensions + an
4998
+ // internal margin, so this value alone won't squeeze
4999
+ // node labels. Keep at 12 for readable subgraph titles.
5000
+ nodeSpacing: 36,
5001
+ rankSpacing: 42,
5002
+ padding: 12,
5003
+ curve: "basis",
5004
+ },
5005
+ sequence: {
5006
+ useMaxWidth: true,
5007
+ diagramMarginX: 16,
5008
+ diagramMarginY: 8,
5009
+ actorMargin: 32,
5010
+ boxMargin: 6,
5011
+ boxTextMargin: 4,
5012
+ noteMargin: 8,
5013
+ messageMargin: 22,
5014
+ mirrorActors: false,
5015
+ bottomMarginAdj: 0,
5016
+ actorFontSize: 12,
5017
+ messageFontSize: 11,
5018
+ noteFontSize: 11,
5019
+ },
5020
+ gantt: {
5021
+ useMaxWidth: true,
5022
+ fontSize: 11,
5023
+ sectionFontSize: 11,
5024
+ numberSectionStyles: 3,
5025
+ leftPadding: 80,
5026
+ topPadding: 24,
5027
+ rightPadding: 24,
5028
+ barGap: 4,
5029
+ barHeight: 18,
5030
+ gridLineStartPadding: 36,
5031
+ },
5032
+ mindmap: {
5033
+ useMaxWidth: true,
5034
+ padding: 6,
5035
+ },
5036
+ stateDiagram: {
5037
+ useMaxWidth: true,
5038
+ fontSize: 12,
5039
+ titleTopMargin: 0,
5040
+ padding: 6,
5041
+ miniPadding: 4,
5042
+ },
5043
+ state: {
5044
+ useMaxWidth: true,
5045
+ fontSize: 12,
5046
+ },
5047
+ journey: {
5048
+ useMaxWidth: true,
5049
+ fontSize: 11,
5050
+ taskFontSize: 11,
5051
+ sectionFontSize: 12,
1723
5052
  },
1724
- flowchart: { useMaxWidth: true, htmlLabels: true },
1725
5053
  // ── xychart-beta · bar charts ──────────────────────────
1726
5054
  // Tight margins, label hidden (caption above), and a single
1727
5055
  // colour palette derived from the spine's pointFill so
@@ -1763,7 +5091,58 @@
1763
5091
  }
1764
5092
  }
1765
5093
 
1766
- load();
5094
+ /** Subscribe to room SSE so the title live-updates when the brief
5095
+ * finalises. Fixes the bug where opening report.html during
5096
+ * generation shows the placeholder title (room.subject / initial
5097
+ * question) until the user manually refreshes. The dashboard
5098
+ * already handles `brief-final` to patch its in-memory state;
5099
+ * this gives the standalone report page parity. */
5100
+ function subscribeBriefUpdates() {
5101
+ if (!roomId || !briefId) return;
5102
+ if (typeof EventSource === "undefined") return;
5103
+ let sse;
5104
+ try {
5105
+ sse = new EventSource("/api/rooms/" + encodeURIComponent(roomId) + "/stream");
5106
+ } catch (_) {
5107
+ return;
5108
+ }
5109
+ let timeout = setTimeout(() => {
5110
+ try { sse.close(); } catch (_) {}
5111
+ }, 10 * 60 * 1000); // 10-min safety timeout · briefs taking longer than that have other problems
5112
+ sse.onmessage = (e) => {
5113
+ if (!e.data) return;
5114
+ let evt;
5115
+ try { evt = JSON.parse(e.data); } catch (_) { return; }
5116
+ if (!evt || evt.type !== "config-event") return;
5117
+ if (evt.kind === "brief-final" && evt.payload && evt.payload.briefId === briefId) {
5118
+ const newTitle = typeof evt.payload.title === "string" ? evt.payload.title.trim() : "";
5119
+ if (newTitle) {
5120
+ document.title = "BOARDROOM // " + newTitle;
5121
+ const h1 = document.querySelector(".cover-title");
5122
+ if (h1) {
5123
+ // Render the title with markdown inline-formatting honoured
5124
+ // (em / strong / code) so the on-screen update matches what
5125
+ // the initial render produced. Falls back to plain text on
5126
+ // any parse glitch.
5127
+ try { h1.innerHTML = inline(escape(newTitle)); }
5128
+ catch (_) { h1.textContent = newTitle; }
5129
+ }
5130
+ }
5131
+ clearTimeout(timeout);
5132
+ try { sse.close(); } catch (_) {}
5133
+ }
5134
+ };
5135
+ sse.onerror = () => {
5136
+ // EventSource auto-reconnects on transient failures · no action
5137
+ // needed. If the server actually closed, it'll surface as a
5138
+ // permanent error after a few retries — at which point the
5139
+ // safety timeout will tear down the subscription anyway.
5140
+ };
5141
+ }
5142
+
5143
+ load().then(() => {
5144
+ subscribeBriefUpdates();
5145
+ });
1767
5146
  })();
1768
5147
  </script>
1769
5148
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js" defer></script>