diff --git a/src/apps/voice/static/apps/voice/voice-glow.js b/src/apps/voice/static/apps/voice/voice-glow.js
new file mode 100644
index 0000000..7e404c9
--- /dev/null
+++ b/src/apps/voice/static/apps/voice/voice-glow.js
@@ -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();
+ }
+}());
diff --git a/src/apps/voice/static/apps/voice/voice-mesh.js b/src/apps/voice/static/apps/voice/voice-mesh.js
index ae563c5..588ef5f 100644
--- a/src/apps/voice/static/apps/voice/voice-mesh.js
+++ b/src/apps/voice/static/apps/voice/voice-mesh.js
@@ -40,8 +40,32 @@
this.iceServers = ICE_FALLBACK;
this.selfId = null;
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 () {
return fetch('/api/voice/turn-credentials/', {
headers: { 'Accept': 'application/json' },
@@ -73,6 +97,7 @@
el.srcObject = e.streams[0];
};
this.peers[peerId] = pc;
+ this._notify(); // a peer joined the mesh
return pc;
};
@@ -105,6 +130,7 @@
if (pc) { try { pc.close(); } catch (e) {} delete this.peers[peerId]; }
var el = document.getElementById('voice-audio-' + peerId);
if (el && el.parentNode) el.parentNode.removeChild(el);
+ this._notify(); // a peer left the mesh
};
VoiceRoom.prototype._send = function (obj) {
@@ -158,6 +184,7 @@
self.ws = new WebSocket(scheme + '://' + window.location.host + '/ws/voice/' + roomId + '/');
self.ws.onmessage = function (e) { self._onMessage(JSON.parse(e.data)); };
self.ws.onclose = function () { self._teardown(); };
+ self._notify(); // mic live (in-call), still alone
return self;
});
};
@@ -169,6 +196,7 @@
t.enabled = !this.muted;
}, this);
}
+ this._notify();
return this.muted;
};
@@ -178,6 +206,7 @@
this.localStream.getTracks().forEach(function (t) { t.stop(); });
this.localStream = null;
}
+ this._notify(); // call ended — back to not-in-call
};
VoiceRoom.prototype.leave = function () {
diff --git a/src/static/tests/MySeaSeatsSpec.js b/src/static/tests/MySeaSeatsSpec.js
index 7e91a3c..c5e37c8 100644
--- a/src/static/tests/MySeaSeatsSpec.js
+++ b/src/static/tests/MySeaSeatsSpec.js
@@ -43,11 +43,14 @@ describe('my-sea-seats one-shot seated glow', function () {
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();
window.playSeatGlow(seat);
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);
+ expect(seat.classList.contains('seat-just-seated')).toBe(true);
+ jasmine.clock().tick(500);
expect(seat.classList.contains('seat-just-seated')).toBe(false);
jasmine.clock().uninstall();
});
diff --git a/src/static/tests/SpecRunner.html b/src/static/tests/SpecRunner.html
index 93e91d7..d68ba53 100644
--- a/src/static/tests/SpecRunner.html
+++ b/src/static/tests/SpecRunner.html
@@ -33,6 +33,7 @@
+
@@ -49,6 +50,7 @@
+
diff --git a/src/static/tests/VoiceGlowSpec.js b/src/static/tests/VoiceGlowSpec.js
new file mode 100644
index 0000000..b5666c1
--- /dev/null
+++ b/src/static/tests/VoiceGlowSpec.js
@@ -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);
+ });
+ });
+});
diff --git a/src/static_src/scss/_burger.scss b/src/static_src/scss/_burger.scss
index 7a5a644..15b01a7 100644
--- a/src/static_src/scss/_burger.scss
+++ b/src/static_src/scss/_burger.scss
@@ -189,6 +189,47 @@
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 sits ABOVE the bud panel (bottom:4.2rem vs panel at bottom:0.5
// + height:3rem); no visual conflict. In landscape they share the
diff --git a/src/static_src/tests/MySeaSeatsSpec.js b/src/static_src/tests/MySeaSeatsSpec.js
index 7e91a3c..c5e37c8 100644
--- a/src/static_src/tests/MySeaSeatsSpec.js
+++ b/src/static_src/tests/MySeaSeatsSpec.js
@@ -43,11 +43,14 @@ describe('my-sea-seats one-shot seated glow', function () {
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();
window.playSeatGlow(seat);
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);
+ expect(seat.classList.contains('seat-just-seated')).toBe(true);
+ jasmine.clock().tick(500);
expect(seat.classList.contains('seat-just-seated')).toBe(false);
jasmine.clock().uninstall();
});
diff --git a/src/static_src/tests/SpecRunner.html b/src/static_src/tests/SpecRunner.html
index 93e91d7..d68ba53 100644
--- a/src/static_src/tests/SpecRunner.html
+++ b/src/static_src/tests/SpecRunner.html
@@ -33,6 +33,7 @@
+
@@ -49,6 +50,7 @@
+
diff --git a/src/static_src/tests/VoiceGlowSpec.js b/src/static_src/tests/VoiceGlowSpec.js
new file mode 100644
index 0000000..b5666c1
--- /dev/null
+++ b/src/static_src/tests/VoiceGlowSpec.js
@@ -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);
+ });
+ });
+});
diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html
index 344c298..0d55864 100644
--- a/src/templates/apps/gameboard/my_sea.html
+++ b/src/templates/apps/gameboard/my_sea.html
@@ -1112,6 +1112,10 @@
{% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %}
{% include "apps/gameboard/_partials/_burger.html" %}
+ {# 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. #}
+
{# Phase 2 of the Sea sub-btn rollout — wires #id_sea_btn click to #}
{# open #id_sea_spread_modal. AUTO DRAW + Escape + backdrop click #}
diff --git a/src/templates/apps/gameboard/my_sea_visit.html b/src/templates/apps/gameboard/my_sea_visit.html
index 2fc58fd..c84e7bf 100644
--- a/src/templates/apps/gameboard/my_sea_visit.html
+++ b/src/templates/apps/gameboard/my_sea_visit.html
@@ -87,6 +87,9 @@
{% block scripts %}
+ {# Voice-affordance glow/pulse machine — keys on the voice sub-btn's #}
+ {# availability + the live mesh state (VoiceRoom.setOnStateChange). #}
+
{% if seat2_present %}
{# Read-only cross stage — StageCard + SeaDeal bind to #id_sea_overlay #}
{# (inside #id_my_sea_visit_draw) for click→stage + SPIN + FYI + hover. #}