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

@@ -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>
<span class="sea-pos-label" data-position="crown">Outcome</span>
<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>
<span class="sea-pos-label" data-position="leave"></span>
<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>
<span class="sea-pos-label" data-position="cover">Action</span>
<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>
<span class="sea-pos-label" data-position="cross"></span>
<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>
<span class="sea-pos-label" data-position="loom"></span>
<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>
<span class="sea-pos-label" data-position="lay">Situation</span>
<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) {
_fillSlot(posName, card, isLevity);
// 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;
}());