My Sea iter 6b: navbar GATE VIEW swap on page-my-sea + landing PAID DRAW state + seat-1 server-render + auto-token IT trap in gatekeeper FT — Sprint 5 iter 6b of My Sea roadmap — TDD
Second of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Wires the always-reachable navbar gate-entry, completes the landing center-btn 3-way state machine (FREE DRAW / GATE VIEW / PAID DRAW), and lifts seat-1's `.seated` state from JS-only to server-rendered (reload-stable).
## Navbar GATE VIEW swap
`templates/core/_partials/_navbar.html` — when `'page-my-sea' in page_class`, CONT GAME swaps for `#id_navbar_gate_view_btn` (`.btn-primary`, plain `<button>` w. inline onclick navigation). Reaches the gatekeeper at any quota state — no confirm guard (non-destructive nav).
**Typeface trap caught (user 2026-05-20 visual report)**: first cut used `<a>` for GATE VIEW, which UA-renders serif while `<button>` stays sans-serif (`.btn` doesn't reset `font-family`). Same fix pattern as iter-4c's in-hex GATE VIEW: always use `<button>`. Second cut used a form-wrapped `<button>` w. `display:contents`; the form was correctly invisible in layout but broke the landscape `> #id_cont_game { order: -1 }` direct-child SCSS pin (form became the direct child, not the button). Final cut: plain `<button>` w. `onclick="window.location.href=..."`, no form, no anchor — direct flex child of `.container-fluid` so the SCSS pin matches.
`_base.scss` — paired `> #id_navbar_gate_view_btn` alongside `> #id_cont_game` in both portrait (line 93) + landscape (line 309) rules so GATE VIEW occupies the same top-center navbar slot CONT GAME does (above brand, `order: -1`).
## Landing center-btn 3-way state machine
`my_sea` view gains `deposit_reserved` (active_draw has deposit_token_id) + `hand_non_empty` context vars.
`my_sea.html` landing branches:
- `deposit_reserved` → **PAID DRAW** form (POSTs to `my_sea_paid_draw`); fastest path back to picker w. one click — no gatekeeper round-trip.
- `quota_spent and not deposit_reserved` → **GATE VIEW** (existing iter-4c btn, navigates to gatekeeper).
- else → **FREE DRAW** (existing iter-1 btn).
Three branches are mutually exclusive — FT asserts only one of `#id_my_sea_paid_draw_btn` / `#id_my_sea_gate_view_btn` / `#id_draw_sea_btn` renders at a time.
## Seat-1 server-render
`my_sea.html` table-seat 1 now picks up `.seated` + `.fa-circle-check` (instead of `.fa-ban`) when `hand_non_empty`. Other 5 seats stay banned (placeholders for the future friend-invite feature; only owner ever occupies seat 1 in solo my-sea). Reloads no longer lose the chair-styling state — existing JS animation (FREE DRAW click → flip seat to seated) still fires on first draw.
In practice today the landing only renders when hand IS empty (show_picker hides landing once hand has cards), so the `.seated` branch isn't actually visible in iter 6b. Defensive code for future surfaces (any hex render w. hand non-empty) per [[sprint-my-sea-iter-6-plan]] §Seat-1 persistence.
## FT delta
**Replaced** `MySeaGatekeeperPageTest.test_gatekeeper_renders_six_chair_seats_with_seat1_seated` w. `test_gatekeeper_renders_no_hex_modal_only`. The iter-6a FT skeleton was written before the user's "no hex on gatekeeper" spec (2026-05-20) — seats now live ONLY on the my-sea picker page; the gatekeeper is a transient `.gate-modal` overlay w. no hex / chair-seats.
**Trap caught**: `MySeaGatekeeperPageTest.test_paid_draw_commits_token_and_redirects_to_picker` was passing in iter 6a only because it didn't actually exist in CI then; running it locally exposed the IT-trap pattern: User post_save signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`), so `_select_my_sea_token` picks the auto-COIN (PASS > **COIN** > FREE > TITHE) instead of the manually-seeded FREE. Test asserted FREE count drops by 1 → fails because COIN was actually debited (sets cooldown, doesn't delete the token). Same trap as the iter-6a IT memo; fix is identical: `self.gamer.tokens.all().delete()` after User.create + then seed only the token the test cares about.
## Tests
- 4 MySeaGatekeeperPageTest (iter 6a, now passing) + 1 MySeaLandingPaidDrawTest + 1 MySeaNavbarGateViewTest + 2 MySeaSeatOnePersistenceTest = 8 FTs green in 84s.
- All 7 `test_core_navbar` FTs (NavbarByeTest + NavbarContGameTest) still green — landscape order rule extension is additive; CONT GAME path unchanged.
- 153/153 gameboard ITs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -221,6 +221,15 @@ def my_sea(request):
|
|||||||
# standard FREE DRAW landing.
|
# standard FREE DRAW landing.
|
||||||
show_picker = active_draw is not None and not hand_empty
|
show_picker = active_draw is not None and not hand_empty
|
||||||
quota_spent = active_draw is not None # any active row = quota committed
|
quota_spent = active_draw is not None # any active row = quota committed
|
||||||
|
# Sprint 6 iter 6b — landing center-btn 3-way + seat-1 persistence.
|
||||||
|
# `deposit_reserved` toggles the landing primary from GATE VIEW to
|
||||||
|
# PAID DRAW (one-click commit of the already-deposited token).
|
||||||
|
# `hand_non_empty` lifts seat 1 to `.seated` server-side so reloads
|
||||||
|
# don't lose the JS-only animation state.
|
||||||
|
deposit_reserved = (
|
||||||
|
active_draw is not None and active_draw.deposit_token_id is not None
|
||||||
|
)
|
||||||
|
hand_non_empty = active_draw is not None and bool(active_draw.hand)
|
||||||
|
|
||||||
# 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
|
||||||
@@ -265,6 +274,8 @@ def my_sea(request):
|
|||||||
"hand_complete": hand_complete,
|
"hand_complete": hand_complete,
|
||||||
"show_picker": show_picker,
|
"show_picker": show_picker,
|
||||||
"quota_spent": quota_spent,
|
"quota_spent": quota_spent,
|
||||||
|
"deposit_reserved": deposit_reserved,
|
||||||
|
"hand_non_empty": hand_non_empty,
|
||||||
"page_class": "page-gameboard page-my-sea",
|
"page_class": "page-gameboard page-my-sea",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1200,6 +1200,12 @@ class MySeaGatekeeperPageTest(FunctionalTest):
|
|||||||
self.email = "gate@test.io"
|
self.email = "gate@test.io"
|
||||||
self.gamer = User.objects.create(email=self.email)
|
self.gamer = User.objects.create(email=self.email)
|
||||||
_assign_sig(self.gamer)
|
_assign_sig(self.gamer)
|
||||||
|
# The User post_save signal auto-creates COIN + FREE tokens
|
||||||
|
# (apps.lyric.models). Clear them so the deposit-priority test
|
||||||
|
# below picks only the FREE we seed explicitly — otherwise
|
||||||
|
# COIN wins (PASS > COIN > FREE > TITHE) and the FREE-count
|
||||||
|
# assertion fails (per IT-trap memo, 2026-05-19).
|
||||||
|
self.gamer.tokens.all().delete()
|
||||||
# Seed a FREE token so the gatekeeper has something to deposit.
|
# Seed a FREE token so the gatekeeper has something to deposit.
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.utils import timezone as dj_tz
|
from django.utils import timezone as dj_tz
|
||||||
@@ -1245,27 +1251,27 @@ class MySeaGatekeeperPageTest(FunctionalTest):
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_gatekeeper_renders_six_chair_seats_with_seat1_seated(self):
|
def test_gatekeeper_renders_no_hex_modal_only(self):
|
||||||
"""Hex w. 6 chair seats; seat 1 is the owner's (always `.seated`
|
"""Per user spec 2026-05-20, the gatekeeper is a transient in-flight
|
||||||
when quota is committed); seats 2-6 carry `.fa-ban` (placeholders
|
UI — modal-over-`--duoUser` bg, NO hex / chair-seats. Seats live on
|
||||||
for the future friend-invite feature)."""
|
the my-sea picker page itself; the gatekeeper just offers the
|
||||||
|
coin-slot + (post-deposit) PAID DRAW affordance."""
|
||||||
self._save_empty_hand_draw()
|
self._save_empty_hand_draw()
|
||||||
self.create_pre_authenticated_session(self.email)
|
self.create_pre_authenticated_session(self.email)
|
||||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self._assert_seats(6)
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".my-sea-gate-modal"
|
||||||
)
|
)
|
||||||
seat1 = self.browser.find_element(
|
|
||||||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
|
||||||
)
|
)
|
||||||
self.assertIn("seated", seat1.get_attribute("class"))
|
self.assertEqual(
|
||||||
seat1.find_element(By.CSS_SELECTOR, ".fa-circle-check")
|
len(self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")),
|
||||||
|
0,
|
||||||
def _assert_seats(self, count):
|
)
|
||||||
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
|
self.assertEqual(
|
||||||
if len(seats) != count:
|
len(self.browser.find_elements(By.CSS_SELECTOR, ".table-hex")),
|
||||||
raise AssertionError(f"expected {count} seats, got {len(seats)}")
|
0,
|
||||||
return seats
|
)
|
||||||
|
|
||||||
def test_insert_token_reserves_deposit_and_reveals_paid_draw_btn(self):
|
def test_insert_token_reserves_deposit_and_reveals_paid_draw_btn(self):
|
||||||
"""Click INSERT TOKEN → server reserves the user's next-priority
|
"""Click INSERT TOKEN → server reserves the user's next-priority
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ body {
|
|||||||
> form { flex-shrink: 0; order: -1; } // BYE left of spans
|
> form { flex-shrink: 0; order: -1; } // BYE left of spans
|
||||||
}
|
}
|
||||||
|
|
||||||
> #id_cont_game { flex-shrink: 0; }
|
> #id_cont_game,
|
||||||
|
> #id_navbar_gate_view_btn { flex-shrink: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-text,
|
.navbar-text,
|
||||||
@@ -306,7 +307,8 @@ body {
|
|||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
margin: 0; // reset portrait margin-right: 0.5rem so container fills full sidebar width
|
margin: 0; // reset portrait margin-right: 0.5rem so container fills full sidebar width
|
||||||
|
|
||||||
> #id_cont_game { flex-shrink: 0; order: -1; } // cont-game above brand
|
> #id_cont_game,
|
||||||
|
> #id_navbar_gate_view_btn { flex-shrink: 0; order: -1; } // cont-game / GATE VIEW above brand
|
||||||
|
|
||||||
.navbar-user {
|
.navbar-user {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -33,13 +33,21 @@
|
|||||||
{# 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". #}
|
{# anchor "Six chairs retained even in solo". #}
|
||||||
{# #}
|
{# #}
|
||||||
{# Iter 4c — landing primary btn is: #}
|
{# Iter 6b — landing primary-btn state machine, 3-way: #}
|
||||||
{# • FREE DRAW (`#id_draw_sea_btn`) when the user has no #}
|
{# • FREE DRAW (`#id_draw_sea_btn`) when no active quota row #}
|
||||||
{# active quota row (fresh user OR >24h since last draw); #}
|
{# (fresh user OR >24h since last draw); #}
|
||||||
|
{# • PAID DRAW (`#id_my_sea_paid_draw_btn`) when the user has #}
|
||||||
|
{# deposited a token via the gatekeeper but hasn't yet #}
|
||||||
|
{# committed it; one-click POST commits + redirects to #}
|
||||||
|
{# fresh-quota /gameboard/my-sea/. #}
|
||||||
{# • GATE VIEW (`#id_my_sea_gate_view_btn`) when the user's #}
|
{# • GATE VIEW (`#id_my_sea_gate_view_btn`) when the user's #}
|
||||||
{# quota row exists but the hand is empty (post-DEL). The #}
|
{# quota row exists, the hand is empty, AND no deposit yet #}
|
||||||
{# daily free draw is spent; further draws require a token #}
|
{# (post-DEL state). Routes to the Sprint-6 gatekeeper. #}
|
||||||
{# deposit via the Sprint-6 gatekeeper (currently 404). #}
|
{# Seat 1 (`data-slot="1"`) carries `.seated` + `.fa-circle- #}
|
||||||
|
{# check` when the user's hand is non-empty (server-rendered #}
|
||||||
|
{# so reloads preserve the state). Otherwise `.fa-ban`. Other #}
|
||||||
|
{# 5 seats are placeholders for the future friend-invite #}
|
||||||
|
{# feature — always banned in solo my-sea. #}
|
||||||
<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">
|
||||||
@@ -47,7 +55,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">
|
||||||
{% if quota_spent %}
|
{% if deposit_reserved %}
|
||||||
|
<form method="POST" action="{% url 'my_sea_paid_draw' %}" style="display:contents">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit"
|
||||||
|
id="id_my_sea_paid_draw_btn"
|
||||||
|
class="btn btn-primary">PAID<br>DRAW</button>
|
||||||
|
</form>
|
||||||
|
{% elif quota_spent %}
|
||||||
<button id="id_my_sea_gate_view_btn"
|
<button id="id_my_sea_gate_view_btn"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@@ -66,10 +81,10 @@
|
|||||||
{# semantics clean. `.position-status-icon` + #}
|
{# semantics clean. `.position-status-icon` + #}
|
||||||
{# `.fa-ban` are unchanged — already role- #}
|
{# `.fa-ban` are unchanged — already role- #}
|
||||||
{# agnostic in _room.scss. #}
|
{# agnostic in _room.scss. #}
|
||||||
<div class="table-seat" data-slot="{{ n }}">
|
<div class="table-seat{% if n == '1' and hand_non_empty %} seated{% endif %}" data-slot="{{ n }}">
|
||||||
<i class="fa-solid fa-chair"></i>
|
<i class="fa-solid fa-chair"></i>
|
||||||
<span class="seat-position-label">{{ n }}C</span>
|
<span class="seat-position-label">{{ n }}C</span>
|
||||||
<i class="position-status-icon fa-solid fa-ban"></i>
|
<i class="position-status-icon fa-solid {% if n == '1' and hand_non_empty %}fa-circle-check{% else %}fa-ban{% endif %}"></i>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% if navbar_recent_room_url %}
|
{% if 'page-my-sea' in page_class %}
|
||||||
|
{# Sprint 6 iter 6b — on any my-sea page (landing/picker or #}
|
||||||
|
{# the gatekeeper itself), CONT GAME swaps for GATE VIEW so #}
|
||||||
|
{# the user can always reach the token-deposit gatekeeper #}
|
||||||
|
{# (regardless of quota state). `<button>` (not `<a>`) #}
|
||||||
|
{# because UA-default fonts differ + `.btn` doesn't reset #}
|
||||||
|
{# font-family — anchors render serif, buttons stay sans- #}
|
||||||
|
{# serif (locked via the in-hex GATE VIEW fix in iter 4c). #}
|
||||||
|
{# Direct child of `.container-fluid` (no form wrapper) so #}
|
||||||
|
{# the `> #id_navbar_gate_view_btn` SCSS pin (top-center in #}
|
||||||
|
{# landscape) matches; inline onclick handles navigation — #}
|
||||||
|
{# no confirm guard since GATE VIEW is non-destructive nav. #}
|
||||||
|
<button
|
||||||
|
id="id_navbar_gate_view_btn"
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
onclick="window.location.href='{% url 'my_sea_gate' %}'"
|
||||||
|
>
|
||||||
|
GATE<br>VIEW
|
||||||
|
</button>
|
||||||
|
{% elif navbar_recent_room_url %}
|
||||||
<button
|
<button
|
||||||
id="id_cont_game"
|
id="id_cont_game"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
|
|||||||
Reference in New Issue
Block a user