phase: 18_nav_hamburger

**Phase 18 — collapse the nav bar into a hamburger: complete, all green.**

Verified (criteria):
- ≤640px: the tab links leave the bar, the ☰ is visible, and it opens a dropdown
  under the sticky masthead (`top: 100%`, `z-index: 41`, viewport-wide) with the
  five stacked 40px links; above 640px the one-row desktop masthead is unchanged
  and the button hidden — `data/logs/nav-task04-verify.cjs` 24/24 and
  `data/logs/nav-final-pass.cjs` 45/45; screenshots
  `data/logs/nav-hamburger-{closed,open}-360.png`, `data/logs/nav-masthead-desktop-1280.png` ✓
- The four close paths: the button again, a tab click (navigates and closes),
  Esc (focus back on the opener), and `matchMedia` resize back above the
  breakpoint (drops a stale `.is-open`; `aria-expanded` tracks state on every
  path through the single `setNavMenu()` funnel) ✓
- `uv run pytest` → 1454 passed, 15 skipped; `bash .agents/validate.sh` → exit 0
  (coverage 98.94% ≥ 94, ruff clean, pyright 0 errors, `node --check` + hygiene
  clean); served-page pins: button on all seven tabbed pages and nowhere on
  /login, CSS and binding compared on the 640px literal ✓

Real-device finding (mobile Chrome): the emulated 360px pass was blind to the
system top-edge zone — Android Chrome is edge-to-edge by default, so the sticky
bar started under the status bar and the first-row ☰ sat in a region the device
does not deliver taps to (unreachable, not unbound; the same symptom brain of
reese fixed in production). The ≤640px block now pads the bar by
`env(safe-area-inset-top, 0px)` (the block's `padding-block` value restated on
the top side), verified on the operator's phone; `env()` resolves to 0 on every
non-notch surface, so all other widths render byte-identical. The served-byte
pin's rule loop carries the line; CDP's `safeAreaInsets` override does not feed
`env()`, which is the honest limit of the emulated proof.

Docs: AGENTS.md *UI conventions* masthead entry extended with the three pins,
the drift-comparison, the safe-area rule, and the real-device verification
caveat; README's `web/` bullet names the ≤640px collapse with the desktop
masthead unchanged above the breakpoint.

Deviations: pinned the shipped token shape (`#nav-toggle { display:
var(--nav-toggle-display) }` + the `:root` token) instead of the task file's
literal `.nav-toggle { display: none }` — that literal loses to
`button.ghost { display: inline-flex }` (task 01 measured it); the test
docstring says so, so nobody "restores" the broken shape. `uv pip list
--outdated` still advisory (fastapi 0.153.0 → 0.156.2); this phase moved no
dependency. NOTES.md (emptied) / TODO.md (operator items added) are
harness-side changes — left in the working tree, as in phase 17.

Next pending phase: `.agents/phases/todo/19_ups_events_expandable/`.

LLM-Generated: true
This commit is contained in:
2026-10-20 16:47:10 -04:00
parent b02c13f7d3
commit 10f86772ee
35 changed files with 752 additions and 14 deletions
+156
View File
@@ -3850,6 +3850,162 @@ def test_the_disks_tab_is_in_the_masthead_of_every_page(client) -> None:
< html.index('href="/llm"')), "tab order is machine, drive, engine, power"
# ---- the collapsing masthead nav (phase 18) -------------------------------- #
#
# At ≤640px the five tab links leave the bar and come back as a dropdown under it, opened by
# one `☰` button. There is no JS test runner (a locked anchor), so the phase is pinned the way
# every page surface here is: on the served bytes. Three questions, in the order the pieces
# were built — is the button on every tabbed page and on no others, does the stylesheet still
# hide it above the breakpoint, and do the stylesheet and the binding still agree on the
# breakpoint itself? That last one is the only failure mode no browser check catches: a number
# that drifts in one file opens a menu into a `display: none` nav on the operator's phone.
#: The attributes that make `#nav-toggle` a button and not a mystery glyph: it is not a form
#: control (`type`), it says what it toggles (`aria-controls`), it says what state it is in
#: (`aria-expanded`, which `app.js` keeps honest), and its only content is a `☰`.
NAV_TOGGLE_ATTRS = ('class="ghost nav-toggle"', 'type="button"', 'aria-expanded="false"',
'aria-controls="nav-tabs"', 'aria-label="Toggle navigation"')
def _masthead(html: str) -> str:
"""The served page's own masthead — the block the toggle and the nav have to live in.
Slicing rather than searching the whole document is the point: a button in the footer would
satisfy `id="nav-toggle" in html` and anchor to nothing, because the dropdown's
`top: 100%` resolves against `.masthead`.
"""
start = html.index('<header class="masthead"')
return html[start:html.index("</header>", start)]
def test_the_nav_toggle_is_in_the_masthead_of_every_tabbed_page(client) -> None:
"""Seven pages, one identical button, and the nav it controls still carrying five tabs.
The dropdown is the only way to change dashboard on a phone, so a page missing the button is
a page you can enter and leave the tab bar on, and a page whose nav lost its `id` is a button
that toggles nothing. Pinned on all seven (`MARKUP_PAGES`) rather than on `PAGES` because the
two admin pages have the same masthead and therefore the same contract.
"""
for path in MARKUP_PAGES:
header = _masthead(client.get(path).text)
button = re.search(r'<button[^>]*id="nav-toggle"[^>]*>', header, re.S)
assert button, f"{path}'s masthead has no #nav-toggle button"
for attr in NAV_TOGGLE_ATTRS:
assert attr in button.group(0), f"{path}'s nav toggle is missing {attr}"
assert header.count('id="nav-toggle"') == 1, f"{path} has more than one nav toggle"
# The nav is the menu: the id the button names, and all five dashboards inside it.
assert '<nav class="tabs" id="nav-tabs" aria-label="Dashboards">' in header, \
f"{path}'s tab nav has no #nav-tabs for the toggle to control"
nav = header[header.index('id="nav-tabs"'):header.index("</nav>",
header.index('id="nav-tabs"'))]
for tab in PAGES:
assert f'href="{tab}"' in nav, f"{path} collapsed a nav that lost the {tab} tab"
# Order inside the masthead: brand, toggle, nav — and the cluster after them, because
# the ≤640px block right-aligns the button and wraps the cluster onto its own row.
assert (header.index('id="nav-toggle"') < header.index('id="nav-tabs"')
< header.index('class="masthead-right"')), \
f"{path} put the toggle or the nav outside the brand → tabs → cluster row"
def test_the_login_page_carries_no_nav_and_no_toggle(tmp_path: Path) -> None:
"""`/login` is brand link + theme toggle, and the shared binding must be inert there.
No `client` fixture, and that is the whole shape of the test: the signed-in one cannot see
this page at all (a signed-in `GET /login` is a `303` to the dashboard, and following it
audits `/`'s markup under the name `/login`), which is the same redirect trap
`tests/test_auth_api.py::test_the_login_page_carries_no_id_nothing_reads_and_no_dashboard_nav`
documents for the orphan-`id` audit.
Every tab on this page bounces straight back to it, so the page renders no tabs at all; a
stray hamburger would be a control that opens a menu of redirects, and the `640px` block
would reveal it. The last assertion is the other half of the contract: `app.js` still loads
here, which is only safe because `if (navToggle && appNav)` no-ops without the elements.
"""
db = str(tmp_path / "login-nav.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2), store)
try:
with TestClient(app) as anon:
response = anon.get("/login")
assert response.status_code == 200, \
"a signed-out /login must be the form, not a bounce"
html = response.text
finally:
store.close()
for absent in ('id="nav-toggle"', 'id="nav-tabs"', 'class="tabs"',
'aria-controls="nav-tabs"', 'aria-label="Dashboards"'):
assert absent not in html, f"/login renders the collapsed nav's {absent}"
assert '<a class="brand" href="/">' in html and 'id="theme-toggle"' in html, \
"/login lost the two pieces of chrome that do belong on it"
assert 'src="/static/app.js"' in html, (
"/login stopped loading the shared chrome whose guard is its no-toggle contract")
def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> None:
"""Hidden by default, revealed at one breakpoint, in two files that must agree on its value.
The base rule is *not* a literal `.nav-toggle { display: none }` and pinning that literal
would be pinning a bug: the element also carries the shared `ghost` class, whose
`button.ghost { display: inline-flex }` out-ranks a single-class rule and ships a visible
button into the desktop masthead. The shipped shape is an id rule reading a token
(`--nav-toggle-display`, `none` in `:root`), which the ≤640px block flips by re-declaring the
token on the element — so that is what is asserted here, plus the dropdown and the four close
paths the binding owns.
"""
css = _code(_css(client))
assert re.search(r"--nav-toggle-display:\s*none", css), \
"the nav toggle is no longer hidden by default — the desktop masthead grew a ☰"
assert re.search(r"#nav-toggle\s*\{\s*display:\s*var\(--nav-toggle-display\)", css), \
"#nav-toggle stopped reading the token that keeps its reveal order-independent"
nav_css = _code(_css_section(_css(client), "mobile nav"))
media = re.search(r"@media\s*\(max-width:\s*(\d+)px\)", nav_css)
assert media, "the mobile nav block lost its media query"
for rule in ("--nav-toggle-display: inline-flex", # the reveal, through task 01's token
"nav.tabs { display: none; }", # the links leave the row
"nav.tabs.is-open", # …and come back as the menu
"position: absolute",
"top: 100%", # pinned under the sticky masthead
"z-index: 41", # one above the masthead's 40
"background: var(--bg-raised)",
"flex-wrap: wrap", # the two-row bar, cluster still visible
# the safe-area inset keeps the first-row #nav-toggle out of the system
# top-edge zone a real phone's status bar owns (taps there never reach
# the page; headless emulation has no such zone, so only this pin says
# the bar still pads for it):
"padding-top: calc(.5rem + env(safe-area-inset-top, 0px))"):
assert rule in nav_css, f"the ≤640px nav block lost `{rule}`"
# Nothing on the phone masthead may be hidden to make the width work: the cluster wraps.
assert ".masthead-right" in nav_css and "#nav-status" in nav_css, \
"the mobile masthead hid the status cluster instead of wrapping it"
js = _code(client.get("/static/app.js").text)
binding = js[js.index("const navToggle ="):js.index("function rangeControl(")]
assert "if (navToggle && appNav)" in binding, \
"the binding lost the guard that makes it inert on /login"
assert binding.count("classList.toggle('is-open'") == 1, \
"a state change now bypasses setNavMenu(), so is-open and aria-expanded can diverge"
for path in ("navToggle.setAttribute('aria-expanded'",
"navToggle.addEventListener('click'", # close (and open) on the button
"event.target.closest('a')", # close on a tab click, delegated
"event.key === 'Escape'", # close on Esc…
"navToggle.focus()", # …with focus back on the opener
"if (!mq.matches) setNavMenu(false)"): # close on resize back to desktop
assert path in binding, f"the nav binding lost a close path: {path}"
assert "mq.addEventListener" in binding, "the resize-back close was never registered"
js_bp = re.search(r"matchMedia\('\(max-width: (\d+)px\)'\)", js)
assert js_bp, "app.js no longer reads the nav breakpoint through matchMedia"
assert js_bp.group(1) == media.group(1), (
f"the nav breakpoint drifted: CSS hides the tabs below {media.group(1)}px, "
f"app.js closes the menu below {js_bp.group(1)}px")
assert media.group(1) == "640", "the nav breakpoint moved off the 640px the phase shipped"
def test_the_header_status_dot_picks_up_the_disks_job(client) -> None:
"""`disks` gets its own dot instead of hiding inside `hosts`.