Compare commits

..

266 Commits

Author SHA1 Message Date
Disco DeDisco
84d328171b ci: re-trigger
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
2026-06-01 00:25:38 -04:00
Disco DeDisco
5447a26827 room seat cron backstop: expire_lapsed_room_seats sweeps idle mid-game tables — TDD
Some checks failed
ci/woodpecker/manual/pyswiss Pipeline failed
ci/woodpecker/manual/main Pipeline failed
Phase 6 (final) of the room GATE VIEW + seat-renewal sprint. Cron
backstop mirroring delete_stale_my_sea_draws — the lazy
_expire_lapsed_seats already frees seats on every room/gate-view access,
but a mid-game table nobody reopens past the grace window would keep its
stuck seats forever. This command runs the same sweep over every room
holding a timestamped FILLED slot. No flags; idempotent.

Tests: ExpireLapsedRoomSeatsCommandTest (2) — frees a >2S lapsed seat +
flags RENEWAL_DUE; no-op within grace. Full project suite 1590 ITs/UTs
green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:40:16 -04:00
Disco DeDisco
0cd16861cd room auto-BYE: free seats lapsed past the renewal grace (2× renewal_period) → RENEWAL_DUE gamer-needed stub — TDD
Phase 5 of the room GATE VIEW + seat-renewal sprint. A seated gamer who
never renews is evicted once their seat's cost passes the renewal-grace
window (filled_at + 2*renewal_period; 14d at the 7d default).

- _expire_lapsed_seats(room): mirrors _expire_reserved_slots — for each
  FILLED slot past 2S, blanks the GateSlot, blanks the matching TableSeat
  (keeps the row for seat-count integrity), records SLOT_RETURNED +
  retracts the prior SLOT_FILLED (scroll redact-pair symmetry), then
  flags the room RENEWAL_DUE. NULL filled_at is never expired (RESERVED
  holds / ORM fixtures / auto-admit trinkets) — protects every existing
  FILLED-slot test
- lazy call sites: room_view, gatekeeper, room_gate (on access; mirrors
  the my-sea delete_stale pattern — no scheduler needed for active rooms)
- room.html: RENEWAL_DUE renders a minimal #id_gamer_needed stub
  (_table_positions + _gatekeeper already suppressed for RENEWAL_DUE).
  Mid-game re-seat flow is a documented follow-on

Tests: ExpireLapsedSeatsTest (10) — frees slot + blanks seat past grace;
no-op within cost window / grace / for null filled_at; sets RENEWAL_DUE;
records SLOT_RETURNED; lazy expiry on room_view + room_gate access;
gamer-needed stub renders. 848 epic+gameboard ITs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:34:20 -04:00
Disco DeDisco
4b3dc91e7f room center: GATE VIEW supersedes SCAN SIGS / CAST SKY / DRAW SEA / sig overlay when token cost lapses — ROLE pick survives grace — TDD
Phase 3 of the room GATE VIEW + seat-renewal sprint. When the viewer's
own FILLED gate-slot cost has lapsed (filled_at past the cost-current
window), the center hex shows a GATE VIEW button (→ room gate-view)
instead of the phase affordances, so they must renew before advancing.

- _role_select_context: adds viewer_cost_current / viewer_in_grace from
  the viewer's FILLED slot (no slot → current, defensive)
- room.html: the ROLE card-stack renders OUTSIDE the cost gate (the
  gamer's own role pick survives the renewal grace — deposit privilege);
  GATE VIEW supersedes the rest of .table-center; #id_pick_sigs_wrap
  (SCAN SIGS, advancing the whole table) is gated on viewer_cost_current;
  the SIG/SKY/SEA overlays are gated too (they embed their trigger-btn
  ids in JS, so they must not render alongside GATE VIEW)
- per user-spec: only the ROLE pick stays in grace; SCAN SIGS + every
  later phase get GATE VIEW

Tests: RoomCenterSupersessionTest (9) — GATE VIEW supersedes sig overlay
/ CAST SKY / DRAW SEA / SCAN SIGS when lapsed, normal buttons when
current; RoomRoleStackGraceTest (1) — card-stack (eligible) kept
alongside GATE VIEW when lapsed. 838 epic+gameboard ITs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:29:43 -04:00
Disco DeDisco
e78ba730e3 room gate-view: reuse the gatekeeper token-slot modal — CONT GAME → hex when satisfied / rails-renew when lapsed — TDD
Redesign of the room gate-view per user-spec 2026-05-31: drop the custom
seat-circle + countdown; render the EXACT gatekeeper modal instead
(title panel + animated status-dots + token-slot rails + roles panel).

- roles-panel .btn-primary is CONT GAME (→ table hex, same target as the
  gear NVM) while the viewer's seat cost is current; absent once it
  lapses, reappears after renewal re-satisfies the cost
- .gate-status-text: "<n> Token(s) Deposited" (literal "(s)" + the shared
  . . . . dots loop) when satisfied; "Please Deposit Token" when not.
  <n> = the room's deposited (FILLED) slot count
- token slot: .claimed (static rails) when current; .active rails that
  POST to renew_token when lapsed
- seat circle + time-remaining removed — the hex's own .fa-chair carries
  seat status & user/seat tooltips land next sprint
- room_gate view trimmed to {room, cost_current, deposited_count,
  page_class}
- tests: RoomGateViewTest reworked (9) — CONT GAME→hex + deposited-count
  status + no renew-form when current; "Please Deposit Token" + renew
  rails + no CONT GAME when lapsed; NVM→hex; page-room; no seat/countdown
  markup. 510 epic tests green

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:21:58 -04:00
Disco DeDisco
65689295a7 navbar: GATE VIEW swaps for CONT GAME on room pages (page-room) → room gate-view — TDD
Phase 0 of the room GATE VIEW + seat-renewal sprint. Mirrors the my-sea
treatment: on any room page the self-referential CONT GAME is replaced
by a GATE VIEW button that opens the room's renewal gate-view.

- `room_view` page_class → "page-gameboard page-room"; the bare gameboard
  listing stays "page-gameboard" (no page-room) so CONT GAME persists
  there for returning to a recent room.
- `_navbar.html` GATE VIEW branch fires on `page-my-sea` OR `page-room`;
  onclick routes, in precedence: page-room → epic:room_gate (room in
  context); my-sea-visit → visitor gate; else owner's sea gate. One
  consolidated branch (DRY) instead of two near-identical button blocks.

Tests: RoomNavbarGateViewTest (4) — room page shows GATE VIEW not CONT
GAME, links to room_gate, gate-view page also shows it, page-room marker
present. 826 epic+gameboard ITs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:11:05 -04:00
Disco DeDisco
516b917420 room gate-view: mid-game renewal area (room_gate) + renew_token endpoint + _room_gear nvm_url — TDD
Phase 4 of the room GATE VIEW + seat-renewal sprint. The 3rd-person
mirror of my_sea_gate: a gate-view a seated gamer can open at any time
to check token TIME REMAINING or RENEW, reachable even mid-game (the
gatekeeper redirects to the table once table_status is set — this view
does not).

- `room_gate` view + `room/<uuid>/gate/view/` URL — renders the viewer's
  own seat/position circle, a live time-remaining ticker (counts down to
  cost_current_until, then to grace_expires_at in renewal grace), and a
  RENEW affordance. page_class carries `page-room` (drives the navbar
  GATE VIEW in Phase 0). No seat → "no seat" copy, no RENEW btn.
- `renew_token` view + `room/<uuid>/gate/renew` URL — re-deposits a token
  into the viewer's already-FILLED slot via the existing `debit_token`
  (resets filled_at=now → restarts the cost-current window). Reuses
  select_token / debit_token wholesale; distinct from confirm_token,
  which needs a RESERVED slot. 402 when token-depleted; no-op redirect
  when the user holds no filled slot (already auto-BYE'd).
- `room_gate.html` — reuses the gatekeeper's .gate-overlay/.gate-modal
  chrome (hand-rolled like my_sea_gate, inner content differs) + an
  inline countdown ticker mirroring the status-dots IIFE.
- DRY: `_room_gear.html` now takes an `nvm_url` param (default the
  gameboard listing — room.html's own gear unchanged); the gate-view
  passes the table-hex URL so NVM returns to the hex, mirroring
  _my_sea_gear's contract.

Tests: RoomGateViewTest (7) + RoomRenewTokenTest (6) — renders mid-game,
own seat circle, data-cost-until, RENEW posts to renew_token, NVM→hex,
page-room marker, no-seat render; renew resets filled_at + consumes FREE
+ records SLOT_FILLED, no-slot/GET redirects, 402 when depleted. 504
epic tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:55:17 -04:00
Disco DeDisco
6fd515bc6d GateSlot seat-occupancy clock: cost_current / renewal-grace / grace_expired derived from filled_at + renewal_period — TDD
Phase 2 of the room GATE VIEW + seat-renewal sprint. Pure model
properties (no migration, no new fields) layering a uniform seat clock
on top of the existing per-token debit rules (which stay untouched):

  [A, A+S)    cost_current      play normally           (A = filled_at)
  [A+S, A+2S) in_renewal_grace  cost lapsed, seat held   (S = renewal_period)
  [A+2S, ∞)   grace_expired     eligible for auto-BYE

Uniform across ALL token types per user-spec (PASS/BAND/CARTE included)
— keyed on filled_at only. A NULL filled_at (RESERVED slots, ORM-built
fixtures) reads cost_current=True / grace_expired=False so nothing
without a fill timestamp is ever evicted (protects existing FILLED-slot
tests that set status via the ORM). renewal_span falls back to 7d when
room.renewal_period is None.

Tests: GateSlotCostCurrentTest — 11 UTs covering within/after span, null
filled_at, until==filled+period, grace boundaries [S,2S), expiry at 2S,
and the 7d span fallback. 491 epic tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:47:51 -04:00
Disco DeDisco
1e70ffabd6 my-sea seating: owner stays seated the full 24h window (row-exists), not just while a hand is down — drop dead seat1_seated — TDD
Phase 1 of the room GATE VIEW + seat-renewal sprint. Decouples 1C
seating from hand/deposit/paid state: the owner is seated as long as
`active_draw_for` returns a row, i.e. for the full 24h after her most
recent FREE or PAID draw (PAID DRAW resets created_at, so the window
runs from the later of the two). A DEL'd row (empty hand, no paid
credit) now keeps 1C seated until the row expires at 24h — previously
1C dropped to .fa-ban the instant she DEL'd, even mid-window.

- `_my_sea_seats` `owner_seated` → `owner_draw is not None` (drives the
  owner's own landing hex + the live `sea_seats` broadcast).
- `my_sea_visit` `owner_seated` → same rule, so the spectator hex and
  the owner's landing agree (was drawn-OR-paid, dropped `owner_paid`).
- DRY: removed the dead `seat1_seated` context (the `seats` ring's
  `seat.present` has driven 1C since the multi-seat hex landed; the flag
  was never read by the template).

Tests — TDD red→green:
- flipped `test_seat_1c_not_seated_at_gate_view_after_del` →
  `..._seated_...`: DEL'd empty-hand row keeps 1C seated, center still
  GATE VIEW (1 check / 5 ban).
- flipped FT `test_seat_1_banned_when_active_draw_has_empty_hand` →
  `..._seated_...` (asserts .fa-circle-check).
- added `test_seat1_seated_context_key_removed` (DRY regression guard).
- added spectator `test_owner_seated_with_empty_hand_no_payment`.
- `test_owner_not_seated_without_draw_or_payment` unchanged (no row →
  still unseated). 318 gameboard ITs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:45:21 -04:00
Disco DeDisco
86a349b64e wallet shop: free ($0) RWS + Fiorentine decks — FREE ITEM claim unlocks to Game Kit — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- model: DeckVariant.free_in_shop flag (0015 schema); data migration 0016
  seeds RWS + Minchiate Fiorentine True (Earthman stays False — it's auto-
  granted at signup, not shopped)
- view: _free_decks_for decorates the free-in-shop catalog w. a per-user
  .owned flag; shop_claim_free POST endpoint adds the deck to unlocked_decks
  (idempotent M2M add) — the free_in_shop filter is the guard that stops the
  $0 endpoint unlocking paid/auto-granted decks (404 otherwise). free_decks
  wired into both the wallet view + toggle_wallet_applets HX context
- url: wallet/shop/claim (action, no trailing slash)
- template: free-deck tiles reuse the deck's own Game Kit tooltip prose
  (name / card-count / description / stock-version line) + a $0 .tt-price
  pinned top-right like paid tiles; .tt-micro carries .tt-free-btn (FREE
  ITEM) or the same .tt-already-owned pill once owned; reuses
  _deck_stack_icon.html
- js: wallet-shop.js _onFreeClick → _doClaimFree POSTs deck_slug → reload
  (server-rendered owned pill, same posture as the BUY reload). No guard
  portal — free = one-click. Rides the SAME delegated roots as BUY +
  idempotent wiring
- css: FREE ITEM wraps to 2 lines like BUY ITEM (extend the mini-portal
  .tt-buy-btn white-space:normal rule to .tt-free-btn); shop deck tiles get
  the Game Kit fan-out on hover/active by adding .shop-tile-deck to the
  .deck-stack-icon splay trigger list — DRY, no transform duplication
- tests: 8 ITs (shop_claim_free behaviors + free_decks context owned flag);
  FT claims RWS → 'Already owned' swap → id_kit_tarot_deck appears in Game
  Kit; 3 Jasmine specs F1-F3 (claim POST / no-guard / idempotent wiring);
  679 dashboard+epic green, no regressions
- trap: hover-hidden microtooltip btn → .text is '' under Selenium; read
  get_attribute('textContent') instead [[feedback-selenium-opacity-zero]]

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:51:21 -04:00
Disco DeDisco
d8377b57bc my-sea cards: fix rotated significator/cross blur — drop the 4-shadow contour chain on rotated image cards, keep depth — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
The image-card contour stroke is 4 chained `drop-shadow()`s; each re-rasterizes
the already-downscaled card (408→~64px), so on a ROTATED card the compounded
re-sampling reads as BLUR. It's NOT the angle — even the 5° significator blurs,
while the 90° cross blurs hardest; the upright COVER (same filter, no rotation)
+ the unrotated preview modal stay crisp. Verified live (dpr 1): a lone depth
drop-shadow on a rotated card is crisp, the 4-shadow chain is not.

Fix: the rotated image cards (`.sea-sig-card` -5° + `.sea-pos-cross .sea-card-slot`
90°/270°) drop the contour chain, keeping only the depth drop-shadow → crisp.
Upright cards keep the full contour. Zeroing `--img-stroke-w` wouldn't help (the
blur is the chained-shadow re-rasterization, not the stroke offset). RWS's
contour was a redundant double-frame over its printed border anyway, so its
rotated cards lose nothing visible.

Verified in Firefox: the enlarged 5° significator renders crisp (sharp title +
edges) with depth + printed border intact.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:26:29 -04:00
Disco DeDisco
7e39740f9c my_sea_visit nav: phase-aware NVM (hex→bud page, draw→hex) + navbar GATE VIEW → visit gate + guard reposition on resize — TDD
Spectator guard-portal + NVM/GATE VIEW routing (user-spec 2026-05-30):
- The leave-the-sea guard now confirms with OK (was NVM); `_my_sea_gear.html`
  gains an `nvm_handler` param so a caller can swap the default leave-nav.
- my_sea_visit NVM is phase-aware (`mySeaVisitNvm`): on the DRAW/spread phase it
  flips back to the table hex (client toggle, stays in-ecosphere → no voice
  guard, mirroring the owner's picker→landing); on the table hex it LEAVES to the
  bud's page (`billboard:bud_page`) behind the shared voice-disconnect guard.
- The navbar GATE VIEW opens THIS owner's visitor gatekeeper on my_sea_visit (+
  its gate page, whose page_class also carries `page-my-sea-visit`), not the
  viewer's own sea gate; owner pages are unchanged.
- showGuard now RE-POSITIONS the open guard on resize/orientationchange
  (rAF-throttled) so it follows its anchor instead of stranding at its show-time
  coords — a cross-cutting fix (every gear-menu guard) for the portal landing
  off-screen after an orientation flip relocated the gear menu.

Coverage: MySeaVisitNavTest (navbar→visit gate, gear NVM→mySeaVisitNvm, bud URL,
OK label) + MySeaOwnerNavbarGateUnaffectedTest (owner gate untouched). Verified
live in Firefox: picker NVM returns to the hex; the guard followed its anchor
from 882px→54px on a simulated orientation flip, staying in-viewport.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:15:23 -04:00
Disco DeDisco
571d5a84ae voice glow: regression spec — 3-min mute auto-disconnect stops the priRd/.fa-ban path + returns to the available nudge, live — TDD
The auto-disconnect already drives this asynchronously (burger-btn `_muteAutoDisconnect`
→ VoiceRoom.leave → _teardown → _notify{inCall:false,muted:false} → voice-glow render),
and the `voiceroom:ready` subscribe fix guarantees voice-glow receives it without a
refresh. Lock the muted→not-in-call transition behind a VoiceGlowSpec case: drops
`voice-muted` (priRd + .fa-ban) + restores the channel-available `voice-glow` nudge,
no longer "live" (no pulse/eq). Jasmine green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:49:35 -04:00
Disco DeDisco
668105aeeb my-sea voice: persist mute across in-sea nav/refresh + 3-min muted auto-disconnect; fix first-connect glow/mute race — TDD
MUTE PERSISTENCE (user-spec 2026-05-30) — a voice mute used to vanish on any
in-sea navigation/refresh (the mesh tears down + auto-rejoins unmuted). Now the
mute is stamped server-side + re-applied on rejoin, with a 3-min muted →
auto-disconnect window:
- `User.voice_muted_at` (timestamp, not a bare bool, so the 3-min window anchors
  here) + migration. Per-user, not per-seat: the owner has no seat row, and a
  user is in ≤1 voice room at a time, so this uniformly covers owner + visitor.
- POST `/voice/mute` {muted} sets/clears it (new voice app views.py + urls.py,
  mounted at `voice/` in core/urls). my_sea + my_sea_visit pass the timestamp to
  `#id_voice_btn` as `data-voice-muted-at`.
- voice-mesh.js gains `setMuted(m)` (set vs. toggleMute's flip), honoured by
  join's post-getUserMedia `_applyMute`. burger-btn.js: a mute toggle POSTs the
  state + arms a client timer; the auto-rejoin re-applies the persisted mute +
  re-arms the timer from the stored timestamp (so the 3-min spans navigations,
  not resets); an elapsed window on rejoin auto-disconnects instead of rejoining;
  a fresh manual join clears any stale mute. On timeout: leave voice + clear.

FIRST-CONNECT GLOW/MUTE RACE (user-reported) — `setOnStateChange` pushes the
current state immediately on subscribe, and voice-glow.js often subscribes
MID-JOIN (getUserMedia pending → inCall=false). Its `setVoiceState` only ever
DELETED `voice.dataset.inCall` (never re-set it) — wiping the join-vs-mute flag
burger-btn.js had just set, so the next click re-joined instead of muting (which
also dropped the peer + killed the equalizer). Two fixes:
- voice-glow keeps `dataset.inCall` SYMMETRIC (set on true, delete on false), so
  the mid-join false is restored once the stream resolves → mute works on first
  connect.
- voice-glow subscribes reliably on AUTO-REJOIN too (no click to trigger its
  poll): voice-mesh.js dispatches `voiceroom:ready` on singleton creation +
  voice-glow listens, so the glow is mesh-driven (peer-count equalizer) after a
  refresh, not just the in-call-class fallback.

Coverage:
- ITs: VoiceMuteViewTest (login/405/invalid-json guards, stamp on true, clear on
  false, re-mute restamps, missing-key=false). voice+lyric 164 green.
- Jasmine: BurgerSpec mute persistence (muteRemainingMs window, rejoin re-mute,
  expired-window auto-disconnect, toggle-persists + 3-min fires, manual-join
  clears); VoiceGlowSpec dataset.inCall sync (sets on in-call, clears on not,
  restores after a mid-join false→true). All green.
- Live multi-party voice (mic/2-device) left to manual verification.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:41:30 -04:00
Disco DeDisco
de4dcd7979 my-sea deck glow: single (monodeck) stack matches the levity/gravity --ninUser halo — make every deck match
Bring the single/non-polarized deck stack's hover/active glow in line with the
tuned --ninUser halo levity + gravity already use (0.5rem blur / 0.5rem spread /
0.3 alpha) so every deck reads identically (user-spec 2026-05-30). Added a
per-deck `$_glow-single` var (parity w. `$_glow-levity`/`$_glow-gravity`, each
independently tunable). The monodeck keeps its own neutral --terUser hover
border; only the glow box-shadow changed.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:19:14 -04:00
Disco DeDisco
b7d871388e my-sea deck-stack + spread-card glow: unify hover-reveal / click-persist + --ninUser halo — TDD
Unify the glow/FLIP interaction across the owner picker (my_sea) + the read-only
spectator (my_sea_visit), then carry the same selection halo onto the spread
cards + deck-stack faces.

DECK STACK (user-spec 2026-05-30) — the owner revealed the FLIP only on click
(persisted) but never on hover; the spectator revealed it on hover but never
persisted. Now BOTH do both:
- `.sea-stack-ok` reveal is a single shared rule in _card-deck.scss — opacity
  fades in on hover/focus (ephemeral) OR via the JS-set `.sea-deck-stack--active`
  class (click-persist, same class the face-glow rides). The owner's inline
  `display` toggling is gone (`_showOk`/`_hideOk` just flip `--active`); the
  spectator's hover-only override in _gameboard.scss is removed.
- Interactivity stays gated on `--active`, NOT hover: hover is a purely VISUAL
  preview (matching the spectator's disabled FLIP). This preserves the owner's
  two-step deal — were the FLIP click-through on hover, a single stack-click
  would land on the centred FLIP + deal early (caught by the draw FT).
- Spectator persist wired in my_sea_visit.html (click a stack → `--active`,
  click elsewhere clears); its FLIP stays `.btn-disabled` (read-only).

SPREAD CARDS — the same hover-glow + active-persist now on EVERY spread card,
building on the cover/cross rules. The prior `.sea-card-slot--focused` glow
(0-1-0) was silently overridden by the filled-card drop-shadow ladder (up to
0-4-0) and never rendered (verified live); `!important` (consistent w. the
existing `opacity:1 !important` there) makes the halo win on hover + focus. The
halo is symmetric (rotation-invariant). No colour change — box-shadow only.

DECK FACE HALO — the levity + gravity stack glows now mirror the card halo's
tuned geometry (0.5rem blur / 0.5rem spread / 0.3 alpha), each in its own
polarity colour (--ninUser / --quaUser); single keeps its own tone.

Verified live in Firefox: deck FLIP persists on click + fades on hover; the card
halo wins over the drop-shadow on hover/focus across crown/cover/(reversed-)cross;
levity/gravity deck glows match the card halo. Draw FTs green (single-draw, hand-
completion, AUTO DRAW, auto-drawn-slot reopen) — the two-step deal + card focus
survive the display→opacity switch.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:11:39 -04:00
Disco DeDisco
7e876557aa my-sea spectate: broadcast spread on modal-close + sequence the spectator's AUTO DRAW reveal — TDD
Two follow-ons to the spectate spread-sync, both over the `mysea_<owner>` consumer:

SPREAD ON MODAL-CLOSE — the spread only reached spectators piggy-backed on the
first `sea_draw`, so a visitor sat on a stale layout until a card landed. The
owner's SPREAD modal now broadcasts her chosen spread the moment she closes it
(backdrop / Escape / guard-OK) — before any draw:
- new hand-less `sea_spread` event: view `_notify_sea_spread` → consumer relay →
  visitor `_applySpread` (re-lays-out `[data-spread]` + re-captions, no hand
  touched).
- new POST `/gameboard/my-sea/spread` the modal-close handler calls, guarded by
  `_lastSpread` so re-opening + closing without a change doesn't re-broadcast.
  When an active row has an EMPTY hand it also persists the spread onto the row
  (so a fresh spectator load lands right too) — stays within the "spread locks
  at first card" policy; never overwrites a drawn hand's spread.

SEQUENCED AUTO DRAW — AUTO DRAW commits all six cards in ONE POST (navigate-away
safety) → one `sea_draw` carrying the whole hand, so the spectator saw them pop
in at once ("async as intended, but not in sequence"). The visitor's `_applyHand`
now reveals only the freshly-added entries, one per ~420ms tick (in DRAW_ORDER,
first immediately) — a lone manual-draw card still reveals instantly. Already-
shown cards (`_isShown` by slot card-id) are left untouched, so a cumulative
re-broadcast never re-animates.

Coverage:
- ITs: MySeaSpreadBroadcastViewTest — login/405/unknown-spread guards, broadcast
  call, empty-hand persist, no-overwrite-of-drawn-spread, broadcast-failure
  resilience.
- channels: spectate consumer relays the hand-less `sea_spread` event.
- Live-verified in Firefox: a 3-card hand fills 1 slot synchronously then the
  rest after the stagger; user visually confirmed the full deal sequence + the
  modal-close spread propagation.

311 gameboard ITs + 7 spectate channels green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:45:00 -04:00
Disco DeDisco
9678d187b4 my-sea spectate: live spread-sync + owner seat-ring push + visit caption fix — TDD
Three fixes to the my-sea spectator (bud-sea), all flowing over the existing
`mysea_<owner>` spectate consumer:

VISIT CAPTIONS (.sea-pos-label) — two bugs left every CROWN/COVER/… caption
blank on my_sea_visit:
- empty-hand: `label_by_position` was built from `latest_draw_slots`, which
  returns [] when the owner's hand is empty (only a significator placed) — so
  an owner mid-setup showed no captions, while her OWN my_sea (whose JS seeds
  labels from the POSITION_LABELS constant) showed them. Now the view pulls
  captions straight from POSITION_LABELS[spread], drawn-cards-independent.
- `--seciUser` typo (used once, never defined) → invalid colour dropped → the
  labels inherited the body colour, contrasting on some palettes but blending
  into the felt on others (read as "missing"). → `--secUser`.

SPREAD-SYNC — the owner's live draw pushed only the hand, not the spread, so a
post-DEL spread switch landed the new cards into the OLD spread's cells (the
asymmetry the user hit: owner on desire-obstacle-solution, visitor still laid
out as escape-velocity). The spread now rides each `sea_draw` broadcast;
`_applySpread` re-sets `data-spread` (CSS keys cell visibility off it),
re-captions from a server-sourced POSITION_LABELS json_script, + clears stale
fills before `_applyHand` repopulates against the right layout.

OWNER-SIDE LIVE SEAT PUSH — the owner's my_sea now subscribes to her own
spectate WS for `sea_seats`, so visitors arriving (deposit → 2C-6C) / leaving
(BYE) appear without a refresh, same broadcast the spectators get. The visit
page's inline `_renderSeats` is hoisted into my-sea-seats.js as the shared
`mySeaRenderSeats(seats, myToken)` (+ `mySeaConnectSeatRing`); each page passes
its own self-token (owner page passes '' — her 1C isn't --self server-side).

Coverage:
- ITs: MySeaVisitEmptyHandLabelsTest (captions present + rendered for an empty
  hand); MySeaLockHandViewTest broadcast test asserts the spread arg; spectate
  consumer test asserts the hand+spread relay (channels).
- Jasmine: 6 new MySeaSeatsSpec cases for mySeaRenderSeats (per-seat rebuild,
  --self by token, owner-page no-self, no-duplicate re-render, one-shot flare).
- Live-verified in Firefox: captions paint khaki on the brown palette; a
  desire-obstacle-solution sync flips data-spread + relabels Solution/Obstacle/
  Desire + hides leave/cover/lay.

[[feedback-jsonfield-exclude-sqlite-null]] not implicated; spread map is a plain
dict lookup. 304 gameboard ITs + Jasmine green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:35:18 -04:00
Disco DeDisco
877e0f544a my-sea: seated chairs settle to --secUser; owner sees all seated members; gear menu column; visit hex scales — TDD
Four my-sea / my_sea_visit fixes from user feedback.

1. Seated-chair snap-back: `.my-sea-landing .table-seat.seated .fa-chair`
   forced PERMANENT --terUser + --ninUser glow, out-specifying _room.scss's
   --secUser settle — so a seated chair eased in (the .seat-just-seated flare)
   then SNAPPED back to the glow. Removed it; the steady look is now the
   _room.scss --secUser as spec'd. The viewer's --self marker moves off the
   chair onto the position label so the chair can rest at --secUser.

2. Owner multi-seat: my_sea.html's landing rendered a hardcoded 1C-only seat
   loop, so the owner only ever saw herself even after refresh. It now renders
   the shared `_my_sea_seats(request.user)` ring — owner 1C + present visitors
   2C-6C — the same list the spectator + broadcasts use. (Live owner-side push
   is a follow-on; this fixes the on-refresh case.)

3. Gear sea menu: NVM + BYE laid out in a ROW because the BYE form is
   display:contents + applets.js force-sets the menu to display:block on open
   (can't flex the menu itself). Wrap them in the shared `.menu-btns` flex
   container and override it to a COLUMN in portrait / ROW in landscape (DRY —
   same container the room/applet menus use).

4. Visit hex scale: my_sea_visit didn't load room.js, so scaleTable() never ran
   and the table-hex rendered unscaled (unlike the owner's my_sea). Load room.js
   on the visit page too.

62 gameboard ITs (gear NVM + owner-seat + visit) green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:04:04 -04:00
Disco DeDisco
0693a422d2 my-sea: second spectate broadcast — seat ring updates live on deposit / BYE — TDD
Extends the async-witness WS so a visitor joining (deposit) or leaving (BYE)
pushes the seat ring to the other watchers — they see members come + go without
a refresh, same channel as the live draw.

- views.py: `_my_sea_seats(owner)` extracted (owner 1C + present invitees 2C-6C
  by deposit order, sans per-viewer is_self) — used by BOTH the my_sea_visit
  render (layers is_self on) AND a new guarded `_notify_sea_seats(owner_id)`
  broadcast. Fired from my_sea_visit_insert_token (seat taken) +
  my_sea_visit_leave (seat freed).
- consumers.py: MySeaSpectateConsumer gains a `sea_seats` handler.
- my_sea_visit.html: the WS client re-renders the `.table-seat` ring from a
  `sea_seats` message, re-marking the viewer's own --self chair via the embedded
  seat token + re-firing the one-shot seated glow (localStorage-gated).

Tests: +1 channels relay IT (sea_seats received) + 2 view ITs (deposit / BYE
each broadcast the ring). Existing multi-seat ITs stay green on the refactored
helper. Client re-render needs live 3-party verification on staging.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:42:22 -04:00
Disco DeDisco
32836704b7 my_sea_visit: add --visible on live-filled spectator slots so the card lands at final opacity (no empty->filled ease race)
register's _fillSlot sets --filled (opacity:0) but not --visible — the owner
adds --visible on stage-dismiss, but the spectator fills directly w. no modal,
so the live card raced the empty->filled opacity transition (the long-standing
my_sea ease-in-before-ease-out glitch). Add --visible in the same tick after
register so the card matches the refreshed state: instant for the outer slots,
the intended sea-cover/cross-appear fade for cover/cross.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:36:59 -04:00
Disco DeDisco
01ee8dc1fb my-sea: async witness — spectators see the owner's draw land live via WS, no refresh — TDD
The bud watching @owner's sea now sees each card appear in real time instead of
having to refresh. Follows the epic RoomConsumer broadcast pattern (view ->
group_send -> consumer handler -> send_json), keyed on the owner (mysea_<owner>)
since my-sea has no Room.

- apps/gameboard/consumers.py: MySeaSpectateConsumer — read-only WS; membership
  gate matches voice (owner OR present invitee: ACCEPTED + deposited + not
  left). Relays a `sea_draw` event carrying the owner's full hand.
- apps/gameboard/routing.py + core/asgi.py: ws/my-sea-spectate/<owner_id>/.
- gameboard/views.py: _notify_sea_draw(owner_id, hand) — best-effort, guarded
  group_send so a down/missing channel layer can't break the solo draw. Fired
  from my_sea_lock (both the create + the mid-draw-upsert branch) and from
  my_sea_delete (empty hand -> clears the spectators' cross).
- my_sea_visit.html: a WS listener fills the cross live — SeaDeal.register(card,
  '.sea-pos-'+pos, isLevity) reuses _fillSlot (incl. the --rank-long squeeze) +
  seeds the slot clickable into the stage; a DEL re-empties cleared slots.
  Capped reconnect for transient blips.

Tests: 5 channels ITs (owner/present-invitee connect + receive; unauth /
stranger / accepted-not-present rejected); +2 view ITs (lock broadcasts owner+
hand; lock still 200s when the broadcast raises). Client fill needs live
two-party verification on staging (Redis up).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:14:48 -04:00
Disco DeDisco
a85f5b6f44 my-sea slot: render the 5+-char numeral squeeze (--rank-long) server-side so it survives a refresh — TDD
sea.js's _fillSlot adds .sea-card-slot--rank-long on draw (corner_rank length
>= 5) to squeeze long Roman numerals (XVIII, XLVIII, ...) into the slot, but
_my_sea_slot.html didn't — so a saved hand stretched the numeral back out on
refresh (server render). Add the same length>=5 class server-side. Fixes both
the owner picker + the spectator cross (shared partial). +3 ITs (long / short /
boundary).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:06:19 -04:00
Disco DeDisco
260c1c1325 my_sea_visit: give the visitor a top-left deck stack (owner's deck) with a hover-revealed disabled FLIP — TDD
Mirrors the owner's deck stack onto the spectator hex, DRY:
- new shared _my_sea_deck_stack.html partial (mono DECK / dubbo DECKS by
  is_polarized) rendered by BOTH the owner picker (my_sea.html, flip_disabled=
  hand_complete) AND the visitor cross (flip_disabled=True). Owner markup is
  byte-identical, so its assertions hold.
- the visitor's stack uses the OWNER's deck (everyone at @owner's sea plays the
  owner's deck — the visitor's own equipped deck is irrelevant), pinned
  top-left (--visit) across the table from the owner who deals bottom-right.
- dubbodeck: the Gravity/Levity name flips above the face + upside-down to
  signal someone across the table is dealing.
- the read-only FLIP (disabled ×) is hidden until its stack is hovered/focused,
  then eases in (same opacity 0->1 over 0.3s as the shared flip-btn-base
  reveal; inlined since _gameboard precedes _card-deck in the import order) so a
  permanent × doesn't clutter the stack.

ITs: stack keys on the owner's deck (not the viewer's), dubbo renders 2 named
stacks, FLIP is the disabled state, no stack when the owner has no deck. Owner
deck-stack IT + FT stay green (identical markup).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:01:21 -04:00
Disco DeDisco
c3594d27ed my_sea_visit: share the owner picker styling (--duoUser felt + fill) when showing the draw — TDD
The bud-sea (visitor) VIEW DRAW rendered the cross but kept data-phase=landing,
so it sat on the --priUser landing bg instead of the owner's --duoUser picker
felt, and its #id_my_sea_visit_draw wrapper wasn't a flex container so the
picker didn't fill/centre like the owner's.

DRY fix (no new visit-only styling):
- VIEW DRAW toggle now flips .my-sea-page data-phase landing<->picker, so the
  cross reuses the shared .my-sea-page[data-phase=picker] --duoUser felt rule.
- .my-sea-visit-draw is display:contents, so its .my-sea-picker child becomes a
  direct flex item of .my-sea-page and fills/centres via the existing
  .my-sea-picker sizing.

FT asserts the page flips to data-phase=picker on VIEW DRAW.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:42:00 -04:00
Disco DeDisco
02d2d565a3 RWS card faces: thin the contour stroke for the english deck so it doesn't double the card's own border
The image-mode contour stroke (4 cardinal drop-shadows) follows the PNG alpha.
Minchiate faces are transparent-cut to their irregular outline, so the stroke
cleaves to the shape; RWS faces are clean cream rectangles that carry their own
printed border, so the full 0.2rem stroke reads as a redundant uniform double-
frame. Rather than re-process 78 images, thin the stroke for the equipped
english deck via CSS only.

- _card-deck.scss: the cardinal-stroke offset is now `var(--img-stroke-w,
  0.2rem)` (default unchanged for Minchiate/Earthman); `body.deck-family-english`
  sets it to 0.08rem (a crisp edge, not a frame). Custom props inherit, so the
  body class cascades into every image card on the page.
- base.html: body gains `deck-family-<equipped-deck-family>` when authenticated
  with an equipped deck.

Aesthetic polish — may be reverted (revert = drop the body class + the
`--img-stroke-w` var + the english rule).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:28:11 -04:00
Disco DeDisco
c4e738ad16 my-sea voice: persist the call across in-sea reloads via auto-rejoin; pngquant the RWS card back — TDD
Voice-persistence follow-up (user-spec item 6). Every my-sea navigation is a
full page reload that kills the WebSocket + peer connections; true no-reload
nav would need an SPA refactor of the heavily-tested draw IIFEs. Instead we
auto-rejoin: bindVoiceBtn remembers the active room in sessionStorage on join
and silently re-joins it on the next my-sea page if voice is still available
there (mic permission persists for the session, so no prompt). Same user-
visible result (a brief reconnect, not seamless) with no risk to the draw flows.

- burger-btn.js: sessionStorage 'mysea-voice-room' remember/forget helpers +
  window.mySeaVoiceForget; bindVoiceBtn refactored to startCall()/withVoiceRoom()
  and auto-rejoins on bind when the remembered room === the active btn's room.
  A failed join (e.g. INSECURE_CONTEXT) forgets the room so it doesn't retry.
- _my_sea_gear.html: the NVM-disconnect guard confirm + BYE forget the room
  (and leave the mesh) — an explicit leave shouldn't auto-rejoin.
- BurgerSpec: +4 auto-rejoin specs (match / different-sea / inactive / remember
  + forget). 438 Jasmine specs green.

Also (bundled, user's parallel work): pngquant the resaved RWS deck card back
(tarot-rider-waite-smith-back.png) from 733KB truecolor+a to a 264KB 8-bit
palette PNG, matching its companion card faces. Dimensions preserved (the
rotated 401x694).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:08:45 -04:00
Disco DeDisco
2cbc1bf292 my-sea spectator: render all present members on the hex (2C-6C), not just the viewer — TDD
The spectator hex showed only owner 1C + the viewer in 2C; other present
visitors were invisible. The view now builds a  list — owner 1C + each
present invitee in 2C-6C by deposit order (capped at MY_SEA_MAX_VISITORS) — so
every viewer sees the same absolute seating, with their own seat marked
.table-seat--self (a subtle --terUser tint).

- my_sea_visit:  context (present/empty + token + label + is_self).
- my_sea_visit.html: seat ring loops  instead of a hardcoded 1C/2C.
- _room.scss: .table-seat--self chair tint.
- +1 IT (3 present visitors → 2C-4C seated, viewer is the --self one); the
  both-seated IT updated for the --self marker. 292 gameboard ITs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:01:23 -04:00
Disco DeDisco
cb7ca4b5f3 voice consumer: cap live connections at 6 per room (defense-in-depth) — TDD
The deposit gate already caps PRESENCE at 5 visitors (+owner = 6), so voice
membership is bounded — but a present member opening multiple tabs could still
oversubscribe the mesh. RoomVoiceConsumer now claims a slot on connect via an
atomic cache counter (cache.incr — atomic on Redis + LocMem) and refuses past
VOICE_MAX_MEMBERS=6; the slot frees on disconnect (26h TTL backstop so a leaked
slot self-clears). Room-agnostic, so epic rooms inherit it.

+1 channels test: 6 connections fit, the 7th is refused, a disconnect reopens a
slot. 9 voice consumer channels tests green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:56:24 -04:00
Disco DeDisco
92d46b3dce my-sea voice: volume-reactive equalizer glow + muted (--priRd/.fa-ban) state — TDD
Phase 5b of the my-sea voice batch. Resolves the Bug-B-vs-item-7 conflict per
user call 2026-05-29: a click while connected MUTES (leaving stays on BYE/NVM),
and the item-7 "--priRd/.fa-ban disconnected" visual maps onto the MUTED state.
Replaces Phase 3's 2x-pulse stand-in with a real Web-Audio equalizer.

- voice-mesh.js: an AnalyserNode taps each incoming peer stream (lazy
  AudioContext); `inputLevel()` returns the loudest current RMS (~0..1) across
  peers. Analysers torn down per-peer + on call end.
- voice-glow.js: the live-mic glow now resolves to — alone → `.voice-pulse`
  (steady 2s cadence); others connected → `.voice-eq`, whose `--voice-level`
  CSS var is fed each frame from inputLevel() via a self-stopping rAF loop;
  muted → `.voice-muted` modifier on either (recolor only). rAF cancelled on
  destroy.
- _burger.scss: `.voice-eq` box-shadow spread+alpha scale with `--voice-level`
  (no keyframe — audio drives it); `.voice-muted` recolors to --priRd (halo
  stays --ninUser) + flips the voice sub-btn icon to .fa-ban. Drops the unused
  `.voice-pulse--fast`.
- VoiceGlowSpec: +4 specs (equalizer swap, muted-alone, muted-with-others,
  unmute clears); VoiceMeshSpec: +1 (inputLevel 0 with no analysers). 438
  Jasmine specs green.

Live-verify on staging (audio can't be auto-tested): the equalizer reacting to
real speech + the muted/unmuted colour swap on a live call.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:46:08 -04:00
Disco DeDisco
f0b9f02c7c my-sea voice: cap visitors at 5 (6 seats) + free a seat on leave — TDD
Phase 5a of the my-sea voice batch (user-spec 2026-05-29). The owner holds 1C;
at most 5 visitors fill 2C–6C, which also caps the voice mesh (voice requires a
deposited seat, so seat-capping caps membership).

- SeaInvite: MY_SEA_MAX_VISITORS=5 + present_count(owner) / table_has_room(owner)
  classmethods (present = ACCEPTED + deposited + not LEFT).
- my_sea_visit_insert_token: a fresh deposit into a full table is bounced
  (?full=1, no token spent, no seat); a visitor who BYEs frees their seat
  (is_present → False) for the next visitor.
- my_sea_visit_gate:  context → the gate shows 'TABLE FULL' + inert
  rails instead of INSERT TOKEN for a not-yet-present visitor.
- 6 capacity ITs (count/room, full-table bounce, leave-frees-seat, gate flag,
  already-seated not blocked). 291 gameboard ITs green.

Remaining Phase 5 (live-verify / needs a spec call): disconnect visuals
(--priRd/.fa-ban, item 7) + the true Web-Audio equalizer (item 5) + consumer-
level voice-member enforcement + multi-seat (3C–6C) spectator viz.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:35:22 -04:00
Disco DeDisco
da97c623c9 voice: fail loud on insecure-context join (mic blocked over HTTP) instead of silent reject — TDD
getUserMedia is only exposed in a secure context (HTTPS, or the localhost
exemption). Reached over plain HTTP on a LAN IP — the dev server from a phone
at http://192.168.x.x:8000 — iOS/Android leave navigator.mediaDevices
undefined, so join() fetched TURN creds (a confusing 200) then silently
rejected deep in a .then() with no mic prompt. Desktop works because
127.0.0.1 IS a secure context. (Not a regression — voice never worked on the
HTTP dev server from mobile.)

- voice-mesh.js: _micSupported() seam + an early INSECURE_CONTEXT reject in
  join() before any network, so the failure is fast + diagnosable.
- burger-btn.js: bindVoiceBtn catches the rejected join, rolls back the
  optimistic .in-call/dataset.inCall (so the next click retries), and surfaces
  a Brief — 'Voice needs HTTPS (or localhost) — your browser blocked the mic
  here.' — instead of failing invisibly.
- VoiceMeshSpec: +2 specs (join rejects INSECURE_CONTEXT; btn rolls back +
  Briefs on reject). Jasmine green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:29:42 -04:00
Disco DeDisco
6799749ede my-sea voice: guard before NVM disconnects voice + harden mute (honor pre-stream mute) — TDD
Phase 4 of the my-sea voice batch (user-spec 2026-05-29).

── Voice-disconnect guard (item 6, the achievable slice) ──
Every my-sea navigation is a full page reload, which tears the WebRTC mesh
down — so voice can't literally persist across a reload without an SPA-style
no-reload nav (a separate, larger refactor, deferred). What ships now: the
gear-menu NVM warns before dropping the call.

- _my_sea_gear.html: the NVM routes through an inline, dependency-free
  `mySeaGuardedNav(event, url)`. When voice is LIVE (VoiceRoom.localStream) AND
  the target leaves my_sea (`url` has no `/my-sea`), it pops the shared guard
  portal — "Leave the Sea? You'll disconnect from voice." — before navigating;
  confirm proceeds, dismiss stays. NVMs that stay within my_sea, or any nav
  with no live mic, go straight through. Covers all NVMs (owner my_sea, the
  gatekeeper, the spectator) since they all include this partial.

── Bug B: desktop mute (mute robustness) ──
- voice-mesh.js: extracted `_applyMute()` and call it from join (post-
  getUserMedia) as well as toggleMute. On desktop the first join pops a mic-
  permission prompt; a mute toggled while that prompt is open used to be lost
  because the stream didn't exist yet — re-applying after it resolves makes the
  mute stick. Teardown resets `muted=false` so a rejoin starts clean.
- voice-glow.js: setVoiceState now syncs the voice btn's own flags
  (.in-call / .muted / dataset.inCall) to the mesh truth, so a rejoin starts
  clean and the glow's DOM fallback can't get stuck "live".

Tests: +5 VoiceMeshSpec mute specs (incl. the pre-stream-mute Bug-B case);
+2 NVM-guard FTs (warn when live / pass through when not); 3 gear-NVM ITs
updated for the mySeaGuardedNav markup. 286 gameboard ITs + 433 Jasmine specs
green.

Note: true cross-view voice persistence (no-reload within-my_sea nav) + the
desktop-mute live confirmation remain to verify on staging w. Redis up.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:22:21 -04:00
Disco DeDisco
b021d8017c my-sea voice: voice-btn glow/pulse state machine (sea-precedence, pulse-while-alone, 2x on 2nd party) — TDD
Phase 3 of the my-sea voice batch (user-spec 2026-05-29). A --quaUser/--ninUser
glow + pulse machine for the burger btn + its voice sub-btn, driven by the
voice sub-btn availability (voice_active) + the live mesh state.

- voice-mesh.js: VoiceRoom gains a state-change hook — setOnStateChange(cb) +
  peerCount() + _notify({inCall, peerCount, muted}), fired on join, every peer
  add/drop, mute toggle, and teardown. No behaviour change without a subscriber
  (VoiceMeshSpec stays green).
- voice-glow.js (new): the glow machine. PRE-JOIN nudge — burger glows when the
  fan is closed (sea draw-nudge keeps burger precedence; voice reclaims it once
  the sea glow clears), voice sub-btn glows when the fan opens. LIVE — the glow
  PULSES on whichever surface shows (voice sub-btn fan-open, burger fan-closed):
  base 2s cadence while alone, doubled (.voice-pulse--fast) once a 2nd party
  connects (equalizer stand-in; a true volume-reactive equalizer is a live-only
  enhancement). Class writes are reconciled (idempotent) so the burger-class
  MutationObserver doesn't feed back on itself.
- _burger.scss: .voice-glow + @keyframes voice-pulse + .voice-pulse(--fast).
- loaded on my_sea.html + my_sea_visit.html (after burger-btn.js).
- VoiceGlowSpec.js (18 specs) + registered in SpecRunner; MySeaSeatsSpec flare
  window updated 1.5s → 2s (Phase 2 bump). 428 Jasmine specs green.

Live-verify on staging: the actual glow colours/cadence + the equalizer
upgrade (item 5) and disconnect states (item 7) land in later phases.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:06:02 -04:00
Disco DeDisco
7bd8e3256a my-sea spectator: render owner's draw as the identical interactive cross stage; owner seated in 1C when paid OR drawn — TDD
Phase 1 + 2 of the my-sea spectator/voice batch (user-spec 2026-05-29).

── Phase 1: spectator VIEW DRAW parity ──
The visitor's VIEW DRAW rendered _my_sea_readonly_draw.html — a flat
`.my-sea-scroll` strip that, out of its applet context, blew a single card up
to fill the viewport. It now renders the SAME `.my-sea-cross` picker +
`_sea_stage` modal the owner sees, populated from the owner's draw, read-only
but fully interactive (click card → magnified stage, hover, SPIN, FYI). No
FLIP / DEL / AUTO DRAW / deck-stacks / spread combobox — the visitor watches.

- `_saved_by_position(saved_hand)` extracted as a shared helper (owner picker
  + spectator render build the IDENTICAL cross); my_sea refactored onto it.
- my_sea_visit context gains `saved_by_position`, `label_by_position`,
  `default_spread`, and the OWNER's `sea_deck_data` (so sea.js resolves each
  clicked slot's full card face for the stage).
- new `_my_sea_visit_cross.html` mirrors the owner cross + includes `_sea_stage`
  under `#id_sea_overlay`; my_sea_visit.html embeds the owner deck JSON + loads
  stage-card.js + sea.js + a trimmed seed IIFE (reconstructs SeaDeal's
  `_seaHand` from the filled slots so each card is clickable into the stage).
- deletes the obsolete `_my_sea_readonly_draw.html`.

── Phase 2: owner 1C seating ──
The owner is "seated" in 1C whenever committed to a draw cycle — paid for one
(deposit reserved / paid-through credit) OR partially/completely drawn — not
only once a card lands. Previously a paid-but-undrawn owner (the PAID DRAW
landing) and the visitor's view of her showed the semi-opaque `.fa-ban`
default. Seat 1C now carries persistent `.seated` + `.fa-circle-check`
(sync on refresh; the one-shot flare just settles into it).

- my_sea: new `seat1_seated = hand_non_empty or show_paid_draw`; my_sea.html
  seat 1C keys on it (class + data-seat-token + status icon).
- my_sea_visit: `seat1_present = owner drawn OR owner paid` so the visitor
  sees the owner seated on the spectator hex under the same conditions.
- seat flare bumped 1.5s → 2s (my-sea-seats.js GLOW_MS + _room.scss keyframe).

Tests: +2 spectator-cross ITs, +1 spectator-cross FT (Phase 1); +4 owner-seat
ITs, +2 visitor both-seated/owner-seated ITs, +1 owner-seating FT (Phase 2).
286 gameboard ITs/UTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:48:31 -04:00
Disco DeDisco
1ac380dfc5 my-buds async add: insert new row before .applet-list-buffer, not after — keeps spacer last
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
`_appendBudEntry` queried `.bud-entry-buffer` (a class that doesn't exist — the
shell renders `.applet-list-buffer`), so the lookup missed and the row fell
through to appendChild, landing BELOW the trailing spacer <li> and leaving a
visible gap between the list and the new bud. Query the real class so the new
row inserts before the spacer. FT now asserts the buffer stays last-child.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:56:03 -04:00
Disco DeDisco
af8452f22d my-buds async add: render full row (anchor + the <Title> + data-tt-* attrs) so the appended entry's tooltip isn't empty — TDD
Adding a bud appended a stripped `@handle`-only <li> — no `applet-list-entry`
class, no bud-page anchor, no ` the <Title>` span, no data-tt-* attrs — so the
new row rendered without its title and its tooltip came up empty next to the
server-rendered rows above it.

- add_bud (billboard/views.py) — bud payload now carries `at_handle` (server-
  computed; the client can't replicate at_handle's truncate_email fallback for
  username-less buds) + `title` (active_title_display). IT asserts both.
- _bud_add_panel.html `_appendBudEntry` — rebuilt to mirror _my_buds_item.html
  exactly: both classes, data-tt-title/description/email/shoptalk, the
  bud-name anchor into /billboard/buds/<id>/, and the trailing ` the <Title>`.

New FT pins the regression: appended row carries the attrs + anchor + title and
its portal populates non-empty on row-lock. AddBudViewTest + append FT green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:56:31 -04:00
Disco DeDisco
3bf35ad539 remove dead my-sea invite accept/decline endpoints — acceptance is now implicit on bud-page sea-btn visit (accept-on-GET)
The @mailman Post's OK/BYE block + _invite_actions.html were dropped by the
bud-landing-page sprint; with accept-on-GET on my_sea_visit shipped in f5ee83b
the explicit endpoints have no trigger left. Removes:

- my_sea_invite_accept / my_sea_invite_decline views + the _sea_invite_for_request
  / _redirect_to_invite_log helpers they alone used (gameboard/views.py)
- the my-sea/invite/accept + my-sea/invite/decline URL routes (gameboard/urls.py)
- _invite_actions.html partial (already un-included from post.html)
- MySeaInviteAcceptDeclineTest (gameboard ITs); MySeaInvitePostRenderTest now
  asserts the form actions are gone by literal path/class instead of reverse()

There is no decline surface now — an un-clicked invite simply lapses after 24h.
post.html comment trimmed to match. 515 gameboard+billboard ITs/UTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:52:38 -04:00
Disco DeDisco
f5ee83be0a bud page sea-btn cascade: live-invite window + accept-on-GET + glow handoff; my-buds tooltip clamp + row hover/lock — TDD
Fixes the Bill Bud invite cascade so the sea sub-btn actually lights + leads
to the bud's my_sea, and gives the My Buds row tooltip viewport clamping +
hover/lock styling.

SeaInvite.invitee_access_open (gameboard/models.py): new invitee-facing
access window — a non-terminal invite (PENDING/ACCEPTED) within 24h of being
proffered OR within 24h of the invitee's last gate token deposit. Re-arms on
each deposit; DECLINED/LEFT/EXPIRED stay shut. Distinct from is_expired
(which only models the PENDING lapse). 8 UTs.

bud_page (billboard/views.py): sea_btn_active / sea_first_draw_pending now key
on invitee_access_open across PENDING + ACCEPTED, not PENDING-only. Old design
darkened the btn the instant the user accepted, so they could never reach the
bud's sea from here post-accept — that was the red .fa-ban the user saw. ITs
updated: accepted-within-window now lights; added stale-accepted-dark +
recent-deposit-relights cases.

my_sea_visit (gameboard/views.py): accept-on-GET — a still-pending, non-expired
invite from the owner to the visitor is accepted implicitly on arrival (the
sea-btn cascade + @mailman post-attribution anchor both land here, so the click
IS the acceptance). Previously PENDING → 403, so the cascade dead-ended. ITs:
pending-invitee now auto-accepts (200); expired-pending still 403s; stranger
still 403s.

bud.html: burger → sea_btn glow-handoff machine (the my_sea.html cascade minus
the spread-modal stage) so the glow rides the affordance chain to the click
target; active sea click clears glow, preserves .active, navigates.

my-buds-tooltip.js: clamp the position:fixed #id_tooltip_portal to the viewport
on row-lock — same 1rem-inset shape as game-kit.js / sky-wheel.js / wallet.js
(measure after .active, clamp left, prefer above / flip below). Reset on clear.

_billboard.scss: .bud-entry hover + .row-locked highlight (rows aren't
.row-3col so the existing rule missed them) — fill --secUser, flip the
--terUser handle to --quiUser, trailing title to readable --priUser.

520 billboard+gameboard ITs/UTs green; affected sea-btn-cascade FT green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:36:25 -04:00
Disco DeDisco
d87f26003b CI: wrap test-two-browser-FTs commands in _retry_failed.sh
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Pipeline #351 hit a NoSuchWindowException / browsing-context-discarded flake on the LAST channels FT (test_first_done_polarity_sees_other_group_settling_message) — typical cumulative-Firefox-memory-pressure failure on a multi-browser test run as the 22nd in its bucket. Test passes locally and in isolation; no code regression.

The other two FT stages (test-FTs-room, test-FTs-non-room) already route through `_retry_failed.sh`, which parses Django's FAIL:/ERROR: lines from stdout and re-runs only the failed labels. Wrapping the three two-browser-FTs commands (two-browser / sequential / channels tags) in the same script gives the channels suite the same flake recovery without slowing the happy path (first-run-green short-circuits to exit 0).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:32:04 -04:00
Disco DeDisco
b563e96f82 RWS deck: flip has_card_images=True to light up image-mode rendering — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
The 79 resized + pngquant'd RWS card-face PNGs at cards-faces/english/rider-waite-smith/ are now in place (commit 1e1a0a5). This data migration sets the `tarot-rider-waite-smith` DeckVariant's `has_card_images` flag to True so the existing image-mode branches across all 6 card+stat-block surfaces (sprint A.0-A.8 of [[project-image-based-deck-face-rendering]]) light up for RWS the same way they already do for Minchiate:

- card face becomes the .png; rank + suit + name + qualifier + arcana all migrate to the adjacent stat block
- deck-stack icon (_deck_stack_icon.html) uses the RWS card-back PNG instead of the SCSS placeholder rect-fill
- monodeck collapse still applies (is_polarized stays False, set by 0012); my_sea picker renders a single deck stack instead of dual gravity/levity halves

Inverts the IT `test_rws_has_card_images_false` → `test_rws_has_card_images_true`. 1475 ITs green; full epic suite (480) green.

No template change needed — the {% if deck.has_card_images %} branches were shipped in sprints A.3-A.7.5 for Minchiate and are deck-agnostic.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:01:57 -04:00
Disco DeDisco
1e1a0a5ab8 deck images: resize Minchiate + RWS to 700px height + re-pngquant; drop orphan MySeaInviteAcceptanceLogTest — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Card art was loading slowly enough on fast networks that animations were firing on empty <img> objects. Photoshop pass dropped both decks to 700px height (~50% linear from Minchiate's prior 1024px and ~57% from RWS's prior 1646px), then pngquant `--quality=65-85 --speed=1 --strip --skip-if-larger` reclaimed the per-pixel bit-rate cost of the fresh PSD save:

  Minchiate: 36.5 MB → 19.1 MB (avg 381 KB → 199 KB)  [-48%]
  RWS:       76.3 MB → 15.8 MB (avg 989 KB → 205 KB)  [-79%]
  Combined:  112.8 MB → 35 MB across 177 cards         [-69%]

RWS dropped the most because it was previously at 960×1646 vs Minchiate's 615×1024 — the higher source resolution had more room to give up. 700px height @ ~420px width is still 2× Retina coverage for the Sea Stage modal (the largest display surface at ~350px wide on desktop). No code change needed — `TarotCard.image_url` resolves the same filenames; the swap is purely on-disk.

Drop `MySeaInviteAcceptanceLogTest` (whole class, single test_invitee_sees_invite_line_with_ok_bye method) from functional_tests/test_game_my_sea.py — orphaned by the bud landing page sprint that removed `.invite-ok-btn` / `.invite-bye-btn` / `.invite-actions` from the @mailman invite Line unconditionally. The salvageable "invites you to" prose assertion is now covered by functional_tests/test_bill_mailman_invite_post.py::MailmanPostStructureTest plus apps/billboard/tests/integrated/test_mail.py::LogSeaInviteTest::test_prose_interpolates_owner_handle_and_default_possessive.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:52:14 -04:00
Disco DeDisco
6cc11924e3 bud landing page: /billboard/buds/<id>/ + my_buds tooltip portal + @mailman post-attribution anchor — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Replaces the @mailman invite Line's inline OK/BYE block w. a dedicated per-bud surface. Three new FTs (test_bill_bud_page, test_bill_my_buds_tooltip, test_bill_mailman_invite_post — landed red 2026-05-27 PM) drive: per-bud landing page rendering 4-btn apparatus + shoptalk textarea + invite-cascade glow handoff; my-buds row tooltip portal w. .tt-title/.tt-description/.tt-email/.tt-shoptalk/.tt-milestone slots; mailman Brief surfacing on any authenticated page-load via context processor + base.html JSON-script.

Models: new `BudshipNote(user, bud, shoptalk[CharField max=160], edited_at)` w. unique_together — per-relation personal note about a bud, never visible to the bud. Lazy-created on first shoptalk save so absence of a row reads as 'never edited' (drives .tt-milestone slot presence).

URLs (billboard): `buds/<uuid:bud_id>/` (bud_page), `buds/<uuid:bud_id>/shoptalk` (save_bud_shoptalk), `buds/<uuid:bud_id>/delete` (delete_bud).

Views: bud_page auto-adds the bud on first visit (mirrors share_post implicit-add); resolves `pending_invite` as non-expired PENDING SeaInvite(owner=bud, invitee=request.user) → drives `sea_btn_active` + `sea_first_draw_pending` flags that _burger.html already reads on my_sea + room. my_buds enriches each bud w. `.shoptalk_text` + `.milestone_dt` so the row template can render data-tt-* attrs without an extra template tag.

mail.py: INVITE_TEMPLATE now interpolates `owner_id` into an `<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>` wrapper around the owner's handle. post.html's existing safe-filter branch (gated on author username == 'mailman') passes it through unescaped. Removed the {% if line.sea_invite %} include path — _invite_actions.html left in place for archival.

Templates: new bud.html (header + shoptalk form + apparatus + gear + burger fan + sea_btn nav inline JS); new _bud_gear.html (NVM→my_buds, DEL→guard portal "Delete this bud?" → POST delete_bud); new _bud_tooltip.html (portal w. .tt-* slots); _my_buds_item.html wraps `@handle` in an anchor to bud_page + carries data-tt-* attrs + " the {{ active_title_display }}"; my_buds.html includes the tooltip portal + loads my-buds-tooltip.js.

JS: new my-buds-tooltip.js binds row clicks → .row-locked + populates #id_tooltip_portal from data-tt-* attrs; anchor clicks pass through to navigate; .tt-milestone is removed from DOM (not just emptied) when never-edited so the FT can distinguish absent vs cleared-after-edit.

SCSS: extend landscape gear-btn rule + #id_*_menu rule w. `.bud-page` + `#id_bud_menu` (otherwise gear-btn collided w. bud-btn in landscape on bud.html). Bump active burger sub-btn z-index to 1 so click hit-test picks the active sub-btn during the 0.25s fan arc-out animation (otherwise a later-in-DOM inactive btn obscured the active target during transition).

Cross-page Brief surface: new `mail_brief_payload` context processor injects the user's oldest unread MAIL_ACCEPTANCE Brief into every authenticated response; base.html renders the JSON-script + auto-fires Brief.showBanner. Mark-read still rides view_post's existing GET unread-flip — no new endpoint.

Pre-existing MySeaInvitePostRenderTest (test_sea_invite_views.py) inverted to match the new contract: the .invite-actions sweep is unconditional (PENDING / ACCEPTED / DECLINED all carry prose only); pinned the post-attribution anchor + bud-page href in its place.

1518 ITs green (1475 app ITs + 43 sprint ITs), 23 sprint FTs green (5 my_buds tooltip + 13 bud page + 5 mailman invite post). Jasmine specs from sprint plan deferred — FT coverage of burger-glow / row-lock / portal-populate paths suffices and the textarea blur-POST flow isn't implemented in this sprint (form is server-action only, save-on-blur AJAX is a follow-on).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:45:20 -04:00
Disco DeDisco
c41cf7ed36 coturn: activate [coturn] inventory host (turn.earthmanrpg.me + v4/v6)
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Uncomment + fill the [coturn] group so the play has a host to target (empty group was the 'no hosts matched' / 'no hosts to target' error). Secret stays vault-only — deliberately omitted from the host line (host_vars override group_vars).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:30:38 -04:00
Disco DeDisco
68239ac5d4 coturn: wire COTURN_* into app env template (gamearray.env.j2)
COTURN_SHARED_SECRET={{ coturn_secret }} (vault) + literal host/realm. Only the shared secret is sensitive; it must equal the coturn droplet's static-auth-secret. Host/realm are public.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:16:33 -04:00
Disco DeDisco
c9a61e5614 coturn: optional dual-stack TURN via guarded coturn_public_ip6
Set coturn_public_ip6 in inventory to advertise IPv6 relay candidates (2nd external-ip) AND emit matching v6 denied-peer-ip ranges (::1, fe80::/10, fc00::/7) for SSRF parity with the v4 lockdown. Unset → byte-identical pure-IPv4 config as before, so it's zero-risk opt-in. Droplet now has IPv6 on; this makes the conf dual-stack-ready.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:10:28 -04:00
Disco DeDisco
41217d5438 my-sea voice Phase C: WebRTC mesh signaling app + TURN endpoint + voice-btn wiring + coturn infra — TDD
Phase C (final) of the my-sea invite → spectator → voice blueprint. Self-
hosted WebRTC mesh voice, built room-general but wired for my-sea only; epic
6-seat rooms reuse the same consumer later (key on Room.id). Media never
touches the server — only signaling is relayed. Built from the blueprint's
distilled spec (disco-voice-mesh.pdf unreadable in-env: no poppler/pypdf).

- C1: new apps/voice/ — RoomVoiceConsumer (AsyncJsonWebsocketConsumer):
  signaling-only relay (room group voice.<room_id> + per-peer peer.<uuid>;
  hello→present handshake, offer/answer/ice routed by target/source, left on
  disconnect). room_id is a STRING kwarg (mysea-<owner_id> now). _can_join
  gates: mysea → owner OR present invitee (token deposited, not left); epic
  UUID → seated gamer (later). routing.py ws/voice/<str:room_id>/; asgi.py
  aggregates epic + voice urlpatterns under AuthMiddlewareStack.
  voice-mesh.js: VoiceRoom client (getUserMedia AEC/NS/AGC, mesh
  RTCPeerConnection, newcomer-offers handshake, tuneOpus SDP munge =
  inbandfec+dtx+40kbps cap, mute via getAudioTracks().enabled), lazy-loaded.
- C2: apps/api VoiceTURNCredentialsAPI at /api/voice/turn-credentials/ —
  coturn use-auth-secret REST scheme: username=<expiry>:<user_id>,
  credential=base64(HMAC-SHA1(username, COTURN_SHARED_SECRET)) + stun/turn
  iceServers + ttl. Authenticated-only. 4 ITs (HMAC shape, auth gate).
- C3: settings COTURN_SHARED_SECRET / COTURN_TURN_HOST / COTURN_REALM /
  COTURN_TTL env block.
- C4: #id_voice_btn wiring — _burger.html renders .active + data-room-id when
  voice_active; burger-btn.js bindVoiceBtn (active click → lazy-load
  voice-mesh.js → join / toggle-mute; inactive → existing 2-pulse flash).
  my_sea (owner) + my_sea_visit (spectator) views compute voice_active
  (open 24h window) + voice_room_id=mysea-<owner_id>; spectator page now
  includes the burger. 4 voice-context ITs.
- C5: infra/coturn.conf.j2 (use-auth-secret, the external-ip footgun, relay
  port range, TLS 5349, peer-IP lockdown) + infra/coturn-playbook.yaml
  (dedicated droplet, PySwiss-style split: install coturn, template conf, ufw
  3478/5349/49152-65535, systemd enable) + [coturn] inventory placeholder.
  *** Manual ops step: provision the droplet + fill inventory before voice
  works on staging/prod; CI/local need none of it. ***
- C6: 8 channels ITs (@tag channels) — connect/auth/_can_join gate (owner,
  present invitee, stranger, not-present, anon) + hello/present handshake +
  offer routing + left-on-disconnect. Scope-injected; TransactionTestCase.
- JS: VoiceMeshSpec.js (tuneOpus) + voice-mesh.js registered in SpecRunner.

1440 IT/UT green; voice channels IT + full Jasmine + voice-btn FT green.
Voice infra is code-complete — provision the coturn droplet to go live.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:57:09 -04:00
Disco DeDisco
d0c39b51b6 my-sea spectator Phase B: seat-2C occupancy + visitor token gate + one-shot seated glow + gear BYE — TDD
Phase B of the my-sea invite → spectator → voice blueprint. An ACCEPTED
invitee can watch the owner's my-sea read-only, deposit a token to occupy
seat 2C (opening a 24h voice window for Phase C), and BYE out. Owner's
my_sea.html is left structurally intact — the spectator gets a dedicated,
simpler my_sea_visit.html; the read-only draw reuses the existing
`latest_draw_slots` payload (no picker surgery).

- B1: my_sea_visit(owner_id) spectator view — 403 unless an ACCEPTED
  SeaInvite(owner, request.user); owner bounced to their own my_sea. Context
  forces owner-only controls off (sea_btn_active=False, read_only=True);
  renders the table hex (1C owner / 2C visitor) + owner draw read-only.
- B2: visitor gate — my_sea_visit_gate reuses my_sea_gate.html w. a
  spectator branch (titles the OWNER's Sea, INSERT posts to the visitor
  endpoint, bud-panel suppressed, gear NVM→visit + BYE). Single-step
  my_sea_visit_insert_token selects+debits the visitor's token (same
  priority chain) and records token_deposited_at + a 24h voice_until on the
  SeaInvite → seat 2C present. Center btn flips GATE VIEW → VIEW DRAW.
- B3: spectator gear BYE — my_sea_visit_leave sets status=LEFT, left_at,
  clears voice_until (frees 2C, ends voice), redirects /gameboard/.
  _my_sea_gear.html gains a `leave_url`-gated BYE below NVM (owner pages
  pass no leave_url, so unchanged).
- B-seat: one-shot "seated" glow per user-spec 2026-05-27 — new shared
  apps/gameboard/my-sea-seats.js: on first view (localStorage-gated by a
  per-occupancy data-seat-token) an occupied seat flares --terUser +
  --ninUser glow ~1.5s then settles to full-opacity --secUser (.fa-ban
  already swapped to .fa-circle-check). _room.scss adds .seated /
  .seat-just-seated + the my-sea-seat-flare keyframes (mirrors the room's
  .active→.role-confirmed handoff). Wired on BOTH the spectator page (load)
  and the owner page (load + on the FREE DRAW seat-1 transition).
  MySeaSeatsSpec.js Jasmine spec covers the gating + timed class removal.
- B5: MySeaSpectatorFlowTest FT — accept → visit → GATE VIEW → deposit →
  VIEW DRAW + seat 2C seated.

URLs: my-sea/visit/<uuid:owner_id>/ (+ /gate/, /insert, /leave). 470 IT/UT
green; spectator FT + full Jasmine suite green. Phase C (WebRTC mesh voice
+ coturn droplet) next — the 24h voice_until window set here drives it.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:35:00 -04:00
Disco DeDisco
fb8563eed2 my-sea bud-invite Phase A: SeaInvite model + @mailman log + OK/BYE accept/decline — TDD
Phase A of the my-sea invite → @mailman → spectator → voice blueprint
(magical-dancing-quasar.md). Pure Django; no new infra this phase (the coturn
droplet lands in Phase C5). Mirrors the @taxman ledger shape throughout.

- A1: SeaInvite model (gameboard) — single source of truth for a my-sea invite
  (owner / invitee / status / timestamps + OneToOne FK to its @mailman Line).
  is_expired / voice_active / is_present / expires_at properties; 12 UTs.
  created_at uses default=timezone.now (MySeaDraw precedent) for testable 24h
  expiry; a token deposit makes the invite non-expiring per spec.
- A2: reserved @mailman system user — get_or_create_mailman + "mailman" added
  to RESERVED_USERNAMES + seed migration lyric/0015. Email domain confirmed
  w. user as mailman@earthmanrpg.local (matches adman/taxman).
- A3: billboard KIND_MAIL_ACCEPTANCE on Post + Brief; extends the post_save
  unsolicited-line guard (_SYSTEM_AUTHOR_POST_KINDS) + migration billboard/0009.
- A4: apps/billboard/mail.py log_sea_invite — appends one interactive Line +
  invitee Brief on the invitee's "Acceptances & rejections" Post, links the
  Line back onto the SeaInvite; "Listen!—@owner invites you to {poss} drawing
  table" prose via at_handle + resolve_pronouns. Unregistered invitee no-ops.
- A5: post.html renders OK .btn-confirm / BYE .btn-abandon (PENDING) or a
  status badge (ACCEPTED / DECLINED / LEFT / EXPIRED) from line.sea_invite.status
  via new _partials/_invite_actions.html; 'mailman' added to the system-author
  |safe + read-only-input + bud-panel-suppression branches.
- A6: real my_sea_invite (replaces the coming-soon stub) — resolves recipient,
  dedups outstanding PENDING/ACCEPTED, creates SeaInvite + logs the @mailman
  line; new my_sea_invite_accept / my_sea_invite_decline endpoints (invitee-only,
  redirect back to the invite-log Post; accept links invitee FK + stamps
  accepted_at). 16 ITs.
- A7: updated MySeaBudBtnInviteTest (stub→real invite) + new
  MySeaInviteAcceptanceLogTest FT (invitee opens their log Post, sees the line
  + OK/BYE). Both green.

457 IT/UT green. Phase B (invitee spectator seat-2 + visitor token gate) +
Phase C (WebRTC mesh voice + coturn droplet) to follow.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:14:06 -04:00
Disco DeDisco
1c799d35ca room-stage FTs: realign 4 fails to new my-sea NVM + spread-modal + landscape kit-bag UX — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
CI #346 test-FTs-room had 4 consistent fails (failed on both the first run
AND the retry, so real, not flakes). All 4 are test-side — the shipped
features are correct; the FTs lagged behind deliberate UX changes + a
race they never needed to depend on.

test_game_my_sea.py
- test_nvm_navigates_back_to_gameboard → renamed test_nvm_navigates_back_to_
  my_sea_hex; asserts /gameboard/my-sea/$ now. NVM on the gatekeeper navigates
  to the table hex, not out to /gameboard/ (changed 5cade51: gatekeeper +
  picker NVM → hex; only landing + sign-gate eject to /gameboard/). Sibling
  test_gear_btn_opens_menu_with_nvm_only still passes (only checks the onclick
  contains /gameboard/).
- test_default_spread_is_situation_action_outcome → add _open_spread_modal(self).
  The spread combobox moved into #id_sea_spread_modal (burger Sea sub-btn
  sprint); .sea-select-current .text returned '' while the modal was hidden.
  Mirrors the already-updated sibling test_picking_spread_swaps_*.

test_trinket_carte_blanche.py (the recurring #344/#345/#346 carte fail)
- Sign-gate Brief: replace the hard wait_for_slow(find .my-sea-sign-gate-brief)
  + NVM-click with dismiss_brief_if_present(). The Brief fires via
  Brief.showBanner on DOM-ready; its appearance is a DOM-ready-vs-note.js-load
  race, so under CI contention it sometimes never lands in-window and ANY hard
  wait throws NoSuchElement. a39053d misdiagnosed this as a timeout (→
  wait_for_slow); it is not. The test never asserts the Brief — it only clears
  it to unblock a later click. dismiss_brief_if_present removes it if present +
  no-ops if absent: robust to the race.
- Kit-bag token select: JS-click the #id_kit_bag_dialog CARTE token. In
  landscape (CI default viewport) the kit-bag dialog is a vertical bar that
  slides in via a max-width transition (burger landscape refactor), so a
  Selenium .click races the animation + can't scroll the token into the
  overflow container ("could not be scrolled into view"). execute_script fires
  the bound handler directly. Applied in both carte tests
  (open_kit_and_select_carte + the in-use attribution flow); token-rails stays
  a normal click (it lives on the gate page, not in the dialog).

Verification: all 4 methods green locally (landscape viewport) —
test_carte_in_use_game_kit_shows_room_attribution 10.8s; the multi-slot carte
+ both my-sea methods 35.5s.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:00:35 -04:00
Disco DeDisco
c30b63cd5d burger Sea sub-btn: first-draw --priYl glow handoff (phase 3/3) — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Final slice of the Sea sub-btn rollout (phase 1 = .active wiring 3ae85b9; phase 2 = modal extraction + CONT DRAW 6fbeed7). Adds a --priYl + --ninUser glow that rides the affordance chain to teach the user where to click pre-first-draw.

## The handoff chain

  burger  →  click  →  sea_btn  →  click  →  .sea-select  →  click  →  end

- Modal close (Esc / backdrop / DEL guard-OK) restarts the cycle on the burger.
- Burger fan close w/o sea_btn click ALSO restarts on the burger.
- AUTO DRAW guard-OK ends the cycle permanently (user found the path).
- `#id_sea_action_btn` data-state → 'gate-view' (last card landed via ANY path — AUTO DRAW or manual FLIP) ALSO ends permanently.

## SCSS

`static_src/scss/_burger.scss` — `.glow-handoff` on burger / sea_btn = --priYl color + border + --ninUser glow.
`static_src/scss/_gameboard.scss` — `.glow-handoff` on .sea-select = --terUser border + --ninUser glow (no font-color change per spec).

## Server side

`apps/gameboard/views.py` — new `sea_first_draw_pending = show_picker and not hand_non_empty`. True when picker is active w. an empty hand (paid-draw entry, or page reload of a freshly-entered picker). The FREE-DRAW → picker transition fires client-side w. show_picker=False on the rendered template, so the FREE DRAW JS handler seeds the burger glow itself in that path.

`templates/apps/gameboard/_partials/_burger.html` — `#id_burger_btn` conditionally renders `class="glow-handoff"` when `sea_first_draw_pending`.

`templates/apps/gameboard/my_sea.html` — FREE DRAW transition handler adds `.glow-handoff` to burger at the same SEAT_ANIM_MS moment data-phase swaps to 'picker' (covers the client-side path).

## JS state machine

`templates/apps/gameboard/my_sea.html` — new inline IIFE owns the .glow-handoff transitions:
- `burger.click` → if .glow-handoff on burger, transfer to sea_btn.
- `sea_btn.click` → if .glow-handoff on sea_btn, transfer to .sea-select.
- `.sea-select.click` → end this cycle (just clear the glow; cycle restarts on next modal open).
- AUTO DRAW guard-OK (via doc-level click listener) → sets `autoDrawConfirmed`.
- Modal `hidden`-attr observer: AUTO DRAW path → endPermanently; any other close (Esc / backdrop / DEL) → startOnBurger (skip if glow already permanently ended).
- Burger `class`-attr observer: fan closes (`.active` removed) while glow on sea_btn → restart on burger.
- `#id_sea_action_btn` `data-state`-attr observer: flips to 'gate-view' (last card landed via ANY path — AUTO DRAW finishing OR manual FLIP filling the final slot) → endPermanently.

The data-state observer makes the "stop glowing when all slots filled" guarantee async + decoupled from how the cards arrived.

## CONT DRAW polish (drag-in from prior commit's spec gap)

`apps/gameboard/views.py` — `show_cont_draw` now additionally requires `bool(active_draw.hand)` (at least one card drawn). Pre-draw NVM-to-landing falls through to the existing 3-way state machine (PAID DRAW / GATE VIEW / FREE DRAW) instead of misleading w. CONT DRAW that lands back on an empty picker.

## Tests (4 new ITs)

`apps/gameboard/tests/integrated/test_views.py::MySeaViewTest`:
- `test_burger_renders_glow_handoff_class_when_sea_first_draw_pending` — paid-draw entry to picker w. empty hand → burger has .glow-handoff.
- `test_burger_omits_glow_handoff_when_hand_non_empty` — mid-draw → no .glow-handoff.
- `test_burger_omits_glow_handoff_on_landing` — landing → no .glow-handoff (FREE DRAW handler seeds client-side instead).
- `test_force_landing_hides_cont_draw_when_hand_empty` — pre-first-draw NVM → no CONT DRAW.

(JS state-machine behaviour is verified visually; not Jasmine-tested since the IIFE lives inline on my_sea.html, not as a separate module.)

## Verification

All 1374 IT+UT green (+4 from Phase 3). Visual verification of glow handoff + hand-complete auto-end confirmed.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:39:46 -04:00
Disco DeDisco
a39053d3f6 CI #345 fixes: bud-kit mutual exclusion test → portrait viewport; carte sign-gate-brief wait → wait_for_slow
Two CI #345 failures addressed:

## test_bud_active_fades_kit_btn (test_core_bud_btn.py)

Real regression — earlier sprint scoped `html.bud-open #id_kit_btn { opacity: 0 }` to `@media (orientation: portrait)` (in `_bud.scss`) because in landscape kit_btn sits at the TOP of the right sidebar + bud_panel slides across the BOTTOM, no visual conflict. The default CI landscape viewport (1366x900) rendered the fade rule inert → the kit_btn stayed at opacity 1 → assertEqual(opacity, 0.0) failed.

Fix: `BudKitMutualExclusionTest.setUp` now resizes to portrait (800x1200) so the fade rule actually fires. Both `test_bud_active_fades_kit_btn` + `test_kit_active_fades_bud_btn` now exercise the rule in the orientation where it lives.

## test_carte_blanche_equip_and_multi_slot_gatekeeper (test_trinket_carte_blanche.py)

CI flake — the test waits up to 10s (`wait_for` default) for `.my-sea-sign-gate-brief` to appear after navigating to /gameboard/. Under CI contention the Brief's DOM-ready handler can land past 10s; CI #345 hit a NoSuchElement timeout. The screendump confirmed the Brief WAS in the DOM, just past the wait window.

Fix: bump that one wait to `wait_for_slow` (60s ceiling). Same pattern used elsewhere (Jasmine spec runner, sig select countdown).

## Verification

Both fixed tests green locally. No model / view / template touches.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:19:34 -04:00
Disco DeDisco
6fbeed78d8 burger Sea sub-btn: spread-form modal + relocated deck stacks + mid-draw CONT DRAW escape — TDD (phase 2/3)
Second slice of the Sea sub-btn rollout (phase 1 = .active wiring in 3ae85b9; phase 3 = --priYl glow handoff to come).

## Spread modal (#id_sea_spread_modal)

`templates/apps/gameboard/my_sea.html` — `.sea-form-col` (spread combobox + AUTO DRAW + DEL btns) now lives inside `<div id="id_sea_spread_modal" class="my-sea-spread-modal" hidden>`. Hidden by default; opens on #id_sea_btn click (per the inline `<script>` block). The deck stacks (`.sea-stacks` + `.sea-stacks-label`) are extracted to a new `.my-sea-stacks-wrap` sibling that stays visible on the page so the FLIP affordance remains usable while the modal is closed.

## Modal SCSS

`static_src/scss/_gameboard.scss`:
- `.my-sea-spread-modal` — position:fixed inset:0; z-index:320 (above all corner btns); pointer-events:none w. children opting back in. `&[hidden]` makes it explicit that the closed state stays display:none.
- `.my-sea-spread-modal__backdrop` — full-viewport semi-transparent w. backdrop-filter blur; pointer-events:auto so click-outside closes.
- `.my-sea-spread-modal__panel` — opaque card, border + shadow, max-width:90vw. `.sea-form-col` inside drops its fixed 16rem width + uses min-width.
- `.my-sea-stacks-wrap` — position:absolute bottom:4.5rem right:1rem (clear of bud/burger); z-index:5 (above .my-sea-cross, below modal).

## Modal JS

`templates/apps/gameboard/my_sea.html` — new inline `<script>` at the bottom owns:
- `#id_sea_btn` click → opens modal (guarded on `.active` so the burger-btn.js inactive-flash handler still runs for inactive sub-btns).
- Escape key → close.
- Backdrop click → close.
- `#id_guard_portal .guard-yes` click (delegated) → close. This is the key UX detail user-spec'd: AUTO DRAW + DEL both open a guard portal that positions itself against the visible action/del btn — closing the modal on the btn click itself would dump the portal at (0,0). Closing on guard OK instead means the portal positions correctly + the modal closes only when the action is confirmed. NVM (`.guard-no`) leaves the modal up so the user can retry.

Also `apps/epic/static/apps/epic/burger-btn.js` — delegated fan click now closes the burger fan when an ACTIVE sub-btn is clicked (was: no-op for active). The per-page sub-btn handler runs in target phase BEFORE the fan-bubble handler, so the action kicks off w. a clean visual.

Client-side `.active` sync: server renders sea_btn inactive on the landing page (show_picker=False there). The DRAW SEA → picker transition is client-side (no re-render), so the IIFE in my_sea.html now flips `seaBtn.classList.add('active')` at the same SEAT_ANIM_MS moment data-phase swaps to 'picker'. Sea_btn stays `.active` for the rest of the picker phase, INCLUDING after hand_complete — DEL + GATE VIEW both live inside the modal + the user needs them accessible (earlier iteration deactivated on hand_complete; reverted on user feedback).

## Mid-draw NVM → CONT DRAW (apps/gameboard/views.py + my_sea.html)

Earlier iteration's gear-menu NVM nav-backed to `/gameboard/my-sea/` mid-draw, but the server's `hand_non_empty` branch re-rendered the picker on the next GET — looping the user right back into the spread. New `?phase=landing` escape hatch:

- `views.py`: `force_landing = request.GET.get('phase') == 'landing'`; `show_picker = (hand_non_empty or (phase_param and show_paid_draw)) and not force_landing`. New context var `show_cont_draw = force_landing and active_draw and not active_draw.is_hand_complete`.
- `my_sea.html` landing: new `{% if show_cont_draw %} CONT DRAW {% elif show_paid_draw %} ... ` branch on the table-center action-btn. CONT DRAW is an `<a href="{% url 'my_sea' %}">` (plain navigation, no `?phase=landing` so the next GET falls through to the hand_non_empty picker branch).
- `my_sea.html` gear include: picker phase passes `nvm_url={% url 'my_sea' %}?phase=landing` so the gear NVM exits to the landing-with-CONT-DRAW state instead of looping back into the picker.

## Tests

`apps/gameboard/tests/integrated/test_views.py` — 4 ITs added/updated:
- `test_sea_btn_stays_active_when_hand_complete` (replaced the prior "returns to inactive" assertion — sea_btn now stays active per user spec).
- `test_gear_nvm_navs_to_my_sea_landing_on_picker_phase` (updated URL to include `?phase=landing`).
- `test_force_landing_renders_cont_draw_btn_mid_draw` (NEW) — verifies CONT DRAW renders + FREE DRAW absent.
- `test_force_landing_hides_cont_draw_when_no_active_draw` (NEW) — regression guard: sig'd user w. no draw still sees FREE DRAW.
- `test_force_landing_hides_cont_draw_when_hand_complete` (NEW) — CONT DRAW absent for completed hands.

`functional_tests/test_game_my_sea.py` — new module-level `_open_spread_modal(test)` helper + applied to 7 tests that touch in-modal selectors (combobox / action_btn / del):
- `MySeaSpreadFormTest.test_picking_spread_swaps_data_spread_and_position_visibility`
- `MySeaSpreadFormTest.test_per_spread_position_labels_render_and_update`
- `MySeaCardDrawTest.test_action_btn_transitions_to_gate_view_on_hand_complete` (also: close modal before manual FLIP draws, use innerText for hidden-element text checks)
- `MySeaCardDrawTest.test_auto_drawn_slots_can_reopen_stage_modal_on_click`
- `MySeaCardDrawTest.test_form_col_renders_decks_lock_hand_del_and_reversal_pct`
- `MySeaLockHandTest.test_del_click_opens_shared_guard_portal`
- `MySeaLockHandTest.test_del_confirm_clears_hand_and_returns_to_gate_view_landing`

JS .click() is used inside `_open_spread_modal` (not Selenium .click) because the fan sub-btns spend ~0.25s mid-animation stacked at the burger centre — Selenium would hit a click-intercept by whichever sub-btn is z-topmost during the transform.

## Verification

All 1370 IT+UT green (+5 new MySeaViewTest, +1 from earlier phase 1 carry-over). 7 updated FTs green when re-run together.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:15:30 -04:00
Disco DeDisco
3ae85b962b burger sea sub-btn: wire .active = show_picker and not hand_complete (phase 1/3) — TDD
First slice of the "Sea sub-btn opens the spread modal" feature push.

## Server (apps/gameboard/views.py)

`my_sea` view now passes `sea_btn_active = show_picker and not hand_complete` in context. Picker phase w. cards still to draw → True; landing / sign-gate / hand-complete states → False. The condition mirrors the AUTO-DRAW-vs-GATE-VIEW state machine — sea_btn lights up exactly when AUTO DRAW is the live action btn, returns to inactive at the same moment AUTO DRAW becomes GATE VIEW + the deck FLIP gains .btn-disabled.

## Template (_partials/_burger.html)

`#id_sea_btn` conditionally renders the `.active` class:
```
<button id="id_sea_btn" type="button" class="burger-fan-btn{% if sea_btn_active %} active{% endif %}" ...>
```

Per the burger sub-btn CSS that landed in 894d65f, .active means opacity 1 + skip the --priRd inactive-click flash. The other 4 sub-btns (sky/earth/voice/text/...) remain inactive scaffolding for later sprints to wire one-by-one.

## Tests (apps/gameboard/tests/integrated/test_views.py)

3 new ITs on MySeaViewTest:
- `test_sea_btn_is_inactive_on_landing_phase` — fresh user / landing → no .active class.
- `test_sea_btn_is_active_on_picker_phase_with_partial_hand` — sig + 1-card MySeaDraw → .active class present.
- `test_sea_btn_returns_to_inactive_when_hand_complete` — 3-card spread w. all 3 positions drawn → hand_complete True → sea_btn back to inactive (regression guard for the GATE VIEW transition).

## Verification

All 12 MySeaViewTest green (+3 new). No JS / CSS touches — pure server + template change. Phase 2 (modal extraction + .sea-stacks relocate) + Phase 3 (--priYl glow handoff) land in follow-up commits.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:24:27 -04:00
Disco DeDisco
5cade51d03 my-sea gear NVM: gatekeeper + picker nav-back to table hex instead of ejecting to gameboard — TDD
User-spec'd 2026-05-26: the gear menu's NVM was always nav-backing to /gameboard/ from every my-sea state, which ejects the user one step further than they want when bailing on the gatekeeper or the spread/crucifix picker. Both should nav-back to the my-sea TABLE HEX (the landing) — one step back, not all the way out.

## templates/apps/gameboard/_partials/_my_sea_gear.html

Now takes an optional `nvm_url` context variable; falls back to `{% url 'gameboard' %}` when unset. The onclick handler reads `{{ nvm_url }}` so callers can override per-page.

## templates/apps/gameboard/my_sea.html

```
{% if show_picker %}{% url 'my_sea' as nvm_url %}{% endif %}
{% include "apps/gameboard/_partials/_my_sea_gear.html" %}
```

Picker phase → nvm_url = my_sea landing (table hex). Sign-gate + landing phases fall through to the default (gameboard) — landing's NVM = "back out of my-sea entirely", which is the existing behaviour.

## templates/apps/gameboard/my_sea_gate.html

```
{% url 'my_sea' as nvm_url %}
{% include "apps/gameboard/_partials/_my_sea_gear.html" %}
```

Gatekeeper unconditionally nav-backs to the my-sea landing.

## Tests

`apps/gameboard/tests/integrated/test_views.py`:
- `MySeaViewTest.test_gear_nvm_navs_to_gameboard_on_landing_phase` — landing NVM still goes to /gameboard/ (regression guard).
- `MySeaViewTest.test_gear_nvm_navs_to_my_sea_landing_on_picker_phase` — picker NVM goes to /gameboard/my-sea/ (seeds a non-empty hand to trigger show_picker per views.py:277's `hand_non_empty or (phase_param and show_paid_draw)` logic).
- `MySeaGateViewTest.test_gear_nvm_navs_to_my_sea_landing_not_gameboard` — gatekeeper NVM goes to /gameboard/my-sea/ (and explicitly NOT /gameboard/).

## Verification

All 1364 IT+UT green. No FTs assert the gear-menu NVM target URL (per pre-change grep) so no test fixes required there.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:39:26 -04:00
Disco DeDisco
ca960d1d43 my_sea.html: persist burger + bud btns across all 3 phases — TDD
Bud + burger now render unconditionally on /gameboard/my-sea/ alongside the always-on gear-btn (was sprint-6c) + base-html kit-btn. Persists through sign-gate, landing, and picker phases so the user can reach the cross-cutting menu / invite affordance from every state.

## templates/apps/gameboard/my_sea.html

3 new includes/scripts placed at the bottom of `<div class="my-sea-page">` alongside the existing `_my_sea_gear.html`:
- `_my_sea_bud_panel.html` — bud btn + slide-out (POSTs to `my_sea_invite` stub, same shape as on my_sea_gate.html)
- `_burger.html` — burger btn + fan of 5 sub-btns
- `<script src="...burger-btn.js">` — the delegated click + flash handler

All unconditional (outside the if/else nesting that branches on user_has_sig / show_picker), so they survive every phase transition.

## apps/gameboard/tests/integrated/test_views.py

3 new ITs on MySeaViewTest — one per phase:
- `test_my_sea_renders_bud_btn_and_burger_in_sign_gate_phase` (no significator)
- `test_my_sea_renders_bud_btn_and_burger_in_landing_phase` (significator set, no draw)
- `test_my_sea_renders_bud_btn_and_burger_in_picker_phase` (significator + empty MySeaDraw row + ?phase=picker)

Each asserts both `id="id_bud_btn"` + `id="id_burger_btn"`. Sign-gate phase also asserts burger-btn.js loaded.

## Verification

All 215 gameboard IT+UT green (+3 new). No JS / model touches; pure template additions.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:27:44 -04:00
Disco DeDisco
894d65fd6b burger sub-btns: opacity-0.6 inactive default + 2-pulse --priRd flash w. fa-ban swap on click — TDD
Adds the active/inactive distinction to the 5 burger fan sub-btns. Default state is INACTIVE (opacity 0.6, real icon visible); active conditions get wired one-by-one in later sprints as each surface matures.

## Markup (templates/apps/gameboard/_partials/_burger.html)

Each sub-btn now renders BOTH icons:
- `<i class="fa-solid fa-<real> burger-fan-icon--on">` (sky/earth/sea/voice/text)
- `<i class="fa-solid fa-ban burger-fan-icon--off">`

CSS keeps the real icon visible by default in both .active + inactive states. The fa-ban only surfaces during the .flash-inactive pulse below (icon swap is tied to the pulse class, not to inactive state per se — user-spec'd).

## SCSS (static_src/scss/_burger.scss)

- `#id_burger_btn.active ~ ... .burger-fan-btn.active { opacity: 1 }` — active sub-btn fully visible.
- `#id_burger_btn.active ~ ... .burger-fan-btn:not(.active) { opacity: 0.6 }` — inactive default.
- `.burger-fan-icon--on / --off` stacked absolute-position so the swap doesn't shift the layout box.
- `.burger-fan-btn.flash-inactive` — adds --priRd border + glow (box-shadow modeled on sig-select's SAVE SIG countdown but lighter), AND swaps to fa-ban via `.burger-fan-icon--on { display: none } / --off { display: inline-block }`.

The `#id_burger_btn` itself (the trigger btn) is explicitly NOT subject to inactive/active opacity treatment — only the sub-btns.

## JS (apps/epic/static/apps/epic/burger-btn.js)

Delegated click handler on `#id_burger_fan`: any `.burger-fan-btn` click that DOESN'T carry `.active` runs `_flashInactive(subBtn)` — 2 pulses, 180ms ON / 120ms OFF (tighter than sig-select's 600ms cadence per user spec). Active sub-btns will route to their per-feature handlers in later sprints; for now they no-op.

## Tests

- `apps/epic/tests/integrated/test_views.py::RoomBurgerBtnRenderTest::test_each_sub_btn_renders_dual_icon_for_inactive_flash_swap` — asserts `burger-fan-icon--on` + `--off` appear 5 times each (one per sub-btn). fa-ban itself isn't counted directly — `_table_positions.html` also renders fa-ban for non-starter seats — but the burger-fan-icon classes are unique to the fan.
- `static_src/tests/BurgerSpec.js` — 5 new specs under `describe("inactive sub-btn flash")`:
  - adds .flash-inactive on click
  - removes after ~180ms (first ON window)
  - re-adds after ~480ms (second ON window during the 2nd pulse)
  - settles back to default after ~800ms (full 2-pulse cycle)
  - does NOT flash when sub-btn carries .active

Uses `jasmine.clock()` for deterministic timing. Mirror-copied to `static/tests/BurgerSpec.js` for the Jasmine FT runner.

## Verification

1358 IT+UT green. Jasmine FT runs all specs (incl. the 5 new flash specs) green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:23:03 -04:00
Disco DeDisco
6809681e5a my_sea_gate burger; _bud_apparatus shared shell; CI #344 tray-anchor fix — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Continuation of the burger sprint into the my-sea gatekeeper + a couple companion cleanups the visual + CI runs surfaced.

## my_sea_gate.html burger

`templates/apps/gameboard/_partials/_room_burger.html` → `_burger.html` (git mv) — now lives at a non-room-scoped path since it's reused across templates. Updated room.html's include path.

`templates/apps/gameboard/my_sea_gate.html` — includes `_burger.html` + loads `burger-btn.js`. Burger renders unconditionally on the my-sea gatekeeper, same affordance as the room gatekeeper.

`apps/gameboard/tests/integrated/test_views.py::MySeaGateViewTest` — new `test_gate_view_renders_burger_btn_and_fan` IT asserts burger_btn + burger_fan + 5 sub-btns w. correct ids + burger-btn.js loaded on `/gameboard/my-sea/gate/`.

## Burger fade rule reinstated

`static_src/scss/_burger.scss` — `html.bud-open #id_burger_btn { opacity: 0; pointer-events: none; }` reinstated, scoped to landscape only. User-confirmed: even after the z-index drop the burger needs to disappear when bud_panel is open in landscape (panel + bud_ok races vs. burger pointer-events). Portrait keeps burger visible since the panel sits BELOW the burger w. no overlap.

## "friend@example.com" → "bud@example.com" placeholder

Renamed in 4 bud-panel templates + the FT asserting it:
- `templates/apps/gameboard/_partials/_my_sea_bud_panel.html`
- `templates/apps/billboard/_partials/_bud_panel.html`
- `templates/apps/billboard/_partials/_bud_invite_panel.html`
- `templates/apps/billboard/_partials/_bud_add_panel.html`
- `functional_tests/test_core_sharing.py`

"bud" matches the broader naming convention (bud_btn, my-buds, share-w.-a-bud, etc.) — `friend` was an outlier.

## _bud_apparatus.html shared shell refactor

New `templates/apps/billboard/_partials/_bud_apparatus.html` — single shared markup partial for the four bud-btn use cases. Contains btn + panel + input + OK + (optional) suggestions div + bud-btn.js script. Each of the 4 specific partials becomes a thin wrapper that `{% include %}`s the shell + renders its own `<script>bindBudBtn({...})</script>` block w. per-use-case submitUrl / onSuccess / duplicateTargetSelector.

Context vars accepted by the shell:
- `aria_label` — string for #id_bud_btn aria-label
- `sharer_name` — optional; renders `data-sharer-name=` on #id_bud_panel (post-share only)
- `include_suggestions` — bool; renders suggestions div + autocomplete script (false on my_buds where the pool == request.user.buds == nothing useful to suggest)

Per-call wrappers are now ~10-50 lines instead of 30-120 lines of duplicate markup. Behaviour is identical; only DRY-ed up.

## CI #344 tray-anchor regression fix

`apps/epic/static/apps/epic/tray.js` — `_computeBounds` in landscape used `id_gear_btn || id_kit_btn` as the bottom anchor (wrap height = anchor.top). After the burger sprint relocated kit_btn to the TOP of the right sidebar (top:0.5rem), kit_btn.top ≈ 8px → wrap.height collapsed to 8px → tray couldn't slide → `test_dragging_tray_btn_down_opens_tray_in_landscape` failed.

Fix: anchor fallback chain is now `id_burger_btn || id_bud_btn` (the new bottom-anchored btns) → `window.innerHeight - 3.5rem` reserved fallback (for pages that have neither). Burger renders unconditionally on room.html so the SIG_SELECT tray test now finds its anchor + lays out the wrap correctly.

## Verification

- IT+UT 1357 green (+1 from MySeaGateViewTest burger).
- Jasmine specs green.
- `test_dragging_tray_btn_down_opens_tray_in_landscape` green (was the CI #344 failure).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:57:45 -04:00
Disco DeDisco
03feaee9f2 burger z-index drop 318→314 to match .gear-btn; FT base dismiss_brief_if_present helper — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Follow-up to 3ca986f. Lands the FT fixes the burger sprint surfaced + tightens the burger's z-stack so the existing kit_btn / bud_btn / bud_panel / dialog naturally cover it on overlap (vs. the explicit opacity-fade rules the first iteration tried).

## Burger z-index drop

`static_src/scss/_burger.scss`:
- `#id_burger_btn` z 318 → 314 (matches the page-level `.gear-btn` z). Below kit_btn + bud_btn (318), bud_panel (317), kit_bag_dialog (316) — so every overlapping surface visually covers the burger when it appears. The earlier `html.bud-open #id_burger_btn { opacity: 0 }` + `html:has(#id_kit_bag_dialog[open]) #id_burger_btn { opacity: 0 }` rules are now redundant + deleted.
- `#id_burger_fan` z 317 → 313 (stays just below burger so burger remains clickable when fan is open).

`static_src/scss/_game-kit.scss`:
- `#id_kit_bag_dialog` z 319 → 316 (reverts an earlier iteration that bumped it above burger). 316 keeps the dialog BELOW kit_btn + bud_btn so those stay visible + clickable when dialog opens — user re-clicks kit_btn to close. Resolves "kit btn disappears when dialog open" reported on iPad portrait.

`static_src/scss/_bud.scss`:
- `html.bud-open #id_kit_btn { opacity: 0 }` now wrapped in `@media (orientation: portrait)`. In landscape kit_btn lives at the TOP of the right sidebar + bud_panel sits at the BOTTOM — no visual conflict, kit stays visible.

## FT base — dismiss_brief_if_present()

`functional_tests/base.py`:
- New helper on both `FunctionalTest` + `ChannelsFunctionalTest`:
  ```python
  def dismiss_brief_if_present(self, banner_selector=".note-banner", browser=None):
  ```
- Removes any matching Brief banner from the DOM via `execute_script`. No-op if absent. Default selector matches every Brief shape; pass a specific selector to target one kind. DOM-removal rather than NVM-click bypasses the dismiss_url POST flow that some Briefs (FREE/PAID DRAW) wire up — use this when the test cares about the page state AFTER a Brief, not the dismissal mechanics.

## FT — test_trinket_coin_on_a_string.py

Two methods (`test_coin_deposit_unequips_from_kit_bag_and_fills_one_slot`, `test_coin_in_use_game_kit_shows_room_attribution_and_btn_disabled`) updated:

1. `self.dismiss_brief_if_present()` right after `wait_for(id_game_kit)`. `coin@test.io` is created fresh w/o a significator, so the `_my_sea_sign_gate_brief.html` auto-spawns on `/gameboard/` + intercepts the create-game btn click. Dismissing the banner clears the runway.

2. `self.wait_for(... dialog.rect["width"] > 50 ...)` after `kit_btn.click()` + before interacting w. tokens inside the dialog. The landscape kit_bag_dialog animates `max-width 0 → 5rem` over 0.25s; the existing wait_for(find_token).click() found the COIN .token in the DOM immediately + raced the animation — Selenium's scrollIntoView fails on a 0-width container. Waiting for the rect to widen past 50px (≈ 3rem) confirms layout has rendered.

## Verification

- All 4 previously-failing FTs from the burger sprint re-run green:
  - CarteBlanche.test_carte_blanche_equip_and_multi_slot_gatekeeper (was flaking; passed on retry — pre-existing tooltip-population race, not in scope)
  - CoinOnAString.test_coin_deposit_unequips_from_kit_bag_and_fills_one_slot
  - CoinOnAString.test_coin_in_use_game_kit_shows_room_attribution_and_btn_disabled
  - GatekeeperTest.test_second_gamer_drops_token_into_open_slot
- IT+UT suite still 1356 green (no touches to model/view code).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:39:36 -04:00
Disco DeDisco
3ca986fb45 room.html burger btn + 5-fan; universal landscape btn refactor; kit_bag_dialog vertical bar — TDD
Major feature push staging room.html for sprint A.8 — adds the burger btn + fan-of-five sub-btns affordance, then rotates the whole landscape btn layout to make room for it. The landscape refactor is universal (not .room-page-scoped) so every page that hosts these btns reads consistently.

## Burger btn + fan-of-five (room.html only)

`templates/apps/gameboard/_partials/_room_burger.html` (NEW) — `#id_burger_btn` (.fa-burger) + `#id_burger_fan` containing 5 sub-btns: `#id_voice_btn` (headset), `#id_sky_btn` (cloud), `#id_earth_btn` (earth-americas), `#id_sea_btn` (bridge-water), `#id_text_btn` (keyboard). Pure scaffolding — no click handlers in this sprint; wire-up lands later as each surface matures.

`apps/epic/static/apps/epic/burger-btn.js` (NEW) — toggle `.active` on click; Escape + click-outside close; opening burger auto-closes the kit dialog (`#id_kit_bag_dialog`) + the bud slide-out panel (`html.bud-open`) by dispatching a click to the owning btn (routes through that btn's own toggle/close path — no fetch on close). `bindBurger()` returns an `AbortController` so test code (+ any future re-bind callers) can detach all listeners cleanly via `ac.abort()`.

`static_src/scss/_burger.scss` (NEW) — burger sits parallel to gear-above-kit but on the bud-side. Portrait: bottom:4.2rem; left:0.5rem (above #id_bud_btn). Landscape: bottom:0.5rem; right:4.2rem (to the LEFT of #id_bud_btn). Fan is a CSS radial menu w. each sub-btn's `--angle = --base + --i * 30deg`. Portrait `--base: 0deg` (arc 12→4 o'clock), landscape `--base: -90deg` (arc 9→1 o'clock). Sub-btn radius `--r: 7.75rem`. Indices: voice=0, sky=1, earth=2, sea=3, text=4 (user-spec'd clockwise order).

room.html includes the partial + the script; the burger renders unconditionally regardless of `gate_status` / `table_status`.

## Universal landscape btn refactor

Sprint replaces the prior centred-in-sidebar landscape arrangement w. a kit-at-top + bud-at-bottom + gear/burger as horizontal partners. Applies to every page in landscape (was scoped to .room-page in the first iteration).

`_game-kit.scss` — kit_btn landscape moves to top:0.5rem; right:0.5rem (was bottom:0.5rem centred in sidebar). The 0.5rem right literal (not the calc((--sidebar-w - 3rem)/2)=1rem) produces a 0.7rem edge-to-edge gap w. gear at right:4.2rem — matching the portrait gear-above-kit gap exactly.

`_bud.scss` — bud_btn landscape moves to bottom:0.5rem; right:0.5rem (was top:0.5rem centred in sidebar). Same 0.5rem literal as kit_btn. bud_panel #id_recipient relocates to bottom:0.5rem (was top:0.5rem) + transform-origin flips to right center. .bud-suggestions rise upward from above the panel (bottom:4rem) instead of dropping from below.

`_applets.scss` — .gear-btn landscape moves to top:0.5rem; right:4.2rem (was centred bottom:3.95rem). All applet menus anchor at top:2.6rem; right:4.2rem (beneath the gear's leftward arc) extending DOWN-LEFT into the viewport. #id_room_menu joins the shared portrait position list (was bespoke in _room.scss).

`_room.scss` — bespoke #id_room_menu rule deleted entirely. The menu now inherits %applet-menu + the shared portrait position list — same chrome + behaviour as #id_post_menu / #id_billscroll_menu. Earlier iteration tried flex-direction:row in landscape; reverted per user request — "lose all scss specificity" wins.

`_card-deck.scss` — obsolete `#id_room_menu { right: 2.5rem; }` override in the XL+landscape block deleted. Was a same-specificity hack to beat _applets.scss's old centred position; no longer needed w. the consolidated rule.

## kit_bag_dialog vertical bar in landscape

`_game-kit.scss` — when open in landscape, dialog covers the right sidebar (top:0; bottom:0; right:0; width: var(--sidebar-w)). Slides in from off-viewport right by animating max-width 0 → var(--sidebar-w). Opaque bg (rgba(--priUser, 1) — was 0.97). z-index: 319 (above burger at 318) so it lands in front of the burger btn when open. Top-edge border → left-edge border.

Inner content flips to `flex-direction: column-reverse` so DOM order Deck→Dice→Trinket→Tokens paints visually bottom→top. .kit-bag-section also column-reverse → icon row above label. .kit-bag-label drops vertical-rl + the rotate(180deg) scaleX(1.3) transform, reads horizontally. .kit-bag-row--scroll flips to column + overflow-y for the Tokens scrollable row.

`game-kit.js attachTooltip()` — 2-axis tooltip clamp matching sky-wheel.js + wallet.js's pattern. Horizontal: left edge stays within [1rem, viewport-ttW-1rem]. Vertical: prefer ABOVE the element; flip BELOW when tooltip is too tall to fit above (e.g. landscape kit bar w. Tokens row near top). Resets top/bottom on mouseleave so next show measures fresh.

## Tests

`apps/epic/tests/integrated/test_views.py` (+1 class, 6 ITs) — RoomBurgerBtnRenderTest: burger_btn renders, fan container renders, 5 sub-btns w. correct ids, icons match spec, burger-btn.js loaded, burger persists thru table_status.

`static_src/tests/BurgerSpec.js` (NEW, 19 Jasmine specs) — bindBurger() returns AbortController; click toggle; Escape close; click-outside close; opening burger closes kit dialog + bud panel when set; AbortController teardown removes listeners.

All 1356 IT+UT green (+6 new from RoomBurgerBtnRenderTest, was 1350). Jasmine suite green (was 220-something specs, +19 BurgerSpec).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:40:33 -04:00
Disco DeDisco
3ad372bc36 FT fix CI #342: seed log_tax_debit in test_saved_draw_renders_brief_banner — @taxman ledger sprint left the FT stale
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
CI pipeline #342 surfaced two errors, both on the same selector failure path:

1. `test_saved_draw_renders_brief_banner_with_next_free_draw_timestamp` (test_game_my_sea.py:1233) — DEFINITELY caused by my @taxman ledger sprint (f44a282). The Brief banner on /gameboard/my-sea/ is now server-driven via the `free_draw_brief_payload` context var, which `my_sea_lock` emits via `log_tax_debit` on the first card of a cycle. This FT bypasses `my_sea_lock` by ORM-creating the MySeaDraw row (`_save_draw_for_user` helper) — no tax-debit emitted, no payload, no banner, selector fails. Fix: explicitly call `log_tax_debit(self.gamer, "free_draw_locked")` after the ORM seed, mirroring what `my_sea_lock` would have done in the real flow. Kept the seed scoped to test 3 only (the only test that asserts the Brief); the other tests using `_save_draw_for_user` (picker phase, saved hand slots, DEL portal, etc.) don't need it.

2. `test_carte_blanche_equip_and_multi_slot_gatekeeper` (test_trinket_carte_blanche.py:82) — selector for `.my-sea-sign-gate-brief` (added in `a133a9c` per polish-9 race fix) fails ONLY in the CI batched run, NOT in isolation (3× local runs pass, including running the full carte test class). The most likely chain: the my_sea FT above fails alphabetically FIRST in the batch, raises NoSuchElement after the 10s wait_for timeout. That can leave the test runner / geckodriver / Firefox in a transient bad state that the next test (carte blanche) inherits. Common signature for this kind of CI-only cascade w. one root cause: fix the upstream test, downstream clears too.

Local sweep: the my_sea fix in isolation passes (12.881s); the full carte class passes (34.456s, 3 tests). Expectation: CI pipeline #343 will clear both errors w. this one-line fix.

If carte still fails on the next CI run after the my_sea fix lands, the next step is to inspect the CI screendump for the carte failure (not synced to local) to see what state the page was actually in.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:19:11 -04:00
Disco DeDisco
c84b3ba9f3 .btn font-family: explicit Segoe UI / system-ui stack — kills the Firefox UA-default inheritance trap
User reported the Firefox inspector "consistently said Georgia but renders sans-serif" on .btn-primary buttons (CONT GAME, OK, etc.). Root cause: Firefox's UA stylesheet sets a hard font-family on `<button>` elements that overrides any inherited body font, but the inspector's computed-style panel reports the inherited value (Georgia from `body`) and silently masks the UA override. On Windows that UA default is Segoe UI — which the user had grown accustomed to.

Fix: explicit `font-family: "Segoe UI", system-ui, sans-serif;` on `.btn` in `_button-pad.scss:11`. Three benefits:
- Inspector + render now agree (no more "lies about Georgia").
- Cross-OS uniformity: Windows → Segoe UI, macOS w. Office → Segoe UI, otherwise system-ui → San Francisco, Linux → OS UI font, last-ditch sans-serif.
- Kills the `<a class="btn">` vs `<button class="btn">` typeface split flagged in [[feedback-btn-vs-anchor-font-family]] (anchors used to render serif via inheritance, buttons sans-serif via UA default — both now render Segoe regardless of element).

Memory update: `feedback_btn_vs_anchor_font_family.md` rewritten — the "prefer <button> over <a> for typeface consistency" rule is OBSOLETE; element choice is now purely semantic (button for actions, anchor for navigation, form for POSTs). The iter-6b form-wrap SCSS-pin trap stays valid + carries over.

No tests needed — pure visual / cross-OS rendering change.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:03:05 -04:00
Disco DeDisco
4ddc0f810c sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.

Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.

Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.

Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
  - `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
  - `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
  - `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
  - `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.

Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.

Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).

All 1350 IT+UT green (1340 before + 10 new).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
Disco DeDisco
c0f4711589 my_sea AUTO DRAW: flash FLIP btn per-card for monodecks too — fall back to --single stack
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
User-spec 2026-05-26 PM: "AUTO DRAW on my_sea.html, when featuring gravity and levity decks, displays the FLIP .btn-reveal atop each card just before the next dealt card appears in the next card slot. Can you update the monodeck AUTO DRAW animation sequence to feature this FLIP .btn-reveal every time the AUTO DRAW runs here too?"

`placeNext` (inside `_autoDraw` in the my_sea.html inline IIFE) was querying `.sea-deck-stack--levity` / `.sea-deck-stack--gravity` only — polarized decks (Earthman) render those stacks + flash their FLIP btn between dealt cards via `_showOk(stack)`. Monodecks (Minchiate, RWS) render only `.sea-deck-stack--single`, so the polarity-keyed query returned null and the per-card FLIP-flash never fired.

One-line fix: `|| picker.querySelector(".sea-deck-stack--single")` fallback after the polarity-keyed query. `_showOk` + `_hideOk` already operate uniformly on whatever stack is passed in (the `.sea-stack-ok` btn renders identically in both template branches), so no other changes needed.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:42:56 -04:00
Disco DeDisco
c745d2453f my_sign main: extend sea_stage FLIP corner-swap fix — SPIN-click now hides + reanchors FLIP to visual bottom-left
Whack-a-mole follow-up to de9c97a (sea_stage reversed-card auto-rotate + FLIP corner-swap). User flagged 2026-05-26 PM: "Looks like the FLIP .btn-reveal in my_sign.html suffered from the change we applied to stop my_sea.html from letting FLIP rotate to top-right when the SPIN .btn-reverse is clicked (whack-a-mole). Since they have unified templates, for the most part, I bet squashing one bug caused the other (They should both be bottom-left)".

Actually my sea_stage scoping was correct — `.sea-stage-card.stage-card--reversed .sea-stage-flip-btn` only touched sea_stage. But my_sign main always had the same latent bug: SPIN rotates `.sig-stage-card` 180°, the in-card `.my-sign-flip-btn` (anchored bottom-left) rides along to visual top-right + reads upside-down. User caught the inconsistency post-fix on sea_stage + asked to extend the fix.

Changes mirror sea_stage exactly:

- `_card-deck.scss` counter-position rule extended w. a second selector — `.sig-stage-card.stage-card--reversed .my-sign-flip-btn` joins the existing `.sea-stage-card.stage-card--reversed .sea-stage-flip-btn`. Same `bottom:auto; left:auto; top:0.6rem; right:0.6rem; transform: rotate(180deg)` — visually re-anchors to bottom-left after the card flip + counter-rotates so the label reads upright.
- `_card-deck.scss` hide-during-rotate chain adds `.sig-stage-card[data-spinning] .my-sign-flip-btn` alongside the existing `.sea-stage-card[data-spinning] .sea-stage-flip-btn`.
- `templates/apps/billboard/my_sign.html`: `_toggleOrientation` now stamps `stageCard.dataset.spinning = "1"` + clears after `SIG_SPIN_MS = 400`. Same pattern as sea.js's SPIN handler.

My_sign-applet stays untouched — the applet renders the sig in its persisted polarity but never SPIN-rotates, so `.stage-card--reversed` never lands on `.my-sign-applet-card`. Comments updated to call out the carve-out.

All 25 affected tests green; Jasmine FT green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:40:35 -04:00
Disco DeDisco
bf79963fec @taxman ledger polish: My Posts applet preview uses Line.display_text; tax debits read "My Sea's …" not "my_sea.html …" — TDD
Two cleanups for the @taxman ledger sprint (f44a282) flagged in-flight by the user via visual inspection:

1. **My Posts applet preview missed the prefix-strip.** `_my_posts_applet_item.html` was rendering `item.latest_line.text|striptags`, which left the raw `[<iso timestamp>] ` prefix visible in the "Debits & credits" applet row body. Swapped to `item.latest_line.display_text|striptags` so the row preview matches the stripped rendering on /billboard/post/<uuid>/ + in the slide-down Brief banner. Other Post kinds are unaffected (`display_text` is identity for non-TAX_LEDGER lines).

2. **Devspeak in user-facing copy.** TAX_DEBIT_TEMPLATES read "Look!—my_sea.html FREE/PAID DRAW is locked. …" — the filename is internal developer language. Swapped to "Look!—My Sea's FREE/PAID DRAW is locked. …" so the prose references the user-facing app name. Substring assertions in test_tax / test_tax_briefs / test_bill_post_debits_credits all pin "{FREE,PAID} DRAW is locked" + "depositing a Token in" + "24h from the production of this log" — unaffected by the leading-clause rename.

All 25 affected tests still green; the broader 1340 IT+UT pass unchanged.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:30:11 -04:00
Disco DeDisco
f44a282007 @taxman Debits & credits ledger + NVM-persistent FREE/PAID DRAW Briefs — TDD
User-spec 2026-05-26 for /gameboard/my-sea/. The transient "Free draw locked" Brief that re-appeared on every page load is replaced by a server-driven Brief whose NVM dismissal persists per-cycle, AND every spend now lands a permanent line on a new @taxman-authored "Debits & credits" Post (so the info goes somewhere instead of vanishing on dismiss). Same NVM-persistence treatment for the new PAID DRAW Brief.

Lyric:
- RESERVED_USERNAMES adds "taxman"; get_or_create_taxman() parallels get_or_create_adman() (username=taxman, email=taxman@earthmanrpg.local, unusable password, searchable=False).
- New nullable User.{free,paid}_draw_brief_dismissed_at DateTimeFields — anchor stamps for the NVM-persistence semantics. Cleared by my_sea_lock (free) / my_sea_paid_draw (paid) on each fresh spend so the new cycle re-opens the Brief surface.
- Migration 0014_brief_dismissal_fields adds the fields + RunPython seeds @taxman (mirror of 0003_seed_adman).

Billboard:
- Post.KIND_TAX_LEDGER + TAX_LEDGER_POST_TITLE = "Debits & credits"; Brief.KIND_TAX_LEDGER for routing.
- _delete_unsolicited_admin_post_lines extended via _SYSTEM_AUTHOR_POST_KINDS tuple — TAX_LEDGER joins NOTE_UNLOCK in the post_save guard that nukes any Line w.o. admin_solicited=True.
- Brief.to_banner_dict adds dismiss_url slot (empty by default; populated by the gameboard view for TAX_LEDGER briefs) + uses line.display_text instead of line.text so the prefix is stripped on the banner too.
- Line.display_text property — strips the leading "[iso-timestamp] " prefix that log_tax_debit bakes into TAX_LEDGER Lines (the prefix exists ONLY to satisfy unique_together = (post, text) on repeat-slug spends; the per-Brief + per-Line created_at slots already render the user-facing moment). Identity for non-tax Lines.
- view_post / delete_post / abandon_post guards extended to treat TAX_LEDGER like NOTE_UNLOCK (POST forbidden, can't delete, can't bye).
- Migration 0008_tax_ledger_kind registers the new choices on Post.kind + Brief.kind.

Billboard tax module (new apps/billboard/tax.py):
- TAX_DEBIT_TEMPLATES — canonical body text per slug, with FREE DRAW / PAID DRAW / GATE VIEW button-labels wrapped in .btn-pri-name spans:
  - free_draw_locked → "Look!—my_sea.html [FREE DRAW] is locked. Next free draw available 24h from the production of this log."
  - paid_draw_locked → "Look!—my_sea.html [PAID DRAW] is locked. Another may be unlocked by depositing a Token in [GATE VIEW]."
- log_tax_debit(user, slug) — get-or-creates the user's TAX_LEDGER Post, appends a timestamp-prefixed Line authored by @taxman w. admin_solicited=True, spawns a Brief. Returns (post, line, brief).

Gameboard:
- my_sea_lock first-card-of-cycle branch calls log_tax_debit(user, "free_draw_locked") + clears free_draw_brief_dismissed_at. Response now includes free_draw_brief_payload (Brief.to_banner_dict w. dismiss_url populated) so the picker IIFE can surface the new Brief in-place w.o. a page reload — same affordance the prior _showFreeDrawLockedBrief provided, w. server-authored copy + NVM-persistence.
- my_sea_paid_draw after paid_through_at stamp calls log_tax_debit(user, "paid_draw_locked") + clears paid_draw_brief_dismissed_at. Next-page-load surfaces the new Brief via the context payload.
- New my_sea_dismiss_free_draw_brief + my_sea_dismiss_paid_draw_brief POST endpoints stamp the matching User anchor field; return 204. URLs at /gameboard/my-sea/brief/{free,paid}-draw/dismiss.
- my_sea view's context computes {free,paid}_draw_brief_payload via the new _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url) helper — returns the latest TAX_LEDGER Brief's to_banner_dict IF (dismissal anchor is None OR anchor < brief.created_at). Slug discrimination via line__text__contains="FREE DRAW" / "PAID DRAW" (kept the Brief schema flat — only two markers today, non-overlapping wordings).

Frontend (apps/dashboard/static/apps/dashboard/note.js):
- Brief.showBanner NVM handler now fires a fire-and-forget POST to brief.dismiss_url (if present) before removing the banner. Persistent-NVM kinds (TAX_LEDGER) supply it; transient kinds leave the field empty + the handler no-ops to the existing dismiss-only behavior. CSRF token pulled from the csrftoken cookie.

SCSS (static_src/scss/_billboard.scss):
- .post-line--system .post-line-text .btn-pri-name — inline emphasis (color: --quaUser, font-weight: 700, font-style: normal) on canonical .btn-primary button labels referenced in @taxman ledger prose. User-spec 2026-05-26 mid-flight clarification: log surface only, not the actual buttons.

Templates:
- templates/apps/gameboard/my_sea.html: replaces the inline _showFreeDrawLockedBrief({{ next_free_draw_at|date:'c' }}) invocation w. two {% if *_brief_payload %} blocks that json_script the payload + dispatch via a new _showTaxBrief(payload, bannerClass) helper. _postLock updated to call _showFreeDrawLockedBrief(body.free_draw_brief_payload) so freshly-emitted Briefs surface in-place w.o. a reload (same affordance as before, w. server payload).
- templates/apps/billboard/post.html: readonly-textarea / system-author-styling / bud-panel-suppression branches all extended to cover post.kind == 'tax_ledger' (parallel to existing 'note_unlock' cases). Line-text rendering uses line.display_text (strips the iso prefix) + treats @taxman the same as @adman (allow HTML rendering for the system-author safe text — required so the .btn-pri-name spans aren't escaped).

Tests:

UTs (apps/billboard/tests/integrated/test_tax.py — 11 specs):
- log_tax_debit creates Post/Line/Brief w. correct kind + author + admin_solicited.
- Both slug templates produce expected text (assertions tolerant of inline .btn-pri-name span HTML).
- Two spends share one Post w. two distinct Lines (timestamp prefix keeps unique_together happy).
- Unknown slug raises KeyError.
- post_save guard nukes unsolicited Lines on TAX_LEDGER Posts; solicited Lines survive.
- "taxman" is reserved (case-insensitive); get_or_create_taxman idempotent.

ITs (apps/gameboard/tests/integrated/test_tax_briefs.py — 13 specs):
- my_sea_lock first-card creates TAX_LEDGER Post + Line + Brief; mid-cycle upserts do NOT emit extra debits; clears free_draw_brief_dismissed_at.
- my_sea_paid_draw commit creates a separate TAX_LEDGER entry; clears paid_draw_brief_dismissed_at.
- Dismiss endpoints stamp the matching User anchor; reject GET (405); require login (302).
- my_sea context: *_brief_payload is None until first spend; populated after; suppressed after NVM-dismiss; returns after cycle reset.

Existing ITs adjusted (apps/gameboard/tests/integrated/test_views.py):
- test_view_triggers_brief_banner_when_active_draw_exists + test_empty_hand_brief_banner_still_triggered + test_view_does_not_trigger_brief_banner_without_active_draw — assertions retargeted from window._showFreeDrawLockedBrief(" to id="id_free_draw_brief_payload" (the new json_script payload tag).
- test_brief_next_free_draw_at_uses_user_anchor_not_paid_row — switched from HTML-substring assertion against the rendered ISO (now absent from the page) to a direct response.context["next_free_draw_at"] comparison. Same underlying invariant; cleaner assertion shape.

FT (functional_tests/test_bill_post_debits_credits.py — 1 spec):
- After two seeded debits, /billboard/post/<uuid>/ renders the "Debits & credits" title, both Line bodies (FREE DRAW + PAID DRAW), @taxman attribution, readonly input w. "No response needed at this time" placeholder, AND verifies the "[iso] " prefix is stripped from display.

All 1340 IT+UT green; new FT green; existing FTs unaffected by these changes.

Pending follow-up (recorded for next sprint):

Per user 2026-05-26 in-flight ask: refactor @adman concerns into apps/billboard/ad.py (paralleling the new apps/billboard/tax.py) — extract Note.grant_if_new's billboard-side concerns (Post/Line/Brief creation, prose templates) out of apps/drama/models.py into the same shape log_tax_debit now follows. Notated for after this sprint lands.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:26:42 -04:00
Disco DeDisco
7f6c0c2883 FT fix CI #340: re-seed Earthman deck in GameboardNavigationTest setUp — TransactionTestCase flush trap
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
`test_game_kit_panel_shows_token_inventory` (a133a9c's polish-9 FT fix) was looking for `id_kit_earthman_deck` in the Game Kit applet. CI #340 surfaced that the selector wasn't rendering — same `TransactionTestCase` migration-seed flush trap documented in `feedback_transactiontestcase_flush.md`:

1. `LiveServerTestCase` derives from `TransactionTestCase` → DB flushed between tests → migration-seeded `DeckVariant(slug="earthman")` row vanishes.
2. `apps/lyric/models.py:537`'s `DeckVariant.objects.filter(slug="earthman").first()` returns None in the post_save signal → `unlocked_decks.add(earthman)` silently skipped.
3. Gameboard view passes `request.user.unlocked_decks.all()` as `deck_variants` → empty → applet partial falls through to `{% empty %}` `id_kit_card_deck` placeholder instead of the per-deck `id_kit_{{ deck.short_key }}_deck` element the FT expects.

Fix mirrors the 14+ other FTs already using this helper: call `_seed_earthman_sig_pile()` in `setUp` before `create_pre_authenticated_session` fires the signal. The helper is `get_or_create`-based + idempotent.

Selector itself was NOT renamed — `short_key = slug.split('-')[0]` still yields `"earthman"` from slug `"earthman"`, so `id_kit_earthman_deck` is correct.

Verified locally: the test runs green w. the seed call in place.

Pre-existing in `git status`, bundled per project commit-everything rule:
- `src/.coveragerc` — add `*/delete_stale_my_sea_draws.py` to coverage omit list (one-off management script doesn't need coverage measurement)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:18:18 -04:00
Disco DeDisco
de9c97a2f8 sea_stage reversed-card open: slow auto-rotate-in + FLIP-btn corner-swap — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
User spec 2026-05-26 for the sea_stage modal (shared by my_sea.html today, room.html SEA SELECT later):

1. **Reversed card opens rightside-up, then slowly auto-rotates 180°.** Preserves the original text-card legibility convention (modal opens w. upright frame + dimmed upright title + highlighted upside-down reversal title) but adds a JS-driven 0.8s rotate-in (2× the SPIN transition's 0.4s) that lands the card upside-down. Stat-block still gets `.is-reversed` immediately so the REVERSAL label is highlighted from the first frame. `_populate` no longer slaps `.stage-card--reversed` on the card up front; `_showStage` schedules `_autoRotateToReversed` 400ms post-flip-in (past the 0.35s `sea-flip-in` keyframe + buffer). Auto-rotate mirrors the SPIN-click pattern — strip the flip-in keyframe class, force reflow, inline-override `transition-duration` to 0.8s, then toggle `.stage-card--reversed` so the static `transition: transform` lerps the rotation. Timers tracked on module-level handles so dismiss-mid-rotate + reopen-mid-rotate cancel cleanly w.o. stacking handlers (cleared in `_populate`, `_hideStage`, `_testInit`).

2. **FLIP btn always lands at visual bottom-left, regardless of reversal.** Previously the in-card btn rode along w. the 180° rotation, ending top-right (user-flagged as wrong: "the game_kit.html carousel already handles this perfectly—FLIP only ever appears bottom-left, regardless of reversal"). The fan-flip-btn pulls this off by being a SIBLING of `.tarot-fan-wrap` (lives outside any rotating card) — sea_stage's btn sits INSIDE `.sea-stage-card` along w. my_sign / my_sign-applet's shared DOM, so restructuring out wasn't an option. Solved via CSS counter-positioning instead: `.sea-stage-card.stage-card--reversed .sea-stage-flip-btn` re-anchors to card-local top-right + counter-rotates 180° on the btn itself, landing it at visual bottom-left w. upright label. Companion `[data-spinning]` attr (joined to the existing flip-btn-mid-flip selector chain) hides the btn during the rotation window so it never jumps visibly between corners. Set by both `_autoRotateToReversed` (0.8s window) + the SPIN click handler (0.4s window).

TDD coverage — `SeaDealSpec.js` gets a new describe block w. jasmine.clock-driven specs:
- `reversed-card open` × 6: `is-reversed` set immediately on stat-block; `.stage-card--reversed` NOT set immediately on card; `data-spinning` NOT set pre-flip-in; both set after 500ms; both cleared (well, `.stage-card--reversed` persists) after 1400ms; upright cards don't trigger auto-rotate at all
- `SPIN click hides FLIP via [data-spinning]` × 2: set on click; cleared after 500ms

Files:
- `apps/epic/static/apps/epic/sea.js` — `_populate` defers `.stage-card--reversed` + clears in-flight rotate state; `_showStage` schedules `_autoRotateToReversed` for reversed cards; SPIN handler sets `data-spinning` for the SPIN_MS window; `_hideStage` + `_testInit` clear rotate timers + spin attr; new module-level timer handles + duration constants
- `static_src/scss/_card-deck.scss` — `.sea-stage-card.stage-card--reversed .sea-stage-flip-btn` counter-positioning rule (bottom→top, left→right, transform: rotate(180deg)); `[data-spinning]` joined to the unified flip-btn mid-rotate-hide selector chain
- `static_src/tests/SeaDealSpec.js` + `static/tests/SeaDealSpec.js` — new describe blocks for the two new behaviors

Jasmine FT green. User-verified visually on `/gameboard/my-sea/` w. an upside-down reversed-card open: "Visually verified just now in my_sea.html, very nicely done".

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:49:48 -04:00
Disco DeDisco
a133a9c1c3 FT fixes for polish-9 spec changes — CI #338 surfaced 4 stale assertions; sig-gate Brief race exposed by removing implicit wait
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
CI pipeline #338 caught 4 FT failures cascading from yesterday's polish-9 + applet realignment commits (955bdc7, 652cef0). All four are stale assertions in FT code — no production code changes needed. ITs were already updated in the original commits; missed the parallel FT updates.

**(1) `test_gear_btn_opens_menu_with_nvm_only`** (test_game_my_sea.py:1851) — NVM btn changed `<a class="btn" href="...">` → `<button onclick="location.href=...">` (per [[feedback-btn-vs-anchor-font-family]] sans-serif fix). FT was reading `href` attr (returns None on buttons → `TypeError: argument of type 'NoneType' is not iterable`). Switched to read `onclick` attr (w. `or ""` guard against None).

**(2) `test_del_btn_is_disabled_until_hand_complete`** (test_game_my_sea.py:982 → renamed) — DEL btn now un-disables on the FIRST draw, not at hand completion (per the new state-machine spec, `_setHasDrawn(true)` fires on first deposit + AUTO DRAW POST-commit). Renamed → `test_del_btn_is_disabled_until_first_draw`; inverted the mid-draw assertion (was: still-disabled after 1 draw → now: un-disables immediately after 1 draw); kept the post-completion check (DEL stays enabled).

**(3) `test_carte_blanche_equip_and_multi_slot_gatekeeper`** (test_trinket_carte_blanche.py:89) — TWO issues here, only the first was symptomatic in CI:
  - Step 2 used `#id_kit_free_token` on /gameboard/ as a "non-trinket, no mini-tooltip" demo target. Free Token moved off Game Kit applet to Wallet applet per the equippables-only spec; no non-equippable icon left on Game Kit to demo w. Dropped step 2 entirely — the test's primary thing (Carte multi-slot equip flow at steps 3+) is intact.
  - SECOND-ORDER issue uncovered when (1) above stopped masking it: the deleted step 2 used to provide a ~5+ second wait (find Free Token + hover + wait for tooltip portal). That wait was enough for the auto-firing `.my-sea-sign-gate-brief` (slides in on /gameboard/ for users w/o a sig via the My Sea applet's `{% include _my_sea_sign_gate_brief.html %}` branch) to settle. Without the wait, the Brief is mid-slide when step 8 tries to click `id_create_game_btn` → `ElementClickInterceptedException` (Brief obscures button). Added explicit `.my-sea-sign-gate-brief .btn-cancel` wait-then-click between steps 1 + 3 to dismiss the Brief before proceeding.

**(4) `test_game_kit_panel_shows_token_inventory`** (test_gameboard.py:74) — TWO issues here too:
  - Step 7's `#id_kit_free_token` Free Token tooltip assertion. Same removal as (3). Replaced w. a NEGATIVE assertion that the element does NOT exist on Game Kit (regression guard against accidentally re-adding non-equippable items).
  - SECOND-ORDER again: step 9's `#id_kit_card_deck` check was a stale assertion that predated the `apps/lyric/models.py:540` `unlocked_decks.add(earthman)` post_save signal. `id_kit_card_deck` is the `{% empty %}`-branch placeholder, only rendered when `deck_variants` is empty. `capman@test.io` (the test fixture user) gets Earthman auto-unlocked → the concrete `id_kit_earthman_deck` renders instead. This was a latent stale assertion that only surfaced now because step 7's Free Token failure used to short-circuit the test before it reached step 9. Switched check to `id_kit_earthman_deck`.

Pattern worth noting for future cross-cutting refactors: when a test step has a side-effect wait (`wait_for(... tooltip displayed ...)`), removing it can unmask sig-gate / palette / Brief banners that auto-slide in on page load. The Brief race in (3) wasn't a NEW bug introduced by polish-9; it was always there, masked by the timing of the removed step. Same for the stale `id_kit_card_deck` assertion — predates the signal change; only surfaced when the failure cascade moved past it.

Discipline note for this session: user explicitly overrode [[feedback-ft-run-discipline]] when "specifically working on FTs, new or old" — ran each fix locally by full dotted path to verify before committing. All 4 green locally (8-16s each).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:34:02 -04:00
Disco DeDisco
652cef09c0 image tree refactor: cards-faces/<family>/<variant>/ + RWS deck import (78 cards + back, renamed + pngquant'd)
Two related sub-changes, bundled because the new image_url path structure has to land in the same commit as the actual file relocations to keep `manage.py runserver` resolvable at every revision.

**(1) `DeckVariant.variant_dir_slug` + image-path tree restructure** — `apps/epic/models.py`. New `variant_dir_slug` property on DeckVariant returns the subdirectory name under `cards-faces/<family>/` for this deck's images. Mapping locked in 2026-05-26:
  - earthman family → "default"  (single-canonical today, locks the variant tier now so future Earthman editions slot in at `earthman/<variant>/` w.o. a path migration)
  - slug startswith "tarot-"     → strips that prefix (RWS slug `tarot-rider-waite-smith` → `rider-waite-smith`; "tarot-" is redundant under family=english)
  - otherwise                    → uses slug as-is (italian/minchiate-fiorentine-1860-1890)

Both `DeckVariant.back_image_url` + `TarotCard.image_url` updated from `cards-faces/<slug>/<filename>` to `cards-faces/<family>/<variant_dir_slug>/<filename>`. Flat → 2-tier tree groups by tarot tradition (italian/english/playing/earthman) rather than scattering 20+ deck dirs at the top level — payoff is most visible when adding multi-variant decks within a family (e.g., future RWS Centennial Edition, Pamela-A pristine scans, both land alongside the original at `english/<variant>/`).

Why this naming over alternatives the user considered:
  - `western-tarot/` — too broad (Italian Minchiate is also western tarot, defeats the partition)
  - `hermetic-dawn/` — too narrow (RWS lineage but doesn't generalize to pre-GD Marseille or non-RWS English decks)
  - `english/` — matches the existing `DeckVariant.FAMILY_CHOICES` field verbatim (source of truth, no new enum)

No tests assert on `image_url` paths (only on `image_filename` — the bare PNG names, which are unchanged). No JS references `cards-faces/` directly — sea.js + stage-card.js + utils.py all consume `image_url` server-rendered.

**(2) Minchiate Fiorentine 1860-1890 dir move** — 98 PNGs relocated from `cards-faces/minchiate-fiorentine-1860-1890/` to `cards-faces/italian/minchiate-fiorentine-1860-1890/`. Initially used `git mv source/ italian/` which Windows-flattened the move (files landed directly in italian/ instead of the nested variant subdir) — recovered by creating the variant subdir explicitly + `git mv *.png variant/`. Worth remembering for future deck imports: on Windows, `git mv dir/ existing_parent_dir/` does NOT auto-nest when the destination has existing entries.

**(3) RWS deck import** — 78 card images + 1 card-back PNG, dropped into `cards-faces/english/rider-waite-smith/`. Source: Wikipedia Commons (Public domain, attributable to Pamela Colman Smith). All scraped at 960px width per the size-vs-quality tradeoff conversation (matches the contour-stroke filter chain's largest CSS-display surface w. retina headroom; full-resolution 2100×3600 was 11.68MB/card → would balloon the page weight).

Filename normalization via one-shot `d:/tmp/rename_rws.py`:
  - Wikipedia patterns: `960px-Ace_of_Cups_(Rider-Waite_Smith_tarot_deck).png` → `tarot-rider-waite-smith-cups-01.png`
  - Trumps: `960px-The_Fool_(...)` → `tarot-rider-waite-smith-majors-00-the-fool.png` (English family uses "majors" not "trumps" per `_TRUMP_CATEGORY_BY_FAMILY` mapping)
  - Courts: `Page/Knight/Queen/King_of_<Suit>` → ranks 11/12/13/14 w. court-name suffix (e.g., `-cups-13-queen.png`)
  - Special: Aces of Pentacles + Aces of Swords Wikipedia-named as "One_of_..." instead of "Ace_of_..." (RANK_BY_WORD dict handles both)
  - Special: "Wheel_of_Fortune" major initially matched the MINOR_RE regex (Wheel + of + Fortune); fixed by adding both-rank-and-suit-in-known-vocab guard so non-real-suit "of" patterns fall through to MAJOR_RE
  - Card back: `Waite-Smith_Tarot_Roses_and_Lilies.png` → `tarot-rider-waite-smith-back.png`

Also: Queen of Cups was missing from the initial Wikipedia batch (caught by per-suit count audit: cups=13, others=14); user grabbed + dropped it in separately, scripted rename was rerun for that single file.

pngquant pass: `--quality=65-85 --speed=1 --strip --skip-if-larger --ext=.png --force` — 219MB → 76MB across the 78 cards (~65% reduction, ~975 KB/card average). Queen-of-Cups single-file pass: 2.4MB → 856KB.

Tests: 834/834 green across epic + gameboard + billboard (and 181/181 epic-isolated post-rename + collectstatic). collectstatic recopied all 176 PNGs (98 minchiate + 78 RWS) into the build dir; manifest hashes refresh.

Tomorrow: A.8 room.html sprint can now proceed w. RWS image-equipped (`has_card_images=True`) the same way Minchiate already does — image-mode SCSS already in place from A.5-A.7 polish. Future Shop applet entries: user mentioned a few decks slated as exclusively-purchasable via wallet shop (paid-only deck variants).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:51:12 -04:00
Disco DeDisco
955bdc7f67 polish + bugfix session — wallet/Game Kit applet realign; my_sea label/shadow polish; DEL/FLIP state machine; sig-change cooldown loophole closure; sky-wheel planet shadow; Fiorentine additive numerals; kit-bag DOFF async refresh — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
End-of-session bundle 2026-05-26 covering ~10 distinct threads atop the A.7.5-polish-8 sky-wheel mini-portal commit (9cdd2cd). A.8 room.html sprint deferred per user — waiting on image scraping for RWS + future decks so the room can apply the image-mode pattern uniformly w.o. straddling text-mode fallback for unequippable Earthman Shabby Cardstock.

**(1) Game Kit + My Wallet applet realignment** — user spec "this isn't a place for tokens" / "only equippables should be there". Game Kit applet (/gameboard/, _applet-game-kit.html) drops the Free Token block — only PASS/BAND/CARTE/COIN trinkets + decks + dice remain. Free + Tithe tokens MOVED to the My Wallet applet on /dashboard/ (_applet-wallet.html rewrite). All trinkets COPIED into Wallet w. same .tt tooltip + DON/DOFF wiring so the user can equip from either surface. Stacked free/tithe icons (single icon per type) carry a .shop-badge ×N count (fa-coins for free, fa-piggy-bank for tithe — the latter standardized from outlier fa-hand-holding-dollar, now matching wallet / kit_bag / shop seed / FTs). Writs placeholder gets the same .token + .tt chrome ("Base currency unit ; Earned at the gate, spent in the shop"). 99+ cap on all badges. home_page view in apps/dashboard/views.py now passes pass/band/carte/coin + free/tithe tokens + counts + equipped_trinket_id. gameboard.js loaded on dashboard for the hover-portal tooltip system; #id_game_kit wrapper added (uses display: contents to stay transparent to the section-grid layout). Standalone game_kit.html page (_game_kit_sections.html) also reorganized — trinkets/tokens/decks each use bare .token icons w. centered flex row + 2rem gap, 1.5rem font-size to match gameboard sizing. id_game_kit outer wrapper data attrs (equipped-id, equipped-deck-id, in-use-deck-ids) feed buildMiniContent() for Equipped/Not Equipped/In-Use status.

**(2) My Sea label + shadow polish (my_sea.html Cross + applet)** — user spec "labels appear below and beneath the card, w. the card's shadow obscuring the very top of the label" per the GRAVITY/LEVITY .sea-stack-name pattern. .sea-pos-label repositioning: CROWN + COVER ABOVE slot (bottom: 100%; translate(-50%, -0.4rem)), LAY + CROSS BELOW slot (top: 100%; translate(-50%, 0.3rem)), LEAVE + LOOM increased breathing room (translate -0.4rem LEFT / 0.4rem RIGHT — was 0.1rem overlap). CROWN cell translateY(-0.5rem) UP + LAY cell translateY(0.5rem) DOWN for COVER/CROSS label breathing room. Filled-card downward shadow chain (1px 2px 0 black, 0 4px 0 black-faint, 2px 5px 5px black-blur) scoped to .my-sea-cross .sea-card-slot--filled only — empty dashed placeholders stay shadowless per user spec ("only the cards that replace [slots] should [have shadows]"). Four rotation-correction overrides for box-shadow rotating w. element transform: base (0deg), reversed (180deg sign-flip), cross (90deg matrix rotation → 2px -1px), cross+reversed (270deg → -2px 1px). Saved here for future reference since the matrix derivation is non-obvious: CSS rotate(θ) CW maps offset (a, b) → screen (a·cos θ − b·sin θ, a·sin θ + b·cos θ); solving for unrotated offsets that produce screen-down-right post-rotation gives the 4 chains. My Sea applet .my-sea-slot-label (z-index 0, margin-top 0.15rem) + .my-sea-slot--filled shadow + reversed-variant shadow inversion all mirror the page treatment.

**(3) DEL btn + FLIP btn state machine** — user spec: DEL un-disables as soon as ANY card drawn (was gated on hand_complete) ; FLIP btn .btn-disabled + text swap to × once hand complete. _setComplete(on) toggles FLIP btn class + label (parity w. DEL convention: × disabled / word active) ; new _setHasDrawn(on) helper extracted (was bundled in _setComplete). Wired into 4 transitions: (a) manual deposit _filled === 1, (b) initial page-load seed when _filled > 0, (c) AUTO DRAW path post-POST (CRITICAL FIX — was missing, only manual deposit synced DEL even though server already committed all cards on AUTO DRAW), (d) _resetHand spread-switch reset. Template DEL btn gates on saved_by_position (any draw); FLIP btn gates on hand_complete. Test test_partial_hand_del_btn_carries_btn_disabled inverted to test_partial_hand_del_btn_is_enabled per the new spec.

**(4) Sig-change MySeaDraw RESET (cooldown loophole closure)** — user-reported revenue-stream loophole 2026-05-26: switching sig used to re-open the FREE DRAW gate + forfeit any paid-draw credit, because apps/gameboard/views.py:266's `in_cooldown = active_draw is not None` keyed entirely off the MySeaDraw row's existence (NOT off User.last_free_draw_at, which is the cooldown TIMER but doesn't drive the in_cooldown decision). Initial draft DELETED the row on sig change — turned out too aggressive: lost both the cooldown anchor (created_at via the active_draw check) AND the paid-state fields (deposit_token_id, paid_through_at). FIX: save_sign on actual sig change `.update(hand=[], significator_id=new, significator_reversed=new)` — preserves cooldown + paid revenue, just resets the hand + sig snapshot. clear_sign left untouched (sig-cleared user can't draw anyway per my_sea_lock's no_significator guard; row sits dormant until re-pick routes through save_sign's reset). Guarded w. sig_changed so re-saving the same sig is a no-op. User.last_free_draw_at was always safe — User-level field, only ever set in my_sea_lock, never cleared (user confirmed the Brief shows 11:59pm consistently). Subtle architectural note for future: the in_cooldown decision being row-existence-based rather than timestamp-based is the load-bearing implicit dependency this loophole exposed; any refactor that delete()s the row needs to either flip in_cooldown to consult last_free_draw_at OR preserve the row as we did here.

**(5) Kit-bag DOFF async refresh** — user-reported 2026-05-26: deck disappears entirely from kit-bag on first DOFF; only manual page refresh restores the placeholder. Root cause: _syncKitBagDialog() in gameboard.js did card.querySelector('i') for the placeholder icon — worked for trinket/token cards (single FA <i>) but BROKE for image-equipped decks whose card-stack icon is <svg class="deck-stack-icon"> (no <i> to copy → empty placeholder div). DROP the client-side optimization, route both DOFF paths thru _refreshKitDialog() (symmetric w. DON). Single source of truth = server-rendered _kit_bag_panel.html's placeholder branch (re-renders _deck_stack_icon.html w.o. the deck arg for the empty-fill SVG).

**(6) Sky-wheel planet circle shadow** — user spec "tight 1px 1px black shadow at opacity 0.7 on planet circle groups in all sky locations". Base `filter: drop-shadow(1px 1px 0 rgba(0,0,0,0.7))` on .nw-planet-group so planet badges lift off the wheel rings on /dashboard/sky/ + My Sky applet + any future surface. Hover/active state chains shadow + glow ("drop-shadow ... ; drop-shadow(0 0 5px primary-lm)") since CSS filter REPLACES rather than APPENDS — shadow has to be re-stated on the hover rule to persist during interaction. Elements/signs/houses groups keep their glow-only hover (the request was planet-specific).

**(7) TarotCard suit_icon + Fiorentine additive numerals** — (a) suit_icon property pre-checks for major arcana trump 0 → fa-hat-cowboy-side (Fool/Nomad/Matto archetype) and trump 1 → fa-hat-wizard (Magician/Schizo/Bagatto archetype), pinned BEFORE the self.icon branch so even a deck seed supplying a different icon for these ranks normalizes to the convention. Earthman's seed already aligns; Minchiate (empty icon field) used to fall thru to fa-hand-dots. (b) _to_roman() adds _FIORENTINE_ADDITIVE_NUMERALS = {4:'IIII', 19:'XVIIII', 24:'XXIIII', 29:'XXVIIII', 34:'XXXIIII', 39:'XXXVIIII'} pre-check — locked-in 6-exception list per user-corrected spec (initial draft used universal additive form, user clarified "no, only these specific ones, e.g. trump 9 still prints IX + trump 14 still prints XIV per the actual Minchiate deck art"). +2 regression tests: additive overrides + non-overridden subtractive (9=IX, 14=XIV, 44=XLIV, 49=XLIX).

**(8) Gear menu NVM font fix** — _my_sea_gear.html's NVM btn changed from <a class="btn"> to <button onclick="location.href=..."> per [[feedback-btn-vs-anchor-font-family]] (anchor inherits body serif font; button stays sans-serif by browser default). Brief's NVM uses <button> + reads correctly — this matches it.

**(9) Image-mode slot transparency overrides** — 3 surfaces got `overflow: visible` (base overflow: hidden was clipping the contour-stroke filter chain) + transparent bg/border re-states for image-equipped Minchiate cards on (a) .my-sea-cross .sea-card-slot--filled + image variant, (b) .sig-stage-card.sea-sig-card.sig-stage-card--image base + levity-polarity nested override, (c) .sea-deck-stack--single .sea-stack-face:has(.sea-stack-face-img) (using :has() to key off the conditional back-img child). Followup to A.7.5-polish-* sprint — those surfaces' image-mode bg overrides didn't include overflow.

Tests: 1336/1336 IT+UT total green (was 1322 before the session). No FT runs per [[feedback-ft-run-discipline]]; visual verify ongoing by user across the session via Firefox reload.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:18:51 -04:00
Disco DeDisco
9cdd2cda68 Sky-wheel Aspected / Unaspected mini-portal — new #id_mini_tooltip_portal for the sky tooltip's DON|DOFF apparatus + dashboard My Sky applet parity + styling polish.
User-spec 2026-05-25 PM ("To the #id_sky_tooltip, whenever it has a DON|DOFF apparatus, we should add a #id_mini_tooltip_portal except, instead of Equipped|Unequipped, this would feature an Aspected|Unaspected toggle"). Mirrors the game-kit / wallet Equipped-Unequipped micro-tooltip pattern — text-swaps "Aspected" / "Unaspected" tied to sky-wheel's `_aspectsVisible` state.

**(1) `sky-wheel.js`** — 3 new helpers (`_updateAspectMiniPortal` / `_showAspectMiniPortal` / `_hideAspectMiniPortal` / `_positionAspectMiniPortal`) + element cache (`_miniPortalEl`) + 5 integration points (cache in `_injectTooltipControls`; show in `_activatePlanet` + `_activateAngle`; hide in `_activateElement` + `_activateSign` + `_activateHouse` + `_closeTooltip`; text-swap in `_updateAspectToggleUI`). State derives from existing `_aspectsVisible` global — single source of truth, no parallel tracking. Only the planets + angles rings show the apparatus (per existing UX); the elements/signs/houses rings hide it w. the rest of the DON/DOFF buttons.

**(2) Positioning** — mirrors `gameboard.js:285-287`'s right-anchored pattern (was left-aligned + 6px gap in the first draft): pin mini-portal RIGHT edge to main tooltip's right edge, 4px below the tooltip's bottom. Text width changes grow/shrink leftward — same visual logic the Game Kit's Equipped/Unequipped already uses.

**(3) z-index** — set to 150 inline via JS for the sky surface (default `#id_mini_tooltip_portal { z-index: 9999 }` from `_gameboard.scss` is universal — too high for the sky tooltip's PRV/NXT buttons, which inherit the tooltip's z-index 200 stacking context). User-reported "make sure its z-index falls behind the NXT button, as now it's in front of PRV". The sky tooltip body itself sits at z-index 200; mini-portal at 150 falls below it where they overlap (they don't — the mini sits below the tooltip body) but lets the absolutely-positioned PRV/NXT btns inside the tooltip render on top.

**(4) Styling** — bumped `#id_mini_tooltip_portal` font-size 0.8em → 0.95em + added `padding: 0.35rem 0.75rem` + `border-radius: 0.3rem` per user-spec "a bit bigger both in dimensions and font-size". Universal change (affects game-kit + wallet mini-portals too) — visually closer to the main tooltip's text scale w/o approaching it.

**(5) Dashboard parity** — `dashboard/home.html` gains the same `<div id="id_mini_tooltip_portal" class="token-tooltip token-tooltip--mini">` scaffold so the My Sky applet (`_applet-my-sky.html`) picks it up. Without this, the applet's sky-wheel rendered the main tooltip but the mini-portal `getElementById` would return null. Now both the standalone /dashboard/sky/ page + the dashboard's My Sky applet host the same mini-portal scaffold; sky-wheel.js caches whichever one is present on init.

Tests: 1314/1314 IT+UT total green (76s; pure SCSS + JS + template changes, no test surface — no new conditional or template branch to test directly). Visual verify on /dashboard/sky/: Saturn planet tooltip opens w. DON visible + "Unaspected" mini-portal below-right; click DON → text swaps to "Aspected" + aspect lines draw on wheel; click DOFF → swaps back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:31:52 -04:00
Disco DeDisco
c4bbac0938 A.7.5-polish-7 h2 3-letter suffix spacing — Sky/Sea/Kit use space-around instead of space-between so the trio doesn't park letters at slot edges. User-reported 2026-05-25 PM: "These three 3-letter titles—Sky, Sea, and Kit—take up way too much space" — the default justify-content: space-between on the suffix word-span (_base.scss:248-253) put the first + last letter flush against the slot's edges + left a yawning gap mid-span ("S E A" reads as a stretched-apart trio).
**Fix** (length-keyed via data attr — extensible to other lengths):
1. `base.html` h2 letter-splitter script adds `span.dataset.letters = String(text.length)` to every word-span as it splits. Length surfaces as `data-letters="3"` / `"4"` / etc. on the DOM.
2. `_base.scss`'s h2 block gets a new `> span[data-letters="3"] { justify-content: space-around; }` override AFTER the default `> span` rule. `space-around` puts equal padding on both sides of each letter, clustering the trio inside the slot rather than splaying it.

Surfaces affected (any suffix == 3 letters): Game Sky, Game Sea, Game Kit, Dash Sky — basically every page whose `{% block header_text %}` renders a 3-char suffix tail.  Other lengths (Sign / Note / Post / Board / Wallet etc.) unaffected — they keep the default `space-between` because the larger letter count fills the slot naturally w/o looking stretched.

**Why length-keyed selector over class-naming**: future expansion. If a 2-letter title ever lands (hypothetical AP / WR), the same selector pattern (`[data-letters="2"]`) bolts in w/o needing a new class taxonomy. The data attr is universal + readable in DevTools. The same hook also opens up `[data-letters]` font-size scaling later if needed.

**No regression risk for prefix word**: prefixes are always 4-letter (BILL / DASH / GAME etc. per the `_base.scss` comment at line 222: "First word (always 4 letters)") so `[data-letters="3"]` never matches them; default `space-between` continues for prefix. Verified across all `{% block header_text %}` consumers — none use a 3-letter prefix.

Tests: 1314/1314 IT+UT total green (74s; pure SCSS + 1-line JS data-attr addition, no test surface). Visual verify pending user confirmation but the change is contained: the new rule is additive at higher specificity (`> span[data-letters="3"]` = 0,0,2,0 vs `> span` = 0,0,0,1 child combinator) + only justifies-content differently; nothing else cascades.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:40:21 -04:00
Disco DeDisco
d10ef94161 A.7.5-polish-6-fix applet FLIP-btn hover-reveal — drop leftover _billboard.scss rule whose ID-context cascaded above the hover-reveal. User-reported 2026-05-25 PM after polish-6 (b308115): "still not seeing it on My Sign applet, but looks good everywhere else". DOM inspection confirmed the btn was present + opacity:0 at rest as expected, but real-hover never flipped it to opacity:1.
Root cause: polish-5 (1e2041e) refactored the applet FLIP-btn position rule to `@include flip-btn-base` but LEFT IT NESTED inside the `#id_applet_my_sign { ... }` outer block of `_billboard.scss`. So the rule resolved to `#id_applet_my_sign .my-sign-applet-card .my-sign-applet-flip-btn` (specificity 0,1,2,0 — 1 ID + 2 classes) — and OUT-CASCADED the hover-reveal rule `.my-sign-applet-card:hover .my-sign-applet-flip-btn` (specificity 0,0,3,0 — 3 classes incl. pseudo, no ID) on the ID axis. Result: opacity:0 from the leftover rule stuck even on real `:hover`, the polish-6 unified positioning rule in `_card-deck.scss` was a redundant 0,0,1,0 also-ran, and the user couldn't see the btn.

Fix: drop the leftover position + mid-flip rules from `_billboard.scss` entirely. The polish-5/6 unified rules in `_card-deck.scss` (`.my-sign-flip-btn, .my-sign-applet-flip-btn, .sea-stage-flip-btn { @include flip-btn-base; z-index: 25; bottom: 0.6rem; left: 0.6rem }` + `.sig-stage-card[data-flipping] .my-sign-flip-btn, .my-sign-applet-card[data-flipping] .my-sign-applet-flip-btn, .sea-stage-card[data-flipping] .sea-stage-flip-btn, ...` + hover-reveal trio) now cover the applet too at clean 0,0,1,0 / 0,0,3,0 / 0,0,2,1 specificities — hover-reveal wins by class count, no ID-context contamination.

Lesson: when refactoring CSS to a shared mixin/placeholder, also LIFT THE SELECTOR OUT of any ID-scoped outer block. Leaving the rule inside `#id_*` inflates its specificity in ways that can shadow other shared rules (esp. hover/state rules that lack an ID). The leftover comment block in `_billboard.scss` now documents the lift-out for future-me / future-DRY-passes.

Tests: 1314/1314 IT+UT total green (74s; pure SCSS rule deletion, no test surface). Visual verify pending user confirmation — the cascade is now clean (hover-reveal wins; transition: opacity 0.3s applies; btn fades in on real card hover + fades out on un-hover).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:52:51 -04:00
Disco DeDisco
b308115fcf A.7.5-polish-6 FLIP btn everywhere — applet gate dropped + sea_stage modal gets FLIP. User-spec 2026-05-25 PM ("If it's interfering to have bespoke rules, just allow the FLIP btn everywhere, including in my_sea.html") follow-up to polish-5 (1e2041e).
**(1) `_applet-my-sign.html`** — FLIP btn moved OUTSIDE the `{% if card.deck_variant.has_card_images %}` + nested `{% if not card.deck_variant.is_polarized %}` gates. Now renders as a direct child of `.my-sign-applet-card` for ALL cards regardless of mode/polarity. Back-img element stays gated (back-img is meaningless for polarized decks or text-mode — would render an empty src). JS handler in the same template ungated too (was wrapped in matching `{% if %}` blocks); now always wires + gracefully no-ops on click when no `.sig-stage-card-back-img` sibling exists. Card-element selector broadened from `.my-sign-applet-card--image` (image-mode only) to `.my-sign-applet-card` (any mode).

**(2) `_sea_stage.html`** — added `<img class="sig-stage-card-back-img">` (gated on `request.user.equipped_deck.has_card_images and not is_polarized` — same condition as my_sign.html's main page back-img) + `<button class="sea-stage-flip-btn">` (unconditional). Both nested INSIDE the `.sig-stage-card.sea-stage-card` for card-relative positioning. Multi-user gameroom is a known limitation here — the back-img src is the room viewer's deck-back, not the drawing gamer's, which is wrong when different gamers' decks have different backs. Parked for a future multi-user polish pass (called out in template comment).

**(3) `_card-deck.scss`** — extended the polish-5 shared FLIP-btn rule trio (positioning + hover-reveal + mid-flip-hide) to include `.sea-stage-flip-btn` across all 3 declarations. Now all 4 surfaces (my_sign main / applet / sea_stage / fan carousel) share the same opacity-0-default + hover-reveal + display:none-mid-flip behavior — single source of truth.

**(4) `sea.js`** — added FLIP btn click handler in the init() function next to the existing SPIN/FYI handlers. Mirrors the `_flipToBackAnimated` shape from my_sign.html / _applet-my-sign.html: rotateY 0→90→0 over 500ms, toggle `.is-flipped-to-back` at midpoint, `[data-flipping]` attr for SCSS mid-flip-hide. Same defensive no-op pattern as the applet — bails when no `.sig-stage-card-back-img` sibling exists. Behavior for polarized text-mode decks (no back-img rendered): click is a no-op. Polarized image-mode (future Earthman art): also no-op since back-img is server-gated to non-polarized. Non-polarized image-mode (Minchiate today): flips between front + back.

**Why ungate the FLIP btn rendering rather than render it conditionally per surface:** user-spec was "just allow the FLIP btn everywhere" + the prior bespoke per-surface gating was causing both visual quirks (missing FLIP btn in applet earlier) + maintenance complexity. The unified "always render, JS picks behavior by sibling existence" pattern eliminates the per-surface conditional templates. The btn is always visible-on-hover, always click-handles cleanly, gracefully no-ops where it has nothing to flip to — minimal surprise, maximal consistency.

**JS handlers not unified into a shared module** (yet): each of the 3 surfaces (my_sign main inline script, applet inline script, sea.js init()) carries its own copy of the ~15-line FLIP-to-back animate-and-toggle dance. Could be DRY'd into a `StageCard.flipToBack(card, btn)` helper at some point, but the call sites differ enough in setup (different parent DOM selectors, different surrounding state — frozen-gate for my_sign, no gate for applet/sea_stage) that the helper would mostly be the animate+setTimeout block. Deferred — flagged in [[project-image-based-deck-face-rendering]] follow-ups if it accretes.

Tests: 1314/1314 IT+UT total green (71s). No new tests — JS handler change is pure DOM augmentation; template changes just relax server-side gates (no new conditionals to test). Visual verify 2026-05-25 PM via Claudezilla on /billboard/: applet FLIP btn present (opacity:0 at rest, hover-reveals); shared `.my-sign-applet-card:hover .my-sign-applet-flip-btn` CSS rule confirmed in computed stylesheet; my_sign main page FLIP behavior unchanged (still works per user 2026-05-25 PM "Works well in my_sign.html tho").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:31:45 -04:00
Disco DeDisco
1e2041ed9f A.7.5-polish-5 DRY _stat_face.html partial + FLIP-btn SCSS unification + my_sign FLIP DOM move-into-card + universal hover-reveal + instant mid-flip vanish + sea-stage-card image-mode bg fix + multi-line comment syntax cleanup. User-spec 2026-05-25 PM bundle of 5 cleanup threads atop polish-4 (4554c71).
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
**(1) `_stat_face.html` partial** — extracted to `templates/core/_partials/_stat_face.html` per user 2026-05-25 PM: "Why are there so many individual instances of this feature? Couldn't we call the same DRY partial for each?". One partial covers all 4 stat-block surfaces (sig-stat-block / sea-stat-block / fan-stage-block / my-sign-applet-stat-block) — ~80 lines of duplicated markup collapse to 7 `{% include %}` sites (3 surfaces × 2 faces + applet × 1 face). Args: `face_modifier` (required: "upright"|"reversed"), `label_text` (required: "Emanation"|"Reversal"), `card` (optional TarotCard for applet's server-render path), `keywords_ul_id` (optional id attr on the keyword `<ul>` — sea_stage + fan need `id_sea_stat_upright/reversed` + `id_fan_stat_upright/reversed` for stage-card.js's `populateKeywords` surface-specific selector overrides). The `.stat-face` wrapper that the partial introduces is a no-op for the applet — applet's bespoke `.my-sign-applet-stat-block` rule doesn't `@include stat-block-shared` so `.stat-face` inherits no padding / display-none from the shared mixin.

**(2) FLIP-btn `@mixin flip-btn-base` + `%flip-btn-revealed` + `%flip-btn-mid-flip` primitives** — `_card-deck.scss` head per user 2026-05-25 PM: "unify the many disparate calculations we use for when we allow that FLIP btn to appear and where it appears". Each surface's flip-btn declaration now `@include`s the base (position absolute + zero margin + hidden default opacity 0 + 0.3s transition) and `@extend`s `%flip-btn-revealed` on its surface-specific reveal trigger + `%flip-btn-mid-flip` on its surface-specific `[data-flipping]` selector chain. ~30 lines of duplication collapsed to 6 lines of mixin/placeholder + 3 `@include` + 4 `@extend` calls.

**(3) my_sign FLIP btn moved INSIDE `.sig-stage-card`** + `.my-sign-flip-btn` + `.my-sign-applet-flip-btn` share one positioning rule (`bottom: 0.6rem; left: 0.6rem`) — was a sibling under `.my-sign-stage` positioned via stage-padding-relative `calc(1.5rem + 0.4rem)`. Polish-5 nests it INSIDE the card so positioning is naturally card-relative + the separate `.my-sign-page[data-current-card-id]` centered-mode geometric override (re-deriving offsets from the centred-row layout) is DROPPED entirely. The applet was already inside-card positioned; same `bottom: 0.6rem; left: 0.6rem` rule combines both surfaces in a single `_card-deck.scss` declaration. The applet's `_billboard.scss` flip-btn rule is now just a shim `@include` + `@extend` (the positioning got DRY'd up to the shared rule).

**(4) Hover-reveal everywhere** + instant mid-flip vanish — user-spec 2026-05-25 PM: "The .btn-reveal behavior here should now (1) disappear much earlier, so no independent ease-in/-out logic needed on clicking FLIP; (2) calculate its position more dynamically; be mirrored in the gameboard's My Sign applet. In all places does the hover-to-reveal-FLIP-.btn-reveal effect abate while the card is finishing a FLIP". my_sign main flipped from `display: none → display: inline-flex` (frozen-gated) to opacity-based hover-reveal on `.sig-stage-card:hover` (still gated by `.sig-stage--frozen`). Applet flipped from always-visible to opacity-based hover-reveal on `.my-sign-applet-card:hover`. Fan kept its existing hover-reveal. Mid-flip-hide changed from `opacity: 0 + pointer-events: none` (faded out over the 0.3s transition, which competed w. the click) to `display: none` — INSTANT vanish, no ease-out animation. All 3 surfaces consolidated into one combined `[data-flipping] -> flip-btn` selector list extending `%flip-btn-mid-flip`. The `:has(.flip-btn:hover)` self-pin clause (already present on fan) added to my_sign + applet too — keeps the btn visible while the cursor is on it, otherwise the btn (z-index 25, on top of the card) steals `:hover` from the card the moment the cursor moves onto it + retracts the reveal mid-click.

**(5) `.sea-stage--levity .sea-stage-card` image-mode bg fix** — user-reported 2026-05-25 PM: "the card preview stage in my_sea.html still sports the old card bg (the --secUser here) behind the card img (with the --quiUser box-shadow border)". Same source-order collision pattern as the sea-sig-card fix in polish-4: `.sea-stage--levity .sea-stage-card`'s `@include stage-card-polarity($invert-frame: true)` sets `background: rgba(var(--secUser), 1) + border-color: rgba(var(--priUser), 1)` at specificity 0,2,0 — matches the shared `.sig-stage-card.sig-stage-card--image` comma-list rule's specificity but source-loses to it (levity rule lives at line 2150, comma-list at line 705). Fix: add a `&.sig-stage-card--image { background: transparent; border: 0; }` nested override (0,3,0 specificity) — re-states the transparency under the levity polarity branch so image-mode drawn cards (Minchiate today) don't show a beige card-shape behind the PNG art. The gravity branch was already fine (its mixin call doesn't pass `$invert-frame`).

**(6) Multi-line `{# #}` comment syntax cleanup** — user-spotted 2026-05-25 PM after my polish-5 partial extraction caused visible comment text to leak into rendered HTML on 4 templates (per [[feedback-django-multiline-comments]] / [[feedback-django-comments-single-line-only]] traps the user has flagged before). All multi-line block comments I added in this polish converted to `{% comment %}...{% endcomment %}` form — covers the `_stat_face.html` partial header + 4 template include sites (my_sign.html × 2 blocks, _applet-my-sign.html, _sea_stage.html, game_kit.html).

Tests: 1314/1314 IT+UT total green (72s). No new tests — existing chip-presence + image-mode ITs from polish-4 still pass through the partial extraction. Visual verify 2026-05-25 PM via Claudezilla: my_sign main page (Queen of Coins) renders cleanly via partial w. card+stat-block; applet renders cleanly w. server-filled chip + title; carousel + sea_stage modal work via JS-populated partial includes; my_sign FLIP btn moved into card + hover-reveals + vanishes instantly on FLIP click; sea-stage-card no longer shows --secUser bg behind image-mode PNG art under levity. DRY partial extraction was held out of polish-4 as user-requested separate concern: "hold it for a separate commit, but fold the FLIP btn unification into it as the styling cleanup part" — done.

**Follow-up parked for next sprint**: user-flagged 2026-05-25 PM "If it's interfering to have bespoke rules, just allow the FLIP btn everywhere, including in my_sea.html". This needs (a) dropping the `not card.deck_variant.is_polarized` server-render gate in the applet template, (b) adding a FLIP btn + back-img element to the `_sea_stage.html` modal scaffold, (c) wiring a JS handler in sea.js (currently has no FLIP behavior for drawn-card stage). Out of scope for the polish-5 commit since it's template + JS scope; will pick up as polish-6 or a fresh sprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:22:08 -04:00
Disco DeDisco
a03d0b0cac Papa Quattro pngquant pass — 980k → 339k (-65% / 641k saved). User-suggested 2026-05-25 PM after the bg-trim re-export grew the file: "if it is higher res, we should strip it like we did yesterday". Same flags as the 0add163 import batch: pngquant --quality=65-85 --speed=1 --strip --skip-if-larger. Resulting file is now smaller than the pre-trim version (378k) too — the user's editor had re-saved w/o pngquant's aggressive quantization; this restores the optimized baseline.
`--skip-if-larger` is a no-op safety net (pngquant won't write if its output would be larger than the input), so re-running this command on any future asset edit is non-destructive. Worth wiring into the eventual admin upload pipeline per the 0add163 commit's "Future: when Sprint B's admin form ships, wire pngquant into an `optimize_card_images` management command so admin uploads auto-optimize on save" note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:23:50 -04:00
Disco DeDisco
9d33cda139 Papa Quattro trump asset re-export — user trimmed leftover background pixels they'd neglected to remove from the original scan (per user 2026-05-25 PM). File grew 378402 → 980335 bytes — the bg trim itself shrinks visible content but the user's editor likely re-saved w. less-aggressive PNG compression than the original pngquant run; consider a re-pngquant pass before prod if asset-size becomes a concern.
Per `git-commit` skill convention: commit-everything-at-once is the default. This asset swap was inadvertently held back from polish-4 (4554c71); restored here as its own one-file commit at user direction. Memory note added so future commit passes don't selectively stage again.

No code touched — pure asset binary swap. The image-mode rendering pipeline (`TarotCard.image_url` → static URL → `<img src=...>` in the 5 image-mode surfaces) picks up the new bytes automatically on next page load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:20:39 -04:00
Disco DeDisco
4554c71aed A.7.5-polish-4 stat-block chip restructure + top-pin + CSS-transition SPIN + sea-sig-card image-mode bg fix + title --quaUser unification — TDD. Mid-session 2026-05-25 PM bundle of 5 user-spec'd polish threads atop the polish-3 alpha bump (1839a37):
(1) **Card title color unified to --quaUser** — shared `stat-block-shared` mixin's `.stat-face-title` was `--quiUser` (cream-purple) for non-major arcana, but the My Sign applet's bespoke override at `_billboard.scss:642` had it as `--quaUser` (bright yellow-gold). User-observed inconsistency 2026-05-25 PM: "only the My Sign applet has --quaUser as a font color; the rest are --quiUser. Let's change the latter to match the former". Mixin default flipped — applet's bespoke override stays (was always --quaUser, the new universal value).

(2) **Stat-face top-pin** — `.stat-face` top padding collapsed from `0.37 * card-w` (which mid-vertically centered the arcana label) to `0.1 * card-w` (uniform w. bottom) so the chip + EMANATION/REVERSAL header pin at the actual top edge + title/arcana/keywords cascade DOWN naturally. User-spec 2026-05-25 PM: "pin the number/alphanumeric at the top and the rest of the content cascades down from it, instead of pinning the arcana type in the center and stacking the rest of the content atop it".

(3) **Chip layout restructured** — header is now a 2-row vertical stack (was a 1-row flex w. chip-pill + label inline). Row 1: `.stat-chip-rank` on its OWN line (room for long Roman numerals like XXVIII without squeezing the label). Row 2: `.stat-chip-tag` flex-row holding `<i class="stat-chip-icon">` + `<p class="stat-face-label">` — the icon is always 1 char so it never crowds the label. Border-bottom on the whole `.stat-face-header` (0.05rem solid --secUser at 0.4 alpha) underscores both rows as one header unit, replacing the prior per-`.stat-face-label` `text-decoration: underline` (dropped). Per user spec 2026-05-25 PM: "allow EMANATION/REVERSAL to remain inline with the <i> el below the alphanumeric, which will more predictably only ever be one character long. Then we should extend the underline as a thin line underscoring them both (not merely underlined text)". Template-side: 4 stat-block surfaces (`my_sign.html` / `_applet-my-sign.html` / `_sea_stage.html` / `game_kit.html`) updated to the new 2-row HTML structure — `.stat-face-chip` wrapper dropped entirely; rank is a direct child of header; icon + label live in `.stat-chip-tag`. 4 ITs adjusted to match the new DOM.

(4) **SPIN animation restored for image-mode via CSS transition** — A.7.5 had gated the 180° card rotation behind `!.fan-card--image` (per the prior "monodecks shouldn't have polarity" spec), leaving image-mode cards static on SPIN while only the stat-block face toggled. User-spec 2026-05-25 PM: "reintroduce the SPIN animation". First attempt used a layered `Element.animate(0→180→0)` keyframe; user reported "card rotates back the other way even quicker". Second attempt continued past 180° to 360° for single-direction spin; user reported "now it does three! Upside down, rightside up, and upside down again!" — root cause was the layered `Element.animate` racing the existing `.fan-card { transition: transform 0.18s ease-out }` set in updateFan, producing double/triple-firing. User suggestion 2026-05-25 PM: "Why can't we just resort to the CSS transition". Final fix: drop the special-case image-mode `Element.animate` block entirely; image-mode + text-mode now share the same SPIN handler — toggle `.stage-card--reversed` + set inline `style.transform` w. the rotate(180deg) appended. The existing CSS transition handles the rotation in a single mechanism, no layering. Persistent state via `.stage-card--reversed` continues to be read by `updateFan()` so post-SPIN nav re-renders the rotation correctly.

(5) **sea-sig-card image-mode bg artifact fix** — User-reported 2026-05-25 PM: "Looks like we still have an artifact card bg behind this version of the card preview img in my_sea.html". The central sig card in `my_sea.html`'s picker was showing a beige card-shape behind the transparent-PNG art. Root cause: `.sig-stage-card.sea-sig-card` (`_card-deck.scss:1684`, specificity 0,2,0) matches the shared `.sig-stage-card.sig-stage-card--image` comma-list rule's specificity exactly but appears LATER in source order — so its `background: rgba(var(--priUser), 1)` + `border: 0.15rem solid ...` + `padding: 0.25rem` overrode the image-mode rule's `background: transparent; border: 0; padding: 0`. Fix: add a `&.sig-stage-card--image { background: transparent; border: 0; padding: 0; }` override INSIDE the bespoke rule (specificity 0,3,0 — wins both source-order against the comma-list AND beats the levity-polarity rule at line 1299). Parallel override added to `.my-sea-page[data-polarity="levity"] .sig-stage-card.sea-sig-card` (0,3,0) for the same reason — under levity the polarity rule re-clothes the sea-sig-card w. --secUser bg even in image mode; the nested `&.sig-stage-card--image` override at 0,4,0 wins. Other 3 image-mode surfaces audited: `.my-sea-slot` + `.sea-card-slot` + `.fan-card` base rules are 0,1,0 and lose to the 0,2,0 comma-list naturally; no parallel fix needed for them.

Tests: 1314/1314 IT+UT total green (73s). 4 ITs updated to match the new chip DOM structure (`.stat-face-chip` wrapper dropped; rank now direct child of header; icon + label inside `.stat-chip-tag`): BillboardMySignViewTest.test_stat_block_renders_rank_suit_chip_per_face + BillboardAppletMySignTest.test_applet_stat_block_renders_server_side_chip + MySeaViewTest.test_sea_stage_stat_block_renders_rank_suit_chip_per_face + GameKitViewTest.test_fan_stage_block_renders_rank_suit_chip_per_face. Visual verify 2026-05-25 PM via Claudezilla: chip restructure renders correctly across game_kit carousel (XXVIII Il Capricorno + Il Matto trumps); sea-sig-card bg artifact gone (computed bg `rgba(0, 0, 0, 0)`, border 0, padding 0); SPIN animation smooth in both image-mode + text-mode. No FT runs per [[feedback-ft-run-discipline]]. DRY partial split for the duplicated stat-face header markup deferred to a follow-up commit per user request 2026-05-25 PM ("hold it for a separate commit").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:14:42 -04:00
Disco DeDisco
b1c6833956 Sky wheel .nw-rx badge — .shop-badge parity for retrograde planets. User-spec 2026-05-25 PM: "Can you help me give a similar badge to .nw-rx as to that by the stack of ×5 Tithe Tokens in wallet.html's Shop applet? One commensurately scaled to the planet, and containing the Rx symbol where it already is, slightly overlapping its planet and along the same degree as it". Mirrors the wallet Shop applet's .shop-badge ×N quantity chip: --secUser disc + --priUser glyph + bold weight.
**Implementation** (SVG, not CSS-positioned since `.nw-rx` is an SVG `<text>` child of `.nw-planet-group`): adds a new `<circle class="nw-rx-badge">` to `_drawPlanets` in `sky-wheel.js` BEFORE the existing `.nw-rx <text>` so the text stacks on top of the disc. Both share the same center coords + animate together via a shared `attrTween` on the planet's degree interpolation. Geometry tuned to the user's "commensurately scaled / slightly overlapping" spec: `RX_OFFSET = R.planetR + _r * 0.07` (radial position along the same angle as the planet — keeps badge on the same degree per the user's "along the same degree as it"); `r = _r * 0.035` (70% of the planet circle radius — "commensurately scaled"). Net: badge center sits `_r * 0.07` outside the planet center; badge edge intrudes `~_r * 0.015` past the planet edge — a thin overlap rather than a flush tangent. Glyph font-size bumped from `_r * 0.040 → _r * 0.045` to read better inside the larger disc.

**SCSS** (`_sky.scss`): new `.nw-rx-badge { fill: rgba(var(--secUser), 1); stroke: rgba(var(--priUser), 0.6); stroke-width: 0.5px; }` — light disc w. a thin dark outline to separate it from same-color planet-element rings (gold-greens, etc.) when an Rx planet lands on a matching-color band. `.nw-rx` glyph rule simplified: `fill --priUser`, drops the prior `stroke --priUser` (now unnecessary — the disc gives the glyph its own clean substrate), gains `font-weight: 900` to match the `.shop-badge` text weight contract.

**Why a new SVG element rather than reusing the existing `<text>`'s background**: SVG `<text>` doesn't support `background-color` directly; the canonical pattern is a sibling `<rect>` or `<circle>` underneath. Picked `<circle>` to match the round `.shop-badge` chrome (1.5rem rounded square ≈ disc at the rendered size).

Tests: 198/198 gameboard ITs+UTs green (23s; no test surface — pure SVG render + SCSS change). Visual verify 2026-05-25 PM via Claudezilla on `/dashboard/sky/`: Pluto + Jupiter + Saturn (today's retrograde planets) all carry the cream-disc Rx badge w. dark `R` glyph, slightly overlapping their planet circles along the same angle. No Jasmine spec run — the change is pure DOM-shape augmentation w/o behavioral logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:30:21 -04:00
Disco DeDisco
1839a375fe A.7.5-polish-3 stat-block alpha unification (1.0) — supersedes polish-2's 0.5. User-reverted 2026-05-25 PM: "please undo that and set all of them to the higher opacity that My Sign just had". The 0.5 unification from polish-2 (efcef15) made all 4 stat-blocks too washed-out against the page bg bleed; user wants them fully opaque matching the My Sign applet's original gravity-state appearance (= rgba(var(--priUser), 1)). Forward edit rather than git revert since the polarity-bg + label-color collapses from earlier polish commits stay in place — only the alpha changes.
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
**5 sites bumped from 0.5 → 1.0** (mirrors the polish-2 site list):
- `.sig-stat-block` default (the my_sign main + sig-overlay reference)
- `.sea-stage-content .sea-stat-block` (no-polarity fallback)
- `.sea-stage--gravity .sea-stat-block, .sea-stage--levity .sea-stat-block` (polarity-classed rule that actually applies in practice)
- `.tarot-fan-wrap[data-polarity="gravity"] .fan-stage-block, .tarot-fan-wrap[data-polarity="levity"] .fan-stage-block`
- `.my-sign-applet-stat-block` default

Net: every stat-block surface now renders `rgba(50, 30, 95)` (--priUser at full alpha) regardless of polarity. Visually identical chrome across my_sign main / applet / sea_stage modal / Game Kit fan stage — no more translucent leak of the page bg through the panel.

Tests: 1314/1314 IT+UT total green (74s; pure alpha-channel SCSS, no test surface). Visual verify 2026-05-25 PM: applet stat-block now `rgb(50, 30, 95)` (full opacity), matching its original gravity state the user references as canonical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:28:35 -04:00
Disco DeDisco
efcef15487 A.7.5-polish-2 stat-block alpha unification — all 4 surfaces collapse to rgba(var(--priUser), 0.5) matching the my_sign main page. User-reported 2026-05-25 PM after the polarity-bg unification (2ec23ea): "Lots of different opacities all around here. Can you unify those too, to the My Sign stat block?" — the my_sign main page's .sig-stat-block default is rgba(var(--priUser), 0.5); the 3 other stat-block surfaces were carrying different alphas (sea 0.85, fan 1.0, applet 0.8 default + 1.0 gravity override) — visually inconsistent. Collapse all to 0.5.
**Touched bg declarations (5 sites, all `rgba(var(--priUser), X)`)**:
- `.sea-stage-content .sea-stat-block`: `0.85 → 0.5` (the no-polarity fallback rule)
- `.sea-stage--gravity .sea-stat-block, .sea-stage--levity .sea-stat-block`: `0.85 → 0.5` (the polarity-classed rule that actually applies in practice; both kept since the previous commit folded gravity into the same colors as levity)
- `.tarot-fan-wrap[data-polarity="gravity"/.levity] .fan-stage-block`: `1 → 0.5`
- `.my-sign-applet-stat-block` default: `0.8 → 0.5`
- `.my-sign-applet-body[data-polarity="gravity"] .my-sign-applet-stat-block`: bg override dropped entirely; the default 0.5 now applies in both polarities. Border + keyword color overrides kept (those target the gravity card-pair convention, not the bg).

Unchanged: `.sig-stat-block` default in `.sig-stage` (was already `0.5`) — the reference value the user pointed to.

Visual verify 2026-05-25 PM: applet stat-block now `rgba(50, 30, 95, 0.5)` — same color + alpha as the my_sign main page's `.sig-stat-block`. Both stat-blocks read as a translucent dark-purple panel that lets the page bg (green on my_sign / billboard purple on the applet) bleed through identically across surfaces.

Tests: 1314/1314 IT+UT total green (72s; no test surface — pure alpha-channel value changes in SCSS). Visual verify confirmed cross-surface match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:05:07 -04:00
Disco DeDisco
2ec23ea2c0 A.7.5-polish stat-block polarity bg unification — gravity flipped to --priUser across sig + sea + fan stages (match My Sign applet's no-flip convention). User-reported 2026-05-25 PM after the A.7.5 land (a9ad422): "the --priUser and --secUser polarity seems to be reversed everywhere but the My Sign applet". DOM inspection confirmed: applet stat-block under gravity = rgb(50, 30, 95) (--priUser); main .sig-stat-block under gravity = rgba(162, 170, 173, 0.75) (--secUser). Applet keeps --priUser bg under BOTH polarities (no gravity override on bg; only label/keyword colors flipped). Main page + sea_stage + fan_stage were doing the opposite-polarity flip per the [[feedback-card-polarity-convention]] lock, which the user is now revising — stat-block should match applet pattern (always --priUser bg, regardless of polarity).
**Change**: collapse the three stat-block gravity-polarity overrides:
- `.tarot-fan-wrap[data-polarity="gravity"] .fan-stage-block` — bg `--secUser → --priUser`; combined w. the existing levity rule into a single comma-list selector (`[data-polarity="gravity"], [data-polarity="levity"]`) since both branches now produce identical colors. Inner overrides (label/chip/keywords) collapsed to the levity values.
- `.sig-stat-block` under `.my-sign-page[data-polarity="gravity"]` (+ `.sig-overlay[data-polarity="gravity"]`) — explicit `background: rgba(var(--secUser), 0.75)` removed; falls through to the default `.sig-stage .sig-stat-block { background: rgba(var(--priUser), 0.5); }` upstream. Label + chip gravity-specific overrides (--quiUser label, --priUser chip — tuned for the now-removed --secUser bg) deleted; the shared --secUser-label / --secUser-chip defaults (tuned for --priUser bg) cover both polarities.
- `.sea-stage--gravity .sea-stat-block` — bg `--secUser → --priUser`; combined w. levity via comma-list selector. Inner overrides collapsed.

Net effect across the 3 surfaces: stat-block bg is now `rgba(var(--priUser), N)` (alpha varies per surface: 0.5 sig, 0.85 sea, 1.0 fan) regardless of polarity — matching the applet's universal --priUser pattern. Card polarity rules untouched: text-mode card bg still flips per the original convention (gravity card --priUser, levity card --secUser). Card + stat-block under gravity NOW share the same polarity bg (was opposite per [[feedback-card-polarity-convention]]); for image-mode cards this is invisible (transparent card bg); for text-mode cards (Earthman + RWS today) the same-polarity bgs read as a coordinated dark pair under gravity rather than the prior dark/light contrast — accepted as the intentional new convention per user spec.

**Convention update**: [[feedback-card-polarity-convention]] needs revision — the "card + stat block carry OPPOSITE-polarity bgs" rule held for sig/sea/fan but never for the applet, and the user is now extending the applet's exception universally. Memory update deferred to a follow-up; commit body documents the new direction so future-me has the rationale.

Tests: 1314/1314 IT+UT total green (no test surface — SCSS-only change; ITs use lxml HTML parsing + don't observe computed styles). Visual verify 2026-05-25 PM: my_sign main page stat-block under gravity now `rgba(50, 30, 95, 0.5)` (--priUser w. page-bg bleed) matching the applet's `rgb(50, 30, 95)` (--priUser at full alpha). No FT runs per [[feedback-ft-run-discipline]] — visual-only change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:44:07 -04:00
Disco DeDisco
a9ad422b35 A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit _tarot_fan.html carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually.
(1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing.

(2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics).

(3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use.

(4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`.

(5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title.

Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:25:41 -04:00
Disco DeDisco
5a1acbd9ca CI test-FTs-room #334 fix — _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.
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
(1) **`_seed_earthman_sig_pile` helper missing two field defaults** — 13 errors + 1 FAIL across `MySeaCardDrawTest`. All 13 errors fail at `_draw_open_modal` line 716 looking for `.sea-deck-stack--levity`; the FAIL at `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` asserts `len(stacks) == 2` and gets 1. Today's 15025b4 (A.7-polish my_sea single-stack collapse) added `{% if request.user.equipped_deck.is_polarized %}` branching to `my_sea.html:224`: polarized decks render `--gravity` + `--levity` stacks; non-polarized decks render a single `--single` stack. The `_seed_earthman_sig_pile()` helper at `functional_tests/sig_page.py` calls `DeckVariant.objects.get_or_create(slug="earthman", defaults={...})` w. only `name`, `card_count`, `is_default` in the defaults dict — `is_polarized` falls through to the model default of `False` (`epic/models.py:260`). Migration 0012 sets the field on the migration-seeded Earthman row via an explicit `.update(is_polarized=True, has_card_images=False, family="earthman")`, but `TransactionTestCase` flushes all tables on teardown (no `serialized_rollback` per [[feedback-transactiontestcase-flush]]) — once the first test method runs + tears down, the migration-seeded row is gone, and every subsequent test's setUp re-creates Earthman via the helper's incomplete defaults → `is_polarized=False` → my_sea picker renders single-stack → FT selector miss. Why local passed: `MySeaCardDrawTest` is class #5 of 6 in test_game_my_sea.py (setUps at lines 49, 208, 381, 474, 691, 1146); local runs typically scope to a single class or method (per [[feedback-ft-run-discipline]]), so the FIRST setUp finds the migration-seeded Earthman row still intact w. `is_polarized=True` and `get_or_create` returns it as-is. In CI's 152-test FT-room batch, four prior classes each truncate `epic_deckvariant` before class #5 runs, so the helper's defaults are all that's left. Fix: add `is_polarized=True, has_card_images=False` to the helper's defaults dict, mirroring migration 0012's explicit `.update()`. `family` stays implicit since the model default of `EARTHMAN` (`"earthman"`) already matches. Note: the parallel `_equip_earthman` helper in `test_game_room_deck_contrib.py:31-40` carries the same incomplete defaults but its tests don't depend on `is_polarized` — left untouched to avoid scope creep; would tighten consistency in a follow-up if the same trap bites again

(2) **Stale `#id_kit_fiorentine_deck` selector** — 1 error at `DeckInUseGameKitTest.test_non_contributing_deck_has_normal_don_doff`. f107522 (A.0 image-rendering schema + RWS rename) renamed the existing `fiorentine-minchiate` DeckVariant slug to `tarot-rider-waite-smith` (audit revealed the deck was actually 78-card RWS Tarot, not 97-card Minchiate). The kit panel template at `_applet-game-kit.html:87` derives the element id from `deck.short_key` (`epic/models.py:291`, first dash-separated word of slug) — so `tarot-rider-waite-smith` produces id `id_kit_tarot_deck`, not the old `id_kit_fiorentine_deck`. f107522's rename pass updated `test_game_room_deck_contrib.py`'s click target on line 194 (`#id_kit_tarot_deck`) but missed the immediately-following assertion's `wait_for` selector on line 198, leaving a mixed-slug FT that clicks the new id but waits for the old. Pure FT-selector swap

Tests: not run locally per [[feedback-ft-run-discipline]] — both fixes are FT-only (helper defaults + selector string); CI verification on next push will confirm the 14 reds go green. Files: `functional_tests/sig_page.py` (+1, -1) + `functional_tests/test_game_room_deck_contrib.py` (+1, -1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 11:12:16 -04:00
Disco DeDisco
711b609e0c A.7-polish-4 applet stat-block palette tweak — user-edited 2026-05-25 PM. Two adjustments inside _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
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:46:55 -04:00
Disco DeDisco
7c6ab39635 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>
2026-05-25 02:43:00 -04:00
Disco DeDisco
26cdf0d38b A.7-polish-2 Sea Stage modal img scaffold — TDD. User-reported bug 2026-05-25 PM after the my_sea polish commit (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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:21:31 -04:00
Disco DeDisco
15025b4188 A.7-polish my_sea slot image-rendering (server-saved + mid-draw JS) + non-polarized single-deck-stack collapse — TDD. End-of-session wrap-up 2026-05-25 PM. Three changes covering my_sea.html surfaces that weren't in A.5/A.7's central-sig-only scope: (1) saved-hand slot rendering (_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 &quot; 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)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:16:53 -04:00
Disco DeDisco
dd99364b78 A.6 + A.7 billboard My Sign applet + gameboard My Sea applet image-rendering + applet-level FLIP-to-back — TDD. Sprints A.6 + A.7 of [[project-image-based-deck-face-rendering]]: rolls image-mode out to the two card-rendering applets (My Sign on /billboard/, My Sea on /gameboard/). Both reuse the shared .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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:58:36 -04:00
Disco DeDisco
bdf6a251f4 A.5-polish-2 FLIP-to-back bug fixes — TDD. Two user-reported bugs from the first FLIP-to-back commit (1963ad4): (1) FLIPping made the entire card disappear instead of showing the back; (2) the FLIP btn stayed visible during the animation when it should hide just like the tarot-fan view's flip btn does. Root cause of (1): the back-image element was rendered with inline style="display:none" in the template. Inline styles beat CSS class rules in the cascade — my .sig-stage-card.is-flipped-to-back .sig-stage-card-back-img { display: block } rule was the right specificity but couldn't override an inline style attribute. So the toggle hid the front (CSS-controlled, no inline override) but failed to show the back (CSS blocked by inline). Net: empty stage. Fix: removed the inline style on the back-img element; default .sig-stage-card-back-img { display: none } rule (already present in SCSS) handles the hidden default, and the .is-flipped-to-back toggle now flips visibility cleanly. Both rules are pure-CSS so they cascade as expected. Root cause of (2): the non-polarized FLIP handler was a bare class toggle (no animation, no data-flipping attr), so there was no SCSS hook to hide the btn. Plus there was no equivalent SCSS rule even for the polarized _flipPolarityAnimated flow which DID set data-flipping — the polarized flip just animated without hiding the btn either. Fix: (a) added _flipToBackAnimated() JS function mirroring _flipPolarityAnimated's shape — rotateY 0→90→0 at 500ms ease, swap visual content at the halfway point (here: class toggle instead of revInput/polarity flip), set stageCard.dataset.flipping = '1' for the duration so SCSS has a hook. (b) New SCSS rule .my-sign-stage:has(.sig-stage-card[data-flipping]) .my-sign-flip-btn { opacity: 0; pointer-events: none } mirrors the tarot-fan view's pattern (_card-deck.scss:459.tarot-fan-wrap:has(.fan-card[data-flipping]) .fan-flip-btn). The :has() selector covers BOTH the polarized animation (which already sets data-flipping) AND the new non-polarized animation, so the btn hide-during-flip behavior now lands consistently across both flip modes — fixes a latent polished-flow gap not just the new code path. No new tests — the existing 3 ITs from 1963ad4 already verify the template/scaffold contract (data-deck-polarized attr + back-img element conditional render); the bug fixes here are CSS/JS-level behavior best caught by visual verify (no automated test would have caught the inline-style cascade issue since the IT asserted on element presence, not display state). 1303/1303 IT+UT total green (71s, unchanged from 1963ad4 since no new tests in this commit)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:35:47 -04:00
Disco DeDisco
1963ad4c71 A.5-polish FLIP-to-back for non-polarized image-equipped decks — TDD. User-spec'd feature 2026-05-25 PM after browser-verifying A.5: the FLIP button on my_sign.html cycles polarity for polarized decks (Earthman) — gravity/levity swap w. a 3D-spin animation, stat block updates to the new polarity's emanation/reversal qualifiers. For non-polarized decks (Minchiate today, future RWS-with-images, future classic-playing decks), polarity has no meaning — clicking FLIP just runs an animation that doesn't change anything content-wise. User wants FLIP repurposed for non-polarized decks: reveal the card-back image while leaving the stat block untouched, so the gesture has visible payoff w/o forcing a meaningless polarity-state change. Implementation thread: server-side page wrapper carries a new data-deck-polarized="{{ user.equipped_deck.is_polarized|yesno:'true,false' }}" attr so the in-page JS can branch on it without making an API call or guessing from card data; stage-card scaffold conditionally renders a hidden <img.sig-stage-card-back-img> element when equipped_deck.has_card_images AND NOT is_polarized (image-equipped polarized decks would still cycle polarity per existing flow — back-image element absent for them, no resource waste). JS branch in flipBtn.click: if (pageEl.dataset.deckPolarized === 'false') { stageCard.classList.toggle('is-flipped-to-back') } else { _flipPolarityAnimated() } — same .is-reversed class toggle on the btn itself so visual feedback is consistent across both modes (btn rotates to signal "flipped state on"). SCSS: .sig-stage-card-back-img joins the existing .sig-stage-card-img filter chain (same contour stroke + silhouette black shadow — back image gets identical visual treatment to the front so the flip reads as same-deck consistency); default display: none; .sig-stage-card.is-flipped-to-back flips visibility — hides front, shows back. Stat block + arcana-key stroke color stay put per user spec — FLIP for non-polarized is purely a visual reveal, no polarity-cycle or content swap. 3 new ITs in MySignViewTest: data-deck-polarized="true" for default Earthman; data-deck-polarized="false" + back-img element present w. correct v2-convention back asset URL when user switches to Minchiate; polarized deck omits the back-img element. No JS unit test (Jasmine spec) for the flipBtn branch — visual verify covers the hover/click interaction; the IT covers the server-side conditional render that determines whether the branch can fire. No FT (the existing my_sign FTs cover the polarized-flip flow already; non-polarized-flip is a CSS class toggle, low-risk for regression). Tests: 3 new green; 9/9 MySignViewTest class green; 1303/1303 IT+UT total green (71s; +3 from 82813e9's 1300). Out of scope: my_sea's central sig card doesn't have a FLIP btn (no analogous behavior to add there); room.html FLIP behavior will be covered in A.8 if applicable; Sea Stage modal FLIP behavior (if any) lands in the my-sea fetch-endpoint extension later in A.5
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:31:42 -04:00
Disco DeDisco
82813e9fc1 A.5 my_sea.html central sig card image-rendering + SCSS lift-out fix — TDD. Sprint A.5 of [[project-image-based-deck-face-rendering]]: second visible surface after my_sign A.3. When the user's equipped deck is image-equipped (Minchiate today), the central significator card in the Celtic-Cross-style spread (.sig-stage-card.sea-sig-card inside .sea-pos-core) renders the transparent-PNG <img> w. contour-following arcana-color drop-shadow stroke + tray-card silhouette black shadow — same visual identity as my_sign's saved-sig stage card so the user's "this is my sig" anchor reads the same across both surfaces. Server-side template branch on significator.deck_variant.has_card_images: image branch renders <img class="sig-stage-card-img" src="{{ significator.image_url }}"> + adds .sig-stage-card--image marker class + data-arcana-key="{{ arcana }}" for the stroke-color selector; text branch keeps the existing corner-rank + suit-icon render unchanged (Earthman, RWS). No JS needed — central sig is statically rendered (vs my_sign's stage card which is JS-populated from the picker grid). Critical SCSS lift-out: the A.3 .sig-stage-card--image rule lived nested inside .sig-stage .sig-stage-card, scoped to my_sign.html's stage container only. my_sea's central sig isn't inside .sig-stage (lives in .sea-pos-core), so the rule wasn't applying — image rendered at native pixel dimensions (~620×1024 PNG) instead of being constrained to the card container, showing only a top-left portion (user bug-report 2026-05-25 PM: "It doesn't scale the img down for the sig — just a portion of the full img"). Fix: moved the entire .sig-stage-card.sig-stage-card--image { ... } block OUT of the .sig-stage nest into top-level scope so it applies to ANY .sig-stage-card carrying the --image class regardless of parent (my_sign's .sig-stage, my_sea's .sea-pos-core, future room.html's table center, future deck-bag UI). Same lift-out also expands the display: none list to include .fan-corner-rank + > i.fa-solid — these elements appear in my_sea's text-mode central sig and need hiding when image-mode kicks in (my_sign's text mode uses the wrapped .fan-card-corner + .fan-card-face classes which were already covered). 2 new ITs in MySeaPickerPhaseTemplateTest: image-equipped Minchiate sig renders .sig-stage-card--image class + <img> w. correct v2-convention src; non-image Earthman keeps .fan-corner-rank text + lacks --image class. Earthman Minchiate test fixture needs the super-nomad + super-schizo Note unlocks (granted manually via Note.grant_if_new since the post_save signal only fires on initial user creation, and we promote-to-superuser AFTER create) to let Il Matto (MAJOR 0) through _filter_major_unlocks. Tests: 2 new green; 1300/1300 IT+UT total green (70s; +2 from 750fef8's 1298). Visual verify pending: refresh /gameboard/my-sea/ w. Minchiate equipped + Il Matto as sig → central sig card should now scale the back image to fit the card container instead of showing a top-left crop. Sea Stage modal + drawn-card slot rendering (the bigger A.5 scope) still pending — they go through stage-card.js + the my-sea draw fetch endpoint, which need data-attr + JSON-payload extensions in a follow-up commit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:20:07 -04:00
Disco DeDisco
750fef890e A.4 cont.: card-deck icon placeholder mode (no-deck-equipped) styled like empty dice slot — TDD. User polish 2026-05-25 PM after browser-verifying the kit-bag dialog: when no deck is equipped (kit-bag-placeholder branch fires) the new card-stack icon should not animate + should render w. the same dimmed rgba(--quaUser, 0.x) palette as the existing fa-dice empty slot next to it, not the bright --terUser stroke / --priUser fill of an equipped-deck icon. Two SCSS changes: (1) Removed .deck-stack-icon:hover + .deck-stack-icon:active from the splay-trigger selector list — only wrapper-based selectors (.token.deck-variant, .kit-bag-deck) fire the fan-out now. Placeholder icons land inside .kit-bag-placeholder (or game_kit applet's .kit-item empty-state) which aren't in the trigger list, so they stay static no matter where the user hovers. (2) New .kit-bag-placeholder .deck-stack-icon + .kit-item .deck-stack-icon descendant-selector rule: drops color (= stroke via currentColor) to rgba(--quaUser, 0.3) matching the existing .kit-bag-placeholder color (_game-kit.scss:143); drops .deck-stack-icon__card fill to rgba(--quaUser, 0.15) (lower than the stroke alpha — user-dialed 2026-05-25 PM for a subtler look, the cards read as faintly-present "absence" rather than bright-but-grey). 1 new IT in KitBagViewTest (clears equipped_deck → asserts .kit-bag-deck absent, .kit-bag-placeholder present + carries svg.deck-stack-icon + lacks fa-id-badge). Tests: 1 new green; 1298/1298 IT+UT total green (71s; +1 from d26c45b's 1297). Visual verify pending: refresh /gameboard/ + clear equipped deck → kit-bag dialog Deck slot should show a dimmed static card-stack icon matching the dice slot's washed-out look
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:07:24 -04:00
Disco DeDisco
d26c45bf77 A.4 cont.: deck back-image renders inside card-stack icon + kit-bag dialog Deck section adopts the icon + size+pattern polish — TDD. Three follow-up improvements after user browser-verified A.4's first cut: (1) image-equipped decks (Minchiate today, future Earthman) now render the deck's actual <deck-slug>-back.png as the card-stack icon's visible faces instead of the placeholder --priUser solid fill — feels like a real deck, not a generic stand-in. (2) The kit-bag dialog Deck section (#id_kit_bag_dialog .kit-bag-deck) gets the same new card-stack icon (was still showing the old fa-regular fa-id-badge), with (×2) tooltip decoration on polarized decks for consistency w. the gameboard applet. (3) Visual polish: icon bumped 1.5× (1.5rem → 2.25rem width; 2.4rem → 3.6rem height, 5:8 aspect preserved); SVG <pattern> switched from patternUnits=userSpaceOnUse (which painted the image at fixed user-space coordinates and let the rect slide out from under it on hover, reading as "low opacity" to the user) to patternUnits=objectBoundingBox + patternContentUnits=objectBoundingBox (transform-aware — image tracks the rect through rest-state offsets + hover fan-out). New DeckVariant.back_image_url property mirrors A.2's TarotCard.image_url pattern: returns full static-asset URL for <deck-slug>-back.png when has_card_images=True, else empty string. Template partial _deck_stack_icon.html extended w. conditional <defs><pattern> block that renders only when deck.has_card_images is true; each of the 3 card rects then carries an inline style="fill: url(#deck-back-<short_key>)" overriding the SCSS default fill: rgba(--priUser, 1) (inline style beats CSS, the only way to opt out of the cascade default per-element). When no deck is passed (kit-bag placeholder branch) or deck has no images (Earthman + RWS), the partial falls through to the placeholder fill — single template handles both modes. _kit_bag_panel.html Deck section: equipped-deck branch swaps <i class="fa-regular fa-id-badge"> for {% include _deck_stack_icon.html with deck=equipped_deck %} + adds (×2) span in --terUser for equipped_deck.is_polarized; placeholder branch swaps for the same include without deck= so the partial's conditional falls through. SCSS reorg: lifted the .deck-stack-icon base rules out of the #id_applet_game_kit nest (they were scoped to gameboard's Game Kit applet only) into top-level scope so the same SCSS applies in the kit-bag dialog context too. Hover/active/focus trigger selector list broadened to cover .deck-stack-icon itself + .token.deck-variant wrapper + .kit-bag-deck wrapper. 4 new ITs total: 2 in GameboardViewTest (image-equipped Minchiate's <pattern> defines + inline fill style on all 3 rects + asset URL ref; non-image Earthman has NEITHER pattern nor inline fill); 2 in dashboard.KitBagViewTest (kit-bag Deck section renders svg.deck-stack-icon + lacks fa-id-badge; polarized equipped deck tooltip carries .tt-x2 — element-presence assertion since literal "×2" character had encoding issues in the dashboard test file vs the gameboard one, which is fine since the template-side rendering of the literal × is exercised by the parent template). Tests: 4 new green; 1297/1297 IT+UT total green (69s; +4 from A.4's 1293). Visual verify pending: refresh /gameboard/ → Minchiate icon should show 3 stacked Minchiate card-backs at 1.5× size, fan out on hover w. back image tracking; refresh kit-bag dialog → same icon visible in Deck section w. (×2) on Earthman tooltip
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:01:05 -04:00
Disco DeDisco
b9bb73db69 A.4 card-deck stack icon + game_kit applet's Card Decks polarization (×2) tooltip decoration — TDD. Sprint A.4 of [[project-image-based-deck-face-rendering]] (folded down from the originally-standalone Sprint D per [[project-card-deck-icon]] 2026-05-25 PM scope-fold). Replaces the <i class="fa-regular fa-id-badge"> placeholder on .token.deck-variant in the gameboard's Game Kit applet w. a new inline SVG card-stack icon: 3 rect children (rx=2.5, 20×32 viewport units inside a 32×48 viewBox to land 5:8 tarot card aspect), stacked tightly at rest w. ±0.4px vertical micro-offsets (suggests stack depth without separating cards visually), whole stack rotated 5° clockwise via .deck-stack-icon__stack group transform. On :hover / :active / :focus of the parent .token.deck-variant, cards 2 + 3 fan out symmetrically — card 2 translates (-5px, -2px) + rotates -12°, card 3 translates (+5px, -2px) + rotates +12° — card 1 stays put on top. Fan-out CSS pseudo-classes match the existing JS-portal tooltip trigger so the splay animation + tooltip-appearance co-activate as user spec'd. Placeholder card-back design: solid --priUser fill + currentColor stroke (= --terUser); detailed Earthman planet-impact illustration deferred to a future art-asset commit (the SVG structure is ready to receive richer fills + pattern elements without re-jigging the stack/fan transforms). Drop-shadow for "lifted off the felt" depth cue: 0.08rem 0.08rem 0.15rem rgba(0, 0, 0, 0.6) — softer than the my-sign-stage card's tray-card-style 1,1 black silhouette since the icon is small + always on a felt background. SVG itself uses overflow: visible so the fan-out exceeds the viewBox bounds; transform-box: fill-box + transform-origin: 50% 50% ensure rotation centers on each card's own geometric center (not the viewBox center). New _deck_stack_icon.html partial in templates/apps/gameboard/_partials/ keeps the SVG markup DRY for the future room.html pile + deck-bag rollouts (per [[project-card-deck-icon]] "other surfaces deferred to later sprints"). New .tt-x2 style in %tt-token-fields placeholder mixin — --terUser color + font-weight 600 — appended inline in .tt-description for is_polarized=True decks (Earthman today): "106-card Tarot deck (×2)" where the (×2) signals "double-polarized = 6 segments = fills 2× as many seats" per [[project-card-deck-icon]]'s decoration rule. Non-polarized decks (Tarot RWS, future Minchiate) render the description without the suffix. 3 new ITs in GameboardViewTest: SVG card-stack renders w. 3 rect children + fa-id-badge gone; polarized Earthman tooltip carries .tt-x2 w. "×2" content; non-polarized RWS tooltip lacks .tt-x2. Out of scope this commit: the dedicated /game-kit/ page's .gk-deck-card rectangles (different template — _game_kit_sections.html) keep their fa-id-badge for now; folding them into the new icon happens in a follow-up "Card Decks rectangle teardown" sprint per user spec ("by the time we finish A.8 the dynamically shaped rectangles around the deck <i> els and their names will be no more"). Tests: 3 new ITs green; 27/27 GameboardViewTest class green; 1293/1293 IT+UT total green (68s; +3 from A.3-polish's 1290). Visual verify pending: browser refresh expected to show the stacked-3-card icon w. 5° rest tilt, fan-out on hover, tooltip + (×2) decoration on Earthman
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:40:10 -04:00
Disco DeDisco
436a710478 A.3-polish-2: thicker contour stroke (0.2rem each cardinal) + tray-card down-right black silhouette drop-shadow. User visual polish after browser-verifying A.3+A.3-polish: stroke thickness bumped from 1.5px → 0.2rem (~3.2px) per cardinal, giving ~6.4px combined apparent stroke (~2× prior); user-confirmed thickness is comfortable in palette-baltimore at the current sig-card-w. Bonus: appended the same silhouette black drop-shadow that .tray-cell > img carries (_tray.scss:272, drop-shadow(1px 1px 2px rgba(0,0,0,1))) to the sig-stage-card image filter chain as a "lifted off the felt" depth cue — consistent w. the rest of the project's image-card treatment. Ordering matters in the filter chain: silhouette black comes AFTER the 4 stroke drop-shadows so it traces the STROKED contour, not just the original PNG alpha (otherwise the depth shadow would land underneath the orange-or-mustard stroke, partially occluded). 4-cardinal stroke is still adequate at 0.2rem; flagged in comment to bump to 8-direction (cardinals + diagonals) if we ever push past ~0.5rem since curved edges would otherwise show uneven thickness at gap-prone diagonals. Pure SCSS — no model/template/JS/test changes. Visual-only polish atop 50a12bc's A.3-polish
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:28:09 -04:00
Disco DeDisco
50a12bccab A.3-polish: cross-deck sig picker (MINOR + MIDDLE courts) + My Sea applet sig-decoupling — TDD. Two user-reported bugs caught during A.3 visual verify (2026-05-25 PM). Bug 1: my_sign picker shows only 2 cards (Major 0 + 1) for Minchiate-equipped users since _sig_unique_cards_for_deck filters by arcana=MIDDLE which Minchiate (and any non-Earthman tarot family) doesn't classify its courts as — Minchiate courts are MINOR per its standard structure. User spec confirmed: my_sign picker = courts + Major 0/1 for EVERY deck (NOT segment-limited, NOT arcana-classification-limited). Fix: broaden the filter to arcana__in=[MIDDLE, MINOR] so courts qualify regardless of how the deck classifies them. For Earthman, behavior unchanged (no MINOR 11-14 cards exist in seed — its courts are exclusively MIDDLE); for Minchiate + RWS, picker expands from 2 → 18 cards as designed. Two side-by-side suit queries (brands_crowns + blades_grails) collapse to a single 4-suit query since the union was already covering all 4 — that was historical artifact, not segment-limiting in effect. Bug 2: deleting the user's sig on /billboard/my-sign/ blanks the My Sea applet on /gameboard/ even though the saved MySeaDraw spread is still in the DB (visible on /billboard/my-sea/), reappearing only when any sig is re-selected. Root cause: _applet-my-sea.html gated the slot-render branch on {% if not request.user.significator_id %} first, treating no-sig as "no draws yet" regardless of actual draw state. But MySeaDraw rows carry their own significator_id snapshot at first-draw time (gameboard.models.MySeaDraw doc lines 130-132) precisely so user-sig clearing doesn't invalidate saved draws — the template ignored that contract. Fix: invert the template branches — slot render now keys solely on my_sea_slots; the sig-gate Brief banner only fires in the empty-state branch when ALSO not request.user.significator_id (the "fresh user, no draws, no sig" case). MySeaDraw display now correctly decoupled from current sig state — sig deletion only matters for users who haven't drawn yet. Companion code: _sig_unique_cards_for_deck docstring updated to articulate the cross-deck symmetry rule ("courts recognized by rank 11-14 regardless of arcana classification") + the spec-confirmed non-segment-limitation. 1 new regression IT in GameboardViewTest.test_my_sea_applet_renders_slots_even_when_user_significator_cleared locks Bug 2's fix: creates a MySeaDraw row w. one filled slot, then sets User.significator=None, GETs /gameboard/, asserts the filled slot still renders + "No draws yet" empty state is absent. Tests: 1 new IT green; 810/810 epic+gameboard+billboard ITs green; 1290/1290 IT+UT total green (70s, +1 from A.3's 1289). No FT changes needed — Bug 1's fix changes the count of cards in the picker grid; existing FTs that count cards target Earthman where the count is unchanged. Visual verify still pending; user will confirm both fixes via Claudezilla browser session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:16:55 -04:00
Disco DeDisco
5e78e6b832 A.3 my_sign.html image-rendering — first visible surface — TDD. Sprint A.3 of [[project-image-based-deck-face-rendering]]. When the user's equipped deck has has_card_images=True (Minchiate Fiorentine 1860-1890 today), the saved-sig stage card on /billboard/my-sign/ renders as an <img> over the irregular-shape transparent PNG with a contour-following arcana-colored stroke — not the text fan-card scaffold. First of 6 surfaces in the image-rendering rollout (my_sea + both billboard applets + room + game_kit follow in A.5+). New TarotCard.image_url property (consumes A.2's image_filename + DeckVariant.has_card_images + django.templatetags.static.static() to produce a full static-asset URL) — empty string when has_card_images=False so legacy text-only decks (Earthman, RWS) pass through transparently. my_sign.html picker grid .sig-card elements gain data-image-url + data-arcana-key attrs (the latter for stroke-color CSS selection); the .sig-stage-card scaffold gains a hidden <img class="sig-stage-card-img"> slot that JS swaps visible when image-mode is active. stage-card.js extends fromDataset to read image_url + arcana_key; new _setImageMode(stageCard, card) toggles the .sig-stage-card--image marker class + sets data-arcana-key on the stage card + populates the img src/alt; called from populateCard so all existing sig-stage flows pick up image rendering automatically (text-mode decks still pass through since image_url is empty). SCSS: new .sig-stage-card.sig-stage-card--image rule hides the .fan-card-corner + .fan-card-face text scaffold, strips the rectangular border/padding, and applies a 4-cardinal-direction filter: drop-shadow() stack to the <img> so the stroke FOLLOWS the alpha contour of the PNG instead of tracing a rectangular bounding box (per user spec 2026-05-25 PM clarification — early draft used a rectangular border which doesn't match the irregular-card aesthetic). Stroke color is driven by a CSS custom prop --img-stroke-color defaulting to rgba(var(--quiUser), 1) (cream — minor + middle arcana); [data-arcana-key="MAJOR"] override flips it to rgba(var(--terUser), 1) (gold) per Q2 lock. mobile-safe — filter on raster images works cross-browser (the [[feedback-mobile-svg-glow]] dead-end was specifically SVG glow, not raster drop-shadows). New _seed_minchiate_image_fixtures() helper in functional_tests/sig_page.py re-seeds the minimal Minchiate fixture (DeckVariant + Il Matto + Papa Uno) needed for image FTs after TransactionTestCase's flush wipes migration data — mirrors the existing _seed_earthman_sig_pile pattern per [[feedback-transactiontestcase-flush]]. New MySignImageRenderingTest.test_saved_sig_renders_as_img_for_image_deck FT seeds Minchiate + creates a superuser test gamer (superuser auto-gets super-nomad + super-schizo Notes via the User post_save signal, which _filter_major_unlocks then lets through to expose Il Matto in the picker grid — otherwise Minchiate's sig pool is empty since it has no MIDDLE arcana cards), equips Minchiate, saves Il Matto as sig, visits /billboard/my-sign/, asserts the stage card displays + contains an <img> w. src ending in the v2-convention filename minchiate-fiorentine-1860-1890-trumps-00-il-matto.png + carries .sig-stage-card--image marker class. Out of scope for this commit (deferred to A.3 follow-up polish + A.5+): the full stat-block restructure (top-left rank+suit chip Q♥ inline w. EMANATION/REVERSAL header; title in arcana-color font; keyword reposition; FYI panel re-anchor — per the locked Q3 spec) — image card-face ships now w. the existing stat-block layout to land the visible-win first. Tests: 1 new FT green; 15/15 my_sign FT class green (no regression on the 14 existing tests); 1289/1289 IT+UT total green (68s, unchanged from A.2 since no new ITs in this commit — FT covers the wiring end-to-end). Sprint A backend foundation (A.0+A.1+A.2) + first visible surface (A.3) all landed; 5 surfaces remain (A.5-A.8 + A.4's card-deck icon)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:04:18 -04:00
Disco DeDisco
91df482dd8 A.2 TarotCard.image_filename + display_suit_name properties — TDD. Sprint A.2 of [[project-image-based-deck-face-rendering]]. Adds two per-card derived properties that consume the new DeckVariant.family field (locked in A.0) to translate canonical-Earthman SUIT enum (BRANDS/CROWNS/GRAILS/BLADES) into family-authentic filename slugs + UI labels per [[reference-card-image-naming-convention]] v2. DeckVariant gains the family-mapping tables + methods (suit_slug / suit_display / trump_category); TarotCard consumes them via image_filename + display_suit_name. Two mapping tables live on DeckVariant (single source of truth for per-family vocab): _SUIT_SLUG_BY_FAMILY (4 families × 4 suits = 16 entries: earthman is identity-mapped {BRANDS→brands, CROWNS→crowns, GRAILS→grails, BLADES→blades}; italian is {BRANDS→batons, CROWNS→coins, GRAILS→cups, BLADES→swords}; english is {BRANDS→wands, CROWNS→pentacles, GRAILS→cups, BLADES→swords}; playing is {BRANDS→clubs, CROWNS→diamonds, GRAILS→hearts, BLADES→spades}) and _TRUMP_CATEGORY_BY_FAMILY (earthman+italian use "trumps", english uses "majors" matching Modern Tarot's "Major Arcana", playing is None since 52-card decks have no trump category — jokers handled separately when a playing deck is seeded). DeckVariant.suit_slug(canonical) returns the filename slug; suit_display(canonical) returns capitalized UI label (via slug.capitalize()); trump_category is a property since it takes no per-card argument. TarotCard.image_filename branches on arcana: MAJOR returns <deck-slug>-<trump-category>-<NN>-<card-slug>.png (NN = zero-padded number per v2 convention, e.g. 00 for Il Matto; card-slug carries the italian name like "il-gobbo" or english like "the-fool"); MINOR/MIDDLE returns <deck-slug>-<suit-slug>-<NN>[-<court>].png where court suffix is "page"/"knight"/"queen"/"king" for ranks 11-14 (tarot family courts; playing-family's 3-court jack/queen/king deferred to playing-deck-seed sprint). display_suit_name returns capitalized family-authentic suit name ("Batons" for italian BRANDS, "Pentacles" for english CROWNS) or empty string for major arcana (no suit). Both properties are pure-derived — no schema migration needed, no DB writes; the template (Sprint A.3+) decides whether to render <img src=image_filename> based on deck.has_card_images. RWS deck's image_filename returns a path even though has_card_images=False (path is correct per convention; just no file exists at that path yet — once RWS images are sourced, flip the flag). 17 new ITs in CardImageFilenameA2Test cover: Minchiate trumps (Il Matto rank-00, Il Gobbo rank-11, Le Trombe rank-40, L'Acqua rank-21 w. apostrophe-restored slug); Minchiate minors (Ace of Batons pip-with-no-court-suffix, Ten of Coins, Page of Cups w. court suffix, King of Swords); RWS post-revocab (Ace of Cups uses english-family "cups" slug despite suit=GRAILS, The Fool uses "majors" category, King of Pentacles uses "pentacles" slug despite suit=CROWNS); Earthman identity-mapped (BRANDS→brands); display_suit_name across all 3 tarot families (italian BRANDS→"Batons", italian CROWNS→"Coins", english CROWNS→"Pentacles", earthman BRANDS→"Brands"); empty for majors. Tests: 17 new green; 1289/1289 IT+UT total green (63s; +17 from A.1's 1272). Out of scope: A.3 wires my_sign.html's first render branch (the visible-win first surface); A.4 builds card-deck icon + game_kit applet; A.5-A.8 DRY across my_sea + both billboard applets + room
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:37:16 -04:00
Disco DeDisco
a4ac25605d A.1 seed Minchiate Fiorentine 1860-1890 deck (97 cards) — TDD. Sprint A.1 follow-up to [[project-image-based-deck-face-rendering]]. Creates the actual Minchiate Fiorentine DeckVariant (separate from the renamed-from-fiorentine-minchiate RWS Tarot now living at tarot-rider-waite-smith per A.0). Slug minchiate-fiorentine-1860-1890 matches the asset dir committed in 0add163 (98 PNGs at src/apps/epic/static/apps/epic/images/cards-faces/minchiate-fiorentine-1860-1890/); Sprint A.2's image_filename property will use deck.slug to point at those images. Schema fields set: family='italian' (drives display + filename slug mapping per [[reference-card-image-naming-convention]] — BRANDS→batons, CROWNS→coins, GRAILS→cups, BLADES→swords), has_card_images=True (first deck w. images shipped), is_polarized=False (Earthman remains the only polarized deck), is_default=False (Earthman is default), card_count=97. 97 TarotCard rows seeded: 41 trumps (Il Matto at rank 0 per the unnumbered-Fool-gets-sortable-position convention from [[reference-card-image-naming-convention]]; then 40 numbered 1-40) + 56 minors (4 suits × 14 cards = pip 1-10 + page=11 + knight=12 + queen=13 + king=14 per the v2 convention's number-prefixed-courts decision). Trump names are Italian (Papa Uno / Papa Due / La Temperanza / La Forza / La Giustizia / La Ruota della Fortuna / Il Carro / Il Gobbo / L'Impiccato / La Morte / Il Diavolo / La Casa del Diavolo / La Speranza / La Prudenza / La Fede / La Carita / Il Fuoco / L'Acqua / La Terra / L'Aria + 12 zodiac signs + La Stella / La Luna / Il Sole / Il Mondo / Le Trombe). Card-suit canonical enum stays BRANDS/CROWNS/GRAILS/BLADES per A.0's lock; minor card NAMES use Italian-family display vocab ("Page of Batons" not "Page of Brands") since names are the user-facing label whereas suit is the structural identity. 16 trumps carry a correspondence field pointing to their RWS Tarot equivalent (Il Matto→The Fool, Il Carro→The Chariot, Il Gobbo→The Hermit, L'Impiccato→The Hanged Man, La Morte→Death, Il Diavolo→The Devil, La Casa del Diavolo→The Tower, La Temperanza→Temperance, La Forza→Strength, La Giustizia→Justice, La Ruota della Fortuna→Wheel of Fortune, La Stella→The Star, La Luna→The Moon, Il Sole→The Sun, Il Mondo→The World, Le Trombe→Judgement); the 25 Minchiate-only trumps (5 popes + 4 theological/cardinal virtues + 4 elements + 12 zodiac) have no RWS parallel → empty correspondence. keywords_upright / keywords_reversed intentionally left empty []: those are interpretive content the user owns; admin form (Sprint B) will enrich via UI rather than have them committed as code in a migration. Five trumps in the v2 filename convention have elided-apostrophe slugs restored (l-impiccato, l-acqua, l-aria, l-ariete, l-acquario); DB slug field matches (no apostrophe, but with the leading l- prefix). 17 new ITs in MinchiateFiorentine1860SeedTest cover the deck attributes (name + family + has_card_images + is_polarized + card_count) + total row count (97) + arcana breakdown (41 trumps + 56 minors + 0 middle) + specific cards (Il Matto at rank 0 + Il Gobbo at rank 11 w. correspondence "The Hermit" + Le Trombe at rank 40 + 5 popes are MAJOR ranks 1-5 + Page of Batons + King of Coins) + canonical-suit-only check (no WANDS/CUPS/SWORDS/PENTACLES in DB) + court rank range (11-14 per suit). Tests: 17 new green; 1272/1272 IT+UT total green (64s; +17 from A.0's 1255). Out of scope: A.2 adds the TarotCard.image_filename + display_suit_name properties consuming deck.family for per-family translation; A.3 wires my_sign.html's first render branch
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:32:19 -04:00
Disco DeDisco
f107522b20 A.0 image-rendering schema + RWS rename + canonical-Earthman suit collapse — TDD. Sprint A.0 of [[project-image-based-deck-face-rendering]]. Adds three DeckVariant fields: has_card_images (BooleanField default=True — Earthman keeps False until its artwork ships, every new deck defaults True), family (CharField choices=[earthman, italian, english, playing] default=earthman — drives per-family display + filename slug mapping per [[reference-card-image-naming-convention]]), is_polarized (BooleanField default=False — Earthman is True today; Sprint A.4 game_kit applet will render "(×2)" in --terUser for polarized decks; Sprint C+B segment model uses it for segment-count logic). TarotCard.SUIT_CHOICES collapses from 8 values to 4 canonical Earthman values (BRANDS / CROWNS / GRAILS / BLADES); WANDS / CUPS / SWORDS / PENTACLES dropped — they were duplicative at the structural level since sig_deck_cards + levity/gravity_sig_cards already treated [WANDS, BRANDS, CROWNS] as one segment and [SWORDS, BLADES, CUPS, GRAILS] as another (so the project already *functionally* equated them; the lock just makes that explicit). Per-family display vocab (batons for Italian, wands for English, clubs for Playing) lives in Sprint A.2's display_suit_name property, not in the enum. Audit 2026-05-25 revealed the existing fiorentine-minchiate DeckVariant is actually 78-card RWS Tarot in disguise (22 majors numbered 0-21 w. RWS names: The Fool / The Magician / ... / The World; 56 minors in 4 suits × 14 cards) — NOT Minchiate (which has 40 trumps + 1 Il Matto + 56 minors = 97 cards). Migration 0012 renames the slug → tarot-rider-waite-smith, name → "Tarot (Rider-Waite-Smith)", sets family='english', has_card_images=False, is_polarized=False — and revocabs its 56 minor cards' suits in-place (WANDS→BRANDS, CUPS→GRAILS, SWORDS→BLADES, PENTACLES→CROWNS) so they match the new canonical enum. FKs (User.equipped_deck, User.unlocked_decks, TableSeat.deck_variant, etc.) survive untouched — slug-only changes don't break referential integrity. Earthman fields set explicitly in 0012 too (family=earthman, has_card_images=False, is_polarized=True). Companion code simplifications: sig_deck_cards + _sig_unique_cards_for_deck queries shrink from suit__in=[3 values] and [4 values] to [2 values] each (one per segment); TarotCard.suit_icon mapping shrinks from 8 entries to 4; gameboard.views.tarot_fan._suit_order shrinks from 8 keys to 4. Existing test files updated: test_game_room_tray.py (largest update — self.fiorentineself.rws, id_kit_fiorentine_deckid_kit_tarot_deck (template-id derives from deck.short_key = first slug segment), assertion "Fiorentine" → "Rider-Waite-Smith"); test_game_room_deck_contrib.py (same pattern, smaller); lyric/test_models.py + gameboard/test_views.py (slug literal swaps only); epic/test_models.py _make_sig_card test fixtures: "WANDS"→"BRANDS", "CUPS"→"GRAILS". 14 new ITs in DeckSchemaA0Test cover the schema additions + migration outcomes (field existence + choice values + earthman has all three fields set correctly + RWS rename verified + RWS cards use canonical suits + dropped enum values absent from SUIT_CHOICES). Tests: 14 new green; 1255/1255 IT+UT total green (38s); no regressions. Out of scope: Sprint A.1 will seed the actual Minchiate Fiorentine 1860-1890 (97-card) DeckVariant + TarotCard rows w. family='italian', has_card_images=True; A.2 adds the image_filename + display_suit_name properties that consume the new family field; A.3+ wires the render branches across 6 surfaces
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:25:26 -04:00
Disco DeDisco
0add163f5b feat: import Minchiate Fiorentine 1860-1890 deck assets (98 PNGs, alpha-channel transparent bg) + naming convention v2 + pngquant optimization tooling. Public-domain 1860-1890 lithograph scans sourced from Wikimedia (single download series + trump 11 Il Gobbo individually-sourced from same era series); user removed white backgrounds in Photoshop to leave irregular card-shape with transparent canvas. Filenames v2-conformant per [[reference-card-image-naming-convention]] (revised from v1 of 2026-05-24): deck slug carries -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
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:39:21 -04:00
Disco DeDisco
8a56ebff2c fix: swap UserAdmin's 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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:05:14 -04:00
Disco DeDisco
92df686d80 fix: significator_reversed=polarity bug + Pattern B name-swap rendering + qualifier-aware applet faces + sticky PAID DRAW + cooldown anchor on User + stat-block polarity unification across Sig/Sea/Fan/applets
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Five-thread sprint atop 53cd7af; all 1238 IT/UT green (no FTs run per [[feedback-ft-run-discipline]]).

**Thread 1 — User.significator_reversed is the POLARITY axis, not orientation.** The saved sig was rendering as a gravity reversal when the user saved a levity emanation. Root cause: `my_sign.html` JS post-save load called `_toggleOrientation()` whenever `revInput.value==='1'` (SPIN-ing a card whose flag only meant "polarity=levity"); `_applet-my-sign.html` applied `.stage-card--reversed` + `keywords_reversed` for the same flag. Fix: JS drops the `_toggleOrientation()` call (saved sigs are always upright in their polarity, never spun); the applet drops the rotation class, swaps to `my-sign-applet-card--{levity,gravity}` modifier, and always renders `keywords_upright` / "Emanation". `data-polarity` cascades correctly. Memory: [[feedback-significator-reversed-is-polarity]].

**Thread 2 — qualifier rendering on the My Sign + My Sea applets.** Both applets were rendering name only — no qualifier word. Added `TarotCard.applet_face(polarity, reversed)` (model method) + `User.sig_face` (delegator for the saved sig) returning `{title, qualifier, qualifier_first}` payload that mirrors `populateCard` in `stage-card.js`. `latest_draw_slots()` augments each slot dict w. `face`. Templates render `.fan-card-qualifier` + `.fan-card-name` in the order the payload dictates (non-Major: qualifier-above-title; Major+qualifier: title-with-trailing-comma above qualifier; polarity-split: single-line title). Typography matched to title (same bold, same size, same color via `color: inherit` w. polarity-pin at 0,3,0 specificity to beat `_card-deck.scss:376-383`'s 0,2,0 `.fan-card-face .fan-card-name` rule that out-cascades when loaded after gameboard).

**Thread 3 — My Sea cooldown bugs.** Two: (a) PAID DRAW button reverted to FREE DRAW after one navigation cycle because `my_sea_paid_draw` deleted the row at commit time — without a row, `quota_spent=False` on next render. (b) Brief's "next free draw at" was anchored to the most recent paid draw, not the original free draw. Fix: new `User.last_free_draw_at` field (set in `my_sea_lock` when a fresh row lands AND user wasn't already in cooldown — i.e., this is a tokenless free draw); paid draws NEVER touch it. New `MySeaDraw.paid_through_at` field stamped at commit time + cleared in `my_sea_lock` when the first card of the paid session lands (one-shot credit per user-spec: "each redraw needs a new token"). `my_sea_paid_draw` no longer deletes the row — clears hand+deposit, sets `paid_through_at`, redirects to `?phase=picker`. View's landing button uses `show_paid_draw` (`deposit_reserved OR paid_through_at`) so PAID DRAW persists across navigation until the paid session's first card lands. Brief reads `user.next_free_draw_at` (= `last_free_draw_at + 24h`) w. row-fallback for legacy test fixtures. 11 new ITs (`MySeaCooldownAnchoredToFreeDrawTest`, `UserFreeDrawCooldownPropertyTest`, expanded `MySeaPhasePickerQueryParamTest`, expanded `my_sea_lock` tests). Existing `test_paid_draw_deletes_active_draw_row` rewritten as `test_paid_draw_preserves_row_and_sets_paid_through_at`. 1 new FT pinning the navigation-persistence regression. Memory: [[feedback-my-sea-cooldown-design]].

**Thread 4 — Pattern B / B' Major reversal name-swap.** Card 34's My Sea applet rendered the reversal as "Animal Powers, Patrilineage" (Patrilineage treated as a qualifier). User-locked semantics: for Majors w. BOTH polarity qualifiers AND a `reversal_qualifier`, the `reversal_qualifier` field carries the NAME SWAP for the reversal face; the polarity qualifier persists across both faces. Affected cards: 2-5 (Pope/Horseman), 10-15 (Elements), 22-33 (Zodiac → Houses), 34-35 (Lunars), 41 (Asteroid Belt). Pattern B': cards 16-18 (Realms — Disco Inferno → Shame etc.) reversal face drops the qualifier entirely; new `TarotCard.reversal_drops_qualifier` BooleanField marks these (set True on 16-18 via `epic/0010_set_reversal_drops_qualifier_realms.py` data migration). `applet_face()` + `stage-card.js::populateCard` both branch on `arcana==MAJOR AND reversal_qualifier AND polarity_qualifier` → Pattern B/B' rendering. Non-Major `reversal_qualifier` semantics unchanged (middle court: "Queen of Crowns" stays as title, "Vacant" renders as the reversal-face qualifier). New data attr `data-reversal-drops-qualifier` added to `my_sign.html`, `_sig_select_overlay.html`, `_tarot_fan.html` so stage-card.js can read it via dataset. `card_dict()` extended w. the same field. 3 new UTs (`TarotCardAppletFaceTest`: Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin). Old `test_reversed_uses_reversal_qualifier_with_comma_for_major` deleted (it pinned the conflated old behavior).

**Thread 5 — unified card + stat-block polarity convention across all 6 surfaces** (Sig Select, Sea Select stage modal, Game Kit fan, My Sign applet, My Sea applet, room.html). User-locked: card and adjacent stat block always carry OPPOSITE-polarity bgs (gravity card --priUser → stat block --secUser; levity card --secUser → stat block --priUser). `.is-reversed` (SPIN) is preview-only — never shifts bg. Per-card scoping (NOT page-wide) — drawn sea cards each carry their own polarity from the deck stack; `.sea-stage--{gravity,levity}` parent rules + `.tarot-fan-wrap[data-polarity=...]` parent rules cascade to their respective stat blocks. `game-kit.js` `_populateStage` + `_flipActive` mirror `_polarity` onto `.tarot-fan-wrap` so SCSS can pick it up without touching the stat block directly. Sea-stat-block was previously stuck at --priUser regardless of polarity; fan-stage-block ditto. Both inverted now. Memory: [[feedback-card-polarity-convention]].

**Bundled polish across the same surfaces** (each one a small visible item the user spotted during the sprint):
- My Sign applet card: levity polarity flips bg to --secUser + border to --priUser + ink to --quiUser (matches page stage card at `_card-deck.scss:1002-1019`). Gravity stat block flips to --secUser bg w. --quiUser label ink + --priUser keyword ink (matches `_card-deck.scss:1042-1046`).
- Qualifier + title share typography (font-size, weight, polarity-color, text-wrap). `.fan-card-face { gap: 0 }` + `line-height: 1.15` so qualifier sits directly above title at the title's own line-height. `.fan-card-arcana { margin-top }` reserves breathing room below.
- `.fan-card-qualifier:empty { display: none }` collapses polarity-split / Major-no-qualifier cards cleanly.

**Memory recorded**:
1. [[feedback-ft-run-discipline]] — re-pinned 2026-05-23 after I burned a multi-minute full-FT-suite run mid-task. Default loop is IT/UT only. FT runs must be ONE test method by full dotted path; never a whole file; never re-run an already-green FT.
2. [[feedback-significator-reversed-is-polarity]] — the flag is polarity (FLIP), not orientation (SPIN); SPIN never persisted; saved sigs always upright in their polarity.
3. [[feedback-card-polarity-convention]] — opposite-polarity stat-block bg, per-card scoping, SPIN never shifts bg, the full color table.
4. [[feedback-my-sea-cooldown-design]] — cooldown anchored to User.last_free_draw_at, paid draws never reset it, paid_through_at is a sticky one-shot credit, button state machine.

**Files** (every uncommitted file folded in — session work + pre-existing modifications):

Models / migrations:
- `apps/epic/models.py` — `applet_face()` extended w. Pattern B/B' branches; new `reversal_drops_qualifier` BooleanField.
- `apps/epic/migrations/0009_reversal_drops_qualifier.py` — schema.
- `apps/epic/migrations/0010_set_reversal_drops_qualifier_realms.py` — data migration setting flag True on cards 16-18.
- `apps/epic/utils.py` — `card_dict` carries `reversal_drops_qualifier`.
- `apps/gameboard/models.py` — `paid_through_at` field; `latest_draw_slots()` attaches `face` payload per slot; `active_draw_for` docstring refreshed.
- `apps/gameboard/migrations/0003_myseadraw_paid_through_at.py` — schema.
- `apps/lyric/models.py` — `last_free_draw_at` field; `free_draw_cooldown_active` + `next_free_draw_at` props; `sig_face` delegator.
- `apps/lyric/migrations/0013_user_last_free_draw_at.py` — schema.

Views:
- `apps/gameboard/views.py` — `my_sea` view button state machine (`show_paid_draw` / `show_gate_view` / `show_picker`); `my_sea_lock` sets `last_free_draw_at` on free-draw + clears `paid_through_at` on paid-session first card; `my_sea_paid_draw` preserves row + stamps `paid_through_at`.

JS:
- `apps/epic/static/apps/epic/stage-card.js` — `fromDataset` reads `reversal_drops_qualifier`; `populateCard` branches Pattern B / B' for the reversal face.
- `apps/gameboard/static/apps/gameboard/game-kit.js` — mirrors `_polarity` onto `.tarot-fan-wrap` so SCSS can invert the fan-stage-block bg per active card.

Templates:
- `templates/apps/billboard/my_sign.html` — JS drops `_toggleOrientation()` on saved-sig load; sig-card grid carries `data-reversal-drops-qualifier`.
- `templates/apps/billboard/_partials/_applet-my-sign.html` — drops `stage-card--reversed`, adds polarity modifier, renders qualifier via `sig_face` payload, always shows Emanation keywords + label.
- `templates/apps/gameboard/_partials/_applet-my-sea.html` — renders qualifier via `slot.face` payload (Pattern B/B' aware).
- `templates/apps/gameboard/_partials/_sig_select_overlay.html` + `_tarot_fan.html` — `data-reversal-drops-qualifier` added to sig-card grid + fan cards.
- `templates/apps/gameboard/my_sea.html` — landing button form swaps to `show_paid_draw` / `show_gate_view` flags.

SCSS:
- `static_src/scss/_billboard.scss` — My Sign applet card polarity inversion (levity bg + ink), polarity stat-block inversion (gravity → --secUser bg), qualifier+title shared typography, polarity-aware ink via `color: inherit`.
- `static_src/scss/_card-deck.scss` — sea-stat-block polarity rules (`.sea-stage--gravity/levity .sea-stat-block`), fan-stage-block polarity rules (`.tarot-fan-wrap[data-polarity] .fan-stage-block`), comments documenting fallback bgs.
- `static_src/scss/_gameboard.scss` — `.my-sea-slot--filled.--gravity/--levity` pin `color: inherit` on `.fan-card-corner`, `.fan-card-qualifier`, `.fan-card-name`, `.fan-card-arcana` (0,3,0 beats global 0,2,0). Slot label keeps original wrap-sibling placement w. `z-index: 2` to render above the dotted bottom border on empty slots.

Tests:
- `apps/billboard/tests/integrated/test_views.py` — updated `test_my_sign_applet_renders_card_when_sig_set` to assert polarity modifier + qualifier text + Emanation-only; new `test_my_sign_applet_renders_gravity_qualifier_when_not_reversed`.
- `apps/epic/tests/unit/test_models.py` — `TarotCardAppletFaceTest` (Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin, polarity-split, reversal qualifier fallback).
- `apps/gameboard/tests/integrated/test_views.py` — `MySeaCooldownAnchoredToFreeDrawTest` (5 tests pinning cooldown anchor on User, sticky PAID DRAW, paid-through credit consumption); `UserFreeDrawCooldownPropertyTest` (4 tests); expanded `MySeaPhasePickerQueryParamTest` w. paid-through-shows-PAID-DRAW-btn assertion; expanded `my_sea_lock` tests (free-draw-anchors-last_free_draw_at, paid-draw-leaves-anchor-alone, first-paid-card-consumes-credit); My Sea applet qualifier IT (Major comma format end-to-end).
- `functional_tests/test_game_my_sea.py` — `test_paid_draw_commits_token_and_redirects_to_picker` updated to assert row preservation + paid_through_at stamping; new `test_paid_draw_btn_persists_after_navigation_without_card_draw` pinning the user-reported regression.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:06:35 -04:00
Disco DeDisco
53cd7afeb4 feat: My Sea applet dynamic population + lay/leave POSITION_LABELS swap fix + My Sign applet stat-block + Brief-fied sign-gate + --duoUser olive on all four personal-data surfaces. Six visual+structural items batched across the dashboard/billboard/gameboard.
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
(1) **My Sea applet dynamic population.** Applet at `_applet-my-sea.html` was referencing an undefined `latest_draw_cards` template var — fell through to "No draws yet" even when the user had an active draw. New helpers in `apps/gameboard/models.py`: `DRAW_ORDER` + `POSITION_LABELS` constants (Python mirrors of the JS dicts in `my_sea.html:274-293`) + `latest_draw_slots(user)` builder that pairs each spread position w. its drawn card + display label + polarity. Wired through `gameboard()` + `toggle_game_applets()` views as `my_sea_slots`. Applet now renders all spread slots in DRAW_ORDER: filled = `.my-sea-slot--filled.my-sea-slot--{gravity,levity}` w. corner-tl + face (name + arcana) + corner-br (mirror) markup (same shape language as my_sign.html `.sig-stage-card`), empty = `.my-sea-slot--empty` w. `0.15rem dashed rgba(var(--terUser), 1)` border (matches the picker's `.sea-card-slot` style exactly so the applet reads as a true scaled-down twin). Container queries (`container-type: size` on `.my-sea-scroll`) lift `--slot-w` to fill the applet's vertical aperture (`min(100cqi, calc((100cqh - 1rem) * 5 / 8))` carves the label row). Position labels pulled tight against the slot's bottom border (`margin-top: -0.15rem` crosses the border line) + vertically stretched (`transform: scaleY(1.4)` mirroring `.sea-pos-label` in `_card-deck.scss:1671-1684`) — empty-slot labels keep the same `--secUser` ink as filled-slot labels for title cohesion across the row. Horizontal-scroll on multi-card spreads via mousewheel — `bindMySeaWheel()` in `gameboard.js` translates vertical wheel events to `scrollLeft += deltaY` (lifted verbatim from `bindPaletteWheel` in `dashboard.js:7-14`).

(2) **lay/leave POSITION_LABELS swap fix.** User caught in the Escape Velocity picker that LEFT slot read "Lay" + BOTTOM slot read "Leave" — opposite of traditional Celtic Cross semantics (LEFT = Behind/past, BOTTOM = Beneath/root). Root cause: POSITION_LABELS for both Waite-Smith + Escape Velocity had `lay`/`leave` slug→label assignments inverted vs the CSS grid's spatial mapping (`_card-deck.scss:1276-1279` puts slug `lay` at BOTTOM, slug `leave` at LEFT). Fix in 5 places: `my_sea.html:287,292` JS POSITION_LABELS (WS: lay→"Beneath", leave→"Behind"; EV: lay→"Lay", leave→"Leave"), `gameboard/models.py:44-47` Python mirror, `test_game_my_sea.py:618-619` FT label-assertion table, `_sea_overlay.html:28,53` annotated comments (`sea-pos-leave` → "Behind (past) — CC pos 6 / EV pos 4"; `sea-pos-lay` → "Beneath (root) — CC pos 4 / EV pos 3"). Slug-to-CSS mapping, DRAW_ORDER, + DB persistence unchanged → no migrations, no data invalidation. **Crucial for Voronoi mapping correctness** per user spec.

(3) **My Sign applet — stage-card layout + stat-block beside.** Applet card markup upgraded to mirror my_sign.html `.sig-stage-card`: corner-tl + face (name + arcana centred) + corner-br (mirror, rotated 180°). Sized to fill applet height via container queries (`--applet-card-w: min(48cqi, 62.5cqh)` — 48cqi caps the card at half the row to leave room for the stat-block). Sibling `.my-sign-applet-stat-block` partial added — emanation/reversal face label + keyword list (from `card.keywords_upright` / `keywords_reversed` keyed off `significator_reversed`), no SPIN/FYI buttons (applet is read-only). Styling cribbed from `.sig-stat-block` in `_card-deck.scss:595-607` — priUser-translucent bg + terUser border + matching `--applet-card-w` sizing.

(4) **My Sea sign-gate refactored to Brief banner.** Was an inline `.my-sea-sign-gate` div w. its own SCSS — broke from the project's `Brief.showBanner` portal pattern. Refactored to a shared `_my_sea_sign_gate_brief.html` partial that fires `Brief.showBanner` w. title="Sign required" + line_text="Look!—pick your sign before drawing the Sea." + post_url=`/billboard/my-sign/`. Brief portals to the page-level h2 anchor via `note.js`'s `_alignToH2` (gaussian-glass `.note-banner` shell, FYI button → my-sign picker, NVM dismisses). Modifier class `.my-sea-sign-gate-brief` added post-render for FT selector disambiguation. note.js load hoisted to gameboard.html `{% block scripts %}` + the top of `my_sea.html {% block content %}` (single load per page — note.js declares `const Brief = ...` at global scope, second load = SyntaxError). All `.my-sea-sign-gate{,--applet,__line,__actions,__back,__fyi}` SCSS deleted. FTs (`test_no_sig_renders_lookline_gate_on_standalone_page` + 5 siblings) + ITs (`test_my_sea_applet_fires_sign_gate_brief_for_user_without_sig` etc.) updated to assert `.note-banner.my-sea-sign-gate-brief` + the JS-rendered FYI/NVM buttons inside the Brief shell.

(5) **Levity card text invisibility fix.** My-sea applet levity slots (--secUser bg) rendered their corner-rank + suit-icon invisible because `.fan-card-corner` carries a global `color: rgba(var(--secUser), 0.75)` rule at `_card-deck.scss:312-319` (specificity 0,1,0) that out-specifics the slot's inherited `color: --priUser`. Same trap as the `.fan-card-name { color: --quiUser }` global. Fix at `_gameboard.scss` inside the levity rule: explicit `.fan-card-corner { color: rgba(var(--priUser), 1) }` + `.fan-card-name { color: rgba(var(--priUser), 1) }` + `.fan-card-arcana { color: rgba(var(--priUser), 0.7) }` overrides at (1,3,1) specificity — beats the globals without `!important`. **Trap captured in memory** — pattern repeats across game-kit, my-sign, my-sea so worth pinning.

(6) **--duoUser olive on all five personal-data surfaces.** Per user spec, the four "personal" applets (My Sign on billboard, My Sea on gameboard, My Sky on dashboard) + the standalone Dashsky page + the standalone My Sign page got `background-color: rgba(var(--duoUser), 1)` so they read as a unified olive-bg group across navigation surfaces. For Dashsky specifically, the form column also got the override (`.sky-page .sky-form-col { background: --duoUser }`) — the base `.sky-form-col { background: --priUser }` (`_sky.scss:137`, shared w. the in-room CAST SKY modal) was leaving the dashsky form column purple inside the otherwise-olive page. Scoped to `.sky-page` so the in-room modal's purple form-col stays intact (sits over --secUser room bg, needs that contrast). One detour caught: tried `body.page-sky { background-color: --duoUser }` to fill the gap below .sky-page's content-sized aperture but it bled to navbar + footer (which sit outside .container) — reverted.

**TDD coverage**: 3 new ITs in `apps/gameboard/tests/integrated/test_views.py` — `test_my_sea_applet_renders_drawn_cards_in_draw_order` (SAO 1-of-3 fills `lay` slot, cover/crown render as empty placeholders), `test_my_sea_applet_labels_match_locked_spread` (SAO labels exactly Situation/Action/Outcome), `test_my_sea_applet_waite_smith_labels_post_fix` (regression pin for the WS Cover/Cross/Crown/**Beneath**/Before/**Behind** sequence post-swap-fix). Existing my-sea applet ITs updated to match the new selector vocabulary (`.my-sea-slot--filled` instead of `.my-sea-card`, Brief script substring instead of `.my-sea-sign-gate--applet`). 6 my-sea FTs updated to the Brief-banner contract. 1214/1214 IT/UT green.

**.gitignore**: temporary entry for `src/apps/epic/static/apps/epic/images/cards-faces/minchiate-fiorentine/` until images get renamed — flagged for removal once the rename lands. (Per user's wget download of the Minchiate faces into the gameboard cards/ tree this session.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:19:34 -04:00
Disco DeDisco
1452de1a76 feat: My Sign saved-sig state — --duoUser bg, centred card+stat-block, stage card auto-rotates for reversed sigs on landing. Three follow-up polish items atop the f609313 read-only-saved-sig batch.
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
(1) **`--duoUser` bg on the saved-sig aperture.** Per user spec — once the table hex is server-side gone (f609313's `{% if not current_significator %}` wrap), the now-mostly-empty olive aperture reads as a distinct mode vs the default landing (--priUser bg w. hex). New `.my-sign-page[data-current-card-id] { background-color: rgba(var(--duoUser), 1); }` block in `_card-deck.scss:644-696`. Keyed on `data-current-card-id` (present only when `current_significator` is set per `my_sign.html:20`) rather than the absence of `[data-phase="landing"]` — picker also lacks the hex but should keep --priUser. Mirrors how `.my-sea-page[data-phase="picker"]` swaps bg in `_gameboard.scss`.

(2) **Stage card + stat block centre in the aperture.** Default landing left-anchored the stage natural-sized at the top of the column (above the hex which filled the rest); w. the hex gone there's a wide empty page bottom. `.my-sign-page[data-current-card-id] .my-sign-stage` overrides to `flex: 1; justify-content: center; align-items: center; padding-left: 0;` — stage grows to fill, card+stat-block centre as a unit. `.my-sign-landing` collapses to `flex: 0 0 auto` + `position: static`; DEL is `position: absolute` so it walks up to `.my-sign-page` (already `position: relative`) + pins to the page corner. **2 traps caught mid-build** in the centring pass: (a) `.sig-stat-block`'s default `align-self: flex-end` (`_card-deck.scss:599`) overrode the parent's `align-items: center` on the cross axis, so the stat block floated to the bottom of the stage while the card sat at vertical-centre — forced `align-self: center` on this state. (b) `.my-sign-flip-btn`'s `left: calc(1.5rem + 0.4rem)` (`_card-deck.scss:747`) assumed the card sat flush against `.sig-stage`'s padded-left edge — true on the picker but wrong w. `justify-content: center`, FLIP landed at the stage's left edge w. the card centred ~3rem to the right of it. Re-derived left/bottom from the centred geometry: card's left edge in stage = `(100% - 2 * sig-card-w - 0.75rem) / 2` (the centred card+gap+stat group's left), card's bottom edge = `50% - sig-card-w * 0.8` from stage bottom (cardHeight = sig-card-w × 8/5 = × 1.6, half = × 0.8). `+ 0.4rem` on each lands FLIP just inside the card's bottom-left corner, same offset as the picker-side intent.

(3) **Stage card auto-rotates 180° on landing for saved-reversed sigs.** Server-side `data-polarity` attribute on `.my-sign-page` already reflected `significator_reversed` correctly (drives the polarity-themed color rules at `_card-deck.scss:917-1042` for levity/gravity ink) but the visual 180° rotation lives in the `stage-card--reversed` class which was only JS-applied via `_toggleOrientation()` (SPIN btn handler). On init w. a saved sig, `_populateStage(savedCardEl)` filled the card's data but didn't touch rotation — so saved-reversed sigs rendered upright on landing while the My Sign applet (template-driven, reads `request.user.significator_reversed` directly + conditionally adds `stage-card--reversed` per `_applet-my-sign.html:9`) correctly rotated them. Two surfaces disagreed → user read the applet as inverted ("non-reversed sig displays upside-down in the applet"). Actually the my_sign.html stage was the liar; the applet was right. Fixed at `my_sign.html:404-406` — after `_populateStage(savedCardEl) + stage.classList.add('sig-stage--frozen')`, if `revInput.value === '1'` (= saved reversed=True) call `_toggleOrientation()` once. That helper covers all three coordinated state mutations: `stageCard.classList.toggle('stage-card--reversed', on)` (visual 180° rotation), `statBlock.classList.toggle('is-reversed', on)` (swaps to reversal face per `_card-deck.scss:62-65`), `spinBtn.classList.toggle('is-reversed', on)` (visual indicator). Both surfaces now agree. Per user direction, a follow-up will lock my_sign.html SAVE to always write `reversed=False` (Tarot-tradition convention) — but the underlying rotation pipeline still has to work for room-side sig-select where reversed sigs are needed.

**TDD coverage**: no new tests — `test_landing_previews_saved_sig_on_stage` (updated in f609313) still passes as written (its assertions are around the frozen-stage + stat-block-visible + hex-absent contract, all of which hold under the centring + rotation patches). The reversed-sig-auto-rotate case is light-weight enough (one branch w. a well-known helper) to not need a dedicated FT; if it regresses, the existing room-side `_toggleOrientation` coverage in the gameboard FTs catches the helper itself + manual verify caught it here. Manual verify done on /billboard/my-sign/ w. `disco`'s saved Jack of Brands (reversed=False, renders upright + centred w. --duoUser bg + FLIP on card's bottom-left + DEL on page's bottom-right). 1211 IT/UT still green; one minor visual to chase before locking my_sign.html to non-reversed-only — verifying that a reversed-saved sig renders rotated on return (DB has none currently, will test after the follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:21:55 -04:00
Disco DeDisco
f6093136f1 fix: shop tooltip price flex-pinned right (cross-file #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.
(1) **Shop tooltip price right-align — root-cause fix.** Earlier today's `feat: shop tooltip price moves to the title row` (commit e90f10f) left `.tt-price` visually adjacent to the name instead of pinned right despite `<h4 class="tt-title">` carrying flex+space-between in `_tooltips.scss:38-46`'s `.token-tooltip, .tt { h4 { ... } }` block. **The bug was in a totally unrelated file**: `_palette-picker.scss:88-95` opens `#id_tooltip_portal { .tt-title, .tt-description, .tt-date, .tt-lock { display: block; } }` for the palette swatch tooltip — `#id_tooltip_portal .tt-title` has specificity **1,1,0** which beats `.token-tooltip h4`'s **0,1,1**, ID wins regardless of source order. The palette tooltip's h4 only carries a text node (no flex children) so `block` vs `flex` looked identical for that surface + the rule sat there as quiet defensive scaffolding for months. The wallet Shop's two-`<span>` h4 finally exercised it. Fix: drop `.tt-title` from the palette override list (left `.tt-description`/`.tt-date`/`.tt-lock` alone — those are `<p>` siblings, already block, redundant but harmless). Also keeps `margin-left: auto !important` on `.tt-price` (today's earlier failed-first-attempt fix) — now redundant w. the flex parent's space-between but documents intent + survives any future flex-direction tweak. Generalizable trap: an ID-scoped child rule in any consumer's SCSS file can silently override shared base rules for every consumer of that portal.

(2) **Game Kit row space-evenly.** `#id_game_kit` in `_gameboard.scss:61-72` was `justify-content: center` + `gap: 0.75rem` so 7 trinket icons clumped left-of-center. Switched to `justify-content: space-evenly` (no gap) — matches the established convention in `.token-row` (`_wallet-tokens.scss:46`) + `.shop-grid` (`_wallet-tokens.scss:92`) which both space-evenly the wallet's parallel rows. Items now spread across the applet width same as the wallet's tokens row + shop row.

(3) **My Sign page collapses to read-only when sig is committed.** Per user spec — once a sig is saved, the SCAN SIGN btn + table hex + chair are meaningless (you can't draw a new sig until you DEL the current one) so the landing renders only the saved-sig preview on the stage + the DEL btn pinned bottom-right. `my_sign.html:91-110` wraps the `.room-shell > .room-table > ... > #id_scan_sign_btn + .table-seat` chain in `{% if not current_significator %}`. `.my-sign-clear-form` stays unconditional (its own `{% if current_significator %}` block) — its `position: absolute; bottom: 0.75rem; right: 1rem` against the now-empty `.my-sign-landing` (which keeps `position: relative` from `_card-deck.scss:671`) lands the DEL in the same bottom-right corner whether the hex is present or not.

(4) **Stat block reveals next to saved-sig preview on landing.** `_populateStage(savedCardEl)` on init was filling the stage card data but `.sig-stat-block` stayed `display: none` because only `.sig-stage--frozen` (added by JS on OK-confirm in picker phase) reveals it via `_card-deck.scss:609`'s `&.sig-stage--frozen .sig-stat-block { display: block; }`. Added `stage.classList.add('sig-stage--frozen');` after the saved-sig `_populateStage` call at `my_sign.html:386`. Stat block now sits flex-row-adjacent to the stage card (stage's `flex-direction: row` + `gap: 0.75rem` from `_card-deck.scss:505-508` does the layout work) — emanation keywords + SPIN + FYI all visible alongside the saved card.

(5) **My Sign applet card — proper 5:8 card shell.** The applet's `<div class="my-sign-applet-card">` markup (corner-rank top-left + name) was rendering bg-less + collapsed to the applet's top-left corner because **no SCSS rule existed** for `.my-sign-applet-card` / `.my-sign-applet-body` / `.my-sign-applet-empty`. Added `#id_applet_my_sign` block at `_billboard.scss:434+` — scaled-down clone of `.sig-stage-card`'s shape language: `--applet-card-w: 5rem` knob drives all child sizing via the same calc-fractions used by `.sig-stage-card`'s `--sig-card-w`, 5:8 aspect-ratio, `--priUser` bg, `--secUser` border, corner-rank absolute top-left, `.fan-card-name` flex-centered, `&.stage-card--reversed { transform: rotate(180deg); }` for the reversed-sig case. `.my-sign-applet-body` flex-centers the card in the 4×6 applet aperture; `.my-sign-applet-empty` flex-centers the 'No sign chosen yet.' empty state. Layered visually consistent w. the room sig-select card + Shop tiles.

(6) **Misc visual cleanup bundled in.** `#id_scan_sign_btn` in `_card-deck.scss:677` lost its `font-size: 0.75rem` + `line-height: 1.1` overrides — the default `.btn-primary` sizing scales fine w. the 4rem circle now that the SCAN/SIGN wordmark fits cleanly w. just `white-space: normal`. `.tt-buy-btn` lost `line-height: 1.1` in `_wallet-tokens.scss:156` — Shop microbutton renders cleanly w. the default.

**TDD coverage**: `test_landing_previews_saved_sig_on_stage` in `test_bill_my_sign.py` rewritten to match the new contract (stage frozen → stat block visible, no SCAN SIGN btn, no `.table-hex` when sig is saved); other 28 my-sign FTs unaffected (they exercise the no-sig path which still renders the hex + SCAN SIGN). 3 traps caught + linked in memory: [[feedback-cross-file-id-scoped-override]] (this commit's #1), the pre-existing [[feedback-margin-auto-needs-flex-parent]] (correctly predicted today's bug — `!important` ladder was a tell to audit the parent's cascade), [[feedback-scss-import-order-specificity]] (related but different: same-specificity source order; this one was specificity-driven w. source order irrelevant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:42:03 -04:00
Disco DeDisco
e90f10fe47 feat: shop tooltip price moves to the title row, right-aligned --priGn. The <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
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:32:05 -04:00
Disco DeDisco
25f55f728a feat: wallet Shop polish — microtooltip extraction, Shop-first ordering, DRY tooltip styling, writs rebalance, "no expiry" on all items. Visual-pass tweaks landing atop the 5-chunk Shop rollout (commits 8e476f5d28cf7b). **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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:21:10 -04:00
Disco DeDisco
d28cf7b538 chore: drop legacy #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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:23:07 -04:00
Disco DeDisco
81b3c112b4 feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug 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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:15:05 -04:00
Disco DeDisco
410664fb0f feat: shop PaymentIntent flow — 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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:42:09 -04:00
Disco DeDisco
849ef3c310 feat: ShopItem + Purchase models + seed tithe-1 / tithe-5 / band-1 + wallet-shop Applet — Chunk 2 of [[project-wallet-shop-expansion]]. ShopItem is the admin-managed catalog: slug, name, description, icon (FA class), badge_text (eg "×5"), price_cents, granted_token_type (any Token type), granted_count, granted_writs (default 0), max_owned (nullable; BAND=1), display_order, active. is_available_for(user) enforces max_owned by comparing user's owned-count of the granted token type. price_display() renders cents → "$1" / "$4.20" for tooltip prose. Purchase is the per-tx audit trail: user + shop_item + stripe_payment_intent_id (unique) + status (PENDING/SUCCEEDED/FAILED/REFUNDED) + amount_cents snapshot + granted_writs snapshot + granted_token_ids JSONField (PKs of minted tokens) + created_at + succeeded_at. fulfill() is idempotent — short-circuits if status==SUCCEEDED + refuses non-PENDING rows so a webhook + sync /shop/confirm racing each other can't double-mint. Schema migration lyric/0008_shopitem_purchase autogenerated. Seed migration lyric/0009_seed_shop_items populates the 3 starting items per locked decisions: tithe-1 ($1 → 1 TITHE + 144 writs, no cap, order=10); tithe-5 ($4 → 5 TITHE + 750 writs, no cap, badge "×5", order=20); band-1 ($20 → 1 BAND + 0 writs, max_owned=1, order=30). Applet migration applets/0011_seed_wallet_shop_applet adds the wallet-shop Applet (context=wallet, 12 cols × 3 rows). Stub _applet-wallet-shop.html lands w. just <section id="id_wallet_shop"> + <h2>Shop</h2>_applets.html's auto-include-by-slug pattern would 500 the wallet page on TemplateDoesNotExist otherwise (caught mid-Chunk-2 by the full app suite). Chunk 4 fills in the shop-tile grid + BUY-ITEM microtooltip + Stripe.js wiring. TDD — 22 ITs in test_shop_models.py: ShopItemModelTest (9 cases — minimal create, defaults for granted_writs / max_owned / active, is_available_for w/ + w/o max_owned cap, str repr), PurchaseModelTest (8 cases — minimal create, PI ID uniqueness constraint, fulfill mints tokens + grants writs + marks SUCCEEDED + records granted_token_ids + is idempotent on re-fire + creates N tokens for bundle), SeededShopCatalogTest (4 cases pin tithe-1 / tithe-5 / band-1 row shapes + display_order ascending), SeededWalletShopAppletTest (1 case pins Applet seeded). 1191 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:30:59 -04:00
Disco DeDisco
8e476f5658 feat: wallet Tokens applet shows CARTE + BAND + COIN + PASS independently — Chunk 1 of the Shop applet rollout per [[project-wallet-shop-expansion]]. Pre-Chunk-1 the _applet-wallet-tokens.html template used a {% if pass_token %} ... {% elif band %} ... {% elif coin %} chain that suppressed 2-of-3 trinkets from the wallet whenever the user held a higher-priority one — bad UX since the equip slot is now the user's opt-in for trinket-as-token use per [[feedback-equip-slot-gates-trinket-use]], so ALL owned trinkets need visibility. Fix: dropped the elif chain → independent {% if %} blocks for PASS / BAND / COIN; added a new CARTE block w. fa-money-check icon mirroring the Game Kit's render. View context (apps.dashboard.views.wallet + :toggle_wallet_applets) now passes carte = user.tokens.filter(token_type=Token.CARTE).first() alongside the existing pass/band/coin keys (no is_staff filter — CARTE has no admin gate). TDD — new WalletTokensAppletAllTrinketsVisibleTest (9 ITs): 6 pin individual #id_<token> visibility for a staff user holding all 5 types, 2 pin view-context shape (carte + band keys), 1 pins CARTE-on-non-staff. New FT test_wallet_tokens_applet_shows_all_owned_trinket_types reads BAND/CARTE .tt innerHTML directly (no hover ceremony — already covered by the COIN/FREE hover paths in test_new_user_wallet_shows_starting_balances) to pin the new template blocks server-render full tooltip prose. **Trap caught mid-build**: initial multi-line {# ... #} Django comment leaked as plain text into the rendered DOM (Django's hash-comment is single-line only), pushing the COIN tile off-screen + breaking the existing hover FT. Switched to {% comment %}...{% endcomment %}. Captured in [[feedback-django-comments-single-line-only]] — symptom signature: previously-passing Selenium hover times out + screendump shows literal {# ... text near the broken element. 1169 IT/UT + 6 wallet FTs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:07:42 -04:00
Disco DeDisco
eb8666ba40 fix: my-sea drawn cards no longer always render levity-coded — yesterday's 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
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:04:53 -04:00
Disco DeDisco
ca169be0fb fix: CI Postgres teardown — 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
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:24:41 -04:00
Disco DeDisco
8dd4347dbe fix: gate token-picker now equip-gated — 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]]
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:56:59 -04:00
Disco DeDisco
f59c1af89a fix: /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)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:40:08 -04:00
Disco DeDisco
99ffdb3943 feat: 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]]
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
Disco DeDisco
0f60c73f3b fix: 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]]
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:35:55 -04:00
Disco DeDisco
97a6da28a5 fix: manual my-sea draws persist on refresh + reloaded slots stay clickable — root cause was SeaDeal stamping 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:
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- test_manual_draw_persists_on_refresh
- test_reloaded_slot_can_reopen_stage_modal_on_click

Changes:
- sea.js: stamp `dataset.posKey` w. raw name (strip `.sea-pos-` prefix); `_seaHand` keyed by raw; `_viewingPos` is raw too (`_hideStage` prefixes when querySelector'ing); new `SeaDeal.seedHand(handByPosName)` public method for init-time DOM-walk seeding.
- my_sea.html inline init: walk server-rendered filled slots, look up each card by `data-card-id` from the embedded deck JSON, reconstruct per-instance `reversed` + polarity from the slot's classes, hand the map to `SeaDeal.seedHand`. Without this, reloaded slots short-circuit the overlay click handler on `if (!_seaHand[pos]) return;`.

The gameroom-side SeaDeal callers in `_sea_overlay.html` continue to pass selector form (SeaDeal accepts either — `_posName` helper strips prefix tolerantly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:08:49 -04:00
Disco DeDisco
bb44aa326a fix: AUTO-DRAWn my-sea cards are now clickable to re-open the stage modal — 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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:53:05 -04:00
Disco DeDisco
31cb8dfc1d CI: route test_game_my_sea*.py to test-FTs-room stage — 49 my-sea FTs DRY-reuse the room-shell hex + sea-cross picker (same Selenium surface as test_game_room_* + test_trinket_*), so they belong w. the heavy room flows instead of bloating test-FTs-non-room. Filename-regex partition stays clean (13 room + 24 non-room = 37 total, no overlap)
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:20:23 -04:00
Disco DeDisco
899e626265 CI: _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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:14:26 -04:00
Disco DeDisco
f348a19312 my-sea portrait SPREAD dropdown opens UP, not down — 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
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:04:51 -04:00
Disco DeDisco
bc4565f161 my-sea portrait form-col grid fix: chain .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)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:02:07 -04:00
Disco DeDisco
4963237420 my-sea portrait form-col split: SPREAD field + action btns LEFT, DECKS RIGHT — fits the form on a phone-portrait viewport without DECKS pushing the AUTO DRAW / DEL row off-screen. CSS grid w. display: contents on .sea-form-main flattens the intermediate wrapper so its children participate directly in the grid
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:58:41 -04:00
Disco DeDisco
191dad5365 my-sea hex-btn state-machine FT pin: extend 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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:56:09 -04:00
Disco DeDisco
611ca9b5b4 my-sea polish v2: portrait .my-sea-picker stacks form col BELOW the cross (mirrors gameroom SEA SELECT modal) + sync MySeaGatekeeperPageTest.test_paid_draw_commits_token_and_redirects_to_picker w. iter-6c row-delete + ?phase=picker semantics (was pinning iter-6a behavior; pipeline #319 caught it)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:52:17 -04:00
Disco DeDisco
db443b7533 Revert universal .btn-disabled → × pseudo-element overlay (iter-4c); restore case-by-case &times; 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.
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
The iter-4c bundle added a universal `&::before { content: "\00d7"; ... }`
overlay on every `.btn-disabled` button + hid native text via
`> * { visibility: hidden }` + `color: transparent`. Visually flattened
every disabled state across the app (DEL, FLIP, DON/DOFF, palette
swatches, etc.) onto a single × glyph — user-rejected: "ruined the old
UX appearance".

Revert restores `_button-pad.scss` to its pre-iter-4c shape:
`color: rgba(--secUser, 0.25)` dims native text in place; no overlay,
no inner-content hiding. Templates that want a × on disabled buttons
render it explicitly in their own markup (game-kit tooltip `<button
class="btn-equip btn-disabled">×</button>`, my_notes DON/DOFF, etc.).

My Sea DEL btn picks up the case-by-case convention: template renders
`{% if hand_complete %}DEL{% else %}&times;{% endif %}`; the picker's
`_setComplete(on)` JS handler swaps `delBtn.innerHTML` between `DEL`
and `×` in lockstep w. the `.btn-disabled` class toggle so visual +
label always agree post-hand-completion.

FT `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` now
asserts `delbtn.text == "×"` instead of relying on the (now-removed)
pseudo-element comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:56:19 -04:00
Disco DeDisco
4417b8c972 My Sea iter 6c: bud-btn invite stub + #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
Bundled fix for the PAID-DRAW-loops-to-GATE-VIEW bug surfaced 2026-05-20 in
live testing: previously the view reset `created_at = now()` + cleared the
hand, but the row's continued existence meant `quota_spent=True` on the
next render → landing rendered GATE VIEW → user clicked it → back to
gatekeeper → loop.

Now PAID DRAW does `active_draw.delete()` after debiting the token + then
redirects to `/gameboard/my-sea/?phase=picker`. The my_sea view honors
`?phase=picker` (only when no active_draw exists — can't bypass
post-DEL GATE VIEW) by forcing `show_picker=True` so the user lands in
the picker ready to draw. First card draw creates a fresh row w. fresh
`created_at`, starting the new 24h quota cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:47:47 -04:00
Disco DeDisco
1e37fe1475 My Sea iter 6b: navbar GATE VIEW swap on page-my-sea + landing PAID DRAW state + seat-1 server-render + auto-token IT trap in gatekeeper FT — Sprint 5 iter 6b of My Sea roadmap — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Second of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Wires the always-reachable navbar gate-entry, completes the landing center-btn 3-way state machine (FREE DRAW / GATE VIEW / PAID DRAW), and lifts seat-1's `.seated` state from JS-only to server-rendered (reload-stable).

## Navbar GATE VIEW swap

`templates/core/_partials/_navbar.html` — when `'page-my-sea' in page_class`, CONT GAME swaps for `#id_navbar_gate_view_btn` (`.btn-primary`, plain `<button>` w. inline onclick navigation). Reaches the gatekeeper at any quota state — no confirm guard (non-destructive nav).

**Typeface trap caught (user 2026-05-20 visual report)**: first cut used `<a>` for GATE VIEW, which UA-renders serif while `<button>` stays sans-serif (`.btn` doesn't reset `font-family`). Same fix pattern as iter-4c's in-hex GATE VIEW: always use `<button>`. Second cut used a form-wrapped `<button>` w. `display:contents`; the form was correctly invisible in layout but broke the landscape `> #id_cont_game { order: -1 }` direct-child SCSS pin (form became the direct child, not the button). Final cut: plain `<button>` w. `onclick="window.location.href=..."`, no form, no anchor — direct flex child of `.container-fluid` so the SCSS pin matches.

`_base.scss` — paired `> #id_navbar_gate_view_btn` alongside `> #id_cont_game` in both portrait (line 93) + landscape (line 309) rules so GATE VIEW occupies the same top-center navbar slot CONT GAME does (above brand, `order: -1`).

## Landing center-btn 3-way state machine

`my_sea` view gains `deposit_reserved` (active_draw has deposit_token_id) + `hand_non_empty` context vars.

`my_sea.html` landing branches:
- `deposit_reserved` → **PAID DRAW** form (POSTs to `my_sea_paid_draw`); fastest path back to picker w. one click — no gatekeeper round-trip.
- `quota_spent and not deposit_reserved` → **GATE VIEW** (existing iter-4c btn, navigates to gatekeeper).
- else → **FREE DRAW** (existing iter-1 btn).

Three branches are mutually exclusive — FT asserts only one of `#id_my_sea_paid_draw_btn` / `#id_my_sea_gate_view_btn` / `#id_draw_sea_btn` renders at a time.

## Seat-1 server-render

`my_sea.html` table-seat 1 now picks up `.seated` + `.fa-circle-check` (instead of `.fa-ban`) when `hand_non_empty`. Other 5 seats stay banned (placeholders for the future friend-invite feature; only owner ever occupies seat 1 in solo my-sea). Reloads no longer lose the chair-styling state — existing JS animation (FREE DRAW click → flip seat to seated) still fires on first draw.

In practice today the landing only renders when hand IS empty (show_picker hides landing once hand has cards), so the `.seated` branch isn't actually visible in iter 6b. Defensive code for future surfaces (any hex render w. hand non-empty) per [[sprint-my-sea-iter-6-plan]] §Seat-1 persistence.

## FT delta

**Replaced** `MySeaGatekeeperPageTest.test_gatekeeper_renders_six_chair_seats_with_seat1_seated` w. `test_gatekeeper_renders_no_hex_modal_only`. The iter-6a FT skeleton was written before the user's "no hex on gatekeeper" spec (2026-05-20) — seats now live ONLY on the my-sea picker page; the gatekeeper is a transient `.gate-modal` overlay w. no hex / chair-seats.

**Trap caught**: `MySeaGatekeeperPageTest.test_paid_draw_commits_token_and_redirects_to_picker` was passing in iter 6a only because it didn't actually exist in CI then; running it locally exposed the IT-trap pattern: User post_save signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`), so `_select_my_sea_token` picks the auto-COIN (PASS > **COIN** > FREE > TITHE) instead of the manually-seeded FREE. Test asserted FREE count drops by 1 → fails because COIN was actually debited (sets cooldown, doesn't delete the token). Same trap as the iter-6a IT memo; fix is identical: `self.gamer.tokens.all().delete()` after User.create + then seed only the token the test cares about.

## Tests

- 4 MySeaGatekeeperPageTest (iter 6a, now passing) + 1 MySeaLandingPaidDrawTest + 1 MySeaNavbarGateViewTest + 2 MySeaSeatOnePersistenceTest = 8 FTs green in 84s.
- All 7 `test_core_navbar` FTs (NavbarByeTest + NavbarContGameTest) still green — landscape order rule extension is additive; CONT GAME path unchanged.
- 153/153 gameboard ITs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 02:50:54 -04:00
Disco DeDisco
d2c34d44d3 My Sea iter 6a follow-up: gatekeeper layout mirrors room exactly — .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
Per user spec 2026-05-20: my-sea gatekeeper should look exactly like the room gatekeeper, with the PAID DRAW button living in its own `--priUser` square panel beside the token-slot rectangle (mirroring room's PICK ROLES placement). Earlier iter-6a draft had the PAID DRAW button rendered as a standalone btn below the token slot; now it sits in `.gate-roles-panel` next to `.gate-main-panel`. Title panel reads "@<handle>'s Sea" via the existing `at_handle` filter — falls back to email prefix for handle-less users (parity w. navbar identity rendering).

No SCSS changes — all three `.gate-*-panel` rules already exist in `_room.scss` lines 98-135 and apply universally to anything under `.gate-modal`. 153 gameboard ITs still green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 02:31:51 -04:00
Disco DeDisco
3fc5491372 My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + _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
First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub.

## Server

`MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot.

`_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations:
- CARTE excluded outright (door-spell trinket, not valid for my-sea draws).
- COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged.

`debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`:
- CARTE → ValueError (defensive; caller validates upstream).
- COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped.
- PASS: no consumption (auto-admit, unlimited redraws).
- FREE / TITHE: deleted.

`my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not).

`my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit.

`my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects.

`my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle.

## Template + UX

`apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit.

Coin-slot rails (mirrors room's `.token-slot`):
- Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY.
- Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN.
- Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape.

SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules.

## FT skeleton

Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`:

- `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths.
- `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands).
- `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b).
- `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b).
- `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c).

## ITs (iter 6a — 22 new + 153 total green)

- `MySeaGateViewTest` (4) — view branching pre/post deposit.
- `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405.
- `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent.
- `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect.
- `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff.
- `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved.

## Trap caught

Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 02:29:08 -04:00
Disco DeDisco
7b7e80520a My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD
Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running.

## Server

`MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker.
- `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS).
- `is_hand_complete` / `is_hand_empty` props drive view branching + template button states.
- `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop).
- `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn").

`my_sea_lock` is now a true upsert:
- First POST creates the row (quota commit).
- Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists).
- Spread-mismatch (attempted spread switch within quota window) → 409.
- Empty/malformed hand → 400.
- Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions.

`my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent.

`my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX.

`my_sea` view branches:
1. No sig → sign-gate
2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand
3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn
4. No active draw → landing phase w. FREE DRAW btn

## Template + UX

- Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`:
  - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists).
  - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6).
  - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion).
- DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing.
- Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag.
- Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish.

## Other polish bundled

- **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity.
- **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression.
- **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg."
- **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw).

## Test coverage

- ITs (1100 IT/UT green in 57s):
  - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`.
  - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response.
  - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test).
  - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state.
  - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW.
  - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled.
  - `MySeaGateStubViewTest` (new) — 404 stub + login required.
- FTs (35 my_sea FTs green in 5m):
  - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW).
  - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`.
  - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`.
  - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic).
  - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion).
  - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
Disco DeDisco
6f901fd9ce My Sea iter 4b polish v2: drop FYI from locked-draw Brief; dynamic aria-selected per default_spread; defensive cross data-spread sync on init (guards bf-cache drift causing all 6 slots to render post-DEL+reload) — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
(1) FYI removal — the locked-draw Brief is purely informational (status + next-free-draw timestamp). No navigation target made sense for the FYI; drop it after Brief.showBanner renders. The NVM dismiss + dedicated `<time>` slot carry all the affordance the user needs.

(2) Dynamic aria-selected on the SPREAD combobox — previously the SAO option was hardcoded `aria-selected="true"`. When active_draw is non-SAO (e.g. Celtic Cross), server-rendered state was internally inconsistent: hidden value = waite-smith, aria-selected = SAO. JS init's force-sync (which reads aria-selected to override autofill on hidden) then overwrote the correct hidden value w. SAO — corrupting the picker's state silently. Made aria-selected + `.sea-select-current` visible label both branch on `default_spread`.

(3) Defensive cross.data-spread sync on init — after the autofill force-sync settles `hidden.value` from the aria-selected source-of-truth, mirror it onto `.my-sea-cross[data-spread]` + re-run syncLabels. Idempotent when server-rendered state is internally consistent; corrective when a prior page state (Firefox bfcache restoring a Celtic-Cross DOM, mid-draw session restored) left a stale `data-spread` that SCSS-hides the wrong subset of cells. User-reported 2026-05-20: after locking a Celtic Cross + DEL + reload, all 6 slots remained visible on the picker w. SAO labels — exactly the symptom of cross.data-spread="waite-smith" surviving an otherwise-fresh server render.

Tests: 116 gameboard ITs + 5 iter-4b FTs green. The dynamic aria-selected behavior is implicitly covered by the existing default-spread IT (no regression on the SAO=true baseline); the bf-cache scenario is hard to express as a deterministic FT/IT — the defensive sync is a safety net, not a behavioral spec.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:27:50 -04:00
Disco DeDisco
c1a8133345 My Sea iter 4b polish: Brief banner uses standard portaled .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
UX refactor on top of iter 4b (b76d3c5) per user direction:

(1) Brief banner — replaced custom `.my-sea-brief` markup + SCSS w. a call to `Brief.showBanner` from note.js. Now matches the my-notes / my-sign default-deck-warning Briefs exactly: standard `.note-banner` portaled atop the h2 w. Gaussian-glass backdrop-filter blur. Tagged `.my-sea-locked-banner` for FT disambiguation only — no visual override.

(2) Brief timestamp — fix for "Invalid Date" rendering in note.js's `<time class="note-banner__timestamp">` slot. Previously passed `created_at: ''` to `Brief.showBanner` → `new Date('')` returns Invalid Date → `toLocaleDateString` renders "Invalid Date". Now passes the next-free-draw ISO timestamp as `created_at` (server emits via `|date:'c'`). After Brief.showBanner returns, the `_showFreeDrawLockedBrief` JS overwrites the rendered text w. the more detailed `D, M j @ g:i A` format ("Wed, May 20 @ 11:57 PM") — leaves the ISO `datetime=` attribute intact for accessibility. The `line_text` no longer carries the timestamp inline (it's redundant w. the dedicated slot).

(3) DEL guard portal — replaced custom `#id_my_sea_del_portal` fullscreen modal + `.my-sea-del-portal` SCSS w. a call to `window.showGuard` from base.html, targeting the shared `#id_guard_portal`. Same Gaussian-glass tooltip the room gear-menu DEL flow uses: no backdrop, positioned above the anchor button, standard `.btn-confirm OK` + `.btn-cancel NVM` pair. Bundled a non-breaking `options.yesLabel` extension to `show()` in base.html for future destructive flows that need a custom YES label (defaults to 'OK', resets on dismiss/confirm) — my-sea doesn't use it per user direction (the `.btn-confirm` class implies "OK"; destructive intent belongs on the trigger button, which is `.btn-danger DEL`).

Tests: 30 iter-4b ITs (model + lock + delete + saved-draw view branches) + 5 iter-4b FTs all green; IT/FT assertions updated to target the shared portal markup (`#id_guard_portal.active`, `.guard-yes`, `.guard-no`, `.note-banner.my-sea-locked-banner`).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:12:52 -04:00
Disco DeDisco
b76d3c5dff My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD
Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring).

Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait.

Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:32 -04:00
Disco DeDisco
31ed2bda0e Billnote palette swatch: DRY w. .swatch via palette-swatch-bg mixin; fixes --quaUser→--quiUser drift
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Note swatches (right-side thumbnail + modal preview tiles) were inlining their own linear-gradient that ended on --quaUser instead of --quiUser, so the 4th band rendered the wrong palette colour (e.g., Maryland's red showed up on the Baltimorean Note where the My Palettes applet's matching tile shows none). Extracted the canonical gradient into a @mixin palette-swatch-bg in _palette-picker.scss and @include'd it from .swatch, .note-item__palette + .note-swatch-body so all three share one source of truth.

Code architected by Disco DeDisco <discodedisco@outlook.com>

Git commit message Co-Authored-By:

Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:09:01 -04:00
Disco DeDisco
b6e93b9d64 My Sea iter-4a follow-up batch: modal port + draw polish + label positioning + Major Arcana fix — TDD
User-driven bug-squash + UX-polish cycle on top of iter 4a (ca2a62f). All 14 fixes ship behind the same iter-4a banner since they close the substage's UX gaps without expanding scope to iter 4b's persistence layer.

SeaDeal modal port — extracted apps/gameboard/_partials/_sea_stage.html shared by gameroom + my-sea; aliased .my-sea-picker w. id=id_sea_overlay so SeaDeal.init() finds it; FLIP click → SeaDeal.openStage delegation instead of bare _fillSlot. Fixes the user-reported 'thumbnail disappears' bug — slot was landing at opacity 0 (.--filled w.o .--visible) because SeaDeal's _hideStage (which adds --visible on modal dismiss) was never running. 3 new FTs cover the modal flow.

Spread lock + DEL reshuffle — _lockSpread/_unlockSpread toggle .sea-select--locked class on the combobox; first deposit locks, _resetHand unlocks. _reshuffleDeck Fisher-Yates over combined piles + re-rolls 25% reversal axis on DEL so successive DELs don't re-deal the same hand. Verified Claudezilla: 3 DEL cycles produced distinct lay cards (150 → 114 → 155).

Cover/cross empty slots — subtle dotted outline (transparent bg + 0.25 alpha border) w. --duoUser mask reveal on hover/touch. Per the user spec; rule lives in _card-deck.scss (shared between gameroom + my-sea). Plus matching label-opacity (0.25 idle → 0.6 hover) via CSS :hover ancestor propagation.

DOS spec — Solution moved from cover → crown per user correction. DRAW_ORDER ['loom', 'cross', 'crown']; POSITION_LABELS {loom: Desire, cross: Obstacle, crown: Solution}; SCSS hide list flipped from [leave, crown, lay] → [leave, cover, lay]; FT/IT assertions updated.

SAO → DOS soft-reload bug — Firefox autofill on hidden input restored the previous-session DOS value, tripping combobox.js's change-event guard. Fix: autocomplete=off + force-sync hidden.value from server-rendered aria-selected option in init. Captured as feedback_firefox_autofill_hidden_inputs (generalizable trap).

.sea-pos-label outside .sea-card-slot — moved label to be a sibling of the slot in the cell, so SeaDeal innerHTML clobber on draw doesn't erase it. Per-position absolute positioning touching slot borders: crown/cover above (translate -50%, 0.1rem, scaleY 1.2); lay/cross below (translate -50%, -0.1rem, scaleY 1.2); leave left, CCW (writing-mode vertical-rl + rotate 180deg + scaleX 1.2); loom right, CW (writing-mode vertical-rl + scaleX 1.2). scaleX for rotated labels (not scaleY) — perpendicular to text-flow is the visible-width direction after rotation. .my-sea-cross gap bumped to 1.75rem for label clearance.

Escape Velocity label swaps — POSITION_LABELS for escape-velocity: {crown: Crown, leave: Lay, cover: Cover, cross: Cross, loom: Loom, lay: Leave}. Replaces the Waite-Smith Behind/Beneath/Before per user spec.

SPREAD dropdown portal — .my-sea-form-col .sea-form-main { overflow: visible } + .sea-select-list { z-index: 1000 } so the dropdown extends past the form-main scroll area + sits above the picker stacking ints. Gameroom .sea-form-main still scrolls (only my-sea opts out).

Major Arcana polarity-split rendering — added 9 missing _card_dict keys to my-sea's _my_sea_deck_data to match gameroom epic.views.sea_deck's contract: levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word, keywords_upright, keywords_reversed, energies, operations. Without these StageCard.populateCard falls through to plain name_title for trumps 19-21 + cards 48-49. Iter 4b cleanup candidate: extract apps.epic.utils.card_dict() to DRY the now-identical helpers.

Tests deferred — user explicitly belayed FT runs during the bug-fix substage. Iter 4b will re-establish a green sweep before its commit lands.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:02:27 -04:00
Disco DeDisco
ca2a62fd84 My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD
Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b.

**Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock:

- No `room` context; excludes only the **current user's significator** (no other seated gamers).
- Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working.
- Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.)

Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL).

`.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there.

**FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP):
- deck JSON embedded w. two polarity halves, disjoint card ids;
- user significator excluded from both halves;
- first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside;
- second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected;
- 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops;
- DEL resets every filled slot, LOCK HAND re-disables;
- LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks;
- switching to MBS mid-draw wipes the in-progress hand.

**ITs** (6 in new `MySeaDeckDataViewTest`):
- context `sea_deck_data` has `levity` + `gravity` keys, both lists;
- user significator absent from both halves;
- halves are disjoint sets of card ids;
- card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract;
- template embeds via `<script id="id_my_sea_deck" type="application/json">`;
- no-equipped-deck users get the Earthman backup pile (not empty).

Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s.

**Deferred to iter 4b** (server persistence):
- `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at);
- LOCK HAND POST endpoint → commits the hand to the DB;
- 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap;
- Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
Disco DeDisco
f154d660bd My Sea per-spread positions + draw-order JS config + position labels — Sprint 5 iter 3 follow-up — TDD
User-locked spec 2026-05-19: each three-card spread uses a DIFFERENT 3-position subset of the 6 surrounding positions, in its own draw order. Replaces the iter-3 binary `data-spread-shape="three-card|six-card"` model w. per-spread `data-spread="<value>"`. Closes iter 3 cleanly + scaffolds the draw-order data iter 4 will consume.

Position subsets (per spread):
  PPF → leave (1) · cover (2) · loom  (3)
  SAO → lay   (1) · cover (2) · crown (3)
  MBS → crown (1) · lay   (2) · loom  (3)
  DOS → loom  (1) · cross (2) · cover (3)
  Waite-Smith     → all 6 surrounding (cover · cross · crown · lay · loom · leave)
  Escape Velocity → all 6 surrounding (cover · cross · lay · leave · crown · loom)

All 6 cells continue to render in DOM unconditionally — `.my-sea-cross[data-spread="<value>"]` SCSS rules hide inactive positions per spread via `display: none`. Cover/cross live nested inside `.sea-pos-core` so their absolute-overlay positioning rules from `_card-deck.scss:1310-1331` carry over for free.

**Position labels** (re-appropriated `.sea-stack-name` typography per user) — `.sea-pos-label` inside each empty `.sea-card-slot--empty` carries the per-spread caption. Server-renders SAO's labels by default (lay=Situation, cover=Action, crown=Outcome); JS swaps labels via `POSITION_LABELS[spread]` lookup on combobox change. Inactive-for-spread positions render their span w. empty `textContent` so JS only has to set text, never toggle visibility. Celtic Cross variants share the gameroom's existing position vocabulary (Crown/Beneath/Cover/Cross/Before/Behind).

**DRAW_ORDER JS const** baked into the inline picker IIFE — array of position names per spread, ready for iter 4's deck-click-deposit logic to consume. Exposed via `window._mySeaDrawOrder` so iter-4 click handlers can `window._mySeaDrawOrder[currentSpread][nextSlotIdx]` to resolve the target position. No click handlers wired yet — iter 4 territory.

**Selenium trap caught**: the combobox click-twice-on-the-toggle bug — re-clicking the combobox while `aria-expanded='true'` closes the dropdown (combobox.js's toggle behavior). Test 3's spread-cycling iterates through 6 spreads, each needs the dropdown OPEN before clicking a new option; added a `_pick(value)` helper that checks `aria-expanded` first.

Files:
- `templates/apps/gameboard/my_sea.html` — `.my-sea-cross[data-spread]` w. server-rendered default; each empty slot wraps a `<span class="sea-pos-label" data-position="<name>">` (SAO labels seeded inline, others empty initially); inline IIFE adds `DRAW_ORDER` + `POSITION_LABELS` consts + `syncLabels()` that swaps captions on `change`.
- `static_src/scss/_gameboard.scss` — drops the `data-spread-shape="three-card"|"six-card"` rules; adds 4 per-spread visibility rules (PPF/SAO/MBS/DOS). Celtic Cross variants inherit the gameroom's full 3×3 grid w. no overrides. `.sea-pos-label` style mirrors `.sea-stack-name` from _card-deck.scss line 1557 (small-uppercase-letter-spaced-scaleY) sans the polarity color — these aren't deck identifiers, just spread-position captions.
- `apps/gameboard/tests/integrated/test_views.py` — IT `test_cross_carries_initial_three_card_spread_shape` renamed + retargeted to `data-spread="situation-action-outcome"`; new IT `test_template_renders_sao_position_labels_on_default` pins the seeded SAO labels + empty spans for inactive positions.
- `functional_tests/test_game_my_sea.py` — iter-2's `test_picker_hides_six_card_only_positions_by_default` renamed to `test_picker_renders_sao_default_position_subset` w. SAO-specific visibility expectations (lay/cover/crown visible; leave/loom/cross hidden). iter-3's `test_picking_celtic_cross_reveals_six_card_positions` rewritten + expanded to `test_picking_spread_swaps_data_spread_and_position_visibility` — cycles through all 6 spreads, asserts `data-spread` attribute + per-position `is_displayed()` for each. New `test_per_spread_position_labels_render_and_update` cycles through 5 spreads (SAO default + 4 switches) asserting captions match the spec.

Tests: 33/33 FT green across test_bill_my_sign + test_game_my_sea; 1049/1049 IT/UT green in 52s.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:38:53 -04:00
Disco DeDisco
fd5db951a7 My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.

Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.

- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
  - `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
  - `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
  - `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
  - `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.

**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):

- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).

**Tests**:

- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.

Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.

Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
Disco DeDisco
f5fc1e15f8 My Sea picker phase: three-card cross (sig + cover/leave/loom) — Sprint 5 iter 2 of My Sea roadmap — TDD
After the FREE DRAW click on iter 1's landing swaps `data-phase` to `picker`, the picker now renders a stripped Celtic Cross: user's saved significator pinned in `.sea-pos-core`, three drawn-card drop zones around it — cover (overlaid on sig), leave (left of core), loom (right of core). Crown / lay / cross from the gameroom's 6-position spread are deliberately forsaken (user-locked spec).

DRY w. the gameroom sea-overlay: reuses `.sea-cards-col` + `.sea-cross` + `.sea-crucifix-cell` + `.sea-pos-*` + `.sea-card-slot--empty` + `.sea-sig-card` classes & their _card-deck.scss styling (1181-1331). Only divergence from the room: a `.my-sea-cross` modifier in `_gameboard.scss` overrides `grid-template-areas` from the room's `". crown . / leave core loom / . lay ."` 3×3 to a single-row `"leave core loom"` — drops the crown + lay rows since those positions are forsaken. Cover stays nested inside `.sea-pos-core` so the absolute-overlay rules from _card-deck.scss line 1310-1331 carry over for free.

Picker bg = `rgba(var(--duoUser), 1)` on `.my-sea-page[data-phase="picker"]` — parallels `.my-sign-page[data-phase="picker"]` from _card-deck.scss line 704, so the landing→picker swap reads as a continuous surface (hex face → felt) like on /billboard/my-sign/.

The sig card renders w. `data-card-id="{{ significator.id }}"` + `.fan-corner-rank` + `.fa-solid {suit-icon}` (mirrors the gameroom's `.sea-sig-card` minimal markup at `_sea_overlay.html` line 33-39). Full card-face / FYI / SPIN wiring deferred — iter 3 lands the form col + interactive draw flow.

View context: `my_sea` now passes `significator` (FK pass-through) + `significator_reversed` so the template can render the corner rank + suit icon at render time without re-fetching.

- 3 FTs in new `MySeaPickerPhaseTest`: sig card w. `data-card-id` matching `user.significator.id` in `.sea-pos-core`; cover/leave/loom empty drop zones render; crown/lay/cross absent. Shared `_enter_picker_phase()` helper polls for `data-phase='picker'` after the ~800ms seat-1C animation delay.
- 4 ITs in new `MySeaPickerPhaseTemplateTest`: server-render contract for sig in core + cover/leave/loom classes + forsaken-positions-absent + picker entirely absent when user has no sig (4b gate precedence).

Tests: 28/28 FT green across test_bill_my_sign + test_game_my_sea (~219s); 1041/1041 IT/UT green (53s).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:06:14 -04:00
Disco DeDisco
285597b467 My Sea FREE DRAW + seat-1C seated transition — Sprint 5 iter 1 follow-up — TDD
Iter-1 follow-up after the user re-spec'd the FREE DRAW click behavior:

- **Btn label** DRAW SEA → FREE DRAW (the 1/24h free-quota draw). Element ID `id_draw_sea_btn` retained — describes intent, not label, so a future sprint can conditionally swap the label back to DRAW SEA once the daily free has been used (at which point the btn calls the room gatekeeper partial for token-deposit per [[project-my-sea-roadmap]] Sprint 6).
- **Chair seat markup** — each `.table-seat` now renders w. `.fa-chair` + `.seat-position-label` (1C-6C) + `.position-status-icon.fa-solid.fa-ban` mirroring the room's hex grammar from `_table_positions.html`. **`.seat-position-label`** (not `.seat-role-label`) because my-sea is the solo flow — no roles — and the existing room class carries role-grammar semantics that don't apply here. New class gets its own grid placement in `_gameboard.scss` (col 2 / row 1 default, col 1 for left-side seats 3/4/5 per the room's flip rule).
- **FREE DRAW click flow** — (1) seat 1C immediately gains `.seated` class & its `.fa-ban` icon swaps to `.fa-circle-check`; (2) after 800ms (so the user sees the seat animation against `_room.scss`'s 0.6s `color`/`filter` transition on `.fa-chair`), `data-phase` swaps to `picker` & landing hides. Seat 1C-only because my-sea is single-user-per-page until friend-invite lands — the user always occupies the lowest-numeral seat.
- **`.table-seat.seated` SCSS** in `_gameboard.scss` — `--terUser` chair color + `drop-shadow(--ninUser)` glow. Mirrors `_room.scss:626` `.table-seat.active .fa-chair` styling but uses a stable `.seated` class (semantically distinct: `.active` = current turn in a multi-user room, `.seated` = draw-locked occupant in the solo flow). Status icon green via the existing `_room.scss:616` `.position-status-icon.fa-circle-check` rule — no new color rule needed.
- **FT/IT updates** — renamed `test_landing_renders_hex_with_draw_sea_btn` → `…_free_draw_btn` w. "FREE DRAW" label assertions; T2 extended to assert `.position-status-icon.fa-ban` on each seat at render time; T3 (formerly `…_transitions_to_picker_phase`) rewritten as `test_free_draw_click_seats_user_in_1C_then_swaps_phase` to pin the full click contract: 1C goes `.seated` + `.fa-circle-check`, seats 2-6 unchanged, picker phase swap after the delay. New IT `test_landing_renders_position_status_ban_icon_on_each_seat` asserts initial ban + `.seat-position-label` counts.

**Substring trap caught** (worth a sticky note): bare class-name substrings (`fa-ban`, `position-status-icon`) appear ALSO in the inline JS handler's `classList.remove(…)` / `querySelector(…)` arg strings → `html.count("fa-ban") = 7`, not 6. Tightened IT assertions to match the full class attribute (`class="position-status-icon fa-solid fa-ban"`) — never count bare class names in `assertContains` / `html.count` when the same class is JS-manipulated client-side.

Tests: 25/25 FT green across test_bill_my_sign + test_game_my_sea (165s); 1037/1037 IT/UT green (49s).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:07 -04:00
Disco DeDisco
de48ae226d My Sea DRAW SEA landing — Sprint 5 iter 1 of My Sea roadmap — TDD
DRAW SEA landing UX on /gameboard/my-sea/ for users past the [[sprint-my-sea-sign-gate-may19]] gate. DRY table hex (reused from the room shell + my-sign Sprint 4a iter 3) w. 6 chair seats labeled 1C-6C (placeholder for friend-invite per the My Sea roadmap "Six chairs retained even in solo" anchor) + central DRAW SEA `.btn-primary` mirroring SCAN SIGN on /billboard/my-sign/. Click swaps `.my-sea-page[data-phase]` from `landing` to `picker`; the picker UX itself (three-card cross w. cover/leave/loom + form col / spread dropdown / decks / LOCK HAND / DEL) lands in iters 2 + 3.

The 'C' suffix on the chair labels = "Chair" (user-locked); no role semantics (this is a solo draw, not a 6-player role assignment). `.table-seat` CSS class + `data-slot` attribute preserved so the room's existing `[data-slot="N"]` positioning rules (`_room.scss` L583-588) carry over for free — no SCSS fork; just a new `.seat-label` span inside each seat.

The 'Default deck warning' Brief banner from /billboard/my-sign/ fires verbatim when `user.equipped_deck` is None (the user is headed for a draw against the Earthman [Shabby Cardstock] backup unless they equip one first). Tagged `.my-sea-intro-banner` so FTs disambiguate from other Briefs. Same FYI (→ /gameboard/) / NVM (dismiss) action grammar.

Bundled: BACK→NVM label swap (user-edited mid-sprint) in the existing sign-gate. CSS class `.my-sea-sign-gate__back` retained — the swap was label-only — so existing FTs targeting the class still pass; docstrings + comments updated for accuracy.

Files:
- `apps/gameboard/views.py` — `my_sea` view adds 2 context keys: `no_equipped_deck` (bool) + `show_backup_intro_banner` (= user_has_sig AND no_equipped_deck). The sig-gate path still wins precedence.
- `templates/apps/gameboard/my_sea.html` — `.my-sea-page[data-phase="landing"]`; new `.my-sea-landing` block w. room-shell hex + `#id_draw_sea_btn` + 6 `.table-seat[data-slot="N"]` w. `<span class="seat-label">NC</span>`; new `.my-sea-picker` placeholder (`display:none` til DRAW SEA click); inline `<script>` for the click→data-phase swap + scaleTable re-fire on next tick (mirrors my-sign's iter-3 RAF dispatch); copies the my-sign Brief banner script block verbatim w. `.my-sea-intro-banner` post-render tag.
- `static_src/scss/_gameboard.scss` — new `.my-sea-page` + `.my-sea-landing` + `.my-sea-picker` rule blocks mirroring `.my-sign-page` / `.my-sign-landing` from `_card-deck.scss`. `.seat-label` styled in `--terUser` to match the chair iconography.
- `apps/gameboard/tests/integrated/test_views.py` — `+7 ITs` in new `MySeaDrawSeaLandingViewTest` pinning the context keys + presence/absence of `#id_draw_sea_btn` + 6 `data-slot=N` w. `NC` labels + Sprint 4b gate's precedence over the new landing.
- `functional_tests/test_game_my_sea.py` — `+5 FTs` in new `MySeaDrawSeaLandingTest` for the visible UX (hex + DRAW SEA, 6 seats labeled 1C-6C, click→picker phase swap, Brief banner on no-deck, no banner when deck equipped). Uses new `_assign_sig` helper from [[sprint-sig-page-helper-may19c]] for the user-w-sig precondition.

Tests: 25/25 FTs green across test_bill_my_sign + test_game_my_sea in 219s; 1036/1036 IT/UT green in 50s (+7 from baseline).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:15:37 -04:00
Disco DeDisco
4d1c74a2af FT helper: sig_page.py — _seed_earthman_sig_pile + _assign_sig — Sprint 4c of My Sea roadmap — TDD
Pure test-infra refactor. Sprint 5+ (sea-select / DRAW SEA / latest-draw rendering) all need a "user has sig" precondition without walking the picker — extracting it once now prevents Sprint 5's setUps from copy-pasting the inline assignment dance again.

Two helpers in new `src/functional_tests/sig_page.py` (mirrors the `room_page.py` / `post_page.py` / `my_posts_page.py` convention — underscored to signal "test infrastructure, not API surface", public within `functional_tests/`):

- `_seed_earthman_sig_pile()` — re-seeds Earthman DeckVariant + the 16 MIDDLE court cards (Maid/Jack/Queen/King × BRANDS/CROWNS/BLADES/GRAILS) that `personal_sig_cards(user)` returns. Hoisted verbatim from the duplicate definitions in `test_bill_my_sign.py` + `test_game_my_sea.py` introduced in [[sprint_serialized_rollback_ft_fix_may19]]. Major 0/1 are deliberately NOT seeded — `_filter_major_unlocks` in `personal_sig_cards()` strips them for users w.o the matching Note unlocks, which is the default state in solo FTs.
- `_assign_sig(user, card=None, reversed_flag=False)` — sets `user.significator` + `significator_reversed` directly, bypassing the picker UI. Returns the assigned card so downstream assertions can use it. `card=None` defaults to `personal_sig_cards(user)[0]` (the same target the picker happy-path FT uses).

Call sites updated:
- `test_bill_my_sign.py` — drops the local `_seed_earthman_sig_pile` definition (32 lines); imports from `sig_page`. `MySignClearTest.setUp` now uses `_assign_sig(self.gamer)` instead of the 4-line manual sig-assignment block.
- `test_game_my_sea.py` — drops the local `_seed_earthman_sig_pile` definition (22 lines); imports from `sig_page`. `MySeaSignGateTest`'s two "user w. sig" tests (#4 + #6) swap their 2-line `self.gamer.significator = ... ; .save(...)` blocks for `_assign_sig(self.gamer, self.target_card)`.

Diff stat: +69 lines (new helper module), -59 lines (duplicate code removed). Net +10 LOC but the duplication trap is closed — single source of truth for sig-state FT setup.

20/20 FT green across both files in 174s post-refactor.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:22:49 -04:00
Disco DeDisco
c8a603484e My Sign DEL btn: clear-sign affordance on SCAN SIGN landing — Sprint 4b-adjacent of My Sea roadmap — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Pre-spec'd in [[sprint-my-sea-sign-gate-may19]] as the unblocker for tomorrow's visual verification of 4b's no-sig branch — admin user (@disco) had a saved sig from Sprint 4a testing & there was no in-UI affordance to undo it short of DB surgery. Lands ahead of the deferred 4b visual verify so dev users can toggle between sig/no-sig states on Claudezilla.

- Endpoint: `path("my-sign/clear", views.clear_sign, name="clear_sign")` — POST sets `User.significator = None` + `significator_reversed = False`, redirects to picker; GET is a no-mutation redirect to picker (mirrors save_sign's GET handling). `login_required(login_url="/")`. No trailing slash per [[feedback_url_convention_actions_no_trailing_slash]] (action endpoint, not page).
- Template (my_sign.html): `<form id="id_clear_sign_form" class="my-sign-clear-form">` w. `<button id="id_clear_sign_btn" class="btn btn-danger">DEL</button>`, rendered ONLY when `current_significator` is set; sits inside `.my-sign-landing` as a sibling of `.room-shell` so it's bound to the landing-phase UI alone (picker phase already has its own NVM unlock affordance on focused thumbnails).
- SCSS: anchored bottom-right of `.my-sign-landing` via `position: absolute; bottom: .75rem; right: 1rem` — `.my-sign-landing` gains `position: relative` to scope the absolute. `.btn-danger` carries the destructive treatment; "DEL" mirrors post.html gear menu's DEL convention from [[sprint-post-polish-may13]].
- 3 FTs in new `MySignClearTest` class — covers: btn renders on landing when sig saved (T1, asserts text "DEL" + `.btn-danger` class); btn absent when no sig (T2); click POSTs, reloads, & wipes `User.significator` + `significator_reversed` in DB (T3).
- 6 ITs in new `ClearSignViewTest` + `MySignClearAffordanceTemplateTest` — covers: login_required gate, POST wipes both fields w. redirect-back, GET redirects w.o mutation, POST-w/o-existing-sig is idempotent no-op, template renders btn only when sig set, template's form action targets `clear_sign` reverse.
- 1029 IT/UT green in 47s (+6 from baseline); 20/20 FT green across test_bill_my_sign + test_game_my_sea in 165s.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:13:20 -04:00
Disco DeDisco
a636e940b7 fix CI FT regression: My Sign + My Sea setUpClass ContentType collision — pipeline #313
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Sprint 4a-cont (400762c) + 4b (cd0add1) introduced `serialized_rollback = True` on `MySignPickerTest`, `MySignBackupDeckTest`, and `MySeaSignGateTest` to keep migration-seeded `DeckVariant` + `TarotCard` rows alive across TransactionTestCase flushes. Locally each file ran clean, but in the full pipeline #313 `test-FTs-non-room` stage all three classes errored in setUpClass:

  django.db.utils.IntegrityError: UNIQUE constraint failed:
  django_content_type.app_label, django_content_type.model

Mechanism: every prior `TransactionTestCase`-derived class in the bucket flushed without `inhibit_post_migrate`, so Django's post-migrate signal recreated `django_content_type` rows. When these three classes hit `_fixture_setup` & tried to deserialize the saved DB snapshot, the inserts collided on `(app_label, model)`. No serialized rollback ⇒ no collision; mixing in the same DB run is the trap.

Fix: drop `serialized_rollback = True` from all 3 classes & inline-reseed via `get_or_create` per the canonical pattern already used by [test_admin_tarot.py](src/functional_tests/test_admin_tarot.py) & [room_page.py](src/functional_tests/room_page.py) (see [[feedback_transactiontestcase_flush]]) — new `_seed_earthman_sig_pile()` module-level helper in each file restores `DeckVariant(slug='earthman')` + the 16 MIDDLE court cards (Maid/Jack/Queen/King × BRANDS/CROWNS/BLADES/GRAILS) that `personal_sig_cards(user)` returns.

Adjacent bug uncovered once the snapshot reload was gone: [test_game_my_sea.py](src/functional_tests/test_game_my_sea.py) `_seed_gameboard_applets` had been seeding `my-palette` w. `context='gameboard'` — but `my-palette` is not a gameboard applet (it's a dashboard slug; the real palette applet is `palette` per migration 0003). The applets template iterates every applet & includes `apps/<context>/_partials/_applet-<slug>.html`, so seeding the bogus row made /gameboard/ try to load a partial that doesn't exist → TemplateDoesNotExist 500 → `#id_applet_my_sea` never rendered. `serialized_rollback = True` had been masking this because the snapshot restored the migration-correct applet rows (which never had my-palette as a gameboard entry to begin with). Swapped `my-palette` for the real `new-game` gameboard applet & corrected grid-cols/rows on `game-kit` to match the 0003 seed (4×3, not 4×6).

Tests: full sweep across all three classes runs green locally (17/17 in 142s).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:05:39 -04:00
Disco DeDisco
76e1bfc9ad My Sea applet: split sign-gate vs empty-state ITs after Sprint 4b layered the gate ahead of the empty placeholder
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- test_my_sea_applet_renders_empty_state_for_new_user was written before
  Sprint 4b (cd0add1) added the applet-side sign-gate; a fresh user now
  hits the gate branch in _applet-my-sea.html, never reaching .my-sea-empty.
- Renamed to test_my_sea_applet_renders_sign_gate_for_user_without_sig
  + added test_my_sea_applet_renders_empty_state_for_user_with_sig_no_draws
  which sets user.significator (via personal_sig_cards) before re-fetching
  /gameboard/. Both branches now pinned.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 01:57:41 -04:00
Disco DeDisco
cd0add1e3c My Sea sign-gate — Sprint 4b of My Sea roadmap — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
/gameboard/my-sea/ standalone page + /gameboard/ My Sea applet gated behind User.significator. When no sig is saved, render a Look!-formatted Brief-style line — "Look!—pick your sign before drawing the Sea." — w. BACK (.btn-cancel → /gameboard/) + FYI (.btn-info → /billboard/my-sign/) action buttons in `--terUser` ink ; gate is inline content (not portaled like .note-banner) — it IS the page content until a sig is picked, not a transient nudge ; applet partial mirrors the gate via `{% if not request.user.significator_id %}` w. a `.my-sea-sign-gate--applet` denser variant (just FYI, no BACK — user's already on the gameboard); .my-sea-sign-gate__line shrinks from 1.1rem → 0.85rem + padding from 1.5rem → 0.5rem ; my_sea view passes `user_has_sig = request.user.significator_id is not None` so the standalone template branches at server side (avoids a request.user template-context-processor dependency in the standalone page) ; .woodpecker/main.yaml routes test_game_my_sea.py to the test-FTs-non-room stage by default (FT doesn't yet touch the table hex — Sprint 5+ will bring the hex into my_sea via the same DRY .room-shell stack as my_sign, at which point the file gets moved to test-FTs-room) ; TDD trail — 6 FTs in test_game_my_sea.py covering standalone gate copy + FYI/BACK href targets (T1-T3), with-sig skips gate + renders draw shell (T4), applet mirrors gate w. FYI (T5), applet w. sig falls back to .my-sea-empty (T6); all written red against the un-implemented gate before view/template/SCSS landed ; KNOWN: visual verification on existing admin user (@disco) blocked by lack of a clear-sign affordance (he has a sig saved from Sprint 4a testing); adjacent feature spec'd in [[sprint_my_sea_sign_gate_may19]] memory — likely lands as a CLEAR btn in the picker's saved-sig stage state, deferred to next session

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 01:38:55 -04:00
Disco DeDisco
5b06d902a8 My Sign picker iteration 3 — SCAN SIGN landing hex + OK/NVM thumbnail two-step + duoUser picker bg — Sprint 4a-cont — TDD
Two-phase picker. Landing phase renders the DRY 1-chair table hex w. a central SCAN SIGN .btn-primary; clicking it swaps the page to picker phase (hex hides, sig-card grid + always-present stage frame + SAVE SIGN visible). Stage frame previews the saved sig on landing if User.significator is set ; sig-card selection lifts the room's two-step OK/NVM-on-thumbnail pattern via `.sig-card-actions` w. `.sig-ok-btn`/`.sig-nvm-btn`: click thumb → `.sig-focused` (CSS reveals OK badge, stage previews card, no lock); click OK → `.sig-reserved--own` (CSS swaps OK→NVM badge, `.sig-stage--frozen` reveals stat block + FLIP, SAVE SIGN enables); click NVM → unlock + clear focus + disable SAVE SIGN ; SAVE SIGN form pinned `position:absolute; bottom:0.75rem; right:1rem` to .my-sign-stage so it stops shifting across the stage row when the stat block reveals on lock (was getting shoved left as a flex item alongside the stat-block reveal) ; .my-sign-page mirrors .room-page's `flex:1; min-height:0; display:flex; flex-direction:column` so the DRY hex container chain propagates real height down into #id_game_table for room.js's scaleTable() to compute against (was reading 0 + leaving the hex unscaled at 200×231 in a 360×320 scene) ; stage min-height gated to picker phase (`.my-sign-page[data-phase="picker"] .my-sign-stage`) — landing-phase stage is natural-sized so the hex centers in the bigger available area instead of being bottom-anchored by a 376px stage reservation ; picker-phase bg uses `rgba(var(--duoUser), 1)` so the transition from "hex face" → "card pile on felt" reads as a continuous surface rather than a context swap ; room sig-select media queries re-scoped to `.sig-overlay .sig-deck-grid` so they don't bleed into my-sign — my-sign gets its own breakpoint cascade: 6×3rem (portrait) → 9×3rem (≥900px landscape) → 18×3rem (≥1600px) → 18×5rem (≥2200px); thresholds bumped from sig-select's 1400/1800px so 18×col + sidebar/footer margins clear the viewport at fluid-rem ceiling (rem=22 → 18×3rem=1188px + 220px margins=1408, safe with 1600px floor) ; default `repeat(6, 1fr)` collapsed to 0-width when paired w. `align-self:center` (no parent width for `fr` to resolve against, hence the dotted-line miniscule cards in portrait); fixed `repeat(6, 3rem)` at portrait default fixes it ; SCAN SIGN font-size 0.75rem (vs .btn-primary's default 0.875rem) so the 2-line "SCAN/SIGN" label fits inside the 4rem circle without crowding the border — treated as a smaller variant via `#id_scan_sign_btn` rule scoped under .my-sign-landing ; room.js's scaleTable() runs on DOMContentLoaded before flex layout flushes (#id_game_table.clientWidth/Height read 0 at that moment) — added `requestAnimationFrame → dispatchEvent('resize')` tick at the end of the inline IIFE so scaleTable re-fires once layout settles ; tests — 6 FTs in test_bill_my_sign.py rewritten for the new flow: test_landing_renders_dry_hex_with_scan_sign_button pins the 1-chair hex + central SCAN SIGN + hidden picker grid; test_scan_sign_click_transitions_to_picker_phase pins the phase swap (hex hides, grid shows); test_click_thumbnail_shows_OK_btn_without_locking pins step 1 (focus + OK appears, no lock yet); test_OK_click_locks_thumbnail_and_enables_save_sign pins step 2 (lock + NVM appears + SAVE SIGN enables + persists to /billboard/ applet); test_NVM_click_deselects_and_disables_save_sign pins NVM unlock cycle; test_landing_previews_saved_sig_on_stage pins the on-load saved-sig preview behavior — all green visually verified across portrait + landscape

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 01:11:41 -04:00
Disco DeDisco
ab5b4c95dd My Sign picker: hover-preview, click-lock, NVM-unlock + polarity SCSS port + bigger stage — Sprint 4a-cont iteration 2
User-driven polish on iteration 1: separate hover-preview from click-lock semantics (room sig-select pattern), add NVM to unlock, port the room's `.sig-overlay[data-polarity]` polarity-themed CSS to also target `.my-sign-page[data-polarity]`, bump the stage card width so it occupies a bigger slice of the viewport ; **state machine** (inline JS in my_sign.html): three discrete states — (a) idle: stage frame visible but empty (stage card hidden via display:none, stat block hidden via `.sig-stage--frozen` absence, FLIP btn hidden via CSS); (b) hover: hovered .sig-card populates the stage card (preview); mouseleave clears it; mouseover-mouseout sequence guards against transient gaps when moving between adjacent thumbnails (relatedTarget closest('.sig-card') check); (c) locked: click on any grid card freezes the stage — populates content, adds `.sig-stage--frozen` to .sig-stage (which surfaces .sig-stat-block + .my-sign-flip-btn via CSS), enables SAVE SIGN, reveals NVM. Subsequent hovers ignored while locked. NVM click reverts to idle (clears content, hides stat-block + FLIP, disables SAVE, hides NVM) ; **new template** elements: NVM `<button id="id_nvm_sign_btn" class="btn btn-cancel">` next to SAVE SIGN in the form, hidden by default (style="display:none") + revealed on lock. Stage card re-acquires `style="display:none"` (hidden on load, JS-shown on hover/lock). `sig-stage--frozen` class no longer initial — JS-added on click ; **polarity SCSS port** (_card-deck.scss L820-905): extended `.sig-overlay[data-polarity="levity"]` + `[data-polarity="gravity"]` selector lists to include `.my-sign-page[data-polarity="levity"]` + `[gravity"]`. Rules inside (e.g. `.sig-card { background: rgba(--secUser) }`, `.sig-stage-card .fan-card-name { color: --quiUser }`, stat-face-label colour flips, text-shadow polarity variants) automatically apply on the my-sign page since `data-polarity` lives on the page wrapper (descendants .sig-card + .sig-stage-card both inherit). Moved `data-polarity` from `.my-sign-stage` to `.my-sign-page` in the template + JS so descendant scoping works (was a stage-scoped attr in iteration 1, which couldn't reach the sibling .sig-deck-grid) ; **bigger stage** (_card-deck.scss): `.my-sign-page { --sig-card-w: clamp(140px, 36vw, 220px); }` — scales w. viewport, 140px floor for portrait, 220px ceiling for landscape, ~36vw in between. Stage card + stat block both width-driven by this var so they scale together. The clamp() ceiling matches the room sig-select's typical sized card on a mid-laptop ; **FLIP btn visibility** (_card-deck.scss): `.my-sign-flip-btn { display: none }` at rest; `.my-sign-stage.sig-stage--frozen .my-sign-flip-btn { display: inline-flex }` on lock. The btn's position (absolute, bottom-left of card) was already added in iteration 1 ; **on-load lock-restore**: if `User.significator` is set, the picker auto-locks that card via `_lock(savedCardEl)` so the user sees their persisted choice in the locked-state UI (stat block + FLIP visible) instead of an idle empty frame. Polarity initial value (data-polarity on .my-sign-page) reflects `current_significator_reversed` — False=gravity (default), True=levity ; **regression**: 7 FTs in test_bill_my_sign green in 57s. Visual verify deferred to user — picker should now show: idle empty stage + grid below; hover thumbnail → stage card preview; click → preview persists + stat block + FLIP appear; NVM → back to idle; FLIP click → horizontal-perspective Y-axis rotation w. polarity content swap mid-animation. Polarity-themed colour styles (levity inverted palette / gravity stark contrast / per-polarity text-shadows) now apply on my-sign matching the room sig-select look

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:15:13 -04:00
Disco DeDisco
559bdc2de7 My Sign picker: stage visible on load, FYI panel, SPIN/FLIP split w. perspective-flip animation — Sprint 4a-cont
User-driven polish on the Sprint 4a picker so it's usable parity-w-room-sig-select (per the Schizo-screenshot reference). My initial pass collapsed SPIN + FLIP into one button — user clarified the correct architecture: **SPIN** stays in the `.sig-stat-block` (room pattern, btn-reverse, toggles orientation 180° + reveals reversal_qualifier), while **FLIP** lives at the bottom-left of the stage card as a `.btn-reveal` (game-kit fan carousel pattern, toggles polarity gravity↔levity w. a horizontal-perspective Y-axis rotation animation). Gravity is the default upright polarity per user — significator_reversed=False → gravity, True → levity ; **template changes** (my_sign.html): (a) `.sig-stage-card` no longer carries inline `display:none` — stage frame visible on page load, before any card click; (b) `.sig-stage` carries `.sig-stage--frozen` modifier from the start so the stat-block shows alongside the stage card (room CSS gates `.sig-stat-block { display: block }` behind this class); (c) stat-block btn relabeled "FLIP" → "SPIN" + restored to btn-reverse / orientation-toggle semantics; (d) new `<button class="btn btn-reveal my-sign-flip-btn">FLIP</button>` outside the stat-block at .sig-stage scope, positioned absolute via new SCSS (bottom-left of stage card, mirroring game_kit.html's #id_fan_flip placement); (e) FYI btn + `_sig_fyi_panel.html` partial included alongside SPIN in stat-block — pinned w. id_my_sign_fyi_panel; (f) all 18 card data-* attrs filled (data-levity-qualifier / data-gravity-qualifier / data-levity-emanation / data-gravity-emanation / data-levity-reversal / data-gravity-reversal / data-energies / data-operations / data-italic-word / data-correspondence) so StageCard.populateCard has everything it needs to render qualifiers + reversal-face text per polarity; (g) data-polarity on .my-sign-stage drives populator polarity arg + (future) polarity-themed styling, initialised from `current_significator_reversed` (False=gravity, True=levity) ; **JS changes** (inline script in my_sign.html, includes apps/epic/stage-card.js): (a) on card click → StageCard.fromDataset → populateCard(stageCard, card, _polarity()) + populateKeywords on stat-block + buildInfoData/renderFyi on FYI panel + sig-focused class on grid cell; (b) FYI btn click toggles `.fyi-open` on stat-block (room pattern — CSS reveals the .sig-info panel + PRV/NXT); (c) PRV/NXT cycle thru _fyiData; (d) SPIN click toggles `.stage-card--reversed` + `.is-reversed` on stat-block (orientation, preview-only — not persisted); (e) FLIP click runs `_flipPolarityAnimated()` — 500ms Y-axis rotateY(90deg) midpoint animation lifted from game-kit.js's `_flipActive`, swaps polarity at offset 0.5 so the new face shows through the 2nd half-rotation, preserves SPIN orientation by including ' rotate(180deg)' in both keyframes when stage-card--reversed is on, in-flight `dataset.flipping` flag prevents re-triggering mid-animation; (f) on-load: if user has a saved sig (`.my-sign-page[data-current-card-id]`), find that grid card + auto-select it so stage shows the persisted choice ; **SCSS** (_card-deck.scss): new `.my-sign-flip-btn` rule positioning the btn absolute z-index:25 bottom:0.4rem left:calc(1.5rem + 0.4rem) — accounts for .sig-stage's padding-left:1.5rem so the btn lands at the visual bottom-left of the stage card; .btn-reveal styling (magenta/cyan) inherited from existing _button-pad.scss; no animation SCSS (the 500ms rotateY is in JS via element.animate()) ; **deferred**: `.sig-overlay[data-polarity="levity"]` / `[data-polarity="gravity"]` themed color overrides at _card-deck.scss:805-885 are scoped to `.sig-overlay` and won't apply to `.my-sign-stage[data-polarity]` until those selectors are extended (or duplicated under a .my-sign-stage sibling). User flagged the visual delta but the picker is functionally complete w.o the polarity-themed colors — followup sub-sprint ; **regression**: 7 FTs in test_bill_my_sign green in 57s; no IT/UT changes needed (only template + SCSS). User-pre-staged rootvars.scss tweak picked up

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:58:39 -04:00
Disco DeDisco
39767c72c2 fix CARTE multi-seat Role-Select bug on navigate-away + back; My Sign applet rename
**CARTE bug** (user-reported on iPhone): a CARTE gamer who contributed their deck to multiple gate slots could fill ≥1 role for ≥1 seat, navigate away (BYE → dashboard, CONT GAME → return, etc.), come back to the room — and the JS guard on .card-stack would wrongly fire "Equip card deck before Role select" + block further role picks, even though the deck was demonstrably in play on existing seats. Symmetric for the "stay in room during Role Select" variant the user thought we'd squashed before (the prior fix was 759ce8d for the multi-slot SELECT path, but the room VIEW context never got the same treatment) ; **root cause**: `select_role()` at epic/views.py:619-621 clears `user.equipped_deck` after the first role pick ("deck committed to room"). The room view's role-select context at epic/views.py:286 then passes `equipped_deck_id = user.equipped_deck_id` to the template — which is now None — and the template renders `data-equipped-deck=""` → JS guard at role-select.js:165 sees the empty string and fires the "no deck" warning. The deck IS in play; the context just isn't recognizing seat-level deck assignment as a deck source ; **fix** (epic/views.py:286ish): when `user.equipped_deck_id` is None, fall back to the deck_variant of any of the user's seats in this room (order_by slot_number for determinism). The guard now sees a non-empty id and the fan opens. Storage-side unchanged — seat.deck_variant remains the canonical "this deck is in play on this seat" signal, and the user's deck-third contribution per role (PC=levity brands+crowns / NC=levity trumps / SC=levity grails+blades / AC=gravity grails+blades / EC=gravity trumps / BC=gravity brands+crowns) flows from existing `select_role` logic that inherits deck_variant from the first seat ; **TDD trail** — 2 new ITs in `SelectRoleMultiSeatTest` (apps.epic.tests.integrated.test_views): T1 pins the context (`response.context["equipped_deck_id"]` equals the existing seat's deck_variant_id after `user.equipped_deck` clears); T2 pins the template (rendered `data-equipped-deck="<id>"` not `""`). Initial reds — `None != 2` + `data-equipped-deck=""` substring assertion. Fix lands both green ; **bundled: My Sign applet rename** — user clarified naming convention 2026-05-18: **applets** use the "My X" prefix (My Sign, My Sea, My Posts), **standalone pages** use the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit page). Sprint 4a's initial migration set the applet name to "Game Sign" — corrected after the user saw the gear-menu toggle list reading the wrong word. Applet template header link "Game Sign" → "My Sign" (user-edited); migration 0010 added to update the Applet row's `name` in already-migrated DBs (dev + staging); applets/0009 frontmatter + defaults updated to "My Sign" in case of a fresh migrate-from-zero; test seed helpers in billboard test_views.py + functional_tests/test_bill_my_sign.py updated to "My Sign". Slug stays `my-sign` (URL + selectors stable) ; **bundled: rootvars.scss** — user-modified mid-session (pre-staged) ; 1022 IT/UT green in 46s — no regressions; 4 ITs in SelectRoleMultiSeatTest green (2 pre-existing CARTE multi-seat ITs + 2 new return-trip context ITs)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:18:32 -04:00
Disco DeDisco
66b2947e8c My Sign: Brief banner + Earthman [Shabby Cardstock] backup deck when no equipped — TDD
User-reported gap on /billboard/my-sign/ — admin user's only deck was in-use as `TableSeat.deck_variant` in another room (Wonderbeard) → `equipped_deck` cleared → previous my-sign template showed "Equip a card deck first…" w. no actionable next step. User scoped fix: don't force equip, just nudge via a Brief banner "Look!—no deck is equipped. Navigate to the Game Kit to equip one (FYI) or (NVM) proceed with the Earthman [Shabby Cardstock] deck.", title "Default deck warning". NVM dismisses + picker proceeds against an Earthman card pile labeled in-copy as the temporary backup; FYI links to /gameboard/ (Game Kit equip). User-modified line text in template: "Paperboard" → "Cardstock" mid-session ; **helper fallback** (epic/models.py): `personal_sig_cards(user)` now falls back to `DeckVariant.objects.filter(slug='earthman').first()` when `user.equipped_deck` is None — same 16-or-18 card pile, just sourced from the canonical Earthman deck rather than the empty FK. No new DeckVariant row needed; "Shabby Cardstock" is purely UX framing (cards are the same TarotCard records the room sig-select uses). Preserves the existing helper signature so no callers had to change ; **view + template** (billboard/views.py + my_sign.html): view passes `no_equipped_deck` + `show_backup_intro_banner` flags. Template removes the old `{% if not equipped_deck %}` forced-equip branch — picker now renders unconditionally w. cards from the backup helper when no deck is equipped. Brief banner fires via `Brief.showBanner({...})` on DOMContentLoaded when `show_backup_intro_banner` is true — gets h2-overlay positioning + NVM behavior + portal styling for free (per [[sprint-baltimorean-note-unlock-may18]] portrait h2 measurement in note.js's `_alignToH2`). Added `<script src="note.js">` to my_sign.html since the page didn't load it before. Post-render JS tags the Brief w. a `.my-sign-intro-banner` class so FTs (and any future my-sign-specific styling) can distinguish this nudge from other Briefs on the page ; **TDD trail** — 4 new FTs in `MySignBackupDeckTest` (test_bill_my_sign.py): T1 banner renders w. "Default deck warning" title + "no deck is equipped" + "Shabby Cardstock" copy + both action btns visible; T2 picker still populates 16 cards from backup; T3 NVM click removes the banner from the DOM; T4 FYI href ends w. /gameboard/. Initial reds (`NoSuchElementException` on all 4) confirmed before implementation. Plus 1 new IT in `PersonalSigCardsTest` pinning the helper fallback (16 cards w. all `c.deck_variant.slug == "earthman"`) ; pre-existing change picked up: `static_src/scss/rootvars.scss` (user-modified mid-session) ; 1020 IT/UT green; 7 FTs green (3 picker happy-path + 4 backup deck) in 56s. Sprint 4a-follow complete — primary deferral from Sprint 4a (deck-source fallback UX) now landed. Unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/ when no sig set)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:49:49 -04:00
Disco DeDisco
400762c0e5 Game Sign picker @ /billboard/my-sign/ + billboard applet — Sprint 4a of My Sea roadmap — TDD
User scope (per design conv this session): split the room's sig-select responsibility off into a standalone billboard-context "My Significator" applet — branded "Game Sign" on the surface. Same 18-card pile as room sig-select (16 middle arcana + Major 0 & 1 filtered by Note unlocks); polarity collapses to a single FLIP choice (the FLIP btn in the picker carousel toggles User.significator_reversed). Selection persists globally on the User model + propagates to the billboard's Game Sign applet ; **naming convention locked**: "significator" stays at storage (User.significator FK + User.significator_reversed) + room sig-select context (DRY w. existing template/JS); "Sign" / "Game Sign" is the billboard-surface branding (file my_sign.html, URL /billboard/my-sign/, URL names my_sign + save_sign, applet name "Game Sign", page wordmark "Game Sign", btn label SAVE SIGN). Action URLs don't carry a trailing slash per project convention (/billboard/my-sign/save vs the page's /billboard/my-sign/) ; **schema**: User gains 2 fields — `significator: FK → epic.TarotCard (nullable, on_delete=SET_NULL)` + `significator_reversed: BooleanField(default=False)`. Migration lyric/0006_user_significator_user_significator_reversed.py auto-generated; reversible. Applet seed in applets/0009_seed_my_sig_applet.py adds the row (slug='my-sign', name='Game Sign', context='billboard', default_visible=True, grid_cols=4, grid_rows=6), idempotent update_or_create, reversible unseed() ; **picker page** (my_sign.html): solo lift of `_sig_select_overlay.html` — sig-stage-card scaffold + sig-stat-block + 18-card grid + SAVE SIGN form. Stripped: countdown / WebSocket / polarity / multi-user / reservations. Empty-state branch covers no-equipped-deck (link back to Game Kit; full Brief-redirect + Earthman-Backup fallback deferred to a follow-up sub-sprint). Minimal inline JS: click .sig-card → mark .sig-focused + set hidden card_id + enable SAVE SIGN; FLIP btn toggles .is-reversed + the hidden reversed input. Stage-card preview (name/qualifier population + keyword swap on FLIP) deferred — Sprint 4a follow-up will lift stage-card.js's populator into a non-room context ; **applet partial** (_applet-my-sign.html): renders user.significator's corner-rank + suit-icon + name_title if set; `.my-sign-applet-empty` "No sign chosen yet." otherwise. Header `<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>` links to the picker ; **helper refactor** (epic/models.py): extracted `_sig_unique_cards_for_deck(deck_variant)` from `_sig_unique_cards(room)`. New public `personal_sig_cards(user)` parallels `levity_sig_cards / gravity_sig_cards` but pulls from `user.equipped_deck` instead of `room.deck_variant`. Same Note-unlock filtering. No behavior change to existing room callers (3-line wrapper preserves the room signature) ; **TDD trail** — user called out mid-sprint that I'd skipped FTs; pivoted to FT-first. test_bill_my_sign.py (new, 3 FTs): T1 picker renders w. wordmark + target card present in grid; T2 click card → SAVE SIGN enables → POST persists → applet shows the card; T3 fresh user → applet renders empty-state. Initial reds — (a) setUp's `personal_sig_cards(user)` returned [] because StaticLiveServerTestCase → TransactionTestCase flushes migration-seeded DeckVariant + TarotCard between tests; fixed w. `serialized_rollback = True` on the test class (per [[feedback_transactiontestcase_flush]]); (b) h2 wordmark assertion against `MYSIGNIFICATOR` failed against the renamed "Game Sign" + the letter-splitter spreading chars across <span> children — switched to whitespace-stripped substring check `GAMESIGN`; (c) `.fan-corner-rank` text is CSS-hidden so Selenium returns "" — replaced corner-rank assertions w. data-card-id selectors (already-proven reliable from the parent .sig-card lookup) ; ITs (+12, in apps.billboard.tests.integrated.test_views): MySignViewTest (6 — login redirect, 200 + template, 16-card pile, save persists, invalid card_id → 403, GET save redirects); BillboardAppletMySignTest (3 — applet rendered, empty-state w/o sig, card+reversed class w. sig). PersonalSigCardsTest in apps.epic.tests.integrated.test_models (3 — happy path 16 cards, no-equipped-deck → [], schizo Note unlocks Major 1) ; pre-existing change picked up by the commit: my_sea.html branding "Game Sea" (user-modified mid-session; was "My Sea" in Sprint 3 — divergence captured in MEMORY.md follow-up) ; 1020 IT/UT green (+12) in 46s; 3 FTs green in 24s. Sprint 4a unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/) + Sprint 4c (FT helper for mocking the sig choice across other FTs)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:23:24 -04:00
Disco DeDisco
5e71b1d5da Baltimorean: post-attribution titles now read "Baltimorean" not "Ard!" — TDD
User caught a missed surface on iPhone after the May-18b rename pass (1ccb045): the post.html `.post-attribution` spans still rendered "@disco the Ard!" instead of "@disco the Baltimorean" — six callsites across the post header (author / invitee / shared-self / created-by) plus `_my_buds_applet_item.html`'s bud row body. Same shape on display: navbar DON greeting is the only surface that should keep "Ayo, Ard!", per the May-18b architectural decision ; root cause: `User.active_title_display` at lyric/models.py:152 returned `self.active_title.display_title` ("Ard!" for Baltimorean) instead of `self.active_title.display_name` ("Baltimorean"). The Sprint-18b rename pass swapped the inline `attr_combo` + Brief.title to use `display_name`, but missed this property which is the indirection layer for `.post-attribution` callsites. Navbar uses `{{ user.active_title.display_title }}` directly (no helper-property indirection) so it stays at "Ard!" — that's the intended single Ard! surface ; fix: one-line swap in `active_title_display` from `display_title` to `display_name`. For stargazer / schizo / nomad these two are equal (the Note model's `display_name` property at drama/models.py:262 falls through to `display_title` unless the slug has an override in `_NOTE_DISPLAY[slug]["display_name"]`) — Baltimorean is the only current override w. `{"display_name": "Baltimorean"}`. So this is no-op for every non-Baltimorean Note ; TDD trail: +3 UTs in apps.lyric.tests.integrated.test_models.UserModelTest: `test_active_title_display_returns_earthman_when_no_note_donned` (smoke), `test_active_title_display_uses_display_name_not_display_title` (pins the Baltimorean override path — went red 'Ard! != Baltimorean' before the fix), `test_active_title_display_falls_through_to_display_title_for_non_overridden_slugs` (pins the no-op path for stargazer). Red → green confirmed. Surfaces auto-affected: post.html post-attribution × 5 callsites + `_my_buds_applet_item.html` bud row body (all use `{{ user.active_title_display }}`) ; 1008 IT/UT green in 46s (+3 from 1005)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:42:58 -04:00
Disco DeDisco
bf44628536 My Sea applet shell — Sprint 3 of the My Sea roadmap
User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
Disco DeDisco
df9cf1eee8 CI: route trinket FTs to test-FTs-room stage alongside game_room_*
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
User ask: ensure trinket FTs (test_trinket_carte_blanche.py, test_trinket_coin_on_a_string.py, test_trinket_backstage_pass.py) run in the room-stage CI bucket so they exercise the same room-template surface as test_game_room_* — relevant when a sprint touches the table hex SCSS / chair geometry / gatekeeper flow, since trinket FTs create rooms + walk thru the gate. Previously they fell into test-FTs-non-room by elimination (the non-room glob was `grep -v 'test_game_room_'`) ; main.yaml updates: non-room step's grep is now `-vE 'test_(game_room|trinket)_'` to exclude both clusters; room step's ls now globs BOTH `test_game_room_*.py` AND `test_trinket_*.py` ; companion memory tweak in feedback_ft_naming_prefix.md documents the new convention so future trinket FTs end up in the right bucket without re-asking ; pipeline behavior unchanged otherwise — both steps still depend_on test-two-browser-FTs only, parallel re-enable still blocked by the shared-sqlite issue from 2026-05-12 (cf. memo note)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:41:37 -04:00
Disco DeDisco
5e5bc5a6af COIN: unequip on deposit (parity w. CARTE) ; fix FT false-positive masking the bug
User reported on iPhone: after depositing a COIN at a game's gatekeeper, the Kit Bag's Trinket slot still shows the COIN — even though the tooltip correctly carries the room attribution ("Ready 2026-05-25 / Billingsworth"). Expected behavior matches CARTE: the deposited token disappears from the Kit Bag Trinket slot because it's committed elsewhere & can't be re-used as the active trinket until released. PASS preserved — auto-admits w.o ever going thru the deposit path so it stays equipped ; **the real bug**: `debit_token` in epic/models.py's COIN branch set `current_room` + `next_ready_at` but never cleared `user.equipped_trinket`. CARTE's `drop_token` view (epic/views.py:440-442) explicitly unequips at deposit time via `user.equipped_trinket = None; user.save(update_fields=["equipped_trinket"])`; COIN had no parity. Fix: same 4-line unequip stanza now lives inside the COIN branch of `debit_token`, guarded by `if user.equipped_trinket_id == token.pk` so a fresh-purchased COIN deposit (not the equipped one) doesn't accidentally clear another trinket. PASS untouched — falls thru `debit_token` w.o entering any branch & never reaches this path; CARTE untouched too (its branch is `pass`, unequip happens at `drop_token` time before debit_token is even called) ; **the FT false-positive**: yesterday's Sprint 2 commit (d2491c5) shipped `test_coin_deposit_unequips_from_kit_bag_and_fills_one_slot` w. selector `#id_kit_bag_dialog .kit-bag-placeholder`. That selector was matching the **Dice** section's placeholder (Dice feature isn't built — `_kit_bag_panel.html` L23-29 renders `.kit-bag-placeholder` unconditionally), masking the bug whether or not the Trinket section was empty. Tightened to `.kit-bag-section--trinket .kit-bag-placeholder` w. comment explaining why a bare selector is unsafe ; template change in `_kit_bag_panel.html` L31: Trinket section gains a `kit-bag-section--trinket` modifier class so the FT (and any future selector that needs to single out the trinket section vs the deck/dice/tokens siblings) has an anchor. Mirrors the existing `kit-bag-section--tokens` class at L70 ; TDD trail: (1) tightened selector + reran → red on `NoSuchElement` (no `.kit-bag-section--trinket .kit-bag-placeholder` because COIN still equipped post-deposit, so trinket section renders the token card not the placeholder); (2) added unequip stanza to debit_token; (3) reran → green. 10 trinket FTs in 99s; 999 IT/UT in 46s — no regressions ; **generalizable trap**: when an FT waits for an element via a CSS selector, scope the selector to the section/container that uniquely identifies the assertion target — a class like `.kit-bag-placeholder` that's reused across multiple sections will silently pass even when the section you care about is in the wrong state. This is the second false-positive trap in two days (cf. d2491c5's wrong-selector trap where `.token-slot.claimed` was Carte-specific); pattern's worth noting

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:35:08 -04:00
Disco DeDisco
d2491c5e1b COIN: Carte treatment in Game Kit applet — data-current-room-name + deposited-state btn-disabled — TDD ; PASS skeleton FT
User-driven roadmap step (Sprint 2 of My Sea cluster): give the COIN trinket parity w. CARTE's deposit-aware Game Kit surface — when COIN is deposited in a room (Token.current_room FK set via debit_token in epic/models.py:135), its Game Kit token should expose the room name on `data-current-room-name` so the mini-portal can render "In-Use: <room name>" on hover, and both DON / DOFF btns should go btn-disabled (token is committed elsewhere, neither equip nor un-equip is valid). Mirrors the 3-state branch CARTE has in the same template (deposited / equipped / unequipped); PASS not in scope — auto-admits w.o deposit ; template change in _applet-game-kit.html — line 40 (COIN's div) gains `data-current-room-name="{{ coin.current_room.name|default:'' }}"` + an extra `{% if coin.current_room %}…{% elif coin.pk == equipped_trinket_id %}…{% else %}…{% endif %}` branch that fronts the existing 2-way w. a deposited-state arm (both btns "×" btn-disabled). View-side wiring already in place — coin context var is the user's COIN token incl. its `current_room` FK; no Python change needed ; TDD trail — test_trinket_coin_on_a_string.py (new, 3 FTs): T1 hover equipped COIN → mini portal "Equipped" + main portal tooltip prose (Coin-on-a-String / Admit 1 Entry / "…and another after that…" / no expiry); T2 deposit flow — rails-click → slot 1 RESERVED → click `.btn-confirm` inside the reserved gate-slot (NOT `.drop-token-btn` which is Carte's carte_active path) → slot fills + COIN admits only 1 entry (slot 2 has no follow-up btn cf. Carte's 6) + kit-bag Trinkets section empty (COIN unequipped on deposit); T3 navigate back to /gameboard/ → COIN's `#id_kit_coin_on_a_string` has `data-current-room-name="Commitment Room"` + both DON & DOFF btns inside `.tt` are btn-disabled ; initial red run hit a Carte-specific selector trap — `.token-slot.claimed` (the Carte machine UI from `user_filled_slot or carte_active` branch in _gatekeeper.html L23) doesn't fire for COIN, which lands on `.token-slot.pending` (user_reserved_slot branch); diagnosed via screendump grep — slot 1 carried class "gate-slot reserved" + token-slot was "pending"; FT rewritten to wait for `.gate-slot[data-slot='1'].reserved` → click `.btn-confirm` (the OK btn rendered for the reserving user in _table_positions.html L7-15) → wait for `.filled`. T1+T2 then green; T3 stayed red on `data-current-room-name` AttributeError (None != "Commitment Room") which is the actual bug the template fix addresses ; test_trinket_backstage_pass.py (new, 4 skeleton FTs): T1 staff-user signal contract — `gamer.equipped_trinket_id == pass_token.pk` post-signal; T2 tooltip renders title/description/shoptalk/expiry (Backstage Pass / Admit All Entry / "'Entry fee'? …" / no expiry); T3 equipped PASS mini portal says "Equipped"; T4 PASS btn apparatus — DON × btn-disabled, DOFF active w. label "DOFF" (symmetric to COIN's equipped state cf. test_gameboard.py:207-220). DEPOSIT FLOW DEFERRED to future sprint w. TODO comment block — PASS magically auto-admits any gate w.o going through the `.token-rails` deposit path that CARTE / COIN share, so no `data-current-room-name` parity work applies; user explicitly chose "Auto-admits, never deposited — keep current behavior" for this sprint ; 10 trinket FTs green in 93s (carte 4 + coin 3 + pass 3 — wait, pass has 4: 4); full IT/UT 999 green in 46s — no regressions; coin context already passing `coin.current_room` correctly thru _game_kit_context (no Python change). Sprint 2 of [[project_my_sea_applet]] cluster — next: My Sea applet shell (Sprint 3+)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:25:46 -04:00
Disco DeDisco
79706e817a iOS focus-zoom prevention — input font-size floor @ 16px ; JS fallback strengthened
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Belt-and-suspenders for the iOS Safari auto-zoom-on-input quirk: Mobile Safari zooms the viewport when an `<input>`/`<textarea>`/`<select>` is focused & its computed font-size < 16px, and never zooms back out on blur. Two layers ; PRIMARY — SCSS prevention: new `input, textarea, select, [contenteditable] { font-size: unquote("max(16px, 1em)") }` in core.scss (Sass can't reconcile px/em units in compile-time max() so unquote() passes the CSS max() through verbatim — modern browsers handle natively). 1em inherits parent, max() floors at 16. ALSO floored `.form-control-lg` in _base.scss — was `font-size: 1.125rem`, which at rem=14 (small portrait, clamp(14px, 2.4vmin, 22px) hits its floor) computes to 15.75px → **0.25px** under iOS's 16px threshold → the "ever so slightly" zoom on New Game + New Post applets the user reported (both use `.form-control.form-control-lg`, specificity 0,2,0 beats my element-level 0,0,1 rule). Floor: `unquote("max(16px, 1.125rem)")` ; SECONDARY — JS fallback in base.html: rewritten from `setAttribute('content', ...)` toggle to full meta-element remove+re-add, which modern iOS handles more reliably than attribute mutations on the existing meta. Triggers on document-level `focusout` (bubbles natively, no capture-phase needed) for `input/textarea/select`; injects fresh viewport meta w. `maximum-scale=1.0, user-scalable=no` for 300ms (iOS reads as zoom violation → snaps to 1:1), then swaps back to the cached base content so pinch-zoom remains available elsewhere ; user observed horizontal scrollbar appearing when the page zoomed — that's the symptom the user actually cared about (broken layout, not aesthetic zoom). w. SCSS floor in place the zoom shouldn't trigger to begin with; the JS is purely for inputs that slip through (future custom controls, shadow DOM, etc.) ; iOS-specific behavior — Selenium+Firefox doesn't replicate the auto-zoom so no FT layer added. Verified by user manual iPhone test (post-fix retest pending after force-refresh)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:32:45 -04:00
Disco DeDisco
8066ac289f iOS viewport zoom-reset on form-field exit — global IIFE in base.html
iOS Safari auto-zooms when a user taps an `<input>`/`<textarea>`/`<select>` whose font-size is < 16px, and does NOT auto-zoom back out on blur — the page stays zoomed even after the field loses focus. New ~10-line IIFE at base.html slots next to the existing h2-letter-splitter at the bottom of <body>: caches the page's `<meta name="viewport">` content, listens (document-level, bubbling `focusout`) for inputs leaving focus, then briefly appends `, maximum-scale=1.0` before reverting 100ms later — iOS reads the tightened constraint as a "zoom violation" and snaps the viewport back to 1:1, after which the revert frees the user to pinch-zoom manually anywhere else on the page ; chose `focusout` over `blur`+capture-phase since focusout bubbles natively (cleaner); skips if `.matches` isn't available (defensive for older browsers); skips silently if no viewport meta is present (defensive) ; no test layer — iOS-specific behavior that's awkward to FT (would need a real iOS Safari runner; Selenium+Firefox doesn't replicate the auto-zoom). Verified no conflict w. other focusout listeners (grep: only vendor JS — d3 / htmx / jquery / select2 — none of which listen at document scope on inputs/textareas/selects). Side-track addition between Sprint 1 (table hex layout fbe6c12... well, 7165974) and Sprint 2 (My Sea applet kickoff) per user ask

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:22:08 -04:00
Disco DeDisco
7165974905 table hex layout: fill aperture + enlarge hex from 160×185 → 200×231 ; chair clearance preserved
User report: hex felt smaller than the aperture even at portrait (off-centered, room to spare on top + bottom), and chair labels overlapped the hex edges at landscape — progressively worse as the hex grew at larger viewports. Three contributors stacked: (1) `.room-shell { max-height: 80vh }` capped the shell at 80% of viewport height even when the .room-page aperture had more room — at 1789×1031 this donated 228px (1053→825) of aperture height to dead margin; (2) scene design 360×300 was wider than tall (1.2 aspect) but landscape aperture is narrower than tall (~1.4), so the height cap bottlenecked scene-scale at min(aperture_w/360, aperture_h/300) instead of letting the hex grow; (3) chair font-size scales w. rem (clamp(14,2.4vmin,22)) but chair position scales w. --table-scale — at large viewports rem maxes at 22 so labels widen and push chair icons further from box-center toward the hex (visual "creep") ; fix: remove the 80vh cap (`max-height: 80vh` → `height: 100%` on .room-shell L340) so the shell stretches to fill the .room-page aperture; bump hex from 160×231 to 200×231 (regular pointy-top w. width = height × √3/2 = 200 * 1.1547 — comment in _room.scss updated); apothem of 200-wide pointy-top regular hex is 100px exact (200/√3 × √3/2), so `$pos-d` 110px → 140px gives 40px design-units of radial chair clearance (was 30); derived `$pos-d-x: round(140*0.5) = 70`, `$pos-d-y: round(140*0.866) = 121` for slot 2/3/5/6 diagonal anchors at 60° from horizontal (matches existing geometry approach); scene design height 300 → 320 to leave enough vertical headroom at large landscape that the rem-driven (font-size 1.6rem × scale) chair icons + labels don't clip the aperture top/bottom edges — at 1789×1111 w. scene_H=300 the AC/BC label tops sat AT aperture top (y=-21 vs aperture y=-22), bumping to 320 drops scale from 4.05 → 3.54 and leaves 76px of headroom; SCENE_H in room.js bumped to 320 to match (Math.min(w/SCENE_W, h/SCENE_H) sets --table-scale CSS var via transform: scale on .room-table-scene) ; visual verification via Claudezilla across three viewports (no test layer per user preference — layout regression coverage via spot-check on next room render) — iPhone-14 portrait 566×875: hex 243×281 → 314×363 (+29% wider, fills 55% of aperture width vs 44% before); mid landscape 1149×781: hex 333×385 → 493×569 (+48% wider, 56% vs 38% before); large landscape 1789×1111: hex 440×509 → 708×818 (+61% wider, 48% vs 30% before — the most dramatic improvement, matching user's "progressively worse the larger the hex grows" observation). Chair clearance now uniform 40 design-units radially across all scales; AC/BC labels stay 76px inside aperture top at the largest viewport ; dead `$seat-r`/`$seat-r-x`/`$seat-r-y` consts at L357-359 left in place (unused elsewhere in codebase but out of scope for this layout fix) ; full IT/UT 999 green in 46s — no regressions; .table-hex / .table-hex-border / .room-table-scene / .table-seat positioning consts are the only refs to these dimensions across SCSS & JS so no cascade beyond room layout. Unblocks Sprint 2+ (My Sea applet will share the same hex CSS, parameterized, per user's intent for future friend-invite up-to-6-person rooms)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:19:11 -04:00
Disco DeDisco
fbe6c12ded fix CAST SKY click opening tray instead of Sky Select — TDD
CAST SKY btn click handler in sig-select.js init() was bound to `Tray.open()` — wrong on two counts: (1) the tray was already played during the `polarity_room_done` → `Tray.placeSig` sequence (sig stage card slides into the tray cell before the overlay dismisses), so re-opening it on CAST SKY click pops the tray a second time; (2) Sky Select never opens — `_sky_overlay.html` is only `{% include %}`d server-side when `room.table_status == "SKY_SELECT"`, so during SIG_SELECT the partial + its `openSky` handler aren't in the DOM and `Tray.open()` is the only thing the click does. Bug surfaced symmetrically in both polarity rooms regardless of which finished first ; fix: replace `Tray.open()` w. `window.location.reload()` so the server re-renders the room w. table_status=SKY_SELECT — which surfaces the sky overlay partial + the `openSky` handler bound at _sky_overlay.html:192-193. Same pattern as `_onSkyConfirmed` in the sky partial (location.reload after sky save) ; testability hook mirrors `RoleSelect.setReload` (role-select.js:236): `var _reload = function () { window.location.reload(); };` at module scope, listener calls `_reload()` (closure looks up the var at click time so reassignment works), `setReload(fn)` exposed on the module's test API. SigSelectSpec.js adds `describe("CAST SKY click (post pick_sky_available)")` w. 2 specs — reload spy hit on click + `Tray.open` spy NOT hit on click; the negative assertion catches the original bug, the positive verifies the fix's intent. Existing 363 specs untouched ; Jasmine FT green in 8.6s; full IT/UT 999 green in 44s ; collectstatic mirror at src/static/apps/epic/sig-select.js refreshed in same commit so the served JS carries the fix (Django serves from STATIC_ROOT, not from app static dirs, in StaticLiveServerTestCase)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:21:32 -04:00
Disco DeDisco
1ccb045889 Baltimorean "Ard!" → "Baltimorean" rename across inline surfaces (banner / scroll line / my-notes Title row); navbar DON greeting keeps "Ayo, Ard!" as the sole Ard! flair — and a companion PronounsAppletFlowTest sync-point fix for the 200/Brief response path introduced by the Baltimorean unlock loop in 435a192 ; FT fix (functional_tests/test_game_kit.py) — PronounsAppletFlowTest.test_pronoun_flip_propagates_to_billscroll_and_most_recent was written pre-Baltimorean when set_pronouns always returned 204 → reload; the Baltimorean loop split the response into 200-w-brief (first-bawlmorese, no reload — Brief banner would be lost) vs 204-reload (other pronouns), so the test's step-4 wait_for(.gk-pronoun-card.active[data-pronoun='bawlmorese']) hung indefinitely on the Brief banner because the active class only updates after the reload that no longer happens; sync point swapped to wait_for(.note-banner) — the Brief banner's appearance is the natural post-commit signal that the server saved the pronoun (after which step-5/6 navigate to billboard + scroll to verify "yos" prose) ; rename core (drama/models.py) — Note.grant_if_new else branch's attr_combo inline attribution now uses note.display_name instead of note.display_title, so the scroll line reads "Look!—new Note unlocked. Baltimorean recognizes @disco the Baltimorean." instead of "…the Ard!." (for stargazer/schizo/nomad display_name == display_title so the line is unchanged; only baltimorean's two values diverge — display_title="Ard!" for navbar flair, display_name="Baltimorean" for everywhere else); Brief.title field also swapped from display_title to display_name so the banner title slot reads "Baltimorean" not "Ard!" — admin-grant branch (_ADMIN_NOTE_SLUGS: super-schizo, super-nomad) still uses display_title for the "honorary title of {X}" phrase because that branch DOES want the don-able title there ("Schizoid Man" / "Stranger") ; my-notes card "Title:" row rename — added card_title key to _NOTE_DISPLAY["baltimorean"] ({"greeting": "Ayo,", "title": "Ard!", "card_title": "Baltimorean"}) + new Note.card_title property that falls through _NOTE_DISPLAY[slug]["card_title"] then defaults to display_title; billboard/views.py _my_notes_context swaps "recognition_title": n.display_titlen.card_title so the my-notes Baltimorean card shows "Title: Baltimorean" instead of "Title: Ard!" — super-schizo's card still reads "Title: Schizoid Man" because card_title falls back to display_title for slugs w.o an override ; ONLY Ard! surface remaining: navbar DON greeting via User.active_title.display_title (lyric/models.py:158) — display_title itself was deliberately untouched so the navbar still flips Welcome, EarthmanAyo, Ard! after DON, which is the entire point of the Baltimorean flair ; existing DB rows w. stored "the Ard!" Line.text + Brief.title="Ard!" from prior dev-DB grants will NOT update — those columns are persisted at grant_if_new time, not computed live; user has accepted dev-DB wipe-and-re-acquire as the path forward, so no data migration ; tests — drama/tests/unit/test_models.py +2 UTs (test_baltimorean_card_title_is_baltimorean pins the override, test_stargazer_card_title_falls_back_to_display_title pins the fallback for non-overridden slugs); drama/tests/integrated/test_note_brief.py test_first_grant_creates_post_line_and_brief updated assertion brief.title == note.display_name (was display_title) to reflect the new contract; dashboard/tests/integrated/test_views.py test_brief_payload_carries_baltimorean_title expects "Baltimorean" not "Ard!"; functional_tests/test_bill_baltimorean.py T1 banner title check swapped "Ard!" → "Baltimorean" + module docstring line 10 updated — T4 (navbar DON greeting flip to "Ayo, Ard!") preserved verbatim, the one surface where Ard! still lives ; full FT suite for baltimorean 6/6 green in 53s; drama + dashboard + billboard ITs/UTs 290 green in 16.5s — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:54:30 -04:00
Disco DeDisco
435a192349 Baltimorean Note unlock loop — full UX from bawlmorese pronoun pick → Brief banner → DON → palette modal → dashboard swatch ; rootvars.scss adds the Baltimorean (Blt) hue family (red 200,16,46 / yellow 255,212,0 / white 255,255,255 / black 0,0,0 / purple 26,25,95 / orange 221,73,38 — Maryland-flag-derived plus a --sixBlt: 162,170,173 neutral) + two .palette-baltimore / .palette-maryland palette classes wiring those hues into the standard --priUser--decUser slots; companion section-header rename "/* X Palette */" → "/* X Hues */" across rootvars to disambiguate raw hue families (Precious Metal / Cosmic Metal / Chroma / Earthman / Technoman / Inferno) from actual palette classes — section-comment-only, no rule-level change ; baltimorean entry added in 3 registries that drive the loop: _NOTE_DISPLAY (drama/models.py) — {"greeting": "Ayo,", "title": "Ard!"} so DON flips navbar Welcome, EarthmanAyo, Ard!; _NOTE_TITLES (dashboard/views.py, user-pre-staged) — drives the "recognized via Baltimorean" copy on dashboard palette swatches; _NOTE_META (billboard/views.py) — Baltimorean title + the literal description "Aaron earned an iron urn." + palette_options [palette-baltimore, palette-maryland] feeding the my-notes swatch modal ; set_pronouns view rewired (dashboard/views.py) — first-time pronouns = bawlmorese selection calls Note.grant_if_new(user, "baltimorean") + returns {"brief": brief.to_banner_dict()} JSON @ 200; idempotent on repeat (the grant_if_new returns brief=None on second call so the 204 path resumes naturally); non-bawlmorese choices stay on the original 204 contract ; client wiring: game-kit.js pronouns commit() handles the 200 JSON path — resp.json().then(data => Brief.showBanner(data.brief)) instead of reload (reload would lose the just-fired banner); 204 still reloads to update active pronoun card; game_kit.html pulls in apps/dashboard/note.js so Brief is in scope on the Game Kit page (it wasn't before) ; Brief banner placement fix — note.js showBanner() now measures the .row .col-lg-6 h2 at render-time + sets inline top so the banner portals SQUARELY OVER the page h2 letter-spread wordmark instead of parking at the SCSS-default top: 0.5rem (which had it lurking above the wordmark area on every page); portrait-only (gated if window.innerWidth > window.innerHeight return) — landscape h2 lives in a writing-mode: vertical-rl fixed sidebar column + would need a full banner reorientation (writing-mode + flex-direction restyle of banner contents) to "overlay" sensibly, deferred to a follow-up sprint ; tests: drama/tests/unit/test_models.py (new file) — 5 UTs for _NOTE_DISPLAY[baltimorean] greeting/title/name + stargazer smoke tests; dashboard/tests/integrated/test_views.py — SetPronounsBawlmoreseUnlockTest (9 ITs covering first-bawlmorese-returns-200-w-brief / Note granted / title Ard! / square_url to /billboard/my-notes/ / idempotent on repeat / non-bawlmorese unaffected / bawlmorese-after-other still grants); existing SetPronounsViewTest.test_post_each_valid_choice docstring updated to flag the bawlmorese 200 branch ; functional_tests/test_bill_baltimorean.py (new file) — 6 FTs walking the full UX: T1 Game-Kit pronouns click → Brief banner w. Ard! title + Look! prose + ?-square + FYI nav; T2 idempotent repeat-click (no re-fire); T3 my-notes Baltimorean item carries the Aaron quote verbatim; T4 DON flips navbar greeting Welcome, EarthmanAyo, Ard!; T5 palette modal offers Baltimore + Maryland swatches (and not Bardo/Sheol); T6 Baltimore swatch click previews → OK commits → dashboard Palette applet shows the swatch unlocked w. data-description carrying Baltimorean + non-empty data-unlocked-date + Note.palette = palette-baltimore in DB — all 6 green in 51s; full IT/UT sweep 997 → green in 45s — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 02:17:07 -04:00
Disco DeDisco
bc77296dd4 +52 IT/UT to close IT/UT-only coverage gaps (93% → 96%) — full suite 983 tests in 47s ; UTs in epic/tests/unit/test_models.py — TarotCardEmanationForTest (4) covers emanation_for(polarity) w. levity/gravity overrides + fallback to name_title for cards w.o a polarity split (cards 48-49 are the only polarity-split cards in the deck so this method is sparsely exercised by ITs); TarotCardReversalForTest (4) covers reversal_for(polarity) w. polarity-split + reversal_qualifier fallback + further fallthrough to emanation_for; TarotCardNameSplitTest (4) covers name_group/name_title colon-split parsing (prefix-w-colon / suffix / no-colon edge); TarotCardCautionsJsonTest (2) covers the cautions_json JSON serialiser ; UTs in epic/tests/unit/test_utils.py — PlanetHouseFallbackTest +1 happy-path test (degree=15 lands in house 1 w. sequential cusps) for the normal cusp-match branch alongside the existing pathological fallback test; TopCapacitorsTest (6) covers all top_capacitors() branches — empty dict / None / all-zero counts (the L56 max(counts.values()) <= 0 fallback that was uncovered) / single-winner / tie-clockwise-order / enriched dict {"count":N} input shape ; ITs in epic/tests/integrated/test_models.py — TarotDeckDrawTest extended w. 5 tests for remaining_count (happy + no-deck-variant fallback to 0) + draw() happy-path (returns n tuples of (TarotCard, bool) / appends to drawn_card_ids / never repeats cards across consecutive draws); existing ValueError + shuffle tests preserved ; ITs in epic/tests/integrated/test_views.py — SigEventRetractionTest (4 tests) covers the three data["retracted"] = True paths that the FT test_game_room_select_sig.py walks transitively but no IT pins directly: sig_unready retracts prior SIG_READY (L937), sig_ready retracts prior SIG_UNREADY (L907), sig_reserve action=release while ready retracts prior SIG_READY + records fresh SIG_UNREADY (L823); SigReserveInvalidCardIdTest (1) covers TarotCard.DoesNotExist → 400 (L840-841) ; SigSelectGravityContextTest (3) covers the user_polarity = 'gravity' branch (L322) + the gravity_sig_cards lookup (L357) — all existing SIG_SELECT context tests use the founder-as-PC-levity setup so these branches sat uncovered; logs in as gamers[5] (BC role) + asserts user_polarity + sig_cards match gravity_sig_cards() output ; SeaDeckViewTest (7) mirrors the test_game_room_select_sea.py FT but isolates the JSON contract — covers 403 when unseated, empty halves when seat has no deck_variant (L1255-1256 early-out), two-halves shape, ~even split, card_dict keys (id/name/arcana/corner_rank/suit_icon/name_group/name_title/reversed/qualifiers), reversed field is bool, claimed-significator exclusion via room.table_seats.exclude(significator__isnull=True) ; ITs in dashboard/tests/integrated/test_views.py — ProfileViewTest +2 (reserved-handle "adman" rejection — L116-117: username stays unchanged + redirect to /); KitBagViewTest (3) covers the kit_bag view's panel render w. TITHE-sort branch (L169-175) + login guard ; ITs in dashboard/tests/integrated/test_sky_views.py — SkyViewTest +2 (saved birth datetime renders in user's sky_birth_tz via astimezone L300-306 — 16:00 UTC → 12:00 EDT; invalid-tz string triggers ZoneInfoNotFoundError → swallowed pass → UTC fallback at 16:00) ; ITs in gameboard/tests/integrated/test_views.py — EquipTrinketViewTest +2 (POST equips trinket + returns 204 — L83-85; non-owner POST returns 404 via get_object_or_404); UnequipTrinketViewTest +2 (POST clears matching equipped_trinket — L107-110; POST of non-matching token is a 204 no-op, the implicit else branch) ; .coveragerc omit gains */reset_staging_db.py per user — mgmt cmd was the only 0%-stmt module that wasn't exercised by tests at all + we agreed it's deliberately untested staging-side code ; palette-monochrome-dark rebalance in rootvars.scss — --quiUser/--sixUser/--sepUser remapped to (secAg / quaAg / priPt) instead of (quaAg / terAg / secAg), shifting the secondary/subtle/deep-subtle anchors up the silver gradient so the palette reads more cleanly under the new sig-stage card colours from 3242873 ; uncovered remnants from earlier analysis intentionally left in place — consumers.py at 68% (channels-tag tests excluded; would need --tag=channels run), Carte Blanche slot navigation + sky_dice + tarot_deck preview view paths (the "bigger investments" tier from session triage; FT-covered + the IT setup is heavier than the immediate value), defensive except fallbacks that need contrived inputs to fire, and a handful of __str__s/pass branches not worth a test apiece — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 01:07:13 -04:00
Disco DeDisco
3242873625 btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default .sig-stage .sig-stage-card .fan-card-face .sig-qualifier-* rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each .fan-card-reversal-* class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-<p> skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two <p>s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
Disco DeDisco
ace8612099 tray apparatus scales w. fluid rem; sig-select 9×2 middling breakpoint — $handle-exposed was 48px fixed while #id_tray_btn is 3rem, so on big-rem viewports (clamp(14px, 2.4vmin, 22px) → up to 22px on tall screens, btn=66) the btn's flex parent (#id_tray_handle) shrank the btn from 66×66 → 48×66 via default flex-shrink:1 in portrait (elongated tall ellipse), and in landscape the btn overflowed the 48px-tall handle vertically (extending 9px past viewport top in closed state); fix: $handle-exposed: 3rem matches the btn so it fills the exposed area at every rem; $handle-rect-h: 4.5rem (was 72px) gives the visible rail thickness a touch of breathing room around the btn at every scale; landscape rules in the same partial that hard-coded 48px / 72px (#id_tray_handle { height: 48px }, #id_tray_grip { bottom: calc(48px/2 - 0.125rem); width: 72px }) now reference the variables so they track in sync — tray.js _computeBounds() swapped from _btn.offsetWidth/Height_handle.offsetWidth/Height for the same reason: even with the SCSS fix, measuring the btn would re-introduce the offset when btn and handle drift (which they shouldn't now, but the handle is the layout-defining element so measure it directly); id_kit_btn added as fallback for id_gear_btn (which no longer renders on the room page) so the open-state landscape wrap height anchors to the bottom-right kit btn instead of the full viewport — id_tray_handle cached on the module via _handle ref alongside _btn and cleared in reset() ; sig-select grid jumped straight from 6 cols (narrow landscape) → 18 cols × 3rem at min-width: 900px, but 18×3rem + 7rem modal margins needs ~1376px to clear at rem=22 so the cards spilled off the sides on common 1280-wide laptops + the previous-era 9×2 middling layout had simply been dropped; new cascade in _card-deck.scss mirrors the comment's documented intent: 6 cols default landscape (row layout, stage beside grid) → 9 cols × 3rem at min-width: 900px (column layout, stage above grid) → 18 cols × 3rem at min-width: 1400px → 18 × 5rem at min-width: 1800px (unchanged) — verified in Claudezilla across iphone-14 portrait (rem=14, btn=42 square, handle right edge at viewport right), 816×826 portrait near-landscape (rem=19.6, btn=58.75 square no longer elongated), 1149×751 landscape mid (rem=18, btn=54 square at viewport top, 9×2 grid), 1789×1111 desktop XL (rem=22, btn=66 square at viewport top, 18×1 grid)
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:21:02 -04:00
Disco DeDisco
f9cd08a510 CI: FT stages run in parallel, --parallel dropped intra-stage — bud-btn click sites bypass scroll-into-view ; .woodpecker/main.yaml restructures the FT split — test-FTs-non-room + test-FTs-room now both depends_on: test-two-browser-FTs (instead of room serially depending on non-room) so they fan out + run concurrently, each w. its own DATABASE_URL: sqlite:////tmp/test_db_{non_room,room}.sqlite3 outside the shared workspace mount so the two stages can't see each other's SQLite file + the #296 EOFError-on-half-created-test-db blocker is gone; --parallel flag dropped from both manage.py test invocations because the empirical wall-clock from #302-304 (151 tests in ~42 min, 83 tests in ~16 min — ~16-17s/test avg either way) shows ~1-1.5x speedup at best — Firefox spawn cost (~3-5s cold-start × 38 tests/worker) + RAM pressure (4 headless Firefoxes ≈ 1.5-2GB working set on a quadcore DO droplet) + SQLite file-lock contention eat most of the gain the cores would otherwise give, while the contention amplifies every transient-DOM flake we've spent the last 2 days chasing (login-race in #300/303 → 054b0aa + ad0041d, gecko-perms in #302/303 → ad0041d, ElementNotInteractable in #304 → this commit, Jasmine-timeout in #303ad0041d); stage-level parallelism gets back the wall-clock reduction (~16min savings vs serial stages) w.o. amplifying within-stage contention — net wall clock should be ~60-65min for both FT stages running concurrently vs ~58min today, but flake exposure drops dramatically; downstream screendumps + build-and-push already list both FT steps in their depends_on so they naturally gate on the slower of the two ; test_core_bud_btn.py wraps 4 unwrapped find_element(...).click() sites in wait_for(execute_script("arguments[0].click()", btn)) — the _open_panel_and_invite helper feeding 6 GatekeeperBudBtnAsyncInviteTest tests + the standalone GatekeeperBudBtnDuplicateInviteErrorTest test_duplicate_invite_shows_error_brief_and_fyi_flashes_slot click + both .note-banner--duplicate .note-banner__fyi clicks (one on the share-flow at line 433, one on the gatekeeper-invite-flow at line 622) — pipeline #304 errored on the OK button click w. ElementNotInteractableException: Element <button id="id_bud_ok"> could not be scrolled into view because the post-send_keys autocomplete dropdown on _bud_invite_panel.html briefly overlapped the OK button under CI contention so Firefox refused the scroll-into-view; same pattern + same fix shape as confirm_guard in base.py — execute_script bypasses Selenium's scroll-into-view gate entirely + wait_for absorbs any leftover transient state via WebDriverException retry ; commit only ships the test-side fix + CI restructure; if the CI changes work as intended (green pipeline #305) the docker-rebuild option for python-tdd-ci:latest still stands as the next ~5min/pipeline win, separately filed in project_ci_remove_pip_install_deferred.md — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 14:12:38 -04:00
Disco DeDisco
ad0041db74 FT flake mitigations triggered by pipelines #302/#303 — three independent fixes consolidated in one commit; test_admin_tarot._login_to_admin now waits on "Site administration" body substring before returning, same shape as 054b0aa's test_admin.py fix — the helper used to click submit + return immediately, letting the three TarotAdminTest tests race their subsequent browser.get(/admin/epic/tarotcard/) against the in-flight POST → 302 → admin home navigation so on a slow CI runner the new GET cancelled the unfinished POST, the session cookie was never set, the browser landed back on /admin/login/?next=…, and the downstream assertion saw the login-page body ('Earthman Deck' not found in 'Django administration\nEmail:\nPassword:' in #303, plus a NoSuchElementException for "The Schiz" on test_admin_earthman_card_detail's link-text click) — wait_for w. assertIn retries til the post-login page actually renders so the rest of the helper's callers start from a settled state ; test_jasmine swaps wait_for(check_results)wait_for_slow(check_results, timeout=60) — the spec suite has grown well past the 10s MAX_WAIT the default wait_for decorator affords + under CI contention (parallel Selenium workers competing for CPU on the same droplet) Jasmine's .jasmine-overall-result was still reporting "Running..." at 10s when the assertion fired w. (no detail) failure list (#303); check_results body is unchanged — "Running..." doesn't match the 0 failures regex so it falls into the failure branch + raises AssertionError, which wait_for_slow naturally retries til the result settles or 60s elapses ; base._make_browser wraps webdriver.Firefox(options=options) in try/except WebDriverException w. one sleep+retry when "geckodriver" appears in the error message — covers the spawn race under --parallel where multiple workers hit the same binary mid-permission-set + one of them gets 'geckodriver' executable may have wrong permissions (1 test in #302 + 1 test in #303, different test classes each time, confirming infra not test-logic); narrow filter on "geckodriver" so a genuine install fault still fails fast — both attempts would surface the same error in <1s ; deferred option filed to memory (project_ci_remove_pip_install_deferred.md) — dropping the pip install -r requirements.dev.txt line from each FT step would save ~5 min/pipeline (CI image drifted from requirements.dev.txt since a21e6aa so the install actually downloads 30+ packages every step instead of the intended "already satisfied" no-op verify) but loses the dep-drift safety net; declined for now, revisit when wall-clock pain > safety value — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 12:53:49 -04:00
Disco DeDisco
db10f345e4 display_name → at_handle for every user-rendering point around the recent-activity surfaces: scroll.html actor <strong>, Most Recent Scroll applet actor <strong>, My Games row body actor prefix, My Scrolls row body actor prefix, My Buds page bud-name span, navbar identity, _bud_panel.html data-sharer-name (consumed by the dynamic post-line-author append on share success) — at_handle was always the right filter for these slots: it produces @<username> when the user has set one, falling back to the truncated email (which already carries an @) so we don't double-prefix; the _my_buds_applet_item.html row was already on at_handle from the 3-col sprint, so this commit just brings the rest of the surfaces in line; _navbar.html swap also drops the literal @ that prefixed {{ user|display_name }} — that literal predated at_handle + worked for users w. usernames (gave @disco) but produced @<email>@<domain> for users w. no username yet; navbar wait_to_be_logged_in(email) FT helper keeps working since the email still appears as a substring whether rendered as @disco@test.io (old, no username) or disco@test.io (new); _bud_add_panel.html's client-side _appendBudEntry JS gains an inline at_handle mirror — display.indexOf('@') >= 0 ? display : '@' + display — since the server's add_bud response packs username or email under the username key (semantic mismatch w. the key name but stable) so the JS has to detect the email case itself; test_bill_my_buds.py two .bud-name text assertions ("alice""@alice") updated for the new prefix; 931 ITs + targeted FT regression on test_bill_my_buds + test_core_navbar + test_core_login green
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
2026-05-13 01:09:43 -04:00
Disco DeDisco
f7fa250804 .bud-duplicate-flash: auto-ease-out 3s after FYI + palette swap — note.js's Brief.showDuplicateBanner FYI handler now setTimeout(() => target.classList.remove('bud-duplicate-flash'), 3000) after the .add(); the existing transition: color 600ms ease, text-shadow 600ms ease rule on the class already covered the ease-in (default → flash), so the same rule now also covers the ease-out (flash → default) when the class drops — net behaviour: tap FYI → flash peaks → flash visibly fades back to the default text styling over ~600ms after a 3s hold, instead of persisting til page refresh; palette keys swapped per user steer — color: var(--terUser); text-shadow: var(--ninUser)color: var(--ninUser); text-shadow: var(--terUser), so the highlight reads as a lighter handle w. a gold glow rather than a gold handle w. a light glow, matching the duplicate-guard spec the user re-aligned on; affects all three flash targets uniformly (.bud-entry .bud-name on /billboard/my-buds/, .post-recipient on post.html share-flow, .gate-slot.filled on the gatekeeper invite-flow) since they all flow through the same _bud.scss .bud-duplicate-flash selector + the same Brief.showDuplicateBanner JS handler; new Jasmine spec D7b in NoteSpec.js uses jasmine.clock().install() + clock().tick(3001) to fast-forward past the dismiss window + assert the class is gone (existing D7 still pins the immediate-after-FYI peak state); existing FTs (test_bill_my_buds.test_re_add_existing_bud_shows_already_present_brief… + test_core_bud_btn duplicate-guard FTs) still green because they assert immediately after the FYI click (well inside the 3s hold) — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:43:03 -04:00
Disco DeDisco
e2040fda8f applet rows: hover + click-lock highlight on every .applet-list-entry.row-3col (My Posts / My Buds / My Notes / My Scrolls / My Games) — bg shifts to --secUser, title to --quiUser (overriding the inherited --terUser link color + stripping the text-shadow the global .applet-list-entry a:hover rule had been baking in), body + ts cells come up from their dimmed 0.6 / 0.5 opacity to full --priUser so the dim middle/right cols pop against the --secUser fill; new apps/applets/static/apps/applets/row-lock.js IIFE module owns the touch-persistence state machine (single _lockedRow ref, .row-locked class toggle): clicking a row not currently locked → locks (clearing any prior lock); clicking the locked row again → unlocks; clicking another row → moves the lock to the new row; clicking anywhere not inside a .row-3col → clears the lock — mirrors the note-page notes-locked click-lock state machine but lighter (no DON/DOFF, no greeting swap, no fetch), one document-level click listener bound once via _bound re-entry guard so beforeEach _init() calls in specs don't pile up handlers; loaded globally via base.html next to applets.js since the rows render on both /billboard/ + /gameboard/; padding-inline 0.5rem + border-radius 0.25rem on the row container shrinks the highlight to a chip shape so hovered rows don't bleed all the way to the applet box edge; 6 Jasmine specs in RowLockSpec.js cover the four state-machine transitions + the "child element of row still locks the parent row" affordance (since the user can tap the body cell text, not just the title link) + the "only one row carries .row-locked at a time" invariant; SpecRunner.html updated (both static_src + the static/ runtime mirror the FT reads from per the project's static-src→static copy discipline) — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:27:39 -04:00
Disco DeDisco
b2f1511c2d my-scrolls / my-games applet rows: prepend actor display_name to the body cell — the latest event's to_prose returns the action alone ("deposits a Carte Blanche…") because scroll.html splits the row across <strong>{{ event.actor|display_name }}</strong> + adjacent {{ to_prose|safe }}; the applet rows have a single middle column (<title> | <body> | <ts>) so they need both halves concatenated into .row-body; ROOM_CREATED welcome events (actor=None) keep rendering prose alone since to_prose already reads "Welcome to <name>!" — the {% if item.latest_event.actor %} guard skips the prefix, mirroring the same actor-guarded <strong> we added to _partials/_scroll.html + _applet-most-recent-scroll.html on c03fb2b so welcome lines don't carry a bogus empty actor; 2 ITs added — BillboardViewTest.test_my_scrolls_applet_row_body_includes_actor_display_name + GameboardViewTest.test_my_games_row_body_includes_actor_display_name — scoped to <span class="row-body">...stuart...deposits...</span> (regex match on the .row-body cell content) so the assertion can't pass on actor renders outside the row (the Most Recent Scroll applet on /billboard/ renders the same actor too, separately — initial pass missed this and assertIn("acto", body) matched there instead, hiding the bug); BillboardViewTest also gains test_my_scrolls_applet_row_body_no_actor_prefix_for_welcome to lock in the no-empty-prefix contract for ROOM_CREATED welcome events; 931 ITs green; settings.local.json fix-up — Bash(git add *) (literal * would only match the exact string "git add *", not git add -u) → Bash(git add:*) + companion read-only git patterns (status / diff / log / show) so the in-session commit flow stops prompting — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:13:33 -04:00
Disco DeDisco
054b0aa82b test_admin FT: wait on post-login content, not on <body> itself — pipeline #300 caught the flake (AssertionError: 'Site administration' not found in 'Django administration\nToggle theme...\nEmail:\nPassword:'); the pre-fix body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) resolved on the FIRST <body> Selenium found — which exists on the login page too — so on a slow CI runner the form submit hadn't navigated yet by the time wait_for completed, the assertion ran against the still-stale login-page body, and the test failed; locally the submit always completes inside the wait window (10s MAX_WAIT) so this never reproduces; fix: wait_for the "Site administration" substring directly via assertIn (the wait decorator retries on AssertionError til MAX_WAIT, so the loop keeps polling the body's text content until the admin home page actually renders), THEN read body.text once + run the remaining Users / Tokens assertions inline — same shape as the working test_admin_tarot / test_admin_post_readonly login flows; 1 FT green locally w. no other changes — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:00:57 -04:00
Disco DeDisco
5beb990623 brief banner: portal over the wordmark + h2 header area (small margin from viewport edges) w. position: fixed; top: 0.5rem; left: 0.5rem; right: 0.5rem; z-index: 10000 — mirrors #id_guard_portal's lift contract so the Brief escapes every game overlay's stacking context the same way the guard portal does; without this lift the Brief landed in document flow as nextSibling of #id_brief_banner_anchor (inside .container, which itself has no stacking context) — fine on pages w. no fixed overlays, but on room.html the gate / role-select / sig / sky backdrops (z-index 100-200, position: fixed; inset: 0, backdrop-filter: blur(...)) ate the Brief alive: the user saw the post-share / invite Brief outline blurred behind the Gaussian glass instead of in front of it; centring via max-width: 960px + auto inline margins matches .container's max-width so landscape viewports see the Brief centred over the page-content column rather than spanning the sidebar gutters; existing margin-bottom: 0.75rem dropped (vestigial — fixed positioning doesn't push siblings, and there's only one Brief at a time so the inline-stack case the margin was hedging on never happens); Django messages banner (the .note-banner.note-banner--message shell rendered by {% if messages %} in base.html for magic-link confirmations / errors) inherits the same portal treatment since both kinds share the same base class — UX is now uniform regardless of which overlay the user is staring through when the message arrives; 42 Brief-touching FTs (test_core_bud_btn + test_bill_my_buds + test_bill_my_notes) green w. no positional assertion regressions — Selenium clicks .note-banner__nvm / .note-banner__fyi / etc. the same w. position:fixed as it did inline
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:58:36 -04:00
Disco DeDisco
c03fb2bab0 billscroll: first log entry on a fresh room is a system-authored Welcome to <name>! greeting — epic.create_room records a ROOM_CREATED GameEvent (actor=None) immediately after Room.objects.create(...) so every room's scroll opens w. the greeting before any user action; GameEvent.to_prose ROOM_CREATED branch swapped from the unused "opens this room" legacy prose (verb was declared since the initial drama-app spike but no view recorded it — only test fixtures + AP federation tests touched it) → f"Welcome to {self.room.name}!", deliberately dropping the actor prefix the rest of the verbs lead w. since the welcome is the room's greeting, not a user action; scroll template (templates/core/_partials/_scroll.html + templates/apps/billboard/_partials/_applet-most-recent-scroll.html) gain an event.actor-guarded <strong> so the welcome line renders w.o. a leading empty <strong></strong> whitespace gap, and the .drama-event class branches now read mine / theirs / system (the new system slot replaces the prior else: theirs fallthrough when actor is None, opening room for system-line styling later w.o. mis-attributing the welcome to a phantom player); RoomCreationViewTest gains test_create_room_records_welcome_event_with_no_actor + test_create_room_welcome_event_renders_welcome_prose; the existing drama.tests.integrated.test_models tests (test_record_without_actor + test_events_ordered_by_timestamp) already exercise actor=None on ROOM_CREATED + the chronological-first position so the rendering contract holds; 928 ITs green — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:14:01 -04:00
Disco DeDisco
c08dd145c3 applet rows: 3-col grid <title> | <body> | <ts> mirroring post.html's .post-line shape — _my_posts_applet_item / _my_buds_applet_item / _my_notes_item / _my_scrolls_item / _my_games_item all gain a .applet-list-entry.row-3col w. <a class="row-title"> (clickable, 35c/32+... server-side truncated via new lyric_extras.truncate_title filter) + <span class="row-body"> (most-recent activity excerpt, dimmed 0.6 opacity, CSS-text-overflow: ellipsis clipped to whatever space remains — no server-side trunc here so the full line lives in the DOM for inspectors) + <time class="row-ts"> (relative_ts formatted, same minmax(3rem,auto) rightward column allocation post.html's .post-line-time uses, font-size 0.75rem + opacity 0.5 + right-aligned + nowrap); SCSS grid minmax(4rem,auto) 1fr minmax(3rem,auto) lifted from .post-line's template so the timestamp column lines up across post.html / scroll.html / every applet list; per-applet data shapes — _recent_posts annotates each Post w. latest_line (Line FK ordered by -id, None for empty Note-unlock posts); _recent_buds select_related('to_user__active_title') warms the bud's donned-Note FK in one query for the buds row body ("the {{ bud.active_title_display }}" + "since {{ bud.active_title.earned_at|relative_ts }}" — the "since " prefix is unique to this row since the ts is "when they donned it", not the row's own creation); _recent_notes attaches description from _NOTE_META per slug; annotate_latest_event(rooms) helper added to apps.epic.utils (next to rooms_for_user) — attaches room.latest_event per Room w. one .events.order_by('-timestamp').first() per item, used by _billboard_context for my_rooms (My Scrolls applet) AND by apps.gameboard.views.gameboard + toggle_game_applets for my_games (My Games applet), keeping the My Scrolls + My Games shapes symmetric; _billboard_context.my_rooms = annotate_latest_event(...) swaps rooms_for_user(...).order_by("-created_at") materialisation point — bud row's "no active title" branch silently drops body + ts cells so unrecognised buds still surface but don't fabricate a "since None" line; new truncate_title filter is the existing _truncate_post_title view helper hoisted into the template namespace (literal ... past 35 chars, None-safe); 5 ITs in BillboardViewTest cover row content / row absence on missing activity / "since" prefix uniquely on the buds row + 1 in GameboardViewTest for My Games row event prose; deferred row-prose body content cap on <span class="row-body"> purely to CSS text-overflow: ellipsis per user's "middle col should take up the remaining space" steer (initial pass also server-side trunc'd the body to 35c; removed) — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:06:55 -04:00
Disco DeDisco
eccb84f92b applet feed unification — My Buds + My Notes drop the [Feature forthcoming] / empty placeholders for live top-3 feeds, mirroring the long-standing My Posts pattern; all five in-grid list applets (My Posts / My Buds / My Notes / My Scrolls / My Games) now route their <ul> through a single shared partial _applet-grid-list.html (newly extracted) so item rendering + empty-state row + scroll-buffer all live in one place — _applet-list-shell.html (the dedicated billbuds/billposts page shell) now internally includes the same grid-list partial for its inner <ul>, so the dedicated-page and in-grid lists share the same skeleton; new per-applet item partials _my_buds_applet_item.html (mirrors _my_buds_item.html w. data-bud-id + display_name), _my_notes_item.html (links to billboard:my_notes; uses display_name), _my_posts_applet_item.html (Post link + title), _my_scrolls_item.html (Room link to billboard:scroll), _my_games_item.html (Room link to epic:gatekeeper); view-side _billboard_context gains _recent_buds(user) — sorts the User.buds auto-through table by -id so newest-added-first w.o. an explicit through model w. timestamps (manage [r.to_user for r in rows]) — + _recent_notes(user) (user.notes.order_by('-earned_at')[:limit]); same two helpers threaded into new_post's GET-with-form-errors branch (line 270-274) so the rerender keeps the new applet content visible; 7 ITs added to BillboardViewTest covering recent_buds ordering / cap / empty + recent_notes ordering / cap / cross-user isolation / empty; SCSS — .applet-list / .applet-list-entry / .applet-list-buffer lifted from .applet-list-page .applet-scroll scope to top level so they apply in both surfaces; in-grid applets get display: flex; flex-direction: column; .applet-list { flex: 1 } so the list scrolls within the applet box; #id_applet_my_games ul-centring + .scroll-list + #id_applet_notes h2 { writing-mode: vertical-rl ... } overrides removed (centring was an empty-state-only behaviour, scroll-list + vertical-rl redundant w. the new shared rule + the %applet-box > h2 rule); My Games items now left-aligned by default; empty-state row recovers the centred-italic-dim treatment via .applet-list-entry--empty { flex: 1; display: flex; align-items: center; justify-content: center; opacity: 0.6; font-style: italic } + .applet-list:has(> .applet-list-entry--empty) { display: flex; flex-direction: column } — so "No buds yet" / "No notes yet" / "No games yet" / "No scrolls yet" / "No posts yet" all centre in their applet aperture, reverting to the left-aligned stack the moment a real item lands; Most Recent Scroll's outer empty <p><small>No recent activity.</small></p> adopts the same .applet-list-entry .applet-list-entry--empty classes (section is already flex-column from existing rule) so it picks up the unified centred-italic-dim treatment
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
pipeline fix — `_post_gear.html` (commit 6a7464e) gated the NVM target on `{% url 'billboard:my_posts' user_id=request.user.id %}`, which exploded w. NoReverseMatch when an anonymous user (Percival ch.18 anonymous-post lab — ownerless `Post.objects.create()`) hit view_post (which has no @login_required); whole gear-include now wrapped in `{% if request.user.is_authenticated %}` since anonymous viewers can't DEL/BYE/back-to-my-posts anyway; AnonymousPostViewerTest pins the 200-render + gear-absence contract so future ownerless-post regressions surface in ITs (pipeline run #298 fixed) — TDD

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 22:48:32 -04:00
Disco DeDisco
6a7464ee4b post.html: gear-btn + #id_post_menu (NVM / DEL / BYE) mirror room.html's #id_room_menu — all Posts get the gear w. NVM (→ billboard:my_posts); user-Posts (kind=USER_POST / SHARE_INVITE) additionally surface DEL for the author (POST → billboard:delete_post → hard-deletes the Post; cascades Lines via FK + clears shared_with M2M) and BYE for invitees (POST → billboard:abandon_post → removes request.user from post.shared_with; owner + other invitees keep the thread); admin-Posts (kind=NOTE_UNLOCK) intentionally render gear w. NVM only since the system thread isn't user-owned (defence-in-depth: both delete_post + abandon_post no-op on NOTE_UNLOCK so a forged POST can't bypass the menu's branch); _post_gear.html partial gates DEL/BYE on viewer_is_owner (set by view_post since the buds sprint) + post.kind, then includes the shared apps/applets/_partials/_gear.html btn; styling rides the existing applets.scss page-level pattern — .post-page joins .billboard-page / .room-page / .dashboard-page / .wallet-page / .gameboard-page / .billscroll-page in the > .gear-btn { position: fixed; bottom: 4.2rem; right: 0.5rem } rule (and the landscape footer-sidebar centred variant), #id_post_menu joins the %applet-menu extension list + the page-level fixed-menu rule (bottom: 6.6rem; right: 1rem); 5 FTs in test_bill_post_gear.py (owner DEL flow, invitee BYE flow, 3 menu-shape assertions for owner/invitee/admin) + 11 ITs across DeletePostViewTest + AbandonPostViewTest (302 redirect target, side effect, GET-is-no-op, non-owner / non-invitee / NOTE_UNLOCK protection) — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 22:26:12 -04:00
Disco DeDisco
c64d7b9534 my_posts: owner-posts pane title @<handle>'s PostsPosts by Me — symmetrises w. the right pane's Posts by Others (both panes now read as point-of-view labels rather than one identity-stamped + one generic), and drops the @<handle> reveal from a screen the owner alone visits (handle is already in the navbar avatar tooltip); handle = owner.username or owner.email derivation goes w. it since nothing else in the view referenced it
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:01:59 -04:00
Disco DeDisco
4b47dabaf0 woodpecker main.yaml: serialise test-FTs-room behind test-FTs-non-room — both FT steps share the workspace AND fall back to SQLite (only test-UTs-n-ITs sets DATABASE_URL to the postgres service), so running them concurrently collided on src/test_db.sqlite3: the second-to-start container hit a half-created DB and the runner's Type 'yes' to delete the existing test database prompt EOFed under non-interactive CI stdin (pipeline run #296); fix flips test-FTs-room.depends_on from test-two-browser-FTstest-FTs-non-room so the heavy room cluster strictly follows the non-room bucket; section comment rewritten from "Parallel FT split" → "FT split (sequential for now)" w. the run #296 EOFError documented + two re-parallelisation paths spelled out for later (per-step distinct sqlite paths via DATABASE_URL=sqlite:////tmp/test_db_<bucket>.sqlite3 OR per-step distinct postgres DBs); the two stale "parallel" mentions (collectstatic note in test-two-browser-FTs + inline comment in test-FTs-room) also updated; screendumps + build-and-push depends_on unchanged — Woodpecker resolves the transitive ordering fine
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:44:04 -04:00
Disco DeDisco
a21e6aa251 requirements: align dev w. prod — add celery, psycopg2-binary, redis to requirements.dev.txt (missing from the CI dev image's pre-installed set, which means the CI fallback pip install -r requirements.dev.txt step would not satisfy these prod runtime deps if the image lagged); bump requests 2.31.0 → 2.32.5 in requirements.txt to align w. the dev pin (eliminates the version-drift wedge where CI's dev-only install would silently downgrade-or-upgrade vs prod's pin)
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:10:22 -04:00
Disco DeDisco
f9c05a3eba functional_tests + CI: rename pass + structural consolidations + parallel test-FTs split — every FT file now starts with one of 6 prefixes (test_admin_* / test_bill_* / test_core_* / test_dash_* / test_game_room_* / test_trinket_*) plus the 4 page-roots test_billboard / test_dashboard / test_gameboard / test_jasmine, so the partition is unambiguous and stable for tooling (the previous mix of test_applet_*, test_room_*, test_component_*, ad-hoc names had no consistent grouping); session-side: merged test_gatekeeper_bud_btn.py into test_bud_btn.py (then user renamed to test_core_bud_btn.py) — both files drove the same #id_bud_btn UI in two contexts (post-share + gatekeeper invite) and shared the bud-btn.js skeleton, so consolidation was overdue; split test_component_cards_tarot.py into test_admin_tarot.py (just TarotAdminTest, sitting next to test_admin / test_admin_post_readonly) + 3 classes (TarotDeckTest / GameKitDeckSelectionTest / GameKitPageTest) appended to test_game_room_tray.py; updated stale test_bud_btn.py references in the test_core_bud_btn.py docstring + test_admin_post_readonly.py comment to point at the new filename; user-driven renames (22 files): test_applet_my_notes/posts → test_bill_my_*, test_applet_new_post[_line_validation] → test_bill_new_post[_line_validation], test_applet_my_sky → test_dash_my_sky, test_applet_palette → test_dash_palette, test_wallet → test_dash_wallet, test_login → test_core_login, test_navbar → test_core_navbar, test_sharing → test_core_sharing, test_layout_and_styling → test_core_styling, test_my_buds → test_bill_my_buds, test_bud_btn → test_core_bud_btn, test_deck_contribution → test_game_room_deck_contrib, test_game_invite → test_game_room_invite, test_room_gatekeeper → test_game_room_gatekeeper, test_room_role_select → test_game_room_select_role, test_room_sea_select → test_game_room_select_sea, test_room_sig_select → test_game_room_select_sig, test_room_sky_select → test_game_room_select_sky, test_room_tray → test_game_room_tray, test_component_tray_tooltip → test_game_room_tray_tooltip; the post_page.py / room_page.py helper modules from the May-12 sprint absorbed the cross-file FT imports that would otherwise have cascade-broken on these renames
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
.woodpecker/main.yaml — CI test-FTs step splits into parallel siblings test-FTs-non-room (22 files via `ls functional_tests/test_*.py | grep -v 'test_game_room_'`) + test-FTs-room (9 files via `ls functional_tests/test_game_room_*.py`); room cluster is the heaviest (~70% of the pre-split ~40-min wall-clock) and now runs concurrently w. the rest instead of in series; DAG explicit via depends_on on every step (Woodpecker mixes default-sequential w. depends_on awkwardly, so each step pins its prerequisite); collectstatic stays in test-two-browser-FTs only — the shared workspace propagates assets to both parallel FT steps, no race + no duplication; screendumps + build-and-push fan back in (depends_on both parallel steps); deploy-staging + deploy-prod depend on build-and-push

smoke-import: 31/31 FT modules green after the rename pass

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:06:25 -04:00
Disco DeDisco
af1a90e76b woodpecker main.yaml: A+B combined CI dependency speedup — test-UTs-n-ITs swaps image from python:3.13-slim to gitea.earthmanrpg.me/discoman/python-tdd-ci:latest (same image as the two FT steps) so all 3 steps inherit the requirements.dev.txt deps Dockerfile.ci already pre-installs; all 3 steps switch pip install -r requirements.txtpip install -r requirements.dev.txt so the install collapses to ~5-10s of "already satisfied" verification per step (vs ~30-60s of unpinned PyPI resolver+download against requirements.txt); ~3-5 min saved per pipeline run; drift safety net preserved — pip still installs deltas if requirements.dev.txt is ahead of the image, so a stale image doesn't break CI, it just runs slower until the image is rebuilt + pushed
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Code architected by Disco DeDisco <discodedisco@outlook.com>

Git commit message Co-Authored-By:

Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:36:04 -04:00
Disco DeDisco
8240de6b45 functional_tests/room_page.py: extract shared FT helpers into a dedicated module so renaming a test_X.py file doesn't cascade-break sibling imports — _fill_room_via_orm (was in test_room_role_select, imported by test_room_tray + test_deck_contribution + test_game_invite + test_room_gatekeeper + test_room_sig_select), _assign_all_roles (was in test_room_sig_select, imported by test_room_tray + test_deck_contribution), and _equip_earthman_deck (duplicated verbatim in test_room_role_select + test_component_tray_tooltip — the test_component_tray_tooltip copy of _fill_room_via_orm was also a near-duplicate, missing only the gamers-return both call sites discarded anyway); SIG_SEAT_ORDER constant moves along since _assign_all_roles depends on it; mirrors the existing post_page.py pattern; underscored helper names kept so the "test infrastructure, not API" signal survives the relocation; dropped now-unused per-file imports (DeckVariant from test_room_role_select; Note/DeckVariant/TableSeat/TarotCard from test_room_sig_select; GateSlot from test_component_tray_tooltip); regression gate: GatekeeperTest (8 FTs) green via the new helper home; smoke-imports green across all 8 touched modules
Code architected by Disco DeDisco <discodedisco@outlook.com>

Git commit message Co-Authored-By:

Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:23:08 -04:00
Disco DeDisco
b97c4a0508 woodpecker main.yaml: workspace-shared pip cache via PIP_CACHE_DIR: .pip-cache on all 3 test steps (test-UTs-n-ITs / test-two-browser-FTs / test-FTs) — each step currently re-downloads the entire requirements.txt wheel set independently; Woodpecker mounts the workspace across steps in one run, so the wheels populated by step 1 are reused by steps 2-3, saving ~1-2 min of pip-install wall-clock per run (only download time — install still happens fresh per container); pyswiss.yaml untouched (separate pipeline, separate workspace, separate requirements); cache is per-run only — cross-run persistence would need a Woodpecker volume plugin
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:01:34 -04:00
Disco DeDisco
3932b17256 aperture architecture: lift the page-locking foundation (html/body/.container overflow:hidden + flex-column + min-height:0; .row flex-shrink:0) from 5 per-page SCSS files into _base.scss — was opt-in per page via body.page-billboard / page-dashboard / page-gameboard / page-sky / page-wallet etc., with 5 near-identical html:has(body.page-X) { overflow: hidden } + body.page-X { … } blocks duplicating the same rules; any page that forgot to set page_class in its view context (e.g. epic.tarot_deck — never set) rendered without the aperture, letting applet borders + titles clip past the fixed navbar/footer sidebars at narrower viewports; foundation now universal, page-specific overrides stay scoped — gameboard keeps .container { overflow: clip } (Firefox seat-tooltip scroll-anchoring quirk) + billboard/dashboard/gameboard keep .row { margin-bottom: -1rem } (h2-row tightening); page_class context vars + body class hooks preserved (FTs at test_bud_btn.py:370 / :379 still assert on them); regression gate: 60 layout-sensitive FTs (billboard, my_buds, bud_btn, applet_my_posts, dashboard, wallet, gameboard, layout_and_styling, jasmine) + 43 room FTs (gatekeeper_bud_btn, room_gatekeeper, room_sky_select, sharing) all green
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:16:12 -04:00
Disco DeDisco
17c4518944 my_buds: dismiss "No buds yet" empty-state row async on first-bud add — _appendBudEntry queried .bud-entry--empty but _applet-list-shell renders it w. the family-agnostic .applet-list-entry--empty class; the selector never matched, so first-bud appends landed alongside the empty row instead of replacing it; existing test_add_bud_via_bud_btn_appends_entry extended to also assert the empty row dismisses post-add (caught the bug — TDD)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:47:13 -04:00
Disco DeDisco
be919c7aff bud panels duplicate-add guard: server-side already_present flag + client-side error Brief w. FYI flash highlight on the existing entry — for each of the three #id_bud_btn panels (My Buds / post-share / gatekeeper-invite), the JSON response from add_bud / share_post / invite_gamer now carries {already_present, recipient_display, recipient_user_id}; bud-btn.js branches on already_present → calls new Brief.showDuplicateBanner({display_name, target_selector}) instead of the normal onSuccess append; banner title reads @<username> is already present, NVM dismisses, FYI dismisses AND eases in the .bud-duplicate-flash class (color: var(--terUser); text-shadow: 0 0 .5em var(--ninUser); transition: 600ms) onto the existing element (.bud-entry .bud-name / .post-recipient[data-user-id=…] / .gate-slot.filled[data-user-id=…]); gatekeeper "already present" = recipient is either GateSlot.FILLED + gamer OR has TableSeat OR has a pending RoomInvite (highlight target only set when seated — pending invites have no visible slot); .post-recipient chips + .gate-slot.filled cells gain data-user-id so the FYI selector can find them; my_buds.html now loads note.js via the {% block scripts %} pattern (Brief module is required by the duplicate banner path); bonus: latent test_jasmine.py bug fixed — "0 failures" in result.text matched "10 failures" / "20 failures" / etc, silently passing up to 99 failed specs; replaced w. re.search(r"(?<!\d)0 failures\b", …) (caught my new red specs, would've caught any prior Jasmine regression); 18 new ITs + 10 new Jasmine specs + 3 new FTs (one per panel) — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:40:15 -04:00
Disco DeDisco
264ed5968e bud panels DRY refactor: extract shared skeleton into bud-btn.js — the three #id_bud_btn partials (_bud_panel post-share, _bud_invite_panel gatekeeper, _bud_add_panel My Buds) duplicated ~70% of their JS (csrf/open/close, btn click, Escape, click-outside w. optional suggestions excl., Enter handler, OK POST + JSON routing); collapse into bindBudBtn({submitUrl, autocompleteUrl?, onSuccess(data)}) so each panel keeps only its markup + an onSuccess callback (post-share: _appendLine + _appendRecipientChip + Brief.showBanner; gatekeeper: Brief.showBanner only; my-buds: _appendBudEntry); autocomplete binding folded into bud-btn.js (drives bindBudAutocomplete when autocompleteUrl is passed) so callers stop repeating the bindBudAutocomplete(...) boilerplate; dead data-share-url/data-invite-url/data-add-url attrs dropped since bindBudBtn takes the URL directly; behavior-preserving — all 21 existing FTs (test_my_buds + test_bud_btn) + 49 ITs (test_buds + test_share_post + test_brief) green
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:52:09 -04:00
Disco DeDisco
7015ddd534 my_buds: strip bud-autocomplete bindings from _bud_add_panel.html — the autocomplete pool is request.user.buds (per search_buds view), so on the page where you ADD new buds the suggestions are the precise set you can't usefully re-add; post-share + gatekeeper-invite panels keep the binding (re-sharing w. an existing bud is a real flow); test_autocomplete_suggests_buds_by_username_prefix → test_no_autocomplete_suggestions_on_my_buds_page (asserts #id_bud_suggestions absent — deterministic, no debounce-window race); dead id_bud_suggestions click-outside guard + unused {% load static %} dropped — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:42:41 -04:00
Disco DeDisco
880408285a post.html line timestamps mirror scroll's relative_ts buckets: swap date:'g:i A' for the existing lyric_extras.relative_ts filter so same-day Lines render as 11:46 p.m., then collapse to weekday (Sat) past 24h, 07 Mar past a week, 07 Mar 2025 past a year — DRYs onto the single filter scroll already uses (no duplicated bucketing logic); same-day rendering shifts from 11:46 PM to 11:46 p.m. as a side effect of reusing the lowercase g:i a format relative_ts already standardised on — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:13:49 -04:00
Disco DeDisco
df99cad984 mobile h2 + sky wheel landscape fit: per-letter flex spread (justify-content: space-between via base.html JS letter-splitter) replaces text-justify: inter-character — iOS Safari + Firefox silently fall back to inter-word for Latin text, leaving letters clustered at the slot start; flex layout works everywhere; viewport-fluid font clamp(1.3rem, 5vw, 2rem) portrait + clamp(1.2rem, 4.4vh, 2.75rem) landscape so glyphs scale w. viewport instead of a fixed-rem ceiling that overflowed the 45/55 slot at the rem-clamp floor; portrait <500px gets padding-inline 0.4em→0.6em on the word-spans so H-B don't run together at the cramped font-size; .sky-page post-save scroll-snap sections pinned to height: 100% (was: min-height: 100%) + .sky-svg max-height: 100% so the wheel fits exactly one aperture on landscape mobile (was: 480px max blew past ~350px aperture, leaving an intermediate scroll position)
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:01:16 -04:00
Disco DeDisco
22d0507c3f post.html header prose branches on viewer-vs-owner: invitees see "shared with me, @viewer the {title}" + "created by @owner the {title}" instead of the owner-centric "just me / shared between" lines; owner view unchanged — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- billboard.views.view_post adds viewer_is_owner + other_recipients context vars. is_real_invitee = (auth AND post has owner AND viewer != owner). Anon viewers + ownerless-post legacy path fall through to owner-style rendering (which renders empty gracefully via the at_handle / display_name AnonymousUser guards).
  - other_recipients = post.shared_with.exclude(viewer) when invitee; .all() otherwise.
  - post.html .post-header branches:
    • viewer_is_owner: existing prose ("just me, @owner …" / "shared between {recipients} & me, @owner …").
    • sole invitee: "shared with me, @viewer the {viewer.title}" + "created by @owner the {owner.title}".
    • multi invitee: "shared with {other_recipients}" + "& me, @viewer the {viewer.title}" + "created by @owner the {owner.title}".
  - lyric_extras at_handle + display_name: guard against AnonymousUser (no .email attribute) — return "" rather than crash. Preserves the Percival ch. 18 anon-views-ownerless-post path.
  - 12 new ITs in test_post_invitee_view (context vars: viewer_is_owner, other_recipients exclude/include; template prose: sole + multi invitee phrasing, owner unchanged).
  - 878 IT regression + 8 post-html FT regression green (1 Marionette flake on multi-run that passes in isolation).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:14:11 -04:00
Disco DeDisco
419e022140 gatekeeper invite ports to #id_bud_btn slide-out: drop the inline #id_invite_email form, add bud-invite panel for room owner during gate phase, async POST to invite_gamer w. autocomplete + symmetric buds auto-add + slide-down Brief banner — TDD
- Brief schema (billboard/0007): post FK becomes nullable + new room FK to epic.Room + KIND_GAME_INVITE enum value. to_banner_dict resolves post_url to reverse('epic:gatekeeper', room.id) when post is null and room is set.
  - epic.invite_gamer view refactor:
    • Accepts `recipient` (matches bud-panel field; legacy `invitee_email` still works for full backwards compat).
    • Resolves via apps.billboard.views._resolve_recipient (email if "@" present, else username).
    • RoomInvite stores the resolved User's email (or raw input if unregistered).
    • Auto-adds inviter ↔ recipient to each others' buds (symmetric per Phase 2 spec) when recipient is a registered User.
    • Spawns a Brief w. owner=request.user, kind=GAME_INVITE, room=room, post=null.
    • Accept: application/json → {brief, recipient_display}; otherwise redirects to gatekeeper as before.
    • Self-invite + blank recipient: 200 w. brief=null, no RoomInvite, no buds touch.
  - _gatekeeper.html: gate-invite-panel block (lines 62-71) removed.
  - new templates/apps/billboard/_partials/_bud_invite_panel.html: clone of _bud_panel.html w. data-invite-url + autocomplete from request.user.buds. JS posts to invite_gamer + Brief.showBanner. room.html includes it owner-only when not table_status and gate_status != RENEWAL_DUE.
  - room.html scripts block now loads apps/dashboard/note.js so window.Brief is defined for the slide-down banner.
  - Tests: new test_invite_gamer.py (14 ITs) covering ajax + legacy form-submit paths, recipient resolution, RoomInvite creation, Brief w. room FK + GAME_INVITE kind, symmetric buds auto-add, unregistered/self/blank silent no-op cases. New test_gatekeeper_bud_btn.py FT (9 tests) covers presence (owner-only), absence of legacy #id_invite_email, async invite flow end-to-end (RoomInvite, Brief, banner, panel close, username resolve, buds auto-add).
  - test_brief.test_brief_owner_post_required relaxed to test_brief_owner_required (post is now nullable).
  - test_room_gatekeeper.test_second_gamer_drops_token_into_open_slot updated to drive the bud-btn flow (drops the #id_invite_email/#id_invite_btn references).
  - 866 ITs (+14) + 9 gatekeeper FTs + 28 existing room-gatekeeper FTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:59:54 -04:00
Disco DeDisco
4010e452a6 recentered copyright on landscape 2026-05-09 00:38:51 -04:00
Disco DeDisco
72fefe2fc7 landscape footer rearrange: #id_bud_btn moves to top of right sidebar (upper-right corner of footer); ©2026 Dis Co. text becomes a single-line vertical strip at the very right edge of the viewport (mirror of portrait's "after the icons" position); bud-panel + bud-suggestions follow the bud-btn to the top w. transform-origin: right center
- _bud.scss #id_bud_btn landscape: left: auto + right: calc((var(--sidebar-w) - 3rem) / 2) + top: 0.5rem + bottom: auto. Centred horizontally in the right sidebar; tucked at the top.
  - _bud.scss #id_bud_panel landscape: top: 0.5rem; bottom: auto; transform-origin: right center. Slides leftward from the bud-btn along the top edge of the viewport (instead of bottom).
  - _bud.scss .bud-suggestions landscape: top: 4rem; bottom: auto; box-shadow flipped from upward to downward — autocomplete dropdown now opens BELOW the panel (since panel is at top).
  - _base.scss .footer-container landscape: writing-mode: vertical-rl + transform: rotate(180deg) for a bottom-to-top single-line read; right: 0.125rem (tight against the viewport edge); bottom: 0.5rem; line-height: 1; <br> { display: none } collapses the two-line "©2026 / Dis Co." into one line "©2026 Dis Co.". Tucks into the empty 0.875rem gutter between the viewport edge and the centred icon column — no overlap w. kit-btn / gear-btn at the bottom.
  - 21 bud FTs green (portrait position contract intact: bottom-left, the landscape move is orientation-scoped).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:35:26 -04:00
Disco DeDisco
47871b5b4a align #id_kit_btn / #id_gear_btn / #id_bud_btn under the unified centre formula — drop the legacy right: 2.5rem ≥1800px override on kit-btn that left it outboard of gear-btn after the rem-fluid sidebar refactor
- _game-kit.scss #id_kit_btn landscape rule now uses `right: calc((var(--sidebar-w) - 3rem) / 2)` — same formula as gear-btn (_applets.scss) and bud-btn (_bud.scss). All three 3rem-wide circular btns now share the same horizontal-centre math against the fluid sidebar.
  - Drops `@media (orientation: landscape) and (min-width: 1800px) { right: 2.5rem }` which was a leftover from the old doubled-8rem-sidebar regime; the rem clamp ceiling now caps the sidebar without per-breakpoint overrides.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:23:53 -04:00
Disco DeDisco
ad9f7b43ed h2 padding-inline boundary between the two spans (BILL | BOARD natural gap) + landscape inherits portrait flex split (45/55 vertical, with padding-inline-end on first span = visual break between rotated words)
- portrait h2 spans get padding-inline-end / padding-inline-start (0.4em each) at the 45/55 boundary; box-sizing: border-box keeps the flex basis honest. Solves the "B I L L B O A R D" run-together where the L of BILL touches the B of BOARD.
  - landscape h2 drops the `display: block` override + the > span resets that nuked text-align: justify and flex. Now inherits the portrait flex 45/55 + per-span justify + padding-inline. With writing-mode: vertical-rl, the flex axis runs vertically (45% bottom for BILL post-rotate, 55% top for POST/BOARD/etc.); padding-inline-end resolves to the bottom edge of the first span = natural gap between the two rotated words.
  - Explicit h2 height: 80vh in landscape so the flex 45/55 percentages have a defined basis to resolve against (block height isn't auto-derived in writing-mode: vertical-rl).
  - 8 layout/navbar FTs still green; assertions are categorical (position: fixed, etc.) not exact-px.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:18:57 -04:00
Disco DeDisco
3ab60c67b6 fluid root rem + landscape aperture: html font-size = clamp(14px, 2.4vmin, 22px) so 1rem scales w. viewport (rotation-invariant via vmin); --sidebar-w + --h2-col-w CSS vars unify navbar/footer/h2 sizing; container margin-left = sidebar + h2-col-w in landscape so applets clip cleanly under the rotated wordmark; h2 markup splits into two spans (45/55 horizontal title); drop the disparate min-height font-size jumps + 1800px sidebar-doubling overrides
- html { font-size: clamp(14px, 2.4vmin, 22px) } — single sliding scale; everything in rem (sidebar widths, h2 font-size, paddings) scales together. Phone rotation swaps width/height but vmin stays the same → 1rem stays the same → navbar/footer/h2 hold their size between portrait + landscape.
  - :root --sidebar-w: 5rem (replaces the locally-scoped $sidebar-w SCSS var that lived inside @media blocks); --h2-col-w: 3rem for the rotated wordmark column in landscape. var(--sidebar-w) + var(--h2-col-w) are the only knobs that move the layout.
  - Landscape container: margin-left = calc(var(--sidebar-w) + var(--h2-col-w)); margin-right = var(--sidebar-w). Applets are now clipped INSIDE the h2 column, so the rotated "BILLPOST" / "DASHBOARD" wordmark never has content bleeding behind it (the original complaint).
  - h2 markup refactor across 13 templates: <span>BILL</span><span>POST</span> instead of <span>BILL</span>POST. Portrait styling: display: flex; first span flex 0 0 45% + --quaUser colour; second span flex 0 0 55% + --secUser inherited. Per-span text-align: justify + text-justify: inter-character keeps the inter-letter spacing within each span. Landscape resets the flex (single rotated wordmark, not split).
  - Drop the four h2 font-size jumps (min-height: 400/500/800px) — single font-size: 3rem now scales fluidly via root rem. Drop the @media (orientation: landscape) and (max-width: 1100px) h1 override (rem-fluid handles cramped widths). Drop the entire @media (orientation: landscape) and (min-width: 1800px) sidebar-doubling block in _base.scss / _applets.scss / _bud.scss — the rem clamp ceiling already caps the size.
  - _bud.scss + _applets.scss: bud-btn / bud-panel / bud-suggestions / gear-btn / applet menus all switch to var(--sidebar-w)-based positioning; landscape rules are single (no per-breakpoint duplication).
  - Per-spec tradeoff: non-.btn-primary buttons (BYE / NVM / OK / kit-btn / etc.) inherit rem-fluid like everything else and will scale slightly w. viewport. User explicitly OK'd this — they don't need to stay px-fixed.
  - 852 ITs + 24 layout/navbar/bud FTs green; existing geometry assertions are relative or categorical (not exact-px) so the rem clamp doesn't surface failures at the 800x1200 FT viewport.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:14:14 -04:00
Disco DeDisco
c426ca69fa Note.grant_if_new admin prose: 'comes with the customary title of' → 'bestows the honorary title of'; 'additional benefits' → 'additional corporate benefits'
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Tonal shift to lean into the bureaucratic-flavour of the @adman entity. Going-forward only; existing super-schizo / super-nomad Lines in DB keep the old prose.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:44:46 -04:00
Disco DeDisco
e0ace01670 post.html attribution palette: usernames render w. @-prefix (bare emails left as-is); .post-attribution spans wrap username+title combos for the --quaUser colour key — line author col, self/shared header lines, Note.grant_if_new prose
- new lyric_extras.at_handle filter: '@{username}' if user.username, else truncate_email(user.email). Companion to display_name (which has no @-prefix). Used by post.html line author col + self/shared self lines.
  - post.html updates: line author span renders {{ line.author|at_handle }}; .post-shared-recipients chips render {{ r|at_handle }} + .post-attribution; .post-shared-self wraps "{handle} the {title}" in <span class="post-attribution">. The 'just me' / '& me' prose stays plain (only the handle+title combo is coloured).
  - Note.grant_if_new prose wraps both the @-handle (or bare email fallback) AND the title in <span class="post-attribution">. Standard format wraps the combo "{handle} the {title}" together; admin format wraps each independently since the prose splits them ("recognizes @disco for ... customary title of Schizoid Man"). Existing Lines unchanged — going-forward styling only.
  - SCSS: .post-attribution { color: rgba(var(--quaUser), 1); } scoped at .post-page so it lights up in both .post-header descendants and #id_post_table descendants. .post-line-author also switches from opacity-based dim to the same --quaUser key (drops opacity 0.75 since the colour change reads as the de-emphasis on its own).
  - 852 ITs still green — line.text inclusions ("Stargazer", "alice@test.io" etc.) still substring-match through the wrapping spans.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:42:51 -04:00
Disco DeDisco
eb0369f0b7 buds Phase 2: top-3 username|email autocomplete on #id_recipient (post share + my_buds add); implicit symmetric auto-add on share_post (sharer ↔ recipient buds graph); recipient field accepts username OR email — TDD
- billboard.views.search_buds(GET /billboard/buds/search?q=...) — top-3 prefix match against request.user.buds via Q(username__istartswith) | Q(email__istartswith). Returns {buds: [{id, username, email}]}. Privacy: only the user's own buds are searched, no leak of strangers.
  - _resolve_recipient(raw) helper resolves a free-form recipient (email if "@" present, else username, both case-insensitive). Wired into add_bud + share_post so #id_recipient accepts either form.
  - share_post implicit auto-add (per-spec): when recipient is registered + first-time-shared, both directions of buds M2M get the link — request.user.buds.add(recipient) AND recipient.buds.add(request.user). Idempotent, no auto-add on reshare/self/unregistered.
  - new bud-autocomplete.js shared module (apps/billboard/static/apps/billboard/) — bindBudAutocomplete(input, suggestionsEl, {searchUrl}). Mirrors sky.html birth-place picker: 250ms debounced fetch from MIN_CHARS=1, click-to-fill, Escape closes, click-outside closes, late-response drop. e.stopPropagation on suggestion-click so the bud-panel's outside-click handler doesn't fire and clear the input.
  - SCSS .bud-suggestions / .bud-suggestion-item mirrors .sky-suggestions but position:fixed bottom:4rem (aligned above the bud panel, with overflow:hidden on the panel forcing the dropdown to live as a sibling rather than a child). Landscape breakpoints clear the navbar/footer 4rem sidebars, 8rem at min-width 1800px.
  - both _bud_panel.html (post share) + _bud_add_panel.html (my_buds add) get the suggestions div sibling + script tags. Each panel's existing document click-outside handler now skips the suggestions container so a click inside doesn't close+clear. type="email" → type="text" since usernames are accepted; placeholder "friend@example.com or username".
  - new test classes in test_buds.py: SearchBudsViewTest (6 — prefix match, cap-3, email prefix, non-bud leakproof, empty-q, anon redirect) + SharePostImplicitAutoAddTest (4 — sharer.buds += recipient, recipient.buds += sharer, username-typed share, unregistered no-add) + AddBudViewTest.test_add_resolves_username_too. test_my_buds.py FT adds test_autocomplete_suggests_buds_by_username_prefix. test_sharing.py placeholder assertion updated to "friend@example.com or username".
  - 852 ITs (+11) + 5 my_buds FTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:34:35 -04:00
Disco DeDisco
11ff109d1e my_posts: titles → @{handle}'s Posts + Posts by Others (view-side string build); applet-list link colour --terUser w. hover/active shifting to --ninUser + --terUser glow halo
- billboard.views.my_posts adds owner_posts_title (f"@{handle}'s Posts") + others_posts_title ("Posts by Others") to context. handle = owner.username or owner.email matches the navbar @-handle pattern.
  - my_posts.html shell invocations use the new vars instead of in-template |add: filter chains.
  - SCSS .applet-list .applet-list-entry > a: base color rgba(var(--terUser), 1), text-decoration none, font-weight bold; on :hover/:active color shifts to rgba(var(--ninUser), 1) + text-shadow 0 0 0.55rem rgba(var(--terUser), 0.7) for the lift halo. transition: text-shadow 0.15s ease.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:16:31 -04:00
Disco DeDisco
246e45e55d buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
  - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
  - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
  - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
  - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
  - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
  - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
  - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
  - 841 ITs + 5 my_buds/my_posts FTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
Disco DeDisco
5f6002aa70 buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial)
- lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design.
  - applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts).
  - billboard.views.my_buddies + add_buddy:
    • my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all().
    • add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup).
  - templates:
    • _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/).
    • my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet."
    • _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add.
  - SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement).
  - tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op.
  - 841 ITs (+14) + 4 my_buddies FTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:31:42 -04:00
Disco DeDisco
b3eb14140c admin Posts (NOTE_UNLOCK): readonly input + 'No response needed' placeholder + secUser focus glow + buddy btn suppressed + view POST 403 + Line.admin_solicited listener nukes errant writes; share Lines: drop ts suffix, author = sharer (adman fallback for anon legacy), silent no-op on re-share — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- billboard/0005 adds Line.admin_solicited (BooleanField default False); RunPython backfills existing note_unlock Lines to True. Note.grant_if_new sets admin_solicited=True on its system prose.
  - billboard.models post_save signal: any Line saved on a Post.kind=NOTE_UNLOCK without admin_solicited=True is deleted (defense-in-depth alongside the view guard).
  - billboard.views.view_post hard-rejects POST on NOTE_UNLOCK kind (HTTP 403) — clean view-level contract; the post_save listener is the safety net for ORM/API paths that bypass it.
  - templates/apps/billboard/post.html: NOTE_UNLOCK branch renders the input as readonly w. 'No response needed at this time' placeholder + no method/action; user_post branch keeps the regular composer. Buddy panel include guarded behind `{% if post.kind != 'note_unlock' %}` — friend invites don't apply to admin threads.
  - SCSS: .post-line-form input.form-control[readonly]:focus uses --secUser glow (cooler than the regular --terUser composer focus).
  - share_post: drop the iso-timestamp suffix on Line.text (just 'Shared with {email}'); author = request.user (anon legacy fallback to adman so AnonymousUser doesn't break the FK); re-share of an already-in-shared_with recipient is a silent no-op (no second Line, brief: null in JSON response). Buddy panel JS now reads data-sharer-name from server-rendered display_name so the optimistic _appendLine matches the post-refresh state.
  - new ITs: test_admin_posts (PostRejectsAdminWritesTest, UnsolicitedLineListenerTest, NoteGrantSetsAdminSolicitedTest) — 7 tests; share_post tests rewritten for the new contract (drop ts, author=sharer, silent re-share dedup) — 12 tests; new FT test_admin_post_readonly w. AdminPostInputReadonlyTest + AdminPostHasNoBuddyBtnTest + UserPostInputUnaffectedTest — 4 tests. 827 ITs + 18 buddy/sharing FTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:52:34 -04:00
Disco DeDisco
6f76f6c176 post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD
- schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner).
  - lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed).
  - drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default.
  - billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title.
  - post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio).
  - SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square.
  - _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self).
  - FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors.
  - rootvars.scss: minor plutonium + fuschia tweaks (pre-existing).
  - 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
Disco DeDisco
ba5f6556c0 buddy btn sprint: banner-anchor + window.Brief fix lands the last red FT — 16/16 buddy + 12 share/jasmine/my_notes + 818 IT regression — TDD
Two small fixes close out the OK→banner gap:

1. Anchor over h2: base.html drops <div id="id_brief_banner_anchor"></div> right before {% block content %} (after the messages block). note.js's showBanner now prefers the explicit anchor over the first <h2> — keeps the banner in the visible content flow on pages where the first h2 is position:absolute (post.html's rotated navbar header was the immediate motivator; sky.html's rotated h2 is the same shape, so this catches that pre-emptively too).
2. window.Brief explicit assignment: const Brief = (...) at script-tag scope is reachable as a bare name but does NOT auto-attach to window. The buddy panel's OK handler gates banner reveal on `if (window.Brief && data.brief)` — that gate was always false, so Brief.showBanner never fired on share-OK even though the chip + Line append in DOM proved the fetch.then() was running. Explicit window.Brief = Brief; window.Note = Note; in note.js (post-IIFE) closes the gap.

Also picks up the deferred page-object update — functional_tests.post_page.PostPage.share_post_with() now drives the buddy-btn flow (click #id_buddy_btn → type → click #id_buddy_panel .btn.btn-confirm → wait for recipient chip), so legacy SharingTest exercises the new pipeline end-to-end.

NoteSpec.js T10 split into T10a/T10b: a covers the anchor-preferred path, b covers the <h2> fallback.

16/16 buddy FTs green (previously 15/16). 12/12 sharing + Jasmine + my_notes FTs green. 818-test IT sweep green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:14:50 -04:00
Disco DeDisco
e465b6a3b3 buddy btn sprint scaffolding: TDD spec + partial template + SCSS + page_class — 15/16 FTs green, 1 captured-red for post-compaction handoff
Pre-compaction handoff for the bottom-left handshake btn that replaces the inline share form on post.html. The full spec lives in functional_tests/test_buddy_btn.py — running it as a red-first TDD checklist for the next agent (or future Disco) to pick up after compaction.

Scaffolding landed:
- functional_tests/test_buddy_btn.py — 16 tests across 6 classes covering presence-only-on-post.html (B1–B3), bottom-left fixed positioning matching kit-btn dimensions, slide-out panel structure (closed/open width, OK btn = .btn-confirm, recipient input left-padding clears the glyph), kit↔buddy mutual-exclusion via opacity, click-outside + Escape dismiss + clear, OK→async share creates Brief + appends Line + chips the recipient + closes the panel + clears the field, and post.html / my_posts.html body class picks up the aperture marker (page-billboard).
- templates/apps/billboard/_partials/_buddy_panel.html — the partial: <button id="id_buddy_btn"><i class="fa-solid fa-handshake"></i></button> + #id_buddy_panel housing #id_recipient and #id_buddy_ok (.btn.btn-confirm). Inline JS mirrors the game-kit.js click/escape/click-outside pattern, toggles html.buddy-open + .active on the btn, intercepts OK to POST share-post w. Accept:application/json (reuses C3.b shape — line_text + brief.to_banner_dict() + recipient_display), appends the chip + line in-DOM, Brief.showBanner shows the slide-down banner, _close clears the input.
- templates/apps/billboard/post.html — drops the inline #id_share_form / #id_recipient / SHARE-primary block + its JS; includes the buddy panel partial at the end of {% block content %}.
- billboard.views.view_post + my_posts now set page_class="page-billboard" so the body class hooks into the aperture SCSS group (the user noted post.html wasn't in that group; this brings it in).
- static_src/scss/_buddy.scss — new partial: #id_buddy_btn fixed bottom-left mirror of #id_kit_btn (3rem circle, secUser border, .active state, transition: opacity 0.15s); #id_buddy_panel slide-out spans calc(100vw - 3rem) (1.5rem each side w. landscape sidebar carve-outs), transform: scaleX(0)→1 from left center on html.buddy-open, opacity 0→1, the recipient input gets padding 0 1rem 0 3.5rem so the glyph doesn't overlap. Mutual exclusion: html.buddy-open #id_kit_btn → opacity:0; html:has(#id_kit_bag_dialog[open]) #id_buddy_btn → opacity:0 (uses :has() per project convention; no JS-side kit-open class needed).
- core.scss imports buddy after game-kit.

15/16 FTs green; the lone red is BuddyBtnOkSubmitsAsyncShareTest.test_ok_creates_brief_appends_line_and_chip — server flow works (Brief is created, recipient chip + line append in DOM both visible in the screendump), only the .note-banner injection isn't surfacing on post.html. Likely cause: note.js inserts after the first <h2>, but post.html's only h2 is the rotated navbar header which is position:absolute, so the banner's geometry parents to that and falls outside the visible aperture. Two clean follow-ups for the post-compaction agent: (a) make Brief.showBanner pick a different anchor when h2.parentElement is position:absolute, or (b) define a #id_brief_banner_anchor in base.html under the page content and have showBanner prefer it.

Also pending for post-compaction: update functional_tests.post_page.PostPage.share_post_with() to drive the new buddy-btn flow (click btn → type → click OK → wait for chip) so the legacy test_sharing FT keeps working — currently it still operates on the inline form selectors that no longer exist.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:00:28 -04:00
Disco DeDisco
7b2780e642 test_applet_new_post: bump current-url regex /dashboard/post/ → /billboard/post/
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Stray /dashboard/post/ regex in NewVisitorTest.test_multiple_users_can_start_posts_at_different_urls — the path moved in the C1 brief-sprint relocation (d192b15) but two occurrences in this FT slipped past the bulk update. CI run #286 caught it.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:26:00 -04:00
Disco DeDisco
14bab444ff brief sprint C3.b+c+d+e: share-post Line+Brief async, magic-link / invalid-link banners use Brief styling, .alert-* retired — TDD
Closes the C3 brief sprint. Three event sources (note unlock, share invite, login messages) now route through the Brief slide-down, & the legacy .alert-success/.alert-warning rendering in base.html is retired.

C3.b — share-post async Line + Brief:
- billboard.share_post detects Accept: application/json. JSON path appends a Line (text="Shared with X at <isoformat>", isoformat carries microseconds so two rapid shares of the same email don't collide on Line.unique_together(post,text)), spawns a Brief(kind=SHARE_INVITE) for the sharer, and returns {brief: brief.to_banner_dict() | None, line_text, recipient_display}. Sharer-shares-with-themselves stays a silent no-op (response carries brief: null). Legacy form-submit path preserved for non-AJAX (still redirects + flashes the privacy-safe message — kept for older FTs / no-JS fallback).
- billboard.Brief.to_banner_dict() (moved from dashboard.views helper to a model method) shapes the JSON the banner JS consumes.
- post.html: share form intercepted by JS — fetches POST w. Accept:application/json, then appends `data.line_text` as the next row in #id_post_table, calls Brief.showBanner(data.brief), and (when registered) appends a fresh `<span class="post-recipient">` to the new #id_post_recipients box. No page reload — the alert-success flash is gone.
- 10 new ITs (SharePostAsyncTest + SharePostLegacyRedirectTest) cover the JSON path, line append, brief creation w. SHARE_INVITE kind, registered/unregistered recipient behaviour, sharer-self skip, line dedupe via timestamp, and that the legacy form-submit redirect path still works.
- functional_tests.test_sharing line numbering updated: the share now records its own Line so the alice-reply lands at row 3 instead of 2.

C3.c+d — magic-link confirmation + invalid-link error use Brief banner styling:
- base.html's {% if messages %} block stops rendering .alert-success/.alert-warning divs. Instead each message renders as a transient Brief-styled banner: <div class="note-banner note-banner--message note-banner--{{level_tag}}"> with .note-banner__body / __description carrying the message text and a .btn-cancel NVM that removes the banner via inline onclick. No DB Brief row; no FYI; no square. Same Gaussian-glass look as note-unlock + share-invite Briefs.
- _note.scss adds the note-banner--message variant (full-opacity description) + note-banner--error/--warning border-color override (priRd 0.6) so the invalid-link banner reads as red/abandon.

C3.e — .alert-success/.alert-warning retired in markup; the SCSS class blocks aren't referenced anywhere else in templates so they sit dormant (left in place — base form styling keeps .form-control etc. working; no need to ripple into _base.scss).

Banner JS (note.js / Brief module) was untouched in C3.b+c+d — the Brief.showBanner contract from C3.a already handles all three kinds (NOTE_UNLOCK / USER_POST / SHARE_INVITE) by reading kind off the brief; the message-banner path doesn't go through showBanner because there's no Brief row.

Tests: 218 dashboard+billboard+api ITs + 322 lyric+dashboard+billboard ITs + 2 sharing FTs + 9 my_notes FTs + 1 Jasmine FT all green. Existing lyric.test_views login message-text assertions unchanged (they pull from messages framework — not the rendered HTML — so the markup swap doesn't affect them).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:15:43 -04:00
Disco DeDisco
fa53bf561a brief sprint C3.a: Note unlock spawns Line + Brief on the user's per-category Post; banner JS consumes the new brief payload (FYI → post detail, square → my-notes) — TDD
Wires the C2 Brief model into the existing Note-unlock pipeline. Per the per-category Post model: each user has a single Post(owner=user, kind=NOTE_UNLOCK) titled by the header Line "Look! — new Note unlocked"; each unlock appends a per-event Line ("Stargazer, 5:21:00 PM") + spawns a Brief FK'd to that Line.

Server:
- billboard.Post gains a `kind` enum (NOTE_UNLOCK / USER_POST / SHARE_INVITE, default USER_POST) so the per-category Post is deterministically discoverable via (owner, kind=NOTE_UNLOCK).
- drama.Note.grant_if_new now returns (note, created, brief) — backwards-incompat tuple shape; the new third slot is None on idempotent re-grants. On a fresh grant it: get-or-creates the user's Note Unlocks Post; ensures the header Line; appends a per-event Line w. timestamp; creates a Brief(kind=NOTE_UNLOCK, owner, post, line, title=note.display_title).
- dashboard.views.sky_save's response shape flips {note: {...}|null} → {brief: {...}|null}. The new helper _brief_to_banner_dict exposes {id, kind, title, line_text, post_url, square_url, created_at}; for NOTE_UNLOCK kind, square_url = reverse('billboard:my_notes').

Banner JS (apps/dashboard/note.js):
- Module renamed Note → Brief (Note kept as an alias during the C3 sprint; will retire in C3.e). showBanner now consumes the Brief shape: title from brief.title, body from brief.line_text, time from brief.created_at, FYI href = brief.post_url, NVM dismisses, .note-banner__image is rendered as a clickable <a href=brief.square_url> when square_url is set. The CSS class names (.note-banner, .note-banner__title, etc.) stay so all existing SCSS + FT selectors keep working.
- sky.html + _applet-my-sky.html flip Note.handleSaveResponse → Brief.handleSaveResponse.

Tests:
- new IT class GrantIfNewSpawnsBriefTest (5 tests): first grant creates Post+Line+Brief, idempotent on second grant returns no brief, two different slugs share one Post, line text carries note title, post.kind == NOTE_UNLOCK. PostKindFieldTest (2 tests): default user_post + all three choices present.
- existing drama test_models.GrantIfNew tests updated to unpack the third tuple element.
- dashboard.tests.integrated.test_sky_views: 5 tests flipped from data["note"] → data["brief"] w/ the new payload shape (kind=note_unlock, title=Stargazer, line_text contains Stargazer, post_url under /billboard/post/, square_url=/billboard/my-notes/).
- NoteSpec.js (Jasmine): 12 specs rewritten for the Brief shape — SAMPLE_BRIEF carries the seven fields, T6 asserts the square is a clickable <a>, T8 asserts FYI points at brief.post_url (was hardcoded /billboard/my-notes/).
- functional_tests.test_applet_my_notes: T2 split — FYI now navigates to /billboard/post/<uuid>/ (the post detail / mark-read contract), the .note-banner__image square preserves the legacy jump direct to /billboard/my-notes/.

billboard/0003_post_kind migration auto-generated. 808 ITs + 9 my_notes FTs + 1 Jasmine FT all green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:00:01 -04:00
Disco DeDisco
7f9ff36d1d brief sprint C2: introduce billboard.Brief notification model + view_post marks-read on GET — TDD
Brief is the slide-down-banner record that connects an event (a Line freshly appended to a Post) to a user who needs to see it. It's the C3 attachment point for note-unlock + share-invite + future event sources; the banner JS (C3) reads the Brief shape to render kind-specific affordances. C2 lays the schema + the FYI-read contract; C3 hooks the senders.

Schema (billboard.Brief):
- owner FK→lyric.User (related_name='briefs') — required; whose attention this is for
- post  FK→billboard.Post (related_name='briefs') — required; where FYI navigates
- line  FK→billboard.Line (related_name='briefs', null=True) — the appended Line that triggered the Brief; nullable for share-invite-style flows where the Line write races behind the Brief
- is_unread BooleanField default=True — flips on view_post GET
- kind CharField (note_unlock | user_post | share_invite, default=user_post) — drives banner-side affordances
- title CharField (blank=True) — banner display title
- created_at DateTimeField (default=timezone.now) — Meta.ordering='-created_at'

view_post (the post-detail GET) now bulk-updates is_unread=False on every Brief where owner == request.user AND post == our_post AND is_unread=True. POST (the compose-a-new-Line path) intentionally does NOT mark read — the user is authoring, not reviewing.

Tests: BriefModelTest (7) covers defaults, kind choices include all three values, line nullability, owner+post requiredness, title field, __str__ shape. ViewPostMarksReadTest (5) covers the GET flips owner's unread Brief to read; doesn't flip other users' Briefs on the same post; doesn't flip Briefs on unrelated posts; idempotent for already-read; POST request does NOT mark read.

Auto migration billboard/0002_brief creates the table. 801-test IT regression green (789 + 12 new).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:35:46 -04:00
Disco DeDisco
d192b1522d brief sprint C1: relocate Post + Line from dashboard → billboard (no behavior change) — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
The Post/Line models always read more like billboard tenants than dashboard ones (1st-person personal vs. 2nd-person provenance feed); the upcoming Brief model needs them in the billboard namespace as the canonical surface they FK into. C1 is a pure relocation w. zero new behavior: 789 ITs + 20 sky/Post FTs green against the moved code.

- billboard.models adds Post (owner + shared_with) + Line (text + post FK), schema mirroring the legacy dashboard models 1:1; Post.get_absolute_url now reverses to `billboard:view_post`.
- billboard.forms adds LineForm + ExistingPostLineForm (moved from dashboard.forms; dashboard/forms.py removed).
- billboard.views absorbs new_post / view_post / share_post / my_posts (templates rendered from apps/billboard/post.html + my_posts.html).
- billboard.urls adds the namespaced routes: /billboard/new-post, /billboard/post/<uuid>/, /billboard/post/<uuid>/share-post, /billboard/users/<uuid>/. dashboard.urls drops the corresponding entries.
- _applet-my-posts + _applet-new-post URL refs now use the billboard: namespace; templates/apps/dashboard/{post,my_posts}.html removed.
- api/serializers + api/views + api/tests/integrated/test_views imports flip dashboard.models → billboard.models (PostSerializer / PostDetailAPI / PostLinesAPI / PostsAPI all retain identifiers — the model rename to Brief lands in C2).
- dashboard/tests/integrated/test_{models,views,forms} + dashboard/tests/unit/test_{models,forms} swap imports; test_views URL strings flip /dashboard/post/ → /billboard/post/, /dashboard/new_post → /billboard/new-post, /dashboard/users/ → /billboard/users/, share_post → share-post (path) / billboard:share_post (reverser). Tests stay in dashboard.tests/ for now — relocation TBD.
- functional_tests/my_posts_page.py URL string flips to /billboard/users/.
- Auto-generated migrations: billboard/0001_initial (CreateModel Post + Line), dashboard/0003_remove_post_* (drops legacy Post + Line), drama/0004_alter_gameevent_verb (incidental — choices field caught up).

This commit drops the dashboard Post/Line tables w/o data preservation; user has confirmed staging-side wipe is acceptable. C2 introduces the Brief model + read-tracking + slide-down banner unification. C3 hooks Note-unlock + share-post-invite + magic-link / invalid-link `messages` calls into the new Brief / banner pipeline.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:20:06 -04:00
Disco DeDisco
f659a64b91 some tweaks to portrait media query .btn-primary responsive sizing
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
2026-05-08 16:18:29 -04:00
Disco DeDisco
a319318740 sky/sea modal titles: PICK SKY/SEA → SKY/SEA SELECT (titles only — table-hex .btn-primary instances stay PICK SKY/SEA where SELECT wouldn't fit)
The in-room PICK SKY / PICK SEA overlay headers now read "SKY SELECT" / "SEA SELECT" — matches the SIG SELECT phase naming. The .btn-primary triggers in the table-hex (PICK<br>SIGS, PICK<br>SKY, PICK<br>SEA) keep their existing labels because the 4rem circular btn cap can't fit "SELECT" on a single line. No code-side renames (id_pick_sky_btn, etc. stay) — only the human-facing modal title text. 21-test sky/sea regression green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:49:58 -04:00
Disco DeDisco
d58fd1db15 small changes to .sky-field styling 2026-05-08 15:41:24 -04:00
Disco DeDisco
846f9ff461 PICK SKY DEL: server purge of seat Character + race guards stop the btn from re-injecting; readonly opacity bump (0.6 → 0.85) — TDD
Two related Sky Select bugs the old DEL flow couldn't address. (1) DEL btn lingered after a clear because an in-flight schedulePreview's .then() could resolve AFTER the OK callback ran, calling _ensureDelBtn() against a freshly-cleared wheel-col. (2) Sky data rehydrated on refresh because clicking SAVE SKY confirms a Character row on the seat — the DEL handler only purged localStorage & in-memory state, leaving the durable Character row to drive subsequent renders.

Server: new epic.sky_delete(room_id) view (POST → JsonResponse {deleted:True}) deletes every Character on the requesting gamer's seat where retired_at is null — drafts (confirmed_at NULL) and confirmed rows alike. 405 on GET, 403 for outsiders, never touches User.sky_chart_data (Dashsky/My Sky applet's DEL owns that side).

JS (_sky_overlay.html): DEL OK callback now (a) bumps a _fetchSeq counter so any in-flight schedulePreview .then()/.catch() short-circuits when its captured seq != current — kills the re-injection race; (b) clearTimeout-s _chartDebounce + _placeDebounce so a typed-just-before-DEL keystroke can't fire schedulePreview after the clear; (c) POSTs to DELETE_URL (overlay.dataset.deleteUrl wired via {% url 'epic:sky_delete' room.id %}) so the seat's Character row is dropped server-side; (d) clears LS + DOM state as before.

SCSS: .sky-field input[readonly] opacity 0.6 → 0.85, & dropped the redundant .sky-coords > div input { opacity:0.6 } that was previously winning the cascade by virtue of being declared later. The browser's default ::placeholder is ~0.54, so 0.85 × 0.54 ≈ 0.46 — close to the birth-place placeholder's ~0.54 effective opacity per the user's "appreciably higher tho not opacity 1" target. Values land at 0.85 (clearly readable but still de-emphasized vs. the editable place input).

Tests: 4 new ITs in PickSkyRenderingTest cover (a) POST clears confirmed Character, returns JSON {deleted:True}; (b) 405 on GET; (c) 403 for non-seat-owner; (d) User.sky_chart_data untouched by in-room DEL. PickSkyDelTest FT picks up an extra assertion: id_sky_delete_btn must be absent from DOM after OK (the bug-1 regression guard). 55-test sky suite green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:39:07 -04:00
Disco DeDisco
1111df8465 sky form TZ: render-readonly + drop #id_nf_tz_hint; placeholder absorbs the auto-detected hint copy — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
A user-typed TZ override fed through schedulePreview's `if (tz) params.set('tz', tz)` path made PySwiss compute the chart against a TZ that didn't match the lat/lon, so a partial edit (e.g. "America/New_Yo|") returned HTTP 400. Mirror the lat/lon convention: tz field gets readonly + tabindex:-1 across all three sky contexts (Dashsky sky.html, in-room PICK SKY _sky_overlay.html, My Sky applet _applet-my-sky.html). Auto-population still works because the JS writes via .value rather than via user input. The <small id="id_nf_tz_hint"> "Auto-detected from coordinates." line is removed; that copy now lives on the <input>'s placeholder so an empty TZ field self-explains. JS purges every tzHint reference (const declaration + 4 .textContent writes per file × 3 files).

SkyViewTest.test_tz_input_is_readonly_and_carries_auto_detect_placeholder pins the rendered Dashsky markup: id_nf_tz carries `readonly`, the placeholder is "auto-detected from coordinates", and `id="id_nf_tz_hint"` no longer appears anywhere. Existing MySkyTimezoneRefreshTest still passes — it asserts the field auto-fills via JS, which still works on a readonly input.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:27:09 -04:00
Disco DeDisco
8a8d1536b1 PICK SKY DEL btn: JS-inject after wheel paints so a blank modal carries no DEL action — TDD
Previously the DEL btn was always template-rendered inside .sky-wheel-col, which on a fresh PICK SKY modal (form pristine, schedulePreview not yet fired) put a red DEL btn floating in the empty wheel area suggesting there's something to delete when the user hasn't even seen a wheel yet. Refactored: drop the <button id="id_sky_delete_btn"> from _sky_overlay.html, lazily create it in JS via _ensureDelBtn() called from the schedulePreview success handler (right after SkyWheel.draw/redraw); the existing DEL click handler now also removes the btn from the DOM after clearing the SVG, so the next preview re-injects it. PickSkyRenderingTest.test_no_sky_delete_btn_in_blank_sky_select_modal IT asserts `id="id_sky_delete_btn"` doesn't appear in the rendered HTML for a SKY_SELECT room (the literal identifier still lives inside the inline <script> that does the injection — assertion targets the HTML-attribute-syntax form so the JS reference doesn't trip it). Existing PickSkyDelTest FT still green: it fires preview before clicking DEL, so the btn is present at click time.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 14:56:43 -04:00
Disco DeDisco
301b4e8201 applet-box: hide inner-applet scrollbars so they match the page-aperture treatment
%applet-box now applies scrollbar-width:none + *::-webkit-scrollbar{display:none} to all descendants. The My Sky applet's #id_applet_sky_form_wrap (overflow-y:auto for the form column) was rendering an OS-default white-track scrollbar that broke the dark theme — same pattern any future applet w. an inner scroll well would inherit. The page-aperture-level scrollers (gameboard / billboard / dashboard apertures) already use this hide-scrollbar pair, so this just propagates that convention down into applet sections.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 14:50:28 -04:00
Disco DeDisco
097a5dd437 my-sky applet DEL btn: shift left by half the applet's padding asymmetry so it re-aligns w. the SVG center
The applet section uses padding 0.75rem 0.75rem 0.75rem 2.5rem — the 2.5rem left gutter holds the rotated h2 — so the SVG's geometric center lands 0.875rem right of the applet's padding-box center. Absolute positioning on the DEL btn references the padding box center, which put the btn 14px left of the wheel's actual center. Compensating w. left:calc(50% + 0.875rem) on #id_applet_sky_delete_btn re-aligns the btn (verified 0px dx/dy in the live browser). The other two DEL anchors (Dashsky #id_sky_delete_form & PICK SKY #id_sky_delete_btn) live in symmetric .sky-wheel-col containers so the base 50%/50% rule still works for them.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 14:44:54 -04:00
Disco DeDisco
e9bceaab62 sky wheel: ubiquitous DEL btn — applet & PICK SKY parity w. Dashsky; PICK SKY clears client-only state (no User-model touch) — TDD
My Sky applet (.../dashboard/_partials/_applet-my-sky.html): adds <button id="id_applet_sky_delete_btn" class="btn btn-danger"> at the wheel center, gated on user.sky_chart_data. Click → window.showGuard("Forget sky?") → on OK, fetch POSTs sky_delete (clears every sky_* field on User), removes the 'sky-form:dashboard:sky' localStorage entry that would otherwise rehydrate the post-reload form via _restoreForm(), then reloads — applet's form-render branch is server-template-gated on chart_data so the page comes back form-only.

PICK SKY in-room overlay (.../gameboard/_partials/_sky_overlay.html): adds <button id="id_sky_delete_btn"> at the wheel center. The wheel here is purely a live preview — sky_save fires only on SAVE SKY click w. action='confirm', so there's no draft Character to delete & we do NOT touch the Character/User model. The DEL handler clears the SVG, resets form fields (including lat/lon/tz/tzHint), nulls _lastChartData, disables the SAVE SKY btn, & purges the LS_KEY entry that would otherwise rehydrate on next overlay open / page refresh. Mirrors the user's spec ("shouldn't be targeting the user model anyway, only the character/seat model" — and there's currently no character/seat draft in the PICK SKY flow).

Both handlers defer the window.showGuard readiness check to click-time rather than gating the listener bind itself: window.showGuard is assigned by a base.html script that lives BELOW the content block, so an `if (window.showGuard)` gate at script-execute time would skip the bind entirely (we hit this writing the applet handler — manifested as portal class never receiving 'active' on click).

SCSS: extends the existing #id_sky_delete_form absolute-center rule onto the two new btn IDs (#id_sky_delete_btn, #id_applet_sky_delete_btn). #id_applet_my_sky picks up position:relative as the absolute anchor for the applet btn.

FTs: MySkyAppletDelTest (applet → DEL → guard → OK → reload, asserts User cleared + LS purged + form re-renders) & PickSkyDelTest (overlay → fill form → wheel paints → DEL → guard → OK, asserts SVG empty + form blank + LS purged). Both red before the wiring, green after; full sky suite (46 tests) green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 14:34:41 -04:00
Disco DeDisco
283b417341 sky.html: tame iOS Firefox date/time native widgets w. appearance:none + max-width; mirror small-landscape btn-primary scaling onto portrait & narrow form-col gap on both
iOS Firefox renders <input type="date|time"> with a native widget whose intrinsic content width ignores min-width:0 alone, so the date/time pills bled past .sky-form-main while text-input siblings fit fine. Adding appearance:none + -webkit-appearance:none on the two input types drops the native chrome so width/padding/border are honored uniformly across pills (the picker still opens on tap); max-width:100% on the .sky-field input baseline is belt-&-suspenders for any other widget that ignores min-width:0.

The .btn-primary shrink-to-2.75rem rule that lived under (orientation:landscape) and (max-width:1100px) now also fires on (orientation:portrait), so SAVE SKY scales down on phone portrait aperture too. Same media envelope narrows .sky-page .sky-form-col gap from 1rem → 0.4rem so SAVE SKY tucks closer to #id_sky_status & the form fits short-aspect phones without clipping the btn against the footer.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 14:04:15 -04:00
Disco DeDisco
c8d7b055d7 sky.html: clip horizontal overflow on iOS Firefox — .sky-page overflow-x:hidden + min-width:0 on .sky-field input
iOS Firefox renders <input type="date"> & <input type="time"> with a native widget whose minimum content width can exceed .sky-form-main on narrow phones, spilling past the form column & triggering page-level horizontal scroll. Two defensive layers: (1) min-width:0 on .sky-field input lets the native widget shrink below its intrinsic content width; (2) overflow-x:hidden on .sky-page clips anything that still slips past — the aperture is the snap scroll container so this only affects the column-stack direction we don't want anyway. body.page-sky already has overflow:hidden but iOS Firefox doesn't always honor it for native form controls.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:18:30 -04:00
Disco DeDisco
9ff437012a sky.html: DEL btn at wheel center; async SAVE SKY transitions into saved state without reload; pre-save hides wheel-col so form+SAVE SKY stay centered — TDD
DEL btn (.btn-danger, "Forget sky?" data-confirm wired to the global #id_guard_portal) sits absolutely centered inside .sky-wheel-col; OK submits a POST to the new sky_delete view, which clears every sky_* field on the User model & redirects back to /dashboard/sky/.

The sky.html aperture is now uniform across saved/unsaved: form-col is always flex-column align-center justify-center so the fields + SAVE SKY pair sits visually centered. body.sky-saved adds *only* the snap-binary scroll layer (scroll-snap-type:y, modal-body display:contents, cols min-height:100% scroll-snap-align:start, wheel-col aspect-ratio cap released, form-col flex:0 0 auto so the snap basis wins) — the column-stacking is no longer gated.

Async save: SAVE SKY's success branch now calls _activateSavedState(), which adds body.sky-saved, draws the wheel from _lastChartData, pins overlay.scrollTop to the form section's offsetTop, then runs the existing _scrollApertureToTop ease-out so the wheel reveals from above instead of replacing the form with a hard cut. The wheel preview that previously redrew during typing is now gated on _savedSky — pre-first-save typing fetches the chart data (so SAVE SKY enables) but does not render the wheel, mirroring the My Sky applet's "no wheel until saved" UX. The in-room PICK SKY overlay (_sky_overlay.html) still previews live, deliberately untouched.

Pre-save the wheel-col is hidden via `body:not(.sky-saved) .sky-page .sky-wheel-col { display: none }`, so the empty SVG can't shunt the form below the fold (& the DEL btn rides the same selector since it lives inside .sky-wheel-col).

Tests: SkyDeleteTest IT class (5: clears fields, redirects, 405 on GET, login required, preserves unrelated user fields). MySkyDeleteFlowTest FT class (3: DEL btn visibility gated on sky data, NVM dismisses w. data intact, OK clears + reverts body class). MySkyAsyncSaveTest FT (1: fresh user → SAVE SKY → body picks up sky-saved, wheel SVG populates, DEL btn becomes visible — all without a page reload). All 13 sky FTs + sky ITs green; existing MySkyApertureSnapScrollTest & MySkyTimezoneRefreshTest still pass.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:07:56 -04:00
Disco DeDisco
bbd1b22bb0 sky.html post-save: reset (max-width:600px) form-main height cap & btn align-self so flex-column flip lands the SAVE SKY beneath the fields
Two carried-over rules from the @media (max-width:600px) block — written for the in-room PICK SKY modal where form-col is flex-row — collide with body.sky-saved's flex-column flip on .sky-page: (1) .sky-form-col > #id_sky_confirm{align-self:flex-end}, which means "bottom" under flex-row but "right" under flex-column, was pushing the btn to the right edge instead of centering it; (2) .sky-form-main{max-height:40vh; overflow-y:auto} clamped form-main into a tiny inner-scroll well. body.sky-saved now resets both — align-self:auto on the btn (inherits the col's align-items:center) & max-height:none + overflow-y:visible on form-main (the aperture handles scroll, not form-main).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:30:01 -04:00
Disco DeDisco
05c9f9c079 sky.html post-save: stack SAVE SKY beneath form fields & vertically center the pair, parity w. wheel
.sky-page .sky-form-col defaults to flex-row align-end (form-main left, btn bottom-right). Under body.sky-saved that pinned the SAVE SKY btn to the corner under the snap layout — fix is flex-direction:column + align-items:center + justify-content:center, gap:1rem so the btn sits a clear rem below the form-main. .sky-page .sky-form-main capped at max-width:22rem so the input pills don't stretch the full landscape-mobile aperture width.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:27:47 -04:00
Disco DeDisco
319b787109 sky.html: snap-binary aperture scroll (wheel ↔ form, full aperture each); SAVE SKY animates scrollTop back to 0 — TDD
post-save the .sky-page aperture flips into scroll-snap-y-mandatory mode: wheel-col & form-col each fill the aperture & carry scroll-snap-align:start, so vertical scroll toggles between them rather than free-flowing through both. Modal-body uses display:contents so the cols become direct flex children of .sky-page (where min-height:100% resolves against the explicit aperture height); wheel-col's aspect-ratio/max-height caps are released under body.sky-saved so the section actually fills the aperture instead of clipping at 480px. SAVE SKY's success branch calls _scrollApertureToTop(), a 280ms RAF loop w. ease-out cubic so the user lands back on the wheel after confirming from the form section. New FT class MySkyApertureSnapScrollTest covers (T1) snap-type:y mandatory + scroll-snap-align:start on both cols, (T2) scrollTop returns to 0 after SAVE SKY click; both red before the SCSS+JS, green after. Snap behavior is gated on body.sky-saved (set by sky_view based on user.sky_chart_data) so the pre-save form-only flow is untouched.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:24:11 -04:00
Disco DeDisco
3beedc3f0a sky form: flip label margins so each label hugs its own input below; zero geo-btn vertical margin so birth place doesn't drift
.sky-field gap goes 0.25rem → 0 so label sits flush above its own input. Field-to-field spacing moves into a `& + & { margin-top: 0.4rem }` rule; small explanation text gets margin-top:0.2rem so it stays separated from the input it annotates. .sky-coords inner column gap zeroed for parity. .sky-place-wrap zeroes the geo button's inherited 4px top/bottom margin from .btn — without that, the wrap was 40px tall (vs 33px input) and align-items:center pushed the place input 4px below its label, leaving birth place as the lone field with a visible label-input gap.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:00:03 -04:00
Disco DeDisco
9e68cfd8e4 sky form inputs: form-control look (gold border, pill, page-bg fill, focus glow); login email label matches sky-field label style
.sky-field input now mirrors .form-control's priUser fill / secUser border & text / pill border-radius / terUser focus glow — same look as the login email input. Readonly inputs (lat/lon) keep opacity:0.6. .input-group label (the "enter email for login" line above the email input) now adopts .sky-field label styling: 0.6rem uppercase, 0.1em letter-spacing, quaUser at 0.8 — so the login form's label/input pair reads as the same component as the sky-field rows.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:53:05 -04:00
Disco DeDisco
4f2c7d9577 sky form: clear timezone field on new place pick so TZ auto-redetects from coords — TDD
selectPlace + geolocation now zero out tzInput.value & tzHint before schedulePreview, so the existing `if (!tzInput.value && data.timezone)` backfill in schedulePreview's success path actually fires when the user changes location after a saved sky exists. Without the clear, the saved/previous TZ stayed pinned & the chart was recomputed against the wrong timezone. Fix mirrored across sky.html (Dashsky), _applet-my-sky.html (My Sky applet entry form), and _sky_overlay.html (PICK SKY in-room overlay). New FT MySkyTimezoneRefreshTest.test_changing_place_refreshes_auto_detected_timezone seeds a user w. saved Baltimore/America/New_York, mocks Nominatim + sky/preview to return Camarillo + America/Los_Angeles, picks the suggestion, and asserts the TZ field updates.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:42:14 -04:00
Disco DeDisco
cc2a3f3526 rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
Disco DeDisco
19b7828ea9 ignore .vscode/ in git & docker contexts; workspace tasks/settings are local-only (Windows paths + per-machine venv layout)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 18:17:54 -04:00
Disco DeDisco
a97cd8dcff test_clicking_pick_sea_btn_opens_sea_overlay: poll click+sea-open assertion to absorb DOM-vs-script race on slow CI — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
The PICK SEA btn parses into the DOM before the inline `<script>` at the bottom of _sea_overlay.html binds `openSea` to it; on fast hosts the gap is invisible but the two-browser CI stage was clicking ahead of the binding and the handler never fired. Wrapping the click + sea-open class assertion in wait_for retries the click until the handler has bound.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 02:23:47 -04:00
Disco DeDisco
c9563308d8 SAVE SKY provenance + sky→hex (not sky→sea) transition — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- drama.GameEvent.SKY_SAVED verb + to_prose branch: "X beholds the skyscape of {poss} birth, which yields {obj} a unique {Cap} capacity."; tied highest scores switch "a unique" → "equal", join w. "and" (2-way) or Oxford comma (3+), and pluralize "capacity" → "capacities"; pronouns resolved from actor.pronouns at render time, same machinery as SIG_READY/ROLE_SELECTED
- epic.utils.ELEMENT_CAPACITOR_NAMES + ELEMENT_ORDER + top_capacitors(elements) helper: maps Fire→Ardor Stone→Ossum Time→Tempo Space→Nexus Air→Pneuma Water→Humor; tolerates both flat-int and enriched-dict (`{count, contributors}`) chart_data shapes; returns capacitor names tied for highest count, ordered by canonical wheel ring
- epic.natus_save: on action=confirm, records GameEvent.SKY_SAVED w. top_capacitors=[…] before _notify_sky_confirmed; per-room billscroll AND billboard Most Recent Scroll pick up the new prose
- _natus_overlay.html _onSkyConfirmed: removed sea-partial fetch+inject; now calls closeNatus() + window.location.reload() so the gamer lands on the table hex w. the PICK SKY → PICK SEA btn swap (server-side, driven by sky_confirmed=True), then opts into the sea overlay manually. The auto-launch via 39e12d6 was buried by FTs that were pinning the wrong contract — gamer never had a chance to witness PICK SEA on the hex
- test_room_sea_select.py: three FTs renamed/rewired from auto-launch assertions (sea_overlay_appears_without_page_refresh, natus_overlay_not_visible_after_sky_confirm, sea_open_class_on_html_after_confirm) to (pick_sea_btn_visible_after_sky_confirm, natus_overlay_closed_after_sky_confirm, clicking_pick_sea_btn_opens_sea_overlay) — sea overlay now requires explicit PICK SEA click

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:57:35 -04:00
Disco DeDisco
5413e63585 billboard Most Recent Scroll: fix SQLite NULL drop on SIG_READY exclude; pronouns flow FT; Blades middle reversal Nervous → Fickle — TDD
- billboard/views.py _billboard_context: `.exclude(verb=SIG_READY, data__retracted=True)` was silently dropping every SIG_READY event whose data had no `retracted` key — `WHERE NOT (NULL AND verb='sig_ready')` evaluates to NULL via JSON_EXTRACT, which the SQL engine treats as "row not satisfying WHERE", so the row was excluded. Fix: pull a 100-row buffer w. only the SIG_UNREADY exclude at the SQL level, then post-filter retracted SIG_READY in Python before slicing to 36; PostgreSQL handles the lookup correctly so this is a SQLite-only manifestation that explained intermittent "No events yet" in Most Recent Scroll
- CLAUDE.md gotchas: new entry warning that `.exclude(data__key=value)` / `.filter(data__key=value)` on SQLite JSONField bites on missing keys; if the predicate must require key existence, post-filter in Python
- functional_tests/test_game_kit.py PronounsAppletFlowTest: end-to-end profile-wide pronoun flip — start on per-room billscroll seeing "their" cognates, navigate to Game Kit, click bawlmorese card, assert guard portal active w. "yo/yo/yos" preview, click OK, navigate to billboard + see Most Recent Scroll re-rendered w. "yos", navigate back to billscroll + see same flip; covers the whole render-time-pronoun-resolution path on real DOM
- epic/0008_blades_reversal_fickle.py: rename Middle Arcana Blades reversal_qualifier "Nervous" → "Fickle" (RunPython forward+reverse on arcana=MIDDLE, suit=BLADES, number ∈ {11,12,13,14}); SigSelectSpec.js hardcoded "Nervous" updated to "Fickle" + collected static

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:27:17 -04:00
Disco DeDisco
29493c4f74 pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
Disco DeDisco
599d40decd auth urls: mount apps.lyric.urls under /dashboard/ to mirror gameboard/epic & billboard/drama convention
- core/urls.py: replace `path('lyric/', …)` with second `path('dashboard/', include('apps.lyric.urls'))` alongside existing dashboard mount; no path-name collision (lyric paths: send_login_email, login, logout, dev-login/<key>/)
- IT test URL strings flipped /lyric/ → /dashboard/ (test_views.py)
- setup_sig_session + setup_sea_session pre-auth URL builders updated
- CLAUDE.md doc note updated
- Templates use unnamespaced `{% url 'logout' %}` / `{% url 'send_login_email' %}` so they auto-resolve; no template edits needed
- /admin/lyric/user/ admin URL untouched (driven by app_label, not URL conf)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 00:18:36 -04:00
Disco DeDisco
2dc68c41a7 billboard applets: drop billboard- prefix from partials & ids; Most Recent → Most Recent Scroll; room_scroll → scroll — TDD
- Slug renames (mig 0004): billboard-my-scrolls → my-scrolls; billboard-my-contacts → my-contacts; billboard-most-recent → most-recent-scroll (name → "Most Recent Scroll"); billboard-notes → notes
- Partial filenames lose the `billboard-` token to mirror dashboard/gameboard convention; element ids follow (id_applet_my_scrolls, id_applet_my_contacts, id_applet_most_recent_scroll, id_applet_notes, id_applet_scroll); .applet-billboard-scroll → .applet-scroll
- View fn billboard.views.room_scroll → scroll; template apps/billboard/room_scroll.html → scroll.html (URL name `billboard:scroll` already correct)
- ITs + FTs updated to new identifiers; SCSS selectors retitled

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 23:22:01 -04:00
Disco DeDisco
b1a11504f5 game kit + role icons + tarot fan: in-use mini-portal label, FLIP cue polarity reset, role icon redraws
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Heterogeneous pre-existing changes (carried across multiple sessions, finally committed alongside the SIG SELECT exit sprint). Grouped:

- gameboard.js: _inUseLabel(roomName) — buildMiniContent renders "In-Use: <name>" on hover (cap 24 chars; overflow → 21 + "…"). Reads token.dataset.inUseRoomName for decks & token.dataset.currentRoomName for trinkets.
- _applet-game-kit.html: removes the inline <p class="tt-token-room-name"> + <p class="tt-deck-game-name"> paragraphs (now redundant — mini-portal carries the name); deck token gains data-in-use-room-name attr.
- gameboard tests: assertions retargeted at data-in-use-room-name + the mini-portal flow rather than the deleted inline paragraphs (test_views, test_deck_contribution, test_trinket_carte_blanche).

- game-kit.js: openFan + _testOpen reset _polarity = 'levity' so reopening the fan after FLIP-to-gravity always lands on the levity-painted face (the FLIP cue). The sessionStorage bookmark intentionally tracks card index only; polarity does NOT persist across reopen.
- _tarot_fan.html: SSR-default polarity flipped from levity to gravity (levity_emanation → gravity_emanation, levity_qualifier → gravity_qualifier, levity_reversal → gravity_reversal across upright + reversal faces). Pairs w. the JS polarity reset above so JS repaints to levity on open.
- FanStageSpec: 2 new specs — openFan polarity reset on reopen even after FLIP-to-gravity; sessionStorage stores no levity/gravity string.

- starter-role-*.svg (Alchemist, Builder, Economist, Narrator, Player, Shepherd): redrawn / re-cropped art — viewBox tightened from 288×560 to ~154×156, paths re-traced. No new role added; existing 6 swapped in place. New starter-role-blank.svg added as fallback for unmapped role codes (referenced by tray.js _ROLE_SCRAWL default → 'Blank').

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:28:32 -04:00
Disco DeDisco
f78177778f SIG SELECT exit: 2s hang after tray closes; suppress waiting msg if PICK SKY already up — TDD
- polarity_room_done handler in sig-select.js wraps Tray.placeSig's callback in setTimeout(_settle, 2000) so the user gets a beat to register the tray closing before the overlay vanishes. Visual order now: stage → tray slides in → sig fades into the tray cell → tray slides out → 2s pause → overlay dismisses → table hex.
- _showWaitingMsg early-exits if #id_pick_sky_btn is already revealed. The cross-polarity case — the OTHER room finishes WHILE this gamer's tray sequence is mid-flight — fires pick_sky_available during the hang, which removes any waiting msg & shows PICK SKY. When _settle fires after the hang, the PICK SKY check skips the now-stale waiting msg.
- 2 SigSelectSpec specs: 2s delay before overlay dismisses; waiting msg suppressed when pick_sky_btn is visible. jasmine.clock() drives the setTimeout in both.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:11:07 -04:00
Disco DeDisco
480cb4aed6 tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.

- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
  .tray-cell in place (sig slot), copies aria-label / data-energies /
  data-operations / corner-rank + suit-icon markup from the source
  .sig-stage-card, then runs the shared open → fade-in → close sequence.
  Extracted _runFadeInSequence helper so placeCard + placeSig share the
  same animation glue. reset() now also clears .tray-sig-card from cells.

- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
  existing tray-role-fade-in keyframes.

- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
  _settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
  Falls back to immediate dismiss when Tray is undefined (test environments
  without the tray).

- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
  (incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
  spec descriptions + assertions, & test_room_role_select.py docstrings.
  The original "arc-in" name suggested a curved-path animation; the actual
  behaviour is a 1s opacity fade, so fade-in is the accurate label.

- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
  data + markup copy, tabIndex, fade-in class, animationend-triggered close,
  onComplete callback, landscape parity, reset cleanup).

- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
  polarity; not called on other polarity; overlay dismiss deferred to the
  Tray.placeSig completion callback).

344 specs / 4 pending green; RoleSelectTrayTest FT still green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
Disco DeDisco
9b93b9d31b tray tooltips: tilt persists while portal is open; PRV|NXT pinned to corners — TDD
- TrayTooltip adds .tt-active to the .tray-role-card / .tray-sig-card cell while its tooltip is open & removes it on _hide. The hover-tilt selectors gain .tt-active alongside :hover, :focus so the card stays tilted while the user is hovering the portal itself rather than the cell.
- #id_tooltip_portal: .fyi-prev / .fyi-next pinned to the bottom corners w. 1rem outside the panel (bottom: -1rem; left/right: -1rem) — same anchor the @stat-block-shared mixin uses for fan / sig / sea, restated here since the portal isn't covered by that mixin.
- 2 new TrayTooltipSpec specs (.tt-active added on hover, removed on _hide; for both role & sig branches).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:18:09 -04:00
Disco DeDisco
b29bcf5c38 tray sig-card tooltip: portal w. PRV|NXT pager — TDD
Phase 2 of the apps.tooltips integration on the tray. Hovering
.tray-sig-card > .sig-stage-card opens #id_tooltip_portal w. an FYI panel
that mirrors #id_fan_fyi_panel (Energy / Operation entries cycled via
PRV|NXT), but w.o. the stage block, w.o. Reversal entries, & w.o. the fan
stage's click-to-dismiss handler — the panel-body click is reserved for
future drag-and-drop on .tray-sig-card:active.

- _partials/_sig_fyi_panel.html — new partial, the .sig-info + PRV|NXT
  block extracted out of game_kit.html, _sig_select_overlay.html, &
  _sea_overlay.html. {% include %}d back from those 3 callers; pure
  copy-paste extraction (no behavioural change to fan stage, sig select,
  or sea select).
- room.html: .tray-sig-card > .sig-stage-card gains data-energies +
  data-operations (the only attrs StageCard.buildInfoData reads), keyed
  off my_tray_sig.energies_json / .operations_json (existing TarotCard
  properties).
- tray-tooltip.js: new sig branch — _showSig() builds the panel inline,
  paints via StageCard.renderFyi, & wires PRV|NXT cycle handlers; the
  mousemove union now covers the .fyi-prev / .fyi-next btn rects (the
  btns hang past the portal's left & right edges) so mouse-over them
  keeps the panel alive. Click stopPropagation on the btns prevents the
  panel-body click from reaching anything else.
- TrayTooltipSpec: 6 new sig-branch specs (panel structure; first energy
  entry rendered; PRV|NXT cycling; body click no-dismiss; pointer over
  btn rects keeps panel alive; pointer outside full union clears).
- test_component_tray_tooltip.py: 4 sig FTs (hover populates portal w.
  Energy/TESTLIBIDO/effect/1-of-2; PRV|NXT cycle; body click does NOT
  dismiss; mouseleave clears).

FT helper note — the sig FT's _hover dispatches a synthetic mouseenter
via JS rather than ActionChains.move_to_element, because the role-card
& sig-card cells sit side-by-side in the tray grid: the pointer's
animated path crosses the role-card on its way to the sig-card &
opens the role tooltip mid-flight, which then occludes the sig stage
by the time the move lands. Direct dispatch lands the event on the
intended trigger w.o. the cross-cell drag-by.

313 epic ITs + 335 Jasmine specs (incl. 6 new) + 6 tray-tooltip FTs all
green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:07:33 -04:00
Disco DeDisco
08243d109d tray cards: shadow, hover-tilt w. focus persistence, role-card tooltip — TDD
- _tray.scss: drop-shadow on cell child elements (img → filter:drop-shadow so the silhouette is the shadow caster, div → box-shadow); 7° hover-tilt on .tray-role-card > img (-7°) and .tray-sig-card > .sig-stage-card (+7° via the standalone `rotate` property so the existing -5° baseline transform composes); :focus persists the tilt after click; cursor: pointer
- tray.js: set tabIndex=0 on placeCard's role cell + on template-rendered .tray-role-card / .tray-sig-card cells at init() so :focus latches the hover state; clear tabindex in reset() for Jasmine afterEach
- TraySpec: 4 new specs covering placeCard tabindex, reset cleanup, init-time tabindex on template-rendered sig & role cards, no-tabindex on bare cells
- New tray-tooltip.js (#id_tooltip_portal) — Phase 1 of the apps.tooltips integration: hovering .tray-role-card > img copies its sibling .tt's innerHTML into the page-root portal, anchors above/below the trigger, & clamps to the viewport horizontally; mousemove outside the union of [trigger, portal] rects clears the portal (Game-Kit pattern, no btns)
- room.html: #id_tooltip_portal mounted at room-page root (outside tray's overflow:hidden); .tt block rendered inline inside .tray-role-card via {% tooltip %} templatetag w. title=role display name & description="[Placeholder description]"
- epic/views.py: my_tray_role_tooltip context dict ({title, description}) keyed off the seated role
- TrayTooltipSpec: 8 specs covering portal population, .active class, sibling-.tt fallback, viewport-edge clamp left/right, and union-rect mouseleave
- 2 FTs in test_component_tray_tooltip.py: hover role img → portal title=Player + description=Placeholder; mouseleave → portal clears

Phase 2 (sig-card tooltip mirroring #id_fan_fyi_panel via a DRY refactor) deferred per plan.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:40:10 -04:00
Disco DeDisco
75fcc5b34d billboard applets: single-root wrapper for HTMX swap; full context on toggle — TDD
- _applets.html wraps menu + container in one #id_billboard_applets_wrapper div; form's hx-target is now the wrapper, so OK no longer leaves a stale duplicate menu in the DOM (which previously caused the next OK to revert prior toggles)
- toggle_billboard_applets passes full context (recent_room, recent_events, viewer, my_rooms) via factored _billboard_context helper, so Most Recent + My Scrolls keep their content after a toggle instead of falling through to the empty fallback
- applets.js: register id_billboard_applets_wrapper as an applet container so post-swap menu cleanup runs
- BillboardAppletsTest: portrait viewport in setUp; FT covers content preservation, no-revert on second toggle, & post-refresh state
- 4 new ITs: Most Recent renders Coin-on-a-String after toggle; My Scrolls renders room name; response has single menu div; second toggle preserves prior hidden state

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:15:26 -04:00
Disco DeDisco
536a558f26 PICK SEA: gate PRV|NXT on .fyi-open like sig + fan
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
stale "always visible" override in .sea-stat-block was leftover from before
the FYI panel became toggle-able. sea.js already adds/removes .fyi-open
on _openInfo/_closeInfo, so SCSS-only fix.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:36:50 -04:00
Disco DeDisco
8b0ad545c9 collapse epic migrations 0007–0022 → 0007_finalize_earthman_deck; add reset_staging_db
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- 16 incremental Earthman tweak migrations folded into one end-state finalize
  migration (rename mechanisms→energies / articulations→operations /
  reversal→reversal_qualifier; +italic_word; suit court reversals; Schizo
  energies+operations; card 49 polarity reversal titles; Castanedan Virtues
  trumps 6–9 + 19–21; trump 8 U+2011 hyphen; trump 9 U+00A0 nbsp; pips → MINOR)
- 22 epic migrations → 7; 748 ITs green
- new mgmt cmd `reset_staging_db` — drops schema (Postgres) / tables (sqlite)
  & re-runs migrate; refuses on prod hosts; needs `--i-mean-it` when DEBUG=False;
  interactive host-name confirmation locally; calls ensure_superuser after

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:22:43 -04:00
Disco DeDisco
3410f073f0 fan-card title symmetry; pips → Minor; tray Sig card
- title slot: <h3> → <p>; font-size 0.1 → 0.087 (deck) / 0.093 → 0.08 (sig/sea); text-wrap: balance — kills upright/reversal asymmetry & all per-card squeeze hacks
- trump 8 hyphen → U+2011, trump 9 space → U+00A0 (mig 0021) so titles wrap as intended
- pips (Earthman 1–10) → MINOR arcana (mig 0022); StageCard._arcanaDisplay() picks the right label
- PICK SEA: re-clicking a deposited slot now restores the server-rolled reversed state (sea.js _populate toggle)
- tray Sig card: render same .sig-stage-card.sea-sig-card (rank + icon, -5deg) as Sea center; --sig-card-w sized off --tray-cell-size
- title_squeeze_class kept as no-op for template compat
- 0020 (Self-Unimportance rename) included from prior turn

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:06:55 -04:00
Disco DeDisco
c264b6e3ee PICK SEA reversal axis: server-side roll + preview + deposited slot — TDD
- new apps/epic/utils.STACK_REVERSAL_PROBABILITY (=0.25) + stack_reversal_probability(user, room) helper; single source of truth across game phases & one-line swap point for forthcoming per-user-profile config
- sea_deck view rolls each card's `reversed` axis at fetch time using the helper, attaches to card JSON; matches the eager shuffle pattern (whole deal determined at phase start)
- room_view + sea_partial pass `stack_reversal_pct` into context for the new <p class="sea-reversal-hint">25% reversals</p> hint above the SPREAD combobox (italic, 0.7rem, 0.55 opacity)
- SeaDeal.openStage applies .stage-card--reversed + .is-reversed to stat block when card.reversed → preview lands face-reversed w. REVERSAL keywords
- _fillSlot adds .sea-card-slot--reversed → slot itself rotates 180° (bg + border + content stack flips, not just inner chars upside-down in place); .sea-pos-cross overrides to 270° to compose w. its existing 90°
- _fillSlot adds .sea-card-slot--rank-long when corner_rank.length ≥ 5 (XVIII / XXIII / XXVIII / XXXIII / XXXVIII / XLIII / XLVIII) → SCSS scaleX(0.7) + letter-spacing -0.05em squeezes horizontally w.o changing font-size

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 00:11:40 -04:00
Disco DeDisco
da57106d7a castanedan virtues + card 49 tweak; italic_word for trumps 19–21; sig/sea propagation — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- migration 0016: card 49 gravity_reversal All-Bestowing → Bestowing
- migration 0017: implicit virtues (trumps 6–9) Sublimating/Sedimentary qualifiers + shared reversals (Indulged Folly / Indulgent Doing / Self-Indulgence / Indulging Personal History); explicit virtues (trumps 19–21) full-string emanation/reversal overrides (The Hunter's/Sleeper's/Quarry's etc.); canonicalize trump 7 name "Not Doing" → "Not-Doing"
- migrations 0018+0019: TarotCard.italic_word field; populated for trumps 19–21 (Stalking / Dreaming / Intent)
- _tarot_fan.html: data-italic-word + |italicize:card.italic_word filter applied to all rendered title slots
- new templatetags/tarot_filters.py: italicize(text, word) — escape-safe <em> wrapping
- StageCard JS: parse data-italic-word; new _escape / _italicize / _setTitle helpers wrap matching word in <em> via innerHTML when present (textContent otherwise)
- views.py _card_dict: include polarity-split overrides + italic_word so Sea Select stage gets them via fetch JSON
- _sig_select_overlay.html: emit the five new data-* attrs on sig-card markup so Sig Select stage picks them up via StageCard.fromDataset

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 23:36:35 -04:00
Disco DeDisco
270e48ab2c cards 48–49 polarity-split titles; sea-stage mobile breakpoints; @comment fix — TDD
- migration 0015 fills card 49 levity_reversal=The Vibrational Mould of Man, gravity_reversal=The All-Bestowing Eagle (card 48 already seeded in 0004)
- _tarot_fan.html: 4 new data-* attrs (data-levity-emanation / data-gravity-emanation / data-levity-reversal / data-gravity-reversal); upright + reversal slots render full polarity-split title in name slot when set, qualifier slots blank
- StageCard.fromDataset: parse the 4 new attrs; populateCard: emanationOverride / reversalOverride per polarity bypasses the standard name+qualifier rendering
- model: emanation_for / reversal_for fall back to name_title (group prefix stripped) instead of full self.name; reversal_for uses self.reversal_qualifier (was leftover self.reversal post-rename)
- sea-stage-content: --sig-card-w lifted from inline style to SCSS w. portrait ≤480px / landscape ≤500h breakpoints both stepping to 130px (mirrors fan modal triggers); default 180px
- _tarot_fan.html: rewrite multi-line {# #} that rendered as page text into {% comment %}{% endcomment %}

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:51:23 -04:00
Disco DeDisco
2f039559e6 Game Kit fan stage + FLIP/SPIN; sig/sea/fan refactor — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- fan modal: stage block w. idle-reveal/careen-out; carousel shifts left so focused card sits left-of-center; SPIN rotates whole card via Element.animate(); FLIP toggles polarity (Levity ↔ Gravity) via perspective rotateY w. mid-flip repaint; SPIN state retained across FLIP; FLIP btn hover-revealed only when focused card or btn is hovered (:has)
- mobile breakpoints: --fan-card-w / --fan-card-h / --fan-stage-shift / --fan-carousel-step lifted to CSS vars on .tarot-fan-wrap; portrait ≤ 480px @ 150×230, landscape ≤ 500h @ 150×235; corners + face text/padding scale w. card width
- shared StageCard JS module (apps/epic/stage-card.js): fromDataset, populateCard, populateKeywords, buildInfoData, renderFyi — sig/sea/fan all delegate; ~150 lines de-duplicated
- shared @mixin stat-block-shared (SCSS) lifts duplicated stat-face / stat-keywords / sig-info rules; @mixin stage-card-polarity unifies sea-stage--levity/--gravity + fan[data-polarity] coloring
- model rename: TarotCard.reversal → reversal_qualifier (migration 0014); render-time fallback to current polarity's qualifier when blank
- class unification: .sig-info-open / .sea-info-open / .fyi-open → .fyi-open (on stat block); .sig-flip-btn / .sea-spin-btn / .fan-spin-btn → .spin-btn; same for .fyi-btn / .fyi-prev / .fyi-next
- custom combobox (apps/epic/combobox.js) replaces native <select> for PICK SEA spread picker — keyboard nav, click-outside-close, aria roles; Firefox/Chrome OS-rendered <option> ignored CSS
- Jasmine: FanStageSpec.js w. idle-reveal / population / SPIN / FYI / FLIP specs; sig + sea fixtures + IT view assertions updated for renamed classes
- 748 ITs + Jasmine green

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:01:52 -04:00
Disco DeDisco
61162e36da fan-card-corner: icon outer-edge aligned; padding-left + fan-only padding-top
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- i { align-self: flex-start } — icon sits at outer card edge regardless of
  rank width; --br rotate(180deg) maps flex-start to visual right/outer edge
- padding-left: 0.5rem on .fan-card-corner — breathing room for rank + icon
- .fan-card .fan-card-corner { padding-top: 0.25rem } — top breathing room
  scoped to game kit fan only; stage cards (sea, sig) unaffected

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:00:37 -04:00
Disco DeDisco
26a3af21fa PICK SEA crucifix grid: rename CSS position classes + remove dead code
Stage 1 — free 'cross':
- sea-cross-cell → sea-crucifix-cell (template, Jasmine fixture, SCSS)

Stage 2 — semantic position names:
- sea-pos-past → sea-pos-leave; grid-area: past → leave
- sea-pos-center → sea-pos-core; grid-area: center → core
- sea-pos-future → sea-pos-loom; grid-area: future → loom
- sea-pos-root → sea-pos-lay; grid-area: root → lay
- grid-template-areas updated to match
- sea-pos-crossing removed (dead code — no element ever carried it)

Jasmine + 35 ITs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:19:30 -04:00
Disco DeDisco
d728900c24 fix Nomad icon fa-hat-cowboy → fa-hat-cowboy-side; setup_sea_session command
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- migration 0011 ICONS dict corrected for fresh installs
- migration 0013 data fix for existing DBs (filters on old value to be safe)
- TarotCardSuitIconTest updated to assert fa-hat-cowboy-side
- setup_sea_session: single-gamer PICK SEA dev session w. pre-auth URL

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 12:07:41 -04:00
Disco DeDisco
2dae861f30 tweaked box-shadow attr on active card in PICK SEA stage 2026-04-29 11:49:26 -04:00
Disco DeDisco
98354fd27b PICK SEA slot interaction: cover/cross appear animation; focused glow; card bg fully opaque
- cover/cross card slots animate 0→1→resting opacity (2s) on deposit
- cover rests at 0.3; cross rests at 0.15; hover reveals to 1
- first-tap focus adds diffuse --ninUser + tight black box-shadow glow
- second tap removes --focused before _showStage() — glow dismisses as modal opens
- levity/gravity card backgrounds bumped to rgba alpha 1 (were 0.85)
- box-shadow 0.15s ease added to --visible transition for smooth glow out

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 11:47:50 -04:00
552 changed files with 46076 additions and 4743 deletions

View File

@@ -1,2 +1,3 @@
src/db.sqlite3 src/db.sqlite3
.claude .claude
.vscode

3
.gitignore vendored
View File

@@ -4,6 +4,9 @@
### Claude ### ### Claude ###
.claude .claude
### VS Code ###
.vscode
### Django ### ### Django ###
*.log *.log
*.pot *.pot

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Usage: bash .woodpecker/_retry_failed.sh <test command args...>
#
# Runs `python manage.py test "$@"`. If any tests fail/error, parses the
# failure labels out of stdout and re-runs ONLY those tests — so a single
# Selenium flake at test 90/93 costs ~22s on retry instead of the full
# 35-minute step.
#
# Django's unittest-based runner prints failures in a predictable shape:
#
# ERROR: test_method (full.dotted.path.TestClass.test_method)
# FAIL: test_method (full.dotted.path.TestClass.test_method)
#
# The dotted path inside the parens is exactly what `manage.py test`
# accepts as a label. We grep for those lines + re-run that list.
#
# Exit semantics:
# - First run green → exit 0, no retry.
# - First run failed AND label parse found nothing (crashed before any
# test reported, e.g. ImportError) → propagate first-run exit code,
# no retry. Genuine infra problems shouldn't be silently re-run.
# - First run failed AND labels parsed → retry just those; exit with
# the retry's exit code. A real (not-flaky) regression fails twice
# → step still red, with the focused retry log as the authoritative
# report (no need to scroll past the noisy first-run output).
#
# Run from inside `src/` (Woodpecker preserves cwd across `commands:`,
# so the upstream `cd ./src` carries through).
set +e # do NOT bail on first failure; we WANT to handle it
LOG=$(mktemp -t ft-retry.XXXXXX.log)
trap 'rm -f "$LOG"' EXIT
echo "──── First run ────"
python manage.py test "$@" 2>&1 | tee "$LOG"
FIRST=${PIPESTATUS[0]}
if [ "$FIRST" -eq 0 ]; then
exit 0
fi
# Parse failure labels. Match both FAIL: and ERROR: lines; the dotted
# path lives inside the trailing parens. `sort -u` dedupes if a single
# test produces multiple lines (rare but possible).
FAILED=$(grep -E '^(FAIL|ERROR): ' "$LOG" \
| sed -E 's/^.*\(([^)]+)\)[^()]*$/\1/' \
| sort -u \
| tr '\n' ' ')
if [ -z "$FAILED" ]; then
echo "──── First run failed, but no FAIL/ERROR labels parseable ────"
echo "──── Not retrying — likely an infra problem, not a test flake ────"
exit "$FIRST"
fi
NUM=$(echo "$FAILED" | wc -w | tr -d ' ')
echo ""
echo "──── Retry ($NUM failed test(s) from first run) ────"
echo "$FAILED" | tr ' ' '\n' | sed 's/^/ /'
echo "─────────────────────────────────────────────────────"
echo ""
python manage.py test $FAILED

View File

@@ -11,13 +11,21 @@ services:
steps: steps:
- name: test-UTs-n-ITs - name: test-UTs-n-ITs
image: python:3.13-slim image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
environment: environment:
DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test
CELERY_BROKER_URL: redis://redis:6379/0 CELERY_BROKER_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/1 REDIS_URL: redis://redis:6379/1
PIP_CACHE_DIR: .pip-cache
commands: commands:
- pip install -r requirements.txt # `requirements.dev.txt` is the pinned superset Dockerfile.ci pre-
# installs; pinning here means pip skips resolver+download and just
# verifies "already satisfied" (~5-10s) instead of resolving unpinned
# requirements.txt against PyPI from scratch (~30-60s). Drift safety
# net: if requirements.dev.txt has changed since the CI image was
# last rebuilt + pushed, pip installs the delta — slower for that
# run but never broken. See TDD SKILL.md § CI dependency discipline.
- pip install -r requirements.dev.txt
- cd ./src - cd ./src
- python manage.py test apps - python manage.py test apps
when: when:
@@ -29,6 +37,8 @@ steps:
- name: test-two-browser-FTs - name: test-two-browser-FTs
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
depends_on:
- test-UTs-n-ITs
environment: environment:
HEADLESS: 1 HEADLESS: 1
CELERY_BROKER_URL: redis://redis:6379/0 CELERY_BROKER_URL: redis://redis:6379/0
@@ -37,13 +47,25 @@ steps:
from_secret: stripe_secret_key from_secret: stripe_secret_key
STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY:
from_secret: stripe_publishable_key from_secret: stripe_publishable_key
PIP_CACHE_DIR: .pip-cache
commands: commands:
- pip install -r requirements.txt - pip install -r requirements.dev.txt
- cd ./src - cd ./src
# Also collectstatic'd here; output sits in the shared workspace so
# the downstream FT steps don't have to repeat it.
- python manage.py collectstatic --noinput - python manage.py collectstatic --noinput
- python manage.py test functional_tests --tag=two-browser # All three tag-stages run through `_retry_failed.sh` so a single
- python manage.py test functional_tests --tag=sequential # browsing-context-discarded / NoSuchWindow flake on a multi-browser
- python manage.py test functional_tests --tag=channels # channels FT (typically the LAST test in the suite, when Firefox
# has accumulated memory pressure from 21 prior browser launches)
# costs ~30s on retry instead of failing the whole step. Matches
# the retry posture of test-FTs-room + test-FTs-non-room. First-
# run-green still exits 0 immediately — no overhead in the happy
# path. First-run-crash w. no parseable labels propagates the
# original exit (genuine infra problems aren't masked).
- bash ../.woodpecker/_retry_failed.sh functional_tests --tag=two-browser
- bash ../.woodpecker/_retry_failed.sh functional_tests --tag=sequential
- bash ../.woodpecker/_retry_failed.sh functional_tests --tag=channels
when: when:
- event: push - event: push
path: path:
@@ -51,21 +73,104 @@ steps:
- "requirements.txt" - "requirements.txt"
- ".woodpecker/main.yaml" - ".woodpecker/main.yaml"
- name: test-FTs # ── FT split (stage-parallel, intra-stage sequential) ─────────────────
#
# test_game_room_* is the heaviest cluster — 9 Selenium-driven room-flow
# FTs that historically dominate the FT step wall-clock (~70% of the
# ~40-min single-step runs). Split off into its own step (`test-FTs-room`)
# so the partition is visible in the pipeline view; the non-room bucket
# is `test-FTs-non-room`. Both depend on test-two-browser-FTs only, so
# they fan out + run concurrently.
#
# The previous SQLite-collision blocker (pipeline #296: second step
# started against the first step's half-created `src/test_db.sqlite3`
# → Django interactive prompt → EOFError under non-interactive CI
# stdin) is resolved by giving each step a distinct `DATABASE_URL`
# pointing at its own sqlite file under /tmp — outside the shared
# workspace mount so the two stages can't see each other's DB.
#
# `--parallel` is dropped from both steps. Empirically (pipelines
# #302-304) it was giving ~1-1.5x speedup at most on these Selenium
# FTs — Firefox spawn cost + RAM pressure + SQLite file-lock contention
# eat most of the gain — while amplifying every transient-DOM flake
# (login-race, gecko-perms, ElementNotInteractable, Jasmine-timeout).
# Stage-level parallelism gives the same wall-clock reduction without
# contention amplification.
- name: test-FTs-non-room
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
depends_on:
- test-two-browser-FTs
environment: environment:
HEADLESS: 1 HEADLESS: 1
# /tmp path (not workspace-relative) so the parallel test-FTs-room
# step can't see this DB + vice versa. See split-rationale above.
DATABASE_URL: sqlite:////tmp/test_db_non_room.sqlite3
CELERY_BROKER_URL: redis://redis:6379/0 CELERY_BROKER_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/1 REDIS_URL: redis://redis:6379/1
STRIPE_SECRET_KEY: STRIPE_SECRET_KEY:
from_secret: stripe_secret_key from_secret: stripe_secret_key
STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY:
from_secret: stripe_publishable_key from_secret: stripe_publishable_key
PIP_CACHE_DIR: .pip-cache
commands: commands:
- pip install -r requirements.txt - pip install -r requirements.dev.txt
- cd ./src - cd ./src
- python manage.py collectstatic --noinput # Every FT file EXCEPT test_game_room_*, test_trinket_*, AND
- python manage.py test functional_tests --parallel --exclude-tag=channels --exclude-tag=two-browser # test_game_my_sea* — all three clusters run in test-FTs-room.
# Channels + two-browser tags already covered upstream.
# `ls | grep -v | sed` enumerates module dotted-paths from
# filenames. (No trailing `_` in the my-sea alternative — the
# file is `test_game_my_sea.py` w. no further suffix today.)
#
# Wrapped in `_retry_failed.sh` so a single Selenium flake (browser
# hang, gecko-perms blip, login race) at test N/M doesn't cost the
# full step wall-clock on retry — the script parses Django's
# FAIL:/ERROR: lines from stdout + re-runs only those labels.
- bash ../.woodpecker/_retry_failed.sh --exclude-tag=channels --exclude-tag=two-browser $(ls functional_tests/test_*.py | grep -vE 'test_(game_room|trinket)_|test_game_my_sea' | sed 's|/|.|g;s|\.py||')
when:
- event: push
path:
- "src/**"
- "requirements.txt"
- ".woodpecker/main.yaml"
- name: test-FTs-room
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
depends_on:
- test-two-browser-FTs
environment:
HEADLESS: 1
# /tmp path (not workspace-relative) so test-FTs-non-room can't see
# this DB + vice versa. See split-rationale above.
DATABASE_URL: sqlite:////tmp/test_db_room.sqlite3
CELERY_BROKER_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/1
STRIPE_SECRET_KEY:
from_secret: stripe_secret_key
STRIPE_PUBLISHABLE_KEY:
from_secret: stripe_publishable_key
PIP_CACHE_DIR: .pip-cache
commands:
- pip install -r requirements.dev.txt
- cd ./src
# Heavy Selenium room flows — test_game_room_* (deck_contrib,
# gatekeeper, invite, select_role/sea/sig/sky, tray, tray_tooltip),
# test_trinket_* (carte_blanche, coin_on_a_string, backstage_pass)
# since trinket FTs create rooms + load the room template (where
# the table hex SCSS + chair geometry live), AND test_game_my_sea*
# (49 my-sea FTs that DRY-reuse the room-shell hex + sea-cross
# picker — same Selenium surface, so the same parallel-stage
# contention concerns apply). Runs in parallel w. test-FTs-non-room
# (distinct DATABASE_URL paths under /tmp; see split-rationale).
#
# `_retry_failed.sh` parses Django FAIL:/ERROR: lines from the first
# run's stdout + re-runs just those labels — single-flake retries
# cost ~22s instead of the full ~35-min step wall-clock. Genuine
# regressions still fail (second run output is the authoritative
# report); first-run crashes w. no parseable labels propagate
# the original exit code (don't silently mask infra problems).
- bash ../.woodpecker/_retry_failed.sh --exclude-tag=channels --exclude-tag=two-browser $(ls functional_tests/test_game_room_*.py functional_tests/test_trinket_*.py functional_tests/test_game_my_sea*.py | sed 's|/|.|g;s|\.py||')
when: when:
- event: push - event: push
path: path:
@@ -75,6 +180,9 @@ steps:
- name: screendumps - name: screendumps
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
depends_on:
- test-FTs-non-room
- test-FTs-room
commands: commands:
- cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found" - cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found"
when: when:
@@ -87,6 +195,9 @@ steps:
- name: build-and-push - name: build-and-push
image: docker:cli image: docker:cli
depends_on:
- test-FTs-non-room
- test-FTs-room
environment: environment:
REGISTRY_PASSWORD: REGISTRY_PASSWORD:
from_secret: gitea_registry_password from_secret: gitea_registry_password
@@ -105,6 +216,8 @@ steps:
- name: deploy-staging - name: deploy-staging
image: alpine image: alpine
depends_on:
- build-and-push
environment: environment:
SSH_KEY: SSH_KEY:
from_secret: deploy_ssh_key from_secret: deploy_ssh_key
@@ -126,6 +239,8 @@ steps:
- name: deploy-prod - name: deploy-prod
image: alpine image: alpine
depends_on:
- build-and-push
environment: environment:
SSH_KEY: SSH_KEY:
from_secret: deploy_ssh_key from_secret: deploy_ssh_key

View File

@@ -73,7 +73,7 @@ python src/manage.py setup_sig_session --base-url http://localhost:8000
python src/manage.py setup_sig_session --room <uuid> python src/manage.py setup_sig_session --room <uuid>
``` ```
Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all superusers with Earthman deck. URLs use `/lyric/dev-login/<session_key>/` pre-auth pattern. Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all superusers with Earthman deck. URLs use `/dashboard/dev-login/<session_key>/` pre-auth pattern.
## CI/CD + Hosting ## CI/CD + Hosting
- Git remote: `git@gitea:discoman/python-tdd.git` (port 222, key `~/.ssh/gitea_keys/id_ed25519_python-tdd`) - Git remote: `git@gitea:discoman/python-tdd.git` (port 222, key `~/.ssh/gitea_keys/id_ed25519_python-tdd`)
@@ -110,7 +110,7 @@ Drop → RESERVED → confirm/reject. `_gate_context()` builds slot state; `_exp
`epic:room` view at `/gameboard/room/<uuid>/`. `gatekeeper` redirects there when `table_status` is set. Error redirects in `select_role`/`select_sig` use `epic:room` if `table_status` is set, else `epic:gatekeeper`. `epic:room` view at `/gameboard/room/<uuid>/`. `gatekeeper` redirects there when `table_status` is set. Error redirects in `select_role`/`select_sig` use `epic:room` if `table_status` is set, else `epic:gatekeeper`.
## SCSS Import Order ## SCSS Import Order
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → card-deck → natus → tray → billboard → tooltips → game-kit → wallet-tokens` `core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → card-deck → sky → tray → billboard → tooltips → game-kit → wallet-tokens`
## Critical Gotchas ## Critical Gotchas
@@ -140,4 +140,7 @@ Use `.parent:has(.child-class)` to style a parent based on its contents without
### Plausible FT noise ### Plausible FT noise
Plausible analytics script in `base.html` fires a beacon during Selenium tests → harmless console error. Fix: `{% if not debug %}` guard around the script tag. Plausible analytics script in `base.html` fires a beacon during Selenium tests → harmless console error. Fix: `{% if not debug %}` guard around the script tag.
### JSONField `.exclude(data__key=value)` on SQLite
`.exclude(data__retracted=True)` on a row whose `data` has no `retracted` key resolves to `WHERE NOT (NULL = TRUE)` → NULL → SQL filters that row out. The exclude becomes "exclude rows where the key is True OR missing" instead of "exclude rows where the key is True". PostgreSQL evaluates this correctly, so the bug only manifests in local dev / SQLite ITs. If you mean *exclude only when the key exists and equals X*, do the predicate in Python after fetching a buffered queryset (see `_billboard_context` for the pattern). The same trap applies to `.filter(data__key=value)` — you'll silently miss rows where the key is missing.
See `.claude/skills/TDD/SKILL.md` for test-specific gotchas (TransactionTestCase flush, static files in tests, Selenium text-transform, multi-browser CI, msgpack integer keys). See `.claude/skills/TDD/SKILL.md` for test-specific gotchas (TransactionTestCase flush, static files in tests, Selenium text-transform, multi-browser CI, msgpack integer keys).

View File

@@ -0,0 +1,81 @@
# Provision the dedicated coturn (TURN/STUN) droplet for WebRTC mesh voice —
# Phase C of the my-sea invite/voice sprint. Mirrors the PySwiss split: its own
# DigitalOcean droplet, NOT the app box. CI needs none of this (signaling tests
# use the in-memory channel layer; the TURN endpoint is unit-tested w. a fake
# secret) — this runs only when you actually stand voice up on staging/prod.
#
# Prereqs (manual, one-time):
# 1. Create a DO droplet + a reserved/static public IP; point
# turn.earthmanrpg.me at it.
# 2. Add it to inventory.ini under [coturn] with host_vars:
# coturn_secret, coturn_realm, coturn_public_ip[, coturn_private_ip,
# coturn_tls_cert, coturn_tls_key]
# 3. Put the SAME coturn_secret into the APP droplet's env as
# COTURN_SHARED_SECRET (+ COTURN_TURN_HOST=turn.earthmanrpg.me,
# COTURN_REALM) so the /api/voice/turn-credentials/ HMAC matches.
#
# Run: ansible-playbook -i inventory.ini coturn-playbook.yaml
#
# nginx already proxy-upgrades WebSocket on the APP droplet (nginx.conf.j2), so
# ws/voice/ rides the existing proxy — no nginx change here.
- hosts: coturn
become: true
tasks:
- name: Install coturn
ansible.builtin.apt:
name: coturn
state: latest
update_cache: true
- name: Enable the coturn daemon
ansible.builtin.lineinfile:
path: /etc/default/coturn
regexp: '^#?TURNSERVER_ENABLED='
line: 'TURNSERVER_ENABLED=1'
- name: Ensure turn log dir exists
ansible.builtin.file:
path: /var/log/turnserver
state: directory
owner: turnserver
group: turnserver
mode: '0755'
- name: Deploy turnserver.conf
ansible.builtin.template:
src: coturn.conf.j2
dest: /etc/turnserver.conf
mode: '0640'
notify: Restart coturn
- name: Open STUN/TURN signaling ports (3478 udp+tcp)
community.general.ufw:
rule: allow
port: '3478'
proto: "{{ item }}"
loop: [udp, tcp]
- name: Open TURN-over-TLS port (5349 tcp)
community.general.ufw:
rule: allow
port: '5349'
proto: tcp
- name: Open the relay UDP port range (49152-65535)
community.general.ufw:
rule: allow
port: '49152:65535'
proto: udp
- name: Enable + start coturn
ansible.builtin.systemd:
name: coturn
enabled: true
state: started
handlers:
- name: Restart coturn
ansible.builtin.systemd:
name: coturn
state: restarted

64
infra/coturn.conf.j2 Normal file
View File

@@ -0,0 +1,64 @@
# coturn (TURN/STUN) config for the EarthmanRPG WebRTC mesh voice feature —
# Phase C of the my-sea invite/voice sprint. Rendered by coturn-playbook.yaml
# onto a DEDICATED droplet (PySwiss-style split), NOT the app droplet.
#
# The app's /api/voice/turn-credentials/ endpoint signs ephemeral credentials
# with HMAC-SHA1(<expiry>:<user_id>, secret); `use-auth-secret` +
# `static-auth-secret` here must use the SAME secret (COTURN_SHARED_SECRET in
# the app env).
listening-port=3478
tls-listening-port=5349
fingerprint
lt-cred-mech
use-auth-secret
static-auth-secret={{ coturn_secret }}
realm={{ coturn_realm }}
# ── THE #1 FOOTGUN ──────────────────────────────────────────────────────────
# Without external-ip, coturn hands out its PRIVATE address as the relay
# candidate and every relayed connection silently fails. On a DigitalOcean
# droplet with a single public IP set it to that IP; if the droplet also has a
# private/anchor IP, use PUBLIC/PRIVATE so coturn maps between them.
external-ip={{ coturn_public_ip }}{% if coturn_private_ip is defined and coturn_private_ip %}/{{ coturn_private_ip }}{% endif %}
{% if coturn_public_ip6 is defined and coturn_public_ip6 %}
# Dual-stack: advertise IPv6 relay candidates too. coturn auto-binds all
# interfaces (incl. v6) since no listening-ip is pinned; this maps the public
# v6 explicitly. Set coturn_public_ip6 in inventory to enable — leave it unset
# for a pure-IPv4 server (the v6 peer-lockdown below is gated on the same var).
external-ip={{ coturn_public_ip6 }}
{% endif %}
# Relay port range — open this exact UDP range in the firewall (playbook does).
min-port=49152
max-port=65535
# ── TLS (turns: on 5349) — prod hardening ──────────────────────────────────
{% if coturn_tls_cert is defined and coturn_tls_cert %}
cert={{ coturn_tls_cert }}
pkey={{ coturn_tls_key }}
{% endif %}
no-tlsv1
no-tlsv1_1
# ── Lockdown: relay only, no SSRF via the TURN server ───────────────────────
no-multicast-peers
no-cli
no-software-attribute
# Block relaying to private ranges so the box can't be used to probe internals.
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
{% if coturn_public_ip6 is defined and coturn_public_ip6 %}
# IPv6 lockdown parity (only emitted when serving v6): loopback, link-local
# (fe80::/10), and unique-local (fc00::/7). coturn takes start-end ranges, not
# CIDR. Keeps a dual-stack relay from being pointed at internal v6 addresses.
denied-peer-ip=::1
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
{% endif %}
log-file=/var/log/turnserver/turn.log
simple-log

View File

@@ -10,4 +10,11 @@ STRIPE_SECRET_KEY={{ stripe_secret_key }}
CELERY_BROKER_URL=redis://gamearray_redis:6379/0 CELERY_BROKER_URL=redis://gamearray_redis:6379/0
REDIS_URL=redis://gamearray_redis:6379/1 REDIS_URL=redis://gamearray_redis:6379/1
PYSWISS_URL=https://charts.earthmanrpg.me PYSWISS_URL=https://charts.earthmanrpg.me
# coturn / WebRTC voice — only COTURN_SHARED_SECRET is sensitive (it signs the
# TURN HMAC creds + must equal the coturn droplet's static-auth-secret). Host +
# realm are public. coturn_secret comes from the vault (share it across the app
# + coturn host groups, e.g. group_vars/all/vault.yaml, so both plays match).
COTURN_SHARED_SECRET={{ coturn_secret }}
COTURN_TURN_HOST=turn.earthmanrpg.me
COTURN_REALM=earthmanrpg.me

View File

@@ -0,0 +1,10 @@
$ANSIBLE_VAULT;1.1;AES256
62633637333430623762333637306466646161323861663564373533353565366661616433376465
6138653163616138396163363764353464616133303731370a656166623332656234356564373330
34656230353138653939313337376365343866623461616466343131313236303439613664616333
6665333231353436650a616663653630613465613931353232383437623434383930313862626164
39653231326663626562323832666264366331306365333061613535396532303937343065616261
62663638386235373566336634616331396434643134303731646435396563343333333034303063
66313030396437666461303137613233666366376430356164386561626337643930383433653130
39663237303737333834366530303435666366336664363666646632396630626434373535303937
3739

View File

@@ -8,3 +8,15 @@ dashboard.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.s
[cicd] [cicd]
gitea.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd gitea.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
# Dedicated coturn (TURN/STUN) droplet for WebRTC mesh voice — provisioned by
# coturn-playbook.yaml. UNCOMMENT + fill once the droplet + static IP exist
# (see the playbook header). coturn_secret is NOT set here — it comes from the
# shared vault (group_vars/all/vault.yaml) so it matches the app's
# COTURN_SHARED_SECRET. (Inventory host_vars OVERRIDE group_vars, so never put
# coturn_secret on this line or it would clobber the vault value.)
# coturn_private_ip / coturn_tls_* are optional. coturn_public_ip6 (optional):
# set the droplet's public IPv6 to serve dual-stack TURN (adds a v6 external-ip
# + matching v6 peer-denial lockdown); leave unset for a pure-IPv4 relay.
[coturn]
turn.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd coturn_realm=earthmanrpg.me coturn_public_ip=167.172.236.157 coturn_public_ip6=2604:a880:800:14:0:3:384:6000

View File

@@ -1,6 +1,7 @@
asgiref==3.11.0 asgiref==3.11.0
attrs==25.4.0 attrs==25.4.0
certifi==2025.11.12 certifi==2025.11.12
celery
cffi==2.0.0 cffi==2.0.0
channels channels
channels-redis channels-redis
@@ -23,9 +24,11 @@ idna==3.11
lxml==6.0.2 lxml==6.0.2
outcome==1.3.0.post0 outcome==1.3.0.post0
packaging==25.0 packaging==25.0
psycopg2-binary
pycparser==2.23 pycparser==2.23
PySocks==1.7.1 PySocks==1.7.1
python-dotenv python-dotenv
redis
requests==2.32.5 requests==2.32.5
scipy scipy
selenium==4.39.0 selenium==4.39.0

View File

@@ -16,7 +16,7 @@ gunicorn==23.0.0
lxml==6.0.2 lxml==6.0.2
psycopg2-binary psycopg2-binary
redis redis
requests==2.31.0 requests==2.32.5
scipy scipy
stripe stripe
whitenoise==6.11.0 whitenoise==6.11.0

View File

@@ -4,6 +4,8 @@ omit =
*/migrations/* */migrations/*
*/tests/* */tests/*
*/routing.py */routing.py
*/reset_staging_db.py
*/delete_stale_my_sea_draws.py
[report] [report]
show_missing = true show_missing = true

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from apps.dashboard.models import Line, Post from apps.billboard.models import Line, Post
from apps.lyric.models import User from apps.lyric.models import User
@@ -18,7 +18,7 @@ class LineSerializer(serializers.ModelSerializer):
fields = ["id", "text"] fields = ["id", "text"]
class PostSerializer(serializers.ModelSerializer): class PostSerializer(serializers.ModelSerializer):
name = serializers.ReadOnlyField() name = serializers.ReadOnlyField(source="title")
url = serializers.CharField(source="get_absolute_url", read_only=True) url = serializers.CharField(source="get_absolute_url", read_only=True)
lines = LineSerializer(many=True, read_only=True) lines = LineSerializer(many=True, read_only=True)

View File

@@ -0,0 +1,55 @@
"""ITs for the WebRTC mesh TURN-credentials endpoint — Phase C of
[[my-sea-invite-voice-blueprint]]. Verifies the coturn `use-auth-secret`
REST scheme (HMAC-SHA1 username/credential) + auth gating.
"""
import base64
import hashlib
import hmac
from django.test import TestCase, override_settings
from django.urls import reverse
from apps.lyric.models import User
@override_settings(
COTURN_SHARED_SECRET="testsecret",
COTURN_TURN_HOST="turn.test",
COTURN_TTL=86400,
)
class TURNCredentialsTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="turn@test.io", username="turner")
self.url = reverse("api_turn_credentials")
def test_requires_authentication(self):
resp = self.client.get(self.url)
self.assertIn(resp.status_code, (401, 403))
def test_returns_ice_servers_and_ttl(self):
self.client.force_login(self.user)
data = self.client.get(self.url).json()
self.assertEqual(data["ttl"], 86400)
urls = [s for srv in data["iceServers"] for s in srv["urls"]]
self.assertTrue(any(u.startswith("stun:turn.test") for u in urls))
self.assertTrue(any("turn:turn.test" in u and "transport=udp" in u for u in urls))
self.assertTrue(any("turn:turn.test" in u and "transport=tcp" in u for u in urls))
def test_username_is_expiry_colon_user_id(self):
self.client.force_login(self.user)
data = self.client.get(self.url).json()
expiry_str, _, uid = data["username"].partition(":")
self.assertTrue(expiry_str.isdigit())
self.assertEqual(uid, str(self.user.id))
def test_credential_is_hmac_sha1_of_username(self):
self.client.force_login(self.user)
data = self.client.get(self.url).json()
expected = base64.b64encode(
hmac.new(b"testsecret", data["username"].encode(), hashlib.sha1).digest()
).decode()
self.assertEqual(data["credential"], expected)
# The TURN iceServer entry carries the same credential.
turn = [s for s in data["iceServers"] if "credential" in s][0]
self.assertEqual(turn["credential"], expected)

View File

@@ -1,7 +1,7 @@
from django.test import TestCase from django.test import TestCase
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.dashboard.models import Line, Post from apps.billboard.models import Line, Post
from apps.lyric.models import User from apps.lyric.models import User
class BaseAPITest(TestCase): class BaseAPITest(TestCase):
@@ -14,8 +14,8 @@ class BaseAPITest(TestCase):
class PostDetailAPITest(BaseAPITest): class PostDetailAPITest(BaseAPITest):
def test_returns_post_with_lines(self): def test_returns_post_with_lines(self):
post = Post.objects.create(owner=self.user) post = Post.objects.create(owner=self.user)
Line.objects.create(text="line 1", post=post) Line.objects.create(text="line 1", post=post, author=self.user)
Line.objects.create(text="line 2", post=post) Line.objects.create(text="line 2", post=post, author=self.user)
response = self.client.get(f"/api/posts/{post.id}/") response = self.client.get(f"/api/posts/{post.id}/")
@@ -49,7 +49,7 @@ class PostLinesAPITest(BaseAPITest):
def test_cannot_add_duplicate_line_to_post(self): def test_cannot_add_duplicate_line_to_post(self):
post = Post.objects.create(owner=self.user) post = Post.objects.create(owner=self.user)
Line.objects.create(text="post line", post=post) Line.objects.create(text="post line", post=post, author=self.user)
duplicate_response = self.client.post( duplicate_response = self.client.post(
f"/api/posts/{post.id}/lines/", f"/api/posts/{post.id}/lines/",
{"text": "post line"}, {"text": "post line"},
@@ -61,7 +61,7 @@ class PostLinesAPITest(BaseAPITest):
class PostsAPITest(BaseAPITest): class PostsAPITest(BaseAPITest):
def test_get_returns_only_users_posts(self): def test_get_returns_only_users_posts(self):
post1 = Post.objects.create(owner=self.user) post1 = Post.objects.create(owner=self.user)
Line.objects.create(text="line 1", post=post1) Line.objects.create(text="line 1", post=post1, author=self.user)
other_user = User.objects.create_user("other@example.com") other_user = User.objects.create_user("other@example.com")
Post.objects.create(owner=other_user) Post.objects.create(owner=other_user)

View File

@@ -8,4 +8,6 @@ urlpatterns = [
path('posts/<uuid:post_id>/', views.PostDetailAPI.as_view(), name='api_post_detail'), path('posts/<uuid:post_id>/', views.PostDetailAPI.as_view(), name='api_post_detail'),
path('posts/<uuid:post_id>/lines/', views.PostLinesAPI.as_view(), name='api_post_lines'), path('posts/<uuid:post_id>/lines/', views.PostLinesAPI.as_view(), name='api_post_lines'),
path('users/', views.UserSearchAPI.as_view(), name='api_users'), path('users/', views.UserSearchAPI.as_view(), name='api_users'),
path('voice/turn-credentials/', views.VoiceTURNCredentialsAPI.as_view(),
name='api_turn_credentials'),
] ]

View File

@@ -1,9 +1,16 @@
import base64
import hashlib
import hmac
import time
from django.conf import settings
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from apps.api.serializers import LineSerializer, PostSerializer, UserSerializer from apps.api.serializers import LineSerializer, PostSerializer, UserSerializer
from apps.dashboard.models import Line, Post from apps.billboard.models import Line, Post
from apps.lyric.models import User from apps.lyric.models import User
@@ -18,7 +25,7 @@ class PostLinesAPI(APIView):
post = get_object_or_404(Post, id=post_id) post = get_object_or_404(Post, id=post_id)
serializer = LineSerializer(data=request.data, context={"post": post}) serializer = LineSerializer(data=request.data, context={"post": post})
if serializer.is_valid(): if serializer.is_valid():
serializer.save(post=post) serializer.save(post=post, author=request.user)
return Response(serializer.data, status=201) return Response(serializer.data, status=201)
return Response(serializer.errors, status=400) return Response(serializer.errors, status=400)
@@ -29,8 +36,9 @@ class PostsAPI(APIView):
return Response(serializer.data) return Response(serializer.data)
def post(self, request): def post(self, request):
post = Post.objects.create(owner=request.user) text = request.data.get("text", "")
line = Line.objects.create(text=request.data.get("text", ""), post=post) post = Post.objects.create(owner=request.user, title=text[:35])
Line.objects.create(text=text, post=post, author=request.user)
serializer = PostSerializer(post) serializer = PostSerializer(post)
return Response(serializer.data, status=201) return Response(serializer.data, status=201)
@@ -43,3 +51,44 @@ class UserSearchAPI(APIView):
) )
serializer = UserSerializer(users, many=True) serializer = UserSerializer(users, many=True)
return Response(serializer.data) return Response(serializer.data)
class VoiceTURNCredentialsAPI(APIView):
"""Time-limited TURN/STUN credentials for the WebRTC mesh voice client
(Phase C of [[my-sea-invite-voice-blueprint]]). Implements the coturn
`use-auth-secret` REST scheme: username = `<expiry>:<user_id>`, credential
= base64(HMAC-SHA1(username, COTURN_SHARED_SECRET)) — the coturn droplet
validates it with the same shared secret, so no per-user state is stored
on either side. Authenticated-only."""
permission_classes = [IsAuthenticated]
def get(self, request):
host = settings.COTURN_TURN_HOST
ttl = settings.COTURN_TTL
expiry = int(time.time()) + ttl
username = f"{expiry}:{request.user.id}"
digest = hmac.new(
settings.COTURN_SHARED_SECRET.encode(),
username.encode(),
hashlib.sha1,
).digest()
credential = base64.b64encode(digest).decode()
ice_servers = []
if host:
ice_servers.append({"urls": [f"stun:{host}:3478"]})
ice_servers.append({
"urls": [
f"turn:{host}:3478?transport=udp",
f"turn:{host}:3478?transport=tcp",
],
"username": username,
"credential": credential,
})
return Response({
"iceServers": ice_servers,
"username": username,
"credential": credential,
"ttl": ttl,
})

View File

@@ -0,0 +1,56 @@
"""Drop the legacy `billboard-` slug prefix from billboard applets and
rename Most Recent → Most Recent Scroll.
The `billboard-` prefix snuck into seed migration 0003 against intent — no
other context (dashboard, gameboard, wallet, game-kit) prefixes its applet
slugs with the context name, and slugs need to stay portable so users can
later rearrange which page hosts which applet.
"""
from django.db import migrations
RENAMES = [
# (old_slug, new_slug, new_name_or_None)
('billboard-my-scrolls', 'my-scrolls', None),
('billboard-my-contacts', 'my-contacts', None),
('billboard-most-recent', 'most-recent-scroll', 'Most Recent Scroll'),
('billboard-notes', 'notes', None),
]
def _apply(apps, mapping):
Applet = apps.get_model('applets', 'Applet')
for old_slug, new_slug, new_name in mapping:
try:
applet = Applet.objects.get(slug=old_slug)
except Applet.DoesNotExist:
continue
applet.slug = new_slug
fields = ['slug']
if new_name is not None:
applet.name = new_name
fields.append('name')
applet.save(update_fields=fields)
def forward(apps, schema_editor):
_apply(apps, RENAMES)
def backward(apps, schema_editor):
inverse = [
(new, old, 'Most Recent' if old == 'billboard-most-recent' else None)
for (old, new, _) in RENAMES
]
_apply(apps, inverse)
class Migration(migrations.Migration):
dependencies = [
('applets', '0003_seed_applets'),
]
operations = [
migrations.RunPython(forward, backward),
]

View File

@@ -0,0 +1,33 @@
"""Seed the Pronouns applet on the Game Kit page (3x3, default visible)."""
from django.db import migrations
SLUG = "pronouns"
NAME = "Pronouns"
CONTEXT = "game-kit"
def forward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.get_or_create(
slug=SLUG,
defaults={
"name": NAME,
"context": CONTEXT,
"default_visible": True,
"grid_cols": 3,
"grid_rows": 3,
},
)
def backward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug=SLUG).delete()
class Migration(migrations.Migration):
dependencies = [
("applets", "0004_rename_billboard_applet_slugs"),
]
operations = [migrations.RunPython(forward, backward)]

View File

@@ -0,0 +1,41 @@
"""Rename the billboard `my-contacts` applet to `my-buddies` (slug + name).
User.buddies M2M (lyric/0004) lands at the same time; the applet links
to the new /billboard/my-buddies/ page where the user manages their
buddy list. "Contacts" was a placeholder name from the original
billboard scaffold.
"""
from django.db import migrations
def forward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
try:
applet = Applet.objects.get(slug="my-contacts")
except Applet.DoesNotExist:
return
applet.slug = "my-buddies"
applet.name = "My Buddies"
applet.save(update_fields=["slug", "name"])
def backward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
try:
applet = Applet.objects.get(slug="my-buddies")
except Applet.DoesNotExist:
return
applet.slug = "my-contacts"
applet.name = "Contacts"
applet.save(update_fields=["slug", "name"])
class Migration(migrations.Migration):
dependencies = [
("applets", "0005_seed_pronouns_applet"),
]
operations = [
migrations.RunPython(forward, backward),
]

View File

@@ -0,0 +1,40 @@
"""Rename the My Buddies applet → My Buds (slug + name).
UI-vocabulary tightening — see lyric/0005_rename_buddies_to_buds for the
parallel User.buddies → User.buds field rename. BILLBUDDIES overflowed
the page-header band; BILLBUDS fits cleanly.
"""
from django.db import migrations
def forward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
try:
applet = Applet.objects.get(slug="my-buddies")
except Applet.DoesNotExist:
return
applet.slug = "my-buds"
applet.name = "My Buds"
applet.save(update_fields=["slug", "name"])
def backward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
try:
applet = Applet.objects.get(slug="my-buds")
except Applet.DoesNotExist:
return
applet.slug = "my-buddies"
applet.name = "My Buddies"
applet.save(update_fields=["slug", "name"])
class Migration(migrations.Migration):
dependencies = [
("applets", "0006_rename_contacts_to_buddies"),
]
operations = [
migrations.RunPython(forward, backward),
]

View File

@@ -0,0 +1,42 @@
"""Seed the My Sea applet — Sprint 3 of the My Sea roadmap.
The applet itself is just a shell for now (header + horizontal scroll
container w. empty-state placeholder). Sprints 4+ will fill in the
sig-select / sea-select / gatekeeper phases that render in the dedicated
`my_sea.html` page reachable via the applet's header link.
Grid: 12 cols × 4 rows — wide horizontal banner so the latest draw's
10-card Celtic Cross spread can render left-to-right in the applet
aperture, scrollable like My Palette.
"""
from django.db import migrations
def seed(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.update_or_create(
slug="my-sea",
defaults={
"name": "My Sea",
"context": "gameboard",
"default_visible": True,
"grid_cols": 12,
"grid_rows": 4,
},
)
def unseed(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="my-sea").delete()
class Migration(migrations.Migration):
dependencies = [
("applets", "0007_rename_my_buddies_to_my_buds"),
]
operations = [
migrations.RunPython(seed, unseed),
]

View File

@@ -0,0 +1,41 @@
"""Seed the My Sign (a.k.a. My Significator) applet on billboard.
Sprint 4 of the My Sea roadmap. "Significator" remains the storage-layer
term (User.significator FK, room sig-select); the applet itself is "My
Sign" (the "My X" convention for applet names) while the standalone page
wordmark reads "Game Sign" (the "Game/Dash/Bill X" convention for pages).
4×6 (narrow + tall), seeded after all other billboard applets so it
renders at the end of the billboard grid. Shows the user's saved
significator card or a blank state.
"""
from django.db import migrations
def seed(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.update_or_create(
slug="my-sign",
defaults={
"name": "My Sign",
"context": "billboard",
"default_visible": True,
"grid_cols": 4,
"grid_rows": 6,
},
)
def unseed(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="my-sign").delete()
class Migration(migrations.Migration):
dependencies = [
("applets", "0008_seed_my_sea_applet"),
]
operations = [
migrations.RunPython(seed, unseed),
]

View File

@@ -0,0 +1,31 @@
"""Update the My Sign applet's display name — "Game Sign""My Sign".
User clarified naming convention 2026-05-18: **applets** use the "My X"
prefix (My Sign, My Sea, My Posts, etc.) while **standalone pages** use
the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit
page). Sprint 4a's initial migration (0009) set the applet name to
"Game Sign", which the user corrected after seeing the gear-menu toggle
list show the wrong word. Slug stays `my-sign` (URL + selectors stable).
"""
from django.db import migrations
def rename(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="my-sign").update(name="My Sign")
def unrename(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="my-sign").update(name="Game Sign")
class Migration(migrations.Migration):
dependencies = [
("applets", "0009_seed_my_sig_applet"),
]
operations = [
migrations.RunPython(rename, unrename),
]

View File

@@ -0,0 +1,43 @@
"""Seed the wallet Shop applet — Chunk 2 of the wallet expansion sprint.
Locked spec from [[project-wallet-shop-expansion]]: 4 rows total in the
wallet context (Shop atop, Balances + Tokens + Payment beneath); 12 cols
in landscape (full-width row). Mimics the existing wallet applets'
grid_cols=12 / grid_rows=3 shape.
`display_order` is NOT a field on Applet — applet ordering is dictated
by the wallet template's include order in `_applets.html` + the applet
slug alphabetical fallback in `applet_context()`. The template's include
order is set in Chunk 4; this migration just ensures the row exists.
"""
from django.db import migrations
def seed(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.update_or_create(
slug="wallet-shop",
defaults={
"name": "Shop",
"context": "wallet",
"default_visible": True,
"grid_cols": 12,
"grid_rows": 3,
},
)
def unseed(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="wallet-shop").delete()
class Migration(migrations.Migration):
dependencies = [
("applets", "0010_rename_my_sign_applet"),
]
operations = [
migrations.RunPython(seed, unseed),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-22 05:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('applets', '0011_seed_wallet_shop_applet'),
]
operations = [
migrations.AddField(
model_name='applet',
name='display_order',
field=models.PositiveSmallIntegerField(default=100),
),
]

View File

@@ -0,0 +1,31 @@
"""Pin the wallet Shop applet atop the wallet row.
The 4-applet wallet layout (per [[project-wallet-shop-expansion]]) wants
Shop first; the other 3 (Balances, Tokens, Payment) keep their historical
order via the default `display_order=100` + PK tie-break.
Idempotent — `update_or_create(slug=…, defaults={display_order: 10})`
also covers fresh DBs where `0011_seed_wallet_shop_applet` already ran.
"""
from django.db import migrations
def forward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="wallet-shop").update(display_order=10)
def reverse(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="wallet-shop").update(display_order=100)
class Migration(migrations.Migration):
dependencies = [
("applets", "0012_applet_display_order"),
]
operations = [
migrations.RunPython(forward, reverse),
]

View File

@@ -18,6 +18,11 @@ class Applet(models.Model):
default_visible = models.BooleanField(default=True) default_visible = models.BooleanField(default=True)
grid_cols = models.PositiveSmallIntegerField(default=12) grid_cols = models.PositiveSmallIntegerField(default=12)
grid_rows = models.PositiveSmallIntegerField(default=3) grid_rows = models.PositiveSmallIntegerField(default=3)
# Render-time sort key. Lower = earlier in the applets row. Default 100
# gives every existing applet a tied position → falls back to PK insertion
# order (the historical behavior), so this field is backwards-compatible.
# Set to <100 to pin an applet ABOVE the rest (eg. wallet-shop = 10).
display_order = models.PositiveSmallIntegerField(default=100)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@@ -30,6 +30,7 @@ const appletContainerIds = new Set([
'id_game_applets_container', 'id_game_applets_container',
'id_gk_sections_container', 'id_gk_sections_container',
'id_wallet_applets_container', 'id_wallet_applets_container',
'id_billboard_applets_wrapper',
]); ]);
document.body.addEventListener('htmx:afterSwap', (e) => { document.body.addEventListener('htmx:afterSwap', (e) => {

View File

@@ -0,0 +1,60 @@
// Click-lock state for `.applet-list-entry.row-3col` rows on the dashboard
// applet grids (My Posts / My Buds / My Notes / My Scrolls / My Games).
//
// Hover styling is pure CSS (`.applet-list-entry.row-3col:hover`); this
// module just persists the same highlight on tap/click so touch devices can
// pin a row to read it, and mirrors the toggle-off / shift-to-other-row /
// click-outside-dismiss behaviour the note-page click-lock established.
//
// State machine (clicking …):
// • a row that's not locked → lock it (clearing any prior lock)
// • the currently-locked row again → unlock it
// • a different row → move the lock to that row
// • anywhere not inside a row → clear the lock
(function () {
'use strict';
var _lockedRow = null;
var _bound = false;
function _clearLock() {
if (_lockedRow) {
_lockedRow.classList.remove('row-locked');
_lockedRow = null;
}
}
function _onClick(e) {
var row = e.target.closest('.applet-list-entry.row-3col');
if (row) {
if (row === _lockedRow) {
_clearLock();
} else {
_clearLock();
row.classList.add('row-locked');
_lockedRow = row;
}
return;
}
_clearLock();
}
function _init() {
if (_bound) return;
_bound = true;
document.addEventListener('click', _onClick);
}
function _testReset() {
_clearLock();
}
window.RowLock = {
_init: _init,
_testReset: _testReset,
get _lockedRow() { return _lockedRow; },
};
document.addEventListener('DOMContentLoaded', _init);
}());

View File

@@ -13,9 +13,12 @@ def apply_applet_toggle(user, context, checked_slugs):
def applet_context(user, context): def applet_context(user, context):
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()} ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
applets = {a.slug: a for a in Applet.objects.filter(context=context)} # `display_order` (lower = earlier) is the primary sort key; `pk` tie-breaks
# so applets at the default order=100 keep their historical insertion-order
# rendering. New applets that want pinned positions set order < 100 in
# their seed migration (eg. wallet-shop = 10 to render atop the wallet row).
applets_qs = Applet.objects.filter(context=context).order_by("display_order", "pk")
return [ return [
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)} {"applet": a, "visible": ua_map.get(a.pk, a.default_visible)}
for slug in applets for a in applets_qs
if slug in applets
] ]

View File

@@ -1,22 +1,26 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError
from .models import Line from .models import Line
DUPLICATE_LINE_ERROR = "You've already logged this to your post" DUPLICATE_LINE_ERROR = "You've already logged this to your post"
EMPTY_LINE_ERROR = "You can't have an empty post line" EMPTY_LINE_ERROR = "You can't have an empty post line"
class LineForm(forms.Form): class LineForm(forms.Form):
text = forms.CharField( text = forms.CharField(
error_messages = {"required": EMPTY_LINE_ERROR}, error_messages={"required": EMPTY_LINE_ERROR},
required=True, required=True,
) )
def save(self, for_post): def save(self, for_post, author):
return Line.objects.create( return Line.objects.create(
post=for_post, post=for_post,
text=self.cleaned_data["text"], text=self.cleaned_data["text"],
author=author,
) )
class ExistingPostLineForm(LineForm): class ExistingPostLineForm(LineForm):
def __init__(self, for_post, *args, **kwargs): def __init__(self, for_post, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -28,5 +32,5 @@ class ExistingPostLineForm(LineForm):
raise forms.ValidationError(DUPLICATE_LINE_ERROR) raise forms.ValidationError(DUPLICATE_LINE_ERROR)
return text return text
def save(self): def save(self, author):
return super().save(for_post=self._for_post) return super().save(for_post=self._for_post, author=author)

View File

@@ -0,0 +1,99 @@
"""@mailman-authored "Acceptances & rejections" invite log — Phase A of
[[my-sea-invite-voice-blueprint]].
`log_sea_invite(sea_invite)` appends one interactive Line + spawns one Brief
on the invitee's single MAIL_ACCEPTANCE Post when an owner invites a bud to
their my-sea table. Models on `apps.billboard.tax.log_tax_debit` (the @taxman
ledger); the one genuinely new wrinkle is that the Line is *stateful* — its
OK/BYE buttons render from the linked `SeaInvite.status` (see post.html, A5),
so the single line transforms in place rather than appending accept/decline
lines.
Unlike the tax ledger, the prose carries no timestamp prefix (one invite =
one line; the A6 view dedups duplicate PENDING/ACCEPTED invites before
calling here, so the `Line.unique_together = (post, text)` invariant isn't
stressed by repeat identical prose). `Line.display_text` therefore needs no
MAIL_ACCEPTANCE branch.
The post_save guard in `billboard.models` nukes any Line on a MAIL_ACCEPTANCE
Post lacking admin_solicited=True, so this helper sets it True.
"""
from apps.billboard.models import (
Brief,
Line,
MAIL_ACCEPTANCE_POST_TITLE,
Post,
)
from apps.lyric.models import get_or_create_mailman, resolve_pronouns
from apps.lyric.templatetags.lyric_extras import at_handle
# Invite prose shown to the invitee. `{handle}` is wrapped in an
# `<a class="post-attribution">` whose href routes to the owner's per-bud
# landing page — bud landing page sprint 2026-05-27 replaced the in-Line
# OK/BYE form-button block w. this navigational anchor. post.html's
# `safe`-filter branch is gated on `line.author.username == 'mailman'`
# (alongside 'adman'/'taxman') so the anchor renders as HTML.
#
# `{poss}` = the owner's possessive pronoun ("their"/"his"/"her"/…), so the
# table reads as the owner's. Em dash matches the @taxman "Look!—" house style.
INVITE_TEMPLATE = (
'Listen!—<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>'
" invites you to {poss} drawing table. "
"This invite will expire 24h from the time it was extended."
)
def log_sea_invite(sea_invite):
"""Append a Line to the invitee's "Acceptances & rejections" Post (creating
the Post on first invite) + spawn a Brief that the invitee's next page-load
surfaces as a slide-down banner. Links the new Line back onto the SeaInvite
so its OK/BYE buttons render from `sea_invite.status`.
Returns ``(post, line, brief)``. For an unregistered invitee (``invitee``
FK still None) there is no per-user log surface yet — returns
``(None, None, None)`` (linking on registration is deferred, mirroring the
long-standing RoomInvite-on-registration gap)."""
invitee = sea_invite.invitee
if invitee is None:
return None, None, None
owner = sea_invite.owner
post, _ = Post.objects.get_or_create(
owner=invitee,
kind=Post.KIND_MAIL_ACCEPTANCE,
defaults={"title": MAIL_ACCEPTANCE_POST_TITLE},
)
# Heal a title-less pre-feature Post once on next invite (mirrors the
# tax-ledger / Note.grant_if_new title heal).
if post.title != MAIL_ACCEPTANCE_POST_TITLE:
post.title = MAIL_ACCEPTANCE_POST_TITLE
post.save(update_fields=["title"])
text = INVITE_TEMPLATE.format(
owner_id=owner.id,
handle=at_handle(owner),
poss=resolve_pronouns(owner.pronouns)[2],
)
line = Line.objects.create(
post=post,
text=text,
author=get_or_create_mailman(),
admin_solicited=True,
)
# Link the Line onto the invite so post.html resolves OK/BYE state via the
# `line.sea_invite` OneToOne reverse accessor.
sea_invite.line = line
sea_invite.save(update_fields=["line"])
brief = Brief.objects.create(
owner=invitee,
post=post,
line=line,
kind=Brief.KIND_MAIL_ACCEPTANCE,
title=MAIL_ACCEPTANCE_POST_TITLE,
)
return post, line, brief

View File

@@ -0,0 +1,38 @@
# Generated by Django 6.0 on 2026-05-08 21:11
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Post',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL)),
('shared_with', models.ManyToManyField(blank=True, related_name='shared_posts', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Line',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField(default='')),
('post', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='billboard.post')),
],
options={
'ordering': ('id',),
'unique_together': {('post', 'text')},
},
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0 on 2026-05-08 21:34
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Brief',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_unread', models.BooleanField(default=True)),
('kind', models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite')], default='user_post', max_length=32)),
('title', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('line', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.line')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to=settings.AUTH_USER_MODEL)),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.post')),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-08 21:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0002_brief'),
]
operations = [
migrations.AddField(
model_name='post',
name='kind',
field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites')], default='user_post', max_length=32),
),
]

View File

@@ -0,0 +1,98 @@
# Adds Post.title, Line.author (PROTECT FK to lyric.User), Line.created_at.
# Backfills Post.title from first-line text (truncate 32 + "…" past 35 chars,
# or hardcoded "Notes & recognitions" for KIND_NOTE_UNLOCK), and Line.author
# from Post.owner — except KIND_NOTE_UNLOCK + ownerless rows, which attribute
# to the seeded `adman` User. Depends on lyric/0003_seed_adman so adman
# exists before backfill runs.
from django.db import migrations, models
from django.db.models import deletion
from django.utils import timezone
_NOTE_UNLOCK_TITLE = "Notes & recognitions"
def _truncate_title(text, length=35):
if len(text) <= length:
return text
return text[: length - 3] + "..."
def backfill(apps, schema_editor):
Post = apps.get_model("billboard", "Post")
Line = apps.get_model("billboard", "Line")
User = apps.get_model("lyric", "User")
adman = User.objects.filter(username="adman").first()
for post in Post.objects.all():
if post.kind == "note_unlock":
post.title = _NOTE_UNLOCK_TITLE
else:
first_line = post.lines.order_by("id").first()
post.title = _truncate_title(first_line.text) if first_line else ""
post.save(update_fields=["title"])
now = timezone.now()
for line in Line.objects.select_related("post").all():
if line.post.kind == "note_unlock":
line.author = adman
elif line.post.owner_id:
line.author_id = line.post.owner_id
else:
line.author = adman
if line.created_at is None:
line.created_at = now
line.save(update_fields=["author", "created_at"])
def reverse_noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("billboard", "0003_post_kind"),
("lyric", "0003_seed_adman"),
]
operations = [
migrations.AddField(
model_name="post",
name="title",
field=models.CharField(default="", max_length=35),
),
migrations.AddField(
model_name="line",
name="created_at",
field=models.DateTimeField(default=timezone.now),
),
migrations.AddField(
model_name="line",
name="author",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=deletion.PROTECT,
related_name="authored_lines",
to="lyric.user",
),
),
migrations.RunPython(backfill, reverse_noop),
migrations.AlterField(
model_name="line",
name="author",
field=models.ForeignKey(
on_delete=deletion.PROTECT,
related_name="authored_lines",
to="lyric.user",
),
),
migrations.AlterField(
model_name="line",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@@ -0,0 +1,34 @@
# Adds Line.admin_solicited (BooleanField) to discriminate
# system-authored Lines (Note.grant_if_new) from user writes on
# NOTE_UNLOCK Posts. The post_save signal nukes any Line on a
# NOTE_UNLOCK Post that lacks admin_solicited=True — defense-in-depth
# alongside the view_post POST guard. Backfill: existing NOTE_UNLOCK
# Lines (the only system-authored kind at this point) get True; all
# others default False.
from django.db import migrations, models
def backfill(apps, schema_editor):
Line = apps.get_model("billboard", "Line")
Line.objects.filter(post__kind="note_unlock").update(admin_solicited=True)
def reverse_noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("billboard", "0004_post_title_line_author_created_at"),
]
operations = [
migrations.AddField(
model_name="line",
name="admin_solicited",
field=models.BooleanField(default=False),
),
migrations.RunPython(backfill, reverse_noop),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 6.0 on 2026-05-09 03:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('billboard', '0005_line_admin_solicited'),
]
operations = [
migrations.AlterModelOptions(
name='line',
options={'ordering': ('created_at', 'id')},
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-05-09 04:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0006_alter_line_options'),
('epic', '0008_blades_reversal_fickle'),
]
operations = [
migrations.AddField(
model_name='brief',
name='room',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='epic.room'),
),
migrations.AlterField(
model_name='brief',
name='kind',
field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite')], default='user_post', max_length=32),
),
migrations.AlterField(
model_name='brief',
name='post',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.post'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0 on 2026-05-26 19:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0007_brief_room_alter_brief_kind_alter_brief_post'),
]
operations = [
migrations.AlterField(
model_name='brief',
name='kind',
field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite'), ('tax_ledger', 'Tax ledger')], default='user_post', max_length=32),
),
migrations.AlterField(
model_name='post',
name='kind',
field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites'), ('tax_ledger', 'Debits & credits')], default='user_post', max_length=32),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0 on 2026-05-27 16:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0008_tax_ledger_kind'),
]
operations = [
migrations.AlterField(
model_name='brief',
name='kind',
field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite'), ('tax_ledger', 'Tax ledger'), ('mail_acceptance', 'Mail acceptance')], default='user_post', max_length=32),
),
migrations.AlterField(
model_name='post',
name='kind',
field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites'), ('tax_ledger', 'Debits & credits'), ('mail_acceptance', 'Acceptances & rejections')], default='user_post', max_length=32),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-05-28 15:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0009_alter_brief_kind_alter_post_kind'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BudshipNote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('shoptalk', models.CharField(default='', max_length=160)),
('edited_at', models.DateTimeField(auto_now=True)),
('bud', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budship_notes_about', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budship_notes_written', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-edited_at',),
'unique_together': {('user', 'bud')},
},
),
]

View File

@@ -0,0 +1,292 @@
import uuid
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
NOTE_UNLOCK_POST_TITLE_HINT = "Notes & recognitions" # see drama.NOTE_UNLOCK_POST_TITLE; copy lives there
TAX_LEDGER_POST_TITLE = "Debits & credits"
MAIL_ACCEPTANCE_POST_TITLE = "Acceptances & rejections"
class Post(models.Model):
KIND_NOTE_UNLOCK = "note_unlock"
KIND_USER_POST = "user_post"
KIND_SHARE_INVITE = "share_invite"
# Per-user @taxman-authored ledger (user-spec 2026-05-26). Each FREE/PAID
# DRAW spend at /gameboard/my-sea/ appends one Line via
# `apps.billboard.tax.log_tax_debit`. Mirrors the NOTE_UNLOCK Post pattern:
# one Post per user, system-authored, readonly textarea in post.html.
KIND_TAX_LEDGER = "tax_ledger"
# Per-user @mailman-authored "Acceptances & rejections" log (my-sea bud-
# invite flow, see [[my-sea-invite-voice-blueprint]]). Each invite appends
# one interactive Line (OK/BYE buttons) via `apps.billboard.mail.
# log_sea_invite`. Mirrors the TAX_LEDGER Post pattern: one Post per
# invitee, system-authored, with admin_solicited Lines.
KIND_MAIL_ACCEPTANCE = "mail_acceptance"
KIND_CHOICES = [
(KIND_NOTE_UNLOCK, "Note unlocks"),
(KIND_USER_POST, "User post"),
(KIND_SHARE_INVITE, "Share invites"),
(KIND_TAX_LEDGER, "Debits & credits"),
(KIND_MAIL_ACCEPTANCE, "Acceptances & rejections"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
"lyric.User",
related_name="posts",
blank=True,
null=True,
on_delete=models.CASCADE,
)
shared_with = models.ManyToManyField(
"lyric.User",
related_name="shared_posts",
blank=True,
)
# `kind` discriminates per-category Posts — e.g. Note.grant_if_new appends
# to the user's single (owner=user, kind=NOTE_UNLOCK) Post; user-authored
# composes default to KIND_USER_POST.
kind = models.CharField(
max_length=32,
choices=KIND_CHOICES,
default=KIND_USER_POST,
)
# Stored title — set explicitly on creation. Note-unlock Posts hardcode
# "Notes & recognitions"; user_post Posts truncate first line to 35 chars
# (32 + "..." past length). Replaces the legacy `name` property which
# gleaned `lines.first().text` lazily and broke if the first Line was
# later edited or deleted.
title = models.CharField(max_length=35, default="")
def get_absolute_url(self):
return reverse("billboard:view_post", args=[self.id])
class Line(models.Model):
text = models.TextField(default="")
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
# `author` PROTECTs against accidental sitewide-entity deletion (notably
# `adman`, the system-author for note_unlock + share_invite Lines).
# User-typed Lines attribute to the typing User; system-rendered Lines
# attribute to adman so the per-line "username" column always renders.
author = models.ForeignKey(
"lyric.User",
on_delete=models.PROTECT,
related_name="authored_lines",
)
created_at = models.DateTimeField(auto_now_add=True)
# System-authored Lines on NOTE_UNLOCK Posts must set this True; the
# post_save signal below deletes any Line on a NOTE_UNLOCK Post w.o.
# this flag (defense-in-depth alongside view_post's POST guard).
admin_solicited = models.BooleanField(default=False)
class Meta:
ordering = ("created_at", "id")
unique_together = ("post", "text")
def __str__(self):
return self.text
@property
def display_text(self):
"""User-facing line text. For TAX_LEDGER lines, strips the leading
`[<ISO timestamp>] ` prefix that the `apps.billboard.tax.log_tax_
debit` helper bakes in to satisfy `unique_together = (post, text)`
on repeat-slug spends — the Brief carries `created_at` and the
Post line carries `created_at` independently, so embedding a third
timestamp in the prose is noise.
For non-tax lines this is identity (returns `text` unchanged) —
Note-unlock + user-typed Lines have no prefix to strip."""
if self.post.kind == Post.KIND_TAX_LEDGER and self.text.startswith("["):
close = self.text.find("] ")
if close != -1:
return self.text[close + 2:]
return self.text
class Brief(models.Model):
"""A slide-down notification record. Owner = whose attention; post = where
FYI navigates (and where mark-read happens on GET); line = the specific
appended Line that triggered it (so the banner can surface its text).
`kind` discriminates the affordances the banner renders. NOTE_UNLOCK
Briefs get a clickable square that jumps direct to my_notes.html;
SHARE_INVITE Briefs render the invitation copy; USER_POST is the legacy
user-authored compose flow.
Magic-link confirmation + invalid-link banners use the same Gaussian-glass
visual styling but ride no Brief row (transient one-shot).
"""
KIND_NOTE_UNLOCK = "note_unlock"
KIND_USER_POST = "user_post"
KIND_SHARE_INVITE = "share_invite"
KIND_GAME_INVITE = "game_invite"
# Tax-ledger Briefs (FREE/PAID DRAW spend, user-spec 2026-05-26). FYI
# navigates to the user's TAX_LEDGER Post. NVM POSTs to the dismiss-
# brief endpoint so the dismissal persists per-cycle (see
# `dismiss_url` in `to_banner_dict`).
KIND_TAX_LEDGER = "tax_ledger"
# Surfaces the invitee's slide-down notification when an owner invites them
# to a my-sea table; FYI navigates to their "Acceptances & rejections" Post.
KIND_MAIL_ACCEPTANCE = "mail_acceptance"
KIND_CHOICES = [
(KIND_NOTE_UNLOCK, "Note unlock"),
(KIND_USER_POST, "User post"),
(KIND_SHARE_INVITE, "Share invite"),
(KIND_GAME_INVITE, "Game invite"),
(KIND_TAX_LEDGER, "Tax ledger"),
(KIND_MAIL_ACCEPTANCE, "Mail acceptance"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
"lyric.User",
related_name="briefs",
on_delete=models.CASCADE,
)
# Post is nullable now: KIND_GAME_INVITE briefs ride on a Room FK
# instead of a Post (the gatekeeper invite confirmation has no post
# to navigate to). Post FKs only set for note_unlock / user_post /
# share_invite kinds.
post = models.ForeignKey(
Post,
related_name="briefs",
on_delete=models.CASCADE,
null=True,
blank=True,
)
# Room FK — set only on KIND_GAME_INVITE briefs; FYI navigates to
# the gatekeeper page for that room.
room = models.ForeignKey(
"epic.Room",
related_name="briefs",
on_delete=models.CASCADE,
null=True,
blank=True,
)
# Line is nullable because a share_invite-style Brief can race ahead of its
# async-appended Line write; the post FK alone is enough to navigate.
line = models.ForeignKey(
Line,
related_name="briefs",
on_delete=models.CASCADE,
null=True,
blank=True,
)
is_unread = models.BooleanField(default=True)
kind = models.CharField(
max_length=32,
choices=KIND_CHOICES,
default=KIND_USER_POST,
)
title = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(default=timezone.now)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return (
f"Brief({self.kind}, {self.owner.email}, "
f"unread={self.is_unread})"
)
def to_banner_dict(self):
"""Shape this Brief for the slide-down banner JS. NOTE_UNLOCK kind
carries a square_url pointing at /billboard/my-notes/ so the
thumbnail-square inside the banner jumps direct to the user's Note
collection. GAME_INVITE kind has no Post — the FYI link navigates
to the gatekeeper page for the brief's Room instead.
`dismiss_url` (TAX_LEDGER only — user-spec 2026-05-26): the POST
endpoint the banner's NVM btn fires to so the dismissal persists
per-cycle (FREE DRAW until next FREE DRAW spend, PAID DRAW until
next PAID DRAW commit). Empty for kinds with no persistence."""
square_url = ""
dismiss_url = ""
if self.kind == self.KIND_NOTE_UNLOCK:
square_url = reverse("billboard:my_notes")
if self.post_id:
post_url = self.post.get_absolute_url()
elif self.room_id:
post_url = reverse("epic:gatekeeper", args=[self.room_id])
else:
post_url = ""
return {
"id": str(self.id),
"kind": self.kind,
"title": self.title,
# `display_text` strips the `[<iso timestamp>] ` prefix on
# TAX_LEDGER lines (the prefix exists only to satisfy Line's
# `unique_together = (post, text)` invariant on repeat-slug
# spends — the Brief's own `created_at` slot below covers the
# user-facing timestamp). Identity for all other line kinds.
"line_text": self.line.display_text if self.line else "",
"post_url": post_url,
"square_url": square_url,
"dismiss_url": dismiss_url,
"created_at": self.created_at.isoformat(),
}
# ── Listener: nuke unsolicited Lines on system-author Posts ──────────────
# Defense-in-depth alongside view_post's POST guard. A Line saved on a
# NOTE_UNLOCK / TAX_LEDGER / MAIL_ACCEPTANCE Post that lacks
# admin_solicited=True (e.g. a stray ORM-level write or an API path that
# bypasses the view) gets deleted right after the save. `Note.grant_if_new`,
# `apps.billboard.tax.log_tax_debit`, and `apps.billboard.mail.log_sea_invite`
# all set admin_solicited=True on their Lines so legitimate system prose
# survives.
_SYSTEM_AUTHOR_POST_KINDS = (
Post.KIND_NOTE_UNLOCK,
Post.KIND_TAX_LEDGER,
Post.KIND_MAIL_ACCEPTANCE,
)
@receiver(post_save, sender=Line)
def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs):
if not created:
return
if instance.post.kind in _SYSTEM_AUTHOR_POST_KINDS and not instance.admin_solicited:
instance.delete()
class BudshipNote(models.Model):
"""Per-relation personal note about a bud — bud landing page sprint
2026-05-27 ([[project-bud-landing-page-sprint]]). One row per
(user, bud) pair: the user's own shoptalk about that bud, NEVER
visible to the bud. Lazy-created on first shoptalk save so the
absence of a row reads as 'never edited' (drives the `.tt-milestone`
slot on the My Buds tooltip portal — present when ≥1 edit, absent
otherwise)."""
user = models.ForeignKey(
"lyric.User",
on_delete=models.CASCADE,
related_name="budship_notes_written",
)
bud = models.ForeignKey(
"lyric.User",
on_delete=models.CASCADE,
related_name="budship_notes_about",
)
shoptalk = models.CharField(max_length=160, default="")
edited_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ("user", "bud")
ordering = ("-edited_at",)
def __str__(self):
return f"BudshipNote({self.user_id}{self.bud_id})"

View File

@@ -0,0 +1,115 @@
// Bud-list autocomplete for #id_recipient inputs (post share panel + my_buds
// add panel). Mirrors the sky.html birth-place picker pattern: debounced
// fetch on input, top-3 suggestions rendered as buttons, click-to-fill,
// Escape closes, click-outside closes. No keyboard arrow/Enter cycling.
//
// Usage:
// <div class="bud-panel-wrap">
// <input id="id_recipient" ...>
// <div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
// </div>
// <script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
// <script>bindBudAutocomplete(
// document.getElementById('id_recipient'),
// document.getElementById('id_bud_suggestions'),
// { searchUrl: '{% url "billboard:search_buds" %}' }
// );</script>
(function () {
'use strict';
var DEBOUNCE_MS = 250;
var MIN_CHARS = 1;
function _esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : s;
return d.innerHTML;
}
window.bindBudAutocomplete = function (input, suggestions, options) {
if (!input || !suggestions || !options || !options.searchUrl) return;
var debounceTimer = null;
var lastQuery = '';
function _hide() {
suggestions.hidden = true;
suggestions.innerHTML = '';
}
function _render(buds) {
if (!buds || !buds.length) {
_hide();
return;
}
suggestions.innerHTML = buds.map(function (b) {
// data-email + data-username so the click handler can fill the
// input with whichever the user originally typed (email if they
// started with `@`, else username).
return (
'<button type="button" class="bud-suggestion-item" ' +
'data-email="' + _esc(b.email) + '" ' +
'data-username="' + _esc(b.username) + '">' +
_esc(b.username) +
'</button>'
);
}).join('');
suggestions.hidden = false;
}
function _fetch(q) {
var url = options.searchUrl + '?q=' + encodeURIComponent(q);
fetch(url, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
// Drop late responses if the user has typed past this query.
if (input.value.trim() !== q) return;
_render(data.buds || []);
})
.catch(function () { _hide(); });
}
input.addEventListener('input', function () {
var q = input.value.trim();
lastQuery = q;
if (q.length < MIN_CHARS) { _hide(); return; }
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () { _fetch(q); }, DEBOUNCE_MS);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') _hide();
});
suggestions.addEventListener('click', function (e) {
var btn = e.target.closest('.bud-suggestion-item');
if (!btn) return;
// Stop propagation so the bud-panel's document-level click-
// outside handler doesn't fire and close+clear the panel —
// _hide() about to detach the target makes a `sg.contains(e.target)`
// check at the document level unreliable.
e.stopPropagation();
// Fill w. whichever form the user was typing (email vs username).
// If the input value already contains '@', prefer email; else
// prefer username. This keeps the OK-submit semantics consistent
// w. what the user intended.
var typed = input.value.trim();
input.value = typed.indexOf('@') !== -1
? btn.dataset.email
: btn.dataset.username;
_hide();
input.focus();
});
document.addEventListener('click', function (e) {
if (suggestions.hidden) return;
if (suggestions.contains(e.target)) return;
if (e.target === input) return;
_hide();
});
};
}());

View File

@@ -0,0 +1,137 @@
// Shared skeleton for the three #id_bud_btn slide-out panels:
// • _bud_panel.html — post-share (POSTs to billboard:share_post)
// • _bud_invite_panel.html — gatekeeper invite (POSTs to epic:invite_gamer)
// • _bud_add_panel.html — My Buds add (POSTs to billboard:add_bud)
//
// Owns: csrf cookie read, open/close + .bud-open html-class, button click,
// Escape, click-outside, Enter-in-input, OK POST + JSON routing, and the
// `data.already_present` duplicate-guard branch (error Brief instead of
// the normal onSuccess append).
//
// Each caller drives it with:
// bindBudBtn({
// submitUrl,
// autocompleteUrl?,
// onSuccess(data), // success path: line/chip/entry/Brief
// duplicateTargetSelector?(data), // selector for .bud-duplicate-flash
// })
//
// `onSuccess(data)` does the panel-specific DOM updates on the new-row path.
// `duplicateTargetSelector(data)` returns a CSS selector for the existing
// element to highlight when the FYI button on the error Brief is clicked
// (.bud-name / .post-recipient / .gate-slot.filled — varies by page).
// _close({clear: true}) fires automatically on every successful response.
//
// `autocompleteUrl` enables bud-autocomplete on the input (post-share +
// gatekeeper panels) by binding bud-autocomplete.js to #id_bud_suggestions.
// My Buds omits it — the autocomplete pool is request.user.buds, which is
// the set you can't usefully re-add.
(function () {
'use strict';
window.bindBudBtn = function (opts) {
opts = opts || {};
var btn = document.getElementById('id_bud_btn');
var panel = document.getElementById('id_bud_panel');
var input = document.getElementById('id_recipient');
var ok = document.getElementById('id_bud_ok');
var html = document.documentElement;
if (!btn || !panel || !input || !ok) return;
var suggestions = opts.autocompleteUrl
? document.getElementById('id_bud_suggestions')
: null;
function _csrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function _open() {
html.classList.add('bud-open');
btn.classList.add('active');
setTimeout(function () { input.focus(); }, 60);
}
function _close(o) {
o = o || {};
html.classList.remove('bud-open');
btn.classList.remove('active');
if (o.clear !== false) input.value = '';
}
btn.addEventListener('click', function () {
if (html.classList.contains('bud-open')) _close();
else _open();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && html.classList.contains('bud-open')) _close();
});
document.addEventListener('click', function (e) {
if (!html.classList.contains('bud-open')) return;
if (panel.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return;
// Suggestions live outside the panel (panel has overflow:hidden
// for its scaleX slide); a click inside them must NOT close+clear.
if (suggestions && suggestions.contains(e.target)) return;
_close();
});
ok.addEventListener('click', function () {
var recipient = input.value.trim();
if (!recipient) return;
var fd = new FormData();
fd.set('recipient', recipient);
fetch(opts.submitUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-CSRFToken': _csrf(),
},
body: fd,
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
if (data && data.already_present) {
// Skip onSuccess — there's nothing to append. Show the
// error Brief instead. target_selector resolves at call
// time from the caller's per-page callback.
if (window.Brief) {
var sel = (typeof opts.duplicateTargetSelector === 'function')
? opts.duplicateTargetSelector(data) : null;
Brief.showDuplicateBanner({
display_name: data.recipient_display,
target_selector: sel,
});
}
} else if (typeof opts.onSuccess === 'function') {
opts.onSuccess(data);
}
_close({ clear: true });
})
.catch(function () {
// Privacy-safe response shape — even an unregistered/self
// recipient is a 200. Network/5xx land here; just close.
});
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
ok.click();
}
});
if (opts.autocompleteUrl && window.bindBudAutocomplete && suggestions) {
window.bindBudAutocomplete(input, suggestions, {
searchUrl: opts.autocompleteUrl,
});
}
};
}());

View File

@@ -0,0 +1,139 @@
// Row-click → row-lock + tooltip-portal for the My Buds list.
//
// Bud landing page sprint 2026-05-27 ([[project-bud-landing-page-sprint]]).
// Mirrors apps/applets/row-lock.js's lock/unlock state machine, but binds
// to `.bud-entry` rows (which are NOT `.row-3col`) AND populates the
// shared `#id_tooltip_portal` from the row's data-tt-* attrs.
//
// Click target semantics:
// • Click on the inner `<a>` (the `@<handle>` anchor) — let it navigate
// to the bud's landing page. NO lock fires.
// • Click anywhere else inside `.bud-entry` — lock the row + open the
// tooltip portal w. its data-tt-* fields populated.
// • Click outside any `.bud-entry` (and outside the portal) — clear.
//
// `.tt-milestone` is REMOVED from the DOM (vs. emptied) when the row's
// `data-tt-milestone` attr is absent so the FT can distinguish "never
// edited" (slot absent) from "cleared after edit" (slot empty).
(function () {
'use strict';
var _lockedRow = null;
var _portal = null;
var _milestoneTemplate = null;
function _clearLock() {
if (_lockedRow) {
_lockedRow.classList.remove('row-locked');
_lockedRow = null;
}
if (_portal) {
_portal.classList.remove('active');
// Reset positional props so the next show measures fresh.
_portal.style.top = '';
_portal.style.bottom = '';
_portal.style.left = '';
}
}
// Clamp the position:fixed portal to the viewport — same 1rem-inset
// shape as game-kit.js / sky-wheel.js / wallet.js. Called AFTER .active
// makes the portal display:block so offsetWidth/Height are real: clamp
// the left edge into [rem, viewport-ttW-rem], then prefer ABOVE the row
// (flip BELOW when the tooltip is too tall to fit above).
function _positionPortal(row) {
if (!_portal) return;
var rect = row.getBoundingClientRect();
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
var ttW = _portal.offsetWidth;
var ttH = _portal.offsetHeight;
var minLeft = rem;
var maxLeft = window.innerWidth - ttW - rem;
var clampedLeft = Math.max(minLeft, Math.min(rect.left, maxLeft));
_portal.style.left = clampedLeft + 'px';
var spaceAbove = rect.top - rem;
if (ttH <= spaceAbove) {
_portal.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
_portal.style.top = '';
} else {
_portal.style.top = (rect.bottom + 8) + 'px';
_portal.style.bottom = '';
}
}
function _findSlot(name) {
if (!_portal) return null;
return _portal.querySelector('.tt-' + name);
}
function _populatePortal(row) {
if (!_portal) return;
var fields = ['title', 'description', 'email', 'shoptalk'];
fields.forEach(function (f) {
var slot = _findSlot(f);
if (slot) slot.textContent = row.dataset['tt' + f.charAt(0).toUpperCase() + f.slice(1)] || '';
});
// .tt-milestone — absent when never-edited; present (+ populated)
// when the row carries data-tt-milestone.
var ms = row.dataset.ttMilestone;
var existing = _findSlot('milestone');
if (ms) {
if (!existing) {
var span = _milestoneTemplate.cloneNode(true);
span.textContent = ms;
_portal.appendChild(span);
} else {
existing.textContent = ms;
}
} else {
if (existing) existing.remove();
}
}
function _onClick(e) {
// Anchor click — let navigation proceed; no lock/portal.
if (e.target.closest('.bud-entry .bud-name a')) {
_clearLock();
return;
}
var row = e.target.closest('.bud-entry');
if (row) {
if (row === _lockedRow) {
_clearLock();
} else {
_clearLock();
row.classList.add('row-locked');
_lockedRow = row;
_populatePortal(row);
if (_portal) {
_portal.classList.add('active');
_positionPortal(row);
}
}
return;
}
// Click inside the portal itself — preserve lock.
if (_portal && _portal.contains(e.target)) return;
_clearLock();
}
function _init() {
_portal = document.getElementById('id_tooltip_portal');
if (_portal) {
// Snapshot the milestone element shape so we can restore it
// after a never-edited row removes it.
var ms = _portal.querySelector('.tt-milestone');
if (ms) _milestoneTemplate = ms.cloneNode(false);
}
document.addEventListener('click', _onClick);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _init);
} else {
_init();
}
}());

100
src/apps/billboard/tax.py Normal file
View File

@@ -0,0 +1,100 @@
"""@taxman-authored "Debits & credits" ledger — user-spec 2026-05-26.
`log_tax_debit(user, slug)` appends one Line + spawns one Brief on the user's
single TAX_LEDGER Post for each FREE/PAID DRAW spend at /gameboard/my-sea/.
Parallels `apps.drama.models.Note.grant_if_new` for Note unlocks; the same
post_save guard in `billboard.models` nukes any Line saved on a TAX_LEDGER
Post w.o. admin_solicited=True.
Two debit slugs today:
free_draw_locked → my_sea_lock first-card-of-cycle
paid_draw_locked → my_sea_paid_draw commit
The Brief that spawns here is rendered via the existing slide-down banner
(note.js `Brief.showBanner`); its FYI .btn-info navigates to the user's
ledger Post; its NVM stamps the matching `User.{free,paid}_draw_brief_
dismissed_at` field via the dismiss-brief gameboard endpoints.
Line text is timestamp-prefixed so a second identical-slug spend doesn't
collide w. `Line.Meta.unique_together = ("post", "text")`."""
from django.utils import timezone
from apps.billboard.models import (
Brief,
Line,
Post,
TAX_LEDGER_POST_TITLE,
)
from apps.lyric.models import get_or_create_taxman
# Canonical Line text per slug. Replaces the prior `_showFreeDrawLockedBrief`
# helper's wording in my_sea.html — the ledger Line IS the source of truth
# for both the persistent log surface (Debits & credits Post) AND the slide-
# down Brief banner (via Brief.line.text in to_banner_dict).
#
# `.btn-pri-name` spans wrap canonical .btn-primary button labels referenced
# inline (FREE DRAW, PAID DRAW, GATE VIEW per user-spec 2026-05-26). SCSS
# (`_billboard.scss` under `.post-line--system .post-line-text`) styles the
# spans so the user sees the same `--quaUser` colour + 700-weight on the
# token in prose as they would on the actual button. Rendered as HTML via
# `Line.display_text|safe` in post.html (the system-author branch).
TAX_DEBIT_TEMPLATES = {
"free_draw_locked": (
'Look!—My Sea\'s <span class="btn-pri-name">FREE DRAW</span> '
'is locked. Next free draw available 24h from the production of this log.'
),
"paid_draw_locked": (
'Look!—My Sea\'s <span class="btn-pri-name">PAID DRAW</span> '
'is locked. Another may be unlocked by depositing a Token in '
'<span class="btn-pri-name">GATE VIEW</span>.'
),
}
def log_tax_debit(user, slug):
"""Append a Line to the user's "Debits & credits" Post (creating the Post
on first call) + spawn a Brief that the next page-load surfaces as a
slide-down banner.
Returns ``(post, line, brief)``. Raises ``KeyError`` for unknown slugs.
Line text is prefixed with `[<ISO timestamp>] ` so successive spends of
the same slug produce distinct rows (each one survives `Line.Meta.
unique_together = ("post", "text")`)."""
if slug not in TAX_DEBIT_TEMPLATES:
raise KeyError(f"Unknown tax debit slug: {slug!r}")
post, _ = Post.objects.get_or_create(
owner=user,
kind=Post.KIND_TAX_LEDGER,
defaults={"title": TAX_LEDGER_POST_TITLE},
)
# Existing TAX_LEDGER Posts (pre-feature migration) might lack a title;
# heal once on next debit. Mirrors the Note.grant_if_new title heal.
if post.title != TAX_LEDGER_POST_TITLE:
post.title = TAX_LEDGER_POST_TITLE
post.save(update_fields=["title"])
body = TAX_DEBIT_TEMPLATES[slug]
# Sub-second timestamp prefix — keeps text unique even when two debits
# land in the same wallclock second (defensive vs auto-draw paths that
# could conceivably commit free + paid in rapid succession).
stamp = timezone.now().isoformat(timespec="microseconds")
text = f"[{stamp}] {body}"
line = Line.objects.create(
post=post,
text=text,
author=get_or_create_taxman(),
admin_solicited=True,
)
brief = Brief.objects.create(
owner=user,
post=post,
line=line,
kind=Brief.KIND_TAX_LEDGER,
title=TAX_LEDGER_POST_TITLE,
)
return post, line, brief

View File

@@ -0,0 +1,126 @@
"""ITs for admin-Post (kind=NOTE_UNLOCK) write protection.
Three guards stack:
1. post.html input is `readonly` w. "No response needed…" placeholder
(FT covers this — `functional_tests/test_admin_post_readonly.py`).
2. view_post POST handler hard-rejects writes (HTTP 403). This file's
PostRejectsAdminWritesTest.
3. post_save signal nukes any Line saved on a NOTE_UNLOCK Post that
lacks `admin_solicited=True` — defense-in-depth for paths that
bypass the view (raw API, ORM, etc.). UnsolicitedLineListenerTest.
Bug A — May 2026.
"""
from django.test import TestCase
from django.urls import reverse
from apps.billboard.models import Brief, Line, Post
from apps.drama.models import Note
from apps.lyric.models import User, get_or_create_adman
class PostRejectsAdminWritesTest(TestCase):
"""POST /billboard/post/<note_unlock>/ → HTTP 403, no Line appended."""
def setUp(self):
self.user = User.objects.create(email="admin-rej@test.io")
self.client.force_login(self.user)
Note.grant_if_new(self.user, "stargazer")
self.admin_post = Post.objects.get(
owner=self.user, kind=Post.KIND_NOTE_UNLOCK,
)
self.line_count_before = Line.objects.filter(post=self.admin_post).count()
def test_post_to_admin_post_returns_403(self):
resp = self.client.post(
reverse("billboard:view_post", args=[self.admin_post.id]),
data={"text": "errant response"},
)
self.assertEqual(resp.status_code, 403)
def test_post_to_admin_post_does_not_append_line(self):
self.client.post(
reverse("billboard:view_post", args=[self.admin_post.id]),
data={"text": "errant response"},
)
self.assertEqual(
Line.objects.filter(post=self.admin_post).count(),
self.line_count_before,
)
def test_post_to_user_post_still_succeeds(self):
"""Regression: kind=USER_POST still accepts compose."""
user_post = Post.objects.create(
owner=self.user, kind=Post.KIND_USER_POST, title="composing",
)
Line.objects.create(post=user_post, text="seed", author=self.user)
resp = self.client.post(
reverse("billboard:view_post", args=[user_post.id]),
data={"text": "valid append"},
)
# 302 redirect on success
self.assertEqual(resp.status_code, 302)
self.assertTrue(
Line.objects.filter(post=user_post, text="valid append").exists(),
)
class UnsolicitedLineListenerTest(TestCase):
"""post_save signal deletes any Line saved on a NOTE_UNLOCK Post without
`admin_solicited=True`. Note.grant_if_new sets it; everything else
defaults to False, so a stray ORM-level write gets nuked."""
def setUp(self):
self.user = User.objects.create(email="listener@test.io")
Note.grant_if_new(self.user, "stargazer")
self.admin_post = Post.objects.get(
owner=self.user, kind=Post.KIND_NOTE_UNLOCK,
)
def test_unsolicited_line_on_note_unlock_post_is_deleted(self):
unsolicited = Line.objects.create(
post=self.admin_post,
text="errant ORM write",
author=self.user,
# admin_solicited defaults to False
)
# Signal fires post_save; the Line should be gone.
self.assertFalse(
Line.objects.filter(pk=unsolicited.pk).exists(),
"Unsolicited Line on NOTE_UNLOCK Post must be deleted",
)
def test_admin_solicited_line_on_note_unlock_post_persists(self):
"""The Note grant Lines are admin_solicited=True — must NOT be nuked."""
adman = get_or_create_adman()
line = Line.objects.create(
post=self.admin_post,
text="valid system prose",
author=adman,
admin_solicited=True,
)
self.assertTrue(Line.objects.filter(pk=line.pk).exists())
def test_unsolicited_line_on_user_post_persists(self):
"""User-typed Lines on user_post posts default to admin_solicited=False
and must NOT be nuked — the listener only guards NOTE_UNLOCK."""
up = Post.objects.create(
owner=self.user, kind=Post.KIND_USER_POST, title="x",
)
line = Line.objects.create(
post=up, text="user-typed line", author=self.user,
)
self.assertTrue(Line.objects.filter(pk=line.pk).exists())
class NoteGrantSetsAdminSolicitedTest(TestCase):
"""Note.grant_if_new must persist Lines with admin_solicited=True so
they survive the listener pass."""
def test_grant_creates_line_with_admin_solicited_true(self):
u = User.objects.create(email="grant@test.io")
Note.grant_if_new(u, "stargazer")
post = Post.objects.get(owner=u, kind=Post.KIND_NOTE_UNLOCK)
# Exactly one Line on a fresh grant
line = post.lines.get()
self.assertTrue(line.admin_solicited)

View File

@@ -0,0 +1,121 @@
"""ITs for the Brief model & view_post's mark-read behavior.
Brief is a notification record — owner + post FK + line FK + is_unread + kind.
It rides on a Post (one-Post-per-category, Lines accumulate). Clicking FYI on
a Brief banner navigates to billboard:view_post for the underlying Post; that
GET is the contract that flips is_unread → False.
"""
from django.test import TestCase
from django.urls import reverse
from apps.billboard.models import Brief, Line, Post
from apps.lyric.models import User
class BriefModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="brief@test.io")
self.post = Post.objects.create(owner=self.user)
self.line = Line.objects.create(post=self.post, text="Stargazer, 5:21pm", author=self.user)
def test_brief_defaults_unread(self):
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
self.assertTrue(b.is_unread)
def test_brief_default_kind_is_user_post(self):
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
self.assertEqual(b.kind, Brief.KIND_USER_POST)
def test_brief_kind_choices_include_note_unlock_and_share_invite(self):
choices = dict(Brief._meta.get_field("kind").choices)
self.assertIn(Brief.KIND_NOTE_UNLOCK, choices)
self.assertIn(Brief.KIND_USER_POST, choices)
self.assertIn(Brief.KIND_SHARE_INVITE, choices)
def test_brief_line_can_be_null(self):
"""A Brief may pre-date its Line (e.g. share-invite spawns the Line
async — the Brief should still be persistable while the Line write
is pending). Doesn't break the post FK."""
b = Brief.objects.create(owner=self.user, post=self.post)
self.assertIsNone(b.line)
def test_brief_owner_required(self):
"""Brief without owner is invalid (load-bearing for "whose
attention"). Post used to be required too, but became nullable
when GAME_INVITE briefs landed (those use Brief.room instead of
Brief.post). The view layer enforces "post XOR room" per kind."""
from django.db import IntegrityError, transaction
with transaction.atomic(), self.assertRaises(IntegrityError):
Brief.objects.create(post=self.post, line=self.line)
def test_brief_carries_title(self):
b = Brief.objects.create(
owner=self.user, post=self.post, line=self.line,
title="Look! — new Note unlocked",
)
self.assertEqual(b.title, "Look! — new Note unlocked")
def test_brief_str_includes_owner_kind_unread(self):
b = Brief.objects.create(owner=self.user, post=self.post, kind=Brief.KIND_NOTE_UNLOCK)
s = str(b)
self.assertIn("brief@test.io", s)
self.assertIn("note_unlock", s)
class ViewPostMarksReadTest(TestCase):
"""GET /billboard/post/<uuid>/ flips every unread Brief on that post for
the requesting user to is_unread=False. NVM (banner dismiss client-side
without nav) leaves Briefs untouched — that path doesn't hit this view."""
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.client.force_login(self.user)
self.post = Post.objects.create(owner=self.user)
self.line = Line.objects.create(post=self.post, text="entry one", author=self.user)
def test_get_view_post_flips_owner_unread_brief_to_read(self):
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
self.assertTrue(b.is_unread)
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
b.refresh_from_db()
self.assertFalse(b.is_unread)
def test_get_does_not_flip_other_users_briefs(self):
other = User.objects.create(email="other@test.io")
# Both users have a Brief on this post; only the requesting user's flips
mine = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
theirs = Brief.objects.create(owner=other, post=self.post, line=self.line)
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
mine.refresh_from_db()
theirs.refresh_from_db()
self.assertFalse(mine.is_unread)
self.assertTrue(theirs.is_unread)
def test_get_does_not_flip_briefs_on_other_posts(self):
other_post = Post.objects.create(owner=self.user)
other_line = Line.objects.create(post=other_post, text="other", author=self.user)
unrelated = Brief.objects.create(owner=self.user, post=other_post, line=other_line)
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
unrelated.refresh_from_db()
self.assertTrue(unrelated.is_unread)
def test_get_idempotent_for_already_read_brief(self):
already_read = Brief.objects.create(
owner=self.user, post=self.post, line=self.line, is_unread=False,
)
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
already_read.refresh_from_db()
self.assertFalse(already_read.is_unread)
def test_post_request_does_not_mark_read(self):
"""Posting a new Line to view_post (the legacy compose flow) is not
the FYI-read contract — the user is composing, not reviewing. Mark-
read happens only on a GET render of post.html."""
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
self.client.post(
reverse("billboard:view_post", args=[self.post.id]),
data={"text": "appended via POST"},
)
b.refresh_from_db()
self.assertTrue(b.is_unread)

View File

@@ -0,0 +1,335 @@
"""ITs for the My Buds feature (User.buds M2M + my_buds view +
add_bud JSON endpoint).
User.buds is a self M2M (symmetrical=False) — adding Alice to Disco's
list does NOT auto-reciprocate. Implicit auto-add on shared events
(post-share, gate-invite) is layered separately in those views.
Privacy: add_bud returns 200 with {bud: null} when the email is
unregistered, so the response shape never leaks membership.
"""
from django.test import TestCase
from django.urls import reverse
from apps.lyric.models import User
class UserBudsM2MTest(TestCase):
"""The buds field is asymmetric — A.buds.add(B) doesn't
reciprocate to B.buds, only to B.added_as_bud."""
def setUp(self):
self.disco = User.objects.create(email="disco@test.io")
self.alice = User.objects.create(email="alice@test.io")
def test_add_bud_one_way(self):
self.disco.buds.add(self.alice)
self.assertIn(self.alice, self.disco.buds.all())
self.assertNotIn(self.disco, self.alice.buds.all())
def test_added_as_bud_reverse_relation(self):
self.disco.buds.add(self.alice)
self.assertIn(self.disco, self.alice.added_as_bud.all())
def test_add_is_idempotent(self):
self.disco.buds.add(self.alice)
self.disco.buds.add(self.alice)
self.assertEqual(self.disco.buds.count(), 1)
class MyBudsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@test.io")
self.client.force_login(self.user)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.bob = User.objects.create(email="bob@test.io", username="bob")
self.user.buds.add(self.alice, self.bob)
def test_my_buds_renders_template(self):
response = self.client.get(reverse("billboard:my_buds"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/my_buds.html")
def test_my_buds_lists_users_buds(self):
response = self.client.get(reverse("billboard:my_buds"))
buds = list(response.context["buds"])
self.assertIn(self.alice, buds)
self.assertIn(self.bob, buds)
def test_my_buds_does_not_list_others_buds(self):
other = User.objects.create(email="other@test.io")
carol = User.objects.create(email="carol@test.io", username="carol")
other.buds.add(carol)
response = self.client.get(reverse("billboard:my_buds"))
self.assertNotIn(carol, list(response.context["buds"]))
def test_my_buds_redirects_anon_to_login(self):
self.client.logout()
response = self.client.get(reverse("billboard:my_buds"))
self.assertEqual(response.status_code, 302)
class AddBudViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="me@test.io", username="me")
self.client.force_login(self.user)
def test_add_registered_email_adds_to_buds(self):
alice = User.objects.create(email="alice@test.io", username="alice")
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIn(alice, self.user.buds.all())
def test_add_returns_bud_payload_with_username(self):
User.objects.create(email="alice@test.io", username="alice")
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "alice@test.io"},
)
body = response.json()
self.assertIsNotNone(body["bud"])
self.assertEqual(body["bud"]["username"], "alice")
def test_add_unregistered_email_returns_null_bud(self):
"""Privacy: 200 with bud=null so the response shape doesn't leak
whether the address is on the system."""
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "ghost@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["bud"])
self.assertEqual(self.user.buds.count(), 0)
def test_add_own_email_is_silent_noop(self):
"""Adding yourself: no bud added, response carries bud=null."""
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "me@test.io"},
)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["bud"])
self.assertNotIn(self.user, self.user.buds.all())
def test_add_existing_bud_is_idempotent(self):
alice = User.objects.create(email="alice@test.io", username="alice")
self.user.buds.add(alice)
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 200)
# Still only one bud entry — M2M dedup
self.assertEqual(self.user.buds.count(), 1)
# Response still carries the bud payload (so the JS can refresh
# an entry if a fast double-click bypassed the data-bud-id guard).
self.assertIsNotNone(response.json()["bud"])
def test_add_falls_back_to_email_when_no_username(self):
"""Bud payload returns email when bud.username is None — display
layer matches the navbar fallback (display_name filter)."""
User.objects.create(email="anon@test.io")
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "anon@test.io"},
)
self.assertEqual(response.json()["bud"]["username"], "anon@test.io")
def test_get_returns_405(self):
response = self.client.get(reverse("billboard:add_bud"))
self.assertEqual(response.status_code, 405)
def test_add_returns_at_handle_and_title_for_tooltip_row(self):
"""The async-appended My Buds row mirrors _my_buds_item.html, so the
payload must carry the bud's at_handle + active_title_display to fill
the data-tt-* attrs — without them the new row's tooltip renders empty
(the entries above it, server-rendered, have them). Regression
2026-05-29."""
alice = User.objects.create(email="alice@test.io", username="alice")
body = self.client.post(
reverse("billboard:add_bud"), data={"recipient": "alice"},
).json()
self.assertEqual(body["bud"]["at_handle"], "@alice")
self.assertEqual(body["bud"]["title"], alice.active_title_display)
def test_add_resolves_username_too_not_just_email(self):
"""Phase 2: recipient field accepts usernames as well as emails."""
alice = User.objects.create(email="alice@test.io", username="alice")
response = self.client.post(
reverse("billboard:add_bud"),
data={"recipient": "alice"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["bud"]["username"], "alice")
self.assertIn(alice, self.user.buds.all())
class SearchBudsViewTest(TestCase):
"""Top-3 prefix-match autocomplete endpoint backing #id_recipient."""
def setUp(self):
self.user = User.objects.create(email="me@test.io", username="me")
self.client.force_login(self.user)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.albert = User.objects.create(email="albert@test.io", username="albert")
self.alvin = User.objects.create(email="alvin@test.io", username="alvin")
self.bob = User.objects.create(email="bob@test.io", username="bob")
self.user.buds.add(self.alice, self.albert, self.alvin, self.bob)
def test_username_prefix_match(self):
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
usernames = [b["username"] for b in response.json()["buds"]]
# alice, albert, alvin all start with "al" — exactly 3 (cap)
self.assertEqual(len(usernames), 3)
self.assertIn("alice", usernames)
self.assertIn("albert", usernames)
self.assertIn("alvin", usernames)
self.assertNotIn("bob", usernames)
def test_caps_at_three_results(self):
d = User.objects.create(email="alfred@test.io", username="alfred")
self.user.buds.add(d)
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
self.assertEqual(len(response.json()["buds"]), 3)
def test_email_prefix_also_matches(self):
response = self.client.get(reverse("billboard:search_buds"), {"q": "bob@"})
usernames = [b["username"] for b in response.json()["buds"]]
self.assertIn("bob", usernames)
def test_does_not_leak_non_buds(self):
"""Non-buds (other registered users) don't appear in suggestions."""
User.objects.create(email="stranger@test.io", username="stranger")
response = self.client.get(reverse("billboard:search_buds"), {"q": "str"})
self.assertEqual(response.json()["buds"], [])
def test_empty_q_returns_empty_list(self):
response = self.client.get(reverse("billboard:search_buds"), {"q": ""})
self.assertEqual(response.json()["buds"], [])
def test_anon_redirects(self):
self.client.logout()
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
self.assertEqual(response.status_code, 302)
class SharePostImplicitAutoAddTest(TestCase):
"""Per-spec: when a share lands a recipient on Post.shared_with, the
sharer + recipient mutually auto-add each other to their buds lists."""
def setUp(self):
from apps.billboard.models import Post
self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
self.client.force_login(self.sharer)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.post = Post.objects.create(owner=self.sharer)
def _share(self, recipient):
return self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": recipient},
HTTP_ACCEPT="application/json",
)
def test_share_adds_recipient_to_sharer_buds(self):
self._share("alice@test.io")
self.assertIn(self.alice, self.sharer.buds.all())
def test_share_adds_sharer_to_recipient_buds(self):
"""Symmetric on shared events — recipient also gets the sharer."""
self._share("alice@test.io")
self.assertIn(self.sharer, self.alice.buds.all())
def test_share_with_username_also_auto_adds(self):
self._share("alice")
self.assertIn(self.alice, self.sharer.buds.all())
self.assertIn(self.sharer, self.alice.buds.all())
def test_unregistered_recipient_does_not_auto_add(self):
"""Privacy: unregistered email doesn't touch the buds graph."""
self._share("ghost@test.io")
self.assertEqual(self.sharer.buds.count(), 0)
class AddBudAlreadyPresentTest(TestCase):
"""Duplicate-add guard: add_bud's JSON response distinguishes "newly
added" from "already a bud" so the bud-btn JS can render an error
Brief titled `@<username> is already present` instead of the normal
bud-entry append. `recipient_user_id` is the highlight target id that
the Brief FYI button toggles `.bud-duplicate-flash` onto."""
def setUp(self):
self.user = User.objects.create(email="me@test.io", username="me")
self.client.force_login(self.user)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.user.buds.add(self.alice)
def _add(self, recipient):
return self.client.post(
reverse("billboard:add_bud"), {"recipient": recipient},
)
def test_already_present_true_when_candidate_is_already_a_bud(self):
self.assertTrue(self._add("alice@test.io").json()["already_present"])
def test_already_present_false_for_new_bud(self):
User.objects.create(email="bob@test.io", username="bob")
self.assertFalse(self._add("bob@test.io").json()["already_present"])
def test_already_present_false_for_unregistered_email(self):
self.assertFalse(self._add("ghost@test.io").json()["already_present"])
def test_recipient_display_carries_username_on_duplicate(self):
self.assertEqual(self._add("alice@test.io").json()["recipient_display"], "alice")
def test_recipient_user_id_carries_alice_id_on_duplicate(self):
self.assertEqual(
self._add("alice@test.io").json()["recipient_user_id"],
str(self.alice.id),
)
def test_username_typed_recipient_also_detects_duplicate(self):
"""A user typing 'alice' (no @) resolves the same way as the email."""
self.assertTrue(self._add("alice").json()["already_present"])
class SharePostAlreadyPresentTest(TestCase):
"""Duplicate-share guard mirrors AddBudAlreadyPresentTest — when the
recipient is already in post.shared_with, response carries
already_present + recipient_user_id for the .post-recipient highlight."""
def setUp(self):
from apps.billboard.models import Post
self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
self.client.force_login(self.sharer)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.post = Post.objects.create(owner=self.sharer)
self.post.shared_with.add(self.alice)
def _share(self, recipient):
return self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": recipient},
HTTP_ACCEPT="application/json",
)
def test_already_present_true_when_recipient_in_shared_with(self):
self.assertTrue(self._share("alice@test.io").json()["already_present"])
def test_already_present_false_for_new_recipient(self):
User.objects.create(email="bob@test.io", username="bob")
self.assertFalse(self._share("bob@test.io").json()["already_present"])
def test_recipient_user_id_present_on_duplicate(self):
self.assertEqual(
self._share("alice@test.io").json()["recipient_user_id"],
str(self.alice.id),
)
def test_recipient_display_already_present_on_duplicate(self):
"""`recipient_display` already exists on the success path; on
duplicate the same field must carry the matched user's handle."""
self.assertEqual(self._share("alice@test.io").json()["recipient_display"], "alice")

View File

@@ -0,0 +1,198 @@
"""ITs for the @mailman "Acceptances & rejections" log — Phase A of
[[my-sea-invite-voice-blueprint]].
Mirrors `apps.billboard.tests.integrated.test_tax` (the @taxman ledger): a
reserved system-author user (`mailman`) authors the interactive invite log
Lines via `apps.billboard.mail.log_sea_invite`.
This file grows in A4 with the `log_sea_invite` Post/Line/Brief tests; A2
lands only the reserved-username + idempotency contract for `mailman`,
mirroring `test_tax.TaxmanReservedUsernameTest`.
"""
from django.test import TestCase
from apps.billboard.mail import INVITE_TEMPLATE, log_sea_invite
from apps.billboard.models import (
Brief,
Line,
MAIL_ACCEPTANCE_POST_TITLE,
Post,
)
from apps.gameboard.models import SeaInvite
from apps.lyric.models import (
User,
get_or_create_mailman,
is_reserved_username,
)
class LogSeaInviteTest(TestCase):
"""`log_sea_invite` appends one interactive Line + spawns a Brief on the
invitee's single "Acceptances & rejections" Post, and links the Line back
to the SeaInvite (powering the OK/BYE render in A5)."""
def setUp(self):
self.owner = User.objects.create(
email="owner@test.io", username="discoman",
)
self.invitee = User.objects.create(
email="bud@test.io", username="budster",
)
self.invite = SeaInvite.objects.create(
owner=self.owner,
invitee=self.invitee,
invitee_email=self.invitee.email,
)
def test_creates_post_line_brief_on_invitee(self):
post, line, brief = log_sea_invite(self.invite)
# Post owned by the INVITEE (it's their notification surface)
self.assertEqual(post.owner, self.invitee)
self.assertEqual(post.kind, Post.KIND_MAIL_ACCEPTANCE)
self.assertEqual(post.title, MAIL_ACCEPTANCE_POST_TITLE)
# Line authored by @mailman + admin_solicited (survives the guard)
self.assertEqual(line.post, post)
self.assertEqual(line.author, get_or_create_mailman())
self.assertTrue(line.admin_solicited)
# Brief points at the Post + Line w. correct kind
self.assertEqual(brief.owner, self.invitee)
self.assertEqual(brief.post, post)
self.assertEqual(brief.line, line)
self.assertEqual(brief.kind, Brief.KIND_MAIL_ACCEPTANCE)
self.assertEqual(brief.title, MAIL_ACCEPTANCE_POST_TITLE)
def test_links_line_to_invite_both_directions(self):
_, line, _ = log_sea_invite(self.invite)
self.invite.refresh_from_db()
self.assertEqual(self.invite.line, line)
# OneToOne reverse accessor used by post.html (A5)
self.assertEqual(line.sea_invite, self.invite)
def test_prose_interpolates_owner_handle_and_default_possessive(self):
_, line, _ = log_sea_invite(self.invite)
self.assertIn("@discoman", line.text)
# pluralism (default) possessive = "their"
self.assertIn("their drawing table", line.text)
self.assertIn("expire 24h", line.text)
def test_possessive_follows_owner_pronouns(self):
self.owner.pronouns = "misogyny" # he/him/his
self.owner.save()
_, line, _ = log_sea_invite(self.invite)
self.assertIn("his drawing table", line.text)
def test_line_survives_post_save_guard(self):
_, line, _ = log_sea_invite(self.invite)
self.assertTrue(Line.objects.filter(pk=line.pk).exists())
def test_two_inviters_share_invitees_one_post(self):
other_owner = User.objects.create(
email="other@test.io", username="amigo",
)
other_invite = SeaInvite.objects.create(
owner=other_owner, invitee=self.invitee,
invitee_email=self.invitee.email,
)
log_sea_invite(self.invite)
log_sea_invite(other_invite)
posts = Post.objects.filter(
owner=self.invitee, kind=Post.KIND_MAIL_ACCEPTANCE,
)
self.assertEqual(posts.count(), 1)
self.assertEqual(posts.first().lines.count(), 2)
def test_unregistered_invitee_creates_no_log(self):
# An unregistered recipient has no per-user Post surface yet — linking
# on registration is deferred. log_sea_invite no-ops to (None,None,None).
invite = SeaInvite.objects.create(
owner=self.owner, invitee=None,
invitee_email="stranger@nowhere.io",
)
self.assertEqual(log_sea_invite(invite), (None, None, None))
def test_template_uses_listen_hook(self):
self.assertTrue(INVITE_TEMPLATE.startswith("Listen!"))
def test_line_wraps_owner_handle_in_post_attribution_anchor(self):
"""Bud landing page sprint 2026-05-27 — the inline OK/BYE block
migrates onto bud.html; the @mailman Line now carries an
`<a class="post-attribution">` around the owner's handle whose
href routes to the owner's per-bud landing page."""
_, line, _ = log_sea_invite(self.invite)
self.assertIn('class="post-attribution"', line.text)
self.assertIn(
f'href="/billboard/buds/{self.owner.id}/"', line.text,
)
# The anchor wraps ONLY the at_handle (not the surrounding prose)
self.assertIn(">@discoman</a>", line.text)
class MailAcceptanceKindTest(TestCase):
"""The new MAIL_ACCEPTANCE kind is registered on both Post + Brief."""
def test_post_kind_registered(self):
self.assertEqual(Post.KIND_MAIL_ACCEPTANCE, "mail_acceptance")
kinds = dict(Post.KIND_CHOICES)
self.assertIn(Post.KIND_MAIL_ACCEPTANCE, kinds)
def test_brief_kind_registered(self):
self.assertEqual(Brief.KIND_MAIL_ACCEPTANCE, "mail_acceptance")
kinds = dict(Brief.KIND_CHOICES)
self.assertIn(Brief.KIND_MAIL_ACCEPTANCE, kinds)
def test_post_title_constant(self):
self.assertEqual(MAIL_ACCEPTANCE_POST_TITLE, "Acceptances & rejections")
class MailAcceptanceGuardTest(TestCase):
"""post_save guard nukes any Line saved on a MAIL_ACCEPTANCE Post w.o.
admin_solicited=True — same defense-in-depth as NOTE_UNLOCK / TAX_LEDGER.
`log_sea_invite` sets admin_solicited=True so legitimate invite Lines
survive."""
def setUp(self):
self.user = User.objects.create(email="guard_mail@test.io")
self.mailman = get_or_create_mailman()
self.post = Post.objects.create(
owner=self.user,
kind=Post.KIND_MAIL_ACCEPTANCE,
title=MAIL_ACCEPTANCE_POST_TITLE,
)
def test_unsolicited_line_on_mail_acceptance_gets_deleted(self):
Line.objects.create(
post=self.post, text="impostor", author=self.mailman,
admin_solicited=False,
)
self.assertEqual(self.post.lines.count(), 0)
def test_solicited_line_on_mail_acceptance_survives(self):
Line.objects.create(
post=self.post, text="legit", author=self.mailman,
admin_solicited=True,
)
self.assertEqual(self.post.lines.count(), 1)
class MailmanReservedUsernameTest(TestCase):
"""`mailman` joins `adman` + `taxman` as a reserved system-author handle."""
def test_mailman_is_reserved(self):
self.assertTrue(is_reserved_username("mailman"))
self.assertTrue(is_reserved_username("MAILMAN")) # case-insensitive
def test_get_or_create_mailman_is_idempotent(self):
a = get_or_create_mailman()
b = get_or_create_mailman()
self.assertEqual(a.pk, b.pk)
self.assertEqual(a.email, "mailman@earthmanrpg.local")
def test_mailman_is_not_searchable(self):
# System users never surface in bud / recipient autocomplete.
self.assertFalse(get_or_create_mailman().searchable)
def test_existing_username_owner_is_not_blocked(self):
# A user who already holds a name isn't blocked from re-saving it.
u = User.objects.create(email="m@test.io", username="mailman_fan")
self.assertFalse(is_reserved_username("mailman_fan", current_user=u))

View File

@@ -0,0 +1,152 @@
"""ITs for post.html invitee-vs-owner header rendering.
The "just me, @owner the {title}" / "shared between … & me, @owner …" lines
were owner-centric (the legacy phrasing assumed the viewer is the post
creator). For an invitee (a user in post.shared_with), that prose is
confusing. This view branches the .post-header block:
• Owner viewing → unchanged existing prose.
• Invitee viewing (sole) → "shared with me, @viewer the {title}" +
"created by @owner the {owner_title}".
• Invitee viewing (multi) → "shared with {other_recipients ...}" +
"& me, @viewer the {title}" +
"created by @owner the {owner_title}".
The view layer adds `viewer_is_owner` + `other_recipients` to the
context; template branches on those.
"""
from django.test import TestCase
from django.urls import reverse
from apps.billboard.models import Line, Post
from apps.lyric.models import User
class PostInviteeViewContextTest(TestCase):
"""Context vars: viewer_is_owner + other_recipients."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.bob = User.objects.create(email="bob@test.io", username="bob")
self.post = Post.objects.create(owner=self.owner, title="Coolio")
Line.objects.create(post=self.post, text="seed", author=self.owner)
def test_owner_viewing_sets_viewer_is_owner_true(self):
self.client.force_login(self.owner)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
self.assertTrue(response.context["viewer_is_owner"])
def test_invitee_viewing_sets_viewer_is_owner_false(self):
self.post.shared_with.add(self.alice)
self.client.force_login(self.alice)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
self.assertFalse(response.context["viewer_is_owner"])
def test_other_recipients_excludes_viewer(self):
"""For an invitee, other_recipients = shared_with minus self."""
self.post.shared_with.add(self.alice, self.bob)
self.client.force_login(self.alice)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
others = list(response.context["other_recipients"])
self.assertIn(self.bob, others)
self.assertNotIn(self.alice, others)
def test_other_recipients_empty_for_sole_invitee(self):
self.post.shared_with.add(self.alice)
self.client.force_login(self.alice)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
self.assertEqual(list(response.context["other_recipients"]), [])
def test_other_recipients_for_owner_is_full_shared_with(self):
"""Owner viewing: other_recipients includes everyone (no self exclusion)."""
self.post.shared_with.add(self.alice, self.bob)
self.client.force_login(self.owner)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
others = list(response.context["other_recipients"])
self.assertIn(self.alice, others)
self.assertIn(self.bob, others)
class PostInviteeViewTemplateTest(TestCase):
"""Template prose: invitee branch shows "shared with me, …" /
"created by @owner …" — does NOT show the owner-centric "just me" or
"shared between"."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.bob = User.objects.create(email="bob@test.io", username="bob")
self.post = Post.objects.create(owner=self.owner, title="Coolio")
Line.objects.create(post=self.post, text="seed", author=self.owner)
def test_sole_invitee_sees_shared_with_me(self):
self.post.shared_with.add(self.alice)
self.client.force_login(self.alice)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
body = response.content.decode()
self.assertIn("shared with me", body)
self.assertIn("@alice", body)
def test_sole_invitee_does_not_see_just_me_or_shared_between(self):
"""Scope to .post-header — the bud-panel JS includes 'just me,' as
a regex literal in inline script, so a body-wide string match
false-positives."""
import lxml.html
self.post.shared_with.add(self.alice)
self.client.force_login(self.alice)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
tree = lxml.html.fromstring(body)
header_text = tree.cssselect(".post-header")[0].text_content()
self.assertNotIn("just me,", header_text)
self.assertNotIn("shared between", header_text)
def test_invitee_sees_created_by_owner(self):
self.post.shared_with.add(self.alice)
self.client.force_login(self.alice)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
self.assertIn("created by", body)
self.assertIn("@owner", body)
def test_multi_invitee_sees_shared_with_others_then_amp_me(self):
self.post.shared_with.add(self.alice, self.bob)
self.client.force_login(self.alice)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
self.assertIn("shared with", body)
self.assertIn("@bob", body)
self.assertIn("&amp; me", body)
self.assertIn("@alice", body)
def test_multi_invitee_does_not_see_self_in_recipients_line(self):
"""The recipients line lists OTHER invitees, not self."""
self.post.shared_with.add(self.alice, self.bob)
self.client.force_login(self.alice)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
# Coarse check: "shared with @bob" appears w/o @alice in same line
# (since alice's "@alice" is on the "& me" line below). The full
# body contains both, but the .post-shared-recipients line should
# only list other_recipients (i.e., bob, not alice).
# Use a narrower lxml-style assertion.
import lxml.html
tree = lxml.html.fromstring(body)
recipients_p = tree.cssselect(".post-shared-recipients")
self.assertEqual(len(recipients_p), 1)
rec_text = recipients_p[0].text_content()
self.assertIn("@bob", rec_text)
self.assertNotIn("@alice", rec_text)
def test_owner_view_unchanged_when_recipients_present(self):
"""Owner sees 'shared between' (old behavior)."""
self.post.shared_with.add(self.alice)
self.client.force_login(self.owner)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
self.assertIn("shared between", body)
self.assertIn("&amp; me", body)
self.assertNotIn("created by", body)
def test_owner_view_just_me_when_no_recipients(self):
"""Owner with no recipients: 'just me, …' (old behavior)."""
self.client.force_login(self.owner)
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
self.assertIn("just me,", body)
self.assertNotIn("created by", body)

View File

@@ -0,0 +1,151 @@
"""ITs for share-post async-Brief flow (C3.b).
POST /billboard/post/<uuid>/share-post w. Accept: application/json now:
- Adds the recipient to Post.shared_with (if registered, not the sharer)
- Appends a Line to the Post recording the share event
- Spawns a Brief(kind=SHARE_INVITE) for the sharer that JS slide-downs
- Returns JSON {brief: {…}, line_text: ""}; no redirect, no messages
Legacy form-submit (no Accept: application/json) still redirects + flashes
the privacy-safe success message — kept for non-AJAX fallback / older FTs.
"""
from django.test import TestCase
from django.urls import reverse
from apps.billboard.models import Brief, Line, Post
from apps.lyric.models import User
class SharePostAsyncTest(TestCase):
def setUp(self):
self.sharer = User.objects.create(email="sharer@test.io")
self.client.force_login(self.sharer)
self.post = Post.objects.create(owner=self.sharer)
def _share_async(self, recipient_email):
return self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": recipient_email},
HTTP_ACCEPT="application/json",
)
def test_async_share_returns_brief_payload(self):
User.objects.create(email="alice@test.io")
response = self._share_async("alice@test.io")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertIn("brief", body)
self.assertIn("line_text", body)
def test_async_share_appends_line_to_post(self):
User.objects.create(email="alice@test.io")
self.assertEqual(self.post.lines.count(), 0)
self._share_async("alice@test.io")
self.assertEqual(self.post.lines.count(), 1)
line = self.post.lines.first()
self.assertIn("alice@test.io", line.text)
def test_async_share_creates_share_invite_brief_for_sharer(self):
User.objects.create(email="alice@test.io")
self._share_async("alice@test.io")
brief = Brief.objects.get(owner=self.sharer)
self.assertEqual(brief.kind, Brief.KIND_SHARE_INVITE)
self.assertEqual(brief.post, self.post)
self.assertIsNotNone(brief.line)
self.assertTrue(brief.is_unread)
def test_async_share_adds_registered_recipient_to_shared_with(self):
alice = User.objects.create(email="alice@test.io")
self._share_async("alice@test.io")
self.assertIn(alice, self.post.shared_with.all())
def test_async_share_unregistered_recipient_still_appends_line_and_brief(self):
"""Privacy: even if the email isn't registered, the sharer gets the
same confirmation Brief + Line. Otherwise the response shape would
leak whether an address is on the system."""
response = self._share_async("ghost@test.io")
self.assertEqual(response.status_code, 200)
self.assertEqual(self.post.lines.count(), 1)
self.assertEqual(Brief.objects.filter(owner=self.sharer).count(), 1)
def test_async_share_does_not_add_owner_as_recipient(self):
"""Sharer shares w. their own email — no shared_with add, no Line, no
Brief; response carries brief: null so the JS just no-ops."""
response = self._share_async("sharer@test.io")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["brief"], None)
self.assertEqual(self.post.lines.count(), 0)
self.assertEqual(Brief.objects.filter(owner=self.sharer).count(), 0)
self.assertNotIn(self.sharer, self.post.shared_with.all())
def test_async_share_brief_payload_carries_share_invite_kind(self):
User.objects.create(email="alice@test.io")
body = self._share_async("alice@test.io").json()
self.assertEqual(body["brief"]["kind"], "share_invite")
self.assertIn("alice@test.io", body["line_text"])
def test_async_reshare_same_recipient_is_silent_noop(self):
"""Sharing the same recipient twice is a silent no-op — Post.shared_with
M2M is idempotent so a second add is meaningless, and we don't want a
duplicate Line cluttering the thread. Response is 200 with brief=null."""
User.objects.create(email="alice@test.io")
self._share_async("alice@test.io")
self.assertEqual(self.post.lines.count(), 1)
before_brief_count = Brief.objects.filter(owner=self.sharer).count()
response = self._share_async("alice@test.io")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["brief"], None)
# No second Line, no second Brief.
self.assertEqual(self.post.lines.count(), 1)
self.assertEqual(
Brief.objects.filter(owner=self.sharer).count(),
before_brief_count,
)
def test_async_share_line_text_drops_timestamp(self):
"""The share Line's text is plain "Shared with X" — no "at <iso ts>"
suffix (timestamp display lives on the per-Line `<time>` element now)."""
User.objects.create(email="alice@test.io")
self._share_async("alice@test.io")
line = self.post.lines.first()
self.assertEqual(line.text, "Shared with alice@test.io")
self.assertNotIn(" at ", line.text)
def test_async_share_line_author_is_sharer_not_adman(self):
"""User-created share Lines attribute to the sharer (the post owner
doing the share), not the system adman entity."""
User.objects.create(email="alice@test.io")
self._share_async("alice@test.io")
line = self.post.lines.first()
self.assertEqual(line.author, self.sharer)
class SharePostLegacyRedirectTest(TestCase):
"""Legacy form-submit path (no Accept: application/json) is preserved —
redirects + flashes the privacy-safe message + adds shared_with. Existing
FTs that submit the share form via Selenium still work."""
def setUp(self):
self.sharer = User.objects.create(email="sharer@test.io")
self.client.force_login(self.sharer)
self.post = Post.objects.create(owner=self.sharer)
def test_form_submit_still_redirects(self):
User.objects.create(email="alice@test.io")
response = self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], reverse("billboard:view_post", args=[self.post.id]))
def test_form_submit_still_adds_shared_with(self):
alice = User.objects.create(email="alice@test.io")
self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": "alice@test.io"},
)
self.assertIn(alice, self.post.shared_with.all())

View File

@@ -0,0 +1,121 @@
"""ITs for `apps.billboard.tax.log_tax_debit` — the @taxman-authored
"Debits & credits" ledger (user-spec 2026-05-26).
Mirrors the shape of `apps.drama.tests.integrated.test_note_brief` for
`Note.grant_if_new` — each spend appends a Line + spawns a Brief on the
user's single TAX_LEDGER Post."""
from django.test import TestCase
from apps.billboard.models import Brief, Line, Post, TAX_LEDGER_POST_TITLE
from apps.billboard.tax import (
TAX_DEBIT_TEMPLATES,
log_tax_debit,
)
from apps.lyric.models import User, get_or_create_taxman
class LogTaxDebitTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="tax@test.io")
def test_free_draw_locked_creates_post_line_brief(self):
post, line, brief = log_tax_debit(self.user, "free_draw_locked")
# Post created with the canonical title + correct kind
self.assertEqual(post.owner, self.user)
self.assertEqual(post.kind, Post.KIND_TAX_LEDGER)
self.assertEqual(post.title, TAX_LEDGER_POST_TITLE)
# Line authored by @taxman + admin_solicited
self.assertEqual(line.post, post)
self.assertEqual(line.author, get_or_create_taxman())
self.assertTrue(line.admin_solicited)
# Brief points at the Post + Line w. correct kind
self.assertEqual(brief.owner, self.user)
self.assertEqual(brief.post, post)
self.assertEqual(brief.line, line)
self.assertEqual(brief.kind, Brief.KIND_TAX_LEDGER)
self.assertEqual(brief.title, TAX_LEDGER_POST_TITLE)
def test_paid_draw_locked_uses_paid_template_text(self):
_, line, _ = log_tax_debit(self.user, "paid_draw_locked")
self.assertIn("PAID DRAW", line.text)
# `GATE VIEW` is wrapped in a `.btn-pri-name` span for inline styling
# parity w. the actual button label (user-spec 2026-05-26), so the
# raw text has HTML between "depositing a Token in " + "GATE VIEW".
self.assertIn("depositing a Token in", line.text)
self.assertIn("GATE VIEW", line.text)
def test_free_draw_locked_uses_free_template_text(self):
_, line, _ = log_tax_debit(self.user, "free_draw_locked")
self.assertIn("FREE DRAW", line.text)
self.assertIn("24h from the production of this log", line.text)
def test_two_spends_share_one_post_with_two_lines(self):
"""Like Note unlocks: one Post per user, growing thread of Lines."""
log_tax_debit(self.user, "free_draw_locked")
log_tax_debit(self.user, "paid_draw_locked")
posts = Post.objects.filter(owner=self.user, kind=Post.KIND_TAX_LEDGER)
self.assertEqual(posts.count(), 1)
self.assertEqual(posts.first().lines.count(), 2)
def test_two_free_draws_produce_distinct_lines(self):
"""Each spend produces a UNIQUE Line — text is timestamp-prefixed so
a second identical-slug spend doesn't collide with `unique_together
= (post, text)`."""
log_tax_debit(self.user, "free_draw_locked")
log_tax_debit(self.user, "free_draw_locked")
post = Post.objects.get(owner=self.user, kind=Post.KIND_TAX_LEDGER)
line_texts = list(post.lines.values_list("text", flat=True))
self.assertEqual(len(line_texts), 2)
self.assertNotEqual(line_texts[0], line_texts[1])
def test_unknown_slug_raises(self):
with self.assertRaises(KeyError):
log_tax_debit(self.user, "no_such_slug")
def test_template_keys_cover_both_known_slugs(self):
self.assertIn("free_draw_locked", TAX_DEBIT_TEMPLATES)
self.assertIn("paid_draw_locked", TAX_DEBIT_TEMPLATES)
class UnsolicitedLineGuardTest(TestCase):
"""post_save guard nukes any Line saved on a TAX_LEDGER Post w.o.
admin_solicited=True — mirrors the NOTE_UNLOCK defense-in-depth."""
def setUp(self):
self.user = User.objects.create(email="guard@test.io")
self.taxman = get_or_create_taxman()
self.post = Post.objects.create(
owner=self.user,
kind=Post.KIND_TAX_LEDGER,
title=TAX_LEDGER_POST_TITLE,
)
def test_unsolicited_line_on_tax_ledger_gets_deleted(self):
Line.objects.create(
post=self.post, text="impostor", author=self.taxman,
admin_solicited=False,
)
self.assertEqual(self.post.lines.count(), 0)
def test_solicited_line_on_tax_ledger_survives(self):
Line.objects.create(
post=self.post, text="legit", author=self.taxman,
admin_solicited=True,
)
self.assertEqual(self.post.lines.count(), 1)
class TaxmanReservedUsernameTest(TestCase):
"""`taxman` joins `adman` as a reserved system-author handle."""
def test_taxman_is_reserved(self):
from apps.lyric.models import is_reserved_username
self.assertTrue(is_reserved_username("taxman"))
self.assertTrue(is_reserved_username("TAXMAN")) # case-insensitive
def test_get_or_create_taxman_is_idempotent(self):
a = get_or_create_taxman()
b = get_or_create_taxman()
self.assertEqual(a.pk, b.pk)
self.assertEqual(a.email, "taxman@earthmanrpg.local")

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,22 @@ urlpatterns = [
path("note/<slug:slug>/set-palette", views.note_set_palette, name="note_set_palette"), path("note/<slug:slug>/set-palette", views.note_set_palette, name="note_set_palette"),
path("note/<slug:slug>/don", views.don_title, name="don_title"), path("note/<slug:slug>/don", views.don_title, name="don_title"),
path("note/<slug:slug>/doff", views.doff_title, name="doff_title"), path("note/<slug:slug>/doff", views.doff_title, name="doff_title"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"), path("room/<uuid:room_id>/scroll/", views.scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"), path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
# Post/Line CRUD (relocated from apps.dashboard.urls)
path("new-post", views.new_post, name="new_post"),
path("post/<uuid:post_id>/", views.view_post, name="view_post"),
path("post/<uuid:post_id>/share-post", views.share_post, name="share_post"),
path("post/<uuid:post_id>/delete", views.delete_post, name="delete_post"),
path("post/<uuid:post_id>/abandon", views.abandon_post, name="abandon_post"),
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
path("my-buds/", views.my_buds, name="my_buds"),
path("buds/add", views.add_bud, name="add_bud"),
path("buds/search", views.search_buds, name="search_buds"),
path("buds/<uuid:bud_id>/", views.bud_page, name="bud_page"),
path("buds/<uuid:bud_id>/shoptalk", views.save_bud_shoptalk, name="save_bud_shoptalk"),
path("buds/<uuid:bud_id>/delete", views.delete_bud, name="delete_bud"),
path("my-sign/", views.my_sign, name="my_sign"),
path("my-sign/save", views.save_sign, name="save_sign"),
path("my-sign/clear", views.clear_sign, name="clear_sign"),
] ]

View File

@@ -1,24 +1,31 @@
import json import json
from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.utils.html import mark_safe from django.utils.html import mark_safe
from django.db.models import Max, Q from django.db.models import Max, Q
from django.http import JsonResponse from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from apps.applets.utils import applet_context, apply_applet_toggle from apps.applets.utils import applet_context, apply_applet_toggle
from apps.dashboard.forms import LineForm
from apps.dashboard.models import Post from apps.billboard.forms import ExistingPostLineForm, LineForm
from apps.billboard.models import Brief, BudshipNote, Line, Post
from apps.dashboard.views import _PALETTE_DEFS from apps.dashboard.views import _PALETTE_DEFS
from apps.drama.models import GameEvent, Note, ScrollPosition from apps.drama.models import GameEvent, Note, ScrollPosition
from apps.epic.models import Room from apps.epic.models import Room
from apps.epic.utils import rooms_for_user from apps.epic.utils import annotate_latest_event, rooms_for_user
from apps.lyric.models import User, get_or_create_adman
_PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS} _PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS}
def _recent_posts(user, limit=3): def _recent_posts(user, limit=3):
return ( """Most-recently-active Posts the user owns or is shared on. Attaches
`latest_line` to each Post (Line or None) so the My Posts applet row
can render its 3-col `<title> | <latest line text> | <ts>` shape
without an extra template-side query."""
posts = list(
Post Post
.objects .objects
.filter(Q(owner=user) | Q(shared_with=user)) .filter(Q(owner=user) | Q(shared_with=user))
@@ -26,15 +33,41 @@ def _recent_posts(user, limit=3):
.order_by('-last_line') .order_by('-last_line')
.distinct()[:limit] .distinct()[:limit]
) )
for p in posts:
p.latest_line = p.lines.order_by("-id").first()
return posts
@login_required(login_url="/") def _recent_buds(user, limit=3):
def billboard(request): """Most-recently-added buds, newest first. M2M has no explicit through
my_rooms = rooms_for_user(request.user).order_by("-created_at") model, so we sort the auto-through table by its monotonic `id`. The
`select_related('to_user__active_title')` chain warms up the active
title FK for the My Buds applet row's middle column."""
through = User.buds.through # type: ignore[attr-defined]
rows = (
through.objects
.filter(from_user=user)
.select_related("to_user__active_title")
.order_by("-id")[:limit]
)
return [r.to_user for r in rows]
def _recent_notes(user, limit=3):
"""Most-recently-earned Notes. Attaches `description` from _NOTE_META
so the My Notes applet row can render `<title> | <description> | <ts>`."""
notes = list(user.notes.order_by("-earned_at")[:limit])
for n in notes:
n.description = _NOTE_META.get(n.slug, {}).get("description", "")
return notes
def _billboard_context(user):
my_rooms = annotate_latest_event(rooms_for_user(user).order_by("-created_at"))
recent_room = ( recent_room = (
Room.objects.filter( Room.objects.filter(
Q(owner=request.user) | Q(gate_slots__gamer=request.user) Q(owner=user) | Q(gate_slots__gamer=user)
) )
.annotate(last_event=Max("events__timestamp")) .annotate(last_event=Max("events__timestamp"))
.filter(last_event__isnull=False) .filter(last_event__isnull=False)
@@ -42,25 +75,44 @@ def billboard(request):
.distinct() .distinct()
.first() .first()
) )
recent_events = ( # SIG_READY+retracted exclusion is done in Python because SQLite's NULL
list( # semantics drop ALL SIG_READY events whose data has no `retracted` key:
# `data__retracted=True` resolves to NULL via JSON_EXTRACT for missing keys,
# and `WHERE NOT (NULL AND verb='sig_ready')` evaluates to NULL → row
# filtered out. We pull a buffer (100) to absorb any retracted prefix and
# then slice to 36 after Python filtering.
if recent_room:
candidates = list(
recent_room.events recent_room.events
.select_related("actor") .select_related("actor")
.exclude(verb=GameEvent.SIG_UNREADY) .exclude(verb=GameEvent.SIG_UNREADY)
.exclude(verb=GameEvent.SIG_READY, data__retracted=True) .order_by("-timestamp")[:100]
.order_by("-timestamp")[:36]
)[::-1]
if recent_room else []
) )
visible = [
e for e in candidates
if not (e.verb == GameEvent.SIG_READY and e.data.get("retracted"))
]
recent_events = visible[:36][::-1]
else:
recent_events = []
return render(request, "apps/billboard/billboard.html", { return {
"my_rooms": my_rooms, "my_rooms": my_rooms,
"recent_room": recent_room, "recent_room": recent_room,
"recent_events": recent_events, "recent_events": recent_events,
"viewer": request.user, "viewer": user,
"applets": applet_context(request.user, "billboard"), "applets": applet_context(user, "billboard"),
"form": LineForm(), "form": LineForm(),
"recent_posts": _recent_posts(request.user), "recent_posts": _recent_posts(user),
"recent_buds": _recent_buds(user),
"recent_notes": _recent_notes(user),
}
@login_required(login_url="/")
def billboard(request):
return render(request, "apps/billboard/billboard.html", {
**_billboard_context(request.user),
"page_class": "page-billboard", "page_class": "page-billboard",
}) })
@@ -70,20 +122,20 @@ def toggle_billboard_applets(request):
checked = request.POST.getlist("applets") checked = request.POST.getlist("applets")
apply_applet_toggle(request.user, "billboard", checked) apply_applet_toggle(request.user, "billboard", checked)
if request.headers.get("HX-Request"): if request.headers.get("HX-Request"):
return render(request, "apps/billboard/_partials/_applets.html", { return render(
"applets": applet_context(request.user, "billboard"), request,
"form": LineForm(), "apps/billboard/_partials/_applets.html",
"recent_posts": _recent_posts(request.user), _billboard_context(request.user),
}) )
return redirect("billboard:billboard") return redirect("billboard:billboard")
@login_required(login_url="/") @login_required(login_url="/")
def room_scroll(request, room_id): def scroll(request, room_id):
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
events = room.events.select_related("actor").all() events = room.events.select_related("actor").all()
sp = ScrollPosition.objects.filter(user=request.user, room=room).first() sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
return render(request, "apps/billboard/room_scroll.html", { return render(request, "apps/billboard/scroll.html", {
"room": room, "room": room,
"events": events, "events": events,
"viewer": request.user, "viewer": request.user,
@@ -127,6 +179,12 @@ _NOTE_META = {
"palette_options": [], "palette_options": [],
"swatch_label": "0", "swatch_label": "0",
}, },
"baltimorean": {
"title": "Baltimorean",
"description": '"Aaron earned an iron urn."',
"palette_options": _palette_opts(["palette-baltimore", "palette-maryland"]),
"swatch_label": None,
},
} }
@@ -158,7 +216,7 @@ def my_notes(request):
{ {
"obj": n, "obj": n,
"title": _NOTE_META.get(n.slug, {}).get("title", n.slug), "title": _NOTE_META.get(n.slug, {}).get("title", n.slug),
"recognition_title": n.display_title, "recognition_title": n.card_title,
"description": _NOTE_META.get(n.slug, {}).get("description", ""), "description": _NOTE_META.get(n.slug, {}).get("description", ""),
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []), "palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
"swatch_label": _NOTE_META.get(n.slug, {}).get("swatch_label"), "swatch_label": _NOTE_META.get(n.slug, {}).get("swatch_label"),
@@ -195,6 +253,501 @@ def doff_title(request, slug):
return JsonResponse({"ok": True, "greeting": "Welcome,", "title": "Earthman"}) return JsonResponse({"ok": True, "greeting": "Welcome,", "title": "Earthman"})
# ── My Sign — global Significator picker (billboard surface) ────────────────
# Standalone page where a user picks their global personal significator. The
# selection persists on User.significator + User.significator_reversed and is
# reused across My Sea draws (and eventually other contexts). "Sign" is the
# billboard-context branding; "significator" stays at the storage layer +
# room sig-select context to keep the DRY model. Sprint 4a of
# [[project-my-sea-roadmap]] — picker UI is a simplified lift of the room's
# `_sig_select_overlay.html` (no countdown / WS / polarity / multi-user).
# Deck-source fallback (Brief-redirect to Game Kit when no equipped deck;
# Earthman-Backup default) deferred to a follow-up sub-sprint.
@login_required(login_url="/")
def my_sign(request):
"""Render the picker — same 18-card pile as room sig-select (16 middle
arcana courts + Major 0 & 1), pulled from the user's equipped deck.
Polarity is determined post-hoc by the FLIP btn (significator_reversed).
Backup-deck branch: if the user has no equipped_deck AND no saved sig,
`personal_sig_cards` falls back to the Earthman pile and the template
renders an intro Brief banner labeling the backup as "Earthman [Shabby
Paperboard]" with FYI (→ Game Kit) + NVM (dismiss + proceed) actions."""
from apps.epic.models import personal_sig_cards
cards = personal_sig_cards(request.user)
no_equipped_deck = request.user.equipped_deck is None
sig = request.user.significator
return render(request, "apps/billboard/my_sign.html", {
"cards": cards,
"no_equipped_deck": no_equipped_deck,
"show_backup_intro_banner": no_equipped_deck and sig is None,
"current_significator": sig,
"current_significator_reversed": request.user.significator_reversed,
"page_class": "page-billboard page-my-sign",
})
@login_required(login_url="/")
def save_sign(request):
"""Persist the user's sign choice — POST { card_id, reversed }."""
from apps.epic.models import TarotCard
from apps.gameboard.models import MySeaDraw
if request.method != "POST":
return redirect("billboard:my_sign")
card_id = request.POST.get("card_id")
reversed_flag = request.POST.get("reversed") in ("1", "true", "True", "on")
try:
card = TarotCard.objects.get(pk=card_id)
except (TarotCard.DoesNotExist, ValueError, TypeError):
return HttpResponseForbidden("invalid card_id")
sig_changed = request.user.significator_id != card.pk
request.user.significator = card
request.user.significator_reversed = reversed_flag
request.user.save(update_fields=["significator", "significator_reversed"])
# Sig change RESETS (but does NOT delete) any active MySeaDraw so the
# next /my-sea/ visit reads the NEW sig snapshot — while PRESERVING the
# cooldown anchor (row's `created_at` gates `in_cooldown` per views.py
# line 266) + paid-state fields (`deposit_token_id`, `paid_through_at`).
# Deleting the row would re-open the FREE DRAW gate (loophole — user
# could circumvent the 24h cooldown by re-picking a sig) AND forfeit
# any paid-draw credit the user already committed. Reset clears just
# the hand + sig snapshot, leaving cooldown + paid revenue intact.
if sig_changed:
MySeaDraw.objects.filter(user=request.user).update(
hand=[],
significator_id=card.pk,
significator_reversed=reversed_flag,
)
return redirect("billboard:my_sign")
@login_required(login_url="/")
def clear_sign(request):
"""Wipe the user's saved sig — POST `/billboard/my-sign/clear`.
Sprint 4b-adjacent. Unblocks manual verification of My Sea's no-sig
branch on dev users w. a sig already set; also gives end users a way
to undo a saved choice without re-picking. GET redirects back to the
picker (no mutation) per the existing save_sign convention."""
if request.method != "POST":
return redirect("billboard:my_sign")
request.user.significator = None
request.user.significator_reversed = False
request.user.save(update_fields=["significator", "significator_reversed"])
# MySeaDraw is INTENTIONALLY left alone on sig-clear. Without a sig the
# user can't draw anyway (`my_sea_lock` returns 400 `no_significator`),
# and /my-sea/ routes to its sign-gate Brief via `user_has_sig`. The
# row's cooldown anchor + paid-state fields must survive a sig clear
# so the user can't re-open the FREE DRAW gate or forfeit paid credit
# by toggling sig-clear → sig-pick (user-reported loophole 2026-05-26).
# When the user re-picks via save_sign, that view's reset path updates
# the row's sig snapshot + clears the hand cleanly.
return redirect("billboard:my_sign")
# ── Post / Line CRUD (relocated from apps.dashboard) ────────────────────────
# Templates also live under templates/apps/billboard/. URL names sit in the
# `billboard:` namespace so reversers across the codebase carry the prefix.
def _truncate_post_title(text, length=35):
"""Glean a Post.title from the first user-submitted Line: copy first
`length` chars exactly, or truncate to `length-3` chars + "..." past
that. Mirrors billboard/migrations/0004 backfill helper."""
if len(text) <= length:
return text
return text[: length - 3] + "..."
def new_post(request):
form = LineForm(data=request.POST)
if form.is_valid():
# Anonymous compose path (Percival ch. 18 chapters) keeps owner=null
# but still needs an author for the Line FK. We require auth on this
# view's caller paths in practice; no anonymous Lines reach prod.
author = request.user if request.user.is_authenticated else None
nupost = Post.objects.create(
title=_truncate_post_title(form.cleaned_data["text"]),
)
if request.user.is_authenticated:
nupost.owner = request.user
nupost.save()
if author is not None:
form.save(for_post=nupost, author=author)
return redirect(nupost)
else:
context = {
"form": form,
"page_class": "page-billboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "billboard")
context["recent_posts"] = _recent_posts(request.user)
context["recent_buds"] = _recent_buds(request.user)
context["recent_notes"] = _recent_notes(request.user)
return render(request, "apps/billboard/billboard.html", context)
def view_post(request, post_id):
our_post = Post.objects.get(id=post_id)
if our_post.owner:
if not request.user.is_authenticated:
return redirect("/")
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
return HttpResponseForbidden()
# System-author Post hard write-rejection (note unlock + tax ledger
# threads) — the per-Line signal in billboard.models nukes any Line
# that bypasses this guard, but at the view level we want a clean 403
# so the FT/IT contract is explicit and the client never sees a silent
# line vanish.
if our_post.kind in (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER) and request.method == "POST":
return HttpResponseForbidden()
form = ExistingPostLineForm(for_post=our_post)
if request.method == "POST":
form = ExistingPostLineForm(for_post=our_post, data=request.POST)
if form.is_valid():
form.save(author=request.user)
return redirect(our_post)
# GET render is the FYI-read contract — flip every unread Brief on this
# post for the requesting user. POST (compose) is intentionally excluded
# because the user is authoring, not reviewing the new Line.
if request.user.is_authenticated:
Brief.objects.filter(
owner=request.user, post=our_post, is_unread=True,
).update(is_unread=False)
# Header-prose branching: post.html shows different self/shared lines
# depending on whether the viewer IS the owner. The invitee branch
# ("shared with me, @viewer …" + "created by @owner …") only kicks
# in when (a) the viewer is authenticated AND (b) the post has an
# owner AND (c) the viewer is NOT that owner. Ownerless posts and
# anonymous viewers fall through to the owner-style rendering (which
# handles missing data gracefully via the at_handle/display_name
# filter guards).
is_real_invitee = (
request.user.is_authenticated
and our_post.owner is not None
and request.user != our_post.owner
)
viewer_is_owner = not is_real_invitee
if is_real_invitee:
other_recipients = our_post.shared_with.exclude(pk=request.user.pk)
else:
other_recipients = our_post.shared_with.all()
return render(request, "apps/billboard/post.html", {
"post": our_post,
"form": form,
"viewer_is_owner": viewer_is_owner,
"other_recipients": other_recipients,
"page_class": "page-billpost",
})
def my_posts(request, user_id):
owner = User.objects.get(id=user_id)
if not request.user.is_authenticated:
return redirect("/")
if request.user.id != owner.id:
return HttpResponseForbidden()
return render(request, "apps/billboard/my_posts.html", {
"owner": owner,
"owner_posts_title": "Posts by Me",
"others_posts_title": "Posts by Others",
"page_class": "page-billposts",
})
@login_required
def delete_post(request, post_id):
if request.method == "POST":
post = Post.objects.get(id=post_id)
if request.user == post.owner and post.kind not in (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER):
post.delete()
return redirect("billboard:my_posts", user_id=request.user.id)
@login_required
def abandon_post(request, post_id):
if request.method == "POST":
post = Post.objects.get(id=post_id)
if post.kind not in (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER):
post.shared_with.remove(request.user)
return redirect("billboard:my_posts", user_id=request.user.id)
def share_post(request, post_id):
our_post = Post.objects.get(id=post_id)
is_ajax = "application/json" in request.headers.get("Accept", "")
# Recipient may be email OR username — _resolve_recipient handles both
# (email if "@" present, else username lookup). The raw value is kept
# for the Line text since users see what they typed in the per-line
# rendering (post-refresh + optimistic JS append).
recipient_email = (request.POST.get("recipient") or "").strip()
recipient = _resolve_recipient(recipient_email)
# Sharer-tries-to-share-with-themselves: silent no-op (existing behavior).
if recipient is not None and recipient == request.user:
if is_ajax:
return JsonResponse({"brief": None, "line_text": ""})
return redirect(our_post)
# Re-share dedup: if the recipient is already in shared_with (registered
# email previously shared), skip the Line + Brief — silent no-op.
# `add()` itself is idempotent on M2M, but we want the JSON response to
# signal "nothing happened" so the JS can suppress the banner.
is_reshare = recipient is not None and recipient in our_post.shared_with.all()
if recipient is not None and not is_reshare:
our_post.shared_with.add(recipient)
# Implicit auto-add to the buds graph — symmetric on shared events
# (per-spec): a share-event implies a mutual social link.
# `add()` is idempotent on M2M, no need to pre-check membership.
if request.user.is_authenticated:
request.user.buds.add(recipient)
recipient.buds.add(request.user)
line = None
brief = None
line_text = ""
if not is_reshare:
# Plain "Shared with X" — timestamp display lives on the per-Line
# `<time>` element, not in the prose. Author = sharer (post owner)
# so the per-line "username" column attributes correctly. Anonymous
# shares (legacy Percival ch. 19 ownerless-post path) fall back to
# adman since AnonymousUser can't be FK'd. Privacy: we still create
# the Line + Brief even when the address is unregistered, so the
# response doesn't leak membership.
line_text = f"Shared with {recipient_email}"
author = request.user if request.user.is_authenticated else get_or_create_adman()
line = Line.objects.create(
post=our_post, text=line_text, author=author,
)
if request.user.is_authenticated:
brief = Brief.objects.create(
owner=request.user,
post=our_post,
line=line,
kind=Brief.KIND_SHARE_INVITE,
title="Invite sent",
)
if is_ajax:
# recipient_display is populated only when the address resolves to a
# registered User — same evidence the server-rendered .post-recipient
# list exposes; doesn't widen the privacy surface beyond what the
# post detail page already shows publicly.
recipient_display = None
recipient_user_id = None
if recipient is not None:
recipient_display = recipient.username or recipient.email
recipient_user_id = str(recipient.id)
return JsonResponse({
"brief": brief.to_banner_dict() if brief is not None else None,
"line_text": line_text,
"recipient_display": recipient_display,
"recipient_user_id": recipient_user_id,
"already_present": is_reshare,
})
messages.success(request, "An invite has been sent if that address is registered.")
return redirect(our_post)
# ── My Buds ───────────────────────────────────────────────────────────────
# User.buds is an asymmetric self M2M (lyric/0004 + 0005 rename). `my_buds`
# is the manage-page; `add_bud` is the JSON endpoint hit by the bud-panel
# slide-out. Privacy: when an entered email isn't a registered User, we
# 200 with {bud: null} so the response shape doesn't leak membership.
@login_required(login_url="/")
def my_buds(request):
"""My Buds page — enriched per-row w. shoptalk + milestone for the
tooltip portal (bud landing page sprint 2026-05-27). Attaches
`.shoptalk_text` + `.milestone_dt` to each bud User so the row
template can render data-tt-* attrs without an extra template tag."""
notes_by_bud = {
bn.bud_id: bn
for bn in BudshipNote.objects.filter(user=request.user)
}
buds = list(request.user.buds.all().select_related("active_title"))
for bud in buds:
bn = notes_by_bud.get(bud.id)
bud.shoptalk_text = bn.shoptalk if bn else ""
bud.milestone_dt = bn.edited_at if bn else None
return render(request, "apps/billboard/my_buds.html", {
"buds": buds,
"page_class": "page-billbuds",
})
# ── Per-bud landing page ───────────────────────────────────────────────────
# /billboard/buds/<bud_id>/ + shoptalk save + bud delete — see
# [[project-bud-landing-page-sprint]]. Replaces the @mailman invite Line's
# inline OK/BYE block w. a dedicated surface; bud.html is also the click
# target of the My Buds row's `@<handle>` anchor.
@login_required(login_url="/")
def bud_page(request, bud_id):
"""Render the per-bud landing page. Auto-adds the bud on first visit
(mirrors share_post's implicit-add posture) so following the @mailman
post-attribution anchor from an invite Brief grows the buds graph
without an explicit add step. Self-visits are no-op for the auto-add
branch — users don't accumulate themselves as a bud.
Cascade context (`sea_btn_active` + `sea_first_draw_pending`) reuses
the same template variables `_burger.html` already reads on my_sea +
room — server-side conditional renders `glow-handoff` on the burger
+ `.active` on the sea sub-btn. The flags fire iff a *live* SeaInvite
exists from this bud to the viewer — non-terminal (PENDING or ACCEPTED)
AND inside its 24h-from-proffer window OR within 24h of the viewer's
last gate token deposit (user-spec 2026-05-29, `invitee_access_open`).
Accepting the invite no longer darkens the btn; the cascade now stays
lit across the whole window so the user can reach the bud's sea
(`my_sea_visit` accepts a still-pending invite on GET)."""
from django.shortcuts import get_object_or_404
from apps.gameboard.models import SeaInvite
bud = get_object_or_404(User, id=bud_id)
if bud != request.user and not request.user.buds.filter(id=bud.id).exists():
request.user.buds.add(bud)
bn = BudshipNote.objects.filter(user=request.user, bud=bud).first()
live = (
SeaInvite.objects
.filter(
owner=bud, invitee=request.user,
status__in=[SeaInvite.PENDING, SeaInvite.ACCEPTED],
)
.order_by("-created_at")
.first()
)
if live is not None and not live.invitee_access_open:
live = None
return render(request, "apps/billboard/bud.html", {
"bud": bud,
"shoptalk_text": bn.shoptalk if bn else "",
"milestone_dt": bn.edited_at if bn else None,
"pending_invite": live,
"sea_btn_active": live is not None,
"sea_first_draw_pending": live is not None,
"page_class": "page-billbud",
})
@login_required(login_url="/")
def save_bud_shoptalk(request, bud_id):
"""POST-only — upsert a BudshipNote w. up to 160 chars of shoptalk."""
from django.http import HttpResponseNotAllowed
from django.shortcuts import get_object_or_404
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
bud = get_object_or_404(User, id=bud_id)
text = (request.POST.get("shoptalk") or "")[:160]
BudshipNote.objects.update_or_create(
user=request.user, bud=bud,
defaults={"shoptalk": text},
)
return JsonResponse({"ok": True, "shoptalk": text})
@login_required(login_url="/")
def delete_bud(request, bud_id):
"""POST-only — remove the bud from the user's M2M; redirect to my_buds.
GET is a silent no-op redirect (no membership change)."""
from django.shortcuts import get_object_or_404
if request.method == "POST":
bud = get_object_or_404(User, id=bud_id)
request.user.buds.remove(bud)
return redirect("billboard:my_buds")
def _resolve_recipient(raw):
"""Resolve a free-form recipient (email OR username) to a User, or None.
Email match takes precedence — if the input contains '@' we don't even
try the username lookup, so a username that happens to match an email
user's local part doesn't get coerced. Used by add_bud + share_post."""
raw = (raw or "").strip()
if not raw:
return None
if "@" in raw:
try:
return User.objects.get(email__iexact=raw)
except User.DoesNotExist:
return None
try:
return User.objects.get(username__iexact=raw)
except User.DoesNotExist:
return None
@login_required(login_url="/")
def add_bud(request):
if request.method != "POST":
from django.http import HttpResponseNotAllowed
return HttpResponseNotAllowed(["POST"])
candidate = _resolve_recipient(request.POST.get("recipient"))
bud = None
already_present = False
recipient_display = None
recipient_user_id = None
if candidate is not None and candidate != request.user:
already_present = candidate in request.user.buds.all()
if not already_present:
request.user.buds.add(candidate)
from apps.lyric.templatetags.lyric_extras import at_handle
display = candidate.username or candidate.email
bud = {
"id": str(candidate.id),
"username": display,
"email": candidate.email,
# at_handle + title feed the async row's data-tt-* attrs so its
# tooltip matches the server-rendered rows (regression 2026-05-29).
"at_handle": at_handle(candidate),
"title": candidate.active_title_display,
}
recipient_display = display
recipient_user_id = str(candidate.id)
return JsonResponse({
"bud": bud,
"already_present": already_present,
"recipient_display": recipient_display,
"recipient_user_id": recipient_user_id,
})
@login_required(login_url="/")
def search_buds(request):
"""Top-3 prefix-match autocomplete pool for #id_recipient inputs.
Pulls only from request.user.buds — buds that haven't been added yet
don't appear in the autocomplete (privacy-by-default; new buds enter
the list via explicit add or implicit auto-add on share/invite).
Matches case-insensitive on either username or email prefix."""
from django.db.models import Q
q = (request.GET.get("q") or "").strip()
if not q:
return JsonResponse({"buds": []})
matches = (
request.user.buds
.filter(Q(username__istartswith=q) | Q(email__istartswith=q))
.order_by("username", "email")[:3]
)
return JsonResponse({"buds": [
{
"id": str(b.id),
"username": b.username or b.email,
"email": b.email,
}
for b in matches
]})
@login_required(login_url="/") @login_required(login_url="/")
def save_scroll_position(request, room_id): def save_scroll_position(request, room_id):
if request.method != "POST": if request.method != "POST":

View File

@@ -0,0 +1,27 @@
# Generated by Django 6.0 on 2026-05-08 21:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0002_initial'),
]
operations = [
migrations.RemoveField(
model_name='post',
name='owner',
),
migrations.RemoveField(
model_name='post',
name='shared_with',
),
migrations.DeleteModel(
name='Line',
),
migrations.DeleteModel(
name='Post',
),
]

View File

@@ -1,39 +1,3 @@
import uuid # Post + Line have moved to apps.billboard. The legacy tables are dropped via
# the migration in dashboard/migrations/0003_*; data is preserved via the
from django.db import models # RunPython step in billboard/migrations/0002_*.
from django.urls import reverse
class Post(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
"lyric.User",
related_name="posts",
blank=True,
null=True,
on_delete=models.CASCADE,
)
shared_with = models.ManyToManyField(
"lyric.User",
related_name="shared_posts",
blank=True,
)
@property
def name(self):
return self.lines.first().text
def get_absolute_url(self):
return reverse("view_post", args=[self.id])
class Line(models.Model):
text = models.TextField(default="")
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
class Meta:
ordering = ("id",)
unique_together = ("post", "text")
def __str__(self):
return self.text

View File

@@ -64,13 +64,42 @@
if (!tooltip) return; if (!tooltip) return;
var rect = el.getBoundingClientRect(); var rect = el.getBoundingClientRect();
tooltip.style.position = 'fixed'; tooltip.style.position = 'fixed';
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; // Show first so offsetWidth/offsetHeight measure real layout,
tooltip.style.left = rect.left + 'px'; // then clamp both axes so the tooltip never bleeds past the
// viewport. Same shape as sky-wheel.js + wallet.js: 1rem inset
// margin on every edge.
tooltip.style.display = 'block'; tooltip.style.display = 'block';
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
var ttW = tooltip.offsetWidth;
var ttH = tooltip.offsetHeight;
// Horizontal clamp — left edge stays within [rem, viewport-ttW-rem].
var minLeft = rem;
var maxLeft = window.innerWidth - ttW - rem;
var clampedLeft = Math.max(minLeft, Math.min(rect.left, maxLeft));
tooltip.style.left = clampedLeft + 'px';
// Vertical: prefer ABOVE the element; flip BELOW when the
// tooltip is too tall to fit above (e.g. in landscape where
// the kit bag dialog runs along the top of the right sidebar
// + tokens row anchors near the top of the viewport).
var spaceAbove = rect.top - rem;
if (ttH <= spaceAbove) {
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
tooltip.style.top = '';
} else {
tooltip.style.top = (rect.bottom + 8) + 'px';
tooltip.style.bottom = '';
}
}); });
el.addEventListener('mouseleave', function () { el.addEventListener('mouseleave', function () {
var tooltip = el.querySelector('.tt'); var tooltip = el.querySelector('.tt');
if (tooltip) tooltip.style.display = ''; if (tooltip) {
tooltip.style.display = '';
// Reset positional props so the next show measures fresh.
tooltip.style.top = '';
tooltip.style.bottom = '';
}
}); });
} }

View File

@@ -1,49 +1,180 @@
const Note = (() => { // Slide-down Brief banner — appears under the navbar h2 with a Gaussian-glass
// background. Banner data comes from the Brief.to_banner_dict shape on the
// server: {id, kind, title, line_text, post_url, square_url, created_at}.
//
// FYI button → brief.post_url (the post detail; that GET marks the Brief read).
// .note-banner__image (the "square") → brief.square_url (kind-specific
// shortcut; note-unlock briefs jump direct to /billboard/my-notes/).
// NVM dismisses without marking read.
//
// `Note` is preserved as an alias for backwards-compat with any leftover
// caller while the C3 sprint lands; new code should use `Brief.*`.
const Brief = (() => {
'use strict'; 'use strict';
function showBanner(note) { function showBanner(brief) {
if (!note) return; if (!brief) return;
const earned = new Date(note.earned_at); const created = new Date(brief.created_at);
const dateStr = earned.toLocaleDateString(undefined, { const dateStr = created.toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric', year: 'numeric', month: 'short', day: 'numeric',
}); });
const banner = document.createElement('div'); const banner = document.createElement('div');
banner.className = 'note-banner'; banner.className = 'note-banner';
// The square mirrors my_notes.html's .note-item__image-box (dashed
// border + "?" placeholder) when the brief carries a square_url —
// currently note_unlock kind, which jumps direct to /billboard/my-notes/.
const squareEl = brief.square_url
? '<a href="' + _esc(brief.square_url) + '" class="note-banner__image note-item__image-box">?</a>'
: '<div class="note-banner__image"></div>';
// line_text is server-rendered prose from drama.Note.grant_if_new
// (and server-side share_post) — it may carry a `<a class="note-ref">`
// anchor wrapping the Note name. Insert as HTML, NOT escaped text.
// Title is plain (no HTML), so it stays escaped.
banner.innerHTML = banner.innerHTML =
'<div class="note-banner__body">' + '<div class="note-banner__body">' +
'<p class="note-banner__title">' + _esc(note.title) + '</p>' + '<p class="note-banner__title">' + _esc(brief.title) + '</p>' +
'<p class="note-banner__description">' + _esc(note.description) + '</p>' + '<p class="note-banner__description">' + (brief.line_text || '') + '</p>' +
'<time class="note-banner__timestamp" datetime="' + _esc(note.earned_at) + '">' + '<time class="note-banner__timestamp" datetime="' + _esc(brief.created_at) + '">' +
dateStr + dateStr +
'</time>' + '</time>' +
'</div>' + '</div>' +
'<div class="note-banner__image"></div>' + squareEl +
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' + '<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' +
'<a href="/billboard/my-notes/" class="btn btn-info note-banner__fyi">FYI</a>'; '<a href="' + _esc(brief.post_url) + '" class="btn btn-info note-banner__fyi">FYI</a>';
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
// Persistent-NVM kinds (TAX_LEDGER FREE/PAID DRAW per user-spec
// 2026-05-26) carry a `dismiss_url` — POST it so the dismissal
// anchor is stamped server-side + the Brief stays suppressed on
// future page loads until the cycle resets. Fire-and-forget; the
// banner removal is unconditional so the user gets immediate
// feedback regardless of network state.
if (brief.dismiss_url) {
var csrfMatch = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
fetch(brief.dismiss_url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-CSRFToken': csrfMatch ? decodeURIComponent(csrfMatch[1]) : '',
},
});
}
banner.remove();
});
// Prefer the explicit anchor (set in base.html under the messages
// block, before {% block content %}) — keeps the banner in the
// visible content flow on pages where the first <h2> is
// position:absolute (e.g. post.html's rotated navbar header).
// Falls back to <h2> for pages that pre-date the anchor.
var anchor = document.getElementById('id_brief_banner_anchor')
|| document.querySelector('h2');
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(banner, anchor.nextSibling);
} else {
document.body.insertBefore(banner, document.body.firstChild);
}
_alignToH2(banner);
}
// Slot the (position:fixed) banner over the page's h2 letter-spread
// header so it visually portals across the wordmark rather than parking
// at the top of the viewport.
//
// Portrait only: h2 is a horizontal wordmark in `.container .row`; its
// top edge is navbar-height + row-padding which differs per viewport,
// so we measure at render-time + set inline `top`.
//
// Landscape: h2 is rotated into the fixed --h2-col-w sidebar slot via
// `writing-mode: vertical-rl`. A horizontally-flowing banner can't
// "overlay" a vertical h2 without a full reorientation of its own
// contents (writing-mode + flex-direction restyle). Defer that to a
// landscape-vertical-banner sprint; for now landscape keeps the SCSS
// default `top: 0.5rem` (banner reads as a horizontal strip near the
// top of the page content area).
function _alignToH2(banner) {
if (window.innerWidth > window.innerHeight) return; // landscape: skip
var h2 = document.querySelector('.row .col-lg-6 h2');
if (!h2) return;
var rect = h2.getBoundingClientRect();
if (rect.height <= 0) return;
banner.style.top = rect.top + 'px';
}
function handleSaveResponse(data) {
showBanner(data && data.brief);
}
// Error-variant banner for "@<username> is already present". Distinct
// from showBanner because it's purely client-side (no Brief DB row);
// title carries the recipient handle; there's no date/square/post_url;
// FYI is a <button> (no navigation) that toggles `.bud-duplicate-flash`
// onto a caller-supplied target element + dismisses. NVM just dismisses.
function showDuplicateBanner(opts) {
if (!opts || !opts.display_name) return;
var banner = document.createElement('div');
banner.className = 'note-banner note-banner--duplicate';
banner.innerHTML =
'<div class="note-banner__body">' +
'<p class="note-banner__title">@' + _esc(opts.display_name) + ' is already present</p>' +
'</div>' +
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' +
'<button type="button" class="btn btn-info note-banner__fyi">FYI</button>';
banner.querySelector('.note-banner__nvm').addEventListener('click', function () { banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
banner.remove(); banner.remove();
}); });
banner.querySelector('.note-banner__fyi').addEventListener('click', function () {
if (opts.target_selector) {
var target = document.querySelector(opts.target_selector);
if (target) {
target.classList.add('bud-duplicate-flash');
// Auto-ease-out — the SCSS rule has a 600ms transition
// on color + text-shadow, so removing the class 3s after
// FYI lets the peak state breathe before fading back to
// the default text styling. Without this the flash
// persisted til page refresh.
setTimeout(function () {
target.classList.remove('bud-duplicate-flash');
}, 3000);
}
}
banner.remove();
});
var h2 = document.querySelector('h2'); var anchor = document.getElementById('id_brief_banner_anchor')
if (h2 && h2.parentNode) { || document.querySelector('h2');
h2.parentNode.insertBefore(banner, h2.nextSibling); if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(banner, anchor.nextSibling);
} else { } else {
document.body.insertBefore(banner, document.body.firstChild); document.body.insertBefore(banner, document.body.firstChild);
} }
} }
function handleSaveResponse(data) {
showBanner(data && data.note);
}
function _esc(str) { function _esc(str) {
var d = document.createElement('div'); var d = document.createElement('div');
d.textContent = str || ''; d.textContent = str || '';
return d.innerHTML; return d.innerHTML;
} }
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse }; return {
showBanner: showBanner,
showDuplicateBanner: showDuplicateBanner,
handleSaveResponse: handleSaveResponse,
};
})(); })();
// Backwards-compat shim — to be removed once the codebase uniformly uses Brief.
const Note = Brief;
// `const Brief = (...)` at script-tag scope is reachable as a bare name but
// is NOT auto-attached to window — explicit assignment so callers that gate
// on `if (window.Brief)` (e.g. _bud_panel.html's OK handler) succeed.
window.Brief = Brief;
window.Note = Note;

View File

@@ -0,0 +1,195 @@
// wallet-shop.js — BUY-ITEM click handler for the wallet's Shop applet.
//
// Flow:
// 1. User clicks a `.tt-buy-btn` inside a `.shop-tile`.
// 2. We open the global guard portal (`window.showGuard`) w. a prompt
// naming the item + price.
// 3. On OK confirm → POST /dashboard/wallet/shop/buy {shop_item_slug} →
// get {client_secret, purchase_id}.
// 4. Stripe.js confirmCardPayment (handles 3DS natively).
// 5. On Stripe success → POST /dashboard/wallet/shop/confirm {purchase_id}.
// 6. Reload the wallet (tokens + balances update server-side via the
// Purchase.fulfill() chain that fires from BOTH /shop/confirm AND
// the /stripe/webhook handler — whichever lands first, idempotent).
//
// Disabled tiles (`.btn-disabled` on the BUY btn — eg already-owned BAND)
// are a no-op: clicks don't open the guard portal.
//
// Module pattern matches the rest of the project (`Brief`, `SeaDeal`,
// `StageCard`) — exposes a singleton `WalletShop` w. a tested public
// `initWalletShop()` method.
const WalletShop = (function () {
'use strict';
function _getCsrf() {
const m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
async function _doBuy(slug, btn, shopRoot) {
const pmId = shopRoot.dataset.defaultPaymentMethodId || '';
const pubKey = shopRoot.dataset.stripePublishableKey || '';
if (!pmId) {
// No saved payment method — surface a friendly message + bail.
// (Server-side this would 402, but we'd rather not waste the
// round trip OR confuse the user w. a stripe-side error.)
alert('Add a payment method below first.');
return;
}
const buyRes = await fetch('/dashboard/wallet/shop/buy', {
method: 'POST',
headers: {
'X-CSRFToken': _getCsrf(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'shop_item_slug=' + encodeURIComponent(slug),
});
if (!buyRes.ok) {
alert('Could not start purchase (' + buyRes.status + ').');
return;
}
const { client_secret, purchase_id } = await buyRes.json();
const stripe = window.Stripe(pubKey);
const { error, paymentIntent } = await stripe.confirmCardPayment(
client_secret, { payment_method: pmId },
);
if (error) {
alert('Payment failed: ' + (error.message || 'unknown error'));
return;
}
if (paymentIntent && paymentIntent.status === 'succeeded') {
// Sync-confirm endpoint: belt-and-suspenders w. the webhook —
// whichever lands first wins via Purchase.fulfill()'s idempotent
// status guard. We always call it for the snappy UX.
await fetch('/dashboard/wallet/shop/confirm', {
method: 'POST',
headers: {
'X-CSRFToken': _getCsrf(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'purchase_id=' + encodeURIComponent(purchase_id),
});
// Reload to refresh the Tokens + Balances applets w. the
// freshly-minted tokens + writs.
window.location.reload();
}
}
async function _doClaimFree(slug) {
// Free decks (RWS / Fiorentine) — $0, no Stripe, no guard portal.
// One click POSTs the claim; the server adds the DeckVariant to
// `unlocked_decks` (→ Game Kit on /gameboard/).
const res = await fetch('/dashboard/wallet/shop/claim', {
method: 'POST',
headers: {
'X-CSRFToken': _getCsrf(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'deck_slug=' + encodeURIComponent(slug),
});
if (!res.ok) {
alert('Could not claim deck (' + res.status + ').');
return;
}
// Reload so the tile re-renders w. the 'Already owned' pill — server
// is the source of truth (same posture as the BUY flow's reload).
window.location.reload();
}
function _onFreeClick(e) {
const btn = e.target.closest('.tt-free-btn');
if (!btn) return;
if (btn.classList.contains('btn-disabled')) return;
e.preventDefault();
e.stopPropagation();
const slug = btn.dataset.deckSlug;
if (!slug) return;
_doClaimFree(slug);
}
function _onBuyClick(e) {
const btn = e.target.closest('.tt-buy-btn');
if (!btn) return;
if (btn.classList.contains('btn-disabled')) return;
e.preventDefault();
e.stopPropagation();
// The btn may be the original (inside .shop-tile) OR the portal
// clone (sibling of .wallet-page) — `closest('.shop-tile')` only
// works on the former. Read every datum from the btn itself + look
// up the shop root via document.querySelector (singleton).
const shopRoot = document.querySelector('.wallet-shop');
if (!shopRoot) return;
const slug = btn.dataset.shopItemSlug;
const name = btn.dataset.itemName || slug;
const priceCents = parseInt(btn.dataset.priceCents || '0', 10);
// Render "$1" / "$4" / "$20.50" — trim ".00" for whole-dollar prices.
const dollars = priceCents / 100;
const priceStr = (dollars === Math.floor(dollars))
? '$' + dollars
: '$' + dollars.toFixed(2);
const message = 'Buy ' + name + ' for ' + priceStr + '?';
if (typeof window.showGuard === 'function') {
// Pin the wallet tooltip so the item card + microbtn stay
// visible while the guard portal is open — otherwise the
// cursor moving from the BUY btn down to the guard's OK/NVM
// triggers the tooltip's mouseleave, leaving the guard's
// "Buy {name} for ${price}?" prompt floating w. no referent.
// Both callbacks (confirm + dismiss) unpin so the next
// mouseleave (or the unpin-triggered _scheduleHide) dismisses
// the tooltip after the user resolves the prompt.
if (window.WalletTooltips && typeof window.WalletTooltips.pin === 'function') {
window.WalletTooltips.pin();
}
window.showGuard(
btn, message,
function () { // onConfirm
if (window.WalletTooltips && window.WalletTooltips.unpin) {
window.WalletTooltips.unpin();
}
_doBuy(slug, btn, shopRoot);
},
function () { // onDismiss
if (window.WalletTooltips && window.WalletTooltips.unpin) {
window.WalletTooltips.unpin();
}
},
);
}
}
function initWalletShop() {
const shopRoot = document.querySelector('.wallet-shop');
if (!shopRoot) return;
// Wired-state is DOM-keyed (`data-shop-wired`) rather than a
// module-scope flag so per-test fixture rebuilds re-wire cleanly.
// Calling twice on the SAME root is still a safe no-op (idempotent).
if (shopRoot.dataset.shopWired === '1') return;
// Triple delegation — the BUY btn click can come from:
// (a) original tile (inside `.shop-tile`) — shopRoot listener
// (b) cloned main portal — kept for legacy / non-mini-portal pages
// (c) cloned mini portal (`#id_mini_tooltip_portal`) — production
// path post-microtooltip refactor; the BUY btn lives in
// `.tt-micro` which clones into the mini portal on hover.
// Free-deck claim (`.tt-free-btn`) rides the SAME delegated roots as
// the BUY flow — the FREE ITEM btn lives in `.tt-micro` (clones into
// the mini portal on hover, same as BUY ITEM).
shopRoot.addEventListener('click', _onBuyClick);
shopRoot.addEventListener('click', _onFreeClick);
const portal = document.getElementById('id_tooltip_portal');
if (portal) {
portal.addEventListener('click', _onBuyClick);
portal.addEventListener('click', _onFreeClick);
}
const miniPortal = document.getElementById('id_mini_tooltip_portal');
if (miniPortal) {
miniPortal.addEventListener('click', _onBuyClick);
miniPortal.addEventListener('click', _onFreeClick);
}
shopRoot.dataset.shopWired = '1';
}
return { initWalletShop: initWalletShop };
})();
document.addEventListener('DOMContentLoaded', WalletShop.initWalletShop);

View File

@@ -64,17 +64,60 @@ const initWallet = () => {
}); });
}; };
// `WalletTooltips` module — exposes pin/unpin so other JS (eg. wallet-shop.js'
// BUY-flow guard portal) can hold the wallet tooltip open across user
// interactions that would otherwise dismiss it. The internals (hide
// timer, _show/_hide helpers) live inside the singleton's IIFE — only
// pin/unpin/initWalletTooltips are public.
const WalletTooltips = (function () {
'use strict';
let _hideTimer = null;
let _pinned = false;
const HIDE_DELAY_MS = 200;
let _portal = null;
let _miniPortal = null;
function _hideAll() {
if (_portal) _portal.classList.remove('active');
if (_miniPortal) _miniPortal.classList.remove('active');
}
function _cancelHide() {
if (_hideTimer) { clearTimeout(_hideTimer); _hideTimer = null; }
}
function _scheduleHide() {
// Pinned (eg. guard portal open) — suppress the hide. unpin() will
// call _scheduleHide() again to dismiss after the guard closes.
if (_pinned) return;
_cancelHide();
_hideTimer = setTimeout(() => { _hideAll(); _hideTimer = null; }, HIDE_DELAY_MS);
}
function pin() { _pinned = true; _cancelHide(); }
function unpin(){ _pinned = false; _scheduleHide(); }
function initWalletTooltips() { function initWalletTooltips() {
const portal = document.getElementById('id_tooltip_portal'); const portal = document.getElementById('id_tooltip_portal');
if (!portal) return; if (!portal) return;
_portal = portal;
// Mini portal — used by shop tiles (BUY ITEM / "Already owned" pill).
// Tokens applet tiles have no `.tt-micro` sibling so the mini stays
// hidden on those hovers. Mirrors gameboard.html's mini portal.
const miniPortal = document.getElementById('id_mini_tooltip_portal');
_miniPortal = miniPortal;
document.querySelectorAll('.wallet-tokens .token').forEach(token => { // Hover-persistence — keep the portal(s) open while the cursor moves
const tooltip = token.querySelector('.tt'); // from tile → portal → mini-portal so users can click the BUY-ITEM
if (!tooltip) return; // microbutton. A short hide delay covers the gap between
// mouseleave-on-tile and mouseenter-on-portal; entering any of the
// 3 zones cancels the hide.
token.addEventListener('mouseenter', () => { function _show(anchor, tooltipHtml, microHtml) {
const rect = token.getBoundingClientRect(); _cancelHide();
portal.innerHTML = tooltip.innerHTML; const rect = anchor.getBoundingClientRect();
portal.innerHTML = tooltipHtml;
portal.classList.add('active'); portal.classList.add('active');
const halfW = portal.offsetWidth / 2; const halfW = portal.offsetWidth / 2;
const rawLeft = rect.left + rect.width / 2; const rawLeft = rect.left + rect.width / 2;
@@ -82,13 +125,52 @@ function initWalletTooltips() {
portal.style.left = Math.round(clampedLeft) + 'px'; portal.style.left = Math.round(clampedLeft) + 'px';
portal.style.top = Math.round(rect.top) + 'px'; portal.style.top = Math.round(rect.top) + 'px';
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))'; portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
});
token.addEventListener('mouseleave', () => { if (miniPortal && microHtml) {
portal.classList.remove('active'); miniPortal.innerHTML = microHtml;
}); miniPortal.classList.add('active');
}); // Pin mini-portal to the bottom-right of the main portal —
// same anchor pattern as gameboard.js's gameKit tooltips.
const mainRect = portal.getBoundingClientRect();
miniPortal.style.left = '';
miniPortal.style.right = Math.round(window.innerWidth - mainRect.right) + 'px';
miniPortal.style.top = (mainRect.bottom + 4) + 'px';
} else if (miniPortal) {
miniPortal.classList.remove('active');
}
}
function _bindHover(anchor) {
const tooltip = anchor.querySelector('.tt');
if (!tooltip) return;
// `.tt-micro` is a SIBLING of `.tt` (not a child) so it lives
// alongside the main tooltip content without nesting — keeps the
// BUY-ITEM btn visually distinct in the mini portal.
const micro = anchor.querySelector(':scope > .tt-micro');
const microHtml = micro ? micro.innerHTML : null;
anchor.addEventListener('mouseenter', () => _show(anchor, tooltip.innerHTML, microHtml));
anchor.addEventListener('mouseleave', _scheduleHide);
}
document.querySelectorAll('.wallet-tokens .token, .wallet-shop .shop-tile')
.forEach(_bindHover);
// Re-entering either portal cancels the pending hide — keeps the
// microbutton clickable. Leaving either restarts the hide timer.
portal.addEventListener('mouseenter', _cancelHide);
portal.addEventListener('mouseleave', _scheduleHide);
if (miniPortal) {
miniPortal.addEventListener('mouseenter', _cancelHide);
miniPortal.addEventListener('mouseleave', _scheduleHide);
}
} }
return { initWalletTooltips: initWalletTooltips, pin: pin, unpin: unpin };
})();
// Expose globally so wallet-shop.js can call WalletTooltips.pin/unpin
// without depending on script-load order.
window.WalletTooltips = WalletTooltips;
document.addEventListener('DOMContentLoaded', initWallet); document.addEventListener('DOMContentLoaded', initWallet);
document.addEventListener('DOMContentLoaded', initWalletTooltips); document.addEventListener('DOMContentLoaded', WalletTooltips.initWalletTooltips);

View File

@@ -1,25 +1,32 @@
from django.test import TestCase from django.test import TestCase
from apps.dashboard.forms import ( from apps.billboard.forms import (
DUPLICATE_LINE_ERROR, DUPLICATE_LINE_ERROR,
EMPTY_LINE_ERROR, EMPTY_LINE_ERROR,
ExistingPostLineForm, ExistingPostLineForm,
LineForm, LineForm,
) )
from apps.dashboard.models import Line, Post from apps.billboard.models import Line, Post
from apps.lyric.models import User
class LineFormTest(TestCase): class LineFormTest(TestCase):
def setUp(self):
self.author = User.objects.create(email="author@test.io")
def test_form_save_handles_saving_to_a_post(self): def test_form_save_handles_saving_to_a_post(self):
mypost = Post.objects.create() mypost = Post.objects.create()
form = LineForm(data={"text": "do re mi"}) form = LineForm(data={"text": "do re mi"})
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
new_line = form.save(for_post=mypost) new_line = form.save(for_post=mypost, author=self.author)
self.assertEqual(new_line, Line.objects.get()) self.assertEqual(new_line, Line.objects.get())
self.assertEqual(new_line.text, "do re mi") self.assertEqual(new_line.text, "do re mi")
self.assertEqual(new_line.post, mypost) self.assertEqual(new_line.post, mypost)
class ExistingPostLineFormTest(TestCase): class ExistingPostLineFormTest(TestCase):
def setUp(self):
self.author = User.objects.create(email="author@test.io")
def test_form_validation_for_blank_lines(self): def test_form_validation_for_blank_lines(self):
post = Post.objects.create() post = Post.objects.create()
form = ExistingPostLineForm(for_post=post, data={"text": ""}) form = ExistingPostLineForm(for_post=post, data={"text": ""})
@@ -28,7 +35,7 @@ class ExistingPostLineFormTest(TestCase):
def test_form_validation_for_duplicate_lines(self): def test_form_validation_for_duplicate_lines(self):
post = Post.objects.create() post = Post.objects.create()
Line.objects.create(post=post, text="twins, basil") Line.objects.create(post=post, text="twins, basil", author=self.author)
form = ExistingPostLineForm(for_post=post, data={"text": "twins, basil"}) form = ExistingPostLineForm(for_post=post, data={"text": "twins, basil"})
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR]) self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR])
@@ -37,5 +44,5 @@ class ExistingPostLineFormTest(TestCase):
mypost = Post.objects.create() mypost = Post.objects.create()
form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"}) form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"})
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
new_line = form.save() new_line = form.save(author=self.author)
self.assertEqual(new_line, Line.objects.get()) self.assertEqual(new_line, Line.objects.get())

View File

@@ -2,69 +2,71 @@ from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.test import TestCase from django.test import TestCase
from apps.dashboard.models import Line, Post from apps.billboard.models import Line, Post
from apps.lyric.models import User from apps.lyric.models import User
class LineModelTest(TestCase): class LineModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
def test_line_is_related_to_post(self): def test_line_is_related_to_post(self):
mypost = Post.objects.create() mypost = Post.objects.create()
line = Line() line = Line(post=mypost, author=self.user, text="x")
line.post = mypost
line.save() line.save()
self.assertIn(line, mypost.lines.all()) self.assertIn(line, mypost.lines.all())
def test_cannot_save_null_post_lines(self): def test_cannot_save_null_post_lines(self):
mypost = Post.objects.create() mypost = Post.objects.create()
line = Line(post=mypost, text=None) line = Line(post=mypost, author=self.user, text=None)
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
line.save() line.save()
def test_cannot_save_empty_post_lines(self): def test_cannot_save_empty_post_lines(self):
mypost = Post.objects.create() mypost = Post.objects.create()
line = Line(post=mypost, text="") line = Line(post=mypost, author=self.user, text="")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
line.full_clean() line.full_clean()
def test_duplicate_lines_are_invalid(self): def test_duplicate_lines_are_invalid(self):
mypost = Post.objects.create() mypost = Post.objects.create()
Line.objects.create(post=mypost, text="jklol") Line.objects.create(post=mypost, author=self.user, text="jklol")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
line = Line(post=mypost, text="jklol") line = Line(post=mypost, author=self.user, text="jklol")
line.full_clean() line.full_clean()
def test_still_can_save_same_line_to_different_posts(self): def test_still_can_save_same_line_to_different_posts(self):
post1 = Post.objects.create() post1 = Post.objects.create()
post2 = Post.objects.create() post2 = Post.objects.create()
Line.objects.create(post=post1, text="nojk") Line.objects.create(post=post1, author=self.user, text="nojk")
line = Line(post=post2, text="nojk") line = Line(post=post2, author=self.user, text="nojk")
line.full_clean() # should not raise line.full_clean() # should not raise
class PostModelTest(TestCase): class PostModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
def test_get_absolute_url(self): def test_get_absolute_url(self):
mypost = Post.objects.create() mypost = Post.objects.create()
self.assertEqual(mypost.get_absolute_url(), f"/dashboard/post/{mypost.id}/") self.assertEqual(mypost.get_absolute_url(), f"/billboard/post/{mypost.id}/")
def test_post_lines_order(self): def test_post_lines_order(self):
post1 = Post.objects.create() post1 = Post.objects.create()
line1 = Line.objects.create(post=post1, text="i1") line1 = Line.objects.create(post=post1, author=self.user, text="i1")
line2 = Line.objects.create(post=post1, text="line 2") line2 = Line.objects.create(post=post1, author=self.user, text="line 2")
line3 = Line.objects.create(post=post1, text="3") line3 = Line.objects.create(post=post1, author=self.user, text="3")
self.assertEqual( self.assertEqual(
list(post1.lines.all()), list(post1.lines.all()),
[line1, line2, line3], [line1, line2, line3],
) )
def test_posts_can_have_owners(self): def test_posts_can_have_owners(self):
user = User.objects.create(email="a@b.cde") mypost = Post.objects.create(owner=self.user)
mypost = Post.objects.create(owner=user) self.assertIn(mypost, self.user.posts.all())
self.assertIn(mypost, user.posts.all())
def test_post_owner_is_optional(self): def test_post_owner_is_optional(self):
Post.objects.create() Post.objects.create()
def test_post_name_is_first_line_text(self): def test_post_title_is_explicit_field(self):
post = Post.objects.create() post = Post.objects.create(title="first line")
Line.objects.create(post=post, text="first line") self.assertEqual(post.title, "first line")
Line.objects.create(post=post, text="second line")
self.assertEqual(post.name, "first line")

View File

@@ -0,0 +1,472 @@
"""Shop view ITs — Chunk 3 of the wallet-expansion sprint.
Three endpoints under test:
* `POST /dashboard/wallet/shop/buy` (`shop_buy`) — creates a Stripe
PaymentIntent + a `Purchase` row in PENDING; returns
`{client_secret, purchase_id}` for Stripe.js confirmCardPayment.
* `POST /dashboard/wallet/shop/confirm` (`shop_confirm`) — sync follow-up
after Stripe.js confirms client-side; retrieves the PI from Stripe,
if `status=='succeeded'` calls `Purchase.fulfill()` (idempotent w. the
webhook's parallel call).
* `POST /stripe/webhook` (`stripe_webhook`) — async fulfillment fallback
+ bulletproof for 3DS-completed cards. Verifies signature against
`STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
`Purchase.fulfill()` (same idempotent method as `shop_confirm`).
`apps.dashboard.views.stripe` is mocked across all three. The webhook
view's signature verification is also mocked via
`stripe.Webhook.construct_event`.
"""
from unittest import mock
from django.test import TestCase, override_settings
from apps.epic.models import DeckVariant
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User
def _seed_free_decks():
"""Two free-in-shop decks (RWS + Fiorentine) + one paid deck (Earthman,
free_in_shop=False) so the claim endpoint's `free_in_shop` guard is
exercised. Mirrors the seed-migration row shapes (TestCase rolls back
the data migrations)."""
rws, _ = DeckVariant.objects.update_or_create(
slug="tarot-rider-waite-smith",
defaults={
"name": "Tarot (Rider-Waite-Smith)", "card_count": 78,
"family": "english", "has_card_images": False,
"is_polarized": False, "free_in_shop": True,
},
)
fiorentine, _ = DeckVariant.objects.update_or_create(
slug="minchiate-fiorentine-1860-1890",
defaults={
"name": "Minchiate Fiorentine (18601890)", "card_count": 97,
"family": "italian", "has_card_images": True,
"is_polarized": False, "free_in_shop": True,
"description": "97-card Minchiate Fiorentine deck.",
},
)
earthman, _ = DeckVariant.objects.update_or_create(
slug="earthman",
defaults={
"name": "Earthman", "card_count": 106, "is_default": True,
"is_polarized": True, "has_card_images": False,
"free_in_shop": False,
},
)
return rws, fiorentine, earthman
class ShopClaimFreeViewTest(TestCase):
"""`POST /dashboard/wallet/shop/claim` (`shop_claim_free`) — adds a free-
in-shop DeckVariant to the user's `unlocked_decks` so it appears in the
Game Kit applet. No Stripe, no Purchase row, idempotent."""
def setUp(self):
self.rws, self.fiorentine, self.earthman = _seed_free_decks()
self.user = User.objects.create(email="decker@test.io")
# Start from a known unlock set — strip the signal's auto-grant so
# the claim is the only thing that adds a deck.
self.user.unlocked_decks.clear()
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.post(
"/dashboard/wallet/shop/claim",
{"deck_slug": "tarot-rider-waite-smith"},
)
self.assertRedirects(
response, "/?next=/dashboard/wallet/shop/claim",
fetch_redirect_response=False,
)
def test_claim_adds_deck_to_unlocked(self):
response = self.client.post(
"/dashboard/wallet/shop/claim",
{"deck_slug": "tarot-rider-waite-smith"},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(
self.user.unlocked_decks.filter(pk=self.rws.pk).exists()
)
def test_claim_returns_owned_json(self):
response = self.client.post(
"/dashboard/wallet/shop/claim",
{"deck_slug": "tarot-rider-waite-smith"},
)
body = response.json()
self.assertTrue(body["owned"])
self.assertEqual(body["deck_name"], "Tarot (Rider-Waite-Smith)")
def test_claim_is_idempotent(self):
for _ in range(2):
response = self.client.post(
"/dashboard/wallet/shop/claim",
{"deck_slug": "tarot-rider-waite-smith"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
self.user.unlocked_decks.filter(pk=self.rws.pk).count(), 1
)
def test_unknown_slug_returns_404(self):
response = self.client.post(
"/dashboard/wallet/shop/claim", {"deck_slug": "no-such-deck"},
)
self.assertEqual(response.status_code, 404)
def test_non_free_deck_cannot_be_claimed(self):
"""Guard: a deck with free_in_shop=False (eg Earthman, or any paid
deck) is NOT claimable via this $0 endpoint → 404, no unlock."""
response = self.client.post(
"/dashboard/wallet/shop/claim", {"deck_slug": "earthman"},
)
self.assertEqual(response.status_code, 404)
self.assertFalse(
self.user.unlocked_decks.filter(pk=self.earthman.pk).exists()
)
class WalletFreeDecksContextTest(TestCase):
"""The wallet page exposes `free_decks` — the free-in-shop DeckVariants
decorated w. a per-user `.owned` flag the Shop applet uses to render
FREE ITEM vs 'Already owned'."""
def setUp(self):
self.rws, self.fiorentine, self.earthman = _seed_free_decks()
self.user = User.objects.create(email="ctx@test.io")
self.user.unlocked_decks.clear()
self.client.force_login(self.user)
def test_free_decks_in_context_unowned_initially(self):
response = self.client.get("/dashboard/wallet/")
free = {d.slug: d for d in response.context["free_decks"]}
# Both free decks present; the paid Earthman is NOT listed here.
self.assertIn("tarot-rider-waite-smith", free)
self.assertIn("minchiate-fiorentine-1860-1890", free)
self.assertNotIn("earthman", free)
self.assertFalse(free["tarot-rider-waite-smith"].owned)
def test_free_deck_marked_owned_after_claim(self):
self.user.unlocked_decks.add(self.rws)
response = self.client.get("/dashboard/wallet/")
free = {d.slug: d for d in response.context["free_decks"]}
self.assertTrue(free["tarot-rider-waite-smith"].owned)
self.assertFalse(free["minchiate-fiorentine-1860-1890"].owned)
def _seed_starting_items():
"""Mirror the seed-migration row shape so each TestCase starts w. a
known catalog (TestCase rolls back the data migration, so the rows
seeded by `0009_seed_shop_items` aren't there during tests)."""
ShopItem.objects.update_or_create(
slug="tithe-1",
defaults={
"name": "Tithe Token", "description": "1 Tithe Token + 12 Writs",
"icon": "fa-piggy-bank", "badge_text": "",
"price_cents": 100, "granted_token_type": Token.TITHE,
"granted_count": 1, "granted_writs": 12,
"max_owned": None, "display_order": 10, "active": True,
},
)
ShopItem.objects.update_or_create(
slug="band-1",
defaults={
"name": "Wristband", "description": "Admit All Entry",
"shoptalk": "Unlimited free entry (BYOB)",
"icon": "fa-ring", "badge_text": "",
"price_cents": 2000, "granted_token_type": Token.BAND,
"granted_count": 1, "granted_writs": 0,
"max_owned": 1, "display_order": 30, "active": True,
},
)
class ShopBuyViewTest(TestCase):
def setUp(self):
_seed_starting_items()
self.user = User.objects.create(email="buyer@test.io")
self.user.stripe_customer_id = "cus_buyer"
self.user.save()
PaymentMethod.objects.create(
user=self.user, stripe_pm_id="pm_test_4242",
last4="4242", brand="visa",
)
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"}
)
self.assertRedirects(
response, "/?next=/dashboard/wallet/shop/buy",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_success_creates_payment_intent_and_purchase(self, mock_stripe):
mock_stripe.PaymentIntent.create.return_value = mock.Mock(
id="pi_test_abc", client_secret="pi_test_abc_secret",
)
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["client_secret"], "pi_test_abc_secret")
purchase = Purchase.objects.get(pk=body["purchase_id"])
self.assertEqual(purchase.user, self.user)
self.assertEqual(purchase.shop_item.slug, "tithe-1")
self.assertEqual(purchase.status, Purchase.PENDING)
self.assertEqual(purchase.stripe_payment_intent_id, "pi_test_abc")
self.assertEqual(purchase.amount_cents, 100)
self.assertEqual(purchase.granted_writs, 12)
@mock.patch("apps.dashboard.views.stripe")
def test_payment_intent_called_with_correct_args(self, mock_stripe):
mock_stripe.PaymentIntent.create.return_value = mock.Mock(
id="pi_a", client_secret="pi_a_secret",
)
self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
self.assertEqual(kwargs["amount"], 100)
self.assertEqual(kwargs["currency"], "usd")
self.assertEqual(kwargs["customer"], "cus_buyer")
self.assertEqual(kwargs["payment_method"], "pm_test_4242")
@mock.patch("apps.dashboard.views.stripe")
def test_unknown_item_slug_returns_404(self, mock_stripe):
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "no-such-item"},
)
self.assertEqual(response.status_code, 404)
@mock.patch("apps.dashboard.views.stripe")
def test_inactive_item_returns_404(self, mock_stripe):
item = ShopItem.objects.get(slug="tithe-1")
item.active = False
item.save()
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
self.assertEqual(response.status_code, 404)
@mock.patch("apps.dashboard.views.stripe")
def test_max_owned_violation_returns_409(self, mock_stripe):
"""User already owns 1 BAND (max_owned=1) → buy refused w. 409."""
Token.objects.create(user=self.user, token_type=Token.BAND)
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "band-1"},
)
self.assertEqual(response.status_code, 409)
# No PaymentIntent created
mock_stripe.PaymentIntent.create.assert_not_called()
@mock.patch("apps.dashboard.views.stripe")
def test_no_payment_method_returns_402(self, mock_stripe):
"""User w. no saved PaymentMethod → 402 Payment Required."""
self.user.payment_methods.all().delete()
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
self.assertEqual(response.status_code, 402)
mock_stripe.PaymentIntent.create.assert_not_called()
class ShopConfirmViewTest(TestCase):
def setUp(self):
_seed_starting_items()
self.user = User.objects.create(email="confirm@test.io")
self.user.tokens.all().delete()
self.user.refresh_from_db()
self.client.force_login(self.user)
self.tithe = ShopItem.objects.get(slug="tithe-1")
self.purchase = Purchase.objects.create(
user=self.user, shop_item=self.tithe,
stripe_payment_intent_id="pi_conf_1",
amount_cents=100, granted_writs=12,
)
def test_requires_login(self):
self.client.logout()
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.assertRedirects(
response, "/?next=/dashboard/wallet/shop/confirm",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_succeeded_pi_triggers_fulfill(self, mock_stripe):
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded")
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_pending_pi_does_not_fulfill(self, mock_stripe):
"""Stripe still processing → leave Purchase PENDING for the webhook."""
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="processing")
self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.PENDING)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 0
)
@mock.patch("apps.dashboard.views.stripe")
def test_idempotent_when_already_succeeded(self, mock_stripe):
"""Webhook already fulfilled — confirm endpoint shouldn't double-mint."""
self.purchase.fulfill()
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded")
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_other_users_purchase_returns_404(self, mock_stripe):
"""A user trying to confirm someone else's PendingPurchase gets 404."""
other = User.objects.create(email="other@test.io")
other_purchase = Purchase.objects.create(
user=other, shop_item=self.tithe,
stripe_payment_intent_id="pi_other",
amount_cents=100, granted_writs=12,
)
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": other_purchase.pk},
)
self.assertEqual(response.status_code, 404)
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test_123")
class StripeWebhookViewTest(TestCase):
def setUp(self):
_seed_starting_items()
self.user = User.objects.create(email="webhook@test.io")
self.user.tokens.all().delete()
self.user.refresh_from_db()
self.tithe = ShopItem.objects.get(slug="tithe-1")
self.purchase = Purchase.objects.create(
user=self.user, shop_item=self.tithe,
stripe_payment_intent_id="pi_wh_1",
amount_cents=100, granted_writs=12,
)
@mock.patch("apps.dashboard.views.stripe")
def test_signature_mismatch_returns_400(self, mock_stripe):
mock_stripe.Webhook.construct_event.side_effect = ValueError("bad sig")
response = self.client.post(
"/stripe/webhook",
data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=deadbeef",
)
self.assertEqual(response.status_code, 400)
@mock.patch("apps.dashboard.views.stripe")
def test_payment_intent_succeeded_triggers_fulfill(self, mock_stripe):
# `Mock(intent_obj, ...)` returns a `dict()`-style object that supports
# `event["type"]` AND `event["data"]["object"]["id"]` — match what the
# webhook view will read.
mock_stripe.Webhook.construct_event.return_value = {
"type": "payment_intent.succeeded",
"data": {"object": {
"id": "pi_wh_1",
"metadata": {"purchase_id": str(self.purchase.pk)},
}},
}
response = self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_unknown_event_type_is_noop(self, mock_stripe):
"""Stripe sends a lot of event types we don't care about (eg
`charge.dispute.created`). Webhook view should return 2xx so
Stripe doesn't retry, but NOT touch the Purchase."""
mock_stripe.Webhook.construct_event.return_value = {
"type": "charge.dispute.created",
"data": {"object": {"id": "pi_wh_1"}},
}
response = self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.PENDING)
@mock.patch("apps.dashboard.views.stripe")
def test_duplicate_delivery_is_idempotent(self, mock_stripe):
"""Stripe may redeliver a webhook (network blip, our 5xx, etc.).
Re-firing the same `payment_intent.succeeded` event must not
double-mint tokens."""
mock_stripe.Webhook.construct_event.return_value = {
"type": "payment_intent.succeeded",
"data": {"object": {
"id": "pi_wh_1",
"metadata": {"purchase_id": str(self.purchase.pk)},
}},
}
self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_unknown_purchase_id_in_metadata_is_noop(self, mock_stripe):
"""If metadata.purchase_id doesn't match any row, log + 200 (don't
crash the webhook listener — Stripe would retry on 5xx)."""
mock_stripe.Webhook.construct_event.return_value = {
"type": "payment_intent.succeeded",
"data": {"object": {
"id": "pi_unknown",
"metadata": {"purchase_id": "999999"},
}},
}
response = self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.PENDING)

View File

@@ -37,6 +37,48 @@ class SkyViewTest(TestCase):
self.assertContains(response, reverse("sky_preview")) self.assertContains(response, reverse("sky_preview"))
self.assertContains(response, reverse("sky_save")) self.assertContains(response, reverse("sky_save"))
def test_saved_birth_date_renders_in_user_tz_when_set(self):
"""A user w. saved sky_birth_dt + sky_birth_tz hits the astimezone
branch (views.py L300-306) — saved_birth_date / saved_birth_time
render in the user's local tz, not UTC."""
from datetime import datetime
import zoneinfo
# 1990-06-15 16:00 UTC = 12:00 PM in America/New_York (EDT, UTC-4)
self.user.sky_birth_dt = datetime(1990, 6, 15, 16, 0, tzinfo=zoneinfo.ZoneInfo("UTC"))
self.user.sky_birth_tz = "America/New_York"
self.user.save()
response = self.client.get(reverse("sky"))
self.assertEqual(response.context["saved_birth_date"], "1990-06-15")
self.assertEqual(response.context["saved_birth_time"], "12:00")
def test_saved_birth_falls_back_to_utc_when_tz_invalid(self):
"""A garbage sky_birth_tz triggers ZoneInfoNotFoundError — the view
swallows it (pass) and renders the UTC representation."""
from datetime import datetime
import zoneinfo
self.user.sky_birth_dt = datetime(1990, 6, 15, 16, 0, tzinfo=zoneinfo.ZoneInfo("UTC"))
self.user.sky_birth_tz = "Not/A/Real_Zone"
self.user.save()
response = self.client.get(reverse("sky"))
# UTC fallback — 16:00 stays 16:00
self.assertEqual(response.context["saved_birth_time"], "16:00")
def test_tz_input_is_readonly_and_carries_auto_detect_placeholder(self):
"""Manual TZ edits throw the schedulePreview / PySwiss fetch off (the
backend gets a stale TZ for the new lat/lon), so the field is render-
readonly like lat/lon — auto-fills from preview, never from a typed
override. The old <small id="id_nf_tz_hint"> is gone; its copy lives
in the placeholder so an empty field is self-explanatory."""
response = self.client.get(reverse("sky"))
self.assertContains(response, 'id="id_nf_tz"')
# readonly + tabindex:-1 mirrors the lat/lon pattern.
self.assertRegex(
response.content.decode(),
r'id="id_nf_tz"[^>]*\breadonly\b',
)
self.assertContains(response, 'placeholder="auto-detected from coordinates"')
self.assertNotContains(response, 'id="id_nf_tz_hint"')
class SkyPreviewTest(TestCase): class SkyPreviewTest(TestCase):
def setUp(self): def setUp(self):
@@ -256,23 +298,25 @@ class SkySaveNoteTest(TestCase):
content_type="application/json", content_type="application/json",
) )
def test_first_save_with_chart_data_returns_stargazer_note(self): def test_first_save_with_chart_data_returns_stargazer_brief(self):
data = self._post().json() data = self._post().json()
self.assertIn("note", data) self.assertIn("brief", data)
recog = data["note"] brief = data["brief"]
self.assertEqual(recog["slug"], "stargazer") self.assertEqual(brief["kind"], "note_unlock")
self.assertIn("title", recog) self.assertEqual(brief["title"], "Stargazer")
self.assertIn("description", recog) self.assertIn("Stargazer", brief["line_text"])
self.assertIn("earned_at", recog) self.assertIn("/billboard/post/", brief["post_url"])
self.assertEqual(brief["square_url"], "/billboard/my-notes/")
self.assertIn("created_at", brief)
def test_first_save_creates_note_in_db(self): def test_first_save_creates_note_in_db(self):
self._post() self._post()
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1) self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
def test_second_save_returns_null_note(self): def test_second_save_returns_null_brief(self):
self._post() self._post()
data = self._post().json() data = self._post().json()
self.assertIsNone(data["note"]) self.assertIsNone(data["brief"])
def test_second_save_does_not_create_duplicate_note(self): def test_second_save_does_not_create_duplicate_note(self):
self._post() self._post()
@@ -281,10 +325,66 @@ class SkySaveNoteTest(TestCase):
def test_save_with_empty_chart_data_does_not_grant_note(self): def test_save_with_empty_chart_data_does_not_grant_note(self):
data = self._post(chart_data={}).json() data = self._post(chart_data={}).json()
self.assertIsNone(data["note"]) self.assertIsNone(data["brief"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0) self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
def test_save_with_null_chart_data_does_not_grant_note(self): def test_save_with_null_chart_data_does_not_grant_note(self):
data = self._post(chart_data=None).json() data = self._post(chart_data=None).json()
self.assertIsNone(data["note"]) self.assertIsNone(data["brief"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0) self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
class SkyDeleteTest(TestCase):
"""POST /dashboard/sky/delete clears all sky fields on the User model and
redirects back to /dashboard/sky/. The Stargazer Note is preserved (it's
earned, not stateful)."""
def setUp(self):
from datetime import datetime
import zoneinfo
self.user = User.objects.create(email="star@test.io")
self.user.sky_chart_data = {"planets": {"Sun": {"sign": "Gemini"}}}
self.user.sky_birth_place = "Baltimore, MD, US"
self.user.sky_birth_tz = "America/New_York"
self.user.sky_birth_lat = 39.2904
self.user.sky_birth_lon = -76.6122
self.user.sky_birth_dt = datetime(
1990, 6, 15, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC")
)
self.user.sky_house_system = "O"
self.user.save()
self.client.force_login(self.user)
self.url = reverse("sky_delete")
def test_post_clears_all_sky_fields(self):
self.client.post(self.url)
self.user.refresh_from_db()
self.assertIsNone(self.user.sky_chart_data)
self.assertIsNone(self.user.sky_birth_dt)
self.assertIsNone(self.user.sky_birth_lat)
self.assertIsNone(self.user.sky_birth_lon)
self.assertEqual(self.user.sky_birth_place, "")
self.assertEqual(self.user.sky_birth_tz, "")
def test_post_redirects_to_sky_view(self):
response = self.client.post(self.url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], reverse("sky"))
def test_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_requires_login(self):
self.client.logout()
response = self.client.post(self.url)
self.assertEqual(response.status_code, 302)
self.assertIn("/?next=", response["Location"])
def test_post_preserves_unrelated_user_fields(self):
self.user.username = "stargazer-keepme"
self.user.save()
self.client.post(self.url)
self.user.refresh_from_db()
self.assertEqual(self.user.username, "stargazer-keepme")

View File

@@ -8,11 +8,11 @@ from django.urls import reverse
from django.utils import html, timezone from django.utils import html, timezone
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
from apps.dashboard.forms import ( from apps.billboard.forms import (
DUPLICATE_LINE_ERROR, DUPLICATE_LINE_ERROR,
EMPTY_LINE_ERROR, EMPTY_LINE_ERROR,
) )
from apps.dashboard.models import Line, Post from apps.billboard.models import Line, Post
from apps.drama.models import Note from apps.drama.models import Note
from apps.lyric.models import User from apps.lyric.models import User
@@ -37,19 +37,19 @@ class NewPostTest(TestCase):
) )
def test_can_save_a_POST_request(self): def test_can_save_a_POST_request(self):
self.client.post("/dashboard/new_post", data={"text": "A new post line"}) self.client.post("/billboard/new-post", data={"text": "A new post line"})
self.assertEqual(Line.objects.count(), 1) self.assertEqual(Line.objects.count(), 1)
new_line = Line.objects.get() new_line = Line.objects.get()
self.assertEqual(new_line.text, "A new post line") self.assertEqual(new_line.text, "A new post line")
def test_redirects_after_POST(self): def test_redirects_after_POST(self):
response = self.client.post("/dashboard/new_post", data={"text": "A new post line"}) response = self.client.post("/billboard/new-post", data={"text": "A new post line"})
new_post = Post.objects.get() new_post = Post.objects.get()
self.assertRedirects(response, f"/dashboard/post/{new_post.id}/") self.assertRedirects(response, f"/billboard/post/{new_post.id}/")
# Post invalid input helper # Post invalid input helper
def post_invalid_input(self): def post_invalid_input(self):
return self.client.post("/dashboard/new_post", data={"text": ""}) return self.client.post("/billboard/new-post", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self): def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input() self.post_invalid_input()
@@ -66,14 +66,21 @@ class NewPostTest(TestCase):
@override_settings(COMPRESS_ENABLED=False) @override_settings(COMPRESS_ENABLED=False)
class PostViewTest(TestCase): class PostViewTest(TestCase):
def setUp(self):
self.author = User.objects.create(email="author@test.io")
# POST flows append a Line with author=request.user — non-null FK
# since Line.author is required. force_login so the view's view_post
# path saves cleanly; anonymous compose is no longer supported.
self.client.force_login(self.author)
def test_uses_post_template(self): def test_uses_post_template(self):
mypost = Post.objects.create() mypost = Post.objects.create()
response = self.client.get(f"/dashboard/post/{mypost.id}/") response = self.client.get(f"/billboard/post/{mypost.id}/")
self.assertTemplateUsed(response, "apps/dashboard/post.html") self.assertTemplateUsed(response, "apps/billboard/post.html")
def test_renders_input_form(self): def test_renders_input_form(self):
mypost = Post.objects.create() mypost = Post.objects.create()
url = f"/dashboard/post/{mypost.id}/" url = f"/billboard/post/{mypost.id}/"
response = self.client.get(url) response = self.client.get(url)
parsed = lxml.html.fromstring(response.content) parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect("form[method=POST]") forms = parsed.cssselect("form[method=POST]")
@@ -85,12 +92,12 @@ class PostViewTest(TestCase):
def test_displays_only_lines_for_that_post(self): def test_displays_only_lines_for_that_post(self):
# Given/Arrange # Given/Arrange
correct_post = Post.objects.create() correct_post = Post.objects.create()
Line.objects.create(text="itemey 1", post=correct_post) Line.objects.create(text="itemey 1", post=correct_post, author=self.author)
Line.objects.create(text="itemey 2", post=correct_post) Line.objects.create(text="itemey 2", post=correct_post, author=self.author)
other_post = Post.objects.create() other_post = Post.objects.create()
Line.objects.create(text="other post line", post=other_post) Line.objects.create(text="other post line", post=other_post, author=self.author)
# When/Act # When/Act
response = self.client.get(f"/dashboard/post/{correct_post.id}/") response = self.client.get(f"/billboard/post/{correct_post.id}/")
# Then/Assert # Then/Assert
self.assertContains(response, "itemey 1") self.assertContains(response, "itemey 1")
self.assertContains(response, "itemey 2") self.assertContains(response, "itemey 2")
@@ -101,7 +108,7 @@ class PostViewTest(TestCase):
correct_post = Post.objects.create() correct_post = Post.objects.create()
self.client.post( self.client.post(
f"/dashboard/post/{correct_post.id}/", f"/billboard/post/{correct_post.id}/",
data={"text": "A new line for an existing post"}, data={"text": "A new line for an existing post"},
) )
@@ -115,16 +122,16 @@ class PostViewTest(TestCase):
correct_post = Post.objects.create() correct_post = Post.objects.create()
response = self.client.post( response = self.client.post(
f"/dashboard/post/{correct_post.id}/", f"/billboard/post/{correct_post.id}/",
data={"text": "A new line for an existing post"}, data={"text": "A new line for an existing post"},
) )
self.assertRedirects(response, f"/dashboard/post/{correct_post.id}/") self.assertRedirects(response, f"/billboard/post/{correct_post.id}/")
# Post invalid input helper # Post invalid input helper
def post_invalid_input(self): def post_invalid_input(self):
mypost = Post.objects.create() mypost = Post.objects.create()
return self.client.post(f"/dashboard/post/{mypost.id}/", data={"text": ""}) return self.client.post(f"/billboard/post/{mypost.id}/", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self): def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input() self.post_invalid_input()
@@ -133,7 +140,7 @@ class PostViewTest(TestCase):
def test_for_invalid_input_renders_post_template(self): def test_for_invalid_input_renders_post_template(self):
response = self.post_invalid_input() response = self.post_invalid_input()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/post.html") self.assertTemplateUsed(response, "apps/billboard/post.html")
def test_for_invalid_input_shows_error_on_page(self): def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input() response = self.post_invalid_input()
@@ -147,49 +154,49 @@ class PostViewTest(TestCase):
def test_duplicate_line_validation_errors_end_up_on_post_page(self): def test_duplicate_line_validation_errors_end_up_on_post_page(self):
post1 = Post.objects.create() post1 = Post.objects.create()
Line.objects.create(post=post1, text="lorem ipsum") Line.objects.create(post=post1, text="lorem ipsum", author=self.author)
response = self.client.post( response = self.client.post(
f"/dashboard/post/{post1.id}/", f"/billboard/post/{post1.id}/",
data={"text": "lorem ipsum"}, data={"text": "lorem ipsum"},
) )
expected_error = html.escape(DUPLICATE_LINE_ERROR) expected_error = html.escape(DUPLICATE_LINE_ERROR)
self.assertContains(response, expected_error) self.assertContains(response, expected_error)
self.assertTemplateUsed(response, "apps/dashboard/post.html") self.assertTemplateUsed(response, "apps/billboard/post.html")
self.assertEqual(Line.objects.all().count(), 1) self.assertEqual(Line.objects.all().count(), 1)
class MyPostsTest(TestCase): class MyPostsTest(TestCase):
def test_my_posts_url_renders_my_posts_template(self): def test_my_posts_url_renders_my_posts_template(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
self.client.force_login(user) self.client.force_login(user)
response = self.client.get(f"/dashboard/users/{user.id}/") response = self.client.get(f"/billboard/users/{user.id}/")
self.assertTemplateUsed(response, "apps/dashboard/my_posts.html") self.assertTemplateUsed(response, "apps/billboard/my_posts.html")
def test_passes_correct_owner_to_template(self): def test_passes_correct_owner_to_template(self):
User.objects.create(email="wrongowner@example.com") User.objects.create(email="wrongowner@example.com")
correct_user = User.objects.create(email="a@b.cde") correct_user = User.objects.create(email="a@b.cde")
self.client.force_login(correct_user) self.client.force_login(correct_user)
response = self.client.get(f"/dashboard/users/{correct_user.id}/") response = self.client.get(f"/billboard/users/{correct_user.id}/")
self.assertEqual(response.context["owner"], correct_user) self.assertEqual(response.context["owner"], correct_user)
def test_post_owner_is_saved_if_user_is_authenticated(self): def test_post_owner_is_saved_if_user_is_authenticated(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
self.client.force_login(user) self.client.force_login(user)
self.client.post("/dashboard/new_post", data={"text": "new line"}) self.client.post("/billboard/new-post", data={"text": "new line"})
new_post = Post.objects.get() new_post = Post.objects.get()
self.assertEqual(new_post.owner, user) self.assertEqual(new_post.owner, user)
def test_my_posts_redirects_if_not_logged_in(self): def test_my_posts_redirects_if_not_logged_in(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
response = self.client.get(f"/dashboard/users/{user.id}/") response = self.client.get(f"/billboard/users/{user.id}/")
self.assertRedirects(response, "/") self.assertRedirects(response, "/")
def test_my_posts_returns_403_for_wrong_user(self): def test_my_posts_returns_403_for_wrong_user(self):
user1 = User.objects.create(email="a@b.cde") user1 = User.objects.create(email="a@b.cde")
user2 = User.objects.create(email="wrongowner@example.com") user2 = User.objects.create(email="wrongowner@example.com")
self.client.force_login(user2) self.client.force_login(user2)
response = self.client.get(f"/dashboard/users/{user1.id}/") response = self.client.get(f"/billboard/users/{user1.id}/")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
class SharePostTest(TestCase): class SharePostTest(TestCase):
@@ -197,16 +204,16 @@ class SharePostTest(TestCase):
our_post = Post.objects.create() our_post = Post.objects.create()
alice = User.objects.create(email="alice@example.com") alice = User.objects.create(email="alice@example.com")
response = self.client.post( response = self.client.post(
f"/dashboard/post/{our_post.id}/share_post", f"/billboard/post/{our_post.id}/share-post",
data={"recipient": "alice@example.com"}, data={"recipient": "alice@example.com"},
) )
self.assertRedirects(response, f"/dashboard/post/{our_post.id}/") self.assertRedirects(response, f"/billboard/post/{our_post.id}/")
def test_post_with_email_adds_user_to_shared_with(self): def test_post_with_email_adds_user_to_shared_with(self):
our_post = Post.objects.create() our_post = Post.objects.create()
alice = User.objects.create(email="alice@example.com") alice = User.objects.create(email="alice@example.com")
self.client.post( self.client.post(
f"/dashboard/post/{our_post.id}/share_post", f"/billboard/post/{our_post.id}/share-post",
data={"recipient": "alice@example.com"}, data={"recipient": "alice@example.com"},
) )
self.assertIn(alice, our_post.shared_with.all()) self.assertIn(alice, our_post.shared_with.all())
@@ -214,12 +221,12 @@ class SharePostTest(TestCase):
def test_post_with_nonexistent_email_redirects_to_post(self): def test_post_with_nonexistent_email_redirects_to_post(self):
our_post = Post.objects.create() our_post = Post.objects.create()
response = self.client.post( response = self.client.post(
f"/dashboard/post/{our_post.id}/share_post", f"/billboard/post/{our_post.id}/share-post",
data={"recipient": "nobody@example.com"}, data={"recipient": "nobody@example.com"},
) )
self.assertRedirects( self.assertRedirects(
response, response,
f"/dashboard/post/{our_post.id}/", f"/billboard/post/{our_post.id}/",
fetch_redirect_response=False, fetch_redirect_response=False,
) )
@@ -227,7 +234,7 @@ class SharePostTest(TestCase):
owner = User.objects.create(email="owner@example.com") owner = User.objects.create(email="owner@example.com")
our_post = Post.objects.create(owner=owner) our_post = Post.objects.create(owner=owner)
self.client.force_login(owner) self.client.force_login(owner)
self.client.post(reverse("share_post", args=[our_post.id]), self.client.post(reverse("billboard:share_post", args=[our_post.id]),
data={"recipient": "owner@example.com"}) data={"recipient": "owner@example.com"})
self.assertNotIn(owner, our_post.shared_with.all()) self.assertNotIn(owner, our_post.shared_with.all())
@@ -235,7 +242,7 @@ class SharePostTest(TestCase):
def test_share_post_shows_privacy_safe_message(self): def test_share_post_shows_privacy_safe_message(self):
our_post = Post.objects.create() our_post = Post.objects.create()
response = self.client.post( response = self.client.post(
f"/dashboard/post/{our_post.id}/share_post", f"/billboard/post/{our_post.id}/share-post",
data={"recipient": "nobody@example.com"}, data={"recipient": "nobody@example.com"},
follow=True, follow=True,
) )
@@ -251,20 +258,20 @@ class ViewAuthPostTest(TestCase):
self.our_post = Post.objects.create(owner=self.owner) self.our_post = Post.objects.create(owner=self.owner)
def test_anonymous_user_is_redirected(self): def test_anonymous_user_is_redirected(self):
response = self.client.get(reverse("view_post", args=[self.our_post.id])) response = self.client.get(reverse("billboard:view_post", args=[self.our_post.id]))
self.assertRedirects(response, "/", fetch_redirect_response=False) self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_non_owner_non_shared_user_gets_403(self): def test_non_owner_non_shared_user_gets_403(self):
stranger = User.objects.create(email="stranger@example.com") stranger = User.objects.create(email="stranger@example.com")
self.client.force_login(stranger) self.client.force_login(stranger)
response = self.client.get(reverse("view_post", args=[self.our_post.id])) response = self.client.get(reverse("billboard:view_post", args=[self.our_post.id]))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_shared_with_user_can_access_post(self): def test_shared_with_user_can_access_post(self):
guest = User.objects.create(email="guest@example.com") guest = User.objects.create(email="guest@example.com")
self.our_post.shared_with.add(guest) self.our_post.shared_with.add(guest)
self.client.force_login(guest) self.client.force_login(guest)
response = self.client.get(reverse("view_post", args=[self.our_post.id])) response = self.client.get(reverse("billboard:view_post", args=[self.our_post.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@override_settings(COMPRESS_ENABLED=False) @override_settings(COMPRESS_ENABLED=False)
@@ -454,6 +461,107 @@ class ProfileViewTest(TestCase):
[username_input] = parsed.cssselect("#id_new_username") [username_input] = parsed.cssselect("#id_new_username")
self.assertEqual("discoman", username_input.get("value")) self.assertEqual("discoman", username_input.get("value"))
def test_post_reserved_username_does_not_save(self):
"""RESERVED_USERNAMES (e.g. 'adman') must be rejected — the view bails
with an error message + redirect to / before reaching user.save()."""
original_username = self.user.username
self.client.post("/dashboard/set_profile", data={"username": "adman"})
self.user.refresh_from_db()
self.assertEqual(self.user.username, original_username)
def test_post_reserved_username_redirects_home(self):
response = self.client.post("/dashboard/set_profile", data={"username": "adman"})
self.assertRedirects(response, "/", fetch_redirect_response=False)
class KitBagViewTest(TestCase):
"""`kit_bag` view — renders the kit-bag panel partial w. equipped + token state."""
def setUp(self):
from apps.lyric.models import Token
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
# Stash a TITHE token so the list-comprehension branch lands non-empty
Token.objects.create(user=self.user, token_type=Token.TITHE)
self.url = "/dashboard/kit-bag/"
def test_get_returns_200_and_renders_panel(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "core/_partials/_kit_bag_panel.html")
def test_context_passes_free_and_tithe_counts(self):
response = self.client.get(self.url)
# signal seeds a FREE + COIN; we added a TITHE in setUp.
self.assertEqual(response.context["tithe_count"], 1)
self.assertGreaterEqual(response.context["free_count"], 0)
def test_requires_login(self):
self.client.logout()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 302)
self.assertIn("/?next=", response["Location"])
def test_deck_section_renders_card_stack_svg_not_fa_id_badge(self):
"""Sprint A.4 follow-up — kit-bag Deck section uses the new card-deck
SVG icon partial, replacing the prior `<i class="fa-regular fa-id-badge">`
for both the equipped-deck branch and the placeholder branch."""
import lxml.html
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
# Auto-equipped Earthman appears in the Deck section.
deck = parsed.cssselect(".kit-bag-deck")[0]
[_] = deck.cssselect("svg.deck-stack-icon")
self.assertEqual(
len(deck.cssselect("i.fa-id-badge")), 0,
"fa-regular fa-id-badge must be gone from kit-bag-deck",
)
def test_no_deck_equipped_renders_placeholder_card_stack_icon(self):
"""Sprint A.4 follow-up — when the user has no equipped_deck, the
kit-bag Deck section renders the same .deck-stack-icon SVG inside a
.kit-bag-placeholder wrapper (no fan-out trigger; CSS dims it to
--quaUser at 0.3 alpha to match the empty dice slot)."""
import lxml.html
# Clear the auto-equipped Earthman to land in the placeholder branch.
self.user.equipped_deck = None
self.user.save(update_fields=["equipped_deck"])
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
# equipped-deck branch is skipped → placeholder branch fires.
self.assertEqual(
len(parsed.cssselect(".kit-bag-deck")), 0,
"No equipped deck → no .kit-bag-deck wrapper",
)
placeholders = parsed.cssselect(".kit-bag-section .kit-bag-placeholder")
# First placeholder is the Deck slot (Dice + Trinket slots also use
# .kit-bag-placeholder when empty); assert at least one carries the
# card-stack SVG.
deck_placeholder = placeholders[0]
[_] = deck_placeholder.cssselect("svg.deck-stack-icon")
# Old fa-id-badge must be gone.
self.assertEqual(
len(deck_placeholder.cssselect("i.fa-id-badge")), 0,
"fa-regular fa-id-badge must be gone from kit-bag-placeholder",
)
def test_polarized_equipped_deck_tooltip_has_x2_decoration(self):
"""Same (x2) decoration as the gameboard applet — polarized decks
signal segment-doubling in the tooltip card-count line via --terUser
`.tt-x2` span. Element-presence assertion (the literal `×2` content
is exercised by the parent template; this test only locks the conditional
render on `is_polarized`)."""
import lxml.html
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
deck = parsed.cssselect(".kit-bag-deck")[0]
# Earthman is auto-equipped + is_polarized=True per A.0 migration.
self.assertEqual(
len(deck.cssselect(".tt-x2")), 1,
"Polarized equipped deck must render .tt-x2 decoration in kit-bag tooltip",
)
class ToggleDashAppletsViewTest(TestCase): class ToggleDashAppletsViewTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="disco@test.io") self.user = User.objects.create(email="disco@test.io")
@@ -619,13 +727,13 @@ class SkySaveViewTest(TestCase):
self.assertAlmostEqual(self.user.sky_chart_data["houses"]["asc"], 123.4) self.assertAlmostEqual(self.user.sky_chart_data["houses"]["asc"], 123.4)
class SkyNatusDataViewTest(TestCase): class SkyDataViewTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="disco@test.io") self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user) self.client.force_login(self.user)
def test_returns_stored_chart_with_asc_preserved(self): def test_returns_stored_chart_with_asc_preserved(self):
"""sky_natus_data returns sky_chart_data — asc must match what was saved.""" """sky_data returns sky_chart_data — asc must match what was saved."""
stored = { stored = {
"planets": {}, "planets": {},
"houses": {"cusps": [float(i * 30) for i in range(12)], "asc": 236.1, "mc": 159.1}, "houses": {"cusps": [float(i * 30) for i in range(12)], "asc": 236.1, "mc": 159.1},
@@ -651,3 +759,114 @@ class SkyNatusDataViewTest(TestCase):
self.client.logout() self.client.logout()
response = self.client.get("/dashboard/sky/data") response = self.client.get("/dashboard/sky/data")
self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False) self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False)
class SetPronounsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="they@test.io")
self.client.force_login(self.user)
self.url = reverse("set_pronouns")
def test_post_valid_choice_persists_on_user(self):
response = self.client.post(self.url, data={"pronouns": "misogyny"})
self.assertEqual(response.status_code, 204)
self.user.refresh_from_db()
self.assertEqual(self.user.pronouns, "misogyny")
def test_post_each_valid_choice(self):
"""Every PRONOUN_TABLE key persists. Note: bawlmorese also triggers the
Baltimorean Note-unlock JSON response (status 200) on first call —
covered separately by SetPronounsBawlmoreseUnlockTest. Here we just
confirm the user.pronouns write lands for each choice."""
for key in ("pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"):
with self.subTest(key=key):
self.client.post(self.url, data={"pronouns": key})
self.user.refresh_from_db()
self.assertEqual(self.user.pronouns, key)
def test_post_invalid_choice_returns_400_and_does_not_change_user(self):
original = self.user.pronouns
response = self.client.post(self.url, data={"pronouns": "bogus"})
self.assertEqual(response.status_code, 400)
self.user.refresh_from_db()
self.assertEqual(self.user.pronouns, original)
def test_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_requires_login(self):
self.client.logout()
response = self.client.post(self.url, data={"pronouns": "misogyny"})
self.assertEqual(response.status_code, 302)
class SetPronounsBawlmoreseUnlockTest(TestCase):
"""First-time selection of `pronouns = bawlmorese` grants the Baltimorean
Note + returns a Brief banner payload (similar to sky_save's stargazer
unlock). Subsequent selections are idempotent — no new Note, no new
Brief, status 204 like other pronoun choices."""
def setUp(self):
self.user = User.objects.create(email="aaron@bawlmore.io")
self.client.force_login(self.user)
self.url = reverse("set_pronouns")
def test_first_bawlmorese_returns_200_with_brief_json(self):
response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("brief", data)
self.assertIsNotNone(data["brief"])
def test_first_bawlmorese_grants_baltimorean_note(self):
self.client.post(self.url, data={"pronouns": "bawlmorese"})
self.assertTrue(
Note.objects.filter(user=self.user, slug="baltimorean").exists()
)
def test_brief_payload_carries_baltimorean_title(self):
response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
brief = response.json()["brief"]
self.assertEqual(brief["title"], "Baltimorean")
def test_brief_payload_square_url_jumps_to_my_notes(self):
"""NOTE_UNLOCK Briefs carry a `square_url` pointing at /billboard/my-notes/
so the banner's `?` square jumps straight to the user's Note list."""
response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
brief = response.json()["brief"]
self.assertEqual(brief["square_url"], "/billboard/my-notes/")
def test_second_bawlmorese_is_idempotent_no_new_note(self):
self.client.post(self.url, data={"pronouns": "bawlmorese"})
self.client.post(self.url, data={"pronouns": "bawlmorese"})
self.assertEqual(
Note.objects.filter(user=self.user, slug="baltimorean").count(), 1
)
def test_second_bawlmorese_returns_204(self):
"""No fresh Brief on the repeat call — status returns to the no-payload
204 that other pronoun choices use."""
self.client.post(self.url, data={"pronouns": "bawlmorese"})
response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
self.assertEqual(response.status_code, 204)
def test_non_bawlmorese_choice_does_not_grant_baltimorean(self):
self.client.post(self.url, data={"pronouns": "misogyny"})
self.assertFalse(
Note.objects.filter(user=self.user, slug="baltimorean").exists()
)
def test_non_bawlmorese_choice_still_returns_204(self):
response = self.client.post(self.url, data={"pronouns": "misogyny"})
self.assertEqual(response.status_code, 204)
def test_bawlmorese_after_other_choice_still_grants_note(self):
"""User picks misogyny first, then bawlmorese — the first bawlmorese
click is still the unlock moment regardless of prior pronoun history."""
self.client.post(self.url, data={"pronouns": "misogyny"})
response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
self.assertEqual(response.status_code, 200)
self.assertTrue(
Note.objects.filter(user=self.user, slug="baltimorean").exists()
)

View File

@@ -40,12 +40,134 @@ class WalletViewTest(TestCase):
def test_wallet_page_shows_stripe_payment_element(self): def test_wallet_page_shows_stripe_payment_element(self):
[_] = self.parsed.cssselect("#id_stripe_payment_element") [_] = self.parsed.cssselect("#id_stripe_payment_element")
def test_wallet_page_shows_tithe_token_shop(self): # Note: the legacy `#id_tithe_token_shop` HTML in Balances was
[_] = self.parsed.cssselect("#id_tithe_token_shop") # superseded by the dedicated Shop applet in Chunk 5 of
# [[project-wallet-shop-expansion]]. Shop-applet coverage lives in
# `WalletTokensAppletAllTrinketsVisibleTest` below + `test_shop_models.py`
# + `test_shop_views.py`.
def test_tithe_token_shop_shows_bundle(self):
bundles = self.parsed.cssselect("#id_tithe_token_shop .token-bundle") class WalletTokensAppletAllTrinketsVisibleTest(TestCase):
self.assertGreater(len(bundles), 0) """Chunk 1 of the Shop applet rollout (2026-05-22) — the Tokens applet
in `wallet.html` must show every owned trinket-as-token type at once.
Pre-Chunk-1 the template's `{% if pass_token %} ... {% elif band %}
... {% elif coin %}` chain hid two of the three from any user holding
a higher-priority trinket — bad UX since all three are usable at the
gate (per [[feedback-equip-slot-gates-trinket-use]], the user picks
WHICH one fires via the equip slot)."""
def setUp(self):
self.user = User.objects.create(email="multitoken@test.io", is_staff=True)
# Auto-COIN (equipped) + FREE created by post_save signal; PASS auto-
# granted by the is_staff branch of the same signal. Add the rest.
Token.objects.create(user=self.user, token_type=Token.BAND)
Token.objects.create(user=self.user, token_type=Token.CARTE)
Token.objects.create(user=self.user, token_type=Token.TITHE)
self.client.force_login(self.user)
response = self.client.get("/dashboard/wallet/")
self.parsed = lxml.html.fromstring(response.content)
def test_wallet_shows_pass_token(self):
[_] = self.parsed.cssselect("#id_pass_token")
def test_wallet_shows_band_token(self):
[_] = self.parsed.cssselect("#id_band_token")
def test_wallet_shows_coin_on_a_string(self):
[_] = self.parsed.cssselect("#id_coin_on_a_string")
def test_wallet_shows_carte_token(self):
[_] = self.parsed.cssselect("#id_carte_token")
def test_wallet_shows_free_token(self):
[_] = self.parsed.cssselect("#id_free_token")
def test_wallet_shows_tithe_token(self):
[_] = self.parsed.cssselect("#id_tithe_token")
def test_view_context_passes_carte(self):
"""Defense-in-depth: not just the template but the view context too —
a renamed/refactored template should still receive `carte` in ctx."""
response = self.client.get("/dashboard/wallet/")
self.assertEqual(response.context["carte"].token_type, Token.CARTE)
def test_view_context_passes_band(self):
response = self.client.get("/dashboard/wallet/")
self.assertEqual(response.context["band"].token_type, Token.BAND)
def test_non_staff_user_with_carte_still_sees_carte(self):
"""CARTE has no `is_staff` gating (unlike PASS) — a regular gamer
holding a CARTE must see it in the Tokens applet."""
non_staff = User.objects.create(email="grunt@test.io")
Token.objects.create(user=non_staff, token_type=Token.CARTE)
self.client.force_login(non_staff)
response = self.client.get("/dashboard/wallet/")
parsed = lxml.html.fromstring(response.content)
[_] = parsed.cssselect("#id_carte_token")
class WalletAppletOrderTest(TestCase):
"""The wallet row renders Shop first, then Balances/Tokens/Payment in
their historical insertion order — pinned via `Applet.display_order`
(lower = earlier; default 100 + PK tie-break preserves the legacy
order for the rest). Bug-prevention pin: a future migration that
renames or reseeds applets must keep wallet-shop at order < 100.
See [[project-wallet-shop-expansion]] for the locked layout spec."""
def setUp(self):
self.user = User.objects.create(email="layout@test.io")
self.client.force_login(self.user)
def test_shop_applet_renders_first_in_wallet_row(self):
response = self.client.get("/dashboard/wallet/")
html = response.content.decode()
shop_pos = html.find('id="id_wallet_shop"')
balances_pos = html.find('id="id_wallet_balances"')
tokens_pos = html.find('id_writs_balance') # inside balances applet
# Shop's id_wallet_shop appears before Balances' id_wallet_balances
self.assertGreater(shop_pos, 0)
self.assertGreater(balances_pos, 0)
self.assertLess(shop_pos, balances_pos)
self.assertLess(shop_pos, tokens_pos)
def test_shop_applet_first_in_context_list(self):
"""View-context shape pin: `applets` is a list ordered Shop-first."""
response = self.client.get("/dashboard/wallet/")
slugs = [e["applet"].slug for e in response.context["applets"]]
self.assertEqual(slugs[0], "wallet-shop")
class WalletPassTokenVisibilityTest(TestCase):
"""PASS is admin-only — the model guard blocks bogus rows from existing
for non-staff users, but defend the wallet surface too so a future
code path that bypasses the model (eg. raw SQL backfill) doesn't
silently leak the trinket into a non-admin's view."""
def test_pass_token_in_context_for_staff(self):
user = User.objects.create(email="staff@test.io", is_staff=True)
self.client.force_login(user)
response = self.client.get("/dashboard/wallet/")
self.assertIsNotNone(response.context["pass_token"])
def test_pass_token_absent_for_non_staff(self):
user = User.objects.create(email="reg@test.io")
self.client.force_login(user)
response = self.client.get("/dashboard/wallet/")
self.assertIsNone(response.context["pass_token"])
def test_pass_token_absent_in_htmx_toggle_partial_for_non_staff(self):
Applet.objects.get_or_create(
slug="wallet-tokens",
defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
)
user = User.objects.create(email="reg2@test.io")
self.client.force_login(user)
response = self.client.post(
"/dashboard/wallet/toggle-applets",
{"applets": ["wallet-tokens"]},
HTTP_HX_REQUEST="true",
)
self.assertIsNone(response.context["pass_token"])
class WalletViewAppletContextTest(TestCase): class WalletViewAppletContextTest(TestCase):

View File

@@ -1,6 +1,6 @@
from django.test import SimpleTestCase from django.test import SimpleTestCase
from apps.dashboard.forms import ( from apps.billboard.forms import (
EMPTY_LINE_ERROR, EMPTY_LINE_ERROR,
LineForm, LineForm,
) )

View File

@@ -1,6 +1,6 @@
from django.test import SimpleTestCase from django.test import SimpleTestCase
from apps.dashboard.models import Line from apps.billboard.models import Line
class SimpleLineModelTest(SimpleTestCase): class SimpleLineModelTest(SimpleTestCase):

View File

@@ -2,20 +2,22 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path('new_post', views.new_post, name='new_post'), # Post/Line CRUD has moved to apps.billboard.urls (`billboard:` namespace).
path('post/<uuid:post_id>/', views.view_post, name='view_post'),
path('post/<uuid:post_id>/share_post', views.share_post, name="share_post"),
path('set_palette', views.set_palette, name='set_palette'), path('set_palette', views.set_palette, name='set_palette'),
path('set_profile', views.set_profile, name='set_profile'), path('set_profile', views.set_profile, name='set_profile'),
path('users/<uuid:user_id>/', views.my_posts, name='my_posts'),
path('toggle_applets', views.toggle_applets, name="toggle_applets"), path('toggle_applets', views.toggle_applets, name="toggle_applets"),
path('wallet/', views.wallet, name='wallet'), path('wallet/', views.wallet, name='wallet'),
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'), path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
path('wallet/setup-intent', views.setup_intent, name='setup_intent'), path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'), path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
path('wallet/shop/buy', views.shop_buy, name='shop_buy'),
path('wallet/shop/confirm', views.shop_confirm, name='shop_confirm'),
path('wallet/shop/claim', views.shop_claim_free, name='shop_claim_free'),
path('kit-bag/', views.kit_bag, name='kit_bag'), path('kit-bag/', views.kit_bag, name='kit_bag'),
path('sky/', views.sky_view, name='sky'), path('sky/', views.sky_view, name='sky'),
path('sky/preview', views.sky_preview, name='sky_preview'), path('sky/preview', views.sky_preview, name='sky_preview'),
path('sky/save', views.sky_save, name='sky_save'), path('sky/save', views.sky_save, name='sky_save'),
path('sky/data', views.sky_natus_data, name='sky_natus_data'), path('sky/delete', views.sky_delete, name='sky_delete'),
path('sky/data', views.sky_data, name='sky_data'),
path('set-pronouns', views.set_pronouns, name='set_pronouns'),
] ]

View File

@@ -9,17 +9,17 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Max, Q from django.db.models import Max, Q
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from apps.applets.utils import applet_context, apply_applet_toggle from apps.applets.utils import applet_context, apply_applet_toggle
from apps.dashboard.forms import ExistingPostLineForm, LineForm
from apps.dashboard.models import Line, Post
from apps.drama.models import Note from apps.drama.models import Note
from apps.epic.models import DeckVariant
from apps.epic.utils import _compute_distinctions from apps.epic.utils import _compute_distinctions
from apps.lyric.models import PaymentMethod, Token, User, Wallet from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User, Wallet, is_reserved_username
APPLET_ORDER = ["wallet", "username", "palette"] APPLET_ORDER = ["wallet", "username", "palette"]
@@ -39,11 +39,14 @@ _PALETTE_DEFS = [
{"name": "palette-inferno", "label": "Inferno", "locked": True}, {"name": "palette-inferno", "label": "Inferno", "locked": True},
{"name": "palette-terrestre", "label": "Terrestre", "locked": True}, {"name": "palette-terrestre", "label": "Terrestre", "locked": True},
{"name": "palette-celestia", "label": "Celestia", "locked": True}, {"name": "palette-celestia", "label": "Celestia", "locked": True},
{"name": "palette-baltimore", "label": "Baltimore", "locked": True},
{"name": "palette-maryland", "label": "Maryland", "locked": True},
] ]
_NOTE_TITLES = { _NOTE_TITLES = {
"stargazer": "Stargazer", "stargazer": "Stargazer",
"schizo": "Schizo", "schizo": "Schizo",
"nomad": "Nomad", "nomad": "Nomad",
"baltimorean": "Baltimorean",
} }
# Keep PALETTES as an alias used by views that don't have a request user. # Keep PALETTES as an alias used by views that don't have a request user.
PALETTES = _PALETTE_DEFS PALETTES = _PALETTE_DEFS
@@ -84,16 +87,6 @@ def _unlocked_palettes_for_user(user):
return base return base
def _recent_posts(user, limit=3):
return (
Post
.objects
.filter(Q(owner=user) | Q(shared_with=user))
.annotate(last_line=Max('lines__id'))
.order_by('-last_line')
.distinct()[:limit]
)
def home_page(request): def home_page(request):
context = { context = {
"palettes": _palettes_for_user(request.user), "palettes": _palettes_for_user(request.user),
@@ -101,64 +94,33 @@ def home_page(request):
} }
if request.user.is_authenticated: if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "dashboard") context["applets"] = applet_context(request.user, "dashboard")
# My Wallet applet — show all trinkets (PASS/BAND/CARTE/COIN) + stacked
# Free + Tithe tokens (badge count) + Writs placeholder. Trinkets COPY
# the Game Kit applet's tooltip + DON/DOFF wiring so the user can equip
# from either surface; Free + Tithe tokens MOVED off the Game Kit applet
# since they aren't equippable (user spec 2026-05-25 PM).
user = request.user
free_tokens = list(user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at"))
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
context.update({
"coin": user.tokens.filter(token_type=Token.COIN).first(),
"pass_token": user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None,
"band": user.tokens.filter(token_type=Token.BAND).first(),
"carte": user.tokens.filter(token_type=Token.CARTE).first(),
"free_tokens": free_tokens,
"tithe_tokens": tithe_tokens,
"free_count": len(free_tokens),
"tithe_count": len(tithe_tokens),
"equipped_trinket_id": user.equipped_trinket_id,
})
return render(request, "apps/dashboard/home.html", context) return render(request, "apps/dashboard/home.html", context)
def new_post(request):
form = LineForm(data=request.POST)
if form.is_valid():
nupost = Post.objects.create()
if request.user.is_authenticated:
nupost.owner = request.user
nupost.save()
form.save(for_post=nupost)
return redirect(nupost)
else:
context = {
"form": form,
"page_class": "page-billboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "billboard")
context["recent_posts"] = _recent_posts(request.user)
return render(request, "apps/billboard/billboard.html", context)
def view_post(request, post_id): # Post / Line CRUD lives in apps.billboard.views since the Post + Line models
our_post = Post.objects.get(id=post_id) # moved to apps.billboard.models.
if our_post.owner:
if not request.user.is_authenticated:
return redirect("/")
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
return HttpResponseForbidden()
form = ExistingPostLineForm(for_post=our_post)
if request.method == "POST":
form = ExistingPostLineForm(for_post=our_post, data=request.POST)
if form.is_valid():
form.save()
return redirect(our_post)
return render(request, "apps/dashboard/post.html", {"post": our_post, "form": form})
def my_posts(request, user_id):
owner = User.objects.get(id=user_id)
if not request.user.is_authenticated:
return redirect("/")
if request.user.id != owner.id:
return HttpResponseForbidden()
return render(request, "apps/dashboard/my_posts.html", {"owner": owner})
def share_post(request, post_id):
our_post = Post.objects.get(id=post_id)
try:
recipient = User.objects.get(email=request.POST["recipient"])
if recipient == request.user:
return redirect(our_post)
our_post.shared_with.add(recipient)
except User.DoesNotExist:
pass
messages.success(request, "An invite has been sent if that address is registered.")
return redirect(our_post)
@login_required(login_url="/") @login_required(login_url="/")
def set_palette(request): def set_palette(request):
@@ -175,10 +137,35 @@ def set_palette(request):
def set_profile(request): def set_profile(request):
if request.method == "POST": if request.method == "POST":
username = request.POST.get("username", "") username = request.POST.get("username", "")
if is_reserved_username(username, current_user=request.user):
messages.error(request, "That handle is reserved.")
return redirect("/")
request.user.username = username request.user.username = username
request.user.save(update_fields=["username"]) request.user.save(update_fields=["username"])
return redirect("/") return redirect("/")
@login_required(login_url="/")
def set_pronouns(request):
from django.http import HttpResponseNotAllowed
from apps.lyric.models import PRONOUN_TABLE
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
choice = request.POST.get("pronouns", "")
if choice not in PRONOUN_TABLE:
return HttpResponse(status=400)
request.user.pronouns = choice
request.user.save(update_fields=["pronouns"])
# bawlmorese is the pronoun-side trigger for the Baltimorean Note unlock —
# mirrors sky_save's stargazer grant. Grant is idempotent (grant_if_new
# returns no Brief on the second + later calls) so the 204 path resumes
# naturally after the first unlock.
if choice == "bawlmorese":
_note, _created, brief = Note.grant_if_new(request.user, "baltimorean")
if brief is not None:
return JsonResponse({"brief": brief.to_banner_dict()})
return HttpResponse(status=204)
@login_required(login_url="/") @login_required(login_url="/")
def toggle_applets(request): def toggle_applets(request):
checked = request.POST.getlist("applets") checked = request.POST.getlist("applets")
@@ -190,6 +177,30 @@ def toggle_applets(request):
}) })
return redirect("home") return redirect("home")
def _shop_items_for(user):
"""Decorate the active ShopItem catalog w. per-user availability so the
template can render `.btn-disabled` + 'Already owned' microtooltip
for `max_owned`-capped items the user already holds. Items are returned
in `display_order` ASC (matches the seeded `tithe-1` < `tithe-5` < `band-1`)."""
items = []
for item in ShopItem.objects.filter(active=True).order_by("display_order", "slug"):
item.available = item.is_available_for(user)
items.append(item)
return items
def _free_decks_for(user):
"""Decorate the free-in-shop DeckVariant catalog (RWS + Fiorentine) w. a
per-user `.owned` flag so the Shop applet renders a FREE ITEM btn for
decks the user hasn't claimed + an 'Already owned' pill for ones already
in `unlocked_decks`. Ordered by name for a stable render."""
unlocked_ids = set(user.unlocked_decks.values_list("pk", flat=True))
decks = list(DeckVariant.objects.filter(free_in_shop=True).order_by("name"))
for deck in decks:
deck.owned = deck.pk in unlocked_ids
return decks
@login_required(login_url="/") @login_required(login_url="/")
@ensure_csrf_cookie @ensure_csrf_cookie
def wallet(request): def wallet(request):
@@ -197,10 +208,18 @@ def wallet(request):
token_type=Token.FREE, expires_at__gt=timezone.now() token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at")) ).order_by("expires_at"))
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE)) tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
shop_items = _shop_items_for(request.user)
default_pm = request.user.payment_methods.order_by("-pk").first()
return render(request, "apps/dashboard/wallet.html", { return render(request, "apps/dashboard/wallet.html", {
"wallet": request.user.wallet, "wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(), "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"shop_items": shop_items,
"free_decks": _free_decks_for(request.user),
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
"free_tokens": free_tokens, "free_tokens": free_tokens,
"tithe_tokens": tithe_tokens, "tithe_tokens": tithe_tokens,
"free_count": len(free_tokens), "free_count": len(free_tokens),
@@ -232,11 +251,18 @@ def toggle_wallet_applets(request):
checked = request.POST.getlist("applets") checked = request.POST.getlist("applets")
apply_applet_toggle(request.user, "wallet", checked) apply_applet_toggle(request.user, "wallet", checked)
if request.headers.get("HX-Request"): if request.headers.get("HX-Request"):
default_pm = request.user.payment_methods.order_by("-pk").first()
return render(request, "apps/wallet/_partials/_applets.html", { return render(request, "apps/wallet/_partials/_applets.html", {
"applets": applet_context(request.user, "wallet"), "applets": applet_context(request.user, "wallet"),
"wallet": request.user.wallet, "wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(), "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"shop_items": _shop_items_for(request.user),
"free_decks": _free_decks_for(request.user),
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)), "tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
}) })
@@ -271,9 +297,169 @@ def save_payment_method(request):
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand}) return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})
# ── Shop: PaymentIntent flow ─────────────────────────────────────────────────
# Three endpoints split fulfillment responsibility:
# /shop/buy creates a Purchase (PENDING) + a Stripe PaymentIntent.
# Returns client_secret so Stripe.js can confirmCardPayment
# (handles 3DS natively).
# /shop/confirm sync follow-up after Stripe.js confirms client-side. Pulls
# PI status from Stripe; if SUCCEEDED, calls Purchase.fulfill()
# immediately (faster UX than waiting for the webhook round-trip).
# /stripe/webhook async fulfillment from Stripe's webhook delivery. Same
# Purchase.fulfill() call — whichever (confirm or webhook)
# lands first wins; the other becomes a no-op via fulfill()'s
# idempotent guard.
#
# Decisions locked 2026-05-21 in [[project-wallet-shop-expansion]]:
# * Webhook is THE authoritative source for fulfillment (resilient to 3DS,
# network drops, browser closes during checkout).
# * Confirm endpoint is a UX-speedup belt-and-suspenders; never required.
# * Webhook idempotency via Purchase.fulfill()'s status==SUCCEEDED guard.
# * No STRIPE_LIVE_MODE setting — env-var swap is all that's needed.
@login_required(login_url="/")
def shop_buy(request):
"""Create a Stripe PaymentIntent + a PENDING Purchase row.
Body: `shop_item_slug` (form-encoded).
Returns: 200 `{client_secret, purchase_id}` on success;
402 if the user has no saved PaymentMethod;
404 if the slug doesn't match an active ShopItem;
409 if the item's max_owned cap is reached for this user.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
slug = request.POST.get("shop_item_slug", "")
item = ShopItem.objects.filter(slug=slug, active=True).first()
if item is None:
return HttpResponse(status=404)
if not item.is_available_for(request.user):
return HttpResponse(status=409)
pm = request.user.payment_methods.order_by("-pk").first()
if pm is None:
return HttpResponse(status=402)
intent = stripe.PaymentIntent.create(
amount=item.price_cents,
currency="usd",
customer=request.user.stripe_customer_id,
payment_method=pm.stripe_pm_id,
# `automatic_payment_methods` so Stripe.js picks the right confirm
# method (cards, wallets, etc.) without us hard-coding payment-method-
# type plumbing. `allow_redirects=never` keeps the 3DS challenge in
# the same window — Stripe.js handles the modal natively.
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
metadata={
# Webhook handler looks up the Purchase by this on
# `payment_intent.succeeded`. Belt-and-suspenders w. looking up
# by `stripe_payment_intent_id` (also unique).
"purchase_id": "_pending_", # overwritten after Purchase.save() below
},
)
purchase = Purchase.objects.create(
user=request.user,
shop_item=item,
stripe_payment_intent_id=intent.id,
amount_cents=item.price_cents,
granted_writs=item.granted_writs,
)
# Now we have purchase.pk — backfill the metadata on the PI so the
# webhook handler can resolve back to it.
stripe.PaymentIntent.modify(
intent.id, metadata={"purchase_id": str(purchase.pk)},
)
return JsonResponse({
"client_secret": intent.client_secret,
"purchase_id": purchase.pk,
})
@login_required(login_url="/")
def shop_confirm(request):
"""Sync follow-up after Stripe.js confirms client-side. Polls the PI
once + fulfills if SUCCEEDED. Idempotent w. the webhook handler via
`Purchase.fulfill()`'s status guard.
Body: `purchase_id` (form-encoded).
Returns: 200 always (sync fulfillment is best-effort; webhook is
authoritative). 404 if the purchase doesn't belong to this user.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
purchase_id = request.POST.get("purchase_id")
purchase = Purchase.objects.filter(
pk=purchase_id, user=request.user,
).first()
if purchase is None:
return HttpResponse(status=404)
if purchase.status != Purchase.SUCCEEDED:
intent = stripe.PaymentIntent.retrieve(purchase.stripe_payment_intent_id)
if intent.status == "succeeded":
purchase.fulfill()
return JsonResponse({"status": purchase.status})
@login_required(login_url="/")
def shop_claim_free(request):
"""Claim a free ($0) deck from the Shop applet — adds the DeckVariant to
the user's `unlocked_decks` so it renders in the Game Kit applet. No
Stripe, no Purchase row (free decks aren't paid goods).
Body: `deck_slug` (form-encoded).
Returns: 200 `{owned: true, deck_name}` on claim or re-claim (M2M add is
idempotent); 404 if the slug isn't a `free_in_shop` deck — the
`free_in_shop` filter is the guard that stops this $0 endpoint
from unlocking paid/auto-granted decks (eg Earthman).
"""
slug = request.POST.get("deck_slug", "")
deck = DeckVariant.objects.filter(slug=slug, free_in_shop=True).first()
if deck is None:
return HttpResponse(status=404)
request.user.unlocked_decks.add(deck)
return JsonResponse({"owned": True, "deck_name": deck.name})
@csrf_exempt
def stripe_webhook(request):
"""Stripe webhook listener. Verifies signature against
`STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
`Purchase.fulfill()` (idempotent w. `/shop/confirm`).
Always returns 2xx (even on unknown event types or already-fulfilled
purchases) — Stripe retries on 5xx, which would just deliver the same
event repeatedly. 4xx is reserved for signature mismatch (a genuine
auth failure that Stripe should NOT retry).
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
payload = request.body
sig_header = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET,
)
except (ValueError, Exception) as e:
# ValueError = invalid payload; SignatureVerificationError = bad sig.
# Either way, refuse — Stripe will alert if it can't deliver.
if isinstance(e, ValueError) or "Signature" in type(e).__name__:
return HttpResponse(status=400)
raise
if event["type"] == "payment_intent.succeeded":
intent = event["data"]["object"]
purchase_id = intent.get("metadata", {}).get("purchase_id")
purchase = None
if purchase_id and purchase_id.isdigit():
purchase = Purchase.objects.filter(pk=int(purchase_id)).first()
# Fall-back lookup by PI ID in case metadata's missing for any reason.
if purchase is None:
purchase = Purchase.objects.filter(
stripe_payment_intent_id=intent.get("id", ""),
).first()
if purchase is not None:
purchase.fulfill()
return HttpResponse(status=200)
# ── My Sky (personal natal chart) ──────────────────────────────────────────── # ── My Sky (personal natal chart) ────────────────────────────────────────────
def _sky_natus_preview(request): def _sky_preview_data(request):
"""Shared preview logic — proxies to PySwiss, no DB writes.""" """Shared preview logic — proxies to PySwiss, no DB writes."""
date_str = request.GET.get('date') date_str = request.GET.get('date')
time_str = request.GET.get('time', '12:00') time_str = request.GET.get('time', '12:00')
@@ -360,13 +546,13 @@ def sky_view(request):
"saved_birth_lat": request.user.sky_birth_lat, "saved_birth_lat": request.user.sky_birth_lat,
"saved_birth_lon": request.user.sky_birth_lon, "saved_birth_lon": request.user.sky_birth_lon,
"saved_birth_tz": request.user.sky_birth_tz, "saved_birth_tz": request.user.sky_birth_tz,
"page_class": "page-sky", "page_class": "page-sky" + (" sky-saved" if chart_data else ""),
}) })
@login_required(login_url="/") @login_required(login_url="/")
def sky_preview(request): def sky_preview(request):
return _sky_natus_preview(request) return _sky_preview_data(request)
@login_required(login_url="/") @login_required(login_url="/")
@@ -411,22 +597,36 @@ def sky_save(request):
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data', 'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
]) ])
note_payload = None brief_payload = None
if user.sky_chart_data: if user.sky_chart_data:
note, created = Note.grant_if_new(user, "stargazer") note, created, brief = Note.grant_if_new(user, "stargazer")
if created: if created and brief is not None:
note_payload = { brief_payload = brief.to_banner_dict()
"slug": note.slug,
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
"earned_at": note.earned_at.isoformat(),
}
return JsonResponse({"saved": True, "note": note_payload}) return JsonResponse({"saved": True, "brief": brief_payload})
@login_required(login_url="/") @login_required(login_url="/")
def sky_natus_data(request): def sky_delete(request):
if request.method != 'POST':
return HttpResponse(status=405)
user = request.user
user.sky_birth_dt = None
user.sky_birth_lat = None
user.sky_birth_lon = None
user.sky_birth_place = ''
user.sky_birth_tz = ''
user.sky_house_system = User._meta.get_field('sky_house_system').default
user.sky_chart_data = None
user.save(update_fields=[
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
])
return HttpResponseRedirect(reverse('sky'))
@login_required(login_url="/")
def sky_data(request):
user = request.user user = request.user
if not user.sky_chart_data: if not user.sky_chart_data:
return HttpResponse(status=404) return HttpResponse(status=404)

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-08 21:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0003_grant_super_notes_to_existing_superusers'),
]
operations = [
migrations.AlterField(
model_name='gameevent',
name='verb',
field=models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed'), ('sig_ready', 'Sig claim staked'), ('sig_unready', 'Sig claim withdrawn'), ('sky_saved', 'Sky saved')], max_length=30),
),
]

View File

@@ -1,11 +1,12 @@
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
# ── Default gender-neutral pronouns (Baltimore original) ────────────────────── from apps.lyric.models import resolve_pronouns
# Later: replace with per-actor lookup when User model gains a pronouns field.
PRONOUN_SUBJ = "yo"
PRONOUN_OBJ = "yo" def _actor_pronouns(actor):
PRONOUN_POSS = "yos" """Return (subj, obj, poss) for an event actor; default = pluralism when None."""
return resolve_pronouns(getattr(actor, "pronouns", None) if actor else None)
class GameEvent(models.Model): class GameEvent(models.Model):
@@ -23,6 +24,8 @@ class GameEvent(models.Model):
# Sig Select phase # Sig Select phase
SIG_READY = "sig_ready" SIG_READY = "sig_ready"
SIG_UNREADY = "sig_unready" SIG_UNREADY = "sig_unready"
# Sky Select phase
SKY_SAVED = "sky_saved"
VERB_CHOICES = [ VERB_CHOICES = [
(ROOM_CREATED, "Room created"), (ROOM_CREATED, "Room created"),
@@ -36,6 +39,7 @@ class GameEvent(models.Model):
(ROLES_REVEALED, "Roles revealed"), (ROLES_REVEALED, "Roles revealed"),
(SIG_READY, "Sig claim staked"), (SIG_READY, "Sig claim staked"),
(SIG_UNREADY, "Sig claim withdrawn"), (SIG_UNREADY, "Sig claim withdrawn"),
(SKY_SAVED, "Sky saved"),
] ]
room = models.ForeignKey( room = models.ForeignKey(
@@ -67,12 +71,29 @@ class GameEvent(models.Model):
return f"deposits a {token} for slot {slot} (expires in {days} days)." return f"deposits a {token} for slot {slot} (expires in {days} days)."
if self.verb == self.SLOT_RESERVED: if self.verb == self.SLOT_RESERVED:
return "reserves a seat" return "reserves a seat"
if self.verb == self.SLOT_RETURNED: if self.verb in (self.SLOT_RETURNED, self.SLOT_RELEASED):
return "withdraws from the gate" # Symmetric counterpart to SLOT_FILLED's "deposits a {token} for
if self.verb == self.SLOT_RELEASED: # slot {#} …" — same shape so the redact-pair (strikethrough on
return f"releases slot {d.get('slot_number', '?')}" # the prior deposit, new withdraw entry below it) reads as a
# mirror image in the room scroll. User-spec 2026-05-26 sprint
# A.8. SLOT_RETURNED + SLOT_RELEASED both render w. this prose;
# the verb distinction stays in the data layer (different paths
# trigger them — full token return vs. per-slot CARTE release).
_token_names = {
"coin": "Coin-on-a-String", "Free": "Free Token",
"tithe": "Tithe Token", "pass": "Backstage Pass", "carte": "Carte Blanche",
}
code = d.get("token_type", "token")
token = d.get("token_display") or _token_names.get(code, code)
slot = d.get("slot_number", "?")
_, _, poss = _actor_pronouns(self.actor)
return f"withdraws {poss} {token} from slot {slot}."
if self.verb == self.ROOM_CREATED: if self.verb == self.ROOM_CREATED:
return "opens this room" # First scroll log on a fresh room — system-authored greeting
# (actor=None upstream). Format intentionally drops the actor
# prefix the rest of the verbs use; the room's title is what
# the welcome line celebrates.
return f"Welcome to {self.room.name}!"
if self.verb == self.INVITE_SENT: if self.verb == self.INVITE_SENT:
return "sends an invitation" return "sends an invitation"
if self.verb == self.ROLE_SELECT_STARTED: if self.verb == self.ROLE_SELECT_STARTED:
@@ -90,7 +111,8 @@ class GameEvent(models.Model):
ordinal = _ordinals[_chair_order.index(code)] ordinal = _ordinals[_chair_order.index(code)]
except ValueError: except ValueError:
ordinal = "?" ordinal = "?"
return f"assumes {ordinal} Chair; yo will start the game as the {role}." subj, _, _ = _actor_pronouns(self.actor)
return f"assumes {ordinal} Chair; {subj} will start the game as the {role}."
if self.verb == self.ROLES_REVEALED: if self.verb == self.ROLES_REVEALED:
return "All roles assigned" return "All roles assigned"
if self.verb == self.SIG_READY: if self.verb == self.SIG_READY:
@@ -102,9 +124,36 @@ class GameEvent(models.Model):
abbrev = f" ({corner_rank}{icon_html})" abbrev = f" ({corner_rank}{icon_html})"
else: else:
abbrev = "" abbrev = ""
return f"embodies as {PRONOUN_POSS} Significator the {card_name}{abbrev}." # Trump cards ("The Schizo", "The Nomad", "The Wanderer") drop
# their "The " in this rendering: the prose template already
# supplies "the", and a levity/gravity qualifier (e.g. "Engraven"
# in "Engraven The Nomad") needs to butt up against the proper name.
card_name = card_name.replace("The ", "", 1)
_, _, poss = _actor_pronouns(self.actor)
return f"embodies as {poss} Significator the {card_name}{abbrev}."
if self.verb == self.SIG_UNREADY: if self.verb == self.SIG_UNREADY:
return f"disembodies {PRONOUN_POSS} Significator." _, _, poss = _actor_pronouns(self.actor)
return f"disembodies {poss} Significator."
if self.verb == self.SKY_SAVED:
_, obj, poss = _actor_pronouns(self.actor)
caps = list(d.get("top_capacitors") or [])
if not caps:
return f"beholds the skyscape of {poss} birth."
if len(caps) == 1:
return (
f"beholds the skyscape of {poss} birth, "
f"which yields {obj} a unique {caps[0]} capacity."
)
# Tied highest: "equal X and Y capacities" (2-way) or
# "equal X, Y, and Z capacities" (3+, Oxford comma).
if len(caps) == 2:
joined = f"{caps[0]} and {caps[1]}"
else:
joined = ", ".join(caps[:-1]) + f", and {caps[-1]}"
return (
f"beholds the skyscape of {poss} birth, "
f"which yields {obj} equal {joined} capacities."
)
return self.verb return self.verb
@property @property
@@ -176,8 +225,18 @@ _NOTE_DISPLAY = {
"nomad": {"greeting": "Welcome,", "title": "Nomad"}, "nomad": {"greeting": "Welcome,", "title": "Nomad"},
"super-schizo": {"greeting": "21<span class='ord'>st</span> Century", "title": "Schizoid Man"}, "super-schizo": {"greeting": "21<span class='ord'>st</span> Century", "title": "Schizoid Man"},
"super-nomad": {"greeting": "Howdy,", "title": "Stranger"}, "super-nomad": {"greeting": "Howdy,", "title": "Stranger"},
"baltimorean": {"greeting": "Ayo,", "title": "Ard!", "card_title": "Baltimorean"},
} }
# Note slugs whose grant prose uses the long admin format ("The administration
# recognizes…") rather than the standard "Look!—new Note unlocked…" format.
# Any slug not in this set gets the standard format.
_ADMIN_NOTE_SLUGS = frozenset({"super-schizo", "super-nomad"})
# Hardcoded title for the per-user "Note unlocks" Post — supplants any
# first-line-glean for posts of kind=NOTE_UNLOCK.
NOTE_UNLOCK_POST_TITLE = "Notes & recognitions"
class Note(models.Model): class Note(models.Model):
user = models.ForeignKey( user = models.ForeignKey(
@@ -203,10 +262,109 @@ class Note(models.Model):
def display_greeting(self): def display_greeting(self):
return _NOTE_DISPLAY.get(self.slug, {}).get("greeting", "Welcome,") return _NOTE_DISPLAY.get(self.slug, {}).get("greeting", "Welcome,")
@property
def card_title(self):
"""The string shown in the my-notes card's "Title:" row. Defaults to
`display_title` (the don-able title — most slugs render the title you
DON here, e.g. "Schizoid Man" for super-schizo). Baltimorean overrides
so the card reads "Baltimorean" instead of the navbar-only "Ard!"
flair."""
return _NOTE_DISPLAY.get(self.slug, {}).get("card_title", self.display_title)
@property
def display_name(self):
"""The Note's *name* (e.g., "Stargazer", "Super-Schizo") — the heading
rendered on the my-notes card. Distinct from `display_title` which is
the *recognition title* the user dons (e.g., "Schizoid Man" for
super-schizo). For all current slugs `slug.title()` recovers the right
casing (.title() capitalizes after non-letter chars, so "super-schizo"
"Super-Schizo"); special-case in `_NOTE_DISPLAY[slug]["name"]` if a
future slug needs a different rendering."""
return _NOTE_DISPLAY.get(self.slug, {}).get("name", self.slug.title())
@classmethod @classmethod
def grant_if_new(cls, user, slug): def grant_if_new(cls, user, slug):
"""Grants the Note if it doesn't already exist on the user; on a fresh
grant ALSO appends a Line to the user's per-category "Notes &
recognitions" Post (creating the Post on first-ever unlock) and spawns
a Brief that FKs the appended Line. Returns ``(note, created, brief)``
— brief is None on idempotent re-grants. Banner-side affordances (FYI
navigation, my-notes square) ride on Brief.kind=NOTE_UNLOCK.
Line text dispatches by slug: admin-grant slugs (super-schizo,
super-nomad) use the long "The administration recognizes…" format;
every other slug uses the standard "Look!—new Note unlocked. {Note
name} recognizes {username} the {title}." format. Both wrap the Note
name in a `note-ref` anchor pointing at /billboard/my-notes/.
Author is hardcoded to the seeded `adman` User; the per-line username
column then attributes the Line correctly."""
from django.utils import timezone from django.utils import timezone
return cls.objects.get_or_create(
from apps.billboard.models import Brief, Line, Post
from apps.lyric.models import get_or_create_adman
note, created = cls.objects.get_or_create(
user=user, slug=slug, user=user, slug=slug,
defaults={"earned_at": timezone.now()}, defaults={"earned_at": timezone.now()},
) )
if not created:
return note, created, None
post, _ = Post.objects.get_or_create(
owner=user, kind=Post.KIND_NOTE_UNLOCK,
defaults={"title": NOTE_UNLOCK_POST_TITLE},
)
# Existing Note-unlock Posts (pre-0004 migration) might lack a title
# if they predate this code path's get_or_create defaults. Heal once.
if post.title != NOTE_UNLOCK_POST_TITLE:
post.title = NOTE_UNLOCK_POST_TITLE
post.save(update_fields=["title"])
# Bare-email fallback when user.username is None (no `@` prefix —
# the address already carries one). When username is set, use the
# `@handle` form. Both wrapped in .post-attribution so the CSS
# palette key (--quaUser) lights up the username + title combo.
handle = f"@{user.username}" if user.username else user.email
note_anchor = (
f'<a class="note-ref" href="/billboard/my-notes/">'
f'{note.display_name}</a>'
)
attr_handle = f'<span class="post-attribution">{handle}</span>'
attr_title = f'<span class="post-attribution">{note.display_title}</span>'
if slug in _ADMIN_NOTE_SLUGS:
line_text = (
f"The administration recognizes {attr_handle} for {note_anchor}, "
f"which bestows the honorary title of {attr_title}. "
"This does not entail any additional corporate benefits."
)
else:
# Inline attribution reads "{handle} the {Note name}", not the
# don-able title. For most slugs the two coincide ("the Stargazer")
# but Baltimorean's title is the navbar flair "Ard!" — "the Ard!"
# reads oddly inline; "the Baltimorean" matches the Note name.
attr_combo = (
f'<span class="post-attribution">{handle} '
f'the {note.display_name}</span>'
)
line_text = (
f"Look!—new Note unlocked. {note_anchor} "
f"recognizes {attr_combo}."
)
# Lazy get-or-create: TransactionTestCase flushes the migration-seeded
# adman row, so tests that create superusers (which auto-grants
# super-schizo + super-nomad via the User post_save signal) need a
# safety net. Production migrations seed it once.
adman = get_or_create_adman()
line = Line.objects.create(
post=post, text=line_text, author=adman,
admin_solicited=True,
)
brief = Brief.objects.create(
owner=user,
post=post,
line=line,
kind=Brief.KIND_NOTE_UNLOCK,
title=note.display_name,
)
return note, created, brief

View File

@@ -56,7 +56,8 @@ class GameEventModelTest(TestCase):
role="PC", role_display="Player") role="PC", role_display="Player")
prose = event.to_prose() prose = event.to_prose()
self.assertIn("Player", prose) self.assertIn("Player", prose)
self.assertIn("yo will start the game", prose) # Default user pronouns = pluralism → "they".
self.assertIn("they will start the game", prose)
# ── to_prose — SIG_READY ───────────────────────────────────────────── # ── to_prose — SIG_READY ─────────────────────────────────────────────
@@ -65,7 +66,8 @@ class GameEventModelTest(TestCase):
card_name="Maid of Brands", corner_rank="M", card_name="Maid of Brands", corner_rank="M",
suit_icon="fa-wand-sparkles") suit_icon="fa-wand-sparkles")
prose = event.to_prose() prose = event.to_prose()
self.assertIn("embodies as yos Significator the Maid of Brands", prose) # Default user pronouns = pluralism → "their".
self.assertIn("embodies as their Significator the Maid of Brands", prose)
self.assertIn("(M", prose) self.assertIn("(M", prose)
self.assertIn("fa-wand-sparkles", prose) self.assertIn("fa-wand-sparkles", prose)
@@ -73,15 +75,100 @@ class GameEventModelTest(TestCase):
event = record(self.room, GameEvent.SIG_READY, actor=self.user, event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="The Wanderer", corner_rank="0", suit_icon="") card_name="The Wanderer", corner_rank="0", suit_icon="")
prose = event.to_prose() prose = event.to_prose()
self.assertIn("embodies as yos Significator the The Wanderer (0)", prose) # Trump card: leading "The " is stripped so qualifier (if any) butts up
# against the proper name and "the The Wanderer" never reads doubled.
self.assertIn("embodies as their Significator the Wanderer (0)", prose)
self.assertNotIn("the The Wanderer", prose)
self.assertNotIn("fa-", prose) self.assertNotIn("fa-", prose)
def test_sig_ready_prose_strips_leading_the_on_qualified_trump(self):
# Trump card with a levity/gravity qualifier already pre-pended in
# card_name: must read "the Engraven Nomad", not "the Engraven The Nomad".
# Default actor pronouns = pluralism → "their".
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="Engraven The Nomad", corner_rank="0", suit_icon="")
prose = event.to_prose()
self.assertIn("embodies as their Significator the Engraven Nomad (0)", prose)
self.assertNotIn("Engraven The Nomad", prose)
def test_sig_ready_prose_uses_actor_pronouns_at_render_time(self):
# Bawlmorese actor → "yos"; default actor → "their"; switching
# the actor's pronouns updates ALL their existing prose on next render.
self.user.pronouns = "bawlmorese"
self.user.save(update_fields=["pronouns"])
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="Maid of Brands", corner_rank="M",
suit_icon="fa-wand-sparkles")
self.assertIn("embodies as yos Significator the Maid of Brands", event.to_prose())
self.user.pronouns = "misogyny"
self.user.save(update_fields=["pronouns"])
# Re-fetch — the related object cache may hold the stale pronouns.
event.refresh_from_db()
self.assertIn("embodies as his Significator the Maid of Brands", event.to_prose())
def test_sig_unready_prose_uses_actor_pronouns(self):
self.user.pronouns = "misandry"
self.user.save(update_fields=["pronouns"])
event = record(self.room, GameEvent.SIG_UNREADY, actor=self.user)
self.assertIn("disembodies hers Significator", event.to_prose())
def test_role_selected_prose_uses_actor_pronouns(self):
self.user.pronouns = "misanthropy"
self.user.save(update_fields=["pronouns"])
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
role="PC", role_display="Player")
self.assertIn("it will start the game", event.to_prose())
# ── to_prose — SKY_SAVED ──────────────────────────────────────────────
def test_sky_saved_prose_single_capacitor(self):
# Default user pronouns = pluralism → "their" + "them".
event = record(self.room, GameEvent.SKY_SAVED, actor=self.user,
top_capacitors=["Ardor"])
prose = event.to_prose()
self.assertIn(
"beholds the skyscape of their birth, which yields them a unique Ardor capacity.",
prose,
)
def test_sky_saved_prose_two_way_tie(self):
# Tied highest scores: pluralize "a unique" → "equal", join w. "and",
# pluralize "capacity" → "capacities".
event = record(self.room, GameEvent.SKY_SAVED, actor=self.user,
top_capacitors=["Ardor", "Ossum"])
prose = event.to_prose()
self.assertIn(
"beholds the skyscape of their birth, which yields them equal Ardor and Ossum capacities.",
prose,
)
def test_sky_saved_prose_three_way_tie_uses_oxford_comma(self):
event = record(self.room, GameEvent.SKY_SAVED, actor=self.user,
top_capacitors=["Ardor", "Ossum", "Pneuma"])
prose = event.to_prose()
self.assertIn(
"beholds the skyscape of their birth, which yields them equal Ardor, Ossum, and Pneuma capacities.",
prose,
)
def test_sky_saved_prose_uses_actor_pronouns(self):
self.user.pronouns = "bawlmorese"
self.user.save(update_fields=["pronouns"])
event = record(self.room, GameEvent.SKY_SAVED, actor=self.user,
top_capacitors=["Tempo"])
# Bawlmorese → poss "yos", obj "yo".
self.assertIn(
"beholds the skyscape of yos birth, which yields yo a unique Tempo capacity.",
event.to_prose(),
)
def test_sig_ready_prose_degrades_without_corner_rank(self): def test_sig_ready_prose_degrades_without_corner_rank(self):
# Old events recorded before this change have no corner_rank key # Old events recorded before this change have no corner_rank key
event = record(self.room, GameEvent.SIG_READY, actor=self.user, event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="Maid of Brands") card_name="Maid of Brands")
prose = event.to_prose() prose = event.to_prose()
self.assertIn("embodies as yos Significator the Maid of Brands", prose) # Default user pronouns = pluralism → "their".
self.assertIn("embodies as their Significator the Maid of Brands", prose)
self.assertNotIn("(", prose) self.assertNotIn("(", prose)
def test_str_without_actor_shows_system(self): def test_str_without_actor_shows_system(self):
@@ -94,13 +181,33 @@ class GameEventModelTest(TestCase):
event = record(self.room, GameEvent.SLOT_RESERVED, actor=self.user) event = record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
self.assertEqual(event.to_prose(), "reserves a seat") self.assertEqual(event.to_prose(), "reserves a seat")
def test_slot_returned_prose(self): def test_slot_returned_prose_includes_token_and_slot(self):
event = record(self.room, GameEvent.SLOT_RETURNED, actor=self.user) # Sprint A.8 (2026-05-26): SLOT_RETURNED now mirrors SLOT_FILLED's
self.assertEqual(event.to_prose(), "withdraws from the gate") # shape — symmetric redact-pair on the scroll. Data fields match
# the deposit event so the prose reads as a clean mirror.
event = record(
self.room, GameEvent.SLOT_RETURNED, actor=self.user,
slot_number=2, token_type="coin",
token_display="Coin-on-a-String",
)
prose = event.to_prose()
self.assertIn("withdraws", prose)
self.assertIn("Coin-on-a-String", prose)
self.assertIn("slot 2", prose)
def test_slot_released_prose_includes_slot_number(self): def test_slot_released_prose_uses_unified_withdraw_shape(self):
event = record(self.room, GameEvent.SLOT_RELEASED, actor=self.user, slot_number=3) # SLOT_RELEASED (per-slot CARTE release) shares the unified
self.assertIn("slot 3", event.to_prose()) # withdraw prose w. SLOT_RETURNED — same visual mirror of the
# deposit. The verb distinction stays in the data layer.
event = record(
self.room, GameEvent.SLOT_RELEASED, actor=self.user,
slot_number=3, token_type="carte",
token_display="Carte Blanche",
)
prose = event.to_prose()
self.assertIn("withdraws", prose)
self.assertIn("Carte Blanche", prose)
self.assertIn("slot 3", prose)
def test_invite_sent_prose(self): def test_invite_sent_prose(self):
event = record(self.room, GameEvent.INVITE_SENT, actor=self.user) event = record(self.room, GameEvent.INVITE_SENT, actor=self.user)
@@ -221,14 +328,14 @@ class NoteModelTest(TestCase):
self.assertIn("earner@test.io", s) self.assertIn("earner@test.io", s)
def test_grant_if_new_creates_on_first_call(self): def test_grant_if_new_creates_on_first_call(self):
recog, created = Note.grant_if_new(self.user, "stargazer") recog, created, _brief = Note.grant_if_new(self.user, "stargazer")
self.assertTrue(created) self.assertTrue(created)
self.assertEqual(recog.slug, "stargazer") self.assertEqual(recog.slug, "stargazer")
self.assertIsNotNone(recog.earned_at) self.assertIsNotNone(recog.earned_at)
def test_grant_if_new_is_idempotent(self): def test_grant_if_new_is_idempotent(self):
Note.grant_if_new(self.user, "stargazer") Note.grant_if_new(self.user, "stargazer")
recog, created = Note.grant_if_new(self.user, "stargazer") recog, created, _brief = Note.grant_if_new(self.user, "stargazer")
self.assertFalse(created) self.assertFalse(created)
self.assertEqual(Note.objects.count(), 1) self.assertEqual(Note.objects.count(), 1)
@@ -237,6 +344,6 @@ class NoteModelTest(TestCase):
user=self.user, slug="stargazer", user=self.user, slug="stargazer",
earned_at=timezone.now(), palette="palette-bardo", earned_at=timezone.now(), palette="palette-bardo",
) )
recog, created = Note.grant_if_new(self.user, "stargazer") recog, created, _brief = Note.grant_if_new(self.user, "stargazer")
self.assertFalse(created) self.assertFalse(created)
self.assertEqual(recog.palette, "palette-bardo") self.assertEqual(recog.palette, "palette-bardo")

View File

@@ -0,0 +1,87 @@
"""ITs for the Brief sprint C3.a — Note.grant_if_new spawns Line + Brief.
Per the per-category Post model: the user has a single "Note Unlocks" Post;
each unlock appends a Line ("Stargazer, 5:21pm") and spawns a Brief FKing
the appended Line. Briefs of kind=NOTE_UNLOCK live on Posts of kind=
NOTE_UNLOCK.
"""
from django.test import TestCase
from apps.billboard.models import Brief, Line, Post
from apps.drama.models import Note
from apps.lyric.models import User
class GrantIfNewSpawnsBriefTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="brief-grant@test.io")
def test_first_grant_creates_post_line_and_brief(self):
note, created, brief = Note.grant_if_new(self.user, "stargazer")
self.assertTrue(created)
self.assertIsNotNone(brief)
# Note Unlocks Post created on user
post = Post.objects.get(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
# Brief points at that Post + the appended Line
self.assertEqual(brief.post_id, post.id)
self.assertEqual(brief.kind, Brief.KIND_NOTE_UNLOCK)
self.assertTrue(brief.is_unread)
self.assertEqual(brief.owner, self.user)
# The Brief's line is one of the Post's lines
self.assertIn(brief.line, list(post.lines.all()))
# Brief title matches Note display name (the Note's name — used in the
# banner's title slot and inline "the {name}" attribution).
self.assertEqual(brief.title, note.display_name)
def test_second_grant_same_slug_returns_no_brief(self):
Note.grant_if_new(self.user, "stargazer")
note, created, brief = Note.grant_if_new(self.user, "stargazer")
self.assertFalse(created)
self.assertIsNone(brief)
# Still only one Brief / Line for the user (idempotent)
self.assertEqual(
Brief.objects.filter(owner=self.user, kind=Brief.KIND_NOTE_UNLOCK).count(), 1
)
def test_two_different_grants_share_one_post(self):
"""Per-category Post: stargazer + schizo unlocks both append Lines to
the same "Notes & recognitions" Post (one growing thread)."""
Note.grant_if_new(self.user, "stargazer")
Note.grant_if_new(self.user, "schizo")
posts = Post.objects.filter(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
self.assertEqual(posts.count(), 1, "Only one Note Unlocks Post per user")
post = posts.first()
# 2 Briefs, one per unlock
self.assertEqual(Brief.objects.filter(post=post).count(), 2)
# 2 distinct Lines on the Post — one per grant. The standalone
# "Look! — new Note unlocked" header Line was dropped in the May-8b
# refactor; the standard format now embeds that text inline per Line.
line_texts = list(post.lines.values_list("text", flat=True))
self.assertEqual(len(set(line_texts)), 2)
def test_brief_line_text_includes_note_title(self):
_, _, brief = Note.grant_if_new(self.user, "stargazer")
self.assertIn("Stargazer", brief.line.text)
def test_post_kind_is_note_unlock_for_grant(self):
Note.grant_if_new(self.user, "stargazer")
post = Post.objects.get(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
self.assertEqual(post.kind, Post.KIND_NOTE_UNLOCK)
class PostKindFieldTest(TestCase):
"""Post gains a `kind` enum so the per-category lookup (e.g. find the
user's Note Unlocks Post) is deterministic; user-authored Posts default
to KIND_USER_POST."""
def test_post_default_kind_is_user_post(self):
u = User.objects.create(email="kind@test.io")
p = Post.objects.create(owner=u)
self.assertEqual(p.kind, Post.KIND_USER_POST)
def test_post_kind_choices_include_three_values(self):
choices = dict(Post._meta.get_field("kind").choices)
self.assertIn(Post.KIND_NOTE_UNLOCK, choices)
self.assertIn(Post.KIND_USER_POST, choices)
self.assertIn(Post.KIND_SHARE_INVITE, choices)

View File

View File

@@ -0,0 +1,50 @@
from django.test import SimpleTestCase
from apps.drama.models import Note
def _note(slug):
n = Note()
n.slug = slug
return n
class NoteDisplayBaltimoreanTest(SimpleTestCase):
"""`_NOTE_DISPLAY['baltimorean']` — greeting/title combo "Ayo, Ard!" replaces
"Welcome, Earthman" once the user dons the Baltimorean Note (unlocked by
first-time `pronouns = bawlmorese` selection)."""
def test_baltimorean_display_title_is_ard(self):
self.assertEqual(_note("baltimorean").display_title, "Ard!")
def test_baltimorean_display_greeting_is_ayo(self):
self.assertEqual(_note("baltimorean").display_greeting, "Ayo,")
def test_baltimorean_display_name_is_baltimorean(self):
"""The my-notes heading label — `.title()` of the slug recovers the
proper rendering for any slug without a `name` override in
`_NOTE_DISPLAY`. Baltimorean has no override, so this should fall
through to `slug.title()` → 'Baltimorean'."""
self.assertEqual(_note("baltimorean").display_name, "Baltimorean")
def test_baltimorean_card_title_is_baltimorean(self):
"""The my-notes card's "Title:" row uses `card_title`, which Baltimorean
explicitly overrides to "Baltimorean" so the navbar-only "Ard!" flair
doesn't leak onto the card."""
self.assertEqual(_note("baltimorean").card_title, "Baltimorean")
class NoteDisplayStargazerTest(SimpleTestCase):
"""Smoke tests for the existing stargazer entry — pins the contract that
new entries follow."""
def test_stargazer_display_title_is_stargazer(self):
self.assertEqual(_note("stargazer").display_title, "Stargazer")
def test_stargazer_display_greeting_is_welcome(self):
self.assertEqual(_note("stargazer").display_greeting, "Welcome,")
def test_stargazer_card_title_falls_back_to_display_title(self):
"""Slugs without a `card_title` override fall through to display_title —
for stargazer that's "Stargazer" (don-able title == Note name)."""
self.assertEqual(_note("stargazer").card_title, "Stargazer")

View File

View File

@@ -0,0 +1,35 @@
"""Auto-BYE gamers whose room seat token cost lapsed past the renewal grace.
The lazy `_expire_lapsed_seats` inside the room view / gatekeeper /
gate-view already frees lapsed seats on every access; this command is the
cron backstop for rooms nobody reopens — a mid-game table left idle past
the grace window (filled_at + 2*renewal_period) would otherwise keep its
stuck seats forever. Mirrors `delete_stale_my_sea_draws`. No flags;
idempotent.
Usage:
python manage.py expire_lapsed_room_seats
"""
from django.core.management.base import BaseCommand
from apps.epic.models import GateSlot, Room
from apps.epic.views import _expire_lapsed_seats
class Command(BaseCommand):
help = "Free room seats whose token cost lapsed past the renewal grace."
def handle(self, *args, **options):
rooms = Room.objects.filter(
gate_slots__status=GateSlot.FILLED,
gate_slots__filled_at__isnull=False,
).distinct()
freed = 0
for room in rooms:
before = room.gate_slots.filter(status=GateSlot.FILLED).count()
_expire_lapsed_seats(room)
if room.gate_slots.filter(status=GateSlot.FILLED).count() < before:
freed += 1
self.stdout.write(self.style.SUCCESS(
f"Freed lapsed seats in {freed} room{'s' if freed != 1 else ''}."
))

View File

@@ -0,0 +1,110 @@
"""Wipe the configured database and re-run all migrations from scratch.
Intended for ephemeral environments (staging) where losing every user, room,
billpost, token, etc. is acceptable. Refuses to run when DEBUG=False unless
the operator explicitly confirms with --i-mean-it, and always prints the
DB host before doing anything destructive.
Typical staging usage from the deploy host:
docker exec -it gamearray python manage.py reset_staging_db --i-mean-it
Locally (sqlite, DEBUG=True) the safety prompt is skipped:
python src/manage.py reset_staging_db
"""
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
from django.db import connection
PROD_HOST_FRAGMENTS = ("earthmanrpg.me",)
class Command(BaseCommand):
help = "Drop every table in the default DB and re-run migrations. Destructive."
def add_arguments(self, parser):
parser.add_argument(
"--i-mean-it",
action="store_true",
help="Required when DEBUG=False. Bypasses the interactive confirmation.",
)
parser.add_argument(
"--no-superuser",
action="store_true",
help="Skip the post-migrate ensure_superuser call.",
)
def handle(self, *args, **opts):
db_settings = settings.DATABASES["default"]
engine = db_settings.get("ENGINE", "")
host = db_settings.get("HOST") or db_settings.get("NAME") or "(unknown)"
# Refuse outright if the host name suggests production
if any(frag in str(host) for frag in PROD_HOST_FRAGMENTS) and not host.startswith("staging"):
if "staging" not in str(host):
raise CommandError(
f"Refusing to reset DB at host={host!r} — looks like production. "
"Edit PROD_HOST_FRAGMENTS in this command if you really mean it."
)
self.stdout.write(self.style.WARNING(
f"\nAbout to wipe DB:\n ENGINE: {engine}\n HOST/NAME: {host}\n"
))
if not settings.DEBUG and not opts["i_mean_it"]:
raise CommandError(
"DEBUG=False — pass --i-mean-it to confirm. "
"(This is the staging-safety check; it does not bypass the prod-host refusal above.)"
)
if settings.DEBUG and not opts["i_mean_it"]:
answer = input("Type the DB host/name to confirm: ").strip()
if answer != str(host):
raise CommandError(f"Got {answer!r}, expected {str(host)!r}. Aborting.")
# Drop schema. Postgres + sqlite both honor `flush --no-input`'s
# truncate-tables-in-place model, but for a *fresh* migration run we
# need the migration history wiped too. For Postgres the cleanest
# route is `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` —
# for sqlite, deleting the file is simpler but Django's connection
# has it open. So: introspect the connection and drop all tables.
self.stdout.write("Dropping all tables…")
self._drop_all_tables()
self.stdout.write("Running migrate from scratch…")
call_command("migrate", verbosity=1, interactive=False)
if not opts["no_superuser"]:
try:
call_command("ensure_superuser", verbosity=1)
except Exception as exc: # pragma: no cover - depends on env vars
self.stdout.write(self.style.WARNING(
f"ensure_superuser skipped/failed: {exc}"
))
self.stdout.write(self.style.SUCCESS("\nDB reset complete."))
def _drop_all_tables(self):
vendor = connection.vendor
with connection.cursor() as cursor:
if vendor == "postgresql":
cursor.execute("DROP SCHEMA public CASCADE;")
cursor.execute("CREATE SCHEMA public;")
cursor.execute("GRANT ALL ON SCHEMA public TO public;")
elif vendor == "sqlite":
cursor.execute("PRAGMA foreign_keys = OFF;")
cursor.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)
tables = [row[0] for row in cursor.fetchall()]
for table in tables:
cursor.execute(f'DROP TABLE IF EXISTS "{table}"')
cursor.execute("PRAGMA foreign_keys = ON;")
else:
raise CommandError(
f"reset_staging_db only knows postgresql + sqlite, got {vendor!r}"
)

View File

@@ -0,0 +1,200 @@
"""Collapse of old migrations 00070022 into a single end-state finalize.
Schema:
- mechanisms → energies (was 0008)
- articulations → operations (was 0008)
- reversal → reversal_qualifier (was 0014)
- +italic_word (was 0018)
Data (operates on the Earthman deck seeded in 0004; idempotent against the
current schema, which is the post-rename state thanks to the operations
above running first):
- Middle court reversal_qualifier per suit (was 0007)
- Schizo energies + operations w/ .card-ref (was 0008 + 0009)
- fa-hand-dots fallback for empty MAJOR icons (was 0010; only card 41
in fresh-seed state)
- Card 49 polarity-split reversal titles (was 0015 + 0016)
- Castanedan Virtues: trumps 6-9 + 19-21 (was 0017)
- italic_word for trumps 19-21 (was 0019)
- Trump 8 rename + non-breaking hyphen (was 0020 + 0021)
- Trump 9 non-breaking space (was 0021)
- Pip cards (number 1-10) MIDDLE → MINOR arcana (was 0022)
Skipped (all no-ops against fresh 0004 seed):
- 0011 (nomad/schizo icons already correct in 0004)
- 0012 (no PENTACLES seeded for Earthman in 0004)
- 0013 (nomad icon already fa-hat-cowboy-side in 0004)
"""
from django.db import migrations, models
# ── Schizo energies + operations ─────────────────────────────────────────────
CR = '<span class="card-ref">{}</span>'
SCHIZO_ENERGIES = [
{"type": "LIBIDO", "effect": f'When encountering territorial Libido, may convert Emanation into {CR.format("1. The Priest")}.'},
{"type": "NUMEN", "effect": f'When encountering despotic Numen, may convert Emanation into {CR.format("1. The Powerful")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering axiomatic Voluptas, may convert Emanation into {CR.format("1. The Normal")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering annihilating Voluptas, may convert Emanation into {CR.format("1. The Surrendered")}.'},
]
SCHIZO_OPERATIONS = [
{"type": "COVER", "effect": f'When covering {CR.format("2. The Occultist")} she may choose, by converting her own Reversal into {CR.format("2. Pestilence")}, to convert this Reversal into {CR.format("1. The Pervert")}.'},
{"type": "CROWN", "effect": f'When crowning {CR.format("3. The Despot")} she may choose, by converting her own Reversal into {CR.format("3. War")}, to convert this Reversal into {CR.format("1. The Paranoiac")}.'},
{"type": "BEHIND", "effect": f'When behind {CR.format("4. The Capitalist")} he may choose, by converting his own Reversal into {CR.format("4. Famine")}, to convert this Reversal into {CR.format("1. The Neurotic")}.'},
{"type": "BEFORE", "effect": f'When before {CR.format("5. The Fascist")} he may choose, by converting his own Reversal into {CR.format("5. Death")}, to convert this Reversal into {CR.format("1. The Suicidal")}.'},
]
# ── Middle court suit reversals ──────────────────────────────────────────────
SUIT_REVERSAL_QUALIFIER = {
"BRANDS": "Seething",
"GRAILS": "Gloomy",
"BLADES": "Nervous",
"CROWNS": "Vacant",
}
COURT_NUMBERS = [11, 12, 13, 14]
# ── Castanedan Virtues ───────────────────────────────────────────────────────
IMPLICIT_VIRTUES = [
# (number, levity_qualifier, gravity_qualifier, reversal_title)
(6, "Sublimating", "Sedimentary", "Indulged Folly"),
(7, "Sublimating", "Sedimentary", "Indulgent Doing"),
(8, "Sublimating", "Sedimentary", "Self-Indulgence"),
(9, "Sublimating", "Sedimentary", "Indulging Personal History"),
]
EXPLICIT_VIRTUES = [
# (number, levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word)
(19, "The Hunter's Stalking", "The Hunter's Stalking", "The Sleeper's Stalking", "The Quarry's Stalking", "Stalking"),
(20, "The Dreamer's Dreaming", "The Dreamer's Dreaming", "The Sleeper's Dreaming", "The Dreamed's Dreaming", "Dreaming"),
(21, "The Warrior's Intent", "The Warrior's Intent", "The Sleeper's Intent", "The Predator's Intent", "Intent"),
]
def finalize(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Middle court suit reversals
for suit, qualifier in SUIT_REVERSAL_QUALIFIER.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE", suit=suit,
number__in=COURT_NUMBERS,
).update(reversal_qualifier=qualifier)
# Schizo: clear stray reversal_qualifier (0004 seeds 'Territoriality') +
# populate energies/operations
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(
reversal_qualifier="",
energies=SCHIZO_ENERGIES,
operations=SCHIZO_OPERATIONS,
)
# fa-hand-dots fallback for empty MAJOR icons (number ≥ 2)
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number__gte=2, icon="",
).update(icon="fa-hand-dots")
# Card 49 polarity reversal titles
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(
levity_reversal="The Vibrational Mould of Man",
gravity_reversal="The Bestowing Eagle",
)
# Castanedan Virtues — implicit (trumps 6-9): trump 7 name canonicalize +
# qualifiers + reversal titles
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not-Doing")
for number, lvty, grav, rev in IMPLICIT_VIRTUES:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_qualifier=lvty,
gravity_qualifier=grav,
levity_reversal=rev,
gravity_reversal=rev,
)
# Castanedan Virtues — explicit (trumps 19-21): polarity-split titles +
# italic_word for the agency stem
for number, le, ge, lr, gr, word in EXPLICIT_VIRTUES:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_emanation=le,
gravity_emanation=ge,
levity_reversal=lr,
gravity_reversal=gr,
italic_word=word,
)
# Trump 8: "Losing Self-Importance" → "SelfUnimportance" w/ U+2011
# non-breaking hyphen (keeps title on one line above qualifier)
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=8,
).update(name="SelfUnimportance", slug="self-unimportance")
# Trump 9: insert U+00A0 between "Personal" and "History" so they wrap as
# a single unit ("Erasing / Personal History, / Sublimating")
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=9,
).update(name="Erasing Personal History")
# Pip cards (number 1-10) → MINOR arcana; courts (11-14) stay MIDDLE
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE", number__lte=10,
).update(arcana="MINOR")
def revert(apps, schema_editor):
"""Reverse just enough to restore 0006 schema state. Data reverts to the
raw 0004 seed shape (without any of the post-seed tweaks)."""
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Pip arcana
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", number__lte=10,
).update(arcana="MIDDLE")
# Trump 8 + 9 names back
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=8,
).update(name="Losing Self-Importance", slug="losing-self-importance")
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=9,
).update(name="Erasing Personal History")
# Trump 7 name back
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not Doing")
class Migration(migrations.Migration):
dependencies = [
("epic", "0006_add_deck_variant_to_tableseat"),
]
operations = [
migrations.RenameField("TarotCard", "mechanisms", "energies"),
migrations.RenameField("TarotCard", "articulations", "operations"),
migrations.RenameField("TarotCard", "reversal", "reversal_qualifier"),
migrations.AddField(
model_name="tarotcard",
name="italic_word",
field=models.CharField(blank=True, default="", max_length=50),
),
migrations.RunPython(finalize, reverse_code=revert),
]

View File

@@ -1,73 +0,0 @@
"""Populate TarotCard.reversal for Earthman Middle Arcana court cards.
Each suit has a fixed reversal qualifier that replaces the polarity qualifier
(Elevated/Graven) when the card is spun to its reversed face:
Brands → Seething Grails → Gloomy Blades → Nervous Crowns → Vacant
Also clears the incorrectly inherited reversal on The Schizo (card 1), which
mistakenly carried 'Territoriality' from The Occultist (card 2).
"""
from django.db import migrations
SUIT_REVERSAL_QUALIFIER = {
"BRANDS": "Seething",
"GRAILS": "Gloomy",
"BLADES": "Nervous",
"CROWNS": "Vacant",
}
RANK_NAMES = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
def populate_reversals(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Middle Arcana court cards
for suit, qualifier in SUIT_REVERSAL_QUALIFIER.items():
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MIDDLE",
suit=suit,
number__in=list(RANK_NAMES.keys()),
).update(reversal=qualifier)
# Clear The Schizo's incorrectly inherited reversal (belongs to The Occultist)
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MAJOR",
number=1,
).update(reversal="")
def clear_reversals(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE",
suit__in=list(SUIT_REVERSAL_QUALIFIER.keys()),
number__in=list(RANK_NAMES.keys()),
).update(reversal="")
TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR", number=1).update(
reversal="Territoriality"
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0006_add_deck_variant_to_tableseat"),
]
operations = [
migrations.RunPython(populate_reversals, reverse_code=clear_reversals),
]

View File

@@ -0,0 +1,38 @@
"""Rename the Blades Middle Arcana reversal qualifier "Nervous""Fickle"."""
from django.db import migrations
SUIT = "BLADES"
COURT_NUMBERS = [11, 12, 13, 14]
OLD = "Nervous"
NEW = "Fickle"
def _update(apps, value):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MIDDLE",
suit=SUIT,
number__in=COURT_NUMBERS,
).update(reversal_qualifier=value)
def forward(apps, schema_editor):
_update(apps, NEW)
def backward(apps, schema_editor):
_update(apps, OLD)
class Migration(migrations.Migration):
dependencies = [
("epic", "0007_finalize_earthman_deck"),
]
operations = [migrations.RunPython(forward, backward)]

View File

@@ -1,57 +0,0 @@
"""Rename mechanisms→energies and articulations→operations on TarotCard;
seed The Schizo (Earthman major arcana card 1) with Energy and Operation entries.
"""
from django.db import migrations
CR = '<span class="card-ref">{}</span>'
SCHIZO_ENERGIES = [
{"type": "LIBIDO", "effect": f'When encountering territorial Libido, may convert Emanation into {CR.format("1. The Priest")}.'},
{"type": "NUMEN", "effect": f'When encountering despotic Numen, may convert Emanation into {CR.format("1. The Powerful")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering axiomatic Voluptas, may convert Emanation into {CR.format("1. The Normal")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering annihilating Voluptas, may convert Emanation into {CR.format("1. The Surrendered")}.'},
]
SCHIZO_OPERATIONS = [
{"type": "COVER", "effect": f'When covering {CR.format("2. The Occultist")} she may choose, by converting her own Reversal into {CR.format("2. Pestilence")}, to convert this Reversal into {CR.format("1. The Pervert")}.'},
{"type": "CROWN", "effect": f'When crowning {CR.format("3. The Despot")} she may choose, by converting her own Reversal into {CR.format("3. War")}, to convert this Reversal into {CR.format("1. The Paranoiac")}.'},
{"type": "BEHIND", "effect": f'When behind {CR.format("4. The Capitalist")} he may choose, by converting his own Reversal into {CR.format("4. Famine")}, to convert this Reversal into {CR.format("1. The Neurotic")}.'},
{"type": "BEFORE", "effect": f'When before {CR.format("5. The Fascist")} he may choose, by converting his own Reversal into {CR.format("5. Death")}, to convert this Reversal into {CR.format("1. The Suicidal")}.'},
]
def seed_schizo(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(energies=SCHIZO_ENERGIES, operations=SCHIZO_OPERATIONS)
def clear_schizo(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(energies=[], operations=[])
class Migration(migrations.Migration):
dependencies = [
("epic", "0007_populate_middle_arcana_reversals"),
]
operations = [
migrations.RenameField("TarotCard", "mechanisms", "energies"),
migrations.RenameField("TarotCard", "articulations", "operations"),
migrations.RunPython(seed_schizo, reverse_code=clear_schizo),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-23 18:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0008_blades_reversal_fickle'),
]
operations = [
migrations.AddField(
model_name='tarotcard',
name='reversal_drops_qualifier',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,53 +0,0 @@
"""Re-seed The Schizo's energies and operations with .card-ref HTML spans."""
from django.db import migrations
CR = '<span class="card-ref">{}</span>'
SCHIZO_ENERGIES = [
{"type": "LIBIDO", "effect": f'When encountering territorial Libido, may convert Emanation into {CR.format("1. The Priest")}.'},
{"type": "NUMEN", "effect": f'When encountering despotic Numen, may convert Emanation into {CR.format("1. The Powerful")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering axiomatic Voluptas, may convert Emanation into {CR.format("1. The Normal")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering annihilating Voluptas, may convert Emanation into {CR.format("1. The Surrendered")}.'},
]
SCHIZO_OPERATIONS = [
{"type": "COVER", "effect": f'When covering {CR.format("2. The Occultist")} she may choose, by converting her own Reversal into {CR.format("2. Pestilence")}, to convert this Reversal into {CR.format("1. The Pervert")}.'},
{"type": "CROWN", "effect": f'When crowning {CR.format("3. The Despot")} she may choose, by converting her own Reversal into {CR.format("3. War")}, to convert this Reversal into {CR.format("1. The Paranoiac")}.'},
{"type": "BEHIND", "effect": f'When behind {CR.format("4. The Capitalist")} he may choose, by converting his own Reversal into {CR.format("4. Famine")}, to convert this Reversal into {CR.format("1. The Neurotic")}.'},
{"type": "BEFORE", "effect": f'When before {CR.format("5. The Fascist")} he may choose, by converting his own Reversal into {CR.format("5. Death")}, to convert this Reversal into {CR.format("1. The Suicidal")}.'},
]
def seed_schizo(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(energies=SCHIZO_ENERGIES, operations=SCHIZO_OPERATIONS)
def clear_schizo(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(energies=[], operations=[])
class Migration(migrations.Migration):
dependencies = [
("epic", "0008_rename_energies_operations_seed_schizo"),
]
operations = [
migrations.RunPython(seed_schizo, reverse_code=clear_schizo),
]

View File

@@ -1,49 +0,0 @@
"""Assign fa-hand-dots icon to all Earthman Major Arcana cards with number >= 2.
Cards 0 (The Nomad) and 1 (The Schizo) keep their existing icon value so they
can receive distinct icons later. All other Major Arcana groups (Popes, Implicit
Virtues, Elements, Realms, Explicit Virtues, Zodiac, Lunars, Planets, Inner Rings,
polarity-split finals) default to fa-hand-dots until per-group icons are assigned.
"""
from django.db import migrations
def assign_hand_dots(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MAJOR",
number__gte=2,
icon="",
).update(icon="fa-hand-dots")
def clear_hand_dots(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MAJOR",
number__gte=2,
icon="fa-hand-dots",
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0009_schizo_card_ref_spans"),
]
operations = [
migrations.RunPython(assign_hand_dots, reverse_code=clear_hand_dots),
]

View File

@@ -0,0 +1,37 @@
"""Cards 16-18 (Realms — Disco Inferno / Torre Terrestre / Fantasia Celestia)
have a reversal NAME swap (`reversal_qualifier` field carrying "Shame" /
"Guilt" / "Anxiety") but per user-spec 2026-05-23 the reversal face renders
the name ALONE, with NO polarity qualifier appended. Set
`reversal_drops_qualifier=True` so `TarotCard.applet_face()` knows to drop
the polarity qualifier on the reversal face. See [[feedback-reversal-
qualifier-dual-role]] for the broader Pattern B vs Pattern B' distinction.
"""
from django.db import migrations
REVERSAL_DROPS_QUALIFIER_NUMBERS = [16, 17, 18]
def set_flag(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
TarotCard.objects.filter(
arcana="MAJOR", number__in=REVERSAL_DROPS_QUALIFIER_NUMBERS,
).update(reversal_drops_qualifier=True)
def clear_flag(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
TarotCard.objects.filter(
arcana="MAJOR", number__in=REVERSAL_DROPS_QUALIFIER_NUMBERS,
).update(reversal_drops_qualifier=False)
class Migration(migrations.Migration):
dependencies = [
("epic", "0009_reversal_drops_qualifier"),
]
operations = [
migrations.RunPython(set_flag, reverse_code=clear_flag),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0 on 2026-05-25 03:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0010_set_reversal_drops_qualifier_realms'),
]
operations = [
migrations.AddField(
model_name='deckvariant',
name='family',
field=models.CharField(choices=[('earthman', 'Earthman'), ('italian', 'Italian / Minchiate'), ('english', 'English Tarot'), ('playing', 'Playing card')], default='earthman', max_length=10),
),
migrations.AddField(
model_name='deckvariant',
name='has_card_images',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='deckvariant',
name='is_polarized',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='tarotcard',
name='suit',
field=models.CharField(blank=True, choices=[('BRANDS', 'Brands'), ('CROWNS', 'Crowns'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True),
),
]

View File

@@ -1,43 +0,0 @@
"""Assign individual icons to The Nomad (0) and The Schizo (1).
All other Major Arcana already have fa-hand-dots from migration 0010.
"""
from django.db import migrations
ICONS = {0: 'fa-hat-cowboy', 1: 'fa-hat-wizard'}
def assign_icons(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
for number, icon in ICONS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(icon=icon)
def clear_icons(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number__in=list(ICONS.keys())
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0010_major_arcana_hand_dots_icon"),
]
operations = [
migrations.RunPython(assign_icons, reverse_code=clear_icons),
]

View File

@@ -1,27 +0,0 @@
"""Delete 4 stray PENTACLES court cards from the Earthman deck.
These survived the migration collapse; the Earthman deck uses
BRANDS/GRAILS/BLADES/CROWNS only.
"""
from django.db import migrations
def delete_pentacles(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES").delete()
class Migration(migrations.Migration):
dependencies = [
("epic", "0011_nomad_schizo_icons"),
]
operations = [
migrations.RunPython(delete_pentacles, reverse_code=migrations.RunPython.noop),
]

View File

@@ -0,0 +1,70 @@
"""Sprint A.0 data migration.
The existing `fiorentine-minchiate` DeckVariant is actually 78-card Rider-Waite-Smith
Tarot (22 majors numbered 0-21 with RWS names + 56 minors in WANDS/CUPS/SWORDS/PENTACLES).
Rename to its true identity, set the new schema fields (family / has_card_images /
is_polarized), and revocab card suits to the canonical Earthman vocabulary that
SUIT_CHOICES now requires. Earthman gets its new-field values set too. FKs remain
intact since slug-only changes don't break referential integrity.
The actual Minchiate Fiorentine deck (97 cards, 1860-1890 publication, image set
already imported per [[reference-card-image-naming-convention]]) is seeded in
Sprint A.1's follow-up migration.
"""
from django.db import migrations
SUIT_REVOCAB = {
"WANDS": "BRANDS",
"CUPS": "GRAILS",
"SWORDS": "BLADES",
"PENTACLES": "CROWNS",
}
def forward(apps, schema_editor):
DeckVariant = apps.get_model("epic", "DeckVariant")
TarotCard = apps.get_model("epic", "TarotCard")
# Earthman: set new-field values explicitly (default=True for has_card_images
# would have left it True after the schema migration — wrong for Earthman).
DeckVariant.objects.filter(slug="earthman").update(
family="earthman", has_card_images=False, is_polarized=True,
)
# fiorentine-minchiate → RWS Tarot rename + new-field values.
rws = DeckVariant.objects.filter(slug="fiorentine-minchiate").first()
if rws:
rws.slug = "tarot-rider-waite-smith"
rws.name = "Tarot (Rider-Waite-Smith)"
rws.family = "english"
rws.has_card_images = False
rws.is_polarized = False
rws.save()
# Revocab the 56 minor cards' suits to canonical Earthman vocab.
for old_suit, new_suit in SUIT_REVOCAB.items():
TarotCard.objects.filter(deck_variant=rws, suit=old_suit).update(
suit=new_suit,
)
def backward(apps, schema_editor):
DeckVariant = apps.get_model("epic", "DeckVariant")
TarotCard = apps.get_model("epic", "TarotCard")
rws = DeckVariant.objects.filter(slug="tarot-rider-waite-smith").first()
if rws:
for new_suit, old_suit in {v: k for k, v in SUIT_REVOCAB.items()}.items():
TarotCard.objects.filter(deck_variant=rws, suit=new_suit).update(
suit=old_suit,
)
rws.slug = "fiorentine-minchiate"
rws.name = "Fiorentine Minchiate"
rws.save()
class Migration(migrations.Migration):
dependencies = [
("epic", "0011_deckvariant_family_deckvariant_has_card_images_and_more"),
]
operations = [migrations.RunPython(forward, backward)]

View File

@@ -0,0 +1,137 @@
"""Sprint A.1 — seed the Minchiate Fiorentine (1860-1890) deck.
97 cards: 40 numbered trumps + Il Matto (rank 0, unnumbered Fool) + 56 minors
(4 suits × 14 cards: pip 1-10, page 11, knight 12, queen 13, king 14).
Names are stored in Italian-display form (Italian for trumps; English-rank +
Italian-suit hybrid for minors — "Page of Batons", "King of Coins"). The
canonical SUIT enum stays Earthman vocab (BRANDS/GRAILS/BLADES/CROWNS) per the
2026-05-25 lock; Sprint A.2's `display_suit_name` property handles the
canonical→display translation.
Correspondences cite the RWS Tarot equivalent where one exists (16 trumps map
cleanly; the 5 popes, 4 theological+cardinal virtues, 4 elements, and 12
zodiac trumps don't have RWS parallels — left blank).
Keywords intentionally left empty for now; admin form (Sprint B) will enrich.
"""
from django.db import migrations
TRUMPS = [
# (number, name, slug, correspondence)
(0, "Il Matto", "il-matto", "The Fool"),
(1, "Papa Uno", "papa-uno", ""),
(2, "Papa Due", "papa-due", ""),
(3, "Papa Tre", "papa-tre", ""),
(4, "Papa Quattro", "papa-quattro", ""),
(5, "Papa Cinque", "papa-cinque", ""),
(6, "La Temperanza", "la-temperanza", "Temperance"),
(7, "La Forza", "la-forza", "Strength"),
(8, "La Giustizia", "la-giustizia", "Justice"),
(9, "La Ruota della Fortuna", "la-ruota-della-fortuna", "Wheel of Fortune"),
(10, "Il Carro", "il-carro", "The Chariot"),
(11, "Il Gobbo", "il-gobbo", "The Hermit"),
(12, "L'Impiccato", "l-impiccato", "The Hanged Man"),
(13, "La Morte", "la-morte", "Death"),
(14, "Il Diavolo", "il-diavolo", "The Devil"),
(15, "La Casa del Diavolo", "la-casa-del-diavolo", "The Tower"),
(16, "La Speranza", "la-speranza", ""), # Hope — theological virtue
(17, "La Prudenza", "la-prudenza", ""), # Prudence — cardinal virtue
(18, "La Fede", "la-fede", ""), # Faith
(19, "La Carita", "la-carita", ""), # Charity
(20, "Il Fuoco", "il-fuoco", ""), # Fire — element
(21, "L'Acqua", "l-acqua", ""), # Water
(22, "La Terra", "la-terra", ""), # Earth
(23, "L'Aria", "l-aria", ""), # Air
(24, "La Bilancia", "la-bilancia", ""), # Libra — zodiac
(25, "La Vergine", "la-vergine", ""), # Virgo
(26, "Il Scorpione", "il-scorpione", ""), # Scorpio
(27, "L'Ariete", "l-ariete", ""), # Aries
(28, "Il Capricorno", "il-capricorno", ""), # Capricorn
(29, "Il Sagittario", "il-sagittario", ""), # Sagittarius
(30, "Il Cancro", "il-cancro", ""), # Cancer
(31, "I Pesci", "i-pesci", ""), # Pisces
(32, "L'Acquario", "l-acquario", ""), # Aquarius
(33, "Il Leone", "il-leone", ""), # Leo
(34, "Il Toro", "il-toro", ""), # Taurus
(35, "I Gemelli", "i-gemelli", ""), # Gemini
(36, "La Stella", "la-stella", "The Star"),
(37, "La Luna", "la-luna", "The Moon"),
(38, "Il Sole", "il-sole", "The Sun"),
(39, "Il Mondo", "il-mondo", "The World"),
(40, "Le Trombe", "le-trombe", "Judgement"),
]
# Canonical Earthman suit enum → Italian-family display name (also used as slug stem).
SUIT_DISPLAY = {
"BRANDS": "Batons",
"GRAILS": "Cups",
"BLADES": "Swords",
"CROWNS": "Coins",
}
PIP_NAMES = {1: "Ace", 2: "Two", 3: "Three", 4: "Four", 5: "Five",
6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten"}
COURT_NAMES = {11: "Page", 12: "Knight", 13: "Queen", 14: "King"}
def forward(apps, schema_editor):
DeckVariant = apps.get_model("epic", "DeckVariant")
TarotCard = apps.get_model("epic", "TarotCard")
deck = DeckVariant.objects.create(
name="Minchiate Fiorentine (18601890)",
slug="minchiate-fiorentine-1860-1890",
card_count=97,
is_default=False,
family="italian",
has_card_images=True,
is_polarized=False,
description=(
"97-card Minchiate Fiorentine deck from the Baragioli-era 1860-1890 "
"Florence lithograph series. Five popes, four theological/cardinal "
"virtues, four elements, twelve zodiac signs, plus the standard "
"trump iconography and Il Matto."
),
)
# 41 trumps (incl. Il Matto at rank 0).
for number, name, slug, correspondence in TRUMPS:
TarotCard.objects.create(
deck_variant=deck,
arcana="MAJOR",
suit=None,
number=number,
name=name,
slug=slug,
correspondence=correspondence,
)
# 56 minors: 4 suits × 14 cards.
for canonical_suit, display in SUIT_DISPLAY.items():
for n in range(1, 15):
rank_word = PIP_NAMES.get(n) or COURT_NAMES[n]
TarotCard.objects.create(
deck_variant=deck,
arcana="MINOR",
suit=canonical_suit,
number=n,
name=f"{rank_word} of {display}",
slug=f"{rank_word.lower()}-of-{display.lower()}",
)
def backward(apps, schema_editor):
DeckVariant = apps.get_model("epic", "DeckVariant")
TarotCard = apps.get_model("epic", "TarotCard")
deck = DeckVariant.objects.filter(slug="minchiate-fiorentine-1860-1890").first()
if deck:
TarotCard.objects.filter(deck_variant=deck).delete()
deck.delete()
class Migration(migrations.Migration):
dependencies = [
("epic", "0012_rws_rename_and_suit_revocab"),
]
operations = [migrations.RunPython(forward, backward)]

View File

@@ -0,0 +1,35 @@
"""Flip RWS `has_card_images` to True now that the 79 RWS card-face PNGs are
installed + resized + pngquant'd at `cards-faces/english/rider-waite-smith/`.
Light up the existing image-mode rendering branches (already shipped for
Minchiate) for the RWS deck too — the card face becomes the image; the text
metadata moves to the adjacent stat block; the deck-stack icon uses the
RWS card-back PNG instead of the generic SCSS placeholder rect-fill.
`is_polarized` stays False (set by 0012). RWS was always a monodeck — flip-to-
back FLIP renders the card-back image instead of cycling gravity/levity halves.
The is_default flag stays False (Earthman remains the default-equipped deck for
new users); explicit equip via Game Kit is required to put RWS on the table.
"""
from django.db import migrations
def forward(apps, schema_editor):
DeckVariant = apps.get_model("epic", "DeckVariant")
DeckVariant.objects.filter(slug="tarot-rider-waite-smith").update(
has_card_images=True,
)
def backward(apps, schema_editor):
DeckVariant = apps.get_model("epic", "DeckVariant")
DeckVariant.objects.filter(slug="tarot-rider-waite-smith").update(
has_card_images=False,
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0013_seed_minchiate_fiorentine_1860_1890"),
]
operations = [migrations.RunPython(forward, backward)]

Some files were not shown because too many files have changed in this diff Show More