fix(nav): #1109 mobile hamburger dropdown clipped by .top-nav overflow:hidden (#1163)

# Fix #1109 — mobile hamburger dropdown clipped invisible by `.top-nav {
overflow:hidden }`

Red commit: `5429b0f` (failing E2E, asserts pixel-level visibility).

## Symptom

On <768px viewports, tapping `#hamburger` toggles `.nav-links.open` and
`body.nav-open` correctly — DOM state is right, `aria-expanded="true"`,
computed `display:flex` — but **nothing appears below the navbar**. The
dropdown is laid out at `y=52..626` but visually clipped.

## Root cause

`.top-nav` is `position:sticky; height:52px; overflow:hidden` (added in
#1066 fluid scaffolding at `417b460` to guard against horizontal
overflow during the Priority+ measurement pass). At <768px the dropdown
becomes `position:absolute; top:52px`, so its containing block is
`.top-nav` — and `.top-nav`'s `overflow:hidden` clips everything below
`y=52`. Result: the dropdown renders inside a 52px box and the user sees
nothing.

Full RCA + screenshots:
https://github.com/Kpa-clawbot/CoreScope/issues/1109#issuecomment-4398900387

## Fix

In `public/style.css`, inside `@media (max-width: 767px)`, change
`.nav-links` from `position:absolute` to `position:fixed`.
`position:fixed` escapes any `overflow:hidden` ancestor (its containing
block becomes the viewport), so the dropdown is no longer clipped. All
other rules (display/flex/background/padding/z-index) keep working.

This deliberately does **not** relax `overflow:hidden` on `.top-nav` —
that would reopen the #1066 horizontal-overflow regression on desktop.

## Why prior tests missed this

Existing nav E2Es asserted `.classList.contains('open')` /
`getComputedStyle().display === 'flex'` — pure DOM state. Those passed
even while the dropdown was clipped invisibly. The new test in this PR
asserts **pixel-level visibility**:
`document.elementFromPoint(viewportWidth/2, 100)` must land on something
inside `.nav-links` (not `<body>`), and the first `.nav-link`'s bounding
rect must satisfy `bottom > 60` and have non-zero area. A state-only fix
can never satisfy this.

E2E assertion added:
`test-issue-1109-hamburger-dropdown-visible-e2e.js:113` (the
`hitInsideNavLinks` check).

## Files changed

- `public/style.css` — one line in the mobile media query: `position:
absolute` → `position: fixed`
- `test-issue-1109-hamburger-dropdown-visible-e2e.js` — new E2E
- `.github/workflows/deploy.yml` — wire the new E2E into the suite

Fixes #1109

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-05-07 09:39:29 -07:00
committed by GitHub
parent 0eee922d5a
commit cf604ca788
3 changed files with 183 additions and 1 deletions
+1
View File
@@ -242,6 +242,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-rebrand-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-theme-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-default-sage-teal-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1109-hamburger-dropdown-visible-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
+9 -1
View File
@@ -1566,7 +1566,15 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
.hamburger { display: inline-flex; }
.nav-more-wrap { display: none !important; }
.nav-links {
display: none; position: absolute; top: 52px; left: 0; right: 0;
/* Issue #1109: was position:absolute. The containing block becomes
.top-nav (the nearest positioned ancestor: position:sticky), which
has `overflow:hidden; height:52px` since #1066 to guard against
horizontal overflow during the Priority+ measurement pass. That
guard clipped this dropdown invisibly below the navbar. Switching
to position:fixed escapes any overflow:hidden ancestor (containing
block becomes the viewport) without relaxing the #1066 desktop
overflow guard. */
display: none; position: fixed; top: 52px; left: 0; right: 0;
background: var(--nav-bg); flex-direction: column; padding: 8px 0;
box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 1100;
max-height: calc(100dvh - 52px); overflow-y: auto;
@@ -0,0 +1,173 @@
#!/usr/bin/env node
/* Issue #1109 Mobile hamburger dropdown is invisible (CSS clip).
*
* Symptom: tap the hamburger on mobile, DOM state goes correct
* (.nav-links.open, body.nav-open, aria-expanded=true) but the
* dropdown is not visible. Cause: `.top-nav { overflow:hidden;
* height:52px }` (added in #1066 fluid scaffolding) clips the
* absolutely-positioned `.nav-links { position:absolute; top:52px }`
* outside its containing block. Fix: switch to position:fixed at
* <768px so the dropdown escapes the navbar's overflow trap.
*
* Prior tests checked only `.classList.contains('open')` pure DOM
* state and missed the regression entirely. This test asserts
* PIXEL-LEVEL visibility via `elementFromPoint` AND a getBoundingClientRect
* sanity check, so a state-only fix can never lie its way past CI.
*
* RCA: https://github.com/Kpa-clawbot/CoreScope/issues/1109#issuecomment-4398900387
*
* This test FAILS on master @ origin/master (elementFromPoint at the
* dropdown center returns <body>, not a .nav-link) and PASSES once
* the position:fixed fix is applied.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const VIEWPORT = { width: 390, height: 844 }; // iPhone 13 dimensions
function fail(msg) {
console.error(`test-issue-1109-hamburger-dropdown-visible-e2e.js: FAIL — ${msg}`);
process.exit(1);
}
async function main() {
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 (process.env.CHROMIUM_REQUIRE === '1') {
console.error(`test-issue-1109-hamburger-dropdown-visible-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-issue-1109-hamburger-dropdown-visible-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
try {
const ctx = await browser.newContext({
viewport: VIEWPORT,
hasTouch: true,
isMobile: true,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
});
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#hamburger');
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
// Sanity: hamburger is visible and dropdown is closed by default.
const initial = await page.evaluate(() => {
const h = document.getElementById('hamburger');
const nl = document.querySelector('.nav-links');
return {
hamburgerDisplay: h ? getComputedStyle(h).display : null,
navOpen: nl ? nl.classList.contains('open') : null,
navDisplay: nl ? getComputedStyle(nl).display : null,
};
});
if (initial.hamburgerDisplay === 'none') fail(`hamburger should be visible at <768px, got display:${initial.hamburgerDisplay}`);
if (initial.navOpen) fail('nav-links should NOT have .open before tap');
if (initial.navDisplay !== 'none') fail(`nav-links display should be 'none' before tap, got ${initial.navDisplay}`);
// Tap (mobile context => Playwright synthesizes touch).
await page.tap('#hamburger');
// Step 1: confirm DOM state (the OLD assertion).
await page.waitForSelector('.nav-links.open', { timeout: 5000 });
const domState = await page.evaluate(() => {
const nl = document.querySelector('.nav-links');
const h = document.getElementById('hamburger');
return {
open: nl.classList.contains('open'),
bodyOpen: document.body.classList.contains('nav-open'),
ariaExpanded: h.getAttribute('aria-expanded'),
display: getComputedStyle(nl).display,
};
});
if (!domState.open) fail('.nav-links.open missing after tap');
if (!domState.bodyOpen) fail('body.nav-open missing after tap');
if (domState.ariaExpanded !== 'true') fail(`aria-expanded should be 'true', got ${domState.ariaExpanded}`);
if (domState.display !== 'flex') fail(`nav-links display should be 'flex' after tap, got ${domState.display}`);
// Step 2: PIXEL-LEVEL visibility (the NEW assertion that gates the bug).
// Pick a point inside where a nav-link should render: center-x, y=100
// (well below the 52px navbar). On the bug, this returns <body> because
// the dropdown is laid out but clipped by .top-nav { overflow:hidden }.
const probe = await page.evaluate(() => {
const x = Math.floor(window.innerWidth / 2);
const y = 100;
const el = document.elementFromPoint(x, y);
const navLinks = document.querySelector('.nav-links');
const firstLink = navLinks ? navLinks.querySelector('.nav-link') : null;
const linkRect = firstLink ? firstLink.getBoundingClientRect() : null;
const navLinksRect = navLinks ? navLinks.getBoundingClientRect() : null;
return {
x, y,
hitTag: el ? el.tagName : null,
hitClass: el ? el.className : null,
// Walk up to see if hit point belongs to the nav-links subtree.
hitInsideNavLinks: !!(el && navLinks && navLinks.contains(el)),
linkRect: linkRect ? {
top: linkRect.top, bottom: linkRect.bottom,
left: linkRect.left, right: linkRect.right,
width: linkRect.width, height: linkRect.height,
} : null,
navLinksRect: navLinksRect ? {
top: navLinksRect.top, bottom: navLinksRect.bottom,
left: navLinksRect.left, right: navLinksRect.right,
} : null,
};
});
if (!probe.hitInsideNavLinks) {
fail(
`pixel-level visibility check failed: elementFromPoint(${probe.x}, ${probe.y}) returned ` +
`<${probe.hitTag} class="${probe.hitClass}">, expected an element inside .nav-links. ` +
`This means the dropdown is laid out but visually clipped (likely by an ancestor with overflow:hidden). ` +
`linkRect=${JSON.stringify(probe.linkRect)} navLinksRect=${JSON.stringify(probe.navLinksRect)}`
);
}
if (!probe.linkRect) fail('no .nav-link found inside .nav-links');
if (probe.linkRect.bottom <= 60) fail(`first .nav-link bounding rect bottom (${probe.linkRect.bottom}) should be > 60 (below 52px navbar)`);
if (probe.linkRect.right <= 0) fail(`first .nav-link bounding rect right (${probe.linkRect.right}) should be > 0`);
if (probe.linkRect.width <= 0 || probe.linkRect.height <= 0) {
fail(`first .nav-link rect has zero area: ${JSON.stringify(probe.linkRect)}`);
}
// Step 3: tap to close, assert dropdown is no longer rendered.
await page.tap('#hamburger');
// Wait for state flip.
await page.waitForFunction(() => {
const nl = document.querySelector('.nav-links');
return nl && !nl.classList.contains('open');
}, { timeout: 5000 });
const closed = await page.evaluate(() => {
const nl = document.querySelector('.nav-links');
return {
open: nl.classList.contains('open'),
display: getComputedStyle(nl).display,
bodyOpen: document.body.classList.contains('nav-open'),
};
});
if (closed.open) fail('.nav-links.open should be gone after second tap');
if (closed.display !== 'none') fail(`nav-links display should be 'none' after close, got ${closed.display}`);
if (closed.bodyOpen) fail('body.nav-open should be cleared after close');
console.log('test-issue-1109-hamburger-dropdown-visible-e2e.js: PASS');
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(`test-issue-1109-hamburger-dropdown-visible-e2e.js: FAIL — ${err.stack || err.message}`);
process.exit(1);
});