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

@@ -10,8 +10,10 @@ from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from django.template.loader import render_to_string
from django.utils import timezone
from apps.billboard.forms import ExistingPostLineForm
from apps.drama.models import GameEvent, record
from django.db.models import Case, IntegerField, Value, When
@@ -652,9 +654,50 @@ def room_view(request, room_id):
ctx["events"] = room.events.select_related("actor").all()
ctx["viewer"] = request.user
ctx["scroll_position"] = 0
# Game-views carousel (POST view) — the room's single game-table thread.
# Lazy-created on first table-page load; its Lines render inline in the
# POST view (same `_post_line.html` partial as post.html) and the composer
# appends via `epic:room_post`. `text_btn_active` lights the burger fan's
# Text sub-btn (the swipe-machine entry to the Post view) on the table only
# — it is unset on the gatekeeper / other _burger.html surfaces, which keep
# the sub-btn in its inactive flash-stub state.
room_post = room.get_thread_post()
ctx["room_post"] = room_post
ctx["room_post_lines"] = room_post.lines.select_related("author").all()
ctx["text_btn_active"] = True
return render(request, "apps/gameboard/room.html", ctx)
@login_required
def room_post(request, room_id):
"""Append a Line to the room's game-table thread (the POST view of the
game-views carousel) and return the rendered line partial as JSON, so
room-views.js can splice it into `#id_post_table` without a page reload
(the carousel must stay put on the POST view). Only seated gamers may
speak at the table; the validation mirrors post.html's composer
(non-empty + no duplicate line text per thread). GET is a no-op redirect
back to the room."""
room = Room.objects.get(id=room_id)
if request.method != "POST":
return redirect("epic:room", room_id=room_id)
# Only gamers holding a filled seat at this table may post to its thread.
if not room.gate_slots.filter(
gamer=request.user, status=GateSlot.FILLED
).exists():
return HttpResponseForbidden()
post = room.get_thread_post()
form = ExistingPostLineForm(for_post=post, data=request.POST)
if not form.is_valid():
return JsonResponse({"ok": False, "errors": form.errors}, status=400)
line = form.save(author=request.user)
line_html = render_to_string(
"apps/billboard/_partials/_post_line.html",
{"line": line},
request=request,
)
return JsonResponse({"ok": True, "line_html": line_html})
@login_required
def room_gate(request, room_id):
"""Room renewal gate-view — reachable mid-game (unlike `gatekeeper`,