Commit Graph

734 Commits

Author SHA1 Message Date
Disco DeDisco
d8377b57bc my-sea cards: fix rotated significator/cross blur — drop the 4-shadow contour chain on rotated image cards, keep depth — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
The image-card contour stroke is 4 chained `drop-shadow()`s; each re-rasterizes
the already-downscaled card (408→~64px), so on a ROTATED card the compounded
re-sampling reads as BLUR. It's NOT the angle — even the 5° significator blurs,
while the 90° cross blurs hardest; the upright COVER (same filter, no rotation)
+ the unrotated preview modal stay crisp. Verified live (dpr 1): a lone depth
drop-shadow on a rotated card is crisp, the 4-shadow chain is not.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

311 gameboard ITs + 7 spectate channels green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## The handoff chain

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

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

## SCSS

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

## Server side

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

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

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

## JS state machine

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

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

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

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

## Tests (4 new ITs)

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

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

## Verification

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

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

## test_bud_active_fades_kit_btn (test_core_bud_btn.py)

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

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

## test_carte_blanche_equip_and_multi_slot_gatekeeper (test_trinket_carte_blanche.py)

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

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

## Verification

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

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

## Spread modal (#id_sea_spread_modal)

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

## Modal SCSS

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

## Modal JS

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

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

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

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

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

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

## Tests

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

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

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

## Verification

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

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

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

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

## Template (_partials/_burger.html)

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

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

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

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

## Verification

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

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

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

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

## templates/apps/gameboard/my_sea.html

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

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

## templates/apps/gameboard/my_sea_gate.html

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

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

## Tests

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

## Verification

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

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

## templates/apps/gameboard/my_sea.html

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

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

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

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

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

## Verification

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

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

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

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

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

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

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

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

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

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

## Tests

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

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

## Verification

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

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

## my_sea_gate.html burger

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

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

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

## Burger fade rule reinstated

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

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

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

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

## _bud_apparatus.html shared shell refactor

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

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

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

## CI #344 tray-anchor regression fix

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

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

## Verification

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

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

## Burger z-index drop

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

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

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

## FT base — dismiss_brief_if_present()

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

## FT — test_trinket_coin_on_a_string.py

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

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

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

## Verification

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

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

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

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

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

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

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

## Universal landscape btn refactor

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

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

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

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

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

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

## kit_bag_dialog vertical bar in landscape

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

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

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

## Tests

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

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

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

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:40:33 -04:00