game-views carousel: ATLAS/SCROLL/POST/CHAT/PULSE views in the room scroll pane — TDD

Unskips the 8 RED FTs from the prior commit (test_game_room_views.py) and lands the feature beneath them — the room's 2nd vertical snap pane becomes a horizontal scroll-snap carousel of five views, landing on SCROLL (2nd).

Carousel core: _room_views.html (5 .room-view panes) + _room_views_strip.html (root-level icon strip, outside the aperture so it clears the scroll card's fade mask + scroll clip); room-views.js owns the horizontal axis — goToView (authoritative active-state) + an IntersectionObserver backing native swipe; horizontal wheel (deltaX / shift+wheel) advances a view while vertical wheel stays for feed scroll; icon-click snaps; the strip shows only while the views pane is on screen (vertical IO mirrors room-scroll.js). SCROLL still wraps _room_scroll.html, so the existing binary y-snap + provenance feed + GAME ROOM ⇄ GAME SCROLL title reel behave unchanged.

Title reel: the .gr-swap reel gains the four extra view words; the active word is driven by data-active-view on the h2 (set by room-views.js), gated by .is-scroll (room-scroll.js) so ROOM shows at the hex.

POST view: a room-scoped game-table thread. New Post.room FK + KIND_ROOM_THREAD (mirrors Brief.room) + Room.get_thread_post(); epic:room_post AJAX endpoint appends a Line (seated-gamer-gated, dup-rejected) and returns the rendered line partial. _post_line.html extracted from post.html and shared by both surfaces + the endpoint. The composer appends OPTIMISTICALLY (synchronous line so the POST + ATLAS views reflect it the instant OK is clicked, no dependence on the round-trip), then reconciles with the server's authoritative @handle/timestamp render; a rejection rolls the optimistic line back.

ATLAS view: a live client-side merge of the SCROLL provenance rows + the POST thread rows, time-ordered, each row tagged data-source=provenance|post for end-of-sprint per-type styling. Rebuilds from the live DOM on activation + on every composer append. CHAT/PULSE are .room-view-stub placeholders (no backing model yet).

Burger Text sub-btn lights .active on the table (text_btn_active from epic.room_view, unset on every other _burger.html surface) → room-views.js binds its active click to the swipe machine: DOWN to the views pane, RIGHT to Post.

Coverage: 8 carousel FTs green; Jasmine RoomViewsSpec (atlas merge order/stability + row data-source); epic ITs (Room.get_thread_post, carousel markup, room_post endpoint 200/403/400/GET); 1636 ITs/UTs + the existing scroll FT green (no regression).

Gotcha logged: build FormData(form) BEFORE clearing the input on optimistic submit — clearing first captures an empty text field → 400 → the line silently rolls back.

[[project-room-game-views-carousel]] [[project-room-scroll-of-events]] [[project-room-title-scroll-reel-jun02]] [[feedback-jsonfield-exclude-sqlite-null]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-02 13:05:36 -04:00
parent 62743aabd0
commit f036c8f461
21 changed files with 1004 additions and 21 deletions

View File

@@ -364,11 +364,27 @@ 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 { transform: translateY(100%); } // parked below the slot
.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
.gr-word--scroll { transform: translateY(0); } // up into view
.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); }
}
}
}

View File

@@ -94,7 +94,39 @@ html.sea-open #id_aperture_fill {
.room-scroll-pane {
display: flex;
flex-direction: column;
padding: 0.75rem;
// ── Horizontal game-views carousel ────────────────────────────────────
// The pane holds a row of 5 full-width views (ATLAS/SCROLL/POST/CHAT/
// PULSE) on native x scroll-snap, nested inside the aperture's binary y
// snap. room-views.js parks the initial scrollLeft on SCROLL (2nd view).
// The horizontal scrollbar is hidden — the root icon strip is the
// affordance. CRITICAL: no z-index/transform here (root stacking context).
.room-views {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar { width: 0; height: 0; }
}
.room-view {
flex: 0 0 100%;
width: 100%;
height: 100%;
min-width: 0;
box-sizing: border-box;
scroll-snap-align: start;
scroll-snap-stop: always; // hard stop each view — no coasting
display: flex;
flex-direction: column;
padding: 0.75rem;
}
// Same %applet-box card chrome + rotated room-name title (`> h2`) as the
// Billscroll page's .applet-scroll. No --duoUser pane bg — the dark card
@@ -136,6 +168,138 @@ html.sea-open #id_aperture_fill {
}
}
}
// ── POST view — the room-scoped game-table thread ─────────────────────
// Reuses post.html's #id_post_table + composer markup (shared
// _post_line.html). The post styling proper is .post-page-scoped, so the
// essentials are restated here for the carousel card.
.room-view--post {
#id_post_table {
list-style: none;
margin: 0;
padding: 0 0.5rem 0 0;
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: flex-end; // bottom-anchor short threads
.post-line {
display: grid;
grid-template-columns: minmax(4rem, auto) 1fr minmax(3rem, auto);
align-items: baseline;
gap: 0.5rem;
padding: 0.25rem 0;
.post-line-author {
font-weight: bold;
color: rgba(var(--quaUser), 1);
white-space: nowrap;
font-size: 0.85rem;
}
.post-line-text { min-width: 0; overflow-wrap: anywhere; }
.post-line-time {
font-size: 0.75rem;
opacity: 0.5;
text-align: right;
white-space: nowrap;
}
}
.post-line-buffer { flex-shrink: 0; height: 0.25rem; }
}
.post-line-form {
flex-shrink: 0;
margin: 0;
padding-top: 0.25rem;
.composer-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.composer-row input.form-control { flex: 1; min-width: 0; width: auto; }
}
}
// ── ATLAS view — provenance + post-thread merged feed ─────────────────
// room-views.js rebuilds the body from the live SCROLL + POST DOM on
// activation; rows carry data-source so the two streams read distinctly.
.room-atlas-feed {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
.atlas-row {
padding: 0.3rem 0;
font-size: 0.9rem;
border-inline-start: 2px solid transparent;
padding-inline-start: 0.5rem;
.atlas-row-time { font-size: 0.7rem; opacity: 0.5; margin-inline-start: 0.4rem; }
.atlas-row-who { font-weight: bold; color: rgba(var(--quaUser), 1); margin-inline-end: 0.3rem; }
}
// Distinct per-source accent (border tint) — the styling hook the
// contract reserves for end-of-sprint polish.
.atlas-row[data-source="provenance"] { border-inline-start-color: rgba(var(--terUser), 0.6); }
.atlas-row[data-source="post"] { border-inline-start-color: rgba(var(--quaUser), 0.6); }
}
// ── CHAT / PULSE stubs ────────────────────────────────────────────────
.room-view-stub {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
opacity: 0.5;
text-align: center;
i { font-size: 2.4rem; }
}
}
// ── Game-views icon strip ─────────────────────────────────────────────────
// Root-level (sibling of the aperture) + position:fixed so it escapes the
// aperture scroll clip and the scroll card's fade mask — the same root-
// stacking trick as the tooltip portals. Hidden at the hex; room-views.js
// adds `.is-visible` while the views pane is on screen. Sits centred at the
// viewport bottom in BOTH orientations, so it always clears the mask + stays
// within the viewport (landscape gutters are 5rem each → ample room).
.room-views-strip {
display: none;
position: fixed;
bottom: 0.85rem;
left: 50%;
transform: translateX(-50%);
z-index: 140; // above the position strip (130) + the card mask
gap: 0.85rem;
pointer-events: auto;
&.is-visible { display: flex; }
.room-view-icon {
background: none;
border: none;
padding: 0.2rem;
cursor: pointer;
line-height: 1;
font-size: 1.15rem;
color: rgba(var(--secUser), 0.6);
transition: color 0.3s ease, text-shadow 0.3s ease, transform 0.3s ease;
// Active view's icon carries the --ninUser glow (+ --terUser shadow),
// half-again the size; the glow hands off as the carousel snaps.
&.is-active {
color: rgba(var(--ninUser), 1);
text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.9);
transform: scale(1.35);
}
}
}
// The gear menu's default pane (NVM/DEL/BYE) is `display:contents` so wrapping