Files
python-tdd/src/templates/apps/gameboard/room.html
Disco DeDisco 4a4e60f668 room scroll-of-events: applet-box card styling + scroll-driven gear menu (Frame/Redact filter) — TDD
The room scroll pane now matches scroll.html: the feed sits in a .applet-scroll %applet-box card with the rotated room-name title; dropped the special --duoUser pane bg (the dark card sits on the room-page bg).

Gear menu is now view-aware. #id_room_menu carries two panes: .room-menu-default (the existing NVM/DEL/BYE) + .room-menu-scroll (a Frame/Redact #id_scroll_filter_form, rendered only when the gear include gets scroll_filter — room.html passes scroll_filter=room.table_status). room-scroll.js (NEW) runs an IntersectionObserver on .room-scroll-pane (root=#id_room_aperture): scrolled to the feed -> show the filter pane; back on the hex -> show the default. The filter mirrors scroll.html (per-room localStorage, toggles .drama-event[data-label] display). Buffer-dots animation moved from the inline partial script into room-scroll.js.

Other views keep their own menus, as asked: GATE VIEW (room_gate.html) includes _room_gear.html with nvm_url only (no scroll_filter, no room-scroll.js) -> NVM(->hex)/DEL/BYE; the cross/spread phase is a modal over the hex (scrollTop 0) -> default pane.

Traps: applets.js caches gear.dataset.menuTarget at bind time, so you can't swap a gear's target to a 2nd menu — both panes live in ONE #id_room_menu and JS toggles visibility. .room-menu-default is display:contents so wrapping the existing controls doesn't change their layout (JS toggles none<->contents, not '').

Tests: +3 ITs (RoomScrollOfEventsTest — .applet-scroll card + room-name title, filter pane renders in table phase, filter absent in gate phase); +2 FTs (test_game_room_scroll — gear swaps to filter when scrolled to feed, unchecking Redact+OK hides struck rows). 8 scroll ITs + 4 scroll FTs green; 554 epic ITs/UTs green; gatekeeper DEL+BYE gear FTs green (the .room-menu-default wrap is layout-neutral).

[[project-room-scroll-of-events]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:03:48 -04:00

199 lines
13 KiB
HTML

{% extends "core/base.html" %}
{% load static tooltip_tags %}
{% block title_text %}Gameboard{% endblock title_text %}
{% block header_text %}<span>Game</span><span>room</span>{% endblock header_text %}
{% block content %}
<div class="room-page" data-room-id="{{ room.id }}"
{% if room.table_status %}data-select-role-url="{% url 'epic:select_role' room.id %}"{% endif %}>
<div id="id_aperture_fill"></div>
{# Table-hex aperture — a binary scroll-snap viewport (mirrors my_sky's #}
{# wheel<->form swap): the hex pane is the default view; from Role #}
{# Select onwards a 2nd pane holds the room's provenance feed, revealed #}
{# by scrolling DOWN. `is-scrollable` engages scroll-snap (2 panes), so #}
{# the hex content is replaced entirely by the Scroll (no partial scroll #}
{# — `scroll-snap-stop: always`). DRY seam: my_sea reuses _room_scroll. #}
<div id="id_room_aperture" class="room-aperture{% if room.table_status %} is-scrollable{% endif %}">
<div class="room-pane room-hex-pane">
<div class="room-shell">
<div id="id_game_table" class="room-table">
{# SCAN SIGS advances the whole table past role-select — gated on #}
{# the viewer's token cost being current (a lapsed gamer gets GATE #}
{# VIEW in the center instead; only their own ROLE pick survives). #}
{% if room.table_status == "ROLE_SELECT" and viewer_cost_current %}
<div id="id_pick_sigs_wrap"{% if starter_roles|length < 6 %} style="display:none"{% endif %}>
<form method="POST" action="{% url 'epic:pick_sigs' room.id %}">
{% csrf_token %}
<button id="id_pick_sigs_btn" type="submit" class="btn btn-primary">SCAN<br>SIGS</button>
</form>
</div>
{% endif %}
<div class="room-table-scene">
<div class="table-hex-border">
<div class="table-hex">
<div class="table-center">
{# ROLE card-stack — the gamer's own role pick stays #}
{# available even when their token cost has lapsed #}
{# (deposit-privilege grace, 7d), so it renders OUTSIDE #}
{# the cost gate below. #}
{% if room.table_status == "ROLE_SELECT" and card_stack_state %}
<div class="card-stack" data-state="{{ card_stack_state }}"
data-starter-roles="{{ starter_roles|join:',' }}"
data-user-slots="{{ user_slots|join:',' }}"
data-active-slot="{{ active_slot }}"
data-equipped-deck="{{ equipped_deck_id|default:'' }}">
{% if card_stack_state == "ineligible" %}
<i class="fa-solid fa-ban"></i>
{% endif %}
</div>
{% endif %}
{% if not viewer_cost_current %}
{# Token cost lapsed → GATE VIEW supersedes SCAN SIGS #}
{# / CAST SKY / DRAW SEA / the sig waiting msg. The #}
{# gamer keeps their seat through the renewal grace; #}
{# GATE VIEW routes to the renewal gate-view. Only #}
{# the ROLE pick (above) survives. `<button>` + #}
{# onclick (not `<a>`) — `.btn` doesn't reset serif #}
{# font on anchors. [[feedback-btn-vs-anchor-font- #}
{# family]] #}
<button id="id_room_gate_view_btn" type="button"
class="btn btn-primary"
onclick="window.location.href='{% url 'epic:room_gate' room.id %}'">GATE<br>VIEW</button>
{% else %}
{% if room.table_status == "SKY_SELECT" %}
{% if sky_confirmed %}
<button id="id_pick_sea_btn" class="btn btn-primary">DRAW<br>SEA</button>
{% else %}
<button id="id_pick_sky_btn" class="btn btn-primary">CAST<br>SKY</button>
{% endif %}
{% elif room.table_status == "SIG_SELECT" %}
<button id="id_pick_sky_btn" class="btn btn-primary" style="display:none">CAST<br>SKY</button>
{% if polarity_done %}
<p id="id_hex_waiting_msg">{% if user_polarity == "levity" %}Gravity settling . . .{% else %}Levity appraising . . .{% endif %}</p>
{% endif %}
{% endif %}
{% endif %}
</div>
</div>
</div>
{# Seats — fa-chair layout persists from ROLE_SELECT through SIG_SELECT #}
{% for pos in gate_positions %}
<div class="table-seat{% if pos.role_label in starter_roles %} role-confirmed{% endif %}"
data-slot="{{ pos.slot.slot_number }}" data-role="{{ pos.role_label }}">
<i class="fa-solid fa-chair"></i>
<span class="seat-role-label">{{ pos.role_label }}</span>
{% if pos.role_label in starter_roles %}
<i class="position-status-icon fa-solid fa-circle-check"></i>
{% else %}
<i class="position-status-icon fa-solid fa-ban"></i>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>{# /.room-shell #}
{# Position circles scroll away WITH the hex (they live inside the hex #}
{# pane, not at room-page root). Neither the aperture nor the pane sets #}
{# z-index/transform, so the strip's z-130 still resolves in the root #}
{# stacking context — above the gate/sig overlays — as before. #}
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
{% include "apps/gameboard/_partials/_table_positions.html" %}
{% endif %}
</div>{# /.room-hex-pane #}
{% if room.table_status %}
<div class="room-pane room-scroll-pane">
{% include "apps/gameboard/_partials/_room_scroll.html" %}
</div>
{% endif %}
</div>{# /.room-aperture #}
{# Phase overlays are gated on `viewer_cost_current` too: a lapsed gamer #}
{# gets GATE VIEW in the center, so the SIG/SKY/SEA modals (which embed #}
{# their trigger-btn ids in JS) must not render alongside it. #}
{# Sig Select overlay — suppressed once this gamer's polarity sigs are assigned #}
{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done and viewer_cost_current %}
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
{% endif %}
{# Sky (Pick Sky) overlay — natal chart entry #}
{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %}
{% include "apps/gameboard/_partials/_sky_overlay.html" %}
{% endif %}
{# Sky tooltip: sibling of .sky-overlay, not inside .sky-modal-wrap (which has transform) #}
{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %}
<div id="id_sky_tooltip" class="tt" style="display:none;"></div>
<div id="id_sky_tooltip_2" class="tt" style="display:none;"></div>
{% endif %}
{# Sea (Pick Sea) overlay — Celtic Cross spread entry #}
{% if room.table_status == "SKY_SELECT" and sky_confirmed and viewer_cost_current %}
{% include "apps/gameboard/_partials/_sea_overlay.html" %}
{% endif %}
{# Gamer-needed stub — a seat lapsed past its renewal grace and was #}
{# auto-BYE'd, so the table no longer fills all six. Minimal stub #}
{# until the mid-game re-seat flow lands (_table_positions + #}
{# _gatekeeper are already suppressed for RENEWAL_DUE below). #}
{% if room.gate_status == "RENEWAL_DUE" %}
<div id="id_gamer_needed" class="hex-stub">
<p>A seat opened — awaiting a gamer.</p>
</div>
{% endif %}
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
{# Owner-only invite affordance: handshake btn at the upper-right #}
{# of the right sidebar w. slide-out + autocomplete. Replaces the #}
{# legacy inline `<form action="invite_gamer">` panel. #}
{% if request.user == room.owner %}
{% include "apps/billboard/_partials/_bud_invite_panel.html" %}
{% endif %}
{% endif %}
{% if room.table_status %}
<div id="id_tray_wrap"{% if room.table_status == "ROLE_SELECT" and starter_roles|length < 6 %} class="role-select-phase"{% endif %}>
<div id="id_tray_handle">
<div id="id_tray_grip"></div>
<button id="id_tray_btn" aria-label="Open seat tray">
<i class="fa-solid fa-dice-d20"></i>
</button>
</div>
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}">{% tooltip my_tray_role_tooltip %}</div>{% else %}<div class="tray-cell"></div>{% endif %}{% if my_tray_sig %}<div class="tray-cell tray-sig-card"><div class="sig-stage-card sea-sig-card" aria-label="{{ my_tray_sig.name }}" data-energies="{{ my_tray_sig.energies_json }}" data-operations="{{ my_tray_sig.operations_json }}"><span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>{% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}</div></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
</div>
{% endif %}
{% comment %}
Tray-tooltip portal — sibling of the tray so it sits at room-page root,
not inside the tray's overflow:hidden / mask-image clip. JS populates
innerHTML on hover of .tray-role-card > img (and Phase 2: sig card).
{% endcomment %}
{% if room.table_status %}
<div id="id_tooltip_portal" class="tt" style="display:none;"></div>
{% endif %}
{# Position-circle tooltip portal — rendered whenever the circles can #}
{# (gatekeeper + role-select; the SIG_SELECT phase hides the strip). #}
{% include "apps/gameboard/_partials/_position_tooltip.html" %}
{# scroll_filter (table phase only) adds the Frame/Redact gear pane that #}
{# room-scroll.js swaps in when the aperture is scrolled to the feed. #}
{% include "apps/gameboard/_partials/_room_gear.html" with scroll_filter=room.table_status %}
{% include "apps/gameboard/_partials/_burger.html" %}
</div>
{% endblock content %}
{% block scripts %}
{# Brief module — needed by _bud_invite_panel's OK handler so the #}
{# slide-down banner shows up on a successful gatekeeper invite. #}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
<script src="{% static 'apps/epic/room.js' %}"></script>
<script src="{% static 'apps/epic/gatekeeper.js' %}"></script>
<script src="{% static 'apps/epic/role-select.js' %}"></script>
<script src="{% static 'apps/epic/stage-card.js' %}"></script>
<script src="{% static 'apps/epic/combobox.js' %}"></script>
<script src="{% static 'apps/epic/sig-select.js' %}"></script>
<script src="{% static 'apps/epic/sea.js' %}"></script>
<script src="{% static 'apps/epic/tray.js' %}"></script>
<script src="{% static 'apps/epic/tray-tooltip.js' %}"></script>
<script src="{% static 'apps/epic/position-tooltip.js' %}"></script>
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
<script src="{% static 'apps/epic/room-scroll.js' %}"></script>
{% endblock scripts %}