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