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
+9 -4
View File
@@ -178,8 +178,11 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
"""#new-chat-btn is a real type=button with an accessible name. Moved
from the shared header bar to the chat page at owner request
(2026-08-28): it lives ONLY in index.html — inside <main>, in
.chat-shell, above the #messages section (the single module binding
+ the no-op guard are pinned in test_shared_header.py)."""
.chat-shell. Phase 65 (2026-09-01, `TODO.md` L3, owner confirmation)
relocated the .chat-actions row from the top of the column to the
bottom: the button now sits BELOW the #messages section, directly
above the composer (the single module binding + the no-op guard are
pinned in test_shared_header.py)."""
html = _index()
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #new-chat-btn"
@@ -191,8 +194,10 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
shell_idx = html.find('class="container chat-shell"')
assert shell_idx != -1 and shell_idx < btn.start(), "the button belongs in .chat-shell"
messages_idx = html.find('id="messages"')
assert messages_idx != -1 and btn.start() < messages_idx, (
"the button sits above the #messages section"
messages_end = html.find("</section>", messages_idx)
composer_idx = html.find('<form class="composer" id="composer"')
assert -1 < messages_idx < messages_end < btn.start() < composer_idx, (
"the button sits below the #messages section, above the composer"
)
# No other page carries the button anymore (owner request 2026-08-28).
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
+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, (
+45 -27
View File
@@ -393,9 +393,12 @@ def test_share_button_ships_visible_beside_new_chat() -> None:
"Share chat", SHIPPED VISIBLE to every visitor (phase 55 task 03 —
NO ``hidden`` attribute, no reveal step; the phase-51 admin-only
ship-hidden gate is gone), BESIDE #new-chat-btn in .chat-shell
inside <main>, above #messages — the chat-shell actions read as a
pair (New chat | Share; the Save pill is gone, phase 55). No other
page carries it (chat-page only, like New chat)."""
inside <main> — the chat-shell actions read as a pair (New chat |
Share; the Save pill is gone, phase 55). Phase 65 (2026-09-01,
``TODO.md`` L3, owner confirmation) moved the row from the top of
the column to the bottom: below #messages, directly above the
composer. No other page carries it (chat-page only, like New
chat)."""
html = _index()
btn = re.search(r'<button[^>]*id="share-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #share-chat-btn"
@@ -408,13 +411,18 @@ def test_share_button_ships_visible_beside_new_chat() -> None:
btn_block = html[btn.start() : html.find("</button>", btn.start())]
assert '>Share</span>' in btn_block
# Beside New chat (the Save pill is gone): after it, still inside
# .chat-shell, above #messages.
# .chat-shell — below #messages, above the composer (phase 65
# moved the row to the bottom of the column).
shell_idx = html.find('class="container chat-shell"')
new_idx = html.find('id="new-chat-btn"')
messages_idx = html.find('id="messages"')
assert -1 < shell_idx < new_idx < btn.start() < messages_idx, (
"the button must sit beside #new-chat-btn in .chat-shell, above #messages"
messages_end = html.find("</section>", messages_idx)
composer_idx = html.find('<form class="composer" id="composer"')
assert -1 < shell_idx < new_idx < btn.start() < composer_idx, (
"the button must sit beside #new-chat-btn in .chat-shell, below "
"#messages, above the composer (phase 65)"
)
assert messages_end < new_idx, ("the row moved below the #messages section")
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
assert 'id="share-chat-btn"' not in other.read_text(encoding="utf-8"), (
@@ -673,13 +681,15 @@ def test_toast_css_top_right_brand_family_and_reduced_motion() -> None:
def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
"""index.html (task 05, A5): ONE ``<div class="chat-actions">``
wraps BOTH pills — its element children are exactly the two
buttons, in the A5 order New chat → Share. The wrapper replaces
the two pills as direct children of ``.chat-shell`` (a normal
column child): inside the shell, above ``#messages``; nothing else
lands between the steering announcer and the row, and nothing but
the phase-49 comment lands between the row and ``#messages``. No
"""index.html (task 05, A5; phase 65 task 01): ONE
``<div class="chat-actions">`` wraps BOTH pills — its element
children are exactly the two buttons, in the A5 order New chat →
Share. The wrapper is a normal ``.chat-shell`` column child, but
phase 65 (2026-09-01, ``TODO.md`` L3, owner confirmation) moved it
from the top of the column to the bottom: below the ``#messages``
section, directly above the composer; nothing but the row's own
comment lands between ``#messages`` and the row, and nothing but
the composer comment lands between the row and the composer. No
other page carries ``.chat-actions`` (chat-page only, like the
pills)."""
html = _index()
@@ -694,23 +704,31 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
new_i = wrap.find('id="new-chat-btn"')
share_i = wrap.find('id="share-chat-btn"')
assert -1 < new_i < share_i, "A5 order: New chat first, then Share"
# Position: a .chat-shell column child, above #messages — the
# kb-banner / stale-banner / steering / announcer structure is
# untouched (nothing else with an id around the row).
# Position: a .chat-shell column child, BELOW #messages and directly
# above the composer (phase 65 — the top of the column is now
# banner → steering → announcer → messages; nothing else with an id
# around the row).
shell_idx = html.find('class="container chat-shell"')
messages_idx = html.find('id="messages"')
assert -1 < shell_idx < start < end < messages_idx, (
"the row is a .chat-shell column child, above #messages"
)
ann_idx = html.find('id="steering-announcer"')
between = html[html.find("</p>", ann_idx):start]
assert "id=" not in between and "<button" not in between, (
"no other element lands between the announcer and the row"
)
after = html[end:messages_idx]
assert "id=" not in after and "<button" not in after, (
"nothing but the phase-49 comment lands between the row and #messages"
messages_end = html.find("</section>", messages_idx)
composer_idx = html.find('<form class="composer" id="composer"')
assert -1 < shell_idx < messages_end < start < end < composer_idx, (
"the row is a .chat-shell column child, below #messages, above the composer"
)
between = html[messages_end:start]
assert (
"id=" not in between
and "<button" not in between
and "<section" not in between
and "<form" not in between
), ("nothing but the row's comment lands between #messages and the row")
after = html[end:composer_idx]
assert (
"id=" not in after
and "<button" not in after
and "<section" not in after
and "<form" not in after
), ("nothing but the composer comment lands between the row and the composer")
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
assert "chat-actions" not in other.read_text(encoding="utf-8"), (