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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+289
-294
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user