From cf1965c4391cbc01e6f3703948798969f82eea44 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 2 Jun 2026 01:05:00 -0400 Subject: [PATCH] =?UTF-8?q?game=20room=20title:=20GAME=20ROOM=20=E2=87=84?= =?UTF-8?q?=20GAME=20SCROLL=20reel=20on=20the=20scroll=20aperture=20?= =?UTF-8?q?=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The h2's second slot becomes a two-word vertical reel: GAME stays put, ROOM rests in view, SCROLL is parked one notch below in the slot's bottom fade. room-scroll.js toggles `.is-scroll` on the h2 from the SAME IntersectionObserver that already watches the table-hex aperture's scroll pane — ROOM slides up & out under the navbar line while SCROLL rises out of the page-aperture gradient (reverses on scroll-up). Table-phase only; the gate phase stays a plain GAME ROOM. One translateY drives both orientations. Portrait: the word is a short horizontal row in a short slot. Landscape: writing-mode: vertical-rl (inherited from the rotated gutter wordmark) makes the word a tall letter-column, so the same translateY slides it ALONG the wordmark — the user-chosen landscape behaviour for free. Landscape uses a shallower --gr-fade + a letter inset so the space-between end-letters parked at the slot edges aren't dimmed by the dissolve. Motion is deliberately old & rusty: a single cubic-bezier can overshoot at most once and can't oscillate, so the easing is a CSS linear() curve — stall against the grime, jerk free, clunk PAST the mark, then a damped end-wobble into place. Exposed as --gr-ease / --gr-dur / --gr-fade knobs on .gr-swap. base.html's letter-splitter now also splits the two .gr-word words; the .gr-swap window ships data-letters-split="1" so the splitter skips it (no 'roomscroll' run). Reel SCSS is scoped to .gr-swap/.gr-word; `> span.gr-swap` ties `> span:last-child` at (0,4,3) and wins on later source order [[feedback-scss-import-order-specificity]]. TRAP: libsass does NOT strip `//` comments INSIDE a CSS custom-property value — they leak into the compiled output, making the linear() (hence the whole `transition` shorthand) invalid-at-computed-value-time, which silently resets to 0s/ease (no animation). Keep every annotation OUTSIDE the linear(). [[feedback-libsass-comment-in-custom-property]] Reusable .gr-swap seam: my_sea gets GAME SEA → GAME SCROLL via a one-line header swap once its sea-scroll pane is built (deferred — the sea scroll doesn't exist yet). Tests: 2 ITs (RoomScrollOfEventsTest) — reel markup renders in the table phase, stays plain in the gate phase; 1 FT (test_scroll_swaps_room_title_to_scroll) — scrolling the aperture toggles GAME ROOM ⇄ GAME SCROLL both ways. collectstatic'd room-scroll.js for the FT [[feedback-collectstatic-before-ft]]. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apps/epic/static/apps/epic/room-scroll.js | 17 +++- src/apps/epic/tests/integrated/test_views.py | 29 ++++++ src/functional_tests/test_game_room_scroll.py | 33 +++++++ src/static_src/scss/_base.scss | 96 +++++++++++++++++++ src/templates/apps/gameboard/room.html | 9 +- src/templates/core/base.html | 9 +- 6 files changed, 187 insertions(+), 6 deletions(-) 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 ` + // ` stops, where output > 1 overshoots. + // The phases of the curve below, by stop: + // 0 → 0.02 15% .......... rusty grip, barely budges + // 0.16 25% → 0.8 56% .... breaks free, jerks across + // 1.05 65% → 1.12 71% ... clunks PAST the mark + // 0.965 78% → 1.05 84% + // → 0.99 89% → 1.015 95% → 1 .. damped end-wobble + // CRITICAL: keep NO inline `//` comments INSIDE the + // linear() value — libsass leaves them in the custom- + // property output, which invalidates the whole + // `transition` (it falls back to 0s/ease). Annotate + // ABOVE the declaration only. (Firefox 112+ / 151 here.) + --gr-ease: linear( + 0, 0.015 7%, 0.02 25%, + 0.16 25%, 0.46 41%, 0.8 56%, + 1.15 65%, 1.12 71%, + 0.935 78%, 1.05 84%, 0.99 89%, 1.015 95%, + 1 + ); + --gr-dur: 0.95s; // heavier/slower so the stutter + wobble read + --gr-fade: 16%; // top/bottom dissolve depth + position: relative; + overflow: hidden; + padding: 0; // reset :last-child's padding-inline-start (re-applied on .gr-word) + // Symmetric top/bottom fade so the words dissolve into + // the navbar line (top) + the page aperture (bottom). + // Symmetric → rotation-invariant under landscape's + // rotate(180deg). + mask-image: + linear-gradient(to bottom, + transparent 0, #000 var(--gr-fade), + #000 calc(100% - var(--gr-fade)), transparent 100%); + -webkit-mask-image: + linear-gradient(to bottom, + transparent 0, #000 var(--gr-fade), + #000 calc(100% - var(--gr-fade)), transparent 100%); + } + .gr-word { + position: absolute; + inset: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding-inline-start: 0.4em; // match > span:last-child word gap + transition: transform var(--gr-dur, 0.55s) var(--gr-ease, ease); + will-change: transform; + + // 3-letter base word (e.g. my_sea's SEA) clusters like + // the top-level 3-letter suffixes instead of parking at + // the slot edges. + &[data-letters="3"] { justify-content: space-around; } + } + .gr-word--base { transform: translateY(0); } // resting in view + .gr-word--scroll { transform: translateY(100%); } // parked below the slot + &.is-scroll { + .gr-word--base { transform: translateY(-100%); } // up & out the top + .gr-word--scroll { transform: translateY(0); } // up into view + } } } } @@ -433,6 +516,19 @@ body { // bottom edge of the first span — natural break between words. } + // Title reel in landscape — the rotated wordmark is a tall letter-column + // that fills the gutter slot, so `space-between` parks the first/last + // letter at the very slot edges. Inset the letters (padding-inline = the + // vertical axis in writing-mode: vertical-rl) and use a shallower fade so + // the dissolve lives in those margins instead of dimming resting letters. + // translateY still slides the full slot length ALONG the wordmark. + body .container .row .col-lg-6 h2 > span.gr-swap { + --gr-fade: 7%; + } + body .container .row .col-lg-6 h2 .gr-word { + padding-inline: 7%; // top+bottom inset (vertical-rl) — supersedes the 0.4em start gap + } + // Footer → fixed right sidebar (mirrors navbar approach — explicit right boundary) // Use body #id_footer (specificity 0,1,0,1) to beat base #id_footer (0,1,0,0) // which compiles later in the output and would otherwise override height: 100vh. diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 4954bc8..837f0bf 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -2,7 +2,14 @@ {% load static tooltip_tags %} {% block title_text %}Gameboard{% endblock title_text %} -{% block header_text %}Gameroom{% endblock header_text %} +{# Two-word reel: GAME ROOM rests in view; GAME SCROLL is parked just below #} +{# in the title slot's bottom fade. room-scroll.js toggles `.is-scroll` on #} +{# the

when the table-hex aperture snaps to the provenance feed, and #} +{# the CSS reel slides ROOM up & out / SCROLL up & in (reverses on scroll- #} +{# up). The window span carries `data-letters-split` so base.html's letter- #} +{# splitter skips it and splits the two inner `.gr-word`s instead. Only in #} +{# the table phase (a scroll exists to reveal); the gate phase stays plain. #} +{% block header_text %}Game{% if room.table_status %}roomscroll{% else %}room{% endif %}{% endblock header_text %} {% block content %}
span'); + // Also split the two `.gr-word`s of the GAME ROOM ⇄ GAME SCROLL + // reel (grandchildren of h2, not direct `> span`s) so each rides + // the same per-letter justify-content spread as the top-level + // words. The reel's window span (`.gr-swap`) is a `> span` but + // ships with data-letters-split="1", so the guard below skips it + // (we don't want "roomscroll" split as one run). + var spans = document.querySelectorAll( + '.row .col-lg-6 h2 > span, .row .col-lg-6 h2 .gr-word'); for (var i = 0; i < spans.length; i++) { var span = spans[i]; if (span.dataset.lettersSplit === '1') continue;