My Sea iter-4a follow-up batch: modal port + draw polish + label positioning + Major Arcana fix — TDD

User-driven bug-squash + UX-polish cycle on top of iter 4a (ca2a62f). All 14 fixes ship behind the same iter-4a banner since they close the substage's UX gaps without expanding scope to iter 4b's persistence layer.

SeaDeal modal port — extracted apps/gameboard/_partials/_sea_stage.html shared by gameroom + my-sea; aliased .my-sea-picker w. id=id_sea_overlay so SeaDeal.init() finds it; FLIP click → SeaDeal.openStage delegation instead of bare _fillSlot. Fixes the user-reported 'thumbnail disappears' bug — slot was landing at opacity 0 (.--filled w.o .--visible) because SeaDeal's _hideStage (which adds --visible on modal dismiss) was never running. 3 new FTs cover the modal flow.

Spread lock + DEL reshuffle — _lockSpread/_unlockSpread toggle .sea-select--locked class on the combobox; first deposit locks, _resetHand unlocks. _reshuffleDeck Fisher-Yates over combined piles + re-rolls 25% reversal axis on DEL so successive DELs don't re-deal the same hand. Verified Claudezilla: 3 DEL cycles produced distinct lay cards (150 → 114 → 155).

Cover/cross empty slots — subtle dotted outline (transparent bg + 0.25 alpha border) w. --duoUser mask reveal on hover/touch. Per the user spec; rule lives in _card-deck.scss (shared between gameroom + my-sea). Plus matching label-opacity (0.25 idle → 0.6 hover) via CSS :hover ancestor propagation.

DOS spec — Solution moved from cover → crown per user correction. DRAW_ORDER ['loom', 'cross', 'crown']; POSITION_LABELS {loom: Desire, cross: Obstacle, crown: Solution}; SCSS hide list flipped from [leave, crown, lay] → [leave, cover, lay]; FT/IT assertions updated.

SAO → DOS soft-reload bug — Firefox autofill on hidden input restored the previous-session DOS value, tripping combobox.js's change-event guard. Fix: autocomplete=off + force-sync hidden.value from server-rendered aria-selected option in init. Captured as feedback_firefox_autofill_hidden_inputs (generalizable trap).

.sea-pos-label outside .sea-card-slot — moved label to be a sibling of the slot in the cell, so SeaDeal innerHTML clobber on draw doesn't erase it. Per-position absolute positioning touching slot borders: crown/cover above (translate -50%, 0.1rem, scaleY 1.2); lay/cross below (translate -50%, -0.1rem, scaleY 1.2); leave left, CCW (writing-mode vertical-rl + rotate 180deg + scaleX 1.2); loom right, CW (writing-mode vertical-rl + scaleX 1.2). scaleX for rotated labels (not scaleY) — perpendicular to text-flow is the visible-width direction after rotation. .my-sea-cross gap bumped to 1.75rem for label clearance.

Escape Velocity label swaps — POSITION_LABELS for escape-velocity: {crown: Crown, leave: Lay, cover: Cover, cross: Cross, loom: Loom, lay: Leave}. Replaces the Waite-Smith Behind/Beneath/Before per user spec.

SPREAD dropdown portal — .my-sea-form-col .sea-form-main { overflow: visible } + .sea-select-list { z-index: 1000 } so the dropdown extends past the form-main scroll area + sits above the picker stacking ints. Gameroom .sea-form-main still scrolls (only my-sea opts out).

Major Arcana polarity-split rendering — added 9 missing _card_dict keys to my-sea's _my_sea_deck_data to match gameroom epic.views.sea_deck's contract: levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word, keywords_upright, keywords_reversed, energies, operations. Without these StageCard.populateCard falls through to plain name_title for trumps 19-21 + cards 48-49. Iter 4b cleanup candidate: extract apps.epic.utils.card_dict() to DRY the now-identical helpers.

Tests deferred — user explicitly belayed FT runs during the bug-fix substage. Iter 4b will re-establish a green sweep before its commit lands.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-19 22:02:27 -04:00
parent ca2a62fd84
commit b6e93b9d64
7 changed files with 473 additions and 98 deletions

View File

@@ -249,6 +249,21 @@ def _my_sea_deck_data(user):
"levity_qualifier": c.levity_qualifier,
"gravity_qualifier": c.gravity_qualifier,
"reversal_qualifier": c.reversal_qualifier,
# Polarity-split full-title overrides — required for Major
# Arcana (Earthman trumps 19-21 + cards 48-49) to render
# their per-polarity emanation/reversal names on the stage
# card. Without these StageCard.populateCard falls back to
# the plain `name_title` w. no qualifier. Mirrors the
# gameroom `epic.views.sea_deck` JSON shape exactly.
"levity_emanation": c.levity_emanation,
"gravity_emanation": c.gravity_emanation,
"levity_reversal": c.levity_reversal,
"gravity_reversal": c.gravity_reversal,
"italic_word": c.italic_word,
"keywords_upright": c.keywords_upright,
"keywords_reversed": c.keywords_reversed,
"energies": c.energies,
"operations": c.operations,
"reversed": random.random() < reversal_prob,
}

View File

@@ -533,7 +533,7 @@ class MySeaSpreadFormTest(FunctionalTest):
"past-present-future": {"leave", "cover", "loom"},
"situation-action-outcome": {"lay", "cover", "crown"},
"mind-body-spirit": {"crown", "lay", "loom"},
"desire-obstacle-solution": {"loom", "cross", "cover"},
"desire-obstacle-solution": {"loom", "cross", "crown"},
"waite-smith": ALL_POSITIONS,
"escape-velocity": ALL_POSITIONS,
}
@@ -583,7 +583,7 @@ class MySeaSpreadFormTest(FunctionalTest):
"situation-action-outcome": {"lay": "Situation", "cover": "Action", "crown": "Outcome"},
"past-present-future": {"leave": "Past", "cover": "Present", "loom": "Future"},
"mind-body-spirit": {"crown": "Mind", "lay": "Body", "loom": "Spirit"},
"desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","cover":"Solution"},
"desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","crown":"Solution"},
"waite-smith": {"crown": "Crown", "leave": "Beneath", "cover": "Cover",
"cross": "Cross", "loom": "Before", "lay": "Behind"},
}
@@ -670,9 +670,10 @@ class MySeaCardDrawTest(FunctionalTest):
)
)
def _draw_one(self, picker, polarity):
"""Click a polarity swatch + the FLIP btn that appears →
deposits a card. `polarity` is `'levity'` or `'gravity'`."""
def _draw_open_modal(self, picker, polarity):
"""Click a polarity swatch + the FLIP btn that appears → opens
the SeaDeal stage modal. Returns the stage element so callers
can assert on it before dismissing."""
stack = picker.find_element(
By.CSS_SELECTOR, f".sea-deck-stack--{polarity}"
)
@@ -680,9 +681,45 @@ class MySeaCardDrawTest(FunctionalTest):
flip = self.wait_for(
lambda: stack.find_element(By.CSS_SELECTOR, ".sea-stack-ok")
)
# FLIP btn becomes visible after the stack click; wait for it.
self.wait_for(lambda: self.assertTrue(flip.is_displayed()))
flip.click()
# SeaDeal.openStage shows #id_sea_stage. Wait for the modal.
return self.wait_for(
lambda: self._stage_visible()
)
def _stage_visible(self):
stage = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage")
if not stage.is_displayed():
raise AssertionError("sea-stage not visible after FLIP click")
return stage
def _dismiss_modal(self):
"""Click the stage backdrop → SeaDeal._hideStage → modal hides +
slot gains `.--visible` (thumbnail fades in).
Uses `execute_script` to dispatch the click rather than a native
Selenium `.click()` — `.sea-stage-content` overlays the backdrop
visually (centered card + stat block), so Selenium reports
ElementClickInterceptedException for a direct click. This is
the documented Selenium-limitation exception per the TDD skill;
the actual backdrop-click → close behaviour is Jasmine-tested
in [[SeaDealSpec.js]] / "Backdrop click closes the stage"."""
self.browser.execute_script(
"document.querySelector('#id_sea_stage .sea-stage-backdrop').click();"
)
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage").is_displayed()
)
)
def _draw_one(self, picker, polarity):
"""Full single-draw cycle: open modal + dismiss it. Used by FTs
that need to deposit multiple cards in sequence (the stage
backdrop blocks subsequent deck-stack clicks)."""
self._draw_open_modal(picker, polarity)
self._dismiss_modal()
# ── Test 1 ───────────────────────────────────────────────────────────────
@@ -890,3 +927,71 @@ class MySeaCardDrawTest(FunctionalTest):
hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
self.assertIn("25", hint.text)
self.assertIn("reversal", hint.text.lower())
# ── Test (modal bug fix) ────────────────────────────────────────────────
def test_flip_click_opens_portaled_stage_modal(self):
"""Bug fix (2026-05-19): the user-reported missing modal. After
clicking the deck stack + the FLIP btn that appears, SeaDeal.
openStage should fire — showing `#id_sea_stage` (position-fixed
full-viewport portal) above everything else. Before the fix the
slot got filled directly at opacity 0 → 'thumbnail summarily
disappears'. Now: modal opens; slot stays at `--filled` but
`--visible` is NOT added yet (waits for backdrop dismiss)."""
picker = self._enter_picker_phase()
stage = self._draw_open_modal(picker, "levity")
# Stage card carries the drawn card's data — non-empty corner rank.
rank = stage.find_element(
By.CSS_SELECTOR, ".sea-stage-card .fan-card-corner--tl .fan-corner-rank"
)
self.assertTrue(rank.text.strip(), "stage card should display the drawn card's corner rank")
# Slot in the cross is in `.--filled` state but the thumbnail is
# invisible until the modal dismisses (the bug we're guarding).
slot = picker.find_element(
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
)
self.assertNotIn(
"sea-card-slot--visible", slot.get_attribute("class"),
"slot should still be in pre-reveal opacity-0 state while modal is open",
)
# ── Test (modal bug fix, dismiss reveal) ───────────────────────────────
def test_backdrop_click_dismisses_modal_and_reveals_thumbnail(self):
"""Bug fix part 2: clicking the `.sea-stage-backdrop` closes the
modal AND adds `.sea-card-slot--visible` to the deposited slot,
making the thumbnail fade in. Confirms the user-reported 'card
appears where the slot was' behavior post-dismiss."""
picker = self._enter_picker_phase()
self._draw_open_modal(picker, "levity")
self._dismiss_modal()
slot = picker.find_element(
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
)
self.assertIn(
"sea-card-slot--visible", slot.get_attribute("class"),
"post-dismiss, the slot should fade in via `.--visible`",
)
# ── Test (modal bug fix, stat block populates) ─────────────────────────
def test_modal_stage_renders_stat_block_dom_contract(self):
"""SeaDeal._populate populates the stat-block keyword `<ul>`s
via `#id_sea_stat_upright` / `#id_sea_stat_reversed`. The DOM
contract — these IDs exist inside the stage — is what this FT
pins; the actual stat content (keyword text, qualifier render)
is exercised by [[SeaDealSpec.js]]. Earthman seed cards in the
iter-4a FT pile carry empty keyword arrays so we can't assert
text content here without enriching the seed."""
picker = self._enter_picker_phase()
self._draw_open_modal(picker, "levity")
# Stat-block UL elements exist inside the visible stage.
upright = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_upright")
reversed_ul = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_reversed")
self.assertIsNotNone(upright)
self.assertIsNotNone(reversed_ul)
# The sea stat block is inside the visible stage modal.
stat_block = self.browser.find_element(
By.CSS_SELECTOR, "#id_sea_stage .sea-stat-block"
)
self.assertIsNotNone(stat_block)

View File

@@ -1324,9 +1324,30 @@ $sea-card-h: 6.5rem;
.sea-pos-cover { z-index: 3; } // above sig (z-index: 2)
.sea-pos-cross { z-index: 4; } // above cover
// Empty Cover/Cross slots are invisible — they reveal only once a card is deposited
// Empty Cover/Cross slots — subtle dotted outline (no fill) so the
// underlying Sig card shows through. Hovering/touching reveals the
// full --duoUser mask, opaquing the slot + obscuring the Sig behind.
// Border + label dim to 0.25 alpha default; bounce to full on hover.
// The filled-slot hover behavior (opacity 0.3/0.15 → 1) at lines 1300-
// 1301 is untouched — this only restyles the EMPTY state.
.sea-pos-cover .sea-card-slot--empty,
.sea-pos-cross .sea-card-slot--empty { opacity: 0; pointer-events: none; }
.sea-pos-cross .sea-card-slot--empty {
background-color: transparent;
border-color: rgba(var(--terUser), 0.25);
box-shadow: none;
pointer-events: auto;
transition: background-color 0.15s ease, border-color 0.15s ease;
.sea-pos-label { opacity: 0.25; }
}
.sea-pos-cover .sea-card-slot--empty:hover,
.sea-pos-cross .sea-card-slot--empty:hover {
background-color: rgba(var(--duoUser), 1);
border-color: rgba(var(--terUser), 1);
.sea-pos-label { opacity: 0.6; }
}
.sea-pos-cross .sea-card-slot { transform: rotate(90deg); }

View File

@@ -297,10 +297,19 @@ body.page-gameboard {
// 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) cover (3) — sig-anchored cluster + loom
// 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,
@@ -321,28 +330,106 @@ body.page-gameboard {
.my-sea-cross[data-spread="desire-obstacle-solution"] {
.sea-pos-leave,
.sea-pos-crown,
.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 inside each empty `.sea-card-slot--empty` —
// 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.
// 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: 0.6;
transform: scaleY(1.2);
color: rgba(var(--terUser), 1);
opacity: 1;
color: rgba(var(--seciUser), 1);
text-align: center;
pointer-events: none;
white-space: nowrap;
position: absolute;
z-index: 2;
}
// 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; }
// Above top border — overlaps slot's top edge by 0.1rem (per the
// `.sea-stack-name` "tuck under" treatment in _card-deck.scss:1564).
.sea-pos-crown > .sea-pos-label,
.sea-pos-cover > .sea-pos-label {
bottom: 100%;
left: 50%;
transform: translate(-50%, 0.1rem) scaleY(1.2);
}
// 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.25 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 the
// `.sea-pos-label` baseline 0.6, matching the slot's `--duoUser` mask
// reveal.
.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;
}
// Below bottom border — same `0.1rem` overlap but downward.
.sea-pos-lay > .sea-pos-label,
.sea-pos-cross > .sea-pos-label {
top: 100%;
left: 50%;
transform: translate(-50%, -0.1rem) scaleY(1.2);
}
// 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;
transform: translate(0.1rem, -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;
transform: translate(-0.1rem, -50%) scaleX(1.2);
}
// Section dividers inside the SPREAD combobox — labels "3-card spreads"
@@ -370,6 +457,26 @@ body.page-gameboard {
.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;
}
// 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;
}
}
// LOCK HAND post-commit visual-lock: dim everything that mutates the
@@ -385,3 +492,14 @@ body.page-gameboard {
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;
}

View File

@@ -139,50 +139,9 @@
</div>{# /.sea-modal-wrap #}
{# ── Sea stage — big card viewer ─────────────────────────────────────────── #}
<div class="sea-stage" id="id_sea_stage" style="display:none">
<div class="sea-stage-backdrop"></div>
<div class="sea-stage-content">
<div class="sig-stage-card sea-stage-card">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
<div class="fan-card-face">
<div class="fan-card-face-upright">
<p class="fan-card-name-group"></p>
<p class="sig-qualifier-above"></p>
<p class="fan-card-name"></p>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p>
<div class="fan-card-face-reversal">
{# Default DOM order — matches non-major arcana layout. stage-card.js #}
{# swaps the class names on these <p>s for Major arcana so each #}
{# element's class still matches its semantic content. #}
<p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p>
</div>
</div>
<div class="fan-card-corner fan-card-corner--br">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
</div>
<div class="sig-stat-block sea-stat-block">
<button class="btn btn-reverse spin-btn" type="button">SPIN</button>
<button class="btn btn-info fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
</div>
<div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
</div>
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}
</div>
</div>
</div>
{# Extracted to a shared partial so the my-sea picker (Sprint 5 iter 4-bugs) #}
{# reuses the same DOM contract that SeaDeal binds to. #}
{% include "apps/gameboard/_partials/_sea_stage.html" %}
</div>{# /.sea-overlay #}

View File

@@ -0,0 +1,52 @@
{# Sea stage — full-viewport portaled modal (`position: fixed; inset: 0` #}
{# per _card-deck.scss:1615) that opens above the picker / overlay when #}
{# `SeaDeal.openStage(card, posSelector, isLevity)` fires. Hosts the #}
{# full card face + stat block + SPIN / FYI controls; click backdrop to #}
{# dismiss + reveal the deposited card thumbnail in its slot. #}
{# #}
{# Shared by the gameroom SEA SELECT phase and the my-sea picker — same #}
{# HTML, same SeaDeal module bindings; only the parent overlay differs. #}
<div class="sea-stage" id="id_sea_stage" style="display:none">
<div class="sea-stage-backdrop"></div>
<div class="sea-stage-content">
<div class="sig-stage-card sea-stage-card">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
<div class="fan-card-face">
<div class="fan-card-face-upright">
<p class="fan-card-name-group"></p>
<p class="sig-qualifier-above"></p>
<p class="fan-card-name"></p>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p>
<div class="fan-card-face-reversal">
{# Default DOM order — matches non-major arcana layout. stage-card.js #}
{# swaps the class names on these <p>s for Major arcana so each #}
{# element's class still matches its semantic content. #}
<p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p>
</div>
</div>
<div class="fan-card-corner fan-card-corner--br">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
</div>
<div class="sig-stat-block sea-stat-block">
<button class="btn btn-reverse spin-btn" type="button">SPIN</button>
<button class="btn btn-info fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
</div>
<div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
</div>
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}
</div>
</div>
</div>

View File

@@ -75,18 +75,28 @@
{# Each empty slot carries a `.sea-pos-label` caption (re- #}
{# appropriated from the GRAVITY/LEVITY .sea-stack-name look) #}
{# that JS updates per spread. #}
<div class="my-sea-picker" style="display:none">
{# #}
{# `id="id_sea_overlay"` aliases the picker to what SeaDeal #}
{# binds to (the gameroom uses the same ID on a different #}
{# page — no DOM collision since my-sea + gameroom never co- #}
{# exist in one DOM). FLIP click delegates to SeaDeal. #}
{# openStage(), which fills the slot AND opens the portaled #}
{# stage modal w. SPIN / FYI controls. #}
<div class="my-sea-picker" id="id_sea_overlay" style="display:none">
<div class="sea-cards-col">
<div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}">
{# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #}
{# `slot.innerHTML = …` (which writes the drawn card's corner- #}
{# rank + suit-icon) doesn't clobber it. Labels persist as #}
{# adjacent siblings + are positioned via absolute SCSS to #}
{# touch the slot's nearest edge. #}
<div class="sea-crucifix-cell sea-pos-crown">
<div class="sea-card-slot sea-card-slot--empty">
<span class="sea-pos-label" data-position="crown">Outcome</span>
</div>
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-crucifix-cell sea-pos-leave">
<div class="sea-card-slot sea-card-slot--empty">
<span class="sea-pos-label" data-position="leave"></span>
</div>
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-crucifix-cell sea-pos-core">
<div class="sig-stage-card sea-sig-card"
@@ -95,25 +105,21 @@
{% if significator.suit_icon %}<i class="fa-solid {{ significator.suit_icon }}"></i>{% endif %}
</div>
<div class="sea-pos-cover">
<div class="sea-card-slot sea-card-slot--empty">
<span class="sea-pos-label" data-position="cover">Action</span>
</div>
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-pos-cross">
<div class="sea-card-slot sea-card-slot--empty sea-card-slot--crossing">
<span class="sea-pos-label" data-position="cross"></span>
</div>
<div class="sea-card-slot sea-card-slot--empty sea-card-slot--crossing"></div>
</div>
</div>
<div class="sea-crucifix-cell sea-pos-loom">
<div class="sea-card-slot sea-card-slot--empty">
<span class="sea-pos-label" data-position="loom"></span>
</div>
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
<div class="sea-crucifix-cell sea-pos-lay">
<div class="sea-card-slot sea-card-slot--empty">
<span class="sea-pos-label" data-position="lay">Situation</span>
</div>
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
</div>
</div>
@@ -129,8 +135,16 @@
<div class="sea-field">
<label for="id_sea_spread" id="id_sea_spread_label">Spread</label>
<p class="sea-reversal-hint">{{ reversals_pct|default:25 }}% reversals</p>
{# autocomplete="off" opts the hidden input out of #}
{# Firefox's form-history autofill, which otherwise #}
{# restores the LAST value on soft reload (F5). #}
{# Without this, combobox.js's `select(i)` short- #}
{# circuits its change-event dispatch when the #}
{# user re-picks the value Firefox already restored #}
{# → my-sea's sync() never fires → data-spread on #}
{# .my-sea-cross stays stuck on SAO default. #}
<input type="hidden" id="id_sea_spread" name="spread"
value="{{ default_spread }}">
value="{{ default_spread }}" autocomplete="off">
<div class="sea-select"
data-combobox
data-combobox-target="id_sea_spread"
@@ -180,11 +194,21 @@
</button>
</div>
</div>
{# Sea stage — portaled modal that opens on FLIP click via #}
{# SeaDeal.openStage. `position:fixed; inset:0` covers the #}
{# viewport; click backdrop to dismiss + reveal the slot #}
{# thumbnail. #}
{% include "apps/gameboard/_partials/_sea_stage.html" %}
</div>
{# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, #}
{# sig excluded) embedded as JSON; JS reads on init and #}
{# pops from the relevant pile on each deposit. #}
{{ sea_deck_data|json_script:"id_my_sea_deck" }}
{# StageCard + SeaDeal — both bind to `#id_sea_overlay` (the #}
{# my-sea-picker) + `#id_sea_stage` (the stage partial) on #}
{# DOMContentLoaded; openStage() runs on FLIP click below. #}
<script src="{% static 'apps/epic/stage-card.js' %}"></script>
<script src="{% static 'apps/epic/sea.js' %}"></script>
<script src="{% static 'apps/epic/combobox.js' %}"></script>
<script>
(function () {
@@ -198,7 +222,7 @@
'past-present-future': ['leave', 'cover', 'loom'],
'situation-action-outcome': ['lay', 'cover', 'crown'],
'mind-body-spirit': ['crown', 'lay', 'loom'],
'desire-obstacle-solution': ['loom', 'cross', 'cover'],
'desire-obstacle-solution': ['loom', 'cross', 'crown'],
'waite-smith': ['cover', 'cross', 'crown', 'lay', 'loom', 'leave'],
'escape-velocity': ['cover', 'cross', 'lay', 'leave', 'crown', 'loom'],
};
@@ -206,9 +230,13 @@
'past-present-future': { leave: 'Past', cover: 'Present', loom: 'Future' },
'situation-action-outcome': { lay: 'Situation', cover: 'Action', crown: 'Outcome' },
'mind-body-spirit': { crown: 'Mind', lay: 'Body', loom: 'Spirit' },
'desire-obstacle-solution': { loom: 'Desire', cross: 'Obstacle',cover: 'Solution' },
'desire-obstacle-solution': { loom: 'Desire', cross: 'Obstacle',crown: 'Solution' },
'waite-smith': { crown: 'Crown', leave: 'Beneath', cover: 'Cover', cross: 'Cross', loom: 'Before', lay: 'Behind' },
'escape-velocity': { crown: 'Crown', leave: 'Beneath', cover: 'Cover', cross: 'Cross', loom: 'Before', lay: 'Behind' },
// Escape Velocity remaps the diagonal positions per the
// user-locked spec (2026-05-19): Beneath→Lay, Before→
// Loom, Behind→Leave. Crown/Cover/Cross keep the WS
// names.
'escape-velocity': { crown: 'Crown', leave: 'Lay', cover: 'Cover', cross: 'Cross', loom: 'Loom', lay: 'Leave' },
};
var hidden = document.getElementById('id_sea_spread');
var cross = document.querySelector('.my-sea-cross');
@@ -216,6 +244,7 @@
var lockBtn= document.getElementById('id_sea_lock_hand');
var delBtn = document.getElementById('id_sea_del');
var deckEl = document.getElementById('id_my_sea_deck');
var seaSelect = document.querySelector('[data-combobox][data-combobox-target="id_sea_spread"]');
if (!hidden || !cross || !picker) return;
// ── Deck state ──────────────────────────────────────────
@@ -272,7 +301,10 @@
function _emptySlot(cell) {
// DEL restores each filled slot to its initial empty
// state w. the .sea-pos-label re-rendered inside.
// state. `.sea-pos-label` is now a SIBLING of the slot
// (outside it) so SeaDeal's innerHTML clobber on draw
// doesn't touch it — we don't need to re-render the
// label here, just wipe slot contents + classes.
var slot = cell.querySelector('.sea-card-slot');
if (!slot) return;
slot.className = slot.className
@@ -284,21 +316,55 @@
slot.classList.add('sea-card-slot--empty');
delete slot.dataset.cardId;
delete slot.dataset.posKey;
var posName = '';
cell.classList.forEach(function (cls) {
var m = /^sea-pos-(.+)$/.exec(cls);
if (m && m[1] !== 'core' && m[1] !== 'label') posName = m[1];
slot.innerHTML = '';
}
function _lockSpread() {
// Lock the SPREAD combobox after the first deposit —
// switching spread mid-draw would scramble the in-
// progress hand. Unlocks on DEL.
if (seaSelect) {
seaSelect.classList.add('sea-select--locked');
seaSelect.setAttribute('aria-disabled', 'true');
}
}
function _unlockSpread() {
if (seaSelect) {
seaSelect.classList.remove('sea-select--locked');
seaSelect.removeAttribute('aria-disabled');
}
}
function _reshuffleDeck() {
// Fisher-Yates re-shuffle on DEL — re-distributes cards
// across both polarity halves + re-rolls the 25% reversal
// axis per card. Page-load shuffle already came from the
// server (`_my_sea_deck_data`); subsequent shuffles run
// client-side so DEL → fresh-hand doesn't require a
// network round-trip.
var all = (_deckData.levity || []).concat(_deckData.gravity || []);
for (var i = all.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = all[i]; all[i] = all[j]; all[j] = tmp;
}
// Clone each card (don't mutate the immutable server payload
// — DEL can fire many times; we don't want successive shuffles
// to fold previous reversal flips into the next round).
all = all.map(function (c) {
var clone = {};
for (var k in c) if (Object.prototype.hasOwnProperty.call(c, k)) clone[k] = c[k];
clone.reversed = Math.random() < 0.25;
return clone;
});
var labels = POSITION_LABELS[hidden.value] || {};
slot.innerHTML =
'<span class="sea-pos-label" data-position="' + posName + '">' +
(labels[posName] || '') +
'</span>';
var mid = Math.floor(all.length / 2);
_levityPile = all.slice(0, mid);
_gravityPile = all.slice(mid);
}
function _resetHand() {
_filled = 0;
_hideOk();
_unlockSpread();
cross.querySelectorAll(
'.sea-crucifix-cell.sea-pos-crown, ' +
'.sea-crucifix-cell.sea-pos-leave, ' +
@@ -306,9 +372,14 @@
'.sea-crucifix-cell.sea-pos-lay, ' +
'.sea-pos-cover, .sea-pos-cross'
).forEach(_emptySlot);
_levityPile = (_deckData.levity || []).slice();
_gravityPile = (_deckData.gravity || []).slice();
_reshuffleDeck();
if (lockBtn) lockBtn.disabled = true;
// Wipe SeaDeal's stage state too — closes a lingering
// modal + clears its `_seaHand` map so previously-
// drawn cards can't reopen via slot-tap focus.
if (window.SeaDeal && window.SeaDeal.resetHand) {
SeaDeal.resetHand();
}
}
function _setLocked(on) {
@@ -343,8 +414,27 @@
var order = _currentOrder();
var posName = order[_filled];
if (card && posName) {
// Delegate to SeaDeal — it `_fillSlot`s
// (sets corner-rank + suit-icon + polarity
// class on the slot at opacity 0) AND opens
// the portaled stage modal w. SPIN / FYI.
// Click-the-backdrop dismisses + the slot
// fades to `.--visible` revealing the
// thumbnail.
if (window.SeaDeal && window.SeaDeal.openStage) {
SeaDeal.openStage(card, '.sea-pos-' + posName, isLevity);
} else {
// Defensive fallback for environments
// where sea.js failed to load (e.g.
// collectstatic miss). Render the slot
// visibly so the draw isn't lost.
_fillSlot(posName, card, isLevity);
}
_filled++;
// First deposit locks the SPREAD combobox —
// switching mid-draw would scramble the
// in-progress hand's position mapping.
if (_filled === 1) _lockSpread();
if (lockBtn) lockBtn.disabled = (_filled < order.length);
}
_hideOk();
@@ -390,6 +480,21 @@
_filled = 0;
if (lockBtn) lockBtn.disabled = true;
// Belt-and-braces autofill defense (paired w. autocomplete=
// off on the hidden input above). Firefox occasionally
// restores form-history values on soft reload even on
// hidden inputs; if it does, hidden.value diverges from
// the server-rendered aria-selected option + combobox.js
// short-circuits its change-event dispatch on subsequent
// option picks of the autofilled value. Force-sync from
// the server-rendered aria-selected source-of-truth.
var _initialOpt = document.querySelector(
'.sea-select-list [role="option"][aria-selected="true"]'
);
if (_initialOpt && hidden.value !== _initialOpt.dataset.value) {
hidden.value = _initialOpt.dataset.value;
}
// Exposed for iter 4b / future surfaces.
window._mySeaDrawOrder = DRAW_ORDER;
}());