game-views: per-view gear menus (SCROLL filter / ATLAS sources / disabled stubs); reelhouse POST placeholder DRY
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:
@@ -22,30 +22,20 @@
|
|||||||
}, 400);
|
}, 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 aperture = document.getElementById('id_room_aperture');
|
||||||
var roomMenu = document.getElementById('id_room_menu');
|
|
||||||
var scrollSection = aperture && aperture.querySelector('.room-scroll-pane');
|
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');
|
var title = document.querySelector('.row .col-lg-6 h2');
|
||||||
if (aperture && scrollSection) {
|
if (aperture && scrollSection) {
|
||||||
var io = new IntersectionObserver(function (entries) {
|
var io = new IntersectionObserver(function (entries) {
|
||||||
entries.forEach(function (e) {
|
entries.forEach(function (e) {
|
||||||
if (e.target !== scrollSection) return;
|
if (e.target !== scrollSection) return;
|
||||||
var onScroll = e.intersectionRatio >= 0.5;
|
|
||||||
// Title reel: GAME ROOM ⇄ GAME SCROLL (CSS slides the words).
|
// Title reel: GAME ROOM ⇄ GAME SCROLL (CSS slides the words).
|
||||||
if (title) title.classList.toggle('is-scroll', onScroll);
|
if (title) title.classList.toggle('is-scroll', e.intersectionRatio >= 0.5);
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, { root: aperture, threshold: [0, 0.5, 1] });
|
}, { root: aperture, threshold: [0, 0.5, 1] });
|
||||||
io.observe(scrollSection);
|
io.observe(scrollSection);
|
||||||
|
|||||||
@@ -78,6 +78,41 @@
|
|||||||
var suppressIO = false;
|
var suppressIO = false;
|
||||||
var suppressTimer = null;
|
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) {
|
function setActiveView(view) {
|
||||||
current = view;
|
current = view;
|
||||||
if (strip) {
|
if (strip) {
|
||||||
@@ -86,6 +121,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (title) title.dataset.activeView = view;
|
if (title) title.dataset.activeView = view;
|
||||||
|
updateGear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instant placement (initial land / layout re-assert) — no slide.
|
// 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
|
// 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');
|
var scrollPane = aperture && aperture.querySelector('.room-scroll-pane');
|
||||||
if (aperture && scrollPane) {
|
if (aperture && scrollPane) {
|
||||||
var vio = new IntersectionObserver(function (entries) {
|
var vio = new IntersectionObserver(function (entries) {
|
||||||
entries.forEach(function (e) {
|
entries.forEach(function (e) {
|
||||||
if (e.target !== scrollPane) return;
|
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] });
|
}, { root: aperture, threshold: [0, 0.5, 1] });
|
||||||
vio.observe(scrollPane);
|
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) ───────────
|
// 4 ── Text sub-btn swipe machine (burger fan → GAME POST) ───────────
|
||||||
// Drives the reelhouse (the fivefold applet-scroll carousel) to the POST
|
// Drives the reelhouse (the fivefold applet-scroll carousel) to the POST
|
||||||
// view. Two cases:
|
// view. Two cases:
|
||||||
|
|||||||
@@ -3477,6 +3477,18 @@ class RoomViewsCarouselTest(TestCase):
|
|||||||
content = self.client.get(self.url).content.decode()
|
content = self.client.get(self.url).content.decode()
|
||||||
self.assertIn("room-view-stub", content)
|
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):
|
def test_text_btn_active_on_the_table(self):
|
||||||
content = self.client.get(self.url).content.decode()
|
content = self.client.get(self.url).content.decode()
|
||||||
# The Text sub-btn carries `.active` so the burger fan routes its click
|
# The Text sub-btn carries `.active` so the burger fan routes its click
|
||||||
|
|||||||
@@ -258,6 +258,36 @@ class GameViewsCarouselTest(FunctionalTest):
|
|||||||
self.assertFalse(atlas.find_elements(
|
self.assertFalse(atlas.find_elements(
|
||||||
By.CSS_SELECTOR, ".atlas-row-body.struck"))
|
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):
|
def test_yarn_and_pulse_render_as_stubs(self):
|
||||||
"""YARN (fa-route) + PULSE (fa-chart-pie) are stub views this sprint —
|
"""YARN (fa-route) + PULSE (fa-chart-pie) are stub views this sprint —
|
||||||
each renders a placeholder, no backing model yet. The watermark icon
|
each renders a placeholder, no backing model yet. The watermark icon
|
||||||
|
|||||||
@@ -350,6 +350,68 @@ html.sea-open #id_aperture_fill {
|
|||||||
display: contents;
|
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 {
|
.gate-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -34,6 +34,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% include "apps/applets/_partials/_gear.html" with menu_id="id_room_menu" %}
|
{% include "apps/applets/_partials/_gear.html" with menu_id="id_room_menu" %}
|
||||||
|
|||||||
@@ -53,8 +53,11 @@
|
|||||||
data-author="{{ request.user|at_handle }}">
|
data-author="{{ request.user|at_handle }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="composer-row">
|
<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"
|
<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>
|
<button type="submit" id="id_post_line_btn" class="btn btn-confirm">OK</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="id_post_line_feedback" class="invalid-feedback"></div>
|
<div id="id_post_line_feedback" class="invalid-feedback"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user