Commit Graph

722 Commits

Author SHA1 Message Date
Disco DeDisco
a85f5b6f44 my-sea slot: render the 5+-char numeral squeeze (--rank-long) server-side so it survives a refresh — TDD
sea.js's _fillSlot adds .sea-card-slot--rank-long on draw (corner_rank length
>= 5) to squeeze long Roman numerals (XVIII, XLVIII, ...) into the slot, but
_my_sea_slot.html didn't — so a saved hand stretched the numeral back out on
refresh (server render). Add the same length>=5 class server-side. Fixes both
the owner picker + the spectator cross (shared partial). +3 ITs (long / short /
boundary).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## The handoff chain

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

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

## SCSS

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

## Server side

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

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

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

## JS state machine

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

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

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

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

## Tests (4 new ITs)

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

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

## Verification

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

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

## test_bud_active_fades_kit_btn (test_core_bud_btn.py)

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

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

## test_carte_blanche_equip_and_multi_slot_gatekeeper (test_trinket_carte_blanche.py)

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

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

## Verification

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

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

## Spread modal (#id_sea_spread_modal)

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

## Modal SCSS

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

## Modal JS

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

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

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

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

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

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

## Tests

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

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

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

## Verification

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

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

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

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

## Template (_partials/_burger.html)

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

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

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

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

## Verification

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

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

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

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

## templates/apps/gameboard/my_sea.html

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

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

## templates/apps/gameboard/my_sea_gate.html

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

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

## Tests

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

## Verification

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

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

## templates/apps/gameboard/my_sea.html

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

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

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

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

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

## Verification

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

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

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

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

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

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

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

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

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

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

## Tests

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

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

## Verification

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

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

## my_sea_gate.html burger

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

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

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

## Burger fade rule reinstated

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

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

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

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

## _bud_apparatus.html shared shell refactor

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

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

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

## CI #344 tray-anchor regression fix

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

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

## Verification

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

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

## Burger z-index drop

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

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

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

## FT base — dismiss_brief_if_present()

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

## FT — test_trinket_coin_on_a_string.py

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

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

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

## Verification

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

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

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

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

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

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

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

## Universal landscape btn refactor

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

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

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

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

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

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

## kit_bag_dialog vertical bar in landscape

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

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

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

## Tests

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Changes mirror sea_stage exactly:

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

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

All 25 affected tests green; Jasmine FT green.

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

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

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

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

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

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

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

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

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

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

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

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

Tests:

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

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

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

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

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

Pending follow-up (recorded for next sprint):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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