From 7c6ab39635410d5026dedad01e45d2069a616310 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 25 May 2026 02:43:00 -0400 Subject: [PATCH] =?UTF-8?q?A.7-polish-3=20stat-block=20title=20+=20arcana?= =?UTF-8?q?=20fields=20across=203=20surfaces=20+=20spread-switch=20unlock?= =?UTF-8?q?=20after=20DEL=20=E2=80=94=20TDD.=20End-of-session=202026-05-25?= =?UTF-8?q?=20PM.=20Two=20changes=20bundled=20(both=20user-requested=20as?= =?UTF-8?q?=20round-out=20work=20for=20tonight's=20final=20push):?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Stat-block restructure per [[project-image-based-deck-face-rendering]]'s locked Q3 spec. User noticed during browser verify that image-mode card surfaces (Minchiate-equipped on my_sign main stage + My Sign applet + Sea Stage modal in my_sea) show only the EMANATION label in the stat-block — no title, no arcana type, no keywords (Minchiate keywords are empty per A.1 seed). For text-mode decks (Earthman, RWS) the title + arcana render ON the card; for image-mode the card is just an image so all textual metadata MUST move to the stat-block. Spec was originally written for image-mode only but user explicitly asked for it universally so non-image cards also get the info in the stat-block (visible duplicates w. card content — acceptable tradeoff per user). Implementation: added `

` + `

` to both upright + reversed `.stat-face` blocks in `my_sign.html` (line ~58) + `_sea_stage.html` (the modal). For `_applet-my-sign.html` (no SPIN btn, no reversed face), rendered server-side from `card.name` + `card.get_arcana_display` + the new `data-arcana-key="{{ card.arcana }}"` attr on the stat-block wrapper. New JS helper `StageCard.populateStatExtras(statBlock, card, opts)` in `stage-card.js` parallels the existing `populateKeywords` — fills `.stat-face-title` (card name minus any "Title, Qualifier" Earthman pattern stripping) + `.stat-face-arcana` (`_arcanaDisplay(card)` reused) + sets `data-arcana-key` on the stat-block parent for SCSS color-keying. Exported alongside populateKeywords; called from `my_sign.html` inline + `sea.js`'s `_populate` (the 2 dynamic stat-block sites). `sig-select.js` (room sig select) intentionally NOT updated — that's A.8 territory + its stat block markup differs. SCSS: extended `stat-block-shared` mixin in `_card-deck.scss` w. `.stat-face-title` (font-weight 700, color --quiUser default; `[data-arcana-key="MAJOR"]` selector flips to --terUser matching the contour-stroke arcana-color convention) + `.stat-face-arcana` (uppercase letter-spaced like `.stat-face-label`). Same rules duplicated in `_billboard.scss` `.my-sign-applet-stat-block` block (different sizing via `--applet-card-w` container query). `:empty` rule hides both title + arcana when JS hasn't populated yet (rest state — prevents zero-height paragraphs inflating the stat block). Also added user-spec'd underline to `.stat-face-label` (text-decoration: underline + 0.15em offset). 2. Spread-switch policy unlock after DEL. User-reported AUTO DRAW failure ("only works with default SOA spread, others give visual click feedback but no cards") + spread-switch failure ("won't persist with a partial draw either, only SAO will"). Investigated: root cause is `views.my_sea_lock` line 372 returning `409 spread_mismatch` whenever the POST's spread != the existing `MySeaDraw` row's spread. The row spread is committed at first-card moment and `active_draw_for` returns rows for 24h regardless of hand state (even empty post-DEL rows still hold the spread lock). Combobox switches visually but every subsequent POST 409s. Refined policy: spread is locked only during an ACTIVE non-empty draw. Once the user DELs (clears hand to []), the spread lock lifts — a POST w. a different spread UPDATES the existing row's spread + populates the new hand. The 24h quota window (created_at + paid_through_at) is preserved so the cooldown clock stays put. Sneaky-POST mitigation is still in effect for mid-non-empty-draw spread switches (those still 409). Server-side: 4-line change in `views.my_sea_lock` — `spread_changed = existing.spread != spread`; `if spread_changed and existing.hand: return 409` (preserves prior behavior for non-empty hands); `if spread_changed: existing.spread = spread; update_fields.append("spread")` in the update block. New IT `MySeaLockHandViewTest.test_lock_post_spread_switch_after_del_succeeds` exercises the full flow: first POST creates row w. spread=SAO + 1 card; DEL clears hand; second POST w. spread=waite-smith + different card → 200 + row.spread is now "waite-smith" + row.created_at unchanged. Existing `test_lock_post_spread_mismatch_within_quota_returns_409` test docstring updated to clarify the new policy ("for the duration of an ACTIVE non-empty draw"); the 409 assertion still holds for its specific scenario (mid-non-empty-draw switch). Tests: 1 new IT green (lock-view spread-switch-after-DEL); 14/14 MySeaLockHandViewTest class green; 1307/1307 IT+UT total green (74s; +1 from 26cdf0d's 1306). Memory: `project_image_based_deck_face_rendering.md` has the detailed AUTO DRAW root-cause writeup; tomorrow's A.8 work is the only remaining image-rendering surface Co-Authored-By: Claude Opus 4.7 (1M context) --- src/apps/epic/static/apps/epic/sea.js | 1 + src/apps/epic/static/apps/epic/stage-card.js | 35 +++++++++++++ .../gameboard/tests/integrated/test_views.py | 50 ++++++++++++++++++- src/apps/gameboard/views.py | 17 +++++-- src/static_src/scss/_billboard.scss | 26 ++++++++++ src/static_src/scss/_card-deck.scss | 39 +++++++++++++++ .../billboard/_partials/_applet-my-sign.html | 4 +- src/templates/apps/billboard/my_sign.html | 12 +++++ .../apps/gameboard/_partials/_sea_stage.html | 4 ++ 9 files changed, 182 insertions(+), 6 deletions(-) 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 }}

      {% for kw in card.keywords_upright %}
    • {{ kw }}
    • diff --git a/src/templates/apps/billboard/my_sign.html b/src/templates/apps/billboard/my_sign.html index 19e7bd8..c99f4cb 100644 --- a/src/templates/apps/billboard/my_sign.html +++ b/src/templates/apps/billboard/my_sign.html @@ -76,12 +76,23 @@
      + {# Sprint A.7-polish-3 — stat-block info per [[project-image-based- #} + {# deck-face-rendering]]'s locked Q3 spec: underlined Emanation / #} + {# Reversal label + title + arcana + keywords. Title color keyed #} + {# off `data-arcana-key` on the stage card via parent selector; #} + {# `.stat-face-title` + `.stat-face-arcana` empty by default, #} + {# populated by stage-card.js `populateStatBlock` from the card #} + {# data flow on focus / save. #}

      Emanation

      +

      +

        Reversal

        +

        +

          {% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_my_sign_fyi_panel" %} @@ -219,6 +230,7 @@ StageCard.populateCard(stageCard, _currentCard, _polarity()); StageCard.populateKeywords(statBlock, _currentCard.keywords_upright, _currentCard.keywords_reversed); + StageCard.populateStatExtras(statBlock, _currentCard); _fyiData = StageCard.buildInfoData(_currentCard); _fyiIdx = 0; if (fyiPanel) StageCard.renderFyi(fyiPanel, _fyiData, _fyiIdx); diff --git a/src/templates/apps/gameboard/_partials/_sea_stage.html b/src/templates/apps/gameboard/_partials/_sea_stage.html index bdd161b..0f6fb68 100644 --- a/src/templates/apps/gameboard/_partials/_sea_stage.html +++ b/src/templates/apps/gameboard/_partials/_sea_stage.html @@ -47,10 +47,14 @@

          Emanation

          +

          +

            Reversal

            +

            +

              {% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}