# Tapir — Heuristic Review (Stage-0, sparse + recency-bounded) _Findings document, not a build spec. The maintainer filters; a spec follows separately. Reviewed against `VISION.md` (Stage-0 gate), `docs/ui-spec.md`, `internal/web/views.templ` + `view.go`, the prior `UX-REVIEW.md` pass, and the current screenshots. Written for the product **as it actually is**: ~283 discovered, ~15 summarized, ~256 in queue behind a respected per-IP caption rate limit, ~12 no-captions; single-user (maintainer) with friends pending; recency- bounded auto-summarize about to ship (auto = recent ~7d, older browsable + manual on demand)._ ## Reviewer stance The Stage-0 gate is **return usage**. So every finding is judged by one question: does this make the maintainer (or a friend) come back to an honestly-sparse feed? The visual layer is already decent — dark mode, cards, the constrained reader were all fixed in the prior pass. The open problems are **expectation-setting, honesty-of-scale, and the recency feed** — not pixels. **The single biggest risk:** a new user connects, sees `15 summarized · 256 fetching soon`, nothing visibly moves, and never returns. The whole gate dies at that moment. Most P0s below attack that one moment. ## Tagging - **`[NOW]`** — improves the product as it is today (sparse, recency-bounded, single-user). Ships in the upcoming bundle. - **`[LATER]`** — improves the product we hope it becomes (abundance, engagement, multiple users). Valuable but premature until real usage validates the core loop. Severity: 🔴 breaks the core loop · 🟠 hurts it · 🟡 noticeable · 🔵 polish. --- ## P0 — fix before/with the recency ship ### 1. 🔴 Empty-connected state tells the user to run a CLI command they can't run — `[NOW]` **Problem.** After connecting YouTube, the empty list says: *"Run `tapir run` to discover your subscriptions."* A friend on the web has no shell. And post-ADR-018 discovery is an in-process scheduled loop — so the instruction is wrong *even for the maintainer*. It is the first thing a newly-onboarded user sees, and it is an impossible, stale instruction. **Principle.** Match between system and the real world; help users recognize, not be blocked (Nielsen #1, #2, #9). **Proposal.** Replace with a passive, honest "we're working" state: *"Your account is connected. Tapir is finding your subscriptions and fetching captions — summaries appear here gradually. Check back later."* No command. No imperative the user can't satisfy. **Evidence.** `views.templ` `summaryList` → `.empty-connected`. ### 2. 🔴 No expectation set for *gradual* fill — the return loop breaks at the cliff — `[NOW]` **Problem.** Captions are rate-limited by design; the backlog trickles over days. Nothing tells the user this. A first visit shows few/no summaries and no "come back" framing. The Stage-0 gate is literally about returns, and the product never asks for one or explains why patience is warranted. **Principle.** Visibility of system status (#1). And: the gate can't be cleared if the UX doesn't survive first contact. **Proposal.** One honest sentence near the pipeline bar / empty state: *"Tapir fetches captions slowly on purpose, to respect YouTube's limits. New summaries land gradually — usually best to check back tomorrow."* Turns confusing emptiness into intentional design. Highest-leverage change in the review. ### 3. 🟠 "256 fetching soon" overstates imminence — a lie of scale — `[NOW]` **Problem.** The pipeline bar and rate-limited cards both say "fetching soon." For 256 items behind a per-IP throttle, "soon" is false — they trickle over days/weeks. Exactly the abundance- implying language the mandate forbids, inverted: it makes the *queue* look imminent. **Principle.** Honesty of scarcity (project mandate); #1. **Proposal.** Relabel by scale. Bar: `15 ready · 256 in queue · 12 no captions`. Card state: "In queue" / "Waiting its turn", not "Fetching soon…". Reserve "soon" for items actually next. **Evidence.** `views.templ` `pipelineBar`; card State 4. ### 4. 🟠 Recency boundary is invisible in the feed — `[NOW, ships with recency]` **Problem.** Once auto-summarize is bounded to ~7d, a 6-month-old pending video and a 2-day-old pending video render identically ("Not summarized" + button). But only one is in the auto path; the other will *never* process unless clicked. The user can't tell "be patient, this is coming" from "this is yours to trigger or ignore." **Principle.** Visibility of system status; predictability (#1). **Proposal.** Bucket the list into two sections: **Recent** (auto, will fill itself) and **Older — browse / summarize on demand**. Card status language should encode *which side of the line it's on*, not the pipeline internals. Central IA decision of the recency change. ### 5. 🟠 The un-summarized mass buries the ~15 readable summaries — `[NOW]` **Problem.** ~268 of 283 cards are not readable (pending / queued / no-captions). Summarized-first ordering helps, but the page is still 95% noise below the fold. "Attention is the scarce resource" is the product's own principle — and the default view violates it. **Principle.** Aesthetic/minimalist design; signal-to-noise (#8). **Proposal.** Default view = readable summaries + the Recent bucket. Collapse the older un-summarized mass behind *"Show 256 older un-summarized videos."* Collapse the 12 no-caption videos into a single line: *"12 videos have no captions"* (terminal, never readable — they don't deserve 12 full cards). --- ## P1 — high, near-term ### 6. 🟠 "Summarize now" overpromises against the rate gate — `[NOW]` **Problem.** Button says "Summarize now" (title: "Summarize this one now"). Backend queues it behind the shared per-IP gate. Click 10 older videos and they all sit at "Fetching soon…". The verb sells immediacy the system can't honor. **Principle.** Honesty; match system/reality (#1). **Proposal.** Drop "now" → "Summarize". On click the card should honestly become "Queued" (it already can). Optionally show queue position once the queue is real. Don't engineer the gate harder — just stop the verb from lying. **Evidence.** `views.templ` card States 4 & 5; `handlers.go` `handleRetryNow`, `handleRequestSummarize`. ### 7. 🟠 Welcome-page copy is stale and misleading post-ADR-019 — `[NOW]` **Problem.** Landing sub-copy: *"If you have an invite link, it will set up your account automatically."* Invites moved to Authentik (ADR-019); Tapir no longer handles invite links. The "Get Started" button goes straight to OIDC. The copy promises a flow that no longer exists. **Principle.** Match between system and reality (#2); honesty. **Proposal.** Rewrite: *"Tapir is invite-only right now. If you've been invited, sign in below."* Single CTA. Also set the gradual-fill expectation here (ties to #2) so it lands before the wall, not after. **Evidence.** `views.templ` `WelcomePage` → `.welcome-sub`. ### 8. 🟡 "Queued · waiting for the next run" leaks system jargon — `[NOW]` **Problem.** "the next run" exposes the discovery-loop concept; a user doesn't know what a "run" is. **Principle.** Speak the user's language (#2). **Proposal.** "Queued — summarizing shortly." Hide the scheduler. **Evidence.** `views.templ` card State 3. ### 9. 🟡 Filters render before there's anything to filter — `[NOW]` **Problem.** The fresh/empty state shows the full Channel/From/To/Filter bar *above* "Connect YouTube." Power tooling stacked on top of the one action that matters. **Principle.** Progressive disclosure; minimalist design (#8). **Proposal.** Hide the filter bar when there are 0 rows (and arguably below ~20). Show the connect CTA alone. **Evidence.** `fixes/03-empty-fresh.png`; `ListPage` renders `filterForm` unconditionally. ### 10. 🟡 Date-range filters are dead weight at this scale — `[NOW]` demote / `[LATER]` rebuild **Problem.** From/To date pickers + a free-text exact-match Channel field are corpus-scale tools. With 15 summaries they're noise; channel-as-freetext is unguessable. (Confirm the prior review's #7 is resolved — that `Channel` shows a real channel name, not the `provider` string; seeded screenshots suggest it is, live data may differ.) **Principle.** Match tool to task; minimalist design. **Proposal.** `[NOW]`: reduce to the "Summarized only" toggle (+ maybe channel chips derived from present rows). Drop date pickers until the corpus justifies them. `[LATER]`: real channel facets + search when there's volume. --- ## P2 — reading experience & polish ### 11. 🟡 Detail leads with Summary; the attention-saving payload (Takeaways) is last — `[NOW]` **Problem.** Product promise is "decide what's worth your time." The element that answers that — Takeaways / verdict — sits at the bottom. The user reads a full summary to reach the point. **Principle.** Lead with the user's actual job-to-be-done. **Proposal.** Reorder or add a one-line TL;DR/verdict at top. Takeaways → Highlights → Summary, or a "Worth watching?" lede. Data already exists; a reorder, not new machinery. **Evidence.** `views.templ` `DetailPage`. ### 12. 🔵 No "back to Summaries" on detail — `[NOW]` **Problem.** Only the brand returns home, losing any filter context. **Proposal.** Explicit "← Summaries" link on the detail page. ### 13. 🔵 Auto/manual toggle copy will be wrong after recency — `[NOW, with recency]` **Problem.** Account copy: *"Automatic summarizes every new video as it is discovered."* Becomes false once auto is bounded to ~7d. **Proposal.** *"Automatic summarizes new videos from the last ~7 days. Older videos stay browsable — summarize them on demand."* **Evidence.** `views.templ` `AccountPage` summarization section. ### 14. 🔵 Register step asks acceptance of nonexistent terms — `[NOW]` **Problem.** "I accept the terms of use" — no terms linked. For a friends-only tool, ceremony accepting nothing. **Proposal.** Either link real terms or drop the checkbox at this stage. **Evidence.** `views.templ` `RegisterPage`. ### 15. 🔵 watched/skipped mutual exclusivity unsignaled — `[NOW]` low **Problem.** Three independent-looking buttons; watched↔skipped are exclusive (carryover from prior review #14). **Proposal.** Segmented control for watched/skipped; keep Saved separate. --- ## `[LATER]` — premature until the core loop is validated _(abundance / engagement / multiple users)_ - **Return-nudges (digest email / push).** 🔴 **Caution, not just defer.** A notification that drives returns *contaminates the exact signal the gate measures* — VISION wants *unprompted* returns and admits it can't distinguish prompted from organic. Building a nudge now poisons the experiment. Defer until after the gate reads. `[LATER]` - **Full-text search across summaries** — needs volume to matter. `[LATER]` - **Channel facets / saved filters / sorting** — corpus-scale tooling (#10). `[LATER]` - **Read/unread + "new since last visit"** — genuinely helps returns, but only meaningful once there is throughput to be "new." `[LATER]` - **Saved/queue view, collections** — engagement surface; no payoff at 15 items. `[LATER]` - **Richer card previews (top-takeaway as preview, 2–3 lines)** — triage aid that only pays off with many cards to triage. `[LATER]` - **Backlog progress heartbeat ("N summarized this week", queue burn-down)** — rewards returning, but needs real throughput to show motion; a static "last updated X ago" is the only `[NOW]`-worthy slice. `[LATER]` - **Onboarding tour / multi-step welcome** — over-built for one user + a few friends. `[LATER]` --- ## What's already right (don't regress) Pipeline-bar concept, the honest "No transcript available" terminal state, the queued/waiting spinner instead of a fake-imminent one, the Unavailable-channels surface, the constrained-width reader, dark mode, the account danger-zone behind a disclosure. The honesty instincts are present — the P0s are about making that honesty *legible and correctly-scaled*, not adding it. ## Two judgment calls to settle first 1. **#4 (Recent/Older split) and #5 (collapse the older mass) are one decision viewed twice.** Settle the recency-feed IA once and both fall out. 2. **The return-nudge caution (`[LATER]` list, item 1) is the one to put in writing now** — before someone "helpfully" ships an email digest to juice the gate and destroys the signal.