privateboard 0.1.13 → 0.1.15

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
@@ -94,6 +94,20 @@
94
94
  * miss falls back to the raw voiceId so the row never blocks on
95
95
  * the fetch. */
96
96
  voiceLabels: {},
97
+ /** Interest-driven topic recommendations · the home composer's
98
+ * "找你可能感兴趣的话题" tray. Always exactly 6 items (or
99
+ * fewer if no batch has been generated yet) — every fresh
100
+ * generation wipes the previous batch server-side, so
101
+ * there's no pagination or history. `loaded` flips true
102
+ * after the first `/api/topic-recs` fetch so first-paint
103
+ * can show a sensible empty state. `job` is the live
104
+ * generation job (id + phase + pct + detail) or null when
105
+ * idle. */
106
+ topicRecs: {
107
+ items: [],
108
+ loaded: false,
109
+ job: null, // { id, phase, label, pct, detail, eventSource }
110
+ },
97
111
  rooms: [],
98
112
  currentRoomId: null,
99
113
  currentRoom: null,
@@ -144,6 +158,21 @@
144
158
  * stays after the bubble dismisses; only this object resets.
145
159
  * `dismissed: true` means no bubble is showing right now. */
146
160
  userBubble: { text: "", deadline: 0, intervalId: null, dismissed: true },
161
+ /** Ephemeral question bubble pinned to the CHAIR seat when the
162
+ * chair drops a clarifying question in voice mode. Mirrors the
163
+ * userBubble shape exactly so the render / tick / dismiss
164
+ * surfaces stay parallel. 10s border countdown. */
165
+ chairBubble: { text: "", deadline: 0, intervalId: null, dismissed: true },
166
+ /** Set of chair messageIds whose voice synthesis hasn't started yet.
167
+ * Bridges the gap between message-appended (the chair's round-prompt
168
+ * / round-end placeholder lands instantly) and the first voice-chunk
169
+ * arriving from the server's TTS pipeline (~0.5-2s later). Without
170
+ * this, isChairBusy briefly returns false in that window and the
171
+ * vote popover flashes onto the chair seat before the chair has
172
+ * even started speaking — then hides again as audio kicks in, then
173
+ * re-appears after audio ends. Cleared when the first voice-chunk
174
+ * arrives (or after a 12s safety timeout if TTS never delivers). */
175
+ _chairVoiceAwaiting: null,
147
176
  currentBrief: null,
148
177
  /** All briefs filed for the current room · newest first. */
149
178
  currentBriefs: [],
@@ -768,6 +797,14 @@
768
797
  clearInterval(this.userBubble.intervalId);
769
798
  }
770
799
  this.userBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
800
+ if (this.chairBubble && this.chairBubble.intervalId) {
801
+ clearInterval(this.chairBubble.intervalId);
802
+ }
803
+ this.chairBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
804
+ // Drop any leftover voice-await IDs from a prior room · timeouts
805
+ // that fire later will harmlessly no-op (their messageId isn't
806
+ // in the set anymore).
807
+ if (this._chairVoiceAwaiting) this._chairVoiceAwaiting.clear();
771
808
  // Chairman's notes for this room · fetched in parallel-ish
772
809
  // with the room body so the in-room highlight overlay can
773
810
  // wrap saved spans on first paint. Stored as a Map keyed by
@@ -943,6 +980,14 @@
943
980
  clearInterval(this.userBubble.intervalId);
944
981
  }
945
982
  this.userBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
983
+ if (this.chairBubble && this.chairBubble.intervalId) {
984
+ clearInterval(this.chairBubble.intervalId);
985
+ }
986
+ this.chairBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
987
+ // Drop any leftover voice-await IDs from a prior room · timeouts
988
+ // that fire later will harmlessly no-op (their messageId isn't
989
+ // in the set anymore).
990
+ if (this._chairVoiceAwaiting) this._chairVoiceAwaiting.clear();
946
991
  this.currentBrief = null;
947
992
  this.currentBriefs = [];
948
993
  this.currentParentRef = null;
@@ -1027,6 +1072,30 @@
1027
1072
  if (data.authorKind === "agent") {
1028
1073
  this.hideChairPending();
1029
1074
  }
1075
+ // Chair vote-trigger message · register it as awaiting voice
1076
+ // so the vote popover stays suppressed through the gap
1077
+ // between message-appended and the first voice-chunk. Scoped
1078
+ // to round-prompt + round-end (the two kinds that surface the
1079
+ // chair-seat popover) so unrelated chair pings don't gate
1080
+ // anything. Voice-mode only · text mode has no such gap.
1081
+ if (
1082
+ data.authorKind === "agent"
1083
+ && this.currentChair && data.authorId === this.currentChair.id
1084
+ && data.meta && (data.meta.kind === "round-prompt" || data.meta.kind === "round-end")
1085
+ && this.currentRoom && this.currentRoom.deliveryMode === "voice"
1086
+ ) {
1087
+ this._chairVoiceAwaiting = this._chairVoiceAwaiting || new Set();
1088
+ this._chairVoiceAwaiting.add(data.messageId);
1089
+ const mid = data.messageId;
1090
+ // Safety net · 12s ceiling in case TTS never delivers
1091
+ // (network drop, server failure). Triggers a repaint so the
1092
+ // panel can finally surface without the user being stuck.
1093
+ setTimeout(() => {
1094
+ if (this._chairVoiceAwaiting && this._chairVoiceAwaiting.delete(mid)) {
1095
+ this.renderRoundTable();
1096
+ }
1097
+ }, 12000);
1098
+ }
1030
1099
  // Convening card · clear the moment any chair message lands
1031
1100
  // (convening speech, clarify, anything). The card has done
1032
1101
  // its job; the chair is taking over from here. We re-render
@@ -1067,6 +1136,21 @@
1067
1136
  this.userSeatVisible = true;
1068
1137
  this.showUserBubble(data.body || "");
1069
1138
  }
1139
+ // Chair clarify bubble · drop it the moment ANY new message
1140
+ // arrives that isn't another chair clarify (a user reply, a
1141
+ // director turn, or the chair moving past the clarify
1142
+ // phase). Without this the 10s timer alone leaves the
1143
+ // question hovering over the chair seat while the user's
1144
+ // own reply bubble or the next speaker's turn already
1145
+ // owns the stage — visually confusing.
1146
+ if (this.chairBubble && !this.chairBubble.dismissed) {
1147
+ const isAnotherClarify =
1148
+ data.authorKind === "agent" &&
1149
+ this.currentChair &&
1150
+ data.authorId === this.currentChair.id &&
1151
+ (data.meta || {}).kind === "clarify";
1152
+ if (!isAnotherClarify) this.dismissChairBubble();
1153
+ }
1070
1154
  // Round-table toasts · gamified surface for chair-emitted
1071
1155
  // template messages (round-open marker, etc.) that the
1072
1156
  // chat normally renders as system cards. Only toast for
@@ -1160,6 +1244,21 @@
1160
1244
  msg.meta.streaming = false;
1161
1245
  msg.meta.speakerStatus = "final";
1162
1246
  }
1247
+ // Chair clarify · in voice mode, the chair's clarifying
1248
+ // question just finished streaming. Pin the question text to
1249
+ // the chair seat as a 10s countdown bubble (border-progress
1250
+ // ring, same mechanic as the user bubble). Voice-mode only ·
1251
+ // text mode users read the question inline in the scroll, so
1252
+ // a duplicate bubble would just clutter the stage.
1253
+ if (
1254
+ msg && msg.authorKind === "agent"
1255
+ && this.currentChair && msg.authorId === this.currentChair.id
1256
+ && msg.meta && msg.meta.kind === "clarify"
1257
+ && this.currentRoom && this.currentRoom.deliveryMode === "voice"
1258
+ && this.currentRoom.status !== "adjourned"
1259
+ ) {
1260
+ this.showChairBubble(msg.body);
1261
+ }
1163
1262
  // Round-table stage · the speaker just finished, so the
1164
1263
  // bubble should drop. Repaint the seats so the lime ring
1165
1264
  // / bubble migrate to whoever's next (director queue head)
@@ -1208,6 +1307,13 @@
1208
1307
  // meta.streaming. Skipping when a queue already exists keeps
1209
1308
  // the SSE hot path cheap (we don't repaint per chunk).
1210
1309
  const fresh = !this.voiceQueues[data.messageId];
1310
+ // Voice has actually arrived for this messageId · clear it
1311
+ // from the awaiting-voice set so isChairBusy hands off to the
1312
+ // voiceQueues check (which keeps the panel suppressed until
1313
+ // _fireVoiceDone removes the queue at audio end).
1314
+ if (fresh && this._chairVoiceAwaiting) {
1315
+ this._chairVoiceAwaiting.delete(data.messageId);
1316
+ }
1211
1317
  // Chair gavel SFX · fire ONCE when fresh chair voice starts
1212
1318
  // streaming, so the user hears the courtroom "knock-knock"
1213
1319
  // calling for attention before the chair speaks. Detection:
@@ -1959,7 +2065,7 @@
1959
2065
  },
1960
2066
 
1961
2067
  // ── Actions ───────────────────────────────────────────────
1962
- async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode }) {
2068
+ async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode, seedContext }) {
1963
2069
  const r = await fetch("/api/rooms", {
1964
2070
  method: "POST",
1965
2071
  headers: { "content-type": "application/json" },
@@ -1971,6 +2077,11 @@
1971
2077
  briefStyle: briefStyle || "auto",
1972
2078
  deliveryMode: deliveryMode === "voice" ? "voice" : "text",
1973
2079
  ...(autoPick ? { autoPick: true } : {}),
2080
+ // seedContext · attached when the user opened this room
2081
+ // from a topic-rec card. Backend writes it to the
2082
+ // opening message's meta so the chair grounds clarify
2083
+ // in the actual source snippets.
2084
+ ...(seedContext ? { seedContext } : {}),
1974
2085
  }),
1975
2086
  });
1976
2087
  if (!r.ok) {
@@ -4235,6 +4346,23 @@
4235
4346
  // Pre-flight · the next round will fire a fresh director queue,
4236
4347
  // each turn requiring a model key.
4237
4348
  if (!(await this.requireModelKey())) return;
4349
+ // Paused-room handling · the chair's vote popover stays mounted
4350
+ // through pause (the user can park the room mid-vote to think),
4351
+ // so its Continue / Switch / Keep buttons must still work. The
4352
+ // /continue endpoint requires `status === "live"`, so resume
4353
+ // first when the room is paused — without this, the POST 409s
4354
+ // and benignRace silently swallows the error, leaving the user
4355
+ // clicking a dead button. The shift-accept flow funnels through
4356
+ // here too via acceptModeShiftAndContinue, so this single hop
4357
+ // covers both buttons.
4358
+ if (this.currentRoom && this.currentRoom.status === "paused") {
4359
+ try {
4360
+ await this.resumeRoom();
4361
+ } catch (err) {
4362
+ alert("Resume failed: " + (err && err.message ? err.message : err));
4363
+ return;
4364
+ }
4365
+ }
4238
4366
  const r = await fetch(
4239
4367
  "/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/continue",
4240
4368
  { method: "POST" },
@@ -5517,10 +5645,19 @@
5517
5645
  const raw = localStorage.getItem("boardroom.composer");
5518
5646
  if (raw) saved = JSON.parse(raw);
5519
5647
  } catch { /* ignore */ }
5648
+ // seedContext · pre-fetched web snippets the user attached
5649
+ // by clicking a topic-rec card on the home composer. Round-
5650
+ // tripped through localStorage so a page refresh between
5651
+ // pick and convene doesn't lose the attached source
5652
+ // material. Shape: { topicRecId?: string, snippets?: [] }.
5653
+ const savedSeed = saved && typeof saved.seedContext === "object" && saved.seedContext
5654
+ ? saved.seedContext
5655
+ : null;
5520
5656
  this.composerState = {
5521
5657
  ...this.DEFAULT_COMPOSER,
5522
5658
  ...(saved || {}),
5523
5659
  subject: (saved && typeof saved.subject === "string") ? saved.subject : "",
5660
+ seedContext: savedSeed,
5524
5661
  };
5525
5662
  return this.composerState;
5526
5663
  },
@@ -5528,10 +5665,10 @@
5528
5665
  saveComposerState() {
5529
5666
  if (!this.composerState) return;
5530
5667
  try {
5531
- const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject } = this.composerState;
5668
+ const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject, seedContext } = this.composerState;
5532
5669
  localStorage.setItem(
5533
5670
  "boardroom.composer",
5534
- JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject }),
5671
+ JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject, seedContext: seedContext || null }),
5535
5672
  );
5536
5673
  } catch { /* ignore */ }
5537
5674
  },
@@ -5574,6 +5711,15 @@
5574
5711
  // overlay — typing in this view + Enter creates the room.
5575
5712
  const head = document.querySelector("[data-room-head]");
5576
5713
  if (head) head.innerHTML = ""; // CSS hides via html.no-room
5714
+ // One-shot lazy fetch · the composer's "or try a starter"
5715
+ // tray renders the latest topic recommendations when any
5716
+ // exist (replacing the legacy hardcoded starters). Skipping
5717
+ // when already loaded keeps re-renders cheap; the trigger
5718
+ // button's SSE path explicitly refreshes after a successful
5719
+ // generation so the list lands without polling.
5720
+ if (!this.topicRecs.loaded) {
5721
+ void this.refreshTopicRecs();
5722
+ }
5577
5723
 
5578
5724
  // Strip stale follow-up fragments inserted by renderFollowUp-
5579
5725
  // Fragments() when a follow-up room was previously open. These
@@ -5664,12 +5810,19 @@
5664
5810
  },
5665
5811
 
5666
5812
  /** Toggle `.chat--composer-overflow` on the chat scroller based on
5667
- * whether the composer's natural height exceeds the visible chat
5668
- * area. In overflow mode the hero (`.cmp-fold`) anchors at the
5669
- * viewport centre and `.cmp-starters` flow below the fold;
5670
- * without overflow, the parent's `align-content: center` keeps
5671
- * the whole composer block centred. Called after composer render
5672
- * and on window resize. */
5813
+ * whether the composer's natural height exceeds 70% of the
5814
+ * visible chat area. In overflow mode the hero pins to a fixed
5815
+ * 120px top offset (`.cmp` `padding-top: 120px`) and the
5816
+ * starters tray flows below; without overflow, the parent's
5817
+ * `align-content: center` keeps the whole composer block
5818
+ * centred. Called after composer render and on window resize.
5819
+ *
5820
+ * The 0.7 threshold (was "strictly > viewport") catches the
5821
+ * in-between case where the input + ~6 topic-rec cards fit
5822
+ * vertically but only just — centred layout would visually
5823
+ * cram the hero against the top edge. Flipping to overflow
5824
+ * mode at 70% gives the hero comfortable breathing room
5825
+ * before any actual scroll is needed. */
5673
5826
  updateComposerOverflow() {
5674
5827
  const chat = document.querySelector(".chat.chat--composer");
5675
5828
  if (!chat) return;
@@ -5677,7 +5830,7 @@
5677
5830
  if (!cmp) return;
5678
5831
  requestAnimationFrame(() => {
5679
5832
  chat.classList.remove("chat--composer-overflow");
5680
- const overflows = cmp.scrollHeight > chat.clientHeight + 1;
5833
+ const overflows = cmp.scrollHeight > chat.clientHeight * 0.7;
5681
5834
  chat.classList.toggle("chat--composer-overflow", overflows);
5682
5835
  });
5683
5836
  if (!this._composerResizeAttached) {
@@ -6951,12 +7104,11 @@
6951
7104
  const author = n.authorName || this._t("notes_director_fallback");
6952
7105
  // The jump link uses the room's hash route + the note id as a
6953
7106
  // fragment-style query so openRoom can scroll to + flash the
6954
- // matching span. The delete button sits as a SIBLING of the
6955
- // anchor (inside the .notes-item) so its click never bubbles
7107
+ // matching span. The action buttons sit as siblings of the
7108
+ // anchor (inside the .notes-item) so their clicks never bubble
6956
7109
  // through the navigation link — same pattern as session-row
6957
7110
  // delete in the rooms sidebar.
6958
7111
  const href = `#/r/${this.escape(n.roomId)}?note=${this.escape(n.id)}`;
6959
- const deleteTitle = "Delete this note";
6960
7112
  return `
6961
7113
  <li class="notes-item" data-note-id="${this.escape(n.id)}">
6962
7114
  <a class="notes-item-link" href="${href}" data-note-jump="${this.escape(n.id)}" data-note-room="${this.escape(n.roomId)}">
@@ -6973,18 +7125,691 @@
6973
7125
  n.contextAfter ? `<span class="note-context note-context-after">${this.escape(n.contextAfter)}</span>` : ""
6974
7126
  }</p>
6975
7127
  </a>
6976
- <button type="button" class="notes-item-delete" data-note-delete title="${this.escape(deleteTitle)}" aria-label="${this.escape(deleteTitle)}">✕</button>
7128
+ <div class="notes-item-actions">
7129
+ <button type="button" class="notes-item-action notes-item-share" data-note-share aria-label="Share this note">
7130
+ <span class="notes-action-glyph" aria-hidden="true">↗</span>
7131
+ <span class="notes-action-label">Share</span>
7132
+ </button>
7133
+ <button type="button" class="notes-item-action notes-item-unfav" data-note-delete aria-label="Remove this note from your saved list">
7134
+ <span class="notes-action-glyph" aria-hidden="true">✕</span>
7135
+ <span class="notes-action-label">Unfavorite</span>
7136
+ </button>
7137
+ </div>
6977
7138
  </li>
6978
7139
  `;
6979
7140
  },
6980
7141
 
7142
+ /** Share-card overlay · mounts a modal with multiple card
7143
+ * templates rendering the selected note as a shareable image.
7144
+ * Templates live in CSS (data-share-template attribute swaps
7145
+ * the visual register without re-rendering markup). PNG export
7146
+ * reuses the html-to-image CDN loader pattern from
7147
+ * `public/magazine.html` · lazy-loaded on first download so
7148
+ * the All Notes page doesn't pay the network cost upfront.
7149
+ *
7150
+ * Each template is fixed at 540×675 (4:5 portrait) — a sweet
7151
+ * spot for IG / Weibo / WeChat moments / Twitter portrait. PNG
7152
+ * export uses pixelRatio: 2 so the saved file is 1080×1350. */
7153
+ SHARE_CARD_TEMPLATES: ["boardroom", "editorial", "terminal", "magazine"],
7154
+ DEFAULT_SHARE_CARD_TEMPLATE: "boardroom",
7155
+
7156
+ /** Open the share-card overlay for a given note id. Resolves the
7157
+ * note from the in-memory cache (`_notesCache`) so no network
7158
+ * round-trip is needed; the cache is populated by the All Notes
7159
+ * fetch and stays fresh through SSE note:created events. */
7160
+ openShareCard(noteId) {
7161
+ const note = (this._notesCache || []).find((n) => n && n.id === noteId);
7162
+ if (!note) {
7163
+ alert("Couldn't find that note — try reloading the page.");
7164
+ return;
7165
+ }
7166
+ // Tear down any prior overlay first · prevents stacking when
7167
+ // the user click-spams Share across different notes.
7168
+ this.closeShareCard();
7169
+ this._shareCardNote = note;
7170
+ this._shareCardTemplate = this.DEFAULT_SHARE_CARD_TEMPLATE;
7171
+
7172
+ // Share-card overlay reuses the room-settings overlay chrome
7173
+ // (`.room-settings-overlay` + `.room-settings-modal` + lime
7174
+ // corner brackets + `.rs-classification` strip + `.rs-head`
7175
+ // header + `.rs-body` scroll area + `.rs-foot` footer) so the
7176
+ // app's overlays share a single visual register. Inner content
7177
+ // (template chips + preview + download button) is local to
7178
+ // this feature.
7179
+ const roomNum = note.roomNumber != null
7180
+ ? `#${String(note.roomNumber).padStart(3, "0")}` : "—";
7181
+ const author = note.authorName || "Director";
7182
+ const overlay = document.createElement("div");
7183
+ overlay.className = "room-settings-overlay open";
7184
+ overlay.id = "share-card-overlay";
7185
+ overlay.setAttribute("role", "dialog");
7186
+ overlay.setAttribute("aria-modal", "true");
7187
+ overlay.setAttribute("aria-label", "Share this note");
7188
+ overlay.innerHTML = `
7189
+ <div class="room-settings-modal" role="document">
7190
+ <div class="rs-classification">
7191
+ <span><span class="dot">●</span> share · note</span>
7192
+ <span class="right">// privateboard.ai</span>
7193
+ </div>
7194
+ <header class="rs-head">
7195
+ <div class="rs-head-text">
7196
+ <div class="meta">// from room ${this.escape(roomNum)} · ${this.escape(author)}</div>
7197
+ <div class="rs-title-wrap">
7198
+ <div class="title rs-title">Share this note as a card</div>
7199
+ </div>
7200
+ </div>
7201
+ <button type="button" class="close-btn" data-share-card-close aria-label="Close">✕</button>
7202
+ </header>
7203
+ <div class="rs-body">
7204
+ <div class="share-card-templates" role="tablist" aria-label="Card template">
7205
+ ${this.SHARE_CARD_TEMPLATES.map((t) => `
7206
+ <button type="button"
7207
+ class="share-card-template-chip${t === this._shareCardTemplate ? " active" : ""}"
7208
+ data-share-card-template="${t}"
7209
+ role="tab"
7210
+ aria-selected="${t === this._shareCardTemplate ? "true" : "false"}"
7211
+ >${this.escape(t)}</button>
7212
+ `).join("")}
7213
+ </div>
7214
+ <div class="share-card-preview" data-share-card-preview>
7215
+ <div class="share-card-preview-inner" data-share-card-preview-inner>
7216
+ ${this.renderShareCardHtml(note, this._shareCardTemplate)}
7217
+ </div>
7218
+ </div>
7219
+ </div>
7220
+ <footer class="rs-foot">
7221
+ <span class="share-card-hint">// 1080 × 1350 png</span>
7222
+ <button type="button" class="rs-action dirty" data-share-card-download>
7223
+ ↓ Download PNG
7224
+ </button>
7225
+ </footer>
7226
+ </div>
7227
+ `;
7228
+ document.body.appendChild(overlay);
7229
+
7230
+ // Fit the 540×800 preview into whatever container the viewport
7231
+ // gives us. CSS caps both width (max-width: 540) AND height
7232
+ // (max-height: calc(100vh - 260px)) so on short viewports the
7233
+ // preview shrinks proportionally — scaling must read whichever
7234
+ // dimension landed smaller so the inner 540×800 native render
7235
+ // fits cleanly without any scrollbars on the modal body.
7236
+ const fitPreview = () => {
7237
+ const preview = overlay.querySelector("[data-share-card-preview]");
7238
+ const inner = overlay.querySelector("[data-share-card-preview-inner]");
7239
+ if (!preview || !inner) return;
7240
+ const rect = preview.getBoundingClientRect();
7241
+ const w = rect.width || 540;
7242
+ const h = rect.height || 800;
7243
+ const scale = Math.min(1, w / 540, h / 800);
7244
+ inner.style.setProperty("--share-card-scale", scale.toFixed(4));
7245
+ };
7246
+ fitPreview();
7247
+ this._shareCardResize = fitPreview;
7248
+ window.addEventListener("resize", this._shareCardResize);
7249
+
7250
+ // Esc closes · same shortcut family as other overlays.
7251
+ this._shareCardEsc = (ev) => {
7252
+ if (ev.key === "Escape") {
7253
+ ev.preventDefault();
7254
+ this.closeShareCard();
7255
+ }
7256
+ };
7257
+ document.addEventListener("keydown", this._shareCardEsc, true);
7258
+
7259
+ // Pre-warm html-to-image so the first Download click doesn't
7260
+ // pay the ~50KB CDN fetch latency. Best-effort; if the network
7261
+ // is slow the download path awaits its own ensure() anyway.
7262
+ this.ensureShareCardHtmlToImage().catch(() => { /* swallow */ });
7263
+ },
7264
+
7265
+ closeShareCard() {
7266
+ const el = document.getElementById("share-card-overlay");
7267
+ if (el) el.remove();
7268
+ if (this._shareCardEsc) {
7269
+ document.removeEventListener("keydown", this._shareCardEsc, true);
7270
+ this._shareCardEsc = null;
7271
+ }
7272
+ if (this._shareCardResize) {
7273
+ window.removeEventListener("resize", this._shareCardResize);
7274
+ this._shareCardResize = null;
7275
+ }
7276
+ this._shareCardNote = null;
7277
+ this._shareCardTemplate = null;
7278
+ },
7279
+
7280
+ /** Swap the active template · re-renders only the preview's
7281
+ * inner block (keeps the chip row + modal chrome stable). The
7282
+ * data-share-template attribute on `.share-card` is what every
7283
+ * template's CSS keys off, so the visual change is purely a
7284
+ * class swap — markup is identical across templates. */
7285
+ setShareCardTemplate(key) {
7286
+ if (!this.SHARE_CARD_TEMPLATES.includes(key)) return;
7287
+ this._shareCardTemplate = key;
7288
+ const overlay = document.getElementById("share-card-overlay");
7289
+ if (!overlay) return;
7290
+ overlay.querySelectorAll("[data-share-card-template]").forEach((btn) => {
7291
+ const active = btn.getAttribute("data-share-card-template") === key;
7292
+ btn.classList.toggle("active", active);
7293
+ btn.setAttribute("aria-selected", active ? "true" : "false");
7294
+ });
7295
+ const inner = overlay.querySelector("[data-share-card-preview-inner]");
7296
+ if (inner && this._shareCardNote) {
7297
+ inner.innerHTML = this.renderShareCardHtml(this._shareCardNote, key);
7298
+ }
7299
+ },
7300
+
7301
+ /** Length-based font-size step-down for share-card quotes.
7302
+ * Quotes never get truncated — instead the font shrinks so the
7303
+ * full passage fits. Returns the font-size in px for a given
7304
+ * template at the supplied character count. Tiers were tuned
7305
+ * empirically against each template's content area: the
7306
+ * boardroom bubble is the smallest box (≈424 × 200), magazine
7307
+ * and editorial have the most real estate.
7308
+ *
7309
+ * Length tiers are deliberately coarse — 5-6 buckets keep the
7310
+ * output predictable and avoid the "text snaps a pixel smaller
7311
+ * every keystroke" feel a continuous formula would produce. */
7312
+ shareCardQuoteSize(template, len) {
7313
+ const TIERS = {
7314
+ boardroom: [
7315
+ { max: 40, size: 24 },
7316
+ { max: 70, size: 21 },
7317
+ { max: 100, size: 19 },
7318
+ { max: 140, size: 16 },
7319
+ { max: 170, size: 14 },
7320
+ { max: 200, size: 13 },
7321
+ ],
7322
+ editorial: [
7323
+ { max: 40, size: 32 },
7324
+ { max: 70, size: 28 },
7325
+ { max: 100, size: 25 },
7326
+ { max: 140, size: 21 },
7327
+ { max: 170, size: 18 },
7328
+ { max: 200, size: 16 },
7329
+ ],
7330
+ terminal: [
7331
+ { max: 40, size: 22 },
7332
+ { max: 70, size: 19 },
7333
+ { max: 100, size: 17 },
7334
+ { max: 140, size: 15 },
7335
+ { max: 170, size: 13 },
7336
+ { max: 200, size: 12 },
7337
+ ],
7338
+ magazine: [
7339
+ { max: 40, size: 34 },
7340
+ { max: 70, size: 30 },
7341
+ { max: 100, size: 26 },
7342
+ { max: 140, size: 22 },
7343
+ { max: 170, size: 19 },
7344
+ { max: 200, size: 17 },
7345
+ ],
7346
+ };
7347
+ const fallback = { max: 200, size: 14 };
7348
+ const tiers = TIERS[template] || TIERS.boardroom;
7349
+ for (const t of tiers) {
7350
+ if (len <= t.max) return t.size;
7351
+ }
7352
+ // > 200 chars · take the smallest tier and shave 2px so the
7353
+ // text still has a chance to fit. Caller can also pre-trim
7354
+ // to 200 if they want a hard cap.
7355
+ return Math.max(10, tiers[tiers.length - 1].size - 2);
7356
+ },
7357
+
7358
+ /** Wrap CJK runs in `<span class="cjk">…</span>` so per-script
7359
+ * font + weight rules apply: CJK switches to PingFang at bold
7360
+ * weight (no italic-skew), Latin keeps the surrounding serif
7361
+ * italic. Always called AFTER `escape()` so the regex only
7362
+ * matches CJK glyphs and never sees raw `<` / `>` / `&`. */
7363
+ wrapCjk(text) {
7364
+ if (!text) return "";
7365
+ return String(text).replace(
7366
+ /([ -〿぀-ゟ゠-ヿ㐀-䶿一-鿿豈-﫿＀-￯]+)/g,
7367
+ '<span class="cjk">$1</span>',
7368
+ );
7369
+ },
7370
+
7371
+ /** Render the 540×800 card HTML for a given note + template key.
7372
+ * All four templates share the SAME inner data slots — kicker,
7373
+ * quote, byline, watermark, stamp — so this single function
7374
+ * works for all of them; CSS handles the visual divergence. */
7375
+ renderShareCardHtml(n, templateKey) {
7376
+ const tpl = templateKey || this.DEFAULT_SHARE_CARD_TEMPLATE;
7377
+ const quote = String(n.quoteText || "").trim();
7378
+ const author = (n.authorName || "Director").trim();
7379
+ const roomNum = n.roomNumber != null ? `#${String(n.roomNumber).padStart(3, "0")}` : "";
7380
+ const stampDate = n.createdAt
7381
+ ? new Date(n.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })
7382
+ : "";
7383
+ const esc = (s) => this.escape(String(s || ""));
7384
+
7385
+ // Template-specific markup · each template needs a slightly
7386
+ // different DOM (8-bit scene for "boardroom", quotation
7387
+ // glyph for "editorial", ASCII frame for "terminal", top
7388
+ // stripe for "magazine"). Watermark on every template is
7389
+ // the domain `Privateboard.ai` (lowercased / capitalised
7390
+ // by the template's text-transform).
7391
+ const DOMAIN = "Privateboard.ai";
7392
+
7393
+ // Quote font-size · scaled by char count so long quotes (up
7394
+ // to 200 chars) shrink to fit rather than getting truncated.
7395
+ const qLen = quote.length;
7396
+ const qFont = this.shareCardQuoteSize(tpl, qLen);
7397
+ const qStyle = `font-size: ${qFont}px;`;
7398
+
7399
+ if (tpl === "boardroom") {
7400
+ // 8-bit Dribbble-style card · striped sunset sky (painted by
7401
+ // CSS) wrapped on every side by an inline-SVG decoration
7402
+ // layer (pixel moon, scattered stars, drifting clouds,
7403
+ // floating balloons). The meeting table + chairs are
7404
+ // intentionally absent — the composition reads as a sunset
7405
+ // poster + system message in a chunky pixel-art dialog box.
7406
+ // Every rect carries an explicit fill so the html-to-image
7407
+ // export keeps colours stable across browsers.
7408
+ const star = (x, y, c = "#FFF6D9") => `<rect x="${x}" y="${y}" width="2" height="2" fill="${c}"/>`;
7409
+ const tinyStar = (x, y, c = "#FFE8A8") => `<rect x="${x}" y="${y}" width="1" height="1" fill="${c}"/>`;
7410
+ // Pixel cloud · 1px-thinner cap row on top + main row +
7411
+ // 1-row peach underbelly shadow. Sky-bands only.
7412
+ const cloud = (x, y) => `
7413
+ <rect x="${x + 4}" y="${y - 2}" width="10" height="2" fill="#FFF6D9"/>
7414
+ <rect x="${x}" y="${y}" width="18" height="4" fill="#FFF6D9"/>
7415
+ <rect x="${x + 2}" y="${y + 4}" width="14" height="2" fill="#F4C078"/>
7416
+ `;
7417
+ const bigCloud = (x, y) => `
7418
+ <rect x="${x + 6}" y="${y - 4}" width="14" height="2" fill="#FFF6D9"/>
7419
+ <rect x="${x + 2}" y="${y - 2}" width="22" height="2" fill="#FFF6D9"/>
7420
+ <rect x="${x}" y="${y}" width="26" height="4" fill="#FFF6D9"/>
7421
+ <rect x="${x + 2}" y="${y + 4}" width="22" height="2" fill="#F4C078"/>
7422
+ `;
7423
+ // Pixel grass tuft · 7-blade upgraded tuft in two greens
7424
+ // with deeper roots, taller blades, and a wider ground-line.
7425
+ // Roughly 13×8 — about 2× the previous tuft.
7426
+ const grass = (x, y) => `
7427
+ <rect x="${x + 2}" y="${y - 4}" width="1" height="4" fill="#2A4F1D"/>
7428
+ <rect x="${x}" y="${y - 1}" width="2" height="3" fill="#3D6E2F"/>
7429
+ <rect x="${x + 3}" y="${y - 5}" width="1" height="5" fill="#2A4F1D"/>
7430
+ <rect x="${x + 5}" y="${y - 3}" width="1" height="3" fill="#3D6E2F"/>
7431
+ <rect x="${x + 4}" y="${y}" width="2" height="3" fill="#3D6E2F"/>
7432
+ <rect x="${x + 7}" y="${y - 4}" width="1" height="4" fill="#2A4F1D"/>
7433
+ <rect x="${x + 8}" y="${y - 1}" width="2" height="3" fill="#3D6E2F"/>
7434
+ <rect x="${x + 10}" y="${y - 3}" width="1" height="3" fill="#2A4F1D"/>
7435
+ <rect x="${x + 12}" y="${y - 2}" width="1" height="2" fill="#3D6E2F"/>
7436
+ <rect x="${x + 1}" y="${y + 3}" width="11" height="1" fill="#6BAA48"/>
7437
+ `;
7438
+ // Wider grass patch · 14-blade tuft for "tall grass clumps"
7439
+ // along the river bank. Reads as a thicker, denser growth.
7440
+ const grassWide = (x, y) => `
7441
+ <rect x="${x + 2}" y="${y - 5}" width="1" height="5" fill="#2A4F1D"/>
7442
+ <rect x="${x}" y="${y - 1}" width="2" height="3" fill="#3D6E2F"/>
7443
+ <rect x="${x + 3}" y="${y - 6}" width="1" height="6" fill="#2A4F1D"/>
7444
+ <rect x="${x + 5}" y="${y - 4}" width="1" height="4" fill="#3D6E2F"/>
7445
+ <rect x="${x + 4}" y="${y - 1}" width="2" height="3" fill="#3D6E2F"/>
7446
+ <rect x="${x + 7}" y="${y - 5}" width="1" height="5" fill="#2A4F1D"/>
7447
+ <rect x="${x + 8}" y="${y - 2}" width="2" height="4" fill="#3D6E2F"/>
7448
+ <rect x="${x + 10}" y="${y - 4}" width="1" height="4" fill="#2A4F1D"/>
7449
+ <rect x="${x + 12}" y="${y - 1}" width="2" height="3" fill="#3D6E2F"/>
7450
+ <rect x="${x + 14}" y="${y - 3}" width="1" height="3" fill="#2A4F1D"/>
7451
+ <rect x="${x + 16}" y="${y - 2}" width="1" height="2" fill="#3D6E2F"/>
7452
+ <rect x="${x + 17}" y="${y - 4}" width="1" height="4" fill="#2A4F1D"/>
7453
+ <rect x="${x + 1}" y="${y + 3}" width="17" height="1" fill="#6BAA48"/>
7454
+ `;
7455
+ // Pixel dirt patch · bare earth showing through the grass.
7456
+ // Four-tone (light top / mid body / shadow bottom / specks)
7457
+ // gives the patch readable 8-bit texture at small sizes.
7458
+ const dirtPatch = (x, y) => `
7459
+ <rect x="${x}" y="${y}" width="14" height="1" fill="#8A6A48"/>
7460
+ <rect x="${x}" y="${y + 1}" width="16" height="2" fill="#6A4A2C"/>
7461
+ <rect x="${x}" y="${y + 3}" width="14" height="2" fill="#5A3A22"/>
7462
+ <rect x="${x + 2}" y="${y + 5}" width="10" height="1" fill="#4A2E18"/>
7463
+ <rect x="${x + 4}" y="${y + 1}" width="2" height="1" fill="#A0814F"/>
7464
+ <rect x="${x + 9}" y="${y + 2}" width="2" height="1" fill="#A0814F"/>
7465
+ `;
7466
+ const dirtPatchBig = (x, y) => `
7467
+ <rect x="${x + 1}" y="${y}" width="22" height="1" fill="#8A6A48"/>
7468
+ <rect x="${x}" y="${y + 1}" width="26" height="2" fill="#6A4A2C"/>
7469
+ <rect x="${x}" y="${y + 3}" width="26" height="2" fill="#5A3A22"/>
7470
+ <rect x="${x + 2}" y="${y + 5}" width="22" height="2" fill="#4A2E18"/>
7471
+ <rect x="${x + 4}" y="${y + 7}" width="18" height="1" fill="#3A2410"/>
7472
+ <rect x="${x + 5}" y="${y + 1}" width="3" height="1" fill="#A0814F"/>
7473
+ <rect x="${x + 14}" y="${y + 2}" width="2" height="1" fill="#A0814F"/>
7474
+ <rect x="${x + 20}" y="${y + 4}" width="2" height="1" fill="#A0814F"/>
7475
+ <rect x="${x + 8}" y="${y + 4}" width="1" height="1" fill="#8A6A48"/>
7476
+ `;
7477
+ // Top-down pixel stones · much bigger than before, with
7478
+ // a round-blob silhouette (stepped rows that wrap around
7479
+ // a domed body), brighter centre (sun catching the top
7480
+ // of the rounded stone), darker rim along the bottom
7481
+ // edge, and a soft ground drop-shadow that reads as
7482
+ // "looking straight down at a chunky rock". Three sizes.
7483
+ const stoneSmall = (x, y) => `
7484
+ <!-- Shadow beneath -->
7485
+ <rect x="${x + 2}" y="${y + 14}" width="16" height="2" fill="#1B0A35" opacity="0.28"/>
7486
+ <!-- Mid-tone body (round-blob silhouette) -->
7487
+ <rect x="${x + 4}" y="${y}" width="12" height="2" fill="#7A6F62"/>
7488
+ <rect x="${x + 2}" y="${y + 2}" width="16" height="2" fill="#7A6F62"/>
7489
+ <rect x="${x}" y="${y + 4}" width="20" height="6" fill="#7A6F62"/>
7490
+ <rect x="${x + 2}" y="${y + 10}" width="16" height="2" fill="#7A6F62"/>
7491
+ <rect x="${x + 4}" y="${y + 12}" width="12" height="2" fill="#7A6F62"/>
7492
+ <!-- Lighter top-center (sun catches dome) -->
7493
+ <rect x="${x + 6}" y="${y + 2}" width="8" height="2" fill="#9F9388"/>
7494
+ <rect x="${x + 4}" y="${y + 4}" width="12" height="4" fill="#9F9388"/>
7495
+ <rect x="${x + 6}" y="${y + 8}" width="8" height="2" fill="#9F9388"/>
7496
+ <!-- Brightest specular spot -->
7497
+ <rect x="${x + 7}" y="${y + 4}" width="4" height="2" fill="#B5A998"/>
7498
+ <!-- Bottom rim shadow -->
7499
+ <rect x="${x + 4}" y="${y + 12}" width="12" height="1" fill="#4A4038"/>
7500
+ `;
7501
+ const stoneMed = (x, y) => `
7502
+ <!-- Shadow beneath -->
7503
+ <rect x="${x + 4}" y="${y + 20}" width="24" height="2" fill="#1B0A35" opacity="0.30"/>
7504
+ <rect x="${x + 6}" y="${y + 22}" width="20" height="1" fill="#1B0A35" opacity="0.18"/>
7505
+ <!-- Mid-tone body -->
7506
+ <rect x="${x + 6}" y="${y}" width="20" height="2" fill="#7A6F62"/>
7507
+ <rect x="${x + 3}" y="${y + 2}" width="26" height="2" fill="#7A6F62"/>
7508
+ <rect x="${x + 1}" y="${y + 4}" width="30" height="2" fill="#7A6F62"/>
7509
+ <rect x="${x}" y="${y + 6}" width="32" height="8" fill="#7A6F62"/>
7510
+ <rect x="${x + 1}" y="${y + 14}" width="30" height="2" fill="#7A6F62"/>
7511
+ <rect x="${x + 3}" y="${y + 16}" width="26" height="2" fill="#7A6F62"/>
7512
+ <rect x="${x + 6}" y="${y + 18}" width="20" height="2" fill="#7A6F62"/>
7513
+ <!-- Lighter top-centre dome -->
7514
+ <rect x="${x + 9}" y="${y + 2}" width="14" height="2" fill="#9F9388"/>
7515
+ <rect x="${x + 6}" y="${y + 4}" width="20" height="2" fill="#9F9388"/>
7516
+ <rect x="${x + 4}" y="${y + 6}" width="24" height="4" fill="#9F9388"/>
7517
+ <rect x="${x + 6}" y="${y + 10}" width="20" height="2" fill="#9F9388"/>
7518
+ <!-- Brightest specular spot (sun) -->
7519
+ <rect x="${x + 11}" y="${y + 5}" width="10" height="3" fill="#B5A998"/>
7520
+ <!-- Bottom rim shadow -->
7521
+ <rect x="${x + 4}" y="${y + 16}" width="24" height="1" fill="#5A5048"/>
7522
+ <rect x="${x + 7}" y="${y + 18}" width="18" height="1" fill="#4A4038"/>
7523
+ `;
7524
+ const stoneLarge = (x, y) => `
7525
+ <!-- Shadow beneath (wider for the bigger stone) -->
7526
+ <rect x="${x + 6}" y="${y + 28}" width="36" height="2" fill="#1B0A35" opacity="0.32"/>
7527
+ <rect x="${x + 8}" y="${y + 30}" width="32" height="1" fill="#1B0A35" opacity="0.20"/>
7528
+ <!-- Mid-tone body (round-blob silhouette, ~46×30) -->
7529
+ <rect x="${x + 10}" y="${y}" width="26" height="2" fill="#7A6F62"/>
7530
+ <rect x="${x + 6}" y="${y + 2}" width="34" height="2" fill="#7A6F62"/>
7531
+ <rect x="${x + 3}" y="${y + 4}" width="40" height="2" fill="#7A6F62"/>
7532
+ <rect x="${x + 1}" y="${y + 6}" width="44" height="2" fill="#7A6F62"/>
7533
+ <rect x="${x}" y="${y + 8}" width="46" height="10" fill="#7A6F62"/>
7534
+ <rect x="${x + 1}" y="${y + 18}" width="44" height="2" fill="#7A6F62"/>
7535
+ <rect x="${x + 3}" y="${y + 20}" width="40" height="2" fill="#7A6F62"/>
7536
+ <rect x="${x + 6}" y="${y + 22}" width="34" height="2" fill="#7A6F62"/>
7537
+ <rect x="${x + 10}" y="${y + 24}" width="26" height="2" fill="#7A6F62"/>
7538
+ <!-- Lighter top-centre dome (sun catches the round top) -->
7539
+ <rect x="${x + 14}" y="${y + 2}" width="18" height="2" fill="#9F9388"/>
7540
+ <rect x="${x + 10}" y="${y + 4}" width="26" height="2" fill="#9F9388"/>
7541
+ <rect x="${x + 6}" y="${y + 6}" width="34" height="2" fill="#9F9388"/>
7542
+ <rect x="${x + 4}" y="${y + 8}" width="38" height="6" fill="#9F9388"/>
7543
+ <rect x="${x + 6}" y="${y + 14}" width="34" height="2" fill="#9F9388"/>
7544
+ <!-- Brightest specular spot -->
7545
+ <rect x="${x + 16}" y="${y + 6}" width="14" height="4" fill="#B5A998"/>
7546
+ <rect x="${x + 18}" y="${y + 10}" width="10" height="2" fill="#C7BDB0"/>
7547
+ <!-- Bottom rim shadow (gives the stone weight) -->
7548
+ <rect x="${x + 6}" y="${y + 20}" width="34" height="1" fill="#5A5048"/>
7549
+ <rect x="${x + 10}" y="${y + 22}" width="26" height="1" fill="#4A4038"/>
7550
+ <rect x="${x + 14}" y="${y + 24}" width="18" height="1" fill="#3A302A"/>
7551
+ `;
7552
+ // Top-down meandering river · a wide cyan ribbon that
7553
+ // S-curves across the bottom band. Coordinates tuned for
7554
+ // the 540×750 card (cream ground starts ≈ y=428): river
7555
+ // body spans roughly y=660-715. Width varies 30-44px at
7556
+ // different points to mimic a real meandering stream.
7557
+ const meanderingRiver = `
7558
+ <!-- Body · solid cyan. The whole scene group (river +
7559
+ stones + grass + dirt) was bumped 100px up from
7560
+ the previous layout so the watermark / stamp at
7561
+ the bottom have a generous cream footer above them. -->
7562
+ <path d="M -10 583
7563
+ C 80 571, 180 603, 260 583
7564
+ C 340 563, 420 595, 550 575
7565
+ L 550 619
7566
+ C 420 631, 340 607, 260 627
7567
+ C 180 647, 80 621, -10 629 Z"
7568
+ fill="#5BC0EB" shape-rendering="geometricPrecision"/>
7569
+ <!-- Top edge highlight (lighter ripple) -->
7570
+ <path d="M -10 585
7571
+ C 80 573, 180 605, 260 585
7572
+ C 340 565, 420 597, 550 577"
7573
+ stroke="#9ADEFA" stroke-width="2" fill="none"
7574
+ shape-rendering="geometricPrecision"/>
7575
+ <!-- Bottom edge shadow (darker rim) -->
7576
+ <path d="M -10 627
7577
+ C 80 619, 180 645, 260 625
7578
+ C 340 605, 420 629, 550 617"
7579
+ stroke="#2A6FA5" stroke-width="2" fill="none"
7580
+ shape-rendering="geometricPrecision"/>
7581
+ <!-- Sparkle pixels on the water surface -->
7582
+ <rect x="44" y="595" width="3" height="1" fill="#FFFFFF"/>
7583
+ <rect x="116" y="605" width="2" height="1" fill="#FFFFFF"/>
7584
+ <rect x="184" y="599" width="3" height="1" fill="#FFFFFF"/>
7585
+ <rect x="244" y="607" width="2" height="1" fill="#FFFFFF"/>
7586
+ <rect x="312" y="591" width="3" height="1" fill="#FFFFFF"/>
7587
+ <rect x="368" y="593" width="2" height="1" fill="#FFFFFF"/>
7588
+ <rect x="436" y="587" width="3" height="1" fill="#FFFFFF"/>
7589
+ <rect x="496" y="595" width="2" height="1" fill="#FFFFFF"/>
7590
+ <rect x="72" y="615" width="2" height="1" fill="#FFFFFF"/>
7591
+ <rect x="160" y="619" width="3" height="1" fill="#FFFFFF"/>
7592
+ <rect x="284" y="615" width="2" height="1" fill="#FFFFFF"/>
7593
+ <rect x="396" y="611" width="3" height="1" fill="#FFFFFF"/>
7594
+ `;
7595
+ // Sky decoration layer · fills the full 540×800 card so
7596
+ // pixel atmosphere wraps the bubble on every side. Sky
7597
+ // half holds the moon + stars + clouds; ground (cream)
7598
+ // half holds pixel stones + grass + dirt + a meandering
7599
+ // river.
7600
+ const skyDeco = `
7601
+ <!-- Pixel moon · 8-bit circle in upper-right, ~y=78 so
7602
+ it clears the top-right "Privateboard.ai" header
7603
+ text (which lives at y=22-34). 5 stepped rows of
7604
+ pixel pairs form the round silhouette; three
7605
+ crater highlights in a warmer cream add character. -->
7606
+ <g transform="translate(440 78)">
7607
+ <rect x="8" y="0" width="20" height="4" fill="#FFF6D9"/>
7608
+ <rect x="4" y="4" width="28" height="4" fill="#FFF6D9"/>
7609
+ <rect x="0" y="8" width="36" height="14" fill="#FFF6D9"/>
7610
+ <rect x="4" y="22" width="28" height="4" fill="#FFF6D9"/>
7611
+ <rect x="8" y="26" width="20" height="4" fill="#FFF6D9"/>
7612
+ <rect x="14" y="10" width="3" height="3" fill="#F4D898"/>
7613
+ <rect x="22" y="14" width="2" height="2" fill="#F4D898"/>
7614
+ <rect x="10" y="18" width="2" height="2" fill="#F4D898"/>
7615
+ </g>
7616
+ <!-- Star field · denser in deep purple bands, thinning
7617
+ as the sky warms. Mix of 2×2 cream pixels and 1×1
7618
+ amber pixels for depth. Stars near the top-right
7619
+ text bounds (y≈22-34, x≈400-512) were moved down
7620
+ so they don't print on the "Privateboard.ai" label. -->
7621
+ ${star(40, 50)}${star(112, 58)}${star(180, 38)}${star(252, 30)}${star(304, 10)}${star(212, 64)}
7622
+ ${star(72, 90)}${star(160, 84)}${star(220, 110)}${star(316, 64)}${star(376, 116)}${star(36, 98)}
7623
+ ${star(140, 116)}${star(264, 102)}${star(420, 132)}${star(196, 138)}${star(348, 142)}
7624
+ ${tinyStar(60, 40)}${tinyStar(168, 22)}${tinyStar(232, 50)}${tinyStar(340, 50)}${tinyStar(414, 116)}
7625
+ ${tinyStar(96, 76)}${tinyStar(204, 104)}${tinyStar(280, 130)}${tinyStar(388, 124)}
7626
+ ${tinyStar(56, 156)}${tinyStar(132, 168)}${tinyStar(420, 178)}
7627
+ <!-- Clouds · sky bands only (rose / coral around y≈170).
7628
+ The two clouds that previously sat at y=232 / y=244
7629
+ were dropped — after the scene group was shifted up,
7630
+ the bubble's top edge (y=200) covered them entirely. -->
7631
+ ${bigCloud(48, 168)}
7632
+ ${cloud(372, 158)}
7633
+ ${cloud(212, 178)}
7634
+ <!-- Ground · meandering top-down river curving across
7635
+ the warm cream band, plus chunky stones on both
7636
+ banks, denser grass tufts (mix of regular + wide),
7637
+ and scattered dirt patches breaking up the grass.
7638
+ The whole scene group was shifted 100px UP from
7639
+ the previous layout so the watermark + stamp at
7640
+ the bottom of the card have a clean cream "footer"
7641
+ above them. -->
7642
+ ${meanderingRiver}
7643
+ <!-- Stones · spread across both banks. -->
7644
+ ${stoneLarge(20, 535)}
7645
+ ${stoneMed(232, 568)}
7646
+ ${stoneSmall(376, 572)}
7647
+ ${stoneMed(444, 633)}
7648
+ ${stoneSmall(96, 639)}
7649
+ ${stoneSmall(312, 639)}
7650
+ <!-- Grass on the upper bank, woven between the stones. -->
7651
+ ${grass(76, 562)}
7652
+ ${grass(140, 566)}
7653
+ ${grass(296, 570)}
7654
+ ${grass(420, 572)}
7655
+ ${grass(496, 566)}
7656
+ ${grass(180, 578)}
7657
+ <!-- Grass on the lower bank just below the river. -->
7658
+ ${grass(40, 649)}
7659
+ ${grass(180, 651)}
7660
+ ${grass(220, 651)}
7661
+ ${grass(360, 653)}
7662
+ ${grass(500, 651)}
7663
+ <!-- Bigger grass clumps + dirt patches in the strip
7664
+ just below the lower-bank stones. -->
7665
+ ${dirtPatchBig(58, 658)}
7666
+ ${dirtPatch(190, 662)}
7667
+ ${dirtPatch(330, 660)}
7668
+ ${dirtPatchBig(402, 658)}
7669
+ ${grassWide(140, 666)}
7670
+ ${grassWide(290, 666)}
7671
+ ${grassWide(430, 666)}
7672
+ <!-- Extra dirt patches scattered on the upper bank. -->
7673
+ ${dirtPatch(180, 584)}
7674
+ ${dirtPatch(326, 578)}
7675
+ `;
7676
+ return `
7677
+ <div class="share-card" data-share-template="boardroom">
7678
+ <div class="sc-header">
7679
+ <span>◆ PRIVATEBOARD<span class="sc-heart">♥</span></span>
7680
+ <span class="sc-header-right">${esc(DOMAIN)}</span>
7681
+ </div>
7682
+ <svg class="sc-sky-deco" viewBox="0 0 540 800" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
7683
+ ${skyDeco}
7684
+ </svg>
7685
+ <div class="sc-bubble">
7686
+ <span class="sc-nail tl" aria-hidden="true"></span>
7687
+ <span class="sc-nail tr" aria-hidden="true"></span>
7688
+ <span class="sc-nail bl" aria-hidden="true"></span>
7689
+ <span class="sc-nail br" aria-hidden="true"></span>
7690
+ ${n.roomSubject ? `<div class="sc-bubble-meta">// re: ${this.wrapCjk(esc(String(n.roomSubject).trim()))}</div>` : ""}
7691
+ <p class="sc-bubble-text" style="${qStyle}">${this.wrapCjk(esc(quote))}</p>
7692
+ </div>
7693
+ <div class="sc-byline">${this.wrapCjk(esc(`被蒸馏的 ${author}`))}</div>
7694
+ <div class="share-card-watermark">${esc(DOMAIN)}</div>
7695
+ <div class="share-card-stamp">${esc(stampDate)}</div>
7696
+ </div>
7697
+ `;
7698
+ }
7699
+ if (tpl === "editorial") {
7700
+ return `
7701
+ <div class="share-card" data-share-template="editorial">
7702
+ <div>
7703
+ <div class="sc-quotemark">&ldquo;</div>
7704
+ <p class="sc-quote" style="${qStyle}">${this.wrapCjk(esc(quote))}</p>
7705
+ </div>
7706
+ <div class="sc-byline">— <strong>${this.wrapCjk(esc(author))}</strong>${roomNum ? ` · room ${esc(roomNum)}` : ""}</div>
7707
+ <div class="share-card-watermark">${esc(DOMAIN)}</div>
7708
+ <div class="share-card-stamp">${esc(stampDate)}</div>
7709
+ </div>
7710
+ `;
7711
+ }
7712
+ if (tpl === "terminal") {
7713
+ return `
7714
+ <div class="share-card" data-share-template="terminal">
7715
+ <div class="sc-frame">
7716
+ <div class="sc-prompt">$ cat note.txt</div>
7717
+ <p class="sc-quote" style="${qStyle}">${this.wrapCjk(esc(quote))}</p>
7718
+ <div class="sc-byline">> attributed to: <strong>${this.wrapCjk(esc(author))}</strong>${roomNum ? ` <span class="sc-byline-dim">[room ${esc(roomNum)}]</span>` : ""}</div>
7719
+ </div>
7720
+ <div class="share-card-watermark">${esc(DOMAIN)}</div>
7721
+ <div class="share-card-stamp">${esc(stampDate)}</div>
7722
+ </div>
7723
+ `;
7724
+ }
7725
+ // magazine (default fallthrough)
7726
+ return `
7727
+ <div class="share-card" data-share-template="magazine">
7728
+ <div class="sc-stripe"></div>
7729
+ <div class="sc-kicker">From the boardroom</div>
7730
+ <p class="sc-quote" style="${qStyle}">${this.wrapCjk(esc(quote))}</p>
7731
+ <div class="sc-byline">
7732
+ <span><strong>${this.wrapCjk(esc(author))}</strong></span>
7733
+ <span>${roomNum ? `room ${esc(roomNum)}` : ""}${stampDate ? (roomNum ? " · " : "") + esc(stampDate) : ""}</span>
7734
+ </div>
7735
+ <div class="share-card-watermark">${esc(DOMAIN)}</div>
7736
+ </div>
7737
+ `;
7738
+ },
7739
+
7740
+ /** Lazy-load html-to-image from CDN. Same loader pattern as
7741
+ * `public/magazine.html`'s `ensureHtmlToImage` so the All Notes
7742
+ * page doesn't ship the lib unless the user actually exports. */
7743
+ _h2iLoaded: null,
7744
+ async ensureShareCardHtmlToImage() {
7745
+ if (window.htmlToImage) return;
7746
+ if (!this._h2iLoaded) {
7747
+ this._h2iLoaded = new Promise((res, rej) => {
7748
+ const s = document.createElement("script");
7749
+ s.src = "https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js";
7750
+ s.onload = res;
7751
+ s.onerror = rej;
7752
+ document.head.appendChild(s);
7753
+ });
7754
+ }
7755
+ await this._h2iLoaded;
7756
+ },
7757
+
7758
+ /** Capture the current preview at native 540×675 and save it as
7759
+ * a 1080×1350 PNG. The visible preview is scaled down for fit;
7760
+ * we capture the un-scaled inner card so the export resolution
7761
+ * is independent of viewport width. Errors surface as a soft
7762
+ * alert + console.warn — same pattern as magazine/ppt exports. */
7763
+ async downloadShareCard() {
7764
+ if (!this._shareCardNote) return;
7765
+ const btn = document.querySelector("[data-share-card-download]");
7766
+ const overlay = document.getElementById("share-card-overlay");
7767
+ if (!overlay) return;
7768
+ try {
7769
+ if (btn) { btn.disabled = true; btn.textContent = "rendering…"; }
7770
+ await this.ensureShareCardHtmlToImage();
7771
+ if (document.fonts && document.fonts.ready) {
7772
+ try { await document.fonts.ready; } catch { /* best-effort */ }
7773
+ }
7774
+ // Find the live card element (the inner-most .share-card div
7775
+ // inside the preview, NOT the scaling wrapper). Capturing the
7776
+ // 540×750 card directly at pixelRatio: 2 gives a 1080×1500
7777
+ // PNG that ignores the cosmetic scale applied to the preview.
7778
+ const card = overlay.querySelector(".share-card");
7779
+ if (!card) throw new Error("share card not mounted");
7780
+ const dataUrl = await window.htmlToImage.toPng(card, {
7781
+ pixelRatio: 2,
7782
+ cacheBust: true,
7783
+ width: 540,
7784
+ height: 800,
7785
+ canvasWidth: 540,
7786
+ canvasHeight: 800,
7787
+ style: { transform: "none", margin: "0", width: "540px", height: "800px" },
7788
+ });
7789
+ const slug = (this._shareCardNote.authorName || "note")
7790
+ .toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40) || "note";
7791
+ const a = document.createElement("a");
7792
+ a.download = `privateboard-${slug}-${(this._shareCardNote.id || "").slice(0, 6)}.png`;
7793
+ a.href = dataUrl;
7794
+ a.click();
7795
+ } catch (e) {
7796
+ console.warn("[share-card] PNG export failed:", e);
7797
+ alert("PNG export failed — check the browser console for details.");
7798
+ } finally {
7799
+ if (btn) {
7800
+ btn.disabled = false;
7801
+ btn.innerHTML = `<span aria-hidden="true">↓</span><span>Download PNG</span>`;
7802
+ }
7803
+ }
7804
+ },
7805
+
6981
7806
  /** DELETE /api/notes/:id · drops a saved excerpt from the index.
6982
7807
  * Confirmation is light because the action is reversible only by
6983
7808
  * re-saving the same passage; we keep the prompt as a single
6984
7809
  * click-confirm rather than a full overlay. */
6985
7810
  async deleteNoteAt(id) {
6986
7811
  if (!id) return;
6987
- if (!confirm("Delete this note? You can save it again from the room.")) return;
7812
+ if (!confirm("Remove this note from your saved list? You can re-save it any time by highlighting the same passage in the room.")) return;
6988
7813
  try {
6989
7814
  const r = await fetch("/api/notes/" + encodeURIComponent(id), { method: "DELETE" });
6990
7815
  if (!r.ok) {
@@ -7880,8 +8705,31 @@
7880
8705
  // unmistakeably a select) without taking up visual real estate.
7881
8706
  const toneLbl = this._t("cmp_tone_label");
7882
8707
  const intensityLbl = this._t("cmp_intensity_label");
7883
- // Starter grid · 2-col responsive cards.
7884
- const starters = Array.isArray(window.BOARDROOM_STARTERS) ? window.BOARDROOM_STARTERS : [];
8708
+ // Starter grid · 2-col responsive cards. Two parallel
8709
+ // pools render in the same `.cmp-starters-grid`:
8710
+ // 1. Topic recommendations (newest-first, paged, visible
8711
+ // list, capped at the 6 the server keeps). Each card
8712
+ // carries a synthesiser-generated tag (e.g.
8713
+ // "strategy") in the left column. Clicking populates
8714
+ // the composer with the rec's subject + attaches its
8715
+ // seedContext snippets so the room opens grounded in
8716
+ // the source material.
8717
+ // 2. Legacy hardcoded starters from window.BOARDROOM_STARTERS.
8718
+ // Show only when there are no recommendations yet (or
8719
+ // when recs are still loading on first paint) so a
8720
+ // brand-new user sees something useful in the tray.
8721
+ // Server keeps at most 6 rows — defensive slice covers
8722
+ // any stale cache that pre-dates the "always 6" rule.
8723
+ const recs = (this.topicRecs.items || []).slice(0, 6);
8724
+ // Card markup lives in `topicRecCardHtml` so the
8725
+ // initial render path and any later append paths can't
8726
+ // drift in shape / attributes.
8727
+ const recCards = recs.map((rec) => this.topicRecCardHtml(rec)).join("");
8728
+
8729
+ const showLegacyStarters = recs.length === 0;
8730
+ const starters = showLegacyStarters && Array.isArray(window.BOARDROOM_STARTERS)
8731
+ ? window.BOARDROOM_STARTERS
8732
+ : [];
7885
8733
  const starterCards = starters.map((q, idx) => {
7886
8734
  const tag = (q.tag || "").replace(/^\/\/\s*/, "");
7887
8735
  return `
@@ -7892,6 +8740,47 @@
7892
8740
  </button>
7893
8741
  `;
7894
8742
  }).join("");
8743
+ // Trigger card · disguised as the first row in the
8744
+ // starters grid, so the "recommend" action lives in the
8745
+ // same visual rhythm as the suggestions it produces. The
8746
+ // card has TWO states wired through the same DOM hooks
8747
+ // ([data-trec-label] / [data-trec-detail] / [data-trec-pct]
8748
+ // / [data-trec-bar]) so the SSE handler can patch them
8749
+ // in place without re-rendering the whole composer:
8750
+ // · IDLE (no job) · tag = "✦ discover", text = the
8751
+ // bilingual label, arrow = "+". Looks like a starter
8752
+ // but with a lime accent + dashed bottom rule.
8753
+ // · BUSY (job live) · tag = phase label, text =
8754
+ // animated dots + phase detail, arrow = "N%". A
8755
+ // thin lime progress bar lives along the bottom edge
8756
+ // and fills as the pipeline ticks.
8757
+ const job = this.topicRecs.job;
8758
+ const triggerBusy = !!job;
8759
+ const triggerCard = `
8760
+ <button type="button"
8761
+ class="cmp-starter cmp-recs-trigger-card${triggerBusy ? " is-busy" : ""}"
8762
+ data-cmp-recs-trigger
8763
+ data-topic-rec-progress
8764
+ ${triggerBusy ? "disabled" : ""}
8765
+ title="${this.escape("找你可能感兴趣的话题")}">
8766
+ <div class="cmp-starter-tag" data-trec-label>${this.escape(triggerBusy ? (job.label || "starting…") : "✦ discover")}</div>
8767
+ <div class="cmp-starter-text">
8768
+ ${triggerBusy
8769
+ ? `<span class="cmp-recs-trigger-dots" aria-hidden="true"><i></i><i></i><i></i></span><span data-trec-detail>${this.escape(job.detail || "")}</span>`
8770
+ : `<span>找你可能感兴趣的话题</span>`}
8771
+ </div>
8772
+ <div class="cmp-starter-arrow" data-trec-pct>${this.escape(triggerBusy ? (job.pct ? `${job.pct}%` : "·") : "+")}</div>
8773
+ <div class="cmp-recs-trigger-bar" aria-hidden="true"><div class="cmp-recs-trigger-fill" data-trec-bar style="width: ${triggerBusy ? (job.pct || 0) : 0}%"></div></div>
8774
+ </button>
8775
+ `;
8776
+
8777
+ // Trigger card always leads; suggestions (or legacy
8778
+ // starters as empty-state fallback) follow.
8779
+ const trayCards = triggerCard + (recs.length > 0 ? recCards : starterCards);
8780
+ // Pagination is intentionally OFF — the server keeps only
8781
+ // the latest batch (6 rows). Every fresh generation wipes
8782
+ // the previous batch, so there's never "older" data to
8783
+ // page back to. The "+ N more" surface is gone.
7895
8784
 
7896
8785
  return `
7897
8786
  <section class="cmp">
@@ -7967,16 +8856,14 @@
7967
8856
  </div>
7968
8857
  </div>
7969
8858
 
7970
- ${starters.length ? `
7971
- <div class="cmp-starters">
7972
- <div class="cmp-starters-rule">
7973
- <span class="cmp-starters-rule-line"></span>
7974
- <span class="cmp-starters-rule-label">${this.escape(t.starterCaption)}</span>
7975
- <span class="cmp-starters-rule-line"></span>
7976
- </div>
7977
- <div class="cmp-starters-grid">${starterCards}</div>
8859
+ <div class="cmp-starters">
8860
+ <div class="cmp-starters-rule">
8861
+ <span class="cmp-starters-rule-line"></span>
8862
+ <span class="cmp-starters-rule-label">${this.escape(t.starterCaption)}</span>
8863
+ <span class="cmp-starters-rule-line"></span>
7978
8864
  </div>
7979
- ` : ""}
8865
+ <div class="cmp-starters-grid">${trayCards}</div>
8866
+ </div>
7980
8867
  </section>
7981
8868
  `;
7982
8869
  },
@@ -10575,11 +11462,16 @@
10575
11462
  intensity: state.intensity,
10576
11463
  deliveryMode: state.deliveryMode,
10577
11464
  autoPick: useAutoPick,
11465
+ seedContext: state.seedContext || null,
10578
11466
  });
10579
11467
  // Clear the saved draft now that the room is convened — next
10580
11468
  // visit to "+ New Room" should land on a fresh textarea, not
10581
- // re-show the just-submitted subject.
11469
+ // re-show the just-submitted subject. Also drop the attached
11470
+ // seedContext — the snippets travelled into the room's
11471
+ // opening message; we don't want them piggy-backing on the
11472
+ // NEXT room the user opens.
10582
11473
  state.subject = "";
11474
+ state.seedContext = null;
10583
11475
  this.saveComposerState();
10584
11476
  } catch (e) {
10585
11477
  if (btn) btn.classList.remove("busy");
@@ -10587,6 +11479,242 @@
10587
11479
  }
10588
11480
  },
10589
11481
 
11482
+ /** Fetch the (single) page of topic recommendations · the
11483
+ * server keeps only the latest batch (6 rows), so one
11484
+ * request is the whole story. Idempotent — safe to call
11485
+ * from renderEmptyState() boot AND after a generation job
11486
+ * completes. Sets `topicRecs.loaded` so first-paint can
11487
+ * fall back to legacy hardcoded starters until this
11488
+ * resolves. */
11489
+ async refreshTopicRecs() {
11490
+ try {
11491
+ const r = await fetch("/api/topic-recs?limit=6");
11492
+ if (!r.ok) {
11493
+ this.topicRecs.loaded = true;
11494
+ this.renderEmptyState();
11495
+ return;
11496
+ }
11497
+ const j = await r.json();
11498
+ this.topicRecs.items = Array.isArray(j.items) ? j.items : [];
11499
+ this.topicRecs.loaded = true;
11500
+ // Re-render only when the user is still on the empty
11501
+ // (composer) state · openRoom paths have already moved
11502
+ // on and an unsolicited re-render would steal focus.
11503
+ if (!this.currentRoomId) this.renderEmptyState();
11504
+ } catch { /* network error · keep stale list, surface nothing */ }
11505
+ },
11506
+
11507
+ /** Derive a short tag from a subject string · used as the
11508
+ * client-side fallback when a rec row has no `tag` field
11509
+ * (legacy rows from before migration 035, or the rare
11510
+ * case where the LLM forgot to include one and the
11511
+ * orchestrator's safety-net path also fired). Mirrors the
11512
+ * orchestrator's `deriveTagFromSubject` logic so the
11513
+ * vocabulary stays consistent across paths. */
11514
+ _deriveTagFromSubject(subject) {
11515
+ const STOP = new Set([
11516
+ "the", "and", "for", "are", "you", "your", "what", "how",
11517
+ "why", "when", "with", "from", "this", "that", "should",
11518
+ "could", "would", "have", "has", "will", "into", "about",
11519
+ ]);
11520
+ const words = (subject || "")
11521
+ .toLowerCase()
11522
+ .replace(/[^a-z0-9\s-]/g, " ")
11523
+ .split(/\s+/)
11524
+ .filter((w) => w.length > 2 && !STOP.has(w));
11525
+ return words.slice(0, 2).join(" ").slice(0, 28) || "topic";
11526
+ },
11527
+
11528
+ /** Build the HTML for a single topic-rec card. Used both by
11529
+ * `renderComposerHtml` (initial render) and `loadMoreTopicRecs`
11530
+ * (in-place append). Keeping the markup in one helper means
11531
+ * the two paths can't drift in shape or attributes. */
11532
+ topicRecCardHtml(rec) {
11533
+ // Tag priority: synthesiser-produced category > derive
11534
+ // from subject. We NEVER fall back to "web" / "memory"
11535
+ // as the visible tag — those are data-provenance tokens,
11536
+ // not topic categories. The `data-source` attribute still
11537
+ // carries the provenance for CSS to colour-tint with.
11538
+ const tag = (typeof rec.tag === "string" && rec.tag.trim().length > 0)
11539
+ ? rec.tag
11540
+ : this._deriveTagFromSubject(rec.subject);
11541
+ const hint = rec.rationale || "";
11542
+ return `
11543
+ <button type="button" class="cmp-starter cmp-rec" data-cmp-rec="${this.escape(rec.id)}" data-source="${this.escape(rec.source)}">
11544
+ <div class="cmp-starter-tag">${this.escape(tag)}</div>
11545
+ <div class="cmp-starter-text">${this.escape(rec.subject || "")}</div>
11546
+ ${hint ? `<div class="cmp-rec-hint">${this.escape(hint)}</div>` : ""}
11547
+ <div class="cmp-starter-arrow">→</div>
11548
+ </button>
11549
+ `;
11550
+ },
11551
+
11552
+ // (no `loadMoreTopicRecs` — see comments at the
11553
+ // pagination-removed render block above.)
11554
+
11555
+ /** Kick off a new topic-recommendation generation job and
11556
+ * attach an SSE stream so the composer's progress strip
11557
+ * ticks through phases live. On `topic-final` we refresh
11558
+ * the tray from the API; on error we surface the message
11559
+ * inline so the user understands why nothing landed. */
11560
+ async startTopicRecJob() {
11561
+ if (this.topicRecs.job) return; // already running · idempotent click
11562
+ // Pre-flight · API gate requires a model key. Surface the
11563
+ // same prompt as Convene so the user fixes it once.
11564
+ if (!(await this.requireModelKey())) return;
11565
+ try {
11566
+ const r = await fetch("/api/topic-recs", {
11567
+ method: "POST",
11568
+ headers: { "content-type": "application/json" },
11569
+ body: JSON.stringify({}),
11570
+ });
11571
+ if (!r.ok) {
11572
+ const j = await r.json().catch(() => ({}));
11573
+ alert("Couldn't start: " + (j.error || r.statusText));
11574
+ return;
11575
+ }
11576
+ const { jobId } = await r.json();
11577
+ this.topicRecs.job = {
11578
+ id: jobId,
11579
+ phase: 0,
11580
+ label: "starting…",
11581
+ pct: 0,
11582
+ detail: "",
11583
+ es: null,
11584
+ error: null,
11585
+ };
11586
+ if (!this.currentRoomId) this.renderEmptyState();
11587
+ this._attachTopicRecJobSSE(jobId);
11588
+ } catch (e) {
11589
+ alert("Couldn't start: " + (e && e.message ? e.message : e));
11590
+ }
11591
+ },
11592
+
11593
+ /** Internal · attach the EventSource for a running job and
11594
+ * wire each event type to the in-memory job state. */
11595
+ _attachTopicRecJobSSE(jobId) {
11596
+ const url = `/api/topic-recs/jobs/${encodeURIComponent(jobId)}/stream`;
11597
+ const es = new EventSource(url);
11598
+ this.topicRecs.job.es = es;
11599
+ const updateStrip = () => {
11600
+ const el = document.querySelector("[data-topic-rec-progress]");
11601
+ if (!el || !this.topicRecs.job) return;
11602
+ const j = this.topicRecs.job;
11603
+ const labelEl = el.querySelector("[data-trec-label]");
11604
+ if (labelEl) labelEl.textContent = j.label || "";
11605
+ const detailEl = el.querySelector("[data-trec-detail]");
11606
+ if (detailEl) detailEl.textContent = j.detail || "";
11607
+ const pctEl = el.querySelector("[data-trec-pct]");
11608
+ if (pctEl) pctEl.textContent = j.pct ? `${j.pct}%` : "·";
11609
+ const bar = el.querySelector("[data-trec-bar]");
11610
+ if (bar) bar.style.width = `${j.pct || 0}%`;
11611
+ };
11612
+ const terminate = () => {
11613
+ try { es.close(); } catch { /* noop */ }
11614
+ this.topicRecs.job = null;
11615
+ if (!this.currentRoomId) this.renderEmptyState();
11616
+ };
11617
+ es.addEventListener("hello", (ev) => {
11618
+ try {
11619
+ const data = JSON.parse(ev.data);
11620
+ if (this.topicRecs.job) {
11621
+ this.topicRecs.job.phase = data.currentPhase || 0;
11622
+ this.topicRecs.job.pct = data.progressPct || 0;
11623
+ }
11624
+ updateStrip();
11625
+ } catch { /* noop */ }
11626
+ });
11627
+ es.addEventListener("topic-phase-start", (ev) => {
11628
+ try {
11629
+ const data = JSON.parse(ev.data);
11630
+ if (this.topicRecs.job) {
11631
+ this.topicRecs.job.phase = data.phase;
11632
+ this.topicRecs.job.label = data.label;
11633
+ this.topicRecs.job.detail = "";
11634
+ }
11635
+ updateStrip();
11636
+ } catch { /* noop */ }
11637
+ });
11638
+ es.addEventListener("topic-phase-progress", (ev) => {
11639
+ try {
11640
+ const data = JSON.parse(ev.data);
11641
+ if (this.topicRecs.job) {
11642
+ this.topicRecs.job.phase = data.phase;
11643
+ this.topicRecs.job.detail = data.detail || "";
11644
+ this.topicRecs.job.pct = data.progressPct || this.topicRecs.job.pct;
11645
+ }
11646
+ updateStrip();
11647
+ } catch { /* noop */ }
11648
+ });
11649
+ es.addEventListener("topic-phase-end", (ev) => {
11650
+ try {
11651
+ const data = JSON.parse(ev.data);
11652
+ if (this.topicRecs.job) {
11653
+ this.topicRecs.job.pct = data.progressPct || this.topicRecs.job.pct;
11654
+ }
11655
+ updateStrip();
11656
+ } catch { /* noop */ }
11657
+ });
11658
+ es.addEventListener("topic-final", () => {
11659
+ // Pull the freshest first page so the new cards land
11660
+ // immediately; closing the EventSource is on the same
11661
+ // tick so the next click doesn't see a stale job.
11662
+ terminate();
11663
+ void this.refreshTopicRecs();
11664
+ });
11665
+ es.addEventListener("topic-error", (ev) => {
11666
+ let msg = "generation failed";
11667
+ try { msg = (JSON.parse(ev.data).message) || msg; } catch { /* noop */ }
11668
+ alert("Topic generation failed: " + msg);
11669
+ terminate();
11670
+ });
11671
+ es.addEventListener("topic-aborted", () => {
11672
+ terminate();
11673
+ });
11674
+ es.onerror = () => {
11675
+ // Transport hiccup · treat as terminal so the UI doesn't
11676
+ // stay stuck on a phantom progress strip.
11677
+ if (this.topicRecs.job) terminate();
11678
+ };
11679
+ },
11680
+
11681
+ /** Click handler on a recommendation card · fetches the
11682
+ * full row (to recover seedContext) then applies it to the
11683
+ * composer state so the next Convene carries the snippets
11684
+ * through to the opening message's meta. */
11685
+ async applyTopicRec(id) {
11686
+ if (!id) return;
11687
+ try {
11688
+ const r = await fetch(`/api/topic-recs/${encodeURIComponent(id)}`);
11689
+ if (!r.ok) return;
11690
+ const row = await r.json();
11691
+ if (!row || typeof row.subject !== "string") return;
11692
+ const state = this.loadComposerState();
11693
+ state.subject = row.subject;
11694
+ state.seedContext = {
11695
+ topicRecId: row.id,
11696
+ // Rationale is the "why this fits you" line the
11697
+ // synthesiser produced · it's hidden from the card
11698
+ // UI but forwarded as background context so the
11699
+ // chair's clarify prompt can ground its first turn
11700
+ // in the same reasoning the recommendation was
11701
+ // built on.
11702
+ rationale: typeof row.rationale === "string" ? row.rationale : "",
11703
+ snippets: Array.isArray(row.seedContext) ? row.seedContext : [],
11704
+ };
11705
+ this.saveComposerState();
11706
+ this.renderEmptyState();
11707
+ setTimeout(() => {
11708
+ const ta = document.querySelector("[data-composer-subject]");
11709
+ if (ta) {
11710
+ ta.focus();
11711
+ ta.setSelectionRange(ta.value.length, ta.value.length);
11712
+ this.autosizeComposerTextarea?.();
11713
+ }
11714
+ }, 50);
11715
+ } catch { /* noop */ }
11716
+ },
11717
+
10590
11718
  /** Apply a starter spec into the composer state — fills the
10591
11719
  * textarea, swaps the cast / tone / intensity to the starter's
10592
11720
  * presets, then re-renders. User can adjust before hitting Enter. */
@@ -11253,6 +12381,56 @@
11253
12381
  this.renderRoundTable();
11254
12382
  },
11255
12383
 
12384
+ /** Chair clarify bubble · parallel surface to the user bubble.
12385
+ * Pins the chair's clarifying question to the chair seat with a
12386
+ * 10s border countdown (same conic-gradient mechanic as the
12387
+ * user bubble). Voice-mode only · in text mode the question
12388
+ * appears as a normal chat bubble and the user can read it
12389
+ * inline. Calling again before timeout REPLACES the text and
12390
+ * resets the deadline. */
12391
+ CHAIR_BUBBLE_TTL_MS: 10000,
12392
+ showChairBubble(text) {
12393
+ const trimmed = String(text || "").trim();
12394
+ if (!trimmed) return;
12395
+ if (this.currentRoom && this.currentRoom.status === "adjourned") return;
12396
+ this.chairBubble.text = trimmed;
12397
+ this.chairBubble.deadline = Date.now() + this.CHAIR_BUBBLE_TTL_MS;
12398
+ this.chairBubble.dismissed = false;
12399
+ if (!this.chairBubble.intervalId) {
12400
+ this.chairBubble.intervalId = setInterval(() => this.tickChairBubble(), 1000);
12401
+ }
12402
+ this.renderRoundTable();
12403
+ },
12404
+
12405
+ /** 1Hz tick · surgical update of the border-progress CSS var.
12406
+ * Mirrors tickUserBubble exactly · see that method for the
12407
+ * full reasoning around surgical-vs-full rerender. */
12408
+ tickChairBubble() {
12409
+ if (this.chairBubble.dismissed) return;
12410
+ const now = Date.now();
12411
+ if (now >= this.chairBubble.deadline) {
12412
+ this.dismissChairBubble();
12413
+ return;
12414
+ }
12415
+ const bubbleEl = document.querySelector("[data-rt-chair-bubble]");
12416
+ if (bubbleEl) {
12417
+ const elapsed = this.CHAIR_BUBBLE_TTL_MS - (this.chairBubble.deadline - now);
12418
+ const progress = Math.min(1, Math.max(0, elapsed / this.CHAIR_BUBBLE_TTL_MS));
12419
+ bubbleEl.style.setProperty("--rt-bubble-chair-progress", progress.toFixed(3));
12420
+ }
12421
+ },
12422
+
12423
+ dismissChairBubble() {
12424
+ if (this.chairBubble.intervalId) {
12425
+ clearInterval(this.chairBubble.intervalId);
12426
+ this.chairBubble.intervalId = null;
12427
+ }
12428
+ this.chairBubble.dismissed = true;
12429
+ this.chairBubble.text = "";
12430
+ this.chairBubble.deadline = 0;
12431
+ this.renderRoundTable();
12432
+ },
12433
+
11256
12434
  /** Cycle to the next preset rate · wraps around at the top.
11257
12435
  * Persists to localStorage and applies the new rate to every
11258
12436
  * audio element currently playing in `voiceQueues`. The HUD
@@ -13304,6 +14482,19 @@
13304
14482
  `<button type="button" class="rt-bubble-user-close" data-rt-user-bubble-close aria-label="Dismiss">✕</button>` +
13305
14483
  `</div>`;
13306
14484
  }
14485
+ } else if (isChair && this.chairBubble && !this.chairBubble.dismissed
14486
+ && this.chairBubble.text && Date.now() < this.chairBubble.deadline) {
14487
+ // Chair clarify question · pinned to the chair seat with
14488
+ // border countdown. Takes precedence over the "Speaking"
14489
+ // status bubble since the chair has already finished its
14490
+ // turn at this point (message-final flipped streaming off).
14491
+ const cb = this.chairBubble;
14492
+ const elapsed = this.CHAIR_BUBBLE_TTL_MS - (cb.deadline - Date.now());
14493
+ const progress = Math.min(1, Math.max(0, elapsed / this.CHAIR_BUBBLE_TTL_MS));
14494
+ bubble = `<div class="rt-bubble rt-bubble-chair-clarify" data-rt-chair-bubble style="--rt-bubble-chair-progress: ${progress.toFixed(3)}">` +
14495
+ `<span class="rt-bubble-chair-clarify-text">${this.escape(cb.text)}</span>` +
14496
+ `<button type="button" class="rt-bubble-chair-clarify-close" data-rt-chair-bubble-close aria-label="Dismiss">✕</button>` +
14497
+ `</div>`;
13307
14498
  } else if (isSpeaking) {
13308
14499
  bubble = `<div class="${bubbleCls}"><span class="rt-bubble-name">${this.escape(m.name || "")}</span><span class="rt-bubble-status">${this.escape(statusWord)}</span><span class="rt-bubble-dots" aria-hidden="true"><i></i><i></i><i></i></span></div>`;
13309
14500
  }
@@ -13328,6 +14519,11 @@
13328
14519
  : false;
13329
14520
  const isChairBusy = (() => {
13330
14521
  if (this.chairPending === true) return true;
14522
+ // Chair message landed but voice synthesis hasn't reached
14523
+ // the client yet · the popover would otherwise flash for
14524
+ // 0.5-2s before audio kicks in. See `_chairVoiceAwaiting`
14525
+ // doc + the message-appended hook for the full reasoning.
14526
+ if (this._chairVoiceAwaiting && this._chairVoiceAwaiting.size > 0) return true;
13331
14527
  const allMsgs = this.currentMessages || [];
13332
14528
  for (let k = allMsgs.length - 1; k >= 0; k--) {
13333
14529
  const mm = allMsgs[k];
@@ -15111,6 +16307,13 @@
15111
16307
  app.dismissUserBubble();
15112
16308
  return;
15113
16309
  }
16310
+ // Same `×` pattern on the chair clarify bubble.
16311
+ const chairBubbleClose = e.target.closest("[data-rt-chair-bubble-close]");
16312
+ if (chairBubbleClose) {
16313
+ e.preventDefault();
16314
+ app.dismissChairBubble();
16315
+ return;
16316
+ }
15114
16317
  // Web Search · click the badge to expand / collapse the source list
15115
16318
  // beneath the bubble.
15116
16319
  const wsBtn = e.target.closest("[data-msg-ws-toggle]");
@@ -16096,6 +17299,23 @@
16096
17299
  if (Number.isFinite(idx)) app.applyComposerStarter(idx);
16097
17300
  return;
16098
17301
  }
17302
+ // Topic-rec card → apply via /api/topic-recs/:id so the
17303
+ // full seedContext lands in composer state.
17304
+ const composerRec = e.target.closest("[data-cmp-rec]");
17305
+ if (composerRec) {
17306
+ e.preventDefault();
17307
+ const id = composerRec.getAttribute("data-cmp-rec");
17308
+ if (id) app.applyTopicRec(id);
17309
+ return;
17310
+ }
17311
+ // Topic-rec trigger button → start a fresh generation job.
17312
+ if (e.target.closest("[data-cmp-recs-trigger]")) {
17313
+ e.preventDefault();
17314
+ void app.startTopicRecJob();
17315
+ return;
17316
+ }
17317
+ // ("+ N more" pagination removed · tray now always shows
17318
+ // the latest 6 recs and wipes on each fresh generation.)
16099
17319
  // Delete a room (any state): confirm, then real DELETE on the backend.
16100
17320
  const del = e.target.closest("[data-room-delete]");
16101
17321
  if (del) {
@@ -16106,6 +17326,42 @@
16106
17326
  if (id) app.deleteRoom(id);
16107
17327
  return;
16108
17328
  }
17329
+ // Share a saved note · opens the share-card overlay.
17330
+ const noteShare = e.target.closest("[data-note-share]");
17331
+ if (noteShare) {
17332
+ e.preventDefault();
17333
+ e.stopPropagation();
17334
+ const item = noteShare.closest("[data-note-id]");
17335
+ const id = item?.dataset.noteId;
17336
+ if (id) app.openShareCard(id);
17337
+ return;
17338
+ }
17339
+ // Share-card overlay · template chip click swaps the visual.
17340
+ const shareTplBtn = e.target.closest("[data-share-card-template]");
17341
+ if (shareTplBtn) {
17342
+ e.preventDefault();
17343
+ const key = shareTplBtn.getAttribute("data-share-card-template");
17344
+ if (key) app.setShareCardTemplate(key);
17345
+ return;
17346
+ }
17347
+ // Share-card overlay · download.
17348
+ if (e.target.closest("[data-share-card-download]")) {
17349
+ e.preventDefault();
17350
+ app.downloadShareCard();
17351
+ return;
17352
+ }
17353
+ // Share-card overlay · close button.
17354
+ if (e.target.closest("[data-share-card-close]")) {
17355
+ e.preventDefault();
17356
+ app.closeShareCard();
17357
+ return;
17358
+ }
17359
+ // Share-card overlay · backdrop click dismisses (mirrors the
17360
+ // pause-choice / vote-trigger overlay convention).
17361
+ if (e.target.id === "share-card-overlay") {
17362
+ app.closeShareCard();
17363
+ return;
17364
+ }
16109
17365
  // Delete a saved note from the All Notes list. The button is a
16110
17366
  // sibling of the note's anchor (inside .notes-item) so its click
16111
17367
  // never bubbles through the navigation link — but we still