# Task 01 — Sticky Bottom Composer **Phase:** `52_pinned_composer` · **Source:** `TODO.md:3` — "The message input text box needs to be pinned to the bottom of the screen so it doesn't \"run away\" from the user as they try to click \"stop\"" **Story:** n/a (TODO-derived) ## Objective The composer stays at the bottom of the screen — resting there on a short page and pinned there at every scroll position of an over-viewport page — with CSS-only changes inside the existing chat column. ## Revision (owner, 2026-08-30) — this task was NOT done the first time The first pass applied only `position: sticky; bottom: env(safe-area-inset-` `bottom)` to `.composer`, and the browser disproved it: on an empty/short chat the input rested just under the empty state (~57% of the viewport) with a dead band all the way down to the footer. `position: sticky` can only pull a box UP to the scrollport's bottom edge; it never pushes a box DOWN to meet it, so on a page that does not overflow it does nothing — the old E2E contract only ever exercised an overflowing conversation, which is why the half-fix passed. Both rules below are now in place and both are source-pinned in `tests/unit/test_pinned_composer.py`. ## Work 1. `frontend/assets/styles.css` — TWO rules, both required: a. `.messages { flex: 1 1 auto; }` (Main-frame block) — the message list absorbs the free space of a short page, so the composer's resting in-flow position is the bottom of the full-height column (`body{min-height:100dvh}` → `.app-main{flex:1}` → `.chat-shell{flex:1}`). `flex-basis` stays `auto` (a `0` basis would size the list below its content once the conversation overflows and let bubbles overlap the box); no `height` cap, no `overflow` — the document stays the scroller. b. `.composer { position: sticky; bottom: env(safe-area-inset-bottom, 0); }` (`/* ---------- Composer ---------- */` block, ~L1133) — takes over the moment the conversation overflows. The page scrolls at the document level and `.chat-shell` is the composer's containing column, so the box sticks to the viewport's bottom edge (offset by the mobile safe-area inset) while `.messages` scrolls behind it; at the document bottom it settles back into its normal flow position above the footer. Keep the existing solid `background: var(--surface)`, border, radius and `box-shadow: var(--shadow)` — messages must never show through the pinned box. 2. `frontend/index.html` — verify NO change needed: the composer is already the LAST child of `.chat-shell` (the sticky context), and the `#message-input` / `#send-btn` / `#send-status` markup is untouched. 3. Do NOT touch `frontend/assets/app.js` — the pin must not add any scroll call site (phase-42 invariant; the one page scroll in the file stays `scrollReveal`). 4. `tests/unit/test_pinned_composer.py` (new, house pin style — see `tests/unit/test_frontend_scroll.py`): assert `styles.css` declares `position: sticky` AND a `bottom:` offset on `.composer` (the sticky-bottom pair, matched inside the `.composer` rule); assert `app.js` is unchanged in its scroll surface (the phase-42 single-`scrollReveal` pin still holds — reuse the same assertion approach `test_no_reply_autoscroll.py`'s companion pins use). - ASSUMPTION (revised): `bottom: env(safe-area-inset-bottom, 0)` — the notch-aware inset with an explicit `0` fallback; `env()`-only (the first pass) degrades to `auto`, i.e. no pin, where the function is unsupported. - ASSUMPTION: no `z-index` added — DOM order already stacks the composer above `.messages`; the sticky header (z 20) and doc modal (z 1000) are unaffected. ## Testing & Quality - Unit: `tests/unit/test_pinned_composer.py` (the pins above) — `uv run pytest tests/unit/test_pinned_composer.py -v` green. - Coverage: **>90%** — no `app/` change; the gate stays green. ## Completion Criteria - [x] `.messages` carries the `flex-grow` and `.composer` carries `position: sticky` + the `bottom` safe-area offset with the `0` fallback; the `frontend/` diff contains no JS and no DOM change. - [x] `uv run pytest` green (1019 passed, TOTAL 99%); `uv run ruff check . && uv run pyright` clean.