phase: 20_masthead_nav_polish

**Phase 20 final verification pass — all four tasks already complete; verified in full.**

- Verified: three-row masthead CSS (`.masthead` flex-wrap + two `flex-basis: 100%`, `min-height`, no `margin-inline-start`), un-sticky `position: relative` + `z-index: 40`, 180 ms `visibility/opacity/transform` slide (closed `nav.tabs` / open `.is-open`, both states in the file's single reduced-motion block), both admin links on all 8 signed-in pages, `/login` neither.
- `uv run pytest` → **1464 passed, 15 skipped** · `uv run pytest --cov --cov-fail-under=94` → **98.94%** (floor 94, unchanged) · `ruff check src tests agent` clean · `pyright` 0 errors · **`bash .agents/validate.sh` EXIT=0** (incl. `node --check` + repo hygiene) · `uv run pytest -m live --live` → **11 passed**.
- Browser (live dev server, invariant 10): `20-task01-rows.cjs` 121 PASS/0 FAIL · `20-task02-unsticky.cjs` 81/0 · `20-task03-slide.cjs` 32/0 · `20-task04-admin-links.cjs` 87/0 · phase-18 `nav-final-pass.cjs` 45/45, `nav-task03-verify` 77, `nav-task04-verify` 24/24 · phase-19 scripts 11/22/24/29 all pass. Screenshots refreshed in `data/logs/` (`masthead-rows*`, `masthead-unsticky*`, `slide-*`, `admin-links-*`). Dev server restarted; `/api/health` all nine sources `ok`.
- Completion criteria: 3-row bar @1280/360 ✓ · scroll leaves bar behind + dropdown under whole bar ✓ · slide out/in 180 ms, reduced-motion snap ✓ · `/certs`→`⚷ Certs`, `/settings`→`⚙ Account` before theme toggle, outside `.tabs` ✓ · pytest/coverage/ruff/pyright ✓ · validate ✓ · no regression in tab structure, token reveal, safe-area padding, four close paths, status dots, theme toggle ✓.
- Docs: **AGENTS.md** gap 12 still said phase 20 was "in `todo/`… admin-link half has landed" and attributed the wrap to the old fixed 58 px row — rewritten to the completed state (three rows/un-sticky/slide/both links; 24 still todo; 21's `/certs` `.scroll` noted as not reaching `/`'s table). **README.md** `web/` bullet enumerated the controls row without `⏸ Pause` and omitted `/login` from the two-row list — both fixed. Everything else (setup, commands, env table vs `config.py`, routes vs `api.py`, agent/installer flags, testing policy) verified consistent.
- Deviations: no dependency bump — `uv pip list --outdated` still reports the pre-existing advisory `fastapi 0.153.0 → 0.156.4` (deps untouched this phase, validate treats it as advisory); `NOTES.md` is empty in the working tree from an operator edit outside these tasks (both items live in phases 20/24) — left as-is; no live-infra changes made or needed.
- Next pending phase: `21_page_cleanups` (pause button removal, `/system` GPU values, `/certs` table scroll).
This commit is contained in:
2026-10-20 22:49:37 -04:00
parent 95df8b695b
commit 6444a158f2
32 changed files with 960 additions and 106 deletions
+299 -25
View File
@@ -996,34 +996,62 @@ def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> N
store.close()
def test_every_page_links_to_both_admin_pages(client) -> None:
def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
"""`⚙ Account` and `⚷ Certs` are the only way to the two admin pages, so they are everywhere.
They sit in `.masthead-right` before the theme toggle and **outside** `.tabs`, because the tab
strip's structure — five dashboards, one of them current — is pinned by
`test_the_disks_tab_is_in_the_masthead_of_every_page` and a sixth entry with no
`aria-current` would break what that marker means.
One loop over all eight signed-in pages, because the controls are now the *same* on all of
them: clock → `⚷ Certs` → `⚙ Account` → theme toggle, in `.masthead-right` and **outside**
`.tabs` (the tab strip's structure — five dashboards, one of them current — is pinned by
`test_the_disks_tab_is_in_the_masthead_of_every_page`, and a sixth entry with no
`aria-current` would break what that marker means).
Neither page links to itself, which is the `/settings` precedent from phase 11 and the reason
the two loops below are over different tuples: an `⚷ Certs` link on `/certs` is a no-op the
operator learns to ignore, and ignored chrome is worse than none.
Both links are on the pages they name, which **inverts** phase 15's "no self-link"
precedent on the operator's decision (`TODO.md:4` — "clicking the certs tab causes the certs
button to disappear … These should remain visible"): masthead chrome that changes shape
depending on where you already are reads as a broken control, while a link to the page you
are on is a no-op that answers with the page you were looking at.
"""
account = '<a class="ghost account" href="/settings"'
certs_link = '<a class="ghost account" href="/certs"'
for path in (*PAGES, SETTINGS_PAGE):
account_open = '<a class="ghost account" href="/settings"'
certs_open = '<a class="ghost account" href="/certs"'
# the opening tag **and** its label: the claim is the cluster does not change shape between
# pages, and `/certs` shipped a theme toggle missing its `aria-pressed` for exactly the
# reason nothing ever compared the eight markups
account = f'{account_open} title="Session and password">⚙ Account</a>'
certs_link = f'{certs_open} title="Agent certificates">⚷ Certs</a>'
theme = '<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">'
for path in MARKUP_PAGES:
html = client.get(path).text
assert certs_link in html, f"{path} has no route to the certificate authority"
assert certs_link in html[:html.index('id="theme-toggle"')], \
f"{path} put the Certs link after the theme toggle"
assert account in html, f"{path} has no route to the account page"
before_theme = html[:html.index('id="theme-toggle"')]
assert certs_link in before_theme, f"{path} put the Certs link after the theme toggle"
assert account in before_theme, f"{path} put the Account link after the theme toggle"
assert before_theme.index(certs_link) < before_theme.index(account), \
f"{path} orders the two admin controls differently from the dashboards"
tabs = html[html.index('<nav class="tabs"'):html.index("</nav>")]
assert "/certs" not in tabs, f"{path} made Certs a dashboard tab"
for path in (*PAGES, CERTS_PAGE):
html = client.get(path).text
assert account in html, f"{path} has no route to the account page"
assert account in html[:html.index('id="theme-toggle"')], \
f"{path} put the Account link after the theme toggle"
assert account not in client.get(SETTINGS_PAGE).text, "/settings links to itself"
assert certs_link not in client.get(CERTS_PAGE).text, "/certs links to itself"
assert "/settings" not in tabs, f"{path} made Account a dashboard tab"
assert theme in html, f"{path}'s theme toggle no longer matches the other seven"
# The one page that renders neither, and its own client: `client` is signed in, and a
# signed-in `GET /login` is a `303` to the dashboard, which would audit `/` and call it
# `/login` (the trap documented at `test_the_login_page_carries_no_nav_and_no_toggle`).
# Pre-auth there is nothing to administer, so the masthead stays brand link + theme toggle.
db = str(tmp_path / "admin-links-login.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"
login_html = response.text
finally:
store.close()
# the opening tag, so an `href` under any other class or label still fails here
for link in (certs_open, account_open):
assert link not in login_html, f"/login offers {link} to a caller who cannot use it"
def test_the_certs_page_is_a_page_and_not_a_sixth_dashboard(client) -> None:
@@ -4037,7 +4065,7 @@ def test_the_disks_tab_is_in_the_masthead_of_every_page(client) -> None:
# 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.
# that drifts in one file opens a menu into a hidden 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
@@ -4145,13 +4173,12 @@ def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> Non
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
"nav.tabs.is-open", # the links leave the row as the menu
"position: absolute",
"top: 100%", # pinned under the sticky masthead
"top: 100%", # pinned under the whole masthead
"z-index: 41", # one above the masthead's 40
"background: var(--bg-raised)",
"flex-wrap: wrap", # the two-row bar, cluster still visible
"flex-wrap: wrap", # the controls wrap, none is hidden
# 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
@@ -4185,6 +4212,253 @@ def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> Non
assert media.group(1) == "640", "the nav breakpoint moved off the 640px the phase shipped"
# ---- the three-row masthead (phase 20 task 01) ------------------------------ #
#
# `TODO.md:5-6` asked for the per-source indicators and the `⚷ Certs ⚙ Account ☾ Dark` cluster
# each on their own left-justified line. The markup could not change — `#nav-status` is a child
# of `.masthead-right`, and the tab structure is pinned by
# `test_the_disks_tab_is_in_the_masthead_of_every_page` — so the rows are made of line breaks:
# in a `flex-wrap: wrap` container an item whose *base* size fills the line is alone on it (the
# break happens before shrink), so `flex-basis: 100%` on `.masthead-right` starts row 2 of the
# bar and the same declaration on the dots starts row 2 *inside* it, pushing the controls to
# row 3. The alternative the task allowed — `flex-direction: column` — would have put the brand
# and the tabs on separate lines, which is why the fallback shipped. A browser proves the
# geometry at 1280 px and 360 px (`data/logs/20-task01-rows.cjs`, `masthead-rows*.png`); these
# pins keep a later re-flow from putting the buttons back on the dots' line in silence.
def _rule_body(css: str, selector: str) -> str:
"""One rule's declaration block, with the comments stripped.
Anchored on the selector itself rather than a section slice: `.masthead-right {` must not
satisfy a lookup for `.masthead {`, and the ≤640px block restates both selectors.
"""
match = re.search(rf"{re.escape(selector)}\s*\{{([^}}]*)\}}", _code(css))
assert match, f"dash.css no longer has a `{selector} {{ … }}` rule"
return match.group(1)
def _declarations(css: str, selector: str) -> dict[str, str]:
"""That block as `{property: value}`, so a pin can ask whether a property exists at all."""
found: dict[str, str] = {}
for declaration in _rule_body(css, selector).split(";"):
prop, sep, value = declaration.partition(":")
if sep:
found[prop.strip()] = value.strip()
return found
def _cascade(css: str, *selectors: str) -> dict[str, str]:
"""The declarations of several rules merged in cascade order, later winning.
Needed for the ≤640px menu: since phase 20 task 03 the dropdown's *geometry* lives on the
closed `nav.tabs` rule and `.is-open` carries only the visible counterpart, because a box
that does not exist cannot animate. A pin that asks "does the open menu sit at `top: 100%`
and paint above the bar?" is asking about the element, so it has to read both rules the way
the browser does — one duplicated `position` in two rules is the drift this avoids.
"""
merged: dict[str, str] = {}
for selector in selectors:
merged.update(_declarations(css, selector))
return merged
def test_the_masthead_is_three_left_justified_rows_at_every_width(client) -> None:
"""Brand + tabs, then the indicators, then the controls — three rows, two of them flush left.
Every assertion here is one declaration the row layout depends on, read out of the
**served** stylesheet: `flex-wrap` plus the two `flex-basis: 100%` rules are the rows, the
two `justify-content` values are the left justification, and the absence of
`margin-inline-start` on the cluster is the end of the right-pinned single-row bar.
"""
css = _css(client)
bar = _declarations(css, ".masthead")
assert bar.get("flex-wrap") == "wrap" or bar.get("flex-direction") == "column", \
"the masthead can no longer break into rows: indicators and controls share a line again"
assert bar.get("flex-direction", "row") == "row", \
"flex-direction: column puts the brand and the tabs on separate rows"
assert "height" not in bar, \
".masthead has a fixed height again — three rows do not fit in --header-h and clip"
assert bar.get("min-height") == "var(--header-h)", \
".masthead lost the min-height that keeps a dots-less page from collapsing to one line"
assert bar.get("gap", "").startswith(".35rem"), \
"the row gap the three rows need is gone from the masthead's shorthand gap"
cluster = _declarations(css, ".masthead-right")
assert cluster.get("flex-basis") == "100%", \
"the control cluster no longer starts its own row under the indicators"
assert cluster.get("justify-content") == "flex-start", \
"the controls are no longer left-justified in their own row"
assert "margin-inline-start" not in cluster, \
"margin-inline-start: auto pushes the cluster back to the right edge of its own row"
dots = _declarations(css, "#nav-status")
assert dots.get("flex-basis") == "100%", \
"the status indicators no longer get a row to themselves"
assert dots.get("justify-content") == "flex-start", \
"the indicators are no longer left-justified"
def test_the_mobile_masthead_block_declares_no_second_bar_height(client) -> None:
"""One `min-height` in the file, and the safe-area padding still on the phone bar.
The ≤640px block used to own `height: auto` + `min-height` because the base rule was a
fixed `--header-h`; task 01 moved the growth to the base rule, and a mobile restatement is
exactly the pair that drifts (one says `auto`, the other a number, and the bar clips on a
phone only). The safe-area line and the wrapping cluster are the phase-18 contract and stay.
"""
css = _css(client)
nav_css = _code(_css_section(css, "mobile nav"))
block = re.search(r"\.masthead\s*\{([^}]*)\}", nav_css)
assert block, "the ≤640px block no longer styles the masthead at all"
for prop in ("height", "min-height", "flex-wrap"):
assert not re.search(rf"(^|;)\s*{prop}\s*:", block.group(1)), \
f"the ≤640px masthead re-declares `{prop}` — two rules now decide the bar's height"
assert "row-gap: .4rem" in block.group(1), "the phone lost its tighter row gap"
assert "padding-top: calc(.5rem + env(safe-area-inset-top, 0px))" in block.group(1), \
"the phone bar stopped padding for the system top-edge zone the toggle sits in"
# The dots stay one line and the cluster still wraps rather than hiding a control.
assert "#nav-status { flex-wrap: nowrap; }" in nav_css
assert re.search(r"\.masthead-right\s*\{[^}]*flex-wrap: wrap", nav_css)
# ---- the masthead that scrolls away (phase 20 task 02) ---------------------- #
#
# `TODO.md:10` — "The navbar is only sticky part way down certain pages on mobile? It shouldn't
# be sticky at all. We want to maximize the amount of screen space for data viewing." The quirk
# and the footprint had the same cause, so both went with the stick. What has to stay is the
# bar's second job: it is the containing block the ≤640px dropdown's `top: 100%` resolves
# against, which is why the declaration is `relative` and not the default `static`. These pins
# read the **served** stylesheet, so a re-stick or a `static` regression fails here rather than
# in a phone's hands. `data/logs/20-task02-unsticky.cjs` is the browser half: scrolled bars at
# 1280 px and 360 px, and the menu still landing under the bar.
def test_the_masthead_scrolls_away_and_stays_the_dropdown_anchor(client) -> None:
"""Not `sticky`, not `static` either: a relative bar scrolls and still anchors the menu.
Every assertion is one half of that trade. `sticky` (with its `top: 0`) is the pinning the
task removes and a `top` on a relative bar would shift the whole masthead down instead;
`position: relative` is the positioned ancestor the dropdown needs; the two `z-index`es are
the stacking sentence "the menu paints one above the bar", which survives the un-stick.
"""
css = _css(client)
bar = _declarations(css, ".masthead")
assert bar.get("position") == "relative", (
"the masthead stopped being the positioned ancestor the ≤640px dropdown resolves "
"against — `sticky` pins the bar to the viewport, `static` moves the menu to the top "
"of the document")
assert "sticky" not in bar.values(), \
".masthead is sticky again: the bar must scroll away for data viewing (TODO.md:10)"
assert "top" not in bar, \
"`top` belongs to a sticky bar; on a relative one it offsets the whole masthead"
assert bar.get("z-index") == "40", \
".masthead lost its z-index, so the dropdown's 41 no longer means one above the bar"
# The mobile half of the contract: the menu still opens directly under the bar and still
# paints above the content it overlaps — with the bar itself now free to scroll. Read as a
# cascade: task 03 moved the box onto the closed rule so the slide has something to animate,
# and `.is-open` is the visibility flip on top of it.
nav_css = _code(_css_section(css, "mobile nav"))
dropdown = _cascade(nav_css, "nav.tabs", "nav.tabs.is-open")
assert dropdown.get("position") == "absolute" and dropdown.get("top") == "100%", \
"the ≤640px menu no longer opens directly under the masthead"
assert int(dropdown["z-index"]) > int(bar["z-index"]), \
"the dropdown no longer stacks above the masthead it hangs from"
# ---- the menu that slides (phase 20 task 03) -------------------------------- #
#
# `TODO.md:11` — "I want a nice animation for the hamburger menu - a slide out and slide back in
# would be perfect. See ~/Project/Personal/brain-of-reese for an example." (The project is
# `brain_of_reese`; the pattern is its `.app-nav` closed/open transition pair plus the phase-46
# reduced-motion block.) It is pure CSS over the class flip `setNavMenu()` already performs,
# which is why `app.js` and its four close paths are untouched. What that pattern *does* force
# is a re-cut of the two rules: a `display: none` box has no intermediate state, so the closed
# menu is `visibility: hidden` now and carries the dropdown geometry the transition animates,
# while `.is-open` carries only the visible counterpart. Three questions are pinned here, in the
# order that makes the pair readable: does the closed state name the invisible, non-interactive
# start of the slide; does the open state name its visible end with the `visibility` delay
# dropped; and does `prefers-reduced-motion` name **both** states, because an open rule left out
# of that block keeps animating. `data/logs/20-task03-slide.cjs` is the browser half: the
# computed closed state, a mid-transition frame, the four close paths, the no-phantom-scroll
# check, and the snap under `prefers-reduced-motion: reduce`.
#: The one duration the slide uses, quoted into both states so a pin can demand they agree.
NAV_SLIDE_MS = "180ms ease"
def test_the_closed_phone_menu_is_the_start_of_the_slide(client) -> None:
"""Invisible, 8 px high, unhittable — and a box that exists, so it can animate.
Every declaration is one half of the closed state the reference pattern needs. `display:
none` is the one thing the block must **not** contain: it removes the box, and a transition
on a box that is not rendered never runs — which is exactly why the menu used to snap, and
why the last assertion is a regression guard and not a style preference.
"""
nav_css = _code(_css_section(_css(client), "mobile nav"))
closed = _declarations(nav_css, "nav.tabs")
assert closed.get("visibility") == "hidden", \
"the closed menu is not hidden by a property that can animate"
assert closed.get("opacity") == "0", "the slide lost its fade: the menu opens at full ink"
assert closed.get("transform") == "translateY(-8px)", \
"the slide lost its 8 px rise (`TODO.md:11`): the menu appears in place"
assert closed.get("pointer-events") == "none", \
"an invisible menu that takes clicks steals taps from the content under it"
assert closed.get("transition") == (
f"opacity {NAV_SLIDE_MS}, transform {NAV_SLIDE_MS}, visibility 0s linear 180ms"), (
"the closed state's transition is what runs on the way OUT; `visibility` must be "
f"delayed by exactly the {NAV_SLIDE_MS} slide or the box vanishes before it fades")
assert "display: none" not in nav_css, (
"a `display: none` in the ≤640px nav block is a box that cannot animate — the closed "
"menu became `visibility: hidden` in phase 20 task 03 for exactly this reason")
def test_the_open_phone_menu_is_the_visible_end_of_the_slide(client) -> None:
"""`transform: none`, and no `visibility` delay — or the menu is invisible for its own 180 ms.
`.is-open` out-ranks `nav.tabs` (two classes to one), so these four declarations are what a
click on `#nav-toggle` actually applies, and the transition line is restated rather than
inherited: the open state needs the same two animated properties with the delay gone.
"""
nav_css = _code(_css_section(_css(client), "mobile nav"))
open_state = _declarations(nav_css, "nav.tabs.is-open")
assert open_state.get("visibility") == "visible", "`.is-open` no longer reveals the menu"
assert open_state.get("opacity") == "1", "the open menu is still half-faded"
assert open_state.get("transform") == "none", \
"the open menu never came down the 8 px, so the slide has no end"
assert open_state.get("pointer-events") == "auto", \
"the open menu's five links would not take a tap"
assert open_state.get("transition") == (
f"opacity {NAV_SLIDE_MS}, transform {NAV_SLIDE_MS}, visibility 0s"), (
"the open state must animate the same two properties with the `visibility` delay "
"dropped — the same duration, in the other direction")
def test_the_menu_snaps_in_both_directions_under_reduced_motion(client) -> None:
"""One reduced-motion block, and it names **both** menu states.
The reference project's phase-46 comment is the reason: `.nav.tabs.is-open` out-specifies
`.nav.tabs`, so stilling only the closed state leaves the open transition running and the
menu still animates for someone who asked not to. The block is the file's only one, merged
with the pre-existing universal rule rather than a second block that would have to be kept
in step with it.
"""
code = _code(_css(client))
blocks = re.findall(r"@media\s*\(prefers-reduced-motion:\s*reduce\)", code)
assert len(blocks) == 1, (
f"dash.css carries {len(blocks)} reduced-motion blocks; the menu's rules belong in the "
"one universal rule, or the two drift")
block = re.search(r"@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{(.*?)\n\}", code, re.S)
assert block, "the reduced-motion block no longer has a body to read"
selector = re.search(r"(nav\.tabs[^{]*)\{", block.group(1))
assert selector, "the reduced-motion block stopped naming the menu at all"
named = {s.strip() for s in selector.group(1).split(",")}
assert named == {"nav.tabs", "nav.tabs.is-open"}, (
"the reduced-motion block must name both menu states: naming only the closed one "
f"leaves the open transition animating — got {sorted(named)}")
assert _declarations(block.group(1), "nav.tabs.is-open").get("transition") == "none", \
"the named open rule stopped stilling the transition"
def test_the_header_status_dot_picks_up_the_disks_job(client) -> None:
"""`disks` gets its own dot instead of hiding inside `hosts`.