SLOPSHOPPER

clawd-spinner

Replaces the loading spinner text with an animated Claude mascot

newpanespinnercommandtimer
★ 4v0.1.0no licenseupdated 2026-10-16tatthien/dotfiles/claude/mods/clawd-spinner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-spinner
│ ┃ Mascots ✕ › fix the failing auth test and add an audit log call │ ┃ ▗▖𜴧𜴧▂ 𜴧𜴧▗▖ 𜷓𜵈 │ ┃ 🮂█▌▀𜶫𜵁𜺠𜵭▀▐█🮂 𜷋▖ ▗𜶻 𜶘𜵰█ ⏺ Read(src/auth.ts) │ ┃ ▀▖ 𜷥𜺨𜺫𜷤 ▟▘ 𜴍𜵏𜴆𜶼𜴕𜶛𜴆𜴆𜴆𜴆𜴆𜴆𜶛𜶷𜷝𜴆𜶛𜴡 𜴅𜶿 ⎿ Read 6 lines │ ┃ ▜𜴧𜴆𜴆███▛ 𜺫𜺨 🮅█▌ ▂𜺣𜺠▂ ▟𜵰🮅 𜺫𜺨 𜶘 ⏺ Update(src/auth.ts) │ ┃ 𜶆▌𜷠█𜴁𜷣█𜷥▐𜶓 𜷀▂𜷕▂▂𜷀▂█ 𜷁▐ ⎿ Added 2 lines, removed 1 line │ ┃ 𜺫𜴡▀▀▀▀▀▀▌▘ 𜶫𜴆🮂▂𜶫██𜵰 🮅𜴡 ⏺ Bash(bun test) │ ┃ 𜷁▐𜷊████𜷣▐𜶗 crab ⎿ 3 pass, 1 fail │ ┃ crab 🮅𜴡🮂🮂🮂🮂🮂🮂🮅𜴡 │ ┃ crab-lift ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ │ › /mascots │ ⎿ clawd-spinner: Showing 6 mascots. │ │ 𜷓𜵈 𜺠𜺠 𜶘𜵰█ 𜺫▄𜵈▗𜵡𜴃𜴃 𜺠 𜴅𜶿▂𜷕▂▂𜷀▂𜺣𜺠𜷚𜷤𜺣 𜴙𜴀 𜶘𜴆🮂🮂███▙𜴱▀𜴂 ▐𜴪𜴪𜶘 𜷁▐𜷊█𜷥𜷥█𜷣▐𜶗 ▐𜴛𜴨▌ 🮅𜴡🮂🮂🮂🮂🮂🮂🮅𜴡𜴆𜴆𜴆𜴆𜴳𜴳𜴆 ✻ Thinking… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mascots
▗▖𜴧𜴧▂ 𜴧𜴧▗▖ 𜷓𜵈 𜺠𜺠 🮂█▌▀𜶫𜵁𜺠𜵭▀▐█🮂 𜷋▖ ▗𜶻 𜶘𜵰█ 𜺫▄𜵈▗𜵡𜴃𜴃 𜺠 ▀▖ 𜷥𜺨𜺫𜷤 ▟▘ 𜴍𜵏𜴆𜶼𜴕𜶛𜴆𜴆𜴆𜴆𜴆𜴆𜶛𜶷𜷝𜴆𜶛𜴡 𜴅𜶿▂𜷕▂▂𜷀▂𜺣𜺠𜷚𜷤𜺣 𜴙𜴀 ▜𜴧𜴆𜴆███▛ 𜺫𜺨 🮅█▌ ▂𜺣𜺠▂ ▟𜵰🮅 𜺫𜺨 𜶘𜴆🮂🮂███▙𜴱▀𜴂 ▐𜴪𜴪𜶘 𜶆▌𜷠█𜴁𜷣█𜷥▐𜶓 𜷀▂𜷕▂▂𜷀▂█ 𜷁▐𜷊█𜷥𜷥█𜷣▐𜶗 ▐𜴛𜴨▌ 𜺫𜴡▀▀▀▀▀▀▌▘ 𜶫𜴆🮂▂𜶫██𜵰 🮅𜴡🮂🮂🮂🮂🮂🮂🮅𜴡𜴆𜴆𜴆𜴆𜴳𜴳𜴆 𜷁▐𜷊████𜷣▐𜶗 crab-angry crab 🮅𜴡🮂🮂🮂🮂🮂🮂🮅𜴡 crab-lift
Spinner
𜷓𜵈 𜺠𜺠 𜶘𜵰█ 𜺫▄𜵈▗𜵡𜴃𜴃 𜺠 𜴅𜶿▂𜷕▂▂𜷀▂𜺣𜺠𜷚𜷤𜺣 𜴙𜴀 𜶘𜴆🮂🮂███▙𜴱▀𜴂 ▐𜴪𜴪𜶘 𜷁▐𜷊█𜷥𜷥█𜷣▐𜶗 ▐𜴛𜴨▌ 🮅𜴡🮂🮂🮂🮂🮂🮂🮅𜴡𜴆𜴆𜴆𜴆𜴳𜴳𜴆 ✻ Thinking…
README

.dotfiles

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

# >>> done
Source 10 files
hooks/register.tsx 115 lines
1import { 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}
115
hooks/mascots.ts 29 lines
1import * 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}
29
hooks/crab.ts 157 lines
1// 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));
157
hooks/crab-lift.ts 132 lines
1// 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));
132
hooks/crab-angry.ts 130 lines
1// 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));
130
hooks/crab-fine.ts 148 lines
1// 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));
148
hooks/crab-flag.ts 116 lines
1// 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));
116
hooks/crab-review.ts 117 lines
1// 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));
117
hooks/octants.ts 77 lines
1// 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}
77
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'clawd-spinner': { state: { tick: number; mascot: number }; galleryTick: number }
4  }
5}
6