# Tapir — Web UI Spec (Stage 0)

Implementation-level spec for the first web surface. Scope is **Stage 0: a reading surface
for the maintainer's own summaries, instrumented to capture the Stage-0 headline test**
("reads summaries weekly and acts on ≥1"). Deployed at `tapir.d-ma.be` via the homelab
GitOps flow. See `VISION.md` (Stage 0 gate), `DECISIONS.md` (ADR-011), `docs/data-model.md`.

## 1. Goal & success criteria

- The maintainer can, from a browser, **read** Tapir's summaries (list + full view) and
  **act** on them (watch / skip / save), with the action recorded.
- Auth is real (Dex OIDC login), but authorization is **single-user**: an allowlist of one
  subject. No user management, no per-tenant isolation (those are Stage 1+).
- Runs as a deployed k3s service behind `tapir.d-ma.be`, secrets via ESO, DB in-cluster —
  i.e. the same binary, "homelab mode" wiring (ADR-003).
- **Done when:** logging in at `tapir.d-ma.be` shows the maintainer's real summaries, opening
  one shows highlights/takeaways, and clicking watch/skip/save persists and is queryable.

## 2. Non-goals (Stage 1+ — do NOT build)

- Multiple users, sign-up, user CRUD, per-tenant data isolation/RLS.
- Editing summaries, subscription management UI, triggering runs from the browser (the run
  loop stays a `tapir run` job for now — revisit later).
- Any billing / public marketing surface. Mobile-native. Real-time push.

## 3. Architecture

- **New transport, not new core.** Add `tapir serve` (subcommand of `cmd/tapir`). It is a
  read/write surface over the existing `store`; the engine, ports, and adapters are
  untouched (ADR-003 — standalone vs homelab is wiring).
- **Stack:** Go stdlib `net/http` + **Templ** (typed templates) + **HTMX** (progressive
  interactions, no SPA). House default; a summary reader is read-heavy and low-interactivity.
- **Layers:** `internal/web/` (handlers, session, middleware, Templ components) depends on
  the `store` read/write methods and a thin `auth/oidc` session package. No business logic in
  handlers beyond presentation + action recording.

## 4. Pages & interactions

| Route | Method | What |
|-------|--------|------|
| `/healthz` | GET | liveness/readiness (no auth) |
| `/auth/login` | GET | redirect to Dex authorize |
| `/auth/callback` | GET | OIDC code exchange → session cookie → redirect to `/` |
| `/auth/logout` | POST | clear session |
| `/` | GET | summary list (auth) — newest first; columns: title · channel · published · AI provider · fallback badge · current action state. Filters: channel, date range (query params, HTMX-swapped) |
| `/v/{videoId}` | GET | full summary: text, highlights (list), takeaways (list), metadata, action buttons |
| `/v/{videoId}/action` | POST | record/clear an action `{watched\|skipped\|saved}` (HTMX, returns the updated button group fragment — no full reload) |

- **HTMX patterns:** filters and action buttons POST/GET and swap a fragment (`hx-target`,
  `hx-swap`). Full-page fallback works without JS (forms degrade).
- **Action semantics:** the three actions are independent toggles per (user, video) —
  "saved" can coexist with "watched"; "skipped" is mutually exclusive with "watched"
  (clicking one clears the other). Re-clicking an active action clears it.

## 5. Data model addition

New table `summary_actions` (migration, golang-migrate, per estate convention):

```
summary_actions
  id          uuid pk
  user_id     uuid   not null      -- isolation column (dormant authz at Stage 0)
  video_id    text   not null
  action      text   not null      -- 'watched' | 'skipped' | 'saved'
  acted_at    timestamptz not null
  unique (user_id, video_id, action)
```

- Store methods (new file `internal/adapters/store/actions.go`, do not edit existing store
  files): `SetAction(ctx, userID, videoID, action) error`, `ClearAction(...)`,
  `ActionsFor(ctx, userID, videoIDs []string) (map[videoID][]action, error)` for the list,
  and join into the existing `SummaryRow` reads so list/detail show current state.
- This column is what makes the Stage-0 metric ("did I act on a summary?") queryable.

## 6. Auth (Dex OIDC)

Authentication is delegated to the homelab OIDC provider at `TAPIR_OIDC_ISSUER` —
**Authentik** since the Dex→Authentik migration (infra ADR-0001; ADR-019). It offers a
Google upstream and Authentik-managed accounts (incl. its invite flow); Tapir no longer
provisions accounts itself. Any authenticated subject can register a Tapir account
(ADR-012: allowlist removed). The `oidc`/`DexAuth` package keeps its name for now (rename
deferred, ADR-019).

- **Flow:** standard Authorization Code. Use `coreos/go-oidc` + `golang.org/x/oauth2`
  (justify the deps in the commit; both are the homelab-standard OIDC libs and small).
- Discover issuer `https://auth.d-ma.be` (`TAPIR_OIDC_ISSUER`); scopes `openid profile email`.
- On callback: verify ID token, extract `sub` (and email). **ADR-012 superseded the
  ADR-011 single-subject allowlist:** any Dex-authenticated subject may sign in; a subject
  with no tapir user is routed to explicit registration (see `internal/web` registration gate).
- **Session:** signed, httpOnly, Secure cookie (HS256 with `TAPIR_SESSION_SECRET`); short TTL
  + sliding refresh. Server-side session store can be in-memory at Stage 0 (single replica).
- **Middleware** guards every route except `/healthz` and `/auth/*`.
- Note: this is `mcp-chassis`'s cousin but NOT the same code — mcp-chassis validates inbound
  *Bearer JWTs* for MCP APIs; this is a *browser session* login. Don't force-fit it.

## 7. Config additions (typed, env, via ESO in-cluster)

`TAPIR_HTTP_ADDR` (`:8080`), `TAPIR_PUBLIC_URL` (`https://tapir.d-ma.be`),
`TAPIR_OIDC_ISSUER` (`https://auth.d-ma.be`), `TAPIR_DEX_CLIENT_ID`, `TAPIR_DEX_CLIENT_SECRET`,
`TAPIR_OIDC_REDIRECT_URL` (`https://tapir.d-ma.be/auth/callback`), `TAPIR_SESSION_SECRET`.
Reuses existing `TAPIR_DB_DSN`, `TAPIR_USER_ID` (the StubAuth dev subject only). No secrets
committed. (`TAPIR_ALLOWED_SUBJECT` was removed by ADR-012; use the keys above.)

## 8. Deployment — k3s + Flux GitOps

- **Image:** `Dockerfile` (multi-stage, distroless/static, non-root). Built by **gitea CI**
  (act_runner + buildah) on push to `main`, pushed to the homelab registry
  (`k8s-registry-pull` secret already exists), GitHub mirror per the `gitea-ci` skill.
- **Manifests live in `mathias/infra` under `k3s/apps/tapir/`** (NOT this repo — app vs
  deployment separation, homelab-integration.md). Flux watches `infra` `main` and reconciles:
  - `Deployment` (1 replica Stage 0), `Service`, `Ingress` for **`tapir.d-ma.be`** (TLS via
    the homelab cert flow / edge), `ExternalSecret` (ESO) materialising `TAPIR_*` secrets from
    the `HomeLab` 1Password vault into a k8s `Secret` mounted as env.
- **DB in-cluster:** the deployed service connects to `postgres18` via its ClusterIP DSN
  (no port-forward); migrations apply on first connect (`store.Migrate`). The `tapir` role/db
  already exist; the in-cluster DSN goes in `op://HomeLab/TAPIR_DB_DSN_INCLUSTER` (or reuse
  with host swapped) and is surfaced via ESO.
- **YouTube refresh token in-cluster:** `tapir auth` is interactive (host-only). Run it once
  on the host, store the resulting refresh token in 1Password, and have the deployed pod's
  `SecretStore` resolve it from the ESO-synced secret (an ESO/k8s-secret-backed SecretStore
  impl, swappable behind the port). The web UI itself does not need the YouTube token; only
  the `run` job does — decide whether `run` is a CronJob in the same deploy or stays host-side
  for now (recommend: CronJob in `k3s/apps/tapir/` once the web UI is up).

## 9. Prerequisites (maintainer setup, before/with the build)

1. **Register a Dex static client** `tapir-web` in the Dex config (in `infra`) with redirect
   `https://tapir.d-ma.be/auth/callback`; client id/secret → 1P `TAPIR_DEX_CLIENT_ID` /
   `TAPIR_DEX_CLIENT_SECRET`. (No allowlist subject to capture — ADR-012 dropped it.)
2. **DNS/edge** for `tapir.d-ma.be` → the k3s ingress (piguard NPM perimeter / existing
   `*.d-ma.be` pattern) + TLS cert.
3. Confirm the **registry** host/path the gitea CI pushes to and the Flux path
   `infra/k3s/apps/tapir/`.

## 10. Records / ADR

`ADR-011` records: the web read-surface at Stage 0, Dex *authentication* now with *trivial
single-user authz* (deviating from the data-model's "auth dormant at Stage 0" note, with
rationale), the `summary_actions` model, and public `tapir.d-ma.be` ingress + GitOps deploy.

## 11. Build decomposition (gated swarm)

Gate (lane A) commits first; B/C/D follow.

- **Lane A — store actions + migration** (the gate): `summary_actions` migration,
  `SetAction`/`ClearAction`/`ActionsFor`, join into `SummaryRow`. embedded-postgres tests.
- **Lane B — Dex OIDC session + middleware**: login/callback/logout, session cookie,
  allowlist, route guard. Tests with a fake issuer (httptest), no live Dex.
- **Lane C — Templ+HTMX pages**: list (+filters), detail (+action button group fragment),
  layout/styles, `tapir serve` wiring. Handler tests.
- **Lane D — deploy**: `Dockerfile`, gitea CI image build + mirror, and the `infra`
  `k3s/apps/tapir/` manifests (Deployment/Service/Ingress/ExternalSecret) + Flux. (Touches the
  `infra` repo, not just this one.)

`task check` green per lane; B/C/D rebase on A. Deploy (D) lands last, after the binary serves
locally.

---

## Deviations and additions (as-built)

This spec describes the **Stage-0 single-user reader** (ADR-011). What actually shipped through
v0.4.0 went further — Stage 1 (ADR-012) opened multi-user, and several UX features were added on
top. Recorded here (append-only; the spec above is left intact) so intent and reality stay
distinguishable.

| As-built feature | What it is | Why | Covered by |
|------------------|-----------|-----|------------|
| **Multi-user + RLS isolation** | Several Dex users per deployment; isolation enforced by Postgres RLS, not the single-subject allowlist of §6. | Maintainer opened Stage 1 ahead of the formal Stage-0 gate, with DB-enforced isolation as the guardrail that keeps it safe. | ADR-012; migration 003 (`6775e5f`, `f28fdc0`, `2ae66da`) |
| **Registration gate** | A Dex subject with no `users` row is routed to `/register`, which creates the `users` row + a `user_identities` mapping. (§2 listed "sign-up / user CRUD" as a non-goal.) | Explicit registration is how a multi-user surface stays honest — no just-in-time row creation. | ADR-012; `f396e01` |
| **Per-user YouTube web connect** | `/oauth/youtube/connect` → `/oauth/youtube/callback` stores a per-user refresh-token ref + a `video_connections` row. (The spec assumed a host-side `tapir auth` only.) | Multi-user means each user connects their own account from the browser. | ADR-006, ADR-012; migration 005 (`0c9531a`, `2aad79b`) |
| **Account management** | `/account` page with **disconnect** and **delete account**; delete removes only Tapir-side state and leaves the Dex identity intact. (§2 listed isolation/CRUD as non-goals.) | A real account needs a way out; deletion semantics are deliberately Tapir-side only. | ADR-013; `22eafcf`, `c7624d9`, `17d5e8c` |
| **Immediate web summarization** | A quiet "Summarize now" button on non-summarized video cards. Pending cards POST to `/v/{id}/summarize` (queues + triggers engine); rate-limited cards POST to `/v/{id}/retry-now` (clears backoff + triggers engine). Both use the same `.btn-quiet` style and label — the internal pipeline distinction is invisible to the user. The page HTMX-polls `GET /v/{id}/status` while processing. Videos with `TranscriptStatus == "none"` show "No transcript available" with no button — this is a terminal honest state. (§2 said "triggering runs from the browser … do NOT build".) | Reading a list you can't act on is half a product; on-demand summarize closes the loop without waiting for a batch `tapir run`. | ADR-012, ADR-014; `25215cb`, `8c6c7ca` |
| **Charmbracelet tapir spinner** | An animated in-flight indicator (charm palette) shown while a summarize is processing; an honest "queued/waiting" state under rate-limiting rather than a stuck spinner. | The spinner must tell the truth when the timedtext endpoint rate-limits (429), not imply imminence. | ADR-014; `25215cb`, `a4aeb5e` |
| **Auto/manual summarization mode** | Per-user `auto_summarize`; manual lists new videos unsummarized and queues via `summarize_requested`; a mode toggle at `/account/summarize-mode`. Default is **true** for new users (migration 011, ADR-018); existing rows back-filled via migration 012. | Control over compute/noise — only summarize what the user cares about. | migration 006 (`748d5eb`, `bdbdce7`, `3014ee0`, `a269d4a`); migration 011/012 |
| **Public landing page** | `/welcome` mounted **outside** the auth guard; unauthenticated `/` redirects there; logout returns there (not `/auth/login`). (The spec guarded everything except `/healthz` and `/auth/*`.) | A first-time visitor needs a public "what is this / get started" page before the login wall. | `d83943c`, `0fdf2f7`, `3a27bf1`, `d208110`, `8ca374e`, `f15f57f` |
| **Invite onboarding** | **Removed from Tapir (ADR-019).** Invites are owned by the IdP (Authentik) now, not Tapir — the Dex local-password provisioning path (`tapir invite` CLI, `/invite/{token}` web flow, `internal/adapters/dex`) was deleted when the homelab migrated Dex→Authentik (infra ADR-0001). A new user is invited via Authentik's invite flow, logs into Tapir via OIDC, and is captured by the existing `/register` (display-name) gate. | Onboarding belongs to the identity provider; keeps Tapir out of the shared identity provider's write path. | ADR-019; infra ADR-0001 |
| **Summarized-only filter** | `?summarized=1` query param on the list view. When set, only videos with a completed summary (`SummaryRow.Summarized = true`) are shown. Rendered as a "Summarized only" checkbox in the filter form. Summarized videos also sort to the top of the unfiltered list (`ORDER BY (s.id IS NOT NULL) DESC, seen_at DESC`). | Lets users focus on videos that are ready to read; newly landing summaries are visible at the top without filtering. | `internal/web/view.go` (`Filter.OnlySummarized`, `ListVideos` ORDER BY) |
| **"Summarize now" foreground path** | Unified quiet nudge button on actionable non-summarized cards. Five explicit card states — (1) summarized: chip + no button; (2) no captions (`transcript_status = 'none'`): "No transcript available", no button; (3) queued: "Queued" chip, no button; (4) rate-limited: "Fetching soon…" + "Summarize now" → `POST /v/{id}/retry-now` (clears `rate_limited_at`, triggers engine); (5) pending: "Not summarized" + "Summarize now" → `POST /v/{id}/summarize` (queues + triggers engine). One verb, one style (`.btn-quiet`); backend difference invisible to user. Both handlers call `ProcessVideo` through `globalFetchGate`. Rate gate respected, not bypassed — this is onboarding prioritisation. | Fast onboarding value; honest dead-end for no-captions videos (no button that fails). | `internal/web/handlers.go` (`handleRetryNow`, `handleRequestSummarize`); `internal/web/views.templ` (`VideoCard`) |
| **Pipeline stats bar** | A one-line status bar above the video list: `N summarized · M fetching soon · K no captions`. Computed from the unfiltered row set; hidden when all videos are summarized. Gives the user a clear read on pipeline state without any interaction. | Replaces the "why is nothing happening?" confusion when most videos are pending or rate-limited. | `internal/web/view.go` (`PipelineStats`, `pipelineStats`) |
| **Unavailable channels (account page)** | The `/account` page shows a "Unavailable channels" section when any channels returned HTTP 404 on the last discovery pass. Lists channel name, an "unavailable" badge, and the first-seen date. Data sourced from the `channel_errors` table (migration 013). | Surfaces silent failures so users know why some subscribed channels produce no new videos. | migration 013; `internal/web/account.go`; `internal/adapters/youtube/youtube.go` (`domain.ErrChannelUnavailable`) |
| **Visual refresh — charm-reader theme + light/dark toggle (ADR-032)** | One layout in two palettes expressed as CSS custom properties: a warm "reader" light theme (sketch B) and a "cozy terminal" dark theme (sketch C). Palette is chosen in cascade order — `:root` light default, an OS-preference dark block scoped to `:root:not([data-theme])` so it only applies absent an explicit choice, and `:root[data-theme="dark"\|"light"]` set by a header toggle that outranks the media query and persists in `localStorage` (guarded; degrades to OS default). An init script in `<head>` applies the stored choice before paint (no flash). Charm touches: monospace meta lines, accent uppercase section dividers with a trailing rule, pill buttons, a lifted/accent-edged expanded card. Error/danger shades became `--err-*` tokens so they follow the theme without per-block dark overrides. Sketches kept as the design record under `docs/sketches/`. | The UI read "flat and boring"; the charm/TUI aesthetic makes it distinctive and gives a real light/dark choice rather than OS-only. | ADR-032; `docs/use-cases/visual_theme.feature`; `internal/web/visual_theme_test.go`; `internal/web/view.go` (`stylesheet`, `themeScript`), `internal/web/views.templ` (Layout/PublicLayout) |
| **Recency window + sparse-state honesty (ADR-020)** | Supersedes the copy/sort in the rows above. Auto-summarize is bounded to videos published within `TAPIR_AUTO_SUMMARIZE_WINDOW` (~7d); older un-summarized videos collapse behind a single "Show N older videos — summarize on demand" disclosure, and caption-less videos collapse to a one-line count (not N cards). List order is now `summarized-first, published_at DESC NULLS LAST`. Copy reframed for honest scarcity: pipeline bar reads "N ready · M in queue · K no captions" (no "fetching soon"); a gradual-fill note explains the rate limit; the nudge verb is "Summarize" (not "Summarize now"); the queued card says "summarizing shortly"; the empty-connected state drops the impossible `tapir run` instruction. Detail leads with Takeaways. Filters slimmed (no date pickers; hidden when empty); watched/skipped segmented; back link on detail; empty terms checkbox removed. | Make the sparse reality legible and honest instead of implying abundance/imminence; bound auto load so the back-catalogue doesn't re-drive the caption gate. Never fetch harder — scarcity is surfaced, not engineered around. | ADR-020; `2384c47`, `3df0459`, `40b703e`, `a1a5217`, `4a0a56e`, `9bf1c31`, `980638d`, `12fb031`, `f775441`, `51aa5d9` |
