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.
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
"""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()
|
||||
@@ -119,7 +119,7 @@ def _bar_selector(page_kind: str) -> str:
|
||||
return ".doc-header .app-header" if page_kind == "viewer" else ".app-header"
|
||||
|
||||
|
||||
def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
|
||||
def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = False) -> None:
|
||||
"""Assert the phase-19 shared-bar contract on the page the ``page``
|
||||
is already showing.
|
||||
|
||||
@@ -128,6 +128,13 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
|
||||
in the HTML, so "exactly one is visible" means /api/whoami resolved
|
||||
and header.js (``initSharedHeader``) did its toggle — before any
|
||||
assertion runs.
|
||||
|
||||
``mobile`` (phase 46, owner permission 2026-08-27, ``TODO.md`` L9):
|
||||
at ≤640px the nav links no longer sit inline — the bar carries the
|
||||
44px ``#nav-toggle`` hamburger and the nav ships as the CLOSED
|
||||
(invisible) dropdown. Per-role link visibility INSIDE the menu is
|
||||
pinned by ``test_mobile_hamburger_nav.py`` (phase 46, task 03); this
|
||||
helper pins the bar-level contract only.
|
||||
"""
|
||||
# Settled auth state: exactly one of Sign in / Sign out is visible
|
||||
# (phase-16 semantics, now owned by the shared module).
|
||||
@@ -147,14 +154,24 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
|
||||
# hidden and are revealed for admin (phase-16 UX revision, owner
|
||||
# permission 2026-08-23; the soft-gate page and the A10 API split
|
||||
# are untouched).
|
||||
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
|
||||
for link_id in ("#nav-sources", "#nav-tuning"):
|
||||
nav = page.locator(link_id)
|
||||
assert nav.count() == 1, f"one {link_id} expected on the {page_kind} page"
|
||||
if admin:
|
||||
expect(nav).to_be_visible()
|
||||
else:
|
||||
expect(nav).to_be_hidden()
|
||||
#
|
||||
# Phase 46 (owner permission 2026-08-27, ``TODO.md`` L9): at ≤640px
|
||||
# the links live in the #nav-toggle dropdown instead — the bar
|
||||
# shows the hamburger and the nav is the closed (invisible +
|
||||
# non-interactive) panel; the per-role link visibility inside the
|
||||
# menu is pinned by test_mobile_hamburger_nav.py (phase 46 task 03).
|
||||
if mobile:
|
||||
expect(page.locator("#nav-toggle")).to_be_visible()
|
||||
expect(page.locator("#app-nav")).to_be_hidden()
|
||||
else:
|
||||
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
|
||||
for link_id in ("#nav-sources", "#nav-tuning"):
|
||||
nav = page.locator(link_id)
|
||||
assert nav.count() == 1, f"one {link_id} expected on the {page_kind} page"
|
||||
if admin:
|
||||
expect(nav).to_be_visible()
|
||||
else:
|
||||
expect(nav).to_be_hidden()
|
||||
|
||||
if page_kind == "viewer":
|
||||
# The document itself has settled (rendered, not Loading…/not-found)
|
||||
@@ -342,6 +359,12 @@ def test_sign_out_from_viewer_returns_to_anonymous(
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Mobile (375×812): 58px bars, no horizontal overflow, in BOTH auth
|
||||
# states — the new pills never grow the bar
|
||||
#
|
||||
# Phase 46 adaptation (owner permission 2026-08-27, ``TODO.md`` L9):
|
||||
# at ≤640px the nav links leave the bar — the hamburger (#nav-toggle)
|
||||
# is visible and the nav is the closed dropdown; the per-role link
|
||||
# visibility inside the menu is pinned by
|
||||
# test_mobile_hamburger_nav.py (phase 46, task 03).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -359,15 +382,16 @@ def test_mobile_bar_fits_and_heights_held(
|
||||
):
|
||||
page.goto(app_url + path)
|
||||
# 58px at 375px is asserted inside assert_shared_bar…
|
||||
assert_shared_bar(page, admin=admin, page_kind=kind)
|
||||
# …and the pills (icon-only at ≤640px) fit without overflow.
|
||||
assert_shared_bar(page, admin=admin, page_kind=kind, mobile=True)
|
||||
# …and the pills (icon-only at ≤640px) + the hamburger fit
|
||||
# without overflow.
|
||||
_assert_no_overflow(page, f"{kind} @375px (admin={admin})")
|
||||
|
||||
# Anonymous: the two icon pills are Sign in + New chat.
|
||||
check_all(admin=False)
|
||||
|
||||
# Signed in: Sign out + the Sources nav link join the bars — and the
|
||||
# bar never grows.
|
||||
# Signed in: Sign out joins the bars (the admin-only nav links join
|
||||
# the MENU, not the bar — phase 46) — and the bar never grows.
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/")
|
||||
check_all(admin=True)
|
||||
|
||||
Reference in New Issue
Block a user