Session dashboard under the prompt: usage limits, context, cost, model, effort, session stats, git status, file changes, and an animated Clawd.

![]()
clawd-dash is a Claude Code mod that puts a little session dashboard under the prompt: plan limits, context, cost, model, effort, session stats, git status, and file changes, with an animated pixel Clawd on the right that reacts to what Claude is doing.
The stats sit in three columns that spread out to fill the space between the hint text and the crab.
Limits
↻ 44m)84k/200k)Session
Opus 5.5 · high)Repo
↑/↓ for commits ahead of or behind the remote, and ●3 for three changed files (or ✓ when the tree is clean)The normal hint line (? for shortcuts, esc to interrupt) stays as the last row, so you don't lose it.
The gauges turn yellow at 50% and red at 80%, and the numbers are always shown too, so you're not reading status from color alone. On narrower terminals the columns drop from the right: repo goes first, then session.
Clawd has four moods, and each one cycles through a few little scenes:
| Mood | When | What happens |
|---|---|---|
working | While Claude is running a turn | Typing on a laptop, shuffling papers, carrying boxes, running charts |
done | Right after a turn finishes | One burst of confetti and a dance |
idle | After the celebration | Sweeping, walking, scrolling a phone, reading |
relax | After about 3 minutes with nothing happening | Sunset chair, fishing, hammock, campfire, barbecue, a drive |
claude --version.This repo is also a small plugin marketplace called pinkpixel, so you can install it straight from GitHub. Inside Claude Code:
/plugin marketplace add pinkpixel-dev/clawd-dash
/plugin install clawd-dash@pinkpixel
Or from your shell:
claude plugin marketplace add pinkpixel-dev/clawd-dash
claude plugin install clawd-dash@pinkpixel
Run /plugin and you should see clawd-dash in the mods active line under the tabs. To pick up new versions later, run claude plugin marketplace update pinkpixel.
If you've cloned the repo and want to hack on it, you can load it from the folder instead. To try it for one session:
claude --plugin-dir /home/sizzlebop/PINKPIXEL/PROJECTS/CURRENT/clawd-dash
To load it every time, add the folder to CLAUDE_CODE_PLUGIN_DIRS, either in your shell or in the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/home/sizzlebop/PINKPIXEL/PROJECTS/CURRENT/clawd-dash"
}
}
Run /plugin inside Claude Code and you should see clawd-dash in the mods active line under the tabs.
To turn it off, remove the folder from that setting, or start Claude Code with --safe-mode for one session.
Edit/Write/MultiEdit/NotebookEdit calls. The only process it runs is git status --porcelain=v1 --branch, at session start, after each Bash or edit tool call, and when a turn finishes. It doesn't write files or make network requests. You can check that yourself with claude plugin validate ., which lists every hook and call the mod makes.The mod is a few small TypeScript files in hooks/:
register.tsx hooks into Claude Code's events. session.measure feeds the usage numbers, turn.start and turn.complete switch the mood and update the prompt count, last turn time, and git status, turn.step picks up the model and effort, and tool.call counts tool calls and lines from edit results. A ui.render hook on PromptHint draws the dashboard in the hint row under the prompt.dash.tsx builds the three stat columns and decides how many fit.git.ts runs git status and reads the branch, ahead/behind counts, and changed files out of it.scene.ts draws each animation frame of a mood as a 100x18 pixel SVG.raster.ts turns that SVG into RGBA pixels, crops off the mostly empty left side, and scales it up 4x so it stays crisp. The terminal can't draw SVG, but it can draw an Image.$.ui.blit, which swaps the picture without redrawing the rest of the dashboard.There's more detail in DOCS/OVERVIEW.md.
Validate the manifest and see what the mod hooks and calls:
claude plugin validate .
Run the tests:
claude plugin test .
The tests render the dashboard at a few widths, check the column breakpoints, the gauge, and git status parsing, and make sure every mood actually draws the mascot.
When Claude Code loads the mod from this folder, it writes type declarations into .claude-plugin/types/, and tsconfig.json extends them. After one load, npx -p typescript tsc -p . type-checks the mod. That folder is generated, so it's in .gitignore.
If you start Claude Code with --plugin-dir pointing here, saving a file reloads the mod live.
Apache 2.0. See LICENSE.
Clawd is Anthropic's mascot. This is a fan-made mod and isn't affiliated with Anthropic.
Made with 💖 by Pink Pixel
hooks/register.tsx 188 lines1import { atom, read, update } from 'claude-code'
2import type { Register, SessionMeasureInput } from 'claude-code'
3
4import type { Limit, Mood, Stats } from '../types'
5import { columnsFor, statsGrid } from './dash'
6import { readGit } from './git'
7import { rasterize } from './raster'
8import { DONE_SECONDS, sceneSvg } from './scene'
9
10// The runtime has the ES2026 Uint8Array base64 methods; es2023's lib doesn't declare them.
11declare global {
12 interface Uint8Array {
13 toBase64(): string
14 }
15}
16
17const SITE = 'PromptHint'
18const FRAME_MS = 125
19const RELAX_AFTER_S = 180
20// The scene is 100x18 art pixels; the left 24 columns are mostly empty dots.
21const ART_W = 100
22const ART_H = 18
23const CROP_X = 24
24const SCALE = 4
25const SCENE_COLS = 36
26const SCENE_ROWS = 4
27const MIN_COLS_FOR_SCENE = 90
28// Room left of the dash for Claude Code's own mode label ("auto mode on ·").
29const MODE_LABEL_COLS = 20
30// Keeps the uptime and reset countdowns current between stat changes.
31const CLOCK_MS = 15_000
32const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
33// Tools after which the working tree may have changed.
34const GIT_TOOLS = new Set([...EDIT_TOOLS, 'Bash'])
35
36const stats = atom({ plugin: 'clawd-dash', key: 'stats' } as const, { added: 0, removed: 0, files: [], tools: 0 } as Stats)
37
38let mood: Mood = 'idle'
39let moodAt = Date.now()
40let site: string | undefined
41
42function setMood(next: Mood) {
43 if (next === mood) return
44 mood = next
45 moodAt = Date.now()
46}
47
48function frame() {
49 const t = (Date.now() - moodAt) / 1000
50 if (mood === 'done' && t > DONE_SECONDS) setMood('idle')
51 else if (mood === 'idle' && t > RELAX_AFTER_S) setMood('relax')
52 const px = rasterize(sceneSvg(mood, (Date.now() - moodAt) / 1000), ART_W, ART_H, CROP_X, SCALE)
53 return { rgba: px.rgba.toBase64(), width: px.width, height: px.height }
54}
55
56function fromUsage(u: Omit<SessionMeasureInput, 'changed'>): Partial<Stats> {
57 const pick = (kind: string): Limit | undefined => {
58 const r = u.rateLimits.find(l => l.kind === kind)
59 return r && { percent: r.percentUsed, resetsAt: r.resetsAt }
60 }
61 return {
62 fiveHour: pick('five_hour'),
63 sevenDay: pick('seven_day'),
64 contextPercent: u.context.percent,
65 contextTokens: u.context.tokens,
66 contextWindow: u.context.window,
67 costUsd: u.cost?.usd,
68 }
69}
70
71export const register: Register = on => {
72 on('session.start', async ($, e, next) => {
73 moodAt = Date.now()
74 const [usage, model, prompts, version, git] = await Promise.all([
75 $.session.usage(),
76 $.session.model(),
77 $.session.turns(),
78 $.session.version(),
79 readGit(argv => $.process.run(argv)),
80 ])
81 await update($, stats, s => ({
82 ...s,
83 ...fromUsage(usage),
84 model,
85 prompts,
86 version: version.base ?? version.version,
87 startedAt: usage.startedAt,
88 git,
89 }))
90
91 $.clock.every(CLOCK_MS, () => $.ui.invalidate('ui.render'))
92
93 let busy = false
94 $.clock.every(FRAME_MS, () => {
95 if (!site || busy) return
96 busy = true
97 void $.ui
98 .blit({ requestId: site, key: 'scene', source: frame() })
99 // Not mounted, or the terminal can't show pixels: wait for the next render.
100 .then(r => { if (r.deny) site = undefined })
101 .finally(() => { busy = false })
102 })
103 return next(e)
104 })
105
106 on('session.measure', async ($, e, next) => {
107 await update($, stats, s => ({ ...s, ...fromUsage(e) }))
108 return next(e)
109 })
110
111 on('turn.start', async ($, e, next) => {
112 setMood('working')
113 const prompts = await $.session.turns()
114 await update($, stats, s => ({ ...s, prompts }))
115 return next(e)
116 })
117
118 on('turn.complete', async ($, e, next) => {
119 if (!e.agentId) {
120 setMood('done')
121 const git = await readGit(argv => $.process.run(argv))
122 await update($, stats, s => ({ ...s, lastTurnMs: e.durationMs, git }))
123 }
124 return next(e)
125 })
126
127 on('turn.step', async function* ($, e, next) {
128 if (!e.agentId) {
129 const effort = e.effort === undefined ? undefined : String(e.effort)
130 await update($, stats, s => ({ ...s, model: e.model, effort }))
131 }
132 return yield* next(e)
133 })
134
135 on('tool.call', async ($, e, next) => {
136 const res = await next(e)
137 const tool = String(e.tool)
138 await update($, stats, s => ({ ...s, tools: s.tools + 1 }))
139 if (GIT_TOOLS.has(tool)) {
140 const git = await readGit(argv => $.process.run(argv))
141 await update($, stats, s => ({ ...s, git }))
142 }
143 if (!EDIT_TOOLS.has(tool) || res.result === undefined || res.isError) return res
144
145 const r = res.result as { filePath?: string; type?: string; content?: string; structuredPatch?: { lines: string[] }[] }
146 let added = 0
147 let removed = 0
148 for (const hunk of r.structuredPatch ?? []) {
149 for (const line of hunk.lines) {
150 if (line.startsWith('+')) added++
151 else if (line.startsWith('-')) removed++
152 }
153 }
154 if (r.type === 'create' && r.content) added += r.content.split('\n').length
155 const path = r.filePath
156 await update($, stats, s => ({
157 ...s,
158 added: s.added + added,
159 removed: s.removed + removed,
160 files: path && !s.files.includes(path) ? [...s.files, path] : s.files,
161 }))
162 return res
163 })
164
165 on('ui.render', { component: SITE }, async ($, e, next) => {
166 if (e.surface !== 'terminal') return next(e)
167 const el = $.ui.resolve(e)
168 const { Box, Text, Image } = el
169 const s = await read($, stats)
170 const cols = e.viewport?.columns ?? 120
171 const showScene = cols >= MIN_COLS_FOR_SCENE
172 site = showScene ? e.requestId : undefined
173 const room = cols - MODE_LABEL_COLS - (showScene ? SCENE_COLS + 2 : 0)
174
175 return (
176 <Box flexDirection="row" gap={2}>
177 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
178 {statsGrid(el, s, columnsFor(room), Date.now())}
179 <Text dimColor wrap="truncate">{e.props.hint}</Text>
180 </Box>
181 {showScene ? (
182 <Image key="scene" source={frame()} columns={SCENE_COLS} rows={SCENE_ROWS} alt={`clawd ${mood}`} />
183 ) : null}
184 </Box>
185 )
186 })
187}
188hooks/dash.tsx 144 lines1// The stats grid drawn under the prompt: limits, session, and repo columns.
2import type { Elements } from 'claude-code'
3
4import type { Limit, Stats } from '../types'
5import { COLOR, effortColor, elapsed, gauge, level, modelName, tokens, until, usd } from './format'
6
7type El = Elements['terminal']
8
9// Narrowest each column may get; the columns share any extra width evenly.
10const LIMITS_W = 40
11const SESSION_W = 32
12const REPO_W = 30
13const SEP_W = 3
14const BAR_CELLS = 14
15const BRANCH_MAX = 22
16
17// How many stat columns fit in `cols` terminal columns.
18export function columnsFor(cols: number) {
19 if (cols >= LIMITS_W + SESSION_W + REPO_W + SEP_W * 2) return 3
20 if (cols >= LIMITS_W + SESSION_W + SEP_W) return 2
21 return 1
22}
23
24export function statsGrid({ Box, Text }: El, s: Stats, columns: number, now: number) {
25 const row = (key: string, ...parts: (JSX.Element | null)[]) => (
26 <Box key={key} gap={1} flexWrap="nowrap" overflow="hidden">{parts}</Box>
27 )
28 const sep = (key: string) => (
29 <Box key={key} flexDirection="column" width={SEP_W} alignItems="center">
30 <Text color={COLOR.faint}>│</Text>
31 <Text color={COLOR.faint}>│</Text>
32 <Text color={COLOR.faint}>│</Text>
33 </Box>
34 )
35 const dot = <Text color={COLOR.faint}>·</Text>
36
37 const meter = (label: string, labelColor: string, p: number, extra: JSX.Element | null) => {
38 const g = gauge(p, BAR_CELLS)
39 const c = level(p)
40 return row(
41 label,
42 <Text bold color={labelColor}>{label.padEnd(7)}</Text>,
43 <Box>
44 {g.full ? <Text color={c}>{g.full}</Text> : null}
45 {g.part ? <Text color={c} backgroundColor={COLOR.track}>{g.part}</Text> : null}
46 {g.rest ? <Text backgroundColor={COLOR.track}>{g.rest}</Text> : null}
47 </Box>,
48 <Text bold color={COLOR.text}>{`${Math.round(p)}%`.padStart(4)}</Text>,
49 extra,
50 )
51 }
52
53 const limit = (label: string, labelColor: string, l: Limit | undefined) =>
54 l
55 ? meter(label, labelColor, l.percent, l.resetsAt ? (
56 <Box gap={1}>
57 <Text color={COLOR.gold}>↻</Text>
58 <Text color={COLOR.soft}>{until(l.resetsAt, now)}</Text>
59 </Box>
60 ) : null)
61 : row(label, <Text bold color={labelColor}>{label.padEnd(7)}</Text>, <Text color={COLOR.dim}>waiting for first reading</Text>)
62
63 const ctx = s.contextPercent ?? 0
64 const limits = (
65 <Box key="limits" flexDirection="column" minWidth={LIMITS_W} flexGrow={1} flexShrink={0}>
66 {limit('5h', COLOR.sky, s.fiveHour)}
67 {limit('7d', COLOR.violet, s.sevenDay)}
68 {meter('context', COLOR.teal, ctx, s.contextTokens !== undefined && s.contextWindow ? (
69 <Box>
70 <Text color={COLOR.teal}>{tokens(s.contextTokens)}</Text>
71 <Text color={COLOR.dim}>{`/${tokens(s.contextWindow)}`}</Text>
72 </Box>
73 ) : null)}
74 </Box>
75 )
76
77 const session = (
78 <Box key="session" flexDirection="column" minWidth={SESSION_W} flexGrow={1} flexShrink={0}>
79 {row(
80 'model',
81 <Text bold color={COLOR.accent}>{s.model ? modelName(s.model) : '…'}</Text>,
82 s.effort ? dot : null,
83 s.effort ? <Text bold color={effortColor(s.effort)}>{s.effort}</Text> : null,
84 )}
85 {row(
86 'time',
87 <Text bold color={COLOR.gold}>{usd(s.costUsd ?? 0)}</Text>,
88 dot,
89 <Text color={COLOR.dim}>up</Text>,
90 <Text color={COLOR.sky}>{s.startedAt ? elapsed(now - s.startedAt) : '…'}</Text>,
91 s.lastTurnMs !== undefined ? dot : null,
92 s.lastTurnMs !== undefined ? <Text color={COLOR.dim}>last</Text> : null,
93 s.lastTurnMs !== undefined ? <Text color={COLOR.rose}>{elapsed(s.lastTurnMs)}</Text> : null,
94 )}
95 {row(
96 'counts',
97 <Text bold color={COLOR.violet}>{String(s.prompts ?? 0)}</Text>,
98 <Text color={COLOR.dim}>{s.prompts === 1 ? 'prompt' : 'prompts'}</Text>,
99 dot,
100 <Text bold color={COLOR.teal}>{String(s.tools)}</Text>,
101 <Text color={COLOR.dim}>{s.tools === 1 ? 'tool' : 'tools'}</Text>,
102 )}
103 </Box>
104 )
105
106 const git = s.git
107 const branch = git && git.branch.length > BRANCH_MAX ? `${git.branch.slice(0, BRANCH_MAX - 1)}…` : git?.branch
108 const fileCount = s.files.length
109 const repo = (
110 <Box key="repo" flexDirection="column" minWidth={REPO_W} flexGrow={1} flexShrink={0}>
111 {git
112 ? row(
113 'git',
114 <Text color={COLOR.violet}>⎇</Text>,
115 <Text bold color={COLOR.violet}>{branch}</Text>,
116 git.ahead ? <Text color={COLOR.ok}>{`↑${git.ahead}`}</Text> : null,
117 git.behind ? <Text color={COLOR.warn}>{`↓${git.behind}`}</Text> : null,
118 git.dirty
119 ? <Text color={COLOR.warn}>{`●${git.dirty}`}</Text>
120 : <Text color={COLOR.ok}>✓</Text>,
121 )
122 : row('git', <Text color={COLOR.dim}>not a git repo</Text>)}
123 {row(
124 'edits',
125 <Text bold color={COLOR.ok}>{`+${s.added}`}</Text>,
126 <Text bold color={COLOR.bad}>{`−${s.removed}`}</Text>,
127 dot,
128 <Text color={COLOR.soft}>{`${fileCount} ${fileCount === 1 ? 'file' : 'files'}`}</Text>,
129 )}
130 {row('version', <Text color={COLOR.dim}>{s.version ? `claude code v${s.version}` : ''}</Text>)}
131 </Box>
132 )
133
134 return (
135 <Box flexDirection="row" flexGrow={1} flexShrink={1}>
136 {limits}
137 {columns >= 2 ? sep('sep1') : null}
138 {columns >= 2 ? session : null}
139 {columns >= 3 ? sep('sep2') : null}
140 {columns >= 3 ? repo : null}
141 </Box>
142 )
143}
144hooks/git.ts 28 lines1// Branch, ahead/behind and dirty count from one `git status --porcelain --branch`.
2import type { Git } from '../types'
3
4export function parseStatus(out: string): Git {
5 const [head = '', ...rest] = out.split('\n')
6 const line = head.replace(/^## /, '')
7 const branch = line.match(/^(?:No commits yet|Initial commit) on (.+)$/)?.[1]
8 ?? (line.startsWith('HEAD (no branch)') ? 'detached' : line.split('...')[0]!.split(' ')[0]!)
9 return {
10 branch,
11 ahead: Number(line.match(/ahead (\d+)/)?.[1] ?? 0),
12 behind: Number(line.match(/behind (\d+)/)?.[1] ?? 0),
13 dirty: rest.filter(l => l.trim()).length,
14 }
15}
16
17type Run = (argv: string[]) => Promise<{ exitCode: number | null; stdout: string }>
18
19// null outside a repository, or when git can't run here.
20export async function readGit(run: Run): Promise<Git | null> {
21 try {
22 const r = await run(['git', 'status', '--porcelain=v1', '--branch'])
23 return r.exitCode === 0 ? parseStatus(r.stdout) : null
24 } catch {
25 return null
26 }
27}
28hooks/raster.ts 91 lines1// Turns one frame from scene.ts (an SVG of <g>/<rect> only) into RGBA pixels,
2// since the terminal draws an Image, not an Svg. Supports exactly what the scene
3// emits: translate/scale transforms, group opacity, and solid #hex fills.
4
5type Mat = { a: number; d: number; e: number; f: number }
6type Ctx = { m: Mat; o: number }
7
8const TAG = /<(\/?)(\w+)([^>]*?)\/?>/g
9const ATTR = /([\w-]+)="([^"]*)"/g
10const STEP = /(translate|scale)\(([^)]*)\)/g
11
12function attrs(src: string): Record<string, string> {
13 const out: Record<string, string> = {}
14 for (const [, k, v] of src.matchAll(ATTR)) out[k!] = v!
15 return out
16}
17
18function compose(m: Mat, transform: string | undefined): Mat {
19 if (!transform) return m
20 let r = m
21 for (const [, kind, args] of transform.matchAll(STEP)) {
22 const [x = 0, y] = args!.trim().split(/[\s,]+/).map(Number)
23 if (kind === 'translate') r = { ...r, e: r.a * x + r.e, f: r.d * (y ?? 0) + r.f }
24 else r = { ...r, a: r.a * x, d: r.d * (y ?? x) }
25 }
26 return r
27}
28
29function hex(c: string): [number, number, number] {
30 const s = c.replace('#', '')
31 const full = s.length === 3 ? s.split('').map(ch => ch + ch).join('') : s
32 const n = parseInt(full, 16)
33 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
34}
35
36/**
37 * Rasterizes `svg` (viewBox 0 0 w h) into RGBA, keeping columns [cropX, w),
38 * each art pixel blown up to `scale` x `scale` so the terminal scales crisply.
39 */
40export function rasterize(svg: string, w: number, h: number, cropX: number, scale: number) {
41 const rgb = new Float32Array(w * h * 3)
42 const stack: Ctx[] = [{ m: { a: 1, d: 1, e: 0, f: 0 }, o: 1 }]
43
44 for (const [, close, name, rest] of svg.matchAll(TAG)) {
45 const top = stack[stack.length - 1]!
46 if (name === 'g') {
47 if (close) stack.pop()
48 else {
49 const at = attrs(rest!)
50 stack.push({ m: compose(top.m, at.transform), o: top.o * (at.opacity ? Number(at.opacity) : 1) })
51 }
52 continue
53 }
54 if (name !== 'rect') continue
55
56 const at = attrs(rest!)
57 const x = Number(at.x), y = Number(at.y), rw = Number(at.width), rh = Number(at.height)
58 const { a, d, e, f } = top.m
59 const xs = [a * x + e, a * (x + rw) + e], ys = [d * y + f, d * (y + rh) + f]
60 const x0 = Math.max(0, Math.round(Math.min(...xs))), x1 = Math.min(w, Math.round(Math.max(...xs)))
61 const y0 = Math.max(0, Math.round(Math.min(...ys))), y1 = Math.min(h, Math.round(Math.max(...ys)))
62 if (x0 >= x1 || y0 >= y1 || top.o <= 0) continue
63
64 const [r, g, b] = hex(at.fill ?? '#000')
65 const o = Math.min(1, top.o)
66 for (let py = y0; py < y1; py++) {
67 for (let px = x0; px < x1; px++) {
68 const i = (py * w + px) * 3
69 rgb[i] = r * o + rgb[i]! * (1 - o)
70 rgb[i + 1] = g * o + rgb[i + 1]! * (1 - o)
71 rgb[i + 2] = b * o + rgb[i + 2]! * (1 - o)
72 }
73 }
74 }
75
76 const ow = (w - cropX) * scale
77 const oh = h * scale
78 const out = new Uint8Array(ow * oh * 4)
79 for (let oy = 0; oy < oh; oy++) {
80 for (let ox = 0; ox < ow; ox++) {
81 const i = (Math.floor(oy / scale) * w + cropX + Math.floor(ox / scale)) * 3
82 const j = (oy * ow + ox) * 4
83 out[j] = rgb[i]!
84 out[j + 1] = rgb[i + 1]!
85 out[j + 2] = rgb[i + 2]!
86 out[j + 3] = 255
87 }
88 }
89 return { rgba: out, width: ow, height: oh }
90}
91hooks/scene.ts 598 lines1// Pixel-art scenes for the mascot. Each call draws ONE still frame of a mood at
2// time `t` (seconds since the mood began): every pixel is a <rect> in a 100x18 grid
3// drawn at 3x, and the motion comes from keyframe tracks evaluated here, so a
4// redraw of the band can never restart the animation.
5
6import type { Mood } from '../types'
7
8// The band's own color, painted behind the scene.
9export const BAND_BG = '#212121'
10
11const W = 100
12const H = 18
13
14const PAL: Record<string, string> = {
15 o: '#d97757', // claude orange
16 O: '#b65a3c', // orange shade
17 e: '#1a1a1a', // eyes
18 K: '#262626',
19 k: '#121212',
20 w: '#f4efe4',
21 y: '#f7dc6f',
22 Y: '#e8a33d',
23 t: '#c9a46a',
24 b: '#8a5a2b',
25 B: '#5b3a1c',
26 g: '#8a8a8a',
27 G: '#4a4a4a',
28 c: '#7fd6e8',
29 C: '#9fd8f0',
30 n: '#5fbf6a',
31 N: '#2f7d3a',
32 r: '#e5534b',
33 R: '#a8322c',
34 p: '#f08fb0',
35 u: '#5a8fe6',
36 U: '#2d4f9e',
37 V: '#1b2c5c',
38 l: '#c3c7cf',
39 L: '#7d838c',
40 P: '#efe8d6',
41 s: '#3a2a52',
42 m: '#7d3b63',
43 d: '#d9673f',
44 f: '#f2a14a',
45 h: '#ffd27a',
46 v: '#9b6be0',
47}
48
49const fill = (c: string) => PAL[c] ?? c
50const rect = (x: number, y: number, w: number, h: number, c: string) =>
51 `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill(c)}"/>`
52
53// An ASCII sprite: one char per pixel, '.' is transparent; runs merge into one rect.
54function spr(rows: string[], x = 0, y = 0): string {
55 let out = ''
56 rows.forEach((row, j) => {
57 let i = 0
58 while (i < row.length) {
59 const c = row[i]
60 if (c === '.' || c === ' ') {
61 i++
62 continue
63 }
64 let k = i
65 while (k < row.length && row[k] === c) k++
66 out += rect(x + i, y + j, k - i, 1, c)
67 i = k
68 }
69 })
70 return out
71}
72
73// Deterministic noise so the art is the same on every frame.
74const rnd = (n: number) => {
75 const s = Math.sin(n * 127.1 + 311.7) * 43758.5453
76 return s - Math.floor(s)
77}
78
79// ---------- keyframe tracks ----------
80
81type Pose = { tx?: number; ty?: number; o?: number; sx?: number; sy?: number }
82type Track = [number, Pose][]
83const BASE: Required<Pose> = { tx: 0, ty: 0, o: 1, sx: 1, sy: 1 }
84
85const KF: Record<string, Track> = {
86 bob: [[0, { ty: 0 }], [50, { ty: -1 }], [100, { ty: 0 }]],
87 hop: [[0, { ty: 0 }], [50, { ty: -3 }], [100, { ty: 0 }]],
88 wave: [[0, { ty: 0 }], [50, { ty: -2 }], [100, { ty: 0 }]],
89 blink: [[0, { o: 1 }], [92, { o: 1 }], [94, { o: 0 }], [97, { o: 0 }], [100, { o: 1 }]],
90 sweep: [[0, { tx: 0 }], [50, { tx: -2 }], [100, { tx: 0 }]],
91 dust: [[0, { tx: 0, ty: 0, o: 1 }], [100, { tx: -9, ty: -3, o: 0 }]],
92 tw: [[0, { o: 0 }], [30, { o: 1 }], [60, { o: 1 }], [100, { o: 0 }]],
93 rise: [[0, { tx: 0, ty: 0, o: 0 }], [15, { o: 1 }], [100, { tx: 0, ty: -10, o: 0 }]],
94 riseR: [[0, { tx: 0, ty: 0, o: 0 }], [15, { o: 1 }], [100, { tx: 4, ty: -9, o: 0 }]],
95 walk: [[0, { tx: -36 }], [45, { tx: 0 }], [50, { tx: 0 }], [95, { tx: -36 }], [100, { tx: -36 }]],
96 look: [[0, { tx: 1 }], [50, { tx: -1 }], [100, { tx: -1 }]],
97 scan: [[0, { tx: -1 }], [50, { tx: 1 }], [100, { tx: -1 }]],
98 grow: [[0, { sx: 0 }], [70, { sx: 1 }], [100, { sx: 1 }]],
99 growY: [[0, { sy: 0 }], [60, { sy: 1 }], [100, { sy: 1 }]],
100 pull: [[0, { ty: 9 }], [12, { ty: 0 }], [100, { ty: 0 }]],
101 fly: [[0, { tx: 0, ty: 0, o: 1 }], [100, { tx: -14, ty: -12, o: 0 }]],
102 dispatch: [[0, { tx: 0, o: 0 }], [6, { o: 1 }], [88, { o: 1 }], [100, { tx: -48, o: 0 }]],
103 fall: [[0, { tx: 0, ty: -4 }], [50, { tx: 2, ty: 10 }], [100, { tx: -1, ty: 24 }]],
104 dance: [
105 [0, { tx: 0, ty: 0 }], [12, { tx: 0, ty: -4 }], [25, { tx: 0, ty: 0 }], [37, { tx: 0, ty: -4 }], [50, { tx: 0, ty: 0 }],
106 [62, { tx: -2, ty: -1 }], [75, { tx: 2, ty: 0 }], [87, { tx: -2, ty: -1 }], [100, { tx: 0, ty: 0 }],
107 ],
108 sink: [[0, { ty: 0 }], [100, { ty: 3 }]],
109 swing: [[0, { tx: -1 }], [50, { tx: 1 }], [100, { tx: -1 }]],
110 on: [[0, { o: 1 }], [50, { o: 0 }], [100, { o: 0 }]],
111 off: [[0, { o: 0 }], [50, { o: 1 }], [100, { o: 1 }]],
112 leap: [
113 [0, { tx: 0, ty: 0, o: 0 }], [78, { tx: 0, ty: 0, o: 0 }], [82, { tx: -2, ty: -4, o: 1 }],
114 [88, { tx: -4, ty: -5, o: 1 }], [94, { tx: -6, ty: 0, o: 0 }], [100, { tx: 0, ty: 0, o: 0 }],
115 ],
116 drift: [[0, { tx: 0, ty: 0 }], [100, { tx: -70, ty: -2 }]],
117 zoom: [[0, { tx: 0 }], [100, { tx: -72 }]],
118 flip: [[0, { sx: 1 }], [60, { sx: 1 }], [70, { sx: 0.5 }], [80, { sx: -0.5 }], [90, { sx: -1 }], [100, { sx: -1 }]],
119 carry: [[0, { tx: 0 }], [8, { tx: 0 }], [45, { tx: -22 }], [55, { tx: -22 }], [92, { tx: 0 }], [100, { tx: 0 }]],
120 pace: [[0, { tx: 0 }], [50, { tx: -6 }], [100, { tx: 0 }]],
121 magscan: [[0, { tx: 0, ty: 0 }], [25, { tx: -14, ty: -2 }], [50, { tx: -8, ty: 3 }], [75, { tx: -18, ty: 2 }], [100, { tx: 0, ty: 0 }]],
122 dash: [[0, { tx: 0 }], [100, { tx: -8 }]],
123 flipfood: [[0, { ty: 0 }], [70, { ty: 0 }], [80, { ty: -3 }], [90, { ty: -2 }], [100, { ty: 0 }]],
124 vis2: [[0, { o: 1 }], [50, { o: 0 }], [100, { o: 0 }]],
125}
126const visTrack = (n: number): Track => [[0, { o: 1 }], [100 / n, { o: 0 }], [100, { o: 0 }]]
127
128type Timing = { kind: 'step' } | { kind: 'linear' } | { kind: 'steps'; n: number }
129type Anim = { track: Track; dur: number; delay: number; timing: Timing; count: number; fwd: boolean; back: boolean }
130
131const animCache = new Map<string, Anim>()
132// Reads a CSS-style shorthand: "bob 1s step-end infinite -0.5s", "fall 2s steps(12) 0.4s 2 both".
133function parseAnim(def: string): Anim {
134 const hit = animCache.get(def)
135 if (hit) return hit
136 const parts = def.trim().split(/\s+/)
137 const name = parts[0]
138 const track = KF[name] ?? (name.startsWith('vis') ? visTrack(Number(name.slice(3))) : [[0, {}], [100, {}]] as Track)
139 const times: number[] = []
140 let timing: Timing = { kind: 'linear' }
141 let count = 1
142 let fwd = false
143 let back = false
144 for (const p of parts.slice(1)) {
145 if (/^-?[\d.]+s$/.test(p)) times.push(parseFloat(p))
146 else if (p === 'step-end') timing = { kind: 'step' }
147 else if (p === 'linear') timing = { kind: 'linear' }
148 else if (p.startsWith('steps(')) timing = { kind: 'steps', n: parseInt(p.slice(6), 10) }
149 else if (p === 'infinite') count = Infinity
150 else if (/^\d+$/.test(p)) count = Number(p)
151 else if (p === 'forwards') fwd = true
152 else if (p === 'backwards') back = true
153 else if (p === 'both') fwd = back = true
154 }
155 const a: Anim = { track, dur: times[0] ?? 1, delay: times[1] ?? 0, timing, count, fwd, back }
156 animCache.set(def, a)
157 return a
158}
159
160function sampleProp(track: Track, key: keyof Pose, pct: number, timing: Timing): number {
161 const pts = track.filter(([, p]) => p[key] !== undefined).map(([at, p]) => [at, p[key] as number] as const)
162 if (!pts.length) return BASE[key]
163 if (pts[0][0] > 0) pts.unshift([0, BASE[key]])
164 if (pts[pts.length - 1][0] < 100) pts.push([100, BASE[key]])
165 for (let i = 0; i < pts.length - 1; i++) {
166 const [a, va] = pts[i]
167 const [b, vb] = pts[i + 1]
168 if (pct >= a && pct < b) {
169 let f = (pct - a) / (b - a)
170 if (timing.kind === 'step') f = 0
171 else if (timing.kind === 'steps') f = Math.floor(f * timing.n) / timing.n
172 return va + (vb - va) * f
173 }
174 }
175 return pts[pts.length - 1][1]
176}
177
178// The pose of an animation at time t, or null where it has no effect (before or after its run).
179function poseAt(def: string, t: number): Required<Pose> | null {
180 const a = parseAnim(def)
181 const el = t - a.delay
182 let pct: number
183 if (el < 0) {
184 if (!a.back) return null
185 pct = 0
186 } else if (el >= a.dur * a.count) {
187 if (!a.fwd) return null
188 pct = 100
189 } else {
190 pct = ((el % a.dur) / a.dur) * 100
191 }
192 const keys: (keyof Pose)[] = ['tx', 'ty', 'o', 'sx', 'sy']
193 const out = { ...BASE }
194 for (const k of keys) out[k] = pct === 100 ? lastValue(a.track, k) : sampleProp(a.track, k, pct, a.timing)
195 return out
196}
197function lastValue(track: Track, key: keyof Pose): number {
198 for (let i = track.length - 1; i >= 0; i--) if (track[i][1][key] !== undefined) return track[i][1][key] as number
199 return BASE[key]
200}
201
202const n2 = (v: number) => Math.round(v * 100) / 100
203
204class Frame {
205 readonly t: number
206 constructor(t: number) {
207 this.t = t
208 }
209 // A group posed by `anim` at this frame, placed at (x, y); `origin` is where it scales from.
210 g(anim: string | null, inner: string, x = 0, y = 0, origin: [number, number] = [0, 0]): string {
211 let body = inner
212 if (anim) {
213 const p = poseAt(anim, this.t)
214 if (p) {
215 if (p.o <= 0.02) return ''
216 const tf: string[] = []
217 if (p.tx || p.ty) tf.push(`translate(${n2(p.tx)} ${n2(p.ty)})`)
218 if (p.sx !== 1 || p.sy !== 1) {
219 tf.push(`translate(${origin[0]} ${origin[1]}) scale(${n2(p.sx) || 0.001} ${n2(p.sy) || 0.001}) translate(${-origin[0]} ${-origin[1]})`)
220 }
221 const attrs = (tf.length ? ` transform="${tf.join(' ')}"` : '') + (p.o < 1 ? ` opacity="${n2(p.o)}"` : '')
222 if (attrs) body = `<g${attrs}>${inner}</g>`
223 }
224 }
225 return x || y ? `<g transform="translate(${x} ${y})">${body}</g>` : body
226 }
227 // Shows sub-scene i of n for its slice of a T-second cycle, building only the one on screen.
228 cycle(scenes: (() => string)[], T: number): string {
229 const n = scenes.length
230 const i = Math.floor((((this.t % T) + T) % T) / (T / n))
231 return scenes[i]()
232 }
233}
234
235// ---------- the mascot ----------
236
237type Eyes = 'normal' | 'look' | 'lookL' | 'lookR' | 'down' | 'focus' | 'happy' | 'shades' | 'closed' | 'scan'
238type ClawdOpts = {
239 eyes?: Eyes
240 bob?: string | null
241 armL?: string | null
242 armR?: string | null
243 armLUp?: boolean
244 armRUp?: boolean
245 walk?: boolean
246 sit?: boolean
247 extra?: string
248}
249
250function eyes(f: Frame, kind: Eyes): string {
251 const pair = rect(4, 1, 1, 2, 'e') + rect(11, 1, 1, 2, 'e')
252 switch (kind) {
253 case 'normal':
254 return f.g('blink 4s step-end infinite', pair)
255 case 'look':
256 return f.g('look 8s step-end infinite', pair)
257 case 'scan':
258 return f.g('scan .9s step-end infinite', pair)
259 case 'lookL':
260 return f.g('blink 3.5s step-end infinite', rect(3, 1, 1, 2, 'e') + rect(10, 1, 1, 2, 'e'))
261 case 'lookR':
262 return f.g('blink 3.1s step-end infinite', rect(5, 1, 1, 2, 'e') + rect(12, 1, 1, 2, 'e'))
263 case 'down':
264 return rect(3, 2, 1, 2, 'e') + rect(10, 2, 1, 2, 'e')
265 case 'focus':
266 return rect(3, 2, 2, 1, 'e') + rect(10, 2, 2, 1, 'e') + rect(3, 1, 2, 1, 'O') + rect(10, 1, 2, 1, 'O')
267 case 'happy':
268 return spr(['.e......e.', 'e.e....e.e'], 3, 1)
269 case 'closed':
270 return rect(3, 2, 2, 1, 'e') + rect(10, 2, 2, 1, 'e')
271 case 'shades':
272 return spr(['kkkkk.kkkkk', 'kkkkkkkkkkk', '.kkk...kkk.'], 2, 1) + rect(3, 1, 1, 1, 'w') + rect(9, 1, 1, 1, 'w')
273 }
274}
275
276// 16x8: body 12x6, arms 2x2 at the sides, four 1x2 legs.
277function clawd(f: Frame, x: number, y: number, o: ClawdOpts = {}): string {
278 const body = rect(2, 0, 12, 6, 'o') + rect(2, 5, 12, 1, 'O')
279 const armL = f.g(o.armL ?? null, o.armLUp ? rect(0, 0, 2, 2, 'o') + rect(1, 2, 1, 1, 'o') : rect(0, 2, 2, 2, 'o'))
280 const armR = f.g(o.armR ?? null, o.armRUp ? rect(14, 0, 2, 2, 'o') + rect(14, 2, 1, 1, 'o') : rect(14, 2, 2, 2, 'o'))
281 let legs = ''
282 if (!o.sit) {
283 const a = rect(3, 6, 1, 2, 'o') + rect(10, 6, 1, 2, 'o')
284 const c = rect(5, 6, 1, 2, 'o') + rect(12, 6, 1, 2, 'o')
285 legs = o.walk ? f.g('bob .3s step-end infinite', a) + f.g('bob .3s step-end infinite -.15s', c) : a + c
286 }
287 return f.g(o.bob ?? null, legs + body + armL + armR + eyes(f, o.eyes ?? 'normal') + (o.extra ?? ''), x, y)
288}
289
290// 8x5 helper mascot.
291function mini(f: Frame, x: number, y: number, carry = '', walk = true): string {
292 const body = rect(1, 0, 6, 3, 'o') + rect(0, 1, 1, 1, 'o') + rect(7, 1, 1, 1, 'o') + rect(2, 1, 1, 1, 'e') + rect(5, 1, 1, 1, 'e')
293 const la = rect(2, 3, 1, 2, 'o')
294 const lb = rect(5, 3, 1, 2, 'o')
295 const legs = walk ? f.g('bob .3s step-end infinite', la) + f.g('bob .3s step-end infinite -.15s', lb) : la + lb
296 return `<g transform="translate(${x} ${y})">${legs}${body}${carry}</g>`
297}
298
299const SPARK = ['.y.', 'yyy', '.y.']
300const sparkle = (f: Frame, x: number, y: number, d: number, dur = 1.6) => f.g(`tw ${dur}s step-end infinite ${-d}s`, spr(SPARK), x, y)
301
302const BANG = ['.wwww.', 'ww.www', 'ww.www', 'wwwwww', 'ww.www', '.wwww.', '.w....']
303const BOX = ['BbbbB', 'bbYbb', 'bbbbb', 'BbbbB']
304const SUN = ['..hhhh..', '.hhhhhh.', 'hhyyyyhh', 'hyyyyyyh', 'hyyyyyyh']
305
306// A horizontally banded backdrop for the right of the band, its left edge dithered.
307function backdrop(bands: [string, number][], x0 = 30): string {
308 let out = ''
309 let y = 0
310 for (const [c, h] of bands) {
311 for (let j = 0; j < h; j++, y++) {
312 out += rect(x0 + 4, y, W - x0 - 4, 1, c)
313 for (let dx = 0; dx < 4; dx++) {
314 const xx = x0 + dx
315 const on = dx === 3 ? (xx + y) % 4 !== 1 : dx === 2 ? (xx + y) % 2 === 0 : dx === 1 ? (xx + y) % 4 === 0 : false
316 if (on) out += rect(xx, y, 1, 1, c)
317 }
318 }
319 }
320 return out
321}
322
323// The dotted field that fades in from the left, as in the band's art.
324function dots(f: Frame): string {
325 let out = ''
326 for (let x = 0; x < W; x += 2) {
327 for (let y = 1; y < H; y += 2) {
328 const r = rnd(x * 31 + y)
329 const p = Math.pow(x / W, 1.6) * 0.6
330 if (r < p) {
331 out += r < p * 0.12
332 ? f.g(`tw ${(2 + rnd(x + y * 7) * 3).toFixed(2)}s step-end infinite ${(-rnd(x * y + 1) * 4).toFixed(2)}s`, rect(x, y, 1, 1, '#555'))
333 : rect(x, y, 1, 1, r < p * 0.35 ? '#3c3c3c' : '#2c2c2c')
334 }
335 }
336 }
337 return out
338}
339
340const stars = (f: Frame, n: number, seed: number) =>
341 Array.from({ length: n }, (_, i) => {
342 const x = 36 + Math.floor(rnd(seed + i * 4.3) * 62)
343 const y = Math.floor(rnd(seed + i * 8.9) * 8)
344 return f.g(`tw ${(1.5 + rnd(i + seed) * 2).toFixed(2)}s step-end infinite -${(rnd(i * 2 + seed) * 2).toFixed(2)}s`, rect(x, y, 1, 1, 'w'))
345 }).join('')
346
347const palm = (x: number) =>
348 rect(x, 4, 2, 14, 'b') + rect(x, 6, 2, 1, 'B') + rect(x, 10, 2, 1, 'B') + spr(['.nnn.nnn.', 'nnNnnnNnn', 'n..nNn..n', '...yny...'], x - 3, 1)
349
350// ---------- idle: sweeping, walking, phone, chatting, reading ----------
351
352function idle(f: Frame): string {
353 const sweeping = () =>
354 clawd(f, 60, 10, { bob: 'bob 1.2s step-end infinite' }) +
355 f.g('sweep .6s step-end infinite', rect(76, 4, 1, 11, 't') + rect(75, 14, 4, 1, 'B') + spr(['YYYYY', 'YyYyY', 'Y.Y.Y'], 74, 15)) +
356 [0, 0.4, 0.8].map((d, i) => f.g(`dust 1.2s linear infinite ${-d}s`, rect(0, 0, 1 + (i % 2), 1, 'g'), 73, 16 - i)).join('') +
357 sparkle(f, 86, 3, 0) + sparkle(f, 92, 9, 0.6) + sparkle(f, 83, 12, 1.1) +
358 f.g('tw 1.2s step-end infinite -.3s', spr(['yyy', '.y.']), 90, 1)
359
360 const walking = () => {
361 const butterfly = f.g('on .3s step-end infinite', spr(['v.v', '.k.'])) + f.g('off .3s step-end infinite', spr(['...', 'vkv']))
362 return (
363 f.g('walk 8s steps(18) infinite', clawd(f, 70, 10, { walk: true, eyes: 'look' })) +
364 f.g('walk 8s steps(18) infinite -.5s', f.g('bob .9s step-end infinite', butterfly), 82, 4)
365 )
366 }
367
368 const phone = () =>
369 clawd(f, 66, 10, { eyes: 'down', bob: 'bob 2.4s step-end infinite' }) +
370 spr(['KKKK', 'KccK', 'KccK', 'KccK', 'KKKK'], 62, 9) +
371 f.g('on .6s step-end infinite', rect(63, 10, 2, 1, 'w') + rect(63, 12, 1, 1, 'w')) +
372 f.g('off .6s step-end infinite', rect(63, 11, 2, 1, 'w') + rect(64, 13, 1, 1, 'w')) +
373 f.g('rise 2.4s linear infinite', spr(['p.p', 'ppp', '.p.']), 60, 6) +
374 f.g('rise 2.4s linear infinite -1.2s', spr(['y', 'y', '.', 'y']), 64, 5) +
375 f.g('rise 2.4s linear infinite -.6s', spr(['nnn', 'n.n', 'nnn']), 56, 7)
376
377 const chatting = () =>
378 clawd(f, 40, 10, { eyes: 'lookR', bob: 'bob .6s step-end infinite', armR: 'wave 1.2s step-end infinite' }) +
379 clawd(f, 64, 10, { eyes: 'lookL', bob: 'bob .6s step-end infinite -.3s', armL: 'wave 1.2s step-end infinite -.6s' }) +
380 f.g('on 2.4s step-end infinite', spr(['.wwwwwww.', 'wwwwwwwww', 'wwKwKwKww', 'wwwwwwwww', '.wwwwwww.', '..ww.....', '..w......'], 48, 1)) +
381 f.g('off 2.4s step-end infinite', spr(['.wwwwwww.', 'wwwpwpwww', 'wwwpppwww', 'wwwwpwwww', '.wwwwwww.', '.....ww..', '......w..'], 60, 1)) +
382 f.g('tw 4.8s step-end infinite -2s', spr(['y.y', '.y.', 'y.y']), 58, 9)
383
384 const reading = () =>
385 clawd(f, 64, 10, { eyes: 'down', bob: 'bob 3s step-end infinite' }) +
386 spr(['UwwwwUwwwwU', 'UwgggUwgggU', 'UwwwwUwwwwU', 'UwgggUwggwU', 'UUUUUUUUUUU'], 55, 11) +
387 f.g('flip 3s step-end infinite', spr(['wwww', 'wggw', 'wwww', 'wgww']), 61, 11) +
388 f.g('tw 3s step-end infinite -1s', spr(['.y.', 'yyy', 'yyy', '.w.']), 80, 3)
389
390 return f.cycle([sweeping, walking, phone, chatting, reading], 40)
391}
392
393// ---------- working: laptop, papers, delegating, boxes, office, analysis ----------
394
395function working(f: Frame): string {
396 const typing = () => {
397 const lines = [4, 6, 3, 5]
398 .map((w, i) => f.g(`grow 1.6s steps(${w}) infinite ${-i * 0.4}s`, rect(54, 9 + i, w, 1, i % 2 ? 'c' : 'n'), 0, 0, [54, 9 + i]))
399 .join('')
400 const laptop = f.g('pull 6s steps(3) infinite', rect(52, 8, 10, 6, 'L') + rect(53, 8, 8, 5, 'k') + lines + rect(50, 14, 14, 1, 'l') + rect(51, 15, 12, 1, 'L'))
401 const bits = [0, 0.5, 1, 1.5].map((d, i) => f.g(`rise 2s linear infinite ${-d}s`, i % 2 ? spr(['n', 'n', 'n']) : spr(['nn', 'nn', 'nn']), 54 + i * 2, 6)).join('')
402 return laptop + bits + clawd(f, 64, 10, { eyes: 'focus', bob: 'bob .6s step-end infinite', armL: 'bob .3s step-end infinite', armR: 'bob .3s step-end infinite -.15s' })
403 }
404
405 const papers = () => {
406 const sheet = spr(['PPP', 'PgP', 'PPP', 'PgP'])
407 return (
408 spr(['PPPPPP', 'PggggP', 'PPPPPP', 'PgggPP', 'PPPPPP', 'PggggP'], 50, 12) +
409 [0, 0.5, 1].map(d => f.g(`fly 1.5s steps(7) infinite ${-d}s`, sheet, 52, 10)).join('') +
410 clawd(f, 66, 10, { eyes: 'scan' }) +
411 f.g('bob 1.2s step-end infinite', spr(['PPPP', 'PggP', 'PPPP', 'PgPP', 'PPPP']), 63, 11) +
412 f.g('tw 2s step-end infinite', spr(['y', 'y', '.', 'y']), 72, 5)
413 )
414 }
415
416 const delegate = () => {
417 const box = rect(2, -2, 4, 2, 'b') + rect(3, -2, 2, 1, 'Y')
418 const doc = rect(2, -3, 3, 3, 'P') + rect(3, -2, 1, 1, 'g')
419 return (
420 clawd(f, 74, 10, { armLUp: true, armL: 'wave .6s step-end infinite' }) +
421 spr(BANG, 66, 2) +
422 [0, 2, 4].map((d, i) => f.g(`dispatch 6s steps(24) infinite ${-d}s`, mini(f, 0, 0, i === 1 ? doc : box), 66, 13)).join('')
423 )
424 }
425
426 const boxes = () =>
427 spr(BOX, 32, 14) + spr(BOX, 38, 14) + spr(BOX, 35, 10) + spr(BOX, 86, 14) + spr(BOX, 92, 14) +
428 f.g('off 6s step-end infinite', spr(BOX, 41, 10)) +
429 f.g('carry 6s steps(24) infinite',
430 clawd(f, 66, 10, { walk: true, armLUp: true, armRUp: true, extra: f.g('on 6s step-end infinite', spr(BOX, 5, -4)) }))
431
432 const office = () =>
433 [28, 43, 58].map((x, i) =>
434 f.g(`bob .3s step-end infinite ${-i * 0.15}s`, mini(f, x, 13, '', false)) +
435 spr(['LLLLL', 'LkkkL', 'LkkkL', 'LLLLL', '..L..'], x + 8, 9) +
436 f.g(`on .6s step-end infinite ${-i * 0.3}s`, rect(x + 9, 10, 2, 1, 'n') + rect(x + 9, 11, 3, 1, 'c')) +
437 f.g(`off .6s step-end infinite ${-i * 0.3}s`, rect(x + 9, 10, 3, 1, 'c') + rect(x + 10, 11, 2, 1, 'n')) +
438 rect(x + 7, 14, 7, 1, 'b') + rect(x + 8, 15, 1, 3, 'B') + rect(x + 12, 15, 1, 3, 'B'),
439 ).join('') +
440 f.g('pace 4s steps(6) infinite', clawd(f, 80, 10, { walk: true, armLUp: true, armL: 'wave .6s step-end infinite' })) +
441 f.g('tw 2s step-end infinite', spr(BANG), 86, 1)
442
443 const analyze = () =>
444 rect(36, 2, 22, 13, 'L') + rect(37, 3, 20, 11, 'k') + rect(45, 15, 4, 2, 'L') + rect(42, 17, 10, 1, 'L') +
445 [2, 5, 3, 7, 4, 8]
446 .map((h, i) => f.g(`growY 3s steps(${h}) infinite ${-i * 0.3}s`, rect(39 + i * 3, 13 - h, 2, h, i % 2 ? 'c' : 'n'), 0, 0, [40 + i * 3, 13]))
447 .join('') +
448 clawd(f, 68, 10, { eyes: 'focus', armLUp: true }) +
449 f.g('magscan 5s steps(10) infinite', spr(['.lll...', 'lcccl..', 'lcccl..', 'lcccl..', '.lllb..', '....bb.', '.....bb']), 58, 5) +
450 f.g('tw 5s step-end infinite', spr(['www', '..w', '.ww', '...', '.w.']), 78, 1)
451
452 return f.cycle([typing, papers, delegate, boxes, office, analyze], 36)
453}
454
455// ---------- task complete: one burst of confetti, jumping and dancing ----------
456
457export const DONE_SECONDS = 6.5
458
459function done(f: Frame): string {
460 const colors = ['r', 'y', 'c', 'n', 'p', 'v', 'f', 'u']
461 let confetti = ''
462 for (let i = 0; i < 34; i++) {
463 const x = 26 + Math.floor(rnd(i * 3.7) * 72)
464 const dur = (1.6 + rnd(i * 5.1) * 1.2).toFixed(2)
465 const d = (rnd(i * 9.3) * 1.2).toFixed(2)
466 const c = colors[i % colors.length]
467 const piece =
468 f.g(`on ${(0.3 + Math.round(rnd(i) * 2) * 0.15).toFixed(2)}s step-end infinite`, rnd(i) > 0.5 ? rect(0, 0, 1, 2, c) : rect(0, 0, 2, 1, c)) +
469 f.g(`off ${(0.3 + Math.round(rnd(i) * 2) * 0.15).toFixed(2)}s step-end infinite`, rect(0, 0, 1, 1, colors[(i + 3) % colors.length]))
470 confetti += f.g(`fall ${dur}s steps(12) ${d}s 2 both`, piece, x, 0)
471 }
472 return (
473 confetti +
474 f.g('dance 2.4s step-end 0s 2 forwards', clawd(f, 66, 10, { eyes: 'happy', armL: 'wave .3s step-end infinite', armR: 'wave .3s step-end infinite -.15s', walk: true })) +
475 f.g('hop .6s step-end 0s 9', mini(f, 52, 13, '', false)) +
476 f.g('hop .6s step-end -.3s 9', mini(f, 86, 13, '', false)) +
477 sparkle(f, 60, 2, 0, 0.9) + sparkle(f, 88, 4, 0.45, 0.9) + sparkle(f, 46, 6, 0.3, 0.9)
478 )
479}
480
481// ---------- relaxing: sunset chair, fishing, hammock, campfire, barbecue, sunset drive ----------
482
483function relax(f: Frame): string {
484 const sunset = () =>
485 backdrop([['s', 3], ['m', 3], ['d', 3], ['f', 3], ['U', 3], ['t', 3]]) +
486 f.g('sink 8s steps(3) infinite', spr(SUN), 76, 7) +
487 rect(34, 12, 66, 3, 'U') +
488 [0, 0.4, 0.8, 1.2].map((d, i) => f.g(`tw 1.6s step-end infinite ${-d}s`, rect(0, 0, 2 + (i % 2), 1, 'h'), 72 + i * 4, 12 + (i % 3))).join('') +
489 f.g('drift 16s linear infinite', spr(['K.K', '.K.']) + spr(['K.K', '.K.'], 5, 2), 96, 2) +
490 spr(['r......', 'wr.....', 'rwr....', 'wrwrwrw', 'b.....b', 'b.....b'], 46, 10) +
491 clawd(f, 46, 8, { eyes: 'shades', bob: 'bob 3s step-end infinite' }) +
492 spr(['..p..', '.ppp.', '..t..', 'cccc.', 'cccc.', '.cc..'], 63, 10) +
493 f.g('riseR 3s linear infinite', spr(['.ww', '.w.', 'ww.']), 58, 5) +
494 f.g('riseR 3s linear infinite -1.5s', spr(['ww', 'w.', 'w.']), 52, 5)
495
496 const fishing = () =>
497 backdrop([['V', 4], ['s', 4], ['V', 3], ['U', 7]]) +
498 stars(f, 14, 3) +
499 spr(['.ww.', 'wwww', 'wwww', '.ww.'], 90, 1) +
500 [0, 1, 2].map(i => f.g(`tw 2s step-end infinite ${-i * 0.6}s`, rect(0, 0, 3, 1, 'u'), 40 + i * 15, 13 + i)).join('') +
501 rect(38, 10, 26, 2, 'b') + rect(38, 10, 26, 1, 't') + rect(40, 12, 1, 6, 'B') + rect(60, 12, 1, 6, 'B') +
502 clawd(f, 46, 4, { eyes: 'closed', bob: 'bob 3s step-end infinite' }) +
503 spr(['.......t', '......t.', '.....t..', '....t...', '...t....', '..t.....', '.t......'], 60, 0) +
504 rect(68, 1, 1, 12, 'g') +
505 f.g('bob 1.2s step-end infinite', rect(67, 12, 3, 1, 'r') + rect(67, 13, 3, 1, 'w')) +
506 f.g('leap 8s steps(10) infinite', spr(['.uu.u', 'uuuuu', '.uu.u']), 86, 13)
507
508 const hammockScene = () => {
509 // The hammock sags between the palms: tied high at the trunks, lowest in the middle.
510 let hammock = ''
511 for (let x = 48; x <= 82; x++) {
512 const t = (x - 65) / 17
513 hammock += rect(x, Math.round(9 + 4 * (1 - t * t)), 1, 1, x % 3 ? 'w' : 'r')
514 }
515 return (
516 backdrop([['C', 11], ['c', 2], ['t', 5]]) +
517 palm(46) + palm(83) +
518 f.g('swing 3s step-end infinite', clawd(f, 57, 7, { eyes: 'closed', sit: true }) + hammock) +
519 f.g('riseR 3s linear infinite', spr(['www', '.w.', 'www']), 70, 2) +
520 f.g('riseR 3s linear infinite -1.5s', spr(['ww', 'ww']), 74, 3)
521 )
522 }
523
524 const campfire = () =>
525 backdrop([['V', 5], ['s', 5], ['m', 2], ['N', 6]]) +
526 stars(f, 12, 11) +
527 f.g('on .3s step-end infinite', spr(['..r...', '.rfr..', '.rfyr.', 'rfyyfr']), 70, 10) +
528 f.g('off .3s step-end infinite', spr(['...r..', '..rfr.', '.ryfr.', 'rfyyfr']), 70, 10) +
529 spr(['BB..BB', '.BBBB.', 'BB..BB'], 70, 14) +
530 [0, 0.7, 1.4].map((d, i) => f.g(`rise 2.1s linear infinite ${-d}s`, rect(0, 0, 1, 1, 'h'), 71 + i * 2, 8)).join('') +
531 f.g('on .6s step-end infinite', rect(64, 16, 18, 1, 'O')) +
532 rect(46, 15, 14, 3, 'B') +
533 clawd(f, 47, 9, { eyes: 'closed', bob: 'bob 3.6s step-end infinite' }) +
534 spr(['www.', 'wwwP', 'www.'], 61, 12) +
535 f.g('rise 2.4s linear infinite', spr(['g', '.', 'g']), 62, 9) +
536 f.g('rise 2.4s linear infinite -1.2s', spr(['g', 'g']), 63, 9)
537
538 const barbecue = () => {
539 let fence = ''
540 for (let x = 35; x < W; x += 3) fence += rect(x, 7, 2, 6, 'w')
541 return (
542 backdrop([['C', 13], ['n', 2], ['N', 3]]) +
543 fence + rect(34, 9, 66, 1, 'w') +
544 spr(['KKKKKKKKKKKK', '.GGGGGGGGGG.', '..GGGGGGGG..', '...G....G...', '...G....G...', '..GG....GG..'], 46, 11) +
545 f.g('on .6s step-end infinite', rect(49, 12, 6, 1, 'd') + rect(56, 12, 1, 1, 'f')) +
546 f.g('off .6s step-end infinite', rect(48, 12, 2, 1, 'f') + rect(51, 12, 6, 1, 'd')) +
547 f.g('flipfood 2s steps(4) infinite', rect(47, 10, 3, 1, 'B')) +
548 rect(51, 10, 3, 1, 'r') +
549 f.g('flipfood 2s steps(4) infinite -1s', rect(55, 10, 2, 1, 'B')) +
550 [0, 0.8, 1.6].map((d, i) => f.g(`rise 2.4s linear infinite ${-d}s`, spr(['g.', '.g']), 48 + i * 3, 6)).join('') +
551 clawd(f, 64, 10, { eyes: 'happy', extra: spr(['.wwwwww.', 'wwwwwwww', '.wwwwww.'], 4, -3) }) +
552 f.g('wave .6s step-end infinite', rect(60, 11, 4, 1, 't') + rect(58, 10, 2, 2, 'l'))
553 )
554 }
555
556 const drive = () => {
557 const palmSmall = rect(0, 4, 1, 8, 'B') + spr(['.N.N.', 'NNNNN', 'N.N.N'], -2, 2)
558 let dashes = ''
559 for (let x = 40; x < W + 8; x += 8) dashes += rect(x, 16, 4, 1, 'y')
560 const car = spr([
561 '.................c....',
562 '.................c....',
563 'rrrrrrrrrrrrrrrrrrrrrr',
564 'rrrrrrrrrrrrrrrrrrrrhh',
565 'RRRRRRRRRRRRRRRRRRRRRR',
566 ])
567 const wheels =
568 spr(['.KKK............KKK...', '.KKK............KKK...'], 0, 5) +
569 f.g('on .3s step-end infinite', rect(2, 5, 1, 1, 'g') + rect(18, 5, 1, 1, 'g')) +
570 f.g('off .3s step-end infinite', rect(2, 6, 1, 1, 'g') + rect(18, 6, 1, 1, 'g'))
571 return (
572 backdrop([['s', 3], ['m', 3], ['d', 3], ['f', 3], ['N', 2], ['G', 4]]) +
573 spr(SUN, 74, 8) + rect(34, 12, 66, 2, 'N') + rect(34, 14, 66, 4, 'G') +
574 f.g('dash .6s steps(4) infinite', dashes) +
575 f.g('zoom 2.4s steps(24) infinite', palmSmall, 100, 0) +
576 f.g('zoom 2.4s steps(24) infinite -1.2s', palmSmall, 100, 0) +
577 f.g('drift 16s linear infinite', spr(['K.K', '.K.']), 96, 2) +
578 f.g('bob .6s step-end infinite', clawd(f, 48, 6, { eyes: 'shades', sit: true, armRUp: true, armR: 'wave 1.2s step-end infinite' }) + `<g transform="translate(46 9)">${car}${wheels}</g>`) +
579 [0, 0.3].map(d => f.g(`dust .6s linear infinite ${-d}s`, rect(0, 0, 2, 1, 'g'), 45, 13)).join('')
580 )
581 }
582
583 return f.cycle([sunset, fishing, hammockScene, campfire, barbecue, drive], 48)
584}
585
586// One still frame of `mood`, `t` seconds after it began.
587export function sceneSvg(mood: Mood, t: number): string {
588 const f = new Frame(t)
589 const body = mood === 'working' ? working(f) : mood === 'done' ? done(f) : mood === 'relax' ? relax(f) : idle(f)
590 return (
591 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W * 3}" height="${H * 3}" shape-rendering="crispEdges">` +
592 `<rect x="-2" y="-2" width="${W + 4}" height="${H + 4}" fill="${BAND_BG}"/>${dots(f)}${body}</svg>`
593 )
594}
595
596export const SCENE_WIDTH = W * 3
597export const SCENE_HEIGHT = H * 3
598hooks/format.ts 69 lines1// Small formatters and color tokens for the dashboard text.
2
3export const COLOR = {
4 text: '#ededed',
5 soft: '#c4c4c4',
6 dim: '#8a8a8a',
7 faint: '#4a4a4a',
8 track: '#333333',
9 accent: '#d97757',
10 ok: '#5fbf6a',
11 warn: '#e8a33d',
12 bad: '#e5534b',
13 sky: '#6cb6ff',
14 violet: '#b392f0',
15 teal: '#4fd1c5',
16 gold: '#e3c06a',
17 rose: '#f0809a',
18}
19
20export const level = (p: number) => (p >= 80 ? COLOR.bad : p >= 50 ? COLOR.warn : COLOR.ok)
21
22const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
23
24// A smooth bar in eighth-cell steps: `full` solid cells, one partial cell, then `rest` empty track cells.
25export function gauge(p: number, cells: number) {
26 const units = Math.min(cells * 8, Math.max(0, Math.round((p / 100) * cells * 8)))
27 const full = Math.floor(units / 8)
28 const part = EIGHTHS[units % 8]!
29 return { full: '█'.repeat(full), part, rest: ' '.repeat(cells - full - (part ? 1 : 0)) }
30}
31
32const EFFORT: Record<string, string> = { low: COLOR.sky, medium: COLOR.teal, high: COLOR.gold }
33
34export const effortColor = (effort: string) => EFFORT[effort] ?? COLOR.accent
35
36export function tokens(n: number) {
37 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
38 if (n >= 1000) return `${Math.round(n / 1000)}k`
39 return String(n)
40}
41
42function span(mins: number) {
43 const d = Math.floor(mins / 1440), h = Math.floor((mins % 1440) / 60), m = mins % 60
44 return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`
45}
46
47export function until(iso: string | undefined, now: number) {
48 if (!iso) return ''
49 return span(Math.max(0, Math.round((Date.parse(iso) - now) / 60000)))
50}
51
52// Elapsed time: seconds under a minute, then the same shape as `until`.
53export function elapsed(ms: number) {
54 const s = Math.max(0, Math.round(ms / 1000))
55 if (s < 60) return `${s}s`
56 if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
57 return span(Math.floor(s / 60))
58}
59
60export const usd = (n: number) => `$${n < 10 ? n.toFixed(2) : n.toFixed(1)}`
61
62// "claude-opus-5-5[1m]" -> "Opus 5.5"; anything else passes through.
63export function modelName(id: string) {
64 const m = id.replace(/\[.*\]$/, '').match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?/)
65 if (!m) return id
66 const family = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1)
67 return m[3] && m[3].length <= 2 ? `${family} ${m[2]}.${m[3]}` : `${family} ${m[2]}`
68}
69types/index.d.ts 32 lines1export type Mood = 'idle' | 'working' | 'done' | 'relax'
2
3export type Limit = { percent: number; resetsAt?: string }
4
5export type Git = { branch: string; ahead: number; behind: number; dirty: number }
6
7export type Stats = {
8 fiveHour?: Limit
9 sevenDay?: Limit
10 contextPercent?: number
11 contextTokens?: number
12 contextWindow?: number
13 costUsd?: number
14 model?: string
15 effort?: string
16 added: number
17 removed: number
18 files: string[]
19 startedAt?: number
20 prompts?: number
21 tools: number
22 lastTurnMs?: number
23 version?: string
24 git?: Git | null
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'clawd-dash': { stats: Stats }
30 }
31}
32