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

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.

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.

He picks a scene from what Claude is actually doing, as it happens:
| Scene | When |
|---|---|
| 💭 Thinking | Claude is thinking, or reading a tool's result before its next move |
| ⌨️ Writing | Claude is writing its reply, or editing .md / .txt docs (a typewriter that faces him) |
| 💻 Coding | Editing or writing code (a laptop, lid turned toward him) |
| 📰 Web search | WebSearch, WebFetch and search or docs MCP tools (he reads the paper with a coffee) |
| 🔍 Reading | Read, Grep, Glob (a magnifying glass over the docs) |
| ⚡ Commands | Any other shell command (green hacker glyphs rain above him) |
| 🧪 Tests | npm test, pytest, vitest, jest, go test, cargo test, ... |
| 📦 Commit | git commit |
| 🚀 Deploy | git push, vercel, npm publish, npm run deploy, deploy MCP tools |
| 🧱 Build | npm run build, tsc, cargo build, docker build, make, ... |
| 🗄️ Database | psql, prisma migrate, Supabase / SQL MCP tools, .sql files and migrations |
| 🐞 Debugging | node --inspect, pdb, gdb, debugging skills |
| 🎨 Design | .css / .svg / Tailwind config edits, Figma and design MCP tools |
| 📔 Memory | Writing CLAUDE.md or Claude's memory files |
| 🐾 Subagents | Claude sends out subagents (tiny Clawds march off with their tasks) |
| 📋 Planning | Todo and task list updates |
| 🌐 Browsing | Browser automation tools |
| 👋 Asking | Claude asks you a question |
| 🪧 Needs your OK | A tool call is waiting for your permission |
| 🎉 Done | A turn finished: he jumps, confetti flies |
| 💧 Oops | A turn ended on an error |
| 😌 Chilling | Nothing going on |
| 💤 Napping | Two quiet minutes in a row |

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.
| Hats | Items 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.
/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.
Not sure what's there? /friend menu lists everything, with buttons in the desktop app.
| Command | What it does |
|---|---|
/friend | Call your friend, or send him off if he's already here (remembered across sessions) |
/friend menu | Every command, with buttons |
/friend skins | The skin gallery; click one to wear it |
/friend <skin> | Wear a skin, e.g. /friend crown or /friend sword |
/friend noskin | Take the skin off |
/friend name <name> | Give him a name (up to 16 characters) |
/friend scenes | Every scene, with buttons to play them |
/friend <scene> | Play any scene for a few seconds, e.g. /friend deploy or /friend build |
/friend pane | Move him into a side pane |
/friend stage | Put him back above the prompt |
/friend stats | What he saw today: file edits, reads, commands, web lookups, helpers, finished turns |
Claude Friend is a Claude Code mod: a plugin whose behaviour is a TypeScript module of function hooks.
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.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.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.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.
.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
hooks/register.tsx 718 lines1import { 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}
718hooks/classify.ts 67 lines1import 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}
67hooks/scenes.ts 1071 lines1import 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 === '<' ? '<' : g === '>' ? '>' : 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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[]
1071hooks/skins.ts 223 lines1// 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}
223types/index.d.ts 61 lines1export 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