btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default .sig-stage .sig-stage-card .fan-card-face .sig-qualifier-* rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each .fan-card-reversal-* class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-<p> skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two <p>s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD

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 00:25:10 -04:00
parent ace8612099
commit 3242873625
23 changed files with 179 additions and 128 deletions

View File

@@ -624,7 +624,7 @@ class Character(models.Model):
"""A gamer's player-character for one seat in one game session. """A gamer's player-character for one seat in one game session.
Lifecycle: Lifecycle:
- Created (draft) when gamer opens PICK SKY overlay. - Created (draft) when gamer opens CAST SKY overlay.
- confirmed_at set on confirm → locked. - confirmed_at set on confirm → locked.
- retired_at set on retirement → archived (seat may hold a new Character). - retired_at set on retirement → archived (seat may hold a new Character).
@@ -647,7 +647,7 @@ class Character(models.Model):
TableSeat, on_delete=models.CASCADE, related_name='characters', TableSeat, on_delete=models.CASCADE, related_name='characters',
) )
# ── significator (set at PICK SKY) ──────────────────────────────────── # ── significator (set at CAST SKY) ────────────────────────────────────
significator = models.ForeignKey( significator = models.ForeignKey(
TarotCard, null=True, blank=True, TarotCard, null=True, blank=True,
on_delete=models.SET_NULL, related_name='character_significators', on_delete=models.SET_NULL, related_name='character_significators',
@@ -665,7 +665,7 @@ class Character(models.Model):
# ── computed sky snapshot (full PySwiss response) ─────────────────── # ── computed sky snapshot (full PySwiss response) ───────────────────
chart_data = models.JSONField(null=True, blank=True) chart_data = models.JSONField(null=True, blank=True)
# ── celtic cross spread (added at PICK SEA) ─────────────────────────── # ── celtic cross spread (added at DRAW SEA) ───────────────────────────
celtic_cross = models.JSONField(null=True, blank=True) celtic_cross = models.JSONField(null=True, blank=True)
# ── lifecycle ───────────────────────────────────────────────────────── # ── lifecycle ─────────────────────────────────────────────────────────

View File

@@ -317,7 +317,7 @@ var SigSelect = (function () {
}); });
} }
// ── TAKE SIG / WAIT NVM button ───────────────────────────────────────── // ── SAVE SIG / WAIT NVM button ─────────────────────────────────────────
function _onTakeSigClick() { function _onTakeSigClick() {
if (_isReady) { if (_isReady) {
@@ -337,7 +337,7 @@ var SigSelect = (function () {
_countdownTimer = null; _countdownTimer = null;
if (_takeSigBtn) _takeSigBtn.style.fontSize = ''; if (_takeSigBtn) _takeSigBtn.style.fontSize = '';
} }
if (_takeSigBtn) _takeSigBtn.textContent = 'TAKE SIG'; if (_takeSigBtn) _takeSigBtn.textContent = 'SAVE SIG';
_stopWaitNoGlow(); _stopWaitNoGlow();
_stopCountdownGlow(); _stopCountdownGlow();
} }
@@ -367,7 +367,7 @@ var SigSelect = (function () {
_takeSigBtn.id = 'id_take_sig_btn'; _takeSigBtn.id = 'id_take_sig_btn';
_takeSigBtn.className = 'btn btn-primary sig-take-sig-btn'; _takeSigBtn.className = 'btn btn-primary sig-take-sig-btn';
_takeSigBtn.type = 'button'; _takeSigBtn.type = 'button';
_takeSigBtn.textContent = 'TAKE SIG'; _takeSigBtn.textContent = 'SAVE SIG';
_takeSigBtn.addEventListener('click', _onTakeSigClick); _takeSigBtn.addEventListener('click', _onTakeSigClick);
stage.appendChild(_takeSigBtn); stage.appendChild(_takeSigBtn);
} }
@@ -495,7 +495,7 @@ var SigSelect = (function () {
function _showWaitingMsg(pendingPolarity) { function _showWaitingMsg(pendingPolarity) {
if (document.getElementById('id_hex_waiting_msg')) return; if (document.getElementById('id_hex_waiting_msg')) return;
// If the OTHER polarity finished before our tray sequence completed, // If the OTHER polarity finished before our tray sequence completed,
// pick_sky_available will already have fired and revealed PICK SKY. // pick_sky_available will already have fired and revealed CAST SKY.
// In that case skip the waiting msg so the two don't co-exist. // In that case skip the waiting msg so the two don't co-exist.
var pickSkyBtn = document.getElementById('id_pick_sky_btn'); var pickSkyBtn = document.getElementById('id_pick_sky_btn');
if (pickSkyBtn && pickSkyBtn.style.display !== 'none') return; if (pickSkyBtn && pickSkyBtn.style.display !== 'none') return;
@@ -539,7 +539,7 @@ var SigSelect = (function () {
// the overlay vanishes; overlay dismissal + waiting msg run last. // the overlay vanishes; overlay dismissal + waiting msg run last.
// User sees: stage card → tray slides in → sig fades into the tray // User sees: stage card → tray slides in → sig fades into the tray
// cell → tray slides out → 2s pause → overlay dismisses → table hex // cell → tray slides out → 2s pause → overlay dismisses → table hex
// w. waiting msg (or PICK SKY btn if both polarities are done). // w. waiting msg (or CAST SKY btn if both polarities are done).
function _settle() { function _settle() {
_dismissSigOverlay(); _dismissSigOverlay();
_showWaitingMsg(pendingPolarity); _showWaitingMsg(pendingPolarity);
@@ -625,7 +625,7 @@ var SigSelect = (function () {
userRole = overlay.dataset.userRole; userRole = overlay.dataset.userRole;
userPolarity= overlay.dataset.polarity; userPolarity= overlay.dataset.polarity;
// PICK SKY btn is rendered hidden during SIG_SELECT; reveal on pick_sky_available // CAST SKY btn is rendered hidden during SIG_SELECT; reveal on pick_sky_available
var pickSkyBtn = document.getElementById('id_pick_sky_btn'); var pickSkyBtn = document.getElementById('id_pick_sky_btn');
if (pickSkyBtn) { if (pickSkyBtn) {
pickSkyBtn.addEventListener('click', function () { pickSkyBtn.addEventListener('click', function () {

View File

@@ -143,26 +143,42 @@ var StageCard = (function () {
if (qBelow) qBelow.textContent = isMajor ? qualifier : ''; if (qBelow) qBelow.textContent = isMajor ? qualifier : '';
} }
// Reversal face — four cases: // Reversal face — class always matches semantic content:
// Polarity-split: full reversal title in qualifier slot (top-after-spin), name slot empty // .fan-card-reversal-name → title-like text
// Major: title (with comma) in qualifier slot, qualifier in name slot // .fan-card-reversal-qualifier qualifier-like text
// Non-major + reversal_qual: reversal_qualifier in qualifier slot, title in name slot // DOM order controls visual layout after the 180° SPIN (DOM-second appears
// Non-major no reversal_qual: fall back to current polarity's qualifier // visually on top). Sig + sea skeletons render a fixed slot pair so we
var rQual = stageCard.querySelector('.fan-card-reversal-qualifier'); // assign classes per-arcana here so each branch lands in the right slot:
var rName = stageCard.querySelector('.fan-card-reversal-name'); // Major / polarity-split — title on top → .name carried by DOM-second
if (rQual && rName) { // Non-major — qualifier on top → .qualifier carried by DOM-second
var slots = stageCard.querySelectorAll('.fan-card-face-reversal > p');
if (slots.length === 2) {
var bottomEl = slots[0]; // DOM-first → visually bottom after spin
var topEl = slots[1]; // DOM-second → visually top after spin
// Wipe any squeeze-class artifacts from a prior populate call before
// re-stamping the base class — keeps the slot's CSS predictable.
bottomEl.className = '';
topEl.className = '';
if (reversalOverride) { if (reversalOverride) {
_setTitle(rQual, reversalOverride, card); // Polarity-split: single-line title on TOP; qualifier slot empty.
rName.textContent = ''; topEl.className = 'fan-card-reversal-name';
_setTitle(topEl, reversalOverride, card);
bottomEl.className = 'fan-card-reversal-qualifier';
bottomEl.textContent = '';
} else if (isMajor) { } else if (isMajor) {
_setTitle(rQual, title + ',', card); // Major: title-with-comma on TOP, qualifier on BOTTOM.
rName.textContent = qualifier; topEl.className = 'fan-card-reversal-name';
} else if (reversalQualifier) { _setTitle(topEl, title + ',', card);
rQual.textContent = reversalQualifier; bottomEl.className = 'fan-card-reversal-qualifier';
_setTitle(rName, title, card); bottomEl.textContent = qualifier;
} else { } else {
rQual.textContent = qualifier; // Non-major: qualifier on TOP, title on BOTTOM (inverted from
_setTitle(rName, title, card); // upright order by design — qualifier word reads first after spin).
topEl.className = 'fan-card-reversal-qualifier';
topEl.textContent = reversalQualifier || qualifier;
bottomEl.className = 'fan-card-reversal-name';
_setTitle(bottomEl, title, card);
} }
} }
} }

View File

@@ -1,5 +1,5 @@
""" """
Countdown scheduler for the polarity-room TAKE SIG gate. Countdown scheduler for the polarity-room SAVE SIG gate.
Uses threading.Timer so no separate Celery worker is needed in development. Uses threading.Timer so no separate Celery worker is needed in development.
Single-process only — swap for a Celery task if production uses multiple Single-process only — swap for a Celery task if production uses multiple

View File

@@ -879,7 +879,7 @@ class SelectRoleMultiSeatTest(TestCase):
class RoomViewAllRolesFilledTest(TestCase): class RoomViewAllRolesFilledTest(TestCase):
"""Room view in ROLE_SELECT with all seats assigned shows PICK SIGS button.""" """Room view in ROLE_SELECT with all seats assigned shows SCAN SIGS button."""
def setUp(self): def setUp(self):
import lxml.html import lxml.html
self.lxml = lxml.html self.lxml = lxml.html
@@ -1785,7 +1785,7 @@ class SigConfirmViewTest(TestCase):
# ── SKY_SELECT rendering ────────────────────────────────────────────────────── # ── SKY_SELECT rendering ──────────────────────────────────────────────────────
class PickSkyRenderingTest(TestCase): class PickSkyRenderingTest(TestCase):
"""Room page at SKY_SELECT renders PICK SKY btn and sig card in tray cell 2.""" """Room page at SKY_SELECT renders CAST SKY btn and sig card in tray cell 2."""
def setUp(self): def setUp(self):
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
@@ -1865,7 +1865,7 @@ class PickSkyRenderingTest(TestCase):
self.assertEqual(founder.sky_birth_tz, "America/New_York") self.assertEqual(founder.sky_birth_tz, "America/New_York")
def test_no_sky_delete_btn_in_blank_sky_select_modal(self): def test_no_sky_delete_btn_in_blank_sky_select_modal(self):
"""A fresh PICK SKY modal (no preview wheel rendered yet) must not """A fresh CAST SKY modal (no preview wheel rendered yet) must not
carry the DEL btn — it would otherwise float in the empty wheel area carry the DEL btn — it would otherwise float in the empty wheel area
suggesting there's something to delete when the user has only seen suggesting there's something to delete when the user has only seen
the form. The JS schedulePreview success handler is the contract that the form. The JS schedulePreview success handler is the contract that
@@ -1881,7 +1881,7 @@ class PickSkyRenderingTest(TestCase):
# ── SEA_SELECT rendering ────────────────────────────────────────────────────── # ── SEA_SELECT rendering ──────────────────────────────────────────────────────
class PickSeaRenderingTest(TestCase): class PickSeaRenderingTest(TestCase):
"""At SKY_SELECT, a confirmed Character swaps PICK SKY → PICK SEA + sea overlay.""" """At SKY_SELECT, a confirmed Character swaps CAST SKY → DRAW SEA + sea overlay."""
def setUp(self): def setUp(self):
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)

View File

@@ -5,7 +5,7 @@ from apps.epic.models import Room, RoomInvite
# ── Game-wide constants ──────────────────────────────────────────────────── # ── Game-wide constants ────────────────────────────────────────────────────
# Reversal probability applied to any card pulled from a stack, anywhere in # Reversal probability applied to any card pulled from a stack, anywhere in
# the game (PICK SEA initially; future phases — gameplay draws etc. — will # the game (DRAW SEA initially; future phases — gameplay draws etc. — will
# share this single source of truth). Stub for a future per-user profile # share this single source of truth). Stub for a future per-user profile
# override: callers MUST go through stack_reversal_probability(user, room) # override: callers MUST go through stack_reversal_probability(user, room)
# rather than referencing the constant directly so the user-config hookup is # rather than referencing the constant directly so the user-config hookup is

View File

@@ -413,7 +413,7 @@ def room_view(request, room_id):
ctx = _role_select_context(room, request.user) ctx = _role_select_context(room, request.user)
ctx["room"] = room ctx["room"] = room
ctx["page_class"] = "page-gameboard" ctx["page_class"] = "page-gameboard"
# Reversal-rate hint label under PICK SEA's SPREAD select — same helper as # Reversal-rate hint label under DRAW SEA's SPREAD select — same helper as
# sea_partial so the value tracks any future per-user override automatically. # sea_partial so the value tracks any future per-user override automatically.
ctx["stack_reversal_pct"] = int(round(stack_reversal_probability(request.user, room) * 100)) ctx["stack_reversal_pct"] = int(round(stack_reversal_probability(request.user, room) * 100))
return render(request, "apps/gameboard/room.html", ctx) return render(request, "apps/gameboard/room.html", ctx)
@@ -1223,7 +1223,7 @@ def sky_save(request, room_id):
@login_required @login_required
def sky_delete(request, room_id): def sky_delete(request, room_id):
"""Purge the requesting gamer's Character on this seat — both unconfirmed """Purge the requesting gamer's Character on this seat — both unconfirmed
drafts AND confirmed rows. The in-room PICK SKY DEL targets this so SAVE drafts AND confirmed rows. The in-room CAST SKY DEL targets this so SAVE
SKY → DEL → refresh truly drops the saved sky for the seat. The User SKY → DEL → refresh truly drops the saved sky for the seat. The User
model's sky_chart_data is intentionally untouched (Dashsky / My Sky model's sky_chart_data is intentionally untouched (Dashsky / My Sky
applet's DEL handles that separately).""" applet's DEL handles that separately)."""
@@ -1239,7 +1239,7 @@ def sky_delete(request, room_id):
@login_required @login_required
def sea_deck(request, room_id): def sea_deck(request, room_id):
"""Shuffled deck lists (levity + gravity halves) for PICK SEA draw. """Shuffled deck lists (levity + gravity halves) for DRAW SEA draw.
Excludes all Significators already claimed by seated gamers. Excludes all Significators already claimed by seated gamers.
Returns {levity: [{id, name, arcana, suit, number, levity_qualifier, Returns {levity: [{id, name, arcana, suit, number, levity_qualifier,

View File

@@ -1,8 +1,8 @@
""" """
Management command for manual single-gamer PICK SEA testing. Management command for manual single-gamer DRAW SEA testing.
Creates a room at SKY_SELECT with one seated gamer whose sky is already Creates a room at SKY_SELECT with one seated gamer whose sky is already
confirmed, so the PICK SEA overlay is immediately visible on page load. confirmed, so the DRAW SEA overlay is immediately visible on page load.
Usage: Usage:
python src/manage.py setup_sea_session python src/manage.py setup_sea_session
@@ -32,7 +32,7 @@ def _make_session(user):
class Command(BaseCommand): class Command(BaseCommand):
help = "Set up a SKY_SELECT room with sky confirmed and print a PICK SEA URL" help = "Set up a SKY_SELECT room with sky confirmed and print a DRAW SEA URL"
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument("--base-url", default="http://localhost:8000") parser.add_argument("--base-url", default="http://localhost:8000")

View File

@@ -453,7 +453,7 @@ class BillscrollGearMenuTest(FunctionalTest):
FT: the billscroll page has a gear menu that filters events by label. FT: the billscroll page has a gear menu that filters events by label.
Frame = all regular (non-struck) drama entries. Frame = all regular (non-struck) drama entries.
Redact = struck-through (retracted) entries, e.g. a WAIT NVM after TAKE SIG. Redact = struck-through (retracted) entries, e.g. a WAIT NVM after SAVE SIG.
Scenario (one gamer, Role + Sig events): Scenario (one gamer, Role + Sig events):
1. Both labels checked by default — all events visible. 1. Both labels checked by default — all events visible.

View File

@@ -843,12 +843,12 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
)) ))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test 7 — PICK SIGS appears + card stack removed on last role # # Test 7 — SCAN SIGS appears + card stack removed on last role #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_pick_sigs_appears_and_card_stack_removed_on_last_role(self): def test_pick_sigs_appears_and_card_stack_removed_on_last_role(self):
"""When the sixth and final role is confirmed, the all_roles_filled """When the sixth and final role is confirmed, the all_roles_filled
WS event makes the PICK SIGS button visible and removes the card WS event makes the SCAN SIGS button visible and removes the card
stack from the DOM entirely.""" stack from the DOM entirely."""
emails = [ emails = [
"founder@test.io", "amigo@test.io", "bud@test.io", "founder@test.io", "amigo@test.io", "bud@test.io",
@@ -883,7 +883,7 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
self.confirm_guard() self.confirm_guard()
# PICK SIGS wrap must become visible via the all_roles_filled WS event. # SCAN SIGS wrap must become visible via the all_roles_filled WS event.
self.wait_for(lambda: self.assertFalse( self.wait_for(lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_pick_sigs_wrap").get_attribute("style"), self.browser.find_element(By.ID, "id_pick_sigs_wrap").get_attribute("style"),
)) ))

View File

@@ -1,4 +1,4 @@
"""Functional tests for the PICK SEA overlay — Celtic Cross draw.""" """Functional tests for the DRAW SEA overlay — Celtic Cross draw."""
from django.test import tag from django.test import tag
from django.urls import reverse from django.urls import reverse
@@ -39,7 +39,7 @@ def _make_sky_confirmed_room(live_server_url, user, earthman):
@tag("channels") @tag("channels")
class PickSeaAsyncTransitionTest(ChannelsFunctionalTest): class PickSeaAsyncTransitionTest(ChannelsFunctionalTest):
"""After sky confirm, the sky overlay closes and the room reloads to the """After sky confirm, the sky overlay closes and the room reloads to the
table hex w. the PICK SEA btn visible — the gamer must opt into the sea table hex w. the DRAW SEA btn visible — the gamer must opt into the sea
overlay rather than be auto-launched into it.""" overlay rather than be auto-launched into it."""
def setUp(self): def setUp(self):
@@ -92,22 +92,22 @@ class PickSeaAsyncTransitionTest(ChannelsFunctionalTest):
""") """)
def test_pick_sea_btn_visible_after_sky_confirm(self): def test_pick_sea_btn_visible_after_sky_confirm(self):
"""Confirming sky reloads the room to the hex w. PICK SEA replacing """Confirming sky reloads the room to the hex w. DRAW SEA replacing
PICK SKY; the sea overlay is NOT auto-opened.""" CAST SKY; the sea overlay is NOT auto-opened."""
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.room_url) self.browser.get(self.room_url)
# Sky not yet confirmed — PICK SKY btn present. # Sky not yet confirmed — CAST SKY btn present.
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
self._confirm_sky() self._confirm_sky()
# Page reloads → hex shows PICK SEA in place of PICK SKY. # Page reloads → hex shows DRAW SEA in place of CAST SKY.
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn")) self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn"))
self.assertEqual(self.browser.find_elements(By.ID, "id_pick_sky_btn"), []) self.assertEqual(self.browser.find_elements(By.ID, "id_pick_sky_btn"), [])
# Sea overlay is NOT auto-opened — it only appears once the gamer # Sea overlay is NOT auto-opened — it only appears once the gamer
# clicks PICK SEA. # clicks DRAW SEA.
has_sea_open = self.browser.execute_script( has_sea_open = self.browser.execute_script(
"return document.documentElement.classList.contains('sea-open');" "return document.documentElement.classList.contains('sea-open');"
) )
@@ -126,7 +126,7 @@ class PickSeaAsyncTransitionTest(ChannelsFunctionalTest):
self.assertTrue(not sky or not sky[0].is_displayed()) self.assertTrue(not sky or not sky[0].is_displayed())
def test_clicking_pick_sea_btn_opens_sea_overlay(self): def test_clicking_pick_sea_btn_opens_sea_overlay(self):
"""The gamer's explicit click on PICK SEA is what opens the sea overlay.""" """The gamer's explicit click on DRAW SEA is what opens the sea overlay."""
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.room_url) self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
@@ -134,7 +134,7 @@ class PickSeaAsyncTransitionTest(ChannelsFunctionalTest):
self._confirm_sky() self._confirm_sky()
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn")) self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn"))
# On slow CI, the PICK SEA btn parses into the DOM before the inline # On slow CI, the DRAW SEA btn parses into the DOM before the inline
# `<script>` at the bottom of _sea_overlay.html has bound `openSea` to # `<script>` at the bottom of _sea_overlay.html has bound `openSea` to
# it; a one-shot click can land before the handler exists. Retry click # it; a one-shot click can land before the handler exists. Retry click
# + assert together via wait_for so the race resolves naturally. # + assert together via wait_for so the race resolves naturally.
@@ -150,7 +150,7 @@ class PickSeaAsyncTransitionTest(ChannelsFunctionalTest):
self.assertTrue(sea_overlay.is_displayed()) self.assertTrue(sea_overlay.is_displayed())
# ── Helpers for PICK SEA deal tests ────────────────────────────────────────── # ── Helpers for DRAW SEA deal tests ──────────────────────────────────────────
def _seed_earthman_cards(earthman, count=20): def _seed_earthman_cards(earthman, count=20):
"""Seed enough Middle Arcana cards for the deck piles.""" """Seed enough Middle Arcana cards for the deck piles."""
@@ -167,7 +167,7 @@ def _seed_earthman_cards(earthman, count=20):
def _make_sea_ready_room(earthman): def _make_sea_ready_room(earthman):
"""Create a SKY_SELECT room with a confirmed Character ready for PICK SEA. """Create a SKY_SELECT room with a confirmed Character ready for DRAW SEA.
Returns (room, gamer, seat, char, room_url). Returns (room, gamer, seat, char, room_url).
""" """
@@ -199,7 +199,7 @@ def _make_sea_ready_room(earthman):
@tag("channels") @tag("channels")
class PickSeaDealTest(ChannelsFunctionalTest): class PickSeaDealTest(ChannelsFunctionalTest):
"""PICK SEA deck stacks, OK btn interaction, card draw, and LOCK HAND.""" """DRAW SEA deck stacks, OK btn interaction, card draw, and LOCK HAND."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()

View File

@@ -325,25 +325,25 @@ class SigSelectThemeTest(FunctionalTest):
self.assertEqual(corr.text, "") self.assertEqual(corr.text, "")
# ── TAKE SIG / WAIT NVM — ready gate ────────────────────────────────────────── # ── SAVE SIG / WAIT NVM — ready gate ──────────────────────────────────────────
# #
# TAKE SIG (.btn.btn-primary) appears at the bottom-left corner of the card # SAVE SIG (.btn.btn-primary) appears at the bottom-left corner of the card
# stage preview once a gamer has clicked OK on a card (SigReservation exists). # stage preview once a gamer has clicked OK on a card (SigReservation exists).
# Clicking it sets the gamer's status to ready and changes the btn to WAIT NVM. # Clicking it sets the gamer's status to ready and changes the btn to WAIT NVM.
# WAIT NVM cancels the ready status and reverts back to TAKE SIG. # WAIT NVM cancels the ready status and reverts back to SAVE SIG.
# #
# When all three gamers in a polarity WS room are ready, a 12-second countdown # When all three gamers in a polarity WS room are ready, a 12-second countdown
# starts. Any WAIT NVM during the countdown cancels it; the saved remaining time # starts. Any WAIT NVM during the countdown cancels it; the saved remaining time
# is resumed when all three are ready again. When the countdown completes # is resumed when all three are ready again. When the countdown completes
# (client POSTs sig_confirm) the polarity group returns to the table hex. # (client POSTs sig_confirm) the polarity group returns to the table hex.
# When both polarity groups have confirmed, PICK SKY btn appears in the hex # When both polarity groups have confirmed, CAST SKY btn appears in the hex
# center for all six gamers. # center for all six gamers.
# #
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
class SigReadyGateTest(FunctionalTest): class SigReadyGateTest(FunctionalTest):
"""Single-browser tests for TAKE SIG / WAIT NVM btn.""" """Single-browser tests for SAVE SIG / WAIT NVM btn."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@@ -377,10 +377,10 @@ class SigReadyGateTest(FunctionalTest):
) )
ok_btn.click() ok_btn.click()
# ── SRG1: TAKE SIG btn not visible before OK ──────────────────────── # # ── SRG1: SAVE SIG btn not visible before OK ──────────────────────── #
def test_take_sig_btn_not_visible_before_ok_click(self): def test_take_sig_btn_not_visible_before_ok_click(self):
"""TAKE SIG must be absent until the gamer has OK'd a card.""" """SAVE SIG must be absent until the gamer has OK'd a card."""
room = self._setup_sig_room() room = self._setup_sig_room()
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
@@ -389,7 +389,7 @@ class SigReadyGateTest(FunctionalTest):
take_sig_btns = self.browser.find_elements(By.ID, "id_take_sig_btn") take_sig_btns = self.browser.find_elements(By.ID, "id_take_sig_btn")
self.assertEqual(len(take_sig_btns), 0) self.assertEqual(len(take_sig_btns), 0)
# ── SRG2: TAKE SIG btn appears after OK ──────────────────────────── # # ── SRG2: SAVE SIG btn appears after OK ──────────────────────────── #
def test_take_sig_btn_appears_after_ok_click(self): def test_take_sig_btn_appears_after_ok_click(self):
room = self._setup_sig_room() room = self._setup_sig_room()
@@ -401,9 +401,9 @@ class SigReadyGateTest(FunctionalTest):
take_sig_btn = self.wait_for( take_sig_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_take_sig_btn") lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
) )
self.assertIn("TAKE SIG", take_sig_btn.text.upper()) self.assertIn("SAVE SIG", take_sig_btn.text.upper())
# ── SRG3: TAKE SIG → WAIT NVM ─────────────────────────────────────── # # ── SRG3: SAVE SIG → WAIT NVM ─────────────────────────────────────── #
def test_take_sig_btn_becomes_wait_no_after_click(self): def test_take_sig_btn_becomes_wait_no_after_click(self):
room = self._setup_sig_room() room = self._setup_sig_room()
@@ -429,7 +429,7 @@ class SigReadyGateTest(FunctionalTest):
).get_attribute("class") ).get_attribute("class")
) )
# ── SRG4: WAIT NVM → TAKE SIG ─────────────────────────────────────── # # ── SRG4: WAIT NVM → SAVE SIG ─────────────────────────────────────── #
def test_wait_no_reverts_to_take_sig(self): def test_wait_no_reverts_to_take_sig(self):
room = self._setup_sig_room() room = self._setup_sig_room()
@@ -446,11 +446,11 @@ class SigReadyGateTest(FunctionalTest):
By.ID, "id_take_sig_btn").text.upper() By.ID, "id_take_sig_btn").text.upper()
) )
btn = self.browser.find_element(By.ID, "id_take_sig_btn") btn = self.browser.find_element(By.ID, "id_take_sig_btn")
btn.click() # → TAKE SIG again btn.click() # → SAVE SIG again
self.wait_for( self.wait_for(
lambda: self.assertIn( lambda: self.assertIn(
"TAKE SIG", "SAVE SIG",
self.browser.find_element(By.ID, "id_take_sig_btn").text.upper(), self.browser.find_element(By.ID, "id_take_sig_btn").text.upper(),
) )
) )
@@ -458,7 +458,7 @@ class SigReadyGateTest(FunctionalTest):
@tag("channels") @tag("channels")
class SigReadyCountdownChannelsTest(ChannelsFunctionalTest): class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
"""Multi-browser WebSocket tests for the polarity-room countdown and PICK SKY.""" """Multi-browser WebSocket tests for the polarity-room countdown and CAST SKY."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@@ -546,7 +546,7 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
@tag("channels") @tag("channels")
def test_countdown_element_appears_when_all_three_levity_gamers_ready(self): def test_countdown_element_appears_when_all_three_levity_gamers_ready(self):
"""When PC, NC, and SC each click TAKE SIG the countdown becomes visible.""" """When PC, NC, and SC each click SAVE SIG the countdown becomes visible."""
room, emails = self._setup_sig_select_room() room, emails = self._setup_sig_select_room()
levity_emails = [emails[0], emails[1], emails[3]] # PC, NC, SC levity_emails = [emails[0], emails[1], emails[3]] # PC, NC, SC
browsers = [] browsers = []
@@ -556,7 +556,7 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
b.get(self.live_server_url + f"/gameboard/room/{room.pk}/") b.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
browsers.append(b) browsers.append(b)
# Each levity gamer OK's a card then clicks TAKE SIG # Each levity gamer OK's a card then clicks SAVE SIG
for b in browsers: for b in browsers:
self._ok_card_in_browser(b) self._ok_card_in_browser(b)
self.wait_for( self.wait_for(
@@ -616,12 +616,12 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
for b in browsers: for b in browsers:
b.quit() b.quit()
# ── SRG7: PICK SKY btn appears after both polarity groups confirm ─── # # ── SRG7: CAST SKY btn appears after both polarity groups confirm ─── #
@tag("channels") @tag("channels")
def test_pick_sky_btn_appears_in_hex_after_both_groups_confirm(self): def test_pick_sky_btn_appears_in_hex_after_both_groups_confirm(self):
"""Once both levity and gravity countdowns complete, all six browsers """Once both levity and gravity countdowns complete, all six browsers
see the PICK SKY btn in the table hex center.""" see the CAST SKY btn in the table hex center."""
# This test drives the full flow end-to-end but uses ORM shortcuts # This test drives the full flow end-to-end but uses ORM shortcuts
# to set all-ready state for one polarity, letting the other complete # to set all-ready state for one polarity, letting the other complete
# via the UI, to keep execution time manageable. # via the UI, to keep execution time manageable.
@@ -647,7 +647,7 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
b.get(self.live_server_url + f"/gameboard/room/{room.pk}/") b.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
browsers.append(b) browsers.append(b)
# All levity gamers OK and TAKE SIG # All levity gamers OK and SAVE SIG
for b in browsers: for b in browsers:
self._ok_card_in_browser(b) self._ok_card_in_browser(b)
self.wait_for( self.wait_for(
@@ -655,7 +655,7 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
) )
b.find_element(By.ID, "id_take_sig_btn").click() b.find_element(By.ID, "id_take_sig_btn").click()
# Wait for countdown to expire or be confirmed; PICK SKY appears in hex # Wait for countdown to expire or be confirmed; CAST SKY appears in hex
# countdown is 12 s so use wait_for_slow (MAX_WAIT=10 is not enough) # countdown is 12 s so use wait_for_slow (MAX_WAIT=10 is not enough)
for b in browsers: for b in browsers:
self.wait_for_slow( self.wait_for_slow(
@@ -711,7 +711,7 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
# class PickSkyTrayFlowTest(FunctionalTest): # class PickSkyTrayFlowTest(FunctionalTest):
# #
# def test_pick_sky_btn_opens_tray_with_sig_card_in_slot_2(self): # def test_pick_sky_btn_opens_tray_with_sig_card_in_slot_2(self):
# """Clicking PICK SKY opens #id_tray; tray cell 2 shows the gamer's # """Clicking CAST SKY opens #id_tray; tray cell 2 shows the gamer's
# sig card icon (Blank.svg placeholder until card-specific icons land).""" # sig card icon (Blank.svg placeholder until card-specific icons land)."""
# ... # ...
# #

View File

@@ -1,4 +1,4 @@
"""Functional tests for the PICK SKY overlay — natal chart entry.""" """Functional tests for the CAST SKY overlay — natal chart entry."""
import json as _json import json as _json
@@ -42,7 +42,7 @@ def _make_sky_select_room():
class PickSkyLocalStorageTest(FunctionalTest): class PickSkyLocalStorageTest(FunctionalTest):
"""PICK SKY form fields persist to localStorage.""" """CAST SKY form fields persist to localStorage."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@@ -145,7 +145,7 @@ class PickSkyLocalStorageTest(FunctionalTest):
class PickSkyDelTest(FunctionalTest): class PickSkyDelTest(FunctionalTest):
"""PICK SKY overlay gets a DEL btn at the wheel center: clicking opens the """CAST SKY overlay gets a DEL btn at the wheel center: clicking opens the
global guard portal; OK clears the wheel SVG, resets the form fields, & global guard portal; OK clears the wheel SVG, resets the form fields, &
purges the localStorage entry that would otherwise rehydrate the form on purges the localStorage entry that would otherwise rehydrate the form on
the next overlay open / page refresh. No server hit (the wheel here is the next overlay open / page refresh. No server hit (the wheel here is
@@ -173,7 +173,7 @@ class PickSkyDelTest(FunctionalTest):
self.create_pre_authenticated_session(self.founder_email) self.create_pre_authenticated_session(self.founder_email)
self.browser.get(self.room_url) self.browser.get(self.room_url)
# Open PICK SKY modal # Open CAST SKY modal
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
self.browser.execute_script("arguments[0].click()", btn) self.browser.execute_script("arguments[0].click()", btn)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sky_overlay")) self.wait_for(lambda: self.browser.find_element(By.ID, "id_sky_overlay"))

View File

@@ -548,14 +548,14 @@ describe("SigSelect", () => {
.toBe(card.dataset.nameTitle); .toBe(card.dataset.nameTitle);
}); });
it("major arcana reversed face: title, in qualifier slot (first after spin); qualifier in name slot (second)", () => { it("major arcana reversed face: title in name slot (visually top after spin); qualifier in qualifier slot (visually bottom)", () => {
makeFixture({ polarity: "levity", userRole: "PC" }); makeFixture({ polarity: "levity", userRole: "PC" });
card.dataset.arcana = "Major Arcana"; card.dataset.arcana = "Major Arcana";
card.dataset.nameTitle = "The Schizo"; card.dataset.nameTitle = "The Schizo";
hover(); hover();
// DOM-second element appears first after card spins — so title goes in qualifier slot // Class matches semantic content: title → .fan-card-reversal-name, qualifier → .fan-card-reversal-qualifier
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("The Schizo,"); expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("The Schizo,");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Elevated"); expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated");
}); });
it("non-major without data-reversal-qualifier: qualifier mirrors polarity, name repeats card title", () => { it("non-major without data-reversal-qualifier: qualifier mirrors polarity, name repeats card title", () => {
@@ -812,7 +812,7 @@ describe("SigSelect", () => {
// ── polarity_room_done → tray sequence ─────────────────────────────────── // // ── polarity_room_done → tray sequence ─────────────────────────────────── //
// //
// After all 3 gamers in the user's polarity confirm TAKE SIG and the // After all 3 gamers in the user's polarity confirm SAVE SIG and the
// 12s countdown expires, the server fires room:polarity_room_done. The // 12s countdown expires, the server fires room:polarity_room_done. The
// sig-select handler should: (1) play the tray sequence — Tray.placeSig // sig-select handler should: (1) play the tray sequence — Tray.placeSig
// with the user's selected stage card; (2) on Tray.placeSig's completion // with the user's selected stage card; (2) on Tray.placeSig's completion
@@ -876,7 +876,7 @@ describe("SigSelect", () => {
it("does NOT add the waiting msg when pick_sky_btn is already revealed", () => { it("does NOT add the waiting msg when pick_sky_btn is already revealed", () => {
// pick_sky_available may fire DURING the tray sequence (other // pick_sky_available may fire DURING the tray sequence (other
// polarity finishes first). When the tray callback then hangs + // polarity finishes first). When the tray callback then hangs +
// dismisses, _settle must check whether PICK SKY is up and skip // dismisses, _settle must check whether CAST SKY is up and skip
// the "Levity appraising…" / "Gravity settling…" message so it // the "Levity appraising…" / "Gravity settling…" message so it
// doesn't co-exist w. the btn. // doesn't co-exist w. the btn.
const btn = document.createElement("button"); const btn = document.createElement("button");

View File

@@ -827,16 +827,28 @@ html:has(.sig-backdrop) {
.fan-card-name { color: rgba(var(--quiUser), 1); } .fan-card-name { color: rgba(var(--quiUser), 1); }
.fan-card-arcana { color: rgba(var(--priUser), 1); } .fan-card-arcana { color: rgba(var(--priUser), 1); }
} }
// Polarity qualifier: same colour as the card title in this context // Polarity title + qualifier text: --quiUser for levity (paired w. gravity's --terUser).
.sig-qualifier-above, // All five selectors prefixed w. .sig-stage-card to match (or beat) the 0,4,0 specificity
.sig-qualifier-below { color: rgba(var(--quiUser), 1); } // of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule —
// Upright + reversal title glow — levity // without the prefix the polarity color loses the cascade on .sig-qualifier-*.
.sig-stage-card .fan-card-name,
.sig-stage-card .fan-card-reversal-name,
.sig-stage-card .fan-card-reversal-qualifier,
.sig-stage-card .sig-qualifier-above,
.sig-stage-card .sig-qualifier-below { color: rgba(var(--quiUser), 1); }
// Stat-face label: levity stat-block bg is --priUser (opposite of levity card's
// --secUser bg), so the label takes the gravity-card text color (--terUser) to
// stay legible against the dark stat-block.
.sig-stat-block .stat-face-label { color: rgba(var(--terUser), 1); }
// Upright + reversal title glow — levity. Drop-shadow is WHITE here (was 0,0,0
// at 0.55) because the inverted-frame levity card uses a light --secUser bg,
// so a dark drop shadow reads as harsh smudge under the --quiUser title text.
.sig-stage-card .fan-card-name, .sig-stage-card .fan-card-name,
.sig-stage-card .sig-qualifier-above, .sig-stage-card .sig-qualifier-above,
.sig-stage-card .sig-qualifier-below, .sig-stage-card .sig-qualifier-below,
.sig-stage-card .fan-card-reversal-name, .sig-stage-card .fan-card-reversal-name,
.sig-stage-card .fan-card-reversal-qualifier { .sig-stage-card .fan-card-reversal-qualifier {
text-shadow: 0 1px 1px rgba(0,0,0,0.55), 0 0 0.55rem rgba(var(--ninUser), 0.7); text-shadow: 0 1px 1px rgba(255,255,255,0.55), 0 0 0.55rem rgba(var(--ninUser), 0.7);
} }
// card-ref spans inside the caution tooltip — must match the base rule's // card-ref spans inside the caution tooltip — must match the base rule's
// .sig-stat-block .sig-info-effect .card-ref specificity (0,3,0) to win. // .sig-stat-block .sig-info-effect .card-ref specificity (0,3,0) to win.
@@ -853,9 +865,18 @@ html:has(.sig-backdrop) {
// Caution tooltip: --tooltip-bg is black so priUser text (dark) would be invisible — // Caution tooltip: --tooltip-bg is black so priUser text (dark) would be invisible —
// override to secUser (light) so body text reads against the dark backdrop. // override to secUser (light) so body text reads against the dark backdrop.
.sig-info { color: rgba(var(--secUser), 1); } .sig-info { color: rgba(var(--secUser), 1); }
// Polarity qualifier: terUser for gravity (quiUser is levity's equivalent) // Polarity title + qualifier text: --terUser for gravity (paired w. levity's --quiUser).
.sig-qualifier-above, // All five selectors prefixed w. .sig-stage-card to meet the 0,4,0 specificity of the
.sig-qualifier-below { color: rgba(var(--terUser), 1); } // default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule.
.sig-stage-card .fan-card-name,
.sig-stage-card .fan-card-reversal-name,
.sig-stage-card .fan-card-reversal-qualifier,
.sig-stage-card .sig-qualifier-above,
.sig-stage-card .sig-qualifier-below { color: rgba(var(--terUser), 1); }
// Stat-face label: gravity stat-block bg is --secUser (opposite of gravity card's
// --priUser bg), so the label takes the levity-card text color (--quiUser) to
// stay legible against the lighter stat-block.
.sig-stat-block .stat-face-label { color: rgba(var(--quiUser), 1); }
// Upright + reversal title glow — gravity // Upright + reversal title glow — gravity
.sig-stage-card .fan-card-name, .sig-stage-card .fan-card-name,
.sig-stage-card .sig-qualifier-above, .sig-stage-card .sig-qualifier-above,
@@ -935,7 +956,7 @@ html:has(.sig-backdrop) {
#id_room_menu { right: 2.5rem; } #id_room_menu { right: 2.5rem; }
} }
// ── PICK SEA overlay ───────────────────────────────────────────────────────── // ── DRAW SEA overlay ─────────────────────────────────────────────────────────
// Mirrors .sky-* structure but with columns reversed: // Mirrors .sky-* structure but with columns reversed:
// left = transparent (Celtic Cross card positions) // left = transparent (Celtic Cross card positions)
// right = rgba(--priUser) opaque (spread select) // right = rgba(--priUser) opaque (spread select)
@@ -1548,14 +1569,17 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6);
100% { transform: perspective(600px) rotateY(0deg) scale(1); opacity: 1; } 100% { transform: perspective(600px) rotateY(0deg) scale(1); opacity: 1; }
} }
// Sea stage card title — polarity-specific colour + shared glow // Sea stage card title — polarity-specific colour + glow. Drop shadow polarity-split:
$_sea-title-shadow: 1px 1px 0 rgba(0,0,0,1), 0 0 0.25rem rgba(var(--ninUser), 0.25); // levity card has a light --secUser bg so a dark drop reads as smudge under the
// --quiUser title; gravity card is dark --priUser bg so the dark drop reads clean.
$_sea-title-shadow-levity: 1px 1px 0 rgba(255,255,255,1), 0 0 0.25rem rgba(var(--ninUser), 0.25);
$_sea-title-shadow-gravity: 1px 1px 0 rgba(0,0,0,1), 0 0 0.25rem rgba(var(--ninUser), 0.25);
$_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .fan-card-reversal-name, .fan-card-reversal-qualifier'; $_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .fan-card-reversal-name, .fan-card-reversal-qualifier';
.sea-stage--levity .sea-stage-card { .sea-stage--levity .sea-stage-card {
@include stage-card-polarity( @include stage-card-polarity(
$titles-color: rgba(var(--quiUser), 1), $titles-color: rgba(var(--quiUser), 1),
$text-shadow: $_sea-title-shadow, $text-shadow: $_sea-title-shadow-levity,
$invert-frame: true, $invert-frame: true,
); );
color: rgba(var(--priUser), 1); color: rgba(var(--priUser), 1);
@@ -1565,7 +1589,7 @@ $_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .f
.sea-stage--gravity .sea-stage-card { .sea-stage--gravity .sea-stage-card {
@include stage-card-polarity( @include stage-card-polarity(
$titles-color: rgba(var(--terUser), 1), $titles-color: rgba(var(--terUser), 1),
$text-shadow: $_sea-title-shadow, $text-shadow: $_sea-title-shadow-gravity,
); );
} }

View File

@@ -1,6 +1,6 @@
// ─── Sky (Pick Sky) overlay ──────────────────────────────────────────────── // ─── Sky (Pick Sky) overlay ────────────────────────────────────────────────
// Gaussian backdrop + centred modal, matching the gate/sig overlay pattern. // Gaussian backdrop + centred modal, matching the gate/sig overlay pattern.
// Open state: html.sky-open (added by JS on PICK SKY click). // Open state: html.sky-open (added by JS on CAST SKY click).
// //
// Layout: header / two-column body (form | wheel) / footer // Layout: header / two-column body (form | wheel) / footer
// Collapses to stacked single-column below 600 px. // Collapses to stacked single-column below 600 px.
@@ -956,7 +956,7 @@ body[class*="-light"] #id_sky_tooltip_2 {
} }
// DEL btn pinned at the wheel center — appears wherever a wheel is shown // DEL btn pinned at the wheel center — appears wherever a wheel is shown
// (Dashsky form#id_sky_delete_form, PICK SKY overlay #id_sky_delete_btn, // (Dashsky form#id_sky_delete_form, CAST SKY overlay #id_sky_delete_btn,
// My Sky applet #id_applet_sky_delete_btn). Anchored to .sky-wheel-col / // My Sky applet #id_applet_sky_delete_btn). Anchored to .sky-wheel-col /
// the applet section (both position:relative) so the btn sits over the SVG's // the applet section (both position:relative) so the btn sits over the SVG's
// geometric center regardless of the surrounding layout. // geometric center regardless of the surrounding layout.
@@ -1021,7 +1021,7 @@ body[class*="-light"] #id_sky_tooltip_2 {
overflow-y: visible; overflow-y: visible;
} }
// The (max-width:600px) block (written for the in-room PICK SKY modal where // The (max-width:600px) block (written for the in-room CAST SKY modal where
// form-col is flex-row) sets align-self:flex-end on the btn — that's "right" // form-col is flex-row) sets align-self:flex-end on the btn — that's "right"
// once we flip to flex-column. Reset. // once we flip to flex-column. Reset.
.sky-page .sky-form-col > #id_sky_confirm { .sky-page .sky-form-col > #id_sky_confirm {

View File

@@ -548,14 +548,14 @@ describe("SigSelect", () => {
.toBe(card.dataset.nameTitle); .toBe(card.dataset.nameTitle);
}); });
it("major arcana reversed face: title, in qualifier slot (first after spin); qualifier in name slot (second)", () => { it("major arcana reversed face: title in name slot (visually top after spin); qualifier in qualifier slot (visually bottom)", () => {
makeFixture({ polarity: "levity", userRole: "PC" }); makeFixture({ polarity: "levity", userRole: "PC" });
card.dataset.arcana = "Major Arcana"; card.dataset.arcana = "Major Arcana";
card.dataset.nameTitle = "The Schizo"; card.dataset.nameTitle = "The Schizo";
hover(); hover();
// DOM-second element appears first after card spins — so title goes in qualifier slot // Class matches semantic content: title → .fan-card-reversal-name, qualifier → .fan-card-reversal-qualifier
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("The Schizo,"); expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("The Schizo,");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Elevated"); expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated");
}); });
it("non-major without data-reversal-qualifier: qualifier mirrors polarity, name repeats card title", () => { it("non-major without data-reversal-qualifier: qualifier mirrors polarity, name repeats card title", () => {
@@ -812,7 +812,7 @@ describe("SigSelect", () => {
// ── polarity_room_done → tray sequence ─────────────────────────────────── // // ── polarity_room_done → tray sequence ─────────────────────────────────── //
// //
// After all 3 gamers in the user's polarity confirm TAKE SIG and the // After all 3 gamers in the user's polarity confirm SAVE SIG and the
// 12s countdown expires, the server fires room:polarity_room_done. The // 12s countdown expires, the server fires room:polarity_room_done. The
// sig-select handler should: (1) play the tray sequence — Tray.placeSig // sig-select handler should: (1) play the tray sequence — Tray.placeSig
// with the user's selected stage card; (2) on Tray.placeSig's completion // with the user's selected stage card; (2) on Tray.placeSig's completion
@@ -876,7 +876,7 @@ describe("SigSelect", () => {
it("does NOT add the waiting msg when pick_sky_btn is already revealed", () => { it("does NOT add the waiting msg when pick_sky_btn is already revealed", () => {
// pick_sky_available may fire DURING the tray sequence (other // pick_sky_available may fire DURING the tray sequence (other
// polarity finishes first). When the tray callback then hangs + // polarity finishes first). When the tray callback then hangs +
// dismisses, _settle must check whether PICK SKY is up and skip // dismisses, _settle must check whether CAST SKY is up and skip
// the "Levity appraising…" / "Gravity settling…" message so it // the "Levity appraising…" / "Gravity settling…" message so it
// doesn't co-exist w. the btn. // doesn't co-exist w. the btn.
const btn = document.createElement("button"); const btn = document.createElement("button");

View File

@@ -270,7 +270,7 @@
// Only redraw the wheel when a saved sky already exists on the page — // Only redraw the wheel when a saved sky already exists on the page —
// pre-first-save we suppress the live wheel preview so it doesn't // pre-first-save we suppress the live wheel preview so it doesn't
// shunt the form (and SAVE SKY) below the fold. Mirrors the My Sky // shunt the form (and SAVE SKY) below the fold. Mirrors the My Sky
// applet's "no wheel until saved" UX. The in-room PICK SKY overlay // applet's "no wheel until saved" UX. The in-room CAST SKY overlay
// intentionally still previews live. // intentionally still previews live.
if (_savedSky) { if (_savedSky) {
if (svgEl.querySelector('*')) { if (svgEl.querySelector('*')) {

View File

@@ -1,7 +1,7 @@
{% load static %} {% load static %}
{# PICK SEA overlay — Celtic Cross spread entry #} {# DRAW SEA overlay — Celtic Cross spread entry #}
{# Included in room.html when table_status == "SKY_SELECT" and sky_confirmed #} {# Included in room.html when table_status == "SKY_SELECT" and sky_confirmed #}
{# Layout is the reverse of PICK SKY: cards left (transparent), form right #} {# Layout is the reverse of CAST SKY: cards left (transparent), form right #}
<div class="sea-backdrop"></div> <div class="sea-backdrop"></div>
<div class="sea-overlay" id="id_sea_overlay" <div class="sea-overlay" id="id_sea_overlay"
@@ -156,6 +156,9 @@
</div> </div>
<p class="fan-card-arcana"></p> <p class="fan-card-arcana"></p>
<div class="fan-card-face-reversal"> <div class="fan-card-face-reversal">
{# Default DOM order — matches non-major arcana layout. stage-card.js #}
{# swaps the class names on these <p>s for Major arcana so each #}
{# element's class still matches its semantic content. #}
<p class="fan-card-reversal-name"></p> <p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p> <p class="fan-card-reversal-qualifier"></p>
</div> </div>

View File

@@ -32,6 +32,10 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
<p class="fan-card-arcana"></p> <p class="fan-card-arcana"></p>
<p class="fan-card-correspondence"></p>{# not shown in sig-select — game-kit only #} <p class="fan-card-correspondence"></p>{# not shown in sig-select — game-kit only #}
<div class="fan-card-face-reversal"> <div class="fan-card-face-reversal">
{# Default DOM order — matches non-major arcana layout (qualifier visually #}
{# on top after spin, title visually below). stage-card.js swaps the class #}
{# names on these <p>s for Major arcana so each element's class still #}
{# matches its semantic content (title → .fan-card-reversal-name etc.). #}
<p class="fan-card-reversal-name"></p> <p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier"></p> <p class="fan-card-reversal-qualifier"></p>
</div> </div>

View File

@@ -1,5 +1,5 @@
{% load static %} {% load static %}
{# PICK SKY overlay — natal chart entry + D3 wheel preview #} {# CAST SKY overlay — natal chart entry + D3 wheel preview #}
{# Included in room.html when table_status == "SKY_SELECT" #} {# Included in room.html when table_status == "SKY_SELECT" #}
{# Opens when user clicks #id_pick_sky_btn; html.sky-open controls #} {# Opens when user clicks #id_pick_sky_btn; html.sky-open controls #}
{# visibility via CSS — backdrop-filter blur + centred modal. #} {# visibility via CSS — backdrop-filter blur + centred modal. #}
@@ -90,7 +90,7 @@
{# ── Wheel column ─────────────────────────────────────── #} {# ── Wheel column ─────────────────────────────────────── #}
{# DEL btn is JS-injected after the wheel paints (see schedule #} {# DEL btn is JS-injected after the wheel paints (see schedule #}
{# Preview success handler) — keeping it out of the template #} {# Preview success handler) — keeping it out of the template #}
{# means a blank PICK SKY modal can never show a DEL action #} {# means a blank CAST SKY modal can never show a DEL action #}
{# against a non-existent wheel. #} {# against a non-existent wheel. #}
<div class="sky-wheel-col"> <div class="sky-wheel-col">
<svg id="id_sky_svg" class="sky-svg"></svg> <svg id="id_sky_svg" class="sky-svg"></svg>
@@ -400,12 +400,12 @@
}); });
}); });
// ── Sky confirmed → close sky & reload to land on hex w. PICK SEA ────── // ── Sky confirmed → close sky & reload to land on hex w. DRAW SEA ──────
// //
// The gamer should witness the table hex (now showing PICK SEA in place of // The gamer should witness the table hex (now showing DRAW SEA in place of
// PICK SKY) before opting into the sea overlay. We reload the room page — // CAST SKY) before opting into the sea overlay. We reload the room page —
// the server-side template will re-render with `sky_confirmed=True` so the // the server-side template will re-render with `sky_confirmed=True` so the
// hex's btn flips automatically, and the user clicks PICK SEA to continue. // hex's btn flips automatically, and the user clicks DRAW SEA to continue.
function _onSkyConfirmed() { function _onSkyConfirmed() {
closeSky(); closeSky();
@@ -413,7 +413,7 @@
} }
// ── DEL btn — JS-injected after the wheel paints; absent on a blank modal // ── DEL btn — JS-injected after the wheel paints; absent on a blank modal
// PICK SKY's wheel is a live preview; un-saved data lives only in LS_KEY. // CAST SKY's wheel is a live preview; un-saved data lives only in LS_KEY.
// The btn is created lazily after the first SkyWheel.draw so a blank modal // The btn is created lazily after the first SkyWheel.draw so a blank modal
// can never offer a DEL action against a non-existent wheel; clearing the // can never offer a DEL action against a non-existent wheel; clearing the
// SVG removes the btn from the DOM entirely (re-injected on next preview). // SVG removes the btn from the DOM entirely (re-injected on next preview).

View File

@@ -43,16 +43,20 @@
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p> <p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
<div class="fan-card-face-reversal"> <div class="fan-card-face-reversal">
{% comment %} {% comment %}
DOM order: reversal-name first, reversal-qualifier second. Class names always match semantic content: qualifier text in
After SPIN's 180° rotation DOM-second appears visually on top. .fan-card-reversal-qualifier, title text in .fan-card-reversal-name.
DOM order is per-arcana, controlling visual layout after the 180°
SPIN rotation (DOM-second appears visually on top):
Major / polarity-split — title on top → name class is DOM-second
Non-major — qualifier on top → qualifier class is DOM-second
{% endcomment %} {% endcomment %}
{% if card.gravity_reversal %} {% if card.gravity_reversal %}
{# Polarity-split reversal title — single line, qualifier slot empty. Title goes in the qualifier slot so it visually lands on top after spin. #} {# Polarity-split: single-line title in the name slot, qualifier slot empty. #}
<p class="fan-card-reversal-name"></p> <p class="fan-card-reversal-qualifier"></p>
<p class="fan-card-reversal-qualifier {{ card.title_squeeze_class }}">{{ card.gravity_reversal|italicize:card.italic_word }}</p> <p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.gravity_reversal|italicize:card.italic_word }}</p>
{% elif card.arcana == "MAJOR" %} {% elif card.arcana == "MAJOR" %}
<p class="fan-card-reversal-name">{{ card.gravity_qualifier|default:card.levity_qualifier }}</p> <p class="fan-card-reversal-qualifier">{{ card.gravity_qualifier|default:card.levity_qualifier }}</p>
<p class="fan-card-reversal-qualifier {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}{% if card.gravity_qualifier %},{% endif %}</p> <p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}{% if card.gravity_qualifier %},{% endif %}</p>
{% else %} {% else %}
<p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}</p> <p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}</p>
<p class="fan-card-reversal-qualifier">{{ card.reversal_qualifier|default:card.gravity_qualifier }}</p> <p class="fan-card-reversal-qualifier">{{ card.reversal_qualifier|default:card.gravity_qualifier }}</p>

View File

@@ -14,7 +14,7 @@
<div id="id_pick_sigs_wrap"{% if starter_roles|length < 6 %} style="display:none"{% endif %}> <div id="id_pick_sigs_wrap"{% if starter_roles|length < 6 %} style="display:none"{% endif %}>
<form method="POST" action="{% url 'epic:pick_sigs' room.id %}"> <form method="POST" action="{% url 'epic:pick_sigs' room.id %}">
{% csrf_token %} {% csrf_token %}
<button id="id_pick_sigs_btn" type="submit" class="btn btn-primary">PICK<br>SIGS</button> <button id="id_pick_sigs_btn" type="submit" class="btn btn-primary">SCAN<br>SIGS</button>
</form> </form>
</div> </div>
{% endif %} {% endif %}
@@ -37,12 +37,12 @@
{% endif %} {% endif %}
{% if room.table_status == "SKY_SELECT" %} {% if room.table_status == "SKY_SELECT" %}
{% if sky_confirmed %} {% if sky_confirmed %}
<button id="id_pick_sea_btn" class="btn btn-primary">PICK<br>SEA</button> <button id="id_pick_sea_btn" class="btn btn-primary">DRAW<br>SEA</button>
{% else %} {% else %}
<button id="id_pick_sky_btn" class="btn btn-primary">PICK<br>SKY</button> <button id="id_pick_sky_btn" class="btn btn-primary">CAST<br>SKY</button>
{% endif %} {% endif %}
{% elif room.table_status == "SIG_SELECT" %} {% elif room.table_status == "SIG_SELECT" %}
<button id="id_pick_sky_btn" class="btn btn-primary" style="display:none">PICK<br>SKY</button> <button id="id_pick_sky_btn" class="btn btn-primary" style="display:none">CAST<br>SKY</button>
{% if polarity_done %} {% if polarity_done %}
<p id="id_hex_waiting_msg">{% if user_polarity == "levity" %}Gravity settling . . .{% else %}Levity appraising . . .{% endif %}</p> <p id="id_hex_waiting_msg">{% if user_polarity == "levity" %}Gravity settling . . .{% else %}Levity appraising . . .{% endif %}</p>
{% endif %} {% endif %}