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"