diff --git a/src/apps/epic/static/apps/epic/room-scroll.js b/src/apps/epic/static/apps/epic/room-scroll.js
index 9391477..2a9633d 100644
--- a/src/apps/epic/static/apps/epic/room-scroll.js
+++ b/src/apps/epic/static/apps/epic/room-scroll.js
@@ -22,21 +22,30 @@
}, 400);
}
- // 2 ── scroll-driven gear menu swap ─────────────────────────────────────
+ // 2 ── scroll-driven title reel + 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) {
+ // The page title (h2) is a two-word reel — toggling `.is-scroll` slides it
+ // GAME ROOM ⇄ GAME SCROLL. Decoupled from the gear panes so the title
+ // still swaps even if the gear menu isn't present.
+ var title = document.querySelector('.row .col-lg-6 h2');
+ if (aperture && scrollSection) {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.target !== scrollSection) return;
var onScroll = e.intersectionRatio >= 0.5;
+ // Title reel: GAME ROOM ⇄ GAME SCROLL (CSS slides the words).
+ if (title) title.classList.toggle('is-scroll', onScroll);
+ // Gear menu swap — only when both panes are present.
// .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';
+ if (defaultPane && filterPane) {
+ defaultPane.style.display = onScroll ? 'none' : 'contents';
+ filterPane.style.display = onScroll ? '' : 'none';
+ }
});
}, { root: aperture, threshold: [0, 0.5, 1] });
io.observe(scrollSection);
diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py
index 25aea0d..ccddbc5 100644
--- a/src/apps/epic/tests/integrated/test_views.py
+++ b/src/apps/epic/tests/integrated/test_views.py
@@ -3383,3 +3383,32 @@ class RoomScrollOfEventsTest(TestCase):
).content.decode()
self.assertNotIn("room-menu-scroll", content)
self.assertNotIn("id_scroll_filter_form", content)
+
+ def test_title_is_room_scroll_reel_in_table_phase(self):
+ """The page title is a two-word vertical reel so it can swap GAME ROOM
+ ⇄ GAME SCROLL as the aperture snaps to the feed (client-side via
+ room-scroll.js's `.is-scroll` toggle). Both words ship in the header;
+ CSS slides between them. The window span carries `data-letters-split`
+ so base.html's letter-splitter leaves it alone and splits the two
+ inner `.gr-word`s instead."""
+ content = self.client.get(self.url).content.decode()
+ # Match the rendered class attribute, not the bare token — base.html's
+ # letter-splitter comment mentions `.gr-swap`/`.gr-word` on every page.
+ self.assertIn('class="gr-swap"', content)
+ self.assertIn("gr-word gr-word--base", content)
+ self.assertIn("gr-word gr-word--scroll", content)
+ # The swap-in word reads "scroll" (lowercase in markup; CSS uppercases).
+ self.assertIn(">scroll", content)
+
+ def test_title_reel_absent_in_gate_phase(self):
+ """The gate phase has no scroll pane to reveal, so the title stays a
+ plain GAME ROOM — no reel to slide."""
+ gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
+ content = self.client.get(
+ reverse("epic:gatekeeper", args=[gate_room.id])
+ ).content.decode()
+ # The reel markup is absent (the bare `gr-swap`/`gr-word` tokens still
+ # appear in base.html's splitter comment, so assert on the rendered
+ # class attribute + the unique swap-word marker instead).
+ self.assertNotIn('class="gr-swap"', content)
+ self.assertNotIn("gr-word--scroll", content)
diff --git a/src/functional_tests/test_game_room_scroll.py b/src/functional_tests/test_game_room_scroll.py
index e08351b..e913d65 100644
--- a/src/functional_tests/test_game_room_scroll.py
+++ b/src/functional_tests/test_game_room_scroll.py
@@ -128,6 +128,39 @@ class RoomScrollOfEventsTest(FunctionalTest):
By.CSS_SELECTOR, "#id_room_menu .room-menu-default a.btn-cancel"
).is_displayed())
+ def test_scroll_swaps_room_title_to_scroll(self):
+ """Scrolling the aperture to the feed advances the page-title reel
+ GAME ROOM → GAME SCROLL (the
gains `.is-scroll`, which the reel
+ CSS uses to slide the words); scrolling back to the hex reverses it."""
+ self._open()
+ # Both reel words ship in the header; the window starts at the hex.
+ self.assertTrue(
+ self.browser.find_elements(By.CSS_SELECTOR, ".gr-word--base"))
+ self.assertTrue(
+ self.browser.find_elements(By.CSS_SELECTOR, ".gr-word--scroll"))
+ h2 = self.browser.find_element(By.CSS_SELECTOR, ".row .col-lg-6 h2")
+ self.assertNotIn("is-scroll", h2.get_attribute("class") or "")
+
+ # Scroll down to the feed → the reel advances to GAME SCROLL.
+ self.browser.execute_script(
+ "document.querySelector('.room-aperture').scrollTop = 99999;")
+ self.wait_for(lambda: self.assertIn(
+ "is-scroll",
+ self.browser.find_element(
+ By.CSS_SELECTOR, ".row .col-lg-6 h2"
+ ).get_attribute("class") or "",
+ ))
+
+ # Scroll back up to the hex → the reel reverses to GAME ROOM.
+ self.browser.execute_script(
+ "document.querySelector('.room-aperture').scrollTop = 0;")
+ self.wait_for(lambda: self.assertNotIn(
+ "is-scroll",
+ self.browser.find_element(
+ By.CSS_SELECTOR, ".row .col-lg-6 h2"
+ ).get_attribute("class") or "",
+ ))
+
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."""
diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss
index 607e583..8a40ed0 100644
--- a/src/static_src/scss/_base.scss
+++ b/src/static_src/scss/_base.scss
@@ -277,6 +277,89 @@ body {
> span[data-letters="3"] {
justify-content: space-around;
}
+
+ // ── GAME ROOM ⇄ GAME SCROLL title reel ─────────────────
+ // The second word becomes a two-word vertical reel inside
+ // the 55% title slot. The base word (ROOM) rests in view;
+ // SCROLL is parked one notch below, in the slot's bottom
+ // fade. room-scroll.js toggles `.is-scroll` on this
+ // when the table-hex aperture snaps to the provenance feed:
+ // ROOM slides up & out under the navbar line while SCROLL
+ // rises out of the bottom gradient — one reel, both words
+ // travelling up. Reverses on scroll-up.
+ //
+ // The slide axis is vertical in BOTH orientations: portrait
+ // the word is a short horizontal row; landscape (writing-
+ // mode: vertical-rl, inherited from the rotated wordmark)
+ // the word is a tall letter-column, so the same translateY
+ // slides it ALONG the wordmark — the user-chosen landscape
+ // behaviour falls out for free.
+ > span.gr-swap { // (0,4,3) ties > span:last-child; later source order wins
+ // Motion knobs — TWEAK here. The reel doesn't run on a
+ // single cubic-bezier any more: a bezier is one curve
+ // with two control points, so it can overshoot at most
+ // ONCE and can't oscillate. To make the word feel old &
+ // rusty — stall against the grime, jerk free, then clunk
+ // past its mark and wobble into place — `--gr-ease` is a
+ // CSS linear() easing: a piecewise list of `