feat(chat): pin the composer to the viewport bottom — Stop is always reachable while reading

This commit is contained in:
2026-08-30 14:59:40 -04:00
parent 619bf2187a
commit 820753948e
4 changed files with 897 additions and 0 deletions
+554
View File
@@ -0,0 +1,554 @@
"""Phase 52 E2E (Playwright): the composer never runs away from the user.
Source: ``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\"" (no user story file — TODO-derived phase).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov
Mock-only, no admin login (chat is public). The owner-locked contract
(2026-08-30) under test — the CSS pin (``position: sticky;
bottom: env(safe-area-inset-bottom)`` on ``.composer``, phase 52 task 01)
observed in a real browser:
* with an over-viewport conversation the composer box is fully inside the
viewport, flush with its bottom edge, at EVERY scroll position — the
reader never has to scroll to find the input;
* the original bug scenario: mid-turn, scrolled up reading earlier
content, the Stop control (``#send-btn.is-stop``, phase 48) is visible
and clickable WITHOUT scrolling, the click settles the turn (partial
kept + ``.stopped-note``, no error banner), it moves the viewport not
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).
Determinism: the mock's grounded answers quote the question and end in the
``Deterministic mock answer for E2E`` marker; the long answer (``write a
long answer`` trigger, phase 11) streams ~900 words over ~8 s at 12 chars /
0.02 s — the in-flight window the Stop click rides — with a unique final
line (``LONG-ANSWER-END``) absent from any partial. Overflow is built from
real UI turns (no fixture, no DOM injection): 6 short turns put ~1.5k px
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``
4. ``test_pin_holds_on_mobile_under_the_header`` — ≤640px stacking
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Browser, Page, ViewportSize, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: Six short grounded questions (the house phrasing + on-topic variants —
#: the honesty gate is HIGH for all of them, so every turn renders an
#: answer bubble + source chips). Six turns overflow the 800px viewport by
#: a wide margin; the tests assert the overflow, so the count can never
#: silently stop being enough.
SHORT_QUESTIONS = (
"How is my Kubernetes cluster set up?",
"How do my backups work?",
"How did I install gitlab?",
"How does my homelab networking work?",
"What is in the new-service deployment?",
"What scripts do I have in the homelab?",
)
#: The phase-11 long-answer trigger: ~900 words ≈ 8 s of streaming — the
#: in-flight window the scrolled-up Stop click rides.
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: The long answer's unique final line — absent from any partial.
LONG_ANSWER_END = "LONG-ANSWER-END"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
STORAGE_KEY = "bor.chat.v1"
#: The conftest ``page`` viewport is 1280×800; the story's phone viewport
#: is the phase-46 one.
MOBILE: ViewportSize = {"width": 375, "height": 812}
#: Tolerance for "flush with the viewport bottom": `env(safe-area-inset-
#: bottom)` resolves to 0 on a desktop/notched-free viewport, so the only
#: slack is sub-pixel rounding. A notched device would shift the box up by
#: the inset — the same few px of headroom the assertion allows.
FLUSH_PX = 4
#: The sticky header's band (phase 12 pins it at 64px) — the pinned
#: composer must never enter it.
HEADER_PX = 64
# The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
# ---------------------------------------------------------------------------
# KB seeding (same pattern as the phase 42/48/49 story suites)
# ---------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
@pytest.fixture()
def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
"""A fresh KB seeded from ``tests/fixtures/docs`` (13 docs, A9 formats),
truncated again on teardown. ``db_ready`` (conftest) skips with clear
instructions when Postgres is down."""
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13
yield
_reset_db(mock_llm, seed=False)
# ---------------------------------------------------------------------------
# Measurement + flow helpers
# ---------------------------------------------------------------------------
def scroll_state(page: Page) -> dict[str, float]:
"""The document scroller's state (there is no inner scroll container)."""
return page.evaluate(
"() => ({ y: window.scrollY, "
"sh: document.documentElement.scrollHeight, "
"ch: window.innerHeight })"
)
def wait_settled(page: Page, timeout: int = 30_000) -> None:
"""The turn is over: the label is back to "Send" (phase 48 — the
in-flight state is the enabled Stop control, so the label carries the
wait; the explicit timeout covers the mock's ~8 s long turn)."""
expect(page.locator("#send-btn")).to_be_enabled(timeout=timeout)
expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout)
def submit(page: Page, question: str) -> None:
"""Submit through the composer (the real-user flow)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def build_conversation(page: Page, n: int = 6) -> None:
"""Settle ``n`` short grounded turns through the UI (the overflow is
real conversation content, not injected DOM)."""
for question in SHORT_QUESTIONS[:n]:
submit(page, question)
wait_settled(page)
def box_of(page: Page, selector: str) -> dict[str, float]:
"""The selector's viewport-relative box as a plain dict.
Measured WITHOUT scrolling (``bounding_box`` never scrolls the page), so
the answer is "where the box sits where the user left it" — exactly the
question this story asks. Playwright hands back a FloatRect; the helpers
below speak ``dict[str, float]``.
"""
rect = page.locator(selector).bounding_box()
assert rect is not None, f"{selector} must be rendered (no bounding box)"
return {
"x": rect["x"],
"y": rect["y"],
"width": rect["width"],
"height": rect["height"],
}
def composer_box(page: Page) -> dict[str, float]:
return box_of(page, "#composer")
def assert_flush_with_viewport_bottom(page: Page) -> dict[str, float]:
"""The composer box is FULLY inside the viewport with its bottom edge
at the viewport bottom (± ``FLUSH_PX`` — the safe-area inset / sub-pixel
rounding). This is the whole story: the input is where the user's
pointer already is, at every scroll position."""
box = composer_box(page)
ch = scroll_state(page)["ch"]
assert box["y"] >= -FLUSH_PX, (
f"the pinned composer is clipped at the TOP of the viewport "
f"(y={box['y']:.1f}, viewport={ch:.0f})"
)
assert box["y"] + box["height"] <= ch + FLUSH_PX, (
f"the composer runs BELOW the viewport bottom — it ran away "
f"(bottom={box['y'] + box['height']:.1f}, viewport={ch:.0f})"
)
assert box["y"] + box["height"] >= ch - FLUSH_PX, (
f"the composer is not pinned to the bottom edge "
f"(bottom={box['y'] + box['height']:.1f}, viewport={ch:.0f})"
)
return box
def assert_no_overlap(a: dict[str, float], b: dict[str, float]) -> None:
"""Two bounding boxes must not overlap vertically."""
assert a["y"] + a["height"] <= b["y"] + FLUSH_PX, (
f"boxes overlap: {a} vs {b}"
)
def _assert_no_error_banner(page: Page) -> None:
"""A stop settles to idle through the same finally path — never the red
role=alert error banner (phase 48 contract)."""
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
expect(banner).not_to_have_attribute("role", "alert")
expect(banner).not_to_have_class(re.compile(r"is-error"))
def _stored_parsed(page: Page) -> dict[str, Any]:
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
assert raw is not None, "the conversation key must exist in localStorage"
return json.loads(raw)
# ---------------------------------------------------------------------------
# 1. Pinned while reading: over-viewport conversation, every scroll
# position keeps the composer inside the viewport at the bottom edge,
# and at the document bottom it settles back into flow above the footer
# ---------------------------------------------------------------------------
def test_composer_pinned_at_every_scroll_position(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
build_conversation(page, n=len(SHORT_QUESTIONS))
# The story's precondition: a conversation longer than the viewport.
state = scroll_state(page)
assert state["sh"] > state["ch"] + 200, (
f"the conversation must overflow the viewport (sh={state['sh']:.0f}, "
f"ch={state['ch']:.0f}) — more turns are needed"
)
# Scrolled to the very top (the "reading the beginning" position): the
# pinned box sits at the bottom edge of the viewport, fully visible.
page.evaluate("() => window.scrollTo(0, 0)")
assert scroll_state(page)["y"] <= 1
assert_flush_with_viewport_bottom(page)
# And at every stop through the sticky range — the range is bounded by
# the composer's CONTAINING BLOCK (`.chat-shell`): the pin holds while
# that column's bottom edge sits below the viewport bottom, which is
# every reading position above the last screenful of conversation.
limits = page.evaluate(
"() => ({ shellBottom: document.querySelector('.chat-shell')"
".getBoundingClientRect().bottom + window.scrollY, "
"sh: document.documentElement.scrollHeight, ch: window.innerHeight })"
)
max_scroll = limits["sh"] - limits["ch"]
pin_limit = limits["shellBottom"] - limits["ch"]
assert pin_limit > 200, (
f"the conversation must keep the composer inside its sticky range "
f"for at least a screen of scrolling (limit={pin_limit:.0f})"
)
assert max_scroll > pin_limit, (
"the document bottom must leave the sticky range, so the settle "
"back into flow is covered too"
)
for y in (0, pin_limit * 0.25, pin_limit * 0.5, pin_limit * 0.75, pin_limit - 1):
page.evaluate("y => window.scrollTo(0, y)", y)
assert abs(scroll_state(page)["y"] - y) <= 1, f"the test scroll to {y} must land"
assert_flush_with_viewport_bottom(page)
# Settled at the document bottom: the sticky box has returned to its
# NORMAL FLOW position — above the footer, not hovering over it (the
# pin must not float the composer over the footer).
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
assert abs(scroll_state(page)["y"] - max_scroll) <= 1, "must land at the document bottom"
footer_box = box_of(page, ".app-footer") # the footer must be rendered
composer = composer_box(page)
assert_no_overlap(composer, footer_box)
assert composer["y"] + composer["height"] < scroll_state(page)["ch"] - FLUSH_PX, (
"at the document bottom the composer settles into flow — it is no "
"longer glued to the viewport edge"
)
# The conversation itself is intact (6 turns, all answers landed) — the
# pin changed the geometry, not the content.
expect(page.locator(".msg.user .bubble")).to_have_count(len(SHORT_QUESTIONS))
expect(page.locator(".msg.brain .bubble")).to_have_count(len(SHORT_QUESTIONS))
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
# ---------------------------------------------------------------------------
# 2. The TODO.md L3 scenario: mid-turn, scrolled up reading earlier
# content, Stop is visible and clickable without scrolling — and the
# click neither moves the viewport nor breaks the phase-48 contract
# ---------------------------------------------------------------------------
def test_stop_is_reachable_from_scrolled_up(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
# Turn 1 (settled long answer) overflows the viewport — the user now
# has earlier content to read while turn 2 streams.
submit(page, LONG_QUESTION)
wait_settled(page)
state = scroll_state(page)
assert state["sh"] > state["ch"] + 200, "a long answer must make the document scrollable"
# Turn 2 streams (~8 s). Wait until it is observably in flight — the
# SECOND answer bubble exists and carries deltas (`.last` alone would
# still resolve to turn 1's finished bubble: the typing indicator is
# `.bubble.typing` and excluded from ANSWER).
submit(page, LONG_QUESTION)
page.wait_for_function(
"() => { const els = document.querySelectorAll('.msg.brain .bubble:not(.typing)');"
" return els.length >= 2 && els[els.length - 1].innerText.length > 80; }",
timeout=30_000,
)
answer = page.locator(ANSWER).nth(1)
expect(page.locator("#send-label")).to_have_text("Stop", timeout=10_000)
# The user scrolls UP to read earlier content. Phase 42 leaves them
# there — the app never follows the stream.
page.evaluate("() => window.scrollTo(0, 0)")
y0 = scroll_state(page)["y"]
assert y0 <= 1, "the test scroll to the top must land"
# The scrolled-up position is inside the composer's sticky range (the
# long answer + its question still extend past this screenful) — the
# premise of the owner's scenario.
assert page.evaluate(
"() => document.querySelector('.chat-shell').getBoundingClientRect().bottom"
) > scroll_state(page)["ch"]
# ... and the Stop control is right there, WITHOUT scrolling: it is
# the enabled `.is-stop` button inside the pinned composer, fully
# inside the viewport. (Before phase 52 the composer sat below the
# fold here — the "runs away as they try to click stop" bug.)
btn = page.locator("#send-btn")
expect(page.locator("#send-label")).to_have_text("Stop")
expect(btn).to_be_enabled()
expect(btn).to_have_class(re.compile(r"is-stop"))
expect(btn).to_be_visible()
btn_box = box_of(page, "#send-btn")
ch = scroll_state(page)["ch"]
assert btn_box["y"] >= 0 and btn_box["y"] + btn_box["height"] <= ch, (
f"Stop is off-screen while the user reads up-page "
f"(box={btn_box}, viewport={ch:.0f})"
)
# Clicking must not need a scroll: the click lands on Stop itself
# (Playwright hits the element at its own position, no scrolling).
assert_flush_with_viewport_bottom(page)
# Stop it — from the scrolled-up position, no scrolling involved.
btn.click()
# The viewport did not move: the pin adds no scroll call site and the
# stop path never scrolls (phase 42, sampled across the settle).
samples = [scroll_state(page)["y"]]
for _ in range(4):
time.sleep(0.25)
samples.append(scroll_state(page)["y"])
assert max(samples) - min(samples) <= 1, (
f"the Stop click moved the viewport ({samples}) — the reader is "
"yanked away from the content they were reading"
)
# Settled to idle with the phase-48 contract intact.
wait_settled(page, timeout=10_000)
expect(btn).not_to_have_class(re.compile(r"is-stop"))
_assert_no_error_banner(page)
expect(page.locator("#send-status")).to_contain_text("Answer stopped.")
# The partial is kept on screen with the "Stopped" marker; the full
# long answer never arrived.
stopped_text = answer.inner_text()
assert stopped_text.strip()
assert "Step 1:" in stopped_text
assert LONG_ANSWER_END not in stopped_text
expect(page.locator(".msg.brain .stopped-note")).to_have_count(1)
expect(page.locator(".msg.brain .stopped-note").first).to_contain_text("Stopped")
# ... and it persists through the normal bor.chat.v1 path: a fresh
# load restores the stopped record (the reader sees it wherever they
# were reading).
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == [
"user",
"brain",
"user",
"brain",
]
last = stored["messages"][-1]
assert last["stopped"] is True
assert LONG_ANSWER_END not in last["text"]
page.reload()
expect(page.locator(".msg.user .bubble")).to_have_count(2)
bubble = page.locator(ANSWER)
expect(bubble).to_have_count(2)
expect(bubble.nth(1)).to_contain_text("Step 1:")
expect(page.locator(".msg.brain .stopped-note")).to_have_count(1)
expect(page.locator(".msg.brain .stopped-note").first).to_contain_text("Stopped")
expect(bubble.nth(1)).not_to_contain_text(LONG_ANSWER_END)
wait_settled(page, timeout=10_000)
# The restored page keeps the pin too (the composer is still the
# last, sticky child of the column).
assert composer_box(page)["height"] > 0
assert page.evaluate(
"() => getComputedStyle(document.querySelector('#composer')).position"
) == "sticky"
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
def test_empty_chat_composer_sits_in_normal_flow(
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.
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"
)
composer = composer_box(page)
assert composer["y"] + composer["height"] <= state["ch"] + FLUSH_PX, (
"the composer may never hang below the viewport bottom"
)
# 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"))
# A single short turn: still in flow, still nothing to scroll past.
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"))
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER)
# ---------------------------------------------------------------------------
# 4. ≤640px: the pin holds on a phone and stays UNDER the sticky header
# (no z-index was added, so the pinned box cannot cover the bar or the
# hamburger dropdown — phase 46 stacking)
# ---------------------------------------------------------------------------
def test_pin_holds_on_mobile_under_the_header(
browser: Browser, app_url: str, seeded_kb: None
) -> None:
page = browser.new_page(viewport=MOBILE)
try:
page.set_default_timeout(30_000)
page.goto(app_url)
build_conversation(page, n=4)
state = scroll_state(page)
assert state["sh"] > state["ch"] + 200, (
f"four turns must overflow an 812px phone (sh={state['sh']:.0f}, "
f"ch={state['ch']:.0f})"
)
page.evaluate("() => window.scrollTo(0, 0)")
assert page.evaluate(
"() => document.querySelector('.chat-shell').getBoundingClientRect().bottom"
) > scroll_state(page)["ch"], "the phone position must sit in the sticky range"
composer = assert_flush_with_viewport_bottom(page)
# The pinned box never enters the sticky header's band, and the
# header stays on top: the composer carries NO z-index, so DOM
# order + the header's z 20 keep the bar (and its dropdown) above.
assert composer["y"] >= HEADER_PX, (
f"the pinned composer overlaps the header band "
f"(y={composer['y']:.1f}, header={HEADER_PX}px)"
)
assert page.evaluate(
"() => getComputedStyle(document.querySelector('#composer')).zIndex"
) in {"auto", "0"}, "the composer must not be lifted above the header"
header_z = page.evaluate(
"() => getComputedStyle(document.querySelector('.app-header')).zIndex"
)
assert header_z == "20"
# Reachable touch target: the Send button stays ≥44px on a phone.
btn_box = box_of(page, "#send-btn")
assert btn_box["height"] >= 44 and btn_box["width"] >= 44
# The composer is usable there: typing + sending works from the pin.
submit(page, SHORT_QUESTIONS[1])
wait_settled(page)
expect(page.locator(".msg.user .bubble")).to_have_count(5)
finally:
page.close()
+320
View File
@@ -0,0 +1,320 @@
"""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'").
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:
* `.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;
* 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
in the ≤640px media query;
* 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
pin can never silently arrive in JS instead of CSS.
The browser geometry itself is E2E-gated by the story suite
(tests/e2e/test_pinned_composer.py, task 02); these are source pins in
the house style (see tests/unit/test_frontend_scroll.py).
"""
from __future__ import annotations
import re
from html.parser import HTMLParser
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
APP_JS = FRONTEND / "assets" / "app.js"
INDEX_HTML = FRONTEND / "index.html"
VOID_ELEMENTS = frozenset(
{
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
}
)
def _css() -> str:
assert STYLES_CSS.is_file(), f"missing {STYLES_CSS}"
return STYLES_CSS.read_text(encoding="utf-8")
def _js() -> str:
assert APP_JS.is_file(), f"missing {APP_JS}"
return APP_JS.read_text(encoding="utf-8")
def _html() -> str:
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
return INDEX_HTML.read_text(encoding="utf-8")
def _rule(css: str, selector: str) -> str:
"""The body of the rule whose selector line is exactly `selector`
(multi-line block) — same slicing style as test_history_page.py."""
block = re.search(rf"^{re.escape(selector)} \{{\n([\s\S]*?)\n\}}", css, re.MULTILINE)
assert block, f"styles.css must carry a `{selector} {{ … }}` rule"
return block.group(1)
def _walk(node: dict) -> list[dict]:
"""Every element in a parsed subtree (depth-first, document order)."""
out = [node]
for child in node["children"]:
out.extend(_walk(child))
return out
def _mobile_block(css: str) -> str:
"""The ≤640px media query body (the phase-07 responsive block)."""
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the mobile media query must exist"
return mobile.group(1)
class _Tree(HTMLParser):
"""Minimal element tree of index.html — enough to ask "who is the
last element child of `.chat-shell`?" without a DOM library."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.root: dict = {"tag": "#root", "attrs": {}, "children": []}
self._stack: list[dict] = [self.root]
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
node = {"tag": tag, "attrs": dict(attrs), "children": []}
self._stack[-1]["children"].append(node)
if tag not in VOID_ELEMENTS:
self._stack.append(node)
def handle_endtag(self, tag: str) -> None:
for i in range(len(self._stack) - 1, 0, -1):
if self._stack[i]["tag"] == tag:
del self._stack[i:]
return
def find(self, classes: str) -> dict:
"""The first element whose `class` attribute contains `classes`
(all of them, space-separated)."""
wanted = classes.split()
stack = [self.root]
while stack:
node = stack.pop(0)
have = (node["attrs"].get("class") or "").split()
if node["tag"] != "#root" and all(c in have for c in wanted):
return node
stack.extend(node["children"])
raise AssertionError(f"index.html must contain .{classes}")
def _tree() -> _Tree:
tree = _Tree()
tree.feed(_html())
return tree
# ---------- the sticky pin itself ----------
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)."""
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)"
)
# `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_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,
radius and shadow (no glass/transparency), so a reply streaming
behind it never shows through the input."""
body = _rule(_css(), ".composer")
assert "background: var(--surface);" in body, (
"the composer needs an opaque background — messages must not "
"show through the pinned box"
)
assert "transparent" not in body and "rgb(" not in body, (
"no translucent background on the pinned composer"
)
assert "border: 1px solid var(--line);" in body
assert "border-radius: var(--radius);" in body
assert "box-shadow: var(--shadow);" in body, (
"the elevation shadow separates the pinned box from the content "
"scrolling behind it"
)
def test_no_z_index_added_to_the_composer() -> None:
"""Locked assumption: NO z-index change. DOM order already paints
the composer above `.messages`, it never reaches the sticky header
(z 20) at the top and it must stay under the z-1000 document modal."""
body = _rule(_css(), ".composer")
assert "z-index" not in body, (
"the composer stays unlayered — a z-index here could lift it over "
"the sticky header (20) or the document modal (1000)"
)
css = _css()
assert re.search(r"\.app-header \{[\s\S]*?z-index: 20;", css), (
"the sticky header keeps its layer"
)
assert re.search(r"^\.doc-modal \{[\s\S]*?z-index: 1000;", css, re.MULTILINE), (
"the document modal stays the topmost layer"
)
def test_mobile_pin_not_undone() -> None:
"""≤640px: the pin must survive the responsive block — the mobile
`.composer` rule only tightens the padding (it must not reset
`position` or drop the box out of the sticky model), and the phone
keeps its own `main { padding-bottom: env(safe-area-inset-bottom) }`
flow padding under the settled box."""
mobile = _mobile_block(_css())
assert ".composer" in mobile, "the mobile composer rule must remain"
mobile_composer = re.search(r"\.composer \{([^}]*)\}", mobile)
assert mobile_composer, "the ≤640px block must keep the .composer rule"
body = mobile_composer.group(1)
assert "padding: 0.5rem;" in body
assert "position" not in body, (
"the mobile rule must not reset the sticky position"
)
assert "main { padding-bottom: env(safe-area-inset-bottom, 0); }" in mobile
# ---------- the sticky context (no inner scroller, no clipping ancestor) ----------
def test_document_level_scroll_is_untouched() -> None:
"""Sticky resolves against the NEAREST SCROLLING ANCESTOR. The pin
relies on that being the document, so no ancestor between the
composer and the viewport may become a scroll container: `.chat-shell`
and `.app-main` stay overflow-visible and `.messages` gains no inner
scroller / height cap (that would move the scroll — and the phase-42
contract — into the message list)."""
css = _css()
for selector in (".chat-shell", ".app-main", ".messages"):
body = _rule(css, selector)
assert "overflow" not in body, (
f"{selector} must not clip/scroll — the composer pins to the "
"document viewport, not to an inner scrollport"
)
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)"
)
# ---------- index.html: no DOM change needed ----------
def test_composer_is_last_child_of_the_chat_shell() -> None:
"""The composer must remain the LAST element child of `.chat-shell`
(the sticky containing block): the sticky shift range is that
column's box, so a sibling after the form would carve the range away
and re-break the pin. The `#message-input` / `#send-btn` /
`#send-status` markup is untouched (the pin ships no DOM change)."""
shell = _tree().find("chat-shell")
last = shell["children"][-1]
assert last["tag"] == "form", (
"the composer <form> must stay the last child of .chat-shell"
)
assert last["attrs"].get("id") == "composer"
assert "novalidate" in last["attrs"], (
"phase 48: the composer form stays `novalidate` (a `required` "
"input would swallow the Stop click)"
)
ids = {
node["attrs"].get("id")
for node in _walk(last)
if node["attrs"].get("id")
}
assert {"composer", "message-input", "send-btn", "send-status"} <= ids, (
"the composer markup contract ids must all be present (task 02 E2E "
"clicks Stop from this pinned box)"
)
# ---------- app.js: the pin adds no page scroll (phase-42 invariant) ----------
def test_app_js_gains_no_page_scroll_call_site() -> None:
"""The phase-42 never-auto-scroll contract, re-pinned for phase 52:
the composer pin is CSS-only, so `app.js`'s scroll surface is
byte-for-byte the phase-42 one — `scrollReveal`'s single
`window.scrollTo` is still the ONLY page scroll, no `scrollIntoView`
came back, nothing measures `window.scrollY`, and the only other
scroll is the thinking window's own bottom pin (`textEl.scrollTop`,
a block-internal clip, not the page). A JS-scrolled "pin" (an
IntersectionObserver / sticky polyfill / scroll listener) would trip
one of these assertions."""
js = _js()
assert js.count("window.scrollTo(") == 1, "exactly one page scroll may exist"
assert js.find("window.scrollTo(") > js.find("function scrollReveal"), (
"the one page scroll must live inside scrollReveal"
)
assert js.count(".scrollIntoView(") == 0
assert "window.scrollY" not in js
assert "addEventListener(\"scroll\"" not in js, (
"the pin must not install a scroll listener"
)
assert "IntersectionObserver" not in js, (
"no JS sticky polyfill — the pin is `position: sticky`"
)
assert js.count("textEl.scrollTop = textEl.scrollHeight") == 1, (
"the thinking window's internal pin stays, and stays the only one"
)
def test_turn_end_focus_still_uses_preventscroll() -> None:
"""The turn-end focus-back lands on the pinned composer every turn —
with the box now sticky it must still never move the viewport
(`focus({ preventScroll: true })`, phase 42), or every finished turn
would yank the reader to the bottom."""
js = _js()
idx = js.find("// done | error | stop → idle: always settle, always focus back")
assert idx != -1, "the turn's finally block must exist"
block = js[idx : js.find("\n}", idx)]
assert "input.focus({ preventScroll: true })" in block