SLOPSHOPPER

crab-buddy

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…

newbandguardtimer
v0.10.0MITupdated 2026-10-15pvjbuilds/crab-buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crab-buddy
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ▣ client module ./term.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./term.tsx
README

Crab Buddy: an animated pixel-art crab for Claude Code

Version License: MIT Claude Code plugin Cost: 0 tokens

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.

Crab Buddy walking across its stage above the prompt: thinking, reading with a magnifier, hammering while a brick wall grows, a green check for passing tests, and done, leaving footprints between spots. A caption on the left scrolls one-liners, the newest such as 'All green. Happy crab.' in full colour and the one before dimmer under it

claude plugin marketplace add pvjbuilds/crab-buddy
claude plugin install crab-buddy@crab-buddy

Start a new session and the crab is there.

Why

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.

Screenshots

Desktop app

Dark themeLight theme
Claude Code desktop app, dark theme: the crab hammers on the right half of the band above the prompt box, laying a brick wall as it goes. The caption on the left shows 'Hammering out the changes.' in full colour and 'Skimming code at crab speed.' dimmer below it.Claude Code desktop app, light theme: the same crab and caption on the app's light background.

Terminal

Dark themeLight theme
Claude Code in a dark terminal: the crab, drawn in block characters, builds a brick wall just above the prompt box while Claude edits a file.Claude Code in a light terminal: the same block-character crab above the prompt box.

Every take

110 takes across 25 moods, each labelled with the name the glossary uses for it.

A grid of all 110 takes, each labelled: idle to idle3, think to think8 (a lightbulb, chess, gears, a chalkboard, a puzzle), read to read8 (magnifier, book, newspaper, telescope, file drawer, map), ten build scenes (hammer and wall, welding, saw, drill, laptop, blueprint, paint roller, broom, gears, crane), test to test8 (flask, checklist, stopwatch, thermometer, scales, microscope, progress bar), test results, debug, dizzy, fixed, done, doze, night owl, web, subagent, another model, git, install, ask, long turn and compacting.

Features

  • 25 moods, 110 takes. Thinking, reading, testing and long turns have nine takes each; a long build runs ten scenes before one repeats. The first take is picked by the moment the mood began, so two test runs in a row rarely look the same.
  • Scenes, not loops. Each take plays as a 14-second scene: the crab works, stops for a beat (a hop, a stretch, a look back at its work) with a little emote, works on, then takes a second, different beat.
  • Nothing flashes. A look holds at least 8 seconds before the next mood replaces it. Results that matter (an error, a test result, a question for you, done) cut straight in.
  • A caption with personality. Ten to fifteen one-liners per mood ("Close, but no seaweed.", "Git it done.") scroll down like a terminal, newest on top. Point at the crab to see the plain meaning.
  • A stage, not a corner. In the desktop app the crab owns the right half of the band. It walks to a spot for each mood, leaves footprints, wanders while idle, scuttles home when done and curls up at the right edge to doze. In a narrow window it falls back to the corner.
  • Honest. Compacting shows only when a compaction really runs; the "?" only when you are really asked something.
  • Desktop and terminal. The desktop app gets smooth CSS animation. The terminal gets the welcome-screen crab in block characters, animated by the terminal itself.
  • Light and dark themes. In the desktop app the band follows the app's theme the moment you switch it. It respects "reduce motion".
  • Zero cost. No model calls, no network, no file writes. It runs on Claude Code's plugin hooks.

What each mood means

What Claude is doingWhat the crab does
ThinkingSways, plays chess, sketches on a chalkboard, fits a puzzle, gets an idea
Reading or searching filesMagnifier, book, newspaper, telescope, file drawer, scanner, map
Editing files or running commandsBuilds 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 testsFlask, checklist, stopwatch, thermometer, scales, microscope, a progress bar; cheers on a pass, trips on a fail
A command failedAn exclamation mark; three failures in a row and it gets dizzy
Tests pass again after a failureConfetti across the whole stage
Searching the webGlobe, surfboard or wifi waves
A subagent is workingA mini crab carries a box
Another model is working (Codex, Gemini)Hands a parcel to a robot
Committing or pushing with gitRocket, stamp or a commit graph
Installing a packageA parcel drops in
Waiting for your replyA question mark
A turn past 5 minutesCoffee, an hourglass, a fan, a snack, a racing clock, a draining battery, a treadmill
Compacting the conversationSquashes flat, only once a compaction really runs
DoneJumps, dances or bows
Idle, then dozing after 10 minutesFidgets, then sleeps; between midnight and 5 am it's a night owl

Install, update, remove

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.

FAQ

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.

Good to know

  • The caption always shows two lines. The newest in full text colour, the one before it dimmer, whether or not other plugins' lines sit beside it.
  • In the terminal, a theme change shows in the next session. The terminal theme is read when a session starts.
  • The band is tuned for 100% app zoom. At other zoom levels the app may leave a thin line at the band's edge.

Glossary

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

TermWhat it is
bandThe strip right above the prompt box. Crab height, never taller.
captionThe two one-liners on the band's left, newest on top.
stageThe band's right half, where the crab walks (desktop app).
stationThe crab's spot on the stage for a mood, 0 (left) to 1 (right).
moodWhat Claude is doing, one of 25 ids: think, read, build, test…
takeOne version of a mood: think, think1 … think8.
lookThe mood and take on screen right now.
sceneThe 14 s a take plays: work, a beat, work, another beat.
work loopThe take's own repeating motion, such as the hammer swing.
beatA break in the scene where the whole crab hops, admires, turns, stretches, shimmies or tilts.
emoteThe tiny icon over its shoulder during a beat: !, ♪, spark, ?, heart, sweat.
prop / set / stage extraOn the crab / beside it / across the whole stage (wall, stripe, confetti, footprints).
build scene / lapThe five moods a build rotates through; a lap is one pass, and the second lap plays their second takes.
dwell / hold8 s, the least a look stays / 5 s, how long a result shows.
one-liner / meaningA caption line / the plain text on hover.

For AI agents

If a user asks you to install, check, remove or change Crab Buddy, here is everything you need.

What it isA Claude Code plugin (a hooks module with a UI render). Its own one-plugin marketplace.
Plugin idcrab-buddy@crab-buddy
Installclaude plugin marketplace add pvjbuilds/crab-buddy, then claude plugin install crab-buddy@crab-buddy
Check it installedclaude plugin list shows crab-buddy@crab-buddy as enabled
Takes effectIn the next new session; the current session is unchanged
Removeclaude plugin uninstall crab-buddy@crab-buddy
Cost and accessNo model calls, no network, no file writes. Reads Claude Code's theme setting at session start.
Side effectOther plugins' status lines draw in the crab's band instead of the footer
VocabularyMap the user's words to code with the glossary; docs/glossary.html names the constant behind every term

Code map:

FileHolds
hooks/crab.tsPure mood logic: classify (tool call to mood), step, shown, take, MEANING, LINES
hooks/art.tsDesktop SVG art: LOOK (take 0), TAKE (the rest), BEATS, EMOTE, STATION, ROAM, stageArt
hooks/term.tsxTerminal art in quadrant block characters
hooks/register.tsxThe hooks: tool calls, turns and compaction set the mood; the AbovePrompt render draws the band; DWELL, CUT
CONTEXT.mdDesign 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.

Develop

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.

Changelog

See Releases. Recent:

  • 0.10.0: scenes, not loops; 110 takes; ten build scenes; compacting only when real.
  • 0.9.5: both caption lines always show, newest in full text colour.
  • 0.9.4: the "?" follows real permission dialogs only.
  • 0.9.3: stage extras (wall, paint stripe, confetti, footprints, the trip) and a calmer pace.

License

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.

Source 5 files
hooks/register.tsx 223 lines
1import { 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}
223
hooks/crab.ts 213 lines
1import 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}
213
hooks/art.ts 597 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</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}
597
hooks/term.tsx 110 lines
1// 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
110
types/index.d.ts 12 lines
1export 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