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:
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user