privateboard 0.1.36 → 0.1.38
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/dist/boot.js +1142 -50
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1142 -50
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1121 -50
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +3 -2
- package/public/adjourn-overlay.css +2 -2
- package/public/agent-overlay.css +27 -15
- package/public/agent-overlay.js +3 -1
- package/public/agent-profile.css +328 -32
- package/public/agent-profile.js +414 -43
- package/public/app-updater.css +1 -1
- package/public/app.js +1807 -35
- package/public/avatars/chair-blink.svg +1 -0
- package/public/home-3d-loader.js +6 -0
- package/public/home.html +3 -3
- package/public/i18n.js +279 -0
- package/public/icons/folded-sidebar.png +0 -0
- package/public/index.html +410 -147
- package/public/mention-picker.js +1 -1
- package/public/new-agent.css +7 -7
- package/public/onboarding.css +5 -5
- package/public/quote-cta.css +5 -4
- package/public/quote-cta.js +50 -5
- package/public/report.html +27 -7
- package/public/room-settings.css +24 -9
- package/public/thread.css +1211 -0
- package/public/user-settings.css +6 -6
- package/public/user-settings.js +37 -20
- package/public/voice-3d.js +167 -3
- package/public/voice-clone.css +875 -0
- package/public/voice-clone.js +1351 -0
- package/public/voice-replay.css +3 -3
- package/public/icons/search.png +0 -0
package/public/user-settings.css
CHANGED
|
@@ -537,7 +537,7 @@
|
|
|
537
537
|
content: ">";
|
|
538
538
|
color: var(--lime);
|
|
539
539
|
font-weight: 700;
|
|
540
|
-
font-size:
|
|
540
|
+
font-size: 14px;
|
|
541
541
|
font-family: var(--mono);
|
|
542
542
|
padding: 9px 0 0 11px;
|
|
543
543
|
align-self: flex-start;
|
|
@@ -547,7 +547,7 @@
|
|
|
547
547
|
border: none;
|
|
548
548
|
background: transparent;
|
|
549
549
|
font-family: var(--font-human);
|
|
550
|
-
font-size:
|
|
550
|
+
font-size: 14px;
|
|
551
551
|
line-height: 1.5;
|
|
552
552
|
color: var(--text);
|
|
553
553
|
outline: none;
|
|
@@ -857,7 +857,7 @@
|
|
|
857
857
|
}
|
|
858
858
|
.us-signout-text {
|
|
859
859
|
font-family: var(--font-human);
|
|
860
|
-
font-size:
|
|
860
|
+
font-size: 14px;
|
|
861
861
|
color: var(--text-soft);
|
|
862
862
|
line-height: 1.6;
|
|
863
863
|
}
|
|
@@ -1021,7 +1021,7 @@
|
|
|
1021
1021
|
font-weight: 600;
|
|
1022
1022
|
}
|
|
1023
1023
|
.us-chart-meta-value {
|
|
1024
|
-
font-size:
|
|
1024
|
+
font-size: 14px;
|
|
1025
1025
|
color: var(--text);
|
|
1026
1026
|
font-weight: 700;
|
|
1027
1027
|
font-variant-numeric: tabular-nums;
|
|
@@ -1293,7 +1293,7 @@
|
|
|
1293
1293
|
font-family: var(--mono, "Inter", system-ui, sans-serif);
|
|
1294
1294
|
}
|
|
1295
1295
|
.us-model-tokens {
|
|
1296
|
-
font-size:
|
|
1296
|
+
font-size: 14px;
|
|
1297
1297
|
color: var(--text);
|
|
1298
1298
|
font-weight: 700;
|
|
1299
1299
|
font-variant-numeric: tabular-nums;
|
|
@@ -2101,7 +2101,7 @@
|
|
|
2101
2101
|
.us-llm-pick-single { min-height: 64px; }
|
|
2102
2102
|
.us-llm-pick-label {
|
|
2103
2103
|
font-family: var(--mono);
|
|
2104
|
-
font-size:
|
|
2104
|
+
font-size: 14px;
|
|
2105
2105
|
font-weight: 700;
|
|
2106
2106
|
color: var(--text);
|
|
2107
2107
|
}
|
package/public/user-settings.js
CHANGED
|
@@ -2004,6 +2004,10 @@
|
|
|
2004
2004
|
});
|
|
2005
2005
|
|
|
2006
2006
|
// ✕ on voice / skill rows · server-side DELETE, then re-render.
|
|
2007
|
+
// Always confirms · API keys can't be recovered from the app once
|
|
2008
|
+
// removed, so accidental click on the ✕ shouldn't silently nuke
|
|
2009
|
+
// a paid credential. Same guard applied to all four credential
|
|
2010
|
+
// categories (provider / LLM / voice / search).
|
|
2007
2011
|
paneEl.querySelectorAll(".us-key-remove[data-remove-provider]").forEach((btn) => {
|
|
2008
2012
|
btn.addEventListener("click", async (e) => {
|
|
2009
2013
|
e.preventDefault();
|
|
@@ -2011,15 +2015,23 @@
|
|
|
2011
2015
|
if (btn.disabled) return;
|
|
2012
2016
|
const id = btn.dataset.removeProvider;
|
|
2013
2017
|
if (!id) return;
|
|
2018
|
+
const row = btn.closest(".us-key-row");
|
|
2019
|
+
const labelEl = row && row.querySelector(".us-key-label");
|
|
2020
|
+
const label = (labelEl && labelEl.textContent.trim()) || id;
|
|
2021
|
+
const tmpl = tr("us_provider_key_delete_confirm")
|
|
2022
|
+
|| "Remove this {label} API key? This can't be undone.";
|
|
2023
|
+
const msg = tmpl.replace("{label}", label);
|
|
2024
|
+
if (!window.confirm(msg)) return;
|
|
2014
2025
|
await setProviderKey(id, "");
|
|
2015
2026
|
await refreshModels();
|
|
2016
2027
|
rerenderKeysSection();
|
|
2017
2028
|
});
|
|
2018
2029
|
});
|
|
2019
2030
|
|
|
2020
|
-
// ✕ on credential rows · DELETE /api/credentials/:id.
|
|
2021
|
-
//
|
|
2022
|
-
//
|
|
2031
|
+
// ✕ on credential rows · DELETE /api/credentials/:id. Always
|
|
2032
|
+
// confirms — active credential gets the strong "will fall back"
|
|
2033
|
+
// warning, non-active gets a short "can't be undone" confirm.
|
|
2034
|
+
// Same pattern as the voice + search blocks below.
|
|
2023
2035
|
paneEl.querySelectorAll(".us-key-remove[data-remove-credential]").forEach((btn) => {
|
|
2024
2036
|
btn.addEventListener("click", async (e) => {
|
|
2025
2037
|
e.preventDefault();
|
|
@@ -2028,11 +2040,12 @@
|
|
|
2028
2040
|
const id = btn.dataset.removeCredential;
|
|
2029
2041
|
if (!id) return;
|
|
2030
2042
|
const isActive = id === activeLlmCredentialId();
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|| "Removing the active provider — the boardroom will fall back to the next added provider, or stop working until you add a new one. Continue?"
|
|
2034
|
-
|
|
2035
|
-
|
|
2043
|
+
const msg = isActive
|
|
2044
|
+
? (tr("us_active_llm_delete_active_confirm")
|
|
2045
|
+
|| "Removing the active provider — the boardroom will fall back to the next added provider, or stop working until you add a new one. Continue?")
|
|
2046
|
+
: (tr("us_llm_delete_confirm")
|
|
2047
|
+
|| "Remove this LLM credential? This can't be undone.");
|
|
2048
|
+
if (!window.confirm(msg)) return;
|
|
2036
2049
|
const ok = window.keysStore && typeof window.keysStore.deleteLlmCredentialRequest === "function"
|
|
2037
2050
|
? await window.keysStore.deleteLlmCredentialRequest(id)
|
|
2038
2051
|
: false;
|
|
@@ -2220,8 +2233,8 @@
|
|
|
2220
2233
|
/* ── Voice credential handlers · mirror LLM block above ────── */
|
|
2221
2234
|
|
|
2222
2235
|
// ✕ on voice credential rows · DELETE /api/voice-credentials/:id.
|
|
2223
|
-
//
|
|
2224
|
-
//
|
|
2236
|
+
// Always confirms — active credential warns about director-voice
|
|
2237
|
+
// reshuffle, non-active gets a short "can't be undone" confirm.
|
|
2225
2238
|
paneEl.querySelectorAll(".us-key-remove[data-remove-voice-credential]").forEach((btn) => {
|
|
2226
2239
|
btn.addEventListener("click", async (e) => {
|
|
2227
2240
|
e.preventDefault();
|
|
@@ -2230,11 +2243,12 @@
|
|
|
2230
2243
|
const id = btn.dataset.removeVoiceCredential;
|
|
2231
2244
|
if (!id) return;
|
|
2232
2245
|
const isActive = id === activeVoiceCredentialId();
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|| "Remove the active voice credential? Director voices reshuffle from the next available credential."
|
|
2236
|
-
|
|
2237
|
-
|
|
2246
|
+
const msg = isActive
|
|
2247
|
+
? (tr("voice_cred_delete_confirm_active")
|
|
2248
|
+
|| "Remove the active voice credential? Director voices reshuffle from the next available credential.")
|
|
2249
|
+
: (tr("voice_cred_delete_confirm")
|
|
2250
|
+
|| "Remove this voice credential? This can't be undone.");
|
|
2251
|
+
if (!window.confirm(msg)) return;
|
|
2238
2252
|
const ok = window.keysStore && typeof window.keysStore.deleteVoiceCredentialRequest === "function"
|
|
2239
2253
|
? await window.keysStore.deleteVoiceCredentialRequest(id)
|
|
2240
2254
|
: false;
|
|
@@ -2383,6 +2397,8 @@
|
|
|
2383
2397
|
|
|
2384
2398
|
/* ── Search credential handlers · mirror voice block ─────── */
|
|
2385
2399
|
|
|
2400
|
+
// Always confirms — active gets the rotation warning, non-active
|
|
2401
|
+
// gets a short "can't be undone" confirm.
|
|
2386
2402
|
paneEl.querySelectorAll(".us-key-remove[data-remove-search-credential]").forEach((btn) => {
|
|
2387
2403
|
btn.addEventListener("click", async (e) => {
|
|
2388
2404
|
e.preventDefault();
|
|
@@ -2391,11 +2407,12 @@
|
|
|
2391
2407
|
const id = btn.dataset.removeSearchCredential;
|
|
2392
2408
|
if (!id) return;
|
|
2393
2409
|
const isActive = id === activeSearchCredentialId();
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|| "Remove the active search credential? Web Search will rotate to the next available credential, or stop working until you add one."
|
|
2397
|
-
|
|
2398
|
-
|
|
2410
|
+
const msg = isActive
|
|
2411
|
+
? (tr("search_cred_delete_confirm_active")
|
|
2412
|
+
|| "Remove the active search credential? Web Search will rotate to the next available credential, or stop working until you add one.")
|
|
2413
|
+
: (tr("search_cred_delete_confirm")
|
|
2414
|
+
|| "Remove this search credential? This can't be undone.");
|
|
2415
|
+
if (!window.confirm(msg)) return;
|
|
2399
2416
|
const ok = window.keysStore && typeof window.keysStore.deleteSearchCredentialRequest === "function"
|
|
2400
2417
|
? await window.keysStore.deleteSearchCredentialRequest(id)
|
|
2401
2418
|
: false;
|
package/public/voice-3d.js
CHANGED
|
@@ -62,6 +62,20 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
|
|
|
62
62
|
// burn on low-end machines.
|
|
63
63
|
let intersectionObserver = null;
|
|
64
64
|
|
|
65
|
+
/* ── Loading overlay state ──────────────────────────────────
|
|
66
|
+
Shown synchronously inside mount() BEFORE WebGL init so the
|
|
67
|
+
user sees a "something is loading" cue instead of a black
|
|
68
|
+
box during the ~200-800 ms gap until the first frame paints
|
|
69
|
+
with avatar textures. Three surfaces benefit: marketing home
|
|
70
|
+
hero, in-app round-table on room open (mobile + desktop),
|
|
71
|
+
and the onboarding voice banner. Hidden on the first
|
|
72
|
+
`update()` call (which is when the populated scene is about
|
|
73
|
+
to paint) with a safety-net timeout in case `update()` is
|
|
74
|
+
never reached. */
|
|
75
|
+
let loadingEl = null;
|
|
76
|
+
let loadingHideTimer = 0;
|
|
77
|
+
let loadingFirstUpdateSeen = false;
|
|
78
|
+
|
|
65
79
|
/** Voxel pixel resolution · how many world units per "voxel cell".
|
|
66
80
|
* Drives the visual chunkiness — bigger value = chunkier blocks. */
|
|
67
81
|
const VOXEL = 0.18;
|
|
@@ -227,6 +241,12 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
|
|
|
227
241
|
* the billboard / position-update loop. */
|
|
228
242
|
const projVec = (typeof THREE !== "undefined") ? new THREE.Vector3() : null;
|
|
229
243
|
|
|
244
|
+
/** Reduced-motion · honour the OS setting by holding the figure at
|
|
245
|
+
* full height (no squash-blink). Read once at load; matches the 2D
|
|
246
|
+
* path's `@media (prefers-reduced-motion: reduce)` blink opt-out. */
|
|
247
|
+
const prefersReducedMotion = (typeof matchMedia === "function")
|
|
248
|
+
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
249
|
+
|
|
230
250
|
/** Picking · click anywhere on a director sprite to open the
|
|
231
251
|
* agent overlay (same modal the 2D head-cast avatar opens via
|
|
232
252
|
* document delegation). One Raycaster + one Vector2 reused across
|
|
@@ -289,6 +309,87 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
|
|
|
289
309
|
* features toward the camera. Sprite-head era needed this so the
|
|
290
310
|
* texture stayed visible; voxel head doesn't. */
|
|
291
311
|
|
|
312
|
+
/* ── Loading overlay ───────────────────────────────────────────
|
|
313
|
+
Painted into `host` synchronously from mount() so the user
|
|
314
|
+
sees a pulsing indicator instead of a black box while WebGL
|
|
315
|
+
warms up and avatar SVGs decode. Hidden when the first
|
|
316
|
+
`update()` call lands (scene is about to populate) with a
|
|
317
|
+
safety-net auto-hide at 2.5 s in case update() never fires
|
|
318
|
+
(e.g. caller mounts then immediately unmounts). */
|
|
319
|
+
function ensureLoadingStyle() {
|
|
320
|
+
if (document.getElementById("voice-3d-loading-style")) return;
|
|
321
|
+
const st = document.createElement("style");
|
|
322
|
+
st.id = "voice-3d-loading-style";
|
|
323
|
+
st.textContent = `
|
|
324
|
+
[data-rt-3d-loading] {
|
|
325
|
+
position: absolute; inset: 0; z-index: 3;
|
|
326
|
+
display: flex; align-items: center; justify-content: center;
|
|
327
|
+
background: rgba(0, 0, 0, 0.42);
|
|
328
|
+
pointer-events: none;
|
|
329
|
+
opacity: 1; transition: opacity 320ms ease;
|
|
330
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, "JetBrains Mono", monospace;
|
|
331
|
+
}
|
|
332
|
+
[data-rt-3d-loading].is-hiding { opacity: 0; }
|
|
333
|
+
[data-rt-3d-loading-inner] {
|
|
334
|
+
display: inline-flex; flex-direction: column; align-items: center; gap: 12px;
|
|
335
|
+
}
|
|
336
|
+
[data-rt-3d-loading-bar] {
|
|
337
|
+
display: inline-flex; gap: 5px; image-rendering: pixelated;
|
|
338
|
+
}
|
|
339
|
+
[data-rt-3d-loading-bar] i {
|
|
340
|
+
width: 8px; height: 8px;
|
|
341
|
+
background: rgba(255, 255, 255, 0.92);
|
|
342
|
+
display: inline-block;
|
|
343
|
+
animation: rt3d-load-pulse 1.05s ease-in-out infinite;
|
|
344
|
+
}
|
|
345
|
+
[data-rt-3d-loading-bar] i:nth-child(2) { animation-delay: 0.14s; }
|
|
346
|
+
[data-rt-3d-loading-bar] i:nth-child(3) { animation-delay: 0.28s; }
|
|
347
|
+
[data-rt-3d-loading-label] {
|
|
348
|
+
font-size: 10px;
|
|
349
|
+
letter-spacing: 0.22em;
|
|
350
|
+
text-transform: uppercase;
|
|
351
|
+
color: rgba(255, 255, 255, 0.78);
|
|
352
|
+
}
|
|
353
|
+
@keyframes rt3d-load-pulse {
|
|
354
|
+
0%, 70%, 100% { opacity: 0.22; transform: translateY(0); }
|
|
355
|
+
35% { opacity: 1; transform: translateY(-4px); }
|
|
356
|
+
}
|
|
357
|
+
`;
|
|
358
|
+
document.head.appendChild(st);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function showLoadingOverlay(host, label) {
|
|
362
|
+
ensureLoadingStyle();
|
|
363
|
+
const el = document.createElement("div");
|
|
364
|
+
el.setAttribute("data-rt-3d-loading", "");
|
|
365
|
+
// Use textContent for the label · caller-supplied string, must
|
|
366
|
+
// not interpolate as HTML. The 8-bit dot trio is fixed markup.
|
|
367
|
+
const inner = document.createElement("span");
|
|
368
|
+
inner.setAttribute("data-rt-3d-loading-inner", "");
|
|
369
|
+
inner.innerHTML = `<span data-rt-3d-loading-bar><i></i><i></i><i></i></span>`;
|
|
370
|
+
if (label) {
|
|
371
|
+
const cap = document.createElement("span");
|
|
372
|
+
cap.setAttribute("data-rt-3d-loading-label", "");
|
|
373
|
+
cap.textContent = String(label);
|
|
374
|
+
inner.appendChild(cap);
|
|
375
|
+
}
|
|
376
|
+
el.appendChild(inner);
|
|
377
|
+
host.appendChild(el);
|
|
378
|
+
return el;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function hideLoadingOverlay() {
|
|
382
|
+
if (loadingHideTimer) {
|
|
383
|
+
clearTimeout(loadingHideTimer);
|
|
384
|
+
loadingHideTimer = 0;
|
|
385
|
+
}
|
|
386
|
+
const el = loadingEl;
|
|
387
|
+
loadingEl = null;
|
|
388
|
+
if (!el) return;
|
|
389
|
+
el.classList.add("is-hiding");
|
|
390
|
+
setTimeout(() => { try { el.remove(); } catch (_) {} }, 380);
|
|
391
|
+
}
|
|
392
|
+
|
|
292
393
|
/* ── Public API ─────────────────────────────────────────────── */
|
|
293
394
|
/** WebGL availability cache · the test creates a throw-away canvas
|
|
294
395
|
* + GL context, both of which count against Chrome's per-tab WebGL
|
|
@@ -336,6 +437,24 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
|
|
|
336
437
|
// 2D fallback path can take over instantly if we ever unmount.
|
|
337
438
|
stageEl.classList.add("is-3d");
|
|
338
439
|
|
|
440
|
+
// Loading overlay · drop in BEFORE we touch WebGL so the user
|
|
441
|
+
// never sees a black void during the shader-compile + avatar-
|
|
442
|
+
// fetch gap. Optional `opts.loadingLabel` lets callers stamp a
|
|
443
|
+
// context-appropriate caption (e.g. "Convening" on onboarding).
|
|
444
|
+
// Set `opts.loading: false` to suppress entirely · the marketing
|
|
445
|
+
// home uses this because the poster img already covers the gap
|
|
446
|
+
// (overlaying a darkening veil on the poster would just dim it).
|
|
447
|
+
loadingFirstUpdateSeen = false;
|
|
448
|
+
const wantsLoading = !(opts && opts.loading === false);
|
|
449
|
+
if (wantsLoading) {
|
|
450
|
+
const loadingLabel = (opts && typeof opts.loadingLabel === "string") ? opts.loadingLabel : "";
|
|
451
|
+
loadingEl = showLoadingOverlay(stageEl, loadingLabel);
|
|
452
|
+
// Safety net · if the caller mounts but never calls update()
|
|
453
|
+
// (e.g. they unmount in the same tick due to a race), the
|
|
454
|
+
// overlay would linger forever. Auto-hide after 2.5 s.
|
|
455
|
+
loadingHideTimer = setTimeout(() => hideLoadingOverlay(), 2500);
|
|
456
|
+
}
|
|
457
|
+
|
|
339
458
|
// DOM overlay layer · created BEFORE the canvas so the canvas
|
|
340
459
|
// ends up the first child of the stage (background). The overlay
|
|
341
460
|
// hosts every per-seat HTML element (nameplate, bubble) that we
|
|
@@ -669,6 +788,20 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
|
|
|
669
788
|
}
|
|
670
789
|
|
|
671
790
|
function unmount() {
|
|
791
|
+
// Loading overlay cleanup · if we're unmounting before the
|
|
792
|
+
// overlay had a chance to fade, yank it now so it doesn't
|
|
793
|
+
// outlive the stage (the host's child list survives unmount
|
|
794
|
+
// for the 2D fallback path; a stranded overlay would sit on
|
|
795
|
+
// top of the 2D pixel art).
|
|
796
|
+
if (loadingHideTimer) {
|
|
797
|
+
clearTimeout(loadingHideTimer);
|
|
798
|
+
loadingHideTimer = 0;
|
|
799
|
+
}
|
|
800
|
+
if (loadingEl && loadingEl.parentNode) {
|
|
801
|
+
try { loadingEl.parentNode.removeChild(loadingEl); } catch (_) {}
|
|
802
|
+
}
|
|
803
|
+
loadingEl = null;
|
|
804
|
+
loadingFirstUpdateSeen = false;
|
|
672
805
|
stopRaf();
|
|
673
806
|
if (resizeObserver) {
|
|
674
807
|
try { resizeObserver.disconnect(); } catch (_) {}
|
|
@@ -780,6 +913,16 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
|
|
|
780
913
|
* }} state */
|
|
781
914
|
function update(state) {
|
|
782
915
|
if (!scene) return;
|
|
916
|
+
// First update after mount = scene is about to populate (seats,
|
|
917
|
+
// avatars, speaker indicator). Schedule the loading overlay to
|
|
918
|
+
// fade out shortly after so the first populated frame has a
|
|
919
|
+
// chance to paint underneath. 320 ms covers the CSS transition
|
|
920
|
+
// duration; the overlay sits on z-index 3 (above the canvas /
|
|
921
|
+
// DOM overlay) so it veils the scene crisply during the gap.
|
|
922
|
+
if (loadingEl && !loadingFirstUpdateSeen) {
|
|
923
|
+
loadingFirstUpdateSeen = true;
|
|
924
|
+
setTimeout(() => hideLoadingOverlay(), 320);
|
|
925
|
+
}
|
|
783
926
|
const mode = (state && state.mode) || "constructive";
|
|
784
927
|
rebuildFloor(mode);
|
|
785
928
|
refreshWallColors(mode);
|
|
@@ -2755,10 +2898,20 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
|
|
|
2755
2898
|
|
|
2756
2899
|
/** Per-frame animation tick for seats:
|
|
2757
2900
|
* · Non-speaker figures · gentle y-bob to look alive.
|
|
2758
|
-
* · Active speaker · figure pinned at base y (no bob)
|
|
2759
|
-
*
|
|
2760
|
-
*
|
|
2901
|
+
* · Active speaker · figure pinned at base y (no bob), with an
|
|
2902
|
+
* occasional quick scaleY squash that reads as a blink (the
|
|
2903
|
+
* billboard's eyes are baked into the texture, so a vertical
|
|
2904
|
+
* squash is the cheapest "alive while talking" cue); the floor
|
|
2905
|
+
* glow ring's opacity pulses too so the eye gets drawn to the
|
|
2906
|
+
* right seat.
|
|
2761
2907
|
* Cheap: one sin + a couple of property writes per seat. */
|
|
2908
|
+
// Squash-blink envelope · most of the period the figure is full
|
|
2909
|
+
// height; a short dip near the start of each period reads as a
|
|
2910
|
+
// blink. Per-seat phase offset (bobPhase) desyncs the cast so they
|
|
2911
|
+
// never blink in unison.
|
|
2912
|
+
const BLINK_PERIOD = 4.2; // seconds between blinks
|
|
2913
|
+
const BLINK_DUR = 0.18; // seconds the close+open takes
|
|
2914
|
+
const BLINK_DEPTH = 0.12; // peak scaleY reduction (1 → 0.88)
|
|
2762
2915
|
function tickSeatAnimations() {
|
|
2763
2916
|
if (!overlaySeats.length) return;
|
|
2764
2917
|
const t = (typeof performance !== "undefined" ? performance.now() : Date.now()) * 0.001;
|
|
@@ -2769,6 +2922,17 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
|
|
|
2769
2922
|
seat.fig.position.y = isSpeaking
|
|
2770
2923
|
? 0
|
|
2771
2924
|
: Math.sin(t * 2.6 + seat.bobPhase) * 0.025;
|
|
2925
|
+
// Speaking squash-blink · scale the figure group's height
|
|
2926
|
+
// down briefly (anchored at the chair seat, so it compresses
|
|
2927
|
+
// toward the body). Non-speakers and reduced-motion hold at 1.
|
|
2928
|
+
let sy = 1;
|
|
2929
|
+
if (isSpeaking && !prefersReducedMotion) {
|
|
2930
|
+
const phase = (t + seat.bobPhase) % BLINK_PERIOD;
|
|
2931
|
+
if (phase < BLINK_DUR) {
|
|
2932
|
+
sy = 1 - BLINK_DEPTH * Math.sin((phase / BLINK_DUR) * Math.PI);
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
if (seat.fig.scale.y !== sy) seat.fig.scale.y = sy;
|
|
2772
2936
|
}
|
|
2773
2937
|
// Speaker halo pulse · 0.35 ↔ 0.75 at ~0.8 Hz (matches the
|
|
2774
2938
|
// 2D `.rt-seat-speaking::before` glow rhythm).
|