Commit Graph

751 Commits

Author SHA1 Message Date
Disco DeDisco
5a39746853 position-circle tooltips: adversarial-review fixes — drop email leak, hide-on-hover-transition, surface #tokens, room_gate tooltip-only, N+1 hoist + specificity hardening — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Follow-up to the position-circle tooltips sprint, addressing confirmed findings from a multi-agent adversarial review of the diff:

- Email leak (privacy): the hidden .slot-gamer span rendered the raw login email into DOM source on every filled circle — widened to room_gate this sprint. Now renders {{ gamer|at_handle }}; new IT asserts no occupant email anywhere in the page source.
- Stale hover state (position-tooltip.js): moving circle→circle accumulated .tt-pos-* classes on the portal (prior set never stripped), and circle→empty left the prior tooltip stranded. Now _hide() before _show() on every transition.
- Dead #tokens plumbing: data-tt-tokens was computed + rendered but never displayed. Surfaced as a .tt-tokens line in the portal.
- room_gate gather forms: the merged _gate_context let a CARTE owner drop/release gate slots from the renewal gate-view. Zeroed carte_next/nvm/is_last_slot so it's tooltip-only; new IT asserts no drop/release forms.
- N+1: hoisted the per-CARTE-slot token lookup into one carte_claims map; added select_related(significator) on seats + select_related(gamer) on gate_slots.
- SIG_SELECT seat override now gated on an EXPLICIT ?seat (no-param falls back to the canonical PC seat, not the lowest gate slot, so every SIG_SELECT surface agrees).
- Dropped dead is_self/is_bud dict keys (kept the locals + is_me_also).
- room-gate pointer-events override doubled to .room-gate-page.room-gate-page → (0,4,1), no longer a source-order tie with the (0,3,1) suppressor.

Tests: 11 position-tooltip FTs green (no skips); +2 ITs (no-email-in-source, room_gate-tooltip-only); full suite 1604 green. Deferred (noted in memory): in-UI seat switcher during SIG_SELECT, NVM-between-seats 409, gate_status/sea_partial enrichment split.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:10:00 -04:00
Disco DeDisco
58280c63f5 my-sea spread: correct CROSS reversal direction — nonreversed (Emanation) faces right, Reversal faces left (swap of 5b6a1be)
Follow-up to 5b6a1be: the rotation values were inverted relative to intent. Corrected per user: the upright/Emanation crossing card points top-RIGHT (90°) and the reversed/Reversal card points top-LEFT (270°), 180° apart. The specificity fix from 5b6a1be stays (the reversed rule is chained to (0,3,0) so it wins the cascade over the (0,2,0) base cross rule — the original equal-specificity tie was why every cross card rendered right and reversal never showed). Pure CSS; modal reversal untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:07:37 -04:00
Disco DeDisco
5b6a1be347 my-sea spread: reversed CROSS card finally reads as reversed — upright cross top points left (270°), reversed points right (90°), 180° apart — TDD-adjacent CSS fix
The Celtic-Cross crossing card is rotated to lie landscape. Two equal-specificity rules set its rotation: `.sea-pos-cross .sea-card-slot` (upright) and `.sea-pos-cross .sea-card-slot--reversed` (reversed) — both (0,2,0). The upright rule sits LATER in source, so it won the cascade for reversed cross cards too: every crossing card rendered at 90° (top-right) and a reversed card never indicated reversal in the spread (the modal was fine). The inline comment claiming the reversed rule had 'higher specificity' was simply wrong.

Fix (user-spec: reversed cross top points rightward): keep the reversed card at 90° (top-right) and flip the UPRIGHT to 270° (top-left), so the two read 180° apart. The reversed rule is re-specified as `.sea-pos-cross .sea-card-slot.sea-card-slot--reversed` (0,3,0) so it genuinely out-specifies the base rule and wins regardless of source order. [[feedback-scss-import-order-specificity]]

Pure CSS in _card-deck.scss; covers both live draws (_fillSlot) and saved hands (_my_sea_slot.html), which share the .sea-card-slot--reversed/.sea-pos-cross classes. Modal reversal (.stage-card--reversed, 180°) untouched. No tests pin the rotation degrees. Verified in the compiled output.css cascade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:59:06 -04:00
Disco DeDisco
efbf98ecf2 per-seat sig for CARTE: ?seat targets the seat + a solo polarity group commits the sig on reserve — multi-gamer reserve→ready→countdown→confirm untouched — TDD
Workstream C of the position-circle tooltips sprint (sprint complete).

Per-seat (not per-gamer) significators for a CARTE solo owner, WITHOUT flipping the SigReservation (room,gamer) unique constraint (which the recon confirmed would break 25+ multi-gamer sig tests + the channels flow).

- sig_reserve resolves the active seat from ?seat=N (carried on the reserve URL) when the viewer owns it, else _canonical_user_seat — so the hold/commit targets the SELECTED seat.
- When the polarity group is SOLO-owned (the viewer owns every PC/NC/SC or BC/EC/AC seat — a CARTE table), reserving commits seat.significator immediately: the 3-ready countdown can never complete solo. The committed sig persists through a NVM release (which only deletes the provisional row), so the viewer reserves each seat in turn. Advances to SKY_SELECT once every seat has a sig (mirrors sig_confirm's tail). Strictly gated to the solo case — a multi-gamer polarity group still rides the existing countdown contract untouched.
- _role_select_context SIG_SELECT branch: overrides user_seat by ?seat (owned) so the overlay reflects the selected seat's role/polarity/deck, and carries ?seat on sig_reserve_url.
- FT refined to the real reserve mechanic (in-card OK btn → .sig-reserved) — the RED spec's .sig-card.reserved / single-body-click was a placeholder; behavior verified is unchanged (per-seat sig persistence).

Tests: CarteSeatSwitchTest.test_carte_saves_a_significator_per_seat FT green (all 4 CarteSeatSwitch + 7 PositionTooltip now green, no skips); 2 solo-CARTE sig ITs (SigReserveSoloCarteTest); full suite 1602 green; channels consumer tests 5 green (WS broadcast intact); multi-gamer SigReserveViewTest/sig_ready/sig_confirm untouched.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:41:51 -04:00
Disco DeDisco
d190b37149 room ?seat=N seat-switch: CARTE gamer previews an owned seat's card-stack — data-active-slot + ineligible .fa-ban when it's not the table's turn — TDD
Workstream B of the position-circle tooltips sprint.

_role_select_context consumes ?seat=N (threaded from room_view): a multi-seat (CARTE) gamer previews a specific owned seat. The card-stack's active slot becomes that seat; it stays 'eligible' only when the previewed seat is also the table's current turn (lowest unassigned seat), else it renders the ineligible .fa-ban. Strictly additive — a one-seat gamer never passes ?seat, and an unowned/garbage param is ignored, so the normal role-select flow (RoleSelectRenderingTest) is untouched.

Tests: CarteSeatSwitchTest.test_switching_seat_loads_that_seats_role_view FT green; 2 new render ITs (seat-param-previews + no-seat-keeps-canonical); RoleSelectRenderingTest still green.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:29:08 -04:00
Disco DeDisco
30246cc94a position circles: rich .tt-pos-* hover tooltips on the gate-view + table circles — @handle/title/seat-sig/shoptalk/#tokens/expiry portal + CARTE me-also ?seat switch href — TDD
Workstream A of the position-circle tooltips sprint (green; B/C ride @skip-ped).

The numbered gate-position circles (1-6) gain rich hover tooltips mirroring the My Buds bud tooltip on every surface — and now render on room_gate.html (the GATE VIEW), which showed no circles before (the headline gap).

- _gate_positions(room, user, current_slot): per-circle .tt-pos-* state class (empty / gamer / gamer+bud / me-current / me-also) + data-tt-* payload (@handle via at_handle NOT email, title, seat significator rank/suit, bud shoptalk, deposited #tokens [CARTE slots_claimed else 1], seat-clock cost_current_until expiry). _viewer_current_slot resolves the viewer's acting seat (?seat override or canonical) to split me-current vs me-also.
- room_gate view merges _gate_context so _table_positions renders there; room_view threads ?seat into _role_select_context.
- _table_positions.html: .tt-pos-* appended AFTER role-assigned (keeps the 'gate-slot filled role-assigned' substring + class-before-data-slot regex intact for RoleSelectRenderingTest), data-tt-* attrs, me-also ?seat switch anchor.
- #id_position_tooltip_portal (page-root, position:fixed) + position-tooltip.js (hover/clamp/union-hide modeled on tray-tooltip.js); .tt-sign rank+suit stack; .tt-pos-* circle accents; room-gate pointer-events re-enable.

Tests: 7 PositionTooltipTest + 2 CarteSeatSwitchTest (tokens, me-also href) FTs green; 8 fast render-level ITs (PositionTooltip{,Carte}RenderTest); full suite 1598 green.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:13:09 -04:00
Disco DeDisco
19471662ff position-circle tooltips: red FT spec (skipped) — gate-position circles get rich .tt-pos-* tooltips + CARTE seat-switch + per-seat SIG — TDD
Outer-loop FTs authored today; implementation lands tomorrow. Both
classes are @skip-ped so the red spec rides into the repo without
breaking the FT CI stage (we just rescued that pipeline); tomorrow's
work removes the skip per-method as each behavior goes green.

Spec encoded (user-spec 2026-06-01):
- gate-position circles (1–6) gain rich hover tooltips mirroring the My
  Buds bud tooltip, on EVERY surface — initial gatekeeper, above the hex,
  AND the new GATE VIEW gate-view (room_gate.html renders no circles
  today: the headline red)
- tooltip: @handle (.tt-title), title (.tt-description), NO email, a
  top-right .tt-sign stack of the SEAT significator (TableSeat.
  significator — per-seat, user-decided), bud shoptalk when the occupant
  is a bud, # tokens deposited (CARTE slots_claimed else 1), .tt-expiry
  (GateSlot.cost_current_until)
- state classes: .tt-pos-empty / .tt-pos-gamer / .tt-pos-gamer.tt-pos-bud
  / .tt-pos-me-current / .tt-pos-me-also (renamed from -me-other per
  user). .tt-pos-me-also carries a ?seat=<n> switch href to load that
  seat's view (preview pos-4 ROLE state w. the .fa-ban atop the deck, or
  SAVE SIG per seat during Sig Select)
- per-seat SIG: today SigReservation is per-(room,gamer) — the FT pins
  per-SEAT sig so a CARTE gamer picks a different sig per seat (tomorrow's
  green = SigReservation rework)

FTs: PositionTooltipTest (8 — circle render on gate-view, me-current /
gamer / bud+shoptalk / no-email / tokens+expiry / seat-sign / hover-
portal) + CarteSeatSwitchTest (4 — me-also switch href, carte token
count, ?seat= loads seat ROLE view, per-seat sig). game_room bucket.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:04:25 -04:00
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