my-sea spectator: render owner's draw as the identical interactive cross stage; owner seated in 1C when paid OR drawn — TDD
Phase 1 + 2 of the my-sea spectator/voice batch (user-spec 2026-05-29). ── Phase 1: spectator VIEW DRAW parity ── The visitor's VIEW DRAW rendered _my_sea_readonly_draw.html — a flat `.my-sea-scroll` strip that, out of its applet context, blew a single card up to fill the viewport. It now renders the SAME `.my-sea-cross` picker + `_sea_stage` modal the owner sees, populated from the owner's draw, read-only but fully interactive (click card → magnified stage, hover, SPIN, FYI). No FLIP / DEL / AUTO DRAW / deck-stacks / spread combobox — the visitor watches. - `_saved_by_position(saved_hand)` extracted as a shared helper (owner picker + spectator render build the IDENTICAL cross); my_sea refactored onto it. - my_sea_visit context gains `saved_by_position`, `label_by_position`, `default_spread`, and the OWNER's `sea_deck_data` (so sea.js resolves each clicked slot's full card face for the stage). - new `_my_sea_visit_cross.html` mirrors the owner cross + includes `_sea_stage` under `#id_sea_overlay`; my_sea_visit.html embeds the owner deck JSON + loads stage-card.js + sea.js + a trimmed seed IIFE (reconstructs SeaDeal's `_seaHand` from the filled slots so each card is clickable into the stage). - deletes the obsolete `_my_sea_readonly_draw.html`. ── Phase 2: owner 1C seating ── The owner is "seated" in 1C whenever committed to a draw cycle — paid for one (deposit reserved / paid-through credit) OR partially/completely drawn — not only once a card lands. Previously a paid-but-undrawn owner (the PAID DRAW landing) and the visitor's view of her showed the semi-opaque `.fa-ban` default. Seat 1C now carries persistent `.seated` + `.fa-circle-check` (sync on refresh; the one-shot flare just settles into it). - my_sea: new `seat1_seated = hand_non_empty or show_paid_draw`; my_sea.html seat 1C keys on it (class + data-seat-token + status icon). - my_sea_visit: `seat1_present = owner drawn OR owner paid` so the visitor sees the owner seated on the spectator hex under the same conditions. - seat flare bumped 1.5s → 2s (my-sea-seats.js GLOW_MS + _room.scss keyframe). Tests: +2 spectator-cross ITs, +1 spectator-cross FT (Phase 1); +4 owner-seat ITs, +2 visitor both-seated/owner-seated ITs, +1 owner-seating FT (Phase 2). 286 gameboard ITs/UTs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,10 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
var STORE_PREFIX = 'mysea-seat-seen:';
|
||||
var GLOW_MS = 1500;
|
||||
// 2s flare (user-spec 2026-05-29) — matches the `my-sea-seat-flare`
|
||||
// keyframe duration in _room.scss so the class is removed exactly as the
|
||||
// chair settles into its steady `.seated` --secUser look.
|
||||
var GLOW_MS = 2000;
|
||||
|
||||
function alreadySeen(token) {
|
||||
try {
|
||||
|
||||
@@ -123,6 +123,80 @@ class MySeaVisitContextTest(TestCase):
|
||||
self.assertTrue(ctx["seat2_present"])
|
||||
self.assertIn("VIEW", self.client.get(self.url).content.decode())
|
||||
|
||||
def test_present_renders_owner_draw_as_interactive_cross_stage(self):
|
||||
"""Phase 1 (2026-05-29): the visitor's VIEW DRAW renders the IDENTICAL
|
||||
`.my-sea-cross` picker + `_sea_stage` the owner sees (click→stage,
|
||||
hover) off the owner's draw — NOT the old flat `.my-sea-scroll` strip
|
||||
that blew one card up to fill the viewport."""
|
||||
self.invite.token_deposited_at = timezone.now()
|
||||
self.invite.voice_until = timezone.now() + timedelta(hours=24)
|
||||
self.invite.save()
|
||||
resp = self.client.get(self.url)
|
||||
ctx = resp.context
|
||||
# Cross-stage parity context is present + sourced from the owner.
|
||||
self.assertEqual(ctx["default_spread"], "situation-action-outcome")
|
||||
self.assertIn("lay", ctx["saved_by_position"])
|
||||
self.assertEqual(ctx["saved_by_position"]["lay"]["card_id"], 1)
|
||||
self.assertIn("lay", ctx["label_by_position"])
|
||||
content = resp.content.decode()
|
||||
# The interactive cross + stage modal render; the old scroll is gone.
|
||||
self.assertIn('id="id_sea_overlay"', content)
|
||||
self.assertIn("sea-cross", content)
|
||||
self.assertIn('id="id_sea_stage"', content)
|
||||
self.assertIn('id="id_my_sea_deck"', content) # deck JSON for stage
|
||||
self.assertIn('data-card-id="1"', content) # owner's drawn card slot
|
||||
self.assertNotIn("my-sea-scroll", content)
|
||||
|
||||
def test_not_present_does_not_render_cross_or_deck_json(self):
|
||||
# Before deposit (gate view) there's no draw surface to seed.
|
||||
content = self.client.get(self.url).content.decode()
|
||||
self.assertNotIn('id="id_sea_overlay"', content)
|
||||
self.assertNotIn('id="id_my_sea_deck"', content)
|
||||
|
||||
def test_both_seats_render_seated_simultaneously(self):
|
||||
# Phase 2 (2026-05-29): with the owner drawn (1C) AND the visitor
|
||||
# deposited (2C), BOTH chairs render `.seated` + `.fa-circle-check`
|
||||
# at once — neither reverts to the semi-opaque .fa-ban default.
|
||||
self.invite.token_deposited_at = timezone.now()
|
||||
self.invite.voice_until = timezone.now() + timedelta(hours=24)
|
||||
self.invite.save()
|
||||
ctx = self.client.get(self.url).context
|
||||
self.assertTrue(ctx["seat1_present"]) # owner drawn
|
||||
self.assertTrue(ctx["seat2_present"]) # visitor present
|
||||
html = self.client.get(self.url).content.decode()
|
||||
self.assertIn('class="table-seat seated" data-slot="1"', html)
|
||||
self.assertIn('class="table-seat seated" data-slot="2"', html)
|
||||
self.assertEqual(
|
||||
html.count('class="position-status-icon fa-solid fa-circle-check"'), 2)
|
||||
|
||||
|
||||
class MySeaVisitOwnerSeatedTest(TestCase):
|
||||
"""Phase 2 — the owner shows seated in 1C on the spectator hex whenever
|
||||
she's committed to a draw cycle (drawn OR paid), not only once a card
|
||||
lands. Previously a paid-but-undrawn owner read as the empty .fa-ban
|
||||
default on the visitor's view."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = _owner_with_sig()
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
|
||||
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
|
||||
)
|
||||
self.client.force_login(self.bud)
|
||||
self.url = reverse("my_sea_visit", args=[self.owner.id])
|
||||
|
||||
def test_owner_seated_when_paid_with_empty_hand(self):
|
||||
MySeaDraw.objects.create(
|
||||
user=self.owner, spread="situation-action-outcome",
|
||||
significator_id=1, hand=[], deposit_token_id=99,
|
||||
deposit_reserved_at=timezone.now(),
|
||||
)
|
||||
self.assertTrue(self.client.get(self.url).context["seat1_present"])
|
||||
|
||||
def test_owner_not_seated_without_draw_or_payment(self):
|
||||
self.assertFalse(self.client.get(self.url).context["seat1_present"])
|
||||
|
||||
|
||||
class MySeaVisitInsertTokenTest(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -1346,6 +1346,61 @@ class MySeaDrawSeaLandingViewTest(TestCase):
|
||||
self.assertEqual(html.count('class="position-status-icon fa-solid fa-ban"'), 6)
|
||||
self.assertEqual(html.count('class="seat-position-label"'), 6)
|
||||
|
||||
# ── Owner 1C seating (Phase 2, 2026-05-29) ───────────────────────────
|
||||
# The owner is "seated" in 1C whenever she's committed to a draw cycle —
|
||||
# paid for one OR partially/completely drawn (w/o DEL). Previously only a
|
||||
# non-empty hand seated 1C, so a paid-but-undrawn owner (the PAID DRAW
|
||||
# landing) showed the semi-opaque .fa-ban default. Seat 1C now carries
|
||||
# `.seated` + `.fa-circle-check` so it persists on refresh (sync).
|
||||
_SEATED_1C = 'class="table-seat seated" data-slot="1"'
|
||||
_STATUS_CHECK = 'class="position-status-icon fa-solid fa-circle-check"'
|
||||
_STATUS_BAN = 'class="position-status-icon fa-solid fa-ban"'
|
||||
|
||||
def _make_draw(self, hand=None, deposit=False, paid_through=False):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
return MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
hand=hand or [],
|
||||
significator_id=self.user.significator_id,
|
||||
deposit_token_id=99 if deposit else None,
|
||||
deposit_reserved_at=timezone.now() if deposit else None,
|
||||
paid_through_at=timezone.now() if paid_through else None,
|
||||
)
|
||||
|
||||
def test_seat_1c_seated_when_paid_draw_pending_with_empty_hand(self):
|
||||
# Paid (deposit reserved) but no card drawn yet → PAID DRAW landing.
|
||||
self._make_draw(hand=[], deposit=True)
|
||||
html = self.client.get(reverse("my_sea")).content.decode()
|
||||
self.assertIn(self._SEATED_1C, html)
|
||||
self.assertIn("PAID", html)
|
||||
self.assertEqual(html.count(self._STATUS_CHECK), 1) # only 1C
|
||||
self.assertEqual(html.count(self._STATUS_BAN), 5) # 2C–6C
|
||||
|
||||
def test_seat_1c_seated_when_paid_through_credit_with_empty_hand(self):
|
||||
self._make_draw(hand=[], paid_through=True)
|
||||
html = self.client.get(reverse("my_sea")).content.decode()
|
||||
self.assertIn(self._SEATED_1C, html)
|
||||
self.assertEqual(html.count(self._STATUS_CHECK), 1)
|
||||
|
||||
def test_seat_1c_seated_mid_draw_on_forced_landing(self):
|
||||
self._make_draw(hand=[
|
||||
{"position": "lay", "card_id": 1, "reversed": False,
|
||||
"polarity": "gravity"},
|
||||
])
|
||||
html = self.client.get(reverse("my_sea") + "?phase=landing").content.decode()
|
||||
self.assertIn(self._SEATED_1C, html)
|
||||
self.assertEqual(html.count(self._STATUS_CHECK), 1)
|
||||
|
||||
def test_seat_1c_not_seated_at_gate_view_after_del(self):
|
||||
# Cooldown row, hand cleared (DEL'd), no deposit/paid credit → GATE
|
||||
# VIEW. The owner has unseated herself; 1C falls back to .fa-ban.
|
||||
self._make_draw(hand=[])
|
||||
html = self.client.get(reverse("my_sea")).content.decode()
|
||||
self.assertNotIn(self._SEATED_1C, html)
|
||||
self.assertIn("GATE", html)
|
||||
self.assertEqual(html.count(self._STATUS_CHECK), 0)
|
||||
self.assertEqual(html.count(self._STATUS_BAN), 6)
|
||||
|
||||
def test_landing_not_rendered_when_user_has_no_sig(self):
|
||||
# Sprint 4b gate still wins precedence — FREE DRAW must not render
|
||||
# when significator is None.
|
||||
|
||||
@@ -194,6 +194,35 @@ def toggle_game_kit_sections(request):
|
||||
return redirect("game_kit")
|
||||
|
||||
|
||||
def _saved_by_position(saved_hand):
|
||||
"""Build the per-position saved-card lookup the picker cross renders from
|
||||
(`_my_sea_slot.html`). Keyed by position slug ("lay", "cover", ...); each
|
||||
value carries the pre-resolved card fields so the template never hits the
|
||||
DB per slot. Shared by the owner picker (`my_sea`) AND the read-only
|
||||
spectator render (`my_sea_visit`) so both draw the IDENTICAL cross."""
|
||||
saved = {}
|
||||
if not saved_hand:
|
||||
return saved
|
||||
from apps.epic.models import TarotCard
|
||||
ids = [e["card_id"] for e in saved_hand]
|
||||
cards_by_id = {c.id: c for c in TarotCard.objects.filter(id__in=ids)}
|
||||
for entry in saved_hand:
|
||||
c = cards_by_id.get(entry["card_id"])
|
||||
saved[entry["position"]] = {
|
||||
"card_id": entry["card_id"],
|
||||
"reversed": entry.get("reversed", False),
|
||||
"polarity": entry.get("polarity", "gravity"),
|
||||
"corner_rank": c.corner_rank if c else "",
|
||||
"suit_icon": c.suit_icon if c else "",
|
||||
"has_card_images": (c.deck_variant.has_card_images
|
||||
if c and c.deck_variant else False),
|
||||
"image_url": c.image_url if c else "",
|
||||
"arcana": c.arcana if c else "",
|
||||
"name": c.name if c else "",
|
||||
}
|
||||
return saved
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_sea(request):
|
||||
"""Shell view for the My Sea standalone page.
|
||||
@@ -295,29 +324,9 @@ def my_sea(request):
|
||||
# entry=saved_by_position.lay %}` block. The card fields (corner_rank,
|
||||
# suit_icon) come pre-resolved so the template doesn't need to do a
|
||||
# DB lookup per slot.
|
||||
saved_by_position = {}
|
||||
if saved_hand:
|
||||
from apps.epic.models import TarotCard
|
||||
ids = [e["card_id"] for e in saved_hand]
|
||||
cards_by_id = {c.id: c for c in TarotCard.objects.filter(id__in=ids)}
|
||||
for entry in saved_hand:
|
||||
c = cards_by_id.get(entry["card_id"])
|
||||
saved_by_position[entry["position"]] = {
|
||||
"card_id": entry["card_id"],
|
||||
"reversed": entry.get("reversed", False),
|
||||
"polarity": entry.get("polarity", "gravity"),
|
||||
"corner_rank": c.corner_rank if c else "",
|
||||
"suit_icon": c.suit_icon if c else "",
|
||||
# Sprint A.7-polish: extra fields for image-mode slot render
|
||||
# in `_my_sea_slot.html`. Empty strings when the card's deck
|
||||
# has no images (legacy text-only); template branches on
|
||||
# `has_card_images` to pick render mode.
|
||||
"has_card_images": (c.deck_variant.has_card_images
|
||||
if c and c.deck_variant else False),
|
||||
"image_url": c.image_url if c else "",
|
||||
"arcana": c.arcana if c else "",
|
||||
"name": c.name if c else "",
|
||||
}
|
||||
# Per-position lookup for `_my_sea_slot.html` — see `_saved_by_position`
|
||||
# (shared with the read-only spectator render so both draw the same cross).
|
||||
saved_by_position = _saved_by_position(saved_hand)
|
||||
|
||||
# @taxman Brief payloads w. NVM-persistence (user-spec 2026-05-26). The
|
||||
# FREE DRAW Brief surfaces ONLY when an active draw exists AND the user
|
||||
@@ -371,6 +380,12 @@ def my_sea(request):
|
||||
"saved_by_position": saved_by_position,
|
||||
"next_free_draw_at": next_free_draw_at,
|
||||
"hand_complete": hand_complete,
|
||||
# Owner seated in 1C whenever committed to a draw cycle — paid for one
|
||||
# (deposit reserved OR paid-through credit, via show_paid_draw) OR has
|
||||
# cards down (hand_non_empty). Drives the persistent `.seated` +
|
||||
# `.fa-circle-check` on the landing's 1C chair (user-spec 2026-05-29);
|
||||
# a DEL'd row at GATE VIEW (empty hand, no paid credit) reads unseated.
|
||||
"seat1_seated": hand_non_empty or show_paid_draw,
|
||||
"show_picker": show_picker,
|
||||
"show_cont_draw": show_cont_draw,
|
||||
# Sub-btn .active flag for the burger fan — Sea sub-btn lights up
|
||||
@@ -871,21 +886,47 @@ def my_sea_visit(request, owner_id):
|
||||
owner_draw = active_draw_for(owner)
|
||||
sig_card, sig_reversed = _resolve_sig(owner, owner_draw)
|
||||
owner_hand_non_empty = owner_draw is not None and bool(owner_draw.hand)
|
||||
# Owner seated in 1C (as the visitor sees it) whenever she's committed to a
|
||||
# draw cycle — drawn OR paid (deposit reserved / paid-through credit). Keeps
|
||||
# the owner's 1C chair persistently `.seated` on the spectator hex even
|
||||
# before a card lands (user-spec 2026-05-29); mirrors my_sea's seat1_seated.
|
||||
owner_paid = owner_draw is not None and (
|
||||
owner_draw.deposit_token_id is not None
|
||||
or owner_draw.paid_through_at is not None
|
||||
)
|
||||
owner_seated = owner_hand_non_empty or owner_paid
|
||||
# Read-only spectator render parity (Phase 1, 2026-05-29): the visitor's
|
||||
# VIEW DRAW renders the SAME `.my-sea-cross` picker + `_sea_stage` the
|
||||
# owner sees, populated from the owner's draw. `saved_by_position` fills
|
||||
# the slots; `label_by_position` captions them per the owner's spread;
|
||||
# `sea_deck_data` is the OWNER's deck so sea.js can resolve each clicked
|
||||
# slot's full card face for the magnified stage.
|
||||
owner_slots = latest_draw_slots(owner)
|
||||
return render(request, "apps/gameboard/my_sea_visit.html", {
|
||||
"spectator": True,
|
||||
"is_owner": False,
|
||||
"read_only": True,
|
||||
"owner": owner,
|
||||
"sea_invite": invite,
|
||||
"seat1_present": owner_hand_non_empty,
|
||||
"seat1_present": owner_seated,
|
||||
"seat2_present": invite.is_present,
|
||||
"owner_draw_id": owner_draw.id if owner_draw is not None else "",
|
||||
"voice_active": invite.voice_active,
|
||||
"voice_room_id": f"mysea-{owner.id}",
|
||||
"significator": sig_card,
|
||||
"significator_reversed": sig_reversed,
|
||||
"my_sea_slots": latest_draw_slots(owner),
|
||||
"my_sea_slots": owner_slots,
|
||||
"owner_hand_non_empty": owner_hand_non_empty,
|
||||
# Read-only cross-stage parity payload.
|
||||
"default_spread": (owner_draw.spread if owner_draw is not None
|
||||
else "situation-action-outcome"),
|
||||
"saved_by_position": _saved_by_position(
|
||||
owner_draw.hand if owner_draw is not None else []),
|
||||
"label_by_position": {s["position"]: s["label"] for s in owner_slots},
|
||||
"sea_deck_data": (
|
||||
_my_sea_deck_data(owner, exclude_id=sig_card.id if sig_card else None)
|
||||
if sig_card is not None else {"levity": [], "gravity": []}
|
||||
),
|
||||
# Owner-only controls forced off on the spectator surface.
|
||||
"sea_btn_active": False,
|
||||
"sea_first_draw_pending": False,
|
||||
|
||||
@@ -2053,3 +2053,93 @@ class MySeaGearBtnTest(FunctionalTest):
|
||||
self.browser.current_url, r"/gameboard/my-sea/$"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class MySeaOwnerSeatingTest(FunctionalTest):
|
||||
"""Phase 2 (2026-05-29) — the owner's 1C chair stays persistently seated
|
||||
(`.seated` + green `.fa-circle-check`, NOT the semi-opaque `.fa-ban`
|
||||
default) whenever she's committed to a draw cycle, including a PAID DRAW
|
||||
landing before any card lands. Previously only a non-empty hand seated 1C,
|
||||
so a paid-but-undrawn owner showed the empty-seat default."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "seated@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
|
||||
def _paid_empty_draw(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
return MySeaDraw.objects.create(
|
||||
user=self.gamer, spread="situation-action-outcome",
|
||||
significator_id=self.gamer.significator_id, hand=[],
|
||||
deposit_token_id=99, deposit_reserved_at=timezone.now(),
|
||||
)
|
||||
|
||||
def test_paid_draw_landing_seats_owner_in_1c_persistently(self):
|
||||
self._paid_empty_draw()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
seat1 = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
||||
))
|
||||
# Seated (not the empty default) + the green check — and STAYS so
|
||||
# after the 2s flare settles (the steady state is server-rendered).
|
||||
self.assertIn("seated", (seat1.get_attribute("class") or "").split())
|
||||
self.assertTrue(seat1.find_elements(
|
||||
By.CSS_SELECTOR, ".position-status-icon.fa-circle-check"
|
||||
))
|
||||
self.assertFalse(seat1.find_elements(
|
||||
By.CSS_SELECTOR, ".position-status-icon.fa-ban"
|
||||
))
|
||||
|
||||
|
||||
class MySeaVisitSpectatorCrossTest(FunctionalTest):
|
||||
"""Phase 1 (2026-05-29) — the visitor's VIEW DRAW renders the owner's draw
|
||||
as the SAME interactive cross stage the owner sees (`.my-sea-cross` +
|
||||
`_sea_stage`), NOT the old flat scroll that blew a single card up to fill
|
||||
the viewport."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||
_assign_sig(self.owner)
|
||||
self.visitor_email = "viz@test.io"
|
||||
self.visitor = User.objects.create(
|
||||
email=self.visitor_email, username="viz")
|
||||
from apps.epic.models import TarotCard
|
||||
from apps.gameboard.models import MySeaDraw, SeaInvite
|
||||
card = TarotCard.objects.exclude(id=self.owner.significator_id).first()
|
||||
MySeaDraw.objects.create(
|
||||
user=self.owner, spread="situation-action-outcome",
|
||||
significator_id=self.owner.significator_id,
|
||||
hand=[{"position": "lay", "card_id": card.id, "reversed": False,
|
||||
"polarity": "gravity"}],
|
||||
)
|
||||
SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.visitor,
|
||||
invitee_email=self.visitor_email,
|
||||
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
|
||||
token_deposited_at=timezone.now(),
|
||||
voice_until=timezone.now() + timedelta(hours=24),
|
||||
)
|
||||
|
||||
def test_view_draw_reveals_interactive_cross_with_filled_slot(self):
|
||||
self.create_pre_authenticated_session(self.visitor_email)
|
||||
self.browser.get(
|
||||
self.live_server_url + f"/gameboard/my-sea/visit/{self.owner.id}/")
|
||||
view_btn = self.wait_for(lambda: self.browser.find_element(
|
||||
By.ID, "id_my_sea_view_draw_btn"))
|
||||
self.browser.execute_script("arguments[0].click()", view_btn)
|
||||
cross = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-cross"))
|
||||
self.assertTrue(cross.is_displayed())
|
||||
self.assertTrue(self.browser.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot--filled"))
|
||||
self.assertTrue(self.browser.find_elements(By.ID, "id_sea_stage"))
|
||||
self.assertFalse(self.browser.find_elements(
|
||||
By.CSS_SELECTOR, ".my-sea-scroll"))
|
||||
|
||||
@@ -410,7 +410,7 @@
|
||||
color: rgba(var(--secUser), 1);
|
||||
.stat-face-label { color: rgba(var(--secUser), 1); }
|
||||
.stat-keywords li {
|
||||
color: rgba(var(--quiUser), 1);
|
||||
color: rgba(var(--secUser), 1);
|
||||
border-bottom-color: rgba(var(--terUser), 0.18);
|
||||
}
|
||||
}
|
||||
@@ -2283,7 +2283,7 @@ $_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .f
|
||||
border-color: rgba(var(--terUser), 0.15);
|
||||
.stat-face-label { color: rgba(var(--secUser), 1); }
|
||||
.stat-keywords li {
|
||||
color: rgba(var(--quiUser), 1);
|
||||
color: rgba(var(--secUser), 1);
|
||||
border-bottom-color: rgba(var(--terUser), 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,13 +631,14 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
|
||||
color: rgba(var(--secUser), 1);
|
||||
filter: none;
|
||||
}
|
||||
// One-shot "just seated" flare (~1.5s) played the FIRST time a viewer
|
||||
// One-shot "just seated" flare (2s) played the FIRST time a viewer
|
||||
// sees the occupancy (my-sea-seats.js adds/removes `.seat-just-seated`,
|
||||
// localStorage-gated). Chair flares --terUser + a --ninUser glow, then
|
||||
// eases back into the steady --secUser .seated look above (user-spec
|
||||
// 2026-05-27). Mirrors the room's .active → .role-confirmed handoff.
|
||||
// 2026-05-29, bumped from 1.5s). Mirrors the room's .active →
|
||||
// .role-confirmed handoff.
|
||||
&.seat-just-seated .fa-chair {
|
||||
animation: my-sea-seat-flare 1.5s ease forwards;
|
||||
animation: my-sea-seat-flare 2s ease forwards;
|
||||
}
|
||||
|
||||
.seat-portrait {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
{# Read-only render of the owner's draw for a my-sea spectator (Phase B of #}
|
||||
{# [[my-sea-invite-voice-blueprint]]). Mirrors `_applet-my-sea.html`'s slot #}
|
||||
{# markup off the same `latest_draw_slots` payload (`my_sea_slots`), but #}
|
||||
{# standalone + with NO interactive affordances (FLIP / DEL / AUTO DRAW). #}
|
||||
<div class="my-sea-scroll my-sea-visit-scroll">
|
||||
{% for slot in my_sea_slots %}
|
||||
{% if slot.card %}
|
||||
<div class="my-sea-slot-wrap">
|
||||
<div class="my-sea-slot my-sea-slot--filled my-sea-slot--{{ slot.polarity }}{% if slot.reversed %} my-sea-slot--reversed{% endif %}{% if slot.card.deck_variant.has_card_images %} my-sea-slot--image{% endif %}"
|
||||
data-position="{{ slot.position }}"
|
||||
data-card-id="{{ slot.card.id }}"
|
||||
data-arcana-key="{{ slot.card.arcana }}">
|
||||
{% if slot.card.deck_variant.has_card_images %}
|
||||
<img class="sig-stage-card-img" src="{{ slot.card.image_url }}" alt="{{ slot.card.name }}">
|
||||
{% else %}
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
{% if slot.face.qualifier_first %}
|
||||
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
|
||||
<p class="fan-card-name">{{ slot.face.title }}</p>
|
||||
{% else %}
|
||||
<p class="fan-card-name">{{ slot.face.title }}</p>
|
||||
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
|
||||
{% endif %}
|
||||
<p class="fan-card-arcana">{{ slot.card.get_arcana_display }}</p>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="my-sea-slot-label">{{ slot.label }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="my-sea-slot-wrap">
|
||||
<div class="my-sea-slot my-sea-slot--empty" data-position="{{ slot.position }}"></div>
|
||||
<span class="my-sea-slot-label my-sea-slot-label--empty">{{ slot.label }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
{# Read-only spectator render of the owner's draw (Phase 1, 2026-05-29). #}
|
||||
{# Reuses the owner's `.sea-cross.my-sea-cross` picker DOM (sig center + 6 #}
|
||||
{# position cells via _my_sea_slot.html) + the shared `_sea_stage.html` #}
|
||||
{# modal, bound by sea.js for click→stage + SPIN + FYI + hover — so the #}
|
||||
{# visitor sees IDENTICALLY what the owner sees on her own my_sea, just #}
|
||||
{# without the FLIP / DEL / AUTO DRAW / deck-stacks / spread combobox #}
|
||||
{# (the visitor only watches). `#id_sea_overlay` is what sea.js binds to; #}
|
||||
{# `--locked` mirrors the owner's hand-complete picker so no draw #}
|
||||
{# affordances surface even if a stray handler fires. #}
|
||||
<div class="my-sea-picker my-sea-picker--locked my-sea-visit-picker"
|
||||
id="id_sea_overlay" data-spectator="true">
|
||||
<div class="sea-cards-col">
|
||||
<div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}">
|
||||
<div class="sea-crucifix-cell sea-pos-crown">
|
||||
<span class="sea-pos-label" data-position="crown">{{ label_by_position.crown }}</span>
|
||||
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="crown" saved=saved_by_position.crown crossing=False %}
|
||||
</div>
|
||||
<div class="sea-crucifix-cell sea-pos-leave">
|
||||
<span class="sea-pos-label" data-position="leave">{{ label_by_position.leave }}</span>
|
||||
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="leave" saved=saved_by_position.leave crossing=False %}
|
||||
</div>
|
||||
<div class="sea-crucifix-cell sea-pos-core">
|
||||
<div class="sig-stage-card sea-sig-card{% if significator.deck_variant.has_card_images %} sig-stage-card--image{% endif %}"
|
||||
data-card-id="{{ significator.id }}"
|
||||
data-arcana-key="{{ significator.arcana }}">
|
||||
{% if significator.deck_variant.has_card_images %}
|
||||
<img class="sig-stage-card-img" src="{{ significator.image_url }}" alt="{{ significator.name }}">
|
||||
{% else %}
|
||||
<span class="fan-corner-rank">{{ significator.corner_rank }}</span>
|
||||
{% if significator.suit_icon %}<i class="fa-solid {{ significator.suit_icon }}"></i>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="sea-pos-cover">
|
||||
<span class="sea-pos-label" data-position="cover">{{ label_by_position.cover }}</span>
|
||||
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="cover" saved=saved_by_position.cover crossing=False %}
|
||||
</div>
|
||||
<div class="sea-pos-cross">
|
||||
<span class="sea-pos-label" data-position="cross">{{ label_by_position.cross }}</span>
|
||||
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="cross" saved=saved_by_position.cross crossing=True %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sea-crucifix-cell sea-pos-loom">
|
||||
<span class="sea-pos-label" data-position="loom">{{ label_by_position.loom }}</span>
|
||||
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="loom" saved=saved_by_position.loom crossing=False %}
|
||||
</div>
|
||||
<div class="sea-crucifix-cell sea-pos-lay">
|
||||
<span class="sea-pos-label" data-position="lay">{{ label_by_position.lay }}</span>
|
||||
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="lay" saved=saved_by_position.lay crossing=False %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "apps/gameboard/_partials/_sea_stage.html" %}
|
||||
</div>
|
||||
@@ -94,10 +94,10 @@
|
||||
{# semantics clean. `.position-status-icon` + #}
|
||||
{# `.fa-ban` are unchanged — already role- #}
|
||||
{# agnostic in _room.scss. #}
|
||||
<div class="table-seat{% if n == '1' and hand_non_empty %} seated{% endif %}" data-slot="{{ n }}"{% if n == '1' and hand_non_empty %} data-seat-token="owner-{{ request.user.id }}-{{ active_draw.id }}"{% endif %}>
|
||||
<div class="table-seat{% if n == '1' and seat1_seated %} seated{% endif %}" data-slot="{{ n }}"{% if n == '1' and seat1_seated %} data-seat-token="owner-{{ request.user.id }}-{{ active_draw.id }}"{% endif %}>
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">{{ n }}C</span>
|
||||
<i class="position-status-icon fa-solid {% if n == '1' and hand_non_empty %}fa-circle-check{% else %}fa-ban{% endif %}"></i>
|
||||
<i class="position-status-icon fa-solid {% if n == '1' and seat1_seated %}fa-circle-check{% else %}fa-ban{% endif %}"></i>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -67,9 +67,12 @@
|
||||
</div>
|
||||
|
||||
{% if seat2_present %}
|
||||
{# Owner's draw, read-only. Hidden until VIEW DRAW toggles it in. #}
|
||||
{# Owner's draw, read-only. Hidden until VIEW DRAW toggles it in. Renders #}
|
||||
{# the SAME interactive cross stage the owner sees (click→stage, hover, #}
|
||||
{# SPIN, FYI) off the owner's draw payload — see _my_sea_visit_cross.html. #}
|
||||
<div id="id_my_sea_visit_draw" class="my-sea-visit-draw" style="display:none">
|
||||
{% include "apps/gameboard/_partials/_my_sea_readonly_draw.html" %}
|
||||
{{ sea_deck_data|json_script:"id_my_sea_deck" }}
|
||||
{% include "apps/gameboard/_partials/_my_sea_visit_cross.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -84,6 +87,53 @@
|
||||
{% block scripts %}
|
||||
<script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
||||
{% if seat2_present %}
|
||||
{# Read-only cross stage — StageCard + SeaDeal bind to #id_sea_overlay #}
|
||||
{# (inside #id_my_sea_visit_draw) for click→stage + SPIN + FYI + hover. #}
|
||||
{# A trimmed seed IIFE (no picker/FLIP/DEL machinery) reconstructs #}
|
||||
{# SeaDeal's `_seaHand` from the server-rendered filled slots so each #}
|
||||
{# card is clickable into the magnified stage — same logic as my_sea's #}
|
||||
{# saved-hand restore, just without the draw affordances. #}
|
||||
<script src="{% static 'apps/epic/stage-card.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/sea.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
function _seed() {
|
||||
if (!window.SeaDeal || !window.SeaDeal.seedHand) return;
|
||||
var cross = document.querySelector('.my-sea-cross');
|
||||
if (!cross) return;
|
||||
var deckEl = document.getElementById('id_my_sea_deck');
|
||||
var deck = {};
|
||||
try { deck = JSON.parse((deckEl && deckEl.textContent) || '{}'); } catch (e) {}
|
||||
var byId = {};
|
||||
(deck.levity || []).concat(deck.gravity || []).forEach(function (c) {
|
||||
byId[c.id] = c;
|
||||
});
|
||||
var seed = {};
|
||||
cross.querySelectorAll('.sea-card-slot.sea-card-slot--filled').forEach(function (slot) {
|
||||
var posName = slot.dataset.posKey;
|
||||
var card = byId[parseInt(slot.dataset.cardId, 10)];
|
||||
if (!posName || !card) return;
|
||||
var slotCard = {};
|
||||
for (var k in card) {
|
||||
if (Object.prototype.hasOwnProperty.call(card, k)) slotCard[k] = card[k];
|
||||
}
|
||||
// Per-instance reversed/polarity come from the slot's DOM
|
||||
// class (the owner's saved hand, not the deck-shuffle axis).
|
||||
slotCard.reversed = slot.classList.contains('sea-card-slot--reversed');
|
||||
var isLevity = slot.classList.contains('sea-card-slot--levity');
|
||||
seed[posName] = { card: slotCard, isLevity: isLevity };
|
||||
});
|
||||
window.SeaDeal.seedHand(seed);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _seed);
|
||||
} else {
|
||||
_seed();
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function () {
|
||||
// VIEW DRAW toggles the read-only draw against the table hex.
|
||||
|
||||
Reference in New Issue
Block a user