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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
`_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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>