feat(web): honest sparse-state and queue copy

Make the sparse reality legible instead of implying abundance or imminence
(UX review A1-A4, A6):

- Empty-connected state drops the impossible "Run `tapir run`" instruction
  (no shell for web users; discovery is in-process since ADR-018) for a
  passive "summaries appear gradually, check back later".
- Pipeline bar reframes counts by what the user can do: "N ready · M in queue
  · K no captions" (was "summarized / fetching soon / pending").
- A one-line note explains captions are fetched slowly on purpose to respect
  YouTube's limits — turning confusing emptiness into intentional design.
- Card state for throttled videos reads "In queue", not "Fetching soon…"
  (256 items behind a per-IP gate are not all imminent — ADR-014).
- Quiet nudge button drops the over-promising "now": "Summarize", not
  "Summarize now". On click the card still honestly becomes "Queued".
- Queued card says "summarizing shortly", not "waiting for the next run"
  (no scheduler jargon).

Pure copy/label — no logic, DB, or fetch-rate change. The rate gate is
untouched; scarcity is surfaced, never engineered around.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:42:40 +02:00
co-authored by Claude Opus 4.8
parent 9c7e3be984
commit a1a5217d77
4 changed files with 326 additions and 322 deletions
+10 -10
View File
@@ -46,7 +46,7 @@ func TestVideoCard_State1_Summarized(t *testing.T) {
t.Errorf("state 1: expected AI provider chip, got:\n%s", html)
}
noActionButton(t, html, "summarized")
if strings.Contains(html, "Summarize now") {
if strings.Contains(html, "Summarize") {
t.Errorf("state 1: no nudge button on a summarized card, got:\n%s", html)
}
}
@@ -64,7 +64,7 @@ func TestVideoCard_State2_NoTranscript(t *testing.T) {
t.Errorf("state 2: expected 'No transcript available' text, got:\n%s", html)
}
noActionButton(t, html, "none-transcript")
if strings.Contains(html, "Summarize now") {
if strings.Contains(html, "Summarize") {
t.Errorf("state 2: no nudge button when there are no captions, got:\n%s", html)
}
}
@@ -84,7 +84,7 @@ func TestVideoCard_State3_Queued(t *testing.T) {
noActionButton(t, html, "queued")
}
// TestVideoCard_State4_RateLimited — quiet status + "Summarize now" → retry-now URL.
// TestVideoCard_State4_RateLimited — quiet status + "Summarize" → retry-now URL.
func TestVideoCard_State4_RateLimited(t *testing.T) {
html := renderVideoCard(t, store.SummaryRow{
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
@@ -93,11 +93,11 @@ func TestVideoCard_State4_RateLimited(t *testing.T) {
TranscriptStatus: "rate_limited",
})
if !strings.Contains(html, "Fetching soon") {
t.Errorf("state 4: expected 'Fetching soon' status text, got:\n%s", html)
if !strings.Contains(html, "In queue") {
t.Errorf("state 4: expected 'In queue' status text, got:\n%s", html)
}
if !strings.Contains(html, "Summarize now") {
t.Errorf("state 4: expected 'Summarize now' button, got:\n%s", html)
if !strings.Contains(html, "Summarize") {
t.Errorf("state 4: expected 'Summarize' button, got:\n%s", html)
}
if !strings.Contains(html, "retry-now") {
t.Errorf("state 4: expected retry-now URL in form action, got:\n%s", html)
@@ -110,7 +110,7 @@ func TestVideoCard_State4_RateLimited(t *testing.T) {
}
}
// TestVideoCard_State5_Pending — quiet status + "Summarize now" → summarize URL.
// TestVideoCard_State5_Pending — quiet status + "Summarize" → summarize URL.
func TestVideoCard_State5_Pending(t *testing.T) {
html := renderVideoCard(t, store.SummaryRow{
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
@@ -121,8 +121,8 @@ func TestVideoCard_State5_Pending(t *testing.T) {
if !strings.Contains(html, "Not summarized") {
t.Errorf("state 5: expected 'Not summarized' status text, got:\n%s", html)
}
if !strings.Contains(html, "Summarize now") {
t.Errorf("state 5: expected 'Summarize now' button, got:\n%s", html)
if !strings.Contains(html, "Summarize") {
t.Errorf("state 5: expected 'Summarize' button, got:\n%s", html)
}
if !strings.Contains(html, "/summarize") {
t.Errorf("state 5: expected summarize URL in form action, got:\n%s", html)
+1
View File
@@ -538,6 +538,7 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.pipeline-bar { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; margin-bottom: var(--s3); font-size: .8rem; color: var(--muted); }
.pipeline-bar span { display: flex; align-items: center; gap: var(--s1); }
.pipeline-bar span + span::before { content: "·"; margin-right: var(--s1); }
.pipeline-note { margin: calc(-1 * var(--s2)) 0 var(--s3); font-size: .8rem; line-height: 1.5; max-width: 40rem; }
.card-nudge-form { display: inline; }
.btn-quiet { font: inherit; font-size: .72rem; font-weight: 600; padding: .15rem .55rem; border-radius: 999px; border: 1px solid var(--accent); background: transparent; color: var(--accent); cursor: pointer; }
.btn-quiet:hover { background: var(--accent-weak); }
+26 -18
View File
@@ -111,22 +111,29 @@ templ ListPage(rows []store.SummaryRow, f Filter, stats PipelineStats, flash str
if stats.RateLimited > 0 || stats.Pending > 0 || stats.NoText > 0 {
@pipelineBar(stats)
}
if stats.RateLimited+stats.Pending > 0 {
<p class="pipeline-note muted">
Tapir fetches captions slowly on purpose, to respect YouTube's limits
new summaries land gradually. Check back tomorrow.
</p>
}
<div id="summary-list">
@summaryList(rows, hasConnected)
</div>
}
}
// pipelineBar is the one-line backlog status. Counts are framed by what the user
// can read NOW ("ready"), what is waiting behind the honest caption rate limit
// ("in queue" = pending + rate-limited, never "fetching soon" — see A3/ADR-014),
// and what is permanently unreadable ("no captions").
templ pipelineBar(s PipelineStats) {
<div class="pipeline-bar">
if s.Summarized > 0 {
<span>{ fmt.Sprintf("%d summarized", s.Summarized) }</span>
<span>{ fmt.Sprintf("%d ready", s.Summarized) }</span>
}
if s.RateLimited > 0 {
<span>{ fmt.Sprintf("%d fetching soon", s.RateLimited) }</span>
}
if s.Pending > 0 {
<span>{ fmt.Sprintf("%d pending", s.Pending) }</span>
if s.RateLimited+s.Pending > 0 {
<span>{ fmt.Sprintf("%d in queue", s.RateLimited+s.Pending) }</span>
}
if s.NoText > 0 {
<span class="muted">{ fmt.Sprintf("%d no captions", s.NoText) }</span>
@@ -163,8 +170,8 @@ templ summaryList(rows []store.SummaryRow, hasConnected bool) {
if len(rows) == 0 {
if hasConnected {
<div class="empty empty-connected">
<strong>Your YouTube account is connected!</strong>
<span>Run <code>tapir run</code> to discover your subscriptions. Videos will appear here once discovered. In manual mode, each new video gets a Summarize button.</span>
<strong>Your account is connected</strong>
<span>Tapir is finding your subscriptions and fetching captions summaries appear here gradually. Check back later.</span>
</div>
} else {
<div class="empty">
@@ -186,10 +193,10 @@ templ summaryList(rows []store.SummaryRow, hasConnected bool) {
// /v/{id}/retry-now (HTMX swaps outerHTML). Five footer states, status-primary:
// 1. Summarized — preview + chip + actions; no button.
// 2. No captions (TranscriptStatus=="none") — terminal; "No transcript available"; no button.
// 3. Queued (SummarizeRequested) — "Queued · waiting for the next run"; no button.
// 4. Rate-limited — "Fetching soon" + quiet "Summarize now" → /retry-now.
// 5. Pending (else) — "Not summarized" + quiet "Summarize now" → /summarize.
// States 4 and 5 use one verb ("Summarize now") and one style (.btn-quiet); the
// 3. Queued (SummarizeRequested) — "Queued · summarizing shortly"; no button.
// 4. Rate-limited — "In queue" + quiet "Summarize" → /retry-now.
// 5. Pending (else) — "Not summarized" + quiet "Summarize" → /summarize.
// States 4 and 5 use one verb ("Summarize") and one style (.btn-quiet); the
// backend side-effect difference (clear-backoff vs. set-flag) is invisible to users.
templ VideoCard(r store.SummaryRow) {
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
@@ -222,12 +229,13 @@ templ VideoCard(r store.SummaryRow) {
// State 2: no captions — terminal dead-end; nothing the user can do.
<span class="card-state muted">No transcript available</span>
} else if r.SummarizeRequested {
// State 3: queued for the next scheduled pass.
// State 3: queued — being summarized on the next pass; no scheduler jargon.
<span class="chip">Queued</span>
<span class="card-state muted">waiting for the next run</span>
<span class="card-state muted">summarizing shortly</span>
} else if r.TranscriptStatus == "rate_limited" {
// State 4: rate-limited — quiet status + quiet nudge button → retry-now handler.
<span class="card-state muted">Fetching soon…</span>
// State 4: rate-limited — honest "in queue" status (NOT "fetching soon",
// which oversells imminence) + a quiet nudge → retry-now handler.
<span class="card-state muted">In queue</span>
<form
method="post"
action={ retryNowURL(r.VideoID) }
@@ -236,7 +244,7 @@ templ VideoCard(r store.SummaryRow) {
hx-swap="outerHTML"
class="card-nudge-form"
>
<button type="submit" class="btn-quiet" title="Summarize this one now">Summarize now</button>
<button type="submit" class="btn-quiet" title="Summarize this video">Summarize</button>
</form>
} else {
// State 5: pending — discovered, not yet attempted; nudge button → summarize handler.
@@ -249,7 +257,7 @@ templ VideoCard(r store.SummaryRow) {
hx-swap="outerHTML"
class="card-nudge-form"
>
<button type="submit" class="btn-quiet" title="Summarize this one now">Summarize now</button>
<button type="submit" class="btn-quiet" title="Summarize this video">Summarize</button>
</form>
}
</div>
File diff suppressed because it is too large Load Diff