privateboard 0.1.32 → 0.1.37

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.
@@ -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;
@@ -118,14 +132,29 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
118
132
  * · brainstorm · sky-blue garden walls (open / outdoor)
119
133
  * · constructive · graphite glass partitions (modern corporate)
120
134
  * · research · light-oak library walls
121
- * · debate · dark-oak wainscot (forum / chamber)
135
+ * · debate · warm oak wainscot (forum / chamber)
122
136
  * · critique · mahogany executive panel
123
- * Each entry: { wall, trim (baseboard), rail (chair-rail accent) }. */
137
+ * Each entry: { wall, trim (baseboard), rail (chair-rail accent) }.
138
+ * NOTE · brainstorm / constructive / critique entries are LEGACY
139
+ * fallbacks · refreshWallColors swaps in procedural textures
140
+ * (red brick / mossy stone / sandstone) for those tones. The
141
+ * palette stays around so any client that hits the painted-
142
+ * material branch (texture build failure, future reduced-detail
143
+ * mode) still gets a coherent fallback colour. debate uses the
144
+ * flat painted-wall path · the bright warm-oak tone reads as a
145
+ * lit forum panel without needing a procedural texture. */
124
146
  const WALL_PALETTE_BY_TONE = {
125
147
  brainstorm: { wall: 0xA3B8C4, trim: 0x5F7A8A, rail: 0x7B97A8 },
126
148
  constructive: { wall: 0x6E7480, trim: 0x42454D, rail: 0x575C66 },
127
149
  research: { wall: 0xD9CBAD, trim: 0x8B7355, rail: 0xB29874 },
128
- debate: { wall: 0x4E3A2A, trim: 0x231811, rail: 0x382821 },
150
+ // Bumped from 0x4E3A2A · the prior tone read as nearly black under
151
+ // the scene's ambient + key lighting, making the debate room feel
152
+ // closed and lightless. 0x856444 is a clear step up the warm-oak
153
+ // ladder: still distinctly darker than research's light oak, but
154
+ // bright enough that walls + trim + rail all register as wood
155
+ // colour rather than silhouette. Trim and rail nudged in
156
+ // proportion so the contrast ratio between layers stays consistent.
157
+ debate: { wall: 0x856444, trim: 0x4A3422, rail: 0x6B4F36 },
129
158
  critique: { wall: 0x6B3F2F, trim: 0x2A1612, rail: 0x4A2A20 },
130
159
  };
131
160
 
@@ -212,6 +241,12 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
212
241
  * the billboard / position-update loop. */
213
242
  const projVec = (typeof THREE !== "undefined") ? new THREE.Vector3() : null;
214
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
+
215
250
  /** Picking · click anywhere on a director sprite to open the
216
251
  * agent overlay (same modal the 2D head-cast avatar opens via
217
252
  * document delegation). One Raycaster + one Vector2 reused across
@@ -274,6 +309,87 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
274
309
  * features toward the camera. Sprite-head era needed this so the
275
310
  * texture stayed visible; voxel head doesn't. */
276
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
+
277
393
  /* ── Public API ─────────────────────────────────────────────── */
278
394
  /** WebGL availability cache · the test creates a throw-away canvas
279
395
  * + GL context, both of which count against Chrome's per-tab WebGL
@@ -321,6 +437,24 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
321
437
  // 2D fallback path can take over instantly if we ever unmount.
322
438
  stageEl.classList.add("is-3d");
323
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
+
324
458
  // DOM overlay layer · created BEFORE the canvas so the canvas
325
459
  // ends up the first child of the stage (background). The overlay
326
460
  // hosts every per-seat HTML element (nameplate, bubble) that we
@@ -570,6 +704,27 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
570
704
  try { unmount(); } catch (_) {}
571
705
  });
572
706
 
707
+ // Pre-warm the WebGL pipeline · the first WebGLRenderer created
708
+ // after an Electron / browser restart pays a one-time cost to
709
+ // initialise the GL context, compile shaders, and upload mesh /
710
+ // material buffers to the GPU. If the first rAF tick fires while
711
+ // any of that is still happening, `renderer.render()` silently
712
+ // produces an empty (pure black) frame and the user sees nothing
713
+ // until something nudges another render — which in practice means
714
+ // the user closes the overlay and reopens it. Forcing a synchronous
715
+ // `compile()` + `render()` here means the warm-up cost is paid
716
+ // BEFORE startRaf, and the very first rAF tick produces a real
717
+ // frame. Wrapped in try/catch so a WebGL hiccup doesn't abort
718
+ // mount · the rAF tick would retry the render anyway. The follow-
719
+ // up render produces output even when the scene has only the
720
+ // static chrome (walls / table / plants) because no chairs /
721
+ // avatars have been added yet · those land via the first
722
+ // VS3D.update() call after mount returns. */
723
+ try {
724
+ renderer.compile(scene, camera);
725
+ renderer.render(scene, camera);
726
+ } catch (_) { /* warm-up best-effort */ }
727
+
573
728
  // RAF loop · animations + render each frame.
574
729
  startRaf();
575
730
 
@@ -633,6 +788,20 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
633
788
  }
634
789
 
635
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;
636
805
  stopRaf();
637
806
  if (resizeObserver) {
638
807
  try { resizeObserver.disconnect(); } catch (_) {}
@@ -744,6 +913,16 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
744
913
  * }} state */
745
914
  function update(state) {
746
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
+ }
747
926
  const mode = (state && state.mode) || "constructive";
748
927
  rebuildFloor(mode);
749
928
  refreshWallColors(mode);
@@ -2719,10 +2898,20 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2719
2898
 
2720
2899
  /** Per-frame animation tick for seats:
2721
2900
  * · Non-speaker figures · gentle y-bob to look alive.
2722
- * · Active speaker · figure pinned at base y (no bob); the floor
2723
- * glow ring's opacity pulses instead, so the user's eye gets
2724
- * 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.
2725
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)
2726
2915
  function tickSeatAnimations() {
2727
2916
  if (!overlaySeats.length) return;
2728
2917
  const t = (typeof performance !== "undefined" ? performance.now() : Date.now()) * 0.001;
@@ -2733,6 +2922,17 @@ import { OrbitControls } from "/vendor/OrbitControls.js";
2733
2922
  seat.fig.position.y = isSpeaking
2734
2923
  ? 0
2735
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;
2736
2936
  }
2737
2937
  // Speaker halo pulse · 0.35 ↔ 0.75 at ~0.8 Hz (matches the
2738
2938
  // 2D `.rt-seat-speaking::before` glow rhythm).
Binary file