privateboard 0.1.0
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/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/cli.js +10502 -0
- package/dist/cli.js.map +1 -0
- package/package.json +63 -0
- package/public/adjourn-overlay.css +253 -0
- package/public/agent-overlay.css +444 -0
- package/public/agent-overlay.js +604 -0
- package/public/agent-profile.css +3230 -0
- package/public/agent-profile.js +3329 -0
- package/public/app.js +6629 -0
- package/public/auto-hide-scroll.js +90 -0
- package/public/avatar-skill.js +793 -0
- package/public/avatars/chair.svg +98 -0
- package/public/avatars/first-principles.svg +122 -0
- package/public/avatars/long-horizon.svg +147 -0
- package/public/avatars/open_ai.png +0 -0
- package/public/avatars/phenomenologist.svg +130 -0
- package/public/avatars/socrates.svg +187 -0
- package/public/avatars/user-empathy.svg +117 -0
- package/public/avatars/value-investor.svg +117 -0
- package/public/favicon.svg +10 -0
- package/public/fonts/agent-Italic.woff2 +0 -0
- package/public/fonts/human-sans.woff2 +0 -0
- package/public/icons.css +103 -0
- package/public/models-cache.js +57 -0
- package/public/new-agent.css +1359 -0
- package/public/new-agent.js +675 -0
- package/public/onboarding.css +628 -0
- package/public/onboarding.js +782 -0
- package/public/prototype-dashboard.html +7596 -0
- package/public/report/spines/a16z-thesis.css +1055 -0
- package/public/report/spines/anthropic-essay.css +556 -0
- package/public/report/spines/boardroom-dark.css +1082 -0
- package/public/report/spines/gartner-note.css +538 -0
- package/public/report/spines/mckinsey-deck.css +523 -0
- package/public/report/spines/openai-paper.css +516 -0
- package/public/report.html +1417 -0
- package/public/room-settings.css +895 -0
- package/public/room-settings.js +1039 -0
- package/public/themes.css +338 -0
- package/public/user-settings.css +1236 -0
- package/public/user-settings.js +1291 -0
|
@@ -0,0 +1,1417 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>BOARDROOM // BRIEF</title>
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
11
|
+
<link rel="stylesheet" href="themes.css">
|
|
12
|
+
<!-- Spine CSS · loaded dynamically below based on brief.spine. The
|
|
13
|
+
default href below is rendered immediately so the report doesn't
|
|
14
|
+
show unstyled until the brief fetch returns. -->
|
|
15
|
+
<link rel="stylesheet" href="report/spines/boardroom-dark.css" id="spine-css">
|
|
16
|
+
<style>
|
|
17
|
+
/* ─── Top-rule "Download PDF" button ──────────────────────────
|
|
18
|
+
Styled to match the existing back link (mono uppercase low-key
|
|
19
|
+
link, not a punchy CTA). The download glyph prefix is a typographic
|
|
20
|
+
down-arrow, not an emoji icon. */
|
|
21
|
+
.top-rule .top-actions {
|
|
22
|
+
display: inline-flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
gap: 18px;
|
|
25
|
+
flex: 0 0 auto;
|
|
26
|
+
}
|
|
27
|
+
.top-rule .download {
|
|
28
|
+
appearance: none;
|
|
29
|
+
background: none;
|
|
30
|
+
border: none;
|
|
31
|
+
padding: 0;
|
|
32
|
+
margin: 0;
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
font: inherit;
|
|
35
|
+
/* Match the back link's mono treatment (per-spine CSS sets the
|
|
36
|
+
precise font-size + color; we only inherit). */
|
|
37
|
+
font-family: var(--mono);
|
|
38
|
+
font-size: 11px;
|
|
39
|
+
letter-spacing: 0.04em;
|
|
40
|
+
color: var(--text-soft);
|
|
41
|
+
text-transform: none;
|
|
42
|
+
}
|
|
43
|
+
.top-rule .download::before { content: "↓ "; color: var(--text-faint); }
|
|
44
|
+
.top-rule .download:hover { color: var(--text); }
|
|
45
|
+
|
|
46
|
+
/* ─── Print / Save-as-PDF discipline ───────────────────────────
|
|
47
|
+
Two responsibilities here:
|
|
48
|
+
(1) Strip the on-screen chrome (top-rule, foot-rule, version
|
|
49
|
+
strip, download button) so the PDF reads as a filed
|
|
50
|
+
deliverable, not a webpage screenshot.
|
|
51
|
+
(2) For the dark spine specifically, invert to a light-paper
|
|
52
|
+
palette by redefining the CSS variables at body level. This
|
|
53
|
+
removes the dependency on Chrome's "Background graphics"
|
|
54
|
+
checkbox and produces a PDF that's universally readable on
|
|
55
|
+
screen and on physical paper. Other spines are already light
|
|
56
|
+
and pass through unchanged. */
|
|
57
|
+
@media print {
|
|
58
|
+
*, *::before, *::after {
|
|
59
|
+
print-color-adjust: exact !important;
|
|
60
|
+
-webkit-print-color-adjust: exact !important;
|
|
61
|
+
box-shadow: none !important;
|
|
62
|
+
}
|
|
63
|
+
@page { size: A4; margin: 14mm 12mm; }
|
|
64
|
+
|
|
65
|
+
/* Strip browser chrome from the PDF. */
|
|
66
|
+
.top-rule,
|
|
67
|
+
.foot-rule,
|
|
68
|
+
.cover-versions,
|
|
69
|
+
[data-back],
|
|
70
|
+
[data-download],
|
|
71
|
+
.top-actions {
|
|
72
|
+
display: none !important;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* Page-frame backgrounds — the html element is what fills the
|
|
76
|
+
paper outside the body's box. Force white universally. */
|
|
77
|
+
html { background: #FFFFFF !important; }
|
|
78
|
+
|
|
79
|
+
/* Container resets so .doc fills the printable area instead of
|
|
80
|
+
being clipped at its 880px on-screen max-width. */
|
|
81
|
+
.doc { max-width: none !important; padding: 0 !important; margin: 0 !important; }
|
|
82
|
+
main.doc > [data-report-content] { padding: 0 !important; }
|
|
83
|
+
|
|
84
|
+
/* Cover gets its own page so the body opens on a fresh sheet. */
|
|
85
|
+
.cover {
|
|
86
|
+
padding: 8mm 0 18mm !important;
|
|
87
|
+
break-after: page;
|
|
88
|
+
page-break-after: always;
|
|
89
|
+
border-bottom: none !important;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ─── Dark spine · invert to light paper for printing ───────
|
|
93
|
+
Token-level swap on body. Every callsite that uses var(--bg)
|
|
94
|
+
/ var(--text) / var(--text-soft) / etc. picks up these
|
|
95
|
+
printed-paper values for the duration of the print render. */
|
|
96
|
+
body[data-spine="boardroom-dark"] {
|
|
97
|
+
background: #FAF7F0 !important;
|
|
98
|
+
color: #1A1814 !important;
|
|
99
|
+
--bg: #FAF7F0;
|
|
100
|
+
--panel: #F4F0E8;
|
|
101
|
+
--panel-2: #EDE6D6;
|
|
102
|
+
--panel-3: #E2D9C4;
|
|
103
|
+
--hi: #DDD5C8;
|
|
104
|
+
--line: #DDD5C8;
|
|
105
|
+
--line-bright: #DDD5C8;
|
|
106
|
+
--line-strong: #C0B8A8;
|
|
107
|
+
--text: #1A1814;
|
|
108
|
+
--text-soft: #4A4338;
|
|
109
|
+
--text-dim: #6B6359;
|
|
110
|
+
--text-faint: #978C7E;
|
|
111
|
+
--accent: #6B6660;
|
|
112
|
+
--accent-faint: #B5AB9B;
|
|
113
|
+
/* Gold (--lime / --em) reads well on cream — keep slightly
|
|
114
|
+
deeper values for paper contrast. */
|
|
115
|
+
--lime: #B58950;
|
|
116
|
+
--lime-dim: #5C4422;
|
|
117
|
+
--em: #B58950;
|
|
118
|
+
--em-deep: #8C6730;
|
|
119
|
+
}
|
|
120
|
+
/* The Bottom Line panel had a transparent bg + top rule; on paper
|
|
121
|
+
give it a faint cream wash so it reads as a callout. */
|
|
122
|
+
body[data-spine="boardroom-dark"] .body section.section-bottom-line {
|
|
123
|
+
background: transparent !important;
|
|
124
|
+
border-top-color: #1A1814 !important;
|
|
125
|
+
}
|
|
126
|
+
/* Dark spine's mermaid frame — give it a paper bg in print. */
|
|
127
|
+
body[data-spine="boardroom-dark"] .body pre.mermaid {
|
|
128
|
+
background: #FFFFFF !important;
|
|
129
|
+
border-color: #DDD5C8 !important;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ─── CJK font fallback for print ────────────────────────────
|
|
133
|
+
In headless Chrome's PDF render path, "PingFang SC" and
|
|
134
|
+
"Hiragino Sans GB" are often unreachable — they live outside
|
|
135
|
+
the standard font directories. "Songti SC" (at /System/Library
|
|
136
|
+
/Fonts/Supplemental/Songti.ttc) IS reachable, so it anchors
|
|
137
|
+
every cascade as the dependable CJK fallback. Latin text still
|
|
138
|
+
renders in the spine's preferred face; only CJK falls through. */
|
|
139
|
+
|
|
140
|
+
/* Sans-anchored body text · Latin in Helvetica/Inter, CJK in Songti SC */
|
|
141
|
+
body, .body, .body p, .body li,
|
|
142
|
+
.body td, .body th,
|
|
143
|
+
.rec-rationale, .rec-risk, .rec-risk-text, .rec-meta-value, .rec-action,
|
|
144
|
+
.nq-why, .cover-deck {
|
|
145
|
+
font-family: "Helvetica Neue", "Inter", "Arial",
|
|
146
|
+
"Songti SC", "STSong",
|
|
147
|
+
"Hiragino Sans GB", "PingFang SC",
|
|
148
|
+
"Source Han Sans CN", "Noto Sans CJK SC",
|
|
149
|
+
sans-serif !important;
|
|
150
|
+
}
|
|
151
|
+
/* Serif-anchored display copy · headlines, italic claims, big-idea
|
|
152
|
+
kickers — the typographic moments worth preserving in serif. */
|
|
153
|
+
.body h1, .body h2, .body h3, .body h4,
|
|
154
|
+
.body section.section-bottom-line p,
|
|
155
|
+
.body section.section-thesis p,
|
|
156
|
+
.body section.section-working-hypothesis p,
|
|
157
|
+
.body section.section-headline-findings .pillar h3,
|
|
158
|
+
.body section.section-big-ideas ol > li p:first-child strong,
|
|
159
|
+
.nq-question, .cover-title {
|
|
160
|
+
font-family: "Charter", "Source Serif Pro", "Iowan Old Style",
|
|
161
|
+
"Georgia",
|
|
162
|
+
"Songti SC", "STSong",
|
|
163
|
+
"Source Han Serif CN", "Noto Serif CJK SC",
|
|
164
|
+
"PingFang SC", "Hiragino Sans GB", serif !important;
|
|
165
|
+
}
|
|
166
|
+
/* Mono kickers / labels stay mono with Songti SC as CJK fallback. */
|
|
167
|
+
.top-rule .crumb,
|
|
168
|
+
.body .chapter-num,
|
|
169
|
+
.cover-tag, .cover-tag .secondary,
|
|
170
|
+
.byline-block .label,
|
|
171
|
+
.cover-versions-label, .cover-version,
|
|
172
|
+
.body section.section-bottom-line h2,
|
|
173
|
+
.body section.section-thesis h2,
|
|
174
|
+
.body section.section-headline-findings .pillar .pillar-num,
|
|
175
|
+
.rec-num, .rec-meta-label, .rec-risk-prefix,
|
|
176
|
+
.nq-num, .nq-attribution,
|
|
177
|
+
.body section.section-planning-assumption blockquote strong:first-child,
|
|
178
|
+
.body section.section-methodology strong,
|
|
179
|
+
.body code,
|
|
180
|
+
.body table.md-table th {
|
|
181
|
+
font-family: "SF Mono", "JetBrains Mono", "Menlo",
|
|
182
|
+
"Helvetica Neue", "Arial",
|
|
183
|
+
"Songti SC", "STSong", "Hiragino Sans GB", "PingFang SC",
|
|
184
|
+
monospace !important;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ─── Page-break discipline (all spines) ─────────────────────
|
|
188
|
+
Avoid splitting short cards / panels across pages. */
|
|
189
|
+
.body section.section-bottom-line,
|
|
190
|
+
.body section.section-thesis,
|
|
191
|
+
.body section.section-working-hypothesis,
|
|
192
|
+
.body section.section-frame-shift,
|
|
193
|
+
.body section.section-planning-assumption,
|
|
194
|
+
.body section.section-why-now,
|
|
195
|
+
.body section.section-two-paths,
|
|
196
|
+
.body section.section-headline-findings .pillar,
|
|
197
|
+
.body section.section-recommendations li.rec-item,
|
|
198
|
+
.body section.section-considerations li.rec-item,
|
|
199
|
+
.body section.section-the-bet > ol > li,
|
|
200
|
+
.body section.section-new-questions li.nq-item,
|
|
201
|
+
.body pre.mermaid,
|
|
202
|
+
.body table.md-table {
|
|
203
|
+
break-inside: avoid;
|
|
204
|
+
page-break-inside: avoid;
|
|
205
|
+
}
|
|
206
|
+
.body h2, .body h3, .body h4 {
|
|
207
|
+
break-after: avoid;
|
|
208
|
+
page-break-after: avoid;
|
|
209
|
+
}
|
|
210
|
+
.body section.section-methodology {
|
|
211
|
+
break-inside: avoid;
|
|
212
|
+
page-break-inside: avoid;
|
|
213
|
+
}
|
|
214
|
+
/* Mermaid charts shouldn't reserve big empty boxes when the SVG
|
|
215
|
+
is small. */
|
|
216
|
+
.body pre.mermaid { min-height: auto !important; padding: 14px !important; }
|
|
217
|
+
.body pre.mermaid svg { max-width: 100% !important; height: auto !important; }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ─── Suppress duplicate borders at section boundaries ──────────
|
|
221
|
+
When a numbered-list section (The Bet, Big Ideas) is immediately
|
|
222
|
+
followed by a .chapter-num divider for the next chapter, the
|
|
223
|
+
list's `:last-child` `border-bottom` and the chapter-num's own
|
|
224
|
+
`border-bottom` (the rule below "Section 0X") render as two
|
|
225
|
+
parallel hairlines right at the section boundary. Drop the
|
|
226
|
+
list's closing rule — the chapter-num underline already
|
|
227
|
+
provides the visual break. Spine-agnostic; the inline-style
|
|
228
|
+
block loads after the spine CSS and the `:has()` selector
|
|
229
|
+
gives it strictly higher specificity than the per-spine rule. */
|
|
230
|
+
.body section.section-the-bet:has(+ .chapter-num) ol > li:last-child,
|
|
231
|
+
.body section.section-big-ideas:has(+ .chapter-num) ol > li:last-child {
|
|
232
|
+
border-bottom: none;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ─── Same fix at the Methodology footer boundary ────────────────
|
|
236
|
+
The Methodology h2 carries its own `border-top` to act as a
|
|
237
|
+
section divider, but it's skipped by `injectChapterNumbers`, so
|
|
238
|
+
there's no chapter-num to absorb that role. When the preceding
|
|
239
|
+
section already ends with its own closing rule (a markdown
|
|
240
|
+
table's last-row `border-bottom`, or The Bet / Big Ideas
|
|
241
|
+
`:last-child` `border-bottom`), the boundary renders two
|
|
242
|
+
parallel hairlines. Suppress the Methodology border-top in
|
|
243
|
+
those exact cases. */
|
|
244
|
+
.body section:has(> table.md-table:last-child) + h2.section-methodology,
|
|
245
|
+
.body section.section-the-bet + h2.section-methodology,
|
|
246
|
+
.body section.section-big-ideas + h2.section-methodology {
|
|
247
|
+
border-top: none;
|
|
248
|
+
}
|
|
249
|
+
</style>
|
|
250
|
+
</head>
|
|
251
|
+
<body>
|
|
252
|
+
|
|
253
|
+
<div class="top-rule">
|
|
254
|
+
<span class="crumb">Boardroom <span class="accent">·</span> Insights</span>
|
|
255
|
+
<div class="top-actions">
|
|
256
|
+
<button type="button" class="download" data-download title="Save this brief as a PDF">Download PDF</button>
|
|
257
|
+
<a href="#" class="back" data-back>↩ back to room</a>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<main class="doc">
|
|
262
|
+
<div data-report-content>
|
|
263
|
+
<div class="placeholder">loading the brief…</div>
|
|
264
|
+
</div>
|
|
265
|
+
</main>
|
|
266
|
+
|
|
267
|
+
<script>
|
|
268
|
+
(function () {
|
|
269
|
+
const params = new URLSearchParams(location.search);
|
|
270
|
+
const roomId = params.get("r");
|
|
271
|
+
const briefId = params.get("b");
|
|
272
|
+
const root = document.querySelector("[data-report-content]");
|
|
273
|
+
const back = document.querySelector("[data-back]");
|
|
274
|
+
if (back) back.href = roomId ? `/#/r/${encodeURIComponent(roomId)}` : "/";
|
|
275
|
+
|
|
276
|
+
/** Save as PDF · just trigger the browser's print dialog. The
|
|
277
|
+
* @media print rules below hide chrome and tighten margins so the
|
|
278
|
+
* output reads as a filed deliverable. The user picks "Save as
|
|
279
|
+
* PDF" as the destination — every modern browser supports it. */
|
|
280
|
+
const downloadBtn = document.querySelector("[data-download]");
|
|
281
|
+
if (downloadBtn) downloadBtn.addEventListener("click", () => {
|
|
282
|
+
// Render the brief's title as the suggested filename (most
|
|
283
|
+
// browsers default to document.title when saving a print-PDF).
|
|
284
|
+
window.print();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/** Available spines · keys must match `composer.ts` SPINES. The link
|
|
288
|
+
* href is rewritten when the brief loads — pre-validated so a typo
|
|
289
|
+
* / unknown value falls back to boardroom-dark instead of 404'ing. */
|
|
290
|
+
const SPINES = new Set([
|
|
291
|
+
"boardroom-dark",
|
|
292
|
+
"a16z-thesis",
|
|
293
|
+
"anthropic-essay",
|
|
294
|
+
"gartner-note",
|
|
295
|
+
"mckinsey-deck",
|
|
296
|
+
"openai-paper",
|
|
297
|
+
]);
|
|
298
|
+
function swapSpine(spine) {
|
|
299
|
+
const link = document.getElementById("spine-css");
|
|
300
|
+
if (!link) return;
|
|
301
|
+
const safe = SPINES.has(spine) ? spine : "boardroom-dark";
|
|
302
|
+
const href = `report/spines/${safe}.css`;
|
|
303
|
+
if (link.getAttribute("href") !== href) link.setAttribute("href", href);
|
|
304
|
+
document.body.setAttribute("data-spine", safe);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function escape(s) {
|
|
308
|
+
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
309
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
310
|
+
}[c]));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function inline(s) {
|
|
314
|
+
return s
|
|
315
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
316
|
+
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
|
317
|
+
// Underscore italics — common in Recommendations / New Questions
|
|
318
|
+
// metadata labels (`_Rationale:_`, `_Owner:_` …). Without this, the
|
|
319
|
+
// labels render as literal underscores and the whole card looks raw.
|
|
320
|
+
.replace(/(^|[\s(\[])_([^_\n]+)_(?=$|[\s.,;:!?·\)\]])/g, "$1<em>$2</em>")
|
|
321
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Mermaid 10.9.5 quadrantChart lexer is strict: anything beyond ASCII
|
|
325
|
+
* alphanumerics + spaces breaks UNQUOTED axis/quadrant/item labels
|
|
326
|
+
* (CJK, parens, `+`, etc. all fail with "Unrecognized text"). The
|
|
327
|
+
* fix is to ALWAYS wrap those labels in double quotes; titles are
|
|
328
|
+
* exempt. Mirrors src/utils/mermaid-sanitize.ts — keep in sync. */
|
|
329
|
+
function sanitizeMermaid(src) {
|
|
330
|
+
if (!src) return src;
|
|
331
|
+
if (!/^\s*quadrantChart\b/i.test(src)) return src;
|
|
332
|
+
|
|
333
|
+
const clamp01 = (n) => {
|
|
334
|
+
if (!Number.isFinite(n)) return 0.5;
|
|
335
|
+
return Math.max(0.02, Math.min(0.98, n));
|
|
336
|
+
};
|
|
337
|
+
const cleanLabel = (s) => s
|
|
338
|
+
.replace(/(/g, "(")
|
|
339
|
+
.replace(/)/g, ")")
|
|
340
|
+
.replace(/,/g, " ")
|
|
341
|
+
.replace(/:/g, " ")
|
|
342
|
+
.replace(/、/g, " ")
|
|
343
|
+
.replace(/。/g, " ")
|
|
344
|
+
.replace(/;/g, " ")
|
|
345
|
+
.replace(/["'`\[\]:]+/g, " ")
|
|
346
|
+
.replace(/\s+/g, " ")
|
|
347
|
+
.trim();
|
|
348
|
+
|
|
349
|
+
return src.split("\n").map((line) => {
|
|
350
|
+
const indentMatch = /^(\s*)/.exec(line);
|
|
351
|
+
const indent = indentMatch ? indentMatch[1] || " " : " ";
|
|
352
|
+
const t = line.trim();
|
|
353
|
+
if (!t) return line;
|
|
354
|
+
|
|
355
|
+
// Title · cleaned but unquoted.
|
|
356
|
+
const titleM = /^title\s+(.+)$/i.exec(t);
|
|
357
|
+
if (titleM) return `${indent}title ${cleanLabel(titleM[1])}`;
|
|
358
|
+
|
|
359
|
+
// Axis lines · always quoted both-ends.
|
|
360
|
+
const ax = /^(x-axis|y-axis)\s+(.+)$/i.exec(t);
|
|
361
|
+
if (ax) {
|
|
362
|
+
const which = ax[1].toLowerCase();
|
|
363
|
+
const rest = ax[2].trim();
|
|
364
|
+
if (rest.includes("-->")) {
|
|
365
|
+
const parts = rest.split("-->").map((s) => cleanLabel(s));
|
|
366
|
+
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
367
|
+
return `${indent}${which} "${parts[0]}" --> "${parts[1]}"`;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const cleaned = cleanLabel(rest);
|
|
371
|
+
return `${indent}${which} "Low ${cleaned}" --> "High ${cleaned}"`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Quadrant labels · always quoted.
|
|
375
|
+
const qM = /^(quadrant-[1-4])\s+(.+)$/i.exec(t);
|
|
376
|
+
if (qM) return `${indent}${qM[1]} "${cleanLabel(qM[2])}"`;
|
|
377
|
+
|
|
378
|
+
// Item lines · always quoted, coords clamped.
|
|
379
|
+
const item = /^"?([^"\[\]]+?)"?\s*:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]\s*$/.exec(t);
|
|
380
|
+
if (item) {
|
|
381
|
+
const label = cleanLabel(item[1]);
|
|
382
|
+
const x = clamp01(parseFloat(item[2]));
|
|
383
|
+
const y = clamp01(parseFloat(item[3]));
|
|
384
|
+
return `${indent}"${label}": [${x.toFixed(2)}, ${y.toFixed(2)}]`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return line;
|
|
388
|
+
}).join("\n");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Slightly richer markdown than the in-room renderer — supports h1–h4,
|
|
392
|
+
// bullets, ordered lists, paragraphs, blockquotes, fenced code blocks
|
|
393
|
+
// (incl. ```mermaid for diagrams), and pipe tables.
|
|
394
|
+
function renderMarkdown(md) {
|
|
395
|
+
// Pre-pass · pull out fenced ```code blocks before splitting on blank
|
|
396
|
+
// lines, since a code block can legally contain blank lines.
|
|
397
|
+
const placeholders = [];
|
|
398
|
+
let stripped = "";
|
|
399
|
+
{
|
|
400
|
+
const lines = md.split("\n");
|
|
401
|
+
let i = 0;
|
|
402
|
+
while (i < lines.length) {
|
|
403
|
+
const fence = /^```(\w*)\s*$/.exec(lines[i]);
|
|
404
|
+
if (fence) {
|
|
405
|
+
const lang = (fence[1] || "").toLowerCase();
|
|
406
|
+
const start = i + 1;
|
|
407
|
+
let end = lines.length;
|
|
408
|
+
for (let j = start; j < lines.length; j++) {
|
|
409
|
+
if (/^```\s*$/.test(lines[j])) { end = j; break; }
|
|
410
|
+
}
|
|
411
|
+
const body = lines.slice(start, end).join("\n");
|
|
412
|
+
const idx = placeholders.length;
|
|
413
|
+
if (lang === "mermaid") {
|
|
414
|
+
placeholders.push(`<pre class="mermaid">${escape(sanitizeMermaid(body))}</pre>`);
|
|
415
|
+
} else {
|
|
416
|
+
placeholders.push(
|
|
417
|
+
`<pre class="codeblock${lang ? ` lang-${escape(lang)}` : ""}"><code>${escape(body)}</code></pre>`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
stripped += `\n\n@@CODEBLOCK_${idx}@@\n\n`;
|
|
421
|
+
i = end + 1;
|
|
422
|
+
} else {
|
|
423
|
+
stripped += lines[i] + "\n";
|
|
424
|
+
i += 1;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const blocks = stripped.split(/\n\s*\n/);
|
|
430
|
+
const out = [];
|
|
431
|
+
for (const raw of blocks) {
|
|
432
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
433
|
+
if (lines.length === 0) continue;
|
|
434
|
+
|
|
435
|
+
// Code block placeholder — passes through unchanged.
|
|
436
|
+
if (lines.length === 1) {
|
|
437
|
+
const m = /^@@CODEBLOCK_(\d+)@@$/.exec(lines[0].trim());
|
|
438
|
+
if (m) {
|
|
439
|
+
out.push(placeholders[Number(m[1])] || "");
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Heading · also handles the "## Title\nbody" case (no blank line
|
|
445
|
+
// between heading and content). Some prompts produce that pattern
|
|
446
|
+
// — without the multi-line branch the H2 leaks as literal `## …`
|
|
447
|
+
// text into a paragraph fallback. The trailing lines are processed
|
|
448
|
+
// as a separate paragraph block so we don't lose them.
|
|
449
|
+
{
|
|
450
|
+
const m = /^(#{1,4})\s+(.+)$/.exec(lines[0]);
|
|
451
|
+
if (m) {
|
|
452
|
+
const level = Math.min(m[1].length, 4);
|
|
453
|
+
out.push(`<h${level}>${inline(escape(m[2]))}</h${level}>`);
|
|
454
|
+
if (lines.length > 1) {
|
|
455
|
+
const rest = lines.slice(1).join(" ").trim();
|
|
456
|
+
if (rest) out.push(`<p>${inline(escape(rest))}</p>`);
|
|
457
|
+
}
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Pipe table · header row · separator · data rows.
|
|
463
|
+
// Detect: ≥ 2 lines, first looks like `| h1 | h2 |`, second is `|---|---|`.
|
|
464
|
+
if (
|
|
465
|
+
lines.length >= 2 &&
|
|
466
|
+
/\|/.test(lines[0]) &&
|
|
467
|
+
/^\s*\|?[\s:|\-]+\|?\s*$/.test(lines[1]) &&
|
|
468
|
+
/-/.test(lines[1])
|
|
469
|
+
) {
|
|
470
|
+
const splitRow = (l) =>
|
|
471
|
+
l
|
|
472
|
+
.replace(/^\s*\|/, "")
|
|
473
|
+
.replace(/\|\s*$/, "")
|
|
474
|
+
.split("|")
|
|
475
|
+
.map((c) => c.trim());
|
|
476
|
+
const headers = splitRow(lines[0]);
|
|
477
|
+
const rows = lines.slice(2).map(splitRow);
|
|
478
|
+
const thead = `<thead><tr>${headers.map((h) => `<th>${inline(escape(h))}</th>`).join("")}</tr></thead>`;
|
|
479
|
+
const tbody = `<tbody>${rows
|
|
480
|
+
.map((r) => `<tr>${r.map((c) => `<td>${inline(escape(c))}</td>`).join("")}</tr>`)
|
|
481
|
+
.join("")}</tbody>`;
|
|
482
|
+
out.push(`<table class="md-table">${thead}${tbody}</table>`);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Blockquote?
|
|
487
|
+
if (lines.every((l) => /^>\s?/.test(l))) {
|
|
488
|
+
const inner = lines.map((l) => inline(escape(l.replace(/^>\s?/, "")))).join("<br>");
|
|
489
|
+
out.push(`<blockquote>${inner}</blockquote>`);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Bulleted?
|
|
494
|
+
if (lines.every((l) => /^\s*[-*]\s+/.test(l))) {
|
|
495
|
+
const items = lines.map((l) => `<li>${inline(escape(l.replace(/^\s*[-*]\s+/, "")))}</li>`);
|
|
496
|
+
out.push(`<ul>${items.join("")}</ul>`);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Ordered list · first line starts with `1. `. Subsequent lines
|
|
501
|
+
// either start a new item (`2. ...`) or are indented continuation
|
|
502
|
+
// lines (e.g. the `_Rationale:_ ...` rows under each rec). Each
|
|
503
|
+
// continuation becomes its own <p> inside the <li>, which is what
|
|
504
|
+
// the post-render Recommendations / New Questions restructurers
|
|
505
|
+
// expect to find.
|
|
506
|
+
const olStart = /^\s*\d+[\.)]\s+/;
|
|
507
|
+
if (olStart.test(lines[0])) {
|
|
508
|
+
const items = []; // Array<string[]> — one bucket per item, lines kept separate
|
|
509
|
+
let cur = null;
|
|
510
|
+
for (const line of lines) {
|
|
511
|
+
if (olStart.test(line)) {
|
|
512
|
+
if (cur !== null) items.push(cur);
|
|
513
|
+
cur = [line.replace(olStart, "")];
|
|
514
|
+
} else if (cur !== null) {
|
|
515
|
+
const t = line.trim();
|
|
516
|
+
if (t.length > 0) cur.push(t);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (cur !== null) items.push(cur);
|
|
520
|
+
const itemsHtml = items.map((parts) => {
|
|
521
|
+
const ps = parts
|
|
522
|
+
.filter((p) => p.length > 0)
|
|
523
|
+
.map((p) => `<p>${inline(escape(p))}</p>`)
|
|
524
|
+
.join("");
|
|
525
|
+
return `<li>${ps}</li>`;
|
|
526
|
+
});
|
|
527
|
+
out.push(`<ol>${itemsHtml.join("")}</ol>`);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Paragraph
|
|
532
|
+
out.push(`<p>${inline(escape(lines.join(" ")))}</p>`);
|
|
533
|
+
}
|
|
534
|
+
return out.join("");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function fmtTime(ms) {
|
|
538
|
+
if (!ms) return "";
|
|
539
|
+
const d = new Date(ms);
|
|
540
|
+
return d.toLocaleString();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Map H2 heading text → section class. We match on a normalised lowercase
|
|
544
|
+
* comparison so the report writer can produce minor variations (e.g.
|
|
545
|
+
* "Where We Diverged · The Crux") and the wrapper still applies. */
|
|
546
|
+
const SECTION_MATCHERS = [
|
|
547
|
+
{ re: /^bottom\s*line/i, cls: "section-bottom-line" },
|
|
548
|
+
{ re: /^the\s*thesis|^thesis$/i, cls: "section-thesis" },
|
|
549
|
+
{ re: /^a\s+working\s+hypothesis|^working\s*hypothesis/i, cls: "section-working-hypothesis" },
|
|
550
|
+
{ re: /^strategic\s*outlook|^战略前景|^战略展望/i, cls: "section-strategic-outlook" },
|
|
551
|
+
{ re: /^frame\s*shift/i, cls: "section-frame-shift" },
|
|
552
|
+
{ re: /^headline\s*findings?/i, cls: "section-headline-findings" },
|
|
553
|
+
{ re: /^three\s*big\s*ideas|^big\s*ideas/i, cls: "section-big-ideas" },
|
|
554
|
+
{ re: /^(where\s*we\s*)?converge[d]?/i, cls: "section-convergence" },
|
|
555
|
+
{ re: /^(where\s*we\s*)?diverge[d]?|crux/i, cls: "section-divergence" },
|
|
556
|
+
{ re: /^positions?/i, cls: "section-positions" },
|
|
557
|
+
{ re: /^options?\s*analysis/i, cls: "section-options-analysis" },
|
|
558
|
+
{ re: /^two\s*paths|^two\s*futures|^two\s*scenarios/i, cls: "section-two-paths" },
|
|
559
|
+
{ re: /^why\s*now/i, cls: "section-why-now" },
|
|
560
|
+
// Gartner-density blocks · placed in classifier order so a brief
|
|
561
|
+
// with all four reads top-to-bottom: outlook → assumptions → tree
|
|
562
|
+
// → indicators. The matchers tolerate light wording variation
|
|
563
|
+
// (e.g. "Critical Assumptions Log").
|
|
564
|
+
{ re: /^critical\s*assumptions?|^load[-\s]bearing\s*assumptions?|^承重假设|^关键假设/i, cls: "section-critical-assumptions" },
|
|
565
|
+
{ re: /^scenario\s*tree|^scenarios?$|^情景树|^情景分析|^命名情景/i, cls: "section-scenario-tree" },
|
|
566
|
+
{ re: /^leading\s*indicators?|^watch[-\s]list|^先行指标|^监测指标|^监控信号/i, cls: "section-leading-indicators" },
|
|
567
|
+
{ re: /^recommendations?/i, cls: "section-recommendations" },
|
|
568
|
+
{ re: /^the\s*bet$|^the\s*bet[\s·:]/i, cls: "section-the-bet" },
|
|
569
|
+
{ re: /^considerations?/i, cls: "section-considerations" },
|
|
570
|
+
{ re: /^pre[-\s]?mortem|risks?/i, cls: "section-pre-mortem" },
|
|
571
|
+
{ re: /^new\s*questions?/i, cls: "section-new-questions" },
|
|
572
|
+
{ re: /^strategic\s*planning\s*assumption|^planning\s*assumption/i, cls: "section-planning-assumption" },
|
|
573
|
+
{ re: /^open\s*questions?/i, cls: "section-open-questions" },
|
|
574
|
+
{ re: /^methodology|^方法论|^methods?$/i, cls: "section-methodology" },
|
|
575
|
+
];
|
|
576
|
+
|
|
577
|
+
function classifyHeading(text) {
|
|
578
|
+
const t = (text || "").trim().toLowerCase();
|
|
579
|
+
for (const m of SECTION_MATCHERS) if (m.re.test(t)) return m.cls;
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Walk the body, tag each H2 + wrap its following content (until the
|
|
584
|
+
* next H2 or end of body) in a <section> with the matching class. */
|
|
585
|
+
function wrapSections(body) {
|
|
586
|
+
const h2s = Array.from(body.querySelectorAll("h2"));
|
|
587
|
+
for (const h2 of h2s) {
|
|
588
|
+
const cls = classifyHeading(h2.textContent);
|
|
589
|
+
if (!cls) continue;
|
|
590
|
+
h2.classList.add(cls);
|
|
591
|
+
// Collect siblings until next H2.
|
|
592
|
+
const between = [];
|
|
593
|
+
let n = h2.nextSibling;
|
|
594
|
+
while (n && !(n.nodeType === 1 && n.tagName === "H2")) {
|
|
595
|
+
const next = n.nextSibling;
|
|
596
|
+
between.push(n);
|
|
597
|
+
n = next;
|
|
598
|
+
}
|
|
599
|
+
if (!between.length) continue;
|
|
600
|
+
const section = document.createElement("section");
|
|
601
|
+
section.classList.add(cls);
|
|
602
|
+
// Insert section right after the H2.
|
|
603
|
+
h2.parentNode.insertBefore(section, h2.nextSibling);
|
|
604
|
+
for (const node of between) section.appendChild(node);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Tag inline `<code>` elements that look like confidence / priority
|
|
609
|
+
* badges so CSS can color them. Looks at text content only. */
|
|
610
|
+
function tagBadges(body) {
|
|
611
|
+
const codes = Array.from(body.querySelectorAll("code"));
|
|
612
|
+
for (const c of codes) {
|
|
613
|
+
const t = (c.textContent || "").trim();
|
|
614
|
+
if (/^P0$/i.test(t)) c.classList.add("badge-priority-p0");
|
|
615
|
+
else if (/^P1$/i.test(t)) c.classList.add("badge-priority-p1");
|
|
616
|
+
else if (/^P2$/i.test(t)) c.classList.add("badge-priority-p2");
|
|
617
|
+
else if (/^high$/i.test(t)) c.classList.add("badge-confidence-high");
|
|
618
|
+
else if (/^medium$/i.test(t)) c.classList.add("badge-confidence-medium");
|
|
619
|
+
else if (/^low$/i.test(t)) c.classList.add("badge-confidence-low");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Inject mono "section 0X" labels above each chapter H2 (skipping
|
|
624
|
+
* the Bottom Line, which has its own treatment, and the Methodology
|
|
625
|
+
* footer). McKinsey-style chapter numbering. */
|
|
626
|
+
function injectChapterNumbers(body) {
|
|
627
|
+
const h2s = Array.from(body.querySelectorAll("h2"));
|
|
628
|
+
let n = 0;
|
|
629
|
+
for (const h2 of h2s) {
|
|
630
|
+
// Skip the anchor sections (executive-summary slot, not a numbered
|
|
631
|
+
// chapter) and Methodology (footer treatment).
|
|
632
|
+
if (h2.classList.contains("section-bottom-line")) continue;
|
|
633
|
+
if (h2.classList.contains("section-thesis")) continue;
|
|
634
|
+
if (h2.classList.contains("section-working-hypothesis")) continue;
|
|
635
|
+
if (h2.classList.contains("section-methodology")) continue;
|
|
636
|
+
n += 1;
|
|
637
|
+
const num = String(n).padStart(2, "0");
|
|
638
|
+
const label = document.createElement("div");
|
|
639
|
+
label.className = "chapter-num";
|
|
640
|
+
label.textContent = `Section ${num}`;
|
|
641
|
+
h2.parentNode.insertBefore(label, h2);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Inside the Headline Findings section, transform 3 H3 sub-headings
|
|
646
|
+
* into a pillar grid. Each pillar = .pillar div containing the H3
|
|
647
|
+
* + everything until the next H3. Three pillars side-by-side on
|
|
648
|
+
* wide screens (CSS handles responsive collapse). */
|
|
649
|
+
function buildHeadlinePillars(body) {
|
|
650
|
+
const section = body.querySelector("section.section-headline-findings");
|
|
651
|
+
if (!section) return;
|
|
652
|
+
const h3s = Array.from(section.querySelectorAll("h3"));
|
|
653
|
+
if (h3s.length === 0) return;
|
|
654
|
+
const grid = document.createElement("div");
|
|
655
|
+
grid.className = "pillars-grid";
|
|
656
|
+
h3s.forEach((h3, i) => {
|
|
657
|
+
const pillar = document.createElement("div");
|
|
658
|
+
pillar.className = "pillar";
|
|
659
|
+
const num = document.createElement("div");
|
|
660
|
+
num.className = "pillar-num";
|
|
661
|
+
num.textContent = String(i + 1).padStart(2, "0");
|
|
662
|
+
pillar.appendChild(num);
|
|
663
|
+
pillar.appendChild(h3.cloneNode(true));
|
|
664
|
+
let n = h3.nextSibling;
|
|
665
|
+
while (n && !(n.nodeType === 1 && n.tagName === "H3")) {
|
|
666
|
+
const next = n.nextSibling;
|
|
667
|
+
pillar.appendChild(n.cloneNode(true));
|
|
668
|
+
n = next;
|
|
669
|
+
}
|
|
670
|
+
grid.appendChild(pillar);
|
|
671
|
+
});
|
|
672
|
+
// Replace original H3 + content with the grid.
|
|
673
|
+
// Remove the H3s and their following siblings up to (but not including) the next non-h3 element after the last h3 group.
|
|
674
|
+
const firstH3 = h3s[0];
|
|
675
|
+
const insertAt = firstH3;
|
|
676
|
+
// Collect all the nodes to remove (H3s + everything between them + after the last H3 within the section).
|
|
677
|
+
const toRemove = [];
|
|
678
|
+
let cur = firstH3;
|
|
679
|
+
while (cur && cur !== section.lastChild?.nextSibling) {
|
|
680
|
+
toRemove.push(cur);
|
|
681
|
+
cur = cur.nextSibling;
|
|
682
|
+
}
|
|
683
|
+
section.insertBefore(grid, firstH3);
|
|
684
|
+
for (const node of toRemove) {
|
|
685
|
+
if (node && node.parentNode === section) section.removeChild(node);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Number Visual H3s as Exhibit 01 / 02 / … inside the Options
|
|
690
|
+
* Analysis section. */
|
|
691
|
+
function injectExhibitLabels(body) {
|
|
692
|
+
const section = body.querySelector("section.section-options-analysis");
|
|
693
|
+
if (!section) return;
|
|
694
|
+
const h3s = Array.from(section.querySelectorAll("h3"));
|
|
695
|
+
h3s.forEach((h3, i) => {
|
|
696
|
+
const num = String(i + 1).padStart(2, "0");
|
|
697
|
+
const label = document.createElement("div");
|
|
698
|
+
label.className = "chapter-num";
|
|
699
|
+
label.style.margin = "32px 0 6px";
|
|
700
|
+
label.textContent = `Exhibit ${num}`;
|
|
701
|
+
h3.parentNode.insertBefore(label, h3);
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Recommendations / Considerations · the prompt produces a numbered
|
|
706
|
+
* list where each `<li>` is a wall of `<em>Label:</em> value`
|
|
707
|
+
* paragraphs. Without restructuring, every metadata field looks
|
|
708
|
+
* identical — readers can't scan owner/horizon/metric/risk apart.
|
|
709
|
+
*
|
|
710
|
+
* We rebuild each `<li>` into:
|
|
711
|
+
* · header · priority badge + action sentence
|
|
712
|
+
* · meta · 3-column dl of Owner / Horizon / Success metric
|
|
713
|
+
* · prose · rationale paragraph (no label)
|
|
714
|
+
* · risk · soft alert callout
|
|
715
|
+
*
|
|
716
|
+
* Field detection is by the leading `<em>` text after the lead p.
|
|
717
|
+
* Robust to two fields-per-paragraph (`_Owner:_ X · _Horizon:_ Y`),
|
|
718
|
+
* to fields appearing in any order, and to mixed en/zh content. */
|
|
719
|
+
|
|
720
|
+
// Canonical key per known label. Substring match (case-insensitive,
|
|
721
|
+
// colons stripped). Considerations uses different label words for the
|
|
722
|
+
// same fields — both map here.
|
|
723
|
+
const REC_LABEL_MAP = [
|
|
724
|
+
{ key: "rationale", patterns: ["rationale", "worth thinking about because"] },
|
|
725
|
+
{ key: "owner", patterns: ["owner", "who'd own it", "who would own it"] },
|
|
726
|
+
{ key: "horizon", patterns: ["horizon", "on what horizon"] },
|
|
727
|
+
{ key: "metric", patterns: ["success metric", "what you'd watch", "what you would watch"] },
|
|
728
|
+
{ key: "risk", patterns: ["risk if skipped", "what you'd give up by not doing this", "what you'd give up"] },
|
|
729
|
+
];
|
|
730
|
+
function recLabelKey(rawLabel) {
|
|
731
|
+
const t = String(rawLabel || "").toLowerCase().replace(/[::]\s*$/, "").trim();
|
|
732
|
+
if (!t) return null;
|
|
733
|
+
for (const m of REC_LABEL_MAP) {
|
|
734
|
+
for (const p of m.patterns) {
|
|
735
|
+
if (t === p || t.startsWith(p)) return m.key;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Walk a `<p>` that may contain one or more `<em>Label:</em> value`
|
|
742
|
+
// pairs separated by ` · ` (or commas / pipes). Return [{ key, value }].
|
|
743
|
+
function parseRecMetaParagraph(p) {
|
|
744
|
+
const ems = Array.from(p.querySelectorAll(":scope > em, :scope > strong > em"));
|
|
745
|
+
const out = [];
|
|
746
|
+
if (ems.length === 0) return out;
|
|
747
|
+
for (let i = 0; i < ems.length; i++) {
|
|
748
|
+
const em = ems[i];
|
|
749
|
+
const key = recLabelKey(em.textContent);
|
|
750
|
+
if (!key) continue;
|
|
751
|
+
// Walk siblings until the next <em> (or end of paragraph),
|
|
752
|
+
// accumulating text + inline markup.
|
|
753
|
+
const stop = ems[i + 1] || null;
|
|
754
|
+
let value = "";
|
|
755
|
+
let n = em.nextSibling;
|
|
756
|
+
while (n && n !== stop) {
|
|
757
|
+
if (n.nodeType === 1 || n.nodeType === 3) {
|
|
758
|
+
value += n.nodeType === 1 ? n.outerHTML || n.textContent : n.textContent;
|
|
759
|
+
}
|
|
760
|
+
n = n.nextSibling;
|
|
761
|
+
}
|
|
762
|
+
// Trim whitespace and the leading `· ` separator that might
|
|
763
|
+
// sit between two fields.
|
|
764
|
+
value = value
|
|
765
|
+
.replace(/^[\s·,|]+/, "")
|
|
766
|
+
.replace(/[\s·,|]+$/, "")
|
|
767
|
+
.trim();
|
|
768
|
+
if (value) out.push({ key, valueHtml: value });
|
|
769
|
+
}
|
|
770
|
+
return out;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function buildRecommendationItem(li, index) {
|
|
774
|
+
const ps = Array.from(li.children).filter((n) => n.tagName === "P");
|
|
775
|
+
if (ps.length === 0) return null;
|
|
776
|
+
|
|
777
|
+
// Lead paragraph · expects priority `<code>` + action `<strong>`.
|
|
778
|
+
const lead = ps[0];
|
|
779
|
+
const codeBadge = lead.querySelector("code");
|
|
780
|
+
const priority = codeBadge ? codeBadge.textContent.trim() : "";
|
|
781
|
+
// Action text = lead minus the priority badge (and its wrapping
|
|
782
|
+
// <strong> when the badge is alone inside one).
|
|
783
|
+
const leadClone = lead.cloneNode(true);
|
|
784
|
+
const cloneBadge = leadClone.querySelector("code");
|
|
785
|
+
if (cloneBadge) {
|
|
786
|
+
const wrappingStrong = cloneBadge.parentElement;
|
|
787
|
+
const isAloneInStrong = wrappingStrong
|
|
788
|
+
&& wrappingStrong.tagName === "STRONG"
|
|
789
|
+
&& wrappingStrong.childNodes.length === 1;
|
|
790
|
+
(isAloneInStrong ? wrappingStrong : cloneBadge).remove();
|
|
791
|
+
}
|
|
792
|
+
// The remaining text is the action — typically still wrapped in a
|
|
793
|
+
// single <strong>. Unwrap it so .rec-action's own typography wins.
|
|
794
|
+
if (leadClone.children.length === 1
|
|
795
|
+
&& leadClone.firstElementChild.tagName === "STRONG"
|
|
796
|
+
&& leadClone.firstElementChild.textContent.trim() === leadClone.textContent.trim()) {
|
|
797
|
+
leadClone.firstElementChild.outerHTML = leadClone.firstElementChild.innerHTML;
|
|
798
|
+
}
|
|
799
|
+
const actionHtml = leadClone.innerHTML.trim();
|
|
800
|
+
|
|
801
|
+
const fields = {};
|
|
802
|
+
for (let i = 1; i < ps.length; i++) {
|
|
803
|
+
const parsed = parseRecMetaParagraph(ps[i]);
|
|
804
|
+
for (const { key, valueHtml } of parsed) {
|
|
805
|
+
if (!fields[key]) fields[key] = valueHtml;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ── New editorial DOM ───────────────────────────────────────────
|
|
810
|
+
// ┌── rule ──┐
|
|
811
|
+
// │ 01 P0 │ ── thin line filling the rest of the row
|
|
812
|
+
// ├──────────┤
|
|
813
|
+
// │ ACTION │ large display
|
|
814
|
+
// │ rational │ body prose
|
|
815
|
+
// │ MO·HO·SM │ inline meta row
|
|
816
|
+
// │ ⚠ risk │ italic one-liner
|
|
817
|
+
// └──────────┘
|
|
818
|
+
const newLi = document.createElement("li");
|
|
819
|
+
newLi.className = "rec-item";
|
|
820
|
+
if (priority) newLi.classList.add(`rec-priority-${priority.toLowerCase()}`);
|
|
821
|
+
|
|
822
|
+
const rule = document.createElement("div");
|
|
823
|
+
rule.className = "rec-rule";
|
|
824
|
+
const num = document.createElement("span");
|
|
825
|
+
num.className = "rec-num";
|
|
826
|
+
num.textContent = String(index).padStart(2, "0");
|
|
827
|
+
rule.appendChild(num);
|
|
828
|
+
if (priority) {
|
|
829
|
+
const badge = document.createElement("code");
|
|
830
|
+
const lvl = priority.toLowerCase();
|
|
831
|
+
if (/^p[012]$/.test(lvl)) badge.className = `badge-priority-${lvl}`;
|
|
832
|
+
badge.textContent = priority;
|
|
833
|
+
rule.appendChild(badge);
|
|
834
|
+
}
|
|
835
|
+
const ruleLine = document.createElement("span");
|
|
836
|
+
ruleLine.className = "rec-rule-line";
|
|
837
|
+
ruleLine.setAttribute("aria-hidden", "true");
|
|
838
|
+
rule.appendChild(ruleLine);
|
|
839
|
+
newLi.appendChild(rule);
|
|
840
|
+
|
|
841
|
+
const action = document.createElement("h3");
|
|
842
|
+
action.className = "rec-action";
|
|
843
|
+
action.innerHTML = actionHtml;
|
|
844
|
+
newLi.appendChild(action);
|
|
845
|
+
|
|
846
|
+
if (fields.rationale) {
|
|
847
|
+
const ratP = document.createElement("p");
|
|
848
|
+
ratP.className = "rec-rationale";
|
|
849
|
+
ratP.innerHTML = fields.rationale;
|
|
850
|
+
newLi.appendChild(ratP);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const metaPairs = [];
|
|
854
|
+
if (fields.owner) metaPairs.push({ label: "Owner", value: fields.owner });
|
|
855
|
+
if (fields.horizon) metaPairs.push({ label: "Horizon", value: fields.horizon });
|
|
856
|
+
if (fields.metric) metaPairs.push({ label: "Metric", value: fields.metric });
|
|
857
|
+
if (metaPairs.length) {
|
|
858
|
+
const meta = document.createElement("div");
|
|
859
|
+
meta.className = "rec-meta";
|
|
860
|
+
metaPairs.forEach((p, i) => {
|
|
861
|
+
const pair = document.createElement("span");
|
|
862
|
+
pair.className = "rec-meta-pair";
|
|
863
|
+
const lbl = document.createElement("span");
|
|
864
|
+
lbl.className = "rec-meta-label";
|
|
865
|
+
lbl.textContent = p.label;
|
|
866
|
+
const val = document.createElement("span");
|
|
867
|
+
val.className = "rec-meta-value";
|
|
868
|
+
val.innerHTML = p.value;
|
|
869
|
+
pair.appendChild(lbl);
|
|
870
|
+
pair.appendChild(val);
|
|
871
|
+
meta.appendChild(pair);
|
|
872
|
+
if (i < metaPairs.length - 1) {
|
|
873
|
+
const sep = document.createElement("span");
|
|
874
|
+
sep.className = "rec-meta-sep";
|
|
875
|
+
sep.setAttribute("aria-hidden", "true");
|
|
876
|
+
sep.textContent = "·";
|
|
877
|
+
meta.appendChild(sep);
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
newLi.appendChild(meta);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (fields.risk) {
|
|
884
|
+
const risk = document.createElement("p");
|
|
885
|
+
risk.className = "rec-risk";
|
|
886
|
+
const prefix = document.createElement("span");
|
|
887
|
+
prefix.className = "rec-risk-prefix";
|
|
888
|
+
prefix.textContent = "Risk if skipped";
|
|
889
|
+
const sep = document.createElement("span");
|
|
890
|
+
sep.className = "rec-risk-sep";
|
|
891
|
+
sep.setAttribute("aria-hidden", "true");
|
|
892
|
+
sep.textContent = "—";
|
|
893
|
+
const text = document.createElement("span");
|
|
894
|
+
text.className = "rec-risk-text";
|
|
895
|
+
text.innerHTML = fields.risk;
|
|
896
|
+
risk.appendChild(prefix);
|
|
897
|
+
risk.appendChild(sep);
|
|
898
|
+
risk.appendChild(text);
|
|
899
|
+
newLi.appendChild(risk);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return newLi;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** The brief writer separates list items with blank lines, which the
|
|
906
|
+
* markdown renderer turns into multiple consecutive `<ol>` blocks
|
|
907
|
+
* rather than one. Merge them before restructuring so item numbering
|
|
908
|
+
* is continuous and CSS sees a single list. */
|
|
909
|
+
function mergeConsecutiveOls(section) {
|
|
910
|
+
const ols = Array.from(section.querySelectorAll(":scope > ol"));
|
|
911
|
+
if (ols.length <= 1) return ols[0] || null;
|
|
912
|
+
const first = ols[0];
|
|
913
|
+
for (let i = 1; i < ols.length; i++) {
|
|
914
|
+
while (ols[i].firstChild) first.appendChild(ols[i].firstChild);
|
|
915
|
+
ols[i].remove();
|
|
916
|
+
}
|
|
917
|
+
return first;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function restructureRecommendations(body) {
|
|
921
|
+
const sections = Array.from(body.querySelectorAll(
|
|
922
|
+
"section.section-recommendations, section.section-considerations"
|
|
923
|
+
));
|
|
924
|
+
for (const section of sections) {
|
|
925
|
+
const ol = mergeConsecutiveOls(section);
|
|
926
|
+
if (!ol) continue;
|
|
927
|
+
const lis = Array.from(ol.children).filter((n) => n.tagName === "LI");
|
|
928
|
+
lis.forEach((li, idx) => {
|
|
929
|
+
const newLi = buildRecommendationItem(li, idx + 1);
|
|
930
|
+
if (newLi) li.replaceWith(newLi);
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** New Questions · same wall-of-em-labels problem. We rebuild each
|
|
936
|
+
* `<li>` into a card with a Q-numeral, italic question text (large
|
|
937
|
+
* & inviting), a why-it-matters prose paragraph (no label), and a
|
|
938
|
+
* small attribution byline at the bottom. */
|
|
939
|
+
const NQ_LABEL_MAP = [
|
|
940
|
+
{ key: "why", patterns: ["why it matters", "why matters"] },
|
|
941
|
+
{ key: "by", patterns: ["surfaced by", "raised by", "asked by"] },
|
|
942
|
+
];
|
|
943
|
+
function nqLabelKey(rawLabel) {
|
|
944
|
+
const t = String(rawLabel || "").toLowerCase().replace(/[::]\s*$/, "").trim();
|
|
945
|
+
if (!t) return null;
|
|
946
|
+
for (const m of NQ_LABEL_MAP) {
|
|
947
|
+
for (const p of m.patterns) {
|
|
948
|
+
if (t === p || t.startsWith(p)) return m.key;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
function parseNqMetaParagraph(p) {
|
|
954
|
+
const ems = Array.from(p.querySelectorAll(":scope > em, :scope > strong > em"));
|
|
955
|
+
const out = [];
|
|
956
|
+
if (ems.length === 0) return out;
|
|
957
|
+
for (let i = 0; i < ems.length; i++) {
|
|
958
|
+
const em = ems[i];
|
|
959
|
+
const key = nqLabelKey(em.textContent);
|
|
960
|
+
if (!key) continue;
|
|
961
|
+
const stop = ems[i + 1] || null;
|
|
962
|
+
let value = "";
|
|
963
|
+
let n = em.nextSibling;
|
|
964
|
+
while (n && n !== stop) {
|
|
965
|
+
if (n.nodeType === 1 || n.nodeType === 3) {
|
|
966
|
+
value += n.nodeType === 1 ? n.outerHTML || n.textContent : n.textContent;
|
|
967
|
+
}
|
|
968
|
+
n = n.nextSibling;
|
|
969
|
+
}
|
|
970
|
+
value = value.replace(/^[\s·,|]+/, "").replace(/[\s·,|]+$/, "").trim();
|
|
971
|
+
if (value) out.push({ key, valueHtml: value });
|
|
972
|
+
}
|
|
973
|
+
return out;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function buildNewQuestionItem(li, index) {
|
|
977
|
+
const ps = Array.from(li.children).filter((n) => n.tagName === "P");
|
|
978
|
+
if (ps.length === 0) return null;
|
|
979
|
+
|
|
980
|
+
const leadClone = ps[0].cloneNode(true);
|
|
981
|
+
if (leadClone.children.length === 1 && leadClone.firstElementChild.tagName === "STRONG"
|
|
982
|
+
&& leadClone.firstElementChild.textContent.trim() === leadClone.textContent.trim()) {
|
|
983
|
+
leadClone.firstElementChild.outerHTML = leadClone.firstElementChild.innerHTML;
|
|
984
|
+
}
|
|
985
|
+
const questionHtml = leadClone.innerHTML.trim();
|
|
986
|
+
|
|
987
|
+
const fields = {};
|
|
988
|
+
for (let i = 1; i < ps.length; i++) {
|
|
989
|
+
const parsed = parseNqMetaParagraph(ps[i]);
|
|
990
|
+
for (const { key, valueHtml } of parsed) {
|
|
991
|
+
if (!fields[key]) fields[key] = valueHtml;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// ── Op-ed style DOM ─────────────────────────────────────────────
|
|
996
|
+
// ┌── rule ──┐
|
|
997
|
+
// │ Q1 ── line ── Director Name (right) │
|
|
998
|
+
// ├──────────────────────────────────────┤
|
|
999
|
+
// │ "italic display question" │
|
|
1000
|
+
// │ why-it-matters body prose │
|
|
1001
|
+
// └──────────────────────────────────────┘
|
|
1002
|
+
const newLi = document.createElement("li");
|
|
1003
|
+
newLi.className = "nq-item";
|
|
1004
|
+
|
|
1005
|
+
const rule = document.createElement("div");
|
|
1006
|
+
rule.className = "nq-rule";
|
|
1007
|
+
const num = document.createElement("span");
|
|
1008
|
+
num.className = "nq-num";
|
|
1009
|
+
num.textContent = `Q${index}`;
|
|
1010
|
+
rule.appendChild(num);
|
|
1011
|
+
const ruleLine = document.createElement("span");
|
|
1012
|
+
ruleLine.className = "nq-rule-line";
|
|
1013
|
+
ruleLine.setAttribute("aria-hidden", "true");
|
|
1014
|
+
rule.appendChild(ruleLine);
|
|
1015
|
+
if (fields.by) {
|
|
1016
|
+
const by = document.createElement("span");
|
|
1017
|
+
by.className = "nq-attribution";
|
|
1018
|
+
const byPrefix = document.createElement("span");
|
|
1019
|
+
byPrefix.className = "nq-attribution-prefix";
|
|
1020
|
+
byPrefix.textContent = "Surfaced by";
|
|
1021
|
+
const who = document.createElement("span");
|
|
1022
|
+
who.className = "nq-attribution-who";
|
|
1023
|
+
who.innerHTML = fields.by;
|
|
1024
|
+
by.appendChild(byPrefix);
|
|
1025
|
+
by.appendChild(who);
|
|
1026
|
+
rule.appendChild(by);
|
|
1027
|
+
}
|
|
1028
|
+
newLi.appendChild(rule);
|
|
1029
|
+
|
|
1030
|
+
const q = document.createElement("blockquote");
|
|
1031
|
+
q.className = "nq-question";
|
|
1032
|
+
q.innerHTML = questionHtml;
|
|
1033
|
+
newLi.appendChild(q);
|
|
1034
|
+
|
|
1035
|
+
if (fields.why) {
|
|
1036
|
+
const why = document.createElement("p");
|
|
1037
|
+
why.className = "nq-why";
|
|
1038
|
+
why.innerHTML = fields.why;
|
|
1039
|
+
newLi.appendChild(why);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
return newLi;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function restructureNewQuestions(body) {
|
|
1046
|
+
const section = body.querySelector("section.section-new-questions");
|
|
1047
|
+
if (!section) return;
|
|
1048
|
+
const ol = mergeConsecutiveOls(section);
|
|
1049
|
+
if (!ol) return;
|
|
1050
|
+
const lis = Array.from(ol.children).filter((n) => n.tagName === "LI");
|
|
1051
|
+
lis.forEach((li, idx) => {
|
|
1052
|
+
const newLi = buildNewQuestionItem(li, idx + 1);
|
|
1053
|
+
if (newLi) li.replaceWith(newLi);
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Merge consecutive `<ol>` blocks in every wrapped section. The
|
|
1058
|
+
* brief writer separates numbered items with blank lines, which the
|
|
1059
|
+
* markdown renderer turns into N adjacent OLs — each restarts the
|
|
1060
|
+
* counter at 01 and produces a doubled rule between siblings (item-1's
|
|
1061
|
+
* bottom border + item-2's top border). Big Ideas, The Bet, and any
|
|
1062
|
+
* other section with `1. … 2. …` content all need this merge before
|
|
1063
|
+
* the spine CSS sees the list. */
|
|
1064
|
+
function mergeAllOlsInSections(body) {
|
|
1065
|
+
const sections = body.querySelectorAll("section");
|
|
1066
|
+
for (const s of sections) mergeConsecutiveOls(s);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function decorateReport(body) {
|
|
1070
|
+
if (!body) return;
|
|
1071
|
+
try {
|
|
1072
|
+
wrapSections(body);
|
|
1073
|
+
mergeAllOlsInSections(body);
|
|
1074
|
+
tagBadges(body);
|
|
1075
|
+
injectChapterNumbers(body);
|
|
1076
|
+
buildHeadlinePillars(body);
|
|
1077
|
+
injectExhibitLabels(body);
|
|
1078
|
+
restructureRecommendations(body);
|
|
1079
|
+
restructureNewQuestions(body);
|
|
1080
|
+
} catch (e) {
|
|
1081
|
+
// Decoration is purely visual — log and leave the raw markdown
|
|
1082
|
+
// standing if anything goes sideways.
|
|
1083
|
+
console.warn("[report] decorate failed:", e);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function showError(msg) {
|
|
1088
|
+
root.innerHTML = `<div class="placeholder error">${escape(msg)}</div>`;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function load() {
|
|
1092
|
+
if (!roomId) {
|
|
1093
|
+
showError("no room id in url");
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
let room, brief, members, briefs;
|
|
1097
|
+
try {
|
|
1098
|
+
// Load room state + ALL briefs for this room in parallel. The
|
|
1099
|
+
// viewer picks the requested brief (when ?b= present) or the
|
|
1100
|
+
// newest, then renders the version-tab strip if there are ≥ 2.
|
|
1101
|
+
const briefEndpoint = briefId
|
|
1102
|
+
? "/api/briefs/" + encodeURIComponent(briefId)
|
|
1103
|
+
: "/api/rooms/" + encodeURIComponent(roomId) + "/brief";
|
|
1104
|
+
const [stateRes, briefRes, listRes] = await Promise.all([
|
|
1105
|
+
fetch("/api/rooms/" + encodeURIComponent(roomId)),
|
|
1106
|
+
fetch(briefEndpoint),
|
|
1107
|
+
fetch("/api/rooms/" + encodeURIComponent(roomId) + "/briefs"),
|
|
1108
|
+
]);
|
|
1109
|
+
if (!briefRes.ok) {
|
|
1110
|
+
showError(briefRes.status === 404 ? "no brief filed for this room yet" : "failed to load brief");
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
brief = await briefRes.json();
|
|
1114
|
+
if (stateRes.ok) {
|
|
1115
|
+
const j = await stateRes.json();
|
|
1116
|
+
room = j.room;
|
|
1117
|
+
members = j.members || [];
|
|
1118
|
+
} else {
|
|
1119
|
+
members = [];
|
|
1120
|
+
}
|
|
1121
|
+
if (listRes.ok) {
|
|
1122
|
+
const j = await listRes.json();
|
|
1123
|
+
briefs = Array.isArray(j.briefs) ? j.briefs : [];
|
|
1124
|
+
} else {
|
|
1125
|
+
briefs = [];
|
|
1126
|
+
}
|
|
1127
|
+
} catch (e) {
|
|
1128
|
+
showError("network error: " + (e && e.message ? e.message : String(e)));
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
document.title = "BOARDROOM // " + (brief.title || "brief");
|
|
1133
|
+
swapSpine(brief.spine || "boardroom-dark");
|
|
1134
|
+
|
|
1135
|
+
const signed = members
|
|
1136
|
+
.map((a) => `<img src="${escape(a.avatarPath)}" alt="${escape(a.name)}" title="${escape(a.name)}">`)
|
|
1137
|
+
.join("");
|
|
1138
|
+
|
|
1139
|
+
const filedDate = brief.createdAt
|
|
1140
|
+
? new Date(brief.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
|
|
1141
|
+
: "—";
|
|
1142
|
+
const docId = (brief.id || "").slice(0, 12).toUpperCase();
|
|
1143
|
+
const subjectShort = room?.subject ? escape(room.subject.length > 64 ? room.subject.slice(0, 64) + "…" : room.subject) : "—";
|
|
1144
|
+
|
|
1145
|
+
// Version strip · only when ≥ 2 briefs filed for this room. Lets
|
|
1146
|
+
// the reader see they're holding "version 02 of 03" of this room's
|
|
1147
|
+
// research note, with siblings linkable. Sorted oldest → newest so
|
|
1148
|
+
// "01" reads as the original.
|
|
1149
|
+
const sortedBriefs = (briefs || []).slice().sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
1150
|
+
const showVersions = sortedBriefs.length > 1;
|
|
1151
|
+
const versionsHtml = showVersions ? `
|
|
1152
|
+
<div class="cover-versions">
|
|
1153
|
+
<span class="cover-versions-label">Versions</span>
|
|
1154
|
+
${sortedBriefs.map((bf, i) => {
|
|
1155
|
+
const num = String(i + 1).padStart(2, "0");
|
|
1156
|
+
const isActive = bf.id === brief.id;
|
|
1157
|
+
const isInitial = i === 0;
|
|
1158
|
+
const hint = isInitial
|
|
1159
|
+
? "Initial brief"
|
|
1160
|
+
: (bf.supplement && bf.supplement.trim()
|
|
1161
|
+
? `Supplement: ${bf.supplement.trim().slice(0, 120)}${bf.supplement.trim().length > 120 ? "…" : ""}`
|
|
1162
|
+
: "Regenerated brief");
|
|
1163
|
+
const href = `/report.html?r=${encodeURIComponent(roomId)}&b=${encodeURIComponent(bf.id)}`;
|
|
1164
|
+
return `<a class="cover-version${isActive ? " active" : ""}" href="${href}" title="${escape(hint)}"><span class="num">${num}</span><span class="sep">/</span><span class="hint">${escape(isInitial ? "Initial" : (bf.supplement || "—").trim().slice(0, 22))}${(!isInitial && bf.supplement && bf.supplement.length > 22) ? "…" : ""}</span></a>`;
|
|
1165
|
+
}).join("")}
|
|
1166
|
+
</div>
|
|
1167
|
+
` : "";
|
|
1168
|
+
|
|
1169
|
+
// Masthead: the dossier strip at the top of the cover. Reads like
|
|
1170
|
+
// a research-note dateline rather than a marketing eyebrow.
|
|
1171
|
+
const isoFiled = brief.createdAt
|
|
1172
|
+
? new Date(brief.createdAt).toISOString().slice(0, 10).replace(/-/g, ".")
|
|
1173
|
+
: "—";
|
|
1174
|
+
root.innerHTML = `
|
|
1175
|
+
<header class="cover">
|
|
1176
|
+
<div class="cover-tag">
|
|
1177
|
+
<span>Boardroom · Research Note</span>
|
|
1178
|
+
<span class="pipe"></span>
|
|
1179
|
+
<span class="secondary">Dossier ${escape(docId || "—")}</span>
|
|
1180
|
+
<span class="pipe"></span>
|
|
1181
|
+
<span class="secondary">Filed ${escape(isoFiled)}</span>
|
|
1182
|
+
</div>
|
|
1183
|
+
<h1 class="cover-title">${escape(brief.title || "(untitled)")}</h1>
|
|
1184
|
+
${room?.subject ? `<div class="cover-deck">${escape(room.subject)}</div>` : ""}
|
|
1185
|
+
<div class="cover-byline">
|
|
1186
|
+
<div class="byline-block">
|
|
1187
|
+
<div class="label">Filed</div>
|
|
1188
|
+
<div class="value">${escape(filedDate)}</div>
|
|
1189
|
+
</div>
|
|
1190
|
+
<div class="byline-block">
|
|
1191
|
+
<div class="label">Authors</div>
|
|
1192
|
+
<div class="value">
|
|
1193
|
+
${members.length} director${members.length === 1 ? "" : "s"}
|
|
1194
|
+
${members.length ? `<div class="signed-avatars">${signed}</div>` : ""}
|
|
1195
|
+
</div>
|
|
1196
|
+
</div>
|
|
1197
|
+
<div class="byline-block">
|
|
1198
|
+
<div class="label">Subject</div>
|
|
1199
|
+
<div class="value">${subjectShort}</div>
|
|
1200
|
+
</div>
|
|
1201
|
+
<div class="byline-block">
|
|
1202
|
+
<div class="label">Doc ID</div>
|
|
1203
|
+
<div class="value" style="font-family: var(--mono); font-size: 12px; letter-spacing: 0.04em;">${escape(docId || "—")}</div>
|
|
1204
|
+
</div>
|
|
1205
|
+
</div>
|
|
1206
|
+
${versionsHtml}
|
|
1207
|
+
</header>
|
|
1208
|
+
|
|
1209
|
+
<article class="body" data-report-body>${renderMarkdown(brief.bodyMd || "_(empty brief)_")}</article>
|
|
1210
|
+
|
|
1211
|
+
<div class="foot-rule">// end of brief · boardroom</div>
|
|
1212
|
+
`;
|
|
1213
|
+
|
|
1214
|
+
// ── Post-render pass · tag specific sections + inline badges so
|
|
1215
|
+
// CSS can apply distinctive treatments. Idempotent and tolerant
|
|
1216
|
+
// to missing sections (a report may skip Frame Shift, etc.).
|
|
1217
|
+
decorateReport(document.querySelector("[data-report-body]"));
|
|
1218
|
+
|
|
1219
|
+
// Render any mermaid blocks. Wrapped in try/catch — a syntax error in
|
|
1220
|
+
// one diagram should not blank the whole report. The mermaid script is
|
|
1221
|
+
// deferred, so it may not be available the instant `load()` resolves;
|
|
1222
|
+
// poll up to 5s before giving up and leaving the raw fenced text in
|
|
1223
|
+
// place as a graceful fallback.
|
|
1224
|
+
if (document.querySelector(".mermaid")) {
|
|
1225
|
+
const mermaid = await new Promise((resolve) => {
|
|
1226
|
+
if (window.mermaid) return resolve(window.mermaid);
|
|
1227
|
+
let elapsed = 0;
|
|
1228
|
+
const t = setInterval(() => {
|
|
1229
|
+
if (window.mermaid) { clearInterval(t); resolve(window.mermaid); }
|
|
1230
|
+
else if ((elapsed += 50) > 5000) { clearInterval(t); resolve(null); }
|
|
1231
|
+
}, 50);
|
|
1232
|
+
});
|
|
1233
|
+
if (mermaid) {
|
|
1234
|
+
try {
|
|
1235
|
+
// Pick mermaid theme variables based on the active spine.
|
|
1236
|
+
// Each spine uses neutral quadrant fills (no colored tints —
|
|
1237
|
+
// all four quadrants share one fill for a clean Gartner / BCG
|
|
1238
|
+
// matrix look) and the spine's accent for plotted points.
|
|
1239
|
+
const spineKey = (brief.spine && SPINES.has(brief.spine)) ? brief.spine : "boardroom-dark";
|
|
1240
|
+
const themes = {
|
|
1241
|
+
"boardroom-dark": {
|
|
1242
|
+
base: "dark",
|
|
1243
|
+
vars: {
|
|
1244
|
+
background: "#131312",
|
|
1245
|
+
quadrantFill: "#1A1A18",
|
|
1246
|
+
quadrantText: "#B6B0A2",
|
|
1247
|
+
pointFill: "#C8C5BE",
|
|
1248
|
+
pointText: "#8E8B83",
|
|
1249
|
+
titleFill: "#C8C5BE",
|
|
1250
|
+
axisText: "#8E8B83",
|
|
1251
|
+
border: "#3A3A35",
|
|
1252
|
+
inner: "#2A2A26",
|
|
1253
|
+
},
|
|
1254
|
+
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
|
|
1255
|
+
},
|
|
1256
|
+
"a16z-thesis": {
|
|
1257
|
+
base: "default",
|
|
1258
|
+
vars: {
|
|
1259
|
+
background: "#F7F3E8",
|
|
1260
|
+
quadrantFill: "#F7F3E8",
|
|
1261
|
+
quadrantText: "#57503F",
|
|
1262
|
+
pointFill: "#14110C",
|
|
1263
|
+
pointText: "#14110C",
|
|
1264
|
+
titleFill: "#14110C",
|
|
1265
|
+
axisText: "#847B65",
|
|
1266
|
+
border: "#BCB39A",
|
|
1267
|
+
inner: "#DCD3BD",
|
|
1268
|
+
},
|
|
1269
|
+
fontFamily: '"Inter", "Helvetica Neue", -apple-system, system-ui, sans-serif',
|
|
1270
|
+
},
|
|
1271
|
+
"anthropic-essay": {
|
|
1272
|
+
base: "default",
|
|
1273
|
+
vars: {
|
|
1274
|
+
background: "#FAF7F0",
|
|
1275
|
+
quadrantFill: "#F4F0E8",
|
|
1276
|
+
quadrantText: "#6B6359",
|
|
1277
|
+
pointFill: "#1A1814",
|
|
1278
|
+
pointText: "#4A4338",
|
|
1279
|
+
titleFill: "#1A1814",
|
|
1280
|
+
axisText: "#978C7E",
|
|
1281
|
+
border: "#DDD5C8",
|
|
1282
|
+
inner: "#E8E1D2",
|
|
1283
|
+
},
|
|
1284
|
+
fontFamily: '"Charter", "Source Serif Pro", "Iowan Old Style", Georgia, serif',
|
|
1285
|
+
},
|
|
1286
|
+
"gartner-note": {
|
|
1287
|
+
base: "default",
|
|
1288
|
+
vars: {
|
|
1289
|
+
background: "#FFFFFF",
|
|
1290
|
+
quadrantFill: "#FAFBFC",
|
|
1291
|
+
quadrantText: "#455364",
|
|
1292
|
+
pointFill: "#0A4DA1",
|
|
1293
|
+
pointText: "#1A2332",
|
|
1294
|
+
titleFill: "#1A2332",
|
|
1295
|
+
axisText: "#6B7785",
|
|
1296
|
+
border: "#BFC8D3",
|
|
1297
|
+
inner: "#DDE2E8",
|
|
1298
|
+
},
|
|
1299
|
+
fontFamily: '"Inter", "Helvetica Neue", Arial, sans-serif',
|
|
1300
|
+
},
|
|
1301
|
+
"mckinsey-deck": {
|
|
1302
|
+
base: "default",
|
|
1303
|
+
vars: {
|
|
1304
|
+
background: "#FFFFFF",
|
|
1305
|
+
quadrantFill: "#FBFCFD",
|
|
1306
|
+
quadrantText: "#4A5870",
|
|
1307
|
+
pointFill: "#2251FF",
|
|
1308
|
+
pointText: "#051C2C",
|
|
1309
|
+
titleFill: "#051C2C",
|
|
1310
|
+
axisText: "#758296",
|
|
1311
|
+
border: "#B8C2D0",
|
|
1312
|
+
inner: "#D5DCE4",
|
|
1313
|
+
},
|
|
1314
|
+
fontFamily: '"Inter", "Helvetica Neue", Arial, sans-serif',
|
|
1315
|
+
},
|
|
1316
|
+
"openai-paper": {
|
|
1317
|
+
base: "default",
|
|
1318
|
+
vars: {
|
|
1319
|
+
background: "#FFFFFF",
|
|
1320
|
+
quadrantFill: "#FAFAFA",
|
|
1321
|
+
quadrantText: "#404040",
|
|
1322
|
+
pointFill: "#10A37F",
|
|
1323
|
+
pointText: "#0D0D0D",
|
|
1324
|
+
titleFill: "#0D0D0D",
|
|
1325
|
+
axisText: "#6E6E80",
|
|
1326
|
+
border: "#E5E5E5",
|
|
1327
|
+
inner: "#EFEFEF",
|
|
1328
|
+
},
|
|
1329
|
+
fontFamily: '"Söhne", "Inter", -apple-system, system-ui, sans-serif',
|
|
1330
|
+
},
|
|
1331
|
+
};
|
|
1332
|
+
const t = themes[spineKey];
|
|
1333
|
+
// themeCSS shapes the rendered SVG beyond what themeVariables
|
|
1334
|
+
// expose. The chart title is hidden because the markdown
|
|
1335
|
+
// already renders an H3 caption directly above each chart —
|
|
1336
|
+
// showing it twice is the single ugliest mermaid default.
|
|
1337
|
+
const themeCSS = `
|
|
1338
|
+
g.quadrant-chart text { font-family: ${t.fontFamily}; }
|
|
1339
|
+
g.quadrant-point > circle, .quadrant-point circle {
|
|
1340
|
+
stroke: ${t.vars.background};
|
|
1341
|
+
stroke-width: 1.5px;
|
|
1342
|
+
}
|
|
1343
|
+
text.quadrant-title { display: none !important; }
|
|
1344
|
+
`;
|
|
1345
|
+
mermaid.initialize({
|
|
1346
|
+
startOnLoad: false,
|
|
1347
|
+
theme: t.base,
|
|
1348
|
+
fontFamily: t.fontFamily,
|
|
1349
|
+
themeCSS,
|
|
1350
|
+
quadrantChart: {
|
|
1351
|
+
chartWidth: 640,
|
|
1352
|
+
chartHeight: 480,
|
|
1353
|
+
// Hide mermaid's own title slot — the H3 above the chart
|
|
1354
|
+
// is the caption. Setting padding/size to 0 keeps the
|
|
1355
|
+
// layout from leaving an empty band at the top.
|
|
1356
|
+
titlePadding: 0,
|
|
1357
|
+
titleFontSize: 0,
|
|
1358
|
+
titleTextMargin: 0,
|
|
1359
|
+
quadrantPadding: 8,
|
|
1360
|
+
quadrantInternalBorderStrokeWidth: 0.5,
|
|
1361
|
+
quadrantExternalBorderStrokeWidth: 1,
|
|
1362
|
+
quadrantLabelFontSize: 12,
|
|
1363
|
+
quadrantTextTopPadding: 12,
|
|
1364
|
+
pointRadius: 6,
|
|
1365
|
+
pointLabelFontSize: 12,
|
|
1366
|
+
pointTextPadding: 8,
|
|
1367
|
+
xAxisLabelFontSize: 13,
|
|
1368
|
+
xAxisLabelPadding: 8,
|
|
1369
|
+
yAxisLabelFontSize: 13,
|
|
1370
|
+
yAxisLabelPadding: 8,
|
|
1371
|
+
xAxisPosition: "bottom",
|
|
1372
|
+
yAxisPosition: "left",
|
|
1373
|
+
},
|
|
1374
|
+
themeVariables: {
|
|
1375
|
+
background: t.vars.background,
|
|
1376
|
+
primaryColor: t.vars.quadrantFill,
|
|
1377
|
+
primaryTextColor: t.vars.titleFill,
|
|
1378
|
+
primaryBorderColor: t.vars.border,
|
|
1379
|
+
lineColor: t.vars.border,
|
|
1380
|
+
secondaryColor: t.vars.quadrantFill,
|
|
1381
|
+
tertiaryColor: t.vars.background,
|
|
1382
|
+
// All 4 quadrants share the same fill — clean matrix.
|
|
1383
|
+
quadrant1Fill: t.vars.quadrantFill,
|
|
1384
|
+
quadrant2Fill: t.vars.quadrantFill,
|
|
1385
|
+
quadrant3Fill: t.vars.quadrantFill,
|
|
1386
|
+
quadrant4Fill: t.vars.quadrantFill,
|
|
1387
|
+
quadrant1TextFill: t.vars.quadrantText,
|
|
1388
|
+
quadrant2TextFill: t.vars.quadrantText,
|
|
1389
|
+
quadrant3TextFill: t.vars.quadrantText,
|
|
1390
|
+
quadrant4TextFill: t.vars.quadrantText,
|
|
1391
|
+
quadrantPointFill: t.vars.pointFill,
|
|
1392
|
+
quadrantPointTextFill: t.vars.pointText,
|
|
1393
|
+
quadrantTitleFill: t.vars.titleFill,
|
|
1394
|
+
quadrantXAxisTextFill: t.vars.axisText,
|
|
1395
|
+
quadrantYAxisTextFill: t.vars.axisText,
|
|
1396
|
+
quadrantInternalBorderStrokeFill: t.vars.inner,
|
|
1397
|
+
quadrantExternalBorderStrokeFill: t.vars.border,
|
|
1398
|
+
},
|
|
1399
|
+
flowchart: { useMaxWidth: true, htmlLabels: true },
|
|
1400
|
+
});
|
|
1401
|
+
await mermaid.run({ querySelector: ".mermaid" });
|
|
1402
|
+
} catch (e) {
|
|
1403
|
+
// Per-diagram failures already render as inline error overlays —
|
|
1404
|
+
// log and move on.
|
|
1405
|
+
console.warn("[mermaid] render failed:", e);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
load();
|
|
1412
|
+
})();
|
|
1413
|
+
</script>
|
|
1414
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js" defer></script>
|
|
1415
|
+
|
|
1416
|
+
</body>
|
|
1417
|
+
</html>
|