SLOPSHOPPER

slapbox

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…

newbandcommandpromptprocess
★ 2v1.0.0MITupdated 2026-10-16timoncool/slapbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slapbox
› 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 › /slap ⎿ slapbox: Slap box hidden. /slap brings it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

slapbox

Spank Claude when it messes up: a stress-relief mod for Claude Code.

License Stars Last Commit

English · Русский

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

Features

  • Eight slappers — hand, slipper, whip, riding crop, belt, ruler, fly swatter, frying pan, each with its own swing, mark and sound
  • Claude finds out — the tally rides along with your next message as a note only the model reads, once
  • Nine butts — thong, no tattoo, Claude tattoo, OpenAI tattoo, shorts, boxers, jeans, robot, peach
  • Alive — the tool swings in, the butt jiggles, the cheeks redden, an insult pops up, fast series become COMBO ×N
  • Folds into one line — "Spank Claude · 3" sits above the prompt until you need it; one click opens the box
  • Keys — 1–8 slap, 9 swaps the butt, 0 toggles the sound
  • English and Russian — follows the language of your messages; /slap lang pins it
  • Zero tokens — no model calls; the only thing Claude reads is the one-line tally

Quick Start

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

Usage

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">

CommandWhat it does
/slapHide or show the box
/slap muteSound on or off (also the Sound button and key 0)
/slap resetForget 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.

Configuration

SettingValuesWhere
Languageauto (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.

How it works

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

Other Projects by @timoncool

ProjectDescription
givememodSay /givememod and Claude Code builds a mod for the task in front of you
Angry SwitcherSwear at Claude Code all you like, it reads the clean version
satoriSelf-learning loop for Claude Code: corrections become skills
telegram-api-mcpFull Telegram Bot API as MCP server
civitai-mcp-ultimateCivitai API as MCP server
ACE-Step StudioAI music studio — songs, vocals, covers, videos
VideoSOSAI video production in the browser

Authors

Acknowledgements

  • Butt shape from Butt Butt on SVG Repo (CC0)
  • Peach from Twemoji, © Twitter, Inc and other contributors, graphics licensed under CC-BY 4.0
  • Interface icons from Tabler Icons (MIT)
  • Claude and OpenAI logos from Lobe Icons (MIT); the logos are trademarks of Anthropic and OpenAI. slapbox is a parody and is not affiliated with either company
  • Built with givememod

Support the Author

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

  • BTC: 1E7dHL22RpyhJGVpcvKdbyZgksSYkYeEBC
  • ETH (ERC20): 0xb5db65adf478983186d4897ba92fe2c25c594a0c
  • USDT (TRC20): TQST9Lp2TjK6FiVkn4fwfGUee7NmkxEE7C

Star History

<a href="https://github.com/timoncool/slapbox/stargazers"> <img alt="Star History Chart" src="docs/stars-light.svg" /> </a>

License

MIT. The peach keeps its CC-BY 4.0 licence; the Claude and OpenAI logos remain trademarks of their owners.

Source 6 files
hooks/register.tsx 319 lines
1import { 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}
319
hooks/art.ts 240 lines
1import 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, '&amp;').replace(/</g, '&lt;')
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}
240
hooks/i18n.ts 126 lines
1import 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}
126
hooks/tools.ts 84 lines
1import 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}
84
hooks/clicker.tsx 35 lines
1/* @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}
35
types/index.d.ts 15 lines
1export 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