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
|
## 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
|
||||||
|
|||||||
@@ -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,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 := 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
|
||||||
|
|||||||
@@ -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")
|
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.
|
||||||
@@ -618,6 +630,10 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
|||||||
.card-meta { color: var(--muted); font-size: .85rem; }
|
.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-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). 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; }
|
.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. */
|
||||||
|
|||||||
@@ -264,7 +264,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 +336,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
|
||||||
|
|||||||
+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
|
// 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",
|
||||||
|
|
||||||
// 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",
|
||||||
|
|||||||
Reference in New Issue
Block a user