my-sea voice: voice-btn glow/pulse state machine (sea-precedence, pulse-while-alone, 2x on 2nd party) — TDD

Phase 3 of the my-sea voice batch (user-spec 2026-05-29). A --quaUser/--ninUser
glow + pulse machine for the burger btn + its voice sub-btn, driven by the
voice sub-btn availability (voice_active) + the live mesh state.

- voice-mesh.js: VoiceRoom gains a state-change hook — setOnStateChange(cb) +
  peerCount() + _notify({inCall, peerCount, muted}), fired on join, every peer
  add/drop, mute toggle, and teardown. No behaviour change without a subscriber
  (VoiceMeshSpec stays green).
- voice-glow.js (new): the glow machine. PRE-JOIN nudge — burger glows when the
  fan is closed (sea draw-nudge keeps burger precedence; voice reclaims it once
  the sea glow clears), voice sub-btn glows when the fan opens. LIVE — the glow
  PULSES on whichever surface shows (voice sub-btn fan-open, burger fan-closed):
  base 2s cadence while alone, doubled (.voice-pulse--fast) once a 2nd party
  connects (equalizer stand-in; a true volume-reactive equalizer is a live-only
  enhancement). Class writes are reconciled (idempotent) so the burger-class
  MutationObserver doesn't feed back on itself.
- _burger.scss: .voice-glow + @keyframes voice-pulse + .voice-pulse(--fast).
- loaded on my_sea.html + my_sea_visit.html (after burger-btn.js).
- VoiceGlowSpec.js (18 specs) + registered in SpecRunner; MySeaSeatsSpec flare
  window updated 1.5s → 2s (Phase 2 bump). 428 Jasmine specs green.

Live-verify on staging: the actual glow colours/cadence + the equalizer
upgrade (item 5) and disconnect states (item 7) land in later phases.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-29 21:06:02 -04:00
parent 7bd8e3256a
commit b021d8017c
11 changed files with 519 additions and 2 deletions

View File

@@ -0,0 +1,126 @@
// voice-glow.js — the my-sea voice-affordance glow/pulse state machine (Phase 3
// of the my-sea invite/voice batch, user-spec 2026-05-29). Drives --quaUser /
// --ninUser glow + pulse on the burger btn + its voice sub-btn off two signals:
// • the voice sub-btn's availability (.active, server-rendered voice_active),
// • the live mesh state (VoiceRoom.setOnStateChange → {inCall, peerCount}).
//
// Rules (mirrors the sea-btn glow handoff, but for voice):
// PRE-JOIN nudge (voice available, mic not yet live):
// • burger open → the voice sub-btn glows (steady --quaUser). The sea-btn
// draw nudge keeps burger precedence; voice is the second-place reveal.
// • burger closed → the burger glows, but ONLY once the sea nudge has
// cleared (sea takes precedence on the burger).
// LIVE (mic in use):
// • the glow PULSES (ease in/out). burger open → the voice sub-btn pulses;
// fan collapsed → the burger pulses instead, so the mic-live cue rides
// whichever surface is showing.
// • alone in the room → base 2s cadence (.voice-pulse). A 2nd+ party →
// doubled cadence (.voice-pulse--fast) — an equalizer stand-in (a true
// volume-reactive equalizer is a live-only enhancement).
//
// `bindVoiceGlow()` reads the DOM, wires the observers + mesh subscription, and
// returns { render, setVoiceState, destroy } for tests / re-binds. Class writes
// are reconciled (idempotent) so the burger-class MutationObserver doesn't feed
// back into itself.
(function () {
'use strict';
var OUR_CLASSES = ['voice-glow', 'voice-pulse', 'voice-pulse--fast'];
function bindVoiceGlow() {
var burger = document.getElementById('id_burger_btn');
var voice = document.getElementById('id_voice_btn');
var sea = document.getElementById('id_sea_btn');
if (!burger || !voice) return null;
var inCall = false;
var peerCount = 0;
var observers = [];
// Reconcile el's OUR_CLASSES to exactly `want` — only mutate on an
// actual diff so the MutationObserver below doesn't loop on our own
// writes.
function _apply(el, want) {
OUR_CLASSES.forEach(function (c) {
var has = el.classList.contains(c);
var need = want.indexOf(c) !== -1;
if (need && !has) el.classList.add(c);
else if (!need && has) el.classList.remove(c);
});
}
function render() {
var voiceWant = [];
var burgerWant = [];
if (voice.classList.contains('active')) { // voice available
var burgerOpen = burger.classList.contains('active');
var live = inCall || voice.classList.contains('in-call');
if (live) {
var pulse = peerCount > 0 ? 'voice-pulse--fast' : 'voice-pulse';
(burgerOpen ? voiceWant : burgerWant).push('voice-glow', pulse);
} else {
var seaGlow = (sea && sea.classList.contains('glow-handoff'))
|| burger.classList.contains('glow-handoff');
if (burgerOpen) voiceWant.push('voice-glow');
else if (!seaGlow) burgerWant.push('voice-glow');
}
}
_apply(voice, voiceWant);
_apply(burger, burgerWant);
}
function setVoiceState(st) {
st = st || {};
inCall = !!st.inCall;
peerCount = st.peerCount || 0;
render();
}
// Burger open/close + sea-btn glow handoff are both class changes.
[burger, sea].forEach(function (el) {
if (!el) return;
var mo = new MutationObserver(render);
mo.observe(el, { attributes: true, attributeFilter: ['class'] });
observers.push(mo);
});
// Subscribe to the mesh. VoiceRoom may load lazily on the first voice
// click (burger-btn.js injects voice-mesh.js), so retry briefly after
// a click until the singleton appears.
function subscribe() {
if (window.VoiceRoom && window.VoiceRoom.setOnStateChange) {
window.VoiceRoom.setOnStateChange(setVoiceState);
return true;
}
return false;
}
if (!subscribe()) {
voice.addEventListener('click', function () {
var tries = 0;
var iv = setInterval(function () {
if (subscribe() || ++tries > 40) clearInterval(iv);
}, 50);
});
}
render();
return {
render: render,
setVoiceState: setVoiceState,
destroy: function () {
observers.forEach(function (o) { o.disconnect(); });
observers = [];
},
};
}
window.bindVoiceGlow = bindVoiceGlow;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindVoiceGlow);
} else {
bindVoiceGlow();
}
}());

View File

@@ -40,8 +40,32 @@
this.iceServers = ICE_FALLBACK; this.iceServers = ICE_FALLBACK;
this.selfId = null; this.selfId = null;
this.muted = false; this.muted = false;
// Subscriber for connection-state changes (voice-glow.js drives the
// burger/voice-btn glow machine off it). Fired on join, every peer
// add/drop, mute toggle, and teardown.
this._onState = null;
} }
// ── State-change notification (drives the glow machine) ───────────────────
VoiceRoom.prototype.peerCount = function () {
return Object.keys(this.peers).length;
};
VoiceRoom.prototype.setOnStateChange = function (cb) {
this._onState = cb;
this._notify(); // push current state immediately on subscribe
};
VoiceRoom.prototype._notify = function () {
if (typeof this._onState !== 'function') return;
this._onState({
inCall: !!this.localStream,
peerCount: this.peerCount(),
muted: this.muted,
});
};
VoiceRoom.prototype._fetchTurn = function () { VoiceRoom.prototype._fetchTurn = function () {
return fetch('/api/voice/turn-credentials/', { return fetch('/api/voice/turn-credentials/', {
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
@@ -73,6 +97,7 @@
el.srcObject = e.streams[0]; el.srcObject = e.streams[0];
}; };
this.peers[peerId] = pc; this.peers[peerId] = pc;
this._notify(); // a peer joined the mesh
return pc; return pc;
}; };
@@ -105,6 +130,7 @@
if (pc) { try { pc.close(); } catch (e) {} delete this.peers[peerId]; } if (pc) { try { pc.close(); } catch (e) {} delete this.peers[peerId]; }
var el = document.getElementById('voice-audio-' + peerId); var el = document.getElementById('voice-audio-' + peerId);
if (el && el.parentNode) el.parentNode.removeChild(el); if (el && el.parentNode) el.parentNode.removeChild(el);
this._notify(); // a peer left the mesh
}; };
VoiceRoom.prototype._send = function (obj) { VoiceRoom.prototype._send = function (obj) {
@@ -158,6 +184,7 @@
self.ws = new WebSocket(scheme + '://' + window.location.host + '/ws/voice/' + roomId + '/'); self.ws = new WebSocket(scheme + '://' + window.location.host + '/ws/voice/' + roomId + '/');
self.ws.onmessage = function (e) { self._onMessage(JSON.parse(e.data)); }; self.ws.onmessage = function (e) { self._onMessage(JSON.parse(e.data)); };
self.ws.onclose = function () { self._teardown(); }; self.ws.onclose = function () { self._teardown(); };
self._notify(); // mic live (in-call), still alone
return self; return self;
}); });
}; };
@@ -169,6 +196,7 @@
t.enabled = !this.muted; t.enabled = !this.muted;
}, this); }, this);
} }
this._notify();
return this.muted; return this.muted;
}; };
@@ -178,6 +206,7 @@
this.localStream.getTracks().forEach(function (t) { t.stop(); }); this.localStream.getTracks().forEach(function (t) { t.stop(); });
this.localStream = null; this.localStream = null;
} }
this._notify(); // call ended — back to not-in-call
}; };
VoiceRoom.prototype.leave = function () { VoiceRoom.prototype.leave = function () {

View File

@@ -43,11 +43,14 @@ describe('my-sea-seats one-shot seated glow', function () {
expect(seat.classList.contains('seat-just-seated')).toBe(true); expect(seat.classList.contains('seat-just-seated')).toBe(true);
}); });
it('removes the flare class after the ~1.5s glow window', function () { it('removes the flare class after the 2s glow window', function () {
jasmine.clock().install(); jasmine.clock().install();
window.playSeatGlow(seat); window.playSeatGlow(seat);
expect(seat.classList.contains('seat-just-seated')).toBe(true); expect(seat.classList.contains('seat-just-seated')).toBe(true);
// Still flaring mid-window (bumped 1.5s → 2s, user-spec 2026-05-29).
jasmine.clock().tick(1600); jasmine.clock().tick(1600);
expect(seat.classList.contains('seat-just-seated')).toBe(true);
jasmine.clock().tick(500);
expect(seat.classList.contains('seat-just-seated')).toBe(false); expect(seat.classList.contains('seat-just-seated')).toBe(false);
jasmine.clock().uninstall(); jasmine.clock().uninstall();
}); });

View File

@@ -33,6 +33,7 @@
<script src="BurgerSpec.js"></script> <script src="BurgerSpec.js"></script>
<script src="MySeaSeatsSpec.js"></script> <script src="MySeaSeatsSpec.js"></script>
<script src="VoiceMeshSpec.js"></script> <script src="VoiceMeshSpec.js"></script>
<script src="VoiceGlowSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script> <script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.js"></script> <script src="/static/apps/dashboard/dashboard.js"></script>
@@ -49,6 +50,7 @@
<script src="/static/apps/gameboard/game-kit.js"></script> <script src="/static/apps/gameboard/game-kit.js"></script>
<script src="/static/apps/gameboard/my-sea-seats.js"></script> <script src="/static/apps/gameboard/my-sea-seats.js"></script>
<script src="/static/apps/voice/voice-mesh.js"></script> <script src="/static/apps/voice/voice-mesh.js"></script>
<script src="/static/apps/voice/voice-glow.js"></script>
<script src="/static/apps/gameboard/d3.min.js"></script> <script src="/static/apps/gameboard/d3.min.js"></script>
<script src="/static/apps/gameboard/sky-wheel.js"></script> <script src="/static/apps/gameboard/sky-wheel.js"></script>
<!-- Jasmine env config (optional) --> <!-- Jasmine env config (optional) -->

View File

@@ -0,0 +1,152 @@
// ── VoiceGlowSpec.js ─────────────────────────────────────────────────────────
//
// Unit specs for voice-glow.js — the my-sea voice-affordance glow/pulse state
// machine (Phase 3, user-spec 2026-05-29).
//
// DOM contract:
// #id_burger_btn — burger btn (carries .active when fan open, .glow-handoff
// when the sea draw-nudge is on it)
// #id_voice_btn — voice sub-btn (.active = voice available; .in-call once
// the mic is live)
// #id_sea_btn — sea sub-btn (.glow-handoff = sea draw-nudge active)
//
// Public API under test (window.bindVoiceGlow):
// var vg = bindVoiceGlow(); // {render, setVoiceState, destroy}
// vg.setVoiceState({inCall, peerCount});
//
// Glow classes: .voice-glow (steady), .voice-pulse (alone), .voice-pulse--fast
// (a 2nd+ party connected).
// ─────────────────────────────────────────────────────────────────────────────
describe("VoiceGlow", () => {
let burger, voice, sea, vg;
function mk(id) {
const el = document.createElement("button");
el.id = id;
document.body.appendChild(el);
return el;
}
beforeEach(() => {
// Ensure no stale singleton subscription from a prior page load.
if (window.VoiceRoom) window.VoiceRoom._onState = null;
burger = mk("id_burger_btn");
voice = mk("id_voice_btn");
sea = mk("id_sea_btn");
voice.classList.add("active"); // voice available by default
});
afterEach(() => {
if (vg) { vg.destroy(); vg = null; }
[burger, voice, sea].forEach((el) => el && el.remove());
});
const hasGlow = (el) => el.classList.contains("voice-glow");
const hasPulse = (el) => el.classList.contains("voice-pulse");
const hasFast = (el) => el.classList.contains("voice-pulse--fast");
describe("bindVoiceGlow()", () => {
it("returns null when the burger btn is absent", () => {
burger.remove(); burger = null;
expect(bindVoiceGlow()).toBeNull();
});
it("returns null when the voice btn is absent", () => {
voice.remove(); voice = null;
expect(bindVoiceGlow()).toBeNull();
});
});
describe("pre-join nudge (voice available, mic not live)", () => {
it("glows the burger when the fan is closed and no sea nudge", () => {
vg = bindVoiceGlow();
expect(hasGlow(burger)).toBe(true);
expect(hasGlow(voice)).toBe(false);
});
it("does NOT glow when voice is unavailable (no .active)", () => {
voice.classList.remove("active");
vg = bindVoiceGlow();
expect(hasGlow(burger)).toBe(false);
expect(hasGlow(voice)).toBe(false);
});
it("yields burger precedence to the sea draw-nudge (sea glowing)", () => {
sea.classList.add("glow-handoff");
vg = bindVoiceGlow();
expect(hasGlow(burger)).toBe(false); // sea keeps the burger
});
it("reclaims the burger once the sea nudge clears", () => {
sea.classList.add("glow-handoff");
vg = bindVoiceGlow();
expect(hasGlow(burger)).toBe(false);
sea.classList.remove("glow-handoff");
vg.render();
expect(hasGlow(burger)).toBe(true);
});
it("moves the glow to the voice sub-btn when the fan opens", () => {
vg = bindVoiceGlow();
burger.classList.add("active"); // fan open
vg.render();
expect(hasGlow(voice)).toBe(true);
expect(hasGlow(burger)).toBe(false);
});
});
describe("live (mic in use)", () => {
it("pulses the burger (base cadence) while alone + fan closed", () => {
vg = bindVoiceGlow();
vg.setVoiceState({ inCall: true, peerCount: 0 });
expect(hasGlow(burger)).toBe(true);
expect(hasPulse(burger)).toBe(true);
expect(hasFast(burger)).toBe(false);
});
it("pulses the voice sub-btn while alone + fan open", () => {
vg = bindVoiceGlow();
burger.classList.add("active");
vg.setVoiceState({ inCall: true, peerCount: 0 });
expect(hasPulse(voice)).toBe(true);
expect(hasPulse(burger)).toBe(false);
});
it("doubles the cadence once a 2nd party connects", () => {
vg = bindVoiceGlow();
vg.setVoiceState({ inCall: true, peerCount: 1 });
expect(hasFast(burger)).toBe(true);
expect(hasPulse(burger)).toBe(false);
});
it("hands the pulse to whichever surface is showing (fan toggles)", () => {
vg = bindVoiceGlow();
vg.setVoiceState({ inCall: true, peerCount: 0 });
expect(hasPulse(burger)).toBe(true);
burger.classList.add("active"); // fan opens mid-call
vg.render();
expect(hasPulse(voice)).toBe(true);
expect(hasPulse(burger)).toBe(false);
});
it("clears the pulse when the call ends", () => {
vg = bindVoiceGlow();
vg.setVoiceState({ inCall: true, peerCount: 0 });
expect(hasPulse(burger)).toBe(true);
vg.setVoiceState({ inCall: false, peerCount: 0 });
expect(hasPulse(burger)).toBe(false);
expect(hasGlow(burger)).toBe(true); // back to the pre-join nudge
});
});
describe("idempotent reconcile", () => {
it("does not stack duplicate classes across repeated renders", () => {
vg = bindVoiceGlow();
vg.setVoiceState({ inCall: true, peerCount: 1 });
vg.render(); vg.render();
const fastCount = (burger.className.match(/voice-pulse--fast/g) || []).length;
expect(fastCount).toBe(1);
});
});
});

View File

@@ -189,6 +189,47 @@
0 0 1.2rem 0.3rem rgba(var(--ninUser), 0.35); 0 0 1.2rem 0.3rem rgba(var(--ninUser), 0.35);
} }
// ── Voice affordance glow + pulse (Phase 3, my-sea voice) ─────────────
//
// Distinct from the sea-btn's --priYl `.glow-handoff` draw nudge: voice uses
// a --quaUser tint + --ninUser halo. voice-glow.js owns the class transitions
// (sea takes burger precedence; the voice glow surfaces on the voice sub-btn
// when the fan is open, or on the burger once the sea nudge clears). Once the
// mic is live the glow PULSES — base cadence while alone, doubled once a 2nd
// party connects (equalizer stand-in).
#id_voice_btn.voice-glow,
#id_burger_btn.voice-glow {
color: rgba(var(--quaUser), 1);
border-color: rgba(var(--quaUser), 1);
box-shadow:
0 0 0.5rem 0.1rem rgba(var(--ninUser), 0.75),
0 0 1.2rem 0.3rem rgba(var(--ninUser), 0.35);
}
@keyframes voice-pulse {
0%, 100% {
box-shadow:
0 0 0.35rem 0.05rem rgba(var(--ninUser), 0.35),
0 0 0.7rem 0.1rem rgba(var(--ninUser), 0.2);
}
50% {
box-shadow:
0 0 0.7rem 0.2rem rgba(var(--ninUser), 0.9),
0 0 1.5rem 0.5rem rgba(var(--ninUser), 0.45);
}
}
// Base cadence — one full ease in+out per 2s (1s each way) while the visiting
// user is alone in the voice room.
#id_voice_btn.voice-pulse,
#id_burger_btn.voice-pulse {
animation: voice-pulse 2s ease-in-out infinite;
}
// Doubled cadence once a 2nd party is connected.
#id_voice_btn.voice-pulse--fast,
#id_burger_btn.voice-pulse--fast {
animation: voice-pulse 1s ease-in-out infinite;
}
// Burger hides when bud_panel is open — LANDSCAPE only. In portrait the // Burger hides when bud_panel is open — LANDSCAPE only. In portrait the
// burger sits ABOVE the bud panel (bottom:4.2rem vs panel at bottom:0.5 // burger sits ABOVE the bud panel (bottom:4.2rem vs panel at bottom:0.5
// + height:3rem); no visual conflict. In landscape they share the // + height:3rem); no visual conflict. In landscape they share the

View File

@@ -43,11 +43,14 @@ describe('my-sea-seats one-shot seated glow', function () {
expect(seat.classList.contains('seat-just-seated')).toBe(true); expect(seat.classList.contains('seat-just-seated')).toBe(true);
}); });
it('removes the flare class after the ~1.5s glow window', function () { it('removes the flare class after the 2s glow window', function () {
jasmine.clock().install(); jasmine.clock().install();
window.playSeatGlow(seat); window.playSeatGlow(seat);
expect(seat.classList.contains('seat-just-seated')).toBe(true); expect(seat.classList.contains('seat-just-seated')).toBe(true);
// Still flaring mid-window (bumped 1.5s → 2s, user-spec 2026-05-29).
jasmine.clock().tick(1600); jasmine.clock().tick(1600);
expect(seat.classList.contains('seat-just-seated')).toBe(true);
jasmine.clock().tick(500);
expect(seat.classList.contains('seat-just-seated')).toBe(false); expect(seat.classList.contains('seat-just-seated')).toBe(false);
jasmine.clock().uninstall(); jasmine.clock().uninstall();
}); });

View File

@@ -33,6 +33,7 @@
<script src="BurgerSpec.js"></script> <script src="BurgerSpec.js"></script>
<script src="MySeaSeatsSpec.js"></script> <script src="MySeaSeatsSpec.js"></script>
<script src="VoiceMeshSpec.js"></script> <script src="VoiceMeshSpec.js"></script>
<script src="VoiceGlowSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script> <script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.js"></script> <script src="/static/apps/dashboard/dashboard.js"></script>
@@ -49,6 +50,7 @@
<script src="/static/apps/gameboard/game-kit.js"></script> <script src="/static/apps/gameboard/game-kit.js"></script>
<script src="/static/apps/gameboard/my-sea-seats.js"></script> <script src="/static/apps/gameboard/my-sea-seats.js"></script>
<script src="/static/apps/voice/voice-mesh.js"></script> <script src="/static/apps/voice/voice-mesh.js"></script>
<script src="/static/apps/voice/voice-glow.js"></script>
<script src="/static/apps/gameboard/d3.min.js"></script> <script src="/static/apps/gameboard/d3.min.js"></script>
<script src="/static/apps/gameboard/sky-wheel.js"></script> <script src="/static/apps/gameboard/sky-wheel.js"></script>
<!-- Jasmine env config (optional) --> <!-- Jasmine env config (optional) -->

View File

@@ -0,0 +1,152 @@
// ── VoiceGlowSpec.js ─────────────────────────────────────────────────────────
//
// Unit specs for voice-glow.js — the my-sea voice-affordance glow/pulse state
// machine (Phase 3, user-spec 2026-05-29).
//
// DOM contract:
// #id_burger_btn — burger btn (carries .active when fan open, .glow-handoff
// when the sea draw-nudge is on it)
// #id_voice_btn — voice sub-btn (.active = voice available; .in-call once
// the mic is live)
// #id_sea_btn — sea sub-btn (.glow-handoff = sea draw-nudge active)
//
// Public API under test (window.bindVoiceGlow):
// var vg = bindVoiceGlow(); // {render, setVoiceState, destroy}
// vg.setVoiceState({inCall, peerCount});
//
// Glow classes: .voice-glow (steady), .voice-pulse (alone), .voice-pulse--fast
// (a 2nd+ party connected).
// ─────────────────────────────────────────────────────────────────────────────
describe("VoiceGlow", () => {
let burger, voice, sea, vg;
function mk(id) {
const el = document.createElement("button");
el.id = id;
document.body.appendChild(el);
return el;
}
beforeEach(() => {
// Ensure no stale singleton subscription from a prior page load.
if (window.VoiceRoom) window.VoiceRoom._onState = null;
burger = mk("id_burger_btn");
voice = mk("id_voice_btn");
sea = mk("id_sea_btn");
voice.classList.add("active"); // voice available by default
});
afterEach(() => {
if (vg) { vg.destroy(); vg = null; }
[burger, voice, sea].forEach((el) => el && el.remove());
});
const hasGlow = (el) => el.classList.contains("voice-glow");
const hasPulse = (el) => el.classList.contains("voice-pulse");
const hasFast = (el) => el.classList.contains("voice-pulse--fast");
describe("bindVoiceGlow()", () => {
it("returns null when the burger btn is absent", () => {
burger.remove(); burger = null;
expect(bindVoiceGlow()).toBeNull();
});
it("returns null when the voice btn is absent", () => {
voice.remove(); voice = null;
expect(bindVoiceGlow()).toBeNull();
});
});
describe("pre-join nudge (voice available, mic not live)", () => {
it("glows the burger when the fan is closed and no sea nudge", () => {
vg = bindVoiceGlow();
expect(hasGlow(burger)).toBe(true);
expect(hasGlow(voice)).toBe(false);
});
it("does NOT glow when voice is unavailable (no .active)", () => {
voice.classList.remove("active");
vg = bindVoiceGlow();
expect(hasGlow(burger)).toBe(false);
expect(hasGlow(voice)).toBe(false);
});
it("yields burger precedence to the sea draw-nudge (sea glowing)", () => {
sea.classList.add("glow-handoff");
vg = bindVoiceGlow();
expect(hasGlow(burger)).toBe(false); // sea keeps the burger
});
it("reclaims the burger once the sea nudge clears", () => {
sea.classList.add("glow-handoff");
vg = bindVoiceGlow();
expect(hasGlow(burger)).toBe(false);
sea.classList.remove("glow-handoff");
vg.render();
expect(hasGlow(burger)).toBe(true);
});
it("moves the glow to the voice sub-btn when the fan opens", () => {
vg = bindVoiceGlow();
burger.classList.add("active"); // fan open
vg.render();
expect(hasGlow(voice)).toBe(true);
expect(hasGlow(burger)).toBe(false);
});
});
describe("live (mic in use)", () => {
it("pulses the burger (base cadence) while alone + fan closed", () => {
vg = bindVoiceGlow();
vg.setVoiceState({ inCall: true, peerCount: 0 });
expect(hasGlow(burger)).toBe(true);
expect(hasPulse(burger)).toBe(true);
expect(hasFast(burger)).toBe(false);
});
it("pulses the voice sub-btn while alone + fan open", () => {
vg = bindVoiceGlow();
burger.classList.add("active");
vg.setVoiceState({ inCall: true, peerCount: 0 });
expect(hasPulse(voice)).toBe(true);
expect(hasPulse(burger)).toBe(false);
});
it("doubles the cadence once a 2nd party connects", () => {
vg = bindVoiceGlow();
vg.setVoiceState({ inCall: true, peerCount: 1 });
expect(hasFast(burger)).toBe(true);
expect(hasPulse(burger)).toBe(false);
});
it("hands the pulse to whichever surface is showing (fan toggles)", () => {
vg = bindVoiceGlow();
vg.setVoiceState({ inCall: true, peerCount: 0 });
expect(hasPulse(burger)).toBe(true);
burger.classList.add("active"); // fan opens mid-call
vg.render();
expect(hasPulse(voice)).toBe(true);
expect(hasPulse(burger)).toBe(false);
});
it("clears the pulse when the call ends", () => {
vg = bindVoiceGlow();
vg.setVoiceState({ inCall: true, peerCount: 0 });
expect(hasPulse(burger)).toBe(true);
vg.setVoiceState({ inCall: false, peerCount: 0 });
expect(hasPulse(burger)).toBe(false);
expect(hasGlow(burger)).toBe(true); // back to the pre-join nudge
});
});
describe("idempotent reconcile", () => {
it("does not stack duplicate classes across repeated renders", () => {
vg = bindVoiceGlow();
vg.setVoiceState({ inCall: true, peerCount: 1 });
vg.render(); vg.render();
const fastCount = (burger.className.match(/voice-pulse--fast/g) || []).length;
expect(fastCount).toBe(1);
});
});
});

View File

@@ -1112,6 +1112,10 @@
{% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %} {% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %}
{% include "apps/gameboard/_partials/_burger.html" %} {% include "apps/gameboard/_partials/_burger.html" %}
<script src="{% static 'apps/epic/burger-btn.js' %}"></script> <script src="{% static 'apps/epic/burger-btn.js' %}"></script>
{# Voice-affordance glow/pulse machine (keys on voice_active + the live #}
{# mesh state). Coexists w. the sea-btn glow-handoff machine below — #}
{# sea takes burger precedence; voice is the second-place reveal. #}
<script src="{% static 'apps/voice/voice-glow.js' %}"></script>
{# Phase 2 of the Sea sub-btn rollout — wires #id_sea_btn click to #} {# Phase 2 of the Sea sub-btn rollout — wires #id_sea_btn click to #}
{# open #id_sea_spread_modal. AUTO DRAW + Escape + backdrop click #} {# open #id_sea_spread_modal. AUTO DRAW + Escape + backdrop click #}

View File

@@ -87,6 +87,9 @@
{% block scripts %} {% block scripts %}
<script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script> <script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script>
<script src="{% static 'apps/epic/burger-btn.js' %}"></script> <script src="{% static 'apps/epic/burger-btn.js' %}"></script>
{# Voice-affordance glow/pulse machine — keys on the voice sub-btn's #}
{# availability + the live mesh state (VoiceRoom.setOnStateChange). #}
<script src="{% static 'apps/voice/voice-glow.js' %}"></script>
{% if seat2_present %} {% if seat2_present %}
{# Read-only cross stage — StageCard + SeaDeal bind to #id_sea_overlay #} {# Read-only cross stage — StageCard + SeaDeal bind to #id_sea_overlay #}
{# (inside #id_my_sea_visit_draw) for click→stage + SPIN + FYI + hover. #} {# (inside #id_my_sea_visit_draw) for click→stage + SPIN + FYI + hover. #}