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:
@@ -1,19 +1,32 @@
|
||||
"""Unit: the pinned (sticky-bottom) composer in the static frontend
|
||||
(phase 52, owner direction 2026-08-30, TODO.md L3 — "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'").
|
||||
"""Unit: the pinned composer in the static frontend (phase 52, owner
|
||||
direction 2026-08-30, TODO.md L3 — "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'"; owner revision the same day — the box had to
|
||||
be at the bottom of the screen on an EMPTY chat too, which sticky alone
|
||||
cannot do).
|
||||
|
||||
The chat page scrolls at the DOCUMENT level and `.chat-shell` (the
|
||||
centered 46rem column, PLAN §7.1) is the composer's sticky containing
|
||||
block, so `position: sticky; bottom: env(safe-area-inset-bottom)` on
|
||||
`.composer` pins the box to the viewport's bottom edge at every scroll
|
||||
position and lets it settle back into its normal flow position (above
|
||||
the footer) once the document bottom is reached. The pin is CSS-only:
|
||||
block. The pin is therefore TWO rules, and both are pinned here:
|
||||
|
||||
* `.messages { flex: 1 1 auto }` absorbs the free space of a short page,
|
||||
so the composer's RESTING (in-flow) position is already the bottom of
|
||||
the full-height column — `position: sticky` can only pull a box UP
|
||||
toward the viewport's bottom edge, it can never push one DOWN to meet
|
||||
it, so without the grow the empty/short chat left the input mid-screen
|
||||
with a dead band under it (the reported bug);
|
||||
* `.composer { position: sticky; bottom: env(safe-area-inset-bottom, 0) }`
|
||||
takes over the moment the conversation overflows the viewport and keeps
|
||||
the box — and the Stop control inside it — glued to the viewport's
|
||||
bottom edge at every scroll position, settling back into flow (above
|
||||
the footer) once the document bottom is reached;
|
||||
|
||||
The pin is CSS-only:
|
||||
|
||||
* `.composer` carries the sticky pair (``position: sticky`` + the
|
||||
notch-aware ``bottom`` inset) and keeps its solid ``--surface``
|
||||
background, border, radius and shadow — a reply scrolling behind the
|
||||
pinned box must never show through it;
|
||||
notch-aware ``bottom`` inset, with the explicit ``0`` fallback) and
|
||||
keeps its solid ``--surface`` background, border, radius and shadow — a
|
||||
reply scrolling behind the pinned box must never show through it;
|
||||
* NO ``z-index`` is added to the composer (it already paints above
|
||||
``.messages`` by DOM order, never overlaps the sticky header (z 20)
|
||||
and stays under the z-1000 document modal), and the pin is not undone
|
||||
@@ -24,6 +37,12 @@ the footer) once the document bottom is reached. The pin is CSS-only:
|
||||
* ``index.html`` needs no change: the composer is already the LAST child
|
||||
of `.chat-shell` (the sticky shift range is that column's box) and the
|
||||
`#message-input` / `#send-btn` / `#send-status` markup is untouched;
|
||||
* the sticky context survives: `.chat-shell` / `.app-main` / `.messages`
|
||||
gain no ``overflow`` and `.messages` gains no inner scroller — the page
|
||||
keeps scrolling at the document level (the phase-42 model);
|
||||
* ``index.html`` needs no change: the composer is already the LAST child
|
||||
of `.chat-shell` (the sticky shift range is that column's box) and the
|
||||
`#message-input` / `#send-btn` / `#send-status` markup is untouched;
|
||||
* ``app.js`` gains NO page-scroll call site — the phase-42 invariant
|
||||
(``scrollReveal`` is still the single ``window.scrollTo``; no
|
||||
``scrollIntoView``, no ``window.scrollY``) is re-pinned here so the
|
||||
@@ -150,22 +169,55 @@ def _tree() -> _Tree:
|
||||
def test_composer_is_sticky_bottom() -> None:
|
||||
"""`.composer` carries the sticky pair — `position: sticky` AND a
|
||||
`bottom` offset — inside its own rule. `bottom` is the notch-aware
|
||||
safe-area inset (on desktop `env()` resolves to 0, so the box sits
|
||||
flush with the viewport bottom; on a notched phone the composer
|
||||
clears the home indicator instead of hiding under it)."""
|
||||
safe-area inset with an explicit `0` fallback (it resolves to 0 on a
|
||||
desktop viewport, so the box sits flush with the viewport bottom; on a
|
||||
notched phone the composer clears the home indicator instead of hiding
|
||||
under it — and a browser without `env()` support still gets 0)."""
|
||||
body = _rule(_css(), ".composer")
|
||||
assert "position: sticky;" in body, (
|
||||
"the composer must be sticky — phase 52 pins it to the viewport "
|
||||
"bottom so Stop is reachable without scrolling (TODO.md L3)"
|
||||
)
|
||||
assert "bottom: env(safe-area-inset-bottom);" in body, (
|
||||
"the sticky offset must be the safe-area inset (the phase-07 "
|
||||
"mobile contract: the composer stays reachable around the notch)"
|
||||
assert "bottom: env(safe-area-inset-bottom, 0);" in body, (
|
||||
"the sticky offset must be the safe-area inset with a 0 fallback "
|
||||
"(the phase-07 mobile contract: the composer stays reachable "
|
||||
"around the notch, and never falls back to `auto` = no pin)"
|
||||
)
|
||||
# `top` would pin it to the wrong edge (and fight the sticky header).
|
||||
assert "top:" not in body, "the composer pins the BOTTOM edge only"
|
||||
|
||||
|
||||
def test_message_list_absorbs_the_short_page_space() -> None:
|
||||
"""The other half of the pin — and the half phase 52 originally missed.
|
||||
|
||||
`position: sticky` never pushes a box DOWN to the viewport's bottom
|
||||
edge, so on an empty/short chat (no free space absorbed) the composer
|
||||
sat right under the empty state, mid-screen. `.messages` must GROW to
|
||||
take up that space, which puts the composer's resting position at the
|
||||
bottom of the full-height column the page already builds
|
||||
(`body{min-height:100dvh}` → `.app-main{flex:1}` → `.chat-shell{flex:1}`).
|
||||
"""
|
||||
messages = _rule(_css(), ".messages")
|
||||
assert re.search(r"flex:\s*1(\s+1\s+auto)?;", messages), (
|
||||
"the message list must grow to fill the short page — otherwise the "
|
||||
"pinned composer only works once the conversation overflows and the "
|
||||
"empty chat leaves the input mid-screen (owner revision 2026-08-30)"
|
||||
)
|
||||
# flex-basis must stay `auto`: a `0` basis would size the list BELOW its
|
||||
# content when the conversation overflows and let bubbles overlap the box.
|
||||
assert not re.search(r"flex:\s*1\s+1\s+0", messages), (
|
||||
"flex-basis must stay auto — the list keeps its content height when "
|
||||
"the page overflows (no free space to absorb there anyway)"
|
||||
)
|
||||
# The column the composer sits in must keep stretching to the viewport.
|
||||
for selector in (".app-main", ".chat-shell"):
|
||||
frame = _rule(_css(), selector)
|
||||
assert re.search(r"flex:\s*1[;\s]", frame), (
|
||||
f"{selector} must keep growing to the viewport height — the "
|
||||
"composer can only rest at the bottom of a full-height column"
|
||||
)
|
||||
|
||||
|
||||
def test_composer_stays_opaque_behind_scrolled_messages() -> None:
|
||||
"""The pinned box overlaps the message list while the page is
|
||||
scrolled: it keeps its SOLID `--surface` background plus border,
|
||||
@@ -243,7 +295,8 @@ def test_document_level_scroll_is_untouched() -> None:
|
||||
messages = _rule(css, ".messages")
|
||||
assert not re.search(r"(?<!min-)\bheight:", messages), (
|
||||
"the message list must not become its own scroller (only the "
|
||||
"phase-01 `min-height` floor is allowed)"
|
||||
"phase-01 `min-height` floor is allowed) — `flex-grow` stretches it, "
|
||||
"a `height` cap would clip the conversation instead"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user