"""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 `` { … }`` 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" )