"""Phase 46 E2E (Playwright): the mobile hamburger dropdown nav. Story: ``.agents/user_stories/mobile-hamburger-nav.md`` TODO.md L9 (owner permission 2026-08-27): "The navbar on mobile is way too squished. Make it a hamburger dropdown menu with a nice animation." Run in isolation (mock LLM; DB up: ``podman compose up -d db``): uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov Contract under test: at ≤640px the nav links LEAVE the bar — a 44px ``#nav-toggle`` hamburger opens ``#app-nav`` as an animated (180ms slide+fade) edge-to-edge dropdown with comfortable rows, the auth visibility contract intact INSIDE the menu; at >640px the bar is byte-identical to pre-phase-46 (hamburger absent, inline pills). No document is ever needed — the suite exercises the shared header only. The conftest ``page`` fixture is 1280×800, so the mobile tests create fresh 375×812 pages via the session ``browser`` fixture (one page per test; the reduced-motion test gets its own context). Test → story mapping (Playwright Mapping Rule): 1. ``test_mobile_hamburger_visible_and_bar_roomy`` — 375px: the toggle is a ≥44px visible button (``aria-expanded="false"``, closed), the inline nav links are not visible in the bar (the closed dropdown is opacity 0 + visibility hidden), and the page does not overflow horizontally. 2. ``test_anonymous_menu_contents`` — anonymous at 375px: the menu shows EXACTLY one visible link ("Chat"); the three admin-only links stay ``hidden`` inside the menu; the open flips ``aria-expanded``. 3. ``test_admin_menu_contents`` — admin at 375px: the menu shows all four links (the whoami reveal works inside the menu). 4. ``test_link_click_navigates_and_closes`` — admin at 375px: clicking "RAG" navigates to /sources.html and the menu on the arrival page ships closed. 5. ``test_esc_and_outside_close`` — Esc closes AND returns focus to the toggle; an outside click does NOT close (accepted — see the test docstring for why). 6. ``test_animation_and_reduced_motion`` — motion allowed: the 180ms opacity/transform transition pair is live and the open flips class + aria; ``reducedMotion: "reduce"``: no transition in EITHER state (the .is-open state included — the specificity trap) and open/close still works. 7. ``test_desktop_unchanged`` — 1280×800 regression: the hamburger is ``display: none`` and the inline nav renders in the bar exactly as before (admin: all four links, all inside the header band). """ from __future__ import annotations import re from playwright.sync_api import Browser, Page, ViewportSize, expect from e2e.auth_helpers import login MOBILE: ViewportSize = {"width": 375, "height": 812} # the story's phone viewport DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size NAV_LINKS = ("#app-nav a[href='/']", "#nav-sources", "#nav-git-sources", "#nav-tuning") LINK_TEXTS = ("Chat", "RAG", "Sources", "Tuning") def _mobile_page(browser: Browser) -> Page: """A fresh 375×812 page (the conftest ``page`` is 1280×800).""" return browser.new_page(viewport=MOBILE) def _wait_settled_anonymous(page: Page) -> None: """Wait until whoami has resolved for the anonymous visitor: the bar Sign in copy (``#sign-in-link``) loses its ship-hidden attribute — the phase-16 settled state, probed by attribute (not visibility): at ≤640px the bar copy is CSS-hidden behind the ``#sign-in-link-mobile`` dropdown copy (phase 46), so visibility is viewport-dependent.""" page.wait_for_function( "() => !document.querySelector('#sign-in-link').hasAttribute('hidden')", timeout=10_000, ) def _wait_settled_admin(page: Page) -> None: """Wait until whoami has resolved for the admin: the whoami reveal has un-hidden the admin-only nav links (the menu-contents assertions must run on a settled auth state). The nav link is the viewport-independent settled signal — the sign-out control is the bar copy on desktop but the #sign-out-btn-mobile dropdown copy at ≤640px (phase-46 UX revision), so it is not a cross-viewport probe.""" page.wait_for_function( "() => !document.querySelector('#nav-sources').hasAttribute('hidden')", timeout=10_000, ) def _visible_nav_links(page: Page) -> list[str]: """The texts of the nav links that are actually visible (Playwright visibility: non-empty box AND not visibility:hidden/display:none — so the closed dropdown and the ``hidden`` admin links both count as invisible).""" return [ page.locator(sel).inner_text() for sel in NAV_LINKS if page.locator(sel).is_visible() ] def _open_menu(page: Page) -> None: page.click("#nav-toggle") expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") # to_have_class(string) is an EXACT match on the class attribute — # the nav is "app-nav is-open", so match the token with a regex. expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b")) expect(page.locator("#app-nav")).to_have_css("opacity", "1") def _js_open_menu(page: Page) -> None: """Phase 79 (task 05): the in-app token gate is a full-viewport overlay for ANONYMOUS visitors — it physically covers the header, so a real click on #nav-toggle is intercepted by the gate (the gate is the only interactive surface; the header is locked out with the rest of the page). The binding is identical, so the menu contract is driven programmatically: a JS-dispatched click runs the exact same listener a real click would.""" page.evaluate("() => document.querySelector('#nav-toggle').click()") expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b")) expect(page.locator("#app-nav")).to_have_css("opacity", "1") def _assert_menu_closed(page: Page) -> None: expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false") assert "is-open" not in (page.locator("#app-nav").get_attribute("class") or ""), ( "the closed menu must not carry the .is-open state" ) # --------------------------------------------------------------------------- # 1. Bar: the toggle is a roomy 44px target and the nav is out of the bar # --------------------------------------------------------------------------- def test_mobile_hamburger_visible_and_bar_roomy( browser: Browser, app_url: str, db_ready: None ) -> None: """AC1/AC5: at 375px the bar carries a ≥44px ``#nav-toggle`` (closed, ``aria-expanded="false"``), the inline nav links are NOT visible in the bar (the closed dropdown is opacity 0 + visibility hidden — the layout box is top:100% less the 8px slide offset, so the story's opacity-0 branch is what pins it), and there is no horizontal overflow — the old squished pills are gone, so the bar has room.""" page = _mobile_page(browser) try: page.goto(app_url) _wait_settled_anonymous(page) # The hamburger: a visible ≥44px×44px touch target (the phase-07 # floor), shipped closed. toggle = page.locator("#nav-toggle") expect(toggle).to_be_visible() box = toggle.bounding_box() assert box is not None, "the toggle must have a box" assert box["width"] >= 44 and box["height"] >= 44, ( f"the toggle must be a ≥44px touch target, got " f"{box['width']:.0f}×{box['height']:.0f}" ) expect(toggle).to_have_attribute("aria-expanded", "false") # The inline nav links are not visible in the bar: the closed # dropdown is opacity 0 + visibility hidden. (The layout box is # top:100% minus the 8px slide offset — inside the band — but # invisible, which is the acceptance branch: opacity 0.) assert page.evaluate( "() => getComputedStyle(document.querySelector('#app-nav')).opacity" ) == "0", "the closed menu must be opacity 0" assert page.evaluate( "() => getComputedStyle(document.querySelector('#app-nav')).visibility" ) == "hidden", "the closed menu must be visibility hidden" for sel in NAV_LINKS: assert not page.locator(sel).is_visible(), ( f"{sel} must not be visible in the bar with the menu closed" ) # No horizontal overflow at 375px (the old four squished text # pills are gone from the bar). assert page.evaluate( "() => document.documentElement.scrollWidth" ) <= page.evaluate("() => window.innerWidth"), ( "the 375px bar must not overflow horizontally" ) finally: page.close() # --------------------------------------------------------------------------- # 2. Menu contents per auth state (the whoami contract inside the menu) # --------------------------------------------------------------------------- def test_anonymous_menu_contents( browser: Browser, app_url: str, db_ready: None ) -> None: """AC2 (anonymous): at 375px the opened menu shows EXACTLY one visible link — "Chat". The three admin-only links keep their ship-hidden state INSIDE the menu (the phase-19/35 contract is preserved by reusing the same