diff --git a/.agent/phases/todo/52_pinned_composer/01_sticky_composer.md b/.agent/phases/complete/52_pinned_composer/01_sticky_composer.md similarity index 100% rename from .agent/phases/todo/52_pinned_composer/01_sticky_composer.md rename to .agent/phases/complete/52_pinned_composer/01_sticky_composer.md diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index d745d8f..98987aa 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -1130,10 +1130,33 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } .suggestion-chip:active { transform: scale(0.98); } /* ---------- Composer ---------- */ +/* Phase 52 (2026-08-30, TODO.md L3): the composer is PINNED to the + viewport bottom. The page scrolls at the document level and + `.chat-shell` (the centered 46rem column) is the composer's sticky + containing block, so the box sticks to the bottom edge of the + viewport at every scroll position and settles back into its normal + flow position (above the footer) once the document bottom is + reached. Before this, a long conversation put the composer below the + fold while phase 42 forbids auto-scrolling a streaming reply — the + Stop control could literally not be reached ("it runs away from the + user as they try to click stop"). + CSS-only by design: no DOM change, no JS, and NO new scroll call + site (the phase-42 never-auto-scroll contract stays intact — + `scrollReveal` is still the one page scroll in app.js). The offset + is the notch-aware safe-area inset (`env()` resolves to 0 on + desktop, so the box sits flush with the viewport bottom; ≤640px keeps + its own `main { padding-bottom: env(safe-area-inset-bottom) }`). + The solid `--surface` background, border, radius and shadow are kept + so messages scrolling behind the pinned box never show through it, + and no z-index is added: DOM order already paints the composer over + `.messages`, it never overlaps the sticky header (z 20) and stays + under the z-1000 document modal. */ .composer { display: flex; align-items: flex-end; gap: 0.6rem; + position: sticky; + bottom: env(safe-area-inset-bottom); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); diff --git a/tests/e2e/test_pinned_composer.py b/tests/e2e/test_pinned_composer.py new file mode 100644 index 0000000..046a850 --- /dev/null +++ b/tests/e2e/test_pinned_composer.py @@ -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() diff --git a/tests/unit/test_pinned_composer.py b/tests/unit/test_pinned_composer.py new file mode 100644 index 0000000..3b823a1 --- /dev/null +++ b/tests/unit/test_pinned_composer.py @@ -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"(? 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
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