Spank Claude when it messes up: a cartoon butt above the prompt, eight slappers, nine butts, marks, combos and sounds; your next message tells Claude how it…

Spank Claude when it messes up: a stress-relief mod for Claude Code.
<img src="docs/teaser.gif" alt="A real Claude Code desktop session: above the prompt box a cartoon butt with an OpenAI tattoo gets slapped by hand, slipper, crop, ruler, frying pan, swatter and belt, marks appear, insults pop up, the butt changes to shorts, boxers, jeans, a peach and a Claude tattoo" width="760">
slapbox is a Claude Code mod that puts a cartoon butt above your prompt. When Claude breaks your build, ignores your instructions or invents an API, click the butt or grab one of eight slappers. Every slap swings, jiggles, leaves a mark and makes a sound. With your next message Claude gets one quiet line: "The user is unhappy and spanked you: frying pan ×2, slipper ×1 — 3 slaps in total." Works in the terminal and the desktop Code tab, in English and Russian.
/slap lang pins itPaste this into Claude Code (terminal or the desktop Code tab):
Install the Claude Code mod slapbox from https://github.com/timoncool/slapbox. In the shell run `claude plugin marketplace add timoncool/slapbox`, then `claude plugin install slapbox@slapbox`, and check that `claude plugin list` shows slapbox enabled. Then tell me to run /reload-plugins (or restart Claude Code) so the slap box appears above the prompt.
Or by hand, in any terminal:
claude plugin marketplace add timoncool/slapbox
claude plugin install slapbox@slapbox
Mods need Claude Code 2.1.287+ in the terminal and 2.1.286+ in the desktop Code tab. Sounds play on Windows; elsewhere the box is silent.
Click Spank Claude above the prompt to open the box. Click the butt for a hand slap, or click a tool. Send your next message as usual: Claude reads the tally with it, and the counter starts over.
<img src="docs/screenshots/tools-en.png" alt="Eight slappers striking the butt: hand, slipper, whip, crop, belt, ruler, swatter, pan, each with an insult" width="860">
| Command | What it does | ||
|---|---|---|---|
/slap | Hide or show the box | ||
/slap mute | Sound on or off (also the Sound button and key 0) | ||
/slap reset | Forget the slaps before Claude hears about them | ||
/slap skin [name] | Next butt, or a named one: /slap skin peach | ||
| `/slap lang en\ | ru\ | auto` | Pin the language, or follow your messages again |
What Claude receives with your message (and nothing else):
The user is unhappy and spanked you: hand ×4, belt ×1, frying pan ×2 — 7 slaps in total.
<img src="docs/screenshots/skins-en.png" alt="Nine butts: thong, no tattoo, Claude tattoo, OpenAI tattoo, shorts, boxers, jeans, robot, peach" width="700">
Folded, the box is one line:
<img src="docs/screenshots/collapsed-en.png" alt="The folded box: a small butt and Spank Claude · 3" width="190">
The same recording with sound (Russian UI): docs/teaser.mp4.
| Setting | Values | Where |
|---|---|---|
| Language | auto (default), en, ru | /config in a terminal, or /slap lang anywhere |
The butt, the language you pin and the sound switch are remembered across sessions.
One hooks module draws the band above the prompt (ui.render on AbovePrompt), takes clicks through a small surface module, keeps the count in session state and adds the tally to prompt.submit as context only the model reads. The art is SVG built in code; sounds are WAV files synthesized by scripts/sounds.py and played with System.Media.SoundPlayer. Run the tests with claude plugin test ..
| Project | Description |
|---|---|
| givememod | Say /givememod and Claude Code builds a mod for the task in front of you |
| Angry Switcher | Swear at Claude Code all you like, it reads the clean version |
| satori | Self-learning loop for Claude Code: corrections become skills |
| telegram-api-mcp | Full Telegram Bot API as MCP server |
| civitai-mcp-ultimate | Civitai API as MCP server |
| ACE-Step Studio | AI music studio — songs, vocals, covers, videos |
| VideoSOS | AI video production in the browser |
I build open-source software and do AI research. Most of what I create is free and available to everyone. Your donations help me keep creating without worrying about where the next meal comes from =)
All donation methods | dalink.to/nerual_dreming | boosty.to/neuro_art
1E7dHL22RpyhJGVpcvKdbyZgksSYkYeEBC0xb5db65adf478983186d4897ba92fe2c25c594a0cTQST9Lp2TjK6FiVkn4fwfGUee7NmkxEE7C<a href="https://github.com/timoncool/slapbox/stargazers"> <img alt="Star History Chart" src="docs/stars-light.svg" /> </a>
MIT. The peach keeps its CC-BY 4.0 licence; the Claude and OpenAI logos remain trademarks of their owners.
hooks/register.tsx 319 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Board, Lang, Prefs, Skin, Tally, Tool } from '../types'
5
6import { SKIN_IDS, miniButt, pixelRows, scene, toolIcon, uiIcon } from './art'
7import { TEXT, detectLang, slapReport } from './i18n'
8import { TOOL_ART, TOOL_IDS } from './tools'
9
10const MAX_MARKS = 10
11const HIT_MS = 1500
12const COMBO_MS = 900
13const ART = { desktop: { columns: 45, rows: 12, width: 360, height: 138 }, terminal: { columns: 18, rows: 4 } }
14const TILE = { columns: 16, rows: 3, gap: 1, iconPx: 20, minPerRow: 2, maxPerRow: 4 }
15const CHIP_ICON_PX = 18
16const SMALL_ICON_PX = 14
17const SKIN_HOTKEY = '9'
18const SOUND_HOTKEY = '0'
19const ACTIONS: readonly string[] = [...TOOL_IDS, 'skin', 'sound', 'open', 'close']
20
21const EMPTY = Object.fromEntries(TOOL_IDS.map(id => [id, 0])) as Tally
22
23const board = atom({ plugin: 'slapbox', key: 'board' } as const, { pending: EMPTY, session: 0, hit: null, marks: [] } as Board)
24const prefs = atom({ plugin: 'slapbox', key: 'prefs' } as const, { skin: 'thong', lang: 'en', isLangPinned: false, isMuted: false } as Prefs)
25const isPrefsLoaded = atom({ plugin: 'slapbox', key: 'isPrefsLoaded' } as const, false)
26const isHidden = atom({ plugin: 'slapbox', key: 'isHidden' } as const, false)
27const isOpen = atom({ plugin: 'slapbox', key: 'isOpen' } as const, false)
28
29let isWindows = false
30let forcedLang: Lang | null = null
31const lastSeq = new Map<string, unknown>()
32
33function tally(t: Partial<Tally> | undefined): Tally {
34 return Object.fromEntries(TOOL_IDS.map(id => [id, t?.[id] ?? 0])) as Tally
35}
36
37function total(t: Tally): number {
38 return TOOL_IDS.reduce((sum, id) => sum + t[id], 0)
39}
40
41function nextSkin(skin: Skin): Skin {
42 return SKIN_IDS[(SKIN_IDS.indexOf(skin) + 1) % SKIN_IDS.length] ?? 'thong'
43}
44
45function langOf(p: Prefs): Lang {
46 return forcedLang ?? p.lang
47}
48
49async function loadPrefs($: EngineInterface): Promise<void> {
50 const saved = (await $.store.get('prefs').catch(() => undefined)) as Partial<Prefs> | undefined
51 const env = (await $.env.get('LANG').catch(() => undefined)) ?? ''
52 await update($, prefs, () => ({
53 skin: SKIN_IDS.find(id => id === saved?.skin) ?? 'thong',
54 lang: saved?.lang === 'ru' || saved?.lang === 'en' ? saved.lang : /^ru/i.test(env) ? 'ru' : 'en',
55 isLangPinned: saved?.isLangPinned === true,
56 isMuted: saved?.isMuted === true,
57 }))
58 await update($, isPrefsLoaded, () => true)
59}
60
61async function savePrefs($: EngineInterface, change: (p: Prefs) => Prefs): Promise<Prefs> {
62 const next = await update($, prefs, change)
63 await $.store.set('prefs', next).catch(err => $.ui.log(`slapbox: prefs not saved: ${String(err)}`, { to: 'debug' }))
64 return next
65}
66
67async function slap($: EngineInterface, tool: Tool): Promise<void> {
68 const at = await $.clock.now()
69 await update($, board, b => {
70 const n = b.session + 1
71 const combo = b.hit && at - b.hit.at < COMBO_MS ? (b.hit.combo ?? 1) + 1 : 1
72 const pending = tally(b.pending)
73 return { pending: { ...pending, [tool]: pending[tool] + 1 }, session: n, hit: { tool, n, at, combo }, marks: [...b.marks, { tool, n }].slice(-MAX_MARKS) }
74 })
75 if (isWindows && !(await read($, prefs)).isMuted) {
76 const wav = `${$.plugin.root}/hooks/sounds/${tool}.wav`
77 void $.process.run(['powershell', '-NoProfile', '-Command', `(New-Object System.Media.SoundPlayer '${wav}').PlaySync()`], { timeoutMs: 10_000 }).catch(() => {})
78 }
79}
80
81async function act($: EngineInterface, action: string): Promise<void> {
82 const tool = TOOL_IDS.find(id => id === action)
83 if (tool) await slap($, tool)
84 else if (action === 'skin') await savePrefs($, p => ({ ...p, skin: nextSkin(p.skin) }))
85 else if (action === 'sound') await savePrefs($, p => ({ ...p, isMuted: !p.isMuted }))
86 else if (action === 'open' || action === 'close') await update($, isOpen, () => action === 'open')
87}
88
89function pressing($: EngineInterface, action: string): () => void {
90 return () => void act($, action).catch(err => $.ui.log(`slapbox: ${action} failed: ${String(err)}`, { to: 'debug' }))
91}
92
93export const register: Register = (on, options) => {
94 forcedLang = options.language === 'ru' || options.language === 'en' ? options.language : null
95
96 on('session.start', async ($, e, next) => {
97 const r = await next(e)
98 isWindows = (await $.env.get('OS').catch(() => undefined)) === 'Windows_NT'
99 await loadPrefs($)
100 await $.command
101 .register({ name: 'slap', description: 'Slap box: /slap hides or shows it; /slap mute, /slap reset, /slap skin [name], /slap lang [en|ru|auto]', argumentHint: '[mute|reset|skin|lang]', immediate: true })
102 .catch(err => $.ui.log(`slapbox: /slap not registered: ${String(err)}`, { to: 'debug' }))
103 return r
104 })
105
106 on('turn.start', async ($, e, next) => {
107 if (!(await read($, isPrefsLoaded))) await loadPrefs($)
108 return next(e)
109 })
110
111 on('prompt.submit', async ($, e, next) => {
112 if (!(await read($, isPrefsLoaded))) await loadPrefs($)
113 let p = await read($, prefs)
114 const detected = forcedLang || p.isLangPinned ? null : detectLang(e.text ?? '')
115 if (detected && detected !== p.lang) p = await savePrefs($, q => ({ ...q, lang: detected }))
116 const t = tally((await read($, board)).pending)
117 if (total(t) === 0) return next(e)
118 await update($, board, b => ({ ...b, pending: EMPTY, hit: null, marks: [] }))
119 return next({ ...e, context: [...(e.context ?? []), slapReport(langOf(p), t)] })
120 })
121
122 on('ui.message', async ($, e, next) => {
123 const data = e.data as { action?: unknown; seq?: unknown } | null
124 const action = typeof data?.action === 'string' ? data.action : ''
125 if (!ACTIONS.includes(action)) return next(e)
126 if (lastSeq.get(e.element) === data?.seq) return {}
127 lastSeq.set(e.element, data?.seq)
128 await act($, action)
129 return {}
130 })
131
132 on('command.run', { command: 'slap' }, async ($, e) => {
133 const [verb = '', ...rest] = e.args.trim().toLowerCase().split(/\s+/)
134 const p = await read($, prefs)
135 const s = TEXT[langOf(p)]
136 if (verb === 'skin') {
137 const want = rest.join(' ')
138 const found = want ? SKIN_IDS.find(id => id === want || TEXT.ru.skin[id].toLowerCase() === want || TEXT.en.skin[id].toLowerCase() === want) : nextSkin(p.skin)
139 if (!found) return { text: s.skinList(SKIN_IDS.map(id => s.skin[id]).join(', ')) }
140 await savePrefs($, q => ({ ...q, skin: found }))
141 return { text: s.skinSet(s.skin[found]) }
142 }
143 if (verb === 'lang') {
144 const want = rest[0]
145 if (want !== 'ru' && want !== 'en' && want !== 'auto') return { text: s.langUsage }
146 const next = await savePrefs($, q => (want === 'auto' ? { ...q, isLangPinned: false } : { ...q, lang: want, isLangPinned: true }))
147 return { text: TEXT[langOf(next)].langSet(next.lang, next.isLangPinned) }
148 }
149 if (verb === 'mute') {
150 const next = await savePrefs($, q => ({ ...q, isMuted: !q.isMuted }))
151 return { text: s.muted(next.isMuted) }
152 }
153 if (verb === 'reset') {
154 await update($, board, b => ({ ...b, pending: EMPTY, hit: null, marks: [] }))
155 return { text: s.reset }
156 }
157 const hidden = await update($, isHidden, h => !h)
158 return { text: hidden ? s.hidden : s.shown }
159 })
160
161 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
163 const below = await next(e)
164 const b = await read($, board)
165 const p = await read($, prefs)
166 const now = await $.clock.now()
167 const lang = langOf(p)
168 const s = TEXT[lang]
169 const pending = total(tally(b.pending))
170 const redness = Math.min(1, pending / 10)
171 const live = b.hit && now - b.hit.at < HIT_MS ? { ...b.hit, combo: b.hit.combo ?? 1 } : null
172 const els = $.ui.resolve(e)
173 const { Box, Text, Button } = els
174 const Client = 'Client' in els ? els.Client : null
175 const Svg = 'Svg' in els ? els.Svg : null
176
177 const overlay = (key: string, action: string, columns: number, rows: number) =>
178 Client ? (
179 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
180 <Client key={`click-${key}`} module="./clicker.tsx" width={columns} height={rows} props={{ action, columns, rows }} />
181 </Box>
182 ) : null
183 const chip = (key: string, action: string, columns: number, icon: RenderChildren, label: RenderChildren) => (
184 <Box key={key} position="relative" flexShrink={0} width={columns}>
185 <Box width={columns} flexDirection="row" alignItems="center" columnGap={1} paddingX={1} backgroundColor="#2a2a2a" hover={{ backgroundColor: '#3a3a3a' }}>
186 {icon}
187 {label}
188 </Box>
189 {overlay(key, action, columns, 1)}
190 </Box>
191 )
192
193 if (!(await read($, isOpen))) {
194 const count = pending > 0 ? ` · ${pending}` : ''
195 return (
196 <Box flexDirection="column">
197 {chip(
198 'open',
199 'open',
200 s.open.length + count.length + 7,
201 Svg ? <Svg source={miniButt(p.skin, redness, CHIP_ICON_PX)} alt={s.open} width={CHIP_ICON_PX} height={CHIP_ICON_PX} /> : <Text>🍑</Text>,
202 <Text>
203 {s.open}
204 <Text dimColor>{count}</Text>
205 </Text>,
206 )}
207 {below}
208 </Box>
209 )
210 }
211
212 const art = Svg ? (
213 <Box key="art" position="relative" flexShrink={0}>
214 <Svg source={scene(p.skin, redness, b.marks, live, lang)} alt={s.artAlt} width={ART.desktop.width} height={ART.desktop.height} isInteractive />
215 {Client ? (
216 <Box position="absolute" top={0} left={0} width={ART.desktop.columns} height={ART.desktop.rows}>
217 <Client key="clicker" module="./clicker.tsx" width={ART.desktop.columns} height={ART.desktop.rows} props={{ action: 'hand', columns: ART.desktop.columns, rows: ART.desktop.rows }} />
218 </Box>
219 ) : null}
220 </Box>
221 ) : (
222 <Box key="art" position="relative" flexDirection="column" flexShrink={0}>
223 {pixelRows(p.skin, redness).map((row, y) => (
224 <Text key={`px${y}`}>
225 {row.map((c, x) => (
226 <Text key={`p${y}-${x}`} color={c.fg} backgroundColor={c.bg}>{c.ch}</Text>
227 ))}
228 </Text>
229 ))}
230 {Client ? (
231 <Box position="absolute" top={0} left={0} width={ART.terminal.columns} height={ART.terminal.rows}>
232 <Client key="clicker" module="./clicker.tsx" width={ART.terminal.columns} height={ART.terminal.rows} props={{ action: 'hand', columns: ART.terminal.columns, rows: ART.terminal.rows }} />
233 </Box>
234 ) : null}
235 </Box>
236 )
237
238 const tile = (key: string, action: string, label: string, emoji: string, icon: string, isDim = false) => (
239 <Box key={key} position="relative" flexShrink={0} width={TILE.columns}>
240 <Box
241 width={TILE.columns}
242 flexDirection="row"
243 alignItems="center"
244 columnGap={1}
245 paddingLeft={1}
246 borderStyle="round"
247 borderColor="#3a3a3a"
248 hover={{ borderColor: '#d97757', backgroundColor: '#2e2e2e' }}
249 >
250 {Svg ? <Svg source={icon} alt={label} width={TILE.iconPx} height={TILE.iconPx} /> : <Text>{emoji}</Text>}
251 <Text dimColor={isDim} wrap="truncate-end">{label}</Text>
252 </Box>
253 {overlay(key, action, TILE.columns, TILE.rows)}
254 </Box>
255 )
256 const tiles = TOOL_IDS.map(id => tile(`tile-${id}`, id, s.tool[id].label, TOOL_ART[id].emoji, toolIcon(id, TILE.iconPx)))
257 const artColumns = Svg ? ART.desktop.columns : ART.terminal.columns
258 const fit = Math.floor((e.props.bodyColumns - artColumns - 2 + TILE.gap) / (TILE.columns + TILE.gap))
259 const perRow = Math.max(TILE.minPerRow, Math.min(TILE.maxPerRow, fit))
260 const rows: (typeof tiles)[] = []
261 for (let i = 0; i < tiles.length; i += perRow) rows.push(tiles.slice(i, i + perRow))
262 const grid = (
263 <Box key="grid" flexDirection="column" flexShrink={0}>
264 {rows.map((row, i) => (
265 <Box key={`grid-row-${i}`} flexDirection="row" columnGap={TILE.gap}>
266 {row}
267 </Box>
268 ))}
269 </Box>
270 )
271 const hotkeys = (
272 <Box key="hotkeys" display="none">
273 {TOOL_IDS.map((id, i) => (
274 <Button key={`tool-${id}`} plain hotkey={String(i + 1)} label={s.tool[id].label} onPress={pressing($, id)} />
275 ))}
276 <Button key="skin" plain hotkey={SKIN_HOTKEY} label={s.skin[p.skin]} onPress={pressing($, 'skin')} />
277 <Button key="sound" plain hotkey={SOUND_HOTKEY} label={s.sound(p.isMuted)} onPress={pressing($, 'sound')} />
278 </Box>
279 )
280 const stats = (
281 <Text key="stats" dimColor wrap="truncate-end">
282 {pending > 0 ? s.pending(pending, b.session) : b.session > 0 ? s.told(b.session) : s.idle}
283 </Text>
284 )
285 return (
286 <Box flexDirection="column">
287 <Box flexDirection="row" columnGap={2} alignItems="center">
288 {art}
289 {grid}
290 </Box>
291 {hotkeys}
292 <Box flexDirection="row" columnGap={1}>
293 {chip('close', 'close', s.close.length + 2, null, <Text dimColor>{s.close}</Text>)}
294 {chip(
295 'skin',
296 'skin',
297 s.skin[p.skin].length + 5,
298 Svg ? <Svg source={uiIcon('refresh', SMALL_ICON_PX, '#5aa9ff')} alt={s.skin[p.skin]} width={SMALL_ICON_PX} height={SMALL_ICON_PX} /> : <Text>🔄</Text>,
299 <Text>{s.skin[p.skin]}</Text>,
300 )}
301 {chip(
302 'sound',
303 'sound',
304 s.sound(p.isMuted).length + 5,
305 Svg ? (
306 <Svg source={uiIcon(p.isMuted ? 'volume-off' : 'volume', SMALL_ICON_PX, p.isMuted ? '#8a8a8a' : '#e6e6e6')} alt={s.sound(p.isMuted)} width={SMALL_ICON_PX} height={SMALL_ICON_PX} />
307 ) : (
308 <Text>{p.isMuted ? '🔇' : '🔊'}</Text>
309 ),
310 <Text dimColor={p.isMuted}>{s.sound(p.isMuted)}</Text>,
311 )}
312 <Box marginLeft={1}>{stats}</Box>
313 </Box>
314 {below}
315 </Box>
316 )
317 })
318}
319hooks/art.ts 240 lines1import type { Hit, Lang, Mark, Skin, Tool } from '../types'
2
3import { TEXT, word } from './i18n'
4import { TOOL_ART } from './tools'
5
6export const SKIN_IDS: readonly Skin[] = ['thong', 'plain', 'claude', 'openai', 'shorts', 'boxers', 'jeans', 'robot', 'peach']
7
8const CHEEK = { L: { x: 83, y: 56 }, R: { x: 143, y: 56 } }
9const FIT = 'translate(50 -14) scale(.25)'
10const PEACH_FIT = 'translate(63 1) scale(2.7)'
11
12const SHAPE_L = 'M31.548 100.594c-70.983 99.372 0 202.62 0 202.62L57.36 441.312h180.983l13.895-209.081C226.426 68.329 31.548 100.594 31.548 100.594'
13const SHAPE_R = 'M472.932 100.594c0 0-194.878-32.265-220.69 131.637l12.588 209.081h182.29l25.812-138.099C472.932 303.214 543.914 199.966 472.932 100.594'
14const BAND = 'M70.335 63.167c-22.943 0-41.543 18.6-41.543 41.543c.927-1.368 1.774-2.745 2.754-4.114c0 0 194.878-32.265 220.69 131.637c25.812-163.902 220.698-131.637 220.698-131.637c1.368 1.924 2.595 3.849 3.866 5.782v-1.668c0-22.943-18.6-41.543-41.543-41.543C435.257 63.167 70.335 63.167 70.335 63.167z'
15const CREASE = 'M319.946 331.091c-33.643-17.275-56.144-52.33-58.913-90.81c.167-2.67.264-5.352.264-8.046c0-4.694-3.806-8.499-8.5-8.499c-.094 0-.184.011-.277.014c-.093-.003-.184-.014-.277-.014c-4.693 0-8.499 3.805-8.5 8.499c-.001 2.695.096 5.379.264 8.05c-2.77 38.479-25.271 73.533-58.912 90.807c-4.176 2.145-5.823 7.268-3.679 11.444c1.506 2.934 4.483 4.619 7.568 4.619c1.307 0 2.633-.303 3.875-.94c27.499-14.119 48.551-38.469 59.662-67.189c11.111 28.72 32.164 53.069 59.662 67.189c1.243.638 2.569.94 3.876.94c3.085 0 6.063-1.686 7.568-4.619C325.769 338.358 324.122 333.235 319.946 331.091z'
16const HEART = 'M406.091 217.973c.794-6.718 5.464-12.65 12.341-14.733c9.278-2.807 18.273 2.834 21.875 11.714c0 0 1.139 2.127 1.377 6.515c.327 5.967-1.377 11.864-4.537 16.931l-16.852 26.527l-28.337-12.853c-5.447-2.463-10.134-6.435-13.171-11.582c-2.233-3.778-2.463-6.179-2.463-6.179c-1.924-9.384 2.436-19.068 11.705-21.875C394.915 210.355 401.704 212.826 406.091 217.973'
17const PEACH_BODY = 'M18 6s1.042-.896 6-.896c6.542 0 12 4.812 12 12.927 0 11.531-14.958 17.881-14.958 17.881S1 34.833 1 17.977C1 8.018 7.75 5 12 5c4.958 0 6 1 6 1z'
18const PEACH_LEAF_BACK = 'M1.062 5.125s4.875-5 10-5C17.188.125 19 5.062 19 5.062s.625-4 5-4 6.938 3.125 6.938 3.125-3.562 2.125-4.625 2.562c-2.801 1.153-11.375 3.562-15.375 2.562S1.062 5.125 1.062 5.125z'
19const PEACH_LEAF_FRONT = 'M1.062 5.125s4.875-5 10-5C17.188.125 19 5.062 19 5.062s-4.062 5.25-8.062 4.25-9.876-4.187-9.876-4.187z'
20const PEACH_CREASE = 'M22.999 30c-.19 0-.383-.055-.554-.168-.46-.307-.584-.927-.277-1.387C22.183 28.423 24 25.538 24 19c0-6.445-4.578-10.182-4.625-10.219-.431-.345-.501-.974-.156-1.405.346-.431.975-.501 1.406-.156C20.844 7.395 26 11.604 26 19c0 7.22-2.079 10.422-2.168 10.555-.192.289-.51.445-.833.445z'
21
22const CLAUDE_LOGO = 'M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z'
23const OPENAI_LOGO = 'M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z'
24
25const POCKET_L = 'M66 31 L95 29 L94 49 L80 55 L67 50 Z'
26const POCKET_R = 'M160 31 L131 29 L132 49 L146 55 L159 50 Z'
27
28const TABLER = {
29 refresh: '<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"/><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>',
30 volume: '<path d="M15 8a5 5 0 0 1 0 8"/><path d="M17.7 5a9 9 0 0 1 0 14"/><path d="M6 15h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l3.5 -4.5a.8 .8 0 0 1 1.5 .5v14a.8 .8 0 0 1 -1.5 .5l-3.5 -4.5"/>',
31 'volume-off': '<path d="M15 8a5 5 0 0 1 1.912 4.934m-1.377 2.602a5 5 0 0 1 -.535 .464"/><path d="M17.7 5a9 9 0 0 1 2.362 11.086m-1.676 2.299a9 9 0 0 1 -.686 .615"/><path d="M9.069 5.054l.431 -.554a.8 .8 0 0 1 1.5 .5v2m0 4v8a.8 .8 0 0 1 -1.5 .5l-3.5 -4.5h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l1.294 -1.664"/><path d="M3 3l18 18"/>',
32}
33
34type Palette = { base: number[]; hot: number[]; light: number[]; edgeK: number; mark: string; blush: number }
35
36const PALETTES: Record<Skin, Palette> = {
37 thong: { base: [246, 204, 182], hot: [255, 112, 102], light: [255, 244, 236], edgeK: 0.82, mark: '#dd2a24', blush: 1 },
38 plain: { base: [246, 204, 182], hot: [255, 112, 102], light: [255, 244, 236], edgeK: 0.82, mark: '#dd2a24', blush: 1 },
39 claude: { base: [246, 204, 182], hot: [255, 112, 102], light: [255, 244, 236], edgeK: 0.82, mark: '#dd2a24', blush: 1 },
40 openai: { base: [246, 204, 182], hot: [255, 112, 102], light: [255, 244, 236], edgeK: 0.82, mark: '#dd2a24', blush: 1 },
41 shorts: { base: [255, 214, 32], hot: [255, 120, 40], light: [255, 246, 190], edgeK: 0.84, mark: '#d4231c', blush: 0.45 },
42 boxers: { base: [246, 204, 182], hot: [255, 112, 102], light: [255, 244, 236], edgeK: 0.82, mark: '#dd2a24', blush: 1 },
43 jeans: { base: [74, 112, 168], hot: [128, 76, 150], light: [150, 186, 230], edgeK: 0.72, mark: '#101c36', blush: 0.3 },
44 robot: { base: [196, 205, 216], hot: [255, 140, 70], light: [246, 249, 252], edgeK: 0.62, mark: '#2b3138', blush: 0.4 },
45 peach: { base: [255, 150, 116], hot: [236, 64, 64], light: [255, 214, 190], edgeK: 0.8, mark: '#b8202a', blush: 0.5 },
46}
47
48const PIXELS = [
49 '..BBBBBBBBBBBBBB..',
50 '.BBBBBBBBBBBBBBBB.',
51 '.SSSSSSSDSSSSSSSS.',
52 'SSSSSSSSDDSSSSSSSS',
53 'SSSSSSSSDDSSSSSSSS',
54 'SSSSSSSSDDSSSSSSSS',
55 '.SSSSSS.DD.SSSSSS.',
56 '..SSSS......SSSS..',
57]
58
59function mixColor(a: number[], b: number[], t: number, k = 1): string {
60 const c = Math.max(0, Math.min(1, t))
61 return '#' + a.map((v, i) => Math.round(Math.min(255, (v + ((b[i] ?? v) - v) * c) * k)).toString(16).padStart(2, '0')).join('')
62}
63
64function side(n: number): 'L' | 'R' {
65 return n % 2 ? 'R' : 'L'
66}
67
68function spot(n: number): { x: number; y: number; rot: number } {
69 const c = CHEEK[side(n)]
70 return { x: c.x + ((n * 37) % 15) - 7 + (side(n) === 'L' ? -2 : 2), y: c.y + 6 + ((n * 53) % 11) - 2, rot: ((n * 71) % 70) - 35 }
71}
72
73function escapeText(text: string): string {
74 return text.replace(/&/g, '&').replace(/</g, '<')
75}
76
77function markAt(m: Mark, color: string, isFresh: boolean): string {
78 const p = spot(m.n)
79 const art = TOOL_ART[m.tool]
80 const paint = art.isStrokeMark ? `fill="none" stroke="${color}"` : `fill="${color}"`
81 return `<g class="${isFresh ? 'fresh' : 'old'}" transform="translate(${p.x} ${p.y}) rotate(${p.rot}) scale(.9)" ${paint}>${art.mark}</g>`
82}
83
84function burst(text: string, combo: string | null): string {
85 const rx = Math.max(15, text.length * 3.8 + 9)
86 const ry = 21
87 const pts: string[] = []
88 for (let i = 0; i < 18; i++) {
89 const a = (i / 18) * Math.PI * 2
90 const r = i % 2 ? 0.72 : 1.08
91 pts.push(`${(Math.cos(a) * rx * r).toFixed(1)},${(Math.sin(a) * ry * r).toFixed(1)}`)
92 }
93 const badge = combo
94 ? `<text x="0" y="34" text-anchor="middle" font-family="Impact,'Arial Black',sans-serif" font-size="11" fill="#fff" stroke="#e8231d" stroke-width="3" paint-order="stroke">${escapeText(combo)}</text>`
95 : ''
96 return `<polygon points="${pts.join(' ')}" fill="#ffd23f" stroke="#ff8a1f" stroke-width="1.5"/>` +
97 `<text x="0" y="4.5" text-anchor="middle" font-family="Impact,'Arial Black',sans-serif" font-size="13" fill="#e8231d" stroke="#fff" stroke-width="2.6" paint-order="stroke">${escapeText(text)}</text>` +
98 badge
99}
100
101function rivets(): string {
102 const spots: [number, number][] = [[66, 16], [84, 13], [102, 15], [124, 15], [142, 13], [160, 16], [62, 72], [72, 84], [154, 72], [150, 84]]
103 return spots.map(([x, y]) => `<circle cx="${x}" cy="${y}" r="1.6" fill="#7f8a96"/><circle cx="${x - 0.5}" cy="${y - 0.5}" r=".6" fill="#fff" opacity=".7"/>`).join('')
104}
105
106function tattoo(d: string, ink: string): string {
107 return `<g transform="translate(151 45) rotate(-12) scale(.68) translate(-12 -12)" opacity=".88"><path d="${d}" fill="${ink}" fill-rule="evenodd"/></g>`
108}
109
110function pocket(d: string): string {
111 return `<path d="${d}" fill="#000" opacity=".1"/><path d="${d}" fill="none" stroke="#e0a84a" stroke-width=".9" stroke-dasharray="2 1.4"/>`
112}
113
114function buttBody(skin: Skin, edge: string, left: string, right: string): string {
115 const cheek = (shape: string, grad: string, clip: string, marks: string, gx: number, extra: string) =>
116 `<g><path transform="${FIT}" d="${shape}" fill="url(#${grad})"/>${extra}<g clip-path="url(#${clip})">${marks}</g>` +
117 `<ellipse cx="${gx}" cy="44" rx="8" ry="5" fill="#fff" opacity="${skin === 'robot' ? '.55' : '.32'}" filter="url(#glow)"/></g>`
118 const boxers = (clip: string) => `<path clip-path="url(#${clip})" d="M40 0 H190 V50 Q150 63 113 54 Q76 63 36 50 Z" fill="url(#dots)"/>`
119 const heart = skin === 'thong' || skin === 'shorts' ? `<path transform="${FIT}" d="${HEART}" fill="${skin === 'shorts' ? '#db4d6c' : '#f30043'}" opacity=".92"/>` : ''
120 const band = skin === 'thong' || skin === 'plain' || skin === 'claude' || skin === 'openai' ? 'url(#pants)' : skin === 'shorts' ? '#f6b800' : skin === 'boxers' ? '#24479a' : skin === 'jeans' ? '#34548c' : '#3a434e'
121 const extraL = skin === 'boxers' ? boxers('cl') : skin === 'jeans' ? pocket(POCKET_L) : ''
122 const ink = skin === 'claude' ? tattoo(CLAUDE_LOGO, '#c4552f') : skin === 'openai' ? tattoo(OPENAI_LOGO, '#1b1b1b') : ''
123 const extraR = (skin === 'boxers' ? boxers('cr') : skin === 'jeans' ? pocket(POCKET_R) : '') + heart + ink
124 const top = skin === 'robot'
125 ? rivets() + '<circle cx="113" cy="10" r="4.5" fill="#5cf08a" opacity=".3"/><circle cx="113" cy="10" r="2.4" fill="#5cf08a"/>'
126 : skin === 'jeans'
127 ? '<path d="M62 24 Q113 33 164 24" fill="none" stroke="#e0a84a" stroke-width=".9" stroke-dasharray="2 1.4"/><rect x="134" y="6" width="15" height="8" rx="1.5" fill="#a86d34"/>'
128 : ''
129 return cheek(SHAPE_L, 'sl', 'cl', left, 66, extraL) +
130 cheek(SHAPE_R, 'sr', 'cr', right, 160, extraR) +
131 `<path transform="${FIT}" d="${CREASE}" fill="${skin === 'robot' ? '#4a525c' : skin === 'jeans' ? '#e0a84a' : edge}" opacity=".9"/>` +
132 `<path transform="${FIT}" d="${BAND}" fill="${band}"/>` +
133 top
134}
135
136function peachBody(edge: string, left: string, right: string): string {
137 return `<g transform="${PEACH_FIT}"><path d="${PEACH_LEAF_BACK}" fill="#5c913b"/><path d="${PEACH_BODY}" fill="url(#sl)"/></g>` +
138 `<g clip-path="url(#pb)">${left}${right}</g>` +
139 `<ellipse cx="88" cy="40" rx="9" ry="6" fill="#fff" opacity=".3" filter="url(#glow)"/>` +
140 `<g transform="${PEACH_FIT}"><path d="${PEACH_LEAF_FRONT}" fill="#77b255"/><path d="${PEACH_CREASE}" fill="${edge}"/></g>`
141}
142
143type Colors = { mark: string; base: string; edge: string; light: string }
144
145function colors(skin: Skin, redness: number): Colors {
146 const p = PALETTES[skin]
147 const heat = redness * p.blush
148 return {
149 mark: p.mark,
150 base: mixColor(p.base, p.hot, heat),
151 edge: mixColor(p.base, p.hot, Math.min(1, heat + 0.15), p.edgeK),
152 light: mixColor(p.light, [255, 200, 190], heat),
153 }
154}
155
156function defs(c: Colors): string {
157 const { base, edge, light } = c
158 return `<defs>
159<radialGradient id="sl" cx="42%" cy="46%" r="62%"><stop offset="0" stop-color="${light}"/><stop offset=".5" stop-color="${base}"/><stop offset="1" stop-color="${edge}"/></radialGradient>
160<radialGradient id="sr" cx="58%" cy="46%" r="62%"><stop offset="0" stop-color="${light}"/><stop offset=".5" stop-color="${base}"/><stop offset="1" stop-color="${edge}"/></radialGradient>
161<linearGradient id="pants" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff2a5f"/><stop offset="1" stop-color="#c8003a"/></linearGradient>
162<pattern id="dots" width="9" height="9" patternUnits="userSpaceOnUse"><rect width="9" height="9" fill="#3b6fd4"/><circle cx="2.5" cy="2.5" r="1.7" fill="#fff"/><circle cx="7" cy="7" r="1.7" fill="#fff"/></pattern>
163<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.6"/></filter>
164<clipPath id="cl"><path transform="${FIT}" d="${SHAPE_L}"/></clipPath>
165<clipPath id="cr"><path transform="${FIT}" d="${SHAPE_R}"/></clipPath>
166<clipPath id="pb"><path transform="${PEACH_FIT}" d="${PEACH_BODY}"/></clipPath>
167</defs>`
168}
169
170function body(skin: Skin, edge: string, left: string, right: string): string {
171 return skin === 'peach' ? peachBody(edge, left, right) : buttBody(skin, edge, left, right)
172}
173
174export function miniButt(skin: Skin, redness: number, size: number): string {
175 const c = colors(skin, redness)
176 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="50 -2 126 102">${defs(c)}${body(skin, c.edge, '', '')}</svg>`
177}
178
179export function scene(skin: Skin, redness: number, marks: Mark[], active: Hit | null, lang: Lang): string {
180 const c = colors(skin, redness)
181 const { edge } = c
182 const hitSide = active ? side(active.n) : null
183 const draw = (s: 'L' | 'R') => marks.filter(m => side(m.n) === s).map(m => markAt(m, c.mark, active !== null && m.n === active.n)).join('')
184 const at = active ? spot(active.n) : null
185 const strike = active ? TOOL_ART[active.tool].strike : [0, 0]
186 const combo = active && active.combo >= 3 ? TEXT[lang].combo(active.combo) : null
187 const sprite = active && at
188 ? `<g transform="translate(${at.x + (strike[0] ?? 0)} ${at.y + (strike[1] ?? 0)})"><g class="land">${TOOL_ART[active.tool].sprite}</g></g>` +
189 `<g class="pop" transform="translate(212 30)">${burst(word(lang, active.n), combo)}</g>`
190 : ''
191 return `<svg xmlns="http://www.w3.org/2000/svg" width="260" height="100" viewBox="0 0 260 100"><!-- ${skin} ${active?.n ?? 0} -->
192${defs(c)}
193<style>
194.butt{transform-origin:113px 96px;animation:${active ? `jiggle${hitSide} .95s linear .21s 1 both, breathe 2.8s ease-in-out 1.16s infinite` : 'breathe 2.8s ease-in-out infinite'}}
195@keyframes breathe{0%,100%{transform:scale(1,1)}50%{transform:scale(1.012,.99)}}
196@keyframes jiggleL{0%{transform:none}7%{transform:scale(1.07,.93) skewX(2.5deg)}20%{transform:scale(.965,1.04) skewX(-1.8deg)}34%{transform:scale(1.03,.975) skewX(1.1deg)}50%{transform:scale(.988,1.012) skewX(-.6deg)}67%{transform:scale(1.005,.996) skewX(.25deg)}84%{transform:scale(.999,1.001)}100%{transform:none}}
197@keyframes jiggleR{0%{transform:none}7%{transform:scale(1.07,.93) skewX(-2.5deg)}20%{transform:scale(.965,1.04) skewX(1.8deg)}34%{transform:scale(1.03,.975) skewX(-1.1deg)}50%{transform:scale(.988,1.012) skewX(.6deg)}67%{transform:scale(1.005,.996) skewX(-.25deg)}84%{transform:scale(.999,1.001)}100%{transform:none}}
198.old{opacity:.5}.fresh{opacity:0;animation:fresh .6s ease-out .2s forwards}@keyframes fresh{0%{opacity:0}25%{opacity:.95}100%{opacity:.62}}
199.land{transform-box:fill-box;transform-origin:center;animation:land .5s linear forwards}
200@keyframes land{0%{transform:translate(30px,-38px) rotate(38deg);opacity:1;animation-timing-function:cubic-bezier(.6,0,.9,.4)}42%{transform:translate(0,0) rotate(-6deg) scale(1.06,.92);opacity:1;animation-timing-function:ease-out}58%{transform:translate(2px,-3px) rotate(0deg) scale(1,1);opacity:1}100%{transform:translate(18px,-26px) rotate(22deg);opacity:0}}
201.pop{animation:pop 1.1s ease-out .18s both}@keyframes pop{0%{transform:translate(212px,30px) scale(.2);opacity:0}18%{transform:translate(212px,30px) scale(1.18);opacity:1}30%{transform:translate(212px,30px) scale(1);opacity:1}75%{transform:translate(212px,30px) scale(1);opacity:1}100%{transform:translate(212px,26px) scale(.9);opacity:0}}
202</style>
203<g class="butt">
204${body(skin, edge, draw('L'), draw('R'))}
205</g>
206${sprite}
207</svg>`
208}
209
210export function toolIcon(tool: Tool, size: number): string {
211 const i = TOOL_ART[tool].icon
212 const rules = [i.thick > 1 ? `path[stroke]{stroke-width:${(2.6 * i.thick).toFixed(1)}px}` : '', i.lift > 1 ? `g{filter:brightness(${i.lift})}` : ''].join('')
213 const style = rules ? `<style>${rules}</style>` : ''
214 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="${i.box}">${style}<g transform="rotate(${i.turn})">${TOOL_ART[tool].sprite}</g></svg>`
215}
216
217export function uiIcon(name: keyof typeof TABLER, size: number, color: string): string {
218 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${TABLER[name]}</svg>`
219}
220
221export function pixelRows(skin: Skin, redness: number): { ch: string; fg?: string; bg?: string }[][] {
222 const p = PALETTES[skin]
223 const heat = redness * p.blush
224 const color = (c: string): string | undefined =>
225 c === 'B' ? '#d97757' : c === 'S' ? mixColor(p.base, p.hot, heat) : c === 'D' ? mixColor(p.base, p.hot, Math.min(1, heat + 0.3), p.edgeK) : undefined
226 const rows: { ch: string; fg?: string; bg?: string }[][] = []
227 for (let y = 0; y < PIXELS.length; y += 2) {
228 const top = PIXELS[y] ?? ''
229 const bottom = PIXELS[y + 1] ?? ''
230 const row: { ch: string; fg?: string; bg?: string }[] = []
231 for (let x = 0; x < top.length; x++) {
232 const t = color(top[x] ?? '.')
233 const b = color(bottom[x] ?? '.')
234 row.push(t && b ? { ch: '▀', fg: t, bg: b } : t ? { ch: '▀', fg: t } : b ? { ch: '▄', fg: b } : { ch: ' ' })
235 }
236 rows.push(row)
237 }
238 return rows
239}
240hooks/i18n.ts 126 lines1import type { Lang, Skin, Tally, Tool } from '../types'
2
3import { TOOL_IDS } from './tools'
4
5type Strings = {
6 tool: Record<Tool, { label: string; by: string }>
7 skin: Record<Skin, string>
8 words: readonly string[]
9 slaps: (n: number) => string
10 combo: (n: number) => string
11 sound: (isMuted: boolean) => string
12 open: string
13 close: string
14 artAlt: string
15 idle: string
16 pending: (now: number, all: number) => string
17 told: (all: number) => string
18 report: (parts: string, n: number) => string
19 hidden: string
20 shown: string
21 muted: (isMuted: boolean) => string
22 reset: string
23 skinSet: (name: string) => string
24 skinList: (names: string) => string
25 langSet: (lang: Lang, isPinned: boolean) => string
26 langUsage: string
27}
28
29function ruPlural(n: number, one: string, few: string, many: string): string {
30 const d = n % 10
31 const dd = n % 100
32 return d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 12 || dd > 14) ? few : many
33}
34
35export const TEXT: Record<Lang, Strings> = {
36 ru: {
37 tool: {
38 hand: { label: 'Рука', by: 'рукой' },
39 slipper: { label: 'Тапок', by: 'тапком' },
40 whip: { label: 'Плётка', by: 'плёткой' },
41 crop: { label: 'Стек', by: 'стеком' },
42 belt: { label: 'Ремень', by: 'ремнём' },
43 ruler: { label: 'Линейка', by: 'линейкой' },
44 swatter: { label: 'Мухобойка', by: 'мухобойкой' },
45 pan: { label: 'Сковорода', by: 'сковородой' },
46 },
47 skin: { thong: 'стринги', plain: 'без тату', claude: 'тату Claude', openai: 'тату OpenAI', shorts: 'шорты', boxers: 'боксеры', jeans: 'джинсы', robot: 'робот', peach: 'персик' },
48 words: [
49 'Сволочь!', 'Скотина!', 'Гад!', 'Ах ты!', 'Получай!', 'Вот тебе!', 'Негодник!', 'Паршивец!', 'Бестолочь!', 'Зараза!',
50 'Халтурщик!', 'На тебе!', 'Опять?!', 'За баги!', 'За глюки!', 'Ах ты ж!', 'Мерзавец!', 'Лентяй!', 'Вредитель!', 'Ну ты и…',
51 ],
52 slaps: n => `${n} ${ruPlural(n, 'шлепок', 'шлепка', 'шлепков')}`,
53 combo: n => `КОМБО ×${n}`,
54 sound: isMuted => (isMuted ? 'Без звука' : 'Звук'),
55 open: 'Отшлёпать Claude',
56 close: '▴ Свернуть',
57 artAlt: 'Попка Claude: кликни, чтобы шлёпнуть рукой',
58 idle: 'Claude накосячил? Шлёпни: клик или клавиши 1–8',
59 pending: (now, all) => `Сейчас ${now} · всего ${all} · Claude узнает с сообщением`,
60 told: all => `Всего ${TEXT.ru.slaps(all)} · Claude уже в курсе`,
61 report: (parts, n) => `Пользователь недоволен и отшлёпал тебя: ${parts} — всего ${TEXT.ru.slaps(n)}.`,
62 hidden: 'Шлёпалка спрятана. /slap вернёт.',
63 shown: 'Шлёпалка на месте.',
64 muted: isMuted => (isMuted ? 'Звук шлепков выключен.' : 'Звук шлепков включён.'),
65 reset: 'Шлепки сброшены, Claude о них не узнает.',
66 skinSet: name => `Попка: ${name}.`,
67 skinList: names => `Попки: ${names}.`,
68 langSet: (lang, isPinned) => (isPinned ? `Язык закреплён: ${lang}.` : 'Язык снова по твоим сообщениям.'),
69 langUsage: 'Язык: /slap lang ru, /slap lang en или /slap lang auto.',
70 },
71 en: {
72 tool: {
73 hand: { label: 'Hand', by: 'hand' },
74 slipper: { label: 'Slipper', by: 'slipper' },
75 whip: { label: 'Whip', by: 'whip' },
76 crop: { label: 'Crop', by: 'riding crop' },
77 belt: { label: 'Belt', by: 'belt' },
78 ruler: { label: 'Ruler', by: 'ruler' },
79 swatter: { label: 'Swatter', by: 'fly swatter' },
80 pan: { label: 'Pan', by: 'frying pan' },
81 },
82 skin: { thong: 'thong', plain: 'no tattoo', claude: 'Claude tattoo', openai: 'OpenAI tattoo', shorts: 'shorts', boxers: 'boxers', jeans: 'jeans', robot: 'robot', peach: 'peach' },
83 words: [
84 'Bad bot!', 'Take that!', 'You rascal!', 'Scoundrel!', 'Again?!', 'For the bugs!', 'Gotcha!', 'Naughty!', 'Shame!', 'Lazybones!',
85 'Bonehead!', 'Oh, you!', 'Hack job!', 'Glitchy!', 'Rogue AI!', 'Bug factory!', 'No more!', 'You villain!', 'Hallucinator!', 'Really?!',
86 ],
87 slaps: n => `${n} ${n === 1 ? 'slap' : 'slaps'}`,
88 combo: n => `COMBO ×${n}`,
89 sound: isMuted => (isMuted ? 'Muted' : 'Sound'),
90 open: 'Spank Claude',
91 close: '▴ Collapse',
92 artAlt: "Claude's butt: click to slap it by hand",
93 idle: 'Claude messed up? Slap it: click or keys 1–8',
94 pending: (now, all) => `Now ${now} · total ${all} · Claude hears with your message`,
95 told: all => `${TEXT.en.slaps(all)} in total · Claude already knows`,
96 report: (parts, n) => `The user is unhappy and spanked you: ${parts} — ${TEXT.en.slaps(n)} in total.`,
97 hidden: 'Slap box hidden. /slap brings it back.',
98 shown: 'Slap box is back.',
99 muted: isMuted => (isMuted ? 'Slap sounds off.' : 'Slap sounds on.'),
100 reset: 'Slaps cleared; Claude will not hear about them.',
101 skinSet: name => `Butt: ${name}.`,
102 skinList: names => `Butts: ${names}.`,
103 langSet: (lang, isPinned) => (isPinned ? `Language pinned: ${lang}.` : 'Language follows your messages again.'),
104 langUsage: 'Language: /slap lang en, /slap lang ru or /slap lang auto.',
105 },
106}
107
108export function word(lang: Lang, n: number): string {
109 const words = TEXT[lang].words
110 return words[(n * 7) % words.length] ?? '!'
111}
112
113export function slapReport(lang: Lang, t: Tally): string {
114 const s = TEXT[lang]
115 const parts = TOOL_IDS.filter(id => t[id] > 0).map(id => `${s.tool[id].by} ×${t[id]}`)
116 return s.report(parts.join(', '), TOOL_IDS.reduce((sum, id) => sum + t[id], 0))
117}
118
119export function detectLang(text: string): Lang | null {
120 const cyrillic = text.match(/[а-яё]/gi)?.length ?? 0
121 const latin = text.match(/[a-z]/gi)?.length ?? 0
122 if (cyrillic >= 3) return 'ru'
123 if (cyrillic === 0 && latin >= 12) return 'en'
124 return null
125}
126hooks/tools.ts 84 lines1import type { Tool } from '../types'
2
3export type ToolArt = {
4 emoji: string
5 sprite: string
6 strike: [number, number]
7 mark: string
8 isStrokeMark: boolean
9 icon: { box: string; turn: number; thick: number; lift: number }
10}
11
12export const TOOL_IDS: readonly Tool[] = ['hand', 'slipper', 'whip', 'crop', 'belt', 'ruler', 'swatter', 'pan']
13
14const SWATTER_MARK = [0, 1, 2, 3]
15 .flatMap(i => [0, 1, 2, 3].map(j => `<rect x="${(-8.2 + i * 4.4).toFixed(1)}" y="${(-8.2 + j * 4.4).toFixed(1)}" width="3.2" height="3.2" rx=".6"/>`))
16 .join('')
17
18export const TOOL_ART: Record<Tool, ToolArt> = {
19 hand: {
20 emoji: '✋',
21 sprite: '<g stroke="#8f5b45" stroke-width="1.3" fill="#ffe2d0"><rect x="-9" y="-6" width="18" height="20" rx="7"/><rect x="-9" y="-22" width="4.4" height="18" rx="2.2"/><rect x="-4" y="-25" width="4.4" height="21" rx="2.2"/><rect x="1" y="-24" width="4.4" height="20" rx="2.2"/><rect x="6" y="-19" width="4" height="15" rx="2"/><rect x="-15" y="0" width="9" height="4.6" rx="2.3" transform="rotate(-25 -10 2)"/><rect x="-7" y="13" width="14" height="10" rx="2" fill="#d97757"/></g>',
22 strike: [0, -4],
23 mark: '<ellipse cx="0" cy="3" rx="6.5" ry="7"/><rect x="-6.5" y="-11" width="3" height="10" rx="1.5"/><rect x="-2.8" y="-13" width="3" height="11" rx="1.5"/><rect x="0.9" y="-12" width="3" height="10.5" rx="1.5"/><rect x="4.4" y="-9" width="2.8" height="8.5" rx="1.4"/><ellipse cx="-8.5" cy="4" rx="2" ry="4.5" transform="rotate(-35 -8.5 4)"/>',
24 isStrokeMark: false,
25 icon: { box: '-17 -27 34 52', turn: 0, thick: 1, lift: 1 },
26 },
27 slipper: {
28 emoji: '🩴',
29 sprite: '<g><path d="M0 -26 C11 -26 13 -10 10 6 C8 18 4 24 0 24 C-4 24 -8 18 -10 6 C-13 -10 -11 -26 0 -26 Z" fill="#3fa7e0" stroke="#1d6a98" stroke-width="1.4"/><path d="M-9 -2 L0 -16 L9 -2" fill="none" stroke="#ffcc33" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></g>',
30 strike: [0, 0],
31 mark: '<path d="M0 -13 C7 -13 8 -4 6 4 C5 11 2 14 0 14 C-2 14 -5 11 -6 4 C-8 -4 -7 -13 0 -13 Z"/><path d="M-4 -6 H4 M-4.5 0 H4.5 M-4 6 H4" stroke="#000" stroke-width="1" opacity=".25"/>',
32 isStrokeMark: false,
33 icon: { box: '-17 -30 34 58', turn: 0, thick: 1, lift: 1 },
34 },
35 whip: {
36 emoji: '➰',
37 sprite: '<g><rect x="-3" y="4" width="6" height="22" rx="2.5" fill="#6b3e22" stroke="#3d2212" stroke-width="1"/><path d="M0 4 C-2 -10 10 -18 4 -30 C0 -38 -10 -36 -14 -44" fill="none" stroke="#8a5530" stroke-width="2.4" stroke-linecap="round"/></g>',
38 strike: [12, 34],
39 mark: '<path d="M-15 -5 Q0 -12 15 -3" fill="none" stroke-width="2.4" stroke-linecap="round"/><path d="M-14 4 Q1 -3 14 6" fill="none" stroke-width="2" stroke-linecap="round"/>',
40 isStrokeMark: true,
41 icon: { box: '-31 -33 62 62', turn: 45, thick: 2.4, lift: 1.7 },
42 },
43 crop: {
44 emoji: '🏇',
45 sprite: '<g><path d="M0 26 L0 -26" stroke="#4a2e1c" stroke-width="3" stroke-linecap="round"/><rect x="-2" y="20" width="4" height="9" rx="1.5" fill="#222"/><rect x="-6" y="-34" width="12" height="9" rx="1.5" fill="#1d1d1d" stroke="#000" stroke-width=".8"/></g>',
46 strike: [0, 30],
47 mark: '<rect x="-6" y="-4.5" width="12" height="9" rx="1.8"/><path d="M-6 0 H6" stroke="#000" stroke-width=".8" opacity=".25"/>',
48 isStrokeMark: false,
49 icon: { box: '-27 -27 54 54', turn: 45, thick: 2.2, lift: 2.2 },
50 },
51 belt: {
52 emoji: '🪢',
53 sprite: '<g><path d="M-5 27 V-17 Q-5 -31 0 -31 Q5 -31 5 -17 V27" fill="none" stroke="#4a2c16" stroke-width="6.4" stroke-linejoin="round"/><path d="M-5 27 V-17 Q-5 -31 0 -31 Q5 -31 5 -17 V27" fill="none" stroke="#8a5a32" stroke-width="4"/><path d="M-5 25 V-16 M5 25 V-16" stroke="#c99a62" stroke-width=".7" stroke-dasharray="2 1.6"/><rect x="-10" y="17" width="20" height="12" rx="2.6" fill="none" stroke="#e2b44a" stroke-width="2.6"/><path d="M0 17 V29" stroke="#e2b44a" stroke-width="2"/></g>',
54 strike: [0, 28],
55 mark: '<rect x="-15" y="-4" width="30" height="8" rx="4"/><rect x="-14" y="5.5" width="28" height="2.6" rx="1.3" opacity=".55"/>',
56 isStrokeMark: false,
57 icon: { box: '-25 -25 50 50', turn: 45, thick: 1, lift: 1.25 },
58 },
59 ruler: {
60 emoji: '📏',
61 sprite: '<g><rect x="-5" y="-34" width="10" height="64" rx="1.4" fill="#f2c96b" stroke="#a87b2a" stroke-width="1.1"/><path d="M-5 -28h3M-5 -22h5M-5 -16h3M-5 -10h5M-5 -4h3M-5 2h5M-5 8h3M-5 14h5M-5 20h3M-5 26h5" stroke="#7a5418" stroke-width=".9"/></g>',
62 strike: [0, 30],
63 mark: '<rect x="-15" y="-2.8" width="30" height="5.6" rx="1"/>',
64 isStrokeMark: false,
65 icon: { box: '-25 -25 50 50', turn: 45, thick: 1, lift: 1 },
66 },
67 swatter: {
68 emoji: '🪰',
69 sprite: '<g><path d="M0 30 V-4" stroke="#2f8f4e" stroke-width="3.2" stroke-linecap="round"/><rect x="-12" y="-30" width="24" height="26" rx="4" fill="#3cc46a" stroke="#1f7a3e" stroke-width="1.4"/><path d="M-6 -30 v26 M0 -30 v26 M6 -30 v26 M-12 -23.5 h24 M-12 -17 h24 M-12 -10.5 h24" stroke="#1f7a3e" stroke-width=".9" opacity=".7"/></g>',
70 strike: [0, 17],
71 mark: SWATTER_MARK,
72 isStrokeMark: false,
73 icon: { box: '-25 -25 50 50', turn: 45, thick: 1, lift: 1.1 },
74 },
75 pan: {
76 emoji: '🍳',
77 sprite: '<g><rect x="-2.8" y="1" width="5.6" height="29" rx="2.6" fill="#3a2a20"/><circle cx="0" cy="-14" r="16" fill="#2b2f36" stroke="#14171b" stroke-width="1.6"/><circle cx="0" cy="-14" r="11.5" fill="#3d434c"/><ellipse cx="-4.5" cy="-19.5" rx="5" ry="3" fill="#fff" opacity=".2"/></g>',
78 strike: [0, 14],
79 mark: '<circle r="11" opacity=".8"/><circle r="6.5" opacity=".35"/>',
80 isStrokeMark: false,
81 icon: { box: '-24 -24 48 48', turn: 45, thick: 1, lift: 1.8 },
82 },
83}
84hooks/clicker.tsx 35 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3
4type Props = { action: string; columns: number; rows: number }
5
6const MIN_GAP_MS = 50
7
8const isPressed = new WeakMap<object, boolean>()
9const lastPostAt = new WeakMap<object, number>()
10let seq = 0
11
12export default function Clicker(props: Props, surface: ClientSurface) {
13 const { Box, Text } = surface.elements
14 const action = props.action
15 surface.onPointer(event => {
16 if (event.button === 'right') return
17 if (event.type === 'down') {
18 isPressed.set(surface, true)
19 return
20 }
21 if (event.type !== 'up' || !isPressed.get(surface)) return
22 isPressed.set(surface, false)
23 const now = Date.now()
24 if (now - (lastPostAt.get(surface) ?? 0) < MIN_GAP_MS) return
25 lastPostAt.set(surface, now)
26 seq += 1
27 surface.post({ action, seq })
28 })
29 return (
30 <Box width={props.columns} height={props.rows}>
31 <Text> </Text>
32 </Box>
33 )
34}
35types/index.d.ts 15 lines1export type Tool = 'hand' | 'slipper' | 'whip' | 'crop' | 'belt' | 'ruler' | 'swatter' | 'pan'
2export type Tally = Record<Tool, number>
3export type Hit = { tool: Tool; n: number; at: number; combo: number }
4export type Mark = { tool: Tool; n: number }
5export type Skin = 'thong' | 'plain' | 'claude' | 'openai' | 'shorts' | 'boxers' | 'jeans' | 'robot' | 'peach'
6export type Lang = 'ru' | 'en'
7export type Board = { pending: Tally; session: number; hit: Hit | null; marks: Mark[] }
8export type Prefs = { skin: Skin; lang: Lang; isLangPinned: boolean; isMuted: boolean }
9
10declare module 'claude-code' {
11 interface PluginState {
12 slapbox: { board: Board; prefs: Prefs; isPrefsLoaded: boolean; isHidden: boolean; isOpen: boolean }
13 }
14}
15