Files
brain-of-reese/tests/unit/test_sticky_header.py
T
ducoterra 5fa620fde5
Build and Push Containers / build-and-push-app (push) Successful in 2m28s
Build and Push Containers / build-and-push-db (push) Successful in 11s
fix(web): keep the navbar stuck to the top — drop the body height cap on the sticky range
2026-09-01 04:20:26 -04:00

149 lines
6.2 KiB
Python

"""Unit: the sticky-header contract (phase 60, TODO L3).
The browser behavior itself is E2E-gated by the phase-60 story suite
(task 02); like the other frontend-adjacent unit files, this module
pins the CSS markers the sticky contract depends on, so a silent
regression is caught without a browser:
* the ROOT CAUSE (owner-locked A1) is the ``body { height: 100% }``
cap — a sticky element's travel range is constrained to its
containing block, and the fixed body height pinned the box to one
viewport, so ``.app-header`` / ``.doc-header`` un-pinned after
~1 viewport of scroll. The FIX (owner-locked A2) is CSS-only:
``html`` keeps ``height: 100%`` (harmless viewport baseline) and
``body`` carries NO ``height:`` declaration — its existing
``min-height: 100dvh`` keeps driving the short-page stretch (the
phase-52 flex-stretch / pinned-composer / footer contract);
* ``.app-header`` (every page) and ``.doc-header`` (document viewer)
keep ``position: sticky; top: 0`` — these pins guard against a
future "simplification" that would drop the sticky rule that was
always the intent.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _block(css: str, selector: str) -> str:
"""The declaration body of the FIRST top-level ``<selector> { … }``
rule. Line-anchored on purpose: the phase-60 provenance comment
quotes ``body { height: 100% }`` literally, so a mid-line match
would capture the comment instead of the rule (real rules start at
column 0)."""
m = re.search(r"(?m)^" + re.escape(selector) + r" \{([\s\S]*?)\n\}", css)
assert m, f"styles.css must style {selector}"
return m.group(1)
# ---------- the root-cause cap is gone (A1 → A2) ----------
def test_body_height_cap_is_gone_from_the_rule() -> None:
"""Phase 60 (A2): the exact old rule ``html, body { height: 100%;
}`` is GONE from styles.css, and only ``html`` carries
``height: 100%`` now (the harmless viewport baseline stays on the
canvas element)."""
css = _css()
assert "html, body { height: 100%; }" not in css, (
"the combined html,body height rule must be gone"
)
assert "html { height: 100%; }" in css, ("html keeps height: 100%")
def test_html_rule_carries_the_phase_60_provenance_comment() -> None:
"""The replacement comment cites the phase 60 provenance (owner
confirmation 2026-08-31, TODO L3) and names the mechanism — the
sticky travel range is capped by the containing block, and
min-height: 100dvh is what stretches short pages."""
css = _css()
i = css.find("html { height: 100%; }")
assert i != -1, "the html height rule must exist"
comment = css[max(0, i - 900) : i]
assert "Phase 60" in comment, "the comment cites the phase 60 provenance"
assert "2026-08-31" in comment, "the comment cites the owner confirmation date"
assert "TODO L3" in comment, "the comment cites the TODO line"
assert "min-height: 100dvh" in comment, (
"the comment names the short-page stretch driver"
)
def test_body_rule_has_no_height_but_keeps_min_height() -> None:
"""The ``body { … }`` rule: NO ``height:`` declaration (the A1 cap
must never come back) and ``min-height: 100dvh`` intact — the
flex-column stretch driver the phase-52 short-page layout (footer
at the viewport bottom, pinned composer) depends on. The
declaration list is otherwise UNCHANGED (the flex column
properties stay)."""
css = _css()
body = _block(css, "body")
assert not re.search(r"(?m)^\s*height\s*:", body), (
"the body rule must carry NO height declaration"
)
assert "min-height: 100dvh" in body, ("the stretch driver stays on body")
for prop in (
"margin: 0",
"display: flex",
"flex-direction: column",
"position: relative",
"background: transparent",
):
assert prop in body, f"the body rule keeps its existing {prop}"
# ---------- the sticky rules stay (both surfaces) ----------
def test_app_header_stays_sticky_at_the_top() -> None:
"""``.app-header`` (the navbar on every page) keeps
``position: sticky; top: 0`` (z-index 20, the 64px --header-h
height) and the phase-12 ``flex-shrink: 0`` guard (reworded phase
60: body stretches via min-height: 100dvh; the guard still covers
content-overflow pages, e.g. Sources at ≤640px)."""
css = _css()
header = _block(css, ".app-header")
assert "position: sticky" in header, ".app-header must stay sticky"
assert "top: 0" in header, ".app-header must pin to the top"
assert "z-index: 20" in header
assert "height: var(--header-h)" in header
assert "flex-shrink: 0" in header, "the shrink guard stays"
def test_doc_header_stays_sticky_at_the_top() -> None:
"""``.doc-header`` (the document viewer's two-row header) keeps
``position: sticky; top: 0`` (z-index 20) and the shrink guard —
the same contract as the app header, so BOTH rows stay pinned
while the document scrolls."""
css = _css()
header = _block(css, ".doc-header")
assert "position: sticky" in header, ".doc-header must stay sticky"
assert "top: 0" in header, ".doc-header must pin to the top"
assert "z-index: 20" in header
assert "flex-shrink: 0" in header, "the shrink guard stays"
def test_sticky_pin_comment_mentions_the_stretch_driver() -> None:
"""The reworded phase-12 comment on ``.app-header`` (inside the
rule, above the guard) matches reality: it names
``min-height: 100dvh`` as the body stretch driver (the
"definite-height" wording of the old cap era is gone) and keeps
the content-overflow rationale for the guard."""
css = _css()
comment = _block(css, ".app-header")
assert "definite-height" not in comment, (
"the stale definite-height wording must be gone"
)
assert "min-height: 100dvh" in comment, (
"the reworded comment names the actual stretch driver"
)
assert "flex-shrink" in comment and "Sources" in comment, (
"the guard's rationale (content-overflow pages) stays"
)