game-views: per-view gear menus (SCROLL filter / ATLAS sources / disabled stubs); reelhouse POST placeholder DRY
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

Each reelhouse view now has its own gear. The single #id_room_menu swaps panes by the active view (room-views.js owns this now — removed from room-scroll.js, which keeps only the title reel):

- hex → default NVM/DEL/BYE; SCROLL → the Frame/Redact log filter; ATLAS → a new source-checkbox pane (.room-menu-atlas / #id_atlas_source_form).
- YARN/POST/PULSE → no menu yet: the gear goes .gear-disabled (opacity 0.6) and an active click is swallowed by a capture-phase listener that flashes a --priRd fa-ban (the burger inactive-flash cadence) instead of opening anything.

ATLAS gear: a checkbox per other reelhouse view (Scroll/Post wired + checked; Yarn/Pulse disabled — struck label, an ✗ in a custom box matching the enabled ✓; starting-majuscule labels, capslock stays reel-only). OK persists to localStorage + re-runs buildAtlasFeed, which now gates each source on atlasSources() (scroll→provenance, post→post).

Also: the reelhouse POST composer drops its bespoke 'Speak at the table' placeholder for the canonical 'Enter a post line' (same as the billboard New Post applet's _form.html + post.html).

Verified: 11 carousel FTs (incl. the new per-view-gear FT) + 310 epic ITs (incl. the atlas-menu IT; room_gate shares _room_gear, unaffected) + the scroll-gear regression FTs + Jasmine, all green.

[[project-room-game-views-carousel]] [[feedback-applet-menu-needs-extend]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-02 15:34:34 -04:00
parent 9754f6a54c
commit 73644e226b
7 changed files with 224 additions and 19 deletions

View File

@@ -22,30 +22,20 @@
}, 400);
}
// 2 ── scroll-driven title reel + gear menu swap ────────────────────────
// 2 ── scroll-driven title reel ─────────────────────────────────────────
// The page title (h2) is a reel — toggling `.is-scroll` slides it GAME ROOM
// ⇄ GAME <view>. The gear-menu pane swap used to live here too, but the
// per-view gear (SCROLL/ATLAS menus, disabled YARN/POST/PULSE) is now owned
// by room-views.js, which knows the active reelhouse view.
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');
// The page title (h2) is a two-word reel — toggling `.is-scroll` slides it
// GAME ROOM ⇄ GAME SCROLL. Decoupled from the gear panes so the title
// still swaps even if the gear menu isn't present.
var title = document.querySelector('.row .col-lg-6 h2');
if (aperture && scrollSection) {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.target !== scrollSection) return;
var onScroll = e.intersectionRatio >= 0.5;
// Title reel: GAME ROOM ⇄ GAME SCROLL (CSS slides the words).
if (title) title.classList.toggle('is-scroll', onScroll);
// Gear menu swap — only when both panes are present.
// .room-menu-default is display:contents in CSS so the toggle
// doesn't disturb the default controls' layout.
if (defaultPane && filterPane) {
defaultPane.style.display = onScroll ? 'none' : 'contents';
filterPane.style.display = onScroll ? '' : 'none';
}
if (title) title.classList.toggle('is-scroll', e.intersectionRatio >= 0.5);
});
}, { root: aperture, threshold: [0, 0.5, 1] });
io.observe(scrollSection);

View File

@@ -78,6 +78,41 @@
var suppressIO = false;
var suppressTimer = null;
// ── per-view gear menu ──────────────────────────────────────────────
// Each reelhouse view has its OWN gear: the single #id_room_menu swaps
// panes by the active view (room-scroll.js no longer drives this). SCROLL
// → the Frame/Redact log filter; ATLAS → its source checkboxes; the hex
// → the default NVM/DEL/BYE; YARN/POST/PULSE → no menu yet (the gear goes
// .gear-disabled and an active click flashes a --priRd fa-ban instead).
var gearBtn = document.querySelector(".gear-btn[data-menu-target='id_room_menu']");
var roomMenu = document.getElementById('id_room_menu');
var paneDefault = roomMenu && roomMenu.querySelector('.room-menu-default');
var paneScroll = roomMenu && roomMenu.querySelector('.room-menu-scroll');
var paneAtlas = roomMenu && roomMenu.querySelector('.room-menu-atlas');
var GEARLESS = { yarn: true, post: true, pulse: true };
var onReelhouse = false;
function showPane(pane) {
// .room-menu-default is display:contents (so wrapping doesn't disturb
// its controls' layout); the swap panes are plain block.
if (paneDefault) paneDefault.style.display = pane === paneDefault ? 'contents' : 'none';
if (paneScroll) paneScroll.style.display = pane === paneScroll ? '' : 'none';
if (paneAtlas) paneAtlas.style.display = pane === paneAtlas ? '' : 'none';
}
function closeRoomMenu() {
if (roomMenu) roomMenu.style.display = 'none';
if (gearBtn) gearBtn.classList.remove('active');
}
function updateGear() {
if (!gearBtn) return;
var disabled = onReelhouse && GEARLESS[current];
gearBtn.classList.toggle('gear-disabled', !!disabled);
if (disabled) { closeRoomMenu(); return; }
if (!onReelhouse) showPane(paneDefault);
else if (current === 'scroll') showPane(paneScroll);
else if (current === 'atlas') showPane(paneAtlas);
}
function setActiveView(view) {
current = view;
if (strip) {
@@ -86,6 +121,7 @@
});
}
if (title) title.dataset.activeView = view;
updateGear();
}
// Instant placement (initial land / layout re-assert) — no slide.
@@ -161,19 +197,75 @@
});
// Strip is shown only while the views pane is on screen — vertical
// IO on the scroll pane (mirrors room-scroll.js's title toggle).
// IO on the scroll pane (mirrors room-scroll.js's title toggle). The
// same on/off-the-reelhouse signal drives the gear pane swap (hex →
// default menu; reelhouse → the active view's menu / disabled).
var scrollPane = aperture && aperture.querySelector('.room-scroll-pane');
if (aperture && scrollPane) {
var vio = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.target !== scrollPane) return;
strip.classList.toggle('is-visible', e.intersectionRatio >= 0.5);
onReelhouse = e.intersectionRatio >= 0.5;
strip.classList.toggle('is-visible', onReelhouse);
updateGear();
});
}, { root: aperture, threshold: [0, 0.5, 1] });
vio.observe(scrollPane);
}
}
// 3b ── disabled gear: swallow the click + flash a --priRd fa-ban ─────
// On YARN/POST/PULSE the gear is .gear-disabled. A capture-phase listener
// intercepts the click BEFORE applets.js's bubble handler so the menu
// never opens; instead the gear flashes twice (the burger inactive-flash
// cadence). Enabled gears fall through to applets.js untouched.
function flashGearBan(gear) {
var pulses = 2, onMs = 180, offMs = 120;
(function pulse(n) {
if (n <= 0) return;
gear.classList.add('gear-flash-ban');
setTimeout(function () {
gear.classList.remove('gear-flash-ban');
setTimeout(function () { pulse(n - 1); }, offMs);
}, onMs);
}(pulses));
}
document.addEventListener('click', function (e) {
var hit = e.target.closest(".gear-btn[data-menu-target='id_room_menu']");
if (hit && hit.classList.contains('gear-disabled')) {
e.stopImmediatePropagation();
e.preventDefault();
flashGearBan(hit);
}
}, true);
// 3c ── ATLAS gear: source checkboxes → which feeds merge ─────────────
var atlasForm = document.getElementById('id_atlas_source_form');
if (atlasForm) {
var page = document.querySelector('.room-page');
var ATLAS_KEY = 'room-atlas-sources-' + (page ? page.dataset.roomId : '');
try {
var savedSrc = JSON.parse(localStorage.getItem(ATLAS_KEY) || 'null');
if (savedSrc) {
atlasForm.querySelectorAll('input[name="views"]').forEach(function (cb) {
if (!cb.disabled) cb.checked = savedSrc.indexOf(cb.value) !== -1;
});
}
} catch (_) { /* localStorage unavailable — non-fatal */ }
atlasForm.addEventListener('submit', function (e) {
e.preventDefault();
var on = Array.prototype.slice.call(
atlasForm.querySelectorAll('input[name="views"]:checked')
).map(function (cb) { return cb.value; });
try { localStorage.setItem(ATLAS_KEY, JSON.stringify(on)); } catch (_) {}
buildAtlasFeed(); // re-merge ATLAS with the new source set
closeRoomMenu();
});
}
// Sync the gear to the initial (hex) state.
updateGear();
// 4 ── Text sub-btn swipe machine (burger fan → GAME POST) ───────────
// Drives the reelhouse (the fivefold applet-scroll carousel) to the POST
// view. Two cases:

View File

@@ -3477,6 +3477,18 @@ class RoomViewsCarouselTest(TestCase):
content = self.client.get(self.url).content.decode()
self.assertIn("room-view-stub", content)
def test_atlas_gear_menu_has_source_checkboxes(self):
"""The ATLAS view's gear pane carries a source checkbox per other
reelhouse view; scroll + post are wired (checked), yarn + pulse have no
model yet (disabled)."""
content = self.client.get(self.url).content.decode()
self.assertIn("room-menu-atlas", content)
self.assertIn("id_atlas_source_form", content)
for view in ("scroll", "yarn", "post", "pulse"):
self.assertIn(f'value="{view}"', content)
self.assertRegex(content, r'value="yarn"[^>]*disabled')
self.assertRegex(content, r'value="pulse"[^>]*disabled')
def test_text_btn_active_on_the_table(self):
content = self.client.get(self.url).content.decode()
# The Text sub-btn carries `.active` so the burger fan routes its click

View File

@@ -258,6 +258,36 @@ class GameViewsCarouselTest(FunctionalTest):
self.assertFalse(atlas.find_elements(
By.CSS_SELECTOR, ".atlas-row-body.struck"))
def test_gear_menu_swaps_per_view_and_disables_on_stubs(self):
"""Each reelhouse view has its own gear: SCROLL → the Frame/Redact log
filter, ATLAS → its source checkboxes; YARN/POST/PULSE → disabled (an
active click does not open a menu)."""
self._open()
self._scroll_to_views() # lands on SCROLL, on the reelhouse
gear = self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_room_menu']")
menu = self.browser.find_element(By.ID, "id_room_menu")
# SCROLL view → the Frame/Redact filter.
self.browser.execute_script("arguments[0].click();", gear)
self.wait_for(lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed()))
# ATLAS view → the source checkboxes (the icon-click closes the menu).
self._click_icon("atlas")
self.wait_for(lambda: self.assertTrue(
self._in_viewport(".room-view[data-view='atlas']")))
self.browser.execute_script("arguments[0].click();", gear)
self.wait_for(lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_atlas_source_form").is_displayed()))
# YARN view → gear disabled; an active click does NOT open the menu.
self._click_icon("yarn")
self.wait_for(lambda: self.assertIn(
"gear-disabled", gear.get_attribute("class")))
self.browser.execute_script("arguments[0].click();", gear)
self.assertFalse(menu.is_displayed())
def test_yarn_and_pulse_render_as_stubs(self):
"""YARN (fa-route) + PULSE (fa-chart-pie) are stub views this sprint —
each renders a placeholder, no backing model yet. The watermark icon

View File

@@ -350,6 +350,68 @@ html.sea-open #id_aperture_fill {
display: contents;
}
// ── Per-view gear states (room-views.js) ──────────────────────────────────
// On YARN/POST/PULSE the reelhouse gear has no menu yet → dimmed; an active
// click flashes a --priRd fa-ban (same cadence/colour as the burger inactive
// sub-btns) instead of opening anything.
.gear-btn.gear-disabled { opacity: 0.6; }
.gear-btn.gear-flash-ban {
color: rgba(var(--priRd), 1);
box-shadow:
0 0 0.5rem 0.1rem rgba(var(--priRd), 0.75),
0 0 1.2rem 0.3rem rgba(var(--priRd), 0.35);
// Swap the gear glyph for fa-ban (FA solid \f05e) for the duration of the
// flash — the "nothing here" signal, mirroring the burger fan.
.fa-gear::before { content: "\f05e"; }
}
// ── ATLAS gear: source checkboxes ─────────────────────────────────────────
// Custom boxes so the disabled (no-model-yet) sources can show an ✗ that reads
// like the enabled ✓ — same box, different mark — with a struck, dimmed label.
// Labels stay lowercase (capslock is reel-only).
.room-menu-atlas {
.atlas-source-form { display: flex; flex-direction: column; gap: 0.3rem; }
.atlas-source {
display: flex;
align-items: center;
gap: 0.4rem;
text-transform: none;
cursor: pointer;
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
margin: 0;
width: 1em;
height: 1em;
flex-shrink: 0;
border: 0.1rem solid rgba(var(--terUser), 0.7);
border-radius: 0.15rem;
display: inline-grid;
place-content: center;
cursor: pointer;
line-height: 1;
&::before { content: ""; font-size: 0.8em; }
&:checked::before { content: ""; color: rgba(var(--terUser), 1); }
}
// Disabled source (yarn/pulse — no backing model): dim + struck NAME
// (not the box, so the ✗ stays legible) + an ✗ in the box.
&:has(input:disabled) {
opacity: 0.55;
cursor: default;
input[type="checkbox"] {
cursor: default;
border-color: rgba(var(--secUser), 0.7);
&::before { content: ""; color: rgba(var(--secUser), 1); }
}
.atlas-source-name { text-decoration: line-through; }
}
}
}
.gate-backdrop {
position: fixed;
inset: 0;

View File

@@ -34,6 +34,22 @@
</div>
</form>
</div>
{# ATLAS view's own gear: which reelhouse sources merge into the feed. Only #}
{# scroll + post are wired sources; yarn + pulse have no model yet, so their #}
{# checkboxes are disabled (struck label, an ✗ in the box). Starting- #}
{# majuscule labels (like Frame/Redact) — only the banner reel is capslock. #}
<div class="room-menu-atlas" style="display:none">
<form id="id_atlas_source_form" class="atlas-source-form">
<label class="atlas-source"><input type="checkbox" name="views" value="scroll" checked> <span class="atlas-source-name">Scroll</span></label>
<label class="atlas-source"><input type="checkbox" name="views" value="yarn" disabled> <span class="atlas-source-name">Yarn</span></label>
<label class="atlas-source"><input type="checkbox" name="views" value="post" checked> <span class="atlas-source-name">Post</span></label>
<label class="atlas-source"><input type="checkbox" name="views" value="pulse" disabled> <span class="atlas-source-name">Pulse</span></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" %}

View File

@@ -53,8 +53,11 @@
data-author="{{ request.user|at_handle }}">
{% csrf_token %}
<div class="composer-row">
{# Canonical post-line placeholder — same as the billboard #}
{# New Post applet (_form.html) + post.html, not a bespoke #}
{# variant. #}
<input id="id_post_line_text" name="text" class="form-control"
placeholder="Speak at the table" autocomplete="off" required>
placeholder="Enter a post line" autocomplete="off" required>
<button type="submit" id="id_post_line_btn" class="btn btn-confirm">OK</button>
</div>
<div id="id_post_line_feedback" class="invalid-feedback"></div>