Skip to content
This repository was archived by the owner on Jul 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions internal/web/gallery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,88 @@ func TestGalleryLinksTab(t *testing.T) {
}
}

// seedLinkConversation writes one conversation carrying a single message with
// the given link URL and returns nothing — callers query by the URL. Shared by
// the #14 copy-control tests (transcript pill + Media→Links row).
func seedLinkConversation(t *testing.T, st *store.Store, name, url string) {
t.Helper()
ctx := context.Background()
id, err := st.UpsertConversation(ctx, source.Signal, name)
if err != nil {
t.Fatal(err)
}
parsed, _ := time.Parse(signal.TimestampLayout, "2022-06-01 10:00:00")
if _, err := st.ReplaceConversationMessages(ctx, id, source.Signal, []signal.Message{
{Conversation: name, Timestamp: parsed, TimestampRaw: "2022-06-01 10:00:00",
Sender: "Robin", Body: "see this", Links: []signal.Link{{URL: url}}},
}); err != nil {
t.Fatal(err)
}
}

// TestGalleryLinkCopyButton asserts each Media→Links row carries an icon-only
// copy control whose copy *source* is the FULL URL (issue #14): copy.js reads
// data-copy-value, so the whole URL — not just the grouped domain — reaches the
// clipboard. The control is labeled for the keyboard, and the row's own link
// still points out so click-through-to-open is untouched.
func TestGalleryLinkCopyButton(t *testing.T) {
srv, st, _ := newTestServer(t)
const url = "https://docs.example.org/guide/copy-me"
seedLinkConversation(t, st, "Linky", url)

rec := get(t, srv, "/gallery?tab=links")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
// The copy button's source is the full URL (not the domain).
if !contains(body, `data-copy-value="`+url+`"`) {
t.Errorf("Media→Links row missing a copy button sourced from the full URL: %s", body)
}
// Keyboard-operable + labeled, and the inline icon-swap button markup.
if !contains(body, `aria-label="Copy link"`) || !contains(body, "copy-btn-inline") {
t.Error("link copy button missing its aria-label or inline copy-btn class")
}
// Click-through-to-open preserved: the URL still links out.
if !contains(body, `class="media-link-url" href="`+url+`"`) {
t.Error("Media→Links row dropped the click-through-to-open link")
}
}

// TestTranscriptLinkCopyButton asserts the transcript link pill (which shows
// only the DOMAIN) gains an icon-only copy control that copies the FULL URL via
// data-copy-value, without breaking the pill's link-out (issue #14).
func TestTranscriptLinkCopyButton(t *testing.T) {
srv, st, _ := newTestServer(t)
const url = "https://blog.example.net/a/very/long/path?ref=chat"
seedLinkConversation(t, st, "Linky", url)
conv, err := st.GetConversation(context.Background(), "Linky")
if err != nil || conv == nil {
t.Fatalf("get conversation: %v", err)
}

rec := get(t, srv, "/c/"+itoa(conv.ID))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
// The pill still links out (click-through-to-open) ...
if !contains(body, `class="link-pill" href="`+url+`"`) {
t.Errorf("transcript link pill dropped its link-out: %s", body)
}
// ... and the sibling copy button copies the full URL, labeled and inline.
if !contains(body, `data-copy-value="`+url+`"`) {
t.Error("transcript link tile missing a copy button sourced from the full URL")
}
if !contains(body, `aria-label="Copy link"`) || !contains(body, "copy-btn-inline") {
t.Error("transcript copy button missing its aria-label or inline copy-btn class")
}
// The shared aria-live announce region is present in the shell (page_end).
if !contains(body, `id="copy-announce"`) || !contains(body, `aria-live="polite"`) {
t.Error("transcript page missing the shared #copy-announce live region")
}
}

func TestGalleryTabPreservesFilter(t *testing.T) {
srv, st, _ := newTestServer(t)
conv, _ := st.GetConversation(context.Background(), "Harper")
Expand Down
2 changes: 1 addition & 1 deletion internal/web/static/app.css

Large diffs are not rendered by default.

21 changes: 16 additions & 5 deletions internal/web/static/copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
// CSP — no inline handlers.
//
// Any button carrying data-copy-target="<id>" copies the textContent of the
// element with that id. Feedback is doubled per the spec's Accessibility
// element with that id. A button carrying data-copy-value="<text>" instead
// copies that literal string — for tiles whose visible text isn't the value to
// copy (e.g. a link pill that shows only the domain but must copy the full URL,
// issue #14), which would otherwise need a hidden per-item element + unique id.
// data-copy-value wins when both are present. Feedback is doubled per the spec's Accessibility
// requirements: visually, the button swaps its copy icon for a check for a
// couple of seconds (.copied class); for assistive tech, the button's
// data-copy-announce text is written into the #copy-announce
Expand Down Expand Up @@ -59,11 +63,18 @@
document.addEventListener("click", function (e) {
var target = e.target;
if (!target || !target.closest) return;
var btn = target.closest("[data-copy-target]");
// data-copy-value carries the literal text to copy; data-copy-target names
// an element whose textContent is copied. Prefer the literal when present.
var btn = target.closest("[data-copy-value],[data-copy-target]");
if (!btn) return;
var src = document.getElementById(btn.getAttribute("data-copy-target"));
if (!src) return;
var text = src.textContent.trim();
var text;
if (btn.hasAttribute("data-copy-value")) {
text = btn.getAttribute("data-copy-value");
} else {
var src = document.getElementById(btn.getAttribute("data-copy-target"));
if (!src) return;
text = src.textContent.trim();
}

if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(
Expand Down
29 changes: 29 additions & 0 deletions internal/web/tailwind/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,13 @@ a.meta-chip:hover {
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
border-color: color-mix(in oklab, var(--color-primary) 55%, transparent);
}
/* Link tile (#14): wraps a .link-pill with its inline copy button so the pair
wraps as one unit inside .msg-links and the button sits just off the pill. */
.link-tile {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}

/* Reaction badges: small emoji pills under a message, with an optional repeat
* count. Driven by the slate variables so the light variant restyles for free. */
Expand Down Expand Up @@ -1588,12 +1595,23 @@ a.meta-chip:hover {
.media-card-meta a:hover {
color: var(--color-primary);
}
/* Media→Links row (#14): the full-URL link plus its inline copy button on one
line — the URL flexes and wraps, the button stays put at the top-right. */
.media-link-row {
display: flex;
align-items: flex-start;
gap: 0.4rem;
}
.media-link-url {
display: block;
font-size: 13px;
color: var(--color-primary);
word-break: break-all;
}
.media-link-row .media-link-url {
flex: 1 1 auto;
min-width: 0;
}
.media-link-url:hover {
text-decoration: underline;
}
Expand Down Expand Up @@ -1806,6 +1824,17 @@ a.meta-chip:hover {
background: color-mix(in oklab, var(--color-primary) 10%, var(--color-base-200));
color: var(--color-base-content);
}
/* Inline copy button for the link tile (#14): the base .copy-btn is absolutely
pinned to a copy-block corner; here it rides inline beside the tile instead,
sized down to sit with the 12–13px pill/URL. It keeps .copy-btn's icon-swap
and :focus-visible rules, so the copy→check acknowledgment and keyboard
outline come for free. flex:none keeps it from shrinking in the flex row. */
.copy-btn-inline {
position: static;
width: 1.5rem;
height: 1.5rem;
flex: none;
}
/* Visible keyboard focus (SPEC-0010 §Accessibility "Keyboard navigation"). */
.copy-btn:focus-visible {
outline: 2px solid var(--color-primary);
Expand Down
8 changes: 7 additions & 1 deletion internal/web/templates/gallery.html
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,13 @@ <h1 class="screen-h1 mb-1">Media</h1>
<ul class="media-list">
{{- range .Links}}
<li class="media-list-card">
<a class="media-link-url" href="{{.URL}}" target="_blank" rel="noopener noreferrer nofollow">{{.URL}}</a>
{{/* The URL links out; the sibling icon-only copy button copies the same
full URL via copy.js (data-copy-value). Sibling, not child, so the
copy click never navigates (#14). */}}
<div class="media-link-row">
<a class="media-link-url" href="{{.URL}}" target="_blank" rel="noopener noreferrer nofollow">{{.URL}}</a>
<button type="button" class="copy-btn copy-btn-inline" data-copy-value="{{.URL}}" data-copy-announce="Link copied" aria-label="Copy link"><span class="icon-copy">{{template "icon-copy" $}}</span><span class="icon-check">{{template "icon-check" $}}</span></button>
</div>
<div class="media-card-meta">
{{- if gt .Count 1}}<span class="font-mono">×{{num .Count}}</span> · {{end}}
<a href="/c/{{.ConversationID}}/at/{{.MessageID}}#m{{.MessageID}}">{{humanName .ConversationName}}</a>
Expand Down
19 changes: 17 additions & 2 deletions internal/web/templates/partials.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
and its aria-expanded in sync (#175's lg+ collapse retired in #190);
shell.js keeps the header tabs' active state correct across boosted
swaps and forwards inner-container scrolls to htmx's `revealed`
checker (#190); copy.js powers the Settings page's copy buttons;
checker (#190); copy.js powers the copy buttons on Settings and the
link tiles in transcripts / Media→Links (#14);
setup.js powers the Setup permission-guidance modals (focus trap,
Escape, restore focus, recheck aria-live) — all via document-level
delegation (CSP-safe, no inline handlers). */}}
Expand Down Expand Up @@ -169,6 +170,14 @@
</aside>
</div>
</div>
{{/* Shared copy-confirmation live region (issue #14): copy.js announces every
successful copy here for assistive tech (SPEC-0010 §Accessibility "Dynamic
feedback"). It lives in the persistent shell — not inside #main-content —
so the link-tile copy buttons on the transcript and Media→Links pages
announce into it, and it survives boosted swaps without being re-rendered.
Settings' own copy buttons reuse this same region (the standalone one it
used to carry is gone). */}}
<p id="copy-announce" class="sr-only" aria-live="polite"></p>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call relocating the live region here. Confirmed it sits outside #main-content (which lives in page_start), so hx-boost swaps that target #main-content leave it intact, and every page composes page_end — so transcript, Media, and Settings all get exactly one #copy-announce. The old Settings-body region is removed, so there's no duplicate id. settings_test.go:376 still asserts the region is present on the Settings render and passes, confirming the move didn't break Settings' existing copy announcements.


Generated by Claude Code

</body>
</html>{{end}}

Expand Down Expand Up @@ -300,8 +309,14 @@
</div>
{{- end}}
{{- if .Links}}
{{/* Each link renders as a tile: the accent pill (icon + domain) links out
in a new tab, and a sibling icon-only copy button copies the FULL URL
(data-copy-value — the pill only shows the domain) via copy.js. The
button is a SIBLING of the anchor, not a child, so copying never
triggers the pill's click-through-to-open (#14). .link-tile keeps the
pair wrapping as one unit inside the flex row. */}}
<div class="msg-links">
{{- range .Links}}<a class="link-pill" href="{{.URL}}" target="_blank" rel="noopener noreferrer nofollow">{{template "icon-link" $}}<span>{{.Domain}}</span></a>{{end}}
{{- range .Links}}<span class="link-tile"><a class="link-pill" href="{{.URL}}" target="_blank" rel="noopener noreferrer nofollow">{{template "icon-link" $}}<span>{{.Domain}}</span></a><button type="button" class="copy-btn copy-btn-inline" data-copy-value="{{.URL}}" data-copy-announce="Link copied" aria-label="Copy link"><span class="icon-copy">{{template "icon-copy" $}}</span><span class="icon-check">{{template "icon-check" $}}</span></button></span>{{end}}
</div>
{{- end}}
{{- if .Reactions}}
Expand Down
11 changes: 5 additions & 6 deletions internal/web/templates/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@
QR + manual code, the pair form, and the paired-device registry). Copy
buttons are wired by /static/copy.js via data-copy-target (no inline
handlers — script-src 'self'); confirmations are announced through the
aria-live region below and shown visually by the button's copy→check icon
swap. Every value is also selectable text, so nothing depends on the
shared #copy-announce aria-live region in the page shell (page_end, #14)
and shown visually by the button's copy→check icon swap. Every value is
also selectable text, so nothing depends on the
clipboard or the QR image alone. Logs and Status & backups render under
the same shell via the sub-nav above (issue #163). */}}
{{define "settings_content"}}<title>{{.Title}}</title>
Expand All @@ -49,10 +50,8 @@ <h1 class="screen-h1">Settings</h1>
{{template "settings_subnav" "settings"}}
<p class="screen-sub">Connect an MCP client to this archive, or pair another device.</p>

{{/* Copy confirmations land here for assistive tech (SPEC-0010
§Accessibility "Dynamic feedback"): polite live region, visually
hidden, updated by copy.js on every successful copy. */}}
<p id="copy-announce" class="sr-only" aria-live="polite"></p>
{{/* Copy confirmations announce into the shared #copy-announce live region
in the page shell (page_end, #14) — no per-page region needed. */}}

<section class="status-card space-y-4" aria-labelledby="settings-mcp-heading">
<h2 id="settings-mcp-heading" class="status-card-title">MCP server</h2>
Expand Down
Loading