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:
@@ -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
|
// Five horizontal views — ATLAS | SCROLL | POST | CHAT | PULSE — reached by
|
||||||
// scrolling DOWN from the hex, landing on SCROLL (the 2nd). This module owns
|
// 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
|
// 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) ───────────
|
// 4 ── Text sub-btn swipe machine (burger fan → GAME POST) ───────────
|
||||||
// An ACTIVE click runs DOWN to the views pane then RIGHT to Post. The
|
// Drives the reelhouse (the fivefold applet-scroll carousel) to the POST
|
||||||
// burger's delegated handler closes the fan afterwards (it fires on the
|
// view. Two cases:
|
||||||
// ancestor, after this target-phase listener). Inactive clicks fall
|
// • Already reel'd DOWN onto the reelhouse → just slide horizontally to
|
||||||
// through to the burger's 2-pulse flash — guard on `.active` here.
|
// 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');
|
var textBtn = document.getElementById('id_text_btn');
|
||||||
if (textBtn) {
|
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 () {
|
textBtn.addEventListener('click', function () {
|
||||||
if (!textBtn.classList.contains('active')) return;
|
if (!textBtn.classList.contains('active')) return;
|
||||||
if (aperture) aperture.scrollTop = aperture.scrollHeight;
|
if (!aperture || onReelhouse()) {
|
||||||
goToView('post');
|
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;
|
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') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -140,11 +140,19 @@ class GameViewsCarouselTest(FunctionalTest):
|
|||||||
"new WheelEvent('wheel', {deltaX: 120, deltaY: 0, bubbles: true}));")
|
"new WheelEvent('wheel', {deltaX: 120, deltaY: 0, bubbles: true}));")
|
||||||
self.wait_for(lambda: self.assertEqual(self._active_view(), "post"))
|
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;
|
"""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
|
clicking it from the hex runs the swipe machine — first DOWN to the
|
||||||
then RIGHT to the Post view (the 3rd icon)."""
|
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()
|
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.
|
# Burger fan: open it, then click the active Text sub-btn.
|
||||||
self.browser.execute_script(
|
self.browser.execute_script(
|
||||||
"document.getElementById('id_burger_btn').click();")
|
"document.getElementById('id_burger_btn').click();")
|
||||||
@@ -152,9 +160,10 @@ class GameViewsCarouselTest(FunctionalTest):
|
|||||||
self.assertIn("active", text_btn.get_attribute("class"))
|
self.assertIn("active", text_btn.get_attribute("class"))
|
||||||
self.browser.execute_script("arguments[0].click();", text_btn)
|
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.wait_for(lambda: self.assertTrue(
|
||||||
self._in_viewport(".room-view[data-view='post']")))
|
self.browser.find_element(By.ID, "id_room_views_strip").is_displayed()))
|
||||||
self.assertEqual(self._active_view(), "post")
|
self.assertTrue(self._in_viewport("#id_room_views"))
|
||||||
|
|
||||||
def test_post_view_is_room_thread_with_working_composer(self):
|
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
|
"""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):
|
def test_chat_and_pulse_render_as_stubs(self):
|
||||||
"""CHAT (fa-comments) + PULSE (fa-chart-pie) are stub views this sprint —
|
"""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._open()
|
||||||
self._scroll_to_views()
|
self._scroll_to_views()
|
||||||
|
|
||||||
|
def _rect(css):
|
||||||
|
return self.browser.execute_script(
|
||||||
|
"return document.querySelector(arguments[0]).getBoundingClientRect();", css)
|
||||||
|
|
||||||
for view in ("chat", "pulse"):
|
for view in ("chat", "pulse"):
|
||||||
self._click_icon(view)
|
self._click_icon(view)
|
||||||
self.wait_for(lambda v=view: self.assertTrue(
|
self.wait_for(lambda v=view: self.assertTrue(
|
||||||
self._in_viewport(f".room-view[data-view='{v}']")))
|
self._in_viewport(f".room-view[data-view='{v}']")))
|
||||||
self.assertTrue(self.browser.find_element(
|
stub_sel = f".room-view[data-view='{view}'] .room-view-stub"
|
||||||
By.CSS_SELECTOR,
|
self.assertTrue(
|
||||||
f".room-view[data-view='{view}'] .room-view-stub").is_displayed())
|
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):
|
def test_icon_strip_clears_the_aperture_mask_in_landscape(self):
|
||||||
"""In landscape the strip sits at the aperture bottom, above the scroll
|
"""In landscape the strip sits at the aperture bottom, above the scroll
|
||||||
|
|||||||
@@ -71,3 +71,76 @@ describe("RoomViews atlas row rendering", () => {
|
|||||||
expect(row).toContain("<script>"); // who escaped
|
expect(row).toContain("<script>"); // 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -264,6 +264,16 @@ html.sea-open #id_aperture_fill {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
i { font-size: 2.4rem; }
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,3 +71,76 @@ describe("RoomViews atlas row rendering", () => {
|
|||||||
expect(row).toContain("<script>"); // who escaped
|
expect(row).toContain("<script>"); // 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 #}
|
{# ([[project-room-game-views-carousel]]). A horizontal scroll-snap strip of #}
|
||||||
{# five views reached by scrolling DOWN from the hex, landing on SCROLL (2nd). #}
|
{# 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 #}
|
{# 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 #}
|
{# The aperture + this scroller set NO z-index/transform so the root stacking #}
|
||||||
{# context (position strip z-130, overlays) is preserved. #}
|
{# context (position strip z-130, overlays) is preserved. #}
|
||||||
{% load lyric_extras %}
|
{% 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 #}
|
{# ATLAS — provenance + post-thread, time-merged. The feed body is rebuilt #}
|
||||||
{# client-side from the live SCROLL + POST DOM on activation (room-views.js #}
|
{# client-side from the live SCROLL + POST DOM on activation (room-views.js #}
|
||||||
{# buildAtlasFeed), so a just-typed line shows without a reload. #}
|
{# buildAtlasFeed), so a just-typed line shows without a reload. #}
|
||||||
|
|||||||
Reference in New Issue
Block a user