An animated pixel-art Claude crab above your prompt that acts out what Claude Code is doing: thinking, reading, building (ten scenes), testing, debugging, web…

A Claude Code plugin that puts the orange Claude crab just above your prompt and has it act out what Claude is doing. It reads with a magnifier while Claude reads files, hammers while files change, cheers when tests pass, sweats through a long turn and dozes off when you step away. It works in the Claude Code desktop app and in the terminal, in light and dark themes, and it costs 0 tokens.

claude plugin marketplace add pvjbuilds/crab-buddy
claude plugin install crab-buddy@crab-buddy
Start a new session and the crab is there.
A long agent turn is a wall of tool calls. Crab Buddy turns it into something you can read from across the room: one glance tells you whether Claude is thinking, reading, building, testing, stuck on a failure or waiting for you. It is a status light with a personality, and it never spends a token or touches the network.
| Dark theme | Light theme |
|---|---|
![]() | ![]() |
| Dark theme | Light theme |
|---|---|
![]() | ![]() |
110 takes across 25 moods, each labelled with the name the glossary uses for it.

| What Claude is doing | What the crab does |
|---|---|
| Thinking | Sways, plays chess, sketches on a chalkboard, fits a puzzle, gets an idea |
| Reading or searching files | Magnifier, book, newspaper, telescope, file drawer, scanner, map |
| Editing files or running commands | Builds a wall, saws, types, paints, turns gears; then welds, drills, drafts a blueprint, sweeps up, runs a crane (a new scene every 14 s) |
| Running tests | Flask, checklist, stopwatch, thermometer, scales, microscope, a progress bar; cheers on a pass, trips on a fail |
| A command failed | An exclamation mark; three failures in a row and it gets dizzy |
| Tests pass again after a failure | Confetti across the whole stage |
| Searching the web | Globe, surfboard or wifi waves |
| A subagent is working | A mini crab carries a box |
| Another model is working (Codex, Gemini) | Hands a parcel to a robot |
| Committing or pushing with git | Rocket, stamp or a commit graph |
| Installing a package | A parcel drops in |
| Waiting for your reply | A question mark |
| A turn past 5 minutes | Coffee, an hourglass, a fan, a snack, a racing clock, a draining battery, a treadmill |
| Compacting the conversation | Squashes flat, only once a compaction really runs |
| Done | Jumps, dances or bows |
| Idle, then dozing after 10 minutes | Fidgets, then sleeps; between midnight and 5 am it's a night owl |
Tested on Claude Code 2.1.296.
# install
claude plugin marketplace add pvjbuilds/crab-buddy
claude plugin install crab-buddy@crab-buddy
# update
claude plugin marketplace update crab-buddy
claude plugin update crab-buddy@crab-buddy
# remove
claude plugin uninstall crab-buddy@crab-buddy
The crab shows in sessions started after an install or update.
Does it cost tokens or slow Claude down? No. It makes no model calls and no network requests. The animation is CSS in the desktop app and the terminal's own frame clock in a terminal, so nothing runs per frame in Claude Code.
Does it work with my other plugins? Yes. Other plugins' status lines move into the crab's band, so they show in one place instead of piling up in the footer. Turn Crab Buddy off and they go back to the footer.
Which platforms? It has been checked in the desktop app on macOS and Windows and in macOS terminals. It uses nothing platform-specific, so it should run wherever Claude Code's plugin hooks run.
Can it keep still? In the desktop app, turn on "reduce motion" in your OS and the crab stops moving; the caption keeps working.
Can I change how it behaves? Yes, it is MIT. Fork it, and use the glossary to name the part you want changed, to yourself or to your coding agent.
One word for each part of the crab, so a change can be asked for in one sentence ("make think5's work loop slower", "dwell 5 s", "no hearts on test"). The full version, with every term's place in the code, a scene timeline and a live atlas of all 110 takes, is the glossary page (source, regenerated by bun docs/glossary.ts).
| Term | What it is |
|---|---|
| band | The strip right above the prompt box. Crab height, never taller. |
| caption | The two one-liners on the band's left, newest on top. |
| stage | The band's right half, where the crab walks (desktop app). |
| station | The crab's spot on the stage for a mood, 0 (left) to 1 (right). |
| mood | What Claude is doing, one of 25 ids: think, read, build, test… |
| take | One version of a mood: think, think1 … think8. |
| look | The mood and take on screen right now. |
| scene | The 14 s a take plays: work, a beat, work, another beat. |
| work loop | The take's own repeating motion, such as the hammer swing. |
| beat | A break in the scene where the whole crab hops, admires, turns, stretches, shimmies or tilts. |
| emote | The tiny icon over its shoulder during a beat: !, ♪, spark, ?, heart, sweat. |
| prop / set / stage extra | On the crab / beside it / across the whole stage (wall, stripe, confetti, footprints). |
| build scene / lap | The five moods a build rotates through; a lap is one pass, and the second lap plays their second takes. |
| dwell / hold | 8 s, the least a look stays / 5 s, how long a result shows. |
| one-liner / meaning | A caption line / the plain text on hover. |
If a user asks you to install, check, remove or change Crab Buddy, here is everything you need.
| What it is | A Claude Code plugin (a hooks module with a UI render). Its own one-plugin marketplace. |
| Plugin id | crab-buddy@crab-buddy |
| Install | claude plugin marketplace add pvjbuilds/crab-buddy, then claude plugin install crab-buddy@crab-buddy |
| Check it installed | claude plugin list shows crab-buddy@crab-buddy as enabled |
| Takes effect | In the next new session; the current session is unchanged |
| Remove | claude plugin uninstall crab-buddy@crab-buddy |
| Cost and access | No model calls, no network, no file writes. Reads Claude Code's theme setting at session start. |
| Side effect | Other plugins' status lines draw in the crab's band instead of the footer |
| Vocabulary | Map the user's words to code with the glossary; docs/glossary.html names the constant behind every term |
Code map:
| File | Holds |
|---|---|
hooks/crab.ts | Pure mood logic: classify (tool call to mood), step, shown, take, MEANING, LINES |
hooks/art.ts | Desktop SVG art: LOOK (take 0), TAKE (the rest), BEATS, EMOTE, STATION, ROAM, stageArt |
hooks/term.tsx | Terminal art in quadrant block characters |
hooks/register.tsx | The hooks: tool calls, turns and compaction set the mood; the AbovePrompt render draws the band; DWELL, CUT |
CONTEXT.md | Design decisions and the current state |
Before claiming a change works, run the checks below. After a change to the art, rebuild the images with bun docs/shots/make.ts and look at them.
claude plugin validate .
claude plugin test .
bun docs/shots/make.ts # README images, social card and glossary (needs Chrome and ffmpeg)
To see edits live without reinstalling, start Claude Code with claude --plugin-dir . from this folder; edits reload at the end of each turn.
See Releases. Recent:
MIT, for the code in this repository.
Crab Buddy is a fan project. It is not made, endorsed or supported by Anthropic. Claude, Claude Code and the Claude crab character are Anthropic's; the crab here is drawn after the one on Claude Code's welcome screen.
hooks/register.tsx 223 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import type { Crab, Mood } from '../types'
4import { classify, step, shown, take, setLine, START, MEANING, LINES } from './crab.ts'
5import type { CrabEvent } from './crab.ts'
6import { crabArt, stageArt, where, STATION } from './art.ts'
7import { COLS, ROWS } from './term.tsx'
8
9const crab = atom({ plugin: 'crab-buddy', key: 'crab' } as const, START)
10// What the crab acts out, which take, since when, the caption log (newest first) and the stage spot it walks
11// in from.
12type Look = { mood: Mood; take: number; at: number; log: string[]; from?: number }
13const look = atom({ plugin: 'crab-buddy', key: 'look' } as const, { mood: 'idle', take: 0, at: 0, log: [], from: 1 } as Look)
14// Rapid changes flashed by too fast to read (Praj, 12 Oct): a look stays at least this long, then the crab
15// jumps to whatever is current. The same mood keeps its take until the take's own clock moves it on.
16// 8 s, not 3 (Praj, 12 Oct 13:05: still swapped every 2-4 s). A held look (an error, a test result, done), a
17// question or a squish cuts straight in: a 5 s hold would vanish inside the dwell, and the trip lands on its moment.
18const DWELL = 8_000
19const CUT: ReadonlySet<Mood> = new Set(['debug', 'dizzy', 'testf', 'testp', 'fixed', 'done', 'ask', 'comp'])
20// Where the crab stands now, mid-walk or mid-wander: a new look walks on from here, never from the old spot,
21// which snapped the crab back across the stage (Praj, 12 Oct). The first look is timed from the session's start.
22let began = 0
23const here = (x: Look, now: number) => (x.at || began) > 0 ? where(x.mood, x.from ?? STATION[x.mood], (now - (x.at || began)) / 1000) : STATION[x.mood]
24// Every plugin's status line, by plugin, drawn in the crab's bar instead of the footer's "+1" overflow.
25const lines = atom({ plugin: 'crab-buddy', key: 'lines' } as const, {} as Record<string, string>)
26
27// The render reads only `look`, so the crab redraws when its mood or take changes and never in between.
28// eslint-disable-next-line @typescript-eslint/no-explicit-any
29async function show($: any, c: Crab, now: number) {
30 const was = await read($, look)
31 const m = shown(c, now)
32 if (now - was.at < DWELL && (m === was.mood || !CUT.has(m))) return
33 if (m === was.mood) {
34 // A repeat (Read after Read) restarts lastActive, which would re-pick the take on every call.
35 const t = take({ ...c, lastActive: was.at }, now, m)
36 if (t !== was.take) await update($, look, x => ({ ...x, take: t, at: now, from: here(x, now) }))
37 return
38 }
39 const t = take(c, now, m)
40 // A one-liner picked by the millisecond the mood came up; the caption scrolls down one line.
41 const line = LINES[m][now % LINES[m].length]
42 await update($, look, x => ({ mood: m, take: t, at: now, from: here(x, now), log: [line, ...x.log.filter(l => l !== line)].slice(0, 2) }))
43}
44
45// eslint-disable-next-line @typescript-eslint/no-explicit-any
46async function apply($: any, ev: CrabEvent) {
47 const now = await $.clock.now()
48 await show($, await update($, crab, x => step(x, ev, now)), now)
49}
50
51// eslint-disable-next-line @typescript-eslint/no-explicit-any
52async function retime($: any) {
53 await show($, await read($, crab), await $.clock.now())
54}
55
56const FOOTER: ReadonlySet<string> = new Set(['handoff-wait'])
57
58// An empty band: nothing drawn, or a Box with no children.
59const isEmpty = (n: unknown) => {
60 const kids = (n as { children?: unknown } | null)?.children
61 return !n || kids == null || (Array.isArray(kids) && kids.every(k => k == null || k === false))
62}
63
64// Subagents' own turns and tools stay out: the main loop's Agent call already shows the mini crab.
65const isSub = (e: unknown) => typeof (e as { agentId?: unknown }).agentId === 'string'
66
67export const register: Register = on => {
68 on('session.start', async ($, e, next) => {
69 const now = await $.clock.now()
70 await update($, crab, c => ({ ...c, lastActive: now }))
71 began = now
72 // Timed moods (done, a test result, doze, night owl, long turn) change with no event; 1 s also ends a dwell on time.
73 $.clock.every(1_000, () => retime($))
74 return next(e)
75 })
76
77 on('turn.start', async ($, e, next) => {
78 if (!isSub(e)) await apply($, { kind: 'turnStart' })
79 return next(e)
80 })
81
82 on('turn.complete', async ($, e, next) => {
83 const r = await next(e)
84 if (!isSub(e)) await apply($, { kind: 'turnEnd' })
85 return r
86 })
87
88 on('tool.call', async ($, e, next) => {
89 if (isSub(e)) return next(e)
90 const m = classify(String(e.tool), e as never as Record<string, unknown>)
91 await apply($, { kind: 'tool', mood: m })
92 const r = await next(e)
93 await apply($, { kind: 'toolEnd', mood: m, isError: r.isError === true })
94 return r
95 })
96
97 // A permission dialog opened: the crab holds up a question mark until the call goes on. Not `tool.check`'s ask,
98 // which the mode settles after it: auto mode's classifier approved most unseen, and the "?" showed for nobody (12 Oct).
99 on('classic.PermissionRequest', async ($, e, next) => {
100 if (typeof (e as { agent_id?: unknown }).agent_id !== 'string') await apply($, { kind: 'ask' })
101 return next(e)
102 })
103
104 // A precompute runs unseen in the background, and a subagent's compaction is its own: neither squishes the crab.
105 // No 🦀 emoji: the real crab squishes right beside the line (Praj, 11 Oct). The bar line doubles as a trace: Praj saw no squish on 11 Oct's auto compaction; line but no squish = drawing,
106 // neither = the hook never fired.
107 // The squish waits for the settings PreCompact hooks: one that blocks (Praj's compact_gate.py holds auto-compaction
108 // mid-task) leaves the conversation as it is, and the crab squished for nothing (Praj, 16 Oct).
109 let armed: string | undefined, squished = false
110 on('session.compact', async ($, e, next) => {
111 if (e.trigger === 'precompute' || isSub(e)) return next(e)
112 armed = e.trigger
113 try { return await next(e) } finally {
114 armed = undefined
115 if (squished) {
116 squished = false
117 await apply($, { kind: 'compactEnd' })
118 await update($, lines, l => setLine(l, 'crab-buddy', undefined))
119 }
120 }
121 })
122 on('classic.PreCompact', async ($, e, next) => {
123 const r = await next(e)
124 if (armed && !r.block && !r.preventContinuation) {
125 squished = true
126 await apply($, { kind: 'compact' })
127 await update($, lines, l => setLine(l, 'crab-buddy', `Compacting (${armed})`))
128 }
129 return r
130 })
131
132 // Cache warm stays in the footer, beside the model name, and not again in the bar (Praj, 11 Oct).
133 // Status lines move into the crab's bar: all of them in one glance, no plugin names, no "+1" (Praj, 11 Oct).
134 // Not passed on, so the footer stays empty; with crab-buddy off they fall back to the footer.
135 on('ui.status', async ($, e, next) => {
136 const who = next.origin.plugin
137 if (FOOTER.has(who)) return next(e)
138 await update($, lines, l => setLine(l, who, e.text))
139 // Cleared below too: a resumed session re-pins the footer lines it had before the restart.
140 return next({ ...e, text: undefined })
141 })
142
143 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
144 const rest = await next(e)
145 if (e.props.hasSurvey) return rest
146 const { mood: m, take: t, log: seen, from } = await read($, look)
147 // The caption log, newest on top; before the first change, the mood's first line.
148 const log = seen.length ? seen : [LINES[m][0]]
149 // Footer-owned lines never draw here, even one a 0.2.0 session left in the atom.
150 const ls = Object.entries(await read($, lines)).filter(([who]) => !FOOTER.has(who))
151 const { Box, Svg, Text, Client } = $.ui.resolve(e) as ReturnType<typeof $.ui.resolve> & { Svg: never; Client: never }
152 const row = ls.length > 0 && (
153 <Box key="crab-lines" flexDirection="row" flexWrap="wrap" columnGap={3}>
154 {ls.map(([who, text]) => <Text key={who} color="text">{text}</Text>)}
155 </Box>
156 )
157 const info = !!row || !isEmpty(rest)
158 // Terminal: the welcome-screen crab in quadrant blocks, at the band's bottom right, three rows tall; the
159 // terminal animates it (./term.tsx). Too narrow for it beside the lines, the lines keep the room.
160 // By surface, not by table: the terminal's table answers Svg too, drawn as an empty fragment.
161 if (e.surface !== 'desktop') {
162 if (e.surface !== 'terminal' || e.props.bodyColumns < COLS + 24) return row ? <Box flexDirection="column">{row}{rest}</Box> : rest
163 return (
164 <Box key="crab-band" flexDirection="row" alignItems="flex-end" columnGap={2}>
165 <Box key="crab-text" flexDirection="column" flexGrow={1} flexShrink={1}>
166 {row}
167 {info && rest}
168 </Box>
169 <Client key="crab-term" module="./term.tsx" props={{ mood: m }} width={COLS} height={ROWS} />
170 </Box>
171 )
172 }
173 // The crab is in flow at the bar's right, beside the lines: floated, the band clipped it or shrank it to
174 // its slot (0.4.3). 52×41: one size up, art ratio 38:30 (Praj, 11 Oct).
175 // The bar reads like the transcript (Praj, 11 Oct). Nothing floats: a floated fill painted over
176 // the crab and the lines in the app (0.4.5-0.4.7; 0.4.7's lift boxes did not lift them, live check 22:07).
177 // - crab-fill is the crab's and the lines' own parent, so they paint over its fill in document order.
178 // - It reaches into the card's padding ((40px - 1lh)/2 x 8px; margin units .5lh and ch) with the same
179 // padding back, so the content stays put. The band clips overshoot above and sideways without
180 // scrolling; below, overshoot would scroll the band, so the bottom stops at the padding's edge:
181 // 1.16 x .5lh = (40px - 1lh)/2 at the measured lh 18.5 (half a line left a 1.5px grey line, 22:24).
182 // - crab-band carries the same colour: the app zeroes vertical margins down a single-child chain from
183 // the root, and stops at a Box with a background.
184 // ponytail: 1.16 is calibrated to lh 18.5: a larger text size leaves a grey line below (lower it), a smaller
185 // one lets the bar scroll a pixel or two.
186 // The transcript's colour by theme key, which the desktop resolves by the app's own theme: Claude Code's theme
187 // setting can say dark while the app is light (Windows, Praj 12 Oct). Probe 10:58: #151515 dark, #fcfcfb light.
188 const bg = 'userMessageBackground'
189 const stage = e.props.bodyColumns >= 60
190 return (
191 <Box key="crab-band" flexDirection="row" backgroundColor={bg}>
192 {/* The crab stands at the bottom right, right on the prompt box, however many lines sit beside it (Praj, 12 Oct). */}
193 <Box key="crab-fill" flexDirection="row" alignItems="flex-end" columnGap={2} flexGrow={1} backgroundColor={bg}
194 marginTop={-2} paddingTop={2} marginBottom={-1.16} paddingBottom={1.16} marginLeft={-2} paddingLeft={2} marginRight={-2} paddingRight={2}>
195 <Box key="crab-text" flexDirection="column" flexGrow={1} flexShrink={1}>
196 {row}
197 {info && rest}
198 {/* Crab alone in the band: its caption fills the left, so the bar is never an empty strip (Praj, 12 Oct). */}
199 {/* Both lines always, newest on top in full text colour, the one before dimmer (Praj, 14 Oct: beside a
200 status line the older one was dropped, which read as the line hiding it). */}
201 {log.map((l, i) => <Text key={l} color={i === 0 ? 'text' : 'subtle'} wrap="truncate-end">{l}</Text>)}
202 </Box>
203 {/* The right half is the crab's stage (Praj, 12 Oct). The Svg asks for more than the half and is capped to it.
204 Under 60 columns the half is too small to walk in, and the crab stands as before. */}
205 <Box key="crab-buddy" flexShrink={0} width={stage ? '50%' : undefined}>
206 {/* On the stage the label is part of the crab's art, over its head wherever it stands; interactive, so
207 the art sees the pointer. On Windows a hover Box got the desktop's own pill, which kept only the text
208 colour (Praj's photos, 12 Oct). */}
209 {stage
210 ? <Svg source={stageArt(m, t, from ?? STATION[m], MEANING[m])} alt={`Claude crab: ${m}`} width={2000} height={41} isInteractive />
211 : <Svg source={crabArt(m, t)} alt={`Claude crab: ${m}`} width={52} height={41} />}
212 {/* Narrow band, crab in the corner: the label floats left of it in the band's own colours, top row. */}
213 {!stage && <Box position="absolute" top={0} right={8} display="none" hover={{ display: 'flex' }}
214 backgroundColor={bg} paddingX={1}>
215 <Text color="text" wrap="truncate-end">{MEANING[m]}</Text>
216 </Box>}
217 </Box>
218 </Box>
219 </Box>
220 )
221 })
222}
223hooks/crab.ts 213 lines1import type { Mood } from '../types'
2
3const TEST = /\b(pytest|jest|vitest|unittest|plugin test|(npm|pnpm|yarn|bun|go|cargo|deno) test)\b/
4const INSTALL = /\b((npm|pnpm|yarn|bun) (i|install|add)|pip3? install|uv (add|pip install|tool install)|brew install)\b/
5
6/** The mood a main-loop tool call starts. */
7export function classify(tool: string, args: Record<string, unknown>): Mood {
8 if (tool === 'AskUserQuestion') return 'ask'
9 if (tool === 'Agent' || tool === 'Task') return 'sub'
10 if (/^(WebSearch|WebFetch)$/.test(tool)) return 'web'
11 if (/^(Read|Grep|Glob|LS)$/.test(tool)) return 'read'
12 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return 'build'
13 if (tool !== 'Bash') return 'think'
14 const cmd = typeof args.command === 'string' ? args.command : ''
15 if (TEST.test(cmd)) return 'test'
16 if (/(^|[\s;&|/])(codex|agy)\s/.test(cmd)) return 'codex'
17 if (/\bgit (commit|push)\b/.test(cmd)) return 'git'
18 if (INSTALL.test(cmd)) return 'inst'
19 return 'build'
20}
21
22import type { Crab } from '../types'
23
24export type CrabEvent =
25 | { kind: 'turnStart' | 'turnEnd' | 'ask' | 'compact' | 'compactEnd' }
26 | { kind: 'tool'; mood: Mood }
27 | { kind: 'toolEnd'; mood: Mood; isError: boolean }
28
29export const START: Crab = { mood: 'idle', until: 0, fails: 0, testFailed: false, turnAt: null, lastActive: 0 }
30
31// A held look cuts through the band's dwell (register.tsx CUT), so it can never be skipped.
32const HOLD = 5_000
33const DONE = 5_000
34const LONG = 5 * 60_000
35const DOZE = 10 * 60_000
36const SWAP = 8_000
37// Building is most of a turn, so it rotates five scenes, 14 s each, counted from the turn's start (Praj, 12 Oct; 10 s
38// switched too often, 40% longer).
39// The 5 s retime tick moves it on; each switch redraws the crab once.
40export const BUILDS = ['build', 'saw', 'type', 'paint', 'gear'] as const satisfies readonly Mood[]
41const SCENE = 14_000
42// The night owl keeps the person's own clock: the host's local hour.
43const localHour = (now: number) => new Date(now).getHours()
44// Moods that only last while their tool runs; after it Claude is thinking again.
45const BRIEF: ReadonlySet<Mood> = new Set(['ask', 'sub', 'codex', 'git', 'inst', 'test'])
46
47export function step(c: Crab, ev: CrabEvent, now: number): Crab {
48 switch (ev.kind) {
49 case 'turnStart': return { ...c, mood: 'think', until: 0, turnAt: now, lastActive: now }
50 case 'turnEnd': return { ...c, mood: 'done', until: now + DONE, turnAt: null, lastActive: now }
51 case 'tool': return { ...c, mood: ev.mood, until: 0, lastActive: now }
52 case 'ask': return { ...c, mood: 'ask', until: 0, lastActive: now }
53 case 'compact': return { ...c, mood: 'comp', until: 0, lastActive: now }
54 case 'compactEnd': return { ...c, mood: c.turnAt === null ? 'idle' : 'think', until: 0, lastActive: now }
55 case 'toolEnd': {
56 const isTest = ev.mood === 'test'
57 if (ev.isError) {
58 const fails = c.fails + 1
59 return { ...c, fails, testFailed: isTest || c.testFailed, mood: isTest ? 'testf' : fails >= 3 ? 'dizzy' : 'debug', until: now + HOLD, lastActive: now }
60 }
61 if (isTest) return { ...c, fails: 0, testFailed: false, mood: c.testFailed ? 'fixed' : 'testp', until: now + HOLD, lastActive: now }
62 return { ...c, fails: 0, mood: BRIEF.has(ev.mood) ? 'think' : ev.mood, until: 0, lastActive: now }
63 }
64 }
65}
66
67/** What the crab acts out at `now`. */
68export function shown(c: Crab, now: number): Mood {
69 if (c.until > now || c.mood === 'comp') return c.mood
70 if (c.turnAt !== null) {
71 const busy = c.until ? 'think' : c.mood
72 // Past 5 minutes the sweat takes turns with the work, 8 s each, so a long turn keeps its flavour (Praj, 11 Oct).
73 const over = now - c.turnAt - LONG
74 if (over > 0 && Math.floor(over / SWAP) % 2 === 0 && (busy === 'think' || busy === 'read' || busy === 'build')) return 'long'
75 return busy === 'build' ? BUILDS[Math.floor((now - c.turnAt) / SCENE) % BUILDS.length] : busy
76 }
77 if (localHour(now) < 5) return 'night'
78 return now - c.lastActive >= DOZE ? 'doze' : 'idle'
79}
80
81// Every other mood has four takes (Praj, 12 Oct: "3 more options to each state"), the busiest nine (16 Oct). The first is picked by the
82// millisecond the mood began, so two test runs in a row seldom match; then the next take every 14 s. Idle,
83// doze and night owl hold a take 42 s, past idle's own 30 s fidget loop. A held mood (done, a test result)
84// lasts under 14 s, so it never switches mid-gag.
85const CALM: ReadonlySet<Mood> = new Set(['idle', 'doze', 'night'])
86// The looks a long task shows most get five more takes (Praj, 16 Oct: "quite repetitive").
87const BUSY: ReadonlySet<Mood> = new Set(['think', 'read', 'test', 'long'])
88const isBuild = (m: Mood) => (BUILDS as readonly Mood[]).includes(m)
89
90/** How many takes `mood` has: each build scene has a second, for the build's second lap. */
91export const takes = (mood: Mood) => isBuild(mood) ? 2 : BUSY.has(mood) ? 9 : 4
92
93/** Which of its takes `mood` plays at `now`. A build scene changes take with each lap of the five, so a long build
94 * shows ten scenes before one repeats (Praj, 16 Oct: weld, drill, blueprint, sweep, crane). */
95export function take(c: Crab, now: number, mood: Mood): number {
96 if (isBuild(mood)) return c.turnAt === null ? 0 : Math.floor((now - c.turnAt) / (SCENE * BUILDS.length)) % 2
97 const held = Math.max(0, now - c.lastActive)
98 return (c.lastActive + Math.floor(held / (CALM.has(mood) ? 3 * SCENE : SCENE))) % takes(mood)
99}
100
101/** The status lines by plugin after `who` set `text`; an empty text takes its line away. */
102export function setLine(l: Record<string, string>, who: string, text: string | undefined): Record<string, string> {
103 if (!text) return Object.fromEntries(Object.entries(l).filter(([k]) => k !== who))
104 return { ...l, [who]: text }
105}
106
107/** What each animation means, shown when the pointer is on the crab. Said with certainty, no "or" (Praj, 12 Oct). */
108export const MEANING: Record<Mood, string> = {
109 idle: 'Idle: waiting for your message',
110 think: 'Thinking',
111 read: 'Reading and searching files',
112 build: 'Editing files and running commands',
113 saw: 'Editing files and running commands',
114 type: 'Editing files and running commands',
115 paint: 'Editing files and running commands',
116 gear: 'Editing files and running commands',
117 test: 'Running tests',
118 testp: 'Tests passed',
119 testf: 'Tests failed',
120 debug: 'A command failed: fixing it',
121 dizzy: 'Three failures in a row',
122 fixed: 'Tests pass again after a failure',
123 done: 'Turn finished',
124 doze: 'Dozing: nothing happened for 10 min',
125 night: 'Night owl: between midnight and 5 am',
126 web: 'Searching and reading the web',
127 sub: 'A subagent is working',
128 codex: 'Another model is working: Codex, Gemini',
129 git: 'Committing and pushing with git',
130 inst: 'Installing a package',
131 ask: 'Waiting for your reply',
132 long: 'This turn has run past 5 min',
133 comp: 'Compacting the conversation',
134}
135
136const BUILD_LINES = [
137 'Hammering out the changes.', 'Building it, brick by brick.', 'Writing code with both claws.',
138 'Under construction. Hard hat on.', 'Making edits. Sawdust everywhere.', 'Typing faster than a crab can scuttle.',
139 'Running commands, claws crossed.', 'Shaping your code into place.', 'Measure twice, edit once.', 'Gears turning, files changing.',
140 'Welding the pieces together.', 'Drilling into the details.', 'Drafting the blueprint.', 'Sweeping up after the edits.',
141 'Craning the next block into place.',
142] as const
143
144/** The band caption: ten one-liners a mood, one picked at random each time the mood comes up (Praj, 12 Oct:
145 * "more fun to read, keeps the user on screen"). The hover label keeps the plain MEANING. No "or". */
146export const LINES: Record<Mood, readonly string[]> = {
147 idle: ['Waiting for your next idea.', 'Claws ready. Your move.', 'Just crabbing around until you type.', 'Sand between my toes, eyes on the prompt.',
148 'Ready when you are.', 'Polishing my shell while I wait.', 'Say the word and I scuttle.', 'Listening to the waves. And your keys.',
149 'Nothing to do. Living the crab life.', 'All quiet on the shoreline.'],
150 think: ['Thinking it through, sideways.', 'Hmm. Clicking claws in thought.', 'Connecting the dots…', 'Weighing it up, claw by claw.',
151 'Brain in motion, legs at rest.', 'Plotting the next move.', 'Deep in thought. Do not poke the crab.', 'Considering every angle. Crabs have many.',
152 'Cooking up a plan.', 'One sec, an idea is surfacing.', 'Mulling it over on the seafloor.',
153 'Moving the pieces around in my head.', 'Sketching the plan on the board.', 'Fitting the puzzle together.', 'Aha! Something just clicked.'],
154 read: ['Reading your files so you don\'t have to.', 'Skimming code at crab speed.', 'Nose in the source.', 'Digging through files like sand.',
155 'Hunting for the right line.', 'Reading the fine print.', 'Searching every nook and cranny.', 'Taking notes with a claw.',
156 'Getting to know your codebase.', 'Turning pages, turning over rocks.', 'Catching up on the code news.',
157 'Scanning the horizon for clues.', 'Flipping through the file drawer.', 'Scanning line by line.', 'Mapping out the codebase.'],
158 build: BUILD_LINES, saw: BUILD_LINES, type: BUILD_LINES, paint: BUILD_LINES, gear: BUILD_LINES,
159 test: ['Running the tests. Claws crossed.', 'Moment of truth: tests running.', 'Putting the code through its paces.', 'Checking the work before you do.',
160 'Tests running. Holding my breath.', 'Poking the code to see if it squeaks.', 'Let\'s see what breaks.', 'Science time: running the experiment.',
161 'Testing, testing, one two three.', 'Waiting on the verdict.', 'Taking the code\'s temperature.',
162 'Weighing the results.', 'Poking it to see if it wobbles.', 'Under the microscope.', 'Progress bar filling up.'],
163 testp: ['All green. Happy crab.', 'Tests pass! Claws in the air.', 'Green across the board.', 'Nailed it. Every test passes.',
164 'Passing with flying colours.', 'The tests approve.', 'Clean run. Not a single failure.', 'Gold star for this code.',
165 'Everything checks out.', 'Tests pass. Doing a little dance.'],
166 testf: ['A test failed. On it.', 'Red light. Something broke.', 'Not quite. A test said no.', 'Failure found. Better now than later.',
167 'A test caught something.', 'Back to the drawing board, briefly.', 'Red results. Rolling up my sleeves.', 'That didn\'t pass. Investigating.',
168 'The tests found a bug. Good tests.', 'Close, but no seaweed.'],
169 debug: ['That command failed. Fixing it.', 'Oops. Error spotted, fix incoming.', 'Something went sideways. Crab-style.', 'Hit a snag. Untangling it.',
170 'Error message received. Reading it.', 'A bump in the road.', 'Bug-hunting season is open.', 'Not what we expected. Adjusting.',
171 'Wrench out. Fixing the error.', 'Every error is a clue.'],
172 dizzy: ['Three fails in a row. Seeing stars.', 'Head spinning, still trying.', 'Tough one. Not giving up.', 'Round and round we go.',
173 'Third time was not the charm.', 'Dizzy, but determined.', 'This bug is putting up a fight.', 'Shaking it off. Trying again.',
174 'Stubborn bug, more stubborn crab.', 'Regrouping after a rough patch.'],
175 fixed: ['Fixed! Tests pass again.', 'Back in the green.', 'Bug squashed.', 'From red to green. Victory.',
176 'Comeback complete.', 'Problem solved. Confetti time.', 'That bug never stood a chance.', 'Broken, then fixed. Classic.',
177 'Tests are happy again.', 'Redemption arc complete.'],
178 done: ['Done! Your turn.', 'All finished. Take a look.', 'Wrapped up. Ready for review.', 'Mission complete.',
179 'That\'s a wrap.', 'Job done. Taking a bow.', 'Finished. What\'s next?', 'Done and dusted.',
180 'Ta-da!', 'Over to you, captain.'],
181 doze: ['Zzz… wake me with a message.', 'Napping. Type to wake me.', 'Dreaming of clean code.', 'Ten quiet minutes. Crab nap.',
182 'Resting my claws.', 'Counting sheep, then crabs.', 'Asleep, but still here.', 'Snoozing on the sand.',
183 'Shh. The crab is sleeping.', 'Recharging. Say hi anytime.'],
184 night: ['Up late? Me too.', 'Night owl mode: on.', 'Burning the midnight oil.', 'The moon is out. So are we.',
185 'Late-night coding crew.', 'Stars out, code in.', 'Quiet hours, good focus.', 'Midnight snack: a few more lines.',
186 'The best bugs come out at night.', 'Remember to sleep sometime.'],
187 web: ['Searching the web for answers.', 'Surfing the internet.', 'Asking the internet.', 'Out on the web, fetching facts.',
188 'Checking the docs online.', 'Casting a net on the web.', 'Browsing so you don\'t have to.', 'Looking it up.',
189 'Catching a wave of results.', 'Reading the web, page by page.'],
190 sub: ['Sent a helper crab on an errand.', 'A subagent is on it.', 'Delegating to a tiny crab.', 'Teamwork: a helper is working.',
191 'Mini crab dispatched.', 'Many claws make light work.', 'A helper is digging in.', 'Crab squad assembled.',
192 'Splitting the work with a helper.', 'Waiting on the helper\'s report.'],
193 codex: ['Another model is working.', 'Calling in a second opinion.', 'Phoning a friend.', 'Handing off to another model.',
194 'Two brains on this one.', 'A colleague model is typing.', 'Outsourcing the heavy lifting.', 'Another model has the ball.',
195 'Waiting on the other model.', 'Collaborating across models.'],
196 git: ['Committing your changes.', 'Shipping it with git.', 'Saving progress to history.', 'Stamped and committed.',
197 'Pushing to the remote.', 'Into the history books.', 'Git it done.', 'Launching your commits.',
198 'Another dot on the commit graph.', 'Changes saved for posterity.'],
199 inst: ['Installing a package.', 'Unboxing a new tool.', 'Delivery arriving.', 'Adding a dependency.',
200 'Fetching packages.', 'New tools for the toolbox.', 'Download in progress.', 'Setting things up.',
201 'Package incoming.', 'Stocking up on supplies.'],
202 ask: ['Waiting for your reply.', 'Your input needed.', 'Quick question for you.', 'Over to you for a decision.',
203 'Need a hand here.', 'Tapping a claw, awaiting your answer.', 'Your call.', 'Help me choose.',
204 'A question awaits you.', 'Paused until you answer.'],
205 long: ['Long task. Coffee break for the crab.', 'Still going. Big job.', 'A marathon, not a sprint.', 'This one takes a while.',
206 'Hang tight, still working.', 'Deep in the weeds.', 'Time flies when you\'re coding.', 'Persistence pays off.',
207 'Worth the wait. Probably.', 'Still at it. Sweating a little.', 'Fanning myself. Long one.',
208 'Snack break, still on it.', 'The clock keeps ticking.', 'Running low, still running.', 'Jogging through a long task.'],
209 comp: ['Compacting the conversation.', 'Squeezing the chat to fit.', 'Packing light for the road ahead.', 'Summarizing what we\'ve done.',
210 'Making room for more.', 'Tidying up the context.', 'Folding the memories neatly.', 'Shrinking the transcript.',
211 'Condensing, not forgetting.', 'Spring cleaning the context.'],
212}
213hooks/art.ts 597 lines1// The crab from Claude's new-session screen, drawn on the 30×28 grid of _shared/crab.ts (crabBody), with
2// one prop and one CSS motion per mood. Pure CSS, run by the compositor: no redraws while it plays.
3// Approved in two previews on 11 Oct 2026 (9 base states + 14 extras). Every motion eases at the screen
4// rate (Praj, 12 Oct: "butter smooth"); only the typing cursor blinks hard, like a real one. Classes carry
5// a cb- prefix so they never meet the handoff crab's `.run` rules if a surface inlines both.
6import type { Mood } from '../types'
7
8const O = '#D97757', I = '#1F1E1D', G = '#8E929A', B = '#7A4A26', Y = '#F5C542', CREAM = '#E8DCC8', DIM = '#9A9890'
9const r = (x: number, y: number, w: number, h: number, c: string) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${c}"/>`
10const g = (cls: string, body: string) => `<g class="${cls}">${body}</g>`
11
12const CSS = `<style>
13.cb g{transform-box:fill-box}
14@keyframes cb-bob{50%{transform:translateY(1px)}}@keyframes cb-st{50%{transform:translateY(-1px)}}
15@keyframes cb-bl{50%{opacity:.15}}@keyframes cb-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.1)}}
16.cb-walk .cb-la{animation:cb-st .4s ease-in-out infinite}.cb-walk .cb-lb{animation:cb-st .4s ease-in-out infinite -.2s}
17.cb-idle .cb-bd{animation:cb-bob 2s ease-in-out infinite}
18.cb-idle .cb-ey{transform-origin:50% 50%;animation:cb-blink 4s infinite,cb-sqz 30s infinite}
19.cb-idle .cb-mo{transform-origin:50% 0;transform:scaleY(0);animation:cb-yawn 30s infinite}
20.cb-idle .cb-ar{animation:cb-hi 30s ease-in-out infinite}.cb-idle .cb-all{animation:cb-side 30s infinite}
21@keyframes cb-yawn{0%,8%,16%,100%{transform:scaleY(0)}10%,14%{transform:scaleY(1)}}@keyframes cb-sqz{10%,14%{transform:scaleY(.4)}}
22@keyframes cb-hi{0%,40%,42%,44%,46%,100%{transform:translateY(0)}41%,43%,45%{transform:translateY(-4px)}}
23@keyframes cb-side{0%,70%,82%,100%{transform:translateX(0)}74%{transform:translateX(-2px)}78%{transform:translateX(-4px)}}
24.cb-think .cb-ey{animation:cb-look 2s ease-in-out infinite}.cb-think .cb-all{transform-origin:50% 100%;animation:cb-sway 2s ease-in-out infinite}
25@keyframes cb-look{30%{transform:translateX(-1px)}60%{transform:translateX(1px)}}@keyframes cb-sway{50%{transform:rotate(-4deg)}}
26.cb-read .cb-ey{animation:cb-scan 1.2s ease-in-out infinite}.cb-read .cb-pr{animation:cb-mag 1.2s ease-in-out infinite}
27@keyframes cb-scan{50%{transform:translateX(1px)}}@keyframes cb-mag{50%{transform:translate(-2px,1px)}}
28.cb-build .cb-ey{transform:translateX(-1px)}.cb-build .cb-rl{animation:cb-rl 4s linear infinite}.cb-build .cb-rl g{animation:cb-scan .6s ease-in-out infinite}
29.cb-build .cb-hm{animation:cb-hm 4s linear infinite}.cb-build .cb-hm g{transform-origin:50% 100%;animation:cb-swing 4s ease-in-out infinite}
30.cb-build .cb-b1{animation:cb-b1 4s linear infinite}.cb-build .cb-b2{animation:cb-b2 4s linear infinite}.cb-build .cb-b3{animation:cb-b3 4s linear infinite}
31.cb-build .cb-du{animation:cb-du 4s linear infinite}
32.cb-saw .cb-ey,.cb-type .cb-ey,.cb-paint .cb-ey,.cb-gear .cb-ey{transform:translateX(-1px)}
33.cb-saw .cb-sw{animation:cb-saw .5s ease-in-out infinite}@keyframes cb-saw{50%{transform:translateX(-2px)}}
34.cb-saw .cb-sd{animation:cb-fall 1s ease-in infinite}.cb-saw .cb-sd2{animation:cb-fall 1s ease-in infinite -.5s}
35@keyframes cb-fall{0%{transform:translateY(0);opacity:1}100%{transform:translateY(5px);opacity:0}}
36.cb-type .cb-ar{animation:cb-st .25s ease-in-out infinite}.cb-type .cb-cu{animation:cb-bl .8s steps(1) infinite}
37.cb-type .cb-l1{animation:cb-b1 3s linear infinite}.cb-type .cb-l2{animation:cb-b2 3s linear infinite}.cb-type .cb-l3{animation:cb-b3 3s linear infinite}
38.cb-paint .cb-pf{transform-origin:50% 100%;animation:cb-coat 6s linear infinite}@keyframes cb-coat{0%{transform:scaleY(0)}85%,100%{transform:scaleY(1)}}
39.cb-paint .cb-ro{animation:cb-roll 1.2s ease-in-out infinite}@keyframes cb-roll{50%{transform:translateY(-6px)}}
40.cb-gear .cb-g1{transform-origin:50% 50%;animation:cb-spin 1.2s linear infinite}.cb-gear .cb-g2{transform-origin:50% 50%;animation:cb-spin 1.2s linear infinite reverse}
41.cb-gear .cb-bd{animation:cb-bob .6s ease-in-out infinite}
42@keyframes cb-rl{0%,22%{opacity:1}25%,100%{opacity:0}}@keyframes cb-hm{0%,22%{opacity:0}25%,90%{opacity:1}92%,100%{opacity:0}}
43@keyframes cb-swing{0%{transform:rotate(10deg)}33%{transform:rotate(-118deg)}40%{transform:rotate(10deg)}53%{transform:rotate(-118deg)}60%{transform:rotate(10deg)}73%{transform:rotate(-118deg)}80%,100%{transform:rotate(10deg)}}
44@keyframes cb-b1{0%,38%{opacity:0}40%,100%{opacity:1}}@keyframes cb-b2{0%,58%{opacity:0}60%,100%{opacity:1}}@keyframes cb-b3{0%,78%{opacity:0}80%,100%{opacity:1}}
45@keyframes cb-du{0%,31%,36%,51%,56%,71%,76%,100%{opacity:0}33%,53%,73%{opacity:1}}
46.cb-test .cb-ar,.cb-testp .cb-ar,.cb-testf .cb-ar{animation:cb-arm 1s ease-in-out infinite}@keyframes cb-arm{50%{transform:translateY(-3px)}}
47.cb-testp .cb-pr,.cb-testf .cb-pr{animation:cb-bl .5s ease-in-out 3}
48.cb-debug .cb-all{animation:cb-jit .25s ease-in-out infinite}.cb-debug .cb-pr{animation:cb-bl .5s ease-in-out infinite}
49@keyframes cb-jit{25%{transform:translateX(-1px)}75%{transform:translateX(1px)}}
50.cb-done .cb-all{animation:cb-jump .8s cubic-bezier(.3,.7,.4,1) 2}.cb-done .cb-ar{transform:translateY(-4px)}
51@keyframes cb-jump{0%,100%{transform:translateY(0)}40%{transform:translateY(-5px)}}
52.cb-doze{opacity:.55}.cb-doze .cb-bd,.cb-night .cb-all{animation:cb-bob 4s ease-in-out infinite}
53.cb-z{font:7px sans-serif;fill:#9A9890;animation:cb-zz 3s ease-out infinite}
54@keyframes cb-zz{0%{opacity:0;transform:translate(0,0)}30%{opacity:1}100%{opacity:0;transform:translate(4px,-7px)}}
55.cb-web .cb-dot{animation:cb-bl .6s ease-in-out infinite}.cb-web .cb-wv{animation:cb-bl 1.2s ease-in-out infinite -.3s}
56.cb-sub .cb-mini{animation:cb-trot 2.4s ease-in-out infinite}@keyframes cb-trot{50%{transform:translateX(-4px)}}
57.cb-codex .cb-pr{animation:cb-hand 1.8s ease-in infinite}
58@keyframes cb-hand{0%,20%{transform:translate(0,0);opacity:1}70%{transform:translate(7px,-3px);opacity:0}100%{opacity:0}}
59.cb-git .cb-fl{transform-origin:0 50%;animation:cb-wave .5s ease-in-out infinite}@keyframes cb-wave{50%{transform:skewY(-14deg) scaleX(.85)}}
60.cb-inst .cb-all{animation:cb-bob .4s ease-in-out infinite}
61.cb-ask .cb-pr{animation:cb-bl 1s ease-in-out infinite}.cb-ask .cb-ar{animation:cb-up .5s ease-in-out infinite}@keyframes cb-up{50%{transform:translateY(-4px)}}
62.cb-long .cb-pr{animation:cb-drip 1.3s ease-in infinite}@keyframes cb-drip{0%{transform:translateY(0);opacity:1}100%{transform:translateY(6px);opacity:0}}
63.cb-comp .cb-all{transform-origin:50% 100%;animation:cb-sq 1.4s ease-in-out infinite}@keyframes cb-sq{50%{transform:scale(1.18,.68)}}
64.cb-dizzy .cb-pr{transform-origin:50% 50%;animation:cb-spin 1.2s linear infinite}.cb-dizzy .cb-all{transform-origin:50% 100%;animation:cb-sw 1.2s ease-in-out infinite}
65@keyframes cb-spin{to{transform:rotate(360deg)}}@keyframes cb-sw{25%{transform:rotate(-6deg)}75%{transform:rotate(6deg)}}
66.cb-fixed .cb-pr rect{animation:cb-cf 1.4s linear infinite}.cb-fixed .cb-pr rect:nth-child(2){animation-delay:-.4s}
67.cb-fixed .cb-pr rect:nth-child(3){animation-delay:-.9s}.cb-fixed .cb-pr rect:nth-child(4){animation-delay:-.6s}
68.cb-fixed .cb-all{animation:cb-jump .8s ease-out infinite}
69@keyframes cb-cf{0%{transform:translateY(-6px);opacity:1}100%{transform:translateY(8px);opacity:0}}
70.cb-night .cb-moon{animation:cb-bl 3s ease-in-out infinite}
71/* Three more takes a mood (Praj, 12 Oct), class cb-MOODn; take 0 is the mood class above. */
72.cb-idle1 .cb-bd{animation:cb-bob 2s ease-in-out infinite}.cb-idle1 .cb-n1{animation:cb-zz 2.4s ease-out infinite}.cb-idle1 .cb-n2{animation:cb-zz 2.4s ease-out infinite -1.2s}
73.cb-idle2 .cb-bd{transform-origin:50% 100%;animation:cb-tall 4s ease-in-out infinite}.cb-idle2 .cb-ey{transform-origin:50% 50%;animation:cb-squint 4s ease-in-out infinite}.cb-idle2 .cb-ar{animation:cb-reach 4s ease-in-out infinite}
74@keyframes cb-tall{40%,60%{transform:scaleY(1.12)}}@keyframes cb-squint{40%,60%{transform:scaleY(.3)}}@keyframes cb-reach{40%,60%{transform:translateY(-6px)}}
75.cb-idle3 .cb-bf{animation:cb-fly 4s ease-in-out infinite}.cb-idle3 .cb-wg{transform-origin:50% 50%;animation:cb-flap .3s ease-in-out infinite}.cb-idle3 .cb-ey{animation:cb-look 4s ease-in-out infinite}
76@keyframes cb-fly{0%,100%{transform:translate(0,0)}25%{transform:translate(3px,-3px)}50%{transform:translate(6px,1px)}75%{transform:translate(2px,3px)}}@keyframes cb-flap{50%{transform:scaleX(.3)}}
77.cb-think1 .cb-pr{animation:cb-glow 2s ease-in-out infinite}.cb-think1 .cb-ey{transform:translateY(-1px)}@keyframes cb-glow{0%,100%{opacity:.25}50%{opacity:1}}
78.cb-think2 .cb-ar{animation:cb-tap 1s ease-in-out infinite}.cb-think2 .cb-ey{transform:translate(-1px,-1px)}
79.cb-think2 .cb-d1{animation:cb-bl 1.5s ease-in-out infinite}.cb-think2 .cb-d2{animation:cb-bl 1.5s ease-in-out infinite -.5s}.cb-think2 .cb-d3{animation:cb-bl 1.5s ease-in-out infinite -1s}
80@keyframes cb-tap{0%,100%{transform:translate(0,0)}30%,50%{transform:translate(-3px,-2px)}40%{transform:translate(-3px,-3px)}}
81.cb-think3 .cb-all{animation:cb-pace 4s ease-in-out infinite}@keyframes cb-pace{0%,100%{transform:translateX(0)}50%{transform:translateX(-5px)}}
82.cb-read1 .cb-ey{transform:translate(-1px,1px)}.cb-read1 .cb-pg{transform-origin:0 50%;animation:cb-flip 2s ease-in-out infinite}@keyframes cb-flip{0%,40%{transform:scaleX(1)}60%,100%{transform:scaleX(-1)}}
83.cb-read2 .cb-sc{transform-origin:50% 0;animation:cb-unroll 3s ease-out infinite}.cb-read2 .cb-ey{animation:cb-down 3s ease-in-out infinite}
84@keyframes cb-unroll{0%{transform:scaleY(.1)}70%,100%{transform:scaleY(1)}}@keyframes cb-down{0%{transform:translateY(-1px)}70%,100%{transform:translateY(1px)}}
85.cb-read3 .cb-bd{animation:cb-bob 1.6s ease-in-out infinite}.cb-read3 .cb-ey{animation:cb-scan 1.2s ease-in-out infinite}.cb-read3 .cb-hl{animation:cb-hl 2.4s ease-in-out infinite}
86@keyframes cb-hl{0%{transform:translateY(0)}33%{transform:translateY(2px)}66%,100%{transform:translateY(4px)}}
87.cb-test1 .cb-ey{transform:translateX(-1px)}.cb-test1 .cb-bu1{animation:cb-rise 1.4s ease-in infinite}.cb-test1 .cb-bu2{animation:cb-rise 1.4s ease-in infinite -.7s}
88@keyframes cb-rise{0%{transform:translateY(0);opacity:1}100%{transform:translateY(-9px);opacity:0}}
89.cb-test2 .cb-ar{animation:cb-arm 1s ease-in-out infinite}.cb-test2 .cb-ey{transform:translateX(-1px)}
90.cb-test2 .cb-k1{animation:cb-b1 3s linear infinite}.cb-test2 .cb-k2{animation:cb-b2 3s linear infinite}.cb-test2 .cb-k3{animation:cb-b3 3s linear infinite}
91.cb-test3 .cb-hd{transform-origin:50% 100%;animation:cb-spin 2s linear infinite}.cb-test3 .cb-la{animation:cb-st .5s ease-in-out infinite}
92.cb-testp1 .cb-tk{transform-origin:50% 50%;animation:cb-pop 1.6s ease-out infinite}.cb-testp1 .cb-all{animation:cb-jump .8s cubic-bezier(.3,.7,.4,1) infinite}
93@keyframes cb-pop{0%{transform:scale(0)}40%{transform:scale(1.2)}60%,100%{transform:scale(1)}}
94.cb-testp2 .cb-gl{animation:cb-glow 1s ease-in-out infinite}.cb-testp2 .cb-bd{animation:cb-bob .5s ease-in-out infinite}
95.cb-testp3 .cb-pr{transform-origin:50% 50%;animation:cb-twinkle 1.2s ease-in-out infinite}@keyframes cb-twinkle{50%{transform:scale(1.25) rotate(15deg)}}
96.cb-testf1 .cb-rd{animation:cb-glow 1s ease-in-out infinite}.cb-testf1 .cb-ey{transform:translateY(1px)}
97.cb-testf2 .cb-bd{animation:cb-bob 3s ease-in-out infinite}.cb-testf2 .cb-ey{transform:translateY(1px)}
98.cb-testf2 .cb-r1{animation:cb-fall .8s ease-in infinite}.cb-testf2 .cb-r2{animation:cb-fall .8s ease-in infinite -.3s}.cb-testf2 .cb-r3{animation:cb-fall .8s ease-in infinite -.55s}
99.cb-testf3 .cb-bd{animation:cb-jit .6s ease-in-out infinite}.cb-testf3 .cb-x{animation:cb-bl 1s ease-in-out infinite}
100.cb-debug1 .cb-bug{animation:cb-crawl 2s ease-in-out infinite}.cb-debug1 .cb-ey{animation:cb-crawl2 2s ease-in-out infinite}.cb-debug1 .cb-ar{animation:cb-up 1s ease-in-out infinite}
101@keyframes cb-crawl{0%,100%{transform:translateX(0)}50%{transform:translateX(5px)}}@keyframes cb-crawl2{0%,100%{transform:translateX(-1px)}50%{transform:translateX(0)}}
102.cb-debug2 .cb-pr{transform-origin:50% 100%;animation:cb-turn .6s ease-in-out infinite}.cb-debug2 .cb-ey{transform:translateX(1px)}@keyframes cb-turn{50%{transform:rotate(-40deg)}}
103.cb-debug3 .cb-mg{animation:cb-search 2.4s ease-in-out infinite}.cb-debug3 .cb-ey{animation:cb-look 2.4s ease-in-out infinite}
104@keyframes cb-search{0%,100%{transform:translate(0,0)}33%{transform:translate(3px,-2px)}66%{transform:translate(1px,2px)}}
105.cb-dizzy1 .cb-ey rect{transform-box:fill-box;animation:cb-circ .6s linear infinite}.cb-dizzy1 .cb-ey rect+rect{animation-delay:-.3s}
106.cb-dizzy1 .cb-all{transform-origin:50% 100%;animation:cb-sw 1.6s ease-in-out infinite}
107@keyframes cb-circ{0%,100%{transform:translate(0,0)}25%{transform:translate(1px,-1px)}50%{transform:translate(0,-2px)}75%{transform:translate(-1px,-1px)}}
108.cb-dizzy2 .cb-bi1{animation:cb-orbit 1.6s linear infinite}.cb-dizzy2 .cb-bi2{animation:cb-orbit 1.6s linear infinite -.8s}.cb-dizzy2 .cb-all{transform-origin:50% 100%;animation:cb-sw 2s ease-in-out infinite}
109@keyframes cb-orbit{0%,100%{transform:translate(-7px,0)}25%{transform:translate(0,-3px)}50%{transform:translate(7px,0)}75%{transform:translate(0,2px)}}
110.cb-dizzy3 .cb-all{transform-origin:50% 100%;animation:cb-tip 2.4s ease-in-out infinite}.cb-dizzy3 .cb-pr{transform-origin:50% 50%;animation:cb-spin 1s linear infinite}
111@keyframes cb-tip{0%,100%{transform:rotate(0)}40%,55%{transform:rotate(18deg)}70%{transform:rotate(-5deg)}}
112.cb-fixed1 .cb-all{animation:cb-jump .8s ease-out infinite}.cb-fixed1 .cb-s1{animation:cb-burst 1.2s ease-out infinite}
113.cb-fixed1 .cb-s2{animation:cb-burst 1.2s ease-out infinite -.4s}.cb-fixed1 .cb-s3{animation:cb-burst 1.2s ease-out infinite -.8s}
114@keyframes cb-burst{0%{transform:translateY(4px) scaleY(.2);opacity:0}30%{opacity:1}100%{transform:translateY(-3px) scaleY(1);opacity:0}}
115.cb-fixed2 .cb-fw,.cb-fixed2 .cb-fw2{transform-origin:50% 50%;animation:cb-fw 1.4s ease-out infinite}.cb-fixed2 .cb-fw2{animation-delay:-.7s}.cb-fixed2 .cb-ey{transform:translateY(-1px)}
116@keyframes cb-fw{0%{transform:scale(.1);opacity:1}70%{opacity:1}100%{transform:scale(1.2);opacity:0}}
117.cb-fixed3 .cb-ar,.cb-fixed3 .cb-pr{animation:cb-up 1s ease-in-out infinite}.cb-fixed3 .cb-sp{animation:cb-bl .6s ease-in-out infinite}
118.cb-done1 .cb-fg{transform-origin:0 50%;animation:cb-wave .6s ease-in-out infinite}.cb-done1 .cb-bd{animation:cb-bob .8s ease-in-out infinite}
119.cb-done2 .cb-all{transform-origin:50% 100%;animation:cb-dance .8s ease-in-out infinite}.cb-done2 .cb-ar{animation:cb-up .4s ease-in-out infinite}
120@keyframes cb-dance{25%{transform:translateX(-2px) rotate(-5deg)}75%{transform:translateX(2px) rotate(5deg)}}
121.cb-done3 .cb-all{transform-origin:50% 100%;animation:cb-bow 2s ease-in-out infinite}.cb-done3 .cb-pr{animation:cb-bl .8s ease-in-out infinite}
122@keyframes cb-bow{0%,20%,80%,100%{transform:scale(1,1)}40%,60%{transform:scale(1.06,.85)}}
123.cb-doze1,.cb-doze2,.cb-doze3{opacity:.55}
124.cb-doze1 .cb-bd{animation:cb-bob 4s ease-in-out infinite}.cb-doze1 .cb-bb{transform-origin:0 0;animation:cb-bub 3s ease-in infinite}
125@keyframes cb-bub{0%{transform:scale(.3);opacity:1}80%{transform:scale(1.8);opacity:1}85%,100%{transform:scale(2);opacity:0}}
126.cb-doze2 .cb-bd{transform-origin:50% 100%;animation:cb-breathe 3s ease-in-out infinite}@keyframes cb-breathe{50%{transform:scaleY(1.06)}}
127.cb-doze3 .cb-bd{animation:cb-bob 4s ease-in-out infinite}.cb-doze3 .cb-sh{animation:cb-hop 2.4s linear infinite}
128@keyframes cb-hop{0%{transform:translate(-2px,6px);opacity:0}15%{opacity:1}50%{transform:translate(3px,-1px)}85%{opacity:1}100%{transform:translate(8px,6px);opacity:0}}
129.cb-night1 .cb-all,.cb-night2 .cb-all,.cb-night3 .cb-all{animation:cb-bob 4s ease-in-out infinite}
130.cb-night1 .cb-ln{animation:cb-flick 1.2s ease-in-out infinite}@keyframes cb-flick{0%,100%{opacity:1}30%{opacity:.6}50%{opacity:.9}70%{opacity:.5}}
131.cb-night2 .cb-ey{transform:translate(-1px,-1px)}.cb-night2 .cb-t1{animation:cb-bl 1.6s ease-in-out infinite}
132.cb-night2 .cb-t2{animation:cb-bl 1.6s ease-in-out infinite -.5s}.cb-night2 .cb-t3{animation:cb-bl 1.6s ease-in-out infinite -1.1s}
133.cb-night3 .cb-ss{animation:cb-shoot 3s ease-in infinite}.cb-night3 .cb-ey{animation:cb-look 3s ease-in-out infinite}
134@keyframes cb-shoot{0%{transform:translate(0,0);opacity:0}10%{opacity:1}60%{transform:translate(-26px,8px);opacity:0}100%{opacity:0}}
135.cb-web1 .cb-p1{animation:cb-glow 1.8s ease-in-out infinite}.cb-web1 .cb-p2{animation:cb-glow 1.8s ease-in-out infinite -.6s}
136.cb-web1 .cb-p3{animation:cb-glow 1.8s ease-in-out infinite -1.2s}.cb-web1 .cb-ey{transform:translateX(-1px)}
137.cb-web2 .cb-all{transform-origin:50% 100%;animation:cb-surf 1.6s ease-in-out infinite}.cb-web2 .cb-wa{animation:cb-trot 1.6s ease-in-out infinite}
138@keyframes cb-surf{0%,100%{transform:rotate(-4deg) translateY(0)}50%{transform:rotate(4deg) translateY(-2px)}}
139.cb-web3 .cb-a1{animation:cb-glow 1.2s ease-in-out infinite}.cb-web3 .cb-a2{animation:cb-glow 1.2s ease-in-out infinite -.4s}
140.cb-sub1 .cb-mini{animation:cb-trot 2.4s ease-in-out infinite}
141.cb-sub2 .cb-mini{animation:cb-bob .8s ease-in-out infinite}.cb-sub2 .cb-s1{animation:cb-glow 1s ease-in-out infinite}.cb-sub2 .cb-s2{animation:cb-glow 1s ease-in-out infinite -.5s}
142.cb-sub3 .cb-m1{animation:cb-hop2 .6s ease-in-out infinite}.cb-sub3 .cb-m2{animation:cb-hop2 .6s ease-in-out infinite -.3s}.cb-sub3 .cb-ar{animation:cb-up .6s ease-in-out infinite}
143@keyframes cb-hop2{50%{transform:translateY(-3px)}}
144.cb-codex1 .cb-ey{transform:translateX(1px)}.cb-codex1 .cb-tk{transform-origin:50% 0;animation:cb-talk .3s ease-in-out infinite}
145.cb-codex1 .cb-w1{animation:cb-glow 1s ease-in-out infinite}.cb-codex1 .cb-w2{animation:cb-glow 1s ease-in-out infinite -.5s}@keyframes cb-talk{50%{transform:scaleY(.2)}}
146.cb-codex2 .cb-pc{animation:cb-pass 1.6s ease-in-out infinite}.cb-codex2 .cb-re{animation:cb-bl 2s ease-in-out infinite}@keyframes cb-pass{0%,100%{transform:translateX(0)}50%{transform:translateX(-4px)}}
147.cb-codex3 .cb-q1{animation:cb-alt 2.4s linear infinite}.cb-codex3 .cb-q2{animation:cb-alt 2.4s linear infinite -1.2s}@keyframes cb-alt{0%,45%{opacity:1}50%,100%{opacity:0}}
148.cb-git1 .cb-rk{animation:cb-launch 2.4s ease-in infinite}.cb-git1 .cb-fm{animation:cb-bl .2s ease-in-out infinite}.cb-git1 .cb-ey{transform:translate(-1px,-1px)}
149@keyframes cb-launch{0%,20%{transform:translateY(0);opacity:1}80%{transform:translateY(-16px);opacity:1}100%{transform:translateY(-16px);opacity:0}}
150.cb-git2 .cb-stp{animation:cb-stamp 1.6s ease-in-out infinite}.cb-git2 .cb-mk{animation:cb-ink 1.6s linear infinite}
151@keyframes cb-stamp{0%,30%,100%{transform:translateY(0)}45%,55%{transform:translateY(5px)}}@keyframes cb-ink{0%,44%{opacity:0}46%,100%{opacity:1}}
152.cb-git3 .cb-k1{animation:cb-b1 3s linear infinite}.cb-git3 .cb-k2{animation:cb-b2 3s linear infinite}.cb-git3 .cb-k3{animation:cb-b3 3s linear infinite}
153.cb-inst1 .cb-pa{animation:cb-drop 3s ease-out infinite}.cb-inst1 .cb-ey{transform:translate(-1px,-1px)}
154@keyframes cb-drop{0%{transform:translateY(-6px);opacity:0}15%{opacity:1}70%,90%{transform:translateY(8px);opacity:1}100%{transform:translateY(8px);opacity:0}}
155.cb-inst2 .cb-fl2{transform-origin:0 50%;animation:cb-fill 3s ease-in-out infinite}.cb-inst2 .cb-ar{animation:cb-arm 1s ease-in-out infinite}
156@keyframes cb-fill{0%{transform:scaleX(0)}85%,100%{transform:scaleX(1)}}
157.cb-inst3 .cb-lid{transform-origin:0 100%;animation:cb-lid 2s ease-in-out infinite}.cb-inst3 .cb-sp2{animation:cb-peek 2s linear infinite}
158@keyframes cb-lid{0%,40%,100%{transform:translateY(0) rotate(0)}55%,75%{transform:translateY(-4px) rotate(-20deg)}}@keyframes cb-peek{0%,45%,90%,100%{opacity:0}55%,80%{opacity:1}}
159.cb-ask1 .cb-qb{transform-origin:100% 100%;animation:cb-pulse 1.2s ease-in-out infinite}@keyframes cb-pulse{50%{transform:scale(1.1)}}
160.cb-ask2 .cb-la{animation:cb-st .3s ease-in-out infinite}.cb-ask2 .cb-all{transform-origin:50% 100%;animation:cb-tilt 2s ease-in-out infinite}
161.cb-ask2 .cb-e1{animation:cb-bl 1.2s ease-in-out infinite}.cb-ask2 .cb-e2{animation:cb-bl 1.2s ease-in-out infinite -.4s}.cb-ask2 .cb-e3{animation:cb-bl 1.2s ease-in-out infinite -.8s}
162@keyframes cb-tilt{0%,100%{transform:rotate(0)}30%,70%{transform:rotate(-7deg)}}
163.cb-ask3 .cb-sg{animation:cb-up .9s ease-in-out infinite}
164.cb-long1 .cb-ar,.cb-long1 .cb-pr{animation:cb-sip 3s ease-in-out infinite}.cb-long1 .cb-stm{animation:cb-steam 1.8s ease-out infinite}
165@keyframes cb-sip{0%,30%,80%,100%{transform:translate(0,0)}45%,65%{transform:translate(-5px,2px)}}@keyframes cb-steam{0%{transform:translateY(0);opacity:0}30%{opacity:.8}100%{transform:translateY(-4px);opacity:0}}
166.cb-long2 .cb-ht{transform-origin:50% 100%;animation:cb-drain 4s linear infinite}.cb-long2 .cb-hb{transform-origin:50% 100%;animation:cb-coat 4s linear infinite}
167.cb-long2 .cb-hs{animation:cb-bl .4s ease-in-out infinite}.cb-long2 .cb-la{animation:cb-st .5s ease-in-out infinite}@keyframes cb-drain{0%{transform:scaleY(1)}90%,100%{transform:scaleY(0)}}
168.cb-long3 .cb-ar{animation:cb-wipe 2s ease-in-out infinite}.cb-long3 .cb-fly{animation:cb-fling 2s ease-out infinite}
169@keyframes cb-wipe{0%,20%,80%,100%{transform:translate(0,0)}40%,60%{transform:translate(-4px,-6px)}}@keyframes cb-fling{0%,55%{transform:translate(0,0);opacity:0}60%{opacity:1}100%{transform:translate(6px,6px);opacity:0}}
170.cb-comp1 .cb-sl{transform-origin:0 100%;animation:cb-close 2.4s ease-in-out infinite}.cb-comp1 .cb-ar{animation:cb-arm 2.4s ease-in-out infinite}
171@keyframes cb-close{0%,10%{transform:rotate(-70deg)}50%,90%{transform:rotate(0)}100%{transform:rotate(-70deg)}}
172.cb-comp2 .cb-stk{transform-origin:50% 100%;animation:cb-shrink 3s ease-in-out infinite}.cb-comp2 .cb-ey{transform:translateX(-1px)}
173@keyframes cb-shrink{0%,10%{transform:scaleY(1)}70%,90%{transform:scaleY(.3)}100%{transform:scaleY(1)}}
174.cb-comp3 .cb-pl{animation:cb-press 2s ease-in-out infinite}.cb-comp3 .cb-bx{transform-origin:50% 100%;animation:cb-squash 2s ease-in-out infinite}
175@keyframes cb-press{50%{transform:translateY(12px)}}@keyframes cb-squash{50%{transform:scaleY(.6)}}
176/* Five more takes for the looks a long task shows most, and a second take a build scene (Praj, 16 Oct). */
177.cb-think4 .cb-ey{transform:translate(-1px,1px)}.cb-think4 .cb-pn{animation:cb-mv 3s ease-in-out infinite}.cb-think4 .cb-ar{animation:cb-tap 3s ease-in-out infinite}
178@keyframes cb-mv{0%,40%{transform:translate(0,0)}55%{transform:translate(2px,-3px)}70%,100%{transform:translate(4px,0)}}
179.cb-think5 .cb-pr{transform-origin:50% 50%;animation:cb-spin 3s linear infinite}.cb-think5 .cb-ey{animation:cb-look 3s ease-in-out infinite}
180.cb-think6 .cb-c1{animation:cb-b1 4s linear infinite}.cb-think6 .cb-c2{animation:cb-b2 4s linear infinite}.cb-think6 .cb-c3{animation:cb-b3 4s linear infinite}
181.cb-think6 .cb-ey{transform:translate(-1px,-1px)}.cb-think6 .cb-bd{animation:cb-bob 2s ease-in-out infinite}
182.cb-think7 .cb-pz{animation:cb-fit 3s ease-in-out infinite}.cb-think7 .cb-ey{transform:translateX(-1px)}
183@keyframes cb-fit{0%,15%,100%{transform:translate(-2px,-5px) rotate(20deg)}55%,85%{transform:translate(0,0) rotate(0)}}
184.cb-think8 .cb-x1{animation:cb-alt 3s linear infinite}.cb-think8 .cb-x2{animation:cb-alt 3s linear infinite -1.5s}.cb-think8 .cb-ar{animation:cb-up 1.5s ease-in-out infinite}
185.cb-read4 .cb-np{animation:cb-shake 2.4s ease-in-out infinite}.cb-read4 .cb-ey{animation:cb-scan 1.6s ease-in-out infinite}
186@keyframes cb-shake{0%,70%,80%,100%{transform:translateY(0)}75%{transform:translateY(-1px)}}
187.cb-read5 .cb-pr{transform-origin:0 100%;animation:cb-sweep 4s ease-in-out infinite}.cb-read5 .cb-ey{transform:translate(1px,-1px)}
188@keyframes cb-sweep{0%,100%{transform:rotate(0)}50%{transform:rotate(-12deg)}}
189.cb-read6 .cb-dr{animation:cb-open 3s ease-in-out infinite}.cb-read6 .cb-cd{animation:cb-peek 3s linear infinite}.cb-read6 .cb-ey{transform:translate(-1px,1px)}
190@keyframes cb-open{0%,15%,100%{transform:translateX(0)}35%,80%{transform:translateX(-2px)}}
191.cb-read7 .cb-bm{animation:cb-beam 1.6s ease-in-out infinite alternate}.cb-read7 .cb-ey{animation:cb-down 1.6s ease-in-out infinite alternate}
192@keyframes cb-beam{to{transform:translateY(10px)}}
193.cb-read8 .cb-pin{animation:cb-pin 2.4s ease-in infinite}.cb-read8 .cb-ey{transform:translate(-1px,1px)}
194@keyframes cb-pin{0%{transform:translateY(-6px);opacity:0}30%{opacity:1}50%,90%{transform:translateY(0);opacity:1}100%{transform:translateY(0);opacity:0}}
195.cb-test4 .cb-th{transform-origin:50% 100%;animation:cb-coat 3s ease-in-out infinite}.cb-test4 .cb-ey{transform:translateX(-1px)}
196.cb-test5 .cb-bal{transform-origin:50% 0;animation:cb-sw 2s ease-in-out infinite}.cb-test5 .cb-ey{animation:cb-look 2s ease-in-out infinite}
197.cb-test6 .cb-pk{animation:cb-poke 1.2s ease-in-out infinite}.cb-test6 .cb-jl{transform-origin:50% 100%;animation:cb-wob 1.2s ease-in-out infinite}
198@keyframes cb-poke{0%,100%{transform:translateX(0)}40%{transform:translateX(-2px)}}
199@keyframes cb-wob{0%,35%,100%{transform:scale(1,1)}45%{transform:scale(.88,1.1)}60%{transform:scale(1.08,.92)}75%{transform:scale(.97,1.03)}}
200.cb-test7 .cb-lt{animation:cb-glow 1.4s ease-in-out infinite}.cb-test7 .cb-all{animation:cb-lean 3s ease-in-out infinite}.cb-test7 .cb-ey{transform:translate(-1px,-1px)}
201@keyframes cb-lean{0%,100%{transform:translateX(0)}40%,70%{transform:translateX(-2px)}}
202.cb-test8 .cb-pb{transform-origin:0 50%;animation:cb-fill 4s ease-in-out infinite}.cb-test8 .cb-ar{animation:cb-arm 1s ease-in-out infinite}.cb-test8 .cb-ey{transform:translate(-1px,1px)}
203.cb-long4 .cb-pr{transform-origin:50% 100%;animation:cb-fan .4s ease-in-out infinite}.cb-long4 .cb-dp{animation:cb-drip 1.3s ease-in infinite}
204@keyframes cb-fan{50%{transform:rotate(-25deg)}}
205.cb-long5 .cb-sn{transform-origin:0 50%;animation:cb-bite 3s ease-in-out infinite}.cb-long5 .cb-cr{animation:cb-fall 1s ease-in infinite}.cb-long5 .cb-ey{transform:translateY(-1px)}
206@keyframes cb-bite{0%{transform:scaleX(1)}90%{transform:scaleX(.35)}100%{transform:scaleX(1)}}
207.cb-long6 .cb-mh{transform-origin:50% 100%;animation:cb-spin 1s linear infinite}.cb-long6 .cb-hh{transform-origin:0 50%;animation:cb-spin 6s linear infinite}.cb-long6 .cb-ey{transform:translate(-1px,-1px)}
208.cb-long7 .cb-bt{transform-origin:50% 100%;animation:cb-drain 5s ease-in infinite}.cb-long7 .cb-bd{animation:cb-bob 2.5s ease-in-out infinite}.cb-long7 .cb-ey{transform:translateY(1px)}
209.cb-long8 .cb-tm{animation:cb-belt .5s linear infinite}.cb-long8 .cb-bd{animation:cb-bob .5s ease-in-out infinite}.cb-long8 .cb-pr{animation:cb-drip 1s ease-in infinite}
210.cb-long8 .cb-la{animation:cb-st .25s ease-in-out infinite}.cb-long8 .cb-lb{animation:cb-st .25s ease-in-out infinite -.12s}@keyframes cb-belt{to{transform:translateX(-8px)}}
211.cb-build1 .cb-wg2{animation:cb-glow .4s ease-in-out infinite}.cb-build1 .cb-sk1{animation:cb-rise .6s ease-out infinite}
212.cb-build1 .cb-sk2{animation:cb-rise .6s ease-out infinite -.2s}.cb-build1 .cb-sk3{animation:cb-rise .6s ease-out infinite -.4s}
213.cb-saw1 .cb-dl{animation:cb-jit .15s ease-in-out infinite}.cb-saw1 .cb-ds{animation:cb-fall .8s ease-in infinite}.cb-saw1 .cb-ey{transform:translateX(-1px)}
214.cb-type1 .cb-pc3{animation:cb-write 3s ease-in-out infinite}.cb-type1 .cb-ey{transform:translate(-1px,1px)}
215.cb-type1 .cb-p1{animation:cb-b1 3s linear infinite}.cb-type1 .cb-p2{animation:cb-b2 3s linear infinite}.cb-type1 .cb-p3{animation:cb-b3 3s linear infinite}
216@keyframes cb-write{0%,100%{transform:translate(0,0)}33%{transform:translate(-1px,3px)}66%{transform:translate(0,5px)}}
217.cb-paint1 .cb-brm{transform-origin:50% 0;animation:cb-brush 1s ease-in-out infinite}.cb-paint1 .cb-pf1{animation:cb-puff 1s ease-out infinite}.cb-paint1 .cb-pf2{animation:cb-puff 1s ease-out infinite -.5s}
218@keyframes cb-brush{50%{transform:rotate(14deg)}}@keyframes cb-puff{0%{transform:translate(0,0);opacity:.9}100%{transform:translate(-3px,-3px);opacity:0}}
219.cb-gear1 .cb-cab{transform-origin:50% 0;animation:cb-cable 3s ease-in-out infinite}.cb-gear1 .cb-blk{animation:cb-hoist 3s ease-in-out infinite}
220.cb-gear1 .cb-ey{transform:translate(-1px,-1px)}.cb-gear1 .cb-ar{animation:cb-arm 3s ease-in-out infinite}
221@keyframes cb-cable{0%,100%{transform:scaleY(1)}50%{transform:scaleY(3)}}@keyframes cb-hoist{0%,100%{transform:translateY(0)}50%{transform:translateY(8px)}}
222@media (prefers-reduced-motion:reduce){.cb *{animation:none!important}}
223</style>`
224
225const MINI_BODY = r(7, 10, 16, 12, O) + r(3, 14, 4, 4, O) + r(23, 14, 4, 4, O) + r(9, 12, 2, 2, I) + r(19, 12, 2, 2, I) + r(7, 22, 2, 4, O) + r(11, 22, 2, 4, O) + r(17, 22, 2, 4, O) + r(21, 22, 2, 4, O)
226const MINI = g('cb-mini', `<g style="transform-box:view-box" transform="translate(-7,17) scale(.4)">${MINI_BODY}</g>`)
227const TICK = r(12, 3, 2, 2, '#1D9E75') + r(14, 5, 2, 2, '#1D9E75') + r(16, 3, 2, 2, '#1D9E75') + r(18, 1, 2, 2, '#1D9E75')
228
229// Build: measure the wall with a ruler, then three hammer strikes, one new brick after each (Praj, 11 Oct).
230// The wall stands left of the crab; the back claw holds the tools; the hammer pivots on its handle's foot.
231const BR = '#B5532F', BR2 = '#93401F'
232const BUILD = r(-5, 24, 4, 2, BR) + r(-1, 24, 4, 2, BR2)
233 + g('cb-b1', r(-5, 22, 4, 2, BR2)) + g('cb-b2', r(-1, 22, 4, 2, BR)) + g('cb-b3', r(-3, 20, 4, 2, BR2))
234 + g('cb-rl', g('', r(-5, 16, 9, 2, Y) + r(-4, 16, 1, 1, B) + r(-2, 16, 1, 1, B) + r(0, 16, 1, 1, B) + r(2, 16, 1, 1, B)))
235 + g('cb-hm', g('', r(4, 9, 1, 7, B) + r(2, 7, 5, 2, G) + r(2, 7, 1, 2, '#5F636B')))
236 + g('cb-du', r(-6, 18, 1, 1, '#E8DCC8') + r(0, 17, 1, 1, '#E8DCC8'))
237
238// Four more build scenes, so a long build is not one hammer loop (Praj, 12 Oct). Each stands left of the
239// crab, in the wall's spot, with the front claw on the tool.
240const SAW = r(-6, 19, 9, 2, '#C8A165') + r(-5, 21, 1, 5, B) + r(1, 21, 1, 5, B) + r(-2, 19, 1, 1, B)
241 + g('cb-sw', r(-4, 17, 7, 2, '#B4B2A9') + r(-4, 18, 7, 1, G) + r(3, 16, 2, 3, B))
242 + g('cb-sd', r(-2, 21, 1, 1, '#E8DCC8')) + g('cb-sd2', r(-1, 21, 1, 1, '#E8DCC8'))
243const LAPTOP = r(-6, 11, 8, 8, '#2C2C2A') + r(-6, 19, 10, 1, '#B4B2A9') + r(-6, 20, 10, 1, B) + r(-5, 21, 1, 5, B) + r(2, 21, 1, 5, B)
244 + g('cb-l1', r(-5, 12, 5, 1, '#1D9E75')) + g('cb-l2', r(-4, 14, 5, 1, '#378ADD')) + g('cb-l3', r(-4, 16, 3, 1, '#EF9F27'))
245 + g('cb-cu', r(0, 16, 1, 1, '#F4F3EE'))
246const PAINT = r(-6, 6, 6, 20, '#4A4A48') + g('cb-pf', r(-6, 6, 6, 20, '#378ADD'))
247 + g('cb-ro', r(-1, 12, 2, 5, '#F4F3EE') + r(1, 14, 2, 1, G) + r(2, 14, 1, 5, B))
248const GEAR = g('cb-g1', r(-4, 15, 5, 5, G) + r(-3, 13, 3, 2, '#B4B2A9') + r(-3, 20, 3, 2, G) + r(-6, 16, 2, 3, G) + r(1, 16, 2, 3, G) + r(-2, 17, 1, 1, I))
249 + g('cb-g2', r(-4, 8, 3, 3, Y) + r(-3, 6, 1, 2, B) + r(-3, 11, 1, 2, Y) + r(-6, 9, 2, 1, Y) + r(-1, 9, 2, 1, Y))
250
251// [prop drawn on the body, extra drawn beside the crab, claw height (14 = resting)]
252const LOOK: Partial<Record<Mood, [string, string?, number?]>> = {
253 read: [r(25, 6, 4, 1, G) + r(24, 7, 1, 3, G) + r(29, 7, 1, 3, G) + r(25, 10, 4, 1, G) + r(25, 7, 4, 3, 'rgba(169,214,245,.6)') + r(24, 11, 2, 3, B), '', 10],
254 build: ['', BUILD],
255 saw: ['', SAW],
256 type: ['', LAPTOP],
257 paint: ['', PAINT],
258 gear: ['', GEAR],
259 testp: [TICK],
260 fixed: [r(6, 2, 2, 2, '#1D9E75') + r(12, 0, 2, 2, Y) + r(19, 2, 2, 2, '#378ADD') + r(24, 1, 2, 2, '#D4537E')],
261 testf: [r(12, 2, 2, 2, '#E24B4A') + r(14, 4, 2, 2, '#E24B4A') + r(16, 2, 2, 2, '#E24B4A') + r(12, 6, 2, 2, '#E24B4A') + r(16, 6, 2, 2, '#E24B4A')],
262 debug: [r(14, 1, 2, 5, '#EF9F27') + r(14, 7, 2, 2, '#EF9F27')],
263 web: [r(14, 4, 1, 6, G) + g('cb-dot', r(13, 2, 3, 2, '#E24B4A')) + g('cb-wv', r(10, 1, 1, 2, G) + r(18, 1, 1, 2, G))],
264 sub: ['', MINI],
265 codex: [r(24, 7, 5, 4, B) + r(26, 7, 1, 4, Y), '', 10],
266 git: [r(26, 2, 1, 12, G) + g('cb-fl', r(27, 2, 4, 3, '#1D9E75'))],
267 inst: [r(9, 4, 12, 6, B) + r(14, 4, 2, 6, Y)],
268 ask: [r(13, 1, 4, 1, '#378ADD') + r(17, 2, 1, 2, '#378ADD') + r(15, 4, 2, 1, '#378ADD') + r(15, 5, 1, 2, '#378ADD') + r(15, 8, 1, 1, '#378ADD'), '', 10],
269 long: [r(5, 8, 1, 1, '#85B7EB') + r(4, 9, 3, 2, '#85B7EB')],
270 dizzy: [r(9, 4, 2, 2, Y) + r(19, 4, 2, 2, Y) + r(14, 1, 2, 2, Y) + r(14, 7, 2, 2, Y)],
271 night: [r(6, 6, 18, 3, '#3C3489') + r(9, 4, 12, 2, '#3C3489') + r(13, 2, 6, 2, '#3C3489') + r(19, 1, 3, 3, '#F4F3EE'), g('cb-moon', r(-3, 2, 3, 3, '#FAC775') + r(-4, 3, 1, 2, '#FAC775'))],
272}
273// The three extra takes of each mood (Praj, 12 Oct): a prop on the body, a set beside it, a claw height.
274const GRN = '#1D9E75', RED = '#E24B4A', BLU = '#378ADD', WH = '#F4F3EE', LG = '#B4B2A9', DK = '#2C2C2A', ORG = '#EF9F27'
275const PINK = '#D4537E', SKY = '#85B7EB', WOOD = '#C8A165', GLASS = 'rgba(200,220,235,.35)'
276const mini = (cls: string, x: number, y: number, s: number) => g(cls, `<g style="transform-box:view-box" transform="translate(${x},${y}) scale(${s})">${r(7, 10, 16, 12, O) + r(3, 14, 4, 4, O) + r(23, 14, 4, 4, O) + r(9, 12, 2, 2, I) + r(19, 12, 2, 2, I) + r(7, 22, 2, 4, O) + r(11, 22, 2, 4, O) + r(17, 22, 2, 4, O) + r(21, 22, 2, 4, O)}</g>`)
277const note = (x: number, y: number) => r(x + 1, y, 1, 3, WH) + r(x, y + 3, 2, 2, WH) + r(x + 2, y, 1, 1, WH)
278const LIGHT = (lit: 0 | 2, c: string, cls: string) => r(-5, 6, 6, 16, DK) + r(-3, 22, 2, 4, G)
279 + [0, 1, 2].map(k => k === lit ? g(cls, r(-4, 7 + 5 * k, 4, 4, c)) : r(-4, 7 + 5 * k, 4, 4, '#3a3a38')).join('')
280const BUG = r(-4, 23, 3, 2, '#639922') + r(-5, 22, 1, 1, LG) + r(-1, 22, 1, 1, LG) + r(-5, 25, 1, 1, LG) + r(-1, 25, 1, 1, LG)
281const ROBOT = r(-6, 10, 7, 6, G) + g('cb-re', r(-5, 12, 2, 2, SKY) + r(-2, 12, 2, 2, SKY)) + r(-3, 8, 1, 2, G) + r(-5, 16, 5, 6, LG) + r(-5, 22, 1, 4, G) + r(-2, 22, 1, 4, G)
282const bird = (cls: string) => g(cls, r(14, 3, 1, 1, WH) + r(15, 4, 1, 1, WH) + r(16, 3, 1, 1, WH))
283
284const TAKE: Record<string, [string, string?, number?]> = {
285 idle1: [r(14, 17, 2, 2, I) + g('cb-n1', note(24, 5)) + g('cb-n2', note(27, 3))],
286 idle2: [''],
287 idle3: ['', g('cb-bf', g('cb-wg', r(-5, 6, 2, 2, Y) + r(-2, 6, 2, 2, Y)) + r(-3, 6, 1, 3, B))],
288 think1: [r(13, 1, 4, 4, Y) + r(14, 5, 2, 2, G)],
289 think2: [g('cb-d1', r(10, 5, 2, 2, DIM)) + g('cb-d2', r(14, 4, 2, 2, DIM)) + g('cb-d3', r(18, 3, 2, 2, DIM))],
290 think3: [''],
291 read1: ['', r(-5, 14, 4, 6, WH) + r(-1, 14, 4, 6, CREAM) + r(-4, 16, 2, 1, G) + r(-4, 18, 2, 1, G) + r(0, 16, 2, 1, G) + g('cb-pg', r(-1, 14, 4, 6, WH))],
292 read2: ['', r(-5, 8, 8, 2, B) + g('cb-sc', r(-4, 10, 6, 12, CREAM) + r(-3, 12, 4, 1, G) + r(-3, 15, 3, 1, G) + r(-3, 18, 4, 1, G))],
293 read3: [r(8, 11, 4, 1, G) + r(18, 11, 4, 1, G) + r(12, 11, 6, 1, G) + r(8, 12, 4, 3, 'rgba(169,214,245,.35)') + r(18, 12, 4, 3, 'rgba(169,214,245,.35)'),
294 r(-5, 12, 7, 9, WH) + r(-4, 14, 5, 1, G) + r(-4, 16, 4, 1, G) + r(-4, 18, 5, 1, G) + g('cb-hl', r(-4, 14, 5, 1, 'rgba(245,197,66,.6)'))],
295 test1: ['', r(-3, 10, 3, 4, GLASS) + r(-5, 14, 7, 8, GLASS) + r(-5, 17, 7, 5, GRN) + g('cb-bu1', r(-3, 17, 1, 1, WH)) + g('cb-bu2', r(-1, 18, 1, 1, WH))],
296 test2: ['', r(-5, 9, 8, 13, B) + r(-4, 11, 6, 10, WH) + r(-2, 8, 2, 2, G) + r(-1, 12, 3, 1, G) + r(-1, 15, 3, 1, G) + r(-1, 18, 3, 1, G)
297 + g('cb-k1', r(-3, 12, 1, 1, GRN)) + g('cb-k2', r(-3, 15, 1, 1, GRN)) + g('cb-k3', r(-3, 18, 1, 1, GRN))],
298 test3: ['', r(-4, 11, 3, 2, G) + r(-5, 13, 7, 7, WH) + g('cb-hd', r(-2, 14, 1, 3, I)) + r(-2, 16, 1, 1, RED)],
299 testp1: ['', g('cb-tk', r(-6, 15, 2, 2, GRN) + r(-4, 17, 2, 2, GRN) + r(-2, 15, 2, 2, GRN) + r(0, 13, 2, 2, GRN) + r(2, 11, 2, 2, GRN))],
300 testp2: ['', LIGHT(2, GRN, 'cb-gl')],
301 testp3: [r(14, 0, 2, 2, Y) + r(12, 2, 6, 2, Y) + r(13, 4, 4, 2, Y) + r(12, 6, 2, 1, Y) + r(16, 6, 2, 1, Y), '', 10],
302 testf1: ['', LIGHT(0, RED, 'cb-rd')],
303 testf2: [r(9, 1, 12, 3, G) + r(11, 0, 8, 1, G) + g('cb-r1', r(11, 5, 1, 2, BLU)) + g('cb-r2', r(15, 5, 1, 2, BLU)) + g('cb-r3', r(19, 5, 1, 2, BLU))],
304 testf3: ['', g('cb-x', r(-5, 12, 2, 2, RED) + r(-1, 12, 2, 2, RED) + r(-3, 14, 2, 2, RED) + r(-5, 16, 2, 2, RED) + r(-1, 16, 2, 2, RED))],
305 debug1: ['', g('cb-bug', BUG)],
306 debug2: [r(26, 6, 2, 6, G) + r(25, 4, 4, 2, G) + r(25, 3, 1, 1, G) + r(28, 3, 1, 1, G), '', 12],
307 debug3: ['', BUG + g('cb-mg', r(-5, 15, 5, 1, G) + r(-6, 16, 1, 4, G) + r(0, 16, 1, 4, G) + r(-5, 20, 5, 1, G) + r(-5, 16, 5, 4, 'rgba(169,214,245,.45)') + r(1, 20, 1, 1, B) + r(2, 21, 1, 1, B))],
308 dizzy1: [''],
309 dizzy2: [bird('cb-bi1') + bird('cb-bi2')],
310 dizzy3: [r(12, 3, 6, 1, LG) + r(17, 4, 1, 2, LG) + r(13, 6, 5, 1, LG) + r(13, 5, 1, 1, LG)],
311 fixed1: ['', r(-4, 21, 3, 3, Y) + r(-5, 24, 2, 2, Y) + g('cb-s1', r(-3, 12, 1, 4, PINK)) + g('cb-s2', r(-1, 11, 1, 4, BLU)) + g('cb-s3', r(1, 13, 1, 3, GRN))],
312 fixed2: ['', g('cb-fw', r(-2, 6, 2, 2, Y) + r(-2, 2, 2, 2, ORG) + r(-2, 10, 2, 2, ORG) + r(-6, 6, 2, 2, ORG) + r(2, 6, 2, 2, ORG) + r(-5, 3, 1, 1, PINK) + r(1, 3, 1, 1, PINK) + r(-5, 10, 1, 1, PINK) + r(1, 10, 1, 1, PINK))
313 + g('cb-fw2', r(27, 2, 2, 2, Y) + r(27, -1, 2, 2, BLU) + r(27, 5, 2, 2, BLU) + r(24, 2, 2, 2, BLU) + r(30, 2, 2, 2, BLU))],
314 fixed3: [r(23, 3, 6, 4, Y) + r(24, 7, 4, 1, Y) + r(25, 8, 2, 2, Y) + r(24, 10, 4, 1, Y) + r(22, 4, 1, 2, Y) + r(29, 4, 1, 2, Y) + g('cb-sp', r(21, 1, 1, 1, WH) + r(30, 1, 1, 1, WH)), '', 10],
315 done1: ['', r(-3, 8, 1, 18, G) + g('cb-fg', r(-2, 8, 2, 2, WH) + r(0, 8, 2, 2, G) + r(2, 8, 2, 2, WH) + r(-2, 10, 2, 2, G) + r(0, 10, 2, 2, WH) + r(2, 10, 2, 2, G)), 10],
316 done2: ['', '', 10],
317 done3: [r(4, 6, 1, 1, Y) + r(26, 4, 1, 1, Y) + r(15, 2, 1, 1, Y)],
318 doze1: [r(9, 6, 12, 4, BLU) + r(18, 4, 4, 2, BLU) + r(22, 5, 2, 2, WH) + g('cb-bb', r(14, 16, 3, 3, 'rgba(169,214,245,.6)'))],
319 doze2: [r(5, 16, 20, 6, '#534AB7') + r(5, 16, 20, 1, '#AFA9EC')],
320 doze3: ['', r(-6, 20, 8, 1, WOOD) + r(-6, 23, 8, 1, WOOD) + r(-5, 19, 1, 7, WOOD) + r(0, 19, 1, 7, WOOD)
321 + g('cb-sh', r(-6, 12, 4, 3, WH) + r(-3, 12, 2, 2, G) + r(-5, 15, 1, 1, G) + r(-3, 15, 1, 1, G))],
322 night1: ['', r(-3, 6, 1, 2, G) + r(-5, 8, 5, 1, G) + g('cb-ln', r(-6, 7, 7, 9, 'rgba(250,199,117,.15)') + r(-4, 9, 3, 5, '#FAC775')) + r(-5, 14, 5, 1, G)],
323 night2: ['', r(1, 13, 2, 2, G) + r(-1, 11, 3, 2, G) + r(-3, 9, 3, 2, LG) + r(-5, 7, 3, 2, LG) + r(-1, 15, 1, 11, B) + r(-3, 22, 1, 4, B) + r(1, 22, 1, 4, B)
324 + g('cb-t1', r(-4, 1, 1, 1, WH)) + g('cb-t2', r(1, -1, 1, 1, WH)) + g('cb-t3', r(4, 3, 1, 1, WH))],
325 night3: ['', r(4, 2, 1, 1, G) + r(-4, 6, 1, 1, G) + g('cb-ss', r(24, 0, 2, 2, '#FAC775') + r(26, 0, 3, 1, 'rgba(250,199,117,.5)'))],
326 web1: ['', r(-4, 10, 6, 8, BLU) + r(-5, 11, 8, 6, BLU) + g('cb-p1', r(-4, 11, 3, 2, GRN)) + g('cb-p2', r(-1, 13, 3, 3, GRN)) + g('cb-p3', r(-4, 15, 2, 2, GRN)) + r(-2, 18, 2, 2, G) + r(-4, 20, 6, 1, G)],
327 web2: [r(4, 26, 22, 1, WOOD), r(-6, 27, 38, 1, BLU) + g('cb-wa', r(-6, 22, 6, 5, BLU) + r(-5, 20, 3, 2, BLU) + r(-4, 19, 2, 1, WH))],
328 web3: [r(14, 5, 2, 5, G) + r(14, 4, 2, 1, RED) + g('cb-a1', r(11, 3, 1, 1, SKY) + r(12, 2, 6, 1, SKY) + r(18, 3, 1, 1, SKY)) + g('cb-a2', r(9, 1, 1, 1, SKY) + r(10, 0, 10, 1, SKY) + r(20, 1, 1, 1, SKY))],
329 sub1: ['', g('cb-mini', mini('', -7, 17, .4) + r(-5, 18, 5, 3, B) + r(-3, 18, 1, 3, Y))],
330 sub2: [r(24, 6, 3, 4, G) + r(25, 4, 1, 2, G), mini('cb-mini', -7, 17, .4) + g('cb-s1', r(1, 19, 1, 2, SKY)) + g('cb-s2', r(2, 18, 1, 4, SKY)), 10],
331 sub3: ['', mini('cb-m1', -7, 19, .28) + mini('cb-m2', -2, 19, .28)],
332 codex1: [r(24, 4, 3, 7, DK) + r(25, 5, 1, 1, SKY) + g('cb-tk', r(14, 17, 3, 2, I)) + g('cb-w1', r(28, 4, 1, 3, SKY)) + g('cb-w2', r(30, 3, 1, 5, SKY)), '', 10],
333 codex2: ['', ROBOT + g('cb-pc', r(1, 17, 3, 3, B) + r(2, 17, 1, 3, Y))],
334 codex3: [g('cb-q2', r(18, 1, 8, 4, WH) + r(19, 5, 1, 1, WH) + r(20, 3, 1, 1, G) + r(22, 3, 1, 1, G) + r(24, 3, 1, 1, G)),
335 ROBOT + g('cb-q1', r(-6, 2, 7, 4, WH) + r(-3, 6, 1, 1, WH) + r(-5, 4, 1, 1, G) + r(-3, 4, 1, 1, G) + r(-1, 4, 1, 1, G))],
336 git1: ['', r(-5, 24, 7, 2, G) + g('cb-rk', r(-3, 14, 3, 6, WH) + r(-2, 12, 1, 2, RED) + r(-2, 15, 1, 1, BLU) + r(-4, 18, 1, 2, RED) + r(0, 18, 1, 2, RED) + g('cb-fm', r(-3, 20, 3, 2, ORG) + r(-2, 22, 1, 1, Y)))],
337 git2: ['', r(-6, 24, 9, 2, WH) + g('cb-mk', r(-4, 24, 4, 1, GRN)) + g('cb-stp', r(-3, 13, 2, 4, B) + r(-4, 12, 4, 1, B) + r(-5, 17, 6, 2, RED))],
338 git3: ['', r(-4, 7, 1, 17, G) + r(-5, 21, 3, 3, GRN) + g('cb-k1', r(-5, 16, 3, 3, GRN)) + g('cb-k2', r(-5, 11, 3, 3, GRN)) + g('cb-k3', r(-5, 6, 3, 3, Y))],
339 inst1: ['', g('cb-pa', r(-5, 4, 8, 2, PINK) + r(-6, 6, 10, 1, PINK) + r(-6, 7, 1, 4, LG) + r(3, 7, 1, 4, LG) + r(-4, 11, 5, 4, B) + r(-2, 11, 1, 4, Y))],
340 inst2: ['', r(-6, 6, 10, 4, G) + r(-5, 7, 8, 2, DK) + g('cb-fl2', r(-5, 7, 8, 2, GRN))],
341 inst3: ['', r(-5, 18, 8, 6, B) + r(-2, 18, 2, 6, Y) + g('cb-lid', r(-6, 16, 9, 2, '#8A5A30')) + g('cb-sp2', r(-3, 12, 1, 1, Y) + r(1, 11, 1, 1, Y) + r(-5, 13, 1, 1, Y))],
342 ask1: ['', g('cb-qb', r(-6, 6, 8, 8, WH) + r(1, 13, 2, 1, WH) + r(2, 14, 1, 1, WH) + r(-4, 8, 3, 1, BLU) + r(-2, 9, 1, 1, BLU) + r(-3, 10, 1, 1, BLU) + r(-3, 12, 1, 1, BLU))],
343 ask2: [r(9, 2, 12, 5, WH) + r(12, 7, 2, 1, WH) + g('cb-e1', r(11, 4, 2, 1, G)) + g('cb-e2', r(14, 4, 2, 1, G)) + g('cb-e3', r(17, 4, 2, 1, G))],
344 ask3: ['', g('cb-sg', r(-2, 12, 1, 14, B) + r(-6, 3, 10, 9, WH) + r(-3, 4, 4, 1, BLU) + r(1, 5, 1, 2, BLU) + r(-1, 7, 2, 1, BLU) + r(-1, 8, 1, 1, BLU) + r(-1, 10, 1, 1, BLU))],
345 long1: [r(25, 8, 4, 5, WH) + r(29, 9, 1, 3, WH) + r(25, 8, 4, 1, B) + g('cb-stm', r(26, 5, 1, 2, LG) + r(28, 3, 1, 2, LG)), '', 12],
346 long2: ['', r(-6, 8, 8, 1, B) + r(-6, 21, 8, 1, B) + r(-5, 9, 6, 5, GLASS) + r(-3, 14, 2, 2, GLASS) + r(-5, 16, 6, 5, GLASS)
347 + g('cb-ht', r(-5, 10, 6, 4, Y)) + g('cb-hs', r(-3, 14, 1, 3, Y)) + g('cb-hb', r(-5, 17, 6, 4, Y))],
348 long3: [g('cb-fly', r(24, 6, 1, 1, SKY) + r(23, 7, 3, 2, SKY))],
349 comp1: ['', r(-6, 16, 9, 8, B) + r(-6, 19, 9, 1, '#5A3418') + r(-4, 14, 3, 2, PINK) + g('cb-sl', r(-6, 13, 9, 3, '#8A5A30') + r(-3, 11, 2, 2, G))],
350 comp2: ['', g('cb-stk', r(-5, 6, 8, 18, WH) + r(-5, 9, 8, 1, LG) + r(-5, 13, 8, 1, LG) + r(-5, 17, 8, 1, LG) + r(-5, 21, 8, 1, LG))],
351 comp3: ['', r(-6, 4, 1, 22, G) + r(2, 4, 1, 22, G) + r(-6, 3, 9, 2, G) + g('cb-bx', r(-5, 16, 7, 10, B)) + g('cb-pl', r(-5, 6, 7, 2, LG) + r(-2, 5, 1, 1, G))],
352 think4: ['', r(-6, 24, 2, 2, WH) + r(-4, 24, 2, 2, DK) + r(-2, 24, 2, 2, WH) + r(0, 24, 2, 2, DK)
353 + g('cb-pn', r(-5, 17, 2, 2, LG) + r(-5, 19, 2, 3, LG) + r(-6, 22, 4, 2, LG))],
354 think5: [r(13, 2, 4, 4, G) + r(14, 0, 2, 2, G) + r(14, 6, 2, 2, G) + r(11, 3, 2, 2, G) + r(17, 3, 2, 2, G) + r(14, 3, 2, 2, I)],
355 think6: ['', r(-6, 5, 10, 10, '#3B4A3F') + r(-6, 15, 10, 1, WOOD) + r(-5, 16, 1, 10, WOOD) + r(2, 16, 1, 10, WOOD)
356 + g('cb-c1', r(-5, 7, 4, 1, WH)) + g('cb-c2', r(-4, 10, 6, 1, WH)) + g('cb-c3', r(-5, 12, 3, 1, Y))],
357 think7: ['', r(-6, 14, 4, 6, BLU) + r(-2, 16, 1, 2, BLU) + g('cb-pz', r(-1, 14, 4, 2, Y) + r(0, 16, 3, 2, Y) + r(-1, 18, 4, 2, Y))],
358 think8: [g('cb-x1', r(13, 1, 4, 1, DIM) + r(17, 2, 1, 2, DIM) + r(15, 4, 2, 1, DIM) + r(15, 5, 1, 2, DIM) + r(15, 8, 1, 1, DIM))
359 + g('cb-x2', r(14, 0, 2, 6, Y) + r(14, 7, 2, 2, Y))],
360 read4: ['', g('cb-np', r(-6, 9, 10, 11, CREAM) + r(-5, 10, 8, 2, G) + r(-5, 13, 3, 3, LG) + r(-1, 13, 3, 1, G) + r(-1, 15, 3, 1, G) + r(-5, 17, 8, 1, G))],
361 read5: [r(25, 7, 3, 3, G) + r(27, 5, 3, 3, LG) + r(29, 3, 3, 2, G), '', 10],
362 read6: ['', g('cb-cd', r(-5, 7, 5, 3, WH)) + r(-6, 10, 8, 16, B) + r(-6, 17, 8, 1, '#5A3418') + g('cb-dr', r(-6, 11, 8, 5, '#8A5A30') + r(-3, 13, 2, 1, Y))],
363 read7: ['', r(-5, 9, 7, 11, WH) + r(-4, 11, 5, 1, G) + r(-4, 13, 4, 1, G) + r(-4, 15, 5, 1, G) + r(-4, 17, 3, 1, G)
364 + g('cb-bm', r(-6, 9, 9, 1, 'rgba(29,158,117,.8)'))],
365 read8: ['', r(-6, 14, 10, 9, CREAM) + r(-5, 16, 2, 1, BLU) + r(-3, 17, 3, 1, BLU) + r(0, 18, 3, 1, BLU) + r(-6, 21, 10, 2, '#9CC08A')
366 + g('cb-pin', r(-2, 8, 3, 3, RED) + r(-1, 11, 1, 3, G))],
367 test4: ['', r(-3, 6, 3, 15, GLASS) + r(-4, 20, 5, 5, RED) + g('cb-th', r(-2, 8, 1, 13, RED)) + r(1, 9, 1, 1, G) + r(1, 12, 1, 1, G) + r(1, 15, 1, 1, G)],
368 test5: ['', r(-2, 10, 1, 14, G) + r(-5, 24, 7, 2, G)
369 + g('cb-bal', r(-6, 9, 9, 1, G) + r(-5, 10, 1, 3, LG) + r(1, 10, 1, 3, LG) + r(-6, 13, 3, 1, LG) + r(0, 13, 3, 1, LG) + r(-6, 12, 2, 1, Y) + r(1, 12, 1, 1, GRN))],
370 test6: ['', g('cb-jl', r(-6, 17, 7, 9, '#7FC8A9') + r(-5, 18, 2, 2, 'rgba(255,255,255,.5)')) + g('cb-pk', r(1, 20, 4, 1, B))],
371 test7: ['', r(-6, 24, 8, 2, G) + r(0, 12, 2, 12, G) + r(-5, 19, 6, 1, DK) + r(-4, 18, 4, 1, SKY) + r(-4, 9, 3, 7, LG) + r(-4, 7, 3, 2, G)
372 + r(-3, 16, 1, 2, G) + g('cb-lt', r(-3, 22, 2, 1, Y))],
373 test8: ['', r(-6, 20, 10, 4, G) + r(-5, 21, 8, 2, DK) + g('cb-pb', r(-5, 21, 8, 2, GRN))],
374 long4: [r(26, 9, 1, 5, B) + r(24, 4, 5, 5, PINK) + r(25, 5, 3, 3, '#E88AAB'), g('cb-dp', r(5, 8, 1, 1, SKY) + r(4, 9, 3, 2, SKY)), 10],
375 long5: [g('cb-sn', r(12, 16, 6, 1, '#E8B04A') + r(12, 17, 6, 1, '#7BA05B') + r(12, 18, 6, 1, RED) + r(12, 19, 6, 1, '#E8B04A'))
376 + g('cb-cr', r(14, 21, 1, 1, '#E8B04A') + r(16, 22, 1, 1, '#E8B04A'))],
377 long6: ['', r(-5, 4, 8, 8, WH) + r(-6, 5, 1, 6, G) + r(3, 5, 1, 6, G) + r(-5, 3, 8, 1, G) + r(-5, 12, 8, 1, G)
378 + g('cb-mh', r(-2, 5, 1, 3, I)) + g('cb-hh', r(-2, 8, 3, 1, I))],
379 long7: ['', r(-5, 9, 7, 15, G) + r(-4, 10, 5, 13, DK) + r(-3, 8, 3, 1, G) + g('cb-bt', r(-4, 10, 5, 13, GRN))],
380 long8: [r(5, 8, 1, 1, SKY) + r(4, 9, 3, 2, SKY), r(-5, 13, 1, 13, G) + r(-5, 13, 6, 1, G) + r(-6, 26, 38, 1, G)
381 + g('cb-tm', r(-6, 26, 2, 1, LG) + r(2, 26, 2, 1, LG) + r(10, 26, 2, 1, LG) + r(18, 26, 2, 1, LG) + r(26, 26, 2, 1, LG) + r(34, 26, 2, 1, LG))],
382 build1: [r(8, 11, 14, 3, DK) + r(8, 11, 14, 1, '#5F636B'), r(-6, 15, 7, 11, G) + r(-6, 20, 7, 1, '#5F636B') + g('cb-wg2', r(-2, 19, 3, 1, ORG))
383 + r(1, 17, 4, 2, DK) + r(0, 17, 1, 1, LG) + g('cb-sk1', r(-1, 15, 1, 1, Y)) + g('cb-sk2', r(1, 14, 1, 1, ORG)) + g('cb-sk3', r(-3, 16, 1, 1, Y))],
384 saw1: ['', r(-6, 12, 5, 14, WOOD) + r(-4, 15, 2, 2, B) + r(-4, 20, 2, 2, B)
385 + g('cb-dl', r(0, 15, 5, 3, ORG) + r(2, 18, 2, 3, DK) + r(-2, 16, 2, 1, LG) + r(-1, 16, 1, 1, G)) + g('cb-ds', r(-2, 18, 1, 1, CREAM))],
386 type1: ['', r(-6, 10, 9, 13, '#2F5E9E') + r(-6, 14, 9, 1, '#4A78B8') + r(-6, 18, 9, 1, '#4A78B8') + r(-3, 10, 1, 13, '#4A78B8')
387 + g('cb-p1', r(-5, 12, 6, 1, WH)) + g('cb-p2', r(-5, 12, 1, 8, WH)) + g('cb-p3', r(-5, 19, 6, 1, WH)) + g('cb-pc3', r(2, 13, 1, 5, Y) + r(2, 18, 1, 1, I))],
388 paint1: ['', g('cb-brm', r(1, 6, 1, 15, B) + r(-1, 21, 5, 4, WOOD) + r(-1, 21, 5, 1, '#8A5A30')) + g('cb-pf1', r(-4, 24, 1, 1, LG)) + g('cb-pf2', r(-6, 23, 2, 1, LG))],
389 gear1: ['', r(-5, 2, 2, 24, Y) + r(-5, 6, 2, 1, '#B8941F') + r(-5, 12, 2, 1, '#B8941F') + r(-5, 18, 2, 1, '#B8941F') + r(-6, 2, 10, 2, Y)
390 + g('cb-cab', r(2, 4, 1, 4, G)) + g('cb-blk', r(1, 8, 3, 1, G) + r(0, 9, 5, 4, BR))],
391}
392const WALKS: ReadonlySet<string> = new Set(['inst', 'codex', 'think3', 'done2'])
393
394// A take is a 14 s scene, as long as it holds, not one short loop (Praj, 16 Oct): the crab works, breaks for a beat
395// at ~5 s with an emote over its shoulder, works on, and takes another beat at ~10 s. The beats move the whole crab
396// (its tools stay put); each take draws its own pair from its name. The dozing crab sleeps through.
397const BEATS: Record<string, [number, string][]> = {
398 hop: [[.2, 'translateY(-3px)'], [.4, 'none'], [.6, 'translateY(-3px)'], [.8, 'none']],
399 admire: [[.25, 'translateX(2px) rotate(4deg)'], [.75, 'translateX(2px) rotate(4deg)']],
400 turn: [[.15, 'scaleX(-1)'], [.8, 'scaleX(-1)']],
401 stretch: [[.3, 'scale(1.06,.88)'], [.6, 'scale(.96,1.1)']],
402 shimmy: [[.15, 'translateX(-1px)'], [.3, 'translateX(1px)'], [.45, 'translateX(-1px)'], [.6, 'translateX(1px)'], [.75, 'none']],
403 tilt: [[.25, 'rotate(-7deg)'], [.75, 'rotate(-7deg)']],
404}
405const EMOTE = {
406 bang: r(4, 1, 2, 5, Y) + r(4, 7, 2, 2, Y),
407 note: r(5, 2, 1, 4, WH) + r(3, 5, 3, 2, WH) + r(6, 2, 2, 1, WH),
408 spark: r(5, 2, 1, 5, Y) + r(3, 4, 5, 1, Y),
409 ask: r(4, 2, 3, 1, DIM) + r(7, 3, 1, 2, DIM) + r(5, 5, 2, 1, DIM) + r(5, 6, 1, 1, DIM) + r(5, 8, 1, 1, DIM),
410 love: r(3, 3, 2, 2, PINK) + r(6, 3, 2, 2, PINK) + r(3, 5, 5, 2, PINK) + r(4, 7, 3, 1, PINK) + r(5, 8, 1, 1, PINK),
411 sweat: r(5, 3, 1, 1, SKY) + r(4, 4, 3, 2, SKY),
412}
413const GLUM: ReadonlySet<Mood> = new Set(['testf', 'debug', 'dizzy', 'long', 'ask'])
414const WINDOW = [[34, 44], [72, 82]] as const
415function scene(mood: Mood, key: string): { css: string, wrap: (body: string) => string } {
416 if (mood === 'doze') return { css: '', wrap: b => b }
417 // ponytail: string hash plus one avalanche step; neighbours (think4, think5) otherwise shared their high bits.
418 let h = [...key].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7)
419 h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0; h = (h ^ (h >>> 16)) >>> 0
420 const names = Object.keys(BEATS), faces = (GLUM.has(mood) ? ['sweat', 'ask', 'bang'] : Object.keys(EMOTE)) as (keyof typeof EMOTE)[]
421 const pick = [names[h % 6], names[(h % 6 + 1 + (h >>> 4) % 5) % 6]]
422 const face = [faces[(h >>> 8) % faces.length], faces[((h >>> 8) + 1) % faces.length]]
423 let css = ''
424 WINDOW.forEach(([a, b], i) => {
425 const at = (f: number) => +(a + f * (b - a)).toFixed(1)
426 css += `.cb-act${i + 1}{transform-origin:50% 100%;animation:cb-k-${pick[i]}-${i + 1} 14s ease-in-out infinite}`
427 + `@keyframes cb-k-${pick[i]}-${i + 1}{0%,${a}%,${b}%,100%{transform:none}${BEATS[pick[i]].map(([f, tf]) => `${at(f)}%{transform:${tf}}`).join('')}}`
428 + `.cb-em${i + 1}{opacity:0;animation:cb-e${i + 1} 14s ease-in-out infinite}@keyframes cb-e${i + 1}{0%,${a}%,${b}%,100%{opacity:0}${a + 2}%,${b - 2}%{opacity:1}}`
429 })
430 return { css, wrap: b => `${g('cb-act1', g('cb-act2', b))}${g('cb-em1', EMOTE[face[0]])}${g('cb-em2', EMOTE[face[1]])}` }
431}
432
433/** One standalone SVG of the crab acting out take `t` of `mood` (0 is the mood's first look). */
434export function crabArt(mood: Mood, t = 0): string {
435 const key = t ? `${mood}${t}` : mood
436 const [prop = '', beside = '', armY = 14] = (t ? TAKE[key] : LOOK[mood]) ?? []
437 const eyes = mood === 'doze' ? r(9, 14, 2, 1, I) + r(19, 14, 2, 1, I) : r(9, 12, 2, 2, I) + r(19, 12, 2, 2, I)
438 const mouth = key === 'idle' ? g('cb-mo', r(13, 17, 4, 3, I)) : ''
439 const z = key === 'doze' ? '<text class="cb-z" x="24" y="8">z</text>' : ''
440 const sc = scene(mood, key)
441 // The scene's beats join the one style block, ahead of the reduced-motion rule that stills them too.
442 return `<svg xmlns="http://www.w3.org/2000/svg" width="38" height="30" viewBox="-6 -2 38 30" shape-rendering="crispEdges">${CSS.replace('@media', `${sc.css}@media`)}`
443 + `<g class="cb cb-${key}${WALKS.has(key) ? ' cb-walk' : ''}">${beside}${sc.wrap(`<g class="cb-all"><g class="cb-bd">${r(7, 10, 16, 12, O) + r(3, 14, 4, 4, O)}`
444 + `${g('cb-ey', eyes)}${mouth}${g('cb-ar', r(23, armY, 4, 4, O))}${g('cb-pr', prop)}</g>`
445 + `${g('cb-la', r(7, 22, 2, 4, O) + r(17, 22, 2, 4, O))}${g('cb-lb', r(11, 22, 2, 4, O) + r(21, 22, 2, 4, O))}</g>`)}${z}</g></svg>`
446}
447
448// The stage (Praj, 12 Oct): the right half of the band, the crab 52×41 inside it. The markup is width="100%" with no
449// viewBox, so user units are CSS px and a percentage is the drawn width (live probe 10:58; a numeric width squashed
450// the crab). Each mood has a station across it, 0 the stage's left end and 1 its right, beside the prompt; a new look
451// scuttles there sideways from the last one's, and some moods roam about their station.
452export const STATION: Record<Mood, number> = {
453 idle: 1, think: .85, read: .65, build: .9, saw: .7, type: .5, paint: .3, gear: .1, test: .5, testp: .5, testf: .5,
454 debug: .35, dizzy: .35, fixed: .5, done: 1, doze: 1, night: 1, web: .7, sub: .6, codex: .6, git: .4, inst: .55, ask: 1,
455 long: .5, comp: 1,
456}
457// [cycle in s, keyframe stops as [percent, offset from the station], 1 to play once and hold the last stop];
458// every stop stays inside the stage.
459const ROAM: Partial<Record<Mood, [number, [string, number][], 1?]>> = {
460 idle: [80, [['0%,15%', 0], ['30%,45%', -.4], ['60%,72%', -.75], ['90%,100%', 0]]],
461 think: [24, [['0%,10%', 0], ['45%,55%', -.3], ['90%,100%', 0]]],
462 read: [32, [['0%', 0], ['50%', -.25], ['100%', 0]]],
463 web: [12, [['0%', 0], ['50%', -.35], ['100%', 0]]],
464 done: [10, [['0%', 0], ['45%,55%', -.8], ['100%', 0]]],
465 long: [40, [['0%', 0], ['50%', -.2], ['100%', 0]]],
466 dizzy: [2.4, [['0%,100%', 0], ['25%', -.04], ['75%', .04]]],
467 // Walks right while it paints, leaving the stripe behind (Praj, 12 Oct).
468 paint: [16, [['0%', 0], ['100%', .4]], 1],
469 // Trips, slides back, gets up (Praj, 12 Oct).
470 testf: [2.4, [['0%,30%', 0], ['75%,100%', -.18]], 1],
471}
472const W = 52
473const x = (f: number) => `translateX(calc((100% - ${W}px) * ${+f.toFixed(3)}))`
474// Seconds to walk from `from` to `to`: the whole stage in 4.8 s, never under .8 s; 0 when already there. Half
475// 0.9.2's speed, and every roam cycle doubled with it (Praj, 12 Oct: the scuttling felt restless).
476const walkFor = (from: number, to: number) => Math.abs(from - to) > .001 ? Math.max(.8, 4.8 * Math.abs(from - to)) : 0
477// CSS ease-in-out (cubic-bezier(.42,0,.58,1)): how far along at progress u, solved by bisection.
478function ease(u: number) {
479 let a = 0, b = 1
480 for (let i = 0; i < 30; i++) { const m = (a + b) / 2; if (3 * m * (1 - m) ** 2 * .42 + 3 * m * m * (1 - m) * .58 + m ** 3 < u) a = m; else b = m }
481 const m = (a + b) / 2
482 return 3 * m * m * (1 - m) + m ** 3
483}
484// The u in 0..1 where ease(u) = p, by bisection: when the crab reaches a fraction p of its walk.
485function uAt(p: number) {
486 let a = 0, b = 1
487 for (let i = 0; i < 30; i++) { const m = (a + b) / 2; if (ease(m) < p) a = m; else b = m }
488 return (a + b) / 2
489}
490// n marks evenly spaced from f0 to f1, each timed to when a walk from f0 (starting at `start`, `dur` s long) passes it.
491const trail = (n: number, f0: number, f1: number, start: number, dur: number) =>
492 Array.from({ length: n }, (_, k) => ({ f: f0 + (f1 - f0) * (k + .5) / n, t: start + dur * uAt((k + .5) / n) }))
493
494/** Where the crab stands (0 to 1) `s` seconds into a look of `mood` that walked in from `from`: the walk, then the wander. */
495export function where(mood: Mood, from: number, s: number): number {
496 const to = STATION[mood], d = walkFor(from, to), roam = ROAM[mood]
497 if (s < d) return from + (to - from) * ease(s / d)
498 if (!roam) return to
499 if (roam[2] && s - d >= roam[0]) return to + roam[1][roam[1].length - 1][1]
500 const p = (s - d) % roam[0] / roam[0] * 100
501 const stops = roam[1].flatMap(([ps, v]) => ps.split(',').map(q => [parseFloat(q), v] as const)).sort((a, b) => a[0] - b[0])
502 const i = stops.findIndex(([q]) => q >= p), [q1, v1] = stops[Math.max(i, 0)], [q0, v0] = stops[Math.max(i - 1, 0)]
503 return to + (q1 > q0 ? v0 + (v1 - v0) * ease((p - q0) / (q1 - q0)) : v1)
504}
505
506// A letter's width in ems, for sizing the label's pill without measuring (measured in Chromium at 11.5 px, 12:35:
507// 4.9 to 6.9 px a letter by text). ponytail: Helvetica-ish classes; the pill's spacing absorbs the rest.
508const em = (c: string) => /[ijl.,:;'|!I]/.test(c) ? .23 : /[ftr ]/.test(c) ? .3 : /[mwMW]/.test(c) ? .83 : /[A-Z]/.test(c) ? .68 : .54
509
510/** The crab acting out take `t` of `mood` on the stage, walking in from `from` (0 to 1), then wandering; `tip` shows
511 * over its head while the pointer is on it (the Svg must be drawn interactive for :hover). */
512export function stageArt(mood: Mood, t = 0, from = STATION[mood], tip = ''): string {
513 const to = STATION[mood], d = walkFor(from, to), go = d > 0, roam = ROAM[mood]
514 const crab = crabArt(mood, t).replace('width="38" height="30"', `width="${W}" height="41"`)
515 const steps = Math.ceil(d / .2), once = !!roam?.[2], legs = once ? Math.round(roam![0] / .4) : 'infinite'
516 const css = '.st-at,.st-in{transform-box:view-box}'
517 + `.st-at{transform:${x(to)}}`
518 + (go ? `.st-go{animation:st-go ${+d.toFixed(2)}s ease-in-out}@keyframes st-go{0%{transform:${x(from)}}100%{transform:${x(to)}}}`
519 + `.st-go .cb:not(.cb-walk) .cb-la{animation:cb-st .2s ease-in-out ${steps}}.st-go .cb:not(.cb-walk) .cb-lb{animation:cb-st .2s ease-in-out ${steps} -.1s}` : '')
520 // The wander waits for the walk in, so the crab never jumps between the two.
521 + (roam ? `.st-in{animation:st-r ${roam[0]}s ease-in-out ${+d.toFixed(2)}s ${once ? '1 forwards' : 'infinite'}}@keyframes st-r{${roam[1].map(([p, v]) => `${p}{transform:${x(v)}}`).join('')}}`
522 + `.st-roam .cb:not(.cb-walk) .cb-la{animation:cb-st .4s ease-in-out ${+d.toFixed(2)}s ${legs}}.st-roam .cb:not(.cb-walk) .cb-lb{animation:cb-st .4s ease-in-out ${+(d + .2).toFixed(2)}s ${legs}}` : '')
523 // The crab faces right, so it pitches forward over its front foot at (40, 38).
524 + (mood === 'testf' ? `.st-tr{transform-box:view-box;transform-origin:0 0;animation:st-trip 2.4s ease-in-out ${+d.toFixed(2)}s 1 both}`
525 + '@keyframes st-trip{0%,15%{transform:none}30%,75%{transform:translate(40px,38px) rotate(24deg) translate(-40px,-38px)}100%{transform:none}}' : '')
526 // Pointer on the crab: the label shows over its head and rides with it; the crab keeps walking. The label
527 // stays 3 s after the pointer leaves, then fades out in .2 s (Praj, 12 Oct: a pause stopped the crab, a quick
528 // pass lost it).
529 + '.st-tip{opacity:0;visibility:hidden;transition:opacity .2s 3s,visibility 0s 3.2s}.st-at:hover .st-tip{opacity:1;visibility:visible;transition:none}'
530 // Dark ink on a light pill, light ink on a dark pill: crab orange read poorly (Praj, 12 Oct).
531 + '.st-bg{fill:#f0eee6}.st-ink{fill:#1f1e1d}@media (prefers-color-scheme:dark){.st-bg{fill:#3a3936}.st-ink{fill:#f0eee6}}'
532 // Stage-wide extras, drawn behind the crab (Praj, 12 Oct).
533 let fx = '', fxCss = ''
534 if (mood === 'fixed') {
535 // A fix throws confetti across the whole stage, three falls.
536 for (let i = 0; i < 18; i++) fx += `<rect x="${((i + .5) / 18 * 100 + ((i * 7) % 5 - 2)).toFixed(1)}%" y="-4" width="${i % 2 ? 3 : 2}" height="${i % 2 ? 2 : 3}" fill="${[Y, PINK, BLU, GRN, ORG][i % 5]}" class="st-cf" style="animation-delay:${(i * .29 % 1.6).toFixed(2)}s"/>`
537 fxCss += '.st-cf{transform-box:fill-box;transform-origin:50% 50%;animation:st-cf 1.6s linear 3 both}@keyframes st-cf{0%{transform:translateY(0) rotate(0)}100%{transform:translateY(46px) rotate(360deg)}}'
538 }
539 if (go) {
540 // Footprints along the walk-in, each laid as the crab passes, then fading (Praj, 12 Oct).
541 trail(Math.max(2, Math.round(Math.abs(to - from) * 14)), from, to, 0, d).forEach(({ f, t }, k) => {
542 const [a, b] = k % 2 ? [22, 36] : [17, 31]
543 fx += `<g class="st-fp" style="transform:${x(f)};animation-delay:${t.toFixed(2)}s">${[a, b].map(v => `<rect x="${v}" y="39" width="3" height="1.5" fill="${G}"/>`).join('')}</g>`
544 })
545 fxCss += '.st-fp{transform-box:view-box;opacity:0;animation:st-fp 2.2s ease-in both}@keyframes st-fp{0%{opacity:0}5%{opacity:.6}100%{opacity:0}}'
546 }
547 if (mood === 'paint' && !t) {
548 // The stripe behind it, each mark laid as the crab passes.
549 trail(40, to, to + .4, d, roam![0]).forEach(({ f, t }) => fx += `<g class="st-sp" style="transform:${x(f)};animation-delay:${t.toFixed(2)}s">${r(4, 37, 14, 2.5, BLU)}</g>`)
550 fxCss += '.st-sp{transform-box:view-box;animation:st-sp .3s ease-out both}@keyframes st-sp{0%{opacity:0}100%{opacity:1}}'
551 }
552 if (mood === 'sub') {
553 // Two mini crabs trot the whole stage, half a lap apart.
554 for (const delay of [0, -7]) fx += `<g class="st-mc" style="animation-delay:${delay}s"><g class="st-mb"><g transform="translate(0,28) scale(.4)">${MINI_BODY}</g></g></g>`
555 fxCss += '.st-mc{transform-box:view-box;animation:st-mc 14s linear infinite}@keyframes st-mc{0%{transform:translateX(-14px)}100%{transform:translateX(100%)}}'
556 + '.st-mb{animation:st-mb .3s ease-in-out infinite}@keyframes st-mb{50%{transform:translateY(-1px)}}'
557 }
558 if (mood === 'web') {
559 // A wave scrolls under the crab and it surfs; the second rect fills the gap the first's -16 offset leaves at the right.
560 fx += `<defs><pattern id="st-wave" width="16" height="6" patternUnits="userSpaceOnUse">${r(0, 3, 16, 3, BLU) + r(3, 1, 6, 2, SKY) + r(4, 0, 3, 1, WH)}</pattern></defs>`
561 + '<g class="st-wv"><rect x="-16" y="35" width="100%" height="6" fill="url(#st-wave)"/><rect x="50%" y="35" width="50%" height="6" fill="url(#st-wave)"/></g>'
562 fxCss += '.st-wv{animation:st-wv 1.2s linear infinite}@keyframes st-wv{100%{transform:translateX(16px)}}'
563 + '.st-tr{animation:st-bob 1.2s ease-in-out infinite}@keyframes st-bob{50%{transform:translateY(-2px)}}'
564 }
565 // Build lays a wall leftward, course by course, riding with the crab.
566 let wall = ''
567 if (mood === 'build' && !t) {
568 const bricks = [...Array.from({ length: 10 }, (_, k) => [-2 - (k + 1) * 9, 34.5]), ...Array.from({ length: 9 }, (_, k) => [-6.5 - (k + 1) * 9, 30])]
569 wall = g('st-fx', bricks.map(([bx, by], j) => `<rect x="${bx}" y="${by}" width="8" height="4" fill="${j % 2 ? BR2 : BR}" class="st-bk" style="animation-delay:${(d + .3 + j * .45).toFixed(2)}s"/>`).join(''))
570 fxCss += '.st-bk{animation:st-bk .25s ease-out both}@keyframes st-bk{0%{opacity:0;transform:translateY(-6px)}100%{opacity:1;transform:none}}'
571 }
572 fxCss += '@media (prefers-reduced-motion:reduce){.st-fx{display:none}.st-tr{animation:none}}'
573 // The label, in the art's clear top 13.67 px; it ends at the crab's right edge on the right half of the stage
574 // and starts at its left edge on the left half, so it never runs off. 13.5 tall: 12 read squashed (Praj, 12 Oct).
575 // Only the spacing flexes to fit: stretched glyphs read squat and low-res (recording 12:30).
576 const tw = +[...tip].reduce((t, c) => t + em(c) * 11.5, 0).toFixed(1), w = Math.round(tw + 8), side = (f: number) => f >= .5 ? W - w : 0, lx = side(to)
577 // The crab crosses the middle while it walks in or wanders, so the label switches sides with it on the same clocks
578 // (recording 12:24: "A command faile" ran off the right edge). Offsets from the resting side, held between steps.
579 const swap = (name: string, at: (u: number) => number, clock: string) => {
580 const k: string[] = []
581 let was = NaN
582 for (let i = 0; i <= 100; i++) {
583 const v = side(at(i / 100)) - lx
584 if (v !== was || i === 100) k.push(`${i}%{transform:translateX(${v}px)}`), was = v
585 }
586 return k.some(f => !f.includes('(0px)')) ? { css: `.${name}{animation:${name} ${clock}}@keyframes ${name}{${k.join('')}}`, name } : undefined
587 }
588 const lw = tip && go ? swap('st-lw', u => where(mood, from, d * u), `${+d.toFixed(2)}s step-end`) : undefined
589 const lr = tip && roam ? swap('st-lr', u => where(mood, from, d + roam[0] * u), `${roam[0]}s step-end ${+d.toFixed(2)}s ${once ? '1 forwards' : 'infinite'}`) : undefined
590 const wrap = (g: string) => [lr, lw].reduce((a, l) => l ? `<g class="${l.name}">${a}</g>` : a, g)
591 const label = tip ? wrap(`<g class="st-tip"><rect x="${lx}" y="0" width="${w}" height="13.5" rx="3" class="st-bg"/>`
592 + `<text x="${lx + 4}" y="10.6" font-family="sans-serif" font-size="11.5" text-rendering="geometricPrecision" class="st-ink" textLength="${tw}" lengthAdjust="spacing">`
593 + `${tip.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</text></g>`) : ''
594 return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="41"><style>${css}${fxCss}${lw?.css ?? ''}${lr?.css ?? ''}</style>${fx ? g('st-fx', fx) : ''}`
595 + `<g class="st-at${go ? ' st-go' : ''}${roam ? ' st-roam' : ''}"><g class="st-in"><rect width="${W}" height="41" fill="transparent"/>${wall}<g class="st-tr">${crab}</g>${label}</g></g></svg>`
596}
597hooks/term.tsx 110 lines1// The crab for terminals (Praj, 12 Oct: "terminal friendly as well, for its wider appeal"). A Client module:
2// the terminal runs its frame clock, so the crab moves with no hook call per frame. Drawn in quadrant blocks,
3// two by two pixels a cell, on the welcome screen's own crab, with one-cell props beside and above it.
4import type { ClientModule } from 'claude-code'
5import type { Mood } from '../types'
6
7export const COLS = 14, ROWS = 3, FRAME = 150
8
9type Cell = { ch: string, fg?: string, bg?: string }
10type Rect = [x: number, y: number, w: number, h: number, c: string]
11type Glyph = [cx: number, cy: number, ch: string, c: string]
12type Look = { dx?: number, dy?: number, eyes?: number | 'shut', legs?: boolean, claw?: boolean, squish?: boolean, px?: Rect[], ch?: Glyph[] }
13
14const O = '#D97757', G = '#8E929A', B = '#7A4A26', Y = '#F5C542', RED = '#E24B4A', GRN = '#1D9E75', BLU = '#378ADD'
15const BR = '#B5532F', BR2 = '#93401F', WH = '#F4F3EE', DIM = '#9A9890', WOOD = '#C8A165', SKY = '#85B7EB'
16
17// The welcome screen's crab, one character a pixel: 18×5, at cells 5-13. Eyes are holes the terminal shows through.
18const CRAB = ['...############...', '...##.######.##...', '.################.', '...############...', '....#.#....#.#....']
19const STEP = '...#.#......#.#...'
20const QUAD = ' ▗▖▄▝▐▞▟▘▚▌▙▀▜▛█'
21
22const on = (f: number, n: number, k: number) => f % n < k
23// Props stand left of the crab (cells 0-4) or in its two free top corners (cells 5 and 13 of the top row).
24const LOOKS: Record<Mood, (f: number) => Look> = {
25 idle: f => { const t = f % 24; return { dy: t >= 12 && t < 15 ? 1 : 0, eyes: t === 20 ? 'shut' : t >= 8 && t < 11 ? -1 : 0 } },
26 think: f => ({ eyes: on(f, 8, 4) ? -1 : 1, ch: [[3, 0, '·', DIM], ...(f % 8 > 1 ? [[2, 0, '•', DIM] as Glyph] : []), ...(f % 8 > 3 ? [[1, 0, '●', DIM] as Glyph] : [])] }),
27 read: f => ({ eyes: on(f, 4, 2) ? -1 : 1, ch: [[on(f, 8, 4) ? 3 : 4, 1, '◎', G]] }),
28 build: f => ({ dy: f % 4 === 2 ? 1 : 0, px: [[1, 5, 4, 1, BR], [5, 5, 4, 1, BR2], ...(f % 16 >= 4 ? [[1, 4, 4, 1, BR2] as Rect] : []), ...(f % 16 >= 8 ? [[5, 4, 4, 1, BR] as Rect] : []), ...(f % 16 >= 12 ? [[3, 3, 4, 1, BR2] as Rect] : []),
29 [8, f % 4 < 2 ? 0 : 2, 2, 1, G], [9, f % 4 < 2 ? 1 : 3, 1, 2, B]], ch: f % 4 === 2 ? [[0, 1, '·', WH]] : [] }),
30 saw: f => ({ px: [[0, 4, 9, 1, WOOD], [1, 5, 1, 1, B], [7, 5, 1, 1, B], [on(f, 2, 1) ? 1 : 3, 3, 5, 1, G], [on(f, 2, 1) ? 6 : 8, 3, 1, 1, B]], ch: f % 2 ? [[2, 2, '·', WOOD]] : [] }),
31 type: f => ({ legs: on(f, 2, 1), px: [[1, 0, 7, 4, '#2C2C2A'], [0, 4, 9, 1, G], ...(f % 8 >= 2 ? [[2, 1, 3, 1, GRN] as Rect] : []), ...(f % 8 >= 4 ? [[2, 2, 4, 1, BLU] as Rect] : []), ...(f % 8 >= 6 ? [[2, 3, 2, 1, '#EF9F27'] as Rect] : []), ...(on(f, 2, 1) ? [[6, 3, 1, 1, WH] as Rect] : [])] }),
32 paint: f => { const h = 1 + Math.floor((f % 12) / 2); return { px: [[1, 0, 6, 6, '#4A4A48'], [1, 6 - h, 6, h, BLU], [7, Math.max(0, 5 - h), 2, 2, WH]] } },
33 gear: f => ({ dy: f % 2, ch: [[2, 1, on(f, 2, 1) ? '┼' : '╳', G], [3, 0, on(f, 2, 1) ? '╳' : '┼', Y]] }),
34 test: f => ({ claw: on(f, 4, 2), ch: [[13, 0, '…', DIM]] }),
35 testp: f => ({ claw: true, ch: on(f, 4, 3) ? [[13, 0, '✓', GRN]] : [] }),
36 testf: f => ({ claw: true, ch: on(f, 4, 3) ? [[13, 0, '✗', RED]] : [] }),
37 debug: f => ({ dx: on(f, 2, 1) ? 0 : 1, ch: on(f, 4, 2) ? [[13, 0, '!', '#EF9F27']] : [] }),
38 done: f => ({ dy: on(f, 4, 2) ? 0 : 1, claw: on(f, 4, 2), ch: [[13, 0, '✓', GRN]] }),
39 doze: f => ({ eyes: 'shut', dy: on(f, 16, 8) ? 0 : 1, ch: [[13, 0, on(f, 8, 4) ? 'z' : 'Z', DIM]] }),
40 web: f => ({ ch: [[2, 1, '·', G], ...(f % 6 >= 2 ? [[3, 1, '∙', G] as Glyph] : []), ...(f % 6 >= 4 ? [[4, 1, '•', G] as Glyph] : []), [0, 1, '◍', RED]] }),
41 sub: f => { const x = [1, 2, 3, 2][Math.floor(f / 2) % 4]; return { ch: [[x, 2, '▟', O], [x + 1, 2, '▙', O]] } },
42 codex: f => ({ legs: on(f, 2, 1), ch: [[4 - (Math.floor(f / 2) % 5), 1, '▣', B]] }),
43 git: f => ({ ch: [[2, 1, '│', G], [2, 2, '│', G], [2, 0, '▕', G], [3, 0, on(f, 2, 1) ? '▶' : '▷', GRN]] }),
44 inst: f => ({ legs: on(f, 2, 1), ch: [[3, on(f, 4, 2) ? 1 : 2, '▣', B]] }),
45 ask: f => ({ claw: on(f, 4, 2), ch: on(f, 4, 3) ? [[13, 0, '?', BLU]] : [] }),
46 long: f => ({ legs: on(f, 2, 1), ch: [[5, f % 3, '•', SKY]] }),
47 comp: f => ({ squish: on(f, 4, 2) }),
48 dizzy: f => ({ dx: on(f, 4, 2) ? 0 : 1, ch: [[on(f, 2, 1) ? 5 : 13, 0, '✶', Y]] }),
49 fixed: f => ({ dy: on(f, 4, 2) ? 0 : 1, ch: [[1, f % 3, '•', GRN], [3, (f + 1) % 3, '•', Y], [13, 0, on(f, 2, 1) ? '✓' : ' ', GRN]] }),
50 night: f => ({ dy: on(f, 16, 8) ? 0 : 1, ch: [[2, 0, '☾', '#FAC775'], ...(on(f, 6, 3) ? [[0, 1, '·', WH] as Glyph] : []), ...(on(f + 3, 6, 3) ? [[4, 0, '·', WH] as Glyph] : [])] }),
51}
52
53/** One frame of `mood`: ROWS rows of COLS cells. Pure, so tests read it as text. */
54export function frame(mood: Mood, f: number): Cell[][] {
55 const look = (LOOKS[mood] ?? LOOKS.idle)(f)
56 const W = COLS * 2, H = ROWS * 2
57 const px: (string | undefined)[][] = Array.from({ length: H }, () => Array(W).fill(undefined))
58 const put = (x: number, y: number, c: string) => { if (x >= 0 && x < W && y >= 0 && y < H) px[y][x] = c }
59 for (const [x, y, w, h, c] of look.px ?? []) for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) put(x + i, y + j, c)
60 const eyes = look.eyes ?? 0
61 const rows = CRAB.map((r, y) => {
62 // Eyes: holes at 5 and 12, shifted a pixel to look aside, filled when shut.
63 if (y === 1) r = [...r].map((_, x) => (x < 3 || x > 14 || (eyes !== 'shut' && (x === 5 + eyes || x === 12 + eyes)) ? '.' : '#')).join('')
64 if (y === 4 && look.legs) r = STEP
65 if (look.claw && y === 1) r = r.slice(0, 15) + '##.'
66 if (look.claw && y === 2) r = '.##############...'
67 return r
68 })
69 const drawn = look.squish ? [rows[0], rows[1], rows[2], rows[4]] : rows
70 const top = (look.squish ? 1 : 0) + (look.dy ?? 0)
71 drawn.forEach((r, y) => [...r].forEach((c, x) => { if (c === '#') put(10 + (look.dx ?? 0) + x, top + y, O) }))
72 const cells: Cell[][] = Array.from({ length: ROWS }, (_, cy) => Array.from({ length: COLS }, (_, cx) => {
73 const q = [px[cy * 2][cx * 2], px[cy * 2][cx * 2 + 1], px[cy * 2 + 1][cx * 2], px[cy * 2 + 1][cx * 2 + 1]]
74 const n = new Map<string, number>()
75 for (const c of q) if (c) n.set(c, (n.get(c) ?? 0) + 1)
76 const [a, b] = [...n.entries()].sort((p, r) => r[1] - p[1]).map(e => e[0])
77 if (!a) return { ch: ' ' }
78 const mask = q.reduce((m, c, i) => (c === a ? m | (8 >> i) : m), 0)
79 return b ? { ch: QUAD[mask], fg: a, bg: b } : { ch: QUAD[mask], fg: a }
80 }))
81 for (const [cx, cy, ch, c] of look.ch ?? []) if (cells[cy]?.[cx]) cells[cy][cx] = { ch, fg: c }
82 return cells
83}
84
85type State = { f: number }
86
87// One Text per run of like-coloured cells: a row is a handful of runs, not fourteen elements.
88const Crab: ClientModule<{ mood: Mood }, State> = (props, surface) => {
89 if (surface.state === undefined) {
90 surface.every(FRAME, () => surface.setState({ f: (surface.state?.f ?? 0) + 1 }))
91 surface.setState({ f: 0 })
92 }
93 const { Box, Text } = surface.elements
94 const rows = frame(props.mood, surface.state?.f ?? 0)
95 return (
96 <Box flexDirection="column">
97 {rows.map((row, y) => {
98 const runs: Cell[] = []
99 for (const c of row) {
100 const last = runs.at(-1)
101 if (last && last.fg === c.fg && last.bg === c.bg) last.ch += c.ch
102 else runs.push({ ...c })
103 }
104 return <Box key={`r${y}`} flexDirection="row">{runs.map((r, i) => <Text key={`c${i}`} color={r.fg} backgroundColor={r.bg} wrap="truncate">{r.ch}</Text>)}</Box>
105 })}
106 </Box>
107 )
108}
109export default Crab
110types/index.d.ts 12 lines1export type Mood =
2 | 'idle' | 'think' | 'read' | 'build' | 'test' | 'testp' | 'testf' | 'debug' | 'done' | 'doze'
3 | 'web' | 'sub' | 'codex' | 'git' | 'inst' | 'ask' | 'long' | 'comp' | 'dizzy' | 'fixed' | 'night'
4 | 'saw' | 'type' | 'paint' | 'gear'
5export type Crab = { mood: Mood; until: number; fails: number; testFailed: boolean; turnAt: number | null; lastActive: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'crab-buddy': { crab: Crab; look: { mood: Mood; take: number; at: number; log: string[]; from?: number }; lines: Record<string, string> }
10 }
11}
12