SLOPSHOPPER

pokemon-spinner

Pokemon-themed spinner animations, one per verb.

newspinnertimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pokemon-spinner
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ✻ Thinking ▓▓█░░░░░░░░░░░░░░░░░░░░░… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

spinner-studio

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).

Install

/plugin marketplace add AntreasPetsas/spinner-studio
/plugin install spinner-studio@spinner-studio

Then run /spinner-studio:new-spinner.

Requirements

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.

What the skill does

  1. Asks for a theme and a mod name.
  2. Proposes about 25 themed verbs; you edit or accept.
  3. Maps verbs to animations (sweep, wave, flicker, fill, pulse, drift, ripple, dash, twinkle, rain, glide, or one it writes for you). Verbs with no match get the fallback.
  4. Writes the mod from skills/new-spinner/template/, validates it with claude plugin validate, and installs it.

It never runs git and never publishes anything.

Limits

  • Plain text only. The spinner suffix drops ANSI colour codes, so animations take the spinner's colour.
  • The animation replaces the trailing ellipsis after the verb.
  • Redraws are capped at about ten per second by the engine.
  • Terminal and desktop surfaces only.
Source 1 files
hooks/register.ts 86 lines
1const 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