privateboard 0.1.8 → 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 = [];
@@ -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) {
@@ -3887,6 +3908,47 @@
3887
3908
  this.composerMode = "room";
3888
3909
  },
3889
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
+
3890
3952
  /** Render the sidebar's Agents panel from the live agent catalog.
3891
3953
  * Three buckets so the seeded directors keep their familiar
3892
3954
  * groupings while user-created ones stack into a Custom section
@@ -4606,7 +4668,44 @@
4606
4668
  if (brief) brief.innerHTML = "";
4607
4669
 
4608
4670
  const chatScroller = document.querySelector(".chat");
4609
- 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
+ }
4610
4709
  },
4611
4710
 
4612
4711
  /** Open the All Reports page · cross-room brief index in a card
@@ -4983,16 +5082,34 @@
4983
5082
  }
4984
5083
  },
4985
5084
 
4986
- /** Single reading-list row · room kicker, title, judgement excerpt,
5085
+ /** Single reading-list row · room kicker, title, subtitle excerpt,
4987
5086
  * meta tail. No card chrome — entries are separated by a hairline
4988
- * 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. */
4989
5095
  renderReportItemHtml(b) {
4990
5096
  const json = b.bodyJson || {};
4991
5097
  const bottomLine = json.bottomLine || {};
4992
- 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
+ );
4993
5109
  const time = this.relTime(b.createdAt) || "";
4994
5110
  const roomLabel = b.roomName || b.roomSubject || "—";
4995
5111
  const roomNumLabel = b.roomNumber != null ? `#${String(b.roomNumber).padStart(3, "0")}` : "";
5112
+ const typeLabel = this.briefModeLabel(b);
4996
5113
  const findingsCount = Array.isArray(json.headlineFindings) ? json.headlineFindings.length : 0;
4997
5114
  const positionsCount = Array.isArray(json.positions) ? json.positions.length : 0;
4998
5115
  const metaParts = [];
@@ -5009,16 +5126,45 @@
5009
5126
  <span class="reports-item-num">${this.escape(roomNumLabel)}</span>
5010
5127
  <span class="reports-item-sep">·</span>
5011
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>
5012
5131
  <span class="reports-item-time">${this.escape(time)}</span>
5013
5132
  </div>
5014
5133
  <h3 class="reports-item-title">${this.escape(b.title || "Untitled brief")}</h3>
5015
- ${judgement ? `<p class="reports-item-judgement">${this.escape(judgement)}</p>` : ""}
5134
+ ${subtitle ? `<p class="reports-item-judgement">${this.escape(subtitle)}</p>` : ""}
5016
5135
  ${metaHtml}
5017
5136
  </a>
5018
5137
  </li>
5019
5138
  `;
5020
5139
  },
5021
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
+
5022
5168
  // ── All Notes view · chairman's notes index ───────────────
5023
5169
  /** Open the All Notes page · cross-room saved-excerpt index in
5024
5170
  * the same main-view-replacement pattern as openAllReports.
@@ -5697,10 +5843,12 @@
5697
5843
  // of which composer we're switching to.
5698
5844
  document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
5699
5845
  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 || "")) {
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 || "")) {
5704
5852
  try { history.replaceState(null, "", location.pathname + location.search); } catch { /* ignore */ }
5705
5853
  }
5706
5854
 
@@ -5741,7 +5889,7 @@
5741
5889
  const t = {
5742
5890
  greet: greeting,
5743
5891
  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",
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?",
5745
5893
  convene: "Convene",
5746
5894
  tuneLabel: "tune",
5747
5895
  starterLabel: "starter",
@@ -7486,18 +7634,16 @@
7486
7634
 
7487
7635
  const tone = r.mode || "constructive";
7488
7636
  const intensity = r.intensity || "sharp";
7489
- const style = r.briefStyle || "auto";
7490
7637
 
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
- }
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
+ );
7501
7647
 
7502
7648
  // Three primary actions, one per state — CSS hides the wrong ones based
7503
7649
  // on html[data-status]. Per PRD §5.2.3:
@@ -7510,20 +7656,24 @@
7510
7656
  // the DOM stays stable; the collapsed state flips room-head's
7511
7657
  // grid template to a 3-track layout so this button takes the
7512
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.
7513
7665
  head.innerHTML = `
7514
7666
  <button type="button" class="room-head-expand" data-sidebar-expand title="Expand sidebar" aria-label="Expand sidebar"></button>
7515
7667
  <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>
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>` : ""}
7519
7675
  </div>
7520
7676
  <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
7677
  </div>
7528
7678
  <div class="head-actions">
7529
7679
  <div class="head-cast">${castHtml}</div>
@@ -7556,8 +7706,9 @@
7556
7706
  // overflow:hidden chain (.main / .main-view both clip absolutely-
7557
7707
  // positioned descendants and CAN'T be relaxed without breaking
7558
7708
  // 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]");
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]");
7561
7712
  if (toneTag) {
7562
7713
  toneTag.addEventListener("mouseenter", () => this.showToneTip(toneTag));
7563
7714
  toneTag.addEventListener("mouseleave", () => this.hideToneTip());
@@ -7602,6 +7753,14 @@
7602
7753
  renderChat() {
7603
7754
  const chat = document.querySelector("[data-chat-messages]");
7604
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
+ }
7605
7764
  const messages = this.currentMessages.slice();
7606
7765
  const r = this.currentRoom;
7607
7766
  const banner = r
@@ -8132,7 +8291,7 @@
8132
8291
  // tight (~4 lines of body text) so even mid-length openers
8133
8292
  // collapse — the user can always one-click to read the full
8134
8293
  // text. Toggle handler lives at doc level (see init).
8135
- const isLongOpener = (m.body || "").length > 100;
8294
+ const isLongOpener = (m.body || "").length > 80;
8136
8295
  // Follow-up rooms get an "origin" row above the question that
8137
8296
  // names the parent room (number + subject) and links back to
8138
8297
  // it via the hash route. Without this, a follow-up looks
@@ -9074,10 +9233,11 @@
9074
9233
  ? "GENERATING…"
9075
9234
  : "FILED · " + (b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()));
9076
9235
 
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.
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.
9081
9241
  const reportHref = this.briefViewerHref(b, this.currentRoomId)
9082
9242
  || (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
9083
9243
 
@@ -9221,19 +9381,6 @@
9221
9381
  "Drafting the Strategic Planning Assumption",
9222
9382
  "Polishing the final pass",
9223
9383
  ],
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
9384
  // Magazine mode · same single-pass chair-LLM call but the
9238
9385
  // emphasis is on editorial cover-line composition rather
9239
9386
  // than infographic compression. Keyed under `magazine-write`
@@ -9259,6 +9406,18 @@
9259
9406
  "Stacking the more-headings sidebar",
9260
9407
  "Stamping the masthead date",
9261
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
+ ],
9262
9421
  },
9263
9422
  // System UI · always English. The `zh` key is kept as an alias
9264
9423
  // of `en` so any caller indexing BRIEF_SUBSTAGES["zh"] still
@@ -9430,9 +9589,9 @@
9430
9589
  // pipeline labels are the app's voice (chrome around the
9431
9590
  // generation), not the report content itself.
9432
9591
  const STRUCTURED_WRITE_LABELS = {
9433
- bento: "Composing the bento",
9434
9592
  magazine: "Composing the magazine",
9435
9593
  newspaper: "Composing the newspaper",
9594
+ ppt: "Composing the deck",
9436
9595
  };
9437
9596
  const STAGE_DEFS = this.isStructuredBriefMode(b.mode)
9438
9597
  ? [
@@ -9499,15 +9658,12 @@
9499
9658
  }
9500
9659
 
9501
9660
  // 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
9661
  // 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.
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.
9666
+ let substageText = "";
9511
9667
  let subKey = activeDef.key;
9512
9668
  if (activeDef.key === "write" && this.isStructuredBriefMode(b.mode)) {
9513
9669
  subKey = `${b.mode}-write`;