Files
brain-of-reese/tests/unit/test_hamburger_nav.py
T
ducoterra 1f0e4c6bb9
Build and Push Containers / build-and-push-app (push) Successful in 1m53s
Build and Push Containers / build-and-push-db (push) Successful in 11s
fix(ui): bind the shared header controls on explicit init, not at module import
header.js's control bindings (sign-out, the mobile hamburger, the
SINGLE New chat button) ran at module import. The Containerfile stage-1
build inlines header.js into every bundle that imports it (the shell's
app.js, token-gate.js and the router's lazy views), so the shell page
registered the #nav-toggle click handler twice, and two toggle handlers
cancel each other — one tap = open + close = the mobile menu dead in
the deployed image only. The dev tree's single ESM instance (and every
test that runs against it) never showed it; a lazy view load adding a
THIRD copy made the menu work again, which is why the failure looked
state-dependent (chat cold boot dead, /sources.html alive).

- header.js: the three bindings move into an exported
  bindSharedHeaderControls(), guarded by a marker on <body> (NOT module
  state — every bundle copy has its own function instance), so later
  bundle copies and repeated inits (the token gate's mid-page header
  re-boot) are no-ops; header.js is now side-effect-free at top level,
  which also lets esbuild tree-shake the dead copies out of the bundles
  that do not need them (the token-gate bundle no longer carries the
  binding code at all)
- app.js / login.js / shared.js / document.js: call
  bindSharedHeaderControls() once at module top — import-time parity,
  unconditional (no async boot path to miss); doc-edit.js ships no
  header controls and calls nothing
- unit: tests/unit/test_header_bindings_once_per_document.py pins the
  contract — the init export, the document-level idempotency marker,
  all three bindings inside the init, NO top-level addEventListener
  remaining, and exactly one module-top call in each header-carrying
  page script; stale import-time docstrings in the legacy header pins
  updated to the new contract

Verified: full unit + integration suite (1746 passed), the hamburger /
pinned-composer / smoke E2E stories green in isolation, ruff + pyright
clean. Containerfile-equivalent esbuild 0.25.5 rebuild probed in
Chromium: exactly ONE #nav-toggle click listener on chat cold boot,
/sources.html and login.html, and a touch tap opens the menu in all
three states (pre-fix production: two listeners on cold boot = dead,
three on sources = alive).
2026-09-08 22:31:45 -04:00

551 lines
24 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.
"""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 explicit-init pattern as the sign-out binding (header.js's
``bindSharedHeaderControls`` — 2026-09-08: import-time binding ran
once per bundle copy under the Containerfile build): 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_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
(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 explicit-init pattern (like the
sign-out binding, inside bindSharedHeaderControls): look up
#nav-toggle and #app-nav with querySelector at init, 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 — plus,
since phase 88, the body-level ``nav-menu-open`` marker (task 03:
it rides on setNavMenu — the same single choke point — so the
marker can never stick while the ≤640px CSS hides the chat
cluster behind the open menu). No OTHER class may 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 classes the section manipulates are the container's
# state (is-open) and the phase-88 body marker (nav-menu-open).
class_toggles = re.findall(r"classList\.(?:add|remove|toggle)\(\s*\"([^\"]+)\"", section)
assert class_toggles == ["is-open", "nav-menu-open"], (
f"only the container .is-open class + the phase-88 "
f"body.nav-menu-open marker may move (found {class_toggles})"
)