Files
python-tdd/src/static_src/scss/_gameboard.scss
Disco DeDisco 6fbeed78d8 burger Sea sub-btn: spread-form modal + relocated deck stacks + mid-draw CONT DRAW escape — TDD (phase 2/3)
Second slice of the Sea sub-btn rollout (phase 1 = .active wiring in 3ae85b9; phase 3 = --priYl glow handoff to come).

## Spread modal (#id_sea_spread_modal)

`templates/apps/gameboard/my_sea.html` — `.sea-form-col` (spread combobox + AUTO DRAW + DEL btns) now lives inside `<div id="id_sea_spread_modal" class="my-sea-spread-modal" hidden>`. Hidden by default; opens on #id_sea_btn click (per the inline `<script>` block). The deck stacks (`.sea-stacks` + `.sea-stacks-label`) are extracted to a new `.my-sea-stacks-wrap` sibling that stays visible on the page so the FLIP affordance remains usable while the modal is closed.

## Modal SCSS

`static_src/scss/_gameboard.scss`:
- `.my-sea-spread-modal` — position:fixed inset:0; z-index:320 (above all corner btns); pointer-events:none w. children opting back in. `&[hidden]` makes it explicit that the closed state stays display:none.
- `.my-sea-spread-modal__backdrop` — full-viewport semi-transparent w. backdrop-filter blur; pointer-events:auto so click-outside closes.
- `.my-sea-spread-modal__panel` — opaque card, border + shadow, max-width:90vw. `.sea-form-col` inside drops its fixed 16rem width + uses min-width.
- `.my-sea-stacks-wrap` — position:absolute bottom:4.5rem right:1rem (clear of bud/burger); z-index:5 (above .my-sea-cross, below modal).

## Modal JS

`templates/apps/gameboard/my_sea.html` — new inline `<script>` at the bottom owns:
- `#id_sea_btn` click → opens modal (guarded on `.active` so the burger-btn.js inactive-flash handler still runs for inactive sub-btns).
- Escape key → close.
- Backdrop click → close.
- `#id_guard_portal .guard-yes` click (delegated) → close. This is the key UX detail user-spec'd: AUTO DRAW + DEL both open a guard portal that positions itself against the visible action/del btn — closing the modal on the btn click itself would dump the portal at (0,0). Closing on guard OK instead means the portal positions correctly + the modal closes only when the action is confirmed. NVM (`.guard-no`) leaves the modal up so the user can retry.

Also `apps/epic/static/apps/epic/burger-btn.js` — delegated fan click now closes the burger fan when an ACTIVE sub-btn is clicked (was: no-op for active). The per-page sub-btn handler runs in target phase BEFORE the fan-bubble handler, so the action kicks off w. a clean visual.

Client-side `.active` sync: server renders sea_btn inactive on the landing page (show_picker=False there). The DRAW SEA → picker transition is client-side (no re-render), so the IIFE in my_sea.html now flips `seaBtn.classList.add('active')` at the same SEAT_ANIM_MS moment data-phase swaps to 'picker'. Sea_btn stays `.active` for the rest of the picker phase, INCLUDING after hand_complete — DEL + GATE VIEW both live inside the modal + the user needs them accessible (earlier iteration deactivated on hand_complete; reverted on user feedback).

## Mid-draw NVM → CONT DRAW (apps/gameboard/views.py + my_sea.html)

Earlier iteration's gear-menu NVM nav-backed to `/gameboard/my-sea/` mid-draw, but the server's `hand_non_empty` branch re-rendered the picker on the next GET — looping the user right back into the spread. New `?phase=landing` escape hatch:

- `views.py`: `force_landing = request.GET.get('phase') == 'landing'`; `show_picker = (hand_non_empty or (phase_param and show_paid_draw)) and not force_landing`. New context var `show_cont_draw = force_landing and active_draw and not active_draw.is_hand_complete`.
- `my_sea.html` landing: new `{% if show_cont_draw %} CONT DRAW {% elif show_paid_draw %} ... ` branch on the table-center action-btn. CONT DRAW is an `<a href="{% url 'my_sea' %}">` (plain navigation, no `?phase=landing` so the next GET falls through to the hand_non_empty picker branch).
- `my_sea.html` gear include: picker phase passes `nvm_url={% url 'my_sea' %}?phase=landing` so the gear NVM exits to the landing-with-CONT-DRAW state instead of looping back into the picker.

## Tests

`apps/gameboard/tests/integrated/test_views.py` — 4 ITs added/updated:
- `test_sea_btn_stays_active_when_hand_complete` (replaced the prior "returns to inactive" assertion — sea_btn now stays active per user spec).
- `test_gear_nvm_navs_to_my_sea_landing_on_picker_phase` (updated URL to include `?phase=landing`).
- `test_force_landing_renders_cont_draw_btn_mid_draw` (NEW) — verifies CONT DRAW renders + FREE DRAW absent.
- `test_force_landing_hides_cont_draw_when_no_active_draw` (NEW) — regression guard: sig'd user w. no draw still sees FREE DRAW.
- `test_force_landing_hides_cont_draw_when_hand_complete` (NEW) — CONT DRAW absent for completed hands.

`functional_tests/test_game_my_sea.py` — new module-level `_open_spread_modal(test)` helper + applied to 7 tests that touch in-modal selectors (combobox / action_btn / del):
- `MySeaSpreadFormTest.test_picking_spread_swaps_data_spread_and_position_visibility`
- `MySeaSpreadFormTest.test_per_spread_position_labels_render_and_update`
- `MySeaCardDrawTest.test_action_btn_transitions_to_gate_view_on_hand_complete` (also: close modal before manual FLIP draws, use innerText for hidden-element text checks)
- `MySeaCardDrawTest.test_auto_drawn_slots_can_reopen_stage_modal_on_click`
- `MySeaCardDrawTest.test_form_col_renders_decks_lock_hand_del_and_reversal_pct`
- `MySeaLockHandTest.test_del_click_opens_shared_guard_portal`
- `MySeaLockHandTest.test_del_confirm_clears_hand_and_returns_to_gate_view_landing`

JS .click() is used inside `_open_spread_modal` (not Selenium .click) because the fan sub-btns spend ~0.25s mid-animation stacked at the burger centre — Selenium would hit a click-intercept by whichever sub-btn is z-topmost during the transform.

## Verification

All 1370 IT+UT green (+5 new MySeaViewTest, +1 from earlier phase 1 carry-over). 7 updated FTs green when re-run together.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:15:30 -04:00

1061 lines
42 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.

// Aperture foundation (html/body/.container overflow + flex-column) lives
// universally in _base.scss. Gameboard's only divergence: `overflow: clip`
// on .container instead of `hidden` — `clip` prevents the seat tooltip
// scroll-anchoring quirk Firefox triggers under overflow:hidden. The
// `.row { margin-bottom: -1rem }` pull mirrors the billboard/dashboard
// h2-row tightening.
body.page-gameboard {
.container {
overflow: clip;
}
.row {
margin-bottom: -1rem;
}
}
.gameboard-page {
flex: 1;
min-width: 425px;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
@media (max-width: 550px) {
.gameboard-page {
min-width: 0;
overflow: hidden;
}
}
@media (min-width: 738px) {
.gameboard-page {
min-width: 666px;
}
body.page-gameboard .container {
overflow: visible;
}
}
@media (orientation: landscape) {
// Restore clip in landscape — overrides the >738px overflow:visible above,
// preventing the gameboard applets from bleeding into the footer sidebar.
body.page-gameboard .container {
overflow: clip;
}
// Reset the 666px min-width so gameboard-page shrinks to fit within the
// sidebar-bounded container rather than overflowing into the footer sidebar.
.gameboard-page {
min-width: 0;
}
}
#id_applet_game_kit {
display: flex;
flex-direction: column;
#id_game_kit {
flex: 1;
position: relative;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-evenly;
overflow-x: visible;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
.token { position: static; }
.token:hover .token-tooltip,
.token:hover .tt { display: none; } // JS portal handles show/hide
.token,
.kit-item { font-size: 1.5rem; }
.kit-item { opacity: 0.6; }
}
}
// Sprint A.4 — card-deck stack icon (.deck-stack-icon) replaces the
// fa-regular fa-id-badge wherever a deck appears in icon form: gameboard's
// .token.deck-variant, kit-bag dialog's .kit-bag-deck, future room.html pile
// + deck-bag. Lifted out of the #id_applet_game_kit nest so the base sizing
// + rest-state SCSS applies in any deck-icon context. 3 stacked card-back
// rects, 5° CW rest tilt; see [[project-card-deck-icon]] for the design rules.
// When the deck has card-images, the rect fills are overridden inline w. an
// SVG <pattern> referencing the deck's <deck-slug>-back.png; otherwise the
// placeholder `fill: rgba(--priUser, 1)` shows through.
.deck-stack-icon {
display: inline-block;
// 2026-05-25 PM user spec: 1.5× the prior fa-id-badge visual weight
// since the icon is no longer constrained to placeholder-icon dimensions
// (now a meaningful first-class deck visualization).
width: 2.25rem; // 1.5rem × 1.5
height: 3.6rem; // 1.5× while preserving 5:8 tarot card aspect
color: rgba(var(--terUser), 1); // stroke color via currentColor
overflow: visible; // fan-out exceeds the viewBox bounds
filter: drop-shadow(0.08rem 0.08rem 0.15rem rgba(0, 0, 0, 0.6));
.deck-stack-icon__stack {
transform: rotate(5deg);
transform-origin: 50% 50%;
transform-box: fill-box;
transition: transform 0.25s ease;
}
.deck-stack-icon__card {
fill: rgba(var(--priUser), 1);
stroke: currentColor;
stroke-width: 1;
transform-origin: 50% 50%;
transform-box: fill-box;
transition: transform 0.25s ease;
}
// Rest: tightly stacked w. tiny vertical offsets (suggests stack
// depth without separating the cards visually).
.deck-stack-icon__card--1 { transform: translateY(-0.4px); }
.deck-stack-icon__card--3 { transform: translateY( 0.4px); }
}
// Sprint A.4 — fan-out trigger is wrapper-only (NOT self-triggered on the
// SVG itself) so placeholder-mode icons inside .kit-bag-placeholder stay
// static + dim like the empty dice slot. Real-deck wrappers (.token.deck-
// variant on gameboard, .kit-bag-deck in kit-bag dialog, future room.html
// pile + deck-bag) drive the splay; cards 2 + 3 fan out from under card 1,
// card 1 stays put. Tooltip portal is wired to the same `.token:hover` /
// `.kit-bag-deck:hover` triggers via JS so splay + tooltip-appearance
// co-activate.
.token.deck-variant:hover .deck-stack-icon,
.token.deck-variant:active .deck-stack-icon,
.token.deck-variant:focus .deck-stack-icon,
.kit-bag-deck:hover .deck-stack-icon,
.kit-bag-deck:active .deck-stack-icon,
.kit-bag-deck:focus .deck-stack-icon {
.deck-stack-icon__card--2 { transform: translate(-5px, -2px) rotate(-12deg); }
.deck-stack-icon__card--3 { transform: translate( 5px, -2px) rotate( 12deg); }
}
// Sprint A.4 — placeholder-mode dim styling. When the icon is inside a
// .kit-bag-placeholder (no deck equipped) or game_kit applet's empty-state
// .kit-item (no decks unlocked), color + fill drop to `--quaUser` at 0.3
// alpha to match the existing empty-slot treatment (`.kit-bag-placeholder`
// at `_game-kit.scss:143`, `.kit-item { opacity: 0.6 }` here). No animation
// since the wrapper isn't in the splay-trigger list above.
.kit-bag-placeholder .deck-stack-icon,
.kit-item .deck-stack-icon {
color: rgba(var(--quaUser), 0.15);
.deck-stack-icon__card { fill: rgba(var(--quiUser), 0.15); }
}
#id_applet_new_game {
display: flex;
flex-direction: column;
}
#id_applet_my_games {
display: flex;
flex-direction: column;
.applet-list {
flex: 1;
padding-top: 0.25rem;
}
}
#id_tooltip_portal {
position: fixed;
z-index: 9999;
padding: 0.75rem 1.5rem;
@extend %tt-token-fields;
.tt-equip-btns {
position: absolute;
left: -1rem;
top: -1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
z-index: 1;
.btn { margin: 0; }
}
// Tray sig-card tooltip (Phase 2) — PRV / NXT btns pinned to the bottom
// corners of the portal, 1rem outside the panel so the btn centres land
// exactly on the corners. The shared @stat-block-shared mixin in
// _card-deck.scss already does this for fan / sig / sea contexts; the
// portal isn't covered by that mixin so we re-state the rules here.
.fyi-prev,
.fyi-next {
display: inline-flex;
position: absolute;
bottom: -1rem;
margin: 0;
z-index: 70;
}
.fyi-prev { left: -1rem; }
.fyi-next { right: -1rem; }
&.active { display: block; }
}
#id_mini_tooltip_portal {
position: fixed;
z-index: 9999;
// Polish-8 — bumped from font-size 0.8em → 0.95em + added padding to
// give the mini-portal a bit more presence per user-spec "a bit bigger
// both in dimensions and font-size". Visually closer to the main
// tooltip's text scale w/o approaching it — still clearly subordinate.
font-size: 0.95em;
font-style: italic;
padding: 0.35rem 0.75rem;
border-radius: 0.3rem;
width: fit-content;
white-space: nowrap;
text-align: right;
&.active { display: block; }
}
@media (max-height: 500px) {
body.page-gameboard {
.container {
.row {
padding: 0.25rem 0;
.col-lg-6 h2 {
margin-bottom: 0.5rem;
}
}
}
}
}
// ─── My Sea sign-gate ────────────────────────────────────────────────────────
// REMOVED 2026-05-22 — refactored to a Brief banner. The no-sig nudge now
// fires via `Brief.showBanner` from `_my_sea_sign_gate_brief.html`, which
// portals a `.note-banner.my-sea-sign-gate-brief` to the page h2 (gaussian-
// glass shell, FYI → /billboard/my-sign/, NVM dismisses). All the inline
// `.my-sea-sign-gate{,--applet,__line,__actions,__back,__fyi}` styling
// dropped — `.note-banner` rules in `_note.scss:11` cover positioning,
// shell, + button placement DRYly.
// ─── My Sea DRAW SEA landing ─────────────────────────────────────────────────
// Sprint 5 iter 1 of [[project-my-sea-roadmap]]. When a user has a saved
// significator (gate passed), /gameboard/my-sea/ renders this landing
// screen: DRY table hex w. 6 chair seats labeled 1C-6C + central DRAW
// SEA btn. Mirrors my-sign's `.my-sign-page` + `.my-sign-landing`
// structure — same room-shell chain so room.js's scaleTable() can size
// the hex; same flex setup so the container chain propagates real
// height down for the scale calc.
.my-sea-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
.my-sea-landing {
flex: 1;
min-height: 0;
display: flex;
// FREE DRAW btn — centered in the hex, mirrors SCAN SIGN's 2-line
// font sizing so "FREE/DRAW" sits cleanly inside the 4rem circle.
#id_draw_sea_btn {
white-space: normal;
}
// Chair-position labels (1C-6C). Mirrors the room's `.seat-role-
// label` grid placement (col 2, row 1 by default; flips to col 1
// for left-side seats 3/4/5 so the label sits closest to the hex)
// but uses a role-free class name — my-sea is the solo draw flow,
// no role-pick phase, so the room's role-grammar doesn't apply.
.table-seat .seat-position-label {
grid-column: 2;
grid-row: 1;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.05em;
color: rgba(var(--secUser), 1);
}
.table-seat[data-slot="3"] .seat-position-label,
.table-seat[data-slot="4"] .seat-position-label,
.table-seat[data-slot="5"] .seat-position-label {
grid-column: 1;
}
// Seated chair (post-FREE DRAW). Visual transition mirrors
// `.table-seat.active .fa-chair` from _room.scss line 626 —
// --terUser color + --ninUser drop-shadow glow — but uses a stable
// `.seated` class (semantically distinct from `.active`: active =
// current turn in a multi-user room; seated = draw-locked occupant
// in this solo-flow). _room.scss line 596 makes the colour change
// a 0.6s ease transition so the chair animates rather than snaps.
// Status icon (.position-status-icon) colour swap fa-ban red →
// fa-circle-check green is handled by _room.scss lines 615-616.
.table-seat.seated .fa-chair {
color: rgba(var(--terUser), 1);
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
}
}
// Picker phase bg — `--duoUser` matches the table hex's interior so
// the landing→picker swap reads as a continuous surface (parallels
// `.my-sign-page[data-phase="picker"]` in _card-deck.scss line 704).
.my-sea-page[data-phase="picker"] {
background: rgba(var(--duoUser), 1);
}
// Landing phase bg — explicit `--priUser` revert per user spec
// (2026-05-20). The hex INTERIOR is `--duoUser` (set on `.table-hex`
// in _room.scss); the aperture AROUND the hex should be the default
// body color. Defensive override so any bf-cache / stale-CSS state
// can't leak the picker-phase green bg onto a landing render.
.my-sea-page[data-phase="landing"] {
background: rgba(var(--priUser), 1);
}
// Sprint 6 iter 6a — gatekeeper page bg + modal chrome. The page bg
// is uniform `--duoUser` (matches the hex interior on landing /
// picker so the visual transitions read as a continuous surface);
// the `.gate-overlay`/`.gate-modal` rules in `_room.scss` already
// give us the darkened Gaussian-glass modal centered over it. No hex
// or chair-seats on this page — the gatekeeper is a transient in-
// flight UI per user spec 2026-05-20.
.my-sea-page[data-phase="gate"] {
background: rgba(var(--duoUser), 1);
flex: 1;
min-height: 0;
}
.my-sea-picker {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
// Portrait — stack the cross spread above the form col (mirrors the
// gameroom SEA SELECT modal's `@media (max-width: 600px)` stack
// pattern in `_card-deck.scss`). Landscape keeps the side-by-side
// layout since horizontal real-estate is the abundant axis there.
// User-spec 2026-05-20.
@media (orientation: portrait) {
flex-direction: column;
}
}
// .my-sea-cross renders all 6 surrounding positions (crown/leave/lay/
// loom + cover/cross overlaid on core) unconditionally. The SPREAD
// dropdown sets `data-spread="<name>"` on this element; per-spread
// rules below hide the positions each spread doesn't use. Inherits
// the 3×3 `grid-template-areas` from _card-deck.scss line 1189-1200
// so visible cells land in their canonical positions; hidden cells
// just leave their grid slots empty.
//
// Per-spread position subsets — user-locked 2026-05-19:
// PPF: leave (1) cover (2) loom (3) — horizontal middle row
// SAO: lay (1) cover (2) crown (3) — vertical center column
// MBS: crown (1) lay (2) loom (3) — T-shape (crown + lay vertical, loom right)
// DOS: loom (1) cross (2) crown (3) — loom right · cross overlay · crown above
// CC variants: all 6 positions (Waite-Smith / Escape Velocity differ in DRAW ORDER only,
// not in position visibility).
// Bump grid gap on my-sea (gameroom .sea-cross stays at 0.5rem since
// gameroom slots have no per-position labels). The vertical leave/loom
// labels need ~1.5rem of horizontal clearance from adjacent cells, and
// the horizontal crown/cover/lay/cross labels need ~1rem of vertical
// clearance so they don't overlap into the next row.
.my-sea-cross {
gap: 1rem !important;
}
.my-sea-cross[data-spread="past-present-future"] {
.sea-pos-crown,
.sea-pos-cross,
.sea-pos-lay { display: none; }
}
.my-sea-cross[data-spread="situation-action-outcome"] {
.sea-pos-leave,
.sea-pos-loom,
.sea-pos-cross { display: none; }
}
.my-sea-cross[data-spread="mind-body-spirit"] {
.sea-pos-leave,
.sea-pos-cover,
.sea-pos-cross { display: none; }
}
.my-sea-cross[data-spread="desire-obstacle-solution"] {
.sea-pos-leave,
.sea-pos-cover,
.sea-pos-lay { display: none; }
}
// Celtic Cross variants (waite-smith / escape-velocity) — all positions
// visible by default. No `display: none` overrides needed.
// Position-name caption — re-appropriates the GRAVITY/LEVITY
// `.sea-stack-name` typographic look (_card-deck.scss line 1557):
// small uppercase letter-spaced w. a subtle scaleY stretch,
// --terUser ink at 0.6 opacity. No polarity coloring — these are
// spread-position labels, not deck identifiers.
//
// Labels live OUTSIDE the .sea-card-slot (sibling, inside the crucifix
// cell or the cover/cross wrapper) so they survive SeaDeal._fillSlot's
// `slot.innerHTML = …` clobber on draw. Each label is absolute-
// positioned to nearly touch the slot's nearest border per the user-
// locked spec:
// crown / cover — above top border
// lay / cross — below bottom border
// leave — left of left border, rotated 90° CCW
// loom — right of right border, rotated 90° CW
.sea-pos-label {
font-size: 0.65rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
opacity: 1;
color: rgba(var(--seciUser), 1);
text-shadow: 0 0 0.25rem rgba(var(--priUser), 1);
text-align: center;
pointer-events: none;
white-space: nowrap;
position: absolute;
// z-index 0 (was 2) — labels sit BEHIND the slot so the slot's
// downward shadow can visually obscure the label's top edge per the
// .sea-stack-name "tuck under" treatment (user spec 2026-05-26).
z-index: 0;
}
// Cells need `position: relative` so absolute label children anchor
// to them. `.sea-pos-core` already has `position: relative` per the
// existing rule in _card-deck.scss line 1311; the other crucifix
// cells need it added.
.my-sea-cross .sea-crucifix-cell { position: relative; }
// CROWN + COVER labels — ABOVE the slot per user spec 2026-05-26.
// Cross convention: crown is at the top + cover is the central card
// being covered, so their labels belong above. `bottom: 100%` anchors
// label's bottom edge to slot's top; `-0.4rem` translate-Y pushes it
// further away so the labels read clearly w. breathing room.
.sea-pos-crown > .sea-pos-label,
.sea-pos-cover > .sea-pos-label {
bottom: 100%;
left: 50%;
transform: translate(-50%, -0.4rem) scaleY(1.2);
}
// LAY + CROSS labels — BELOW the slot. `0.3rem` translate-Y pushes each
// label a bit further from its slot's bottom edge than the prior tuck-
// under (which crowded the label up against the slot's border). The
// filled-card shadow (added below to `.sea-card-slot--filled`, NOT the
// empty slot per user clarification 2026-05-26) extends 0.25rem downward
// via the `$_sea-shadow` chain — covers the label's top edge w/o the
// label having to physically overlap the card's border-box.
.sea-pos-lay > .sea-pos-label,
.sea-pos-cross > .sea-pos-label {
top: 100%;
left: 50%;
transform: translate(-50%, 0.3rem) scaleY(1.2);
}
// Breathing room around COVER + CROSS labels — bump CROWN cell UP by
// 0.5rem + LAY cell DOWN by 0.5rem so the COVER label (below the central
// sig card) + CROSS label (also in the central row) have vertical space
// w/o colliding into the crown's slot from above or the lay's slot from
// below. Translate (not margin) so the surrounding grid layout doesn't
// reflow — cells stay in their grid-areas but visually shift.
.my-sea-cross .sea-pos-crown { transform: translateY(-0.5rem); }
.my-sea-cross .sea-pos-lay { transform: translateY( 0.5rem); }
// Filled-card downward shadow — only on the my-sea Cross page (NOT the
// picker or other surfaces using `.sea-card-slot`), and only the FILLED
// variant (the dashed empty slot stays shadowless per user clarification
// 2026-05-26: "the slots themselves should not have box-shadows ... only
// the cards that replace them should"). Mirrors `.sea-stack-face`'s
// `$_sea-shadow` chain in `_card-deck.scss:1976` (the GRAVITY/LEVITY deck
// stacks): solid-black 1px×2px offset shadow + a softer 4px-down spread +
// a 2px×5px blurred falloff. The image-mode slot still keeps its filter-
// chain contour-stroke + 1px depth shadow from `_card-deck.scss:837` — the
// box-shadow layers cleanly over it for a richer depth read.
.my-sea-cross .sea-card-slot--filled {
box-shadow:
1px 2px 0 rgba(0, 0, 0, 0.7),
0 4px 0 rgba(0, 0, 0, 0.18),
2px 5px 5px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 1;
}
// Rotated-card shadow corrections — `box-shadow` rotates w. the element's
// `transform`, so any rotated card's down-right shadow rotates to a wrong
// direction. Each rotation case needs its offsets pre-inverted so the
// post-rotation render still reads down-right.
//
// Derivation: CSS rotate(θ) CW maps offset (a, b) → screen (a·cos θ
// b·sin θ, a·sin θ + b·cos θ). To get screen (1, 2) after rotation, solve
// for unrotated (a, b). Below shows the result for each rotation in play:
// • 180° (reversed, `.sea-card-slot--reversed` at _card-deck:1616):
// (1, 2) → (1, 2). Flip all signs.
// • 90° (cross, `.sea-pos-cross .sea-card-slot` at _card-deck:1705):
// (1, 2) → (2, 1). Swap + negate y.
// • 270° (cross + reversed at _card-deck:1620): (1, 2) → (2, 1).
//
// Specificity ladder: base filled (0,2,0) < reversed-only (0,3,0) = cross-
// only (0,3,0) < cross+reversed (0,4,0). For a card matching multiple, the
// most-specific rule wins; cross-only + reversed-only tie at (0,3,0) but
// only one matches per card (cross OR non-cross), so no source-order trap.
.my-sea-cross .sea-card-slot--filled.sea-card-slot--reversed {
box-shadow:
-1px -2px 0 rgba(0, 0, 0, 0.7),
0 -4px 0 rgba(0, 0, 0, 0.18),
-2px -5px 5px rgba(0, 0, 0, 0.5);
}
.my-sea-cross .sea-pos-cross .sea-card-slot--filled {
box-shadow:
2px -1px 0 rgba(0, 0, 0, 0.7),
4px 0 0 rgba(0, 0, 0, 0.18),
5px -2px 5px rgba(0, 0, 0, 0.5);
}
.my-sea-cross .sea-pos-cross .sea-card-slot--filled.sea-card-slot--reversed {
box-shadow:
-2px 1px 0 rgba(0, 0, 0, 0.7),
-4px 0 0 rgba(0, 0, 0, 0.18),
-5px 2px 5px rgba(0, 0, 0, 0.5);
}
// Cover + cross labels dim w. their slots — they sit on top of the
// sig card so a vivid label would compete w. the sig at idle. Default
// 0.5 opacity matches the slot's faint dotted-outline at idle; the
// parent's :hover state (propagated up when the inside `.sea-card-
// slot:hover` fires per CSS hover-ancestor rules) boosts to full.
.sea-pos-cover > .sea-pos-label,
.sea-pos-cross > .sea-pos-label {
opacity: 0.5;
transition: opacity 0.15s ease;
}
.sea-pos-cover:hover > .sea-pos-label,
.sea-pos-cross:hover > .sea-pos-label {
opacity: 1;
}
// Left of left border, rotated 90° CCW — text reads bottom-to-top.
// `writing-mode: vertical-rl` puts text top-to-bottom (CW); a 180°
// rotation flips it to read bottom-to-top (CCW), satisfying the user-
// locked "Leave: counterclockwise" spec.
//
// `scaleX(1.2)` (instead of the horizontal labels' scaleY) widens the
// character column (perpendicular to text-flow) — for vertical-rl
// labels, that's the visible "width" the user noticed had been lost
// at this angle. Without it, the rotated labels look squat.
.sea-pos-leave > .sea-pos-label {
right: 100%;
top: 50%;
writing-mode: vertical-rl;
// User spec 2026-05-26: parity w. CROWN/LAY's ~0.4rem breathing room.
// Was `0.1rem` overlap (tuck-under); now `-0.4rem` pulls the label
// further LEFT, AWAY from the slot's left edge so it reads cleanly w/o
// colliding into the slot's border-radius zone.
transform: translate(-0.3rem, -50%) rotate(180deg) scaleX(1.2);
}
// Right of right border, rotated 90° CW — text reads top-to-bottom.
// Native `writing-mode: vertical-rl` direction; no extra rotation.
.sea-pos-loom > .sea-pos-label {
left: 100%;
top: 50%;
writing-mode: vertical-rl;
// User spec 2026-05-26: same `0.4rem` AWAY distance as LEAVE (mirrored).
transform: translate(0.3rem, -50%) scaleX(1.2);
}
// Section dividers inside the SPREAD combobox — labels "3-card spreads"
// / "6-card spreads" separating the option groups. Styled to echo the
// `.kit-bag-label` treatment (small uppercase underlined letter-spaced
// --quaUser) but horizontal rather than vertical (kit-bag uses writing-
// mode: vertical-rl; this is a flat dropdown).
.sea-select-list .sea-select-divider {
font-size: 0.55rem;
text-transform: uppercase;
text-decoration: underline;
letter-spacing: 0.12em;
color: rgba(var(--quaUser), 0.75);
padding: 0.4rem 0.6rem 0.2rem;
pointer-events: none; // not selectable; combobox.js skips it
// (no role=option), but belt-and-braces
// against accidental hover/click styles.
list-style: none;
}
// Form col on my-sea — same DRY treatment as the gameroom sea-overlay
// `.sea-form-col` (handled in _card-deck.scss) but sits next to the
// picker's cross on a `--duoUser` page. Just constrain the width so it
// doesn't fight the cross for horizontal space.
.my-sea-form-col {
flex: 0 0 16rem;
max-width: 16rem;
// Portal the SPREAD dropdown out of `.sea-form-main`'s overflow
// clip — by default the gameroom's `.sea-form-main { overflow-y:
// auto }` (from _card-deck.scss:1424) keeps the modal contents
// scrollable, but for my-sea's much shorter form the dropdown gets
// clipped instead of overlaying the LOCK HAND / DEL btns below.
// Setting overflow visible here lets the absolute-positioned
// `.sea-select-list` extend past the form area + sit "above
// everything else" via its existing z-index: 100.
.sea-form-main {
overflow: visible;
}
// Portrait — split the form into two columns. LEFT carries the
// SPREAD field (label + reversal hint + combobox) above the action
// btns (AUTO DRAW / GATE VIEW + DEL); RIGHT carries the DECKS
// section (label + GRAVITY/LEVITY stacks) spanning both rows.
// Without this rearrange, on a phone-portrait viewport the stacked
// form col runs off the bottom of the viewport — DECKS lives above
// the action btns + everything below the fold (user-spec
// 2026-05-21). `.sea-form-main` uses `display: contents` so its
// `.sea-field` + `.sea-stacks` children act as direct grid items of
// the form col despite the intermediate wrapper in the DOM.
//
// Selector chains `.sea-form-col.my-sea-form-col` to win against
// `_card-deck.scss`'s base `.sea-form-col { display: flex; flex-
// direction: column }` (same 1-class specificity; card-deck loads
// AFTER gameboard in `core.scss`, so source order would otherwise
// overrule my-sea's grid). Chained 2-class selector pulls
// specificity ahead regardless of source order.
@media (orientation: portrait) {
&.sea-form-col {
flex: 0 0 auto;
max-width: none;
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-areas:
"field stacks"
"actions stacks";
column-gap: 1rem;
row-gap: 0.5rem;
align-items: start;
.sea-form-main { display: contents; }
.sea-field { grid-area: field; margin-bottom: 0; }
.sea-stacks { grid-area: stacks; margin: 0; justify-content: center; }
.sea-form-actions { grid-area: actions; align-self: end; padding-top: 0; }
}
}
// Bump the dropdown z-index well above the picker's stacking ints
// (cover z:3, cross z:4, modal stage z:9999 only opens on draw
// anyway). 1000 sits above any in-page layer the user might be
// interacting w. when they open the SPREAD picker.
.sea-select-list {
z-index: 1000;
}
// Portrait — open the SPREAD dropdown UPWARD instead of downward.
// The portrait form col sits at the bottom of the viewport (below
// the cross spread) w. the navbar/footer pinned beneath it, so the
// default `top: 100%` dropdown extends BELOW the visible aperture
// + the user can't scroll to it. Flipping to `bottom: 100%` makes
// the list grow upward into the abundant green aperture above.
// Chained `&.sea-form-col` to beat card-deck's later-loaded base
// (per [[feedback-scss-import-order-specificity]]).
@media (orientation: portrait) {
&.sea-form-col .sea-select .sea-select-list {
top: auto;
bottom: 100%;
margin: 0 0 0.2rem;
}
}
}
// LOCK HAND post-commit visual-lock: dim everything that mutates the
// hand. `.btn-disabled` is the project's existing soft-disabled
// treatment per [[feedback_btn_disabled_pointer_events]] — pointer-
// events:none + opacity reduction. The deck stacks aren't buttons
// themselves so we apply the class manually + the rule below ensures
// they stop responding to clicks.
.my-sea-picker--locked {
.sea-deck-stack.btn-disabled {
pointer-events: none;
opacity: 0.5;
cursor: default;
}
}
// SPREAD combobox lock — applied after the first deposit so the user
// can't switch spread mid-draw + scramble the in-progress hand's
// position-to-card mapping. DEL releases the lock by removing this
// class. Same `pointer-events: none` treatment as `.btn-disabled` per
// [[feedback_btn_disabled_pointer_events]].
.sea-select.sea-select--locked {
pointer-events: none;
opacity: 0.5;
cursor: default;
}
// ── My Sea spread modal (Phase 2 of the burger Sea sub-btn rollout) ──
//
// Holds the .sea-form-col chrome (spread combobox + AUTO DRAW + DEL).
// Hidden by default via the `hidden` attribute; JS removes the attr to
// open. Backdrop is click-to-dismiss, Escape also closes (handlers in
// the inline <script> at the bottom of my_sea.html).
.my-sea-spread-modal {
position: fixed;
inset: 0;
z-index: 320; // above burger (314), kit (318), bud (318), dialog (316)
display: flex;
align-items: center;
justify-content: center;
pointer-events: none; // children re-enable
&[hidden] {
// `hidden` attr would already display:none — kept explicit so
// any later cascade still respects the closed state.
display: none;
}
&__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(0.25rem);
pointer-events: auto;
}
&__panel {
position: relative;
z-index: 1;
background: rgba(var(--priUser), 1);
border: 0.15rem solid rgba(var(--secUser), 1);
border-radius: 0.75rem;
box-shadow:
0 0 1rem rgba(var(--secUser), 0.5),
0.15rem 0.15rem 0.5rem rgba(0, 0, 0, 0.5);
padding: 1.25rem;
pointer-events: auto;
max-width: 90vw;
.sea-form-col {
// Already-styled .sea-form-col container handles internal layout.
// Drop the fixed width inside the modal — modal panel sizes to
// content + the form is the content.
width: auto;
min-width: 16rem;
}
}
}
// ── Relocated deck-stacks (Phase 2) ──────────────────────────────────
//
// .sea-stacks was a child of .sea-form-col; now lives on the page in
// .my-sea-stacks-wrap so it stays visible when the spread modal is
// closed. Pin to the bottom-right of the aperture (above the bud +
// burger btns, below the modal).
.my-sea-stacks-wrap {
position: absolute;
bottom: 4.5rem; // clear of the bud/burger pair at bottom
right: 1rem;
z-index: 5; // above .my-sea-cross, below modal (320)
pointer-events: auto;
}
// ── Iter 4b: Brief banner + DEL guard portal ─────────────────────────────────
// Both reuse shared chrome: the Brief is `.note-banner` from note.js
// (portaled atop h2 w. Gaussian glass); the DEL guard is `#id_guard_portal`
// from base.html (the same one the room gear-menu DEL uses, positioned
// above the anchor button w. Gaussian glass + no backdrop). The picker IIFE
// invokes it via `window.showGuard(delBtn, "Are you sure?", confirmFn,
// null, {yesLabel: "DEL"})`. No my-sea-specific SCSS needed.
// ── My Sea applet (billboard-style gameboard applet) ─────────────────────────
// The applet at `_applet-my-sea.html` lists the active draw's slots in
// DRAW_ORDER — drawn cards filled + empty slots placeholder'd, each
// w. a label caption tucked tight against the slot's bottom edge.
// Horizontal-scroll mirrors the Palettes applet (`.palette` in
// `_palette-picker.scss:1`): row of fixed-size items + `overflow-x:
// auto`, so 6-card spreads scroll while 3-card spreads fit. Slots use
// the same `.sig-stage-card` layout language as the my_sign.html stage
// card (corner-tl + face w. name + corner-br) at applet scale —
// container queries on `.my-sea-scroll` lift `--slot-w` to fill the
// scroll's vertical aperture (minus label) so cards span the whole
// applet height per user spec 2026-05-22.
#id_applet_my_sea {
display: flex;
flex-direction: column;
// Anchor for #id_applet_sky_delete_btn's absolute centering.
position: relative;
background-color: rgba(var(--duoUser), 1) !important;
h2 {
flex-shrink: 0;
background-color: rgba(var(--priUser), 1);
box-shadow: rgba(0, 0, 0, 1) !important;
}
.my-sea-scroll {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.75rem;
padding: 0.25rem 0.5rem 0.5rem;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
container-type: size;
}
.my-sea-slot-wrap {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
scroll-snap-align: start;
height: 100%;
// No gap — the label sits directly against the slot's bottom
// border per user spec ("tighter [...] practically overlapping").
// Slight negative margin pulls the label baseline up into the
// slot's border line so the two visually merge.
}
// Slot shell — 5:8 card, sized to fill the wrap's height minus the
// label row. `--slot-w` resolves via container queries: 100cqi-cap
// when the scroll is wide-but-shallow, 100cqh*5/8 = 62.5cqh - tiny-
// label-reservation otherwise. The `- 1rem` carves out the label
// row + tight gap so the card doesn't overshoot the applet floor.
.my-sea-slot {
--slot-w: min(100cqi, calc((100cqh - 1rem) * 5 / 8));
width: var(--slot-w);
aspect-ratio: 5 / 8;
border-radius: 0.4rem;
border: 0.12rem solid rgba(var(--secUser), 0.6);
padding: 0.35rem;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
flex: 0 0 auto;
.fan-card-corner--tl,
.fan-card-corner--br {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.05;
gap: 0.05rem;
position: absolute;
.fan-corner-rank {
font-size: calc(var(--slot-w) * 0.16);
font-weight: 700;
}
i { font-size: calc(var(--slot-w) * 0.13); }
}
.fan-card-corner--tl { top: 0.25rem; left: 0.3rem; }
.fan-card-corner--br {
bottom: 0.25rem; right: 0.3rem;
transform: rotate(180deg);
}
// `gap: 0` so qualifier sits directly above the title at the
// title's own line-height (no flex gap between them); `.fan-card-
// arcana` carries its own margin-top to restore breathing room
// between title block and arcana label.
.fan-card-face {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
text-align: center;
padding: 0 0.2rem;
}
// Qualifier + title share the same typography (per `_card-deck.scss`
// convention at lines 568-572 / 1821-1823) — both bold, same size,
// same wrap, same line-height. Color inherits from the slot's
// polarity-driven `color:` (set on `--gravity` / `--levity`).
.fan-card-qualifier,
.fan-card-name {
margin: 0;
font-size: calc(var(--slot-w) * 0.105);
font-weight: 700;
line-height: 1.15;
text-wrap: balance;
}
.fan-card-qualifier:empty { display: none; }
.fan-card-arcana {
margin: calc(var(--slot-w) * 0.05) 0 0;
font-size: calc(var(--slot-w) * 0.07);
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.6;
}
}
// Filled slot polarity — mirrors `.sea-card-slot--gravity` / `--levity`
// in `_card-deck.scss:1332-1341`. Gravity = priUser bg + quiUser text;
// levity = inverted (secUser bg + priUser text). `.fan-card-name`,
// `.fan-card-qualifier`, `.fan-card-corner` + `.fan-card-arcana` all
// pin `color: inherit` so they pick up the slot's polarity color
// uniformly — the global `.fan-card-face .fan-card-name { color:
// --terUser }` rule in `_card-deck.scss:376-383` loads AFTER gameboard
// (per `core.scss` import order) and otherwise wins at matching 0,2,0
// specificity, stranding the title at --terUser while the qualifier
// inherits the slot color. Explicit `inherit` here at 0,3,0 beats it.
.my-sea-slot--filled.my-sea-slot--gravity {
background: rgba(var(--priUser), 1);
color: rgba(var(--quiUser), 1);
border-color: rgba(var(--secUser), 0.6);
.fan-card-corner { color: inherit; }
.fan-card-qualifier { color: inherit; }
.fan-card-name { color: inherit; }
.fan-card-arcana { color: inherit; opacity: 0.6; }
}
.my-sea-slot--filled.my-sea-slot--levity {
background: rgba(var(--secUser), 1);
color: rgba(var(--priUser), 1);
border-color: rgba(var(--priUser), 1);
.fan-card-corner { color: inherit; }
.fan-card-qualifier { color: inherit; }
.fan-card-name { color: inherit; }
.fan-card-arcana { color: inherit; opacity: 0.7; }
}
.my-sea-slot--filled.my-sea-slot--reversed { transform: rotate(180deg); }
// Sprint A.7 — image-mode slot override. `_card-deck.scss` imports
// after `_gameboard.scss`, but the polarity rules above (`.my-sea-slot--
// filled.my-sea-slot--gravity` / `--levity`) are nested inside
// `#id_applet_my_sea` (specificity 1,2,0) and beat the top-level shared
// `.my-sea-slot.my-sea-slot--image` rule (0,2,0) on bg + border + color.
// Re-state the transparency here at matching nested specificity so the
// PNG card-back is unobstructed. Filter-chain / contour-stroke / depth
// shadow on `.sig-stage-card-img` still come from the shared rule (no
// collision — different selector target).
//
// `overflow: visible` is critical — the base `.my-sea-slot` rule above
// sets `overflow: hidden` at (1,1,0) which BEATS the shared image-mode
// rule's `overflow: visible` at (0,2,0) on the ID axis. Without re-
// stating here, the fourfold contour-stroke drop-shadows get clipped
// by the slot bounding box → reads as a uniform rectangular frame
// around the image, defeating the "stroke follows alpha contour"
// illusion. See [[feedback-scss-id-context-specificity-trap]].
.my-sea-slot--filled.my-sea-slot--image {
background: transparent;
border: 0;
padding: 0;
overflow: visible;
}
// Empty slot — matches the my_sea.html picker's empty `.sea-card-
// slot` style (`_card-deck.scss:1299-1303`): 0.15rem DASHED border in
// --terUser at full opacity, --duoUser fill. Same width + dash
// frequency as the picker so the applet reads as a true "miniature"
// of the picker rather than a cousin w. different dotting cadence.
// `!important` on the three border properties: the base `.my-sea-
// slot` border shorthand sits at the same (1,1,0) specificity, so
// belt-and-suspenders the override.
.my-sea-slot--empty {
background-color: rgba(var(--duoUser), 1);
border-style: dashed !important;
border-color: rgba(var(--terUser), 1) !important;
border-width: 0.15rem !important;
}
// Label — sibling of the slot inside the wrap, sits BELOW the slot.
// User spec 2026-05-26: parity w. my_sea.html's `.sea-pos-lay` label
// treatment — label tucked just below the slot w. the filled card's
// downward shadow (added below to `.my-sea-slot--filled`) obscuring
// the label's top edge. Empty dashed slots stay shadowless so the
// label sits cleanly below them.
.my-sea-slot-label {
position: relative;
// z-index 0 (was 2) — slot's shadow lives on top + casts over the
// label's top edge; the label is behind the slot in stacking order.
z-index: 0;
// Small gap (was `-0.05rem` flush margin) so the label doesn't
// glue to the slot's bottom border on the empty-slot case where
// there's no shadow to bridge the visual gap.
margin-top: 0.15rem;
padding: 0 0.2rem;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(var(--secUser), 0.85);
text-shadow: 0 0 0.25rem rgba(var(--priUser), 1);
text-align: center;
white-space: nowrap;
line-height: 1.1;
transform: scaleY(1.3);
transform-origin: top center;
}
// Filled-card downward shadow — applet parity w. my_sea.html's
// `.my-sea-cross .sea-card-slot--filled` rule. Empty dashed slots
// stay shadowless (per user clarification 2026-05-26: only the cards
// that replace the slots should carry a shadow).
.my-sea-slot--filled {
box-shadow:
1px 2px 0 rgba(0, 0, 0, 0.7),
0 4px 0 rgba(0, 0, 0, 0.18),
2px 5px 5px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 1;
}
// Reversed-card shadow inversion — parity w. my_sea.html's
// `.sea-card-slot--reversed` override below. The applet's `.my-sea-
// slot--reversed` (line 843) also adds `rotate(180deg)` which flips the
// shadow up-left; invert ALL offsets so post-rotation it reads down-
// right. See [[feedback-css-transform-rotates-box-shadow]] (or similar
// future-note) for the gotcha.
.my-sea-slot--filled.my-sea-slot--reversed {
box-shadow:
-1px -2px 0 rgba(0, 0, 0, 0.7),
0 -4px 0 rgba(0, 0, 0, 0.18),
-2px -5px 5px rgba(0, 0, 0, 0.5);
}
// `.my-sea-slot-label--empty` intentionally has NO per-state recolor
// — the empty-state label keeps the same `--secUser` ink as the
// filled-slot label per user spec 2026-05-22 (pins position identity
// Cover/Cross/etc. across the row regardless of fill state).
// Previously dimmed to --terUser to echo the dashed border tone —
// but that broke title cohesion when most slots were empty.
// No-draws empty state — centred italic, mirrors the Brief / applet-
// list-entry--empty pattern in `_billboard.scss:29-38`.
.my-sea-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-style: italic;
opacity: 0.6;
margin: 0;
}
}