diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index aca9571..848036e 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -3413,6 +3413,33 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } @media (max-width: 640px) { :root { --header-h: 58px; } .container { padding-inline: 0.9rem; } + /* Owner report 2026-09-08: the hamburger "won't extend" on a real + phone while the same width in a desktop browser works. The page + opts into `viewport-fit=cover` (the ), so on notched phones + the layout viewport runs UNDER the status bar / Dynamic Island — + and this bar (58px, toggle tap zone y≈7–51px) sits entirely in the + system top-edge zone: visually under the battery/signal icons, and + in the region where a real device does not reliably deliver taps + to the page (desktop emulation has no system top zone, so it works + there — the device-only failure). Pad the bar DOWN by the top + safe-area inset: the header surface still fills the notch region + (that is the point of viewport-fit=cover) but every control — the + #nav-toggle first — lands in the reliably tappable area, and the + dropdown's top:100% follows the taller bar automatically. The + .app-header rule doubles as the document viewer's row-1 bar (the + same class), so the viewer's hamburger is covered by the same + rule. env() resolves to 0 on every non-notch surface (desktop, + headless, no-island phones) — byte-identical rendering there. */ + .app-header { + height: calc(var(--header-h) + env(safe-area-inset-top, 0px)); + padding-top: env(safe-area-inset-top, 0px); + } + /* The share toast rests just under the bar — the bar is taller by + the same inset on notched phones, so the offset must carry it too + (the toast would otherwise tuck under the padded bar). */ + .toast { + top: calc(var(--header-h) + 0.75rem + env(safe-area-inset-top, 0px)); + } /* Phase 14: the New chat pill joins the header — tighten the bar so brand + nav + pill fit at 360px without horizontal overflow (the brand text may ellipsize as the designated squeeze target). */ diff --git a/tests/unit/test_hamburger_nav.py b/tests/unit/test_hamburger_nav.py index 17b9d58..62bdaa9 100644 --- a/tests/unit/test_hamburger_nav.py +++ b/tests/unit/test_hamburger_nav.py @@ -251,6 +251,35 @@ def test_mobile_block_renders_the_44px_toggle() -> None: ) +def test_mobile_block_pads_the_bar_for_the_top_safe_area() -> None: + """Owner report 2026-09-08: on a real notched phone (the page opts + into `viewport-fit=cover`) the whole 58px bar — including the + #nav-toggle tap zone (y≈7–51px) — sat under the status bar / + Dynamic Island system top-edge zone, where a device does not + reliably deliver taps to the page (desktop emulation has no such + zone, so the menu only "broke" on the phone). The ≤640px block + must pad the bar down by the top safe-area inset (explicit 0 + fallback — the house `env(..., 0)` pattern, so non-notch surfaces + render byte-identical) and grow the height by the same amount + (border-box: the control row keeps its 58px). The share toast's + under-bar offset must carry the same inset.""" + mobile = _media_block(_css(), "@media (max-width: 640px)") + header = _rule_block(mobile, ".app-header") + assert "padding-top: env(safe-area-inset-top, 0px)" in header, ( + "the mobile bar must pad down by the top safe-area inset " + "(the viewport-fit=cover contract)" + ) + assert "height: calc(var(--header-h) + env(safe-area-inset-top, 0px))" in header, ( + "the bar height must grow by the same inset (border-box keeps " + "the control row at --header-h)" + ) + toast = _rule_block(mobile, ".toast") + assert ( + "top: calc(var(--header-h) + 0.75rem + env(safe-area-inset-top, 0px))" + in toast + ), "the toast's under-bar offset must carry the same inset" + + def test_mobile_block_turns_the_nav_into_the_dropdown() -> None: """The closed (default) mobile nav is the invisible, non-interactive dropdown panel: absolute edge-to-edge under the sticky .app-header