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
+153
View File
@@ -338,6 +338,37 @@ html::after {
the whole control below 640px (mirrored in the ≤640 block below). */
.save-chat-btn svg { width: 16px; height: 16px; display: none; }
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the "Share" pill —
the EXACT visual family of .save-chat-btn (same declarations, so the
chat-shell actions always read as a pair): solid brand pill, --bg
text on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover
lightens the brand fill, focus-visible via the global 3px rule.
Shipped hidden in index.html — app.js reveals it for admin only
(phase 16 absent-not-hidden); ≤640px overrides below mirror the
Save ones (label stays visible in .chat-shell, icon hidden there). */
.share-chat-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 0;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.share-chat-btn:hover { background: #f55a72; color: var(--bg); }
/* The link mark is hidden on desktop (the label carries the pill); it
is the whole control below 640px (mirrored in the ≤640 block
below). */
.share-chat-btn svg { width: 16px; height: 16px; display: none; }
/* Phase 16: header auth controls (Sign in link / Sign out button) — the
same ghost pill as New chat, so the bar keeps one visual language.
ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1.
@@ -1897,6 +1928,60 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
cursor: pointer;
}
.history-confirm-no:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Share column (phase 51, owner-locked 2026-08-29): the Tune/Retry-
family ghost buttons — Create link (unshared) and Copy (shared);
Unshare is the .history-unshare ghost and its two-step confirm reuses
the .history-confirm-* pair CSS above (the phase-50 pattern). The
row-action language of .history-delete: --line border, transparent
fill, ink-soft (>=5.1:1), ≥44px comfortable target, focus-visible
via the global 3px rule; hover takes the brand pair (6.9:1). */
.history-share { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; }
.history-share-cell { white-space: nowrap; }
.history-share-create,
.history-share-copy,
.history-unshare {
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.history-share-create:hover:not(:disabled),
.history-share-copy:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); }
.history-unshare:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.history-share-create:disabled,
.history-share-copy:disabled,
.history-unshare:disabled { opacity: 0.5; cursor: wait; }
/* The share link's inline fallback field (phase 51, owner-locked): a
non-secure (http) origin rejects the clipboard, so the full URL is
offered as an input-like <a> that selects itself on focus — click or
Tab, then Ctrl/Cmd+C. Mono (the URL is data), surface fill, --line
border; truncates with an ellipsis at narrow widths (the full URL is
the title + the text selection). Ink on surface ≈13.8:1. */
.share-link-fallback {
display: inline-block;
max-width: 14rem;
padding: 0.35rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font-family: var(--mono);
font-size: 0.78rem;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.share-link-fallback:hover { border-color: var(--brand); }
.share-link-fallback:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* Empty-state row: the muted centered message at full table width
(the .git-sources-empty language, inline in the table). */
.history-empty-row td {
@@ -1906,6 +1991,61 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-style: italic;
}
/* ---------- Shared page (phase 51, task 03) ----------
/shared/<token>: the anonymous read-only conversation (owner-locked
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
46rem chat column — the conversation reads exactly like the chat
page (the .msg/.bubble/.thinking/.tool-calls/.msg-meta rules apply
unchanged) with NO composer, so the column contract holds for a
guest. Zero interactive controls (owner-locked): the chips are
plain text, so the pill families' pointer treatments are switched
off IN THIS SCOPE ONLY — the chat page's interactive chips keep
their styles untouched. Every pair reuses the Phase-08 AA palette
(ink-soft ≥6.9:1 on surface/bg, brand-ink on brand-soft 6.9:1);
:focus-visible via the global 3px outline rule. No CDN, system
fonts. */
.shared-shell {
width: 100%;
max-width: 46rem; /* the PLAN §7 centered chat column */
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
/* Page title (JS-filled with the shared chat's title; the static
fallback is "Shared conversation") — the page-head h1 size. */
#shared-title { margin: 0; font-size: 1.7rem; }
/* The muted meta line under the h1 ("Shared via … — read-only."):
ink-soft on the page bg ≥8.6:1, the page-sub language. */
.shared-note {
margin: 0;
color: var(--ink-soft);
font-size: 0.92rem;
}
/* The invalid / revoked state: a centered muted card (the not-found
language — surface fill, --line border, italic ink-soft). Revealed
by shared.js for a malformed token (no fetch) or a failed read. */
#shared-invalid {
padding: 1.4rem 1rem;
text-align: center;
color: var(--ink-soft);
font-style: italic;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* Static chips: a guest's "Maybe try" and source chips are TEXT — no
pointer, no hover, no cursor (owner-locked zero controls). The pill
look is kept; the interactivity is scoped off here and only here
(pointer-events: none also makes the :hover rules unreachable). */
.shared-shell .suggestion-chip,
.shared-shell .source-chip {
pointer-events: none;
cursor: default;
}
/* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
rows in one sticky <header> — row 1 reuses the standard .app-header /
@@ -2445,12 +2585,19 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.save-chat-btn { padding: 0.4rem 0.3rem; }
.save-chat-label { display: none; }
.save-chat-btn svg { display: block; }
/* Phase 51: the Share pill squeezes with Save (same family, same
rules — the chat-shell overrides below keep both labels visible). */
.share-chat-btn { padding: 0.4rem 0.3rem; }
.share-chat-label { display: none; }
.share-chat-btn svg { display: block; }
/* But on the chat page there is room — keep the label visible and
hide the icon (the button lives inside .chat-shell, not the navbar). */
.chat-shell .new-chat-label { display: inline; }
.chat-shell .new-chat-btn svg { display: none; }
.chat-shell .save-chat-label { display: inline; }
.chat-shell .save-chat-btn svg { display: none; }
.chat-shell .share-chat-label { display: inline; }
.chat-shell .share-chat-btn svg { display: none; }
/* Phase 16: the auth pill goes icon-only like New chat — brand text
ellipsizes as the designated squeeze target, no bar overflow. */
.auth-link { padding: 0.4rem 0.3rem; }
@@ -2564,6 +2711,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
the two-step confirm pair fits the phone width. */
.history-actions-cell { white-space: normal; }
.history-actions { flex-wrap: wrap; }
/* Phase 51: the shared page squeezes like the chat column — the
title and the note step down (the empty-state-title family); the
shell keeps its 46rem column (it is already the narrowest box on
the page) and .msg-body's 92% override above applies. */
#shared-title { font-size: 1.35rem; }
.shared-note { font-size: 0.88rem; }
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
main { padding-bottom: env(safe-area-inset-bottom, 0); }
/* Sync button goes icon-only on mobile; the label hides, aria-label