From 4a4e60f6685983621e52c583bf08df8158f83aa4 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 1 Jun 2026 20:03:48 -0400 Subject: [PATCH] =?UTF-8?q?room=20scroll-of-events:=20applet-box=20card=20?= =?UTF-8?q?styling=20+=20scroll-driven=20gear=20menu=20(Frame/Redact=20fil?= =?UTF-8?q?ter)=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/apps/epic/static/apps/epic/room-scroll.js | 81 +++++++++++++++++++ src/apps/epic/tests/integrated/test_views.py | 29 +++++++ src/functional_tests/test_game_room_scroll.py | 67 ++++++++++++++- src/static_src/scss/_room.scss | 54 ++++++++----- .../apps/gameboard/_partials/_room_gear.html | 43 +++++++--- .../gameboard/_partials/_room_scroll.html | 35 +++----- src/templates/apps/gameboard/room.html | 5 +- 7 files changed, 256 insertions(+), 58 deletions(-) create mode 100644 src/apps/epic/static/apps/epic/room-scroll.js diff --git a/src/apps/epic/static/apps/epic/room-scroll.js b/src/apps/epic/static/apps/epic/room-scroll.js new file mode 100644 index 0000000..9391477 --- /dev/null +++ b/src/apps/epic/static/apps/epic/room-scroll.js @@ -0,0 +1,81 @@ +// Room scroll-of-events behaviors (the table-hex aperture's 2nd snap pane): +// 1. animate the "What happens next . . ?" buffer dots, +// 2. swap the gear menu by scroll position — the hex view keeps the default +// NVM/DEL/BYE pane; scrolled to the feed it shows the Frame/Redact filter, +// 3. the Frame/Redact filter itself (per-room localStorage; mirrors the +// Billscroll page's scroll.html filter so the two surfaces behave alike). +// All guards no-op on surfaces without a scroll pane (gate phase, room_gate). +(function () { + var scroll = document.getElementById('id_drama_scroll'); + if (!scroll) return; + + // 1 ── buffer dots ────────────────────────────────────────────────────── + var dotsWrap = scroll.querySelector('.scroll-buffer-dots'); + if (dotsWrap) { + 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); + } + + // 2 ── scroll-driven gear menu swap ───────────────────────────────────── + var aperture = document.getElementById('id_room_aperture'); + var roomMenu = document.getElementById('id_room_menu'); + var scrollSection = aperture && aperture.querySelector('.room-scroll-pane'); + var defaultPane = roomMenu && roomMenu.querySelector('.room-menu-default'); + var filterPane = roomMenu && roomMenu.querySelector('.room-menu-scroll'); + if (aperture && scrollSection && defaultPane && filterPane) { + var io = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { + if (e.target !== scrollSection) return; + var onScroll = e.intersectionRatio >= 0.5; + // .room-menu-default is display:contents in CSS so the toggle + // doesn't disturb the default controls' layout. + defaultPane.style.display = onScroll ? 'none' : 'contents'; + filterPane.style.display = onScroll ? '' : 'none'; + }); + }, { root: aperture, threshold: [0, 0.5, 1] }); + io.observe(scrollSection); + } + + // 3 ── Frame/Redact filter ────────────────────────────────────────────── + var form = document.getElementById('id_scroll_filter_form'); + if (!form) return; + var page = document.querySelector('.room-page'); + var STORAGE_KEY = 'room-scroll-labels-' + (page ? page.dataset.roomId : ''); + + function applyFilter(checked) { + scroll.querySelectorAll('.drama-event[data-label]').forEach(function (el) { + el.style.display = checked.indexOf(el.dataset.label) !== -1 ? '' : 'none'; + }); + } + function syncCheckboxes(checked) { + form.querySelectorAll('input[name="labels"]').forEach(function (cb) { + cb.checked = checked.indexOf(cb.value) !== -1; + }); + } + + try { + var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); + if (saved) { applyFilter(saved); syncCheckboxes(saved); } + } catch (_) { /* localStorage unavailable — non-fatal */ } + + form.addEventListener('submit', function (e) { + e.preventDefault(); + var checked = Array.prototype.slice.call( + form.querySelectorAll('input[name="labels"]:checked') + ).map(function (cb) { return cb.value; }); + applyFilter(checked); + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(checked)); } catch (_) {} + // Close the gear menu (mirror applets.js close logic). + if (roomMenu) roomMenu.style.display = 'none'; + var gear = document.querySelector( + ".gear-btn[data-menu-target='id_room_menu']" + ); + if (gear) gear.classList.remove('active'); + }); +}()); diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 5c0cf27..25aea0d 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -3354,3 +3354,32 @@ class RoomScrollOfEventsTest(TestCase): ).content.decode() self.assertNotIn("room-scroll-pane", content) self.assertNotIn("is-scrollable", content) + + def test_scroll_pane_uses_applet_box_card_with_room_name(self): + """The feed sits in an `.applet-scroll` card (same %applet-box chrome as + the Billscroll page), with the room name as its rotated title.""" + content = self.client.get(self.url).content.decode() + self.assertIn("applet-scroll", content) + # The rotated

title inside the card carries the room name. + self.assertIn("

Whataburgher

", content) + + def test_scroll_gear_filter_renders_in_table_phase(self): + """From Role Select onwards the gear menu carries BOTH panes: the + default NVM/DEL/BYE (`.room-menu-default`) and the Frame/Redact log + filter (`.room-menu-scroll`), swapped by scroll position client-side.""" + content = self.client.get(self.url).content.decode() + self.assertIn("room-menu-default", content) + self.assertIn("room-menu-scroll", content) + self.assertIn("id_scroll_filter_form", content) + self.assertIn('value="frame"', content) + self.assertIn('value="redact"', content) + + def test_scroll_gear_filter_absent_in_gate_phase(self): + """The gate phase has no scroll, so no Frame/Redact filter pane — the + gear keeps only its NVM/DEL/BYE menu.""" + 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-menu-scroll", content) + self.assertNotIn("id_scroll_filter_form", content) diff --git a/src/functional_tests/test_game_room_scroll.py b/src/functional_tests/test_game_room_scroll.py index ee424d4..e08351b 100644 --- a/src/functional_tests/test_game_room_scroll.py +++ b/src/functional_tests/test_game_room_scroll.py @@ -26,13 +26,19 @@ class RoomScrollOfEventsTest(FunctionalTest): ["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)." + # A deposit line for the feed (data-label="frame") — renders via the + # shared scroll partial as "deposits a Carte Blanche for slot 1 …". 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}, ) + # A retracted SIG_READY (struck → data-label="redact") so the Frame / + # Redact filter has both kinds of row to toggle. + GameEvent.objects.create( + room=self.room, actor=self.viewer, verb=GameEvent.SIG_READY, + data={"retracted": True, "card_name": "The Nomad"}, + ) self.room.table_status = Room.ROLE_SELECT self.room.gate_status = Room.OPEN self.room.save() @@ -93,3 +99,60 @@ class RoomScrollOfEventsTest(FunctionalTest): "deposits a Carte Blanche for slot 1", self.browser.find_element(By.ID, "id_drama_scroll").text, ) + + def _open_gear(self): + gear = self.browser.find_element( + By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_room_menu']") + self.browser.execute_script("arguments[0].click();", gear) + + def test_gear_swaps_to_filter_when_scrolled_to_feed(self): + """At the hex the gear shows the room menu (NVM/DEL/BYE); scrolled to + the feed it swaps to the Frame/Redact filter.""" + self._open() + # Hex view: open the gear → the room menu is visible, the filter isn't. + self._open_gear() + self.wait_for(lambda: self.assertTrue( + self.browser.find_element( + By.CSS_SELECTOR, "#id_room_menu .room-menu-default a.btn-cancel" + ).is_displayed())) + self.assertFalse( + self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed()) + + # Scroll to the feed → the gear live-swaps to the filter pane. + self.browser.execute_script( + "document.querySelector('.room-aperture').scrollTop = 99999;") + self.wait_for(lambda: self.assertTrue( + self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed())) + self.assertFalse( + self.browser.find_element( + By.CSS_SELECTOR, "#id_room_menu .room-menu-default a.btn-cancel" + ).is_displayed()) + + def test_redact_filter_hides_struck_rows(self): + """Unchecking Redact + OK in the scroll-view gear hides the struck + (retracted) rows while the framed ones stay.""" + self._open() + self.browser.execute_script( + "document.querySelector('.room-aperture').scrollTop = 99999;") + self._open_gear() + self.wait_for(lambda: self.assertTrue( + self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed())) + + redact_cb = self.browser.find_element( + By.CSS_SELECTOR, "#id_scroll_filter_form input[value='redact']") + if redact_cb.is_selected(): + self.browser.execute_script("arguments[0].click();", redact_cb) + ok = self.browser.find_element( + By.CSS_SELECTOR, "#id_scroll_filter_form button[type='submit']") + self.browser.execute_script("arguments[0].click();", ok) + + self.wait_for(lambda: self.assertFalse( + self.browser.find_element( + By.CSS_SELECTOR, + "#id_drama_scroll .drama-event[data-label='redact']", + ).is_displayed())) + self.assertTrue( + self.browser.find_element( + By.CSS_SELECTOR, + "#id_drama_scroll .drama-event[data-label='frame']", + ).is_displayed()) diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 86a4f67..8f6272c 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -95,39 +95,57 @@ html.sea-open #id_aperture_fill { display: flex; flex-direction: column; padding: 0.75rem; - background: rgba(var(--duoUser), 1); // matches the aperture / billscroll bg - #id_drama_scroll { + // Same %applet-box card chrome + rotated room-name title (`> h2`) as the + // Billscroll page's .applet-scroll. No --duoUser pane bg — the dark card + // sits straight on the room-page background, matching scroll.html. + .applet-scroll { + @extend %applet-box; 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; + #id_drama_scroll { + flex: 1; + min-height: 0; + overflow-y: auto; display: flex; - justify-content: center; - align-items: baseline; - padding: 2rem 0 1rem; - opacity: 0.4; - font-size: 0.8rem; - text-transform: uppercase; + flex-direction: column; - .scroll-buffer-text { letter-spacing: 0.33em; } + // 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-dots { - display: inline-flex; - letter-spacing: 0; + .scroll-buffer-text { letter-spacing: 0.33em; } - span { display: inline-block; width: 0.7em; text-align: center; } + .scroll-buffer-dots { + display: inline-flex; + letter-spacing: 0; + + span { display: inline-block; width: 0.7em; text-align: center; } + } } } } } +// The gear menu's default pane (NVM/DEL/BYE) is `display:contents` so wrapping +// the existing controls in `.room-menu-default` (for the scroll-view swap) +// leaves their layout untouched. room-scroll.js toggles it to `none` while the +// Frame/Redact filter pane shows, and back to `contents` on the hex. +.room-menu-default { + display: contents; +} + .gate-backdrop { position: fixed; inset: 0; diff --git a/src/templates/apps/gameboard/_partials/_room_gear.html b/src/templates/apps/gameboard/_partials/_room_gear.html index 9910065..b989326 100644 --- a/src/templates/apps/gameboard/_partials/_room_gear.html +++ b/src/templates/apps/gameboard/_partials/_room_gear.html @@ -2,19 +2,38 @@ {# the table-hex URL so NVM returns to the hex, not out to /gameboard/). #} {# Defaults to the gameboard listing — room.html's own gear menu is unchanged.#} {% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %} +{# `scroll_filter` (passed only from room.html's table phase, where the scroll #} +{# pane exists) adds a 2nd gear pane: room-scroll.js swaps the visible pane by #} +{# scroll position — the hex view keeps the default NVM/DEL/BYE; scrolling to #} +{# the Scroll swaps in the Frame/Redact log filter (mirrors the Billscroll #} +{# gear). `.room-menu-default` is `display:contents` so wrapping the existing #} +{# controls doesn't change their layout. #} {% include "apps/applets/_partials/_gear.html" with menu_id="id_room_menu" %} - diff --git a/src/templates/apps/gameboard/_partials/_room_scroll.html b/src/templates/apps/gameboard/_partials/_room_scroll.html index e81ab8a..f803f2e 100644 --- a/src/templates/apps/gameboard/_partials/_room_scroll.html +++ b/src/templates/apps/gameboard/_partials/_room_scroll.html @@ -1,27 +1,12 @@ {# 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" %} - +{# GameEvent stream the Billscroll page shows). Same `.applet-scroll` #} +{# %applet-box card chrome + rotated room-name title as scroll.html. Renders #} +{# the shared `core/_partials/_scroll.html` row list (events / viewer / #} +{# scroll_position come from the view). Behaviors (buffer dots, gear-menu #} +{# swap, Frame/Redact filter) live in room-scroll.js. DRY seam: my_sea will #} +{# include this same partial. #} +
+

{{ room.name }}

+ {% include "core/_partials/_scroll.html" %} +
diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 49ec5a8..4954bc8 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -172,7 +172,9 @@ {# Position-circle tooltip portal — rendered whenever the circles can #} {# (gatekeeper + role-select; the SIG_SELECT phase hides the strip). #} {% include "apps/gameboard/_partials/_position_tooltip.html" %} - {% include "apps/gameboard/_partials/_room_gear.html" %} + {# scroll_filter (table phase only) adds the Frame/Redact gear pane that #} + {# room-scroll.js swaps in when the aperture is scrolled to the feed. #} + {% include "apps/gameboard/_partials/_room_gear.html" with scroll_filter=room.table_status %} {% include "apps/gameboard/_partials/_burger.html" %} {% endblock content %} @@ -192,4 +194,5 @@ + {% endblock scripts %}