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

@@ -0,0 +1,24 @@
"""Hard-delete MySeaDraw rows older than FREE_DRAW_COOLDOWN_HOURS.
The lazy cleanup inside `apps.gameboard.models.active_draw_for` already
prunes a user's stale rows on every view hit; this command is the cron
backstop for inactive accounts whose rows would otherwise sit forever.
Run from cron (or invoke manually). No flags; idempotent.
Usage:
python manage.py delete_stale_my_sea_draws
"""
from django.core.management.base import BaseCommand
from apps.gameboard.models import MySeaDraw
class Command(BaseCommand):
help = "Delete MySeaDraw rows older than the free-draw cooldown window (24h)."
def handle(self, *args, **options):
n = MySeaDraw.delete_stale()
self.stdout.write(self.style.SUCCESS(
f"Deleted {n} stale MySeaDraw row{'s' if n != 1 else ''}."
))

View File

@@ -5,28 +5,45 @@ from django.utils import timezone
FREE_DRAW_COOLDOWN_HOURS = 24 FREE_DRAW_COOLDOWN_HOURS = 24
# Total hand size per spread — matches DRAW_ORDER in my_sea.html JS.
# Used by `is_hand_complete` to decide whether the picker UX is in
# "still drawing" (AUTO DRAW visible, DEL disabled, FLIPs enabled)
# vs "completed" (GATE VIEW visible, DEL enabled, FLIPs disabled) state.
HAND_SIZE_BY_SPREAD = {
"past-present-future": 3,
"situation-action-outcome": 3,
"mind-body-spirit": 3,
"desire-obstacle-solution": 3,
"waite-smith": 6,
"escape-velocity": 6,
}
class MySeaDraw(models.Model): class MySeaDraw(models.Model):
"""Persisted Celtic-Cross-style tarot draw for the solo-user My Sea """Persisted Celtic-Cross-style tarot draw for the solo-user My Sea
feature. Each row is one locked hand by one user. feature. Each row is the user's active draw — both the hand state
AND the 24h quota tracker.
Sprint 5 iter 4b of [[project-my-sea-roadmap]] — server-side Sprint 5 iter 4c of [[project-my-sea-roadmap]] — refactor of the
persistence of the iter-4a client-side draw mechanic. iter-4b LOCK-HAND model. The row is created on the FIRST card draw
(manual or AUTO DRAW), not on a separate LOCK action. Quota is
Quota: one row per user per `FREE_DRAW_COOLDOWN_HOURS` window committed at first-card moment + survives a DEL (DEL clears the
(24h, irrespective of spread type). Subsequent draws within the `hand` JSON but preserves the row — so `created_at` keeps running
window are intended to be gated behind a token deposit at the My the 24h clock + landing renders GATE VIEW instead of FREE DRAW
Sea gatekeeper, which Sprint 6 will build. until the row expires).
`hand` is an ordered list of position-dicts in draw order — Sprint 7's `hand` is an ordered list of position-dicts in draw order — Sprint 7's
applet renders them left-to-right in that order. Each entry shape: applet renders them left-to-right in that order. Each entry shape:
{"position": "lay", "card_id": 42, "reversed": false, "polarity": "gravity"} {"position": "lay", "card_id": 42, "reversed": false, "polarity": "gravity"}
For mid-draw rows, `hand` is partial (1+ entries < HAND_SIZE_BY_SPREAD).
For DEL'd rows, `hand` is `[]` but `created_at` still anchors the
quota window.
`significator_id` + `significator_reversed` snapshot the user's sig `significator_id` + `significator_reversed` snapshot the user's sig
at lock time so a subsequent `User.significator = None` (via my-sign at first-card-draw time so a subsequent `User.significator = None`
DEL) doesn't invalidate the saved draw — per user spec, preserve the (via my-sign DEL) doesn't invalidate the saved draw.
old sig; any future draw uses whatever sig is current at that time.
""" """
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
@@ -60,16 +77,45 @@ class MySeaDraw(models.Model):
timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS) timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
) )
@property
def is_hand_complete(self):
"""True iff the hand has filled every position for its spread.
Drives the picker-form-col button state (AUTO DRAW → GATE VIEW
+ DEL enable + FLIPs disable on the deck stacks)."""
return len(self.hand or []) >= HAND_SIZE_BY_SPREAD.get(self.spread, 0)
@property
def is_hand_empty(self):
"""True iff the hand carries no entries — the post-DEL state,
where the row stays as a quota tracker but the cards are gone."""
return not self.hand
@classmethod
def delete_stale(cls):
"""Hard-delete every draw older than FREE_DRAW_COOLDOWN_HOURS.
Returns the deletion count.
Called lazily from `active_draw_for` on every view access (so
the cleanup naturally rides user traffic — no scheduler needed
for the common case) AND from the `delete_stale_my_sea_draws`
management command (cron backstop for inactive periods)."""
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
deleted, _ = cls.objects.filter(created_at__lt=cutoff).delete()
return deleted
def active_draw_for(user): def active_draw_for(user):
"""Return the user's most-recent draw within the quota window, or """Return the user's most-recent draw within the quota window, or
None. Used both for rendering the picker w. saved hand on page load None. Single source of truth for "does the user have an active draw"
and for gating LOCK HAND POSTs. (drives view branching: no-active → landing w. FREE DRAW; active w.
empty hand → landing w. GATE VIEW; active w. non-empty hand → picker).
Importing this helper rather than re-deriving the cutoff in every Lazy stale-row cleanup: every call prunes the user's >24h rows, so
caller keeps the 24h window a single-source-of-truth tied to the DB doesn't accumulate one row per user per day. The user-spec
FREE_DRAW_COOLDOWN_HOURS.""" 'auto-delete all draws after 24hrs, whether or not the user has
deleted them' (2026-05-20) lands here w. no scheduler required."""
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS) cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete()
return MySeaDraw.objects.filter( return MySeaDraw.objects.filter(
user=user, created_at__gte=cutoff, user=user, created_at__gte=cutoff,
).order_by("-created_at").first() ).order_by("-created_at").first()

View File

@@ -717,12 +717,18 @@ class MySeaSpreadFormTemplateTest(TestCase):
self.assertIn('data-position="loom"></span>', html) self.assertIn('data-position="loom"></span>', html)
self.assertIn('data-position="cross"></span>', html) self.assertIn('data-position="cross"></span>', html)
def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self): def test_form_col_renders_decks_action_btn_del_and_reversal_hint(self):
# Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) which JS
# transitions to GATE VIEW on completion. ID `id_sea_action_btn`
# is the single slot housing both states (label + `data-state`
# toggled by JS). User w. no active draw → AUTO DRAW label.
response = self.client.get(reverse("my_sea")) response = self.client.get(reverse("my_sea"))
html = response.content.decode() html = response.content.decode()
self.assertIn("sea-deck-stack--gravity", html) self.assertIn("sea-deck-stack--gravity", html)
self.assertIn("sea-deck-stack--levity", html) self.assertIn("sea-deck-stack--levity", html)
self.assertIn('id="id_sea_lock_hand"', html) self.assertIn('id="id_sea_action_btn"', html)
self.assertIn('data-state="auto-draw"', html)
self.assertIn("AUTO", html)
self.assertIn('id="id_sea_del"', html) self.assertIn('id="id_sea_del"', html)
self.assertIn("sea-reversal-hint", html) self.assertIn("sea-reversal-hint", html)
self.assertIn("25% reversals", html) self.assertIn("25% reversals", html)
@@ -956,21 +962,77 @@ class MySeaLockHandViewTest(TestCase):
parsed = datetime.fromisoformat(body["next_free_draw_at"]) parsed = datetime.fromisoformat(body["next_free_draw_at"])
self.assertIsNotNone(parsed) self.assertIsNotNone(parsed)
def test_lock_post_within_quota_window_returns_409(self): def test_lock_post_within_quota_upserts_same_row(self):
# Second lock within 24h: the existing draw already occupies the # Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
# quota; the server rejects rather than overwriting. # Second POST w. same spread updates the existing row's hand
# rather than 409'ing. Only one row exists per user per 24h.
import json import json
from apps.gameboard.models import MySeaDraw from apps.gameboard.models import MySeaDraw
from apps.epic.models import TarotCard
# First POST: 1-card partial hand
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
partial = {
"spread": "situation-action-outcome",
"hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
}
r1 = self.client.post(
self.url, data=json.dumps(partial),
content_type="application/json",
)
self.assertEqual(r1.status_code, 200)
# Second POST: full 3-card hand (the SAME draw progressing).
full = self._build_payload()
r2 = self.client.post(
self.url, data=json.dumps(full),
content_type="application/json",
)
self.assertEqual(r2.status_code, 200)
# Exactly one MySeaDraw row exists; hand is the latest full one.
rows = MySeaDraw.objects.filter(user=self.user)
self.assertEqual(rows.count(), 1)
self.assertEqual(len(rows.first().hand), 3)
def test_lock_post_spread_mismatch_within_quota_returns_409(self):
# Spread is committed at first-card moment; switching to a
# different spread mid-quota-window is rejected.
import json
from apps.epic.models import TarotCard
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
self.client.post( self.client.post(
self.url, data=json.dumps(self._build_payload()), self.url, data=json.dumps({
"spread": "situation-action-outcome",
"hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
}),
content_type="application/json", content_type="application/json",
) )
response = self.client.post( response = self.client.post(
self.url, data=json.dumps(self._build_payload()), self.url, data=json.dumps({
"spread": "waite-smith",
"hand": [{"position": "crown", "card_id": cards[1].id, "reversed": False, "polarity": "levity"}],
}),
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, 409) self.assertEqual(response.status_code, 409)
self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)
def test_lock_post_returns_hand_complete_flag(self):
# Body includes `hand_complete` so the JS can decide whether to
# transition the picker into post-completion state (DEL enable,
# FLIPs disable, AUTO DRAW → GATE VIEW).
import json
partial = {
"spread": "situation-action-outcome",
"hand": self._build_payload()["hand"][:1],
}
r1 = self.client.post(
self.url, data=json.dumps(partial),
content_type="application/json",
)
self.assertFalse(r1.json()["hand_complete"])
r2 = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
self.assertTrue(r2.json()["hand_complete"])
def test_lock_post_empty_hand_returns_400(self): def test_lock_post_empty_hand_returns_400(self):
import json import json
@@ -1006,7 +1068,8 @@ class MySeaLockHandViewTest(TestCase):
class MySeaDeleteDrawViewTest(TestCase): class MySeaDeleteDrawViewTest(TestCase):
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/delete` clears the draw.""" """Sprint 5 iter 4c — POST `/gameboard/my-sea/delete` clears the HAND
but preserves the row so the 24h quota window keeps running."""
def setUp(self): def setUp(self):
from apps.epic.models import personal_sig_cards, TarotCard from apps.epic.models import personal_sig_cards, TarotCard
@@ -1037,11 +1100,18 @@ class MySeaDeleteDrawViewTest(TestCase):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertEqual(response.status_code, 405) self.assertEqual(response.status_code, 405)
def test_delete_post_clears_active_draw(self): def test_delete_post_clears_hand_but_preserves_row(self):
# Iter 4c — DEL no longer deletes; the row stays as a 24h quota
# tracker. Hand JSON gets wiped + `created_at` preserved (so the
# landing renders GATE VIEW, not FREE DRAW, until the row expires).
from apps.gameboard.models import MySeaDraw from apps.gameboard.models import MySeaDraw
original_created_at = self.draw.created_at
response = self.client.post(self.url) response = self.client.post(self.url)
self.assertIn(response.status_code, (200, 204, 302)) self.assertIn(response.status_code, (200, 204, 302))
self.assertFalse(MySeaDraw.objects.filter(user=self.user).exists()) self.draw.refresh_from_db()
self.assertEqual(self.draw.hand, [])
self.assertEqual(self.draw.created_at, original_created_at)
self.assertTrue(MySeaDraw.objects.filter(user=self.user).exists())
def test_delete_post_scoped_to_user_does_not_touch_others(self): def test_delete_post_scoped_to_user_does_not_touch_others(self):
from apps.gameboard.models import MySeaDraw from apps.gameboard.models import MySeaDraw
@@ -1051,10 +1121,13 @@ class MySeaDeleteDrawViewTest(TestCase):
hand=self.draw.hand, significator_id=self.target.id, hand=self.draw.hand, significator_id=self.target.id,
) )
self.client.post(self.url) self.client.post(self.url)
self.assertTrue(MySeaDraw.objects.filter(pk=other_draw.pk).exists()) other_draw.refresh_from_db()
self.assertEqual(len(other_draw.hand), 3) # untouched
def test_delete_post_idempotent_when_no_active_draw(self): def test_delete_post_idempotent_when_no_active_draw(self):
# User deletes twice in a row — second call is a no-op, not a 500. # User deletes twice in a row — second call is a no-op, not a 500.
# First DEL clears hand. Second DEL finds the row w. empty hand
# already; just no-ops.
self.client.post(self.url) self.client.post(self.url)
response = self.client.post(self.url) response = self.client.post(self.url)
self.assertIn(response.status_code, (200, 204, 302)) self.assertIn(response.status_code, (200, 204, 302))
@@ -1170,3 +1243,146 @@ class MySeaViewWithSavedDrawTest(TestCase):
response = self.client.get(reverse("my_sea")) response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'id="id_draw_sea_btn"') self.assertNotContains(response, 'id="id_draw_sea_btn"')
self.assertNotContains(response, 'data-phase="landing"') self.assertNotContains(response, 'data-phase="landing"')
def test_complete_hand_renders_action_btn_as_gate_view(self):
# Iter 4c — server pre-renders the action btn label + data-state
# based on `hand_complete`. With the setUp's 3-card SAO hand,
# hand is complete → btn label is GATE VIEW.
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-state="gate-view"')
self.assertContains(response, "GATE")
self.assertContains(response, "VIEW")
def test_complete_hand_picker_carries_locked_class(self):
# `.my-sea-picker--locked` is server-rendered for completed hands
# so the JS init seeds `_locked=true` w.o. waiting for the post-
# placement state transition (matters for hot reloads, bfcache).
response = self.client.get(reverse("my_sea"))
self.assertContains(response, "my-sea-picker--locked")
def test_complete_hand_del_btn_is_not_disabled(self):
# DEL is `.btn-disabled` only when hand is INCOMPLETE. Complete
# hand → DEL renders w.o. the disabled class (clicking opens the
# guard portal).
import re
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
m = re.search(
r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
)
self.assertIsNotNone(m)
self.assertNotIn("btn-disabled", m.group(1))
class MySeaViewWithEmptyHandTest(TestCase):
"""Sprint 5 iter 4c — view branch for an active draw w. empty hand
(the post-DEL state, where the quota row stays as a 24h tracker but
the user's hand has been wiped). Landing renders w. GATE VIEW (NOT
FREE DRAW) as the primary nav."""
def setUp(self):
from apps.epic.models import personal_sig_cards
from apps.gameboard.models import MySeaDraw
self.user = User.objects.create(email="empty@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
# Active draw row but hand is empty — simulates the post-DEL state.
self.draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id, hand=[],
)
def test_empty_hand_renders_landing_phase_not_picker(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-phase="landing"')
def test_empty_hand_landing_renders_gate_view_btn_not_free_draw(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'id="id_my_sea_gate_view_btn"')
self.assertNotContains(response, 'id="id_draw_sea_btn"')
def test_empty_hand_gate_view_btn_links_to_gate_url(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, reverse("my_sea_gate"))
def test_empty_hand_brief_banner_still_triggered(self):
# Quota's still committed (row exists, 24h clock still running) →
# the Brief banner is part of the saved-draw context, regardless
# of hand state. Informs the user when the next free draw is.
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'window._showFreeDrawLockedBrief("')
class MySeaViewWithPartialHandTest(TestCase):
"""Sprint 5 iter 4c — view branch for an active draw w. mid-progress
hand (some slots filled, not yet complete). Picker renders w. the
partial slots + AUTO DRAW btn (not GATE VIEW); DEL stays disabled."""
def setUp(self):
from apps.epic.models import personal_sig_cards, TarotCard
from apps.gameboard.models import MySeaDraw
self.user = User.objects.create(email="partial@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
cards = list(TarotCard.objects.exclude(id=self.target.id)[:2])
# SAO is a 3-position spread; partial = 2 cards drawn.
self.draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id,
hand=[
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
],
)
def test_partial_hand_renders_picker_phase(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-phase="picker"')
def test_partial_hand_action_btn_is_auto_draw_not_gate_view(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-state="auto-draw"')
self.assertContains(response, "AUTO")
def test_partial_hand_del_btn_carries_btn_disabled(self):
import re
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
m = re.search(
r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
)
self.assertIsNotNone(m)
self.assertIn("btn-disabled", m.group(1))
def test_partial_hand_picker_does_NOT_carry_locked_class(self):
# Hand is mid-progress; locked class only applies on completion.
import re
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
m = re.search(r'<div class="my-sea-picker([^"]*)"', html)
self.assertIsNotNone(m)
self.assertNotIn("my-sea-picker--locked", m.group(1))
class MySeaGateStubViewTest(TestCase):
"""Sprint 5 iter 4c — placeholder for the Sprint 6 gatekeeper. Returns
a 404 so the template-side GATE VIEW button URL resolves but the
actual gatekeeper UX rides Sprint 6."""
def setUp(self):
self.user = User.objects.create(email="gate@test.io")
self.client.force_login(self.user)
def test_gate_view_returns_404(self):
response = self.client.get(reverse("my_sea_gate"))
self.assertEqual(response.status_code, 404)
def test_gate_view_requires_login(self):
self.client.logout()
response = self.client.get(reverse("my_sea_gate"))
# @login_required redirects before the 404 path runs.
self.assertEqual(response.status_code, 302)

View File

@@ -16,5 +16,6 @@ urlpatterns = [
path('my-sea/', views.my_sea, name='my_sea'), path('my-sea/', views.my_sea, name='my_sea'),
path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'), path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'),
path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'), path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'),
path('my-sea/gate/', views.my_sea_gate, name='my_sea_gate'),
] ]

View File

@@ -7,7 +7,7 @@ from django.utils import timezone
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from apps.applets.utils import applet_context, apply_applet_toggle from apps.applets.utils import applet_context, apply_applet_toggle
from .models import MySeaDraw, active_draw_for from .models import HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for
def _annotate_deck_in_use(decks, user): def _annotate_deck_in_use(decks, user):
@@ -174,16 +174,24 @@ def toggle_game_kit_sections(request):
def my_sea(request): def my_sea(request):
"""Shell view for the My Sea standalone page. """Shell view for the My Sea standalone page.
Branches: Sprint 5 iter 4c branching — `MySeaDraw` now plays double-duty as
hand storage AND 24h quota tracker. The row is created on first
card draw + survives DEL (which only wipes the hand). View states:
1. Active saved draw (within FREE_DRAW_COOLDOWN_HOURS) → picker phase 1. No sig → Look!-formatted sign-gate (Sprint 4b).
w. saved hand + Brief banner + DEL guard portal. The draw's sig 2. Active draw, hand non-empty (mid-draw or complete) → picker phase
snapshot is rendered (NOT user.significator) so a cleared sig w. saved hand state. The DEL btn is server-rendered `.btn-
elsewhere doesn't invalidate the saved draw. disabled` until hand is complete; AUTO DRAW becomes GATE VIEW on
2. No active draw + no sig → Look!-formatted sign-gate (Sprint 4b). completion. The draw's sig snapshot is rendered (NOT user.
3. No active draw + sig set → FREE DRAW landing (Sprint 5 iter 1) → significator) so a cleared sig elsewhere doesn't invalidate the
click swaps data-phase to picker for a fresh draw. saved draw.
3a. + no equipped deck → also show backup-deck Brief banner. 3. Active draw, hand empty (post-DEL) → landing w. GATE VIEW (the
free-draw quota is spent; user must use tokens via the upcoming
Sprint 6 gatekeeper). Brief banner still surfaces the next-
free-draw timestamp.
4. No active draw + sig set → landing w. FREE DRAW (the daily quota
is available).
4a. + no equipped deck → also show backup-deck Brief banner.
""" """
active_draw = active_draw_for(request.user) active_draw = active_draw_for(request.user)
sig_card, sig_reversed = _resolve_sig(request.user, active_draw) sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
@@ -194,10 +202,22 @@ def my_sea(request):
default_spread = active_draw.spread default_spread = active_draw.spread
saved_hand = active_draw.hand saved_hand = active_draw.hand
next_free_draw_at = active_draw.next_free_draw_at next_free_draw_at = active_draw.next_free_draw_at
hand_complete = active_draw.is_hand_complete
hand_empty = active_draw.is_hand_empty
else: else:
default_spread = "situation-action-outcome" default_spread = "situation-action-outcome"
saved_hand = [] saved_hand = []
next_free_draw_at = None next_free_draw_at = None
hand_complete = False
hand_empty = True
# Picker is the active phase iff the user has a non-empty hand in
# progress (or completed). Empty-hand active draws (post-DEL) fall
# back to the landing — but render GATE VIEW instead of FREE DRAW
# (the daily quota's spent already; landing's primary nav routes to
# the upcoming gatekeeper). New users + post-24h users land on the
# standard FREE DRAW landing.
show_picker = active_draw is not None and not hand_empty
quota_spent = active_draw is not None # any active row = quota committed
# Per-position lookup for the template — keyed by the position slug # Per-position lookup for the template — keyed by the position slug
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render # ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
@@ -234,11 +254,14 @@ def my_sea(request):
_my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None) _my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
if user_has_sig else {"levity": [], "gravity": []} if user_has_sig else {"levity": [], "gravity": []}
), ),
# Iter 4b # Iter 4b / 4c
"active_draw": active_draw, "active_draw": active_draw,
"saved_hand": saved_hand, "saved_hand": saved_hand,
"saved_by_position": saved_by_position, "saved_by_position": saved_by_position,
"next_free_draw_at": next_free_draw_at, "next_free_draw_at": next_free_draw_at,
"hand_complete": hand_complete,
"show_picker": show_picker,
"quota_spent": quota_spent,
"page_class": "page-gameboard page-my-sea", "page_class": "page-gameboard page-my-sea",
}) })
@@ -257,12 +280,21 @@ def _resolve_sig(user, active_draw):
@login_required(login_url="/") @login_required(login_url="/")
@require_POST @require_POST
def my_sea_lock(request): def my_sea_lock(request):
"""Persist the user's just-drawn hand as a `MySeaDraw` row. """Upsert the user's draw hand state. Sprint 5 iter 4c refactor —
fires on every card placement (manual FLIP or AUTO DRAW completion)
rather than only on a discrete LOCK HAND action.
Body: JSON `{"spread": "<slug>", "hand": [{position, card_id, reversed, Body: JSON `{"spread": "<slug>", "hand": [{position, card_id, reversed,
polarity}, ...]}`. Returns 200 w. `{ok, next_free_draw_at}` on success, polarity}, ...]}` — `hand` is the current FULL state (partial OK
400 for malformed payload, 409 if the user is still within the free- for mid-draw; sized to HAND_SIZE_BY_SPREAD for complete).
draw cooldown window (existing active draw)."""
Returns:
200 `{ok, next_free_draw_at, hand_complete}` on success
400 malformed payload or no sig
409 spread differs from the user's already-active draw's spread
(the spread is locked at first-card moment; can't switch mid-
draw via a sneaky POST)
"""
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -272,10 +304,26 @@ def my_sea_lock(request):
hand = payload.get("hand") hand = payload.get("hand")
if not spread or not isinstance(hand, list) or not hand: if not spread or not isinstance(hand, list) or not hand:
return JsonResponse({"error": "spread_and_hand_required"}, status=400) return JsonResponse({"error": "spread_and_hand_required"}, status=400)
if spread not in HAND_SIZE_BY_SPREAD:
return JsonResponse({"error": "unknown_spread"}, status=400)
if active_draw_for(request.user) is not None: existing = active_draw_for(request.user)
return JsonResponse({"error": "quota_active"}, status=409) if existing is not None:
# Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route
# through the gatekeeper but the endpoint stays permissive here).
# Spread-switch attempts get 409 — the spread is committed at
# first-card moment.
if existing.spread != spread:
return JsonResponse({"error": "spread_mismatch"}, status=409)
existing.hand = hand
existing.save(update_fields=["hand"])
return JsonResponse({
"ok": True,
"next_free_draw_at": existing.next_free_draw_at.isoformat(),
"hand_complete": existing.is_hand_complete,
})
# First card draw → quota commit. Create the row.
sig_id = request.user.significator_id sig_id = request.user.significator_id
if sig_id is None: if sig_id is None:
return JsonResponse({"error": "no_significator"}, status=400) return JsonResponse({"error": "no_significator"}, status=400)
@@ -290,18 +338,35 @@ def my_sea_lock(request):
return JsonResponse({ return JsonResponse({
"ok": True, "ok": True,
"next_free_draw_at": draw.next_free_draw_at.isoformat(), "next_free_draw_at": draw.next_free_draw_at.isoformat(),
"hand_complete": draw.is_hand_complete,
}) })
@login_required(login_url="/") @login_required(login_url="/")
@require_POST @require_POST
def my_sea_delete(request): def my_sea_delete(request):
"""Delete the user's active draw — invoked by the DEL guard portal's """Clear the user's active draw hand — preserves the `MySeaDraw` row
CONFIRM. Idempotent: a second call w. no active draw is a 204.""" so the 24h quota window keeps running. Per user spec (2026-05-20):
MySeaDraw.objects.filter(user=request.user).delete() DEL doesn't refund the daily free-draw; the row stays as a quota
tracker until 24h elapse, after which `active_draw_for`'s lazy
cleanup reaps it (or the `delete_stale_my_sea_draws` mgmt cmd does).
Idempotent: re-firing on a row w. already-empty hand is a no-op."""
draw = active_draw_for(request.user)
if draw is not None:
draw.hand = []
draw.save(update_fields=["hand"])
return HttpResponse(status=204) return HttpResponse(status=204)
@login_required(login_url="/")
def my_sea_gate(request):
"""Stub for the Sprint 6 gatekeeper. Renders a 404 for now — the
button-target placeholder lets the template's GATE VIEW UX wire up
in advance; Sprint 6 will replace this w. the token-deposit flow."""
return HttpResponse(status=404)
def _my_sea_deck_data(user, exclude_id=None): def _my_sea_deck_data(user, exclude_id=None):
"""Build the shuffled deck (levity + gravity halves) for the my-sea """Build the shuffled deck (levity + gravity halves) for the my-sea
picker's card-draw mechanic. Card payload shape is whatever picker's card-draw mechanic. Card payload shape is whatever

View File

@@ -799,100 +799,78 @@ class MySeaCardDrawTest(FunctionalTest):
# ── Test 5 ─────────────────────────────────────────────────────────────── # ── Test 5 ───────────────────────────────────────────────────────────────
def test_lock_hand_enables_when_sao_hand_is_complete(self): def test_action_btn_transitions_to_gate_view_on_hand_complete(self):
"""LOCK HAND starts disabled; flips to enabled once all 3 SAO """Iter-4c — the action btn (`#id_sea_action_btn`) starts as AUTO
positions are drawn (hand-size = 3 for any three-card spread).""" DRAW (`data-state="auto-draw"`); when the final card lands, JS
transitions it to GATE VIEW (`data-state="gate-view"`, label =
"GATE VIEW")."""
picker = self._enter_picker_phase() picker = self._enter_picker_phase()
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand") action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
self.assertEqual(lock.get_attribute("disabled"), "true") self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
self.assertIn("AUTO", action_btn.text.upper())
self._draw_one(picker, "levity") self._draw_one(picker, "levity")
self._draw_one(picker, "levity") self._draw_one(picker, "levity")
# Two draws — still disabled.
self.assertEqual(lock.get_attribute("disabled"), "true")
self._draw_one(picker, "gravity") self._draw_one(picker, "gravity")
# Third draw completes the SAO hand — LOCK HAND enables. # Third draw completes the SAO hand — action btn becomes GATE VIEW.
self.wait_for( self.wait_for(
lambda: self.assertIsNone(lock.get_attribute("disabled")) lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view")
) )
self.assertIn("GATE", action_btn.text.upper())
# ── Test 6 ─────────────────────────────────────────────────────────────── # ── Test 6 ───────────────────────────────────────────────────────────────
def test_del_click_resets_hand_and_disables_lock_hand(self): def test_del_btn_is_disabled_until_hand_complete(self):
"""DEL fully resets — every filled slot returns to `--empty`, """Iter-4c — DEL btn renders `.btn-disabled` server-side until
labels re-render, _filled counter zeros, LOCK HAND disables.""" the hand is complete (per spec: the 24h free-draw quota is
committed at first-card-draw, can't be refunded by an early
DEL). Once the hand fills, JS removes `.btn-disabled` from DEL."""
picker = self._enter_picker_phase() picker = self._enter_picker_phase()
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
self._draw_one(picker, "levity")
# Mid-draw — still disabled.
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
self._draw_one(picker, "levity") self._draw_one(picker, "levity")
self._draw_one(picker, "gravity") self._draw_one(picker, "gravity")
self.assertEqual( # Hand complete — DEL un-disables (clicking now opens guard portal).
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
2,
)
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
delbtn.click()
self.wait_for( self.wait_for(
lambda: self.assertEqual( lambda: self.assertNotIn("btn-disabled", delbtn.get_attribute("class"))
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
0,
)
) )
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
self.assertEqual(lock.get_attribute("disabled"), "true")
# ── Test 7 ─────────────────────────────────────────────────────────────── # ── Test 7 ───────────────────────────────────────────────────────────────
def test_lock_hand_click_disables_further_interaction(self): def test_hand_completion_locks_picker_state(self):
"""After LOCK HAND fires, deck swatches + DEL btn + LOCK HAND """Iter-4c — when the final card lands (manual or AUTO DRAW),
itself all carry the `.btn-disabled` class so the hand can't the picker gains `.my-sea-picker--locked`; further deck-stack
be mutated further. Persistence (POST to a server endpoint) clicks still SHOW the FLIP btn (so the user can see why no
defers to iter 4b — this test pins only the visual lock.""" further drawing is allowed) but the FLIP carries `.btn-disabled`
+ cards no longer fire on its click. No discrete LOCK HAND
action; the transition is automatic on hand-completion."""
picker = self._enter_picker_phase() picker = self._enter_picker_phase()
self._draw_one(picker, "levity") self._draw_one(picker, "levity")
self._draw_one(picker, "levity") self._draw_one(picker, "levity")
self._draw_one(picker, "gravity") self._draw_one(picker, "gravity")
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
self.wait_for(
lambda: self.assertIsNone(lock.get_attribute("disabled"))
)
lock.click()
# Picker carries a .my-sea-picker--locked class after LOCK HAND.
self.wait_for( self.wait_for(
lambda: self.browser.find_element( lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked" By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked"
) )
) )
# Swatches no longer respond — clicking them does nothing.
gravity_stack = picker.find_element(
By.CSS_SELECTOR, ".sea-deck-stack--gravity"
)
self.assertIn("btn-disabled", gravity_stack.get_attribute("class"))
# ── Test 8 ─────────────────────────────────────────────────────────────── # ── Test 8 ───────────────────────────────────────────────────────────────
def test_first_draw_locks_spread_combobox(self): def test_first_draw_locks_spread_combobox(self):
"""Per the iter-4a follow-up spec lock (2026-05-19): once the """Iter-4c — once the first card lands, the SPREAD combobox
first card lands, the SPREAD combobox carries `.sea-select-- carries `.sea-select--locked` for the rest of the quota window.
locked` so mid-draw spread switching is prevented (it would The spread is committed at first-card moment (server-side too:
scramble the position→card mapping). DEL releases the lock. any later POST w. a different spread → 409); no client-side
unlock path. (Iter-4a had DEL release the lock; iter-4c made DEL
Was previously `test_switching_spread_resets_in_progress_hand` `.btn-disabled` pre-completion → no reset pathway.)"""
— that test's premise (mid-draw spread switch resets hand) is
obsolete now that switching is blocked outright."""
picker = self._enter_picker_phase() picker = self._enter_picker_phase()
self._draw_one(picker, "levity") self._draw_one(picker, "levity")
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]") combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
self.wait_for( self.wait_for(
lambda: self.assertIn("sea-select--locked", combo.get_attribute("class")) lambda: self.assertIn("sea-select--locked", combo.get_attribute("class"))
) )
# DEL unlocks (mirrors `_resetHand` calling `_unlockSpread`).
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
delbtn.click()
self.wait_for(
lambda: self.assertNotIn("sea-select--locked", combo.get_attribute("class"))
)
# ── Test 4 ─────────────────────────────────────────────────────────────── # ── Test 4 ───────────────────────────────────────────────────────────────
@@ -910,14 +888,18 @@ class MySeaCardDrawTest(FunctionalTest):
) )
self.assertIn("GRAVITY", names) self.assertIn("GRAVITY", names)
self.assertIn("LEVITY", names) self.assertIn("LEVITY", names)
# LOCK HAND + DEL # Iter-4c — action btn (AUTO DRAW / GATE VIEW slot) + DEL.
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand") action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
self.assertIn("LOCK", lock.text.upper()) self.assertIn("AUTO", action_btn.text.upper())
self.assertIn("HAND", lock.text.upper()) self.assertIn("btn-primary", action_btn.get_attribute("class"))
self.assertIn("btn-primary", lock.get_attribute("class")) self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del") delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
self.assertIn("DEL", delbtn.text.upper()) # DEL renders w. `.btn-disabled` pre-completion (the `×` overlay
# is CSS-only; raw text content is still "DEL" in the DOM).
# Assert on class state — `.text` returns the visible glyph
# rendered by the pseudo-element layer.
self.assertIn("btn-danger", delbtn.get_attribute("class")) self.assertIn("btn-danger", delbtn.get_attribute("class"))
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
# Reversal % caption — default 25 # Reversal % caption — default 25
hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint") hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
self.assertIn("25", hint.text) self.assertIn("25", hint.text)
@@ -1157,11 +1139,13 @@ class MySeaLockHandTest(FunctionalTest):
# ── Test 5 ─────────────────────────────────────────────────────────────── # ── Test 5 ───────────────────────────────────────────────────────────────
def test_del_confirm_clears_saved_draw_and_returns_to_landing(self): def test_del_confirm_clears_hand_and_returns_to_gate_view_landing(self):
"""Clicking the portal's OK (`.guard-yes`) POSTs to the delete """Iter-4c semantics: clicking the portal's OK (`.guard-yes`)
endpoint → server wipes the MySeaDraw row → reload lands on the POSTs to the delete endpoint → server CLEARS the hand JSON but
FREE DRAW landing again (no saved hand, no Brief banner, FREE preserves the MySeaDraw row (quota tracker stays running for the
DRAW btn present).""" 24h window). Reload lands on the table-hex landing — but the
primary nav btn is GATE VIEW (`#id_my_sea_gate_view_btn`), NOT
FREE DRAW, since the quota's spent until the row expires."""
from apps.gameboard.models import MySeaDraw from apps.gameboard.models import MySeaDraw
self._save_draw_for_user() self._save_draw_for_user()
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 1) self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 1)
@@ -1184,4 +1168,13 @@ class MySeaLockHandTest(FunctionalTest):
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']" By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
) )
) )
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 0) # Row preserved as quota tracker; hand wiped.
rows = MySeaDraw.objects.filter(user=self.gamer)
self.assertEqual(rows.count(), 1)
self.assertEqual(rows.first().hand, [])
# Landing renders GATE VIEW (not FREE DRAW) per iter-4c spec.
self.browser.find_element(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
0,
)

View File

@@ -515,7 +515,7 @@
pointer-events: none; pointer-events: none;
font-size: 1.2rem; font-size: 1.2rem;
padding-bottom: 0.1rem; padding-bottom: 0.1rem;
color: rgba(var(--secUser), 0.25) !important; color: transparent !important; // hide native text
background-color: rgba(var(--priUser), 1) !important; background-color: rgba(var(--priUser), 1) !important;
border-color: rgba(var(--secUser), 0.25) !important; border-color: rgba(var(--secUser), 0.25) !important;
box-shadow: box-shadow:
@@ -523,6 +523,28 @@
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--secUser), 0.12) 0.25rem 0.25rem 0.25rem rgba(var(--secUser), 0.12)
; ;
position: relative;
// Universal × overlay — any `.btn-disabled` button reads as ×
// regardless of its native inner text/icons (DEL → ×, FLIP → ×,
// LOCK HAND → ×, etc.). Templates that already render `&times;`
// explicitly (e.g. don/doff toggle pairs) just have their inner
// × hidden by `visibility: hidden` on children; the pseudo's
// glyph is the only one visible — no double-× regression. User
// spec 2026-05-20.
> * { visibility: hidden; }
&::before {
content: "\00d7";
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(var(--secUser), 0.5);
font-size: 1.5rem;
font-weight: bold;
}
&:hover { &:hover {
text-shadow: text-shadow:

View File

@@ -915,7 +915,8 @@ html:has(.sig-backdrop) {
// data-polarity lives on the page wrapper (not on .my-sign-stage) so descendant // data-polarity lives on the page wrapper (not on .my-sign-stage) so descendant
// `.sig-card` (in the grid, sibling to the stage) inherits the rules. // `.sig-card` (in the grid, sibling to the stage) inherits the rules.
.sig-overlay[data-polarity="levity"], .sig-overlay[data-polarity="levity"],
.my-sign-page[data-polarity="levity"] { .my-sign-page[data-polarity="levity"],
.my-sea-page[data-polarity="levity"] {
// Mini card: inverted palette. game-kit sets explicit colours on .fan-card-name // Mini card: inverted palette. game-kit sets explicit colours on .fan-card-name
// and .fan-card-corner that out-specifc the parent color, so re-target them here. // and .fan-card-corner that out-specifc the parent color, so re-target them here.
.sig-card { .sig-card {
@@ -969,7 +970,8 @@ html:has(.sig-backdrop) {
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements) // Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
} }
.sig-overlay[data-polarity="gravity"], .sig-overlay[data-polarity="gravity"],
.my-sign-page[data-polarity="gravity"] { .my-sign-page[data-polarity="gravity"],
.my-sea-page[data-polarity="gravity"] {
// Stat block: invert priUser/secUser so gravity gets the same stark contrast as leavened cards // Stat block: invert priUser/secUser so gravity gets the same stark contrast as leavened cards
.sig-stat-block { .sig-stat-block {
background: rgba(var(--secUser), 0.75); background: rgba(var(--secUser), 0.75);

View File

@@ -276,6 +276,15 @@ body.page-gameboard {
background: rgba(var(--duoUser), 1); background: rgba(var(--duoUser), 1);
} }
// Landing phase bg — explicit `--priUser` revert per user spec
// (2026-05-20). The hex INTERIOR is `--duoUser` (set on `.table-hex`
// in _room.scss); the aperture AROUND the hex should be the default
// body color. Defensive override so any bf-cache / stale-CSS state
// can't leak the picker-phase green bg onto a landing render.
.my-sea-page[data-phase="landing"] {
background: rgba(var(--priUser), 1);
}
.my-sea-picker { .my-sea-picker {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View File

@@ -5,7 +5,9 @@
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %} {% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
{% block content %} {% 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 %} {% if not user_has_sig %}
{# Sprint 4b sign-gate. The draw UX is gated behind a saved #} {# Sprint 4b sign-gate. The draw UX is gated behind a saved #}
{# significator — render a Look!-formatted Brief-style line w. #} {# significator — render a Look!-formatted Brief-style line w. #}
@@ -24,15 +26,20 @@
</div> </div>
</div> </div>
{% else %} {% else %}
{% if not active_draw %} {% if not show_picker %}
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #} {# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
{# the room shell (.room-shell > .room-table > … > .table-hex) #} {# the room shell (.room-shell > .room-table > … > .table-hex) #}
{# w. 6 chair seats labeled 1C-6C as placeholders for the #} {# w. 6 chair seats labeled 1C-6C as placeholders for the #}
{# friend-invite feature per the My Sea roadmap architectural #} {# friend-invite feature per the My Sea roadmap architectural #}
{# anchor "Six chairs retained even in solo". DRAW SEA btn #} {# anchor "Six chairs retained even in solo". #}
{# mirrors SCAN SIGN on /billboard/my-sign/. Suppressed when #} {# #}
{# an active draw exists (iter 4b)the picker phase is the #} {# Iter 4clanding primary btn is: #}
{# landing state once the user has spent their free quota. #} {# • 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="my-sea-landing">
<div class="room-shell"> <div class="room-shell">
<div id="id_game_table" class="room-table"> <div id="id_game_table" class="room-table">
@@ -40,13 +47,14 @@
<div class="table-hex-border"> <div class="table-hex-border">
<div class="table-hex"> <div class="table-hex">
<div class="table-center"> <div class="table-center">
{# Sprint 5 iter 1 — FREE DRAW = the 1/24hr free-quota draw. #} {% if quota_spent %}
{# Future sprint will conditionally swap this for a DRAW SEA #} <button id="id_my_sea_gate_view_btn"
{# .btn-primary that calls the gatekeeper partial once the #} type="button"
{# free daily has been used; until then the btn renders FREE #} class="btn btn-primary"
{# DRAW. ID retained as `id_draw_sea_btn` (intent: the draw #} data-gate-url="{% url 'my_sea_gate' %}">GATE<br>VIEW</button>
{# entry point) so the swap is label-only when iter 6+ lands. #} {% else %}
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button> <button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -86,7 +94,7 @@
{# exist in one DOM). FLIP click delegates to SeaDeal. #} {# exist in one DOM). FLIP click delegates to SeaDeal. #}
{# openStage(), which fills the slot AND opens the portaled #} {# openStage(), which fills the slot AND opens the portaled #}
{# stage modal w. SPIN / FYI controls. #} {# 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-cards-col">
<div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}"> <div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}">
{# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #} {# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #}
@@ -190,10 +198,26 @@
</div> </div>
<div class="sea-form-actions"> <div class="sea-form-actions">
<button type="button" id="id_sea_lock_hand" class="btn btn-primary" disabled> {# Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) #}
LOCK HAND {# / GATE VIEW (hand complete). Same button slot; JS #}
</button> {# transitions label + behavior + `data-state` when #}
<button type="button" id="id_sea_del" class="btn btn-danger"> {# 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 DEL
</button> </button>
</div> </div>
@@ -252,7 +276,7 @@
var hidden = document.getElementById('id_sea_spread'); var hidden = document.getElementById('id_sea_spread');
var cross = document.querySelector('.my-sea-cross'); var cross = document.querySelector('.my-sea-cross');
var picker = document.querySelector('.my-sea-picker'); 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 delBtn = document.getElementById('id_sea_del');
var deckEl = document.getElementById('id_my_sea_deck'); var deckEl = document.getElementById('id_my_sea_deck');
var seaSelect = document.querySelector('[data-combobox][data-combobox-target="id_sea_spread"]'); var seaSelect = document.querySelector('[data-combobox][data-combobox-target="id_sea_spread"]');
@@ -373,6 +397,10 @@
} }
function _resetHand() { 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; _filled = 0;
_hideOk(); _hideOk();
_unlockSpread(); _unlockSpread();
@@ -384,37 +412,35 @@
'.sea-pos-cover, .sea-pos-cross' '.sea-pos-cover, .sea-pos-cross'
).forEach(_emptySlot); ).forEach(_emptySlot);
_reshuffleDeck(); _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) { if (window.SeaDeal && window.SeaDeal.resetHand) {
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; _locked = on;
picker.classList.toggle('my-sea-picker--locked', on); picker.classList.toggle('my-sea-picker--locked', on);
// Decks + LOCK HAND go disabled; DEL stays interactive if (delBtn) delBtn.classList.toggle('btn-disabled', !on);
// — it's the user's escape from the locked state if (actionBtn) {
// (opens the guard portal in iter 4b, resets the hand actionBtn.dataset.state = on ? 'gate-view' : 'auto-draw';
// in iter 4a). Without this exemption the SCSS rule actionBtn.innerHTML = on ? 'GATE<br>VIEW' : 'AUTO<br>DRAW';
// `.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);
});
_hideOk(); _hideOk();
} }
// ── Deck-stack click → show FLIP → click FLIP → deposit ─ // ── 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) { picker.querySelectorAll('.sea-deck-stack').forEach(function (stack) {
stack.addEventListener('click', function (e) { stack.addEventListener('click', function (e) {
if (_locked) return;
e.stopPropagation(); e.stopPropagation();
if (_activeStack === stack) _hideOk(); if (_activeStack === stack) _hideOk();
else _showOk(stack); else _showOk(stack);
@@ -423,68 +449,149 @@
if (ok) { if (ok) {
ok.style.display = 'none'; ok.style.display = 'none';
ok.addEventListener('click', function (e) { ok.addEventListener('click', function (e) {
if (_locked) return;
e.stopPropagation(); e.stopPropagation();
// Hand complete → FLIP is disabled. No draw.
if (_locked) { _hideOk(); return; }
var isLevity = stack.classList.contains('sea-deck-stack--levity'); var isLevity = stack.classList.contains('sea-deck-stack--levity');
var pile = isLevity ? _levityPile : _gravityPile; var pile = isLevity ? _levityPile : _gravityPile;
var card = pile.length ? pile.shift() : null; var card = pile.length ? pile.shift() : null;
var order = _currentOrder(); var order = _currentOrder();
var posName = order[_filled]; var posName = order[_filled];
if (card && posName) { 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) { if (window.SeaDeal && window.SeaDeal.openStage) {
SeaDeal.openStage(card, '.sea-pos-' + posName, isLevity); SeaDeal.openStage(card, '.sea-pos-' + posName, isLevity);
} else { } 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); _fillSlot(posName, card, isLevity);
} }
_filled++; _filled++;
// First deposit locks the SPREAD combobox —
// switching mid-draw would scramble the
// in-progress hand's position mapping.
if (_filled === 1) _lockSpread(); 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(); _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. // Click elsewhere inside the picker dismisses the FLIP btn.
picker.addEventListener('click', _hideOk); picker.addEventListener('click', _hideOk);
// ── DEL semantics differ by lock state ────────────────── // ── DEL: post-completion only (server-rendered `.btn-
// Pre-lock: DEL resets the in-progress hand client-side // disabled` pre-completion gates pre-hand DEL attempts).
// (iter 4a behaviour — no server round-trip). // Opens the shared `#id_guard_portal` from base.html;
// Post-lock: DEL invokes the shared `#id_guard_portal` // CONFIRM POSTs to /gameboard/my-sea/delete which clears
// from base.html via `window.showGuard`, w. a // the hand but preserves the row (quota stays committed
// "DEL" YES-label override (the room's gear- // for the 24h window). Server returns 204; we reload to
// menu DEL flow uses the same portal). On YES // land on the GATE VIEW landing (FREE DRAW is gone until
// we POST to /gameboard/my-sea/delete then // the row expires).
// navigate back to the page (server returns
// 204; we redirect manually to land on the
// FREE DRAW landing).
if (delBtn) { if (delBtn) {
delBtn.addEventListener('click', function (e) { delBtn.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
if (!_locked) { if (delBtn.classList.contains('btn-disabled')) return;
_resetHand();
return;
}
if (!window.showGuard) 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( window.showGuard(
delBtn, delBtn,
'Are you sure?', 'Are you sure?',
@@ -500,29 +607,28 @@
); );
}); });
} }
if (lockBtn) {
lockBtn.addEventListener('click', function () { // ── Action btn: AUTO DRAW (pre-complete) ↔ GATE VIEW
if (lockBtn.disabled || _locked) return; // (post-complete). Same DOM node, label + behavior keyed
// Collect the in-progress hand for the POST. Slot // on `data-state`. AUTO DRAW opens a guard portal "Auto
// class includes `--levity` / `--gravity`; reversed // deal cards?"; on OK, fills remaining slots client-side
// is on `.sea-card-slot--reversed`. Position comes // + commits to server in one POST. GATE VIEW navigates
// from `data-pos-key`. Card id from `data-card-id`. // to the Sprint-6 gatekeeper (currently 404 stub).
var hand = []; if (actionBtn) {
cross.querySelectorAll( actionBtn.addEventListener('click', function (e) {
'.sea-card-slot.sea-card-slot--filled' e.preventDefault();
).forEach(function (slot) { var state = actionBtn.dataset.state;
var cls = slot.className; if (state === 'gate-view') {
hand.push({ window.location.href = actionBtn.dataset.gateUrl || '#';
position: slot.dataset.posKey || '', return;
card_id: parseInt(slot.dataset.cardId, 10), }
reversed: /sea-card-slot--reversed\b/.test(cls), // AUTO DRAW
polarity: /sea-card-slot--levity\b/.test(cls) ? 'levity' : 'gravity', if (!window.showGuard) return;
}); window.showGuard(
}); actionBtn,
var order = _currentOrder(); 'Auto deal cards?',
if (hand.length < order.length) return; _autoDraw
_setLocked(true); );
_postLock(hand);
}); });
} }
@@ -587,7 +693,13 @@
} }
}; };
function _postLock(hand) { 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', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
@@ -601,9 +713,11 @@
}).then(function (r) { }).then(function (r) {
return r.ok ? r.json() : null; return r.ok ? r.json() : null;
}).then(function (body) { }).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); window._showFreeDrawLockedBrief(body.next_free_draw_at);
} }
return body;
}); });
} }
@@ -623,20 +737,24 @@
} }
hidden.addEventListener('change', sync); hidden.addEventListener('change', sync);
// Initial state — labels already server-rendered for the // Initial state — iter-4c restoration of partial / complete
// default spread. If the picker was server-rendered w. a // saved-hand sessions. Server pre-renders the slots from
// locked saved hand (iter 4b's active_draw branch), the // saved_by_position; count the filled slots to seed _filled.
// .my-sea-picker--locked class is already on the page — // If hand is complete (server-rendered `.my-sea-picker--
// mirror it in JS state so further interactions stay // locked`), call `_setComplete(true)` to align JS state
// disabled + DEL routes to the guard portal. // (decks click-responsive but FLIP arrives disabled; DEL
_filled = 0; // enabled; action btn = GATE VIEW). Mid-draw saved hands
var _preLocked = picker.classList.contains('my-sea-picker--locked'); // just need the count + spread lock (the user resumes
if (_preLocked) { // drawing from where they left off).
_filled = _currentOrder().length; _filled = cross.querySelectorAll(
_setLocked(true); '.sea-card-slot.sea-card-slot--filled'
).length;
if (_filled >= _currentOrder().length) {
_setComplete(true);
_lockSpread();
} else if (_filled > 0) {
_lockSpread(); _lockSpread();
} }
if (lockBtn) lockBtn.disabled = (_filled < _currentOrder().length);
// Belt-and-braces autofill defense (paired w. autocomplete= // Belt-and-braces autofill defense (paired w. autocomplete=
// off on the hidden input above). Firefox occasionally // off on the hidden input above). Firefox occasionally
@@ -709,6 +827,15 @@
}, SEAT_ANIM_MS); }, 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 // Mirror my-sign's scaleTable() init timing fix — the
// .my-sea-page hasn't flushed its flex sizing on // .my-sea-page hasn't flushed its flex sizing on
// DOMContentLoaded, so the hex stays unscaled until we // DOMContentLoaded, so the hex stays unscaled until we