Game Sign picker @ /billboard/my-sign/ + billboard applet — Sprint 4a of My Sea roadmap — TDD

User scope (per design conv this session): split the room's sig-select responsibility off into a standalone billboard-context "My Significator" applet — branded "Game Sign" on the surface. Same 18-card pile as room sig-select (16 middle arcana + Major 0 & 1 filtered by Note unlocks); polarity collapses to a single FLIP choice (the FLIP btn in the picker carousel toggles User.significator_reversed). Selection persists globally on the User model + propagates to the billboard's Game Sign applet ; **naming convention locked**: "significator" stays at storage (User.significator FK + User.significator_reversed) + room sig-select context (DRY w. existing template/JS); "Sign" / "Game Sign" is the billboard-surface branding (file my_sign.html, URL /billboard/my-sign/, URL names my_sign + save_sign, applet name "Game Sign", page wordmark "Game Sign", btn label SAVE SIGN). Action URLs don't carry a trailing slash per project convention (/billboard/my-sign/save vs the page's /billboard/my-sign/) ; **schema**: User gains 2 fields — `significator: FK → epic.TarotCard (nullable, on_delete=SET_NULL)` + `significator_reversed: BooleanField(default=False)`. Migration lyric/0006_user_significator_user_significator_reversed.py auto-generated; reversible. Applet seed in applets/0009_seed_my_sig_applet.py adds the row (slug='my-sign', name='Game Sign', context='billboard', default_visible=True, grid_cols=4, grid_rows=6), idempotent update_or_create, reversible unseed() ; **picker page** (my_sign.html): solo lift of `_sig_select_overlay.html` — sig-stage-card scaffold + sig-stat-block + 18-card grid + SAVE SIGN form. Stripped: countdown / WebSocket / polarity / multi-user / reservations. Empty-state branch covers no-equipped-deck (link back to Game Kit; full Brief-redirect + Earthman-Backup fallback deferred to a follow-up sub-sprint). Minimal inline JS: click .sig-card → mark .sig-focused + set hidden card_id + enable SAVE SIGN; FLIP btn toggles .is-reversed + the hidden reversed input. Stage-card preview (name/qualifier population + keyword swap on FLIP) deferred — Sprint 4a follow-up will lift stage-card.js's populator into a non-room context ; **applet partial** (_applet-my-sign.html): renders user.significator's corner-rank + suit-icon + name_title if set; `.my-sign-applet-empty` "No sign chosen yet." otherwise. Header `<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>` links to the picker ; **helper refactor** (epic/models.py): extracted `_sig_unique_cards_for_deck(deck_variant)` from `_sig_unique_cards(room)`. New public `personal_sig_cards(user)` parallels `levity_sig_cards / gravity_sig_cards` but pulls from `user.equipped_deck` instead of `room.deck_variant`. Same Note-unlock filtering. No behavior change to existing room callers (3-line wrapper preserves the room signature) ; **TDD trail** — user called out mid-sprint that I'd skipped FTs; pivoted to FT-first. test_bill_my_sign.py (new, 3 FTs): T1 picker renders w. wordmark + target card present in grid; T2 click card → SAVE SIGN enables → POST persists → applet shows the card; T3 fresh user → applet renders empty-state. Initial reds — (a) setUp's `personal_sig_cards(user)` returned [] because StaticLiveServerTestCase → TransactionTestCase flushes migration-seeded DeckVariant + TarotCard between tests; fixed w. `serialized_rollback = True` on the test class (per [[feedback_transactiontestcase_flush]]); (b) h2 wordmark assertion against `MYSIGNIFICATOR` failed against the renamed "Game Sign" + the letter-splitter spreading chars across <span> children — switched to whitespace-stripped substring check `GAMESIGN`; (c) `.fan-corner-rank` text is CSS-hidden so Selenium returns "" — replaced corner-rank assertions w. data-card-id selectors (already-proven reliable from the parent .sig-card lookup) ; ITs (+12, in apps.billboard.tests.integrated.test_views): MySignViewTest (6 — login redirect, 200 + template, 16-card pile, save persists, invalid card_id → 403, GET save redirects); BillboardAppletMySignTest (3 — applet rendered, empty-state w/o sig, card+reversed class w. sig). PersonalSigCardsTest in apps.epic.tests.integrated.test_models (3 — happy path 16 cards, no-equipped-deck → [], schizo Note unlocks Major 1) ; pre-existing change picked up by the commit: my_sea.html branding "Game Sea" (user-modified mid-session; was "My Sea" in Sprint 3 — divergence captured in MEMORY.md follow-up) ; 1020 IT/UT green (+12) in 46s; 3 FTs green in 24s. Sprint 4a unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/) + Sprint 4c (FT helper for mocking the sig choice across other FTs)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-18 22:23:24 -04:00
parent 5e71b1d5da
commit 400762c0e5
13 changed files with 570 additions and 10 deletions

View File

@@ -0,0 +1,39 @@
"""Seed the My Sign (a.k.a. My Significator) applet on billboard.
Sprint 4 of the My Sea roadmap. "Significator" remains the storage-layer
term (User.significator FK, room sig-select) — this billboard surface is
branded "Game Sign". 4×6 (narrow + tall), seeded after all other billboard
applets so it renders at the end of the billboard grid. Shows the user's
saved significator card or a blank state.
"""
from django.db import migrations
def seed(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.update_or_create(
slug="my-sign",
defaults={
"name": "Game Sign",
"context": "billboard",
"default_visible": True,
"grid_cols": 4,
"grid_rows": 6,
},
)
def unseed(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="my-sign").delete()
class Migration(migrations.Migration):
dependencies = [
("applets", "0008_seed_my_sea_applet"),
]
operations = [
migrations.RunPython(seed, unseed),
]

View File

@@ -15,6 +15,7 @@ def _seed_billboard_applets():
("my-scrolls", "My Scrolls", 4, 3), ("my-scrolls", "My Scrolls", 4, 3),
("my-buds", "My Buds", 4, 3), ("my-buds", "My Buds", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6), ("most-recent-scroll", "Most Recent Scroll", 8, 6),
("my-sign", "Game Sign", 4, 6),
]: ]:
Applet.objects.get_or_create( Applet.objects.get_or_create(
slug=slug, slug=slug,
@@ -812,3 +813,87 @@ class AnonymousPostViewerTest(TestCase):
reverse("billboard:view_post", args=[self.post.id]) reverse("billboard:view_post", args=[self.post.id])
) )
self.assertNotIn(b"id_post_menu", response.content) self.assertNotIn(b"id_post_menu", response.content)
class MySignViewTest(TestCase):
"""Game Sign picker view at /billboard/my-sign/ — Sprint 4a of
[[project-my-sea-roadmap]]. Pins the GET render + POST save contract."""
def setUp(self):
self.user = User.objects.create(email="sign@test.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_my_sign_requires_login(self):
self.client.logout()
response = self.client.get(reverse("billboard:my_sign"))
self.assertRedirects(
response, "/?next=/billboard/my-sign/", fetch_redirect_response=False,
)
def test_my_sign_renders_200(self):
response = self.client.get(reverse("billboard:my_sign"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/my_sign.html")
def test_my_sign_passes_18_card_pile_for_user_w_no_notes(self):
# The signal auto-equips Earthman; personal_sig_cards returns 16
# middle arcana courts (Majors 0/1 filtered out w.o Schizo/Nomad).
response = self.client.get(reverse("billboard:my_sign"))
self.assertEqual(len(response.context["cards"]), 16)
def test_save_sign_persists_card_and_reversed_flag(self):
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
response = self.client.post(
reverse("billboard:save_sign"),
{"card_id": target.id, "reversed": "1"},
)
self.assertRedirects(response, reverse("billboard:my_sign"))
self.user.refresh_from_db()
self.assertEqual(self.user.significator_id, target.id)
self.assertTrue(self.user.significator_reversed)
def test_save_sign_rejects_invalid_card_id(self):
response = self.client.post(
reverse("billboard:save_sign"),
{"card_id": 999999, "reversed": "0"},
)
self.assertEqual(response.status_code, 403)
self.user.refresh_from_db()
self.assertIsNone(self.user.significator_id)
def test_save_sign_get_redirects_back_to_picker(self):
response = self.client.get(reverse("billboard:save_sign"))
self.assertRedirects(response, reverse("billboard:my_sign"))
class BillboardAppletMySignTest(TestCase):
"""My Sign applet rendering on /billboard/."""
def setUp(self):
self.user = User.objects.create(email="apllet@test.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_billboard_shows_my_sign_applet(self):
response = self.client.get("/billboard/")
self.assertContains(response, 'id="id_applet_my_sign"')
def test_my_sign_applet_renders_empty_state_when_no_sig(self):
response = self.client.get("/billboard/")
self.assertContains(response, "my-sign-applet-empty")
self.assertContains(response, "No sign chosen yet.")
self.assertNotContains(response, "my-sign-applet-card")
def test_my_sign_applet_renders_card_when_sig_set(self):
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
self.user.significator = target
self.user.significator_reversed = True
self.user.save(update_fields=["significator", "significator_reversed"])
response = self.client.get("/billboard/")
self.assertContains(response, "my-sign-applet-card")
self.assertContains(response, f'data-card-id="{target.id}"')
# significator_reversed = True → card carries stage-card--reversed class
self.assertContains(response, "stage-card--reversed")

View File

@@ -23,4 +23,6 @@ urlpatterns = [
path("my-buds/", views.my_buds, name="my_buds"), path("my-buds/", views.my_buds, name="my_buds"),
path("buds/add", views.add_bud, name="add_bud"), path("buds/add", views.add_bud, name="add_bud"),
path("buds/search", views.search_buds, name="search_buds"), path("buds/search", views.search_buds, name="search_buds"),
path("my-sign/", views.my_sign, name="my_sign"),
path("my-sign/save", views.save_sign, name="save_sign"),
] ]

View File

@@ -253,6 +253,52 @@ def doff_title(request, slug):
return JsonResponse({"ok": True, "greeting": "Welcome,", "title": "Earthman"}) return JsonResponse({"ok": True, "greeting": "Welcome,", "title": "Earthman"})
# ── My Sign — global Significator picker (billboard surface) ────────────────
# Standalone page where a user picks their global personal significator. The
# selection persists on User.significator + User.significator_reversed and is
# reused across My Sea draws (and eventually other contexts). "Sign" is the
# billboard-context branding; "significator" stays at the storage layer +
# room sig-select context to keep the DRY model. Sprint 4a of
# [[project-my-sea-roadmap]] — picker UI is a simplified lift of the room's
# `_sig_select_overlay.html` (no countdown / WS / polarity / multi-user).
# Deck-source fallback (Brief-redirect to Game Kit when no equipped deck;
# Earthman-Backup default) deferred to a follow-up sub-sprint.
@login_required(login_url="/")
def my_sign(request):
"""Render the picker — same 18-card pile as room sig-select (16 middle
arcana courts + Major 0 & 1), pulled from the user's equipped deck.
Polarity is determined post-hoc by the FLIP btn (significator_reversed)."""
from apps.epic.models import personal_sig_cards
deck = request.user.equipped_deck
cards = personal_sig_cards(request.user) if deck else []
return render(request, "apps/billboard/my_sign.html", {
"cards": cards,
"equipped_deck": deck,
"current_significator": request.user.significator,
"current_significator_reversed": request.user.significator_reversed,
"page_class": "page-billboard page-my-sign",
})
@login_required(login_url="/")
def save_sign(request):
"""Persist the user's sign choice — POST { card_id, reversed }."""
from apps.epic.models import TarotCard
if request.method != "POST":
return redirect("billboard:my_sign")
card_id = request.POST.get("card_id")
reversed_flag = request.POST.get("reversed") in ("1", "true", "True", "on")
try:
card = TarotCard.objects.get(pk=card_id)
except (TarotCard.DoesNotExist, ValueError, TypeError):
return HttpResponseForbidden("invalid card_id")
request.user.significator = card
request.user.significator_reversed = reversed_flag
request.user.save(update_fields=["significator", "significator_reversed"])
return redirect("billboard:my_sign")
# ── Post / Line CRUD (relocated from apps.dashboard) ──────────────────────── # ── Post / Line CRUD (relocated from apps.dashboard) ────────────────────────
# Templates also live under templates/apps/billboard/. URL names sit in the # Templates also live under templates/apps/billboard/. URL names sit in the
# `billboard:` namespace so reversers across the codebase carry the prefix. # `billboard:` namespace so reversers across the codebase carry the prefix.

View File

@@ -495,9 +495,11 @@ def sig_deck_cards(room):
return unique_cards + unique_cards # × 2 = 36 return unique_cards + unique_cards # × 2 = 36
def _sig_unique_cards(room): def _sig_unique_cards_for_deck(deck_variant):
"""Return the 18 unique TarotCard objects that form one sig pile.""" """Return the 18 unique TarotCards forming one sig pile for the given
deck_variant = _room_deck_variant(room) deck variant. Shared between room sig-select (called via _sig_unique_cards
after room → deck_variant lookup) and the solo My Sig picker (called
via personal_sig_cards from User.equipped_deck)."""
if deck_variant is None: if deck_variant is None:
return [] return []
wands_crowns = list(TarotCard.objects.filter( wands_crowns = list(TarotCard.objects.filter(
@@ -520,6 +522,21 @@ def _sig_unique_cards(room):
return wands_crowns + swords_cups + major return wands_crowns + swords_cups + major
def _sig_unique_cards(room):
"""Return the 18 unique TarotCard objects that form one sig pile."""
return _sig_unique_cards_for_deck(_room_deck_variant(room))
def personal_sig_cards(user):
"""Solo equivalent of levity_sig_cards / gravity_sig_cards — uses
User.equipped_deck instead of room.deck_variant. For the My Sig picker
at /billboard/my-sig/. Same 18-card pile (16 middle arcana + Major 0 + 1),
filtered by the user's Note unlocks (Schizo/Nomad lines)."""
return _filter_major_unlocks(
_sig_unique_cards_for_deck(user.equipped_deck), user,
)
def _filter_major_unlocks(cards, user): def _filter_major_unlocks(cards, user):
"""Remove Nomad (0) and Schizo (1) unless the user has the matching Note unlock.""" """Remove Nomad (0) and Schizo (1) unless the user has the matching Note unlock."""
if user is None or not user.is_authenticated: if user is None or not user.is_authenticated:

View File

@@ -503,6 +503,37 @@ class SigCardHelperTest(TestCase):
self.assertEqual(gravity_sig_cards(self.room, self.owner), []) self.assertEqual(gravity_sig_cards(self.room, self.owner), [])
class PersonalSigCardsTest(TestCase):
"""personal_sig_cards(user) — solo (room-less) sig pile sourced from
User.equipped_deck. Same 18-card pile + Note-unlock filtering as
levity_sig_cards / gravity_sig_cards (which route through a room)."""
def test_fresh_user_gets_16_cards_via_auto_equipped_earthman(self):
from apps.epic.models import personal_sig_cards
user = User.objects.create(email="solo@test.io")
# post_save signal auto-equips Earthman; no Schizo/Nomad notes yet,
# so Majors 0 and 1 are filtered out by _filter_major_unlocks.
cards = personal_sig_cards(user)
self.assertEqual(len(cards), 16)
def test_empty_when_user_has_no_equipped_deck(self):
from apps.epic.models import personal_sig_cards
user = User.objects.create(email="dekless@test.io")
user.equipped_deck = None
user.save(update_fields=["equipped_deck"])
self.assertEqual(personal_sig_cards(user), [])
def test_schizo_note_unlocks_major_1(self):
from apps.drama.models import Note
from apps.epic.models import personal_sig_cards
from django.utils import timezone
user = User.objects.create(email="schizo@test.io")
Note.objects.create(user=user, slug="schizo", earned_at=timezone.now())
cards = personal_sig_cards(user)
self.assertEqual(len(cards), 17)
self.assertTrue(any(c.number == 1 and c.arcana == "MAJOR" for c in cards))
class TarotCardCautionsTest(TestCase): class TarotCardCautionsTest(TestCase):
"""TarotCard.cautions JSONField — field existence and Schizo seed data.""" """TarotCard.cautions JSONField — field existence and Schizo seed data."""

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0 on 2026-05-19 01:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0008_blades_reversal_fickle'),
('lyric', '0005_rename_buddies_to_buds'),
]
operations = [
migrations.AddField(
model_name='user',
name='significator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='epic.tarotcard'),
),
migrations.AddField(
model_name='user',
name='significator_reversed',
field=models.BooleanField(default=False),
),
]

View File

@@ -125,6 +125,15 @@ class User(AbstractBaseUser):
"drama.Note", null=True, blank=True, "drama.Note", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+", on_delete=models.SET_NULL, related_name="+",
) )
# Global personal significator — chosen at /billboard/my-sig/ + persisted
# for reuse across My Sea draws (and eventually other contexts). Single
# FK; the orientation in `significator_reversed` (FLIP btn in the picker
# carousel) determines polarity at draw time.
significator = models.ForeignKey(
"epic.TarotCard", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
significator_reversed = models.BooleanField(default=False)
ap_public_key = models.TextField(blank=True, default="") ap_public_key = models.TextField(blank=True, default="")
ap_private_key = models.TextField(blank=True, default="") ap_private_key = models.TextField(blank=True, default="")

View File

@@ -0,0 +1,162 @@
"""FTs for the Game Sign (a.k.a. My Significator) picker + billboard applet.
Sprint 4a of [[project-my-sea-roadmap]]. The picker lives at
`/billboard/my-sign/` — solo lift of the room's sig-select grid (no
countdown / polarity / multi-user). Selection persists on
User.significator + User.significator_reversed. "Significator" remains
the storage-layer term + room sig-select context; this billboard surface
is branded "Sign" / "Game Sign".
"""
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.applets.models import Applet
from apps.epic.models import personal_sig_cards
from apps.lyric.models import User
def _seed_my_sign_applet():
Applet.objects.get_or_create(
slug="my-sign",
defaults={"name": "Game Sign", "context": "billboard",
"default_visible": True, "grid_cols": 4, "grid_rows": 6},
)
class MySignPickerTest(FunctionalTest):
"""Happy-path picker: a user with the Earthman deck equipped lands at
/billboard/my-sign/, picks a card, clicks SAVE SIGN, and sees the sig
propagate to the Game Sign applet on /billboard/."""
# StaticLiveServerTestCase → TransactionTestCase flushes DB between tests,
# wiping migration-seeded DeckVariant + TarotCard rows. Without this flag,
# personal_sig_cards(user) returns [] because the signal that auto-equips
# Earthman can't find the deck. See [[feedback_transactiontestcase_flush]].
serialized_rollback = True
def setUp(self):
super().setUp()
_seed_my_sign_applet()
# Seed the rest of the billboard applets so /billboard/ renders
# without missing-applet errors.
for slug, name in [
("my-scrolls", "My Scrolls"),
("my-buds", "My Buds"),
("most-recent-scroll", "Most Recent Scroll"),
]:
Applet.objects.get_or_create(
slug=slug, defaults={"name": name, "context": "billboard"},
)
self.email = "sig@test.io"
self.gamer = User.objects.create(email=self.email)
# post_save signal auto-equips Earthman. Picker uses personal_sig_cards
# (= 16 middle arcana + Major 0 & 1, filtered by Note unlocks) so the
# target must come from that subset, not the full deck.
sig_pile = personal_sig_cards(self.gamer)
self.target_card = sig_pile[0] if sig_pile else None
self.assertIsNotNone(
self.target_card,
"personal_sig_cards(user) returned no cards — check Earthman seed"
" + DeckVariant fixture availability in the FT DB.",
)
# ── Test 1 ───────────────────────────────────────────────────────────────
def test_picker_renders_card_grid_from_equipped_deck(self):
"""GET /billboard/my-sign/ → page renders w. a card grid + the page
wordmark reads "Game Sign", populated by the user's equipped_deck."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/billboard/my-sign/")
# Wordmark. The h2 letter-splitter (base.html) wraps each character
# in its own <span>, so Selenium's `.text` joins them w. newlines —
# strip whitespace before the substring check.
self.wait_for(
lambda: self.assertIn(
"GAMESIGN",
"".join(
self.browser.find_element(By.CSS_SELECTOR, "h2").text.upper().split()
),
)
)
# Target card present in the grid. The data-card-id selector itself
# is the assertion — find_element raises if absent. Avoid asserting
# against `.fan-corner-rank .text` (CSS hides it via font-size or
# similar, so Selenium returns "").
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f'.my-sign-deck-grid .sig-card[data-card-id="{self.target_card.id}"]',
)
)
# ── Test 2 ───────────────────────────────────────────────────────────────
def test_pick_card_then_save_persists_choice_and_shows_in_applet(self):
"""Click card → SAVE SIGN btn enables → click → DB updated → applet
on /billboard/ shows the chosen card."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/billboard/my-sign/")
# Click target card
card_el = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f'.my-sign-deck-grid .sig-card[data-card-id="{self.target_card.id}"]',
)
)
self.browser.execute_script("arguments[0].click()", card_el)
# SAVE SIGN should be enabled now
save_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_save_sign_btn")
)
self.wait_for(
lambda: self.assertFalse(
save_btn.get_attribute("disabled"),
"SAVE SIGN should be enabled after card click",
)
)
# Hidden card_id input should match the clicked card
self.assertEqual(
str(self.target_card.id),
self.browser.find_element(By.ID, "id_save_sign_card_id").get_attribute("value"),
)
# Save → DB updated
self.browser.execute_script("arguments[0].click()", save_btn)
self.wait_for(
lambda: self.gamer.refresh_from_db() or self.assertEqual(
self.gamer.significator_id, self.target_card.id,
)
)
# Navigate to /billboard/ → applet shows the saved card. Pin by
# data-card-id (same reasoning as test 1 re: CSS-hidden corner rank).
self.browser.get(self.live_server_url + "/billboard/")
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f'#id_applet_my_sign .my-sign-applet-card[data-card-id="{self.target_card.id}"]',
)
)
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_applet_renders_blank_state_when_no_sig_chosen(self):
"""Fresh user with no significator → applet shows the empty-state
copy, no card."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/billboard/")
empty = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_applet_my_sign .my-sign-applet-empty"
)
)
self.assertIn("No sign chosen", empty.text)
self.assertEqual(
len(self.browser.find_elements(
By.CSS_SELECTOR, "#id_applet_my_sign .my-sign-applet-card"
)),
0,
)

View File

@@ -445,8 +445,8 @@
--priUser: var(--sepBlt); --priUser: var(--sepBlt);
--secUser: var(--terBlt); --secUser: var(--terBlt);
--terUser: var(--ninBlt); --terUser: var(--ninBlt);
--quaUser: var(--priYl); --quaUser: var(--priBlt);
--quiUser: var(--priBlt); --quiUser: var(--terYl);
--sixUser: var(--quiBlt); --sixUser: var(--quiBlt);
--sepUser: var(--quiBlt); --sepUser: var(--quiBlt);
--octUser: var(--quiBlt); --octUser: var(--quiBlt);
@@ -458,9 +458,9 @@
.palette-maryland { .palette-maryland {
--priUser: var(--quiBlt); --priUser: var(--quiBlt);
--secUser: var(--sixBlt); --secUser: var(--sixBlt);
--terUser: var(--octBlt); --terUser: var(--terYl);
--quaUser: var(--priBlt); --quaUser: var(--priBlt);
--quiUser: var(--secBlt); --quiUser: var(--octBlt);
--sixUser: var(--quiBlt); --sixUser: var(--quiBlt);
--sepUser: var(--quiBlt); --sepUser: var(--quiBlt);
--octUser: var(--quiBlt); --octUser: var(--quiBlt);

View File

@@ -0,0 +1,22 @@
<section
id="id_applet_my_sign"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>
<div class="my-sign-applet-body">
{% if request.user.significator %}
{% with card=request.user.significator %}
<div class="my-sign-applet-card{% if request.user.significator_reversed %} stage-card--reversed{% endif %}"
data-card-id="{{ card.id }}">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
</div>
<p class="fan-card-name">{{ card.name_title }}</p>
</div>
{% endwith %}
{% else %}
<p class="my-sign-applet-empty">No sign chosen yet.</p>
{% endif %}
</div>
</section>

View File

@@ -0,0 +1,122 @@
{% extends "core/base.html" %}
{% load static %}
{% block title_text %}Game Sign{% endblock title_text %}
{% block header_text %}<span>Game</span><span>Sign</span>{% endblock header_text %}
{% block content %}
{# Solo lift of `_sig_select_overlay.html`. Same card-grid + stage-card #}
{# choreography as the room sig-select, minus countdown / WebSocket / #}
{# polarity / multi-user. FLIP btn (.spin-btn) lets the user choose the #}
{# card's orientation; SAVE SIG persists it on the User model. #}
{# "Significator" is preserved at the storage layer (User.significator) + #}
{# game-room context; this billboard surface re-brands to "Sign". #}
<div class="my-sign-page"
data-save-url="{% url 'billboard:save_sign' %}"
{% if current_significator %}data-current-card-id="{{ current_significator.id }}"{% endif %}
data-current-reversed="{{ current_significator_reversed|yesno:'true,false' }}">
{% if not equipped_deck %}
<p class="my-sign-empty">
Equip a card deck first in the
<a href="{% url 'gameboard' %}">Game Kit</a> to pick your significator.
</p>
{% else %}
<div class="my-sign-stage">
<div class="sig-stage-card" style="display:none">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
<div class="fan-card-face">
<div class="fan-card-face-upright">
<p class="fan-card-name-group"></p>
<p class="sig-qualifier-above"></p>
<p class="fan-card-name"></p>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p>
<div class="fan-card-face-reversal">
<p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p>
</div>
</div>
<div class="fan-card-corner fan-card-corner--br">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
</div>
<div class="sig-stat-block">
<button class="btn btn-reverse spin-btn" type="button">FLIP</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<ul class="stat-keywords"></ul>
</div>
<div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords"></ul>
</div>
</div>
</div>
<div class="my-sign-deck-grid">
{% for card in cards %}
<div class="sig-card"
data-card-id="{{ card.id }}"
data-suit-icon="{{ card.suit_icon }}"
data-corner-rank="{{ card.corner_rank }}"
data-name-group="{{ card.name_group }}"
data-name-title="{{ card.name_title }}"
data-arcana="{{ card.get_arcana_display }}"
data-keywords-upright="{{ card.keywords_upright|join:',' }}"
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"
data-reversal-qualifier="{{ card.reversal_qualifier }}">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
</div>
</div>
{% endfor %}
</div>
<form id="id_save_sign_form" method="POST" action="{% url 'billboard:save_sign' %}">
{% csrf_token %}
<input type="hidden" name="card_id" id="id_save_sign_card_id" value="{{ current_significator.id|default:'' }}">
<input type="hidden" name="reversed" id="id_save_sign_reversed" value="{{ current_significator_reversed|yesno:'1,0' }}">
<button type="submit" id="id_save_sign_btn" class="btn btn-primary"{% if not current_significator %} disabled{% endif %}>SAVE SIGN</button>
</form>
{# Minimal picker JS — click .sig-card to pick + enable SAVE SIGN. #}
{# FLIP-btn integration (reversed toggle) lands w. the stage-card #}
{# preview JS in a Sprint 4a-follow-up; for now we just record #}
{# the chosen card_id + the FLIP state. #}
<script>
(function () {
var grid = document.querySelector('.my-sign-deck-grid');
if (!grid) return;
var cardIdInput = document.getElementById('id_save_sign_card_id');
var saveBtn = document.getElementById('id_save_sign_btn');
var flipBtn = document.querySelector('.my-sign-stage .spin-btn');
var revInput = document.getElementById('id_save_sign_reversed');
grid.addEventListener('click', function (e) {
var card = e.target.closest('.sig-card');
if (!card) return;
grid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
c.classList.remove('sig-focused');
});
card.classList.add('sig-focused');
cardIdInput.value = card.dataset.cardId;
saveBtn.removeAttribute('disabled');
});
if (flipBtn) {
flipBtn.addEventListener('click', function () {
var current = revInput.value === '1';
revInput.value = current ? '0' : '1';
flipBtn.classList.toggle('is-reversed', !current);
});
}
}());
</script>
{% endif %}
</div>
{% endblock content %}

View File

@@ -1,13 +1,13 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% load static %} {% load static %}
{% block title_text %}My Sea{% endblock title_text %} {% block title_text %}Game Sea{% endblock title_text %}
{% block header_text %}<span>My</span><span>Sea</span>{% endblock header_text %} {% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
{% block content %} {% block content %}
<div class="my-sea-page"> <div class="my-sea-page">
{# Sprint 3 shell only — gatekeeper / sig-select / sea-select phases #} {# Sprint 3 shell only — gatekeeper / sig-select / sea-select phases #}
{# will land here in later sprints of the My Sea roadmap. #} {# will land here in later sprints of the My Sea roadmap. #}
<p class="my-sea-page__empty">Your sea is calm. Draws will appear here.</p> <p class="my-sea-page__empty">No draws yet—the depths remain unfathomable.</p>
</div> </div>
{% endblock content %} {% endblock content %}