My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD

Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running.

## Server

`MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker.
- `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS).
- `is_hand_complete` / `is_hand_empty` props drive view branching + template button states.
- `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop).
- `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn").

`my_sea_lock` is now a true upsert:
- First POST creates the row (quota commit).
- Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists).
- Spread-mismatch (attempted spread switch within quota window) → 409.
- Empty/malformed hand → 400.
- Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions.

`my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent.

`my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX.

`my_sea` view branches:
1. No sig → sign-gate
2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand
3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn
4. No active draw → landing phase w. FREE DRAW btn

## Template + UX

- Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`:
  - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists).
  - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6).
  - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion).
- DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing.
- Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag.
- Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish.

## Other polish bundled

- **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity.
- **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression.
- **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg."
- **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw).

## Test coverage

- ITs (1100 IT/UT green in 57s):
  - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`.
  - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response.
  - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test).
  - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state.
  - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW.
  - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled.
  - `MySeaGateStubViewTest` (new) — 404 stub + login required.
- FTs (35 my_sea FTs green in 5m):
  - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW).
  - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`.
  - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`.
  - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic).
  - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion).
  - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-20 01:34:03 -04:00
parent 6f901fd9ce
commit 7b7e80520a
12 changed files with 735 additions and 230 deletions

View File

@@ -5,7 +5,9 @@
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
{% block content %}
<div class="my-sea-page" data-phase="{% if active_draw %}picker{% else %}landing{% endif %}">
<div class="my-sea-page"
data-phase="{% if show_picker %}picker{% else %}landing{% endif %}"
data-polarity="{% if significator_reversed %}gravity{% else %}levity{% endif %}">
{% if not user_has_sig %}
{# Sprint 4b sign-gate. The draw UX is gated behind a saved #}
{# significator — render a Look!-formatted Brief-style line w. #}
@@ -24,15 +26,20 @@
</div>
</div>
{% else %}
{% if not active_draw %}
{% if not show_picker %}
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
{# the room shell (.room-shell > .room-table > … > .table-hex) #}
{# w. 6 chair seats labeled 1C-6C as placeholders for the #}
{# friend-invite feature per the My Sea roadmap architectural #}
{# anchor "Six chairs retained even in solo". DRAW SEA btn #}
{# mirrors SCAN SIGN on /billboard/my-sign/. Suppressed when #}
{# an active draw exists (iter 4b)the picker phase is the #}
{# landing state once the user has spent their free quota. #}
{# anchor "Six chairs retained even in solo". #}
{# #}
{# Iter 4clanding primary btn is: #}
{# • FREE DRAW (`#id_draw_sea_btn`) when the user has no #}
{# active quota row (fresh user OR >24h since last draw); #}
{# • GATE VIEW (`#id_my_sea_gate_view_btn`) when the user's #}
{# quota row exists but the hand is empty (post-DEL). The #}
{# daily free draw is spent; further draws require a token #}
{# deposit via the Sprint-6 gatekeeper (currently 404). #}
<div class="my-sea-landing">
<div class="room-shell">
<div id="id_game_table" class="room-table">
@@ -40,13 +47,14 @@
<div class="table-hex-border">
<div class="table-hex">
<div class="table-center">
{# Sprint 5 iter 1 — FREE DRAW = the 1/24hr free-quota draw. #}
{# Future sprint will conditionally swap this for a DRAW SEA #}
{# .btn-primary that calls the gatekeeper partial once the #}
{# free daily has been used; until then the btn renders FREE #}
{# DRAW. ID retained as `id_draw_sea_btn` (intent: the draw #}
{# entry point) so the swap is label-only when iter 6+ lands. #}
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button>
{% if quota_spent %}
<button id="id_my_sea_gate_view_btn"
type="button"
class="btn btn-primary"
data-gate-url="{% url 'my_sea_gate' %}">GATE<br>VIEW</button>
{% else %}
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button>
{% endif %}
</div>
</div>
</div>
@@ -86,7 +94,7 @@
{# 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{% if active_draw %} my-sea-picker--locked{% endif %}" id="id_sea_overlay"{% if not active_draw %} style="display:none"{% endif %}>
<div class="my-sea-picker{% if hand_complete %} my-sea-picker--locked{% endif %}" id="id_sea_overlay"{% if not show_picker %} style="display:none"{% endif %}>
<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 #}
@@ -190,10 +198,26 @@
</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">
{# Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) #}
{# / GATE VIEW (hand complete). Same button slot; JS #}
{# transitions label + behavior + `data-state` when #}
{# the final card lands. AUTO DRAW opens a guard portal #}
{# ("Auto deal cards?") + POSTs the remaining hand in #}
{# one shot so a navigate-away mid-animation still #}
{# persists. GATE VIEW navigates to the Sprint-6 gate- #}
{# keeper (currently a 404 stub). #}
<button type="button"
id="id_sea_action_btn"
class="btn btn-primary"
data-state="{% if hand_complete %}gate-view{% else %}auto-draw{% endif %}"
data-gate-url="{% url 'my_sea_gate' %}">{% if hand_complete %}GATE<br>VIEW{% else %}AUTO<br>DRAW{% endif %}</button>
{# DEL starts `.btn-disabled` until the hand is #}
{# complete — per Sprint-5-iter-4c spec, the 1/day #}
{# quota is committed at first-card-draw + can't be #}
{# refunded by an early DEL. #}
<button type="button"
id="id_sea_del"
class="btn btn-danger{% if not hand_complete %} btn-disabled{% endif %}">
DEL
</button>
</div>
@@ -252,7 +276,7 @@
var hidden = document.getElementById('id_sea_spread');
var cross = document.querySelector('.my-sea-cross');
var picker = document.querySelector('.my-sea-picker');
var lockBtn= document.getElementById('id_sea_lock_hand');
var actionBtn = document.getElementById('id_sea_action_btn');
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"]');
@@ -373,6 +397,10 @@
}
function _resetHand() {
// Spread-switch reset only (mid-draw guard inside sync()).
// Iter 4c removed the explicit DEL-resets-hand pathway:
// pre-completion DEL is `.btn-disabled`; post-completion
// DEL routes to the guard portal → server-side delete.
_filled = 0;
_hideOk();
_unlockSpread();
@@ -384,37 +412,35 @@
'.sea-pos-cover, .sea-pos-cross'
).forEach(_emptySlot);
_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) {
function _setComplete(on) {
// Iter 4c — single-call state transition for "hand
// complete": DEL un-disables, action btn becomes GATE
// VIEW, FLIP buttons on the deck stacks render as
// disabled when shown. The deck STACKS themselves stay
// click-responsive (so the user can see the disabled
// FLIP feedback) — `_locked` gates the actual draw.
_locked = on;
picker.classList.toggle('my-sea-picker--locked', on);
// Decks + LOCK HAND go disabled; DEL stays interactive
// — it's the user's escape from the locked state
// (opens the guard portal in iter 4b, resets the hand
// in iter 4a). Without this exemption the SCSS rule
// `.btn-disabled { pointer-events: none }` would block
// every Selenium / user click on DEL post-LOCK.
[picker.querySelector('.sea-deck-stack--levity'),
picker.querySelector('.sea-deck-stack--gravity'),
lockBtn].forEach(function (el) {
if (!el) return;
el.classList.toggle('btn-disabled', on);
});
if (delBtn) delBtn.classList.toggle('btn-disabled', !on);
if (actionBtn) {
actionBtn.dataset.state = on ? 'gate-view' : 'auto-draw';
actionBtn.innerHTML = on ? 'GATE<br>VIEW' : 'AUTO<br>DRAW';
}
_hideOk();
}
// ── Deck-stack click → show FLIP → click FLIP → deposit ─
// Iter 4c — stacks remain click-responsive even after hand
// is complete (so the user sees the disabled-FLIP feedback,
// signalling "no more draws allowed"). Each card placement
// POSTs the current hand to /lock for server upsert.
picker.querySelectorAll('.sea-deck-stack').forEach(function (stack) {
stack.addEventListener('click', function (e) {
if (_locked) return;
e.stopPropagation();
if (_activeStack === stack) _hideOk();
else _showOk(stack);
@@ -423,68 +449,149 @@
if (ok) {
ok.style.display = 'none';
ok.addEventListener('click', function (e) {
if (_locked) return;
e.stopPropagation();
// Hand complete → FLIP is disabled. No draw.
if (_locked) { _hideOk(); return; }
var isLevity = stack.classList.contains('sea-deck-stack--levity');
var pile = isLevity ? _levityPile : _gravityPile;
var card = pile.length ? pile.shift() : null;
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);
// Per-placement upsert — server stays current
// so a navigate-away mid-draw still persists
// the partial hand. User-spec 2026-05-20.
_postLock(_collectHandFromDom());
if (_filled >= order.length) {
_setComplete(true);
}
}
_hideOk();
});
}
});
function _collectHandFromDom() {
// Walks every `.sea-card-slot--filled` and reconstructs
// the hand array in DRAW_ORDER. data-card-id, polarity
// class, and `.sea-card-slot--reversed` are set at
// FLIP-click time so this is always current.
var byPos = {};
cross.querySelectorAll(
'.sea-card-slot.sea-card-slot--filled'
).forEach(function (slot) {
var cls = slot.className;
var pos = slot.dataset.posKey || '';
if (!pos) return;
byPos[pos] = {
position: pos,
card_id: parseInt(slot.dataset.cardId, 10),
reversed: /sea-card-slot--reversed\b/.test(cls),
polarity: /sea-card-slot--levity\b/.test(cls) ? 'levity' : 'gravity',
};
});
// Emit in DRAW_ORDER so the server stores cards in
// chronological draw order (Sprint 7 applet reads this
// sequence left-to-right).
var hand = [];
_currentOrder().forEach(function (pos) {
if (byPos[pos]) hand.push(byPos[pos]);
});
return hand;
}
function _autoDraw() {
// Compute all remaining cards client-side + POST in one
// shot (per user spec 2026-05-20: "commit all six draws
// in the same POST, just display them in order via js"
// — survives navigate-away mid-animation). After server
// commit, animate placement sequentially.
var order = _currentOrder();
var remaining = order.length - _filled;
if (remaining <= 0) return;
var autoEntries = [];
for (var i = 0; i < remaining; i++) {
var isLevity = Math.random() < 0.5;
var pile = isLevity ? _levityPile : _gravityPile;
if (!pile.length) {
isLevity = !isLevity;
pile = isLevity ? _levityPile : _gravityPile;
}
if (!pile.length) break;
var card = pile.shift();
autoEntries.push({
card: card,
posName: order[_filled + i],
isLevity: isLevity,
});
}
var fullHand = _collectHandFromDom();
autoEntries.forEach(function (e) {
fullHand.push({
position: e.posName,
card_id: e.card.id,
reversed: !!e.card.reversed,
polarity: e.isLevity ? 'levity' : 'gravity',
});
});
_postLock(fullHand).then(function (body) {
if (!body || !body.ok) return;
// POST succeeded → animate placement client-side.
// Even if the user navigates away now, the server
// already has the full hand — reload restores the
// complete picker state.
if (autoEntries.length === 0) {
_setComplete(true);
return;
}
if (_filled === 0) _lockSpread();
var idx = 0;
function placeNext() {
if (idx >= autoEntries.length) {
_setComplete(true);
return;
}
var e = autoEntries[idx++];
var stack = picker.querySelector(
'.sea-deck-stack--' + (e.isLevity ? 'levity' : 'gravity')
);
if (stack) _showOk(stack);
setTimeout(function () {
_fillSlot(e.posName, e.card, e.isLevity);
cross.querySelector('.sea-pos-' + e.posName + ' .sea-card-slot')
.classList.add('sea-card-slot--visible');
_filled++;
_hideOk();
setTimeout(placeNext, 250);
}, 350);
}
placeNext();
});
}
// Click elsewhere inside the picker dismisses the FLIP btn.
picker.addEventListener('click', _hideOk);
// ── DEL semantics differ by lock state ──────────────────
// Pre-lock: DEL resets the in-progress hand client-side
// (iter 4a behaviour — no server round-trip).
// Post-lock: DEL invokes the shared `#id_guard_portal`
// from base.html via `window.showGuard`, w. a
// "DEL" YES-label override (the room's gear-
// menu DEL flow uses the same portal). On YES
// we POST to /gameboard/my-sea/delete then
// navigate back to the page (server returns
// 204; we redirect manually to land on the
// FREE DRAW landing).
// ── DEL: post-completion only (server-rendered `.btn-
// disabled` pre-completion gates pre-hand DEL attempts).
// Opens the shared `#id_guard_portal` from base.html;
// CONFIRM POSTs to /gameboard/my-sea/delete which clears
// the hand but preserves the row (quota stays committed
// for the 24h window). Server returns 204; we reload to
// land on the GATE VIEW landing (FREE DRAW is gone until
// the row expires).
if (delBtn) {
delBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (!_locked) {
_resetHand();
return;
}
if (delBtn.classList.contains('btn-disabled')) return;
if (!window.showGuard) return;
// Trigger btn (DEL, `.btn-danger`) opens the shared
// guard portal; the portal's confirm button is the
// standard `.btn-confirm` "OK" + `.btn-cancel` "NVM"
// pair — matches the room gear-menu DEL flow exactly.
window.showGuard(
delBtn,
'Are you sure?',
@@ -500,29 +607,28 @@
);
});
}
if (lockBtn) {
lockBtn.addEventListener('click', function () {
if (lockBtn.disabled || _locked) return;
// Collect the in-progress hand for the POST. Slot
// class includes `--levity` / `--gravity`; reversed
// is on `.sea-card-slot--reversed`. Position comes
// from `data-pos-key`. Card id from `data-card-id`.
var hand = [];
cross.querySelectorAll(
'.sea-card-slot.sea-card-slot--filled'
).forEach(function (slot) {
var cls = slot.className;
hand.push({
position: slot.dataset.posKey || '',
card_id: parseInt(slot.dataset.cardId, 10),
reversed: /sea-card-slot--reversed\b/.test(cls),
polarity: /sea-card-slot--levity\b/.test(cls) ? 'levity' : 'gravity',
});
});
var order = _currentOrder();
if (hand.length < order.length) return;
_setLocked(true);
_postLock(hand);
// ── Action btn: AUTO DRAW (pre-complete) ↔ GATE VIEW
// (post-complete). Same DOM node, label + behavior keyed
// on `data-state`. AUTO DRAW opens a guard portal "Auto
// deal cards?"; on OK, fills remaining slots client-side
// + commits to server in one POST. GATE VIEW navigates
// to the Sprint-6 gatekeeper (currently 404 stub).
if (actionBtn) {
actionBtn.addEventListener('click', function (e) {
e.preventDefault();
var state = actionBtn.dataset.state;
if (state === 'gate-view') {
window.location.href = actionBtn.dataset.gateUrl || '#';
return;
}
// AUTO DRAW
if (!window.showGuard) return;
window.showGuard(
actionBtn,
'Auto deal cards?',
_autoDraw
);
});
}
@@ -587,7 +693,13 @@
}
};
function _postLock(hand) {
fetch('{% url "my_sea_lock" %}', {
// Returns the parsed JSON body promise so callers (e.g.
// _autoDraw) can chain animation onto server commit.
// Surfaces the Brief banner only on the *first* commit
// of this session (quota commit moment) — dedupe via
// `.my-sea-locked-banner` presence in DOM, so per-
// placement POSTs don't spam multiple banners.
return fetch('{% url "my_sea_lock" %}', {
method: 'POST',
credentials: 'same-origin',
headers: {
@@ -601,9 +713,11 @@
}).then(function (r) {
return r.ok ? r.json() : null;
}).then(function (body) {
if (body && body.next_free_draw_at) {
if (body && body.next_free_draw_at
&& !document.querySelector('.my-sea-locked-banner')) {
window._showFreeDrawLockedBrief(body.next_free_draw_at);
}
return body;
});
}
@@ -623,20 +737,24 @@
}
hidden.addEventListener('change', sync);
// Initial state — labels already server-rendered for the
// default spread. If the picker was server-rendered w. a
// locked saved hand (iter 4b's active_draw branch), the
// .my-sea-picker--locked class is already on the page —
// mirror it in JS state so further interactions stay
// disabled + DEL routes to the guard portal.
_filled = 0;
var _preLocked = picker.classList.contains('my-sea-picker--locked');
if (_preLocked) {
_filled = _currentOrder().length;
_setLocked(true);
// Initial state — iter-4c restoration of partial / complete
// saved-hand sessions. Server pre-renders the slots from
// saved_by_position; count the filled slots to seed _filled.
// If hand is complete (server-rendered `.my-sea-picker--
// locked`), call `_setComplete(true)` to align JS state
// (decks click-responsive but FLIP arrives disabled; DEL
// enabled; action btn = GATE VIEW). Mid-draw saved hands
// just need the count + spread lock (the user resumes
// drawing from where they left off).
_filled = cross.querySelectorAll(
'.sea-card-slot.sea-card-slot--filled'
).length;
if (_filled >= _currentOrder().length) {
_setComplete(true);
_lockSpread();
} else if (_filled > 0) {
_lockSpread();
}
if (lockBtn) lockBtn.disabled = (_filled < _currentOrder().length);
// Belt-and-braces autofill defense (paired w. autocomplete=
// off on the hidden input above). Firefox occasionally
@@ -709,6 +827,15 @@
}, SEAT_ANIM_MS);
});
}
// Iter 4c — landing GATE VIEW (replaces FREE DRAW when the
// user's quota row exists w. empty hand, i.e. post-DEL).
// Navigates to the Sprint-6 gatekeeper (currently 404 stub).
var gateLandingBtn = document.getElementById('id_my_sea_gate_view_btn');
if (gateLandingBtn) {
gateLandingBtn.addEventListener('click', function () {
window.location.href = gateLandingBtn.dataset.gateUrl || '#';
});
}
// Mirror my-sign's scaleTable() init timing fix — the
// .my-sea-page hasn't flushed its flex sizing on
// DOMContentLoaded, so the hex stays unscaled until we