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:
@@ -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);
|
||||
|
||||
@@ -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%); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 %}<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 %}
|
||||
<div class="room-page" data-room-id="{{ room.id }}"
|
||||
|
||||
Reference in New Issue
Block a user