privateboard 0.1.37 → 0.1.40

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 (76) hide show
  1. package/dist/boot.js +1415 -91
  2. package/dist/boot.js.map +1 -1
  3. package/dist/cli.js +1415 -91
  4. package/dist/cli.js.map +1 -1
  5. package/dist/server.js +1271 -81
  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/adjourn-overlay.css +2 -2
  13. package/public/agent-overlay.css +27 -15
  14. package/public/agent-overlay.js +3 -1
  15. package/public/agent-profile.css +331 -41
  16. package/public/agent-profile.js +499 -75
  17. package/public/app-updater.css +1 -1
  18. package/public/app.js +2090 -547
  19. package/public/avatar-3d-snap.js +205 -0
  20. package/public/avatar-3d.js +792 -0
  21. package/public/avatar-customizer.html +274 -0
  22. package/public/avatar3d-editor.css +240 -0
  23. package/public/avatar3d-editor.js +481 -0
  24. package/public/avatars/3d/chair.png +0 -0
  25. package/public/avatars/3d/first-principles.png +0 -0
  26. package/public/avatars/3d/historian.png +0 -0
  27. package/public/avatars/3d/long-horizon.png +0 -0
  28. package/public/avatars/3d/phenomenologist.png +0 -0
  29. package/public/avatars/3d/socrates.png +0 -0
  30. package/public/avatars/3d/user-empathy.png +0 -0
  31. package/public/avatars/3d/value-investor.png +0 -0
  32. package/public/core-avatars.js +86 -0
  33. package/public/home-3d-loader.js +15 -4
  34. package/public/home-3d-mock.js +18 -7
  35. package/public/home.html +80 -18
  36. package/public/i18n.js +279 -4
  37. package/public/icons/avatar_1779855104027.glb +0 -0
  38. package/public/icons/logo.png +0 -0
  39. package/public/icons/new-style.glb +0 -0
  40. package/public/icons/new-style2.glb +0 -0
  41. package/public/icons/new-style3.glb +0 -0
  42. package/public/icons/new-style4.glb +0 -0
  43. package/public/icons/new-style5.glb +0 -0
  44. package/public/icons/office.glb +0 -0
  45. package/public/icons/stuff.glb +0 -0
  46. package/public/index.html +203 -182
  47. package/public/mention-picker.js +1 -1
  48. package/public/new-agent.css +7 -7
  49. package/public/new-agent.js +46 -20
  50. package/public/office-viewer.html +340 -0
  51. package/public/onboarding.css +5 -5
  52. package/public/quote-cta.css +5 -4
  53. package/public/quote-cta.js +50 -5
  54. package/public/room-settings.css +24 -9
  55. package/public/stuff-viewer.html +330 -0
  56. package/public/thread.css +1211 -0
  57. package/public/user-settings.css +16 -19
  58. package/public/user-settings.js +86 -78
  59. package/public/vendor/BufferGeometryUtils.js +1434 -0
  60. package/public/vendor/DRACOLoader.js +739 -0
  61. package/public/vendor/GLTFLoader.js +4860 -0
  62. package/public/vendor/RoomEnvironment.js +185 -0
  63. package/public/vendor/SkeletonUtils.js +496 -0
  64. package/public/vendor/draco/draco_decoder.js +34 -0
  65. package/public/vendor/draco/draco_decoder.wasm +0 -0
  66. package/public/vendor/draco/draco_encoder.js +33 -0
  67. package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
  68. package/public/vendor/meshopt_decoder.module.js +196 -0
  69. package/public/voice-3d-banner.js +12 -0
  70. package/public/voice-3d.js +1407 -432
  71. package/public/voice-clone.css +875 -0
  72. package/public/voice-clone.js +1351 -0
  73. package/public/voice-replay.css +3 -3
  74. package/public/voice-replay.js +21 -0
  75. package/public/avatar-skill.js +0 -629
  76. package/public/icons/folded-sidebar.png +0 -0
@@ -64,7 +64,7 @@
64
64
  flex-direction: column;
65
65
  gap: 4px;
66
66
  font-family: var(--sans, system-ui), sans-serif;
67
- font-size: 13px;
67
+ font-size: 14px;
68
68
  color: var(--text);
69
69
  }
70
70
  .mention-picker[hidden] { display: none; }
@@ -304,7 +304,7 @@
304
304
  }
305
305
  .na-avatar-regen .icon {
306
306
  font-family: var(--mono);
307
- font-size: 13px;
307
+ font-size: 14px;
308
308
  font-weight: 400;
309
309
  line-height: 1;
310
310
  }
@@ -362,7 +362,7 @@
362
362
  color: var(--lime);
363
363
  }
364
364
  .na-model-trigger .trigger-l .name {
365
- font-size: 13px;
365
+ font-size: 14px;
366
366
  font-weight: 700;
367
367
  color: var(--text);
368
368
  letter-spacing: -0.003em;
@@ -779,7 +779,7 @@
779
779
  border: none;
780
780
  background: transparent;
781
781
  font-family: var(--font-human);
782
- font-size: 13px;
782
+ font-size: 14px;
783
783
  line-height: 1.5;
784
784
  color: var(--text);
785
785
  outline: none;
@@ -951,7 +951,7 @@
951
951
  margin-top: 6px;
952
952
  align-self: flex-start;
953
953
  }
954
- .new-agent-overlay .na-rule-add .plus { color: var(--lime); font-size: 13px; line-height: 1; }
954
+ .new-agent-overlay .na-rule-add .plus { color: var(--lime); font-size: 14px; line-height: 1; }
955
955
  .new-agent-overlay .na-rule-add:hover { border-color: var(--lime); color: var(--lime); border-style: solid; }
956
956
  .new-agent-overlay .na-rule-add[disabled],
957
957
  .new-agent-overlay .na-rule-add.at-cap {
@@ -1119,7 +1119,7 @@
1119
1119
  border: 0.5px solid var(--line-bright);
1120
1120
  color: var(--text-faint);
1121
1121
  font-family: var(--mono);
1122
- font-size: 13px;
1122
+ font-size: 14px;
1123
1123
  line-height: 1;
1124
1124
  cursor: pointer;
1125
1125
  display: flex;
@@ -1352,7 +1352,7 @@
1352
1352
  .new-agent-overlay .na-textarea-wrap.tall textarea {
1353
1353
  min-height: 220px;
1354
1354
  font-family: var(--mono);
1355
- font-size: 13px;
1355
+ font-size: 14px;
1356
1356
  line-height: 1.5;
1357
1357
  resize: vertical;
1358
1358
  overflow-y: auto;
@@ -1382,7 +1382,7 @@
1382
1382
  font-weight: 700;
1383
1383
  letter-spacing: -0.005em;
1384
1384
  font-family: var(--font-human);
1385
- font-size: 13px;
1385
+ font-size: 14px;
1386
1386
  }
1387
1387
  .na-summary-handle { color: var(--lime); }
1388
1388
  .na-foot-sep { color: var(--text-faint); }
@@ -141,12 +141,15 @@
141
141
  const rng = makeRng(seed);
142
142
  return [0,0,0,0].map(() => Math.floor(rng() * 16).toString(16)).join("");
143
143
  }
144
- // Avatar generation delegates to the shared AvatarSkill
145
- // (see public/avatar-skill.js). One source of truth for the
146
- // 8-bit pixel-art look used here, in user settings, and anywhere
147
- // else that wants a director-style avatar.
148
- function generateAvatar(seed, opts) {
149
- return window.AvatarSkill.generate(seed, opts);
144
+ // Avatar generation delegates to the shared Avatar3DSnap helper
145
+ // (public/avatar-3d-snap.js). One source of truth for the 3D
146
+ // voxel head-and-shoulders portrait used everywhere a director
147
+ // face appears. The legacy AvatarSkill 8-bit SVG generator was
148
+ // retired in favour of this 3D pipeline.
149
+ async function generateAvatarDataUrl(seed) {
150
+ const snap = window.Avatar3DSnap;
151
+ if (!snap || typeof snap.generate !== "function") return "";
152
+ return snap.generate(seed);
150
153
  }
151
154
 
152
155
  function modalHTML() {
@@ -612,16 +615,37 @@
612
615
  if (!frame) return;
613
616
  if (avatarState.placeholder) {
614
617
  frame.classList.add("placeholder");
615
- frame.innerHTML = generateAvatar("__placeholder__", { placeholder: true });
618
+ // Empty frame · the wrapper's CSS placeholder (initial letter
619
+ // / texture) is the resting state until the user clicks
620
+ // regenerate. The legacy 8-bit SVG silhouette was retired.
621
+ frame.innerHTML = "";
616
622
  if (seedEl) seedEl.textContent = "—";
617
623
  if (rollEl) rollEl.textContent = "";
618
- } else {
619
- frame.classList.remove("placeholder");
620
- const seedKey = avatarState.seed + "::" + avatarState.roll;
621
- frame.innerHTML = generateAvatar(seedKey);
622
- if (seedEl) seedEl.textContent = shortHash(avatarState.seed);
623
- if (rollEl) rollEl.textContent = " · #" + avatarState.roll;
624
+ return;
625
+ }
626
+ frame.classList.remove("placeholder");
627
+ const seedKey = avatarState.seed + "::" + avatarState.roll;
628
+ if (seedEl) seedEl.textContent = shortHash(avatarState.seed);
629
+ if (rollEl) rollEl.textContent = " · #" + avatarState.roll;
630
+ const snap = window.Avatar3DSnap;
631
+ const cached = snap && typeof snap.cacheGet === "function" ? snap.cacheGet(seedKey) : null;
632
+ if (cached) {
633
+ frame.innerHTML = `<img src="${cached}" alt="">`;
634
+ return;
624
635
  }
636
+ frame.innerHTML = '<div class="na-avatar-loading" aria-hidden="true">…</div>';
637
+ if (!snap || typeof snap.generate !== "function") return;
638
+ const expected = seedKey;
639
+ snap.generate(seedKey).then((dataUrl) => {
640
+ if (!dataUrl) return;
641
+ // Bail if the user has already rolled again — only the latest
642
+ // seed's render should land in the frame.
643
+ const cur = avatarState.placeholder ? null : (avatarState.seed + "::" + avatarState.roll);
644
+ if (cur !== expected) return;
645
+ const f2 = modal.querySelector("[data-na-avatar]");
646
+ if (!f2) return;
647
+ f2.innerHTML = `<img src="${dataUrl}" alt="">`;
648
+ }).catch(() => { /* */ });
625
649
  }
626
650
 
627
651
  function positionDropdown() {
@@ -665,10 +689,10 @@
665
689
  }
666
690
 
667
691
  /** Regenerate the avatar by asking the LLM for a "vibe seed" derived
668
- * from the director's name + bio, then painting the SVG via the
669
- * shared AvatarSkill. Falls back to a local random seed if the
670
- * endpoint errors (no key, network, etc.) so the button always
671
- * produces a fresh face. */
692
+ * from the director's name + bio, then painting the 3D portrait
693
+ * via the shared Avatar3DSnap. Falls back to a local random seed
694
+ * if the endpoint errors (no key, network, etc.) so the button
695
+ * always produces a fresh face. */
672
696
  async function regenerateAvatar() {
673
697
  const name = modal.querySelector(".na-name-input").value.trim();
674
698
  const desc = modal.querySelector(".na-desc-input").value.trim();
@@ -682,7 +706,7 @@
682
706
  // Without a name, just produce a random seed locally — no point
683
707
  // burning an LLM call on an empty form.
684
708
  if (!name) {
685
- avatarState.seed = (window.AvatarSkill?.randomSeed?.() || ("anon|" + Date.now()));
709
+ avatarState.seed = (window.Avatar3DSnap?.randomSeed?.() || ("anon|" + Date.now()));
686
710
  if (vibeEl) vibeEl.textContent = "";
687
711
  paintAvatar();
688
712
  return;
@@ -800,14 +824,16 @@
800
824
 
801
825
  // Avatar → data URL. If the user never clicked "regenerate", we
802
826
  // build one off the form values now so the agent has a real face.
827
+ // The render is async (3D portrait via Avatar3DSnap); falls back
828
+ // to an empty string when WebGL isn't available — the backend
829
+ // already tolerates a missing avatarPath.
803
830
  let avatarSeed = avatarState.seed;
804
831
  let avatarRoll = avatarState.roll || 1;
805
832
  if (avatarState.placeholder) {
806
833
  avatarSeed = (name + "|" + bio) || "anon";
807
834
  avatarRoll = 1;
808
835
  }
809
- const svg = generateAvatar(avatarSeed + "::" + avatarRoll);
810
- const avatarPath = "data:image/svg+xml;utf8," + encodeURIComponent(svg);
836
+ const avatarPath = (await generateAvatarDataUrl(avatarSeed + "::" + avatarRoll)) || "";
811
837
 
812
838
  // Lock the button while the request is in flight.
813
839
  create.disabled = true;
@@ -0,0 +1,340 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>office.glb · viewer</title>
6
+ <style>
7
+ html, body { margin: 0; height: 100%; background: #16181c; overflow: hidden; color: #cde; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
8
+ #status {
9
+ position: fixed; top: 8px; left: 10px;
10
+ color: #9fe; z-index: 9; white-space: pre-wrap;
11
+ max-width: 60vw;
12
+ padding: 6px 10px;
13
+ background: rgba(0, 0, 0, 0.45);
14
+ border: 0.5px solid #2c3038;
15
+ border-radius: 4px;
16
+ }
17
+ #status.is-err { color: #f88; border-color: #5a2a2a; }
18
+ #info {
19
+ position: fixed; bottom: 10px; left: 10px; z-index: 9;
20
+ background: rgba(0, 0, 0, 0.55);
21
+ border: 0.5px solid #2c3038;
22
+ border-radius: 4px;
23
+ padding: 8px 12px;
24
+ min-width: 220px;
25
+ }
26
+ #info h1 { margin: 0 0 6px; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #6ad29a; font-weight: 600; }
27
+ #info dl { margin: 0; display: grid; grid-template-columns: auto 1fr; column-gap: 12px; row-gap: 2px; font-size: 11px; }
28
+ #info dt { color: #7a8290; }
29
+ #info dd { margin: 0; color: #cde; }
30
+ #panel {
31
+ position: fixed; top: 8px; right: 10px; z-index: 9;
32
+ background: rgba(0, 0, 0, 0.55);
33
+ border: 0.5px solid #2c3038; border-radius: 4px;
34
+ padding: 10px 12px; width: 230px;
35
+ }
36
+ #panel label { display: block; margin: 6px 0 2px; color: #aab2c0; }
37
+ #panel input[type=range] { width: 100%; accent-color: #6ad29a; }
38
+ #panel b { color: #6ad29a; }
39
+ #panel button {
40
+ width: 100%; margin-top: 8px; padding: 6px;
41
+ background: #232a36; color: #cde;
42
+ border: 0.5px solid #3a414d; border-radius: 4px;
43
+ font: 11px ui-monospace, monospace; cursor: pointer;
44
+ }
45
+ #panel button:hover { border-color: #6ad29a; color: #6ad29a; }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <div id="status">booting…</div>
50
+ <div id="panel">
51
+ <label>exposure · <b id="vExp">1.00</b></label>
52
+ <input type="range" id="exp" min="0.2" max="2.0" step="0.01" value="1.00">
53
+ <label>env intensity · <b id="vEnv">0.85</b></label>
54
+ <input type="range" id="env" min="0" max="2" step="0.01" value="0.85">
55
+ <label>key light · <b id="vKey">1.00</b></label>
56
+ <input type="range" id="key" min="0" max="3" step="0.01" value="1.00">
57
+ <label>rotate model · <b id="vRot">auto</b></label>
58
+ <input type="range" id="rot" min="0" max="360" step="1" value="0">
59
+ <button id="autoRotateToggle">↻ auto-rotate: ON</button>
60
+ <button id="reframeBtn">⤧ re-fit camera</button>
61
+ </div>
62
+ <div id="info">
63
+ <h1>// office.glb</h1>
64
+ <dl>
65
+ <dt>size</dt><dd id="iSize">—</dd>
66
+ <dt>meshes</dt><dd id="iMesh">—</dd>
67
+ <dt>vertices</dt><dd id="iVerts">—</dd>
68
+ <dt>materials</dt><dd id="iMats">—</dd>
69
+ <dt>bounds</dt><dd id="iBounds">—</dd>
70
+ <dt>fps</dt><dd id="iFps">—</dd>
71
+ </dl>
72
+ </div>
73
+
74
+ <script>
75
+ window.__setStatus = function (msg, isErr) {
76
+ const el = document.getElementById("status");
77
+ if (!el) return;
78
+ el.textContent = msg;
79
+ el.classList.toggle("is-err", !!isErr);
80
+ };
81
+ if (location.protocol === "file:") {
82
+ window.__setStatus("⚠ Open via http://localhost:<port>/stuff-viewer.html · file:// blocks module + GLB loads.", true);
83
+ }
84
+ window.addEventListener("error", (e) => {
85
+ const what = e.message || (e.target && (e.target.src || e.target.href)) || "unknown";
86
+ window.__setStatus("JS / asset error: " + what, true);
87
+ }, true);
88
+ window.addEventListener("unhandledrejection", (e) => {
89
+ const r = e.reason;
90
+ window.__setStatus("Unhandled rejection: " + ((r && r.stack) || (r && r.message) || r), true);
91
+ });
92
+ </script>
93
+
94
+ <script type="module">
95
+ const S = window.__setStatus;
96
+ (async () => {
97
+ try {
98
+ const GLB = "/icons/office.glb";
99
+ S("checking " + GLB + " …");
100
+ const head = await fetch(GLB, { method: "HEAD" }).catch((err) => ({ ok: false, status: "fetch failed: " + err.message }));
101
+ if (!head.ok) {
102
+ S("GLB unreachable: " + GLB + " → " + head.status, true);
103
+ return;
104
+ }
105
+ const bytes = parseInt(head.headers.get("content-length") || "0", 10);
106
+ document.getElementById("iSize").textContent = bytes ? (bytes / 1024).toFixed(1) + " KB" : "?";
107
+
108
+ S("loading three.js …");
109
+ const THREE = await import("/vendor/three.module.min.js");
110
+ S("loading OrbitControls + GLTFLoader + RoomEnvironment …");
111
+ const { OrbitControls } = await import("/vendor/OrbitControls.js");
112
+ const { GLTFLoader } = await import("/vendor/GLTFLoader.js");
113
+ const { DRACOLoader } = await import("/vendor/DRACOLoader.js");
114
+ const { RoomEnvironment } = await import("/vendor/RoomEnvironment.js");
115
+ // GLBs in the wild can use TWO compression schemes:
116
+ // · meshopt (EXT_meshopt_compression) — stuff.glb
117
+ // · Draco (KHR_draco_mesh_compression) — office.glb
118
+ // Wire BOTH decoders unconditionally so the viewer handles
119
+ // either input without further config. Both are vendored
120
+ // under /vendor/* so the viewer works offline.
121
+ const { MeshoptDecoder } = await import("/vendor/meshopt_decoder.module.js");
122
+
123
+ // ── Renderer / scene / camera ───────────────────────────
124
+ const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
125
+ renderer.setSize(innerWidth, innerHeight);
126
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
127
+ renderer.shadowMap.enabled = true;
128
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
129
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
130
+ renderer.toneMappingExposure = 1.0;
131
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
132
+ document.body.appendChild(renderer.domElement);
133
+
134
+ const scene = new THREE.Scene();
135
+ scene.background = new THREE.Color(0x16181c);
136
+
137
+ const camera = new THREE.PerspectiveCamera(38, innerWidth / innerHeight, 0.01, 100);
138
+ camera.position.set(2.0, 1.6, 2.8);
139
+
140
+ const controls = new OrbitControls(camera, renderer.domElement);
141
+ controls.enableDamping = true;
142
+ controls.dampingFactor = 0.08;
143
+ controls.target.set(0, 0.5, 0);
144
+ controls.update();
145
+
146
+ // IBL · same recipe as the avatar viewer; gives any GLB
147
+ // PBR materials something to reflect.
148
+ const pmrem = new THREE.PMREMGenerator(renderer);
149
+ scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
150
+ scene.environmentIntensity = 0.85;
151
+
152
+ // Direct lights · key + fill + rim. Soft enough that PBR
153
+ // doesn't blow out, strong enough to read shape on matte
154
+ // surfaces.
155
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x2a3140, 0.32));
156
+ const key = new THREE.DirectionalLight(0xffffff, 1.0);
157
+ key.position.set(3, 5, 4);
158
+ key.castShadow = true;
159
+ key.shadow.mapSize.set(1024, 1024);
160
+ key.shadow.camera.near = 0.1;
161
+ key.shadow.camera.far = 20;
162
+ key.shadow.camera.top = 4;
163
+ key.shadow.camera.bottom = -4;
164
+ key.shadow.camera.left = -4;
165
+ key.shadow.camera.right = 4;
166
+ key.shadow.bias = -0.0005;
167
+ scene.add(key);
168
+ const rim = new THREE.DirectionalLight(0xbfd4ff, 0.35);
169
+ rim.position.set(-4, 3, -3);
170
+ scene.add(rim);
171
+
172
+ // Ground · circular disk so shadows can land; matches the
173
+ // dark BG so it reads as "stage" not "floor".
174
+ const ground = new THREE.Mesh(
175
+ new THREE.CircleGeometry(4.0, 64),
176
+ new THREE.MeshStandardMaterial({ color: 0x1d2026, roughness: 1, metalness: 0 }),
177
+ );
178
+ ground.rotation.x = -Math.PI / 2;
179
+ ground.position.y = -0.001;
180
+ ground.receiveShadow = true;
181
+ scene.add(ground);
182
+
183
+ // Grid for scale reference · soft, low-contrast so it doesn't
184
+ // compete with the model.
185
+ const grid = new THREE.GridHelper(8, 32, 0x2a3140, 0x222831);
186
+ grid.material.transparent = true;
187
+ grid.material.opacity = 0.5;
188
+ scene.add(grid);
189
+
190
+ // ── Load GLB ────────────────────────────────────────────
191
+ S("downloading + parsing " + GLB + " …");
192
+ // The decoder boots its WASM lazily on first use — `ready` is a
193
+ // Promise we have to await so subsequent .load() calls find an
194
+ // initialised decoder. Without this the loader fires the same
195
+ // "setMeshoptDecoder must be called before loading compressed
196
+ // files" again on the next compressed buffer view.
197
+ await MeshoptDecoder.ready;
198
+ const loader = new GLTFLoader();
199
+ loader.setMeshoptDecoder(MeshoptDecoder);
200
+ // Draco · decoder JS + wasm live under /vendor/draco/.
201
+ // `setDecoderPath` joins onto the directory; the loader
202
+ // fetches draco_decoder.js + draco_decoder.wasm lazily on
203
+ // the first compressed buffer it encounters.
204
+ const draco = new DRACOLoader();
205
+ draco.setDecoderPath("/vendor/draco/");
206
+ draco.setDecoderConfig({ type: "wasm" });
207
+ loader.setDRACOLoader(draco);
208
+ const gltf = await new Promise((resolve, reject) => {
209
+ loader.load(
210
+ GLB,
211
+ (g) => resolve(g),
212
+ (xhr) => {
213
+ if (xhr.total) {
214
+ S(`downloading … ${((xhr.loaded / xhr.total) * 100).toFixed(0)} %`);
215
+ } else {
216
+ S(`downloading … ${(xhr.loaded / 1024).toFixed(0)} KB`);
217
+ }
218
+ },
219
+ (err) => reject(err),
220
+ );
221
+ });
222
+
223
+ const model = gltf.scene;
224
+ // Enable shadows on every mesh in the imported tree.
225
+ let meshCount = 0;
226
+ let vertCount = 0;
227
+ const mats = new Set();
228
+ model.traverse((node) => {
229
+ if (node.isMesh) {
230
+ node.castShadow = true;
231
+ node.receiveShadow = true;
232
+ meshCount += 1;
233
+ if (node.geometry && node.geometry.attributes && node.geometry.attributes.position) {
234
+ vertCount += node.geometry.attributes.position.count;
235
+ }
236
+ if (node.material) {
237
+ if (Array.isArray(node.material)) node.material.forEach((m) => mats.add(m));
238
+ else mats.add(node.material);
239
+ }
240
+ }
241
+ });
242
+ scene.add(model);
243
+
244
+ // Animations · play all clips at 1× if present.
245
+ let mixer = null;
246
+ if (gltf.animations && gltf.animations.length) {
247
+ mixer = new THREE.AnimationMixer(model);
248
+ for (const clip of gltf.animations) mixer.clipAction(clip).play();
249
+ }
250
+
251
+ // ── Fit camera to model bounds ─────────────────────────
252
+ function reframe() {
253
+ const box = new THREE.Box3().setFromObject(model);
254
+ const size = box.getSize(new THREE.Vector3());
255
+ const center = box.getCenter(new THREE.Vector3());
256
+ const maxDim = Math.max(size.x, size.y, size.z);
257
+ const fov = camera.fov * Math.PI / 180;
258
+ const dist = (maxDim / 2) / Math.tan(fov / 2) * 1.7;
259
+ camera.position.set(
260
+ center.x + dist * 0.6,
261
+ center.y + dist * 0.55,
262
+ center.z + dist * 0.9,
263
+ );
264
+ camera.near = Math.max(0.005, maxDim / 200);
265
+ camera.far = Math.max(20, dist * 4);
266
+ camera.updateProjectionMatrix();
267
+ controls.target.copy(center);
268
+ controls.update();
269
+ document.getElementById("iBounds").textContent =
270
+ `${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)}`;
271
+ }
272
+ reframe();
273
+
274
+ // ── Info panel ─────────────────────────────────────────
275
+ document.getElementById("iMesh").textContent = String(meshCount);
276
+ document.getElementById("iVerts").textContent = vertCount.toLocaleString();
277
+ document.getElementById("iMats").textContent = String(mats.size);
278
+
279
+ // ── UI wiring ───────────────────────────────────────────
280
+ const $ = (id) => document.getElementById(id);
281
+ const wire = (id, vid, fn, fmt) => {
282
+ const el = $(id), lab = $(vid);
283
+ const upd = () => { const v = parseFloat(el.value); fn(v); lab.textContent = fmt(v); };
284
+ el.addEventListener("input", upd); upd();
285
+ };
286
+ wire("exp", "vExp", (v) => { renderer.toneMappingExposure = v; }, (v) => v.toFixed(2));
287
+ wire("env", "vEnv", (v) => { scene.environmentIntensity = v; }, (v) => v.toFixed(2));
288
+ wire("key", "vKey", (v) => { key.intensity = v; }, (v) => v.toFixed(2));
289
+ const rotEl = $("rot");
290
+ rotEl.addEventListener("input", () => {
291
+ autoRotate = false;
292
+ autoBtn.textContent = "↻ auto-rotate: OFF";
293
+ const deg = parseFloat(rotEl.value);
294
+ model.rotation.y = deg * Math.PI / 180;
295
+ $("vRot").textContent = deg.toFixed(0) + "°";
296
+ });
297
+
298
+ let autoRotate = true;
299
+ const autoBtn = $("autoRotateToggle");
300
+ autoBtn.addEventListener("click", () => {
301
+ autoRotate = !autoRotate;
302
+ autoBtn.textContent = "↻ auto-rotate: " + (autoRotate ? "ON" : "OFF");
303
+ });
304
+ $("reframeBtn").addEventListener("click", reframe);
305
+
306
+ // ── Render loop ────────────────────────────────────────
307
+ const clock = new THREE.Clock();
308
+ let frames = 0, fpsAcc = 0;
309
+ renderer.setAnimationLoop(() => {
310
+ const dt = clock.getDelta();
311
+ if (autoRotate) {
312
+ model.rotation.y += dt * 0.6;
313
+ const deg = (model.rotation.y * 180 / Math.PI) % 360;
314
+ rotEl.value = ((deg + 360) % 360).toFixed(0);
315
+ $("vRot").textContent = "auto";
316
+ }
317
+ if (mixer) mixer.update(dt);
318
+ controls.update();
319
+ renderer.render(scene, camera);
320
+ frames += 1; fpsAcc += dt;
321
+ if (fpsAcc >= 0.5) {
322
+ $("iFps").textContent = (frames / fpsAcc).toFixed(0) + " fps";
323
+ frames = 0; fpsAcc = 0;
324
+ }
325
+ });
326
+
327
+ addEventListener("resize", () => {
328
+ camera.aspect = innerWidth / innerHeight;
329
+ camera.updateProjectionMatrix();
330
+ renderer.setSize(innerWidth, innerHeight);
331
+ });
332
+
333
+ S(`✓ loaded · ${meshCount} mesh · ${vertCount.toLocaleString()} verts · drag to orbit, scroll to zoom`);
334
+ } catch (e) {
335
+ S("ERROR: " + (e && e.stack ? e.stack : (e && e.message) || e), true);
336
+ }
337
+ })();
338
+ </script>
339
+ </body>
340
+ </html>
@@ -109,7 +109,7 @@
109
109
  }
110
110
  .onb-deck {
111
111
  font-family: var(--font-human, var(--mono));
112
- font-size: 13px;
112
+ font-size: 14px;
113
113
  color: var(--text-soft);
114
114
  line-height: 1.55;
115
115
  margin-top: 8px;
@@ -179,7 +179,7 @@
179
179
  content: ">";
180
180
  color: var(--lime);
181
181
  font-weight: 700;
182
- font-size: 13px;
182
+ font-size: 14px;
183
183
  font-family: var(--mono);
184
184
  padding: 9px 0 0 11px;
185
185
  align-self: flex-start;
@@ -332,7 +332,7 @@
332
332
  }
333
333
  .onb-voice-pitch {
334
334
  font-family: var(--font-human, "Inter", system-ui, sans-serif);
335
- font-size: 13px;
335
+ font-size: 14px;
336
336
  line-height: 1.6;
337
337
  color: var(--text-soft);
338
338
  margin: 0;
@@ -393,7 +393,7 @@
393
393
  }
394
394
  .onb-cast-next-arrow {
395
395
  font-family: var(--mono);
396
- font-size: 13px;
396
+ font-size: 14px;
397
397
  font-weight: 700;
398
398
  color: var(--text-faint);
399
399
  align-self: center;
@@ -592,7 +592,7 @@
592
592
  color: var(--text-dim);
593
593
  cursor: pointer;
594
594
  font-family: var(--font-human, "Inter", system-ui, sans-serif);
595
- font-size: 13px;
595
+ font-size: 14px;
596
596
  font-weight: 500;
597
597
  letter-spacing: -0.005em;
598
598
  line-height: 1.2;
@@ -80,9 +80,10 @@
80
80
  white-space: nowrap;
81
81
  }
82
82
  /* Adjourned-room state · Probe / Second hide (they post to a
83
- closed room), but Save stays — bookmarking from a finished
84
- session is a primary use case. */
85
- .qcta.qcta-readonly .qcta-btn:not(.qcta-btn-save) { display: none; }
83
+ closed room), but Save and Thread stay — bookmarking and opening a
84
+ private 1:1 follow-up thread are both primary review-mode uses
85
+ (a thread spawns its own live room, independent of the parent). */
86
+ .qcta.qcta-readonly .qcta-btn:not(.qcta-btn-save):not(.qcta-btn-thread) { display: none; }
86
87
  .qcta.qcta-readonly .qcta-hint { display: inline-flex; align-items: center; }
87
88
 
88
89
  /* ─── Save toast ─────────────────────────────────────────────
@@ -220,7 +221,7 @@
220
221
  content: ">";
221
222
  color: var(--lime);
222
223
  font-weight: 700;
223
- font-size: 13px;
224
+ font-size: 14px;
224
225
  font-family: var(--mono, "Inter", system-ui, sans-serif);
225
226
  padding: 9px 0 0 11px;
226
227
  align-self: flex-start;