privateboard 0.1.7 → 0.1.8

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
@@ -912,11 +912,37 @@
912
912
  }
913
913
  } else if (kind === "brief-started") {
914
914
  this.markBriefEvent();
915
+ // Mode determines BOTH the seeded stage map and which pip
916
+ // rail renderBriefStages picks. Bento, magazine, and
917
+ // newspaper modes each emit only 2 stage events (extract
918
+ // + write), so seeding the 7-stage layout for a
919
+ // structured-mode brief leaves the 5 middle pips forever
920
+ // pending — the rail then renders with a row of dead
921
+ // black pips between extract and write. Read mode from
922
+ // the payload (server includes it on brief-started for
923
+ // exactly this reason) and seed accordingly.
924
+ const isStructured = this.isStructuredBriefMode(payload.mode);
925
+ const briefMode = isStructured ? payload.mode : "research-note";
926
+ const seededStages = isStructured
927
+ ? {
928
+ extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
929
+ write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
930
+ }
931
+ : {
932
+ extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
933
+ compose: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
934
+ "scaffold-anchor": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
935
+ "scaffold-findings": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
936
+ "scaffold-cluster": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
937
+ "scaffold-actions": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
938
+ write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
939
+ };
915
940
  const newBrief = {
916
941
  id: payload.briefId,
917
942
  title: "Generating…",
918
943
  bodyMd: "",
919
944
  style: payload.style || "mckinsey",
945
+ mode: briefMode,
920
946
  // Carry the supplement so the tab strip can render the
921
947
  // in-progress brief with its supplement label ("xxx 视角")
922
948
  // immediately, instead of waiting for brief-final to
@@ -931,21 +957,13 @@
931
957
  language: payload.language === "zh" ? "zh" : "en",
932
958
  pipelineStartedAt: Date.now(),
933
959
  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
- },
960
+ // Stage checklist · seeded according to the brief mode (see
961
+ // briefMode resolution above). brief-stage events flip
962
+ // them active → done as the pipeline progresses.
963
+ // startedAt is captured when each stage first becomes
964
+ // active so the UI can display elapsed time alongside the
965
+ // ETA range.
966
+ stages: seededStages,
949
967
  };
950
968
  this.currentBrief = newBrief;
951
969
  // Insert the in-progress brief into currentBriefs so the tab
@@ -1076,10 +1094,14 @@
1076
1094
  }
1077
1095
  }
1078
1096
  }
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();
1097
+ // No typing-SFX during the brief writer's Stage 3 stream.
1098
+ // The write phase is a long, off-screen streaming pass (the
1099
+ // user is usually reading prior content or doing something
1100
+ // else while the report builds) — a continuous click track
1101
+ // for tens of seconds reads as background noise, not a
1102
+ // presence cue. Director / chair turns still tick because
1103
+ // those land in the active conversation the user is
1104
+ // following.
1083
1105
  } else if (kind === "brief-final") {
1084
1106
  this.markBriefEvent();
1085
1107
  const target = this._briefById(payload.briefId);
@@ -1484,6 +1506,192 @@
1484
1506
  return false;
1485
1507
  },
1486
1508
 
1509
+ /** Has the brief got its body content yet? Mode-aware check ·
1510
+ * research-note briefs land their content in `bodyMd`; bento
1511
+ * briefs land theirs in `bodyJson` and leave bodyMd empty. The
1512
+ * card-rendering / placeholder-detection / generating-check
1513
+ * logic uses this everywhere so a bento brief doesn't stay
1514
+ * stuck in "generating…" state after the BentoScaffold has
1515
+ * actually been persisted.
1516
+ *
1517
+ * Without this helper, a successful bento generation followed
1518
+ * by a page refresh hid the View Report button forever (the
1519
+ * card thought it was still generating because bodyMd was empty
1520
+ * — which is the steady-state shape for bento, not an
1521
+ * in-flight signal). */
1522
+ /** True for any structured-output mode — bento, magazine,
1523
+ * newspaper — that persists to body_json instead of body_md and
1524
+ * runs the single-pass chair-LLM pipeline (extract + write only,
1525
+ * no Stage 2/3 scaffold/write). Centralised so future modes
1526
+ * touch one place. */
1527
+ isStructuredBriefMode(mode) {
1528
+ return mode === "bento" || mode === "magazine" || mode === "newspaper";
1529
+ },
1530
+
1531
+ briefHasBody(b) {
1532
+ if (!b) return false;
1533
+ if (this.isStructuredBriefMode(b.mode)) {
1534
+ // Bento + magazine populate bodyJson with the BentoScaffold.
1535
+ // An empty object isn't a body; check for at least a title
1536
+ // field.
1537
+ const j = b.bodyJson;
1538
+ return !!(j && typeof j === "object" && (j.title || j.milestones));
1539
+ }
1540
+ // Default research-note · body lives in markdown.
1541
+ return !!(b.bodyMd && b.bodyMd.trim());
1542
+ },
1543
+
1544
+ /** Build the right viewer URL for a brief based on its mode.
1545
+ * · 'research-note' (default · or unknown) → `/report.html?r=R&b=B`
1546
+ * · 'bento' → `/bento.html?b=B`
1547
+ * · 'magazine' → `/magazine.html?b=B`
1548
+ * · 'newspaper' → `/newspaper.html?b=B`
1549
+ *
1550
+ * Structured renderers don't need the room context · the brief
1551
+ * is self-contained. Used by the View Report button, the
1552
+ * brief-picker popover, the open-report link in the brief card,
1553
+ * and the All Reports page — one helper so a future renderer
1554
+ * route change touches one place. */
1555
+ briefViewerHref(b, roomId) {
1556
+ if (!b || !b.id) return null;
1557
+ const id = encodeURIComponent(b.id);
1558
+ if (b.mode === "bento") return `/bento.html?b=${id}`;
1559
+ if (b.mode === "magazine") return `/magazine.html?b=${id}`;
1560
+ if (b.mode === "newspaper") return `/newspaper.html?b=${id}`;
1561
+ const r = roomId ? encodeURIComponent(roomId) : "";
1562
+ return r ? `/report.html?r=${r}&b=${id}` : `/report.html?b=${id}`;
1563
+ },
1564
+
1565
+ /** Human-readable label for a brief's mode · used in the brief-card
1566
+ * banner so the user sees which renderer the report will use
1567
+ * before they open it.
1568
+ *
1569
+ * IMPORTANT · the user-facing label for `research-note` is
1570
+ * "Report", NOT "Research Note". The internal mode key stays as
1571
+ * `research-note` (storage column, API value, picker option id),
1572
+ * but every user-facing surface — the mode picker (`renderBrief-
1573
+ * ModePicker` line ~1662 in this file), the report viewer's
1574
+ * header, etc. — calls it "Report". Keep this map in sync with
1575
+ * the picker labels rather than inventing a fresh user-facing
1576
+ * vocabulary.
1577
+ *
1578
+ * Legacy rows without an explicit `mode` default to "Report" by
1579
+ * the same backfill the storage layer applies. */
1580
+ briefModeLabel(b) {
1581
+ const mode = (b && b.mode) || "research-note";
1582
+ switch (mode) {
1583
+ case "bento": return "Bento";
1584
+ case "magazine": return "Magazine";
1585
+ case "newspaper": return "Newspaper";
1586
+ default: return "Report";
1587
+ }
1588
+ },
1589
+
1590
+ /** Render the report-mode picker · four icon-tile cards that let
1591
+ * the user pick between research-note, bento, magazine, and
1592
+ * newspaper. Each tile shows a custom monoline SVG that
1593
+ * visually mirrors that mode's layout — research-note as a
1594
+ * memo, bento as a 4-cell grid, magazine as a masthead +
1595
+ * numeral block + small cards, newspaper as a banner + 3-column
1596
+ * text grid. The native radio is visually hidden (kept in the
1597
+ * DOM for a11y / keyboard nav).
1598
+ *
1599
+ * Used by both the adjourn overlay (filing the report at
1600
+ * adjourn-time / generate-brief flow) AND the supplement
1601
+ * overlay (regenerating with an extra perspective). The picker
1602
+ * reads its selected option via `input[name="brief-mode"]:checked`
1603
+ * from inside whatever overlay it lives in · the click-to-toggle
1604
+ * handler scopes to the `.adjourn-mode-options` container so it
1605
+ * works for any overlay embedding the picker. */
1606
+ renderBriefModePicker(defaultMode) {
1607
+ const safe = this.isStructuredBriefMode(defaultMode) ? defaultMode : "research-note";
1608
+ // Icon SVGs · `currentColor` lets each tile pick up its hover /
1609
+ // selected accent from the parent `.adjourn-mode-icon { color }`
1610
+ // rule. Stroke hierarchy is deliberate · main outlines at 1.0,
1611
+ // a single emphasis rule at 1.2 (newspaper headline + magazine
1612
+ // masthead bar), body details at 0.8. Filled blocks soften to
1613
+ // currentColor at 0.5 opacity so the lime selected-state reads
1614
+ // as a tint rather than a slab. The 24-unit viewBox renders at
1615
+ // 36px on screen → 1.5× scale, so stroke-1.0 is a clean 1.5px.
1616
+ const ICONS = {
1617
+ "research-note": `
1618
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1619
+ <path d="M6 3h9l4 4v14H6z"/>
1620
+ <path d="M15 3v4h4"/>
1621
+ <line x1="9" y1="12" x2="16" y2="12"/>
1622
+ <line x1="9" y1="15" x2="16" y2="15"/>
1623
+ <line x1="9" y1="18" x2="14" y2="18"/>
1624
+ </svg>`,
1625
+ "bento": `
1626
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1627
+ <rect x="3" y="3" width="9" height="13" rx="1.5"/>
1628
+ <rect x="13" y="3" width="8" height="6" rx="1.5"/>
1629
+ <rect x="13" y="10" width="8" height="6" rx="1.5"/>
1630
+ <rect x="3" y="17" width="18" height="4" rx="1.5"/>
1631
+ </svg>`,
1632
+ "magazine": `
1633
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1634
+ <line x1="3" y1="4" x2="21" y2="4" stroke-width="1.2"/>
1635
+ <rect x="3" y="7" width="6" height="9" rx="1.2"/>
1636
+ <rect x="11" y="7" width="4.5" height="4" rx="1.2"/>
1637
+ <rect x="16.5" y="7" width="4.5" height="4" rx="1.2"/>
1638
+ <rect x="11" y="12" width="4.5" height="4" rx="1.2"/>
1639
+ <rect x="16.5" y="12" width="4.5" height="4" rx="1.2"/>
1640
+ <rect x="3" y="18" width="18" height="3" rx="1" fill="currentColor" fill-opacity="0.45" stroke="none"/>
1641
+ </svg>`,
1642
+ "newspaper": `
1643
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1644
+ <rect x="3" y="3" width="18" height="2.5" fill="currentColor" fill-opacity="0.85" stroke="none"/>
1645
+ <line x1="7" y1="8" x2="17" y2="8" stroke-width="1.2"/>
1646
+ <line x1="4" y1="11.5" x2="8" y2="11.5" stroke-width="0.8"/>
1647
+ <line x1="4" y1="13.5" x2="8" y2="13.5" stroke-width="0.8"/>
1648
+ <line x1="4" y1="15.5" x2="6.5" y2="15.5" stroke-width="0.8"/>
1649
+ <line x1="10" y1="11.5" x2="14" y2="11.5" stroke-width="0.8"/>
1650
+ <line x1="10" y1="13.5" x2="14" y2="13.5" stroke-width="0.8"/>
1651
+ <line x1="10" y1="15.5" x2="12.5" y2="15.5" stroke-width="0.8"/>
1652
+ <line x1="16" y1="11.5" x2="20" y2="11.5" stroke-width="0.8"/>
1653
+ <line x1="16" y1="13.5" x2="20" y2="13.5" stroke-width="0.8"/>
1654
+ <line x1="16" y1="15.5" x2="18.5" y2="15.5" stroke-width="0.8"/>
1655
+ <line x1="3" y1="18.5" x2="21" y2="18.5" stroke-width="0.9"/>
1656
+ </svg>`,
1657
+ };
1658
+ const opt = (value, title, deck, primary) => `
1659
+ <label class="adjourn-mode-option${primary ? " adjourn-mode-option-primary" : ""}${safe === value ? " on" : ""}">
1660
+ <input type="radio" name="brief-mode" value="${value}"${safe === value ? " checked" : ""}>
1661
+ <div class="adjourn-mode-icon">${ICONS[value] || ""}</div>
1662
+ <div class="adjourn-mode-body">
1663
+ <div class="adjourn-mode-title">${title}</div>
1664
+ <div class="adjourn-mode-deck">${deck}</div>
1665
+ </div>
1666
+ </label>`;
1667
+ return `
1668
+ <div class="adjourn-mode-picker" data-mode-picker>
1669
+ <div class="adjourn-mode-label">// report format</div>
1670
+ <div class="adjourn-mode-options adjourn-mode-options-4">
1671
+ ${opt("research-note", "Report", "Long-form markdown · bottom line, findings, recommendations.", true)}
1672
+ ${opt("bento", "Bento", "Single-page poster · 3 milestones + sidebar cards.")}
1673
+ ${opt("magazine", "Magazine", "Editorial spread · cover line, 5 cards, dark closer.")}
1674
+ ${opt("newspaper", "Newspaper", "Broadsheet · banner masthead, 3-column editorial.")}
1675
+ </div>
1676
+ </div>`;
1677
+ },
1678
+
1679
+ /** Read the user's last-picked report mode from localStorage so the
1680
+ * picker defaults to their previous choice. Falls back to
1681
+ * 'research-note' on first run / private mode. */
1682
+ lastBriefMode() {
1683
+ try {
1684
+ const v = localStorage.getItem("pb.briefMode");
1685
+ return this.isStructuredBriefMode(v) ? v : "research-note";
1686
+ } catch { return "research-note"; }
1687
+ },
1688
+ saveLastBriefMode(mode) {
1689
+ try {
1690
+ const safe = this.isStructuredBriefMode(mode) ? mode : "research-note";
1691
+ localStorage.setItem("pb.briefMode", safe);
1692
+ } catch { /* private-mode etc. — silently ignore */ }
1693
+ },
1694
+
1487
1695
  /** Pure cache read · returns true when the local app.keys map
1488
1696
  * reports any model provider as configured. Used by the gate
1489
1697
  * and (via wrappers) anywhere downstream UI needs the answer
@@ -1647,6 +1855,9 @@
1647
1855
  async adjournRoom(opts) {
1648
1856
  if (!this.currentRoomId) return;
1649
1857
  const skipBrief = !!(opts && opts.skipBrief);
1858
+ const mode = opts && this.isStructuredBriefMode(opts.mode)
1859
+ ? opts.mode
1860
+ : "research-note";
1650
1861
  // Pre-flight · adjourn-with-brief triggers the brief writer
1651
1862
  // pipeline (3 LLM stages). When skipBrief=true the chair just
1652
1863
  // posts the no-brief marker without LLM calls, so we let it
@@ -1657,7 +1868,7 @@
1657
1868
  {
1658
1869
  method: "POST",
1659
1870
  headers: { "content-type": "application/json" },
1660
- body: JSON.stringify(skipBrief ? { skipBrief: true } : {}),
1871
+ body: JSON.stringify(skipBrief ? { skipBrief: true } : { mode }),
1661
1872
  },
1662
1873
  );
1663
1874
  if (!r.ok) {
@@ -1691,6 +1902,10 @@
1691
1902
  const confirmTxt = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
1692
1903
  const subjectTxt = room.subject || room.name || "—";
1693
1904
  const memberCount = (this.currentMembers || []).length;
1905
+ // Default the mode picker to whatever the user picked last. Bento
1906
+ // is opt-in · 'research-note' is the safe initial default for new
1907
+ // users / private-mode browsers (where localStorage is empty).
1908
+ const defaultMode = this.lastBriefMode();
1694
1909
  const html = `
1695
1910
  <div class="adjourn-overlay" id="adjourn-overlay" role="dialog" aria-modal="true" data-adjourn-mode="${this.escape(mode)}">
1696
1911
  <div class="adjourn-backdrop" data-adjourn-close></div>
@@ -1711,9 +1926,12 @@
1711
1926
 
1712
1927
  <div class="adjourn-body">
1713
1928
  <div class="adjourn-summary">
1714
- <div class="adjourn-summary-row">
1929
+ <div class="adjourn-summary-row adjourn-summary-row-subject">
1715
1930
  <span class="adjourn-summary-key">// subject</span>
1716
- <span class="adjourn-summary-val">${this.escape(subjectTxt)}</span>
1931
+ <div class="adjourn-summary-val adjourn-subject-wrap">
1932
+ <span class="adjourn-subject-text is-clamped" data-adjourn-subject>${this.escape(subjectTxt)}</span>
1933
+ <button type="button" class="adjourn-subject-toggle" data-adjourn-subject-toggle hidden>Show more</button>
1934
+ </div>
1717
1935
  </div>
1718
1936
  <div class="adjourn-summary-row">
1719
1937
  <span class="adjourn-summary-key">// authors</span>
@@ -1725,10 +1943,12 @@
1725
1943
  </div>
1726
1944
  </div>
1727
1945
  <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.
1946
+ The chair compiles a report from the room's transcript. Pick the
1947
+ format below the room is marked adjourned and the report is
1948
+ filed in the chat once it's ready.
1731
1949
  </p>
1950
+
1951
+ ${this.renderBriefModePicker(defaultMode)}
1732
1952
  </div>
1733
1953
 
1734
1954
  <footer class="adjourn-foot">
@@ -1749,6 +1969,19 @@
1749
1969
  wrap.innerHTML = html.trim();
1750
1970
  document.body.appendChild(wrap.firstChild);
1751
1971
  document.body.style.overflow = "hidden";
1972
+ // Subject clamp · the subject value is clamped to 3 lines by
1973
+ // default (a long room title shouldn't crowd the rest of the
1974
+ // overlay). After mount, measure whether the text actually
1975
+ // overflows the clamp — only then reveal the Show more / less
1976
+ // toggle. rAF lets the browser settle the initial layout so
1977
+ // scrollHeight is meaningful.
1978
+ requestAnimationFrame(() => {
1979
+ const subjEl = document.querySelector("[data-adjourn-subject]");
1980
+ const subjBtn = document.querySelector("[data-adjourn-subject-toggle]");
1981
+ if (subjEl && subjBtn && subjEl.scrollHeight > subjEl.clientHeight + 1) {
1982
+ subjBtn.hidden = false;
1983
+ }
1984
+ });
1752
1985
  // Esc closes the overlay. Listener auto-detaches on close so
1753
1986
  // we don't accumulate handlers across opens.
1754
1987
  this._adjournEsc = (ev) => {
@@ -1788,6 +2021,14 @@
1788
2021
  confirm: "[ Regenerate ]",
1789
2022
  confirmBusy: "[ Regenerating… ]",
1790
2023
  };
2024
+ // Default the picker to the existing brief's mode · the user's
2025
+ // baseline assumption when regenerating with a supplement is "I
2026
+ // want the same kind of report, just with this extra angle." If
2027
+ // the parent brief's mode is missing for any reason, fall back
2028
+ // to the user's last picked mode (localStorage).
2029
+ const defaultMode = this.isStructuredBriefMode(this.currentBrief?.mode)
2030
+ ? this.currentBrief.mode
2031
+ : 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>
@@ -2455,6 +2697,17 @@
2455
2697
  if (input) input.focus();
2456
2698
  return;
2457
2699
  }
2700
+ // Read the report-mode picker (research-note / bento). The
2701
+ // overlay defaults the picker to the existing brief's mode, so a
2702
+ // plain "regenerate with this perspective" keeps the same
2703
+ // format. Persisting the choice keeps the picker stable next
2704
+ // time. Server-side the same `/api/rooms/:id/brief` route reads
2705
+ // `mode` regardless of whether the call came from supplement or
2706
+ // generate-brief, so no backend change is needed.
2707
+ const briefModeInput = overlay.querySelector('input[name="brief-mode"]:checked');
2708
+ const v = briefModeInput && briefModeInput.value;
2709
+ const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
2710
+ this.saveLastBriefMode(briefMode);
2458
2711
  // Frontend in-flight guard · without this, a slow server roundtrip
2459
2712
  // gives the user time to click confirm twice (or to close+reopen
2460
2713
  // the overlay and click again). Each click was firing its own POST,
@@ -2473,7 +2726,7 @@
2473
2726
  {
2474
2727
  method: "POST",
2475
2728
  headers: { "content-type": "application/json" },
2476
- body: JSON.stringify({ supplement: text }),
2729
+ body: JSON.stringify({ supplement: text, mode: briefMode }),
2477
2730
  },
2478
2731
  );
2479
2732
  if (!r.ok) {
@@ -2504,7 +2757,7 @@
2504
2757
  * whether streaming is actually in flight. */
2505
2758
  isBriefPlaceholder(brief, room) {
2506
2759
  if (!brief) return false;
2507
- if (brief.bodyMd && brief.bodyMd.trim()) return false;
2760
+ if (this.briefHasBody(brief)) return false;
2508
2761
  if (!room) return true;
2509
2762
  // Title matches the seed (room subject) → never got an updated title.
2510
2763
  // Or title is the literal "Generating…" placeholder.
@@ -2621,7 +2874,7 @@
2621
2874
  const target = (this.currentBriefs || []).find((b) => b.id === briefId);
2622
2875
  // System UI · always English. Confirm + alert dialogs are app
2623
2876
  // chrome and stay fixed-string regardless of brief language.
2624
- const isStillGenerating = !!(target && (!target.bodyMd || !target.bodyMd.trim()));
2877
+ const isStillGenerating = !!(target && !this.briefHasBody(target));
2625
2878
  const confirmText = isStillGenerating
2626
2879
  ? "This report is still generating. Deleting will stop the generation and remove the report. This can't be undone."
2627
2880
  : (target?.supplement
@@ -2723,17 +2976,23 @@
2723
2976
  const mode = overlay.getAttribute("data-adjourn-mode") || "adjourn";
2724
2977
  const isGen = mode === "generate-brief";
2725
2978
  const skipPicked = overlay.querySelector(".adjourn-skip-btn.picked") !== null;
2979
+ // Read the report-mode picker (research-note / bento). Persist the
2980
+ // choice so the picker defaults to the same option next time.
2981
+ const briefModeInput = overlay.querySelector('input[name="brief-mode"]:checked');
2982
+ const v = briefModeInput && briefModeInput.value;
2983
+ const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
2984
+ this.saveLastBriefMode(briefMode);
2726
2985
  const btn = overlay.querySelector("[data-adjourn-confirm]");
2727
2986
  const origLabel = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
2728
2987
  const busyLabel = isGen ? "[ Generating… ]" : "[ Adjourning… ]";
2729
2988
  if (btn) { btn.disabled = true; btn.textContent = busyLabel; }
2730
2989
  try {
2731
2990
  if (isGen) {
2732
- await this.generateBriefForAdjournedRoom();
2991
+ await this.generateBriefForAdjournedRoom(briefMode);
2733
2992
  } else if (skipPicked) {
2734
2993
  await this.adjournRoom({ skipBrief: true });
2735
2994
  } else {
2736
- await this.adjournRoom({});
2995
+ await this.adjournRoom({ mode: briefMode });
2737
2996
  }
2738
2997
  this.closeAdjournOverlay();
2739
2998
  } catch (e) {
@@ -2746,8 +3005,9 @@
2746
3005
  * whose user originally skipped the brief. Server emits the same
2747
3006
  * brief-started / brief-token / brief-final SSE events as a normal
2748
3007
  * adjourn, so the existing handlers in connectSSE handle the rest. */
2749
- async generateBriefForAdjournedRoom() {
3008
+ async generateBriefForAdjournedRoom(mode) {
2750
3009
  if (!this.currentRoomId) return;
3010
+ const briefMode = this.isStructuredBriefMode(mode) ? mode : "research-note";
2751
3011
  // Pre-flight · the brief writer is a 3-stage LLM pipeline (per-
2752
3012
  // director extract → composer → final write). All require a key.
2753
3013
  if (!(await this.requireModelKey())) return;
@@ -2756,7 +3016,7 @@
2756
3016
  {
2757
3017
  method: "POST",
2758
3018
  headers: { "content-type": "application/json" },
2759
- body: "{}",
3019
+ body: JSON.stringify({ mode: briefMode }),
2760
3020
  },
2761
3021
  );
2762
3022
  if (!r.ok) {
@@ -6610,7 +6870,7 @@
6610
6870
  year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
6611
6871
  })
6612
6872
  : "";
6613
- const href = `/report.html?r=${encodeURIComponent(roomId)}&b=${encodeURIComponent(b.id)}`;
6873
+ const href = this.briefViewerHref(b, roomId);
6614
6874
  return `
6615
6875
  <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
6876
  <span class="brief-picker-num">${this.escape(num)}</span>
@@ -7280,7 +7540,7 @@
7280
7540
  // opens the picker.
7281
7541
  const briefs = Array.isArray(this.currentBriefs) ? this.currentBriefs : [];
7282
7542
  const multi = briefs.length > 1;
7283
- const directHref = `/report.html?r=${this.escape(r.id)}${this.currentBrief.id ? `&b=${this.escape(this.currentBrief.id)}` : ""}`;
7543
+ const directHref = this.briefViewerHref(this.currentBrief, r.id) || `/report.html?r=${this.escape(r.id)}`;
7284
7544
  if (!multi) {
7285
7545
  return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report>[ View Report ]</a>`;
7286
7546
  }
@@ -8722,7 +8982,7 @@
8722
8982
  // a failed-brief tab becomes unreachable — clicking it just
8723
8983
  // re-renders whichever good brief the salvage path picks.
8724
8984
  const goodBrief = bypassSalvage ? null : (this.currentBriefs || []).find(
8725
- (x) => x && x.id !== b.id && !x.error && x.bodyMd && x.bodyMd.trim().length > 0,
8985
+ (x) => x && x.id !== b.id && !x.error && this.briefHasBody(x),
8726
8986
  );
8727
8987
  if (goodBrief) {
8728
8988
  const failed = b;
@@ -8782,6 +9042,7 @@
8782
9042
  ${tabsStripHtml}
8783
9043
  <div class="brief-banner">
8784
9044
  <span class="brief-banner-tag" style="color: var(--red);">// report</span>
9045
+ <span class="brief-banner-type" style="color: var(--red); border-color: var(--red);">${this.escape(this.briefModeLabel(b))}</span>
8785
9046
  <span class="brief-banner-stamp" style="color: var(--red);">${this.escape(copy.stamp)}</span>
8786
9047
  </div>
8787
9048
  <div class="brief-body brief-body-error">
@@ -8804,7 +9065,7 @@
8804
9065
  return;
8805
9066
  }
8806
9067
 
8807
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9068
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
8808
9069
  const signed = this.currentMembers
8809
9070
  .map((a) => `<img src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`)
8810
9071
  .join("");
@@ -8813,12 +9074,12 @@
8813
9074
  ? "GENERATING…"
8814
9075
  : "FILED · " + (b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()));
8815
9076
 
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);
9077
+ // Open Report URL · routes to /bento.html for bento briefs and
9078
+ // /report.html for research-note briefs (default). The bento
9079
+ // renderer doesn't need the room id so the URL is shorter for
9080
+ // that mode.
9081
+ const reportHref = this.briefViewerHref(b, this.currentRoomId)
9082
+ || (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
8822
9083
 
8823
9084
  // Tab strip — already computed at the top of renderBrief as
8824
9085
  // `tabsStripHtml` so both error and success paths can mount it.
@@ -8837,6 +9098,7 @@
8837
9098
  ${tabsHtml}
8838
9099
  <div class="brief-banner">
8839
9100
  <span class="brief-banner-tag">// report</span>
9101
+ <span class="brief-banner-type">${this.escape(this.briefModeLabel(b))}</span>
8840
9102
  <span class="brief-banner-stamp">${filedLabel}</span>
8841
9103
  </div>
8842
9104
 
@@ -8955,11 +9217,48 @@
8955
9217
  "Writing the 3 Headline Findings",
8956
9218
  "Drafting Convergence + Divergence sections",
8957
9219
  "Composing Recommendations",
8958
- "Writing the Pre-mortem",
8959
9220
  "Surfacing New Questions",
8960
9221
  "Drafting the Strategic Planning Assumption",
8961
9222
  "Polishing the final pass",
8962
9223
  ],
9224
+ // Bento's `write` stage does different work · one chair-LLM
9225
+ // call that compresses the room into the 8-slot bento layout.
9226
+ // Keyed under `bento-write` and selected at render time when
9227
+ // the brief's mode is bento.
9228
+ "bento-write": [
9229
+ "Compressing to the takeaway · the 1-line title",
9230
+ "Picking the 3 milestones",
9231
+ "Sizing the big-number callouts",
9232
+ "Mapping ranked bars from the room's data",
9233
+ "Distilling the verification signals",
9234
+ "Compressing recommendations into elevator-pitch lines",
9235
+ "Naming the closing flow · before → after",
9236
+ ],
9237
+ // Magazine mode · same single-pass chair-LLM call but the
9238
+ // emphasis is on editorial cover-line composition rather
9239
+ // than infographic compression. Keyed under `magazine-write`
9240
+ // and selected at render time when the brief's mode is
9241
+ // magazine.
9242
+ "magazine-write": [
9243
+ "Drafting the cover headline",
9244
+ "Writing the subdeck",
9245
+ "Picking the 5 numbered cards",
9246
+ "Sequencing the 3 setup steps",
9247
+ "Selecting why-this-matters reasons",
9248
+ "Stamping the masthead byline",
9249
+ ],
9250
+ // Newspaper mode · same single-pass chair-LLM call but voice
9251
+ // shifts to broadsheet front-page journalism. Keyed under
9252
+ // `newspaper-write` and selected at render time when the
9253
+ // brief's mode is newspaper.
9254
+ "newspaper-write": [
9255
+ "Setting the banner headline",
9256
+ "Writing the subdeck",
9257
+ "Filing the three column stories",
9258
+ "Drafting the bottom-line callout",
9259
+ "Stacking the more-headings sidebar",
9260
+ "Stamping the masthead date",
9261
+ ],
8963
9262
  },
8964
9263
  // System UI · always English. The `zh` key is kept as an alias
8965
9264
  // of `en` so any caller indexing BRIEF_SUBSTAGES["zh"] still
@@ -8980,7 +9279,7 @@
8980
9279
  }
8981
9280
  const stages = b.stages || {};
8982
9281
  const anyActive = Object.values(stages).some((s) => s && s.status === "active");
8983
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9282
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
8984
9283
  if (!anyActive && !generating) {
8985
9284
  this.stopBriefStageTick();
8986
9285
  return;
@@ -9031,7 +9330,7 @@
9031
9330
  if (this._briefStallWatchTimer) return;
9032
9331
  const b = this.currentBrief;
9033
9332
  if (!b || !b.id || b.error) return;
9034
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9333
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
9035
9334
  if (!generating) return;
9036
9335
  if (!this._lastBriefEventAt) this._lastBriefEventAt = Date.now();
9037
9336
  this._lastBriefHealthPollAt = 0;
@@ -9051,7 +9350,7 @@
9051
9350
  async tickBriefStallWatch() {
9052
9351
  const b = this.currentBrief;
9053
9352
  if (!b || b.error) { this.stopBriefStallWatch(); return; }
9054
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9353
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
9055
9354
  if (!generating) { this.stopBriefStallWatch(); return; }
9056
9355
 
9057
9356
  const now = Date.now();
@@ -9112,27 +9411,43 @@
9112
9411
  ? (b.bodyMd.trim().match(/\S+/g) || []).length
9113
9412
  : 0;
9114
9413
 
9115
- // Stage definitions · 7 ordered cells. Must align with the wire
9116
- // format emitted by emitStage() in src/orchestrator/brief.ts:
9414
+ // Stage definitions · mode-aware. The wire format emitted by
9415
+ // emitStage() in src/orchestrator/brief.ts depends on which
9416
+ // pipeline ran:
9117
9417
  //
9118
- // extract → compose → scaffold-{anchor,findings,cluster,actions} → write
9418
+ // research-note (default) · extract → compose → scaffold-
9419
+ // {anchor,findings,cluster,actions} → write (7 stages · the
9420
+ // 4 scaffold sub-stages are driven by JSON-key arrival in
9421
+ // the streaming buffer, see SCAFFOLD_TRIGGERS in brief.ts)
9119
9422
  //
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
9423
+ // bento + magazine · extract write (2 stages · runBentoStage
9424
+ // runs ONE chair-LLM call that produces the BentoScaffold;
9425
+ // composer and the scaffold sub-stages are skipped entirely.
9426
+ // Both modes share the same 2-stage rail · only the write
9427
+ // label and renderer differ)
9428
+ //
9429
+ // System UI · always English regardless of brief language. The
9430
+ // pipeline labels are the app's voice (chrome around the
9126
9431
  // 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
- ];
9432
+ const STRUCTURED_WRITE_LABELS = {
9433
+ bento: "Composing the bento",
9434
+ magazine: "Composing the magazine",
9435
+ newspaper: "Composing the newspaper",
9436
+ };
9437
+ const STAGE_DEFS = this.isStructuredBriefMode(b.mode)
9438
+ ? [
9439
+ { key: "extract", label: "Reading what each director said", pipShort: "read" },
9440
+ { key: "write", label: STRUCTURED_WRITE_LABELS[b.mode] || "Composing the report", pipShort: "compose" },
9441
+ ]
9442
+ : [
9443
+ { key: "extract", label: "Reading what each director said", pipShort: "read" },
9444
+ { key: "compose", label: "Picking the report shape", pipShort: "pick" },
9445
+ { key: "scaffold-anchor", label: "Setting the anchor", pipShort: "anchor" },
9446
+ { key: "scaffold-findings", label: "Sketching findings", pipShort: "find" },
9447
+ { key: "scaffold-cluster", label: "Mapping consensus + dissent", pipShort: "split" },
9448
+ { key: "scaffold-actions", label: "Drafting actions + risks", pipShort: "act" },
9449
+ { key: "write", label: "Writing the report", pipShort: "write" },
9450
+ ];
9136
9451
 
9137
9452
  const meta = this.BRIEF_STAGE_META;
9138
9453
  const substages = (this.BRIEF_SUBSTAGES[lang] || this.BRIEF_SUBSTAGES.en);
@@ -9184,9 +9499,21 @@
9184
9499
  }
9185
9500
 
9186
9501
  // Rotating sub-line · advances every 3s within the active stage.
9502
+ // Bento mode's `write` stage does different work than the
9503
+ // research-note `write` stage — pick its dedicated rotator
9504
+ // (`bento-write`) when active. Falls through to the regular
9505
+ // key for every other stage / mode.
9187
9506
  let substageText = "";
9188
- if (activeStatus === "active" && substages[activeDef.key]?.length) {
9189
- const list = substages[activeDef.key];
9507
+ // Structured-mode write stages get their own rotator copy
9508
+ // ("bento-write" / "magazine-write" / "newspaper-write") since
9509
+ // the work is different from research-note's chapter-by-chapter
9510
+ // write.
9511
+ let subKey = activeDef.key;
9512
+ if (activeDef.key === "write" && this.isStructuredBriefMode(b.mode)) {
9513
+ subKey = `${b.mode}-write`;
9514
+ }
9515
+ if (activeStatus === "active" && substages[subKey]?.length) {
9516
+ const list = substages[subKey];
9190
9517
  substageText = list[Math.floor(activeElapsed / 3) % list.length];
9191
9518
  }
9192
9519
 
@@ -9684,6 +10011,27 @@
9684
10011
  });
9685
10012
  return;
9686
10013
  }
10014
+ // Brief-mode picker · clicking a label (or its radio) toggles the
10015
+ // .on class on that option AND off on the siblings. Used by both
10016
+ // the adjourn overlay and the supplement overlay. We scope to the
10017
+ // picker's `.adjourn-mode-options` container (set in
10018
+ // renderBriefModePicker) instead of a specific overlay id, so the
10019
+ // same handler works wherever the picker is embedded. The native
10020
+ // radio behaviour handles `:checked` state but we use a manual
10021
+ // class for visual styling (left accent stripe + tint) since we
10022
+ // can't `:has()` reliably on older browsers.
10023
+ const modeOpt = e.target.closest(".adjourn-mode-option");
10024
+ if (modeOpt) {
10025
+ const group = modeOpt.closest(".adjourn-mode-options");
10026
+ if (group) {
10027
+ group.querySelectorAll(".adjourn-mode-option").forEach((el) => el.classList.remove("on"));
10028
+ modeOpt.classList.add("on");
10029
+ const radio = modeOpt.querySelector('input[type="radio"]');
10030
+ if (radio) radio.checked = true;
10031
+ }
10032
+ // Don't preventDefault · let the label's native click behaviour
10033
+ // also tick the radio for keyboard / a11y users.
10034
+ }
9687
10035
  // Adjourn overlay · "skip report" footer button — clicking commits
9688
10036
  // immediately (mark picked + dispatch + close).
9689
10037
  if (e.target.closest("[data-adjourn-skip]")) {
@@ -9707,6 +10055,18 @@
9707
10055
  app.closeAdjournOverlay();
9708
10056
  return;
9709
10057
  }
10058
+ // Adjourn overlay · subject Show more / less toggle. Flips the
10059
+ // `is-clamped` class on the value span and swaps the button label.
10060
+ const subjToggle = e.target.closest("[data-adjourn-subject-toggle]");
10061
+ if (subjToggle) {
10062
+ e.preventDefault();
10063
+ const subjEl = document.querySelector("[data-adjourn-subject]");
10064
+ if (subjEl) {
10065
+ const expanded = subjEl.classList.toggle("is-clamped") === false;
10066
+ subjToggle.textContent = expanded ? "Show less" : "Show more";
10067
+ }
10068
+ return;
10069
+ }
9710
10070
  // Brief card · "Add a perspective" → opens the supplement overlay.
9711
10071
  if (e.target.closest("[data-brief-supplement]")) {
9712
10072
  e.preventDefault();