"""Unit: the .table-wrap containing-block contract (phase 86, task 01). TODO.md L4 (owner bug report, 2026-09-07): "The history page appears to be the width of the table despite the table being scrollable. On mobile this results in half the page being blank and awkwardly scrollable." Bug basis (confirmed by live reproduction, 2026-09-07 — headless Chromium, 375×812, signed-in admin): the History and Tokens tables carry ``class="visually-hidden"`` on their ```` and their Actions column header — a ``position: absolute`` 1px clipped box. No ancestor in the chain (th → table → .table-wrap → the view shell → #main → body) was positioned, so the span's containing block was the INITIAL containing block: its 1px box sat at the 640px table's right edge and contributed to the DOCUMENT's scrollable overflow, bypassing the card's own scroll clipping. Measured ``documentElement.scrollWidth`` at a 375px viewport: /history.html **626** (the page panned ~250px into a blank region — the owner's "half the page being blank"), /tokens.html **618** (the byte-identical defect, folded in per the owner-confirmed A3 scope), /sources.html **375** (the RAG view is clean — its table headers are visible text, no positioned hidden spans). Hiding the span, OR making the card ``position: relative``, both bring the scrollWidth back to 375. The fix is ONE CSS property (owner decision A3): ``position: relative`` on the shared ``.table-wrap`` card — it becomes the containing block for the ``.visually-hidden`` elements it hosts, so History AND Tokens are fixed by the same rule with no markup change (the accessible column name / caption stay) and no JS change. Zero-offset positioning changes no layout: the spans stay clipped by their own ``clip: rect(0 0 0 0)`` + 1px box, and cards without such spans (the RAG ``.docs-table``, the git-sources table) render unchanged. This module pins the source-level contract (the house pattern of ``tests/unit/test_hamburger_nav.py``: read ``styles.css`` as text, no browser) so the regression that omitted the containing block cannot return. Phase 156 (task 01) superseded two of these pins: the card's ``overflow-x: auto`` became ``overflow: auto`` (both axes — the tables' min-width still triggers the in-card horizontal scroll) plus the bounded-panel ``max-height: min(70vh, 64rem)`` (owner-locked A1: the card is the vertical scroll region the sticky ``th`` pins to), and the ``#git-sources-table-wrap`` id override was harmonized into the base ``.table-wrap`` (the wrapper carries ``class="table-wrap"`, so its duplicate declarations were removed — one-rule contract). """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" STYLES_CSS = FRONTEND / "assets" / "styles.css" def _text(path: Path) -> str: assert path.is_file(), f"missing frontend file: {path}" return path.read_text(encoding="utf-8") def _css() -> str: """styles.css with comments stripped (a comment may legally carry braces — the brace-matching helper below must never see them).""" return re.sub(r"/\*.*?\*/", "", _text(STYLES_CSS), flags=re.S) def _rule_block(css: str, selector: str) -> str: """The first rule body for the EXACT selector ``selector`` (e.g. ``.table-wrap``). The lookbehind before the selector rejects decorated variants: ``.table-wrap`` must NOT match ``.md-table-wrap`` (the chat-answer markdown tables) nor ``.history-table-wrap`` / ``.tokens-table-wrap`` (the cards' extra classes), and an id rule like ``#git-sources-table-wrap`` is never a match for the class selector.""" m = re.search(r"(? None: """THE regression pin: the shared .table-wrap card carries position:relative — as its FIRST declaration — making it the containing block for the position:absolute .visually-hidden elements it hosts (the table caption + the Actions header span in #view-history / #view-tokens). Without a positioned ancestor their 1px boxes are laid out against the initial containing block at the 640px table's right edge and leak into the document's scrollable overflow (TODO.md L4 — the page appeared "the width of the table": 626px on History, 618px on Tokens, at 375px).""" block = _rule_block(_css(), ".table-wrap") assert re.search(r"position:\s*relative", block), ( "the .table-wrap card must be position:relative (the " "containing block for the .visually-hidden spans)" ) assert block.lstrip().startswith("position: relative"), ( "position:relative must be the FIRST declaration of .table-wrap " "(the containing-block contract precedes the card look)" ) def test_table_wrap_keeps_the_horizontal_scroll_contract() -> None: """Phase 156 (task 01) supersedes the phase-86 overflow-x pin: the card carries overflow:auto (BOTH axes — at any viewport narrower than the tables' min-width the table still scrolls INSIDE the card, AGENTS.md rule 5: full-width tables stay) plus the bounded-panel max-height: min(70vh, 64rem) (owner-locked A1: the card is the vertical scroll region — a 15-row table scrolls, a 3-row one fits without a scrollbar) so the sticky th pins to the card instead of scrolling away with the page.""" block = _rule_block(_css(), ".table-wrap") assert "overflow: auto" in block, ( "the .table-wrap card must carry overflow: auto (both axes — " "the in-card horizontal scroll + the bounded vertical scroll)" ) assert "max-height: min(70vh, 64rem)" in block, ( "the .table-wrap card must be bounded: max-height: " "min(70vh, 64rem) (the A1 bounded-panel design)" ) def test_the_fix_touched_only_the_shared_card() -> None: """The one-rule scope (A3, harmonized by phase 156 task 01): the positioning fix lives on .table-wrap ONLY — .md-table-wrap (chat answer tables — different class, different context) keeps its pre-phase-86 rules (no position declaration), and the #git-sources-table-wrap id override is GONE: the wrapper carries class="table-wrap" and inherits the base card, so a divergent unbounded id rule cannot return.""" css = _css() block = _rule_block(css, ".md-table-wrap") assert not re.search(r"position\s*:", block), ( ".md-table-wrap must not acquire a position declaration (the " "fix is the ONE .table-wrap rule)" ) assert not re.search(r"(?