diff --git a/src/apps/epic/static/apps/epic/tray.js b/src/apps/epic/static/apps/epic/tray.js index d48a372..714474a 100644 --- a/src/apps/epic/static/apps/epic/tray.js +++ b/src/apps/epic/static/apps/epic/tray.js @@ -71,29 +71,36 @@ var Tray = (function () { if (_isLandscape()) { // Landscape: the wrap slides on the Y axis. // Structure (column-reverse): tray above, handle below. - // Tray has an explicit CSS height (80vh) so offsetHeight is real. - // Closed: wrap top = -(trayH) so tray is above viewport, handle at y=0. - // Open: wrap top = gearBtnTop - wrapH so handle bottom = gear btn top. + // Wrap height is fixed to gearBtnTop so the handle bottom always + // meets the gear button when open. Tray is flex:1 and fills the rest. + // Open: wrap top = 0 (pinned to viewport top). + // Closed: wrap top = -(gearBtnTop - handleH) = tray fully above viewport. var gearBtn = document.getElementById('id_gear_btn'); var gearBtnTop = window.innerHeight; if (gearBtn) { gearBtnTop = Math.round(gearBtn.getBoundingClientRect().top); } - var handleH = (_btn && _btn.offsetHeight) || 48; - var wrapH = (_wrap && _wrap.offsetHeight) || (handleH + 280); + var handleH = (_btn && _btn.offsetHeight) || 48; + + // Pin wrap height so handle bottom = gear btn top when open. + if (_wrap) _wrap.style.height = gearBtnTop + 'px'; + + // Open: wrap pinned to viewport top. + _minTop = 0; // Closed: tray hidden above viewport, handle visible at y=0. - _maxTop = -(wrapH - handleH); - - // Open: handle bottom at gear btn top. - _minTop = gearBtnTop - wrapH; + _maxTop = -(gearBtnTop - handleH); } else { // Portrait: slide on X axis. - var rightPx = parseInt(getComputedStyle(_wrap).right, 10); - if (isNaN(rightPx)) rightPx = 0; + // Wrap width is pinned to viewportW (JS) so its right edge only + // reaches the viewport boundary when left = 0 (fully open). + // This mirrors landscape: the open edge appears only at the last moment. + // Open: left = 0 → wrap right = viewportW exactly. + // Closed: left = viewportW - handleW → tray fully off-screen right. var handleW = _btn.offsetWidth || 48; + if (_wrap) _wrap.style.width = window.innerWidth + 'px'; _minLeft = 0; - _maxLeft = window.innerWidth - rightPx - handleW; + _maxLeft = window.innerWidth - handleW; } } @@ -322,21 +329,39 @@ var Tray = (function () { _btn.addEventListener('click', _onBtnClick); window.addEventListener('resize', function () { + // Always close on resize: bounds change invalidates current position. + // Cancel any in-flight close animation, then force-close state. + _cancelPendingHide(); + _open = false; + if (_btn) _btn.classList.remove('open'); + if (_wrap) _wrap.classList.remove('wobble', 'snap', 'tray-dragging'); + if (_isLandscape()) { // Ensure tray is visible before measuring bounds. if (_tray) _tray.style.display = 'grid'; - if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; } + if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; _wrap.style.width = ''; } _computeBounds(); _computeCellSize(); - if (!_open && _wrap) _wrap.style.top = _maxTop + 'px'; + // Snap to closed without transition (resize fires continuously). + if (_wrap) { + _wrap.classList.add('tray-dragging'); + _wrap.style.top = _maxTop + 'px'; + void _wrap.offsetWidth; // flush reflow so position lands before transition restored + _wrap.classList.remove('tray-dragging'); + } } else { - // Switching to portrait: hide tray if closed. - if (!_open && _tray) _tray.style.display = 'none'; - if (_wrap) _wrap.style.top = ''; + if (_tray) _tray.style.display = 'none'; + if (_wrap) { _wrap.style.top = ''; _wrap.style.height = ''; } _computeBounds(); _applyVerticalBounds(); _computeCellSize(); - if (!_open && _wrap) _wrap.style.left = _maxLeft + 'px'; + // Snap to closed without transition. + if (_wrap) { + _wrap.classList.add('tray-dragging'); + _wrap.style.left = _maxLeft + 'px'; + void _wrap.offsetWidth; // flush reflow + _wrap.classList.remove('tray-dragging'); + } } }); } @@ -358,8 +383,10 @@ var Tray = (function () { if (_btn) _btn.classList.remove('open'); if (_wrap) { _wrap.classList.remove('wobble', 'snap', 'tray-dragging'); - _wrap.style.left = ''; - _wrap.style.top = ''; + _wrap.style.left = ''; + _wrap.style.top = ''; + _wrap.style.height = ''; + _wrap.style.width = ''; } if (_onDocMove) { document.removeEventListener('pointermove', _onDocMove); diff --git a/src/static/tests/TraySpec.js b/src/static/tests/TraySpec.js index 2523024..264275f 100644 --- a/src/static/tests/TraySpec.js +++ b/src/static/tests/TraySpec.js @@ -358,5 +358,68 @@ describe("Tray", () => { const top = parseInt(wrap.style.top, 10); expect(top).toBeLessThan(0); }); + + // ── resize closes landscape tray ─────────────────────────────── // + + describe("resize closes the tray", () => { + it("closes when landscape tray is open", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(Tray.isOpen()).toBe(false); + }); + + it("removes .open from btn on resize", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(btn.classList.contains("open")).toBe(false); + }); + + it("resets wrap to closed top position on resize", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(parseInt(wrap.style.top, 10)).toBeLessThan(0); + }); + + it("does not re-open a closed tray on resize", () => { + window.dispatchEvent(new Event("resize")); + expect(Tray.isOpen()).toBe(false); + }); + }); + }); + + // ---------------------------------------------------------------------- // + // window resize — portrait // + // ---------------------------------------------------------------------- // + + describe("window resize (portrait)", () => { + it("closes the tray when open", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(Tray.isOpen()).toBe(false); + }); + + it("removes .open from btn on resize", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(btn.classList.contains("open")).toBe(false); + }); + + it("hides the tray panel on resize", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(tray.style.display).toBe("none"); + }); + + it("resets wrap to closed left position on resize", () => { + Tray.open(); + expect(wrap.style.left).toBe("0px"); + window.dispatchEvent(new Event("resize")); + expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0); + }); + + it("does not re-open a closed tray on resize", () => { + window.dispatchEvent(new Event("resize")); + expect(Tray.isOpen()).toBe(false); + }); }); }); diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 180dbde..1fd753c 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -296,6 +296,10 @@ html:has(.gate-overlay) { } } } + + .form-container { + margin-top: 1rem; + } } // Narrow viewport — scale down, 2×3 slot grid (portrait mobile + narrow desktop) @@ -698,7 +702,8 @@ $inv-strip: 30px; // visible height of each stacked card after the first } .form-container { - h3 { font-size: 0.85rem; margin: 0.25rem 0; } + margin-top: 0.75rem; + h3 { font-size: 0.85rem; margin: 0.5rem 0; } form { gap: 0.35rem; } diff --git a/src/static_src/scss/_tray.scss b/src/static_src/scss/_tray.scss index d51bd35..74315db 100644 --- a/src/static_src/scss/_tray.scss +++ b/src/static_src/scss/_tray.scss @@ -26,10 +26,11 @@ $handle-r: 1rem; #id_tray_wrap { position: fixed; - // left set by JS: closed = vw - handle; open = 0 + // left set by JS: closed = vw - handleW; open = vw - wrapW // top/bottom set by JS from nav/footer measurements + // right intentionally absent — wrap has fixed CSS width (handle + tray) + // so the open edge only reaches the viewport boundary when fully open. top: 0; - right: 0; bottom: 0; z-index: 310; pointer-events: none; @@ -201,19 +202,6 @@ $handle-r: 1rem; &.wobble { animation: tray-wobble-landscape 0.45s ease; } &.snap { animation: tray-snap-landscape 0.30s ease; } - // Landscape: extend upward instead of rightward - &::before { - top: -9999px; - bottom: auto; - right: auto; - left: 0; - width: 100%; - height: 9999px; - border-top: none; - border-bottom: none; - border-left: 2.5rem solid rgba(var(--quaUser), 1); - border-right: 2.5rem solid rgba(var(--quaUser), 1); - } } #id_tray_handle { @@ -259,8 +247,8 @@ $handle-r: 1rem; inset -0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // right wall depth inset -0.6rem 0 1.5rem -0.5rem rgba(var(--quaUser), 0.5) // right wall depth (hue) ; - flex: none; // override portrait's flex:1 so height applies - height: 80vh; // gives JS a real offsetHeight to compute bounds from + flex: 1; // fill wrap height (JS sets wrap height = gearBtnTop) + height: auto; min-height: unset; overflow: hidden; // clip #id_tray_grid to the felt interior } diff --git a/src/static_src/tests/TraySpec.js b/src/static_src/tests/TraySpec.js index 2523024..264275f 100644 --- a/src/static_src/tests/TraySpec.js +++ b/src/static_src/tests/TraySpec.js @@ -358,5 +358,68 @@ describe("Tray", () => { const top = parseInt(wrap.style.top, 10); expect(top).toBeLessThan(0); }); + + // ── resize closes landscape tray ─────────────────────────────── // + + describe("resize closes the tray", () => { + it("closes when landscape tray is open", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(Tray.isOpen()).toBe(false); + }); + + it("removes .open from btn on resize", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(btn.classList.contains("open")).toBe(false); + }); + + it("resets wrap to closed top position on resize", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(parseInt(wrap.style.top, 10)).toBeLessThan(0); + }); + + it("does not re-open a closed tray on resize", () => { + window.dispatchEvent(new Event("resize")); + expect(Tray.isOpen()).toBe(false); + }); + }); + }); + + // ---------------------------------------------------------------------- // + // window resize — portrait // + // ---------------------------------------------------------------------- // + + describe("window resize (portrait)", () => { + it("closes the tray when open", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(Tray.isOpen()).toBe(false); + }); + + it("removes .open from btn on resize", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(btn.classList.contains("open")).toBe(false); + }); + + it("hides the tray panel on resize", () => { + Tray.open(); + window.dispatchEvent(new Event("resize")); + expect(tray.style.display).toBe("none"); + }); + + it("resets wrap to closed left position on resize", () => { + Tray.open(); + expect(wrap.style.left).toBe("0px"); + window.dispatchEvent(new Event("resize")); + expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0); + }); + + it("does not re-open a closed tray on resize", () => { + window.dispatchEvent(new Event("resize")); + expect(Tray.isOpen()).toBe(false); + }); }); });