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
+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"), (