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
+52 -8
View File
@@ -423,17 +423,31 @@ html::after {
(a normal .chat-shell column child). As a flex ITEM of the column it
spans the column width, but as a flex ROW with align-items: center
(not the column default stretch) each pill keeps its INTRINSIC
content width — two pills side by side, left-aligned in the column,
on desktop. The ≤640px block flips this to a vertical stack
(flex-direction: column + align-items: stretch — full-width pills,
New chat above Share; the existing ≤640px pill rules apply to the
stacked pills unchanged). The reading-column contract is untouched
(--chat-column: 46rem base, 92rem at >=1500px — PLAN §7 lineage).
*/
content width — two pills side by side on desktop. The ≤640px block
flips this to a vertical stack (flex-direction: column +
align-items: stretch — full-width pills, New chat above Share; the
existing ≤640px pill rules apply to the stacked pills unchanged).
The reading-column contract is untouched (--chat-column: 46rem base,
92rem at >=1500px — PLAN §7 lineage).
Phase 65 (task 03, 2026-09-01, `TODO.md` L3, owner-locked A2): the
row is the TOP member of the pinned .chat-bottom cluster (task
01/02) and hugs the column's RIGHT edge on desktop
(justify-content: flex-end) — mirroring the right-aligned
"Save as doc" action of every brain bubble above it, so the
bottom-right of the chat reads as ONE aligned action column. The
pills keep their intrinsic widths (align-items: center already
beats the column default stretch; justify-content only shifts the
group). The vertical row↔composer spacing is the .chat-bottom
wrapper's 0.5rem gap (task 02). The ≤640px stack rule below is
UNCHANGED — with stretched full-width pills the horizontal
alignment is moot (the stacked pills already edge-to-edge the
column). */
.chat-actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 0.6rem;
}
@@ -1222,7 +1236,37 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
so messages scrolling behind the pinned box never show through it,
and no z-index is added: DOM order already paints the composer over
`.messages`, it never overlaps the sticky header (z 20) and stays
under the z-1000 document modal. */
under the z-1000 document modal.
Phase 65 (2026-09-01, `TODO.md` L3, owner-locked A1): the pin now
belongs to the .chat-bottom wrapper (rule below) — the row + this
form are ONE sticky unit; this composer's own sticky declaration is
kept, redundant inside the wrapper but pinned as a computed style by
tests/e2e/test_pinned_composer.py. */
/* Phase 65 (owner-locked A1, 2026-09-01, `TODO.md` L3): the pinned
bottom cluster — the .chat-actions row + the #composer form as ONE
sticky unit (A1): the pills are literally always at the bottom of
the screen, even while the reader is scrolled up through a long
conversation (the rejected in-flow alternative would scroll away).
The sticky range is the .chat-shell containing block (the wrapper's
parent) — the same range the composer used to have alone — and the
.messages flex-grow still rests the WHOLE unit at the screen bottom
on short chats (the wrapper is the last shell child, so the grow
pushes it down). The shell's 1rem gap applies only ABOVE the unit,
from .messages — the 0.5rem gap here is the row↔composer spacing
INSIDE it. No z-index: the phase-12 sticky header (z 20) and the
phase-46 dropdown always stay on top, and at the document bottom the
unit settles back into flow above the footer (the composer's opaque
surface keeps messages hidden where the box overlaps them). The
composer's own sticky rule below stays — redundant here (its
containing block is the wrapper, so it cannot shift within the
unit), pinned by tests/e2e/test_pinned_composer.py. */
.chat-bottom {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: sticky;
bottom: env(safe-area-inset-bottom, 0);
}
.composer {
display: flex;
align-items: flex-end;
+110 -88
View File
@@ -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>