feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row

This commit is contained in:
2026-08-31 05:20:25 -04:00
parent c564e317ed
commit 914097abcf
17 changed files with 1803 additions and 491 deletions
+480 -158
View File
@@ -1,18 +1,60 @@
"""Unit: the phase-50 task-03 save-chat contract on the chat page.
"""Unit: the save/share contract on the chat page (phase 50 → phase 55).
The browser behavior itself is E2E-gated by the story suite (task 05);
like the other frontend-adjacent unit files, this module pins the
JS/CSS/HTML markers the save/load contract depends on, so a silent
regression is caught without a browser:
The browser behavior itself is E2E-gated by the story suites (phase 50
+ phase 55 task 06); like the other frontend-adjacent unit files, this
module pins the JS/CSS/HTML markers the save/load contract depends on,
so a silent regression is caught without a browser:
* the ``currentChatId`` lifecycle (set on create/open, cleared by New
chat and by the 404-PUT fallback);
* the upsert branch (PUT when linked, POST when not, the 404→recreate
fallback, the live-region feedback strings);
* phase 55 (A2): the Save pill is GONE — no ``#save-chat-btn`` in
index.html, no ``.save-chat-btn`` in styles.css, no ``saveBtn`` /
``saveCurrentChat`` symbol in app.js; the headless
``persistConversation()`` upsert (PUT when linked, POST when not, the
404→recreate fallback) is wired to the save points (the user send in
runTurn's ``!reask`` block, ``rememberBrainTurn`` — the pagehide
partial rides it, no direct call there) with the A2 quiet contract
(one-line status note on failure, NO error banner, silent success)
and the module-level ``persisting`` double-fire guard;
* the ``bor.chat.v1`` record carries ``chatId`` (the row link survives
reloads; a pre-55 record without the field reads as null — never
throws);
* the ``currentChatId`` lifecycle (set on create/open, hydrated from the
record on the local restore, cleared by New chat and by the 404-PUT
fallback);
* the boot-load precedence (a valid ``?chat=`` uuid + admin replaces the
local restore and mirrors it to localStorage; anonymous / invalid /
404 / network → the local restore);
* the ship-hidden / reveal-for-admin gate on ``#save-chat-btn``.
* the phase-55 task-03 Share contract on ``#share-chat-btn``: static,
ALWAYS-VISIBLE markup — no ``hidden`` attribute, NO reveal step
(no ``shareBtn.hidden`` assignment anywhere in app.js), and NEUTRAL
error copy on a failed share (the write surface is public — no
sign-in wording);
* phase 55 task 04 (the share-success toast, owner-locked A4):
``showToast`` — the SINGLE aria-hidden ``.toast`` node (lazy-created
once, reused — no stacking; text via ``textContent``, never
``innerHTML``; the pending dismiss cleared + reflow forced so a
second toast re-runs the entry; ~4s auto-dismiss) is called from
BOTH ``shareCurrentChat`` success branches with their own texts and
NEVER from a failure branch (the error banner is the failure UI);
styles.css ``.toast`` — fixed top-right just under the sticky header
(z-index 1000, brand fill — --bg on --brand 5.2:1 AA, a small
max-width, hidden by default with ``pointer-events: none``), a ~200ms
slide-down + fade entry via ``.toast.is-visible``, and the
reduced-motion override (transform dropped for BOTH states, the
opacity fade kept);
* phase 55 task 05 (the action row, owner-locked A5): a single
``<div class="chat-actions">`` in index.html wraps BOTH pills as its
element children (DOM order New chat → Share), replacing the two
pills as direct children of ``.chat-shell`` — the row sits inside the
shell, above ``#messages``, with the kb-banner / stale-banner /
steering / announcer structure around it untouched; styles.css
``.chat-actions`` — base ``display: flex; flex-direction: row;
align-items: center; gap: 0.6rem`` (the row's cross-axis override of
the column's stretch: the pills keep their intrinsic widths, side by
side, left-aligned) and the ≤640px override
``flex-direction: column; align-items: stretch; gap: 0.5rem`` (full-
width stack, New chat above Share) with the existing ≤640px pill
rules (padding, icon/label handling, the ``.chat-shell`` label
overrides) left intact for the stacked pills.
"""
from __future__ import annotations
@@ -52,62 +94,29 @@ def _fn(js: str, name: str) -> str:
# ---------- the Save button on the chat page ----------
def test_save_button_ships_hidden_beside_new_chat() -> None:
"""#save-chat-btn: a real type=button with the accessible name
"Save chat", SHIPPED HIDDEN (app.js reveals it for admin only),
beside #new-chat-btn in .chat-shell inside <main>, above
#messages — the two chat-shell actions read as a pair. No other
page carries it (chat-page only, like New chat)."""
def test_save_pill_is_gone_from_the_chat_page() -> None:
"""Phase 55 (A2): the Save control is RETIRED — there is no
#save-chat-btn anywhere in index.html (at no width), no
.save-chat-btn rule anywhere in styles.css (base, ≤900px squeeze,
≤640px overrides), and no saveBtn / saveCurrentChat symbol left in
app.js (the headless persistConversation() replaced the handler)."""
html = _index()
btn = re.search(r'<button[^>]*id="save-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #save-chat-btn"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="Save chat"' in tag
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)"
# Beside New chat: after it, still inside .chat-shell, above #messages.
main_idx = html.find('main id="main"')
shell_idx = html.find('class="container chat-shell"')
new_idx = html.find('id="new-chat-btn"')
messages_idx = html.find('id="messages"')
assert -1 < main_idx < shell_idx < new_idx < btn.start() < messages_idx, (
"the button must sit beside #new-chat-btn in .chat-shell, above #messages"
)
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
assert 'id="save-chat-btn"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the Save button is chat-page only"
)
def test_save_button_css_is_the_exact_new_chat_family() -> None:
"""styles.css: .save-chat-btn carries the EXACT visual family of
.new-chat-btn — solid brand pill (--bg on --brand = 5.2:1, AA),
borderless, 999px radius, ≥44px target, hover lightens the brand
fill; the ≤640px block mirrors the New chat overrides (label stays
visible in .chat-shell, icon hidden there; icon-only elsewhere)."""
assert 'id="save-chat-btn"' not in html, "index.html must not carry #save-chat-btn"
assert "save-chat-label" not in html, "no Save label left in index.html"
css = _css()
block = re.search(r"\.save-chat-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .save-chat-btn"
body = block.group(1)
assert "min-height: 44px" in body
assert "border-radius: 999px" in body
assert "border: 0" in body
assert "background: var(--brand)" in body, "same solid brand fill as New chat"
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
hover = re.search(r"\.save-chat-btn:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
svg = re.search(r"\.save-chat-btn svg \{([\s\S]*?)\n\}", css)
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like New chat)"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
mbody = mobile.group(1)
assert ".save-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, "squeezes with New chat"
assert ".save-chat-label { display: none; }" in mbody
assert ".save-chat-btn svg { display: block; }" in mbody
assert ".chat-shell .save-chat-label { display: inline; }" in mbody, (
"in .chat-shell the label stays visible, as for New chat"
assert "save-chat-btn" not in css, "styles.css must not style .save-chat-btn"
assert "save-chat-label" not in css, "styles.css must not style .save-chat-label"
# The ≤900px combined squeeze rule drops the Save pill (New chat +
# auth only).
tablet = re.search(r"@media \(max-width: 900px\) \{([\s\S]*?)\n\}", css)
assert tablet, "tablet media query missing"
assert ".new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }" in tablet.group(1), (
"the tablet squeeze rule is New chat + auth only"
)
assert ".chat-shell .save-chat-btn svg { display: none; }" in mbody
js = _js()
assert "saveBtn" not in js, "no saveBtn symbol left in app.js"
assert "saveCurrentChat" not in js, "no saveCurrentChat symbol left in app.js"
assert 'querySelector("#save-chat-btn")' not in js, "the pill query is gone"
# ---------- currentChatId lifecycle ----------
@@ -115,20 +124,27 @@ def test_save_button_css_is_the_exact_new_chat_family() -> None:
def test_current_chat_id_module_scope_and_lifecycle() -> None:
"""currentChatId: module scope, string | null — set to the created
row's id on a fresh Save (201), set to the opened id on a
successful boot load, cleared by "New chat" AND by the 404-PUT
fallback (a stale link must never leave the conversation unsaved)."""
row's id on a fresh auto-save (201), set to the opened id on a
successful boot load, hydrated from the record on the local restore
(phase 55 — the link survives reloads), cleared by "New chat" AND by
the 404-PUT fallback (a stale link must never wedge the
conversation)."""
js = _js()
assert "let currentChatId = null" in js, "module-scope link, null = unlinked"
# Set on create: the 201 branch links to the created row's id.
save_body = _fn(js, "saveCurrentChat")
save_body = _fn(js, "persistConversation")
assert "res.status === 201" in save_body
assert "currentChatId = String(created.id)" in save_body, (
"a fresh Save links to the created row's id"
"a fresh auto-save links to the created row's id"
)
# Set on open: the boot load links to the fetched id.
load_body = _fn(js, "restoreSavedChatFromUrl")
assert "currentChatId = chatId" in load_body
# Hydrated on the local restore: the record carries the link.
restore_body = _fn(js, "restoreConversation")
assert "currentChatId = record ? record.chatId : null" in restore_body, (
"the local restore hydrates the link from the record (phase 55)"
)
# Cleared by New chat.
new_body = _fn(js, "startNewChat")
assert "currentChatId = null" in new_body, "New chat unlinks"
@@ -137,24 +153,24 @@ def test_current_chat_id_module_scope_and_lifecycle() -> None:
assert save_body.count("currentChatId = null") >= 1
# ---------- the upsert branch ----------
# ---------- the headless auto-save (phase 55, A2) ----------
def test_save_upsert_put_when_linked_post_when_not() -> None:
"""saveCurrentChat: linked → PUT /api/chats/<id> with the messages
payload (re-Save updates the SAME row — no title in the body, so the
row keeps its current one); unlinked → POST /api/chats (the server
auto-titles). The 404 from the PUT unlinks and retries as a create.
Empty conversation → no request, live-region "Nothing to save
yet."; success → live-region "Conversation saved." (status text
only, no banner); 403/5xx/network → the error banner."""
def test_persist_conversation_upsert_semantics() -> None:
"""persistConversation (the headless replacement of the phase-50
Save handler): the EXACT upsert semantics, unchanged — linked →
PUT /api/chats/<id> with the messages payload (the SAME row updates
— no title in the body, so the row keeps its current one);
unlinked → POST /api/chats (the server auto-titles) and link to the
created id (201). The 404 from the PUT unlinks and retries as a
create — a stale link can never wedge the conversation. Empty
conversation → no-op (no request, no feedback line)."""
js = _js()
body = _fn(js, "saveCurrentChat")
# No-op first: nothing to save → live-region line, no fetch.
noop = body.find('sendStatus.textContent = "Nothing to save yet."')
body = _fn(js, "persistConversation")
# No-op first: nothing to save → silent return before any fetch.
noop = body.find("if (!conversation.length) return;")
first_fetch = body.find("await fetch(")
assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch"
assert "if (!conversation.length)" in body
# The branch: PUT when linked, POST when not.
assert "if (currentChatId)" in body
assert '`/api/chats/${currentChatId}`' in body
@@ -170,46 +186,125 @@ def test_save_upsert_put_when_linked_post_when_not() -> None:
assert notfound_idx != -1, "the PUT 404 must be handled"
fallback = body[notfound_idx:post_idx]
assert "currentChatId = null" in fallback, "the stale link is dropped"
# Success is status text only — the live region, never stale — and
# nothing between the 201 link and the success line may raise a
# banner (the !res.ok branch returns before either).
assert body.count('sendStatus.textContent = "Conversation saved."') == 1
saved_line = 'sendStatus.textContent = "Conversation saved."'
between = body[body.find("res.status === 201") : body.find(saved_line)]
assert "showErrorBanner" not in between, "no banner on the success path"
# Failures raise an actionable banner (non-ok HTTP + network).
assert 'showErrorBanner("Couldn\'t save the conversation — is the app reachable?")' in body
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
# The double-click guard releases on EVERY outcome.
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "saveBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
# The 201 branch links to the created row.
assert "res.status === 201" in body
assert "currentChatId = String(created.id)" in body
def test_persist_conversation_is_headless_and_quiet() -> None:
"""The A2 quiet contract: NO error banner anywhere in the helper
(the phase-50 banner lines are gone), NO success status text
("Conversation saved." is retired — success is silent; the History
page is the visible proof), and the failure feedback is the
one-line #send-status note — on BOTH failure paths (non-ok HTTP and
network) — with the "next save point retries" promise. The
module-level `persisting` flag is the double-fire guard (released
in the finally — never stuck)."""
js = _js()
body = _fn(js, "persistConversation")
assert "showErrorBanner" not in body, "A2: a failed auto-save never raises a banner"
assert "Conversation saved." not in body, "A2: success is silent (no status text)"
note = "Couldn't save automatically — will try on the next message."
assert body.count(note) == 2, "the one-line note covers non-ok AND network failure"
# The non-ok branch notes and returns (no banner, no 201 handling).
notok = body.find("if (!res.ok)")
first_note = body.find(note)
assert -1 < notok < first_note, "the non-ok branch lands on the one-line note"
# The network path (catch) notes too.
catch_idx = body.find("} catch {")
assert catch_idx != -1 and first_note < body.rfind(note) < body.rfind("finally"), (
"the catch branch carries the second note"
)
# The module-level double-fire guard, released on EVERY outcome.
assert re.search(r"^let persisting = false", js, re.M), (
"the persisting flag is module scope (save points can overlap)"
)
assert "if (persisting) return;" in body, "an in-flight upsert skips the second call"
assert "persisting = true;" in body
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "persisting = false" in body[finally_idx:], (
"the flag is released in the finally — never stuck"
)
def test_auto_save_wired_to_the_save_points() -> None:
"""The headless helper is referenced from the save points: the user
send (runTurn's ``!reask`` block — after the localStorage
saveConversation()) and the brain save point (rememberBrainTurn —
after its saveConversation()). The pagehide partial rides
rememberBrainTurn: NO second direct call there."""
js = _js()
# Save point 1: the user send in runTurn's !reask block.
turn = js.find("async function runTurn")
reask_block = js[js.find("if (!reask) {", turn) : js.find("let wrap = null", turn)]
save1 = reask_block.find("saveConversation();")
persist1 = reask_block.find("persistConversation();")
assert -1 < save1 < persist1, "the user-send save point rides persistConversation()"
# Save point 2: rememberBrainTurn (the brain-done + stop + pagehide path).
body = _fn(js, "rememberBrainTurn")
save2 = body.find("saveConversation();")
persist2 = body.find("persistConversation();")
assert -1 < save2 < persist2, "the brain save point rides persistConversation()"
# The pagehide handler itself carries no direct persist call — the
# partial rides rememberBrainTurn (no extra wiring, phase 20
# contract untouched).
m = re.search(r'window\.addEventListener\("pagehide", \(\) => \{([\s\S]*?)\n\}\);', js)
assert m, "the pagehide handler must exist"
assert "persistConversation" not in m.group(1), (
"the pagehide partial rides rememberBrainTurn — no second call"
)
def test_record_carries_the_row_link() -> None:
"""Phase 55 (A2): the bor.chat.v1 record carries ``chatId``. The
write (saveConversation) persists the CURRENT currentChatId (null
when unlinked) with the versioned record; the reader
(loadStoredRecord) reads it back with old-record safety — a
pre-55 record without the field (or a non-string) reads as null,
never throws; the restore validates the version before trusting
anything."""
js = _js()
save_body = _fn(js, "saveConversation")
assert "chatId: currentChatId" in save_body, ("the write persists the current link")
assert "v: STORAGE_VERSION" in save_body
assert "trimToBudget(conversation)" in save_body
read_body = _fn(js, "loadStoredRecord")
assert "data.v !== STORAGE_VERSION" in read_body, "version validated first"
assert "Array.isArray(data.messages)" in read_body
# Old-record safety: optional field, string check, null fallback.
assert 'typeof data.chatId === "string"' in read_body
assert "data.chatId.length ? data.chatId : null" in read_body
# The defensive message filter survives the reshape.
assert 'm.who === "user" || m.who === "brain"' in read_body
assert 'typeof m.text === "string"' in read_body
# ---------- boot-load precedence ----------
def test_boot_load_precedence_saved_chat_over_local_restore() -> None:
"""Inside the boot IIFE: after fetchIsAdmin() + the reveal gate,
restoreSavedChatFromUrl() runs; only when it returns false does the
phase-14 local restore run. Header init stays first (shared-module
contract)."""
"""Inside the boot IIFE: after fetchIsAdmin(), restoreSavedChatFromUrl()
runs; only when it returns false does the phase-14 local restore run
(which hydrates the row link from the record — phase 55). Header init
stays first (shared-module contract). The phase-50 Save-reveal line is
GONE — and phase 55 task 03 removed the Share-reveal line too (the
pill is static, always-visible markup: no reveal step at boot)."""
js = _js()
boot_start = js.find("(async () => {")
assert boot_start != -1, "the boot IIFE must exist"
boot = js[boot_start:]
init_i = boot.find("await initSharedHeader();")
admin_i = boot.find("isAdmin = await fetchIsAdmin();")
reveal_i = boot.find("saveBtn.hidden = !isAdmin")
saved_i = boot.find("await restoreSavedChatFromUrl();")
local_i = boot.find("restoreConversation();")
assert -1 < init_i < admin_i < reveal_i < saved_i < local_i, (
"boot order: header init → whoami → Save reveal → ?chat= load → local fallback"
assert -1 < init_i < admin_i < saved_i < local_i, (
"boot order: header init → whoami → ?chat= load → local fallback"
)
assert "shareBtn.hidden" not in boot, ("no Share-reveal line left in boot (phase 55 task 03)")
assert "if (!openedSaved) restoreConversation();" in boot, (
"the local restore runs ONLY when the saved-chat load did not open"
)
assert "saveBtn" not in boot, "no Save-reveal line left in boot (phase 55)"
def test_boot_load_gates_valid_uuid_and_admin_only() -> None:
@@ -261,20 +356,17 @@ def test_boot_load_gates_valid_uuid_and_admin_only() -> None:
# ---------- the reveal gate ----------
def test_save_button_revealed_only_for_admin() -> None:
"""The ship-hidden/reveal-for-admin contract: app.js queries
#save-chat-btn, binds the click to saveCurrentChat, and the boot
IIFE sets saveBtn.hidden = !isAdmin (phase 16 absent-not-hidden —
hidden is display:none, no trace for anonymous)."""
def test_no_save_pill_wiring_left_in_app_js() -> None:
"""Phase 55 (A2): the Save pill's wiring is GONE — no
#save-chat-btn query, no click binding to a save handler, no boot
reveal line. The headless persistConversation() replaces all of it
(no button, no admin gate: every visitor's conversation auto-saves
— the write surface is public, phase 55 task 01)."""
js = _js()
assert 'document.querySelector("#save-chat-btn")' in js
assert 'saveBtn?.addEventListener("click", saveCurrentChat)' in js
assert "saveBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot"
# The reveal happens in the boot IIFE (after whoami), not at module
# evaluation (isAdmin is false there).
boot_start = js.find("(async () => {")
reveal = js.find("saveBtn.hidden = !isAdmin")
assert boot_start < reveal, "the reveal must run at boot, after whoami resolves"
assert 'querySelector("#save-chat-btn")' not in js, "the pill query is gone"
assert "saveCurrentChat" not in js, "the button handler is gone"
assert 'addEventListener("click", saveCurrentChat)' not in js, "no save binding"
assert "saveBtn" not in js, "no saveBtn symbol anywhere (the tune form uses its own)"
def test_boot_load_adds_no_direct_storage_access() -> None:
@@ -296,29 +388,32 @@ def test_no_cdn_added() -> None:
# ---------- the Share button on the chat page (phase 51, task 02) ----------
def test_share_button_ships_hidden_beside_save() -> None:
def test_share_button_ships_visible_beside_new_chat() -> None:
"""#share-chat-btn: a real type=button with the accessible name
"Share chat", SHIPPED HIDDEN (app.js reveals it for admin only),
BESIDE #save-chat-btn in .chat-shell inside <main>, above
#messages — the chat-shell actions read as a pair (Save | Share).
No other page carries it (chat-page only, like Save)."""
"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)."""
html = _index()
btn = re.search(r'<button[^>]*id="share-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #share-chat-btn"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="Share chat"' in tag
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)"
assert "hidden" not in tag, ("the button ships visible — no reveal step (phase 55 task 03)")
# The label: the visible text is "Share" (the link SVG is aria-hidden
# decoration; the aria-label carries the accessible name).
btn_block = html[btn.start() : html.find("</button>", btn.start())]
assert '>Share</span>' in btn_block
# Beside Save: after it, still inside .chat-shell, above #messages.
# Beside New chat (the Save pill is gone): after it, still inside
# .chat-shell, above #messages.
shell_idx = html.find('class="container chat-shell"')
save_idx = html.find('id="save-chat-btn"')
new_idx = html.find('id="new-chat-btn"')
messages_idx = html.find('id="messages"')
assert -1 < shell_idx < save_idx < btn.start() < messages_idx, (
"the button must sit beside #save-chat-btn in .chat-shell, above #messages"
assert -1 < shell_idx < new_idx < btn.start() < messages_idx, (
"the button must sit beside #new-chat-btn in .chat-shell, above #messages"
)
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
@@ -328,11 +423,13 @@ def test_share_button_ships_hidden_beside_save() -> None:
def test_share_button_css_is_the_exact_save_family() -> None:
"""styles.css: .share-chat-btn carries the EXACT visual family of
.save-chat-btn (same solid brand pill — --bg on --brand = 5.2:1, AA;
borderless; 999px radius; ≥44px target; hover lightens the brand
fill); the ≤640px block mirrors the Save overrides (label stays
visible in .chat-shell, icon hidden there; icon-only elsewhere)."""
"""styles.css: .share-chat-btn carries the EXACT visual family of the
phase-50 Save pill (now the .new-chat-btn family — the Save rules
are gone with the pill, phase 55): same solid brand pill — --bg on
--brand = 5.2:1, AA; borderless; 999px radius; ≥44px target; hover
lightens the brand fill; the ≤640px block mirrors the New chat
overrides (label stays visible in .chat-shell, icon hidden there).
"""
css = _css()
block = re.search(r"\.share-chat-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .share-chat-btn"
@@ -368,9 +465,11 @@ def test_share_current_chat_save_then_share_branch() -> None:
copied — the clipboard try succeeds → the live region reads
"Share link copied."; the rejection (a non-secure http origin)
renders the .share-link-fallback field + "Share link ready — copy it
from the field." 403/5xx → the actionable banner (signed-out hint);
network → the reachable? banner. The double-click guard releases in
the finally — never stale."""
from the field." 403/5xx → the actionable banner (NEUTRAL "try
again" — the write surface is public, phase 55 task 01, so a 403
is no longer a sign-in problem for a guest); network → the
reachable? banner. The double-click guard releases in the finally —
never stale."""
js = _js()
body = _fn(js, "shareCurrentChat")
# No-op first: nothing to share → live-region line, no fetch.
@@ -405,11 +504,15 @@ def test_share_current_chat_save_then_share_branch() -> None:
' : "Share link ready — copy it from the field."'
) in body
# Failures raise an actionable banner (non-ok HTTP + network).
# Phase 55 task 03: the 403/5xx copy is NEUTRAL ("try again") — no
# sign-in wording anywhere in the share handler (the write surface
# is public); the network banner keeps its own line.
assert 'showErrorBanner("Couldn\'t share the conversation — is the app reachable?")' in body
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
assert body.count("check you're still signed in and try again") == 2, (
"both the linked and the unlinked branch carry the non-ok banner"
neutral = "Couldn't share the conversation — try again."
assert body.count(neutral) == 2, (
"both the linked and the unlinked branch carry the neutral non-ok banner"
)
assert "signed in" not in body, "no sign-in wording left in the share handler (task 03)"
# The double-click guard releases on EVERY outcome.
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
@@ -430,23 +533,239 @@ def test_share_current_chat_save_then_share_branch() -> None:
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
def test_share_button_revealed_only_for_admin() -> None:
"""The ship-hidden/reveal-for-admin contract: app.js queries
#share-chat-btn, binds the click to shareCurrentChat, and the boot
IIFE sets shareBtn.hidden = !isAdmin in the SAME admin-reveal block
as Save (phase 16 absent-not-hidden — no trace for anonymous)."""
def test_share_button_has_no_reveal_gate() -> None:
"""Phase 55 task 03: the pill is VISIBLE TO EVERY VISITOR — app.js
queries #share-chat-btn and binds the click to shareCurrentChat, but
there is NO reveal step: no ``shareBtn.hidden`` assignment ANYWHERE
in app.js (the phase-51 ship-hidden/admin-reveal gate is gone; the
markup ships visible and task 01 opened the write surface to all).
"""
js = _js()
assert 'document.querySelector("#share-chat-btn")' in js
assert 'shareBtn?.addEventListener("click", shareCurrentChat)' in js
assert "shareBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot"
# The reveal happens in the boot IIFE (after whoami), not at module
# evaluation — and right next to Save's own reveal line.
boot_start = js.find("(async () => {")
reveal = js.find("shareBtn.hidden = !isAdmin")
save_reveal = js.find("saveBtn.hidden = !isAdmin")
assert boot_start < save_reveal < reveal, (
"the Share reveal joins the same admin-reveal block as Save"
assert "shareBtn.hidden" not in js, "no reveal step — the pill ships visible to all"
# ---------- the share-success toast (phase 55, task 04, A4) ----------
def test_show_toast_helper_single_instance_and_aria_hidden() -> None:
"""showToast (A4 owner-locked): a SINGLE node — lazy-created on the
first call and REUSED thereafter (toasts never stack), a plain
``<div class="toast">`` appended to ``document.body``; the text
lands via ``textContent`` (XSS-safe — never innerHTML); the node is
``aria-hidden="true"`` (visual only — #send-status is the
announcer). Re-triggering the entry (a second share while the first
toast is up): clear the pending dismiss timer, remove the visible
state class, force a reflow (``offsetWidth`` — restarts the CSS
transition), re-add the class. Auto-dismiss: a 4000ms timer set
AFTER the visible class is added, removing the class on fire."""
js = _js()
body = _fn(js, "showToast")
# Lazy single instance, appended to <body>, marked visual-only.
assert "if (!toastEl)" in body, "the node is created once, on first use"
assert 'document.createElement("div")' in body
assert 'toastEl.className = "toast"' in body
assert 'toastEl.setAttribute("aria-hidden", "true")' in body, ("A4: visual only")
assert "document.body.appendChild(toastEl)" in body
# textContent only — never innerHTML.
assert "toastEl.textContent = message" in body
assert "innerHTML" not in body, "XSS contract: textContent only"
# Single instance: module-scope node + timer, reused (no stacking).
assert re.search(r"^let toastEl = null", js, re.M), ("the node is module scope")
assert re.search(r"^let toastTimer = 0", js, re.M), ("the timer is module scope")
# Re-trigger order: clear dismiss → remove class → force reflow →
# re-add the visible class.
clear_i = body.find("clearTimeout(toastTimer)")
remove_i = body.find('toastEl.classList.remove("is-visible")')
reflow_i = body.find("void toastEl.offsetWidth")
add_i = body.find('toastEl.classList.add("is-visible")')
assert -1 < clear_i < remove_i < reflow_i < add_i, (
"dismiss cleared → class removed → reflow forced → visible re-added"
)
# Auto-dismiss ~4s, armed AFTER the visible class is set.
timer_i = body.find("setTimeout")
assert -1 < add_i < timer_i and "4000" in body
assert 'toastEl.classList.remove("is-visible")' in body[timer_i:], (
"the pending dismiss removes the visible state"
)
def test_toast_called_from_both_share_success_branches_only() -> None:
"""shareCurrentChat (task 04): BOTH success paths call showToast
with their own text — the clipboard path → "Share link copied.",
the fallback-field path → "Share link ready — copy it from the
field." — and both calls ride the SUCCESS branch (after the copy,
after the untouched #send-status live-region lines). showToast
appears EXACTLY twice in the handler and never in a failure branch
(the two !res.ok banners precede the copy; the network catch —
the error banner is the failure UI — carries no toast)."""
js = _js()
body = _fn(js, "shareCurrentChat")
assert body.count("showToast(") == 2, "exactly one toast per success path"
copy_i = body.find("copyShareLinkWithFallback(absoluteShareUrl(shareUrl))")
assert copy_i != -1, "the copy (the success branch) must exist"
t1 = body.find('showToast("Share link copied.")')
t2 = body.find('showToast("Share link ready — copy it from the field.")')
assert t1 != -1 and t2 != -1, "both success paths toast their own text"
assert -1 < copy_i < min(t1, t2), ("the toasts ride the SUCCESS branch (after the copy)")
# The #send-status lines stay exactly as they were (the a11y
# announcer) and precede the toast calls.
status_i = body.find("sendStatus.textContent = copied")
assert -1 < status_i < min(t1, t2)
# Never on failure: the catch block carries no toast.
catch_i = body.rfind("} catch {")
assert catch_i != -1 and "showToast" not in body[catch_i:], (
"a failed share shows the error banner, no toast"
)
def test_toast_css_top_right_brand_family_and_reduced_motion() -> None:
"""styles.css (task 04): .toast — position: fixed, top-right just
under the sticky header (--header-h + offset — the variable steps
64px → 58px at ≤640px), z-index 1000 (the modal overlay contract —
above the header's 20), a small max-width so long text wraps, the
solid brand fill (--bg text on --brand = 5.2:1, AA — the
.new-chat-btn family), rounded + shadowed. Hidden by default
(opacity 0 + pointer-events: none — it never intercepts clicks when
idle) and resting at translateY(-8px), with the ~200ms entry
transition; .toast.is-visible lands at opacity 1 / translateY(0).
Under prefers-reduced-motion: reduce the transform is dropped for
BOTH states (.is-visible would otherwise out-specify the bare
.toast) and the opacity fade remains."""
css = _css()
block = re.search(r"\.toast \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .toast"
body = block.group(1)
for prop in (
"position: fixed",
"top: calc(var(--header-h) + 0.75rem)",
"right: 1rem",
"z-index: 1000",
"max-width: min(22rem, calc(100vw - 2rem))",
"background: var(--brand)",
"color: var(--bg)",
"border-radius: var(--radius-sm)",
"box-shadow: var(--shadow)",
"opacity: 0",
"pointer-events: none",
"transform: translateY(-8px)",
):
assert prop in body, f".toast must keep {prop}"
assert "transition:" in body and "200ms" in body, ("the entry is a ~200ms slide-down + fade")
visible = re.search(r"\.toast\.is-visible \{([\s\S]*?)\n\}", css)
assert visible, "the .toast.is-visible state class (toggled by showToast) must exist"
assert "opacity: 1" in visible.group(1)
assert "transform: translateY(0)" in visible.group(1)
# The reduced-motion override: transform dropped (BOTH states
# named), the opacity fade kept.
rm = None
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css):
if ".toast" in m.group(1):
rm = m.group(1)
break
assert rm is not None, "a reduced-motion block must cover .toast"
assert ".toast.is-visible { transform: none; }" in rm, ("the slide is dropped for BOTH states")
assert re.search(r"\.toast \{ transition: opacity", rm), ("the opacity fade remains")
# ---------- the chat-actions row (phase 55, task 05, A5) ----------
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
other page carries ``.chat-actions`` (chat-page only, like the
pills)."""
html = _index()
start = html.find('<div class="chat-actions">')
assert start != -1, "index.html must carry the .chat-actions wrapper"
end = html.find("</div>", start)
assert end != -1, "the wrapper must close"
wrap = html[start:end]
# Exactly two element children: the two pill buttons, New chat first.
assert wrap.count("<div") == 1, "no nested div inside the row wrapper"
assert wrap.count("<button") == 2, "the row holds exactly the two pills"
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).
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"
)
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"), (
f"{other.name}: the action row is chat-page only"
)
def test_chat_actions_row_on_desktop_and_stack_at_640() -> None:
"""styles.css (task 05, A5): the base ``.chat-actions`` rule is a
horizontal flex row — ``display: flex; flex-direction: row;
align-items: center; gap: 0.6rem``. The ``align-items: center`` is
load-bearing: the wrapper is a flex ITEM of the ``.chat-shell``
column (which stretches its items), and the row's own
cross-axis ``center`` (not the column default ``stretch``) keeps
each pill at its intrinsic content width — two pills side by side,
left-aligned, never full-column. The ≤640px override flips the
row to a full-width vertical stack — ``flex-direction: column;
align-items: stretch; gap: 0.5rem`` (New chat above Share) — and
the EXISTING ≤640px pill rules (padding squeeze, the icon/label
handling, the ``.chat-shell`` label overrides) stay in place for
the stacked pills."""
css = _css()
block = re.search(r"\.chat-actions \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .chat-actions (base row)"
for prop in (
"display: flex",
"flex-direction: row",
"align-items: center",
"gap: 0.6rem",
):
assert prop in block.group(1), f".chat-actions must keep {prop}"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
mbody = mobile.group(1)
m = re.search(r"\.chat-actions \{([^}]*)\}", mbody)
assert m, "the ≤640px override (vertical stack) must exist"
for prop in (
"flex-direction: column",
"align-items: stretch",
"gap: 0.5rem",
):
assert prop in m.group(1), f"the ≤640px .chat-actions must keep {prop}"
# The stacked pills keep their existing mobile treatment (the rules
# the phase-50/51 pairs established — untouched by this task).
for rule in (
".new-chat-btn { padding: 0.4rem 0.3rem; }",
".share-chat-btn { padding: 0.4rem 0.3rem; }",
".new-chat-label { display: none; }",
".share-chat-label { display: none; }",
".chat-shell .new-chat-label { display: inline; }",
".chat-shell .new-chat-btn svg { display: none; }",
".chat-shell .share-chat-label { display: inline; }",
".chat-shell .share-chat-btn svg { display: none; }",
):
assert rule in mbody, f"the existing ≤640px pill rule must stay: {rule}"
# ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ----------
@@ -571,7 +890,7 @@ def test_stale_regenerate_persists_the_linked_row() -> None:
"""The post-regenerate persist (the existing upsert path): linked →
PUT /api/chats/<id> (the server re-stamps sources_version → the row
is fresh); a 404 (the row was deleted from History meanwhile)
follows saveCurrentChat's stale-link rule — unlink + recreate
follows persistConversation's stale-link rule — unlink + recreate
(POST), and the recreate links the new id. Success hides the banner
AND announces the outcome in the #send-status live region; 403/5xx
→ the actionable error banner (the row stays as the turn left it);
@@ -616,19 +935,22 @@ def test_stale_regenerate_binding_and_element_queries() -> None:
assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js
def test_stale_banner_cleared_on_new_chat_and_resave() -> None:
def test_stale_banner_cleared_on_new_chat_and_autosave() -> None:
"""Never-stale (PLAN §7.4): "New chat" replaces the conversation the
banner described (and unlinks it) — the banner hides; a successful
manual re-Save re-stamps the row to the current generation (task
03) — the banner is done the moment the save succeeds."""
auto-save re-stamps the row to the current generation (task 03) —
the banner is done the moment the save succeeds (on the success
path only — after the !res.ok early return and the 201 link)."""
js = _js()
new_body = _fn(js, "startNewChat")
assert "staleBanner.hidden = true" in new_body, "New chat hides the banner"
save_body = _fn(js, "saveCurrentChat")
saved_line = 'sendStatus.textContent = "Conversation saved."'
after = save_body[save_body.find(saved_line):]
assert "staleBanner.hidden = true" in after, (
"a successful re-save re-stamps the row — the banner is done"
save_body = _fn(js, "persistConversation")
notok_idx = save_body.find("if (!res.ok)")
two01_idx = save_body.find("res.status === 201")
hide_idx = save_body.find("staleBanner.hidden = true")
catch_idx = save_body.find("} catch {")
assert -1 < notok_idx < two01_idx < hide_idx < catch_idx, (
"the banner clears on the success path, after the 201 link, never on failure"
)