SLOPSHOPPER

spinner-packs

Themed spinner words: Claude is 'Plundering…' then 'Plundered for 1m 3s'. Ten packs (pirate, Shakespeare, corporate, wizard, chef, space, noir, Gen Z, zen…

newspinnercommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spinner-packs
› 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. ✻ Schemed for 42s · done 4:20 PM › /spinner ⎿ spinner-packs: Spinner pack: random, 🎭 shakespeare this session. ⎿ spinner-packs: ⎿ spinner-packs: 🏴‍☠️ pirate Plundering · Raising the Jolly Roger · Reading the map… ⎿ spinner-packs: 🎭 shakespeare Pondering · Scheming · Dreaming, perchance… ⎿ spinner-packs: 💼 corporate Synergizing · Taking it offline · Level-setting… ⎿ spinner-packs: 🧙 wizard Conjuring · Charging the staff · Channeling mana… ✻ Scheming… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🏴‍☠️ spinner-packs

Claude isn't "Sautéing…" anymore. It's Charting a course…, then Charted a course for 51s. Ten themed word packs for the spinner, or your own words.

✳ Charting a course… (12s · thinking)

● Here's the poem…

✻ Charted a course for 51s · done 3:23 PM

Captured from a real session with the pirate pack. The spinner keeps its word for the whole turn, and the line that closes the turn uses the same word in the past tense.

Features

  • Ten packs, 27 words each, every one with a past form for the closing line:
PackA taste
🏴‍☠️ piratePlundering · Keelhauling bugs · Counting doubloons
🎭 shakespeareSoliloquizing · Perchancing · Consulting the witches
💼 corporateSynergizing · Circling back · Boiling the ocean
🧙 wizardConjuring · Polishing the orb · Hexing bugs
👩‍🍳 chefDeglazing · Julienning · Tempering chocolate
🚀 spaceCalibrating thrusters · Engaging hyperdrive · Splashing down
🕵️ noirDusting for prints · Rounding up the usual suspects · Smelling a rat
✨ genzCooking · Locking in · Understanding the assignment
🍃 zenRaking the gravel · Sipping tea · Returning to stillness
💾 retroDialing up · Reticulating splines · Downloading more RAM
  • random (the default) picks a pack each session.
  • Your own words with the custom option. Write Frobbing|Frobbed to theme the closing line too.
  • /spinner <pack> switches mid-turn and remembers the choice. Also /spinner random, /spinner off, and /spinner reset (back to your setting). /spinner lists every pack with samples.
  • Leaves status messages alone. While Claude Code shows a state such as compacting, its own text stays. On the desktop app, the row's step text ("Creating notes.md") stays and only its idle "Working" is themed.

Install

/plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install spinner-packs@awesome-claude-mods

Requires Claude Code 2.1.287 or later.

Configuration

OptionDefaultWhat it does
packrandomrandom, one of the ten packs, or off (Claude Code's own words). A /spinner choice wins over this until /spinner reset.
customemptyComma-separated words that replace every pack, e.g. `Frobbing\Frobbed, Yak shaving\Shaved yaks`.
themeClosingLinetrueAlso theme the line that closes a turn ("Plundered for 1m 3s"). Terminal only.

How it works

EventWhy
ui.render on SpinnerRewrites props.word with next({ ...e, props }), so the engine still draws the glyph, timer and tokens
ui.render on TurnDurationRewrites the past-tense word to match the spinner word that turn showed
turn.startMoves to the next word and redraws
command.run/spinner
$.storeRemembers your /spinner choice

It rewrites props rather than drawing its own tree, so it stacks with other mods on the same sites, such as cost-meter's turn cost.

Test it

claude plugin test mods/fun/spinner-packs   # 26 tests

Limitations

  • The closing line is terminal-only, because the desktop app draws its own footer.
  • Closing lines drawn before the mod loaded, such as a resumed transcript, get a word from the pack but not necessarily the one their spinner showed.
Source 2 files
hooks/register.ts 130 lines
1import type { Register } from 'claude-code'
2
3import { PACK_IDS, PACKS, indexFor, packById, parseCustom, pastAt, pastFor, presentFor, samples, type Pack } from './packs'
4
5// What /spinner chose, read from $.store at session start: a pack id,
6// 'random' or 'off'; undefined follows the `pack` option.
7let chosen: string | undefined
8// The pack 'random' landed on for this session.
9let rolled: Pack | undefined
10// Bumped at every turn start, so each turn gets its own word.
11let turnSeq = 0
12// The pair the spinner last showed, and the pair each closing line took, so
13// "Charting a course…" ends as "Charted a course for 51s" and stays that way.
14let lastIndex: number | undefined
15const closing = new Map<string, number>()
16
17function roll(): Pack {
18  rolled = PACKS[Math.floor(Math.random() * PACKS.length)]!
19  return rolled
20}
21
22export const register: Register = (on, options) => {
23  const custom = parseCustom(String(options.custom ?? ''))
24  const optionPack = String(options.pack ?? 'random')
25  const themeClosingLine = options.themeClosingLine !== false
26
27  const setting = (): string => chosen ?? optionPack
28
29  const currentPack = (): Pack | undefined => {
30    if (custom !== undefined) return custom
31    const id = setting()
32    if (id === 'off') return undefined
33    if (id === 'random') return rolled ?? roll()
34    return packById(id) ?? rolled ?? roll()
35  }
36
37  on('session.start', async ($, e, next) => {
38    const stored = await $.store.get('pack')
39    chosen = typeof stored === 'string' ? stored : undefined
40    await $.command.register({
41      name: 'spinner',
42      description: 'Pick the spinner word pack: /spinner <pack|random|off|reset>',
43      argumentHint: '[pack]',
44      immediate: true,
45    })
46    return next(e)
47  })
48
49  on('turn.start', ($, e, next) => {
50    turnSeq += 1
51    $.ui.invalidate('ui.render')
52    return next(e)
53  })
54
55  // The line that animates while Claude works.
56  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
57    const pack = currentPack()
58    // A state that overrides the word (compacting, retrying) keeps its text.
59    if (pack === undefined || e.props.message !== null) return next(e)
60    // The desktop's row names the step it is on; only its idle "Working" is ours.
61    if (e.surface === 'desktop' && e.props.word !== 'Working') return next(e)
62    lastIndex = indexFor(pack, `${turnSeq}:${e.requestId}`)
63    return next({ ...e, props: { ...e.props, word: pack.words[lastIndex]![0] } })
64  })
65
66  // The line that closes a turn ("Plundered for 1m 3s"), terminal only.
67  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
68    const pack = currentPack()
69    if (pack === undefined || !themeClosingLine) return next(e)
70    let index = closing.get(e.requestId)
71    if (index === undefined && lastIndex !== undefined) {
72      index = lastIndex
73      closing.set(e.requestId, index)
74      if (closing.size > 500) closing.delete(closing.keys().next().value!)
75    }
76    const word = (index !== undefined ? pastAt(pack, index) : undefined) ?? pastFor(pack, e.requestId)
77    if (word === undefined) return next(e)
78    return next({ ...e, props: { ...e.props, word } })
79  })
80
81  on('command.run', { command: 'spinner' }, async ($, e) => {
82    const arg = e.args.trim().toLowerCase()
83
84    if (arg === '' || arg === 'list') {
85      const now = currentPack()
86      const current = custom !== undefined
87        ? 'custom words (from the `custom` option)'
88        : setting() === 'off'
89          ? 'off (Claude Code’s own words)'
90          : setting() === 'random'
91            ? `random, ${now?.emoji ?? ''} ${now?.id ?? ''} this session`
92            : `${now?.emoji ?? ''} ${now?.id ?? ''}`
93      const rows = PACKS.map(p => `  ${p.emoji} ${p.id.padEnd(12)} ${samples(p).join(' · ')}…`)
94      return {
95        text: [
96          `Spinner pack: ${current}.`,
97          '',
98          ...rows,
99          '',
100          'Switch with /spinner <pack>, /spinner random, /spinner off, or /spinner reset (back to your setting).',
101        ].join('\n'),
102      }
103    }
104
105    if (arg === 'reset') {
106      chosen = undefined
107      await $.store.delete('pack')
108    } else if (arg === 'random') {
109      chosen = 'random'
110      roll()
111      await $.store.set('pack', chosen)
112    } else if (arg === 'off') {
113      chosen = 'off'
114      await $.store.set('pack', chosen)
115    } else if (packById(arg) !== undefined) {
116      chosen = arg
117      await $.store.set('pack', chosen)
118    } else {
119      return { text: `No pack named "${arg}". Packs: ${PACK_IDS.join(', ')}, or random, off, reset.` }
120    }
121
122    $.ui.invalidate('ui.render')
123    const pack = currentPack()
124    if (custom !== undefined) return { text: 'Saved, but your `custom` words win while that option is set.' }
125    if (pack === undefined) return { text: 'Spinner pack off: Claude Code’s own words are back.' }
126    const label = chosen === undefined ? `back to your setting (${optionPack})` : `${pack.emoji} ${pack.title}`
127    return { text: `Spinner pack: ${label}. For example: ${samples(pack, 2).join(', ')}…` }
128  })
129}
130
hooks/packs.ts 235 lines
1// The word packs, as [while working, after the turn] pairs: the spinner says
2// "Plundering…", the line that closes the turn says "Plundered for 1m 3s".
3
4export type Pair = readonly [present: string, past: string | null]
5
6export type Pack = {
7  id: string
8  emoji: string
9  title: string
10  words: readonly Pair[]
11}
12
13export const PACKS: readonly Pack[] = [
14  {
15    id: 'pirate',
16    emoji: '🏴‍☠️',
17    title: 'Pirate',
18    words: [
19      ['Plundering', 'Plundered'], ['Hoisting the sails', 'Hoisted the sails'], ['Swabbing the deck', 'Swabbed the deck'],
20      ['Charting a course', 'Charted a course'], ['Weighing anchor', 'Weighed anchor'], ['Splicing the mainbrace', 'Spliced the mainbrace'],
21      ['Keelhauling bugs', 'Keelhauled bugs'], ['Burying treasure', 'Buried treasure'], ['Scanning the horizon', 'Scanned the horizon'],
22      ['Raising the Jolly Roger', 'Raised the Jolly Roger'], ['Battening the hatches', 'Battened the hatches'], ['Boarding', 'Boarded'],
23      ['Parleying', 'Parleyed'], ['Navigating', 'Navigated'], ['Tacking', 'Tacked'], ['Hauling', 'Hauled'],
24      ['Mending the rigging', 'Mended the rigging'], ['Counting doubloons', 'Counted doubloons'], ['Reading the map', 'Read the map'],
25      ['Pillaging', 'Pillaged'], ['Sailing', 'Sailed'], ['Dropping anchor', 'Dropped anchor'],
26      ["Climbing the crow's nest", "Climbed the crow's nest"], ['Polishing the cannons', 'Polished the cannons'],
27      ['Feeding the parrot', 'Fed the parrot'], ['Swashbuckling', 'Swashbuckled'], ['Yo-ho-ho-ing', 'Yo-ho-ho-ed'],
28    ],
29  },
30  {
31    id: 'shakespeare',
32    emoji: '🎭',
33    title: 'Shakespeare',
34    words: [
35      ['Pondering', 'Pondered'], ['Soliloquizing', 'Soliloquized'], ['Musing', 'Mused'], ['Contemplating', 'Contemplated'],
36      ['Versifying', 'Versified'], ['Declaiming', 'Declaimed'], ['Brooding', 'Brooded'], ['Rehearsing', 'Rehearsed'],
37      ['Composing a sonnet', 'Composed a sonnet'], ['Scheming', 'Schemed'], ['Lamenting', 'Lamented'], ['Beseeching', 'Beseeched'],
38      ['Proclaiming', 'Proclaimed'], ['Quilling', 'Quilled'], ['Wooing', 'Wooed'], ['Plotting', 'Plotted'],
39      ['Exeunting', 'Exeunted'], ['Perchancing', 'Perchanced'], ['Dreaming, perchance', 'Dreamt, perchance'],
40      ['Taking arms', 'Took arms'], ['Treading the boards', 'Trod the boards'], ['Scanning the meter', 'Scanned the meter'],
41      ['Consulting the witches', 'Consulted the witches'], ['Holding the mirror up', 'Held the mirror up'],
42      ['Mustering', 'Mustered'], ['Bestirring', 'Bestirred'], ['Forswearing', 'Forswore'],
43    ],
44  },
45  {
46    id: 'corporate',
47    emoji: '💼',
48    title: 'Corporate',
49    words: [
50      ['Synergizing', 'Synergized'], ['Circling back', 'Circled back'], ['Aligning stakeholders', 'Aligned stakeholders'],
51      ['Leveraging', 'Leveraged'], ['Ideating', 'Ideated'], ['Actioning', 'Actioned'], ['Deep-diving', 'Deep-dived'],
52      ['Moving the needle', 'Moved the needle'], ['Boiling the ocean', 'Boiled the ocean'], ['Taking it offline', 'Took it offline'],
53      ['Touching base', 'Touched base'], ['Pivoting', 'Pivoted'], ['Socializing the deck', 'Socialized the deck'],
54      ['Right-sizing', 'Right-sized'], ['Operationalizing', 'Operationalized'], ['Unpacking', 'Unpacked'],
55      ['Double-clicking', 'Double-clicked'], ['Parking it', 'Parked it'], ['Level-setting', 'Level-set'],
56      ['Optimizing synergies', 'Optimized synergies'], ['Drafting the roadmap', 'Drafted the roadmap'],
57      ['Scheduling a sync', 'Scheduled a sync'], ['Picking low-hanging fruit', 'Picked low-hanging fruit'],
58      ['Running it up the flagpole', 'Ran it up the flagpole'], ['Blue-sky thinking', 'Blue-sky thought'],
59      ['Shifting paradigms', 'Shifted paradigms'], ['Adding value', 'Added value'],
60    ],
61  },
62  {
63    id: 'wizard',
64    emoji: '🧙',
65    title: 'Wizard',
66    words: [
67      ['Conjuring', 'Conjured'], ['Enchanting', 'Enchanted'], ['Transmuting', 'Transmuted'], ['Brewing potions', 'Brewed potions'],
68      ['Scrying', 'Scried'], ['Summoning', 'Summoned'], ['Invoking', 'Invoked'], ['Spellcasting', 'Spellcast'],
69      ['Consulting the grimoire', 'Consulted the grimoire'], ['Charging the staff', 'Charged the staff'],
70      ['Reading the runes', 'Read the runes'], ['Warding', 'Warded'], ['Divining', 'Divined'], ['Levitating', 'Levitated'],
71      ['Polishing the orb', 'Polished the orb'], ['Stirring the cauldron', 'Stirred the cauldron'], ['Hexing bugs', 'Hexed bugs'],
72      ['Dispelling', 'Dispelled'], ['Channeling mana', 'Channeled mana'], ['Inscribing glyphs', 'Inscribed glyphs'],
73      ['Bewitching', 'Bewitched'], ['Alchemizing', 'Alchemized'], ['Gazing at the stars', 'Gazed at the stars'],
74      ['Whispering incantations', 'Whispered incantations'], ['Feeding the familiar', 'Fed the familiar'],
75      ['Tracing sigils', 'Traced sigils'], ['Opening a portal', 'Opened a portal'],
76    ],
77  },
78  {
79    id: 'chef',
80    emoji: '👩‍🍳',
81    title: 'Chef',
82    words: [
83      ['Simmering', 'Simmered'], ['Whisking', 'Whisked'], ['Braising', 'Braised'], ['Julienning', 'Julienned'],
84      ['Marinating', 'Marinated'], ['Kneading', 'Kneaded'], ['Flambéing', 'Flambéed'], ['Deglazing', 'Deglazed'],
85      ['Reducing', 'Reduced'], ['Plating', 'Plated'], ['Seasoning', 'Seasoned'], ['Proofing the dough', 'Proofed the dough'],
86      ['Folding', 'Folded'], ['Basting', 'Basted'], ['Caramelizing', 'Caramelized'], ['Blanching', 'Blanched'],
87      ['Emulsifying', 'Emulsified'], ['Tasting', 'Tasted'], ['Prepping the mise en place', 'Prepped the mise en place'],
88      ['Zesting', 'Zested'], ['Searing', 'Seared'], ['Glazing', 'Glazed'], ['Garnishing', 'Garnished'],
89      ['Tempering chocolate', 'Tempered chocolate'], ['Slow-roasting', 'Slow-roasted'], ['Fermenting', 'Fermented'],
90      ['Sharpening knives', 'Sharpened knives'],
91    ],
92  },
93  {
94    id: 'space',
95    emoji: '🚀',
96    title: 'Space',
97    words: [
98      ['Launching', 'Launched'], ['Orbiting', 'Orbited'], ['Calibrating thrusters', 'Calibrated thrusters'], ['Docking', 'Docked'],
99      ['Warping', 'Warped'], ['Charting stars', 'Charted stars'], ['Counting down', 'Counted down'],
100      ['Achieving liftoff', 'Achieved liftoff'], ['Slingshotting', 'Slingshotted'], ['Spacewalking', 'Spacewalked'],
101      ['Deploying solar panels', 'Deployed solar panels'], ['Pinging mission control', 'Pinged mission control'],
102      ['Re-entering', 'Re-entered'], ['Terraforming', 'Terraformed'], ['Scanning nebulae', 'Scanned nebulae'],
103      ['Aligning the dish', 'Aligned the dish'], ['Running telemetry', 'Ran telemetry'], ['Trimming the trajectory', 'Trimmed the trajectory'],
104      ['Firing boosters', 'Fired boosters'], ['Mapping craters', 'Mapped craters'], ['Collecting stardust', 'Collected stardust'],
105      ['Engaging hyperdrive', 'Engaged hyperdrive'], ['Escaping gravity', 'Escaped gravity'], ['Decoding signals', 'Decoded signals'],
106      ['Stargazing', 'Stargazed'], ['Rendezvousing', 'Rendezvoused'], ['Splashing down', 'Splashed down'],
107    ],
108  },
109  {
110    id: 'noir',
111    emoji: '🕵️',
112    title: 'Noir',
113    words: [
114      ['Sleuthing', 'Sleuthed'], ['Tailing a suspect', 'Tailed a suspect'], ['Dusting for prints', 'Dusted for prints'],
115      ['Following leads', 'Followed leads'], ['Working the case', 'Worked the case'], ['Shaking down sources', 'Shook down sources'],
116      ['Connecting the dots', 'Connected the dots'], ['Staking out', 'Staked out'], ['Interrogating', 'Interrogated'],
117      ['Reading the dossier', 'Read the dossier'], ['Lighting a match', 'Lit a match'], ['Pounding the pavement', 'Pounded the pavement'],
118      ['Brooding in the rain', 'Brooded in the rain'], ['Narrating', 'Narrated'], ['Checking alibis', 'Checked alibis'],
119      ['Cracking the case', 'Cracked the case'], ['Squinting through blinds', 'Squinted through blinds'],
120      ['Calling in a favor', 'Called in a favor'], ['Tipping the fedora', 'Tipped the fedora'], ['Sifting evidence', 'Sifted evidence'],
121      ['Pinning the board', 'Pinned the board'], ['Tracing the money', 'Traced the money'],
122      ['Rounding up the usual suspects', 'Rounded up the usual suspects'], ['Listening to the jazz', 'Listened to the jazz'],
123      ['Smelling a rat', 'Smelled a rat'], ['Closing in', 'Closed in'], ['Filing the report', 'Filed the report'],
124    ],
125  },
126  {
127    id: 'genz',
128    emoji: '✨',
129    title: 'Gen Z',
130    words: [
131      ['Cooking', 'Cooked'], ['Locking in', 'Locked in'], ['Vibing', 'Vibed'], ['Manifesting', 'Manifested'],
132      ['Slaying', 'Slayed'], ['Rizzing up the compiler', 'Rizzed up the compiler'], ['Glowing up', 'Glowed up'],
133      ['Understanding the assignment', 'Understood the assignment'], ['Main-charactering', 'Main-charactered'],
134      ['Speedrunning', 'Speedran'], ['Lowkey thinking', 'Lowkey thought'], ['Highkey shipping', 'Highkey shipped'],
135      ['Living rent-free', 'Lived rent-free'], ['Touching grass', 'Touched grass'], ['Sending it', 'Sent it'],
136      ['Eating', 'Ate'], ['Serving', 'Served'], ["Making it bussin'", "Made it bussin'"], ['Entering my era', 'Entered my era'],
137      ['Staying delulu', 'Stayed delulu'], ['Being so real', 'Was so real'], ['Giving', 'Gave'], ['Mogging bugs', 'Mogged bugs'],
138      ['Hard-launching', 'Hard-launched'], ['Soft-launching', 'Soft-launched'], ['Doomscrolling', 'Doomscrolled'], ['Yapping', 'Yapped'],
139    ],
140  },
141  {
142    id: 'zen',
143    emoji: '🍃',
144    title: 'Zen',
145    words: [
146      ['Breathing', 'Breathed'], ['Centering', 'Centered'], ['Raking the gravel', 'Raked the gravel'], ['Sipping tea', 'Sipped tea'],
147      ['Watching clouds', 'Watched clouds'], ['Letting go', 'Let go'], ['Being present', 'Was present'], ['Sitting with it', 'Sat with it'],
148      ['Balancing stones', 'Balanced stones'], ['Listening to rain', 'Listened to rain'], ['Folding paper cranes', 'Folded paper cranes'],
149      ['Pruning the bonsai', 'Pruned the bonsai'], ['Ringing the bell', 'Rang the bell'], ['Walking slowly', 'Walked slowly'],
150      ['Noticing', 'Noticed'], ['Emptying the cup', 'Emptied the cup'], ['Following the breath', 'Followed the breath'],
151      ['Settling', 'Settled'], ['Unfolding', 'Unfolded'], ['Meditating', 'Meditated'], ['Drifting', 'Drifted'], ['Grounding', 'Grounded'],
152      ['Returning to stillness', 'Returned to stillness'], ['Watering the garden', 'Watered the garden'], ['Lighting incense', 'Lit incense'],
153      ['Flowing', 'Flowed'], ['Sweeping the path', 'Swept the path'],
154    ],
155  },
156  {
157    id: 'retro',
158    emoji: '💾',
159    title: 'Retro',
160    words: [
161      ['Dialing up', 'Dialed up'], ['Defragmenting', 'Defragmented'], ['Rewinding the tape', 'Rewound the tape'],
162      ['Blowing on the cartridge', 'Blew on the cartridge'], ['Hacking the mainframe', 'Hacked the mainframe'], ['Compiling', 'Compiled'],
163      ['Buffering', 'Buffered'], ['Loading from cassette', 'Loaded from cassette'], ['Burning a CD', 'Burned a CD'],
164      ['Formatting the floppy', 'Formatted the floppy'], ['Reticulating splines', 'Reticulated splines'], ['Booting', 'Booted'],
165      ['Telnetting', 'Telnetted'], ['Pinging', 'Pinged'], ['Downloading more RAM', 'Downloaded more RAM'],
166      ['Adjusting the antenna', 'Adjusted the antenna'], ['Overclocking', 'Overclocked'], ['Saving to disk', 'Saved to disk'],
167      ['Entering cheat codes', 'Entered cheat codes'], ['Typing in BASIC', 'Typed in BASIC'], ['Waiting for the modem', 'Waited for the modem'],
168      ['Sorting the mixtape', 'Sorted the mixtape'], ['Clearing the cache', 'Cleared the cache'], ['Untangling the cables', 'Untangled the cables'],
169      ['Scanning for viruses', 'Scanned for viruses'], ['Writing to ROM', 'Wrote to ROM'], ['Inserting disk 2', 'Inserted disk 2'],
170    ],
171  },
172]
173
174export const PACK_IDS: readonly string[] = PACKS.map(p => p.id)
175
176export function packById(id: string): Pack | undefined {
177  return PACKS.find(p => p.id === id)
178}
179
180/**
181 * Parses the `custom` option: comma-separated words, each optionally
182 * `Present|Past` (`Frobbing|Frobbed`) to theme the closing line too.
183 */
184export function parseCustom(text: string): Pack | undefined {
185  const words: Pair[] = text
186    .split(',')
187    .map(w => w.trim())
188    .filter(w => w !== '')
189    .map(w => {
190      const [present, past] = w.split('|').map(s => s.trim())
191      return [present ?? w, past !== undefined && past !== '' ? past : null] as const
192    })
193    .filter(([present]) => present !== '')
194  if (words.length === 0) return undefined
195  return { id: 'custom', emoji: '🎨', title: 'Custom', words }
196}
197
198/** A small, stable string hash (FNV-1a), so a seed always picks the same word. */
199export function hash(text: string): number {
200  let h = 0x811c9dc5
201  for (let i = 0; i < text.length; i++) {
202    h ^= text.charCodeAt(i)
203    h = Math.imul(h, 0x01000193)
204  }
205  return h >>> 0
206}
207
208/** Which pair a seed picks (one per turn and spinner). */
209export function indexFor(pack: Pack, seed: string): number {
210  return hash(seed) % pack.words.length
211}
212
213/** The word the spinner shows for this seed. */
214export function presentFor(pack: Pack, seed: string): string {
215  return pack.words[indexFor(pack, seed)]![0]
216}
217
218/** The past form of the pair at `index`, so the closing line echoes the spinner. */
219export function pastAt(pack: Pack, index: number): string | undefined {
220  return pack.words[index % pack.words.length]?.[1] ?? undefined
221}
222
223/** The past form for the closing line, or undefined when the pack has none for it. */
224export function pastFor(pack: Pack, seed: string): string | undefined {
225  const withPast = pack.words.filter(([, past]) => past !== null)
226  if (withPast.length === 0) return undefined
227  return withPast[hash(seed) % withPast.length]![1] ?? undefined
228}
229
230/** Three sample words for the /spinner listing. */
231export function samples(pack: Pack, count = 3): string[] {
232  const step = Math.max(1, Math.floor(pack.words.length / count))
233  return Array.from({ length: Math.min(count, pack.words.length) }, (_, i) => pack.words[i * step]![0])
234}
235