room.html burger btn + 5-fan; universal landscape btn refactor; kit_bag_dialog vertical bar — TDD

Major feature push staging room.html for sprint A.8 — adds the burger btn + fan-of-five sub-btns affordance, then rotates the whole landscape btn layout to make room for it. The landscape refactor is universal (not .room-page-scoped) so every page that hosts these btns reads consistently.

## Burger btn + fan-of-five (room.html only)

`templates/apps/gameboard/_partials/_room_burger.html` (NEW) — `#id_burger_btn` (.fa-burger) + `#id_burger_fan` containing 5 sub-btns: `#id_voice_btn` (headset), `#id_sky_btn` (cloud), `#id_earth_btn` (earth-americas), `#id_sea_btn` (bridge-water), `#id_text_btn` (keyboard). Pure scaffolding — no click handlers in this sprint; wire-up lands later as each surface matures.

`apps/epic/static/apps/epic/burger-btn.js` (NEW) — toggle `.active` on click; Escape + click-outside close; opening burger auto-closes the kit dialog (`#id_kit_bag_dialog`) + the bud slide-out panel (`html.bud-open`) by dispatching a click to the owning btn (routes through that btn's own toggle/close path — no fetch on close). `bindBurger()` returns an `AbortController` so test code (+ any future re-bind callers) can detach all listeners cleanly via `ac.abort()`.

`static_src/scss/_burger.scss` (NEW) — burger sits parallel to gear-above-kit but on the bud-side. Portrait: bottom:4.2rem; left:0.5rem (above #id_bud_btn). Landscape: bottom:0.5rem; right:4.2rem (to the LEFT of #id_bud_btn). Fan is a CSS radial menu w. each sub-btn's `--angle = --base + --i * 30deg`. Portrait `--base: 0deg` (arc 12→4 o'clock), landscape `--base: -90deg` (arc 9→1 o'clock). Sub-btn radius `--r: 7.75rem`. Indices: voice=0, sky=1, earth=2, sea=3, text=4 (user-spec'd clockwise order).

room.html includes the partial + the script; the burger renders unconditionally regardless of `gate_status` / `table_status`.

## Universal landscape btn refactor

Sprint replaces the prior centred-in-sidebar landscape arrangement w. a kit-at-top + bud-at-bottom + gear/burger as horizontal partners. Applies to every page in landscape (was scoped to .room-page in the first iteration).

`_game-kit.scss` — kit_btn landscape moves to top:0.5rem; right:0.5rem (was bottom:0.5rem centred in sidebar). The 0.5rem right literal (not the calc((--sidebar-w - 3rem)/2)=1rem) produces a 0.7rem edge-to-edge gap w. gear at right:4.2rem — matching the portrait gear-above-kit gap exactly.

`_bud.scss` — bud_btn landscape moves to bottom:0.5rem; right:0.5rem (was top:0.5rem centred in sidebar). Same 0.5rem literal as kit_btn. bud_panel #id_recipient relocates to bottom:0.5rem (was top:0.5rem) + transform-origin flips to right center. .bud-suggestions rise upward from above the panel (bottom:4rem) instead of dropping from below.

`_applets.scss` — .gear-btn landscape moves to top:0.5rem; right:4.2rem (was centred bottom:3.95rem). All applet menus anchor at top:2.6rem; right:4.2rem (beneath the gear's leftward arc) extending DOWN-LEFT into the viewport. #id_room_menu joins the shared portrait position list (was bespoke in _room.scss).

`_room.scss` — bespoke #id_room_menu rule deleted entirely. The menu now inherits %applet-menu + the shared portrait position list — same chrome + behaviour as #id_post_menu / #id_billscroll_menu. Earlier iteration tried flex-direction:row in landscape; reverted per user request — "lose all scss specificity" wins.

`_card-deck.scss` — obsolete `#id_room_menu { right: 2.5rem; }` override in the XL+landscape block deleted. Was a same-specificity hack to beat _applets.scss's old centred position; no longer needed w. the consolidated rule.

## kit_bag_dialog vertical bar in landscape

`_game-kit.scss` — when open in landscape, dialog covers the right sidebar (top:0; bottom:0; right:0; width: var(--sidebar-w)). Slides in from off-viewport right by animating max-width 0 → var(--sidebar-w). Opaque bg (rgba(--priUser, 1) — was 0.97). z-index: 319 (above burger at 318) so it lands in front of the burger btn when open. Top-edge border → left-edge border.

Inner content flips to `flex-direction: column-reverse` so DOM order Deck→Dice→Trinket→Tokens paints visually bottom→top. .kit-bag-section also column-reverse → icon row above label. .kit-bag-label drops vertical-rl + the rotate(180deg) scaleX(1.3) transform, reads horizontally. .kit-bag-row--scroll flips to column + overflow-y for the Tokens scrollable row.

`game-kit.js attachTooltip()` — 2-axis tooltip clamp matching sky-wheel.js + wallet.js's pattern. Horizontal: left edge stays within [1rem, viewport-ttW-1rem]. Vertical: prefer ABOVE the element; flip BELOW when tooltip is too tall to fit above (e.g. landscape kit bar w. Tokens row near top). Resets top/bottom on mouseleave so next show measures fresh.

## Tests

`apps/epic/tests/integrated/test_views.py` (+1 class, 6 ITs) — RoomBurgerBtnRenderTest: burger_btn renders, fan container renders, 5 sub-btns w. correct ids, icons match spec, burger-btn.js loaded, burger persists thru table_status.

`static_src/tests/BurgerSpec.js` (NEW, 19 Jasmine specs) — bindBurger() returns AbortController; click toggle; Escape close; click-outside close; opening burger closes kit dialog + bud panel when set; AbortController teardown removes listeners.

All 1356 IT+UT green (+6 new from RoomBurgerBtnRenderTest, was 1350). Jasmine suite green (was 220-something specs, +19 BurgerSpec).

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:
Disco DeDisco
2026-05-26 20:40:33 -04:00
parent 3ad372bc36
commit 3ca986fb45
16 changed files with 864 additions and 77 deletions

View File

@@ -0,0 +1,206 @@
// ── BurgerSpec.js ────────────────────────────────────────────────────────────
//
// Unit specs for burger-btn.js — the room.html corner-menu btn + its 5-btn
// fanning arc (sky, earth, sea, voice, text).
//
// DOM contract assumed by the module:
// #id_burger_btn — the burger btn
// #id_burger_fan — the fan container (sibling)
// .burger-fan-btn — each sub-btn (5 of them)
// #id_kit_btn — kit btn (optional)
// #id_kit_bag_dialog — kit dialog (optional)
// #id_bud_btn — bud btn (optional)
//
// Public API under test (window.bindBurger):
// var ac = bindBurger(); // returns an AbortController for cleanup
//
// Behaviours:
// • Click toggles .active class on #id_burger_btn.
// • Escape closes when open.
// • Click outside closes when open.
// • Opening burger closes the kit dialog + the bud panel if either is open.
//
// ─────────────────────────────────────────────────────────────────────────────
describe("Burger", () => {
let burgerBtn, fan, ac;
beforeEach(() => {
burgerBtn = document.createElement("button");
burgerBtn.id = "id_burger_btn";
document.body.appendChild(burgerBtn);
fan = document.createElement("div");
fan.id = "id_burger_fan";
document.body.appendChild(fan);
ac = bindBurger();
});
afterEach(() => {
if (ac) ac.abort();
burgerBtn.remove();
fan.remove();
// Clean up any test-added kit/bud elements + classes.
["id_kit_btn", "id_kit_bag_dialog", "id_bud_btn"].forEach(id => {
const el = document.getElementById(id);
if (el) el.remove();
});
document.documentElement.classList.remove("bud-open");
});
describe("bindBurger()", () => {
it("returns an AbortController when DOM is present", () => {
expect(ac).not.toBeNull();
expect(ac.abort).toBeDefined();
});
it("returns null when #id_burger_btn is absent", () => {
burgerBtn.remove();
const ac2 = bindBurger();
expect(ac2).toBeNull();
});
it("returns null when #id_burger_fan is absent", () => {
fan.remove();
const ac2 = bindBurger();
expect(ac2).toBeNull();
});
});
describe("click toggle", () => {
it("first click adds .active to #id_burger_btn", () => {
burgerBtn.click();
expect(burgerBtn.classList.contains("active")).toBe(true);
});
it("first click sets aria-expanded='true'", () => {
burgerBtn.click();
expect(burgerBtn.getAttribute("aria-expanded")).toBe("true");
});
it("first click sets fan aria-hidden='false'", () => {
burgerBtn.click();
expect(fan.getAttribute("aria-hidden")).toBe("false");
});
it("second click removes .active (closes)", () => {
burgerBtn.click();
burgerBtn.click();
expect(burgerBtn.classList.contains("active")).toBe(false);
});
it("second click resets aria-expanded='false'", () => {
burgerBtn.click();
burgerBtn.click();
expect(burgerBtn.getAttribute("aria-expanded")).toBe("false");
});
});
describe("Escape key", () => {
it("closes burger when open", () => {
burgerBtn.click();
const evt = new KeyboardEvent("keydown", { key: "Escape" });
document.dispatchEvent(evt);
expect(burgerBtn.classList.contains("active")).toBe(false);
});
it("no-op when burger already closed", () => {
const evt = new KeyboardEvent("keydown", { key: "Escape" });
document.dispatchEvent(evt);
expect(burgerBtn.classList.contains("active")).toBe(false);
});
});
describe("click-outside", () => {
it("closes burger on click outside btn + fan", () => {
burgerBtn.click();
const outsider = document.createElement("div");
document.body.appendChild(outsider);
outsider.click();
expect(burgerBtn.classList.contains("active")).toBe(false);
outsider.remove();
});
it("does NOT close on click inside fan", () => {
burgerBtn.click();
const subBtn = document.createElement("button");
subBtn.className = "burger-fan-btn";
fan.appendChild(subBtn);
subBtn.click();
expect(burgerBtn.classList.contains("active")).toBe(true);
});
});
describe("opening burger closes other corner panels", () => {
it("clicks #id_kit_btn when kit dialog is open", () => {
const kitBtn = document.createElement("button");
kitBtn.id = "id_kit_btn";
const kitClickSpy = jasmine.createSpy("kitClick");
kitBtn.addEventListener("click", kitClickSpy);
document.body.appendChild(kitBtn);
const dialog = document.createElement("dialog");
dialog.id = "id_kit_bag_dialog";
dialog.setAttribute("open", "");
document.body.appendChild(dialog);
burgerBtn.click();
expect(kitClickSpy).toHaveBeenCalled();
});
it("does NOT click #id_kit_btn when kit dialog is closed", () => {
const kitBtn = document.createElement("button");
kitBtn.id = "id_kit_btn";
const kitClickSpy = jasmine.createSpy("kitClick");
kitBtn.addEventListener("click", kitClickSpy);
document.body.appendChild(kitBtn);
const dialog = document.createElement("dialog");
dialog.id = "id_kit_bag_dialog";
// no open attribute
document.body.appendChild(dialog);
burgerBtn.click();
expect(kitClickSpy).not.toHaveBeenCalled();
});
it("clicks #id_bud_btn when html.bud-open is set", () => {
const budBtn = document.createElement("button");
budBtn.id = "id_bud_btn";
const budClickSpy = jasmine.createSpy("budClick");
budBtn.addEventListener("click", budClickSpy);
document.body.appendChild(budBtn);
document.documentElement.classList.add("bud-open");
burgerBtn.click();
expect(budClickSpy).toHaveBeenCalled();
});
it("does NOT click #id_bud_btn when html.bud-open is absent", () => {
const budBtn = document.createElement("button");
budBtn.id = "id_bud_btn";
const budClickSpy = jasmine.createSpy("budClick");
budBtn.addEventListener("click", budClickSpy);
document.body.appendChild(budBtn);
burgerBtn.click();
expect(budClickSpy).not.toHaveBeenCalled();
});
it("ignores missing kit/bud btns w.o. error", () => {
// No kit, no bud — opening should still succeed.
expect(() => burgerBtn.click()).not.toThrow();
expect(burgerBtn.classList.contains("active")).toBe(true);
});
});
describe("AbortController teardown", () => {
it("detaches click handler when ac.abort() is called", () => {
ac.abort();
burgerBtn.click();
expect(burgerBtn.classList.contains("active")).toBe(false);
});
});
});

View File

@@ -30,6 +30,7 @@
<script src="NotePageSpec.js"></script>
<script src="RowLockSpec.js"></script>
<script src="WalletShopSpec.js"></script>
<script src="BurgerSpec.js"></script>
<!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.js"></script>
@@ -42,6 +43,7 @@
<script src="/static/apps/epic/tray-tooltip.js"></script>
<script src="/static/apps/epic/sig-select.js"></script>
<script src="/static/apps/epic/sea.js"></script>
<script src="/static/apps/epic/burger-btn.js"></script>
<script src="/static/apps/gameboard/game-kit.js"></script>
<script src="/static/apps/gameboard/d3.min.js"></script>
<script src="/static/apps/gameboard/sky-wheel.js"></script>