privateboard 0.1.0 → 0.1.2

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.
@@ -0,0 +1,355 @@
1
+ /* ═══════════════════════════════════════════
2
+ QUOTE CTA · selection-driven follow-up
3
+ ═══════════════════════════════════════════
4
+ When the user selects text inside a director's message bubble,
5
+ a small floating bar appears above the selection with two
6
+ actions:
7
+
8
+ ✎ Probe / 追问 → opens an overlay; user types a question;
9
+ submits a user message that quotes the
10
+ selection (markdown blockquote) above the
11
+ question. Routes through the existing send
12
+ path: idle → send, mid-turn → interrupt-or-
13
+ queue choice modal, paused → server queues
14
+ for next round.
15
+
16
+ ★ Second / 附议 → one-click; submits the same shape with a
17
+ fixed parliamentary "Seconded." line below
18
+ the quote, signalling the user co-signs the
19
+ director's point. Same routing.
20
+
21
+ Director scope · selection only counts when both ends sit inside
22
+ the same `article.msg` whose class is neither `user` nor `chair`.
23
+
24
+ No backend changes · everything rides on existing /api/rooms/:id/
25
+ messages POST and the markdown blockquote renderer in app.js.
26
+ */
27
+ (function () {
28
+ let cta = null; // floating button bar
29
+ let lastSelection = null; // { text, directorId, directorName } — captured on showCTA
30
+
31
+ // ── Selection scope · is the selection inside a director bubble? ──
32
+ function getDirectorContext() {
33
+ const sel = window.getSelection ? window.getSelection() : null;
34
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
35
+ const text = sel.toString().trim();
36
+ if (text.length < 4) return null;
37
+ const range = sel.getRangeAt(0);
38
+ const anchor = sel.anchorNode;
39
+ const focus = sel.focusNode;
40
+ if (!anchor || !focus) return null;
41
+ const elFor = (n) => (n.nodeType === Node.ELEMENT_NODE ? n : n.parentElement);
42
+ const anchorEl = elFor(anchor);
43
+ const focusEl = elFor(focus);
44
+ if (!anchorEl || !focusEl) return null;
45
+ const bubble = anchorEl.closest(".msg-bubble");
46
+ if (!bubble) return null;
47
+ const article = bubble.closest("article.msg");
48
+ if (!article) return null;
49
+ // Reject user / chair messages — feature is director-only.
50
+ if (article.classList.contains("user") || article.classList.contains("chair")) return null;
51
+ // Both ends must be in the same message · cross-message selections
52
+ // make no sense for "quote this passage from director X".
53
+ if (focusEl.closest("article.msg") !== article) return null;
54
+
55
+ // Director identity · the article carries data-author-id for
56
+ // director bubbles (added in app.js messageHtml). Name comes from
57
+ // the visible .msg-name span in the same message header.
58
+ const directorId = article.dataset.authorId || "";
59
+ const nameEl = article.querySelector(".msg-name");
60
+ const directorName = nameEl ? nameEl.textContent.trim() : "";
61
+ // Adjourned rooms · CTA still shows but in a read-only state with
62
+ // a hint instead of buttons. Surfacing the bar (rather than
63
+ // silently doing nothing) tells the user "your selection was
64
+ // detected" and explains why probe / second aren't available.
65
+ const app = window.app;
66
+ const adjourned = !!(app && app.currentRoom && app.currentRoom.status === "adjourned");
67
+ return { article, bubble, range, text, directorId, directorName, adjourned };
68
+ }
69
+
70
+ function lang() {
71
+ try {
72
+ if (window.app && typeof window.app.composerLanguage === "function") {
73
+ return window.app.composerLanguage();
74
+ }
75
+ } catch { /* */ }
76
+ return "en";
77
+ }
78
+
79
+ // ── Floating CTA bar ─────────────────────────────────────────
80
+ function ensureCTA() {
81
+ if (cta) return cta;
82
+ cta = document.createElement("div");
83
+ cta.className = "qcta";
84
+ cta.setAttribute("role", "toolbar");
85
+ const t = lang() === "zh"
86
+ ? { ask: "追问", love: "附议" }
87
+ : { ask: "Probe", love: "Second" };
88
+ // Inline chat-bubble SVG · uses currentColor so it inherits the
89
+ // hover lime / base text colour like the ★ glyph does.
90
+ const askIcon = `
91
+ <svg viewBox="0 0 14 14" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="miter" stroke-linecap="square" aria-hidden="true">
92
+ <path d="M2 3 H12 V9 H6.5 L4 11 L4 9 H2 Z"/>
93
+ </svg>
94
+ `;
95
+ cta.innerHTML = `
96
+ <button type="button" class="qcta-btn" data-qcta="ask">
97
+ <span class="ico">${askIcon}</span><span>${t.ask}</span>
98
+ </button>
99
+ <button type="button" class="qcta-btn" data-qcta="second">
100
+ <span class="ico">★</span><span>${t.love}</span>
101
+ </button>
102
+ <span class="qcta-hint" data-qcta-hint></span>
103
+ `;
104
+ // Prevent the bar's mousedown from collapsing the selection BEFORE
105
+ // the click reaches us — Chrome/Safari clear the selection on a
106
+ // click outside the active range, which would defeat the bar.
107
+ cta.addEventListener("mousedown", (e) => e.preventDefault());
108
+ cta.addEventListener("click", (e) => {
109
+ const btn = e.target.closest("[data-qcta]");
110
+ if (!btn) return;
111
+ // Read-only state · the bar shows a hint about why instead of
112
+ // doing anything. Bail before hideCTA so the user can keep
113
+ // reading the hint while their selection stays.
114
+ if (cta.classList.contains("qcta-readonly")) return;
115
+ const action = btn.getAttribute("data-qcta");
116
+ const sel = lastSelection;
117
+ hideCTA();
118
+ if (!sel || !sel.text) return;
119
+ if (action === "ask") openAskOverlay(sel);
120
+ else if (action === "second") submitSecond(sel);
121
+ });
122
+ document.body.appendChild(cta);
123
+ return cta;
124
+ }
125
+
126
+ function showCTA(ctx) {
127
+ const bar = ensureCTA();
128
+ lastSelection = {
129
+ text: ctx.text,
130
+ directorId: ctx.directorId,
131
+ directorName: ctx.directorName,
132
+ };
133
+ // Read-only state · adjourned room. Hide buttons, show hint text
134
+ // so the user knows the selection was detected but the room is
135
+ // closed for new replies.
136
+ bar.classList.toggle("qcta-readonly", !!ctx.adjourned);
137
+ const hint = bar.querySelector("[data-qcta-hint]");
138
+ if (hint) {
139
+ hint.textContent = ctx.adjourned
140
+ ? (lang() === "zh" ? "// 已结束的房间 · 只读" : "// adjourned · read-only")
141
+ : "";
142
+ }
143
+ const rect = ctx.range.getBoundingClientRect();
144
+ if (rect.width === 0 && rect.height === 0) return;
145
+ // Render first so we can measure width.
146
+ bar.classList.add("open");
147
+ const barWidth = bar.offsetWidth;
148
+ const barHeight = bar.offsetHeight;
149
+ let top = window.scrollY + rect.top - barHeight - 8;
150
+ if (top < window.scrollY + 4) {
151
+ // Not enough room above · drop below the selection.
152
+ top = window.scrollY + rect.bottom + 8;
153
+ }
154
+ let left = window.scrollX + rect.left + rect.width / 2 - barWidth / 2;
155
+ left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - barWidth - 8));
156
+ bar.style.top = top + "px";
157
+ bar.style.left = left + "px";
158
+ }
159
+
160
+ function hideCTA() {
161
+ if (cta) cta.classList.remove("open");
162
+ }
163
+
164
+ function refresh() {
165
+ // Skip when the ask overlay is open — don't stack a CTA on top
166
+ // of the modal's own selection.
167
+ if (document.getElementById("qask-overlay")) { hideCTA(); return; }
168
+ const ctx = getDirectorContext();
169
+ if (!ctx) { hideCTA(); return; }
170
+ showCTA(ctx);
171
+ }
172
+
173
+ // Show only AFTER the user finishes selecting · hide as soon as a
174
+ // new mousedown begins so the bar doesn't track mid-drag. Skip the
175
+ // mousedown-hide when the click originates inside the CTA itself
176
+ // (otherwise clicking a button hides the bar before the click
177
+ // handler can read the action).
178
+ document.addEventListener("mousedown", (e) => {
179
+ if (cta && cta.contains(e.target)) return;
180
+ hideCTA();
181
+ });
182
+ document.addEventListener("mouseup", () => {
183
+ // Defer one tick so the browser has finished updating the
184
+ // selection before we measure it.
185
+ requestAnimationFrame(refresh);
186
+ });
187
+
188
+ // Hide on scroll · the absolute-positioned bar would otherwise
189
+ // float in stale coords as the chat pane scrolls.
190
+ window.addEventListener("scroll", hideCTA, true);
191
+ document.addEventListener("keydown", (e) => {
192
+ if (e.key === "Escape") hideCTA();
193
+ });
194
+
195
+ // ── Ask-follow-up overlay ────────────────────────────────────
196
+ function openAskOverlay(sel) {
197
+ closeAskOverlay();
198
+ const dirName = sel.directorName || "director";
199
+ const t = lang() === "zh"
200
+ ? {
201
+ tag: "▸ 追问",
202
+ quoteTag: "// 引自 " + dirName,
203
+ placeholder: "把你想问的写在这里 · 回车发送,Shift+Enter 换行",
204
+ send: "发送",
205
+ cancel: "取消",
206
+ status: "选区追问 · " + dirName,
207
+ }
208
+ : {
209
+ tag: "▸ Probe",
210
+ quoteTag: "// quoting " + dirName,
211
+ placeholder: "Type your follow-up · Enter to send, Shift+Enter for newline",
212
+ send: "Send",
213
+ cancel: "Cancel",
214
+ status: "selection · probing " + dirName,
215
+ };
216
+ const overlay = document.createElement("div");
217
+ overlay.className = "qask-overlay";
218
+ overlay.id = "qask-overlay";
219
+ overlay.innerHTML = `
220
+ <div class="qask-modal" role="dialog" aria-modal="true">
221
+ <div class="qask-classification">
222
+ <span><span class="dot">●</span> ${t.status}</span>
223
+ <span class="right">${t.tag}</span>
224
+ </div>
225
+ <div class="qask-body">
226
+ <div class="qask-quote">
227
+ <div class="qask-quote-tag">${t.quoteTag}</div>
228
+ <div class="qask-quote-body" data-qask-quote></div>
229
+ </div>
230
+ <div class="qask-input-wrap">
231
+ <textarea class="qask-input" data-qask-input rows="3" placeholder="${escapeAttr(t.placeholder)}"></textarea>
232
+ </div>
233
+ </div>
234
+ <div class="qask-foot">
235
+ <button type="button" class="qask-btn" data-qask-cancel>${t.cancel}</button>
236
+ <button type="button" class="qask-btn primary" data-qask-send disabled>${t.send}</button>
237
+ </div>
238
+ </div>
239
+ `;
240
+ document.body.appendChild(overlay);
241
+ overlay.querySelector("[data-qask-quote]").textContent = sel.text;
242
+ const input = overlay.querySelector("[data-qask-input]");
243
+ const sendBtn = overlay.querySelector("[data-qask-send]");
244
+ setTimeout(() => input.focus(), 30);
245
+ input.addEventListener("input", () => {
246
+ sendBtn.disabled = input.value.trim().length === 0;
247
+ });
248
+ input.addEventListener("keydown", (e) => {
249
+ if (e.key === "Enter" && !e.shiftKey && !isImeComposing(e)) {
250
+ e.preventDefault();
251
+ if (!sendBtn.disabled) sendBtn.click();
252
+ } else if (e.key === "Escape") {
253
+ e.preventDefault();
254
+ closeAskOverlay();
255
+ }
256
+ });
257
+ overlay.addEventListener("click", (e) => {
258
+ if (e.target === overlay) closeAskOverlay();
259
+ });
260
+ overlay.querySelector("[data-qask-cancel]").addEventListener("click", closeAskOverlay);
261
+ sendBtn.addEventListener("click", () => {
262
+ const text = input.value.trim();
263
+ if (!text) return;
264
+ closeAskOverlay();
265
+ submitProbe(sel, text);
266
+ });
267
+ }
268
+
269
+ function closeAskOverlay() {
270
+ const el = document.getElementById("qask-overlay");
271
+ if (el) el.remove();
272
+ }
273
+
274
+ function isImeComposing(e) {
275
+ return !!(e.isComposing || (e.nativeEvent && e.nativeEvent.isComposing) || e.keyCode === 229);
276
+ }
277
+
278
+ function escapeAttr(s) {
279
+ return String(s).replace(/[&<>"']/g, (c) => ({
280
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
281
+ }[c]));
282
+ }
283
+
284
+ // ── Submit ──────────────────────────────────────────────────
285
+ // Quote prefix · markdown blockquote, one "> " per line so multi-
286
+ // line selections stay inside the quote when rendered by app.js's
287
+ // markdown-ish renderBody. The attribution line (`> — @Director`)
288
+ // sits inside the same blockquote so chair / readers see at a
289
+ // glance who the user is quoting.
290
+ function quoteBlock(text, directorName) {
291
+ const lines = text.split(/\r?\n/).map((line) => "> " + line);
292
+ if (directorName) lines.push("> — @" + directorName);
293
+ return lines.join("\n");
294
+ }
295
+
296
+ function submitSecond(sel) {
297
+ // Parliamentary acknowledgement · "I second this." · short and
298
+ // ceremonial, matches the boardroom motif. No mentions array — a
299
+ // second is a passive signal, not a question; the room continues
300
+ // its normal cadence rather than forcing the seconded director
301
+ // to immediately speak again.
302
+ const reaction = lang() === "zh" ? "附议。" : "Seconded.";
303
+ const body = quoteBlock(sel.text, sel.directorName) + "\n\n" + reaction;
304
+ routeSend(body, []);
305
+ }
306
+
307
+ function submitProbe(sel, userText) {
308
+ // Probe targets the quoted director · putting their id first in
309
+ // mentions makes them the forced speaker for the next tick (per
310
+ // tickRoom in src/orchestrator/room.ts), so the user's follow-up
311
+ // gets answered by the right voice, not whoever's next in the
312
+ // round-robin.
313
+ const body = quoteBlock(sel.text, sel.directorName) + "\n\n" + userText;
314
+ const mentions = sel.directorId ? [sel.directorId] : [];
315
+ routeSend(body, mentions);
316
+ }
317
+
318
+ /** Routing matrix:
319
+ * paused → auto-resume the room first, then send
320
+ * live + agent mid-turn → open the interrupt-or-queue modal
321
+ * live + idle → send straight through
322
+ *
323
+ * Auto-resume on paused matches the user intent: they wrote a
324
+ * follow-up, they expect it to land. The server rejects POST
325
+ * /messages on paused rooms (409 "room is not live"), so without
326
+ * this the probe button silently fails on every paused room. */
327
+ async function routeSend(body, mentions) {
328
+ const app = window.app;
329
+ if (!app || typeof app.sendMessage !== "function") return;
330
+ const ms = Array.isArray(mentions) ? mentions : [];
331
+ const status = app.currentRoom && app.currentRoom.status;
332
+ try {
333
+ if (status === "paused" && typeof app.resumeRoom === "function") {
334
+ await app.resumeRoom();
335
+ }
336
+ } catch (err) {
337
+ alert("Couldn't resume the room: " + (err && err.message ? err.message : err));
338
+ return;
339
+ }
340
+ if (typeof app.isAgentSpeaking === "function" && app.isAgentSpeaking() && !app.pendingUserMessage) {
341
+ if (typeof app.openSendChoiceModal === "function") {
342
+ // openSendChoiceModal currently doesn't carry a mentions array
343
+ // — the existing modal was built for the plain composer where
344
+ // mentions are inferred from "@" tokens in the text. Our
345
+ // attributed body already contains "@Director" inline, so the
346
+ // server's text-based @mention path will catch it.
347
+ app.openSendChoiceModal(body);
348
+ return;
349
+ }
350
+ }
351
+ app.sendMessage(body, ms).catch((err) => {
352
+ alert("Send failed: " + (err && err.message ? err.message : err));
353
+ });
354
+ }
355
+ })();
@@ -248,7 +248,6 @@ html, body {
248
248
  font-size: 24px;
249
249
  line-height: 1.24;
250
250
  margin: 0 0 18px;
251
- max-width: 720px;
252
251
  letter-spacing: -0.018em;
253
252
  font-weight: 600;
254
253
  }
@@ -1053,3 +1052,36 @@ html, body {
1053
1052
  font-size: 11px;
1054
1053
  }
1055
1054
  .placeholder.error { color: var(--oxblood); }
1055
+
1056
+ /* ─── Metric strip · per-spine treatment ─────────────────────────
1057
+ Inherits the spine-agnostic baseline in public/report.html's inline
1058
+ <style>; this block tweaks surface, accent + typography to match
1059
+ the spine's voice. No `border-left`, no parallel borders against
1060
+ adjacent .chapter-num / section h2. */
1061
+ .body .metric-strip-intro {
1062
+ color: var(--ink-soft);
1063
+ font-family: var(--mono);
1064
+ font-size: 11px;
1065
+ }
1066
+ .body .metric-card {
1067
+ background: var(--gold-pale, rgba(201, 164, 107, 0.06));
1068
+ border: 0.5px solid var(--gold-soft, rgba(201, 164, 107, 0.18));
1069
+ padding: 16px 18px;
1070
+ min-height: 104px;
1071
+ }
1072
+ .body .metric-label {
1073
+ color: var(--gold-deep);
1074
+ font-weight: 700;
1075
+ }
1076
+ .body .metric-value {
1077
+ color: var(--ink);
1078
+ font-size: 30px;
1079
+ font-weight: 800;
1080
+ letter-spacing: -0.015em;
1081
+ }
1082
+ .body .metric-card[data-trend="up"] .metric-value { color: var(--gold-deep); }
1083
+ .body .metric-card[data-trend="down"] .metric-value { color: var(--oxblood-deep); }
1084
+ .body .metric-trend[data-trend="up"] { color: var(--gold-deep); }
1085
+ .body .metric-trend[data-trend="down"] { color: var(--oxblood-deep); }
1086
+ .body .metric-qualifier { color: var(--ink-soft); }
1087
+ .body .metric-attribution { color: var(--ink-faint); letter-spacing: 0.08em; }
@@ -179,7 +179,7 @@ html, body {
179
179
  text-transform: uppercase;
180
180
  margin: 56px 0 12px;
181
181
  }
182
- .body h2 { font-size: 30px; line-height: 1.22; margin: 0 0 18px; max-width: 700px; font-style: italic; }
182
+ .body h2 { font-size: 30px; line-height: 1.22; margin: 0 0 18px; font-style: italic; }
183
183
  .body h3 { font-size: 21px; margin: 28px 0 10px; font-style: italic; }
184
184
  .body h4 { font-size: 14px; margin: 18px 0 6px; text-transform: none; letter-spacing: 0; font-style: italic; color: var(--ink-mid); }
185
185
  .body p { margin: 14px 0; line-height: 1.72; color: var(--ink-soft); font-size: 18px; }
@@ -554,3 +554,56 @@ html, body {
554
554
  line-height: 1; font-family: var(--serif);
555
555
  }
556
556
  .nq-why { font-family: var(--serif); font-size: 17px; line-height: 1.72; color: var(--ink-soft); margin: 0; max-width: 56ch; }
557
+
558
+ /* ─── Metric strip · per-spine treatment ─────────────────────────
559
+ Inherits the spine-agnostic baseline in public/report.html's inline
560
+ <style>; this block tweaks surface, accent + typography to match
561
+ the spine's voice. No `border-left`, no parallel borders against
562
+ adjacent .chapter-num / section h2. */
563
+ .body .metric-strip { margin: 26px 0 30px; }
564
+ .body .metric-strip-intro {
565
+ color: var(--ink-mid);
566
+ font-family: "Charter", "Georgia", serif;
567
+ font-style: italic;
568
+ text-transform: none;
569
+ letter-spacing: 0;
570
+ font-size: 13px;
571
+ }
572
+ /* No card borders · paper-on-paper feel. The cards lift via a tiny
573
+ warmer surface tint and generous padding instead. */
574
+ .body .metric-card {
575
+ background: var(--paper-soft);
576
+ border: none;
577
+ padding: 18px 20px 14px;
578
+ min-height: 96px;
579
+ }
580
+ .body .metric-label {
581
+ color: var(--clay-deep);
582
+ font-family: "Charter", "Georgia", serif;
583
+ font-style: italic;
584
+ font-weight: 500;
585
+ text-transform: none;
586
+ letter-spacing: 0;
587
+ font-size: 12px;
588
+ }
589
+ .body .metric-value {
590
+ color: var(--ink);
591
+ font-family: "Charter", "Georgia", "Songti SC", serif;
592
+ font-size: 32px;
593
+ font-weight: 600;
594
+ letter-spacing: -0.02em;
595
+ }
596
+ .body .metric-card[data-trend="up"] .metric-value { color: var(--sage-deep, #4F6F4A); }
597
+ .body .metric-card[data-trend="down"] .metric-value { color: var(--clay-deep); }
598
+ .body .metric-trend[data-trend="up"] { color: var(--sage-deep, #4F6F4A); }
599
+ .body .metric-trend[data-trend="down"] { color: var(--clay-deep); }
600
+ .body .metric-qualifier {
601
+ color: var(--ink-mid);
602
+ font-family: "Charter", "Georgia", serif;
603
+ font-style: italic;
604
+ }
605
+ .body .metric-attribution {
606
+ color: var(--ink-faint);
607
+ font-family: var(--mono);
608
+ letter-spacing: 0.1em;
609
+ }
@@ -25,7 +25,7 @@
25
25
  --lime-dim: #5C4422;
26
26
  --red: #B5706A;
27
27
  /* Warm gold for italic accents in headlines (mirrors the Anthropic
28
- prototype's `clay-deep` use). Used only on `em` inside h2/h3/rec
28
+ spine's `clay-deep` use). Used only on `em` inside h2/h3/rec
29
29
  action / nq question / etc. — never as a decorative fill. */
30
30
  --em: #C9A46B;
31
31
  --em-deep: #B58950;
@@ -315,7 +315,6 @@ html, body {
315
315
  margin: 0 0 22px;
316
316
  padding: 0;
317
317
  border: none;
318
- max-width: 760px;
319
318
  letter-spacing: -0.018em;
320
319
  }
321
320
  .body h2::before { display: none; }
@@ -1080,3 +1079,20 @@ html, body {
1080
1079
  font-size: 11px;
1081
1080
  }
1082
1081
  .placeholder.error { color: var(--red); }
1082
+
1083
+ /* ─── Metric strip · per-spine treatment ─────────────────────────
1084
+ Inherits the spine-agnostic baseline in public/report.html's inline
1085
+ <style>; this block tweaks surface, accent + typography to match
1086
+ the spine's voice. No `border-left`, no parallel borders against
1087
+ adjacent .chapter-num / section h2. */
1088
+ .body .metric-strip-intro { color: var(--text-soft); }
1089
+ .body .metric-card {
1090
+ background: var(--panel-2);
1091
+ border-color: var(--line-bright);
1092
+ }
1093
+ .body .metric-value { color: var(--text); }
1094
+ .body .metric-card[data-trend="up"] .metric-value { color: var(--em); }
1095
+ .body .metric-card[data-trend="down"] .metric-value { color: var(--red); }
1096
+ .body .metric-trend[data-trend="up"] { color: var(--em); }
1097
+ .body .metric-trend[data-trend="down"] { color: var(--red); }
1098
+ .body .metric-label { color: var(--accent); }
@@ -536,3 +536,50 @@ html, body {
536
536
  line-height: 1; font-family: Georgia, serif;
537
537
  }
538
538
  .nq-why { font-size: 14.5px; line-height: 1.65; color: var(--ink-soft); margin: 0; max-width: 60ch; }
539
+
540
+ /* ─── Metric strip · per-spine treatment ─────────────────────────
541
+ Inherits the spine-agnostic baseline in public/report.html's inline
542
+ <style>; this block tweaks surface, accent + typography to match
543
+ the spine's voice. No `border-left`, no parallel borders against
544
+ adjacent .chapter-num / section h2. */
545
+ /* Dense table-like treatment · 4-up grid by default, monospace
546
+ everywhere, the value is primary but disciplined (not display-sized). */
547
+ .body .metric-strip-grid {
548
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
549
+ gap: 1px;
550
+ background: var(--rule, rgba(0, 0, 0, 0.08));
551
+ border: 0.5px solid var(--rule-soft, rgba(0, 0, 0, 0.12));
552
+ }
553
+ .body .metric-strip-intro {
554
+ color: var(--ink-soft);
555
+ font-family: var(--mono);
556
+ border-bottom: 0.5px solid var(--rule-soft, rgba(0, 0, 0, 0.12));
557
+ padding-bottom: 6px;
558
+ margin-bottom: 0;
559
+ }
560
+ .body .metric-card {
561
+ background: var(--bg);
562
+ border: none;
563
+ padding: 12px 14px;
564
+ min-height: 84px;
565
+ gap: 4px;
566
+ }
567
+ .body .metric-label {
568
+ color: var(--brand-deep);
569
+ font-family: var(--mono);
570
+ font-size: 9.5px;
571
+ font-weight: 700;
572
+ }
573
+ .body .metric-value {
574
+ color: var(--ink);
575
+ font-family: var(--mono);
576
+ font-size: 22px;
577
+ font-weight: 700;
578
+ letter-spacing: -0.01em;
579
+ }
580
+ .body .metric-card[data-trend="up"] .metric-value { color: var(--green); }
581
+ .body .metric-card[data-trend="down"] .metric-value { color: var(--brand-deep); }
582
+ .body .metric-trend[data-trend="up"] { color: var(--green); }
583
+ .body .metric-trend[data-trend="down"] { color: var(--brand-deep); }
584
+ .body .metric-qualifier { color: var(--ink-soft); font-family: var(--mono); font-size: 11px; }
585
+ .body .metric-attribution { color: var(--ink-faint); }
@@ -172,7 +172,7 @@ html, body {
172
172
  text-transform: uppercase;
173
173
  margin: 56px 0 8px;
174
174
  }
175
- .body h2 { font-size: 28px; line-height: 1.2; margin: 0 0 16px; padding-bottom: 12px; border-bottom: 2px solid var(--navy); max-width: 820px; }
175
+ .body h2 { font-size: 28px; line-height: 1.2; margin: 0 0 16px; padding-bottom: 12px; border-bottom: 2px solid var(--navy); }
176
176
  .body h3 { font-size: 19px; margin: 28px 0 10px; }
177
177
  .body h4 { font-size: 13px; margin: 18px 0 8px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-soft); }
178
178
  .body p { margin: 12px 0; line-height: 1.65; color: var(--ink-soft); font-size: 15px; }
@@ -521,3 +521,40 @@ html, body {
521
521
  line-height: 1; font-family: Georgia, serif;
522
522
  }
523
523
  .nq-why { font-size: 15px; line-height: 1.7; color: var(--ink-soft); margin: 0; max-width: 60ch; }
524
+
525
+ /* ─── Metric strip · per-spine treatment ─────────────────────────
526
+ Inherits the spine-agnostic baseline in public/report.html's inline
527
+ <style>; this block tweaks surface, accent + typography to match
528
+ the spine's voice. No `border-left`, no parallel borders against
529
+ adjacent .chapter-num / section h2. */
530
+ /* Clean white card with a thin top accent rule (navy) — boardroom
531
+ deck feel. Top accent only (project rule: never `border-left` as
532
+ callout treatment). */
533
+ .body .metric-strip-intro {
534
+ color: var(--ink-soft);
535
+ font-family: var(--mono);
536
+ }
537
+ .body .metric-card {
538
+ background: #FFFFFF;
539
+ border: 0.5px solid var(--rule-soft, rgba(0, 0, 0, 0.08));
540
+ border-top: 2px solid var(--blue);
541
+ padding: 16px 18px 14px;
542
+ }
543
+ .body .metric-label {
544
+ color: var(--ink-soft);
545
+ font-family: var(--mono);
546
+ }
547
+ .body .metric-value {
548
+ color: var(--navy-deep, var(--blue-deep));
549
+ font-family: "SF Mono", "JetBrains Mono", "Helvetica Neue", "Songti SC", monospace;
550
+ font-size: 28px;
551
+ font-weight: 700;
552
+ }
553
+ .body .metric-card[data-trend="up"] { border-top-color: var(--green, #1E8E3E); }
554
+ .body .metric-card[data-trend="up"] .metric-value { color: var(--green, #1E8E3E); }
555
+ .body .metric-card[data-trend="down"] { border-top-color: var(--red, #C5221F); }
556
+ .body .metric-card[data-trend="down"] .metric-value { color: var(--red, #C5221F); }
557
+ .body .metric-trend[data-trend="up"] { color: var(--green, #1E8E3E); }
558
+ .body .metric-trend[data-trend="down"] { color: var(--red, #C5221F); }
559
+ .body .metric-qualifier { color: var(--ink-mid); font-size: 12px; }
560
+ .body .metric-attribution { color: var(--ink-faint); }
@@ -164,7 +164,7 @@ html, body {
164
164
  text-transform: none;
165
165
  margin: 56px 0 8px;
166
166
  }
167
- .body h2 { font-size: 25px; line-height: 1.22; margin: 0 0 16px; max-width: 720px; }
167
+ .body h2 { font-size: 25px; line-height: 1.22; margin: 0 0 16px; }
168
168
  .body h3 { font-size: 18px; margin: 28px 0 10px; font-weight: 600; }
169
169
  .body h4 { font-size: 13.5px; margin: 18px 0 6px; text-transform: none; letter-spacing: 0; color: var(--ink-soft); font-weight: 600; }
170
170
  .body p { margin: 12px 0; line-height: 1.7; color: var(--ink-soft); font-size: 16px; }
@@ -514,3 +514,39 @@ html, body {
514
514
  line-height: 1; font-family: Georgia, serif;
515
515
  }
516
516
  .nq-why { font-size: 15.5px; line-height: 1.72; color: var(--ink-soft); margin: 0; max-width: 58ch; }
517
+
518
+ /* ─── Metric strip · per-spine treatment ─────────────────────────
519
+ Inherits the spine-agnostic baseline in public/report.html's inline
520
+ <style>; this block tweaks surface, accent + typography to match
521
+ the spine's voice. No `border-left`, no parallel borders against
522
+ adjacent .chapter-num / section h2. */
523
+ /* Papery research-note feel · sans label, slab-serif value, square
524
+ corners, no card borders — uses surface tint to delineate. */
525
+ .body .metric-strip-intro {
526
+ color: var(--ink-soft);
527
+ font-family: var(--sans);
528
+ }
529
+ .body .metric-card {
530
+ background: var(--soft, rgba(0, 0, 0, 0.025));
531
+ border: none;
532
+ border-top: 1px solid var(--rule, rgba(0, 0, 0, 0.12));
533
+ padding: 14px 16px 12px;
534
+ }
535
+ .body .metric-label {
536
+ color: var(--teal-deep);
537
+ font-family: var(--sans);
538
+ font-weight: 600;
539
+ }
540
+ .body .metric-value {
541
+ color: var(--ink);
542
+ font-family: "Charter", "Times New Roman", "Songti SC", serif;
543
+ font-size: 30px;
544
+ font-weight: 700;
545
+ letter-spacing: -0.015em;
546
+ }
547
+ .body .metric-card[data-trend="up"] .metric-value { color: var(--teal-deep); }
548
+ .body .metric-card[data-trend="down"] .metric-value { color: var(--orange); }
549
+ .body .metric-trend[data-trend="up"] { color: var(--teal-deep); }
550
+ .body .metric-trend[data-trend="down"] { color: var(--orange); }
551
+ .body .metric-qualifier { color: var(--ink-mid); font-family: var(--sans); }
552
+ .body .metric-attribution { color: var(--ink-faint); }