A deeper-dive chat entered from the summary view: ask questions about a video against its already-stored transcript (ADR-021), no caption fetch, ever. Safety by construction — the load-bearing property. The chat handlers reach the chat.Service only after reading the SHARED stored transcript via Store.GetTranscript (a pure DB read); the service holds no VideoSource. So an enabled chat cannot trigger a caption fetch, touch the rate gate, or reach YouTube. A video with no stored transcript gets an honest "not available" — no fetch, no model call. The key web test wires the summarize/fetch collaborators as tripwires that fail the test if chat ever routes into them, and asserts the model answered from the stored text. Model defaults to the summary's own model and is switchable among the ADR-022 chain (phi4-mini → gemma4-26b → mistral-small); switching re-runs against the same transcript — deliberate model-comparison instrumentation. The cloud model is absent from the switcher when TAPIR_CLOUD_FALLBACK_MODEL="" (the local-first / NDA lever), honoured the same way the summarizer honours it. Reuses the existing LiteLLM gateway client (a chat is a different call, not a new integration) and the TAPIR_MAX_TRANSCRIPT_CHARS truncation, surfacing an honest bounded-context note when a long transcript is cut. Ephemeral v1: the multi-turn conversation rides in hidden request fields; no table, no migration, nothing persisted. Entry is RLS-scoped through GetSummaryByVideo, so chat is reachable only from the user's own summary view. Show-source verification and on-demand fetch are deferred (ADR-027). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
826 lines
38 KiB
Go
826 lines
38 KiB
Go
package web
|
||
|
||
import (
|
||
"regexp"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/a-h/templ"
|
||
|
||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||
)
|
||
|
||
// youtubeIDRe matches a canonical 11-char YouTube video id (the provider's
|
||
// base64url alphabet). Anything else is rejected so we never emit a broken
|
||
// embed src.
|
||
var youtubeIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)
|
||
|
||
// embedURL builds a privacy-friendly nocookie embed URL for a YouTube video id.
|
||
// It returns ("", false) for any id that isn't a valid 11-char YouTube id, so
|
||
// the caller can omit the embed instead of rendering a broken iframe.
|
||
func embedURL(providerVideoID string) (string, bool) {
|
||
if !youtubeIDRe.MatchString(providerVideoID) {
|
||
return "", false
|
||
}
|
||
return "https://www.youtube-nocookie.com/embed/" + providerVideoID, true
|
||
}
|
||
|
||
// actionVerbs is the fixed, ordered set of action toggles rendered in the button
|
||
// group. It mirrors the store's allowed actions (store/actions.go); order here is
|
||
// the display order, not the store's.
|
||
var actionVerbs = []string{"watched", "skipped", "saved"}
|
||
|
||
// actionLabels maps each verb to its button caption.
|
||
var actionLabels = map[string]string{
|
||
"watched": "Watched",
|
||
"skipped": "Skipped",
|
||
"saved": "Saved",
|
||
}
|
||
|
||
func actionLabel(v string) string {
|
||
if l, ok := actionLabels[v]; ok {
|
||
return l
|
||
}
|
||
return v
|
||
}
|
||
|
||
// isActionVerb reports whether v is a known action verb. The handler rejects
|
||
// anything else before touching the store (defence in depth — the store also
|
||
// validates).
|
||
func isActionVerb(v string) bool {
|
||
for _, a := range actionVerbs {
|
||
if a == v {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// actionSet turns the store's active-action slice into a set for the template's
|
||
// O(1) "is this verb active?" lookups.
|
||
func actionSet(xs []string) map[string]bool {
|
||
m := make(map[string]bool, len(xs))
|
||
for _, x := range xs {
|
||
m[x] = true
|
||
}
|
||
return m
|
||
}
|
||
|
||
func ariaPressed(b bool) string {
|
||
if b {
|
||
return "true"
|
||
}
|
||
return "false"
|
||
}
|
||
|
||
// displayTitle falls back to the video id when no videos row supplied a title
|
||
// (the LEFT JOIN can yield an empty Title — see store.SummaryRow).
|
||
func displayTitle(r store.SummaryRow) string {
|
||
if r.Title != "" {
|
||
return r.Title
|
||
}
|
||
return r.VideoID
|
||
}
|
||
|
||
// cardMeta is the muted "channel · date" line on a list card. Empty parts are
|
||
// omitted so a row with no joined videos row renders no stray separators.
|
||
func cardMeta(r store.SummaryRow) string {
|
||
var parts []string
|
||
if r.Channel != "" {
|
||
parts = append(parts, r.Channel)
|
||
}
|
||
if !r.PublishedAt.IsZero() {
|
||
parts = append(parts, r.PublishedAt.Format("2006-01-02"))
|
||
}
|
||
return strings.Join(parts, " · ")
|
||
}
|
||
|
||
// detailMeta is the single-line meta join under the detail title. It builds a
|
||
// slice of the present parts (channel, date, provider[+model]) and joins with
|
||
// " · ", so an absent videos row never produces a dangling "· — ·" (review #5).
|
||
// The fallback badge is rendered separately, not part of this string.
|
||
func detailMeta(r store.SummaryRow) string {
|
||
var parts []string
|
||
if r.Channel != "" {
|
||
parts = append(parts, r.Channel)
|
||
}
|
||
if !r.PublishedAt.IsZero() {
|
||
parts = append(parts, r.PublishedAt.Format("2006-01-02"))
|
||
}
|
||
if prov := r.AIProvider; prov != "" {
|
||
if r.AIModel != "" {
|
||
prov += " (" + r.AIModel + ")"
|
||
}
|
||
parts = append(parts, prov)
|
||
}
|
||
return strings.Join(parts, " · ")
|
||
}
|
||
|
||
// previewText renders a one-line lede for a summary card: it collapses internal
|
||
// whitespace, then returns the first sentence when one ends within max runes,
|
||
// otherwise truncates at max runes on a word boundary (never mid-word) and
|
||
// appends an ellipsis. Empty/short input is returned unchanged (no ellipsis).
|
||
// Pure and multibyte-safe — all length work is on runes, not bytes.
|
||
func previewText(s string, max int) string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
runes := []rune(s)
|
||
|
||
// Prefer the first sentence when it terminates within the budget.
|
||
if end := firstSentenceEnd(runes); end > 0 && end <= max {
|
||
return string(runes[:end])
|
||
}
|
||
if len(runes) <= max {
|
||
return s
|
||
}
|
||
|
||
// Truncate at max runes, then back off to the last word boundary so no
|
||
// partial word is emitted. Space is single-byte, so the byte-index slice
|
||
// lands cleanly on a rune boundary.
|
||
cut := string(runes[:max])
|
||
if i := strings.LastIndexByte(cut, ' '); i > 0 {
|
||
cut = cut[:i]
|
||
}
|
||
return strings.TrimRight(cut, " ") + "…"
|
||
}
|
||
|
||
// firstSentenceEnd returns the rune index just past the first sentence
|
||
// terminator (. ! ?) that is followed by whitespace or the end of input, or 0
|
||
// when there is none.
|
||
func firstSentenceEnd(runes []rune) int {
|
||
for i, r := range runes {
|
||
if r == '.' || r == '!' || r == '?' {
|
||
if i+1 == len(runes) || runes[i+1] == ' ' {
|
||
return i + 1
|
||
}
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// videoURL builds the internal detail-page path for a video id.
|
||
func videoURL(videoID string) templ.SafeURL {
|
||
return templ.SafeURL("/v/" + videoID)
|
||
}
|
||
|
||
// actionURL builds the action POST path for a video id.
|
||
func actionURL(videoID string) templ.SafeURL {
|
||
return templ.SafeURL("/v/" + videoID + "/action")
|
||
}
|
||
|
||
// summarizeURL builds the manual-queue POST path for a video id.
|
||
func summarizeURL(videoID string) templ.SafeURL {
|
||
return templ.SafeURL("/v/" + videoID + "/summarize")
|
||
}
|
||
|
||
// statusURL builds the processing-status poll path (GET) for a video id — the
|
||
// HTMX poll target while an immediate summarization is in flight.
|
||
func statusURL(videoID string) templ.SafeURL {
|
||
return templ.SafeURL("/v/" + videoID + "/status")
|
||
}
|
||
|
||
// chatURL builds the per-video chat path (GET renders the page, POST answers) —
|
||
// the deeper-dive over the stored transcript (ADR-027).
|
||
func chatURL(videoID string) templ.SafeURL {
|
||
return templ.SafeURL("/v/" + videoID + "/chat")
|
||
}
|
||
|
||
// 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.
|
||
const (
|
||
CharmPurple = "#7653FC" // box border
|
||
CharmPink = "#FF6E9C" // tapir body
|
||
CharmMint = "#0EF9B6" // snout, eyes, progress fill
|
||
CharmCream = "#FFFDF5" // bright text
|
||
CharmDim = "#6C6C6C" // dim text
|
||
charmTrack = "#2D2D2D" // empty progress track (internal: dark char colour)
|
||
)
|
||
|
||
// tapirInteriorW is the fixed inner width of the Charm box, in monospace cells.
|
||
const tapirInteriorW = 34
|
||
|
||
// tapirBarFill is the mint progress fill (27 cells), revealed left→right by the
|
||
// CSS width/clip animation over the dim track drawn in each frame.
|
||
const tapirBarFill = "███████████████████████████"
|
||
|
||
// tapirRun is one coloured (or uncoloured) text segment of a box row.
|
||
type tapirRun struct {
|
||
s string
|
||
color string // "" = no span (plain text)
|
||
}
|
||
|
||
func tapirSpan(color, s string) string {
|
||
if color == "" {
|
||
return s
|
||
}
|
||
return `<span style="color:` + color + `">` + s + `</span>`
|
||
}
|
||
|
||
// tapirLine renders one interior box row: concatenate the coloured runs, pad with
|
||
// spaces to the fixed interior width, then flank with the purple side borders.
|
||
// Padding is computed from the runs' rune counts, so every row's right border
|
||
// lines up no matter how many runs it has (assuming 1-cell monospace glyphs).
|
||
func tapirLine(runs ...tapirRun) string {
|
||
var b strings.Builder
|
||
width := 0
|
||
for _, r := range runs {
|
||
b.WriteString(tapirSpan(r.color, r.s))
|
||
width += utf8.RuneCountInString(r.s)
|
||
}
|
||
if width < tapirInteriorW {
|
||
b.WriteString(strings.Repeat(" ", tapirInteriorW-width))
|
||
}
|
||
bar := tapirSpan(CharmPurple, "│")
|
||
return bar + b.String() + bar
|
||
}
|
||
|
||
// tapirFrameHTML builds one animation frame: a rounded Charm box around a colored
|
||
// ASCII tapir, a dim progress track, and labels. snout is the wiggling nose glyph
|
||
// that differs between the three frames. Returned as raw HTML (coloured spans),
|
||
// emitted verbatim by the template via templ.Raw.
|
||
func tapirFrameHTML(snout string) string {
|
||
top := tapirSpan(CharmPurple, "╭"+strings.Repeat("─", tapirInteriorW)+"╮")
|
||
bottom := tapirSpan(CharmPurple, "╰"+strings.Repeat("─", tapirInteriorW)+"╯")
|
||
lines := []string{
|
||
top,
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "◆", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "tapir", color: CharmCream}),
|
||
tapirLine(),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄▄▄▄▄", color: CharmPink}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄█▓▓▓▓█▄", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: snout, color: CharmMint}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "█▓(", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "◕ ◕", color: CharmMint}, tapirRun{s: ")▓█", color: CharmPink}, tapirRun{s: "──┘", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "< thinking...", color: CharmDim}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀█▓▓▓▓█▀", color: CharmPink}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "██▄▄██", color: CharmPink}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}),
|
||
tapirLine(),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "[", color: CharmDim}, tapirRun{s: strings.Repeat("░", 27), color: charmTrack}, tapirRun{s: "]", color: CharmDim}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "summarizing", color: CharmDim}),
|
||
bottom,
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// The three frames differ only in the snout glyph (∩ → ∪ → ~), cross-faded by CSS
|
||
// to read as a tapir wiggling its nose while it thinks.
|
||
var (
|
||
tapirFrameHTML1 = tapirFrameHTML("∩")
|
||
tapirFrameHTML2 = tapirFrameHTML("∪")
|
||
tapirFrameHTML3 = tapirFrameHTML("~")
|
||
)
|
||
|
||
// welcomeHeroHTML is the static Charm-box tapir mascot on the public landing
|
||
// page — the same rounded purple box / pink tapir / mint accents as the spinner,
|
||
// but a single still frame with a friendly tagline instead of the animation.
|
||
// Built from the shared tapirLine helpers so the aesthetic stays in one place.
|
||
func welcomeHeroHTML() string {
|
||
top := tapirSpan(CharmPurple, "╭"+strings.Repeat("─", tapirInteriorW)+"╮")
|
||
bottom := tapirSpan(CharmPurple, "╰"+strings.Repeat("─", tapirInteriorW)+"╯")
|
||
lines := []string{
|
||
top,
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "◆", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "tapir", color: CharmCream}),
|
||
tapirLine(),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄▄▄▄▄", color: CharmPink}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄█▓▓▓▓█▄", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "∩", color: CharmMint}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "█▓(", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "◕ ◕", color: CharmMint}, tapirRun{s: ")▓█", color: CharmPink}, tapirRun{s: "──┘", color: CharmMint}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀█▓▓▓▓█▀", color: CharmPink}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "██▄▄██", color: CharmPink}),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}),
|
||
tapirLine(),
|
||
tapirLine(tapirRun{s: " "}, tapirRun{s: "watch less, know more", color: CharmMint}),
|
||
bottom,
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
var welcomeHero = welcomeHeroHTML()
|
||
|
||
// summarizeModeLabel names the current mode for display.
|
||
func summarizeModeLabel(auto bool) string {
|
||
if auto {
|
||
return "Automatic"
|
||
}
|
||
return "Manual"
|
||
}
|
||
|
||
// summarizeModeToggleLabel is the caption on the toggle button — it names the mode
|
||
// the click switches TO (the opposite of the current one).
|
||
func summarizeModeToggleLabel(auto bool) string {
|
||
if auto {
|
||
return "Switch to manual"
|
||
}
|
||
return "Switch to automatic"
|
||
}
|
||
|
||
// boolStr renders a bool as the "enabled" form value the toggle submits.
|
||
func boolStr(b bool) string {
|
||
if b {
|
||
return "true"
|
||
}
|
||
return "false"
|
||
}
|
||
|
||
// externalURL passes a stored source URL through templ's URL sanitiser.
|
||
func externalURL(u string) templ.SafeURL {
|
||
return templ.URL(u)
|
||
}
|
||
|
||
// flashView is the rendered form of a flash code: a severity (drives the banner
|
||
// colour) and the human message. Keeping the text here — not in the cookie —
|
||
// means the cookie only ever carries an opaque, validated code.
|
||
type flashView struct {
|
||
Kind string // "success" | "error"
|
||
Message string
|
||
}
|
||
|
||
// flashMessages maps each flash code to its banner. An unknown code renders no
|
||
// banner (flashFor returns ok=false), so a forged cookie value is inert.
|
||
var flashMessages = map[string]flashView{
|
||
flashConnected: {"success", "YouTube account connected — finding your subscriptions. Your newest videos will appear below as they're summarized."},
|
||
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
|
||
flashDisconnected: {"success", "Account disconnected."},
|
||
flashDeleted: {"success", "Your account and all its data were deleted."},
|
||
flashRegistered: {"success", "Welcome to Tapir — your account is ready."},
|
||
}
|
||
|
||
func flashFor(code string) (flashView, bool) {
|
||
f, ok := flashMessages[code]
|
||
return f, ok
|
||
}
|
||
|
||
// providerLabels maps a provider key to its display name for the account page.
|
||
var providerLabels = map[string]string{
|
||
"youtube": "YouTube",
|
||
"vimeo": "Vimeo",
|
||
}
|
||
|
||
func providerLabel(p string) string {
|
||
if l, ok := providerLabels[p]; ok {
|
||
return l
|
||
}
|
||
return p
|
||
}
|
||
|
||
// displayNameOr falls back to a placeholder when the user has no display name set.
|
||
func displayNameOr(name string) string {
|
||
if name == "" {
|
||
return "(not set)"
|
||
}
|
||
return name
|
||
}
|
||
|
||
// hasYouTube reports whether the user already has a YouTube connection, so the
|
||
// account page hides the Connect link when one exists.
|
||
func hasYouTube(conns []store.Connection) bool {
|
||
for _, c := range conns {
|
||
if c.Provider == "youtube" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// disconnectURL builds the disconnect POST path for a provider.
|
||
func disconnectURL(provider string) templ.SafeURL {
|
||
return templ.SafeURL("/account/disconnect/" + provider)
|
||
}
|
||
|
||
// PipelineStats summarises the user's video backlog so the list page can show
|
||
// a one-line status bar ("2 summaries · 256 fetching soon · 12 no captions").
|
||
type PipelineStats struct {
|
||
Summarized int
|
||
RateLimited int // in the backoff window, will be retried
|
||
NoText int // no caption track available
|
||
Pending int // discovered but not yet attempted
|
||
}
|
||
|
||
// pipelineStats computes a PipelineStats from all (unfiltered) rows.
|
||
func pipelineStats(rows []store.SummaryRow) PipelineStats {
|
||
var s PipelineStats
|
||
for _, r := range rows {
|
||
switch {
|
||
case r.Summarized:
|
||
s.Summarized++
|
||
case r.TranscriptStatus == "rate_limited":
|
||
s.RateLimited++
|
||
case r.TranscriptStatus == "none":
|
||
s.NoText++
|
||
default:
|
||
s.Pending++
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
// retryNowURL builds the POST path for manual retry of a rate-limited video.
|
||
func retryNowURL(videoID string) templ.SafeURL {
|
||
return templ.SafeURL("/v/" + videoID + "/retry-now")
|
||
}
|
||
|
||
// listBuckets splits the (already filtered) video list into what the list view
|
||
// shows where, so the readable summaries are not buried under the un-summarized
|
||
// back-catalogue (UX review B3/B4). It is one feed with a noise-collapse, not
|
||
// separate sections:
|
||
// - Main: summarized videos + recent un-summarized ones — shown inline as cards.
|
||
// - Older: un-summarized videos published before the recency cutoff — collapsed
|
||
// behind a single "Show N older videos" disclosure (they will not auto-fill;
|
||
// they are summarize-on-demand).
|
||
// - NoCaption: count of un-summarized videos with no caption track — collapsed
|
||
// to one honest line instead of N dead terminal cards.
|
||
type listBuckets struct {
|
||
Main []store.SummaryRow
|
||
Older []store.SummaryRow
|
||
NoCaption int
|
||
}
|
||
|
||
// bucketRows classifies rows into the list buckets given a recency cutoff. A zero
|
||
// cutoff (recency collapse disabled) leaves Older empty — every un-summarized,
|
||
// captioned video stays inline. Order within each bucket is preserved.
|
||
func bucketRows(rows []store.SummaryRow, cutoff time.Time) listBuckets {
|
||
var b listBuckets
|
||
for _, r := range rows {
|
||
switch {
|
||
case r.Summarized:
|
||
b.Main = append(b.Main, r)
|
||
case r.TranscriptStatus == "none":
|
||
b.NoCaption++
|
||
case isOlder(r, cutoff):
|
||
b.Older = append(b.Older, r)
|
||
default:
|
||
b.Main = append(b.Main, r)
|
||
}
|
||
}
|
||
return b
|
||
}
|
||
|
||
// isOlder reports whether an un-summarized row falls before the recency cutoff.
|
||
// A zero cutoff (window disabled) or an undated row is never "older" — it cannot
|
||
// be aged out, so it stays inline rather than being hidden in the disclosure.
|
||
func isOlder(r store.SummaryRow, cutoff time.Time) bool {
|
||
if cutoff.IsZero() || r.PublishedAt.IsZero() {
|
||
return false
|
||
}
|
||
return r.PublishedAt.Before(cutoff)
|
||
}
|
||
|
||
// empty reports whether there is nothing to show at all (drives the empty state).
|
||
func (b listBuckets) empty() bool {
|
||
return len(b.Main) == 0 && len(b.Older) == 0 && b.NoCaption == 0
|
||
}
|
||
|
||
// Filter holds the list-view query parameters. Empty fields mean "no constraint".
|
||
// Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
|
||
// input verbatim; parsing happens in matchFilter.
|
||
type Filter struct {
|
||
Channels []string // selected channel titles; empty = all channels
|
||
From string
|
||
To string
|
||
OnlySummarized bool // show only videos that have a summary
|
||
}
|
||
|
||
// active reports whether any filter constraint is set. Drives whether the filter
|
||
// bar is shown at all: on a genuinely empty account (no rows AND no active
|
||
// filter) the bar is hidden so the connect CTA stands alone (UX review C1); a
|
||
// filter that happens to match nothing still shows the bar so it can be cleared.
|
||
func (f Filter) active() bool {
|
||
return len(f.Channels) > 0 || f.From != "" || f.To != "" || f.OnlySummarized
|
||
}
|
||
|
||
// HasChannel reports whether a channel is currently selected (drives the
|
||
// multi-select's selected state in the view).
|
||
func (f Filter) HasChannel(c string) bool {
|
||
return slices.Contains(f.Channels, c)
|
||
}
|
||
|
||
// nonEmptyStrings drops blank entries. A channel multi-select submits real
|
||
// channel titles; this guards against a stray empty value reaching the filter.
|
||
func nonEmptyStrings(ss []string) []string {
|
||
out := ss[:0:0]
|
||
for _, s := range ss {
|
||
if strings.TrimSpace(s) != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// matches reports whether a row satisfies the filter. Channel is an exact match;
|
||
// From/To bound PublishedAt inclusively. Unparseable or empty bounds are ignored
|
||
// (no constraint) — Stage-0 filtering is in-memory over the listed rows, not a
|
||
// store query.
|
||
func (f Filter) matches(r store.SummaryRow) bool {
|
||
if f.OnlySummarized && !r.Summarized {
|
||
return false
|
||
}
|
||
if len(f.Channels) > 0 && !slices.Contains(f.Channels, r.ChannelTitle) {
|
||
return false
|
||
}
|
||
if from, ok := parseDate(f.From); ok {
|
||
if r.PublishedAt.IsZero() || r.PublishedAt.Before(from) {
|
||
return false
|
||
}
|
||
}
|
||
if to, ok := parseDate(f.To); ok {
|
||
// inclusive of the whole "to" day
|
||
if r.PublishedAt.IsZero() || r.PublishedAt.After(to.Add(24*time.Hour-time.Nanosecond)) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func parseDate(s string) (time.Time, bool) {
|
||
if s == "" {
|
||
return time.Time{}, false
|
||
}
|
||
t, err := time.Parse("2006-01-02", s)
|
||
if err != nil {
|
||
return time.Time{}, false
|
||
}
|
||
return t, true
|
||
}
|
||
|
||
// apply returns the subset of rows matching the filter, preserving order.
|
||
func (f Filter) apply(rows []store.SummaryRow) []store.SummaryRow {
|
||
if len(f.Channels) == 0 && f.From == "" && f.To == "" && !f.OnlySummarized {
|
||
return rows
|
||
}
|
||
out := rows[:0:0]
|
||
for _, r := range rows {
|
||
if f.matches(r) {
|
||
out = append(out, r)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// styleTag wraps the stylesheet in a <style> element, injected verbatim via
|
||
// templ.Raw (templ treats the body of a literal <style> element as opaque text,
|
||
// not as template expressions — so the CSS is rendered as a raw node instead).
|
||
var styleTag = "<style>" + stylesheet + "</style>"
|
||
|
||
// The design system is a small set of CSS custom properties: one accent, a
|
||
// 4/8px-derived spacing scale, a single radius, and full light+dark palettes so
|
||
// color-scheme: light dark is actually honoured (review #1). Muted is #595959
|
||
// (~7:1 on white) / #9aa0a8 on dark to clear WCAG AA (review #4).
|
||
const stylesheet = `
|
||
:root {
|
||
color-scheme: light dark;
|
||
--bg:#fbfbfa; --card:#ffffff; --fg:#1a1a1a; --muted:#595959; --line:#e4e4e1;
|
||
--accent:#2b6cb0; --accent-fg:#ffffff; --accent-weak:#eaf1f8;
|
||
--badge-bg:#e7a13a; --badge-fg:#2a1d00;
|
||
--s1:.25rem; --s2:.5rem; --s3:1rem; --s4:1.5rem; --s5:2.5rem; --radius:.5rem;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg:#15171b; --card:#1e2128; --fg:#e7e7e4; --muted:#9aa0a8; --line:#2d313a;
|
||
--accent:#76aae6; --accent-fg:#0c0f13; --accent-weak:#222b38;
|
||
--badge-bg:#caa24a; --badge-fg:#1a1300;
|
||
}
|
||
}
|
||
* { box-sizing: border-box; }
|
||
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:hover, a:focus-visible { text-decoration: underline; }
|
||
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); }
|
||
.brand { font-weight: 700; font-size: 1.05rem; color: var(--accent); }
|
||
.nav { display: flex; gap: var(--s3); font-size: .9rem; }
|
||
main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
||
.muted { color: var(--muted); }
|
||
|
||
/* filter bar */
|
||
.filters { display: flex; gap: var(--s3); align-items: end; flex-wrap: wrap; margin-bottom: var(--s4); }
|
||
.filters label { display: flex; flex-direction: column; font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); gap: var(--s1); }
|
||
.filters input { font: inherit; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); min-width: 9rem; }
|
||
.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 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; }
|
||
/* anchors styled as buttons: the generic a{} / a:visited{} colour rules outrank
|
||
.btn on <a>, painting the label accent-on-accent (invisible). Restore the
|
||
button foreground for anchor buttons, visited included. */
|
||
a.btn, a.btn:visited { color: var(--accent-fg); }
|
||
.btn:hover { filter: brightness(1.05); }
|
||
.btn:active { transform: translateY(1px); }
|
||
|
||
/* card list */
|
||
.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-title { font-size: 1.1rem; font-weight: 600; line-height: 1.3; }
|
||
.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); }
|
||
.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. */
|
||
.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 span { display: flex; align-items: center; gap: 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; }
|
||
/* one-line count of caption-less videos (collapsed instead of N dead cards) */
|
||
.list-note { margin: var(--s3) 0 0; font-size: .85rem; }
|
||
/* older un-summarized back-catalogue, collapsed behind a disclosure so it does
|
||
not bury the readable summaries above it */
|
||
.older-videos { margin-top: var(--s4); }
|
||
.older-videos > summary { cursor: pointer; font-size: .85rem; font-weight: 600; color: var(--accent); padding: var(--s2) 0; list-style: revert; }
|
||
.older-videos > summary:hover { text-decoration: underline; }
|
||
.older-videos[open] > summary { margin-bottom: var(--s3); }
|
||
.older-videos .cards { margin-top: 0; }
|
||
.card-nudge-form { display: inline; }
|
||
.btn-quiet { font: inherit; font-size: .72rem; font-weight: 600; padding: .15rem .55rem; border-radius: 999px; border: 1px solid var(--accent); background: transparent; color: var(--accent); cursor: pointer; }
|
||
.btn-quiet:hover { background: var(--accent-weak); }
|
||
.chip-warn { background: rgba(255, 110, 156, .15); color: #FF6E9C; }
|
||
.card-state { color: var(--muted); font-size: .8rem; }
|
||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
|
||
|
||
/* pending (discovered-but-unsummarized) card: muted until summarized */
|
||
.card-pending { border-style: dashed; }
|
||
.card-pending .card-title { color: var(--muted); font-weight: 600; }
|
||
|
||
/* summarizing animation — a Charmbracelet-style TUI panel rendered in the
|
||
browser: a dark terminal card, a rounded purple box around a pink ASCII tapir,
|
||
and a lipgloss-style progress bar. Three frames are stacked and cross-faded by
|
||
a stepped keyframe (staggered delays) so the snout appears to wiggle; the
|
||
progress fill grows independently via a clip animation over the dim track. */
|
||
.card-processing { border-style: dashed; }
|
||
.tapir-charm { position: relative; display: inline-block; background: #0d0d12; border-radius: 10px; padding: .8em 1em; margin: var(--s2) 0; font: .82rem/1.15 ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace; box-shadow: 0 2px 14px rgba(118, 83, 252, .25); }
|
||
.tapir-charm pre { margin: 0; white-space: pre; opacity: 0; animation: tapir-cycle 1.2s steps(1, end) infinite; }
|
||
.tapir-charm .tapir-f1 { position: relative; animation-delay: 0s; }
|
||
.tapir-charm .tapir-f2 { position: absolute; top: .8em; left: 1em; animation-delay: .4s; }
|
||
.tapir-charm .tapir-f3 { position: absolute; top: .8em; left: 1em; animation-delay: .8s; }
|
||
@keyframes tapir-cycle { 0%, 33.32% { opacity: 1; } 33.33%, 100% { opacity: 0; } }
|
||
/* progress fill: 27 mint cells overlaying the dim track at box row 10, col 3,
|
||
revealed left→right over 8s, looping. */
|
||
.tapir-bar { position: absolute; top: calc(.8em + 11.5em); left: calc(1em + 3ch); height: 1.15em; line-height: 1.15; overflow: hidden; }
|
||
.tapir-bar-fill { animation: tapir-fill 8s linear infinite; text-shadow: 0 0 6px rgba(14, 249, 182, .7); }
|
||
@keyframes tapir-fill { 0% { clip-path: inset(0 100% 0 0); } 100% { clip-path: inset(0 0 0 0); } }
|
||
.tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
|
||
/* Cycling status verbs (Claude-Code / Crush style): five gerunds stacked, each
|
||
visible 1/5 of a 6s loop, cross-faded. The container reserves one line height
|
||
so the layout does not jump as verbs swap. */
|
||
.tapir-verbs { position: relative; height: 1.3em; margin: .2em 0 0; color: var(--muted); font-size: .9rem; }
|
||
.tapir-verbs span { position: absolute; left: 0; top: 0; white-space: nowrap; opacity: 0; animation: tapir-verb 6s steps(1, end) infinite; }
|
||
.tapir-verbs .tv1 { animation-delay: 0s; }
|
||
.tapir-verbs .tv2 { animation-delay: 1.2s; }
|
||
.tapir-verbs .tv3 { animation-delay: 2.4s; }
|
||
.tapir-verbs .tv4 { animation-delay: 3.6s; }
|
||
.tapir-verbs .tv5 { animation-delay: 4.8s; }
|
||
@keyframes tapir-verb { 0%, 19.99% { opacity: 1; } 20%, 100% { opacity: 0; } }
|
||
/* Resting tapir for the rate-limit waiting state: the panel, one still frame, no
|
||
animation — calm, not busy, signalling "parked, not stuck". */
|
||
.tapir-resting pre { position: relative; opacity: 1; animation: none; }
|
||
.card-waiting { border-style: dashed; opacity: .92; }
|
||
.card-no-captions .card-state { font-style: italic; }
|
||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.tapir-charm pre { animation: none; }
|
||
.tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; }
|
||
.tapir-charm .tapir-f1 { opacity: 1; }
|
||
.tapir-bar-fill { animation: none; clip-path: inset(0 35% 0 0); }
|
||
.tapir-verbs span { animation: none; }
|
||
.tapir-verbs .tv1 { opacity: 1; }
|
||
}
|
||
|
||
/* summarization mode toggle on the account page */
|
||
.summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }
|
||
.summarize-mode p { margin: 0; }
|
||
.summarize-mode form { margin: 0; }
|
||
|
||
/* empty state */
|
||
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
|
||
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
|
||
.empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; }
|
||
.empty p { margin: var(--s3) 0 0; }
|
||
/* connected-but-empty: a distinct accent callout, not a muted blank state, so a
|
||
fresh account knows the next step is to run tapir, not "something is broken". */
|
||
.empty-connected { border-style: solid; border-color: var(--accent); background: var(--accent-weak); color: var(--fg); }
|
||
.empty-connected strong { color: var(--accent); }
|
||
|
||
/* 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-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
|
||
.flash-error { background: #fce8e6; color: #8a1c10; border-color: #d9534f; }
|
||
@media (prefers-color-scheme: dark) {
|
||
.flash-error { background: #3a1714; color: #f3b5ae; border-color: #a6362e; }
|
||
}
|
||
|
||
/* htmx loading feedback */
|
||
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
|
||
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
|
||
|
||
/* detail reader */
|
||
.detail { max-width: 38rem; }
|
||
.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 .meta { color: var(--muted); font-size: .9rem; 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 .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 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 .body { white-space: pre-wrap; line-height: 1.7; margin: 0; }
|
||
.detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; }
|
||
.detail li { margin-bottom: var(--s1); }
|
||
|
||
/* deeper-dive chat (ADR-027) */
|
||
.dig-deeper { margin: var(--s3) 0 0; }
|
||
.chat .chat-scope { margin: 0 0 var(--s4); font-size: .9rem; }
|
||
.chat-panel { display: flex; flex-direction: column; gap: var(--s3); }
|
||
.chat-log { display: flex; flex-direction: column; gap: var(--s3); }
|
||
.chat-turn { border-radius: var(--radius); padding: var(--s2) var(--s3); }
|
||
.chat-turn p { margin: 0; }
|
||
.chat-q { background: var(--accent-weak); color: var(--fg); align-self: flex-end; max-width: 85%; }
|
||
.chat-a { background: var(--card); border: 1px solid var(--line); }
|
||
.chat-a .body { white-space: pre-wrap; line-height: 1.6; }
|
||
.chat-note { margin: 0; font-size: .82rem; font-style: italic; }
|
||
.chat-error { margin: 0; color: #8a1c10; 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-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-hint { text-transform: none; letter-spacing: 0; font-size: .78rem; }
|
||
.chat-form textarea { font: inherit; padding: .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); resize: vertical; }
|
||
.chat-form textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
||
.chat-form .btn { align-self: flex-start; }
|
||
.chat-thinking { font-style: italic; }
|
||
|
||
/* action toggles — watched|skipped form one segmented control (they are mutually
|
||
exclusive), "saved" sits apart as an independent toggle */
|
||
.actions { display: flex; gap: var(--s3); margin: var(--s4) 0; flex-wrap: wrap; align-items: center; }
|
||
.segmented { display: inline-flex; }
|
||
.segmented .action { border-radius: 0; border-right-width: 0; }
|
||
.segmented .action:first-child { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); }
|
||
.segmented .action:last-child { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); border-right-width: 1px; }
|
||
.actions .action { font: inherit; padding: .4rem .9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); cursor: pointer; transition: border-color .15s, background .15s; }
|
||
.actions .action:hover { border-color: var(--accent); }
|
||
.actions .action:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||
.actions .action:active { transform: translateY(1px); }
|
||
.actions .action.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||
|
||
/* account page */
|
||
.account { max-width: 40rem; }
|
||
.account h1 { font-size: 1.7rem; margin: 0 0 var(--s4); }
|
||
.account section { margin-top: var(--s5); }
|
||
.account 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(--s3); }
|
||
.account-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s1) var(--s3); margin: 0; }
|
||
.account-meta dt { color: var(--muted); font-size: .85rem; }
|
||
.account-meta dd { margin: 0; }
|
||
.channel-errors { }
|
||
.channel-error-list { list-style: none; margin: 0 0 var(--s3); padding: 0; display: grid; gap: var(--s1); }
|
||
.channel-error-list li { display: flex; align-items: center; gap: var(--s2); }
|
||
.channel-error-name { font-weight: 500; }
|
||
.channel-error-since { font-size: .8rem; }
|
||
.conn-list { list-style: none; margin: 0 0 var(--s3); padding: 0; display: grid; gap: var(--s2); }
|
||
.conn { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3); display: flex; flex-direction: column; gap: var(--s1); }
|
||
.conn-main { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
|
||
.conn-provider { font-weight: 600; }
|
||
.conn-meta { font-size: .8rem; }
|
||
.conn form { margin-top: var(--s1); }
|
||
.btn-secondary { font: inherit; font-weight: 600; padding: .4rem .9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); cursor: pointer; }
|
||
.btn-secondary:hover { border-color: var(--accent); }
|
||
.btn-secondary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||
|
||
/* delete danger zone — destructive action behind a confirm disclosure */
|
||
.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::-webkit-details-marker { display: none; }
|
||
.confirm-delete > summary:hover { background: #fce8e6; }
|
||
.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; }
|
||
.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:focus-visible { outline: 2px solid #d9534f; outline-offset: 1px; }
|
||
@media (prefers-color-scheme: dark) {
|
||
.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 */
|
||
.welcome { text-align: center; padding: var(--s5) var(--s3); display: flex; flex-direction: column; align-items: center; gap: var(--s4); }
|
||
.welcome-hero { background: #0d0d12; border-radius: 10px; padding: .9em 1.1em; display: inline-block; box-shadow: 0 2px 14px rgba(118, 83, 252, .25); }
|
||
.welcome-hero pre { margin: 0; white-space: pre; font: .82rem/1.15 ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace; }
|
||
.welcome-title { font-size: 1.9rem; line-height: 1.2; margin: 0; }
|
||
.welcome-tagline { color: var(--muted); font-size: 1.05rem; line-height: 1.5; margin: 0; max-width: 32rem; }
|
||
.welcome-cta { display: flex; gap: var(--s3); flex-wrap: wrap; justify-content: center; align-items: center; }
|
||
.welcome-sub { color: var(--muted); font-size: .9rem; margin: 0; }
|
||
.btn-lg { padding: .6rem 1.6rem; font-size: 1.05rem; }
|
||
|
||
@media (max-width: 640px) {
|
||
main { padding: var(--s3) var(--s2); }
|
||
.filters { gap: var(--s2); }
|
||
.filters input { min-width: 0; width: 100%; }
|
||
.filters label { flex: 1 1 8rem; }
|
||
.card { padding: var(--s3); }
|
||
}
|
||
`
|