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

@@ -3288,3 +3288,69 @@ class ExpireLapsedRoomSeatsCommandTest(TestCase):
call_command("expire_lapsed_room_seats")
slot.refresh_from_db()
self.assertEqual(slot.status, GateSlot.FILLED)
class RoomScrollOfEventsTest(TestCase):
"""The table-hex aperture gains a 2nd scroll-snap section: scrolling down
past the hex reveals the room's provenance feed (the same GameEvent stream
the Billscroll page shows). Available from Role Select onwards — the gate
phase (no `table_status`) shows no scroll. Mirrors my_sky's wheel<->form
binary scroll-snap toggle; built to DRY-template onto my_sea next.
The shared row list comes from `core/_partials/_scroll.html` (the same
partial the Billscroll page uses), so the feed renders identically here."""
def setUp(self):
self.user = User.objects.create(email="founder@test.io", username="disco")
self.client.force_login(self.user)
self.room = Room.objects.create(
name="Whataburgher", owner=self.user,
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
)
TableSeat.objects.create(
room=self.room, gamer=self.user, slot_number=1, role="PC",
)
# A welcome line (system-authored) + a deposit line — feed content.
GameEvent.objects.create(room=self.room, verb=GameEvent.ROOM_CREATED)
GameEvent.objects.create(
room=self.room, actor=self.user, verb=GameEvent.SLOT_FILLED,
data={"token_type": "carte", "token_display": "Carte Blanche",
"slot_number": 1, "renewal_days": 7},
)
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
def test_aperture_wraps_hex_and_scroll_panes(self):
content = self.client.get(self.url).content.decode()
self.assertIn("room-aperture", content)
self.assertIn("room-hex-pane", content)
self.assertIn("room-scroll-pane", content)
def test_aperture_is_scrollable_from_role_select(self):
# `is-scrollable` engages the binary scroll-snap (2 panes present).
content = self.client.get(self.url).content.decode()
self.assertIn("room-aperture is-scrollable", content)
def test_scroll_pane_renders_the_event_feed(self):
content = self.client.get(self.url).content.decode()
self.assertIn("id_drama_scroll", content)
self.assertIn("Welcome to Whataburgher!", content)
self.assertIn("deposits a Carte Blanche for slot 1", content)
def test_scroll_feed_scoped_to_this_room(self):
other = Room.objects.create(
name="Elsewhere", owner=self.user,
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
)
GameEvent.objects.create(room=other, verb=GameEvent.ROOM_CREATED)
content = self.client.get(self.url).content.decode()
self.assertNotIn("Welcome to Elsewhere!", content)
def test_no_scroll_pane_in_gate_phase(self):
"""The gate phase (table_status unset) shows no scroll — the feed is
reachable only from Role Select onwards."""
gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
content = self.client.get(
reverse("epic:gatekeeper", args=[gate_room.id])
).content.decode()
self.assertNotIn("room-scroll-pane", content)
self.assertNotIn("is-scrollable", content)

View File

@@ -643,6 +643,15 @@ def room_view(request, room_id):
# Reversal-rate hint label under DRAW SEA's SPREAD select — same helper as
# sea_partial so the value tracks any future per-user override automatically.
ctx["stack_reversal_pct"] = int(round(stack_reversal_probability(request.user, room) * 100))
# Scroll-of-events — the table-hex aperture's 2nd snap section. Same query
# + shared `core/_partials/_scroll.html` partial as the Billscroll page
# (`billboard.views.scroll`), so the feed renders identically. Unfiltered
# (Meta-ordered by timestamp) — the `struck`/redact rendering is the
# partial's job. Only reached from Role Select onwards (the template gates
# the scroll pane on `room.table_status`).
ctx["events"] = room.events.select_related("actor").all()
ctx["viewer"] = request.user
ctx["scroll_position"] = 0
return render(request, "apps/gameboard/room.html", ctx)

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,
)

View File

@@ -44,6 +44,90 @@ html.sea-open #id_aperture_fill {
opacity: 1;
}
// ─── Table-hex aperture: binary scroll-snap toggle ─────────────────────────
// Mirrors my_sky's wheel<->form swap (`_sky.scss` body.sky-saved block). The
// aperture fills .room-page; from Role Select onwards it holds TWO panes —
// the hex (default) and the room's provenance scroll — and scroll-snaps
// between them. CRITICAL: the aperture and panes set NO z-index / transform /
// opacity / filter, so they create NO stacking context — the position strip's
// z-130 (a hex-pane descendant) still resolves in the root context, above the
// gate/sig overlays (z-100/120), exactly as when it lived at room-page root.
.room-aperture {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
// Gate phase (single pane): static + clipped, like the old .room-page.
overflow: hidden;
}
.room-pane {
position: relative; // containing block for the position strip (z-auto)
flex: 0 0 auto;
width: 100%;
// EXACTLY one aperture tall (not min-height) so the snap stops land at
// integer multiples of the viewport — the binary toggle, no mid-scroll.
height: 100%;
}
.room-hex-pane {
display: flex;
align-items: center;
justify-content: center;
}
// Two panes present (table phase) → engage the binary snap. `is-scrollable`
// is added by the template iff `room.table_status` is set (Role Select on).
.room-aperture.is-scrollable {
overflow-y: auto;
overflow-x: hidden;
scroll-snap-type: y mandatory;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
.room-pane {
scroll-snap-align: start;
scroll-snap-stop: always; // hard stop each section — no coasting
}
}
.room-scroll-pane {
display: flex;
flex-direction: column;
padding: 0.75rem;
background: rgba(var(--duoUser), 1); // matches the aperture / billscroll bg
#id_drama_scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
// Pin the "What happens next…?" buffer to the bottom when the feed is
// short (pure-CSS equivalent of billscroll's rAF marginTop nudge).
.scroll-buffer {
margin-top: auto;
display: flex;
justify-content: center;
align-items: baseline;
padding: 2rem 0 1rem;
opacity: 0.4;
font-size: 0.8rem;
text-transform: uppercase;
.scroll-buffer-text { letter-spacing: 0.33em; }
.scroll-buffer-dots {
display: inline-flex;
letter-spacing: 0;
span { display: inline-block; width: 0.7em; text-align: center; }
}
}
}
}
.gate-backdrop {
position: fixed;
inset: 0;

View File

@@ -0,0 +1,27 @@
{# Room scroll-of-events — the table-hex aperture's 2nd scroll-snap pane. #}
{# Scroll DOWN past the hex to read the room's provenance feed (the same #}
{# GameEvent stream the Billscroll page shows). Renders the shared #}
{# `core/_partials/_scroll.html` row list (events / viewer / scroll_position #}
{# come from the view). DRY seam: my_sea will include this same partial. #}
{% include "core/_partials/_scroll.html" %}
<script>
(function () {
var scroll = document.getElementById('id_drama_scroll');
if (!scroll || scroll.dataset.roomScrollBound) return;
scroll.dataset.roomScrollBound = '1';
// Animate the "What happens next . . ?" buffer dots (4th span shows '?').
// The buffer's bottom-pin is pure CSS (margin-top:auto in the flex column);
// only the dot cycle needs JS. No scroll-position persistence here — the
// room scroll is an at-a-glance ledger, not a read-tracking feed.
var dotsWrap = scroll.querySelector('.scroll-buffer-dots');
if (!dotsWrap) return;
var dots = dotsWrap.querySelectorAll('span');
var n = 0;
setInterval(function () {
dots.forEach(function (d, i) {
d.textContent = i < n ? (i === 3 ? '?' : '.') : '';
});
n = (n + 1) % 5;
}, 400);
}());
</script>

View File

@@ -8,6 +8,14 @@
<div class="room-page" data-room-id="{{ room.id }}"
{% if room.table_status %}data-select-role-url="{% url 'epic:select_role' room.id %}"{% endif %}>
<div id="id_aperture_fill"></div>
{# Table-hex aperture — a binary scroll-snap viewport (mirrors my_sky's #}
{# wheel<->form swap): the hex pane is the default view; from Role #}
{# Select onwards a 2nd pane holds the room's provenance feed, revealed #}
{# by scrolling DOWN. `is-scrollable` engages scroll-snap (2 panes), so #}
{# the hex content is replaced entirely by the Scroll (no partial scroll #}
{# — `scroll-snap-stop: always`). DRY seam: my_sea reuses _room_scroll. #}
<div id="id_room_aperture" class="room-aperture{% if room.table_status %} is-scrollable{% endif %}">
<div class="room-pane room-hex-pane">
<div class="room-shell">
<div id="id_game_table" class="room-table">
{# SCAN SIGS advances the whole table past role-select — gated on #}
@@ -84,7 +92,21 @@
{% endfor %}
</div>
</div>
</div>{# /.room-shell #}
{# Position circles scroll away WITH the hex (they live inside the hex #}
{# pane, not at room-page root). Neither the aperture nor the pane sets #}
{# z-index/transform, so the strip's z-130 still resolves in the root #}
{# stacking context — above the gate/sig overlays — as before. #}
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
{% include "apps/gameboard/_partials/_table_positions.html" %}
{% endif %}
</div>{# /.room-hex-pane #}
{% if room.table_status %}
<div class="room-pane room-scroll-pane">
{% include "apps/gameboard/_partials/_room_scroll.html" %}
</div>
{% endif %}
</div>{# /.room-aperture #}
{# Phase overlays are gated on `viewer_cost_current` too: a lapsed gamer #}
{# gets GATE VIEW in the center, so the SIG/SKY/SEA modals (which embed #}
@@ -119,9 +141,6 @@
</div>
{% endif %}
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
{% include "apps/gameboard/_partials/_table_positions.html" %}
{% endif %}
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
{# Owner-only invite affordance: handshake btn at the upper-right #}