feat(chat): pin the composer to the viewport bottom — Stop is always reachable while reading
This commit is contained in:
@@ -1130,10 +1130,33 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
.suggestion-chip:active { transform: scale(0.98); }
|
.suggestion-chip:active { transform: scale(0.98); }
|
||||||
|
|
||||||
/* ---------- Composer ---------- */
|
/* ---------- 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 {
|
.composer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
|
position: sticky;
|
||||||
|
bottom: env(safe-area-inset-bottom);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user