The GATE VIEW buttons (navbar + room.html lapsed-cost) linked to room_gate
with no ?seat, so _viewer_current_slot fell back to owned[0] (pos 1). On the
gate view a CARTE multi-seat gamer acting at pos 2+ saw pos 1 wrongly flagged
me-current AND href-less — the one circle you could never switch back to.
Fix (option a): _role_select_context + _gate_context now both expose
current_slot; both GATE VIEW buttons append ?seat={{ current_slot }} on
page-room. The gate view's current now matches the table → pos 1 becomes
me-also (switch href) when acting elsewhere, the occupied seat correctly
carries no href. _gate_context computes current_slot once and reuses it for
gate_positions.
3 ITs in CarteTrayFollowsSelectedSeatTest (button carries acting seat;
default targets lowest owned; gate page re-carries seat + pos 1 is me-also).
911 epic+gameboard ITs green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A multi-seat (CARTE) gamer clicking a pos-circle switched the sig overlay
(via the ?seat=N override) but the tray stayed pinned to the role-canonical
PC seat — my_tray_role read assigned_seats[0] (role-sorted, always PC) and
my_tray_sig read _canonical_user_seat. So every circle put the PC icon in
the tray regardless of which one was clicked.
Re-point both tray keys to the seat occupying current_slot (the acting
seat). Single-seat gamers are unaffected — their lone slot IS current_slot,
so selected_seat == their canonical seat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RoomScrollLiveRefreshTest could never pass: ChannelsLiveServerTestCase runs
daphne in a SEPARATE PROCESS, and the test settings force the in-memory channel
layer, so a group_send issued from the test process (via record() → on_commit)
can't reach the consumer in the daphne process — the scroll_update nudge is
undeliverable no matter how correct the feature is. (Probed it: WS open, the
scroll-status endpoint returns the new row, but the browser logs 0 nudges.)
Production uses Redis, which is cross-process, so the live refresh works there.
Every link is already covered by ITs, so the E2E hop is the only gap:
record() schedules the broadcast on_commit (test_record_broadcasts_scroll_update_
on_commit), RoomConsumer relays it (test_receives_scroll_update_broadcast), and
the re-fetched feed partial renders the latest events (ScrollStatusViewTest).
Replaced the FT with a NOTE documenting why there's no FT + where the coverage
lives. Unblocks the channels CI stage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The qualifier (Elevated/Enlightened/Graven) now renders on the frozen stat-block
stat-face, not just the hover-only text card-face (which image-mode decks hide).
populateStatExtras takes opts.polarity and fills new .stat-face-qualifier--above
/--below slots, mirroring the card-face placement: non-major above the title,
major below it (title gets a trailing comma). Qualifier shares the title's style
per request ("same style as 'Jack of Crowns' below it").
The 3 theme FTs were asserting the qualifier on hover, but the stat-block is
display:none until a card is OK'd (.sig-stage--frozen) — only the card preview
shows on hover. Repointed them to select→OK→freeze, then read the stat-block.
Threaded polarity through the sig + my_sign callers; added 4 Jasmine specs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Earthman deck now renders the sig stage in image-mode (has_card_images
defaults True), so .fan-card-face — where .sig-qualifier-above/below live — is
display:none and Selenium reads "". The Elevated/Enlightened/Graven qualifier
belongs on the always-visible stat-block now (the card-face instance goes away
once every deck gets images). Skipped pending the repoint + green-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end coverage for this session's two shipped features.
SigStageUnifiedTest (FunctionalTest, no WS — both pass locally):
- the sig stage renders INSIDE .room-hex-pane on green --duoUser felt
(.has-sig-stage), the overlay is a descendant of the hex pane, the dark
.sig-backdrop is gone, and the overlay bg is not a translucent-black wash;
- OK'ing a card freezes the stage and reveals the DRY _stat_face.html —
.stat-face-title + .stat-chip-rank populate (the old reduced block had
neither; proves the populateStatExtras wiring).
RoomScrollLiveRefreshTest (ChannelsFunctionalTest, @tag channels):
- with the room open, a server-side record() of a new GameEvent grows the
feed (#id_drama_scroll .drama-event 1 → 2) WITHOUT a reload, via the
record() on_commit broadcast → RoomConsumer.scroll_update relay →
room-scroll.js re-fetch+swap. Validated in the CI channels stage (needs a
cross-process channel layer); the plumbing is already green via the
consumer-relay + record-hook + scroll_status ITs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The room's scroll-of-events feed only updated on refresh — a gamer
watching the SCROLL view never saw a co-player's deposit / role pick /
sig appear. Now every recorded GameEvent nudges all open room sockets to
re-fetch the feed.
- drama.models.record() broadcasts a `scroll_update` to the `room_<id>`
group via transaction.on_commit — so the live re-fetch sees the
committed row, and a rolled-back TestCase never fires it (zero overhead
/ channel-layer traffic for the plain IT suite). _broadcast_scroll_update
is fully guarded: a missing/unreachable channel layer must NEVER break
event recording (falls back to refresh-to-update). One central hook
covers every event writer, current + future.
- RoomConsumer gains a `scroll_update` relay handler (same one-liner shape
as gate_update / turn_changed).
- New `scroll_status` view + url (epic:scroll_status,
room/<id>/scroll/status) renders JUST core/_partials/_scroll.html with
the same events/viewer/scroll_position context as room_view's inline
paint, so the swapped feed is identical.
- room-scroll.js listens for `room:scroll_update`, fetches the partial,
swaps #id_drama_scroll, then re-applies the saved Frame/Redact filter +
restarts the buffer dots on the fresh nodes. URL comes from
.room-page[data-scroll-status-url]. Refactored the dots + filter into
re-runnable helpers; existing behavior (title reel IO, filter form,
localStorage) preserved.
TDD:
- drama RecordBroadcast ITs: record() schedules the broadcast on commit
(captureOnCommitCallbacks execute=True) and NOT before commit.
- RoomConsumer relays scroll_update (InMemory layer, WebsocketCommunicator).
- ScrollStatusViewTest: endpoint renders the feed section, reflects the
latest events, is the bare partial (no navbar/aperture chrome).
544 drama+epic ITs green — the on_commit hook is inert under TestCase, so
no existing event-writer test regressed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-room SIG_SELECT stage diverged from the polished GAME SIGN page:
a fixed dark-Gaussian modal over the hex, a stale label-only stat-block,
and no card imagery. This brings it in line with my_sign / my_sea.
A — Stat-block DRY: _sig_select_overlay.html now renders the shared
core/_partials/_stat_face.html (rank-chip + title + arcana + keywords)
instead of a reduced label-only copy; sig-select.js's updateStage() now
calls StageCard.populateStatExtras (the missing call that left those
fields blank). data-arcana-key added per card for title color-keying.
B — Per-card stage image: the stage card gains a .sig-stage-card-img
slot + data-image-url per thumbnail, so an image-equipped seat deck
(RWS / Minchiate) shows real card art on the preview. Thumbnails stay
glyph-only (rank + suit) at every deck — only the stage shows the image.
Keyed off each card's OWN deck_variant, so it auto-upgrades to mixed art
when the dubbodeck assembly lands. No backend change (cards already
carry a deck_variant via _room_deck_variant).
C — Felt-in-aperture: the stage renders INSIDE .room-hex-pane on edge-to-
edge green --duoUser felt (my_sea-style), replacing the hex content; the
old .sig-backdrop blur is gone. .sig-overlay absolute-fills the pane
(.room-hex-pane.has-sig-stage = positioning context); dismissing it
reveals the hex + waiting message behind. Scroll-down still reaches the
reelhouse carousel (untouched scroll pane).
Polishes:
- Image-mode bg escape: the levity 0,3,0 polarity rule
(.sig-overlay/.my-sign-page[data-polarity="levity"] .sig-stage-card)
hard-set a --secUser background that re-clothed image cards behind the
transparent PNG. Added the &.sig-stage-card--image { background:
transparent; border:0; overflow:visible } escape (parity w. the base +
my-sea rules). Latent my_sign bug too. Monodeck-era assumption.
- FLIP .btn-reveal: non-polarized image decks get a FLIP that turns the
preview to the deck card-back (my_sign parity) — back-img + reused
.my-sign-flip-btn (shared positioning/hide/counter-position rules
already cover .sig-stage-card) + a frozen-gated reveal scoped to
.sig-overlay + sig-select.js _flipToBack (500ms Y-rotate, midpoint
swap). SPIN now sets data-spinning so the btn hides mid-rotate.
- Reserved thumbs-up / hover cursors portal to a body-root fixed
container, so they hung over the reelhouse on scroll. sig-select.js now
toggles .cursors-hidden off the aperture scrollTop: instant hide the
moment the scroll leaves the hex, 0.5s opacity ease-in on the full
return. Tray intentionally kept.
TDD: SigSelectUnifiedStageTest (6 ITs) — DRY stat-face present, per-card
data-image-url + data-arcana-key, .sig-stage-card-img slot, image deck
non-empty face URL / text deck empty, has-sig-stage felt + overlay inside
the hex pane. 319 epic test_views ITs green; user-verified live on an RWS
room (no rect, FLIP works, thumb timing). Jasmine for the JS wiring +
the dubbodeck cross-deck assembly (per-seat segment cards, CARTE-solo
both-polarity case, per-card backs) are the tracked follow-on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gatekeeper's GATE VIEW is the canonical "who holds which position"
surface, but it reused _table_positions.html, which paints the
role-assigned fade-out class (opacity:0 / scale .5) server-side from
pos.role_assigned (slot <= assigned_count). So as gamers got seated
during Role Select, the gate-view circles vanished one-by-one in
lockstep with the table-hex — and by the time SCAN SIGS appeared (all
six roles assigned) the gate showed an empty hex. The disappear-as-
seated animation is meant as a TABLE-HEX-only cue.
Fix: _table_positions.html now suppresses role-assigned when its new
persist_circles flag is set; room_gate.html includes the partial with
persist_circles=True. room.html passes no flag (→ falsy), so the
table-hex keeps the fade animation untouched. No JS reads role-assigned
in the gate view (role-select.js isn't loaded there), so the server-side
guard is sufficient.
TDD: PositionTooltipRenderTest.test_gate_view_circles_never_fade_when_roles_assigned
— assigns all six roles, asserts the gate view keeps six .gate-slot
circles with NO role-assigned. Verified live via Claudezilla on a
SIG_SELECT setup_sig_session room. 47 render/gate ITs green
(RoleSelectRenderingTest still asserts the table-hex DOES fade).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- In landscape the wordmark is `writing-mode: vertical-rl` + `rotate(180deg)`. The rotation runs every reel child's translate in the rotated frame (inverting motion vs portrait), and the vertical-rl forces the flex MAIN axis onto the vertical, so the five view-word cells stack vertically.
- Vertical reel (hex⇄views): negate the translateY so ROOM exits UP + the view reel rises from BELOW (was dropping ROOM below the rising view).
- Lateral reel (view-to-view): the cells stack vertically, so the portrait `translateX` slid that stack sideways off the slot — only one word rendered (desktop showed PULSE, mobile ATLAS; `row`/`column`/`order`/`row-reverse` all collapse). Traverse with `translateY` instead: the words now slide up-down ALONG the rotated wordmark and each view lands its own word, consistently across desktop + mobile. (True left-right needs a track writing-mode override + reopens the rotate inversion — deferred; up-down is the working result.)
- `overflow: hidden` on the view reel clips it to its one-slot box so the neighbouring cell can't bleed over ROOM (the lateral reel now shares the vertical axis with the hex⇄views reel).
- Pure SCSS, verified visually across desktop + mobile landscape; portrait untouched (all rules inside the landscape media query).
[[feedback-vertical-rl-flex-axis]] [[project-room-game-views-carousel]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The reelhouse POST card now takes the billboard New Post applet's look: a --duoUser green felt bg with the rotated room-name `> h2` given the SAME green-tinted strip — three translucent 0,0,0/0.125 layers over the felt, NO opaque base, so the felt shows through (matches #id_applet_new_post, which lands there via a %applet-box > h2 specificity quirk; we do it on purpose).
- Each .post-line is restyled to LOOK LIKE the "Enter a post line" composer input below it (mirrors .form-control, _base.scss): a --priUser fill (0.8 alpha), a 0.1rem --secUser border at the same border-radius, full width, an up/down margin + content-driven (dynamic) height — so the thread reads as a stack of input-style pills on the felt. #id_post_table left/right padding zeroed so pills span the full card content width (= the composer row).
- OK button wrapped in .applet-btn-panel (--priUser fill + faint --terUser border) so the green .btn-confirm reads against the felt, mirroring the New Post composer.
- All scoped to .room-view--post — post.html (.post-page), the billboard New Post applet, and MY POSTS (.applet-list-entry) stay untouched (verified live: no .post-line bleed).
- Verified: GameViewsCarouselTest.test_post_view_is_room_thread_with_working_composer + test_atlas_aggregates_provenance_and_posts green (composer still works with the OK-panel wrap); billboard + GAME POST visually confirmed via Claudezilla.
[[feedback-scss-id-context-specificity-trap]] — %applet-box > h2's background-color inherits the applets-container ID via the @extend chain, out-specifying a plain #id_applet > h2 override, so an "opaque base" mask silently renders translucent (the felt bleeds through). New Post hit this by accident; GAME POST replicates it deliberately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Bug (staging, touch only): a finger-swipe onto ATLAS landed blank ("The atlas gathers . . .") though SCROLL + POST had content; opening the ATLAS gear & clicking OK (no option change) made it appear. Desktop never reproduced.
- Cause: the ATLAS feed is a client-side merge of the live SCROLL + POST DOM. buildAtlasFeed ran in placeView (initial land) + goToView (icon-click / horizontal wheel) only. A native swipe is scroll-snap → it reaches the carousel solely through the IntersectionObserver, which called setActiveView('atlas') w. NO build. The gear-OK was the only other path that re-ran the merge.
- Fix: centralise the build in setActiveView — the single chokepoint placeView, goToView, AND the IO all share — so every activation path (incl. swipe) rebuilds; placeView/goToView no longer call buildAtlasFeed directly.
- Removed the empty-state (template + JS): ATLAS is never reached before SCROLL's game-creation "Welcome to <game>!" event, so rows is never bare; an empty render beats a stale placeholder lingering.
- Jasmine: new spec stubs window.IntersectionObserver, fires a synthetic atlas-intersect entry, asserts #id_room_atlas fills from the SCROLL/POST DOM w.o. any goToView or gear-OK (headless can't fire a real intersection — see the swipe-machine note). 473 specs green; the 3 ATLAS carousel FTs green (icon-click path + empty-state removal).
[[feedback-client-view-rebuild-on-io-swipe-path]] [[project-room-game-views-carousel]] [[feedback-headless-delayed-scroll-dropped]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unifies the Post applet across post.html and the room game-views POST chat:
- Extract _post_recipient.html (the @handle chip: post-recipient + post-attribution) + _post_header.html (title + 'shared between … & me' / 'just me' prose). post.html's owner branch + the reelhouse POST view both render via _post_header; the invitee branch stays bespoke but reuses the chip.
- Async-chip styling bug (post.html): the bud-invite append built a bare <span class=post-recipient> with the raw display name, so the recipient rendered without the --quaUser key + the @ until a refresh. Now billboard:share_post returns recipient_chip_html (the server-rendered _post_recipient.html) and the bud panel splices THAT in — identical classes + @handle. Also fixed the 'just me'→'& me' flip to mutate only the leading text node so the self line's own .post-attribution span survives.
- Reelhouse POST chat: gains the full .post-header — title hardcoded to 'Gamer Introduction' (dynamic template later) + recipients = the gamers OCCUPYING SEATS (room.table_seats, deduped, viewer excluded), NOT gate-slot/token depositors. And room_post ACCESS now requires a TableSeat, not a filled gate slot: a depositor who never took a seat can retract + leave, so they must not have R/W access to the private chat.
Tests: header IT (seatmate listed, transient gate-slot-only depositor not — scoped to the recipients paragraph since the position strip carries every gate-slot handle elsewhere); room_post seat-access ITs (seated OK; non-seated + gate-slot-only → 403); share_post recipient_chip_html IT; carousel FT setUp now seats disco/amigo/bud (pal/dude/bro stay transient). All green: 255 ITs, 11 carousel FTs, 29 bud-btn/composer FTs.
[[project-room-game-views-carousel]] [[feedback-at-handle-for-usernames]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each reelhouse view now has its own gear. The single #id_room_menu swaps panes by the active view (room-views.js owns this now — removed from room-scroll.js, which keeps only the title reel):
- hex → default NVM/DEL/BYE; SCROLL → the Frame/Redact log filter; ATLAS → a new source-checkbox pane (.room-menu-atlas / #id_atlas_source_form).
- YARN/POST/PULSE → no menu yet: the gear goes .gear-disabled (opacity 0.6) and an active click is swallowed by a capture-phase listener that flashes a --priRd fa-ban (the burger inactive-flash cadence) instead of opening anything.
ATLAS gear: a checkbox per other reelhouse view (Scroll/Post wired + checked; Yarn/Pulse disabled — struck label, an ✗ in a custom box matching the enabled ✓; starting-majuscule labels, capslock stays reel-only). OK persists to localStorage + re-runs buildAtlasFeed, which now gates each source on atlasSources() (scroll→provenance, post→post).
Also: the reelhouse POST composer drops its bespoke 'Speak at the table' placeholder for the canonical 'Enter a post line' (same as the billboard New Post applet's _form.html + post.html).
Verified: 11 carousel FTs (incl. the new per-view-gear FT) + 310 epic ITs (incl. the atlas-menu IT; room_gate shares _room_gear, unaffected) + the scroll-gear regression FTs + Jasmine, all green.
[[project-room-game-views-carousel]] [[feedback-applet-menu-needs-extend]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The merged ATLAS feed now reads its provenance rows the way SCROLL does:
- Struck (retracted/redacted) logs carry the strikethrough — buildAtlasFeed captures the source .drama-event-body.struck state and renderAtlasRow re-applies it as .atlas-row-body.struck (styled to match .drama-event-body.struck).
- A log hidden by the SCROLL Frame/Redact gear filter (display:none) is skipped in the ATLAS merge too — buildAtlasFeed checks getComputedStyle(ev).display, so unchecking Redact on SCROLL keeps those rows out of ATLAS.
- Also lays the source-toggle seam: atlasSources() reads the (forthcoming) ATLAS gear's view-checkboxes (scroll→provenance, post→post), defaulting to both when absent.
Verified: Jasmine renderAtlasRow struck spec + two FTs (struck row shows struck in ATLAS; redact-filtered rows absent from ATLAS) + the existing atlas aggregate FT, all green.
[[project-room-game-views-carousel]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CHAT conceptually overlapped POST, so it's gone — replaced by a distinct YARN view (fa-route) shifted one slot left, between SCROLL and POST. Reelhouse order is now ATLAS | SCROLL | YARN | POST | PULSE. YARN is a stub (the shared [Feature forthcoming] partial in its .applet-scroll, like PULSE).
Touched: the carousel + strip partials, the h2 reel words, the _base.scss data-active-view translateX reindex (yarn=-200%, post now -300%), room-views.js VIEW_ORDER, the Jasmine spec VIEWS, and the FT/IT order + stub assertions. The horizontal-wheel FT now expects scroll's neighbour to be YARN.
ATLAS timestamps: the merged rows carry the ORIGINAL <time> from their source (.drama-event-time from SCROLL, .post-line-time from POST), but those source rules are feed/thread-scoped so the atlas copies rendered full-size inline. Made .atlas-row a flex row and restated the shared small / dim / right-aligned look on both source time classes so each timestamp reads the same as in the view it came from.
Verified: 8 carousel FTs + carousel ITs + Jasmine (atlas merge + swipe machine) green.
[[project-room-game-views-carousel]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three things on the carousel:
- CHAT/PULSE stub: the shared [Feature forthcoming] partial centres itself absolutely, landing on top of the flex-centred watermark icon. Override it to flow (position:static) inside .room-view-stub so the icon keeps the top slot and the label rests below — FT asserts the icon's bottom clears the label's top (no clip).
- Adopt 'reelhouse' as the collective term for the fivefold applet-scroll carousel (class on #id_room_views + comments).
- Text sub-btn swipe machine: when already reel'd down onto the reelhouse, slide straight over to POST (plain goToView); when starting up in the room (the hex), run smooth DOWN to the reelhouse, HOLD 0.5s, then OVER to POST unless already there — the hold beats the two motions apart (DOWN-then-OVER, never diagonal).
Test rework: the from-hex OVER beat is a DELAYED (post-descent + hold) programmatic scroll, which headless Selenium drops/resets (works fine in a real browser), so the end-to-end land-on-POST can't be FT'd. Split it: the FT now asserts the reliable DESCENT beat (Text from the hex reveals the reelhouse + icon strip), and a new Jasmine swipe-machine spec pins the nav DECISION (from hex → POST after the descent+hold; inactive btn → no-op) against a fixture + mocked clock. room-views.js exposes init() so the spec can bind to the fixture.
Verified: 8 carousel FTs + Jasmine (atlas merge + swipe machine) green.
[[project-room-game-views-carousel]] [[feedback-ft-run-discipline]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The carousel's lateral nav animated wrong: the h2 reel slid VERTICALLY (old word down, new word up — both visible at once), and a first attempt that put translateX+translateY on one transform produced a DIAGONAL blend on hex->views (ROOM+ATLAS+SCROLL all flashing, always landing on ATLAS).
Fix — split the two axes onto NESTED elements so they never blend:
- OUTER .gr-views-reel does translateY ONLY, gated on .is-scroll (the hex<->views vertical reel, in lockstep with ROOM sliding up/out).
- INNER .gr-views-track does translateX -idx*100% (VIEW_ORDER atlas|scroll|post|chat|pulse), gated on data-active-view ALONE — so the active view's cell sits in the slot at ALL times, including at the hex. Default (pre-JS/unset) = SCROLL.
Result: hex<->views is a pure vertical reel that lands on whatever view you left off on (POST returns to POST, no diagonal); lateral nav is a pure horizontal slide — old word out one side, new in from the other, direction from the translateX sign — same rusty linear() sequence as ROOM<->SCROLL, just left-right.
Cards: goToView now smooth-scrolls (scrollTo behavior:smooth) instead of jumping scrollLeft, so the five .applet-scroll panes visibly SLIDE; an IO-suppression flag during the programmatic snap keeps the icon glow + reel from jittering through passed-over views (native touch-drag still updates via the IO). Initial land uses an instant placeView (no slide on arrival).
Verified: 8 carousel FTs + the GAME ROOM<->SCROLL vertical-reel FT green; a 3-lens adversarial audit (vertical axis / horizontal axis+clipping / repo-wide regression sweep) returned holds with no findings.
[[project-room-game-views-carousel]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Deepen .room-view bottom padding to 3.5rem so the .applet-scroll card ends above the absolute icon strip (bottom 0.85rem, ~2.1rem tall) — the strip now stands on its own in a reserved lane beneath the card instead of overlapping its bottom edge / the WHAT HAPPENS NEXT buffer.
- CHAT + PULSE stub bodies swap the ad-hoc 'Chat/Pulse opens soon.' line for the shared core/_partials/_forthcoming.html ([Feature forthcoming]); the identity icon + .room-view-stub wrapper stay, so the stub FT + styling are unaffected.
[[project-room-game-views-carousel]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups to the carousel sprint:
- The footer's billboard nav icon was fa-scroll, which now collides semantically with the carousel's SCROLL view; FA's billboard glyph is paywalled, so the billboard nav uses fa-receipt (ledger/scroll) instead. The footer FT selects by href, so navigation is unaffected.
- #id_room_views_strip was position:fixed (viewport bottom) and rendered down in the fixed footer/burger zone. Switched to position:absolute so it anchors to .room-page's bottom — and since the aperture fills .room-page via inset:0, that IS the bottom of the views pane, clear of the fixed footer. Still a sibling of the aperture (escapes the scroll-card fade mask + scroll clip); .room-page's overflow:hidden doesn't clip it (stays in bounds). Strip-visibility + landscape-within-viewport FTs still green.
[[project-room-game-views-carousel]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unskips the 8 RED FTs from the prior commit (test_game_room_views.py) and lands the feature beneath them — the room's 2nd vertical snap pane becomes a horizontal scroll-snap carousel of five views, landing on SCROLL (2nd).
Carousel core: _room_views.html (5 .room-view panes) + _room_views_strip.html (root-level icon strip, outside the aperture so it clears the scroll card's fade mask + scroll clip); room-views.js owns the horizontal axis — goToView (authoritative active-state) + an IntersectionObserver backing native swipe; horizontal wheel (deltaX / shift+wheel) advances a view while vertical wheel stays for feed scroll; icon-click snaps; the strip shows only while the views pane is on screen (vertical IO mirrors room-scroll.js). SCROLL still wraps _room_scroll.html, so the existing binary y-snap + provenance feed + GAME ROOM ⇄ GAME SCROLL title reel behave unchanged.
Title reel: the .gr-swap reel gains the four extra view words; the active word is driven by data-active-view on the h2 (set by room-views.js), gated by .is-scroll (room-scroll.js) so ROOM shows at the hex.
POST view: a room-scoped game-table thread. New Post.room FK + KIND_ROOM_THREAD (mirrors Brief.room) + Room.get_thread_post(); epic:room_post AJAX endpoint appends a Line (seated-gamer-gated, dup-rejected) and returns the rendered line partial. _post_line.html extracted from post.html and shared by both surfaces + the endpoint. The composer appends OPTIMISTICALLY (synchronous line so the POST + ATLAS views reflect it the instant OK is clicked, no dependence on the round-trip), then reconciles with the server's authoritative @handle/timestamp render; a rejection rolls the optimistic line back.
ATLAS view: a live client-side merge of the SCROLL provenance rows + the POST thread rows, time-ordered, each row tagged data-source=provenance|post for end-of-sprint per-type styling. Rebuilds from the live DOM on activation + on every composer append. CHAT/PULSE are .room-view-stub placeholders (no backing model yet).
Burger Text sub-btn lights .active on the table (text_btn_active from epic.room_view, unset on every other _burger.html surface) → room-views.js binds its active click to the swipe machine: DOWN to the views pane, RIGHT to Post.
Coverage: 8 carousel FTs green; Jasmine RoomViewsSpec (atlas merge order/stability + row data-source); epic ITs (Room.get_thread_post, carousel markup, room_post endpoint 200/403/400/GET); 1636 ITs/UTs + the existing scroll FT green (no regression).
Gotcha logged: build FormData(form) BEFORE clearing the input on optimistic submit — clearing first captures an empty text field → 400 → the line silently rolls back.
[[project-room-game-views-carousel]] [[project-room-scroll-of-events]] [[project-room-title-scroll-reel-jun02]] [[feedback-jsonfield-exclude-sqlite-null]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OK-button composer redesign wrapped the line input in .composer-row, so the .form-control.is-invalid input is no longer a general SIBLING of its .invalid-feedback — the `&.is-invalid ~ .invalid-feedback` reveal (\_base.scss) silently stopped matching, so post-line validation errors rendered in the DOM but stayed display:none (invisible to users). Reveal via `.composer-row:has(.form-control.is-invalid) ~ .invalid-feedback`. Greens test_cannot_add_duplicate_lines + test_error_messages_are_cleared_on_input (both were catching this real regression, not flaky).
Harden WalletShopFreeDeckTest: the .tt-micro is briefly detached mid-HTMX-swap, so get_attribute('innerHTML') returns None and a bare assertIn raises TypeError — which wait_for does NOT retry. Coalesce to '' so it polls until the swap settles (explains the local-pass / CI-fail).
Delete test_core_styling.test_layout_and_styling: a Percival-era assertion that the post input is horizontally CENTRED in its section. The responsive .composer-row (input + OK btn) + the orientation-aware right-margin clamp intentionally removed that invariant (the input now lands in different spots per viewport). Zero behavioural coverage lost — the composer is covered by LineValidationTest + PostComposerOkButtonTest.
Skip GameViewsCarouselTest (red planning contract for the unbuilt Game-views carousel — see project-room-game-views-carousel).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Outside-in RED contract (test_game_room_views.py, game_room bucket) for the next sprint: the room's 2nd vertical snap pane becomes a horizontal carousel of 5 views reached by scrolling down from the hex — ATLAS | SCROLL | POST | CHAT | PULSE, landing on SCROLL (2nd).
Covers: a root-level icon strip (hidden at the hex, shown in the views pane, SCROLL active + glowing, 5 icons in order); icon-click + horizontal-wheel nav with glow handoff + a data-active-view title reveal; #id_text_btn running the down-then-right swipe machine from the hex to the Post view; the Post view as a room-scoped thread reusing post.html's #id_post_table + #id_post_line_text composer; the Atlas view aggregating GameEvents + room posts (data-source=provenance|post); CHAT/PULSE as .room-view-stub placeholders; and the landscape strip clearing the scroll card's fade mask.
No implementation yet — these fail until the feature lands; they are the build contract. Plan + decisions captured in memory (project-room-game-views-carousel). Builds on the GAME ROOM ⇄ GAME SCROLL title reel + the room scroll-of-events aperture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Workstream C of the position-circle tooltips sprint (sprint complete).
Per-seat (not per-gamer) significators for a CARTE solo owner, WITHOUT flipping the SigReservation (room,gamer) unique constraint (which the recon confirmed would break 25+ multi-gamer sig tests + the channels flow).
- sig_reserve resolves the active seat from ?seat=N (carried on the reserve URL) when the viewer owns it, else _canonical_user_seat — so the hold/commit targets the SELECTED seat.
- When the polarity group is SOLO-owned (the viewer owns every PC/NC/SC or BC/EC/AC seat — a CARTE table), reserving commits seat.significator immediately: the 3-ready countdown can never complete solo. The committed sig persists through a NVM release (which only deletes the provisional row), so the viewer reserves each seat in turn. Advances to SKY_SELECT once every seat has a sig (mirrors sig_confirm's tail). Strictly gated to the solo case — a multi-gamer polarity group still rides the existing countdown contract untouched.
- _role_select_context SIG_SELECT branch: overrides user_seat by ?seat (owned) so the overlay reflects the selected seat's role/polarity/deck, and carries ?seat on sig_reserve_url.
- FT refined to the real reserve mechanic (in-card OK btn → .sig-reserved) — the RED spec's .sig-card.reserved / single-body-click was a placeholder; behavior verified is unchanged (per-seat sig persistence).
Tests: CarteSeatSwitchTest.test_carte_saves_a_significator_per_seat FT green (all 4 CarteSeatSwitch + 7 PositionTooltip now green, no skips); 2 solo-CARTE sig ITs (SigReserveSoloCarteTest); full suite 1602 green; channels consumer tests 5 green (WS broadcast intact); multi-gamer SigReserveViewTest/sig_ready/sig_confirm untouched.
[[project-position-circle-tooltips]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Workstream B of the position-circle tooltips sprint.
_role_select_context consumes ?seat=N (threaded from room_view): a multi-seat (CARTE) gamer previews a specific owned seat. The card-stack's active slot becomes that seat; it stays 'eligible' only when the previewed seat is also the table's current turn (lowest unassigned seat), else it renders the ineligible .fa-ban. Strictly additive — a one-seat gamer never passes ?seat, and an unowned/garbage param is ignored, so the normal role-select flow (RoleSelectRenderingTest) is untouched.
Tests: CarteSeatSwitchTest.test_switching_seat_loads_that_seats_role_view FT green; 2 new render ITs (seat-param-previews + no-seat-keeps-canonical); RoleSelectRenderingTest still green.
[[project-position-circle-tooltips]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Outer-loop FTs authored today; implementation lands tomorrow. Both
classes are @skip-ped so the red spec rides into the repo without
breaking the FT CI stage (we just rescued that pipeline); tomorrow's
work removes the skip per-method as each behavior goes green.
Spec encoded (user-spec 2026-06-01):
- gate-position circles (1–6) gain rich hover tooltips mirroring the My
Buds bud tooltip, on EVERY surface — initial gatekeeper, above the hex,
AND the new GATE VIEW gate-view (room_gate.html renders no circles
today: the headline red)
- tooltip: @handle (.tt-title), title (.tt-description), NO email, a
top-right .tt-sign stack of the SEAT significator (TableSeat.
significator — per-seat, user-decided), bud shoptalk when the occupant
is a bud, # tokens deposited (CARTE slots_claimed else 1), .tt-expiry
(GateSlot.cost_current_until)
- state classes: .tt-pos-empty / .tt-pos-gamer / .tt-pos-gamer.tt-pos-bud
/ .tt-pos-me-current / .tt-pos-me-also (renamed from -me-other per
user). .tt-pos-me-also carries a ?seat=<n> switch href to load that
seat's view (preview pos-4 ROLE state w. the .fa-ban atop the deck, or
SAVE SIG per seat during Sig Select)
- per-seat SIG: today SigReservation is per-(room,gamer) — the FT pins
per-SEAT sig so a CARTE gamer picks a different sig per seat (tomorrow's
green = SigReservation rework)
FTs: PositionTooltipTest (8 — circle render on gate-view, me-current /
gamer / bud+shoptalk / no-email / tokens+expiry / seat-sign / hover-
portal) + CarteSeatSwitchTest (4 — me-also switch href, carte token
count, ?seat= loads seat ROLE view, per-seat sig). game_room bucket.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 6 (final) of the room GATE VIEW + seat-renewal sprint. Cron
backstop mirroring delete_stale_my_sea_draws — the lazy
_expire_lapsed_seats already frees seats on every room/gate-view access,
but a mid-game table nobody reopens past the grace window would keep its
stuck seats forever. This command runs the same sweep over every room
holding a timestamped FILLED slot. No flags; idempotent.
Tests: ExpireLapsedRoomSeatsCommandTest (2) — frees a >2S lapsed seat +
flags RENEWAL_DUE; no-op within grace. Full project suite 1590 ITs/UTs
green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5 of the room GATE VIEW + seat-renewal sprint. A seated gamer who
never renews is evicted once their seat's cost passes the renewal-grace
window (filled_at + 2*renewal_period; 14d at the 7d default).
- _expire_lapsed_seats(room): mirrors _expire_reserved_slots — for each
FILLED slot past 2S, blanks the GateSlot, blanks the matching TableSeat
(keeps the row for seat-count integrity), records SLOT_RETURNED +
retracts the prior SLOT_FILLED (scroll redact-pair symmetry), then
flags the room RENEWAL_DUE. NULL filled_at is never expired (RESERVED
holds / ORM fixtures / auto-admit trinkets) — protects every existing
FILLED-slot test
- lazy call sites: room_view, gatekeeper, room_gate (on access; mirrors
the my-sea delete_stale pattern — no scheduler needed for active rooms)
- room.html: RENEWAL_DUE renders a minimal #id_gamer_needed stub
(_table_positions + _gatekeeper already suppressed for RENEWAL_DUE).
Mid-game re-seat flow is a documented follow-on
Tests: ExpireLapsedSeatsTest (10) — frees slot + blanks seat past grace;
no-op within cost window / grace / for null filled_at; sets RENEWAL_DUE;
records SLOT_RETURNED; lazy expiry on room_view + room_gate access;
gamer-needed stub renders. 848 epic+gameboard ITs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3 of the room GATE VIEW + seat-renewal sprint. When the viewer's
own FILLED gate-slot cost has lapsed (filled_at past the cost-current
window), the center hex shows a GATE VIEW button (→ room gate-view)
instead of the phase affordances, so they must renew before advancing.
- _role_select_context: adds viewer_cost_current / viewer_in_grace from
the viewer's FILLED slot (no slot → current, defensive)
- room.html: the ROLE card-stack renders OUTSIDE the cost gate (the
gamer's own role pick survives the renewal grace — deposit privilege);
GATE VIEW supersedes the rest of .table-center; #id_pick_sigs_wrap
(SCAN SIGS, advancing the whole table) is gated on viewer_cost_current;
the SIG/SKY/SEA overlays are gated too (they embed their trigger-btn
ids in JS, so they must not render alongside GATE VIEW)
- per user-spec: only the ROLE pick stays in grace; SCAN SIGS + every
later phase get GATE VIEW
Tests: RoomCenterSupersessionTest (9) — GATE VIEW supersedes sig overlay
/ CAST SKY / DRAW SEA / SCAN SIGS when lapsed, normal buttons when
current; RoomRoleStackGraceTest (1) — card-stack (eligible) kept
alongside GATE VIEW when lapsed. 838 epic+gameboard ITs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesign of the room gate-view per user-spec 2026-05-31: drop the custom
seat-circle + countdown; render the EXACT gatekeeper modal instead
(title panel + animated status-dots + token-slot rails + roles panel).
- roles-panel .btn-primary is CONT GAME (→ table hex, same target as the
gear NVM) while the viewer's seat cost is current; absent once it
lapses, reappears after renewal re-satisfies the cost
- .gate-status-text: "<n> Token(s) Deposited" (literal "(s)" + the shared
. . . . dots loop) when satisfied; "Please Deposit Token" when not.
<n> = the room's deposited (FILLED) slot count
- token slot: .claimed (static rails) when current; .active rails that
POST to renew_token when lapsed
- seat circle + time-remaining removed — the hex's own .fa-chair carries
seat status & user/seat tooltips land next sprint
- room_gate view trimmed to {room, cost_current, deposited_count,
page_class}
- tests: RoomGateViewTest reworked (9) — CONT GAME→hex + deposited-count
status + no renew-form when current; "Please Deposit Token" + renew
rails + no CONT GAME when lapsed; NVM→hex; page-room; no seat/countdown
markup. 510 epic tests green
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 0 of the room GATE VIEW + seat-renewal sprint. Mirrors the my-sea
treatment: on any room page the self-referential CONT GAME is replaced
by a GATE VIEW button that opens the room's renewal gate-view.
- `room_view` page_class → "page-gameboard page-room"; the bare gameboard
listing stays "page-gameboard" (no page-room) so CONT GAME persists
there for returning to a recent room.
- `_navbar.html` GATE VIEW branch fires on `page-my-sea` OR `page-room`;
onclick routes, in precedence: page-room → epic:room_gate (room in
context); my-sea-visit → visitor gate; else owner's sea gate. One
consolidated branch (DRY) instead of two near-identical button blocks.
Tests: RoomNavbarGateViewTest (4) — room page shows GATE VIEW not CONT
GAME, links to room_gate, gate-view page also shows it, page-room marker
present. 826 epic+gameboard ITs green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 of the room GATE VIEW + seat-renewal sprint. The 3rd-person
mirror of my_sea_gate: a gate-view a seated gamer can open at any time
to check token TIME REMAINING or RENEW, reachable even mid-game (the
gatekeeper redirects to the table once table_status is set — this view
does not).
- `room_gate` view + `room/<uuid>/gate/view/` URL — renders the viewer's
own seat/position circle, a live time-remaining ticker (counts down to
cost_current_until, then to grace_expires_at in renewal grace), and a
RENEW affordance. page_class carries `page-room` (drives the navbar
GATE VIEW in Phase 0). No seat → "no seat" copy, no RENEW btn.
- `renew_token` view + `room/<uuid>/gate/renew` URL — re-deposits a token
into the viewer's already-FILLED slot via the existing `debit_token`
(resets filled_at=now → restarts the cost-current window). Reuses
select_token / debit_token wholesale; distinct from confirm_token,
which needs a RESERVED slot. 402 when token-depleted; no-op redirect
when the user holds no filled slot (already auto-BYE'd).
- `room_gate.html` — reuses the gatekeeper's .gate-overlay/.gate-modal
chrome (hand-rolled like my_sea_gate, inner content differs) + an
inline countdown ticker mirroring the status-dots IIFE.
- DRY: `_room_gear.html` now takes an `nvm_url` param (default the
gameboard listing — room.html's own gear unchanged); the gate-view
passes the table-hex URL so NVM returns to the hex, mirroring
_my_sea_gear's contract.
Tests: RoomGateViewTest (7) + RoomRenewTokenTest (6) — renders mid-game,
own seat circle, data-cost-until, RENEW posts to renew_token, NVM→hex,
page-room marker, no-seat render; renew resets filled_at + consumes FREE
+ records SLOT_FILLED, no-slot/GET redirects, 402 when depleted. 504
epic tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 of the room GATE VIEW + seat-renewal sprint. Pure model
properties (no migration, no new fields) layering a uniform seat clock
on top of the existing per-token debit rules (which stay untouched):
[A, A+S) cost_current play normally (A = filled_at)
[A+S, A+2S) in_renewal_grace cost lapsed, seat held (S = renewal_period)
[A+2S, ∞) grace_expired eligible for auto-BYE
Uniform across ALL token types per user-spec (PASS/BAND/CARTE included)
— keyed on filled_at only. A NULL filled_at (RESERVED slots, ORM-built
fixtures) reads cost_current=True / grace_expired=False so nothing
without a fill timestamp is ever evicted (protects existing FILLED-slot
tests that set status via the ORM). renewal_span falls back to 7d when
room.renewal_period is None.
Tests: GateSlotCostCurrentTest — 11 UTs covering within/after span, null
filled_at, until==filled+period, grace boundaries [S,2S), expiry at 2S,
and the 7d span fallback. 491 epic tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1 of the room GATE VIEW + seat-renewal sprint. Decouples 1C
seating from hand/deposit/paid state: the owner is seated as long as
`active_draw_for` returns a row, i.e. for the full 24h after her most
recent FREE or PAID draw (PAID DRAW resets created_at, so the window
runs from the later of the two). A DEL'd row (empty hand, no paid
credit) now keeps 1C seated until the row expires at 24h — previously
1C dropped to .fa-ban the instant she DEL'd, even mid-window.
- `_my_sea_seats` `owner_seated` → `owner_draw is not None` (drives the
owner's own landing hex + the live `sea_seats` broadcast).
- `my_sea_visit` `owner_seated` → same rule, so the spectator hex and
the owner's landing agree (was drawn-OR-paid, dropped `owner_paid`).
- DRY: removed the dead `seat1_seated` context (the `seats` ring's
`seat.present` has driven 1C since the multi-seat hex landed; the flag
was never read by the template).
Tests — TDD red→green:
- flipped `test_seat_1c_not_seated_at_gate_view_after_del` →
`..._seated_...`: DEL'd empty-hand row keeps 1C seated, center still
GATE VIEW (1 check / 5 ban).
- flipped FT `test_seat_1_banned_when_active_draw_has_empty_hand` →
`..._seated_...` (asserts .fa-circle-check).
- added `test_seat1_seated_context_key_removed` (DRY regression guard).
- added spectator `test_owner_seated_with_empty_hand_no_payment`.
- `test_owner_not_seated_without_draw_or_payment` unchanged (no row →
still unseated). 318 gameboard ITs green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>