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:
+110
-88
@@ -128,63 +128,6 @@
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
|
||||
<!-- Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5):
|
||||
the chat-actions row — New chat + Share share ONE
|
||||
.chat-actions wrapper (a normal .chat-shell column child,
|
||||
replacing the two pills as its direct children): a
|
||||
HORIZONTAL row on desktop (side by side, left-aligned, each
|
||||
pill at its intrinsic width — the row's align-items: center
|
||||
beats the column's stretch) and a VERTICAL stack at the
|
||||
existing ≤640px breakpoint (New chat above Share,
|
||||
full-width). DOM order New chat → Share in both
|
||||
orientations; the 46rem column contract is untouched
|
||||
(PLAN §7). The kb-banner / stale-banner / steering /
|
||||
announcer structure around the row is unchanged.
|
||||
|
||||
Phase 14 (module-owned since phase 34 task 02): "New chat"
|
||||
resets the local (localStorage) conversation and clears the
|
||||
rendered list. The binding lives in header.js — dispatches
|
||||
"bor:new-chat" which app.js acts on (it owns the
|
||||
in-flight-turn guard + list reset). -->
|
||||
<div class="chat-actions">
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
|
||||
<!-- Phase 55 (owner-locked A2, 2026-08-31): there is NO Save
|
||||
control — every conversation auto-saves itself (app.js
|
||||
persistConversation at the phase-14/20 save points). The
|
||||
History page is the visible proof; a failed auto-save leaves
|
||||
only a one-line status note (never a button, never a banner).
|
||||
|
||||
Phase 55 (owner-locked A1, 2026-08-31): the "Share" pill is
|
||||
STATIC, ALWAYS-VISIBLE markup — there is no reveal step
|
||||
(phase 51's admin-only ship-hidden gate is gone with it; task
|
||||
01 opened the save/share write surface to every visitor).
|
||||
|
||||
Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share"
|
||||
turns the current conversation into a PUBLIC read-only link —
|
||||
/shared/<token> (a 128-bit uuid4 on the saved_chats row,
|
||||
migration 0009). The save-then-share contract: an UNSAVED
|
||||
(unlinked) conversation is saved AND shared in ONE action —
|
||||
app.js POSTs /api/chats with { messages, share: true } (the
|
||||
server sets the token in the same commit) and links the
|
||||
conversation to the created row; a saved (linked) one just
|
||||
POSTs /api/chats/<id>/share (idempotent — the existing token
|
||||
comes back unchanged). On success the ABSOLUTE link is
|
||||
copied to the clipboard; a non-secure (http) homelab origin
|
||||
that rejects the clipboard gets the inline link-field
|
||||
fallback instead (owner-locked — app.js renders
|
||||
.share-link-fallback near the status line). app.js binds the
|
||||
click to shareCurrentChat. Unsharing lives on the History
|
||||
page's Share column (admin-only). -->
|
||||
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
<span class="share-chat-label">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
|
||||
bubble can carry JS-injected actions (app.js) — Tune (admin
|
||||
only, phase 15) and Retry (every visitor; the LAST brain
|
||||
@@ -206,37 +149,116 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
|
||||
roles — #send-btn reads "Send" when idle and morphs into the
|
||||
enabled "Stop" control (.is-stop, rose treatment) while a turn
|
||||
is in flight; a click or Enter in flight aborts the fetch
|
||||
(AbortController in app.js) and the partial is kept + persisted
|
||||
with the optional `stopped` marker, rendered as the .stopped-note
|
||||
meta-row note by app.js — live and on restore. The spinner span
|
||||
stays in the markup (contract marker, reduced-motion pin) but
|
||||
never shows: the Stop label + treatment carry the in-flight
|
||||
state.
|
||||
`novalidate`: the input is cleared after send, so a `required`
|
||||
constraint would silently block the Stop click/Enter — the
|
||||
browser's constraint validation runs before the `submit` event
|
||||
and would never reach handleSend's in-flight guard. The
|
||||
`!text` guard in app.js is the real empty-input check (same
|
||||
precedent as the tuning form's noValidate). -->
|
||||
<form class="composer" id="composer" novalidate>
|
||||
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
|
||||
<textarea
|
||||
id="message-input"
|
||||
name="message"
|
||||
rows="1"
|
||||
placeholder="Ask me anything…"
|
||||
autocomplete="off"
|
||||
></textarea>
|
||||
<button type="submit" class="send-btn" id="send-btn">
|
||||
<span class="spinner" aria-hidden="true" hidden></span>
|
||||
<span class="btn-label" id="send-label">Send</span>
|
||||
<span class="visually-hidden" aria-live="polite" id="send-status"></span>
|
||||
</button>
|
||||
</form>
|
||||
<!-- Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5):
|
||||
the chat-actions row — New chat + Share share ONE
|
||||
.chat-actions wrapper (a normal .chat-shell column child,
|
||||
replacing the two pills as its direct children): a
|
||||
HORIZONTAL row on desktop (side by side, left-aligned, each
|
||||
pill at its intrinsic width — the row's align-items: center
|
||||
beats the column's stretch) and a VERTICAL stack at the
|
||||
existing ≤640px breakpoint (New chat above Share,
|
||||
full-width). DOM order New chat → Share in both
|
||||
orientations; the 46rem column contract is untouched
|
||||
(PLAN §7). The kb-banner / stale-banner / steering /
|
||||
announcer structure around the row is unchanged.
|
||||
|
||||
Phase 14 (module-owned since phase 34 task 02): "New chat"
|
||||
resets the local (localStorage) conversation and clears the
|
||||
rendered list. The binding lives in header.js — dispatches
|
||||
"bor:new-chat" which app.js acts on (it owns the
|
||||
in-flight-turn guard + list reset).
|
||||
|
||||
Phase 65 (task 01, 2026-09-01, `TODO.md` L3, owner
|
||||
confirmation 2026-09-01): the row was relocated from
|
||||
the top of the column to the bottom (above the
|
||||
composer), so the cluster sits where the
|
||||
tune/retry/save-doc meta actions live; the top of the
|
||||
column is now banner → steering → announcer →
|
||||
messages.
|
||||
|
||||
Phase 65 (task 02, 2026-09-01, `TODO.md` L3, owner-locked
|
||||
A1): the row + the composer below are wrapped in ONE
|
||||
.chat-bottom unit (the LAST child of .chat-shell) —
|
||||
position: sticky; bottom:
|
||||
env(safe-area-inset-bottom, 0), NO z-index (the sticky
|
||||
header stays on top — the phase-46 stacking pinned by
|
||||
tests/e2e/test_pinned_composer.py). The composer's OWN
|
||||
sticky declaration is kept: it is redundant inside the
|
||||
wrapper (its containing block is the wrapper, so it cannot
|
||||
shift) but tests/e2e/test_pinned_composer.py asserts the
|
||||
computed style sticky on #composer. -->
|
||||
<div class="chat-bottom">
|
||||
<div class="chat-actions">
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
|
||||
<!-- Phase 55 (owner-locked A2, 2026-08-31): there is NO Save
|
||||
control — every conversation auto-saves itself (app.js
|
||||
persistConversation at the phase-14/20 save points). The
|
||||
History page is the visible proof; a failed auto-save leaves
|
||||
only a one-line status note (never a button, never a banner).
|
||||
|
||||
Phase 55 (owner-locked A1, 2026-08-31): the "Share" pill is
|
||||
STATIC, ALWAYS-VISIBLE markup — there is no reveal step
|
||||
(phase 51's admin-only ship-hidden gate is gone with it; task
|
||||
01 opened the save/share write surface to every visitor).
|
||||
|
||||
Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share"
|
||||
turns the current conversation into a PUBLIC read-only link —
|
||||
/shared/<token> (a 128-bit uuid4 on the saved_chats row,
|
||||
migration 0009). The save-then-share contract: an UNSAVED
|
||||
(unlinked) conversation is saved AND shared in ONE action —
|
||||
app.js POSTs /api/chats with { messages, share: true } (the
|
||||
server sets the token in the same commit) and links the
|
||||
conversation to the created row; a saved (linked) one just
|
||||
POSTs /api/chats/<id>/share (idempotent — the existing token
|
||||
comes back unchanged). On success the ABSOLUTE link is
|
||||
copied to the clipboard; a non-secure (http) homelab origin
|
||||
that rejects the clipboard gets the inline link-field
|
||||
fallback instead (owner-locked — app.js renders
|
||||
.share-link-fallback near the status line). app.js binds the
|
||||
click to shareCurrentChat. Unsharing lives on the History
|
||||
page's Share column (admin-only). -->
|
||||
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
<span class="share-chat-label">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
|
||||
roles — #send-btn reads "Send" when idle and morphs into the
|
||||
enabled "Stop" control (.is-stop, rose treatment) while a turn
|
||||
is in flight; a click or Enter in flight aborts the fetch
|
||||
(AbortController in app.js) and the partial is kept + persisted
|
||||
with the optional `stopped` marker, rendered as the .stopped-note
|
||||
meta-row note by app.js — live and on restore. The spinner span
|
||||
stays in the markup (contract marker, reduced-motion pin) but
|
||||
never shows: the Stop label + treatment carry the in-flight
|
||||
state.
|
||||
`novalidate`: the input is cleared after send, so a `required`
|
||||
constraint would silently block the Stop click/Enter — the
|
||||
browser's constraint validation runs before the `submit` event
|
||||
and would never reach handleSend's in-flight guard. The
|
||||
`!text` guard in app.js is the real empty-input check (same
|
||||
precedent as the tuning form's noValidate). -->
|
||||
<form class="composer" id="composer" novalidate>
|
||||
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
|
||||
<textarea
|
||||
id="message-input"
|
||||
name="message"
|
||||
rows="1"
|
||||
placeholder="Ask me anything…"
|
||||
autocomplete="off"
|
||||
></textarea>
|
||||
<button type="submit" class="send-btn" id="send-btn">
|
||||
<span class="spinner" aria-hidden="true" hidden></span>
|
||||
<span class="btn-label" id="send-label">Send</span>
|
||||
<span class="visually-hidden" aria-live="polite" id="send-status"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user