privateboard 0.1.2 → 0.1.3
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.
- package/dist/cli.js +3355 -940
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/agent-profile.js +17 -7
- package/public/app.js +3775 -434
- package/public/index.html +2357 -265
- package/public/onboarding.js +36 -9
- package/public/quote-cta.css +49 -5
- package/public/quote-cta.js +215 -17
- package/public/report/spines/a16z-thesis.css +212 -97
- package/public/report/spines/anthropic-essay.css +564 -221
- package/public/report/spines/boardroom-dark.css +130 -72
- package/public/report/spines/gartner-note.css +83 -48
- package/public/report/spines/mckinsey-deck.css +81 -31
- package/public/report/spines/openai-paper.css +96 -35
- package/public/report.html +3576 -197
- package/public/room-settings.js +11 -8
- package/public/themes.css +15 -2
- package/public/user-settings.css +19 -8
- package/public/user-settings.js +37 -162
package/public/report.html
CHANGED
|
@@ -61,25 +61,57 @@
|
|
|
61
61
|
box-shadow: none !important;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
renders in the spine's
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
217
|
+
serif !important;
|
|
183
218
|
}
|
|
184
|
-
/* Mono kickers / labels stay mono with
|
|
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
|
-
"
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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-
|
|
269
|
-
break.
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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`
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
border:
|
|
419
|
-
min-height:
|
|
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-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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:
|
|
437
|
-
|
|
438
|
-
font-size:
|
|
439
|
-
font-weight:
|
|
440
|
-
font-variant-numeric:
|
|
441
|
-
color: var(--text, #C8C5BE);
|
|
442
|
-
letter-spacing: -0.
|
|
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:
|
|
446
|
-
font-size:
|
|
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:
|
|
456
|
-
line-height: 1.
|
|
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:
|
|
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(--
|
|
466
|
-
margin-top: 2px;
|
|
2531
|
+
color: var(--ink-faint, var(--text-faint));
|
|
467
2532
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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.
|
|
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 .
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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">“</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.
|
|
752
|
-
//
|
|
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
|
-
|
|
760
|
-
|
|
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
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
781
|
-
|
|
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
|
|
852
|
-
{ re: /^the\s*thesis|^thesis
|
|
853
|
-
{ re: /^a\s+working\s+hypothesis|^working\s*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,
|
|
856
|
-
{ re: /^headline\s*findings?/i,
|
|
857
|
-
{ re: /^three\s*big\s*ideas|^big\s*ideas
|
|
858
|
-
{ re: /^(where\s*we\s*)?converge[d]
|
|
859
|
-
{ re: /^(where\s*we\s*)?diverge[d]?|crux/i,
|
|
860
|
-
{ re: /^positions
|
|
861
|
-
{ re: /^options?\s*analysis/i,
|
|
862
|
-
{ re: /^two\s*paths|^two\s*futures|^two\s*scenarios
|
|
863
|
-
{ re: /^why\s*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
|
|
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
|
|
877
|
-
{ re: /^the\s*bet$|^the\s*bet[\s·:]/i,
|
|
878
|
-
{ re: /^considerations
|
|
879
|
-
{ re: /^pre[-\s]?mortem|risks
|
|
880
|
-
{ re: /^new\s*questions
|
|
881
|
-
{ re: /^strategic\s*planning\s*assumption|^planning\s*assumption/i, cls: "section-planning-assumption" },
|
|
882
|
-
{ re: /^open\s*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",
|
|
1034
|
-
{ key: "owner",
|
|
1035
|
-
{ key: "horizon",
|
|
1036
|
-
{ key: "metric",
|
|
1037
|
-
|
|
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
|
-
|
|
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 (
|
|
1266
|
-
for (let i = 0; i <
|
|
1267
|
-
const em =
|
|
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 =
|
|
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
|
|
4306
|
+
let room, brief, members;
|
|
1406
4307
|
try {
|
|
1407
|
-
// Load room state +
|
|
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
|
|
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
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
-
${
|
|
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
|
|
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: "#
|
|
4537
|
+
quadrantFill: "#F2EAD8",
|
|
1570
4538
|
quadrantText: "#57503F",
|
|
1571
|
-
pointFill: "#
|
|
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: "#
|
|
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:
|
|
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
|
-
|
|
1662
|
-
|
|
4645
|
+
|
|
4646
|
+
/* ── xychart-beta · bars ── */
|
|
1663
4647
|
.xy-chart .bar { stroke: ${t.vars.background}; stroke-width: 1px; }
|
|
1664
|
-
|
|
1665
|
-
|
|
4648
|
+
|
|
4649
|
+
/* ── Pie ── */
|
|
4650
|
+
.pieCircle { stroke: ${t.vars.background}; stroke-width: 2px; }
|
|
1666
4651
|
text.slice, .pieLegendText { font-family: ${t.fontFamily}; }
|
|
1667
|
-
|
|
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
|
-
|
|
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>
|