A.7-polish-3 stat-block title + arcana fields across 3 surfaces + spread-switch unlock after DEL — TDD. End-of-session 2026-05-25 PM. Two changes bundled (both user-requested as round-out work for tonight's final push):

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 `<p class="stat-face-title">` + `<p class="stat-face-arcana">` 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) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-25 02:43:00 -04:00
parent 26cdf0d38b
commit 7c6ab39635
9 changed files with 182 additions and 6 deletions

View File

@@ -38,6 +38,7 @@ var SeaDeal = (function () {
uprightSel: '#id_sea_stat_upright', uprightSel: '#id_sea_stat_upright',
reversedSel: '#id_sea_stat_reversed', reversedSel: '#id_sea_stat_reversed',
}); });
StageCard.populateStatExtras(statBlock, card);
_infoData = StageCard.buildInfoData(card); _infoData = StageCard.buildInfoData(card);
_infoIdx = 0; _infoIdx = 0;

View File

@@ -266,6 +266,40 @@ var StageCard = (function () {
if (rl) rl.innerHTML = (reversedItems || []).map(function (k) { return '<li>' + k + '</li>'; }).join(''); if (rl) rl.innerHTML = (reversedItems || []).map(function (k) { return '<li>' + k + '</li>'; }).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. // Concatenate energies + operations into a single FYI nav array.
function buildInfoData(card) { function buildInfoData(card) {
var data = (card.energies || []).map(function (e) { var data = (card.energies || []).map(function (e) {
@@ -305,6 +339,7 @@ var StageCard = (function () {
fromDataset: fromDataset, fromDataset: fromDataset,
populateCard: populateCard, populateCard: populateCard,
populateKeywords: populateKeywords, populateKeywords: populateKeywords,
populateStatExtras: populateStatExtras,
buildInfoData: buildInfoData, buildInfoData: buildInfoData,
renderFyi: renderFyi, renderFyi: renderFyi,
}; };

View File

@@ -1559,8 +1559,8 @@ class MySeaLockHandViewTest(TestCase):
self.assertEqual(len(rows.first().hand), 3) self.assertEqual(len(rows.first().hand), 3)
def test_lock_post_spread_mismatch_within_quota_returns_409(self): def test_lock_post_spread_mismatch_within_quota_returns_409(self):
# Spread is committed at first-card moment; switching to a # Spread is committed at first-card moment for the duration of an
# different spread mid-quota-window is rejected. # ACTIVE non-empty draw. Switching spread mid-non-empty-draw → 409.
import json import json
from apps.epic.models import TarotCard from apps.epic.models import TarotCard
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3]) cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
@@ -1580,6 +1580,52 @@ class MySeaLockHandViewTest(TestCase):
) )
self.assertEqual(response.status_code, 409) 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): def test_lock_post_returns_hand_complete_flag(self):
# Body includes `hand_complete` so the JS can decide whether to # Body includes `hand_complete` so the JS can decide whether to
# transition the picker into post-completion state (DEL enable, # transition the picker into post-completion state (DEL enable,

View File

@@ -367,9 +367,17 @@ def my_sea_lock(request):
if existing is not None: if existing is not None:
# Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route # Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route
# through the gatekeeper but the endpoint stays permissive here). # through the gatekeeper but the endpoint stays permissive here).
# Spread-switch attempts get 409 — the spread is committed at # Spread-switch policy (refined 2026-05-25 PM per user bug report —
# first-card moment. # AUTO DRAW failing on non-SAO spreads with 409): the spread is
if existing.spread != spread: # 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) return JsonResponse({"error": "spread_mismatch"}, status=409)
# If this row carried a paid-through credit (set by `my_sea_paid_ # If this row carried a paid-through credit (set by `my_sea_paid_
# draw` at commit time) AND we're transitioning empty→non-empty, # 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 was_empty = not existing.hand
existing.hand = hand existing.hand = hand
update_fields = ["hand"] update_fields = ["hand"]
if spread_changed:
existing.spread = spread
update_fields.append("spread")
if (was_empty and hand if (was_empty and hand
and existing.paid_through_at is not None): and existing.paid_through_at is not None):
existing.paid_through_at = None existing.paid_through_at = None

View File

@@ -626,8 +626,34 @@ body.page-billposts {
opacity: 0.7; opacity: 0.7;
color: rgba(var(--terUser), 1); color: rgba(var(--terUser), 1);
margin: 0 0 calc(var(--applet-card-w) * 0.06); 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 { .stat-keywords {
list-style: none; list-style: none;
padding: 0; padding: 0;

View File

@@ -73,8 +73,47 @@
opacity: 0.7; opacity: 0.7;
color: rgba(var(--terUser), 1); color: rgba(var(--terUser), 1);
margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07); 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 { .stat-keywords {
list-style: none; list-style: none;
padding: 0; padding: 0;

View File

@@ -73,8 +73,10 @@
{# since the applet is a read-only preview. Saved sigs persist #} {# since the applet is a read-only preview. Saved sigs persist #}
{# only the polarity axis (FLIP), never the orientation axis #} {# only the polarity axis (FLIP), never the orientation axis #}
{# (SPIN), so always render the upright/emanation face. #} {# (SPIN), so always render the upright/emanation face. #}
<div class="my-sign-applet-stat-block"> <div class="my-sign-applet-stat-block" data-arcana-key="{{ card.arcana }}">
<p class="stat-face-label">Emanation</p> <p class="stat-face-label">Emanation</p>
<p class="stat-face-title">{{ card.name }}</p>
<p class="stat-face-arcana">{{ card.get_arcana_display }}</p>
<ul class="stat-keywords"> <ul class="stat-keywords">
{% for kw in card.keywords_upright %} {% for kw in card.keywords_upright %}
<li>{{ kw }}</li> <li>{{ kw }}</li>

View File

@@ -76,12 +76,23 @@
<div class="sig-stat-block"> <div class="sig-stat-block">
<button class="btn btn-reverse spin-btn" type="button">SPIN</button> <button class="btn btn-reverse spin-btn" type="button">SPIN</button>
<button class="btn btn-info fyi-btn" type="button">FYI</button> <button class="btn btn-info fyi-btn" type="button">FYI</button>
{# 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. #}
<div class="stat-face stat-face--upright"> <div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p> <p class="stat-face-label">Emanation</p>
<p class="stat-face-title"></p>
<p class="stat-face-arcana"></p>
<ul class="stat-keywords"></ul> <ul class="stat-keywords"></ul>
</div> </div>
<div class="stat-face stat-face--reversed"> <div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p> <p class="stat-face-label">Reversal</p>
<p class="stat-face-title"></p>
<p class="stat-face-arcana"></p>
<ul class="stat-keywords"></ul> <ul class="stat-keywords"></ul>
</div> </div>
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_my_sign_fyi_panel" %} {% 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.populateCard(stageCard, _currentCard, _polarity());
StageCard.populateKeywords(statBlock, StageCard.populateKeywords(statBlock,
_currentCard.keywords_upright, _currentCard.keywords_reversed); _currentCard.keywords_upright, _currentCard.keywords_reversed);
StageCard.populateStatExtras(statBlock, _currentCard);
_fyiData = StageCard.buildInfoData(_currentCard); _fyiData = StageCard.buildInfoData(_currentCard);
_fyiIdx = 0; _fyiIdx = 0;
if (fyiPanel) StageCard.renderFyi(fyiPanel, _fyiData, _fyiIdx); if (fyiPanel) StageCard.renderFyi(fyiPanel, _fyiData, _fyiIdx);

View File

@@ -47,10 +47,14 @@
<button class="btn btn-info fyi-btn" type="button">FYI</button> <button class="btn btn-info fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright"> <div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p> <p class="stat-face-label">Emanation</p>
<p class="stat-face-title"></p>
<p class="stat-face-arcana"></p>
<ul class="stat-keywords" id="id_sea_stat_upright"></ul> <ul class="stat-keywords" id="id_sea_stat_upright"></ul>
</div> </div>
<div class="stat-face stat-face--reversed"> <div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p> <p class="stat-face-label">Reversal</p>
<p class="stat-face-title"></p>
<p class="stat-face-arcana"></p>
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul> <ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
</div> </div>
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %} {% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}