From a4aeb5efcd48affa874d73b17a90f1deebf3a3e7 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Wed, 3 Jun 2026 20:11:17 +0200
Subject: [PATCH] feat(web): charmbracelet-aesthetic tapir spinner with charm
palette
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the plain ASCII spinner with a Charmbracelet-style TUI panel rendered in
the browser: a dark terminal card holding a rounded purple box (╭─╮╰─╯│) around a
pink block-char tapir (▄▓▀█) with mint eyes (◕ ◕) and a snout that wiggles ∩→∪→~
across three cross-faded frames, plus a lipgloss-style progress bar whose mint
fill grows over a dim track via an 8s CSS clip animation.
Colours come from named palette consts (CharmPurple/Pink/Mint/Cream/Dim) applied
as inline span styles, so the box and CSS share one source of truth. Frames are
built by a small run/line helper that pads each row to a fixed interior width —
an internal test asserts every box row is the same cell width so the border stays
flush. Honours prefers-reduced-motion (static frame + partial bar).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
internal/web/processing_handler_test.go | 6 +-
internal/web/view.go | 125 ++++-
internal/web/view_internal_test.go | 23 +-
internal/web/views.templ | 17 +-
internal/web/views_templ.go | 585 ++++++++++++------------
5 files changed, 435 insertions(+), 321 deletions(-)
diff --git a/internal/web/processing_handler_test.go b/internal/web/processing_handler_test.go
index 0b561af..456b3ff 100644
--- a/internal/web/processing_handler_test.go
+++ b/internal/web/processing_handler_test.go
@@ -48,8 +48,10 @@ func TestRequestSummarizeImmediateProcessing(t *testing.T) {
// The processing card came back while ProcessVideo is still parked on block:
// the work runs in a goroutine, the handler did not wait for it.
require.Contains(t, html, "Summarizing", "processing card returned")
- require.Contains(t, html, "ω", "ascii tapir frame rendered")
- require.Contains(t, html, "∪∪", "all tapir frames rendered")
+ require.Contains(t, html, "╭", "charm box rendered")
+ require.Contains(t, html, "▓", "tapir body block chars rendered")
+ require.Contains(t, html, "∩", "wiggling snout frame rendered")
+ require.Contains(t, html, web.CharmPurple, "charm palette applied to the border")
require.Contains(t, html, "/v/"+videoX+"/status", "card polls the status endpoint")
require.Contains(t, html, `hx-trigger="every 2s"`, "card auto-polls every 2s")
require.NotContains(t, html, "Queued", "not the queue-only card")
diff --git a/internal/web/view.go b/internal/web/view.go
index 7bd5f63..4c9479b 100644
--- a/internal/web/view.go
+++ b/internal/web/view.go
@@ -4,6 +4,7 @@ import (
"regexp"
"strings"
"time"
+ "unicode/utf8"
"github.com/a-h/templ"
@@ -182,22 +183,87 @@ func statusURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/status")
}
-// tapirFrame1..3 are the ASCII tapir frames cycled by the TapirSpinner CSS
-// animation. Three near-identical frames (snout and feet shift) read as a stocky,
-// big-snouted tapir ambling in place while a summary is generated.
+// 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 (
- tapirFrame1 = ` /\_____/\
-( · ω · )
- ) ∩∩ (
-(__( )__)`
- tapirFrame2 = ` /\_____/\
-( · ω · )
- ) ∩∩ (
- (__( ^ )__)`
- tapirFrame3 = ` /\_____/\
-( · ω · )
- ) ∪∪ (
-(__( )__)`
+ 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("~")
)
// summarizeModeLabel names the current mode for display.
@@ -405,20 +471,29 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.card-pending { border-style: dashed; }
.card-pending .card-title { color: var(--muted); font-weight: 600; }
-/* summarizing animation — a little ASCII tapir ambling in place. Three frames
- are stacked absolutely and cross-faded by a stepped keyframe with staggered
- delays, so exactly one shows at a time (no JS). */
+/* 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-spin { position: relative; height: 5.2em; margin: var(--s2) 0; }
-.tapir-spin pre { position: absolute; inset: 0; margin: 0; opacity: 0; font: .9rem/1.15 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--accent); animation: tapir-cycle 1.2s steps(1, end) infinite; }
-.tapir-spin pre:nth-child(1) { animation-delay: 0s; }
-.tapir-spin pre:nth-child(2) { animation-delay: .4s; }
-.tapir-spin pre:nth-child(3) { animation-delay: .8s; }
+.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-spin pre { animation: none; }
- .tapir-spin pre:nth-child(1) { opacity: 1; }
+ .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 */
diff --git a/internal/web/view_internal_test.go b/internal/web/view_internal_test.go
index 1e2d1b6..30e51d5 100644
--- a/internal/web/view_internal_test.go
+++ b/internal/web/view_internal_test.go
@@ -1,6 +1,11 @@
package web
-import "testing"
+import (
+ "regexp"
+ "strings"
+ "testing"
+ "unicode/utf8"
+)
func TestEmbedURL(t *testing.T) {
tests := []struct {
@@ -27,3 +32,19 @@ func TestEmbedURL(t *testing.T) {
})
}
}
+
+// TestTapirFrameRowsAligned asserts every box row has the same cell width once
+// the inline-colour spans are stripped, so the rounded border lines up on every
+// line (the panel only looks right if the right │ is flush across all rows).
+func TestTapirFrameRowsAligned(t *testing.T) {
+ stripSpan := regexp.MustCompile(`?span[^>]*>`)
+ for name, frame := range map[string]string{"f1": tapirFrameHTML1, "f2": tapirFrameHTML2, "f3": tapirFrameHTML3} {
+ plain := stripSpan.ReplaceAllString(frame, "")
+ want := tapirInteriorW + 2 // both purple side borders
+ for i, line := range strings.Split(plain, "\n") {
+ if got := utf8.RuneCountInString(line); got != want {
+ t.Errorf("%s line %d width = %d, want %d: %q", name, i, got, want, line)
+ }
+ }
+ }
+}
diff --git a/internal/web/views.templ b/internal/web/views.templ
index 7ce9ad9..dc526d6 100644
--- a/internal/web/views.templ
+++ b/internal/web/views.templ
@@ -140,14 +140,17 @@ templ VideoCard(r store.SummaryRow) {
}
-// TapirSpinner is the summarizing animation: three ASCII tapir frames stacked
-// and cross-faded by CSS keyframes (no JS), with a muted "Summarizing…" label.
-// The art is aria-hidden — it is decorative; the label carries the meaning.
+// 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
+// grows over the dim track. The panel is aria-hidden (decorative); the
+// "Summarizing…" label below carries the meaning for assistive tech.
templ TapirSpinner() {
-
-
{ tapirFrame1 }
-
{ tapirFrame2 }
-
{ tapirFrame3 }
+
+
@templ.Raw(tapirFrameHTML1)
+
@templ.Raw(tapirFrameHTML2)
+
@templ.Raw(tapirFrameHTML3)
+
{ tapirBarFill }
Summarizing…
}
diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go
index 5641174..c6bc75e 100644
--- a/internal/web/views_templ.go
+++ b/internal/web/views_templ.go
@@ -595,9 +595,11 @@ func VideoCard(r store.SummaryRow) templ.Component {
})
}
-// TapirSpinner is the summarizing animation: three ASCII tapir frames stacked
-// and cross-faded by CSS keyframes (no JS), with a muted "Summarizing…" label.
-// The art is aria-hidden — it is decorative; the label carries the meaning.
+// 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
+// grows over the dim track. The panel is aria-hidden (decorative); the
+// "Summarizing…" label below carries the meaning for assistive tech.
func TapirSpinner() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -619,46 +621,57 @@ func TapirSpinner() templ.Component {
templ_7745c5c3_Var28 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
Automatic summarizes every new video as it is discovered. Manual lets you pick which videos to summarize — new videos appear in your list with a Summarize button.
Automatic summarizes every new video as it is discovered. Manual lets you pick which videos to summarize — new videos appear in your list with a Summarize button.