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

@@ -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%); }
}
}
}