Pokemon-themed spinner animations, one per verb.

A Claude Code plugin with one skill, /spinner-studio:new-spinner. Give it a theme and it walks you through building a spinner mod: themed spinner verbs, each verb with its own plain-text animation next to it.
examples/pokemon-spinner/ is a finished result (a Pokemon theme).
/plugin marketplace add AntreasPetsas/spinner-studio
/plugin install spinner-studio@spinner-studio
Then run /spinner-studio:new-spinner.
Spinner mods use Claude Code function hooks, which are early access and off by default:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude
The skill offers to put this in ~/.claude/settings.json under env. It also offers to set spinnerVerbs; it asks before touching either file.
sweep, wave, flicker, fill, pulse, drift, ripple, dash, twinkle, rain, glide, or one it writes for you). Verbs with no match get the fallback.skills/new-spinner/template/, validates it with claude plugin validate, and installs it.It never runs git and never publishes anything.
hooks/register.ts 86 lines1const WIDTH = 24
2const TRAIL = 6
3const STEP_MS = 80
4let active = false
5
6const cells = (f: (i: number) => string) => Array.from({ length: WIDTH }, (_, i) => f(i)).join('')
7const tickOf = () => Math.floor(Date.now() / STEP_MS)
8const mod = (n: number, m: number) => ((n % m) + m) % m
9
10// Every animation is a pure function of the tick and returns exactly WIDTH cells of plain text:
11// the Spinner suffix drops ANSI colour codes, and wide emoji break the width.
12const anim = {
13 // a bright head bouncing back and forth with a trail
14 sweep: (t: number) => {
15 const period = WIDTH * 2 - 2
16 const tick = t % period
17 const forward = tick < WIDTH
18 const pos = forward ? tick : period - tick
19 const dir = forward ? 1 : -1
20 return cells(i => {
21 const d = (pos - i) * dir
22 return d >= 0 && d < TRAIL ? (d === 0 ? '█' : '▓') : '░'
23 })
24 },
25 wave: (t: number) => cells(i => '~≈'[(i + t) % 2]),
26 flicker: (t: number) => cells(i => ((i * 7 + t * 13) % 9 === 0 ? 'ϟ' : '·')),
27 fill: (t: number) => cells(i => (i < t % (WIDTH + 1) ? '●' : '○')),
28 pulse: (t: number) => cells(i => '▁▁▂▅█▃▁▁'[(i + t) % 8]),
29 drift: (t: number) => cells(i => ((i + t) % 6 === 0 ? 'z' : (i + t) % 6 === 3 ? 'Z' : ' ')),
30 ripple: (t: number) => cells(i => '·○◎●◎○'[(i + t) % 6]),
31 dash: (t: number) => cells(i => (mod(t - i, 5) === 0 ? '»' : '·')),
32 twinkle: (t: number) => cells(i => ((i * 5 + Math.floor(t / 4) * 7) % 11 === 0 ? '*' : '·')),
33 rain: (t: number) => cells(i => ((i * 3 + t) % 7 === 0 ? '/' : '·')),
34 // a head with a flapping wing and a body, flying across and re-entering from the left
35 glide: (t: number) => {
36 const pos = t % (WIDTH + 6)
37 const wing = Math.floor(t / 3) % 2 ? '^' : 'v'
38 return cells(i => {
39 const d = pos - i
40 return d === 0 ? '>' : d === 1 ? wing : d > 1 && d < 6 ? '~' : '·'
41 })
42 },
43}
44
45type Name = keyof typeof anim
46
47// First match on the spinner verb wins; anything else gets the fallback.
48const rules: [RegExp, Name][] = [
49 [/Thunderbolt|Pikachu/i, 'flicker'],
50 [/Surfing|Splashing|Squirtle/i, 'wave'],
51 [/Hatching|Evolving|Training/i, 'fill'],
52 [/Healing|Potion/i, 'pulse'],
53 [/Snorlax|Jigglypuff/i, 'drift'],
54 [/Psychic|Mew|Lunala/i, 'ripple'],
55 [/Razor Leaf|Bulbasaur/i, 'dash'],
56 [/Dragonite/i, 'glide'],
57]
58const fallback: Name = 'sweep' // FALLBACK
59
60export function animationFor(word: string): (t: number) => string {
61 return anim[rules.find(([re]) => re.test(word))?.[1] ?? fallback]
62}
63
64export function register(on: any) {
65 on('session.start', async ($: any, e: any, next: any) => {
66 $.clock.every(STEP_MS, () => {
67 if (active) $.ui.invalidate('ui.render')
68 })
69 return next(e)
70 })
71
72 on('turn.start', async ($: any, e: any, next: any) => {
73 if (!e.agentId) active = true
74 return next(e)
75 })
76
77 on('turn.complete', async ($: any, e: any, next: any) => {
78 if (!e.agentId) active = false
79 return next(e)
80 })
81
82 on('ui.render', { component: 'Spinner' }, async ($: any, e: any, next: any) =>
83 next({ ...e, props: { ...e.props, suffix: ' ' + animationFor(e.props.word ?? '')(tickOf()) } }),
84 )
85}
86