Files
brain-of-reese/tests/e2e/test_mobile_hamburger_nav.py
T
ducoterra 6be692d999 feat(header): hamburger dropdown nav on mobile (owner permission)
TODO.md L9 (owner permission 2026-08-27, roadmap A5): "The navbar on
mobile is way too squished. Make it a hamburger dropdown menu with a
nice animation." At <=640px the nav links leave the bar — a 44px
#nav-toggle opens #app-nav as an animated (180ms slide+fade)
edge-to-edge dropdown with comfortable rows and the auth visibility
contract intact inside the menu; at >640px the bar is byte-identical
to pre-phase-46 (hamburger absent, inline pills as before).

- frontend/*.html (all six pages): the shared bar gains the
  #nav-toggle button (type=button, aria-expanded=false,
  aria-controls="app-nav", aria-label="Menu", aria-hidden 3-line
  SVG icon) immediately before the nav, and the nav gains
  id="app-nav" — one <nav>, no duplicated links, so the whoami reveal
  works inside the menu unchanged (phase-34 same-bar contract intact).
- frontend/assets/styles.css: .nav-toggle is display:none outside media
  queries (desktop untouched); the <=640px block adds the 44px toggle
  (+hover in the .steering-toggle:hover family, sized 20px icon), turns
  .app-nav into the dropdown (absolute top:100% edge-to-edge under the
  sticky header, surface + hairline + --shadow-lg, z-index 21 =
  header+1, closed state invisible + non-interactive with the 180ms
  opacity/transform/visibility-delayed pair, .is-open the only
  opener), and comfortable 1rem/0.75rem menu rows — superseding the
  phase-34/35 pill-squeeze rules for .nav-link/.app-nav (the 900px
  tablet block, action pills, and 58px bar height untouched). The
  reduced-motion block stills BOTH the closed and .is-open states: the
  .is-open rule (0,2,0) out-specifies a bare .app-nav (0,1,0), so the
  override must name both — verified live in Chromium (task 03).
- frontend/assets/header.js: ONE module-owned binding (import-time,
  null-safe like the sign-out binding): click toggles .is-open +
  aria-expanded in sync, a delegated nav-link click closes, Esc closes
  and refocuses the toggle, and matchMedia("(max-width: 640px)")
  change drops the state on resize back to desktop. The binding
  touches only the container — ship-hidden whoami links stay hidden.
- tests/unit/test_hamburger_nav.py (new): the markup/CSS/JS contract
  pins (six identical toggles in the shared row, desktop byte-
  identical, dropdown + .is-open + 180ms + reduced-motion rules, the
  superseded squeeze rules gone, the one-binding behavior).
- tests/e2e/test_shared_header.py: assert_shared_bar gains mobile=True
  (at <=640px the bar shows the hamburger + the closed nav; the
  per-role menu contents are pinned by the story suite).
- tests/e2e/test_mobile_hamburger_nav.py (new, story suite, 375x812):
  toggle is a visible >=44px target, menu closed (opacity 0 /
  visibility hidden), no horizontal overflow; anonymous menu shows
  exactly "Chat" (admin-only links stay hidden inside); admin menu
  shows all four links (whoami reveal inside the menu); a link click
  navigates + the arrival page ships closed; Esc closes and refocuses
  the toggle (outside click does NOT close — accepted: the locked
  close set is Esc + link + resize, no backdrop); the 180ms
  opacity/transform pair is live and reducedMotion:reduce stills both
  states with open/close still working; 1280x800 regression — toggle
  display:none, all four inline links inside the header band.

Gates: unit+integration 773 passed; app/ coverage TOTAL 99%
(unchanged — frontend-only phase); story E2E 7 passed in isolation
(mock LLM, DB up); regression suites test_nav_consistency (6) /
test_header_consistency (3) / test_shared_header (6) /
test_responsive_polish (7) / test_tuning_nav_link (4) all pass in
isolation; ruff check + pyright clean. A11 honored: no CDN, no new
assets.

Also records the 46_mobile_hamburger_nav todo/ -> complete/ move.
2026-08-28 06:07:02 -04:00

415 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 46 E2E (Playwright): the mobile hamburger dropdown nav.
Story: ``.agent/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
"Sources" 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", "Sources", "Git 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 (Sign in
visible — the phase-16 settled state the header pins)."""
expect(page.locator("#sign-in-link")).to_be_visible(timeout=10_000)
def _wait_settled_admin(page: Page) -> None:
"""Wait until whoami has resolved for the admin (Sign out visible)
AND the whoami reveal has un-hidden the admin-only nav links (the
menu-contents assertions must run on a settled auth state)."""
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=10_000)
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 _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 <nav> element); opening flips
aria-expanded true."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
_assert_menu_closed(page)
_open_menu(page)
assert _visible_nav_links(page) == ["Chat"], (
"anonymous: the menu must show exactly one visible link (Chat)"
)
for sel in ("#nav-sources", "#nav-git-sources", "#nav-tuning"):
expect(page.locator(sel)).to_be_hidden()
# A second click closes it again — aria-expanded round-trips.
page.click("#nav-toggle")
_assert_menu_closed(page)
finally:
page.close()
def test_admin_menu_contents(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC2 (admin): at 375px the opened menu shows ALL FOUR links —
Chat / Sources / Git sources / Tuning — i.e. the whoami reveal
works inside the menu exactly as it does inline (one <nav>, one
set of links, the same hidden attributes header.js drives)."""
page = _mobile_page(browser)
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
_open_menu(page)
assert _visible_nav_links(page) == list(LINK_TEXTS), (
f"admin: the menu must show all four links, got {_visible_nav_links(page)}"
)
for sel in NAV_LINKS:
expect(page.locator(sel)).to_be_visible()
finally:
page.close()
# ---------------------------------------------------------------------------
# 3. Link close + navigation
# ---------------------------------------------------------------------------
def test_link_click_navigates_and_closes(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC4: a menu link click navigates AND closes the menu — and the
arrival page ships the fresh (closed) header: aria-expanded false,
no .is-open, menu invisible."""
page = _mobile_page(browser)
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
_open_menu(page)
expect(page.locator("#nav-sources")).to_be_visible()
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html", timeout=15_000)
# The arrival page: a fresh header, shipped closed.
_assert_menu_closed(page)
expect(page.locator("#app-nav")).to_be_hidden()
finally:
page.close()
# ---------------------------------------------------------------------------
# 4. Esc close (+ focus return) and the accepted outside-click behavior
# ---------------------------------------------------------------------------
def test_esc_and_outside_close(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC4: Esc closes the menu AND returns focus to the toggle (the
opener — a keyboard user never loses their place).
Accepted behavior (NOT a defect, per the task-02 contract and story
AC 4): an OUTSIDE click does not close the menu. The locked close
set is Esc + link + resize — the story's AC 4 lists exactly those
three, and the owner-locked scope (2026-08-27) does not include a
backdrop click (there is no backdrop element at all — the menu is
the nav itself dropping out of the sticky header). This test pins
the menu STAYING OPEN on an outside click so an accidental
backdrop-close implementation cannot sneak in later."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
# Esc closes + refocuses the opener.
_open_menu(page)
page.keyboard.press("Escape")
_assert_menu_closed(page)
assert page.evaluate("() => document.activeElement.id") == "nav-toggle", (
"Esc-close must return focus to the #nav-toggle opener"
)
# Outside click: the menu STAYS open (accepted behavior — the
# locked close set is Esc + link + resize, not backdrop click).
_open_menu(page)
page.locator("footer span").first.click() # a neutral, non-link point
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
assert "is-open" in (page.locator("#app-nav").get_attribute("class") or ""), (
"accepted behavior: an outside click must NOT close the menu"
)
# …and the menu is still fully usable (Esc still settles it).
page.keyboard.press("Escape")
_assert_menu_closed(page)
finally:
page.close()
# ---------------------------------------------------------------------------
# 5. Animation + prefers-reduced-motion
# ---------------------------------------------------------------------------
def test_animation_and_reduced_motion(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC3: motion allowed — the menu carries the 180ms opacity/transform
transition pair and opening flips the .is-open class + aria-expanded
in lockstep. reducedMotion: "reduce" — no transition in EITHER state
(the .is-open rule is higher-specificity than a bare .app-nav rule,
so the override must name both — pinned here against the live
computed style) and open/close still works, instantly."""
# Motion allowed: the 180ms slide+fade pair is live.
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
report = page.evaluate(
"() => { const cs = getComputedStyle(document.querySelector('#app-nav'));"
" return { duration: cs.transitionDuration, property: cs.transitionProperty };"
" }"
)
assert "0.18s" in report["duration"], (
f"the menu must transition in 180ms, got {report['duration']!r}"
)
assert "opacity" in report["property"] and "transform" in report["property"], (
f"the transition must cover the opacity/transform slide+fade, "
f"got {report['property']!r}"
)
# Opening flips class + aria together (the animated state).
_open_menu(page)
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
page.keyboard.press("Escape")
_assert_menu_closed(page)
finally:
page.close()
# Reduced motion: stills in both states, open/close still works.
context = browser.new_context(
reduced_motion="reduce", viewport=MOBILE
)
rpage = context.new_page()
try:
rpage.goto(app_url)
_wait_settled_anonymous(rpage)
def _stilled(el: str) -> str:
return rpage.evaluate(
f"() => getComputedStyle(document.querySelector('{el}')).transitionDuration"
)
assert _stilled("#app-nav") == "0s", (
f"reduced motion: closed state must not transition, got {_stilled('#app-nav')!r}"
)
rpage.click("#nav-toggle")
expect(rpage.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
expect(rpage.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
assert _stilled("#app-nav") == "0s", (
"reduced motion: the .is-open state must not transition either "
"(the override must out-specificity .app-nav.is-open)"
)
rpage.keyboard.press("Escape")
_assert_menu_closed(rpage)
assert not rpage.locator("#app-nav").is_visible(), (
"reduced motion: the closed menu must be invisible"
)
finally:
context.close()
# ---------------------------------------------------------------------------
# 6. Desktop regression: the bar is byte-identical to pre-phase-46
# ---------------------------------------------------------------------------
def test_desktop_unchanged(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC6 (regression): at 1280×800 the hamburger is absent
(``display: none`` — outside the ≤640px block) and the inline nav
renders in the bar exactly as before — admin sees all four links,
every one of them INSIDE the header band (no dropdown at this
width; the phase-34/35 bar contract is intact)."""
page = browser.new_page(viewport=DESKTOP)
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
# The hamburger is absent on desktop (global display:none).
toggle = page.locator("#nav-toggle")
expect(toggle).to_be_hidden()
assert page.evaluate(
"() => getComputedStyle(document.querySelector('#nav-toggle')).display"
) == "none", "the toggle must be display:none outside the ≤640px block"
# The inline nav: all four links visible, each box inside the
# header band (the dropdown positioning only applies ≤640px).
expect(page.locator("#app-nav")).to_be_visible()
header_box = page.locator("header.app-header").bounding_box()
assert header_box is not None
for sel in NAV_LINKS:
link = page.locator(sel)
expect(link).to_be_visible()
box = link.bounding_box()
assert box is not None
assert box["y"] >= header_box["y"] - 1, f"{sel}: link is above the header"
assert box["y"] + box["height"] <= header_box["y"] + header_box["height"] + 1, (
f"{sel}: the inline link must sit inside the header band"
)
assert _visible_nav_links(page) == list(LINK_TEXTS)
finally:
page.close()