Files
python-tdd/src/static_src/scss/_room.scss
Disco DeDisco 6f5927083c game-views: forthcoming watermark no-overlap; reelhouse term; Text swipe-machine DOWN-hold-OVER + Jasmine-tested nav
Three things on the carousel:

- CHAT/PULSE stub: the shared [Feature forthcoming] partial centres itself absolutely, landing on top of the flex-centred watermark icon. Override it to flow (position:static) inside .room-view-stub so the icon keeps the top slot and the label rests below — FT asserts the icon's bottom clears the label's top (no clip).
- Adopt 'reelhouse' as the collective term for the fivefold applet-scroll carousel (class on #id_room_views + comments).
- Text sub-btn swipe machine: when already reel'd down onto the reelhouse, slide straight over to POST (plain goToView); when starting up in the room (the hex), run smooth DOWN to the reelhouse, HOLD 0.5s, then OVER to POST unless already there — the hold beats the two motions apart (DOWN-then-OVER, never diagonal).

Test rework: the from-hex OVER beat is a DELAYED (post-descent + hold) programmatic scroll, which headless Selenium drops/resets (works fine in a real browser), so the end-to-end land-on-POST can't be FT'd. Split it: the FT now asserts the reliable DESCENT beat (Text from the hex reveals the reelhouse + icon strip), and a new Jasmine swipe-machine spec pins the nav DECISION (from hex → POST after the descent+hold; inactive btn → no-op) against a fixture + mocked clock. room-views.js exposes init() so the spec can bind to the fixture.

Verified: 8 carousel FTs + Jasmine (atlas merge + swipe machine) green.

[[project-room-game-views-carousel]] [[feedback-ft-run-discipline]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:31:35 -04:00

1222 lines
41 KiB
SCSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

$gate-node: 64px;
$gate-gap: 36px;
$gate-line: 2px;
.room-page {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 0;
overflow: hidden;
}
// Scroll-lock when gate is open. Uses html (not body) to avoid CSS overflow
// propagation quirk on Linux headless Firefox where body overflow:hidden can
// disrupt pointer events on position:fixed descendants.
// NOTE: may be superfluous — root cause of CI kit-btn failures turned out to be
// game-kit.js missing from git (was in gitignored STATIC_ROOT only).
html:has(.gate-backdrop) {
overflow: hidden;
}
// Aperture fill — solid --duoUser layer that covers the game table (.room-page).
// Uses position:absolute so it's clipped to .room-page bounds (overflow:hidden),
// naturally staying below the h2 title + navbar/footer in both orientations.
// Sits at z-90: below blur backdrops (z-100) which render on top via backdrop-filter.
// Fades in/out via opacity transition when a backdrop class is present.
#id_aperture_fill {
position: absolute;
inset: 0;
background: rgba(var(--duoUser), 1);
z-index: 90;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
html:has(.gate-backdrop) #id_aperture_fill,
html:has(.sig-backdrop) #id_aperture_fill,
html:has(.role-select-backdrop) #id_aperture_fill,
html.sea-open #id_aperture_fill {
opacity: 1;
}
// ─── Table-hex aperture: binary scroll-snap toggle ─────────────────────────
// Mirrors my_sky's wheel<->form swap (`_sky.scss` body.sky-saved block). The
// aperture fills .room-page; from Role Select onwards it holds TWO panes —
// the hex (default) and the room's provenance scroll — and scroll-snaps
// between them. CRITICAL: the aperture and panes set NO z-index / transform /
// opacity / filter, so they create NO stacking context — the position strip's
// z-130 (a hex-pane descendant) still resolves in the root context, above the
// gate/sig overlays (z-100/120), exactly as when it lived at room-page root.
.room-aperture {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
// Gate phase (single pane): static + clipped, like the old .room-page.
overflow: hidden;
}
.room-pane {
position: relative; // containing block for the position strip (z-auto)
flex: 0 0 auto;
width: 100%;
// EXACTLY one aperture tall (not min-height) so the snap stops land at
// integer multiples of the viewport — the binary toggle, no mid-scroll.
height: 100%;
}
.room-hex-pane {
display: flex;
align-items: center;
justify-content: center;
}
// Two panes present (table phase) → engage the binary snap. `is-scrollable`
// is added by the template iff `room.table_status` is set (Role Select on).
.room-aperture.is-scrollable {
overflow-y: auto;
overflow-x: hidden;
scroll-snap-type: y mandatory;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
.room-pane {
scroll-snap-align: start;
scroll-snap-stop: always; // hard stop each section — no coasting
}
}
.room-scroll-pane {
display: flex;
flex-direction: column;
// ── Horizontal game-views carousel ────────────────────────────────────
// The pane holds a row of 5 full-width views (ATLAS/SCROLL/POST/CHAT/
// PULSE) on native x scroll-snap, nested inside the aperture's binary y
// snap. room-views.js parks the initial scrollLeft on SCROLL (2nd view).
// The horizontal scrollbar is hidden — the root icon strip is the
// affordance. CRITICAL: no z-index/transform here (root stacking context).
.room-views {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar { width: 0; height: 0; }
}
.room-view {
flex: 0 0 100%;
width: 100%;
height: 100%;
min-width: 0;
box-sizing: border-box;
scroll-snap-align: start;
scroll-snap-stop: always; // hard stop each view — no coasting
display: flex;
flex-direction: column;
// Deeper bottom padding reserves a lane for the icon strip (absolute @
// bottom 0.85rem, ~2.1rem tall incl. the active scale) so the
// .applet-scroll card ENDS ABOVE the strip — the strip stands on its
// own beneath the card instead of overlapping its bottom edge.
padding: 0.75rem 0.75rem 3.5rem;
}
// Same %applet-box card chrome + rotated room-name title (`> h2`) as the
// Billscroll page's .applet-scroll. No --duoUser pane bg — the dark card
// sits straight on the room-page background, matching scroll.html.
.applet-scroll {
@extend %applet-box;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
#id_drama_scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
// Pin the "What happens next…?" buffer to the bottom when the feed
// is short (pure-CSS equivalent of billscroll's rAF marginTop nudge).
.scroll-buffer {
margin-top: auto;
display: flex;
justify-content: center;
align-items: baseline;
padding: 2rem 0 1rem;
opacity: 0.4;
font-size: 0.8rem;
text-transform: uppercase;
.scroll-buffer-text { letter-spacing: 0.33em; }
.scroll-buffer-dots {
display: inline-flex;
letter-spacing: 0;
span { display: inline-block; width: 0.7em; text-align: center; }
}
}
}
}
// ── POST view — the room-scoped game-table thread ─────────────────────
// Reuses post.html's #id_post_table + composer markup (shared
// _post_line.html). The post styling proper is .post-page-scoped, so the
// essentials are restated here for the carousel card.
.room-view--post {
#id_post_table {
list-style: none;
margin: 0;
padding: 0 0.5rem 0 0;
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: flex-end; // bottom-anchor short threads
.post-line {
display: grid;
grid-template-columns: minmax(4rem, auto) 1fr minmax(3rem, auto);
align-items: baseline;
gap: 0.5rem;
padding: 0.25rem 0;
.post-line-author {
font-weight: bold;
color: rgba(var(--quaUser), 1);
white-space: nowrap;
font-size: 0.85rem;
}
.post-line-text { min-width: 0; overflow-wrap: anywhere; }
.post-line-time {
font-size: 0.75rem;
opacity: 0.5;
text-align: right;
white-space: nowrap;
}
}
.post-line-buffer { flex-shrink: 0; height: 0.25rem; }
}
.post-line-form {
flex-shrink: 0;
margin: 0;
padding-top: 0.25rem;
.composer-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.composer-row input.form-control { flex: 1; min-width: 0; width: auto; }
}
}
// ── ATLAS view — provenance + post-thread merged feed ─────────────────
// room-views.js rebuilds the body from the live SCROLL + POST DOM on
// activation; rows carry data-source so the two streams read distinctly.
.room-atlas-feed {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
.atlas-row {
padding: 0.3rem 0;
font-size: 0.9rem;
border-inline-start: 2px solid transparent;
padding-inline-start: 0.5rem;
.atlas-row-time { font-size: 0.7rem; opacity: 0.5; margin-inline-start: 0.4rem; }
.atlas-row-who { font-weight: bold; color: rgba(var(--quaUser), 1); margin-inline-end: 0.3rem; }
}
// Distinct per-source accent (border tint) — the styling hook the
// contract reserves for end-of-sprint polish.
.atlas-row[data-source="provenance"] { border-inline-start-color: rgba(var(--terUser), 0.6); }
.atlas-row[data-source="post"] { border-inline-start-color: rgba(var(--quaUser), 0.6); }
}
// ── CHAT / PULSE stubs ────────────────────────────────────────────────
.room-view-stub {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
opacity: 0.5;
text-align: center;
i { font-size: 2.4rem; }
// The shared [Feature forthcoming] partial centres ITSELF absolutely
// (position:absolute + translate -50%/-50%), which lands it dead-centre
// on top of the flex-centred watermark icon. Inside the stub, let it
// flow in the column instead so the icon keeps the top slot and the
// label rests below it (the `gap` separates them) — no overlap/clip.
.forthcoming {
position: static;
transform: none;
}
}
}
// ── Game-views icon strip ─────────────────────────────────────────────────
// Sibling of the aperture inside .room-page (NOT a child of the aperture) so
// it escapes the aperture's scroll clip + the scroll card's fade mask — the
// same root-stacking trick as the tooltip portals. position:absolute anchors
// it to the BOTTOM OF THE APERTURE (.room-page is position:relative and the
// aperture fills it via inset:0), NOT the viewport bottom — so it sits at the
// foot of the views pane, clear of the fixed burger/gear/footer below. Hidden
// at the hex; room-views.js adds `.is-visible` while the views pane is on
// screen. .room-page's overflow:hidden doesn't clip it (it stays in bounds).
.room-views-strip {
display: none;
position: absolute;
bottom: 0.85rem;
left: 50%;
transform: translateX(-50%);
z-index: 140; // above the position strip (130) + the card mask
gap: 0.85rem;
pointer-events: auto;
&.is-visible { display: flex; }
.room-view-icon {
background: none;
border: none;
padding: 0.2rem;
cursor: pointer;
line-height: 1;
font-size: 1.15rem;
color: rgba(var(--secUser), 0.6);
transition: color 0.3s ease, text-shadow 0.3s ease, transform 0.3s ease;
// Active view's icon carries the --ninUser glow (+ --terUser shadow),
// half-again the size; the glow hands off as the carousel snaps.
&.is-active {
color: rgba(var(--ninUser), 1);
text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.9);
transform: scale(1.35);
}
}
}
// The gear menu's default pane (NVM/DEL/BYE) is `display:contents` so wrapping
// the existing controls in `.room-menu-default` (for the scroll-view swap)
// leaves their layout untouched. room-scroll.js toggles it to `none` while the
// Frame/Redact filter pane shows, and back to `contents` on the hex.
.room-menu-default {
display: contents;
}
.gate-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 100;
pointer-events: none;
}
.gate-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 120;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
pointer-events: none;
margin-top: 5rem;
}
.gate-modal {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
min-width: 26rem;
pointer-events: auto;
border: none;
background-color: transparent;
.gate-title-panel {
border: 0.1rem solid rgba(var(--terUser), 0.25);
border-radius: 0.5rem;
padding: 0.75rem;
background: rgba(var(--priUser), 1);
}
.gate-top-row {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
.gate-main-panel {
flex: 3;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
border: 0.1rem solid rgba(var(--terUser), 0.25);
border-radius: 0.5rem;
padding: 0.75rem;
background: rgba(var(--priUser), 1);
}
.gate-roles-panel {
flex: 1;
min-width: 5rem;
display: flex;
align-items: center;
justify-content: center;
border: 0.1rem solid rgba(var(--terUser), 0.25);
border-radius: 0.5rem;
padding: 0.75rem;
background: rgba(var(--priUser), 1);
.launch-game-btn { margin-top: 0; }
}
.gate-invite-panel {
display: flex;
flex-direction: column;
gap: 0.4rem;
border: 0.1rem solid rgba(var(--terUser), 0.25);
border-radius: 0.5rem;
padding: 0.75rem;
background: rgba(var(--priUser), 1);
}
.gate-header {
text-align: center;
h1 {
font-size: 2rem;
color: rgba(var(--secUser), 0.6);
margin-bottom: 1rem;
text-align: justify;
text-align-last: center;
text-justify: inter-character;
text-transform: uppercase;
text-shadow:
1px 1px 0 rgba(255, 255, 255, 0.125), // highlight (up-left)
var(--title-shadow-offset) var(--title-shadow-offset) 0 rgba(0, 0, 0, 0.8) // shadow (down-right)
;
span {
color: rgba(var(--quaUser), 0.6);
}
margin: 0 0 0.5rem;
}
.gate-status-wrap {
display: flex;
justify-content: center;
align-items: baseline;
opacity: 0.5;
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 0.15em;
.status-dots {
display: inline-flex;
span {
display: inline-block;
width: 0.5em;
text-align: center;
}
}
}
}
.token-slot {
position: relative;
display: flex;
flex-direction: row;
border: 2px solid rgba(var(--terUser), 0.7);
border-radius: 0.4rem;
background: rgba(0, 0, 0, 0.35);
min-width: 180px;
&.locked {
opacity: 0.3;
pointer-events: none;
}
&.ready {
border-color: rgba(var(--terUser), 1);
button.token-rails {
box-shadow:
0 0 0.6rem rgba(var(--terUser), 0.6),
0 0 1.6rem rgba(var(--terUser), 0.25)
;
.rail { background: rgba(var(--terUser), 1); }
}
}
&.pending,
&.claimed {
box-shadow:
0 0 0.6rem rgba(var(--terUser), 0.5),
0 0 1.4rem rgba(var(--terUser), 0.2),
;
.token-return-btn { text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.8); }
&:hover {
border-color: rgba(var(--terUser), 1);
background: rgba(0, 0, 0, 0.55);
box-shadow:
0 0 0.8rem rgba(var(--terUser), 0.75),
0 0 2rem rgba(var(--terUser), 0.35),
;
}
}
.token-rails,
button.token-rails {
display: flex;
flex-direction: row;
align-items: stretch;
padding: 0.6rem 0.45rem;
gap: 0.2rem;
border-right: 1px solid rgba(var(--terUser), 0.35);
.rail {
display: block;
width: 2px;
background: rgba(var(--terUser), 0.55);
border-radius: 1px;
}
}
button.token-rails {
background: transparent;
border: none;
outline: none;
border-right: 1px solid rgba(var(--terUser), 0.35);
cursor: pointer;
border-radius: 0.3rem 0 0 0.3rem;
&:hover {
background: rgba(var(--terUser), 0.1);
.rail { background: rgba(var(--terUser), 1); }
}
}
.token-return-btn {
position: absolute;
inset: 0;
background: transparent;
border: none;
outline: none;
cursor: pointer;
border-radius: inherit;
}
.token-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.45rem 0.75rem;
gap: 0.15rem;
.token-denomination {
font-size: 1.5em;
font-weight: bold;
color: rgba(var(--terUser), 1);
line-height: 1;
}
.token-insert-label,
.token-insert-btn {
&::before {
content: '';
}
font-size: 0.6em;
text-transform: uppercase;
letter-spacing: 0.08em;
text-align: center;
line-height: 1.3;
}
.token-return-label {
font-size: 0.55em;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.5;
line-height: 1.3;
text-align: center;
}
}
}
}
// Narrow viewport — scale down, 2×3 slot grid (portrait mobile + narrow desktop)
@media (max-width: 700px) {
// Floor the gatekeeper modal below the position-strip circles (~1.5rem top + 3rem height)
.gate-overlay {
padding-top: 5.5rem;
}
.gate-modal {
padding: 1.25rem 1.5rem;
.gate-header {
h1 { font-size: 1.5rem; }
}
.token-slot { min-width: 150px; }
}
}
// ─── Room shell layout ─────────────────────────────────────────────────────
.room-shell {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 2rem;
width: 100%;
height: 100%;
align-self: stretch;
}
// ─── Table hex + seat positions ────────────────────────────────────────────
//
// .table-hex: regular pointy-top hexagon.
// clip-path polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)
// on a 200×231 container gives equal-length sides (height = width × 2/√3).
//
// Seats use absolute positioning from the .room-table centre.
// Clockwise from top: slots 1→2→3→4→5→6.
$seat-r: 140px;
$seat-r-x: round($seat-r * 0.866); // 121px
$seat-r-y: round($seat-r * 0.5); // 70px
// Seat edge-midpoint geometry (pointy-top hex).
// 200×231 hex → apothem = 100px; $pos-d = 140 leaves 40px design-units of
// chair clearance radially. $pos-d-x / $pos-d-y are the x/y components for
// diagonal seats (cos/sin of 60° from horizontal).
$pos-d: 140px;
$pos-d-x: round($pos-d * 0.5); // 70px
$pos-d-y: round($pos-d * 0.866); // 121px
// ─── Position strip ────────────────────────────────────────────────────────
// Numbered gate-slot circles sit above the gate backdrop/overlay (z 130 > 120
// > 100) but below the role-select fan (z 200), tray (310), and menus (310+).
// .room-page is position:relative with no z-index, so its absolute children
// share the root stacking context with the fixed overlays.
// When the gate modal or role-select fan is open, suppress pointer events so
// the strip doesn't intercept clicks or hover effects on the modal beneath it
// (landscape: strip overlaps centered card fan too).
// Must target .gate-slot directly — it has an explicit pointer-events: auto
// override that wins over a rule on the parent .position-strip alone.
html:has(.gate-backdrop) .position-strip .gate-slot,
html:has(.role-select-backdrop) .position-strip .gate-slot { pointer-events: none; }
// Re-enable clicks on confirm/reject/drop-token forms inside slots
html:has(.gate-backdrop) .position-strip .gate-slot form,
html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: auto; }
// Occupied circles must stay HOVERABLE under the gatekeeper / role-select
// backdrop so their rich position tooltips fire. A filled/reserved circle has
// no click action of its own (only the OK/NVM/drop forms do, re-enabled
// above) — but mouseenter only reaches a pointer-events:none `.gate-slot` when
// something inside it IS hit-testable, so a filled circle lacking a button
// silently produced no tooltip on the initial gatekeeper. Empty circles stay
// suppressed (they carry no tooltip). (0,4,1) beats the (0,3,1) suppressor.
html:has(.gate-backdrop) .position-strip .gate-slot.filled,
html:has(.gate-backdrop) .position-strip .gate-slot.reserved,
html:has(.role-select-backdrop) .position-strip .gate-slot.filled,
html:has(.role-select-backdrop) .position-strip .gate-slot.reserved { pointer-events: auto; }
// The room-gate renewal modal renders its OWN .gate-backdrop, but its
// position circles are hover-only (tooltips) and must stay live — re-enable
// them. The doubled `.room-gate-page` makes this (0,4,1) so it UNAMBIGUOUSLY
// out-specifies the (0,3,1) suppressor above — not a fragile source-order tie
// that a future SCSS reorder could silently flip. [[feedback-scss-import-order-specificity]]
html .room-gate-page.room-gate-page .position-strip .gate-slot { pointer-events: auto; }
.position-strip {
position: absolute;
top: 1rem;
left: 0;
right: 0;
z-index: 130;
display: flex;
justify-content: center;
gap: round($gate-gap * 0.6);
pointer-events: none;
.gate-slot {
position: relative;
width: round($gate-node * 0.75);
height: round($gate-node * 0.75);
border-radius: 50%;
border: $gate-line solid rgba(var(--terUser), 0.5);
background: rgba(var(--priUser), 1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
pointer-events: auto;
font-size: 1.8rem;
transition: opacity 0.6s ease, transform 0.6s ease;
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--priUser), 0.12)
;
&.role-assigned {
opacity: 0;
transform: scale(0.5);
pointer-events: none;
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--terUser), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terUser), 0.12)
;
}
&.filled, &.reserved {
background: rgba(var(--terUser), 0.9);
border-color: rgba(var(--terUser), 1);
color: rgba(var(--priUser), 1);
}
&.filled:hover, &.reserved:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
}
.slot-number { font-size: 0.7em; opacity: 0.5; }
.slot-gamer { display: none; }
form {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
&:has(.drop-token-btn) {
background: rgba(var(--terUser), 1);
border-color: rgba(var(--ninUser), 0.5);
&:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
}
}
// Occupant-relative accents (sprint 2026-06-02). Additive over the
// .filled/.reserved fill above — border tint only, appended last so
// a single-class modifier wins on source order.
&.tt-pos-me-current { border-color: rgba(var(--ninUser), 1); }
&.tt-pos-me-also { border-color: rgba(var(--ninUser), 0.6); cursor: pointer; }
&.tt-pos-bud { border-color: rgba(var(--secUser), 1); }
// CARTE seat-switch — a full-circle anchor on the viewer's own
// non-current seats; ?seat=N loads that seat's view. Sits below any
// confirm/release form (later in DOM) so the NVM button still wins.
.pos-seat-switch {
position: absolute;
inset: 0;
border-radius: 50%;
}
}
}
@media (max-width: 700px) {
.position-strip {
gap: round($gate-gap * 0.3);
.gate-slot {
width: round($gate-node * 0.75);
height: round($gate-node * 0.75);
}
}
}
.room-table {
flex: 2;
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
// Fixed design-size scene; JS scales it to fill .room-table via transform: scale().
// Design dims: seat reach is ±140px H / ±121px V from centre + seat element size.
// scene H of 320 leaves vertical headroom at large landscape so the rem-scaled
// chair icons + labels don't clip the aperture top/bottom edges.
.room-table-scene {
width: 360px;
height: 320px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
transform-origin: center center;
}
// Hex border: clip-path clips CSS borders, so the ring is a wrapper with the
// same hex polygon at a slightly larger size. 0.25rem each side — subtle only.
.table-hex-border {
width: calc(200px + 0.5rem);
height: calc(231px + 0.5rem);
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
background: rgba(var(--quaUser), 1);
filter: drop-shadow(0 0 6px rgba(var(--quaUser), 0.5));
display: flex;
align-items: center;
justify-content: center;
}
.table-hex {
width: 200px;
height: 231px;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
// Six gradients — one per hex face — each perpendicular to that face so the
// shadows follow the hex geometry rather than the rectangular bounding box.
// CSS angle convention: 0°=up, 90°=right. Shadow goes FROM face INWARD.
// Left face → 90° Right face → 270°
// Top-left face → 150° Top-right face → 210°
// Bottom-left face → 30° Bottom-right face→ 330°
background:
linear-gradient(90deg, rgba(0, 0, 0, 0.2) 0%, transparent 15%),
linear-gradient(90deg, rgba(var(--quaUser), 0.1) 0%, transparent 15%),
linear-gradient(270deg, rgba(0, 0, 0, 0.2) 0%, transparent 15%),
linear-gradient(270deg, rgba(var(--quaUser), 0.1) 0%, transparent 15%),
linear-gradient(210deg, rgba(0, 0, 0, 0.2) 0%, transparent 15%),
linear-gradient(210deg, rgba(var(--quaUser), 0.1) 0%, transparent 15%),
linear-gradient(150deg, rgba(0, 0, 0, 0.2) 0%, transparent 15%),
linear-gradient(150deg, rgba(var(--quaUser), 0.1) 0%, transparent 15%),
linear-gradient(30deg, rgba(0, 0, 0, 0.2) 0%, transparent 15%),
linear-gradient(30deg, rgba(var(--quaUser), 0.1) 0%, transparent 15%),
linear-gradient(330deg, rgba(0, 0, 0, 0.2) 0%, transparent 15%),
linear-gradient(330deg, rgba(var(--quaUser), 0.1) 0%, transparent 15%),
rgba(var(--duoUser), 1);
display: flex;
align-items: center;
justify-content: center;
}
// Outside .room-table-scene so it isn't scaled by scaleTable().
// Positioned absolute so it floats over the hex without affecting flex layout.
#id_pick_sigs_wrap {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
.table-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
// "Gravity settling . . ." / "Levity appraising . . ." shown after a polarity
// group confirms their sigs while the other group is still selecting.
// Pulsing opacity signals active waiting without being jarring.
#id_hex_waiting_msg {
font-size: 0.7rem;
letter-spacing: 0.06em;
color: rgba(var(--terUser), 0.8);
text-align: center;
margin: 0.4rem 0 0;
animation: hex-wait-pulse 2.4s ease-in-out infinite;
}
@keyframes hex-wait-pulse {
0%, 100% { opacity: 0.75; }
50% { opacity: 0.3; }
}
// my-sea seat one-shot "just seated" flare — see `.table-seat.seat-just-seated`.
@keyframes my-sea-seat-flare {
0% { color: rgba(var(--terUser), 1); filter: drop-shadow(0 0 6px rgba(var(--ninUser), 1)); }
70% { color: rgba(var(--terUser), 1); filter: drop-shadow(0 0 6px rgba(var(--ninUser), 0.85)); }
100% { color: rgba(var(--secUser), 1); filter: none; }
}
.table-seat {
position: absolute;
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
column-gap: 0.25rem;
align-items: center;
transform: translate(-50%, -50%);
pointer-events: none;
// Edge midpoints, clockwise from 3 o'clock (slot drop order → role order)
&[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; }
&[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
&[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
&[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; }
&[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
&[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
// Chair: col 1, spans both rows
.fa-chair {
grid-column: 1;
grid-row: 1 / 3;
font-size: 1.6rem;
color: rgba(var(--secUser), 0.4);
transition: color 0.6s ease, filter 0.6s ease;
}
// Abbreviation: col 2, row 1
.seat-role-label {
grid-column: 2;
grid-row: 1;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.05em;
color: rgba(var(--secUser), 1);
}
// Status icon: col 2, row 2, centred under the abbreviation
.position-status-icon {
grid-column: 2;
grid-row: 2;
justify-self: center;
font-size: 0.8rem;
&.fa-ban { color: rgba(var(--priRd), 1); }
&.fa-circle-check { color: rgba(var(--priGn), 1); }
}
// Left-side positions: flip column order so chair is closest to the table
&[data-slot="3"], &[data-slot="4"], &[data-slot="5"] {
.fa-chair { grid-column: 2; }
.seat-role-label { grid-column: 1; }
.position-status-icon { grid-column: 1; }
}
&.active .fa-chair {
color: rgba(var(--terUser), 1);
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
}
// After role confirmed: chair settles to full-opacity --secUser (no glow)
&.role-confirmed .fa-chair {
color: rgba(var(--secUser), 1);
filter: none;
}
// ── my-sea "seated" occupancy (seat 1C owner, 2C visitor) ──────────────
// Steady state once a seat is taken: chair settles to full-opacity
// --secUser (mirrors .role-confirmed); the status icon is already
// .fa-circle-check (green) from the server / JS swap.
&.seated .fa-chair {
color: rgba(var(--secUser), 1);
filter: none;
}
// One-shot "just seated" flare (2s) played the FIRST time a viewer
// sees the occupancy (my-sea-seats.js adds/removes `.seat-just-seated`,
// localStorage-gated). Chair flares --terUser + a --ninUser glow, then
// eases back into the steady --secUser .seated look above (user-spec
// 2026-05-29, bumped from 1.5s). Mirrors the room's .active →
// .role-confirmed handoff.
&.seat-just-seated .fa-chair {
animation: my-sea-seat-flare 2s ease forwards;
}
// The viewer's own occupied seat on the multi-seat spectator hex — tint
// the position LABEL (2C…) --terUser so they can pick themselves out,
// WITHOUT recolouring the chair (which must rest at the steady --secUser
// seated look, not the flare colour). 2026-05-29.
&.table-seat--self .seat-position-label {
color: rgba(var(--terUser), 1);
}
.seat-portrait {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid rgba(var(--terUser), 1);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
opacity: 0.6;
}
.seat-label {
font-size: 0.65rem;
opacity: 0.5;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Arc of mini cards — visible only on the currently active seat
.seat-card-arc {
display: none;
position: absolute;
width: 18px;
height: 26px;
border-radius: 2px;
border: 1px solid rgba(var(--terUser), 0.7);
background: rgba(var(--quaUser), 0.9);
// Three fanned cards stacked behind the portrait
&::before,
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
border: inherit;
background: inherit;
}
&::before { transform: rotate(-18deg) translate(-4px, 2px); }
&::after { transform: rotate( 18deg) translate( 4px, 2px); }
}
&.active .seat-portrait {
opacity: 1;
border-color: rgba(var(--secUser), 1);
box-shadow: 0 0 0.5rem rgba(var(--ninUser), 0.5);
}
&.active .seat-card-arc {
display: block;
transform: translateY(-28px); // float above the portrait
}
}
// ─── Card stack ────────────────────────────────────────────────────────────
.card-stack {
width: 90px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 2px solid rgba(var(--quiUser), 1);
background: rgba(var(--terUser), 1);
cursor: default;
transition: box-shadow 0.2s ease;
position: relative;
&::before {
content: "ROLE";
font-size: 0.6rem;
letter-spacing: 0.14em;
color: rgba(var(--quiUser), 1);
}
.fa-ban {
position: absolute;
font-size: 1.4rem;
}
&[data-state="eligible"] {
cursor: pointer;
border: 2px solid rgba(var(--quiUser), 1);
box-shadow:
0 0 0.6rem rgba(var(--ninUser), 1),
0 0 1.6rem rgba(var(--secUser), 0.25);
}
&[data-state="ineligible"] {
opacity: 0.4;
cursor: not-allowed;
}
}
// ─── Card dimensions ───────────────────────────────────────────────────────
// Base size matches the card-stack footprint; --table-scale (set by scaleTable()
// in room.js) stretches both the grid and individual cards to stay in sync with
// the scene transform. Fallback of 1 keeps the fan functional if JS hasn't run.
$card-w: 90px;
$card-h: 60px;
// ─── No-deck warning overlay ──────────────────────────────────────────────
.role-no-deck-warning {
position: fixed;
z-index: 10000;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
max-width: 11rem;
border-radius: 0.5rem;
background-color: rgba(var(--tooltip-bg), 0.75);
backdrop-filter: blur(6px);
border: 0.1rem solid rgba(var(--secUser), 0.4);
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
p {
font-size: 0.75rem;
color: rgba(var(--secUser), 0.9);
text-align: center;
margin: 0;
}
.guard-actions {
display: flex;
gap: 0.5rem;
}
}
// ─── Role select modal ─────────────────────────────────────────────────────
.role-select-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
cursor: pointer;
}
#id_role_select {
// Always a 3×2 grid — 6 landscape cards in a row would overflow any viewport.
display: grid;
grid-template-columns: repeat(3, calc(#{$card-w} * var(--table-scale, 1)));
gap: 1rem;
pointer-events: none;
}
// ─── Card component ────────────────────────────────────────────────────────
.card {
width: calc(#{$card-w} * var(--table-scale, 1));
height: calc(#{$card-h} * var(--table-scale, 1));
border-radius: 6px;
cursor: pointer;
pointer-events: auto;
position: relative;
perspective: 600px;
.card-back,
.card-front {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: inherit;
border: 2px solid rgba(var(--terUser), 1);
background: rgba(var(--quiUser), 1);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
transition: transform 0.35s ease;
}
.card-back {
transform: rotateY(0deg);
font-size: calc(0.66rem * var(--table-scale, 1));
letter-spacing: 0.14em;
color: rgba(var(--quiUser), 1);
background: rgba(var(--terUser), 1);
border: 2px solid rgba(var(--quiUser), 1);
}
.card-front {
transform: rotateY(180deg);
padding: 0.5rem;
text-align: center;
.card-role-name {
font-size: calc(0.66rem * var(--table-scale, 1));
color: rgba(var(--quaUser), 1);
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
&.flipped,
&.face-up {
.card-back { transform: rotateY(-180deg); }
.card-front { transform: rotateY(0deg); }
}
}
// Landscape mobile — aggressively scale down to fit short viewport
@media (orientation: landscape) {
// Sink navbar + footer sidebar below any modal backdrop when open.
// Landscape navbar and footer sidebar are both z-index:100 (_base.scss).
// Gate/role-select/sig backdrops are also z-index:100 — DOM paint-order ties
// let the footer (later in DOM) bleed through. Drop both to 50.
html:has(.gate-backdrop) body .container .navbar,
html:has(.role-select-backdrop) body .container .navbar,
html:has(.sig-backdrop) body .container .navbar {
z-index: 50;
}
html:has(.gate-backdrop) body #id_footer,
html:has(.role-select-backdrop) body #id_footer,
html:has(.sig-backdrop) body #id_footer {
z-index: 50;
}
// Position strip: horizontal row across the top, slots 1-6 in order.
// Offset from both sidebars (5rem each) and centred with gap.
.position-strip {
flex-direction: row;
top: 2.5rem;
left: 5rem;
right: 5rem;
justify-content: center;
gap: round($gate-gap * 0.4);
}
// Small landscape (phones ≤550px tall): strip stays horizontal — no two-column
// trick needed now that the h2 is in the gutter. Just clear any order overrides.
@media (max-height: 550px) {
.position-strip {
.gate-slot { order: 0; }
top: 1rem;
}
}
}
// ─── Seat tray — see _tray.scss ─────────────────────────────────────────────