feat(web): charmbracelet-aesthetic tapir spinner with charm palette
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Has been skipped

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 20:11:17 +02:00
co-authored by Claude Opus 4.8
parent 8c6c7ca947
commit a4aeb5efcd
5 changed files with 435 additions and 321 deletions
+4 -2
View File
@@ -48,8 +48,10 @@ func TestRequestSummarizeImmediateProcessing(t *testing.T) {
// The processing card came back while ProcessVideo is still parked on block: // 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. // 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, "Summarizing", "processing card returned")
require.Contains(t, html, "ω", "ascii tapir frame rendered") require.Contains(t, html, "", "charm box rendered")
require.Contains(t, html, "∪∪", "all tapir frames 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, "/v/"+videoX+"/status", "card polls the status endpoint")
require.Contains(t, html, `hx-trigger="every 2s"`, "card auto-polls every 2s") require.Contains(t, html, `hx-trigger="every 2s"`, "card auto-polls every 2s")
require.NotContains(t, html, "Queued", "not the queue-only card") require.NotContains(t, html, "Queued", "not the queue-only card")
+100 -25
View File
@@ -4,6 +4,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/a-h/templ" "github.com/a-h/templ"
@@ -182,22 +183,87 @@ func statusURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/status") return templ.SafeURL("/v/" + videoID + "/status")
} }
// tapirFrame1..3 are the ASCII tapir frames cycled by the TapirSpinner CSS // Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
// animation. Three near-identical frames (snout and feet shift) read as a stocky, // a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
// big-snouted tapir ambling in place while a summary is generated. // so the inline span colours and the CSS track/fill share one source of truth.
const ( const (
tapirFrame1 = ` /\_____/\ CharmPurple = "#7653FC" // box border
( · ω · ) CharmPink = "#FF6E9C" // tapir body
) ∩∩ ( CharmMint = "#0EF9B6" // snout, eyes, progress fill
(__( )__)` CharmCream = "#FFFDF5" // bright text
tapirFrame2 = ` /\_____/\ CharmDim = "#6C6C6C" // dim text
( · ω · ) charmTrack = "#2D2D2D" // empty progress track (internal: dark char colour)
) ∩∩ ( )
(__( ^ )__)`
tapirFrame3 = ` /\_____/\ // 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("~")
) )
// summarizeModeLabel names the current mode for display. // 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 { border-style: dashed; }
.card-pending .card-title { color: var(--muted); font-weight: 600; } .card-pending .card-title { color: var(--muted); font-weight: 600; }
/* summarizing animation — a little ASCII tapir ambling in place. Three frames /* summarizing animation — a Charmbracelet-style TUI panel rendered in the
are stacked absolutely and cross-faded by a stepped keyframe with staggered browser: a dark terminal card, a rounded purple box around a pink ASCII tapir,
delays, so exactly one shows at a time (no JS). */ 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; } .card-processing { border-style: dashed; }
.tapir-spin { position: relative; height: 5.2em; margin: var(--s2) 0; } .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-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-charm pre { margin: 0; white-space: pre; opacity: 0; animation: tapir-cycle 1.2s steps(1, end) infinite; }
.tapir-spin pre:nth-child(1) { animation-delay: 0s; } .tapir-charm .tapir-f1 { position: relative; animation-delay: 0s; }
.tapir-spin pre:nth-child(2) { animation-delay: .4s; } .tapir-charm .tapir-f2 { position: absolute; top: .8em; left: 1em; animation-delay: .4s; }
.tapir-spin pre:nth-child(3) { animation-delay: .8s; } .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; } } @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; } .tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.tapir-spin pre { animation: none; } .tapir-charm pre { animation: none; }
.tapir-spin pre:nth-child(1) { opacity: 1; } .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 */ /* summarization mode toggle on the account page */
+22 -1
View File
@@ -1,6 +1,11 @@
package web package web
import "testing" import (
"regexp"
"strings"
"testing"
"unicode/utf8"
)
func TestEmbedURL(t *testing.T) { func TestEmbedURL(t *testing.T) {
tests := []struct { 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)
}
}
}
}
+10 -7
View File
@@ -140,14 +140,17 @@ templ VideoCard(r store.SummaryRow) {
</li> </li>
} }
// TapirSpinner is the summarizing animation: three ASCII tapir frames stacked // TapirSpinner is the summarizing animation: a Charmbracelet-style TUI panel —
// and cross-faded by CSS keyframes (no JS), with a muted "Summarizing…" label. // three richly coloured ASCII tapir frames (inline span colours, snout wiggling
// The art is aria-hidden — it is decorative; the label carries the meaning. // ∩→∪→~) 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() { templ TapirSpinner() {
<div class="tapir-spin" aria-hidden="true"> <div class="tapir-charm" aria-hidden="true">
<pre>{ tapirFrame1 }</pre> <pre class="tapir-f1">@templ.Raw(tapirFrameHTML1)</pre>
<pre>{ tapirFrame2 }</pre> <pre class="tapir-f2">@templ.Raw(tapirFrameHTML2)</pre>
<pre>{ tapirFrame3 }</pre> <pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
</div> </div>
<p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p> <p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p>
} }
File diff suppressed because it is too large Load Diff