"""Unit: the Sources (git sources) page card rhythm (phase 174, task 01). Owner bug report 2026-10-21: "the bubbles on the sources page are really smooshed together" — the "Git sources" header card, the "Add a git source" card, the "Upload a source archive" card, and the sources table card stacked flush (only their 1px borders separated them). Root cause: the `.git-sources-shell` gap (1.25rem) only separates the sign-in gate from the single ``#git-sources-content`` wrapper; every visible card is a child of THAT div, which was a plain block. The fix is the phase-171 ``#settings-content`` idiom applied to the one remaining admin view whose cards live inside a wrapper div: ``#git-sources-content`` itself becomes ``display: flex; flex-direction: column; gap: 1.25rem;`` — the shell's OWN existing token (no new spacing value). This module pins the source-level contract a silent regression would break; the browser behavior (bounding-box gaps at 1440px and 375px, hidden-children pin, gate reveal) is E2E-gated by the phase-174 story suite (``tests/e2e/test_git_sources_card_rhythm.py``, task 02). Pinned: * the ``#git-sources-content`` rule exists in ``styles.css`` and carries ``display: flex``, ``flex-direction: column``, and ``gap: 1.25rem`` EXACTLY (a regression to any other gap value or a missing flex line fails); * the ``.git-sources-shell`` rule still carries ``gap: 1.25rem`` and ``flex: 1`` (the fix must not have "fixed" the shell — D3); * the structural pin: in ``index.html``, ``#git-sources-content`` sits inside ``.container.git-sources-shell`` and the ``.sources-gate`` (``#git-sources-gate``) is a sibling of it — if a future refactor moves the cards to the shell's direct children, this rule becomes dead weight and this pin names it; * ``#git-sources-load-error`` (role=alert) and the env note (id ``git-sources-env-note``, class ``git-source-env-note`` — the phase docstring's ``#git-source-env-note`` names the class) still carry ``hidden`` in ``index.html`` (the D2 premise the E2E pin proves in a browser: the UA ``[hidden] → display:none`` holds inside a flex parent, so the reveal lifecycle is untouched). """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" SHELL_HTML = FRONTEND / "index.html" STYLES_CSS = FRONTEND / "assets" / "styles.css" GAP_TOKEN = "1.25rem" def _read(path: Path) -> str: return path.read_text(encoding="utf-8") def _css_rule(css: str, selector: str) -> str: """The body of one CSS rule: ``selector { … }`` (no nested braces in these rules), whitespace-normalized. Raises if the selector's rule is absent (a missing rule IS the regression).""" m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css) assert m is not None, f"rule for {selector!r} not found in styles.css" return " ".join(m.group(1).split()) def _div_span(html: str, open_tag: str) -> str: """The substring from an opening
to its matching
(balanced div-tag counting — the test_git_source_branch_ui.py brace-counting helper, applied to HTML).""" start = html.index(open_tag) depth = 0 for m in re.finditer(r"", html[start:]): if m.group(0) == "": depth -= 1 if depth == 0: return html[start : start + m.end()] else: depth += 1 raise AssertionError(f"unbalanced divs from {open_tag!r}") def _direct_children_tags(html: str) -> list[str]: """The element tags that open at DEPTH 1 (direct children) of the substring (the leading
is depth 0 — the counted element). Void elements (input/br/img/… — this markup uses non-self-closing ) change no depth.""" VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr"} out: list[str] = [] depth = 1 # we are inside the shell div for m in re.finditer(r"]*?>", html): tag = m.group(0) mname = re.match(r""): if depth == 1: out.append(tag) depth += 1 return out def test_content_wrapper_is_the_flex_rhythm_column() -> None: """D1: #git-sources-content carries the exact flex-column rhythm — display: flex, flex-direction: column, gap: 1.25rem (the shell's own token; a different gap value or a missing flex line fails).""" body = _css_rule(_read(STYLES_CSS), "#git-sources-content") assert "display: flex" in body, body assert "flex-direction: column" in body, body # The EXACT token — ``gap: 1.25rem`` and no other gap declaration: gaps = re.findall(r"gap\s*:\s*([^;]+)", body) assert gaps == [GAP_TOKEN], f"gap regression: {gaps}" def test_shell_rule_unchanged() -> None: """D3: the fix must not have 'fixed' the shell — .git-sources-shell keeps flex-direction: column, gap: 1.25rem, and flex: 1 (the gate↔content separation at anonymous).""" body = _css_rule(_read(STYLES_CSS), ".git-sources-shell") assert "display: flex" in body, body assert "flex-direction: column" in body, body gaps = re.findall(r"gap\s*:\s*([^;]+)", body) assert gaps == [GAP_TOKEN], f"shell gap regression: {gaps}" assert "flex: 1" in body, body def test_content_div_sits_inside_shell_with_gate_sibling() -> None: """The structural pin: #git-sources-content is a direct child of .container.git-sources-shell, and the .sources-gate (#git-sources-gate) is its sibling. If a future refactor moves the cards back to the shell's direct children, the phase-174 rule becomes dead weight — this pin names that.""" html = _read(SHELL_HTML) shell_open = '
' assert shell_open in html, "the .git-sources-shell container is gone" shell = _div_span(html, shell_open)[len(shell_open):] children = _direct_children_tags(shell) gates = [t for t in children if 'id="git-sources-gate"' in t] contents = [t for t in children if 'id="git-sources-content"' in t] assert len(gates) == 1, ( "the #git-sources-gate section is no longer a DIRECT child of " f"the shell (children: {children})" ) assert len(contents) == 1, ( "the #git-sources-content div is no longer a DIRECT child of " f"the shell (children: {children}) — if the cards moved back to " "the shell's direct children, the phase-174 wrapper rule is " "dead weight: move the rule or the cards, and update this pin" ) # The gate precedes the content (the reveal hides the gate and # flips the content): assert children.index(gates[0]) < children.index(contents[0]) def test_hidden_children_keep_hidden_in_markup() -> None: """The D2 premise: #git-sources-load-error (role=alert) and the env note (id git-sources-env-note, role=note) still carry ``hidden`` in the markup — inside the flex wrapper the UA [hidden] display:none holds (the parent's display never overrides a child's own), so git-sources.js's reveal lifecycle is untouched.""" html = _read(SHELL_HTML) # The phase docstring's #git-source-env-note is the element's CLASS; # its id is git-sources-env-note — pin the id, fall back to the # class (both name the same element). for tag_id in ("git-sources-load-error", "git-sources-env-note"): m = re.search(rf'<[a-z]+ [^>]*id="{tag_id}"[^>]*>', html) assert m is not None, f"the {tag_id} element is gone" tag = m.group(0) assert " hidden" in tag or tag.rstrip(">").endswith("hidden"), ( f"{tag_id} lost its hidden attribute — the E2E hidden pin " f"would break: {tag}" )