"""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 RESTING position, which the grow puts at the BOTTOM of the screen — the only band below it is chrome (``.app-footer``, in normal flow, never overlapped), and at the document bottom the box has settled back into that same flow slot above the footer instead of hovering on the viewport edge; * ≤640px the same two rules hold: the empty phone chat puts the input at the bottom of the screen and an over-viewport chat keeps it there, still UNDER the sticky header (no z-index was added, so the pinned box can never cover the bar or the hamburger dropdown — phase 46 stacking). Determinism: the mock's grounded answers quote the question and end in the ``Deterministic mock answer for E2E`` marker; the long answer (``write a 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_at_the_screen_bottom`` 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 from e2e.auth_helpers import login 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 #: How much of the viewport may sit BELOW a resting composer as chrome: #: the footer's own measured height plus the flow padding the settled slot #: keeps (`main { padding-block: 1.25rem }` + the column's 1rem gap, with #: headroom for the safe-area inset). Anything more is wasted dead space — #: the empty chat must NOT leave the input hanging mid-screen. BOTTOM_SLACK_PX = 56 #: The resting composer also has to be visibly in the lower part of the #: screen — the pre-revision geometry rested at ~57% of the viewport here. LOWER_PART = 0.8 # The typing indicator is itself a .msg.brain — exclude its bubble. ANSWER = ".msg.brain .bubble:not(.typing)" # --------------------------------------------------------------------------- # 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_reveal_scroll_done(page: Page, target: int, timeout: int = 10_000) -> None: """Block until the submit's ``scrollReveal`` smooth scroll has ARRIVED at its target (the document bottom captured right after the click, ``target`` — the first streamed delta lands tens of ms later and would inflate a later read). Needed before a test scroll: Chromium's smooth-scroll duration is distance- and environment-dependent (roughly half a second to a second for a full page), and a ``window.scrollTo`` issued mid-animation does not settle the measured position (the animation keeps moving the page after the test's scroll, so a position read lands mid-flight, e.g. ``y=71`` instead of ``0``). The wait is an ARRIVAL wait (``scrollY`` reached the target bottom), not a "looks still" wait: mid-animation frame stalls under load make a short stillness window pass while the animation is still pending (observed: a 120 ms window passed mid-scroll; 800 ms is the fallback for the vanishingly-rare late-target read, where the arrival threshold is inflated by an already-landed delta). Phase 104 (task 03, 2026-09-12): pinned deterministically when the phase-104 E2E regression exposed the race (it reproduces on the pre-phase-104 tree — a pre-existing flake, not a phase-104 regression).""" page.wait_for_function( """(target) => { if (window.scrollY >= target - window.innerHeight - 2) return true; const y0 = window.scrollY; return new Promise((resolve) => setTimeout(() => resolve(window.scrollY === y0), 800)); }""", arg=target, timeout=timeout, ) 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) -> int: """Submit through the composer (the real-user flow). Returns the document ``scrollHeight`` read right after the click — the submit's ``scrollReveal`` target (``window.scrollTo({top: scrollHeight})`` runs in the click's own event dispatch, and the first streamed delta lands tens of ms later and would inflate a later read).""" page.fill("#message-input", question) page.click("#send-btn") expect(page.locator(".msg.user .bubble").last).to_contain_text(question) # The bubble exists by now and the first streamed delta is still tens # of ms away — the read is the scrollReveal's own target. return page.evaluate("() => document.documentElement.scrollHeight") 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) login(page, app_url, next="/") 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) login(page, app_url, next="/") # 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). reveal_target = 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 submit's own smooth scrollReveal (to the document bottom) must # ARRIVE before the test scrolls up — mid-animation, the browser # keeps moving the page after the test's scrollTo and the position # read lands mid-flight (the race wait_reveal_scroll_done documents). wait_reveal_scroll_done(page, reveal_target) # 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 RESTS the composer at the bottom # of the screen — no dead band under it, no float over the footer # --------------------------------------------------------------------------- def assert_rests_at_the_screen_bottom(page: Page) -> dict[str, float]: """The resting (in-flow) composer sits at the bottom of the screen. This is the half `position: sticky` cannot deliver — sticky only pulls a box UP toward the viewport bottom while the document overflows, so on a page that does not scroll the box rested where the empty state ended (mid-screen) until `.messages` gained `flex: 1 1 auto`. The band below the box must be chrome only: the footer (measured live, so phones with the stacked footer are covered) plus the flow padding of the settled slot. """ ch = scroll_state(page)["ch"] composer = composer_box(page) bottom = composer["y"] + composer["height"] footer = box_of(page, ".app-footer") assert bottom <= ch + FLUSH_PX, ( f"the resting composer hangs below the viewport bottom " f"(bottom={bottom:.1f}, viewport={ch:.0f})" ) assert ch - bottom <= footer["height"] + BOTTOM_SLACK_PX, ( f"dead band under the composer: {ch - bottom:.0f}px below it with a " f"{footer['height']:.0f}px footer — the input is not at the bottom " "of the screen" ) assert bottom >= ch * LOWER_PART, ( f"the composer rests at {bottom / ch:.0%} of the viewport — it has " "to sit at the bottom, not under the empty state" ) assert_no_overlap(composer, footer) # chrome, but never overlapped return composer def test_empty_chat_composer_sits_at_the_screen_bottom( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) login(page, app_url, next="/") # A fresh visitor: the empty state, nothing to scroll — and yet the # input is already at the bottom of the screen. expect(page.locator("#empty-state")).to_be_visible() state = scroll_state(page) assert state["sh"] <= state["ch"] + 1, ( "an empty chat must not be scrollable — the grow must not invent " "scrollable space, it only redistributes the space the page has" ) assert_rests_at_the_screen_bottom(page) # The footer is present, in flow, BELOW the composer: the grow pushes # the composer down to its own flow slot, it does not float it over the # footer. expect(page.locator(".app-footer")).to_be_visible() # A single short turn: still nothing to scroll past, still resting at # the bottom (the conversation has not overflowed the viewport yet). submit(page, SHORT_QUESTIONS[0]) wait_settled(page) assert scroll_state(page)["sh"] <= scroll_state(page)["ch"] + 1, ( "one short turn must not overflow the 800px test viewport" ) assert_rests_at_the_screen_bottom(page) expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER) # --------------------------------------------------------------------------- # 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) login(page, app_url, next="/") # An empty phone chat rests the input at the bottom of the screen. expect(page.locator("#empty-state")).to_be_visible() assert_rests_at_the_screen_bottom(page) build_conversation(page, n=4) state = scroll_state(page) assert state["sh"] > state["ch"] + 200, ( f"four turns must overflow an 812px phone (sh={state['sh']:.0f}, " f"ch={state['ch']:.0f})" ) # …and an over-viewport one keeps it pinned there while reading. page.evaluate("() => window.scrollTo(0, 0)") assert page.evaluate( "() => document.querySelector('.chat-shell').getBoundingClientRect().bottom" ) > 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()