phase: 174_git_sources_card_rhythm
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 13s

All checks pass. Both docs verified consistent. Final state — no live-infrastructure changes were needed (pure CSS fix; verification ran against the already-running local dev DB).

**Phase 174 final verification pass — report**

- **Verified (no new work needed; both tasks already complete in `complete/`):** the single `#git-sources-content { display:flex; flex-direction:column; gap:1.25rem }` rule in `frontend/assets/styles.css` (with the phase-174 root-cause comment), `.git-sources-shell` untouched, the 4-test unit pin module, and the 4-test Playwright suite.
- **Full suite:** `uv run pytest --cov=app --cov-report=term-missing` → **4116 passed, 99% `app/` coverage** (gate >90% ✓).
- **Lint/types:** `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings.
- **Dedicated E2E in isolation:** `uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov` → **4 passed** (1440px + 375px bounding-box rhythm pins 20±2px, hidden-children + revealed-joins-rhythm, anonymous gate, a11y/no-CDN).
- **Regression set, each in isolation:** `test_git_sources_admin.py` **6 passed**, `test_archive_upload_sources.py` **5 passed**, `test_git_source_dates.py` **4 passed**.
- **Completion criteria:** cards stack with uniform ≈20px gap at both viewports ✓; hidden children stay hidden / gate+content flip intact ✓; ≤640px forms column-stack + table scrolls inside its card (no page-level h-scroll) ✓; full suite + coverage + ruff + pyright ✓; dedicated + regression E2E suites ✓.
- **Documentation:** README.md reviewed in full (every `.env.example` var documented; all documented routes/pages/endpoints exist; every config default cross-checked against `app/config.py`; payload-limit table, ports 8123/8901, Containerfile/compose/entrypoint claims all match) — **verified consistent, no changes needed**. AGENTS.md verified (all referenced files/sections exist: PLAN §2/§7.1/§7.4/§9, `response_budget.py`, `debugging.py` conditional import, `.agents/` tracked + gitignore rules, `commit.gpgsign=false`, phase-pinned CSS classes) — **verified consistent, no changes needed**.
- **Notable:** commit + phase-dir move are handled by the harness per pipeline rules (no git history/index touched by this pass); no live-infrastructure changes were required.
- **Next pending phase:** `175_history_pagination` (in `todo/`).
This commit is contained in:
2026-10-11 01:49:49 -04:00
parent da441ea799
commit 938d6cf09d
18 changed files with 1333 additions and 0 deletions
+563
View File
@@ -0,0 +1,563 @@
"""Phase 174 E2E (Playwright): the Sources (git sources) page — the
card stack regains its 1.25rem rhythm.
Source: owner bug report 2026-10-21 (screenshot of
``/git-sources.html``): "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 (task 01) 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 suite proves
the rhythm IN A BROWSER at 1440px and 375px, pins the
hidden-children behavior (D2) on both sides, keeps the anonymous
gate↔content flip (D3) intact, and runs the standard a11y / no-CDN
light pass (AGENTS.md rules 5/6).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov
This suite is UI-only: NO sync, NO git, NO network — the app boots
plain and the cards render regardless of row count (the one seeded
row per test below is the test-isolation reset that makes the
TABLE card render: git-sources.js hides ``#git-sources-table-wrap``
while the table has no rows and shows ``#git-sources-empty``
instead). The per-test ``git_sources`` TRUNCATE (the
``test_git_sources_admin.py`` pattern) makes each test's table state
its own.
Per-module app env (the conftest pattern, module-scoped — the
``test_git_sources_admin.py`` app shape): this suite's app boots
PLAIN but pins ``BOR_GIT_SOURCES=""`` (the phase-61 leak guard):
the operator's local (gitignored) ``.env`` is loaded by
pydantic-settings from ``cwd=REPO`` and may carry a real
``BOR_GIT_SOURCES`` value — with the per-test truncated (EMPTY)
``git_sources`` table, a leaked CSV would flip the page to the env
fallback (env rows + the visible ``#git-sources-env-note``) and
break the hidden-children pin and the four-card gap contract
below. The session app is never started in this isolated run, so
no port clash.
Test → contract mapping (one phase, one isolated file):
1. ``test_cards_stack_with_uniform_rhythm_desktop`` — the
"smooshed" pin at 1440×900 (admin): ``#git-sources-content``'s
computed display is ``flex`` (column, 20px gap — the 1.25rem
token at the 16px root) and the four visible cards —
``.page-head`` → ``#git-source-form`` → ``#archive-upload-form``
→ ``#git-sources-table-wrap`` — are pairwise disjoint with each
successive gap 20 ± 2px (pre-fix the gaps were ~0 — the 1px
borders).
2. ``test_cards_stack_with_uniform_rhythm_mobile`` — the pin at
375×720 (admin): the same four cards are pairwise disjoint with
≈20px gaps; the two form cards column-stack per the ≤640px block
(``#git-source-form`` / ``#archive-upload-form`` computed
``flex-direction: column``) and the table scrolls INSIDE its
card (the ``#git-sources-table-wrap`` right edge is left of the
viewport; no page-level horizontal scroll —
``document.scrollingElement.scrollWidth <= 375``).
3. ``test_hidden_children_stay_hidden_in_flex_wrapper`` — the D2
both-sides pin (admin): ``#git-sources-load-error`` and
``#git-sources-env-note`` (the phase docstring's
``#git-source-env-note`` names the element's CLASS) are not
visible (the ``[hidden]`` UA
``display:none`` survives the flex parent — the parent's
``display`` never overrides a child's own — ``offsetParent`` is
``None``); then the load-error's ``hidden`` is removed and the
element JOINS the rhythm (visible, ≥18px below the element above
it). The D3 regression (anonymous): ``#git-sources-gate``
visible, ``#git-sources-content`` hidden, NO
``/api/git-sources`` call (the ``test_git_sources_admin.py``
anonymous contract — the gate↔content flip is unchanged by the
fix).
4. ``test_sources_page_a11y_and_no_cdn_basics`` — the standard light
pass (AGENTS.md rules 5/6, the ``test_git_sources_admin.py``
a11y shape): landmarks on the view, the labeled form controls,
the 3px ``:focus-visible`` outline on a table-row control (a
real keyboard Tab walk), same-origin assets only.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from collections.abc import Iterator
from pathlib import Path
import pytest
from playwright.sync_api import FloatRect, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import GitSource
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns
# its port in a combined run — this module app binds its own port
# instead (a same-port second uvicorn dies on bind and would drive
# the wrong server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_GITCARD", "8126"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
GIT_SOURCES_URL = "/git-sources.html"
#: The four visible cards of the stack, in DOM order inside
#: ``#git-sources-content`` (the hidden load-error / env-note
#: children sit between the page-head and the forms).
CARDS = (
"#git-sources-content .page-head",
"#git-source-form",
"#archive-upload-form",
"#git-sources-table-wrap",
)
#: The 1.25rem gap at the 16px root — the exact token (the pre-fix
#: gaps were ~0: the 1px borders).
GAP_PX = 20
GAP_TOL = 2
#: A revealed hidden child must sit at least this far below the
#: element above it (20px gap − the 2px tolerance — the D2
#: both-sides pin).
REVEALED_MIN_GAP = 18 # the task's floor (20px gap − 2px tolerance)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: BOOTS PLAIN (no
sync, no git, no network; the cards render regardless of row
count) with the phase-61 leak guard (``BOR_GIT_SOURCES=""`` —
see the module docstring)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated gate pins (conftest pattern — the phase-112
# lesson: the code-default floor violates the startup validator
# against the mock-calibrated threshold). No chat turn is ever
# sent in this suite, but the app boots with the same env shape.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to
# boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# Phase 61/62 leak guards: force the code defaults so an
# operator's local (gitignored) ``.env`` cannot leak corpus-
# specific strings into the app under test (derived from the
# class fields — never drifts from ``app/config.py``).
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
# The leak guard this suite exists for (module docstring): an
# EMPTY effective CSV — with the per-test truncated (empty)
# ``git_sources`` table, a leaked CSV would flip the page to the
# env fallback (env rows + the visible env note) and break the
# hidden-children pin and the four-card gap contract.
env["BOR_GIT_SOURCES"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
def _truncate_git_sources() -> None:
with SessionLocal() as db:
db.execute(text("TRUNCATE git_sources"))
db.commit()
@pytest.fixture(autouse=True)
def _clean_git_sources(db_ready: None) -> Iterator[None]:
"""Fresh ``git_sources`` table per test (the
``test_git_sources_admin.py`` pattern) — each test's table state
is its own (this suite never syncs, so nothing re-populates it
mid-test). The table is emptied BOTH before and after every
test: suites run in isolation but share one Postgres, and a
leftover row would flip the next suite's app from the
``BOR_GIT_SOURCES`` env fallback to the DB list (the sync
story's ``effective_git_sources`` resolver reads the DB
first)."""
_truncate_git_sources()
yield
_truncate_git_sources()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _seed_row(url: str) -> None:
"""Store one row directly (the ``test_git_sources_admin.py``
``_seed_rows`` shape) — a stored row makes
``#git-sources-table-wrap`` render (git-sources.js:
``tableWrap.hidden = !hasRows`` — with no rows the empty
state replaces the table card) and renders the per-row action
controls the a11y walk targets (env-fallback rows render only
the "from .env" tag)."""
with SessionLocal() as db:
db.add(GitSource(url=url))
db.commit()
def _admin_git_sources_page(
page: Page, app_url: str, expect_sign_out: bool = True
) -> None:
"""Real form login landing on the git sources page (admin
settled: the manager revealed by the page module —
git-sources.js reveals it only once the cached whoami says
admin, so the reveal IS the settle gate; the shared header's
Sign out is visible on desktop, but at ≤640px the nav folds
into the hamburger, so the mobile test passes
``expect_sign_out=False``)."""
login(page, app_url, next=GIT_SOURCES_URL)
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
expect(page.locator("#git-sources-content")).to_be_visible(timeout=15_000)
expect(page.locator("#git-sources-gate")).to_be_hidden()
if expect_sign_out:
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
def _card_boxes(page: Page) -> list[FloatRect]:
boxes: list[FloatRect] = []
for sel in CARDS:
box = page.locator(sel).bounding_box()
assert box is not None, f"{sel} must render"
boxes.append(box)
return boxes
def _assert_rhythm(boxes: list[FloatRect], width: int) -> None:
"""The pre-fix regression pin: the four cards are pairwise
disjoint (no two card edges touch — pre-fix the gaps were ~0,
the 1px borders) and each successive gap is 20 ± 2px (the
1.25rem token)."""
for i in range(len(boxes)):
for j in range(i + 1, len(boxes)):
a, b = boxes[i], boxes[j]
disjoint = (
a["x"] + a["width"] <= b["x"] + 0.5
or b["x"] + b["width"] <= a["x"] + 0.5
or a["y"] + a["height"] <= b["y"] + 0.5
or b["y"] + b["height"] <= a["y"] + 0.5
)
assert disjoint, (
f"the {CARDS[i]} and {CARDS[j]} cards touch at {width}px "
f"(a is {a}, b is {b})"
)
for i in range(len(boxes) - 1):
gap = boxes[i + 1]["y"] - (boxes[i]["y"] + boxes[i]["height"])
assert GAP_PX - GAP_TOL <= gap <= GAP_PX + GAP_TOL, (
f"the gap between {CARDS[i]} and {CARDS[i + 1]} is {gap:.1f}px "
f"at {width}px (expected {GAP_PX} ± {GAP_TOL} — pre-fix the "
f"gaps were ~0: the 1px borders)"
)
# ---------------------------------------------------------------------------
# 1. The "smooshed" pin at 1440×900 (admin)
# ---------------------------------------------------------------------------
def test_cards_stack_with_uniform_rhythm_desktop(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
page.set_viewport_size({"width": 1440, "height": 900})
_seed_row("https://github.com/reese/card-rhythm.git")
_admin_git_sources_page(page, app_url)
# The wrapper IS the flex-column rhythm (task 01's rule,
# computed — the phase-171 idiom): display flex, column, and the
# EXACT 1.25rem token at the 16px root (20px — a regression to
# any other value fails).
style = page.evaluate(
"""() => {
const s = getComputedStyle(
document.getElementById("git-sources-content")
);
return {
display: s.display,
direction: s.flexDirection,
rowGap: s.rowGap,
columnGap: s.columnGap,
};
}"""
)
assert style["display"] == "flex", (
f"#git-sources-content must be display:flex (got {style['display']!r})"
)
assert style["direction"] == "column", (
f"#git-sources-content must be flex-direction:column "
f"(got {style['direction']!r})"
)
assert style["rowGap"] == "20px" and style["columnGap"] == "20px", (
f"#git-sources-content must keep the 1.25rem (20px) gap — the "
f"shell's own token (got {style['rowGap']!r}/"
f"{style['columnGap']!r})"
)
# The four always-visible cards (the seeded row keeps the table
# card rendered; the revealed load-error / env note join the
# same rhythm — pinned in test 3): pairwise disjoint, each
# successive gap 20 ± 2px.
_assert_rhythm(_card_boxes(page), 1440)
# ---------------------------------------------------------------------------
# 2. The pin at 375×720 (admin): the rhythm survives the mobile
# layout (the ≤640px block composes with the fix unchanged)
# ---------------------------------------------------------------------------
def test_cards_stack_with_uniform_rhythm_mobile(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
page.set_viewport_size({"width": 375, "height": 720})
_seed_row("https://github.com/reese/card-rhythm.git")
_admin_git_sources_page(page, app_url, expect_sign_out=False)
# The same four cards are pairwise disjoint with ≈20px gaps at
# 375px.
_assert_rhythm(_card_boxes(page), 375)
# The two form cards column-stack per the ≤640px block (the
# L6400–6403 media query — the fix composes with it unchanged).
for sel in ("#git-source-form", "#archive-upload-form"):
direction = page.evaluate(
f"() => getComputedStyle(document.querySelector('{sel}'))"
".flexDirection"
)
assert direction == "column", (
f"{sel} must column-stack at 375px (got {direction!r})"
)
# The table scrolls INSIDE its card: the card's right edge is
# left of the viewport…
card = page.locator("#git-sources-table-wrap").bounding_box()
assert card is not None, "the table card must render at 375px"
assert card["x"] + card["width"] <= 375 + 1, (
f"the table card's right edge ({card['x'] + card['width']:.1f}) "
f"reaches/past the 375px viewport — it must sit inside"
)
# …and the page itself never scrolls horizontally (the
# table-wrap's own overflow-x scroll is the mobile scroll).
scroll_width = page.evaluate(
"() => document.scrollingElement.scrollWidth"
)
assert scroll_width <= 375, (
f"page-level horizontal scroll at 375px "
f"(scrollWidth {scroll_width})"
)
# ---------------------------------------------------------------------------
# 3. The D2 both-sides pin (hidden children inside the flex wrapper)
# + the D3 anonymous gate↔content regression
# ---------------------------------------------------------------------------
def test_hidden_children_stay_hidden_in_flex_wrapper(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
page.set_viewport_size({"width": 1440, "height": 900})
_seed_row("https://github.com/reese/card-rhythm.git")
_admin_git_sources_page(page, app_url)
# --- the hidden side (D2): the [hidden] UA display:none
# survives the flex parent (the parent's display never overrides
# a child's own display) — both hidden children are absent from
# the layout, git-sources.js's reveal lifecycle untouched…
for sel in ("#git-sources-load-error", "#git-sources-env-note"):
el = page.locator(sel)
assert not el.is_visible(), f"{sel} must stay hidden in the flex wrapper"
offset = page.evaluate(f"() => document.querySelector('{sel}').offsetParent")
assert offset is None, (
f"{sel} must be display:none inside the flex wrapper "
f"(offsetParent {offset!r} — the [hidden] UA rule was "
f"overridden)"
)
# …and a revealed one JOINS the rhythm (the both-sides pin):
# un-hide the load-error and it stacks ≥18px below the element
# above it (the page-head — the env note is still hidden) with
# the same 1.25rem gap.
page.evaluate(
"() => document.getElementById('git-sources-load-error')"
".removeAttribute('hidden')"
)
err = page.locator("#git-sources-load-error")
assert err.is_visible(), "the un-hidden load error must become visible"
offset = page.evaluate(
"() => document.getElementById('git-sources-load-error').offsetParent"
)
assert offset is not None, "the un-hidden load error must be laid out"
err_box = err.bounding_box()
head_box = page.locator("#git-sources-content .page-head").bounding_box()
assert err_box is not None and head_box is not None
gap = err_box["y"] - (head_box["y"] + head_box["height"])
assert gap >= REVEALED_MIN_GAP, (
f"the revealed load error must join the rhythm: it sits "
f"{gap:.1f}px below the element above it (floor "
f"{REVEALED_MIN_GAP}px)"
)
# --- the D3 regression (anonymous): the gate↔content flip is
# unchanged by the fix — reset to anonymous (the session cookie
# cleared; the shared header's whoami is re-fetched on the
# reload — the module-level promise cache is per page load)…
page.context.clear_cookies()
api_calls: list[str] = []
page.on(
"request",
lambda r: api_calls.append(r.url) if "/api/git-sources" in r.url else None,
)
page.goto(app_url + GIT_SOURCES_URL)
# The header's whoami toggle has landed (one of Sign in / Sign
# out visible)…
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
# …the sign-in gate is what anonymous sees…
expect(page.locator("#git-sources-gate")).to_be_visible()
# …and the manager (the flex wrapper) stays hidden…
expect(page.locator("#git-sources-content")).to_be_hidden()
# …and the gate never called the admin API (the
# test_git_sources_admin.py anonymous contract; a short settle
# wait so the claim is made on a settled page).
page.wait_for_timeout(500)
assert api_calls == [], (
f"anonymous page called the git sources API: {api_calls}"
)
# ---------------------------------------------------------------------------
# 4. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6)
# ---------------------------------------------------------------------------
def test_sources_page_a11y_and_no_cdn_basics(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_seed_row("https://github.com/reese/card-rhythm.git")
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
# Standard app frame: landmarks + skip link (PLAN §7.2).
expect(page.locator("header.app-header")).to_have_count(1)
expect(page.locator('nav[aria-label="Primary"]')).to_have_count(1)
expect(page.locator("main#main")).to_have_count(1)
expect(page.locator("footer.app-footer")).to_have_count(1)
expect(page.locator(".skip-link")).to_have_count(1)
# The form controls are labeled (visible <label for=…>).
expect(page.get_by_label("Add a git source")).to_have_count(1)
expect(
page.get_by_label(
"Upload a source archive (.tar, .tar.gz, .tgz, .zip)"
)
).to_have_count(1)
# The table-row control reaches the 3px :focus-visible outline
# via a REAL keyboard Tab walk (the phase-171 walk shape: the
# skip link is the document's FIRST tabbable element — the
# script focus starts the cycle; every move after it is a real
# Tab keypress).
page.evaluate("() => document.querySelector('a.skip-link').focus()")
seen: list[str] = []
reached = False
for _ in range(60):
page.keyboard.press("Tab")
active = page.evaluate(
"""() => {
const el = document.activeElement;
if (!el) return { key: "", cls: "" };
return {
key: el.id || "",
cls: typeof el.className === "string" ? el.className : "",
};
}"""
)
seen.append(active["cls"] or active["key"] or "?")
if "git-source-remove" in active["cls"]:
reached = True
break
assert reached, (
f"the walk never reached the row's Remove control "
f"(seen={seen[:25]})"
)
outline = page.evaluate(
"() => getComputedStyle(document.activeElement).outlineWidth"
)
assert outline == "3px", (
f"focus-visible outline missing on the row control: {outline!r}"
)
# No CDN (rule 6): no https:// asset tags; every script/link
# ref is same-origin or a data: URI.
html = page.content()
assert 'src="https://' not in html and 'href="https://' not in html
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, "expected local asset references"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), (
f"non-local asset reference: {ref}"
)
+174
View File
@@ -0,0 +1,174 @@
"""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 <div …> to its matching </div>
(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"<div\b|</div>", html[start:]):
if m.group(0) == "</div>":
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 <div> is depth 0 — the counted element).
Void elements (input/br/img/… — this markup uses non-self-closing
<input …>) 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 = '<div class="container git-sources-shell">'
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}"
)