From c08b5b764e53d12345b2796617ee82ccf05fc4f9 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 28 Mar 2026 21:23:50 -0400 Subject: [PATCH] new landscape styling & scripting for gameroom #id_tray apparatus, & some overall scripting & styling like wobble on click-to-close; new --undUser & --duoUser rootvars universally the table felt values; many new Jasmine tests to handle tray functionality --- src/apps/epic/static/apps/epic/tray.js | 269 ++++++++++++++++++++----- src/functional_tests/test_room_tray.py | 53 +++++ src/static/tests/TraySpec.js | 132 +++++++++++- src/static_src/scss/_base.scss | 24 +-- src/static_src/scss/_room.scss | 117 ++++++++++- src/static_src/scss/rootvars.scss | 12 +- src/static_src/tests/TraySpec.js | 158 ++++++++++++++- 7 files changed, 680 insertions(+), 85 deletions(-) diff --git a/src/apps/epic/static/apps/epic/tray.js b/src/apps/epic/static/apps/epic/tray.js index b83e9a9..83c3683 100644 --- a/src/apps/epic/static/apps/epic/tray.js +++ b/src/apps/epic/static/apps/epic/tray.js @@ -1,28 +1,77 @@ var Tray = (function () { var _open = false; var _dragStartX = null; + var _dragStartY = null; var _dragStartLeft = null; + var _dragStartTop = null; var _dragHandled = false; var _wrap = null; var _btn = null; var _tray = null; + + // Portrait bounds (X axis) var _minLeft = 0; var _maxLeft = 0; - // Stored so reset() can remove them from document - var _onDocMove = null; - var _onDocUp = null; + // Landscape bounds (Y axis): _maxTop = closed (more negative), _minTop = open + var _minTop = 0; + var _maxTop = 0; + + // Stored so reset() can remove them + var _onDocMove = null; + var _onDocUp = null; + var _onBtnClick = null; + var _closePendingHide = null; // portrait: pending display:none after slide + + function _cancelPendingHide() { + if (_closePendingHide && _wrap) { + _wrap.removeEventListener('transitionend', _closePendingHide); + } + _closePendingHide = null; + } + + // Testing hook — null means use real window dimensions + var _landscapeOverride = null; + + function _isLandscape() { + if (_landscapeOverride !== null) return _landscapeOverride; + return window.innerWidth > window.innerHeight; + } function _computeBounds() { - var rightPx = parseInt(getComputedStyle(_wrap).right, 10); - if (isNaN(rightPx)) rightPx = 0; - var handleW = _btn.offsetWidth || 48; - _minLeft = 0; - _maxLeft = window.innerWidth - rightPx - handleW; + if (_isLandscape()) { + // Landscape: the wrap slides on the Y axis. + // Structure (column-reverse): tray above, handle below. + // Tray is always display:block in landscape — wrap top hides/reveals it. + // Closed: wrap top = -(trayH) so tray is above viewport, handle at y=0. + // Open: wrap top = gearBtnTop - wrapH so handle bottom = gear btn top. + 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; + // Tray is display:block so offsetHeight includes it; fall back to 280. + var wrapH = (_wrap && _wrap.offsetHeight) || (handleH + 280); + + // Closed: tray hidden above viewport, handle visible at y=0. + _maxTop = -(wrapH - handleH); + + // Open: handle bottom at gear btn top. + _minTop = gearBtnTop - wrapH; + } else { + // Portrait: slide on X axis. + var rightPx = parseInt(getComputedStyle(_wrap).right, 10); + if (isNaN(rightPx)) rightPx = 0; + var handleW = _btn.offsetWidth || 48; + _minLeft = 0; + _maxLeft = window.innerWidth - rightPx - handleW; + } } function _applyVerticalBounds() { + // Portrait only: nudge wrap top/bottom to avoid overlapping nav/footer bars. var nav = document.querySelector('nav'); var footer = document.querySelector('footer'); var rem = parseFloat(getComputedStyle(document.documentElement).fontSize); @@ -43,42 +92,93 @@ var Tray = (function () { function open() { if (_open) return; + _cancelPendingHide(); // abort any in-flight portrait close animation _open = true; - if (_tray) _tray.style.display = 'block'; + // Portrait only: toggle tray display. + // Landscape: tray is always display:block; wrap position controls visibility. + if (!_isLandscape() && _tray) _tray.style.display = 'block'; if (_btn) _btn.classList.add('open'); if (_wrap) { _wrap.classList.remove('tray-dragging'); - _wrap.style.left = _minLeft + 'px'; + if (_isLandscape()) { + _wrap.style.top = _minTop + 'px'; + } else { + _wrap.style.left = _minLeft + 'px'; + } } } function close() { if (!_open) return; _open = false; - if (_tray) _tray.style.display = 'none'; if (_btn) _btn.classList.remove('open'); if (_wrap) { _wrap.classList.remove('tray-dragging'); - _wrap.style.left = _maxLeft + 'px'; + if (_isLandscape()) { + _wrap.style.top = _maxTop + 'px'; + // Snap after the slide completes. + _closePendingHide = function (e) { + if (e.propertyName !== 'top') return; + if (_wrap) _wrap.removeEventListener('transitionend', _closePendingHide); + _closePendingHide = null; + _snapWrap(); + }; + _wrap.addEventListener('transitionend', _closePendingHide); + } else { + _wrap.style.left = _maxLeft + 'px'; + // Snap first (tray still visible so it peeks), then hide tray. + _closePendingHide = function (e) { + if (e.propertyName !== 'left') return; + if (_wrap) _wrap.removeEventListener('transitionend', _closePendingHide); + _closePendingHide = null; + _snapWrap(function () { + if (!_open && _tray) _tray.style.display = 'none'; + }); + }; + _wrap.addEventListener('transitionend', _closePendingHide); + } } } function isOpen() { return _open; } + function _snapWrap(onDone) { + if (!_wrap) return; + _wrap.classList.add('snap'); + _wrap.addEventListener('animationend', function handler() { + if (_wrap) _wrap.classList.remove('snap'); + _wrap.removeEventListener('animationend', handler); + if (onDone) onDone(); + }); + } + function _wobble() { if (!_wrap) return; + // Portrait: show tray so it peeks in during the translateX animation, + // then re-hide it if the tray is still closed when the animation ends. + if (!_isLandscape() && _tray) _tray.style.display = 'block'; _wrap.classList.add('wobble'); _wrap.addEventListener('animationend', function handler() { _wrap.classList.remove('wobble'); _wrap.removeEventListener('animationend', handler); + if (!_isLandscape() && !_open && _tray) _tray.style.display = 'none'; }); } - function _startDrag(clientX) { - _dragStartX = clientX; - _dragStartLeft = _wrap ? (parseInt(_wrap.style.left, 10) || _maxLeft) : _maxLeft; - _dragHandled = false; + function _startDrag(clientX, clientY) { + _dragHandled = false; if (_wrap) _wrap.classList.add('tray-dragging'); + if (_isLandscape()) { + _dragStartY = clientY; + _dragStartX = null; + _dragStartTop = _wrap ? (parseInt(_wrap.style.top, 10) || _maxTop) : _maxTop; + _dragStartLeft = null; + } else { + _dragStartX = clientX; + _dragStartY = null; + _dragStartLeft = _wrap ? (parseInt(_wrap.style.left, 10) || _maxLeft) : _maxLeft; + _dragStartTop = null; + } } function init() { @@ -87,40 +187,72 @@ var Tray = (function () { _tray = document.getElementById('id_tray'); if (!_btn) return; - _computeBounds(); - _applyVerticalBounds(); - if (_wrap) _wrap.style.left = _maxLeft + 'px'; + if (_isLandscape()) { + // Show tray before measuring so offsetHeight includes it. + if (_tray) _tray.style.display = 'block'; + _computeBounds(); + // Clear portrait's inline left/bottom so media-query CSS applies. + if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; } + if (_wrap) _wrap.style.top = _maxTop + 'px'; + } else { + // Clear landscape's inline top so portrait CSS applies. + if (_wrap) _wrap.style.top = ''; + _applyVerticalBounds(); + _computeBounds(); + if (_wrap) _wrap.style.left = _maxLeft + 'px'; + } // Drag start — pointer and mouse variants so Selenium W3C actions // and synthetic Jasmine PointerEvents both work. _btn.addEventListener('pointerdown', function (e) { - _startDrag(e.clientX); + _startDrag(e.clientX, e.clientY); try { _btn.setPointerCapture(e.pointerId); } catch (_) {} }); _btn.addEventListener('mousedown', function (e) { if (e.button !== 0) return; - if (_dragStartX !== null) return; // pointerdown already handled it - _startDrag(e.clientX); + if (_dragStartX !== null || _dragStartY !== null) return; + _startDrag(e.clientX, e.clientY); }); // Drag move / end — on document so events that land elsewhere during // the drag (no capture, or Selenium pointer quirks) still bubble here. _onDocMove = function (e) { - if (_dragStartX === null || !_wrap) return; - var newLeft = _dragStartLeft + (e.clientX - _dragStartX); - newLeft = Math.max(_minLeft, Math.min(_maxLeft, newLeft)); - _wrap.style.left = newLeft + 'px'; - if (newLeft < _maxLeft) { - if (!_open) { - _open = true; - if (_tray) _tray.style.display = 'block'; - if (_btn) _btn.classList.add('open'); + if (!_wrap) return; + if (_isLandscape()) { + if (_dragStartY === null) return; + var newTop = _dragStartTop + (e.clientY - _dragStartY); + newTop = Math.max(_maxTop, Math.min(_minTop, newTop)); + _wrap.style.top = newTop + 'px'; + // Open when dragged below closed position; update state + class only. + // Tray display is not toggled in landscape — position controls visibility. + if (newTop > _maxTop) { + if (!_open) { + _open = true; + if (_btn) _btn.classList.add('open'); + } + } else { + if (_open) { + _open = false; + if (_btn) _btn.classList.remove('open'); + } } } else { - if (_open) { - _open = false; - if (_tray) _tray.style.display = 'none'; - if (_btn) _btn.classList.remove('open'); + if (_dragStartX === null) return; + var newLeft = _dragStartLeft + (e.clientX - _dragStartX); + newLeft = Math.max(_minLeft, Math.min(_maxLeft, newLeft)); + _wrap.style.left = newLeft + 'px'; + if (newLeft < _maxLeft) { + if (!_open) { + _open = true; + if (_tray) _tray.style.display = 'block'; + if (_btn) _btn.classList.add('open'); + } + } else { + if (_open) { + _open = false; + if (_tray) _tray.style.display = 'none'; + if (_btn) _btn.classList.remove('open'); + } } } }; @@ -128,17 +260,25 @@ var Tray = (function () { document.addEventListener('mousemove', _onDocMove); _onDocUp = function (e) { - if (_dragStartX !== null && Math.abs(e.clientX - _dragStartX) > 10) { - _dragHandled = true; + if (_isLandscape()) { + if (_dragStartY !== null && Math.abs(e.clientY - _dragStartY) > 10) { + _dragHandled = true; + } + _dragStartY = null; + _dragStartTop = null; + } else { + if (_dragStartX !== null && Math.abs(e.clientX - _dragStartX) > 10) { + _dragHandled = true; + } + _dragStartX = null; + _dragStartLeft = null; } - _dragStartX = null; - _dragStartLeft = null; if (_wrap) _wrap.classList.remove('tray-dragging'); }; document.addEventListener('pointerup', _onDocUp); document.addEventListener('mouseup', _onDocUp); - _btn.addEventListener('click', function () { + _onBtnClick = function () { if (_dragHandled) { _dragHandled = false; return; @@ -148,26 +288,43 @@ var Tray = (function () { } else { _wobble(); } - }); + }; + _btn.addEventListener('click', _onBtnClick); window.addEventListener('resize', function () { - _computeBounds(); - _applyVerticalBounds(); - if (!_open && _wrap) _wrap.style.left = _maxLeft + 'px'; + if (_isLandscape()) { + // Ensure tray is visible before measuring bounds. + if (_tray) _tray.style.display = 'block'; + if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; } + _computeBounds(); + if (!_open && _wrap) _wrap.style.top = _maxTop + 'px'; + } else { + // Switching to portrait: hide tray if closed. + if (!_open && _tray) _tray.style.display = 'none'; + if (_wrap) _wrap.style.top = ''; + _computeBounds(); + _applyVerticalBounds(); + if (!_open && _wrap) _wrap.style.left = _maxLeft + 'px'; + } }); } // reset() — restores module state; used by Jasmine afterEach function reset() { - _open = false; - _dragStartX = null; - _dragStartLeft = null; - _dragHandled = false; + _open = false; + _dragStartX = null; + _dragStartY = null; + _dragStartLeft = null; + _dragStartTop = null; + _dragHandled = false; + _landscapeOverride = null; + // Restore portrait default (display:none); landscape init() will show it. if (_tray) _tray.style.display = 'none'; if (_btn) _btn.classList.remove('open'); if (_wrap) { - _wrap.classList.remove('wobble', 'tray-dragging'); + _wrap.classList.remove('wobble', 'snap', 'tray-dragging'); _wrap.style.left = ''; + _wrap.style.top = ''; } if (_onDocMove) { document.removeEventListener('pointermove', _onDocMove); @@ -179,6 +336,11 @@ var Tray = (function () { document.removeEventListener('mouseup', _onDocUp); _onDocUp = null; } + if (_onBtnClick && _btn) { + _btn.removeEventListener('click', _onBtnClick); + _onBtnClick = null; + } + _cancelPendingHide(); _wrap = null; _btn = null; _tray = null; @@ -190,5 +352,12 @@ var Tray = (function () { init(); } - return { init: init, open: open, close: close, isOpen: isOpen, reset: reset }; + return { + init: init, + open: open, + close: close, + isOpen: isOpen, + reset: reset, + _testSetLandscape: function (v) { _landscapeOverride = v; }, + }; }()); diff --git a/src/functional_tests/test_room_tray.py b/src/functional_tests/test_room_tray.py index a8c26c8..c90fdbd 100644 --- a/src/functional_tests/test_room_tray.py +++ b/src/functional_tests/test_room_tray.py @@ -41,6 +41,17 @@ class TrayTest(FunctionalTest): document.dispatchEvent(new PointerEvent("pointerup", {clientX: endX, bubbles: true})); """, btn, start_x, end_x) + def _simulate_drag_y(self, btn, offset_y): + """Dispatch JS pointer events on the Y axis for landscape drag tests.""" + start_y = btn.rect['y'] + btn.rect['height'] / 2 + end_y = start_y + offset_y + self.browser.execute_script(""" + var btn = arguments[0], startY = arguments[1], endY = arguments[2]; + btn.dispatchEvent(new PointerEvent("pointerdown", {clientY: startY, clientX: 0, bubbles: true})); + document.dispatchEvent(new PointerEvent("pointermove", {clientY: endY, clientX: 0, bubbles: true})); + document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true})); + """, btn, start_y, end_y) + def _make_sig_select_room(self, founder_email="founder@test.io"): founder, _ = User.objects.get_or_create(email=founder_email) room = Room.objects.create(name="Tray Test Room", owner=founder) @@ -155,3 +166,45 @@ class TrayTest(FunctionalTest): self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) tray = self.browser.find_element(By.ID, "id_tray") self.assertFalse(tray.is_displayed()) + + # ------------------------------------------------------------------ # + # Test T6 — landscape: tray btn is near the top edge of the viewport # + # ------------------------------------------------------------------ # + + def test_tray_btn_anchored_near_top_in_landscape(self): + room = self._make_sig_select_room() + self.create_pre_authenticated_session("founder@test.io") + self.browser.set_window_size(900, 500) + self.browser.get(self._room_url(room)) + + btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_tray_btn") + ) + self.assertTrue(btn.is_displayed()) + + # In landscape the handle sits at the top of the content area; + # btn bottom should be within the top 40% of the viewport. + vh = self.browser.execute_script("return window.innerHeight") + btn_bottom = btn.location["y"] + btn.size["height"] + self.assertLess(btn_bottom, vh * 0.4) + + # ------------------------------------------------------------------ # + # Test T7 — landscape: dragging btn downward opens the tray # + # ------------------------------------------------------------------ # + + def test_dragging_tray_btn_down_opens_tray_in_landscape(self): + room = self._make_sig_select_room() + self.create_pre_authenticated_session("founder@test.io") + self.browser.set_window_size(900, 500) + self.browser.get(self._room_url(room)) + + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) + # In landscape, #id_tray is always display:block; position controls visibility. + # Use Tray.isOpen() to check logical state. + self.assertFalse(self.browser.execute_script("return Tray.isOpen()")) + + self._simulate_drag_y(btn, 300) + + self.wait_for( + lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()")) + ) diff --git a/src/static/tests/TraySpec.js b/src/static/tests/TraySpec.js index 14ea830..a442056 100644 --- a/src/static/tests/TraySpec.js +++ b/src/static/tests/TraySpec.js @@ -39,6 +39,7 @@ describe("Tray", () => { document.body.appendChild(wrap); document.body.appendChild(tray); + Tray._testSetLandscape(false); // force portrait regardless of window size Tray.init(); }); @@ -83,8 +84,10 @@ describe("Tray", () => { describe("close()", () => { beforeEach(() => Tray.open()); - it("hides #id_tray", () => { + it("hides #id_tray after the slide transition completes", () => { Tray.close(); + // display:none is deferred until transitionend — fire it manually. + wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" })); expect(tray.style.display).toBe("none"); }); @@ -203,4 +206,131 @@ describe("Tray", () => { expect(wrap.classList.contains("wobble")).toBe(false); }); }); + + // ---------------------------------------------------------------------- // + // Landscape mode — Y-axis drag, top-positioned wrap // + // ---------------------------------------------------------------------- // + + describe("landscape mode", () => { + // Re-init in landscape after the portrait init from outer beforeEach. + beforeEach(() => { + Tray.reset(); + Tray._testSetLandscape(true); + Tray.init(); + }); + + function simulateDragY(deltaY) { + const startY = 50; + btn.dispatchEvent(new PointerEvent("pointerdown", { clientY: startY, clientX: 0, bubbles: true })); + btn.dispatchEvent(new PointerEvent("pointermove", { clientY: startY + deltaY, clientX: 0, bubbles: true })); + btn.dispatchEvent(new PointerEvent("pointerup", { clientY: startY + deltaY, clientX: 0, bubbles: true })); + } + + // ── open() in landscape ─────────────────────────────────────────── // + + describe("open()", () => { + it("makes #id_tray visible", () => { + Tray.open(); + expect(tray.style.display).not.toBe("none"); + }); + + it("adds .open to #id_tray_btn", () => { + Tray.open(); + expect(btn.classList.contains("open")).toBe(true); + }); + + it("positions wrap via style.top, not style.left", () => { + Tray.open(); + expect(wrap.style.top).not.toBe(""); + expect(wrap.style.left).toBe(""); + }); + }); + + // ── close() in landscape ────────────────────────────────────────── // + + describe("close()", () => { + beforeEach(() => Tray.open()); + + it("closes the tray (display not toggled in landscape)", () => { + Tray.close(); + expect(Tray.isOpen()).toBe(false); + }); + + it("removes .open from #id_tray_btn", () => { + Tray.close(); + expect(btn.classList.contains("open")).toBe(false); + }); + + it("closed top is less than open top (wrap slides up to close)", () => { + const openTop = parseInt(wrap.style.top, 10); + Tray.close(); + const closedTop = parseInt(wrap.style.top, 10); + expect(closedTop).toBeLessThan(openTop); + }); + }); + + // ── drag — Y axis ──────────────────────────────────────────────── // + + describe("drag interaction", () => { + it("dragging down opens the tray", () => { + simulateDragY(100); + expect(Tray.isOpen()).toBe(true); + }); + + it("dragging up does not open the tray", () => { + simulateDragY(-100); + expect(Tray.isOpen()).toBe(false); + }); + + it("drag > 10px downward suppresses subsequent click", () => { + simulateDragY(100); + btn.click(); // should be swallowed — tray stays open + expect(Tray.isOpen()).toBe(true); + }); + + it("does not set style.left (Y axis only)", () => { + simulateDragY(100); + expect(wrap.style.left).toBe(""); + }); + + it("does not add .wobble during drag", () => { + simulateDragY(100); + expect(wrap.classList.contains("wobble")).toBe(false); + }); + }); + + // ── click when closed — wobble, no open ───────────────────────── // + + describe("clicking btn when closed", () => { + it("adds .wobble to wrap", () => { + btn.click(); + expect(wrap.classList.contains("wobble")).toBe(true); + }); + + it("does not open the tray", () => { + btn.click(); + expect(Tray.isOpen()).toBe(false); + }); + }); + + // ── click when open — close ────────────────────────────────────── // + + describe("clicking btn when open", () => { + beforeEach(() => Tray.open()); + + it("closes the tray", () => { + btn.click(); + expect(Tray.isOpen()).toBe(false); + }); + }); + + // ── init positions wrap at closed (top) ────────────────────────── // + + it("init sets wrap to closed position (top < 0 or = maxTop)", () => { + // After landscape init with no real elements, _maxTop = -(wrapH_fallback - handleH_fallback) + // which will be negative. Wrap starts off-screen above. + const top = parseInt(wrap.style.top, 10); + expect(top).toBeLessThan(0); + }); + }); }); diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss index 2b049d5..44941cf 100644 --- a/src/static_src/scss/_base.scss +++ b/src/static_src/scss/_base.scss @@ -266,7 +266,7 @@ body { } } - // Container: fill centre, compensate for fixed sidebars on both sides + // Container: fill center, compensate for fixed sidebars on both sides body .container { flex: 1; min-width: 0; @@ -284,7 +284,7 @@ body { margin: 0 0 0.25rem; letter-spacing: 0.4em; text-align: center; - text-align-last: center; + text-align-last: left; } } @@ -363,26 +363,6 @@ body { } } -// @media (min-width: 1024px) and (max-height: 700px) { -// body .container .navbar { -// padding: 0.5rem 0; - -// .navbar-brand h1 { -// font-size: 1.4rem; -// } -// } - -// #id_footer { -// height: 3.5rem; -// padding: 0.7rem 1rem; -// gap: 0.35rem; - -// #id_footer_nav a { -// font-size: 1.2rem; -// } -// } -// } - #id_footer { flex-shrink: 0; height: 6rem; diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index b3a50c3..1e247b8 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -371,9 +371,9 @@ $seat-r-y: round($seat-r * 0.5); // 65px width: 160px; height: 185px; clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); - background: rgba(var(--priUser), 0.8); + background: rgba(var(--duoUser), 1); // box-shadow is clipped by clip-path; use filter instead - filter: drop-shadow(0 0 8px rgba(var(--terUser), 0.25)); + filter: drop-shadow(0 0 8px rgba(var(--duoUser), 1)); display: flex; align-items: center; justify-content: center; @@ -678,6 +678,7 @@ $inv-strip: 30px; // visible height of each stacked card after the first } } } + } // ─── Significator deck (SIG_SELECT phase) ────────────────────────────────── @@ -781,7 +782,8 @@ $handle-r: 1rem; transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1); &.tray-dragging { transition: none; } - &.wobble { animation: tray-wobble 0.45s ease; } + &.wobble { animation: tray-wobble 0.45s ease; } + &.snap { animation: tray-snap 0.30s ease; } } #id_tray_handle { @@ -865,6 +867,15 @@ $handle-r: 1rem; 80% { transform: translateX(3px); } } +// Inverted wobble — handle overshoots past the wall on close, then bounces back. +@keyframes tray-snap { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(8px); } + 40% { transform: translateX(-6px); } + 60% { transform: translateX(5px); } + 80% { transform: translateX(-3px); } +} + #id_tray { flex: 1; min-width: 0; @@ -872,10 +883,10 @@ $handle-r: 1rem; pointer-events: auto; position: relative; z-index: 1; // above #id_tray_grip pseudo-elements - background: rgba(var(--secUser), 1); - border-left:2.5rem solid rgba(var(--terUser), 1); - border-top: 2.5rem solid rgba(var(--terUser), 1); - border-bottom: 2.5rem solid rgba(var(--terUser), 1); + background: rgba(var(--duoUser), 1); + border-left:2.5rem solid rgba(var(--quaUser), 1); + border-top: 2.5rem solid rgba(var(--quaUser), 1); + border-bottom: 2.5rem solid rgba(var(--quaUser), 1); box-shadow: -0.25rem 0 0.5rem rgba(0, 0, 0, 0.75), inset 0 0 0 0.12rem rgba(255, 255, 255, 0.12), // bright bevel ring at wall edge @@ -884,6 +895,98 @@ $handle-r: 1rem; inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 0.3) // bottom wall depth ; overflow-y: auto; + max-height: 85vh; // cap on very tall portrait screens // scrollbar-width: thin; // scrollbar-color: rgba(var(--terUser), 0.3) transparent; } + +// ─── Tray: landscape reorientation ───────────────────────────────────────── +// +// Must come AFTER the portrait tray rules above to win the cascade +// (same specificity — later declaration wins). +// +// In landscape the tray slides DOWN from the top instead of in from the right. +// Structure (column-reverse): tray panel above, handle below. +// JS controls style.top for the Y-axis slide: +// Closed: top = -(trayH) → only handle visible at y = 0 +// Open: top = gearBtnTop - wrapH → handle bottom at gear btn top +// +// The wrap fits horizontally between the fixed left-nav and right-footer sidebars. + +@media (orientation: landscape) { + $sidebar-w: 4rem; + $tray-landscape-max-w: 960px; // cap tray width on very wide screens + + #id_tray_wrap { + flex-direction: column-reverse; // tray panel above, handle below + left: $sidebar-w; + right: $sidebar-w; + top: auto; // JS controls style.top for the Y-axis slide + bottom: auto; + transition: top 0.35s cubic-bezier(0.4, 0, 0.2, 1); + + &.tray-dragging { transition: none; } + &.wobble { animation: tray-wobble-landscape 0.45s ease; } + &.snap { animation: tray-snap-landscape 0.30s ease; } + } + + #id_tray_handle { + width: auto; // full width of wrap + height: 48px; // $handle-exposed — same exposed dimension as portrait + } + + #id_tray_grip { + // Rotate 90°: centred horizontally, extends vertically. + // bottom mirrors portrait's left: grip starts at handle centre and extends + // toward the tray (upward in column-reverse layout). + bottom: calc(48px / 2 - 0.125rem); // $handle-exposed / 2 from handle bottom + top: auto; + left: 50%; + transform: translateX(-50%); + width: 72px; // $handle-rect-h — narrow visible dimension + height: 10000px; // $handle-rect-w — extends upward into tray area + } + + #id_tray { + // Borders: left/right/bottom are visible walls; top edge is open. + // Bottom faces the handle (same logic as portrait's left border facing handle). + border-left: 2.5rem solid rgba(var(--quaUser), 1); + border-right: 2.5rem solid rgba(var(--quaUser), 1); + border-bottom: 2.5rem solid rgba(var(--quaUser), 1); + border-top: none; + + margin-left: 0; // portrait horizontal gap no longer needed + margin-bottom: 0.5rem; // gap between tray bottom and handle top + + // Cap width on ultra-wide screens; center within the handle shelf. + width: 100%; + max-width: $tray-landscape-max-w; + align-self: center; + + box-shadow: + 0 0.25rem 0.5rem rgba(0, 0, 0, 0.75), // outer shadow (downward, below tray toward handle) + inset 0 0 0 0.12rem rgba(255, 255, 255, 0.12), // bright bevel ring + inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 0.45), // bottom wall depth (inward from bottom border) + inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 0.3), // left wall depth + inset -0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 0.3) // right wall depth + ; + min-height: 2000px; // give tray real height so JS offsetHeight > 0 + } + + @keyframes tray-wobble-landscape { + 0%, 100% { transform: translateY(0); } + 20% { transform: translateY(-8px); } + 40% { transform: translateY(6px); } + 60% { transform: translateY(-5px); } + 80% { transform: translateY(3px); } + } + + // Inverted wobble — wrap overshoots upward on close, then bounces back. + @keyframes tray-snap-landscape { + 0%, 100% { transform: translateY(0); } + 20% { transform: translateY(8px); } + 40% { transform: translateY(-6px); } + 60% { transform: translateY(5px); } + 80% { transform: translateY(-3px); } + } +} diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss index 47ce21d..1938ca6 100644 --- a/src/static_src/scss/rootvars.scss +++ b/src/static_src/scss/rootvars.scss @@ -199,9 +199,9 @@ --secPmm: 150, 120, 182; --terPmm: 112, 79, 146; // forest - --priFor: 190, 209, 170; - --secFor: 152, 182, 120; - --terFor: 114, 146, 79; + --priFor: 114, 146, 79; + --secFor: 94, 124, 61; + --terFor: 74, 102, 43; /* Technoman Palette */ // carbon steel @@ -301,7 +301,11 @@ --octClh: 26, 51, 105; // • pure (rare) --ninClh: 192, 77, 1; - --decClh: 255, 174, 0; + --decClh: 255, 174, 0; + + // Felt values + --undUser: var(--priFor); + --duoUser: var(--terFor); } /* Default Earthman Palette */ diff --git a/src/static_src/tests/TraySpec.js b/src/static_src/tests/TraySpec.js index 14ea830..2523024 100644 --- a/src/static_src/tests/TraySpec.js +++ b/src/static_src/tests/TraySpec.js @@ -39,6 +39,7 @@ describe("Tray", () => { document.body.appendChild(wrap); document.body.appendChild(tray); + Tray._testSetLandscape(false); // force portrait regardless of window size Tray.init(); }); @@ -83,11 +84,26 @@ describe("Tray", () => { describe("close()", () => { beforeEach(() => Tray.open()); - it("hides #id_tray", () => { + it("hides #id_tray after slide + snap both complete", () => { Tray.close(); + wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" })); + wrap.dispatchEvent(new Event("animationend")); expect(tray.style.display).toBe("none"); }); + it("adds .snap to wrap after slide transition completes", () => { + Tray.close(); + wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" })); + expect(wrap.classList.contains("snap")).toBe(true); + }); + + it("removes .snap from wrap once animationend fires", () => { + Tray.close(); + wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" })); + wrap.dispatchEvent(new Event("animationend")); + expect(wrap.classList.contains("snap")).toBe(false); + }); + it("removes .open from #id_tray_btn", () => { Tray.close(); expect(btn.classList.contains("open")).toBe(false); @@ -203,4 +219,144 @@ describe("Tray", () => { expect(wrap.classList.contains("wobble")).toBe(false); }); }); + + // ---------------------------------------------------------------------- // + // Landscape mode — Y-axis drag, top-positioned wrap // + // ---------------------------------------------------------------------- // + + describe("landscape mode", () => { + // Re-init in landscape after the portrait init from outer beforeEach. + beforeEach(() => { + Tray.reset(); + Tray._testSetLandscape(true); + Tray.init(); + }); + + function simulateDragY(deltaY) { + const startY = 50; + btn.dispatchEvent(new PointerEvent("pointerdown", { clientY: startY, clientX: 0, bubbles: true })); + btn.dispatchEvent(new PointerEvent("pointermove", { clientY: startY + deltaY, clientX: 0, bubbles: true })); + btn.dispatchEvent(new PointerEvent("pointerup", { clientY: startY + deltaY, clientX: 0, bubbles: true })); + } + + // ── open() in landscape ─────────────────────────────────────────── // + + describe("open()", () => { + it("makes #id_tray visible", () => { + Tray.open(); + expect(tray.style.display).not.toBe("none"); + }); + + it("adds .open to #id_tray_btn", () => { + Tray.open(); + expect(btn.classList.contains("open")).toBe(true); + }); + + it("positions wrap via style.top, not style.left", () => { + Tray.open(); + expect(wrap.style.top).not.toBe(""); + expect(wrap.style.left).toBe(""); + }); + }); + + // ── close() in landscape ────────────────────────────────────────── // + + describe("close()", () => { + beforeEach(() => Tray.open()); + + it("closes the tray (display not toggled in landscape)", () => { + Tray.close(); + expect(Tray.isOpen()).toBe(false); + }); + + it("removes .open from #id_tray_btn", () => { + Tray.close(); + expect(btn.classList.contains("open")).toBe(false); + }); + + it("closed top is less than open top (wrap slides up to close)", () => { + const openTop = parseInt(wrap.style.top, 10); + Tray.close(); + const closedTop = parseInt(wrap.style.top, 10); + expect(closedTop).toBeLessThan(openTop); + }); + + it("adds .snap to wrap after top transition completes", () => { + Tray.close(); + wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" })); + expect(wrap.classList.contains("snap")).toBe(true); + }); + + it("removes .snap from wrap once animationend fires", () => { + Tray.close(); + wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" })); + wrap.dispatchEvent(new Event("animationend")); + expect(wrap.classList.contains("snap")).toBe(false); + }); + }); + + // ── drag — Y axis ──────────────────────────────────────────────── // + + describe("drag interaction", () => { + it("dragging down opens the tray", () => { + simulateDragY(100); + expect(Tray.isOpen()).toBe(true); + }); + + it("dragging up does not open the tray", () => { + simulateDragY(-100); + expect(Tray.isOpen()).toBe(false); + }); + + it("drag > 10px downward suppresses subsequent click", () => { + simulateDragY(100); + btn.click(); // should be swallowed — tray stays open + expect(Tray.isOpen()).toBe(true); + }); + + it("does not set style.left (Y axis only)", () => { + simulateDragY(100); + expect(wrap.style.left).toBe(""); + }); + + it("does not add .wobble during drag", () => { + simulateDragY(100); + expect(wrap.classList.contains("wobble")).toBe(false); + }); + }); + + // ── click when closed — wobble, no open ───────────────────────── // + + describe("clicking btn when closed", () => { + it("adds .wobble to wrap", () => { + btn.click(); + expect(wrap.classList.contains("wobble")).toBe(true); + }); + + it("does not open the tray", () => { + btn.click(); + expect(Tray.isOpen()).toBe(false); + }); + }); + + // ── click when open — close ────────────────────────────────────── // + + describe("clicking btn when open", () => { + beforeEach(() => Tray.open()); + + it("closes the tray", () => { + btn.click(); + expect(Tray.isOpen()).toBe(false); + }); + }); + + // ── init positions wrap at closed (top) ────────────────────────── // + + it("init sets wrap to closed position (top < 0 or = maxTop)", () => { + // After landscape init with no real elements, _maxTop = -(wrapH_fallback - handleH_fallback) + // which will be negative. Wrap starts off-screen above. + const top = parseInt(wrap.style.top, 10); + expect(top).toBeLessThan(0); + }); + }); });