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:
@@ -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):
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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