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:
2026-08-30 16:14:18 -04:00
parent 820753948e
commit aba8615177
7 changed files with 300 additions and 101 deletions
+77 -28
View File
@@ -22,13 +22,16 @@ observed in a real browser:
at all (the phase-42 never-auto-scroll contract — the pin adds no scroll
call site), and a reload restores the ``stopped`` record through the
normal ``bor.chat.v1`` path;
* on an empty/short chat the composer renders in its normal flow position
— the pin does not float it over the footer (``.app-footer`` stays in
flow below it), and at the document bottom the box has settled back into
flow above the footer instead of hovering on the viewport edge;
* ≤640px the pin holds too (the composer is reachable, ≥44px Send target)
and stays UNDER the sticky header — no z-index was added, so the pinned
box can never cover the header or the hamburger dropdown (phase 46).
* on an empty/short chat the composer renders in its RESTING position,
which the grow puts at the BOTTOM of the screen — the only band below it
is chrome (``.app-footer``, in normal flow, never overlapped), and at the
document bottom the box has settled back into that same flow slot above
the footer instead of hovering on the viewport edge;
* ≤640px the same two rules hold: the empty phone chat puts the input at
the bottom of the screen and an over-viewport chat keeps it there, still
UNDER the sticky header (no z-index was added, so the pinned box can
never cover the bar or the hamburger dropdown — phase 46 stacking).
Determinism: the mock's grounded answers quote the question and end in the
``Deterministic mock answer for E2E`` marker; the long answer (``write a
@@ -41,7 +44,7 @@ of conversation into the 800px viewport.
Test → story mapping (Playwright Mapping Rule):
1. ``test_composer_pinned_at_every_scroll_position``
2. ``test_stop_is_reachable_from_scrolled_up`` — the TODO.md L3 scenario
3. ``test_empty_chat_composer_sits_in_normal_flow``
3. ``test_empty_chat_composer_sits_at_the_screen_bottom``
4. ``test_pin_holds_on_mobile_under_the_header`` — ≤640px stacking
"""
from __future__ import annotations
@@ -100,6 +103,15 @@ FLUSH_PX = 4
#: The sticky header's band (phase 12 pins it at 64px) — the pinned
#: composer must never enter it.
HEADER_PX = 64
#: How much of the viewport may sit BELOW a resting composer as chrome:
#: the footer's own measured height plus the flow padding the settled slot
#: keeps (`main { padding-block: 1.25rem }` + the column's 1rem gap, with
#: headroom for the safe-area inset). Anything more is wasted dead space —
#: the empty chat must NOT leave the input hanging mid-screen.
BOTTOM_SLACK_PX = 56
#: The resting composer also has to be visibly in the lower part of the
#: screen — the pre-revision geometry rested at ~57% of the viewport here.
LOWER_PART = 0.8
# The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
@@ -459,43 +471,74 @@ def test_stop_is_reachable_from_scrolled_up(
# ---------------------------------------------------------------------------
# 3. Natural bottom: an empty/short chat keeps the composer in its flow
# position — the pin does not float it over the footer or shift the page
# 3. Natural bottom: an empty/short chat RESTS the composer at the bottom
# of the screen — no dead band under it, no float over the footer
# ---------------------------------------------------------------------------
def test_empty_chat_composer_sits_in_normal_flow(
def assert_rests_at_the_screen_bottom(page: Page) -> dict[str, float]:
"""The resting (in-flow) composer sits at the bottom of the screen.
This is the half `position: sticky` cannot deliver — sticky only pulls
a box UP toward the viewport bottom while the document overflows, so on
a page that does not scroll the box rested where the empty state ended
(mid-screen) until `.messages` gained `flex: 1 1 auto`. The band below
the box must be chrome only: the footer (measured live, so phones with
the stacked footer are covered) plus the flow padding of the settled
slot.
"""
ch = scroll_state(page)["ch"]
composer = composer_box(page)
bottom = composer["y"] + composer["height"]
footer = box_of(page, ".app-footer")
assert bottom <= ch + FLUSH_PX, (
f"the resting composer hangs below the viewport bottom "
f"(bottom={bottom:.1f}, viewport={ch:.0f})"
)
assert ch - bottom <= footer["height"] + BOTTOM_SLACK_PX, (
f"dead band under the composer: {ch - bottom:.0f}px below it with a "
f"{footer['height']:.0f}px footer — the input is not at the bottom "
"of the screen"
)
assert bottom >= ch * LOWER_PART, (
f"the composer rests at {bottom / ch:.0%} of the viewport — it has "
"to sit at the bottom, not under the empty state"
)
assert_no_overlap(composer, footer) # chrome, but never overlapped
return composer
def test_empty_chat_composer_sits_at_the_screen_bottom(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
# A fresh visitor: the empty state, nothing to scroll.
# A fresh visitor: the empty state, nothing to scroll — and yet the
# input is already at the bottom of the screen.
expect(page.locator("#empty-state")).to_be_visible()
state = scroll_state(page)
assert state["sh"] <= state["ch"] + 1, (
"an empty chat must not be scrollable — there is nothing to pin against"
"an empty chat must not be scrollable — the grow must not invent "
"scrollable space, it only redistributes the space the page has"
)
composer = composer_box(page)
assert composer["y"] + composer["height"] <= state["ch"] + FLUSH_PX, (
"the composer may never hang below the viewport bottom"
)
assert_rests_at_the_screen_bottom(page)
# The footer is present, in normal flow, BELOW the composer: the sticky
# box settles in its own flow slot (last child of .chat-shell) instead
# of floating over the footer on a short page.
footer = page.locator(".app-footer")
expect(footer).to_be_visible()
assert_no_overlap(composer, box_of(page, ".app-footer"))
# The footer is present, in flow, BELOW the composer: the grow pushes
# the composer down to its own flow slot, it does not float it over the
# footer.
expect(page.locator(".app-footer")).to_be_visible()
# A single short turn: still in flow, still nothing to scroll past.
# A single short turn: still nothing to scroll past, still resting at
# the bottom (the conversation has not overflowed the viewport yet).
submit(page, SHORT_QUESTIONS[0])
wait_settled(page)
state = scroll_state(page)
composer = composer_box(page)
assert composer["y"] + composer["height"] <= state["ch"] + FLUSH_PX
assert_no_overlap(composer, box_of(page, ".app-footer"))
assert scroll_state(page)["sh"] <= scroll_state(page)["ch"] + 1, (
"one short turn must not overflow the 800px test viewport"
)
assert_rests_at_the_screen_bottom(page)
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER)
@@ -513,6 +556,11 @@ def test_pin_holds_on_mobile_under_the_header(
try:
page.set_default_timeout(30_000)
page.goto(app_url)
# An empty phone chat rests the input at the bottom of the screen.
expect(page.locator("#empty-state")).to_be_visible()
assert_rests_at_the_screen_bottom(page)
build_conversation(page, n=4)
state = scroll_state(page)
@@ -521,6 +569,7 @@ def test_pin_holds_on_mobile_under_the_header(
f"ch={state['ch']:.0f})"
)
# …and an over-viewport one keeps it pinned there while reading.
page.evaluate("() => window.scrollTo(0, 0)")
assert page.evaluate(
"() => document.querySelector('.chat-shell').getBoundingClientRect().bottom"
+71 -18
View File
@@ -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"
)