diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 59bcc46..d2dc297 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -292,6 +292,7 @@ def my_sign(request): def save_sign(request): """Persist the user's sign choice — POST { card_id, reversed }.""" from apps.epic.models import TarotCard + from apps.gameboard.models import MySeaDraw if request.method != "POST": return redirect("billboard:my_sign") card_id = request.POST.get("card_id") @@ -300,9 +301,24 @@ def save_sign(request): card = TarotCard.objects.get(pk=card_id) except (TarotCard.DoesNotExist, ValueError, TypeError): return HttpResponseForbidden("invalid card_id") + sig_changed = request.user.significator_id != card.pk request.user.significator = card request.user.significator_reversed = reversed_flag request.user.save(update_fields=["significator", "significator_reversed"]) + # Sig change RESETS (but does NOT delete) any active MySeaDraw so the + # next /my-sea/ visit reads the NEW sig snapshot — while PRESERVING the + # cooldown anchor (row's `created_at` gates `in_cooldown` per views.py + # line 266) + paid-state fields (`deposit_token_id`, `paid_through_at`). + # Deleting the row would re-open the FREE DRAW gate (loophole — user + # could circumvent the 24h cooldown by re-picking a sig) AND forfeit + # any paid-draw credit the user already committed. Reset clears just + # the hand + sig snapshot, leaving cooldown + paid revenue intact. + if sig_changed: + MySeaDraw.objects.filter(user=request.user).update( + hand=[], + significator_id=card.pk, + significator_reversed=reversed_flag, + ) return redirect("billboard:my_sign") @@ -318,6 +334,14 @@ def clear_sign(request): request.user.significator = None request.user.significator_reversed = False request.user.save(update_fields=["significator", "significator_reversed"]) + # MySeaDraw is INTENTIONALLY left alone on sig-clear. Without a sig the + # user can't draw anyway (`my_sea_lock` returns 400 `no_significator`), + # and /my-sea/ routes to its sign-gate Brief via `user_has_sig`. The + # row's cooldown anchor + paid-state fields must survive a sig clear + # so the user can't re-open the FREE DRAW gate or forfeit paid credit + # by toggling sig-clear → sig-pick (user-reported loophole 2026-05-26). + # When the user re-picks via save_sign, that view's reset path updates + # the row's sig snapshot + clears the hand cleanly. return redirect("billboard:my_sign") diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 4b23aa3..1edbdb9 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -93,6 +93,27 @@ def home_page(request): } if request.user.is_authenticated: context["applets"] = applet_context(request.user, "dashboard") + # My Wallet applet — show all trinkets (PASS/BAND/CARTE/COIN) + stacked + # Free + Tithe tokens (badge count) + Writs placeholder. Trinkets COPY + # the Game Kit applet's tooltip + DON/DOFF wiring so the user can equip + # from either surface; Free + Tithe tokens MOVED off the Game Kit applet + # since they aren't equippable (user spec 2026-05-25 PM). + user = request.user + free_tokens = list(user.tokens.filter( + token_type=Token.FREE, expires_at__gt=timezone.now() + ).order_by("expires_at")) + tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE)) + context.update({ + "coin": user.tokens.filter(token_type=Token.COIN).first(), + "pass_token": user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None, + "band": user.tokens.filter(token_type=Token.BAND).first(), + "carte": user.tokens.filter(token_type=Token.CARTE).first(), + "free_tokens": free_tokens, + "tithe_tokens": tithe_tokens, + "free_count": len(free_tokens), + "tithe_count": len(tithe_tokens), + "equipped_trinket_id": user.equipped_trinket_id, + }) return render(request, "apps/dashboard/home.html", context) diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index f791c9c..1fcd5c6 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -351,10 +351,27 @@ class TarotCard(models.Model): ordering = ["deck_variant", "arcana", "suit", "number"] unique_together = [("deck_variant", "slug")] + # Per-trump overrides for Fiorentine Minchiate fidelity — the historical + # deck art uses additive numerals at these specific ranks only (NOT every + # 4/9 ending; e.g. trump 9 = IX, trump 14 = XIV stay subtractive per the + # actual printed cards). Earthman's 0-49 trumps inherit the same mapping + # for visual consistency w. the Fiorentine deck. Other ranks fall through + # to the standard subtractive `_to_roman` algorithm. + _FIORENTINE_ADDITIVE_NUMERALS = { + 4: 'IIII', + 19: 'XVIIII', + 24: 'XXIIII', + 29: 'XXVIIII', + 34: 'XXXIIII', + 39: 'XXXVIIII', + } + @staticmethod def _to_roman(n): if n == 0: return '0' + if n in TarotCard._FIORENTINE_ADDITIVE_NUMERALS: + return TarotCard._FIORENTINE_ADDITIVE_NUMERALS[n] val = [50, 40, 10, 9, 5, 4, 1] syms = ['L','XL','X','IX','V','IV','I'] result = '' @@ -485,6 +502,18 @@ class TarotCard(models.Model): @property def suit_icon(self): + if self.arcana == self.MAJOR: + # Trump 0 (Fool / Nomad / Matto) + trump 1 (Magician / Schizo / + # Bagatto) carry universal symbol overrides — cowboy-hat-side for + # the wanderer/fool archetype, wizard-hat for the magus archetype. + # Pinned BEFORE the `self.icon` branch so even a deck seed that + # supplies a different icon for these two ranks gets normalized + # to the convention (Earthman's seed already aligns; Minchiate's + # empty icon field used to fall through to fa-hand-dots). + if self.number == 0: + return 'fa-hat-cowboy-side' + if self.number == 1: + return 'fa-hat-wizard' if self.icon: return self.icon if self.arcana == self.MAJOR: @@ -493,7 +522,7 @@ class TarotCard(models.Model): # card overrides still win via the `self.icon` branch above (the # Earthman seed sets `icon="fa-hand-dots"` explicitly for trumps # 2+, which was the only place this fallback used to live; trumps - # 0/1 + every Minchiate trump now pick it up for free). + # 2+ Minchiate trumps still pick it up for free here). return 'fa-hand-dots' return { self.BRANDS: 'fa-wand-sparkles', diff --git a/src/apps/epic/tests/unit/test_models.py b/src/apps/epic/tests/unit/test_models.py index ce339d3..9356a9e 100644 --- a/src/apps/epic/tests/unit/test_models.py +++ b/src/apps/epic/tests/unit/test_models.py @@ -48,6 +48,26 @@ class TarotCardCornerRankTest(SimpleTestCase): def test_court_king_gives_K(self): self.assertEqual(_card('MIDDLE', 14, 'CROWNS').corner_rank, 'K') + def test_major_arcana_fiorentine_additive_overrides(self): + """The 6 trumps the Fiorentine deck prints in additive form (NOT a + universal pattern shift — e.g. trump 9 still prints IX, trump 14 + still prints XIV, per the actual deck art). Locked-in list.""" + self.assertEqual(_card('MAJOR', 4).corner_rank, 'IIII') + self.assertEqual(_card('MAJOR', 19).corner_rank, 'XVIIII') + self.assertEqual(_card('MAJOR', 24).corner_rank, 'XXIIII') + self.assertEqual(_card('MAJOR', 29).corner_rank, 'XXVIIII') + self.assertEqual(_card('MAJOR', 34).corner_rank, 'XXXIIII') + self.assertEqual(_card('MAJOR', 39).corner_rank, 'XXXVIIII') + + def test_major_arcana_non_overridden_ranks_stay_subtractive(self): + """Trumps NOT in the additive-override list keep standard subtractive + Roman numerals. Regression guard against accidental pattern-shift.""" + self.assertEqual(_card('MAJOR', 9).corner_rank, 'IX') + self.assertEqual(_card('MAJOR', 14).corner_rank, 'XIV') + self.assertEqual(_card('MAJOR', 40).corner_rank, 'XL') + self.assertEqual(_card('MAJOR', 44).corner_rank, 'XLIV') + self.assertEqual(_card('MAJOR', 49).corner_rank, 'XLIX') + class TarotCardSuitIconTest(SimpleTestCase): """TarotCard.suit_icon — icon class resolution.""" diff --git a/src/apps/gameboard/static/apps/gameboard/gameboard.js b/src/apps/gameboard/static/apps/gameboard/gameboard.js index 2209cc8..fb79b01 100644 --- a/src/apps/gameboard/static/apps/gameboard/gameboard.js +++ b/src/apps/gameboard/static/apps/gameboard/gameboard.js @@ -129,22 +129,15 @@ function initGameKitTooltips() { }); } - // If the kit bag dialog is open, replace the matching card with a placeholder. - function _syncKitBagDialog(kind, id) { - const dialog = document.getElementById('id_kit_bag_dialog'); - if (!dialog || !dialog.hasAttribute('open')) return; - const selector = kind === 'deck' - ? `.kit-bag-deck[data-deck-id="${id}"]` - : `.token[data-token-id="${id}"]`; - const card = dialog.querySelector(selector); - if (!card) return; - const placeholder = document.createElement('div'); - placeholder.className = 'kit-bag-placeholder'; - const icon = card.querySelector('i'); - if (icon) placeholder.innerHTML = icon.outerHTML; - card.parentNode.insertBefore(placeholder, card); - card.remove(); - } + // Kit-bag DOFF refresh used to do a client-side replace-with-placeholder + // here (faster than a re-fetch) but it queried `card.querySelector('i')` + // for the placeholder icon — works for trinket/token cards (single FA + // ``) but BREAKS for image-equipped decks whose card-stack icon is + // an `` (no `` to copy → empty placeholder + // until manual refresh). Symmetric w. DON now: re-fetch the panel HTML + // so the server-rendered `_kit_bag_panel.html` placeholder branch (which + // re-renders `_deck_stack_icon.html` w/o the `deck` arg for the empty + // fill) is the single source of truth. function wireDonDoff(token) { const donBtn = portal.querySelector('.btn-equip'); @@ -205,7 +198,7 @@ function initGameKitTooltips() { _setEquipState(donBtn, doffBtn, false); _syncTokenButtons('trinket', ''); buildMiniContent(token); - _syncKitBagDialog('token', tokenId); + _refreshKitDialog(); } }); } else if (deckId) { @@ -219,7 +212,7 @@ function initGameKitTooltips() { _setEquipState(donBtn, doffBtn, false); _syncTokenButtons('deck', ''); buildMiniContent(token); - _syncKitBagDialog('deck', deckId); + _refreshKitDialog(); } }); } diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 5301907..b381e4a 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -442,8 +442,15 @@ class GameboardViewTest(TestCase): def test_game_kit_has_coin_on_a_string(self): [_] = self.parsed.cssselect("#id_game_kit #id_kit_coin_on_a_string") - def test_game_kit_has_free_token(self): - [_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token") + def test_game_kit_has_no_free_token(self): + """Free Tokens were moved off the Game Kit applet 2026-05-25 PM — + only equippable items (PASS/BAND/CARTE/COIN trinkets + decks + dice) + belong here. Free + Tithe tokens now live in the My Wallet applet + on /dashboard/ (`_applet-wallet.html`).""" + self.assertEqual( + len(self.parsed.cssselect("#id_game_kit #id_kit_free_token")), 0, + "Free Token must NOT render in the Game Kit applet — it's not equippable", + ) def test_game_kit_shows_deck_variant_cards(self): decks = self.parsed.cssselect("#id_game_kit .deck-variant") @@ -2070,15 +2077,19 @@ class MySeaViewWithPartialHandTest(TestCase): self.assertContains(response, 'data-state="auto-draw"') self.assertContains(response, "AUTO") - def test_partial_hand_del_btn_carries_btn_disabled(self): + def test_partial_hand_del_btn_is_enabled(self): + """User spec 2026-05-26: DEL is enabled as soon as ANY card has been + drawn (not gated until hand_complete as before). The partial-hand + fixture has one saved slot, so DEL should be active w. label 'DEL'.""" import re response = self.client.get(reverse("my_sea")) html = response.content.decode() m = re.search( - r']*id="id_sea_del"[^>]*class="([^"]*)"', html, + r']*id="id_sea_del"[^>]*class="([^"]*)"[^>]*>([^<]*)<', html, ) self.assertIsNotNone(m) - self.assertIn("btn-disabled", m.group(1)) + self.assertNotIn("btn-disabled", m.group(1)) + self.assertEqual(m.group(2).strip(), "DEL") def test_partial_hand_picker_does_NOT_carry_locked_class(self): # Hand is mid-progress; locked class only applies on completion. diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index bc96e44..0b720fc 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -155,7 +155,21 @@ def _game_kit_context(user): "carte": carte, "free_tokens": free_tokens, "tithe_tokens": tithe_tokens, - "unlocked_decks": list(user.unlocked_decks.all()), + # `free_count` / `tithe_count` mirror the gameboard Game Kit applet's + # context — drive the `(×N)` chip in the .tt-title plus the rendered + # `.shop-badge` on the icon. Both capped to "99+" at the template + # level so a runaway count doesn't blow the badge layout. + "free_count": len(free_tokens), + "tithe_count": len(tithe_tokens), + # `deck_variants` is the annotated list (carries .in_use_room_name + # per `_annotate_deck_in_use`); the gk-decks section uses the same + # SVG card-stack icon + tt-tooltip pattern as the gameboard's Game + # Kit applet so the deck row reads identically on both surfaces. + "deck_variants": _annotate_deck_in_use(list(user.unlocked_decks.all()), user), + "equipped_deck_id": user.equipped_deck_id, + # `equipped_trinket_id` powers the gk-trinkets section's DON/DOFF + # buttons + mini-portal Equipped status (parity w. gameboard applet). + "equipped_trinket_id": user.equipped_trinket_id, "applets": applet_context(user, "game-kit"), "pronoun_options": pronoun_options, "current_pronouns": user.pronouns, diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 3ee411d..2df11f9 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -1346,9 +1346,12 @@ html:has(.sig-backdrop) { // Sprint A.7.5-polish-4 — same image-mode override as the base rule // above. Without this the 0,3,0 levity rule's --secUser bg would // re-clothe the sea-sig-card under levity even in image mode. + // `overflow: visible` pinned here too so the stroke-contour isn't + // clipped under levity polarity (parity w. base above). &.sig-stage-card--image { background: transparent; border: 0; + overflow: visible; } } @@ -1753,10 +1756,18 @@ $sea-card-h: 6.5rem; // the my_sign-applet pattern in `_billboard.scss`. Filter chain on // `.sig-stage-card-img` is still inherited from the shared rule (the // image-mode rule's img descendant selector doesn't lose anywhere). + // + // `overflow: visible` re-stated — base `.sig-stage-card.sea-sig-card` + // above sets `overflow: hidden` at (0,2,0) which beats the shared + // image-mode rule's `overflow: visible` by source order. Without this + // the contour-stroke filter chain (drop-shadow ±0.2rem cardinals) + // gets clipped to the card's bounding rectangle, painting a uniform + // rectangular frame instead of an alpha-following contour stroke. &.sig-stage-card--image { background: transparent; border: 0; padding: 0; + overflow: visible; } .fan-card-face { @@ -1988,6 +1999,19 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6); box-shadow: $_sea-shadow; overflow: hidden; // clip the back-img to the rounded-rect face } +// Sprint A.7.5-polish-9 — image-mode override for the single deck stack. +// When the back-img child is present (deck has_card_images), drop the +// neutral palette bg + gold border so only the actual card-back image +// reads. `:has()` selector keys off the conditional `` child rendered by the my_sea.html template, no marker class needed. +// border-color: transparent (not border: 0) so the 0.15rem solid base +// border-width stays — the image's object-fit: cover fills the inside, and +// keeping the border-box dimensions identical means hover/active glow +// (box-shadow) renders at the same offset as the polarized variants. +.sea-deck-stack--single .sea-stack-face:has(.sea-stack-face-img) { + background: transparent; + border-color: transparent; +} .sea-stack-face-img { position: absolute; inset: 0; diff --git a/src/static_src/scss/_game-kit.scss b/src/static_src/scss/_game-kit.scss index b8eb424..b607afa 100644 --- a/src/static_src/scss/_game-kit.scss +++ b/src/static_src/scss/_game-kit.scss @@ -82,14 +82,15 @@ } .kit-bag-label { - font-size: 0.55rem; + font-size: 0.6rem; + font-weight: 700; text-transform: uppercase; text-decoration: underline; - letter-spacing: 0.12em; - color: rgba(var(--quaUser), 0.75); + letter-spacing: 0.2em; + color: rgba(var(--secUser), 0.5); writing-mode: vertical-rl; text-orientation: mixed; - transform: rotate(180deg); + transform: rotate(180deg) scaleX(1.3); padding: 0 0.25rem 0 0.5rem; } @@ -187,9 +188,67 @@ align-items: center; } -.gk-deck-card, -.gk-trinket-card, -.gk-token-card, +// `.gk-deck-card` + `.gk-trinket-card` + `.gk-token-card` dropped from this +// multi-class rule — the gk-decks/trinkets/tokens sections all render as +// bare `.token` icons (FA glyph or SVG card-stack) sized by their own rules +// in `_gameboard.scss`; no border + no padding-shell since the hover-portal +// tooltip carries the name/count/description. Pronoun cards still use the +// bordered card-shell shape via this rule. +// +// Icon-row treatment for Trinkets / Tokens / Card Decks — the small icons +// look lonely huddled on the left edge of the wider applet rectangle, so +// center them along the main axis + bump the gap. Scoped to the 3 specific +// section IDs so it doesn't bleed to Pronouns (whose cards fill the row + +// look fine left-anchored). +#id_gk_trinkets .gk-items, +#id_gk_tokens .gk-items, +#id_gk_decks .gk-items { + justify-content: center; + gap: 2rem; +} + +// `#id_game_kit` wraps every section in `_game_kit_sections.html` so +// `gameboard.js`'s `getElementById('id_game_kit')` finds ONE scope covering +// trinkets + tokens + decks. But that wrapper sits between `#id_gk_sections +// _container` (the CSS-grid parent — see `_applets.scss:268`) and its +// `
` children, breaking the `grid-column: span var(--applet-cols)` +// layout that needs the sections as DIRECT children. `display: contents` +// makes the wrapper transparent to layout — sections still resolve as grid +// children of `#id_gk_sections_container` — while keeping the wrapper a +// real DOM element JS can query + read data-attrs off. +#id_gk_sections_container > #id_game_kit { display: contents; } + +// My Wallet applet (`_applet-wallet.html` on /dashboard/) uses the same +// `#id_game_kit` wrapper so `gameboard.js` picks up its `.token` icons + +// fires the hover-portal tooltips. Lay items out as a centered flex row +// (parity w. the standalone game_kit.html sections — see `#id_gk_trinkets +// .gk-items, #id_gk_tokens .gk-items, #id_gk_decks .gk-items` above). +// `height: 100%` keeps the row vertically centered inside the applet box +// since the section's body has the h2 absolute-positioned + claims full +// box height. +#id_applet_wallet > #id_game_kit.wallet-items { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 2rem; + height: 100%; +} + +// `.token` is a flex child of `.gk-items` — make it a positioning context +// so the `.shop-badge` (position: absolute, top: -0.8rem, right: -1.2rem) +// anchors off the icon, NOT off some grandparent. Also pin `font-size: +// 1.5rem` so the standalone game_kit.html icons match the gameboard's +// Game Kit applet sizing — `_gameboard.scss:79` sets this for `#id_applet +// _game_kit #id_game_kit .token` (only fires on /gameboard/ since `#id_ +// applet_game_kit` doesn't exist on this page); we re-state at the same +// 1.5rem so the visual weight is identical across the two surfaces. +#id_game_kit .token { + position: relative; + font-size: 1.5rem; +} + .gk-pronoun-card { display: flex; flex-direction: column; diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index b5e3e70..9045fa7 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -434,7 +434,10 @@ body.page-gameboard { pointer-events: none; white-space: nowrap; position: absolute; - z-index: 2; + // z-index 0 (was 2) — labels sit BEHIND the slot so the slot's + // downward shadow can visually obscure the label's top edge per the + // .sea-stack-name "tuck under" treatment (user spec 2026-05-26). + z-index: 0; } // Cells need `position: relative` so absolute label children anchor @@ -443,22 +446,102 @@ body.page-gameboard { // cells need it added. .my-sea-cross .sea-crucifix-cell { position: relative; } -// Above top border — overlaps slot's top edge by 0.1rem (per the -// `.sea-stack-name` "tuck under" treatment in _card-deck.scss:1564). +// CROWN + COVER labels — ABOVE the slot per user spec 2026-05-26. +// Cross convention: crown is at the top + cover is the central card +// being covered, so their labels belong above. `bottom: 100%` anchors +// label's bottom edge to slot's top; `-0.4rem` translate-Y pushes it +// further away so the labels read clearly w. breathing room. .sea-pos-crown > .sea-pos-label, .sea-pos-cover > .sea-pos-label { bottom: 100%; left: 50%; - transform: translate(-50%, 0.1rem) scaleY(1.2); + transform: translate(-50%, -0.4rem) scaleY(1.2); +} + +// LAY + CROSS labels — BELOW the slot. `0.3rem` translate-Y pushes each +// label a bit further from its slot's bottom edge than the prior tuck- +// under (which crowded the label up against the slot's border). The +// filled-card shadow (added below to `.sea-card-slot--filled`, NOT the +// empty slot per user clarification 2026-05-26) extends 0.25rem downward +// via the `$_sea-shadow` chain — covers the label's top edge w/o the +// label having to physically overlap the card's border-box. +.sea-pos-lay > .sea-pos-label, +.sea-pos-cross > .sea-pos-label { + top: 100%; + left: 50%; + transform: translate(-50%, 0.3rem) scaleY(1.2); +} + +// Breathing room around COVER + CROSS labels — bump CROWN cell UP by +// 0.5rem + LAY cell DOWN by 0.5rem so the COVER label (below the central +// sig card) + CROSS label (also in the central row) have vertical space +// w/o colliding into the crown's slot from above or the lay's slot from +// below. Translate (not margin) so the surrounding grid layout doesn't +// reflow — cells stay in their grid-areas but visually shift. +.my-sea-cross .sea-pos-crown { transform: translateY(-0.5rem); } +.my-sea-cross .sea-pos-lay { transform: translateY( 0.5rem); } + +// Filled-card downward shadow — only on the my-sea Cross page (NOT the +// picker or other surfaces using `.sea-card-slot`), and only the FILLED +// variant (the dashed empty slot stays shadowless per user clarification +// 2026-05-26: "the slots themselves should not have box-shadows ... only +// the cards that replace them should"). Mirrors `.sea-stack-face`'s +// `$_sea-shadow` chain in `_card-deck.scss:1976` (the GRAVITY/LEVITY deck +// stacks): solid-black 1px×2px offset shadow + a softer 4px-down spread + +// a 2px×5px blurred falloff. The image-mode slot still keeps its filter- +// chain contour-stroke + 1px depth shadow from `_card-deck.scss:837` — the +// box-shadow layers cleanly over it for a richer depth read. +.my-sea-cross .sea-card-slot--filled { + box-shadow: + 1px 2px 0 rgba(0, 0, 0, 0.7), + 0 4px 0 rgba(0, 0, 0, 0.18), + 2px 5px 5px rgba(0, 0, 0, 0.5); + position: relative; + z-index: 1; +} + +// Rotated-card shadow corrections — `box-shadow` rotates w. the element's +// `transform`, so any rotated card's down-right shadow rotates to a wrong +// direction. Each rotation case needs its offsets pre-inverted so the +// post-rotation render still reads down-right. +// +// Derivation: CSS rotate(θ) CW maps offset (a, b) → screen (a·cos θ − +// b·sin θ, a·sin θ + b·cos θ). To get screen (1, 2) after rotation, solve +// for unrotated (a, b). Below shows the result for each rotation in play: +// • 180° (reversed, `.sea-card-slot--reversed` at _card-deck:1616): +// (1, 2) → (−1, −2). Flip all signs. +// • 90° (cross, `.sea-pos-cross .sea-card-slot` at _card-deck:1705): +// (1, 2) → (2, −1). Swap + negate y. +// • 270° (cross + reversed at _card-deck:1620): (1, 2) → (−2, 1). +// +// Specificity ladder: base filled (0,2,0) < reversed-only (0,3,0) = cross- +// only (0,3,0) < cross+reversed (0,4,0). For a card matching multiple, the +// most-specific rule wins; cross-only + reversed-only tie at (0,3,0) but +// only one matches per card (cross OR non-cross), so no source-order trap. +.my-sea-cross .sea-card-slot--filled.sea-card-slot--reversed { + box-shadow: + -1px -2px 0 rgba(0, 0, 0, 0.7), + 0 -4px 0 rgba(0, 0, 0, 0.18), + -2px -5px 5px rgba(0, 0, 0, 0.5); +} +.my-sea-cross .sea-pos-cross .sea-card-slot--filled { + box-shadow: + 2px -1px 0 rgba(0, 0, 0, 0.7), + 4px 0 0 rgba(0, 0, 0, 0.18), + 5px -2px 5px rgba(0, 0, 0, 0.5); +} +.my-sea-cross .sea-pos-cross .sea-card-slot--filled.sea-card-slot--reversed { + box-shadow: + -2px 1px 0 rgba(0, 0, 0, 0.7), + -4px 0 0 rgba(0, 0, 0, 0.18), + -5px 2px 5px rgba(0, 0, 0, 0.5); } // Cover + cross labels dim w. their slots — they sit on top of the // sig card so a vivid label would compete w. the sig at idle. Default -// 0.25 opacity matches the slot's faint dotted-outline at idle; the +// 0.5 opacity matches the slot's faint dotted-outline at idle; the // parent's :hover state (propagated up when the inside `.sea-card- -// slot:hover` fires per CSS hover-ancestor rules) boosts to the -// `.sea-pos-label` baseline 0.6, matching the slot's `--duoUser` mask -// reveal. +// slot:hover` fires per CSS hover-ancestor rules) boosts to full. .sea-pos-cover > .sea-pos-label, .sea-pos-cross > .sea-pos-label { opacity: 0.5; @@ -470,14 +553,6 @@ body.page-gameboard { opacity: 1; } -// Below bottom border — same `0.1rem` overlap but downward. -.sea-pos-lay > .sea-pos-label, -.sea-pos-cross > .sea-pos-label { - top: 100%; - left: 50%; - transform: translate(-50%, -0.1rem) scaleY(1.2); -} - // Left of left border, rotated 90° CCW — text reads bottom-to-top. // `writing-mode: vertical-rl` puts text top-to-bottom (CW); a 180° // rotation flips it to read bottom-to-top (CCW), satisfying the user- @@ -491,7 +566,11 @@ body.page-gameboard { right: 100%; top: 50%; writing-mode: vertical-rl; - transform: translate(0.1rem, -50%) rotate(180deg) scaleX(1.2); + // User spec 2026-05-26: parity w. CROWN/LAY's ~0.4rem breathing room. + // Was `0.1rem` overlap (tuck-under); now `-0.4rem` pulls the label + // further LEFT, AWAY from the slot's left edge so it reads cleanly w/o + // colliding into the slot's border-radius zone. + transform: translate(-0.3rem, -50%) rotate(180deg) scaleX(1.2); } // Right of right border, rotated 90° CW — text reads top-to-bottom. @@ -500,7 +579,8 @@ body.page-gameboard { left: 100%; top: 50%; writing-mode: vertical-rl; - transform: translate(-0.1rem, -50%) scaleX(1.2); + // User spec 2026-05-26: same `0.4rem` AWAY distance as LEAVE (mirrored). + transform: translate(0.3rem, -50%) scaleX(1.2); } // Section dividers inside the SPREAD combobox — labels "3-card spreads" @@ -808,10 +888,19 @@ body.page-gameboard { // PNG card-back is unobstructed. Filter-chain / contour-stroke / depth // shadow on `.sig-stage-card-img` still come from the shared rule (no // collision — different selector target). + // + // `overflow: visible` is critical — the base `.my-sea-slot` rule above + // sets `overflow: hidden` at (1,1,0) which BEATS the shared image-mode + // rule's `overflow: visible` at (0,2,0) on the ID axis. Without re- + // stating here, the fourfold contour-stroke drop-shadows get clipped + // by the slot bounding box → reads as a uniform rectangular frame + // around the image, defeating the "stroke follows alpha contour" + // illusion. See [[feedback-scss-id-context-specificity-trap]]. .my-sea-slot--filled.my-sea-slot--image { background: transparent; border: 0; padding: 0; + overflow: visible; } // Empty slot — matches the my_sea.html picker's empty `.sea-card- @@ -829,16 +918,21 @@ body.page-gameboard { border-width: 0.15rem !important; } - // Label — sibling of the slot inside the wrap, sits BELOW the slot - // box (mirrors the my_sea.html picker's `.sea-pos-label` placement). - // `margin-top: -0.15rem` crosses the slot's bottom border so the - // label's top edge sits flush against it; `position: relative; - // z-index: 2` keeps the label text rendering ATOP the slot's bottom - // border (dotted for empty slots, solid for filled). + // Label — sibling of the slot inside the wrap, sits BELOW the slot. + // User spec 2026-05-26: parity w. my_sea.html's `.sea-pos-lay` label + // treatment — label tucked just below the slot w. the filled card's + // downward shadow (added below to `.my-sea-slot--filled`) obscuring + // the label's top edge. Empty dashed slots stay shadowless so the + // label sits cleanly below them. .my-sea-slot-label { position: relative; - z-index: 2; - margin-top: -0.05rem; + // z-index 0 (was 2) — slot's shadow lives on top + casts over the + // label's top edge; the label is behind the slot in stacking order. + z-index: 0; + // Small gap (was `-0.05rem` flush margin) so the label doesn't + // glue to the slot's bottom border on the empty-slot case where + // there's no shadow to bridge the visual gap. + margin-top: 0.15rem; padding: 0 0.2rem; font-size: 0.65rem; font-weight: 600; @@ -852,6 +946,32 @@ body.page-gameboard { transform: scaleY(1.3); transform-origin: top center; } + + // Filled-card downward shadow — applet parity w. my_sea.html's + // `.my-sea-cross .sea-card-slot--filled` rule. Empty dashed slots + // stay shadowless (per user clarification 2026-05-26: only the cards + // that replace the slots should carry a shadow). + .my-sea-slot--filled { + box-shadow: + 1px 2px 0 rgba(0, 0, 0, 0.7), + 0 4px 0 rgba(0, 0, 0, 0.18), + 2px 5px 5px rgba(0, 0, 0, 0.5); + position: relative; + z-index: 1; + } + + // Reversed-card shadow inversion — parity w. my_sea.html's + // `.sea-card-slot--reversed` override below. The applet's `.my-sea- + // slot--reversed` (line 843) also adds `rotate(180deg)` which flips the + // shadow up-left; invert ALL offsets so post-rotation it reads down- + // right. See [[feedback-css-transform-rotates-box-shadow]] (or similar + // future-note) for the gotcha. + .my-sea-slot--filled.my-sea-slot--reversed { + box-shadow: + -1px -2px 0 rgba(0, 0, 0, 0.7), + 0 -4px 0 rgba(0, 0, 0, 0.18), + -2px -5px 5px rgba(0, 0, 0, 0.5); + } // `.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 diff --git a/src/static_src/scss/_sky.scss b/src/static_src/scss/_sky.scss index 4214892..ae091f9 100644 --- a/src/static_src/scss/_sky.scss +++ b/src/static_src/scss/_sky.scss @@ -503,9 +503,23 @@ html.sky-open .sky-modal-wrap { .nw-sign-group, .nw-house-group { cursor: pointer; } +// Planets get a base 1px×1px black drop-shadow so the colored circle +// reads as a depthed badge floating above the wheel rings — user spec +// 2026-05-25 PM. Chained w. the hover/active glow below (CSS `filter` +// replaces rather than appends, so the chain has to be re-stated on the +// hover rule to keep the shadow when the glow kicks in). +.nw-planet-group { + filter: drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.7)); +} + .nw-planet-group:hover, .nw-planet-group.nw-planet--active, -.nw-planet-group.nw-planet--asp-active, +.nw-planet-group.nw-planet--asp-active { + filter: + drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.7)) + drop-shadow(0 0 5px rgba(var(--priLm), 1)); +} + .nw-element-group:hover, .nw-element-group.nw-element--active, .nw-sign-group:hover, diff --git a/src/templates/apps/dashboard/_partials/_applet-wallet.html b/src/templates/apps/dashboard/_partials/_applet-wallet.html index a4df12d..66e0498 100644 --- a/src/templates/apps/dashboard/_partials/_applet-wallet.html +++ b/src/templates/apps/dashboard/_partials/_applet-wallet.html @@ -3,5 +3,111 @@ style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" >

My Wallet

- : {{ user.wallet.writs }} -
\ No newline at end of file + {% comment %} + `id="id_game_kit"` lets `gameboard.js`'s `initGameKitTooltips()` scope its + `.token` query here. `display: contents` (in `_game-kit.scss`) keeps this + wrapper layout-invisible so the section's own grid still applies. Trinkets + (PASS/BAND/CARTE/COIN) COPY the Game Kit applet's tooltip + DON/DOFF + wiring so the user can equip from either surface; Free + Tithe tokens MOVE + here from Game Kit (they aren't equippable). Writs uses the same `.token` + + `.tt` chrome so all four item types read as one consistent inventory row. + {% endcomment %} +
+ {% if pass_token %} +
+ +
+
+ {% if pass_token.pk == equipped_trinket_id %}{% else %}{% endif %} +
+

{{ pass_token.tooltip_name }}

+

{{ pass_token.tooltip_description }}

+ {% if pass_token.tooltip_shoptalk %}

{{ pass_token.tooltip_shoptalk }}

{% endif %} +

{{ pass_token.tooltip_expiry }}

+
+
+ {% endif %} + {% if band %} +
+ +
+
+ {% if band.pk == equipped_trinket_id %}{% else %}{% endif %} +
+

{{ band.tooltip_name }}

+

{{ band.tooltip_description }}

+ {% if band.tooltip_shoptalk %}

{{ band.tooltip_shoptalk }}

{% endif %} +

{{ band.tooltip_expiry }}

+
+
+ {% endif %} + {% if carte %} +
+ +
+
+ {% if carte.current_room %}{% elif carte.pk == equipped_trinket_id %}{% else %}{% endif %} +
+

{{ carte.tooltip_name }}

+

{{ carte.tooltip_description }}

+ {% if carte.tooltip_shoptalk %}

{{ carte.tooltip_shoptalk }}

{% endif %} +

{{ carte.tooltip_expiry }}

+
+
+ {% endif %} + {% if coin %} +
+ +
+
+ {% if coin.current_room %}{% elif coin.pk == equipped_trinket_id %}{% else %}{% endif %} +
+

{{ coin.tooltip_name }}

+

{{ coin.tooltip_description }}

+ {% if coin.tooltip_shoptalk %}

{{ coin.tooltip_shoptalk }}

{% endif %} +

{{ coin.tooltip_expiry }}

+
+
+ {% endif %} + {% if free_tokens %} + {% with free_tokens.0 as token %} +
+ + {% if free_count > 1 %}{% if free_count > 99 %}99+{% else %}{{ free_count }}{% endif %}{% endif %} +
+

{{ token.tooltip_name }}{% if free_count > 1 %} (×{% if free_count > 99 %}99+{% else %}{{ free_count }}{% endif %}){% endif %}

+

{{ token.tooltip_description }}

+ {% if token.tooltip_shoptalk %}

{{ token.tooltip_shoptalk }}

{% endif %} +

{{ token.tooltip_expiry }}

+
+
+ {% endwith %} + {% endif %} + {% if tithe_tokens %} + {% with tithe_tokens.0 as token %} +
+ + {% if tithe_count > 1 %}{% if tithe_count > 99 %}99+{% else %}{{ tithe_count }}{% endif %}{% endif %} +
+

{{ token.tooltip_name }}{% if tithe_count > 1 %} (×{% if tithe_count > 99 %}99+{% else %}{{ tithe_count }}{% endif %}){% endif %}

+

{{ token.tooltip_description }}

+

{{ token.tooltip_expiry }}

+
+
+ {% endwith %} + {% endif %} + {# Writs placeholder — currency tier (NOT a Token model row). Tooltip #} + {# mirrors the Token-style chrome so the row reads as one inventory. #} + {# Count lives in the badge (Writs count > 0); empty case still renders #} + {# the icon since Writs are the base balance unit. #} +
+ + {% if user.wallet.writs > 0 %}{% if user.wallet.writs > 99 %}99+{% else %}{{ user.wallet.writs }}{% endif %}{% endif %} +
+

Writs{% if user.wallet.writs > 0 %} (×{% if user.wallet.writs > 99 %}99+{% else %}{{ user.wallet.writs }}{% endif %}){% endif %}

+

Base currency unit

+

Earned at the gate; spent in the shop.

+
+
+
+ diff --git a/src/templates/apps/dashboard/_partials/_scripts.html b/src/templates/apps/dashboard/_partials/_scripts.html index b7e3f10..000e30f 100644 --- a/src/templates/apps/dashboard/_partials/_scripts.html +++ b/src/templates/apps/dashboard/_partials/_scripts.html @@ -1,6 +1,12 @@ {% load static %} +{# `gameboard.js` powers the hover-portal tooltip system for `.token` icons #} +{# inside `#id_game_kit`. The My Wallet applet uses this scaffold so its #} +{# trinkets + tokens + writs read with the same chrome as the gameboard's #} +{# Game Kit applet. `initGameKitTooltips()` no-ops if `#id_game_kit` isn't #} +{# present (e.g. the My Wallet applet hidden via toggle). #} + + {# gameboard.js for `initGameKitTooltips()` — the gk-decks section's #} + {# `#id_game_kit` wrapper + `.token deck-variant` icons hook into its #} + {# hover-portal + DON/DOFF wiring. game-kit.js still owns the carousel #} + {# (fan modal) — clicking the icon opens the fan via .gk-deck-card. #} + {% endblock scripts %} diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index b10ae65..8da1474 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -221,18 +221,22 @@ {# Critical: this collapse is my_sea-ONLY — room.html #} {# keeps the dual layout since multiple gamers contribute #} {# (each might bring a different polarization). #} + {# FLIP btn picks up `.btn-disabled` once the hand is #} + {# complete — visual signal that no more draws remain. #} + {# JS `_setComplete(on)` toggles the same class on #} + {# live transition (last-card landed). #} {% if request.user.equipped_deck.is_polarized %}
DECKS
- +
Gravity
- +
Levity
@@ -245,7 +249,7 @@ {% if request.user.equipped_deck.has_card_images %} {% endif %} - +
@@ -266,18 +270,16 @@ class="btn btn-primary" data-state="{% if hand_complete %}gate-view{% else %}auto-draw{% endif %}" data-gate-url="{% url 'my_sea_gate' %}">{% if hand_complete %}GATE
VIEW{% else %}AUTO
DRAW{% endif %} - {# DEL starts `.btn-disabled` until the hand is #} - {# complete — per Sprint-5-iter-4c spec, the 1/day #} - {# quota is committed at first-card-draw + can't be #} - {# refunded by an early DEL. Case-by-case `×` #} - {# rendering matches game-kit tooltip + DON/DOFF #} - {# convention (user-spec 2026-05-20): a disabled btn #} - {# carries × in its own text node, not via a CSS #} - {# overlay. JS `_setComplete` swaps inner text in #} - {# lockstep with the `.btn-disabled` class toggle. #} + {# DEL gates on whether ANY card has been drawn (user #} + {# spec 2026-05-26): pre-first-draw it's disabled; as #} + {# soon as `saved_by_position` has at least one entry #} + {# the user can DEL the in-progress hand. `×` #} + {# is the disabled-state label (parity w. game-kit's #} + {# DON/DOFF disabled convention). JS `_setHasDrawn` #} + {# swaps text + class in lockstep w. live deposits. #} + class="btn btn-danger{% if not saved_by_position %} btn-disabled{% endif %}">{% if saved_by_position %}DEL{% else %}×{% endif %} {# Sea stage — portaled modal that opens on FLIP click via #} @@ -464,12 +466,14 @@ function _resetHand() { // Spread-switch reset only (mid-draw guard inside sync()). - // Iter 4c removed the explicit DEL-resets-hand pathway: - // pre-completion DEL is `.btn-disabled`; post-completion - // DEL routes to the guard portal → server-side delete. + // Post-DEL routes through the guard portal → server-side + // delete + page reload, so this path doesn't fire there. + // Re-disable DEL + re-enable FLIP for the fresh-empty state. _filled = 0; _hideOk(); _unlockSpread(); + _setHasDrawn(false); + _setComplete(false); cross.querySelectorAll( '.sea-crucifix-cell.sea-pos-crown, ' + '.sea-crucifix-cell.sea-pos-leave, ' + @@ -484,24 +488,24 @@ } function _setComplete(on) { - // Iter 4c — single-call state transition for "hand - // complete": DEL un-disables, action btn becomes GATE - // VIEW, FLIP buttons on the deck stacks render as - // disabled when shown. The deck STACKS themselves stay - // click-responsive (so the user can see the disabled - // FLIP feedback) — `_locked` gates the actual draw. - // - // DEL btn text follows the game-kit tooltip convention - // (user-spec 2026-05-20): disabled state reads × (the - // template's initial render does the same), active - // state reads DEL. Lockstep w. the `.btn-disabled` - // class so the visual + label always agree. + // State transition for "hand complete": deck-stack FLIP + // buttons render as disabled (visual signal that no more + // draws remain), action btn becomes GATE VIEW. The deck + // STACKS themselves stay click-responsive so the user + // sees the disabled-FLIP feedback — `_locked` gates the + // actual draw at the click-handler level. DEL state was + // here previously but is now driven by `_setHasDrawn` + // instead (user spec 2026-05-26: DEL unlocks at first + // draw, NOT at completion). _locked = on; picker.classList.toggle('my-sea-picker--locked', on); - if (delBtn) { - delBtn.classList.toggle('btn-disabled', !on); - delBtn.innerHTML = on ? 'DEL' : '×'; - } + picker.querySelectorAll('.sea-deck-stack .sea-stack-ok').forEach(function (btn) { + btn.classList.toggle('btn-disabled', on); + // Mirrors DEL btn convention: disabled label is × (the + // template's initial render does the same), active is + // 'FLIP'. Keeps visual + class in lockstep. + btn.innerHTML = on ? '×' : 'FLIP'; + }); if (actionBtn) { actionBtn.dataset.state = on ? 'gate-view' : 'auto-draw'; actionBtn.innerHTML = on ? 'GATE
VIEW' : 'AUTO
DRAW'; @@ -509,6 +513,19 @@ _hideOk(); } + function _setHasDrawn(on) { + // DEL btn state — un-disables as soon as ANY card has + // been drawn (user spec 2026-05-26). Pre-first-draw the + // user can't DEL an empty hand; once a card is down, + // DEL routes to the guard portal → server-side delete. + // Disabled-state label `×` mirrors the game-kit DON/DOFF + // convention. + if (delBtn) { + delBtn.classList.toggle('btn-disabled', !on); + delBtn.innerHTML = on ? 'DEL' : '×'; + } + } + // ── Deck-stack click → show FLIP → click FLIP → deposit ─ // Iter 4c — stacks remain click-responsive even after hand // is complete (so the user sees the disabled-FLIP feedback, @@ -539,7 +556,10 @@ _fillSlot(posName, card, isLevity); } _filled++; - if (_filled === 1) _lockSpread(); + if (_filled === 1) { + _lockSpread(); + _setHasDrawn(true); // first card → DEL enables + } // Per-placement upsert — server stays current // so a navigate-away mid-draw still persists // the partial hand. User-spec 2026-05-20. @@ -626,7 +646,16 @@ _setComplete(true); return; } + // First-draw transitions — mirror the manual-flip flow. + // Lock spread + un-disable DEL as soon as the POST + // commits (we KNOW ≥1 card is now server-saved). Was a + // bug: AUTO DRAW only synced DEL state at completion + // via `_setComplete(true)`, so a mid-animation refresh + // (or backing out during the staggered placeNext loop) + // would leave DEL stuck disabled even though cards were + // already saved (user-reported 2026-05-26). if (_filled === 0) _lockSpread(); + _setHasDrawn(true); var idx = 0; function placeNext() { if (idx >= autoEntries.length) { @@ -840,8 +869,10 @@ if (_filled >= _currentOrder().length) { _setComplete(true); _lockSpread(); + _setHasDrawn(true); // also enables DEL post-completion } else if (_filled > 0) { _lockSpread(); + _setHasDrawn(true); // any partial draw → DEL enabled } // Seed SeaDeal's `_seaHand` from the server-rendered