149 lines
6.2 KiB
Python
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"
|
|
)
|