feat(web): move the chat action cluster to the pinned bottom and align the button sets

- task 01: relocate the .chat-actions row (New chat + Share, comments byte-identical with a Phase 65 note) from the top of the column to the bottom of .chat-shell, directly above the composer
- task 02 (owner-locked A1): wrap the row + #composer in ONE sticky .chat-bottom unit (position: sticky; bottom: env(safe-area-inset-bottom, 0), no z-index) — the pills stay at the bottom of the screen at every scroll position and settle into flow above the footer
- task 03 (owner-locked A2): right-align the bottom row to the column's right edge (justify-content: flex-end), mirroring the right-aligned Save-as-doc corner; the five action pills share one 44px / 999px-pill geometry
- task 04: dedicated Playwright suite tests/e2e/test_bottom_chat_actions.py (resting geometry, the A1 pin across the sticky range, A2 alignment + DOM order + mobile stack + 360px overflow bound + 44px touch targets, New chat / Share click-through) — green in isolation
- task 05: regression matrix green in isolation (pinned_composer 4, save_share_ux 5, chat_persistence 4, share_chat 4, chat_history 5, smoke 3); full gate green — unit + integration pass, app/ coverage 99% (>90%), ruff + pyright clean
This commit is contained in:
2026-09-02 01:08:18 -04:00
parent 4677d86f49
commit 8a1f99cb38
30 changed files with 1376 additions and 147 deletions
+576
View File
@@ -0,0 +1,576 @@
"""Phase 65 E2E (Playwright): the chat action cluster is the pinned bottom.
Source: ``TODO.md`` L3 — "Move the new chat and share button to the
tune/retry/save doc cluster area so it's always at the bottom of the
screen and easily accessble. Make the button clusters look better,
neater, more aligned" (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_bottom_chat_actions.py -v --no-cov
Mock-only, no admin login (chat is public). The owner-locked contracts
(2026-09-01, roadmap confirmation) under test — observed in a real
browser:
* **A1 — the pinned bottom cluster:** the ``.chat-bottom`` wrapper
(the ``.chat-actions`` row + the ``#composer``, ONE sticky unit —
the LAST child of ``.chat-shell``; ``position: sticky; bottom:
env(safe-area-inset-bottom, 0)``, NO z-index) is fully inside the
viewport with its bottom flush with the viewport bottom at EVERY
scroll position inside the sticky range — the New chat + Share
pills are literally always at the bottom of the screen, even while
the reader is scrolled up through a long conversation — and at the
document bottom the whole unit settles back into normal flow above
the ``.app-footer`` (never floating over it);
* **A2 — the right-aligned bottom row:** on desktop the row hugs the
column's RIGHT edge (mirroring the right-aligned "Save as doc"
corner of every brain bubble above — the bottom-right of the chat
reads as one aligned action column); at ≤640px the row is a
full-width vertical stack (alignment is moot when stretched);
* **the click-through survived the move:** ``#new-chat-btn``
(header.js → the ``bor:new-chat`` window event → the app.js reset)
clears the conversation (``.msg`` count 0, ``#empty-state`` back,
``bor.chat.v1`` gone from localStorage, "New chat started" in
``#send-status``); ``#share-chat-btn`` on the now-empty chat is the
guarded no-op (app.js ``shareCurrentChat``: "Nothing to share
yet.", no toast, no navigation).
Determinism: the mock's grounded answers quote the question and end in
the ``Deterministic mock answer for E2E`` marker; overflow is built
from real UI turns (6 short grounded turns — the house
``SHORT_QUESTIONS`` phrasing, the same overflow recipe as
``test_pinned_composer.py``); all scrolling is done by the tests via
``window.scrollTo`` (the phase-42 never-auto-scroll contract — the
app, and the phase-65 sticky wrapper, add no scroll call site).
Test → contract mapping (Playwright Mapping Rule):
1. ``test_empty_chat_row_rests_at_screen_bottom`` — the resting
A1/A2 geometry on a fresh page (static markup, no KB seed)
2. ``test_bottom_cluster_pinned_at_every_scroll_position`` — A1
across the whole sticky range + the settle into flow
3. ``test_row_geometry_and_alignment`` — A2 geometry + the A5 DOM
order + the mobile stack + the 360px overflow bound + the 44px
touch targets
4. ``test_buttons_still_work_from_the_bottom`` — the click-through
contracts (New chat reset, Share no-op)
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Page, 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"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
STORAGE_KEY = "bor.chat.v1"
#: 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?",
)
#: 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 (the same headroom `test_pinned_composer.py`
#: allows the composer).
FLUSH_PX = 4
#: The resting row must sit in the LOWER part of the screen — the pre-phase
#: 65 geometry had it at the very top of the column (the exact defect this
#: phase removes).
LOWER_PART = 0.75
# 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/52/55 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, steering notes, saved chats —
fully deterministic per test), then optionally re-import fixtures.
``saved_chats`` IS truncated here (unlike the phase-55 suite, which
keeps rows across its tests): the click-through tests below touch
the auto-save path, so each test starts from an empty table."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes, saved_chats"))
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)."""
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 phase asks (always at the bottom).
"""
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 column_right_edge(page: Page) -> float:
"""The right edge of the chat column's CONTENT (A2's reference edge).
``.chat-shell`` is a ``.container`` (``padding-inline`` 1.25rem
desktop / 0.9rem ≤640px), so its bounding box includes the padding —
but the column's visible content (every bubble, the meta rows, the
bottom row) ends at the padding edge. Measured live so the
breakpoint's padding value is never hard-coded."""
return page.evaluate(
"() => {"
" const s = document.querySelector('.chat-shell');"
" const r = s.getBoundingClientRect();"
" return r.right - parseFloat(getComputedStyle(s).paddingRight);"
" }"
)
def assert_cluster_flush_with_viewport_bottom(page: Page) -> dict[str, float]:
"""The ``.chat-bottom`` unit is FULLY inside the viewport with its
bottom edge at the viewport bottom (± ``FLUSH_PX``), and the row
AND the composer are each fully visible — the pills are literally
always at the bottom of the screen (A1) at every reading
position. This is the whole story: nothing in the cluster hides
behind the fold while the reader is scrolled up."""
box = box_of(page, ".chat-bottom")
ch = scroll_state(page)["ch"]
assert box["y"] >= -FLUSH_PX, (
f"the pinned cluster is clipped at the TOP of the viewport "
f"(y={box['y']:.1f}, viewport={ch:.0f})"
)
bottom = box["y"] + box["height"]
assert bottom <= ch + FLUSH_PX, (
f"the cluster runs BELOW the viewport bottom "
f"(bottom={bottom:.1f}, viewport={ch:.0f})"
)
assert bottom >= ch - FLUSH_PX, (
f"the cluster is not pinned to the bottom edge "
f"(bottom={bottom:.1f}, viewport={ch:.0f})"
)
# The unit's two members are each fully visible too — the row is
# the contract (the pills), the composer rides along with it.
row = box_of(page, ".chat-actions")
composer = box_of(page, "#composer")
for name, b in ((".chat-actions", row), ("#composer", composer)):
assert b["y"] >= -FLUSH_PX and b["y"] + b["height"] <= ch + FLUSH_PX, (
f"{name} is not fully inside the viewport at a reading position "
f"(box={b}, viewport={ch:.0f})"
)
return box
# ---------------------------------------------------------------------------
# 1. Resting geometry: a fresh chat is not scrollable and the row rests
# in the lower part of the screen, directly above the composer,
# hugging the column's right edge (A2)
# ---------------------------------------------------------------------------
def test_empty_chat_row_rests_at_screen_bottom(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
# Exactly ONE pinned unit, holding exactly the row + the composer —
# the static markup of the new bottom cluster (no KB seed needed).
expect(page.locator(".chat-bottom")).to_have_count(1)
cluster = page.locator(".chat-bottom")
expect(cluster.locator(".chat-actions")).to_have_count(1)
expect(cluster.locator("#composer")).to_have_count(1)
# A fresh visitor: the empty state, nothing to scroll — the wrapper
# must not invent scrollable space (the phase-52 contract, kept:
# the wrapper adds no height the row did not already carry).
expect(page.locator("#empty-state")).to_be_visible()
state = scroll_state(page)
assert state["sh"] <= state["ch"] + 1, (
f"an empty chat must not be scrollable (sh={state['sh']:.0f}, "
f"ch={state['ch']:.0f}) — the wrapper must not invent scrollable space"
)
ch = state["ch"]
row = box_of(page, ".chat-actions")
composer = box_of(page, "#composer")
share = box_of(page, "#share-chat-btn")
# The row rests in the LOWER part of the screen ... (pre-phase-65 it
# sat at the top of the column and scrolled off-screen immediately).
assert row["y"] + row["height"] >= ch * LOWER_PART, (
f"the resting row is at {row['y'] + row['height']:.0f}px of a "
f"{ch:.0f}px viewport — it has to sit at the bottom of the screen"
)
# ... directly ABOVE the composer (the unit's 0.5rem gap sits
# between them — no overlap, no dead band).
assert row["y"] + row["height"] <= composer["y"] + FLUSH_PX, (
f"the row must sit directly above the composer (row={row}, "
f"composer={composer})"
)
# A2: the row hugs the column's right edge — the row box spans the
# column's content width, and the rightmost pill (Share) is flush
# with that edge (justify-content: flex-end — a left-aligned group
# would leave a dead band on the right).
right = column_right_edge(page)
assert abs(row["x"] + row["width"] - right) <= 2, (
f"the row's right edge ({row['x'] + row['width']:.1f}) does not align "
f"with the column's right edge ({right:.1f})"
)
assert abs(share["x"] + share["width"] - right) <= 2, (
f"the rightmost pill is not flush with the column's right edge "
f"(share right={share['x'] + share['width']:.1f}, column={right:.1f})"
)
# The composer is not clipped below the viewport (it rests in flow,
# above the footer — never hanging off the bottom of the screen).
assert composer["y"] + composer["height"] <= ch + FLUSH_PX, (
f"the resting composer hangs below the viewport bottom "
f"(bottom={composer['y'] + composer['height']:.1f}, viewport={ch:.0f})"
)
# ---------------------------------------------------------------------------
# 2. The A1 pin: an over-viewport conversation keeps the whole cluster
# (row + composer) flush with the viewport bottom at every scroll
# position, settling into flow above the footer at the document bottom
# ---------------------------------------------------------------------------
def test_bottom_cluster_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 phase's precondition: a conversation longer than the viewport
# (the reader has content to scroll through — the moment the old
# top-of-column row used to leave the screen).
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 WHOLE cluster is inside the viewport, flush with its bottom —
# the pills are reachable without any scrolling.
page.evaluate("() => window.scrollTo(0, 0)")
assert scroll_state(page)["y"] <= 1
assert_cluster_flush_with_viewport_bottom(page)
# And at every stop through the sticky range. The range is bounded by
# the cluster's CONTAINING BLOCK (`.chat-shell` — the wrapper's
# parent): the pin holds while that column's bottom edge sits below
# the viewport bottom (the same math as
# `test_pinned_composer.py::test_composer_pinned_at_every_scroll_position`).
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 cluster 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_cluster_flush_with_viewport_bottom(page)
# Settled at the document bottom: the unit has returned to its NORMAL
# FLOW position — above the footer, not hovering over it, the composer
# no longer glued to the viewport edge, the row still directly above it.
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
assert abs(scroll_state(page)["y"] - max_scroll) <= 1, "must land at the document bottom"
ch = scroll_state(page)["ch"]
cluster = box_of(page, ".chat-bottom")
row = box_of(page, ".chat-actions")
composer = box_of(page, "#composer")
footer = box_of(page, ".app-footer") # the footer must be rendered
assert cluster["y"] + cluster["height"] <= footer["y"] + FLUSH_PX, (
f"the settled cluster overlaps the footer (cluster={cluster}, "
f"footer={footer}) — the pin must not float the unit over chrome"
)
assert composer["y"] + composer["height"] < ch - FLUSH_PX, (
"at the document bottom the composer settles into flow — it is no "
"longer glued to the viewport edge"
)
assert row["y"] + row["height"] <= composer["y"] + FLUSH_PX, (
"in its settled slot the row must still sit directly above the "
f"composer (row={row}, composer={composer})"
)
# 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)
# ---------------------------------------------------------------------------
# 3. Row geometry + alignment (A2): ONE row with the two pills in the A5
# DOM order — horizontal + right-hugging at desktop, full-width stack
# at ≤640px, no horizontal overflow at 360px, 44px touch targets
# ---------------------------------------------------------------------------
def test_row_geometry_and_alignment(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
# No conversation needed for the row — static markup, always present
# (the page fixture's viewport is the 1280×800 desktop).
page.goto(app_url)
# Exactly ONE .chat-actions, inside the pinned unit, holding exactly
# the two pills — and the A5 DOM order: New chat, then Share (the
# same compareDocumentPosition check as test_save_share_ux.py).
expect(page.locator(".chat-actions")).to_have_count(1)
expect(page.locator(".chat-bottom .chat-actions")).to_have_count(1)
row_el = page.locator(".chat-actions")
assert row_el.locator("#new-chat-btn").count() == 1
assert row_el.locator("#share-chat-btn").count() == 1
expect(page.locator("#new-chat-btn")).to_have_count(1)
expect(page.locator("#share-chat-btn")).to_have_count(1)
assert page.evaluate(
"() => {"
" const n = document.getElementById('new-chat-btn');"
" const s = document.getElementById('share-chat-btn');"
" return !!(n && s && (n.compareDocumentPosition(s) & Node.DOCUMENT_POSITION_FOLLOWING));"
" }"
), "the DOM order must be New chat, then Share"
new_btn = page.locator("#new-chat-btn")
share_btn = page.locator("#share-chat-btn")
# Desktop (1280×800): one horizontal row — overlapping y bands,
# Share to the right of New chat, each pill at its INTRINSIC width
# (never stretched), and the row hugging the column's RIGHT edge (A2).
nb = new_btn.bounding_box()
sb = share_btn.bounding_box()
assert nb is not None and sb is not None
assert nb["y"] < sb["y"] + sb["height"] and sb["y"] < nb["y"] + nb["height"], (
f"the pills must share one horizontal row (new={nb}, share={sb})"
)
assert sb["x"] > nb["x"] + nb["width"], "Share must sit right of New chat"
column_w = page.evaluate(
"() => document.querySelector('.chat-shell').getBoundingClientRect().width"
)
assert nb["width"] < column_w / 2 and sb["width"] < column_w / 2, (
"each pill must keep its intrinsic width on desktop, not stretch the column"
)
row_box = page.locator(".chat-actions").bounding_box()
assert row_box is not None
right = column_right_edge(page)
assert abs(row_box["x"] + row_box["width"] - right) <= 2, (
f"the row's right edge ({row_box['x'] + row_box['width']:.1f}) does not "
f"align with the column's right edge ({right:.1f}) — A2"
)
assert abs(sb["x"] + sb["width"] - right) <= 2, (
f"the rightmost pill is not flush with the column's right edge "
f"(share right={sb['x'] + sb['width']:.1f}, column={right:.1f}) — A2"
)
# Mobile (390×844): a vertical stack — Share BELOW New chat, both
# pills the SAME width, each stretched to the unit's full content
# width (the ≤640px stack rule — alignment is moot when stretched).
page.set_viewport_size({"width": 390, "height": 844})
nb = new_btn.bounding_box()
sb = share_btn.bounding_box()
assert nb is not None and sb is not None
assert sb["y"] > nb["y"] + nb["height"], (
f"the pills must stack at 390px, Share below New chat (new={nb}, share={sb})"
)
cluster_box = page.locator(".chat-bottom").bounding_box()
assert cluster_box is not None
assert abs(nb["width"] - sb["width"]) <= 2, "the stacked pills share one full width"
# .chat-bottom carries no padding/border — its box width IS the
# unit's content width the pills must fill.
assert abs(nb["width"] - cluster_box["width"]) <= 2, (
"the stacked New chat pill must stretch the unit's full width"
)
assert abs(sb["width"] - cluster_box["width"]) <= 2, (
"the stacked Share pill must stretch the unit's full width"
)
# 360px wide: no horizontal page overflow (the two stacked pills +
# the container padding must fit).
page.set_viewport_size({"width": 360, "height": 800})
scroll_w = page.evaluate("() => document.documentElement.scrollWidth")
assert scroll_w <= 360, f"horizontal overflow at 360px: scrollWidth={scroll_w}"
# Touch targets: the bottom cluster's pills render ≥44px tall at BOTH
# widths — and so does the .retry-btn the app injects on the last
# brain bubble (one seeded turn makes it exist; the ghost family
# shares the 44px floor with the solid family).
page.set_viewport_size({"width": 1280, "height": 800})
submit(page, SHORT_QUESTIONS[0])
wait_settled(page)
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER)
expect(page.locator(".retry-btn")).to_have_count(1)
for selector in ("#new-chat-btn", "#share-chat-btn", ".retry-btn"):
assert box_of(page, selector)["height"] >= 44, (
f"{selector} must keep the 44px touch floor at 1280px"
)
page.set_viewport_size({"width": 390, "height": 844})
for selector in ("#new-chat-btn", "#share-chat-btn", ".retry-btn"):
assert box_of(page, selector)["height"] >= 44, (
f"{selector} must keep the 44px touch floor at 390px"
)
# ---------------------------------------------------------------------------
# 4. The click-through survived the move: New chat resets the
# conversation from the bottom cluster; Share on the empty chat is
# the guarded no-op
# ---------------------------------------------------------------------------
def test_buttons_still_work_from_the_bottom(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
# One real conversation: the buttons have something to act on (and
# the auto-save path fires — the row is cleaned up by _reset_db).
submit(page, SHORT_QUESTIONS[0])
wait_settled(page)
expect(page.locator(".msg")).to_have_count(2)
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER)
# New chat (header.js → the `bor:new-chat` window event → the app.js
# reset): the conversation is cleared from the DOM, from localStorage,
# and the a11y announcer reports the reset — from its new home in the
# bottom cluster (the binding is id-based and position-independent).
page.click("#new-chat-btn")
expect(page.locator(".msg")).to_have_count(0)
expect(page.locator("#empty-state")).to_be_visible()
assert page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')") is None, (
"the cleared conversation must be gone from localStorage (bor.chat.v1)"
)
expect(page.locator("#send-status")).to_contain_text("New chat started")
# Share on the now-empty chat: the guarded no-op (app.js
# shareCurrentChat) — the a11y status line, no toast, no navigation.
url_before = page.url
page.click("#share-chat-btn")
expect(page.locator("#send-status")).to_contain_text("Nothing to share yet.")
expect(page.locator(".toast")).to_have_count(0)
assert page.url == url_before, "the empty-chat share no-op must not navigate"