Files
python-tdd/src/static_src/scss/_tray.scss
Disco DeDisco ace8612099
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
tray apparatus scales w. fluid rem; sig-select 9×2 middling breakpoint — $handle-exposed was 48px fixed while #id_tray_btn is 3rem, so on big-rem viewports (clamp(14px, 2.4vmin, 22px) → up to 22px on tall screens, btn=66) the btn's flex parent (#id_tray_handle) shrank the btn from 66×66 → 48×66 via default flex-shrink:1 in portrait (elongated tall ellipse), and in landscape the btn overflowed the 48px-tall handle vertically (extending 9px past viewport top in closed state); fix: $handle-exposed: 3rem matches the btn so it fills the exposed area at every rem; $handle-rect-h: 4.5rem (was 72px) gives the visible rail thickness a touch of breathing room around the btn at every scale; landscape rules in the same partial that hard-coded 48px / 72px (#id_tray_handle { height: 48px }, #id_tray_grip { bottom: calc(48px/2 - 0.125rem); width: 72px }) now reference the variables so they track in sync — tray.js _computeBounds() swapped from _btn.offsetWidth/Height_handle.offsetWidth/Height for the same reason: even with the SCSS fix, measuring the btn would re-introduce the offset when btn and handle drift (which they shouldn't now, but the handle is the layout-defining element so measure it directly); id_kit_btn added as fallback for id_gear_btn (which no longer renders on the room page) so the open-state landscape wrap height anchors to the bottom-right kit btn instead of the full viewport — id_tray_handle cached on the module via _handle ref alongside _btn and cleared in reset() ; sig-select grid jumped straight from 6 cols (narrow landscape) → 18 cols × 3rem at min-width: 900px, but 18×3rem + 7rem modal margins needs ~1376px to clear at rem=22 so the cards spilled off the sides on common 1280-wide laptops + the previous-era 9×2 middling layout had simply been dropped; new cascade in _card-deck.scss mirrors the comment's documented intent: 6 cols default landscape (row layout, stage beside grid) → 9 cols × 3rem at min-width: 900px (column layout, stage above grid) → 18 cols × 3rem at min-width: 1400px → 18 × 5rem at min-width: 1800px (unchanged) — verified in Claudezilla across iphone-14 portrait (rem=14, btn=42 square, handle right edge at viewport right), 816×826 portrait near-landscape (rem=19.6, btn=58.75 square no longer elongated), 1149×751 landscape mid (rem=18, btn=54 square at viewport top, 9×2 grid), 1789×1111 desktop XL (rem=22, btn=66 square at viewport top, 18×1 grid)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:21:02 -04:00

401 lines
16 KiB
SCSS

// ─── 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: 4.5rem; // visible rail thickness — scales w. rem; ≈ btn (3rem) + breathing room
$handle-exposed: 3rem; // matches #id_tray_btn so the btn fills the exposed handle area
// (previously 48px → flex-shrink squished the btn on rem > 16px viewports)
$handle-r: 1rem;
$tray-bevel: 0.3rem; // inner bevel ring; grid must sit inside this
#id_tray_wrap.role-select-phase {
#id_tray_handle { visibility: hidden; pointer-events: none; }
}
#id_tray_wrap {
position: fixed;
// 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;
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 .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); }
}
// ─── Role card: scrawl fade-in ───────────────────────────────────────────────
@keyframes tray-role-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.tray-role-card {
padding: 0.5rem; // breathing room around role art (post-bleed-trim)
overflow: hidden;
background: transparent;
// Dotted borders on .tray-cell would otherwise shrink the content box
// and push a `width/height: 100%` img off-centre. Flex centring +
// sizing the img to the full grid track keeps it visually centred
// while preserving the dotted grid markings.
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:focus { outline: none; }
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
transition: rotate 0.25s ease;
}
// Hover/touch tilts the scrawl 7° counter-clockwise; :focus persists the
// tilt after a click (cell receives tabindex="0" from tray.js); .tt-active
// (set by TrayTooltip while the portal is open for this cell) keeps the
// tilt while the user is hovering the portal itself rather than the cell.
&:hover > img,
&:focus > img,
&.tt-active > img {
rotate: -7deg;
}
// Cell stays static; only the scrawl image fades in.
&.fade-in img {
animation: tray-role-fade-in 1s ease forwards;
}
}
// Hosts the same compact rank-+-icon Sig card used in the Sea Select center
// (.sig-stage-card.sea-sig-card). Width is sized so the 5:8-aspect card
// height ≈ tray cell height.
.tray-sig-card {
padding: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:focus { outline: none; }
.sig-stage-card.sea-sig-card {
--sig-card-w: calc(var(--tray-cell-size, 48px) * 5 / 8);
// `rotate` is independent of `transform`, so the existing -5° baseline
// (set on .sig-stage-card.sea-sig-card via transform) is preserved and
// this rotates 7° clockwise on top of it.
transition: rotate 0.25s ease;
}
&:hover > .sig-stage-card,
&:focus > .sig-stage-card,
&.tt-active > .sig-stage-card {
rotate: 7deg;
}
// Fade-in mirrors .tray-role-card.fade-in img — the .sig-stage-card child
// fades from opacity 0 to 1 once placeSig() lands.
&.fade-in > .sig-stage-card {
animation: tray-role-fade-in 1s ease forwards;
}
}
@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 {
--tray-bevel: #{$tray-bevel}; // exposed to JS via getComputedStyle for cell-size math
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);
padding: $tray-bevel; // inset grid inside the bevel ring on every felt-facing side
box-shadow:
-0.25rem 0 0.5rem rgba(0, 0, 0, 0.55),
inset 0 0 0 $tray-bevel 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-color: rgba(var(--priUser), 0.35);
border-right: 2px dotted rgba(var(--priUser), 0.35);
border-bottom: 2px dotted rgba(var(--priUser), 0.35);
position: relative;
// Whatever a cell holds (role-card img, sig stage card, future Celtic Cross
// / sky wheel / dice) gets a soft drop shadow to lift it off the felt.
// Applied to the child rather than the cell itself so the dotted grid
// borders stay shadow-free.
> * {
box-shadow: 1px 1px 5px rgba(0, 0, 0, 1);
}
// Img children (SVG/PNG with transparent regions): use filter drop-shadow
// instead so the shadow traces the rendered silhouette, not the SVG viewport.
> img {
box-shadow: none;
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 1));
}
}
// ─── 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: $handle-exposed; // same exposed dimension as portrait — scales w. rem
}
#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(#{$handle-exposed} / 2 - 0.125rem); // $handle-exposed / 2 from handle bottom
top: auto;
left: 50%;
transform: translateX(-50%);
width: $handle-rect-h; // narrow visible dimension — scales w. rem
height: $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 $tray-bevel 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: 1; // fill wrap height (JS sets wrap height = gearBtnTop)
height: auto;
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.
// Inset by --tray-bevel so the grid sits inside the bevel ring rather
// than under it (matches the portrait padding inset of the same width).
position: absolute;
bottom: var(--tray-bevel, 0.3rem);
left: var(--tray-bevel, 0.3rem);
}
// 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-color: rgba(var(--priUser), 0.35);
border-top: 2px dotted rgba(var(--priUser), 0.35);
border-bottom: none;
}
// Role card: same fade-in in landscape — no override needed.
@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); }
}
}
// ≥1800px uses the same landscape tray rules as narrower landscape — no override block needed.