Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f98b640531 | ||
|
|
607a8cbe8d | ||
|
|
54d60e53b9 | ||
|
|
9298e0c686 |
@@ -1219,6 +1219,54 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
- `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
|
||||
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`
|
||||
|
||||
@@ -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)
|
||||
@@ -151,6 +151,8 @@ func (a *App) Router() http.Handler {
|
||||
app := http.NewServeMux()
|
||||
app.HandleFunc("GET /{$}", a.handleList)
|
||||
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}/summarize", a.handleRequestSummarize)
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
// is set (the store enforces watched↔skipped exclusion atomically). It returns
|
||||
// the refreshed button-group fragment for HTMX; without JS it redirects back to
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,18 @@ func chatURL(videoID string) templ.SafeURL {
|
||||
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) —
|
||||
// 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.
|
||||
@@ -618,6 +630,10 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
||||
.card-meta { color: var(--muted); font-size: .85rem; }
|
||||
.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); }
|
||||
/* Inline-expanded card (ADR-031). Minimal layout only — the TUI/charm restyle is #17. */
|
||||
.card-expanded { border-color: var(--accent, #7653fc); }
|
||||
.card-expanded-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s2); }
|
||||
.card-collapse { font-size: .85rem; 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; }
|
||||
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a
|
||||
status, not an action the user can take. */
|
||||
|
||||
@@ -264,7 +264,16 @@ templ summaryList(b listBuckets, hasConnected bool, autoSummarize bool) {
|
||||
templ VideoCard(r store.SummaryRow) {
|
||||
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
||||
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 {
|
||||
<div class="card-title">{ displayTitle(r) }</div>
|
||||
}
|
||||
@@ -327,6 +336,32 @@ templ VideoCard(r store.SummaryRow) {
|
||||
</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 —
|
||||
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
|
||||
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
|
||||
|
||||
+761
-621
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,14 @@ import (
|
||||
// fails if a scenario is unmapped, a mapped test is missing, or an entry no
|
||||
// longer matches a real non-pending scenario.
|
||||
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",
|
||||
|
||||
// observability.feature (ADR-030, #15)
|
||||
"Summarization latency is recorded per endpoint": "TestSummarizerRecordsMetric",
|
||||
"A failing summarizer endpoint records its failure outcome": "TestObserveSummarizeRecordsFailureOutcomes",
|
||||
|
||||
Reference in New Issue
Block a user