diff --git a/src/apps/epic/static/apps/epic/room-scroll.js b/src/apps/epic/static/apps/epic/room-scroll.js index 2a9633d..d3f83f0 100644 --- a/src/apps/epic/static/apps/epic/room-scroll.js +++ b/src/apps/epic/static/apps/epic/room-scroll.js @@ -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 . 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); diff --git a/src/apps/epic/static/apps/epic/room-views.js b/src/apps/epic/static/apps/epic/room-views.js index e4cb4e3..ab87d05 100644 --- a/src/apps/epic/static/apps/epic/room-views.js +++ b/src/apps/epic/static/apps/epic/room-views.js @@ -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: diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 8de36b7..1bb77bc 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -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 diff --git a/src/functional_tests/test_game_room_views.py b/src/functional_tests/test_game_room_views.py index c5191e8..1d3b511 100644 --- a/src/functional_tests/test_game_room_views.py +++ b/src/functional_tests/test_game_room_views.py @@ -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 diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 94fb471..aa08711 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -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; diff --git a/src/templates/apps/gameboard/_partials/_room_gear.html b/src/templates/apps/gameboard/_partials/_room_gear.html index b989326..1aa0f10 100644 --- a/src/templates/apps/gameboard/_partials/_room_gear.html +++ b/src/templates/apps/gameboard/_partials/_room_gear.html @@ -34,6 +34,22 @@ + {# 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. #} + {% endif %} {% include "apps/applets/_partials/_gear.html" with menu_id="id_room_menu" %} diff --git a/src/templates/apps/gameboard/_partials/_room_views.html b/src/templates/apps/gameboard/_partials/_room_views.html index 2887897..e9a276e 100644 --- a/src/templates/apps/gameboard/_partials/_room_views.html +++ b/src/templates/apps/gameboard/_partials/_room_views.html @@ -53,8 +53,11 @@ data-author="{{ request.user|at_handle }}"> {% csrf_token %}
+ {# Canonical post-line placeholder — same as the billboard #} + {# New Post applet (_form.html) + post.html, not a bespoke #} + {# variant. #} + placeholder="Enter a post line" autocomplete="off" required>