Commit Graph

323 Commits

Author SHA1 Message Date
Disco DeDisco
cf1965c439 game room title: GAME ROOM ⇄ GAME SCROLL reel on the scroll aperture — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
The h2's second slot becomes a two-word vertical reel: GAME stays put, ROOM rests in view, SCROLL is parked one notch below in the slot's bottom fade. room-scroll.js toggles `.is-scroll` on the h2 from the SAME IntersectionObserver that already watches the table-hex aperture's scroll pane — ROOM slides up & out under the navbar line while SCROLL rises out of the page-aperture gradient (reverses on scroll-up). Table-phase only; the gate phase stays a plain GAME ROOM.

One translateY drives both orientations. Portrait: the word is a short horizontal row in a short slot. Landscape: writing-mode: vertical-rl (inherited from the rotated gutter wordmark) makes the word a tall letter-column, so the same translateY slides it ALONG the wordmark — the user-chosen landscape behaviour for free. Landscape uses a shallower --gr-fade + a letter inset so the space-between end-letters parked at the slot edges aren't dimmed by the dissolve.

Motion is deliberately old & rusty: a single cubic-bezier can overshoot at most once and can't oscillate, so the easing is a CSS linear() curve — stall against the grime, jerk free, clunk PAST the mark, then a damped end-wobble into place. Exposed as --gr-ease / --gr-dur / --gr-fade knobs on .gr-swap.

base.html's letter-splitter now also splits the two .gr-word words; the .gr-swap window ships data-letters-split="1" so the splitter skips it (no 'roomscroll' run). Reel SCSS is scoped to .gr-swap/.gr-word; `> span.gr-swap` ties `> span:last-child` at (0,4,3) and wins on later source order [[feedback-scss-import-order-specificity]].

TRAP: libsass does NOT strip `//` comments INSIDE a CSS custom-property value — they leak into the compiled output, making the linear() (hence the whole `transition` shorthand) invalid-at-computed-value-time, which silently resets to 0s/ease (no animation). Keep every annotation OUTSIDE the linear(). [[feedback-libsass-comment-in-custom-property]]

Reusable .gr-swap seam: my_sea gets GAME SEA → GAME SCROLL via a one-line header swap once its sea-scroll pane is built (deferred — the sea scroll doesn't exist yet).

Tests: 2 ITs (RoomScrollOfEventsTest) — reel markup renders in the table phase, stays plain in the gate phase; 1 FT (test_scroll_swaps_room_title_to_scroll) — scrolling the aperture toggles GAME ROOM ⇄ GAME SCROLL both ways. collectstatic'd room-scroll.js for the FT [[feedback-collectstatic-before-ft]].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:05:00 -04:00
Disco DeDisco
114f0fd0db composer applets: .applet-btn-panel behind the OK btn for contrast on the --duoUser felt — TDD
Giving New Game / New Post the --duoUser green felt made the green OK .btn-confirm hard to see. Wrap it in an .applet-btn-panel — a --priUser fill + faint --terUser border, mirroring .gate-roles-panel (_room.scss) with tighter padding so it stays snug beside the line input — so the button reads clearly against the felt.

Applied in _applet-new-game.html and the shared _form.html (New Post now; room.html's composer inherits it when ported).

Tests: GameboardViewTest.test_new_game_ok_button_in_applet_btn_panel (OK btn inside #id_applet_new_game .applet-btn-panel); NewPostTest.test_new_post_applet_has_ok_confirm_button extended to assert the panel wrap. The New Game OK btn stays clickable inside the panel (GatekeeperTest.test_founder_creates_room_and_sees_gatekeeper FT green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:15:11 -04:00
Disco DeDisco
22fc38b92b billboard applets view: stop the top-fade mask clipping the first applet
The billboard applets view (.billboard-page) was a plain page-level scroller, unlike .gameboard-page / .dashboard-page (flex columns whose applet grid fills the aperture and scrolls internally). So #id_billboard_applets_container (flex:1, from %applets-grid) had no flex parent — it grew to its full content height, and the shared %applets-grid `transparent 0% -> black 2%` top-fade became 2% of that tall box, fading away the top of the first applet (newly visible now that New Post carries the --duoUser felt).

Fix — match the other two boards:
- .billboard-page: display:flex; flex-direction:column; overflow:hidden (overrides %billboard-page-base's overflow-y:auto).
- #id_billboard_applets_wrapper (the extra hx-swap wrapper the other boards don't have): display:flex; flex-direction:column; flex:1; min-height:0 — passes the flex height through so the grid is aperture-constrained with an internal scroll. Its top-fade is now the same small fade as gameboard/dashboard.

Verified: BillboardAppletsTest.test_billboard_shows_three_applets + test_toggling_applets_keeps_content_and_persists_per_applet green (the pair-run flake was the known Selenium memory-pressure NoSuchWindow, not a regression — both pass in isolation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:39:35 -04:00
Disco DeDisco
430c3bf141 composer applets: --duoUser felt + masked h2 (matching My Sky / My Sea / My Sign)
The New Game and New Post composer applets (#id_applet_new_game / #id_applet_new_post) now take the same treatment as the My Sky / My Sea / My Sign applets: a --duoUser green-felt content bg with the rotated >h2 title bar masked back to the default dark.

The h2 mask is an opaque --priUser base under the same two 0,0,0/0.125 overlays the %applet-box + its >h2 give every normal applet — so the title bars read identically dark instead of green-tinted, and it stays palette-correct on *-light palettes (no flat 0,0,0). The same mask was applied to the three #id_applet_my_* shells, whose h2 had been left as pure --priUser (lighter than the other applets).

Composer line-inputs (#id_new_game_name / #id_new_post_text / #id_post_line_text) keep a --priUser fill + faint --secUser placeholder + 700 weight + --terUser focus, standing out against the felt like My Sky's form fields.

Bundled: palette felt retunes (rootvars --terFor / --terPer).

[[project-room-scroll-of-events]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:24:42 -04:00
Disco DeDisco
91f48384ff post composer: OK btn + orientation-aware right clamp; #id_post_line_text styling — TDD
The Billpost composer (#id_post_line_text) now matches the other composers and prepares the pattern for room.html.

1. OK .btn-confirm (#id_post_line_btn) added to the editable post-line form, in a flex .composer-row beside the input (read-only system Posts — note_unlock / tax_ledger / mail_acceptance — invite no response, so get no OK). #id_post_line_text also joins the composer-input styling selector in _applets.scss (700 weight + duoUser fill + terUser focus + priUser placeholder).

2. Orientation-aware right clamp on .post-line-form so the OK btn clears the bottom-right corner button:
   - portrait: margin-right 3.5rem — clears the gear (.post-page > .gear-btn, right:0.5rem, 3rem wide → 3.5rem slot).
   - landscape: margin-right 7.2rem — clears the burger slot (#id_burger_btn lands at right:4.2rem, 3rem wide → its left edge sits 7.2rem from the viewport's right edge; this also clears the bud at right:0.5rem). post.html carries no burger (room.html will) — the slot is reserved for parity.
   .composer-row is flex (input flex:1 + OK); the read-only input keeps width:100%.

Bundled: rootvars --terPer felt retune (82,71,138).

Tests: PostViewTest ITs (editable post renders the OK .btn-confirm in .composer-row; read-only system post does not); functional_tests/test_bill_post_composer FTs (portrait OK right edge <= gear left edge; landscape OK right edge >= 7.2rem in from the viewport edge). 12 PostViewTest ITs + 442 dashboard/billboard ITs + 2 composer FTs + test_bill_new_post FT (ENTER-submit through the composer-row) green.

[[project-room-scroll-of-events]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:06:51 -04:00
Disco DeDisco
d66d898f4d light palettes: --duoUser = --undUser felt so input/aperture fills blend with the page
On *-light palettes the dark-olive --duoUser felt (terFor) read too heavy as an input / aperture fill against the light page. The light-palette override block (body[class*="-light"]) now sets --duoUser: var(--undUser), so every --duoUser surface — e.g. oblivion-light's New Game / New Post composer inputs — uses the lighter forest felt and blends in. Overrides the :root --duoUser for every *-light palette (body[class*="-light"] out-specifies :root).

Composer polish (_applets.scss): the New Game / New Post line inputs gain an explicit --duoUser background fill + a --priUser placeholder colour, alongside the 700 weight + terUser focus shift already added. Forest felt (priFor) retuned in rootvars to suit the lighter light-palette fill.

[[project-room-scroll-of-events]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:47:12 -04:00
Disco DeDisco
39a2a49d7e New Post applet: kill #id_text remnant, add OK btn, bolden composer inputs — TDD
Prep for porting a post composer into room.html. Three changes on the New Game / New Post applets:

1. Renamed the old Percival #id_text → #id_new_post_text. _form.html (the shared post composer) is now parameterized on input_id (default id_new_post_text) + submit_id (default id_new_post_btn); the feedback div + aria-describedby track {{ input_id }}_feedback. _applet-new-post.html includes it with input_id="id_new_post_text". room.html will reuse the same partial with input_id="id_room_post_text". Refs updated: _scripts.html (initialize("#id_new_post_text")), Jasmine Spec.js, FT post_page.py. _form.html has exactly one includer.

2. Added an OK .btn-confirm submit to _form.html (flex .composer-row: line input + OK, validation feedback below), mirroring the New Game applet. ENTER still submits, so the existing add_post_line FTs stay green.

3. Composer-input styling in _applets.scss: #id_new_game_name, #id_new_post_text { font-weight: 700; &:focus { color: --terUser } } — mirrors .sky-form-col input. The duoUser fill / secUser default text / terUser border+glow on focus already come from .form-control; this adds the 700 weight + the focus text-colour shift. room's #id_room_post_text joins this selector list when it lands.

Tests: NewPostTest ITs (conventional id + aria-describedby, OK .btn-confirm). 440 dashboard+billboard ITs green; Jasmine spec green with the renamed id; test_bill_new_post FT green (renamed input + OK btn, ENTER submit).

[[project-room-scroll-of-events]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:37:52 -04:00
Disco DeDisco
4a4e60f668 room scroll-of-events: applet-box card styling + scroll-driven gear menu (Frame/Redact filter) — TDD
The room scroll pane now matches scroll.html: the feed sits in a .applet-scroll %applet-box card with the rotated room-name title; dropped the special --duoUser pane bg (the dark card sits on the room-page bg).

Gear menu is now view-aware. #id_room_menu carries two panes: .room-menu-default (the existing NVM/DEL/BYE) + .room-menu-scroll (a Frame/Redact #id_scroll_filter_form, rendered only when the gear include gets scroll_filter — room.html passes scroll_filter=room.table_status). room-scroll.js (NEW) runs an IntersectionObserver on .room-scroll-pane (root=#id_room_aperture): scrolled to the feed -> show the filter pane; back on the hex -> show the default. The filter mirrors scroll.html (per-room localStorage, toggles .drama-event[data-label] display). Buffer-dots animation moved from the inline partial script into room-scroll.js.

Other views keep their own menus, as asked: GATE VIEW (room_gate.html) includes _room_gear.html with nvm_url only (no scroll_filter, no room-scroll.js) -> NVM(->hex)/DEL/BYE; the cross/spread phase is a modal over the hex (scrollTop 0) -> default pane.

Traps: applets.js caches gear.dataset.menuTarget at bind time, so you can't swap a gear's target to a 2nd menu — both panes live in ONE #id_room_menu and JS toggles visibility. .room-menu-default is display:contents so wrapping the existing controls doesn't change their layout (JS toggles none<->contents, not '').

Tests: +3 ITs (RoomScrollOfEventsTest — .applet-scroll card + room-name title, filter pane renders in table phase, filter absent in gate phase); +2 FTs (test_game_room_scroll — gear swaps to filter when scrolled to feed, unchecking Redact+OK hides struck rows). 8 scroll ITs + 4 scroll FTs green; 554 epic ITs/UTs green; gatekeeper DEL+BYE gear FTs green (the .room-menu-default wrap is layout-neutral).

[[project-room-scroll-of-events]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:03:48 -04:00
Disco DeDisco
a4adf9664b room scroll-of-events: table-hex aperture binary scroll-snaps to the room provenance feed — TDD
From Role Select onwards, scrolling DOWN in the table-hex aperture swaps the entire hex view for the room's GameEvent feed (mirrors my_sky's wheel<->form; scroll-snap-stop:always = no partial scroll). Reuses the Billscroll query + core/_partials/_scroll.html so the feed renders identically.

room.html: #id_room_aperture wraps .room-hex-pane (existing .room-shell + the _table_positions strip moved INSIDE so the circles scroll away with the hex) + .room-scroll-pane (includes new _room_scroll.html); 'is-scrollable' added iff table_status set.

_room_scroll.html (new) = the DRY seam for my_sea; includes the shared scroll partial + a tiny dots-animation script (no scroll-position persistence). room_view adds events/viewer/scroll_position (same query as billboard.views.scroll).

_room.scss: .room-aperture + .room-pane (height:100%, not min-height); .is-scrollable engages scroll-snap-type:y mandatory + per-pane scroll-snap-align:start & scroll-snap-stop:always; .room-scroll-pane styles #id_drama_scroll + .scroll-buffer { margin-top:auto } (pure-CSS bottom-pin).

Trap: the aperture & panes set NO z-index/transform/opacity/filter -> NO stacking context, so the position strip's z-130 still resolves in the root context, above the gate/sig overlays (z-100/120). Verified by gatekeeper FTs (token drop + circle/modal layering).

Deferred INDEFINITELY (user): rising-game-cost + max room membership + max simultaneous CARTE slots + in-slot token combinations — until CARTE is anything but a secret type of Trinket.

Tests: RoomScrollOfEventsTest (5 ITs — aperture wraps hex+scroll panes, is-scrollable from Role Select, feed renders + scoped to room, no scroll pane in gate phase); functional_tests/test_game_room_scroll.py (2 FTs — computed scroll-snap props + scroll-down reveals the feed). 551 epic ITs/UTs green; 2 new FTs green; gatekeeper (token-drop + circle layering) + role-select (card fan) FTs green.

[[project-room-scroll-of-events]] [[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:32:57 -04:00
Disco DeDisco
5229b9f96a slot/token tooltips: 'expires <relative>' (lowercase, .row-ts timescale) + per-slot token_cost on GateSlot + '+ <Token>' deposited-token list — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Three pieces of housekeeping on the token tooltips:

1. Expiry format. relative_ts is now distance-based (abs gap from now), so it formats FUTURE expiries with the same timescale rules it already uses for past .row-ts timestamps (<24h time, <7d weekday, <1y 'dd Mon', else +year) — past behaviour unchanged (abs is a no-op for past). The FREE-token wallet tooltip (Token.tooltip_expiry) and the position-circle .tt-expiry both read 'expires <when>' (lowercase, no majuscule); the position tooltip is server-formatted via the filter (JS just copies the attr — no JS date logic).

2. Per-slot token count moved off the user's CARTE token onto the slot model. New GateSlot.token_cost (PositiveSmallIntegerField default 1) — the per-seat expenditure count. _gate_positions reads slot.token_cost instead of the CARTE Token.slots_claimed high-water mark, which wrongly showed '6' on every CARTE-covered seat. Every slot now reads 1 (a CARTE covers each seat at cost 1, like any token); the field only rises above 1 when the rising-game-cost feature lands.

3. Per-slot deposited-token list. Under the '<n> Token(s) deposited' header the tooltip now lists a '+ <Token name>' bulleted <ul> — one entry today (a slot ejects its token on any re-deposit, so combinations aren't yet possible). Derived from the slot's debited_token_type (e.g. 'carte' -> 'Carte Blanche', 'Free' -> 'Free Token'); a CARTE across all six seats shows '+ Carte Blanche' on each. token_types is a list, future-ready for token combinations + elevated per-slot cost.

Rising-game-cost is NOT built (recon-confirmed), so the per-slot count is always 1 and the 2-token-slot FT is intentionally skipped per user.

Tests: relative_ts future-date unit tests; FreeTokenTooltipTest rewritten for the relative format (real datetime, no MagicMock/strftime); wallet FT + the two CARTE token-count tests updated to per-slot semantics (1 + 'Carte Blanche'); FREE-slot IT asserts the token-list + 'expires '. Full suite 1606 green; 11 position-tooltip FTs + wallet tooltip FT green.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:39:15 -04:00
Disco DeDisco
cbc4f4f323 position tooltips: titles read 'the Earthman' (article) + occupied gatekeeper circles now produce tooltips; FT flow dismisses the gameboard Brief — TDD
Three follow-ups from manual review:

1. Title article: the tooltip .tt-description (position circles + My Buds) prepends 'the ' so it reads 'the Earthman', matching the established '@handle the <Title>' convention + the visible bud-row. Touches _table_positions.html, _my_buds_item.html, and the async add-bud row builder in _bud_add_panel.html (was building data-tt-description without the article, diverging from the server-rendered rows). active_title_display always returns a value so the article is always well-formed. Tests updated: epic + billboard IT (data-tt-description='the Earthman'), the two My Buds FTs.

2. Initial-gatekeeper tooltips: under the gatekeeper .gate-backdrop, .position-strip circles are pointer-events:none, so mouseenter only reached a .gate-slot that contained a pointer-events:auto descendant (an OK/NVM/drop button). An occupied circle WITHOUT such a button never fired its tooltip. Re-enable .gate-slot.filled/.reserved (occupied, hoverable, no click action of their own) at (0,4,1) > the (0,3,1) suppressor; empty circles stay suppressed. room_gate already handled via .room-gate-page; Role Select covered by the same .role-select-backdrop variant.

3. FT flow: the @taxman 'Debits & credits' ledger Brief renders over the gameboard top and intercepts id_create_game_btn. Added dismiss_brief_if_present() before the create-game click in the gatekeeper + select_role FTs (the trinket FTs already carry their own dismisses).

Verified: 11 position-tooltip FTs, gatekeeper drop, both My Buds FTs, select_role create-game FT all green; full IT/UT suite 1604 green. The gatekeeper-circle pointer-events couldn't be FT-asserted reliably (synthetic mouseenter bypasses pointer-events; real-hover is flaky) — verified via the compiled-CSS cascade + the drop FT confirming the OK button still clicks.

[[project-position-circle-tooltips]] [[feedback-dismiss-brief-ft-helper]] [[feedback-scss-import-order-specificity]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:45:18 -04:00
Disco DeDisco
5a39746853 position-circle tooltips: adversarial-review fixes — drop email leak, hide-on-hover-transition, surface #tokens, room_gate tooltip-only, N+1 hoist + specificity hardening — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Follow-up to the position-circle tooltips sprint, addressing confirmed findings from a multi-agent adversarial review of the diff:

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

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

[[project-position-circle-tooltips]]

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

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

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

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

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

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

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

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

[[project-position-circle-tooltips]]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:30:21 -04:00