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
+47 -20
View File
@@ -21,6 +21,15 @@ block. The pin is therefore TWO rules, and both are pinned here:
bottom edge at every scroll position, settling back into flow (above
the footer) once the document bottom is reached;
Phase 65 (task 02, owner-locked A1, 2026-09-01, `TODO.md` L3): the pin
moved up one level — the `.chat-bottom` wrapper (the row + this form,
the LAST child of `.chat-shell`) carries `position: sticky; bottom:
env(safe-area-inset-bottom, 0)` and is the unit that stays glued to the
viewport bottom; the composer's own sticky pair above and the
`.messages` grow stay exactly as phase 52 pinned them (the composer's
sticky is redundant inside the wrapper but kept — and pinned here —
for the computed-style contract).
The pin is CSS-only:
* `.composer` carries the sticky pair (``position: sticky`` + the
@@ -34,15 +43,11 @@ The pin is CSS-only:
* 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;
* 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;
* ``index.html`` (phase 65 task 02, owner-locked A1): the row + composer
are wrapped in the `.chat-bottom` unit — the wrapper (NO id) is now
the LAST child of `.chat-shell` (the sticky shift range is still 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
@@ -303,25 +308,47 @@ def test_document_level_scroll_is_untouched() -> None:
# ---------- 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)."""
def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
"""Phase 65 (task 02, owner-locked A1): the LAST element child of
`.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS
binds it; the bindings live on the inner elements, the move is pure
HTML/CSS) — holding exactly the `.chat-actions` row and then the
`#composer` form, in that order: the row + composer are ONE sticky
unit, and the wrapper owns the shell's bottom slot, so the sticky
shift range is still that column's box (a sibling after it would
carve the range away and re-break the pin). The composer form keeps
`novalidate` and its contract ids."""
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["tag"] == "div" and (
last["attrs"].get("class") or ""
).split() == ["chat-bottom"], (
"the .chat-bottom wrapper must be the last child of .chat-shell"
)
assert last["attrs"].get("id") == "composer"
assert "novalidate" in last["attrs"], (
assert last["attrs"].get("id") is None, (
"the wrapper carries no id — the JS bindings live on the inner "
"elements"
)
kids = last["children"]
assert len(kids) == 2, (
"the unit holds exactly two element children: .chat-actions, then "
"#composer"
)
row, form = kids
assert row["tag"] == "div" and (
row["attrs"].get("class") or ""
).split() == ["chat-actions"], (
"the first child is the .chat-actions row (the New chat → Share "
"DOM order is pinned by test_save_chat_ui.py)"
)
assert form["tag"] == "form" and form["attrs"].get("id") == "composer"
assert "novalidate" in form["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)
for node in _walk(form)
if node["attrs"].get("id")
}
assert {"composer", "message-input", "send-btn", "send-status"} <= ids, (