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 %}