diff --git a/.gitignore b/.gitignore index f7f85f6..adf5d62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Created by https://www.toptal.com/developers/gitignore/api/django # Edit at https://www.toptal.com/developers/gitignore?templates=django +### Remove this once images renamed ### +src/apps/epic/static/apps/epic/images/cards-faces/minchiate-fiorentine/ + ### Claude ### .claude diff --git a/src/apps/gameboard/models.py b/src/apps/gameboard/models.py index 13c2d15..30b4de4 100644 --- a/src/apps/gameboard/models.py +++ b/src/apps/gameboard/models.py @@ -18,6 +18,86 @@ HAND_SIZE_BY_SPREAD = { "escape-velocity": 6, } +# Position order per spread — mirrors `DRAW_ORDER` in my_sea.html JS +# (lines 274-281). The applet renders slots in this order left-to-right +# so drawn cards appear in chronological draw sequence regardless of +# their spread-grid placement. +DRAW_ORDER = { + "past-present-future": ("leave", "cover", "loom"), + "situation-action-outcome": ("lay", "cover", "crown"), + "mind-body-spirit": ("crown", "lay", "loom"), + "desire-obstacle-solution": ("loom", "cross", "crown"), + "waite-smith": ("cover", "cross", "crown", "lay", "loom", "leave"), + "escape-velocity": ("cover", "cross", "lay", "leave", "crown", "loom"), +} + +# Per-spread human-readable labels for each position slug. Mirrors +# `POSITION_LABELS` in my_sea.html JS (lines 282-293). Escape Velocity +# remaps the diagonal positions per the user-locked spec 2026-05-19 +# (Beneath→Lay, Before→Loom, Behind→Leave) — Crown/Cover/Cross keep +# their Waite-Smith names. +POSITION_LABELS = { + "past-present-future": {"leave": "Past", "cover": "Present", "loom": "Future"}, + "situation-action-outcome": {"lay": "Situation", "cover": "Action", "crown": "Outcome"}, + "mind-body-spirit": {"crown": "Mind", "lay": "Body", "loom": "Spirit"}, + "desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","crown": "Solution"}, + "waite-smith": {"crown": "Crown", "leave": "Behind", "cover": "Cover", + "cross": "Cross", "loom": "Before", "lay": "Beneath"}, + "escape-velocity": {"crown": "Crown", "leave": "Leave", "cover": "Cover", + "cross": "Cross", "loom": "Loom", "lay": "Lay"}, +} + + +def latest_draw_slots(user): + """Build the slot list for the My Sea applet — pairs each draw-order + position w. its drawn card (if any) + display label + polarity. + + Drives `_applet-my-sea.html`: the applet renders ALL positions in + spread DRAW_ORDER, filled slots showing the drawn card, empty slots + rendering as a labelled placeholder w. --duoUser bg + --terUser + dotted border (user spec 2026-05-22). Spread is locked at first + card draw + survives DEL'd-but-not-expired rows... but the applet + only populates while `hand` is non-empty: a DEL'd row reads as + "No draws yet" since the user has explicitly cleared their picks. + + Returns: + list[dict] — one entry per spread position in draw order: + { + "position": "lay", # position slug + "label": "Situation", # spread-specific human label + "card": , # None if not yet drawn + "reversed": bool, # False if not yet drawn + "polarity": "gravity", # "" if not yet drawn + } + Empty list = no active draw OR active draw w. empty hand. + """ + draw = active_draw_for(user) + if draw is None or not draw.hand: + return [] + from apps.epic.models import TarotCard + order = DRAW_ORDER.get(draw.spread, ()) + labels = POSITION_LABELS.get(draw.spread, {}) + by_position = {entry["position"]: entry for entry in draw.hand} + card_ids = { + entry["card_id"] for entry in draw.hand + if entry.get("card_id") + } + cards_by_id = ( + {c.id: c for c in TarotCard.objects.filter(id__in=card_ids)} + if card_ids else {} + ) + slots = [] + for pos in order: + entry = by_position.get(pos) + slots.append({ + "position": pos, + "label": labels.get(pos, ""), + "card": cards_by_id.get(entry["card_id"]) if entry else None, + "reversed": entry.get("reversed", False) if entry else False, + "polarity": entry.get("polarity", "") if entry else "", + }) + return slots + class MySeaDraw(models.Model): """Persisted Celtic-Cross-style tarot draw for the solo-user My Sea diff --git a/src/apps/gameboard/static/apps/gameboard/gameboard.js b/src/apps/gameboard/static/apps/gameboard/gameboard.js index a3e74de..2209cc8 100644 --- a/src/apps/gameboard/static/apps/gameboard/gameboard.js +++ b/src/apps/gameboard/static/apps/gameboard/gameboard.js @@ -306,4 +306,20 @@ function initGameKitTooltips() { }); } +// Mousewheel → horizontal scroll on the My Sea applet (mirrors the +// Palette applet's `bindPaletteWheel` in `apps/dashboard/dashboard.js`). +// Vertical wheel events translate to horizontal scroll so multi-card +// spreads (6-slot Celtic Cross / Escape Velocity) can be panned through +// without touchpad gestures or a horizontal scrollbar drag. +function bindMySeaWheel() { + document.querySelectorAll('.my-sea-scroll').forEach(function (el) { + el.addEventListener('wheel', function (e) { + if (e.deltaY === 0) return; + e.preventDefault(); + el.scrollLeft += e.deltaY; + }, { passive: false }); + }); +} + document.addEventListener('DOMContentLoaded', initGameKitTooltips); +document.addEventListener('DOMContentLoaded', bindMySeaWheel); diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 7efbe15..bcfc853 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -42,23 +42,32 @@ class GameboardViewTest(TestCase): # flow lands in later sprints. Seeded via migration 0008. [_] = self.parsed.cssselect("#id_applet_my_sea") - def test_my_sea_applet_renders_sign_gate_for_user_without_sig(self): - # Sprint 4b — user with no significator sees the Look!-formatted - # gate (mirror of the standalone page), not the draw UX. - [_gate] = self.parsed.cssselect( - "#id_applet_my_sea .my-sea-sign-gate--applet" - ) - # Draw-state nodes are suppressed while the gate is up. + def test_my_sea_applet_fires_sign_gate_brief_for_user_without_sig(self): + # Sprint 4b (refactored 2026-05-22) — user with no significator + # gets a Look!-formatted Brief banner (`Brief.showBanner` script + # fired in the applet template) AND the applet body falls through + # to the empty-state "No draws yet" (no sig → no draws is the only + # possible state). The Brief itself renders client-side via JS so + # we assert the script content + the FYI url, not the DOM banner. + html = self.parsed.text_content() if False else \ + lxml.html.tostring(self.parsed, encoding="unicode") + self.assertIn("Look!", html) + self.assertIn("pick your sign before drawing the Sea", html) + self.assertIn("Brief.showBanner", html) + # FYI url baked into the Brief script's `post_url` + self.assertIn("/billboard/my-sign/", html) + # Old inline gate markup is gone self.assertEqual( - len(self.parsed.cssselect("#id_applet_my_sea .my-sea-empty")), 0, + len(self.parsed.cssselect(".my-sea-sign-gate")), 0, ) + # Card cells suppressed (no active draw possible without sig) self.assertEqual( - len(self.parsed.cssselect("#id_applet_my_sea .my-sea-card")), 0, + len(self.parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")), 0, ) def test_my_sea_applet_renders_empty_state_for_user_with_sig_no_draws(self): # Sig set + no saved draws → the scroll container hosts a single - # placeholder line ("No draws yet."), no card cells, no gate. + # placeholder line ("No draws yet."), no card cells, no gate Brief. from apps.epic.models import personal_sig_cards sig_pile = personal_sig_cards(self.user) self.user.significator = sig_pile[0] @@ -68,16 +77,123 @@ class GameboardViewTest(TestCase): [empty] = parsed.cssselect("#id_applet_my_sea .my-sea-empty") self.assertIn("No draws yet", empty.text_content()) self.assertEqual( - len(parsed.cssselect("#id_applet_my_sea .my-sea-card")), 0, - ) - self.assertEqual( - len(parsed.cssselect("#id_applet_my_sea .my-sea-sign-gate--applet")), - 0, + len(parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")), 0, ) + # No sign-gate Brief script fires when the user already has a sig + html = lxml.html.tostring(parsed, encoding="unicode") + self.assertNotIn("pick your sign before drawing the Sea", html) def test_my_sea_applet_header_links_to_my_sea_page(self): [link] = self.parsed.cssselect("#id_applet_my_sea h2 a") self.assertEqual(link.get("href"), reverse("my_sea")) + + def test_my_sea_applet_renders_drawn_cards_in_draw_order(self): + """User w. a partial SAO draw — applet renders 3 slots in DRAW_ + ORDER (lay, cover, crown → labelled Beneath/Cover/Crown? no — + SAO labels are Situation/Action/Outcome). Drawn slot 1 (`lay`) + carries the card; the un-drawn `cover` + `crown` slots are + empty placeholders w. their per-spread labels.""" + from apps.epic.models import personal_sig_cards, TarotCard + from apps.gameboard.models import MySeaDraw + sig_pile = personal_sig_cards(self.user) + self.user.significator = sig_pile[0] + self.user.save() + card = TarotCard.objects.first() + MySeaDraw.objects.create( + user=self.user, + spread="situation-action-outcome", + hand=[ + {"position": "lay", "card_id": card.id, + "reversed": False, "polarity": "gravity"}, + ], + significator_id=self.user.significator_id, + ) + response = self.client.get("/gameboard/") + parsed = lxml.html.fromstring(response.content) + # All 3 SAO positions render in DRAW_ORDER (lay, cover, crown). + wraps = parsed.cssselect("#id_applet_my_sea .my-sea-slot-wrap") + self.assertEqual(len(wraps), 3, + "SAO has 3 positions — applet should render 3 slot wraps") + # Position 1 (`lay`) is filled w. the drawn card. + filled = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled") + self.assertEqual(len(filled), 1) + self.assertEqual( + filled[0].get("data-position"), "lay", + "First drawn card should land in the `lay` slug slot", + ) + self.assertEqual( + filled[0].get("data-card-id"), str(card.id), + ) + # Positions 2 + 3 (cover, crown) are empty placeholders. + empties = parsed.cssselect("#id_applet_my_sea .my-sea-slot--empty") + self.assertEqual(len(empties), 2) + empty_positions = {e.get("data-position") for e in empties} + self.assertEqual(empty_positions, {"cover", "crown"}) + + def test_my_sea_applet_labels_match_locked_spread(self): + """SAO label per spec: lay='Situation', cover='Action', + crown='Outcome'. Empty slots still carry their label so the + user can see which position is yet to draw.""" + from apps.epic.models import personal_sig_cards, TarotCard + from apps.gameboard.models import MySeaDraw + sig_pile = personal_sig_cards(self.user) + self.user.significator = sig_pile[0] + self.user.save() + card = TarotCard.objects.first() + MySeaDraw.objects.create( + user=self.user, + spread="situation-action-outcome", + hand=[ + {"position": "lay", "card_id": card.id, + "reversed": False, "polarity": "gravity"}, + ], + significator_id=self.user.significator_id, + ) + response = self.client.get("/gameboard/") + parsed = lxml.html.fromstring(response.content) + labels = parsed.cssselect( + "#id_applet_my_sea .my-sea-slot-wrap .my-sea-slot-label" + ) + # Labels in DOM order (== DRAW_ORDER): Situation, Action, Outcome + self.assertEqual( + [l.text_content().strip() for l in labels], + ["Situation", "Action", "Outcome"], + ) + + def test_my_sea_applet_waite_smith_labels_post_fix(self): + """Regression pin for the 2026-05-22 POSITION_LABELS swap fix: + WS `leave` slot (LEFT) is "Behind", `lay` slot (BOTTOM) is + "Beneath" — was inverted prior to the fix. Voronoi mapping + depends on this being right.""" + from apps.epic.models import personal_sig_cards, TarotCard + from apps.gameboard.models import MySeaDraw + sig_pile = personal_sig_cards(self.user) + self.user.significator = sig_pile[0] + self.user.save() + card = TarotCard.objects.first() + # Single-card WS draw — populates `cover` (DRAW_ORDER pos 1). + MySeaDraw.objects.create( + user=self.user, + spread="waite-smith", + hand=[ + {"position": "cover", "card_id": card.id, + "reversed": False, "polarity": "gravity"}, + ], + significator_id=self.user.significator_id, + ) + response = self.client.get("/gameboard/") + parsed = lxml.html.fromstring(response.content) + wraps = parsed.cssselect("#id_applet_my_sea .my-sea-slot-wrap") + # WS DRAW_ORDER = [cover, cross, crown, lay, loom, leave] + # Labels post-fix: Cover Cross Crown Beneath Before Behind + labels = [ + w.cssselect(".my-sea-slot-label")[0].text_content().strip() + for w in wraps + ] + self.assertEqual( + labels, + ["Cover", "Cross", "Crown", "Beneath", "Before", "Behind"], + ) def test_gameboard_shows_game_kit(self): [_] = self.parsed.cssselect("#id_game_kit") diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 1f1b6c8..5a87008 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -8,7 +8,7 @@ from django.views.decorators.http import require_POST from apps.applets.utils import applet_context, apply_applet_toggle from .models import ( - HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for, + HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for, latest_draw_slots, _select_my_sea_token, debit_my_sea_token, ) @@ -59,6 +59,7 @@ def gameboard(request): "applets": applet_context(request.user, "gameboard"), "page_class": "page-gameboard", "my_games": annotate_latest_event(rooms_for_user(request.user)), + "my_sea_slots": latest_draw_slots(request.user), } ) @@ -82,6 +83,7 @@ def toggle_game_applets(request): "free_tokens": free_tokens, "free_count": len(free_tokens), "my_games": annotate_latest_event(rooms_for_user(request.user)), + "my_sea_slots": latest_draw_slots(request.user), }) return redirect("gameboard") diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index b1f877c..c8577b4 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -62,35 +62,37 @@ class MySeaSignGateTest(FunctionalTest): # ── Test 1 ─────────────────────────────────────────────────────────────── def test_no_sig_renders_lookline_gate_on_standalone_page(self): - """User without significator → /gameboard/my-sea/ shows the Look!- - formatted Brief-style line w. the gate copy + FYI + NVM buttons.""" + """User without significator → /gameboard/my-sea/ fires the Look!- + formatted sign-gate Brief banner w. FYI + NVM controls (refactored + 2026-05-22 from inline `.my-sea-sign-gate` div to a `.note-banner` + portaled via `Brief.showBanner`).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") - gate = self.wait_for( + banner = self.wait_for( lambda: self.browser.find_element( - By.CSS_SELECTOR, ".my-sea-sign-gate" + By.CSS_SELECTOR, ".note-banner.my-sea-sign-gate-brief" ) ) - text = gate.text + text = banner.text self.assertIn("Look!", text) self.assertIn("pick your sign", text.lower()) self.assertIn("drawing the Sea", text) - # FYI + NVM action buttons (class .my-sea-sign-gate__back retained - # post-relabel; the BACK→NVM swap was label-only). - fyi = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi") + # FYI + NVM action buttons live inside the Brief shell (built-in). + fyi = banner.find_element(By.CSS_SELECTOR, ".note-banner__fyi") self.assertTrue(fyi.is_displayed()) - nvm = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__back") + nvm = banner.find_element(By.CSS_SELECTOR, ".note-banner__nvm") self.assertTrue(nvm.is_displayed()) # ── Test 2 ─────────────────────────────────────────────────────────────── def test_gate_fyi_links_to_my_sign_picker(self): - """FYI button is an `` pointing at /billboard/my-sign/.""" + """FYI button on the Brief is an `` pointing at /billboard/ + my-sign/. Carries the standard `.note-banner__fyi` class.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") fyi = self.wait_for( lambda: self.browser.find_element( - By.CSS_SELECTOR, ".my-sea-sign-gate__fyi" + By.CSS_SELECTOR, ".my-sea-sign-gate-brief .note-banner__fyi" ) ) href = fyi.get_attribute("href") or "" @@ -101,27 +103,32 @@ class MySeaSignGateTest(FunctionalTest): # ── Test 3 ─────────────────────────────────────────────────────────────── - def test_gate_back_links_to_gameboard(self): - """NVM button is an `` pointing at /gameboard/. CSS class - `.my-sea-sign-gate__back` retained post BACK→NVM label swap.""" + def test_gate_nvm_dismisses_brief(self): + """NVM button on the Brief dismisses the banner (built into + `Brief.showBanner`'s nvm handler — `banner.remove()`).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") nvm = self.wait_for( lambda: self.browser.find_element( - By.CSS_SELECTOR, ".my-sea-sign-gate__back" + By.CSS_SELECTOR, ".my-sea-sign-gate-brief .note-banner__nvm" ) ) - href = nvm.get_attribute("href") or "" - self.assertTrue( - href.endswith("/gameboard/"), - f"NVM should link to /gameboard/, got {href!r}", + nvm.click() + self.wait_for( + lambda: self.assertEqual( + len(self.browser.find_elements( + By.CSS_SELECTOR, ".my-sea-sign-gate-brief" + )), + 0, + "NVM should remove the gate Brief from the DOM", + ) ) # ── Test 4 ─────────────────────────────────────────────────────────────── def test_with_sig_skips_gate_and_renders_draw_shell(self): - """User w. saved significator → no .my-sea-sign-gate on the page; - draw shell renders normally (Sprint 3 placeholder).""" + """User w. saved significator → no sign-gate Brief on the page; + draw shell renders normally.""" _assign_sig(self.gamer, self.target_card) self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/") @@ -129,34 +136,35 @@ class MySeaSignGateTest(FunctionalTest): lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page") ) self.assertEqual( - len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-sign-gate")), + len(self.browser.find_elements( + By.CSS_SELECTOR, ".my-sea-sign-gate-brief")), 0, - "Gate should not render when user has a saved significator", + "Gate Brief should not render when user has a saved significator", ) # ── Test 5 ─────────────────────────────────────────────────────────────── def test_no_sig_applet_mirrors_gate_with_fyi_link(self): - """On /gameboard/, the My Sea applet's empty state shows the same - Look!-formatted gate w. FYI link to /billboard/my-sign/ when the - user has no significator. Provides a consistent UX across surfaces.""" + """On /gameboard/, the My Sea applet fires the same sign-gate + Brief w. FYI link to /billboard/my-sign/ when the user has no + significator. Provides a consistent UX across surfaces.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/") - applet_gate = self.wait_for( + banner = self.wait_for( lambda: self.browser.find_element( - By.CSS_SELECTOR, "#id_applet_my_sea .my-sea-sign-gate" + By.CSS_SELECTOR, ".note-banner.my-sea-sign-gate-brief" ) ) - self.assertIn("Look!", applet_gate.text) - fyi = applet_gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi") + self.assertIn("Look!", banner.text) + fyi = banner.find_element(By.CSS_SELECTOR, ".note-banner__fyi") href = fyi.get_attribute("href") or "" self.assertTrue(href.endswith("/billboard/my-sign/")) # ── Test 6 ─────────────────────────────────────────────────────────────── def test_with_sig_applet_renders_default_empty_state(self): - """Applet w. saved sig → no gate, empty-state placeholder (until - Sprint 7 wires up the latest-draw rendering).""" + """Applet w. saved sig → no gate Brief, empty-state placeholder + ("No draws yet.") inside the applet body.""" _assign_sig(self.gamer, self.target_card) self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/") @@ -165,7 +173,7 @@ class MySeaSignGateTest(FunctionalTest): ) self.assertEqual( len(self.browser.find_elements( - By.CSS_SELECTOR, "#id_applet_my_sea .my-sea-sign-gate" + By.CSS_SELECTOR, ".my-sea-sign-gate-brief" )), 0, ) @@ -615,8 +623,8 @@ class MySeaSpreadFormTest(FunctionalTest): "past-present-future": {"leave": "Past", "cover": "Present", "loom": "Future"}, "mind-body-spirit": {"crown": "Mind", "lay": "Body", "loom": "Spirit"}, "desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","crown":"Solution"}, - "waite-smith": {"crown": "Crown", "leave": "Beneath", "cover": "Cover", - "cross": "Cross", "loom": "Before", "lay": "Behind"}, + "waite-smith": {"crown": "Crown", "leave": "Behind", "cover": "Cover", + "cross": "Cross", "loom": "Before", "lay": "Beneath"}, } picker = self._enter_picker_phase() combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]") diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index 1db9624..1bb75ec 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -433,73 +433,165 @@ body.page-billposts { // `.applet-list-page .applet-scroll`). Old `.scroll-list` styling removed. // ── My Sign applet (billboard) ──────────────────────────────────────────── -// Saved-sig preview thumbnail. Same 5:8 card shell + corner/name layout as -// `.sig-stage-card` (see `_card-deck.scss`) but scaled down to fit the 4×6 -// applet aperture. Without these rules the markup collapses bg-less to the -// applet's top-left corner. `--applet-card-w` drives all child font sizing -// off a single knob (same calc-fractions as `.sig-stage-card` w. --sig-card-w). +// Saved-sig preview — mirrors the `.sig-stage-card` layout (corner top- +// left + face w. name + arcana + mirror corner bottom-right) but sized +// to fill the applet's vertical aperture rather than a fixed 5rem. +// Container queries on `.my-sign-applet-body` lift `--applet-card-w` to +// `min(100cqi, 62.5cqh)` — the card grows to fill whichever axis is +// constraining (62.5cqh = `100cqh * 5/8` keeps the 5:8 aspect inside +// the container height). All child font sizes calc off --applet-card-w +// so the typography scales w. the card without per-applet tuning. #id_applet_my_sign { display: flex; flex-direction: column; + // Anchor for #id_applet_sky_delete_btn's absolute centering. + position: relative; + background-color: rgba(var(--duoUser), 1) !important; + + .my-sign-applet-empty { + opacity: 1 !important; + } + + h2 { + flex-shrink: 0; + background-color: rgba(var(--priUser), 1); + box-shadow: rgba(0, 0, 0, 1) !important; + } .my-sign-applet-body { flex: 1; min-height: 0; display: flex; + flex-direction: row; align-items: center; justify-content: center; - padding: 0.5rem; + gap: 0.5rem; + padding: 0.3rem; + container-type: size; } .my-sign-applet-card { - --applet-card-w: 5rem; + // Width-cap shrinks by half the row (card + gap + stat-block) so the + // pair centres without horizontal overflow. `1.0cqi` keeps the + // gap/padding allowance — anything left after the stat-block grows + // to fill, capped by the card's natural 5:8 aspect. + --applet-card-w: min(48cqi, 62.5cqh); width: var(--applet-card-w); aspect-ratio: 5 / 8; border-radius: 0.4rem; background: rgba(var(--priUser), 1); border: 0.12rem solid rgba(var(--secUser), 0.6); color: rgba(var(--secUser), 1); - padding: 0.25rem; + padding: 0.35rem; position: relative; display: flex; flex-direction: column; overflow: hidden; transition: transform 0.4s ease; - .fan-card-corner--tl { + // Top-left + bottom-right corners — rank + suit icon stacked. + // br is rotated 180° (mirror) so the card reads as "completed" + // from both edges, matching the stage card pattern. + .fan-card-corner--tl, + .fan-card-corner--br { display: flex; flex-direction: column; align-items: center; - line-height: 1.1; + line-height: 1.05; gap: 0.05rem; position: absolute; - top: 0.2rem; - left: 0.2rem; .fan-corner-rank { - font-size: calc(var(--applet-card-w) * 0.18); + font-size: calc(var(--applet-card-w) * 0.16); font-weight: 700; } - i { font-size: calc(var(--applet-card-w) * 0.14); } + i { font-size: calc(var(--applet-card-w) * 0.13); } + } + .fan-card-corner--tl { top: 0.25rem; left: 0.3rem; } + .fan-card-corner--br { + bottom: 0.25rem; right: 0.3rem; + transform: rotate(180deg); + } + + // Card face — name + arcana stacked, centred in the remaining + // vertical space between the two corners. `flex: 1` lets the + // face absorb whatever's left after the absolute-positioned + // corners. + .fan-card-face { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2rem; + text-align: center; + padding: 0 0.2rem; } .fan-card-name { - flex: 1; - display: flex; - align-items: center; - justify-content: center; margin: 0; - text-align: center; - font-size: calc(var(--applet-card-w) * 0.13); - font-weight: 600; + font-size: calc(var(--applet-card-w) * 0.11); + font-weight: 700; + line-height: 1.15; text-wrap: balance; - padding: 0 0.15rem; color: rgba(var(--quiUser), 1); } + .fan-card-arcana { + margin: 0; + font-size: calc(var(--applet-card-w) * 0.075); + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.6; + } + &.stage-card--reversed { transform: rotate(180deg); } } + // Stat block — mirrors the stage card's footprint (same 5:8 aspect + + // height) so the pair reads as a balanced 2-tile composition centred + // in the applet aperture. Styling cribbed from `.sig-stat-block` in + // `_card-deck.scss:595-607` (priUser-translucent bg + terUser border) + // minus the SPIN/FYI button apparatus — applet is read-only, no + // interaction needed. `--applet-card-w` is reused as the sizing knob + // so stat-face-label + stat-keywords typography scales w. the card. + .my-sign-applet-stat-block { + --applet-card-w: min(48cqi, 62.5cqh); + width: var(--applet-card-w); + aspect-ratio: 5 / 8; + align-self: center; + background: rgba(var(--priUser), 0.8); + border-radius: 0.4rem; + border: 0.1rem solid rgba(var(--terUser), 0.15); + padding: calc(var(--applet-card-w) * 0.08); + display: flex; + flex-direction: column; + overflow: hidden; + + .stat-face-label { + font-size: calc(var(--applet-card-w) * 0.08); + text-transform: uppercase; + letter-spacing: 0.09em; + opacity: 0.7; + color: rgba(var(--terUser), 1); + margin: 0 0 calc(var(--applet-card-w) * 0.06); + } + + .stat-keywords { + list-style: none; + padding: 0; + margin: 0; + + li { + font-size: calc(var(--applet-card-w) * 0.1); + padding: calc(var(--applet-card-w) * 0.04) 0; + color: rgba(var(--quiUser), 1); + border-bottom: 0.05rem solid rgba(var(--terUser), 0.18); + &:last-child { border-bottom: none; } + } + } + } + .my-sign-applet-empty { flex: 1; display: flex; diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index f176116..1b54505 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -60,6 +60,8 @@ .stat-face--upright { display: block; } &.is-reversed { + opacity: 1; + .stat-face--upright { display: none; } .stat-face--reversed { display: block; } } diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index b5fcdda..4e7413d 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -162,48 +162,13 @@ body.page-gameboard { } // ─── My Sea sign-gate ──────────────────────────────────────────────────────── -// Sprint 4b of [[project-my-sea-roadmap]]. Renders when User.significator -// is None, on both the standalone /gameboard/my-sea/ page AND the -// /gameboard/ My Sea applet. Look!-formatted Brief-style line w. FYI -// (→ /billboard/my-sign/) + BACK (→ /gameboard/) action buttons. Inline -// content (not portaled like .note-banner) — it IS the page content -// until a sig is picked, not a transient nudge. -.my-sea-sign-gate { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1rem; - padding: 1.5rem; - color: rgba(var(--terUser), 1); - - .my-sea-sign-gate__line { - text-align: center; - font-size: 1.1rem; - line-height: 1.4; - margin: 0; - // --terUser ink mirrors the gate's accent + signals "do this - // first" visually distinct from the body's standard --secUser. - color: rgba(var(--terUser), 1); - } - - .my-sea-sign-gate__actions { - display: flex; - gap: 1rem; - align-items: center; - } - - // Applet variant — denser layout, omits NVM (the user is already on - // the gameboard). Smaller line + just the FYI action surviving. - &.my-sea-sign-gate--applet { - padding: 0.5rem; - gap: 0.5rem; - - .my-sea-sign-gate__line { - font-size: 0.85rem; - } - } -} +// REMOVED 2026-05-22 — refactored to a Brief banner. The no-sig nudge now +// fires via `Brief.showBanner` from `_my_sea_sign_gate_brief.html`, which +// portals a `.note-banner.my-sea-sign-gate-brief` to the page h2 (gaussian- +// glass shell, FYI → /billboard/my-sign/, NVM dismisses). All the inline +// `.my-sea-sign-gate{,--applet,__line,__actions,__back,__fyi}` styling +// dropped — `.note-banner` rules in `_note.scss:11` cover positioning, +// shell, + button placement DRYly. // ─── My Sea DRAW SEA landing ───────────────────────────────────────────────── // Sprint 5 iter 1 of [[project-my-sea-roadmap]]. When a user has a saved @@ -596,3 +561,214 @@ body.page-gameboard { // above the anchor button w. Gaussian glass + no backdrop). The picker IIFE // invokes it via `window.showGuard(delBtn, "Are you sure?", confirmFn, // null, {yesLabel: "DEL"})`. No my-sea-specific SCSS needed. + + +// ── My Sea applet (billboard-style gameboard applet) ───────────────────────── +// The applet at `_applet-my-sea.html` lists the active draw's slots in +// DRAW_ORDER — drawn cards filled + empty slots placeholder'd, each +// w. a label caption tucked tight against the slot's bottom edge. +// Horizontal-scroll mirrors the Palettes applet (`.palette` in +// `_palette-picker.scss:1`): row of fixed-size items + `overflow-x: +// auto`, so 6-card spreads scroll while 3-card spreads fit. Slots use +// the same `.sig-stage-card` layout language as the my_sign.html stage +// card (corner-tl + face w. name + corner-br) at applet scale — +// container queries on `.my-sea-scroll` lift `--slot-w` to fill the +// scroll's vertical aperture (minus label) so cards span the whole +// applet height per user spec 2026-05-22. +#id_applet_my_sea { + display: flex; + flex-direction: column; + // Anchor for #id_applet_sky_delete_btn's absolute centering. + position: relative; + background-color: rgba(var(--duoUser), 1) !important; + + h2 { + flex-shrink: 0; + background-color: rgba(var(--priUser), 1); + box-shadow: rgba(0, 0, 0, 1) !important; + } + + .my-sea-scroll { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0.75rem; + padding: 0.25rem 0.5rem 0.5rem; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + container-type: size; + } + + .my-sea-slot-wrap { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; + scroll-snap-align: start; + height: 100%; + // No gap — the label sits directly against the slot's bottom + // border per user spec ("tighter [...] practically overlapping"). + // Slight negative margin pulls the label baseline up into the + // slot's border line so the two visually merge. + } + + // Slot shell — 5:8 card, sized to fill the wrap's height minus the + // label row. `--slot-w` resolves via container queries: 100cqi-cap + // when the scroll is wide-but-shallow, 100cqh*5/8 = 62.5cqh - tiny- + // label-reservation otherwise. The `- 1rem` carves out the label + // row + tight gap so the card doesn't overshoot the applet floor. + .my-sea-slot { + --slot-w: min(100cqi, calc((100cqh - 1rem) * 5 / 8)); + width: var(--slot-w); + aspect-ratio: 5 / 8; + border-radius: 0.4rem; + border: 0.12rem solid rgba(var(--secUser), 0.6); + padding: 0.35rem; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + flex: 0 0 auto; + + .fan-card-corner--tl, + .fan-card-corner--br { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.05; + gap: 0.05rem; + position: absolute; + + .fan-corner-rank { + font-size: calc(var(--slot-w) * 0.16); + font-weight: 700; + } + i { font-size: calc(var(--slot-w) * 0.13); } + } + .fan-card-corner--tl { top: 0.25rem; left: 0.3rem; } + .fan-card-corner--br { + bottom: 0.25rem; right: 0.3rem; + transform: rotate(180deg); + } + + .fan-card-face { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2rem; + text-align: center; + padding: 0 0.2rem; + } + + .fan-card-name { + margin: 0; + font-size: calc(var(--slot-w) * 0.105); + font-weight: 700; + line-height: 1.15; + text-wrap: balance; + } + + .fan-card-arcana { + margin: 0; + font-size: calc(var(--slot-w) * 0.07); + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.6; + } + } + + // Filled slot polarity — mirrors `.sea-card-slot--gravity` / `--levity` + // in `_card-deck.scss:1332-1341`. Gravity = priUser bg + quiUser text; + // levity = inverted (secUser bg + priUser text). Explicit + // `.fan-card-name { color: ... }` override is required: the global + // `.fan-card-name` rule in `_card-deck.scss:569-570` hardcodes + // --quiUser, which is invisible on the levity --secUser bg (both + // light variants). Setting it back to --priUser here restores + // contrast. Corner-rank + arcana inherit from the slot's `color` + // (no global override) so they follow polarity automatically. + .my-sea-slot--filled.my-sea-slot--gravity { + background: rgba(var(--priUser), 1); + color: rgba(var(--quiUser), 1); + border-color: rgba(var(--secUser), 0.6); + } + .my-sea-slot--filled.my-sea-slot--levity { + background: rgba(var(--secUser), 1); + color: rgba(var(--priUser), 1); + border-color: rgba(var(--priUser), 1); + // `.fan-card-corner` carries a global `color: rgba(var(--secUser), + // 0.75)` rule in `_card-deck.scss:312-319` that out-specifics the + // slot's inherited color (specificity 0,1,0 wins over inheritance). + // On the levity --secUser bg this paints the corner-rank + suit- + // icon in the same color as the background → invisible. Same trap + // bit my-sign + game-kit earlier — fix is an explicit override at + // matching/higher specificity inside the polarity rule. + // `.fan-card-name` has its own `color: --quiUser` global rule + // (`_card-deck.scss:569-570`); `.fan-card-arcana` inherits but pin + // explicitly so a future global tweak can't silently re-break it. + .fan-card-corner { color: rgba(var(--priUser), 1); } + .fan-card-name { color: rgba(var(--priUser), 1); } + .fan-card-arcana { color: rgba(var(--priUser), 0.7); } + } + .my-sea-slot--filled.my-sea-slot--reversed { transform: rotate(180deg); } + + // Empty slot — matches the my_sea.html picker's empty `.sea-card- + // slot` style (`_card-deck.scss:1299-1303`): 0.15rem DASHED border in + // --terUser at full opacity, --duoUser fill. Same width + dash + // frequency as the picker so the applet reads as a true "miniature" + // of the picker rather than a cousin w. different dotting cadence. + // `!important` on the three border properties: the base `.my-sea- + // slot` border shorthand sits at the same (1,1,0) specificity, so + // belt-and-suspenders the override. + .my-sea-slot--empty { + background-color: rgba(var(--duoUser), 1); + border-style: dashed !important; + border-color: rgba(var(--terUser), 1) !important; + border-width: 0.15rem !important; + } + + // Label — pulled tight against the slot's bottom border + vertically + // stretched via `scaleY(1.2)` to match the my_sea.html picker's + // `.sea-pos-label` typography (re-appropriated from `.sea-stack-name` + // in `_card-deck.scss:1671-1684`). Negative margin-top crosses the + // 0.12rem border so the label's top edge overlaps the bottom edge of + // the slot, per the user-locked spec ("practically overlapping"). + .my-sea-slot-label { + margin-top: -0.15rem; + padding: 0 0.2rem; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(var(--secUser), 0.85); + text-align: center; + white-space: nowrap; + line-height: 1.1; + transform: scaleY(1.4); + transform-origin: top center; + } + // `.my-sea-slot-label--empty` intentionally has NO per-state recolor + // — the empty-state label keeps the same `--secUser` ink as the + // filled-slot label per user spec 2026-05-22 (pins position identity + // Cover/Cross/etc. across the row regardless of fill state). + // Previously dimmed to --terUser to echo the dashed border tone — + // but that broke title cohesion when most slots were empty. + + // No-draws empty state — centred italic, mirrors the Brief / applet- + // list-entry--empty pattern in `_billboard.scss:29-38`. + .my-sea-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-style: italic; + opacity: 0.6; + margin: 0; + } +} diff --git a/src/static_src/scss/_sky.scss b/src/static_src/scss/_sky.scss index 78f6baf..5e61def 100644 --- a/src/static_src/scss/_sky.scss +++ b/src/static_src/scss/_sky.scss @@ -906,8 +906,13 @@ body[class*="-light"] #id_sky_tooltip_2 { flex-direction: column; // Anchor for #id_applet_sky_delete_btn's absolute centering. position: relative; + background-color: rgba(var(--duoUser), 1) !important; - h2 { flex-shrink: 0; } + h2 { + flex-shrink: 0; + background-color: rgba(var(--priUser), 1); + box-shadow: rgba(0, 0, 0, 1) !important; + } .sky-svg { flex: 1; @@ -953,8 +958,20 @@ body[class*="-light"] #id_sky_tooltip_2 { flex-direction: column; overflow-y: auto; overflow-x: hidden; + // --duoUser aperture bg — matches the My Sky / My Sea / My Sign + // applet shells (`_sky.scss:909`, `_gameboard.scss:583`, + // `_billboard.scss:449`) so all four personal-data surfaces read + // as a unified olive-bg group across the dashboard/billboard/ + // gameboard navigation. + background-color: rgba(var(--duoUser), 1); } +// Note: tried `body.page-sky { background-color: --duoUser }` to fill +// any gap below the .sky-page aperture but it bled to navbar + footer +// (which sit OUTSIDE .container) — reverted 2026-05-22. The real fix +// is the scoped `.sky-page .sky-form-col` rule below, which puts the +// olive on the form column where the purple was leaking through. + // DEL btn pinned at the wheel center — appears wherever a wheel is shown // (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 / @@ -1021,6 +1038,16 @@ body[class*="-light"] #id_sky_tooltip_2 { overflow-y: visible; } +// Override the base `.sky-form-col { background: --priUser }` (line 137, +// shared w. the in-room CAST SKY modal) so the dashsky form column sits +// on the same --duoUser olive as the rest of the .sky-page aperture. +// Scoped to `.sky-page` so the in-room modal's purple form-col stays +// intact (it sits over the room's --secUser bg + needs the contrast). +.sky-page .sky-form-col { + background: rgba(var(--duoUser), 1); + border-right-color: transparent; +} + // 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" // once we flip to flex-column. Reset. diff --git a/src/templates/apps/billboard/_partials/_applet-my-sign.html b/src/templates/apps/billboard/_partials/_applet-my-sign.html index f32640c..f4d3139 100644 --- a/src/templates/apps/billboard/_partials/_applet-my-sign.html +++ b/src/templates/apps/billboard/_partials/_applet-my-sign.html @@ -6,13 +6,48 @@
{% if request.user.significator %} {% with card=request.user.significator %} + {# Mirrors the my_sign.html `.sig-stage-card` layout — corner #} + {# top-left, full name in the face, polarity-reversed mirror #} + {# at the bottom (pre-rotated). Sized to fill the applet's #} + {# vertical aperture via container queries in `_billboard.scss`. #}
{{ card.corner_rank }} {% if card.suit_icon %}{% endif %}
-

{{ card.name_title }}

+
+

{{ card.name_title }}

+

{{ card.get_arcana_display }}

+
+
+ {{ card.corner_rank }} + {% if card.suit_icon %}{% endif %} +
+
+ {# Stat block — same shape as my_sign.html's `.sig-stat-block` #} + {# (Emanation/Reversal face label + keyword list) but no SPIN #} + {# or FYI buttons since the applet is a read-only preview. The #} + {# face shown is keyed off significator_reversed: True → #} + {# reversal keywords (labelled "Reversal"), False → upright #} + {# (labelled "Emanation"). Mirrors the FYI panel populated by #} + {# `StageCard.populateKeywords` in my_sign.html's JS init. #} +
+ {% if request.user.significator_reversed %} +

Reversal

+
    + {% for kw in card.keywords_reversed %} +
  • {{ kw }}
  • + {% endfor %} +
+ {% else %} +

Emanation

+
    + {% for kw in card.keywords_upright %} +
  • {{ kw }}
  • + {% endfor %} +
+ {% endif %}
{% endwith %} {% else %} diff --git a/src/templates/apps/gameboard/_partials/_applet-my-sea.html b/src/templates/apps/gameboard/_partials/_applet-my-sea.html index 7dee233..9628c7c 100644 --- a/src/templates/apps/gameboard/_partials/_applet-my-sea.html +++ b/src/templates/apps/gameboard/_partials/_applet-my-sea.html @@ -3,26 +3,56 @@ style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" >

My Sea

-
- {% if not request.user.significator_id %} - {# Sprint 4b applet-gate mirror — same Look!-formatted nudge as #} - {# the standalone page so the UX is consistent across surfaces. #} -
-

- Look!—pick your sign before drawing the Sea. -

- FYI -
- {% elif latest_draw_cards %} - {% for card in latest_draw_cards %} -
- {{ card.corner_rank }} - {% if card.suit_icon %}{% endif %} + {# `my_sea_slots` (built by `latest_draw_slots()` in `gameboard.models`) #} + {# carries one entry per spread position in DRAW_ORDER — filled slots #} + {# render the drawn card, empty slots render as labelled placeholders. #} + {# Spread lock-in: the row is created at first card draw, so the moment #} + {# 1+ cards exist all the spread's positions show in the applet. The #} + {# scroll container handles overflow (mirrors the Palettes applet). #} + {% if not request.user.significator_id %} + {# Sprint 4b applet-gate — DRYly rendered as a project-wide Brief #} + {# banner (`note-banner` Gaussian-glass shell, portaled to the #} + {# page h2). Inline body falls through to the empty-state "No #} + {# draws yet" since no sig → no draws is the only possible state.#} + {% include "apps/gameboard/_partials/_my_sea_sign_gate_brief.html" %} +

No draws yet.

+ {% elif my_sea_slots %} +
+ {% for slot in my_sea_slots %} + {% if slot.card %} +
+ {# Mirrors the my_sign.html `.sig-stage-card` layout — #} + {# corner top-left, face w. name + arcana, mirror corner #} + {# bottom-right. Sized to fill the applet height via #} + {# container queries in `_gameboard.scss`. #} +
+
+ {{ slot.card.corner_rank }} + {% if slot.card.suit_icon %}{% endif %} +
+
+

{{ slot.card.name_title }}

+

{{ slot.card.get_arcana_display }}

+
+
+ {{ slot.card.corner_rank }} + {% if slot.card.suit_icon %}{% endif %} +
+
+ {{ slot.label }}
- {% endfor %} - {% else %} -

No draws yet.

- {% endif %} -
+ {% else %} +
+
+ {{ slot.label }} +
+ {% endif %} + {% endfor %} +
+ {% else %} +

No draws yet.

+ {% endif %} diff --git a/src/templates/apps/gameboard/_partials/_my_sea_sign_gate_brief.html b/src/templates/apps/gameboard/_partials/_my_sea_sign_gate_brief.html new file mode 100644 index 0000000..69c8a12 --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_my_sea_sign_gate_brief.html @@ -0,0 +1,26 @@ +{# Sign-gate Brief — fired when the user has no `significator` set on the #} +{# My Sea page OR the My Sea applet. Replaces the prior inline `.my-sea- #} +{# sign-gate` markup w. a DRY call into the project-wide `Brief.showBanner` #} +{# pattern (gaussian-glass `.note-banner` shell, portaled to the h2 anchor #} +{# by `note.js`'s `_alignToH2`). FYI → /billboard/my-sign/ (pick a sign); #} +{# NVM dismisses (built into Brief). `.my-sea-sign-gate-brief` modifier #} +{# class is added post-render so FT selectors can disambiguate this banner #} +{# from any other Brief on the same page. #} +{# #} +{# Host pages MUST load `apps/dashboard/note.js` themselves (the partial #} +{# can't safely re-load it — `note.js` declares `const Brief = ...` at #} +{# global scope, so a second load would SyntaxError on re-declaration). #} + diff --git a/src/templates/apps/gameboard/_partials/_sea_overlay.html b/src/templates/apps/gameboard/_partials/_sea_overlay.html index 988d5e3..8c41bbe 100644 --- a/src/templates/apps/gameboard/_partials/_sea_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sea_overlay.html @@ -25,7 +25,7 @@
- {# Beneath (past) — CC pos 4 / EV pos 3 #} + {# Behind (past) — CC pos 6 / EV pos 4 #}
@@ -50,7 +50,7 @@
- {# Behind (root) — CC pos 6 / EV pos 4 #} + {# Beneath (root) — CC pos 4 / EV pos 3 #}
diff --git a/src/templates/apps/gameboard/gameboard.html b/src/templates/apps/gameboard/gameboard.html index 970ea62..33e04e3 100644 --- a/src/templates/apps/gameboard/gameboard.html +++ b/src/templates/apps/gameboard/gameboard.html @@ -14,5 +14,10 @@ {% endblock content %} {% block scripts %} + {# note.js exposes window.Brief — used by the My Sea applet's sign-gate #} + {# Brief partial (`_my_sea_sign_gate_brief.html`) when the user has no #} + {# saved significator. Page-level load so the partial doesn't risk a #} + {# re-declaration SyntaxError (note.js uses `const Brief = ...`). #} + {% endblock scripts %} diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index ff6bda9..59b5e61 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -5,26 +5,23 @@ {% block header_text %}GameSea{% endblock header_text %} {% block content %} + {# note.js exposes window.Brief — needed by BOTH the no-sig branch's #} + {# sign-gate Brief partial AND the with-sig branch's Default-deck + #} + {# Free-draw-locked Briefs. Hoisted out of the branches so it loads #} + {# exactly once regardless of which path the user is on (note.js #} + {# declares `const Brief = ...` at global scope — a second load would #} + {# SyntaxError on re-declaration). #} +
{% if not user_has_sig %} - {# Sprint 4b sign-gate. The draw UX is gated behind a saved #} - {# significator — render a Look!-formatted Brief-style line w. #} - {# FYI (→ /billboard/my-sign/) + NVM (→ /gameboard/) until the #} - {# user picks a sign. Inline (not portaled like .note-banner) #} - {# because the gate IS the page content, not a transient nudge. #} -
-

- Look!—pick your sign before drawing the Sea. -

-
- NVM - FYI -
-
+ {# Sprint 4b sign-gate — DRYly Brief-banner'd (was inline div #} + {# .my-sea-sign-gate, refactored 2026-05-22 to use the project- #} + {# wide `.note-banner` portal via `Brief.showBanner`). NVM lives #} + {# inside the Brief shell; FYI links to /billboard/my-sign/. #} + {# Page body stays empty — the Brief is the entire no-sig UX. #} + {% include "apps/gameboard/_partials/_my_sea_sign_gate_brief.html" %} {% else %} {% if not show_picker %} {# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #} @@ -284,12 +281,20 @@ 'situation-action-outcome': { lay: 'Situation', cover: 'Action', crown: 'Outcome' }, 'mind-body-spirit': { crown: 'Mind', lay: 'Body', loom: 'Spirit' }, 'desire-obstacle-solution': { loom: 'Desire', cross: 'Obstacle',crown: 'Solution' }, - 'waite-smith': { crown: 'Crown', leave: 'Beneath', cover: 'Cover', cross: 'Cross', loom: 'Before', lay: 'Behind' }, + // CSS grid (`_card-deck.scss:1276-1279`) places sea-pos- + // leave at LEFT + sea-pos-lay at BOTTOM. Traditional + // Celtic Cross has "Behind" (past) at LEFT + "Beneath" + // (root) at BOTTOM, so labels here pair lay→"Beneath" + // + leave→"Behind". Fix landed 2026-05-22: prior assign- + // ment had Beneath at LEFT + Behind at BOTTOM (inverted + // vs tradition + vs the grid's own commented intent in + // `_sea_overlay.html`). + 'waite-smith': { crown: 'Crown', leave: 'Behind', cover: 'Cover', cross: 'Cross', loom: 'Before', lay: 'Beneath' }, // Escape Velocity remaps the diagonal positions per the // user-locked spec (2026-05-19): Beneath→Lay, Before→ // Loom, Behind→Leave. Crown/Cover/Cross keep the WS - // names. - 'escape-velocity': { crown: 'Crown', leave: 'Lay', cover: 'Cover', cross: 'Cross', loom: 'Loom', lay: 'Leave' }, + // names. Carries the same LEFT/BOTTOM correction as WS. + 'escape-velocity': { crown: 'Crown', leave: 'Leave', cover: 'Cover', cross: 'Cross', loom: 'Loom', lay: 'Lay' }, }; var hidden = document.getElementById('id_sea_spread'); var cross = document.querySelector('.my-sea-cross'); @@ -934,8 +939,8 @@ {# /billboard/my-sign/'s no-equipped-deck path. Same copy, #} {# same FYI (→ /gameboard/) + NVM (dismiss + proceed) actions.#} {# Tagged w. .my-sea-intro-banner so FTs disambiguate from #} - {# any other Briefs on the page. #} - + {# any other Briefs on the page. note.js itself is hoisted #} + {# to the top of {% block content %} (single load per page). #} {% if active_draw %} {# Iter 4b — saved-draw Brief. Standard portaled banner via #} {# Brief.showBanner (Gaussian-glass bg, atop-h2 positioning); #}