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
@@ -0,0 +1,46 @@
# Phase 174 — 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 stack flush — only their 1px borders separate them).
**Story:** n/a (owner bug report, this chat 2026-10-21)
**Context:** `frontend/index.html` — `#view-git-sources` (L646): `.container.git-sources-shell` holds TWO children — the anonymous `.sources-gate` (`#git-sources-gate`, L651) and the single `#git-sources-content` wrapper (L672) that wraps **every** visible card: `.page-head`, the `#git-sources-load-error` alert (hidden), the `#git-source-env-note` (hidden), `#git-source-form`, `#archive-upload-form`, and the `.table-wrap#git-sources-table-wrap`; `frontend/assets/styles.css` — `.git-sources-shell` (L3193–3200: `display:flex; flex-direction:column; gap: 1.25rem; flex:1` — the gap only separates the gate from the wrapper), `#git-sources-content` (**no rule at all** — a plain div, so its children stack with zero vertical rhythm — the root of the "smooshed" look), the phase-93 `.page-head` card (L2423–2428), the phase-27/49 form cards `#git-source-form, #archive-upload-form` (L3207+), the ≤640px block (L6400–6403: the forms go column — they compose with the fix unchanged), `frontend/assets/git-sources.js` (L294, L1515–1519: the gate↔content reveal — `contentEl.hidden` flips only); `tests/e2e/auth_helpers.py::login` (the admin E2E sign-in), `tests/e2e/test_admin_settings_layout.py` (the phase-171 bounding-box rhythm E2E — the house pattern this suite copies), `tests/unit/test_git_source_branch_ui.py` (the static CSS/HTML/JS pin style this phase extends).
## Objective
The five stacked cards on the Sources page (the navbar "Sources" link → `/git-sources.html`) get one uniform 1.25rem gap between them — CSS only, reusing the shell's **existing** `gap: 1.25rem` token (the phase-171 `#settings-content` idiom applied to the one remaining admin view whose cards live inside a wrapper div). Nothing else changes: no JS, no `app/` code, no new palette/spacing value, the gate + load-error + env-note reveal lifecycles byte-identical.
## Design (shared by all tasks — the executor reads this, not the chat)
- **D1 — the rhythm (task 01).** `#git-sources-content` becomes `display: flex; flex-direction: column; gap: 1.25rem;` — the **same** token `.git-sources-shell` already uses (L3196) and the value phase 171 gave `#settings-content`. One rule, placed directly after the `.git-sources-shell` rule with a phase-174 comment explaining the wrapper-swallowed-the-shell-gap root cause. The children (page-head card, load-error alert, env note, the two form cards, the table card) then stack with one uniform gap at every viewport.
- **D2 — the hidden children (task 01, pinned in 02).** The two `hidden` children (`#git-sources-load-error`, `#git-source-env-note`) keep the UA `[hidden] → display:none` even inside a flex parent (the parent's `display` never overrides a child's own `display`), so `git-sources.js`'s reveal lifecycle (L1515–1519) is untouched; a revealed error/note simply joins the rhythm. The anonymous gate↔content flip (L1515–1519: `contentEl.hidden = false`) works on a flex element exactly as on a block.
- **D3 — no other changes.** `.git-sources-shell` (its `flex: 1` + gap stay — the gate↔content separation at anonymous), `git-sources.js`, `app/` server code, the form/table card rules, and the ≤640px media queries (L6400–6403) are all untouched; the fix is a single wrapper rule.
## Dependencies
- `171_admin_settings_layout` (complete) — the idiom this phase reuses (wrapper div → flex column + the existing 1.25rem token).
- `93_theme_semantic_completion` (complete) — the `.page-head` card language the rhythm spaces out (context only).
## Tasks
1. `01_content_card_rhythm.md` — `#git-sources-content` becomes the flex-column rhythm wrapper (the one CSS rule); the static pins.
2. `02_e2e_sources_card_rhythm.md` — `tests/e2e/test_git_sources_card_rhythm.py` (the phase's dedicated Playwright suite, AGENTS rule 4), the regression set, the full gates, the atomic commit.
## Testing & Quality
- **Unit:** `tests/unit/test_git_sources_card_rhythm.py` (new module, the `test_git_source_branch_ui.py` pin style): the `#git-sources-content` rule in `styles.css` with `display: flex` / `flex-direction: column` / `gap: 1.25rem` (the exact token — a regression to a different value fails), the `.git-sources-shell` gap staying `1.25rem` (unchanged), the `#git-sources-content` div present in `index.html` inside `.git-sources-shell` with the gate as a sibling (the structural pin — if the cards ever move back to the shell's direct children the rule becomes dead weight and the pin must say so).
- **E2E (AGENTS rule 4, task 02):** `tests/e2e/test_git_sources_card_rhythm.py`, run **in isolation** — the rhythm contract proven in a browser at 1440px and 375px, plus the hidden-children pin and the standard a11y/no-CDN light pass.
- **Coverage:** **>90%** on `app/` (this phase changes no `app/` code — the gate must stay green, not drop).
- **Lint/types:** `uv run ruff check . && uv run pyright`.
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's) and the gates above are green.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (this phase is a visual fix with no setup/command/config surface — expected: no doc edits).
## Completion Criteria
- [ ] On `/git-sources.html` (admin), the five cards (page-head → add form → upload form → table, plus a revealed load-error when one is forced) stack with one uniform ≈20px gap — no two card edges touch (the E2E bounding-box pins, at 1440px and 375px).
- [ ] `#git-sources-content`'s hidden children (`#git-sources-load-error`, `#git-source-env-note`) stay hidden in the flex wrapper (E2E pin); the anonymous gate still shows and the content stays hidden for anonymous (regression).
- [ ] The ≤640px mobile layout is intact (the forms column-stack per L6400–6403; the table scrolls inside its card — the E2E mobile pin).
- [ ] `uv run pytest` green, `app/` coverage >90% (`uv run pytest --cov=app --cov-report=term-missing`), ruff + pyright clean.
- [ ] `uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov` green **in isolation**; `test_git_sources_admin.py` + `test_archive_upload_sources.py` + `test_git_source_dates.py` green in isolation (the regression set).
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass).
- [ ] One atomic `--no-gpg-sign` Conventional Commit; the phase dir moves to `.agents/phases/complete/`.
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(sources): Sources (git sources) page — the card stack regains its 1.25rem rhythm"
```
@@ -0,0 +1,45 @@
# Task 01 — `#git-sources-content` becomes the flex-column rhythm wrapper
**Phase:** `174_git_sources_card_rhythm` · **Story:** n/a (owner bug report 2026-10-21)
## Objective
The one CSS rule that restores the vertical rhythm on the Sources page: `#git-sources-content` becomes a flex column with the shell's existing `1.25rem` gap — so the five stacked cards (page-head, load-error, env-note, add form, upload form, table card) stop stacking flush.
## Work
1. `frontend/assets/styles.css` — directly after the `.git-sources-shell` rule (L3193–3200), add the `#git-sources-content` rule with a phase-174 comment block (the house comment style: root cause + the reused token + the hidden-children behavior):
```css
/* Phase 174 (owner bug report 2026-10-21): the shell's gap only
separates the gate from this wrapper — every visible card
(page-head, the load-error alert, the env note, the two form
cards, the table card) is a child of THIS div, which was a
plain block, so the cards stacked flush (1px borders touching).
The phase-171 #settings-content idiom: the wrapper itself is
the rhythm column, reusing the shell's own 1.25rem token — no
new spacing value. The hidden children (#git-sources-load-error,
#git-source-env-note) keep the UA [hidden] display:none inside
a flex parent, so git-sources.js's reveal lifecycle is
untouched; a revealed one joins the rhythm. */
#git-sources-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
```
No other CSS change. Do not touch `.git-sources-shell`, the form/table card rules, or the ≤640px block (L6400–6403 — the forms' column-stacks compose with the new wrapper unchanged).
2. `tests/unit/test_git_sources_card_rhythm.py` — new module (the `test_git_source_branch_ui.py` pin style: `FRONTEND`/`STYLES_CSS`/`SHELL_HTML` path constants, raw text assertions, module docstring citing the phase + the owner report):
- the `#git-sources-content` rule exists in `styles.css` and carries `display: flex`, `flex-direction: column`, and `gap: 1.25rem` **exactly** (extract the rule body with a small regex over the `#git-sources-content { … }` block — a regression to any other gap value or a missing flex line fails);
- the `.git-sources-shell` rule still carries `gap: 1.25rem` (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 (so the rule is not dead weight — if a future refactor moves the cards to the shell's direct children, this pin names the dead rule);
- `#git-sources-load-error` and `#git-source-env-note` still carry `hidden` in `index.html` (the D2 premise the E2E pin proves in a browser).
## Testing & Quality
- Unit: the pins above; `uv run pytest tests/unit/test_git_sources_card_rhythm.py -v` green.
- Coverage: **>90%** on `app/` (unchanged by this task — the gate must stay green, not drop).
- No `app/` code change; no JS change.
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_git_sources_card_rhythm.py -v` green.
- [ ] `uv run pytest` green (the full unit + integration suite — no pin broke).
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] README.md/AGENTS.md need no change for this task (visual-only; the phase's final pass re-checks both project-wide).
- [ ] no behavior change in completed work (the gate reveal, the load-error retry, the env note, the form lifecycles are untouched — the rule only adds inter-card spacing).
@@ -0,0 +1,33 @@
# Task 02 — the phase's dedicated Playwright E2E suite + gates + commit
**Phase:** `174_git_sources_card_rhythm` · **Story:** n/a (owner bug report 2026-10-21)
## Objective
Prove the rhythm in a browser — `tests/e2e/test_git_sources_card_rhythm.py` (the phase's dedicated Playwright suite, AGENTS rule 4): the cards on `/git-sources.html` stack with one uniform gap at 1440px and 375px, the hidden children stay hidden in the flex wrapper, and the anonymous gate is intact. Then the regression set, the full gates, and the atomic commit.
## Work
1. `tests/e2e/test_git_sources_card_rhythm.py` — new module, run **in isolation** (`uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov`; DB must be up: `podman compose up -d db`). House shape: module docstring (the owner report + the run command + the test→contract map), the session `mock_llm`/`app_server`/`app_url`/`db_ready`/`page` fixtures from `tests/e2e/conftest.py`, `tests/e2e/auth_helpers.py::login` for the admin sign-in, a per-test `_truncate_kb`-style reset so each test's table state is its own (the `test_git_sources_admin.py` pattern — this suite is UI-only: it needs NO sync, no git, no network; the app can boot plain, the cards render regardless of row count).
Tests (four):
- `test_cards_stack_with_uniform_rhythm_desktop` — 1440×900, admin (`login(page, app_url, next="/git-sources.html")`): `#git-sources-content`'s computed `display` is `flex` and its `gap` resolves to 20px; the bounding boxes of the four visible cards — `.page-head` → `#git-source-form` → `#archive-upload-form` → `#git-sources-table-wrap` — are **pairwise disjoint** and each successive gap is ≈20px (`box_{n+1}.top − box_n.bottom == 20 ± 2`); no two card edges touch (the pre-fix regression: the gaps were ~0 — the 1px borders).
- `test_cards_stack_with_uniform_rhythm_mobile` — 375×720, admin: the same four cards are pairwise disjoint with ≈20px gaps; the two form cards column-stack per the ≤640px block (L6400–6403 — assert `#git-source-form` computed `flex-direction: column`) and the table scrolls **inside** its card (the `#git-sources-table-wrap` horizontal overflow is contained — the card's right edge is left of the viewport, no page-level horizontal scroll: `document.scrollingElement.scrollWidth <= 375`).
- `test_hidden_children_stay_hidden_in_flex_wrapper` — admin: `#git-sources-load-error` and `#git-source-env-note` are not visible (the `[hidden]` UA rule survives the flex parent — `offsetParent is None` / `is_visible() == False`); then force the load-error's `hidden` off via the page (attribute removal) and assert it **joins the rhythm** (visible, and its box sits ≥18px below the element above it) — the D2 both-sides pin; anonymous: `#git-sources-gate` visible, `#git-sources-content` hidden, no `/api/git-sources` call (the `test_git_sources_admin.py` anonymous contract, copied — the gate↔content flip is unchanged by the fix).
- `test_sources_page_a11y_and_no_cdn_basics` — the standard light pass (AGENTS rule 5/6, the `test_git_sources_admin.py` a11y test shape): landmarks on the view, the labeled form controls, the 3px `:focus-visible` outline on a table-row control (keyboard Tab), same-origin assets only (no external `<script src>`/`<link href>`).
2. **Regression set (in isolation, in order):** `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov`, then `test_archive_upload_sources.py`, then `test_git_source_dates.py` (the three suites that touch the git-sources view's gate/forms/table — all must be green with the wrapper now flex).
3. **Full gates (the `.agents/validate.sh` set):** `uv run pytest --cov=app --cov-report=term-missing` (the entire project suite 100% passing + `app/` coverage >90% — this phase changes no `app/` code, the floor must hold, not drop), `uv run ruff check .`, `uv run pyright`.
4. **Final documentation & consistency pass:** review `README.md` and `AGENTS.md` in full against the project as it stands after this phase; fix every inconsistency found, **including pre-existing ones** (expected for this phase: no doc edits — visual-only).
5. **Commit + move:** the atomic commit, then move the phase dir to `.agents/phases/complete/` (the `.agents/` tree is tracked — commit it with the phase per AGENTS rule 8).
## Testing & Quality
- E2E: the dedicated suite green **in isolation** + the three-suite regression set green in isolation.
- Coverage: **>90%** on `app/` (unchanged — the floor holds).
- Lint/types: ruff + pyright clean.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov` green **in isolation** (DB up).
- [ ] `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov`, `test_archive_upload_sources.py`, `test_git_source_dates.py` — each green in isolation.
- [ ] `uv run pytest --cov=app --cov-report=term-missing`: the entire project suite 100% passing, `app/` coverage >90%.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] README.md and AGENTS.md accurate and consistent with the whole project (verified in the final pass).
- [ ] one atomic `--no-gpg-sign` Conventional Commit:
`git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(sources): Sources (git sources) page — the card stack regains its 1.25rem rhythm"`
- [ ] the phase dir moved to `.agents/phases/complete/174_git_sources_card_rhythm/`.
@@ -0,0 +1,13 @@
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/`).
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
............ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 91 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 532 0 100%
--------------------------------------------------
TOTAL 6449 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
Task 01 complete — all gates green.
**Implemented**
- `frontend/assets/styles.css`: single new rule `#git-sources-content { display: flex; flex-direction: column; gap: 1.25rem; }` directly after the `.git-sources-shell` rule, with the phase-174 comment block (root cause, reused 1.25rem token, hidden-children D2 note). Nothing else touched (D3 verified by pin).
- `tests/unit/test_git_sources_card_rhythm.py` (new, house pin style): exact-rule pin for the wrapper (regex-extracted body — `display: flex` / `flex-direction: column` / `gap: 1.25rem` exact), `.git-sources-shell` unchanged pin (gap + `flex: 1`), structural pin (`#git-sources-content` + `#git-sources-gate` are direct children of the shell via a balanced div/depth scan, gate precedes content), and the `hidden`-attribute pins for `#git-sources-load-error` + the env note.
**Results**
- `uv run pytest tests/unit/test_git_sources_card_rhythm.py -v` → 4 passed
- `uv run pytest` → **4116 passed**, 0 failed
- `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (gate >90%)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
**Docs** — no doc impact (visual-only fix, no setup/command/config surface), per the task's own note.
**Notable decisions**
- The phase doc's `#git-source-env-note` is the element's *class*; its actual id is `git-sources-env-note` — the pin targets the id and documents the alias.
- Structural pin uses a depth-1 tag scan (handles void `<input>` tags and self-closing SVG children) rather than end-of-shell anchoring, since the shell's last child is the `#git-sources-announcer` region.
**Next pending task** — `02_e2e_sources_card_rhythm.md` (dedicated Playwright suite, regression set, full gates, atomic commit).
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
............ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 91 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 532 0 100%
--------------------------------------------------
TOTAL 6449 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,11 @@
All green. Final report:
**Phase 174 task 02 — complete**
- **E2E suite** `tests/e2e/test_git_sources_card_rhythm.py` (4 tests, isolated): uniform 1.25rem (20px) card rhythm at 1440×900 and 375×720 (pairwise-disjoint boxes, gaps 20±2px, wrapper computed flex/column/20px); ≤640px forms column-stack + table scrolls inside its card (no page-level horizontal scroll); D2 both-sides pin (`[hidden]` children stay hidden in the flex wrapper — `offsetParent` None — and a revealed load-error joins the rhythm ≥18px below page-head); D3 anonymous regression (gate visible, content hidden, zero `/api/git-sources` calls); a11y/no-CDN light pass (landmarks, labeled controls, 3px `:focus-visible` on a table-row control via real Tab walk, same-origin assets only).
- **Defect fixes (rule 8):** task-01's CSS comment named the env note's id as `#git-source-env-note` — the real id is `git-sources-env-note` (that string is the element's *class*); comment corrected in `styles.css`. (Task-01's unit test already handled the id/class distinction correctly.)
- **Gates:** `uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov` → 4 passed; regression set in isolation: `test_git_sources_admin.py` 6 passed, `test_archive_upload_sources.py` 5 passed, `test_git_source_dates.py` 4 passed; `uv run pytest --cov=app --cov-report=term-missing` → 4116 passed, `app/` coverage 99% (>90 floor); `uv run ruff check .` + `uv run pyright` → clean (0 errors).
- **Docs:** README.md and AGENTS.md reviewed in full against the project — accurate as-is; no doc edits (visual-only fix, as the phase expected).
- **Decisions:** (1) module-scoped app pinning `BOR_GIT_SOURCES=""` (phase-61 leak guard — this machine's `.env` carries a real value; with the per-test truncated table a leaked CSV would flip the page to env fallback and break the hidden-children pin); (2) the rhythm tests seed one row — `git-sources.js` sets `tableWrap.hidden = !hasRows`, so the table card (part of the 4-card stack the phase pins) only renders with a row; (3) mobile settle gate is `#git-sources-content` visibility (the reveal is the whoami gate) since `#sign-out-btn` hides inside the ≤640px hamburger nav.
- **Commit/phase-move:** not performed by me — per the binding harness rules the harness makes the one atomic `--no-gpg-sign` commit and moves the phase dir on success; all changes left in the working tree.
- **Next pending task:** none in `todo/174` (01 complete, 02 done) — phase 174 is complete.
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
............ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 91 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 532 0 100%
--------------------------------------------------
TOTAL 6449 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+18
View File
@@ -3197,6 +3197,24 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
flex: 1;
}
/* Phase 174 (owner bug report 2026-10-21): the shell's gap only
separates the gate from this wrapper — every visible card
(page-head, the load-error alert, the env note, the two form
cards, the table card) is a child of THIS div, which was a
plain block, so the cards stacked flush (1px borders touching).
The phase-171 #settings-content idiom: the wrapper itself is
the rhythm column, reusing the shell's own 1.25rem token — no
new spacing value. The hidden children (#git-sources-load-error,
#git-sources-env-note — the env note's CLASS is the singular
git-source-env-note) keep the UA [hidden] display:none inside
a flex parent, so git-sources.js's reveal lifecycle is
untouched; a revealed one joins the rhythm. */
#git-sources-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Add forms — the tuning form's surface as a single row: visible
label + mono location input (git URLs may embed credentials, so the
input is mono) + the brand button; wraps to a column at narrow
+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}"
)