"""Unit: the phase-182 verbatim-panel stylesheet contract (task 06). The panel DOM is built by ``verbatim.js`` (task 07) and its streaming placeholder by ``markdown.js`` (task 06); this module pins the CSS contract that makes the phase readable, in the ``test_frontend_cluster_css.py`` / ``test_markdown_inline_links_images.py`` house pattern (no browser — the browser-level evidence is the phase's Playwright suite, task 08): * the phase-182 block EXISTS as one clearly-labelled region of ``frontend/assets/styles.css`` (BEGIN/END markers) and every selector the contract names is styled there — ``.verbatim``, ``.verbatim-head``, ``.verbatim-caption``, ``.verbatim-badge``, ``.verbatim-body``, ``.verbatim-lines``, ``.verbatim-pending``, ``.verbatim-state``, ``.verbatim-unresolved``, ``.code-block``, ``.code-block-copy``, ``.verbatim-copy``; * the panel is a BOUNDED scroll panel (PLAN §7.1): ``max-height`` in the ``min(…, 70vh)`` form + ``overflow: auto``, with the head row STICKY (``position: sticky; top: 0``) on an opaque background so the caption never scrolls away and rows never show through it; * the code surface never wraps (phase 116 locked A1, the phase-165 prose/code split): ``white-space: pre`` + ``overflow-wrap: normal`` + ``tab-size: 2``, and the line-number gutter is ``user-select: none`` so a Copy/select never drags numbers into the clipboard; * text + color, never color alone (WCAG 1.4.1): the badge is a TEXT badge with ``text-transform: none``, the unresolved state hides the badge, and the ``.copied`` state exists beside the text swap task 07 performs; * the whole block is PALETTE-VARIABLES ONLY (the phase-92 zero-literal / monochrome invariant): no hex/rgb/hsl literal anywhere, and every ``var()`` is a declared ``:root`` token — so a light theme keeps this code surface readable and no new hue enters the system; * the ≤640px block lifts the Copy controls to the 44px touch floor, and nothing in the block animates (so ``prefers-reduced-motion`` has nothing to still — the rule is written as a guard so a future animation cannot be added without its still). """ from __future__ import annotations import re from pathlib import Path import pytest STYLES_CSS = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css" #: The phase-182 region markers (the block is one contiguous, labelled #: region, so "no literals in the phase-182 block" is a parseable claim). BEGIN = "/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------" END = "/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */" #: Every selector the task's contract names. PANEL_SELECTORS = ( ".verbatim", ".verbatim-head", ".verbatim-caption", ".verbatim-badge", ".verbatim-body", ".verbatim-lines", ".verbatim-pending", ".verbatim-state", ".verbatim-unresolved", ".code-block", ".code-block-copy", ".verbatim-copy", ) def _css() -> str: return STYLES_CSS.read_text(encoding="utf-8") def _phase182_block(css: str) -> str: """The phase-182 region, markers included (comments intact — the block carries the contract in prose too).""" start = css.find(BEGIN) assert start != -1, "styles.css must carry the labelled phase-182 block (BEGIN marker)" end = css.find(END, start) assert end != -1, "the phase-182 block must be closed by its END marker" return css[start : end + len(END)] def _strip_comments(css: str) -> str: return re.sub(r"/\*[\s\S]*?\*/", "", css) def _rule_bodies(block: str, selector: str) -> list[str]: """The bodies of every rule whose selector list contains ``selector`` EXACTLY (the phase-180 parse pattern, comment-stripped).""" bodies: list[str] = [] for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", _strip_comments(block)): selectors = [s.strip() for s in " ".join(match.group(1).split()).split(",")] if selector in selectors: bodies.append(match.group(2)) return bodies def _body(block: str, selector: str) -> str: bodies = _rule_bodies(block, selector) assert bodies, f"styles.css must style {selector}" return " ".join(bodies) def _root_palette(css: str) -> set[str]: """Every variable name the ``:root`` block declares.""" root = re.search(r":root\s*\{", css) assert root, "styles.css must define :root" body = css[root.end() : css.find("}", root.end())] palette = set(re.findall(r"(--[a-z0-9-]+)\s*:", body)) assert palette, "the :root palette must declare variables" return palette def _media_block(css: str, query: str) -> str: """The body of the ``@media ()`` block — the house non-greedy pattern (nested closing braces are indented, so the first column-0 ``}`` is the block's own end).""" m = re.search("@media \\(" + re.escape(query) + r"\) \{([\s\S]*?)\n\}", css) assert m, f"{query} media query missing" return m.group(1) _CSS = _css() _BLOCK = _phase182_block(_CSS) _ROOT = _root_palette(_CSS) # --------------------------------------------------------------------------- # the block and its selectors # --------------------------------------------------------------------------- def test_phase_182_block_is_labelled_and_contains_every_selector() -> None: """The block is ONE clearly-labelled region carrying every selector of the contract (task 06 item 5) — a rule that drifted outside the region would escape the palette-only and reduced-motion pins below.""" for selector in PANEL_SELECTORS: assert _rule_bodies(_BLOCK, selector), f"the phase-182 block must style {selector}" # …and the block says what it is. assert "phase 182" in _BLOCK assert "VERBATIM" in _BLOCK, "the block documents the badge it styles" def test_panel_is_a_themed_surface_card() -> None: """``.verbatim`` is a block on the theme's own canvas: the bubble's rhythm (a vertical margin), a --line hairline, --radius-sm corners, the --surface fill, ``min-width: 0`` (the long-line escape hatch every scroller here carries) and ``overflow: hidden`` so the rounded corners clip the scrolled body.""" body = _body(_BLOCK, ".verbatim") for prop in ( "display: block", "min-width: 0", "background: var(--surface)", "border: 1px solid var(--line)", "border-radius: var(--radius-sm)", "overflow: hidden", ): assert prop in body, f".verbatim must carry {prop}" assert re.search(r"margin:\s0\.[0-9]+rem 0;", body), ( ".verbatim sits in the bubble's rhythm (a rem margin, not a px one)" ) def test_head_row_is_sticky_with_an_opaque_background() -> None: """PLAN §7.1's bounded-panel rule: the head row STICKS to the top of the scroll panel, so the caption (``source/path · lines 12–40 of 210``) is still on screen in a 40-line slice. OPAQUE ``--surface`` background — a translucent head would let the bytes read through it (the phase-156 ``.table-wrap table thead th`` idiom).""" body = _body(_BLOCK, ".verbatim-head") assert "position: sticky" in body, "the head row must be sticky" assert "top: 0" in body, "the sticky offset is the panel top" assert "background: var(--surface)" in body, "the sticky head must be opaque" assert "z-index: 1" in body, "the sticky head paints above the scrolled body" assert "display: flex" in body, "the head is a row (caption, badge, copy)" def test_caption_is_mono_soft_and_clipped_the_house_way() -> None: """``.verbatim-caption`` uses the phase-156 ``.cell-clip`` ellipsis convention (``min-width: 0`` + ``overflow: hidden`` + ``text-overflow: ellipsis`` + ``white-space: nowrap``) so a long ``source/path`` clips to ``…`` instead of breaking the head row — the FULL text stays reachable through the control's ``aria-label``/``title`` (the clip is visual only). ``--ink-soft`` on ``--surface`` is 8.2:1 (AA), and the mono family matches every other path in the app.""" body = _body(_BLOCK, ".verbatim-caption") for prop in ( "min-width: 0", "overflow: hidden", "text-overflow: ellipsis", "white-space: nowrap", "font-family: var(--mono)", "color: var(--ink-soft)", ): assert prop in body, f".verbatim-caption must carry {prop}" # the ellipsis convention must not rely on a fixed max-width here: the # caption is the flexible item of a wrapping flex row. assert "flex: 1" in body, "the caption takes the row's flexible space" def test_badge_is_text_with_no_uppercase_surprise() -> None: """The badge is the WORD ``VERBATIM`` in the --ok family (10.6:1 on its own fill) with ``text-transform: none`` — an uppercase-forced word is spelled out letter-by-letter by some screen readers, and an icon-only badge would fail WCAG 1.4.1 (the claim must be TEXT).""" body = _body(_BLOCK, ".verbatim-badge") assert "text-transform: none" in body assert "color: var(--ok-ink)" in body, "the badge is the --ok family" assert "background: var(--ok-bg)" in body assert "content:" not in body, "the badge text is DOM text, never a CSS ::before" def test_body_is_a_bounded_scroll_panel_of_code() -> None: """``.verbatim-body`` is the bounded scroll panel: ``max-height`` in the ``min(…, 70vh)`` form (PLAN §7.1 — the panel never eats the page), ``overflow: auto`` both axes, the mono family, ``white-space: pre`` and ``tab-size: 2`` (the file's own indentation survives), and ``overflow-wrap: normal`` — CODE scrolls horizontally and prose never does (phase 116 locked A1, the phase-165 split), which also cancels the ``.bubble``'s inherited ``overflow-wrap: anywhere``. ``min-width: 0`` keeps the long-line escape hatch.""" body = _body(_BLOCK, ".verbatim-body") assert re.search(r"max-height:\s*min\(\s*[0-9.]+rem,\s*70vh\s*\)", body), ( ".verbatim-body must cap with max-height: min(, 70vh)" ) for prop in ( "overflow: auto", "min-width: 0", "font-family: var(--mono)", "white-space: pre", "tab-size: 2", "overflow-wrap: normal", ): assert prop in body, f".verbatim-body must carry {prop}" assert "white-space: pre-wrap" not in body, "code never wraps (locked A1)" def test_line_numbers_are_not_selectable() -> None: """The gutter is ``user-select: none`` (with the ``-webkit-`` prefix) on BOTH the container and its items, right-aligned in ``--ink-soft``: a reader who selects the panel — or a Copy path that reads the selection — never drags line numbers into the clipboard. ``list-style: none`` means the digits are TEXT, so the rule behaves identically in every engine.""" container = _body(_BLOCK, ".verbatim-lines") for prop in ( "user-select: none", "-webkit-user-select: none", "text-align: right", "color: var(--ink-soft)", "list-style: none", ): assert prop in container, f".verbatim-lines must carry {prop}" items = _body(_BLOCK, ".verbatim-lines li") assert "user-select: none" in items, "the
  • line numbers must be unselectable too" def test_pending_state_is_muted_and_static() -> None: """The streaming state (``.verbatim-pending`` + ``.verbatim-state``, the markdown.js placeholder) is MUTED — ``--ink-soft`` on ``--surface``, 8.2:1 — and it never announces itself with motion: the pending line is a ``role="status"`` live region, so the announcement IS the feedback.""" pending = _body(_BLOCK, ".verbatim-pending") state = _body(_BLOCK, ".verbatim-state") assert "color: var(--ink-soft)" in state, "the pending line is muted (not an answer)" assert "background: var(--surface)" in pending assert "animation" not in pending and "transition" not in pending assert "animation" not in state and "transition" not in state def test_unresolved_state_carries_no_badge() -> None: """The honest failure state: the --err boundary + --err-bg canvas with --ink-soft text (7.9:1, AA), and CSS-side enforcement that a ``VERBATIM`` badge NEVER appears on an unresolved panel whatever DOM reaches it — the badge belongs only to a server-resolved block (locked A5).""" body = _body(_BLOCK, ".verbatim-unresolved") assert "var(--err" in body, "the unresolved state is the --err family" assert "color: var(--ink-soft)" in body hide = _body(_BLOCK, ".verbatim-unresolved .verbatim-badge") assert "display: none" in hide, "an unresolved panel never badges" def test_copy_controls_are_pills_with_a_ring_and_an_ellipsis_label() -> None: """``.code-block-copy`` / ``.verbatim-copy``: the house pill (mono text, --line hairline, --surface fill, ink-soft label), the label is the clip target (``text-overflow: ellipsis`` on its span, the control's ``aria-label`` naming the full path), the ``:focus-visible`` ring is the app's 3px --brand ring, and the ``.copied`` rule exists to SUPPORT the text swap (``Copy`` → ``Copied``) with colour — never colour alone.""" both = _body(_BLOCK, ".code-block-copy") + " " + _body(_BLOCK, ".verbatim-copy") for prop in ( "border: 1px solid var(--line)", "background: var(--surface)", "color: var(--ink-soft)", "font-family: var(--mono)", "cursor: pointer", "min-height:", ): assert prop in both, f"the copy controls must carry {prop}" label = _body(_BLOCK, ".code-block-copy > span") assert "text-overflow: ellipsis" in label, "a clipped copy label shows the hint" for selector in (".code-block-copy:focus-visible", ".verbatim-copy:focus-visible"): ring = _body(_BLOCK, selector) assert "outline: 3px solid var(--brand)" in ring, f"{selector} needs the house ring" assert "outline-offset: 2px" in ring for selector in (".code-block-copy.copied", ".verbatim-copy.copied"): copied = _body(_BLOCK, selector) assert "var(--ok" in copied, f"{selector} must use the --ok family as SUPPORT" def test_plain_code_block_wrapper_is_not_a_verbatim_panel() -> None: """``.code-block`` is the plain wrapper task 07 puts around an ordinary answer ``
    ``: a Copy button belongs to it, the ``VERBATIM`` badge
        does NOT — so the wrapper rule carries no --ok fill and no panel chrome
        (locked A5: verbatim-and-badged OR plainly the model's, never a hybrid)."""
        body = _body(_BLOCK, ".code-block")
        assert "position: relative" in body
        assert "min-width: 0" in body
        assert "--ok" not in body, "an ordinary code block never wears the verbatim badge"
        assert not _rule_bodies(_BLOCK, ".code-block .verbatim-badge"), (
            "no badge rule may exist inside the plain wrapper"
        )
    
    
    # ---------------------------------------------------------------------------
    # the palette-only invariant (phase 92 / the :root-drift guard)
    # ---------------------------------------------------------------------------
    
    
    def test_phase_182_block_uses_palette_variables_only() -> None:
        """NO colour literal anywhere in the phase-182 block: every colour comes
        from a declared ``:root`` token (no hex, no ``rgb()``/``rgba()``, no
        ``hsl()``, no named colour). A literal would be a second source of truth
        for the built-in palette (the ``test_theming.py`` drift guard parses the
        built-ins out of this file) and a new hue would break the monochrome
        invariant — this is also what keeps a code surface readable when the
        owner switches the app to a light or a monochrome theme."""
        code = _strip_comments(_BLOCK)
        refs = re.findall(r"var\((--[a-z0-9-]+)\)", code)
        assert refs, "the phase-182 block must reference palette vars"
        for ref in set(refs):
            assert ref in _ROOT, f"{ref} is not a :root palette var"
        assert not re.search(r"#[0-9a-fA-F]{3,8}\b", code), "no hex color literal in the block"
        for fn in ("rgb(", "rgba(", "hsl(", "hsla("):
            assert fn not in code, f"no {fn} color literal in the block"
        # named colours are the other way a second source of truth sneaks in
        for name in (": white", ": black", "white;", "black;", "white }", "black }"):
            assert name not in code, f"no named colour in the block ({name.strip()})"
    
    
    def test_phase_182_block_restates_no_global_focus_rule_wrongly() -> None:
        """The restatement of the global ``:focus-visible`` ring uses the SAME
        values as the app-wide rule (3px --brand, 2px offset) — a different
        ring here would be a second, drifting source of focus truth."""
        global_rule = _css()[: _css().find("\n::selection")]
        assert re.search(r":focus-visible \{\s*outline: 3px solid var\(--brand\);\s*"
                         r"outline-offset: 2px;", global_rule), (
            "the app-wide :focus-visible rule is the baseline this block copies"
        )
    
    
    # ---------------------------------------------------------------------------
    # mobile + reduced motion
    # ---------------------------------------------------------------------------
    
    
    def test_mobile_block_keeps_a_44px_control() -> None:
        """The ≤640px block lifts BOTH Copy controls to the 44px touch floor
        (PLAN §7: the phone tap target) — the desktop pill is smaller, so the
        rule must live in the media block, not only in the base rule."""
        mobile = _media_block(_CSS, "max-width: 640px")
        assert "Phase 182" in mobile, "the mobile block must name the phase it serves"
        hit = re.search(r"\.code-block-copy, \.verbatim-copy \{([^}]*)\}", mobile)
        assert hit, "the mobile block must raise both Copy controls together"
        assert "min-height: 44px" in hit.group(1)
    
    
    def test_nothing_in_the_block_animates_without_a_reduced_motion_still() -> None:
        """``prefers-reduced-motion`` is honored by anything that animates. The
        phase-182 block animates NOTHING (the pending state is a live region, so
        the text swap is the feedback), which this pins as a GUARD: if a future
        edit adds an ``animation``/``transition``, the same selectors must appear
        in a ``prefers-reduced-motion: reduce`` block that stills them."""
        code = _strip_comments(_BLOCK)
        animated: list[str] = []
        for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", code):
            body = match.group(2)
            if re.search(r"(^|[;\s])(animation|transition)\s*:", body):
                animated.append(" ".join(match.group(1).split()))
        if not animated:
            assert "prefers-reduced-motion" in _BLOCK, (
                "a static block must say so, so the next reader does not add "
                "a pulse 'to match the app'"
            )
            return
        reduced = "\n".join(
            m.group(0)
            for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\)[\s\S]*?\n\}", _CSS)
        )
        for selectors in animated:
            for selector in selectors.split(","):
                assert selector.strip() in reduced, (
                    f"{selector} animates but is never stilled under "
                    "prefers-reduced-motion"
                )
    
    
    def test_the_panel_never_repaints_the_existing_code_and_bubble_rules() -> None:
        """The neighbours are untouched: ``.bubble pre`` (the ordinary answer
        code block) and ``.doc-raw`` (the viewer's scroller) keep their
        declarations, so the phase adds a SURFACE and does not restyle every
        code block in the app."""
        css = _CSS
        pre = re.search(r"\.bubble pre \{([^}]*)\}", css)
        assert pre, ".bubble pre must still exist"
        for prop in ("background: var(--surface)", "border: 1px solid var(--line)", "overflow-x: auto"):
            assert prop in pre.group(1), f".bubble pre must keep {prop}"
        raw = re.search(r"\.doc-raw \{([^}]*)\}", css)
        assert raw, ".doc-raw must still exist"
        assert "white-space: pre" in raw.group(1)
    
    
    def test_block_is_one_contiguous_region_of_the_sheet() -> None:
        """The BEGIN/END markers wrap ONE region (a phase that appends a stray
        ``.verbatim-*`` rule elsewhere escapes every pin in this module)."""
        css = _CSS
        assert css.count(BEGIN) == 1 and css.count(END) == 1
        assert css.index(BEGIN) < css.index(END)
        between = css[css.index(BEGIN) : css.index(END)]
        # every verbatim selector lives inside it
        for selector in PANEL_SELECTORS:
            assert re.search(r"(?m)^" + re.escape(selector) + r"[^\n]*\{", between), (
                f"{selector} must be declared inside the labelled phase-182 region"
            )
        outside = _strip_comments(css[: css.index(BEGIN)] + css[css.index(END) :])
        for selector in PANEL_SELECTORS:
            assert not re.search(
                r"(?m)^" + re.escape(selector) + r"[^\n]*\{", outside
            ), f"{selector} must not be restyled outside the phase-182 region"
    
    
    def test_selector_names_match_the_dom_the_renderer_and_task_07_build() -> None:
        """The CSS class names are the contract between the renderer's pending
        template (``markdown.js``) and the panel builder (``verbatim.js``, task
        07): the pending template's classes must be exactly the ones styled here,
        so the streaming placeholder is never an unstyled div."""
        js = (STYLES_CSS.parent / "markdown.js").read_text(encoding="utf-8")
        for cls in ("verbatim", "verbatim-pending", "verbatim-caption", "verbatim-state"):
            assert cls in js, f"markdown.js must emit .{cls}"
            assert _rule_bodies(_BLOCK, "." + cls), f"styles.css must style .{cls}"
        assert "verbatim-unresolved" in _BLOCK, (
            "the unresolved class is part of THIS contract (task 07 renders it)"
        )
    
    
    @pytest.mark.parametrize("pair", [("--ink", "--surface"), ("--ink-soft", "--surface")])
    def test_block_pairs_the_documented_text_tokens(pair: tuple[str, str]) -> None:
        """The block pairs its text with the canvas the :root comment documents
        (``--ink`` and ``--ink-soft`` on ``--surface``) rather than inventing a
        third ink — the AA duty is inherited from the palette, not restated."""
        code = _strip_comments(_BLOCK)
        assert re.search(r"color: var\(" + re.escape(pair[0]) + r"\)", code), (
            f"the block must use {pair[0]}"
        )
        assert re.search(r"background: var\(" + re.escape(pair[1]) + r"\)", code), (
            f"the block must sit on {pair[1]}"
        )