privateboard 0.1.13 → 0.1.16

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/public/themes.css CHANGED
@@ -218,40 +218,6 @@
218
218
  --magenta: #6E4F73;
219
219
  }
220
220
 
221
- /* ─── APPLE · pure-white minimalism · Apple.com aesthetic ─────
222
- Light theme inspired by Apple's brand language: pure-white
223
- primary background, the iconic #F5F5F7 off-white for cards,
224
- Apple's near-black #1D1D1F for text, and system-blue #0071E3
225
- mapped onto --lime (the codebase's primary-accent role) so
226
- every CTA reads as that signature blue. */
227
- :root[data-theme="apple"] {
228
- --bg: #FFFFFF;
229
- --panel: #F5F5F7;
230
- --panel-2: #FBFBFD;
231
- --panel-3: #ECECEE;
232
- --hi: #D2D2D7;
233
-
234
- --line: #E5E5EA;
235
- --line-bright: #D2D2D7;
236
- --line-strong: #86868B;
237
-
238
- --text: #1D1D1F;
239
- --text-soft: #424245;
240
- --text-dim: #6E6E73;
241
- --text-faint: #86868B;
242
-
243
- --lime: #0071E3;
244
- --lime-deep: #0051A8;
245
- --lime-dim: #D6E8FA;
246
-
247
- --amber: #FF9500;
248
- --amber-dim: #FFE6C2;
249
- --red: #FF3B30;
250
- --red-dim: #FFE0DC;
251
- --cyan: #5AC8FA;
252
- --magenta: #BF5AF2;
253
- }
254
-
255
221
  /* ─── PINTEREST · clean white surface, signature red accent ───
256
222
  Light theme inspired by Pinterest's brand: true-white
257
223
  background, soft gray cards, neutral grayscale text, and the
@@ -79,7 +79,17 @@
79
79
 
80
80
  function ensureContext() {
81
81
  if (_ctxFailed) return null;
82
- if (_ctx) return _ctx;
82
+ if (_ctx) {
83
+ // Resume on EVERY call when suspended · we proactively suspend
84
+ // the context between SFX bursts (see `releaseContextSoon`) to
85
+ // release the audio session for the HTMLAudioElement TTS path.
86
+ // Without this re-check, a context suspended for TTS would
87
+ // stay silent on the next tick/blip/gavel.
88
+ if (_ctx.state === "suspended") {
89
+ _ctx.resume().catch(() => { /* swallow */ });
90
+ }
91
+ return _ctx;
92
+ }
83
93
  if (!_hadGesture) return null; // refuse to create until gestured
84
94
  try {
85
95
  const Ctx = window.AudioContext || window.webkitAudioContext;
@@ -97,6 +107,33 @@
97
107
  }
98
108
  }
99
109
 
110
+ /** Suspend the AudioContext shortly after `setThinking(false)` so
111
+ * the system audio session is released for the HTMLAudioElement
112
+ * TTS path. Browsers (Safari / iOS especially) treat a "running"
113
+ * AudioContext as the active audio source; an `<audio>` element
114
+ * starting `play()` against that state can be blocked or silently
115
+ * routed away.
116
+ *
117
+ * We don't suspend instantly · a small grace lets the thinking
118
+ * fade-out finish without click. */
119
+ let _releaseTimer = null;
120
+ function releaseContextSoon() {
121
+ if (!_ctx) return;
122
+ if (_releaseTimer) { clearTimeout(_releaseTimer); _releaseTimer = null; }
123
+ _releaseTimer = setTimeout(() => {
124
+ _releaseTimer = null;
125
+ // Only suspend when nothing has restarted the loop in the
126
+ // meantime · belt-and-suspenders against rapid toggle races.
127
+ if (!_thinkingInterval && _ctx && _ctx.state === "running") {
128
+ _ctx.suspend().catch(() => { /* swallow */ });
129
+ }
130
+ }, 200);
131
+ }
132
+
133
+ function cancelRelease() {
134
+ if (_releaseTimer) { clearTimeout(_releaseTimer); _releaseTimer = null; }
135
+ }
136
+
100
137
  function tick() {
101
138
  if (!_enabled) return;
102
139
  if (document.visibilityState !== "visible") return; // background tab
@@ -171,6 +208,91 @@
171
208
  osc.stop(t0 + dur);
172
209
  }
173
210
 
211
+ /** Director thinking cue · loops the original 8-bit "blip-blip"
212
+ * pair while a voice-room seat shows the thought-bubble. Each
213
+ * cycle: two pulse-wave blips spaced 60 ms apart (G5 → B5, a
214
+ * minor third up · reads as "thought lifting"), each blip about
215
+ * 60 ms long with a fast attack and exponential decay. Cycles
216
+ * repeat every ~1100 ms — generous silence between pairs so the
217
+ * rhythm feels like classic NES dialog blips, not an alarm.
218
+ *
219
+ * Per-blip oscillator creation is recreated on each tick · the
220
+ * earlier TTS conflict turned out to be a missing TTS provider
221
+ * key, not AudioContext churn, so we can safely use setInterval
222
+ * for the loop. */
223
+ let _thinkingInterval = null;
224
+ let _thinkingPhase = 0;
225
+
226
+ function _playThinkingPair() {
227
+ if (!_enabled) { setThinking(false); return; }
228
+ if (document.visibilityState !== "visible") return;
229
+ const ctx = ensureContext();
230
+ if (!ctx) return;
231
+ const t0 = ctx.currentTime;
232
+ // Two blips · G5 (784 Hz) then B5 (988 Hz). 60 ms gap between
233
+ // blip starts so the ear hears them as a pair, not a chord.
234
+ // Phase-flip every cycle (B5→G5 alternation across cycles)
235
+ // adds slight melodic interest, like a thinker switching gears.
236
+ const ascending = _thinkingPhase % 2 === 0;
237
+ _thinkingPhase = (_thinkingPhase + 1) % 1024;
238
+ const blips = ascending
239
+ ? [{ freq: 784, start: 0.000 }, { freq: 988, start: 0.060 }]
240
+ : [{ freq: 988, start: 0.000 }, { freq: 784, start: 0.060 }];
241
+ for (const b of blips) {
242
+ const t = t0 + b.start;
243
+ const osc = ctx.createOscillator();
244
+ osc.type = "square";
245
+ osc.frequency.value = b.freq;
246
+ // Low-pass softens the harsh square edges so the blip sits
247
+ // in the room ambience rather than slicing through it.
248
+ const lpf = ctx.createBiquadFilter();
249
+ lpf.type = "lowpass";
250
+ lpf.frequency.value = 4500;
251
+ lpf.Q.value = 0.7;
252
+ const gain = ctx.createGain();
253
+ gain.gain.setValueAtTime(0.0001, t);
254
+ gain.gain.exponentialRampToValueAtTime(0.05, t + 0.005);
255
+ gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.06);
256
+ osc.connect(lpf).connect(gain).connect(ctx.destination);
257
+ osc.start(t);
258
+ const stopAt = t + 0.08;
259
+ osc.stop(stopAt);
260
+ // Cleanup · disconnect after stop so GainNodes aren't retained
261
+ // across hundreds of blips on a long thinking phase.
262
+ osc.onended = () => {
263
+ try { osc.disconnect(); } catch { /* ignore */ }
264
+ try { lpf.disconnect(); } catch { /* ignore */ }
265
+ try { gain.disconnect(); } catch { /* ignore */ }
266
+ };
267
+ }
268
+ }
269
+
270
+ function setThinking(on) {
271
+ if (on) {
272
+ cancelRelease();
273
+ if (_thinkingInterval) return; // already looping · idempotent
274
+ if (!_enabled) return;
275
+ // First pair immediately so the user hears feedback the moment
276
+ // the bubble appears; subsequent pairs on a ~1100 ms cadence.
277
+ // 1100 ms (~0.9 Hz) is intentionally slow — earlier 700 ms /
278
+ // 1.4 Hz read as an alarm tempo; this sits closer to a
279
+ // contemplative NES dialog cadence.
280
+ _thinkingPhase = 0;
281
+ _playThinkingPair();
282
+ _thinkingInterval = setInterval(_playThinkingPair, 1100);
283
+ } else {
284
+ if (!_thinkingInterval) return;
285
+ clearInterval(_thinkingInterval);
286
+ _thinkingInterval = null;
287
+ _thinkingPhase = 0;
288
+ // Suspend the AudioContext after a brief grace so the audio
289
+ // session is fully released for any HTMLAudioElement TTS that
290
+ // might be about to play. ensureContext resumes automatically
291
+ // on the next SFX call.
292
+ releaseContextSoon();
293
+ }
294
+ }
295
+
174
296
  /** Chair gavel cue · fires before chair voice playback begins in
175
297
  * voice mode. Two-strike wooden knock that reads as "court is in
176
298
  * session — listen up." Designed by ear:
@@ -228,10 +350,61 @@
228
350
  }
229
351
  }
230
352
 
353
+ /** Continue / vote auto-fire countdown beep · fires once per second
354
+ * on the 10 → 1 visible tick so the user feels the timer urgency
355
+ * audibly, not just visually. Two tiers:
356
+ * · seconds 10-4 · low square-wave (~600 Hz), 50 ms, quiet — a
357
+ * steady "metronome" tick that's clearly background.
358
+ * · seconds 3-1 · higher square-wave (~880 Hz), 80 ms, louder —
359
+ * the "3-2-1!" alarm register that signals imminent fire.
360
+ * Second 0 is skipped here; the auto-continue action that fires
361
+ * on hit-zero has its own UX (the room moves on). Low-pass keeps
362
+ * the square edges from slicing through, same approach as the
363
+ * thinking-blip pair. */
364
+ function countdownTick(secondsLeft) {
365
+ if (!_enabled) return;
366
+ if (document.visibilityState !== "visible") return;
367
+ if (typeof secondsLeft !== "number" || secondsLeft <= 0) return;
368
+ const ctx = ensureContext();
369
+ if (!ctx) return;
370
+
371
+ const urgent = secondsLeft <= 3;
372
+ const freq = urgent ? 880 : 600;
373
+ const dur = urgent ? 0.08 : 0.05;
374
+ const peak = urgent ? 0.075 : 0.045;
375
+
376
+ const t0 = ctx.currentTime;
377
+ const osc = ctx.createOscillator();
378
+ osc.type = "square";
379
+ osc.frequency.value = freq;
380
+ const lpf = ctx.createBiquadFilter();
381
+ lpf.type = "lowpass";
382
+ lpf.frequency.value = 4000;
383
+ lpf.Q.value = 0.7;
384
+ const gain = ctx.createGain();
385
+ gain.gain.setValueAtTime(0.0001, t0);
386
+ gain.gain.exponentialRampToValueAtTime(peak, t0 + 0.005);
387
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
388
+ osc.connect(lpf).connect(gain).connect(ctx.destination);
389
+ osc.start(t0);
390
+ osc.stop(t0 + dur + 0.02);
391
+ osc.onended = () => {
392
+ try { osc.disconnect(); } catch { /* ignore */ }
393
+ try { lpf.disconnect(); } catch { /* ignore */ }
394
+ try { gain.disconnect(); } catch { /* ignore */ }
395
+ };
396
+ }
397
+
231
398
  function setEnabled(on) {
232
399
  _enabled = !!on;
233
400
  writeEnabled(_enabled);
234
- // No-op when disabling · live AudioContext stays alive cheaply
401
+ // Stop any in-flight thinking loop when SFX gets disabled · we
402
+ // don't want a stale setInterval ticking silently and resuming
403
+ // audio the moment the user toggles back on inside the same
404
+ // thinking phase. Re-entry happens cleanly via the next
405
+ // `setThinking(true)` call from the render loop.
406
+ if (!_enabled) setThinking(false);
407
+ // No-op for AudioContext when disabling · stays alive cheaply
235
408
  // (a few KB) and an outright close() leaves us re-paying the
236
409
  // creation cost if the user toggles back on within the session.
237
410
  }
@@ -240,5 +413,5 @@
240
413
 
241
414
  // Public surface · attached to window so app.js (and the
242
415
  // user-settings toggle) can reach it without an import.
243
- window.boardroomTypingSfx = { tick, speakerChange, gavel, setEnabled, isEnabled };
416
+ window.boardroomTypingSfx = { tick, speakerChange, setThinking, gavel, countdownTick, setEnabled, isEnabled };
244
417
  })();
@@ -181,7 +181,7 @@
181
181
  font-family: var(--mono);
182
182
  }
183
183
  .us-nav-foot-label {
184
- font-size: 8.5px;
184
+ font-size: 8px;
185
185
  letter-spacing: 0.22em;
186
186
  text-transform: uppercase;
187
187
  color: var(--text-faint, #3A382F);
@@ -257,7 +257,7 @@
257
257
  .us-row-field { min-width: 0; }
258
258
  .us-row-meta {
259
259
  font-family: var(--mono);
260
- font-size: 9.5px;
260
+ font-size: 10px;
261
261
  letter-spacing: 0.06em;
262
262
  text-transform: uppercase;
263
263
  color: var(--text-faint, #3A382F);
@@ -325,7 +325,7 @@
325
325
  outline-offset: 2px;
326
326
  }
327
327
  .us-switch-label {
328
- font-size: 10.5px;
328
+ font-size: 10px;
329
329
  letter-spacing: 0.18em;
330
330
  text-transform: uppercase;
331
331
  font-weight: 700;
@@ -343,6 +343,29 @@
343
343
  min-width: 0;
344
344
  }
345
345
 
346
+ /* Ghost button · inline action in a settings row (e.g. "Replay
347
+ onboarding"). Matches the .onb-btn family — mono uppercase, hairline
348
+ border, lime on hover — so the affordance reads consistent across
349
+ the user-settings and onboarding overlays. */
350
+ .us-btn-ghost {
351
+ font-family: var(--mono);
352
+ font-size: 10px;
353
+ font-weight: 700;
354
+ text-transform: uppercase;
355
+ letter-spacing: 0.1em;
356
+ padding: 7px 13px;
357
+ border: 0.5px solid var(--line-strong, #3A3A35);
358
+ background: transparent;
359
+ color: var(--text-soft, #8E8B83);
360
+ cursor: pointer;
361
+ transition: border-color 0.12s, color 0.12s;
362
+ flex-shrink: 0;
363
+ }
364
+ .us-btn-ghost:hover {
365
+ border-color: var(--lime, #6FB572);
366
+ color: var(--lime, #6FB572);
367
+ }
368
+
346
369
  /* Preferences → Other · EN / Zh (moved off the sidebar chrome) */
347
370
  .us-pane-body .locale-switch {
348
371
  display: inline-flex;
@@ -570,7 +593,7 @@
570
593
  .us-theme.active .us-theme-name { color: var(--lime, #6FB572); }
571
594
  .us-theme-desc {
572
595
  font-family: var(--font-human);
573
- font-size: 10.5px;
596
+ font-size: 10px;
574
597
  color: var(--text-dim, #5C5A52);
575
598
  line-height: 1.4;
576
599
  margin-top: 1px;
@@ -618,7 +641,7 @@
618
641
  display: inline-block;
619
642
  margin-left: 6px;
620
643
  padding: 1px 5px;
621
- font-size: 8.5px;
644
+ font-size: 8px;
622
645
  font-weight: 700;
623
646
  letter-spacing: 0.14em;
624
647
  text-transform: uppercase;
@@ -629,7 +652,7 @@
629
652
  }
630
653
  .us-key-status {
631
654
  font-family: var(--mono);
632
- font-size: 9.5px;
655
+ font-size: 10px;
633
656
  letter-spacing: 0.1em;
634
657
  text-transform: uppercase;
635
658
  }
@@ -700,7 +723,7 @@
700
723
  border: 0.5px solid var(--line-bright, #2A2A26);
701
724
  color: var(--text-soft, #8E8B83);
702
725
  font-family: var(--mono);
703
- font-size: 9.5px;
726
+ font-size: 10px;
704
727
  font-weight: 600;
705
728
  letter-spacing: 0.16em;
706
729
  text-transform: uppercase;
@@ -814,7 +837,7 @@
814
837
  justify-content: space-between;
815
838
  align-items: center;
816
839
  font-family: var(--mono);
817
- font-size: 9.5px;
840
+ font-size: 10px;
818
841
  color: var(--text-faint, #3A382F);
819
842
  letter-spacing: 0.1em;
820
843
  text-transform: uppercase;
@@ -845,7 +868,7 @@
845
868
  }
846
869
  .us-foot .us-website {
847
870
  font-family: var(--mono);
848
- font-size: 9.5px;
871
+ font-size: 10px;
849
872
  font-weight: 600;
850
873
  letter-spacing: 0.1em;
851
874
  text-transform: uppercase;
@@ -894,7 +917,7 @@
894
917
  line-height: 1;
895
918
  }
896
919
  .us-usage-empty-text {
897
- font-size: 11.5px;
920
+ font-size: 12px;
898
921
  letter-spacing: 0.04em;
899
922
  color: var(--text-soft, #8E8B83);
900
923
  text-align: center;
@@ -918,7 +941,7 @@
918
941
  border: 0.5px solid var(--line-bright, #2A2A26);
919
942
  color: var(--text-soft, #8E8B83);
920
943
  font-family: var(--mono, "Inter", system-ui, sans-serif);
921
- font-size: 10.5px;
944
+ font-size: 10px;
922
945
  letter-spacing: 0.12em;
923
946
  text-transform: uppercase;
924
947
  font-weight: 600;
@@ -957,7 +980,7 @@
957
980
  font-family: var(--mono, "Inter", system-ui, sans-serif);
958
981
  }
959
982
  .us-chart-meta-label {
960
- font-size: 9.5px;
983
+ font-size: 10px;
961
984
  letter-spacing: 0.18em;
962
985
  text-transform: uppercase;
963
986
  color: var(--text-faint, #3A382F);
@@ -1054,7 +1077,7 @@
1054
1077
  margin-top: 4px;
1055
1078
  height: 10px;
1056
1079
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1057
- font-size: 8.5px;
1080
+ font-size: 8px;
1058
1081
  letter-spacing: 0.06em;
1059
1082
  color: var(--text-faint, #3A382F);
1060
1083
  text-align: center;
@@ -1077,14 +1100,14 @@
1077
1100
  }
1078
1101
  .us-day-empty-tag {
1079
1102
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1080
- font-size: 10.5px;
1103
+ font-size: 10px;
1081
1104
  letter-spacing: 0.18em;
1082
1105
  text-transform: uppercase;
1083
1106
  color: var(--text-soft, #8E8B83);
1084
1107
  font-weight: 700;
1085
1108
  }
1086
1109
  .us-day-empty-text {
1087
- font-size: 11.5px;
1110
+ font-size: 12px;
1088
1111
  color: var(--text-faint, #3A382F);
1089
1112
  }
1090
1113
 
@@ -1093,7 +1116,7 @@
1093
1116
  kicker so the user knows what scope the head numbers describe. */
1094
1117
  .us-usage-total-scope {
1095
1118
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1096
- font-size: 9.5px;
1119
+ font-size: 10px;
1097
1120
  letter-spacing: 0.18em;
1098
1121
  text-transform: uppercase;
1099
1122
  color: var(--text-faint, #3A382F);
@@ -1139,7 +1162,7 @@
1139
1162
  gap: 12px;
1140
1163
  justify-content: flex-end;
1141
1164
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1142
- font-size: 10.5px;
1165
+ font-size: 10px;
1143
1166
  }
1144
1167
  .us-usage-meta-label {
1145
1168
  color: var(--text-faint, #3A382F);
@@ -1176,7 +1199,7 @@
1176
1199
  .us-usage-section { display: flex; flex-direction: column; gap: 10px; }
1177
1200
  .us-usage-section-tag {
1178
1201
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1179
- font-size: 9.5px;
1202
+ font-size: 10px;
1180
1203
  letter-spacing: 0.22em;
1181
1204
  text-transform: uppercase;
1182
1205
  color: var(--text-faint, #3A382F);
@@ -1213,7 +1236,7 @@
1213
1236
  }
1214
1237
  .us-model-provider {
1215
1238
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1216
- font-size: 9.5px;
1239
+ font-size: 10px;
1217
1240
  letter-spacing: 0.18em;
1218
1241
  text-transform: uppercase;
1219
1242
  color: var(--text-faint, #3A382F);
@@ -1243,7 +1266,7 @@
1243
1266
  letter-spacing: -0.01em;
1244
1267
  }
1245
1268
  .us-model-pct {
1246
- font-size: 10.5px;
1269
+ font-size: 10px;
1247
1270
  color: var(--text-soft, #8E8B83);
1248
1271
  letter-spacing: 0.04em;
1249
1272
  font-variant-numeric: tabular-nums;
@@ -1251,7 +1274,7 @@
1251
1274
  text-align: right;
1252
1275
  }
1253
1276
  .us-model-agents {
1254
- font-size: 9.5px;
1277
+ font-size: 10px;
1255
1278
  letter-spacing: 0.18em;
1256
1279
  text-transform: uppercase;
1257
1280
  color: var(--text-faint, #3A382F);
@@ -1307,7 +1330,7 @@
1307
1330
  .us-agent-bar > span { display: block; height: 100%; }
1308
1331
  .us-agent-tokens {
1309
1332
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1310
- font-size: 11.5px;
1333
+ font-size: 12px;
1311
1334
  font-weight: 700;
1312
1335
  color: var(--text, #C8C5BE);
1313
1336
  font-variant-numeric: tabular-nums;
@@ -1317,7 +1340,7 @@
1317
1340
  .us-agent-silent {
1318
1341
  margin-top: 6px;
1319
1342
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1320
- font-size: 9.5px;
1343
+ font-size: 10px;
1321
1344
  letter-spacing: 0.16em;
1322
1345
  text-transform: uppercase;
1323
1346
  color: var(--text-faint, #3A382F);
@@ -1386,7 +1409,7 @@
1386
1409
  }
1387
1410
  .us-key-group-tag {
1388
1411
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1389
- font-size: 9.5px;
1412
+ font-size: 10px;
1390
1413
  letter-spacing: 0.28em;
1391
1414
  text-transform: uppercase;
1392
1415
  color: var(--text-faint, #3A382F);
@@ -1448,7 +1471,7 @@
1448
1471
  }
1449
1472
  .us-models-provider-tag {
1450
1473
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1451
- font-size: 9.5px;
1474
+ font-size: 10px;
1452
1475
  letter-spacing: 0.22em;
1453
1476
  text-transform: uppercase;
1454
1477
  color: var(--text-faint, #3A382F);
@@ -1503,7 +1526,7 @@
1503
1526
  }
1504
1527
  .us-models-default-label {
1505
1528
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1506
- font-size: 9.5px;
1529
+ font-size: 10px;
1507
1530
  letter-spacing: 0.22em;
1508
1531
  text-transform: uppercase;
1509
1532
  color: var(--text-faint, #3A382F);
@@ -1551,7 +1574,7 @@
1551
1574
  }
1552
1575
  .us-models-default-note {
1553
1576
  font-family: var(--mono, "Inter", system-ui, sans-serif);
1554
- font-size: 9.5px;
1577
+ font-size: 10px;
1555
1578
  letter-spacing: 0.18em;
1556
1579
  text-transform: uppercase;
1557
1580
  color: var(--text-faint, #3A382F);
@@ -29,8 +29,6 @@
29
29
  swatches: ["#FBFBF7","#F4F2EC","#2E7D32","#1B5E20","#A86C2A","#A8403D","#2E7D7A","#1F1E1A"] },
30
30
  { slug: "pinterest", name: "Pinterest", desc: "clean white · Pinterest red · light",
31
31
  swatches: ["#FFFFFF","#FAFAFA","#E60023","#AD081B","#F4A100","#E60023","#2E7D7A","#111111"] },
32
- { slug: "apple", name: "Apple", desc: "pure white · system blue · Apple.com aesthetic · light",
33
- swatches: ["#FFFFFF","#F5F5F7","#0071E3","#0051A8","#FF9500","#FF3B30","#5AC8FA","#1D1D1F"] },
34
32
  { slug: "alanpeabody", name: "Alan Peabody", desc: "cool blue · git-green accents",
35
33
  swatches: ["#0E1419","#131A21","#6BAFE0","#3F7AAA","#C8A463","#D67373","#6FB5A8","#C8D0DA"] },
36
34
  { slug: "amuse", name: "Amuse", desc: "magenta + cyan · playful",
@@ -269,7 +267,7 @@
269
267
  <div class="us-row-field">
270
268
  <button type="button" class="cmp-dd" data-cmp-dropdown="locale" title="${escape(tr("us_locale_label"))}" data-i18n-aria="aria_language" aria-label="">
271
269
  <span class="cmp-dd-label" data-i18n="us_locale_label">${escape(tr("us_locale_label"))}</span>
272
- <span class="cmp-dd-value" data-cmp-dd-value="locale">${escape(tr(window.I18n && window.I18n.getLocale && window.I18n.getLocale() === "zh" ? "locale_zh" : "locale_en"))}</span>
270
+ <span class="cmp-dd-value" data-cmp-dd-value="locale">${escape(tr(`locale_${(window.I18n && window.I18n.getLocale && window.I18n.getLocale()) || "en"}`))}</span>
273
271
  <span class="cmp-dd-chevron">▾</span>
274
272
  </button>
275
273
  <p class="us-locale-deck">${escape(tr("us_locale_deck"))}</p>
@@ -290,6 +288,16 @@
290
288
  </div>
291
289
  </div>
292
290
  </div>
291
+
292
+ <div class="us-row">
293
+ <div class="us-row-label">${escape(tr("us_replay_onb_label"))}</div>
294
+ <div class="us-row-field">
295
+ <div class="us-toggle-row">
296
+ <button type="button" class="us-btn-ghost" data-us-replay-onb>${escape(tr("us_replay_onb_btn"))}</button>
297
+ <span class="us-toggle-deck">${escape(tr("us_replay_onb_deck"))}</span>
298
+ </div>
299
+ </div>
300
+ </div>
293
301
  </div>
294
302
  `;
295
303
  }
@@ -326,6 +334,32 @@
326
334
  // toggled also serves as the gesture the AudioContext needs,
327
335
  // so this tick is actually heard.
328
336
  if (next) window.boardroomTypingSfx.tick();
337
+ // Re-evaluate the agent-build ambient · this toggle is the
338
+ // master gate. Flipping OFF silences any active build BGM;
339
+ // flipping ON resumes it if a build is currently running on
340
+ // the user's foreground composer.
341
+ try { window.app?._syncAgentBuildBgm?.(); } catch { /* ignore */ }
342
+ });
343
+ }
344
+
345
+ // Replay onboarding · close the settings overlay first so the user
346
+ // lands on a clean dashboard, then trigger the storyline overlay.
347
+ // The replay helper in onboarding.js handles step reset + the
348
+ // once-only composer-hint flag.
349
+ const replayBtn = paneEl.querySelector("[data-us-replay-onb]");
350
+ if (replayBtn) {
351
+ replayBtn.addEventListener("click", () => {
352
+ try { if (typeof window.closeUserSettings === "function") window.closeUserSettings(); } catch { /* ignore */ }
353
+ // Settings overlay teardown sets up a 220ms close animation
354
+ // (see modal close handler); kick off onboarding after that so
355
+ // the two overlays don't briefly stack.
356
+ setTimeout(() => {
357
+ if (typeof window.boardroomReplayOnboarding === "function") {
358
+ window.boardroomReplayOnboarding();
359
+ } else if (typeof window.boardroomShowOnboarding === "function") {
360
+ window.boardroomShowOnboarding();
361
+ }
362
+ }, 240);
329
363
  });
330
364
  }
331
365
  }
@@ -1437,24 +1471,19 @@
1437
1471
  fetchAppVersion();
1438
1472
  }
1439
1473
 
1440
- let _versionCache = null;
1441
1474
  async function fetchAppVersion() {
1442
1475
  const slot = overlay && overlay.querySelector("[data-us-version-value]");
1443
1476
  if (!slot) return;
1444
- // Use cache if we already have it · the version doesn't change
1445
- // mid-process. First open does the network round-trip; subsequent
1446
- // opens repaint from cache instantly.
1447
- if (_versionCache) {
1448
- slot.textContent = _versionCache;
1449
- return;
1450
- }
1477
+ // No cache · every overlay open hits /api/version so a dev-server
1478
+ // restart (npm version bump + new build) reflects immediately
1479
+ // in the foot without requiring a hard reload. The call is one
1480
+ // tiny round-trip with no DB hit, so it's cheap to repeat.
1451
1481
  try {
1452
- const r = await fetch("/api/version");
1482
+ const r = await fetch("/api/version", { cache: "no-store" });
1453
1483
  if (!r.ok) return;
1454
1484
  const j = await r.json();
1455
1485
  if (j && typeof j.version === "string") {
1456
- _versionCache = `v${j.version}`;
1457
- slot.textContent = _versionCache;
1486
+ slot.textContent = `v${j.version}`;
1458
1487
  }
1459
1488
  } catch { /* swallow · the foot just stays at "·" if offline */ }
1460
1489
  }