From b021d8017c0bab572f9c295f8a0e52d096797b48 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 29 May 2026 21:06:02 -0400 Subject: [PATCH] =?UTF-8?q?my-sea=20voice:=20voice-btn=20glow/pulse=20stat?= =?UTF-8?q?e=20machine=20(sea-precedence,=20pulse-while-alone,=202x=20on?= =?UTF-8?q?=202nd=20party)=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) --- .../voice/static/apps/voice/voice-glow.js | 126 +++++++++++++++ .../voice/static/apps/voice/voice-mesh.js | 29 ++++ src/static/tests/MySeaSeatsSpec.js | 5 +- src/static/tests/SpecRunner.html | 2 + src/static/tests/VoiceGlowSpec.js | 152 ++++++++++++++++++ src/static_src/scss/_burger.scss | 41 +++++ src/static_src/tests/MySeaSeatsSpec.js | 5 +- src/static_src/tests/SpecRunner.html | 2 + src/static_src/tests/VoiceGlowSpec.js | 152 ++++++++++++++++++ src/templates/apps/gameboard/my_sea.html | 4 + .../apps/gameboard/my_sea_visit.html | 3 + 11 files changed, 519 insertions(+), 2 deletions(-) create mode 100644 src/apps/voice/static/apps/voice/voice-glow.js create mode 100644 src/static/tests/VoiceGlowSpec.js create mode 100644 src/static_src/tests/VoiceGlowSpec.js 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. #}