Block a user
_seed_earthman_sig_pile missing is_polarized=True + has_card_images=False defaults (13 errors) + stale id_kit_fiorentine_deck selector → id_kit_tarot_deck (1 error). CI pipeline #334 test-FTs-room reported 1 FAIL + 13 errors after retry on a clean local-green branch; user asked for diagnosis. Two unrelated root causes — both pure FT-helper / FT-selector bugs surfaced by today's earlier landings (15025b4 my_sea single-stack collapse + f107522 RWS rename). No app code touched.
_billboard.scss's .my-sign-applet-stat-block block: (1) Minor/middle .stat-face-title color shifts from --quiUser to --quaUser — the previous --quiUser tint blended too closely w. the keywords text in the applet at applet-card-w sizing; --quaUser provides better contrast against the dimmer surrounding body. (2) The gravity-polarity stat-block inversion now reads --priUser bg + --secUser-tinted keywords/border (was --secUser bg + --priUser keywords) — the prior assignment had the stat-block more saturated than the adjacent card, drawing the eye away from the card art; flipping the assignment lets the card stay the visual anchor while the stat-block recedes into a calmer companion surface. .stat-face-label color stays --quiUser since it's the always-on identity marker and reads correctly against both bg variants. Pure SCSS — no template / view / JS / test changes
15025b4): drawing a card manually opens the Sea Stage modal but the card area is blank instead of showing the v2-convention card image (or the old text fallback). Root cause: stage-card.js's _setImageMode (added in A.3) correctly adds the .sig-stage-card--image class to the modal's .sig-stage-card.sea-stage-card when the drawn card has a non-empty image_url payload (now flowing through from A.7-polish's card_dict update), and the shared SCSS rule (_card-deck.scss .sig-stage-card.sig-stage-card--image) correctly hides the text scaffold children (.fan-card-corner / .fan-card-face) via display:none. But — the modal's HTML scaffold in _sea_stage.html was missing the <img class="sig-stage-card-img"> slot that the JS expects to populate. _setImageMode queries stageCard.querySelector('.sig-stage-card-img'), gets null, and silently falls through — leaving the modal w. the text scaffold hidden + no img element to show. Net: blank card. Fix: add the same hidden <img class="sig-stage-card-img" alt="" style="display:none"> slot to _sea_stage.html that already lives in my_sign.html's stage card scaffold (the contract the stage-card.js module assumes). Matches the my_sign pattern: inline style="display:none" is cleared by JS via img.style.display = '' when image mode activates (vs. the my_sign template back-img which uses pure CSS-toggled visibility — different contract since the back-img is server-rendered conditionally). No SCSS / JS changes — the shared image-mode SCSS rule already covers the modal's .sig-stage-card.sig-stage-card--image selector (lifted to top-level in A.5 commit 82813e9 specifically so non-.sig-stage-nested cards like the my_sea central sig + Sea Stage modal both work). Also memory-updated: noted the pre-existing AUTO DRAW bug (only works on default SAO spread; other 5 spreads silently fail). Bug pre-dates the image-rendering sprint per user — likely a hardcoded position-list or pile-slice assumption in sea.js's auto-draw handler. Not blocking A.8; flagged in [[project-image-based-deck-face-rendering]] follow-ups. Tests: 1306/1306 IT+UT total green (74s, unchanged — pure template scaffold extension, no test surface). Visual verify: refresh /gameboard/my-sea/ + draw a Minchiate card manually → modal should now show the actual Minchiate card image w. contour stroke + depth shadow, NOT the previous blank state
_my_sea_slot.html server-rendered partial fired when a draw is resumed via refresh); (2) mid-draw slot fill (sea.js's _fillSlot writes slot.innerHTML on each card-deposit click; previously rendered corner-rank + suit-icon only, NOW renders <img> when card.image_url is non-empty); (3) deck-stack collapse for non-polarized decks (Minchiate today) — the bottom-right of my_sea.html showed two side-by-side GRAVITY + LEVITY stacks regardless of equipped-deck polarization; for non-polarized decks polarity has no meaning so the dual layout misleads. **Critical lock**: collapse is my_sea-ONLY. room.html keeps the dual stacks since multiple gamers contribute (each might bring a different polarization). Server-side template branches {% if request.user.equipped_deck.is_polarized %} to pick dual vs. single rendering; the --single stack carries the actual deck back-image via <img class="sea-stack-face-img"> (object-fit: cover) when has_card_images=True. Sub-changes: card_dict() in apps/epic/utils.py now includes image_url + arcana_key fields so the picker grid's JSON payload carries the data the JS fill-handler needs (single source of truth shared w. the gameroom sea_deck endpoint — apps/gameboard/views.py's saved_by_position dict gets the parallel additions for server-rendered saved hand). SCSS: extended the shared image-mode rule's comma-list selector in _card-deck.scss to include .sea-card-slot.sea-card-slot--image so the contour stroke + depth shadow apply to both saved + mid-draw slots from a single rule definition. Also added .sea-deck-stack--single .sea-stack-face block w. neutral --priUser/--terUser palette (vs. gravity's --quiUser/--quaUser + levity's --terUser/--ninUser) + the corresponding hover/active glow rule positioned AFTER the $_sea-shadow SCSS variable definition at line 1808 (initial draft hit a compile error: Undefined variable: "$_sea-shadow" because the hover rule was placed before the variable was defined; SCSS variables are scope/order-dependent). JS: _fillSlot in sea.js branches on card.image_url — when non-empty, write <img class="sig-stage-card-img"> + add .sea-card-slot--image marker + data-arcana-key attr; otherwise legacy corner-rank + suit-icon. innerHTML alt-attribute properly escapes " to " so card names w. quotes (none today, but defensive) don't break HTML. Existing JS that activates a clicked stack (_activeStack flow + _showOk / _hideOk) works unchanged w. the single-stack variant since the selector .sea-deck-stack matches all variants regardless of polarity suffix; the isLevity = stack.classList.contains('sea-deck-stack--levity') check at the deposit moment returns false for --single → defaults to gravity polarity assignment, which is fine for non-polarized decks (polarity field has no card-content effect). Memory updated: project_image_based_deck_face_rendering.md now lists A.0-A.7 done + this polish + room.html (A.8) as the sole remaining surface for tomorrow. The 6-surface scope sheet shows A.8 as the last red box; everything else green. Tests: 1306/1306 IT+UT total green (73s). No new ITs in this commit — the saved-slot render touch was an extension of existing saved_by_position view context shape (covered by existing slot-render tests' implicit invariance); the JS change is hard to test via Django ITs (would need Jasmine spec or FT, deferred); the deck-stack collapse is a template branch (visual; user verified live in browser this session). Tomorrow: A.8 room.html image-rendering (multi-user surface via Channels WebSocket payload + same template branch pattern; keep dual gravity/levity stacks per user spec)
.sig-stage-card.sig-stage-card--image SCSS contract via a comma-list selector extension covering the parallel container classes (.my-sign-applet-card.my-sign-applet-card--image + .my-sea-slot.my-sea-slot--image) — single source of truth for the contour-stroke drop-shadow chain + tray-card silhouette black depth shadow + .is-flipped-to-back visibility toggle + the --img-stroke-color arcana-keyed CSS prop. Templates branch server-side on card.deck_variant.has_card_images: image-mode renders <img class="sig-stage-card-img" src="{{ card.image_url }}"> w. the marker class + data-arcana-key attr; text mode keeps the existing fan-card-corner + fan-card-face scaffold unchanged. SCSS import-order quirk: _card-deck.scss imports BEFORE both _billboard.scss (which nests .my-sign-applet-card inside .my-sign-applet-body for container queries) and _gameboard.scss (which nests .my-sea-slot--filled.--gravity/--levity inside #id_applet_my_sea w. specificity 1,2,0). The shared top-level image-mode rule at 0,2,0 loses on bg/border/padding to those nested base rules, so each app's stylesheet gets a parallel &.--image { background: transparent; border: 0; padding: 0 } override inside its own nest. The filter-chain rules on .sig-stage-card-img (descendant selector inside the shared rule) DO win since the apps don't restyle that class — only the outer container needs the parallel override. Sprint A.6 bonus: applet-level FLIP btn for non-polarized image-equipped decks (Minchiate today). Mirrors the my_sign.html main page A.5-polish-2 FLIP-to-back contract — .my-sign-applet-flip-btn nested inside the .--image card so absolute positioning anchors to the card bounds; inline <script> IIFE (gated inside the sig-present {% with card %} scope to keep card in lexical reach + prevent the JS selector string leaking into the no-sig DOM where assertNotContains "my-sign-applet-card" ITs catch it) attaches a click handler that runs the same rotateY 0→90→0 animation, toggles .is-flipped-to-back at the halfway point, and clears data-flipping at end; SCSS .my-sign-applet-card[data-flipping] .my-sign-applet-flip-btn { opacity: 0; pointer-events: none } hides the btn mid-spin. Critical scope bug caught + fixed during browser verify: initial draft had the script BLOCK + its {% if card.deck_variant.has_card_images %} gate placed AFTER the {% endwith %} closing tag — card was out of scope at the {% if %} evaluation, Django treats undefined vars as empty string, the gate evaluated falsy, and the script NEVER rendered (the FLIP btn rendered fine since it was inside the with block, but no JS handler → click did nothing but the CSS depress animation). Fix: move {% endwith %} to AFTER the script gate so card is still in scope. 7 new ITs total: 2 in BillboardAppletMySignTest (image-equipped Minchiate renders --image class + img + correct asset URL + lacks text scaffold; Earthman keeps the text scaffold + lacks --image); 3 in BillboardMySignViewTest (data-deck-polarized attr present; back-img element renders for non-polarized image deck; polarized deck omits it); 1 in GameboardViewTest (image-equipped Minchiate slot renders --image + img + lacks text scaffold); plus regression coverage on the no-sig empty-state assertion that originally caught the script-scope bug (assertNotContains validates the script doesn't leak in the no-sig case). Tests: 6 new ITs green; 1306/1306 IT+UT total green (72s; +6 from bdf6a25's 1303 — minus 3 dups since some ITs were counted across both A.6 + A.5-polish-2 runs). Visual verify by user 2026-05-25 PM: stage card image renders cleanly; FLIP cycles to back image + back via animation; FLIP btn hides during 500ms spin; placeholder dim styling correctly distinguishes no-deck state
-1860-1890 publication-year suffix so future Minchiate Fiorentine variants from different publishers/eras coexist cleanly; courts use rank-number-prefix (batons-11-page not batons-page) for linear sort key; trumps carry both numeric rank AND italian-name suffix (trumps-01-papa-uno, trumps-11-il-gobbo) for forensic identification across variant decks the user plans to sell. Il Matto (unnumbered Fool in Minchiate tradition) assigned rank 00 to give it a sortable position. Five trump filenames had elided-apostrophe slugs restored from the download source (-lacqua → -l-acqua etc.). Card-back at <deck-slug>-back.png sorts alphabetically before all suit categories — no separate card-back/ subdir needed. pngquant 2.17.0 installed at C:\Users\adamc\AppData\Local\Programs\pngquant\, added to user PATH (effective next session) for future deck imports; ran with --quality=65-85 --speed=1 --strip --skip-if-larger for 57.6% size reduction (86.6 MB → 36.7 MB total, 935 KB → 383 KB avg). Second pass at --quality=40-65 hit pngquant's floor (only 0.5% further reduction — re-quantizing an already-quantized image has little headroom). Il Gobbo from Wikimedia was a dimensional outlier (1426x2366 vs siblings ~620x1024) — resized via System.Drawing HighQualityBicubic to 620x1029 before optimization. Format32bppArgb alpha channel verified intact across samples after optimization pass. Visually validated by user: cards must fill entire screen before any pixelization visible. Sprint A precursor — DeckVariant.has_card_images toggle + image-rendering template branch per [[project-image-based-deck-face-rendering]] follows in subsequent commits, will consume these assets in 6 surfaces (my_sign, my_sea, both billboard applets, room, game_kit). Asset set also unblocks downstream Sprint C+B [[project-deck-segment-model]] (admin form will require image upload + enforce naming convention) and Sprint D [[project-card-deck-icon]] (uses -back.png as the deck-stack icon's repeating card-face). Future: when Sprint B's admin form ships, wire pngquant into an optimize_card_images management command so admin uploads auto-optimize on save. Gitignore line src/apps/epic/static/apps/epic/images/cards-faces/minchiate-fiorentine/ dropped — v1 staging dir deleted (was only ever the rename-staging set; superseded by v2-named optimized set). Total disk delta: +36.7 MB binary content. No code changes — pure asset + convention import
unlocked_decks + buds M2M widgets from SelectMultiple to filter_horizontal (dual-listbox). The default SelectMultiple widget renders ALL DeckVariant/User rows in a single listbox with only the currently-selected ones blue-highlighted — visually indistinguishable from "all of these are unlocked" if the reader doesn't notice the highlight state. This cost a half-hour staging bug investigation: Fiorentine Minchiate appeared in the listbox for admin disco and was read as "unlocked", but the Game Kit + Card Decks applets correctly rendered only Earthman because only Earthman was actually in unlocked_decks (Fiorentine was an *available option*, not a selected value). filter_horizontal splits into "Available" (left) + "Chosen" (right) panes with explicit arrows between — selected vs available is unambiguous. Same trap applies to buds (also a bare M2M per [[project-deck-contribution-spec]] adjacent note), so fixing both. No model/template/test changes — just the admin widget. UserAdminTest only exercises the changelist (/admin/lyric/user/), not the change form, so no test impact
f609313 read-only-saved-sig batch.
#id_tooltip_portal .tt-title { display: block } was clobbering the flex h4) + My Sign page collapses to read-only card+stat-block when sig is saved + My Sign applet card gets proper 5:8 shell + Game Kit row space-evenly. Five visual polish items batched.
<h4 class="tt-title"> already has display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem (from _tooltips.scss:31-46's .tt block, originally meant for the .token-count chip pattern in Tokens row), so wrapping the name + price as two sibling <span>s inside the h4 auto-spaces: name pinned left, price pinned right, on the same baseline. .tt-price joins .tt-expiry (priRd) + .tt-date (priGn) in the shared %tt-token-fields placeholder at _tooltips.scss:8-19 — same shape (1rem) as both, --priGn coloring to mirror .tt-date's "in the green" semantics for the payment cue. Standalone <p class="tt-price"> line below the description is dropped (price now lives in the title row). 1211 IT/UT still green; no test changes needed — existing FT assertion (assertIn("$1", tithe1_tt)) reads .tt innerHTML which still contains the dollar string in either position
8e476f5 → d28cf7b). **Microtooltip extraction**: .tt-microbutton-portal (Chunk 4's wrap-inside-.tt) replaced w. a sibling .tt-micro div on each .shop-tile. wallet.js's initWalletTooltips clones BOTH into separate portals on hover — .tt → #id_tooltip_portal (main card), .tt-micro → #id_mini_tooltip_portal (small italic pill at bottom-right of main, mirroring Game Kit's Equipped/Unequipped/In-Use mini portal). Hover persistence covers both portals + the source tile w. a 200ms grace timer cancelled by mouseenter on any of the 3 zones. Capped items (BAND-owned) render NO btn at all — just "Already owned" microtext (mirrors Game Kit's status-only "Equipped" pill rather than the disabled-× pattern that lived in Chunk 4). **Tooltip-pin on guard open**: WalletTooltips.pin() / .unpin() exposed on window; wallet-shop.js's BUY click calls pin() before showGuard() + both onConfirm / onDismiss callbacks call unpin() → the item tooltip stays visible behind the guard's "Buy {name} for ${price}?" prompt instead of orphaning. **Shop-first applet ordering**: new Applet.display_order field (default 100, lower = earlier; PK tie-break preserves legacy insertion-order for the existing 3 applets); seed migration sets wallet-shop.display_order=10 so Shop renders atop Balances/Tokens/Payment. applet_context() updated to .order_by("display_order", "pk"). New WalletAppletOrderTest (2 ITs) pins Shop-first DOM order + view-context list. **DRY tooltip styling**: shop tooltip now uses the same 4-slot .tt-title / .tt-description / .tt-shoptalk / .tt-expiry classes as the Tokens row. New ShopItem.shoptalk field for the italic flavor line (band-1 = "Unlimited free entry (BYOB)" split out of description; tithes blank). New ShopItem.tooltip_expiry() method returns "no expiry" — eternal-stock convention (all current items; seasonal listings could override later). **Writs rebalance**: locked 2026-05-22 — tithe-1 144→12 writs, tithe-5 750→60 writs. Description text updated in lockstep ("1 Tithe Token + 12 Writs" / "5 Tithe Tokens + 60 Writs"). **Badge tweak**: ×N badge shrunk 2rem → 1.5rem + nudged further off-tile (top: -0.7rem, right: -1rem) so most of the underlying icon stays visible. **SCSS**: .tt-micro hidden in source DOM (portal-only); #id_mini_tooltip_portal mostly mirrors gameboard's mini at _gameboard.scss:140 but allows BUY-btn label to wrap onto multiple lines (white-space: normal on .tt-buy-btn); .tt-already-owned styled w. --secUser italic at 0.85rem to match Game Kit pills. **Migrations** — 5 new: lyric/0010_repricing_tithe_writs (writs + description), lyric/0011_shopitem_shoptalk (schema), lyric/0012_seed_shop_shoptalk (band split), applets/0012_applet_display_order (schema), applets/0013_wallet_shop_display_order (Shop atop). All idempotent. **TDD** — 5 new ITs across test_shop_models.py (shoptalk default + per-item assertions, tooltip_expiry method, updated tithe writs values, WalletAppletOrderTest), 1 new FT (test_shop_buy_guard_portal_pins_item_tooltip — programmatically dispatches mouseenter/mouseleave to exercise the pin/unpin race), 3 new Jasmine specs (T6 pin-on-click, T7 unpin-on-confirm, T8 unpin-on-dismiss). Existing FT band-owned assertion switched to .tt-micro (no .tt-buy-btn present), Jasmine T2 rewritten to assert no btn renders. **3 traps caught** mid-build: (a) multi-line {# #} comment leaked into DOM again (cf [[feedback-django-comments-single-line-only]]) — pinned the trap; (b) spyOn(window, 'fetch') Jasmine double-spy collision (cf trapped previously); (c) async pollution where afterEach restores window.Stripe=undefined before _doBuy's continuation hits it — fixed by per-test never-resolving fetch mock. 1211 IT/UT + 9 wallet FTs green; Jasmine SpecRunner verified visually (FT hangs Selenium-side on spec count). Pipeline will sweep all FTs
#id_tithe_token_shop block from Balances applet — Chunk 5 (final) of [[project-wallet-shop-expansion]]. The inline 1 Tithe Token +144 Writs $1.00 / 5 Tithe Tokens +750 Writs $4.00 token-bundle HTML in _applet-wallet-balances.html was display-only (no purchase wiring was ever attached) + has been fully superseded by the dedicated Shop applet shipped in Chunks 2-4. Per the locked decision in the scope doc, Balances is now read-only — writs + esteem totals only — and the Shop is the canonical purchase surface. **Removed**: 8 lines of <div id="id_tithe_token_shop"> w. 2 .token-bundle children. **Replaced with** a {% comment %} pointer noting the move so the next archeologist looking at the Balances HTML doesn't reinvent the wheel. **Dropped tests**: WalletViewTest.test_wallet_page_shows_tithe_token_shop + :test_tithe_token_shop_shows_bundle ITs + the legacy test_user_can_purchase_tithe_token_bundle FT — all asserted the now-removed selector. Replaced w. a comment pointing to the 3 new shop FTs (test_shop_applet_renders_seeded_items_with_icons_and_badges, test_shop_buy_click_opens_guard_portal_with_purchase_prompt, test_shop_band_already_owned_shows_disabled_buy_btn) + the model + view ITs in test_shop_models.py + test_shop_views.py. 1206 IT/UT (was 1208 — 2 stale ITs gone) + 8 wallet FTs (was 9 — 1 stale FT gone) green
wallet-shop, seeded in Chunk 2) now renders the catalog as a horizontal grid of .shop-tile icons: tithe-1 ($1, fa-piggy-bank), tithe-5 ($4, fa-piggy-bank w. ×5 badge), band-1 ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a .tt-microbutton-portal w. a .btn-primary BUY ITEM button — clicking opens #id_guard_portal w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit max_owned (eg. BAND, owned=1, cap=1) render w. .btn-disabled + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — wallet view + toggle_wallet_applets view both pass shop_items (decorated w. per-user .available via the new _shop_items_for(user) helper) + default_payment_method_id + stripe_publishable_key. SCSS — .wallet-shop (flex column wrapping .shop-grid flex row), .shop-tile (inline-flex tooltip target), .shop-badge (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), .tt-microbutton-portal (column-flex, BUY btn + 'Already owned' caption styling). JS in wallet-shop.js exposes a singleton WalletShop module (matching the project's Brief / SeaDeal / StageCard module pattern) w. a tested initWalletShop() method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed data-shop-wired flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into wallet.html after wallet.js. **TDD** — 5 Jasmine specs in WalletShopSpec.js: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs shop_item_slug to /shop/buy; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) spyOn(window, 'fetch') collides if another spec already spied on fetch — switched to save+restore via per-test _origFetch capture; (b) T3 async pollution — sync assertion passed, afterEach restored window.Stripe=undefined, then _doBuy's async continuation hit Stripe(pubKey) and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in test_dash_wallet.py: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via textContent since .tt is display: none). FT trap caught: TransactionTestCase wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors test_shop_views.py's _seed_starting_items pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green
shop_buy + shop_confirm + stripe_webhook — Chunk 3 of [[project-wallet-shop-expansion]]. Three-endpoint split per the locked Stripe design: webhook is authoritative for fulfillment (resilient to 3DS, browser closes, network drops); sync /shop/confirm is a best-effort UX speedup (fulfills immediately when Stripe.js confirms client-side, no waiting for webhook delivery); both call Purchase.fulfill() which is idempotent — whichever lands first wins, the other becomes a no-op via the status==SUCCEEDED guard. **POST /dashboard/wallet/shop/buy** (form-encoded shop_item_slug): looks up active ShopItem (404 if missing/inactive); enforces max_owned via is_available_for(user) (409 if cap hit, eg already-owned BAND); requires a saved PaymentMethod (402 otherwise — picks most-recent via order_by('-pk').first() per the open-Q note in the scope doc); creates Stripe PaymentIntent (amount=item.price_cents, currency=usd, customer=user.stripe_customer_id, payment_method=pm.stripe_pm_id, automatic_payment_methods={enabled, allow_redirects=never} for in-window 3DS); creates Purchase w. pi.id; backfills pi.metadata.purchase_id via PaymentIntent.modify so the webhook handler can resolve back to the row; returns {client_secret, purchase_id} JSON for Stripe.js confirmCardPayment. **POST /dashboard/wallet/shop/confirm** (form-encoded purchase_id): retrieves PI from Stripe, if status=='succeeded' calls purchase.fulfill(); returns {status} JSON. 404 if the purchase doesn't belong to request.user. Idempotent — re-firing after fulfill is a safe no-op. **POST /stripe/webhook** (csrf_exempt, mounted at root /stripe/webhook so the URL stays stable across app-routing refactors w. Stripe's dashboard config): verifies signature via stripe.Webhook.construct_event against STRIPE_WEBHOOK_SECRET env var (400 on mismatch — Stripe won't retry on 4xx, only 5xx); on payment_intent.succeeded looks up Purchase by metadata.purchase_id w. fall-back to stripe_payment_intent_id (both unique). Unknown event types are no-op 200 (Stripe sends charge.dispute.created etc. + would retry indefinitely on 5xx). New STRIPE_WEBHOOK_SECRET = os.environ.get(...) setting; user swaps it on staging+prod per the live-mode env-var-only decision. TDD — 17 ITs in test_shop_views.py across 3 classes: ShopBuyViewTest (7 cases — login required, success path creates PI + Purchase w. correct shape, PI.create called w. correct args, unknown slug 404, inactive item 404, max_owned 409, no PM 402); ShopConfirmViewTest (5 cases — login required, succeeded PI triggers fulfill, processing PI leaves PENDING, idempotent on already-SUCCEEDED, other user's purchase 404); StripeWebhookViewTest (5 cases — sig mismatch 400, succeeded event triggers fulfill, unknown event type 2xx no-op, duplicate delivery idempotent, unknown purchase_id 2xx no-op). All Stripe API calls mocked via mock.patch('apps.dashboard.views.stripe'). 1208 IT/UT green
feedback_polarity_must_agree_across_surfaces fix (f59c1af) added .my-sea-page[data-polarity="..."] to the shared .sig-overlay, .my-sign-page polarity block at _card-deck.scss:919. Worked for the spread-center sig (.sea-sig-card) but silently bled into the drawn-card stage modal: the stage's element carries BOTH classes .sig-stage-card sea-stage-card (per _sea_stage.html:12), so the shared rule's .sig-stage-card descendant selector matched. Specificity .my-sea-page[data-polarity="levity"] .sig-stage-card = 0,3,0 silently beat the card-specific .sea-stage--gravity .sea-stage-card = 0,2,0 (set by sea.js's _showStage(isLevity) at line 104-108) → every drawn card on my-sea rendered the user's-sig polarity instead of the deck-stack it was actually drawn from. Room.html Sea Select unaffected (no .my-sea-page ancestor on the stage there). User-reported 2026-05-21 — symptom: a gravity card opened in my-sea stage shows the light/cream levity styling even though the card came from the gravity deck. Fix: drop .my-sea-page[data-polarity] from the shared selector list at _card-deck.scss:917-919 + :972-974; add a NEW dedicated rule at the end of the shared block scoped tightly to .sig-stage-card.sea-sig-card (0,4,0 specificity) — the central sig stays page-polarity-driven (yesterday's MySeaPolarityMatchesMySignTest still pins this) but every other .sig-stage-card descendant (drawn-card stages, future spread elements) is free to follow its own polarity. Gravity is the default rendering for .sea-sig-card per the base rule at :1379 so only the levity override needs an explicit block. 6/6 existing polarity + picker ITs green; visual verify deferred to user. Trap captured: [[feedback-page-polarity-scope-trap]] — multi-class elements (.A.B) match both shared (.A) AND scoped (.B) selectors, so any new page wrapper added to a shared block needs an audit of every descendant selector in the block for nested polarity overlap
RobustCompressorTestRunner.teardown_databases now force-closes lingering connections before Django's DROP. CI step test-UTs-n-ITs (python manage.py test apps, full suite incl. channels-tagged tests) was failing post-test even when all 1165 tests passed — psycopg2.errors.ObjectInUse: database "test_python_tdd_test" is being accessed by other users / DETAIL: There is 1 other session using the database. Two-step leak: (1) core.settings.DATABASES['default']['conn_max_age']=600 keeps Postgres connections alive in the per-thread pool for 10 min (prod-perf default); (2) Channels' database_sync_to_async (16 call sites across apps.epic.tests.integrated.test_consumers's CursorMoveConsumerTest + SigHoverConsumerTest) runs in a process-wide asgiref threadpool — each worker thread accumulates its own DB connection that outlives the test + sits idle in the pool when teardown fires. Postgres refuses DROP while ANY session targets the row. Local dev unaffected: --exclude-tag=channels skips the consumer tests + SQLite has no DROP step. **Fix** lives entirely in core/runner.py's teardown override — connections.close_all() covers the main thread's runner connection; iterating old_config + running SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s AND pid <> pg_backend_pid() against each test DB kicks any worker-thread session still pinning the row. Both safe on a green run (DB about to be dropped anyway) + scoped to vendor == "postgresql" so SQLite local-dev is a clean no-op. Prod CONN_MAX_AGE=600 untouched — fix lives in the test runner, NOT in settings. 19/19 lyric UTs green via the new runner path (smoke verify the override is benign on SQLite); Postgres-side validated next CI run. Trap captured: [[feedback-test-teardown-conn-leak]] — symptom signature Ran NNNN tests / OK / Destroying.../ObjectInUse: ... belongs in CI-fail triage notes so future flakes get diagnosed in seconds instead of test-hunting
User.equipped_trinket is the sole opt-in for trinket-as-token use at BOTH gatekeepers (/gameboard/room/<id>/gate/ + /gameboard/my-sea/gate/). Old flat-priority chain (PASS→BAND→COIN→FREE→TITHE) silently consumed a DOFFed-but-owned COIN when the user clicked the rails — current_room advanced, no inventory decrement, wallet looked unchanged. User-reported 2026-05-21 as "free for all" admit when no trinket equipped. Root cause: select_token + _select_my_sea_token ignored equipped_trinket_id entirely + just grabbed the highest-priority owned token regardless of equip state, making the equip slot a decorative no-op. **Fix**: both pickers now start from user.equipped_trinket_id; equipped PASS (staff)/BAND/COIN-with-no-current-room → return it; equipped CARTE → fall through (CARTE is opt-in via kit-bag click that sets token_id POST param routed through drop_token's explicit branch, NOT select_token); my-sea additionally checks COIN cooldown (next_ready_at <= now); no equipped trinket OR equipped trinket invalid → FREE (FEFO) → TITHE → None. **Fresh-query defense**: pickers query user.tokens.filter(pk=user.equipped_trinket_id).first() instead of the cached user.equipped_trinket FK descriptor — descriptor goes stale across mid-request state changes + bites tests where tokens.all().delete() triggers SET_NULL cascade but the Python object stays unrefreshed (SQLite reuses deleted PKs so a coincidentally-matching new token slips through). TDD — new SelectTokenEquipGatedTest (7 ITs) + SelectMySeaTokenEquipGatedTest (6 ITs) pin: skip-unequipped-COIN → FREE; skip-unequipped-BAND → TITHE; no equip + no consumables → None; CARTE equipped → falls through; equipped-COIN-in-use-elsewhere falls through; staff with unequipped PASS falls through; my-sea cooldown-COIN-equipped falls through. **Existing tests updated** (5 cases pinned the old flat-priority semantic + needed equipping explicit before assertion): SelectTokenTest.test_returns_pass_for_staff + test_returns_band_when_equipped + test_pass_wins_when_equipped_over_band + SelectMySeaTokenTest.test_pass_wins_priority_for_staff (now equip PASS first); ConfirmTokenPriorityViewTest.test_pass_not_consumed_and_coin_not_leased + TokenPriorityTest.test_staff_backstage_pass_bypasses_token_cost (FT) now DON the PASS before clicking rails. SelectMySeaTokenTest.setUp adds refresh_from_db() after tokens.all().delete() so the cascade SET_NULL on equipped_trinket_id is reflected in the Python object. 1160 IT/UT + 5 TokenPriority FTs green. Trap captured: [[feedback-equip-slot-gates-trinket-use]]
/gameboard/my-sea/ sig polarity now matches /billboard/my-sign/ — two-bug stack. **Bug 1 (primary):** my_sea.html:10 had {% if significator_reversed %}gravity{% else %}levity{% endif %} — INVERTED from my_sign.html:22's {% if current_significator_reversed %}levity{% else %}gravity{% endif %} + its JS _polarity() (revInput.value === '1' ? 'levity' : 'gravity'). Same User.significator_reversed value produced opposite polarity styling across the two surfaces → a levity sig picked on my-sign rendered gravity-styled on my-sea (--priUser bg + --secUser text); a gravity sig rendered levity-styled. User-reported 2026-05-21. **Bug 2 (latent, masked by Bug 1):** .sea-sig-card hardcoded .fan-corner-rank + i to color: rgba(var(--secUser), …) — fine against gravity's --priUser bg, but against levity's --secUser bg (set by .sig-stage-card in the polarity rule at _card-deck.scss:935-943) the rank + suit-icon collided w. the bg and disappeared. Bug 1 was hiding this: levity sigs were getting rendered gravity-styled (visible), so the invisibility only surfaced for gravity sigs (which got levity-styled). Fixing Bug 1 alone would've exposed Bug 2 for the previously-fine levity case → fix both in one shot. **Bug 2 fix:** switch .fan-corner-rank + i to color: currentColor w. opacity preserved (0.85 / 0.75); add explicit color: rgba(var(--secUser), 1) on the default .sig-stage-card.sea-sig-card rule so gravity inherits secUser; levity polarity rule already sets .sig-stage-card { color: rgba(var(--priUser), 1) } so it cascades down through currentColor. TDD — new MySeaPolarityMatchesMySignTest (2 ITs) pins both pages to the same User.significator_reversed → data-polarity mapping: unreversed → gravity on BOTH surfaces; reversed → levity on BOTH. 1147 IT/UT green. Visual verify deferred to user — the SCSS edge case wasn't reachable via Selenium (computed-style-on---secUser would require palette resolution at runtime)
Token.BAND (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO is_staff coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no current_room tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". fa-ring icon across all 4 surfaces (Game Kit applet #id_kit_wristband between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both apps.epic.models.select_token (room gatekeeper) + apps.gameboard.models._select_my_sea_token (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. debit_token + debit_my_sea_token treat BAND same as PASS: slot marked FILLED w. debited_token_type=BAND, token row preserved, current_room untouched, equipped_trinket unchanged. View contexts (gameboard, toggle_game_applets, _game_kit_context, wallet, toggle_wallet_applets) pass a band key — universal lookup, NO is_staff filter. Migration lyric/0007_alter_token_token_type — choices-only AlterField. TDD — 5 FTs in test_trinket_wristband.py (test_band_not_auto_equipped_after_award, test_band_tooltip_renders_full_prose, test_band_uses_fa_ring_icon, test_equipped_band_shows_equipped_mini_tooltip, test_equipped_band_shows_doff_active_don_disabled); 4 tooltip UTs (BandTokenTooltipTest); 5 model ITs (BandTokenAdminAwardTest — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (test_returns_band_when_held_and_no_pass, test_pass_still_wins_over_band_for_staff); 1 debit IT (test_debit_band_does_not_consume_or_unequip). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]]
Token.PASS is now model-enforced as staff-only — Token.clean/save raise ValidationError when a non-staff user is the FK target. Staging bug 2026-05-21 — admin awarded a PASS to a non-admin via Django admin; row was created + showed in the user's wallet, but every game-side surface (gameboard, game-kit, gate-pad select_token, _select_my_sea_token) had always filtered PASS behind is_staff, so the token was unequippable + unusable. Five is_staff-gated PASS surfaces made PASS a deliberate staff-only trinket; the wallet was the lone outlier surfacing it. Bundled: wallet view (+ HTMX toggle partial) now gates pass_token behind is_staff mirroring the gameboard pattern — defense-in-depth in case any future bypass writes a stray row. TDD — new ITs: PassTokenStaffOnlyGuardTest (model raises for non-staff, accepts for staff, leaves other token types unaffected); WalletPassTokenVisibilityTest (3 cases pin wallet + HTMX gating); TokenAdminFormTest.test_pass_token_for_non_staff_user_is_invalid + test_pass_token_for_staff_user_is_valid. Adjusted 2 existing tests that incidentally exercised the now-blocked pattern (test_paid_draw_with_pass_does_not_consume, test_pass_token_is_not_consumed — both flip is_staff = True inline before Token.objects.create); dropped PASS from test_other_token_types_do_not_require_expires_at's loop (covered by the new dedicated tests). 1133 IT/UT green. A non-admin "boost-pass" variant lands as a distinct token_type later, NEVER by relaxing the staff gate — captured in [[feedback-pass-token-staff-only]]
slot.dataset.posKey w. selector form (".sea-pos-cover") while my-sea's inline _collectHandFromDom + template's _my_sea_slot.html use raw names ("cover"). Key mismatch silently dropped manual draws from the lock POST → server rejected empty hand → no row → refresh showed empty state. AUTO DRAW worked only because it assembled fullHand w. raw posNames directly, bypassing the broken collector. TDD — 2 new FTs pin the contract:
SeaDeal.register(card, posSelector, isLevity) public method populates _seaHand + delegates to SeaDeal's internal _fillSlot so the overlay click handler can resolve _seaHand[pos] for auto-drawn slots (previously short-circuited → silent no-op). AUTO DRAW in my_sea.html now calls register instead of the inline _fillSlot shim — also fixes a dataset.posKey inconsistency (inline stored raw "cover", SeaDeal stores ".sea-pos-cover"; click handler reads SeaDeal's form). User-reported 2026-05-21. TDD — new FT test_auto_drawn_slots_can_reopen_stage_modal_on_click pins the contract
_retry_failed.sh wraps both FT steps — single-flake retries cost ~22s instead of a full 35-min step re-run. Parses Django's FAIL:/ERROR: test_method (full.dotted.path) lines from stdout, re-runs only those labels (deduped + sorted). Green first runs skip the retry; first-run crashes w. no parseable labels propagate the original exit code without masking infra problems
top: 100% was extending the list below the form col, which on portrait sits flush at the bottom of the visible aperture w. navbar/footer pinned beneath it (options unreachable). bottom: 100% (+ margin flipped to bottom) grows the list into the abundant green aperture above. Chained &.sea-form-col per [[feedback-scss-import-order-specificity]] to beat card-deck's later-loaded base
.sea-form-col.my-sea-form-col so the 2-class selector beats _card-deck.scss's base .sea-form-col { display: flex } regardless of source order (card-deck loads AFTER gameboard in core.scss, so the prior 1-class selector lost to source order and the grid never took effect)
display: contents on .sea-form-main flattens the intermediate wrapper so its children participate directly in the grid
test_landing_renders_hex_with_free_draw_btn to assert PAID DRAW + GATE VIEW are absent for fresh users — closes the mutual-exclusion gap (the other two states already pin the same invariant from their own directions; this adds the FREE-DRAW side). Docstring spells out the 3-way state machine for future readers
.btn-disabled → × pseudo-element overlay (iter-4c); restore case-by-case × rendering convention. My Sea DEL btn now swaps DEL ↔ × in lockstep w. its .btn-disabled toggle (matches game-kit tooltip + DON/DOFF pattern). User-spec 2026-05-20.
#id_my_sea_menu gear (NVM-only, %applet-menu-styled, on both /gameboard/my-sea/ and the gatekeeper) + PAID DRAW now deletes the row and redirects to ?phase=picker so the user drops straight into picking cards instead of looping back to GATE VIEW — Sprint 5 iter 6c of My Sea roadmap — TDD
.gate-title-panel w. "@<handle>'s Sea" + .gate-top-row w. .gate-main-panel (token slot) + .gate-roles-panel (PAID DRAW square), all on shared --priUser panel chrome — TDD
_select_my_sea_token / debit_my_sea_token helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD
.note-banner (Gaussian glass atop h2); next-free-draw datetime in dedicated <time> slot (not "Invalid Date"); DEL guard reuses shared #id_guard_portal from base.html — TDD