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>
This commit is contained in:
Disco DeDisco
2026-06-01 18:32:57 -04:00
parent 5229b9f96a
commit a4adf9664b
6 changed files with 303 additions and 3 deletions

View File

@@ -0,0 +1,95 @@
"""Room scroll-of-events FT — the table-hex aperture's binary scroll-snap
toggle (mirrors my_sky's wheel<->form swap). From Role Select onwards the
viewer scrolls DOWN past the hex to reveal the room's provenance feed (the
same GameEvent stream the Billscroll page shows); scrolling back up snaps to
the hex. No partial scroll — `scroll-snap-stop: always` enforces the binary.
FT bucket: game_room. Built to DRY-template onto my_sea next.
"""
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .room_page import _equip_earthman_deck, _fill_room_via_orm
from apps.drama.models import GameEvent
from apps.epic.models import Room
from apps.lyric.models import User
class RoomScrollOfEventsTest(FunctionalTest):
def setUp(self):
super().setUp()
self.viewer = User.objects.create(email="disco@test.io", username="disco")
_equip_earthman_deck(self.viewer)
self.room = Room.objects.create(name="Whataburgher", owner=self.viewer)
_fill_room_via_orm(
self.room,
["disco@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io"],
)
# A deposit line for the feed — renders via the shared scroll partial as
# "deposits a Carte Blanche for slot 1 (expires in 7 days)."
GameEvent.objects.create(
room=self.room, actor=self.viewer, verb=GameEvent.SLOT_FILLED,
data={"token_type": "carte", "token_display": "Carte Blanche",
"slot_number": 1, "renewal_days": 7},
)
self.room.table_status = Room.ROLE_SELECT
self.room.gate_status = Room.OPEN
self.room.save()
self.url = self.live_server_url + f"/gameboard/room/{self.room.id}/"
def _open(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.set_window_size(820, 600)
self.browser.get(self.url)
return self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".room-aperture")
)
def _in_viewport(self, css):
return self.browser.execute_script(
"var e=document.querySelector(arguments[0]); if(!e) return false;"
"var r=e.getBoundingClientRect();"
"return r.top < window.innerHeight && r.bottom > 0;", css)
def test_aperture_binary_scroll_snap_with_two_panes(self):
"""The aperture is scroll-snap-y-mandatory and both panes snap to the
start with `always` — without these it's a free scroll, not the binary
hex<->scroll toggle."""
self._open()
snap = self.browser.execute_script(
"return getComputedStyle(document.querySelector('.room-aperture'))"
".scrollSnapType;"
)
self.assertIn("y", snap)
self.assertIn("mandatory", snap)
for pane in (".room-hex-pane", ".room-scroll-pane"):
align = self.browser.execute_script(
"return getComputedStyle(document.querySelector(arguments[0]))"
".scrollSnapAlign;", pane,
)
self.assertIn("start", align)
def test_scroll_down_reveals_event_feed(self):
"""The feed sits below the hex (off-screen); scrolling the aperture
down brings it into view — the hex content is replaced entirely."""
self._open()
# The event feed starts off-screen, below the hex pane.
self.assertFalse(self._in_viewport("#id_drama_scroll"))
self.browser.execute_script(
"document.querySelector('.room-aperture').scrollTop = 99999;"
)
# The aperture actually scrolled to the 2nd snap section…
self.wait_for(lambda: self.assertGreater(
self.browser.execute_script(
"return document.querySelector('.room-aperture').scrollTop;"
),
10,
))
# …and the provenance feed (with the deposit line) is now in view.
self.wait_for(lambda: self.assertTrue(self._in_viewport("#id_drama_scroll")))
self.assertIn(
"deposits a Carte Blanche for slot 1",
self.browser.find_element(By.ID, "id_drama_scroll").text,
)