diff --git a/src/apps/epic/static/apps/epic/sea.js b/src/apps/epic/static/apps/epic/sea.js index a332d0f..75e59b4 100644 --- a/src/apps/epic/static/apps/epic/sea.js +++ b/src/apps/epic/static/apps/epic/sea.js @@ -38,6 +38,7 @@ var SeaDeal = (function () { uprightSel: '#id_sea_stat_upright', reversedSel: '#id_sea_stat_reversed', }); + StageCard.populateStatExtras(statBlock, card); _infoData = StageCard.buildInfoData(card); _infoIdx = 0; diff --git a/src/apps/epic/static/apps/epic/stage-card.js b/src/apps/epic/static/apps/epic/stage-card.js index e0052f2..823cf44 100644 --- a/src/apps/epic/static/apps/epic/stage-card.js +++ b/src/apps/epic/static/apps/epic/stage-card.js @@ -266,6 +266,40 @@ var StageCard = (function () { if (rl) rl.innerHTML = (reversedItems || []).map(function (k) { return '
  • ' + k + '
  • '; }).join(''); } + // Sprint A.7-polish-3 — fill the title + arcana fields per [[project- + // image-based-deck-face-rendering]]'s locked Q3 spec: each stat face + // gets a title (card name minus any comma+qualifier for Earthman-style + // major arcana) + an arcana type label ("Major Arcana" / "Minor Arcana"). + // For text-mode decks the same info is on the card face too — duplicates, + // not regressive. For image-mode decks the stat block is the only home + // for textual metadata. Caller-side opt-out by passing opts.skipExtras=true. + function populateStatExtras(statBlock, card, opts) { + if (!statBlock) return; + opts = opts || {}; + if (opts.skipExtras) return; + // Strip any "Title, Qualifier" Earthman pattern → just "Title". + var rawTitle = card.name_title || card.name || ''; + var title = rawTitle.split(',')[0].trim(); + var arcana = _arcanaDisplay(card); + statBlock.querySelectorAll('.stat-face-title').forEach(function (el) { + el.textContent = title; + }); + statBlock.querySelectorAll('.stat-face-arcana').forEach(function (el) { + el.textContent = arcana; + }); + // Surface arcana on the stat-block parent so SCSS `[data-arcana-key]` + // selectors can color-key the title (--quiUser for minor/middle, + // --terUser for major) — mirrors the same hook used on the card. + if (card.arcana_key) { + statBlock.setAttribute('data-arcana-key', card.arcana_key); + } else if (card.arcana) { + // Fallback: card.arcana may be "MAJOR" (model code) or "Major + // Arcana" (display) — uppercase + take first word for the key. + statBlock.setAttribute('data-arcana-key', + (card.arcana || '').toUpperCase().split(' ')[0]); + } + } + // Concatenate energies + operations into a single FYI nav array. function buildInfoData(card) { var data = (card.energies || []).map(function (e) { @@ -305,6 +339,7 @@ var StageCard = (function () { fromDataset: fromDataset, populateCard: populateCard, populateKeywords: populateKeywords, + populateStatExtras: populateStatExtras, buildInfoData: buildInfoData, renderFyi: renderFyi, }; diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 38092a1..b88f353 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -1559,8 +1559,8 @@ class MySeaLockHandViewTest(TestCase): self.assertEqual(len(rows.first().hand), 3) def test_lock_post_spread_mismatch_within_quota_returns_409(self): - # Spread is committed at first-card moment; switching to a - # different spread mid-quota-window is rejected. + # Spread is committed at first-card moment for the duration of an + # ACTIVE non-empty draw. Switching spread mid-non-empty-draw → 409. import json from apps.epic.models import TarotCard cards = list(TarotCard.objects.exclude(id=self.target.id)[:3]) @@ -1580,6 +1580,52 @@ class MySeaLockHandViewTest(TestCase): ) self.assertEqual(response.status_code, 409) + def test_lock_post_spread_switch_after_del_succeeds(self): + """Sprint A.7-polish-3 — once the user DELs (clears hand to []), the + spread lock lifts: a subsequent POST with a different spread + UPDATES the existing row's spread + populates the new hand. The 24h + quota window (created_at + paid_through_at) is preserved — only the + spread field changes. Fixes the AUTO-DRAW-only-works-on-SAO user bug + report (2026-05-25 PM): users couldn't switch spreads even after + DELing because the row's spread stayed locked for the full 24h.""" + import json + from apps.epic.models import TarotCard + from apps.gameboard.models import MySeaDraw + cards = list(TarotCard.objects.exclude(id=self.target.id)[:3]) + # First draw under SAO — creates row w. spread=SAO + 1 card. + self.client.post( + self.url, data=json.dumps({ + "spread": "situation-action-outcome", + "hand": [{"position": "lay", "card_id": cards[0].id, + "reversed": False, "polarity": "gravity"}], + }), + content_type="application/json", + ) + row = MySeaDraw.objects.get(user=self.user) + self.assertEqual(row.spread, "situation-action-outcome") + original_created_at = row.created_at + # DEL clears hand to [] but preserves the row. + self.client.post(reverse("my_sea_delete")) + row.refresh_from_db() + self.assertEqual(row.hand, []) + self.assertEqual(row.spread, "situation-action-outcome") + # Now POST a draw under a DIFFERENT spread. Should succeed + + # update the row's spread. + response = self.client.post( + self.url, data=json.dumps({ + "spread": "waite-smith", + "hand": [{"position": "crown", "card_id": cards[1].id, + "reversed": False, "polarity": "levity"}], + }), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + row.refresh_from_db() + self.assertEqual(row.spread, "waite-smith") + self.assertEqual(len(row.hand), 1) + # The 24h quota window is preserved — created_at unchanged. + self.assertEqual(row.created_at, original_created_at) + def test_lock_post_returns_hand_complete_flag(self): # Body includes `hand_complete` so the JS can decide whether to # transition the picker into post-completion state (DEL enable, diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 471cd82..bc96e44 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -367,9 +367,17 @@ def my_sea_lock(request): if existing is not None: # Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route # through the gatekeeper but the endpoint stays permissive here). - # Spread-switch attempts get 409 — the spread is committed at - # first-card moment. - if existing.spread != spread: + # Spread-switch policy (refined 2026-05-25 PM per user bug report — + # AUTO DRAW failing on non-SAO spreads with 409): the spread is + # committed at first-card moment for the duration of the active + # NON-EMPTY draw. Once the user DELs (clears hand to []) the row + # stays alive for its 24h quota window but the spread lock lifts — + # the user can pick a fresh spread for the next draw without losing + # the cooldown clock. Mid-non-empty-draw spread switches still get + # 409 to prevent the "sneaky POST" of a different spread without an + # explicit DEL gesture. + spread_changed = existing.spread != spread + if spread_changed and existing.hand: return JsonResponse({"error": "spread_mismatch"}, status=409) # If this row carried a paid-through credit (set by `my_sea_paid_ # draw` at commit time) AND we're transitioning empty→non-empty, @@ -379,6 +387,9 @@ def my_sea_lock(request): was_empty = not existing.hand existing.hand = hand update_fields = ["hand"] + if spread_changed: + existing.spread = spread + update_fields.append("spread") if (was_empty and hand and existing.paid_through_at is not None): existing.paid_through_at = None diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index a47b1f2..1826ec4 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -626,8 +626,34 @@ body.page-billposts { opacity: 0.7; color: rgba(var(--terUser), 1); margin: 0 0 calc(var(--applet-card-w) * 0.06); + text-decoration: underline; + text-underline-offset: 0.15em; } + // Sprint A.7-polish-3 — title + arcana in applet stat-block per + // user spec 2026-05-25 PM. Title color keys off the parent's + // `data-arcana-key` (rendered server-side from `card.arcana`). + .stat-face-title { + font-size: calc(var(--applet-card-w) * 0.11); + font-weight: 700; + line-height: 1.15; + margin: 0 0 calc(var(--applet-card-w) * 0.03); + text-wrap: balance; + color: rgba(var(--quiUser), 1); + } + &[data-arcana-key="MAJOR"] .stat-face-title { + color: rgba(var(--terUser), 1); + } + .stat-face-arcana { + font-size: calc(var(--applet-card-w) * 0.075); + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.6; + margin: 0 0 calc(var(--applet-card-w) * 0.06); + } + .stat-face-title:empty, + .stat-face-arcana:empty { display: none; } + .stat-keywords { list-style: none; padding: 0; diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index d15ebf8..83fae56 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -73,8 +73,47 @@ opacity: 0.7; color: rgba(var(--terUser), 1); margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07); + // Sprint A.7-polish-3 — underline per user spec 2026-05-25 PM + // (the original A.3 Q3 lock referred to underlined Emanation / + // Reversal headers in the image-mode stat block; same spec re- + // applied universally here so non-image-mode stat blocks get the + // same visual treatment). + text-decoration: underline; + text-underline-offset: 0.15em; } + // Sprint A.7-polish-3 — title + arcana fields per locked Q3 spec. + // Title color keys off the stat-block's `data-arcana-key` attr (set by + // stage-card.js populateStatExtras OR server-side in the applet partial): + // - MAJOR → --terUser (gold) + // - MINOR / MIDDLE → --quiUser (cream) + // Matches the contour-stroke color on the image-mode card so card + + // title read as a coordinated pair. + .stat-face-title { + font-size: calc(var(--sig-card-w, 120px) * 0.105); + font-weight: 700; + line-height: 1.15; + margin: 0 0 calc(var(--sig-card-w, 120px) * 0.03); + text-wrap: balance; + color: rgba(var(--quiUser), 1); + } + [data-arcana-key="MAJOR"] .stat-face-title { + color: rgba(var(--terUser), 1); + } + + .stat-face-arcana { + font-size: calc(var(--sig-card-w, 120px) * 0.063); + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.6; + margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07); + } + // `:empty` rule hides title + arcana when stage-card.js hasn't populated + // them yet (rest state) — prevents zero-height paragraphs from inflating + // the stat block vertical layout. + .stat-face-title:empty, + .stat-face-arcana:empty { display: none; } + .stat-keywords { list-style: none; padding: 0; diff --git a/src/templates/apps/billboard/_partials/_applet-my-sign.html b/src/templates/apps/billboard/_partials/_applet-my-sign.html index fd36022..7dc8d6f 100644 --- a/src/templates/apps/billboard/_partials/_applet-my-sign.html +++ b/src/templates/apps/billboard/_partials/_applet-my-sign.html @@ -73,8 +73,10 @@ {# since the applet is a read-only preview. Saved sigs persist #} {# only the polarity axis (FLIP), never the orientation axis #} {# (SPIN), so always render the upright/emanation face. #} -
    +

    Emanation

    +

    {{ card.name }}

    +

    {{ card.get_arcana_display }}