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