diff --git a/src/apps/epic/static/apps/epic/sea.js b/src/apps/epic/static/apps/epic/sea.js
index 732d47a..677cf51 100644
--- a/src/apps/epic/static/apps/epic/sea.js
+++ b/src/apps/epic/static/apps/epic/sea.js
@@ -6,8 +6,24 @@ var SeaDeal = (function () {
var fyiPanel, fyiTitle, fyiType, fyiEffect, fyiIndex, fyiPrev, fyiNext;
var _userPolarity = 'levity';
- var _seaHand = {}; // posSelector → {card, isLevity}
+ // posName (raw, e.g. "cover") → {card, isLevity}. The KEY here MUST
+ // match what `_collectHandFromDom` in `my_sea.html` reads (which is
+ // `slot.dataset.posKey`, stamped raw by both the template + the
+ // inline `_fillSlot` shim). Storing the SELECTOR form ".sea-pos-
+ // cover" instead silently broke persistence (manual draws never
+ // POSTed) — fixed 2026-05-21.
+ var _seaHand = {};
+ // Raw position name (e.g. "cover") of the currently-showing stage.
+ // `_hideStage` prefixes `.sea-pos-` when querySelector'ing the cell.
var _viewingPos = null;
+
+ // Internal helper — strip the `.sea-pos-` prefix off a selector to
+ // get the raw position name. Tolerant of inputs that are already
+ // raw (no prefix → returned as-is) so callers don't need to know
+ // which form they have.
+ function _posName(posSelector) {
+ return (posSelector || '').replace(/^\.sea-pos-/, '');
+ }
var _infoData = [];
var _infoIdx = 0;
var _infoOpen = false;
@@ -72,7 +88,12 @@ var SeaDeal = (function () {
// XLIII / XLVIII) need horizontal squeezing to fit the slot — see SCSS.
if ((card.corner_rank || '').length >= 5) slot.classList.add('sea-card-slot--rank-long');
slot.dataset.cardId = String(card.id);
- slot.dataset.posKey = posSelector;
+ // Raw position name (e.g. "cover"), NOT the selector form. The
+ // my-sea inline `_collectHandFromDom` reads this + looks up by
+ // raw name from `_currentOrder()`; the template's empty-slot
+ // partial also stamps `data-pos-key="{{ position }}"` raw.
+ // Standardize so both code paths agree (2026-05-21 fix).
+ slot.dataset.posKey = _posName(posSelector);
slot.innerHTML =
'' + card.corner_rank + '' +
(card.suit_icon ? '' : '');
@@ -90,7 +111,7 @@ var SeaDeal = (function () {
function _hideStage() {
// Reveal the deposited card in its slot (opacity 0 → 0.6 transition)
if (_viewingPos) {
- var cell = overlay.querySelector(_viewingPos);
+ var cell = overlay.querySelector('.sea-pos-' + _viewingPos);
if (cell) {
var slot = cell.querySelector('.sea-card-slot--filled');
if (slot) slot.classList.add('sea-card-slot--visible');
@@ -105,8 +126,9 @@ var SeaDeal = (function () {
// ── Public API ─────────────────────────────────────────────────────────────
function openStage(card, posSelector, isLevity) {
- _viewingPos = posSelector;
- _seaHand[posSelector] = { card: card, isLevity: isLevity };
+ var posName = _posName(posSelector);
+ _viewingPos = posName;
+ _seaHand[posName] = { card: card, isLevity: isLevity };
_populate(card, isLevity);
_fillSlot(posSelector, card, isLevity);
_showStage(isLevity);
@@ -115,16 +137,30 @@ var SeaDeal = (function () {
// Like `openStage` but DOESN'T show the stage modal — used by AUTO
// DRAW (my_sea.html) to place cards quietly while keeping them
// clickable later. The overlay click handler reads `_seaHand[pos]`;
- // without an entry here, click silently no-ops (the user-reported
+ // without an entry here, click silently no-ops (user-reported
// bug fixed 2026-05-21). Routing slot fill thru SeaDeal's internal
// `_fillSlot` (instead of the inline shim) also ensures
- // `dataset.posKey` stays consistent w. the manual-FLIP path
- // (selector form like ".sea-pos-cover", not raw "cover").
+ // `dataset.posKey` stays consistent w. the manual-FLIP path.
function register(card, posSelector, isLevity) {
- _seaHand[posSelector] = { card: card, isLevity: isLevity };
+ _seaHand[_posName(posSelector)] = { card: card, isLevity: isLevity };
_fillSlot(posSelector, card, isLevity);
}
+ // Seed `_seaHand` from outside SeaDeal — used by my-sea's inline
+ // IIFE on init to make server-rendered (saved-hand) slots re-
+ // clickable after a refresh. Caller walks the filled DOM slots,
+ // looks up each card by `data-card-id` against the embedded deck
+ // JSON, and passes a `{posName: {card, isLevity}}` map here. Does
+ // NOT touch slot DOM — the slots are already filled by the server
+ // template; we just need the in-memory lookup to resolve the
+ // overlay click handler. Idempotent.
+ function seedHand(handByPosName) {
+ if (!handByPosName) return;
+ Object.keys(handByPosName).forEach(function (posName) {
+ _seaHand[posName] = handByPosName[posName];
+ });
+ }
+
// ── Init ──────────────────────────────────────────────────────────────────
function init() {
@@ -253,6 +289,7 @@ var SeaDeal = (function () {
return {
openStage: openStage,
register: register,
+ seedHand: seedHand,
resetHand: resetHand,
reinit: init, // call after overlay is injected into the DOM
_testInit: function () {
diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py
index beebc3f..b1f877c 100644
--- a/src/functional_tests/test_game_my_sea.py
+++ b/src/functional_tests/test_game_my_sea.py
@@ -902,6 +902,63 @@ class MySeaCardDrawTest(FunctionalTest):
)
self.wait_for(lambda: self.assertTrue(stage.is_displayed()))
+ # ── Test 5c — manual draw survives refresh ──────────────────────────────
+
+ def test_manual_draw_persists_on_refresh(self):
+ """User-reported 2026-05-21: manually-drawn cards disappeared
+ after a refresh. Root cause: SeaDeal's `_fillSlot` stamps
+ `slot.dataset.posKey` w. the selector form (".sea-pos-cover"),
+ but the inline `_collectHandFromDom` looks up by raw name from
+ `_currentOrder()` ("cover"). The key mismatch means manual
+ draws are dropped from the POST body — server stores nothing
+ → refresh shows empty state.
+
+ Fix: standardize on raw position names for `dataset.posKey`."""
+ from apps.gameboard.models import MySeaDraw
+ picker = self._enter_picker_phase()
+ self._draw_one(picker, "levity")
+ # POST is async — wait for server commit by polling the DB.
+ self.wait_for(
+ lambda: self.assertEqual(
+ len(MySeaDraw.objects.get(user=self.gamer).hand), 1,
+ )
+ )
+ self.browser.refresh()
+ picker = self.wait_for(
+ lambda: self.browser.find_element(
+ By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
+ )
+ )
+ self.assertEqual(_count_filled_slots(picker), 1)
+
+ # ── Test 5d — reloaded slot re-opens stage modal on click ───────────────
+
+ def test_reloaded_slot_can_reopen_stage_modal_on_click(self):
+ """After refresh, server-rendered filled slots must remain
+ clickable to re-open the stage modal. Requires SeaDeal's init
+ to seed `_seaHand` from the embedded deck JSON + the saved
+ slots in the DOM. User-reported 2026-05-21."""
+ from apps.gameboard.models import MySeaDraw
+ picker = self._enter_picker_phase()
+ self._draw_one(picker, "levity")
+ self.wait_for(
+ lambda: self.assertEqual(
+ len(MySeaDraw.objects.get(user=self.gamer).hand), 1,
+ )
+ )
+ self.browser.refresh()
+ slot = self.wait_for(
+ lambda: self.browser.find_element(
+ By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot--filled"
+ )
+ )
+ slot.click() # first tap — focus
+ slot.click() # second tap — open modal
+ stage = self.wait_for(
+ lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage")
+ )
+ self.wait_for(lambda: self.assertTrue(stage.is_displayed()))
+
# ── Test 6 ───────────────────────────────────────────────────────────────
def test_del_btn_is_disabled_until_hand_complete(self):
diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html
index 340651a..52dfeb6 100644
--- a/src/templates/apps/gameboard/my_sea.html
+++ b/src/templates/apps/gameboard/my_sea.html
@@ -796,6 +796,50 @@
_lockSpread();
}
+ // Seed SeaDeal's `_seaHand` from the server-rendered
+ // saved slots so they remain clickable for re-opening
+ // the stage modal after a refresh. Walks the filled
+ // slots + maps each `data-card-id` against the embedded
+ // deck JSON. Without this, the overlay click handler
+ // short-circuits on `if (!_seaHand[pos]) return;` —
+ // sea.js's in-memory `_seaHand` only gets populated by
+ // openStage/register during the live session.
+ // (User-reported 2026-05-21.)
+ if (window.SeaDeal && window.SeaDeal.seedHand) {
+ var _allCards = (_deckData.levity || [])
+ .concat(_deckData.gravity || []);
+ var _byId = {};
+ _allCards.forEach(function (c) { _byId[c.id] = c; });
+ var _seed = {};
+ cross.querySelectorAll(
+ '.sea-card-slot.sea-card-slot--filled'
+ ).forEach(function (slot) {
+ var posName = slot.dataset.posKey;
+ var cardId = parseInt(slot.dataset.cardId, 10);
+ var card = _byId[cardId];
+ if (!posName || !card) return;
+ // Reconstruct the per-instance `reversed` flag
+ // from the slot's DOM class (the deck JSON's
+ // `reversed` field is the original-shuffle axis
+ // — server's saved hand may have flipped it
+ // independently). Polarity also DOM-sourced.
+ var slotCard = {};
+ for (var k in card) {
+ if (Object.prototype.hasOwnProperty.call(card, k)) {
+ slotCard[k] = card[k];
+ }
+ }
+ slotCard.reversed = slot.classList.contains(
+ 'sea-card-slot--reversed'
+ );
+ var isLevity = slot.classList.contains(
+ 'sea-card-slot--levity'
+ );
+ _seed[posName] = { card: slotCard, isLevity: isLevity };
+ });
+ SeaDeal.seedHand(_seed);
+ }
+
// 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