Replaces the loading spinner text with an animated Claude mascot

There is no magic here. The bash script just creates symbolic links from ~/dotfiles.
# >>> start
cd ~
git clone git@github.com:tatthien/dotfiles.git
cd dotfiles
./install.sh
# >>> donehooks/register.tsx 115 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { FRAME_MS, MASCOTS, draw, type Run } from './mascots'
5
6const state = atom({ plugin: 'clawd-spinner', key: 'state' } as const, { tick: 0, mascot: 0 })
7const galleryTick = atom({ plugin: 'clawd-spinner', key: 'galleryTick' } as const, 0)
8
9// The turn timer ticks every TICK_MS; the mascot advances one frame per FRAME_MS.
10const TICK_MS = 95
11const GALLERY = 'clawd-gallery'
12
13export const register: Register = on => {
14 let timer: { cancel: () => void } | null = null
15 let galleryTimer: { cancel: () => void } | null = null
16 let reduceMotion = false
17
18 const stop = () => {
19 timer?.cancel()
20 timer = null
21 }
22
23 on('session.start', async ($, e, next) => {
24 await $.command.register({ name: 'mascots', description: 'Show every spinner mascot, animated, in a pane' })
25 await pickNext($)
26 // The terminal has no prefers-reduced-motion, so Claude Code's own setting stands in for it.
27 reduceMotion = (await $.settings.read()).prefersReducedMotion === true
28
29 return next(e)
30 })
31
32 on('command.run', { command: 'mascots' }, async $ => {
33 await $.ui.open({ id: GALLERY, title: 'Mascots' })
34 galleryTimer?.cancel()
35 galleryTimer = $.clock.every(FRAME_MS, async () => {
36 if (!(await $.ui.panes()).some(p => p.id === GALLERY)) {
37 galleryTimer?.cancel()
38 galleryTimer = null
39 return
40 }
41 await update($, galleryTick, n => n + 1)
42 })
43
44 return { text: `Showing ${MASCOTS.length} ${MASCOTS.length === 1 ? 'mascot' : 'mascots'}.` }
45 })
46
47 on('turn.start', async ($, e, next) => {
48 stop()
49 timer = $.clock.every(TICK_MS, () => update($, state, s => ({ ...s, tick: s.tick + 1 })))
50
51 return next(e)
52 })
53
54 on('turn.complete', async ($, e, next) => {
55 stop()
56 await pickNext($)
57
58 return next(e)
59 })
60
61 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
62 // Terminal only: the Claude desktop app keeps its own spinner.
63 if (e.surface !== 'terminal') return next(e)
64 const { Box, Text } = $.ui.resolve(e)
65 const s = await read($, state)
66 const lines = draw(MASCOTS[s.mascot % MASCOTS.length]!, Math.floor((s.tick * TICK_MS) / FRAME_MS), reduceMotion)
67
68 return (
69 <Box flexDirection="column">
70 <Box flexDirection="column">{rows(Text, lines)}</Box>
71 {await next({ ...e, props: { ...e.props, word: 'Thinking' } })}
72 </Box>
73 )
74 })
75
76 on('ui.render', { component: 'Pane', requestId: GALLERY }, async ($, e, next) => {
77 if (e.surface !== 'terminal') return next(e)
78 const { Box, Text } = $.ui.resolve(e)
79 const tick = await read($, galleryTick)
80
81 return (
82 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
83 {MASCOTS.map(m => (
84 <Box key={m.name} flexDirection="column" marginBottom={1}>
85 {rows(Text, draw(m, tick, reduceMotion))}
86 <Text dimColor>{m.name}</Text>
87 </Box>
88 ))}
89 </Box>
90 )
91 })
92}
93
94// The spinner can paint before turn.start's state write lands, so the next mascot is picked when the previous turn ends.
95// Offsetting by 1..n-1 never lands on the current mascot, so the same one never shows twice in a row.
96function pickNext($: Parameters<typeof read>[0]) {
97 const n = MASCOTS.length
98 return update($, state, s => ({
99 tick: 0,
100 mascot: n > 1 ? (s.mascot + 1 + Math.floor(Math.random() * (n - 1))) % n : 0,
101 }))
102}
103
104function rows(Text: (props: any) => any, lines: Run[][]) {
105 return lines.map((runs, y) => (
106 <Text key={`row-${y}`}>
107 {runs.map((r, i) => (
108 <Text key={`run-${i}`} color={r.fg} backgroundColor={r.bg}>
109 {r.text}
110 </Text>
111 ))}
112 </Text>
113 ))
114}
115hooks/mascots.ts 29 lines1import * as crab from './crab'
2import * as crabLift from './crab-lift'
3import * as crabAngry from './crab-angry'
4import * as crabFine from './crab-fine'
5import * as crabFlag from './crab-flag'
6import * as crabReview from './crab-review'
7import { toOctants } from './octants'
8
9export type Run = { text: string; fg?: string; bg?: string }
10
11type Mascot = { name: string; frames: Run[][][] }
12
13// Every crab steps every 95ms, 5% faster than the 100ms its SVG uses.
14export const FRAME_MS = 95
15
16export const MASCOTS: Mascot[] = [
17 { name: 'crab', frames: toOctants(crab.frames) },
18 { name: 'crab-lift', frames: toOctants(crabLift.frames) },
19 { name: 'crab-angry', frames: toOctants(crabAngry.frames) },
20 { name: 'crab-fine', frames: toOctants(crabFine.frames) },
21 { name: 'crab-flag', frames: toOctants(crabFlag.frames) },
22 { name: 'crab-review', frames: toOctants(crabReview.frames) },
23]
24
25/** The mascot's frame at `tick`, its renderFrame redrawn in octants; frozen on frame 0 under reduced motion. */
26export function draw(m: Mascot, tick: number, reduceMotion = false): Run[][] {
27 return m.frames[reduceMotion ? 0 : tick % m.frames.length]!
28}
29hooks/crab.ts 157 lines1// Pixel crab "disconnected" animation for the terminal.
2// Renders with half-block characters (▀ ▄) and 24-bit ANSI color,
3// so every text cell shows two vertical pixels.
4//
5// Ported from crab.mjs: the CLI and the ANSI escapes are gone (a mod cannot run Node or print);
6// renderFrame returns the same ▀/▄ cells as colored runs instead.
7
8import type { Run } from './mascots'
9
10type Rect = [number, number, number, number, keyof typeof COLORS]
11type Layer = {
12 name: string
13 rects: Rect[]
14 body?: boolean
15 legs?: 'L' | 'R'
16 claw?: 'L' | 'R'
17 eyes?: boolean
18 mouth?: 'A' | 'B'
19 spark?: boolean
20}
21
22const COLORS = {
23 red: '#E04A3F', dark: '#B32F2A', light: '#FF7A66', ink: '#2E2A3F',
24 white: '#FFFFFF', blue: '#3A86F0', blueHi: '#6FA8F7', purple: '#6E35F0',
25 purpleHi: '#9467F5', yellow: '#FFD83D',
26};
27
28// Artwork in the SVG's coordinate space: [x, y, width, height, color]
29const LAYERS: Layer[] = [
30 { name: 'legsL', body: true, legs: 'L', rects: [
31 [150, 300, 20, 10, 'dark'], [140, 310, 10, 25, 'dark'],
32 [160, 320, 20, 10, 'dark'], [150, 330, 10, 25, 'dark'],
33 [175, 335, 20, 10, 'dark'], [170, 345, 10, 25, 'dark'],
34 ] },
35 { name: 'legsR', body: true, legs: 'R', rects: [
36 [310, 300, 20, 10, 'dark'], [330, 310, 10, 25, 'dark'],
37 [300, 320, 20, 10, 'dark'], [320, 330, 10, 25, 'dark'],
38 [285, 335, 20, 10, 'dark'], [300, 345, 10, 25, 'dark'],
39 ] },
40 { name: 'clawL', body: true, claw: 'L', rects: [
41 [160, 230, 15, 40, 'red'], [145, 210, 20, 20, 'red'],
42 [120, 170, 50, 40, 'red'], [150, 150, 20, 20, 'red'],
43 [120, 170, 20, 10, 'light'], [160, 150, 10, 10, 'dark'],
44 [170, 175, 10, 10, 'ink'],
45 [180, 155, 40, 40, 'blue'], [180, 155, 40, 8, 'blueHi'],
46 [220, 162, 20, 8, 'yellow'], [220, 182, 20, 8, 'yellow'],
47 ] },
48 { name: 'clawR', body: true, claw: 'R', rects: [
49 [305, 230, 15, 40, 'red'], [315, 210, 20, 20, 'red'],
50 [310, 170, 50, 40, 'red'], [310, 150, 20, 20, 'red'],
51 [340, 170, 20, 10, 'light'], [310, 150, 10, 10, 'dark'],
52 [300, 175, 10, 10, 'ink'],
53 [260, 155, 40, 40, 'purple'], [260, 155, 40, 8, 'purpleHi'],
54 [265, 162, 15, 8, 'ink'], [265, 182, 15, 8, 'ink'],
55 ] },
56 { name: 'stalks', body: true, rects: [
57 [210, 220, 10, 30, 'dark'], [260, 220, 10, 30, 'dark'],
58 [200, 200, 30, 25, 'white'], [250, 200, 30, 25, 'white'],
59 ] },
60 { name: 'pupils', body: true, eyes: true, rects: [
61 [212, 205, 10, 15, 'ink'], [258, 205, 10, 15, 'ink'],
62 ] },
63 { name: 'shell', body: true, rects: [
64 [170, 250, 140, 100, 'red'],
65 [200, 260, 40, 10, 'light'], [180, 270, 20, 10, 'light'],
66 [190, 300, 10, 10, 'dark'], [280, 295, 10, 10, 'dark'],
67 [170, 340, 140, 10, 'dark'],
68 ] },
69 { name: 'mouthA', body: true, mouth: 'A', rects: [
70 [225, 300, 10, 8, 'ink'], [235, 295, 10, 8, 'ink'], [245, 300, 10, 8, 'ink'],
71 ] },
72 { name: 'mouthB', body: true, mouth: 'B', rects: [[233, 292, 15, 15, 'ink']] },
73 { name: 'spark', spark: true, rects: [
74 [240, 168, 16, 16, 'yellow'], [228, 152, 8, 8, 'yellow'],
75 [260, 152, 8, 8, 'yellow'], [228, 192, 8, 8, 'yellow'],
76 [260, 192, 8, 8, 'yellow'], [244, 140, 8, 8, 'yellow'],
77 ] },
78];
79
80// Rendering window and resolution (one pixel = 10 SVG units)
81const PX = 10, X0 = 100, X1 = 380, Y0 = 130, Y1 = 390;
82const FRAMES_PER_LOOP = 14;
83export const FRAME_MS = 100; // 14 frames x 100ms = 1.4s loop, same as the SVG
84
85// Linear keyframes, t in [0, 1]
86function kf(t: number, keys: [number, number][]) {
87 for (let i = 1; i < keys.length; i++) {
88 const [t0, v0] = keys[i - 1]!, [t1, v1] = keys[i]!;
89 if (t <= t1) return v0 + (v1 - v0) * ((t - t0) / (t1 - t0 || 1));
90 }
91 return keys[keys.length - 1]![1];
92}
93
94function stateAt(index: number) {
95 const loop = Math.floor(index / FRAMES_PER_LOOP);
96 const local = index % FRAMES_PER_LOOP;
97 const t = local / FRAMES_PER_LOOP;
98 const reach = kf(t, [[0, 0], [0.42, 7], [0.5, 7], [0.58, -8], [0.75, -3], [1, 0]]);
99 const legUp = Math.floor(t * 8) % 2 === 0; // scuttle 4x per loop
100 return {
101 bodyY: Math.round(kf(t, [[0, 0], [0.4, -10], [0.55, 10], [0.7, 0], [1, 0]]) / PX) * PX,
102 legL: legUp ? -10 : 0,
103 legR: legUp ? 0 : -10,
104 clawL: reach,
105 clawR: -reach,
106 spark: t >= 0.42 && t < 0.56,
107 oh: t >= 0.56 && t < 0.75,
108 blink: loop === 1 && local === 3, // blink once every two loops
109 };
110}
111
112function rotateBack(x: number, y: number, cx: number, cy: number, deg: number): [number, number] {
113 const a = (-deg * Math.PI) / 180, dx = x - cx, dy = y - cy;
114 return [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)];
115}
116
117function colorAt(px: number, py: number, s: ReturnType<typeof stateAt>) {
118 for (let i = LAYERS.length - 1; i >= 0; i--) {
119 const L = LAYERS[i]!;
120 if (L.spark && !s.spark) continue;
121 if (L.mouth === 'A' && s.oh) continue;
122 if (L.mouth === 'B' && !s.oh) continue;
123 if (L.eyes && s.blink) continue;
124 let x = px, y = py;
125 if (L.body) y -= s.bodyY;
126 if (L.legs === 'L') y -= s.legL;
127 if (L.legs === 'R') y -= s.legR;
128 if (L.claw === 'L') [x, y] = rotateBack(x, y, 170, 270, s.clawL);
129 if (L.claw === 'R') [x, y] = rotateBack(x, y, 310, 270, s.clawR);
130 for (let j = L.rects.length - 1; j >= 0; j--) {
131 const [rx, ry, rw, rh, c] = L.rects[j]!;
132 if (x >= rx && x < rx + rw && y >= ry && y < ry + rh) return COLORS[c];
133 }
134 }
135 return null;
136}
137
138export function renderFrame(index: number) {
139 const s = stateAt(index);
140 const lines: Run[][] = [];
141 for (let y = Y0; y < Y1; y += PX * 2) {
142 const line: Run[] = [];
143 for (let x = X0; x < X1; x += PX) {
144 const top = colorAt(x + PX / 2, y + PX / 2, s);
145 const bottom = colorAt(x + PX / 2, y + PX * 1.5, s);
146 if (top && bottom) line.push({ text: '▀', fg: top, bg: bottom });
147 else if (top) line.push({ text: '▀', fg: top });
148 else if (bottom) line.push({ text: '▄', fg: bottom });
149 else line.push({ text: ' ' });
150 }
151 lines.push(line);
152 }
153 return lines;
154}
155
156export const frames = Array.from({ length: FRAMES_PER_LOOP * 2 }, (_, i) => renderFrame(i));
157hooks/crab-lift.ts 132 lines1// Pixel crab pressing a barbell, for the terminal.
2// Half-block characters (▀ ▄) + 24-bit ANSI color: two pixels per text cell.
3//
4// Ported from crab-lift.mjs: the CLI and the ANSI escapes are gone (a mod cannot run Node or print);
5// renderFrame returns the same ▀/▄ cells as colored runs instead.
6
7import type { Run } from './mascots'
8
9type Rect = [number, number, number, number, keyof typeof COLORS]
10type Layer = {
11 rects: Rect[]
12 body?: boolean
13 lift?: boolean
14 happy?: boolean
15 strain?: boolean
16 sweat?: boolean
17 sparkle?: boolean
18}
19
20const COLORS = {
21 red: '#E04A3F', dark: '#B32F2A', light: '#FF7A66', ink: '#2E2A3F',
22 white: '#FFFFFF', bar: '#6B7499', plate: '#3A47E0', plateHi: '#6672F5',
23 sweat: '#6FA8F7', yellow: '#FFD83D',
24};
25
26// Artwork in the SVG's coordinate space: [x, y, width, height, color]
27const LAYERS: Layer[] = [
28 { rects: [ // legs stay planted
29 [150, 300, 20, 10, 'dark'], [140, 310, 10, 60, 'dark'],
30 [160, 320, 20, 10, 'dark'], [155, 330, 10, 40, 'dark'],
31 [175, 335, 20, 10, 'dark'], [170, 345, 10, 25, 'dark'],
32 [310, 300, 20, 10, 'dark'], [330, 310, 10, 60, 'dark'],
33 [300, 320, 20, 10, 'dark'], [315, 330, 10, 40, 'dark'],
34 [285, 335, 20, 10, 'dark'], [300, 345, 10, 25, 'dark'],
35 ] },
36 { body: true, lift: true, rects: [
37 [160, 200, 15, 90, 'red'], [145, 205, 20, 15, 'red'],
38 [120, 170, 50, 40, 'red'], [120, 140, 15, 30, 'red'], [155, 140, 15, 30, 'red'],
39 [305, 200, 15, 90, 'red'], [315, 205, 20, 15, 'red'],
40 [310, 170, 50, 40, 'red'], [310, 140, 15, 30, 'red'], [345, 140, 15, 30, 'red'],
41 [120, 170, 20, 10, 'light'], [340, 170, 20, 10, 'light'],
42 [80, 150, 320, 10, 'bar'],
43 [70, 120, 20, 70, 'plate'], [60, 135, 10, 40, 'plateHi'],
44 [390, 120, 20, 70, 'plate'], [410, 135, 10, 40, 'plateHi'],
45 ] },
46 { body: true, rects: [[210, 220, 10, 30, 'dark'], [260, 220, 10, 30, 'dark']] },
47 { body: true, happy: true, rects: [
48 [200, 200, 30, 25, 'white'], [250, 200, 30, 25, 'white'],
49 [212, 205, 10, 15, 'ink'], [258, 205, 10, 15, 'ink'],
50 ] },
51 { body: true, strain: true, rects: [[200, 210, 30, 10, 'ink'], [250, 210, 30, 10, 'ink']] },
52 { body: true, rects: [
53 [170, 250, 140, 100, 'red'],
54 [200, 260, 40, 10, 'light'], [180, 270, 20, 10, 'light'],
55 [190, 320, 10, 10, 'dark'], [280, 315, 10, 10, 'dark'], [170, 340, 140, 10, 'dark'],
56 ] },
57 { body: true, strain: true, rects: [[225, 295, 30, 10, 'ink']] },
58 { body: true, happy: true, rects: [
59 [225, 290, 10, 10, 'ink'], [235, 300, 10, 10, 'ink'], [245, 290, 10, 10, 'ink'],
60 ] },
61 { body: true, sweat: true, rects: [[290, 230, 10, 10, 'sweat']] },
62 { sparkle: true, rects: [
63 [40, 80, 10, 10, 'yellow'], [30, 90, 10, 10, 'yellow'], [50, 90, 10, 10, 'yellow'], [40, 100, 10, 10, 'yellow'],
64 [430, 80, 10, 10, 'yellow'], [420, 90, 10, 10, 'yellow'], [440, 90, 10, 10, 'yellow'], [430, 100, 10, 10, 'yellow'],
65 [235, 70, 10, 10, 'yellow'],
66 ] },
67];
68
69const PX = 10, X0 = 20, X1 = 460, Y0 = 60, Y1 = 380;
70const FRAME_COUNT = 16;
71export const FRAME_MS = 100; // 16 x 100ms = 1.6s, same as the SVG
72
73function kf(t: number, keys: [number, number][]) {
74 for (let i = 1; i < keys.length; i++) {
75 const [t0, v0] = keys[i - 1]!, [t1, v1] = keys[i]!;
76 if (t <= t1) return v0 + (v1 - v0) * ((t - t0) / (t1 - t0 || 1));
77 }
78 return keys[keys.length - 1]![1];
79}
80const snap = (v: number) => Math.round(v / PX) * PX;
81
82function stateAt(i: number) {
83 const t = i / FRAME_COUNT;
84 return {
85 bodyY: snap(kf(t, [[0, 0], [0.15, 10], [0.3, 10], [0.45, 0], [0.55, -10], [0.62, 0], [1, 0]])),
86 liftY: snap(kf(t, [[0, 0], [0.3, 0], [0.45, -40], [0.7, -40], [0.85, 0], [1, 0]])),
87 strain: t < 0.45,
88 sparkle: (t >= 0.5 && t < 0.58) || (t >= 0.6 && t < 0.72),
89 sweat: t >= 0.1 && t < 0.41,
90 sweatY: snap(kf(t, [[0.1, 0], [0.4, 30]])),
91 };
92}
93
94function colorAt(px: number, py: number, s: ReturnType<typeof stateAt>) {
95 for (let i = LAYERS.length - 1; i >= 0; i--) {
96 const L = LAYERS[i]!;
97 if (L.strain && !s.strain) continue;
98 if (L.happy && s.strain) continue;
99 if (L.sparkle && !s.sparkle) continue;
100 if (L.sweat && !s.sweat) continue;
101 let y = py;
102 if (L.body) y -= s.bodyY;
103 if (L.lift) y -= s.liftY;
104 if (L.sweat) y -= s.sweatY;
105 for (let j = L.rects.length - 1; j >= 0; j--) {
106 const [rx, ry, rw, rh, c] = L.rects[j]!;
107 if (px >= rx && px < rx + rw && y >= ry && y < ry + rh) return COLORS[c];
108 }
109 }
110 return null;
111}
112
113export function renderFrame(index: number) {
114 const s = stateAt(index);
115 const lines: Run[][] = [];
116 for (let y = Y0; y < Y1; y += PX * 2) {
117 const line: Run[] = [];
118 for (let x = X0; x < X1; x += PX) {
119 const top = colorAt(x + PX / 2, y + PX / 2, s);
120 const bottom = colorAt(x + PX / 2, y + PX * 1.5, s);
121 if (top && bottom) line.push({ text: '▀', fg: top, bg: bottom });
122 else if (top) line.push({ text: '▀', fg: top });
123 else if (bottom) line.push({ text: '▄', fg: bottom });
124 else line.push({ text: ' ' });
125 }
126 lines.push(line);
127 }
128 return lines;
129}
130
131export const frames = Array.from({ length: FRAME_COUNT }, (_, i) => renderFrame(i));
132hooks/crab-angry.ts 130 lines1// Pixel crab furiously pounding a glitching laptop, for the terminal.
2// Half-block characters (▀ ▄) + 24-bit ANSI color: two pixels per text cell.
3//
4// Ported from crab-angry.mjs: the CLI and the ANSI escapes are gone (a mod cannot run Node or print);
5// renderFrame returns the same ▀/▄ cells as colored runs instead.
6
7import type { Run } from './mascots'
8
9type Rect = [number, number, number, number, keyof typeof COLORS]
10type Layer = {
11 rects: Rect[]
12 laptop?: boolean
13 glitch?: 'A' | 'B'
14 smoke?: boolean
15 pivot?: [number, number]
16 angle?: 'fist' | 'slam'
17 anger?: boolean
18 burst?: boolean
19}
20
21const COLORS = {
22 red: '#E04A3F', dark: '#B32F2A', light: '#FF7A66', ink: '#2E2A3F', white: '#FFFFFF',
23 lid: '#5B30D0', lidHi: '#8A6CF5', screen: '#241A55', magenta: '#B033D0', cyan: '#3AD0E0',
24 smoke: '#9A97A8', yellow: '#FDC232', orange: '#FF6B35', anger: '#FF5A3C',
25};
26
27// Artwork in the SVG's coordinate space: [x, y, width, height, color]
28const LAYERS: Layer[] = [
29 { laptop: true, rects: [[350, 265, 70, 90, 'lid'], [358, 273, 54, 74, 'screen']] },
30 { laptop: true, glitch: 'A', rects: [[365, 285, 40, 8, 'magenta'], [365, 305, 25, 8, 'magenta'], [375, 325, 30, 8, 'magenta']] },
31 { laptop: true, glitch: 'B', rects: [[370, 280, 20, 8, 'cyan'], [360, 300, 45, 8, 'cyan'], [365, 320, 15, 8, 'cyan']] },
32 { laptop: true, rects: [[270, 355, 150, 15, 'lid'], [270, 355, 150, 5, 'lidHi']] },
33 { smoke: true, rects: [[395, 245, 10, 10, 'smoke'], [405, 235, 10, 10, 'smoke'], [385, 225, 10, 10, 'smoke']] },
34 { rects: [
35 [90, 300, 20, 10, 'dark'], [80, 310, 10, 60, 'dark'], [100, 320, 20, 10, 'dark'], [95, 330, 10, 40, 'dark'],
36 [115, 335, 20, 10, 'dark'], [110, 345, 10, 25, 'dark'], [250, 300, 20, 10, 'dark'], [270, 310, 10, 60, 'dark'],
37 [240, 320, 20, 10, 'dark'], [255, 330, 10, 40, 'dark'], [225, 335, 20, 10, 'dark'], [240, 345, 10, 25, 'dark'],
38 ] },
39 { pivot: [100, 270], angle: 'fist', rects: [
40 [100, 230, 15, 40, 'red'], [85, 210, 20, 20, 'red'], [60, 170, 50, 40, 'red'], [90, 150, 20, 20, 'red'],
41 [60, 170, 20, 10, 'light'], [100, 150, 10, 10, 'dark'],
42 ] },
43 { pivot: [250, 292], angle: 'slam', rects: [
44 [245, 285, 45, 15, 'red'], [285, 265, 50, 40, 'red'], [315, 245, 20, 20, 'red'],
45 [285, 265, 20, 10, 'light'], [325, 245, 10, 10, 'dark'],
46 ] },
47 { rects: [
48 [150, 220, 10, 30, 'dark'], [200, 220, 10, 30, 'dark'],
49 [140, 200, 30, 25, 'white'], [190, 200, 30, 25, 'white'],
50 [158, 208, 10, 12, 'ink'], [208, 208, 10, 12, 'ink'],
51 [135, 185, 15, 10, 'ink'], [150, 195, 20, 10, 'ink'], [190, 195, 20, 10, 'ink'], [210, 185, 15, 10, 'ink'],
52 ] },
53 { rects: [
54 [110, 250, 140, 100, 'red'], [140, 260, 40, 10, 'light'], [120, 270, 20, 10, 'light'],
55 [130, 320, 10, 10, 'dark'], [220, 315, 10, 10, 'dark'], [110, 340, 140, 10, 'dark'],
56 [165, 300, 10, 10, 'ink'], [175, 290, 10, 10, 'ink'], [185, 300, 10, 10, 'ink'],
57 ] },
58 { anger: true, rects: [[235, 175, 10, 10, 'anger'], [255, 175, 10, 10, 'anger'], [235, 195, 10, 10, 'anger'], [255, 195, 10, 10, 'anger']] },
59 { burst: true, rects: [
60 [300, 320, 10, 10, 'yellow'], [280, 330, 10, 10, 'yellow'], [330, 330, 10, 10, 'yellow'],
61 [270, 310, 10, 10, 'yellow'], [335, 305, 10, 10, 'yellow'], [305, 300, 10, 10, 'yellow'],
62 [290, 320, 10, 10, 'orange'], [310, 320, 10, 10, 'orange'], [300, 310, 10, 10, 'orange'],
63 ] },
64];
65
66const PX = 10, X0 = 40, X1 = 440, Y0 = 140, Y1 = 380;
67const FRAME_COUNT = 12;
68export const FRAME_MS = 100; // 12 x 100ms = 1.2s, same as the SVG
69
70function stateAt(i: number) {
71 const t = i / FRAME_COUNT;
72 const slamming = (t >= 0.15 && t < 0.45) || (t >= 0.55 && t < 0.85);
73 const impact = (t >= 0.15 && t < 0.31) || (t >= 0.55 && t < 0.71);
74 return {
75 fist: i % 4 < 2 ? -10 : 8,
76 slam: slamming ? 35 : -30,
77 burst: impact,
78 laptopY: (t >= 0.15 && t < 0.2) || (t >= 0.55 && t < 0.6) ? 10 : 0,
79 glitchA: i % 4 < 2,
80 anger: i % 6 < 4,
81 smokeY: -Math.round((t * 60) / PX) * PX,
82 smoke: t < 0.9,
83 };
84}
85
86function rotateBack(x: number, y: number, [cx, cy]: [number, number], deg: number): [number, number] {
87 const a = (-deg * Math.PI) / 180, dx = x - cx, dy = y - cy;
88 return [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)];
89}
90
91function colorAt(px: number, py: number, s: ReturnType<typeof stateAt>) {
92 for (let i = LAYERS.length - 1; i >= 0; i--) {
93 const L = LAYERS[i]!;
94 if (L.glitch === 'A' && !s.glitchA) continue;
95 if (L.glitch === 'B' && s.glitchA) continue;
96 if (L.burst && !s.burst) continue;
97 if (L.anger && !s.anger) continue;
98 if (L.smoke && !s.smoke) continue;
99 let x = px, y = py;
100 if (L.laptop) y -= s.laptopY;
101 if (L.smoke) y -= s.smokeY;
102 if (L.angle) [x, y] = rotateBack(x, y, L.pivot!, s[L.angle]);
103 for (let j = L.rects.length - 1; j >= 0; j--) {
104 const [rx, ry, rw, rh, c] = L.rects[j]!;
105 if (x >= rx && x < rx + rw && y >= ry && y < ry + rh) return COLORS[c];
106 }
107 }
108 return null;
109}
110
111export function renderFrame(index: number) {
112 const s = stateAt(index);
113 const lines: Run[][] = [];
114 for (let y = Y0; y < Y1; y += PX * 2) {
115 const line: Run[] = [];
116 for (let x = X0; x < X1; x += PX) {
117 const top = colorAt(x + PX / 2, y + PX / 2, s);
118 const bottom = colorAt(x + PX / 2, y + PX * 1.5, s);
119 if (top && bottom) line.push({ text: '▀', fg: top, bg: bottom });
120 else if (top) line.push({ text: '▀', fg: top });
121 else if (bottom) line.push({ text: '▄', fg: bottom });
122 else line.push({ text: ' ' });
123 }
124 lines.push(line);
125 }
126 return lines;
127}
128
129export const frames = Array.from({ length: FRAME_COUNT }, (_, i) => renderFrame(i));
130hooks/crab-fine.ts 148 lines1// Pixel crab calmly sipping coffee while everything around it is on fire.
2// Half-block characters (▀ ▄) + 24-bit ANSI color: two pixels per text cell.
3//
4// Ported from crab-fine.mjs: the CLI and the ANSI escapes are gone (a mod cannot run Node or print);
5// renderFrame returns the same ▀/▄ cells as colored runs instead.
6
7import type { Run } from './mascots'
8
9type State = ReturnType<typeof stateAt>
10type Rect = [number, number, number, number, keyof typeof COLORS]
11type Layer = {
12 rects: Rect[]
13 when?: (s: State) => boolean
14 dy?: (s: State) => number
15 pivot?: [number, number]
16 angle?: (s: State) => number
17}
18
19const COLORS = {
20 red: '#E04A3F', dark: '#B32F2A', light: '#FF7A66', ink: '#2E2A3F', white: '#FFFFFF',
21 mug: '#F2E6C4', coffee: '#6B3E26', steam: '#C9C5D3', smoke: '#B4B0C0',
22 yellow: '#FDC232', orange: '#FF8A2A', ember: '#E5502A',
23};
24
25const flamesA: Rect[] = [
26 [60, 330, 60, 40, 'yellow'], [70, 300, 40, 30, 'yellow'], [80, 280, 20, 20, 'yellow'], [90, 260, 10, 20, 'yellow'],
27 [360, 330, 60, 40, 'yellow'], [370, 300, 40, 30, 'yellow'], [380, 280, 20, 20, 'yellow'], [380, 260, 10, 20, 'yellow'],
28 [135, 350, 15, 20, 'yellow'], [325, 345, 15, 25, 'yellow'],
29 [70, 340, 40, 30, 'orange'], [80, 315, 20, 25, 'orange'], [370, 340, 40, 30, 'orange'], [380, 315, 20, 25, 'orange'],
30 [80, 355, 20, 15, 'ember'], [380, 355, 20, 15, 'ember'],
31];
32const flamesB: Rect[] = [
33 [60, 320, 60, 50, 'yellow'], [65, 295, 35, 25, 'yellow'], [75, 270, 15, 25, 'yellow'], [100, 285, 15, 20, 'yellow'],
34 [360, 320, 60, 50, 'yellow'], [380, 295, 35, 25, 'yellow'], [390, 270, 15, 25, 'yellow'], [365, 285, 15, 20, 'yellow'],
35 [130, 345, 15, 25, 'yellow'], [330, 352, 15, 18, 'yellow'],
36 [70, 335, 40, 35, 'orange'], [75, 310, 20, 25, 'orange'], [370, 335, 40, 35, 'orange'], [385, 310, 20, 25, 'orange'],
37 [85, 350, 20, 20, 'ember'], [375, 350, 20, 20, 'ember'],
38];
39
40// Artwork in the SVG's coordinate space: [x, y, width, height, color], drawn in order
41const LAYERS: Layer[] = [
42 { when: (s) => s.flameA, rects: [[180, 225, 15, 25, 'yellow'], [285, 230, 15, 20, 'yellow']] },
43 { when: (s) => !s.flameA, rects: [[175, 230, 15, 20, 'yellow'], [290, 220, 15, 30, 'yellow']] },
44 { when: (s) => s.smoke, dy: (s) => s.smokeY, rects: [
45 [80, 240, 10, 10, 'smoke'], [95, 225, 10, 10, 'smoke'], [380, 235, 10, 10, 'smoke'], [390, 220, 10, 10, 'smoke'],
46 ] },
47 { rects: [
48 [150, 300, 20, 10, 'dark'], [140, 310, 10, 60, 'dark'], [160, 320, 20, 10, 'dark'], [155, 330, 10, 40, 'dark'],
49 [175, 335, 20, 10, 'dark'], [170, 345, 10, 25, 'dark'], [310, 300, 20, 10, 'dark'], [330, 310, 10, 60, 'dark'],
50 [300, 320, 20, 10, 'dark'], [315, 330, 10, 40, 'dark'], [285, 335, 20, 10, 'dark'], [300, 345, 10, 25, 'dark'],
51 ] },
52 { pivot: [160, 270], angle: (s) => s.sip, rects: [
53 [160, 230, 15, 40, 'red'], [145, 210, 20, 20, 'red'],
54 [125, 135, 30, 40, 'mug'], [115, 145, 10, 20, 'mug'], [125, 135, 30, 8, 'coffee'],
55 [120, 170, 50, 40, 'red'], [150, 150, 20, 20, 'red'], [120, 170, 20, 10, 'light'], [160, 150, 10, 10, 'dark'],
56 ] },
57 { pivot: [160, 270], angle: (s) => s.sip, dy: (s) => s.steamY, rects: [
58 [130, 115, 10, 10, 'steam'], [140, 105, 10, 10, 'steam'],
59 ] },
60 { pivot: [310, 270], angle: (s) => s.wave, rects: [
61 [305, 230, 15, 40, 'red'], [315, 210, 20, 20, 'red'], [310, 170, 50, 40, 'red'], [310, 150, 20, 20, 'red'],
62 [340, 170, 20, 10, 'light'], [310, 150, 10, 10, 'dark'],
63 ] },
64 { rects: [
65 [210, 220, 10, 30, 'dark'], [260, 220, 10, 30, 'dark'],
66 [200, 200, 30, 25, 'white'], [250, 200, 30, 25, 'white'],
67 [210, 210, 10, 15, 'ink'], [260, 210, 10, 15, 'ink'],
68 [200, 200, 30, 10, 'red'], [250, 200, 30, 10, 'red'],
69 [170, 250, 140, 100, 'red'], [200, 260, 40, 10, 'light'], [180, 270, 20, 10, 'light'],
70 [190, 320, 10, 10, 'dark'], [280, 315, 10, 10, 'dark'], [170, 340, 140, 10, 'dark'],
71 [220, 290, 10, 10, 'ink'], [230, 300, 20, 10, 'ink'], [250, 290, 10, 10, 'ink'],
72 ] },
73 { when: (s) => s.flameA, rects: flamesA },
74 { when: (s) => !s.flameA, rects: flamesB },
75 { rects: [
76 [350, 50, 100, 10, 'ink'], [350, 100, 100, 10, 'ink'], [340, 60, 10, 40, 'ink'], [450, 60, 10, 40, 'ink'],
77 [350, 110, 10, 10, 'ink'], [340, 120, 10, 10, 'ink'], [350, 60, 100, 40, 'white'],
78 ] },
79 { when: (s) => s.dots >= 1, rects: [[370, 75, 10, 10, 'ink']] },
80 { when: (s) => s.dots >= 2, rects: [[395, 75, 10, 10, 'ink']] },
81 { when: (s) => s.dots >= 3, rects: [[420, 75, 10, 10, 'ink']] },
82];
83
84const PX = 10, X0 = 50, X1 = 470, Y0 = 40, Y1 = 380;
85const FRAME_COUNT = 16;
86export const FRAME_MS = 100; // 16 x 100ms = 1.6s, same as the SVG
87
88function kf(t: number, keys: [number, number][]) {
89 for (let i = 1; i < keys.length; i++) {
90 const [t0, v0] = keys[i - 1]!, [t1, v1] = keys[i]!;
91 if (t <= t1) return v0 + (v1 - v0) * ((t - t0) / (t1 - t0 || 1));
92 }
93 return keys[keys.length - 1]![1];
94}
95
96function stateAt(i: number) {
97 const t = i / FRAME_COUNT;
98 return {
99 flameA: i % 4 < 2,
100 sip: kf(t, [[0, 0], [0.25, 0], [0.4, 14], [0.6, 14], [0.75, 0], [1, 0]]),
101 wave: i % 16 < 8 ? 0 : -6,
102 steamY: i % 8 < 4 ? 0 : -10,
103 smoke: t < 0.9,
104 smokeY: -Math.round((t * 50) / PX) * PX,
105 dots: t >= 0.65 ? 3 : t >= 0.4 ? 2 : t >= 0.15 ? 1 : 0,
106 };
107}
108
109function rotateBack(x: number, y: number, [cx, cy]: [number, number], deg: number): [number, number] {
110 const a = (-deg * Math.PI) / 180, dx = x - cx, dy = y - cy;
111 return [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)];
112}
113
114function colorAt(px: number, py: number, s: State) {
115 for (let i = LAYERS.length - 1; i >= 0; i--) {
116 const L = LAYERS[i]!;
117 if (L.when && !L.when(s)) continue;
118 let x = px, y = py;
119 if (L.angle) [x, y] = rotateBack(x, y, L.pivot!, L.angle(s));
120 if (L.dy) y -= L.dy(s);
121 for (let j = L.rects.length - 1; j >= 0; j--) {
122 const [rx, ry, rw, rh, c] = L.rects[j]!;
123 if (x >= rx && x < rx + rw && y >= ry && y < ry + rh) return COLORS[c];
124 }
125 }
126 return null;
127}
128
129export function renderFrame(index: number) {
130 const s = stateAt(index);
131 const lines: Run[][] = [];
132 for (let y = Y0; y < Y1; y += PX * 2) {
133 const line: Run[] = [];
134 for (let x = X0; x < X1; x += PX) {
135 const top = colorAt(x + PX / 2, y + PX / 2, s);
136 const bottom = colorAt(x + PX / 2, y + PX * 1.5, s);
137 if (top && bottom) line.push({ text: '▀', fg: top, bg: bottom });
138 else if (top) line.push({ text: '▀', fg: top });
139 else if (bottom) line.push({ text: '▄', fg: bottom });
140 else line.push({ text: ' ' });
141 }
142 lines.push(line);
143 }
144 return lines;
145}
146
147export const frames = Array.from({ length: FRAME_COUNT }, (_, i) => renderFrame(i));
148hooks/crab-flag.ts 116 lines1// Pixel crab celebrating with a checkered flag, for the terminal.
2// Half-block characters (▀ ▄) + 24-bit ANSI color: two pixels per text cell.
3//
4// Ported from crab-flag.mjs: the CLI and the ANSI escapes are gone (a mod cannot run Node or print);
5// renderFrame returns the same ▀/▄ cells as colored runs instead.
6
7import type { Run } from './mascots'
8
9type State = ReturnType<typeof stateAt>
10type Rect = [number, number, number, number, keyof typeof COLORS]
11type Layer = {
12 rects: Rect[]
13 conf?: number
14 when?: (s: State) => boolean
15 dy?: (s: State) => number
16 pivot?: [number, number]
17 angle?: (s: State) => number
18}
19
20const COLORS = {
21 red: '#E04A3F',
22 dark: '#B32F2A',
23 light: '#FF7A66',
24 ink: '#2E2A3F',
25 white: '#FFFFFF',
26 pink: '#F59AB5',
27 pole: '#8F8AA0',
28 yellow: '#FDC232',
29 purple: '#9467F5',
30 cyan: '#3AD0E0',
31 rose: '#FF7AB0',
32};
33// The flag's dark squares use a slightly lighter ink so they still read on dark terminals
34COLORS.ink = '#3A3550';
35
36// Artwork in the SVG's coordinate space: [x, y, width, height, color], drawn in order
37const LAYERS: Layer[] = [
38 { conf: 0, rects: [[90, 60, 10, 10, 'yellow']] },
39 { conf: 0.25, rects: [[140, 40, 10, 10, 'purple']] },
40 { conf: 0.5, rects: [[230, 50, 10, 10, 'cyan']] },
41 { conf: 0.75, rects: [[420, 40, 10, 10, 'rose']] },
42 { conf: 0.6, rects: [[60, 120, 10, 10, 'cyan']] },
43 { conf: 0.1, rects: [[440, 140, 10, 10, 'yellow']] },
44 { conf: 0.35, rects: [[270, 30, 10, 10, 'rose']] },
45 { conf: 0.85, rects: [[180, 90, 10, 10, 'yellow']] },
46 { dy: (s) => s.hop + s.legL, rects: [[150, 300, 20, 10, 'dark'], [140, 310, 10, 25, 'dark'], [160, 320, 20, 10, 'dark'], [150, 330, 10, 25, 'dark'], [175, 335, 20, 10, 'dark'], [170, 345, 10, 25, 'dark']] },
47 { dy: (s) => s.hop + s.legR, rects: [[310, 300, 20, 10, 'dark'], [330, 310, 10, 25, 'dark'], [300, 320, 20, 10, 'dark'], [320, 330, 10, 25, 'dark'], [285, 335, 20, 10, 'dark'], [300, 345, 10, 25, 'dark']] },
48 { dy: (s) => s.hop, pivot: [160, 270], angle: (s) => s.pump, rects: [[160, 230, 15, 40, 'red'], [145, 210, 20, 20, 'red'], [120, 170, 50, 40, 'red'], [150, 150, 20, 20, 'red'], [120, 170, 20, 10, 'light'], [160, 150, 10, 10, 'dark']] },
49 { dy: (s) => s.hop, pivot: [310, 270], angle: (s) => s.wave, when: (s) => s.flagA, rects: [[345, 70, 10, 10, 'white'], [345, 80, 10, 10, 'ink'], [345, 90, 10, 10, 'white'], [345, 100, 10, 10, 'ink'], [345, 110, 10, 10, 'white'], [355, 70, 10, 10, 'ink'], [355, 80, 10, 10, 'white'], [355, 90, 10, 10, 'ink'], [355, 100, 10, 10, 'white'], [355, 110, 10, 10, 'ink'], [365, 80, 10, 10, 'white'], [365, 90, 10, 10, 'ink'], [365, 100, 10, 10, 'white'], [365, 110, 10, 10, 'ink'], [365, 120, 10, 10, 'white'], [375, 80, 10, 10, 'ink'], [375, 90, 10, 10, 'white'], [375, 100, 10, 10, 'ink'], [375, 110, 10, 10, 'white'], [375, 120, 10, 10, 'ink'], [385, 70, 10, 10, 'white'], [385, 80, 10, 10, 'ink'], [385, 90, 10, 10, 'white'], [385, 100, 10, 10, 'ink'], [385, 110, 10, 10, 'white'], [395, 70, 10, 10, 'ink'], [395, 80, 10, 10, 'white'], [395, 90, 10, 10, 'ink'], [395, 100, 10, 10, 'white'], [395, 110, 10, 10, 'ink'], [405, 80, 10, 10, 'white'], [405, 90, 10, 10, 'ink'], [405, 100, 10, 10, 'white'], [405, 110, 10, 10, 'ink'], [405, 120, 10, 10, 'white'], [415, 80, 10, 10, 'ink'], [415, 90, 10, 10, 'white'], [415, 100, 10, 10, 'ink'], [415, 110, 10, 10, 'white'], [415, 120, 10, 10, 'ink']] },
50 { dy: (s) => s.hop, pivot: [310, 270], angle: (s) => s.wave, when: (s) => !s.flagA, rects: [[345, 70, 10, 10, 'white'], [345, 80, 10, 10, 'ink'], [345, 90, 10, 10, 'white'], [345, 100, 10, 10, 'ink'], [345, 110, 10, 10, 'white'], [355, 80, 10, 10, 'ink'], [355, 90, 10, 10, 'white'], [355, 100, 10, 10, 'ink'], [355, 110, 10, 10, 'white'], [355, 120, 10, 10, 'ink'], [365, 70, 10, 10, 'white'], [365, 80, 10, 10, 'ink'], [365, 90, 10, 10, 'white'], [365, 100, 10, 10, 'ink'], [365, 110, 10, 10, 'white'], [375, 70, 10, 10, 'ink'], [375, 80, 10, 10, 'white'], [375, 90, 10, 10, 'ink'], [375, 100, 10, 10, 'white'], [375, 110, 10, 10, 'ink'], [385, 80, 10, 10, 'white'], [385, 90, 10, 10, 'ink'], [385, 100, 10, 10, 'white'], [385, 110, 10, 10, 'ink'], [385, 120, 10, 10, 'white'], [395, 80, 10, 10, 'ink'], [395, 90, 10, 10, 'white'], [395, 100, 10, 10, 'ink'], [395, 110, 10, 10, 'white'], [395, 120, 10, 10, 'ink'], [405, 70, 10, 10, 'white'], [405, 80, 10, 10, 'ink'], [405, 90, 10, 10, 'white'], [405, 100, 10, 10, 'ink'], [405, 110, 10, 10, 'white'], [415, 70, 10, 10, 'ink'], [415, 80, 10, 10, 'white'], [415, 90, 10, 10, 'ink'], [415, 100, 10, 10, 'white'], [415, 110, 10, 10, 'ink']] },
51 { dy: (s) => s.hop, pivot: [310, 270], angle: (s) => s.wave, rects: [[335, 70, 10, 110, 'pole'], [305, 230, 15, 40, 'red'], [315, 210, 20, 20, 'red'], [310, 170, 50, 40, 'red'], [310, 150, 20, 20, 'red'], [340, 170, 20, 10, 'light'], [310, 150, 10, 10, 'dark']] },
52 { dy: (s) => s.hop, rects: [[210, 220, 10, 30, 'dark'], [260, 220, 10, 30, 'dark'], [195, 195, 40, 30, 'white'], [245, 195, 40, 30, 'white'], [200, 210, 10, 10, 'ink'], [210, 200, 10, 10, 'ink'], [220, 210, 10, 10, 'ink'], [250, 210, 10, 10, 'ink'], [260, 200, 10, 10, 'ink'], [270, 210, 10, 10, 'ink'], [170, 250, 140, 100, 'red'], [185, 260, 30, 10, 'light'], [180, 270, 20, 10, 'light'], [190, 320, 10, 10, 'dark'], [280, 315, 10, 10, 'dark'], [170, 340, 140, 10, 'dark'], [225, 282, 30, 16, 'ink'], [232, 290, 16, 8, 'pink']] },
53];
54
55const PX = 10, X0 = 40, X1 = 460, Y0 = 20, Y1 = 380;
56const FRAME_COUNT = 16;
57export const FRAME_MS = 100; // 16 x 100ms = 1.6s, same as the SVG
58
59function stateAt(i: number) {
60 const t = i / FRAME_COUNT;
61 return {
62 hop: i % 4 < 2 ? 0 : -10,
63 legL: i % 2 === 0 ? 0 : -10,
64 legR: i % 2 === 0 ? -10 : 0,
65 wave: i % 8 < 4 ? 0 : -8,
66 pump: i % 8 < 4 ? 12 : 0,
67 flagA: i % 4 < 2,
68 t,
69 };
70}
71
72function rotateBack(x: number, y: number, [cx, cy]: [number, number], deg: number): [number, number] {
73 const a = (-deg * Math.PI) / 180, dx = x - cx, dy = y - cy;
74 return [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)];
75}
76
77function colorAt(px: number, py: number, s: State) {
78 for (let i = LAYERS.length - 1; i >= 0; i--) {
79 const L = LAYERS[i]!;
80 if (L.when && !L.when(s)) continue;
81 let x = px, y = py;
82 if (L.conf !== undefined) {
83 const local = (s.t + L.conf) % 1;
84 if (local >= 0.85) continue;
85 y -= Math.floor(local * 8) * 20;
86 }
87 if (L.dy) y -= L.dy(s);
88 if (L.angle) [x, y] = rotateBack(x, y, L.pivot!, L.angle(s));
89 for (let j = L.rects.length - 1; j >= 0; j--) {
90 const [rx, ry, rw, rh, c] = L.rects[j]!;
91 if (x >= rx && x < rx + rw && y >= ry && y < ry + rh) return COLORS[c];
92 }
93 }
94 return null;
95}
96
97export function renderFrame(index: number) {
98 const s = stateAt(index);
99 const lines: Run[][] = [];
100 for (let y = Y0; y < Y1; y += PX * 2) {
101 const line: Run[] = [];
102 for (let x = X0; x < X1; x += PX) {
103 const top = colorAt(x + PX / 2, y + PX / 2, s);
104 const bottom = colorAt(x + PX / 2, y + PX * 1.5, s);
105 if (top && bottom) line.push({ text: '▀', fg: top, bg: bottom });
106 else if (top) line.push({ text: '▀', fg: top });
107 else if (bottom) line.push({ text: '▄', fg: bottom });
108 else line.push({ text: ' ' });
109 }
110 lines.push(line);
111 }
112 return lines;
113}
114
115export const frames = Array.from({ length: FRAME_COUNT }, (_, i) => renderFrame(i));
116hooks/crab-review.ts 117 lines1// Pixel crab reviewing a long data log, for the terminal: it holds the scroll in one claw while the
2// rows run up the paper, its eyes scan each line, and the other claw taps.
3// Half-block characters (▀ ▄) + 24-bit color: two pixels per text cell, like the other crabs.
4
5import type { Run } from './mascots'
6
7type State = ReturnType<typeof stateAt>
8type Rect = [number, number, number, number, keyof typeof COLORS]
9type Layer = {
10 rects: Rect[]
11 when?: (s: State) => boolean
12 dx?: (s: State) => number
13 dy?: (s: State) => number
14 pivot?: [number, number]
15 angle?: (s: State) => number
16}
17
18const COLORS = {
19 red: '#E04A3F',
20 dark: '#B32F2A',
21 light: '#FF7A66',
22 ink: '#3A3550',
23 white: '#FFFFFF',
24 paper: '#C4BEE9',
25 shade: '#A29ED6',
26 text: '#8985CE',
27};
28
29// Artwork in the same coordinate space as the other crabs: [x, y, width, height, color], drawn in order
30const LAYERS: Layer[] = [
31 { rects: [[150, 300, 20, 10, 'dark'], [140, 310, 10, 25, 'dark'], [160, 320, 20, 10, 'dark'], [150, 330, 10, 25, 'dark'], [175, 335, 20, 10, 'dark'], [170, 345, 10, 25, 'dark']] },
32 { dy: (s) => s.tapLeg, rects: [[310, 300, 20, 10, 'dark'], [330, 310, 10, 25, 'dark'], [300, 320, 20, 10, 'dark'], [320, 330, 10, 25, 'dark'], [285, 335, 20, 10, 'dark'], [300, 345, 10, 25, 'dark']] },
33 // Holds the scroll's edge
34 { rects: [[160, 230, 15, 40, 'red'], [145, 210, 20, 20, 'red'], [120, 170, 50, 40, 'red'], [150, 150, 20, 20, 'red'], [120, 170, 20, 10, 'light'], [160, 150, 10, 10, 'dark']] },
35 { pivot: [310, 270], angle: (s) => s.tap, rects: [[305, 230, 15, 40, 'red'], [315, 210, 20, 20, 'red'], [310, 170, 50, 40, 'red'], [310, 150, 20, 20, 'red'], [340, 170, 20, 10, 'light'], [310, 150, 10, 10, 'dark']] },
36 { rects: [[210, 220, 10, 30, 'dark'], [260, 220, 10, 30, 'dark'], [170, 250, 140, 100, 'red'], [185, 260, 30, 10, 'light'], [180, 270, 20, 10, 'light'], [190, 320, 10, 10, 'dark'], [280, 315, 10, 10, 'dark'], [170, 340, 140, 10, 'dark'], [225, 290, 30, 10, 'ink']] },
37 { when: (s) => !s.blink, rects: [[195, 195, 40, 30, 'white'], [245, 195, 40, 30, 'white']] },
38 { when: (s) => !s.blink, dx: (s) => s.lookX, rects: [[195, 205, 10, 20, 'ink'], [245, 205, 10, 20, 'ink']] },
39 { when: (s) => s.blink, rects: [[195, 210, 40, 10, 'ink'], [245, 210, 40, 10, 'ink']] },
40];
41
42// The scroll sits behind the crab. Each paper row is shifted sideways so the long sheet curls like the original.
43const PAPER_TOP = 110, PAPER_W = 80;
44const PAPER_SHIFT = [10, 10, 0, 0, -10, -10, -10, -10, -10, 0, 0, 0, 0, 10, 10, 10, 10, 10, 0, 0, 0, -10, -10, -10, -10, -10];
45// Lengths of the data rows, repeating; 4 rows take 16 frames to scroll by, so the loop is seamless.
46const LINES = [50, 30, 60, 40];
47
48const PX = 10, X0 = 30, X1 = 370, Y0 = 100, Y1 = 380;
49const FRAME_COUNT = 16;
50export const FRAME_MS = 100;
51
52function stateAt(i: number) {
53 return {
54 scroll: Math.floor(i / 2),
55 lookX: i % 4 < 2 ? 0 : 10,
56 blink: i === 13,
57 tap: i % 8 < 4 ? 0 : -8,
58 tapLeg: i % 8 === 6 || i % 8 === 7 ? -10 : 0,
59 };
60}
61
62function rotateBack(x: number, y: number, [cx, cy]: [number, number], deg: number): [number, number] {
63 const a = (-deg * Math.PI) / 180, dx = x - cx, dy = y - cy;
64 return [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)];
65}
66
67function paperAt(x: number, y: number, s: State) {
68 const row = Math.floor((y - PAPER_TOP) / PX);
69 if (row < 0 || row >= PAPER_SHIFT.length) return null;
70 const left = 50 + PAPER_SHIFT[row]!;
71 if (x < left || x >= left + PAPER_W) return null;
72 // Rolled ends and the right edge are in shade
73 if (row === 0 || row === PAPER_SHIFT.length - 1 || x >= left + PAPER_W - PX) return COLORS.shade;
74 const k = row + s.scroll;
75 if (row >= 2 && row < PAPER_SHIFT.length - 2 && k % 2 === 1) {
76 const len = LINES[Math.floor(k / 2) % LINES.length]!;
77 if (x >= left + PX && x < left + PX + len) return COLORS.text;
78 }
79 return COLORS.paper;
80}
81
82function colorAt(px: number, py: number, s: State) {
83 for (let i = LAYERS.length - 1; i >= 0; i--) {
84 const L = LAYERS[i]!;
85 if (L.when && !L.when(s)) continue;
86 let x = px, y = py;
87 if (L.dx) x -= L.dx(s);
88 if (L.dy) y -= L.dy(s);
89 if (L.angle) [x, y] = rotateBack(x, y, L.pivot!, L.angle(s));
90 for (let j = L.rects.length - 1; j >= 0; j--) {
91 const [rx, ry, rw, rh, c] = L.rects[j]!;
92 if (x >= rx && x < rx + rw && y >= ry && y < ry + rh) return COLORS[c];
93 }
94 }
95 return paperAt(px, py, s);
96}
97
98export function renderFrame(index: number) {
99 const s = stateAt(index);
100 const lines: Run[][] = [];
101 for (let y = Y0; y < Y1; y += PX * 2) {
102 const line: Run[] = [];
103 for (let x = X0; x < X1; x += PX) {
104 const top = colorAt(x + PX / 2, y + PX / 2, s);
105 const bottom = colorAt(x + PX / 2, y + PX * 1.5, s);
106 if (top && bottom) line.push({ text: '▀', fg: top, bg: bottom });
107 else if (top) line.push({ text: '▀', fg: top });
108 else if (bottom) line.push({ text: '▄', fg: bottom });
109 else line.push({ text: ' ' });
110 }
111 lines.push(line);
112 }
113 return lines;
114}
115
116export const frames = Array.from({ length: FRAME_COUNT }, (_, i) => renderFrame(i));
117hooks/octants.ts 77 lines1// Redraws half-block frames (1x2 pixels per cell) with octants (2x4 per cell), so a mascot keeps
2// every pixel at half the width and half the height.
3
4import type { Run } from './mascots'
5
6type Px = string | null
7
8// Indexed by the cell's pixel mask, bits row by row, left then right (Unicode's octant numbering).
9// From UnicodeData 16.0, where 26 patterns reuse older block characters.
10const GLYPHS = [...' 🮂▘▝▀🮅▖▌▞▛▗▚▐▜▂▄▙▟▆█']
11// Catppuccin Macchiato base: the color an empty pixel is compared as.
12const BACKGROUND = '#24273a'
13
14export function toOctants(frames: Run[][][]): Run[][][] {
15 const grids = frames.map(decode)
16 const freq = new Map<string, number>()
17 for (const g of grids) for (const row of g) for (const c of row) if (c) freq.set(c, (freq.get(c) ?? 0) + 1)
18 const most = Math.max(...freq.values())
19 // A cell holds two colors; rarer ones (eyes, mouths, highlights) outrank the shell when three meet.
20 const weight = (c: Px) => (c === null ? 1 : Math.sqrt(most / (freq.get(c) ?? most)))
21
22 return grids.map(grid => {
23 const width = Math.max(...grid.map(r => r.length))
24 const lines: Run[][] = []
25 for (let y = 0; y < grid.length; y += 4) {
26 const line: Run[] = []
27 for (let x = 0; x < width; x += 2) {
28 const px: Px[] = []
29 for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 2; dx++) px.push(grid[y + dy]?.[x + dx] ?? null)
30 const counts = new Map<Px, number>()
31 for (const c of px) counts.set(c, (counts.get(c) ?? 0) + 1)
32 const [a = null, b = null] = [...counts].sort((p, q) => q[1] * weight(q[0]) - p[1] * weight(p[0])).map(([c]) => c)
33 const ink = a ?? b, paper = a === null ? null : b
34 if (ink === null) {
35 line.push({ text: ' ' })
36 continue
37 }
38 let mask = 0
39 px.forEach((c, i) => {
40 const pick = c === a || c === b ? c : distance(c, a) <= distance(c, b) ? a : b
41 if (pick === ink) mask |= 1 << i
42 })
43 line.push(paper ? { text: GLYPHS[mask]!, fg: ink, bg: paper } : { text: GLYPHS[mask]!, fg: ink })
44 }
45 lines.push(line)
46 }
47 return lines
48 })
49}
50
51function decode(frame: Run[][]): Px[][] {
52 const grid: Px[][] = []
53 for (const line of frame) {
54 const top: Px[] = [], bottom: Px[] = []
55 for (const r of line) {
56 for (const ch of [...r.text]) {
57 if (ch === '▀') { top.push(r.fg ?? null); bottom.push(r.bg ?? null) }
58 else if (ch === '▄') { top.push(r.bg ?? null); bottom.push(r.fg ?? null) }
59 else if (ch === '█') { top.push(r.fg ?? null); bottom.push(r.fg ?? null) }
60 else { top.push(r.bg ?? null); bottom.push(r.bg ?? null) }
61 }
62 }
63 grid.push(top, bottom)
64 }
65 return grid
66}
67
68function distance(a: Px, b: Px) {
69 const [r1, g1, b1] = rgb(a ?? BACKGROUND), [r2, g2, b2] = rgb(b ?? BACKGROUND)
70 return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2
71}
72
73function rgb(hex: string) {
74 const n = parseInt(hex.slice(1), 16)
75 return [(n >> 16) & 255, (n >> 8) & 255, n & 255] as const
76}
77types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'clawd-spinner': { state: { tick: number; mascot: number }; galleryTick: number }
4 }
5}
6