phase: 143_mobile_keyboard_after_answer
Build and Push Containers / build-and-push-app (push) Successful in 2m32s
Build and Push Containers / build-and-push-db (push) Successful in 13s

**Phase 143 final verification pass — 100% green, no code changes needed (all work verified in-tree)**

- **Verified implementation:** `COARSE_POINTER` constant (L395, L391-twin `typeof`-guarded shape) + gated finally-block focus `if (!COARSE_POINTER) input.focus({ preventScroll: true })` in `frontend/assets/app.js`; SETTLE lines/anchor untouched; extended unit pin in `test_frontend_scroll.py`; dedicated two-context E2E `tests/e2e/test_mobile_keyboard_focus.py`
- **C1 desktop byte-identical refocus:** ✅ unit pin passes (gated shape keeps `preventScroll`); desktop E2E asserts composer active; `test_loading_feedback.py` 5 passed (phase-06 regression)
- **C2 mobile keyboard stays down:** ✅ `test_mobile_turn_end_keeps_keyboard_down` passes — `pointer: coarse` sanity assert first, composer NOT active after done
- **C3 gates:** ✅ `uv run pytest --cov=app --cov-report=term-missing` → 3359 passed, `app/` TOTAL 99% (>90%); `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` → 2 passed in isolation; `uv run ruff check .` clean; `uv run pyright` 0 errors
- **C4 no regression:** ✅ full suite green (all frontend source pins: char counter, send flow, never-scroll); `test_stop_generation.py` 3 passed (single settle)
- **Docs:** README.md fully reviewed — all env vars cross-checked against `app/config.py` defaults, routes/endpoints/tools verified in code; AGENTS.md rules + quick reference verified against repo. Consistent — no changes needed
- **No live-infrastructure changes** (dev Postgres container already running)
- **Next pending phase:** `144_modal_back_button`
This commit is contained in:
2026-10-01 07:03:15 -04:00
parent aa65d2d691
commit ba4841a811
18 changed files with 817 additions and 6 deletions
@@ -0,0 +1,32 @@
# Phase 143 — the mobile keyboard stays down after an answer completes
**Source:** `TODO.md` L5 — "mobile keyboard pops back up after answer is complete and that's annoying"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `frontend/assets/app.js` — the turn's `finally` block (~L3478–3493): `// done | error | stop → idle: always settle, always focus back.` … `input.focus({ preventScroll: true })` (L3492) — EVERY turn end (done, error, stop) re-focuses the composer; on a phone, focusing the textarea pops the virtual keyboard the moment the answer completes (the owner's annoyance). The existing `matchMedia` precedent in the file (L391: `reducedMotion` for `prefers-reduced-motion` — the module-level-constant shape); `tests/unit/test_frontend_scroll.py` — `test_turn_end_focus_does_not_scroll` (L213+: the source-level pin that the finally block contains the `preventScroll` focus line — must be EXTENDED, not broken); the composer is a ≥44px tap target on mobile (PLAN §7.1).
**Locked (owner-confirmed 2026-09-30, chat):** desktop-only refocus — the post-turn focus-back is skipped on coarse-pointer devices (`matchMedia("(pointer: coarse)")`), kept on desktop so the next question can be typed immediately.
## Objective
A phone user reads the completed answer with the keyboard DOWN: the composer is only re-focused at turn end on fine-pointer (desktop) devices; a coarse-pointer device stays unfocused (the next question starts with a tap on the ≥44px composer). Desktop behavior — including the never-scroll focus contract — is byte-identical.
## Dependencies
- `142_kb_overview_freshness` (todo) — pipeline order only; no code dependency.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The gate (task 01):** a module-level constant beside the existing `reducedMotion` one (app.js ~L391): `const COARSE_POINTER = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;` (the L391 shape — the `typeof` guard keeps the constant safe where `matchMedia` is absent, the house style for the reduced-motion line). The finally block's focus line becomes GATED: `if (!COARSE_POINTER) input.focus({ preventScroll: true });` with a phase-143 comment explaining WHY (the keyboard pop) and the §7.1 tap-target note. The `preventScroll` argument and the never-scroll contract (phase 42 / the `test_turn_end_focus_does_not_scroll` intent) are UNCHANGED on desktop; the error/stop paths take the SAME gate (one focus line, one gate — the finally block is the single settle).
- **The pin (task 01):** `tests/unit/test_frontend_scroll.py::test_turn_end_focus_does_not_scroll` is EXTENDED: the finally block still contains the `preventScroll` focus call (now inside the `if (!COARSE_POINTER)` guard) and the `COARSE_POINTER` constant is defined from `matchMedia("(pointer: coarse)")` — the pin asserts the GATED shape (a bare ungated `input.focus(...)` line in the block is a failure, as today).
- **The E2E (task 02):** dedicated file, two contexts — desktop (default Chromium, no touch) and mobile (a Playwright device descriptor with `isMobile` + `hasTouch` — the descriptor's touch emulation makes Chromium report `pointer: coarse`). Each context: a mock-LLM turn to completion (the house E2E fixture — a single on-topic question, the done state, the same assertions as the chat story files), then assert `document.activeElement` — desktop: the composer's textarea (the phase-06 "always focus back" contract, preserved); mobile: NOT the composer (the keyboard stays down). A sanity assert in the mobile test: `page.evaluate(() => matchMedia("(pointer: coarse)").matches)` is true (the emulation is doing its job — if a future Playwright/Chromium change breaks the emulation, the test fails LOUDLY on the sanity line, not mysteriously on the focus line).
## Tasks
1. `01_coarse_pointer_focus_gate.md` — the constant + the gated focus line + the extended source pin.
2. `02_e2e_mobile_focus.md` — the dedicated two-context E2E.
## Testing & Quality
- Unit: the extended source-level pin (the gated focus shape; the constant definition); every other `app.js` pin passes unchanged.
- E2E: one dedicated Playwright file, run in isolation, mock-only (desktop + mobile contexts).
- Coverage: **>90%** on new/modified code (this phase is frontend — the `app/` coverage gate is unaffected and must stay green).
## Completion Criteria
- [ ] desktop: after a turn ends (done, error, stop), the composer is focused with `preventScroll` — byte-identical to pre-phase
- [ ] mobile (coarse pointer): after a turn ends, the composer is NOT focused — the keyboard stays down; the user's tap starts the next question
- [ ] `uv run pytest` green (the extended pin passes); `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the never-scroll focus contract on desktop, the stop path's single settle, the char counter, the send flow)
@@ -0,0 +1,38 @@
# Task 01 — the coarse-pointer gate on the post-turn focus-back
**Phase:** `143_mobile_keyboard_after_answer` · **Source:** `TODO.md:5` — "mobile keyboard pops back up after answer is complete and that's annoying"
**Story:** n/a
## Objective
The turn's `finally` block re-focuses the composer only on fine-pointer devices: a coarse-pointer (mobile) turn end leaves the page unfocused, so the virtual keyboard does not pop back up over the completed answer.
## Work
1. `frontend/assets/app.js` — beside the existing reduced-motion constant (~L391: `const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;`), add the twin constant: `const COARSE_POINTER = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;` — a short phase-143 comment: the coarse-pointer state of THIS device (touch phones/tablets), the keyboard-pop gate for the turn-end focus-back.
2. `frontend/assets/app.js` — the turn's `finally` block (~L3478–3493): the last line `input.focus({ preventScroll: true });` becomes:
```js
// Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a
// coarse pointer (a phone/tablet) focusing the composer pops the
// virtual keyboard over the completed answer, which is exactly what
// the owner found annoying. Fine pointers keep the immediate
// refocus (type the follow-up without moving a hand); a coarse
// pointer stays unfocused — the composer is a ≥44px tap target
// (PLAN §7.1), so the next question starts with a tap. The
// never-scroll contract (phase 42) and the single-settle rule are
// unchanged on every device — only the focus call is gated.
if (!COARSE_POINTER) input.focus({ preventScroll: true });
```
The comment above the line ("Focus back for the next question, but never move the viewport…") is folded into the phase-143 comment (one comment block, no stale duplicate). The block's SETTLE lines (clearTurnTimeout / stopThinkingClock / cancelStream / setUiState / the stop status line) are UNTOUCHED — the gate wraps the focus call alone.
3. `tests/unit/test_frontend_scroll.py` — EXTEND `test_turn_end_focus_does_not_scroll` (L213+):
- the finally block (located by the existing `// done | error | stop → idle: always settle, always focus back` anchor — KEEP the anchor comment in app.js verbatim or update the anchor in lockstep) now asserts the GATED shape: `if (!COARSE_POINTER) input.focus({ preventScroll: true })` is in the block, and a bare ungated `input.focus(` occurrence (the pre-phase line) is NOT,
- and the constant: `matchMedia("(pointer: coarse)")` appears in the file with the `COARSE_POINTER` binding (the L391-twin shape),
- the docstring is updated to state the phase-143 gate (the pin's job is now: focus-back exists, is preventScroll, and is coarse-gated).
## Testing & Quality
- Unit: the extended pin + every other frontend source pin green (`uv run pytest tests/unit/ -k frontend`).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green: `uv run pytest --cov=app`).
## Completion Criteria
- [ ] desktop (fine pointer): the turn end focuses the composer with `preventScroll` — the phase-06/42 contract byte-preserved
- [ ] coarse pointer: the turn end does NOT focus the composer (the keyboard stays down)
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the settle lines, the error/stop paths, the char counter, the send flow — the gate wraps the focus call alone)
@@ -0,0 +1,26 @@
# Task 02 — the dedicated two-context E2E: the keyboard stays down on mobile
**Phase:** `143_mobile_keyboard_after_answer` · **Source:** `TODO.md:5` — "mobile keyboard pops back up after answer is complete and that's annoying"
**Story:** n/a
## Objective
The browser-visible proof on BOTH devices: a completed turn leaves the composer focused on desktop (the preserved contract) and UNFOCUSED on a mobile-emulated context (the owner's fix) — deterministic, mock-only, in one dedicated file.
## Work
1. `tests/e2e/test_mobile_keyboard_focus.py` — dedicated Playwright file, run in isolation, mock-only (the house E2E conventions — the per-module fixture import, the login helper, the SSE/done-state waits from the chat story files):
- a small shared helper in the file: `turn_to_completion(page)` — asks one on-topic question (the fixture's marker question, the mock answers immediately), waits for the done state (the `aria-live` stream settled / the send button back to its idle contract — the same wait the chat story E2Es use).
- test 1 (`test_desktop_turn_end_refocuses_composer`): the DEFAULT Chromium context (no touch — `pointer: fine`): `turn_to_completion` → assert `page.evaluate("document.activeElement")` IS the composer's textarea (locate by the composer's selector — the `input` the app.js module owns; identify it via the existing selector the E2E helpers already use for typing questions). This is the regression guard: the phase-06 "always focus back" contract on desktop.
- test 2 (`test_mobile_turn_end_keeps_keyboard_down`): a MOBILE context — a Playwright device descriptor with `isMobile: true` + `hasTouch: true` (e.g. the built-in Pixel/iPhone descriptor, or an explicit `{ viewport, isMobile, hasTouch }` — the mobile ≤640px CSS breakpoint must also apply, so the descriptor's viewport is the deciding factor): FIRST a sanity assert `page.evaluate("matchMedia('(pointer: coarse)').matches")` is TRUE (the emulation flips the media feature — if a future Playwright/Chromium change breaks that, this line fails loudly and names why, instead of the focus assertion failing mysteriously), THEN `turn_to_completion` → assert `document.activeElement` is NOT the composer's textarea (the keyboard stays down — the owner's complaint is gone).
- both contexts run against the SAME fixture KB and the SAME question — the only variable is the device.
2. Run the regression set in isolation: this file + ONE chat story E2E that exercises the turn-end state (e.g. the phase-06 loading-feedback or the phase-48 stop-generation file — whichever asserts the post-turn UI) to prove the desktop contract survived.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate unaffected).
## Completion Criteria
- [ ] desktop context: after a completed turn, the composer is the active element
- [ ] mobile context: the `pointer: coarse` sanity assert passes AND after a completed turn the composer is NOT the active element
- [ ] `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` green in isolation; the chosen chat story E2E still green in isolation
- [ ] `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the E2E is additive; no app/frontend change in this task)