Files
tapir/docs/ui-spec.md
T
mathiasandClaude Opus 4.8 64e3368f5f
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 10s
feat(web): charm-reader visual refresh with light/dark theme toggle (ADR-032, #17)
The UI read flat and boring. Reskin to one charm/TUI-inspired layout in two
palettes (CSS custom properties): a warm "reader" light theme (sketch B) and a
"cozy terminal" dark theme (sketch C).

- Palette chosen in cascade order: :root light default; an OS-preference dark
  block scoped to :root:not([data-theme]) so it applies only absent an explicit
  choice; and :root[data-theme="dark"|"light"] set by a header toggle that
  outranks the media query by specificity and persists in localStorage (guarded,
  degrades to OS default). A <head> init script applies the stored choice before
  paint, so no flash of the wrong palette.
- Charm touches via existing classes (no templ structure churn): monospace meta
  lines, accent uppercase section dividers with a trailing rule, pill buttons, a
  lifted/accent-edged expanded card.
- Error/danger shades become --err-* tokens so they follow the theme, replacing
  three per-block prefers-color-scheme dark overrides.
- Theme toggle wired into Layout and PublicLayout headers.

BDD: docs/use-cases/visual_theme.feature un-pended, mapped in scenarioCoverage.
TDD: internal/web/visual_theme_test.go (palettes, OS default, persisted toggle,
expanded-card embed). Verified light+dark on list/reader/welcome via web-shot.
Sketches kept as the design record. ui-spec.md as-built row added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:54:06 +02:00

18 KiB

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