privateboard 0.1.32 → 0.1.36

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
@@ -391,6 +391,31 @@
391
391
  // page-reload shortcut (it's not a reserved keystroke).
392
392
  // Menu reload / URL-bar reload still get caught by the
393
393
  // `beforeunload` handler below.
394
+ // Search overlay keyboard · Cmd+K / Ctrl+K toggles the
395
+ // floating search palette; Esc closes it when open. Both
396
+ // pre-empt other handlers — registered with capture so the
397
+ // overlay reliably opens / closes regardless of where focus
398
+ // sits in the document.
399
+ document.addEventListener("keydown", (e) => {
400
+ const isCmdK = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey
401
+ && (e.key === "k" || e.key === "K" || e.code === "KeyK");
402
+ if (isCmdK) {
403
+ e.preventDefault();
404
+ const overlay = document.querySelector("[data-search-overlay]");
405
+ if (!overlay) return;
406
+ if (overlay.hasAttribute("hidden")) this.openSearch();
407
+ else this.closeSearchOverlay();
408
+ return;
409
+ }
410
+ if (e.key === "Escape") {
411
+ const overlay = document.querySelector("[data-search-overlay]");
412
+ if (overlay && !overlay.hasAttribute("hidden")) {
413
+ e.preventDefault();
414
+ this.closeSearchOverlay();
415
+ }
416
+ }
417
+ }, true);
418
+
394
419
  document.addEventListener("keydown", (e) => {
395
420
  const isReload = (e.ctrlKey || e.metaKey)
396
421
  && !e.altKey
@@ -528,6 +553,19 @@
528
553
  this.refreshMiniPlayer();
529
554
  if (!this.currentRoomId) return;
530
555
  this.renderRtSubtitle();
556
+ // Karaoke-style sentence highlight inside the chat bubble of
557
+ // the currently-playing message · long agent replies are easy
558
+ // to lose your place in when TTS reads them aloud, so the
559
+ // sentence at the audio cursor gets a theme-color tint.
560
+ this.updateTtsSentenceHighlight();
561
+ });
562
+ // Replay state transitions · when playback stops (manual close,
563
+ // playlist exhausted), clear any lingering sentence highlight
564
+ // so the chat doesn't keep glowing the last-read line.
565
+ document.addEventListener("boardroom:replay-active", () => {
566
+ const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
567
+ const active = (vr && typeof vr.getActive === "function") ? vr.getActive() : null;
568
+ if (!active) this._clearTtsSentenceHighlights();
531
569
  });
532
570
  window.addEventListener("hashchange", () => this.handleRoute());
533
571
  this.handleRoute();
@@ -3745,6 +3783,14 @@
3745
3783
  if (choice === "cancel" || !text.trim()) return;
3746
3784
  if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
3747
3785
  input.value = "";
3786
+ // Shrink the room input-bar textarea back to baseline · the
3787
+ // user grew it by typing the message we're now sending, so
3788
+ // the post-clear field would otherwise stay at the prior
3789
+ // tall height. Mirrors the same call submitFromComposer
3790
+ // makes on the direct (no-modal) send path.
3791
+ if (input.matches?.(".ib-textarea[data-send-input]")) {
3792
+ this.autosizeRoomInputTextarea();
3793
+ }
3748
3794
  }
3749
3795
  if (choice === "interrupt") {
3750
3796
  this.sendMessage(text, mentions).catch((err) => alert("Send failed: " + err.message));
@@ -4798,6 +4844,217 @@
4798
4844
  this.refreshFollowUpCastButton();
4799
4845
  },
4800
4846
 
4847
+ /* ─── Cast-edit popover · room-head "Add director" button ───
4848
+ Anchored under the head icon. Lists every director with
4849
+ checkboxes pre-checked for current cast members. Diffs against
4850
+ initial state on confirm; PATCH /api/rooms/:id/members applies
4851
+ adds + removes atomically. The orchestrator's announceMember-
4852
+ Change posts a chair welcome / farewell in chat, and any newly-
4853
+ added director gets injected into the live speaker queue. */
4854
+ openCastEditOverlay(anchorBtn) {
4855
+ this.closeCastEditOverlay();
4856
+ if (!anchorBtn || !this.currentRoomId || !this.currentRoom) return;
4857
+ if (this.currentRoom.status === "adjourned") return;
4858
+
4859
+ const dirs = (this.agents || [])
4860
+ .filter((a) => a.roleKind !== "moderator")
4861
+ .slice()
4862
+ .sort((a, b) => (a.name || "").localeCompare(b.name || ""));
4863
+ const currentIds = new Set(
4864
+ (this.currentMembers || [])
4865
+ .filter((m) => (m.roleKind || "director") !== "moderator")
4866
+ .map((m) => m.id),
4867
+ );
4868
+ // State carries the INITIAL set (for diff on confirm) + the
4869
+ // user's working selection (mutated by toggleCastEditDirector).
4870
+ this._castEditState = {
4871
+ initialIds: new Set(currentIds),
4872
+ currentIds: new Set(currentIds),
4873
+ saving: false,
4874
+ };
4875
+
4876
+ const rows = dirs.map((a) => {
4877
+ const checked = currentIds.has(a.id);
4878
+ return `
4879
+ <label class="composer-pick-row${checked ? " on" : ""}" data-cast-edit-pick-id="${this.escape(a.id)}">
4880
+ <input type="checkbox" ${checked ? "checked" : ""}>
4881
+ <img class="composer-pick-av" src="${this.escape(a.avatarPath || "")}" alt="${this.escape(a.name || "")}">
4882
+ <span class="composer-pick-main">
4883
+ <span class="composer-pick-name">${this.escape(a.name || "")}</span>
4884
+ <span class="composer-pick-tag">${this.escape(a.roleTag || "")}</span>
4885
+ </span>
4886
+ </label>
4887
+ `;
4888
+ }).join("");
4889
+
4890
+ const pop = document.createElement("div");
4891
+ pop.id = "cast-edit-pop";
4892
+ pop.className = "cast-edit-pop";
4893
+ pop.innerHTML = `
4894
+ <div class="composer-pick-head">
4895
+ <span class="composer-pick-title">${this.escape(this._t("cast_edit_title"))}</span>
4896
+ </div>
4897
+ <div class="composer-pick-list">${rows || `<div class="composer-pick-empty">${this.escape(this._t("cast_edit_empty"))}</div>`}</div>
4898
+ <div class="cast-edit-foot">
4899
+ <span class="cast-edit-floor-msg" data-cast-edit-floor>${this.escape(this._t("cast_edit_min_floor"))}</span>
4900
+ <button type="button" class="cast-edit-btn" data-cast-edit-close>${this.escape(this._t("cast_edit_cancel"))}</button>
4901
+ <button type="button" class="cast-edit-btn is-primary" data-cast-edit-confirm disabled>${this.escape(this._t("cast_edit_confirm"))}</button>
4902
+ </div>
4903
+ `;
4904
+ document.body.appendChild(pop);
4905
+
4906
+ // Position · mirrors openFollowUpCastPicker · pick the side with
4907
+ // more room (the head icon sits near the top so "below" usually
4908
+ // wins). Cap max-height to the available space so the row list
4909
+ // scrolls within view instead of overflowing.
4910
+ const r = anchorBtn.getBoundingClientRect();
4911
+ const MARGIN = 8;
4912
+ const GAP = 6;
4913
+ const popW = Math.min(340, window.innerWidth - MARGIN * 2);
4914
+ const spaceBelow = window.innerHeight - r.bottom - GAP - MARGIN;
4915
+ const spaceAbove = r.top - GAP - MARGIN;
4916
+ const openAbove = spaceAbove > spaceBelow;
4917
+ // Cap the popover height · without this the list stretches to
4918
+ // every available pixel of `spaceBelow` and visually anchors to
4919
+ // the window's bottom edge (~750px on a 13" laptop). The cap
4920
+ // is `min(420px, 55vh)` — enough rows to scan ~6-7 directors
4921
+ // without scrolling, short enough to read as a compact menu
4922
+ // instead of a full-height panel.
4923
+ const HEIGHT_CAP = Math.min(420, Math.round(window.innerHeight * 0.55));
4924
+ const available = openAbove ? spaceAbove : spaceBelow;
4925
+ const maxHeight = Math.max(180, Math.min(HEIGHT_CAP, available));
4926
+ pop.style.width = popW + "px";
4927
+ // Right-align to the anchor so the popover doesn't jut past the
4928
+ // right edge of the window (head-actions sits flush right).
4929
+ const left = Math.min(
4930
+ Math.max(MARGIN, r.right - popW),
4931
+ window.innerWidth - popW - MARGIN,
4932
+ );
4933
+ pop.style.left = left + "px";
4934
+ pop.style.maxHeight = maxHeight + "px";
4935
+ pop.style.top = openAbove
4936
+ ? (r.top - GAP - Math.min(pop.scrollHeight || maxHeight, maxHeight)) + "px"
4937
+ : (r.bottom + GAP) + "px";
4938
+
4939
+ // Outside-click + Esc dismiss · same dismiss vocabulary as the
4940
+ // follow-up cast picker. We bind the outside-click on the NEXT
4941
+ // tick so this open click doesn't immediately close.
4942
+ this._castEditEsc = (ev) => {
4943
+ if (ev.key === "Escape") {
4944
+ ev.stopImmediatePropagation();
4945
+ this.closeCastEditOverlay();
4946
+ }
4947
+ };
4948
+ this._castEditOutside = (ev) => {
4949
+ if (
4950
+ !pop.contains(ev.target)
4951
+ && !ev.target.closest("[data-cast-edit-trigger]")
4952
+ ) {
4953
+ this.closeCastEditOverlay();
4954
+ }
4955
+ };
4956
+ document.addEventListener("keydown", this._castEditEsc, true);
4957
+ setTimeout(() => document.addEventListener("click", this._castEditOutside, true), 0);
4958
+
4959
+ // Single source of truth for row toggling · listens at the
4960
+ // popover root so both label-click (browser flips the input)
4961
+ // and direct-checkbox-click reach us as one `change` event.
4962
+ pop.addEventListener("change", (ev) => {
4963
+ const cb = ev.target;
4964
+ if (!cb || cb.type !== "checkbox") return;
4965
+ const row = cb.closest("[data-cast-edit-pick-id]");
4966
+ if (!row) return;
4967
+ const id = row.getAttribute("data-cast-edit-pick-id");
4968
+ this.setCastEditDirector(id, !!cb.checked);
4969
+ });
4970
+
4971
+ this.refreshCastEditConfirm();
4972
+ },
4973
+
4974
+ closeCastEditOverlay() {
4975
+ const el = document.getElementById("cast-edit-pop");
4976
+ if (el) el.remove();
4977
+ if (this._castEditEsc) {
4978
+ document.removeEventListener("keydown", this._castEditEsc, true);
4979
+ this._castEditEsc = null;
4980
+ }
4981
+ if (this._castEditOutside) {
4982
+ document.removeEventListener("click", this._castEditOutside, true);
4983
+ this._castEditOutside = null;
4984
+ }
4985
+ this._castEditState = null;
4986
+ },
4987
+
4988
+ /** Mirror the row's checkbox state into _castEditState · driven
4989
+ * by the `change` event in openCastEditOverlay. `on` reflects
4990
+ * the input.checked value AFTER the native flip, so this is
4991
+ * always a set/clear (never a flip) and is idempotent under
4992
+ * duplicate fires. */
4993
+ setCastEditDirector(id, on) {
4994
+ const state = this._castEditState;
4995
+ if (!state || state.saving) return;
4996
+ if (on) state.currentIds.add(id);
4997
+ else state.currentIds.delete(id);
4998
+ const row = document.querySelector(`[data-cast-edit-pick-id="${CSS.escape(id)}"]`);
4999
+ if (row) row.classList.toggle("on", on);
5000
+ this.refreshCastEditConfirm();
5001
+ },
5002
+
5003
+ /** Recompute the confirm-button enabled state · enabled when the
5004
+ * selection differs from the initial AND keeps at least one
5005
+ * director (the server enforces the same floor; we mirror it
5006
+ * here so the button reflects validity). Also flips the floor-
5007
+ * message visibility. */
5008
+ refreshCastEditConfirm() {
5009
+ const state = this._castEditState;
5010
+ const pop = document.getElementById("cast-edit-pop");
5011
+ if (!state || !pop) return;
5012
+ const confirmBtn = pop.querySelector("[data-cast-edit-confirm]");
5013
+ const floorMsg = pop.querySelector("[data-cast-edit-floor]");
5014
+ const sizeOk = state.currentIds.size >= 1;
5015
+ const changed =
5016
+ state.currentIds.size !== state.initialIds.size
5017
+ || [...state.currentIds].some((id) => !state.initialIds.has(id));
5018
+ if (confirmBtn) confirmBtn.disabled = state.saving || !changed || !sizeOk;
5019
+ if (floorMsg) floorMsg.classList.toggle("is-visible", !sizeOk);
5020
+ },
5021
+
5022
+ async submitCastEdit() {
5023
+ const state = this._castEditState;
5024
+ if (!state || state.saving || !this.currentRoomId) return;
5025
+ if (state.currentIds.size < 1) return;
5026
+ state.saving = true;
5027
+ this.refreshCastEditConfirm();
5028
+ const pop = document.getElementById("cast-edit-pop");
5029
+ const confirmBtn = pop ? pop.querySelector("[data-cast-edit-confirm]") : null;
5030
+ const origLabel = confirmBtn ? confirmBtn.textContent : "";
5031
+ if (confirmBtn) confirmBtn.textContent = this._t("cast_edit_saving");
5032
+ try {
5033
+ const res = await fetch(
5034
+ "/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/members",
5035
+ {
5036
+ method: "PATCH",
5037
+ headers: { "content-type": "application/json" },
5038
+ body: JSON.stringify({ agentIds: [...state.currentIds] }),
5039
+ },
5040
+ );
5041
+ if (!res.ok) {
5042
+ const j = await res.json().catch(() => ({}));
5043
+ throw new Error(j.error || "Failed to update cast");
5044
+ }
5045
+ // SSE `members-changed` config-event arrives moments later
5046
+ // and triggers a member re-fetch in the openRoom path's
5047
+ // handler. Close the popover immediately so the user sees the
5048
+ // chair's join/leave announcement land in chat.
5049
+ this.closeCastEditOverlay();
5050
+ } catch (e) {
5051
+ if (confirmBtn) confirmBtn.textContent = origLabel;
5052
+ state.saving = false;
5053
+ this.refreshCastEditConfirm();
5054
+ alert((e && e.message) || "Failed to update cast");
5055
+ }
5056
+ },
5057
+
4801
5058
  async submitFollowUp() {
4802
5059
  const overlay = document.getElementById("followup-overlay");
4803
5060
  if (!overlay) return;
@@ -6863,13 +7120,9 @@
6863
7120
  const sectionHeader = (label, kind) => {
6864
7121
  const pinned = kind === "pinned";
6865
7122
  const chair = kind === "chair";
6866
- const glyph = pinned
6867
- ? `<svg class="pin-glyph" viewBox="0 0 24 24"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5v6h2v-6h5v-2l-2-2z"/></svg>`
6868
- : "";
6869
7123
  const cls = "agents-section-header" + (pinned ? " pinned" : "") + (chair ? " chair" : "");
6870
7124
  return `
6871
7125
  <div class="${cls}">
6872
- ${glyph}
6873
7126
  <span>${this.escape(label)}</span>
6874
7127
  <span class="line"></span>
6875
7128
  </div>
@@ -7073,7 +7326,10 @@
7073
7326
  const kids = Array.isArray(this.currentFollowUps) ? this.currentFollowUps : [];
7074
7327
  if (briefCard && kids.length > 0) {
7075
7328
  // System UI · always English (sidebar / brief-card chrome head).
7076
- const headLabel = `Follow-up rooms · ${kids.length}`;
7329
+ // `// ` prefix matches the `.sa-banner-tag` kicker convention
7330
+ // (`// session analytics`) so both post-adjourn cards read as
7331
+ // the same component family.
7332
+ const headLabel = `// Follow-up rooms · ${kids.length}`;
7077
7333
  const block = document.createElement("div");
7078
7334
  block.className = "followup-children";
7079
7335
  block.innerHTML = [
@@ -8387,367 +8643,58 @@
8387
8643
  this.renderNotesPage(notesList);
8388
8644
  },
8389
8645
 
8390
- // ── Search view · cross-room keyword search ────────────────
8391
- /** Open the Search page · same main-view-replacement pattern as
8392
- * openAllReports / openAllNotes. Mounts a search input + an
8393
- * empty results panel; user-typed input triggers debounced
8394
- * /api/search calls (see runSearch). Result rows route back
8395
- * into rooms with `?q=<term>&m=<msgId>` so the message gets
8396
- * scrolled into view + flashed on arrival. */
8646
+ // ── Search overlay · cross-room keyword search ─────────────
8647
+ /** Open the floating search overlay. Layers above whatever
8648
+ * view is mounted (room / agent / reports / notes stay
8649
+ * underneath). Esc / scrim-click / X close — see
8650
+ * closeSearchOverlay + the doc-level handlers in
8651
+ * index.html. Restores the last query on re-open so the
8652
+ * user sees their prior results without retyping. */
8397
8653
  openSearch() {
8398
- document.documentElement.classList.add("no-room");
8399
- if (this.currentRoomId) {
8400
- this.disconnectSSE?.();
8401
- this.currentRoomId = null;
8402
- this.currentRoom = null;
8403
- this.currentMessages = [];
8404
- this.currentMembers = [];
8405
- this.currentHistoricalMembers = [];
8406
- this.currentQueue = [];
8407
- this.currentBrief = null;
8408
- if (/^#\/r\//.test(location.hash)) {
8409
- history.replaceState(null, "", location.pathname + location.search);
8410
- }
8411
- }
8412
- if (typeof window.closeAgentProfile === "function") {
8413
- try { window.closeAgentProfile(); } catch { /* ignore */ }
8414
- }
8415
- const room = document.querySelector('[data-main-view="room"]');
8416
- const agent = document.querySelector('[data-main-view="agent"]');
8417
- const reports = document.querySelector('[data-main-view="reports"]');
8418
- const notes = document.querySelector('[data-main-view="notes"]');
8419
- const search = document.querySelector('[data-main-view="search"]');
8420
- if (room) room.setAttribute("hidden", "");
8421
- if (agent) agent.setAttribute("hidden", "");
8422
- if (reports) reports.setAttribute("hidden", "");
8423
- if (notes) notes.setAttribute("hidden", "");
8424
- if (search) search.removeAttribute("hidden");
8425
-
8426
- this.composerMode = "room";
8427
- document.querySelectorAll("[data-convene-trigger], [data-agent-composer-trigger]").forEach((el) => el.classList.remove("active"));
8428
- document.querySelectorAll(".session-row-shell.active, .agent-row.active").forEach((el) => el.classList.remove("active"));
8429
- document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
8430
- document.querySelectorAll("[data-notes-trigger].active").forEach((el) => el.classList.remove("active"));
8654
+ const overlay = document.querySelector("[data-search-overlay]");
8655
+ if (!overlay) return;
8656
+ overlay.removeAttribute("hidden");
8657
+ overlay.setAttribute("aria-hidden", "false");
8658
+ document.documentElement.classList.add("has-search-overlay");
8431
8659
  document.querySelectorAll("[data-search-trigger]").forEach((el) => el.classList.add("active"));
8432
-
8433
- if (location.hash !== "#/search") {
8434
- try { history.replaceState(null, "", "#/search"); } catch { /* ignore */ }
8435
- }
8436
-
8437
- const page = document.querySelector("[data-search-page]");
8438
- if (!page) return;
8439
- // Initial paint · Google-style two-state markup. Both the
8440
- // hero (kicker / wordmark / caption) and the head row (input
8441
- // + result-count meta) live in the DOM together so the
8442
- // `.is-initial` ↔ `.has-results` flip is purely a CSS class
8443
- // change — the input element stays put so its value / focus
8444
- // / cursor survive the swap. The doc-level `[data-search-
8445
- // input]` listener (~line 15486) calls `runSearch(value)` on
8446
- // every input event; the clear button (~line 14577) routes
8447
- // back through `runSearch("")`.
8448
- const lastQuery = this._searchLastQuery || "";
8449
- const startsInResults = lastQuery.length > 0;
8450
- page.className = "search-page " + (startsInResults ? "has-results" : "is-initial");
8451
- // Perplexity-style hero · calm conversational tagline above
8452
- // a substantial card-style input with an internal footer
8453
- // toolbar (mono hint left, lime send button right). Starter
8454
- // chips below give one-click into common queries. The same
8455
- // input element serves both states; `.is-initial` styles
8456
- // make the wrapping `.search-card` look like a standalone
8457
- // card, while `.has-results` strips the card chrome and
8458
- // collapses input + meta into a compact head row.
8459
- // 8-bit ambient deco · scattered pixel constellation that
8460
- // gives the page texture without competing with the hero.
8461
- // All coordinates picked by hand to avoid the "regular
8462
- // grid" feel · varied densities + 3 lime accents land
8463
- // the eye softly. CSS masks the bottom edge to a fade so
8464
- // it never crowds the card. Static · no animation,
8465
- // pointer-events: none, aria-hidden.
8466
- const BG_DECO_SVG = `
8467
- <svg viewBox="0 0 800 280" preserveAspectRatio="xMidYMin slice"
8468
- shape-rendering="crispEdges" aria-hidden="true">
8469
- <!-- Faint pixel dots · scattered, mostly 2×2. -->
8470
- <g fill="var(--line-bright, #2A2A26)">
8471
- <rect x="32" y="38" width="2" height="2"/>
8472
- <rect x="78" y="22" width="2" height="2"/>
8473
- <rect x="118" y="62" width="2" height="2"/>
8474
- <rect x="156" y="30" width="2" height="2"/>
8475
- <rect x="206" y="78" width="2" height="2"/>
8476
- <rect x="254" y="44" width="2" height="2"/>
8477
- <rect x="298" y="92" width="2" height="2"/>
8478
- <rect x="342" y="26" width="2" height="2"/>
8479
- <rect x="388" y="68" width="2" height="2"/>
8480
- <rect x="436" y="38" width="2" height="2"/>
8481
- <rect x="486" y="86" width="2" height="2"/>
8482
- <rect x="528" y="50" width="2" height="2"/>
8483
- <rect x="572" y="22" width="2" height="2"/>
8484
- <rect x="618" y="74" width="2" height="2"/>
8485
- <rect x="664" y="42" width="2" height="2"/>
8486
- <rect x="708" y="88" width="2" height="2"/>
8487
- <rect x="752" y="32" width="2" height="2"/>
8488
- <rect x="60" y="118" width="2" height="2"/>
8489
- <rect x="146" y="142" width="2" height="2"/>
8490
- <rect x="222" y="116" width="2" height="2"/>
8491
- <rect x="316" y="148" width="2" height="2"/>
8492
- <rect x="402" y="124" width="2" height="2"/>
8493
- <rect x="488" y="158" width="2" height="2"/>
8494
- <rect x="572" y="118" width="2" height="2"/>
8495
- <rect x="654" y="146" width="2" height="2"/>
8496
- <rect x="734" y="124" width="2" height="2"/>
8497
- <rect x="92" y="180" width="2" height="2"/>
8498
- <rect x="186" y="206" width="2" height="2"/>
8499
- <rect x="278" y="186" width="2" height="2"/>
8500
- <rect x="370" y="216" width="2" height="2"/>
8501
- <rect x="462" y="194" width="2" height="2"/>
8502
- <rect x="554" y="222" width="2" height="2"/>
8503
- <rect x="646" y="198" width="2" height="2"/>
8504
- <rect x="724" y="226" width="2" height="2"/>
8505
- </g>
8506
- <!-- Mid accents · 4×4 squares, lime-dim. -->
8507
- <g fill="var(--lime-dim, #2D5532)">
8508
- <rect x="226" y="46" width="4" height="4"/>
8509
- <rect x="514" y="100" width="4" height="4"/>
8510
- <rect x="694" y="170" width="4" height="4"/>
8511
- </g>
8512
- <!-- Pixel "plus" accents · evoke the 8-bit
8513
- star/sparkle vocabulary. Lime-dim. -->
8514
- <g fill="var(--lime-dim, #2D5532)">
8515
- <!-- top-left plus -->
8516
- <rect x="98" y="78" width="6" height="2"/>
8517
- <rect x="100" y="76" width="2" height="6"/>
8518
- <!-- mid-right plus -->
8519
- <rect x="640" y="62" width="6" height="2"/>
8520
- <rect x="642" y="60" width="2" height="6"/>
8521
- <!-- lower-left plus -->
8522
- <rect x="354" y="178" width="6" height="2"/>
8523
- <rect x="356" y="176" width="2" height="6"/>
8524
- </g>
8525
- <!-- Bright accents · sparse, lime. Catch-the-eye
8526
- points scattered at the visual rule-of-thirds. -->
8527
- <g fill="var(--lime, #6FB572)">
8528
- <rect x="170" y="98" width="3" height="3"/>
8529
- <rect x="540" y="38" width="3" height="3"/>
8530
- <rect x="416" y="170" width="3" height="3"/>
8531
- </g>
8532
- <!-- Pixel "frame" segments · short hairline brackets at
8533
- the very top corners · evoke a CRT scanlines feel
8534
- without a full grid. -->
8535
- <g fill="var(--line-bright, #2A2A26)">
8536
- <rect x="14" y="14" width="20" height="2"/>
8537
- <rect x="14" y="14" width="2" height="20"/>
8538
- <rect x="766" y="14" width="20" height="2"/>
8539
- <rect x="784" y="14" width="2" height="20"/>
8540
- </g>
8541
- </svg>
8542
- `;
8543
- // Results-only deco · second 8-bit layer that mounts on
8544
- // top of the constellation when .has-results is active.
8545
- // Adds proper "search station" character: pixel antennas
8546
- // anchoring the corners, scattered "+" sparkles between
8547
- // them, denser dot field, and a horizon-tick floor at
8548
- // the bottom of the band. CSS scanlines (declared in
8549
- // index.html) sit underneath via background-image.
8550
- const RESULTS_DECO_SVG = `
8551
- <svg viewBox="0 0 800 130" preserveAspectRatio="xMidYMin slice"
8552
- shape-rendering="crispEdges" aria-hidden="true">
8553
- <!-- Left antenna · vertical mast + 2 crossbars +
8554
- base tile. Reads as a pixel radio tower. -->
8555
- <g fill="var(--line-bright, #2A2A26)">
8556
- <rect x="22" y="40" width="2" height="74"/>
8557
- <rect x="18" y="56" width="10" height="2"/>
8558
- <rect x="20" y="74" width="6" height="2"/>
8559
- <rect x="14" y="114" width="18" height="3"/>
8560
- </g>
8561
- <!-- Left antenna blink tip · static lime accent. -->
8562
- <rect x="22" y="36" width="2" height="2" fill="var(--lime, #6FB572)"/>
8563
- <!-- Right antenna · mirror of the left. -->
8564
- <g fill="var(--line-bright, #2A2A26)">
8565
- <rect x="776" y="44" width="2" height="70"/>
8566
- <rect x="772" y="60" width="10" height="2"/>
8567
- <rect x="774" y="78" width="6" height="2"/>
8568
- <rect x="768" y="114" width="18" height="3"/>
8569
- </g>
8570
- <rect x="776" y="40" width="2" height="2" fill="var(--lime, #6FB572)"/>
8571
- <!-- Pixel "+" sparkles · scattered in the central
8572
- band between the antennas. Reads as scanner
8573
- pings. -->
8574
- <g fill="var(--lime-dim, #2D5532)">
8575
- <rect x="138" y="44" width="6" height="2"/>
8576
- <rect x="140" y="42" width="2" height="6"/>
8577
- <rect x="324" y="68" width="6" height="2"/>
8578
- <rect x="326" y="66" width="2" height="6"/>
8579
- <rect x="498" y="34" width="6" height="2"/>
8580
- <rect x="500" y="32" width="2" height="6"/>
8581
- <rect x="612" y="86" width="6" height="2"/>
8582
- <rect x="614" y="84" width="2" height="6"/>
8583
- <rect x="220" y="92" width="6" height="2"/>
8584
- <rect x="222" y="90" width="2" height="6"/>
8585
- <rect x="700" y="50" width="6" height="2"/>
8586
- <rect x="702" y="48" width="2" height="6"/>
8587
- </g>
8588
- <!-- Dense pixel-dot field · layered on top of the
8589
- existing constellation to thicken the header. -->
8590
- <g fill="var(--line-bright, #2A2A26)">
8591
- <rect x="62" y="28" width="2" height="2"/>
8592
- <rect x="106" y="78" width="2" height="2"/>
8593
- <rect x="170" y="34" width="2" height="2"/>
8594
- <rect x="248" y="56" width="2" height="2"/>
8595
- <rect x="288" y="22" width="2" height="2"/>
8596
- <rect x="370" y="50" width="2" height="2"/>
8597
- <rect x="412" y="92" width="2" height="2"/>
8598
- <rect x="468" y="68" width="2" height="2"/>
8599
- <rect x="544" y="84" width="2" height="2"/>
8600
- <rect x="588" y="44" width="2" height="2"/>
8601
- <rect x="668" y="76" width="2" height="2"/>
8602
- <rect x="722" y="32" width="2" height="2"/>
8603
- <rect x="84" y="98" width="2" height="2"/>
8604
- <rect x="262" y="100" width="2" height="2"/>
8605
- </g>
8606
- <!-- Bright lime accent dots · 2 only, at rule-of-
8607
- thirds positions. Catches the eye. -->
8608
- <g fill="var(--lime, #6FB572)">
8609
- <rect x="266" y="40" width="3" height="3"/>
8610
- <rect x="566" y="74" width="3" height="3"/>
8611
- </g>
8612
- <!-- Horizon ticks · faint dashed pixel line near
8613
- the bottom edge of the band. Acts as visual
8614
- "floor" the antennas plant on. -->
8615
- <g fill="var(--line, #1A1A18)">
8616
- <rect x="40" y="122" width="6" height="1"/>
8617
- <rect x="60" y="122" width="2" height="1"/>
8618
- <rect x="76" y="122" width="6" height="1"/>
8619
- <rect x="96" y="122" width="2" height="1"/>
8620
- <rect x="112" y="122" width="6" height="1"/>
8621
- <rect x="132" y="122" width="2" height="1"/>
8622
- <rect x="148" y="122" width="6" height="1"/>
8623
- <rect x="168" y="122" width="2" height="1"/>
8624
- <rect x="184" y="122" width="6" height="1"/>
8625
- <rect x="204" y="122" width="2" height="1"/>
8626
- <rect x="220" y="122" width="6" height="1"/>
8627
- <rect x="240" y="122" width="2" height="1"/>
8628
- <rect x="256" y="122" width="6" height="1"/>
8629
- <rect x="276" y="122" width="2" height="1"/>
8630
- <rect x="292" y="122" width="6" height="1"/>
8631
- <rect x="312" y="122" width="2" height="1"/>
8632
- <rect x="328" y="122" width="6" height="1"/>
8633
- <rect x="348" y="122" width="2" height="1"/>
8634
- <rect x="364" y="122" width="6" height="1"/>
8635
- <rect x="384" y="122" width="2" height="1"/>
8636
- <rect x="400" y="122" width="6" height="1"/>
8637
- <rect x="420" y="122" width="2" height="1"/>
8638
- <rect x="436" y="122" width="6" height="1"/>
8639
- <rect x="456" y="122" width="2" height="1"/>
8640
- <rect x="472" y="122" width="6" height="1"/>
8641
- <rect x="492" y="122" width="2" height="1"/>
8642
- <rect x="508" y="122" width="6" height="1"/>
8643
- <rect x="528" y="122" width="2" height="1"/>
8644
- <rect x="544" y="122" width="6" height="1"/>
8645
- <rect x="564" y="122" width="2" height="1"/>
8646
- <rect x="580" y="122" width="6" height="1"/>
8647
- <rect x="600" y="122" width="2" height="1"/>
8648
- <rect x="616" y="122" width="6" height="1"/>
8649
- <rect x="636" y="122" width="2" height="1"/>
8650
- <rect x="652" y="122" width="6" height="1"/>
8651
- <rect x="672" y="122" width="2" height="1"/>
8652
- <rect x="688" y="122" width="6" height="1"/>
8653
- <rect x="708" y="122" width="2" height="1"/>
8654
- <rect x="724" y="122" width="6" height="1"/>
8655
- <rect x="744" y="122" width="2" height="1"/>
8656
- <rect x="760" y="122" width="6" height="1"/>
8657
- </g>
8658
- </svg>
8659
- `;
8660
- page.innerHTML = `
8661
- <!-- 8-bit ambient deco · top-of-page background overlay
8662
- for the is-initial state. Stays visible (dimmer)
8663
- in has-results as the base atmosphere layer. -->
8664
- <div class="search-bg-deco">${BG_DECO_SVG}</div>
8665
- <!-- Results-only deco · second layer with antennas +
8666
- sparkles + horizon ticks + CSS scanlines (in CSS).
8667
- Hidden in is-initial via opacity. -->
8668
- <div class="search-results-deco">${RESULTS_DECO_SVG}</div>
8669
- <!-- Hero · matches the new-room composer's two-row header ·
8670
- a mono caps greeting (cmp-greet · "Good afternoon, Kay")
8671
- above the headline. The old "across every room ..." sub-
8672
- line moved INSIDE the search-card's topbar so the card
8673
- itself surfaces its scope, matching .cmp-input-frame. -->
8674
- <div class="search-hero">
8675
- <div class="cmp-greet">${this.escape(this.composerGreeting(this.composerLanguage(), (this.prefs?.name || "you").trim() || "you"))}</div>
8676
- <h1 class="search-hero-title">Search every conversation</h1>
8677
- </div>
8678
- <!-- Card · is-initial = framed input with a brand-tinted
8679
- topbar hosting the scope hint, then the input below;
8680
- has-results = flex row with the meta beside it, topbar
8681
- collapsed via CSS. Mirrors new-room .cmp-input-frame. -->
8682
- <div class="search-card${lastQuery ? "" : " is-empty"}" data-search-card>
8683
- <div class="search-topbar">
8684
- <span class="search-topbar-hint">across every room · keyword · message body · room name</span>
8685
- </div>
8686
- <div class="search-input-wrap">
8687
- <span class="search-input-icon" aria-hidden="true">
8688
- <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
8689
- <circle cx="7" cy="7" r="4.5"/>
8690
- <line x1="10.3" y1="10.3" x2="13.5" y2="13.5"/>
8691
- </svg>
8692
- </span>
8693
- <input type="text" class="search-input" data-search-input
8694
- placeholder="Find a keyword, decision, or name across rooms"
8695
- value="${this.escape(lastQuery)}"
8696
- spellcheck="false"
8697
- autocomplete="off">
8698
- <button type="button" class="search-input-clear" data-search-clear aria-label="Clear" title="Clear">✕</button>
8699
- </div>
8700
- <!-- Sort filter · only visible in .has-results. Toggle
8701
- between newest-first (default) and oldest-first.
8702
- Click handler in the doc-level delegate updates
8703
- app._searchSort and re-renders from the cached
8704
- result list (no re-fetch). -->
8705
- <div class="search-results-sort" data-search-sort>
8706
- <span class="srs-label">sort</span>
8707
- <button type="button" data-search-sort-by="newest" class="active">Newest</button>
8708
- <button type="button" data-search-sort-by="oldest">Oldest</button>
8709
- </div>
8710
- <span class="search-results-meta" data-search-results-meta></span>
8711
- </div>
8712
- <!-- Static starter chips · one click pre-fills the input
8713
- and triggers the search. Hidden once the user types
8714
- anything (.has-results). System UI English-only. -->
8715
- <div class="search-starters" data-search-starters>
8716
- <span class="search-starters-label">try</span>
8717
- <button type="button" class="search-starter" data-search-starter="decision">decision</button>
8718
- <button type="button" class="search-starter" data-search-starter="next step">next step</button>
8719
- <button type="button" class="search-starter" data-search-starter="risk">risk</button>
8720
- <button type="button" class="search-starter" data-search-starter="ship">ship</button>
8721
- </div>
8722
- <div class="search-results" data-search-results></div>
8723
- `;
8724
- // Initialise the sort default · defaults to "newest"
8725
- // (most recent matches first). The chip group reads from
8726
- // this on every render to set the active state.
8727
- if (this._searchSort !== "oldest") this._searchSort = "newest";
8728
- this._refreshSortChips();
8729
- const inputEl = page.querySelector("[data-search-input]");
8730
- if (inputEl) {
8731
- // Defer focus so the layout settles + view is visible.
8732
- setTimeout(() => inputEl.focus(), 50);
8733
- if (lastQuery) {
8734
- // Re-run last search on re-open so the user sees their
8735
- // prior results without retyping.
8736
- this.runSearch(lastQuery);
8737
- }
8660
+ const input = overlay.querySelector("[data-search-input]");
8661
+ if (input) {
8662
+ const last = this._searchLastQuery || "";
8663
+ if (input.value !== last) input.value = last;
8664
+ setTimeout(() => { input.focus(); input.select(); }, 30);
8665
+ if (last) this.runSearch(last);
8738
8666
  }
8739
8667
  },
8740
8668
 
8741
- /** Flip the page-level state class. `is-initial` mounts the
8742
- * vertical-centre hero; `has-results` collapses the hero and
8743
- * shrinks the input into a head row beside the meta. Called
8744
- * from runSearch whenever the query crosses the empty
8745
- * threshold (and on cold mount in renderSearchPage). */
8746
- _setSearchPageState(state) {
8747
- const page = document.querySelector("[data-search-page]");
8748
- if (!page) return;
8749
- page.classList.toggle("is-initial", state === "initial");
8750
- page.classList.toggle("has-results", state !== "initial");
8669
+ /** Close the floating search overlay. Leaves the underlying
8670
+ * view exactly as it was. Doesn't clear the input or cached
8671
+ * query re-open restores them via openSearch. */
8672
+ closeSearchOverlay() {
8673
+ const overlay = document.querySelector("[data-search-overlay]");
8674
+ if (!overlay) return;
8675
+ overlay.setAttribute("hidden", "");
8676
+ overlay.setAttribute("aria-hidden", "true");
8677
+ document.documentElement.classList.remove("has-search-overlay");
8678
+ document.querySelectorAll("[data-search-trigger]").forEach((el) => el.classList.remove("active"));
8679
+ },
8680
+
8681
+ /** Static HTML for the empty-state placeholder · captured
8682
+ * here so runSearch("") can restore the same markup that
8683
+ * index.html mounts initially. */
8684
+ _searchOverlayEmptyHtml() {
8685
+ const title = (this._t && this._t("search_overlay_empty_title")) || "Search across rooms and messages";
8686
+ const hint = (this._t && this._t("search_overlay_empty_hint")) || "Type to filter live · click a result to jump";
8687
+ return `<div class="search-overlay-empty">
8688
+ <svg class="search-overlay-empty-icon" viewBox="0 0 64 64" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
8689
+ <circle cx="27" cy="27" r="17"/>
8690
+ <line x1="50" y1="50" x2="39" y2="39"/>
8691
+ <line x1="21" y1="27" x2="33" y2="27" opacity="0.45"/>
8692
+ <line x1="21" y1="22" x2="28" y2="22" opacity="0.3"/>
8693
+ <line x1="21" y1="32" x2="30" y2="32" opacity="0.3"/>
8694
+ </svg>
8695
+ <div class="search-overlay-empty-text">${this.escape(title)}</div>
8696
+ <div class="search-overlay-empty-hint">${this.escape(hint)}</div>
8697
+ </div>`;
8751
8698
  },
8752
8699
 
8753
8700
  /** Debounced search · 200ms after last keystroke we hit
@@ -8763,118 +8710,55 @@
8763
8710
  }
8764
8711
  const seq = (this._searchSeq = (this._searchSeq || 0) + 1);
8765
8712
  const target = document.querySelector("[data-search-results]");
8766
- const metaEl = document.querySelector("[data-search-results-meta]");
8767
- // Keep the card's empty-state class in sync with the live
8768
- // query · the CSS class hides the trailing ✕ when the
8769
- // input is genuinely empty so the hero's right edge stays
8770
- // clean before the user has typed anything.
8771
- const card = document.querySelector("[data-search-card]");
8772
- if (card) card.classList.toggle("is-empty", q.length === 0);
8773
8713
  if (!target) return;
8774
8714
  if (q.length === 0) {
8775
- // Empty query · snap back to the hero (initial state),
8776
- // clear the results list + meta. The CSS hides both via
8777
- // `.is-initial`; we still wipe innerHTML so a later
8778
- // .has-results flip doesn't briefly show stale rows.
8779
- this._setSearchPageState("initial");
8780
- target.innerHTML = "";
8781
- if (metaEl) metaEl.textContent = "";
8715
+ target.innerHTML = this._searchOverlayEmptyHtml();
8782
8716
  return;
8783
8717
  }
8784
- // Non-empty query · flip to results state (CSS fades the
8785
- // hero out, shrinks the input into the head row) and show
8786
- // a small "searching…" placeholder while the fetch is in
8787
- // flight. The meta line takes a beat to populate.
8788
- this._setSearchPageState("results");
8789
- target.innerHTML = `<div class="search-empty"><span class="search-empty-kicker">// searching</span><div class="search-empty-msg">…</div></div>`;
8790
- if (metaEl) metaEl.textContent = "";
8718
+ target.innerHTML = `<div class="search-overlay-status"><span class="search-overlay-status-kicker">// searching</span><div class="search-overlay-status-msg">…</div></div>`;
8791
8719
  this._searchDebounceTimer = setTimeout(async () => {
8792
8720
  try {
8793
8721
  const r = await fetch("/api/search?q=" + encodeURIComponent(q));
8794
8722
  if (!r.ok) throw new Error("HTTP " + r.status);
8795
8723
  const j = await r.json();
8796
- if (seq !== this._searchSeq) return; // stale
8724
+ if (seq !== this._searchSeq) return;
8797
8725
  this.renderSearchResults(j.results || [], q);
8798
8726
  } catch (e) {
8799
8727
  if (seq !== this._searchSeq) return;
8800
- target.innerHTML = `<div class="search-empty"><span class="search-empty-kicker">// error</span><div class="search-empty-msg">${this.escape(String(e && e.message ? e.message : e))}</div></div>`;
8728
+ target.innerHTML = `<div class="search-overlay-status"><span class="search-overlay-status-kicker">// error</span><div class="search-overlay-status-msg">${this.escape(String(e && e.message ? e.message : e))}</div></div>`;
8801
8729
  }
8802
8730
  }, 200);
8803
8731
  },
8804
8732
 
8805
- /** Render the search results list · flat ordered list with
8806
- * Google-style 3-line rows (mono source breadcrumb sans
8807
- * link title sans snippet body). Per-row chrome is defined
8808
- * in `renderSearchResultRow`. The sort order ("newest" /
8809
- * "oldest") is read from `app._searchSort` (default
8810
- * "newest") and re-applied client-side by `_applySearchSort`
8811
- * whenever the user clicks a sort chip — no re-fetch. */
8733
+ /** Render the result list · flat `<ul.search-overlay-list>` of
8734
+ * `.so-row` rows, sorted newest-first by createdAt. 0-match
8735
+ * shows a status block in place of the list. */
8812
8736
  renderSearchResults(results, query) {
8813
8737
  const target = document.querySelector("[data-search-results]");
8814
- const metaEl = document.querySelector("[data-search-results-meta]");
8815
8738
  if (!target) return;
8816
- // Cache the raw response so the sort chip click can re-
8817
- // render without hitting the server again. Also store the
8818
- // query so the row builder knows what to highlight.
8819
8739
  this._searchLastResults = Array.isArray(results) ? results.slice() : [];
8820
8740
  this._searchLastQueryRendered = query || "";
8821
8741
  if (!results || results.length === 0) {
8822
- if (metaEl) metaEl.textContent = `0 matches`;
8823
- target.innerHTML = `<div class="search-empty"><span class="search-empty-kicker">// no matches</span><div class="search-empty-msg">No messages match "${this.escape(query)}". Try a shorter or different term.</div></div>`;
8742
+ target.innerHTML = `<div class="search-overlay-status"><span class="search-overlay-status-kicker">// no matches</span><div class="search-overlay-status-msg">No messages match "${this.escape(query)}". Try a shorter or different term.</div></div>`;
8824
8743
  return;
8825
8744
  }
8826
- const sorted = this._applySearchSort(results);
8827
- const roomSet = new Set(sorted.map((r) => r.roomId));
8828
- if (metaEl) {
8829
- metaEl.textContent =
8830
- `${sorted.length} match${sorted.length === 1 ? "" : "es"} · ` +
8831
- `${roomSet.size} room${roomSet.size === 1 ? "" : "s"}`;
8832
- }
8745
+ const sorted = results.slice().sort((a, b) => ((b && b.createdAt) || 0) - ((a && a.createdAt) || 0));
8833
8746
  const rows = sorted.map((hit) => this.renderSearchResultRow(hit, query)).join("");
8834
- target.innerHTML = `<ul class="search-results-list">${rows}</ul>`;
8835
- },
8836
-
8837
- /** Sort a results array by createdAt according to the current
8838
- * `app._searchSort` setting. Returns a NEW array so the
8839
- * cached `_searchLastResults` stays in original order. */
8840
- _applySearchSort(results) {
8841
- const order = this._searchSort === "oldest" ? "oldest" : "newest";
8842
- const sorted = (results || []).slice();
8843
- sorted.sort((a, b) => {
8844
- const aT = (a && a.createdAt) || 0;
8845
- const bT = (b && b.createdAt) || 0;
8846
- return order === "oldest" ? aT - bT : bT - aT;
8847
- });
8848
- return sorted;
8849
- },
8850
-
8851
- /** Sync the active state on the sort chips so the lit button
8852
- * reflects `app._searchSort`. Called from the chip click
8853
- * handler + on initial mount. */
8854
- _refreshSortChips() {
8855
- const order = this._searchSort === "oldest" ? "oldest" : "newest";
8856
- document.querySelectorAll("[data-search-sort-by]").forEach((btn) => {
8857
- btn.classList.toggle("active", btn.getAttribute("data-search-sort-by") === order);
8858
- });
8747
+ target.innerHTML = `<ul class="search-overlay-list">${rows}</ul>`;
8859
8748
  },
8860
8749
 
8861
- /** Build one Google-style result row · three stacked text
8862
- * lines:
8863
- * 1. Source breadcrumb (mono caption, faint) · author name
8864
- * + time-ago the "URL row" equivalent.
8865
- * 2. Title (sans link, lime on hover) · the room subject
8866
- * (truncated). Clicking jumps to `#/r/<id>?m=<mid>&q=
8867
- * <q>` exactly like the previous flat-list row.
8868
- * 3. Snippet (sans body, soft tone) · 2-line clamp of the
8869
- * message context with `<mark>` highlight on the
8870
- * matched keyword.
8871
- * Snippet window (~110 lead, ~180 trail) is unchanged from
8872
- * the prior implementation — the change is purely visual. */
8750
+ /** Build one overlay result row · grid layout (icon | title |
8751
+ * meta · snippet spans cols 2-3). `<mark>` wraps the matched
8752
+ * fragment inside the snippet so the keyword stays visible
8753
+ * inside the truncated context. Click `#/r/{roomId}?m=
8754
+ * {msgId}&q={q}` same hash-router path the prior page used,
8755
+ * so message-jump + keyword-flash on the destination is
8756
+ * unchanged. */
8873
8757
  renderSearchResultRow(hit, query) {
8874
8758
  const body = hit.body || "";
8875
8759
  const offset = Math.max(0, hit.matchOffset || 0);
8876
8760
  const ql = (query || "").length;
8877
- const LEAD = 110, TRAIL = 180;
8761
+ const LEAD = 60, TRAIL = 120;
8878
8762
  const start = Math.max(0, offset - LEAD);
8879
8763
  const end = Math.min(body.length, offset + ql + TRAIL);
8880
8764
  const prefix = start > 0 ? "…" : "";
@@ -8888,31 +8772,12 @@
8888
8772
  `<mark>${this.escape(flat(matched))}</mark>` +
8889
8773
  this.escape(flat(after) + suffix);
8890
8774
  const roomTitle = (hit.roomTitle || "Untitled room").trim() || "Untitled room";
8891
- const author = (hit.authorName || "").trim() || "Director";
8892
8775
  const timeAgo = hit.createdAt ? this.relTime(hit.createdAt) : "";
8893
8776
  const qParam = query ? `&q=${encodeURIComponent(query)}` : "";
8894
- // Source breadcrumb · author + relative time. No "from"
8895
- // label · room title is now the prominent title line (where
8896
- // the user navigates to), so the breadcrumb only needs to
8897
- // identify the speaker + when.
8898
- const sourceLine =
8899
- `<span class="sr-source-author">${this.escape(author)}</span>` +
8900
- (timeAgo
8901
- ? `<span class="sr-source-sep">·</span><span class="sr-source-time">${this.escape(timeAgo)}</span>`
8902
- : "");
8903
- return `
8904
- <li>
8905
- <a href="#/r/${this.escape(hit.roomId)}?m=${this.escape(hit.messageId)}${qParam}"
8906
- class="sr-row"
8907
- data-search-jump-room="${this.escape(hit.roomId)}"
8908
- data-search-jump-msg="${this.escape(hit.messageId)}"
8909
- data-search-jump-q="${this.escape(query || "")}">
8910
- <div class="sr-source">${sourceLine}</div>
8911
- <div class="sr-title">${this.escape(roomTitle)}</div>
8912
- <div class="sr-snippet">${snippet}</div>
8913
- </a>
8914
- </li>
8915
- `;
8777
+ // Lucide MessageSquare · chat-bubble icon matching the
8778
+ // Claude search.png reference. Inherit currentColor.
8779
+ const icon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>`;
8780
+ return `<li><a href="#/r/${this.escape(hit.roomId)}?m=${this.escape(hit.messageId)}${qParam}" class="so-row" data-search-jump-room="${this.escape(hit.roomId)}" data-search-jump-msg="${this.escape(hit.messageId)}" data-search-jump-q="${this.escape(query || "")}"><span class="so-row-icon">${icon}</span><span class="so-row-title">${this.escape(roomTitle)}</span><span class="so-row-meta">${this.escape(timeAgo)}</span><span class="so-row-snippet">${snippet}</span></a></li>`;
8916
8781
  },
8917
8782
 
8918
8783
  /** Render the All Notes timeline · same filter-strip + date-
@@ -10241,182 +10106,6 @@
10241
10106
  * tune live as a slim toolbar inside its bottom edge so the page
10242
10107
  * has one clear gravitational centre, not three competing form
10243
10108
  * fields. */
10244
- /** 8-bit ambient backdrop · same vocabulary as the Search page's
10245
- * `.search-bg-deco` (crispEdges pixel motifs, lime accents) but
10246
- * scene-tuned for each composer:
10247
- * · room → mini boardroom (pixel table + 4 chair silhouettes)
10248
- * · agent → row of pixel character heads w/ speech bubbles
10249
- * Constellation dots + corner brackets are shared. Returns plain
10250
- * SVG markup ready to drop into `.cmp-bg-deco`. */
10251
- composerBgDecoSvg(scene) {
10252
- // Helper · stagger animation-delays so siblings don't pulse in
10253
- // lockstep. Returns a string like `style="animation-delay: 1.2s"`.
10254
- // The pseudo-random offset is index-based so it's deterministic
10255
- // (no flicker between renders).
10256
- const _delay = (i, base) => `style="animation-delay: ${(((i * 137) % 100) / 100 * base).toFixed(2)}s"`;
10257
- // Constellation dots + lime accents + corner brackets · shared
10258
- // ambient "8-bit sky" the user singled out as the visual goal.
10259
- // Each dot animates with `deco-twinkle` keyframes for a slow
10260
- // opacity blink, staggered by index so the field shimmers
10261
- // organically instead of pulsing as a single beat.
10262
- const scatterDots = [
10263
- [32, 38], [78, 22], [118, 62], [156, 30], [206, 78], [254, 44],
10264
- [298, 92], [342, 26], [388, 68], [436, 38], [486, 86], [528, 50],
10265
- [572, 22], [618, 74], [664, 42], [708, 88], [752, 32], [60, 200],
10266
- [186, 216], [278, 196], [554, 222], [646, 198], [734, 226],
10267
- ];
10268
- const limeAccents = [
10269
- // Dropped the centre-lower lime dot (416, 170) — it sat
10270
- // directly behind the H1 prompt and read as a typo.
10271
- [170, 98], [540, 38],
10272
- ];
10273
- const scatter = `
10274
- <g fill="var(--line-bright, #2A2A26)">
10275
- ${scatterDots.map(([x, y], i) =>
10276
- `<rect class="deco-twinkle" ${_delay(i, 4.5)} x="${x}" y="${y}" width="2" height="2"/>`
10277
- ).join("")}
10278
- </g>
10279
- <g fill="var(--lime, #6FB572)">
10280
- ${limeAccents.map(([x, y], i) =>
10281
- `<rect class="deco-shine" ${_delay(i + 7, 2.8)} x="${x}" y="${y}" width="3" height="3"/>`
10282
- ).join("")}
10283
- </g>
10284
- <g fill="var(--line-bright, #2A2A26)">
10285
- <rect x="14" y="14" width="20" height="2"/>
10286
- <rect x="14" y="14" width="2" height="20"/>
10287
- <rect x="766" y="14" width="20" height="2"/>
10288
- <rect x="784" y="14" width="2" height="20"/>
10289
- </g>
10290
- `;
10291
- // Scene-specific MINI motifs · small scattered 8-bit glyphs that
10292
- // theme the constellation without occupying the centre stage.
10293
- // Replaces the previous big centred tableau (table + chairs /
10294
- // row of character heads) — the user wanted ambient dots /
10295
- // sparkles tinted with each composer's flavour, not a literal
10296
- // scene in the hero band. Each motif is ≤ 14×14 px so the band
10297
- // still reads as scatter, not a feature illustration.
10298
- let motif = "";
10299
- if (scene === "room") {
10300
- // Room scene · tiny pixel chairs + mics + plus-sparkles + a
10301
- // few pixel "speech-mark" pairs (boardroom vocabulary). All
10302
- // in the warm wood / cyan moderator palette so the scatter
10303
- // tints toward "meeting" without ever forming a tableau.
10304
- // Each group carries an animation class · `deco-bob` for
10305
- // chairs, `deco-spark` for "+" glyphs, `deco-twinkle` for
10306
- // quote dots. Inline animation-delays stagger across siblings
10307
- // so the field never pulses in lockstep.
10308
- // Center-lower zone (roughly x ∈ [280, 520], y > 140) is
10309
- // where the H1 "What's on your mind today?" prompt lands.
10310
- // Cleared of motif elements so the chairs / mics / sparks /
10311
- // quote dots never collide with the title text · the deco
10312
- // stays visible only at the periphery + along the top band.
10313
- // Same hygiene pass as the agent composer's motif.
10314
- // All motif fills below pull from theme tokens (`--accent-line`
10315
- // is the brand-tinted hairline = very subtle in both themes)
10316
- // so the backdrop adopts the page's palette instead of staying
10317
- // a fixed wood-brown patch that looks "dirty" on a light bg.
10318
- const chairs = [
10319
- { x: 108, y: 138 },
10320
- { x: 372, y: 46 },
10321
- { x: 678, y: 148 },
10322
- ];
10323
- const sparks = [
10324
- [98, 78], [640, 62], [588, 156],
10325
- ];
10326
- const quotes = [
10327
- [148, 110], [152, 110], [716, 98], [720, 98],
10328
- ];
10329
- motif = `
10330
- <g shape-rendering="crispEdges">
10331
- <!-- Mini chair silhouettes — fill pulled from theme token -->
10332
- ${chairs.map((c, i) => `
10333
- <g class="deco-bob" ${_delay(i + 3, 3.2)} fill="var(--accent-line)">
10334
- <rect x="${c.x}" y="${c.y + 10}" width="6" height="2"/>
10335
- <rect x="${c.x}" y="${c.y}" width="2" height="10"/>
10336
- <rect x="${c.x + 6}" y="${c.y}" width="2" height="10"/>
10337
- </g>
10338
- `).join("")}
10339
- <!-- Tiny microphones · static (small, would feel busy). -->
10340
- <g fill="var(--accent-line)">
10341
- <rect x="226" y="46" width="3" height="2"/>
10342
- <rect x="227" y="42" width="1" height="4"/>
10343
- <rect x="514" y="100" width="3" height="2"/>
10344
- <rect x="515" y="96" width="1" height="4"/>
10345
- </g>
10346
- <!-- Wood-tone "+" sparkles · scale-pulse animation -->
10347
- ${sparks.map(([x, y], i) => `
10348
- <g class="deco-spark" ${_delay(i + 11, 2.4)} fill="var(--amber-dim, #5C3A1F)">
10349
- <rect x="${x}" y="${y + 2}" width="6" height="2"/>
10350
- <rect x="${x + 2}" y="${y}" width="2" height="6"/>
10351
- </g>
10352
- `).join("")}
10353
- <!-- Pixel "quote marks" · 2-dot pairs, twinkle in sync -->
10354
- ${quotes.map(([x, y], i) =>
10355
- `<rect class="deco-twinkle" ${_delay(i + 17, 3.5)} x="${x}" y="${y}" width="2" height="2" fill="var(--lime-dim, #2D5532)"/>`
10356
- ).join("")}
10357
- </g>
10358
- `;
10359
- } else if (scene === "agent") {
10360
- // Agent scene · tiny pixel character heads (4×4) + mini
10361
- // speech bubbles (5×3) + lime sparkles · evokes "cast / new
10362
- // persona" without ever forming a centre tableau. Heads are
10363
- // small enough to read as constellation, not as portraits.
10364
- // Heads bob, bubbles blink in / out, sparkles pulse.
10365
- // Center-lower zone (roughly x ∈ [280, 520], y > 140) is
10366
- // where the H1 "What do you want to build?" prompt lands.
10367
- // Cleared of motif elements so the heads / bubbles / sparks
10368
- // never collide with the title text · the deco stays
10369
- // visible only at the periphery + along the top band.
10370
- const heads = [
10371
- [124, 48], [498, 64], [676, 178], [218, 200],
10372
- ];
10373
- const bubbles = [
10374
- [170, 68], [362, 92], [552, 46], [612, 206],
10375
- ];
10376
- const ideaSparks = [
10377
- [68, 118], [724, 138], [84, 186],
10378
- ];
10379
- motif = `
10380
- <g shape-rendering="crispEdges">
10381
- <!-- Mini character heads · fills pulled from theme tokens
10382
- so the heads stop reading as out-of-place skin-tan +
10383
- dark-brown patches against a white bg. -->
10384
- ${heads.map(([x, y], i) => `
10385
- <g class="deco-bob" ${_delay(i + 21, 3.0)}>
10386
- <rect x="${x}" y="${y}" width="4" height="4" fill="var(--accent-line)"/>
10387
- <rect x="${x}" y="${y}" width="4" height="1" fill="var(--lime-dim)"/>
10388
- </g>
10389
- `).join("")}
10390
- <!-- Tiny speech bubbles · 10×6 pill with 1-pixel tail · blink -->
10391
- ${bubbles.map(([x, y], i) => `
10392
- <g class="deco-blink" ${_delay(i + 27, 4.0)}>
10393
- <rect x="${x}" y="${y}" width="10" height="6" fill="var(--panel-3, #1A1A18)"/>
10394
- <rect x="${x}" y="${y}" width="10" height="1" fill="var(--lime-dim, #2D5532)"/>
10395
- <rect x="${x}" y="${y + 5}" width="10" height="1" fill="var(--lime-dim, #2D5532)"/>
10396
- <rect x="${x}" y="${y}" width="1" height="6" fill="var(--lime-dim, #2D5532)"/>
10397
- <rect x="${x + 9}" y="${y}" width="1" height="6" fill="var(--lime-dim, #2D5532)"/>
10398
- <rect x="${x + 2}" y="${y + 6}" width="2" height="1" fill="var(--lime-dim, #2D5532)"/>
10399
- </g>
10400
- `).join("")}
10401
- <!-- Idea sparkles · "+" glyphs in lime-dim · scale pulse -->
10402
- ${ideaSparks.map(([x, y], i) => `
10403
- <g class="deco-spark" ${_delay(i + 33, 2.6)} fill="var(--lime-dim, #2D5532)">
10404
- <rect x="${x}" y="${y + 2}" width="6" height="2"/>
10405
- <rect x="${x + 2}" y="${y}" width="2" height="6"/>
10406
- </g>
10407
- `).join("")}
10408
- </g>
10409
- `;
10410
- }
10411
- return `
10412
- <svg viewBox="0 0 800 280" preserveAspectRatio="xMidYMin slice"
10413
- shape-rendering="crispEdges" aria-hidden="true">
10414
- ${scatter}
10415
- ${motif}
10416
- </svg>
10417
- `;
10418
- },
10419
-
10420
10109
  renderComposerHtml(state) {
10421
10110
  const userName = (this.prefs?.name || "you").trim() || "you";
10422
10111
  const lang = this.composerLanguage();
@@ -10508,7 +10197,6 @@
10508
10197
 
10509
10198
  return `
10510
10199
  <section class="cmp">
10511
- <div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("room")}</div>
10512
10200
  <header class="cmp-hero">
10513
10201
  <div class="cmp-greet">${this.escape(t.greet)}</div>
10514
10202
  <h1 class="cmp-prompt">${this.escape(t.prompt)}</h1>
@@ -10679,6 +10367,18 @@
10679
10367
  if (!ta) return;
10680
10368
  const MIN = 24;
10681
10369
  const MAX = 200;
10370
+ // Empty value · clear the inline `height` entirely so CSS
10371
+ // `min-height: 24px` reasserts the single-line baseline.
10372
+ // Without this, a `style.height = "auto"` + scrollHeight cycle
10373
+ // can leave the field at the prior tall value · Chromium
10374
+ // sometimes returns the OLD scrollHeight when value was just
10375
+ // cleared synchronously (layout invalidation doesn't always
10376
+ // flush by the next read), so the post-send "shrink back"
10377
+ // visually fails. Explicit unset bypasses the read entirely.
10378
+ if (!ta.value) {
10379
+ ta.style.removeProperty("height");
10380
+ return;
10381
+ }
10682
10382
  ta.style.height = "auto";
10683
10383
  const h = Math.min(MAX, Math.max(MIN, ta.scrollHeight));
10684
10384
  ta.style.height = h + "px";
@@ -11406,15 +11106,14 @@
11406
11106
  const ctaHint = builderMode === "full"
11407
11107
  ? "5–10 min · ReAct research + 7-phase build"
11408
11108
  : t.ctaHint;
11409
- // Celebrity seed cards · 6 random portraits from the pool of
11109
+ // Celebrity seed cards · 4 random portraits from the pool of
11410
11110
  // (CELEBRITY_SEEDS_V1 ∪ generated) − consumed. New random
11411
11111
  // pick on every render so users browse a rotating set.
11412
- const celebrityCards = this._pickRandomCelebrities(6)
11112
+ const celebrityCards = this._pickRandomCelebrities(4)
11413
11113
  .map((seed) => this.celebrityCardHtml(seed))
11414
11114
  .join("");
11415
11115
  return `
11416
11116
  <section class="cmp ag-cmp">
11417
- <div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("agent")}</div>
11418
11117
  <header class="cmp-hero">
11419
11118
  <div class="cmp-greet">${this.escape(t.greet)}</div>
11420
11119
  <h1 class="cmp-prompt">${this.escape(t.prompt)}</h1>
@@ -13787,13 +13486,13 @@
13787
13486
  return pool.slice(0, take);
13788
13487
  },
13789
13488
 
13790
- /** Build the markup for the 4 visible scenario cards. Compact
13489
+ /** Build the markup for the 2 visible scenario cards. Compact
13791
13490
  * 2-col grid layout — matches the celebrity seed cards on the
13792
13491
  * new-agent page (mono kicker → serif italic headline → italic
13793
13492
  * intro). No pills, no avatars, no CTA arrow — the entire card
13794
13493
  * is one click target. */
13795
13494
  scenarioAdCardsHtml() {
13796
- return this._pickRandomScenarios(4)
13495
+ return this._pickRandomScenarios(2)
13797
13496
  .map((c) => this.scenarioCardHtml(c))
13798
13497
  .join("");
13799
13498
  },
@@ -13974,9 +13673,10 @@
13974
13673
  <h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(this._truncateRoomSubject(r.name || r.subject, 60))}</h1>
13975
13674
  </div>
13976
13675
  <div class="head-actions">
13676
+ <a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
13977
13677
  <div class="head-cast">${castHtml}</div>
13678
+ <a href="#" class="head-icon-btn head-add-cast" data-cast-edit-trigger data-tip="${this.escape(this._t("head_add_cast_tip"))}" aria-label="${this.escape(this._t("head_add_cast_label"))}"></a>
13978
13679
  <a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> ${this.escape(this._t("room_pause_verb"))} ]</a>
13979
- <a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
13980
13680
  <a href="#" class="head-icon-btn head-divergence" data-divergence-open data-tip="See how widely the room has explored your question" aria-label="Coverage check"></a>
13981
13681
  ${this.currentBrief
13982
13682
  ? (() => {
@@ -18169,6 +17869,184 @@
18169
17869
  * `.roundtable-stage`, so it's automatically hidden by the
18170
17870
  * stage's `[hidden]` attribute when in chat view — no separate
18171
17871
  * visibility gate needed. */
17872
+ /** TTS sentence-highlight regex · matches one sentence including
17873
+ * the trailing punctuation. CJK punctuation (。!?;) + English
17874
+ * punctuation (.!?;) + newline boundaries. Mirrors the same regex
17875
+ * the subtitle picker uses in `renderRtSubtitle` so the chat
17876
+ * highlight and the stage subtitle agree on sentence boundaries. */
17877
+ _TTS_SENT_RE: /[^。!?.!?;;\n]+[。!?.!?;;\n]?/g,
17878
+
17879
+ /** Wrap each sentence inside a message's bubble in a `.tts-sentence`
17880
+ * span so the replay-tick listener can light up one at a time. No-op
17881
+ * when already wrapped, when there's no recognisable bubble, or
17882
+ * when the message only contains one sentence (no benefit to
17883
+ * wrapping a single-sentence body). Idempotent · safe to call
17884
+ * on every tick. */
17885
+ _wrapMessageSentencesForTts(article) {
17886
+ if (!article) return false;
17887
+ if (article.dataset.ttsWrapped === "1") return true;
17888
+ // Mid-stream bodies change as tokens arrive · wrapping now would
17889
+ // be torn down by the next innerHTML rewrite. Skip until the
17890
+ // streaming flag clears.
17891
+ if (article.classList.contains("streaming") || article.classList.contains("is-streaming")) {
17892
+ return false;
17893
+ }
17894
+ const bubble =
17895
+ article.querySelector(".cd-body") ||
17896
+ article.querySelector(".ci-body") ||
17897
+ article.querySelector(".msg-bubble");
17898
+ if (!bubble) return false;
17899
+ // Walk text nodes IN ORDER · sentence boundaries cut across
17900
+ // inline formatting (a sentence can include <strong>, <em>,
17901
+ // <a>...) so we have to operate on the concatenated plain text.
17902
+ const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT, null);
17903
+ const textNodes = [];
17904
+ let n;
17905
+ while ((n = walker.nextNode())) textNodes.push(n);
17906
+ if (textNodes.length === 0) return false;
17907
+ let combined = "";
17908
+ const nodeRanges = [];
17909
+ for (const tn of textNodes) {
17910
+ const start = combined.length;
17911
+ combined += tn.textContent;
17912
+ nodeRanges.push({ node: tn, start, end: combined.length });
17913
+ }
17914
+ // Find sentence boundaries in the combined text.
17915
+ const sentences = [];
17916
+ const re = new RegExp(this._TTS_SENT_RE.source, "g");
17917
+ let mtch;
17918
+ while ((mtch = re.exec(combined)) !== null) {
17919
+ const sStart = mtch.index;
17920
+ const sEnd = mtch.index + mtch[0].length;
17921
+ if (mtch[0].trim()) sentences.push({ start: sStart, end: sEnd });
17922
+ }
17923
+ if (sentences.length <= 1) return false;
17924
+ // Bucket per-node ranges by which sentence each character belongs
17925
+ // to · one text node may carry pieces of multiple sentences,
17926
+ // one sentence may span multiple text nodes.
17927
+ const nodeSplits = new Map();
17928
+ for (let sIdx = 0; sIdx < sentences.length; sIdx++) {
17929
+ const { start, end } = sentences[sIdx];
17930
+ for (const nr of nodeRanges) {
17931
+ const overlapStart = Math.max(start, nr.start);
17932
+ const overlapEnd = Math.min(end, nr.end);
17933
+ if (overlapStart >= overlapEnd) continue;
17934
+ if (!nodeSplits.has(nr.node)) nodeSplits.set(nr.node, []);
17935
+ nodeSplits.get(nr.node).push({
17936
+ idx: sIdx,
17937
+ ls: overlapStart - nr.start,
17938
+ le: overlapEnd - nr.start,
17939
+ });
17940
+ }
17941
+ }
17942
+ // Replace each text node with a fragment of (raw text gaps +
17943
+ // sentence spans). Gaps cover whitespace/leading characters
17944
+ // that fell between matched sentences.
17945
+ for (const [tn, splits] of nodeSplits) {
17946
+ const text = tn.textContent;
17947
+ const frag = document.createDocumentFragment();
17948
+ splits.sort((a, b) => a.ls - b.ls);
17949
+ let cursor = 0;
17950
+ for (const s of splits) {
17951
+ if (s.ls > cursor) {
17952
+ frag.appendChild(document.createTextNode(text.slice(cursor, s.ls)));
17953
+ }
17954
+ const span = document.createElement("span");
17955
+ span.className = "tts-sentence";
17956
+ span.dataset.ttsIdx = String(s.idx);
17957
+ span.textContent = text.slice(s.ls, s.le);
17958
+ frag.appendChild(span);
17959
+ cursor = s.le;
17960
+ }
17961
+ if (cursor < text.length) {
17962
+ frag.appendChild(document.createTextNode(text.slice(cursor)));
17963
+ }
17964
+ tn.parentNode.replaceChild(frag, tn);
17965
+ }
17966
+ bubble.dataset.ttsSentenceCount = String(sentences.length);
17967
+ // Store per-sentence character bounds so the tick picker can do
17968
+ // character-cursor-based matching (more accurate than even-split
17969
+ // sentence count division because sentences vary in length).
17970
+ bubble.dataset.ttsSentenceEnds = sentences.map((s) => s.end).join(",");
17971
+ bubble.dataset.ttsTotalChars = String(combined.length);
17972
+ article.dataset.ttsWrapped = "1";
17973
+ return true;
17974
+ },
17975
+
17976
+ _clearTtsSentenceHighlights() {
17977
+ const prior = document.querySelectorAll(".tts-sentence.is-current");
17978
+ prior.forEach((el) => el.classList.remove("is-current"));
17979
+ },
17980
+
17981
+ /** Drive the karaoke-style highlight · reads the live replay
17982
+ * cursor (currentTime / duration) and turns it into the matching
17983
+ * sentence index, then toggles `.is-current` on the right span.
17984
+ * Called from the `boardroom:replay-tick` event listener; no-op
17985
+ * when replay isn't active or the message isn't in the visible
17986
+ * chat (e.g. user scrolled it off, or the room is in stage view). */
17987
+ updateTtsSentenceHighlight() {
17988
+ const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
17989
+ if (!vr || typeof vr.getActive !== "function") return;
17990
+ const active = vr.getActive();
17991
+ if (!active || !active.messageId) {
17992
+ this._clearTtsSentenceHighlights();
17993
+ return;
17994
+ }
17995
+ const audio = (typeof vr.getActiveAudio === "function") ? vr.getActiveAudio() : null;
17996
+ if (!audio || !isFinite(audio.duration) || audio.duration <= 0) return;
17997
+ const article = document.querySelector(`[data-message-id="${active.messageId}"]`);
17998
+ if (!article) return;
17999
+ if (!this._wrapMessageSentencesForTts(article)) {
18000
+ // Single-sentence message · no per-sentence highlight needed.
18001
+ return;
18002
+ }
18003
+ const bubble =
18004
+ article.querySelector(".cd-body") ||
18005
+ article.querySelector(".ci-body") ||
18006
+ article.querySelector(".msg-bubble");
18007
+ if (!bubble) return;
18008
+ const totalChars = parseInt(bubble.dataset.ttsTotalChars || "0", 10);
18009
+ const endsRaw = bubble.dataset.ttsSentenceEnds || "";
18010
+ if (!totalChars || !endsRaw) return;
18011
+ const ends = endsRaw.split(",").map((s) => parseInt(s, 10)).filter((n) => Number.isFinite(n));
18012
+ if (ends.length === 0) return;
18013
+ // Same picker as the subtitle's replay branch · character-cursor
18014
+ // based, so a long sentence holds the highlight for the right
18015
+ // proportion of audio time even when sentence lengths vary.
18016
+ const progress = Math.max(0, Math.min(1, audio.currentTime / audio.duration));
18017
+ const cursor = Math.floor(totalChars * progress);
18018
+ let pickIdx = ends.length - 1;
18019
+ for (let i = 0; i < ends.length; i++) {
18020
+ if (ends[i] >= cursor) { pickIdx = i; break; }
18021
+ }
18022
+ // Diff-apply · only update DOM when the picked sentence
18023
+ // changed since the last tick (avoids reflows ~4×/sec).
18024
+ if (article.dataset.ttsCurrentIdx === String(pickIdx)) return;
18025
+ article.dataset.ttsCurrentIdx = String(pickIdx);
18026
+ // Clear any prior current spans inside THIS article first; spans
18027
+ // in other articles are dropped via the `getActive` change path.
18028
+ const prior = article.querySelectorAll(".tts-sentence.is-current");
18029
+ prior.forEach((el) => el.classList.remove("is-current"));
18030
+ // Also clear highlights in any other articles · the active
18031
+ // message changed (handled below).
18032
+ const stale = document.querySelectorAll(`.tts-sentence.is-current`);
18033
+ stale.forEach((el) => {
18034
+ if (!article.contains(el)) el.classList.remove("is-current");
18035
+ });
18036
+ const spans = article.querySelectorAll(`.tts-sentence[data-tts-idx="${pickIdx}"]`);
18037
+ spans.forEach((el) => el.classList.add("is-current"));
18038
+ // Auto-scroll the current sentence into view · long messages
18039
+ // are the whole reason for this feature, so we shouldn't make
18040
+ // the user scroll manually to follow the cursor. `block: "nearest"`
18041
+ // only scrolls when the sentence is outside the visible area;
18042
+ // no-op when it's already on screen.
18043
+ if (spans.length > 0) {
18044
+ try {
18045
+ spans[0].scrollIntoView({ block: "nearest", behavior: "smooth" });
18046
+ } catch (_) { /* old browsers · noop */ }
18047
+ }
18048
+ },
18049
+
18172
18050
  /** Live subtitle · paints the speaker's name + the tail of their
18173
18051
  * body text into the `[data-rt-subtitle]` panel pinned to the
18174
18052
  * bottom of the round-table stage. Cheap DOM update only — no
@@ -19736,15 +19614,55 @@
19736
19614
  CHAT_STICK_THRESHOLD: 96,
19737
19615
 
19738
19616
  /** Bind a scroll listener once · the listener flips chatStuckToBottom
19739
- * based on how far from the bottom the user has scrolled. Idempotent —
19740
- * re-bind is fine since we tag the element. */
19617
+ * based on direction-aware reasoning (scroll-up = unstick, near-
19618
+ * bottom = stick). Idempotent — re-bind is fine since we tag the
19619
+ * element.
19620
+ *
19621
+ * Earlier implementation used pure distance-from-bottom: any time
19622
+ * `scrollHeight - clientHeight - scrollTop > 96 px` the user was
19623
+ * treated as "reading history" and auto-scroll stopped. That
19624
+ * broke during fast director token streams · content grew faster
19625
+ * than the rAF-batched scroll could catch up, dist temporarily
19626
+ * exceeded the threshold (sometimes by hundreds of pixels mid-
19627
+ * paragraph), the watcher unstuck the user, and from that moment
19628
+ * on no further auto-scroll fired even though the user hadn't
19629
+ * touched the wheel. Net effect: director content rolled off the
19630
+ * bottom and the user had to scroll manually.
19631
+ *
19632
+ * Direction-aware logic instead:
19633
+ * · User scrolled UP (scrollTop decreased ≥ 4 px) → unstick.
19634
+ * The 4 px hysteresis tolerates browser-level micro-jitter so
19635
+ * a smooth-scroll animation overshooting by 1-2 px doesn't
19636
+ * look like a user scroll-up.
19637
+ * · scrollTop landed within CHAT_STICK_THRESHOLD of the bottom
19638
+ * → stick. Restores the bottom-feed after a manual scroll
19639
+ * back to the tail.
19640
+ * · Neither (scroll-down but not at bottom, or content-growth
19641
+ * pushed dist > threshold without user action) → keep current
19642
+ * state. This is the critical clause that fixes the bug —
19643
+ * auto-content-growth alone never unsticks. */
19741
19644
  bindChatScrollWatch() {
19742
19645
  const chat = document.querySelector(".chat");
19743
19646
  if (!chat || chat.dataset.scrollWatch === "1") return;
19744
19647
  chat.dataset.scrollWatch = "1";
19648
+ let prevScrollTop = chat.scrollTop;
19745
19649
  const update = () => {
19746
- const dist = chat.scrollHeight - chat.clientHeight - chat.scrollTop;
19747
- this.chatStuckToBottom = dist <= this.CHAT_STICK_THRESHOLD;
19650
+ const cur = chat.scrollTop;
19651
+ const dist = chat.scrollHeight - chat.clientHeight - cur;
19652
+ if (cur < prevScrollTop - 4) {
19653
+ // User-initiated scroll-up · leave the stick now so token
19654
+ // streaming doesn't keep snapping them back down.
19655
+ this.chatStuckToBottom = false;
19656
+ } else if (dist <= this.CHAT_STICK_THRESHOLD) {
19657
+ // Near the bottom (auto-scroll just landed there, or the
19658
+ // user scrolled back down to the tail) · re-engage stick.
19659
+ this.chatStuckToBottom = true;
19660
+ }
19661
+ // The "else" branch · scroll-down but not yet at bottom, OR
19662
+ // content-growth pushed dist > threshold without the user
19663
+ // touching anything · DO NOTHING. Keeps the prior state
19664
+ // intact so a stuck user stays stuck through fast streams.
19665
+ prevScrollTop = cur;
19748
19666
  };
19749
19667
  chat.addEventListener("scroll", update, { passive: true });
19750
19668
  update();
@@ -20027,6 +19945,30 @@
20027
19945
  app.openDivergenceOverlay();
20028
19946
  return;
20029
19947
  }
19948
+ // Cast-edit · header "Add director" icon → popover with
19949
+ // checkboxes + explicit Confirm. Anchor is the icon button.
19950
+ const castEditTrigger = e.target.closest("[data-cast-edit-trigger]");
19951
+ if (castEditTrigger) {
19952
+ e.preventDefault();
19953
+ app.openCastEditOverlay(castEditTrigger);
19954
+ return;
19955
+ }
19956
+ // Row toggle is wired via a `change` listener inside
19957
+ // openCastEditOverlay · not the global click delegation. Reason:
19958
+ // <label> wraps the <input>, so a single click fires both a label
19959
+ // click AND a synthetic input click — both bubble through any
19960
+ // `[data-cast-edit-pick-id]` `closest()` match → double toggle.
19961
+ // The `change` event fires exactly once per state flip.
19962
+ if (e.target.closest("[data-cast-edit-confirm]")) {
19963
+ e.preventDefault();
19964
+ app.submitCastEdit();
19965
+ return;
19966
+ }
19967
+ if (e.target.closest("[data-cast-edit-close]")) {
19968
+ e.preventDefault();
19969
+ app.closeCastEditOverlay();
19970
+ return;
19971
+ }
20030
19972
  // Jump-to-opener · scroll the chat back to the room's first
20031
19973
  // user message (the convene question). Useful when a long room
20032
19974
  // has scrolled the opener many screens up.
@@ -20115,67 +20057,27 @@
20115
20057
  app.downloadRoomVoiceMp3s().catch((err) => alert(String(err && err.message ? err.message : err)));
20116
20058
  return;
20117
20059
  }
20118
- // Search view · clear-input button (X) inside the input wrap.
20119
- if (e.target.closest("[data-search-clear]")) {
20060
+ // Search overlay · close button (X) or scrim click. Either
20061
+ // element carries `[data-search-overlay-close]`. Closes the
20062
+ // overlay without touching the underlying view.
20063
+ if (e.target.closest("[data-search-overlay-close]")) {
20120
20064
  e.preventDefault();
20121
- const input = document.querySelector("[data-search-input]");
20122
- if (input) {
20123
- input.value = "";
20124
- input.focus();
20125
- app.runSearch("");
20126
- }
20127
- return;
20128
- }
20129
- // Search view · starter chip click. Pre-fills the input with
20130
- // the chip's keyword and triggers a search. Dispatching an
20131
- // input event ensures the existing doc-level input listener
20132
- // (which calls runSearch) fires too, so the debounced fetch
20133
- // path stays canonical.
20134
- const starter = e.target.closest("[data-search-starter]");
20135
- if (starter) {
20136
- e.preventDefault();
20137
- const term = starter.getAttribute("data-search-starter") || "";
20138
- const input = document.querySelector("[data-search-input]");
20139
- if (input && term) {
20140
- input.value = term;
20141
- input.focus();
20142
- input.dispatchEvent(new Event("input", { bubbles: true }));
20143
- }
20144
- return;
20145
- }
20146
- // Search view · sort chip ("Newest" / "Oldest"). Updates the
20147
- // sort key + re-renders the cached result list client-side.
20148
- // No re-fetch · the API doesn't expose a sort param and the
20149
- // dataset is small (≤ 200 hits per query) so an in-memory
20150
- // sort is the right call.
20151
- const sortChip = e.target.closest("[data-search-sort-by]");
20152
- if (sortChip) {
20153
- e.preventDefault();
20154
- const next = sortChip.getAttribute("data-search-sort-by") === "oldest" ? "oldest" : "newest";
20155
- if (app._searchSort === next) return; // already active
20156
- app._searchSort = next;
20157
- app._refreshSortChips();
20158
- // Re-render from the cached results · skip the no-cache
20159
- // path (search not yet run) since the chip group is hidden
20160
- // in is-initial.
20161
- const cached = Array.isArray(app._searchLastResults) ? app._searchLastResults : null;
20162
- const cachedQuery = app._searchLastQueryRendered || app._searchLastQuery || "";
20163
- if (cached && cached.length > 0) {
20164
- app.renderSearchResults(cached, cachedQuery);
20165
- }
20065
+ if (typeof app.closeSearchOverlay === "function") app.closeSearchOverlay();
20166
20066
  return;
20167
20067
  }
20168
- // Search view · result row click. Anchor's href is
20068
+ // Search overlay · result row click. Anchor's href is
20169
20069
  // `#/r/<id>?m=<mid>&q=<query>`, which the hashchange route
20170
20070
  // handler picks up. We stash the pending message id + query
20171
20071
  // here too as a belt-and-braces in case the hash is consumed
20172
- // by another listener before handleRoute runs.
20072
+ // by another listener before handleRoute runs. Also closes
20073
+ // the overlay so the destination room is visible immediately.
20173
20074
  const searchJumpMsg = e.target.closest("[data-search-jump-msg]");
20174
20075
  if (searchJumpMsg) {
20175
20076
  const mid = searchJumpMsg.getAttribute("data-search-jump-msg");
20176
20077
  const qry = searchJumpMsg.getAttribute("data-search-jump-q") || "";
20177
20078
  if (mid) app._pendingMessageScroll = mid;
20178
20079
  app._pendingMessageQuery = qry;
20080
+ if (typeof app.closeSearchOverlay === "function") app.closeSearchOverlay();
20179
20081
  // Let the anchor navigate naturally (hash change → handleRoute
20180
20082
  // → openRoom). No `return` since the link's default action
20181
20083
  // does the navigation.
@@ -21140,7 +21042,15 @@
21140
21042
  // Restore the text into the composer so the user can edit/resend.
21141
21043
  const input = document.querySelector('.input-bar input, [data-send-input]');
21142
21044
  if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
21143
- if (!input.value.trim()) input.value = app.pendingUserMessage;
21045
+ if (!input.value.trim()) {
21046
+ input.value = app.pendingUserMessage;
21047
+ // Re-grow the textarea to fit the restored body · without
21048
+ // this a multi-line queued message reappears clipped to
21049
+ // the single-line baseline.
21050
+ if (input.matches?.(".ib-textarea[data-send-input]")) {
21051
+ app.autosizeRoomInputTextarea();
21052
+ }
21053
+ }
21144
21054
  }
21145
21055
  app.pendingUserMessage = null;
21146
21056
  app.pendingForSpeakerId = null;