Compare commits
3
Commits
375169bbfc
...
dd119d534a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd119d534a | ||
|
|
2a5df7cb31 | ||
|
|
bd271a6f52 |
@@ -0,0 +1,51 @@
|
||||
# Phase 176 — Admin settings page: a usable Value-column width on mobile
|
||||
|
||||
**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 TODO's `image-1.png` screenshot: at a 375 px phone viewport the Value-column inputs render only 2–3 characters wide — "Br", "0.", "pr", "Wr", "Pc", "IN" — unreadable and unusable)
|
||||
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-12)
|
||||
**Context:** `frontend/index.html` — `#view-settings` (L1519), the `.table-wrap` card (L1596) holding `table.settings-table` with the static `thead` (L1599–1606: the Setting / Value / Source / Actions `th`s — the Value `th` at L1602 carries no class), `frontend/assets/settings.js` (the row render — `makeRow` builds `td.settings-name-cell` + `td.settings-value-cell` (the `.settings-control` control) + the Source badge cell + the Reset cell; the controls are type-correct per item and carry `width: 100%; min-width: 0`), `frontend/assets/styles.css` — `.settings-table` (L4742–4746: `width: 100%; min-width: 560px;` — the ONLY width floor, no per-column widths — the root cause: at 375 px the table renders at its 560 px floor and the Setting cell's long `white-space: nowrap` labels (L4754: `td { white-space: nowrap }`) devour it, collapsing the `min-width: 0` Value cell), the `.table-wrap` bounded-scroll card (L2836–2842: `overflow: auto; max-height: min(70vh, 64rem)` — horizontal scroll is the established contract; the ≤640px block's own comment: "the table's own horizontal scroll covers the columns"), the ≤640px mobile block (L6180–6529) with the existing phase-168 settings squeeze rules (L6484–6488), `tests/unit/test_settings_frontend_view.py` (the phase-168/171 static pin set, incl. `test_styles_css_settings_block_uses_existing_tokens_only` L516), `tests/e2e/test_admin_settings_layout.py` (phase 171 — the "no horizontal page overflow at 375px / 1440px" pin L482–486) and `tests/e2e/test_admin_settings_panel.py` (phase 168 — the regression set), PLAN.md §7.1 (≥44px touch targets on mobile; lists/tables ≥80–90% of container; the table scrolls INSIDE its card).
|
||||
|
||||
## Objective
|
||||
On a phone, the Settings table's Value inputs are impossibly small (2–3 chars). The owner's direction: the table is already horizontally scrollable, so the inputs should be **slightly wider** — i.e. give the Value column a usable minimum width (and raise the table's scroll floor to match) so every `.settings-control` is readable and editable on a 375 px phone, absorbed by the card's existing horizontal scroll. The fix is **CSS-only, mobile-scoped** (the ≤640px block) — the desktop layout stays byte-identical, no `app/` code, no JS logic change.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **D1 — the Value-column floor (task 01, owner-locked A2).** Inside the existing `@media (max-width: 640px)` block (L6180, appended beside the phase-168 settings squeeze rules L6484–6488), two rules:
|
||||
- `.settings-table { min-width: 720px; }` — the mobile scroll floor (was 560 px, which the nowrap Setting labels exhaust). At a 375 px viewport the table renders at 720 px and scrolls horizontally INSIDE the `.table-wrap` card (overflow: auto) — the page itself never overflows (the phase-171 pin holds: the scroll is the card's, not the page's).
|
||||
- `.settings-value-cell, .settings-value-th { min-width: 13rem; }` — the Value column (and its control, `width: 100%`) gets a 13 rem (≈208 px ≈ 14 mono chars at the control's 0.85 rem) floor at the phone. `13rem` is a plain length value — NO new CSS custom property (the house token rule B3 is about palette variables; a bare length literal in the mobile block is the same idiom as the existing `#settings-save { width: 100% }` rules there).
|
||||
- To make the column floor deterministic (auto table layout can otherwise leave the `th` unconstrained), the Value `th` in `frontend/index.html` L1602 gains the class: `<th scope="col" class="settings-value-th">Value</th>` — static HTML (the no-HTML-string house rule binds the JS, not the templates; `settings.js` is untouched).
|
||||
- **D2 — mobile-only scope (owner-locked A1).** Every new rule lives INSIDE the `@media (max-width: 640px)` block only. At ≥641 px (the 1440 px E2E desktop pin, and the 72 rem container ≈ 1152 px) the media query is inert: the table renders at card width as today, the Value column takes its natural auto share, and the desktop bytes of `styles.css`/`index.html` behavior are unchanged.
|
||||
- **D3 — no other change (task 01).** `settings.js` (render / filter / save / reset / clear-all), the `.table-wrap` bounded-scroll + sticky-th contract (L2836–2848), the phase-171 `#settings-content` rhythm, the group bands, the Source badges, the mobile action-row squeeze rules (L6484–6488), `app/` server code — all byte-identical. Nothing animates (nothing for `prefers-reduced-motion` to still).
|
||||
- **NOT touched:** `app/`, `settings.js`, the ≥641 px rules in `styles.css`, the other catalogue tables.
|
||||
|
||||
## Dependencies
|
||||
- `171_admin_settings_layout` (complete) — the Settings page rhythm/band/cursor layout this phase extends, and the ≤640px mobile block's existing settings squeeze rules (L6484–6488) the new rules are appended beside.
|
||||
- `168_admin_settings_panel` (complete) — the panel contract (the gate, the catalogue, the `.table-wrap` scroll card, the §7.4 Save lifecycle) that must stay behavior-identical.
|
||||
|
||||
## Tasks
|
||||
1. `01_mobile_value_column_width.md` — the Value-column `min-width: 13rem` + the table `min-width: 720px` floor, both inside the ≤640px block; the `settings-value-th` class on the Value `th`.
|
||||
2. `02_unit_pins.md` — `tests/unit/test_settings_frontend_view.py`: the static pins for the two mobile rules (present ONLY inside the ≤640px block) + the `settings-value-th` class pin on the static thead; the existing token-only pin stays green.
|
||||
3. `03_e2e_mobile_value_width.md` — `tests/e2e/test_settings_mobile_value_width.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_settings_frontend_view.py` (the phase-168/171 static pin set, extended in task 02): the two new rules parse out of the ≤640px block with the exact values, the `settings-value-th` class is present on the settings `thead`'s Value `th`, and `test_styles_css_settings_block_uses_existing_tokens_only` stays green (no new palette variable — a bare length is not a token).
|
||||
- **E2E (AGENTS rule 4, task 03):** `tests/e2e/test_settings_mobile_value_width.py`, run **in isolation** — the width contract, proven in a browser at 375 px and 1440 px.
|
||||
- **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`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] At a 375 px viewport, every Settings `.settings-control` (the Value inputs) is ≥ 12 rem (≈192 px, the A3 pin threshold) wide — readable and editable (the E2E width pin).
|
||||
- [ ] At 375 px the `.table-wrap` card scrolls horizontally (`scrollWidth > clientWidth`) while the PAGE does not overflow horizontally (the phase-171 pin at 375 px and 1440 px stays green).
|
||||
- [ ] At a 1440 px viewport the Value input width is unchanged vs. pre-phase (the mobile rules are inert — no desktop regression; the phase-171 E2E suite stays green in isolation).
|
||||
- [ ] The keyboard tab order still reaches filter → first row input → **Save settings** → **Clear all overrides** at 375 px (the phase-168/171 contract under the new column floor); the whole test issues ZERO external requests (rule 6).
|
||||
- [ ] `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_settings_mobile_value_width.py -v --no-cov` green **in isolation**; `test_admin_settings_layout.py` (phase 171) + `test_admin_settings_panel.py` (phase 168) green in isolation.
|
||||
- [ ] One atomic `--no-gpg-sign` Conventional Commit; the phase dir moves to `.agents/phases/complete/`.
|
||||
|
||||
## Locked decisions (owner-confirmed 2026-10-12, roadmap confirmation)
|
||||
- **A1 — mobile-only scope.** The new width rules live ONLY in the existing `@media (max-width: 640px)` block; the desktop (≥641 px) layout stays byte-identical — the TODO is mobile-scoped.
|
||||
- **A2 — the width values.** Table floor **720 px** (was 560 px — the phone scrolls the card horizontally, the established contract); Value column **min-width 13rem** → the input ≈ 208 px ≈ 14 mono characters, i.e. "slightly wider input fields". No new CSS custom property, no JS change.
|
||||
- **A3 — the E2E pin threshold is 12 rem** (192 px) — a deliberate margin below the 13 rem (208 px) rule so the pin doesn't become brittle to sub-pixel rounding.
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(settings): mobile settings table — a usable Value-column width (the card's horizontal scroll absorbs it)"
|
||||
```
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Task 01 — the mobile Value column gets a usable width (the card's horizontal scroll absorbs it)
|
||||
|
||||
**Phase:** `176_mobile_settings_value_width` · **Source:** `TODO.md:3` — "On mobile, the settnigs values are impossibly small. This is a horizontally scrollable table so we can have slightly wider input fields."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
At a 375 px phone viewport the Settings table's Value inputs render 2–3 characters wide (the Setting cell's long `white-space: nowrap` labels exhaust the table's 560 px floor). Give the Value column a **13 rem floor** and raise the table's mobile scroll floor to **720 px** — both inside the existing `@media (max-width: 640px)` block, so the phone scrolls the card horizontally (the established contract) while every `.settings-control` is readable and editable. CSS only, plus the one `th` class that makes the column floor deterministic; the desktop (≥641 px) stays byte-identical (owner-locked A1).
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` L1602 — the static `thead`'s Value cell: `<th scope="col">Value</th>` → `<th scope="col" class="settings-value-th">Value</th>`. Static HTML only (the no-HTML-string house rule binds the JS — `settings.js` stays byte-unchanged); the class exists so the column floor constrains the header cell, not just the `td`s, under auto table layout.
|
||||
2. `frontend/assets/styles.css` — inside the existing `@media (max-width: 640px)` block (opens L6180), appended directly after the phase-168 settings squeeze rules (L6484–6488: `.settings-search` / `.settings-actions` stack + `#settings-save` / `#settings-clear-all` full width + `.settings-actions-cell` wrap), add ONE comment + two rules (the phase-176 header comment, stating the TODO L3 contract: the Value column's 13 rem floor makes the input ≈208 px ≈ 14 mono chars at the phone; the 720 px table floor + the card's `overflow: auto` (L2836) mean the phone scrolls the CARD, never the page):
|
||||
- `.settings-table { min-width: 720px; }`
|
||||
- `.settings-value-cell, .settings-value-th { min-width: 13rem; }`
|
||||
No other rule moves. `13rem` / `720px` are bare length literals — 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).
|
||||
3. `frontend/assets/styles.css` L4742–4746 — update the `.settings-table` comment to note the mobile block (≤640px) raises the floor to 720 px for the Value column (one line; the rule itself stays here at 560 px for ≥641 px viewports).
|
||||
|
||||
- ASSUMPTION: A1 (mobile-only scope — the rules live ONLY in the ≤640px block; desktop byte-identical), A2 (the width values — 720 px table floor, 13rem Value floor) — both owner-locked 2026-10-12 in the phase-176 roadmap confirmation. No runtime guesses.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_settings_frontend_view.py` — the existing pins (incl. `test_styles_css_settings_block_uses_existing_tokens_only` L516) must stay green; the task-02 pins cover the new rules.
|
||||
- Coverage: **>90%** on `app/` (unchanged code — the gate must stay green, not drop).
|
||||
- Lint/types: `uv run ruff check . && uv run pyright`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] In the ≤640px block of `styles.css`, the two new rules are present with the exact values (720 px / 13 rem) and NO `settings-value` rule appears outside that block (grep: the selectors occur only once, inside `@media (max-width: 640px) … }`).
|
||||
- [ ] `frontend/index.html` L1602 carries `class="settings-value-th"`; `settings.js` is byte-unchanged (`git diff --stat` shows only `frontend/index.html` + `frontend/assets/styles.css` + the test pin).
|
||||
- [ ] `uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov` green; ruff + pyright clean.
|
||||
- [ ] No `app/` change; the ≥641 px rules are byte-unchanged.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Task 02 — unit pins: the two mobile width rules + the `settings-value-th` class
|
||||
|
||||
**Phase:** `176_mobile_settings_value_width` · **Source:** `TODO.md:3` — "On mobile, the settnigs values are impossibly small. This is a horizontally scrollable table so we can have slightly wider input fields."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
Pin task 01's CSS in `tests/unit/test_settings_frontend_view.py` the house way (the phase-168/171 static-pin idiom — `re.search` the rule body, assert the declarations): the two new rules exist with their exact values **inside the ≤640px block only**, and the `settings-value-th` class is on the settings `thead`'s Value `th`. The existing pins — incl. `test_styles_css_settings_block_uses_existing_tokens_only` (L516) — stay green: the new rules add a bare length, no `:root` custom property (B3 holds).
|
||||
|
||||
## Work
|
||||
1. `tests/unit/test_settings_frontend_view.py` — a new section `# ---------- phase 176 (task 01): the mobile Value-column floor ----------` after the phase-171 section (after `test_settings_save_disabled_is_just_disabled_no_wait_cursor`, L714), with ONE test function `test_settings_mobile_value_column_floor` (the house one-test-per-contract idiom; the function docstring cites `TODO.md` L3 + owner-locked A1/A2, the way the phase-171 docstrings cite their TODO line):
|
||||
- Extract the ≤640px block: `mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n\n/\* Phase 46", css)` is too brittle — instead pin the rules by SELECTOR SCOPING: assert `css.count(".settings-value-cell, .settings-value-th")` is **exactly 1** and that the occurrence sits INSIDE the ≤640px block (locate the `@media (max-width: 640px)` opening at L6180 and the block's closing `}` at the end of the media region — the test may slice `css[mobile_open:media_close]` with the open/close markers; the phase-171 pin `test_settings_content_is_a_flex_column_with_the_shell_gap` (L589) already sets the rule-extraction idiom to follow).
|
||||
- In the mobile block: the `.settings-table` rule declares `min-width: 720px` (the mobile floor — the base L4742 rule keeps `min-width: 560px` for ≥641 px; pin BOTH: the base `.settings-table` rule body contains `min-width: 560px` and NO `720px`, the mobile-block `.settings-table` rule body contains `min-width: 720px`).
|
||||
- In the mobile block: the Value rule declares `min-width: 13rem` on both `.settings-value-cell` and `.settings-value-th`.
|
||||
- `index.html` (via the file read the settings-view pins already use, `_read(FRONTEND / "index.html")`): the settings `thead` (the `table.settings-table` region — the `#settings-tbody` anchor) contains `<th scope="col" class="settings-value-th">Value</th>`.
|
||||
2. `tests/unit/test_settings_frontend_view.py` — verify `test_styles_css_settings_block_uses_existing_tokens_only` (L516) still passes UNCHANGED (the new rules are inside the media block, not the settings block's `:root` scan scope; if its no-new-custom-property scan is a global grep for `--settings-` or similar, confirm it does not match `13rem`/`720px` — it cannot, they are bare lengths; run it and record the pass).
|
||||
|
||||
- ASSUMPTION: the media-block extraction uses open/closing markers (not a hardcoded line range) so the pin survives the two-line insertion above it — the phase-171 docstring's "the 640px mobile stacking composes with it" contract is the same robustness bar. (Not an owner decision: test-internal mechanics.)
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_settings_frontend_view.py` — the new pin + all existing settings pins green.
|
||||
- Coverage: **>90%** on `app/` (unchanged code).
|
||||
- Lint/types: `uv run ruff check . && uv run pyright`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov` green, incl. the new `test_settings_mobile_value_column_floor` and the untouched `test_styles_css_settings_block_uses_existing_tokens_only`.
|
||||
- [ ] The pin fails if either mobile rule is removed or moved out of the ≤640px block (mutation check: temporarily drop the rule → red → restore).
|
||||
- [ ] `frontend/` and `app/` are byte-unchanged by this task (the diff is `tests/unit/test_settings_frontend_view.py` only).
|
||||
- [ ] ruff + pyright clean.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Task 03 — E2E: the mobile Value-column width contract + regression + gates + commit
|
||||
|
||||
**Phase:** `176_mobile_settings_value_width` · **Source:** `TODO.md:3` — "On mobile, the settnigs values are impossibly small. This is a horizontally scrollable table so we can have slightly wider input fields."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
Prove the width contract in a real browser (AGENTS rule 4 — the phase's dedicated Playwright suite, run **in isolation**), prove the desktop is unchanged, run the regression set + full gates, and commit the phase atomically.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_settings_mobile_value_width.py` — the phase suite, structured on `tests/e2e/test_admin_settings_layout.py` (phase 171) VERBATIM where the contract allows (module docstring citing `TODO.md` L3 + the test→contract mapping; the `_boot_app` / `app_server` fixture with `env["BOR_SETTINGS_DB"] = "1"`; the per-test `_clean_settings_state` truncation of `app_settings` / `ui_settings` + the `e2e-`-labeled token cleanup — never a TRUNCATE on `api_tokens`; the module app env block; `login` from `e2e.auth_helpers`; `expect` from `playwright.sync_api`). Tests (each at the stated viewport via `page.set_viewport_size({"width": …, "height": 900})`):
|
||||
- **`test_mobile_value_inputs_are_usable_width`** — at **375 px**: the admin arrives at the settings view (the phase-168 gate flow); for a representative spread of rows (at least one `type="search"`/text input, one `select`, one textarea — the catalogue is 75 rows; pick three distinct control types), the `.settings-control` bounding-box width is **≥ 192 px** (12 rem — owner-locked A3, the deliberate margin under the 13 rem rule) AND ≥ 200 px wide is NOT asserted (the 12 rem pin is the contract; 13 rem ≈ 208 px is the rule). Also the `.settings-table`'s own width (`scrollWidth`) **exceeds** the `.table-wrap` card's `clientWidth` (the horizontal scroll is active on the phone — the TODO's own premise) while `document.documentElement.scrollWidth ≤` the 375 px viewport (the PAGE does not overflow — the phase-171 pin, the scroll is the card's).
|
||||
- **`test_mobile_table_still_scrolls_and_page_does_not_overflow`** — at **375 px**: `.table-wrap` computed `overflow` is `auto`; the card's `scrollWidth > clientWidth`; the page-level `documentElement.scrollWidth` ≤ 375 px + 1 px rounding; the sticky `thead` contract survives (the first row's group band/`thead` is reachable — scroll the card and the `thead` `th`s stay pinned, computed `position: sticky`).
|
||||
- **`test_desktop_value_width_is_unchanged`** — at **1440 px**: a fixed probe row (the same first `type="search"` control) has a width STRICTLY GREATER than at 375 px is the wrong shape — the contract is the media query is INERT: the 1440 px `.settings-control` width equals the pre-phase natural layout; pinned as: the mobile rules do not apply (computed `min-width` of `.settings-value-cell` at 1440 px is the UA `0px`/auto — i.e. `getComputedStyle` returns `0px` or `auto`, never `208px`/`208.0px`) and the `.settings-table` `scrollWidth` ≤ the card's `clientWidth` (no forced scroll at desktop — the 720 px floor binds only inside the media query).
|
||||
- **`test_keyboard_order_and_no_external_requests`** — at **375 px**: the tab order reaches `#settings-filter` → the first row's Value control → `#settings-save` → `#settings-clear-all` (the phase-168/171 contract under the new column floor); the whole test's `page.on("request")` log contains ZERO external (non-127.0.0.1) requests (rule 6).
|
||||
- **The DB-isolation docstring + module teardown** (the phase-171 idiom): the suite's only write is none (no save is issued — the width contract needs no dirty row); `app_settings` pinned empty at teardown.
|
||||
2. Run the **regression set** in isolation (each its own run, per the phase-171 completion criteria):
|
||||
- `uv run pytest tests/e2e/test_settings_mobile_value_width.py -v --no-cov` (THIS phase — must be green in isolation),
|
||||
- `uv run pytest tests/e2e/test_admin_settings_layout.py -v --no-cov` (phase 171 — the rhythm/band/cursor + the 375/1440 no-page-overflow pins),
|
||||
- `uv run pytest tests/e2e/test_admin_settings_panel.py -v --no-cov` (phase 168 — the panel contract).
|
||||
3. Run the **full gates**: `uv run pytest` (green), `uv run pytest --cov=app --cov-report=term-missing` (**>90%** on `app/` — this phase changes no `app/` code: the gate stays green, not dropped), `uv run ruff check . && uv run pyright` (clean).
|
||||
4. **Commit** the phase atomically (AGENTS rule 8):
|
||||
```bash
|
||||
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(settings): mobile settings table — a usable Value-column width (the card's horizontal scroll absorbs it)"
|
||||
```
|
||||
then move the phase dir: `mv .agents/phases/todo/176_mobile_settings_value_width .agents/phases/complete/176_mobile_settings_value_width` (the dir move is part of the same phase completion — commit it with `git add .agents/ && git commit --no-gpg-sign -m "chore(agent): complete phase 176 mobile settings value width"` ONLY if the repo's convention is a separate commit; the phase-171 precedent shows a single commit — follow it: the dir move is folded into the atomic commit by `git add .agents/phases/` AFTER the `mv`).
|
||||
|
||||
- ASSUMPTION: A3 (the 12 rem / 192 px E2E pin threshold, the deliberate margin under the 13 rem rule) — owner-locked 2026-10-12. The probe-row choice (one text input, one select, one textarea) is executor mechanics within that lock: the three control types must exist among the 75 rows (the phase-168 catalogue guarantees text / number / select / textarea / password controls).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (AGENTS rule 4): `tests/e2e/test_settings_mobile_value_width.py` green **in isolation**; phase 171 + phase 168 suites green in isolation (regression).
|
||||
- Coverage: **>90%** on `app/` (unchanged code).
|
||||
- Lint/types: `uv run ruff check . && uv run pyright`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_settings_mobile_value_width.py -v --no-cov` green in isolation (the 375 px input ≥ 192 px, the card scrolls, the page does not; 1440 px inert; keyboard + zero external requests).
|
||||
- [ ] `uv run pytest tests/e2e/test_admin_settings_layout.py -v --no-cov` and `uv run pytest tests/e2e/test_admin_settings_panel.py -v --no-cov` green in isolation.
|
||||
- [ ] `uv run pytest` green; `app/` coverage >90%; ruff + pyright clean.
|
||||
- [ ] One atomic `--no-gpg-sign` Conventional Commit (the phase-171 single-commit precedent, dir move folded in); the phase dir lives in `.agents/phases/complete/176_mobile_settings_value_width/`.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Phase 177 — The favicon's colors follow the effective theme palette
|
||||
|
||||
**Source:** `TODO.md` L3 — "We need the favicon to change colors to match the theme."
|
||||
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-13)
|
||||
**Context:** `frontend/assets/favicon.svg` (the current static art — a hexagon + center dot + 4 spokes whose three hardcoded colors map 1:1 onto the built-in palette: `#1a0f0f` = `surface`, `#f43f5e` = `brand`, `#fca5a5` = `brand_ink`), the five templates' L8 `<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">` (`frontend/index.html`, `frontend/document.html`, `frontend/login.html`, `frontend/shared.html`, `frontend/doc-edit.html`), `app/core/theming.py` (`BUILTIN_COLORS`, `COLOR_FIELDS`, `effective_settings()` — the DB-over-env/built-in resolver with NO process cache, `theme_style_tag()` + `inject_theme()` — the pre-paint tag pattern, `theme_csp_hash()`), `app/core/caching.py` (`CachingMiddleware`'s per-response short-lived-session theme read with the never-break DB-blip fallback; `/favicon.svg` is neither in `HTML_PAGES` nor under `/assets/` → passes through untouched), `app/main.py` `create_app()` (router includes end ~L199; the static catch-all mount at L252 — routes-first), `app/core/security_headers.py` (CSP `default-src 'self'; … img-src 'self' data:` — the phase-123 carve-out that makes a `data:` favicon URI legal; CSP3 governs `link[rel=icon]` by `img-src`), `frontend/assets/theme.js` (the admin Theme tab — `previewColor()` live preview, the `input` bindings, `saveTheme()`/`resetTheme()` + the `bor:view-refresh` re-show handler), `tests/unit/test_theming.py` (the static pin set incl. `test_builtin_colors_match_styles_css_root`), `tests/unit/test_theme_frontend.py` (the JS static-pin pattern), `tests/e2e/test_theme_save_and_coverage.py` + `test_theme_semantic_completion.py` (the admin theme-tab E2E pattern: `e2e/auth_helpers.login(page, app_url, next="/theme.html")`, the `#theme-*` inputs), AGENTS rules 4/6/8/9/10, PLAN §7.4 (the never-stale feedback contract).
|
||||
|
||||
## Objective
|
||||
The browser-tab favicon must carry the EFFECTIVE theme palette (built-in or the owner's saved one), painted on first load like everything else — no red hexagon on a graphite theme. The favicon is served by the app from the same resolver that paints the page (`effective_settings`), and the admin Theme tab's live (unsaved) picks repaint it too (owner-confirmed A4).
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **D1 — the pure renderer (task 01, A1).** `theming.favicon_svg(colors: dict[str, str]) -> str` — the SAME artwork (viewBox `0 0 64 64`, the hexagon path, the r=6.5 center circle, the 4 spokes, all stroke widths/caps unchanged) with only the three color roles swapped: hexagon `fill` → `surface`, hexagon `stroke` + circle `fill` → `brand`, spokes `stroke` → `brand_ink`. The input is the full 20-value effective dict (mirroring `theme_style_tag`'s self-contained signature; the function reads its three keys). **Byte-identical contract:** for the built-in palette the output is EXACTLY the current static file's `<svg>` element (lowercase hex, same attribute order) — pinned by a golden constant in `tests/unit/test_theming.py`; the static file's comment header is file packaging, not art, and is not part of the contract.
|
||||
- **D2 — serving (task 02, A2/A3).** `GET /favicon.svg` (+ HEAD for static-file parity) — a no-prefix route in `create_app()` after the router includes and BEFORE the static catch-all mount (routes-first, the phase-51 shared-page pattern). Sync handler: one short-lived `SessionLocal()` session (the `caching.py` house pattern — NO process cache, so a runtime theme change is visible on the next request), `effective_settings()` → `favicon_svg()`; ANY exception → `logger.exception` + `favicon_svg(BUILTIN_COLORS)` (200, never 500 — the theme-tag's never-break contract, `caching.py`'s DB-blip pattern). Response: `media_type="image/svg+xml"`, `Cache-Control: no-cache` (the pages are no-cache; the palette changes at runtime, so the browser must revalidate — the route is NOT under `/assets/`, so no version token; freshness is revalidation, not busting). Deliberately OUTSIDE `/api/*`: no per-response byte-budget line (AGENTS rule 10 is `/api/*`-only), no auth dependency (the favicon is chrome, like the login page). `CachingMiddleware` passes it through untouched (neither an HTML page nor an asset); `SecurityHeadersMiddleware` still adds the CSP — no change needed (`img-src 'self' data:` covers the D4 preview).
|
||||
- **D3 — the templates + the static file's deletion (task 03, A2).** All five L8 links → `href="/favicon.svg"`; `frontend/assets/favicon.svg` is **deleted** — the route is the single source (the built-in bytes stay pinned by D1's golden constant, so nothing is lost). The new href is not under `/assets/`, so `CachingMiddleware`'s `?v=` rewrite regex can never touch it (a pin keeps this explicit — the link stays bare).
|
||||
- **D4 — the Theme tab live preview (task 04, A4 — owner-confirmed 2026-10-13).** `theme.js` gains a pure `themeFaviconDataUri(surface, brand, brandInk)` — the CLIENT-SIDE MIRROR of `theming.favicon_svg` (same artwork, same roles; a unit pin keeps the two from diverging — the `PAIRS`-mirror house pattern). The existing per-color `input` binding ALSO updates `link[rel="icon"].href` to the `data:` URI whenever one of the three driving fields (`surface`/`brand`/`brand_ink`) changes (rebuilt from the three inputs' current values; the other 14 picks never touch it). **Restore to `"/favicon.svg"`** on: Save success (after `applyServedTheme`), Reset success, the Reset-failure `clearPreview()` path, and the `bor:view-refresh` re-show — after every restore the SETTLED palette (re-read per request, no process cache) governs. CSP note (commented in the code): `data:` is legal for the favicon via the phase-123 `img-src 'self' data:` carve-out (CSP3: `link[rel=icon]` is governed by `img-src`; the carve-out is image-scoped — `data:` never becomes a script/style/fetch source).
|
||||
- **NOT touched:** `CachingMiddleware`, the `#bor-theme` tag injection + CSP hash, the 8 semantic state colors (they are NOT in the artwork — a favicon has no state meaning), `app/` outside `theming.py`/`main.py`, `brand.js`, the other templates/assets, the `theme.js` save/reset/contrast machinery beyond the additions above.
|
||||
|
||||
## Dependencies
|
||||
- `176_mobile_settings_value_width` (todo) — the immediately preceding phase (Protocol B ordering; no functional dependency).
|
||||
- `92_theme_save_and_coverage` (complete) — the served-theme sync + the `#theme-*` input contract the D4 additions bind to.
|
||||
- `91_admin_theme_tab` (complete) — the Theme tab's live-preview mechanism (`previewColor`) and the `bor:view-refresh` re-show hook.
|
||||
|
||||
## Tasks
|
||||
1. `01_favicon_svg_renderer.md` — `theming.favicon_svg()`: the pure palette→artwork renderer, byte-identical for the built-in palette (golden pin) — A1.
|
||||
2. `02_favicon_route.md` — `GET /favicon.svg` (+ HEAD) in `create_app()`: effective palette, built-in fallback on DB failure (200, never 500), `image/svg+xml` + `no-cache` — A2/A3.
|
||||
3. `03_template_link_swap.md` — the five template links → `/favicon.svg`; `frontend/assets/favicon.svg` deleted (single source).
|
||||
4. `04_theme_tab_live_preview.md` — the Theme tab's live picks repaint the favicon (the CSP-legal `data:` mirror, A4); Save/Reset/re-show restore the route URL; the dedicated E2E suite + the atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- **Unit:** `tests/unit/test_theming.py` (task 01): the golden byte-identical pin, the themed-role pin (the three hexes in their exact roles), the geometry-invariance pin (only the 4 color attributes differ); `tests/unit/test_theme_frontend.py` (task 04): the `data:` mechanism pin (the three driving field names, the exact `/favicon.svg` restore, the restore wiring).
|
||||
- **Integration:** `tests/integration/test_favicon_route.py` (tasks 02–03): the three route states (default = golden bytes, themed = effective colors, DB failure = built-in fallback 200), the CSP header presence, the five template link pins (bare `/favicon.svg`, no `?v=`, no `/assets/favicon.svg` anywhere in the served HTML), the deleted file's 404.
|
||||
- **E2E (AGENTS rule 4, task 04):** `tests/e2e/test_themed_favicon.py`, run **in isolation** — the full contract in a real browser (default/themed route bytes, the pre-paint tag non-regression, the live-preview `data:` repaint, the restore contract, zero external requests — rule 6).
|
||||
- **Coverage:** **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`); **lint/types:** `uv run ruff check . && uv run pyright`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `GET /favicon.svg` on a default (no ui_settings row) deployment is 200 `image/svg+xml` with `Cache-Control: no-cache` and a body byte-identical to the current static art (the golden pin, the integration test).
|
||||
- [ ] With a saved non-default palette (distinct `surface`/`brand`/`brand_ink`), the route's SVG carries those three hexes in their exact roles, and the served pages STILL inject the `#bor-theme` tag (the phase-91 pin stays green — no regression to the tag/CSP contract).
|
||||
- [ ] A DB failure at request time serves the built-in art with 200 (never 500).
|
||||
- [ ] All five templates link `href="/favicon.svg"` (bare — no `?v=`), `/assets/favicon.svg` is 404 (the file is deleted), and the favicon actually renders in the browser (the E2E).
|
||||
- [ ] In the admin Theme tab, a live (unsaved) pick of `surface`/`brand`/`brand_ink` repaints the favicon via the `data:` URI; Save / Reset / re-show restore `href="/favicon.svg"` with the settled palette governing (the E2E).
|
||||
- [ ] `uv run pytest` green, `app/` coverage >90%, ruff + pyright clean.
|
||||
- [ ] `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` green **in isolation**; `test_theme_save_and_coverage.py` + `test_theme_semantic_completion.py` (phases 92/93) green in isolation (no theme-tab regression).
|
||||
- [ ] ONE atomic Conventional Commits commit (AGENTS rule 8, `--no-gpg-sign`) covering the phase + this directory + the cleared TODO.md.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Task 01 — The pure favicon renderer in `theming.py`
|
||||
|
||||
**Phase:** `177_themed_favicon` · **Source:** `TODO.md:3` — "We need the favicon to change colors to match the theme."
|
||||
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-13)
|
||||
|
||||
## Objective
|
||||
A pure, unit-testable renderer that produces the favicon artwork from the effective palette — the same art as today's static `frontend/assets/favicon.svg`, byte-identical for the built-in palette.
|
||||
|
||||
## Work
|
||||
1. `app/core/theming.py` — add `favicon_svg(colors: dict[str, str]) -> str` next to `theme_style_tag()`. The output is EXACTLY the `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">…</svg>` element (no comment header — the static file's comment is file packaging, not art). The artwork is unchanged: the hexagon path (`d="M32 4 55 18v28L32 60 9 46V18Z"`, `stroke-width="4"`, `stroke-linejoin="round"`), the center `<circle cx="32" cy="32" r="6.5">`, the spokes path (`d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5"`, `stroke-width="3"`, `stroke-linecap="round"`). Only the three color roles swap, per the A1 mapping: hexagon `fill` ← `colors["surface"]`, hexagon `stroke` ← `colors["brand"]`, circle `fill` ← `colors["brand"]`, spokes `stroke` ← `colors["brand_ink"]`. The input is the full 20-value effective dict (mirroring `theme_style_tag`'s self-contained signature — the function reads its three keys); pure (dict in → str out, no DB, no app, no defensive branches — the `effective_settings`/`BUILTIN_COLORS` contract guarantees the values). Docstring: the A1 role table, the byte-identical contract, and the "no state colors" note (the 8 semantic state colors are not in the artwork — a favicon has no state meaning).
|
||||
- ASSUMPTION: A1 (owner-confirmed) — the color mapping is hexagon fill→`surface`, hexagon stroke + center dot→`brand`, spokes→`brand_ink`; only these three identity colors drive the favicon.
|
||||
2. `tests/unit/test_theming.py` — extend the pin set:
|
||||
- `test_favicon_svg_builtins_is_byte_identical_to_the_static_art` — `favicon_svg(BUILTIN_COLORS)` equals a GOLDEN constant holding the current static file's `<svg>` element bytes. During this task's execution, verify the golden constant against the current `frontend/assets/favicon.svg` file (a one-time sanity check — the file is deleted in task 03, so the golden constant, not the file, is the durable contract).
|
||||
- `test_favicon_svg_themed_carries_the_three_roles` — a palette with distinct `surface`/`brand`/`brand_ink` (e.g. `#102030` / `#4f46e5` / `#a5b4fc`) plus the other 14 built-ins: the three hexes appear in their exact attribute roles (assert by parsing the attributes, not by substring), and none of the other 14 hexes appears.
|
||||
- `test_favicon_svg_geometry_is_invariant` — with a themed palette, everything except the four color attributes is byte-equal to the built-in output (viewBox, both path `d`s, circle `cx/cy/r`, stroke widths, `linejoin`/`linecap`, attribute order).
|
||||
3. One-time sanity: diff the golden constant against `frontend/assets/favicon.svg`'s `<svg>` element and log the result (evidence for the hand-off; not a persistent test).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the three pins above (`tests/unit/test_theming.py`), run with `uv run pytest tests/unit/test_theming.py -v`.
|
||||
- Coverage: **>90%** on this task's new code (a pure function — the pins drive it to ~100%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_theming.py -v` green (new + existing pins).
|
||||
- [ ] The one-time golden-vs-file sanity check passes (logged).
|
||||
- [ ] Full test suite green; `app/` coverage >90%; ruff + pyright clean.
|
||||
- [ ] No behavior change in completed work (a new function with no existing call site).
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task 02 — Serve the themed favicon: `GET /favicon.svg`
|
||||
|
||||
**Phase:** `177_themed_favicon` · **Source:** `TODO.md:3` — "We need the favicon to change colors to match the theme."
|
||||
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-13)
|
||||
|
||||
## Objective
|
||||
The favicon over the wire: a route that renders the artwork from the EFFECTIVE palette on every request (no process cache — a runtime theme change is visible on the next request), with the never-break DB-blip fallback.
|
||||
|
||||
## Work
|
||||
1. `app/main.py` — in `create_app()`, after the router includes (ending at the `chats_shared_page_router` line ~L200) and BEFORE the static catch-all mount (`app.mount("/", …)` at L252, routes-first): register `app.api_route("/favicon.svg", methods=["GET", "HEAD"], include_in_schema=False)` (HEAD for static-file parity, the phase-76 `_shell_routes` parity idiom). The handler is a sync `def` (the house pattern for short-lived-session reads):
|
||||
- open a short-lived `SessionLocal()` session (imported from `app.db`, the `caching.py` middleware's exact pattern — NO process cache, `try/finally` close);
|
||||
- `effective = theming.effective_settings(db)` → `svg = theming.favicon_svg(effective)`;
|
||||
- ANY exception (DB blip, pre-migration boot): `logger.exception("favicon: theme read failed — serving the built-in favicon")` and `svg = theming.favicon_svg(theming.BUILTIN_COLORS)` — **200, never 500** (the theme-tag's never-break contract, `caching.py`'s DB-blip pattern);
|
||||
- return `Response(svg, media_type="image/svg+xml", headers={"Cache-Control": "no-cache"})`.
|
||||
- Commented rationale in the code (house style): (a) the route is deliberately OUTSIDE `/api/*` → no per-response byte-budget line (AGENTS rule 10 is `/api/*`-only) and no auth dependency (the favicon is chrome — public like the login page); (b) `CachingMiddleware` passes it through untouched (neither in `HTML_PAGES` nor under `/assets/`) → the route's own `no-cache` is the freshness contract (the palette changes at runtime; the page is no-cache too, so the browser revalidates together with it); (c) `SecurityHeadersMiddleware` still adds the CSP — no change needed (`img-src 'self' data:` covers task 04's `data:` preview).
|
||||
- ASSUMPTION: A2 (owner-confirmed) — serving shape: a new dynamic route `GET /favicon.svg` replaces the static file (task 03 deletes it).
|
||||
- ASSUMPTION: A3 (owner-confirmed) — fault tolerance: a DB failure / pre-migration boot serves the built-in palette with 200 (never 500).
|
||||
2. `tests/integration/test_favicon_route.py` — NEW, via the house `TestClient` fixture (follow the `tests/integration/test_ui_settings.py` app/db fixture pattern):
|
||||
- **default state** (no `ui_settings` row): `GET /favicon.svg` → 200, `content-type: image/svg+xml`, `cache-control: no-cache`, body byte-identical to the golden art constant (import it from `tests/unit/test_theming.py` — one source), and the response carries the CSP header (the `SecurityHeadersMiddleware` pass-through).
|
||||
- **themed state**: a `ui_settings` row (id 1) with distinct `surface`/`brand`/`brand_ink` (e.g. `#102030` / `#4f46e5` / `#a5b4fc`): the body carries the three hexes in their roles (attribute-parsed, same helper idiom as the unit pins) and none of the other 14; a second request after updating the row reflects the new palette immediately (no process cache — the runtime-change contract).
|
||||
- **DB-failure state**: patch `effective_settings` (or `SessionLocal`) to raise → 200 + the built-in golden bytes (never 500).
|
||||
- **HEAD parity**: `HEAD /favicon.svg` → 200, the same headers, empty body.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the four states above (`tests/integration/test_favicon_route.py`), run with `uv run pytest tests/integration/test_favicon_route.py -v`.
|
||||
- Coverage: **>90%** on this task's new/modified code (the handler's success + fallback branches both driven).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_favicon_route.py -v` green (all four states).
|
||||
- [ ] The route wins over the static mount (a file named `favicon.svg` in the static dir could not shadow it — the routes-first position holds).
|
||||
- [ ] Full test suite green; `app/` coverage >90%; ruff + pyright clean.
|
||||
- [ ] No behavior change in completed work (a new route only; `/assets/favicon.svg` still serves until task 03 deletes it).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 03 — Point all five templates at the route; delete the static file
|
||||
|
||||
**Phase:** `177_themed_favicon` · **Source:** `TODO.md:3` — "We need the favicon to change colors to match the theme."
|
||||
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-13)
|
||||
|
||||
## Objective
|
||||
Every page asks for the themed favicon from the route, and the static file stops existing — the route is the single source (the built-in bytes stay pinned by task 01's golden constant, so nothing is lost).
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html`, `frontend/document.html`, `frontend/login.html`, `frontend/shared.html`, `frontend/doc-edit.html` — L8 in each file: `<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">` → `<link rel="icon" type="image/svg+xml" href="/favicon.svg">`. Nothing else in any template changes.
|
||||
2. Delete `frontend/assets/favicon.svg` (A2, owner-confirmed — the single-source decision; the one-time golden-vs-file sanity was done in task 01, so the deletion loses no bytes: the golden constant in `tests/unit/test_theming.py` is the durable contract).
|
||||
3. `tests/integration/test_favicon_route.py` — extend with the template pins:
|
||||
- each of the five served page bodies (`/`, `/document.html`, `/login.html`, `/shared.html`, `/doc-edit.html` via `TestClient`) contains `<link rel="icon" type="image/svg+xml" href="/favicon.svg">` and does NOT contain `/assets/favicon.svg`;
|
||||
- the favicon link in the served HTML stays BARE — no `?v=` (the href is not under `/assets/`, so `CachingMiddleware`'s `_ASSET_REF_RE` can never touch it; the pin keeps that contract explicit against a future regex drift);
|
||||
- `GET /assets/favicon.svg` → 404 (the file is gone);
|
||||
- a tree scan of `frontend/` (the test walks the directory, the house idiom) asserts zero remaining references to `/assets/favicon.svg`.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the template pins above, run with `uv run pytest tests/integration/test_favicon_route.py -v`.
|
||||
- Coverage: **>90%** on `app/` (no new `app/` code in this task — the gate must stay green, not drop).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] All five templates link `href="/favicon.svg"`; the tree scan finds zero `/assets/favicon.svg` references.
|
||||
- [ ] `GET /assets/favicon.svg` is 404; `GET /favicon.svg` still serves the golden bytes (the route is the single source now).
|
||||
- [ ] In a running app, a browser tab loads the favicon from `/favicon.svg` (final browser proof in task 04's E2E).
|
||||
- [ ] Full test suite green; `app/` coverage >90%; ruff + pyright clean.
|
||||
- [ ] No behavior change in completed work (the favicon art is byte-identical on a default deployment — the golden pin holds).
|
||||
@@ -0,0 +1,40 @@
|
||||
# Task 04 — Theme-tab live preview + the dedicated E2E suite + the commit
|
||||
|
||||
**Phase:** `177_themed_favicon` · **Source:** `TODO.md:3` — "We need the favicon to change colors to match the theme."
|
||||
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-13)
|
||||
|
||||
## Objective
|
||||
The admin Theme tab's live (unsaved) picks repaint the favicon too (owner-confirmed A4), the settled palette always wins after Save/Reset/re-show — and the phase's dedicated Playwright suite proves the whole contract in a real browser.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/theme.js` —
|
||||
- a pure helper `themeFaviconDataUri(surface, brand, brandInk)` → `"data:image/svg+xml," + encodeURIComponent(svg)`, where `svg` is the CLIENT-SIDE MIRROR of `theming.favicon_svg` (the same artwork, the same A1 roles: hexagon fill ← `surface`, hexagon stroke + circle fill ← `brand`, spokes stroke ← `brand_ink`). Guard with the existing `isHex` idiom: a non-hex input returns `null` (the link is left untouched — the color inputs are always valid hex, the guard is belt-and-braces; the inputs are the only source of the values, so there is no XSS path — the house "never build HTML" rule binds the DOM, the URI is built from constants + validated hexes).
|
||||
- in the existing per-color `input` bindings (the live-preview loop): when the field is one of the three driving fields (`surface`, `brand`, `brand_ink`), ALSO set the `link[rel="icon"]` element's `href` to `themeFaviconDataUri(<the three inputs' current values>)` (rebuilt, not patched — a `surface` pick repaints with the current `brand`/`brand_ink` picks). The other 14 picks never touch the link.
|
||||
- **restore to `"/favicon.svg"`** in the four settle points: `saveTheme()` success (after `applyServedTheme(settings)`), `resetTheme()` success (after the reconcile), the `resetTheme()` failure path (where `clearPreview()` runs — the picks are stale), and the `bor:view-refresh` re-show handler (after its `applyServedTheme`). After every restore the SETTLED palette governs — the route re-reads the DB per request (no process cache), so the restored URL is always fresh; the page is `no-cache`, so the browser revalidates the favicon with it.
|
||||
- CSP comment (house style, per D4): `data:` is legal for the favicon via the phase-123 `img-src 'self' data:` carve-out (CSP3 governs `link[rel=icon]` by `img-src`; the carve-out is image-scoped — `data:` never becomes a script/style/fetch source).
|
||||
- file-header docstring: extend with the favicon-preview paragraph (the mechanism, the four restore points, the CSP note — the theme.js header documents every behavior).
|
||||
- ASSUMPTION: A4 (owner-confirmed 2026-10-13) — the live (unsaved) picks repaint the favicon via the CSP-legal `data:` URI; the settled palette always wins after Save/Reset/re-show. (The simpler alternative — saved palette only, no `data:` — was considered and declined.)
|
||||
2. `tests/unit/test_theme_frontend.py` — extend the static pin set (the house JS-pin idiom — the pins parse `theme.js`'s source):
|
||||
- the `data:image/svg+xml` URI mechanism is present (the literal prefix, `encodeURIComponent`);
|
||||
- the favicon helper reads EXACTLY the three field names `surface`/`brand`/`brand_ink` (the driving-set pin — a fourth field or a missing one fails the pin);
|
||||
- the restore string is EXACTLY `"/favicon.svg"` and is wired to all four settle points (save success, reset success, reset-failure `clearPreview` path, `bor:view-refresh`);
|
||||
- the mirror's artwork constants match the server's (the viewBox + both path `d` strings + circle `r` — the divergence guard, the `PAIRS`-mirror house pattern).
|
||||
3. `tests/e2e/test_themed_favicon.py` — NEW, the phase's dedicated Playwright suite (AGENTS rule 4), run in isolation, following the `test_theme_save_and_coverage.py` fixture idiom (`app_server`/`app_url`/`db_ready`, `e2e/auth_helpers.login(page, app_url, next="/theme.html")`, the `_clean` ui_settings reset):
|
||||
- **default state:** `GET /favicon.svg` → 200 + the golden art bytes (fetched in the test); the five pages' HTML reference `/favicon.svg`; `/assets/favicon.svg` → 404;
|
||||
- **themed state:** admin login → `PUT /api/ui-settings` with a distinct palette (e.g. `surface #102030`, `brand #4f46e5`, `brand_ink #a5b4fc`): the route's SVG carries the three hexes in their roles; a fresh page load STILL injects `#bor-theme` with the expected content (the phase-91 non-regression pin — the tag/CSP contract is untouched);
|
||||
- **live preview:** admin opens `/theme.html` (the `#view-theme` view), sets the `#theme-surface` / `#theme-brand` / `#theme-brand-ink` inputs (the `input` event, the house `_fill_theme_form` idiom) → `link[rel="icon"]`'s href starts with `data:image/svg+xml` and, decoded, carries the three picked hexes in their roles; a non-driving pick (e.g. `#theme-bg`) does not change the href;
|
||||
- **restore contract:** click **Save theme** → the href is EXACTLY `/favicon.svg` and the route now serves the saved palette's bytes; click **Reset to defaults** → the href is `/favicon.svg` and the route serves the built-in golden bytes; re-showing the tab (the `bor:view-refresh` path) restores the href too;
|
||||
- **zero external requests** across the whole suite (AGENTS rule 6 — the house E2E assertion idiom: a `page.on("request")` collector asserting every URL is the local origin / a `data:` URI).
|
||||
4. The atomic commit (AGENTS rule 8): ONE Conventional Commits commit, e.g. `feat(theming): serve the favicon from the effective theme palette`, `--no-gpg-sign` — covering the `app/` + `frontend/` + `tests/` changes, this phase directory (`.agents/` is tracked), and the cleared `TODO.md`.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the JS static pins (`tests/unit/test_theme_frontend.py`), run with `uv run pytest tests/unit/test_theme_frontend.py -v`.
|
||||
- E2E: `tests/e2e/test_themed_favicon.py` run **in isolation** — `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov`.
|
||||
- Coverage: **>90%** on `app/` (the JS is gated by the pins + the browser E2E — the house idiom); ruff + pyright clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_theme_frontend.py -v` green (new pins).
|
||||
- [ ] `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` green **in isolation** (all six contract blocks).
|
||||
- [ ] `test_theme_save_and_coverage.py` (phase 92) + `test_theme_semantic_completion.py` (phase 93) green in isolation (no Theme-tab regression).
|
||||
- [ ] `uv run pytest` green, `app/` coverage >90% (`uv run pytest --cov=app --cov-report=term-missing`), `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] The atomic commit is landed (Conventional Commits, `--no-gpg-sign`, `.agents/` + the cleared `TODO.md` included).
|
||||
+11
@@ -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`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+136
@@ -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
|
||||
+12
@@ -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 `<th scope="col">` 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).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+136
@@ -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
|
||||
+23
@@ -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, `</thead>` searched after it) carries `<th scope="col" class="settings-value-th">Value</th>`
|
||||
- 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 `</thead>` after the table start (the shell's earlier tables break a file-global find).
|
||||
|
||||
**Next pending task:** `03_e2e_mobile_value_width.md`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+136
@@ -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
|
||||
+10
@@ -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.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+136
@@ -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
|
||||
@@ -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 —
|
||||
|
||||
+1
-1
@@ -1599,7 +1599,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Setting</th>
|
||||
<th scope="col">Value</th>
|
||||
<th scope="col" class="settings-value-th">Value</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||
</tr>
|
||||
|
||||
@@ -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}"
|
||||
@@ -224,11 +224,15 @@ def test_settings_content_scaffold_and_feedback_regions() -> None:
|
||||
assert '<caption class="visually-hidden">' in thead, (
|
||||
"the caption is visually-hidden (the phase-86 table pattern)"
|
||||
)
|
||||
assert thead.count('<th scope="col">') == 4, (
|
||||
assert thead.count('<th scope="col"') == 4, (
|
||||
"the table is four columns (Setting | Value | Source | Actions)"
|
||||
)
|
||||
assert '<th scope="col">Setting</th>' in thead
|
||||
assert '<th scope="col">Value</th>' in thead
|
||||
assert '<th scope="col" class="settings-value-th">Value</th>' 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 '<th scope="col">Source</th>' in thead
|
||||
assert (
|
||||
'<th scope="col"><span class="visually-hidden">Actions</span></th>'
|
||||
@@ -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 <th> 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("</thead>", table_start)]
|
||||
assert '<th scope="col" class="settings-value-th">Value</th>' 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 ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user