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:
@@ -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, (
|
||||
|
||||
Reference in New Issue
Block a user