From 1c7f7d0adffe5c8d382f0550dc28e268992c8e02 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 2 Jun 2026 14:00:20 -0400 Subject: [PATCH] game-views: horizontal direction-aware title reel + smooth card slide for lateral nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The carousel's lateral nav animated wrong: the h2 reel slid VERTICALLY (old word down, new word up — both visible at once), and a first attempt that put translateX+translateY on one transform produced a DIAGONAL blend on hex->views (ROOM+ATLAS+SCROLL all flashing, always landing on ATLAS). Fix — split the two axes onto NESTED elements so they never blend: - OUTER .gr-views-reel does translateY ONLY, gated on .is-scroll (the hex<->views vertical reel, in lockstep with ROOM sliding up/out). - INNER .gr-views-track does translateX -idx*100% (VIEW_ORDER atlas|scroll|post|chat|pulse), gated on data-active-view ALONE — so the active view's cell sits in the slot at ALL times, including at the hex. Default (pre-JS/unset) = SCROLL. Result: hex<->views is a pure vertical reel that lands on whatever view you left off on (POST returns to POST, no diagonal); lateral nav is a pure horizontal slide — old word out one side, new in from the other, direction from the translateX sign — same rusty linear() sequence as ROOM<->SCROLL, just left-right. Cards: goToView now smooth-scrolls (scrollTo behavior:smooth) instead of jumping scrollLeft, so the five .applet-scroll panes visibly SLIDE; an IO-suppression flag during the programmatic snap keeps the icon glow + reel from jittering through passed-over views (native touch-drag still updates via the IO). Initial land uses an instant placeView (no slide on arrival). Verified: 8 carousel FTs + the GAME ROOM<->SCROLL vertical-reel FT green; a 3-lens adversarial audit (vertical axis / horizontal axis+clipping / repo-wide regression sweep) returned holds with no findings. [[project-room-game-views-carousel]] Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apps/epic/static/apps/epic/room-views.js | 35 ++++++++- src/static_src/scss/_base.scss | 79 ++++++++++++++------ src/templates/apps/gameboard/room.html | 11 ++- 3 files changed, 96 insertions(+), 29 deletions(-) diff --git a/src/apps/epic/static/apps/epic/room-views.js b/src/apps/epic/static/apps/epic/room-views.js index 98244f4..fdce7d3 100644 --- a/src/apps/epic/static/apps/epic/room-views.js +++ b/src/apps/epic/static/apps/epic/room-views.js @@ -66,6 +66,13 @@ var aperture = document.getElementById('id_room_aperture'); var title = document.querySelector('.row .col-lg-6 h2'); var current = DEFAULT_VIEW; + // While a programmatic (smooth) snap is in flight, the active view is + // already known (goToView set it), so suppress the IntersectionObserver + // — else the views the snap PASSES THROUGH would each fire, jittering + // the icon glow + the h2 reel. The IO is left live only for genuine + // native touch/drag swipes (no goToView). + var suppressIO = false; + var suppressTimer = null; function setActiveView(view) { current = view; @@ -77,7 +84,8 @@ if (title) title.dataset.activeView = view; } - function goToView(view) { + // Instant placement (initial land / layout re-assert) — no slide. + function placeView(view) { var idx = VIEW_ORDER.indexOf(view); if (idx === -1) return; var w = viewsEl.clientWidth; @@ -85,18 +93,37 @@ setActiveView(view); if (view === 'atlas') buildAtlasFeed(); } + + // Animated nav — the card SLIDES horizontally (smooth scroll) while the + // h2 reel slides its word the matching direction (CSS, on data-active- + // view). setActiveView fires immediately so the title leads in lockstep; + // the card's native smooth scroll trails just behind. + function goToView(view) { + var idx = VIEW_ORDER.indexOf(view); + if (idx === -1) return; + var w = viewsEl.clientWidth; + if (w > 0) { + suppressIO = true; + clearTimeout(suppressTimer); + suppressTimer = setTimeout(function () { suppressIO = false; }, 700); + viewsEl.scrollTo({ left: idx * w, behavior: 'smooth' }); + } + setActiveView(view); + if (view === 'atlas') buildAtlasFeed(); + } window.RoomViews.goToView = goToView; - // Land on SCROLL (the default) + sync the strip/title glow. - goToView(DEFAULT_VIEW); + // Land on SCROLL (the default) instantly + sync the strip/title glow. + placeView(DEFAULT_VIEW); // Re-assert once layout has fully settled (clientWidth may be 0 if the // pane hadn't been measured at DOMContentLoaded). if (document.readyState !== 'complete') { - window.addEventListener('load', function () { goToView(current); }); + window.addEventListener('load', function () { placeView(current); }); } // 1 ── active-view IntersectionObserver (native swipe/drag) ────────── var io = new IntersectionObserver(function (entries) { + if (suppressIO) return; entries.forEach(function (e) { if (e.intersectionRatio >= 0.6 && e.target.dataset.view) { setActiveView(e.target.dataset.view); diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss index 92b3dea..dd58f86 100644 --- a/src/static_src/scss/_base.scss +++ b/src/static_src/scss/_base.scss @@ -350,13 +350,10 @@ body { #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 @@ -364,28 +361,62 @@ body { // the slot edges. &[data-letters="3"] { justify-content: space-around; } } - // ROOM rests in view at the hex; every view word (SCROLL is - // the default) parks one notch below in the bottom fade. - .gr-word--base { transform: translateY(0); } // resting in view - .gr-word--scroll, - .gr-word--atlas, - .gr-word--post, - .gr-word--chat, - .gr-word--pulse { transform: translateY(100%); } // parked below the slot - // Scrolled to the views pane (room-scroll.js adds `.is-scroll`) - // → ROOM slides up & out and the ACTIVE view's word rises in. - // The active view is carried by `data-active-view` on the h2 - // (room-views.js, the horizontal carousel axis); before JS - // sets it, SCROLL (the landing view) shows. - &.is-scroll { - .gr-word--base { transform: translateY(-100%); } // up & out the top - &:not([data-active-view]) .gr-word--scroll, - &[data-active-view="scroll"] .gr-word--scroll, - &[data-active-view="atlas"] .gr-word--atlas, - &[data-active-view="post"] .gr-word--post, - &[data-active-view="chat"] .gr-word--chat, - &[data-active-view="pulse"] .gr-word--pulse { transform: translateY(0); } + // ROOM (base) — standalone overlay word on the VERTICAL reel + // only (hex ⇄ views). Rests in view at the hex; slides up & + // out under `.is-scroll`. + .gr-word--base { + position: absolute; + inset: 0; + transition: transform var(--gr-dur, 0.55s) var(--gr-ease, ease); + transform: translateY(0); } + // The five view words ride two NESTED elements so the axes + // never blend into a diagonal. OUTER `.gr-views-reel` = the + // VERTICAL axis ONLY (hex ⇄ views): parked below at the hex, + // rises into the slot under `.is-scroll` — in lockstep with + // ROOM sliding up & out. Same rusty reel as before. + .gr-views-reel { + position: absolute; + inset: 0; + transition: transform var(--gr-dur, 0.55s) var(--gr-ease, ease); + transform: translateY(100%); // parked below at the hex + will-change: transform; + } + // INNER `.gr-views-track` = the HORIZONTAL axis ONLY, keyed by + // `data-active-view` (room-views.js) ALONE — NOT `.is-scroll`. + // The active view's cell therefore sits in the slot at ALL + // times, including at the hex, so scrolling hex⇄views moves + // only the reel's translateY (a pure vertical reel that lands + // on whatever view you left off on), while lateral nav moves + // only this translateX (a pure horizontal slide — old word out + // one side, new in from the other; direction = translateX + // sign). Default (pre-JS / no attr) = SCROLL, the landing view. + .gr-views-track { + position: absolute; + inset: 0; + display: flex; + flex-wrap: nowrap; + transition: transform var(--gr-dur, 0.55s) var(--gr-ease, ease); + transform: translateX(-100%); // default = SCROLL (idx 1) + will-change: transform; + + .gr-word { + flex: 0 0 100%; // each cell = one slot width + box-sizing: border-box; + height: 100%; + } + } + &.is-scroll { + .gr-word--base { transform: translateY(-100%); } // up & out + .gr-views-reel { transform: translateY(0); } // rises in + } + // Horizontal cell (VIEW_ORDER atlas|scroll|post|chat|pulse) — + // keyed on the active view ALONE so it holds at the hex too. + &[data-active-view="atlas"] .gr-views-track { transform: translateX(0); } + &[data-active-view="scroll"] .gr-views-track { transform: translateX(-100%); } + &[data-active-view="post"] .gr-views-track { transform: translateX(-200%); } + &[data-active-view="chat"] .gr-views-track { transform: translateX(-300%); } + &[data-active-view="pulse"] .gr-views-track { transform: translateX(-400%); } } } } diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 0ed6a1a..3ffd1c0 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -9,7 +9,16 @@ {# 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 %}roomscrollatlaspostchatpulse{% else %}room{% endif %}{% endblock header_text %} +{% comment %} + Title reel — two axes on NESTED elements so they never blend. ROOM + (`.gr-word--base`) + the outer `.gr-views-reel` ride the VERTICAL reel (hex ⇄ + views, driven by `.is-scroll`). The inner `.gr-views-track` (the five view + words in VIEW_ORDER) rides the HORIZONTAL axis, keyed by `data-active-view` + (set by room-views.js) ALONE — so the active view's cell sits in the slot at + all times, including at the hex. Result: hex⇄views is a pure vertical reel + landing on wherever you left off; lateral nav is a pure horizontal slide + (old word out one side, new in from the other). base.html splits `.gr-word`. +{% endcomment %}{% block header_text %}Game{% if room.table_status %}roomatlasscrollpostchatpulse{% else %}room{% endif %}{% endblock header_text %} {% block content %}