From 21c6c774d0e110129f2b242ea7c1aa6d4579632e Mon Sep 17 00:00:00 2001 From: Openclaw Date: Wed, 23 Sep 2026 15:59:58 +0200 Subject: [PATCH 1/2] fix(live): keep legend toggle above VCR bar --- .github/workflows/deploy.yml | 2 + public/live.css | 16 +- test-all.sh | 1 + test-issue-1107-live-layout.js | 14 +- ...-issue-1833-legend-toggle-clickable-e2e.js | 183 ++++++++++++++++++ test-issue-1833-legend-toggle-vcr-offset.js | 34 ++++ 6 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 test-issue-1833-legend-toggle-clickable-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 108d2b970..53c6978ba 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -153,6 +153,7 @@ jobs: node test-node-reach-coverage.js node test-reach-rank.js node test-issue-1107-live-layout.js + node test-issue-1833-legend-toggle-vcr-offset.js node test-issue-1532-live-fullscreen.js node test-issue-1619-feed-detail-card-draggable.js node test-xss-escape-sinks.js @@ -467,6 +468,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1204-live-panel-structure-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1234-live-chrome-pass2-e2e.js 2>&1 | tee -a e2e-output.txt 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-1833-legend-toggle-clickable-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 diff --git a/public/live.css b/public/live.css index a76d00d35..abd26a0c7 100644 --- a/public/live.css +++ b/public/live.css @@ -906,11 +906,13 @@ body.live-fullscreen #liveFullscreenToggle:hover, .feed-hide-btn:hover { opacity: 1; color: #fff; background: rgba(239,68,68,0.6); } .feed-show-btn { - /* #1107: pin fixed bottom-right (stacked above legend toggle) so the + /* #1107: pin bottom-right (stacked above legend toggle) so the * activate/hide button group docks together as a tidy cluster. - * bottom uses max() to clear the VCR bar when present (#1206). */ + * bottom uses max() to clear the VCR bar when present (#1206). + * #1833 round 2: use the same .live-page containing block as .vcr-bar. + * A viewport-fixed anchor drifts by --bottom-nav-reserve at 641..768px. */ --legend-toggle-stack: calc(var(--legend-toggle-h, 46px) + 10px); /* gap above legend-toggle */ - position: fixed; + position: absolute; bottom: max(1rem, calc(var(--vcr-bar-height, 0px) + 10px + var(--legend-toggle-stack))); right: 1rem; z-index: 500; /* Stack above .legend-toggle-btn using margin-bottom so the `right: 1rem` @@ -1316,11 +1318,13 @@ input.live-node-filter-input:focus { } /* Legend toggle button — visible at all sizes (#60, #279). - * #1107: pinned fixed bottom-right so the activate/hide button group + * #1107: pinned bottom-right so the activate/hide button group * docks consistently (with .feed-show-btn) instead of floating loose - * inside the map container. */ + * inside the map container. + * #1833 round 2: anchor to .live-page, like .vcr-bar, so the mobile bottom + * navigation reserve cannot move this button underneath the VCR bar. */ .legend-toggle-btn { - position: fixed; + position: absolute; bottom: calc(var(--vcr-bar-height, 58px) + 10px); right: 1rem; z-index: 500; diff --git a/test-all.sh b/test-all.sh index 0e63d460f..d8737d34f 100755 --- a/test-all.sh +++ b/test-all.sh @@ -81,6 +81,7 @@ 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-1833-legend-toggle-vcr-offset.js node test-naive-banner-tone.js node test-issue-1473-reserved-prefixes.js node test-issue-1473-prefix-generator.js diff --git a/test-issue-1107-live-layout.js b/test-issue-1107-live-layout.js index 0e5a682a2..9035bcd54 100644 --- a/test-issue-1107-live-layout.js +++ b/test-issue-1107-live-layout.js @@ -7,8 +7,12 @@ * with a `max-width` cap so it doesn't dominate the map. * 2. The activate/hide toggle button group at the bottom of the map * (`.legend-toggle-btn`, `.feed-show-btn`) must be pinned via - * `position: fixed; bottom: 1rem; right: 1rem` so they dock as one + * `position: absolute; bottom: 1rem; right: 1rem` so they dock as one * tidy bottom-right group instead of being scattered/cramped. + * (#1833 round 2 changed the anchor from `fixed` to `absolute`: the + * group must resolve its offsets against .live-page, the same box as + * .vcr-bar, or it drifts into the bar by --bottom-nav-reserve at + * 641..768px and stops taking clicks.) * 3. Theming uses existing CSS variables only — no new hex colors. * * Source-invariant assertions on public/live.css, same approach as @@ -86,8 +90,8 @@ assert(legendBtn != null, '.legend-toggle-btn rule block found'); if (legendBtn) { assert( - /position\s*:\s*fixed/.test(legendBtn), - '.legend-toggle-btn uses position: fixed (pinned to viewport)' + /position\s*:\s*absolute/.test(legendBtn), + '.legend-toggle-btn uses position: absolute (docked to .live-page, #1833 round 2)' ); assert( /bottom\s*:\s*(1rem|max\(1rem|calc\(\s*var\(--vcr-bar-height)/.test(legendBtn), @@ -104,8 +108,8 @@ assert(feedShowBtn != null, '.feed-show-btn rule block found'); if (feedShowBtn) { assert( - /position\s*:\s*fixed/.test(feedShowBtn), - '.feed-show-btn uses position: fixed (pinned to viewport)' + /position\s*:\s*absolute/.test(feedShowBtn), + '.feed-show-btn uses position: absolute (docked to .live-page, #1833 round 2)' ); assert( /bottom\s*:\s*(1rem|max\(1rem)/.test(feedShowBtn), diff --git a/test-issue-1833-legend-toggle-clickable-e2e.js b/test-issue-1833-legend-toggle-clickable-e2e.js new file mode 100644 index 000000000..6996fe69e --- /dev/null +++ b/test-issue-1833-legend-toggle-clickable-e2e.js @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * Regression (#1833 round 2): at 641..768px the bottom navigation shortens + * .live-page, while fixed toggle buttons remain viewport-relative. The + * higher-z-index VCR bar then covers the legend toggle and eats real clicks. + * + * Run: BASE_URL=http://localhost:13581 node test-issue-1833-legend-toggle-clickable-e2e.js + */ +'use strict'; + +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const TOLERANCE_PX = 2; + +let passed = 0; +let failed = 0; + +function assert(condition, message) { + if (!condition) throw new Error(message || 'assertion failed'); +} + +async function step(name, fn) { + try { + await fn(); + passed++; + console.log(' \u2713 ' + name); + } catch (err) { + failed++; + console.error(' \u2717 ' + name + ': ' + err.message); + } +} + +async function gotoLive(page) { + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#vcrBar'); + await page.waitForSelector('#legendToggleBtn'); + await page.waitForFunction(() => { + const livePage = document.querySelector('.live-page'); + if (!livePage) return false; + const value = getComputedStyle(livePage).getPropertyValue('--vcr-bar-height'); + return value && parseFloat(value) > 0; + }, null, { timeout: 8000 }); + await page.waitForTimeout(150); +} + +async function revealFeedShowButton(page) { + const usable = await page.evaluate(() => { + const button = document.getElementById('feedHideBtn'); + if (!button) return false; + const rect = button.getBoundingClientRect(); + return getComputedStyle(button).display !== 'none' && rect.width > 0 && rect.height > 0; + }); + if (!usable) return false; + await page.click('#feedHideBtn'); + await page.waitForTimeout(100); + return page.evaluate(() => { + const button = document.getElementById('feedShowBtn'); + if (!button) return false; + const rect = button.getBoundingClientRect(); + return getComputedStyle(button).display !== 'none' && rect.width > 0 && rect.height > 0; + }); +} + +async function probe(page, id) { + return page.evaluate((buttonId) => { + const button = document.getElementById(buttonId); + const bar = document.getElementById('vcrBar'); + const livePage = document.querySelector('.live-page'); + if (!button || !bar || !livePage) return null; + + const buttonRect = button.getBoundingClientRect(); + const barRect = bar.getBoundingClientRect(); + const pageRect = livePage.getBoundingClientRect(); + const hit = document.elementFromPoint( + buttonRect.left + buttonRect.width / 2, + buttonRect.top + buttonRect.height / 2 + ); + + return { + id: buttonId, + position: getComputedStyle(button).position, + visible: buttonRect.width > 0 && buttonRect.height > 0, + bottom: buttonRect.bottom, + barTop: barRect.top, + slack: pageRect.bottom - buttonRect.bottom - barRect.height, + gapBelowLivePage: window.innerHeight - pageRect.bottom, + hitIsSelf: hit === button || button.contains(hit), + hitName: hit ? hit.tagName + (hit.id ? '#' + hit.id : '') : 'none', + }; + }, id); +} + +async function launchBrowser() { + try { + return await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + } catch (err) { + if (process.env.CHROMIUM_REQUIRE === '1') throw err; + console.log('SKIP: Chromium unavailable: ' + err.message.split('\n')[0]); + process.exit(0); + } +} + +(async () => { + const browser = await launchBrowser(); + const context = await browser.newContext(); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + + console.log('\n=== #1833 legend/feed toggle hit testing ==='); + + await page.setViewportSize({ width: 720, height: 900 }); + await gotoLive(page); + + const narrowLegend = await probe(page, 'legendToggleBtn'); + await step('[720px] reserve band and legend geometry are active', async () => { + assert(narrowLegend, 'legend button, VCR bar, or live page missing'); + assert(narrowLegend.gapBelowLivePage > 0, + '.live-page is not shortened by the mobile bottom navigation'); + assert(narrowLegend.position === 'absolute', + 'legend button must be absolute, got ' + narrowLegend.position); + assert(narrowLegend.visible, 'legend button is not visible'); + assert(narrowLegend.bottom <= narrowLegend.barTop + 0.5, + 'legend button overlaps VCR bar by ' + (narrowLegend.bottom - narrowLegend.barTop) + 'px'); + assert(narrowLegend.hitIsSelf, + 'legend button is covered by ' + narrowLegend.hitName); + }); + + await step('[720px] a real hit-tested click toggles the legend', async () => { + const before = await page.$eval('#liveLegend', (el) => el.classList.contains('hidden')); + await page.click('#legendToggleBtn'); + const after = await page.$eval('#liveLegend', (el) => el.classList.contains('hidden')); + assert(before !== after, 'legend hidden state did not change'); + }); + + let narrowFeed = null; + await step('[720px] feed show button clears the bar and restores the feed', async () => { + assert(await revealFeedShowButton(page), 'could not reveal feed show button'); + narrowFeed = await probe(page, 'feedShowBtn'); + assert(narrowFeed && narrowFeed.position === 'absolute', + 'feed show button must be absolute'); + assert(narrowFeed.bottom <= narrowFeed.barTop + 0.5, + 'feed show button overlaps VCR bar'); + assert(narrowFeed.hitIsSelf, + 'feed show button is covered by ' + narrowFeed.hitName); + await page.click('#feedShowBtn'); + const hidden = await page.$eval('#liveFeed', (el) => el.classList.contains('hidden')); + assert(!hidden, 'real click did not restore the live feed'); + }); + + await page.setViewportSize({ width: 1440, height: 900 }); + await gotoLive(page); + const wideLegend = await probe(page, 'legendToggleBtn'); + await revealFeedShowButton(page); + const wideFeed = await probe(page, 'feedShowBtn'); + + await step('[1440px] desktop remains clickable and uses the same anchor geometry', async () => { + for (const pair of [[narrowLegend, wideLegend], [narrowFeed, wideFeed]]) { + const narrow = pair[0]; + const wide = pair[1]; + assert(narrow && wide, 'missing narrow or wide probe'); + assert(wide.visible, '#' + wide.id + ' is not visible on desktop'); + assert(wide.bottom <= wide.barTop + 0.5, + '#' + wide.id + ' overlaps VCR bar on desktop'); + assert(wide.hitIsSelf, + '#' + wide.id + ' is covered by ' + wide.hitName + ' on desktop'); + assert(Math.abs(narrow.slack - wide.slack) <= TOLERANCE_PX, + '#' + wide.id + ' moves with the viewport reserve: ' + + narrow.slack + 'px narrow vs ' + wide.slack + 'px wide'); + } + }); + + await browser.close(); + console.log('\nResults: ' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed ? 1 : 0); +})().catch((err) => { + console.error('FAIL: ' + err.stack); + process.exit(1); +}); diff --git a/test-issue-1833-legend-toggle-vcr-offset.js b/test-issue-1833-legend-toggle-vcr-offset.js index 00d99b5be..3d8210847 100644 --- a/test-issue-1833-legend-toggle-vcr-offset.js +++ b/test-issue-1833-legend-toggle-vcr-offset.js @@ -59,6 +59,40 @@ step('.legend-toggle-btn bottom uses var(--vcr-bar-height)', () => { ); }); +// The offset only works when it is resolved against the same box as the +// absolute-positioned VCR bar. At 641..768px .live-page is shortened by the +// bottom navigation reserve; a fixed button remains viewport-relative and +// consequently drops into the higher-z-index VCR bar. +function findDeclaration(source, selector, prop) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(escaped + '\\s*\\{([^}]*)\\}', 'g'); + const propRe = new RegExp('(?:^|;|\\s)' + prop + '\\s*:\\s*([a-z-]+)\\s*;'); + let m; + while ((m = re.exec(source))) { + const hit = m[1].match(propRe); + if (hit) return hit[1]; + } + return null; +} + +step('.legend-toggle-btn shares .live-page containing block with .vcr-bar', () => { + const position = findDeclaration(css, '.legend-toggle-btn', 'position'); + assert(position !== null, 'no .legend-toggle-btn rule declares `position`'); + assert( + position === 'absolute', + '.legend-toggle-btn must be position:absolute (got: ' + position + ')' + ); +}); + +step('.feed-show-btn uses the same anchor as .legend-toggle-btn', () => { + const position = findDeclaration(css, '.feed-show-btn', 'position'); + assert(position !== null, 'no .feed-show-btn rule declares `position`'); + assert( + position === 'absolute', + '.feed-show-btn must be position:absolute (got: ' + position + ')' + ); +}); + step('sister overlays still track --vcr-bar-height (guard against regression sweep)', () => { // Sanity check: the pattern we mirror is the same one already used by // .live-feed, .live-overlay[data-position="br"], and .feed-show-btn. From f2dd3f0b22f31359e29abff076c6583f136e53bc Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Fri, 25 Sep 2026 08:44:50 +0200 Subject: [PATCH 2/2] fix(live): keep #feedShowBtn clear of .top-nav at short viewport heights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1833 round 2 anchored both .feed-show-btn and .legend-toggle-btn to .live-page via position:absolute so a viewport-fixed anchor could no longer drift out of sync with the VCR bar at 641-768px width. That change also introduced a new regression: .feed-show-btn's offset (stacked above .legend-toggle-btn) can rise far enough at short viewport heights to land underneath .top-nav, since .live-page sits flush with the viewport top on /live with .top-nav fixed on top of it rather than pushing it down. A real click on the button then does nothing, and the feed panel can never be un-hidden again at those sizes. A/B/C testing (master, PR #84's original feature-head, and the current master-integrated head, identical fixture + Playwright flow, 10-viewport matrix with 720x300 repeated 10x) confirmed this is a regression introduced by #84, not pre-existing: master passes 19/19 required cases; both #84-derived trees fail ~16/19 with the button geometrically overlapping and hit-testing under .top-nav. Fix: clamp .feed-show-btn's total bottom offset so it can never rise closer than .top-nav's height to .live-page's own top edge, while keeping position:absolute (matching .legend-toggle-btn, avoiding the fixed/absolute containing-block mismatch that an earlier attempt at this fix — reverting only .feed-show-btn to position:fixed — was empirically found to reintroduce as a different collision with .legend-toggle-btn at 641px width). The clamp only ever pulls the offset down from its existing VCR-bar-clearing value, so it cannot reintroduce the VCR-bar overlap #1833 round 2 already prevents. Adds a regression test to the existing #84 E2E file covering the 720x300 case: real hit-test against .top-nav, and a real click verified against the actual liveFeed hidden-state change (not just CSS). Verified clean across the full required viewport matrix, the 641-768px width band at both short and normal heights (no collision with .legend-toggle-btn or #vcrBar), an SPA remount, keyboard Enter/Space activation, and all four corner hit-tests. Confirmed via 4 mutation tests that the new assertions actually catch: the original unclamped offset, legend-toggle reverted to fixed, a z-index-only non-fix, and a broken click handler that passes hit-testing alone. Co-Authored-By: Claude Sonnet 5 --- public/live.css | 20 ++- ...-issue-1833-legend-toggle-clickable-e2e.js | 115 ++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/public/live.css b/public/live.css index abd26a0c7..818841b71 100644 --- a/public/live.css +++ b/public/live.css @@ -910,14 +910,24 @@ body.live-fullscreen #liveFullscreenToggle:hover, * activate/hide button group docks together as a tidy cluster. * bottom uses max() to clear the VCR bar when present (#1206). * #1833 round 2: use the same .live-page containing block as .vcr-bar. - * A viewport-fixed anchor drifts by --bottom-nav-reserve at 641..768px. */ + * A viewport-fixed anchor drifts by --bottom-nav-reserve at 641..768px. + * #1833 round 3: at short viewport heights the stacked-above-legend + * offset can rise far enough to land under .top-nav (.live-page sits at + * viewport-top, with .top-nav fixed on top of it, not pushing it down — + * so "bottom" offsets big enough reach right into the nav's 52px band). + * The old margin-bottom stacking trick is folded into bottom below so + * there is a single total offset to clamp; its ceiling keeps the + * button's top edge at least .top-nav's height below .live-page's own + * top edge, regardless of viewport height. min() only ever pulls the + * offset DOWN from its VCR-bar-clearing value, so it cannot reintroduce + * the VCR-bar overlap this same rule already prevents. */ --legend-toggle-stack: calc(var(--legend-toggle-h, 46px) + 10px); /* gap above legend-toggle */ position: absolute; - bottom: max(1rem, calc(var(--vcr-bar-height, 0px) + 10px + var(--legend-toggle-stack))); + bottom: max(1rem, min( + calc(max(1rem, calc(var(--vcr-bar-height, 0px) + 10px + var(--legend-toggle-stack))) + var(--legend-toggle-stack)), + calc(100% - var(--legend-toggle-h, 46px) - 52px - 10px) + )); right: 1rem; z-index: 500; - /* Stack above .legend-toggle-btn using margin-bottom so the `right: 1rem` - * invariant (and bottom-right anchor) stays consistent across both buttons. */ - margin-bottom: var(--legend-toggle-stack); background: color-mix(in srgb, var(--surface-1) 92%, transparent); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 8px; color: var(--text-muted); font-size: 18px; padding: 8px 10px; diff --git a/test-issue-1833-legend-toggle-clickable-e2e.js b/test-issue-1833-legend-toggle-clickable-e2e.js index 6996fe69e..c5929a5c4 100644 --- a/test-issue-1833-legend-toggle-clickable-e2e.js +++ b/test-issue-1833-legend-toggle-clickable-e2e.js @@ -4,6 +4,17 @@ * .live-page, while fixed toggle buttons remain viewport-relative. The * higher-z-index VCR bar then covers the legend toggle and eats real clicks. * + * Regression (#1833 round 3): round 2's fix anchors both toggle buttons to + * .live-page instead. At short viewport heights in the same 641..768px band, + * the same offset that clears .feed-show-btn of the VCR bar can push it up + * far enough to land under .top-nav (#62, position:fixed, z-index:1100) and + * eat the click there instead. A/B verified against master (position:fixed) + * and against #1833 round 2 (position:absolute): the natural "hide feed, + * then show feed" click sequence succeeds on master at every viewport down + * to 720x250 and fails on round 2 at every height from 720x250 through + * 720x320 plus 641/700/768x300 (deterministic across 10 repeats). It does + * not affect the legend toggle, which stays clear down to ~215px height. + * * Run: BASE_URL=http://localhost:13581 node test-issue-1833-legend-toggle-clickable-e2e.js */ 'use strict'; @@ -62,6 +73,52 @@ async function revealFeedShowButton(page) { }); } +// Real user action: click #feedHideBtn (a click on .live-page, which is +// exactly what live.js's #62 nav-autohide showNav() listens for) and wait for +// the actual DOM/CSS consequences — never a fixed sleep. #liveFeed picks up +// .hidden synchronously in the click handler; #feedShowBtn's own CSS class +// removal (same handler) is what makes it start taking up layout space. +async function revealFeedShowButtonNoSleep(page) { + await page.click('#feedHideBtn'); + await page.waitForFunction(() => { + const feed = document.getElementById('liveFeed'); + return feed && feed.classList.contains('hidden'); + }, null, { timeout: 5000 }); + await page.waitForFunction(() => { + const btn = document.getElementById('feedShowBtn'); + if (!btn) return false; + const r = btn.getBoundingClientRect(); + return getComputedStyle(btn).display !== 'none' && r.width > 0 && r.height > 0; + }, null, { timeout: 5000 }); +} + +// Geometry against the app's own top navigation (.top-nav), not just the VCR +// bar. On /live, live.js pins .top-nav to position:fixed, z-index:1100 (#62) +// — above .feed-show-btn's z-index:500 — so if the button's box falls inside +// the nav's band it silently eats the click instead of the button. +async function probeAgainstTopNav(page, id) { + return page.evaluate((buttonId) => { + const button = document.getElementById(buttonId); + const nav = document.querySelector('.top-nav'); + if (!button || !nav) return null; + const buttonRect = button.getBoundingClientRect(); + const navRect = nav.getBoundingClientRect(); + const overlapPx = Math.min(buttonRect.bottom, navRect.bottom) - Math.max(buttonRect.top, navRect.top); + const hit = document.elementFromPoint( + buttonRect.left + buttonRect.width / 2, + buttonRect.top + buttonRect.height / 2 + ); + return { + id: buttonId, + visible: buttonRect.width > 0 && buttonRect.height > 0, + overlapsTopNav: overlapPx > 0, + overlapPx, + hitIsSelf: hit === button || button.contains(hit), + hitName: hit ? hit.tagName + (hit.id ? '#' + hit.id : '') : 'none', + }; + }, id); +} + async function probe(page, id) { return page.evaluate((buttonId) => { const button = document.getElementById(buttonId); @@ -152,6 +209,64 @@ async function launchBrowser() { assert(!hidden, 'real click did not restore the live feed'); }); + // Regression: at short viewport heights in the 641-768px reserve band, the + // vcr-bar-height + legend-toggle-stack offset that pins .feed-show-btn + // above .vcr-bar can push it up far enough to land under .top-nav (#62's + // position:fixed, z-index:1100 overlay), which eats the click instead of + // the button. Natural flow only: hide the feed, then click show — the same + // two .live-page clicks a real user makes, which is also exactly what + // resets the nav-autohide idle timer (showNav()) and keeps the nav visible + // through the whole sequence. No artificial waits. + await page.setViewportSize({ width: 720, height: 300 }); + await gotoLive(page); + + const shortLegend = await probe(page, 'legendToggleBtn'); + await step('[720x300] legend toggle still clears the VCR bar at a short viewport', async () => { + assert(shortLegend && shortLegend.visible, 'legend button missing or not visible'); + assert(shortLegend.bottom <= shortLegend.barTop + 0.5, + 'legend button overlaps VCR bar by ' + (shortLegend.bottom - shortLegend.barTop) + 'px'); + assert(shortLegend.hitIsSelf, 'legend button is covered by ' + shortLegend.hitName); + }); + + // page.goto() to the same #/live hash is a same-document no-op in this SPA + // (verified: in-memory state and DOM survive it, only a full navigation + // to a different route tears it down). So a step that fails before + // restoring #liveFeed would leave it hidden for every later gotoLive() + // call in this file. Assertions are collected rather than thrown + // immediately, and the restoring click is always attempted, so this step + // leaves the same clean state (feed visible) whether it passes or fails — + // exactly what the fixed code produces on its own via a successful click. + await step('[720x300] feed show button is not hidden under the top nav and a real click restores the feed', async () => { + const problems = []; + await revealFeedShowButtonNoSleep(page); + const navProbe = await probeAgainstTopNav(page, 'feedShowBtn'); + if (!navProbe || !navProbe.visible) problems.push('feed show button missing or not visible'); + if (navProbe && navProbe.overlapsTopNav) problems.push('feed show button overlaps .top-nav by ' + navProbe.overlapPx + 'px'); + if (navProbe && !navProbe.hitIsSelf) problems.push('feed show button is covered by ' + navProbe.hitName + ' (expected .top-nav to be clear)'); + + const hiddenBefore = await page.$eval('#liveFeed', (el) => el.classList.contains('hidden')); + try { + await page.click('#feedShowBtn'); + } catch (clickErr) { + problems.push('real click on feed show button failed: ' + clickErr.message.split('\n')[0]); + } + const hiddenAfter = await page.$eval('#liveFeed', (el) => el.classList.contains('hidden')); + if (!(hiddenBefore === true && hiddenAfter === false)) { + problems.push('a real click on feed show button did not restore the live feed (hiddenBefore=' + hiddenBefore + ', hiddenAfter=' + hiddenAfter + ')'); + } + if (hiddenAfter) { + // Best-effort restore for the sections that follow, regardless of why + // the click above did not land — mirrors exactly what the click + // handler itself does (live.js feedShowBtn listener) on both elements, + // and does not affect this step's own pass/fail result. + await page.evaluate(() => { + document.getElementById('liveFeed').classList.remove('hidden'); + document.getElementById('feedShowBtn').classList.add('hidden'); + }).catch(() => {}); + } + assert(problems.length === 0, problems.join('; ')); + }); + await page.setViewportSize({ width: 1440, height: 900 }); await gotoLive(page); const wideLegend = await probe(page, 'legendToggleBtn');