Compare commits

...
4 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 fe56e2fe01 test: make embedded-postgres per-process so concurrent CI runs don't collide
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Successful in 10s
A push to main and its version tag fire two CI runs for the same commit. Both ran
`go test ./...`, which starts embedded-postgres on a FIXED port (54329/54330) and
a shared data dir. -p 1 serialises packages WITHIN a run, not across two
concurrent runs — so when the two runs overlapped they collided on the port/data
dir and BOTH failed the Lint/Test job (no image built). Prior commits passed only
because their two runs happened not to overlap.

Derive the port and runtime/data dirs from the PID; share only CachePath so the
PG archive downloads once. Proven: two concurrent `go test` of the store package
now both pass. Unblocks the v0.21.0 (Pillar A) build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:26:33 +02:00
mathiasandClaude Opus 4.8 beeb5bc31b feat(gate): foreground caption fetches take priority over the background sweep (ADR-026)
CI / Lint / Test / Vet (push) Failing after 9s
CI / Build & Import (push) Has been skipped
A user waiting on a Summarize click shared the per-IP caption gate equally with
the background firehose, so on a busy IP the click was slow or 429'd. Add a
context-marked priority lane: the web path (engineProcessor.ProcessVideo) marks
its context foreground; the gate serves foreground immediately while background
fetches yield until no foreground is pending. Threaded via a context value (no
new signatures) + a process-wide foregroundPending counter. Clicks are rare, so
the background barely loses throughput; the waiting human gets the cleaner slot.

Drops the credentials probe: ADR-010 and captions.go already settle it — the
timedtext/InnerTube path rejects authenticated requests and the OAuth token does
not authenticate it anyway, so auth cannot help and can hurt. Documented in
ADR-026 rather than built.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:10:15 +02:00
mathiasandClaude Opus 4.8 09eb31d1fe fix(scheduler): derive rotation offset from wall-clock, not a reset-on-restart counter
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 14s
The lead-user rotation used an in-memory pass counter reset to 0 on every pod
restart, so the first-listed user always re-took the lead after a restart — a
deploy-heavy session re-starved the last user (the pilot stalled at 2 summaries
because each deploy reset his every-other-pass lead before the 2h tick fired).
Derive the offset from wall-clock (floor(now/interval)) so it advances with real
time and is identical across restarts: rotation stays fair however often the pod
bounces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:59:12 +02:00
mathiasandClaude Opus 4.8 1665a1e7c4 feat(web): honest, state-aware summarize status with charm spinner (ADR-025)
CI / Build & Import (push) Successful in 10s
CI / Lint / Test / Vet (push) Successful in 12s
Clicking Summarize polled /status, which knew only "spinning" or "done". The web
ProcessVideo recorded an outcome only on success, so a 429'd or caption-less
click left transcript_status unset and the poll silently reverted to the
Summarize button — the rate limit was invisible and the click felt broken.

- ProcessVideo now records rate_limited / none / fetched (mirrors the runner); a
  rate-limited video keeps its requested flag so the background sweep retries it.
- /status is state-aware: summary card (done), working spinner (in-flight),
  a calm "waiting on rate limit, will retry" card that keeps polling so the
  summary lands on its own (no re-click), and a terminal "no captions" card.
- Charm status text (Claude-Code / Crush inspired): the spinner cycles playful
  tapir-themed gerunds via CSS only (no JS), decorative + an sr-only stable
  status line for a11y.

Pillar A (foreground fetch priority lane) is a separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:52:33 +02:00
12 changed files with 739 additions and 231 deletions
+65
View File
@@ -923,6 +923,71 @@ summaries.
--- ---
## ADR-025 — Honest, state-aware foreground summarization status
**Status:** Accepted (2026-06-10). **Pillar B of the manual-mode UX work** (Pillar A, foreground
fetch priority, is a separate follow-up). Builds on ADR-014 (the rate limit the UX must make
legible).
**Context.** Clicking "Summarize" spawned a background goroutine and polled `/status`, which
returned only two states: the spinner (in-flight) or the normal card (done). But the web
`ProcessVideo` only recorded an outcome on *success* — a 429'd or caption-less click left
`transcript_status` unset, so the next poll silently reverted to the "Summarize" button. The
user saw either an endless spinner or a button that did nothing useful when clicked again. The
binding constraint (YouTube's caption rate limit) was completely invisible.
**Decision.**
1. **Record every outcome on the web path**, mirroring the runner: `ProcessVideo` stamps
`rate_limited` / `none` / `fetched`. A rate-limited video keeps its requested flag so the
background sweep retries it; `none` and `fetched` are terminal.
2. **`/status` is state-aware**: summarized → summary card; in-flight → working spinner;
`rate_limited` → a calm "waiting, will retry" card that keeps polling (every 30s) so the
summary appears on its own when the retry lands — the user never clicks again;
`none` → a terminal "no captions" card with no poll and no dead-end button.
3. **Charm status text** (Claude-Code / Crush inspired): the working spinner cycles playful,
tapir-themed gerunds ("Chewing the cud…", "Munching leaves…", "Distilling the gist…") via
CSS only — no JS, keeping the HTMX/no-JS ethos. Decorative (aria-hidden) with a stable
`role=status` line for assistive tech.
**Principle.** When the system cannot be fast (throttled IP), it is at least honest, and it
self-resolves without making the user retry. Honesty is the load-bearing half — Pillar A's
priority lane only improves the odds of a fast slot; it cannot beat an already-hot IP.
**Reversibility.** Pure transport-layer + view change over the unchanged engine/ports. No
schema change (reuses `transcript_status` from migration 007).
---
## ADR-026 — Foreground caption fetches take priority; the credentials probe is dead
**Status:** Accepted (2026-06-10). **Pillar A of the manual-mode UX work** (Pillar B was
ADR-025). Builds on ADR-014 (the shared per-IP gate).
**Context.** Every caption fetch — the background sweep and the web click-path — shared one
process-wide rate gate equally. So a user waiting on a "Summarize" click competed with the
firehose for both pacing and the scarce pre-429 window; on a busy IP the click was slow or
429'd while the background churned.
**Decision.** A context-marked priority lane. The web path
(`engineProcessor.ProcessVideo`) wraps its context with `ForegroundContext`; the gate gives
foreground fetches a token immediately, while **background fetches yield** — they wait until no
foreground fetch is pending before taking a token. Threaded via a context value (not new
signatures) and a process-wide `foregroundPending` counter. Clicks are rare and bursty, so the
background barely loses throughput; the waiting human gets the next (and cleanest) slot.
**Credentials probe — rejected, not built.** The idea was to fetch captions with the user's
auth in manual mode to dodge 429s. It is a dead end, already settled by ADR-010 and the code:
the caption path is *deliberately anonymous* because the InnerTube/timedtext endpoints **reject
or break on authenticated requests** (`captions.go`: "no OAuth token — it can break the
timedtext endpoint"). The user's OAuth (a Data API credential) does not authenticate InnerTube
at all, and the official `captions.download` is owner-only (403 on third-party). So auth cannot
help here and can actively hurt. No probe needed — building one would only re-confirm the ADR.
**Reversibility.** Context-marker + a yield loop in the gate; removing the marker collapses to
the prior equal-share behaviour. No schema or API change.
---
## Rejected alternatives ## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
+27 -1
View File
@@ -113,6 +113,11 @@ type engineProcessor struct {
} }
func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID string) error { func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID string) error {
// This is the user-initiated (foreground) path — a click on "Summarize",
// "Try now", or a pasted URL. Mark the context so the caption gate gives it
// priority over the background sweep (ADR-026, Pillar A).
ctx = youtube.ForegroundContext(ctx)
row, err := p.store.GetVideoRow(ctx, userID, videoID) row, err := p.store.GetVideoRow(ctx, userID, videoID)
if err != nil { if err != nil {
return fmt.Errorf("load video %q: %w", videoID, err) return fmt.Errorf("load video %q: %w", videoID, err)
@@ -132,7 +137,28 @@ func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID stri
if err != nil { if err != nil {
return fmt.Errorf("process video %q: %w", videoID, err) return fmt.Errorf("process video %q: %w", videoID, err)
} }
if res.Summary != nil {
// Record the outcome so the status endpoint can show honest state (ADR-025):
// a 429'd or caption-less click used to leave transcript_status unset, so the
// poll silently reverted to the "Summarize" button. Mirror the runner: stamp
// rate_limited / none / fetched. A rate-limited video keeps its requested flag
// so the background sweep retries it; none and fetched are terminal here.
switch {
case res.Skipped && res.TranscriptSource == string(domain.SourceRateLimited):
if err := p.store.SetTranscriptStatus(ctx, userID, videoID, "rate_limited"); err != nil {
return fmt.Errorf("set rate_limited status %q: %w", videoID, err)
}
case res.Skipped:
if err := p.store.SetTranscriptStatus(ctx, userID, videoID, "none"); err != nil {
return fmt.Errorf("set none status %q: %w", videoID, err)
}
if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
}
case res.Summary != nil:
if err := p.store.SetTranscriptStatus(ctx, userID, videoID, "fetched"); err != nil {
return fmt.Errorf("set fetched status %q: %w", videoID, err)
}
if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil { if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
return fmt.Errorf("clear summarize flag %q: %w", videoID, err) return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
} }
+13 -4
View File
@@ -143,8 +143,18 @@ func runScheduler(
return // disabled return // disabled
} }
pass := 0 // Derive the rotation offset from wall-clock, NOT an in-memory counter. A
runDiscoveryPass(ctx, pass, lister, runUser, log) // counter reset to 0 on every pod restart always hands the lead to the
// first-listed user — so frequent deploys re-starve whoever is last (exactly
// what happened to the first pilot user during a deploy-heavy session). A
// time-based offset advances with real time and is identical across restarts,
// so the lead rotates fairly regardless of how often the pod bounces.
runPass := func() {
pass := int(time.Now().Unix() / int64(interval/time.Second))
runDiscoveryPass(ctx, pass, lister, runUser, log)
}
runPass()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
@@ -153,8 +163,7 @@ func runScheduler(
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
pass++ runPass()
runDiscoveryPass(ctx, pass, lister, runUser, log)
} }
} }
} }
+15 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path/filepath"
"testing" "testing"
embeddedpostgres "github.com/fergusstrange/embedded-postgres" embeddedpostgres "github.com/fergusstrange/embedded-postgres"
@@ -24,11 +25,22 @@ var _ ports.Sink = (*store.Store)(nil)
var dsn string var dsn string
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
const port = 54329 // Port + runtime/data dirs are per-process (PID-derived) so two concurrent
// `go test` invocations — e.g. a push-run and a tag-run firing together in CI —
// don't collide on a fixed port or a shared data dir (which silently failed
// both runs). CachePath is shared so the PG archive is downloaded once, not
// per process. Base 54000 keeps this package's range distinct from web's.
port := uint32(54000 + os.Getpid()%1000)
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port) dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-store-%d", os.Getpid()))
pg := embeddedpostgres.NewDatabase( pg := embeddedpostgres.NewDatabase(
embeddedpostgres.DefaultConfig().Port(port), embeddedpostgres.DefaultConfig().
Port(port).
RuntimePath(rt).
DataPath(filepath.Join(rt, "data")).
BinariesPath(filepath.Join(rt, "bin")).
CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")),
) )
if err := pg.Start(); err != nil { if err := pg.Start(); err != nil {
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err) fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
@@ -40,6 +52,7 @@ func TestMain(m *testing.M) {
if err := pg.Stop(); err != nil { if err := pg.Stop(); err != nil {
fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err) fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
} }
_ = os.RemoveAll(rt)
os.Exit(code) os.Exit(code)
} }
+47
View File
@@ -2,6 +2,7 @@ package youtube
import ( import (
"context" "context"
"sync/atomic"
"time" "time"
"golang.org/x/time/rate" "golang.org/x/time/rate"
@@ -29,9 +30,55 @@ func SetFetchRate(interval time.Duration) {
globalFetchGate = rate.NewLimiter(rate.Every(interval), 1) globalFetchGate = rate.NewLimiter(rate.Every(interval), 1)
} }
// foregroundPending counts in-flight foreground (user-initiated) caption fetches.
// The background sweep yields the gate while this is non-zero so a human waiting
// on a click gets the next slot — and, on a near-throttled IP, the pre-429 window
// — instead of competing equally with the firehose (ADR-026, Pillar A). Clicks are
// rare and bursty, so background barely notices; the win to the click is large.
var foregroundPending atomic.Int64
// fgCtxKey marks a context as foreground (user-initiated). Unexported; set via
// ForegroundContext and read via isForeground so only this package owns the key.
type fgCtxKey struct{}
// ForegroundContext marks ctx as a user-initiated (foreground) fetch so the gate
// gives it priority. The web "Summarize"/paste/retry path wraps its context with
// this; the background scheduler leaves it unset.
func ForegroundContext(ctx context.Context) context.Context {
return context.WithValue(ctx, fgCtxKey{}, true)
}
func isForeground(ctx context.Context) bool {
v, _ := ctx.Value(fgCtxKey{}).(bool)
return v
}
// fgYieldPoll is how often a background waiter re-checks whether a foreground
// fetch is still pending. Short enough to feel immediate, long enough not to spin.
const fgYieldPoll = 200 * time.Millisecond
// WaitFetchGate blocks until the process-wide gate allows one timedtext fetch, // WaitFetchGate blocks until the process-wide gate allows one timedtext fetch,
// respecting ctx cancellation. Called from httpDo before every live outbound // respecting ctx cancellation. Called from httpDo before every live outbound
// caption fetch so the scheduler and the click-path share the same egress budget. // caption fetch so the scheduler and the click-path share the same egress budget.
//
// Foreground (user-initiated) fetches take priority: they register as pending and
// acquire a token immediately. Background fetches first yield — they wait until no
// foreground fetch is pending — so a live click is never stuck behind the
// background sweep and gets the cleaner slot against the per-IP limit (ADR-026).
func WaitFetchGate(ctx context.Context) error { func WaitFetchGate(ctx context.Context) error {
if isForeground(ctx) {
foregroundPending.Add(1)
defer foregroundPending.Add(-1)
return globalFetchGate.Wait(ctx)
}
// Background: defer to any pending foreground fetch before taking a token.
for foregroundPending.Load() > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(fgYieldPoll):
}
}
return globalFetchGate.Wait(ctx) return globalFetchGate.Wait(ctx)
} }
+34
View File
@@ -82,3 +82,37 @@ func TestSetFetchRateZeroIsUnlimited(t *testing.T) {
require.NoError(t, WaitFetchGate(context.Background())) require.NoError(t, WaitFetchGate(context.Background()))
} }
} }
func TestForegroundContextMarker(t *testing.T) {
require.False(t, isForeground(context.Background()), "plain context is background")
require.True(t, isForeground(ForegroundContext(context.Background())), "marked context is foreground")
}
// TestWaitFetchGateForegroundProceedsImmediately: a foreground fetch acquires a
// token without yielding, even when background callers exist.
func TestWaitFetchGateForegroundProceedsImmediately(t *testing.T) {
SetFetchRate(0) // unlimited limiter — isolate the yield logic from pacing
foregroundPending.Store(0)
t.Cleanup(func() { foregroundPending.Store(0) })
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, WaitFetchGate(ForegroundContext(ctx)), "foreground proceeds immediately")
}
// TestWaitFetchGateBackgroundYieldsToForeground: while a foreground fetch is
// pending, a background fetch yields (does not take a token) until the foreground
// clears — proven by a background wait timing out against its own deadline, then
// succeeding once the foreground is done.
func TestWaitFetchGateBackgroundYieldsToForeground(t *testing.T) {
SetFetchRate(0)
foregroundPending.Store(1) // simulate a foreground fetch in flight
t.Cleanup(func() { foregroundPending.Store(0) })
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
defer cancel()
require.Error(t, WaitFetchGate(ctx), "background yields (blocks) while foreground is pending")
foregroundPending.Store(0) // foreground done
require.NoError(t, WaitFetchGate(context.Background()), "background proceeds once foreground clears")
}
+14 -3
View File
@@ -495,11 +495,22 @@ func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) { // Honest, state-aware status (ADR-025). Order matters: a finished summary wins;
// an in-flight goroutine shows the working spinner; a recorded rate-limit shows
// the calm "waiting, will retry" card that keeps polling; a recorded "none" is
// terminal; anything else falls back to the normal card.
switch {
case row.Summarized:
a.render(w, r, VideoCard(*row))
case a.Processing.Has(processingKey(userID, videoID)):
a.render(w, r, processingCard(*row))
case row.TranscriptStatus == "rate_limited":
a.render(w, r, waitingCard(*row))
case row.TranscriptStatus == "none":
a.render(w, r, noCaptionsCard(*row))
default:
a.render(w, r, VideoCard(*row)) a.render(w, r, VideoCard(*row))
return
} }
a.render(w, r, processingCard(*row))
} }
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form // handleSummarizeMode toggles the user's auto/manual summarization mode. The form
+16 -2
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -26,10 +27,22 @@ import (
var dsn string var dsn string
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
const port = 54330 // distinct from the store package's embedded PG (54329) // Per-process port + dirs so concurrent `go test` runs (e.g. a push-run and a
// tag-run in CI) never collide on a fixed port or shared data dir. Base 55000
// keeps web's range distinct from the store package (54000). Shared CachePath
// downloads the PG archive once.
port := uint32(55000 + os.Getpid()%1000)
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port) dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(port)) rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-web-%d", os.Getpid()))
pg := embeddedpostgres.NewDatabase(
embeddedpostgres.DefaultConfig().
Port(port).
RuntimePath(rt).
DataPath(filepath.Join(rt, "data")).
BinariesPath(filepath.Join(rt, "bin")).
CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")),
)
if err := pg.Start(); err != nil { if err := pg.Start(); err != nil {
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err) fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -38,6 +51,7 @@ func TestMain(m *testing.M) {
if err := pg.Stop(); err != nil { if err := pg.Stop(); err != nil {
fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err) fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
} }
_ = os.RemoveAll(rt)
os.Exit(code) os.Exit(code)
} }
+40
View File
@@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/web" "gitea.d-ma.be/mathias/tapir/internal/web"
@@ -129,3 +130,42 @@ func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRec
app.Router().ServeHTTP(rec, req) app.Router().ServeHTTP(rec, req)
return rec return rec
} }
// setTranscriptStatus stamps videos.transcript_status directly (bypassing RLS via
// the super pool) so a test can drive the status endpoint into a given state.
func setTranscriptStatus(t *testing.T, p *pgxpool.Pool, videoID, status string) {
t.Helper()
_, err := p.Exec(context.Background(),
`UPDATE videos SET transcript_status = $2 WHERE id = $1`, videoID, status)
require.NoError(t, err)
}
// TestStatusRateLimitedShowsWaitingCard: a click that hit YouTube's rate limit
// must surface the honest "waiting, will retry" card that keeps polling — not a
// silent revert to the Summarize button.
func TestStatusRateLimitedShowsWaitingCard(t *testing.T) {
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "Throttled Title", "https://x", time.Time{})
setTranscriptStatus(t, p, videoX, "rate_limited")
html := body(t, getStatus(t, app, videoX))
require.Contains(t, html, "Waiting on YouTube rate limits", "honest rate-limit copy")
require.Contains(t, html, `hx-trigger="every 30s"`, "waiting card keeps polling so it self-resolves")
require.NotContains(t, html, "Summarize this video", "must not revert to the Summarize button")
}
// TestStatusNoCaptionsTerminal: a no-captions outcome is terminal — an honest
// message, no poll, no button to click back into the same dead end.
func TestStatusNoCaptionsTerminal(t *testing.T) {
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "Silent Title", "https://x", time.Time{})
setTranscriptStatus(t, p, videoX, "none")
html := body(t, getStatus(t, app, videoX))
require.Contains(t, html, "No captions available", "honest terminal copy")
require.NotContains(t, html, "hx-trigger", "terminal card must stop polling")
}
+19
View File
@@ -658,11 +658,30 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.tapir-bar-fill { animation: tapir-fill 8s linear infinite; text-shadow: 0 0 6px rgba(14, 249, 182, .7); } .tapir-bar-fill { animation: tapir-fill 8s linear infinite; text-shadow: 0 0 6px rgba(14, 249, 182, .7); }
@keyframes tapir-fill { 0% { clip-path: inset(0 100% 0 0); } 100% { clip-path: inset(0 0 0 0); } } @keyframes tapir-fill { 0% { clip-path: inset(0 100% 0 0); } 100% { clip-path: inset(0 0 0 0); } }
.tapir-label { color: var(--muted); font-size: .9rem; margin: 0; } .tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
/* Cycling status verbs (Claude-Code / Crush style): five gerunds stacked, each
visible 1/5 of a 6s loop, cross-faded. The container reserves one line height
so the layout does not jump as verbs swap. */
.tapir-verbs { position: relative; height: 1.3em; margin: .2em 0 0; color: var(--muted); font-size: .9rem; }
.tapir-verbs span { position: absolute; left: 0; top: 0; white-space: nowrap; opacity: 0; animation: tapir-verb 6s steps(1, end) infinite; }
.tapir-verbs .tv1 { animation-delay: 0s; }
.tapir-verbs .tv2 { animation-delay: 1.2s; }
.tapir-verbs .tv3 { animation-delay: 2.4s; }
.tapir-verbs .tv4 { animation-delay: 3.6s; }
.tapir-verbs .tv5 { animation-delay: 4.8s; }
@keyframes tapir-verb { 0%, 19.99% { opacity: 1; } 20%, 100% { opacity: 0; } }
/* Resting tapir for the rate-limit waiting state: the panel, one still frame, no
animation — calm, not busy, signalling "parked, not stuck". */
.tapir-resting pre { position: relative; opacity: 1; animation: none; }
.card-waiting { border-style: dashed; opacity: .92; }
.card-no-captions .card-state { font-style: italic; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.tapir-charm pre { animation: none; } .tapir-charm pre { animation: none; }
.tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; } .tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; }
.tapir-charm .tapir-f1 { opacity: 1; } .tapir-charm .tapir-f1 { opacity: 1; }
.tapir-bar-fill { animation: none; clip-path: inset(0 35% 0 0); } .tapir-bar-fill { animation: none; clip-path: inset(0 35% 0 0); }
.tapir-verbs span { animation: none; }
.tapir-verbs .tv1 { opacity: 1; }
} }
/* summarization mode toggle on the account page */ /* summarization mode toggle on the account page */
+48 -1
View File
@@ -339,7 +339,17 @@ templ TapirSpinner() {
<pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre> <pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div> <div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
</div> </div>
<p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p> // Claude-Code / Crush-style status: playful gerunds cycle in place (CSS only,
// no JS). Decorative — aria-hidden — with one stable status line below for
// assistive tech.
<p class="tapir-verbs" aria-hidden="true">
<span class="tv1"><em>Fetching captions…</em></span>
<span class="tv2"><em>Chewing the cud…</em></span>
<span class="tv3"><em>Munching leaves…</em></span>
<span class="tv4"><em>Distilling the gist…</em></span>
<span class="tv5"><em>Summarizing…</em></span>
</p>
<p class="sr-only" role="status" aria-live="polite">Summarizing…</p>
} }
// processingCard is the in-flight summarization card. It replaces the Summarize // processingCard is the in-flight summarization card. It replaces the Summarize
@@ -363,6 +373,43 @@ templ processingCard(r store.SummaryRow) {
</li> </li>
} }
// waitingCard is the honest rate-limited state: the click landed but YouTube is
// throttling the caption fetch, so the tapir rests and the card keeps polling
// (gently, every 30s) until the background retry lands the summary — the user
// never has to click again. Replaces the old silent revert to a Summarize button.
templ waitingCard(r store.SummaryRow) {
<li
class="card card-waiting"
id={ "video-" + r.VideoID }
hx-get={ string(statusURL(r.VideoID)) }
hx-trigger="every 30s"
hx-swap="outerHTML"
>
<div class="card-title">{ displayTitle(r) }</div>
if cardMeta(r) != "" {
<div class="card-meta">{ cardMeta(r) }</div>
}
<div class="tapir-charm tapir-resting" aria-hidden="true">
<pre class="tapir-f1">@templ.Raw(tapirFrameHTML2)</pre>
</div>
<p class="tapir-label" role="status" aria-live="polite">
Waiting on YouTube rate limits. Tapir keeps trying, slowly and politely, and the summary will appear here on its own.
</p>
</li>
}
// noCaptionsCard is the terminal no-captions state: nothing to summarize, so the
// card stops (no poll, no button to click again into the same dead end).
templ noCaptionsCard(r store.SummaryRow) {
<li class="card card-no-captions" id={ "video-" + r.VideoID }>
<div class="card-title">{ displayTitle(r) }</div>
if cardMeta(r) != "" {
<div class="card-meta">{ cardMeta(r) }</div>
}
<p class="card-state muted">No captions available, so Tapir cannot summarize this one.</p>
</li>
}
// DetailPage is the full summary view: text, highlights, takeaways, metadata, // DetailPage is the full summary view: text, highlights, takeaways, metadata,
// and the action button group. // and the action button group.
templ DetailPage(r store.SummaryRow) { templ DetailPage(r store.SummaryRow) {
File diff suppressed because it is too large Load Diff