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
+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>