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
# 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):
"""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
persistence of the iter-4a client-side draw mechanic.
Quota: one row per user per `FREE_DRAW_COOLDOWN_HOURS` window
(24h, irrespective of spread type). Subsequent draws within the
window are intended to be gated behind a token deposit at the My
Sea gatekeeper, which Sprint 6 will build.
Sprint 5 iter 4c of [[project-my-sea-roadmap]] — refactor of the
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
committed at first-card moment + survives a DEL (DEL clears the
`hand` JSON but preserves the row — so `created_at` keeps running
the 24h clock + landing renders GATE VIEW instead of FREE DRAW
until the row expires).
`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:
{"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
at lock time so a subsequent `User.significator = None` (via my-sign
DEL) doesn't invalidate the saved draw — per user spec, preserve the
old sig; any future draw uses whatever sig is current at that time.
at first-card-draw time so a subsequent `User.significator = None`
(via my-sign DEL) doesn't invalidate the saved draw.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -60,16 +77,45 @@ class MySeaDraw(models.Model):
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):
"""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
and for gating LOCK HAND POSTs.
None. Single source of truth for "does the user have an active draw"
(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
caller keeps the 24h window a single-source-of-truth tied to
FREE_DRAW_COOLDOWN_HOURS."""
Lazy stale-row cleanup: every call prunes the user's >24h rows, so
the DB doesn't accumulate one row per user per day. The user-spec
'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)
MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete()
return MySeaDraw.objects.filter(
user=user, created_at__gte=cutoff,
).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="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"))
html = response.content.decode()
self.assertIn("sea-deck-stack--gravity", 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("sea-reversal-hint", html)
self.assertIn("25% reversals", html)
@@ -956,21 +962,77 @@ class MySeaLockHandViewTest(TestCase):
parsed = datetime.fromisoformat(body["next_free_draw_at"])
self.assertIsNotNone(parsed)
def test_lock_post_within_quota_window_returns_409(self):
# Second lock within 24h: the existing draw already occupies the
# quota; the server rejects rather than overwriting.
def test_lock_post_within_quota_upserts_same_row(self):
# Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
# 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
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.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",
)
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",
)
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):
import json
@@ -1006,7 +1068,8 @@ class MySeaLockHandViewTest(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):
from apps.epic.models import personal_sig_cards, TarotCard
@@ -1037,11 +1100,18 @@ class MySeaDeleteDrawViewTest(TestCase):
response = self.client.get(self.url)
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
original_created_at = self.draw.created_at
response = self.client.post(self.url)
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):
from apps.gameboard.models import MySeaDraw
@@ -1051,10 +1121,13 @@ class MySeaDeleteDrawViewTest(TestCase):
hand=self.draw.hand, significator_id=self.target.id,
)
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):
# 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)
response = self.client.post(self.url)
self.assertIn(response.status_code, (200, 204, 302))
@@ -1170,3 +1243,146 @@ class MySeaViewWithSavedDrawTest(TestCase):
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'id="id_draw_sea_btn"')
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/lock', views.my_sea_lock, name='my_sea_lock'),
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 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):
@@ -174,16 +174,24 @@ def toggle_game_kit_sections(request):
def my_sea(request):
"""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
w. saved hand + Brief banner + DEL guard portal. The draw's sig
snapshot is rendered (NOT user.significator) so a cleared sig
elsewhere doesn't invalidate the saved draw.
2. No active draw + no sig → Look!-formatted sign-gate (Sprint 4b).
3. No active draw + sig set → FREE DRAW landing (Sprint 5 iter 1) →
click swaps data-phase to picker for a fresh draw.
3a. + no equipped deck → also show backup-deck Brief banner.
1. No sig → Look!-formatted sign-gate (Sprint 4b).
2. Active draw, hand non-empty (mid-draw or complete) → picker phase
w. saved hand state. The DEL btn is server-rendered `.btn-
disabled` until hand is complete; AUTO DRAW becomes GATE VIEW on
completion. The draw's sig snapshot is rendered (NOT user.
significator) so a cleared sig elsewhere doesn't invalidate the
saved draw.
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)
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
@@ -194,10 +202,22 @@ def my_sea(request):
default_spread = active_draw.spread
saved_hand = active_draw.hand
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:
default_spread = "situation-action-outcome"
saved_hand = []
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
# ("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)
if user_has_sig else {"levity": [], "gravity": []}
),
# Iter 4b
# Iter 4b / 4c
"active_draw": active_draw,
"saved_hand": saved_hand,
"saved_by_position": saved_by_position,
"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",
})
@@ -257,12 +280,21 @@ def _resolve_sig(user, active_draw):
@login_required(login_url="/")
@require_POST
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,
polarity}, ...]}`. Returns 200 w. `{ok, next_free_draw_at}` on success,
400 for malformed payload, 409 if the user is still within the free-
draw cooldown window (existing active draw)."""
polarity}, ...]}` — `hand` is the current FULL state (partial OK
for mid-draw; sized to HAND_SIZE_BY_SPREAD for complete).
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:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
@@ -272,10 +304,26 @@ def my_sea_lock(request):
hand = payload.get("hand")
if not spread or not isinstance(hand, list) or not hand:
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:
return JsonResponse({"error": "quota_active"}, status=409)
existing = active_draw_for(request.user)
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
if sig_id is None:
return JsonResponse({"error": "no_significator"}, status=400)
@@ -290,18 +338,35 @@ def my_sea_lock(request):
return JsonResponse({
"ok": True,
"next_free_draw_at": draw.next_free_draw_at.isoformat(),
"hand_complete": draw.is_hand_complete,
})
@login_required(login_url="/")
@require_POST
def my_sea_delete(request):
"""Delete the user's active draw — invoked by the DEL guard portal's
CONFIRM. Idempotent: a second call w. no active draw is a 204."""
MySeaDraw.objects.filter(user=request.user).delete()
"""Clear the user's active draw hand — preserves the `MySeaDraw` row
so the 24h quota window keeps running. Per user spec (2026-05-20):
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)
@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):
"""Build the shuffled deck (levity + gravity halves) for the my-sea
picker's card-draw mechanic. Card payload shape is whatever

View File

@@ -799,100 +799,78 @@ class MySeaCardDrawTest(FunctionalTest):
# ── Test 5 ───────────────────────────────────────────────────────────────
def test_lock_hand_enables_when_sao_hand_is_complete(self):
"""LOCK HAND starts disabled; flips to enabled once all 3 SAO
positions are drawn (hand-size = 3 for any three-card spread)."""
def test_action_btn_transitions_to_gate_view_on_hand_complete(self):
"""Iter-4c — the action btn (`#id_sea_action_btn`) starts as AUTO
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()
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
self.assertEqual(lock.get_attribute("disabled"), "true")
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
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")
# Two draws — still disabled.
self.assertEqual(lock.get_attribute("disabled"), "true")
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(
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 ───────────────────────────────────────────────────────────────
def test_del_click_resets_hand_and_disables_lock_hand(self):
"""DEL fully resets — every filled slot returns to `--empty`,
labels re-render, _filled counter zeros, LOCK HAND disables."""
def test_del_btn_is_disabled_until_hand_complete(self):
"""Iter-4c — DEL btn renders `.btn-disabled` server-side until
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()
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, "gravity")
self.assertEqual(
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()
# Hand complete — DEL un-disables (clicking now opens guard portal).
self.wait_for(
lambda: self.assertEqual(
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
0,
)
lambda: self.assertNotIn("btn-disabled", delbtn.get_attribute("class"))
)
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
self.assertEqual(lock.get_attribute("disabled"), "true")
# ── Test 7 ───────────────────────────────────────────────────────────────
def test_lock_hand_click_disables_further_interaction(self):
"""After LOCK HAND fires, deck swatches + DEL btn + LOCK HAND
itself all carry the `.btn-disabled` class so the hand can't
be mutated further. Persistence (POST to a server endpoint)
defers to iter 4b — this test pins only the visual lock."""
def test_hand_completion_locks_picker_state(self):
"""Iter-4c — when the final card lands (manual or AUTO DRAW),
the picker gains `.my-sea-picker--locked`; further deck-stack
clicks still SHOW the FLIP btn (so the user can see why no
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()
self._draw_one(picker, "levity")
self._draw_one(picker, "levity")
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(
lambda: self.browser.find_element(
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 ───────────────────────────────────────────────────────────────
def test_first_draw_locks_spread_combobox(self):
"""Per the iter-4a follow-up spec lock (2026-05-19): once the
first card lands, the SPREAD combobox carries `.sea-select--
locked` so mid-draw spread switching is prevented (it would
scramble the position→card mapping). DEL releases the lock.
Was previously `test_switching_spread_resets_in_progress_hand`
— that test's premise (mid-draw spread switch resets hand) is
obsolete now that switching is blocked outright."""
"""Iter-4c — once the first card lands, the SPREAD combobox
carries `.sea-select--locked` for the rest of the quota window.
The spread is committed at first-card moment (server-side too:
any later POST w. a different spread → 409); no client-side
unlock path. (Iter-4a had DEL release the lock; iter-4c made DEL
`.btn-disabled` pre-completion → no reset pathway.)"""
picker = self._enter_picker_phase()
self._draw_one(picker, "levity")
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
self.wait_for(
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 ───────────────────────────────────────────────────────────────
@@ -910,14 +888,18 @@ class MySeaCardDrawTest(FunctionalTest):
)
self.assertIn("GRAVITY", names)
self.assertIn("LEVITY", names)
# LOCK HAND + DEL
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
self.assertIn("LOCK", lock.text.upper())
self.assertIn("HAND", lock.text.upper())
self.assertIn("btn-primary", lock.get_attribute("class"))
# Iter-4c — action btn (AUTO DRAW / GATE VIEW slot) + DEL.
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
self.assertIn("AUTO", action_btn.text.upper())
self.assertIn("btn-primary", action_btn.get_attribute("class"))
self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
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-disabled", delbtn.get_attribute("class"))
# Reversal % caption — default 25
hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
self.assertIn("25", hint.text)
@@ -1157,11 +1139,13 @@ class MySeaLockHandTest(FunctionalTest):
# ── Test 5 ───────────────────────────────────────────────────────────────
def test_del_confirm_clears_saved_draw_and_returns_to_landing(self):
"""Clicking the portal's OK (`.guard-yes`) POSTs to the delete
endpoint → server wipes the MySeaDraw row → reload lands on the
FREE DRAW landing again (no saved hand, no Brief banner, FREE
DRAW btn present)."""
def test_del_confirm_clears_hand_and_returns_to_gate_view_landing(self):
"""Iter-4c semantics: clicking the portal's OK (`.guard-yes`)
POSTs to the delete endpoint → server CLEARS the hand JSON but
preserves the MySeaDraw row (quota tracker stays running for the
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
self._save_draw_for_user()
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']"
)
)
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;
font-size: 1.2rem;
padding-bottom: 0.1rem;
color: rgba(var(--secUser), 0.25) !important;
color: transparent !important; // hide native text
background-color: rgba(var(--priUser), 1) !important;
border-color: rgba(var(--secUser), 0.25) !important;
box-shadow:
@@ -523,6 +523,28 @@
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
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 {
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
// `.sig-card` (in the grid, sibling to the stage) inherits the rules.
.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
// and .fan-card-corner that out-specifc the parent color, so re-target them here.
.sig-card {
@@ -969,7 +970,8 @@ html:has(.sig-backdrop) {
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
}
.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
.sig-stat-block {
background: rgba(var(--secUser), 0.75);

View File

@@ -276,6 +276,15 @@ body.page-gameboard {
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 {
flex: 1;
min-height: 0;

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