feat(web): inline-expand summary + Q&A in the list (ADR-031, #16)
Click a summarized card → its full summary (summaryBody) + chat dock (chatReveal)
expand in place via HTMX (GET /v/{id}/expand → expandedCard), collapse back via
GET /v/{id}/card → compact VideoCard. Same <li id>, outerHTML swap — the existing
list-fragment pattern. The card title carries href=/v/{id} as the no-JS fallback
(detail page stays for no-JS + deep links); only summarized cards expand. Reuses
summaryBody + chatReveal so the expanded card never drifts from the detail page.
BDD: inline_expand.feature un-pended + mapped. TDD: 6 handler/fragment tests
(expand/collapse fragments, chat dock, summarized-only, no-JS href, shared body).
Minimal CSS only — the TUI/charm restyle is #17.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,39 +4,33 @@ Feature: Inline-expand summary + Q&A in the list (ADR-031, #16)
|
||||
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; tagged @pending until the TDD step lands it.
|
||||
# in scenario_coverage_test.go (the BDD name-coverage gate).
|
||||
|
||||
@pending # TestExpandReturnsSummaryBodyFragment
|
||||
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
|
||||
|
||||
@pending # TestCollapseReturnsCompactCard
|
||||
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
|
||||
|
||||
@pending # TestExpandedCardOffersChatDock
|
||||
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
|
||||
|
||||
@pending # TestCompactCardExpandOnlyWhenSummarized
|
||||
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
|
||||
|
||||
@pending # TestCompactCardHasNoJSDetailFallback
|
||||
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
|
||||
|
||||
@pending # TestDetailAndExpandShareSummaryBody
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+654
-514
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