game-views: forthcoming watermark no-overlap; reelhouse term; Text swipe-machine DOWN-hold-OVER + Jasmine-tested nav

Three things on the carousel:

- CHAT/PULSE stub: the shared [Feature forthcoming] partial centres itself absolutely, landing on top of the flex-centred watermark icon. Override it to flow (position:static) inside .room-view-stub so the icon keeps the top slot and the label rests below — FT asserts the icon's bottom clears the label's top (no clip).
- Adopt 'reelhouse' as the collective term for the fivefold applet-scroll carousel (class on #id_room_views + comments).
- Text sub-btn swipe machine: when already reel'd down onto the reelhouse, slide straight over to POST (plain goToView); when starting up in the room (the hex), run smooth DOWN to the reelhouse, HOLD 0.5s, then OVER to POST unless already there — the hold beats the two motions apart (DOWN-then-OVER, never diagonal).

Test rework: the from-hex OVER beat is a DELAYED (post-descent + hold) programmatic scroll, which headless Selenium drops/resets (works fine in a real browser), so the end-to-end land-on-POST can't be FT'd. Split it: the FT now asserts the reliable DESCENT beat (Text from the hex reveals the reelhouse + icon strip), and a new Jasmine swipe-machine spec pins the nav DECISION (from hex → POST after the descent+hold; inactive btn → no-op) against a fixture + mocked clock. room-views.js exposes init() so the spec can bind to the fixture.

Verified: 8 carousel FTs + Jasmine (atlas merge + swipe machine) green.

[[project-room-game-views-carousel]] [[feedback-ft-run-discipline]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-02 14:31:35 -04:00
parent 1c7f7d0adf
commit 6f5927083c
6 changed files with 243 additions and 19 deletions

View File

@@ -1,4 +1,5 @@
// Game-views carousel (the table-hex aperture's 2nd vertical snap pane).
// Game-views carousel the "reelhouse" (the fivefold applet-scroll carousel)
// in the table-hex aperture's 2nd vertical snap pane.
// Five horizontal views — ATLAS | SCROLL | POST | CHAT | PULSE — reached by
// scrolling DOWN from the hex, landing on SCROLL (the 2nd). This module owns
// the HORIZONTAL axis; room-scroll.js still owns the vertical hex<->views
@@ -170,17 +171,54 @@
}
}
// 4 ── Text sub-btn swipe machine (burger fan → Post view) ───────────
// An ACTIVE click runs DOWN to the views pane then RIGHT to Post. The
// burger's delegated handler closes the fan afterwards (it fires on the
// ancestor, after this target-phase listener). Inactive clicks fall
// through to the burger's 2-pulse flash — guard on `.active` here.
// 4 ── Text sub-btn swipe machine (burger fan → GAME POST) ───────────
// Drives the reelhouse (the fivefold applet-scroll carousel) to the POST
// view. Two cases:
// • Already reel'd DOWN onto the reelhouse → just slide horizontally to
// POST (the plain smooth goToView, which reads great on its own).
// • Starting UP in the room (at the hex) → run straight DOWN to the
// reelhouse, HOLD 0.5s, then check whether we landed on POST already
// and — only if not — slide horizontally to it. The hold beats the
// two motions apart so it reads as DOWN-then-OVER, never a diagonal.
// Inactive clicks fall through to the burger's 2-pulse flash (guard on
// `.active`); the burger's delegated handler closes the fan after this.
var textBtn = document.getElementById('id_text_btn');
if (textBtn) {
var SWIPE_HOLD_MS = 500;
// Past the halfway mark of the vertical snap → the reelhouse pane is
// the one on screen (hex = 1st pane, reelhouse = 2nd).
function onReelhouse() {
return aperture && aperture.scrollTop > aperture.clientHeight / 2;
}
// Fire cb once the aperture's vertical scroll settles — `scrollend`
// where supported (Firefox 109+), a timed ceiling otherwise.
function afterDescent(cb) {
var done = false;
function fire() {
if (done) return;
done = true;
clearTimeout(fallback);
aperture.removeEventListener('scrollend', fire);
cb();
}
var fallback = setTimeout(fire, 700);
aperture.addEventListener('scrollend', fire);
}
textBtn.addEventListener('click', function () {
if (!textBtn.classList.contains('active')) return;
if (aperture) aperture.scrollTop = aperture.scrollHeight;
goToView('post');
if (!aperture || onReelhouse()) {
goToView('post'); // already down → straight over
return;
}
// From the hex: DOWN, hold, then OVER to POST unless already on it.
aperture.scrollTo({ top: aperture.scrollHeight, behavior: 'smooth' });
afterDescent(function () {
setTimeout(function () {
if (current !== 'post') goToView('post');
}, SWIPE_HOLD_MS);
});
});
}
@@ -314,6 +352,12 @@
window.RoomViews.buildAtlasFeed = buildAtlasFeed;
}
// Exposed so the Jasmine swipe-machine spec can (re-)bind against a fixture
// DOM — the from-hex swipe's delayed programmatic scroll can't be verified
// end-to-end in headless Selenium (the browser drops it), so the nav
// DECISION is unit-tested here instead.
window.RoomViews.init = init;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {

View File

@@ -140,11 +140,19 @@ class GameViewsCarouselTest(FunctionalTest):
"new WheelEvent('wheel', {deltaX: 120, deltaY: 0, bubbles: true}));")
self.wait_for(lambda: self.assertEqual(self._active_view(), "post"))
def test_text_btn_from_hex_lands_on_post_view(self):
def test_text_btn_from_hex_swipes_down_to_the_reelhouse(self):
"""The burger fan's #id_text_btn (fa-keyboard) is active on the table;
clicking it from the hex runs the swipe machine — DOWN to the views pane
then RIGHT to the Post view (the 3rd icon)."""
clicking it from the hex runs the swipe machine — first DOWN to the
reelhouse (the views pane), which brings the icon strip on screen.
The follow-on OVER-to-POST beat is a delayed (post-descent + 0.5s hold)
programmatic scroll that headless Firefox drops, so that nav DECISION is
pinned in the Jasmine swipe-machine spec instead; here we assert the
reliable descent beat + that the swipe was even triggered."""
self._open()
# At the hex the strip is hidden.
self.assertFalse(
self.browser.find_element(By.ID, "id_room_views_strip").is_displayed())
# Burger fan: open it, then click the active Text sub-btn.
self.browser.execute_script(
"document.getElementById('id_burger_btn').click();")
@@ -152,9 +160,10 @@ class GameViewsCarouselTest(FunctionalTest):
self.assertIn("active", text_btn.get_attribute("class"))
self.browser.execute_script("arguments[0].click();", text_btn)
# Swipe DOWN: the reelhouse comes on screen → the icon strip reveals.
self.wait_for(lambda: self.assertTrue(
self._in_viewport(".room-view[data-view='post']")))
self.assertEqual(self._active_view(), "post")
self.browser.find_element(By.ID, "id_room_views_strip").is_displayed()))
self.assertTrue(self._in_viewport("#id_room_views"))
def test_post_view_is_room_thread_with_working_composer(self):
"""The Post view embeds the room-scoped post thread: the #id_post_line_text
@@ -206,16 +215,30 @@ class GameViewsCarouselTest(FunctionalTest):
def test_chat_and_pulse_render_as_stubs(self):
"""CHAT (fa-comments) + PULSE (fa-chart-pie) are stub views this sprint —
each renders a placeholder, no backing model yet."""
each renders a placeholder, no backing model yet. The watermark icon
rests ABOVE the [Feature forthcoming] label (the shared partial centres
itself absolutely, so without the stub override it would overlap the
icon dead-centre)."""
self._open()
self._scroll_to_views()
def _rect(css):
return self.browser.execute_script(
"return document.querySelector(arguments[0]).getBoundingClientRect();", css)
for view in ("chat", "pulse"):
self._click_icon(view)
self.wait_for(lambda v=view: self.assertTrue(
self._in_viewport(f".room-view[data-view='{v}']")))
self.assertTrue(self.browser.find_element(
By.CSS_SELECTOR,
f".room-view[data-view='{view}'] .room-view-stub").is_displayed())
stub_sel = f".room-view[data-view='{view}'] .room-view-stub"
self.assertTrue(
self.browser.find_element(By.CSS_SELECTOR, stub_sel).is_displayed())
# Icon's bottom edge clears the label's top edge — stacked, no clip.
icon = _rect(f"{stub_sel} i")
label = _rect(f"{stub_sel} .forthcoming")
self.assertLessEqual(
icon["bottom"], label["top"],
f"{view} watermark icon overlaps the forthcoming label")
def test_icon_strip_clears_the_aperture_mask_in_landscape(self):
"""In landscape the strip sits at the aperture bottom, above the scroll

View File

@@ -71,3 +71,76 @@ describe("RoomViews atlas row rendering", () => {
expect(row).toContain("&lt;script&gt;"); // who escaped
});
});
// The Text sub-btn swipe machine drives the reelhouse to GAME POST. Its from-
// hex path runs the OVER-to-POST nav on a delayed (post-descent + hold)
// programmatic scroll, which headless Selenium drops — so the nav DECISION is
// pinned here against a fixture + mocked clock, where it doesn't depend on real
// scrolling. (The descent beat itself + plain goToView are covered by the FT.)
describe("RoomViews swipe machine", () => {
let aperture, viewsEl, strip, textBtn;
const VIEWS = ["atlas", "scroll", "post", "chat", "pulse"];
function tag(name, attrs) {
const n = document.createElement(name);
Object.keys(attrs || {}).forEach(k => n.setAttribute(k, attrs[k]));
return n;
}
beforeEach(() => {
jasmine.clock().install();
aperture = tag("div", { id: "id_room_aperture" });
const pane = tag("div", { class: "room-scroll-pane" });
viewsEl = tag("div", { id: "id_room_views", class: "room-views reelhouse" });
VIEWS.forEach(v => {
const view = tag("div", { class: "room-view room-view--" + v });
view.dataset.view = v;
viewsEl.appendChild(view);
});
pane.appendChild(viewsEl);
aperture.appendChild(pane);
document.body.appendChild(aperture);
strip = tag("div", { id: "id_room_views_strip" });
VIEWS.forEach(v => {
const ic = tag("button", { class: "room-view-icon" });
ic.dataset.view = v;
strip.appendChild(ic);
});
document.body.appendChild(strip);
textBtn = tag("button", { id: "id_text_btn", class: "active" });
document.body.appendChild(textBtn);
window.RoomViews.init(); // bind the carousel + swipe machine to the fixture
});
afterEach(() => {
jasmine.clock().uninstall();
[aperture, strip, textBtn].forEach(n => n && n.remove());
});
function activeView() {
const a = strip.querySelector(".room-view-icon.is-active");
return a ? a.dataset.view : null;
}
it("from the hex, an active click navigates to POST after the descent + hold", () => {
aperture.scrollTop = 0; // up in the room (the hex)
textBtn.click();
// It must NOT be on POST mid-descent / mid-hold (the OVER beat waits).
expect(activeView()).not.toBe("post");
jasmine.clock().tick(700); // descent settles (fallback ceiling)
jasmine.clock().tick(500); // the 0.5s hold
expect(activeView()).toBe("post"); // then OVER to POST
});
it("an inactive Text btn does not navigate (falls through to the burger flash)", () => {
window.RoomViews.goToView("scroll");
textBtn.classList.remove("active");
textBtn.click();
jasmine.clock().tick(2000);
expect(activeView()).toBe("scroll");
});
});

View File

@@ -264,6 +264,16 @@ html.sea-open #id_aperture_fill {
text-align: center;
i { font-size: 2.4rem; }
// The shared [Feature forthcoming] partial centres ITSELF absolutely
// (position:absolute + translate -50%/-50%), which lands it dead-centre
// on top of the flex-centred watermark icon. Inside the stub, let it
// flow in the column instead so the icon keeps the top slot and the
// label rests below it (the `gap` separates them) — no overlap/clip.
.forthcoming {
position: static;
transform: none;
}
}
}

View File

@@ -71,3 +71,76 @@ describe("RoomViews atlas row rendering", () => {
expect(row).toContain("&lt;script&gt;"); // who escaped
});
});
// The Text sub-btn swipe machine drives the reelhouse to GAME POST. Its from-
// hex path runs the OVER-to-POST nav on a delayed (post-descent + hold)
// programmatic scroll, which headless Selenium drops — so the nav DECISION is
// pinned here against a fixture + mocked clock, where it doesn't depend on real
// scrolling. (The descent beat itself + plain goToView are covered by the FT.)
describe("RoomViews swipe machine", () => {
let aperture, viewsEl, strip, textBtn;
const VIEWS = ["atlas", "scroll", "post", "chat", "pulse"];
function tag(name, attrs) {
const n = document.createElement(name);
Object.keys(attrs || {}).forEach(k => n.setAttribute(k, attrs[k]));
return n;
}
beforeEach(() => {
jasmine.clock().install();
aperture = tag("div", { id: "id_room_aperture" });
const pane = tag("div", { class: "room-scroll-pane" });
viewsEl = tag("div", { id: "id_room_views", class: "room-views reelhouse" });
VIEWS.forEach(v => {
const view = tag("div", { class: "room-view room-view--" + v });
view.dataset.view = v;
viewsEl.appendChild(view);
});
pane.appendChild(viewsEl);
aperture.appendChild(pane);
document.body.appendChild(aperture);
strip = tag("div", { id: "id_room_views_strip" });
VIEWS.forEach(v => {
const ic = tag("button", { class: "room-view-icon" });
ic.dataset.view = v;
strip.appendChild(ic);
});
document.body.appendChild(strip);
textBtn = tag("button", { id: "id_text_btn", class: "active" });
document.body.appendChild(textBtn);
window.RoomViews.init(); // bind the carousel + swipe machine to the fixture
});
afterEach(() => {
jasmine.clock().uninstall();
[aperture, strip, textBtn].forEach(n => n && n.remove());
});
function activeView() {
const a = strip.querySelector(".room-view-icon.is-active");
return a ? a.dataset.view : null;
}
it("from the hex, an active click navigates to POST after the descent + hold", () => {
aperture.scrollTop = 0; // up in the room (the hex)
textBtn.click();
// It must NOT be on POST mid-descent / mid-hold (the OVER beat waits).
expect(activeView()).not.toBe("post");
jasmine.clock().tick(700); // descent settles (fallback ceiling)
jasmine.clock().tick(500); // the 0.5s hold
expect(activeView()).toBe("post"); // then OVER to POST
});
it("an inactive Text btn does not navigate (falls through to the burger flash)", () => {
window.RoomViews.goToView("scroll");
textBtn.classList.remove("active");
textBtn.click();
jasmine.clock().tick(2000);
expect(activeView()).toBe("scroll");
});
});

View File

@@ -1,4 +1,5 @@
{# Game-views carousel — the table-hex aperture's 2nd vertical snap pane #}
{# Game-views carousel — the "reelhouse": the fivefold applet-scroll carousel #}
{# in the table-hex aperture's 2nd vertical snap pane #}
{# ([[project-room-game-views-carousel]]). A horizontal scroll-snap strip of #}
{# five views reached by scrolling DOWN from the hex, landing on SCROLL (2nd). #}
{# room-views.js sets the initial scrollLeft, drives the icon strip / title #}
@@ -6,7 +7,7 @@
{# The aperture + this scroller set NO z-index/transform so the root stacking #}
{# context (position strip z-130, overlays) is preserved. #}
{% load lyric_extras %}
<div id="id_room_views" class="room-views">
<div id="id_room_views" class="room-views reelhouse">
{# ATLAS — provenance + post-thread, time-merged. The feed body is rebuilt #}
{# client-side from the live SCROLL + POST DOM on activation (room-views.js #}
{# buildAtlasFeed), so a just-typed line shows without a reload. #}