feat(web): charmbracelet-aesthetic tapir spinner with charm palette
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:
@@ -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
@@ -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 */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
+299
-286
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user