privateboard 0.1.8 → 0.1.10

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 = [];
@@ -1519,21 +1517,21 @@
1519
1517
  * card thought it was still generating because bodyMd was empty
1520
1518
  * — which is the steady-state shape for bento, not an
1521
1519
  * 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. */
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. */
1527
1525
  isStructuredBriefMode(mode) {
1528
- return mode === "bento" || mode === "magazine" || mode === "newspaper";
1526
+ return mode === "magazine" || mode === "newspaper" || mode === "ppt";
1529
1527
  },
1530
1528
 
1531
1529
  briefHasBody(b) {
1532
1530
  if (!b) return false;
1533
1531
  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.
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.
1537
1535
  const j = b.bodyJson;
1538
1536
  return !!(j && typeof j === "object" && (j.title || j.milestones));
1539
1537
  }
@@ -1543,9 +1541,9 @@
1543
1541
 
1544
1542
  /** Build the right viewer URL for a brief based on its mode.
1545
1543
  * · 'research-note' (default · or unknown) → `/report.html?r=R&b=B`
1546
- * · 'bento' → `/bento.html?b=B`
1547
1544
  * · 'magazine' → `/magazine.html?b=B`
1548
1545
  * · 'newspaper' → `/newspaper.html?b=B`
1546
+ * · 'ppt' → `/ppt.html?b=B`
1549
1547
  *
1550
1548
  * Structured renderers don't need the room context · the brief
1551
1549
  * is self-contained. Used by the View Report button, the
@@ -1555,9 +1553,9 @@
1555
1553
  briefViewerHref(b, roomId) {
1556
1554
  if (!b || !b.id) return null;
1557
1555
  const id = encodeURIComponent(b.id);
1558
- if (b.mode === "bento") return `/bento.html?b=${id}`;
1559
1556
  if (b.mode === "magazine") return `/magazine.html?b=${id}`;
1560
1557
  if (b.mode === "newspaper") return `/newspaper.html?b=${id}`;
1558
+ if (b.mode === "ppt") return `/ppt.html?b=${id}`;
1561
1559
  const r = roomId ? encodeURIComponent(roomId) : "";
1562
1560
  return r ? `/report.html?r=${r}&b=${id}` : `/report.html?b=${id}`;
1563
1561
  },
@@ -1580,21 +1578,21 @@
1580
1578
  briefModeLabel(b) {
1581
1579
  const mode = (b && b.mode) || "research-note";
1582
1580
  switch (mode) {
1583
- case "bento": return "Bento";
1584
1581
  case "magazine": return "Magazine";
1585
1582
  case "newspaper": return "Newspaper";
1583
+ case "ppt": return "Slides";
1586
1584
  default: return "Report";
1587
1585
  }
1588
1586
  },
1589
1587
 
1590
1588
  /** 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).
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).
1598
1596
  *
1599
1597
  * Used by both the adjourn overlay (filing the report at
1600
1598
  * adjourn-time / generate-brief flow) AND the supplement
@@ -1622,13 +1620,6 @@
1622
1620
  <line x1="9" y1="15" x2="16" y2="15"/>
1623
1621
  <line x1="9" y1="18" x2="14" y2="18"/>
1624
1622
  </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
1623
  "magazine": `
1633
1624
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1634
1625
  <line x1="3" y1="4" x2="21" y2="4" stroke-width="1.2"/>
@@ -1654,6 +1645,17 @@
1654
1645
  <line x1="16" y1="15.5" x2="18.5" y2="15.5" stroke-width="0.8"/>
1655
1646
  <line x1="3" y1="18.5" x2="21" y2="18.5" stroke-width="0.9"/>
1656
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>`,
1657
1659
  };
1658
1660
  const opt = (value, title, deck, primary) => `
1659
1661
  <label class="adjourn-mode-option${primary ? " adjourn-mode-option-primary" : ""}${safe === value ? " on" : ""}">
@@ -1669,9 +1671,9 @@
1669
1671
  <div class="adjourn-mode-label">// report format</div>
1670
1672
  <div class="adjourn-mode-options adjourn-mode-options-4">
1671
1673
  ${opt("research-note", "Report", "Long-form markdown · bottom line, findings, recommendations.", true)}
1672
- ${opt("bento", "Bento", "Single-page poster · 3 milestones + sidebar cards.")}
1673
1674
  ${opt("magazine", "Magazine", "Editorial spread · cover line, 5 cards, dark closer.")}
1674
1675
  ${opt("newspaper", "Newspaper", "Broadsheet · banner masthead, 3-column editorial.")}
1676
+ ${opt("ppt", "Slides", "Slide deck · 7-9 slides, arrow-key navigation, present mode.")}
1675
1677
  </div>
1676
1678
  </div>`;
1677
1679
  },
@@ -2021,14 +2023,12 @@
2021
2023
  confirm: "[ Regenerate ]",
2022
2024
  confirmBusy: "[ Regenerating… ]",
2023
2025
  };
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();
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();
2032
2032
  const html = `
2033
2033
  <div class="supplement-overlay" id="supplement-overlay" role="dialog" aria-modal="true">
2034
2034
  <div class="supplement-backdrop" data-supplement-close></div>
@@ -2261,7 +2261,7 @@
2261
2261
  const adjournedLine = room.adjournedAt
2262
2262
  ? `${t.adjournedAtPrefix} ${this.timeFmt(room.adjournedAt)}`
2263
2263
  : "";
2264
- const subjectShort = (room.subject || "(no subject)").slice(0, 140);
2264
+ const subjectFull = room.subject || "(no subject)";
2265
2265
 
2266
2266
  // Tone + intensity inherit from parent. Trigger uses the same
2267
2267
  // `.cmp-dd` markup as the new-room composer's toolbar buttons —
@@ -2311,8 +2311,11 @@
2311
2311
  </header>
2312
2312
  <div class="supplement-body">
2313
2313
  <div class="followup-parent-card">
2314
- <div class="followup-parent-subject">${this.escape(subjectShort)}</div>
2315
- <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>
2316
2319
  <div class="followup-parent-note">${this.escape(t.contextNote)}</div>
2317
2320
  </div>
2318
2321
 
@@ -2386,6 +2389,24 @@
2386
2389
  // so listeners are GC'd when the modal is removed.
2387
2390
  const overlayEl = document.getElementById("followup-overlay");
2388
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
+ }
2389
2410
  const sameCheckbox = overlayEl.querySelector("[data-followup-same-cast]");
2390
2411
  const castBtn = overlayEl.querySelector("[data-followup-cast-btn]");
2391
2412
  if (sameCheckbox && castBtn) {
@@ -3778,7 +3799,6 @@
3778
3799
  <div class="row-content">
3779
3800
  <div class="row-top-line">
3780
3801
  <span class="row-title">${this.escape(fullTitle)}</span>
3781
- <span class="row-time">${this.escape(time)}</span>
3782
3802
  </div>
3783
3803
  <div class="row-subtitle">${status}${this.escape(r.subject || "")}</div>
3784
3804
  </div>
@@ -3887,6 +3907,47 @@
3887
3907
  this.composerMode = "room";
3888
3908
  },
3889
3909
 
3910
+ /** Toggle the `isPinned` flag for an agent · the sidebar pin
3911
+ * button calls this. Persists to the server (PATCH /api/agents/:id
3912
+ * { isPinned }), updates local state in place, then re-renders
3913
+ * the sidebar so the row moves between the Pinned / Custom /
3914
+ * Core buckets. Optimistic with rollback on failure: we flip the
3915
+ * flag locally + repaint immediately, then revert if the PATCH
3916
+ * fails — no waiting on the round-trip for visible feedback. */
3917
+ async togglePinAgent(agentId) {
3918
+ if (!agentId) return;
3919
+ const idx = (this.agents || []).findIndex((a) => a && a.id === agentId);
3920
+ if (idx < 0) return;
3921
+ const agent = this.agents[idx];
3922
+ const prev = !!agent.isPinned;
3923
+ const next = !prev;
3924
+ // Optimistic flip · render immediately so the click feels instant.
3925
+ agent.isPinned = next;
3926
+ this.agentsById[agent.id] = agent;
3927
+ this.renderSidebarAgents();
3928
+ try {
3929
+ const r = await fetch("/api/agents/" + encodeURIComponent(agentId), {
3930
+ method: "PATCH",
3931
+ headers: { "content-type": "application/json" },
3932
+ body: JSON.stringify({ isPinned: next }),
3933
+ });
3934
+ if (!r.ok) throw new Error("HTTP " + r.status);
3935
+ // Server returns the updated row · merge the canonical fields
3936
+ // back in so any side-effect updates (updated_at, etc.) land.
3937
+ const updated = await r.json();
3938
+ if (updated && updated.id) {
3939
+ this.agents[idx] = updated;
3940
+ this.agentsById[updated.id] = updated;
3941
+ }
3942
+ } catch (e) {
3943
+ // Rollback · keep the UI honest about persistence failure.
3944
+ agent.isPinned = prev;
3945
+ this.agentsById[agent.id] = agent;
3946
+ this.renderSidebarAgents();
3947
+ alert((e && e.message ? e.message : "pin failed") + " — try again.");
3948
+ }
3949
+ },
3950
+
3890
3951
  /** Render the sidebar's Agents panel from the live agent catalog.
3891
3952
  * Three buckets so the seeded directors keep their familiar
3892
3953
  * groupings while user-created ones stack into a Custom section
@@ -3919,7 +3980,6 @@
3919
3980
  <div class="agent-row-content">
3920
3981
  <div class="agent-row-top-line">
3921
3982
  <span class="agent-row-title">${this.escape(a.name)}</span>
3922
- <span class="agent-row-time">${this.escape(time)}</span>
3923
3983
  ${pinBtn}
3924
3984
  </div>
3925
3985
  <div class="agent-row-subtitle">
@@ -4606,7 +4666,44 @@
4606
4666
  if (brief) brief.innerHTML = "";
4607
4667
 
4608
4668
  const chatScroller = document.querySelector(".chat");
4609
- if (chatScroller) chatScroller.scrollTop = 0;
4669
+ if (chatScroller) {
4670
+ chatScroller.scrollTop = 0;
4671
+ // Mark the chat as hosting a composer so the CSS rule
4672
+ // `.chat.chat--composer` flips it to a grid with
4673
+ // `align-content: center` — vertically centring the
4674
+ // composer when it fits the viewport. When .cmp's natural
4675
+ // height > .chat height we also add `.chat--composer-overflow`
4676
+ // (via updateComposerOverflow) which switches to a layout
4677
+ // where the .cmp-fold (hero + input) is min-height: 100% of
4678
+ // the chat with content centred inside, and .cmp-starters
4679
+ // flow below as scrollable extras. Removed in renderChat()
4680
+ // when real chat messages take over.
4681
+ chatScroller.classList.add("chat--composer");
4682
+ this.updateComposerOverflow();
4683
+ }
4684
+ },
4685
+
4686
+ /** Toggle `.chat--composer-overflow` on the chat scroller based on
4687
+ * whether the composer's natural height exceeds the visible chat
4688
+ * area. In overflow mode the hero (`.cmp-fold`) anchors at the
4689
+ * viewport centre and `.cmp-starters` flow below the fold;
4690
+ * without overflow, the parent's `align-content: center` keeps
4691
+ * the whole composer block centred. Called after composer render
4692
+ * and on window resize. */
4693
+ updateComposerOverflow() {
4694
+ const chat = document.querySelector(".chat.chat--composer");
4695
+ if (!chat) return;
4696
+ const cmp = chat.querySelector(".cmp");
4697
+ if (!cmp) return;
4698
+ requestAnimationFrame(() => {
4699
+ chat.classList.remove("chat--composer-overflow");
4700
+ const overflows = cmp.scrollHeight > chat.clientHeight + 1;
4701
+ chat.classList.toggle("chat--composer-overflow", overflows);
4702
+ });
4703
+ if (!this._composerResizeAttached) {
4704
+ this._composerResizeAttached = true;
4705
+ window.addEventListener("resize", () => this.updateComposerOverflow());
4706
+ }
4610
4707
  },
4611
4708
 
4612
4709
  /** Open the All Reports page · cross-room brief index in a card
@@ -4951,11 +5048,16 @@
4951
5048
  }
4952
5049
 
4953
5050
  // Scroll path · IntersectionObserver fires when the sentinel
4954
- // crosses the viewport. `rootMargin: 200px` triggers slightly
4955
- // before the actual edge so the next batch is rendered before
4956
- // the user reaches the bottom feels seamless rather than
4957
- // chunked.
5051
+ // crosses the viewport. The All Reports view uses an inner
5052
+ // scroll container (`.main-view[data-main-view="reports"]` has
5053
+ // `overflow-y: auto`), so the document viewport itself never
5054
+ // scrolls. The observer's `root` MUST point at the inner
5055
+ // scroller — otherwise the sentinel never intersects and
5056
+ // infinite scroll silently does nothing. `rootMargin: 200px`
5057
+ // triggers slightly before the actual edge so the next batch
5058
+ // is rendered before the user reaches the bottom.
4958
5059
  try {
5060
+ const scrollRoot = document.querySelector('.main-view[data-main-view="reports"]') || null;
4959
5061
  this._reportsLoadObserver = new IntersectionObserver(
4960
5062
  (entries) => {
4961
5063
  for (const entry of entries) {
@@ -4965,7 +5067,7 @@
4965
5067
  }
4966
5068
  }
4967
5069
  },
4968
- { rootMargin: "200px 0px 200px 0px", threshold: 0.01 },
5070
+ { root: scrollRoot, rootMargin: "200px 0px 200px 0px", threshold: 0.01 },
4969
5071
  );
4970
5072
  this._reportsLoadObserver.observe(sentinel);
4971
5073
  } catch { /* IntersectionObserver unavailable · click path remains */ }
@@ -4983,16 +5085,34 @@
4983
5085
  }
4984
5086
  },
4985
5087
 
4986
- /** Single reading-list row · room kicker, title, judgement excerpt,
5088
+ /** Single reading-list row · room kicker, title, subtitle excerpt,
4987
5089
  * meta tail. No card chrome — entries are separated by a hairline
4988
- * inside the list. */
5090
+ * inside the list.
5091
+ *
5092
+ * Subtitle source by mode:
5093
+ * · Report → bodyJson.bottomLine.judgement
5094
+ * · Magazine → bodyJson.kicker
5095
+ * · Newspaper → bodyJson.kicker
5096
+ * All three fall back to a cleaned-up first content paragraph
5097
+ * from bodyMd if the structured field is missing. */
4989
5098
  renderReportItemHtml(b) {
4990
5099
  const json = b.bodyJson || {};
4991
5100
  const bottomLine = json.bottomLine || {};
4992
- const judgement = (bottomLine.judgement || "").trim() || (b.bodyMd || "").slice(0, 240);
5101
+ // Pull the right subtitle for this mode. Magazine / newspaper
5102
+ // carry their deck/lede text in `kicker`; Report carries it
5103
+ // in `bottomLine.judgement`. Either may be missing on legacy
5104
+ // / partial briefs — fall back to the first prose paragraph
5105
+ // from bodyMd.
5106
+ const subtitle = (
5107
+ (bottomLine.judgement || "").trim() ||
5108
+ (json.kicker || "").trim() ||
5109
+ this._extractBriefExcerpt(b.bodyMd) ||
5110
+ ""
5111
+ );
4993
5112
  const time = this.relTime(b.createdAt) || "";
4994
5113
  const roomLabel = b.roomName || b.roomSubject || "—";
4995
5114
  const roomNumLabel = b.roomNumber != null ? `#${String(b.roomNumber).padStart(3, "0")}` : "";
5115
+ const typeLabel = this.briefModeLabel(b);
4996
5116
  const findingsCount = Array.isArray(json.headlineFindings) ? json.headlineFindings.length : 0;
4997
5117
  const positionsCount = Array.isArray(json.positions) ? json.positions.length : 0;
4998
5118
  const metaParts = [];
@@ -5009,16 +5129,45 @@
5009
5129
  <span class="reports-item-num">${this.escape(roomNumLabel)}</span>
5010
5130
  <span class="reports-item-sep">·</span>
5011
5131
  <span class="reports-item-room">${this.escape(roomLabel)}</span>
5132
+ <span class="reports-item-sep">·</span>
5133
+ <span class="reports-item-type" data-mode="${this.escape(((b && b.mode) || "research-note").toLowerCase())}">${this.escape(typeLabel)}</span>
5012
5134
  <span class="reports-item-time">${this.escape(time)}</span>
5013
5135
  </div>
5014
5136
  <h3 class="reports-item-title">${this.escape(b.title || "Untitled brief")}</h3>
5015
- ${judgement ? `<p class="reports-item-judgement">${this.escape(judgement)}</p>` : ""}
5137
+ ${subtitle ? `<p class="reports-item-judgement">${this.escape(subtitle)}</p>` : ""}
5016
5138
  ${metaHtml}
5017
5139
  </a>
5018
5140
  </li>
5019
5141
  `;
5020
5142
  },
5021
5143
 
5144
+ /** Pull the first prose paragraph from a brief's bodyMd as a
5145
+ * subtitle fallback. Skips markdown headers, code fences, table
5146
+ * rows, list markers, blockquotes — finds the first content line
5147
+ * that reads as prose. Strips inline markdown (bold/italic/code/
5148
+ * link syntax) so the excerpt reads as plain text. */
5149
+ _extractBriefExcerpt(md) {
5150
+ if (!md || typeof md !== "string") return "";
5151
+ const lines = md.split("\n");
5152
+ for (const line of lines) {
5153
+ const t = line.trim();
5154
+ if (!t) continue;
5155
+ if (t.startsWith("#")) continue; // markdown heading
5156
+ if (t.startsWith("```")) continue; // code fence
5157
+ if (t.startsWith("|") || /^[-=]{3,}$/.test(t)) continue; // table / hr
5158
+ if (t.startsWith("> ")) continue; // blockquote
5159
+ if (/^[-*+]\s/.test(t)) continue; // list item
5160
+ if (/^\d+\.\s/.test(t)) continue; // ordered list item
5161
+ const cleaned = t
5162
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
5163
+ .replace(/\*([^*]+)\*/g, "$1")
5164
+ .replace(/`([^`]+)`/g, "$1")
5165
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
5166
+ if (cleaned.length >= 12) return cleaned.slice(0, 240);
5167
+ }
5168
+ return md.slice(0, 240).replace(/\s+/g, " ").trim();
5169
+ },
5170
+
5022
5171
  // ── All Notes view · chairman's notes index ───────────────
5023
5172
  /** Open the All Notes page · cross-room saved-excerpt index in
5024
5173
  * the same main-view-replacement pattern as openAllReports.
@@ -5697,10 +5846,12 @@
5697
5846
  // of which composer we're switching to.
5698
5847
  document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
5699
5848
  document.querySelectorAll("[data-notes-trigger].active").forEach((el) => el.classList.remove("active"));
5700
- // If the URL still carries the All-Reports hash from a prior
5701
- // navigation, drop it — otherwise refresh would bounce the user
5702
- // back to the reports view.
5703
- if (/^#\/reports$/i.test(location.hash || "")) {
5849
+ // If the URL still carries an All-Reports / All-Notes hash from
5850
+ // a prior navigation, drop it — otherwise refresh would bounce
5851
+ // the user back to that view (handleRoute matches the hash on
5852
+ // boot and beats the sidebar-restore path that would otherwise
5853
+ // honour ROOMS_KEY="new").
5854
+ if (/^#\/(reports|notes)$/i.test(location.hash || "")) {
5704
5855
  try { history.replaceState(null, "", location.pathname + location.search); } catch { /* ignore */ }
5705
5856
  }
5706
5857
 
@@ -5741,7 +5892,7 @@
5741
5892
  const t = {
5742
5893
  greet: greeting,
5743
5894
  prompt: "What's on your mind today?",
5744
- placeholder: "an idea you're not sure about · a decision you keep avoiding · a thesis you want stress-tested",
5895
+ 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?",
5745
5896
  convene: "Convene",
5746
5897
  tuneLabel: "tune",
5747
5898
  starterLabel: "starter",
@@ -7486,18 +7637,16 @@
7486
7637
 
7487
7638
  const tone = r.mode || "constructive";
7488
7639
  const intensity = r.intensity || "sharp";
7489
- const style = r.briefStyle || "auto";
7490
7640
 
7491
- // Status timestamp what was on the right (paused-stamp / stamp) now
7492
- // lives inline in the meta row.
7493
- let stamp = "";
7494
- if (r.status === "paused" && r.pausedAt) {
7495
- stamp = `paused ${this.relTime(r.pausedAt)} ago`;
7496
- } else if (r.status === "adjourned" && r.adjournedAt) {
7497
- stamp = `adjourned ${this.relTime(r.adjournedAt)} ago`;
7498
- } else if (r.status === "live" && r.createdAt) {
7499
- stamp = `opened ${this.relTime(r.createdAt)} ago`;
7500
- }
7641
+ // Status word for the kicker · the coloured glyph (●/❚❚/▣) is
7642
+ // drawn via CSS `::before` on `.kicker-status.status-{state}`
7643
+ // so all the styling lives next to the colour rule.
7644
+ const statusWord = (
7645
+ r.status === "paused" ? "PAUSED" :
7646
+ r.status === "adjourned" ? "ADJOURNED" :
7647
+ r.status === "live" ? "LIVE" :
7648
+ ""
7649
+ );
7501
7650
 
7502
7651
  // Three primary actions, one per state — CSS hides the wrong ones based
7503
7652
  // on html[data-status]. Per PRD §5.2.3:
@@ -7510,20 +7659,24 @@
7510
7659
  // the DOM stays stable; the collapsed state flips room-head's
7511
7660
  // grid template to a 3-track layout so this button takes the
7512
7661
  // leading auto-track slot.
7662
+ //
7663
+ // Compact two-row layout: kicker (mono, all the meta) + subject.
7664
+ // Replaces the prior three-row stack (badge+id / subject /
7665
+ // tagged-meta-pills) — net height reduction ~150 → ~85px.
7666
+ // Tone / intensity / brief-style remain editable in the room
7667
+ // settings overlay; only their on-header surface is collapsed.
7513
7668
  head.innerHTML = `
7514
7669
  <button type="button" class="room-head-expand" data-sidebar-expand title="Expand sidebar" aria-label="Expand sidebar"></button>
7515
7670
  <div class="room-info">
7516
- <div class="room-id">
7517
- <span class="room-name">Meeting Room</span>
7518
- <span class="session-num">// ROOM #${r.number} · ${this.escape(tone.toUpperCase())}</span>
7671
+ <div class="room-kicker">
7672
+ <span class="kicker-num">// ROOM #${r.number}</span>
7673
+ <span class="kicker-sep">·</span>
7674
+ <span class="kicker-tone" data-tone-tip="${this.escape(TONE_TIPS[tone] || "")}">${this.escape(tone.toUpperCase())}</span>
7675
+ <span class="kicker-sep">·</span>
7676
+ <span class="kicker-intensity">${this.escape(intensity.toUpperCase())}</span>
7677
+ ${statusWord ? `<span class="kicker-sep">·</span><span class="kicker-status status-${this.escape(r.status)}">${statusWord}</span>` : ""}
7519
7678
  </div>
7520
7679
  <h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(r.subject)}</h1>
7521
- <div class="room-meta" data-room-meta>
7522
- <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>
7523
- <span class="meta-tag tag-intensity"><span class="k">intensity</span><span class="v">${this.escape(intensity)}</span></span>
7524
- <span class="meta-tag tag-report"><span class="k">report</span><span class="v">${this.escape(style)}</span></span>
7525
- ${stamp ? `<span class="meta-stamp">${this.escape(stamp)}</span>` : ""}
7526
- </div>
7527
7680
  </div>
7528
7681
  <div class="head-actions">
7529
7682
  <div class="head-cast">${castHtml}</div>
@@ -7556,8 +7709,9 @@
7556
7709
  // overflow:hidden chain (.main / .main-view both clip absolutely-
7557
7710
  // positioned descendants and CAN'T be relaxed without breaking
7558
7711
  // chat scroll). Body-attached fixed-position tooltip is the only
7559
- // reliable approach.
7560
- const toneTag = head.querySelector(".meta-tag.tag-tone[data-tone-tip]");
7712
+ // reliable approach. Anchor moved from the old .meta-tag pill to
7713
+ // .kicker-tone in the compact two-row header.
7714
+ const toneTag = head.querySelector(".kicker-tone[data-tone-tip]");
7561
7715
  if (toneTag) {
7562
7716
  toneTag.addEventListener("mouseenter", () => this.showToneTip(toneTag));
7563
7717
  toneTag.addEventListener("mouseleave", () => this.hideToneTip());
@@ -7602,6 +7756,14 @@
7602
7756
  renderChat() {
7603
7757
  const chat = document.querySelector("[data-chat-messages]");
7604
7758
  if (!chat) return;
7759
+ // Strip the composer-centring class · real chat messages take
7760
+ // over `[data-chat-messages]` here, and chat-message mode wants
7761
+ // the default top-flow scroll (messages stack from the top).
7762
+ const chatScroller = chat.closest(".chat");
7763
+ if (chatScroller) {
7764
+ chatScroller.classList.remove("chat--composer");
7765
+ chatScroller.classList.remove("chat--composer-overflow");
7766
+ }
7605
7767
  const messages = this.currentMessages.slice();
7606
7768
  const r = this.currentRoom;
7607
7769
  const banner = r
@@ -8132,7 +8294,7 @@
8132
8294
  // tight (~4 lines of body text) so even mid-length openers
8133
8295
  // collapse — the user can always one-click to read the full
8134
8296
  // text. Toggle handler lives at doc level (see init).
8135
- const isLongOpener = (m.body || "").length > 100;
8297
+ const isLongOpener = (m.body || "").length > 80;
8136
8298
  // Follow-up rooms get an "origin" row above the question that
8137
8299
  // names the parent room (number + subject) and links back to
8138
8300
  // it via the hash route. Without this, a follow-up looks
@@ -9074,10 +9236,11 @@
9074
9236
  ? "GENERATING…"
9075
9237
  : "FILED · " + (b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()));
9076
9238
 
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.
9239
+ // Open Report URL · routes to /magazine.html or /newspaper.html
9240
+ // for the structured modes and /report.html for research-note
9241
+ // briefs (default). The structured renderers don't need the
9242
+ // room id so the URL is shorter for those modes. See
9243
+ // briefViewerHref for the routing table.
9081
9244
  const reportHref = this.briefViewerHref(b, this.currentRoomId)
9082
9245
  || (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
9083
9246
 
@@ -9221,19 +9384,6 @@
9221
9384
  "Drafting the Strategic Planning Assumption",
9222
9385
  "Polishing the final pass",
9223
9386
  ],
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
9387
  // Magazine mode · same single-pass chair-LLM call but the
9238
9388
  // emphasis is on editorial cover-line composition rather
9239
9389
  // than infographic compression. Keyed under `magazine-write`
@@ -9259,6 +9409,18 @@
9259
9409
  "Stacking the more-headings sidebar",
9260
9410
  "Stamping the masthead date",
9261
9411
  ],
9412
+ // PPT mode · same single-pass chair-LLM call · biases toward
9413
+ // slide-friendly short claims and one-idea-per-slide content.
9414
+ // Keyed under `ppt-write` and selected at render time when
9415
+ // the brief's mode is ppt.
9416
+ "ppt-write": [
9417
+ "Drafting the cover slide",
9418
+ "Sketching the agenda",
9419
+ "Writing milestone slides",
9420
+ "Compressing recommendations to bullets",
9421
+ "Sizing the data callouts",
9422
+ "Stamping the closing takeaway",
9423
+ ],
9262
9424
  },
9263
9425
  // System UI · always English. The `zh` key is kept as an alias
9264
9426
  // of `en` so any caller indexing BRIEF_SUBSTAGES["zh"] still
@@ -9430,9 +9592,9 @@
9430
9592
  // pipeline labels are the app's voice (chrome around the
9431
9593
  // generation), not the report content itself.
9432
9594
  const STRUCTURED_WRITE_LABELS = {
9433
- bento: "Composing the bento",
9434
9595
  magazine: "Composing the magazine",
9435
9596
  newspaper: "Composing the newspaper",
9597
+ ppt: "Composing the deck",
9436
9598
  };
9437
9599
  const STAGE_DEFS = this.isStructuredBriefMode(b.mode)
9438
9600
  ? [
@@ -9499,15 +9661,12 @@
9499
9661
  }
9500
9662
 
9501
9663
  // 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.
9506
- let substageText = "";
9507
9664
  // 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.
9665
+ // (`magazine-write` / `newspaper-write`) since the work is
9666
+ // different from research-note's chapter-by-chapter write.
9667
+ // Falls through to the regular stage key for every other
9668
+ // stage / mode.
9669
+ let substageText = "";
9511
9670
  let subKey = activeDef.key;
9512
9671
  if (activeDef.key === "write" && this.isStructuredBriefMode(b.mode)) {
9513
9672
  subKey = `${b.mode}-write`;