diff --git a/src/apps/dashboard/static/apps/dashboard/game-kit.js b/src/apps/dashboard/static/apps/dashboard/game-kit.js index 05d9f73..7014eab 100644 --- a/src/apps/dashboard/static/apps/dashboard/game-kit.js +++ b/src/apps/dashboard/static/apps/dashboard/game-kit.js @@ -64,13 +64,42 @@ if (!tooltip) return; var rect = el.getBoundingClientRect(); tooltip.style.position = 'fixed'; - tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; - tooltip.style.left = rect.left + 'px'; + // Show first so offsetWidth/offsetHeight measure real layout, + // then clamp both axes so the tooltip never bleeds past the + // viewport. Same shape as sky-wheel.js + wallet.js: 1rem inset + // margin on every edge. tooltip.style.display = 'block'; + var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + var ttW = tooltip.offsetWidth; + var ttH = tooltip.offsetHeight; + + // Horizontal clamp — left edge stays within [rem, viewport-ttW-rem]. + var minLeft = rem; + var maxLeft = window.innerWidth - ttW - rem; + var clampedLeft = Math.max(minLeft, Math.min(rect.left, maxLeft)); + tooltip.style.left = clampedLeft + 'px'; + + // Vertical: prefer ABOVE the element; flip BELOW when the + // tooltip is too tall to fit above (e.g. in landscape where + // the kit bag dialog runs along the top of the right sidebar + // + tokens row anchors near the top of the viewport). + var spaceAbove = rect.top - rem; + if (ttH <= spaceAbove) { + tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; + tooltip.style.top = ''; + } else { + tooltip.style.top = (rect.bottom + 8) + 'px'; + tooltip.style.bottom = ''; + } }); el.addEventListener('mouseleave', function () { var tooltip = el.querySelector('.tt'); - if (tooltip) tooltip.style.display = ''; + if (tooltip) { + tooltip.style.display = ''; + // Reset positional props so the next show measures fresh. + tooltip.style.top = ''; + tooltip.style.bottom = ''; + } }); } diff --git a/src/apps/epic/static/apps/epic/burger-btn.js b/src/apps/epic/static/apps/epic/burger-btn.js new file mode 100644 index 0000000..8921e45 --- /dev/null +++ b/src/apps/epic/static/apps/epic/burger-btn.js @@ -0,0 +1,88 @@ +// Burger btn + 5-fan menu on room.html. Pure scaffolding for now — +// sub-btns have no click handlers in this sprint. Behaviour owned here: +// • Toggle #id_burger_btn.active on click. +// • Closing burger via re-click / Escape / click-outside. +// • Opening burger auto-closes the kit dialog (#id_kit_bag_dialog) and +// the bud slide-out panel (html.bud-open) if either is open — +// dispatched by clicking the owning btn, which routes through that +// btn's own toggle/close path (no fetch on close). +// +// bindBurger() returns an AbortController so test code (and any future +// re-bind callers) can detach all listeners cleanly via ac.abort(). + +(function () { + 'use strict'; + + function bindBurger() { + var btn = document.getElementById('id_burger_btn'); + var fan = document.getElementById('id_burger_fan'); + if (!btn || !fan) return null; + + var ac = new AbortController(); + var sig = ac.signal; + + function _isOpen() { + return btn.classList.contains('active'); + } + + function _closeKit() { + var dialog = document.getElementById('id_kit_bag_dialog'); + var kitBtn = document.getElementById('id_kit_btn'); + if (dialog && dialog.hasAttribute('open') && kitBtn) { + kitBtn.click(); + } + } + + function _closeBud() { + var budBtn = document.getElementById('id_bud_btn'); + if (document.documentElement.classList.contains('bud-open') && budBtn) { + budBtn.click(); + } + } + + function _open() { + _closeKit(); + _closeBud(); + btn.classList.add('active'); + btn.setAttribute('aria-expanded', 'true'); + fan.setAttribute('aria-hidden', 'false'); + } + + function _close() { + btn.classList.remove('active'); + btn.setAttribute('aria-expanded', 'false'); + fan.setAttribute('aria-hidden', 'true'); + } + + btn.addEventListener('click', function (e) { + e.stopPropagation(); + if (_isOpen()) _close(); + else _open(); + }, { signal: sig }); + + fan.addEventListener('click', function (e) { + e.stopPropagation(); + }, { signal: sig }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && _isOpen()) _close(); + }, { signal: sig }); + + document.addEventListener('click', function (e) { + if (!_isOpen()) return; + if (btn.contains(e.target)) return; + if (fan.contains(e.target)) return; + _close(); + }, { signal: sig }); + + return ac; + } + + window.bindBurger = bindBurger; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bindBurger); + } else { + bindBurger(); + } +}()); diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index d3a5779..9cab1ef 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -2547,3 +2547,52 @@ class SeaDeckViewTest(TestCase): data = response.json() all_ids = {c["id"] for c in data["levity"]} | {c["id"] for c in data["gravity"]} self.assertNotIn(sig_card.id, all_ids) + + +class RoomBurgerBtnRenderTest(TestCase): + """Burger btn + fan of 5 sub-btns renders on room.html unconditionally — + from the gatekeeper state through every table_status. Sub-btns are + pure scaffolding (no handlers in this sprint).""" + + def setUp(self): + self.user = User.objects.create(email="founder@test.io") + self.room = Room.objects.create(name="Burger Room", owner=self.user) + self.client.force_login(self.user) + self.url = reverse("epic:room", kwargs={"room_id": self.room.id}) + + def test_burger_btn_renders(self): + response = self.client.get(self.url) + self.assertContains(response, 'id="id_burger_btn"') + self.assertContains(response, "fa-burger") + + def test_burger_fan_container_renders(self): + response = self.client.get(self.url) + self.assertContains(response, 'id="id_burger_fan"') + + def test_five_fan_sub_btns_render_with_correct_ids(self): + response = self.client.get(self.url) + for btn_id in ( + "id_sky_btn", "id_earth_btn", "id_sea_btn", + "id_voice_btn", "id_text_btn", + ): + self.assertContains(response, f'id="{btn_id}"') + + def test_fan_sub_btn_icons_match_spec(self): + response = self.client.get(self.url) + for icon in ( + "fa-cloud", "fa-earth-americas", "fa-bridge-water", + "fa-headset", "fa-keyboard", + ): + self.assertContains(response, icon) + + def test_burger_btn_script_loaded(self): + response = self.client.get(self.url) + self.assertContains(response, "burger-btn.js") + + def test_burger_persists_in_table_status_state(self): + """Burger renders past the gatekeeper too — confirmed for ROLE_SELECT.""" + self.room.gate_status = Room.OPEN + self.room.table_status = Room.ROLE_SELECT + self.room.save() + response = self.client.get(self.url) + self.assertContains(response, 'id="id_burger_btn"') diff --git a/src/static/tests/BurgerSpec.js b/src/static/tests/BurgerSpec.js new file mode 100644 index 0000000..936a8ef --- /dev/null +++ b/src/static/tests/BurgerSpec.js @@ -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); + }); + }); +}); diff --git a/src/static/tests/SpecRunner.html b/src/static/tests/SpecRunner.html index 41adf9f..8ea2547 100644 --- a/src/static/tests/SpecRunner.html +++ b/src/static/tests/SpecRunner.html @@ -30,6 +30,7 @@ + @@ -42,6 +43,7 @@ + diff --git a/src/static_src/scss/_applets.scss b/src/static_src/scss/_applets.scss index b21bbb6..d11f47a 100644 --- a/src/static_src/scss/_applets.scss +++ b/src/static_src/scss/_applets.scss @@ -108,6 +108,7 @@ #id_game_applet_menu, #id_game_kit_menu, #id_wallet_applet_menu, +#id_room_menu, #id_post_menu, #id_billboard_applet_menu, #id_billscroll_menu, @@ -118,24 +119,28 @@ z-index: 312; } -// In landscape: shift gear btn and applet menus into the footer-sidebar -// column. Both gear-btn (3rem wide) and the menus are centred in the -// `var(--sidebar-w)` slot via `right: calc((var(--sidebar-w) - 3rem) / 2)`, -// which scales with the rem-fluid root — no per-breakpoint override. +// In landscape: gear-btn relocates to the LEFT of kit_btn at the top of +// the right sidebar (kit at right:0.5rem, gear at right:4.2rem — same +// 3.7rem centre-to-centre delta as the portrait gear-above-kit stack, +// rotated 90deg into the horizontal axis). Each page that hosts a page- +// level `.gear-btn` gets the same anchor. +// +// Applet menus all anchor at top:2.1rem; right:4.2rem (beneath the gear's +// leftward arc) and extend DOWN-LEFT. Vertical column flow stays the +// default — the only menu that rotates aspect is #id_room_menu, which +// carries that override in _room.scss (2-btn menu → row layout). @media (orientation: landscape) { - .gameboard-page, - .dashboard-page, - .wallet-page, - .room-page, - .post-page, - .billboard-page, - .billscroll-page, - .my-sea-page { - > .gear-btn { - right: calc((var(--sidebar-w) - 3rem) / 2); - bottom: 3.95rem; - top: auto; - } + .gameboard-page > .gear-btn, + .dashboard-page > .gear-btn, + .wallet-page > .gear-btn, + .room-page > .gear-btn, + .post-page > .gear-btn, + .billboard-page > .gear-btn, + .billscroll-page > .gear-btn, + .my-sea-page > .gear-btn { + top: 0.5rem; + bottom: auto; + right: 4.2rem; } #id_dash_applet_menu, @@ -147,9 +152,11 @@ #id_billboard_applet_menu, #id_billscroll_menu, #id_my_sea_menu { - right: calc((var(--sidebar-w) - 3rem) / 2); - bottom: 6.6rem; - top: auto; + position: fixed; + top: 2.6rem; + right: 4.2rem; + bottom: auto; + left: auto; } } diff --git a/src/static_src/scss/_bud.scss b/src/static_src/scss/_bud.scss index ab14acb..6583e79 100644 --- a/src/static_src/scss/_bud.scss +++ b/src/static_src/scss/_bud.scss @@ -12,15 +12,17 @@ bottom: 0.5rem; left: 0.5rem; - // In landscape, the bud-btn moves to the UPPER-RIGHT corner of the - // footer sidebar (top of the right sidebar). Centred within the - // var(--sidebar-w) column. Mirrors kit-btn / gear-btn (bottom-right) - // for the horizontal-centre formula, but anchors at top instead. + // In landscape, the bud-btn lives at the BOTTOM-RIGHT corner of the + // right sidebar (mirror of kit_btn at the TOP). Burger sits to its + // LEFT in the same horizontal pair (kit/gear at top, bud/burger at + // bottom). Anchored at right: 0.5rem (same as portrait) so the + // burger-to-the-LEFT-of-bud gap reads as 0.7rem edge-to-edge, + // matching the portrait burger-above-bud gap. @media (orientation: landscape) { left: auto; - right: calc((var(--sidebar-w) - 3rem) / 2); - top: 0.5rem; - bottom: auto; + right: 0.5rem; + top: auto; + bottom: 0.5rem; } z-index: 318; @@ -64,12 +66,12 @@ opacity: 0; @media (orientation: landscape) { - // Bud-btn lives at the top of the right sidebar in landscape, so - // the panel slides out leftward from the right edge along the top. - // Clear both fixed sidebars; transform-origin flips to right so - // the closed state collapses into the bud-btn. - top: 0.5rem; - bottom: auto; + // Bud-btn lives at the BOTTOM of the right sidebar in landscape, + // so the panel slides out leftward from the right edge along the + // BOTTOM. Clear both fixed sidebars; transform-origin flips to + // right so the closed state collapses into the bud-btn. + top: auto; + bottom: 0.5rem; left: calc(var(--sidebar-w) + 0.5rem); right: calc(var(--sidebar-w) + 0.5rem); transform-origin: right center; @@ -141,10 +143,11 @@ html:has(#id_kit_bag_dialog[open]) #id_bud_btn { box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4); @media (orientation: landscape) { - // Panel now at top in landscape, so suggestions drop downward. - top: 4rem; - bottom: auto; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + // Panel sits at the BOTTOM in landscape, so suggestions rise upward + // from above the panel — shadow direction stays "up" (negative y). + top: auto; + bottom: 4rem; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4); left: calc(var(--sidebar-w) + 0.5rem); right: calc(var(--sidebar-w) + 0.5rem); } diff --git a/src/static_src/scss/_burger.scss b/src/static_src/scss/_burger.scss new file mode 100644 index 0000000..02378ca --- /dev/null +++ b/src/static_src/scss/_burger.scss @@ -0,0 +1,121 @@ +// ── Burger btn + fan-of-5 (room.html only) ─────────────────────────── +// +// Sits parallel to .gear-btn-above-#id_kit_btn, but on the bud-side: +// portrait : bottom-left, above #id_bud_btn +// landscape: right sidebar, to the LEFT of #id_bud_btn (same horizontal +// pair as gear ← kit at the top) +// +// Active state opens a fanning arc of 5 sub-btns 30deg apart, each +// centred --r from the burger centre. R=7.75rem reads as a comfortable +// spread w. ~0.4rem edge-to-edge gaps between adjacent sub-btns. +// portrait : arc 12→4 o'clock (sky, earth, sea, voice, text) +// landscape: arc 9→1 o'clock (same icon order, rotated CCW 90°) +// +// Sub-btns are pure scaffolding — no click handlers in this sprint. +// +// Landscape btn relocation rules for kit / gear / bud / kit_dialog / bud +// panel / applet menus DON'T live in this partial — they're owned by +// their respective `_game-kit.scss`, `_bud.scss`, `_applets.scss`, +// `_room.scss` partials. _burger.scss only owns burger-specific styling. + +#id_burger_btn { + position: fixed; + bottom: 4.2rem; + left: 0.5rem; + z-index: 318; + font-size: 1.75rem; + cursor: pointer; + color: rgba(var(--secUser), 1); + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 50%; + background-color: rgba(var(--priUser), 1); + border: 0.15rem solid rgba(var(--secUser), 1); + transition: opacity 0.15s ease; + + &.active { + color: rgba(var(--quaUser), 1); + border-color: rgba(var(--quaUser), 1); + } + + // Landscape: burger sits to the LEFT of #id_bud_btn (same bottom + // anchor as bud). Mirrors gear-to-the-LEFT-of-kit at the top of the + // right sidebar (see _applets.scss for the gear placement). + @media (orientation: landscape) { + left: auto; + right: 4.2rem; // bud_btn right (0.5) + 3.7rem centre-to-centre delta + bottom: 0.5rem; + top: auto; + } +} + +// Fan anchor — sits at the burger centre. Sub-btns inside translate +// outward radially when #id_burger_btn carries the .active class. +#id_burger_fan { + position: fixed; + bottom: 5.7rem; // burger bottom (4.2) + half-btn (1.5) + left: 2rem; // burger left (0.5) + half-btn (1.5) + width: 0; + height: 0; + z-index: 317; // below #id_burger_btn so the burger stays clickable + pointer-events: none; + + @media (orientation: landscape) { + left: auto; + right: 5.7rem; // burger right (4.2) + half-btn (1.5) → burger centre + bottom: 2rem; // burger bottom (0.5) + half-btn (1.5) → burger centre + top: auto; + } +} + +.burger-fan-btn { + --r: 7.75rem; + --i: 0; + --base: 0deg; // portrait: arc starts at 12 o'clock + --angle: calc(var(--base) + var(--i) * 30deg); + + position: absolute; + left: -1.5rem; // centre 3rem btn on parent's 0,0 origin + top: -1.5rem; + width: 3rem; + height: 3rem; + border-radius: 50%; + background-color: rgba(var(--priUser), 1); + border: 0.15rem solid rgba(var(--secUser), 1); + color: rgba(var(--secUser), 1); + font-size: 1.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + // Closed state — collapsed onto burger centre, invisible + transform: rotate(var(--angle)) translateY(0) rotate(calc(-1 * var(--angle))); + opacity: 0; + pointer-events: none; + transition: transform 0.25s ease-out, opacity 0.2s ease; + + // Landscape: arc rotates CCW 90° — start at 9 o'clock instead of 12. + @media (orientation: landscape) { + --base: -90deg; + } +} + +// Per-btn arc index (clockwise from arc start) — voice leads, then sky, +// earth, sea, text. Portrait arc: voice at 12, text at 4 o'clock. +// Landscape arc: voice at 9, text at 1 o'clock. +#id_voice_btn { --i: 0; } +#id_sky_btn { --i: 1; } +#id_earth_btn { --i: 2; } +#id_sea_btn { --i: 3; } +#id_text_btn { --i: 4; } + +// 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; +} diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 052f942..f730bc6 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -1456,9 +1456,6 @@ html:has(.sig-backdrop) { align-self: center; } - // Room menu: base right: 0.5rem (same-specificity ID rule) overrides _applets.scss - // XL block because _card-deck.scss is imported after _applets.scss. Re-declare here to win the cascade. - #id_room_menu { right: 2.5rem; } } // ─── My Sign picker grid ──────────────────────────────────────────────────── diff --git a/src/static_src/scss/_game-kit.scss b/src/static_src/scss/_game-kit.scss index b607afa..538faf2 100644 --- a/src/static_src/scss/_game-kit.scss +++ b/src/static_src/scss/_game-kit.scss @@ -3,15 +3,16 @@ bottom: 0.5rem; right: 0.5rem; - // In landscape, centre the 3rem-wide btn within the var(--sidebar-w) - // footer sidebar — matches the bud-btn (bottom-left) + gear-btn (above - // kit-btn) formula. The clamp on the root rem means the sidebar scales - // fluidly, so this single rule covers all viewports without a per- - // breakpoint override. + // In landscape, kit btn relocates to the TOP of the right sidebar + // — anchored at right: 0.5rem (same as portrait), so the gear-to-the- + // LEFT-of-kit gap (in _applets.scss) reads as 0.7rem edge-to-edge, + // matching portrait's vertical gear-above-kit gap. Sidebar-centring + // via calc((--sidebar-w - 3rem) / 2) = 1rem would tighten that gap + // to 0.2rem, which reads visually wider than portrait. @media (orientation: landscape) { - right: calc((var(--sidebar-w) - 3rem) / 2); - bottom: 0.5rem; - top: auto; + right: 0.5rem; + top: 0.5rem; + bottom: auto; } z-index: 318; @@ -47,16 +48,13 @@ border: none; border-top: 0.1rem solid rgba(var(--quaUser), 1); background: rgba(var(--priUser), 0.97); - z-index: 316; + // Above the burger btn (z-318) so the dialog covers it when open in + // BOTH orientations — burger sits in the bottom-left corner in + // portrait + the right sidebar in landscape; the dialog lands atop. + z-index: 319; overflow: hidden; - @media (orientation: landscape) { - $sidebar-w: 4rem; - // left: $sidebar-w; - right: $sidebar-w; - z-index: 316; - } - // Closed state + // Closed state — portrait: grow from below the footer (max-height) max-height: 0; visibility: hidden; transition: max-height 0.25s ease-out, visibility 0s 0.25s; @@ -71,6 +69,49 @@ align-items: center; padding: 0.4rem 1rem; } + + // In landscape, the dialog slides in from off-viewport to the RIGHT, + // landing ATOP the right sidebar (covers kit + gear + burger + bud + // when open). Full viewport height. Width matches sidebar-w so the + // closed→open width animation reads like the portrait grow-UPWARD- + // from-bottom rotated 90° — closed has zero width, open spans the + // sidebar column from right edge inward. + @media (orientation: landscape) { + top: 0; + bottom: 0; + right: 0; + left: auto; + width: var(--sidebar-w); + height: auto; + + // Opaque in landscape — the dialog covers the sidebar btns + the + // burger + bud area when open; transparency would let those btns + // bleed through the dialog content. + background: rgba(var(--priUser), 1); + + // Closed state — width animates from 0 (grows leftward from right) + max-width: 0; + max-height: none; + transition: max-width 0.25s ease-out, visibility 0s 0.25s; + + // Top-edge accent → left-edge accent (border on the aperture-facing side) + border-top: none; + border-left: 0.1rem solid rgba(var(--quaUser), 1); + + &[open] { + max-width: var(--sidebar-w); + max-height: none; + transition: max-width 0.25s ease-out, visibility 0s; + display: flex !important; + // DOM order is Deck→Dice→Trinket→Tokens; column-reverse paints + // them visually bottom→top so Deck rests at the bottom of the + // bar and Tokens at the top. + flex-direction: column-reverse; + gap: 1rem; + align-items: center; + padding: 1rem 0.25rem; + } + } } .kit-bag-section { @@ -79,6 +120,14 @@ align-items: center; gap: 0.5rem; flex-shrink: 0; + + // Landscape — stack icon row ABOVE label. DOM order is label → row; + // column-reverse paints row at top + label at bottom (icon above title). + @media (orientation: landscape) { + flex-direction: column-reverse; + align-items: center; + gap: 0.25rem; + } } .kit-bag-label { @@ -92,6 +141,17 @@ text-orientation: mixed; transform: rotate(180deg) scaleX(1.3); padding: 0 0.25rem 0 0.5rem; + + // Landscape — undo vertical writing-mode + rotation; label reads + // horizontally beneath its icon in the column-reverse section. + @media (orientation: landscape) { + writing-mode: horizontal-tb; + text-orientation: mixed; + transform: none; + padding: 0; + font-size: 0.5rem; + text-align: center; + } } .kit-bag-row { @@ -156,6 +216,14 @@ flex-wrap: nowrap; scrollbar-width: none; &::-webkit-scrollbar { display: none; } + + // Landscape — the dialog runs vertically, so the Tokens row also + // flips to a vertical column + scrolls along the y-axis instead. + @media (orientation: landscape) { + flex-direction: column; + overflow-x: visible; + overflow-y: auto; + } } .kit-bag-empty { diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index a436d54..68bf197 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -13,24 +13,6 @@ $gate-line: 2px; } -#id_room_menu { - position: fixed; - bottom: 6.6rem; - right: 0.5rem; - z-index: 314; - background-color: rgba(var(--priUser), 0.95); - border: 0.15rem solid rgba(var(--secUser), 1); - box-shadow: - 0 0 0.5rem rgba(var(--secUser), 0.75), - 0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25) - ; - border-radius: 0.75rem; - padding: 1rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - // Scroll-lock when gate is open. Uses html (not body) to avoid CSS overflow // propagation quirk on Linux headless Firefox where body overflow:hidden can // disrupt pointer events on position:fixed descendants. diff --git a/src/static_src/scss/core.scss b/src/static_src/scss/core.scss index f343135..bc7a5e5 100644 --- a/src/static_src/scss/core.scss +++ b/src/static_src/scss/core.scss @@ -14,6 +14,7 @@ @import 'tooltips'; @import 'game-kit'; @import 'bud'; +@import 'burger'; @import 'wallet-tokens'; diff --git a/src/static_src/tests/BurgerSpec.js b/src/static_src/tests/BurgerSpec.js new file mode 100644 index 0000000..936a8ef --- /dev/null +++ b/src/static_src/tests/BurgerSpec.js @@ -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); + }); + }); +}); diff --git a/src/static_src/tests/SpecRunner.html b/src/static_src/tests/SpecRunner.html index 41adf9f..8ea2547 100644 --- a/src/static_src/tests/SpecRunner.html +++ b/src/static_src/tests/SpecRunner.html @@ -30,6 +30,7 @@ + @@ -42,6 +43,7 @@ + diff --git a/src/templates/apps/gameboard/_partials/_room_burger.html b/src/templates/apps/gameboard/_partials/_room_burger.html new file mode 100644 index 0000000..034d23b --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_room_burger.html @@ -0,0 +1,24 @@ +{# 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. #} + + diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 47fceb4..ef92801 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -119,6 +119,7 @@ {% endif %} {% include "apps/gameboard/_partials/_room_gear.html" %} + {% include "apps/gameboard/_partials/_room_burger.html" %} {% endblock content %} @@ -135,4 +136,5 @@ + {% endblock scripts %}