diff --git a/src/functional_tests/test_bill_my_sign.py b/src/functional_tests/test_bill_my_sign.py index c91ed33..d7bdb64 100644 --- a/src/functional_tests/test_bill_my_sign.py +++ b/src/functional_tests/test_bill_my_sign.py @@ -62,14 +62,12 @@ class MySignPickerTest(FunctionalTest): # ── 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.""" + def test_landing_renders_dry_hex_with_scan_sign_button(self): + """GET /billboard/my-sign/ → the DRY table hex (1-chair) renders w. + a central .btn-primary reading "SCAN SIGN"; the card grid is hidden + until SCAN SIGN is clicked.""" 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 , so Selenium's `.text` joins them w. newlines — - # strip whitespace before the substring check. self.wait_for( lambda: self.assertIn( "GAMESIGN", @@ -78,26 +76,70 @@ class MySignPickerTest(FunctionalTest): ), ) ) - # 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 ""). + # Landing phase markers: hex + 1 chair + SCAN SIGN btn 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}"]', + By.CSS_SELECTOR, ".my-sign-page .table-hex" ) ) + chairs = self.browser.find_elements( + By.CSS_SELECTOR, ".my-sign-page .table-seat" + ) + self.assertEqual(len(chairs), 1, "Landing hex should render exactly 1 chair") + scan_btn = self.browser.find_element(By.ID, "id_scan_sign_btn") + self.assertIn("SCAN", scan_btn.text.upper()) + self.assertIn("SIGN", scan_btn.text.upper()) + # Picker grid not yet visible — landing phase only + grid = self.browser.find_element(By.CSS_SELECTOR, ".my-sign-deck-grid") + self.assertFalse( + grid.is_displayed(), + "Picker grid should be hidden on landing phase", + ) # ── 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.""" + def test_scan_sign_click_transitions_to_picker_phase(self): + """Click SCAN SIGN → landing hex hides; picker grid + populated stage + frame appear. Target card present in the grid.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") + scan_btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_scan_sign_btn") + ) + self.browser.execute_script("arguments[0].click()", scan_btn) + # Phase swap — picker now visible, hex now hidden + self.wait_for( + lambda: self.assertTrue( + self.browser.find_element( + By.CSS_SELECTOR, ".my-sign-deck-grid" + ).is_displayed(), + "Picker grid should be visible after SCAN SIGN click", + ) + ) + # Hex collapses (display:none) after transition + self.assertFalse( + self.browser.find_element( + By.CSS_SELECTOR, ".my-sign-page .table-hex" + ).is_displayed(), + "Landing hex should hide once picker phase is active", + ) + # Target card present in the grid + self.browser.find_element( + By.CSS_SELECTOR, + f'.my-sign-deck-grid .sig-card[data-card-id="{self.target_card.id}"]', + ) - # Click target card + # ── Test 3 ─────────────────────────────────────────────────────────────── + + def test_click_thumbnail_shows_OK_btn_without_locking(self): + """In picker phase: click a thumbnail → .sig-focused class + OK btn + visible on it; stat block + FLIP still hidden (no lock yet); SAVE + SIGN still disabled.""" + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/billboard/my-sign/") + self.browser.execute_script( + "document.getElementById('id_scan_sign_btn').click()" + ) card_el = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, @@ -105,34 +147,79 @@ class MySignPickerTest(FunctionalTest): ) ) 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") - ) + # .sig-focused class → OK btn visible self.wait_for( - lambda: self.assertFalse( - save_btn.get_attribute("disabled"), - "SAVE SIGN should be enabled after card click", + lambda: self.assertIn( + "sig-focused", + self.browser.find_element( + By.CSS_SELECTOR, + f'.sig-card[data-card-id="{self.target_card.id}"]', + ).get_attribute("class"), ) ) - - # 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"), + ok_btn = self.browser.find_element( + By.CSS_SELECTOR, + f'.sig-card[data-card-id="{self.target_card.id}"] .sig-ok-btn', ) + self.assertTrue(ok_btn.is_displayed(), "OK btn should be visible on focused thumb") + # Stat block still hidden (no lock yet) + stage = self.browser.find_element(By.CSS_SELECTOR, ".my-sign-stage") + self.assertNotIn("sig-stage--frozen", stage.get_attribute("class")) + # SAVE SIGN still disabled + save_btn = self.browser.find_element(By.ID, "id_save_sign_btn") + self.assertTrue(save_btn.get_attribute("disabled")) - # Save → DB updated + # ── Test 4 ─────────────────────────────────────────────────────────────── + + def test_OK_click_locks_thumbnail_and_enables_save_sign(self): + """In picker phase: click thumbnail → click its OK btn → stage locks + (.sig-stage--frozen), NVM btn visible on the thumbnail, SAVE SIGN + enables. Then save persists + applet shows the card.""" + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/billboard/my-sign/") + self.browser.execute_script( + "document.getElementById('id_scan_sign_btn').click()" + ) + 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) + ok_btn = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + f'.sig-card[data-card-id="{self.target_card.id}"] .sig-ok-btn', + ) + ) + self.browser.execute_script("arguments[0].click()", ok_btn) + # Lock applied + self.wait_for( + lambda: self.assertIn( + "sig-stage--frozen", + self.browser.find_element( + By.CSS_SELECTOR, ".my-sign-stage" + ).get_attribute("class"), + ) + ) + # NVM btn on the same thumbnail now visible + nvm_btn = self.browser.find_element( + By.CSS_SELECTOR, + f'.sig-card[data-card-id="{self.target_card.id}"] .sig-nvm-btn', + ) + self.assertTrue(nvm_btn.is_displayed(), "NVM btn should be visible after OK confirm") + # SAVE SIGN enabled + save_btn = self.browser.find_element(By.ID, "id_save_sign_btn") + self.assertFalse(save_btn.get_attribute("disabled")) + # Save persists 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). + # Applet on /billboard/ shows the saved card self.browser.get(self.live_server_url + "/billboard/") self.wait_for( lambda: self.browser.find_element( @@ -141,6 +228,87 @@ class MySignPickerTest(FunctionalTest): ) ) + # ── Test 5 ─────────────────────────────────────────────────────────────── + + def test_NVM_click_deselects_and_disables_save_sign(self): + """After OK-lock, click NVM on the thumbnail → lock clears (no + .sig-stage--frozen), .sig-focused removed, SAVE SIGN disables.""" + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/billboard/my-sign/") + self.browser.execute_script( + "document.getElementById('id_scan_sign_btn').click()" + ) + 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) + ok_btn = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + f'.sig-card[data-card-id="{self.target_card.id}"] .sig-ok-btn', + ) + ) + self.browser.execute_script("arguments[0].click()", ok_btn) + # Now NVM + nvm_btn = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + f'.sig-card[data-card-id="{self.target_card.id}"] .sig-nvm-btn', + ) + ) + self.browser.execute_script("arguments[0].click()", nvm_btn) + # Lock cleared + self.wait_for( + lambda: self.assertNotIn( + "sig-stage--frozen", + self.browser.find_element( + By.CSS_SELECTOR, ".my-sign-stage" + ).get_attribute("class"), + ) + ) + # SAVE SIGN disables again + save_btn = self.browser.find_element(By.ID, "id_save_sign_btn") + self.assertTrue(save_btn.get_attribute("disabled")) + # .sig-focused removed from thumb + self.assertNotIn( + "sig-focused", + self.browser.find_element( + By.CSS_SELECTOR, + f'.sig-card[data-card-id="{self.target_card.id}"]', + ).get_attribute("class"), + ) + + # ── Test 6 ─────────────────────────────────────────────────────────────── + + def test_landing_previews_saved_sig_on_stage(self): + """If user already has a saved significator, the landing-phase stage + frame should preview the saved card (above/behind the hex).""" + # Pre-save a sig in the DB + self.gamer.significator = self.target_card + self.gamer.save(update_fields=["significator"]) + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/billboard/my-sign/") + # Landing phase still shows hex w. SCAN SIGN + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_scan_sign_btn") + ) + # Stage card is visible w. the saved card populated (corner-rank + + # name pinned by data-card-id on the stage card wrapper) + stage_card = self.browser.find_element( + By.CSS_SELECTOR, ".my-sign-stage .sig-stage-card" + ) + self.assertTrue( + stage_card.is_displayed(), + "Stage card should preview the saved sig on landing", + ) + self.assertEqual( + stage_card.get_attribute("data-card-id"), + str(self.target_card.id), + ) + # ── Test 3 ─────────────────────────────────────────────────────────────── def test_applet_renders_blank_state_when_no_sig_chosen(self): diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 6c2a1f3..e9ab5f4 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -616,13 +616,81 @@ html:has(.sig-backdrop) { } // ─── My Sign picker — sizing + state-gated reveal ──────────────────────────── -// Bigger preview card than the room (no shared overlay width budget) — clamp -// scales w. viewport for portrait/landscape both. FLIP btn + stat block hidden -// at rest; revealed by `.sig-stage--frozen` (added by JS on click, cleared by -// NVM). SPIN (orientation 180°) stays in `.sig-stat-block`; FLIP toggles -// polarity (data-polarity attr on .my-sign-page). +// Two-phase layout: landing (DRY 1-chair hex w. SCAN SIGN center) → picker +// (sig-card grid below an always-present stage frame). SAVE SIGN rides +// inside .my-sign-stage to its right, sig-select-style. FLIP btn + stat +// block hidden at rest; revealed by `.sig-stage--frozen` (added by JS on +// OK confirm, cleared by NVM). SPIN (orientation 180°) stays in +// `.sig-stat-block`; FLIP toggles polarity (data-polarity on .my-sign-page). +// .my-sign-page mirrors .room-page's flex-column-fill-aperture pattern so +// the DRY hex inside .my-sign-landing gets a non-zero #id_game_table size +// for room.js's scaleTable() to compute against. Without flex:1 + min-height:0 +// the container chain collapses + the hex renders unscaled (200×231 inside +// a 360×320 scene, looking elongated/portrait). .my-sign-page { --sig-card-w: clamp(140px, 36vw, 220px); + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +} + +// Stage frame — fixed slice in picker phase, natural-sized on landing. +// The picker min-height reserves real estate so hover-preview cards don't +// shift adjacent layout; on landing the stage shrinks to its actual content +// (empty or saved-sig preview) so the DRY hex below gets fair vertical +// space. SAVE SIGN form is absolutely positioned (see below) so it stays +// pinned when the stat block reveals on OK confirm. +.my-sign-stage { + flex: 0 0 auto; + position: relative; +} + +.my-sign-page[data-phase="picker"] .my-sign-stage { + min-height: calc(var(--sig-card-w, 140px) * 8 / 5 + 1.5rem); +} + +// SAVE SIGN form — pinned to the bottom-right of the stage so it stays in +// place across hover/lock states (the stat block reveal would otherwise +// shove a flex-positioned btn around the stage row). +#id_save_sign_form { + position: absolute; + bottom: 0.75rem; + right: 1rem; + margin: 0; + z-index: 6; +} + +// Landing phase — DRY hex container. flex:1 + min-height:0 propagates the +// available vertical space into .room-shell → #id_game_table → scaleTable(). +.my-sign-landing { + flex: 1; + min-height: 0; + display: flex; + + // SCAN SIGN btn — centered in the hex. Default .btn-primary text + // (0.875rem) scales tighter than the room's PICK SIGS btn font; this + // bumps it down a notch so the 2-line "SCAN/SIGN" label sits cleanly + // inside the 4rem circle without crowding the border. + #id_scan_sign_btn { + font-size: 0.75rem; + line-height: 1.1; + white-space: normal; + } +} + +// Hide SAVE SIGN on landing — the form only makes sense once the user +// has entered the picker. Saved-sig preview on landing is read-only. +.my-sign-page[data-phase="landing"] #id_save_sign_form { + display: none; +} + +// Picker phase — bg matches the table hex's interior (--duoUser) so the +// transition from "hex face" → "card pile on felt" reads as a continuous +// surface rather than a context swap. Landing phase keeps the body bg. +.my-sign-page[data-phase="picker"] { + background: rgba(var(--duoUser), 1); } .my-sign-flip-btn { @@ -637,7 +705,7 @@ html:has(.sig-backdrop) { display: none; } -// FLIP btn appears only when the stage is frozen (post-click). Hover-only +// FLIP btn appears only when the stage is frozen (post-OK confirm). Hover-only // previews don't reveal the polarity toggle — the user hasn't committed yet. .my-sign-stage.sig-stage--frozen .my-sign-flip-btn { display: inline-flex; @@ -938,10 +1006,13 @@ html:has(.sig-backdrop) { margin-left: 4rem; margin-right: 3rem; } - .sig-stage { + .sig-overlay .sig-stage { min-width: 0; // allow shrinking in row layout; align-items:flex-end already set } - .sig-deck-grid { + // Scoped to .sig-overlay — the room sig-select modal has its own width + // budget. .my-sign-page gets its own breakpoints below (different col + // counts + thresholds tuned for the full content area). + .sig-overlay .sig-deck-grid { grid-template-columns: repeat(6, 2.5rem); margin: 0; align-self: flex-end; // sit at the bottom of the modal row @@ -954,12 +1025,12 @@ html:has(.sig-backdrop) { flex-direction: column; align-items: stretch; } - .sig-stage { + .sig-overlay .sig-stage { min-width: auto; align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth margin-left: 3rem; } - .sig-deck-grid { + .sig-overlay .sig-deck-grid { grid-template-columns: repeat(9, 3rem); align-self: center; } @@ -968,7 +1039,7 @@ html:has(.sig-backdrop) { @media (orientation: landscape) and (min-width: 1400px) { // Wide landscape: 18-card single-row grid. 18×3rem + ~7rem modal margins // clears the viewport here even at the fluid-rem ceiling (rem=22 → ~1376px). - .sig-deck-grid { + .sig-overlay .sig-deck-grid { grid-template-columns: repeat(18, 3rem); } } @@ -976,11 +1047,11 @@ html:has(.sig-backdrop) { @media (orientation: landscape) and (min-width: 1800px) { // Sig overlay: clear doubled sidebars (8rem each instead of 4rem/6rem) .sig-overlay { padding-left: 8rem; padding-right: 8rem; } - .sig-stage { + .sig-overlay .sig-stage { align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth margin-left: 3rem; } - .sig-deck-grid { + .sig-overlay .sig-deck-grid { grid-template-columns: repeat(18, 5rem); align-self: center; } @@ -990,6 +1061,46 @@ html:has(.sig-backdrop) { #id_room_menu { right: 2.5rem; } } +// ─── My Sign picker grid ──────────────────────────────────────────────────── +// align-self:center horizontally centers the shrink-to-content grid in +// .my-sign-page's flex column; overflow:visible avoids the modal's hidden +// clip. Col counts ramp up at wider viewports — sig-select's breakpoints +// are tuned for the modal's width budget so we use our own thresholds that +// account for the navbar/footer sidebars (~5rem each) eating viewport width. +// Portrait: fixed rem cols (default repeat(6, 1fr) collapses to 0 width +// w. align-self:center because 1fr has no defined parent to fr against). +.my-sign-deck-grid { + align-self: center; + margin: 1rem auto; + overflow: visible; + grid-template-columns: repeat(6, 3rem); +} + +@media (orientation: landscape) and (min-width: 900px) { + // Middling landscape: 9-card row × 2 (mirrors sig-select's middling step). + .my-sign-deck-grid { + grid-template-columns: repeat(9, 3rem); + } +} + +@media (orientation: landscape) and (min-width: 1600px) { + // Wide landscape: 18-card single row. Bumped from sig-select's 1400px so + // 18×3rem + the doubled-sidebar margins (~10rem) still clears the viewport + // at the fluid-rem ceiling (rem=22 → 18×3rem=1188px + 220px margins = 1408, + // safe with 1600px floor). + .my-sign-deck-grid { + grid-template-columns: repeat(18, 3rem); + } +} + +@media (orientation: landscape) and (min-width: 2200px) { + // XL landscape: 18×5rem. Bumped from sig-select's 1800px — 18×5rem=1980px + // at rem=22 needs ~2200px viewport after sidebar/footer clearance. + .my-sign-deck-grid { + grid-template-columns: repeat(18, 5rem); + } +} + // ── DRAW SEA overlay ───────────────────────────────────────────────────────── // Mirrors .sky-* structure but with columns reversed: // left = transparent (Celtic Cross card positions) diff --git a/src/templates/apps/billboard/my_sign.html b/src/templates/apps/billboard/my_sign.html index f866e05..a85b83b 100644 --- a/src/templates/apps/billboard/my_sign.html +++ b/src/templates/apps/billboard/my_sign.html @@ -5,236 +5,291 @@ {% block header_text %}GameSign{% 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". #} +{# Two-phase picker. Landing renders the DRY table hex (1-chair) w. a #} +{# central SCAN SIGN btn; the stage frame above previews the user's #} +{# saved sig if any. Clicking SCAN SIGN swaps to picker phase: the hex #} +{# hides, the card grid appears below the stage. Selection is a two-step #} +{# click on the thumbnail itself (matching room sig-select): click thumb #} +{# → OK btn appears; click OK → lock (stat block + FLIP + SAVE SIGN #} +{# enable + NVM appears for deselect). #} +{# "Significator" is preserved at the storage layer (User.significator); #} +{# this billboard surface re-brands to "Sign". #}
- {# Stage frame always visible; stage card + stat block + FLIP btn #} - {# gated by hover (preview) + click (lock). `.sig-stage--frozen` is #} - {# added on click + cleared by NVM. data-polarity moved to the page #} - {# wrapper so descendant .sig-card / .sig-stage-card both get the #} - {# polarity-themed CSS rules. #} -
-