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:
@@ -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
@@ -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