diff --git a/src/apps/epic/static/apps/epic/burger-btn.js b/src/apps/epic/static/apps/epic/burger-btn.js index 8921e45..e91b9be 100644 --- a/src/apps/epic/static/apps/epic/burger-btn.js +++ b/src/apps/epic/static/apps/epic/burger-btn.js @@ -54,14 +54,41 @@ fan.setAttribute('aria-hidden', 'true'); } + // 2 pulses, ~180ms ON / 120ms OFF — tighter cadence than + // sig-select's countdown glow (600ms), but same shape. + function _flashInactive(subBtn) { + var pulses = 2; + var onMs = 180; + var offMs = 120; + function pulse(remaining) { + if (remaining <= 0) return; + subBtn.classList.add('flash-inactive'); + setTimeout(function () { + subBtn.classList.remove('flash-inactive'); + setTimeout(function () { + pulse(remaining - 1); + }, offMs); + }, onMs); + } + pulse(pulses); + } + btn.addEventListener('click', function (e) { e.stopPropagation(); if (_isOpen()) _close(); else _open(); }, { signal: sig }); + // Delegated click on the fan — flash --priRd glow twice when an + // INACTIVE sub-btn is clicked (its feature isn't wired yet). Active + // sub-btns will route to their per-feature handlers in later sprints. fan.addEventListener('click', function (e) { e.stopPropagation(); + var subBtn = e.target.closest('.burger-fan-btn'); + if (!subBtn) return; + if (!subBtn.classList.contains('active')) { + _flashInactive(subBtn); + } }, { signal: sig }); document.addEventListener('keydown', function (e) { diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 9cab1ef..ca58b0e 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -2585,6 +2585,19 @@ class RoomBurgerBtnRenderTest(TestCase): ): self.assertContains(response, icon) + def test_each_sub_btn_renders_dual_icon_for_inactive_flash_swap(self): + """Sub-btns carry BOTH the real icon (.burger-fan-icon--on) + a + fa-ban placeholder (.burger-fan-icon--off). CSS keeps the real + icon visible by default; .flash-inactive swaps to fa-ban during + the click-while-inactive pulse. fa-ban itself isn't counted + directly — _table_positions.html also renders fa-ban for + non-starter seats — but the burger-fan-icon classes are unique + to the fan + load-bearing for the CSS swap rule.""" + response = self.client.get(self.url) + body = response.content.decode() + self.assertEqual(body.count("burger-fan-icon--on"), 5) + self.assertEqual(body.count("burger-fan-icon--off"), 5) + def test_burger_btn_script_loaded(self): response = self.client.get(self.url) self.assertContains(response, "burger-btn.js") diff --git a/src/static/tests/BurgerSpec.js b/src/static/tests/BurgerSpec.js index 936a8ef..688d552 100644 --- a/src/static/tests/BurgerSpec.js +++ b/src/static/tests/BurgerSpec.js @@ -196,6 +196,51 @@ describe("Burger", () => { }); }); + describe("inactive sub-btn flash", () => { + let subBtn; + + beforeEach(() => { + subBtn = document.createElement("button"); + subBtn.className = "burger-fan-btn"; + fan.appendChild(subBtn); + jasmine.clock().install(); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it("adds .flash-inactive to inactive sub-btn on click", () => { + subBtn.click(); + expect(subBtn.classList.contains("flash-inactive")).toBe(true); + }); + + it("removes .flash-inactive after the ON window (~180ms)", () => { + subBtn.click(); + jasmine.clock().tick(200); + expect(subBtn.classList.contains("flash-inactive")).toBe(false); + }); + + it("pulses twice — second ON window arrives after off+on cycle (~480ms)", () => { + subBtn.click(); + // First pulse cycle: 180ms ON + 120ms OFF = 300ms. Second pulse ON starts. + jasmine.clock().tick(310); + expect(subBtn.classList.contains("flash-inactive")).toBe(true); + }); + + it("settles back to default after 2 pulses (~700ms total)", () => { + subBtn.click(); + jasmine.clock().tick(800); + expect(subBtn.classList.contains("flash-inactive")).toBe(false); + }); + + it("does NOT flash when sub-btn carries .active class", () => { + subBtn.classList.add("active"); + subBtn.click(); + expect(subBtn.classList.contains("flash-inactive")).toBe(false); + }); + }); + describe("AbortController teardown", () => { it("detaches click handler when ac.abort() is called", () => { ac.abort(); diff --git a/src/static_src/scss/_burger.scss b/src/static_src/scss/_burger.scss index 3ff3b3e..9e3a21b 100644 --- a/src/static_src/scss/_burger.scss +++ b/src/static_src/scss/_burger.scss @@ -119,10 +119,51 @@ // Open state — sub-btns swing out to their arc positions. #id_burger_btn.active ~ #id_burger_fan .burger-fan-btn { transform: rotate(var(--angle)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--angle))); - opacity: 1; pointer-events: auto; } +// Active sub-btn = fully visible; inactive (default) = 0.6 opacity hint. +// Active conditions are wired one-by-one in later sprints. +#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn.active { + opacity: 1; +} +#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn:not(.active) { + opacity: 0.6; +} + +// Icon swap — every sub-btn renders BOTH the real icon (.burger-fan-icon--on) +// + a fa-ban placeholder (.burger-fan-icon--off). Real icon shows by +// default in BOTH .active + inactive states; fa-ban only surfaces during +// the .flash-inactive pulse below. Stacked absolute-position so swapping +// doesn't shift the layout box. +.burger-fan-btn { + .burger-fan-icon--on, + .burger-fan-icon--off { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + .burger-fan-icon--on { display: inline-block; } + .burger-fan-icon--off { display: none; } +} + +// Click-while-inactive flash — burger-btn.js toggles .flash-inactive +// twice in quick succession (~180ms on / 120ms off) for a brief --priRd +// glow on the border + icon, AND swaps the real icon for fa-ban during +// each pulse-on phase. Modeled on sig-select.js's SAVE SIG countdown +// glow but tighter cadence + only 2 pulses. +.burger-fan-btn.flash-inactive { + border-color: rgba(var(--priRd), 1); + color: rgba(var(--priRd), 1); + box-shadow: + 0 0 0.5rem 0.1rem rgba(var(--priRd), 0.75), + 0 0 1.2rem 0.3rem rgba(var(--priRd), 0.35); + + .burger-fan-icon--on { display: none; } + .burger-fan-icon--off { display: inline-block; } +} + // 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/BurgerSpec.js b/src/static_src/tests/BurgerSpec.js index 936a8ef..688d552 100644 --- a/src/static_src/tests/BurgerSpec.js +++ b/src/static_src/tests/BurgerSpec.js @@ -196,6 +196,51 @@ describe("Burger", () => { }); }); + describe("inactive sub-btn flash", () => { + let subBtn; + + beforeEach(() => { + subBtn = document.createElement("button"); + subBtn.className = "burger-fan-btn"; + fan.appendChild(subBtn); + jasmine.clock().install(); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it("adds .flash-inactive to inactive sub-btn on click", () => { + subBtn.click(); + expect(subBtn.classList.contains("flash-inactive")).toBe(true); + }); + + it("removes .flash-inactive after the ON window (~180ms)", () => { + subBtn.click(); + jasmine.clock().tick(200); + expect(subBtn.classList.contains("flash-inactive")).toBe(false); + }); + + it("pulses twice — second ON window arrives after off+on cycle (~480ms)", () => { + subBtn.click(); + // First pulse cycle: 180ms ON + 120ms OFF = 300ms. Second pulse ON starts. + jasmine.clock().tick(310); + expect(subBtn.classList.contains("flash-inactive")).toBe(true); + }); + + it("settles back to default after 2 pulses (~700ms total)", () => { + subBtn.click(); + jasmine.clock().tick(800); + expect(subBtn.classList.contains("flash-inactive")).toBe(false); + }); + + it("does NOT flash when sub-btn carries .active class", () => { + subBtn.classList.add("active"); + subBtn.click(); + expect(subBtn.classList.contains("flash-inactive")).toBe(false); + }); + }); + describe("AbortController teardown", () => { it("detaches click handler when ac.abort() is called", () => { ac.abort(); diff --git a/src/templates/apps/gameboard/_partials/_burger.html b/src/templates/apps/gameboard/_partials/_burger.html index 034d23b..a1d6ef2 100644 --- a/src/templates/apps/gameboard/_partials/_burger.html +++ b/src/templates/apps/gameboard/_partials/_burger.html @@ -1,24 +1,35 @@ -{# Burger btn + fan of 5 sub-btns for room.html. Renders unconditionally #} -{# above #id_bud_btn (portrait: bottom-left; landscape: right sidebar #} -{# bottom). Sub-btns are pure scaffolding for now — no click handlers. #} -{# Click handlers + targets land in later sprints as each surface matures. #} +{# Burger btn + fan of 5 sub-btns. Sub-btns default to INACTIVE #} +{# (opacity 0.6, fa-ban icon). When a condition flips a sub-btn to #} +{# .active, the real icon (sky/earth/sea/voice/text) shows + opacity #} +{# goes to 1. Each sub-btn renders BOTH icons; CSS hides one based on #} +{# the .active class. #} +{# #} +{# Active conditions are wired one-by-one in later sprints. For now all #} +{# sub-btns are inactive; clicking an inactive sub-btn flashes a brief #} +{# --priRd glow (twice, fast cadence) — burger-btn.js owns the delegated #} +{# click + flash. #}