docs(phases): convert TODO.md L3-L4 into phases 170-171 (edit bubble, settings layout)

170_edit_bubble_sizing — the edit form renders inside the user bubble
(same box, no shrink) and its submit label reads "Send", not "Save"
(TODO.md L3).
171_admin_settings_layout — settings page CSS fixes: #settings-content
vertical rhythm (search/table overlap + smushed action row), distinct
group bands, a properly disabled Save cursor (TODO.md L4).

Numbering note: 169 (embedding generation migration) was created,
executed and abandoned before this conversion; its number is not
reused. TODO.md is cleared — the items now live in .agents/phases/.

LLM-Generated: true
This commit is contained in:
2026-10-09 23:42:15 -04:00
parent 542e568d25
commit abe7388dda
9 changed files with 327 additions and 0 deletions
@@ -0,0 +1,47 @@
# Phase 170 — Edit bubble: the editor lives inside the user bubble, "Send" re-asks
**Source:** `TODO.md` L3 — "the edit bubble is tiny compared to the message bubble. When you click "edit" it collapses down to this super small text field. It should stay the same size as the user message bubble. Also the save button should read "send" rather than "save" because you're not saving it you're re-sending the message."
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-10)
**Context:** `frontend/assets/app.js` — `openEditor(wrap, toggleBtn)` (L1210–1357: label + `textarea[rows=3][maxlength=4000]` prefilled through `.value`, `editSaveBtn` "Save" L1238 + cancel, `closeEditor()` L1252 (form.remove + `.is-editing` remove + focus), the submit handler L1265–1345 (in-flight guard, empty guard, replace-in-place, tail truncation + DOM drop, `lastBrainWrap = null`, `bubble.innerHTML = renderMarkdown(text)` + the `.msg-image` re-prepend L1322–1325, `saveConversation()` + `persistConversation()`, `markLastRetryable()`, `closeEditor()` L1337, `runTurn(text, { reask: true })`), the open-tail L1355–1357 (`classList.add("is-editing")` + `body.appendChild(form)` + `ta.focus()`)), `appendEditButton` / `markLastEditable` (L793–871, the phase-159 single-target discipline), `tests/unit/test_frontend_edit_message.py` (house source pins: `editSaveBtn.textContent = "Save"` L348; the `.is-editing` add L365 / remove L387 / ordering L372; the `body.appendChild(form)` pin near L699), `tests/e2e/test_edit_and_resend.py` (the phase-159 suite: `page.click(".edit-form .edit-save")` L337 — class-based, so the label rename touches only its docstrings; the composer's `#send-label` "Send" pins L312/L533 are a different control), `frontend/assets/styles.css` — `.msg.user .bubble.is-editing { display: none; }` (L1411), `.edit-form` (L1412–1420 — its own `--surface` box + border + padding), `.edit-form label` (L1421–1425), `.edit-form textarea` (L1426–1436, `resize: vertical`, `min-height: 2.6rem`), `.edit-form-actions` (L1437), `.edit-save` (L1438–1455, incl. `:disabled { opacity: 0.6; cursor: wait; }` L1454), `.edit-cancel` (L1456–1469), `.edit-error` (L1470–1479), `.bubble` (L501–508), `.msg.user .bubble` (L592–596 — the `--brand` fill + `--bg` text).
## Objective
Clicking **Edit** no longer swaps the user bubble for a shrunken form: the editor lives *inside* the bubble itself (inheriting its box, background, and padding, so it keeps the bubble's dimensions) and the submit button reads **Send** — the action re-sends the question, it does not save a note. Everything else of the phase-159 flow (prefill, empty guard, tail truncation, the `reask` re-ask, one-editor-at-a-time, image survival, the in-flight guards) stays behavior-identical.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The form IS the bubble's content (LOCKED A1).** `openEditor` stops appending the form as a sibling of `.bubble` in `.msg-body` and stops hiding the bubble. It keeps the `.msg-image` child in a closure variable, keeps `bubble.classList.add("is-editing")` (L1355 — the state class survives, the unit pins keep it), and replaces `body.appendChild(form)` (L1356) with the form appended **into** the bubble, the rendered markdown cleared first. The bubble keeps its own `--brand` fill, border, padding, and width — "the same size as the user message bubble" holds by construction (the box IS the box), with zero measurement/matching code. `.msg.user .bubble.is-editing { display: none; }` (styles.css L1411) is **deleted** — the bubble stays visible while the editor is open.
- **The form is transparent.** `.edit-form` drops its own `background` / `border` / `padding` (the bubble provides them) and keeps `display:flex; flex-direction:column; gap: 0.5rem`. The label and the action text take the bubble's `--bg`-family ink (contrast documented in a comment — the B5 text-state rule); the textarea keeps its own readable `--surface` fill + `--ink` text (an edit field on a `--brand` bubble) and gains `width: 100%`. `rows=3` and `maxlength=4000` are untouched — the *outer* box is the bubble's, the textarea is the inner field.
- **Cancel = restore the markdown.** `closeEditor()` keeps exactly three jobs: `form.remove()`, `bubble.classList.remove("is-editing")`, `toggleBtn.focus()` — no content change. The cancel branch (Escape + the Cancel button) gains a restore step: `bubble.innerHTML = renderMarkdown(conversation[editIdx].text)` + re-prepend the saved image (the mirror of the submit re-render L1322–1325), then `closeEditor()`.
- **Submit ordering.** The submit handler currently re-renders (`bubble.innerHTML = renderMarkdown(text)` L1322) *before* `closeEditor()` (L1337). With the form inside the bubble, `closeEditor()` (which detaches the form) must run **before** the innerHTML re-render; the rest of the handler — truncation, `lastBrainWrap = null` / `leavePartialIndex` reset, persistence, `markLastRetryable()`, the in-flight re-check, `runTurn(text, { reask: true })` — is byte-untouched.
- **Label (LOCKED A2).** `editSaveBtn.textContent = "Send"` (app.js L1238) — matching the composer's `#send-label` "Send" and the "Cancel" sibling. The phase-168 "Save settings" button is a different control and keeps its name.
- **NOT touched:** `appendEditButton` / `markLastEditable`, `runTurn`, `retryLastTurn`, `handleSend`, `rememberBrainTurn`, the record-truncation + persistence path, `MAX_QUESTION_CHARS` / `updateCharCount`, the composer (the unsent draft survives the edit), the SSE contract (A15), the shared-chat page, `app/` server code (A10 stateless holds).
## Dependencies
- `159_edit_and_resend_message` (complete) — the inline editor + re-ask flow this phase re-houses and re-labels.
- `166_hide_empty_answer_bubble` (complete) — `.msg .bubble:empty { display: none }` (styles.css L524): while the editor is open the bubble holds the form (never `:empty`), and on cancel the re-rendered markdown is never empty (`min_length=1` pinned client- and server-side) — no interaction, the pin stays green.
## Tasks
1. `01_editor_inside_bubble.md` — `openEditor` builds the form **inside** `.bubble` (transparent form, `display:none` deleted), the cancel re-render, the submit ordering; the unit pins updated.
2. `02_save_label_send.md` — the submit label "Save" → "Send" (app.js L1238), the unit pin, the phase-159 E2E docstring alignment.
3. `03_e2e_edit_bubble.md` — `tests/e2e/test_edit_bubble_sizing.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_frontend_edit_message.py` (house source pins, updated in tasks 01/02): the form is appended to the bubble (not `.msg-body`), the `display:none` rule's absence pinned in the CSS, `.is-editing` still added (L365) / removed (L387), the cancel restore (markdown re-render + image re-prepend) and the submit ordering (form detached before the re-render), `editSaveBtn.textContent = "Send"`, and `runTurn` / `retryLastTurn` / `handleSend` byte-unchanged.
- **E2E (AGENTS rule 4, task 03):** `tests/e2e/test_edit_bubble_sizing.py`, run **in isolation** — the size/label contract plus the regression flows.
- **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
- [ ] Clicking Edit on the last user bubble shows the editor **inside** the bubble: the bubble is visible (computed `display` ≠ `none`), keeps its `--brand` fill, and its box width is unchanged by the edit (the E2E bounding-box pin).
- [ ] The submit button reads "Send"; Cancel / `Escape` restore the rendered question; trim + Send re-asks with the truncated shape and the "Question too long" driver case still resolves.
- [ ] `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_edit_bubble_sizing.py -v --no-cov` green **in isolation**; regression set green in isolation: `test_edit_and_resend.py`, `test_retry_answer.py`, `test_failed_turn_retry.py`, `test_chat_persistence.py`.
- [ ] One atomic `--no-gpg-sign` Conventional Commit; the phase dir moves to `.agents/phases/complete/`.
## Locked decisions (owner-confirmed 2026-10-10, roadmap confirmation)
- **A1 — the editor lives inside the bubble:** "stay the same size as the user message bubble" = the editor occupies the bubble element itself (inherits its box/styling), not a pixel-matched sibling form. `rows=3` + `maxlength=4000` stay.
- **A2 — the label is "Send"** (capital S, matching the composer's `#send-label` "Send" and "Cancel"); only the edit-form label changes — the phase-168 "Save settings" button keeps its name.
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): edit the last question inside its bubble and re-send it with Send"
```
@@ -0,0 +1,40 @@
# Task 01 — the editor renders inside the user bubble (same box, no shrink)
**Phase:** `170_edit_bubble_sizing` · **Source:** `TODO.md:3` — "the edit bubble is tiny compared to the message bubble. When you click "edit" it collapses down to this super small text field. It should stay the same size as the user message bubble."
**Story:** n/a
## Objective
`openEditor` renders its form **inside** `.bubble` instead of as a hidden-bubble + small-sibling-form: the editor inherits the bubble's own box, background, and padding, so it keeps the bubble's dimensions by construction. The open / cancel / submit behavior of the phase-159 flow is otherwise unchanged.
## Work
1. `frontend/assets/app.js` — `openEditor` (L1210–1357), three surgical changes:
- **Open:** keep `if (bubble) bubble.classList.add("is-editing");` (L1355); replace `body.appendChild(form);` (L1356) with: grab the `.msg-image` child into a closure variable, clear the bubble's rendered markdown (`bubble.innerHTML = "";` or `bubble.replaceChildren(form)`), append the form **into** the bubble; `ta.focus()` (L1357) stays.
- **Cancel:** `closeEditor()` (L1252–1257) keeps exactly `form.remove()` + `bubble.classList.remove("is-editing")` + `toggleBtn.focus()` — no content change. The cancel paths (the `Escape` keydown branch L1258–1261 and the Cancel-button listener) gain a restore step BEFORE `closeEditor()`: `bubble.innerHTML = renderMarkdown(conversation[editIdx].text)` + re-prepend the saved image (the exact mirror of the submit re-render L1322–1325).
- **Submit:** reorder — move `closeEditor()` (L1337) to **before** the `if (bubble) { … bubble.innerHTML = renderMarkdown(text); … }` re-render block (L1322–1325), so the form is detached cleanly first. Everything else in the handler — the in-flight guard, the empty guard, `conversation[editIdx].text = text`, the tail truncation + DOM drop, `lastBrainWrap = null` / `leavePartialIndex` reset, `saveConversation()` + `persistConversation()`, `markLastRetryable()`, the second in-flight re-check, `runTurn(text, { reask: true })` — is byte-untouched.
- Update the `openEditor` comment block (L1154–1195): the "While open the rendered .bubble is hidden (.is-editing — the question appears ONCE)" sentence becomes the new contract (the form renders inside the bubble; the bubble stays visible; `.is-editing` is the state class); keep the rest of the block.
2. `frontend/assets/styles.css`:
- **Delete** `.msg.user .bubble.is-editing { display: none; }` (L1411) — the bubble stays visible while the editor is open.
- `.edit-form` (L1412–1420): drop the own `background: var(--surface);`, `border: 1px solid var(--brand-soft);`, and `padding: 0.75rem 0.85rem;` (the bubble provides them); keep `display: flex; flex-direction: column; gap: 0.5rem;`.
- `.edit-form label` (L1421–1425): the color becomes the bubble's `--bg`-family ink — on the `--brand` fill, `--bg` is the documented 1.0-against-brand pairing inverted (the user-bubble text pairing, L592–596); document the ratio in the comment (B5: text states it, never color alone); keep `font-size: 0.85rem; font-weight: 600;`.
- `.edit-form textarea` (L1426–1436): add `width: 100%;` (the field fills the bubble's width — the "not tiny" fix); keep `font-size: 0.9rem`, the `--surface` fill + `--ink` text (a readable edit field on the `--brand` bubble), `border`, `resize: vertical`, `min-height: 2.6rem`.
- `.edit-error` (L1470–1479): the `--err-bg` / `--err-ink` pair is documented against `--surface`; on the `--brand` bubble it needs a check — if the pair fails ≥4.5:1 there, scope a `.msg.user .bubble .edit-error` variant on the bubble's own ink pairing (`--bg` text on a `--brand`-darkened fill) and document the ratio; the `role="alert"` behavior is untouched.
- The `:disabled` wait-cursor on `.edit-save` (L1454) is the phase-159 in-flight dim — leave it (task 03 of THIS phase does not touch it; the owner's cursor complaint is the settings page, phase 171).
3. `tests/unit/test_frontend_edit_message.py` — update the pins (house source-pin style):
- the form is appended to the bubble: replace the `body.appendChild(form)` pin (near L699) with the bubble-append pin (e.g. `bubble.replaceChildren(form)` or the innerHTML-clear + append, whichever lands in app.js);
- the `.is-editing` add (L365) / remove (L387) / ordering (L372) pins **stay** (the class is still the state marker);
- new pins: the cancel restore (`renderMarkdown(conversation[editIdx].text)` on cancel + the image re-prepend) and the submit ordering (`closeEditor()` before the `bubble.innerHTML = renderMarkdown(text)` re-render);
- a CSS pin: `.msg.user .bubble.is-editing { display: none; }` is **absent** from `styles.css`;
- `runTurn` / `retryLastTurn` / `handleSend` pins byte-unchanged.
- ASSUMPTION: A1 (phase-locked) — "stay the same size as the user message bubble" is implemented by the editor occupying the bubble element itself (it inherits the bubble's box/styling), not by pixel-matching a sibling form; `rows=3` + `maxlength=4000` stay.
## Testing & Quality
- Unit: `tests/unit/test_frontend_edit_message.py` green (`uv run pytest tests/unit/test_frontend_edit_message.py -v --no-cov`).
- Coverage: **>90%** on `app/` (unchanged code — the gate must stay green).
- Lint/types: `uv run ruff check . && uv run pyright` (the JS is not linted by ruff — the unit pins are the JS gate).
## Completion Criteria
- [ ] In a live browser (or the E2E from task 03): clicking Edit shows the form inside the visible bubble — no `display:none`, no shrunken sibling box.
- [ ] Cancel / `Escape` restore the rendered markdown + the attachment; the record is unchanged.
- [ ] `uv run pytest tests/unit/test_frontend_edit_message.py -v --no-cov` green; `uv run pytest` green; ruff + pyright clean.
- [ ] No behavior change in `runTurn` / `retryLastTurn` / `handleSend` (byte-unchanged pins green).
@@ -0,0 +1,25 @@
# Task 02 — the edit-form submit label reads "Send", not "Save"
**Phase:** `170_edit_bubble_sizing` · **Source:** `TODO.md:3` — "Also the save button should read "send" rather than "save" because you're not saving it you're re-sending the message."
**Story:** n/a
## Objective
The edit form's submit button reads **Send** — the action re-sends the question (it never persists a standalone note, A10 stateless holds), so "Save" was a lie. The label matches the composer's `#send-label` "Send" and the "Cancel" sibling; nothing else in the flow changes.
## Work
1. `frontend/assets/app.js` L1238 — `editSaveBtn.textContent = "Save";` → `editSaveBtn.textContent = "Send";`. The `edit-save` class, the `type="submit"` wiring, the Ctrl/Cmd+Enter submit, and the whole submit handler are untouched.
2. `tests/unit/test_frontend_edit_message.py` L348 — the pin `assert 'editSaveBtn.textContent = "Save";' in body` → `"Send"`; keep the surrounding pin block (the `editSaveBtn.type` / `editSaveBtn.className` pins) and align the module docstring's "Save/Cancel" phrasing (L15) to "Send/Cancel" where it names the **edit form** (the docstring lines that name the composer's send flow stay — that control was always "Send").
3. `tests/e2e/test_edit_and_resend.py` — the flow drives the edit submit by class (`page.click(".edit-form .edit-save")` L337) — **no code change**. Align the module docstring / comments that name the edit form's "Save" action (L29, L32, L51, L79, L255, L341, L383, L510) to "Send"; the composer's `#send-label` "Send" assertions (L312, L533) are a different control and stay byte-identical.
- ASSUMPTION: A2 (phase-locked) — the label is "Send" (capital S, matching the composer's `#send-label` and "Cancel"); only the edit-form label changes — the phase-168 "Save settings" button (a different control) keeps its name.
## Testing & Quality
- Unit: `tests/unit/test_frontend_edit_message.py` green.
- E2E (regression, in isolation): `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` — the phase-159 suite now runs the flow through the "Send" label (the click is class-based, so the suite is code-identical; the docstrings are aligned).
- Coverage: **>90%** on `app/` (unchanged).
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] The edit form's submit button reads "Send" in the live UI; "Save" appears nowhere in the edit form.
- [ ] `uv run pytest tests/unit/test_frontend_edit_message.py -v --no-cov` green; `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` green in isolation.
- [ ] The phase-168 "Save settings" button (settings view) is byte-untouched.
@@ -0,0 +1,44 @@
# Task 03 — `tests/e2e/test_edit_bubble_sizing.py`: the in-bubble editor, proven in a browser
**Phase:** `170_edit_bubble_sizing` · **Source:** `TODO.md:3` — "the edit bubble is tiny compared to the message bubble … It should stay the same size as the user message bubble. Also the save button should read "send" rather than "save" …"
**Story:** n/a
## Objective
The phase's dedicated Playwright suite (AGENTS rule 4/9): the editor opens **inside** the user bubble with the bubble's dimensions, the submit label reads "Send", and the whole phase-159 flow (trim + re-ask, cancel restore, the "Question too long" driver case) works through the new housing. Then the regression set, the full gates, and the one atomic commit.
## Work
1. `tests/e2e/test_edit_bubble_sizing.py` (new) — copy the module-scoped app fixture from `tests/e2e/test_edit_and_resend.py` verbatim (mock LLM, `BOR_ENVIRONMENT=e2e`, the E2E admin/secret pins) and change exactly: the module docstring (what the suite proves + the Source line `TODO.md:3` — the "in-bubble editor + Send label" contract) and the test list below. DB isolation: this suite writes no `app_settings` / theme rows; the `saved_chats` rows it creates follow the phase-159 suite's convention (no extra teardown needed beyond what that fixture already does — if the fixture lacks a `saved_chats` cleanup, add the same one it would need, mirroring `test_chat_persistence.py`).
2. Tests (names pinned; helpers `login(page, app_url)` from `tests/e2e/auth_helpers.py` where a gate is crossed — the chat is public, so most tests need no login, exactly like the phase-159 suite):
- `test_editor_renders_inside_the_bubble` — send a fixture question, wait for the grounded answer (the mock-LLM settle convention), capture the user bubble's `bounding_box()` + computed `background-color`, click Edit: the form is a **child of the bubble** (`bubble.evaluate("el => el.querySelector('.edit-form') !== null")`), the bubble's computed `display` is not `none`, its `background-color` is unchanged (the `--brand` fill), the textarea prefills the EXACT question, and `.is-editing` is on the bubble.
- `test_editor_box_keeps_the_bubble_dimensions` — the pre-edit bubble `bounding_box()` vs the open-editor bubble: width is ≥ 95 % of the pre-edit width (the "no longer tiny" pin — the old shrunken sibling form failed this by construction) and height is ≥ the pre-edit height (the box grows, never collapses); at 375 px the bubble has no horizontal overflow (§7.1 reading column).
- `test_submit_label_reads_send` — `.edit-save`'s text is "Send" (and not "Save"); `.edit-cancel`'s is "Cancel".
- `test_trim_and_send_reasks_in_place` — the phase-159 flow through the new label: append " in one line", click `.edit-form .edit-save` (the "Send" button): the truncated shape `[user(edited), brain]` (the `bor.chat.v1` localStorage pin, the phase-159 convention), the re-ask streams the grounded answer, no duplicated question, the UNSSENT composer draft survives.
- `test_cancel_restores_the_bubble` — open the editor, Cancel: the rendered markdown is back (the question text is visible in the bubble, the form is gone, `.is-editing` off), the `conversation` record is byte-identical; a second pass with `Escape` (a fresh turn) pins the same restore through the keydown path.
- `test_failed_turn_still_editable` — the phase-114 driver case (the 4000+ char question → the "Question too long — trim it and re-ask" error frame, the phase-120 failed record): the editor opens inside the bubble, trimming the question to the cap + "Send" streams the grounded answer (the failed record is replaced).
3. **Regression set, all in isolation** (`--no-cov`): `test_edit_and_resend.py` (the phase-159 suite — now running against the in-bubble editor + the "Send" label), `test_retry_answer.py`, `test_failed_turn_retry.py`, `test_chat_persistence.py`.
4. **Full gates** (AGENTS rule 9, all green before the commit):
```bash
uv run pytest
uv run pytest --cov=app --cov-report=term-missing # >90% on app/
uv run pytest tests/e2e/test_edit_bubble_sizing.py -v --no-cov
uv run ruff check . && uv run pyright
```
5. **Docs & close-out:** confirm `README.md`'s edit-the-last-question note (if any names the "Save" label or the hidden-bubble behavior — phase 159's README section, grep `edit` there) is aligned to the in-bubble editor + "Send"; move the phase directory:
```bash
git mv .agents/phases/todo/170_edit_bubble_sizing .agents/phases/complete/170_edit_bubble_sizing
```
and commit (AGENTS rule 8, `--no-gpg-sign`):
```bash
git add frontend/ tests/ README.md .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): edit the last question inside its bubble and re-send it with Send"
```
## Testing & Quality
- The suite passes **in isolation** (`-v --no-cov`).
- Unit pins already exist from tasks 01/02 — this file adds the browser contract only; if it needs a behavior the UI lacks, fix the UI in this task and say so in the commit body.
- Coverage: **>90%** on `app/` after everything (the `--cov` runs exclude E2E by the repo's config; the gate is the unit+integration number).
## Completion Criteria
- [ ] `test_editor_box_keeps_the_bubble_dimensions` is green: the open editor's bubble keeps the pre-edit width (≥ 95 %) and never collapses.
- [ ] The "Send" label, the in-bubble housing, the cancel restore, and the failed-turn driver case are all green in isolation.
- [ ] `uv run pytest` green, `app/` coverage >90%, ruff + pyright clean.
- [ ] Phase directory moved to `.agents/phases/complete/170_edit_bubble_sizing/` and one atomic `--no-gpg-sign` Conventional Commit exists (verify with `git log -1 --stat`).
@@ -0,0 +1,46 @@
# Phase 171 — Admin settings page: row rhythm, distinct group bands, a properly disabled Save
**Source:** `TODO.md` L4 — "In the admin settings page: The search settings text box overlaps settings table. The settings category headers are not differentiated from the settings themselves, making them confusing at a glance. The save settings and clear all overrides are smushed right up against the settings table. The save settings shows a loading cursor when moused over rather than just being disabled."
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-10)
**Context:** `frontend/index.html` — `#view-settings` (L1499), `#settings-content` (L1535, a plain block — its children `#settings-disabled`, `#settings-status`, `#settings-error`, `.settings-search` (L1562), the `.table-wrap` card (L1576, `#settings-tbody` created by settings.js), and `.settings-actions` (L1607: `#settings-save` disabled by default + `#settings-clear-all` + `#settings-dirty`) stack with **zero vertical rhythm** — the root of the "overlap" and the "smushed" complaints), `frontend/assets/settings.js` (637 lines — the group-row render L319–335: `tr.settings-group-row` + `th[colspan=4][scope=colgroup]` with the group name, the client-side search filter, the §7.4 Save lifecycle), `frontend/assets/styles.css` — `.settings-shell` (L4660–4665: `display:flex; flex-direction:column; gap: 1.25rem;`), `.settings-group-row th` (L4684–4690: `background: var(--surface)` — the **same** fill as the `.table-wrap` card (L2825–2831) → the band is effectively invisible, the "not differentiated" complaint), the sticky-th tint rule `.table-wrap table thead th { background: var(--brand-soft); }` (L2841–2848, the solid-tint precedent), `.settings-search` (L4780–4786), `.settings-actions` (L4804–4809), `#settings-save` (L4810–4824, incl. `:disabled { opacity: 0.6; cursor: wait; }` L4824 — the loading-cursor complaint), `.settings-reset:disabled { opacity: 0.5; cursor: default; }` (L4775 — the house idiom), the mobile media queries (L6396–6400: `.settings-search` / `.settings-actions` `flex-direction: column`, `#settings-save` `width: 100%`), `tests/unit/test_settings_frontend_view.py` (the phase-168 static pins, incl. the L561 media-query pin), `tests/e2e/test_admin_settings_panel.py` (the phase-168 suite — the regression set).
## Objective
The four layout complaints on the Settings page — the search box overlapping the table card, the group headers indistinguishable from the rows, the Save / Clear row smushed against the table, and the disabled Save showing a loading cursor — are fixed with **CSS only** (plus the pin updates): one rhythm rule, one band rule, one cursor value. The phase-168 contract (the gate, the catalogue, the §7.4 Save lifecycle, the API, the bounded scroll + sticky header) stays behavior-identical — no `app/` code, no JS logic change.
## Design (shared by all tasks — the executor reads this, not the chat)
- **D1 — the rhythm (task 01).** `#settings-content` becomes `display: flex; flex-direction: column; gap: 1.25rem;` — the **same** token `.settings-shell` already uses (L4664). The children (the disabled banner, the status line, the error line, the search row, the `.table-wrap` card, the `.settings-actions` row) then stack with one uniform gap: the search box clears the table card (the "overlap" dies) and the Save / Clear row is separated from the table (the "smushed" dies) in the same change. The `min-height` rules on `#settings-status` / `#settings-error` (L4849–4862) already reserve their lines — the gap is additive and uniform, no per-element margins are introduced.
- **D2 — the band (task 02).** `.settings-group-row th` gets a band that is distinct from the row cells at a glance: `background: var(--brand-soft)` — the **same solid tint** the sticky `thead` rule already uses (L2847; B3: no new palette variable), `font-weight: 700` (the 0.82rem / uppercase / letter-spacing stay), and `border-top: 1px solid var(--line)` so a band never merges with the card's top edge or a previous band. The row cells stay on the card's `--surface`. The group text stays `--ink` on `--brand-soft` — the pairing the sticky-th rule already documents. Static, text-only, no animation (nothing for `prefers-reduced-motion` to still).
- **D3 — the cursor (task 03).** `#settings-save:disabled` → `cursor: default` — the `.settings-reset:disabled` idiom (L4775); "just disabled", per the owner. The §7.4 in-flight state is the **dimmed** `:disabled` (`opacity: 0.6` stays) + `aria-busy` — the `cursor: wait` was never part of the contract; it was the "loading cursor on hover" the owner saw.
- **NOT touched:** `settings.js` (the render / filter / save / reset / clear-all logic), `app/api/admin_settings.py`, the sign-in gate, the nav reveal, the `.table-wrap` bounded-scroll + sticky-th contract (L2825–2848), the `#settings-status` / `#settings-error` regions, the `restart required` flag, the mobile media queries (L6396–6400 — they compose with the new gap and stay byte-identical), `app/` server code.
## Dependencies
- `168_admin_settings_panel` (in flight — the code + the E2E suite are committed; only its directory move + final commit are pending) — the Settings view this phase polishes. Note: the abandoned `169` settings status card never landed — there is nothing to coordinate with.
## Tasks
1. `01_content_vertical_rhythm.md` — `#settings-content` becomes a flex column with the shell's `1.25rem` gap (fixes the search/table overlap **and** the smushed action row); the pin added.
2. `02_group_band_distinction.md` — `.settings-group-row th` gets the `--brand-soft` band + 700 weight + top hairline, so groups read at a glance.
3. `03_disabled_save_cursor.md` — `#settings-save:disabled` → `cursor: default` (the house idiom), killing the loading cursor.
4. `04_e2e_settings_layout.md` — `tests/e2e/test_admin_settings_layout.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 static pin set, extended in tasks 01/02/03): the `#settings-content` flex-column + gap rule, the band's `--brand-soft` background + weight + top border, `#settings-save:disabled` `cursor: default` (and `cursor: wait` absent from the settings rules), plus the existing pins (the no-CDN pin, the `router.js` mapping, the media-query pin L561) staying green.
- **E2E (AGENTS rule 4, task 04):** `tests/e2e/test_admin_settings_layout.py`, run **in isolation** — the four layout contracts, proven in a browser.
- **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
- [ ] The search box and the table card do not intersect; the Save / Clear row is visibly separated from the table (the E2E bounding-box pins, at 1440 px and 375 px).
- [ ] A settings group band is visually distinct from its rows (computed background ≠ the row cells' background; weight 700) and no band merges with the card edge.
- [ ] A disabled **Save settings** shows `cursor: default` (not `wait`); the §7.4 dimmed-`aria-busy` in-flight state is intact.
- [ ] `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_admin_settings_layout.py -v --no-cov` green **in isolation**; `test_admin_settings_panel.py` (phase 168) + `test_wide_desktop_column.py` green in isolation.
- [ ] One atomic `--no-gpg-sign` Conventional Commit; the phase dir moves to `.agents/phases/complete/`.
## Locked decisions (owner-confirmed 2026-10-10, roadmap confirmation)
- **A3 — the band tint is `--brand-soft`** (the solid tint the sticky `thead` already uses — B3: no new palette variable) + a `--line` top hairline + weight 700; static, no animation.
- **A4 — the rhythm reuses the shell's existing `gap: 1.25rem`** — no new spacing value; the existing mobile media queries (L6396–6400) keep working unchanged.
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(settings): settings page layout — row rhythm, distinct group bands, a properly disabled Save"
```
@@ -0,0 +1,31 @@
# Task 01 — `#settings-content` gets the shell's vertical rhythm (overlap + smushing die)
**Phase:** `171_admin_settings_layout` · **Source:** `TODO.md:4` — "The search settings text box overlaps settings table. … The save settings and clear all overrides are smushed right up against the settings table."
**Story:** n/a
## Objective
The `#settings-content` block becomes a flex column with the shell's existing `gap: 1.25rem`, so its children — the disabled banner, the status line, the error line, the search row, the `.table-wrap` card, the `.settings-actions` row — stack with one uniform gap. One change kills both complaints: the search box clears the table card (the "overlap") and the Save / Clear row separates from the table (the "smushed").
## Work
1. `frontend/assets/styles.css` — add ONE rule (place it directly after the `.settings-shell` rule, L4660–4665, with a comment citing the phase-171 task + TODO L4):
```css
#settings-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
```
No per-element margins anywhere; the `min-height` rules on `#settings-status` / `#settings-error` (L4849–4862) stay (they reserve the lines; the gap is additive and uniform). Verify by reading, not by adding: `.settings-search` (L4780–4786) and `.settings-actions` (L4804–4809) get **no** new margins — the gap is the only spacing source.
2. `tests/unit/test_settings_frontend_view.py` — add the house-style CSS pin: the `#settings-content` rule exists with `display: flex`, `flex-direction: column`, and `gap: 1.25rem` (the same pin family as the L561 media-query pin — parse `styles.css`, assert the three declarations in one rule block).
- ASSUMPTION: A4 (phase-locked) — the rhythm reuses the shell's existing `gap: 1.25rem` token; no new spacing value; the existing mobile media queries (styles.css L6396–6400: `.settings-search` / `.settings-actions` `flex-direction: column; align-items: stretch;` + `#settings-save { width: 100%; }`) keep working unchanged.
## Testing & Quality
- Unit: `tests/unit/test_settings_frontend_view.py` green (`uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov`).
- Coverage: **>90%** on `app/` (unchanged code — the gate must stay green).
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] In the live UI (or the task-04 E2E): the search input's box and the `.table-wrap` card's box do not touch — a visible, uniform gap between them, and the same gap above the `.settings-actions` row.
- [ ] `uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov` green; `uv run pytest` green; ruff + pyright clean.
- [ ] No per-element margin was added (the diff touches exactly one CSS rule + the pin); the mobile stacking (L6396–6400) is byte-unchanged.
@@ -0,0 +1,28 @@
# Task 02 — the group bands are distinct from the rows at a glance
**Phase:** `171_admin_settings_layout` · **Source:** `TODO.md:4` — "The settings category headers are not differentiated from the settings themselves, making them confusing at a glance."
**Story:** n/a
## Objective
`.settings-group-row th` — today `background: var(--surface)`, the **same** fill as the `.table-wrap` card, i.e. invisible — becomes a band that reads as a category: the `--brand-soft` tint (the solid tint the sticky `thead` rule already uses), weight 700, and a `--line` top hairline so a band never merges with the card edge or a previous band.
## Work
1. `frontend/assets/styles.css` — `.settings-group-row th` (L4684–4690), three value changes in the existing rule (no new selector):
- `background: var(--surface);` → `background: var(--brand-soft);` (B3: no new palette variable — the exact tint `.table-wrap table thead th` already documents at L2841–2848);
- add `font-weight: 700;` (the `font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.04em;` stay);
- add `border-top: 1px solid var(--line);` (the rule's existing `border-bottom` from the `.settings-table th` family, L4676–4681, stays — a band is delimited above AND below, and can never merge with the card's top edge or the band before it);
- update the contrast comment (L4683): the text stays `--ink` on `--brand-soft` — the pairing the sticky-th rule already documents (the existing "15.3:1, AA" comment applies to `--surface`; recompute / restate the ratio on `--brand-soft` in the comment — the `:root` `--brand-soft` token carries its own documented pairing).
2. `tests/unit/test_settings_frontend_view.py` — add the pin: the `.settings-group-row th` rule carries `background: var(--brand-soft)`, `font-weight: 700`, and `border-top: 1px solid var(--line)` (same pin family as task 01's).
- ASSUMPTION: A3 (phase-locked) — the band = `--brand-soft` (the sticky-th tint, no new palette variable) + `--line` top hairline + weight 700; static, text-only, no animation (nothing for `prefers-reduced-motion` to still).
## Testing & Quality
- Unit: `tests/unit/test_settings_frontend_view.py` green.
- Coverage: **>90%** on `app/` (unchanged code).
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] In the live UI (or the task-04 E2E): each group band is visibly tinted (`--brand-soft`), bold, and hairline-delimited above; the row cells stay on the card's `--surface` — the two are distinguishable at a glance.
- [ ] No band merges with the card's top edge or the band above it (the top hairline is painted).
- [ ] `uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov` green; ruff + pyright clean.
- [ ] `settings.js` is byte-unchanged (the band is pure CSS — the group-row render L319–335 is untouched).
@@ -0,0 +1,23 @@
# Task 03 — a disabled Save settings is just disabled (no loading cursor)
**Phase:** `171_admin_settings_layout` · **Source:** `TODO.md:4` — "The save settings shows a loading cursor when moused over rather than just being disabled."
**Story:** n/a
## Objective
`#settings-save:disabled` stops painting `cursor: wait` (the loading cursor the owner saw on hover) and takes `cursor: default` — the house idiom `.settings-reset:disabled` already uses. "Just disabled": the §7.4 in-flight state stays the **dimmed** `:disabled` (`opacity: 0.6`) + `aria-busy`; the cursor was never part of the contract.
## Work
1. `frontend/assets/styles.css` L4824 — `#settings-save:disabled { opacity: 0.6; cursor: wait; }` → `#settings-save:disabled { opacity: 0.6; cursor: default; }`. Keep `opacity: 0.6` (the §7.4 dim) and the `#settings-save:hover:not(:disabled)` rule (L4823) byte-unchanged. Update the adjacent comment block (L4809–4810, "the §7.4 in-flight state is the dimmed :disabled + aria-busy") to state the cursor contract explicitly: `cursor: default` when disabled (the `.settings-reset:disabled` L4775 idiom) — the wait cursor was never part of §7.4.
2. `tests/unit/test_settings_frontend_view.py` — add the pin: `#settings-save:disabled` carries `cursor: default`, and `cursor: wait` is **absent** from the settings-page rules (a negative grep over the `.settings*` / `#settings-*` rule blocks — the house negative-pin idiom). Note: `.edit-save:disabled` (styles.css L1454) keeps its own `cursor: wait` — it is the chat edit form's in-flight state (phase 159/170), a different control; the negative pin is scoped to the settings rules only, and the test must not fail on the `.edit-save` rule.
- ASSUMPTION: none — the owner's words fix the target ("just being disabled"); the idiom choice (`default`, mirroring L4775) is the house convention, not a guess.
## Testing & Quality
- Unit: `tests/unit/test_settings_frontend_view.py` green.
- Coverage: **>90%** on `app/` (unchanged code).
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] In the live UI (or the task-04 E2E): an untouched **Save settings** (no dirty rows) is `disabled` and its computed `cursor` is `default` — hovering shows no wait/loading cursor; after a row is edited the button enables and its cursor is `pointer` again (the §7.4 lifecycle, phase 168, intact).
- [ ] `uv run pytest tests/unit/test_settings_frontend_view.py -v --no-cov` green; ruff + pyright clean.
- [ ] The diff is exactly one CSS value + one comment + the pin; `settings.js` and `app/` are byte-unchanged.
@@ -0,0 +1,43 @@
# Task 04 — `tests/e2e/test_admin_settings_layout.py`: the four layout contracts, proven in a browser
**Phase:** `171_admin_settings_layout` · **Source:** `TODO.md:4` — "In the admin settings page: The search settings text box overlaps settings table. The settings category headers are not differentiated … The save settings and clear all overrides are smushed right up against the settings table. The save settings shows a loading cursor when moused over rather than just being disabled."
**Story:** n/a
## Objective
The phase's dedicated Playwright suite (AGENTS rule 4/9): the admin signs in, and the four layout contracts hold in a real browser — the search row clears the table card, the action row is gapped from the table, the group bands are distinct, and a disabled Save is just disabled. Then the regression set, the full gates, and the one atomic commit.
## Work
1. `tests/e2e/test_admin_settings_layout.py` (new) — copy the module-scoped app fixture from `tests/e2e/test_admin_settings_panel.py` verbatim (the mock LLM, `BOR_ENVIRONMENT=e2e`, `BOR_STATIC_DIR`, the E2E admin password/secret, the leak guards, the module's `BOR_SETTINGS_DB=1` opt-in + the `TRUNCATE app_settings` before/after — this suite never writes overrides, so the truncation is belt-and-braces, the same idiom) and change exactly: the module docstring (what the suite proves + the Source line `TODO.md:4` — the four layout contracts) and the test list below.
2. Tests (names pinned; `login(page, app_url)` from `tests/e2e/auth_helpers.py`; the suite drives the admin gate exactly like the phase-168 suite):
- `test_search_row_is_clear_of_the_table` — at 1440 px and 375 px: `#settings-filter`'s `bounding_box()` and the `.table-wrap` card's box do not intersect (the vertical gap between the search row's bottom and the card's top is > 0), and the card keeps its bounded scroll + sticky `thead` (the computed `position: sticky` on the `thead th` — the phase-156/168 contract intact under the new rhythm).
- `test_actions_row_is_gapped_from_the_table` — at 1440 px and 375 px: `.settings-actions`'s box does not intersect the `.table-wrap` card's box (the "smushed" pin — the gap between the card's bottom and the action row's top is > 0); `#settings-save` and `#settings-clear-all` are both reachable (no clipping at either viewport).
- `test_group_bands_are_distinct_from_the_rows` — after `login()`: at least the `Retrieval & honesty` and `LLM (aipi)` `tr.settings-group-row` bands render; a band's computed `background-color` ≠ the `.table-wrap` card's computed background (the `--surface` row-cell fill) — the "not differentiated" pin — and the band's computed `font-weight` is ≥ 700; a band's computed `border-top-width` is ≥ 1 px (the hairline — no merge with the card edge or the previous band).
- `test_disabled_save_is_just_disabled` — before any edit: `#settings-save` is `disabled` and its computed `cursor` is `default` (NOT `wait` — the loading-cursor pin); edit one row (a `type=search` filter + a value change, the phase-168 dirty-row flow): the button enables (computed `cursor` `pointer`) and dims back to `default` after a **Save settings** settles (the §7.4 lifecycle, phase 168, intact under the new cursor).
- `test_settings_page_layout_and_accessibility` — no horizontal page overflow at 375 px and 1440 px (§7.1: the table is ≥80 % of the container, not a skinny list); keyboard tab order still reaches filter → first row input → **Save settings** → **Clear all overrides** (a `page.keyboard` walk asserting focus lands on the real controls — the new rhythm must not have broken the phase-168 keyboard contract); zero external requests during the whole test (`page.on("request")` — rule 6).
3. **Regression set, all in isolation** (`--no-cov`): `test_admin_settings_panel.py` (the phase-168 suite — the gate, the save-live contract, the masking, the reset / clear-all, now under the new layout), `test_wide_desktop_column.py` (the 72rem container contract, the Settings view included).
4. **Full gates** (AGENTS rule 9, all green before the commit):
```bash
uv run pytest
uv run pytest --cov=app --cov-report=term-missing # >90% on app/
uv run pytest tests/e2e/test_admin_settings_layout.py -v --no-cov
uv run ruff check . && uv run pyright
```
5. **Docs & close-out:** confirm `README.md`'s Settings page section (phase 168's addition) needs no layout wording change (it describes the contract, not the pixels — grep `Settings` there); move the phase directory:
```bash
git mv .agents/phases/todo/171_admin_settings_layout .agents/phases/complete/171_admin_settings_layout
```
and commit (AGENTS rule 8, `--no-gpg-sign`):
```bash
git add frontend/ tests/ README.md .agents/phases/ && git commit --no-gpg-sign -m "fix(settings): settings page layout — row rhythm, distinct group bands, a properly disabled Save"
```
## Testing & Quality
- The suite passes **in isolation** (`-v --no-cov`) and leaves `app_settings` empty (the `TRUNCATE` idiom — a leftover row is the cross-suite poison phase-168 D6 exists to prevent).
- The pins already exist from tasks 01/02/03 — this file adds the browser contract only; if it needs a behavior the CSS lacks, fix the CSS in this task and say so in the commit body.
- Coverage: **>90%** on `app/` after everything (the `--cov` runs exclude E2E by the repo's config; the gate is the unit+integration number).
## Completion Criteria
- [ ] All four layout pins are green in isolation: search clear of the table, action row gapped, bands distinct (tint + weight + hairline), disabled Save `cursor: default`.
- [ ] `app_settings` is empty after the suite and the phase-168 regression suite is green in isolation.
- [ ] `uv run pytest` green, `app/` coverage >90%, ruff + pyright clean.
- [ ] Phase directory moved to `.agents/phases/complete/171_admin_settings_layout/` and one atomic `--no-gpg-sign` Conventional Commit exists (verify with `git log -1 --stat`).