privateboard 0.1.38 → 0.1.41

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.
Files changed (78) hide show
  1. package/dist/boot.js +327 -50
  2. package/dist/boot.js.map +1 -1
  3. package/dist/cli.js +327 -50
  4. package/dist/cli.js.map +1 -1
  5. package/dist/server.js +201 -40
  6. package/dist/server.js.map +1 -1
  7. package/dist/version.d.ts +1 -1
  8. package/dist/version.js +1 -1
  9. package/dist/version.js.map +1 -1
  10. package/package.json +1 -1
  11. package/public/__avatar3d_test.html +156 -0
  12. package/public/agent-overlay.css +14 -6
  13. package/public/agent-overlay.js +6 -6
  14. package/public/agent-profile.css +9 -12
  15. package/public/agent-profile.js +91 -38
  16. package/public/app.js +471 -528
  17. package/public/avatar-3d-snap.js +205 -0
  18. package/public/avatar-3d.js +836 -0
  19. package/public/avatar-customizer.html +274 -0
  20. package/public/avatar3d-editor.css +240 -0
  21. package/public/avatar3d-editor.js +484 -0
  22. package/public/avatars/3d/chair.png +0 -0
  23. package/public/avatars/3d/first-principles.png +0 -0
  24. package/public/avatars/3d/historian.png +0 -0
  25. package/public/avatars/3d/long-horizon.png +0 -0
  26. package/public/avatars/3d/phenomenologist.png +0 -0
  27. package/public/avatars/3d/socrates.png +0 -0
  28. package/public/avatars/3d/user-empathy.png +0 -0
  29. package/public/avatars/3d/value-investor.png +0 -0
  30. package/public/core-avatars.js +86 -0
  31. package/public/home-3d-loader.js +15 -4
  32. package/public/home-3d-mock.js +25 -14
  33. package/public/home.html +78 -16
  34. package/public/i18n.js +8 -4
  35. package/public/icons/avatar_1779855104027.glb +0 -0
  36. package/public/icons/logo.png +0 -0
  37. package/public/icons/new-style.glb +0 -0
  38. package/public/icons/new-style2.glb +0 -0
  39. package/public/icons/new-style3.glb +0 -0
  40. package/public/icons/new-style4.glb +0 -0
  41. package/public/icons/new-style5.glb +0 -0
  42. package/public/icons/new-style6.glb +0 -0
  43. package/public/icons/office.glb +0 -0
  44. package/public/icons/stuff.glb +0 -0
  45. package/public/index.html +169 -141
  46. package/public/magazine.html +1 -1
  47. package/public/new-agent.js +46 -20
  48. package/public/newspaper.html +1 -1
  49. package/public/office-viewer.html +340 -0
  50. package/public/ppt.html +1 -1
  51. package/public/stuff-viewer.html +330 -0
  52. package/public/thread.css +16 -15
  53. package/public/user-settings.css +7 -31
  54. package/public/user-settings.js +75 -89
  55. package/public/vendor/BufferGeometryUtils.js +1434 -0
  56. package/public/vendor/DRACOLoader.js +739 -0
  57. package/public/vendor/GLTFLoader.js +4860 -0
  58. package/public/vendor/RoomEnvironment.js +185 -0
  59. package/public/vendor/SkeletonUtils.js +496 -0
  60. package/public/vendor/draco/draco_decoder.js +34 -0
  61. package/public/vendor/draco/draco_decoder.wasm +0 -0
  62. package/public/vendor/draco/draco_encoder.js +33 -0
  63. package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
  64. package/public/vendor/meshopt_decoder.module.js +196 -0
  65. package/public/voice-3d-banner.js +19 -7
  66. package/public/voice-3d.js +1407 -432
  67. package/public/voice-replay.js +21 -0
  68. package/public/avatar-skill.js +0 -629
  69. package/public/avatars/chair-blink.svg +0 -1
  70. package/public/avatars/chair.svg +0 -1
  71. package/public/avatars/first-principles.svg +0 -1
  72. package/public/avatars/historian.svg +0 -1
  73. package/public/avatars/long-horizon.svg +0 -1
  74. package/public/avatars/phenomenologist.svg +0 -1
  75. package/public/avatars/socrates.svg +0 -1
  76. package/public/avatars/user-empathy.svg +0 -1
  77. package/public/avatars/value-investor.svg +0 -1
  78. package/public/icons/folded-sidebar.png +0 -0
package/public/app.js CHANGED
@@ -566,6 +566,17 @@
566
566
  // currentRoomId). Stage repaint still bails out when no
567
567
  // room is open, since the stage DOM isn't mounted there.
568
568
  this.refreshMiniPlayer();
569
+ // The Record button is offered while a replay runs (adjourned
570
+ // rooms), so re-render the header when the replay open-state
571
+ // flips. Guarded so we don't rebuild the header on every
572
+ // per-message item transition — only on open ↔ close.
573
+ const replOpen = this._isReplayActiveForRoom(this.currentRoomId);
574
+ if (replOpen !== this._lastReplayHeaderState) {
575
+ this._lastReplayHeaderState = replOpen;
576
+ if (this.currentRoomId && typeof this.renderHeader === "function") {
577
+ this.renderHeader();
578
+ }
579
+ }
569
580
  if (!this.currentRoomId) return;
570
581
  this.renderRoundTable();
571
582
  });
@@ -7287,6 +7298,19 @@
7287
7298
  }
7288
7299
  },
7289
7300
 
7301
+ /** True when a voice replay is currently running for `roomId`.
7302
+ * Drives the Record button's visibility in adjourned rooms ·
7303
+ * recording the replay reconstructs a meeting video (3D stage
7304
+ * driven by the replay + the replay's TTS audio) the same way
7305
+ * a live room records. `getRoomId()` is null for legacy replays
7306
+ * that didn't pass a roomId · treat that as "matches". */
7307
+ _isReplayActiveForRoom(roomId) {
7308
+ const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
7309
+ if (!vr || typeof vr.isOpen !== "function" || !vr.isOpen()) return false;
7310
+ const rid = typeof vr.getRoomId === "function" ? vr.getRoomId() : null;
7311
+ return !rid || rid === roomId;
7312
+ },
7313
+
7290
7314
  /** Toggle recording for the current voice room. Idempotent · if
7291
7315
  * already recording, stops + downloads; otherwise starts. */
7292
7316
  async handleRecordToggle() {
@@ -7295,15 +7319,30 @@
7295
7319
  console.warn("[recorder] module not loaded");
7296
7320
  return;
7297
7321
  }
7322
+ const room = this.currentRoom;
7323
+ const isLive = !!(room && room.status === "live");
7298
7324
  if (rec.isRecording()) {
7299
- // Live recording · open the stop-choice modal instead of
7300
- // stopping immediately. The user picks whether to also end
7301
- // the live room (interrupt / wait for speaker / keep room).
7302
- this.openRecordingStopModal();
7325
+ if (isLive) {
7326
+ // Live recording · open the stop-choice modal instead of
7327
+ // stopping immediately. The user picks whether to also end
7328
+ // the live room (interrupt / wait for speaker / keep room).
7329
+ this.openRecordingStopModal();
7330
+ } else {
7331
+ // Replay / adjourned recording · there's no live room to
7332
+ // interrupt or pause, so skip the lifecycle modal and just
7333
+ // stop + download the clip.
7334
+ await this._stopRecordingAndToast();
7335
+ }
7303
7336
  return;
7304
7337
  }
7305
- const room = this.currentRoom;
7306
7338
  if (!room || room.deliveryMode !== "voice") return;
7339
+ // Replay / adjourned · the round-table stage is opt-in there
7340
+ // (hidden by default), but the recorder crops the window capture
7341
+ // to the stage's on-screen rect — so the stage MUST be visible
7342
+ // before start() locks the composite size. Force the stage view
7343
+ // and collapse the floating replay player so it doesn't bleed
7344
+ // into the captured region.
7345
+ if (!isLive) this._ensureStageVisibleForRecording();
7307
7346
  try {
7308
7347
  await rec.start(room.id, room.subject || room.title || "Meeting");
7309
7348
  // Recorder is now capturing chunks · play the cinematic
@@ -7318,6 +7357,44 @@
7318
7357
  }
7319
7358
  },
7320
7359
 
7360
+ /** Stop + download the active recording and surface the saved
7361
+ * toast. Used by the replay / adjourned stop path where there's
7362
+ * no room lifecycle to reconcile (cf. handleRecordingStopChoice
7363
+ * for the live-room flow). */
7364
+ async _stopRecordingAndToast() {
7365
+ let blob = null;
7366
+ try { blob = await window.BoardroomRecorder.stopAndDownload(); }
7367
+ catch (e) { console.error("[recorder] stopAndDownload failed", e); }
7368
+ if (blob && blob.size > 0) {
7369
+ try {
7370
+ this.showRoundTableToast({
7371
+ kind: "settings",
7372
+ glyph: "↓",
7373
+ htmlText: this.escape(this._t("rec_saved_toast")),
7374
+ lifetimeMs: 5200,
7375
+ });
7376
+ } catch (_) { /* toast best-effort */ }
7377
+ }
7378
+ },
7379
+
7380
+ /** Reveal the round-table stage (opt-in for adjourned rooms) so
7381
+ * the recorder has a non-empty region to crop, and collapse the
7382
+ * floating voice-replay player so its panel stays out of the
7383
+ * captured frame. */
7384
+ _ensureStageVisibleForRecording() {
7385
+ const roomId = this.currentRoomId;
7386
+ if (!roomId) return;
7387
+ try { localStorage.setItem("rt-view-" + roomId, "stage"); } catch (_) { /* noop */ }
7388
+ if (typeof this.applyRoundTableVisibility === "function") {
7389
+ this.applyRoundTableVisibility(roomId);
7390
+ }
7391
+ const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
7392
+ if (vr && typeof vr.isOpen === "function" && vr.isOpen()
7393
+ && typeof vr.collapse === "function") {
7394
+ try { vr.collapse(); } catch (_) { /* noop */ }
7395
+ }
7396
+ },
7397
+
7321
7398
  /** Movie-trailer countdown · 4 frames (Ready, 3, 2, 1) each
7322
7399
  * ~850 ms with a pop+fade animation. Mounts inside the stage
7323
7400
  * so the recording captures the overlay as an opening title
@@ -8827,29 +8904,42 @@
8827
8904
  meta = this._t("sidebar_host");
8828
8905
  }
8829
8906
 
8830
- // Avatar source-of-truth · prefs.avatarSeed (set by the
8831
- // preference overlay's "regenerate avatar" button). When a seed
8832
- // is present we render the AvatarSkill SVG so the sidebar foot
8833
- // matches what the user picked in settings; otherwise fall back
8834
- // to the initial-letter chip we shipped before AvatarSkill
8835
- // existed.
8907
+ // Avatar source-of-truth · prefs.avatarUrl (rendered 3D PNG
8908
+ // captured by the avatar customizer) wins. Otherwise we paint
8909
+ // the seed-derived 3D snap (Avatar3DSnap.generate) and fall
8910
+ // back to the initial-letter chip while the async render is
8911
+ // in flight / when WebGL isn't available.
8836
8912
  // Sync every avatar slot · the full sidebar foot AND the
8837
8913
  // collapsed mini-rail foot both carry [data-user-avatar], so
8838
8914
  // querySelectorAll keeps them identical regardless of which is
8839
8915
  // currently visible.
8916
+ const url = this.prefs?.avatarUrl;
8840
8917
  const seed = this.prefs?.avatarSeed;
8841
- const avHtml = (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function")
8842
- ? window.AvatarSkill.generate(seed)
8843
- : null;
8918
+ const snap = window.Avatar3DSnap;
8919
+ const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
8920
+ const imgSrc = url || cachedSnap || null;
8844
8921
  document.querySelectorAll("[data-user-avatar]").forEach((av) => {
8845
- if (avHtml) {
8922
+ if (imgSrc) {
8846
8923
  av.classList.add("has-pixel-av");
8847
- av.innerHTML = avHtml;
8924
+ av.innerHTML = `<img src="${imgSrc}" alt="" style="width:100%;height:100%;object-fit:cover;image-rendering:auto">`;
8848
8925
  } else {
8849
8926
  av.classList.remove("has-pixel-av");
8850
8927
  av.textContent = initial;
8851
8928
  }
8852
8929
  });
8930
+ if (!url && seed && !cachedSnap && snap && typeof snap.generate === "function") {
8931
+ snap.generate(seed).then((dataUrl) => {
8932
+ if (!dataUrl) return;
8933
+ // Only repaint if the same seed is still in play — guard
8934
+ // against fast-fire regenerate clicks landing stale renders.
8935
+ if (this.prefs?.avatarSeed !== seed) return;
8936
+ if (this.prefs?.avatarUrl) return;
8937
+ document.querySelectorAll("[data-user-avatar]").forEach((av) => {
8938
+ av.classList.add("has-pixel-av");
8939
+ av.innerHTML = `<img src="${dataUrl}" alt="" style="width:100%;height:100%;object-fit:cover;image-rendering:auto">`;
8940
+ });
8941
+ }).catch(() => { /* */ });
8942
+ }
8853
8943
  const nm = document.querySelector("[data-user-name]");
8854
8944
  if (nm) nm.textContent = name;
8855
8945
  const mt = document.querySelector("[data-user-meta]");
@@ -9538,6 +9628,12 @@
9538
9628
  if (chat) {
9539
9629
  if (this.composerMode === "agent") {
9540
9630
  chat.innerHTML = this.renderAgentComposerHtml();
9631
+ // Hire-a-known-mind portraits · upgrade the 2D placeholders
9632
+ // to deterministic 3D voxel renders. Fire-and-forget · the
9633
+ // hydrator lazy-loads three.js + avatar-3d.js on first use,
9634
+ // caches per-seed dataURLs, and re-skips already-rendered
9635
+ // cards. Safe against rapid re-renders (idempotent).
9636
+ try { void this._hydrateCelebrityAvatars3D(); } catch (_) { /* */ }
9541
9637
  // Focus the description textarea unless we're showing a preview.
9542
9638
  setTimeout(() => {
9543
9639
  const ta = chat.querySelector("[data-agent-composer-desc]");
@@ -12149,19 +12245,25 @@
12149
12245
  });
12150
12246
  },
12151
12247
 
12152
- /** Card HTML for one celebrity seed. Avatar comes from
12153
- * AvatarSkill (deterministic from seed.id), so the portrait
12154
- * is the same every render. Intro picks the en/zh field
12155
- * that matches the active locale; ja/es fall back to en. */
12248
+ /** Card HTML for one celebrity seed. Avatar tile mounts empty
12249
+ * (or with a cached 3D snap if one is hot) and gets painted by
12250
+ * the deterministic 3D voxel renderer asynchronously when WebGL
12251
+ * is available see `_hydrateCelebrityAvatars3D()`. The 3D
12252
+ * config is derived from `seed.id` via
12253
+ * `deriveDefaultAvatarConfig`, so the portrait is stable across
12254
+ * re-renders + matches the random face shipped with the persona
12255
+ * once it's hired. Intro picks the en/zh field that matches the
12256
+ * active locale; ja/es fall back to en. */
12156
12257
  celebrityCardHtml(seed) {
12157
12258
  const lang = this.composerLanguage();
12158
12259
  const intro = (seed.intro && (seed.intro[lang] || seed.intro.en || "")) || "";
12159
- const avatarUrl = (window.AvatarSkill && typeof window.AvatarSkill.generateDataUrl === "function")
12160
- ? window.AvatarSkill.generateDataUrl(seed.id)
12161
- : "";
12162
- const avatarHtml = avatarUrl
12163
- ? `<img class="cmp-celeb-img" src="${this.escape(avatarUrl)}" alt="${this.escape(seed.name)}">`
12164
- : `<span class="cmp-celeb-img-fallback" aria-hidden="true">·</span>`;
12260
+ // Prefer the cached 3D render if a previous open already
12261
+ // hydrated this seed (hot-path through the composer should NOT
12262
+ // flash an empty tile on a second open).
12263
+ const cached = (this._celebrityAvatar3dCache && this._celebrityAvatar3dCache.get(seed.id)) || null;
12264
+ const avatarHtml = cached
12265
+ ? `<img class="cmp-celeb-img" data-cmp-celeb-img="${this.escape(seed.id)}" src="${this.escape(cached)}" alt="${this.escape(seed.name)}">`
12266
+ : `<span class="cmp-celeb-img-fallback" data-cmp-celeb-img="${this.escape(seed.id)}" aria-hidden="true">·</span>`;
12165
12267
  return `
12166
12268
  <button type="button" class="cmp-celeb-card" data-celebrity-seed="${this.escape(seed.id)}" title="${this.escape(seed.name)}">
12167
12269
  <span class="cmp-celeb-av">${avatarHtml}</span>
@@ -12174,6 +12276,172 @@
12174
12276
  `;
12175
12277
  },
12176
12278
 
12279
+ /** Walk every `.cmp-celeb-card` currently mounted and render a
12280
+ * 3D voxel portrait into each one (writes the image dataURL
12281
+ * back to its `<img data-cmp-celeb-img>` src). Idempotent · the
12282
+ * per-seed cache means subsequent calls only render new seeds.
12283
+ * Lazy-loads three.js + avatar-3d.js on the first call so the
12284
+ * composer's first paint isn't blocked. Skips silently when
12285
+ * WebGL isn't available (the 2D fallback already rendered). */
12286
+ async _hydrateCelebrityAvatars3D() {
12287
+ if (!this._celebrityAvatar3dCache) this._celebrityAvatar3dCache = new Map();
12288
+ // Capability gate · skip on no-WebGL so the 2D fallback remains
12289
+ // the visible portrait. Cheap test, runs every call (the user
12290
+ // could plug in a WebGL-capable display between calls).
12291
+ let canWebGL = false;
12292
+ try {
12293
+ const c = document.createElement("canvas");
12294
+ canWebGL = !!(c.getContext("webgl2") || c.getContext("webgl"));
12295
+ } catch (_) { canWebGL = false; }
12296
+ if (!canWebGL) return;
12297
+ const cards = Array.from(document.querySelectorAll("img[data-cmp-celeb-img], span[data-cmp-celeb-img]"));
12298
+ if (cards.length === 0) return;
12299
+ const want = [];
12300
+ for (const el of cards) {
12301
+ const id = el.getAttribute("data-cmp-celeb-img");
12302
+ if (!id) continue;
12303
+ // Already 3D-rendered? Skip (cached value was injected by the
12304
+ // first paint). The check leans on the dataset marker rather
12305
+ // than reading the src so a re-render that fell back to 2D
12306
+ // gets reprocessed.
12307
+ if (el.tagName === "IMG" && el.dataset.cmpCelebRendered === "1") continue;
12308
+ want.push({ id, el });
12309
+ }
12310
+ if (want.length === 0) return;
12311
+ // Cache hot · just paint and bail without loading three.
12312
+ const hot = want.filter((j) => this._celebrityAvatar3dCache.has(j.id));
12313
+ for (const j of hot) {
12314
+ const url = this._celebrityAvatar3dCache.get(j.id);
12315
+ this._paintCelebrityImg(j.el, url);
12316
+ }
12317
+ const cold = want.filter((j) => !this._celebrityAvatar3dCache.has(j.id));
12318
+ if (cold.length === 0) return;
12319
+
12320
+ // Lazy-load three + avatar-3d once per session. The two imports
12321
+ // race in parallel and resolve together.
12322
+ let THREE, av;
12323
+ try {
12324
+ [THREE, av] = await Promise.all([
12325
+ import("/vendor/three.module.min.js"),
12326
+ import("/avatar-3d.js"),
12327
+ ]);
12328
+ } catch (e) {
12329
+ try { console.warn("[celebrity-3d] dep load failed", e); } catch (_) {}
12330
+ return;
12331
+ }
12332
+ // Preload every body / hair / outfit referenced by the cold
12333
+ // seeds before rendering · cross-model swaps need the source
12334
+ // GLB cached before buildAvatar3D walks the skeleton.
12335
+ const cfgs = cold.map((j) => ({ id: j.id, cfg: av.deriveDefaultAvatarConfig(j.id) }));
12336
+ const modelIds = new Set();
12337
+ for (const { cfg } of cfgs) {
12338
+ if (cfg.model) modelIds.add(cfg.model);
12339
+ if (cfg.hairStyle && cfg.hairStyle !== "none") modelIds.add(cfg.hairStyle);
12340
+ if (cfg.outfitStyle) modelIds.add(cfg.outfitStyle);
12341
+ }
12342
+ try {
12343
+ await Promise.all(Array.from(modelIds).map((m) => av.loadAvatar3D(m).catch(() => null)));
12344
+ } catch (_) { /* */ }
12345
+
12346
+ // Shared offscreen renderer · same recipe as home.html's
12347
+ // (transparent BG, ACES tone-map, IBL via PMREM + RoomEnv).
12348
+ // 112×112 is 2× retina for the 56-pixel display tile.
12349
+ const SIZE = 112;
12350
+ const off = document.createElement("canvas");
12351
+ off.width = SIZE * 2; off.height = SIZE * 2;
12352
+ let renderer;
12353
+ try {
12354
+ renderer = new THREE.WebGLRenderer({ canvas: off, antialias: true, alpha: true, preserveDrawingBuffer: true });
12355
+ } catch (e) {
12356
+ try { console.warn("[celebrity-3d] renderer init failed", e); } catch (_) {}
12357
+ return;
12358
+ }
12359
+ renderer.setSize(SIZE * 2, SIZE * 2, false);
12360
+ renderer.setClearColor(0x000000, 0);
12361
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
12362
+ renderer.toneMappingExposure = 1.0;
12363
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
12364
+
12365
+ const scene = new THREE.Scene();
12366
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x2a3140, 0.5));
12367
+ const key = new THREE.DirectionalLight(0xffffff, 1.2);
12368
+ key.position.set(2, 3, 2.5);
12369
+ scene.add(key);
12370
+ const rim = new THREE.DirectionalLight(0xbfd4ff, 0.4);
12371
+ rim.position.set(-2, 2, -2);
12372
+ scene.add(rim);
12373
+ // Camera FOV mirrors avatar3d-editor's capturePng so the
12374
+ // head-anchor framing math (`applyFaceFraming`) produces the
12375
+ // SAME crop the agent-profile portrait does. Position is
12376
+ // computed per-figure right before render — face mesh is
12377
+ // present at that point.
12378
+ const camera = new THREE.PerspectiveCamera(35, 1, 0.05, 20);
12379
+
12380
+ for (const { id, cfg } of cfgs) {
12381
+ let figure = null;
12382
+ try {
12383
+ figure = av.buildAvatar3D(id, {
12384
+ model: cfg.model,
12385
+ hairStyle: cfg.hairStyle,
12386
+ outfitStyle: cfg.outfitStyle,
12387
+ accessory: cfg.accessory,
12388
+ height: 1.7,
12389
+ skin: cfg.skin, hair: cfg.hair, brow: cfg.brow, outfit: cfg.outfit,
12390
+ browStyle: cfg.browStyle, tieStyle: cfg.tieStyle,
12391
+ tie: cfg.tie, eye: cfg.eye,
12392
+ });
12393
+ } catch (_) { figure = null; }
12394
+ if (!figure) continue;
12395
+ // Faint 3/4 turn for visual interest, then frame on the
12396
+ // face mesh (same routine the agent-profile capture uses)
12397
+ // so every card shows the same head-and-shoulders crop.
12398
+ figure.rotation.y = -0.18;
12399
+ scene.add(figure);
12400
+ try {
12401
+ if (typeof av.applyFaceFraming === "function") {
12402
+ av.applyFaceFraming(camera, figure);
12403
+ }
12404
+ } catch (_) { /* fallback to constructor defaults */ }
12405
+ renderer.render(scene, camera);
12406
+ const dataUrl = renderer.domElement.toDataURL("image/png");
12407
+ this._celebrityAvatar3dCache.set(id, dataUrl);
12408
+ // Find every mounted card with this id (composer can be open
12409
+ // in two surfaces simultaneously in some flows) and paint.
12410
+ document.querySelectorAll(`[data-cmp-celeb-img="${CSS.escape(id)}"]`)
12411
+ .forEach((el) => this._paintCelebrityImg(el, dataUrl));
12412
+ scene.remove(figure);
12413
+ figure.traverse((n) => {
12414
+ if (n.material) {
12415
+ const ms = Array.isArray(n.material) ? n.material : [n.material];
12416
+ for (const m of ms) { try { m.dispose(); } catch (_) {} }
12417
+ }
12418
+ if (n.geometry) { try { n.geometry.dispose(); } catch (_) {} }
12419
+ });
12420
+ }
12421
+ renderer.dispose();
12422
+ },
12423
+
12424
+ /** Internal · set a celebrity-card image to a dataURL, swapping
12425
+ * the placeholder `<span>` for an `<img>` when the initial
12426
+ * paint used the fallback dot. Marks the node so the next
12427
+ * hydrate pass skips it. */
12428
+ _paintCelebrityImg(el, dataUrl) {
12429
+ if (!el || !dataUrl) return;
12430
+ if (el.tagName === "IMG") {
12431
+ el.src = dataUrl;
12432
+ el.dataset.cmpCelebRendered = "1";
12433
+ return;
12434
+ }
12435
+ // Replace the fallback span with an img inside the same `.cmp-celeb-av` tile.
12436
+ const img = document.createElement("img");
12437
+ img.className = "cmp-celeb-img";
12438
+ img.dataset.cmpCelebImg = el.getAttribute("data-cmp-celeb-img") || "";
12439
+ img.src = dataUrl;
12440
+ img.alt = "";
12441
+ img.dataset.cmpCelebRendered = "1";
12442
+ if (el.parentNode) el.parentNode.replaceChild(img, el);
12443
+ },
12444
+
12177
12445
  /** Click handler · kick the full-mode persona builder with the
12178
12446
  * celebrity's seed description, tag the live job with the seed
12179
12447
  * id so the save-success callback can mark it consumed. */
@@ -12419,8 +12687,8 @@
12419
12687
  * topped up via the LLM-driven `_topUpCelebritySeeds` path.
12420
12688
  *
12421
12689
  * Per-entry shape:
12422
- * · id · kebab-slug · doubles as `AvatarSkill` seed
12423
- * (deterministic 8-bit portrait)
12690
+ * · id · kebab-slug · doubles as `Avatar3DSnap` seed
12691
+ * (deterministic 3D voxel portrait)
12424
12692
  * · name · verbatim, no i18n (proper nouns)
12425
12693
  * · roleTag · short mono tag · kept English to match the
12426
12694
  * mono kicker register used elsewhere
@@ -12838,8 +13106,13 @@
12838
13106
 
12839
13107
  /** Preview card · all generated fields editable inline. */
12840
13108
  renderAgentSpecPreviewHtml(spec) { const seed = this.agentSpecAvatarSeed;
12841
- const avatarSvg = (window.AvatarSkill && seed)
12842
- ? window.AvatarSkill.generate(seed, { size: 96 })
13109
+ // Avatar paints async via Avatar3DSnap (see the post-render
13110
+ // hydration that follows). The frame mounts with a cached
13111
+ // snap if hot, otherwise empty + a CSS placeholder spinner.
13112
+ const snap = window.Avatar3DSnap;
13113
+ const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
13114
+ const avatarSvg = cachedSnap
13115
+ ? `<img src="${cachedSnap}" alt="">`
12843
13116
  : `<div class="ag-prev-av-empty">—</div>`;
12844
13117
  // Reachable models only · filter by `/api/models` cache so the
12845
13118
  // user can't pick a model their active credential can't route
@@ -13284,9 +13557,14 @@
13284
13557
  this.personaJob.finalGuessRoleTag = data.guessRoleTag || "director";
13285
13558
  // Avatar seed · matches Signal-mode's pattern. Random per
13286
13559
  // build, user can re-roll on the save card.
13287
- this.personaJob.avatarSeed = (window.AvatarSkill && window.AvatarSkill.randomSeed)
13288
- ? window.AvatarSkill.randomSeed()
13560
+ this.personaJob.avatarSeed = (window.Avatar3DSnap && window.Avatar3DSnap.randomSeed)
13561
+ ? window.Avatar3DSnap.randomSeed()
13289
13562
  : null;
13563
+ // Warm the 3D snap cache so the persona save overlay's
13564
+ // portrait paints instantly when it opens.
13565
+ if (this.personaJob.avatarSeed && window.Avatar3DSnap && typeof window.Avatar3DSnap.generate === "function") {
13566
+ window.Avatar3DSnap.generate(this.personaJob.avatarSeed).catch(() => { /* */ });
13567
+ }
13290
13568
  this._closePersonaSse();
13291
13569
  this._stopPersonaTick();
13292
13570
  this._personaRender();
@@ -13913,6 +14191,11 @@
13913
14191
  const e = await r.json().catch(() => ({}));
13914
14192
  throw new Error(e.error || ("HTTP " + r.status));
13915
14193
  }
14194
+ // Swap the 8-bit avatar for a 3D screenshot (best-effort) before the
14195
+ // roster refresh below picks the new director up.
14196
+ const saved = await r.json().catch(() => null);
14197
+ const newId = saved && (saved.id || (saved.agent && saved.agent.id));
14198
+ if (newId) await this.apply3dPortrait(newId);
13916
14199
  // Capture the celebrity seed id (if any) BEFORE nulling
13917
14200
  // personaJob below · `_markCelebritySeedConsumed` needs
13918
14201
  // it to update localStorage. Tagging happens in
@@ -13998,9 +14281,19 @@
13998
14281
  const userModel = this.loadAgentComposerModel();
13999
14282
  if (userModel && MODEL_LABELS[userModel]) this.agentSpec.modelV = userModel;
14000
14283
  }
14001
- this.agentSpecAvatarSeed = (window.AvatarSkill && window.AvatarSkill.randomSeed)
14002
- ? window.AvatarSkill.randomSeed()
14284
+ this.agentSpecAvatarSeed = (window.Avatar3DSnap && window.Avatar3DSnap.randomSeed)
14285
+ ? window.Avatar3DSnap.randomSeed()
14003
14286
  : null;
14287
+ // Kick off the 3D portrait render so the cache is hot by the
14288
+ // time the preview card mounts + the save handler reads it.
14289
+ if (this.agentSpecAvatarSeed && window.Avatar3DSnap && typeof window.Avatar3DSnap.generate === "function") {
14290
+ const seed = this.agentSpecAvatarSeed;
14291
+ window.Avatar3DSnap.generate(seed).then((dataUrl) => {
14292
+ if (!dataUrl || this.agentSpecAvatarSeed !== seed) return;
14293
+ const f = document.querySelector(".ag-prev-av-frame");
14294
+ if (f) f.innerHTML = `<img src="${dataUrl}" alt="">`;
14295
+ }).catch(() => { /* */ });
14296
+ }
14004
14297
  this.agentSpecError = null;
14005
14298
  } catch (e) {
14006
14299
  const isAbort = (e && (e.name === "AbortError" || /aborted/i.test(String(e.message))));
@@ -14091,12 +14384,14 @@
14091
14384
  throw new Error(e.error || ("HTTP " + r.status));
14092
14385
  }
14093
14386
  const j = await r.json();
14387
+ const newId = j && (j.id || (j.agent && j.agent.id));
14388
+ // 3D screenshot avatar (before refresh, so the roster shows it).
14389
+ if (newId) await this.apply3dPortrait(newId);
14094
14390
  await this.refreshAgents?.();
14095
14391
  this.agentSpec = null;
14096
14392
  this.agentSpecAvatarSeed = null;
14097
14393
  this.clearAgentComposerDraft();
14098
14394
  this.composerMode = "room";
14099
- const newId = j && (j.id || (j.agent && j.agent.id));
14100
14395
  // Land on the new agent's full profile (mirrors the prior
14101
14396
  // inline-preview save flow's post-success navigation).
14102
14397
  if (newId && typeof window.boardroomFocusAgent === "function") {
@@ -14130,14 +14425,19 @@
14130
14425
  },
14131
14426
 
14132
14427
  rerollAgentSpecAvatar() {
14133
- if (!window.AvatarSkill || !window.AvatarSkill.randomSeed || !window.AvatarSkill.generate) return;
14134
- this.agentSpecAvatarSeed = window.AvatarSkill.randomSeed();
14135
- // In-place re-render of just the avatar frame. AvatarSkill exposes
14136
- // `generate(seed, opts)` (returns SVG markup) — there's no
14137
- // renderSeedSvg helper, hence the previous reroll silently
14138
- // failed.
14428
+ const snap = window.Avatar3DSnap;
14429
+ if (!snap || !snap.randomSeed || !snap.generate) return;
14430
+ const seed = snap.randomSeed();
14431
+ this.agentSpecAvatarSeed = seed;
14139
14432
  const frame = document.querySelector(".ag-prev-av-frame");
14140
- if (frame) frame.innerHTML = window.AvatarSkill.generate(this.agentSpecAvatarSeed, { size: 96 });
14433
+ if (!frame) return;
14434
+ frame.innerHTML = '<div class="ag-prev-av-empty">…</div>';
14435
+ snap.generate(seed).then((dataUrl) => {
14436
+ if (!dataUrl) return;
14437
+ if (this.agentSpecAvatarSeed !== seed) return; // user rolled again
14438
+ const f2 = document.querySelector(".ag-prev-av-frame");
14439
+ if (f2) f2.innerHTML = `<img src="${dataUrl}" alt="">`;
14440
+ }).catch(() => { /* */ });
14141
14441
  },
14142
14442
 
14143
14443
  discardAgentSpec() {
@@ -14167,6 +14467,28 @@
14167
14467
  this._runAgentSpecGeneration(desc);
14168
14468
  },
14169
14469
 
14470
+ /** Give a freshly-created director a 3D screenshot avatar. Renders the
14471
+ * director's deterministic default 3D config (derived from its id — the
14472
+ * same look the room + editor show) to a head-and-shoulders PNG and PATCHes
14473
+ * it as the avatar, plus persists the config. Best-effort: on any failure
14474
+ * the director keeps the 8-bit SVG avatar it was created with. Call AFTER
14475
+ * creation (id known) and BEFORE refreshAgents so the roster picks it up. */
14476
+ async apply3dPortrait(agentId) {
14477
+ try {
14478
+ if (!agentId || !window.Avatar3D || typeof window.renderAvatar3DPortrait !== "function") return;
14479
+ const cfg = window.Avatar3D.deriveDefaultAvatarConfig(agentId);
14480
+ const png = await window.renderAvatar3DPortrait(cfg);
14481
+ if (!png) return;
14482
+ await fetch("/api/agents/" + encodeURIComponent(agentId), {
14483
+ method: "PATCH",
14484
+ headers: { "content-type": "application/json" },
14485
+ body: JSON.stringify({ avatarPath: png, avatar3d: cfg }),
14486
+ });
14487
+ } catch (e) {
14488
+ console.warn("[avatar3d] new-director portrait failed; keeping fallback avatar", e);
14489
+ }
14490
+ },
14491
+
14170
14492
  /** Read inline-edited values from the Signal preview card and
14171
14493
  * POST to /api/agents. Persona-mode save is a separate path
14172
14494
  * (`openPersonaConfirmOverlay` → manual-config overlay → its
@@ -14187,10 +14509,15 @@
14187
14509
  instruction: read("instruction").trim(),
14188
14510
  modelV: read("modelV").trim(),
14189
14511
  };
14190
- // Avatar — generated SVG from current seed, embedded as data: URL.
14512
+ // Avatar — rendered 3D portrait from the current seed, embedded
14513
+ // as a PNG data URL. Render is async (lazy three.js init) but
14514
+ // we already kicked it off when the user landed on the preview
14515
+ // card, so the seed should be hot in the per-seed cache by the
14516
+ // time they click save.
14191
14517
  let avatarPath = null;
14192
- if (window.AvatarSkill && this.agentSpecAvatarSeed && window.AvatarSkill.generateDataUrl) {
14193
- avatarPath = window.AvatarSkill.generateDataUrl(this.agentSpecAvatarSeed);
14518
+ const snap = window.Avatar3DSnap;
14519
+ if (snap && this.agentSpecAvatarSeed && typeof snap.generate === "function") {
14520
+ try { avatarPath = await snap.generate(this.agentSpecAvatarSeed); } catch (_) { avatarPath = null; }
14194
14521
  }
14195
14522
  // Ability axes · lifted from the spec produced by /api/agents/generate-spec.
14196
14523
  // The server validates + clamps + falls back to a heuristic if missing.
@@ -14214,6 +14541,11 @@
14214
14541
  throw new Error(e.error || ("HTTP " + r.status));
14215
14542
  }
14216
14543
  const j = await r.json();
14544
+ // POST /api/agents returns the agent record directly (not wrapped).
14545
+ const newId = j && (j.id || (j.agent && j.agent.id));
14546
+ // Replace the 8-bit avatar with a 3D screenshot before the roster
14547
+ // refresh so the sidebar + profile show the 3D portrait immediately.
14548
+ if (newId) await this.apply3dPortrait(newId);
14217
14549
  // Refresh local agent catalog so the new director shows up
14218
14550
  // in pickers + sidebar immediately.
14219
14551
  await this.refreshAgents?.();
@@ -14223,8 +14555,6 @@
14223
14555
  // a future visit to "+ New Agent" should land on a fresh textarea.
14224
14556
  this.clearAgentComposerDraft();
14225
14557
  this.composerMode = "room";
14226
- // POST /api/agents returns the agent record directly (not wrapped).
14227
- const newId = j && (j.id || (j.agent && j.agent.id));
14228
14558
  // Land the user on the new agent's full profile page · also
14229
14559
  // switches the sidebar to the Agents tab and persists the
14230
14560
  // sub-state so a refresh keeps them on the same agent.
@@ -15293,6 +15623,9 @@
15293
15623
  <div class="head-actions">
15294
15624
  <a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
15295
15625
  <a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> ${this.escape(this._t("room_pause_verb"))} ]</a>
15626
+ ${this._isReplayActiveForRoom(r.id)
15627
+ ? `<a href="#" class="replay-stop-btn" data-vr-header-stop aria-label="${this.escape(this._t("head_replay_stop_label") || "Stop")}">[ <span class="replay-stop-icon">■</span> ${this.escape(this._t("head_replay_stop_label") || "Stop")} ]</a>`
15628
+ : ""}
15296
15629
  <div class="head-cast">${castHtml}</div>
15297
15630
  <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>
15298
15631
  <a href="#" class="head-icon-btn head-threads" data-threads-trigger data-tip="${this.escape(this._t("head_threads_tip") || "All private threads in this room")}" aria-label="${this.escape(this._t("head_threads_label") || "All threads")}"></a>
@@ -15345,10 +15678,12 @@
15345
15678
  ? `<a href="#" class="head-icon-btn head-followup" data-room-followup data-tip="${this.escape(this._t("adj_followup_label"))}" aria-label="${this.escape(this._t("adj_followup_label"))}"></a>`
15346
15679
  : `<a href="#" class="head-icon-btn head-adjourn" data-adjourn data-tip="${this.escape(this._t("ib_adjourn_tip"))}" aria-label="${this.escape(this._t("ib_adjourn_label"))}"></a>`}
15347
15680
  ${(r.deliveryMode === "voice"
15348
- && r.status === "live"
15349
15681
  && window.BoardroomRecorder
15350
15682
  && typeof window.BoardroomRecorder.isAvailable === "function"
15351
- && window.BoardroomRecorder.isAvailable()) ? (() => {
15683
+ && window.BoardroomRecorder.isAvailable()
15684
+ && (r.status === "live"
15685
+ || this._isReplayActiveForRoom(r.id)
15686
+ || window.BoardroomRecorder.isRecording())) ? (() => {
15352
15687
  const isRec = window.BoardroomRecorder.isRecording();
15353
15688
  const tip = isRec ? this._t("head_record_stop_tip") : this._t("head_record_tip");
15354
15689
  const cls = "head-icon-btn head-record" + (isRec ? " is-recording" : "");
@@ -17843,22 +18178,35 @@
17843
18178
  // profile" kept regressing for custom directors.
17844
18179
  const agentTag = !isUser && author ? ` data-agent="${this.escape(author.id)}"` : "";
17845
18180
  // User avatar mirrors the preference-overlay setting · when
17846
- // prefs.avatarSeed is present we render the AvatarSkill SVG
17847
- // (same seed as the sidebar foot's user-av), otherwise fall back
17848
- // to the initial-letter chip we shipped before AvatarSkill
17849
- // existed.
18181
+ // prefs.avatarUrl (a captured 3D PNG) is present we use that
18182
+ // directly. Otherwise, if the avatarSeed has a cached 3D snap
18183
+ // we paint it synchronously; if not we fall back to the
18184
+ // initial-letter chip (the async snap render kicks in on the
18185
+ // next renderUserBlock paint).
17850
18186
  let userAvHtml;
17851
18187
  if (isUser) {
18188
+ const url = this.prefs?.avatarUrl;
17852
18189
  const seed = this.prefs?.avatarSeed;
17853
- if (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function") {
17854
- userAvHtml = `<div class="msg-av msg-av-pixel">${window.AvatarSkill.generate(seed)}</div>`;
18190
+ const snap = window.Avatar3DSnap;
18191
+ const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
18192
+ const src = url || cachedSnap || "";
18193
+ if (src) {
18194
+ userAvHtml = `<img class="msg-av" src="${this.escape(src)}" alt="">`;
17855
18195
  } else {
17856
18196
  userAvHtml = `<div class="msg-av">${this.escape((this.prefs?.name || "Y").charAt(0).toUpperCase())}</div>`;
18197
+ // Kick off the async render so future paints hit cache.
18198
+ if (seed && snap && typeof snap.generate === "function") {
18199
+ snap.generate(seed).then(() => {
18200
+ if (typeof this.renderChat === "function") {
18201
+ try { this.renderChat(); } catch (_) {}
18202
+ }
18203
+ }).catch(() => { /* */ });
18204
+ }
17857
18205
  }
17858
18206
  }
17859
18207
  const avatarHtml = isUser
17860
18208
  ? userAvHtml
17861
- : `<img class="msg-av" src="${this.escape(author?.avatarPath || "/avatars/socrates.svg")}" alt=""${agentTag}>`;
18209
+ : `<img class="msg-av" src="${this.escape(author?.avatarPath || "/avatars/3d/socrates.png")}" alt=""${agentTag}>`;
17862
18210
 
17863
18211
  // Chair round-end: trim the POINTS: block from the bubble; the
17864
18212
  // vote card below the bubble surfaces the points as toggles.
@@ -18681,7 +19029,7 @@
18681
19029
  },
18682
19030
 
18683
19031
  /** Inline pixel-art chair sprite · 32×40 viewBox, painted as 1×1
18684
- * rect cells in the same vocabulary as /public/avatars/chair.svg.
19032
+ * rect cells in the same vocabulary as /public/avatars/3d/chair.png.
18685
19033
  * `isModerator: true` adds two cyan side-rails + a cyan headrest
18686
19034
  * gem to distinguish the chair's seat from the directors'. */
18687
19035
  renderRoundTableChairSvg(isModerator) {
@@ -18754,8 +19102,12 @@
18754
19102
  members.push({
18755
19103
  id: "__user__",
18756
19104
  name: prefs.name || "You",
18757
- avatarPath: null,
19105
+ // Prefer the 3D portrait PNG; the seat sprite shows it directly.
19106
+ avatarPath: prefs.avatarUrl || null,
18758
19107
  __seed: prefs.avatarSeed || null,
19108
+ // When the user has a saved 3D avatar, the room renders them as a
19109
+ // full 3D figure (like directors) rather than a flat billboard.
19110
+ avatar3d: prefs.avatar3d || null,
18759
19111
  __isUser: true,
18760
19112
  });
18761
19113
  }
@@ -19048,6 +19400,38 @@
19048
19400
  return { id: null, state: null };
19049
19401
  },
19050
19402
 
19403
+ /** Is `authorId`'s TTS audio actually PRODUCING SOUND right now? Used by
19404
+ * the 3D room to sync the talking-mouth animation to real audio — the
19405
+ * "speaking" stage state can lead the audio (a streaming-text turn shows
19406
+ * "speaking" before its TTS chunk starts), which made mouths move
19407
+ * silently. This reads the live <audio> element, so it's frame-accurate.
19408
+ * Read each frame from voice-3d's tick (cheap · 0-2 queues). */
19409
+ isSpeakerAudible(authorId) {
19410
+ if (!authorId) return false;
19411
+ // Voice-replay (adjourned playback) drives the seat itself · audible
19412
+ // exactly when its playback state is "speaking".
19413
+ const replay = (typeof window !== "undefined" && window.boardroomVoiceReplay
19414
+ && typeof window.boardroomVoiceReplay.getActive === "function")
19415
+ ? window.boardroomVoiceReplay.getActive() : null;
19416
+ if (replay && replay.authorId) {
19417
+ return replay.authorId === authorId && replay.state === "speaking";
19418
+ }
19419
+ // Live room · a voice queue for this author whose <audio> is playing.
19420
+ const qs = this.voiceQueues;
19421
+ if (qs) {
19422
+ for (const k in qs) {
19423
+ const vq = qs[k];
19424
+ if (!vq || vq.playState !== "playing" || vq.authorId !== authorId) continue;
19425
+ const a = vq.audio;
19426
+ // The "playing" flag is set when audio.play() is invoked; confirm the
19427
+ // element is genuinely running (not paused/ended/buffering at 0).
19428
+ if (!a) return true; // flagged playing, no element to inspect → trust it
19429
+ if (!a.paused && !a.ended && a.currentTime > 0) return true;
19430
+ }
19431
+ }
19432
+ return false;
19433
+ },
19434
+
19051
19435
  /** Stage SFX driver · used by BOTH the 3D delegate path and the
19052
19436
  * legacy 2D path so the thinking-blip loop + speaker-change
19053
19437
  * chime fire regardless of which stage renderer is active.
@@ -19093,35 +19477,23 @@
19093
19477
  const floorMode = VALID_FLOORS.includes(tone) ? tone : "constructive";
19094
19478
  stage.setAttribute("data-floor", floorMode);
19095
19479
 
19096
- // ── 3D stage delegate ─────────────────────────────────────
19097
- // When the user's "voxel 3D stage" toggle is on (default) AND
19098
- // the voice-3d module loaded AND WebGL is available, hand off
19099
- // to VoiceStage3D and skip the legacy 2D seat DOM render.
19100
- // Falls through to the 2D path on toggle off / no WebGL / no
19101
- // module · the legacy SVG table + seats DOM stay in place
19102
- // for that case (we never delete them, only `.is-3d` hides).
19103
- const stage3dPref = (() => {
19104
- try { return localStorage.getItem("boardroom.stage3d") !== "off"; }
19105
- catch (_) { return true; }
19106
- })();
19480
+ // ── 3D stage · the only path now ──────────────────────────
19481
+ // The legacy 2D SVG stage was retired · the voice room is
19482
+ // 3D-only. WebGL is required; if it's not available, the stage
19483
+ // simply doesn't paint (caller is expected to gate room entry
19484
+ // on `VS3D.isSupported()` if a no-WebGL fallback ever returns).
19485
+ // The aria-label + HUD + subtitle + SFX tail still runs so
19486
+ // screen readers and the status panels stay in sync regardless.
19487
+ const members = this.roundTableMembers();
19488
+ const positions = this.computeSeatPositions(members);
19107
19489
  const VS3D = window.VoiceStage3D;
19108
- const use3d = stage3dPref && VS3D && typeof VS3D.mount === "function" && VS3D.isSupported();
19109
- const members3d = this.roundTableMembers();
19110
- const positions3d = this.computeSeatPositions(members3d);
19111
- if (use3d) {
19490
+ const sp = this._resolveStageSpeaker();
19491
+ let speakingId = sp && sp.id ? sp.id : null;
19492
+
19493
+ if (VS3D && typeof VS3D.mount === "function" && VS3D.isSupported()) {
19112
19494
  try {
19113
19495
  VS3D.mount(stage);
19114
- // Compact speaker resolution · mirrors the longer 2D path
19115
- // below (priority: voice-replay → audible voice queue →
19116
- // streaming agent message → chair pending / convene →
19117
- // queue head). Enough to drive the 3D overlay's bubble +
19118
- // nameplate-hide while-speaking behaviour.
19119
- const sp = this._resolveStageSpeaker();
19120
19496
  const votePopHtml = this._resolveStageVotePop();
19121
- // User-spoke bubble · mirrors the 2D `data-rt-user-bubble`
19122
- // element. Active when the latest user message landed
19123
- // within the last USER_BUBBLE_TTL_MS, computed once per
19124
- // render so the bubble's countdown progress is fresh.
19125
19497
  const ub = this.userBubble;
19126
19498
  const nowMs = Date.now();
19127
19499
  const ubActive = !!(ub && ub.text && !ub.dismissed && nowMs < ub.deadline);
@@ -19130,8 +19502,8 @@
19130
19502
  (this.USER_BUBBLE_TTL_MS - (ub.deadline - nowMs)) / this.USER_BUBBLE_TTL_MS))
19131
19503
  : 0;
19132
19504
  VS3D.update({
19133
- members: members3d,
19134
- positions: positions3d,
19505
+ members,
19506
+ positions,
19135
19507
  mode: floorMode,
19136
19508
  speakerId: sp.id,
19137
19509
  speakerState: sp.state,
@@ -19143,444 +19515,18 @@
19143
19515
  userWait: !!this.pendingUserMessage,
19144
19516
  userBubble: ubActive ? { text: ub.text, progress: ubProgress } : null,
19145
19517
  });
19146
- // The 2D path below ends with calls to renderRoundTableHud
19147
- // + renderRtSubtitle so the status panel + live subtitle
19148
- // stay in sync with each renderRoundTable. The 3D delegate
19149
- // `return`s above the 2D code, so without these two calls
19150
- // here the HUD div stays empty until some unrelated SSE
19151
- // (config-event / queue-update) happens to fire them ·
19152
- // user reported "HUD shows as a thin line, fixed by
19153
- // toggling the tone" which was exactly that race.
19154
- this.renderRoundTableHud();
19155
- this.renderRtSubtitle();
19156
- // Stage SFX · same call the 2D tail makes (thinking loop +
19157
- // speaker-change chime). Reuses `sp` resolved above so the
19158
- // helper sees the exact speaker/state the 3D scene rendered.
19159
- this._applyStageSfx(sp.id, sp.state);
19160
- return;
19161
19518
  } catch (e) {
19162
- // 3D path blew up · fall through to the legacy 2D render
19163
- // so the user still gets a working stage. Logged so we can
19164
- // diagnose later · the toggle stays on (user-flipped only).
19165
- console.warn("[voice-3d] mount/update failed, falling back to 2D:", e);
19166
- try { VS3D.unmount(); } catch (_) {}
19167
- }
19168
- } else if (VS3D && typeof VS3D.unmount === "function" && stage.classList.contains("is-3d")) {
19169
- // Toggle just flipped off · tear down the 3D canvas so the
19170
- // 2D path below paints into a clean stage.
19171
- try { VS3D.unmount(); } catch (_) {}
19172
- }
19173
-
19174
- const seatsHost = stage.querySelector("[data-rt-seats]");
19175
- if (!seatsHost) return;
19176
- const members = members3d;
19177
- const positions = positions3d;
19178
-
19179
- // Build seat HTML. Z-order via inline style based on the y
19180
- // coordinate so seats with larger y (front) paint last and
19181
- // occlude back-row seats / the table edge.
19182
- const seatsByZ = positions
19183
- .map((seat, i) => ({ seat, i, zScore: Math.round(seat.y * 10) }))
19184
- .sort((a, b) => a.zScore - b.zScore);
19185
-
19186
- // Determine the active speaker AND whether they're thinking
19187
- // (warming up · no tokens yet) or actively speaking (tokens
19188
- // flowing). Priority:
19189
- // 1. Most recent streaming message → that author. State
19190
- // depends on body content: empty body = thinking,
19191
- // non-empty = speaking.
19192
- // 2. Most recent message with an ACTIVE VOICE QUEUE (audio
19193
- // is being streamed/played for it). Catches chair
19194
- // templated announcements (announceRoundPrompt +
19195
- // announceIntervention) that emit voice-chunks without
19196
- // setting meta.streaming · the user hears them speaking,
19197
- // so the bubble must surface even though the streaming
19198
- // flag is false.
19199
- // 3. currentQueue[0] when status === "speaking" → queue head
19200
- // just promoted, no message-appended yet → thinking.
19201
- // 4. Otherwise null (idle).
19202
- let speakingId = null;
19203
- let speakerState = null; // "thinking" | "speaking"
19204
- let replayBody = null; // populated only during voice-replay
19205
- // (0) Voice-replay override · when an adjourned room is playing
19206
- // back its transcript via the replay overlay, the live
19207
- // `streaming` / queue signals are absent (the room is
19208
- // done). Read the replay's active speaker so the seat
19209
- // lights up + the bubble + subtitle reflect the playback.
19210
- // The `body` field powers the subtitle bar at the foot of
19211
- // the stage (`renderRoundTableSubtitle`). Falls through to
19212
- // the live-detection paths below when replay isn't active.
19213
- const replayActive = (typeof window !== "undefined"
19214
- && window.boardroomVoiceReplay
19215
- && typeof window.boardroomVoiceReplay.getActive === "function")
19216
- ? window.boardroomVoiceReplay.getActive()
19217
- : null;
19218
- if (replayActive && replayActive.authorId) {
19219
- speakingId = replayActive.authorId;
19220
- speakerState = replayActive.state === "speaking" ? "speaking" : "thinking";
19221
- replayBody = replayActive.body || "";
19222
- }
19223
- const msgs = this.currentMessages || [];
19224
- // (1) Audible voice queue takes precedence · with cross-director
19225
- // pipelining, the most-recent streaming message is often the
19226
- // PRE-WARMED next speaker (B's placeholder lands while A's
19227
- // audio still plays). The seat that lights up must match
19228
- // whoever the user is HEARING — not whoever's text is
19229
- // streaming in the background. Scan voiceQueues for the
19230
- // playing one and resolve back to its message's author.
19231
- if (!speakingId && this.voiceQueues) {
19232
- for (let i = msgs.length - 1; i >= 0; i--) {
19233
- const mm = msgs[i];
19234
- if (!mm || mm.authorKind !== "agent") continue;
19235
- const vq = this.voiceQueues[mm.id];
19236
- if (vq && vq.playState === "playing") {
19237
- speakingId = mm.authorId;
19238
- speakerState = "speaking";
19239
- break;
19240
- }
19241
- }
19242
- }
19243
- // (2) Streaming message fallback · only relevant when no audible
19244
- // queue exists (text mode, OR the brief warmup between
19245
- // message-appended and first voice-chunk). Picks the most
19246
- // recent streaming agent message that ISN'T pre-warmed —
19247
- // pre-warmed messages have a voiceQueue with playState
19248
- // "queued" and should NOT light up a seat.
19249
- if (!speakingId) {
19250
- for (let i = msgs.length - 1; i >= 0; i--) {
19251
- const mm = msgs[i];
19252
- if (!(mm && mm.meta && mm.meta.streaming === true && mm.authorKind === "agent")) continue;
19253
- // Skip queued (pre-warmed) speakers · their voice waits for
19254
- // the playing speaker's audio to finish.
19255
- const vq = this.voiceQueues && this.voiceQueues[mm.id];
19256
- if (vq && vq.playState === "queued") continue;
19257
- speakingId = mm.authorId;
19258
- const body = String(mm.body || "").trim();
19259
- speakerState = body.length > 0 ? "speaking" : "thinking";
19260
- break;
19261
- }
19262
- }
19263
- // (2b) Dead branch removed · the old "voice queue exists at all"
19264
- // path was replaced by the playing-queue priority above.
19265
- if (false && !speakingId && this.voiceQueues) {
19266
- for (let i = msgs.length - 1; i >= 0; i--) {
19267
- const mm = msgs[i];
19268
- if (!mm || mm.authorKind !== "agent") continue;
19269
- const vq = this.voiceQueues[mm.id];
19270
- if (vq && vq.playState === "playing") {
19271
- speakingId = mm.authorId;
19272
- speakerState = "speaking";
19273
- break;
19274
- }
19275
- }
19276
- }
19277
- // (3) Chair preparing (silent prep phase between user input
19278
- // and the chair's message-appended) · without this hook
19279
- // the user has no visual signal during the seconds the
19280
- // chair spends on tools + LLM startup. Show the thinking
19281
- // bubble on the chair seat so they know the chair is
19282
- // working. Cleared the moment the chair's real message
19283
- // appends (hideChairPending fires).
19284
- if (!speakingId && this.chairPending === true && this.currentChair) {
19285
- speakingId = this.currentChair.id;
19286
- speakerState = "thinking";
19287
- }
19288
- // (3b) Convening sequence active (auto-pick + chair prep) ·
19289
- // the chat-side convening card is hidden in voice mode,
19290
- // so without this the stage shows nothing happening for
19291
- // the 3-4s the picker LLM + chair tools + LLM startup
19292
- // take. chairPending above only fires AFTER auto-pick-
19293
- // complete; conveneState covers the EARLIER picker phase
19294
- // too. Cleared on the chair's first message-appended.
19295
- if (!speakingId && this.conveneState && this.currentChair) {
19296
- speakingId = this.currentChair.id;
19297
- speakerState = "thinking";
19298
- }
19299
- if (!speakingId && Array.isArray(this.currentQueue) && this.currentQueue[0] && this.currentQueue[0].status === "speaking") {
19300
- speakingId = this.currentQueue[0].agentId;
19301
- speakerState = "thinking";
19302
- }
19303
-
19304
- // Speaker's messageId · derived from the same priority chain
19305
- // above. Used by the stalled-audio bubble (per-queue watchdog
19306
- // surfaces on the speaker's bubble + the Skip click needs the
19307
- // messageId to POST /voice-done). Null when the speaker is
19308
- // thinking-only (no message id yet) or during replay.
19309
- let speakingMsgId = null;
19310
- if (speakingId && this.voiceQueues) {
19311
- for (let i = msgs.length - 1; i >= 0; i--) {
19312
- const mm = msgs[i];
19313
- if (!mm || mm.authorKind !== "agent") continue;
19314
- if (mm.authorId !== speakingId) continue;
19315
- if (this.voiceQueues[mm.id]) { speakingMsgId = mm.id; break; }
19519
+ console.warn("[voice-3d] mount/update failed:", e);
19316
19520
  }
19317
19521
  }
19318
- const stalledQ = (speakingMsgId && this.voiceQueues && this.voiceQueues[speakingMsgId] && this.voiceQueues[speakingMsgId].stalled)
19319
- ? this.voiceQueues[speakingMsgId]
19320
- : null;
19321
19522
 
19322
- // Stage SFX · thinking loop + speaker-change chime. Logic
19323
- // lives in _applyStageSfx so the 3D delegate path can reuse
19324
- // it; see that helper for the AudioContext / voice-queue
19325
- // gating rationale.
19326
- this._applyStageSfx(speakingId, speakerState);
19327
-
19328
- const html = seatsByZ.map(({ seat, i }) => {
19329
- const m = seat.member;
19330
- const isChair = seat.kind === "chair";
19331
- const isUser = !!m.__isUser;
19332
- const qIdx = isUser ? -1 : this.roundTableQueueIndex(m.id);
19333
- const isSpeaking = !isUser && m.id === speakingId;
19334
- const isQueued = qIdx >= 1; // anyone after the head
19335
-
19336
- // Chair sprite · user gets the plain (no-moderator-gem) chair
19337
- // sprite; the seat reads as "joined the table" not "running it".
19338
- const chairSvg = this.renderRoundTableChairSvg(isChair);
19339
- // Avatar · user takes prefs.avatarSeed via AvatarSkill when
19340
- // available, otherwise falls back to an initial-letter chip
19341
- // (mirrors the sidebar pattern at app.js:4847). Directors /
19342
- // chair use their stored avatarPath image.
19343
- let avatar;
19344
- if (isUser) {
19345
- const seed = m.__seed;
19346
- if (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function") {
19347
- avatar = `<div class="rt-avatar rt-avatar-user has-pixel-av">${window.AvatarSkill.generate(seed)}</div>`;
19348
- } else {
19349
- const initial = this.escape(((m.name || "?")[0] || "?").toUpperCase());
19350
- avatar = `<div class="rt-avatar rt-avatar-user rt-avatar-initial">${initial}</div>`;
19351
- }
19352
- } else {
19353
- // `data-agent` opens the lightweight director overlay (see
19354
- // public/agent-overlay.js bubble-phase click handler). Tagged
19355
- // on directors AND the chair so the user can peek at any
19356
- // seated voice from the stage; user seat is a div, not an
19357
- // img, and is intentionally not tagged.
19358
- avatar = `<img class="rt-avatar" data-agent="${this.escape(m.id)}" src="${this.escape(m.avatarPath || "")}" alt="${this.escape(m.name || "")}">`;
19359
- }
19360
- // Name plate · adds a small "Chairman / 董事长" title beneath
19361
- // the user's name so the user seat reads as the room owner /
19362
- // chairman. Pulled from i18n (`rt_user_title`) so the label
19363
- // follows the active UI locale: defaults to "Chairman" in
19364
- // English, "董事长" in Chinese. Directors and the chair
19365
- // render the plain single-line name.
19366
- const name = isUser
19367
- ? `<div class="rt-name">${this.escape(m.name || "")}<div class="rt-name-title">${this.escape(this._t("rt_user_title"))}</div></div>`
19368
- : `<div class="rt-name">${this.escape(m.name || "")}</div>`;
19369
- const bubbleState = isSpeaking ? speakerState : null;
19370
- // Bubble carries the speaker's NAME so the user always knows
19371
- // who's speaking — the name plate beneath/above the seat is
19372
- // hidden during speaking (display:none in CSS), and without a
19373
- // name on the bubble itself the user could only guess. Status
19374
- // word ("thinking" / "speaking") is rendered as a smaller
19375
- // mono kicker beneath the name. Color + dots animation still
19376
- // distinguishes thinking (amber) from speaking (lime).
19377
- // Convene override · while the room is mid-convening, the
19378
- // chair seat shows the current stage label ("Analyzing topic"
19379
- // / "Seating directors" / "Preparing remarks") instead of the
19380
- // generic "Thinking" so the user knows WHICH part of the
19381
- // setup is in flight. Falls back to the generic label for
19382
- // every non-chair speaker and for chair turns post-convene.
19383
- let statusWord = bubbleState === "thinking"
19384
- ? this._t("rt_thinking")
19385
- : this._t("rt_speaking");
19386
- if (bubbleState === "thinking" && isChair && this.conveneState) {
19387
- const stageKey = ({
19388
- analyzing: "conv_stage_analyzing_title",
19389
- seating: "conv_stage_seating_title",
19390
- preparing: "conv_stage_preparing_title",
19391
- })[this.conveneState.stage];
19392
- if (stageKey) statusWord = this._t(stageKey);
19393
- }
19394
- // Chair-pending phase (server-driven · chair-pending payload.phase).
19395
- // Maps known phase strings to i18n keys so the bubble label
19396
- // reflects WHAT silent work is happening — picker LLM, clarify
19397
- // gate, vote summary, brief stages, etc. Falls back to the
19398
- // existing convene-stage / "Thinking" label when phase is
19399
- // empty or unrecognised.
19400
- if (bubbleState === "thinking" && isChair && this.chairPending && this.chairPendingPhase) {
19401
- const phaseKey = ({
19402
- "clarify-deciding": "rt_phase_clarify_deciding",
19403
- "picker-deciding": "rt_phase_picker_deciding",
19404
- "next-speaker": "rt_phase_next_speaker",
19405
- "llm-warming": "rt_phase_llm_warming",
19406
- "vote-summary": "rt_phase_vote_summary",
19407
- "brief-extracting": "rt_phase_brief_extracting",
19408
- "brief-composing": "rt_phase_brief_composing",
19409
- "brief-writing": "rt_phase_brief_writing",
19410
- })[this.chairPendingPhase];
19411
- if (phaseKey) statusWord = this._t(phaseKey);
19412
- }
19413
- // Per-message LLM first-token watchdog · client-side belt for
19414
- // the server's 60s hard cap. When a streaming director message
19415
- // has gone >8s with no message-token arriving, _localPhase is
19416
- // flipped to "llm-warming" and the bubble shows that label
19417
- // until the first token lands (or the server auto-skips).
19418
- if (bubbleState === "thinking" && !isChair) {
19419
- const streamingMsg = (this.currentMessages || []).slice().reverse().find(
19420
- (mm) => mm && mm.authorKind === "agent" && mm.authorId === m.id
19421
- && mm.meta && mm.meta.streaming === true,
19422
- );
19423
- if (streamingMsg && streamingMsg.meta && streamingMsg.meta._localPhase === "llm-warming") {
19424
- statusWord = this._t("rt_phase_llm_warming");
19425
- }
19426
- }
19427
- const bubbleCls = bubbleState === "thinking"
19428
- ? "rt-bubble is-thinking"
19429
- : "rt-bubble";
19430
- // User bubble · ephemeral, shows latest typed message plus
19431
- // an `×` close button. Visible only when not dismissed and
19432
- // the deadline hasn't elapsed. The 10s countdown is
19433
- // rendered AS the bubble's border via a conic-gradient
19434
- // driven by `--rt-bubble-user-progress` (0 → 1 over 10s);
19435
- // the inline-text countdown digit was removed so prose
19436
- // can use the bubble's full interior width.
19437
- let bubble = "";
19438
- if (isUser) {
19439
- const ub = this.userBubble;
19440
- if (ub && !ub.dismissed && ub.text && Date.now() < ub.deadline) {
19441
- const elapsed = this.USER_BUBBLE_TTL_MS - (ub.deadline - Date.now());
19442
- const progress = Math.min(1, Math.max(0, elapsed / this.USER_BUBBLE_TTL_MS));
19443
- bubble = `<div class="rt-bubble rt-bubble-user" data-rt-user-bubble style="--rt-bubble-user-progress: ${progress.toFixed(3)}">` +
19444
- `<span class="rt-bubble-user-text">${this.escape(ub.text)}</span>` +
19445
- `<button type="button" class="rt-bubble-user-close" data-rt-user-bubble-close aria-label="Dismiss">✕</button>` +
19446
- `</div>`;
19447
- }
19448
- } else if (isChair && this.chairBubble && !this.chairBubble.dismissed
19449
- && this.chairBubble.text && Date.now() < this.chairBubble.deadline) {
19450
- // Chair clarify question · pinned to the chair seat with
19451
- // border countdown. Takes precedence over the "Speaking"
19452
- // status bubble since the chair has already finished its
19453
- // turn at this point (message-final flipped streaming off).
19454
- const cb = this.chairBubble;
19455
- const elapsed = this.CHAIR_BUBBLE_TTL_MS - (cb.deadline - Date.now());
19456
- const progress = Math.min(1, Math.max(0, elapsed / this.CHAIR_BUBBLE_TTL_MS));
19457
- bubble = `<div class="rt-bubble rt-bubble-chair-clarify" data-rt-chair-bubble style="--rt-bubble-chair-progress: ${progress.toFixed(3)}">` +
19458
- `<span class="rt-bubble-chair-clarify-text">${this.escape(cb.text)}</span>` +
19459
- `<button type="button" class="rt-bubble-chair-clarify-close" data-rt-chair-bubble-close aria-label="Dismiss">✕</button>` +
19460
- `</div>`;
19461
- } else if (isSpeaking) {
19462
- // Stalled-audio variant · the chunk-arrival watchdog flipped
19463
- // q.stalled when no new TTS chunks arrived for ~8s. Repaint
19464
- // the bubble amber to signal the issue; the 15s auto-skip
19465
- // inside the watchdog will recover automatically.
19466
- if (stalledQ && stalledQ.messageId === (this.voiceQueues[speakingMsgId]?.messageId)) {
19467
- // Audio stalled · the chunk-arrival watchdog flipped
19468
- // q.stalled when no new TTS chunks arrived for ~8s. Show
19469
- // an amber state on the bubble so the user understands
19470
- // why playback paused. The 15s auto-skip in the watchdog
19471
- // recovers automatically · no manual click affordance.
19472
- const stalledText = this._t("rt_audio_stalled");
19473
- bubble = `<div class="${bubbleCls} is-stalled"`
19474
- + ` title="${this.escape(stalledText)}">`
19475
- + `<span class="rt-bubble-name">${this.escape(m.name || "")}</span>`
19476
- + `<span class="rt-bubble-status">${this.escape(stalledText)}</span>`
19477
- + `</div>`;
19478
- } else {
19479
- bubble = `<div class="${bubbleCls}"><span class="rt-bubble-name">${this.escape(m.name || "")}</span><span class="rt-bubble-status">${this.escape(statusWord)}</span><span class="rt-bubble-dots" aria-hidden="true"><i></i><i></i><i></i></span></div>`;
19480
- }
19481
- }
19482
- const badge = (isQueued && !isSpeaking)
19483
- ? `<div class="rt-badge">${String(qIdx + 1).padStart(2, "0")}</div>`
19484
- : "";
19485
-
19486
- // Vote popover on the chair seat · single voice-mode surface
19487
- // for both phases of the vote flow:
19488
- // (A) round-prompt phase · chair has just prompted at round
19489
- // wrap; popover shows [Open vote] [Continue] [Adjourn].
19490
- // (B) round-end vote phase · awaitingContinue === true;
19491
- // popover shows the 3 key-points + Continue / Adjourn
19492
- // AFTER the chair has finished its TTS turn.
19493
- // The popover is suppressed while the chair is mid-presenting
19494
- // (preparing, streaming text, or audio still playing). The
19495
- // user wants to hear the chair speak first, THEN see the
19496
- // panel — without this gate the panel popped up under the
19497
- // chair's voice and split the user's attention.
19498
- const hasActivePrompt = (typeof this.activeRoundPromptId === "function")
19499
- ? !!this.activeRoundPromptId()
19500
- : false;
19501
- const isChairBusy = (() => {
19502
- if (this.chairPending === true) return true;
19503
- // Chair message landed but voice synthesis hasn't reached
19504
- // the client yet · the popover would otherwise flash for
19505
- // 0.5-2s before audio kicks in. See `_chairVoiceAwaiting`
19506
- // doc + the message-appended hook for the full reasoning.
19507
- if (this._chairVoiceAwaiting && this._chairVoiceAwaiting.size > 0) return true;
19508
- const allMsgs = this.currentMessages || [];
19509
- for (let k = allMsgs.length - 1; k >= 0; k--) {
19510
- const mm = allMsgs[k];
19511
- if (!mm || mm.authorKind !== "agent") continue;
19512
- // Most recent agent turn · streaming text OR active voice
19513
- // playback counts as "busy". Voice queues stay live for
19514
- // templated chair messages too (announceRoundPrompt), so
19515
- // this catches round-prompt voice as well as the LLM
19516
- // round-end stream.
19517
- if (mm.meta && mm.meta.streaming === true) return true;
19518
- if (this.voiceQueues && this.voiceQueues[mm.id]) return true;
19519
- return false;
19520
- }
19521
- return false;
19522
- })();
19523
- const showVotePop = isChair && this.currentRoom
19524
- && this.currentRoom.status !== "adjourned"
19525
- && (this.currentRoom.awaitingContinue === true || hasActivePrompt)
19526
- && !isChairBusy;
19527
- const votePop = showVotePop ? this.renderRoundTableVotePop() : "";
19528
-
19529
- // Seats whose y is below the stage midline (chair + any
19530
- // front-row directors) get `rt-seat-below`. CSS flips the
19531
- // name plate to sit BELOW the chair sprite for these so
19532
- // names of front-row seats don't land on the table surface
19533
- // and obscure the props.
19534
- const isBelow = seat.y > 50;
19535
- const cls = [
19536
- "rt-seat",
19537
- isChair ? "rt-seat-chair" : (isUser ? "rt-seat-user" : "rt-seat-director"),
19538
- isSpeaking ? "rt-seat-speaking" : "",
19539
- isSpeaking && speakerState === "thinking" ? "rt-seat-thinking" : "",
19540
- showVotePop ? "rt-seat-voting" : "",
19541
- isBelow ? "rt-seat-below" : "",
19542
- ].filter(Boolean).join(" ");
19523
+ // Stage SFX · thinking loop + speaker-change chime. The 2D
19524
+ // path used to compute speakingId/state itself; we reuse the
19525
+ // resolver above so this stays single-source.
19526
+ this._applyStageSfx(sp.id, sp.state);
19543
19527
 
19544
- // Inline style carries position + scaleHint + stagger index.
19545
- const style = [
19546
- `left: ${seat.x.toFixed(2)}%`,
19547
- `top: ${seat.y.toFixed(2)}%`,
19548
- `--rt-scale: ${seat.scaleHint.toFixed(3)}`,
19549
- `--seat-i: ${i}`,
19550
- ].join("; ");
19551
-
19552
- // Wait-marker · only on the user seat, only when the user
19553
- // has picked "wait — flush after current speaker finishes"
19554
- // (pendingUserMessage is set). Reads as a small pixel pill
19555
- // anchored to the seat so a glance at the table answers
19556
- // "is my queued message still parked?" — clears the moment
19557
- // the message-appended SSE for the user's body lands.
19558
- const waitMark = (isUser && this.pendingUserMessage)
19559
- ? `<div class="rt-seat-wait-mark" aria-label="Waiting for current speaker to finish">⌛&nbsp;WAIT</div>`
19560
- : "";
19561
-
19562
- return `
19563
- <div class="${cls}" data-seat-index="${i}" data-agent-id="${this.escape(m.id)}" style="${style}">
19564
- ${chairSvg}
19565
- ${avatar}
19566
- ${bubble}
19567
- ${badge}
19568
- ${waitMark}
19569
- ${name}
19570
- ${votePop}
19571
- </div>
19572
- `;
19573
- }).join("");
19574
-
19575
- // Empty state · no directors yet.
19576
- const empty = members.length <= 1
19577
- ? `<div class="rt-empty"><span>// awaiting directors</span></div>`
19578
- : "";
19579
-
19580
- seatsHost.innerHTML = html + empty;
19581
-
19582
- // Update aria-label to keep screen readers in sync with the
19583
- // visual state · the speaking-queue strip below remains the
19528
+ // Aria-label keeps screen readers in sync with the visible
19529
+ // speaker · the speaking-queue strip below remains the
19584
19530
  // canonical accessible source of truth, this is just a hint.
19585
19531
  const speaker = members.find((m) => m.id === speakingId);
19586
19532
  const queuedNames = (this.currentQueue || [])
@@ -19599,18 +19545,15 @@
19599
19545
  }
19600
19546
  stage.setAttribute("aria-label", aria);
19601
19547
 
19602
- // Persistent HUD repaint · cheap (single template literal +
19603
- // innerHTML write) and the data sources are the same room
19604
- // state already consulted above. Always called on round-table
19605
- // re-render so the stat block stays in sync with round /
19606
- // member / vote changes that don't fire toasts.
19548
+ // Persistent HUD + live subtitle repaint · cheap and the data
19549
+ // sources are the same room state already consulted above. The
19550
+ // message-token hot path calls renderRtSubtitle directly to
19551
+ // bypass full re-render cost; this call covers queue-update /
19552
+ // config-event / SSE hello cases that flow through here.
19607
19553
  this.renderRoundTableHud();
19608
- // Live subtitle · keep in sync with the same speaker-detection
19609
- // signals the seats use. Repaints triggered from message-token
19610
- // already call renderRtSubtitle directly to bypass the full
19611
- // seat re-render cost; this call covers queue-update / config-
19612
- // event / SSE hello cases that flow through renderRoundTable.
19613
19554
  this.renderRtSubtitle();
19555
+ return;
19556
+
19614
19557
  },
19615
19558
 
19616
19559
  /** Paint the top-left HUD console · gamified RPG-style status