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
+29 -2
View File
@@ -214,12 +214,39 @@ def test_turn_end_focus_does_not_scroll() -> None:
"""The turn-end focus-back (phase 06's "always focus back") must not
move the viewport: focusing the composer while the user is scrolled
up would yank them to the bottom at the moment the turn ends.
preventScroll keeps the keyboard flow without the scroll."""
preventScroll keeps the keyboard flow without the scroll.
Phase 143 (TODO L5): the focus-back is now GATED on pointer type —
`if (!COARSE_POINTER) input.focus({ preventScroll: true })`. On a
coarse pointer (phone/tablet) focusing the composer pops the
virtual keyboard over the completed answer, so the composer stays
unfocused there (the ≥44px tap target starts the next question).
This pin's job: the focus-back exists, is preventScroll (the
phase-06/42 contract, byte-preserved on desktop), and is
coarse-gated — a bare ungated `input.focus(...)` line in the
finally block is a failure, as a missing or mistyped
`COARSE_POINTER` constant is."""
js = _js()
# Phase 48: the settle line gained the user-stop terminal (stop →
# idle, no banner) — the focus-back contract is unchanged.
finally_idx = js.find("// done | error | stop → idle: always settle, always focus back")
assert finally_idx != -1, "the turn's finally block must exist"
block = js[finally_idx : js.find("\n}", finally_idx)]
assert 'input.focus({ preventScroll: true })' in block
# Phase 143: the gated shape — the focus call exists, keeps
# preventScroll, and is wrapped in the coarse-pointer gate.
assert "if (!COARSE_POINTER) input.focus({ preventScroll: true })" in block
# A bare, ungated focus line (the pre-phase-143 shape) is gone:
# every focus call in the block rides the gate.
focus_lines = [ln for ln in block.splitlines() if "input.focus(" in ln]
assert focus_lines, "the turn-end focus-back must still exist"
assert all("if (!COARSE_POINTER)" in ln for ln in focus_lines), (
"the turn-end focus-back must be coarse-pointer gated (phase 143)"
)
assert "input.focus()" not in block
# The L391-twin constant: the same typeof-guarded matchMedia shape
# as reducedMotion, bound to COARSE_POINTER.
assert re.search(
r'const COARSE_POINTER\s*=\s*typeof matchMedia === "function"'
r' && matchMedia\("\(pointer: coarse\)"\)\.matches;',
js,
)