diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index b63b381b..6b35903f 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -128,6 +128,7 @@ jobs:
node test-issue-1438-marker-css-vars.js
node test-issue-1562-observers-summary.js
node test-live.js
+ node test-issue-1532-live-fullscreen.js
node test-xss-escape-sinks.js
node test-preflight-xss-gate.js
@@ -385,6 +386,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1510-live-nav-pin-e2e.js 2>&1 | tee -a e2e-output.txt
+ CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-fullscreen-1572-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
diff --git a/public/app.js b/public/app.js
index f52953c9..09c5fc22 100644
--- a/public/app.js
+++ b/public/app.js
@@ -953,6 +953,14 @@ function navigate() {
}
currentPage = basePage;
+ // #1572 — defensive: ensure body.live-fullscreen is cleared whenever
+ // we navigate to a non-live route. Live page's destroy() also clears
+ // it, but this guards against any boot path (deep-link, restore) that
+ // somehow puts the body into fullscreen state outside /live.
+ if (basePage !== 'live' && document.body) {
+ document.body.classList.remove('live-fullscreen');
+ }
+
const app = document.getElementById('app');
// Pages with fixed-height containers (maps, virtual-scroll, split-panels)
const fixedPages = { packets: 1, nodes: 1, map: 1, live: 1, channels: 1, 'audio-lab': 1 };
diff --git a/public/live.css b/public/live.css
index 70554af0..6bf010f4 100644
--- a/public/live.css
+++ b/public/live.css
@@ -461,6 +461,72 @@
white-space: nowrap;
}
+/* ---- #1532 Live: default-collapsed controls + fullscreen mode ---- */
+
+/* #1532 — `.live-controls` collapses by default at ALL viewports (was
+ ≤768px only). The ⚙ pin reveals the toggles row on demand, parity
+ with the map-controls accordion pattern. Top-level (not media-gated)
+ so it applies on desktop too. */
+.live-controls-toggle,
+.live-fullscreen-toggle { display: inline-flex; }
+.live-controls.is-collapsed .live-controls-body { display: none; }
+
+/* Fullscreen mode (#1532). The body class hides chrome and
+ pins the 3 stat pills top-right so the firework animation is the
+ primary visual surface. The header band, controls row, VCR row,
+ and bottom-nav drop out; the beacon + pkt-count critical strip is
+ inside the header body so it goes with it (LIVE rate stays in
+ the stats row, which remains). */
+body.live-fullscreen .live-header-body { display: none !important; }
+body.live-fullscreen .live-controls-body { display: none !important; }
+body.live-fullscreen .live-controls-toggle,
+body.live-fullscreen .live-header-toggle { display: none !important; }
+body.live-fullscreen .vcr-bar .vcr-controls { display: none !important; }
+body.live-fullscreen .vcr-bar { display: none !important; }
+body.live-fullscreen .bottom-nav { display: none !important; }
+body.live-fullscreen .live-feed,
+body.live-fullscreen .live-legend,
+body.live-fullscreen .legend-toggle-btn,
+body.live-fullscreen .feed-show-btn { display: none !important; }
+
+/* Collapse the header chrome to just the floating stats row pinned
+ top-right. The header background/border drop so the stats pills
+ float over the map. */
+body.live-fullscreen .live-header {
+ position: fixed;
+ top: 12px;
+ right: 12px;
+ left: auto;
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ padding: 0;
+ max-width: none;
+ z-index: 700;
+}
+body.live-fullscreen .live-stats-row {
+ position: fixed;
+ top: 12px;
+ right: 12px;
+ left: auto;
+ z-index: 700;
+ background: color-mix(in srgb, var(--surface-1) 84%, transparent);
+ backdrop-filter: blur(10px);
+ padding: 4px 8px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+}
+
+/* Keep the fullscreen exit affordance reachable: a small chip in the
+ top-left so users can leave the mode without remembering the F key. */
+body.live-fullscreen #liveFullscreenToggle {
+ display: inline-flex !important;
+ position: fixed;
+ top: 12px;
+ left: 12px;
+ z-index: 700;
+}
+
/* ---- Medium breakpoint (#279) + collapse toggles (#1178, #1179) ---- */
@media (max-width: 768px) {
.live-feed { width: 280px; max-height: 200px; }
diff --git a/public/live.js b/public/live.js
index 9923ce98..90dcb422 100644
--- a/public/live.js
+++ b/public/live.js
@@ -1116,6 +1116,10 @@
+
@@ -1889,9 +1893,24 @@
function applyForViewport() {
for (var i = 0; i < pairs.length; i++) {
var p = pairs[i];
- if (narrowMql.matches) {
- // Default collapsed at narrow viewports
- setExpanded(p, false);
+ // #1532 — `liveControls` defaults collapsed at ALL viewports
+ // (previously narrow-only). Operators reveal the toggle row
+ // via the ⚙ pin, parity with map-controls accordion.
+ var defaultCollapsed = (p.rootId === 'liveControls') ? true : false;
+ // Respect the user's prior choice across reloads.
+ if (p.rootId === 'liveControls') {
+ try {
+ var pref = localStorage.getItem('live-controls-expanded');
+ if (pref === 'true') defaultCollapsed = false;
+ if (pref === 'false') defaultCollapsed = true;
+ } catch (_) { /* private browsing */ }
+ }
+ if (narrowMql.matches || defaultCollapsed) {
+ // Default collapsed; preserve existing expansion if user
+ // already opened it this mount.
+ var root = document.getElementById(p.rootId);
+ var alreadyExpanded = root && root.classList.contains('is-expanded');
+ if (!alreadyExpanded) setExpanded(p, false);
} else {
// Always expanded; no hidden attr; no collapse class
var root = document.getElementById(p.rootId);
@@ -1910,6 +1929,11 @@
var root = document.getElementById(p.rootId);
var nowExpanded = !(root && root.classList.contains('is-expanded'));
setExpanded(p, nowExpanded);
+ // #1532 — persist controls pin state across reloads.
+ if (p.rootId === 'liveControls') {
+ try { localStorage.setItem('live-controls-expanded', nowExpanded ? 'true' : 'false'); }
+ catch (_) { /* private browsing */ }
+ }
});
});
applyForViewport();
@@ -1926,6 +1950,76 @@
})();
// ───────────────────────────────────────────────────────────────────────
+ // ── #1532 — Live fullscreen toggle ─────────────────────────────────────
+ // Adds `body.live-fullscreen` which CSS uses to hide header body,
+ // controls body, VCR controls, and bottom-nav while leaving
+ // .live-stats-row pinned top-right. Triggered by:
+ // • clicking #liveFullscreenToggle (⛶ button next to ⚙)
+ // • pressing the `F` key (when focus is not in an input/textarea)
+ // State persists across reloads via localStorage('live-fullscreen').
+ (function wireLiveFullscreenToggle() {
+ var STORAGE_KEY = 'live-fullscreen';
+ var btn = document.getElementById('liveFullscreenToggle');
+ if (btn) btn.addEventListener('click', function () { toggleFullscreen(); });
+ function setFullscreen(on) {
+ document.body.classList.toggle('live-fullscreen', !!on);
+ if (btn) {
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
+ btn.textContent = on ? '⛶' : '⛶';
+ btn.setAttribute('aria-label', on
+ ? 'Exit fullscreen (F)'
+ : 'Toggle fullscreen (F) — hide chrome, keep stats');
+ }
+ try { localStorage.setItem(STORAGE_KEY, on ? 'true' : 'false'); }
+ catch (_) { /* private browsing */ }
+ }
+ function toggleFullscreen() {
+ setFullscreen(!document.body.classList.contains('live-fullscreen'));
+ }
+ // Restore prior choice on mount.
+ try {
+ if (localStorage.getItem(STORAGE_KEY) === 'true') setFullscreen(true);
+ } catch (_) { /* ignore */ }
+
+ // `F` keypress toggles fullscreen — but only when focus is NOT in
+ // a typing surface (node-filter input, audio sliders, etc.).
+ // Escape exits fullscreen (only when currently in fullscreen so we
+ // don't shadow other Escape handlers, e.g. dropdown close on the
+ // node-filter input).
+ if (!window.__liveFullscreenKeyBound) {
+ window.addEventListener('keydown', function (e) {
+ if (e.defaultPrevented) return;
+ if (typeof e.key !== 'string') return;
+ // Only act when on the live page.
+ if (!document.querySelector('.live-page')) return;
+ // Escape: exit fullscreen if currently in fullscreen. Don't
+ // gate on focus-in-input here — exiting fullscreen via Escape
+ // should always work when chrome is hidden. Do NOT fire when
+ // not currently in fullscreen so other handlers see the key.
+ if (e.key === 'Escape') {
+ if (document.body.classList.contains('live-fullscreen')) {
+ e.preventDefault();
+ setFullscreen(false);
+ }
+ return;
+ }
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
+ var isF = (e.key === 'f' || e.key === 'F' || e.key.toLowerCase() === 'f');
+ if (!isF) return;
+ var t = e.target;
+ if (t) {
+ var tag = (t.tagName || '').toUpperCase();
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
+ if (t.isContentEditable) return;
+ }
+ e.preventDefault();
+ toggleFullscreen();
+ });
+ window.__liveFullscreenKeyBound = true;
+ }
+ })();
+ // ───────────────────────────────────────────────────────────────────────
+
if (legendToggleBtn && legendEl) {
// Restore legend collapsed state from localStorage (#279)
try {
@@ -4217,6 +4311,12 @@
nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = clickablePathsLayer = null;
clickablePaths = [];
stopMatrixRain();
+ // #1572 — clear body.live-fullscreen on route exit. The class hides
+ // .bottom-nav (the only nav on mobile), so leaking it across SPA
+ // routes strands the user. Reset state but DO NOT clear the
+ // localStorage preference — restoring fullscreen on return to /live
+ // is intentional.
+ if (document.body) document.body.classList.remove('live-fullscreen');
nodeMarkers = {}; nodeData = {};
activeNodeDetailKey = null;
recentPaths = [];
diff --git a/test-1110-live-filter.js b/test-1110-live-filter.js
index 37e8ebf5..2d2d3cbd 100644
--- a/test-1110-live-filter.js
+++ b/test-1110-live-filter.js
@@ -33,6 +33,10 @@ async function test(name, fn) {
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext();
+ // #1532 — controls panel defaults collapsed; pre-seed expanded pref.
+ await ctx.addInitScript(() => {
+ try { localStorage.setItem('live-controls-expanded', 'true'); } catch (_) {}
+ });
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
console.log(`#1110 Live filter E2E against ${BASE}`);
diff --git a/test-all.sh b/test-all.sh
index 5d16a2b3..3a9f6992 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -48,6 +48,7 @@ node test-issue-1456-score-labels.js
node test-issue-1461-mobile-page-actions.js
node test-issue-1470-node-tile-helper.js
node test-issue-1485-live-anim-z.js
+node test-issue-1532-live-fullscreen.js
node test-issue-1473-reserved-prefixes.js
node test-issue-1473-prefix-generator.js
diff --git a/test-audio-live-1297-e2e.js b/test-audio-live-1297-e2e.js
index dbcf0f0b..9a25800a 100644
--- a/test-audio-live-1297-e2e.js
+++ b/test-audio-live-1297-e2e.js
@@ -52,6 +52,10 @@ async function main() {
const pass = (msg) => { passes += 1; console.log(` PASS: ${msg}`); };
const ctx = await browser.newContext();
+ // #1532 — controls panel defaults collapsed; pre-seed expanded pref.
+ await ctx.addInitScript(() => {
+ try { localStorage.setItem('live-controls-expanded', 'true'); } catch (_) {}
+ });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js
index 90631b63..9dc9bff7 100644
--- a/test-e2e-playwright.js
+++ b/test-e2e-playwright.js
@@ -53,6 +53,12 @@ async function run() {
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
const context = await browser.newContext();
+ // #1532 — `.live-controls` defaults collapsed; pre-seed the user's pin
+ // preference so toggle children (#liveHeatToggle, etc.) are visible in
+ // tests that pre-date the change.
+ await context.addInitScript(() => {
+ try { localStorage.setItem('live-controls-expanded', 'true'); } catch (_) {}
+ });
const page = await context.newPage();
page.setDefaultTimeout(10000);
diff --git a/test-issue-1205-live-controls-anchor-e2e.js b/test-issue-1205-live-controls-anchor-e2e.js
index 0d2205cc..10fa79fe 100644
--- a/test-issue-1205-live-controls-anchor-e2e.js
+++ b/test-issue-1205-live-controls-anchor-e2e.js
@@ -158,6 +158,11 @@ async function assertMatrixThemeTransparent(page, label) {
{ w: 320, h: 800, tag: '[320x800 narrow phone]' },
]) {
const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } });
+ // #1532 — controls panel defaults collapsed; pre-seed expanded pref
+ // so anchor + reachability assertions still run against the expanded layout.
+ await ctx.addInitScript(() => {
+ try { localStorage.setItem('live-controls-expanded', 'true'); } catch (_) {}
+ });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
diff --git a/test-issue-1532-live-fullscreen.js b/test-issue-1532-live-fullscreen.js
new file mode 100644
index 00000000..b901e2a6
--- /dev/null
+++ b/test-issue-1532-live-fullscreen.js
@@ -0,0 +1,190 @@
+/**
+ * #1532 — Live page: fullscreen toggle + collapse controls by default.
+ *
+ * Per triage fix path (Kpa-clawbot/CoreScope#1532):
+ * 1. `.live-controls` is collapsed by default on desktop too, not just
+ * mobile (existing `#liveControlsToggle` reveals it).
+ * 2. A new `#liveFullscreenToggle` button sits next to ⚙ — toggles a
+ * `body.live-fullscreen` class. CSS under that class hides
+ * `.live-header-body`, `.live-controls-body`, `.vcr-controls`, and
+ * `.bottom-nav`; `.live-stats-row` stays pinned (top-right).
+ * 3. Pin-icon parity with the map-controls accordion in map.js.
+ *
+ * Plus: keyboard shortcut `F` toggles fullscreen, with focus-in-input
+ * guard so it doesn't fire while typing in the node-filter.
+ *
+ * Source-invariant assertions on public/live.js + public/live.css. Same
+ * approach as test-issue-1485-live-anim-z.js so the test runs in the JS
+ * unit-test gate (no playwright/server needed).
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+let passed = 0, failed = 0;
+function assert(cond, msg) {
+ if (cond) { passed++; console.log(' ✓ ' + msg); }
+ else { failed++; console.error(' ✗ ' + msg); }
+}
+
+const liveJs = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
+const liveCss = fs.readFileSync(path.join(__dirname, 'public', 'live.css'), 'utf8');
+
+// ─────────────────────────────────────────────────────────────────────
+console.log('\n=== #1532 A: #liveFullscreenToggle button declared ===');
+
+assert(
+ /id=["']liveFullscreenToggle["']/.test(liveJs),
+ '#liveFullscreenToggle id appears in live.js init() HTML template'
+);
+
+assert(
+ /liveFullscreenToggle[\s\S]{0,400}aria-label/.test(liveJs),
+ '#liveFullscreenToggle has an aria-label attribute (a11y)'
+);
+
+// Button sits *next to* the existing settings (⚙) toggle. Cheap proxy:
+// both ids appear within ~600 chars of each other in the source.
+{
+ const cIdx = liveJs.indexOf('liveControlsToggle');
+ const fIdx = liveJs.indexOf('liveFullscreenToggle');
+ assert(
+ cIdx > 0 && fIdx > 0 && Math.abs(cIdx - fIdx) < 1200,
+ '#liveFullscreenToggle is co-located with #liveControlsToggle in the header template'
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────
+console.log('\n=== #1532 B: fullscreen toggle wires body.live-fullscreen ===');
+
+// A click handler + a body class toggle. The handler must reference
+// 'live-fullscreen' (the class name CSS hangs hides off of).
+assert(
+ /liveFullscreenToggle[\s\S]{0,800}addEventListener\(\s*['"]click['"]/.test(liveJs),
+ '#liveFullscreenToggle has a click listener'
+);
+assert(
+ /document\.body\.classList\.toggle\(\s*['"]live-fullscreen['"]/.test(liveJs),
+ 'click handler toggles document.body.classList["live-fullscreen"]'
+);
+
+// Persist via localStorage so the choice survives reloads.
+assert(
+ /localStorage[\s\S]{0,200}live-fullscreen/.test(liveJs),
+ 'fullscreen state is persisted to localStorage (key contains "live-fullscreen")'
+);
+
+// ─────────────────────────────────────────────────────────────────────
+console.log('\n=== #1532 C: keyboard shortcut F toggles fullscreen ===');
+
+// keydown listener gated on the 'f'/'F' key, with input/textarea guard
+// so the shortcut doesn't fire while focus is in the node-filter input.
+assert(
+ /addEventListener\(\s*['"]keydown['"]/.test(liveJs),
+ 'a keydown listener is registered'
+);
+assert(
+ /(key\s*===?\s*['"]f['"]|key\s*===?\s*['"]F['"]|toLowerCase\(\)\s*===?\s*['"]f['"])/.test(liveJs),
+ 'keydown handler matches the F key'
+);
+// Guard: don't fire when focus is in an input/textarea/contenteditable.
+assert(
+ /(tagName[\s\S]{0,80}(INPUT|TEXTAREA)|isContentEditable|matches\([^)]*input)/i.test(liveJs),
+ 'keydown handler skips when focus is in an INPUT/TEXTAREA/contenteditable'
+);
+
+// ─────────────────────────────────────────────────────────────────────
+console.log('\n=== #1532 D: CSS hides chrome under body.live-fullscreen ===');
+
+function cssHides(selector) {
+ // Match: body.live-fullscreen { ... display: none ... }
+ // OR a comma-list containing the selector.
+ const re = new RegExp(
+ 'body\\.live-fullscreen[^{}]*' + selector.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') +
+ '[\\s\\S]{0,600}?display\\s*:\\s*none'
+ );
+ return re.test(liveCss);
+}
+
+assert(cssHides('.live-header-body'),
+ 'body.live-fullscreen hides .live-header-body (display:none)');
+assert(cssHides('.live-controls-body'),
+ 'body.live-fullscreen hides .live-controls-body (display:none)');
+assert(cssHides('.vcr-controls'),
+ 'body.live-fullscreen hides .vcr-controls (display:none)');
+assert(cssHides('.bottom-nav'),
+ 'body.live-fullscreen hides .bottom-nav (display:none)');
+
+// .live-stats-row must remain visible AND get pinned positioning.
+// Negative: no `body.live-fullscreen .live-stats-row { display: none }`.
+{
+ const re = /body\.live-fullscreen[^{}]*\.live-stats-row[\s\S]{0,400}?display\s*:\s*none/;
+ assert(!re.test(liveCss),
+ '.live-stats-row is NOT hidden by body.live-fullscreen (must stay visible)');
+}
+// Positive: pinned positioning under fullscreen.
+assert(
+ /body\.live-fullscreen[\s\S]{0,800}?\.live-stats-row[\s\S]{0,400}?position\s*:\s*(fixed|absolute)/.test(liveCss),
+ '.live-stats-row gets fixed/absolute positioning under body.live-fullscreen'
+);
+
+// ─────────────────────────────────────────────────────────────────────
+console.log('\n=== #1532 E: .live-controls collapsed by default on desktop ===');
+
+// The pre-#1532 collapse rule lived inside @media (max-width: 768px).
+// Post-#1532 the body-toggle / hidden-attribute path must apply
+// regardless of viewport. We detect this by asserting that the
+// applyForViewport() function does NOT condition the "default collapsed"
+// behavior on narrowMql.matches alone — i.e. the unconditional path
+// also calls setExpanded(p, false) for the controls pair, OR an
+// equivalent .is-collapsed default is asserted on #liveControls.
+{
+ // Heuristic: the code path that handles wide viewports must NOT
+ // force the controls panel to always-expanded. Either both branches
+ // collapse by default (preferred), or a dedicated initial collapse
+ // is applied to liveControls regardless of MQL.
+ //
+ // We assert one of:
+ // (a) setExpanded called with the controls pair AND false in the
+ // unconditional / wide branch.
+ // (b) An explicit "liveControls" / .live-controls .is-collapsed
+ // initialization that runs at all viewports.
+ const wideBranch = /narrowMql\.matches[\s\S]{0,2000}/.exec(liveJs);
+ const elseBlockHasAlwaysExpanded =
+ /else\s*\{[\s\S]{0,800}?removeAttribute\(\s*['"]hidden['"]\s*\);[\s\S]{0,200}?remove\(\s*['"]is-collapsed['"]/.test(liveJs);
+
+ // Acceptable: code has been updated so the controls pair defaults
+ // collapsed even on desktop. We pass if either the explicit "always
+ // expanded" else-branch no longer applies to liveControls, or a
+ // separate desktop-default-collapse step is present.
+ const desktopCollapseHook =
+ /liveControls[\s\S]{0,400}?(is-collapsed|setAttribute\(\s*['"]hidden['"])/.test(liveJs) ||
+ /setExpanded\(\s*pairs\[1\][\s\S]{0,80}?,\s*false\s*\)/.test(liveJs) ||
+ /defaultCollapsed[\s\S]{0,80}?true/.test(liveJs);
+
+ assert(desktopCollapseHook,
+ '.live-controls defaults to collapsed at all viewports (not just ≤768px)');
+}
+
+// CSS supporting rule: the .is-collapsed → hide body rule must NOT be
+// gated to ≤768px any more. Detect a top-level (non-media-scoped) rule.
+{
+ // Find first top-level occurrence of `.live-controls.is-collapsed .live-controls-body`
+ // outside an @media block. Cheap test: split on @media and search the
+ // pre-media chunk.
+ const beforeFirstMedia = liveCss.split(/@media/)[0];
+ const ruleRe = /\.live-controls\.is-collapsed\s+\.live-controls-body[\s\S]{0,200}?display\s*:\s*none/;
+ // Either the rule exists outside any @media, OR the body class path
+ // (body.live-fullscreen) is what does the hiding (which the D-block
+ // already asserts). We accept the body-class path AND additionally
+ // a non-mobile-gated .is-collapsed rule for the pin-only default.
+ const collapsedOutsideMedia = ruleRe.test(beforeFirstMedia);
+ assert(collapsedOutsideMedia,
+ '.live-controls.is-collapsed → hides .live-controls-body at all viewports (rule lives outside @media max-width)');
+}
+
+// ─────────────────────────────────────────────────────────────────────
+console.log('\n=== #1532 results ===');
+console.log(` ${passed} passed, ${failed} failed`);
+if (failed > 0) process.exit(1);
diff --git a/test-live-fullscreen-1572-e2e.js b/test-live-fullscreen-1572-e2e.js
new file mode 100644
index 00000000..6c7b51fc
--- /dev/null
+++ b/test-live-fullscreen-1572-e2e.js
@@ -0,0 +1,210 @@
+#!/usr/bin/env node
+/* Issue #1572 round-1 — Live fullscreen behavioral E2E.
+ *
+ * Replaces source-grep assertions (which a hidden no-op button or a
+ * dead-branch input guard would pass) with computed-style /
+ * keystroke-bus assertions in a real browser.
+ *
+ * Findings under test:
+ * A. body.live-fullscreen MUST be cleared on SPA route exit (mobile
+ * .bottom-nav is hidden by that class — leaking it strands the
+ * user on a navless page).
+ * B. Escape MUST exit fullscreen (no F-key dance to escape).
+ * C. F-key input guard: typing 'f' in an input MUST land in the input
+ * and MUST NOT enter fullscreen.
+ * D. Toggle round-trip: click → in fullscreen + .live-header-body
+ * computed display:none; click again → out + visible.
+ */
+'use strict';
+
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+
+async function main() {
+ const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
+ let browser;
+ try {
+ browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ } catch (err) {
+ if (requireChromium) {
+ console.error(`test-live-fullscreen-1572-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
+ process.exit(1);
+ }
+ console.log(`test-live-fullscreen-1572-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
+ process.exit(0);
+ }
+
+ let failures = 0, passes = 0;
+ const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
+ const pass = (m) => { passes++; console.log(' PASS: ' + m); };
+
+ const ctx = await browser.newContext();
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(15000);
+
+ // Ensure no fullscreen pref leaks from a previous test run.
+ await page.addInitScript(() => {
+ try { localStorage.removeItem('live-fullscreen'); } catch (_) {}
+ });
+
+ async function waitForLive() {
+ await page.waitForSelector('.live-page', { timeout: 15000 });
+ await page.waitForSelector('#liveFullscreenToggle', { timeout: 15000 });
+ }
+
+ try {
+ // ─────────────────────────────────────────────────────────────
+ // Mobile viewport so .bottom-nav is the active nav (finding A).
+ await page.setViewportSize({ width: 390, height: 844 });
+
+ // ── D. Toggle round-trip ────────────────────────────────────
+ await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
+ await waitForLive();
+
+ let state = await page.evaluate(() => ({
+ hasClass: document.body.classList.contains('live-fullscreen'),
+ headerDisplay: getComputedStyle(document.querySelector('.live-header-body')).display,
+ }));
+ if (!state.hasClass) pass('D: body lacks live-fullscreen on initial load');
+ else fail(`D: body has live-fullscreen on initial load (display=${state.headerDisplay})`);
+
+ await page.click('#liveFullscreenToggle');
+ state = await page.evaluate(() => ({
+ hasClass: document.body.classList.contains('live-fullscreen'),
+ headerDisplay: getComputedStyle(document.querySelector('.live-header-body')).display,
+ }));
+ if (state.hasClass && state.headerDisplay === 'none') {
+ pass('D: click #1 → body.live-fullscreen AND .live-header-body display:none');
+ } else {
+ fail(`D: click #1 expected (true, none), got (${state.hasClass}, ${state.headerDisplay})`);
+ }
+
+ await page.click('#liveFullscreenToggle');
+ state = await page.evaluate(() => {
+ const bn = document.querySelector('[data-bottom-nav], .bottom-nav');
+ const sr = document.querySelector('.live-stats-row');
+ return {
+ hasClass: document.body.classList.contains('live-fullscreen'),
+ bnDisplay: bn ? getComputedStyle(bn).display : null,
+ statsPosition: sr ? getComputedStyle(sr).position : null,
+ };
+ });
+ // Round-trip success: body class cleared AND the user-visible
+ // fullscreen-only side-effects revert. `.bottom-nav` going back to
+ // display!=none is the key mobile signal; `.live-stats-row` losing
+ // its fixed/absolute pin is the desktop one. At least one must
+ // revert (depends on viewport and whether the nav is present).
+ if (!state.hasClass) {
+ const reverted =
+ (state.bnDisplay && state.bnDisplay !== 'none') ||
+ (state.statsPosition && state.statsPosition !== 'fixed' && state.statsPosition !== 'absolute');
+ if (reverted) {
+ pass(`D: click #2 → body cleared, fullscreen side-effects reverted (bnDisplay=${state.bnDisplay}, statsPos=${state.statsPosition})`);
+ } else {
+ fail(`D: click #2 cleared body but side-effects did not revert (bnDisplay=${state.bnDisplay}, statsPos=${state.statsPosition})`);
+ }
+ } else {
+ fail(`D: click #2 did NOT clear body.live-fullscreen`);
+ }
+
+ // ── B. Escape exits fullscreen ──────────────────────────────
+ await page.click('#liveFullscreenToggle'); // re-enter fullscreen
+ state = await page.evaluate(() => document.body.classList.contains('live-fullscreen'));
+ if (state) pass('B: re-entered fullscreen before Escape test');
+ else fail('B: setup — re-enter fullscreen failed');
+
+ // Press Escape on body (not in an input).
+ await page.evaluate(() => document.body.focus());
+ await page.keyboard.press('Escape');
+ state = await page.evaluate(() => document.body.classList.contains('live-fullscreen'));
+ if (!state) pass('B: Escape cleared body.live-fullscreen');
+ else fail('B: Escape did NOT clear body.live-fullscreen');
+
+ // ── C. F-key input guard (behavioral) ───────────────────────
+ // Make sure we are NOT in fullscreen.
+ let fs = await page.evaluate(() => document.body.classList.contains('live-fullscreen'));
+ if (fs) await page.click('#liveFullscreenToggle');
+
+ const filterSel = '#liveNodeFilterInput';
+ // Controls are collapsed by default — expand so the filter input is
+ // focusable. If the toggle is absent for some reason, fall through.
+ const ctlToggle = await page.$('#liveControlsToggle');
+ if (ctlToggle) await ctlToggle.click();
+ await page.waitForTimeout(100);
+ const hasFilter = await page.$(filterSel);
+ if (!hasFilter) {
+ fail('C: #liveNodeFilterInput not present — cannot run input-guard test');
+ } else {
+ const visible = await page.evaluate((sel) => {
+ const el = document.querySelector(sel);
+ const cs = el ? getComputedStyle(el) : null;
+ const r = el ? el.getBoundingClientRect() : null;
+ return !!el && cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0;
+ }, filterSel);
+ if (!visible) {
+ fail('C: #liveNodeFilterInput is not visible after expanding controls');
+ } else {
+ await page.focus(filterSel);
+ await page.keyboard.type('f');
+ const result = await page.evaluate((sel) => ({
+ hasClass: document.body.classList.contains('live-fullscreen'),
+ value: document.querySelector(sel).value,
+ }), filterSel);
+ if (!result.hasClass && /f/i.test(result.value)) {
+ pass(`C: typing 'f' in #liveNodeFilterInput did NOT toggle fullscreen (input value="${result.value}")`);
+ } else {
+ fail(`C: F-key leaked into toggle — hasClass=${result.hasClass}, value="${result.value}"`);
+ }
+ }
+ }
+
+ // ── A. body class cleared on route exit ─────────────────────
+ // Enter fullscreen, then navigate to /#/nodes; assert body class
+ // gone AND .bottom-nav not display:none.
+ fs = await page.evaluate(() => document.body.classList.contains('live-fullscreen'));
+ if (!fs) await page.click('#liveFullscreenToggle');
+ fs = await page.evaluate(() => document.body.classList.contains('live-fullscreen'));
+ if (!fs) {
+ fail('A: setup — could not enter fullscreen');
+ } else {
+ pass('A: setup — in fullscreen before SPA nav');
+ }
+
+ // SPA navigate via hash change (no full reload).
+ await page.evaluate(() => { location.hash = '#/nodes'; });
+ await page.waitForFunction(() => location.hash.indexOf('#/nodes') === 0);
+ // Give the router and destroy() a tick.
+ await page.waitForTimeout(150);
+
+ const post = await page.evaluate(() => {
+ const bn = document.querySelector('[data-bottom-nav], .bottom-nav');
+ return {
+ hasClass: document.body.classList.contains('live-fullscreen'),
+ bnPresent: !!bn,
+ bnDisplay: bn ? getComputedStyle(bn).display : null,
+ };
+ });
+ if (!post.hasClass) pass('A: body.live-fullscreen cleared after SPA nav to /#/nodes');
+ else fail('A: body.live-fullscreen LEAKED after SPA nav (.bottom-nav would be hidden on mobile)');
+
+ if (post.bnPresent && post.bnDisplay && post.bnDisplay !== 'none') {
+ pass(`A: .bottom-nav visible after nav (display=${post.bnDisplay})`);
+ } else if (!post.bnPresent) {
+ fail('A: .bottom-nav not present in DOM after SPA nav');
+ } else {
+ fail(`A: .bottom-nav has display:none after SPA nav — user stranded`);
+ }
+ } finally {
+ await browser.close();
+ }
+
+ console.log(`\n#1572 fullscreen E2E: ${passes} passed, ${failures} failed`);
+ process.exit(failures > 0 ? 1 : 0);
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });