burger sub-btns: opacity-0.6 inactive default + 2-pulse --priRd flash w. fa-ban swap on click — TDD
Adds the active/inactive distinction to the 5 burger fan sub-btns. Default state is INACTIVE (opacity 0.6, real icon visible); active conditions get wired one-by-one in later sprints as each surface matures.
## Markup (templates/apps/gameboard/_partials/_burger.html)
Each sub-btn now renders BOTH icons:
- `<i class="fa-solid fa-<real> burger-fan-icon--on">` (sky/earth/sea/voice/text)
- `<i class="fa-solid fa-ban burger-fan-icon--off">`
CSS keeps the real icon visible by default in both .active + inactive states. The fa-ban only surfaces during the .flash-inactive pulse below (icon swap is tied to the pulse class, not to inactive state per se — user-spec'd).
## SCSS (static_src/scss/_burger.scss)
- `#id_burger_btn.active ~ ... .burger-fan-btn.active { opacity: 1 }` — active sub-btn fully visible.
- `#id_burger_btn.active ~ ... .burger-fan-btn:not(.active) { opacity: 0.6 }` — inactive default.
- `.burger-fan-icon--on / --off` stacked absolute-position so the swap doesn't shift the layout box.
- `.burger-fan-btn.flash-inactive` — adds --priRd border + glow (box-shadow modeled on sig-select's SAVE SIG countdown but lighter), AND swaps to fa-ban via `.burger-fan-icon--on { display: none } / --off { display: inline-block }`.
The `#id_burger_btn` itself (the trigger btn) is explicitly NOT subject to inactive/active opacity treatment — only the sub-btns.
## JS (apps/epic/static/apps/epic/burger-btn.js)
Delegated click handler on `#id_burger_fan`: any `.burger-fan-btn` click that DOESN'T carry `.active` runs `_flashInactive(subBtn)` — 2 pulses, 180ms ON / 120ms OFF (tighter than sig-select's 600ms cadence per user spec). Active sub-btns will route to their per-feature handlers in later sprints; for now they no-op.
## Tests
- `apps/epic/tests/integrated/test_views.py::RoomBurgerBtnRenderTest::test_each_sub_btn_renders_dual_icon_for_inactive_flash_swap` — asserts `burger-fan-icon--on` + `--off` appear 5 times each (one per sub-btn). 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.
- `static_src/tests/BurgerSpec.js` — 5 new specs under `describe("inactive sub-btn flash")`:
- adds .flash-inactive on click
- removes after ~180ms (first ON window)
- re-adds after ~480ms (second ON window during the 2nd pulse)
- settles back to default after ~800ms (full 2-pulse cycle)
- does NOT flash when sub-btn carries .active
Uses `jasmine.clock()` for deterministic timing. Mirror-copied to `static/tests/BurgerSpec.js` for the Jasmine FT runner.
## Verification
1358 IT+UT green. Jasmine FT runs all specs (incl. the 5 new flash specs) green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,14 +54,41 @@
|
|||||||
fan.setAttribute('aria-hidden', 'true');
|
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) {
|
btn.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (_isOpen()) _close();
|
if (_isOpen()) _close();
|
||||||
else _open();
|
else _open();
|
||||||
}, { signal: sig });
|
}, { 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) {
|
fan.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
var subBtn = e.target.closest('.burger-fan-btn');
|
||||||
|
if (!subBtn) return;
|
||||||
|
if (!subBtn.classList.contains('active')) {
|
||||||
|
_flashInactive(subBtn);
|
||||||
|
}
|
||||||
}, { signal: sig });
|
}, { signal: sig });
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
|
|||||||
@@ -2585,6 +2585,19 @@ class RoomBurgerBtnRenderTest(TestCase):
|
|||||||
):
|
):
|
||||||
self.assertContains(response, icon)
|
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):
|
def test_burger_btn_script_loaded(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertContains(response, "burger-btn.js")
|
self.assertContains(response, "burger-btn.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", () => {
|
describe("AbortController teardown", () => {
|
||||||
it("detaches click handler when ac.abort() is called", () => {
|
it("detaches click handler when ac.abort() is called", () => {
|
||||||
ac.abort();
|
ac.abort();
|
||||||
|
|||||||
@@ -119,10 +119,51 @@
|
|||||||
// Open state — sub-btns swing out to their arc positions.
|
// Open state — sub-btns swing out to their arc positions.
|
||||||
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn {
|
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn {
|
||||||
transform: rotate(var(--angle)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--angle)));
|
transform: rotate(var(--angle)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--angle)));
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
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 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
|
||||||
|
|||||||
@@ -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", () => {
|
describe("AbortController teardown", () => {
|
||||||
it("detaches click handler when ac.abort() is called", () => {
|
it("detaches click handler when ac.abort() is called", () => {
|
||||||
ac.abort();
|
ac.abort();
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
{# Burger btn + fan of 5 sub-btns for room.html. Renders unconditionally #}
|
{# Burger btn + fan of 5 sub-btns. Sub-btns default to INACTIVE #}
|
||||||
{# above #id_bud_btn (portrait: bottom-left; landscape: right sidebar #}
|
{# (opacity 0.6, fa-ban icon). When a condition flips a sub-btn to #}
|
||||||
{# bottom). Sub-btns are pure scaffolding for now — no click handlers. #}
|
{# .active, the real icon (sky/earth/sea/voice/text) shows + opacity #}
|
||||||
{# Click handlers + targets land in later sprints as each surface matures. #}
|
{# 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. #}
|
||||||
<button id="id_burger_btn" type="button" aria-label="Open burger menu" aria-expanded="false">
|
<button id="id_burger_btn" type="button" aria-label="Open burger menu" aria-expanded="false">
|
||||||
<i class="fa-solid fa-burger"></i>
|
<i class="fa-solid fa-burger"></i>
|
||||||
</button>
|
</button>
|
||||||
<div id="id_burger_fan" aria-hidden="true">
|
<div id="id_burger_fan" aria-hidden="true">
|
||||||
|
<button id="id_voice_btn" type="button" class="burger-fan-btn" aria-label="Voice">
|
||||||
|
<i class="fa-solid fa-headset burger-fan-icon--on"></i>
|
||||||
|
<i class="fa-solid fa-ban burger-fan-icon--off"></i>
|
||||||
|
</button>
|
||||||
<button id="id_sky_btn" type="button" class="burger-fan-btn" aria-label="Sky">
|
<button id="id_sky_btn" type="button" class="burger-fan-btn" aria-label="Sky">
|
||||||
<i class="fa-solid fa-cloud"></i>
|
<i class="fa-solid fa-cloud burger-fan-icon--on"></i>
|
||||||
|
<i class="fa-solid fa-ban burger-fan-icon--off"></i>
|
||||||
</button>
|
</button>
|
||||||
<button id="id_earth_btn" type="button" class="burger-fan-btn" aria-label="Earth">
|
<button id="id_earth_btn" type="button" class="burger-fan-btn" aria-label="Earth">
|
||||||
<i class="fa-solid fa-earth-americas"></i>
|
<i class="fa-solid fa-earth-americas burger-fan-icon--on"></i>
|
||||||
|
<i class="fa-solid fa-ban burger-fan-icon--off"></i>
|
||||||
</button>
|
</button>
|
||||||
<button id="id_sea_btn" type="button" class="burger-fan-btn" aria-label="Sea">
|
<button id="id_sea_btn" type="button" class="burger-fan-btn" aria-label="Sea">
|
||||||
<i class="fa-solid fa-bridge-water"></i>
|
<i class="fa-solid fa-bridge-water burger-fan-icon--on"></i>
|
||||||
</button>
|
<i class="fa-solid fa-ban burger-fan-icon--off"></i>
|
||||||
<button id="id_voice_btn" type="button" class="burger-fan-btn" aria-label="Voice">
|
|
||||||
<i class="fa-solid fa-headset"></i>
|
|
||||||
</button>
|
</button>
|
||||||
<button id="id_text_btn" type="button" class="burger-fan-btn" aria-label="Text">
|
<button id="id_text_btn" type="button" class="burger-fan-btn" aria-label="Text">
|
||||||
<i class="fa-solid fa-keyboard"></i>
|
<i class="fa-solid fa-keyboard burger-fan-icon--on"></i>
|
||||||
|
<i class="fa-solid fa-ban burger-fan-icon--off"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user