privateboard 0.1.11 → 0.1.13
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/cli.js +4260 -1660
- package/dist/cli.js.map +1 -1
- package/package.json +2 -1
- package/public/adjourn-overlay.css +8 -4
- package/public/agent-profile.css +345 -0
- package/public/agent-profile.js +222 -3
- package/public/app.js +4990 -149
- package/public/home.html +609 -1
- package/public/i18n.js +49 -385
- package/public/index.html +9549 -5314
- package/public/new-agent.js +148 -37
- package/public/quote-cta.css +7 -3
- package/public/quote-cta.js +21 -0
- package/public/report.html +109 -12
- package/public/room-settings.css +67 -0
- package/public/room-settings.js +72 -6
- package/public/themes.css +38 -0
- package/public/typing-sfx.js +87 -1
- package/public/user-settings.js +3 -1
- package/public/voice-replay.css +70 -1
- package/public/voice-replay.js +265 -2
package/public/voice-replay.js
CHANGED
|
@@ -58,12 +58,50 @@
|
|
|
58
58
|
skipUser: true,
|
|
59
59
|
abortCtrl: null,
|
|
60
60
|
prefetched: new Map(), // idx → { audioBase64, mimeType }
|
|
61
|
+
/** Currently-active replay item · what the round-table stage
|
|
62
|
+
* reads via getActive() to drive seat highlights, the rt-bubble,
|
|
63
|
+
* and the subtitle bar. `state` flips to "thinking" while the
|
|
64
|
+
* next message is being fetched / synthesised, then "speaking"
|
|
65
|
+
* once audio.play resolves. Cleared on close + on playlist end. */
|
|
66
|
+
active: null, // { messageId, authorId, kind, state, body }
|
|
61
67
|
};
|
|
62
68
|
|
|
63
69
|
function isOpen() {
|
|
64
70
|
return !!STATE.overlay;
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
function getActive() {
|
|
74
|
+
return STATE.active;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Expose the live audio element so the round-table stage's
|
|
78
|
+
* subtitle bar can sync with playback time (currentTime /
|
|
79
|
+
* duration) when replay is active. The replay's audio is a
|
|
80
|
+
* single full-message clip (not a chunked stream), so the
|
|
81
|
+
* subtitle has to interpolate sentence position from the
|
|
82
|
+
* playhead — there's no per-chunk timing metadata available. */
|
|
83
|
+
function getActiveAudio() {
|
|
84
|
+
return STATE.audio || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Fire a DOM event so listeners (the room view's renderRoundTable
|
|
88
|
+
* + renderRoundTableHud) can repaint without each having to
|
|
89
|
+
* poll. Bubble + composed so a subtree listener catches it. */
|
|
90
|
+
function emitActiveChanged() {
|
|
91
|
+
try {
|
|
92
|
+
const ev = new CustomEvent("boardroom:replay-active", {
|
|
93
|
+
detail: STATE.active ? { ...STATE.active } : null,
|
|
94
|
+
bubbles: true,
|
|
95
|
+
});
|
|
96
|
+
document.dispatchEvent(ev);
|
|
97
|
+
} catch { /* IE-style envs · CustomEvent missing → noop */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function setActive(next) {
|
|
101
|
+
STATE.active = next;
|
|
102
|
+
emitActiveChanged();
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
/** Build the ordered playlist · keep chronological order, drop
|
|
68
106
|
* procedural / system messages, optionally drop user messages.
|
|
69
107
|
* Each entry carries everything the playback loop and the UI
|
|
@@ -169,8 +207,10 @@
|
|
|
169
207
|
STATE.overlay = null;
|
|
170
208
|
}
|
|
171
209
|
clearActiveHighlight();
|
|
210
|
+
removeInlineExpand(); // any inline pill in the adjourned-bar drops too
|
|
172
211
|
STATE.playlist = [];
|
|
173
212
|
STATE.prefetched = new Map();
|
|
213
|
+
setActive(null); // round-table stage clears its replay seat / subtitle
|
|
174
214
|
}
|
|
175
215
|
|
|
176
216
|
// ─── Mount + render ──────────────────────────────────────────
|
|
@@ -182,7 +222,10 @@
|
|
|
182
222
|
el.innerHTML = `
|
|
183
223
|
<div class="vr-head">
|
|
184
224
|
<span class="vr-kicker"><span class="vr-kicker-glyph">♪</span> voice replay</span>
|
|
185
|
-
<
|
|
225
|
+
<div class="vr-head-actions">
|
|
226
|
+
<button type="button" class="vr-collapse" data-vr-collapse aria-label="Collapse" title="Collapse">_</button>
|
|
227
|
+
<button type="button" class="vr-close" data-vr-close aria-label="Close">✕</button>
|
|
228
|
+
</div>
|
|
186
229
|
</div>
|
|
187
230
|
<div class="vr-body" data-vr-body>
|
|
188
231
|
<div class="vr-spinner-row">
|
|
@@ -196,10 +239,25 @@
|
|
|
196
239
|
</div>
|
|
197
240
|
`;
|
|
198
241
|
document.body.appendChild(el);
|
|
242
|
+
// Every fresh `open()` starts with the floating panel
|
|
243
|
+
// expanded — collapse is a per-session preference, not a
|
|
244
|
+
// sticky one. The previous version persisted `voice-replay.
|
|
245
|
+
// collapsed` to localStorage so a re-open would mount
|
|
246
|
+
// already-collapsed; that confused users who clicked the
|
|
247
|
+
// bottom-bar Voice Replay button and saw the inline group
|
|
248
|
+
// appear without the floating panel ever showing. The bug
|
|
249
|
+
// looked like "Voice Replay morphs into Pause/Next/Stop
|
|
250
|
+
// /Expand instead of opening the player." Solve by NOT
|
|
251
|
+
// restoring the collapsed flag on cold open. Also clear any
|
|
252
|
+
// legacy "1" left in storage from earlier builds so users
|
|
253
|
+
// upgrading from those don't keep getting the same bug for
|
|
254
|
+
// one more session before re-toggling.
|
|
255
|
+
try { localStorage.removeItem("voice-replay.collapsed"); } catch { /* noop */ }
|
|
199
256
|
el.addEventListener("click", (ev) => {
|
|
200
257
|
const target = ev.target;
|
|
201
258
|
if (!target || !(target instanceof Element)) return;
|
|
202
259
|
if (target.closest("[data-vr-close]")) { ev.preventDefault(); close(); return; }
|
|
260
|
+
if (target.closest("[data-vr-collapse]")) { ev.preventDefault(); toggleCollapsed(); return; }
|
|
203
261
|
if (target.closest("[data-vr-pause]")) { ev.preventDefault(); togglePause(); return; }
|
|
204
262
|
if (target.closest("[data-vr-skip]")) { ev.preventDefault(); skipCurrent(); return; }
|
|
205
263
|
if (target.closest("[data-vr-speed]")) { ev.preventDefault(); cycleSpeed(); return; }
|
|
@@ -222,6 +280,169 @@
|
|
|
222
280
|
return el;
|
|
223
281
|
}
|
|
224
282
|
|
|
283
|
+
/** Doc-level handler for the inline replay control group in the
|
|
284
|
+
* adjourned-bar — those buttons are mounted OUTSIDE the overlay
|
|
285
|
+
* so the overlay-scoped click delegate above can't see them.
|
|
286
|
+
* Bound once per page lifetime; safe even when no replay is
|
|
287
|
+
* active (the buttons simply aren't in the DOM until
|
|
288
|
+
* toggleCollapsed mounts them). */
|
|
289
|
+
if (!root.__vrInlineExpandBound && typeof document !== "undefined"
|
|
290
|
+
&& typeof document.addEventListener === "function") {
|
|
291
|
+
root.__vrInlineExpandBound = true;
|
|
292
|
+
document.addEventListener("click", (ev) => {
|
|
293
|
+
const target = ev.target;
|
|
294
|
+
if (!target || !(target instanceof Element)) return;
|
|
295
|
+
if (target.closest("[data-vr-inline-next]")) {
|
|
296
|
+
ev.preventDefault();
|
|
297
|
+
skipCurrent();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (target.closest("[data-vr-inline-pause]")) {
|
|
301
|
+
ev.preventDefault();
|
|
302
|
+
togglePause();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (target.closest("[data-vr-inline-stop]")) {
|
|
306
|
+
ev.preventDefault();
|
|
307
|
+
close();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (target.closest("[data-vr-inline-expand]")) {
|
|
311
|
+
ev.preventDefault();
|
|
312
|
+
toggleCollapsed();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Collapse the player by hiding the floating overlay entirely +
|
|
319
|
+
* surfacing the inline replay control group in the adjourned-bar
|
|
320
|
+
* right after where the Voice Replay button used to be. Audio
|
|
321
|
+
* keeps playing in the background; the user's content is no
|
|
322
|
+
* longer blocked.
|
|
323
|
+
*
|
|
324
|
+
* The collapsed posture is per-session only. We deliberately
|
|
325
|
+
* do NOT persist to localStorage — restoring a collapsed flag
|
|
326
|
+
* on cold open would make a fresh `open()` look like the panel
|
|
327
|
+
* never appeared (it'd mount already-collapsed and hide the
|
|
328
|
+
* floating overlay), confusing users who expect the player to
|
|
329
|
+
* show every time they click Voice Replay. */
|
|
330
|
+
function toggleCollapsed() {
|
|
331
|
+
if (!STATE.overlay) return;
|
|
332
|
+
const collapsed = STATE.overlay.classList.toggle("is-collapsed");
|
|
333
|
+
if (collapsed) mountInlineExpand();
|
|
334
|
+
else removeInlineExpand();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Slot the inline replay control group into the bottom-bar
|
|
338
|
+
* action group right after the existing Voice Replay anchor.
|
|
339
|
+
* Three buttons: Next (skip current message), Pause/Resume
|
|
340
|
+
* (toggles audio playback), Expand (re-opens the panel). The
|
|
341
|
+
* group reads as a sibling of Export / Voice Replay / Convene
|
|
342
|
+
* Follow-up via `.ghost-btn` chrome. Idempotent · re-mount is
|
|
343
|
+
* a no-op when the group is already present. */
|
|
344
|
+
function mountInlineExpand() {
|
|
345
|
+
if (document.querySelector("[data-vr-inline-group]")) return;
|
|
346
|
+
const replayBtn = document.querySelector(".adjourned-bar [data-room-replay]");
|
|
347
|
+
if (!replayBtn) return;
|
|
348
|
+
const group = document.createElement("span");
|
|
349
|
+
group.className = "vr-inline-group";
|
|
350
|
+
group.setAttribute("data-vr-inline-group", "1");
|
|
351
|
+
// Inline-SVG icons · all `currentColor` so they inherit
|
|
352
|
+
// `.ghost-btn`'s text + lime-on-hover treatment. Standard
|
|
353
|
+
// media-player vocabulary: filled triangles for play/skip,
|
|
354
|
+
// filled bars for pause, stroked corner-out arrows for expand.
|
|
355
|
+
// 14×14 viewBox sized down to 12px so the buttons stay tight.
|
|
356
|
+
// Hover / aria-label carry the action text.
|
|
357
|
+
const NEXT_SVG = `
|
|
358
|
+
<svg class="vib-icon" viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
|
|
359
|
+
<path d="M2 2 L7.5 7 L2 12 Z" fill="currentColor"/>
|
|
360
|
+
<path d="M7 2 L12.5 7 L7 12 Z" fill="currentColor"/>
|
|
361
|
+
</svg>
|
|
362
|
+
`;
|
|
363
|
+
const PAUSE_SVG = `
|
|
364
|
+
<svg class="vib-icon" viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
|
|
365
|
+
<rect x="3.5" y="2.5" width="2.5" height="9" rx="0.6" fill="currentColor"/>
|
|
366
|
+
<rect x="8" y="2.5" width="2.5" height="9" rx="0.6" fill="currentColor"/>
|
|
367
|
+
</svg>
|
|
368
|
+
`;
|
|
369
|
+
const PLAY_SVG = `
|
|
370
|
+
<svg class="vib-icon" viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
|
|
371
|
+
<path d="M3.5 2 L12 7 L3.5 12 Z" fill="currentColor"/>
|
|
372
|
+
</svg>
|
|
373
|
+
`;
|
|
374
|
+
// Stash the play SVG on the constructor so refreshInlinePauseButton
|
|
375
|
+
// can swap between Pause / Play without re-wiring the markup.
|
|
376
|
+
group.dataset.vrPlaySvg = PLAY_SVG.trim();
|
|
377
|
+
group.dataset.vrPauseSvg = PAUSE_SVG.trim();
|
|
378
|
+
const STOP_SVG = `
|
|
379
|
+
<svg class="vib-icon" viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
|
|
380
|
+
<rect x="3" y="3" width="8" height="8" rx="0.6" fill="currentColor"/>
|
|
381
|
+
</svg>
|
|
382
|
+
`;
|
|
383
|
+
const EXPAND_SVG = `
|
|
384
|
+
<svg class="vib-icon" viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
|
|
385
|
+
<!-- Top-right corner out -->
|
|
386
|
+
<polyline points="8.5,3 11,3 11,5.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
387
|
+
<line x1="11" y1="3" x2="7.5" y2="6.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
|
388
|
+
<!-- Bottom-left corner out -->
|
|
389
|
+
<polyline points="5.5,11 3,11 3,8.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
390
|
+
<line x1="3" y1="11" x2="6.5" y2="7.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
|
391
|
+
</svg>
|
|
392
|
+
`;
|
|
393
|
+
group.innerHTML = `
|
|
394
|
+
<button type="button" class="ghost-btn vr-inline-btn" data-vr-inline-next aria-label="Next message" title="Next message">${NEXT_SVG}</button>
|
|
395
|
+
<button type="button" class="ghost-btn vr-inline-btn" data-vr-inline-pause aria-label="Pause" title="Pause"><span data-vib-pause-mark>${PAUSE_SVG}</span></button>
|
|
396
|
+
<button type="button" class="ghost-btn vr-inline-btn" data-vr-inline-stop aria-label="Stop replay" title="Stop replay">${STOP_SVG}</button>
|
|
397
|
+
<button type="button" class="ghost-btn vr-inline-btn vr-inline-expand" data-vr-inline-expand aria-label="Expand voice replay" title="Expand voice replay">${EXPAND_SVG}<span class="vie-pulse" aria-hidden="true"></span></button>
|
|
398
|
+
`;
|
|
399
|
+
replayBtn.insertAdjacentElement("afterend", group);
|
|
400
|
+
// Hide the original Voice Replay anchor while the inline group
|
|
401
|
+
// is showing — its job (open the player) is supplanted by the
|
|
402
|
+
// inline Expand button. Stash the previous display so we can
|
|
403
|
+
// restore it cleanly on remove.
|
|
404
|
+
replayBtn.dataset.vrPrevDisplay = replayBtn.style.display || "";
|
|
405
|
+
replayBtn.style.display = "none";
|
|
406
|
+
refreshInlinePauseButton();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function removeInlineExpand() {
|
|
410
|
+
const group = document.querySelector("[data-vr-inline-group]");
|
|
411
|
+
if (group) group.remove();
|
|
412
|
+
// Restore the original Voice Replay anchor in the bottom bar
|
|
413
|
+
// so the user can re-trigger the player. We stashed the prior
|
|
414
|
+
// inline display when we hid it; restore it (empty string ==
|
|
415
|
+
// CSS default).
|
|
416
|
+
const replayBtn = document.querySelector(".adjourned-bar [data-room-replay]");
|
|
417
|
+
if (replayBtn) {
|
|
418
|
+
const prev = replayBtn.dataset.vrPrevDisplay;
|
|
419
|
+
replayBtn.style.display = (prev === undefined || prev === null) ? "" : prev;
|
|
420
|
+
delete replayBtn.dataset.vrPrevDisplay;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Sync the inline pause/resume button with the live STATE.paused
|
|
425
|
+
* flag. Called from mount, togglePause, and on advance so the
|
|
426
|
+
* glyph + label always read the current state. No-op when the
|
|
427
|
+
* inline group isn't mounted. */
|
|
428
|
+
function refreshInlinePauseButton() {
|
|
429
|
+
const mark = document.querySelector("[data-vib-pause-mark]");
|
|
430
|
+
if (!mark) return;
|
|
431
|
+
const group = document.querySelector("[data-vr-inline-group]");
|
|
432
|
+
if (!group) return;
|
|
433
|
+
const playing = STATE.audio && !STATE.paused && !STATE.audio.paused;
|
|
434
|
+
// Pull the cached SVG sources stashed at mount time. innerHTML
|
|
435
|
+
// assignment is fine — SVG strings are author-controlled
|
|
436
|
+
// constants, not user input.
|
|
437
|
+
mark.innerHTML = playing ? group.dataset.vrPauseSvg : group.dataset.vrPlaySvg;
|
|
438
|
+
const btn = mark.closest("[data-vr-inline-pause]");
|
|
439
|
+
if (btn) {
|
|
440
|
+
const label = playing ? "Pause" : "Resume";
|
|
441
|
+
btn.setAttribute("aria-label", label);
|
|
442
|
+
btn.setAttribute("title", label);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
225
446
|
function setBusy(busy, msg) {
|
|
226
447
|
if (!STATE.overlay) return;
|
|
227
448
|
const t = STATE.overlay.querySelector("[data-vr-spinner-text]");
|
|
@@ -312,6 +533,17 @@
|
|
|
312
533
|
if (!cur) { close(); return; }
|
|
313
534
|
renderPlayer();
|
|
314
535
|
highlightActive(cur.messageId);
|
|
536
|
+
// Mark the seat as "thinking" while we fetch + synthesise. The
|
|
537
|
+
// round-table stage reads this via getActive() and lights the
|
|
538
|
+
// seat with a thinking bubble. Body is included so the stage
|
|
539
|
+
// subtitle can preview the line that's about to be spoken.
|
|
540
|
+
setActive({
|
|
541
|
+
messageId: cur.messageId,
|
|
542
|
+
authorId: cur.authorId,
|
|
543
|
+
kind: cur.kind,
|
|
544
|
+
state: "thinking",
|
|
545
|
+
body: cur.body || "",
|
|
546
|
+
});
|
|
315
547
|
let payload;
|
|
316
548
|
try {
|
|
317
549
|
payload = await fetchAudio(STATE.idx);
|
|
@@ -336,14 +568,40 @@
|
|
|
336
568
|
STATE.audio.playbackRate = STATE.speed;
|
|
337
569
|
STATE.audio.addEventListener("ended", () => advance());
|
|
338
570
|
STATE.audio.addEventListener("error", () => advance());
|
|
571
|
+
// Tick out a DOM event on every timeupdate (~4 Hz) so the
|
|
572
|
+
// round-table stage's subtitle bar can poll currentTime /
|
|
573
|
+
// duration and interpolate which sentence is being read. We
|
|
574
|
+
// can't capture per-sentence timing for replay (the audio is
|
|
575
|
+
// a single base64-decoded clip, not a chunked stream), so the
|
|
576
|
+
// subtitle has to estimate · firing the event keeps the
|
|
577
|
+
// cadence consistent without coupling the modules.
|
|
578
|
+
STATE.audio.addEventListener("timeupdate", () => {
|
|
579
|
+
try {
|
|
580
|
+
const ev = new CustomEvent("boardroom:replay-tick", { bubbles: true });
|
|
581
|
+
document.dispatchEvent(ev);
|
|
582
|
+
} catch { /* old browsers · noop */ }
|
|
583
|
+
});
|
|
339
584
|
if (!STATE.paused) {
|
|
340
|
-
try {
|
|
585
|
+
try {
|
|
586
|
+
await STATE.audio.play();
|
|
587
|
+
// Audio is running · flip seat from "thinking" → "speaking".
|
|
588
|
+
setActive({
|
|
589
|
+
messageId: cur.messageId,
|
|
590
|
+
authorId: cur.authorId,
|
|
591
|
+
kind: cur.kind,
|
|
592
|
+
state: "speaking",
|
|
593
|
+
body: cur.body || "",
|
|
594
|
+
});
|
|
595
|
+
}
|
|
341
596
|
catch (e) {
|
|
342
597
|
// Autoplay block · pause and let the user click resume.
|
|
343
598
|
STATE.paused = true;
|
|
344
599
|
renderPlayer();
|
|
345
600
|
}
|
|
346
601
|
}
|
|
602
|
+
// Sync the inline (collapsed) pause button so its glyph
|
|
603
|
+
// tracks the live playback state across message handoffs.
|
|
604
|
+
refreshInlinePauseButton();
|
|
347
605
|
// Pre-fetch the next message while this one plays so the
|
|
348
606
|
// handoff is gapless. Single in-flight pre-fetch.
|
|
349
607
|
void prefetch(STATE.idx + 1);
|
|
@@ -355,6 +613,7 @@
|
|
|
355
613
|
if (STATE.idx >= STATE.playlist.length) {
|
|
356
614
|
// Playback complete · keep the overlay open with a "done"
|
|
357
615
|
// message so the user can dismiss explicitly.
|
|
616
|
+
setActive(null);
|
|
358
617
|
const body = STATE.overlay && STATE.overlay.querySelector("[data-vr-body]");
|
|
359
618
|
if (body) {
|
|
360
619
|
body.innerHTML = `
|
|
@@ -415,6 +674,7 @@
|
|
|
415
674
|
if (!STATE.audio) {
|
|
416
675
|
STATE.paused = !STATE.paused;
|
|
417
676
|
renderPlayer();
|
|
677
|
+
refreshInlinePauseButton();
|
|
418
678
|
return;
|
|
419
679
|
}
|
|
420
680
|
if (STATE.audio.paused) {
|
|
@@ -425,6 +685,7 @@
|
|
|
425
685
|
STATE.paused = true;
|
|
426
686
|
}
|
|
427
687
|
renderPlayer();
|
|
688
|
+
refreshInlinePauseButton();
|
|
428
689
|
}
|
|
429
690
|
|
|
430
691
|
function skipCurrent() {
|
|
@@ -527,6 +788,8 @@
|
|
|
527
788
|
open: open,
|
|
528
789
|
close: close,
|
|
529
790
|
isOpen: isOpen,
|
|
791
|
+
getActive: getActive,
|
|
792
|
+
getActiveAudio: getActiveAudio,
|
|
530
793
|
// Exposed for testing.
|
|
531
794
|
_internals: { buildPlaylist, PROCEDURAL_KINDS },
|
|
532
795
|
};
|