diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b63b381b..d8972778 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 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-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);