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.
- package/dist/cli.js +2060 -183
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/agent-overlay.js +3 -3
- package/public/agent-profile.css +5 -4
- package/public/agent-profile.js +18 -2
- package/public/app.js +513 -26
- package/public/avatar-skill.js +6 -9
- package/public/home.html +1750 -0
- package/public/{prototype-dashboard.html → index.html} +129 -116
- package/public/onboarding.js +4 -4
- package/public/quote-cta.css +225 -0
- package/public/quote-cta.js +355 -0
- package/public/report/spines/a16z-thesis.css +33 -1
- package/public/report/spines/anthropic-essay.css +54 -1
- package/public/report/spines/boardroom-dark.css +18 -2
- package/public/report/spines/gartner-note.css +47 -0
- package/public/report/spines/mckinsey-deck.css +38 -1
- package/public/report/spines/openai-paper.css +37 -1
- package/public/report.html +361 -6
- package/public/room-settings.css +6 -4
- package/public/room-settings.js +8 -5
- package/public/user-settings.css +18 -0
- package/public/user-settings.js +31 -2
|
@@ -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
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
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;
|
|
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
|
-
|
|
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);
|
|
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;
|
|
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); }
|