privateboard 0.1.7 → 0.1.9

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/public/app.js CHANGED
@@ -422,24 +422,14 @@
422
422
  // No explicit room in the hash — land on the new-room composer
423
423
  // (ChatGPT / Claude style: default = fresh, existing rooms live
424
424
  // in the sidebar). The composer renders inside closeRoom →
425
- // renderEmptyState. Previously we auto-opened the first selectable
426
- // room; that's been retired so users always start on the composer
427
- // unless they explicitly navigate to a room.
425
+ // renderEmptyState. The sidebar persistence layer in index.html
426
+ // (boardroom.sidebar.* keys + restore() on DOMContentLoaded)
427
+ // takes over from here · if the user's last sidebar selection
428
+ // was a specific room / reports / notes, that fires AFTER
429
+ // app.init and overrides this composer landing.
428
430
  this.closeRoom();
429
431
  },
430
432
 
431
- /** Choose a sensible default room when none is in the URL hash. */
432
- firstSelectableRoom() {
433
- if (!this.rooms || !this.rooms.length) return null;
434
- const rank = (status) => (status === "live" ? 0 : status === "paused" ? 1 : 2);
435
- const sorted = this.rooms.slice().sort((a, b) => {
436
- const dr = rank(a.status) - rank(b.status);
437
- if (dr !== 0) return dr;
438
- return (b.createdAt || 0) - (a.createdAt || 0);
439
- });
440
- return sorted[0] || null;
441
- },
442
-
443
433
  navigateToRoom(roomId) {
444
434
  location.hash = "#/r/" + roomId;
445
435
  },
@@ -648,7 +638,15 @@
648
638
  this.currentRoom = null;
649
639
  this.currentMessages = [];
650
640
  this.currentMembers = [];
651
- this.currentChair = null;
641
+ // `currentChair` is NOT reset · the chair is a structural
642
+ // singleton (one moderator agent in the catalog, same across
643
+ // every room), and the sidebar's Chair section keys off it
644
+ // whether or not a room is loaded. Earlier this line read
645
+ // `this.currentChair = null;`, which dropped the Chair row
646
+ // from the Agents tab any time the user landed on the no-
647
+ // room state — the chair would re-appear the moment a room
648
+ // re-opened (since `openRoom` re-sets it from data.chair),
649
+ // but the empty + composer states looked broken.
652
650
  this.currentQueue = [];
653
651
  this.currentRound = { spoken: 0, total: 0 };
654
652
  this.currentKeyPoints = [];
@@ -912,11 +910,37 @@
912
910
  }
913
911
  } else if (kind === "brief-started") {
914
912
  this.markBriefEvent();
913
+ // Mode determines BOTH the seeded stage map and which pip
914
+ // rail renderBriefStages picks. Bento, magazine, and
915
+ // newspaper modes each emit only 2 stage events (extract
916
+ // + write), so seeding the 7-stage layout for a
917
+ // structured-mode brief leaves the 5 middle pips forever
918
+ // pending — the rail then renders with a row of dead
919
+ // black pips between extract and write. Read mode from
920
+ // the payload (server includes it on brief-started for
921
+ // exactly this reason) and seed accordingly.
922
+ const isStructured = this.isStructuredBriefMode(payload.mode);
923
+ const briefMode = isStructured ? payload.mode : "research-note";
924
+ const seededStages = isStructured
925
+ ? {
926
+ extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
927
+ write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
928
+ }
929
+ : {
930
+ extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
931
+ compose: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
932
+ "scaffold-anchor": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
933
+ "scaffold-findings": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
934
+ "scaffold-cluster": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
935
+ "scaffold-actions": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
936
+ write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
937
+ };
915
938
  const newBrief = {
916
939
  id: payload.briefId,
917
940
  title: "Generating…",
918
941
  bodyMd: "",
919
942
  style: payload.style || "mckinsey",
943
+ mode: briefMode,
920
944
  // Carry the supplement so the tab strip can render the
921
945
  // in-progress brief with its supplement label ("xxx 视角")
922
946
  // immediately, instead of waiting for brief-final to
@@ -931,21 +955,13 @@
931
955
  language: payload.language === "zh" ? "zh" : "en",
932
956
  pipelineStartedAt: Date.now(),
933
957
  createdAt: Date.now(),
934
- // Stage checklist · seeded with all seven stages in pending
935
- // state (extract / compose / 4 scaffold sub-stages / write).
936
- // brief-stage events flip them active → done as the pipeline
937
- // progresses. startedAt is captured when each stage first
938
- // becomes active so the UI can display elapsed time
939
- // alongside the ETA range.
940
- stages: {
941
- extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
942
- compose: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
943
- "scaffold-anchor": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
944
- "scaffold-findings": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
945
- "scaffold-cluster": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
946
- "scaffold-actions": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
947
- write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
948
- },
958
+ // Stage checklist · seeded according to the brief mode (see
959
+ // briefMode resolution above). brief-stage events flip
960
+ // them active → done as the pipeline progresses.
961
+ // startedAt is captured when each stage first becomes
962
+ // active so the UI can display elapsed time alongside the
963
+ // ETA range.
964
+ stages: seededStages,
949
965
  };
950
966
  this.currentBrief = newBrief;
951
967
  // Insert the in-progress brief into currentBriefs so the tab
@@ -1076,10 +1092,14 @@
1076
1092
  }
1077
1093
  }
1078
1094
  }
1079
- // Typing-SFX presence cue · same as message-token. Ticks
1080
- // even on off-tab briefs so the user has aural awareness
1081
- // that the brief writer is making progress.
1082
- window.boardroomTypingSfx && window.boardroomTypingSfx.tick();
1095
+ // No typing-SFX during the brief writer's Stage 3 stream.
1096
+ // The write phase is a long, off-screen streaming pass (the
1097
+ // user is usually reading prior content or doing something
1098
+ // else while the report builds) — a continuous click track
1099
+ // for tens of seconds reads as background noise, not a
1100
+ // presence cue. Director / chair turns still tick because
1101
+ // those land in the active conversation the user is
1102
+ // following.
1083
1103
  } else if (kind === "brief-final") {
1084
1104
  this.markBriefEvent();
1085
1105
  const target = this._briefById(payload.briefId);
@@ -1484,6 +1504,196 @@
1484
1504
  return false;
1485
1505
  },
1486
1506
 
1507
+ /** Has the brief got its body content yet? Mode-aware check ·
1508
+ * research-note briefs land their content in `bodyMd`; bento
1509
+ * briefs land theirs in `bodyJson` and leave bodyMd empty. The
1510
+ * card-rendering / placeholder-detection / generating-check
1511
+ * logic uses this everywhere so a bento brief doesn't stay
1512
+ * stuck in "generating…" state after the BentoScaffold has
1513
+ * actually been persisted.
1514
+ *
1515
+ * Without this helper, a successful bento generation followed
1516
+ * by a page refresh hid the View Report button forever (the
1517
+ * card thought it was still generating because bodyMd was empty
1518
+ * — which is the steady-state shape for bento, not an
1519
+ * in-flight signal). */
1520
+ /** True for any structured-output mode — magazine, newspaper,
1521
+ * ppt — that persists to body_json instead of body_md and
1522
+ * runs the single-pass chair-LLM pipeline (extract + write
1523
+ * only, no Stage 2/3 scaffold/write). Centralised so future
1524
+ * modes touch one place. */
1525
+ isStructuredBriefMode(mode) {
1526
+ return mode === "magazine" || mode === "newspaper" || mode === "ppt";
1527
+ },
1528
+
1529
+ briefHasBody(b) {
1530
+ if (!b) return false;
1531
+ if (this.isStructuredBriefMode(b.mode)) {
1532
+ // Magazine, newspaper + ppt all populate bodyJson with the
1533
+ // structured scaffold. An empty object isn't a body; check
1534
+ // for at least a title field.
1535
+ const j = b.bodyJson;
1536
+ return !!(j && typeof j === "object" && (j.title || j.milestones));
1537
+ }
1538
+ // Default research-note · body lives in markdown.
1539
+ return !!(b.bodyMd && b.bodyMd.trim());
1540
+ },
1541
+
1542
+ /** Build the right viewer URL for a brief based on its mode.
1543
+ * · 'research-note' (default · or unknown) → `/report.html?r=R&b=B`
1544
+ * · 'magazine' → `/magazine.html?b=B`
1545
+ * · 'newspaper' → `/newspaper.html?b=B`
1546
+ * · 'ppt' → `/ppt.html?b=B`
1547
+ *
1548
+ * Structured renderers don't need the room context · the brief
1549
+ * is self-contained. Used by the View Report button, the
1550
+ * brief-picker popover, the open-report link in the brief card,
1551
+ * and the All Reports page — one helper so a future renderer
1552
+ * route change touches one place. */
1553
+ briefViewerHref(b, roomId) {
1554
+ if (!b || !b.id) return null;
1555
+ const id = encodeURIComponent(b.id);
1556
+ if (b.mode === "magazine") return `/magazine.html?b=${id}`;
1557
+ if (b.mode === "newspaper") return `/newspaper.html?b=${id}`;
1558
+ if (b.mode === "ppt") return `/ppt.html?b=${id}`;
1559
+ const r = roomId ? encodeURIComponent(roomId) : "";
1560
+ return r ? `/report.html?r=${r}&b=${id}` : `/report.html?b=${id}`;
1561
+ },
1562
+
1563
+ /** Human-readable label for a brief's mode · used in the brief-card
1564
+ * banner so the user sees which renderer the report will use
1565
+ * before they open it.
1566
+ *
1567
+ * IMPORTANT · the user-facing label for `research-note` is
1568
+ * "Report", NOT "Research Note". The internal mode key stays as
1569
+ * `research-note` (storage column, API value, picker option id),
1570
+ * but every user-facing surface — the mode picker (`renderBrief-
1571
+ * ModePicker` line ~1662 in this file), the report viewer's
1572
+ * header, etc. — calls it "Report". Keep this map in sync with
1573
+ * the picker labels rather than inventing a fresh user-facing
1574
+ * vocabulary.
1575
+ *
1576
+ * Legacy rows without an explicit `mode` default to "Report" by
1577
+ * the same backfill the storage layer applies. */
1578
+ briefModeLabel(b) {
1579
+ const mode = (b && b.mode) || "research-note";
1580
+ switch (mode) {
1581
+ case "magazine": return "Magazine";
1582
+ case "newspaper": return "Newspaper";
1583
+ case "ppt": return "Slides";
1584
+ default: return "Report";
1585
+ }
1586
+ },
1587
+
1588
+ /** Render the report-mode picker · four icon-tile cards that let
1589
+ * the user pick between research-note, magazine, newspaper, and
1590
+ * ppt (slides). Each tile shows a custom monoline SVG that
1591
+ * visually mirrors that mode's layout — research-note as a memo,
1592
+ * magazine as a masthead + numeral block + small cards,
1593
+ * newspaper as a banner + 3-column text grid, ppt as a slide
1594
+ * rectangle with bullet lines. The native radio is visually
1595
+ * hidden (kept in the DOM for a11y / keyboard nav).
1596
+ *
1597
+ * Used by both the adjourn overlay (filing the report at
1598
+ * adjourn-time / generate-brief flow) AND the supplement
1599
+ * overlay (regenerating with an extra perspective). The picker
1600
+ * reads its selected option via `input[name="brief-mode"]:checked`
1601
+ * from inside whatever overlay it lives in · the click-to-toggle
1602
+ * handler scopes to the `.adjourn-mode-options` container so it
1603
+ * works for any overlay embedding the picker. */
1604
+ renderBriefModePicker(defaultMode) {
1605
+ const safe = this.isStructuredBriefMode(defaultMode) ? defaultMode : "research-note";
1606
+ // Icon SVGs · `currentColor` lets each tile pick up its hover /
1607
+ // selected accent from the parent `.adjourn-mode-icon { color }`
1608
+ // rule. Stroke hierarchy is deliberate · main outlines at 1.0,
1609
+ // a single emphasis rule at 1.2 (newspaper headline + magazine
1610
+ // masthead bar), body details at 0.8. Filled blocks soften to
1611
+ // currentColor at 0.5 opacity so the lime selected-state reads
1612
+ // as a tint rather than a slab. The 24-unit viewBox renders at
1613
+ // 36px on screen → 1.5× scale, so stroke-1.0 is a clean 1.5px.
1614
+ const ICONS = {
1615
+ "research-note": `
1616
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1617
+ <path d="M6 3h9l4 4v14H6z"/>
1618
+ <path d="M15 3v4h4"/>
1619
+ <line x1="9" y1="12" x2="16" y2="12"/>
1620
+ <line x1="9" y1="15" x2="16" y2="15"/>
1621
+ <line x1="9" y1="18" x2="14" y2="18"/>
1622
+ </svg>`,
1623
+ "magazine": `
1624
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1625
+ <line x1="3" y1="4" x2="21" y2="4" stroke-width="1.2"/>
1626
+ <rect x="3" y="7" width="6" height="9" rx="1.2"/>
1627
+ <rect x="11" y="7" width="4.5" height="4" rx="1.2"/>
1628
+ <rect x="16.5" y="7" width="4.5" height="4" rx="1.2"/>
1629
+ <rect x="11" y="12" width="4.5" height="4" rx="1.2"/>
1630
+ <rect x="16.5" y="12" width="4.5" height="4" rx="1.2"/>
1631
+ <rect x="3" y="18" width="18" height="3" rx="1" fill="currentColor" fill-opacity="0.45" stroke="none"/>
1632
+ </svg>`,
1633
+ "newspaper": `
1634
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1635
+ <rect x="3" y="3" width="18" height="2.5" fill="currentColor" fill-opacity="0.85" stroke="none"/>
1636
+ <line x1="7" y1="8" x2="17" y2="8" stroke-width="1.2"/>
1637
+ <line x1="4" y1="11.5" x2="8" y2="11.5" stroke-width="0.8"/>
1638
+ <line x1="4" y1="13.5" x2="8" y2="13.5" stroke-width="0.8"/>
1639
+ <line x1="4" y1="15.5" x2="6.5" y2="15.5" stroke-width="0.8"/>
1640
+ <line x1="10" y1="11.5" x2="14" y2="11.5" stroke-width="0.8"/>
1641
+ <line x1="10" y1="13.5" x2="14" y2="13.5" stroke-width="0.8"/>
1642
+ <line x1="10" y1="15.5" x2="12.5" y2="15.5" stroke-width="0.8"/>
1643
+ <line x1="16" y1="11.5" x2="20" y2="11.5" stroke-width="0.8"/>
1644
+ <line x1="16" y1="13.5" x2="20" y2="13.5" stroke-width="0.8"/>
1645
+ <line x1="16" y1="15.5" x2="18.5" y2="15.5" stroke-width="0.8"/>
1646
+ <line x1="3" y1="18.5" x2="21" y2="18.5" stroke-width="0.9"/>
1647
+ </svg>`,
1648
+ "ppt": `
1649
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1650
+ <rect x="3" y="4" width="18" height="13" rx="1"/>
1651
+ <line x1="7" y1="9" x2="14" y2="9" stroke-width="1.2"/>
1652
+ <line x1="7" y1="12" x2="17" y2="12" stroke-width="0.8"/>
1653
+ <line x1="7" y1="14" x2="15" y2="14" stroke-width="0.8"/>
1654
+ <circle cx="5.5" cy="12" r="0.6" fill="currentColor" stroke="none"/>
1655
+ <circle cx="5.5" cy="14" r="0.6" fill="currentColor" stroke="none"/>
1656
+ <line x1="10" y1="20" x2="14" y2="20" stroke-width="1"/>
1657
+ <line x1="12" y1="17" x2="12" y2="20" stroke-width="0.6"/>
1658
+ </svg>`,
1659
+ };
1660
+ const opt = (value, title, deck, primary) => `
1661
+ <label class="adjourn-mode-option${primary ? " adjourn-mode-option-primary" : ""}${safe === value ? " on" : ""}">
1662
+ <input type="radio" name="brief-mode" value="${value}"${safe === value ? " checked" : ""}>
1663
+ <div class="adjourn-mode-icon">${ICONS[value] || ""}</div>
1664
+ <div class="adjourn-mode-body">
1665
+ <div class="adjourn-mode-title">${title}</div>
1666
+ <div class="adjourn-mode-deck">${deck}</div>
1667
+ </div>
1668
+ </label>`;
1669
+ return `
1670
+ <div class="adjourn-mode-picker" data-mode-picker>
1671
+ <div class="adjourn-mode-label">// report format</div>
1672
+ <div class="adjourn-mode-options adjourn-mode-options-4">
1673
+ ${opt("research-note", "Report", "Long-form markdown · bottom line, findings, recommendations.", true)}
1674
+ ${opt("magazine", "Magazine", "Editorial spread · cover line, 5 cards, dark closer.")}
1675
+ ${opt("newspaper", "Newspaper", "Broadsheet · banner masthead, 3-column editorial.")}
1676
+ ${opt("ppt", "Slides", "Slide deck · 7-9 slides, arrow-key navigation, present mode.")}
1677
+ </div>
1678
+ </div>`;
1679
+ },
1680
+
1681
+ /** Read the user's last-picked report mode from localStorage so the
1682
+ * picker defaults to their previous choice. Falls back to
1683
+ * 'research-note' on first run / private mode. */
1684
+ lastBriefMode() {
1685
+ try {
1686
+ const v = localStorage.getItem("pb.briefMode");
1687
+ return this.isStructuredBriefMode(v) ? v : "research-note";
1688
+ } catch { return "research-note"; }
1689
+ },
1690
+ saveLastBriefMode(mode) {
1691
+ try {
1692
+ const safe = this.isStructuredBriefMode(mode) ? mode : "research-note";
1693
+ localStorage.setItem("pb.briefMode", safe);
1694
+ } catch { /* private-mode etc. — silently ignore */ }
1695
+ },
1696
+
1487
1697
  /** Pure cache read · returns true when the local app.keys map
1488
1698
  * reports any model provider as configured. Used by the gate
1489
1699
  * and (via wrappers) anywhere downstream UI needs the answer
@@ -1647,6 +1857,9 @@
1647
1857
  async adjournRoom(opts) {
1648
1858
  if (!this.currentRoomId) return;
1649
1859
  const skipBrief = !!(opts && opts.skipBrief);
1860
+ const mode = opts && this.isStructuredBriefMode(opts.mode)
1861
+ ? opts.mode
1862
+ : "research-note";
1650
1863
  // Pre-flight · adjourn-with-brief triggers the brief writer
1651
1864
  // pipeline (3 LLM stages). When skipBrief=true the chair just
1652
1865
  // posts the no-brief marker without LLM calls, so we let it
@@ -1657,7 +1870,7 @@
1657
1870
  {
1658
1871
  method: "POST",
1659
1872
  headers: { "content-type": "application/json" },
1660
- body: JSON.stringify(skipBrief ? { skipBrief: true } : {}),
1873
+ body: JSON.stringify(skipBrief ? { skipBrief: true } : { mode }),
1661
1874
  },
1662
1875
  );
1663
1876
  if (!r.ok) {
@@ -1691,6 +1904,10 @@
1691
1904
  const confirmTxt = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
1692
1905
  const subjectTxt = room.subject || room.name || "—";
1693
1906
  const memberCount = (this.currentMembers || []).length;
1907
+ // Default the mode picker to whatever the user picked last. Bento
1908
+ // is opt-in · 'research-note' is the safe initial default for new
1909
+ // users / private-mode browsers (where localStorage is empty).
1910
+ const defaultMode = this.lastBriefMode();
1694
1911
  const html = `
1695
1912
  <div class="adjourn-overlay" id="adjourn-overlay" role="dialog" aria-modal="true" data-adjourn-mode="${this.escape(mode)}">
1696
1913
  <div class="adjourn-backdrop" data-adjourn-close></div>
@@ -1711,9 +1928,12 @@
1711
1928
 
1712
1929
  <div class="adjourn-body">
1713
1930
  <div class="adjourn-summary">
1714
- <div class="adjourn-summary-row">
1931
+ <div class="adjourn-summary-row adjourn-summary-row-subject">
1715
1932
  <span class="adjourn-summary-key">// subject</span>
1716
- <span class="adjourn-summary-val">${this.escape(subjectTxt)}</span>
1933
+ <div class="adjourn-summary-val adjourn-subject-wrap">
1934
+ <span class="adjourn-subject-text is-clamped" data-adjourn-subject>${this.escape(subjectTxt)}</span>
1935
+ <button type="button" class="adjourn-subject-toggle" data-adjourn-subject-toggle hidden>Show more</button>
1936
+ </div>
1717
1937
  </div>
1718
1938
  <div class="adjourn-summary-row">
1719
1939
  <span class="adjourn-summary-key">// authors</span>
@@ -1725,10 +1945,12 @@
1725
1945
  </div>
1726
1946
  </div>
1727
1947
  <p class="adjourn-summary-note">
1728
- The chair compiles a standard report from the room's transcript
1729
- situation, key findings, and implications. The room is marked
1730
- adjourned and the report is filed in the chat.
1948
+ The chair compiles a report from the room's transcript. Pick the
1949
+ format below the room is marked adjourned and the report is
1950
+ filed in the chat once it's ready.
1731
1951
  </p>
1952
+
1953
+ ${this.renderBriefModePicker(defaultMode)}
1732
1954
  </div>
1733
1955
 
1734
1956
  <footer class="adjourn-foot">
@@ -1749,6 +1971,19 @@
1749
1971
  wrap.innerHTML = html.trim();
1750
1972
  document.body.appendChild(wrap.firstChild);
1751
1973
  document.body.style.overflow = "hidden";
1974
+ // Subject clamp · the subject value is clamped to 3 lines by
1975
+ // default (a long room title shouldn't crowd the rest of the
1976
+ // overlay). After mount, measure whether the text actually
1977
+ // overflows the clamp — only then reveal the Show more / less
1978
+ // toggle. rAF lets the browser settle the initial layout so
1979
+ // scrollHeight is meaningful.
1980
+ requestAnimationFrame(() => {
1981
+ const subjEl = document.querySelector("[data-adjourn-subject]");
1982
+ const subjBtn = document.querySelector("[data-adjourn-subject-toggle]");
1983
+ if (subjEl && subjBtn && subjEl.scrollHeight > subjEl.clientHeight + 1) {
1984
+ subjBtn.hidden = false;
1985
+ }
1986
+ });
1752
1987
  // Esc closes the overlay. Listener auto-detaches on close so
1753
1988
  // we don't accumulate handlers across opens.
1754
1989
  this._adjournEsc = (ev) => {
@@ -1788,6 +2023,12 @@
1788
2023
  confirm: "[ Regenerate ]",
1789
2024
  confirmBusy: "[ Regenerating… ]",
1790
2025
  };
2026
+ // Default the picker to the user's last picked mode — same
2027
+ // behaviour as the adjourn (Generate) flow, so the picker reads
2028
+ // consistently across both surfaces. The parent brief's mode is
2029
+ // available if needed, but the user's most recent explicit
2030
+ // choice wins.
2031
+ const defaultMode = this.lastBriefMode();
1791
2032
  const html = `
1792
2033
  <div class="supplement-overlay" id="supplement-overlay" role="dialog" aria-modal="true">
1793
2034
  <div class="supplement-backdrop" data-supplement-close></div>
@@ -1806,6 +2047,7 @@
1806
2047
  <div class="supplement-body">
1807
2048
  <textarea class="supplement-input" data-supplement-input rows="6" placeholder="${this.escape(t.placeholder)}"></textarea>
1808
2049
  <p class="supplement-hint">${this.escape(t.hint)}</p>
2050
+ ${this.renderBriefModePicker(defaultMode)}
1809
2051
  </div>
1810
2052
  <footer class="supplement-foot">
1811
2053
  <button type="button" class="supplement-cancel" data-supplement-close>${this.escape(t.cancel)}</button>
@@ -2019,7 +2261,7 @@
2019
2261
  const adjournedLine = room.adjournedAt
2020
2262
  ? `${t.adjournedAtPrefix} ${this.timeFmt(room.adjournedAt)}`
2021
2263
  : "";
2022
- const subjectShort = (room.subject || "(no subject)").slice(0, 140);
2264
+ const subjectFull = room.subject || "(no subject)";
2023
2265
 
2024
2266
  // Tone + intensity inherit from parent. Trigger uses the same
2025
2267
  // `.cmp-dd` markup as the new-room composer's toolbar buttons —
@@ -2069,8 +2311,11 @@
2069
2311
  </header>
2070
2312
  <div class="supplement-body">
2071
2313
  <div class="followup-parent-card">
2072
- <div class="followup-parent-subject">${this.escape(subjectShort)}</div>
2073
- <div class="followup-parent-meta">${this.escape(adjournedLine)}${adjournedLine && briefLine ? " · " : ""}${this.escape(briefLine)}</div>
2314
+ <div class="followup-parent-subject is-clamped" data-followup-subject-text>${this.escape(subjectFull)}</div>
2315
+ <div class="followup-parent-meta-row">
2316
+ <button type="button" class="followup-parent-subject-toggle" data-followup-subject-toggle hidden>Show more</button>
2317
+ <div class="followup-parent-meta">${this.escape(adjournedLine)}${adjournedLine && briefLine ? " · " : ""}${this.escape(briefLine)}</div>
2318
+ </div>
2074
2319
  <div class="followup-parent-note">${this.escape(t.contextNote)}</div>
2075
2320
  </div>
2076
2321
 
@@ -2144,6 +2389,24 @@
2144
2389
  // so listeners are GC'd when the modal is removed.
2145
2390
  const overlayEl = document.getElementById("followup-overlay");
2146
2391
  if (overlayEl) {
2392
+ // Subject clamp · long parent-room subjects clamp to 2 lines
2393
+ // by default. Post-mount measurement reveals the Show more /
2394
+ // less toggle only when the text actually overflows. rAF
2395
+ // settles initial layout so scrollHeight is meaningful.
2396
+ const subjEl = overlayEl.querySelector("[data-followup-subject-text]");
2397
+ const subjBtn = overlayEl.querySelector("[data-followup-subject-toggle]");
2398
+ if (subjEl && subjBtn) {
2399
+ requestAnimationFrame(() => {
2400
+ if (subjEl.scrollHeight > subjEl.clientHeight + 1) {
2401
+ subjBtn.hidden = false;
2402
+ }
2403
+ });
2404
+ subjBtn.addEventListener("click", (ev) => {
2405
+ ev.preventDefault();
2406
+ const expanded = subjEl.classList.toggle("is-clamped") === false;
2407
+ subjBtn.textContent = expanded ? "Show less" : "Show more";
2408
+ });
2409
+ }
2147
2410
  const sameCheckbox = overlayEl.querySelector("[data-followup-same-cast]");
2148
2411
  const castBtn = overlayEl.querySelector("[data-followup-cast-btn]");
2149
2412
  if (sameCheckbox && castBtn) {
@@ -2455,6 +2718,17 @@
2455
2718
  if (input) input.focus();
2456
2719
  return;
2457
2720
  }
2721
+ // Read the report-mode picker (research-note / bento). The
2722
+ // overlay defaults the picker to the existing brief's mode, so a
2723
+ // plain "regenerate with this perspective" keeps the same
2724
+ // format. Persisting the choice keeps the picker stable next
2725
+ // time. Server-side the same `/api/rooms/:id/brief` route reads
2726
+ // `mode` regardless of whether the call came from supplement or
2727
+ // generate-brief, so no backend change is needed.
2728
+ const briefModeInput = overlay.querySelector('input[name="brief-mode"]:checked');
2729
+ const v = briefModeInput && briefModeInput.value;
2730
+ const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
2731
+ this.saveLastBriefMode(briefMode);
2458
2732
  // Frontend in-flight guard · without this, a slow server roundtrip
2459
2733
  // gives the user time to click confirm twice (or to close+reopen
2460
2734
  // the overlay and click again). Each click was firing its own POST,
@@ -2473,7 +2747,7 @@
2473
2747
  {
2474
2748
  method: "POST",
2475
2749
  headers: { "content-type": "application/json" },
2476
- body: JSON.stringify({ supplement: text }),
2750
+ body: JSON.stringify({ supplement: text, mode: briefMode }),
2477
2751
  },
2478
2752
  );
2479
2753
  if (!r.ok) {
@@ -2504,7 +2778,7 @@
2504
2778
  * whether streaming is actually in flight. */
2505
2779
  isBriefPlaceholder(brief, room) {
2506
2780
  if (!brief) return false;
2507
- if (brief.bodyMd && brief.bodyMd.trim()) return false;
2781
+ if (this.briefHasBody(brief)) return false;
2508
2782
  if (!room) return true;
2509
2783
  // Title matches the seed (room subject) → never got an updated title.
2510
2784
  // Or title is the literal "Generating…" placeholder.
@@ -2621,7 +2895,7 @@
2621
2895
  const target = (this.currentBriefs || []).find((b) => b.id === briefId);
2622
2896
  // System UI · always English. Confirm + alert dialogs are app
2623
2897
  // chrome and stay fixed-string regardless of brief language.
2624
- const isStillGenerating = !!(target && (!target.bodyMd || !target.bodyMd.trim()));
2898
+ const isStillGenerating = !!(target && !this.briefHasBody(target));
2625
2899
  const confirmText = isStillGenerating
2626
2900
  ? "This report is still generating. Deleting will stop the generation and remove the report. This can't be undone."
2627
2901
  : (target?.supplement
@@ -2723,17 +2997,23 @@
2723
2997
  const mode = overlay.getAttribute("data-adjourn-mode") || "adjourn";
2724
2998
  const isGen = mode === "generate-brief";
2725
2999
  const skipPicked = overlay.querySelector(".adjourn-skip-btn.picked") !== null;
3000
+ // Read the report-mode picker (research-note / bento). Persist the
3001
+ // choice so the picker defaults to the same option next time.
3002
+ const briefModeInput = overlay.querySelector('input[name="brief-mode"]:checked');
3003
+ const v = briefModeInput && briefModeInput.value;
3004
+ const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
3005
+ this.saveLastBriefMode(briefMode);
2726
3006
  const btn = overlay.querySelector("[data-adjourn-confirm]");
2727
3007
  const origLabel = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
2728
3008
  const busyLabel = isGen ? "[ Generating… ]" : "[ Adjourning… ]";
2729
3009
  if (btn) { btn.disabled = true; btn.textContent = busyLabel; }
2730
3010
  try {
2731
3011
  if (isGen) {
2732
- await this.generateBriefForAdjournedRoom();
3012
+ await this.generateBriefForAdjournedRoom(briefMode);
2733
3013
  } else if (skipPicked) {
2734
3014
  await this.adjournRoom({ skipBrief: true });
2735
3015
  } else {
2736
- await this.adjournRoom({});
3016
+ await this.adjournRoom({ mode: briefMode });
2737
3017
  }
2738
3018
  this.closeAdjournOverlay();
2739
3019
  } catch (e) {
@@ -2746,8 +3026,9 @@
2746
3026
  * whose user originally skipped the brief. Server emits the same
2747
3027
  * brief-started / brief-token / brief-final SSE events as a normal
2748
3028
  * adjourn, so the existing handlers in connectSSE handle the rest. */
2749
- async generateBriefForAdjournedRoom() {
3029
+ async generateBriefForAdjournedRoom(mode) {
2750
3030
  if (!this.currentRoomId) return;
3031
+ const briefMode = this.isStructuredBriefMode(mode) ? mode : "research-note";
2751
3032
  // Pre-flight · the brief writer is a 3-stage LLM pipeline (per-
2752
3033
  // director extract → composer → final write). All require a key.
2753
3034
  if (!(await this.requireModelKey())) return;
@@ -2756,7 +3037,7 @@
2756
3037
  {
2757
3038
  method: "POST",
2758
3039
  headers: { "content-type": "application/json" },
2759
- body: "{}",
3040
+ body: JSON.stringify({ mode: briefMode }),
2760
3041
  },
2761
3042
  );
2762
3043
  if (!r.ok) {
@@ -3627,6 +3908,47 @@
3627
3908
  this.composerMode = "room";
3628
3909
  },
3629
3910
 
3911
+ /** Toggle the `isPinned` flag for an agent · the sidebar pin
3912
+ * button calls this. Persists to the server (PATCH /api/agents/:id
3913
+ * { isPinned }), updates local state in place, then re-renders
3914
+ * the sidebar so the row moves between the Pinned / Custom /
3915
+ * Core buckets. Optimistic with rollback on failure: we flip the
3916
+ * flag locally + repaint immediately, then revert if the PATCH
3917
+ * fails — no waiting on the round-trip for visible feedback. */
3918
+ async togglePinAgent(agentId) {
3919
+ if (!agentId) return;
3920
+ const idx = (this.agents || []).findIndex((a) => a && a.id === agentId);
3921
+ if (idx < 0) return;
3922
+ const agent = this.agents[idx];
3923
+ const prev = !!agent.isPinned;
3924
+ const next = !prev;
3925
+ // Optimistic flip · render immediately so the click feels instant.
3926
+ agent.isPinned = next;
3927
+ this.agentsById[agent.id] = agent;
3928
+ this.renderSidebarAgents();
3929
+ try {
3930
+ const r = await fetch("/api/agents/" + encodeURIComponent(agentId), {
3931
+ method: "PATCH",
3932
+ headers: { "content-type": "application/json" },
3933
+ body: JSON.stringify({ isPinned: next }),
3934
+ });
3935
+ if (!r.ok) throw new Error("HTTP " + r.status);
3936
+ // Server returns the updated row · merge the canonical fields
3937
+ // back in so any side-effect updates (updated_at, etc.) land.
3938
+ const updated = await r.json();
3939
+ if (updated && updated.id) {
3940
+ this.agents[idx] = updated;
3941
+ this.agentsById[updated.id] = updated;
3942
+ }
3943
+ } catch (e) {
3944
+ // Rollback · keep the UI honest about persistence failure.
3945
+ agent.isPinned = prev;
3946
+ this.agentsById[agent.id] = agent;
3947
+ this.renderSidebarAgents();
3948
+ alert((e && e.message ? e.message : "pin failed") + " — try again.");
3949
+ }
3950
+ },
3951
+
3630
3952
  /** Render the sidebar's Agents panel from the live agent catalog.
3631
3953
  * Three buckets so the seeded directors keep their familiar
3632
3954
  * groupings while user-created ones stack into a Custom section
@@ -4346,7 +4668,44 @@
4346
4668
  if (brief) brief.innerHTML = "";
4347
4669
 
4348
4670
  const chatScroller = document.querySelector(".chat");
4349
- if (chatScroller) chatScroller.scrollTop = 0;
4671
+ if (chatScroller) {
4672
+ chatScroller.scrollTop = 0;
4673
+ // Mark the chat as hosting a composer so the CSS rule
4674
+ // `.chat.chat--composer` flips it to a grid with
4675
+ // `align-content: center` — vertically centring the
4676
+ // composer when it fits the viewport. When .cmp's natural
4677
+ // height > .chat height we also add `.chat--composer-overflow`
4678
+ // (via updateComposerOverflow) which switches to a layout
4679
+ // where the .cmp-fold (hero + input) is min-height: 100% of
4680
+ // the chat with content centred inside, and .cmp-starters
4681
+ // flow below as scrollable extras. Removed in renderChat()
4682
+ // when real chat messages take over.
4683
+ chatScroller.classList.add("chat--composer");
4684
+ this.updateComposerOverflow();
4685
+ }
4686
+ },
4687
+
4688
+ /** Toggle `.chat--composer-overflow` on the chat scroller based on
4689
+ * whether the composer's natural height exceeds the visible chat
4690
+ * area. In overflow mode the hero (`.cmp-fold`) anchors at the
4691
+ * viewport centre and `.cmp-starters` flow below the fold;
4692
+ * without overflow, the parent's `align-content: center` keeps
4693
+ * the whole composer block centred. Called after composer render
4694
+ * and on window resize. */
4695
+ updateComposerOverflow() {
4696
+ const chat = document.querySelector(".chat.chat--composer");
4697
+ if (!chat) return;
4698
+ const cmp = chat.querySelector(".cmp");
4699
+ if (!cmp) return;
4700
+ requestAnimationFrame(() => {
4701
+ chat.classList.remove("chat--composer-overflow");
4702
+ const overflows = cmp.scrollHeight > chat.clientHeight + 1;
4703
+ chat.classList.toggle("chat--composer-overflow", overflows);
4704
+ });
4705
+ if (!this._composerResizeAttached) {
4706
+ this._composerResizeAttached = true;
4707
+ window.addEventListener("resize", () => this.updateComposerOverflow());
4708
+ }
4350
4709
  },
4351
4710
 
4352
4711
  /** Open the All Reports page · cross-room brief index in a card
@@ -4723,16 +5082,34 @@
4723
5082
  }
4724
5083
  },
4725
5084
 
4726
- /** Single reading-list row · room kicker, title, judgement excerpt,
5085
+ /** Single reading-list row · room kicker, title, subtitle excerpt,
4727
5086
  * meta tail. No card chrome — entries are separated by a hairline
4728
- * inside the list. */
5087
+ * inside the list.
5088
+ *
5089
+ * Subtitle source by mode:
5090
+ * · Report → bodyJson.bottomLine.judgement
5091
+ * · Magazine → bodyJson.kicker
5092
+ * · Newspaper → bodyJson.kicker
5093
+ * All three fall back to a cleaned-up first content paragraph
5094
+ * from bodyMd if the structured field is missing. */
4729
5095
  renderReportItemHtml(b) {
4730
5096
  const json = b.bodyJson || {};
4731
5097
  const bottomLine = json.bottomLine || {};
4732
- const judgement = (bottomLine.judgement || "").trim() || (b.bodyMd || "").slice(0, 240);
5098
+ // Pull the right subtitle for this mode. Magazine / newspaper
5099
+ // carry their deck/lede text in `kicker`; Report carries it
5100
+ // in `bottomLine.judgement`. Either may be missing on legacy
5101
+ // / partial briefs — fall back to the first prose paragraph
5102
+ // from bodyMd.
5103
+ const subtitle = (
5104
+ (bottomLine.judgement || "").trim() ||
5105
+ (json.kicker || "").trim() ||
5106
+ this._extractBriefExcerpt(b.bodyMd) ||
5107
+ ""
5108
+ );
4733
5109
  const time = this.relTime(b.createdAt) || "";
4734
5110
  const roomLabel = b.roomName || b.roomSubject || "—";
4735
5111
  const roomNumLabel = b.roomNumber != null ? `#${String(b.roomNumber).padStart(3, "0")}` : "";
5112
+ const typeLabel = this.briefModeLabel(b);
4736
5113
  const findingsCount = Array.isArray(json.headlineFindings) ? json.headlineFindings.length : 0;
4737
5114
  const positionsCount = Array.isArray(json.positions) ? json.positions.length : 0;
4738
5115
  const metaParts = [];
@@ -4749,16 +5126,45 @@
4749
5126
  <span class="reports-item-num">${this.escape(roomNumLabel)}</span>
4750
5127
  <span class="reports-item-sep">·</span>
4751
5128
  <span class="reports-item-room">${this.escape(roomLabel)}</span>
5129
+ <span class="reports-item-sep">·</span>
5130
+ <span class="reports-item-type" data-mode="${this.escape(((b && b.mode) || "research-note").toLowerCase())}">${this.escape(typeLabel)}</span>
4752
5131
  <span class="reports-item-time">${this.escape(time)}</span>
4753
5132
  </div>
4754
5133
  <h3 class="reports-item-title">${this.escape(b.title || "Untitled brief")}</h3>
4755
- ${judgement ? `<p class="reports-item-judgement">${this.escape(judgement)}</p>` : ""}
5134
+ ${subtitle ? `<p class="reports-item-judgement">${this.escape(subtitle)}</p>` : ""}
4756
5135
  ${metaHtml}
4757
5136
  </a>
4758
5137
  </li>
4759
5138
  `;
4760
5139
  },
4761
5140
 
5141
+ /** Pull the first prose paragraph from a brief's bodyMd as a
5142
+ * subtitle fallback. Skips markdown headers, code fences, table
5143
+ * rows, list markers, blockquotes — finds the first content line
5144
+ * that reads as prose. Strips inline markdown (bold/italic/code/
5145
+ * link syntax) so the excerpt reads as plain text. */
5146
+ _extractBriefExcerpt(md) {
5147
+ if (!md || typeof md !== "string") return "";
5148
+ const lines = md.split("\n");
5149
+ for (const line of lines) {
5150
+ const t = line.trim();
5151
+ if (!t) continue;
5152
+ if (t.startsWith("#")) continue; // markdown heading
5153
+ if (t.startsWith("```")) continue; // code fence
5154
+ if (t.startsWith("|") || /^[-=]{3,}$/.test(t)) continue; // table / hr
5155
+ if (t.startsWith("> ")) continue; // blockquote
5156
+ if (/^[-*+]\s/.test(t)) continue; // list item
5157
+ if (/^\d+\.\s/.test(t)) continue; // ordered list item
5158
+ const cleaned = t
5159
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
5160
+ .replace(/\*([^*]+)\*/g, "$1")
5161
+ .replace(/`([^`]+)`/g, "$1")
5162
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
5163
+ if (cleaned.length >= 12) return cleaned.slice(0, 240);
5164
+ }
5165
+ return md.slice(0, 240).replace(/\s+/g, " ").trim();
5166
+ },
5167
+
4762
5168
  // ── All Notes view · chairman's notes index ───────────────
4763
5169
  /** Open the All Notes page · cross-room saved-excerpt index in
4764
5170
  * the same main-view-replacement pattern as openAllReports.
@@ -5437,10 +5843,12 @@
5437
5843
  // of which composer we're switching to.
5438
5844
  document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
5439
5845
  document.querySelectorAll("[data-notes-trigger].active").forEach((el) => el.classList.remove("active"));
5440
- // If the URL still carries the All-Reports hash from a prior
5441
- // navigation, drop it — otherwise refresh would bounce the user
5442
- // back to the reports view.
5443
- if (/^#\/reports$/i.test(location.hash || "")) {
5846
+ // If the URL still carries an All-Reports / All-Notes hash from
5847
+ // a prior navigation, drop it — otherwise refresh would bounce
5848
+ // the user back to that view (handleRoute matches the hash on
5849
+ // boot and beats the sidebar-restore path that would otherwise
5850
+ // honour ROOMS_KEY="new").
5851
+ if (/^#\/(reports|notes)$/i.test(location.hash || "")) {
5444
5852
  try { history.replaceState(null, "", location.pathname + location.search); } catch { /* ignore */ }
5445
5853
  }
5446
5854
 
@@ -5481,7 +5889,7 @@
5481
5889
  const t = {
5482
5890
  greet: greeting,
5483
5891
  prompt: "What's on your mind today?",
5484
- placeholder: "an idea you're not sure about · a decision you keep avoiding · a thesis you want stress-tested",
5892
+ placeholder: "An idea you're not sure about, a decision you keep avoiding, or a thesis you want stress-tested. e.g. Should we lean into mid-market or stay enterprise-only? Or: What's the strongest argument against shipping the self-serve tier next quarter?",
5485
5893
  convene: "Convene",
5486
5894
  tuneLabel: "tune",
5487
5895
  starterLabel: "starter",
@@ -6610,7 +7018,7 @@
6610
7018
  year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
6611
7019
  })
6612
7020
  : "";
6613
- const href = `/report.html?r=${encodeURIComponent(roomId)}&b=${encodeURIComponent(b.id)}`;
7021
+ const href = this.briefViewerHref(b, roomId);
6614
7022
  return `
6615
7023
  <a class="brief-picker-row" href="${this.escape(href)}" target="_blank" rel="noopener" data-brief-picker-row data-brief-id="${this.escape(b.id)}">
6616
7024
  <span class="brief-picker-num">${this.escape(num)}</span>
@@ -7226,18 +7634,16 @@
7226
7634
 
7227
7635
  const tone = r.mode || "constructive";
7228
7636
  const intensity = r.intensity || "sharp";
7229
- const style = r.briefStyle || "auto";
7230
7637
 
7231
- // Status timestamp what was on the right (paused-stamp / stamp) now
7232
- // lives inline in the meta row.
7233
- let stamp = "";
7234
- if (r.status === "paused" && r.pausedAt) {
7235
- stamp = `paused ${this.relTime(r.pausedAt)} ago`;
7236
- } else if (r.status === "adjourned" && r.adjournedAt) {
7237
- stamp = `adjourned ${this.relTime(r.adjournedAt)} ago`;
7238
- } else if (r.status === "live" && r.createdAt) {
7239
- stamp = `opened ${this.relTime(r.createdAt)} ago`;
7240
- }
7638
+ // Status word for the kicker · the coloured glyph (●/❚❚/▣) is
7639
+ // drawn via CSS `::before` on `.kicker-status.status-{state}`
7640
+ // so all the styling lives next to the colour rule.
7641
+ const statusWord = (
7642
+ r.status === "paused" ? "PAUSED" :
7643
+ r.status === "adjourned" ? "ADJOURNED" :
7644
+ r.status === "live" ? "LIVE" :
7645
+ ""
7646
+ );
7241
7647
 
7242
7648
  // Three primary actions, one per state — CSS hides the wrong ones based
7243
7649
  // on html[data-status]. Per PRD §5.2.3:
@@ -7250,20 +7656,24 @@
7250
7656
  // the DOM stays stable; the collapsed state flips room-head's
7251
7657
  // grid template to a 3-track layout so this button takes the
7252
7658
  // leading auto-track slot.
7659
+ //
7660
+ // Compact two-row layout: kicker (mono, all the meta) + subject.
7661
+ // Replaces the prior three-row stack (badge+id / subject /
7662
+ // tagged-meta-pills) — net height reduction ~150 → ~85px.
7663
+ // Tone / intensity / brief-style remain editable in the room
7664
+ // settings overlay; only their on-header surface is collapsed.
7253
7665
  head.innerHTML = `
7254
7666
  <button type="button" class="room-head-expand" data-sidebar-expand title="Expand sidebar" aria-label="Expand sidebar"></button>
7255
7667
  <div class="room-info">
7256
- <div class="room-id">
7257
- <span class="room-name">Meeting Room</span>
7258
- <span class="session-num">// ROOM #${r.number} · ${this.escape(tone.toUpperCase())}</span>
7668
+ <div class="room-kicker">
7669
+ <span class="kicker-num">// ROOM #${r.number}</span>
7670
+ <span class="kicker-sep">·</span>
7671
+ <span class="kicker-tone" data-tone-tip="${this.escape(TONE_TIPS[tone] || "")}">${this.escape(tone.toUpperCase())}</span>
7672
+ <span class="kicker-sep">·</span>
7673
+ <span class="kicker-intensity">${this.escape(intensity.toUpperCase())}</span>
7674
+ ${statusWord ? `<span class="kicker-sep">·</span><span class="kicker-status status-${this.escape(r.status)}">${statusWord}</span>` : ""}
7259
7675
  </div>
7260
7676
  <h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(r.subject)}</h1>
7261
- <div class="room-meta" data-room-meta>
7262
- <span class="meta-tag tag-tone" data-tone-tip="${this.escape(TONE_TIPS[tone] || "")}"><span class="k">tone</span><span class="v">${this.escape(tone)}</span></span>
7263
- <span class="meta-tag tag-intensity"><span class="k">intensity</span><span class="v">${this.escape(intensity)}</span></span>
7264
- <span class="meta-tag tag-report"><span class="k">report</span><span class="v">${this.escape(style)}</span></span>
7265
- ${stamp ? `<span class="meta-stamp">${this.escape(stamp)}</span>` : ""}
7266
- </div>
7267
7677
  </div>
7268
7678
  <div class="head-actions">
7269
7679
  <div class="head-cast">${castHtml}</div>
@@ -7280,7 +7690,7 @@
7280
7690
  // opens the picker.
7281
7691
  const briefs = Array.isArray(this.currentBriefs) ? this.currentBriefs : [];
7282
7692
  const multi = briefs.length > 1;
7283
- const directHref = `/report.html?r=${this.escape(r.id)}${this.currentBrief.id ? `&b=${this.escape(this.currentBrief.id)}` : ""}`;
7693
+ const directHref = this.briefViewerHref(this.currentBrief, r.id) || `/report.html?r=${this.escape(r.id)}`;
7284
7694
  if (!multi) {
7285
7695
  return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report>[ View Report ]</a>`;
7286
7696
  }
@@ -7296,8 +7706,9 @@
7296
7706
  // overflow:hidden chain (.main / .main-view both clip absolutely-
7297
7707
  // positioned descendants and CAN'T be relaxed without breaking
7298
7708
  // chat scroll). Body-attached fixed-position tooltip is the only
7299
- // reliable approach.
7300
- const toneTag = head.querySelector(".meta-tag.tag-tone[data-tone-tip]");
7709
+ // reliable approach. Anchor moved from the old .meta-tag pill to
7710
+ // .kicker-tone in the compact two-row header.
7711
+ const toneTag = head.querySelector(".kicker-tone[data-tone-tip]");
7301
7712
  if (toneTag) {
7302
7713
  toneTag.addEventListener("mouseenter", () => this.showToneTip(toneTag));
7303
7714
  toneTag.addEventListener("mouseleave", () => this.hideToneTip());
@@ -7342,6 +7753,14 @@
7342
7753
  renderChat() {
7343
7754
  const chat = document.querySelector("[data-chat-messages]");
7344
7755
  if (!chat) return;
7756
+ // Strip the composer-centring class · real chat messages take
7757
+ // over `[data-chat-messages]` here, and chat-message mode wants
7758
+ // the default top-flow scroll (messages stack from the top).
7759
+ const chatScroller = chat.closest(".chat");
7760
+ if (chatScroller) {
7761
+ chatScroller.classList.remove("chat--composer");
7762
+ chatScroller.classList.remove("chat--composer-overflow");
7763
+ }
7345
7764
  const messages = this.currentMessages.slice();
7346
7765
  const r = this.currentRoom;
7347
7766
  const banner = r
@@ -7872,7 +8291,7 @@
7872
8291
  // tight (~4 lines of body text) so even mid-length openers
7873
8292
  // collapse — the user can always one-click to read the full
7874
8293
  // text. Toggle handler lives at doc level (see init).
7875
- const isLongOpener = (m.body || "").length > 100;
8294
+ const isLongOpener = (m.body || "").length > 80;
7876
8295
  // Follow-up rooms get an "origin" row above the question that
7877
8296
  // names the parent room (number + subject) and links back to
7878
8297
  // it via the hash route. Without this, a follow-up looks
@@ -8722,7 +9141,7 @@
8722
9141
  // a failed-brief tab becomes unreachable — clicking it just
8723
9142
  // re-renders whichever good brief the salvage path picks.
8724
9143
  const goodBrief = bypassSalvage ? null : (this.currentBriefs || []).find(
8725
- (x) => x && x.id !== b.id && !x.error && x.bodyMd && x.bodyMd.trim().length > 0,
9144
+ (x) => x && x.id !== b.id && !x.error && this.briefHasBody(x),
8726
9145
  );
8727
9146
  if (goodBrief) {
8728
9147
  const failed = b;
@@ -8782,6 +9201,7 @@
8782
9201
  ${tabsStripHtml}
8783
9202
  <div class="brief-banner">
8784
9203
  <span class="brief-banner-tag" style="color: var(--red);">// report</span>
9204
+ <span class="brief-banner-type" style="color: var(--red); border-color: var(--red);">${this.escape(this.briefModeLabel(b))}</span>
8785
9205
  <span class="brief-banner-stamp" style="color: var(--red);">${this.escape(copy.stamp)}</span>
8786
9206
  </div>
8787
9207
  <div class="brief-body brief-body-error">
@@ -8804,7 +9224,7 @@
8804
9224
  return;
8805
9225
  }
8806
9226
 
8807
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9227
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
8808
9228
  const signed = this.currentMembers
8809
9229
  .map((a) => `<img src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`)
8810
9230
  .join("");
@@ -8813,12 +9233,13 @@
8813
9233
  ? "GENERATING…"
8814
9234
  : "FILED · " + (b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()));
8815
9235
 
8816
- // Open Report links to /report.html with both r (room) and b
8817
- // (brief id) — the viewer uses b when present so refining
8818
- // shows the right version.
8819
- const reportHref = this.currentRoomId && b.id
8820
- ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}&b=${encodeURIComponent(b.id)}`
8821
- : (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
9236
+ // Open Report URL · routes to /magazine.html or /newspaper.html
9237
+ // for the structured modes and /report.html for research-note
9238
+ // briefs (default). The structured renderers don't need the
9239
+ // room id so the URL is shorter for those modes. See
9240
+ // briefViewerHref for the routing table.
9241
+ const reportHref = this.briefViewerHref(b, this.currentRoomId)
9242
+ || (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
8822
9243
 
8823
9244
  // Tab strip — already computed at the top of renderBrief as
8824
9245
  // `tabsStripHtml` so both error and success paths can mount it.
@@ -8837,6 +9258,7 @@
8837
9258
  ${tabsHtml}
8838
9259
  <div class="brief-banner">
8839
9260
  <span class="brief-banner-tag">// report</span>
9261
+ <span class="brief-banner-type">${this.escape(this.briefModeLabel(b))}</span>
8840
9262
  <span class="brief-banner-stamp">${filedLabel}</span>
8841
9263
  </div>
8842
9264
 
@@ -8955,11 +9377,47 @@
8955
9377
  "Writing the 3 Headline Findings",
8956
9378
  "Drafting Convergence + Divergence sections",
8957
9379
  "Composing Recommendations",
8958
- "Writing the Pre-mortem",
8959
9380
  "Surfacing New Questions",
8960
9381
  "Drafting the Strategic Planning Assumption",
8961
9382
  "Polishing the final pass",
8962
9383
  ],
9384
+ // Magazine mode · same single-pass chair-LLM call but the
9385
+ // emphasis is on editorial cover-line composition rather
9386
+ // than infographic compression. Keyed under `magazine-write`
9387
+ // and selected at render time when the brief's mode is
9388
+ // magazine.
9389
+ "magazine-write": [
9390
+ "Drafting the cover headline",
9391
+ "Writing the subdeck",
9392
+ "Picking the 5 numbered cards",
9393
+ "Sequencing the 3 setup steps",
9394
+ "Selecting why-this-matters reasons",
9395
+ "Stamping the masthead byline",
9396
+ ],
9397
+ // Newspaper mode · same single-pass chair-LLM call but voice
9398
+ // shifts to broadsheet front-page journalism. Keyed under
9399
+ // `newspaper-write` and selected at render time when the
9400
+ // brief's mode is newspaper.
9401
+ "newspaper-write": [
9402
+ "Setting the banner headline",
9403
+ "Writing the subdeck",
9404
+ "Filing the three column stories",
9405
+ "Drafting the bottom-line callout",
9406
+ "Stacking the more-headings sidebar",
9407
+ "Stamping the masthead date",
9408
+ ],
9409
+ // PPT mode · same single-pass chair-LLM call · biases toward
9410
+ // slide-friendly short claims and one-idea-per-slide content.
9411
+ // Keyed under `ppt-write` and selected at render time when
9412
+ // the brief's mode is ppt.
9413
+ "ppt-write": [
9414
+ "Drafting the cover slide",
9415
+ "Sketching the agenda",
9416
+ "Writing milestone slides",
9417
+ "Compressing recommendations to bullets",
9418
+ "Sizing the data callouts",
9419
+ "Stamping the closing takeaway",
9420
+ ],
8963
9421
  },
8964
9422
  // System UI · always English. The `zh` key is kept as an alias
8965
9423
  // of `en` so any caller indexing BRIEF_SUBSTAGES["zh"] still
@@ -8980,7 +9438,7 @@
8980
9438
  }
8981
9439
  const stages = b.stages || {};
8982
9440
  const anyActive = Object.values(stages).some((s) => s && s.status === "active");
8983
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9441
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
8984
9442
  if (!anyActive && !generating) {
8985
9443
  this.stopBriefStageTick();
8986
9444
  return;
@@ -9031,7 +9489,7 @@
9031
9489
  if (this._briefStallWatchTimer) return;
9032
9490
  const b = this.currentBrief;
9033
9491
  if (!b || !b.id || b.error) return;
9034
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9492
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
9035
9493
  if (!generating) return;
9036
9494
  if (!this._lastBriefEventAt) this._lastBriefEventAt = Date.now();
9037
9495
  this._lastBriefHealthPollAt = 0;
@@ -9051,7 +9509,7 @@
9051
9509
  async tickBriefStallWatch() {
9052
9510
  const b = this.currentBrief;
9053
9511
  if (!b || b.error) { this.stopBriefStallWatch(); return; }
9054
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9512
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
9055
9513
  if (!generating) { this.stopBriefStallWatch(); return; }
9056
9514
 
9057
9515
  const now = Date.now();
@@ -9112,27 +9570,43 @@
9112
9570
  ? (b.bodyMd.trim().match(/\S+/g) || []).length
9113
9571
  : 0;
9114
9572
 
9115
- // Stage definitions · 7 ordered cells. Must align with the wire
9116
- // format emitted by emitStage() in src/orchestrator/brief.ts:
9573
+ // Stage definitions · mode-aware. The wire format emitted by
9574
+ // emitStage() in src/orchestrator/brief.ts depends on which
9575
+ // pipeline ran:
9576
+ //
9577
+ // research-note (default) · extract → compose → scaffold-
9578
+ // {anchor,findings,cluster,actions} → write (7 stages · the
9579
+ // 4 scaffold sub-stages are driven by JSON-key arrival in
9580
+ // the streaming buffer, see SCAFFOLD_TRIGGERS in brief.ts)
9117
9581
  //
9118
- // extract → compose scaffold-{anchor,findings,cluster,actions} write
9582
+ // bento + magazine · extract → write (2 stages · runBentoStage
9583
+ // runs ONE chair-LLM call that produces the BentoScaffold;
9584
+ // composer and the scaffold sub-stages are skipped entirely.
9585
+ // Both modes share the same 2-stage rail · only the write
9586
+ // label and renderer differ)
9119
9587
  //
9120
- // The 4 scaffold sub-stages are driven by JSON-key arrival in the
9121
- // Stage 2 streaming buffer (see runStage2 / SCAFFOLD_TRIGGERS in
9122
- // brief.ts), so each pip transition reflects a real moment in the
9123
- // model's output — not a synthetic timer.
9124
- // System UI · always English regardless of brief language.
9125
- // The pipeline labels are the app's voice (chrome around the
9588
+ // System UI · always English regardless of brief language. The
9589
+ // pipeline labels are the app's voice (chrome around the
9126
9590
  // generation), not the report content itself.
9127
- const STAGE_DEFS = [
9128
- { key: "extract", label: "Reading what each director said", pipShort: "read" },
9129
- { key: "compose", label: "Picking the report shape", pipShort: "pick" },
9130
- { key: "scaffold-anchor", label: "Setting the anchor", pipShort: "anchor" },
9131
- { key: "scaffold-findings", label: "Sketching findings", pipShort: "find" },
9132
- { key: "scaffold-cluster", label: "Mapping consensus + dissent", pipShort: "split" },
9133
- { key: "scaffold-actions", label: "Drafting actions + risks", pipShort: "act" },
9134
- { key: "write", label: "Writing the report", pipShort: "write" },
9135
- ];
9591
+ const STRUCTURED_WRITE_LABELS = {
9592
+ magazine: "Composing the magazine",
9593
+ newspaper: "Composing the newspaper",
9594
+ ppt: "Composing the deck",
9595
+ };
9596
+ const STAGE_DEFS = this.isStructuredBriefMode(b.mode)
9597
+ ? [
9598
+ { key: "extract", label: "Reading what each director said", pipShort: "read" },
9599
+ { key: "write", label: STRUCTURED_WRITE_LABELS[b.mode] || "Composing the report", pipShort: "compose" },
9600
+ ]
9601
+ : [
9602
+ { key: "extract", label: "Reading what each director said", pipShort: "read" },
9603
+ { key: "compose", label: "Picking the report shape", pipShort: "pick" },
9604
+ { key: "scaffold-anchor", label: "Setting the anchor", pipShort: "anchor" },
9605
+ { key: "scaffold-findings", label: "Sketching findings", pipShort: "find" },
9606
+ { key: "scaffold-cluster", label: "Mapping consensus + dissent", pipShort: "split" },
9607
+ { key: "scaffold-actions", label: "Drafting actions + risks", pipShort: "act" },
9608
+ { key: "write", label: "Writing the report", pipShort: "write" },
9609
+ ];
9136
9610
 
9137
9611
  const meta = this.BRIEF_STAGE_META;
9138
9612
  const substages = (this.BRIEF_SUBSTAGES[lang] || this.BRIEF_SUBSTAGES.en);
@@ -9184,9 +9658,18 @@
9184
9658
  }
9185
9659
 
9186
9660
  // Rotating sub-line · advances every 3s within the active stage.
9661
+ // Structured-mode write stages get their own rotator copy
9662
+ // (`magazine-write` / `newspaper-write`) since the work is
9663
+ // different from research-note's chapter-by-chapter write.
9664
+ // Falls through to the regular stage key for every other
9665
+ // stage / mode.
9187
9666
  let substageText = "";
9188
- if (activeStatus === "active" && substages[activeDef.key]?.length) {
9189
- const list = substages[activeDef.key];
9667
+ let subKey = activeDef.key;
9668
+ if (activeDef.key === "write" && this.isStructuredBriefMode(b.mode)) {
9669
+ subKey = `${b.mode}-write`;
9670
+ }
9671
+ if (activeStatus === "active" && substages[subKey]?.length) {
9672
+ const list = substages[subKey];
9190
9673
  substageText = list[Math.floor(activeElapsed / 3) % list.length];
9191
9674
  }
9192
9675
 
@@ -9684,6 +10167,27 @@
9684
10167
  });
9685
10168
  return;
9686
10169
  }
10170
+ // Brief-mode picker · clicking a label (or its radio) toggles the
10171
+ // .on class on that option AND off on the siblings. Used by both
10172
+ // the adjourn overlay and the supplement overlay. We scope to the
10173
+ // picker's `.adjourn-mode-options` container (set in
10174
+ // renderBriefModePicker) instead of a specific overlay id, so the
10175
+ // same handler works wherever the picker is embedded. The native
10176
+ // radio behaviour handles `:checked` state but we use a manual
10177
+ // class for visual styling (left accent stripe + tint) since we
10178
+ // can't `:has()` reliably on older browsers.
10179
+ const modeOpt = e.target.closest(".adjourn-mode-option");
10180
+ if (modeOpt) {
10181
+ const group = modeOpt.closest(".adjourn-mode-options");
10182
+ if (group) {
10183
+ group.querySelectorAll(".adjourn-mode-option").forEach((el) => el.classList.remove("on"));
10184
+ modeOpt.classList.add("on");
10185
+ const radio = modeOpt.querySelector('input[type="radio"]');
10186
+ if (radio) radio.checked = true;
10187
+ }
10188
+ // Don't preventDefault · let the label's native click behaviour
10189
+ // also tick the radio for keyboard / a11y users.
10190
+ }
9687
10191
  // Adjourn overlay · "skip report" footer button — clicking commits
9688
10192
  // immediately (mark picked + dispatch + close).
9689
10193
  if (e.target.closest("[data-adjourn-skip]")) {
@@ -9707,6 +10211,18 @@
9707
10211
  app.closeAdjournOverlay();
9708
10212
  return;
9709
10213
  }
10214
+ // Adjourn overlay · subject Show more / less toggle. Flips the
10215
+ // `is-clamped` class on the value span and swaps the button label.
10216
+ const subjToggle = e.target.closest("[data-adjourn-subject-toggle]");
10217
+ if (subjToggle) {
10218
+ e.preventDefault();
10219
+ const subjEl = document.querySelector("[data-adjourn-subject]");
10220
+ if (subjEl) {
10221
+ const expanded = subjEl.classList.toggle("is-clamped") === false;
10222
+ subjToggle.textContent = expanded ? "Show less" : "Show more";
10223
+ }
10224
+ return;
10225
+ }
9710
10226
  // Brief card · "Add a perspective" → opens the supplement overlay.
9711
10227
  if (e.target.closest("[data-brief-supplement]")) {
9712
10228
  e.preventDefault();