fix(chat): rest the composer at the viewport bottom — sticky alone left it mid-screen
Phase 52's first pass shipped `position: sticky; bottom` on `.composer` and
called the phase done, but the owner's requirement — "the chat message-input
textarea should be at the bottom of the screen" — still failed in the browser:
on an empty/short chat the input rested just under the empty state (~57% of
the viewport) with a dead band down to the footer.
`position: sticky` can only pull a box UP toward the scrollport's bottom edge;
it can never push a box DOWN to meet it, so on a page that does not overflow
it is a no-op. The old story suite only exercised an overflowing conversation
(one test even asserted the buggy resting position as expected), which is why
the half-fix passed.
- `.messages { flex: 1 1 auto }` — absorbs a short page's free space 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); basis
stays `auto`, no height cap, no overflow — the document stays the scroller
- `.composer { bottom: env(safe-area-inset-bottom, 0) }` — the explicit 0
fallback replaces the env()-only offset, which degraded to `auto` (no pin)
wherever env() is unsupported
- E2E: `test_empty_chat_composer_sits_in_normal_flow` ->
`..._at_the_screen_bottom` (chrome-only band below the resting composer);
the phone suite now checks the resting position as well as the pinned one
- Unit pins: the flex-grow half and the full-height column are pinned, so the
fix cannot silently regress to sticky-only
Still CSS-only — no DOM change, no JS, no new scroll call site (phase 42
never-auto-scroll contract intact), no z-index.
Verified: 1019 unit/integration tests pass (app/ coverage 99%), ruff and
pyright clean; tests/e2e/test_pinned_composer.py green in isolation (4), plus
the stop/autoscroll/persistence/mobile-nav suites and 14 layout/scroll
neighbours green in isolation.
This commit is contained in:
@@ -4,14 +4,37 @@
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The composer stays pinned to the bottom of the viewport at every scroll position — a CSS-only change inside the existing chat column.
|
||||
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` — in the `/* ---------- Composer ---------- */` block (`.composer`, ~L1133): add `position: sticky;` and `bottom: env(safe-area-inset-bottom);` to `.composer`. 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.
|
||||
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: `bottom: env(safe-area-inset-bottom)` (not `bottom: 0` + extra padding) — the standard notch-aware inset; on desktop `env()` resolves to 0, so the box sits flush with the viewport bottom.
|
||||
- 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
|
||||
@@ -19,5 +42,5 @@ The composer stays pinned to the bottom of the viewport at every scroll position
|
||||
- Coverage: **>90%** — no `app/` change; the gate stays green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `.composer` in `styles.css` carries `position: sticky` + the `bottom` safe-area offset; the `frontend/` diff contains no JS change.
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [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.
|
||||
|
||||
Reference in New Issue
Block a user