From dd119d534a4ead86f26753af48601364947447fc Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sun, 11 Oct 2026 13:23:04 -0400 Subject: [PATCH] phase: 176_mobile_settings_value_width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All documentation claims verify against the code. Everything is green — final verification complete. ## Phase 176 — Final Verification Report - **Verified (all 3 tasks in `complete/`):** the mobile Value-column fix is in the working tree — `frontend/index.html` L1602 carries `class="settings-value-th"`; `frontend/assets/styles.css` has exactly two new rules inside the `@media (max-width: 640px)` block: `.settings-table { min-width: 720px }` and `.settings-value-cell, .settings-value-th { min-width: calc(13rem + 2rem) }`; `settings.js` + `app/` byte-unchanged (diff = index.html + styles.css + unit test only). - **Documented deviation (flagged in task-03 report, not silent):** spec's `min-width: 13rem` on the border-box cell left the control at 176px (< the 192px A3 pin); executor corrected the cell to `calc(13rem + 2rem)` so the control (100% of content) lands at exactly 13rem ≈ 208px — the A2 lock intent preserved, unit pin + CSS comment updated to match. - **Tests/lint/coverage (exact commands, all green):** `uv run pytest --cov=app` → **4138 passed**, `app/` coverage **99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings. - **Completion criteria:** 375px controls ≥ 192px — ✅ (E2E `test_mobile_value_inputs_are_usable_width`); card scrolls / page doesn't overflow at 375px — ✅ (`test_mobile_table_still_scrolls_and_page_does_not_overflow`); 1440px media query inert — ✅ (`test_desktop_value_width_is_unchanged`); tab order + zero external requests — ✅; phase E2E isolated — ✅ 4/4; phase 171 isolated — ✅ 5/5; phase 168 isolated — ✅ 12/12; unit pins — ✅ 17/17; commit/move — the harness commits; phase dir already in `complete/`. - **Documentation:** README.md and AGENTS.md reviewed in full — every claim spot-checked against code (E2E ports 8123/8901, defaults `BOR_COMPRESS_MIN_BYTES=860` / `BOR_LOG_LARGE_RESPONSE_BYTES=65536`, audit budget 262144, `.cell-clip`, `.bubble:empty`, `min(70vh, 64rem)`, aria-pressed toggle, CDN integration test, `.gitignore` entries, all referenced scripts/routes) — verified consistent, no changes needed. - **Notable:** `image-1.png` (owner's TODO screenshot, referenced by the phase overview) sits untracked in the tree — left alone; not part of the phase's commit scope. - **Next pending phase:** `177_themed_favicon`. --- .../00_phase.md | 0 .../01_mobile_value_column_width.md | 0 .../02_unit_pins.md | 0 .../03_e2e_mobile_value_width.md | 0 ...bile_settings_value_width__00_phase.a1.err | 0 ...obile_settings_value_width__00_phase.a1.md | 11 + ...ettings_value_width__00_phase.a1.md.status | 1 + ...settings_value_width__00_phase.a1.validate | 136 ++++ ...width__01_mobile_value_column_width.a1.err | 0 ..._width__01_mobile_value_column_width.a1.md | 12 + ..._01_mobile_value_column_width.a1.md.status | 1 + ...__01_mobile_value_column_width.a1.validate | 136 ++++ ..._settings_value_width__02_unit_pins.a1.err | 0 ...e_settings_value_width__02_unit_pins.a1.md | 23 + ...ngs_value_width__02_unit_pins.a1.md.status | 1 + ...ings_value_width__02_unit_pins.a1.validate | 136 ++++ ...ue_width__03_e2e_mobile_value_width.a1.err | 0 ...lue_width__03_e2e_mobile_value_width.a1.md | 10 + ...th__03_e2e_mobile_value_width.a1.md.status | 1 + ...dth__03_e2e_mobile_value_width.a1.validate | 136 ++++ frontend/assets/styles.css | 23 +- frontend/index.html | 2 +- tests/e2e/test_settings_mobile_value_width.py | 581 ++++++++++++++++++ tests/unit/test_settings_frontend_view.py | 123 +++- 24 files changed, 1329 insertions(+), 4 deletions(-) rename .agents/phases/{todo => complete}/176_mobile_settings_value_width/00_phase.md (100%) rename .agents/phases/{todo => complete}/176_mobile_settings_value_width/01_mobile_value_column_width.md (100%) rename .agents/phases/{todo => complete}/176_mobile_settings_value_width/02_unit_pins.md (100%) rename .agents/phases/{todo => complete}/176_mobile_settings_value_width/03_e2e_mobile_value_width.md (100%) create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.err create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.md create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.md.status create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.validate create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.err create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.md create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.md.status create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.validate create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.err create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.md create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.md.status create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.validate create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.err create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.md create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.md.status create mode 100644 .agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.validate create mode 100644 tests/e2e/test_settings_mobile_value_width.py diff --git a/.agents/phases/todo/176_mobile_settings_value_width/00_phase.md b/.agents/phases/complete/176_mobile_settings_value_width/00_phase.md similarity index 100% rename from .agents/phases/todo/176_mobile_settings_value_width/00_phase.md rename to .agents/phases/complete/176_mobile_settings_value_width/00_phase.md diff --git a/.agents/phases/todo/176_mobile_settings_value_width/01_mobile_value_column_width.md b/.agents/phases/complete/176_mobile_settings_value_width/01_mobile_value_column_width.md similarity index 100% rename from .agents/phases/todo/176_mobile_settings_value_width/01_mobile_value_column_width.md rename to .agents/phases/complete/176_mobile_settings_value_width/01_mobile_value_column_width.md diff --git a/.agents/phases/todo/176_mobile_settings_value_width/02_unit_pins.md b/.agents/phases/complete/176_mobile_settings_value_width/02_unit_pins.md similarity index 100% rename from .agents/phases/todo/176_mobile_settings_value_width/02_unit_pins.md rename to .agents/phases/complete/176_mobile_settings_value_width/02_unit_pins.md diff --git a/.agents/phases/todo/176_mobile_settings_value_width/03_e2e_mobile_value_width.md b/.agents/phases/complete/176_mobile_settings_value_width/03_e2e_mobile_value_width.md similarity index 100% rename from .agents/phases/todo/176_mobile_settings_value_width/03_e2e_mobile_value_width.md rename to .agents/phases/complete/176_mobile_settings_value_width/03_e2e_mobile_value_width.md diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.err b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.md b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.md new file mode 100644 index 0000000..e76b5bf --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.md @@ -0,0 +1,11 @@ +All documentation claims verify against the code. Everything is green — final verification complete. + +## Phase 176 — Final Verification Report + +- **Verified (all 3 tasks in `complete/`):** the mobile Value-column fix is in the working tree — `frontend/index.html` L1602 carries `class="settings-value-th"`; `frontend/assets/styles.css` has exactly two new rules inside the `@media (max-width: 640px)` block: `.settings-table { min-width: 720px }` and `.settings-value-cell, .settings-value-th { min-width: calc(13rem + 2rem) }`; `settings.js` + `app/` byte-unchanged (diff = index.html + styles.css + unit test only). +- **Documented deviation (flagged in task-03 report, not silent):** spec's `min-width: 13rem` on the border-box cell left the control at 176px (< the 192px A3 pin); executor corrected the cell to `calc(13rem + 2rem)` so the control (100% of content) lands at exactly 13rem ≈ 208px — the A2 lock intent preserved, unit pin + CSS comment updated to match. +- **Tests/lint/coverage (exact commands, all green):** `uv run pytest --cov=app` → **4138 passed**, `app/` coverage **99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings. +- **Completion criteria:** 375px controls ≥ 192px — ✅ (E2E `test_mobile_value_inputs_are_usable_width`); card scrolls / page doesn't overflow at 375px — ✅ (`test_mobile_table_still_scrolls_and_page_does_not_overflow`); 1440px media query inert — ✅ (`test_desktop_value_width_is_unchanged`); tab order + zero external requests — ✅; phase E2E isolated — ✅ 4/4; phase 171 isolated — ✅ 5/5; phase 168 isolated — ✅ 12/12; unit pins — ✅ 17/17; commit/move — the harness commits; phase dir already in `complete/`. +- **Documentation:** README.md and AGENTS.md reviewed in full — every claim spot-checked against code (E2E ports 8123/8901, defaults `BOR_COMPRESS_MIN_BYTES=860` / `BOR_LOG_LARGE_RESPONSE_BYTES=65536`, audit budget 262144, `.cell-clip`, `.bubble:empty`, `min(70vh, 64rem)`, aria-pressed toggle, CDN integration test, `.gitignore` entries, all referenced scripts/routes) — verified consistent, no changes needed. +- **Notable:** `image-1.png` (owner's TODO screenshot, referenced by the phase overview) sits untracked in the tree — left alone; not part of the phase's commit scope. +- **Next pending phase:** `177_themed_favicon`. diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.md.status b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.validate b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.validate new file mode 100644 index 0000000..935dd25 --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__00_phase.a1.validate @@ -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%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 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 111 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 533 0 100% +-------------------------------------------------- +TOTAL 6451 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 diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.err b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.md b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.md new file mode 100644 index 0000000..39f3113 --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.md @@ -0,0 +1,12 @@ +**Report — phase 176, task 01 (mobile Value-column width floor)** + +- `frontend/assets/styles.css`: two rules added inside the `@media (max-width: 640px)` block, directly after the phase-168 settings squeeze rules — `.settings-table { min-width: 720px; }` and `.settings-value-cell, .settings-value-th { min-width: 13rem; }` (with the phase-176 header comment; no new CSS custom property). +- `frontend/assets/styles.css`: the `.settings-table` comment (≥641px block) now notes the mobile block raises the floor to 720 px; the 560 px rule itself is byte-unchanged. +- `frontend/index.html` L1602: the Value `th` gained `class="settings-value-th"` (static HTML; `settings.js` byte-unchanged). +- `tests/unit/test_settings_frontend_view.py`: the pre-existing thead pin counted bare `` and broke by design; updated it to be class-aware (count pin now matches the 4th column, and asserts the `settings-value-th` class with rationale — this doubles as task 02's class pin). +- Verification: the two selectors occur exactly once, inside the ≤640 px block (grep/awk confirmed block bounds 6183–6542, rules at 6506–6507); `git diff --stat` shows only `frontend/index.html` + `frontend/assets/styles.css` + the test pin; no `app/` change. +- **`uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov`**: 16 passed (incl. `test_styles_css_settings_block_uses_existing_tokens_only` — bare lengths are not tokens). +- **`uv run pytest --cov=app --cov-report=term-missing`**: 4137 passed, 0 failed; app/ coverage 99% (>90% gate holds). +- **`uv run ruff check .`**: all checks passed; **`uv run pyright`**: 0 errors, 0 warnings. +- Docs: no doc impact (CSS-only mobile polish — no commands, config, env, API, or convention changed). +- Next pending task: `02_unit_pins.md` (the ≤640px-block-only pins for the two new rules; the class pin is already in place). diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.md.status b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.validate b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.validate new file mode 100644 index 0000000..bb91fa2 --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__01_mobile_value_column_width.a1.validate @@ -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%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 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 111 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 533 0 100% +-------------------------------------------------- +TOTAL 6451 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 diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.err b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.md b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.md new file mode 100644 index 0000000..4e8451f --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.md @@ -0,0 +1,23 @@ +All criteria met. Final report: + +**Task 02 (unit pins) — complete** + +- Added `test_settings_mobile_value_column_floor` in `tests/unit/test_settings_frontend_view.py` (new phase-176 section after the phase-171 section, house one-test-per-contract idiom; docstring cites TODO.md L3 + owner-locked A1/A2): + - ≤640px block sliced by open/close markers (`@media (max-width: 640px) {` → first column-zero `}`; no hardcoded line range — survives insertions above it) + - `.settings-value-cell, .settings-value-th` occurs exactly once in `styles.css` and that occurrence is inside the block; rule body declares `min-width: 13rem` + - Both table floors pinned: base `.settings-table` keeps `min-width: 560px` (and does not know `720px`); the mobile-block rule declares `min-width: 720px` (also asserted to exist exactly once, in the block) + - `index.html` settings thead (located via the settings table tag, `` searched after it) carries `Value` +- Mutation check (both red → restored): dropping the Value rule → pin FAILED; moving the 720px rule outside the block → pin FAILED +- `test_styles_css_settings_block_uses_existing_tokens_only` passes UNCHANGED (its `:root` scan is scoped to the Settings block; 13rem/720px are bare lengths) + +**Results (all green):** +- `uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov` → 17 passed +- `uv run pytest --no-cov` → 4138 passed, 0 failed +- `uv run pytest --cov=app --cov-report=term-missing` → 4138 passed, `app/` TOTAL 99% (>90%) +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings +- `frontend/` + `app/` byte-unchanged by this task (diff is `tests/unit/test_settings_frontend_view.py` only) + +**Docs:** no doc impact (pure test pin, no user-visible change). +**Decisions:** used `[^{}]*` single-line rule extraction (phase-171 task-03 idiom) for the two one-line mobile rules; thead slice searches `` after the table start (the shell's earlier tables break a file-global find). + +**Next pending task:** `03_e2e_mobile_value_width.md` diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.md.status b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.validate b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.validate new file mode 100644 index 0000000..935dd25 --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__02_unit_pins.a1.validate @@ -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%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 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 111 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 533 0 100% +-------------------------------------------------- +TOTAL 6451 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 diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.err b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.md b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.md new file mode 100644 index 0000000..ebac8d8 --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.md @@ -0,0 +1,10 @@ +All gates green; every change left in the working tree per harness protocol (no `git add`/`commit`, no phase-dir move — the harness makes the one atomic commit and moves the dir on success; this overrides the task file's commit step). + +**Report** + +- **Implemented:** `tests/e2e/test_settings_mobile_value_width.py` — the phase-176 Playwright suite (4 tests, structured verbatim on phase 171: module `_boot_app`/`app_server` with `BOR_SETTINGS_DB=1` opt-in, per-test `app_settings`/`ui_settings` truncation + `e2e-` token cleanup, never a TRUNCATE on `api_tokens`, DB-isolation docstring + teardown pin, `.env` mtime guard): 375px width contract (3 distinct control types — text/select/textarea — each `.settings-control` ≥ 192px, card `scrollWidth > clientWidth`, page `≤ 375px`), the scroll/sticky-`thead` pin, the 1440px inert-media-query pin (min-width `0px`/`auto`, no forced scroll), the 375px keyboard walk (filter → first row's Value control → Save → Clear all) + zero external requests; suite issues no save (only write is none). +- **Defect fixed (task 01, flagged not silent):** `min-width: 13rem` on the border-box cell left the control at 176px (208px − 32px cell padding) — under the A3 pin and below the A2 intent (≈208px input). Fixed the mobile rule to `min-width: calc(13rem + 2rem)` (13rem content + the td's 2rem padding) → controls now measure exactly **208px** (16px margin over the 192px pin); CSS comment + the task-02 unit pin updated with the border-box arithmetic (lock intent preserved: input = the locked 13rem ≈ 14 mono chars). +- **Tests/lint:** `uv run pytest tests/e2e/test_settings_mobile_value_width.py -v --no-cov` → 4 passed (isolated); `test_admin_settings_layout.py` → 5 passed; `test_admin_settings_panel.py` → 12 passed (both isolated); `uv run pytest` → 4138 passed; `--cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean (0 errors). +- **Docs:** no doc impact (README/AGENTS.md don't document pixel-level layout; no setup/command/env/API change). +- **Deviations:** commit + phase-dir move deferred to the harness (binding override); probe-row spread chosen by control type (text/select/textarea) per the task's mechanics allowance; task's "75 rows" is 78 in the live catalogue (phase-168 pin) — asserted only that the three probe rows exist and are distinct, no count pin. +- **Next pending task:** none in this phase (task 03 is the final task) — the phase is complete and ready for the harness's atomic commit + dir move. diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.md.status b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.validate b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.validate new file mode 100644 index 0000000..935dd25 --- /dev/null +++ b/.agents/reports/176_mobile_settings_value_width/176_mobile_settings_value_width__03_e2e_mobile_value_width.a1.validate @@ -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%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 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 111 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 533 0 100% +-------------------------------------------------- +TOTAL 6451 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 diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index f87f59a..dc3f740 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -4738,7 +4738,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und /* The catalogue: FULL-WIDTH (AGENTS.md rule 5 — the .git-sources- table language: no skinny list), one cell per column, the .table-wrap card + its bounded scroll + the sticky thead carry - the phase-156 contract. */ + the phase-156 contract. The ≤640px mobile block (phase 176) raises + the floor to 720px so the 13rem Value column stays readable on a + phone (the card's horizontal scroll absorbs it; this rule keeps + 560px for ≥641px viewports). */ .settings-table { width: 100%; border-collapse: collapse; @@ -6486,6 +6489,24 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und #settings-save { width: 100%; } #settings-clear-all { width: 100%; } .settings-actions-cell { white-space: normal; } + /* Phase 176 (task 01; TODO L3, owner-locked A2): the Value column + gets a usable floor on the phone. The Setting cells' nowrap + labels (the .settings-table td rule above) exhausted the 560px + table floor, collapsing the min-width: 0 Value cells to 2–3 + characters at 375px. The cell's min-width is 13rem + 2rem (the + td's own 1rem × 2 padding — the global border-box model): the + .settings-control (width: 100% of the cell's CONTENT) lands at + the locked 13rem (≈208px ≈ 14 mono chars at the controls' + 0.85rem) — readable and editable on a phone; the 720px table + floor + the card's overflow: auto (.table-wrap) mean the phone + scrolls the CARD, never the page (the phase-171 "no horizontal + page overflow" pin holds). 720px is a bare length literal — NO + new CSS custom property (B3 binds palette variables; a length in + the mobile block is the idiom the neighboring width: 100% rules + already use). Mobile-only (A1): at ≥641px these rules are inert + and the desktop layout stays byte-identical. */ + .settings-table { min-width: 720px; } + .settings-value-cell, .settings-value-th { min-width: calc(13rem + 2rem); } /* Phase 51: the shared page squeezes like the chat column — the title and the note step down (the empty-state-title family); the shell is full-width here (the 72rem cap never binds on a phone — diff --git a/frontend/index.html b/frontend/index.html index b9ec73b..e83e98a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1599,7 +1599,7 @@ Setting - Value + Value Source Actions diff --git a/tests/e2e/test_settings_mobile_value_width.py b/tests/e2e/test_settings_mobile_value_width.py new file mode 100644 index 0000000..d34e97c --- /dev/null +++ b/tests/e2e/test_settings_mobile_value_width.py @@ -0,0 +1,581 @@ +"""Phase 176 E2E (Playwright): the mobile Settings Value-column width — +the width contract from ``TODO.md`` L3, proven in a real browser. + +Source: ``TODO.md`` L3 — "On mobile, the settnigs values are +impossibly small. This is a horizontally scrollable table so we can +have slightly wider input fields." (the screenshot: at a 375px phone +viewport the Value-column inputs render only 2–3 characters wide — +"Br", "0.", "pr", "Wr", "Pc", "IN" — unreadable and unusable.) + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_settings_mobile_value_width.py -v --no-cov + +Test → contract mapping (one story, one phase, one isolated file): + +1. ``test_mobile_value_inputs_are_usable_width`` — the "impossibly + small" pin: at 375px the admin arrives at the settings view (the + phase-168 gate flow), and a representative spread of rows — one + ``type="text"`` input, one ``select``, one ``textarea`` (three + distinct control types among the catalogue's 78 rows; the + phase-168 catalogue guarantees text / number / select / textarea / + password controls) — each renders its ``.settings-control`` + bounding box ≥ 192px wide (12 rem — owner-locked A3: the + deliberate margin under the 13 rem rule; the pin is 12 rem and + ≥ 200px is NOT asserted, so the pin is not brittle to sub-pixel + rounding), the ``.settings-table``'s own ``scrollWidth`` exceeds + the ``.table-wrap`` card's ``clientWidth`` (the horizontal scroll + is active on the phone — the TODO's own premise, the 720px + mobile floor, owner-locked A2), while + ``document.documentElement.scrollWidth ≤ 375`` (the PAGE does not + overflow — the phase-171 pin: the scroll is the card's, not the + page's). +2. ``test_mobile_table_still_scrolls_and_page_does_not_overflow`` — + the "the card scrolls, never the page" pin: at 375px the + ``.table-wrap`` computed ``overflow`` is ``auto``, the card's + ``scrollWidth > clientWidth``, the page-level + ``documentElement.scrollWidth ≤ 375 + 1`` (1px rounding), and the + sticky-``thead`` contract survives — scroll the card and the + ``thead th``s stay pinned at the card's top edge (computed + ``position: sticky`` — the phase-156/168/171 catalogue + contract intact under the new column floor). +3. ``test_desktop_value_width_is_unchanged`` — the "no desktop + regression" pin: at 1440px the media query is INERT — the + computed ``min-width`` of ``.settings-value-cell`` (and + ``.settings-value-th``) is the UA ``0px``/``auto`` + (``getComputedStyle`` returns ``0px`` or ``auto``, never + ``208px``/``208.0px`` — the 13rem floor binds only inside the + ≤640px block, owner-locked A1) and the ``.settings-table`` + ``scrollWidth`` ≤ the card's ``clientWidth`` (no forced scroll at + desktop — the 720px floor is inert, the 560px base rule is + subsumed by the card's width). +4. ``test_keyboard_order_and_no_external_requests`` — PLAN §7.1 + + rule 6: at 375px the keyboard tab order still reaches + ``#settings-filter`` → the first row's Value control → + ``#settings-save`` → ``#settings-clear-all`` (the phase-168/171 + contract under the new column floor), and the whole test issues + ZERO external (non-127.0.0.1) requests. + +DB isolation: the shared e2e Postgres keeps ``app_settings`` (the +overlay rows — a leftover row would retune every later suite's app +through the conftest pin's failure mode), ``ui_settings`` (the +single row the caching middleware reads for EVERY served page — the +effective-strings resolution ``/api/config`` uses) and ``api_tokens`` +rows across suites. An autouse fixture truncates ``app_settings`` +and ``ui_settings`` and deletes the ``e2e-``-labeled tokens before +AND after every test (never a TRUNCATE on ``api_tokens`` — the +shared DB may hold the owner's real tokens); the module teardown +pins ``app_settings`` empty. This suite's only write is NONE (no +save is issued — the width contract needs no dirty row; test 4's +typed row is form-dirty only, never PUT): the per-test TRUNCATE +before/after is the same idiom as phases 168/171, and the teardown +guarantees no leftover row. The module app opts BACK IN over the +conftest's global ``BOR_SETTINGS_DB=0`` pin +(``env["BOR_SETTINGS_DB"] = "1"`` — the ``test_pictorial_pdf.py`` +opt-in pattern; the panel's gate flow — ``#settings-disabled`` +hidden — needs the overlay ON) and pins the branding base to +DISTINCTIVE values (``Env placeholder for panel E2E`` / ``Env footer +for panel E2E``, the phase-168 idiom). A module-scope ``.env`` mtime +guard pins LOCKED A1: no code path the suite drives opens the env +file for writing. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from collections.abc import Iterator + +import pytest +from playwright.sync_api import Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from e2e.auth_helpers import login +from e2e.conftest import ( + ADMIN_PASSWORD, + APP_PORT, + REPO, + SESSION_SECRET, + USE_REAL_LLM, + _wait_http, +) + +APP_URL = f"http://127.0.0.1:{APP_PORT}" + +#: The distinctive env base (the phase-168 panel-suite idiom — the +#: module boots exactly like the panel/layout suites, so the width +#: contract is proven against the same app shape). +ENV_PLACEHOLDER = "Env placeholder for panel E2E" +ENV_FOOTER = "Env footer for panel E2E" + +#: The typed (never saved) dirty-row value — test 4's Save must be +#: ENABLED (a disabled button is not tabbable) for the keyboard +#: walk. +DIRTY_VALUE = "E2E width brain" + + +# --------------------------------------------------------------------------- +# Per-module app env (the phase-171 env block — the test_admin_settings_panel.py +# fixture, verbatim) +# --------------------------------------------------------------------------- + + +def _boot_app(port: int, settings_db: str) -> subprocess.Popen[bytes]: + """One module app (the shared env block + the task's pins), on + ``port`` with the overlay ``settings_db`` (``"1"`` / ``"0"``).""" + 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_PORT}/v1" + ) + # The E2E-calibrated gate pins (the conftest pattern — the mock's + # token-overlap distribution, explicit so an operator's local + # .env cannot leak a different gate into the suite's app). + 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 + env["BOR_DOCS_REPO"] = "" + env["BOR_SUGGESTIONS"] = json.dumps( + Settings.model_fields["suggestions"].default + ) + # The app name stays the CODE default (the theme-suite pin — the + # assertions below name it). + env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default + # Phase 176: the module opts BACK IN over the conftest's global + # BOR_SETTINGS_DB=0 pin (the test_pictorial_pdf.py pattern) — + # the panel's gate flow (test 1) needs the overlay ON. + env["BOR_SETTINGS_DB"] = settings_db + # The distinctive env base: the module boots exactly like the + # phase-168/171 panel suites (the width contract is proven + # against the same app shape). + env["BOR_INPUT_PLACEHOLDER"] = ENV_PLACEHOLDER + env["BOR_FOOTER_TEXT"] = ENV_FOOTER + env["BOR_GIT_SOURCES"] = "" + return subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"], + cwd=REPO, + env=env, + ) + + +def _stop_app(proc: subprocess.Popen[bytes]) -> None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture(scope="module") +def app_server(mock_llm: int) -> Iterator[str]: + """The real app under test — per-module env: the branding vars + pinned to the DISTINCTIVE E2E values (the phase-168 idiom) and + ``BOR_SETTINGS_DB=1`` (the module opts back in over the + conftest pin).""" + global _MOCK_PORT + _MOCK_PORT = mock_llm + proc = _boot_app(APP_PORT, "1") + try: + _wait_http(f"{APP_URL}/api/health") + yield APP_URL + finally: + _stop_app(proc) + + +@pytest.fixture(scope="module") +def app_url(app_server: str) -> str: + return app_server + + +# --------------------------------------------------------------------------- +# DB isolation + helpers +# --------------------------------------------------------------------------- + + +def _clean_settings_state() -> None: + """Fresh settings + theme + token state per test: truncate + ``app_settings`` (a leftover row is the cross-suite poison this + phase's D6 exists to prevent) and the single-row + ``ui_settings`` (the middleware reads it for EVERY page — the + effective-strings resolution ``/api/config`` uses), and delete + this suite's issued tokens (label-scoped on ``e2e-`` — never a + TRUNCATE: the shared DB may hold the owner's real tokens).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE app_settings")) + db.execute(text("TRUNCATE ui_settings")) + db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'")) + db.commit() + + +@pytest.fixture(autouse=True) +def _clean(db_ready: None) -> Iterator[None]: + _clean_settings_state() + yield + _clean_settings_state() + # The module's final state: app_settings EMPTY (the D6 poison + # guard — the next suite's app must boot with no overrides). + with SessionLocal() as db: + assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0 + + +@pytest.fixture(scope="module", autouse=True) +def _env_never_written() -> Iterator[None]: + """LOCKED A1, mtime-pinned: no code path the suite drives opens + ``.env`` for writing (the file is gitignored — a ``git status`` + check cannot see it, so ``stat``'s mtime is the assertion).""" + env_file = REPO / ".env" + before = env_file.stat().st_mtime_ns if env_file.exists() else None + yield + after = env_file.stat().st_mtime_ns if env_file.exists() else None + assert before == after, "the app under test must never write .env" + + +def _login_admin(page: Page, app_url: str) -> None: + """The phase-168 gate flow: the admin arrives at the settings + view (the gate down, the overlay on).""" + page.set_default_timeout(30_000) + login(page, app_url, next="/settings.html") + expect(page.locator("#nav-settings")).to_be_visible(timeout=15_000) + expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) + expect(page.locator("#settings-gate")).to_be_hidden() + expect(page.locator("#settings-disabled")).to_be_hidden() # the overlay is on + + +# --------------------------------------------------------------------------- +# 1. The "impossibly small" pin: a representative spread of rows — +# one text input, one select, one textarea — each renders its +# .settings-control ≥ 192px wide at 375px (12 rem, A3), the card +# scrolls horizontally, the page does not +# --------------------------------------------------------------------------- + + +def test_mobile_value_inputs_are_usable_width( + page: Page, app_url: str, db_ready: None +) -> None: + # The gate flow at the default (1280) viewport — the nav link is + # only visible on desktop (the ≤640px hamburger owns the nav + # pill); the width contract itself is proven at 375px below. + _login_admin(page, app_url) + + page.set_viewport_size({"width": 375, "height": 900}) + page.wait_for_timeout(150) # the reflow settles before the measure + expect(page.locator("#settings-content")).to_be_visible() + + # A representative spread: the FIRST row of each of three + # distinct control types (one type="text" input, one select, one + # textarea) among the catalogue's 78 rows (the phase-168 + # catalogue guarantees text / number / select / textarea / + # password controls). + keys = page.evaluate( + """() => { + const row = (sel) => { + const c = document.querySelector(sel); + return c ? c.closest("tr").dataset.key : null; + }; + return { + text: row("tr.settings-row input.settings-control[type='text']"), + select: row("tr.settings-row select.settings-control"), + textarea: row("tr.settings-row textarea.settings-control"), + }; + }""" + ) + rows = page.evaluate( + "() => document.querySelectorAll('tr.settings-row').length" + ) + assert keys["text"] is not None, ( + f"the catalogue must carry a text-input row (catalogue = {rows} rows)" + ) + assert keys["select"] is not None, "the catalogue must carry a select row" + assert keys["textarea"] is not None, "the catalogue must carry a textarea row" + assert len(set(keys.values())) == 3, ( + f"the three probes must be three DISTINCT rows (got {keys!r})" + ) + + # The "impossibly small" pin: every probe's .settings-control + # bounding box is ≥ 192px (12 rem — owner-locked A3: the + # deliberate margin under the 13 rem rule). The pin is 12 rem; + # ≥ 200px is deliberately NOT asserted (13 rem ≈ 208px is the + # RULE, 12 rem the pin) so the pin is not brittle to sub-pixel + # rounding. + for kind, key in keys.items(): + box = page.locator( + f'tr[data-key="{key}"] .settings-control' + ).bounding_box() + assert box is not None, f"the {kind} control ({key}) must render at 375px" + assert box["width"] >= 192, ( + f"the {kind} control ({key}) must be ≥ 192px (12 rem) at 375px " + f"(got {box['width']:.1f}px)" + ) + + # The TODO's own premise: the table is horizontally scrollable — + # at 375px the .settings-table's own scrollWidth exceeds the + # .table-wrap card's clientWidth (the 720px mobile floor, + # owner-locked A2)… + scroll = page.evaluate( + """() => { + const card = document.querySelector(".settings-shell .table-wrap"); + const table = document.querySelector( + ".settings-shell .table-wrap .settings-table"); + return { table: table.scrollWidth, card: card.clientWidth }; + }""" + ) + assert scroll["table"] > scroll["card"], ( + f"at 375px the table must overflow the card horizontally " + f"(table {scroll['table']}px vs card {scroll['card']}px)" + ) + # …while the PAGE itself does not overflow (the phase-171 pin — + # the scroll is the card's, not the page's). + page_width = page.evaluate("() => document.documentElement.scrollWidth") + assert page_width <= 375, ( + f"the page must not overflow at 375px (got {page_width}px)" + ) + + +# --------------------------------------------------------------------------- +# 2. The "the card scrolls, never the page" pin: overflow: auto, +# scrollWidth > clientWidth, no page overflow, the sticky thead +# survives a card scroll +# --------------------------------------------------------------------------- + + +def test_mobile_table_still_scrolls_and_page_does_not_overflow( + page: Page, app_url: str, db_ready: None +) -> None: + _login_admin(page, app_url) + + page.set_viewport_size({"width": 375, "height": 900}) + page.wait_for_timeout(150) # the reflow settles before the measure + expect(page.locator("#settings-content")).to_be_visible() + + # The .table-wrap is the bounded-scroll card (the phase-156 + # catalogue contract): computed overflow auto… + overflow = page.evaluate( + "() => getComputedStyle(" + "document.querySelector('.settings-shell .table-wrap')).overflow" + ) + assert overflow == "auto", ( + f"the card's computed overflow must be `auto` (got {overflow!r})" + ) + # …and at 375px the card's horizontal scroll is active + # (scrollWidth > clientWidth — the 720px table floor, the + # TODO's premise). + dims = page.evaluate( + """() => { + const card = document.querySelector(".settings-shell .table-wrap"); + return { scroll: card.scrollWidth, client: card.clientWidth }; + }""" + ) + assert dims["scroll"] > dims["client"], ( + f"the card's horizontal scroll must be active at 375px " + f"(scrollWidth {dims['scroll']}px vs clientWidth {dims['client']}px)" + ) + # …while the PAGE-level width stays inside the viewport (1px + # rounding — the phase-171 pin). + page_width = page.evaluate("() => document.documentElement.scrollWidth") + assert page_width <= 375 + 1, ( + f"the page must not overflow at 375px (got {page_width}px)" + ) + + # The sticky-``thead`` contract survives the new column floor: + # scroll the card down and the thead ths stay pinned at the + # card's top edge (computed position: sticky — the phase-156/168 + # contract, inherited). + page.evaluate( + "() => { const c = document.querySelector('.settings-shell .table-wrap');" + " c.scrollTop = 300; }" + ) + page.wait_for_timeout(100) # the sticky reposition settles + sticky = page.evaluate( + "() => getComputedStyle(" + "document.querySelector(" + "'.settings-shell .table-wrap .settings-table thead th'" + ")).position" + ) + assert sticky == "sticky", ( + f"the thead th must stay sticky after a card scroll (got {sticky!r})" + ) + pinned = page.evaluate( + """() => { + const card = document.querySelector(".settings-shell .table-wrap"); + const th = document.querySelector( + ".settings-shell .table-wrap .settings-table thead th"); + return { th: th.getBoundingClientRect().top, + card: card.getBoundingClientRect().top }; + }""" + ) + assert abs(pinned["th"] - pinned["card"]) <= 1, ( + f"the thead th must stay pinned at the card's top edge after a " + f"scroll (th {pinned['th']:.1f}px vs card {pinned['card']:.1f}px)" + ) + + +# --------------------------------------------------------------------------- +# 3. The "no desktop regression" pin: at 1440px the media query is +# INERT — no 13rem floor on the Value cell, no forced scroll +# --------------------------------------------------------------------------- + + +def test_desktop_value_width_is_unchanged( + page: Page, app_url: str, db_ready: None +) -> None: + _login_admin(page, app_url) + + page.set_viewport_size({"width": 1440, "height": 900}) + page.wait_for_timeout(150) # the reflow settles before the measure + expect(page.locator("#settings-content")).to_be_visible() + + # The fixed probe row (the first type="text" control) renders — + # the desktop layout is the pre-phase natural layout. + first = page.locator("tr.settings-row input[type='text']").first.bounding_box() + assert first is not None, "the first text control must render at 1440px" + assert first["width"] > 0 and first["height"] > 0, ( + "the first text control must be non-degenerate at 1440px" + ) + + # The contract (the media query is INERT — owner-locked A1): the + # computed min-width of .settings-value-cell (and its header + # twin .settings-value-th) at 1440px is the UA 0px/auto — + # never 208px/208.0px (the 13rem floor binds only inside the + # ≤640px block; the 720px floor with it). + for selector in (".settings-value-cell", ".settings-value-th"): + min_width = page.evaluate( + f"() => getComputedStyle(" + f"document.querySelector('{selector}')).minWidth" + ) + normalized = min_width.strip().lower() + assert normalized in ("0px", "0.0px", "auto"), ( + f"the 13rem floor must be inert at 1440px: {selector} computed " + f"min-width must be `0px`/`auto` (got {min_width!r})" + ) + assert "208" not in min_width, ( + f"the 13rem floor (208px) must not apply at 1440px " + f"({selector} min-width {min_width!r})" + ) + + # And no forced horizontal scroll at desktop: the table's + # scrollWidth stays inside the card's clientWidth (the 720px + # floor is inert — the 560px base rule is subsumed by the + # card's width). + dims = page.evaluate( + """() => { + const card = document.querySelector(".settings-shell .table-wrap"); + const table = document.querySelector( + ".settings-shell .table-wrap .settings-table"); + return { table: table.scrollWidth, card: card.clientWidth }; + }""" + ) + assert dims["table"] <= dims["card"], ( + f"the table must not force horizontal scroll at 1440px " + f"(table {dims['table']}px vs card {dims['card']}px)" + ) + + +# --------------------------------------------------------------------------- +# 4. PLAN §7.1 + rule 6: the keyboard walk under the new column +# floor at 375px, and ZERO external requests +# --------------------------------------------------------------------------- + + +def test_keyboard_order_and_no_external_requests( + page: Page, app_url: str, db_ready: None +) -> None: + page.set_default_timeout(30_000) + externals: list[str] = [] + + def _watch(request: object) -> None: + url = getattr(request, "url", "") + if url.startswith(("http://", "https://")) and "127.0.0.1" not in url: + externals.append(url) + + page.on("request", _watch) + _login_admin(page, app_url) + + page.set_viewport_size({"width": 375, "height": 900}) + page.wait_for_timeout(150) # the reflow settles before the measure + expect(page.locator("#settings-content")).to_be_visible() + + # The first row's Value control — the walk's middle stop. + first_control_id = page.evaluate( + "() => document.querySelector('tr.settings-row .settings-control').id" + ) + assert first_control_id.startswith("set-"), ( + f"the first row's Value control must be a settings control " + f"(got {first_control_id!r})" + ) + + # A dirty row (typed, never saved — the suite leaves no override + # and issues no save): the walk's Save must be ENABLED (a + # disabled button is not tabbable). + page.fill(f"#{first_control_id}", DIRTY_VALUE) + expect(page.locator("#settings-dirty")).to_have_text("1 unsaved changes") + expect(page.locator("#settings-save")).to_be_enabled() + + # The keyboard tab order still reaches filter → first row's Value + # control → Save settings → Clear all overrides (the + # phase-168/171 contract under the new column floor, at 375px). + # The walk starts at the skip-link — the document's FIRST + # tabbable element (a blur() would only continue the cycle after + # the last focused element, so the script focuses the cycle + # start; every move after it is a real Tab keypress). + page.evaluate("() => document.querySelector('a.skip-link').focus()") + seen: list[str] = [] + for _ in range(200): + page.keyboard.press("Tab") + seen.append( + page.evaluate( + """() => { + const el = document.activeElement; + if (!el) return ""; + return el.id || (typeof el.className === "string" ? el.className : ""); + }""" + ) + ) + i_filter = next( + (i for i, s in enumerate(seen) if s == "settings-filter"), None + ) + i_first_input = next( + (i for i, s in enumerate(seen) if s == first_control_id), None + ) + i_save = next( + (i for i, s in enumerate(seen) if s == "settings-save"), None + ) + i_clear = next( + (i for i, s in enumerate(seen) if s == "settings-clear-all"), None + ) + assert i_filter is not None, ( + f"the walk never reached the filter (seen={seen[:20]})" + ) + assert i_first_input is not None, ( + f"the walk never reached the first row's Value control " + f"({first_control_id}; seen={seen[:20]})" + ) + assert i_save is not None, "the walk never reached Save (the dirty row enables it)" + assert i_clear is not None, "the walk never reached Clear all" + assert i_filter < i_first_input, "the filter must precede the first input" + assert i_first_input < i_save, "the first input must precede Save" + assert i_save < i_clear, "Save must precede Clear all" + + # The suite's only write is NONE: no save was issued (the dirty + # row is form-state only — the per-test TRUNCATE + the module + # teardown pin app_settings empty). + + # Rule 6: the whole test issued ZERO external requests (the CSP + # default-src 'self' posture — every asset is local). + assert externals == [], f"external requests leaked: {externals}" diff --git a/tests/unit/test_settings_frontend_view.py b/tests/unit/test_settings_frontend_view.py index e3ffd23..9f0457c 100644 --- a/tests/unit/test_settings_frontend_view.py +++ b/tests/unit/test_settings_frontend_view.py @@ -224,11 +224,15 @@ def test_settings_content_scaffold_and_feedback_regions() -> None: assert '' in thead, ( "the caption is visually-hidden (the phase-86 table pattern)" ) - assert thead.count('') == 4, ( + assert thead.count('Setting' in thead - assert 'Value' in thead + assert 'Value' in thead, ( + "phase 176: the Value th carries the .settings-value-th class " + "(the mobile 13rem column floor constrains the header cell " + "under auto table layout, not just the td's)" + ) assert 'Source' in thead assert ( 'Actions' @@ -764,6 +768,121 @@ def test_settings_save_disabled_is_just_disabled_no_wait_cursor() -> None: ) +# ---------- phase 176 (task 01): the mobile Value-column floor ---------- + + +def test_settings_mobile_value_column_floor() -> None: + """Phase 176 (task 01; TODO.md L3, owner-locked A1/A2): on a + phone (≤640px) the Settings table's Value inputs rendered 2–3 + characters wide — the Setting cells' nowrap labels (the + ``.settings-table td`` rule) exhausted the table's 560px floor + and collapsed the ``min-width: 0`` Value cells. The owner's + direction: "the table is horizontally scrollable, so we can have + slightly wider input fields." Pinned here (static, no browser): + + * the Value column — ``.settings-value-cell`` (the td) and + ``.settings-value-th`` (the th; task 01 added the class so the + column floor constrains the header cell too, under auto table + layout) — gets a ``min-width: calc(13rem + 2rem)`` floor: the + global border-box model (``* { box-sizing: border-box }``) + makes a cell's min-width a BORDER-box, so the cell is floored + at the locked 13rem of CONTENT (≈208px ≈ 14 mono chars at the + controls' 0.85rem) plus the td's own 1rem × 2 padding — the + ``.settings-control`` (``width: 100%`` of the cell's content) + thus lands at the locked 13rem (the A2 intent, "slightly + wider input fields", and the A3 pin's 12rem / 192px threshold + with a 16px margin); a bare ``13rem`` border-box would only + leave 176px of content (the control), under the A3 pin, and + * the table's mobile scroll floor is raised to ``720px`` while + the base rule keeps ``560px`` (≥641px — the desktop layout + stays byte-identical, A1/A2). + + Both rules live ONLY inside the existing ``@media (max-width: + 640px)`` block (A1 — mobile-only scope): the selectors occur + exactly once in the whole file, and that one occurrence sits + inside the media block (the slice is by open/close markers — + the inner rules are 2-space-indented, so the first column-zero + ``}`` after the open is the block's close; no hardcoded line + range, so the pin survives an insertion above it). The 720px + floor + the ``.table-wrap`` card's ``overflow: auto`` mean the + phone scrolls the CARD, never the page (the phase-171 "no + horizontal page overflow" E2E pin holds). 720px is a bare + length literal and the cell floor is the locked 13rem of content + + the cell's 2rem of padding — NO new CSS custom property (B3 + binds palette variables; a length in the mobile block is the + idiom the neighbouring ``width: 100%`` rules already use), so + ``test_styles_css_settings_block_uses_existing_tokens_only`` + stays green unchanged.""" + css = _read(ASSETS / "styles.css") + # --- the ≤640px block, sliced by its open/close markers. + open_marker = "@media (max-width: 640px) {" + mobile_open = css.index(open_marker) + mobile_close = css.index("\n}", mobile_open + len(open_marker)) + mobile = css[mobile_open:mobile_close + 2] + # --- the Value rule: exactly ONCE in the whole file, and that + # one occurrence sits INSIDE the ≤640px block (A1: no desktop + # copy, no stray rule elsewhere). + value_selector = ".settings-value-cell, .settings-value-th" + assert css.count(value_selector) == 1, ( + "the Value rule must occur exactly once in styles.css" + ) + assert mobile.count(value_selector) == 1, ( + "the Value rule must sit inside the ≤640px block (A1: " + "mobile-only — at ≥641px the rule is inert)" + ) + value_rule = re.search( + r"\.settings-value-cell, \.settings-value-th \{([^{}]*)\}", mobile + ) + assert value_rule, "the Value rule must declare the column floor" + assert "min-width: calc(13rem + 2rem)" in value_rule.group(1), ( + "the Value column (th AND td) is floored at 13rem of content " + "(≈208px ≈ 14 mono chars — A2: 'slightly wider input fields') " + "+ the cell's 2rem of padding (the global border-box model — " + "a bare 13rem border-box would leave the control at 176px, " + "under the A3 192px pin)" + ) + # --- the table floor: BOTH rules pinned — the base keeps 560px + # for ≥641px (and must not know 720px) while the mobile block + # raises it to 720px (the phone scrolls the card — the + # .table-wrap overflow: auto contract, never the page). + base = re.search(r"\.settings-table \{([^{}]*)\}", css) + assert base, "styles.css must style .settings-table (the base rule)" + assert "min-width: 560px" in base.group(1), ( + "the base .settings-table floor stays 560px (≥641px — " + "unchanged, A1)" + ) + assert "720px" not in base.group(1), ( + "the base .settings-table rule must not know 720px (the " + "720px floor belongs to the mobile block only, A1)" + ) + mtable = re.search(r"\.settings-table \{([^{}]*)\}", mobile) + assert mtable, ( + "the ≤640px block must carry the .settings-table floor rule" + ) + assert "min-width: 720px" in mtable.group(1), ( + "the mobile table floor is 720px (A2: the phone scrolls " + "the card horizontally)" + ) + # The 720px declaration exists exactly once in the whole file — + # and it is the mobile block's (no ≥641px copy anywhere). + assert css.count("min-width: 720px") == 1 and ( + "min-width: 720px" in mobile + ), "the 720px floor must exist exactly once, in the ≤640px block" + # --- the index.html static thead: the Value th carries the + # .settings-value-th class so the column floor constrains the + # header cell too (deterministic under auto table layout — + # a plain would otherwise stay unconstrained). + html = _html() + table_start = html.find('class="settings-table"') + assert table_start != -1, "the settings table must exist" + thead = html[table_start: html.find("", table_start)] + assert 'Value' in thead, ( + "the settings thead's Value th carries the settings-value-th " + "class (task 01: the column floor is deterministic under " + "auto table layout)" + ) + + # ---------- the four Theme-bearing pages carry the Settings link ----------