SLOPSHOPPER

clawd-friend

Claude Friend: a tiny Clawd above your prompt who acts out whatever Claude is doing, live

newpanebandrowsguardcommand
★ 2v1.1.0MITupdated 2026-10-04redemptionnn/clawd-friend/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-friend
│ ┃ friend ✕ › fix the failing auth test and add an audit log call │ ┃ 👋 Clawd has arrived! (•‿•)/ │ ⏺ 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 │ │ › /friend │ ⎿ clawd-friend: Clawd went for a walk. /friend calls them back. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · friend
👋 Clawd has arrived! (•‿•)/
README

Claude Friend

A tiny Clawd who lives above your prompt in Claude Code and acts out whatever Claude is doing, live.

He taps away at a laptop while Claude codes, reads the newspaper during a web search, watches a flask bubble while your tests run, stamps a parcel on every commit, launches a rocket when you deploy, chases a bug with a net, and nods off when you go quiet.

Claude Friend running tests above the prompt

Install

In Claude Code, run:

/plugin marketplace add redemptionnn/clawd-friend
/plugin install clawd-friend

Then start a new session (or run /reload-plugins) and your friend shows up above the prompt.

Or from a shell:

claude plugin marketplace add redemptionnn/clawd-friend
claude plugin install clawd-friend

To update later:

claude plugin marketplace update clawd-friend

Requirements: Claude Code 2.1.286 or newer. Mods are an early-access Claude Code feature, so the API may change between releases. The full animated character is drawn in the Claude Code desktop app (and the VS Code extension); the terminal shows a compact text version.

23 scenes, one tiny friend

All 23 scenes

He picks a scene from what Claude is actually doing, as it happens:

SceneWhen
💭 ThinkingClaude is thinking, or reading a tool's result before its next move
⌨️ WritingClaude is writing its reply, or editing .md / .txt docs (a typewriter that faces him)
💻 CodingEditing or writing code (a laptop, lid turned toward him)
📰 Web searchWebSearch, WebFetch and search or docs MCP tools (he reads the paper with a coffee)
🔍 ReadingRead, Grep, Glob (a magnifying glass over the docs)
⚡ CommandsAny other shell command (green hacker glyphs rain above him)
🧪 Testsnpm test, pytest, vitest, jest, go test, cargo test, ...
📦 Commitgit commit
🚀 Deploygit push, vercel, npm publish, npm run deploy, deploy MCP tools
🧱 Buildnpm run build, tsc, cargo build, docker build, make, ...
🗄️ Databasepsql, prisma migrate, Supabase / SQL MCP tools, .sql files and migrations
🐞 Debuggingnode --inspect, pdb, gdb, debugging skills
🎨 Design.css / .svg / Tailwind config edits, Figma and design MCP tools
📔 MemoryWriting CLAUDE.md or Claude's memory files
🐾 SubagentsClaude sends out subagents (tiny Clawds march off with their tasks)
📋 PlanningTodo and task list updates
🌐 BrowsingBrowser automation tools
👋 AskingClaude asks you a question
🪧 Needs your OKA tool call is waiting for your permission
🎉 DoneA turn finished: he jumps, confetti flies
💧 OopsA turn ended on an error
😌 ChillingNothing going on
💤 NappingTwo quiet minutes in a row

Skins

15 skins, a name and the /friend menu

Dress him up. A skin stays on through every scene and is remembered across sessions. When a scene brings its own hat (the hard hat while building, the beret while designing) that one wins for a moment, and he only holds an item while his hand is free.

HatsItems in hand
👑 /friend crown🎩 /friend tophat☕ /friend coffee🪄 /friend wand
🧙 /friend wizard🧢 /friend beanie🗡️ /friend sword🌸 /friend flower
🤠 /friend cowboy🎧 /friend headphones🎈 /friend balloon
⚔️ /friend viking😇 /friend halo
🥳 /friend party🎃 /friend pumpkin

/friend skins shows them all in a gallery (click one to put it on), and /friend noskin takes it off.

Give him a name

/friend name Bob

From then on he greets you with a little speech bubble, "Bob has arrived!", whenever you call him or start a session.

Commands

Not sure what's there? /friend menu lists everything, with buttons in the desktop app.

CommandWhat it does
/friendCall your friend, or send him off if he's already here (remembered across sessions)
/friend menuEvery command, with buttons
/friend skinsThe skin gallery; click one to wear it
/friend <skin>Wear a skin, e.g. /friend crown or /friend sword
/friend noskinTake the skin off
/friend name <name>Give him a name (up to 16 characters)
/friend scenesEvery scene, with buttons to play them
/friend <scene>Play any scene for a few seconds, e.g. /friend deploy or /friend build
/friend paneMove him into a side pane
/friend stagePut him back above the prompt
/friend statsWhat he saw today: file edits, reads, commands, web lookups, helpers, finished turns

How it works

Claude Friend is a Claude Code mod: a plugin whose behaviour is a TypeScript module of function hooks.

  • What Claude is doing. A turn.step hook watches the model's response as it streams: thinking, visible text, and the start of each tool call, whose arguments it reads as they arrive, so the scene changes before the tool even runs. A tool.call hook confirms the scene while the tool runs, and a tool.check hook notices when a call needs your permission.
  • Which scene. plugin/hooks/classify.ts maps tool names, shell commands and file paths to scenes: git commit is a commit, npm test is tests, a .css edit is design, an MCP tool named execute_sql is the database.
  • Drawing. A ui.render hook draws the band above the prompt. Every scene is an SVG built in code in plugin/hooks/scenes.ts, animated with CSS. There are no image files.
  • Cheap and private. No model calls, no tokens, no network. Your daily stats stay on your machine in the plugin's own store.

Develop

Clone the repo and load the plugin straight from disk; saving a file hot-reloads it.

git clone https://github.com/redemptionnn/clawd-friend.git
claude --plugin-dir ./clawd-friend/plugin

In the desktop app, where you can't pass flags, add the folder to the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/clawd-friend/plugin"
  }
}

If you also installed it with /plugin install, disable that copy (claude plugin disable clawd-friend) so it doesn't load twice.

Check and test it against the engine:

claude plugin validate ./plugin
claude plugin test ./plugin

The tests in plugin/tests/scenes.test.ts run real tool calls through Claude Code's engine, draw the band while each call is in flight, and check that the right scene shows. plugin/tests/skins.test.ts covers skins, names and the menu cards.

Good to know

  • The rounded panel behind the friend is drawn by Claude Code itself; a mod can't remove or recolor it.
  • Claude Code doesn't tell a mod the moment you approve a permission prompt, so the "OK?" sign stays up until that tool call finishes.

Layout

.claude-plugin/marketplace.json   lets /plugin find and install the plugin
plugin/
  .claude-plugin/plugin.json      name, version, metadata
  hooks/hooks.json                points at the hooks module
  hooks/register.tsx              the hooks: what Claude is doing, commands, drawing
  hooks/classify.ts               tool calls to scenes
  hooks/scenes.ts                 the 23 scenes, drawn as SVG in code
  hooks/skins.ts                  hats and items in hand
  types/index.d.ts                the plugin's state contract
  tests/                          claude plugin test
media/                            the demo GIF and screenshots

License

MIT

Source 5 files
hooks/register.tsx 718 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Activity, Place, Scene, Stats } from '../types'
5import { classify } from './classify'
6import { ACTIVITIES, renderHello, renderScene } from './scenes'
7import { SKINS, skinById } from './skins'
8
9const scene = atom({ plugin: 'clawd-friend', key: 'scene' } as const, {
10  activity: 'idle',
11  detail: '',
12} as Scene)
13
14const isOn = atom({ plugin: 'clawd-friend', key: 'isOn' } as const, true)
15const preview = atom({ plugin: 'clawd-friend', key: 'preview' } as const, null as Activity | null)
16const place = atom({ plugin: 'clawd-friend', key: 'place' } as const, 'stage' as Place)
17const skin = atom({ plugin: 'clawd-friend', key: 'skin' } as const, '')
18const friendName = atom({ plugin: 'clawd-friend', key: 'name' } as const, 'Clawd')
19const isArriving = atom({ plugin: 'clawd-friend', key: 'isArriving' } as const, false)
20
21const PANE = 'friend'
22const PREVIEW_MS = 6_000
23const ARRIVAL_MS = 4_500
24const DEFAULT_NAME = 'Clawd'
25const NAME_MAX = 16
26
27const LABELS: Record<Activity, string> = {
28  idle: 'is chilling',
29  sleep: 'is napping',
30  thinking: 'is thinking',
31  writing: 'is writing',
32  coding: 'is coding',
33  web: 'is searching the web',
34  reading: 'is reading files',
35  bash: 'is running a command',
36  agents: 'is sending helpers',
37  todo: 'is planning',
38  ask: 'has a question',
39  browser: 'is browsing',
40  testing: 'is running tests',
41  commit: 'is committing',
42  deploy: 'is deploying',
43  waiting: 'is waiting for your OK',
44  debugging: 'is debugging',
45  memory: 'is noting something down',
46  database: 'is working with the database',
47  design: 'is designing',
48  build: 'is building',
49  done: 'is done!',
50  oops: 'hit a snag',
51}
52
53const GLYPHS: Record<Activity, string> = {
54  idle: '(•‿•)',
55  sleep: '(-‿-) zZ',
56  thinking: '(•‿•) 💭',
57  writing: '(•‿•) ⌨',
58  coding: '(•‿•) 💻',
59  web: '(•‿•) 📰',
60  reading: '(•‿•) 🔍',
61  bash: '(•‿•) ⚙',
62  agents: '(•‿•) ⇠ ••',
63  todo: '(•‿•) 📋',
64  ask: '(•‿•) ❓',
65  browser: '(•‿•) 🌐',
66  testing: '(•‿•) 🧪',
67  commit: '(•‿•) 📦',
68  deploy: '(•‿•) 🚀',
69  waiting: '(•‿•) [OK?]',
70  debugging: '(•‿•) 🐞',
71  memory: '(•‿•) 📔',
72  database: '(•‿•) 🗄',
73  design: '(•‿•) 🎨',
74  build: '(•‿•) 🧱',
75  done: '\\(^‿^)/ ✨',
76  oops: '(×_×) 💧',
77}
78
79// Which tally each tool activity counts toward in /friend stats.
80const TALLY: Partial<Record<Activity, keyof Omit<Stats, 'day'>>> = {
81  coding: 'edits',
82  writing: 'edits',
83  design: 'edits',
84  memory: 'edits',
85  reading: 'reads',
86  bash: 'commands',
87  testing: 'commands',
88  commit: 'commands',
89  deploy: 'commands',
90  build: 'commands',
91  database: 'commands',
92  debugging: 'commands',
93  web: 'web',
94  browser: 'web',
95  agents: 'helpers',
96  todo: 'plans',
97  ask: 'questions',
98}
99
100const RESTING: ReadonlySet<Activity> = new Set(['idle', 'sleep', 'done', 'oops'])
101const SLEEP_AFTER_MS = 120_000
102const CELEBRATE_MS = 3_500
103
104const basename = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p
105const clip = (s: string, n = 34) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
106
107function detailOf(tool: string, input: Record<string, unknown>): string {
108  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
109  const path = str('file_path') || str('notebook_path') || str('path')
110  if (path) return clip(basename(path))
111  if (str('query')) return clip(`"${str('query')}"`)
112  if (str('url')) return clip(str('url').replace(/^https?:\/\//, ''))
113  if (str('pattern')) return clip(str('pattern'))
114  if (str('command')) return clip(str('command').trim().split('\n')[0])
115  if (str('description')) return clip(str('description'))
116  return tool.startsWith('mcp__') ? clip(tool.split('__').pop() ?? tool) : ''
117}
118
119// Pull the fields detailOf reads out of a tool call's partial JSON as it streams.
120const FIELD = /"(file_path|notebook_path|path|query|url|pattern|command|description|skill)"\s*:\s*"((?:[^"\\]|\\.)*)"/g
121
122function partialInput(json: string): Record<string, unknown> {
123  const out: Record<string, unknown> = {}
124  for (const m of json.matchAll(FIELD)) {
125    try {
126      out[m[1]] = JSON.parse(`"${m[2]}"`)
127    } catch {
128      out[m[1]] = m[2]
129    }
130  }
131  return out
132}
133
134let shown = ''
135let timer: Timer | undefined
136let previewTimer: Timer | undefined
137let arrivalTimer: Timer | undefined
138let stats: Stats | undefined
139const running = new Map<string, Scene>()
140
141function later($: EngineInterface, ms: number, fn: () => void) {
142  timer?.cancel()
143  timer = $.clock.after(ms, fn)
144}
145
146async function show($: EngineInterface, activity: Activity, detail = '') {
147  const key = `${activity}|${detail}`
148  if (key === shown) return
149  shown = key
150  if (activity === 'idle') later($, SLEEP_AFTER_MS, () => void show($, 'sleep'))
151  else if (activity === 'done' || activity === 'oops') later($, CELEBRATE_MS, () => void show($, 'idle'))
152  else if (activity !== 'sleep') {
153    timer?.cancel()
154    timer = undefined
155  }
156  try {
157    await update($, scene, () => ({ activity, detail }))
158  } catch {
159    // A failed write only costs one frame of the animation.
160  }
161}
162
163async function setOn($: EngineInterface, value: boolean) {
164  await update($, isOn, () => value)
165  await $.store.set('isOn', value)
166}
167
168async function setPlace($: EngineInterface, value: Place) {
169  await update($, place, () => value)
170  await $.store.set('place', value)
171}
172
173async function openPane($: EngineInterface) {
174  return $.ui.open({ id: PANE, title: 'Friend' })
175}
176
177async function startPreview($: EngineInterface, activity: Activity) {
178  previewTimer?.cancel()
179  arrivalTimer?.cancel()
180  await update($, isArriving, () => false)
181  await update($, preview, () => activity)
182  previewTimer = $.clock.after(PREVIEW_MS, () => void update($, preview, () => null))
183}
184
185// He waves hello with "<name> has arrived!" for a few seconds.
186async function arrive($: EngineInterface) {
187  arrivalTimer?.cancel()
188  await update($, isArriving, () => true)
189  arrivalTimer = $.clock.after(ARRIVAL_MS, () => void update($, isArriving, () => false))
190}
191
192async function setSkin($: EngineInterface, id: string) {
193  await update($, skin, () => id)
194  await $.store.set('skin', id)
195}
196
197// Names are drawn in his speech bubble: keep them short and plain.
198function cleanName(raw: string) {
199  return raw
200    .replace(/[<>&"\\\u0000-\u001f]/g, '')
201    .replace(/\s+/g, ' ')
202    .trim()
203    .slice(0, NAME_MAX)
204}
205
206async function setName($: EngineInterface, value: string) {
207  await update($, friendName, () => value)
208  await $.store.set('name', value)
209}
210
211// Shows him if he is hidden, and greets you either way.
212async function callFriend($: EngineInterface) {
213  await setOn($, true)
214  if ((await read($, place)) === 'pane') await openPane($)
215  await arrive($)
216}
217
218// ── Stats ────────────────────────────────────────────────────────────────
219
220const emptyStats = (day: string): Stats => ({
221  day,
222  edits: 0,
223  reads: 0,
224  commands: 0,
225  web: 0,
226  helpers: 0,
227  plans: 0,
228  questions: 0,
229  done: 0,
230  snags: 0,
231})
232
233async function today($: EngineInterface) {
234  const d = new Date(await $.clock.now())
235  const pad = (n: number) => String(n).padStart(2, '0')
236  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
237}
238
239// The tally lives in this module and is written through to $.store, so
240// parallel tool calls never race a read of the store.
241async function loadStats($: EngineInterface) {
242  const day = await today($)
243  const saved = (await $.store.get('stats')) as Stats | undefined
244  stats = saved && saved.day === day ? { ...emptyStats(day), ...saved } : emptyStats(day)
245  return stats
246}
247
248async function count($: EngineInterface, key: keyof Omit<Stats, 'day'>) {
249  try {
250    const day = await today($)
251    if (!stats || stats.day !== day) await loadStats($)
252    if (!stats) return
253    stats[key] += 1
254    await $.store.set('stats', stats)
255  } catch {
256    // Stats are a nicety; never let them get in the way of a tool call.
257  }
258}
259
260function statsText(s: Stats) {
261  const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
262  const total = s.edits + s.reads + s.commands + s.web + s.helpers + s.plans + s.questions
263  if (total === 0 && s.done === 0) return 'Your friend has nothing to report yet today. Give Claude something to do!'
264  return [
265    `Today with your friend (${s.day}):`,
266    `  💻 ${plural(s.edits, 'file edit')}`,
267    `  🔍 ${plural(s.reads, 'file read or search', 'file reads and searches')}`,
268    `  ⚡ ${plural(s.commands, 'command')}`,
269    `  📰 ${plural(s.web, 'web lookup')}`,
270    `  🐾 ${plural(s.helpers, 'helper sent', 'helpers sent')}`,
271    `  📋 ${plural(s.plans, 'plan update')}`,
272    `  ❓ ${plural(s.questions, 'question for you', 'questions for you')}`,
273    `  🎉 ${plural(s.done, 'turn finished', 'turns finished')}${s.snags ? `, ${plural(s.snags, 'snag')}` : ''}`,
274  ].join('\n')
275}
276
277// Every command, as the menu lists it.
278const COMMANDS: [command: string, what: string][] = [
279  ['/friend', 'call your friend, or send him off if he is here'],
280  ['/friend menu', 'this menu'],
281  ['/friend skins', 'pick a hat or something for his hand'],
282  ['/friend <skin>', 'wear a skin, e.g. /friend crown (/friend noskin takes it off)'],
283  ['/friend name <name>', 'give him a name, e.g. /friend name Bob'],
284  ['/friend scenes', 'every scene he can play'],
285  ['/friend <scene>', 'play a scene for a few seconds, e.g. /friend deploy'],
286  ['/friend stats', 'what he saw today'],
287  ['/friend pane', 'move him into a side pane (/friend stage brings him back)'],
288]
289
290// What each scene is called in the scenes list.
291const SCENE_NAMES: Record<Activity, string> = {
292  idle: '😌 chilling',
293  sleep: '💤 napping',
294  thinking: '💭 thinking',
295  writing: '⌨️ writing',
296  coding: '💻 coding',
297  web: '📰 web search',
298  reading: '🔍 reading',
299  bash: '⚡ commands',
300  agents: '🐾 subagents',
301  todo: '📋 planning',
302  ask: '👋 asking',
303  browser: '🌐 browsing',
304  testing: '🧪 tests',
305  commit: '📦 commit',
306  deploy: '🚀 deploy',
307  waiting: '🪧 needs your OK',
308  debugging: '🐞 debugging',
309  memory: '📔 memory',
310  database: '🗄️ database',
311  design: '🎨 design',
312  build: '🧱 build',
313  done: '🎉 done',
314  oops: '💧 oops',
315}
316
317function menuText(name: string) {
318  return [`**${name}** · Claude Friend`, '', ...COMMANDS.map(([cmd, what]) => `- \`${cmd}\`: ${what}`)].join('\n')
319}
320
321function skinsText(current: string) {
322  const line = (kind: 'hat' | 'hand') =>
323    SKINS.filter(s => s.kind === kind)
324      .map(s => `${s.emoji} \`/friend ${s.id}\`${s.id === current ? ' (wearing)' : ''}`)
325      .join('  ')
326  return ['**Skins**', '', `Hats: ${line('hat')}`, '', `In his hand: ${line('hand')}`, '', 'Take it off with `/friend noskin`.'].join('\n')
327}
328
329function scenesText() {
330  return ['**Scenes** (play one with `/friend <scene>`)', '', ACTIVITIES.map(a => `\`${a}\` ${SCENE_NAMES[a]}`).join(' · ')].join('\n')
331}
332
333// ── Drawing ──────────────────────────────────────────────────────────────
334
335async function currentActivity($: EngineInterface, isWorking: boolean) {
336  const shownPreview = await read($, preview)
337  if (shownPreview) return { activity: shownPreview, detail: '' }
338  const now = await read($, scene)
339  if (!isWorking && !RESTING.has(now.activity)) return { activity: 'idle' as Activity, detail: '' }
340  if (isWorking && RESTING.has(now.activity)) return { activity: 'thinking' as Activity, detail: '' }
341  return now
342}
343
344// What to draw right now: the hello wave while he arrives, else a played
345// scene, else what Claude is doing. isWorking is null where the surface
346// doesn't say (the pane), so the scene is drawn as it stands.
347async function friendLook($: EngineInterface, isWorking: boolean | null) {
348  const wearingId = await read($, skin)
349  const worn = skinById(wearingId)
350  if (await read($, isArriving)) {
351    const name = await read($, friendName)
352    return {
353      art: renderHello(name, wearingId),
354      alt: `${name} has arrived!`,
355      glyph: `👋 ${name} has arrived! (•‿•)/${worn ? ` ${worn.emoji}` : ''}`,
356    }
357  }
358  let now: Scene
359  if (isWorking === null) {
360    const shownPreview = await read($, preview)
361    now = shownPreview ? { activity: shownPreview, detail: '' } : await read($, scene)
362  } else {
363    now = await currentActivity($, isWorking)
364  }
365  return {
366    art: renderScene(now.activity, wearingId),
367    // Only the character shows; the label lives on for screen readers.
368    alt: `Claude ${LABELS[now.activity]}${now.detail ? `: ${now.detail}` : ''}`,
369    glyph: `${GLYPHS[now.activity]}${worn ? ` ${worn.emoji}` : ''}`,
370  }
371}
372
373async function moveTo($: EngineInterface, where: Place) {
374  await setOn($, true)
375  await setPlace($, where)
376  if (where === 'pane') await openPane($)
377  else await $.ui.close({ id: PANE })
378}
379
380async function wearSkin($: EngineInterface, id: string) {
381  await setSkin($, id)
382  await setOn($, true)
383}
384
385export const register: Register = on => {
386  on('session.start', async ($, e, next) => {
387    await $.command.register({
388      name: 'friend',
389      description: 'Call or send off your friend; /friend menu shows everything else',
390      argumentHint: '[menu|skins|scenes|name <name>|<skin>|<scene>|stats|pane]',
391    })
392    const savedOn = await $.store.get('isOn')
393    const savedPlace = await $.store.get('place')
394    const savedSkin = await $.store.get('skin')
395    const savedName = await $.store.get('name')
396    await update($, isOn, () => savedOn !== false)
397    await update($, place, () => (savedPlace === 'pane' ? 'pane' : 'stage'))
398    await update($, preview, () => null)
399    await update($, skin, () => (typeof savedSkin === 'string' && skinById(savedSkin) ? savedSkin : ''))
400    await update($, friendName, () => (typeof savedName === 'string' && cleanName(savedName)) || DEFAULT_NAME)
401    await loadStats($)
402    await show($, 'idle')
403    if (savedOn !== false) {
404      if (savedPlace === 'pane') void openPane($)
405      await arrive($)
406    }
407    return next(e)
408  })
409
410  on('command.run', { command: 'friend' }, async ($, e) => {
411    const raw = e.args.trim()
412    const arg = raw.toLowerCase()
413    const name = await read($, friendName)
414
415    // /friend calls him, or sends him off if he is already here.
416    if (arg === '' || arg === 'on' || arg === 'off') {
417      const value = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isOn))
418      if (value) {
419        await callFriend($)
420        return { text: `👋 ${name} has arrived!` }
421      }
422      await setOn($, false)
423      if ((await read($, place)) === 'pane') await $.ui.close({ id: PANE })
424      return { text: `${name} went for a walk. /friend calls them back.` }
425    }
426
427    if (arg === 'menu' || arg === 'help') return { text: menuText(name) }
428    if (arg === 'skins' || arg === 'skin') return { text: skinsText(await read($, skin)) }
429    if (arg === 'scenes') return { text: scenesText() }
430
431    if (arg === 'name' || arg.startsWith('name ')) {
432      const wanted = cleanName(raw.slice(4))
433      if (!wanted) return { text: `Your friend is called ${name}. Rename them with /friend name <name>, e.g. /friend name Bob.` }
434      await setName($, wanted)
435      await callFriend($)
436      return { text: `Nice to meet you! Your friend is now called ${wanted}.` }
437    }
438
439    if (arg === 'noskin' || arg === 'none') {
440      await setSkin($, '')
441      return { text: `${name} took the skin off.` }
442    }
443
444    const chosen = skinById(arg)
445    if (chosen) {
446      await setSkin($, chosen.id)
447      await callFriend($)
448      return { text: `${chosen.emoji} ${name} is wearing the ${chosen.label.toLowerCase()}.` }
449    }
450
451    if (arg === 'pane') {
452      await setOn($, true)
453      await setPlace($, 'pane')
454      const opened = await openPane($)
455      return {
456        text: opened.isPlaced
457          ? 'Your friend moved into a side pane. /friend stage brings him back above the prompt.'
458          : 'Your friend will appear in a side pane once there is room for it.',
459      }
460    }
461
462    if (arg === 'stage') {
463      await setOn($, true)
464      await setPlace($, 'stage')
465      await $.ui.close({ id: PANE })
466      return { text: 'Your friend is back above the prompt.' }
467    }
468
469    if (arg === 'stats') {
470      const day = await today($)
471      const s = stats && stats.day === day ? stats : await loadStats($)
472      return { text: statsText(s) }
473    }
474
475    if ((ACTIVITIES as string[]).includes(arg)) {
476      await setOn($, true)
477      await startPreview($, arg as Activity)
478      return { text: `Playing "${arg}" for a few seconds.` }
479    }
480
481    return { text: `There's no "${raw}" yet.\n\n${menuText(name)}` }
482  })
483
484  // The person closed the pane from its own mark: go back to the stage.
485  on('ui.close', async ($, e, next) => {
486    if (e.id === PANE && e.origin.kind === 'person') await setPlace($, 'stage')
487    return next(e)
488  })
489
490  on('turn.start', async ($, e, next) => {
491    running.clear()
492    await show($, 'thinking')
493    return next(e)
494  })
495
496  // The model's response as it streams: thinking, visible text, and the start
497  // of each tool call (whose arguments can take a while to write, an Edit's
498  // new content above all), so the friend switches props before the tool runs.
499  on('turn.step', async function* ($, e, next) {
500    const isMain = e.agentId === undefined
501    const tools = new Map<number, { name: string; json: string }>()
502    for await (const chunk of next(e)) {
503      if (isMain) {
504        try {
505          if (chunk.kind === 'thinking') await show($, 'thinking')
506          else if (chunk.kind === 'text' && chunk.text.trim()) await show($, 'writing')
507          else if (chunk.kind === 'tool') {
508            tools.set(chunk.index, { name: chunk.name, json: '' })
509            await show($, classify(chunk.name), detailOf(chunk.name, {}))
510          } else if (chunk.kind === 'input') {
511            const t = tools.get(chunk.index)
512            if (t && t.json.length < 8000) {
513              t.json += chunk.json
514              const input = partialInput(t.json)
515              await show($, classify(t.name, input), detailOf(t.name, input))
516            }
517          }
518        } catch {
519          // Never let the friend get in the way of the response.
520        }
521      }
522      yield chunk
523    }
524  })
525
526  on('tool.call', async ($, e, next) => {
527    if (e.agentId !== undefined) return next(e)
528    const input = e as unknown as Record<string, unknown>
529    const id = e.tool_use_id ?? `${e.tool}:${running.size}`
530    const now: Scene = {
531      activity: classify(e.tool, input),
532      detail: detailOf(e.tool, input),
533    }
534    running.set(id, now)
535    await show($, now.activity, now.detail)
536    const tally = TALLY[now.activity]
537    if (tally) await count($, tally)
538    try {
539      return await next(e)
540    } finally {
541      running.delete(id)
542      const still = [...running.values()].pop()
543      if (still) await show($, still.activity, still.detail)
544      else await show($, 'thinking')
545    }
546  })
547
548  // The engine settles whether a call may run inside tool.call's next(). When
549  // the answer is to ask the person, he holds up an OK? sign; the app reports
550  // no moment of approval, so the sign stays until the call finishes.
551  on('tool.check', async ($, e, next) => {
552    const verdict = await next(e)
553    // Only calls tool.call is tracking: the main conversation's, not a
554    // subagent's and not another plugin's query.
555    const id = e.tool_use_id
556    if (id && running.has(id) && verdict.decision === 'ask') {
557      try {
558        const input = (e.input ?? {}) as Record<string, unknown>
559        const waitingScene: Scene = { activity: 'waiting', detail: detailOf(e.tool, input) }
560        running.set(id, waitingScene)
561        await show($, waitingScene.activity, waitingScene.detail)
562      } catch {
563        // The sign is a nicety; the verdict stands either way.
564      }
565    }
566    return verdict
567  })
568
569  on('turn.complete', async ($, e, next) => {
570    if (e.agentId === undefined) {
571      running.clear()
572      if (e.reason === 'answer') {
573        await show($, 'done')
574        await count($, 'done')
575      } else if (e.reason === 'aborted') {
576        await show($, 'idle')
577      } else {
578        await show($, 'oops')
579        await count($, 'snags')
580      }
581    }
582    return next(e)
583  })
584
585  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
586    if (e.props.hasSurvey || !(await read($, isOn)) || (await read($, place)) === 'pane') return next(e)
587
588    const look = await friendLook($, e.props.isWorking)
589
590    if (e.surface === 'terminal') {
591      const { Box, Text } = $.ui.resolve(e)
592      return (
593        <Box flexDirection="row" justifyContent="flex-end">
594          <Text color="#D97757">{look.glyph}</Text>
595        </Box>
596      )
597    }
598
599    const { Box, Svg } = $.ui.resolve(e)
600    return (
601      <Box flexDirection="row" justifyContent="flex-end">
602        <Svg source={look.art.source} alt={look.alt} width={look.art.width} height={look.art.height} />
603      </Box>
604    )
605  })
606
607  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
608    // The pane has no isWorking flag of its own; the scene state already
609    // follows the turn, so draw it as it stands.
610    const look = await friendLook($, null)
611
612    if (e.surface === 'terminal') {
613      const { Box, Text } = $.ui.resolve(e)
614      return (
615        <Box flexDirection="column" alignItems="center">
616          <Text color="#D97757">{look.glyph}</Text>
617        </Box>
618      )
619    }
620
621    const { Box, Svg } = $.ui.resolve(e)
622    return (
623      <Box flexDirection="column" alignItems="center">
624        <Svg source={look.art.source} alt={look.alt} width={look.art.width} height={look.art.height} />
625      </Box>
626    )
627  })
628
629  // /friend menu, skins and scenes print text the model reads; on surfaces that
630  // draw pictures, the row is drawn as a card with buttons instead.
631  on('ui.render', { component: 'CommandOutput', props: { command: 'friend' } }, async ($, e, next) => {
632    const arg = e.props.args.trim().toLowerCase()
633    const isCard = ['menu', 'help', 'skins', 'skin', 'scenes'].includes(arg)
634    if (!isCard || e.props.isErrored || e.surface === 'terminal') return next(e)
635
636    const { Box, Text, Button, Svg } = $.ui.resolve(e)
637    const name = await read($, friendName)
638    const isHere = await read($, isOn)
639    const wearing = await read($, skin)
640
641    if (arg === 'menu' || arg === 'help') {
642      return (
643        <Box flexDirection="column" gap={1}>
644          <Text bold>
645            {name} <Text color="#D97757">· Claude Friend</Text>
646          </Text>
647          <Box flexDirection="row" flexWrap="wrap" gap={1}>
648            <Button
649              key="toggle"
650              label={isHere ? 'Send off' : 'Call'}
651              variant="primary"
652              onPress={() => void (isHere ? setOn($, false) : callFriend($))}
653            />
654            <Button key="hello" label="Say hello" onPress={() => void callFriend($)} />
655            <Button key="pane" label="Side pane" onPress={() => void moveTo($, 'pane')} />
656            <Button key="stage" label="Above the prompt" onPress={() => void moveTo($, 'stage')} />
657          </Box>
658          <Box flexDirection="column">
659            {COMMANDS.map(([cmd, what]) => (
660              <Text key={cmd}>
661                <Text color="#D97757">{cmd}</Text>
662                <Text dimColor> {what}</Text>
663              </Text>
664            ))}
665          </Box>
666        </Box>
667      )
668    }
669
670    if (arg === 'skins' || arg === 'skin') {
671      const cell = (id: string, label: string) => {
672        const art = renderScene('idle', id)
673        const isWorn = wearing === id
674        return (
675          <Box key={`skin-${id || 'none'}`} flexDirection="column" alignItems="center" width={16}>
676            <Svg source={art.source} alt={label} width={Math.round(art.width * 0.7)} height={Math.round(art.height * 0.7)} />
677            <Button
678              key={`wear-${id || 'none'}`}
679              label={isWorn ? `✓ ${label}` : label}
680              variant={isWorn ? 'primary' : 'secondary'}
681              onPress={() => void wearSkin($, id)}
682            />
683          </Box>
684        )
685      }
686      return (
687        <Box flexDirection="column" gap={1}>
688          <Text bold>
689            Skins for {name} <Text dimColor>· click one to wear it, or type /friend crown</Text>
690          </Text>
691          <Text dimColor>Hats</Text>
692          <Box flexDirection="row" flexWrap="wrap" gap={1}>
693            {SKINS.filter(s => s.kind === 'hat').map(s => cell(s.id, `${s.emoji} ${s.label}`))}
694          </Box>
695          <Text dimColor>In his hand</Text>
696          <Box flexDirection="row" flexWrap="wrap" gap={1}>
697            {SKINS.filter(s => s.kind === 'hand').map(s => cell(s.id, `${s.emoji} ${s.label}`))}
698            {cell('', 'No skin')}
699          </Box>
700        </Box>
701      )
702    }
703
704    return (
705      <Box flexDirection="column" gap={1}>
706        <Text bold>
707          Scenes <Text dimColor>· click one to play it, or type /friend deploy</Text>
708        </Text>
709        <Box flexDirection="row" flexWrap="wrap" gap={1}>
710          {ACTIVITIES.map(a => (
711            <Button key={`play-${a}`} label={SCENE_NAMES[a]} onPress={() => void startPreview($, a)} />
712          ))}
713        </Box>
714      </Box>
715    )
716  })
717}
718
hooks/classify.ts 67 lines
1import type { Activity } from '../types'
2
3// Which scene a tool call gets. Pure: no $, so it is easy to reason about and
4// test. The streaming path calls it with partial input as the arguments arrive,
5// so a Bash call can start as 'bash' and become 'testing' once its command is in.
6
7// Shell commands, checked in this order; the first that matches wins.
8const TEST =
9  /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(vitest|jest|pytest|mocha|phpunit|rspec|karma)\b|\bcypress\s+run\b|\bplaywright\s+test\b|\bgo\s+test\b|\bcargo\s+(test|nextest)\b|\bdotnet\s+test\b|\bgradlew?\s+test\b|\bmvn\b.*\btest\b|\bpython\d*\s+-m\s+(pytest|unittest)\b|\bclaude\s+plugin\s+test\b/i
10const COMMIT = /\bgit(\s+-[cC]\s+\S+)*\s+commit\b/
11const DEPLOY =
12  /\bgit(\s+-[cC]\s+\S+)*\s+push\b|\b(npm|pnpm|yarn)\s+publish\b|\bdocker\s+push\b|\bkubectl\s+(apply|rollout)\b|\bhelm\s+(install|upgrade)\b|\bgh\s+release\s+create\b|\bvercel\b|\bnetlify\s+deploy\b|\b(fly|flyctl)\s+deploy\b|\bfirebase\s+deploy\b|\bwrangler\s+(deploy|publish)\b|\bdeploy\b/i
13const BUILD =
14  /\b(npm|pnpm|yarn|bun)\s+(run\s+)?build\b|\btsc\b|\bvite\s+build\b|\bnext\s+build\b|\b(webpack|rollup|esbuild|msbuild)\b|\bturbo\s+(run\s+)?build\b|\b(cargo|go|dotnet|swift)\s+build\b|\b(make|cmake)\b|\bdocker\s+(compose\s+)?build\b|\bgradlew?\s+(build|assemble)\b|\bmvn\b.*\b(package|install|compile)\b/i
15const DATABASE =
16  /\b(psql|mysql|sqlite3|mongosh?|redis-cli|pg_dump|pg_restore|mysqldump)\b|\bprisma\s+(migrate|db|studio|generate)\b|\bdrizzle-kit\b|\bknex\s+migrate\b|\bsupabase\s+(db|migration)\b|\balembic\b|\brails\s+db:|\bmanage\.py\s+(migrate|makemigrations|dbshell)\b/i
17const DEBUG = /--inspect(-brk)?\b|\b(gdb|lldb|pdb|ipdb|dlv|ndb|valgrind|strace)\b|\bnode\s+inspect\b|\bpython\d*\s+-m\s+(pdb|debugpy)\b/i
18
19// Files written or edited.
20const MEMORY_FILE = /(^|[\\/])(CLAUDE|MEMORY|AGENTS|GEMINI)\.md$|[\\/]memory[\\/]/i
21const DESIGN_FILE = /\.(css|scss|sass|less|styl|svg)$|(^|[\\/])tailwind\.config\.\w+$/i
22const DATABASE_FILE = /\.(sql|prisma|dbml)$|[\\/]migrations?[\\/]/i
23const DOC_FILE = /\.(md|mdx|txt|rst|adoc|org|tex)$/i
24
25export function commandActivity(command: string): Activity {
26  if (TEST.test(command)) return 'testing'
27  if (COMMIT.test(command)) return 'commit'
28  if (DEPLOY.test(command)) return 'deploy'
29  if (BUILD.test(command)) return 'build'
30  if (DATABASE.test(command)) return 'database'
31  if (DEBUG.test(command)) return 'debugging'
32  return 'bash'
33}
34
35export function fileActivity(path: string): Activity {
36  if (MEMORY_FILE.test(path)) return 'memory'
37  if (DESIGN_FILE.test(path)) return 'design'
38  if (DATABASE_FILE.test(path)) return 'database'
39  if (DOC_FILE.test(path)) return 'writing'
40  return 'coding'
41}
42
43export function classify(tool: string, input: Record<string, unknown> = {}): Activity {
44  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
45
46  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return fileActivity(str('file_path') || str('notebook_path'))
47  if (/^(Bash|PowerShell)$/.test(tool)) return commandActivity(str('command'))
48  if (/^(Read|Grep|Glob|LS|NotebookRead)$/.test(tool)) return 'reading'
49  if (/^(WebSearch|WebFetch)$/.test(tool)) return 'web'
50  if (/^(Agent|Task|SendMessage)$/.test(tool)) return 'agents'
51  if (/^(TodoWrite|TaskCreate|TaskUpdate|TaskList)$/.test(tool)) return 'todo'
52  if (/^(AskUserQuestion|ExitPlanMode)$/.test(tool)) return 'ask'
53  if (tool === 'Skill') return /debug/i.test(str('skill')) ? 'debugging' : 'reading'
54
55  // MCP and plugin tools, by what their names say. Order matters: a Supabase
56  // deploy is a deploy, its docs search is a web lookup, the rest is database.
57  if (/browser|chrome|computer|playwright|puppeteer/i.test(tool)) return 'browser'
58  if (/deploy|publish|release/i.test(tool)) return 'deploy'
59  if (/search_docs|docs|context7|exa|firecrawl|web_?search|fetch/i.test(tool)) return 'web'
60  if (/supabase|postgres|mysql|sqlite|mongo|redis|database|_sql|migration|list_tables|query/i.test(tool)) return 'database'
61  if (/figma|canva|design|excalidraw/i.test(tool)) return 'design'
62  if (/memory|remember/i.test(tool)) return 'memory'
63  if (/debug/i.test(tool)) return 'debugging'
64  if (/search/i.test(tool)) return 'web'
65  return 'bash'
66}
67
hooks/scenes.ts 1071 lines
1import type { Activity } from '../types'
2import { skinById } from './skins'
3
4// Every scene shares one coordinate system: 40 units tall, Clawd standing at
5// x 60..78 on the right, props to his left. A scene crops its viewBox from the
6// left only, so its right edge (and Clawd) never moves between scenes. The
7// right edge leaves room for what he holds in his right hand.
8const RIGHT = 88
9const HEIGHT = 40
10export const SCALE = 2
11
12const P = {
13  body: '#D97757',
14  light: '#E5906F',
15  shade: '#BF5F3E',
16  eye: '#1F1E1D',
17  ink: '#3B3A36',
18  steel: '#8F8C84',
19  steelDark: '#5F5D58',
20  steelLight: '#BDBAB2',
21  paper: '#FBF9F4',
22  paperEdge: '#D3CFC4',
23  line: '#A8A49A',
24  outline: '#B9B6AE',
25  green: '#4E9A62',
26  yellow: '#E8B04B',
27  red: '#E96B5F',
28  mint: '#6BBE6E',
29}
30
31const r = (x: number, y: number, w: number, h: number, fill: string, extra = '') =>
32  `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"${extra ? ' ' + extra : ''}/>`
33
34const c = (cx: number, cy: number, rad: number, fill: string, extra = '') =>
35  `<circle cx="${cx}" cy="${cy}" r="${rad}" fill="${fill}"${extra ? ' ' + extra : ''}/>`
36
37const BASE_CSS = `
38:root{background:transparent;overflow:hidden}
39*{transform-box:fill-box}
40.br{animation:br 3.2s ease-in-out infinite;transform-origin:50% 100%}
41@keyframes br{0%,100%{transform:scaleY(1)}50%{transform:scaleY(1.035)}}
42.bl{animation:bl 4.6s infinite;transform-origin:50% 50%}
43@keyframes bl{0%,91%,97%,100%{transform:scaleY(1)}94%{transform:scaleY(.12)}}
44.tap{animation:tap .34s steps(1,end) infinite}
45.tap2{animation:tap .34s steps(1,end) -.17s infinite}
46@keyframes tap{0%{transform:translateY(0)}50%{transform:translateY(.9px)}}
47@media (prefers-reduced-motion:reduce){*{animation:none!important}}
48`
49
50// ---------------------------------------------------------------- Clawd
51
52type Pose = {
53  eyes?: string
54  armL?: string
55  armR?: string
56  upper?: string
57  whole?: string
58  // Drawn inside the breathing group, so it moves with his head.
59  hat?: string
60  legs?: string
61}
62
63const SHADOW = `<ellipse cx="69" cy="35.4" rx="10" ry="1.1" fill="#000" opacity=".13"/>`
64const LEGS = [61, 65, 71, 75].map(x => r(x, 31, 2, 4, P.shade)).join('')
65const ARM_L = r(57, 24, 3, 3, P.body)
66const ARM_R = r(78, 24, 3, 3, P.body)
67
68function eyes(dx = -1, dy = 0, look = '') {
69  return (
70    `<g transform="translate(${dx} ${dy})"><g class="${look}">` +
71    r(64, 21, 2, 4, P.eye, 'class="bl"') +
72    r(72, 21, 2, 4, P.eye, 'class="bl"') +
73    `</g></g>`
74  )
75}
76
77// The skin being drawn, set by renderScene around a build. A scene's own hat
78// (the beret, the hard hat) wins over a skin's; a held item shows only while
79// his right hand is in its resting place, not busy with a prop.
80let wearing: { hat: string; hand: string } = { hat: '', hand: '' }
81
82function clawd(p: Pose = {}, shadow = SHADOW) {
83  const isHandFree = p.armR === undefined || p.armR.includes('x="78" y="24"')
84  return (
85    shadow +
86    `<g class="${p.whole ?? ''}">` +
87    (p.legs ?? LEGS) +
88    `<g class="${p.upper ?? 'br'}">` +
89    (p.armL ?? ARM_L) +
90    (p.armR ?? ARM_R) +
91    r(60, 18, 18, 13, P.body) +
92    r(60, 18, 18, 1, P.light) +
93    r(60, 29, 18, 2, P.shade) +
94    (p.eyes ?? eyes()) +
95    (p.hat ?? wearing.hat) +
96    (isHandFree ? wearing.hand : '') +
97    `</g></g>`
98  )
99}
100
101// ---------------------------------------------------------------- scenes
102
103type Built = { x: number; body: string; css?: string }
104
105function idle(): Built {
106  return {
107    x: 50,
108    css: `.look{animation:look 9s ease-in-out infinite}
109@keyframes look{0%,38%{transform:translateX(0)}44%,58%{transform:translateX(-1.2px)}64%,78%{transform:translateX(0)}84%,94%{transform:translateX(1px)}100%{transform:translateX(0)}}`,
110    body: clawd({ eyes: eyes(0, 0, 'look') }),
111  }
112}
113
114function sleep(): Built {
115  const z = (delay: number) =>
116    `<g transform="translate(58 15)"><g class="zz" style="animation-delay:${delay}s">` +
117    `<path d="M0 0h2.6l-2.6 2.6h2.6" fill="none" stroke="${P.steel}" stroke-width=".7" stroke-linecap="round" stroke-linejoin="round"/>` +
118    `</g></g>`
119  return {
120    x: 44,
121    css: `.br{animation-duration:4.8s}
122.zz{opacity:0;animation:zz 3.6s ease-out infinite}
123@keyframes zz{0%{opacity:0;transform:translate(0,0) scale(.6)}20%{opacity:1}100%{opacity:0;transform:translate(-9px,-12px) scale(1.4)}}`,
124    body:
125      clawd({
126        eyes: r(63.5, 23.5, 3, 0.9, P.eye) + r(71.5, 23.5, 3, 0.9, P.eye),
127      }) +
128      z(0) +
129      z(-1.2) +
130      z(-2.4),
131  }
132}
133
134function thinking(): Built {
135  const blobs: [number, number, number][] = [
136    [30, 11.5, 4.2],
137    [35, 8, 5],
138    [41, 8.6, 4.6],
139    [45.5, 11.6, 3.6],
140    [40, 13.4, 4],
141    [33.5, 13.6, 3.8],
142  ]
143  const cloud =
144    blobs.map(([x, y, rad]) => c(x, y, rad + 0.6, P.outline)).join('') +
145    blobs.map(([x, y, rad]) => c(x, y, rad, '#FFFFFF')).join('')
146  return {
147    x: 22,
148    css: `.sway{animation:sway 4s ease-in-out infinite;transform-origin:50% 100%}
149@keyframes sway{0%,100%{transform:rotate(-1.4deg)}50%{transform:rotate(1.4deg)}}
150.cloud{animation:cloud 3s ease-in-out infinite;transform-origin:80% 80%}
151@keyframes cloud{0%,100%{transform:scale(1)}50%{transform:scale(1.03)}}
152.t1{animation:tr 3s ease-in-out infinite}.t2{animation:tr 3s ease-in-out -.4s infinite}
153@keyframes tr{0%,100%{opacity:1}50%{opacity:.55}}
154.look{animation:look 6s ease-in-out infinite}
155@keyframes look{0%,45%{transform:translate(0,0)}55%,90%{transform:translate(.6px,-.3px)}100%{transform:translate(0,0)}}`,
156    body:
157      `<g class="t1">${c(55.5, 17, 1, '#FFFFFF', `stroke="${P.outline}" stroke-width=".6"`)}</g>` +
158      `<g class="t2">${c(51.6, 13.6, 1.6, '#FFFFFF', `stroke="${P.outline}" stroke-width=".6"`)}</g>` +
159      `<g class="cloud">${cloud}</g>` +
160      clawd({
161        whole: 'sway',
162        eyes: eyes(-1.2, -1.2, 'look'),
163        armL: r(57, 22, 3, 3, P.body),
164      }),
165  }
166}
167
168// Clawd sitting behind a prop that faces him, seen from the far side: lifted
169// three units so his eyes clear it, his arms reduced to hands at its sides.
170function behind(p: Pose) {
171  return `<g transform="translate(0 -3)">${clawd({ armL: '', armR: '', ...p }, '')}</g>`
172}
173
174const DESK_SHADOW = `<ellipse cx="69" cy="36" rx="16" ry="1" fill="#000" opacity=".13"/>`
175
176// Eyes cast down at the prop in front of him.
177const DOWN = -0.6
178
179function writing(): Built {
180  // The sheet faces Clawd: from here its typed lines show through backwards,
181  // growing right to left, and the carriage travels to our right.
182  const lines: [number, number][] = [
183    [22.4, 8],
184    [23.8, 6.5],
185    [25.2, 7.5],
186  ]
187  const period = 100 / lines.length
188  const lineCss = lines
189    .map((_, i) => {
190      const s = +(i * period).toFixed(2)
191      const e = +(s + period * 0.8).toFixed(2)
192      return `.l${i}{transform-origin:100% 50%;animation:l${i} 3.6s infinite}
193@keyframes l${i}{0%${s ? `,${s}%` : ''}{transform:scaleX(0);opacity:1;animation-timing-function:steps(7,end)}${e}%,94%{transform:scaleX(1);opacity:1}100%{transform:scaleX(1);opacity:0}}`
194    })
195    .join('\n')
196  const slats = [30.2, 31.4, 32.6].map(y => r(61.5, y, 15, 0.5, '#4A7068', 'rx=".25"')).join('')
197  const typewriter =
198    `<g class="car">` +
199    r(63, 21.2, 12, 6.2, '#F3F0E8', `stroke="${P.paperEdge}" stroke-width=".45"`) +
200    lines.map(([y, w], i) => r(73.5 - w, y, w, 0.6, '#8A877F', `opacity=".38" class="l${i}"`)).join('') +
201    r(58, 26.6, 22, 2, P.ink, 'rx=".8"') +
202    c(57.6, 27.6, 1.3, '#6B6A65') +
203    c(80.4, 27.6, 1.3, '#6B6A65') +
204    r(80.2, 26.3, 3.2, 0.8, P.steel, 'rx=".4"') +
205    `</g>` +
206    `<path d="M56.5 28.6H81.5L83.4 34.6H54.6Z" fill="#5E8C82"/>` +
207    r(56.5, 28.6, 25, 1, '#72A396') +
208    slats +
209    r(67.4, 33.4, 3.2, 0.6, '#C9A85C', 'rx=".3"') +
210    r(54, 34.6, 30, 1.3, '#46695F', 'rx=".6"') +
211    `<g transform="translate(55.4 24.2)"><g class="ding"><path d="M0-1.5L.4-.4 1.5 0 .4.4 0 1.5-.4.4-1.5 0-.4-.4Z" fill="${P.yellow}"/></g></g>`
212  return {
213    x: 50,
214    css: `${lineCss}
215.car{animation:car 1.2s infinite}
216@keyframes car{0%{transform:translateX(0);animation-timing-function:steps(7,end)}80%{transform:translateX(3px);animation-timing-function:linear}88%{transform:translateX(3px);animation-timing-function:ease-out}100%{transform:translateX(0)}}
217.ding{opacity:0;animation:ding 1.2s infinite;transform-origin:50% 50%}
218@keyframes ding{0%,82%{opacity:0;transform:scale(.4)}90%{opacity:1;transform:scale(1.15)}100%{opacity:0;transform:scale(.8)}}
219.look{animation:look 1.2s infinite}
220@keyframes look{0%{transform:translateX(-.6px);animation-timing-function:steps(7,end)}80%,88%{transform:translateX(.6px)}100%{transform:translateX(-.6px)}}`,
221    body:
222      DESK_SHADOW +
223      behind({ eyes: eyes(0, DOWN, 'look') }) +
224      typewriter +
225      r(54.6, 29.8, 2.3, 2.2, P.body, 'class="tap"') +
226      r(81.1, 29.8, 2.3, 2.2, P.body, 'class="tap2"'),
227  }
228}
229
230function coding(): Built {
231  // The lid faces Clawd: we see its back with the logo, and the screen's
232  // light flickers on his face as the code scrolls by on his side.
233  const rays = Array.from({ length: 8 }, (_, i) => {
234    const a = (i / 8) * Math.PI * 2 + Math.PI / 8
235    const [x1, y1] = [69 + Math.cos(a) * 0.55, 27.2 + Math.sin(a) * 0.55]
236    const [x2, y2] = [69 + Math.cos(a) * 1.9, 27.2 + Math.sin(a) * 1.9]
237    return `M${x1.toFixed(2)} ${y1.toFixed(2)}L${x2.toFixed(2)} ${y2.toFixed(2)}`
238  }).join('')
239  const laptop =
240    r(56, 21.6, 26, 11.4, '#B9B6AE', 'rx="1.3"') +
241    r(56.7, 22.3, 24.6, 10, '#C6C3BB', 'rx="1"') +
242    r(57.4, 22.6, 23.2, 0.5, '#D6D3CC', 'rx=".25"') +
243    `<path class="logo" d="${rays}" stroke="${P.body}" stroke-width=".75" stroke-linecap="round"/>` +
244    r(58, 33, 22, 0.9, '#6B6A65', 'rx=".4"') +
245    `<path d="M55.4 33.9H82.6L83.6 35.6H54.4Z" fill="#A6A39A"/>` +
246    r(54.4, 35.5, 29.2, 0.6, '#8C8A83', 'rx=".3"')
247  return {
248    x: 50,
249    css: `.glow{animation:glow 1.8s steps(6,end) infinite}
250@keyframes glow{0%{opacity:.5}16%{opacity:.75}33%{opacity:.55}50%{opacity:.85}66%{opacity:.6}83%{opacity:.7}100%{opacity:.5}}
251.logo{animation:logo 3s ease-in-out infinite}
252@keyframes logo{0%,100%{opacity:.85}50%{opacity:1}}
253.read{animation:read 2.4s ease-in-out infinite}
254@keyframes read{0%,100%{transform:translateX(-.7px)}50%{transform:translateX(.7px)}}`,
255    body:
256      `<defs><linearGradient id="screen" x1="0" y1="1" x2="0" y2="0">` +
257      `<stop offset="0" stop-color="#BFD9FF" stop-opacity=".4"/><stop offset="1" stop-color="#BFD9FF" stop-opacity="0"/>` +
258      `</linearGradient></defs>` +
259      DESK_SHADOW +
260      behind({ eyes: eyes(0, DOWN, 'read') }) +
261      r(60, 15.5, 18, 6.3, 'url(#screen)', 'class="glow"') +
262      laptop +
263      r(54.4, 31.3, 2.4, 2.1, P.body, 'class="tap"') +
264      r(81.2, 31.3, 2.4, 2.1, P.body, 'class="tap2"'),
265  }
266}
267
268function web(): Built {
269  const steam = (x: number, d: number) =>
270    `<g transform="translate(${x} 27)"><g class="st" style="animation-delay:${d}s">` +
271    `<path d="M0 0c-.9-1.2.9-2 0-3.2s.9-2 0-3.2" fill="none" stroke="${P.outline}" stroke-width=".7" stroke-linecap="round"/>` +
272    `</g></g>`
273  const mug =
274    `<ellipse cx="43.5" cy="35.4" rx="4.6" ry=".8" fill="#000" opacity=".12"/>` +
275    `<path d="M40 30h-1.3a1.8 1.8 0 0 0 0 3.6H40" fill="none" stroke="#BDB8AC" stroke-width="1"/>` +
276    r(40, 28.5, 7, 6.6, '#EFEBE1', `rx="1" stroke="#BDB8AC" stroke-width=".5"`) +
277    `<ellipse cx="43.5" cy="28.9" rx="3.1" ry=".7" fill="#7A5236"/>` +
278    r(40.25, 31.2, 6.5, 1.1, P.body) +
279    steam(42.2, 0) +
280    steam(44.6, -1.2)
281  const paper =
282    r(55, 23.8, 28, 9.4, '#F4F1E8', `stroke="#C9C5BA" stroke-width=".5"`) +
283    r(68.8, 23.8, 0.45, 9.4, '#D8D4CA') +
284    r(57, 25, 10, 1.6, P.ink) +
285    [27.6, 29, 30.4, 31.8].map((y, i) => r(57, y, [10, 9, 10, 6.5][i], 0.6, P.line)).join('') +
286    r(70.8, 25, 4.6, 3.6, '#CFC9BB') +
287    `<path d="M71.2 28.2l1.5-1.6 1 1 .9-.8 1 1.4Z" fill="#A79F8C"/>` +
288    r(76.4, 25.2, 4.8, 0.9, '#6B6A65') +
289    r(76.4, 26.8, 4, 0.6, P.line) +
290    [29.6, 31, 32.2].map((y, i) => r(70.8, y, [10.2, 9.6, 8][i], 0.6, P.line)).join('')
291  return {
292    x: 36,
293    css: `.st{opacity:0;animation:st 2.4s ease-out infinite}
294@keyframes st{0%{opacity:0;transform:translateY(0)}30%{opacity:.85}100%{opacity:0;transform:translateY(-3px)}}
295.rustle{animation:rustle 3.4s ease-in-out infinite;transform-origin:50% 50%}
296@keyframes rustle{0%,100%{transform:rotate(-.7deg)}50%{transform:rotate(.7deg)}}
297.read{animation:read 3s linear infinite}
298@keyframes read{0%{transform:translateX(-1.3px)}40%{transform:translateX(1.3px)}50%{transform:translateX(-1.3px)}90%{transform:translateX(1.3px)}100%{transform:translateX(-1.3px)}}`,
299    body:
300      mug +
301      clawd({ eyes: eyes(0, -1.4, 'read'), armL: '', armR: '' }) +
302      `<g class="rustle">${paper}${r(53.8, 26.6, 2.2, 3, P.body)}${r(82, 26.6, 2, 3, P.body)}</g>`,
303  }
304}
305
306function reading(): Built {
307  const docLines = [17, 19, 21, 23, 25, 27, 29, 31]
308  const widths = [11, 9.5, 11.5, 8, 10.5, 11, 7.5, 9]
309  const docs =
310    r(26, 9, 16, 22.5, '#E6E2D8', `stroke="#CFCBC1" stroke-width=".4"`) +
311    `<path d="M22 11H34L38 15V34H22Z" fill="${P.paper}" stroke="#CFCBC1" stroke-width=".5" stroke-linejoin="round"/>` +
312    `<path d="M34 11V15H38Z" fill="#E6E2D8" stroke="#CFCBC1" stroke-width=".4" stroke-linejoin="round"/>` +
313    r(24.5, 13.3, 6, 1, '#6B6A65') +
314    `<g class="hl">${r(23.6, 16.45, 13.2, 1.7, '#F2C94C', 'opacity=".45" rx=".4"')}</g>` +
315    docLines.map((y, i) => r(24.5, y + 0.05, widths[i], 0.7, P.line)).join('')
316  const lens =
317    `<path d="M61 26.4L57.6 29.8" stroke="#8A6A4F" stroke-width="1.7" stroke-linecap="round"/>` +
318    c(64, 23, 4.2, 'rgba(190,220,245,.35)', `stroke="${P.steelDark}" stroke-width="1.3"`) +
319    `<g class="scan">${r(62.6, 20.2, 2.8, 5.6, P.eye, 'class="bl"')}</g>` +
320    `<path d="M61.6 21.4a3 3 0 0 1 2-1.7" fill="none" stroke="#FFFFFF" stroke-width=".6" stroke-linecap="round" opacity=".9"/>`
321  return {
322    x: 30,
323    css: `.hl{animation:hl 5.6s steps(7,end) infinite}
324@keyframes hl{0%{transform:translateY(0)}100%{transform:translateY(14px)}}
325.lean{animation:lean 2.8s ease-in-out infinite alternate}
326@keyframes lean{0%{transform:translateX(0)}100%{transform:translateX(-1.2px)}}
327.scan{animation:scan 2.8s ease-in-out infinite alternate}
328@keyframes scan{0%{transform:translate(-.3px,-.5px)}100%{transform:translate(-.6px,.6px)}}`,
329    body:
330      `<g transform="translate(12 0)">${docs}</g>` +
331      `<g class="lean">` +
332      clawd({
333        eyes: r(71.4, 21, 2, 4, P.eye, 'class="bl"'),
334        armL: r(56, 28.4, 3, 3, P.body),
335      }) +
336      lens +
337      `</g>`,
338  }
339}
340
341function bash(): Built {
342  // Seeded glyphs keep the markup identical on every redraw, so the running
343  // animation never restarts.
344  let seed = 7
345  const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647
346  const GLYPHS = '01アイウエカキクコサシスセソタチツテナニハヒフホマミムメモヤユラリルレロワ$#<>{}=+*/'
347  const glyph = () => {
348    const g = GLYPHS[Math.floor(rand() * GLYPHS.length)]
349    return g === '<' ? '&lt;' : g === '>' ? '&gt;' : g
350  }
351  const columns = Array.from({ length: 9 }, (_, i) => {
352    const x = 55 + i * 3.2
353    const duration = (1.7 + rand() * 1.6).toFixed(2)
354    const delay = (-rand() * 3).toFixed(2)
355    const trail = 5 + Math.floor(rand() * 3)
356    const chars = Array.from({ length: trail }, (_, j) => {
357      const isHead = j === trail - 1
358      const opacity = isHead ? 1 : ((j + 1) / trail) * 0.75
359      const fill = isHead ? '#C9FFD6' : '#3DDC84'
360      return `<text x="${x.toFixed(1)}" y="${(j * 3.2).toFixed(1)}" fill="${fill}" opacity="${opacity.toFixed(2)}">${glyph()}</text>`
361    }).join('')
362    return `<g class="rain" style="animation-duration:${duration}s;animation-delay:${delay}s">${chars}</g>`
363  }).join('')
364  const glint = (x: number) => r(x, 21.4, 0.7, 0.7, '#7BD389')
365  return {
366    x: 50,
367    css: `.rain{animation:rain 2.4s linear infinite}
368@keyframes rain{0%{transform:translateY(-22px)}100%{transform:translateY(20px)}}
369text{font-family:ui-monospace,Consolas,'Cascadia Mono',monospace;font-size:3px;text-anchor:middle}
370.glow{animation:glow 2.4s ease-in-out infinite}
371@keyframes glow{0%,100%{opacity:.16}50%{opacity:.3}}`,
372    body:
373      `<defs><linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">` +
374      `<stop offset="0" stop-color="#000"/><stop offset=".22" stop-color="#fff"/><stop offset=".72" stop-color="#fff"/><stop offset="1" stop-color="#000"/>` +
375      `</linearGradient><mask id="m"><rect x="52" y="0" width="32" height="18" fill="url(#fade)"/></mask>` +
376      `<radialGradient id="halo"><stop offset="0" stop-color="#3DDC84"/><stop offset="1" stop-color="#3DDC84" stop-opacity="0"/></radialGradient></defs>` +
377      `<ellipse class="glow" cx="69" cy="11" rx="15" ry="9" fill="url(#halo)"/>` +
378      `<g mask="url(#m)">${columns}</g>` +
379      clawd({
380        eyes: eyes(0, -1.2) + `<g transform="translate(0 -1.2)">${glint(64.2)}${glint(72.2)}</g>`,
381        armL: r(57, 24, 3, 3, P.body, 'class="tap"'),
382        armR: r(78, 24, 3, 3, P.body, 'class="tap2"'),
383      }),
384  }
385}
386
387function agents(): Built {
388  const mini = (delay: number) =>
389    `<g class="walk" style="animation-delay:${delay}s"><g class="hop" style="animation-delay:${delay}s">` +
390    r(1, 33, 0.9, 2, P.shade, 'class="la"') +
391    r(3.1, 33, 0.9, 2, P.shade, 'class="lb"') +
392    r(4.6, 33, 0.9, 2, P.shade, 'class="la"') +
393    r(6.7, 33, 0.9, 2, P.shade, 'class="lb"') +
394    r(-0.9, 30.3, 1, 1.2, '#E08A6D') +
395    r(7.9, 30.3, 1, 1.2, '#E08A6D') +
396    r(0, 28, 7.9, 5.2, '#E08A6D') +
397    r(0, 28, 7.9, 0.5, '#EDA085') +
398    r(1.5, 29.4, 0.9, 1.6, P.eye) +
399    r(4.6, 29.4, 0.9, 1.6, P.eye) +
400    r(-1.2, 26.6, 2.2, 2.8, P.paper, `stroke="${P.paperEdge}" stroke-width=".3"`) +
401    `</g></g>`
402  return {
403    x: 0,
404    css: `.walk{opacity:0;animation:walk 4.2s linear infinite}
405@keyframes walk{0%{opacity:0;transform:translateX(52px)}10%{opacity:1}82%{opacity:1}100%{opacity:0;transform:translateX(4px)}}
406.hop{animation:hop .35s steps(1,end) infinite}
407@keyframes hop{0%{transform:translateY(0)}50%{transform:translateY(-.5px)}}
408.la{animation:lift .35s steps(1,end) infinite}
409.lb{animation:lift .35s steps(1,end) -.175s infinite}
410@keyframes lift{0%{transform:translateY(0)}50%{transform:translateY(-.45px)}}
411.point{animation:point 1.4s ease-in-out infinite;transform-origin:100% 50%}
412@keyframes point{0%,100%{transform:rotate(28deg)}50%{transform:rotate(18deg)}}`,
413    body:
414      `<ellipse cx="30" cy="35.3" rx="26" ry=".7" fill="#000" opacity=".06"/>` +
415      mini(0) +
416      mini(-1.4) +
417      mini(-2.8) +
418      clawd({
419        eyes: eyes(-1.4, -0.4),
420        armL: `<g class="point">${r(53.5, 24, 6.5, 2.4, P.body)}</g>`,
421      }),
422  }
423}
424
425function todo(): Built {
426  const rows = [16.5, 22, 27.5]
427  const checkCss = rows
428    .map((_, i) => {
429      const s = i * 25
430      const e = s + 7
431      return `.ck${i}{stroke-dasharray:4.6;stroke-dashoffset:4.6;animation:ck${i} 4.8s infinite}
432@keyframes ck${i}{0%${s ? `,${s}%` : ''}{stroke-dashoffset:4.6}${e}%,92%{stroke-dashoffset:0;opacity:1}100%{stroke-dashoffset:0;opacity:0}}`
433    })
434    .join('\n')
435  const board =
436    r(34, 11, 16, 24, '#B98B5E', 'rx="1.2"') +
437    r(34, 11, 16, 0.8, '#CC9F72', 'rx=".4"') +
438    r(35.5, 13.5, 13, 20, P.paper) +
439    r(38.5, 9.5, 7, 3, P.steel, 'rx=".8"') +
440    r(40.8, 10.3, 2.4, 0.9, '#6B6A65', 'rx=".45"') +
441    rows
442      .map(
443        (y, i) =>
444          r(37, y, 3, 3, 'none', `stroke="#8A877F" stroke-width=".6" rx=".4"`) +
445          r(41.5, y + 0.6, 5.5, 0.8, P.line) +
446          r(41.5, y + 2, 3.5, 0.6, '#C6C2B8') +
447          `<polyline points="${37.6},${y + 1.6} ${38.6},${y + 2.6} ${40.4},${y + 0.4}" fill="none" stroke="${P.green}" stroke-width=".95" stroke-linecap="round" stroke-linejoin="round" class="ck${i}"/>`,
448      )
449      .join('')
450  const pencil =
451    `<g class="pen"><g transform="rotate(-38 44 19)" style="transform-box:view-box">` +
452    r(43.3, 12.6, 1.5, 6.2, P.yellow) +
453    r(43.3, 11.4, 1.5, 1.2, '#E38C8C') +
454    r(43.3, 12.3, 1.5, 0.4, P.steelLight) +
455    `<path d="M43.3 18.8h1.5l-.75 1.6Z" fill="#E9D3B0"/>` +
456    `<path d="M43.8 19.85h.5l-.25.55Z" fill="${P.ink}"/>` +
457    `</g></g>`
458  return {
459    x: 34,
460    css: `${checkCss}
461.pen{animation:pen 4.8s ease-in-out infinite}
462@keyframes pen{0%,2%{transform:translate(0,0)}6%{transform:translate(-.8px,.6px)}10%,25%{transform:translate(0,0)}27%,29%{transform:translate(0,5.5px)}31%{transform:translate(-.8px,6.1px)}35%,50%{transform:translate(0,5.5px)}52%,54%{transform:translate(0,11px)}56%{transform:translate(-.8px,11.6px)}60%,88%{transform:translate(0,11px)}100%{transform:translate(0,0)}}`,
463    body:
464      `<g transform="translate(6 0)">${board}${pencil}</g>` +
465      clawd({
466        eyes: eyes(-1.5, 0),
467        armL: r(57, 24, 3, 3, P.body, 'class="tap"'),
468      }),
469  }
470}
471
472function ask(): Built {
473  // Just a wave at you, looking your way, with a little hop to catch your eye.
474  return {
475    x: 48,
476    css: `.wave{animation:wave .9s ease-in-out infinite;transform-origin:100% 50%}
477@keyframes wave{0%,100%{transform:rotate(38deg)}50%{transform:rotate(66deg)}}
478.hop{animation:hop 1.8s ease-in-out infinite}
479@keyframes hop{0%,60%,100%{transform:translateY(0)}70%{transform:translateY(-1px)}80%{transform:translateY(0)}}`,
480    body: clawd({
481      whole: 'hop',
482      eyes: eyes(0, -0.4),
483      armL: `<g class="wave">${r(53.5, 21, 6.5, 2.6, P.body)}</g>`,
484    }),
485  }
486}
487
488function browser(): Built {
489  const win =
490    r(22, 7, 30, 26, P.paper, `rx="1.5" stroke="${P.outline}" stroke-width=".6"`) +
491    `<path d="M23.5 7.3h27a1.2 1.2 0 0 1 1.2 1.2V11H22.3V8.5a1.2 1.2 0 0 1 1.2-1.2Z" fill="#E6E2D8"/>` +
492    c(24.4, 9.1, 0.65, P.red) +
493    c(26.3, 9.1, 0.65, P.yellow) +
494    c(28.2, 9.1, 0.65, P.mint) +
495    r(30.5, 8.1, 19, 2, P.paper, 'rx="1"') +
496    r(31.4, 8.8, 7, 0.6, '#C6C2B8') +
497    r(24.5, 13, 25, 6.5, '#F4DCD1', 'rx=".6"') +
498    r(26.3, 14.8, 11, 1.2, '#FFFFFF', 'rx=".3"') +
499    r(26.3, 16.8, 7, 0.8, '#FFFFFF', 'opacity=".75" rx=".3"') +
500    r(24.5, 21, 12, 5.5, '#ECE9E1', 'rx=".5"') +
501    r(37.5, 21, 12, 5.5, '#ECE9E1', 'rx=".5"') +
502    r(25.8, 22.3, 7, 0.7, P.line) +
503    r(25.8, 23.8, 5, 0.6, '#C6C2B8') +
504    r(38.8, 22.3, 7.5, 0.7, P.line) +
505    r(38.8, 23.8, 4.5, 0.6, '#C6C2B8') +
506    `<g class="btn">${r(24.5, 28.2, 8.5, 2.8, P.body, 'rx="1.4"')}</g>` +
507    r(26.4, 29.25, 4.7, 0.7, '#FFFFFF', 'rx=".3" opacity=".9"') +
508    `<g transform="translate(28.75 29.6)"><g class="rip">${c(0, 0, 3, 'none', `stroke="${P.body}" stroke-width=".6"`)}</g></g>` +
509    `<g class="cursor"><g class="press"><path d="M0 0V5.2L1.35 4 2.35 6.2 3.25 5.8 2.25 3.7H4.05Z" fill="#FFFFFF" stroke="${P.eye}" stroke-width=".45" stroke-linejoin="round"/></g></g>`
510  return {
511    x: 22,
512    css: `.cursor{animation:cur 4.4s ease-in-out infinite}
513@keyframes cur{0%,8%{transform:translate(44px,15px)}30%,38%{transform:translate(41px,23.5px)}58%,78%{transform:translate(28.5px,29.2px)}100%{transform:translate(44px,15px)}}
514.press{transform-origin:0 0;animation:press 4.4s infinite}
515@keyframes press{0%,62%,68%,100%{transform:scale(1)}65%{transform:scale(.82)}}
516.rip{opacity:0;transform-origin:50% 50%;animation:rip 4.4s infinite}
517@keyframes rip{0%,63%{opacity:0;transform:scale(.2)}66%{opacity:.8}80%,100%{opacity:0;transform:scale(1.4)}}
518.btn{animation:btn 4.4s infinite}
519@keyframes btn{0%,62%,72%,100%{filter:none}65%{filter:brightness(.82)}}`,
520    body:
521      `<g transform="translate(4 0)">${win}</g>` +
522      clawd({ eyes: eyes(-1.5, -0.4) }),
523  }
524}
525
526// ---------------------------------------------------------------- work scenes
527
528const GLASS = 'rgba(220,235,245,.4)'
529const GLASS_EDGE = '#9FB3C2'
530
531// A rotate() written as an attribute turns about the user-space point it
532// names only when the element's transform box is the view box.
533const ATTR = 'style="transform-box:view-box"'
534
535function testing(): Built {
536  const flask = 'M46.6 25L42 34.2Q41.6 35.2 42.7 35.2H53.3Q54.4 35.2 54 34.2L49.4 25Z'
537  const bubble = (x: number, rad: number, delay: number, dur: number) =>
538    `<g transform="translate(${x} 34)"><g class="bub" style="animation-delay:${delay}s;animation-duration:${dur}s">${c(0, 0, rad, '#D6F5DD')}</g></g>`
539  const tube = (x: number, color: string, level: number, delay: number) =>
540    r(x, 24, 1.6, 9, GLASS, `rx=".8" stroke="${GLASS_EDGE}" stroke-width=".35"`) +
541    `<g class="lvl" style="animation-delay:${delay}s">${r(x + 0.25, 33 - level, 1.1, level - 0.25, color, 'rx=".5"')}</g>`
542  const rack =
543    r(32, 32.2, 0.7, 3.2, '#8A6A4F') +
544    r(39.9, 32.2, 0.7, 3.2, '#8A6A4F') +
545    tube(32.7, P.yellow, 5, 0) +
546    tube(35.5, '#7FA7D9', 3.5, -0.9) +
547    tube(38.3, '#C9A7E0', 6, -1.7) +
548    r(31.6, 31.2, 9.4, 1, '#B98B5E', 'rx=".3"')
549  const flaskArt =
550    `<clipPath id="fl"><path d="${flask}"/></clipPath>` +
551    `<g clip-path="url(#fl)"><g class="liq">${r(40, 29.4, 16, 7, '#7BD389', 'opacity=".9"')}${r(40, 29.4, 16, 0.6, '#A8E6B8')}</g></g>` +
552    bubble(46.8, 0.55, 0, 2.2) +
553    bubble(48.6, 0.75, -0.8, 2.6) +
554    bubble(47.6, 0.45, -1.5, 2) +
555    bubble(49.4, 0.6, -0.4, 2.4) +
556    `<path d="${flask}" fill="${GLASS}" stroke="${GLASS_EDGE}" stroke-width=".5" stroke-linejoin="round"/>` +
557    r(46.6, 21.4, 2.8, 3.8, GLASS, `stroke="${GLASS_EDGE}" stroke-width=".5"`) +
558    r(46.1, 20.8, 3.8, 0.8, GLASS_EDGE, 'rx=".3"') +
559    r(43.6, 31, 0.5, 2.6, '#FFFFFF', 'opacity=".55" rx=".25"') +
560    `<g transform="translate(51.6 18.6)"><g class="tw"><path d="M0-1.4L.35-.35 1.4 0 .35.35 0 1.4-.35.35-1.4 0-.35-.35Z" fill="${P.yellow}"/></g></g>`
561  return {
562    x: 30,
563    css: `.bub{opacity:0;animation:bub 2.2s ease-in infinite;transform-origin:50% 50%}
564@keyframes bub{0%{opacity:0;transform:translateY(0) scale(.6)}10%{opacity:1}80%{opacity:.85}100%{opacity:0;transform:translateY(-16px) scale(1.15)}}
565.lvl{animation:lvl 2.8s ease-in-out infinite alternate;transform-origin:50% 100%}
566@keyframes lvl{0%{transform:scaleY(.72)}100%{transform:scaleY(1)}}
567.liq{animation:liq 1.6s ease-in-out infinite alternate}
568@keyframes liq{0%{transform:translateX(-.5px)}100%{transform:translateX(.5px)}}
569.tw{opacity:0;animation:tw 2.2s infinite;transform-origin:50% 50%}
570@keyframes tw{0%,70%{opacity:0;transform:scale(.4)}80%{opacity:1;transform:scale(1.1)}100%{opacity:0;transform:scale(.7)}}`,
571    body:
572      rack +
573      `<g transform="translate(3 0)">${flaskArt}</g>` +
574      clawd({
575        eyes: eyes(-1.5, 0.4),
576        armL: r(53.6, 26.2, 6.4, 2.2, P.body),
577      }),
578  }
579}
580
581function commit(): Built {
582  const box =
583    `<path d="M42 25.6L43.2 24.4H54.8L56 25.6Z" fill="#DDAE7A"/>` +
584    r(42, 25.6, 14, 9.8, '#C9965F', 'rx=".4"') +
585    r(42, 34.6, 14, 0.8, '#B07E4C') +
586    r(48.2, 24.4, 1.6, 11, '#E9D8B4') +
587    r(43.4, 27.2, 3.6, 2.4, '#F4ECDD', 'rx=".2"') +
588    r(43.9, 27.8, 2.6, 0.35, '#B8A78A') +
589    r(43.9, 28.6, 1.8, 0.35, '#B8A78A')
590  const mark =
591    `<g transform="translate(51.2 31) rotate(-8)" ${ATTR}><g class="mark">` +
592    r(-3.4, -2, 6.8, 4, 'none', `rx=".6" stroke="${P.green}" stroke-width=".7"`) +
593    `<polyline points="-1.5,0 -0.4,1.1 1.7,-1.1" fill="none" stroke="${P.green}" stroke-width=".8" stroke-linecap="round" stroke-linejoin="round"/>` +
594    `</g></g>`
595  // The arm runs from the stamp's stem down to his shoulder and rides with it.
596  const stamp =
597    `<g class="stamp">` +
598    `<g transform="rotate(19 50 17.5)" ${ATTR}>${r(50, 16.4, 10.6, 2.2, P.body)}</g>` +
599    c(49, 14.4, 1.3, '#A33B2B') +
600    r(48.5, 15.4, 1, 3.6, '#6B4A35') +
601    r(45.5, 19, 7, 1.5, P.ink, 'rx=".3"') +
602    r(46, 20.5, 6, 0.5, P.green) +
603    `</g>`
604  return {
605    x: 34,
606    css: `.stamp{animation:stamp 1.4s infinite}
607@keyframes stamp{0%{transform:translateY(0)}25%{transform:translateY(-1.5px);animation-timing-function:ease-in}38%,50%{transform:translateY(3.4px)}70%,100%{transform:translateY(0)}}
608.box{animation:squash 1.4s infinite;transform-origin:50% 100%}
609@keyframes squash{0%,36%,48%,100%{transform:scaleY(1)}40%{transform:scaleY(.95)}}
610.mark{opacity:0;animation:mark 1.4s infinite;transform-origin:50% 50%}
611@keyframes mark{0%,37%{opacity:0;transform:scale(1.4)}42%{opacity:1;transform:scale(1)}90%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(1)}}`,
612    body:
613      `<ellipse cx="49" cy="35.6" rx="8" ry=".7" fill="#000" opacity=".1"/>` +
614      `<g class="box">${box}${mark}</g>` +
615      clawd({ eyes: eyes(-1.5, 0.2), armL: '' }) +
616      stamp,
617  }
618}
619
620function deploy(): Built {
621  const rocket =
622    `<path d="M43 25.5L40.4 30.4L43 29.6Z" fill="${P.body}"/>` +
623    `<path d="M49 25.5L51.6 30.4L49 29.6Z" fill="${P.body}"/>` +
624    `<path d="M46 14.2C48.4 16 49 19 49 22V29.8H43V22C43 19 43.6 16 46 14.2Z" fill="#F3F0E8" stroke="#BDBAB2" stroke-width=".4"/>` +
625    `<path d="M46 14.2C47.4 15.3 48.1 16.6 48.5 18H43.5C43.9 16.6 44.6 15.3 46 14.2Z" fill="${P.body}"/>` +
626    c(46, 21.8, 1.25, '#7FA7D9', 'stroke="#8C8A83" stroke-width=".45"') +
627    `<path d="M45.3 21.3a.9.9 0 0 1 .8-.6" fill="none" stroke="#FFFFFF" stroke-width=".35" stroke-linecap="round"/>` +
628    r(44.2, 29.8, 3.6, 1, '#6B6A65', 'rx=".2"') +
629    `<g class="flame"><path d="M44.4 30.8Q46 36.6 47.6 30.8Z" fill="${P.yellow}"/><path d="M45.2 30.8Q46 34 46.8 30.8Z" fill="#F7E3A1"/></g>`
630  const puff = (x: number) => `<g transform="translate(${x} 33)"><g class="smoke">${c(0, 0, 1.6, '#D5D1C7')}</g></g>`
631  return {
632    x: 30,
633    css: `.launch{animation:launch 3.4s infinite}
634@keyframes launch{0%{transform:translateY(0);opacity:0}6%{opacity:1}38%{transform:translateY(0);animation-timing-function:cubic-bezier(.5,0,.9,.5)}72%{transform:translateY(-34px);opacity:1}73%,100%{transform:translateY(-34px);opacity:0}}
635.shake{animation:shake .12s steps(2,end) infinite}
636@keyframes shake{0%{transform:translateX(-.2px)}100%{transform:translateX(.2px)}}
637.flame{animation:flame .16s steps(2,end) infinite;transform-origin:50% 0}
638@keyframes flame{0%{transform:scaleY(.65)}100%{transform:scaleY(1.05)}}
639.smoke{opacity:0;animation:smoke 3.4s infinite;transform-origin:50% 50%}
640@keyframes smoke{0%,36%{opacity:0;transform:scale(.3)}45%{opacity:.85}75%,100%{opacity:0;transform:scale(1.9)}}
641.watch{animation:watch 3.4s ease-in-out infinite}
642@keyframes watch{0%,38%{transform:translateY(0)}72%{transform:translateY(-1.2px)}100%{transform:translateY(0)}}
643.led{animation:led .8s steps(1,end) infinite}
644@keyframes led{0%{opacity:1}50%{opacity:.25}}`,
645    body:
646      r(39, 33.6, 14, 1.8, '#8F8C84', 'rx=".4"') +
647      r(40, 32.4, 1, 1.2, '#6B6A65') +
648      r(51, 32.4, 1, 1.2, '#6B6A65') +
649      c(41.6, 34.5, 0.4, P.red, 'class="led"') +
650      c(50.4, 34.5, 0.4, P.mint, 'class="led" style="animation-delay:.4s"') +
651      puff(40.6) +
652      puff(51.4) +
653      `<g class="launch"><g class="shake">${rocket}</g></g>` +
654      clawd({
655        eyes: eyes(-1.3, -1.2, 'watch'),
656        armL: r(57, 19.6, 3, 4.6, P.body),
657      }),
658  }
659}
660
661function waiting(): Built {
662  const sign =
663    r(55.4, 9.5, 0.9, 15, '#8A6A4F') +
664    r(48, 3.2, 15.6, 8.2, '#FFFFFF', `rx="1.2" stroke="${P.body}" stroke-width=".8"`) +
665    `<text x="55.8" y="9.3" font-family="system-ui,'Segoe UI',sans-serif" font-size="5" font-weight="800" fill="${P.ink}" text-anchor="middle">OK?</text>`
666  const legs =
667    r(61, 31, 2, 4, P.shade) + r(65, 31, 2, 4, P.shade) + r(71, 31, 2, 4, P.shade) + r(75, 31, 2, 4, P.shade, 'class="tapfoot"')
668  return {
669    x: 46,
670    css: `.sway{animation:sway 2.4s ease-in-out infinite;transform-origin:50% 100%}
671@keyframes sway{0%,100%{transform:rotate(-3deg)}50%{transform:rotate(3deg)}}
672.tapfoot{animation:tapfoot .5s steps(1,end) infinite}
673@keyframes tapfoot{0%{transform:translateY(0)}50%{transform:translateY(-.9px)}}`,
674    body:
675      `<g class="sway">${sign}</g>` +
676      clawd({
677        eyes: eyes(0, 0),
678        armL: r(55, 21.6, 5, 2.2, P.body),
679        legs,
680      }),
681  }
682}
683
684function debugging(): Built {
685  const bug =
686    `<g class="legs">` +
687    `<path d="M-1.2 34.5l-.5.9M0 34.8v.9M1.2 34.5l.5.9M-1.2 32.3l-.5-.7M1.2 32.3l.5-.7" stroke="#2B2A27" stroke-width=".3" stroke-linecap="round"/>` +
688    `</g>` +
689    `<ellipse cx="0" cy="33.4" rx="2.1" ry="1.5" fill="#E05A47"/>` +
690    `<path d="M0 31.9V34.9" stroke="#2B2A27" stroke-width=".3"/>` +
691    c(-0.9, 32.9, 0.35, '#2B2A27') +
692    c(0.9, 33.8, 0.35, '#2B2A27') +
693    c(-0.7, 34.1, 0.3, '#2B2A27') +
694    c(1, 32.8, 0.3, '#2B2A27') +
695    c(-2.3, 33.4, 0.8, '#2B2A27') +
696    `<path d="M-2.8 32.8l-.7-.9M-2.4 32.7l-.2-1.1" stroke="#2B2A27" stroke-width=".25" stroke-linecap="round"/>`
697  const net =
698    `<path d="M57.2 25.2L46.5 17.4" stroke="#8A6A4F" stroke-width="1" stroke-linecap="round"/>` +
699    `<path d="M41.7 16.6Q43.4 21.6 45.3 20.9Q47.4 20.1 47.5 16.6" fill="rgba(255,255,255,.12)" stroke="#CFCBC1" stroke-width=".4"/>` +
700    `<path d="M43 17.6l1.4 2.4M44.6 17.8l.6 2.6M46.2 17.6l-.4 2.6" stroke="#CFCBC1" stroke-width=".25"/>` +
701    `<ellipse cx="44.6" cy="16.6" rx="3" ry="1.9" fill="none" stroke="#BDBAB2" stroke-width=".6"/>`
702  return {
703    x: 26,
704    css: `.crawl{animation:crawl 4s linear infinite}
705@keyframes crawl{0%{transform:translateX(50px)}45%,50%{transform:translateX(31px)}95%,100%{transform:translateX(50px)}}
706.flip{animation:flip 4s steps(1,end) infinite;transform-origin:50% 50%}
707@keyframes flip{0%{transform:scaleX(1)}47.5%{transform:scaleX(-1)}97.5%{transform:scaleX(1)}}
708.legs{animation:legs .2s steps(2,end) infinite}
709@keyframes legs{0%{transform:translateX(-.15px)}100%{transform:translateX(.15px)}}
710.swing{animation:swing 4s ease-in-out infinite;transform-origin:100% 100%}
711@keyframes swing{0%,18%{transform:rotate(0)}27%,32%{transform:rotate(-58deg)}42%,64%{transform:rotate(0)}73%,78%{transform:rotate(-46deg)}88%,100%{transform:rotate(0)}}
712.track{animation:track 4s ease-in-out infinite}
713@keyframes track{0%{transform:translateX(-.3px)}45%,50%{transform:translateX(-1.6px)}95%,100%{transform:translateX(-.3px)}}`,
714    body:
715      `<ellipse cx="41" cy="35.6" rx="13" ry=".6" fill="#000" opacity=".06"/>` +
716      `<g class="crawl"><g class="flip">${bug}</g></g>` +
717      `<g class="swing">${net}</g>` +
718      clawd({
719        eyes: eyes(0, 0.4, 'track'),
720        armL: r(56, 24, 4, 2.2, P.body),
721      }),
722  }
723}
724
725function memory(): Built {
726  const lines: [number, number][] = [
727    [25, 8.5],
728    [26.6, 9],
729    [28.2, 7.5],
730    [29.8, 9],
731    [31.4, 5],
732  ]
733  const period = 100 / lines.length
734  const lineCss = lines
735    .map((_, i) => {
736      const s = +(i * period).toFixed(2)
737      const e = +(s + period * 0.8).toFixed(2)
738      return `.m${i}{transform-origin:0 50%;animation:m${i} 6s infinite}
739@keyframes m${i}{0%${s ? `,${s}%` : ''}{transform:scaleX(0);opacity:1;animation-timing-function:steps(8,end)}${e}%,94%{transform:scaleX(1);opacity:1}100%{transform:scaleX(1);opacity:0}}`
740    })
741    .join('\n')
742  const heart = (delay: number, dx: number) =>
743    `<g transform="translate(${69 + dx} 21.5)"><g class="heart" style="animation-delay:${delay}s">` +
744    `<path d="M0 1C-2-.3-1.2-2 0-.8C1.2-2 2-.3 0 1Z" fill="#E38C9A"/></g></g>`
745  const book =
746    r(55.8, 22.6, 26.4, 10.9, '#D9707F', 'rx=".9"') +
747    r(56.8, 23.3, 11.8, 9.6, P.paper) +
748    r(69.4, 23.3, 11.8, 9.6, P.paper) +
749    r(68.6, 22.6, 0.8, 10.9, '#B85A68') +
750    [25, 26.6, 28.2, 29.8].map((y, i) => r(58, y, [9, 8, 9.4, 6][i], 0.55, '#C2BDB2')).join('') +
751    `<path d="M65 31.6C64 30.8 64.6 29.9 65.4 30.6C66.2 29.9 66.8 30.8 65.8 31.6L65.4 31.9Z" fill="#E38C9A" opacity=".8"/>` +
752    lines.map(([y, w], i) => r(70.6, y, w, 0.55, '#6B6A65', `class="m${i}"`)).join('')
753  const pencil =
754    `<g class="scrib"><g transform="rotate(35 80 29)" ${ATTR}>` +
755    r(79.4, 24.6, 1.2, 5.4, P.yellow) +
756    r(79.4, 23.7, 1.2, 0.9, '#E38C9A') +
757    `<path d="M79.4 30h1.2l-.6 1.3Z" fill="#E9D3B0"/>` +
758    `</g></g>`
759  return {
760    x: 50,
761    css: `${lineCss}
762.heart{opacity:0;animation:heart 3.9s ease-out infinite;transform-origin:50% 50%}
763@keyframes heart{0%{opacity:0;transform:translate(0,0) scale(.5)}15%{opacity:1}50%{transform:translate(-1.2px,-6px) scale(1)}100%{opacity:0;transform:translate(1px,-12px) scale(1.15)}}
764.scrib{animation:scrib .5s ease-in-out infinite}
765@keyframes scrib{0%,100%{transform:translate(0,0)}25%{transform:translate(-.6px,.3px)}50%{transform:translate(-.2px,-.3px)}75%{transform:translate(-.8px,0)}}
766.look{animation:look 1.2s ease-in-out infinite alternate}
767@keyframes look{0%{transform:translateX(-.4px)}100%{transform:translateX(.6px)}}`,
768    body:
769      DESK_SHADOW +
770      behind({ eyes: eyes(0, DOWN, 'look') }) +
771      heart(0, -1) +
772      heart(-1.3, 1.5) +
773      heart(-2.6, 0) +
774      book +
775      r(53.8, 26, 2.2, 3, P.body) +
776      pencil +
777      r(82, 26, 2, 3, P.body),
778  }
779}
780
781function database(): Built {
782  const disc = (top: number, i: number) =>
783    r(38, top, 14, 3.6, '#5E7FA8') +
784    `<ellipse cx="45" cy="${top + 3.6}" rx="7" ry="1.6" fill="#4C6B91"/>` +
785    `<ellipse cx="45" cy="${top}" rx="7" ry="1.6" fill="#86A6CC"/>` +
786    c(49.6, top + 2.4, 0.55, '#7BD389', `class="led" style="animation-delay:${i * 0.25}s"`)
787  const packet = (color: string, delay: number, isBack: boolean) =>
788    `<g transform="translate(${isBack ? 58 : 52.6} ${isBack ? 28.4 : 25.4})"><g class="${isBack ? 'pktb' : 'pkt'}" style="animation-delay:${delay}s">` +
789    r(-0.6, -0.6, 1.2, 1.2, color, 'rx=".2"') +
790    `</g></g>`
791  return {
792    x: 30,
793    css: `.led{animation:led 1s steps(1,end) infinite}
794@keyframes led{0%{opacity:1}25%{opacity:.2}}
795.pkt{opacity:0;animation:pkt 1.5s linear infinite}
796@keyframes pkt{0%{opacity:0;transform:translateX(0)}15%,85%{opacity:1}100%{opacity:0;transform:translateX(5px)}}
797.pktb{opacity:0;animation:pktb 1.5s linear infinite}
798@keyframes pktb{0%{opacity:0;transform:translateX(0)}15%,85%{opacity:1}100%{opacity:0;transform:translateX(-5px)}}
799.gloss{animation:gloss 2.4s ease-in-out infinite}
800@keyframes gloss{0%,100%{opacity:.25}50%{opacity:.6}}`,
801    body:
802      `<ellipse cx="45" cy="35.5" rx="8.5" ry=".8" fill="#000" opacity=".12"/>` +
803      disc(29, 2) +
804      disc(24.6, 1) +
805      disc(20.2, 0) +
806      `<ellipse class="gloss" cx="43" cy="19.8" rx="3.4" ry=".6" fill="#FFFFFF"/>` +
807      packet(P.body, 0, false) +
808      packet('#7BD389', -0.75, false) +
809      packet('#7FA7D9', -0.4, true) +
810      packet(P.yellow, -1.15, true) +
811      clawd({
812        eyes: eyes(-1.4, 0.2),
813        armL: r(57, 24, 3, 3, P.body, 'class="tap"'),
814      }),
815  }
816}
817
818function design(): Built {
819  const strokes = [
820    `<circle cx="43.6" cy="17.6" r="1.8" pathLength="1" fill="none" stroke="${P.yellow}" stroke-width="1.2" class="s0"/>`,
821    `<path d="M34.4 17Q36.4 15.4 38.6 17.2" pathLength="1" fill="none" stroke="#7FA7D9" stroke-width="1" stroke-linecap="round" class="s1"/>`,
822    `<path d="M33.8 25.4Q37 20.6 40.2 24Q42.6 21.4 46.2 25.4" pathLength="1" fill="none" stroke="${P.mint}" stroke-width="1.1" stroke-linecap="round" class="s2"/>`,
823  ].join('')
824  const spans: [number, number][] = [
825    [0, 20],
826    [20, 42],
827    [42, 66],
828  ]
829  const strokeCss = spans
830    .map(
831      ([s, e], i) => `.s${i}{stroke-dasharray:1;stroke-dashoffset:1;animation:s${i} 6s infinite}
832@keyframes s${i}{0%${s ? `,${s}%` : ''}{stroke-dashoffset:1;opacity:1}${e}%,92%{stroke-dashoffset:0;opacity:1}100%{stroke-dashoffset:0;opacity:0}}`,
833    )
834    .join('\n')
835  const easel =
836    `<path d="M40 12.5V35" stroke="#6B4A35" stroke-width=".8"/>` +
837    `<path d="M40 12.5L34.5 35.4M40 12.5L45.5 35.4" stroke="#8A6A4F" stroke-width="1" stroke-linecap="round"/>` +
838    r(32.6, 13.6, 14.8, 13.6, P.paper, `stroke="#CFCBC1" stroke-width=".5"`) +
839    strokes +
840    r(31.6, 27.2, 16.8, 1.1, '#8A6A4F', 'rx=".3"')
841  const brush =
842    `<g class="brush">` +
843    r(56, 24, 4, 2.2, P.body) +
844    `<path d="M57 24.8L49.6 19.4" stroke="#B98B5E" stroke-width=".9" stroke-linecap="round"/>` +
845    `<path d="M49.9 19.6L49.2 19.1" stroke="#BDBAB2" stroke-width="1.1"/>` +
846    c(48.8, 18.8, 0.7, P.red) +
847    `</g>`
848  const palette =
849    `<ellipse cx="81.6" cy="27.8" rx="2.3" ry="1.5" fill="#E9D3B0" stroke="#C9AE86" stroke-width=".3"/>` +
850    c(80.8, 27.4, 0.45, P.red) +
851    c(82.2, 27.2, 0.45, '#7FA7D9') +
852    c(81.9, 28.4, 0.45, P.mint)
853  const beret =
854    `<ellipse cx="66.5" cy="17.7" rx="6.2" ry="1.5" fill="${P.ink}"/>` + c(66.5, 16.1, 0.55, P.ink)
855  return {
856    x: 26,
857    css: `${strokeCss}
858.brush{animation:brush 1.2s ease-in-out infinite}
859@keyframes brush{0%,100%{transform:translate(0,0)}30%{transform:translate(-1.4px,.6px)}55%{transform:translate(-.8px,-.7px)}80%{transform:translate(-1.6px,.2px)}}`,
860    body:
861      easel +
862      brush +
863      clawd({
864        eyes: eyes(-1.4, -0.2),
865        armL: '',
866        armR: r(78, 25.6, 3, 2.4, P.body),
867        hat: beret,
868      }) +
869      palette,
870  }
871}
872
873function build(): Built {
874  const block = (x: number, y: number, w: number, color: string, light: string) =>
875    r(x + 1.2, y - 0.7, 1.6, 0.7, color, 'rx=".2"') +
876    r(x + w - 2.8, y - 0.7, 1.6, 0.7, color, 'rx=".2"') +
877    r(x, y, w, 4, color, 'rx=".4"') +
878    r(x, y, w, 0.6, light, 'rx=".3"')
879  const blocks: [number, number, number, string, string][] = [
880    [42, 31.4, 9, '#7FA7D9', '#A6C2E6'],
881    [42.6, 26.7, 7.8, P.yellow, '#F3CB6E'],
882    [43.2, 22, 6.6, P.red, '#F29489'],
883  ]
884  const blockCss = blocks
885    .map((_, i) => {
886      const s = i * 22
887      return `.b${i}{animation:b${i} 4s infinite}
888@keyframes b${i}{0%${s ? `,${s}%` : ''}{opacity:0;transform:translateY(-14px);animation-timing-function:ease-in}${s + 1}%{opacity:1}${s + 9}%{transform:translateY(0);animation-timing-function:ease-out}${s + 12}%{transform:translateY(-1.2px);animation-timing-function:ease-in}${s + 15}%,90%{opacity:1;transform:translateY(0)}100%{opacity:0;transform:translateY(0)}}`
889    })
890    .join('\n')
891  const hardHat =
892    `<path d="M61.2 18.3C61.2 13.6 76.8 13.6 76.8 18.3Z" fill="${P.yellow}"/>` +
893    r(68.4, 14, 1.2, 4.3, '#F3CB6E', 'rx=".4"') +
894    r(59.6, 17.7, 18.8, 1.1, '#CC9433', 'rx=".5"')
895  const hammer =
896    `<g class="hammer">` +
897    r(54.6, 24.6, 5.4, 2.2, P.body) +
898    r(52.6, 22.4, 0.8, 4.4, '#8A6A4F') +
899    r(51.4, 21.4, 3.2, 1.6, '#6B6A65', 'rx=".3"') +
900    `</g>`
901  return {
902    x: 32,
903    css: `${blockCss}
904.hammer{animation:hammer .6s ease-in-out infinite}
905@keyframes hammer{0%,100%{transform:translate(0,0)}35%{transform:translate(-.4px,1px)}}`,
906    body:
907      `<ellipse cx="46.5" cy="35.6" rx="6.5" ry=".7" fill="#000" opacity=".12"/>` +
908      blocks.map(([x, y, w, color, light], i) => `<g class="b${i}">${block(x, y, w, color, light)}</g>`).join('') +
909      hammer +
910      clawd({
911        eyes: eyes(-1.5, -0.4),
912        armL: '',
913        hat: hardHat,
914      }),
915  }
916}
917
918function done(): Built {
919  const colors = [P.body, P.yellow, P.mint, '#7FA7D9', '#C9A7E0', P.red]
920  const bits = Array.from({ length: 16 }, (_, i) => {
921    const a = (i / 16) * Math.PI * 2 + (i % 2) * 0.2
922    const dist = 9 + (i % 3) * 3
923    const tx = Math.cos(a) * dist
924    const ty = Math.sin(a) * dist * 0.7 - 4
925    return { i, tx, ty, color: colors[i % colors.length], rot: (i * 47) % 360, delay: (i % 4) * 0.08 }
926  })
927  const confettiCss = bits
928    .map(
929      b => `.cf${b.i}{animation:cf${b.i} 1.9s cubic-bezier(.2,.7,.3,1) ${b.delay}s infinite}
930@keyframes cf${b.i}{0%{opacity:0;transform:translate(0,0) rotate(0)}12%{opacity:1}70%{opacity:1;transform:translate(${b.tx.toFixed(1)}px,${b.ty.toFixed(1)}px) rotate(${b.rot}deg)}100%{opacity:0;transform:translate(${b.tx.toFixed(1)}px,${(b.ty + 5).toFixed(1)}px) rotate(${b.rot + 90}deg)}}`,
931    )
932    .join('\n')
933  const confetti = bits
934    .map(b => `<g transform="translate(69 15)"><g class="cf${b.i}">${r(-0.6, -0.35, 1.2, 0.7, b.color)}</g></g>`)
935    .join('')
936  const happy =
937    `<polyline points="63,23.8 64.6,22 66.2,23.8" fill="none" stroke="${P.eye}" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>` +
938    `<polyline points="71.8,23.8 73.4,22 75,23.8" fill="none" stroke="${P.eye}" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>`
939  return {
940    x: 46,
941    css: `${confettiCss}
942.jump{animation:jump .95s cubic-bezier(.3,0,.4,1) infinite}
943@keyframes jump{0%,100%{transform:translateY(0) scaleY(.96)}45%{transform:translateY(-3.2px) scaleY(1.03)}}
944.jump{transform-origin:50% 100%}
945.sh{animation:sh .95s cubic-bezier(.3,0,.4,1) infinite;transform-origin:50% 50%}
946@keyframes sh{0%,100%{transform:scaleX(1);opacity:.13}45%{transform:scaleX(.7);opacity:.07}}
947.arms{animation:arms .95s ease-in-out infinite}
948@keyframes arms{0%,100%{transform:translateY(.6px)}45%{transform:translateY(-.4px)}}`,
949    body:
950      confetti +
951      clawd(
952        {
953          whole: 'jump',
954          upper: '',
955          eyes: happy,
956          armL: `<g class="arms">${r(57, 15.5, 3, 4.5, P.body)}</g>`,
957          armR: `<g class="arms">${r(78, 15.5, 3, 4.5, P.body)}</g>`,
958        },
959        `<g class="sh">${SHADOW}</g>`,
960      ),
961  }
962}
963
964function oops(): Built {
965  const x = (cx: number) =>
966    `<path d="M${cx - 1.1} 21.9l2.2 2.2M${cx + 1.1} 21.9l-2.2 2.2" stroke="${P.eye}" stroke-width="1" stroke-linecap="round"/>`
967  return {
968    x: 50,
969    css: `.shake{animation:shake 2.4s ease-in-out infinite}
970@keyframes shake{0%,30%,100%{transform:translateX(0)}4%,12%,20%{transform:translateX(-.5px)}8%,16%,24%{transform:translateX(.5px)}}
971.drop{animation:drop 1.6s ease-in infinite;opacity:0}
972@keyframes drop{0%{opacity:0;transform:translateY(0)}15%{opacity:1}100%{opacity:0;transform:translateY(4px)}}`,
973    body:
974      clawd({ whole: 'shake', eyes: x(65) + x(73) }) +
975      `<g transform="translate(79.5 15.5)"><g class="drop"><path d="M0 0C.9 1.3 1.5 2.2 1.5 2.9A1.5 1.5 0 0 1-1.5 2.9C-1.5 2.2-.9 1.3 0 0Z" fill="#7FB2E5"/></g></g>`,
976  }
977}
978
979const BUILDERS: Record<Activity, () => Built> = {
980  idle,
981  sleep,
982  thinking,
983  writing,
984  coding,
985  web,
986  reading,
987  bash,
988  agents,
989  todo,
990  ask,
991  browser,
992  testing,
993  commit,
994  deploy,
995  waiting,
996  debugging,
997  memory,
998  database,
999  design,
1000  build,
1001  done,
1002  oops,
1003}
1004
1005// He waves hello with a speech bubble: "<name> has arrived!".
1006function hello(name: string): Built {
1007  const text = `${name} has arrived!`
1008  const safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
1009  // System-ui at 3.6 units runs about 2 units a character.
1010  const w = Math.max(16, text.length * 2.05 + 6)
1011  const right = 55.5
1012  const left = right - w
1013  const bubble =
1014    `<path d="M${right - 4} 15.4L56.6 19.6L${right - 7.4} 15.6Z" fill="#FFFFFF" stroke="${P.outline}" stroke-width=".6" stroke-linejoin="round"/>` +
1015    r(left, 5.6, w, 10, '#FFFFFF', `rx="3.4" stroke="${P.outline}" stroke-width=".6"`) +
1016    r(right - 7.6, 14.8, 4.4, 1.2, '#FFFFFF') +
1017    `<text x="${left + w / 2}" y="11.9" font-family="system-ui,'Segoe UI',sans-serif" font-size="3.6" font-weight="700" fill="${P.ink}" text-anchor="middle">${safe}</text>`
1018  return {
1019    x: Math.min(48, Math.floor(left - 1.5)),
1020    css: `.wave{animation:wave .9s ease-in-out infinite;transform-origin:100% 50%}
1021@keyframes wave{0%,100%{transform:rotate(38deg)}50%{transform:rotate(66deg)}}
1022.pop{animation:pop .45s cubic-bezier(.3,1.6,.6,1) both;transform-origin:100% 100%}
1023@keyframes pop{0%{transform:scale(.2);opacity:0}100%{transform:scale(1);opacity:1}}
1024.hop{animation:hop 1.8s ease-in-out infinite}
1025@keyframes hop{0%,60%,100%{transform:translateY(0)}70%{transform:translateY(-1px)}80%{transform:translateY(0)}}`,
1026    body:
1027      `<g class="pop">${bubble}</g>` +
1028      clawd({
1029        whole: 'hop',
1030        eyes: eyes(0, -0.4),
1031        armL: `<g class="wave">${r(53.5, 21, 6.5, 2.6, P.body)}</g>`,
1032      }),
1033  }
1034}
1035
1036type Rendered = { source: string; width: number; height: number }
1037
1038function toSvg(build: () => Built, skinId: string): Rendered {
1039  const skin = skinById(skinId)
1040  wearing = { hat: skin?.kind === 'hat' ? skin.svg : '', hand: skin?.kind === 'hand' ? skin.svg : '' }
1041  try {
1042    const built = build()
1043    const w = RIGHT - built.x
1044    const source =
1045      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${built.x} 0 ${w} ${HEIGHT}" width="${w * SCALE}" height="${HEIGHT * SCALE}">` +
1046      `<style>${BASE_CSS}${built.css ?? ''}${skin?.css ?? ''}</style>` +
1047      built.body +
1048      `</svg>`
1049    return { source, width: w * SCALE, height: HEIGHT * SCALE }
1050  } finally {
1051    wearing = { hat: '', hand: '' }
1052  }
1053}
1054
1055const cache = new Map<string, Rendered>()
1056
1057export function renderScene(activity: Activity, skinId = ''): Rendered {
1058  const key = `${activity}|${skinId}`
1059  const hit = cache.get(key)
1060  if (hit) return hit
1061  const out = toSvg(BUILDERS[activity], skinId)
1062  cache.set(key, out)
1063  return out
1064}
1065
1066export function renderHello(name: string, skinId = ''): Rendered {
1067  return toSvg(() => hello(name), skinId)
1068}
1069
1070export const ACTIVITIES = Object.keys(BUILDERS) as Activity[]
1071
hooks/skins.ts 223 lines
1// Skins: a hat on his head or something in his right hand. Drawn in the same
2// coordinates as the scenes (body x 60..78, y 18..31; right hand x 78..81,
3// y 24..27), inside his breathing group so they move with him.
4
5export type SkinKind = 'hat' | 'hand'
6
7export type Skin = {
8  id: string
9  label: string
10  emoji: string
11  kind: SkinKind
12  svg: string
13  css?: string
14}
15
16const star = (x: number, y: number, s: number, fill: string, cls = '') =>
17  `<path${cls ? ` class="${cls}"` : ''} d="M${x} ${y - s}L${x + s * 0.3} ${y - s * 0.3} ${x + s} ${y} ${x + s * 0.3} ${y + s * 0.3} ${x} ${y + s} ${x - s * 0.3} ${y + s * 0.3} ${x - s} ${y} ${x - s * 0.3} ${y - s * 0.3}Z" fill="${fill}"/>`
18
19export const SKINS: Skin[] = [
20  {
21    id: 'crown',
22    label: 'Crown',
23    emoji: '👑',
24    kind: 'hat',
25    svg:
26      `<path d="M62 18V12.8L65 15 69 11.4 73 15 76 12.8V18Z" fill="#E8B04B"/>` +
27      `<rect x="62" y="16.6" width="14" height="1.4" fill="#C9932E"/>` +
28      `<circle cx="69" cy="17.3" r=".65" fill="#E96B5F"/>` +
29      `<circle cx="65.2" cy="17.3" r=".5" fill="#7FA7D9"/>` +
30      `<circle cx="72.8" cy="17.3" r=".5" fill="#6BBE6E"/>` +
31      `<circle cx="69" cy="11.4" r=".55" fill="#F7E3A1"/>` +
32      star(74.6, 12.4, 0.9, '#FFF6D6', 'sk-tw'),
33    css: `.sk-tw{animation:sk-tw 2.4s ease-in-out infinite;transform-origin:50% 50%}
34@keyframes sk-tw{0%,70%,100%{opacity:0;transform:scale(.3)}80%{opacity:1;transform:scale(1)}}`,
35  },
36  {
37    id: 'wizard',
38    label: 'Wizard hat',
39    emoji: '🧙',
40    kind: 'hat',
41    svg:
42      `<path d="M63 18L68.6 6.4Q69.4 5.6 70 6.6L75 18Z" fill="#5B4B9E"/>` +
43      `<ellipse cx="69" cy="18" rx="8.2" ry="1.3" fill="#4A3D82"/>` +
44      star(68.6, 12, 0.9, '#F2D06B', 'sk-tw') +
45      star(71.4, 15.4, 0.7, '#F2D06B') +
46      star(66.4, 15.8, 0.55, '#F7E3A1'),
47    css: `.sk-tw{animation:sk-tw 1.8s ease-in-out infinite;transform-origin:50% 50%}
48@keyframes sk-tw{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(.7)}}`,
49  },
50  {
51    id: 'cowboy',
52    label: 'Cowboy hat',
53    emoji: '🤠',
54    kind: 'hat',
55    svg:
56      `<path d="M64 17.6C64 13 74 13 74 17.6Z" fill="#9C6B3F"/>` +
57      `<path d="M67.6 13.9Q69 14.9 70.4 13.9" fill="none" stroke="#7E5430" stroke-width=".5"/>` +
58      `<rect x="64.2" y="16" width="9.6" height="1" fill="#5E3F22"/>` +
59      `<path d="M58.6 18.4Q69 15.4 79.4 18.4Q69 20 58.6 18.4Z" fill="#8A5C33"/>`,
60  },
61  {
62    id: 'viking',
63    label: 'Viking helmet',
64    emoji: '⚔️',
65    kind: 'hat',
66    svg:
67      `<path d="M62.6 16C59.4 15.5 58.5 12.4 59.5 10.4C60.3 12.9 61.6 13.7 63.2 13.9Z" fill="#F1EEE6" stroke="#CFCBC1" stroke-width=".3"/>` +
68      `<path d="M75.4 16C78.6 15.5 79.5 12.4 78.5 10.4C77.7 12.9 76.4 13.7 74.8 13.9Z" fill="#F1EEE6" stroke="#CFCBC1" stroke-width=".3"/>` +
69      `<path d="M62.5 18C62.5 12.2 75.5 12.2 75.5 18Z" fill="#9CA3AB"/>` +
70      `<rect x="68.4" y="12.4" width="1.2" height="5.4" fill="#B6BCC3"/>` +
71      `<rect x="62" y="16.8" width="14" height="1.4" rx=".5" fill="#7A828B"/>` +
72      `<circle cx="64.2" cy="17.5" r=".35" fill="#D8DCE0"/><circle cx="69" cy="17.5" r=".35" fill="#D8DCE0"/><circle cx="73.8" cy="17.5" r=".35" fill="#D8DCE0"/>`,
73  },
74  {
75    id: 'party',
76    label: 'Party hat',
77    emoji: '🥳',
78    kind: 'hat',
79    svg:
80      `<clipPath id="sk-party"><path d="M65.4 18L69 9.6L72.6 18Z"/></clipPath>` +
81      `<path d="M65.4 18L69 9.6L72.6 18Z" fill="#7FA7D9"/>` +
82      `<g clip-path="url(#sk-party)"><path d="M64 13.4L74 11.4M64 16.4L74 14.4" stroke="#E8B04B" stroke-width="1.1"/></g>` +
83      `<circle cx="69" cy="9.4" r="1.05" fill="#E96B5F"/>`,
84  },
85  {
86    id: 'tophat',
87    label: 'Top hat',
88    emoji: '🎩',
89    kind: 'hat',
90    svg:
91      `<rect x="64.5" y="10.4" width="9" height="7" fill="#2B2A27"/>` +
92      `<rect x="64.5" y="15" width="9" height="1.3" fill="#D97757"/>` +
93      `<rect x="65.2" y="11" width=".6" height="3.6" fill="#4A4945"/>` +
94      `<rect x="62" y="17" width="14" height="1.3" rx=".6" fill="#2B2A27"/>`,
95  },
96  {
97    id: 'beanie',
98    label: 'Beanie',
99    emoji: '🧢',
100    kind: 'hat',
101    svg:
102      `<path d="M62 18.2C62 12.8 76 12.8 76 18.2Z" fill="#7FA7D9"/>` +
103      `<path d="M66 13.6V16.4M69 13V16.4M72 13.6V16.4" stroke="#6A92C4" stroke-width=".5"/>` +
104      `<rect x="61.6" y="16.4" width="14.8" height="2.2" rx=".8" fill="#6A92C4"/>` +
105      `<circle cx="69" cy="12.2" r="1.5" fill="#FBF9F4"/>`,
106  },
107  {
108    id: 'headphones',
109    label: 'Headphones',
110    emoji: '🎧',
111    kind: 'hat',
112    svg:
113      `<path d="M60.6 21.6C60.6 11.6 77.4 11.6 77.4 21.6" fill="none" stroke="#3B3A36" stroke-width="1.4"/>` +
114      `<rect x="58.4" y="20" width="2.6" height="4.6" rx=".7" fill="#3B3A36"/>` +
115      `<rect x="77" y="20" width="2.6" height="4.6" rx=".7" fill="#3B3A36"/>` +
116      `<rect x="58.9" y="21.2" width=".6" height="2.2" fill="#D97757"/>` +
117      `<rect x="78.5" y="21.2" width=".6" height="2.2" fill="#D97757"/>`,
118  },
119  {
120    id: 'halo',
121    label: 'Halo',
122    emoji: '😇',
123    kind: 'hat',
124    svg: `<g class="sk-halo"><ellipse cx="69" cy="13.6" rx="5" ry="1.25" fill="none" stroke="#F2D06B" stroke-width=".9"/></g>`,
125    css: `.sk-halo{animation:sk-halo 2.6s ease-in-out infinite}
126@keyframes sk-halo{0%,100%{transform:translateY(0)}50%{transform:translateY(-.7px)}}`,
127  },
128  {
129    id: 'pumpkin',
130    label: 'Pumpkin',
131    emoji: '🎃',
132    kind: 'hat',
133    svg:
134      `<rect x="68.4" y="9.4" width="1.2" height="2" rx=".3" fill="#5E8C3E"/>` +
135      `<ellipse cx="69" cy="14.6" rx="5.8" ry="3.7" fill="#E8892E"/>` +
136      `<path d="M66 11.4Q64.6 14.6 66 17.8M72 11.4Q73.4 14.6 72 17.8M69 10.9V18.3" fill="none" stroke="#C96E1F" stroke-width=".45"/>` +
137      `<path d="M65.6 13.4L67 13.4 66.3 14.6Z M71 13.4L72.4 13.4 71.7 14.6Z" fill="#3B2A1A"/>` +
138      `<path d="M65.8 15.6Q69 17.6 72.2 15.6Q69 16.4 65.8 15.6Z" fill="#3B2A1A"/>`,
139  },
140  {
141    id: 'coffee',
142    label: 'Coffee',
143    emoji: '☕',
144    kind: 'hand',
145    svg:
146      `<path d="M83.6 22.4h.7a1.1 1.1 0 0 1 0 2.2h-.7" fill="none" stroke="#BDB8AC" stroke-width=".55"/>` +
147      `<rect x="80.4" y="21.4" width="3.4" height="3.6" rx=".5" fill="#FBF9F4" stroke="#BDB8AC" stroke-width=".35"/>` +
148      `<rect x="80.6" y="21.6" width="3" height=".7" fill="#7A5236"/>` +
149      `<rect x="80.4" y="23.1" width="3.4" height=".7" fill="#D97757"/>` +
150      `<g class="sk-steam"><path d="M81.6 20.6c-.5-.6.5-1.1 0-1.7s.5-1.1 0-1.7" fill="none" stroke="#B9B6AE" stroke-width=".4" stroke-linecap="round"/></g>`,
151    css: `.sk-steam{animation:sk-steam 2.2s ease-out infinite;opacity:0}
152@keyframes sk-steam{0%{opacity:0;transform:translateY(0)}30%{opacity:.85}100%{opacity:0;transform:translateY(-1.8px)}}`,
153  },
154  {
155    id: 'sword',
156    label: 'Sword',
157    emoji: '🗡️',
158    kind: 'hand',
159    svg:
160      `<path d="M81.6 22.8V12.6L82.1 11.4 82.6 12.6V22.8Z" fill="#D8DCE0"/>` +
161      `<rect x="82" y="13" width=".25" height="9.4" fill="#F4F6F8"/>` +
162      `<rect x="79.9" y="22.6" width="4.4" height=".9" rx=".3" fill="#C9932E"/>` +
163      `<rect x="81.6" y="23.5" width="1" height="2.6" fill="#6B4A35"/>` +
164      `<circle cx="82.1" cy="26.6" r=".6" fill="#C9932E"/>` +
165      `<g class="sk-glint">${star(82.1, 14.2, 0.9, '#FFFFFF')}</g>`,
166    css: `.sk-glint{animation:sk-glint 2.6s ease-in-out infinite;opacity:0;transform-origin:50% 50%}
167@keyframes sk-glint{0%,75%,100%{opacity:0;transform:translateY(0) scale(.4)}85%{opacity:1;transform:translateY(3px) scale(1)}}`,
168  },
169  {
170    id: 'balloon',
171    label: 'Balloon',
172    emoji: '🎈',
173    kind: 'hand',
174    svg:
175      `<g class="sk-float">` +
176      `<path d="M80.8 25Q83.6 19 83 11.2" fill="none" stroke="#8A877F" stroke-width=".3"/>` +
177      `<ellipse cx="83.4" cy="7.8" rx="2.7" ry="3.3" fill="#E96B5F"/>` +
178      `<path d="M82.8 11L84 11 83.4 11.8Z" fill="#C9564B"/>` +
179      `<ellipse cx="82.4" cy="6.6" rx=".6" ry="1" fill="#F7A49B"/>` +
180      `</g>`,
181    css: `.sk-float{animation:sk-float 3s ease-in-out infinite;transform-origin:0 100%}
182@keyframes sk-float{0%,100%{transform:rotate(-3deg)}50%{transform:rotate(3deg)}}`,
183  },
184  {
185    id: 'wand',
186    label: 'Magic wand',
187    emoji: '🪄',
188    kind: 'hand',
189    svg:
190      `<path d="M80.8 25.4L84.4 19.8" stroke="#3B3A36" stroke-width=".9" stroke-linecap="round"/>` +
191      `<path d="M84 20.4L84.4 19.8" stroke="#FBF9F4" stroke-width=".9" stroke-linecap="round"/>` +
192      star(84.9, 19, 1.3, '#F2D06B') +
193      `<g class="sk-spark">${star(86.6, 16.8, 0.6, '#F7E3A1')}${star(83.6, 16.4, 0.45, '#F7E3A1')}</g>`,
194    css: `.sk-spark{animation:sk-spark 1.4s ease-in-out infinite;transform-origin:50% 100%}
195@keyframes sk-spark{0%,100%{opacity:.2;transform:translateY(0)}50%{opacity:1;transform:translateY(-1px)}}`,
196  },
197  {
198    id: 'flower',
199    label: 'Flower',
200    emoji: '🌸',
201    kind: 'hand',
202    svg:
203      `<path d="M80.8 25.4Q81.4 21.4 82.8 18.4" fill="none" stroke="#5E8C3E" stroke-width=".55"/>` +
204      `<path d="M81.3 22.4Q79.8 21.4 80.2 20.4Q81.4 21 81.3 22.4Z" fill="#6BBE6E"/>` +
205      `<g class="sk-sway">` +
206      [0, 72, 144, 216, 288]
207        .map(a => {
208          const rad = (a * Math.PI) / 180
209          return `<circle cx="${(82.9 + Math.cos(rad) * 1.05).toFixed(2)}" cy="${(17.6 + Math.sin(rad) * 1.05).toFixed(2)}" r=".85" fill="#F29BB3"/>`
210        })
211        .join('') +
212      `<circle cx="82.9" cy="17.6" r=".6" fill="#E8B04B"/></g>`,
213    css: `.sk-sway{animation:sk-sway 2.8s ease-in-out infinite;transform-origin:50% 100%}
214@keyframes sk-sway{0%,100%{transform:rotate(-6deg)}50%{transform:rotate(6deg)}}`,
215  },
216]
217
218export const SKIN_IDS = SKINS.map(s => s.id)
219
220export function skinById(id: string): Skin | undefined {
221  return SKINS.find(s => s.id === id)
222}
223
types/index.d.ts 61 lines
1export type Activity =
2  | 'idle'
3  | 'sleep'
4  | 'thinking'
5  | 'writing'
6  | 'coding'
7  | 'web'
8  | 'reading'
9  | 'bash'
10  | 'agents'
11  | 'todo'
12  | 'ask'
13  | 'browser'
14  | 'testing'
15  | 'commit'
16  | 'deploy'
17  | 'waiting'
18  | 'debugging'
19  | 'memory'
20  | 'database'
21  | 'design'
22  | 'build'
23  | 'done'
24  | 'oops'
25
26export type Scene = { activity: Activity; detail: string }
27
28// Where the friend lives: the band above the prompt or a side pane.
29export type Place = 'stage' | 'pane'
30
31// Today's tally, kept in $.store and reset when the date changes.
32export type Stats = {
33  day: string
34  edits: number
35  reads: number
36  commands: number
37  web: number
38  helpers: number
39  plans: number
40  questions: number
41  done: number
42  snags: number
43}
44
45declare module 'claude-code' {
46  interface PluginState {
47    'clawd-friend': {
48      scene: Scene
49      isOn: boolean
50      preview: Activity | null
51      place: Place
52      // The skin he wears: a skin id, or '' for none.
53      skin: string
54      // What he's called; greets with "<name> has arrived!".
55      name: string
56      // True for a few seconds after he arrives, while he waves hello.
57      isArriving: boolean
58    }
59  }
60}
61