Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f222c931 | ||
|
|
e042b6d26d | ||
|
|
c85a32e770 | ||
|
|
b246c0e688 | ||
|
|
c7896cb3cc | ||
|
|
b79fb892c8 | ||
|
|
c315e3e003 | ||
|
|
64e3368f5f | ||
|
|
b01f8d5783 | ||
|
|
8ca767c144 | ||
|
|
f98b640531 | ||
|
|
607a8cbe8d | ||
|
|
54d60e53b9 | ||
|
|
9298e0c686 |
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"brain": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://brain-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://git-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ docs it indexes.
|
|||||||
3. `DECISIONS.md` — the ADRs. Decisions are settled here; do not re-litigate without a new ADR.
|
3. `DECISIONS.md` — the ADRs. Decisions are settled here; do not re-litigate without a new ADR.
|
||||||
4. `docs/architecture/architecture.md`, `docs/data-model.md`, `docs/use-cases/*.feature`.
|
4. `docs/architecture/architecture.md`, `docs/data-model.md`, `docs/use-cases/*.feature`.
|
||||||
5. `docs/homelab-integration.md` — the concrete endpoints/conventions you'll need.
|
5. `docs/homelab-integration.md` — the concrete endpoints/conventions you'll need.
|
||||||
|
6. `LANGUAGE.md` — the project vocabulary. Apply the caveman rubric before destructive operations.
|
||||||
|
|
||||||
## How to work in this repo
|
## How to work in this repo
|
||||||
|
|
||||||
|
|||||||
@@ -1219,6 +1219,101 @@ stops scraping; the app is unaffected. No schema change.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-031 — SPA-like reader: inline-expand summary + Q&A in the list (HTMX, no framework)
|
||||||
|
|
||||||
|
**Status:** Proposed (2026-06-12). Issue #16. **Draft for review — no code yet.**
|
||||||
|
|
||||||
|
**Context / requirements.** The reader is multi-page: a list of compact cards (`/`), then a
|
||||||
|
navigation to a separate detail page (`/v/{id}`) for the full summary + the docked chat (ADR-027).
|
||||||
|
It feels less fluid than a single integrated view. We want the full summary AND the per-video
|
||||||
|
Q&A to open **in place in the list**, no page hop. Requirements:
|
||||||
|
- R1: clicking a summarized card expands it in place to the full summary (summary/highlights/
|
||||||
|
takeaways) + the chat dock; a collapse returns it to the compact card.
|
||||||
|
- R2: **no SPA framework** — stay HTMX + Templ (ADR-003); reuse existing fragments, not a rewrite.
|
||||||
|
- R3: **progressive enhancement** — with JS off, the card link still navigates to `/v/{id}`
|
||||||
|
(the detail page stays as the no-JS + deep-link surface). Nothing becomes JS-only.
|
||||||
|
- R4: only **summarized** cards expand; pending/rate-limited/no-caption cards keep their current
|
||||||
|
footer behaviour (Summarize button, waiting/none states).
|
||||||
|
- R5: chat inside an expanded card works exactly as on the detail page (reuse `chatReveal`/
|
||||||
|
`chatSection` + the existing `/v/{id}/chat` endpoints, unchanged).
|
||||||
|
|
||||||
|
**Decision / architecture.**
|
||||||
|
1. **Reuse the existing fragments.** `summaryBody(r)` and `chatReveal(videoID)` already exist and
|
||||||
|
render the detail page; a new `expandedCard(r, chatEnabled)` composes the compact header + a
|
||||||
|
collapse control + `summaryBody` + `chatReveal`. `DetailPage` is refactored to also compose
|
||||||
|
`summaryBody` so the two never drift (DRY).
|
||||||
|
2. **Two fragment endpoints** (mirroring the existing list/status HTMX fragment pattern):
|
||||||
|
`GET /v/{videoId}/expand` → `expandedCard`; collapse reuses the existing compact `VideoCard`
|
||||||
|
via `GET /v/{videoId}/card`. Both are list-card `<li>` fragments with the SAME `id`
|
||||||
|
(`video-{id}`), swapped `outerHTML` — same mechanism as `processingCard`/`VideoCard` today.
|
||||||
|
3. **The compact card's title/"Read" affordance** becomes `hx-get=/v/{id}/expand`,
|
||||||
|
`hx-target=#video-{id}`, `hx-swap=outerHTML`, with `href=/v/{id}` as the no-JS fallback (R3).
|
||||||
|
The expanded card's collapse control is the inverse (`hx-get=/v/{id}/card`).
|
||||||
|
4. **Only when `r.Summarized`** does the expand affordance render (R4); the other states are
|
||||||
|
unchanged.
|
||||||
|
5. **v1 does NOT push the URL** (`hx-push-url`) — expand/collapse is ephemeral list UI state; the
|
||||||
|
detail page remains the deep-link/shareable URL. Deep-linking the open state via `hx-push-url`
|
||||||
|
is noted as a later option (needs list-state restore on back).
|
||||||
|
|
||||||
|
**Out of scope / later.** URL push / deep-linkable open state; the visual refresh (#17) — though
|
||||||
|
the expanded-card markup is where #17's TUI/charm styling will land, so they pair.
|
||||||
|
|
||||||
|
**Reversibility.** Additive: two fragment endpoints + one templ + an affordance swap on the
|
||||||
|
compact card. Removing the affordance reverts to plain list→detail navigation; the detail page is
|
||||||
|
untouched. No schema change.
|
||||||
|
|
||||||
|
**Next steps (gated):** on approval → BDD (`docs/use-cases/inline_expand.feature` + coverage
|
||||||
|
map) → TDD → implement → SemVer + docs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-032 — Visual refresh: one charm-reader layout, light + dark themes
|
||||||
|
|
||||||
|
**Status:** Proposed (2026-06-12). Issue #17. **Draft for review — no code yet.** Follows the
|
||||||
|
sketch-first explore step (3 throwaway mockups in `docs/sketches/`, screenshotted for review).
|
||||||
|
|
||||||
|
**Context / decision.** The UI is flat. From the mockups, directions **B (light reader + charm)**
|
||||||
|
and **C (dark cozy terminal)** are the SAME layout — readable sans body, monospace meta, charm
|
||||||
|
palette accents, lipgloss-style bordered cards — in two palettes. Direction A (full-monospace TUI)
|
||||||
|
is dropped as too heavy to read long summaries. Decision: ship that one layout with **both a light
|
||||||
|
theme (B) and a dark theme (C)**, user-toggleable, defaulting to the OS preference.
|
||||||
|
|
||||||
|
**Requirements.**
|
||||||
|
- R1: one set of markup/structure; the two themes are pure palette (CSS variables), no duplicate templates.
|
||||||
|
- R2: a **theme toggle** persisted across visits; default to `prefers-color-scheme` when no choice stored.
|
||||||
|
- R3: charm language in both — mint/purple/pink accents, mono meta + section labels, lipgloss
|
||||||
|
bordered/gradient cards, the (fixed) ASCII tapir; readable sans body.
|
||||||
|
- R4: style the existing pieces — list, compact card, **expanded card incl. the already-present
|
||||||
|
video embed (ADR-031/summaryBody)**, detail page, chat dock, the queue note, the charm spinner.
|
||||||
|
- R5: WCAG-AA contrast for body text in BOTH themes; keep `prefers-reduced-motion` (already honoured).
|
||||||
|
- R6: stay HTMX+Templ; no CSS framework.
|
||||||
|
|
||||||
|
**Architecture.**
|
||||||
|
1. **Palette as CSS variables.** `:root` holds the light (B) tokens; `:root[data-theme="dark"]`
|
||||||
|
holds the dark (C) tokens; a `prefers-color-scheme: dark` media block sets the dark tokens when
|
||||||
|
no explicit `data-theme` is set. All component CSS references variables only (R1). The existing
|
||||||
|
`CharmMint/Purple/Pink/Cream/Dim` Go consts remain the source for the spinner's inline colours.
|
||||||
|
2. **Theme toggle** = a small inline script (a dozen lines, no framework) in `Layout`: on load,
|
||||||
|
apply stored theme (localStorage) or fall through to the media query; a header toggle button
|
||||||
|
flips `data-theme` on `<html>` and stores it. This is the one new bit of JS; everything else
|
||||||
|
stays server-rendered + HTMX. (Considered: cookie + server-render — rejected, a full round-trip
|
||||||
|
per toggle is clunky for a pure presentation flip.)
|
||||||
|
3. **Scope = the `styleTag` CSS in `view.go`** (the single style source) plus tiny class hooks in
|
||||||
|
the templ where needed; content/structure are unchanged, so existing view tests keep passing.
|
||||||
|
|
||||||
|
**Verification.** The deployed UI is behind auth (web-shot can't log in), so visual review is via
|
||||||
|
the mockups now + a styled full-set mockup screenshot before merge, then a live eyeball on device.
|
||||||
|
Automated tests stay structural/behavioural (theme tokens present, toggle persists, expanded card
|
||||||
|
embeds the video, no-JS still renders a readable default) — colours are not unit-tested.
|
||||||
|
|
||||||
|
**Reversibility.** A CSS theme swap + one small script + a few class hooks; revert `styleTag` to
|
||||||
|
roll back. No schema, no structural change.
|
||||||
|
|
||||||
|
**Next steps (gated):** on approval → BDD (`docs/use-cases/visual_theme.feature` + coverage map)
|
||||||
|
→ TDD → implement → SemVer + docs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# Vocabulary (hand-written pilot 2026-06 — will be generated by langgen in Phase 2)
|
||||||
|
|
||||||
|
| Term | Means | Never say |
|
||||||
|
|---|---|---|
|
||||||
|
| transcript | Captions-first text of a video; the source for summarizing. Shared store keyed by `(provider, video_id)`. | "subtitles file", "the audio" |
|
||||||
|
| video | Per-user record of a seen video; the unit of work. Not globally deduped. | "the shared video" |
|
||||||
|
| subscription | A watched channel on a user's connection that Tapir polls for new videos. | "feed", "follow" |
|
||||||
|
| summary | A video's produced output: summary text + highlights + takeaways + AI provenance. | "transcript" |
|
||||||
|
| highlight | A notable point pulled from a video (`Summary.Highlights`). | "takeaway" |
|
||||||
|
| takeaway | An actionable conclusion from a video (`Summary.Takeaways`). | "highlight" |
|
||||||
|
| sink | A delivery destination for a summary (`store`, `brain`). New one = new adapter. | "the database" |
|
||||||
|
| AI router | Local-first chain: local Primary → local fallback → external worst-case. | "the API" |
|
||||||
|
| BYO-AI fallback | User's own external AI key, used only when local fails; opt-in. Without it, content is never sent externally. | "the default AI" |
|
||||||
|
| brain | Persistent homelab knowledge store; in Tapir, one optional HTTP sink (`brain_ingest`), not the filesystem package. | "the database", "the store" |
|
||||||
|
| LiteLLM gateway | Local AI gateway (`koala:30401/v1`); the Primary in the AI router. | "piguard:4000", "koala:4000", "the cloud" |
|
||||||
|
| connection | A connected video account a user authorizes via OAuth; subscriptions hang off it. | "login", "session" |
|
||||||
|
|
||||||
|
## Caveman rubric
|
||||||
|
Before any HIGH/CRITICAL operation, output one line:
|
||||||
|
`caveman: me <verb> <object>, not <excluded thing>`
|
||||||
|
Valid iff: (1) only vocabulary terms + plain verbs, (2) a stranger could identify
|
||||||
|
the exact operation, (3) names one thing explicitly NOT being done.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `caveman: me delete user connection, not the shared transcript`
|
||||||
|
- `caveman: me send transcript to BYO-AI fallback, not the LiteLLM gateway`
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"git.d-ma.be/mathias/tapir/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestChatModelsReuseTheChainLocalFirst: the switcher offers the ADR-022 chain in
|
// TestChatModelsReuseTheChainLocalFirst: the switcher offers the ADR-022 chain in
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// discoveryRunner runs one user's discovery pass.
|
// discoveryRunner runs one user's discovery pass.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// serialize must guarantee at most one discovery pass runs at a time, so a
|
// serialize must guarantee at most one discovery pass runs at a time, so a
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Env var names for the read-only CLI. DSN and user id are never hardcoded — the
|
// Env var names for the read-only CLI. DSN and user id are never hardcoded — the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFormatListColumnsAndOrdering(t *testing.T) {
|
func TestFormatListColumnsAndOrdering(t *testing.T) {
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runList prints the user's stored summaries as a table, most recent first.
|
// runList prints the user's stored summaries as a table, most recent first.
|
||||||
|
|||||||
+9
-9
@@ -23,15 +23,15 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
"git.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
"git.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"git.d-ma.be/mathias/tapir/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"git.d-ma.be/mathias/tapir/internal/config"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/metrics"
|
"git.d-ma.be/mathias/tapir/internal/metrics"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
|
"git.d-ma.be/mathias/tapir/internal/web/oidc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
+12
-12
@@ -5,18 +5,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/chat"
|
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
|
"git.d-ma.be/mathias/tapir/internal/adapters/llm"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
"git.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
|
"git.d-ma.be/mathias/tapir/internal/adapters/summarizer"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
"git.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"git.d-ma.be/mathias/tapir/internal/config"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/metrics"
|
"git.d-ma.be/mathias/tapir/internal/metrics"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"git.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// videoFetcher adapts the YouTube adapter to web.VideoFetcher for the paste flow
|
// videoFetcher adapts the YouTube adapter to web.VideoFetcher for the paste flow
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"git.d-ma.be/mathias/tapir/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestBuildProcessorNilOnIncompleteConfig asserts the queue-only fallback: when a
|
// TestBuildProcessorNilOnIncompleteConfig asserts the queue-only fallback: when a
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import (
|
|||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// gateThreshold is the Stage-0 gate (VISION/ADR-016): usage in >= 2 distinct
|
// gateThreshold is the Stage-0 gate (VISION/ADR-016): usage in >= 2 distinct
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var testSince = time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC)
|
var testSince = time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
"git.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"git.d-ma.be/mathias/tapir/internal/config"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"git.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildUserRunner constructs a runner.Runner for one user, reusing the same
|
// buildUserRunner constructs a runner.Runner for one user, reusing the same
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
)
|
)
|
||||||
|
|
||||||
func quietLog() *slog.Logger {
|
func quietLog() *slog.Logger {
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runShow prints the full summary for one video: text, highlights, takeaways,
|
// runShow prints the full summary for one video: text, highlights, takeaways,
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ knobs plus one load-bearing deployment constraint:
|
|||||||
- `TAPIR_USAGE_GATE_START` — `YYYY-MM-DD`, default **`2026-06-11`** (the morning the pilot was
|
- `TAPIR_USAGE_GATE_START` — `YYYY-MM-DD`, default **`2026-06-11`** (the morning the pilot was
|
||||||
unblocked and summaries started flowing). `tapir report` counts return-usage (distinct active
|
unblocked and summaries started flowing). `tapir report` counts return-usage (distinct active
|
||||||
weeks, ADR-016) only from this date, so pre-launch testing and the blocked period are excluded.
|
weeks, ADR-016) only from this date, so pre-launch testing and the blocked period are excluded.
|
||||||
|
- `TAPIR_METRICS_ADDR` — listen address for the Prometheus `/metrics` endpoint (ADR-030).
|
||||||
|
**Default `:9090`** — a SEPARATE port from `TAPIR_HTTP_ADDR` so metrics are never on the public
|
||||||
|
app; scraped in-cluster only (PodMonitor). Empty disables the metrics server. Key series:
|
||||||
|
`tapir_summarize_duration_seconds{model,outcome,fallback}`, `tapir_caption_fetch_duration_seconds{outcome}`,
|
||||||
|
`tapir_chat_duration_seconds{model}`, `tapir_llm_tokens_total{model,kind}`,
|
||||||
|
`tapir_http_request_duration_seconds{method,route}`, `tapir_logins_total`.
|
||||||
- `TAPIR_FETCH_RATE` — Go duration, default `2s`. The **process-wide per-egress-IP caption-fetch
|
- `TAPIR_FETCH_RATE` — Go duration, default `2s`. The **process-wide per-egress-IP caption-fetch
|
||||||
rate gate** (ADR-014 item 2). Every caption fetch — scheduler runners *and* the web "Summarize"
|
rate gate** (ADR-014 item 2). Every caption fetch — scheduler runners *and* the web "Summarize"
|
||||||
click-path — serialises through this one limiter so the pod cannot collectively trip 429s. `0`
|
click-path — serialises through this one limiter so the pod cannot collectively trip 429s. `0`
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>tapir — A · TUI panels</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0d0d12;--panel:#14141c;--line:#2a2a3a;--mint:#0EF9B6;--pink:#F740A0;--purple:#7653fc;--cream:#F5E9D6;--dim:#6b6b86;--text:#d9d9e6}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,"Cascadia Code",monospace}
|
||||||
|
.wrap{max-width:760px;margin:0 auto;padding:18px 14px 60px}
|
||||||
|
header{display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding-bottom:12px;margin-bottom:18px}
|
||||||
|
.brand{color:var(--mint);font-weight:700;letter-spacing:.5px}
|
||||||
|
.brand b{color:var(--cream)}
|
||||||
|
.tip{margin-left:auto;color:var(--dim);font-size:12px}
|
||||||
|
.note{color:var(--dim);font-size:12.5px;border-left:2px solid var(--purple);padding:6px 10px;margin:0 0 16px;background:#11111a}
|
||||||
|
/* lipgloss-style bordered panel */
|
||||||
|
.card{border:1px solid var(--line);border-radius:8px;background:var(--panel);padding:12px 14px;margin:0 0 12px;position:relative}
|
||||||
|
.card::before{content:"";position:absolute;left:0;top:10px;bottom:10px;width:3px;border-radius:3px;background:var(--purple)}
|
||||||
|
.card.ready::before{background:var(--mint)}
|
||||||
|
.title{color:var(--cream);font-size:15px;font-weight:600;margin:0 0 4px}
|
||||||
|
.meta{color:var(--dim);font-size:12px}
|
||||||
|
.chip{display:inline-block;border:1px solid var(--mint);color:var(--mint);border-radius:4px;padding:0 6px;font-size:11px;margin-left:6px}
|
||||||
|
.chip.q{border-color:var(--pink);color:var(--pink)}
|
||||||
|
.preview{color:var(--dim);margin:6px 0 0;font-size:13px}
|
||||||
|
/* expanded */
|
||||||
|
.exp{border-color:var(--purple)}
|
||||||
|
.exp .head{display:flex;justify-content:space-between;align-items:baseline}
|
||||||
|
.collapse{color:var(--mint);font-size:12px;text-decoration:none;border:1px solid var(--line);border-radius:4px;padding:1px 7px}
|
||||||
|
.sec h2{color:var(--mint);font-size:12px;text-transform:uppercase;letter-spacing:1px;margin:16px 0 6px;border-bottom:1px dashed var(--line);padding-bottom:3px}
|
||||||
|
.sec ul{margin:0;padding-left:18px}.sec li{margin:3px 0}
|
||||||
|
.body{color:var(--text)}
|
||||||
|
.dock{margin-top:16px;border-top:1px solid var(--line);padding-top:12px}
|
||||||
|
.ask{display:inline-block;background:linear-gradient(90deg,var(--purple),var(--pink));color:#fff;border:0;border-radius:6px;padding:7px 12px;font:inherit;font-size:13px;cursor:pointer}
|
||||||
|
pre.tapir{margin:0;color:var(--mint);font-size:10px;line-height:1.05}
|
||||||
|
</style></head><body><div class="wrap">
|
||||||
|
<header>
|
||||||
|
<pre class="tapir"> ▄█▓▓█▄ ∩
|
||||||
|
█▓( ◕ ◕ )▓█──┘</pre>
|
||||||
|
<span class="brand">◆ <b>tapir</b> · watch less, know more</span>
|
||||||
|
<span class="tip">261 in queue</span>
|
||||||
|
</header>
|
||||||
|
<p class="note">Tapir fetches captions slowly on purpose, to respect YouTube's limits — new summaries land gradually.</p>
|
||||||
|
|
||||||
|
<div class="card ready"><div class="title">How the Attention Economy Rewires Your Brain</div>
|
||||||
|
<div class="meta">youtube · 2026-06-11 · 18 min <span class="chip">ready</span></div>
|
||||||
|
<div class="preview">A tour of the incentive loops behind infinite feeds and three concrete ways to claw back focus…</div></div>
|
||||||
|
|
||||||
|
<article class="card exp ready">
|
||||||
|
<div class="head"><div class="title">Postgres 18 — What's Actually New</div><a class="collapse" href="#">collapse ↑</a></div>
|
||||||
|
<div class="meta">youtube · 2026-06-10 · 42 min · phi4-mini</div>
|
||||||
|
<div class="sec"><h2>Takeaways</h2><ul>
|
||||||
|
<li>Async I/O cuts cold-cache read latency materially on NVMe.</li>
|
||||||
|
<li>Skip-scan makes more multicolumn indexes usable without rewrites.</li>
|
||||||
|
<li>Upgrade path is smooth; test the new planner stats first.</li></ul></div>
|
||||||
|
<div class="sec"><h2>Highlights</h2><ul>
|
||||||
|
<li>Async I/O subsystem (effective_io_concurrency now matters more).</li>
|
||||||
|
<li>B-tree skip scan for leading-column gaps.</li>
|
||||||
|
<li>Better partition-wise joins.</li></ul></div>
|
||||||
|
<div class="sec"><h2>Summary</h2><p class="body">Postgres 18 is an incremental but meaningful release: the headline is the new asynchronous I/O path, with skip-scan and planner improvements close behind. For most homelabs the upgrade is low-risk and worth it for the read-latency wins.</p></div>
|
||||||
|
<div class="dock"><button class="ask">Dig deeper — ask about this video →</button></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="card"><div class="title">RAG is dead, right?? — Conference Talk</div>
|
||||||
|
<div class="meta">youtube · 2026-06-09 · 31 min <span class="chip q">queued</span></div></div>
|
||||||
|
</div></body></html>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>tapir — B · reader + charm accents</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#faf7f2;--card:#fff;--ink:#1c1b22;--soft:#6a6878;--line:#e7e2d8;--mint:#0bbf8c;--purple:#6a4cf0;--pink:#e0379a}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
|
||||||
|
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||||
|
.wrap{max-width:680px;margin:0 auto;padding:20px 16px 60px}
|
||||||
|
header{display:flex;align-items:center;gap:10px;margin-bottom:18px}
|
||||||
|
.brand{font-weight:800;font-size:18px;letter-spacing:-.3px}
|
||||||
|
.brand .dot{color:var(--mint)}
|
||||||
|
.tag{color:var(--soft);font-size:13px}
|
||||||
|
.tip{margin-left:auto;color:var(--soft);font-size:12px}
|
||||||
|
.note{color:var(--soft);font-size:13px;background:#fff;border:1px solid var(--line);border-left:3px solid var(--mint);border-radius:8px;padding:8px 12px;margin:0 0 18px}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:0 0 14px;box-shadow:0 1px 2px rgba(20,18,40,.04)}
|
||||||
|
.title{font-size:18px;font-weight:700;letter-spacing:-.2px;margin:0 0 4px;line-height:1.3}
|
||||||
|
.meta{color:var(--soft);font-size:13px}
|
||||||
|
.meta .mono{font-size:12.5px}
|
||||||
|
.chip{display:inline-block;background:rgba(11,191,140,.12);color:var(--mint);border-radius:999px;padding:1px 9px;font-size:12px;font-weight:600;margin-left:6px}
|
||||||
|
.chip.q{background:rgba(224,55,154,.12);color:var(--pink)}
|
||||||
|
.preview{color:var(--soft);margin:8px 0 0}
|
||||||
|
.exp{border-color:#d9d0ee;box-shadow:0 6px 24px rgba(106,76,240,.10)}
|
||||||
|
.head{display:flex;justify-content:space-between;align-items:baseline;gap:10px}
|
||||||
|
.collapse{color:var(--purple);font-size:13px;font-weight:600;text-decoration:none;white-space:nowrap}
|
||||||
|
.sec h2{font-size:12px;text-transform:uppercase;letter-spacing:1.2px;color:var(--purple);margin:18px 0 6px}
|
||||||
|
.sec ul{margin:0;padding-left:20px}.sec li{margin:5px 0}
|
||||||
|
.body{line-height:1.7}
|
||||||
|
.dock{margin-top:18px;border-top:1px solid var(--line);padding-top:14px}
|
||||||
|
.ask{display:inline-flex;align-items:center;gap:6px;background:var(--ink);color:#fff;border:0;border-radius:999px;padding:9px 16px;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
|
||||||
|
pre.tapir{margin:0;color:var(--mint);font-size:10px;line-height:1.05}
|
||||||
|
</style></head><body><div class="wrap">
|
||||||
|
<header>
|
||||||
|
<pre class="tapir"> ▄█▓▓█▄ ∩
|
||||||
|
█▓( ◕ ◕ )▓█──┘</pre>
|
||||||
|
<span class="brand">tapir<span class="dot">.</span></span><span class="tag">watch less, know more</span>
|
||||||
|
<span class="tip mono">261 in queue</span>
|
||||||
|
</header>
|
||||||
|
<p class="note">Tapir fetches captions slowly on purpose, to respect YouTube's limits — new summaries land gradually.</p>
|
||||||
|
|
||||||
|
<div class="card"><div class="title">How the Attention Economy Rewires Your Brain</div>
|
||||||
|
<div class="meta mono">youtube · 2026-06-11 · 18 min <span class="chip">ready</span></div>
|
||||||
|
<div class="preview">A tour of the incentive loops behind infinite feeds and three concrete ways to claw back focus…</div></div>
|
||||||
|
|
||||||
|
<article class="card exp">
|
||||||
|
<div class="head"><div class="title">Postgres 18 — What's Actually New</div><a class="collapse" href="#">collapse ↑</a></div>
|
||||||
|
<div class="meta mono">youtube · 2026-06-10 · 42 min · phi4-mini</div>
|
||||||
|
<div class="sec"><h2>Takeaways</h2><ul>
|
||||||
|
<li>Async I/O cuts cold-cache read latency materially on NVMe.</li>
|
||||||
|
<li>Skip-scan makes more multicolumn indexes usable without rewrites.</li>
|
||||||
|
<li>Upgrade path is smooth; test the new planner stats first.</li></ul></div>
|
||||||
|
<div class="sec"><h2>Highlights</h2><ul>
|
||||||
|
<li>Async I/O subsystem (effective_io_concurrency now matters more).</li>
|
||||||
|
<li>B-tree skip scan for leading-column gaps.</li>
|
||||||
|
<li>Better partition-wise joins.</li></ul></div>
|
||||||
|
<div class="sec"><h2>Summary</h2><p class="body">Postgres 18 is an incremental but meaningful release: the headline is the new asynchronous I/O path, with skip-scan and planner improvements close behind. For most homelabs the upgrade is low-risk and worth it for the read-latency wins.</p></div>
|
||||||
|
<div class="dock"><button class="ask">✦ Ask about this video</button></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="card"><div class="title">RAG is dead, right?? — Conference Talk</div>
|
||||||
|
<div class="meta mono">youtube · 2026-06-09 · 31 min <span class="chip q">queued</span></div></div>
|
||||||
|
</div></body></html>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>tapir — C · cozy terminal</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#16131d;--card:#1e1a28;--ink:#ece7f5;--soft:#9a92b4;--line:#322b44;--mint:#2ee6b6;--purple:#9d7bff;--pink:#ff6bdb;--cream:#f3ead8}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:radial-gradient(1200px 600px at 70% -10%,#231b33,transparent),var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
|
||||||
|
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||||
|
.wrap{max-width:700px;margin:0 auto;padding:20px 16px 60px}
|
||||||
|
header{display:flex;align-items:center;gap:12px;margin-bottom:18px}
|
||||||
|
.brand{font-weight:800;font-size:18px}.brand .c{color:var(--mint)}
|
||||||
|
.tag{color:var(--soft);font-size:13px}
|
||||||
|
.tip{margin-left:auto;color:var(--soft);font-size:12px}
|
||||||
|
.note{color:var(--soft);font-size:13px;background:#1b1726;border:1px solid var(--line);border-radius:10px;padding:9px 12px;margin:0 0 18px}
|
||||||
|
.note b{color:var(--mint);font-weight:600}
|
||||||
|
.card{position:relative;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px 14px 18px;margin:0 0 13px;overflow:hidden}
|
||||||
|
.card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--purple)}
|
||||||
|
.card.ready::before{background:linear-gradient(var(--mint),var(--purple))}
|
||||||
|
.title{font-size:16px;font-weight:700;margin:0 0 4px;color:var(--cream)}
|
||||||
|
.meta{color:var(--soft);font-size:12.5px}
|
||||||
|
.chip{display:inline-block;border-radius:999px;padding:1px 9px;font-size:11px;font-weight:600;margin-left:6px;background:rgba(46,230,182,.14);color:var(--mint)}
|
||||||
|
.chip.q{background:rgba(157,123,255,.18);color:var(--purple)}
|
||||||
|
.preview{color:var(--soft);margin:7px 0 0;font-size:14px}
|
||||||
|
.exp{border-color:#473a63;box-shadow:0 10px 40px rgba(0,0,0,.35)}
|
||||||
|
.head{display:flex;justify-content:space-between;align-items:baseline;gap:10px}
|
||||||
|
.collapse{color:var(--mint);font-size:12px;text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 8px;white-space:nowrap}
|
||||||
|
.sec h2{font-size:11px;text-transform:uppercase;letter-spacing:1.3px;color:var(--mint);margin:18px 0 7px;display:flex;align-items:center;gap:8px}
|
||||||
|
.sec h2::after{content:"";flex:1;height:1px;background:var(--line)}
|
||||||
|
.sec ul{margin:0;padding-left:18px}.sec li{margin:5px 0}
|
||||||
|
.body{line-height:1.7;color:var(--ink)}
|
||||||
|
.dock{margin-top:18px;border-top:1px dashed var(--line);padding-top:14px;display:flex;align-items:center;gap:10px}
|
||||||
|
.ask{display:inline-flex;align-items:center;gap:7px;background:linear-gradient(90deg,var(--purple),var(--mint));color:#10101a;border:0;border-radius:999px;padding:9px 16px;font:inherit;font-weight:700;font-size:14px;cursor:pointer}
|
||||||
|
.dockhint{color:var(--soft);font-size:12px}
|
||||||
|
pre.tapir{margin:0;color:var(--mint);font-size:11px;line-height:1.05}
|
||||||
|
</style></head><body><div class="wrap">
|
||||||
|
<header>
|
||||||
|
<pre class="tapir"> ▄█▓▓█▄ ∩
|
||||||
|
█▓( ◕ ◕ )▓█──┘</pre>
|
||||||
|
<span class="brand">tapir<span class="c">_</span></span><span class="tag">watch less, know more</span>
|
||||||
|
<span class="tip mono">261 in queue</span>
|
||||||
|
</header>
|
||||||
|
<p class="note">Tapir fetches captions slowly on purpose, to respect YouTube's limits — <b>new summaries land gradually</b>.</p>
|
||||||
|
|
||||||
|
<div class="card ready"><div class="title">How the Attention Economy Rewires Your Brain</div>
|
||||||
|
<div class="meta mono">youtube · 2026-06-11 · 18 min <span class="chip">ready</span></div>
|
||||||
|
<div class="preview">A tour of the incentive loops behind infinite feeds and three concrete ways to claw back focus…</div></div>
|
||||||
|
|
||||||
|
<article class="card exp ready">
|
||||||
|
<div class="head"><div class="title">Postgres 18 — What's Actually New</div><a class="collapse" href="#">collapse ↑</a></div>
|
||||||
|
<div class="meta mono">youtube · 2026-06-10 · 42 min · phi4-mini</div>
|
||||||
|
<div class="sec"><h2>Takeaways</h2><ul>
|
||||||
|
<li>Async I/O cuts cold-cache read latency materially on NVMe.</li>
|
||||||
|
<li>Skip-scan makes more multicolumn indexes usable without rewrites.</li>
|
||||||
|
<li>Upgrade path is smooth; test the new planner stats first.</li></ul></div>
|
||||||
|
<div class="sec"><h2>Highlights</h2><ul>
|
||||||
|
<li>Async I/O subsystem (effective_io_concurrency now matters more).</li>
|
||||||
|
<li>B-tree skip scan for leading-column gaps.</li>
|
||||||
|
<li>Better partition-wise joins.</li></ul></div>
|
||||||
|
<div class="sec"><h2>Summary</h2><p class="body">Postgres 18 is an incremental but meaningful release: the headline is the new asynchronous I/O path, with skip-scan and planner improvements close behind. For most homelabs the upgrade is low-risk and worth it for the read-latency wins.</p></div>
|
||||||
|
<div class="dock"><button class="ask">◆ Ask about this video</button><span class="dockhint mono">answers only from this video's transcript</span></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="card"><div class="title">RAG is dead, right?? — Conference Talk</div>
|
||||||
|
<div class="meta mono">youtube · 2026-06-09 · 31 min <span class="chip q">queued</span></div></div>
|
||||||
|
</div></body></html>
|
||||||
@@ -179,4 +179,5 @@ distinguishable.
|
|||||||
| **"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`) |
|
| **"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`) |
|
| **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`) |
|
| **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` |
|
| **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` |
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
Feature: Inline-expand summary + Q&A in the list (ADR-031, #16)
|
||||||
|
As a reader skimming my summaries
|
||||||
|
I want to open a summary and its Q&A in place in the list
|
||||||
|
So that I get the full read and follow-up without leaving the list (SPA-like, no page hop)
|
||||||
|
|
||||||
|
# HTMX inline-expand, no SPA framework (ADR-031). Each scenario maps to a Go test
|
||||||
|
# in scenario_coverage_test.go (the BDD name-coverage gate).
|
||||||
|
|
||||||
|
Scenario: A summarized card expands to the full summary in place
|
||||||
|
Given a summarized video in my list
|
||||||
|
When I expand its card
|
||||||
|
Then the full summary, highlights, and takeaways are returned as an in-place card fragment, not a full page
|
||||||
|
|
||||||
|
Scenario: An expanded card collapses back to the compact card
|
||||||
|
Given an expanded card
|
||||||
|
When I collapse it
|
||||||
|
Then the compact card fragment is returned in its place
|
||||||
|
|
||||||
|
Scenario: The expanded card offers the Q&A dock
|
||||||
|
Given chat is enabled
|
||||||
|
When a summarized card is expanded
|
||||||
|
Then the expanded card includes the deeper-dive chat affordance for that video
|
||||||
|
|
||||||
|
Scenario: Only a summarized card offers expand
|
||||||
|
Given a discovered but not-yet-summarized card
|
||||||
|
When the card is rendered
|
||||||
|
Then it shows its summarize/queue footer and no expand affordance
|
||||||
|
|
||||||
|
Scenario: With JS off the card still reaches the full summary
|
||||||
|
Given a summarized card
|
||||||
|
When it is rendered
|
||||||
|
Then its expand affordance carries an href to the detail page as a no-JS fallback
|
||||||
|
|
||||||
|
Scenario: The detail page and the expanded card show the same summary
|
||||||
|
Given a summarized video
|
||||||
|
When I view it on the detail page and as an expanded card
|
||||||
|
Then both render the same summary body (one shared fragment, no drift)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
Feature: Visual refresh — one charm-reader layout, light + dark themes (ADR-032, #17)
|
||||||
|
As a reader who likes a TUI/charm aesthetic
|
||||||
|
I want a fresh look with a light and a dark theme
|
||||||
|
So that the app feels distinctive and reads well in either mode
|
||||||
|
|
||||||
|
# Direction B (light) + C (dark) are one layout in two palettes (CSS variables),
|
||||||
|
# plus a persisted toggle and an OS-preference default. Colours are reviewed via
|
||||||
|
# the mockups in docs/sketches/, not unit-tested; these scenarios cover the
|
||||||
|
# testable structure. Each maps to a Go test in scenario_coverage_test.go.
|
||||||
|
|
||||||
|
Scenario: Light and dark themes share one layout via CSS variables
|
||||||
|
Given the app stylesheet
|
||||||
|
When it is rendered
|
||||||
|
Then it defines a light palette on the root and a dark palette under data-theme="dark", with no duplicate markup
|
||||||
|
|
||||||
|
Scenario: Without a stored choice the theme follows the OS preference
|
||||||
|
Given a visitor with no saved theme
|
||||||
|
When the page loads
|
||||||
|
Then a prefers-color-scheme dark block applies the dark palette automatically
|
||||||
|
|
||||||
|
Scenario: A persisted toggle switches light and dark
|
||||||
|
Given any page
|
||||||
|
When it is rendered
|
||||||
|
Then it includes a theme-toggle control and a small script that flips data-theme and persists the choice
|
||||||
|
|
||||||
|
Scenario: The expanded card embeds the video player
|
||||||
|
Given a summarized video with a valid provider id
|
||||||
|
When its card is expanded
|
||||||
|
Then the expanded card includes the embedded video player
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module gitea.d-ma.be/mathias/tapir
|
module git.d-ma.be/mathias/tapir
|
||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/metrics"
|
"git.d-ma.be/mathias/tapir/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Completer is the minimal LLM chat surface the Service needs. *llm.Client
|
// Completer is the minimal LLM chat surface the Service needs. *llm.Client
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileStore is a SecretStore backed by a single 0600 JSON file mapping opaque
|
// FileStore is a SecretStore backed by a single 0600 JSON file mapping opaque
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
"git.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPutThenGet(t *testing.T) {
|
func TestPutThenGet(t *testing.T) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedUserRow inserts a bare users row (FK target for a connection) as the
|
// seedUserRow inserts a bare users row (FK target for a connection) as the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedVideo inserts a videos row whose id matches a summary's video_id, so the
|
// seedVideo inserts a videos row whose id matches a summary's video_id, so the
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate
|
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Static check: Store satisfies the Sink port.
|
// Static check: Store satisfies the Sink port.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedBareVideo inserts a videos row with no summary, so the all-videos read and
|
// seedBareVideo inserts a videos row with no summary, so the all-videos read and
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetTranscript returns the shared, stored transcript for a video keyed by the
|
// GetTranscript returns the shared, stored transcript for a video keyed by the
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetTranscriptStatus_RoundTrip(t *testing.T) {
|
func TestSetTranscriptStatus_RoundTrip(t *testing.T) {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Static check: Store satisfies the shared TranscriptStore port (ADR-021).
|
// Static check: Store satisfies the shared TranscriptStore port (ADR-021).
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpsertVideo persists a video's metadata and returns its durable store id (the
|
// UpsertVideo persists a video's metadata and returns its durable store id (the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ytVideo(userID, provVideoID, title string) domain.Video {
|
func ytVideo(userID, provVideoID, title string) domain.Video {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/metrics"
|
"git.d-ma.be/mathias/tapir/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Completer is the minimal LLM chat surface the Summarizer needs.
|
// Completer is the minimal LLM chat surface the Summarizer needs.
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/metrics"
|
"git.d-ma.be/mathias/tapir/internal/metrics"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSummarizerRecordsMetric verifies the summarizer→metrics wiring (ADR-030)
|
// TestSummarizerRecordsMetric verifies the summarizer→metrics wiring (ADR-030)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/metrics"
|
"git.d-ma.be/mathias/tapir/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultPlayerBaseURL is the InnerTube / watch-page host. Overridable via
|
// defaultPlayerBaseURL is the InnerTube / watch-page host. Overridable via
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVideoByID(t *testing.T) {
|
func TestVideoByID(t *testing.T) {
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultBaseURL is the YouTube Data API v3 root. Overridable via Config.BaseURL
|
// defaultBaseURL is the YouTube Data API v3 root. Overridable via Config.BaseURL
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- fakes ------------------------------------------------------------------
|
// --- fakes ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"git.d-ma.be/mathias/tapir/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
|
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ package ports
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VideoSource is a video platform Tapir watches (YouTube, Vimeo).
|
// VideoSource is a video platform Tapir watches (YouTube, Vimeo).
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"git.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
// passCandidate is a video that passed all pre-filters (seen/manual/backoff)
|
// passCandidate is a video that passed all pre-filters (seen/manual/backoff)
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"git.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testUser = "11111111-1111-1111-1111-111111111111"
|
const testUser = "11111111-1111-1111-1111-111111111111"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotImplemented marks scaffold methods awaiting implementation.
|
// ErrNotImplemented marks scaffold methods awaiting implementation.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// These tests pin the ADR-021 read-stored-first behaviour at the engine core:
|
// These tests pin the ADR-021 read-stored-first behaviour at the engine core:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleAccount renders the account page: the user's display name, the
|
// handleAccount renders the account page: the user's display name, the
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so
|
// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/chat"
|
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Chatter is the per-video chat backend (ADR-027). *chat.Service satisfies it;
|
// Chatter is the per-video chat backend (ADR-027). *chat.Service satisfies it;
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/chat"
|
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// videoZ is a video id used by the isolation test for a DIFFERENT user's video.
|
// videoZ is a video id used by the isolation test for a DIFFERENT user's video.
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"git.d-ma.be/mathias/tapir/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Connections is the narrow write port the connect flow depends on (Clean
|
// Connections is the narrow write port the connect flow depends on (Clean
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"git.d-ma.be/mathias/tapir/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
|
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store is the read/write surface the web handlers depend on — a narrow port over
|
// Store is the read/write surface the web handlers depend on — a narrow port over
|
||||||
@@ -151,6 +151,8 @@ func (a *App) Router() http.Handler {
|
|||||||
app := http.NewServeMux()
|
app := http.NewServeMux()
|
||||||
app.HandleFunc("GET /{$}", a.handleList)
|
app.HandleFunc("GET /{$}", a.handleList)
|
||||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
||||||
|
app.HandleFunc("GET /v/{videoId}/expand", a.handleExpand)
|
||||||
|
app.HandleFunc("GET /v/{videoId}/card", a.handleCard)
|
||||||
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
||||||
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
||||||
app.HandleFunc("POST /v/{videoId}/retry-now", a.handleRetryNow)
|
app.HandleFunc("POST /v/{videoId}/retry-now", a.handleRetryNow)
|
||||||
@@ -283,6 +285,48 @@ func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.render(w, r, DetailPage(*row, a.Chat != nil))
|
a.render(w, r, DetailPage(*row, a.Chat != nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleExpand returns the inline-expanded card fragment — the full summary +
|
||||||
|
// chat dock swapped into the list card in place (ADR-031). Only summarized videos
|
||||||
|
// have a summary to expand; a non-summarized id is a 404 (the compact card never
|
||||||
|
// offers expand for it).
|
||||||
|
func (a *App) handleExpand(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoID := r.PathValue("videoId")
|
||||||
|
row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "get summary", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, expandedCard(*row, a.Chat != nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCard returns the compact card fragment — the collapse target that returns
|
||||||
|
// an expanded card to its compact form in the list (ADR-031).
|
||||||
|
func (a *App) handleCard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoID := r.PathValue("videoId")
|
||||||
|
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "get video", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, VideoCard(*row))
|
||||||
|
}
|
||||||
|
|
||||||
// handleAction toggles one action: re-clicking an active verb clears it, else it
|
// handleAction toggles one action: re-clicking an active verb clears it, else it
|
||||||
// is set (the store enforces watched↔skipped exclusion atomically). It returns
|
// is set (the store enforces watched↔skipped exclusion atomically). It returns
|
||||||
// the refreshed button-group fragment for HTMX; without JS it redirects back to
|
// the refreshed button-group fragment for HTMX; without JS it redirects back to
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dsn points at the in-process Postgres started in TestMain. Handler tests run
|
// dsn points at the in-process Postgres started in TestMain. Handler tests run
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExpandReturnsSummaryBodyFragment: GET /v/{id}/expand returns the full
|
||||||
|
// summary as an in-place card fragment (not a full page) — ADR-031.
|
||||||
|
func TestExpandReturnsSummaryBodyFragment(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
require.NoError(t, deliver(ctx, app, videoX, "the full summary text"))
|
||||||
|
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/expand", nil)))
|
||||||
|
require.Contains(t, html, "the full summary text")
|
||||||
|
require.Contains(t, html, "Takeaways")
|
||||||
|
require.Contains(t, html, "highlight one")
|
||||||
|
require.Contains(t, html, "card-expanded", "rendered as the expanded card")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"/card", "carries a collapse affordance")
|
||||||
|
require.NotContains(t, html, "<html", "fragment, not a full page")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCollapseReturnsCompactCard: GET /v/{id}/card returns the compact card with
|
||||||
|
// the expand affordance — the collapse target.
|
||||||
|
func TestCollapseReturnsCompactCard(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
require.NoError(t, deliver(ctx, app, videoX, "summary text"))
|
||||||
|
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/card", nil)))
|
||||||
|
require.Contains(t, html, `class="card"`, "compact card")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"/expand", "compact card offers expand")
|
||||||
|
require.NotContains(t, html, "card-expanded")
|
||||||
|
require.NotContains(t, html, "<html", "fragment, not a full page")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExpandedCardOffersChatDock: with chat enabled, the expanded card includes
|
||||||
|
// the deeper-dive chat affordance.
|
||||||
|
func TestExpandedCardOffersChatDock(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newChatApp(t, &fakeChatter{models: []string{"m"}}, nil)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
require.NoError(t, deliver(ctx, app, videoX, "summary text"))
|
||||||
|
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/expand", nil)))
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"/chat", "expanded card wires the chat dock")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompactCardExpandOnlyWhenSummarized: a not-yet-summarized card shows its
|
||||||
|
// summarize footer and no expand affordance.
|
||||||
|
func TestCompactCardExpandOnlyWhenSummarized(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{}) // no summary
|
||||||
|
|
||||||
|
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/card", nil)))
|
||||||
|
require.Contains(t, html, "Not summarized")
|
||||||
|
require.NotContains(t, html, "/v/"+videoX+"/expand", "pending card offers no expand")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompactCardHasNoJSDetailFallback: the expand affordance carries an href to
|
||||||
|
// the detail page, so JS-off users still reach the full summary.
|
||||||
|
func TestCompactCardHasNoJSDetailFallback(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
require.NoError(t, deliver(ctx, app, videoX, "summary text"))
|
||||||
|
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/card", nil)))
|
||||||
|
require.Contains(t, html, `href="/v/`+videoX+`"`, "no-JS fallback to the detail page")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"/expand", "and the HTMX expand for JS users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetailAndExpandShareSummaryBody: the detail page and the expanded card render
|
||||||
|
// the same summary body (one shared fragment, no drift).
|
||||||
|
func TestDetailAndExpandShareSummaryBody(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
require.NoError(t, deliver(ctx, app, videoX, "shared summary text"))
|
||||||
|
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
detail := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil)))
|
||||||
|
expand := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/expand", nil)))
|
||||||
|
for _, want := range []string{"shared summary text", "Takeaways", "highlight one"} {
|
||||||
|
require.Contains(t, detail, want)
|
||||||
|
require.Contains(t, expand, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,8 +27,8 @@ import (
|
|||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/metrics"
|
"git.d-ma.be/mathias/tapir/internal/metrics"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the OIDC + session configuration. cmd/tapir maps these from
|
// Config is the OIDC + session configuration. cmd/tapir maps these from
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
josev4 "github.com/go-jose/go-jose/v4"
|
josev4 "github.com/go-jose/go-jose/v4"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
|
"git.d-ma.be/mathias/tapir/internal/web/oidc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// randToken returns a URL-safe 256-bit random string for session IDs, OIDC
|
// randToken returns a URL-safe 256-bit random string for session IDs, OIDC
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeFetcher is a web.VideoFetcher returning a fixed video (or an error),
|
// fakeFetcher is a web.VideoFetcher returning a fixed video (or an error),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Processor runs the core summarization use case for a single already-discovered
|
// Processor runs the core summarization use case for a single already-discovered
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeProcessor records ProcessVideo calls. With block set it parks until the
|
// fakeProcessor records ProcessVideo calls. With block set it parks until the
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func renderVideoCard(t *testing.T, r store.SummaryRow) string {
|
func renderVideoCard(t *testing.T, r store.SummaryRow) string {
|
||||||
|
|||||||
+79
-31
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// youtubeIDRe matches a canonical 11-char YouTube video id (the provider's
|
// youtubeIDRe matches a canonical 11-char YouTube video id (the provider's
|
||||||
@@ -190,6 +190,18 @@ func chatURL(videoID string) templ.SafeURL {
|
|||||||
return templ.SafeURL("/v/" + videoID + "/chat")
|
return templ.SafeURL("/v/" + videoID + "/chat")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expandURL builds the inline-expand fragment path (GET) — the full summary + chat
|
||||||
|
// dock swapped into the list card in place (ADR-031).
|
||||||
|
func expandURL(videoID string) templ.SafeURL {
|
||||||
|
return templ.SafeURL("/v/" + videoID + "/expand")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cardURL builds the compact-card fragment path (GET) — the collapse target that
|
||||||
|
// returns an expanded card to its compact form (ADR-031).
|
||||||
|
func cardURL(videoID string) templ.SafeURL {
|
||||||
|
return templ.SafeURL("/v/" + videoID + "/card")
|
||||||
|
}
|
||||||
|
|
||||||
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
|
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
|
||||||
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
|
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
|
||||||
// so the inline span colours and the CSS track/fill share one source of truth.
|
// so the inline span colours and the CSS track/fill share one source of truth.
|
||||||
@@ -566,33 +578,71 @@ func (f Filter) apply(rows []store.SummaryRow) []store.SummaryRow {
|
|||||||
// not as template expressions — so the CSS is rendered as a raw node instead).
|
// not as template expressions — so the CSS is rendered as a raw node instead).
|
||||||
var styleTag = "<style>" + stylesheet + "</style>"
|
var styleTag = "<style>" + stylesheet + "</style>"
|
||||||
|
|
||||||
// The design system is a small set of CSS custom properties: one accent, a
|
// themeScript powers the light/dark toggle (ADR-032). The init runs in <head>
|
||||||
// 4/8px-derived spacing scale, a single radius, and full light+dark palettes so
|
// before paint: if the visitor has a stored choice it is applied as data-theme
|
||||||
// color-scheme: light dark is actually honoured (review #1). Muted is #595959
|
// immediately, so there is no flash of the wrong palette; with no stored choice
|
||||||
// (~7:1 on white) / #9aa0a8 on dark to clear WCAG AA (review #4).
|
// nothing is set and the CSS @media (prefers-color-scheme) default takes over.
|
||||||
|
// tapirToggleTheme flips to the opposite of the *effective* theme (reading
|
||||||
|
// matchMedia when no explicit choice is set yet) and persists it. localStorage
|
||||||
|
// access is guarded so a privacy-locked browser degrades to the OS default.
|
||||||
|
const themeScript = `
|
||||||
|
(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);}catch(e){}})();
|
||||||
|
function tapirToggleTheme(){var d=document.documentElement,c=d.getAttribute('data-theme');if(!c){c=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}var n=c==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('theme',n);}catch(e){}}
|
||||||
|
`
|
||||||
|
|
||||||
|
// themeScriptTag is the init/toggle script, injected verbatim in <head>.
|
||||||
|
var themeScriptTag = "<script>" + themeScript + "</script>"
|
||||||
|
|
||||||
|
// themeToggleButton is the header control that calls tapirToggleTheme. Shared by
|
||||||
|
// the authenticated and public layouts so the toggle is on every page.
|
||||||
|
const themeToggleButton = `<button type="button" class="theme-toggle" onclick="tapirToggleTheme()" aria-label="Toggle light or dark theme" title="Toggle light/dark">◐</button>`
|
||||||
|
|
||||||
|
// The design system is one charm-reader layout in two palettes (ADR-032, #17):
|
||||||
|
// a warm "reader" light theme (sketch B) and a "cozy terminal" dark theme
|
||||||
|
// (sketch C), both expressed as CSS custom properties. The palette is selected
|
||||||
|
// three ways, in cascade order: the :root light default; an OS-preference dark
|
||||||
|
// block that applies only when the visitor has made no explicit choice
|
||||||
|
// (:root:not([data-theme])); and an explicit :root[data-theme="dark"|"light"]
|
||||||
|
// set by the persisted toggle, which outranks the media query by specificity.
|
||||||
|
// charmDarkVars is declared once and injected in both dark selectors so the two
|
||||||
|
// never drift. Muted clears WCAG AA on each background (#5b5968 on cream,
|
||||||
|
// #a59cc0 on the dark card). Error/danger shades are tokens so they follow the
|
||||||
|
// theme too, instead of needing per-block dark overrides.
|
||||||
|
const charmDarkVars = `
|
||||||
|
--bg:#16131d; --card:#1e1a28; --fg:#ece7f5; --muted:#a59cc0; --line:#322b44;
|
||||||
|
--mint:#2ee6b6; --purple:#9d7bff; --pink:#ff6bdb; --cream:#f3ead8;
|
||||||
|
--accent:#9d7bff; --accent-fg:#16131d; --accent-weak:#2a2340;
|
||||||
|
--badge-bg:#caa24a; --badge-fg:#1a1300;
|
||||||
|
--err-bg:#3a1714; --err-fg:#f3b5ae; --err-line:#a6362e;`
|
||||||
|
|
||||||
const stylesheet = `
|
const stylesheet = `
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
--bg:#fbfbfa; --card:#ffffff; --fg:#1a1a1a; --muted:#595959; --line:#e4e4e1;
|
--bg:#faf7f2; --card:#ffffff; --fg:#1c1b22; --muted:#5b5968; --line:#e7e2d8;
|
||||||
--accent:#2b6cb0; --accent-fg:#ffffff; --accent-weak:#eaf1f8;
|
--mint:#0bbf8c; --purple:#6a4cf0; --pink:#e0379a; --cream:#1c1b22;
|
||||||
|
--accent:#6a4cf0; --accent-fg:#ffffff; --accent-weak:#efebfd;
|
||||||
--badge-bg:#e7a13a; --badge-fg:#2a1d00;
|
--badge-bg:#e7a13a; --badge-fg:#2a1d00;
|
||||||
|
--err-bg:#fce8e6; --err-fg:#8a1c10; --err-line:#d9534f;
|
||||||
--s1:.25rem; --s2:.5rem; --s3:1rem; --s4:1.5rem; --s5:2.5rem; --radius:.5rem;
|
--s1:.25rem; --s2:.5rem; --s3:1rem; --s4:1.5rem; --s5:2.5rem; --radius:.5rem;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not([data-theme]) {` + charmDarkVars + `
|
||||||
--bg:#15171b; --card:#1e2128; --fg:#e7e7e4; --muted:#9aa0a8; --line:#2d313a;
|
|
||||||
--accent:#76aae6; --accent-fg:#0c0f13; --accent-weak:#222b38;
|
|
||||||
--badge-bg:#caa24a; --badge-fg:#1a1300;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
:root[data-theme="dark"] {` + charmDarkVars + `
|
||||||
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { font: 15px/1.6 system-ui, -apple-system, sans-serif; margin: 0; color: var(--fg); background: var(--bg); }
|
body { font: 15px/1.6 system-ui, -apple-system, sans-serif; margin: 0; color: var(--fg); background: var(--bg); }
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover, a:focus-visible { text-decoration: underline; }
|
a:hover, a:focus-visible { text-decoration: underline; }
|
||||||
a:visited { color: var(--accent); }
|
a:visited { color: var(--accent); }
|
||||||
header { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); background: var(--card); display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
|
header { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); background: var(--card); display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
|
||||||
.brand { font-weight: 700; font-size: 1.05rem; color: var(--accent); }
|
.brand { font-weight: 800; font-size: 1.05rem; letter-spacing: -.01em; color: var(--accent); }
|
||||||
.nav { display: flex; gap: var(--s3); font-size: .9rem; }
|
.nav { display: flex; gap: var(--s3); align-items: center; font-size: .9rem; }
|
||||||
|
.theme-toggle { font: inherit; font-size: 1rem; line-height: 1; padding: .3rem .5rem; border: 1px solid var(--line); border-radius: 999px; background: var(--card); color: var(--muted); cursor: pointer; }
|
||||||
|
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
@@ -603,7 +653,7 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
|||||||
.filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
.filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
||||||
.filter-check { flex-direction: row !important; align-items: center; gap: var(--s2) !important; padding-bottom: .45rem; }
|
.filter-check { flex-direction: row !important; align-items: center; gap: var(--s2) !important; padding-bottom: .45rem; }
|
||||||
.filter-check input[type=checkbox] { width: 1rem; height: 1rem; min-width: 0; padding: 0; accent-color: var(--accent); cursor: pointer; }
|
.filter-check input[type=checkbox] { width: 1rem; height: 1rem; min-width: 0; padding: 0; accent-color: var(--accent); cursor: pointer; }
|
||||||
.btn { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent); color: var(--accent-fg); cursor: pointer; }
|
.btn { font: inherit; font-weight: 600; padding: .45rem 1.1rem; border: 1px solid var(--accent); border-radius: 999px; background: var(--accent); color: var(--accent-fg); cursor: pointer; }
|
||||||
/* anchors styled as buttons: the generic a{} / a:visited{} colour rules outrank
|
/* anchors styled as buttons: the generic a{} / a:visited{} colour rules outrank
|
||||||
.btn on <a>, painting the label accent-on-accent (invisible). Restore the
|
.btn on <a>, painting the label accent-on-accent (invisible). Restore the
|
||||||
button foreground for anchor buttons, visited included. */
|
button foreground for anchor buttons, visited included. */
|
||||||
@@ -615,14 +665,19 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
|||||||
.cards { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s3); }
|
.cards { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s3); }
|
||||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3) var(--s4); display: flex; flex-direction: column; gap: var(--s2); }
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3) var(--s4); display: flex; flex-direction: column; gap: var(--s2); }
|
||||||
.card-title { font-size: 1.1rem; font-weight: 600; line-height: 1.3; }
|
.card-title { font-size: 1.1rem; font-weight: 600; line-height: 1.3; }
|
||||||
.card-meta { color: var(--muted); font-size: .85rem; }
|
.card-meta { color: var(--muted); font-size: .8rem; font-family: var(--mono); }
|
||||||
.card-preview { color: var(--muted); font-size: .9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
.card-preview { color: var(--muted); font-size: .9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
.card-foot { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-top: var(--s1); }
|
.card-foot { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-top: var(--s1); }
|
||||||
|
/* Inline-expanded card (ADR-031/032): lifted, with a charm accent edge so the
|
||||||
|
open card reads as the focused one in the feed (sketch B/C). */
|
||||||
|
.card-expanded { border-color: var(--accent); border-left: 3px solid var(--mint); box-shadow: 0 6px 24px color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||||
|
.card-expanded-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s2); }
|
||||||
|
.card-collapse { font-size: .8rem; font-family: var(--mono); white-space: nowrap; }
|
||||||
.chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; }
|
.chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; }
|
||||||
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a
|
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a
|
||||||
status, not an action the user can take. */
|
status, not an action the user can take. */
|
||||||
.chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; }
|
.chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; }
|
||||||
.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 { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; margin-bottom: var(--s3); font-size: .78rem; font-family: var(--mono); color: var(--muted); }
|
||||||
.pipeline-bar span { display: flex; align-items: center; gap: var(--s1); }
|
.pipeline-bar span { display: flex; align-items: center; gap: var(--s1); }
|
||||||
.pipeline-bar span + span::before { content: "·"; margin-right: 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; }
|
.pipeline-note { margin: calc(-1 * var(--s2)) 0 var(--s3); font-size: .8rem; line-height: 1.5; max-width: 40rem; }
|
||||||
@@ -708,10 +763,7 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
|||||||
/* flash / notification banner */
|
/* flash / notification banner */
|
||||||
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
|
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
|
||||||
.flash-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
|
.flash-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
|
||||||
.flash-error { background: #fce8e6; color: #8a1c10; border-color: #d9534f; }
|
.flash-error { background: var(--err-bg); color: var(--err-fg); border-color: var(--err-line); }
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.flash-error { background: #3a1714; color: #f3b5ae; border-color: #a6362e; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* htmx loading feedback */
|
/* htmx loading feedback */
|
||||||
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
|
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
|
||||||
@@ -721,12 +773,13 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
|||||||
.detail { max-width: 38rem; }
|
.detail { max-width: 38rem; }
|
||||||
.detail .back { margin: 0 0 var(--s3); font-size: .85rem; }
|
.detail .back { margin: 0 0 var(--s3); font-size: .85rem; }
|
||||||
.detail h1 { font-size: 1.7rem; line-height: 1.25; margin: 0 0 var(--s2); }
|
.detail h1 { font-size: 1.7rem; line-height: 1.25; margin: 0 0 var(--s2); }
|
||||||
.detail .meta { color: var(--muted); font-size: .9rem; margin: 0 0 var(--s2); display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
|
.detail .meta { color: var(--muted); font-size: .82rem; font-family: var(--mono); margin: 0 0 var(--s2); display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
|
||||||
.detail .source { margin: 0 0 var(--s4); font-size: .9rem; }
|
.detail .source { margin: 0 0 var(--s4); font-size: .9rem; }
|
||||||
.detail .embed { margin: 0 0 var(--s4); aspect-ratio: 16 / 9; border-radius: var(--radius); overflow: hidden; background: #000; border: 1px solid var(--line); }
|
.detail .embed { margin: 0 0 var(--s4); aspect-ratio: 16 / 9; border-radius: var(--radius); overflow: hidden; background: #000; border: 1px solid var(--line); }
|
||||||
.detail .embed iframe { display: block; width: 100%; height: 100%; border: 0; }
|
.detail .embed iframe { display: block; width: 100%; height: 100%; border: 0; }
|
||||||
.detail section { margin-top: var(--s4); }
|
.detail section { margin-top: var(--s4); }
|
||||||
.detail section h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-top: 1px solid var(--line); padding-top: var(--s3); margin: 0 0 var(--s2); }
|
.detail section h2 { font-size: .72rem; text-transform: uppercase; letter-spacing: .12em; color: var(--accent); display: flex; align-items: center; gap: var(--s2); margin: var(--s4) 0 var(--s2); }
|
||||||
|
.detail section h2::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||||
.detail .body { white-space: pre-wrap; line-height: 1.7; margin: 0; }
|
.detail .body { white-space: pre-wrap; line-height: 1.7; margin: 0; }
|
||||||
.detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; }
|
.detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; }
|
||||||
.detail li { margin-bottom: var(--s1); }
|
.detail li { margin-bottom: var(--s1); }
|
||||||
@@ -744,8 +797,7 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
|||||||
.chat-a { background: var(--card); border: 1px solid var(--line); }
|
.chat-a { background: var(--card); border: 1px solid var(--line); }
|
||||||
.chat-a .body { white-space: pre-wrap; line-height: 1.6; }
|
.chat-a .body { white-space: pre-wrap; line-height: 1.6; }
|
||||||
.chat-note { margin: 0; font-size: .82rem; font-style: italic; }
|
.chat-note { margin: 0; font-size: .82rem; font-style: italic; }
|
||||||
.chat-error { margin: 0; color: #8a1c10; font-size: .9rem; }
|
.chat-error { margin: 0; color: var(--err-fg); font-size: .9rem; }
|
||||||
@media (prefers-color-scheme: dark) { .chat-error { color: #f3b5ae; } }
|
|
||||||
.chat-form { display: flex; flex-direction: column; gap: var(--s2); margin: var(--s2) 0 0; }
|
.chat-form { display: flex; flex-direction: column; gap: var(--s2); margin: var(--s2) 0 0; }
|
||||||
.chat-model { flex-direction: column; display: flex; gap: var(--s1); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); align-items: flex-start; }
|
.chat-model { flex-direction: column; display: flex; gap: var(--s1); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); align-items: flex-start; }
|
||||||
.chat-model select { font: inherit; text-transform: none; letter-spacing: 0; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); }
|
.chat-model select { font: inherit; text-transform: none; letter-spacing: 0; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); }
|
||||||
@@ -795,17 +847,13 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
|||||||
.danger-zone h2 { border-top-color: #d9534f; }
|
.danger-zone h2 { border-top-color: #d9534f; }
|
||||||
.confirm-delete > summary { display: inline-block; list-style: none; cursor: pointer; font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: transparent; color: #c0392b; }
|
.confirm-delete > summary { display: inline-block; list-style: none; cursor: pointer; font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: transparent; color: #c0392b; }
|
||||||
.confirm-delete > summary::-webkit-details-marker { display: none; }
|
.confirm-delete > summary::-webkit-details-marker { display: none; }
|
||||||
.confirm-delete > summary:hover { background: #fce8e6; }
|
.confirm-delete > summary:hover { background: var(--err-bg); }
|
||||||
.confirm-delete[open] > summary { margin-bottom: var(--s3); }
|
.confirm-delete[open] > summary { margin-bottom: var(--s3); }
|
||||||
.confirm-body { border: 1px solid #d9534f; border-radius: var(--radius); padding: var(--s3); background: #fce8e6; color: #8a1c10; }
|
.confirm-body { border: 1px solid #d9534f; border-radius: var(--radius); padding: var(--s3); background: var(--err-bg); color: var(--err-fg); }
|
||||||
.btn-danger { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: #d9534f; color: #fff; cursor: pointer; }
|
.btn-danger { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: #d9534f; color: #fff; cursor: pointer; }
|
||||||
.btn-danger:hover { filter: brightness(1.05); }
|
.btn-danger:hover { filter: brightness(1.05); }
|
||||||
.btn-danger:focus-visible { outline: 2px solid #d9534f; outline-offset: 1px; }
|
.btn-danger:focus-visible { outline: 2px solid #d9534f; outline-offset: 1px; }
|
||||||
@media (prefers-color-scheme: dark) {
|
.confirm-delete > summary { color: var(--err-fg); }
|
||||||
.confirm-body { background: #3a1714; color: #f3b5ae; }
|
|
||||||
.confirm-delete > summary { color: #f3b5ae; }
|
|
||||||
.confirm-delete > summary:hover { background: #3a1714; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* public landing page (/welcome) — the Charm-box mascot hero plus the sign-in CTA */
|
/* public landing page (/welcome) — the Charm-box mascot hero plus the sign-in CTA */
|
||||||
.welcome { text-align: center; padding: var(--s5) var(--s3); display: flex; flex-direction: column; align-items: center; gap: var(--s4); }
|
.welcome { text-align: center; padding: var(--s5) var(--s3); display: flex; flex-direction: column; align-items: center; gap: var(--s4); }
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ templ Layout(title string) {
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>{ title }</title>
|
<title>{ title }</title>
|
||||||
|
@templ.Raw(themeScriptTag)
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
@templ.Raw(styleTag)
|
@templ.Raw(styleTag)
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<a href="/" class="brand">Tapir</a>
|
<a href="/" class="brand">Tapir</a>
|
||||||
<nav class="nav"><a href="/account">Account</a><a href="/auth/logout">Log out</a></nav>
|
<nav class="nav"><a href="/account">Account</a><a href="/auth/logout">Log out</a>@templ.Raw(themeToggleButton)</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{ children... }
|
{ children... }
|
||||||
@@ -42,12 +43,14 @@ templ PublicLayout(title string) {
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>{ title }</title>
|
<title>{ title }</title>
|
||||||
|
@templ.Raw(themeScriptTag)
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
@templ.Raw(styleTag)
|
@templ.Raw(styleTag)
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<a href="/" class="brand">Tapir</a>
|
<a href="/" class="brand">Tapir</a>
|
||||||
|
<nav class="nav">@templ.Raw(themeToggleButton)</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{ children... }
|
{ children... }
|
||||||
@@ -264,7 +267,16 @@ templ summaryList(b listBuckets, hasConnected bool, autoSummarize bool) {
|
|||||||
templ VideoCard(r store.SummaryRow) {
|
templ VideoCard(r store.SummaryRow) {
|
||||||
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
||||||
if r.Summarized {
|
if r.Summarized {
|
||||||
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
|
// Expand the full summary + Q&A in place (ADR-031); href is the no-JS
|
||||||
|
// fallback to the detail page, so nothing becomes JS-only.
|
||||||
|
<div class="card-title">
|
||||||
|
<a
|
||||||
|
href={ videoURL(r.VideoID) }
|
||||||
|
hx-get={ string(expandURL(r.VideoID)) }
|
||||||
|
hx-target={ "#video-" + r.VideoID }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>{ displayTitle(r) }</a>
|
||||||
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="card-title">{ displayTitle(r) }</div>
|
<div class="card-title">{ displayTitle(r) }</div>
|
||||||
}
|
}
|
||||||
@@ -327,6 +339,32 @@ templ VideoCard(r store.SummaryRow) {
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expandedCard is a summarized list card opened IN PLACE (ADR-031): the full
|
||||||
|
// summary body + the deeper-dive chat dock, with a collapse control back to the
|
||||||
|
// compact card. It shares the <li id> with VideoCard so HTMX swaps it outerHTML,
|
||||||
|
// and reuses summaryBody + chatReveal so it never drifts from the detail page.
|
||||||
|
// Note: chatReveal uses a single #chat-section id, so this assumes one card open
|
||||||
|
// at a time; a per-video chat id is a follow-up if simultaneous expansion is wanted.
|
||||||
|
templ expandedCard(r store.SummaryRow, chatEnabled bool) {
|
||||||
|
<li class="card card-expanded" id={ "video-" + r.VideoID }>
|
||||||
|
<div class="card-expanded-head">
|
||||||
|
<span class="card-title">{ displayTitle(r) }</span>
|
||||||
|
<a
|
||||||
|
href={ videoURL(r.VideoID) }
|
||||||
|
hx-get={ string(cardURL(r.VideoID)) }
|
||||||
|
hx-target={ "#video-" + r.VideoID }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="card-collapse"
|
||||||
|
title="Collapse"
|
||||||
|
>collapse ↑</a>
|
||||||
|
</div>
|
||||||
|
@summaryBody(r)
|
||||||
|
if chatEnabled {
|
||||||
|
@chatReveal(r.VideoID)
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
// TapirSpinner is the summarizing animation: a Charmbracelet-style TUI panel —
|
// TapirSpinner is the summarizing animation: a Charmbracelet-style TUI panel —
|
||||||
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
|
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
|
||||||
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
|
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
|
||||||
|
|||||||
+798
-626
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// page renders any GET path's full HTML (helper for theme/structure assertions).
|
||||||
|
func page(t *testing.T, app interface {
|
||||||
|
Router() http.Handler
|
||||||
|
}, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestThemeHasLightAndDarkPalettes: one layout, two palettes via CSS variables —
|
||||||
|
// a root light palette and a data-theme="dark" dark palette (ADR-032).
|
||||||
|
func TestThemeHasLightAndDarkPalettes(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
html := page(t, app, "/welcome") // public, no DB needed
|
||||||
|
require.Contains(t, html, ":root", "light palette on the root")
|
||||||
|
require.Contains(t, html, `[data-theme="dark"]`, "explicit dark palette override for the toggle")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestThemeFollowsOSPreference: with no stored choice, the OS preference applies
|
||||||
|
// the dark palette automatically.
|
||||||
|
func TestThemeFollowsOSPreference(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
html := page(t, app, "/welcome")
|
||||||
|
require.Contains(t, html, "prefers-color-scheme: dark", "OS-preference dark default")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestThemeTogglePersists: every page carries a theme toggle control and a small
|
||||||
|
// script that flips data-theme and persists the choice.
|
||||||
|
func TestThemeTogglePersists(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
html := page(t, app, "/welcome")
|
||||||
|
require.Contains(t, html, "theme-toggle", "a theme toggle control")
|
||||||
|
require.Contains(t, html, "localStorage", "the choice is persisted")
|
||||||
|
require.Contains(t, html, "data-theme", "the toggle flips data-theme")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExpandedCardEmbedsVideo: expanding a summarized card with a valid provider id
|
||||||
|
// includes the embedded video player (ADR-031/032).
|
||||||
|
func TestExpandedCardEmbedsVideo(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
require.NoError(t, deliver(ctx, app, videoX, "summary text"))
|
||||||
|
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/expand", nil)))
|
||||||
|
require.Contains(t, html, "<iframe", "expanded card embeds a player")
|
||||||
|
require.True(t, strings.Contains(html, "youtube.com/embed") || strings.Contains(html, "youtube-nocookie.com/embed"),
|
||||||
|
"the embed is the YouTube player")
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeAuth is a configurable web.Auth for the landing-page tests: it reports a
|
// fakeAuth is a configurable web.Auth for the landing-page tests: it reports a
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ import (
|
|||||||
// fails if a scenario is unmapped, a mapped test is missing, or an entry no
|
// fails if a scenario is unmapped, a mapped test is missing, or an entry no
|
||||||
// longer matches a real non-pending scenario.
|
// longer matches a real non-pending scenario.
|
||||||
var scenarioCoverage = map[string]string{
|
var scenarioCoverage = map[string]string{
|
||||||
|
// inline_expand.feature (ADR-031, #16)
|
||||||
|
"A summarized card expands to the full summary in place": "TestExpandReturnsSummaryBodyFragment",
|
||||||
|
"An expanded card collapses back to the compact card": "TestCollapseReturnsCompactCard",
|
||||||
|
"The expanded card offers the Q&A dock": "TestExpandedCardOffersChatDock",
|
||||||
|
"Only a summarized card offers expand": "TestCompactCardExpandOnlyWhenSummarized",
|
||||||
|
"With JS off the card still reaches the full summary": "TestCompactCardHasNoJSDetailFallback",
|
||||||
|
"The detail page and the expanded card show the same summary": "TestDetailAndExpandShareSummaryBody",
|
||||||
|
|
||||||
|
// visual_theme.feature (ADR-032, #17)
|
||||||
|
"Light and dark themes share one layout via CSS variables": "TestThemeHasLightAndDarkPalettes",
|
||||||
|
"Without a stored choice the theme follows the OS preference": "TestThemeFollowsOSPreference",
|
||||||
|
"A persisted toggle switches light and dark": "TestThemeTogglePersists",
|
||||||
|
"The expanded card embeds the video player": "TestExpandedCardEmbedsVideo",
|
||||||
|
|
||||||
// observability.feature (ADR-030, #15)
|
// observability.feature (ADR-030, #15)
|
||||||
"Summarization latency is recorded per endpoint": "TestSummarizerRecordsMetric",
|
"Summarization latency is recorded per endpoint": "TestSummarizerRecordsMetric",
|
||||||
"A failing summarizer endpoint records its failure outcome": "TestObserveSummarizeRecordsFailureOutcomes",
|
"A failing summarizer endpoint records its failure outcome": "TestObserveSummarizeRecordsFailureOutcomes",
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"git.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- fake adapters ---------------------------------------------------------
|
// --- fake adapters ---------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user