game-views: horizontal direction-aware title reel + smooth card slide for lateral nav

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) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-02 14:00:20 -04:00
parent be7a8c17f0
commit 1c7f7d0adf
3 changed files with 96 additions and 29 deletions

View File

@@ -66,6 +66,13 @@
var aperture = document.getElementById('id_room_aperture'); var aperture = document.getElementById('id_room_aperture');
var title = document.querySelector('.row .col-lg-6 h2'); var title = document.querySelector('.row .col-lg-6 h2');
var current = DEFAULT_VIEW; 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) { function setActiveView(view) {
current = view; current = view;
@@ -77,7 +84,8 @@
if (title) title.dataset.activeView = view; 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); var idx = VIEW_ORDER.indexOf(view);
if (idx === -1) return; if (idx === -1) return;
var w = viewsEl.clientWidth; var w = viewsEl.clientWidth;
@@ -85,18 +93,37 @@
setActiveView(view); setActiveView(view);
if (view === 'atlas') buildAtlasFeed(); 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; window.RoomViews.goToView = goToView;
// Land on SCROLL (the default) + sync the strip/title glow. // Land on SCROLL (the default) instantly + sync the strip/title glow.
goToView(DEFAULT_VIEW); placeView(DEFAULT_VIEW);
// Re-assert once layout has fully settled (clientWidth may be 0 if the // Re-assert once layout has fully settled (clientWidth may be 0 if the
// pane hadn't been measured at DOMContentLoaded). // pane hadn't been measured at DOMContentLoaded).
if (document.readyState !== 'complete') { if (document.readyState !== 'complete') {
window.addEventListener('load', function () { goToView(current); }); window.addEventListener('load', function () { placeView(current); });
} }
// 1 ── active-view IntersectionObserver (native swipe/drag) ────────── // 1 ── active-view IntersectionObserver (native swipe/drag) ──────────
var io = new IntersectionObserver(function (entries) { var io = new IntersectionObserver(function (entries) {
if (suppressIO) return;
entries.forEach(function (e) { entries.forEach(function (e) {
if (e.intersectionRatio >= 0.6 && e.target.dataset.view) { if (e.intersectionRatio >= 0.6 && e.target.dataset.view) {
setActiveView(e.target.dataset.view); setActiveView(e.target.dataset.view);

View File

@@ -350,13 +350,10 @@ body {
#000 calc(100% - var(--gr-fade)), transparent 100%); #000 calc(100% - var(--gr-fade)), transparent 100%);
} }
.gr-word { .gr-word {
position: absolute;
inset: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-inline-start: 0.4em; // match > span:last-child word gap 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; will-change: transform;
// 3-letter base word (e.g. my_sea's SEA) clusters like // 3-letter base word (e.g. my_sea's SEA) clusters like
@@ -364,28 +361,62 @@ body {
// the slot edges. // the slot edges.
&[data-letters="3"] { justify-content: space-around; } &[data-letters="3"] { justify-content: space-around; }
} }
// ROOM rests in view at the hex; every view word (SCROLL is // ROOM (base) — standalone overlay word on the VERTICAL reel
// the default) parks one notch below in the bottom fade. // only (hex ⇄ views). Rests in view at the hex; slides up &
.gr-word--base { transform: translateY(0); } // resting in view // out under `.is-scroll`.
.gr-word--scroll, .gr-word--base {
.gr-word--atlas, position: absolute;
.gr-word--post, inset: 0;
.gr-word--chat, transition: transform var(--gr-dur, 0.55s) var(--gr-ease, ease);
.gr-word--pulse { transform: translateY(100%); } // parked below the slot transform: translateY(0);
// 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); }
} }
// 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%); }
} }
} }
} }

View File

@@ -9,7 +9,16 @@
{# up). The window span carries `data-letters-split` so base.html's letter- #} {# 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 #} {# 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. #} {# the table phase (a scroll exists to reveal); the gate phase stays plain. #}
{% block header_text %}<span>Game</span>{% if room.table_status %}<span class="gr-swap" data-letters-split="1"><span class="gr-word gr-word--base">room</span><span class="gr-word gr-word--scroll">scroll</span><span class="gr-word gr-word--atlas">atlas</span><span class="gr-word gr-word--post">post</span><span class="gr-word gr-word--chat">chat</span><span class="gr-word gr-word--pulse">pulse</span></span>{% else %}<span>room</span>{% 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 %}<span>Game</span>{% if room.table_status %}<span class="gr-swap" data-letters-split="1"><span class="gr-word gr-word--base">room</span><span class="gr-views-reel"><span class="gr-views-track"><span class="gr-word gr-word--atlas">atlas</span><span class="gr-word gr-word--scroll">scroll</span><span class="gr-word gr-word--post">post</span><span class="gr-word gr-word--chat">chat</span><span class="gr-word gr-word--pulse">pulse</span></span></span></span>{% else %}<span>room</span>{% endif %}{% endblock header_text %}
{% block content %} {% block content %}
<div class="room-page" data-room-id="{{ room.id }}" <div class="room-page" data-room-id="{{ room.id }}"