Files
python-tdd/src/templates/apps/gameboard/_partials/_sea_overlay.html
Disco DeDisco b6e93b9d64 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>
2026-05-19 22:02:27 -04:00

263 lines
12 KiB
HTML

{% load static %}
{# DRAW SEA overlay — Celtic Cross spread entry #}
{# Included in room.html when table_status == "SKY_SELECT" and sky_confirmed #}
{# Layout is the reverse of CAST SKY: cards left (transparent), form right #}
<div class="sea-backdrop"></div>
<div class="sea-overlay" id="id_sea_overlay"
data-sea-deck-url="{% url 'epic:sea_deck' room.id %}"
data-sea-user-polarity="{{ user_polarity }}">
<div class="sea-modal-wrap">
<div class="sea-modal">
<header class="sea-modal-header">
<h2>SEA <span>SELECT</span></h2>
<p>Draw +6 cards to describe your character's influences and seed the map.</p>
</header>
<div class="sea-modal-body">
{# ── Cards column (transparent) ───────────────────────────── #}
<div class="sea-cards-col">
<div class="sea-cross">
{# Crown — CC pos 3 / EV pos 5 #}
<div class="sea-crucifix-cell sea-pos-crown">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
{# Beneath (past) — CC pos 4 / EV pos 3 #}
<div class="sea-crucifix-cell sea-pos-leave">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
{# Center — Significator (always placed) + Cover + Cross overlaid #}
<div class="sea-crucifix-cell sea-pos-core">
<div class="sig-stage-card sea-sig-card" style="--sig-card-w: 4rem">
{% if my_tray_sig %}
<span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>
{% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}
{% endif %}
</div>
{# Cover — CC/EV pos 1, stacked face-up on Sig #}
<div class="sea-pos-cover">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
{# Cross — CC/EV pos 2, rotated 90° on Cover #}
<div class="sea-pos-cross">
<div class="sea-card-slot sea-card-slot--empty sea-card-slot--crossing"></div>
</div>
</div>
{# Before (future) — CC pos 5 / EV pos 6 #}
<div class="sea-crucifix-cell sea-pos-loom">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
{# Behind (root) — CC pos 6 / EV pos 4 #}
<div class="sea-crucifix-cell sea-pos-lay">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
</div>
</div>
{# ── Form column (priUser / opaque) ───────────────────────── #}
<div class="sea-form-col">
<div class="sea-form-main">
<div class="sea-field">
<label for="id_sea_spread" id="id_sea_spread_label">Spread</label>
{% comment %}
Reversal-rate hint — `stack_reversal_pct` flows from
apps.epic.utils.stack_reversal_probability via the
view. Currently a module default; placeholder UI for
a forthcoming per-user setting.
{% endcomment %}
<p class="sea-reversal-hint">{{ stack_reversal_pct|default:25 }}% reversals</p>
{% comment %}
Custom combobox — native <select> dropdowns ignore most CSS on
Firefox/Chrome (OS-rendered list); this gives full styling control.
combobox.js wires up the keyboard nav, click-outside-to-close, and
writes the chosen value to the hidden <input id="id_sea_spread"> so
sea.js's existing `spreadSel.value` read still works.
{% endcomment %}
<input type="hidden" id="id_sea_spread" name="spread"
value="{% if user_polarity == 'levity' %}waite-smith{% else %}escape-velocity{% endif %}">
<div class="sea-select"
data-combobox
data-combobox-target="id_sea_spread"
role="combobox"
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="id_sea_spread_label"
tabindex="0">
<span class="sea-select-current">{% if user_polarity == "levity" %}Celtic Cross, Waite-Smith{% else %}Celtic Cross, Escape Velocity{% endif %}</span>
<span class="sea-select-arrow" aria-hidden="true"></span>
<ul class="sea-select-list" role="listbox">
{% if user_polarity == "levity" %}
<li role="option" data-value="waite-smith" aria-selected="true">Celtic Cross, Waite-Smith</li>
<li role="option" data-value="escape-velocity" aria-selected="false">Celtic Cross, Escape Velocity</li>
{% else %}
<li role="option" data-value="escape-velocity" aria-selected="true">Celtic Cross, Escape Velocity</li>
<li role="option" data-value="waite-smith" aria-selected="false">Celtic Cross, Waite-Smith</li>
{% endif %}
</ul>
</div>
</div>
{# Two face-down deck piles — tap to proffer OK #}
<div class="sea-stacks">
<span class="sea-stacks-label">DECKS</span>
<div class="sea-deck-stack sea-deck-stack--gravity">
<div class="sea-stack-face">
<button class="btn btn-reveal sea-stack-ok" type="button">FLIP</button>
</div>
<span class="sea-stack-name">Gravity</span>
</div>
<div class="sea-deck-stack sea-deck-stack--levity">
<div class="sea-stack-face">
<button class="btn btn-reveal sea-stack-ok" type="button">FLIP</button>
</div>
<span class="sea-stack-name">Levity</span>
</div>
</div>
</div>
<div class="sea-form-actions">
<button type="button" id="id_sea_lock_hand" class="btn btn-primary" disabled>
LOCK HAND
</button>
<button type="button" id="id_sea_del" class="btn btn-danger">
DEL
</button>
</div>
</div>
</div>{# /.sea-modal-body #}
</div>{# /.sea-modal #}
<button type="button" id="id_sea_cancel" class="btn btn-cancel btn-sm">NVM</button>
</div>{# /.sea-modal-wrap #}
{# ── Sea stage — big card viewer ─────────────────────────────────────────── #}
{# 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 #}
<script>
(function () {
'use strict';
const overlay = document.getElementById('id_sea_overlay');
const cancelBtn = document.getElementById('id_sea_cancel');
function openSea() {
document.documentElement.classList.add('sea-open');
}
function closeSea() {
document.documentElement.classList.remove('sea-open');
}
const pickSeaBtn = document.getElementById('id_pick_sea_btn');
if (pickSeaBtn) pickSeaBtn.addEventListener('click', openSea);
cancelBtn.addEventListener('click', closeSea);
// ── Deck draw ──────────────────────────────────────────────────────────────
const SEA_DECK_URL = overlay.dataset.seaDeckUrl;
const SPREAD_ORDER = {
'waite-smith': ['.sea-pos-cover', '.sea-pos-cross', '.sea-pos-crown', '.sea-pos-lay', '.sea-pos-loom', '.sea-pos-leave'],
'escape-velocity': ['.sea-pos-cover', '.sea-pos-cross', '.sea-pos-lay', '.sea-pos-leave', '.sea-pos-crown', '.sea-pos-loom'],
};
let levityPile = [], gravityPile = [];
let _filled = 0;
let _activeStack = null;
const spreadSel = overlay.querySelector('#id_sea_spread');
const lockBtn = overlay.querySelector('#id_sea_lock_hand');
const delBtn = overlay.querySelector('#id_sea_del');
function _spreadKey() {
return spreadSel ? spreadSel.value : 'waite-smith';
}
function _nextPosSelector() {
const order = SPREAD_ORDER[_spreadKey()] || SPREAD_ORDER['waite-smith'];
return order[_filled] || null;
}
function _hideOk() {
if (_activeStack) {
const ok = _activeStack.querySelector('.sea-stack-ok');
if (ok) ok.style.display = 'none';
_activeStack.classList.remove('sea-deck-stack--active');
_activeStack = null;
}
}
function _showOk(stack) {
_hideOk();
_activeStack = stack;
stack.classList.add('sea-deck-stack--active');
const ok = stack.querySelector('.sea-stack-ok');
if (ok) ok.style.display = '';
}
function _reset() {
_filled = 0;
_hideOk();
overlay.querySelectorAll('.sea-card-slot').forEach(s => {
s.classList.remove('sea-card-slot--filled', 'sea-card-slot--visible', 'sea-card-slot--focused', 'sea-card-slot--levity', 'sea-card-slot--gravity');
s.classList.add('sea-card-slot--empty');
s.innerHTML = '';
delete s.dataset.cardId;
delete s.dataset.posKey;
});
if (lockBtn) lockBtn.disabled = true;
if (window.SeaDeal) SeaDeal.resetHand();
_fetchDeck();
}
function _fetchDeck() {
fetch(SEA_DECK_URL, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => { levityPile = data.levity || []; gravityPile = data.gravity || []; })
.catch(() => {});
}
overlay.querySelectorAll('.sea-deck-stack').forEach(stack => {
stack.addEventListener('click', e => {
e.stopPropagation();
_activeStack === stack ? _hideOk() : _showOk(stack);
});
const ok = stack.querySelector('.sea-stack-ok');
if (ok) {
ok.style.display = 'none';
ok.addEventListener('click', e => {
e.stopPropagation();
const isLevity = stack.classList.contains('sea-deck-stack--levity');
const pile = isLevity ? levityPile : gravityPile;
const card = pile.length ? pile.shift() : null;
const pos = _nextPosSelector();
if (card && pos) {
_filled++;
if (lockBtn) lockBtn.disabled = (_filled < 6);
if (window.SeaDeal) SeaDeal.openStage(card, pos, isLevity);
}
_hideOk();
});
}
});
overlay.addEventListener('click', _hideOk);
if (delBtn) delBtn.addEventListener('click', _reset);
_fetchDeck();
if (window.SeaDeal) SeaDeal.reinit();
})();
</script>