"""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"?[a-zA-Z][^>]*?>", html):
tag = m.group(0)
mname = re.match(r"?([a-zA-Z]+)", tag)
name = mname.group(1).lower() if mname else ""
if tag.startswith(""):
depth -= 1
if depth == 0:
break
elif name not in VOID and not tag.endswith("/>"):
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}"
)