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:
@@ -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 = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
88
src/apps/epic/static/apps/epic/burger-btn.js
Normal file
88
src/apps/epic/static/apps/epic/burger-btn.js
Normal file
@@ -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();
|
||||
}
|
||||
}());
|
||||
@@ -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"')
|
||||
|
||||
Reference in New Issue
Block a user