feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare

This commit is contained in:
2026-08-30 01:34:44 -04:00
parent ece93a7c8f
commit 114b115034
28 changed files with 3442 additions and 54 deletions
+199 -12
View File
@@ -39,11 +39,13 @@ TUNING_HTML = FRONTEND / "tuning.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
HISTORY_HTML = FRONTEND / "history.html"
SHARED_HTML = FRONTEND / "shared.html" # phase 51: the anonymous shared page
HISTORY_JS = ASSETS / "history.js"
HEADER_JS = ASSETS / "header.js"
STYLES_CSS = ASSETS / "styles.css"
#: The phase-34 one-bar contract + the new History page: SEVEN pages.
#: The phase-34 one-bar contract + the History page + the shared
#: page: EIGHT pages.
ALL_PAGES = (
INDEX_HTML,
SOURCES_HTML,
@@ -52,6 +54,7 @@ ALL_PAGES = (
DOCUMENT_HTML,
LOGIN_HTML,
HISTORY_HTML,
SHARED_HTML,
)
@@ -110,16 +113,17 @@ def test_nav_history_present_on_all_seven_pages() -> None:
)
def test_nav_history_count_is_exactly_seven_pages() -> None:
def test_nav_history_count_is_exactly_eight_pages() -> None:
"""The pin counting occurrences across ``frontend/*.html`` — exactly
one ``id="nav-history"`` per page, seven pages, no duplicates and no
extra page that forgot (or added twice)."""
one ``id="nav-history"`` per page, eight pages (phase 51: + the
shared page), no duplicates and no extra page that forgot (or
added twice)."""
total = 0
for html in sorted(FRONTEND.glob("*.html")):
count = html.read_text(encoding="utf-8").count('id="nav-history"')
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
total += count
assert total == 7, f"expected #nav-history on 7 pages, found {total}"
assert total == 8, f"expected #nav-history on 8 pages, found {total}"
def test_header_js_reveals_nav_history_for_admin() -> None:
@@ -173,27 +177,35 @@ def test_history_page_scaffold_and_landmarks() -> None:
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the four columns —
Title | Messages | Updated | Actions (the Actions header text is
visually-hidden — the row buttons carry their own aria-labels) —
and the empty-state row (ship-hidden, the exact copy)."""
"""The table skeleton: ``.history-table`` with the five columns —
Title | Messages | Updated | Share (phase 51) | Actions (the
Actions header text is visually-hidden — the row buttons carry
their own aria-labels) — and the empty-state row (ship-hidden, the
exact copy)."""
html = _text(HISTORY_HTML)
assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>'):
'<th scope="col">Updated</th>', '<th scope="col">Share</th>'):
assert col in html
# The Share column sits BETWEEN Updated and Actions.
assert (
html.find('<th scope="col">Updated</th>')
< html.find('<th scope="col">Share</th>')
< html.find('visually-hidden">Actions')
), "the Share column must sit between Updated and Actions"
actions_th = re.search(
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
html,
)
assert actions_th, "the Actions column header must be visually-hidden text"
assert actions_th.group(1) == "", "no visible text beside the hidden header"
# The empty-state row: ship-hidden, colspan 4, the exact copy.
# The empty-state row: ship-hidden, colspan 5 (the Share column
# joined the table in phase 51), the exact copy.
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
assert row, "the empty-state row must ship in the skeleton"
assert "hidden" in row.group(0)
assert 'id="history-empty-row"' in row.group(0)
assert "<td colspan=\"4\">" in html
assert "<td colspan=\"5\">" in html
assert (
"No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat."
@@ -426,3 +438,178 @@ def test_history_table_mobile_behavior() -> None:
mbody = mobile.group(1)
assert ".history-actions-cell { white-space: normal; }" in mbody
assert ".history-actions { flex-wrap: wrap; }" in mbody
# ---------- the Share column (phase 51, owner-locked 2026-08-29) ----------
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
"""makeRow: the Share <td> (with the share control) lands BETWEEN
the Updated cell and the Actions cell — the column order in
history.html is Title | Messages | Updated | Share | Actions."""
js = _js()
row = _fn(js, "makeRow")
updated_i = row.find('updatedTd.className = "history-updated-cell"')
share_i = row.find('shareTd.className = "history-share-cell"')
actions_i = row.find('actionsTd.className = "history-actions-cell"')
assert -1 < updated_i < share_i < actions_i, (
"the share cell must sit between Updated and Actions"
)
assert "makeShareControl(chat)" in row
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
f"row cell order must be title/count/updated/share/actions, got {seq}"
)
def test_share_control_three_states_and_two_step_unshare() -> None:
"""The share cell's THREE states — unshared → [Create link];
shared → [Copy] [Unshare]; confirming → "Unshare? [Yes] [No]" —
plus the inline two-step unshare (the phase-50 Delete-confirm
pattern: focus moves to Yes, No restores the shared state, no
native dialog). The shipped state comes from the row's share_url
(the list endpoint populates it — no second fetch)."""
js = _js()
assert "window.confirm" not in js, "history.js must use the inline two-step only"
# makeShareControl: the shipped state branches on chat.share_url.
make = _fn(js, "makeShareControl")
assert "cell.className = \"history-share\"" in make
assert "chat.share_url" in make
assert "renderShareShared(chat, cell)" in make
assert "renderShareUnshared(chat, cell)" in make
# Unshared state: the Create link button (labeled, textContent).
unshared = _fn(js, "renderShareUnshared")
assert 'create.className = "history-share-create"' in unshared
assert 'create.textContent = "Create link"' in unshared
assert 'create.setAttribute("aria-label", `Create share link: ${chat.title}`)' in unshared
assert "innerHTML" not in unshared, "XSS contract: textContent only"
# Shared state: Copy + Unshare, then the two-step confirm.
shared = _fn(js, "renderShareShared")
assert 'copy.className = "history-share-copy"' in shared
assert 'copy.textContent = "Copy"' in shared
assert 'unshare.className = "history-unshare"' in shared
assert 'unshare.textContent = "Unshare"' in shared
assert 'label.textContent = "Unshare?"' in shared
assert 'yes.className = "history-confirm-yes"' in shared, (
"the unshare two-step reuses the phase-50 .history-confirm-* pair"
)
assert 'no.className = "history-confirm-no"' in shared
assert "cell.replaceChildren(label, yes, no)" in shared
swap_i = shared.find("cell.replaceChildren(label, yes, no)")
assert shared.find("yes.focus(", swap_i) > 0, "focus moves to Yes after the swap"
assert 'no.addEventListener("click", restoreShared)' in shared
restore_i = shared.find("function restoreShared")
assert restore_i != -1
assert "cell.replaceChildren(copy, unshare)" in shared[restore_i:restore_i + 120], (
"No (and a failed request) restore the shared state"
)
def test_share_create_and_unshare_request_outcomes() -> None:
"""createShareLink: POST /api/chats/<id>/share → the response's
share_url becomes the row's data, the cell re-renders shared, and
the ABSOLUTE link is offered for copying (clipboard → fallback);
non-2xx / network keep the unshared state (retryable) + the error
line. confirmUnshare: POST /api/chats/<id>/unshare → the cell
re-renders unshared + `Unshared "<title>".`; non-2xx / network
restore the shared state + the error line. Both double-fire
guarded."""
js = _js()
create = _fn(js, "createShareLink")
assert "createBtn.disabled = true" in create
assert 'fetch(`/api/chats/${chat.id}/share`, { method: "POST" })' in create
assert "renderShareShared(chat, cell)" in create, "success re-renders the shared state"
assert "chat.share_url = share_url" in create, "the row's data gains the link"
assert "new URL(share_url, window.location.origin).toString()" in create, (
"the ABSOLUTE link is what gets copied (the origin supplies scheme/host)"
)
assert (
'announce(copied ? "Share link copied."'
' : "Share link ready — copy it from the field.")'
) in create
assert "is the app reachable?" in create, "the network-error line"
assert "try again" in create, "the non-2xx line"
# A failed request keeps the button (re-enabled) — retryable.
assert create.count("createBtn.disabled = false") == 2, (
"both failure paths re-enable the Create link button"
)
unshare = _fn(js, "confirmUnshare")
assert "yesBtn.disabled = true" in unshare
assert 'fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" })' in unshare
assert "chat.share_url = null" in unshare, "a revoked link drops the row's share_url"
assert "renderShareUnshared(chat, cell)" in unshare, "success re-renders the unshared state"
assert 'announce(`Unshared "${chat.title}".`)' in unshare
assert unshare.count("restoreShared()") == 2, (
"non-2xx and network both restore the shared state (retryable)"
)
assert "is the app reachable?" in unshare
assert "try again" in unshare
def test_share_copy_uses_own_per_page_clipboard_helper_with_fallback() -> None:
"""The per-page duplication house style: history.js keeps its OWN
~10-line copy of the clipboard + inline-link fallback helper (no
import from app.js, no new shared module). A non-secure (http)
origin rejects navigator.clipboard → a transient .share-link-fallback
<a> field lands in the row's share cell (selects its full URL on
focus — the range-based selectAllInField), one field at a time."""
js = _js()
import_lines = [line for line in js.splitlines() if line.strip().startswith("import")]
assert all("app.js" not in line for line in import_lines), (
"no cross-page import — the helper is duplicated per page"
)
copy = _fn(js, "copyShareLink")
assert "navigator.clipboard.writeText(absoluteUrl)" in copy, "the clipboard try"
assert 'cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove())' in copy, (
"one field at a time — a new offer replaces the old"
)
assert 'field.className = "share-link-fallback"' in copy
assert "field.href = absoluteUrl" in copy
assert "field.textContent = absoluteUrl" in copy, "XSS contract: textContent only"
assert 'field.addEventListener("focus", () => selectAllInField(field))' in copy
assert "field.focus({ preventScroll: true })" in copy, "selects the URL on focus"
sel = _fn(js, "selectAllInField")
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
# Copy (shared state) goes through the same helper.
rowcopy = _fn(js, "copyRowShareLink")
assert "copyShareLink(" in rowcopy
assert (
'announce(copied ? "Share link copied."'
' : "Share link ready — copy it from the field.")'
) in rowcopy
def test_share_column_css() -> None:
"""styles.css: the Share cell's ghost buttons (the Tune/Retry family
— transparent, --line border, ink-soft, ≥44px) + Unshare's
error-rose hover (it revokes — the Delete language) + the inline
fallback field (input-like: mono, surface fill, --line border,
ellipsis, 3px focus-visible). The unshare two-step reuses the
.history-confirm-* pair CSS (no new confirm styles)."""
css = _css()
for cls in (".history-share-create", ".history-share-copy", ".history-unshare"):
assert re.search(re.escape(cls), css), f"styles.css must style {cls}"
btn = re.search(
r"\.history-share-create,\n\.history-share-copy,\n\.history-unshare \{([\s\S]*?)\n\}",
css,
)
assert btn, "the share buttons share one ghost-button block"
body = btn.group(1)
assert "min-height: 44px" in body, "≥44px comfortable target"
assert "border: 1px solid var(--line)" in body
assert "background: transparent" in body
assert "var(--ink-soft)" in body
assert (
".history-unshare:hover:not(:disabled) { background: var(--err-bg);"
" color: var(--err-ink); border-color: var(--err-line); }"
) in css, "Unshare hovers the error rose (it revokes the link)"
field = re.search(r"\.share-link-fallback \{([\s\S]*?)\n\}", css)
assert field, "the inline fallback field must be styled"
fbody = field.group(1)
assert "var(--mono)" in fbody, "input-like: mono (the URL is data)"
assert "background: var(--surface)" in fbody
assert "border: 1px solid var(--line)" in fbody
assert "text-overflow: ellipsis" in fbody
assert re.search(r"\.share-link-fallback:focus-visible \{[^}]*outline[^}]*3px", css), (
"the fallback field keeps a 3px :focus-visible outline"
)