room ATLAS: rebuild on the native-swipe (IO) path, not only goToView; drop the dead "atlas gathers" empty-state — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

- Bug (staging, touch only): a finger-swipe onto ATLAS landed blank ("The atlas gathers . . .") though SCROLL + POST had content; opening the ATLAS gear & clicking OK (no option change) made it appear. Desktop never reproduced.
- Cause: the ATLAS feed is a client-side merge of the live SCROLL + POST DOM. buildAtlasFeed ran in placeView (initial land) + goToView (icon-click / horizontal wheel) only. A native swipe is scroll-snap → it reaches the carousel solely through the IntersectionObserver, which called setActiveView('atlas') w. NO build. The gear-OK was the only other path that re-ran the merge.
- Fix: centralise the build in setActiveView — the single chokepoint placeView, goToView, AND the IO all share — so every activation path (incl. swipe) rebuilds; placeView/goToView no longer call buildAtlasFeed directly.
- Removed the empty-state (template + JS): ATLAS is never reached before SCROLL's game-creation "Welcome to <game>!" event, so rows is never bare; an empty render beats a stale placeholder lingering.
- Jasmine: new spec stubs window.IntersectionObserver, fires a synthetic atlas-intersect entry, asserts #id_room_atlas fills from the SCROLL/POST DOM w.o. any goToView or gear-OK (headless can't fire a real intersection — see the swipe-machine note). 473 specs green; the 3 ATLAS carousel FTs green (icon-click path + empty-state removal).

[[feedback-client-view-rebuild-on-io-swipe-path]] [[project-room-game-views-carousel]] [[feedback-headless-delayed-scroll-dropped]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-02 21:42:48 -04:00
parent b243d512e4
commit c00cd151c4
4 changed files with 220 additions and 12 deletions

View File

@@ -122,6 +122,14 @@
}
if (title) title.dataset.activeView = view;
updateGear();
// ATLAS is a client-side merge of the live SCROLL + POST DOM, so it
// must be (re)built on EVERY path that activates it. setActiveView is
// the one chokepoint all three share — placeView (initial land),
// goToView (icon-click / wheel), AND the IntersectionObserver (a
// native finger-swipe, which reaches here with no goToView). Building
// only in the first two left the swipe path blank on touch devices
// ("The atlas gathers . . .") until a gear-OK re-ran the merge.
if (view === 'atlas') buildAtlasFeed();
}
// Instant placement (initial land / layout re-assert) — no slide.
@@ -130,8 +138,7 @@
if (idx === -1) return;
var w = viewsEl.clientWidth;
if (w > 0) viewsEl.scrollLeft = idx * w;
setActiveView(view);
if (view === 'atlas') buildAtlasFeed();
setActiveView(view); // builds ATLAS itself when view === 'atlas'
}
// Animated nav — the card SLIDES horizontally (smooth scroll) while the
@@ -148,8 +155,7 @@
suppressTimer = setTimeout(function () { suppressIO = false; }, 700);
viewsEl.scrollTo({ left: idx * w, behavior: 'smooth' });
}
setActiveView(view);
if (view === 'atlas') buildAtlasFeed();
setActiveView(view); // builds ATLAS itself when view === 'atlas'
}
window.RoomViews.goToView = goToView;
@@ -462,11 +468,10 @@
});
});
}
if (!rows.length) {
atlas.innerHTML =
'<p class="event-empty"><small>The atlas gathers . . .</small></p>';
return;
}
// No empty-state: the ATLAS is never reached before SCROLL has its
// game-creation "Welcome to <game>!" event, so `rows` is never empty
// in practice — and an empty render is preferable to a stale
// placeholder lingering if a merge ever does come back bare.
atlas.innerHTML = mergeAtlasRows(rows).map(renderAtlasRow).join('');
}
window.RoomViews.buildAtlasFeed = buildAtlasFeed;

View File

@@ -153,3 +153,104 @@ describe("RoomViews swipe machine", () => {
expect(activeView()).toBe("scroll");
});
});
// The ATLAS feed is built client-side by merging the live SCROLL + POST DOM, so
// it must be (re)built on EVERY path that makes ATLAS the active view. The
// desktop paths — icon-click / horizontal wheel (goToView) — always did; the
// path a native finger-SWIPE takes did NOT. A swipe reaches the carousel only
// through the IntersectionObserver → setActiveView, which never built the feed,
// so on touch devices ATLAS arrived blank ("The atlas gathers . . .") until a
// gear-OK re-ran the merge. We reproduce the swipe by driving a stubbed
// IntersectionObserver's callback (headless never fires a real intersection
// without real scrolling — see the swipe-machine note above).
describe("RoomViews atlas builds on a native swipe (IntersectionObserver path)", () => {
let aperture, viewsEl, strip, savedIO, observers;
function FakeIO(cb) { this.cb = cb; this.targets = []; observers.push(this); }
FakeIO.prototype.observe = function (el) { this.targets.push(el); };
FakeIO.prototype.unobserve = function () {};
FakeIO.prototype.disconnect = function () {};
function tag(name, attrs, html) {
const n = document.createElement(name);
Object.keys(attrs || {}).forEach(k => n.setAttribute(k, attrs[k]));
if (html != null) n.innerHTML = html;
return n;
}
beforeEach(() => {
observers = [];
savedIO = window.IntersectionObserver;
window.IntersectionObserver = FakeIO;
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" });
// ATLAS — the feed starts EMPTY (no server placeholder); JS fills it.
const atlas = tag("div", { class: "room-view room-view--atlas" });
atlas.dataset.view = "atlas";
atlas.appendChild(tag("div", { id: "id_room_atlas", class: "room-atlas-feed" }));
viewsEl.appendChild(atlas);
// SCROLL — one provenance row to merge.
const scroll = tag("div", { class: "room-view room-view--scroll" });
scroll.dataset.view = "scroll";
scroll.innerHTML =
'<div class="drama-event">' +
'<time class="drama-event-time" datetime="2026-06-02T10:00:00">10:00</time>' +
'<span class="drama-event-body">deposits a Carte Blanche</span></div>';
viewsEl.appendChild(scroll);
// POST — one thread line to merge.
const post = tag("div", { class: "room-view room-view--post" });
post.dataset.view = "post";
post.innerHTML =
'<ul id="id_post_table"><li class="post-line">' +
'<span class="post-line-author">@disco</span>' +
'<span class="post-line-text">door creaks open</span>' +
'<time class="post-line-time" datetime="2026-06-02T10:05:00">10:05</time>' +
'</li></ul>';
viewsEl.appendChild(post);
pane.appendChild(viewsEl);
aperture.appendChild(pane);
document.body.appendChild(aperture);
strip = tag("div", { id: "id_room_views_strip" });
["atlas", "scroll", "post"].forEach(v => {
const ic = tag("button", { class: "room-view-icon" });
ic.dataset.view = v;
strip.appendChild(ic);
});
document.body.appendChild(strip);
window.RoomViews.init(); // binds the carousel + its IOs (stubbed) to the fixture
});
afterEach(() => {
window.IntersectionObserver = savedIO;
[aperture, strip].forEach(n => n && n.remove());
});
// Drive the active-view IO (the one watching the .room-view panes) the way a
// native swipe would — no goToView, no gear interaction.
function swipeTo(view) {
const io = observers.find(o => o.targets.some(t => t.dataset && t.dataset.view));
const target = viewsEl.querySelector(".room-view--" + view);
io.cb([{ target: target, intersectionRatio: 1 }]);
}
it("populates #id_room_atlas on swipe-in — without any goToView or gear-OK", () => {
const atlas = document.getElementById("id_room_atlas");
expect(atlas.innerHTML).toBe(""); // SCROLL is the default land → nothing built yet
swipeTo("atlas"); // a finger-swipe: IO → setActiveView('atlas')
expect(atlas.innerHTML).not.toBe("");
expect(atlas.textContent).toContain("deposits a Carte Blanche");
expect(atlas.textContent).toContain("door creaks open");
expect(atlas.querySelectorAll("[data-source='provenance']").length).toBe(1);
expect(atlas.querySelectorAll("[data-source='post']").length).toBe(1);
});
});

View File

@@ -153,3 +153,104 @@ describe("RoomViews swipe machine", () => {
expect(activeView()).toBe("scroll");
});
});
// The ATLAS feed is built client-side by merging the live SCROLL + POST DOM, so
// it must be (re)built on EVERY path that makes ATLAS the active view. The
// desktop paths — icon-click / horizontal wheel (goToView) — always did; the
// path a native finger-SWIPE takes did NOT. A swipe reaches the carousel only
// through the IntersectionObserver → setActiveView, which never built the feed,
// so on touch devices ATLAS arrived blank ("The atlas gathers . . .") until a
// gear-OK re-ran the merge. We reproduce the swipe by driving a stubbed
// IntersectionObserver's callback (headless never fires a real intersection
// without real scrolling — see the swipe-machine note above).
describe("RoomViews atlas builds on a native swipe (IntersectionObserver path)", () => {
let aperture, viewsEl, strip, savedIO, observers;
function FakeIO(cb) { this.cb = cb; this.targets = []; observers.push(this); }
FakeIO.prototype.observe = function (el) { this.targets.push(el); };
FakeIO.prototype.unobserve = function () {};
FakeIO.prototype.disconnect = function () {};
function tag(name, attrs, html) {
const n = document.createElement(name);
Object.keys(attrs || {}).forEach(k => n.setAttribute(k, attrs[k]));
if (html != null) n.innerHTML = html;
return n;
}
beforeEach(() => {
observers = [];
savedIO = window.IntersectionObserver;
window.IntersectionObserver = FakeIO;
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" });
// ATLAS — the feed starts EMPTY (no server placeholder); JS fills it.
const atlas = tag("div", { class: "room-view room-view--atlas" });
atlas.dataset.view = "atlas";
atlas.appendChild(tag("div", { id: "id_room_atlas", class: "room-atlas-feed" }));
viewsEl.appendChild(atlas);
// SCROLL — one provenance row to merge.
const scroll = tag("div", { class: "room-view room-view--scroll" });
scroll.dataset.view = "scroll";
scroll.innerHTML =
'<div class="drama-event">' +
'<time class="drama-event-time" datetime="2026-06-02T10:00:00">10:00</time>' +
'<span class="drama-event-body">deposits a Carte Blanche</span></div>';
viewsEl.appendChild(scroll);
// POST — one thread line to merge.
const post = tag("div", { class: "room-view room-view--post" });
post.dataset.view = "post";
post.innerHTML =
'<ul id="id_post_table"><li class="post-line">' +
'<span class="post-line-author">@disco</span>' +
'<span class="post-line-text">door creaks open</span>' +
'<time class="post-line-time" datetime="2026-06-02T10:05:00">10:05</time>' +
'</li></ul>';
viewsEl.appendChild(post);
pane.appendChild(viewsEl);
aperture.appendChild(pane);
document.body.appendChild(aperture);
strip = tag("div", { id: "id_room_views_strip" });
["atlas", "scroll", "post"].forEach(v => {
const ic = tag("button", { class: "room-view-icon" });
ic.dataset.view = v;
strip.appendChild(ic);
});
document.body.appendChild(strip);
window.RoomViews.init(); // binds the carousel + its IOs (stubbed) to the fixture
});
afterEach(() => {
window.IntersectionObserver = savedIO;
[aperture, strip].forEach(n => n && n.remove());
});
// Drive the active-view IO (the one watching the .room-view panes) the way a
// native swipe would — no goToView, no gear interaction.
function swipeTo(view) {
const io = observers.find(o => o.targets.some(t => t.dataset && t.dataset.view));
const target = viewsEl.querySelector(".room-view--" + view);
io.cb([{ target: target, intersectionRatio: 1 }]);
}
it("populates #id_room_atlas on swipe-in — without any goToView or gear-OK", () => {
const atlas = document.getElementById("id_room_atlas");
expect(atlas.innerHTML).toBe(""); // SCROLL is the default land → nothing built yet
swipeTo("atlas"); // a finger-swipe: IO → setActiveView('atlas')
expect(atlas.innerHTML).not.toBe("");
expect(atlas.textContent).toContain("deposits a Carte Blanche");
expect(atlas.textContent).toContain("door creaks open");
expect(atlas.querySelectorAll("[data-source='provenance']").length).toBe(1);
expect(atlas.querySelectorAll("[data-source='post']").length).toBe(1);
});
});

View File

@@ -14,9 +14,10 @@
<div class="room-view room-view--atlas" data-view="atlas">
<div class="applet-scroll room-view-card">
<h2>{{ room.name }}</h2>
<div id="id_room_atlas" class="room-atlas-feed">
<p class="event-empty"><small>The atlas gathers . . .</small></p>
</div>
{# Body is filled client-side by room-views.js (buildAtlasFeed) on #}
{# activation — no server placeholder; the ATLAS is never reached #}
{# before SCROLL has its "Welcome to <game>!" creation event. #}
<div id="id_room_atlas" class="room-atlas-feed"></div>
</div>
</div>