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
+100 -25
View File
@@ -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 `<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.
@@ -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 */