privateboard 0.1.12 → 0.1.13

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
@@ -87,11 +87,24 @@
87
87
  keys: {},
88
88
  agents: [],
89
89
  agentsById: {},
90
+ /** Voice-options label cache · keyed by `<provider>:<voiceId>` →
91
+ * friendly label (e.g. "minimax:male-qn-qingse" → "青涩青年").
92
+ * Populated by a one-shot /api/voices prefetch in loadInitial.
93
+ * Sidebar's agent-row subtitle reads from this synchronously; a
94
+ * miss falls back to the raw voiceId so the row never blocks on
95
+ * the fetch. */
96
+ voiceLabels: {},
90
97
  rooms: [],
91
98
  currentRoomId: null,
92
99
  currentRoom: null,
93
100
  currentMessages: [],
94
- currentMembers: [], // directors only (chair excluded)
101
+ currentMembers: [], // directors only (chair excluded) · ACTIVE roster
102
+ // Every director who's ever been in this room, including those
103
+ // the chair has soft-excused. Each carries `removedAt` (null =
104
+ // active, timestamp = excused). Used for chat-history speaker
105
+ // resolution + voice replay so excused directors' past messages
106
+ // still render their name / avatar / voice profile.
107
+ currentHistoricalMembers: [],
95
108
  currentChair: null, // chair agent for the current room
96
109
  currentQueue: [],
97
110
  voiceQueues: {},
@@ -436,6 +449,15 @@
436
449
  },
437
450
 
438
451
  async loadInitial() {
452
+ // CRITICAL fetches block the dashboard's first paint — only the
453
+ // four queries below have data the initial render NEEDS. The
454
+ // `/api/voices` route is intentionally NOT in this Promise.all:
455
+ // its server handler can synchronously hit MiniMax + ElevenLabs
456
+ // cloud APIs (1-2s each), and putting it here used to delay the
457
+ // ENTIRE UI by the slowest of those calls. Voice labels are a
458
+ // sidebar-row sweetener — `voiceLabelFor()` already falls back
459
+ // to raw voice IDs on cache miss — so we fetch them in the
460
+ // background and re-render the sidebar once they arrive.
439
461
  const [prefsRes, keysRes, agentsRes, roomsRes] = await Promise.all([
440
462
  fetch("/api/prefs"),
441
463
  fetch("/api/keys"),
@@ -460,6 +482,41 @@
460
482
  const j = await roomsRes.json();
461
483
  this.rooms = j.rooms || [];
462
484
  }
485
+ // Fire-and-forget the voice-labels prefetch · the initial paint
486
+ // already finished by the time this resolves. Re-renders the
487
+ // agents sidebar so the friendly label ("青涩青年") swaps in for
488
+ // any raw voiceId rows that already mounted. Safe to discard on
489
+ // failure (sidebar keeps the raw id fallback).
490
+ void this._prefetchVoiceLabels();
491
+ },
492
+
493
+ /** Background prefetch for /api/voices · runs after the initial
494
+ * load completes so the dashboard's first paint isn't held up by
495
+ * the MiniMax / ElevenLabs cloud-API round-trips inside the
496
+ * server's `listAvailableVoices`. Triggers a sidebar re-render
497
+ * when labels arrive so the friendly names replace the raw voice
498
+ * IDs in-place. Idempotent · safe to call multiple times. */
499
+ async _prefetchVoiceLabels() {
500
+ try {
501
+ const res = await fetch("/api/voices");
502
+ if (!res || !res.ok) return;
503
+ const j = await res.json();
504
+ const list = Array.isArray(j.voices) ? j.voices : [];
505
+ const map = {};
506
+ for (const v of list) {
507
+ if (v && typeof v.provider === "string" && typeof v.voiceId === "string") {
508
+ const label = typeof v.label === "string" && v.label.trim() ? v.label.trim() : v.voiceId;
509
+ map[`${v.provider}:${v.voiceId}`] = label;
510
+ }
511
+ }
512
+ this.voiceLabels = map;
513
+ // Re-render the agents sidebar so any rows currently showing
514
+ // a raw voice id (e.g. "male-qn-qingse") swap to the prettier
515
+ // label ("青涩青年"). Cheap idempotent paint.
516
+ if (typeof this.renderSidebarAgents === "function") {
517
+ this.renderSidebarAgents();
518
+ }
519
+ } catch { /* keep empty map · sidebar falls back to voiceId */ }
463
520
  },
464
521
 
465
522
  // ── Routing ───────────────────────────────────────────────
@@ -685,6 +742,11 @@
685
742
  this.currentRoom = data.room;
686
743
  this.currentMessages = data.messages || [];
687
744
  this.currentMembers = data.members || [];
745
+ // Full historical roster · falls back to active members for
746
+ // older servers that don't ship `historicalMembers` yet.
747
+ this.currentHistoricalMembers = Array.isArray(data.historicalMembers)
748
+ ? data.historicalMembers
749
+ : (data.members || []).map((m) => ({ ...m, removedAt: null }));
688
750
  this.currentChair = data.chair || null;
689
751
  this.currentQueue = data.queue || [];
690
752
  this.currentRound = data.round || { spoken: 0, total: 0 };
@@ -859,6 +921,7 @@
859
921
  this.currentRoom = null;
860
922
  this.currentMessages = [];
861
923
  this.currentMembers = [];
924
+ this.currentHistoricalMembers = [];
862
925
  // `currentChair` is NOT reset · the chair is a structural
863
926
  // singleton (one moderator agent in the catalog, same across
864
927
  // every room), and the sidebar's Chair section keys off it
@@ -1145,6 +1208,22 @@
1145
1208
  // meta.streaming. Skipping when a queue already exists keeps
1146
1209
  // the SSE hot path cheap (we don't repaint per chunk).
1147
1210
  const fresh = !this.voiceQueues[data.messageId];
1211
+ // Chair gavel SFX · fire ONCE when fresh chair voice starts
1212
+ // streaming, so the user hears the courtroom "knock-knock"
1213
+ // calling for attention before the chair speaks. Detection:
1214
+ // pull the message from currentMessages by id, check author
1215
+ // is the chair. Fires before enqueueVoiceChunk schedules the
1216
+ // first audio buffer, so the gavel overlaps only the audio
1217
+ // header / chunker startup (~50-100ms of inaudible buffer),
1218
+ // not the chair's first spoken word. Same enabled-flag as
1219
+ // typing/speaker-change · respects user-settings sound toggle.
1220
+ if (fresh && this.currentChair && window.boardroomTypingSfx
1221
+ && typeof window.boardroomTypingSfx.gavel === "function") {
1222
+ const msg = (this.currentMessages || []).find((m) => m.id === data.messageId);
1223
+ if (msg && msg.authorKind === "agent" && msg.authorId === this.currentChair.id) {
1224
+ window.boardroomTypingSfx.gavel();
1225
+ }
1226
+ }
1148
1227
  this.enqueueVoiceChunk(roomId, data);
1149
1228
  if (fresh) {
1150
1229
  this.renderRoundTable();
@@ -1669,6 +1748,27 @@
1669
1748
  this.currentMembers.push(byId[aid]);
1670
1749
  }
1671
1750
  }
1751
+ // Mirror the diff into historicalMembers so excused
1752
+ // directors stay queryable for chat-history + voice
1753
+ // replay lookups. Removal flips `removedAt` to now;
1754
+ // re-adding clears it back to null. New additions
1755
+ // append the snapshot from the global agents catalog.
1756
+ if (!Array.isArray(this.currentHistoricalMembers)) {
1757
+ this.currentHistoricalMembers = [];
1758
+ }
1759
+ if (removed.length > 0) {
1760
+ const ts = Date.now();
1761
+ for (const id of removed) {
1762
+ const entry = this.currentHistoricalMembers.find((m) => m.id === id);
1763
+ if (entry) entry.removedAt = ts;
1764
+ else if (byId[id]) this.currentHistoricalMembers.push({ ...byId[id], removedAt: ts });
1765
+ }
1766
+ }
1767
+ for (const aid of added) {
1768
+ const entry = this.currentHistoricalMembers.find((m) => m.id === aid);
1769
+ if (entry) entry.removedAt = null;
1770
+ else if (byId[aid]) this.currentHistoricalMembers.push({ ...byId[aid], removedAt: null });
1771
+ }
1672
1772
  this.renderHeader();
1673
1773
  this.renderQueue();
1674
1774
  // Round-table toasts · one chip per added / removed agent.
@@ -4259,6 +4359,7 @@
4259
4359
  this.currentRoom = null;
4260
4360
  this.currentMessages = [];
4261
4361
  this.currentMembers = [];
4362
+ this.currentHistoricalMembers = [];
4262
4363
  this.currentQueue = [];
4263
4364
  this.currentBrief = null;
4264
4365
  location.hash = "";
@@ -4729,10 +4830,27 @@
4729
4830
 
4730
4831
  const renderRow = (a, opts = {}) => {
4731
4832
  const status = "active"; // every persisted director is active in v1
4732
- const time = this.relTime(a.createdAt) || "—";
4733
4833
  const pinBtn = `
4734
4834
  <button type="button" class="pin-toggle" title="${this.escape(a.isPinned ? this._t("sidebar_unpin") : this._t("sidebar_pin"))}" data-pin-toggle>${PIN_GLYPH}</button>
4735
4835
  `;
4836
+ // Subtitle · model name + (optional) voice character.
4837
+ // Replaces the prior "{roleTag} · Active" pattern which carried
4838
+ // no information beyond what the section header already
4839
+ // conveyed. Model is the practical "what's this agent" tell;
4840
+ // voice (when set) tells the user which TTS voice they'll hear
4841
+ // in voice rooms.
4842
+ const modelLabel = this.modelLabel(a.modelV);
4843
+ const voiceLabel = this.voiceLabelFor(a);
4844
+ const subParts = [];
4845
+ if (modelLabel) {
4846
+ subParts.push(`<span class="agent-row-model">${this.escape(modelLabel)}</span>`);
4847
+ }
4848
+ if (voiceLabel) {
4849
+ if (subParts.length > 0) {
4850
+ subParts.push(`<span class="agent-row-sep">·</span>`);
4851
+ }
4852
+ subParts.push(`<span class="agent-row-voice">${this.escape(voiceLabel)}</span>`);
4853
+ }
4736
4854
  // Delete moved off the sidebar row · it now lives inside the
4737
4855
  // agent profile's ⋯ overflow menu where it's protected by the
4738
4856
  // standard ⋯ → confirm flow. The sidebar row is the navigate-
@@ -4746,11 +4864,7 @@
4746
4864
  <span class="agent-row-title">${this.escape(a.name)}</span>
4747
4865
  ${pinBtn}
4748
4866
  </div>
4749
- <div class="agent-row-subtitle">
4750
- <span>${this.escape(a.roleTag || this._t("sidebar_role_director"))}</span>
4751
- <span class="agent-row-sep">·</span>
4752
- <span class="agent-row-status">${this.escape(this._t("sidebar_status_active"))}</span>
4753
- </div>
4867
+ <div class="agent-row-subtitle">${subParts.join("")}</div>
4754
4868
  </div>
4755
4869
  </a>
4756
4870
  </div>
@@ -5599,6 +5713,7 @@
5599
5713
  this.currentRoom = null;
5600
5714
  this.currentMessages = [];
5601
5715
  this.currentMembers = [];
5716
+ this.currentHistoricalMembers = [];
5602
5717
  this.currentQueue = [];
5603
5718
  this.currentBrief = null;
5604
5719
  // Clear the URL hash so re-clicking a room link works (same
@@ -6089,6 +6204,7 @@
6089
6204
  this.currentRoom = null;
6090
6205
  this.currentMessages = [];
6091
6206
  this.currentMembers = [];
6207
+ this.currentHistoricalMembers = [];
6092
6208
  this.currentQueue = [];
6093
6209
  this.currentBrief = null;
6094
6210
  if (/^#\/r\//.test(location.hash)) {
@@ -6163,6 +6279,7 @@
6163
6279
  this.currentRoom = null;
6164
6280
  this.currentMessages = [];
6165
6281
  this.currentMembers = [];
6282
+ this.currentHistoricalMembers = [];
6166
6283
  this.currentQueue = [];
6167
6284
  this.currentBrief = null;
6168
6285
  if (/^#\/r\//.test(location.hash)) {
@@ -6196,35 +6313,293 @@
6196
6313
 
6197
6314
  const page = document.querySelector("[data-search-page]");
6198
6315
  if (!page) return;
6199
- // Initial paint · search input + empty hint. The input field
6200
- // listens for `input` events to debounce-fire runSearch.
6316
+ // Initial paint · Google-style two-state markup. Both the
6317
+ // hero (kicker / wordmark / caption) and the head row (input
6318
+ // + result-count meta) live in the DOM together so the
6319
+ // `.is-initial` ↔ `.has-results` flip is purely a CSS class
6320
+ // change — the input element stays put so its value / focus
6321
+ // / cursor survive the swap. The doc-level `[data-search-
6322
+ // input]` listener (~line 15486) calls `runSearch(value)` on
6323
+ // every input event; the clear button (~line 14577) routes
6324
+ // back through `runSearch("")`.
6201
6325
  const lastQuery = this._searchLastQuery || "";
6326
+ const startsInResults = lastQuery.length > 0;
6327
+ page.className = "search-page " + (startsInResults ? "has-results" : "is-initial");
6328
+ // Perplexity-style hero · calm conversational tagline above
6329
+ // a substantial card-style input with an internal footer
6330
+ // toolbar (mono hint left, lime send button right). Starter
6331
+ // chips below give one-click into common queries. The same
6332
+ // input element serves both states; `.is-initial` styles
6333
+ // make the wrapping `.search-card` look like a standalone
6334
+ // card, while `.has-results` strips the card chrome and
6335
+ // collapses input + meta into a compact head row.
6336
+ // 8-bit ambient deco · scattered pixel constellation that
6337
+ // gives the page texture without competing with the hero.
6338
+ // All coordinates picked by hand to avoid the "regular
6339
+ // grid" feel · varied densities + 3 lime accents land
6340
+ // the eye softly. CSS masks the bottom edge to a fade so
6341
+ // it never crowds the card. Static · no animation,
6342
+ // pointer-events: none, aria-hidden.
6343
+ const BG_DECO_SVG = `
6344
+ <svg viewBox="0 0 800 280" preserveAspectRatio="xMidYMin slice"
6345
+ shape-rendering="crispEdges" aria-hidden="true">
6346
+ <!-- Faint pixel dots · scattered, mostly 2×2. -->
6347
+ <g fill="var(--line-bright, #2A2A26)">
6348
+ <rect x="32" y="38" width="2" height="2"/>
6349
+ <rect x="78" y="22" width="2" height="2"/>
6350
+ <rect x="118" y="62" width="2" height="2"/>
6351
+ <rect x="156" y="30" width="2" height="2"/>
6352
+ <rect x="206" y="78" width="2" height="2"/>
6353
+ <rect x="254" y="44" width="2" height="2"/>
6354
+ <rect x="298" y="92" width="2" height="2"/>
6355
+ <rect x="342" y="26" width="2" height="2"/>
6356
+ <rect x="388" y="68" width="2" height="2"/>
6357
+ <rect x="436" y="38" width="2" height="2"/>
6358
+ <rect x="486" y="86" width="2" height="2"/>
6359
+ <rect x="528" y="50" width="2" height="2"/>
6360
+ <rect x="572" y="22" width="2" height="2"/>
6361
+ <rect x="618" y="74" width="2" height="2"/>
6362
+ <rect x="664" y="42" width="2" height="2"/>
6363
+ <rect x="708" y="88" width="2" height="2"/>
6364
+ <rect x="752" y="32" width="2" height="2"/>
6365
+ <rect x="60" y="118" width="2" height="2"/>
6366
+ <rect x="146" y="142" width="2" height="2"/>
6367
+ <rect x="222" y="116" width="2" height="2"/>
6368
+ <rect x="316" y="148" width="2" height="2"/>
6369
+ <rect x="402" y="124" width="2" height="2"/>
6370
+ <rect x="488" y="158" width="2" height="2"/>
6371
+ <rect x="572" y="118" width="2" height="2"/>
6372
+ <rect x="654" y="146" width="2" height="2"/>
6373
+ <rect x="734" y="124" width="2" height="2"/>
6374
+ <rect x="92" y="180" width="2" height="2"/>
6375
+ <rect x="186" y="206" width="2" height="2"/>
6376
+ <rect x="278" y="186" width="2" height="2"/>
6377
+ <rect x="370" y="216" width="2" height="2"/>
6378
+ <rect x="462" y="194" width="2" height="2"/>
6379
+ <rect x="554" y="222" width="2" height="2"/>
6380
+ <rect x="646" y="198" width="2" height="2"/>
6381
+ <rect x="724" y="226" width="2" height="2"/>
6382
+ </g>
6383
+ <!-- Mid accents · 4×4 squares, lime-dim. -->
6384
+ <g fill="var(--lime-dim, #2D5532)">
6385
+ <rect x="226" y="46" width="4" height="4"/>
6386
+ <rect x="514" y="100" width="4" height="4"/>
6387
+ <rect x="694" y="170" width="4" height="4"/>
6388
+ </g>
6389
+ <!-- Pixel "plus" accents · evoke the 8-bit
6390
+ star/sparkle vocabulary. Lime-dim. -->
6391
+ <g fill="var(--lime-dim, #2D5532)">
6392
+ <!-- top-left plus -->
6393
+ <rect x="98" y="78" width="6" height="2"/>
6394
+ <rect x="100" y="76" width="2" height="6"/>
6395
+ <!-- mid-right plus -->
6396
+ <rect x="640" y="62" width="6" height="2"/>
6397
+ <rect x="642" y="60" width="2" height="6"/>
6398
+ <!-- lower-left plus -->
6399
+ <rect x="354" y="178" width="6" height="2"/>
6400
+ <rect x="356" y="176" width="2" height="6"/>
6401
+ </g>
6402
+ <!-- Bright accents · sparse, lime. Catch-the-eye
6403
+ points scattered at the visual rule-of-thirds. -->
6404
+ <g fill="var(--lime, #6FB572)">
6405
+ <rect x="170" y="98" width="3" height="3"/>
6406
+ <rect x="540" y="38" width="3" height="3"/>
6407
+ <rect x="416" y="170" width="3" height="3"/>
6408
+ </g>
6409
+ <!-- Pixel "frame" segments · short hairline brackets at
6410
+ the very top corners · evoke a CRT scanlines feel
6411
+ without a full grid. -->
6412
+ <g fill="var(--line-bright, #2A2A26)">
6413
+ <rect x="14" y="14" width="20" height="2"/>
6414
+ <rect x="14" y="14" width="2" height="20"/>
6415
+ <rect x="766" y="14" width="20" height="2"/>
6416
+ <rect x="784" y="14" width="2" height="20"/>
6417
+ </g>
6418
+ </svg>
6419
+ `;
6420
+ // Results-only deco · second 8-bit layer that mounts on
6421
+ // top of the constellation when .has-results is active.
6422
+ // Adds proper "search station" character: pixel antennas
6423
+ // anchoring the corners, scattered "+" sparkles between
6424
+ // them, denser dot field, and a horizon-tick floor at
6425
+ // the bottom of the band. CSS scanlines (declared in
6426
+ // index.html) sit underneath via background-image.
6427
+ const RESULTS_DECO_SVG = `
6428
+ <svg viewBox="0 0 800 130" preserveAspectRatio="xMidYMin slice"
6429
+ shape-rendering="crispEdges" aria-hidden="true">
6430
+ <!-- Left antenna · vertical mast + 2 crossbars +
6431
+ base tile. Reads as a pixel radio tower. -->
6432
+ <g fill="var(--line-bright, #2A2A26)">
6433
+ <rect x="22" y="40" width="2" height="74"/>
6434
+ <rect x="18" y="56" width="10" height="2"/>
6435
+ <rect x="20" y="74" width="6" height="2"/>
6436
+ <rect x="14" y="114" width="18" height="3"/>
6437
+ </g>
6438
+ <!-- Left antenna blink tip · static lime accent. -->
6439
+ <rect x="22" y="36" width="2" height="2" fill="var(--lime, #6FB572)"/>
6440
+ <!-- Right antenna · mirror of the left. -->
6441
+ <g fill="var(--line-bright, #2A2A26)">
6442
+ <rect x="776" y="44" width="2" height="70"/>
6443
+ <rect x="772" y="60" width="10" height="2"/>
6444
+ <rect x="774" y="78" width="6" height="2"/>
6445
+ <rect x="768" y="114" width="18" height="3"/>
6446
+ </g>
6447
+ <rect x="776" y="40" width="2" height="2" fill="var(--lime, #6FB572)"/>
6448
+ <!-- Pixel "+" sparkles · scattered in the central
6449
+ band between the antennas. Reads as scanner
6450
+ pings. -->
6451
+ <g fill="var(--lime-dim, #2D5532)">
6452
+ <rect x="138" y="44" width="6" height="2"/>
6453
+ <rect x="140" y="42" width="2" height="6"/>
6454
+ <rect x="324" y="68" width="6" height="2"/>
6455
+ <rect x="326" y="66" width="2" height="6"/>
6456
+ <rect x="498" y="34" width="6" height="2"/>
6457
+ <rect x="500" y="32" width="2" height="6"/>
6458
+ <rect x="612" y="86" width="6" height="2"/>
6459
+ <rect x="614" y="84" width="2" height="6"/>
6460
+ <rect x="220" y="92" width="6" height="2"/>
6461
+ <rect x="222" y="90" width="2" height="6"/>
6462
+ <rect x="700" y="50" width="6" height="2"/>
6463
+ <rect x="702" y="48" width="2" height="6"/>
6464
+ </g>
6465
+ <!-- Dense pixel-dot field · layered on top of the
6466
+ existing constellation to thicken the header. -->
6467
+ <g fill="var(--line-bright, #2A2A26)">
6468
+ <rect x="62" y="28" width="2" height="2"/>
6469
+ <rect x="106" y="78" width="2" height="2"/>
6470
+ <rect x="170" y="34" width="2" height="2"/>
6471
+ <rect x="248" y="56" width="2" height="2"/>
6472
+ <rect x="288" y="22" width="2" height="2"/>
6473
+ <rect x="370" y="50" width="2" height="2"/>
6474
+ <rect x="412" y="92" width="2" height="2"/>
6475
+ <rect x="468" y="68" width="2" height="2"/>
6476
+ <rect x="544" y="84" width="2" height="2"/>
6477
+ <rect x="588" y="44" width="2" height="2"/>
6478
+ <rect x="668" y="76" width="2" height="2"/>
6479
+ <rect x="722" y="32" width="2" height="2"/>
6480
+ <rect x="84" y="98" width="2" height="2"/>
6481
+ <rect x="262" y="100" width="2" height="2"/>
6482
+ </g>
6483
+ <!-- Bright lime accent dots · 2 only, at rule-of-
6484
+ thirds positions. Catches the eye. -->
6485
+ <g fill="var(--lime, #6FB572)">
6486
+ <rect x="266" y="40" width="3" height="3"/>
6487
+ <rect x="566" y="74" width="3" height="3"/>
6488
+ </g>
6489
+ <!-- Horizon ticks · faint dashed pixel line near
6490
+ the bottom edge of the band. Acts as visual
6491
+ "floor" the antennas plant on. -->
6492
+ <g fill="var(--line, #1A1A18)">
6493
+ <rect x="40" y="122" width="6" height="1"/>
6494
+ <rect x="60" y="122" width="2" height="1"/>
6495
+ <rect x="76" y="122" width="6" height="1"/>
6496
+ <rect x="96" y="122" width="2" height="1"/>
6497
+ <rect x="112" y="122" width="6" height="1"/>
6498
+ <rect x="132" y="122" width="2" height="1"/>
6499
+ <rect x="148" y="122" width="6" height="1"/>
6500
+ <rect x="168" y="122" width="2" height="1"/>
6501
+ <rect x="184" y="122" width="6" height="1"/>
6502
+ <rect x="204" y="122" width="2" height="1"/>
6503
+ <rect x="220" y="122" width="6" height="1"/>
6504
+ <rect x="240" y="122" width="2" height="1"/>
6505
+ <rect x="256" y="122" width="6" height="1"/>
6506
+ <rect x="276" y="122" width="2" height="1"/>
6507
+ <rect x="292" y="122" width="6" height="1"/>
6508
+ <rect x="312" y="122" width="2" height="1"/>
6509
+ <rect x="328" y="122" width="6" height="1"/>
6510
+ <rect x="348" y="122" width="2" height="1"/>
6511
+ <rect x="364" y="122" width="6" height="1"/>
6512
+ <rect x="384" y="122" width="2" height="1"/>
6513
+ <rect x="400" y="122" width="6" height="1"/>
6514
+ <rect x="420" y="122" width="2" height="1"/>
6515
+ <rect x="436" y="122" width="6" height="1"/>
6516
+ <rect x="456" y="122" width="2" height="1"/>
6517
+ <rect x="472" y="122" width="6" height="1"/>
6518
+ <rect x="492" y="122" width="2" height="1"/>
6519
+ <rect x="508" y="122" width="6" height="1"/>
6520
+ <rect x="528" y="122" width="2" height="1"/>
6521
+ <rect x="544" y="122" width="6" height="1"/>
6522
+ <rect x="564" y="122" width="2" height="1"/>
6523
+ <rect x="580" y="122" width="6" height="1"/>
6524
+ <rect x="600" y="122" width="2" height="1"/>
6525
+ <rect x="616" y="122" width="6" height="1"/>
6526
+ <rect x="636" y="122" width="2" height="1"/>
6527
+ <rect x="652" y="122" width="6" height="1"/>
6528
+ <rect x="672" y="122" width="2" height="1"/>
6529
+ <rect x="688" y="122" width="6" height="1"/>
6530
+ <rect x="708" y="122" width="2" height="1"/>
6531
+ <rect x="724" y="122" width="6" height="1"/>
6532
+ <rect x="744" y="122" width="2" height="1"/>
6533
+ <rect x="760" y="122" width="6" height="1"/>
6534
+ </g>
6535
+ </svg>
6536
+ `;
6202
6537
  page.innerHTML = `
6203
- <div class="search-page-head">
6204
- <div>
6205
- <div class="search-page-kicker">// search · across every room</div>
6206
- <h1 class="search-page-title">Search</h1>
6207
- </div>
6538
+ <!-- 8-bit ambient deco · top-of-page background overlay
6539
+ for the is-initial state. Stays visible (dimmer)
6540
+ in has-results as the base atmosphere layer. -->
6541
+ <div class="search-bg-deco">${BG_DECO_SVG}</div>
6542
+ <!-- Results-only deco · second layer with antennas +
6543
+ sparkles + horizon ticks + CSS scanlines (in CSS).
6544
+ Hidden in is-initial via opacity. -->
6545
+ <div class="search-results-deco">${RESULTS_DECO_SVG}</div>
6546
+ <!-- Hero · longer wordmark + mono subline. Only visible
6547
+ in .is-initial · faded + collapsed via CSS once
6548
+ .has-results lands. -->
6549
+ <div class="search-hero">
6550
+ <h1 class="search-hero-title">Search every conversation</h1>
6551
+ <p class="search-hero-sub">across every room · keyword · message body · room name</p>
6208
6552
  </div>
6209
- <div class="search-input-wrap">
6210
- <span class="search-input-icon" aria-hidden="true">
6211
- <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
6212
- <circle cx="7" cy="7" r="4.5"/>
6213
- <line x1="10.3" y1="10.3" x2="13.5" y2="13.5"/>
6214
- </svg>
6215
- </span>
6216
- <input type="text" class="search-input" data-search-input
6217
- placeholder="Find any keyword across rooms · room name, director name, message body…"
6218
- value="${this.escape(lastQuery)}"
6219
- spellcheck="false">
6220
- <button type="button" class="search-input-clear" data-search-clear aria-label="Clear" title="Clear">✕</button>
6553
+ <!-- Card · is-initial = standalone framed input with
6554
+ internal toolbar; has-results = flex row with the
6555
+ meta beside it, toolbar hidden. -->
6556
+ <div class="search-card${lastQuery ? "" : " is-empty"}" data-search-card>
6557
+ <div class="search-input-wrap">
6558
+ <span class="search-input-icon" aria-hidden="true">
6559
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
6560
+ <circle cx="7" cy="7" r="4.5"/>
6561
+ <line x1="10.3" y1="10.3" x2="13.5" y2="13.5"/>
6562
+ </svg>
6563
+ </span>
6564
+ <input type="text" class="search-input" data-search-input
6565
+ placeholder="Find a keyword, decision, or name across rooms"
6566
+ value="${this.escape(lastQuery)}"
6567
+ spellcheck="false"
6568
+ autocomplete="off">
6569
+ <button type="button" class="search-input-clear" data-search-clear aria-label="Clear" title="Clear">✕</button>
6570
+ </div>
6571
+ <!-- Sort filter · only visible in .has-results. Toggle
6572
+ between newest-first (default) and oldest-first.
6573
+ Click handler in the doc-level delegate updates
6574
+ app._searchSort and re-renders from the cached
6575
+ result list (no re-fetch). -->
6576
+ <div class="search-results-sort" data-search-sort>
6577
+ <span class="srs-label">sort</span>
6578
+ <button type="button" data-search-sort-by="newest" class="active">Newest</button>
6579
+ <button type="button" data-search-sort-by="oldest">Oldest</button>
6580
+ </div>
6581
+ <span class="search-results-meta" data-search-results-meta></span>
6582
+ <div class="search-input-toolbar">
6583
+ <span class="search-toolbar-hint">keyword · message body · room name</span>
6584
+ </div>
6221
6585
  </div>
6222
- <div class="search-results" data-search-results>
6223
- ${lastQuery
6224
- ? `<div class="search-empty"><span class="search-empty-kicker">// queued</span><div class="search-empty-msg">Searching…</div></div>`
6225
- : `<div class="search-empty"><span class="search-empty-kicker">// hint</span><div class="search-empty-msg">Type a keyword above. Searches every message in every room · the result row jumps straight to the match.</div></div>`}
6586
+ <!-- Static starter chips · one click pre-fills the input
6587
+ and triggers the search. Hidden once the user types
6588
+ anything (.has-results). System UI English-only. -->
6589
+ <div class="search-starters" data-search-starters>
6590
+ <span class="search-starters-label">try</span>
6591
+ <button type="button" class="search-starter" data-search-starter="decision">decision</button>
6592
+ <button type="button" class="search-starter" data-search-starter="next step">next step</button>
6593
+ <button type="button" class="search-starter" data-search-starter="risk">risk</button>
6594
+ <button type="button" class="search-starter" data-search-starter="ship">ship</button>
6226
6595
  </div>
6596
+ <div class="search-results" data-search-results></div>
6227
6597
  `;
6598
+ // Initialise the sort default · defaults to "newest"
6599
+ // (most recent matches first). The chip group reads from
6600
+ // this on every render to set the active state.
6601
+ if (this._searchSort !== "oldest") this._searchSort = "newest";
6602
+ this._refreshSortChips();
6228
6603
  const inputEl = page.querySelector("[data-search-input]");
6229
6604
  if (inputEl) {
6230
6605
  // Defer focus so the layout settles + view is visible.
@@ -6237,6 +6612,18 @@
6237
6612
  }
6238
6613
  },
6239
6614
 
6615
+ /** Flip the page-level state class. `is-initial` mounts the
6616
+ * vertical-centre hero; `has-results` collapses the hero and
6617
+ * shrinks the input into a head row beside the meta. Called
6618
+ * from runSearch whenever the query crosses the empty
6619
+ * threshold (and on cold mount in renderSearchPage). */
6620
+ _setSearchPageState(state) {
6621
+ const page = document.querySelector("[data-search-page]");
6622
+ if (!page) return;
6623
+ page.classList.toggle("is-initial", state === "initial");
6624
+ page.classList.toggle("has-results", state !== "initial");
6625
+ },
6626
+
6240
6627
  /** Debounced search · 200ms after last keystroke we hit
6241
6628
  * /api/search and re-render the results panel. Re-entrancy is
6242
6629
  * guarded by a sequence number so a slow earlier request
@@ -6250,12 +6637,31 @@
6250
6637
  }
6251
6638
  const seq = (this._searchSeq = (this._searchSeq || 0) + 1);
6252
6639
  const target = document.querySelector("[data-search-results]");
6640
+ const metaEl = document.querySelector("[data-search-results-meta]");
6641
+ // Keep the card's empty-state class in sync with the live
6642
+ // query · the CSS class hides the trailing ✕ when the
6643
+ // input is genuinely empty so the hero's right edge stays
6644
+ // clean before the user has typed anything.
6645
+ const card = document.querySelector("[data-search-card]");
6646
+ if (card) card.classList.toggle("is-empty", q.length === 0);
6253
6647
  if (!target) return;
6254
6648
  if (q.length === 0) {
6255
- target.innerHTML = `<div class="search-empty"><span class="search-empty-kicker">// hint</span><div class="search-empty-msg">Type a keyword above. Searches every message in every room · the result row jumps straight to the match.</div></div>`;
6649
+ // Empty query · snap back to the hero (initial state),
6650
+ // clear the results list + meta. The CSS hides both via
6651
+ // `.is-initial`; we still wipe innerHTML so a later
6652
+ // .has-results flip doesn't briefly show stale rows.
6653
+ this._setSearchPageState("initial");
6654
+ target.innerHTML = "";
6655
+ if (metaEl) metaEl.textContent = "";
6256
6656
  return;
6257
6657
  }
6658
+ // Non-empty query · flip to results state (CSS fades the
6659
+ // hero out, shrinks the input into the head row) and show
6660
+ // a small "searching…" placeholder while the fetch is in
6661
+ // flight. The meta line takes a beat to populate.
6662
+ this._setSearchPageState("results");
6258
6663
  target.innerHTML = `<div class="search-empty"><span class="search-empty-kicker">// searching</span><div class="search-empty-msg">…</div></div>`;
6664
+ if (metaEl) metaEl.textContent = "";
6259
6665
  this._searchDebounceTimer = setTimeout(async () => {
6260
6666
  try {
6261
6667
  const r = await fetch("/api/search?q=" + encodeURIComponent(q));
@@ -6270,34 +6676,74 @@
6270
6676
  }, 200);
6271
6677
  },
6272
6678
 
6273
- /** Render the search results list · flat ordered-by-recency list.
6274
- * Each row carries its own provenance line ("from <Room>") so
6275
- * the user can spot which room each hit belongs to without a
6276
- * separate per-room cluster header. The header pattern was
6277
- * retired because rooms with one match each were producing
6278
- * N tiny clusters; the in-row footer reads cleaner. */
6679
+ /** Render the search results list · flat ordered list with
6680
+ * Google-style 3-line rows (mono source breadcrumb sans
6681
+ * link title sans snippet body). Per-row chrome is defined
6682
+ * in `renderSearchResultRow`. The sort order ("newest" /
6683
+ * "oldest") is read from `app._searchSort` (default
6684
+ * "newest") and re-applied client-side by `_applySearchSort`
6685
+ * whenever the user clicks a sort chip — no re-fetch. */
6279
6686
  renderSearchResults(results, query) {
6280
6687
  const target = document.querySelector("[data-search-results]");
6688
+ const metaEl = document.querySelector("[data-search-results-meta]");
6281
6689
  if (!target) return;
6690
+ // Cache the raw response so the sort chip click can re-
6691
+ // render without hitting the server again. Also store the
6692
+ // query so the row builder knows what to highlight.
6693
+ this._searchLastResults = Array.isArray(results) ? results.slice() : [];
6694
+ this._searchLastQueryRendered = query || "";
6282
6695
  if (!results || results.length === 0) {
6696
+ if (metaEl) metaEl.textContent = `0 matches`;
6283
6697
  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>`;
6284
6698
  return;
6285
6699
  }
6286
- const roomSet = new Set(results.map((r) => r.roomId));
6287
- const rows = results.map((hit) => this.renderSearchResultRow(hit, query)).join("");
6288
- target.innerHTML = `
6289
- <div class="search-results-meta">${results.length} match${results.length === 1 ? "" : "es"} · ${roomSet.size} room${roomSet.size === 1 ? "" : "s"}</div>
6290
- <ul class="search-flat-list">${rows}</ul>
6291
- `;
6700
+ const sorted = this._applySearchSort(results);
6701
+ const roomSet = new Set(sorted.map((r) => r.roomId));
6702
+ if (metaEl) {
6703
+ metaEl.textContent =
6704
+ `${sorted.length} match${sorted.length === 1 ? "" : "es"} · ` +
6705
+ `${roomSet.size} room${roomSet.size === 1 ? "" : "s"}`;
6706
+ }
6707
+ const rows = sorted.map((hit) => this.renderSearchResultRow(hit, query)).join("");
6708
+ target.innerHTML = `<ul class="search-results-list">${rows}</ul>`;
6709
+ },
6710
+
6711
+ /** Sort a results array by createdAt according to the current
6712
+ * `app._searchSort` setting. Returns a NEW array so the
6713
+ * cached `_searchLastResults` stays in original order. */
6714
+ _applySearchSort(results) {
6715
+ const order = this._searchSort === "oldest" ? "oldest" : "newest";
6716
+ const sorted = (results || []).slice();
6717
+ sorted.sort((a, b) => {
6718
+ const aT = (a && a.createdAt) || 0;
6719
+ const bT = (b && b.createdAt) || 0;
6720
+ return order === "oldest" ? aT - bT : bT - aT;
6721
+ });
6722
+ return sorted;
6723
+ },
6724
+
6725
+ /** Sync the active state on the sort chips so the lit button
6726
+ * reflects `app._searchSort`. Called from the chip click
6727
+ * handler + on initial mount. */
6728
+ _refreshSortChips() {
6729
+ const order = this._searchSort === "oldest" ? "oldest" : "newest";
6730
+ document.querySelectorAll("[data-search-sort-by]").forEach((btn) => {
6731
+ btn.classList.toggle("active", btn.getAttribute("data-search-sort-by") === order);
6732
+ });
6292
6733
  },
6293
6734
 
6294
- /** Build one result row · 2-line snippet centered on the match
6295
- * + a "from <Room>" provenance line beneath. Avatar / author /
6296
- * timestamp removed in favour of a denser list — the click
6297
- * destination has all that context. Wider snippet window
6298
- * (~110 lead, ~180 trail) so two lines of 14px text aren't
6299
- * half-empty. The hash carries `?q=...` so the room view can
6300
- * highlight the matched word in place. */
6735
+ /** Build one Google-style result row · three stacked text
6736
+ * lines:
6737
+ * 1. Source breadcrumb (mono caption, faint) · author name
6738
+ * + time-ago the "URL row" equivalent.
6739
+ * 2. Title (sans link, lime on hover) · the room subject
6740
+ * (truncated). Clicking jumps to `#/r/<id>?m=<mid>&q=
6741
+ * <q>` exactly like the previous flat-list row.
6742
+ * 3. Snippet (sans body, soft tone) · 2-line clamp of the
6743
+ * message context with `<mark>` highlight on the
6744
+ * matched keyword.
6745
+ * Snippet window (~110 lead, ~180 trail) is unchanged from
6746
+ * the prior implementation — the change is purely visual. */
6301
6747
  renderSearchResultRow(hit, query) {
6302
6748
  const body = hit.body || "";
6303
6749
  const offset = Math.max(0, hit.matchOffset || 0);
@@ -6316,19 +6762,28 @@
6316
6762
  `<mark>${this.escape(flat(matched))}</mark>` +
6317
6763
  this.escape(flat(after) + suffix);
6318
6764
  const roomTitle = (hit.roomTitle || "Untitled room").trim() || "Untitled room";
6765
+ const author = (hit.authorName || "").trim() || "Director";
6766
+ const timeAgo = hit.createdAt ? this.relTime(hit.createdAt) : "";
6319
6767
  const qParam = query ? `&q=${encodeURIComponent(query)}` : "";
6768
+ // Source breadcrumb · author + relative time. No "from"
6769
+ // label · room title is now the prominent title line (where
6770
+ // the user navigates to), so the breadcrumb only needs to
6771
+ // identify the speaker + when.
6772
+ const sourceLine =
6773
+ `<span class="sr-source-author">${this.escape(author)}</span>` +
6774
+ (timeAgo
6775
+ ? `<span class="sr-source-sep">·</span><span class="sr-source-time">${this.escape(timeAgo)}</span>`
6776
+ : "");
6320
6777
  return `
6321
6778
  <li>
6322
6779
  <a href="#/r/${this.escape(hit.roomId)}?m=${this.escape(hit.messageId)}${qParam}"
6323
- class="search-row"
6780
+ class="sr-row"
6324
6781
  data-search-jump-room="${this.escape(hit.roomId)}"
6325
6782
  data-search-jump-msg="${this.escape(hit.messageId)}"
6326
6783
  data-search-jump-q="${this.escape(query || "")}">
6327
- <div class="search-row-snippet">${snippet}</div>
6328
- <div class="search-row-source">
6329
- <span class="search-row-source-label">from</span>
6330
- <span class="search-row-source-room">${this.escape(roomTitle)}</span>
6331
- </div>
6784
+ <div class="sr-source">${sourceLine}</div>
6785
+ <div class="sr-title">${this.escape(roomTitle)}</div>
6786
+ <div class="sr-snippet">${snippet}</div>
6332
6787
  </a>
6333
6788
  </li>
6334
6789
  `;
@@ -7177,6 +7632,180 @@
7177
7632
  * tune live as a slim toolbar inside its bottom edge so the page
7178
7633
  * has one clear gravitational centre, not three competing form
7179
7634
  * fields. */
7635
+ /** 8-bit ambient backdrop · same vocabulary as the Search page's
7636
+ * `.search-bg-deco` (crispEdges pixel motifs, lime accents) but
7637
+ * scene-tuned for each composer:
7638
+ * · room → mini boardroom (pixel table + 4 chair silhouettes)
7639
+ * · agent → row of pixel character heads w/ speech bubbles
7640
+ * Constellation dots + corner brackets are shared. Returns plain
7641
+ * SVG markup ready to drop into `.cmp-bg-deco`. */
7642
+ composerBgDecoSvg(scene) {
7643
+ // Helper · stagger animation-delays so siblings don't pulse in
7644
+ // lockstep. Returns a string like `style="animation-delay: 1.2s"`.
7645
+ // The pseudo-random offset is index-based so it's deterministic
7646
+ // (no flicker between renders).
7647
+ const _delay = (i, base) => `style="animation-delay: ${(((i * 137) % 100) / 100 * base).toFixed(2)}s"`;
7648
+ // Constellation dots + lime accents + corner brackets · shared
7649
+ // ambient "8-bit sky" the user singled out as the visual goal.
7650
+ // Each dot animates with `deco-twinkle` keyframes for a slow
7651
+ // opacity blink, staggered by index so the field shimmers
7652
+ // organically instead of pulsing as a single beat.
7653
+ const scatterDots = [
7654
+ [32, 38], [78, 22], [118, 62], [156, 30], [206, 78], [254, 44],
7655
+ [298, 92], [342, 26], [388, 68], [436, 38], [486, 86], [528, 50],
7656
+ [572, 22], [618, 74], [664, 42], [708, 88], [752, 32], [60, 200],
7657
+ [186, 216], [278, 196], [554, 222], [646, 198], [734, 226],
7658
+ ];
7659
+ const limeAccents = [
7660
+ // Dropped the centre-lower lime dot (416, 170) — it sat
7661
+ // directly behind the H1 prompt and read as a typo.
7662
+ [170, 98], [540, 38],
7663
+ ];
7664
+ const scatter = `
7665
+ <g fill="var(--line-bright, #2A2A26)">
7666
+ ${scatterDots.map(([x, y], i) =>
7667
+ `<rect class="deco-twinkle" ${_delay(i, 4.5)} x="${x}" y="${y}" width="2" height="2"/>`
7668
+ ).join("")}
7669
+ </g>
7670
+ <g fill="var(--lime, #6FB572)">
7671
+ ${limeAccents.map(([x, y], i) =>
7672
+ `<rect class="deco-shine" ${_delay(i + 7, 2.8)} x="${x}" y="${y}" width="3" height="3"/>`
7673
+ ).join("")}
7674
+ </g>
7675
+ <g fill="var(--line-bright, #2A2A26)">
7676
+ <rect x="14" y="14" width="20" height="2"/>
7677
+ <rect x="14" y="14" width="2" height="20"/>
7678
+ <rect x="766" y="14" width="20" height="2"/>
7679
+ <rect x="784" y="14" width="2" height="20"/>
7680
+ </g>
7681
+ `;
7682
+ // Scene-specific MINI motifs · small scattered 8-bit glyphs that
7683
+ // theme the constellation without occupying the centre stage.
7684
+ // Replaces the previous big centred tableau (table + chairs /
7685
+ // row of character heads) — the user wanted ambient dots /
7686
+ // sparkles tinted with each composer's flavour, not a literal
7687
+ // scene in the hero band. Each motif is ≤ 14×14 px so the band
7688
+ // still reads as scatter, not a feature illustration.
7689
+ let motif = "";
7690
+ if (scene === "room") {
7691
+ // Room scene · tiny pixel chairs + mics + plus-sparkles + a
7692
+ // few pixel "speech-mark" pairs (boardroom vocabulary). All
7693
+ // in the warm wood / cyan moderator palette so the scatter
7694
+ // tints toward "meeting" without ever forming a tableau.
7695
+ // Each group carries an animation class · `deco-bob` for
7696
+ // chairs, `deco-spark` for "+" glyphs, `deco-twinkle` for
7697
+ // quote dots. Inline animation-delays stagger across siblings
7698
+ // so the field never pulses in lockstep.
7699
+ // Center-lower zone (roughly x ∈ [280, 520], y > 140) is
7700
+ // where the H1 "What's on your mind today?" prompt lands.
7701
+ // Cleared of motif elements so the chairs / mics / sparks /
7702
+ // quote dots never collide with the title text · the deco
7703
+ // stays visible only at the periphery + along the top band.
7704
+ // Same hygiene pass as the agent composer's motif.
7705
+ const chairs = [
7706
+ { x: 108, y: 138, fill: "#7A5230" },
7707
+ { x: 372, y: 46, fill: "#7A5230" },
7708
+ { x: 678, y: 148, fill: "#7A5230" },
7709
+ ];
7710
+ const sparks = [
7711
+ [98, 78], [640, 62], [588, 156],
7712
+ ];
7713
+ const quotes = [
7714
+ [148, 110], [152, 110], [716, 98], [720, 98],
7715
+ ];
7716
+ motif = `
7717
+ <g shape-rendering="crispEdges">
7718
+ <!-- Mini chair silhouettes (3 wood + 1 cyan moderator) -->
7719
+ ${chairs.map((c, i) => `
7720
+ <g class="deco-bob" ${_delay(i + 3, 3.2)} fill="${c.fill}">
7721
+ <rect x="${c.x}" y="${c.y + 10}" width="6" height="2"/>
7722
+ <rect x="${c.x}" y="${c.y}" width="2" height="10"/>
7723
+ <rect x="${c.x + 6}" y="${c.y}" width="2" height="10"/>
7724
+ </g>
7725
+ `).join("")}
7726
+ <!-- Tiny microphones · static (small, would feel busy).
7727
+ Removed the (296, 206) centre-lower mic and the
7728
+ (244, 216) cyan moderator chair — both sat in the
7729
+ title's footprint and read as typos behind the
7730
+ prompt. -->
7731
+ <g fill="#8E8B83">
7732
+ <rect x="226" y="46" width="3" height="2"/>
7733
+ <rect x="227" y="42" width="1" height="4"/>
7734
+ <rect x="514" y="100" width="3" height="2"/>
7735
+ <rect x="515" y="96" width="1" height="4"/>
7736
+ </g>
7737
+ <!-- Wood-tone "+" sparkles · scale-pulse animation -->
7738
+ ${sparks.map(([x, y], i) => `
7739
+ <g class="deco-spark" ${_delay(i + 11, 2.4)} fill="var(--amber-dim, #5C3A1F)">
7740
+ <rect x="${x}" y="${y + 2}" width="6" height="2"/>
7741
+ <rect x="${x + 2}" y="${y}" width="2" height="6"/>
7742
+ </g>
7743
+ `).join("")}
7744
+ <!-- Pixel "quote marks" · 2-dot pairs, twinkle in sync -->
7745
+ ${quotes.map(([x, y], i) =>
7746
+ `<rect class="deco-twinkle" ${_delay(i + 17, 3.5)} x="${x}" y="${y}" width="2" height="2" fill="var(--lime-dim, #2D5532)"/>`
7747
+ ).join("")}
7748
+ </g>
7749
+ `;
7750
+ } else if (scene === "agent") {
7751
+ // Agent scene · tiny pixel character heads (4×4) + mini
7752
+ // speech bubbles (5×3) + lime sparkles · evokes "cast / new
7753
+ // persona" without ever forming a centre tableau. Heads are
7754
+ // small enough to read as constellation, not as portraits.
7755
+ // Heads bob, bubbles blink in / out, sparkles pulse.
7756
+ // Center-lower zone (roughly x ∈ [280, 520], y > 140) is
7757
+ // where the H1 "What do you want to build?" prompt lands.
7758
+ // Cleared of motif elements so the heads / bubbles / sparks
7759
+ // never collide with the title text · the deco stays
7760
+ // visible only at the periphery + along the top band.
7761
+ const heads = [
7762
+ [124, 48], [498, 64], [676, 178], [218, 200],
7763
+ ];
7764
+ const bubbles = [
7765
+ [170, 68], [362, 92], [552, 46], [612, 206],
7766
+ ];
7767
+ const ideaSparks = [
7768
+ [68, 118], [724, 138], [84, 186],
7769
+ ];
7770
+ motif = `
7771
+ <g shape-rendering="crispEdges">
7772
+ <!-- Mini character heads · 4×4 face + 1px hat band -->
7773
+ ${heads.map(([x, y], i) => `
7774
+ <g class="deco-bob" ${_delay(i + 21, 3.0)}>
7775
+ <rect x="${x}" y="${y}" width="4" height="4" fill="#D8A878"/>
7776
+ <rect x="${x}" y="${y}" width="4" height="1" fill="#5C3A1F"/>
7777
+ </g>
7778
+ `).join("")}
7779
+ <!-- Tiny speech bubbles · 10×6 pill with 1-pixel tail · blink -->
7780
+ ${bubbles.map(([x, y], i) => `
7781
+ <g class="deco-blink" ${_delay(i + 27, 4.0)}>
7782
+ <rect x="${x}" y="${y}" width="10" height="6" fill="var(--panel-3, #1A1A18)"/>
7783
+ <rect x="${x}" y="${y}" width="10" height="1" fill="var(--lime-dim, #2D5532)"/>
7784
+ <rect x="${x}" y="${y + 5}" width="10" height="1" fill="var(--lime-dim, #2D5532)"/>
7785
+ <rect x="${x}" y="${y}" width="1" height="6" fill="var(--lime-dim, #2D5532)"/>
7786
+ <rect x="${x + 9}" y="${y}" width="1" height="6" fill="var(--lime-dim, #2D5532)"/>
7787
+ <rect x="${x + 2}" y="${y + 6}" width="2" height="1" fill="var(--lime-dim, #2D5532)"/>
7788
+ </g>
7789
+ `).join("")}
7790
+ <!-- Idea sparkles · "+" glyphs in lime-dim · scale pulse -->
7791
+ ${ideaSparks.map(([x, y], i) => `
7792
+ <g class="deco-spark" ${_delay(i + 33, 2.6)} fill="var(--lime-dim, #2D5532)">
7793
+ <rect x="${x}" y="${y + 2}" width="6" height="2"/>
7794
+ <rect x="${x + 2}" y="${y}" width="2" height="6"/>
7795
+ </g>
7796
+ `).join("")}
7797
+ </g>
7798
+ `;
7799
+ }
7800
+ return `
7801
+ <svg viewBox="0 0 800 280" preserveAspectRatio="xMidYMin slice"
7802
+ shape-rendering="crispEdges" aria-hidden="true">
7803
+ ${scatter}
7804
+ ${motif}
7805
+ </svg>
7806
+ `;
7807
+ },
7808
+
7180
7809
  renderComposerHtml(state) {
7181
7810
  const userName = (this.prefs?.name || "you").trim() || "you";
7182
7811
  const lang = this.composerLanguage();
@@ -7266,6 +7895,7 @@
7266
7895
 
7267
7896
  return `
7268
7897
  <section class="cmp">
7898
+ <div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("room")}</div>
7269
7899
  <header class="cmp-hero">
7270
7900
  <div class="cmp-greet">${this.escape(t.greet)}</div>
7271
7901
  <h1 class="cmp-prompt">${this.escape(t.prompt)}</h1>
@@ -7531,6 +8161,20 @@
7531
8161
  return MODEL_LABELS[modelV] || modelV;
7532
8162
  },
7533
8163
 
8164
+ /** Friendly label for an agent's voice profile. Returns "" when
8165
+ * the agent has no voice set, so callers can omit a voice chip
8166
+ * entirely. Looks up the prefetched `voiceLabels` map (populated
8167
+ * by loadInitial) and falls back to the raw voiceId when the
8168
+ * prefetch missed (e.g. /api/voices failed, or the agent uses
8169
+ * a fresh cloned voice that wasn't in the snapshot). */
8170
+ voiceLabelFor(agent) {
8171
+ const v = agent && agent.voice;
8172
+ if (!v || !v.provider || !v.voiceId) return "";
8173
+ const key = `${v.provider}:${v.voiceId}`;
8174
+ const cached = this.voiceLabels && this.voiceLabels[key];
8175
+ return cached || v.voiceId;
8176
+ },
8177
+
7534
8178
  setAgentComposerModel(modelV) {
7535
8179
  if (!modelV) return;
7536
8180
  // Accept any modelV the registry knows about (MODEL_LABELS) so
@@ -7864,6 +8508,7 @@
7864
8508
  `).join("");
7865
8509
  return `
7866
8510
  <section class="cmp ag-cmp">
8511
+ <div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("agent")}</div>
7867
8512
  <header class="cmp-hero">
7868
8513
  <div class="cmp-greet">${this.escape(t.greet)}</div>
7869
8514
  <h1 class="cmp-prompt">${this.escape(t.prompt)}</h1>
@@ -11306,6 +11951,14 @@
11306
11951
  const isUser = m.authorKind === "user";
11307
11952
  const author = isUser ? null : this.agentsById[m.authorId];
11308
11953
  const isChair = !isUser && author?.roleKind === "moderator";
11954
+ // Excused-from-room marker · the director is in this room's
11955
+ // historicalMembers with a non-null removedAt. Past messages
11956
+ // keep their name + role tag (so the chat transcript still
11957
+ // makes sense) and get a small "// excused" pill in the
11958
+ // header so the reader knows this seat is gone.
11959
+ const excusedMember = (!isUser && !isChair && m.authorId)
11960
+ ? (this.currentHistoricalMembers || []).find((hm) => hm.id === m.authorId && hm.removedAt != null)
11961
+ : null;
11309
11962
  const metaKind = m.meta && typeof m.meta.kind === "string" ? m.meta.kind : null;
11310
11963
 
11311
11964
  // Convening · the chair's spoken introduction of the auto-
@@ -11706,6 +12359,7 @@
11706
12359
  <span class="msg-name">${this.escape(name)}</span>
11707
12360
  ${modelLabel ? `<span class="msg-model" title="${this.escape(this._t("msg_model_title", { label: modelLabel }))}">${this.escape(modelLabel)}</span>` : ""}
11708
12361
  <span class="msg-tag">${tag}</span>
12362
+ ${excusedMember ? `<span class="msg-excused" title="Excused from this room by the chair">// excused</span>` : ""}
11709
12363
  ${skillsBadge}
11710
12364
  ${webSearchBadge}
11711
12365
  ${ctxBadge}
@@ -12609,11 +13263,14 @@
12609
13263
  } else {
12610
13264
  avatar = `<img class="rt-avatar" src="${this.escape(m.avatarPath || "")}" alt="" aria-hidden="true">`;
12611
13265
  }
12612
- // Name plate · adds a small "董事长" title beneath the user's
12613
- // name so the user seat reads as the room owner / chairman.
12614
- // Directors and the chair render the plain single-line name.
13266
+ // Name plate · adds a small "Chairman / 董事长" title beneath
13267
+ // the user's name so the user seat reads as the room owner /
13268
+ // chairman. Pulled from i18n (`rt_user_title`) so the label
13269
+ // follows the active UI locale: defaults to "Chairman" in
13270
+ // English, "董事长" in Chinese. Directors and the chair
13271
+ // render the plain single-line name.
12615
13272
  const name = isUser
12616
- ? `<div class="rt-name">${this.escape(m.name || "")}<div class="rt-name-title">董事长</div></div>`
13273
+ ? `<div class="rt-name">${this.escape(m.name || "")}<div class="rt-name-title">${this.escape(this._t("rt_user_title"))}</div></div>`
12617
13274
  : `<div class="rt-name">${this.escape(m.name || "")}</div>`;
12618
13275
  const bubbleState = isSpeaking ? speakerState : null;
12619
13276
  // Bubble carries the speaker's NAME so the user always knows
@@ -14564,10 +15221,18 @@
14564
15221
  e.preventDefault();
14565
15222
  if (!app.currentRoomId) return;
14566
15223
  if (window.boardroomVoiceReplay && typeof window.boardroomVoiceReplay.open === "function") {
15224
+ // Pass historicalMembers (includes excused directors) so
15225
+ // past messages from a director the chair has excused still
15226
+ // resolve to the speaker's name + voice profile. Falls back
15227
+ // to active members for legacy contexts where the historical
15228
+ // list isn't populated yet.
15229
+ const replayMembers = Array.isArray(app.currentHistoricalMembers) && app.currentHistoricalMembers.length > 0
15230
+ ? app.currentHistoricalMembers.slice()
15231
+ : (Array.isArray(app.currentMembers) ? app.currentMembers.slice() : []);
14567
15232
  window.boardroomVoiceReplay.open({
14568
15233
  roomId: app.currentRoomId,
14569
15234
  messages: Array.isArray(app.currentMessages) ? app.currentMessages.slice() : [],
14570
- members: Array.isArray(app.currentMembers) ? app.currentMembers.slice() : [],
15235
+ members: replayMembers,
14571
15236
  chair: app.currentChair || null,
14572
15237
  });
14573
15238
  }
@@ -14584,6 +15249,45 @@
14584
15249
  }
14585
15250
  return;
14586
15251
  }
15252
+ // Search view · starter chip click. Pre-fills the input with
15253
+ // the chip's keyword and triggers a search. Dispatching an
15254
+ // input event ensures the existing doc-level input listener
15255
+ // (which calls runSearch) fires too, so the debounced fetch
15256
+ // path stays canonical.
15257
+ const starter = e.target.closest("[data-search-starter]");
15258
+ if (starter) {
15259
+ e.preventDefault();
15260
+ const term = starter.getAttribute("data-search-starter") || "";
15261
+ const input = document.querySelector("[data-search-input]");
15262
+ if (input && term) {
15263
+ input.value = term;
15264
+ input.focus();
15265
+ input.dispatchEvent(new Event("input", { bubbles: true }));
15266
+ }
15267
+ return;
15268
+ }
15269
+ // Search view · sort chip ("Newest" / "Oldest"). Updates the
15270
+ // sort key + re-renders the cached result list client-side.
15271
+ // No re-fetch · the API doesn't expose a sort param and the
15272
+ // dataset is small (≤ 200 hits per query) so an in-memory
15273
+ // sort is the right call.
15274
+ const sortChip = e.target.closest("[data-search-sort-by]");
15275
+ if (sortChip) {
15276
+ e.preventDefault();
15277
+ const next = sortChip.getAttribute("data-search-sort-by") === "oldest" ? "oldest" : "newest";
15278
+ if (app._searchSort === next) return; // already active
15279
+ app._searchSort = next;
15280
+ app._refreshSortChips();
15281
+ // Re-render from the cached results · skip the no-cache
15282
+ // path (search not yet run) since the chip group is hidden
15283
+ // in is-initial.
15284
+ const cached = Array.isArray(app._searchLastResults) ? app._searchLastResults : null;
15285
+ const cachedQuery = app._searchLastQueryRendered || app._searchLastQuery || "";
15286
+ if (cached && cached.length > 0) {
15287
+ app.renderSearchResults(cached, cachedQuery);
15288
+ }
15289
+ return;
15290
+ }
14587
15291
  // Search view · result row click. Anchor's href is
14588
15292
  // `#/r/<id>?m=<mid>&q=<query>`, which the hashchange route
14589
15293
  // handler picks up. We stash the pending message id + query