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>
2026-05-29 21:06:02 -04:00
|
|
|
// ── 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");
|
2026-05-29 21:46:08 -04:00
|
|
|
const hasEq = (el) => el.classList.contains("voice-eq");
|
|
|
|
|
const hasMuted = (el) => el.classList.contains("voice-muted");
|
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>
2026-05-29 21:06:02 -04:00
|
|
|
|
|
|
|
|
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);
|
2026-05-29 21:46:08 -04:00
|
|
|
expect(hasEq(burger)).toBe(false);
|
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>
2026-05-29 21:06:02 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-29 21:46:08 -04:00
|
|
|
it("switches to the equalizer once a 2nd party connects", () => {
|
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>
2026-05-29 21:06:02 -04:00
|
|
|
vg = bindVoiceGlow();
|
|
|
|
|
vg.setVoiceState({ inCall: true, peerCount: 1 });
|
2026-05-29 21:46:08 -04:00
|
|
|
expect(hasEq(burger)).toBe(true);
|
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>
2026-05-29 21:06:02 -04:00
|
|
|
expect(hasPulse(burger)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-29 21:46:08 -04:00
|
|
|
it("recolors to muted (--priRd) when the mic is muted, both alone…", () => {
|
|
|
|
|
vg = bindVoiceGlow();
|
|
|
|
|
vg.setVoiceState({ inCall: true, peerCount: 0, muted: true });
|
|
|
|
|
expect(hasPulse(burger)).toBe(true); // still pulsing (alone)
|
|
|
|
|
expect(hasMuted(burger)).toBe(true); // …but in the muted colour
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("…and while the equalizer runs with others connected", () => {
|
|
|
|
|
vg = bindVoiceGlow();
|
|
|
|
|
vg.setVoiceState({ inCall: true, peerCount: 2, muted: true });
|
|
|
|
|
expect(hasEq(burger)).toBe(true);
|
|
|
|
|
expect(hasMuted(burger)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("drops the muted colour on unmute", () => {
|
|
|
|
|
vg = bindVoiceGlow();
|
|
|
|
|
vg.setVoiceState({ inCall: true, peerCount: 0, muted: true });
|
|
|
|
|
expect(hasMuted(burger)).toBe(true);
|
|
|
|
|
vg.setVoiceState({ inCall: true, peerCount: 0, muted: false });
|
|
|
|
|
expect(hasMuted(burger)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-05-29 21:06:02 -04:00
|
|
|
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();
|
2026-05-29 21:46:08 -04:00
|
|
|
const eqCount = (burger.className.match(/voice-eq/g) || []).length;
|
|
|
|
|
expect(eqCount).toBe(1);
|
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>
2026-05-29 21:06:02 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|