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.
@@ -60,7 +60,18 @@
60
60
  -webkit-print-color-adjust: exact !important;
61
61
  box-shadow: none !important;
62
62
  }
63
- @page { size: A4; margin: 14mm 12mm; }
63
+
64
+ /* Edge-to-edge backgrounds in PDF · zero @page margin so the
65
+ body's paper colour fills every pixel of the page. The visible
66
+ gutter is recreated via body padding so existing per-section
67
+ paddings (cover / chapters / methodology) keep working
68
+ unchanged. Without this, A4 PDFs of light-paper spines
69
+ (anthropic-essay #F4F0E8 / a16z-thesis #F7F3E8 / cream-printed
70
+ boardroom-dark #FAF7F0) showed a jarring white perimeter
71
+ outside a tan content rectangle — the user's "why is there
72
+ white around my yellow report" complaint. */
73
+ @page { size: A4; margin: 0; }
74
+ body { padding: 14mm 12mm !important; }
64
75
 
65
76
  /* Strip browser chrome from the PDF. */
66
77
  .top-rule,
@@ -72,9 +83,16 @@
72
83
  display: none !important;
73
84
  }
74
85
 
75
- /* Page-frame backgrounds the html element is what fills the
76
- paper outside the body's box. Force white universally. */
86
+ /* Page-frame background · matches body's printed colour per spine
87
+ so any PDF engine that paints html beyond body's box (some
88
+ legacy print pipelines) stays coherent. Modern Chrome with
89
+ @page margin: 0 fills the page from body alone, but this is
90
+ belt-and-braces. Default white; per-spine `:has()` selectors
91
+ below override for the light-paper spines. */
77
92
  html { background: #FFFFFF !important; }
93
+ html:has(body[data-spine="anthropic-essay"]) { background: #F4F0E8 !important; }
94
+ html:has(body[data-spine="a16z-thesis"]) { background: #F7F3E8 !important; }
95
+ html:has(body[data-spine="boardroom-dark"]) { background: #FAF7F0 !important; }
78
96
 
79
97
  /* Container resets so .doc fills the printable area instead of
80
98
  being clipped at its 880px on-screen max-width. */
@@ -199,14 +217,112 @@
199
217
  .body section.section-the-bet > ol > li,
200
218
  .body section.section-new-questions li.nq-item,
201
219
  .body pre.mermaid,
202
- .body table.md-table {
220
+ .body table.md-table,
221
+ /* Tier A additions · the units that were still getting sliced
222
+ between pages despite the legacy ruleset. Each one is a small
223
+ atomic visual block that deserves to land whole. The strip
224
+ parent IS in this list (intro + 3-5 cards typically fits on
225
+ one page); the grid itself is NOT — under Tier B the grid is
226
+ block-flowed in print, so individual `.metric-card` atoms are
227
+ what we actually want unbreakable. Listing the grid here would
228
+ force ALL cards onto the same page even when they overflow,
229
+ leaving large dead space at page bottoms. */
230
+ .body .metric-strip, /* intro + grid as one unit */
231
+ .body blockquote, /* convergence / SPA / pull-quotes */
232
+ .body ol > li, .body ul > li { /* tighter than the per-section li rules above */
203
233
  break-inside: avoid;
204
234
  page-break-inside: avoid;
205
235
  }
236
+ /* Strip intro stays glued to the first card · lone "Three numbers
237
+ worth pricing in" titles wandering to a previous page bottom
238
+ look broken even when individual cards are intact. */
239
+ .body .metric-strip-intro {
240
+ break-after: avoid;
241
+ page-break-after: avoid;
242
+ }
243
+ /* Glue rules · keep heading + first content together. CSS only
244
+ has break-after: avoid, which says "no break right after this
245
+ element" — so applying it to the first content element after a
246
+ heading also makes that pair atomic. We pair them with the
247
+ heading rule below for redundancy. */
206
248
  .body h2, .body h3, .body h4 {
207
249
  break-after: avoid;
208
250
  page-break-after: avoid;
209
251
  }
252
+ /* Don't let a heading land alone in the page footer with its
253
+ paragraph / table on the next page. The adjacent-sibling
254
+ selector matches the FIRST element after the heading and
255
+ refuses to break before it · effectively keeps "heading +
256
+ opener" as one unit. */
257
+ .body section > h2 + p,
258
+ .body section > h2 + blockquote,
259
+ .body section > h2 + table.md-table,
260
+ .body section > h2 + .metric-strip,
261
+ .body section > h3 + p,
262
+ .body section > h3 + blockquote,
263
+ .body section > h3 + table.md-table {
264
+ break-before: avoid;
265
+ page-break-before: avoid;
266
+ }
267
+ /* Paragraph-level orphan / widow control · a paragraph isn't
268
+ allowed to leave only 1-2 lines on either side of a page
269
+ break. Cheapest typographic discipline; massive effect on the
270
+ feel of multi-page reports. */
271
+ .body { orphans: 3; widows: 3; }
272
+
273
+ /* ─── Tier B · CSS Grid → flex-wrap in print ────────────────
274
+ Chrome's PDF engine notoriously ignores `break-inside: avoid`
275
+ on direct children of a `display: grid` container — but it
276
+ DOES honour the same hint on flex children. The earlier fix
277
+ collapsed both the Headline-Findings pillar grid and the
278
+ metric-strip card grid to `display: block` for safety, which
279
+ worked but stacked everything vertically — the user reported
280
+ the PDF then looks nothing like the on-screen side-by-side
281
+ layout. Switching to flex-wrap keeps the side-by-side cards
282
+ visible in print AND lets the per-card `break-inside: avoid`
283
+ (defined later in this @media block for `.metric-card`) hold,
284
+ so a single card never gets sliced across pages. The on-screen
285
+ grid is untouched.
286
+
287
+ `flex: 1 1 180px` mirrors the screen `minmax(180px, 1fr)` —
288
+ each card gets at least 180px and shares any extra evenly. */
289
+ .body .pillars-grid,
290
+ .body .metric-strip-grid {
291
+ display: flex !important;
292
+ flex-wrap: wrap !important;
293
+ gap: 10px !important;
294
+ align-items: stretch !important;
295
+ }
296
+ .body .pillars-grid > .pillar,
297
+ .body .metric-strip-grid > .metric-card {
298
+ flex: 1 1 180px !important;
299
+ max-width: 100% !important;
300
+ margin-bottom: 0 !important;
301
+ }
302
+
303
+ /* Glue chapter-num to its h2 · the "Section 0X" kicker is a
304
+ sibling element inserted before the heading (not nested), so
305
+ the legacy heading-only break-after: avoid doesn't cover it.
306
+ Without this, the kicker can land alone in a page footer with
307
+ its h2 floating to the next page top — visually orphaned. */
308
+ .body .chapter-num {
309
+ break-after: avoid;
310
+ page-break-after: avoid;
311
+ }
312
+ /* First-child glue · the first item inside a grid (now block-
313
+ flowed) shouldn't be allowed to break away from the section's
314
+ h2 above. Combined with the h2's break-after: avoid this
315
+ locks the heading + opener pair across page boundaries. */
316
+ .body .pillars-grid > .pillar:first-child,
317
+ .body .metric-strip-grid > .metric-card:first-child,
318
+ .body section.section-recommendations ol > li:first-child,
319
+ .body section.section-considerations ol > li:first-child,
320
+ .body section.section-the-bet ol > li:first-child,
321
+ .body section.section-new-questions ol > li:first-child,
322
+ .body section.section-big-ideas ol > li:first-child {
323
+ break-before: avoid;
324
+ page-break-before: avoid;
325
+ }
210
326
  .body section.section-methodology {
211
327
  break-inside: avoid;
212
328
  page-break-inside: avoid;
@@ -246,6 +362,128 @@
246
362
  .body section.section-big-ideas + h2.section-methodology {
247
363
  border-top: none;
248
364
  }
365
+
366
+ /* ─── Drop the table's heavy top rule when it's the first content
367
+ of a section ───────────────────────────────────────────────────
368
+ Pattern hit hard by Risks-to-Manage / Pre-Mortem (and any other
369
+ table-first section): the chapter-num kicker above the H2
370
+ already carries an underline (its border-bottom hairline), and
371
+ the table immediately below the H2 carries its own 1.5px
372
+ border-top. With only the H2 title between them, those two
373
+ parallel rules read as a doubled frame around the heading. The
374
+ table's row separators keep its internal structure clear without
375
+ the top rule. Spine-agnostic — applied via the inline-loaded
376
+ stylesheet which beats per-spine selectors. */
377
+ .body section > table.md-table:first-child {
378
+ border-top: none;
379
+ }
380
+
381
+ /* ─── Metric-strip · spine-agnostic baseline ─────────────────────
382
+ A row of KPI cards generated by the ```metric-strip fenced block.
383
+ Per-spine CSS (public/report/spines/*.css) overrides surfaces +
384
+ typography for visual personality; this baseline is what every
385
+ spine inherits before its overrides land — keeps the strip
386
+ readable even for spines that haven't shipped a treatment yet.
387
+
388
+ Discipline:
389
+ · No `border-left` as a callout (project rule · feedback memo).
390
+ · Card boundaries are surface + spacing, not double parallel
391
+ borders. The grid sits on the section background; cards lift
392
+ via a tiny background tint.
393
+ · Metric value is the eye-catch · big tabular numerals.
394
+ · Trend glyph is small and doesn't compete with the value. */
395
+ .body .metric-strip {
396
+ margin: 18px 0 22px;
397
+ }
398
+ .body .metric-strip-intro {
399
+ font-family: var(--mono);
400
+ font-size: 11px;
401
+ letter-spacing: 0.06em;
402
+ text-transform: uppercase;
403
+ color: var(--text-soft, #8E8B83);
404
+ margin: 0 0 10px;
405
+ }
406
+ .body .metric-strip-grid {
407
+ display: grid;
408
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
409
+ gap: 10px;
410
+ align-items: stretch;
411
+ }
412
+ .body .metric-card {
413
+ display: flex;
414
+ flex-direction: column;
415
+ gap: 6px;
416
+ padding: 14px 16px;
417
+ background: var(--panel-2, rgba(255, 255, 255, 0.025));
418
+ border: 0.5px solid var(--line-bright, rgba(255, 255, 255, 0.08));
419
+ min-height: 92px;
420
+ }
421
+ .body .metric-label {
422
+ font-family: var(--mono);
423
+ font-size: 10px;
424
+ letter-spacing: 0.1em;
425
+ text-transform: uppercase;
426
+ color: var(--text-soft, #8E8B83);
427
+ line-height: 1.3;
428
+ }
429
+ .body .metric-value-row {
430
+ display: flex;
431
+ align-items: baseline;
432
+ gap: 8px;
433
+ line-height: 1;
434
+ }
435
+ .body .metric-value {
436
+ font-family: "SF Mono", "JetBrains Mono", "Menlo", "Helvetica Neue",
437
+ "Songti SC", monospace;
438
+ font-size: 26px;
439
+ font-weight: 700;
440
+ font-variant-numeric: tabular-nums lining-nums;
441
+ color: var(--text, #C8C5BE);
442
+ letter-spacing: -0.01em;
443
+ }
444
+ .body .metric-trend {
445
+ font-family: "SF Mono", "JetBrains Mono", monospace;
446
+ font-size: 14px;
447
+ line-height: 1;
448
+ color: var(--text-dim, #5C5A52);
449
+ }
450
+ .body .metric-trend[data-trend="up"] { color: var(--em, var(--lime, #6FB572)); }
451
+ .body .metric-trend[data-trend="down"] { color: var(--red, #B5706A); }
452
+ .body .metric-trend[data-trend="flat"] { color: var(--text-dim, #5C5A52); }
453
+ .body .metric-qualifier {
454
+ font-family: var(--sans);
455
+ font-size: 11.5px;
456
+ line-height: 1.4;
457
+ color: var(--text-soft, #8E8B83);
458
+ letter-spacing: -0.003em;
459
+ }
460
+ .body .metric-attribution {
461
+ font-family: var(--mono);
462
+ font-size: 9.5px;
463
+ letter-spacing: 0.06em;
464
+ text-transform: uppercase;
465
+ color: var(--text-faint, #5C5A4D);
466
+ margin-top: 2px;
467
+ }
468
+ /* Print · keep cards intact across page breaks. */
469
+ @media print {
470
+ .body .metric-card { break-inside: avoid; page-break-inside: avoid; }
471
+ }
472
+ /* Defensive · malformed strips render as a quiet placeholder. */
473
+ .body .metric-strip-error {
474
+ padding: 12px 14px;
475
+ background: var(--panel-3, rgba(255, 255, 255, 0.04));
476
+ color: var(--text-dim, #5C5A52);
477
+ font-family: var(--mono);
478
+ font-size: 11px;
479
+ letter-spacing: 0.06em;
480
+ }
481
+ .body .metric-strip-error::before {
482
+ content: "// metric-strip · malformed";
483
+ display: block;
484
+ margin-bottom: 6px;
485
+ color: var(--red, #B5706A);
486
+ }
249
487
  </style>
250
488
  </head>
251
489
  <body>
@@ -388,6 +626,66 @@
388
626
  }).join("\n");
389
627
  }
390
628
 
629
+ /** Parse a fenced ```metric-strip block body (strict JSON) and emit
630
+ * the dashboard card grid. Mirrors how ```mermaid is handled — the
631
+ * block is pulled out at the placeholder layer before block parsing,
632
+ * so the structured HTML survives the renderer's escape pass.
633
+ * Defensive: bad JSON / missing fields render as a quiet fallback
634
+ * block so a malformed strip doesn't break the rest of the report. */
635
+ function renderMetricStrip(body) {
636
+ let parsed;
637
+ try { parsed = JSON.parse(body); } catch (e) {
638
+ return `<div class="metric-strip metric-strip-error" data-err="parse">${escape(body)}</div>`;
639
+ }
640
+ if (!parsed || typeof parsed !== "object") {
641
+ return `<div class="metric-strip metric-strip-error" data-err="shape"></div>`;
642
+ }
643
+ const intro = typeof parsed.intro === "string" ? parsed.intro.trim() : "";
644
+ const cardsRaw = Array.isArray(parsed.cards) ? parsed.cards : [];
645
+ const cards = [];
646
+ for (const c of cardsRaw) {
647
+ if (!c || typeof c !== "object") continue;
648
+ const label = typeof c.label === "string" ? c.label.trim() : "";
649
+ const value = typeof c.value === "string" ? c.value.trim() : "";
650
+ if (!label || !value) continue;
651
+ const qualifier = typeof c.qualifier === "string" ? c.qualifier.trim() : "";
652
+ const attribution = typeof c.attribution === "string" ? c.attribution.trim() : "";
653
+ const trend =
654
+ c.trend === "up" || c.trend === "down" || c.trend === "flat" ? c.trend : "";
655
+ cards.push({ label, value, qualifier, attribution, trend });
656
+ if (cards.length >= 5) break;
657
+ }
658
+ if (cards.length === 0) {
659
+ return `<div class="metric-strip metric-strip-error" data-err="empty"></div>`;
660
+ }
661
+
662
+ // Trend → arrow glyph (rendered in a small badge on the card).
663
+ const trendGlyph = (t) => (t === "up" ? "↑" : t === "down" ? "↓" : t === "flat" ? "→" : "");
664
+
665
+ const introHtml = intro
666
+ ? `<p class="metric-strip-intro">${escape(intro)}</p>`
667
+ : "";
668
+ const cardsHtml = cards.map((c) => {
669
+ const trendAttr = c.trend ? ` data-trend="${escape(c.trend)}"` : "";
670
+ const trendBadge = c.trend
671
+ ? `<span class="metric-trend" data-trend="${escape(c.trend)}" aria-label="${escape(c.trend)}">${trendGlyph(c.trend)}</span>`
672
+ : "";
673
+ const qualifier = c.qualifier ? `<div class="metric-qualifier">${escape(c.qualifier)}</div>` : "";
674
+ const attribution = c.attribution ? `<div class="metric-attribution">${escape(c.attribution)}</div>` : "";
675
+ return `<div class="metric-card"${trendAttr}>` +
676
+ `<div class="metric-label">${escape(c.label)}</div>` +
677
+ `<div class="metric-value-row"><span class="metric-value">${escape(c.value)}</span>${trendBadge}</div>` +
678
+ qualifier +
679
+ attribution +
680
+ `</div>`;
681
+ }).join("");
682
+
683
+ return `<div class="metric-strip" data-cards="${cards.length}">` +
684
+ introHtml +
685
+ `<div class="metric-strip-grid">${cardsHtml}</div>` +
686
+ `</div>`;
687
+ }
688
+
391
689
  // Slightly richer markdown than the in-room renderer — supports h1–h4,
392
690
  // bullets, ordered lists, paragraphs, blockquotes, fenced code blocks
393
691
  // (incl. ```mermaid for diagrams), and pipe tables.
@@ -400,7 +698,11 @@
400
698
  const lines = md.split("\n");
401
699
  let i = 0;
402
700
  while (i < lines.length) {
403
- const fence = /^```(\w*)\s*$/.exec(lines[i]);
701
+ // Fence info-string allows dashes too (e.g. ```metric-strip).
702
+ // Stock `\w*` excludes `-`, so multi-word lang tags fell
703
+ // through and rendered as raw text · the brief writer emits
704
+ // ```metric-strip / ```chart-block style fences regularly.
705
+ const fence = /^```([\w-]*)\s*$/.exec(lines[i]);
404
706
  if (fence) {
405
707
  const lang = (fence[1] || "").toLowerCase();
406
708
  const start = i + 1;
@@ -412,6 +714,8 @@
412
714
  const idx = placeholders.length;
413
715
  if (lang === "mermaid") {
414
716
  placeholders.push(`<pre class="mermaid">${escape(sanitizeMermaid(body))}</pre>`);
717
+ } else if (lang === "metric-strip") {
718
+ placeholders.push(renderMetricStrip(body));
415
719
  } else {
416
720
  placeholders.push(
417
721
  `<pre class="codeblock${lang ? ` lang-${escape(lang)}` : ""}"><code>${escape(body)}</code></pre>`,
@@ -564,6 +868,11 @@
564
868
  { re: /^critical\s*assumptions?|^load[-\s]bearing\s*assumptions?|^承重假设|^关键假设/i, cls: "section-critical-assumptions" },
565
869
  { re: /^scenario\s*tree|^scenarios?$|^情景树|^情景分析|^命名情景/i, cls: "section-scenario-tree" },
566
870
  { re: /^leading\s*indicators?|^watch[-\s]list|^先行指标|^监测指标|^监控信号/i, cls: "section-leading-indicators" },
871
+ // Dashboard-style KPI strip · catches the house-style label
872
+ // variants (By the Numbers / The Underwrite / Three Numbers Worth
873
+ // Pricing In / Strategic Indicators / Quantitative Reads / 用数字说话 /
874
+ // 关键指标 / 指标看板 / 实证锚点 / 战略指标 / etc.) plus the literal default.
875
+ { re: /^by\s*the\s*numbers|^the\s*underwrite|^(three|key|strategic|quantitative)\s*(numbers?|metrics?|indicators?|reads?|anchors?)|^numbers?\s*(that|worth)|^the\s*numbers$|^a\s+few\s+numbers|^what\s+the\s+(numbers|data|math)|^why\s+the\s+math|^the\s*diagnostic\s*at\s*a\s*glance|^indicator\s*dashboard|^empirical\s*anchors|^metric\s*strip|^用数字说话|^支撑数据|^值得定价的|^数字$|^数字告诉我们|^几个浮上来的数字|^数据怎么说|^为什么数算得过来|^战略指标|^关键指标|^诊断速览|^驱动判断的数字|^定量结果|^实证锚点|^指标看板|^定量判读/i, cls: "section-metric-strip" },
567
876
  { re: /^recommendations?/i, cls: "section-recommendations" },
568
877
  { re: /^the\s*bet$|^the\s*bet[\s·:]/i, cls: "section-the-bet" },
569
878
  { re: /^considerations?/i, cls: "section-considerations" },
@@ -1334,13 +1643,29 @@
1334
1643
  // expose. The chart title is hidden because the markdown
1335
1644
  // already renders an H3 caption directly above each chart —
1336
1645
  // showing it twice is the single ugliest mermaid default.
1646
+ // Hide every mermaid chart's built-in <text> title — the markdown
1647
+ // already prints an H3 caption right above each diagram, and
1648
+ // showing it twice is mermaid's single ugliest default. Apply
1649
+ // across quadrant / xychart / pie / timeline.
1337
1650
  const themeCSS = `
1338
1651
  g.quadrant-chart text { font-family: ${t.fontFamily}; }
1339
1652
  g.quadrant-point > circle, .quadrant-point circle {
1340
1653
  stroke: ${t.vars.background};
1341
1654
  stroke-width: 1.5px;
1342
1655
  }
1343
- text.quadrant-title { display: none !important; }
1656
+ text.quadrant-title,
1657
+ .pieTitleText,
1658
+ .xy-chart .title-text,
1659
+ .timeline .title-text,
1660
+ .timeline-title { display: none !important; }
1661
+ /* xychart bars: thin stroke against the background so adjacent
1662
+ bars don't merge visually on dense datasets. */
1663
+ .xy-chart .bar { stroke: ${t.vars.background}; stroke-width: 1px; }
1664
+ /* Pie slice text: legibility on the dark / light spines. */
1665
+ .pieCircle { stroke: ${t.vars.background}; stroke-width: 1.5px; }
1666
+ text.slice, .pieLegendText { font-family: ${t.fontFamily}; }
1667
+ /* Timeline · period/label fonts inherit the spine. */
1668
+ .timeline text { font-family: ${t.fontFamily}; }
1344
1669
  `;
1345
1670
  mermaid.initialize({
1346
1671
  startOnLoad: false,
@@ -1397,6 +1722,36 @@
1397
1722
  quadrantExternalBorderStrokeFill: t.vars.border,
1398
1723
  },
1399
1724
  flowchart: { useMaxWidth: true, htmlLabels: true },
1725
+ // ── xychart-beta · bar charts ──────────────────────────
1726
+ // Tight margins, label hidden (caption above), and a single
1727
+ // colour palette derived from the spine's pointFill so
1728
+ // every bar in a chart shares a coherent voice. Mermaid
1729
+ // 11+ accepts the `xyChart` key (older releases tolerated
1730
+ // `xychart` — keep BOTH so the config isn't fragile across
1731
+ // CDN bumps).
1732
+ xyChart: {
1733
+ width: 720,
1734
+ height: 360,
1735
+ titlePadding: 0,
1736
+ titleFontSize: 0,
1737
+ showTitle: false,
1738
+ showDataLabel: true,
1739
+ plotReservedSpacePercent: 50,
1740
+ xAxis: { labelFontSize: 12, titleFontSize: 0, showTitle: false },
1741
+ yAxis: { labelFontSize: 12, titleFontSize: 13 },
1742
+ },
1743
+ // Pie · legend on the right, slice labels disabled so wide
1744
+ // labels don't push the chart off-screen. The legend
1745
+ // already carries label + value (showData: true above).
1746
+ pie: {
1747
+ textPosition: 0.7,
1748
+ useMaxWidth: true,
1749
+ },
1750
+ // Timeline · cards-on-rail; title suppressed (caption above).
1751
+ timeline: {
1752
+ padding: 16,
1753
+ useMaxWidth: true,
1754
+ },
1400
1755
  });
1401
1756
  await mermaid.run({ querySelector: ".mermaid" });
1402
1757
  } catch (e) {
@@ -183,13 +183,15 @@
183
183
  .rs-config-row { grid-template-columns: 1fr; gap: 6px; }
184
184
  }
185
185
 
186
- /* Tone + intensity chip rows · explicit grid columns (4 / 3) so the
186
+ /* Tone + intensity chip rows · explicit grid columns (5 / 3) so the
187
187
  chips always sit in a single row at content width. flex-wrap was
188
188
  the prior approach but allowed chips to fold onto a second line on
189
- tight viewports — the user wants them locked to one row. */
189
+ tight viewports — the user wants them locked to one row. Tone count
190
+ bumped from 4 → 5 when `research` and `critique` were added; if
191
+ you add more modes, bump this number too. */
190
192
  .rs-mode-grid {
191
193
  display: grid;
192
- grid-template-columns: repeat(4, max-content);
194
+ grid-template-columns: repeat(5, max-content);
193
195
  gap: 3px;
194
196
  }
195
197
  .rs-intensity-chips {
@@ -439,7 +441,7 @@
439
441
  /* Section label · matches the new composer's mono micro-type kicker.
440
442
  No leading lime bullet — the inline count chip carries enough
441
443
  visual weight, and dropping the bullet lets the labels sit flush
442
- left like the rest of the prototype's section heads. */
444
+ left like the rest of the app's section heads. */
443
445
  .rs-label {
444
446
  font-size: 9px;
445
447
  font-weight: 700;
@@ -16,10 +16,12 @@
16
16
  "Co-creator. Directors stand with you and push the idea outward — yes-and a contribution, name a concrete adjacent variant (\"what if we instead…\"), borrow pieces from another director's turn into new combinations. May end with one curious question, never a defense-demanding one.",
17
17
  constructive:
18
18
  "Sympathetic interrogator. They want you to win, but only via the strongest version. Each turn picks ONE load-bearing assumption and proposes the candidate stronger version that would stand. Disagreement is allowed, but every objection comes packaged with a forward path.",
19
+ research:
20
+ "Collaborative inquiry. The room mines the materials in front of it (your brief, web-search results, prior turns) for what's actually there. Each turn must cite a specific source piece, label it OBSERVATION / INFERENCE / SPECULATION, then extract the insight your lens makes salient. Defaults web search ON when a Brave key is configured.",
19
21
  debate:
20
22
  "Peer reviewer. Each turn opens by steelmanning your strongest claim (\"the strongest read of your point is…\") and only then attacks THAT version — naming a specific risk, demanding evidence, exposing the trade-off you're hiding. Sharp but professional. Skipping the steelman is a protocol violation.",
21
- "no-mercy":
22
- "Hostile reviewer. Default: you're wrong until proved otherwise. Points at vague terms / hand-waved mechanisms, says \"this is wrong because X\" flat no hedge. Refuses undefined terms. Attacks the argument as half-baked / wrong, never the person. Forbidden hedge words: perhaps / maybe / could be / might.",
23
+ critique:
24
+ "Review board. The room audits a finished deliverable systematically each turn names the dimension being audited (logic / evidence / scope / risk / etc.), surfaces 2–3 specific flaws labelled BLOCKER · MAJOR · MINOR, points at the load-bearing piece, and indicates the direction a fix would lie. At least one BLOCKER or MAJOR per turn is mandatory.",
23
25
  };
24
26
 
25
27
  /** Intensity tooltips · what each pick does to the directors' default
@@ -146,8 +148,8 @@
146
148
  const NAMES = {};
147
149
 
148
150
  // Baseline state — synced from window.app.currentRoom each time the
149
- // overlay opens. The fallback values keep the prototype usable in
150
- // standalone preview (where window.app is absent).
151
+ // overlay opens. The fallback values keep the page usable in standalone
152
+ // preview (where window.app is absent).
151
153
  const ROOM_STATE = {
152
154
  title: "the minimum viable structure of a data flywheel",
153
155
  topic: "I want to build an AI assistant for enterprise HR teams — automated resume screening + interview guides. Does this idea hold up under three-director scrutiny?",
@@ -201,8 +203,9 @@
201
203
  const MODES = [
202
204
  { v: "brainstorm", label: "Brainstorm", desc: "yes-and" },
203
205
  { v: "constructive", label: "Constructive", desc: "push & sharpen" },
206
+ { v: "research", label: "Research", desc: "mine the material" },
204
207
  { v: "debate", label: "Debate", desc: "find holes" },
205
- { v: "no-mercy", label: "No Mercy", desc: "tear apart" }
208
+ { v: "critique", label: "Critique", desc: "audit the deliverable" }
206
209
  ];
207
210
 
208
211
 
@@ -656,6 +656,24 @@
656
656
  }
657
657
  .us-foot .us-done:hover { border-color: var(--lime, #6FB572); color: var(--lime, #6FB572); }
658
658
 
659
+ /* Right-side cluster · website link + Done button. */
660
+ .us-foot-right {
661
+ display: flex;
662
+ align-items: center;
663
+ gap: 14px;
664
+ }
665
+ .us-foot .us-website {
666
+ font-family: var(--mono);
667
+ font-size: 9.5px;
668
+ font-weight: 600;
669
+ letter-spacing: 0.1em;
670
+ text-transform: uppercase;
671
+ color: var(--text-soft, #8E8B83);
672
+ text-decoration: none;
673
+ transition: color 0.12s;
674
+ }
675
+ .us-foot .us-website:hover { color: var(--lime, #6FB572); }
676
+
659
677
  @media (max-width: 600px) {
660
678
  .user-settings-overlay { padding: 12px; }
661
679
  .us-head { padding-left: 14px; padding-right: 14px; }
@@ -903,7 +903,10 @@
903
903
 
904
904
  <footer class="us-foot">
905
905
  <span class="saved">changes save automatically</span>
906
- <button type="button" class="us-done">[ Done ]</button>
906
+ <div class="us-foot-right">
907
+ <a class="us-website" href="/home.html" target="_blank" rel="noopener">website ↗</a>
908
+ <button type="button" class="us-done">[ Done ]</button>
909
+ </div>
907
910
  </footer>
908
911
 
909
912
  </div>
@@ -971,7 +974,6 @@
971
974
  if (typeof window.app.renderUserBlock === "function") window.app.renderUserBlock();
972
975
  } else {
973
976
  document.querySelectorAll(".sidebar-foot .user-name").forEach((el) => { el.textContent = (u.name || "Kay").toUpperCase(); });
974
- document.querySelectorAll(".sidebar-foot .user-menu .name").forEach((el) => { el.textContent = u.name || "Kay"; });
975
977
  }
976
978
  }
977
979
 
@@ -1124,6 +1126,26 @@
1124
1126
  // wireKeysSection again.
1125
1127
  fetchKeyMeta().then(() => {
1126
1128
  if (currentSection !== "keys") return;
1129
+
1130
+ // After-onboarding sync · the bootstrap fetchKeyMeta ran before
1131
+ // the user wrote their first key (during onboarding). When the
1132
+ // user opens settings without a page refresh, _keysMeta was
1133
+ // empty at first render, so activeProviders was derived without
1134
+ // the just-configured provider — and the keys tab paints with
1135
+ // no row for it (e.g. "no OpenRouter section visible until
1136
+ // refresh"). Detect that drift here and rebuild the section
1137
+ // when a configured provider is missing its row. Inline pill
1138
+ // refresh below handles the simpler case where the row already
1139
+ // exists and only its `● configured` state needs flipping.
1140
+ const missingActive = LLM_PROVIDER_IDS.filter(
1141
+ (id) => _keysMeta[id] && _keysMeta[id].configured && !activeProviders.includes(id),
1142
+ );
1143
+ if (missingActive.length > 0) {
1144
+ activeProviders = null;
1145
+ rerenderKeysSection();
1146
+ return;
1147
+ }
1148
+
1127
1149
  paneEl.querySelectorAll(".us-key-row").forEach((row) => {
1128
1150
  const provider = row.dataset.provider;
1129
1151
  const meta = _keysMeta[provider];
@@ -1179,6 +1201,13 @@
1179
1201
  if (window.app && typeof window.app.refreshKeys === "function") {
1180
1202
  window.app.refreshKeys();
1181
1203
  }
1204
+ // If an agent profile is open, its skill rows have data-key-
1205
+ // configured cached from first paint. Re-fetch so the web-search
1206
+ // toggle no longer shows the "configure key" prompt after the
1207
+ // user added the Brave key here.
1208
+ if (typeof window.refreshAgentProfileSkills === "function") {
1209
+ window.refreshAgentProfileSkills();
1210
+ }
1182
1211
  }
1183
1212
 
1184
1213
  function init() {