321 lines
13 KiB
Python
321 lines
13 KiB
Python
"""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
|