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>
This commit is contained in:
81
src/apps/epic/static/apps/epic/room-scroll.js
Normal file
81
src/apps/epic/static/apps/epic/room-scroll.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Room scroll-of-events behaviors (the table-hex aperture's 2nd snap pane):
|
||||
// 1. animate the "What happens next . . ?" buffer dots,
|
||||
// 2. swap the gear menu by scroll position — the hex view keeps the default
|
||||
// NVM/DEL/BYE pane; scrolled to the feed it shows the Frame/Redact filter,
|
||||
// 3. the Frame/Redact filter itself (per-room localStorage; mirrors the
|
||||
// Billscroll page's scroll.html filter so the two surfaces behave alike).
|
||||
// All guards no-op on surfaces without a scroll pane (gate phase, room_gate).
|
||||
(function () {
|
||||
var scroll = document.getElementById('id_drama_scroll');
|
||||
if (!scroll) return;
|
||||
|
||||
// 1 ── buffer dots ──────────────────────────────────────────────────────
|
||||
var dotsWrap = scroll.querySelector('.scroll-buffer-dots');
|
||||
if (dotsWrap) {
|
||||
var dots = dotsWrap.querySelectorAll('span');
|
||||
var n = 0;
|
||||
setInterval(function () {
|
||||
dots.forEach(function (d, i) {
|
||||
d.textContent = i < n ? (i === 3 ? '?' : '.') : '';
|
||||
});
|
||||
n = (n + 1) % 5;
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// 2 ── scroll-driven gear menu swap ─────────────────────────────────────
|
||||
var aperture = document.getElementById('id_room_aperture');
|
||||
var roomMenu = document.getElementById('id_room_menu');
|
||||
var scrollSection = aperture && aperture.querySelector('.room-scroll-pane');
|
||||
var defaultPane = roomMenu && roomMenu.querySelector('.room-menu-default');
|
||||
var filterPane = roomMenu && roomMenu.querySelector('.room-menu-scroll');
|
||||
if (aperture && scrollSection && defaultPane && filterPane) {
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) {
|
||||
if (e.target !== scrollSection) return;
|
||||
var onScroll = e.intersectionRatio >= 0.5;
|
||||
// .room-menu-default is display:contents in CSS so the toggle
|
||||
// doesn't disturb the default controls' layout.
|
||||
defaultPane.style.display = onScroll ? 'none' : 'contents';
|
||||
filterPane.style.display = onScroll ? '' : 'none';
|
||||
});
|
||||
}, { root: aperture, threshold: [0, 0.5, 1] });
|
||||
io.observe(scrollSection);
|
||||
}
|
||||
|
||||
// 3 ── Frame/Redact filter ──────────────────────────────────────────────
|
||||
var form = document.getElementById('id_scroll_filter_form');
|
||||
if (!form) return;
|
||||
var page = document.querySelector('.room-page');
|
||||
var STORAGE_KEY = 'room-scroll-labels-' + (page ? page.dataset.roomId : '');
|
||||
|
||||
function applyFilter(checked) {
|
||||
scroll.querySelectorAll('.drama-event[data-label]').forEach(function (el) {
|
||||
el.style.display = checked.indexOf(el.dataset.label) !== -1 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
function syncCheckboxes(checked) {
|
||||
form.querySelectorAll('input[name="labels"]').forEach(function (cb) {
|
||||
cb.checked = checked.indexOf(cb.value) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||||
if (saved) { applyFilter(saved); syncCheckboxes(saved); }
|
||||
} catch (_) { /* localStorage unavailable — non-fatal */ }
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var checked = Array.prototype.slice.call(
|
||||
form.querySelectorAll('input[name="labels"]:checked')
|
||||
).map(function (cb) { return cb.value; });
|
||||
applyFilter(checked);
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(checked)); } catch (_) {}
|
||||
// Close the gear menu (mirror applets.js close logic).
|
||||
if (roomMenu) roomMenu.style.display = 'none';
|
||||
var gear = document.querySelector(
|
||||
".gear-btn[data-menu-target='id_room_menu']"
|
||||
);
|
||||
if (gear) gear.classList.remove('active');
|
||||
});
|
||||
}());
|
||||
@@ -3354,3 +3354,32 @@ class RoomScrollOfEventsTest(TestCase):
|
||||
).content.decode()
|
||||
self.assertNotIn("room-scroll-pane", content)
|
||||
self.assertNotIn("is-scrollable", content)
|
||||
|
||||
def test_scroll_pane_uses_applet_box_card_with_room_name(self):
|
||||
"""The feed sits in an `.applet-scroll` card (same %applet-box chrome as
|
||||
the Billscroll page), with the room name as its rotated title."""
|
||||
content = self.client.get(self.url).content.decode()
|
||||
self.assertIn("applet-scroll", content)
|
||||
# The rotated <h2> title inside the card carries the room name.
|
||||
self.assertIn("<h2>Whataburgher</h2>", content)
|
||||
|
||||
def test_scroll_gear_filter_renders_in_table_phase(self):
|
||||
"""From Role Select onwards the gear menu carries BOTH panes: the
|
||||
default NVM/DEL/BYE (`.room-menu-default`) and the Frame/Redact log
|
||||
filter (`.room-menu-scroll`), swapped by scroll position client-side."""
|
||||
content = self.client.get(self.url).content.decode()
|
||||
self.assertIn("room-menu-default", content)
|
||||
self.assertIn("room-menu-scroll", content)
|
||||
self.assertIn("id_scroll_filter_form", content)
|
||||
self.assertIn('value="frame"', content)
|
||||
self.assertIn('value="redact"', content)
|
||||
|
||||
def test_scroll_gear_filter_absent_in_gate_phase(self):
|
||||
"""The gate phase has no scroll, so no Frame/Redact filter pane — the
|
||||
gear keeps only its NVM/DEL/BYE menu."""
|
||||
gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
|
||||
content = self.client.get(
|
||||
reverse("epic:gatekeeper", args=[gate_room.id])
|
||||
).content.decode()
|
||||
self.assertNotIn("room-menu-scroll", content)
|
||||
self.assertNotIn("id_scroll_filter_form", content)
|
||||
|
||||
@@ -26,13 +26,19 @@ class RoomScrollOfEventsTest(FunctionalTest):
|
||||
["disco@test.io", "amigo@test.io", "bud@test.io",
|
||||
"pal@test.io", "dude@test.io", "bro@test.io"],
|
||||
)
|
||||
# A deposit line for the feed — renders via the shared scroll partial as
|
||||
# "deposits a Carte Blanche for slot 1 (expires in 7 days)."
|
||||
# A deposit line for the feed (data-label="frame") — renders via the
|
||||
# shared scroll partial as "deposits a Carte Blanche for slot 1 …".
|
||||
GameEvent.objects.create(
|
||||
room=self.room, actor=self.viewer, verb=GameEvent.SLOT_FILLED,
|
||||
data={"token_type": "carte", "token_display": "Carte Blanche",
|
||||
"slot_number": 1, "renewal_days": 7},
|
||||
)
|
||||
# A retracted SIG_READY (struck → data-label="redact") so the Frame /
|
||||
# Redact filter has both kinds of row to toggle.
|
||||
GameEvent.objects.create(
|
||||
room=self.room, actor=self.viewer, verb=GameEvent.SIG_READY,
|
||||
data={"retracted": True, "card_name": "The Nomad"},
|
||||
)
|
||||
self.room.table_status = Room.ROLE_SELECT
|
||||
self.room.gate_status = Room.OPEN
|
||||
self.room.save()
|
||||
@@ -93,3 +99,60 @@ class RoomScrollOfEventsTest(FunctionalTest):
|
||||
"deposits a Carte Blanche for slot 1",
|
||||
self.browser.find_element(By.ID, "id_drama_scroll").text,
|
||||
)
|
||||
|
||||
def _open_gear(self):
|
||||
gear = self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_room_menu']")
|
||||
self.browser.execute_script("arguments[0].click();", gear)
|
||||
|
||||
def test_gear_swaps_to_filter_when_scrolled_to_feed(self):
|
||||
"""At the hex the gear shows the room menu (NVM/DEL/BYE); scrolled to
|
||||
the feed it swaps to the Frame/Redact filter."""
|
||||
self._open()
|
||||
# Hex view: open the gear → the room menu is visible, the filter isn't.
|
||||
self._open_gear()
|
||||
self.wait_for(lambda: self.assertTrue(
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_room_menu .room-menu-default a.btn-cancel"
|
||||
).is_displayed()))
|
||||
self.assertFalse(
|
||||
self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed())
|
||||
|
||||
# Scroll to the feed → the gear live-swaps to the filter pane.
|
||||
self.browser.execute_script(
|
||||
"document.querySelector('.room-aperture').scrollTop = 99999;")
|
||||
self.wait_for(lambda: self.assertTrue(
|
||||
self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed()))
|
||||
self.assertFalse(
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_room_menu .room-menu-default a.btn-cancel"
|
||||
).is_displayed())
|
||||
|
||||
def test_redact_filter_hides_struck_rows(self):
|
||||
"""Unchecking Redact + OK in the scroll-view gear hides the struck
|
||||
(retracted) rows while the framed ones stay."""
|
||||
self._open()
|
||||
self.browser.execute_script(
|
||||
"document.querySelector('.room-aperture').scrollTop = 99999;")
|
||||
self._open_gear()
|
||||
self.wait_for(lambda: self.assertTrue(
|
||||
self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed()))
|
||||
|
||||
redact_cb = self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_scroll_filter_form input[value='redact']")
|
||||
if redact_cb.is_selected():
|
||||
self.browser.execute_script("arguments[0].click();", redact_cb)
|
||||
ok = self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_scroll_filter_form button[type='submit']")
|
||||
self.browser.execute_script("arguments[0].click();", ok)
|
||||
|
||||
self.wait_for(lambda: self.assertFalse(
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"#id_drama_scroll .drama-event[data-label='redact']",
|
||||
).is_displayed()))
|
||||
self.assertTrue(
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"#id_drama_scroll .drama-event[data-label='frame']",
|
||||
).is_displayed())
|
||||
|
||||
@@ -95,39 +95,57 @@ html.sea-open #id_aperture_fill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.75rem;
|
||||
background: rgba(var(--duoUser), 1); // matches the aperture / billscroll bg
|
||||
|
||||
#id_drama_scroll {
|
||||
// Same %applet-box card chrome + rotated room-name title (`> h2`) as the
|
||||
// Billscroll page's .applet-scroll. No --duoUser pane bg — the dark card
|
||||
// sits straight on the room-page background, matching scroll.html.
|
||||
.applet-scroll {
|
||||
@extend %applet-box;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Pin the "What happens next…?" buffer to the bottom when the feed is
|
||||
// short (pure-CSS equivalent of billscroll's rAF marginTop nudge).
|
||||
.scroll-buffer {
|
||||
margin-top: auto;
|
||||
#id_drama_scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: baseline;
|
||||
padding: 2rem 0 1rem;
|
||||
opacity: 0.4;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
flex-direction: column;
|
||||
|
||||
.scroll-buffer-text { letter-spacing: 0.33em; }
|
||||
// Pin the "What happens next…?" buffer to the bottom when the feed
|
||||
// is short (pure-CSS equivalent of billscroll's rAF marginTop nudge).
|
||||
.scroll-buffer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: baseline;
|
||||
padding: 2rem 0 1rem;
|
||||
opacity: 0.4;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
|
||||
.scroll-buffer-dots {
|
||||
display: inline-flex;
|
||||
letter-spacing: 0;
|
||||
.scroll-buffer-text { letter-spacing: 0.33em; }
|
||||
|
||||
span { display: inline-block; width: 0.7em; text-align: center; }
|
||||
.scroll-buffer-dots {
|
||||
display: inline-flex;
|
||||
letter-spacing: 0;
|
||||
|
||||
span { display: inline-block; width: 0.7em; text-align: center; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The gear menu's default pane (NVM/DEL/BYE) is `display:contents` so wrapping
|
||||
// the existing controls in `.room-menu-default` (for the scroll-view swap)
|
||||
// leaves their layout untouched. room-scroll.js toggles it to `none` while the
|
||||
// Frame/Redact filter pane shows, and back to `contents` on the hex.
|
||||
.room-menu-default {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.gate-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -2,19 +2,38 @@
|
||||
{# the table-hex URL so NVM returns to the hex, not out to /gameboard/). #}
|
||||
{# Defaults to the gameboard listing — room.html's own gear menu is unchanged.#}
|
||||
{% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %}
|
||||
{# `scroll_filter` (passed only from room.html's table phase, where the scroll #}
|
||||
{# pane exists) adds a 2nd gear pane: room-scroll.js swaps the visible pane by #}
|
||||
{# scroll position — the hex view keeps the default NVM/DEL/BYE; scrolling to #}
|
||||
{# the Scroll swaps in the Frame/Redact log filter (mirrors the Billscroll #}
|
||||
{# gear). `.room-menu-default` is `display:contents` so wrapping the existing #}
|
||||
{# controls doesn't change their layout. #}
|
||||
<div id="id_room_menu" style="display:none">
|
||||
<a href="{{ nvm_url }}" class="btn btn-cancel">NVM</a>
|
||||
{% if request.user == room.owner %}
|
||||
<form method="POST" action="{% url 'epic:delete_room' room.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger" data-confirm="Delete this room?">DEL</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="{% url 'epic:abandon_room' room.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-abandon" data-confirm="Leave this room?">BYE</button>
|
||||
</form>
|
||||
<div class="room-menu-default">
|
||||
<a href="{{ nvm_url }}" class="btn btn-cancel">NVM</a>
|
||||
{% if request.user == room.owner %}
|
||||
<form method="POST" action="{% url 'epic:delete_room' room.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger" data-confirm="Delete this room?">DEL</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="{% url 'epic:abandon_room' room.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-abandon" data-confirm="Leave this room?">BYE</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if scroll_filter %}
|
||||
<div class="room-menu-scroll" style="display:none">
|
||||
<form id="id_scroll_filter_form">
|
||||
<label><input type="checkbox" name="labels" value="frame" checked> Frame</label>
|
||||
<label><input type="checkbox" name="labels" value="redact" checked> Redact</label>
|
||||
<div class="menu-btns">
|
||||
<button type="submit" class="btn btn-confirm">OK</button>
|
||||
<button type="button" class="btn btn-cancel applet-menu-cancel">NVM</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "apps/applets/_partials/_gear.html" with menu_id="id_room_menu" %}
|
||||
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
{# Room scroll-of-events — the table-hex aperture's 2nd scroll-snap pane. #}
|
||||
{# Scroll DOWN past the hex to read the room's provenance feed (the same #}
|
||||
{# GameEvent stream the Billscroll page shows). Renders the shared #}
|
||||
{# `core/_partials/_scroll.html` row list (events / viewer / scroll_position #}
|
||||
{# come from the view). DRY seam: my_sea will include this same partial. #}
|
||||
{% include "core/_partials/_scroll.html" %}
|
||||
<script>
|
||||
(function () {
|
||||
var scroll = document.getElementById('id_drama_scroll');
|
||||
if (!scroll || scroll.dataset.roomScrollBound) return;
|
||||
scroll.dataset.roomScrollBound = '1';
|
||||
// Animate the "What happens next . . ?" buffer dots (4th span shows '?').
|
||||
// The buffer's bottom-pin is pure CSS (margin-top:auto in the flex column);
|
||||
// only the dot cycle needs JS. No scroll-position persistence here — the
|
||||
// room scroll is an at-a-glance ledger, not a read-tracking feed.
|
||||
var dotsWrap = scroll.querySelector('.scroll-buffer-dots');
|
||||
if (!dotsWrap) return;
|
||||
var dots = dotsWrap.querySelectorAll('span');
|
||||
var n = 0;
|
||||
setInterval(function () {
|
||||
dots.forEach(function (d, i) {
|
||||
d.textContent = i < n ? (i === 3 ? '?' : '.') : '';
|
||||
});
|
||||
n = (n + 1) % 5;
|
||||
}, 400);
|
||||
}());
|
||||
</script>
|
||||
{# GameEvent stream the Billscroll page shows). Same `.applet-scroll` #}
|
||||
{# %applet-box card chrome + rotated room-name title as scroll.html. Renders #}
|
||||
{# the shared `core/_partials/_scroll.html` row list (events / viewer / #}
|
||||
{# scroll_position come from the view). Behaviors (buffer dots, gear-menu #}
|
||||
{# swap, Frame/Redact filter) live in room-scroll.js. DRY seam: my_sea will #}
|
||||
{# include this same partial. #}
|
||||
<div class="applet-scroll room-scroll-card">
|
||||
<h2>{{ room.name }}</h2>
|
||||
{% include "core/_partials/_scroll.html" %}
|
||||
</div>
|
||||
|
||||
@@ -172,7 +172,9 @@
|
||||
{# 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" %}
|
||||
{% include "apps/gameboard/_partials/_room_gear.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 %}
|
||||
@@ -192,4 +194,5 @@
|
||||
<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 %}
|
||||
|
||||
Reference in New Issue
Block a user