diff --git a/src/apps/epic/static/apps/epic/tray.js b/src/apps/epic/static/apps/epic/tray.js index 83c3683..d48a372 100644 --- a/src/apps/epic/static/apps/epic/tray.js +++ b/src/apps/epic/static/apps/epic/tray.js @@ -9,6 +9,7 @@ var Tray = (function () { var _wrap = null; var _btn = null; var _tray = null; + var _grid = null; // Portrait bounds (X axis) var _minLeft = 0; @@ -39,11 +40,38 @@ var Tray = (function () { return window.innerWidth > window.innerHeight; } + // Compute the square cell size from the tray's interior dimension and set + // --tray-cell-size on #id_tray so SCSS grid tracks pick it up. + // Portrait: divide height / 8. Landscape: divide width / 8. + // In portrait the tray may be display:none; we show it with visibility:hidden + // briefly so clientHeight returns a real value, then restore display:none. + function _computeCellSize() { + if (!_tray) return; + var size; + if (_isLandscape()) { + size = Math.floor(_tray.clientWidth / 8); + } else { + var wasHidden = (_tray.style.display === 'none' || !_tray.style.display); + if (wasHidden) { + _tray.style.visibility = 'hidden'; + _tray.style.display = 'grid'; + } + size = Math.floor(_tray.clientHeight / 8); + if (wasHidden) { + _tray.style.display = 'none'; + _tray.style.visibility = ''; + } + } + if (size > 0) { + _tray.style.setProperty('--tray-cell-size', size + 'px'); + } + } + function _computeBounds() { 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. + // 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. var gearBtn = document.getElementById('id_gear_btn'); @@ -52,7 +80,6 @@ var Tray = (function () { 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. @@ -96,7 +123,7 @@ var Tray = (function () { _open = true; // Portrait only: toggle tray display. // Landscape: tray is always display:block; wrap position controls visibility. - if (!_isLandscape() && _tray) _tray.style.display = 'block'; + if (!_isLandscape() && _tray) _tray.style.display = 'grid'; if (_btn) _btn.classList.add('open'); if (_wrap) { _wrap.classList.remove('tray-dragging'); @@ -156,7 +183,7 @@ var Tray = (function () { 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'; + if (!_isLandscape() && _tray) _tray.style.display = 'grid'; _wrap.classList.add('wobble'); _wrap.addEventListener('animationend', function handler() { _wrap.classList.remove('wobble'); @@ -185,19 +212,22 @@ var Tray = (function () { _wrap = document.getElementById('id_tray_wrap'); _btn = document.getElementById('id_tray_btn'); _tray = document.getElementById('id_tray'); + _grid = document.getElementById('id_tray_grid'); if (!_btn) return; if (_isLandscape()) { // Show tray before measuring so offsetHeight includes it. - if (_tray) _tray.style.display = 'block'; + if (_tray) _tray.style.display = 'grid'; _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'; + _computeCellSize(); } else { // Clear landscape's inline top so portrait CSS applies. if (_wrap) _wrap.style.top = ''; _applyVerticalBounds(); + _computeCellSize(); // wrap has correct height after _applyVerticalBounds _computeBounds(); if (_wrap) _wrap.style.left = _maxLeft + 'px'; } @@ -244,7 +274,7 @@ var Tray = (function () { if (newLeft < _maxLeft) { if (!_open) { _open = true; - if (_tray) _tray.style.display = 'block'; + if (_tray) _tray.style.display = 'grid'; if (_btn) _btn.classList.add('open'); } } else { @@ -294,9 +324,10 @@ var Tray = (function () { window.addEventListener('resize', function () { if (_isLandscape()) { // Ensure tray is visible before measuring bounds. - if (_tray) _tray.style.display = 'block'; + if (_tray) _tray.style.display = 'grid'; if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; } _computeBounds(); + _computeCellSize(); if (!_open && _wrap) _wrap.style.top = _maxTop + 'px'; } else { // Switching to portrait: hide tray if closed. @@ -304,6 +335,7 @@ var Tray = (function () { if (_wrap) _wrap.style.top = ''; _computeBounds(); _applyVerticalBounds(); + _computeCellSize(); if (!_open && _wrap) _wrap.style.left = _maxLeft + 'px'; } }); @@ -319,7 +351,10 @@ var Tray = (function () { _dragHandled = false; _landscapeOverride = null; // Restore portrait default (display:none); landscape init() will show it. - if (_tray) _tray.style.display = 'none'; + if (_tray) { + _tray.style.display = 'none'; + _tray.style.removeProperty('--tray-cell-size'); + } if (_btn) _btn.classList.remove('open'); if (_wrap) { _wrap.classList.remove('wobble', 'snap', 'tray-dragging'); @@ -344,6 +379,7 @@ var Tray = (function () { _wrap = null; _btn = null; _tray = null; + _grid = null; } if (document.readyState === 'loading') { diff --git a/src/functional_tests/test_room_tray.py b/src/functional_tests/test_room_tray.py index 1058617..1b651f7 100644 --- a/src/functional_tests/test_room_tray.py +++ b/src/functional_tests/test_room_tray.py @@ -230,3 +230,106 @@ class TrayTest(FunctionalTest): self.wait_for( lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()")) ) + + # ------------------------------------------------------------------ # + # Test T8 — portrait: 1 column × 8 rows of square cells # + # ------------------------------------------------------------------ # + + def test_tray_grid_is_1_column_by_8_rows_in_portrait(self): + room = self._make_sig_select_room() + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self._room_url(room)) + + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) + self._simulate_drag(btn, -300) + self.wait_for( + lambda: self.assertTrue( + self.browser.find_element(By.ID, "id_tray").is_displayed() + ) + ) + + cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell") + self.assertEqual(len(cells), 8) + + # 8 explicit rows set via grid-template-rows + row_count = self.browser.execute_script(""" + var s = getComputedStyle(document.getElementById('id_tray_grid')); + return s.gridTemplateRows.trim().split(/\\s+/).length; + """) + self.assertEqual(row_count, 8) + + # All 8 cells share the same x position — one column only + xs = {round(c.location['x']) for c in cells} + self.assertEqual(len(xs), 1) + + # Cells are square + cell = cells[0] + self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2) + + # ------------------------------------------------------------------ # + # Test T9 — landscape: 8 columns × 1 row of square cells # + # ------------------------------------------------------------------ # + # T9a — column/row count (structure) + def test_tray_grid_is_8_columns_by_1_row_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._simulate_drag_y(btn, 300) + self.wait_for( + lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()")) + ) + + cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell") + self.assertEqual(len(cells), 8) + + # 8 explicit columns set via grid-template-columns + col_count = self.browser.execute_script(""" + var s = getComputedStyle(document.getElementById('id_tray_grid')); + return s.gridTemplateColumns.trim().split(/\\s+/).length; + """) + self.assertEqual(col_count, 8) + + # All 8 cells share the same y position — one row only + ys = {round(c.location['y']) for c in cells} + self.assertEqual(len(ys), 1) + + # Cells are square + cell = cells[0] + self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2) + + # ------------------------------------------------------------------ # + # Test T9b — landscape: all 8 cells visible within the tray interior # + # ------------------------------------------------------------------ # + + def test_landscape_tray_all_8_cells_visible(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._simulate_drag_y(btn, 300) + self.wait_for( + lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()")) + ) + + tray = self.browser.find_element(By.ID, "id_tray") + cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell") + self.assertEqual(len(cells), 8) + + tray_right = tray.location['x'] + tray.size['width'] + tray_bottom = tray.location['y'] + tray.size['height'] + + # Each cell must fit within the tray interior (2px rounding slack) + for cell in cells: + self.assertLessEqual( + cell.location['x'] + cell.size['width'], tray_right + 2, + msg="Cell overflows tray right edge" + ) + self.assertLessEqual( + cell.location['y'] + cell.size['height'], tray_bottom + 2, + msg="Cell overflows tray bottom edge" + ) diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 7951018..180dbde 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -779,250 +779,4 @@ $inv-strip: 30px; // visible height of each stacked card after the first } } -// ─── Seat tray ────────────────────────────────────────────────────────────── -// -// Structure: -// #id_tray_wrap — fixed right edge, flex row, slides on :has(.open) -// #id_tray_handle — $handle-exposed wide; contains grip + button -// #id_tray_grip — position:absolute; ::before/::after = concentric rects -// #id_tray_btn — circle button (z-index:1, paints above grip) -// #id_tray — 280px panel; covers grip's rightward extension when open -// -// Closed: wrap translateX($tray-w) → only button circle visible at right edge. -// Open: translateX(0) → full tray panel slides in; grip rects visible as handle. - -$tray-w: 280px; -$handle-rect-w: 10000px; -$handle-rect-h: 72px; -$handle-exposed: 48px; -$handle-r: 1rem; - -#id_tray_wrap { - position: fixed; - // left set by JS: closed = vw - handle; open = 0 - // top/bottom set by JS from nav/footer measurements - top: 0; - right: 0; - bottom: 0; - z-index: 310; - pointer-events: none; - display: flex; - flex-direction: row; - align-items: stretch; - transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1); - - &.tray-dragging { transition: none; } - &.wobble { animation: tray-wobble 0.45s ease; } - &.snap { animation: tray-snap 0.30s ease; } -} - -#id_tray_handle { - flex-shrink: 0; - position: relative; - width: $handle-exposed; - display: flex; - align-items: center; - justify-content: center; -} - -#id_tray_grip { - position: absolute; - top: 50%; - left: calc(#{$handle-exposed} / 2 - 0.125rem); - transform: translateY(-50%); - width: $handle-rect-w; - height: $handle-rect-h; - pointer-events: none; - // Border + overflow:hidden on the grip itself clips ::before's shadow with correct radius - border-radius: $handle-r; - border: 0.15rem solid rgba(var(--secUser), 1); - overflow: hidden; - - // Inset inner window: box-shadow spills outward to fill the opaque frame area, - // clipped to grip's rounded edge by overflow:hidden. background:transparent = see-through hole. - &::before { - content: ''; - position: absolute; - inset: 0.4rem; - border-radius: calc(#{$handle-r} - 0.35rem); - border: 0.15rem solid rgba(var(--secUser), 1); - background: transparent; - box-shadow: 0 0 0 200px rgba(var(--priUser), 1); - } - - &::after { - content: none; - } -} - -#id_tray_btn { - pointer-events: auto; - position: relative; - z-index: 1; // above #id_tray_grip - width: 3rem; - height: 3rem; - border-radius: 50%; - background-color: rgba(var(--priUser), 1); - border: 0.15rem solid rgba(var(--secUser), 1); - cursor: grab; - display: inline-flex; - align-items: center; - justify-content: center; - - i { - font-size: 1.75rem; - color: rgba(var(--secUser), 1); - pointer-events: none; - } - - &:active { cursor: grabbing; } - &.open { - cursor: pointer; - border-color: rgba(var(--quaUser), 1); - i { color: rgba(var(--quaUser), 1); } - } -} - -// Grip borders → --quaUser when tray is open (btn.open precedes grip in DOM so :has() needed) -#id_tray_wrap:has(#id_tray_btn.open) #id_tray_grip { - border-color: rgba(var(--quaUser), 1); - &::before { border-color: rgba(var(--quaUser), 1); } -} - -@keyframes tray-wobble { - 0%, 100% { transform: translateX(0); } - 20% { transform: translateX(-8px); } - 40% { transform: translateX(6px); } - 60% { transform: translateX(-5px); } - 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; - margin-left: 0.5rem; // small gap so tray appears slightly off-screen on drag start - pointer-events: auto; - position: relative; - z-index: 1; // above #id_tray_grip pseudo-elements - 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.55), - inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring at wall edge - inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth - inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // left wall depth (hue) - inset 0 0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // top wall depth - inset 0 0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // top wall depth (hue) - inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth - inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5) // bottom wall depth (hue) - ; - 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.55), - inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring - inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth - inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // bottom wall depth (hue) - inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth - inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // left wall depth (hue) - 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) - ; - 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); } - } -} +// ─── Seat tray — see _tray.scss ───────────────────────────────────────────── diff --git a/src/static_src/scss/_tray.scss b/src/static_src/scss/_tray.scss new file mode 100644 index 0000000..d51bd35 --- /dev/null +++ b/src/static_src/scss/_tray.scss @@ -0,0 +1,305 @@ +// ─── Seat tray ────────────────────────────────────────────────────────────── +// +// Structure: +// #id_tray_wrap — fixed right edge, flex row, slides on :has(.open) +// #id_tray_handle — $handle-exposed wide; contains grip + button +// #id_tray_grip — position:absolute; ::before/::after = concentric rects +// #id_tray_btn — circle button (z-index:1, paints above grip) +// #id_tray — 280px panel; covers grip's rightward extension when open +// +// Closed: wrap translateX($tray-w) → only button circle visible at right edge. +// Open: translateX(0) → full tray panel slides in; grip rects visible as handle. +// +// Grid layout (portrait): +// 8 explicit rows; columns auto-added as items arrive (grid-auto-flow: column). +// --tray-cell-size set by JS from tray.clientHeight / 8 → always square cells. +// +// Grid layout (landscape): +// 8 explicit columns; rows auto-added as items arrive (grid-auto-flow: row). +// --tray-cell-size set by JS from tray.clientWidth / 8 → always square cells. + +$tray-w: 280px; +$handle-rect-w: 10000px; +$handle-rect-h: 72px; +$handle-exposed: 48px; +$handle-r: 1rem; + +#id_tray_wrap { + position: fixed; + // left set by JS: closed = vw - handle; open = 0 + // top/bottom set by JS from nav/footer measurements + top: 0; + right: 0; + bottom: 0; + z-index: 310; + pointer-events: none; + display: flex; + flex-direction: row; + align-items: stretch; + transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1); + + &.tray-dragging { transition: none; } + &.wobble { animation: tray-wobble 0.45s ease; } + &.snap { animation: tray-snap 0.30s ease; } +} + +#id_tray_handle { + flex-shrink: 0; + position: relative; + width: $handle-exposed; + display: flex; + align-items: center; + justify-content: center; +} + +#id_tray_grip { + position: absolute; + top: 50%; + left: calc(#{$handle-exposed} / 2 - 0.125rem); + transform: translateY(-50%); + width: $handle-rect-w; + height: $handle-rect-h; + pointer-events: none; + // Border + overflow:hidden on the grip itself clips ::before's shadow with correct radius + border-radius: $handle-r; + border: 0.15rem solid rgba(var(--secUser), 1); + overflow: hidden; + + // Inset inner window: box-shadow spills outward to fill the opaque frame area, + // clipped to grip's rounded edge by overflow:hidden. background:transparent = see-through hole. + &::before { + content: ''; + position: absolute; + inset: 0.4rem; + border-radius: calc(#{$handle-r} - 0.35rem); + border: 0.15rem solid rgba(var(--secUser), 1); + background: transparent; + box-shadow: 0 0 0 200px rgba(var(--priUser), 1); + } + + &::after { + content: none; + } +} + +#id_tray_btn { + pointer-events: auto; + position: relative; + z-index: 1; // above #id_tray_grip + width: 3rem; + height: 3rem; + border-radius: 50%; + background-color: rgba(var(--priUser), 1); + border: 0.15rem solid rgba(var(--secUser), 1); + cursor: grab; + display: inline-flex; + align-items: center; + justify-content: center; + + i { + font-size: 1.75rem; + color: rgba(var(--secUser), 1); + pointer-events: none; + } + + &:active { cursor: grabbing; } + &.open { + cursor: pointer; + border-color: rgba(var(--quaUser), 1); + i { color: rgba(var(--quaUser), 1); } + } +} + +// Grip borders → --quaUser when tray is open (btn.open precedes grip in DOM so :has() needed) +#id_tray_wrap:has(#id_tray_btn.open) #id_tray_grip { + border-color: rgba(var(--quaUser), 1); + &::before { border-color: rgba(var(--quaUser), 1); } +} + +@keyframes tray-wobble { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-8px); } + 40% { transform: translateX(6px); } + 60% { transform: translateX(-5px); } + 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; + margin-left: 0.5rem; // small gap so tray appears slightly off-screen on drag start + pointer-events: auto; + position: relative; + z-index: 1; // above #id_tray_grip pseudo-elements + 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.55), + inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring at wall edge + inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth + inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quiUser), 0.5), // left wall depth (hue) + inset 0 0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // top wall depth + inset 0 0.6rem 1.5rem -0.5rem rgba(var(--quiUser), 0.5), // top wall depth (hue) + inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth + inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quiUser), 0.5) // bottom wall depth (hue) + ; + overflow: hidden; // clip #id_tray_grid to the felt interior +} + +#id_tray_grid { + display: grid; + // Portrait: 8 explicit rows; columns auto-added as items arrive. + // --tray-cell-size set by JS from tray.clientHeight / 8 → always square cells. + grid-template-rows: repeat(8, var(--tray-cell-size, 48px)); + grid-auto-flow: column; + grid-auto-columns: var(--tray-cell-size, 48px); +} + +.tray-cell { + border-right: 2px dotted rgba(var(--quaUser), 0.35); + border-bottom: 2px dotted rgba(var(--quaUser), 0.35); + position: relative; +} + +// ─── 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; } + + // 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 { + 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.55), + inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring + inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth + inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // bottom wall depth (hue) + inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth + inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // left wall depth (hue) + 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 + min-height: unset; + overflow: hidden; // clip #id_tray_grid to the felt interior + } + + #id_tray_grid { + // Landscape: 8 explicit columns; rows auto-added as items arrive. + // --tray-cell-size set by JS from tray.clientWidth / 8 → always square cells. + grid-template-columns: repeat(8, var(--tray-cell-size, 48px)); + grid-template-rows: none; // clear portrait's 8-row template + grid-auto-flow: row; + grid-auto-rows: var(--tray-cell-size, 48px); + // Anchor grid to the handle-side (bottom) of the tray so the first row + // is visible when partially open; additional rows grow upward. + position: absolute; + bottom: 0; + left: 0; + } + + // In landscape the first row sits at the bottom; border-top divides it from + // the felt above. border-bottom would face the wall — swap it out. + .tray-cell { + border-top: 2px dotted rgba(var(--quaUser), 0.35); + border-bottom: none; + } + + @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/core.scss b/src/static_src/scss/core.scss index fdb06e1..d4cc18f 100644 --- a/src/static_src/scss/core.scss +++ b/src/static_src/scss/core.scss @@ -6,6 +6,7 @@ @import 'gameboard'; @import 'palette-picker'; @import 'room'; +@import 'tray'; @import 'billboard'; @import 'game-kit'; @import 'wallet-tokens'; diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index fc0014b..7592e5c 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -109,7 +109,7 @@ - + {% include "apps/gameboard/_partials/_room_gear.html" %}