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.
@@ -537,7 +537,7 @@
537
537
  content: ">";
538
538
  color: var(--lime);
539
539
  font-weight: 700;
540
- font-size: 13px;
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: 13px;
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: 13px;
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: 13px;
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: 13px;
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: 13px;
2104
+ font-size: 14px;
2105
2105
  font-weight: 700;
2106
2106
  color: var(--text);
2107
2107
  }
@@ -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. If the
2021
- // credential is the currently active one, pop a confirm() first
2022
- // (matches the user's explicit ask: "删除后将无法正常使用").
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
- if (isActive) {
2032
- const msg = tr("us_active_llm_delete_active_confirm")
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
- if (!window.confirm(msg)) return;
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
- // Active credential gets a confirm prompt that warns the directors'
2224
- // voices will reshuffle.
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
- if (isActive) {
2234
- const msg = tr("voice_cred_delete_confirm_active")
2235
- || "Remove the active voice credential? Director voices reshuffle from the next available credential.";
2236
- if (!window.confirm(msg)) return;
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
- if (isActive) {
2395
- const msg = tr("search_cred_delete_confirm_active")
2396
- || "Remove the active search credential? Web Search will rotate to the next available credential, or stop working until you add one.";
2397
- if (!window.confirm(msg)) return;
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;
@@ -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); the floor
2759
- * glow ring's opacity pulses instead, so the user's eye gets
2760
- * drawn to the right seat.
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).