Files
python-tdd/src/static_src/scss/_room.scss
Disco DeDisco c037e876e2 Sea Select: rebuild as a felt + Gaussian spread modal, unify w. my_sea — TDD
Hollow out the gameroom DRAW SEA dark modal into the two surfaces my_sea
uses: a --duoUser felt (.sea-page--room) filling the hex pane where the
Celtic Cross deals, + a Gaussian spread modal (#id_sea_spread_modal: the
.sea-select combobox w. the two Celtic Cross 6-card opts ONLY, AUTO DRAW/DEL,
a mini preview, + a corner #id_sea_cancel NVM). Opened by DRAW SEA
(html.sea-open); the room gear's NVM (room-menu-sea) returns to the hex;
#id_text_btn + #id_sky_btn go inert while the felt is open.

- persist: epic:sea_save / sea_delete upsert the seat's Character.celtic_cross
  (none of my_sea's MySeaDraw quota/Brief machinery); room ctx adds
  saved_by_position + saved_sea_spread + sea_default_spread + hand_complete so
  a reload re-renders the filled cross. celtic_cross field already existed (no
  migration)
- mini spread preview (_sea_spread_preview.html) in BOTH the gameroom + my_sea
  modals — shape only, NEVER dealt to: SeaDeal scopes its slot queries to
  .sea-cross:not(.sea-cross--preview)
- always TWO deck stacks (Gravity + Levity) in the room Sea Select — the gamer
  draws from either populated half (sea_deck split), even a CARTE monodeck;
  unlike my_sea / Sig Select's polarization collapse
- glow coordination: the sky-saved glow is muted in the sky/sea phases
  (html.sky-open / sea-open / sea-entered); sea glow color --priYl -> --priId
  (distinct from sky's --priTk); the sea glow-machine fires at hand-COMPLETION
  (mirrors Sky Select), not during drawing
- guard copy "Auto deal cards?" -> "Auto Draw cards?" (match the AUTO DRAW btn)
- fix: drop the stale `html.sea-open #id_aperture_fill { opacity:1 }` — it
  painted the opaque z-90 fill over the z-5 felt so the spread flashed then
  vanished (same trap as the CAST SKY felt); removed the dead .sea-backdrop /
  .sea-overlay / .sea-modal-* SCSS
- tests: epic PickSeaPersistTest (7) + PickSeaUnifiedFeltTest (6) ITs; SeaDeal
  preview-scoping + BurgerSpec sky-glow-mute Jasmine specs; my_sea sig-card
  ITs scoped to .my-sea-cross (the preview adds a 2nd .sea-sig-card)

[[feedback-felt-aperture-fill-covers-felt]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:42:24 -04:00

1388 lines
48 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 {
opacity: 1;
}
// NB: `html.sea-open #id_aperture_fill { opacity: 1 }` was DROPPED in the felt
// rebuild (2026-06-07). The DRAW SEA felt now sits at z-5 INSIDE the hex pane
// (.sea-page--room), BELOW the z-90 fill — lighting the fill painted an opaque
// green sheet OVER the felt + spread (the 0.15s opacity transition is why the
// spread "flashed then vanished"). Same trap + fix as the CAST SKY felt.
// [[feedback-felt-aperture-fill-covers-felt]]
// ─── 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
}
}
// While the CAST SKY felt is summoned (html.sky-open) the outer aperture must
// NOT scroll down to the reelhouse carousel (ATLAS/SCROLL/YARN/POST/PULSE) —
// the felt's OWN form↔wheel scroll-snap (.sky-page--room) is the only scroll
// in play. Pin the aperture to the hex pane (which the felt fills); restored
// the instant the felt closes (sky-open removed). overflow:hidden alone leaves
// the aperture parked on whichever pane it was showing — and CAST SKY is only
// reachable from the hex pane, so it pins to the hex every time.
html.sky-open .room-aperture.is-scrollable {
overflow-y: hidden;
scroll-snap-type: none;
}
// Same pin while the DRAW SEA felt is summoned (html.sea-open) — the felt fills
// the hex pane + the reelhouse below must stay out of reach.
html.sea-open .room-aperture.is-scrollable {
overflow-y: hidden;
scroll-snap-type: none;
}
.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 {
// New Post applet treatment (mirrors #id_applet_new_post in _applets.scss):
// a --duoUser green-felt card. The rotated room-name `> h2` gets the SAME
// green-tinted strip as the billboard New Post title — the felt shows
// through (see the `> h2` note). POST-scoped, so the SCROLL/ATLAS/YARN/PULSE
// cards keep their plain dark %applet-box wash.
.applet-scroll {
background-color: rgba(var(--duoUser), 1);
> h2 {
// Match the New Post title strip exactly: three translucent
// 0,0,0/0.125 layers and NO opaque --priUser base, so the --duoUser
// felt shows THROUGH for the same green-tinted "partial mask". (On
// the billboard #id_applet_new_post lands here by accident — its
// opaque base is out-specified by %applet-box > h2's background-color;
// here we do it on purpose.) bg-color 0.125 + two gradients = 3 layers.
background-color: rgba(0, 0, 0, 0.125);
background-image:
linear-gradient(rgba(0, 0, 0, 0.125), rgba(0, 0, 0, 0.125)),
linear-gradient(rgba(0, 0, 0, 0.125), rgba(0, 0, 0, 0.125));
}
}
#id_post_table {
list-style: none;
margin: 0;
padding: 0; // pills span the full card content width (= composer row)
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: flex-end; // bottom-anchor short threads
// Each line is styled to LOOK LIKE the "Enter a post line" composer
// input below it (mirrors .form-control, _base.scss): a --priUser fill +
// a 0.1rem --secUser border at the same border-radius, full width, an
// up/down margin and content-driven (dynamic) height — so the thread
// reads as a stack of input-style pills on the --duoUser felt.
.post-line {
display: grid;
grid-template-columns: minmax(4rem, auto) 1fr minmax(3rem, auto);
align-items: baseline;
gap: 0.5rem;
width: 100%;
background-color: rgba(var(--priUser), 0.8);
border: 0.1rem solid rgba(var(--secUser), 0.5);
border-radius: calc((1rem + 1em) / 3); // == .form-control radius
margin: 0.35rem 0;
padding: 0.5rem 0.75rem;
box-shadow: 1px 1px 0.125rem 1px rgba(0, 0, 0, 0.6);
.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 {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.3rem 0;
font-size: 0.9rem;
border-inline-start: 2px solid transparent;
padding-inline-start: 0.5rem;
.atlas-row-who { font-weight: bold; color: rgba(var(--quaUser), 1); flex-shrink: 0; }
.atlas-row-body {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
// Struck (retracted/redacted) provenance rows read the same
// strikethrough they do in SCROLL (.drama-event-body.struck).
&.struck { text-decoration: line-through; opacity: 0.5; }
}
// The merged rows carry the ORIGINAL <time> from their source row
// (.drama-event-time from SCROLL, .post-line-time from POST). Those
// source rules are scoped to the feed/thread, so restate the shared
// small / dim / right-aligned look here so each timestamp reads the
// same as it does in the view it came from.
.drama-event-time,
.post-line-time {
flex-shrink: 0;
margin-inline-start: auto;
font-size: 0.75rem;
opacity: 0.5;
text-align: right;
white-space: nowrap;
}
}
// 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;
}
// ── Per-view gear states (room-views.js) ──────────────────────────────────
// On YARN/POST/PULSE the reelhouse gear has no menu yet → dimmed; an active
// click flashes a --priRd fa-ban (same cadence/colour as the burger inactive
// sub-btns) instead of opening anything.
.gear-btn.gear-disabled { opacity: 0.6; }
.gear-btn.gear-flash-ban {
color: rgba(var(--priRd), 1);
box-shadow:
0 0 0.5rem 0.1rem rgba(var(--priRd), 0.75),
0 0 1.2rem 0.3rem rgba(var(--priRd), 0.35);
// Swap the gear glyph for fa-ban (FA solid \f05e) for the duration of the
// flash — the "nothing here" signal, mirroring the burger fan.
.fa-gear::before { content: "\f05e"; }
}
// ── ATLAS gear: source checkboxes ─────────────────────────────────────────
// Custom boxes so the disabled (no-model-yet) sources can show an ✗ that reads
// like the enabled ✓ — same box, different mark — with a struck, dimmed label.
// Labels stay lowercase (capslock is reel-only).
.room-menu-atlas {
.atlas-source-form { display: flex; flex-direction: column; gap: 0.3rem; }
.atlas-source {
display: flex;
align-items: center;
gap: 0.4rem;
text-transform: none;
cursor: pointer;
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
margin: 0;
width: 1em;
height: 1em;
flex-shrink: 0;
border: 0.1rem solid rgba(var(--terUser), 0.7);
border-radius: 0.15rem;
display: inline-grid;
place-content: center;
cursor: pointer;
line-height: 1;
&::before { content: ""; font-size: 0.8em; }
&:checked::before { content: ""; color: rgba(var(--terUser), 1); }
}
// Disabled source (yarn/pulse — no backing model): dim + struck NAME
// (not the box, so the ✗ stays legible) + an ✗ in the box.
&:has(input:disabled) {
opacity: 0.55;
cursor: default;
input[type="checkbox"] {
cursor: default;
border-color: rgba(var(--secUser), 0.7);
&::before { content: ""; color: rgba(var(--secUser), 1); }
}
.atlas-source-name { text-decoration: line-through; }
}
}
}
.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;
}
// Hex phase-button stack — CAST SKY + DRAW SEA share ONE grid cell so they can
// cross-fade IN PLACE during the post-save cascade (the sky-overlay JS toggles
// .hex-phase-btn--out). The grid sizes to the larger btn; .table-center centers
// it. Only the SKY_SELECT phase renders both; other phases use a lone button.
.hex-phase-stack {
display: grid;
justify-items: center;
align-items: center;
}
.hex-phase-stack > .hex-phase-btn {
grid-area: 1 / 1; // stack both buttons in the same cell
transition: opacity 0.45s ease, transform 0.45s ease;
}
// Eased-out state — invisible + inert, but still in layout so the partner btn
// can cross-fade in (display:none can't transition).
.hex-phase-btn--out {
opacity: 0;
transform: scale(0.7);
pointer-events: none;
}
// "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 ─────────────────────────────────────────────