At <=640px the RAG "Sync sources" pill and the History "Refresh" pill squeezed down to tiny icon-only buttons — hard to discover and tap on a phone. They are now full-width labeled pills: - the RAG page-head row wraps so the Sync pill drops below the "Knowledge base" title at full width; the History page-head already wrapped the pill below its title block - the Sync label's min(16rem, 40vw) cap lifts on mobile (min-width: 0 engages the ellipsis) so the live-file text truncates against the full width instead of the 40vw cap - the Refresh glyph joins its visible label (it stays hidden on desktop, where the label carries the pill) This matches the established mobile full-width pill language (New chat / Share / stale-ban Regenerate). The three unit tests that pinned the old icon-only CSS are updated to pin the new behavior.
513 lines
22 KiB
Python
513 lines
22 KiB
Python
"""Unit: the mobile hamburger nav — markup + CSS contract (phase 46).
|
||
|
||
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."
|
||
|
||
This module pins the source-level contract for tasks 01 and 02:
|
||
|
||
* all SIX pages carry the identical ``#nav-toggle`` button (``aria-expanded``
|
||
/ ``aria-controls="app-nav"`` / ``aria-label="Menu"``), positioned inside
|
||
the shared ``.header-inner`` row immediately before the nav — and the nav
|
||
itself keeps the single ``<nav class="app-nav" id="app-nav">`` (no
|
||
duplicated links, so the whoami reveal keeps working unchanged);
|
||
* desktop is byte-identical to before: ``.nav-toggle { display: none }``
|
||
OUTSIDE any media query (the inline nav is untouched at >640px);
|
||
* the ≤640px block turns the nav into the animated dropdown: the 44px
|
||
toggle, the edge-to-edge absolute panel (containing block = the sticky
|
||
``.app-header``, z-index header+1), the closed state (invisible +
|
||
non-interactive), the ``.is-open`` state, the 180ms transition pair, the
|
||
comfortable menu rows, and the ``prefers-reduced-motion`` override;
|
||
* the superseded ≤640px nav-pill squeeze rules are gone (the 900px tablet
|
||
block still squeezes the inline nav at 641–900px, and the action pills'
|
||
squeeze rules + the 58px bar height are untouched);
|
||
* the header.js toggle behavior (task 02) is ONE module-owned binding —
|
||
the same import-time pattern as the sign-out/steering bindings: null-
|
||
safe lookups of ``#nav-toggle`` + ``#app-nav``, a ``setNavMenu`` that
|
||
syncs BOTH the ``.is-open`` class and ``aria-expanded``, a click
|
||
toggle, a delegated nav-link close, an Esc close that refocuses the
|
||
opener, and a matchMedia resize-back-to-desktop close; the binding
|
||
touches ONLY the container (the ship-hidden whoami links are left to
|
||
``initSharedHeader``).
|
||
|
||
The browser behavior (open/close, Esc, link-close, resize-close, the menu's
|
||
animated states) is E2E-covered by ``tests/e2e/test_mobile_hamburger_nav.py``
|
||
(task 03).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||
ASSETS = FRONTEND / "assets"
|
||
STYLES_CSS = ASSETS / "styles.css"
|
||
|
||
#: The app's pages (phase 46: the shared bar contract extends to the
|
||
#: phase-35 git-sources page — the hamburger is part of that bar; the
|
||
#: phase-50 History page and the phase-51 shared page carry the same
|
||
#: bar). Phase 76 (task 02): the folded RAG + Sources files are gone —
|
||
#: the shell (index.html) stands in for both views. Phase 76 (task
|
||
#: 03): the History file is gone too — the shell stands in for the
|
||
#: History view (all four folded view files deleted).
|
||
PAGES = (
|
||
FRONTEND / "index.html",
|
||
FRONTEND / "document.html",
|
||
FRONTEND / "login.html",
|
||
FRONTEND / "shared.html",
|
||
)
|
||
|
||
NAV_TAG = '<nav class="app-nav" id="app-nav" aria-label="Primary">'
|
||
|
||
|
||
def _text(path: Path) -> str:
|
||
assert path.is_file(), f"missing frontend file: {path}"
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def _css() -> str:
|
||
"""styles.css with comments stripped (a comment may legally carry
|
||
braces — e.g. the [hidden] rule documents the UA snippet — so the
|
||
brace-matching helpers below must never see them)."""
|
||
return re.sub(r"/\*.*?\*/", "", _text(STYLES_CSS), flags=re.S)
|
||
|
||
|
||
def _media_block(css: str, query: str) -> str:
|
||
"""The full text of the FIRST ``@media <query>`` block (brace-matched,
|
||
nested rules included verbatim)."""
|
||
m = re.search(re.escape(query) + r"[^{]*\{", css)
|
||
assert m, f"missing {query!r} media query in styles.css"
|
||
depth = 0
|
||
for i in range(m.end() - 1, len(css)):
|
||
if css[i] == "{":
|
||
depth += 1
|
||
elif css[i] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return css[m.start() : i + 1]
|
||
raise AssertionError(f"unbalanced braces in {query!r} media block")
|
||
|
||
|
||
def _global_css(css: str) -> str:
|
||
"""The rules OUTSIDE any @media block (the desktop baseline), in file
|
||
order."""
|
||
out: list[str] = []
|
||
pos = 0
|
||
while True:
|
||
m = re.search(r"@media[^{]*\{", css[pos:])
|
||
if not m:
|
||
out.append(css[pos:])
|
||
break
|
||
start = pos + m.end() - 1 # the @media's own opening brace
|
||
depth = 0
|
||
i = start
|
||
while i < len(css):
|
||
if css[i] == "{":
|
||
depth += 1
|
||
elif css[i] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
break
|
||
i += 1
|
||
out.append(css[pos:start])
|
||
pos = i + 1
|
||
return "".join(out)
|
||
|
||
|
||
def _rule_block(css: str, selector: str) -> str:
|
||
"""The first rule body for ``selector`` (e.g. ``.app-nav.is-open``)."""
|
||
m = re.search(re.escape(selector) + r"[^{}]*\{([^}]*)\}", css)
|
||
assert m, f"missing rule for {selector!r}"
|
||
return m.group(1)
|
||
|
||
|
||
# ---------- markup: the identical toggle + labeled nav on the pages ----------
|
||
|
||
|
||
def test_all_pages_carry_the_hamburger_toggle() -> None:
|
||
"""Every page carries the #nav-toggle button with the full aria
|
||
contract: a real button (type=button), aria-expanded defaulting to
|
||
"false", aria-controls pointing at the nav, the accessible name
|
||
"Menu", and the 3-line SVG icon (aria-hidden — the name comes from
|
||
aria-label). Exactly once per page (no duplicated control)."""
|
||
for html in PAGES:
|
||
text = _text(html)
|
||
tags = re.findall(r"<button[^>]*id=\"nav-toggle\"[^>]*>", text)
|
||
assert len(tags) == 1, f"{html.name}: exactly one #nav-toggle (found {len(tags)})"
|
||
tag = tags[0]
|
||
assert 'type="button"' in tag, f"{html.name}: the toggle must be a real button"
|
||
assert 'class="nav-toggle"' in tag, f"{html.name}: the toggle class is missing"
|
||
assert 'aria-expanded="false"' in tag, (
|
||
f"{html.name}: the toggle ships closed (aria-expanded=false)"
|
||
)
|
||
assert 'aria-controls="app-nav"' in tag, (
|
||
f"{html.name}: aria-controls must point at the nav's id"
|
||
)
|
||
assert 'aria-label="Menu"' in tag, f"{html.name}: the toggle is labeled 'Menu'"
|
||
# The button body is the aria-hidden 3-line hamburger icon.
|
||
end = text.find("</button>", text.find('id="nav-toggle"'))
|
||
body = text[text.find('id="nav-toggle"') : end]
|
||
assert 'aria-hidden="true"' in body, f"{html.name}: the icon is aria-hidden"
|
||
assert "M4 7h16M4 12h16M4 17h16" in body, (
|
||
f"{html.name}: the icon is the 3-line hamburger path"
|
||
)
|
||
|
||
|
||
def test_toggle_lives_in_the_shared_bar_right_before_the_nav() -> None:
|
||
"""The toggle is part of the shared bar block: inside the
|
||
.header-inner row, immediately before the <nav> — the same position
|
||
on every page (phase-34 identical-bar contract)."""
|
||
for html in PAGES:
|
||
text = _text(html)
|
||
inner = text.find('<div class="container header-inner">')
|
||
assert inner != -1, f"{html.name}: missing the shared .header-inner row"
|
||
toggle = text.find('id="nav-toggle"', inner)
|
||
nav = text.find(NAV_TAG, inner)
|
||
assert -1 < toggle < nav, (
|
||
f"{html.name}: the toggle must sit in the shared row, before the nav"
|
||
)
|
||
# The toggle is the only new element between the brand and the nav
|
||
# (no stray control broke the phase-34 order).
|
||
segment = text[inner:nav]
|
||
assert 'class="brand"' in segment, f"{html.name}: brand precedes the toggle"
|
||
assert 'id="steering-toggle"' not in segment, (
|
||
f"{html.name}: the toggle must not follow the action controls"
|
||
)
|
||
|
||
|
||
def test_all_pages_carry_the_labeled_nav_with_id() -> None:
|
||
"""The nav keeps its single element + label and gains ONLY the id
|
||
(the whoami reveal targets the same four links — no duplicated
|
||
markup, so the phase-19/35 visibility rules apply inside the menu
|
||
exactly as before)."""
|
||
for html in PAGES:
|
||
text = _text(html)
|
||
assert text.count(NAV_TAG) == 1, (
|
||
f"{html.name}: exactly one <nav class='app-nav' id='app-nav' "
|
||
"aria-label='Primary'> (no duplicated nav)"
|
||
)
|
||
# The four links, all still shipping inside that nav (the
|
||
# admin-only three keep their hidden attributes).
|
||
region = text[text.find(NAV_TAG) : text.find("</nav>")]
|
||
for link in ('href="/" class="nav-link', 'id="nav-sources" hidden',
|
||
'id="nav-git-sources" hidden', 'id="nav-tuning" hidden'):
|
||
assert link in region, f"{html.name}: nav lost {link!r}"
|
||
|
||
|
||
# ---------- CSS: desktop byte-identical, mobile dropdown ----------
|
||
|
||
|
||
def test_toggle_is_absent_on_desktop_outside_media_queries() -> None:
|
||
"""Global (non-media) CSS hides the toggle — the desktop bar is
|
||
byte-identical to pre-phase-46 (the ≤640px block re-displays it)."""
|
||
css = _global_css(_css())
|
||
block = _rule_block(css, ".nav-toggle")
|
||
assert "display: none" in block, (
|
||
".nav-toggle must be display:none outside media queries (desktop)"
|
||
)
|
||
|
||
|
||
def test_header_z_index_is_20_so_the_dropdown_uses_21() -> None:
|
||
"""The dropdown's z-index is the header's z-index + 1 — pinned as a
|
||
RELATIONSHIP (if the header z-index ever moves, the menu must move
|
||
with it)."""
|
||
css = _css()
|
||
header = _rule_block(_global_css(css), ".app-header")
|
||
m = re.search(r"z-index:\s*(\d+)", header)
|
||
assert m, "the .app-header must keep its z-index"
|
||
assert int(m.group(1)) + 1 == 21, "the dropdown is pinned at header+1 (21)"
|
||
mobile = _media_block(css, "@media (max-width: 640px)")
|
||
assert "z-index: 21" in _rule_block(mobile, ".app-nav")
|
||
|
||
|
||
def test_mobile_block_renders_the_44px_toggle() -> None:
|
||
"""At ≤640px the toggle is a 44px×44px button (the phase-07 touch
|
||
floor), ghost look like the other pills, a hover state in the
|
||
shared pill hover family (brand-ink on brand-soft, as
|
||
.nav-link:hover), and a SIZED icon (an unsized inline SVG defaults
|
||
to 300px and would blow the bar out at 360px).
|
||
:focus-visible needs no rule — the global 3px outline applies."""
|
||
mobile = _media_block(_css(), "@media (max-width: 640px)")
|
||
block = _rule_block(mobile, ".nav-toggle")
|
||
for decl in (
|
||
"display: inline-flex",
|
||
"align-items: center",
|
||
"justify-content: center",
|
||
"width: 44px",
|
||
"height: 44px",
|
||
"border: 0",
|
||
"border-radius: var(--radius-sm)",
|
||
"cursor: pointer",
|
||
):
|
||
assert decl in block, f"mobile .nav-toggle missing {decl!r}"
|
||
assert "color: var(--ink)" in block, "the icon ink is the page ink"
|
||
hover = _rule_block(mobile, ".nav-toggle:hover")
|
||
assert "background: var(--brand-soft)" in hover
|
||
assert "color: var(--brand-ink)" in hover, (
|
||
"hover matches the shared pill hover family (≈6.9:1 pair)"
|
||
)
|
||
icon = _rule_block(mobile, ".nav-toggle svg")
|
||
assert "width: 20px" in icon and "height: 20px" in icon, (
|
||
"the hamburger icon must be sized (no 300px default)"
|
||
)
|
||
|
||
|
||
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
|
||
(the containing block — .header-inner is not positioned), the
|
||
surface background + hairline + existing shadow token, and the
|
||
slide+fade closed state with the 180ms transition pair."""
|
||
mobile = _media_block(_css(), "@media (max-width: 640px)")
|
||
block = _rule_block(mobile, ".app-nav")
|
||
for decl in (
|
||
"position: absolute",
|
||
"top: 100%",
|
||
"left: 0",
|
||
"right: 0",
|
||
"flex-direction: column",
|
||
"background: var(--surface)",
|
||
"border-bottom: 1px solid var(--line)",
|
||
"box-shadow: var(--shadow-lg)",
|
||
"z-index: 21",
|
||
# closed state — invisible and non-interactive
|
||
"visibility: hidden",
|
||
"opacity: 0",
|
||
"transform: translateY(-8px)",
|
||
"pointer-events: none",
|
||
# the 180ms slide+fade (visibility is the delayed snap)
|
||
"transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 180ms",
|
||
):
|
||
assert decl in block, f"mobile .app-nav dropdown missing {decl!r}"
|
||
assert "gap: 0" in block, "column menu rows stack with no pill gap"
|
||
assert "margin-left: 0" in block, (
|
||
"the desktop margin-left:auto must not shift the absolute panel"
|
||
)
|
||
|
||
|
||
def test_mobile_open_state_is_the_only_opener() -> None:
|
||
"""The .is-open state (added by task 02's header.js) flips every
|
||
closed-state property back, with the matching 180ms transition and
|
||
an UNDELAYED visibility snap (the menu can never linger visible but
|
||
dead while closing)."""
|
||
mobile = _media_block(_css(), "@media (max-width: 640px)")
|
||
block = _rule_block(mobile, ".app-nav.is-open")
|
||
for decl in (
|
||
"visibility: visible",
|
||
"opacity: 1",
|
||
"transform: none",
|
||
"pointer-events: auto",
|
||
"transition: opacity 180ms ease, transform 180ms ease, visibility 0s",
|
||
):
|
||
assert decl in block, f".app-nav.is-open missing {decl!r}"
|
||
|
||
|
||
def test_mobile_menu_rows_are_comfortable_targets() -> None:
|
||
"""The menu rows supersede the pill-squeeze: 1rem text with 0.75rem
|
||
vertical padding (≥44px with the line box) — the old 0.72rem /
|
||
0.3rem 0.25rem squeeze is gone from the ≤640px block."""
|
||
mobile = _media_block(_css(), "@media (max-width: 640px)")
|
||
rows = _rule_block(mobile, ".app-nav .nav-link")
|
||
assert "padding: 0.75rem 1.25rem" in rows
|
||
assert "font-size: 1rem" in rows
|
||
# The superseded squeeze rules are GONE from the ≤640px block…
|
||
assert "0.72rem" not in mobile, "the 0.72rem nav-pill font is superseded"
|
||
assert "padding: 0.3rem 0.25rem" not in mobile, (
|
||
"the 0.3rem 0.25rem nav-pill padding is superseded"
|
||
)
|
||
assert "gap: 0.05rem" not in mobile, "the 0.05rem nav gap is superseded"
|
||
# …while the rest of the block is untouched: the tablet block still
|
||
# squeezes the inline nav at 641–900px, the action pills keep their
|
||
# icon-only squeeze, and the 58px bar height is pinned.
|
||
tablet = _media_block(_css(), "@media (max-width: 900px)")
|
||
assert re.search(r"\.nav-link\s*\{[^}]*0\.85rem", tablet), (
|
||
"the 900px tablet squeeze of the inline nav must be untouched"
|
||
)
|
||
assert "gap: 0.15rem" in tablet, "the 900px nav gap squeeze must be untouched"
|
||
for untouched in (
|
||
".new-chat-label { display: none; }",
|
||
".auth-label { display: none; }",
|
||
":root { --header-h: 58px; }",
|
||
):
|
||
assert untouched in mobile, f"the ≤640px block lost {untouched!r}"
|
||
|
||
|
||
def test_reduced_motion_stills_the_menu() -> None:
|
||
"""prefers-reduced-motion kills the 180ms slide+fade in BOTH states —
|
||
open/close snaps (the visibility/opacity flip applies instantly) and
|
||
stays correct. The override must name .app-nav.is-open too: that rule
|
||
carries higher specificity (0,2,0) than a bare .app-nav (0,1,0), so
|
||
without it the OPEN transition would still animate under the
|
||
preference (verified live in Chromium, phase 46 task 03)."""
|
||
css = _css()
|
||
blocks = []
|
||
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\)[^{]*\{", css):
|
||
depth = 0
|
||
for i in range(m.end() - 1, len(css)):
|
||
if css[i] == "{":
|
||
depth += 1
|
||
elif css[i] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
blocks.append(css[m.start() : i + 1])
|
||
break
|
||
assert any(
|
||
".app-nav.is-open" in b and "transition: none" in b for b in blocks
|
||
), (
|
||
"a prefers-reduced-motion block must still .app-nav in BOTH the "
|
||
"closed and .is-open states (specificity: a bare .app-nav rule "
|
||
"loses to .app-nav.is-open)"
|
||
)
|
||
|
||
|
||
# ---------- header.js: the module-owned toggle binding (task 02) ----------
|
||
|
||
HEADER_JS = ASSETS / "header.js"
|
||
|
||
|
||
def _js() -> str:
|
||
return _text(HEADER_JS)
|
||
|
||
|
||
def _hamburger_section(js: str) -> str:
|
||
"""The header.js mobile-hamburger section (from its banner comment to
|
||
the next section banner), so the pins target the binding and not
|
||
unrelated code that happens to mention the same ids."""
|
||
start = js.find("/* ---------- mobile hamburger")
|
||
assert start != -1, "missing the mobile hamburger section in header.js"
|
||
end = js.find("/* ----------", start + 1)
|
||
assert end != -1, "the hamburger section must end before the next section"
|
||
return js[start:end]
|
||
|
||
|
||
def _js_clean(code: str) -> str:
|
||
"""JS with block + line comments stripped (comments may legally
|
||
contain keywords — the pins below must match executable code
|
||
only). The hamburger section carries no string literals with ``//``
|
||
in them, so the line-comment strip is safe here."""
|
||
code = re.sub(r"/\*.*?\*/", "", code, flags=re.S)
|
||
return re.sub(r"//[^\n]*", "", code)
|
||
|
||
|
||
def test_header_js_looks_up_toggle_and_nav_null_safe() -> None:
|
||
"""The binding follows the module's import-time pattern (like the
|
||
sign-out binding): look up #nav-toggle and #app-nav with
|
||
querySelector at import, and guard the whole binding block behind
|
||
``navToggle && appNav`` — a page lacking either element is a
|
||
complete no-op."""
|
||
js = _js()
|
||
assert 'document.querySelector("#nav-toggle")' in js, (
|
||
"header.js must look up the #nav-toggle button"
|
||
)
|
||
assert 'document.querySelector("#app-nav")' in js, (
|
||
"header.js must look up the #app-nav container"
|
||
)
|
||
section = _hamburger_section(js)
|
||
assert "if (navToggle && appNav)" in section, (
|
||
"the binding block must be null-safe (navToggle && appNav guard)"
|
||
)
|
||
|
||
|
||
def test_set_nav_menu_syncs_both_class_and_aria() -> None:
|
||
"""The single state setter syncs BOTH surfaces at once — the
|
||
animated ``.is-open`` class on the nav AND the toggle's
|
||
aria-expanded (true/false) — so the ARIA state can never drift
|
||
from the visual state."""
|
||
section = _hamburger_section(_js())
|
||
assert "function setNavMenu(open)" in section, (
|
||
"a setNavMenu state setter must own the open/close transition"
|
||
)
|
||
assert 'appNav.classList.toggle("is-open", open)' in section, (
|
||
"setNavMenu must set/unset the .is-open class on the nav"
|
||
)
|
||
assert 'navToggle.setAttribute("aria-expanded"' in section, (
|
||
"setNavMenu must keep aria-expanded in sync"
|
||
)
|
||
assert '"true"' in section and '"false"' in section, (
|
||
"aria-expanded must be set to the explicit true/false strings"
|
||
)
|
||
|
||
|
||
def test_click_on_the_toggle_toggles_the_menu() -> None:
|
||
"""Clicking the toggle flips the CURRENT state (read from the
|
||
.is-open class, not a local boolean — a second binding could
|
||
never desync it)."""
|
||
section = _hamburger_section(_js())
|
||
assert 'navToggle.addEventListener("click"' in section, (
|
||
"the toggle button must own the click-to-open/close binding"
|
||
)
|
||
assert "setNavMenu(!appNav.classList.contains(\"is-open\"))" in section, (
|
||
"the click must toggle the menu off its current .is-open state"
|
||
)
|
||
|
||
|
||
def test_delegated_nav_link_click_closes_the_menu() -> None:
|
||
"""A click on ANY nav link (delegated on the nav container — the
|
||
links keep their hidden/whoami contract, so no per-link binding)
|
||
shuts the menu; the navigation then proceeds normally."""
|
||
section = _hamburger_section(_js())
|
||
assert 'appNav.addEventListener("click"' in section, (
|
||
"link-close must be delegated on the nav container"
|
||
)
|
||
assert "e.target.closest(\"a\")" in section, (
|
||
"the delegation must hit-test for a link (e.target.closest('a'))"
|
||
)
|
||
assert "setNavMenu(false)" in section, "a link click must close the menu"
|
||
|
||
|
||
def test_escape_closes_the_menu_and_refocuses_the_opener() -> None:
|
||
"""Esc (document-level keydown) closes the menu only while it is
|
||
open, and focus returns to the toggle — the opener — so a keyboard
|
||
user never loses their place."""
|
||
section = _js_clean(_hamburger_section(_js()))
|
||
assert 'document.addEventListener("keydown"' in section, (
|
||
"Esc-close must be a document-level keydown binding"
|
||
)
|
||
assert 'e.key === "Escape"' in section, "the handler must match the Escape key"
|
||
assert 'appNav.classList.contains("is-open")' in section, (
|
||
"Esc must act only while the menu is open"
|
||
)
|
||
assert "setNavMenu(false)" in section, "Esc must close the menu"
|
||
assert "navToggle.focus()" in section, (
|
||
"Esc-close must return focus to the toggle (the opener)"
|
||
)
|
||
|
||
|
||
def test_resizing_back_to_desktop_closes_the_menu() -> None:
|
||
"""Crossing back above 640px drops the open state (matchMedia
|
||
change): the inline nav reappears and aria-expanded stays honest
|
||
(the ≤640px CSS scopes .is-open anyway, but the class is dropped
|
||
so a resize round-trip never ships a stale true). The modern
|
||
addEventListener API is used with the older addListener as a
|
||
defensive fallback."""
|
||
section = _hamburger_section(_js())
|
||
assert 'window.matchMedia("(max-width: 640px)")' in section, (
|
||
"the resize cleanup must watch the SAME ≤640px breakpoint as the CSS"
|
||
)
|
||
assert "if (!mq.matches) setNavMenu(false)" in section, (
|
||
"leaving mobile (mq no longer matches) must close the menu"
|
||
)
|
||
assert 'mq.addEventListener("change"' in section, (
|
||
"the modern MediaQueryList listener must be used"
|
||
)
|
||
assert "mq.addListener(" in section, "the older-engine addListener fallback"
|
||
|
||
|
||
def test_the_binding_toggles_only_the_container() -> None:
|
||
"""The auth visibility contract is untouched: the binding never
|
||
assigns ``.hidden`` and never touches the whoami links — hidden
|
||
links stay hidden inside the menu, exactly as on the inline bar.
|
||
Only the .is-open container class + aria-expanded move."""
|
||
section = _js_clean(_hamburger_section(_js()))
|
||
assert ".hidden" not in section, (
|
||
"the hamburger binding must not hide/reveal any element"
|
||
)
|
||
for link in ("nav-sources", "nav-git-sources", "nav-tuning"):
|
||
assert link not in section, (
|
||
f"the hamburger binding must not touch the {link!r} link"
|
||
)
|
||
# The only class the section manipulates is the container's state.
|
||
class_toggles = re.findall(r"classList\.(?:add|remove|toggle)\(\s*\"([^\"]+)\"", section)
|
||
assert class_toggles == ["is-open"], (
|
||
f"only the container .is-open class may move (found {class_toggles})"
|
||
)
|