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") } // 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 `` + s + `` } // 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 " // 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 , 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; } @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); } } /* 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); } /* 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); } } `