A tall aquarium pane: tropical fish, seahorse and jellyfish above a coral floor; at work the pump runs, the lights come up and a yellow submarine goes down.


A tall aquarium pane with tropical fish, a seahorse and jellyfish above a coral floor. While Claude works the pump runs, the lights come up and a small yellow submarine goes down through the water; when the turn ends the tank goes calm again.
This is a mod for Claude Code: a decorative panel drawn as a hand-written SVG animation. It changes between an idle look and a working look so you have something calm to watch while Claude runs a long task.
claude plugin marketplace add Gnosis333/mod_gallery
claude plugin install reef-tank@mod-gallery --scope user
Then run /reload-plugins or start a new session. The panel opens only when you run /reef, so it costs nothing until you ask for it.
Labels are English by default. For Korean, run echo '{"language":"ko"}' | claude plugin configure reef-tank@mod-gallery --values-stdin and restart. Use en to switch back.
It only draws an animated SVG pane and one slash command (/reef), and it reads Claude's idle or working state to switch the scene. It makes no network requests, reads or writes no files, runs no processes, and sends no data anywhere.
Part of mod_gallery. MIT licensed.
hooks/register.tsx 815 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { Reef } from '../types'
5
6const PANE = 'reef-tank'
7const STRINGS = {
8 en: {
9 title: 'Reef tank',
10 alt: 'A tank with corals and anemones where colourful tropical fish swim and bubbles rise now and then.',
11 working: (clock: string, tools: number) => `Working ${clock} · tools ${tools}`,
12 idle: (pearls: number) => `Idle · pearls ${pearls}`,
13 opened: 'Opened the reef tank pane.',
14 note: 'The animation shows in the desktop app.',
15 },
16 ko: {
17 title: '어항',
18 alt: '산호와 말미잘이 있는 수족관에서 알록달록한 열대어가 헤엄치고, 아래에서 물방울이 가끔 올라옵니다.',
19 working: (clock: string, tools: number) => `작업 중 ${clock} · 도구 ${tools}회`,
20 idle: (pearls: number) => `쉬는 중 · 진주 ${pearls}개`,
21 opened: '어항을 열었습니다.',
22 note: '그림 움직임은 데스크톱 앱에서 보입니다.',
23 },
24}
25
26type Words = (typeof STRINGS)['en']
27
28// ---scene-start
29type Pt = [number, number]
30
31const W = 400
32const H = 1500
33const DY = 500 // the whole sea floor sits this far lower
34const FLOOR = 962
35
36const n = (v: number) => String(Math.round(v * 10) / 10)
37const EASE = '.45 0 .55 1'
38
39const sway = (deg: number, cx: number, cy: number, dur: number, begin: number) =>
40 `<animateTransform attributeName="transform" type="rotate" values="${-deg} ${cx} ${cy};${deg} ${cx} ${cy};${-deg} ${cx} ${cy}" keyTimes="0;.5;1" calcMode="spline" keySplines="${EASE};${EASE}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>`
41
42let cid = 0
43const nextId = (p: string) => `${p}${++cid}`
44
45const DEFS =
46 '<defs>' +
47 '<linearGradient id="water" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#86d6ea"/><stop offset=".45" stop-color="#2f97c0"/><stop offset="1" stop-color="#103f66"/></linearGradient>' +
48 '<linearGradient id="ray" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".6"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
49 '<linearGradient id="sand" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ead9a9"/><stop offset="1" stop-color="#bda772"/></linearGradient>' +
50 '<linearGradient id="tube" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e2a82e"/><stop offset=".45" stop-color="#f7d767"/><stop offset="1" stop-color="#d79a20"/></linearGradient>' +
51 '<linearGradient id="lid" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff6c8"/><stop offset="1" stop-color="#fff6c8" stop-opacity="0"/></linearGradient>' +
52 '<linearGradient id="glow" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff8d0" stop-opacity=".75"/><stop offset="1" stop-color="#fff8d0" stop-opacity="0"/></linearGradient>' +
53 '<linearGradient id="beam" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fffbd0" stop-opacity=".7"/><stop offset="1" stop-color="#fffbd0" stop-opacity="0"/></linearGradient>' +
54 `<clipPath id="tank"><rect x="8" y="26" width="384" height="${H - 26}"/></clipPath>` +
55 '<clipPath id="brain"><path d="M176 962 C176 904 198 888 219 888 C240 888 262 904 262 962Z"/></clipPath>' +
56 '</defs>'
57
58// ---- water, light, surface -----------------------------------------------------------
59const RAYS = [
60 [40, 70, 0, 8],
61 [120, 50, 3, 11],
62 [210, 80, 6, 9],
63 [300, 60, 1, 13],
64]
65const rays = RAYS.map(
66 ([x, w, b, d]) =>
67 `<polygon points="${x},26 ${x + w},26 ${x + w + 90},1180 ${x + 20},1180" fill="url(#ray)" opacity=".1">` +
68 `<animate attributeName="opacity" values=".05;.2;.05" dur="${d}s" begin="${-b}s" repeatCount="indefinite"/>` +
69 `<animateTransform attributeName="transform" type="translate" values="0 0;22 0;0 0" dur="${d + 4}s" begin="${-b}s" repeatCount="indefinite"/></polygon>`,
70).join('')
71
72const surface =
73 '<path d="M8 34' +
74 ' q24 -8 48 0 t48 0'.repeat(5) +
75 ' V26 H8Z" fill="#c7eef7" opacity=".7">' +
76 '<animateTransform attributeName="transform" type="translate" values="0 0;-96 0" dur="7s" repeatCount="indefinite"/></path>'
77
78const PLANKTON: [number, number, number, number][] = [
79 [60, 180, 14, 0],
80 [140, 330, 18, 4],
81 [250, 120, 16, 2],
82 [330, 420, 20, 7],
83 [90, 560, 17, 5],
84 [300, 640, 19, 9],
85 [180, 780, 15, 3],
86 [350, 250, 21, 6],
87]
88const plankton = [
89 ...PLANKTON,
90 ...PLANKTON.map(([x, y, d, b]): [number, number, number, number] => [((x + 130) % 360) + 20, y + 640, d + 3, b + 2]),
91].map(
92 ([x, y, d, b]) =>
93 `<circle cx="${x}" cy="${y}" r=".9" fill="#fff" opacity=".5"><animateMotion path="M0 0 C10 -14 22 -6 12 10 C4 24 -10 14 0 0" dur="${d}s" begin="${-b}s" repeatCount="indefinite"/></circle>`,
94).join('')
95
96const BACK =
97 `<path d="M8 ${FLOOR} C40 900 80 912 110 942 C150 892 200 906 240 936 C290 896 340 916 392 950 V${FLOOR}Z" fill="#1a5d80" opacity=".7"/>` +
98 `<path d="M8 ${FLOOR} C60 930 120 940 170 952 C240 930 310 944 392 936 V${FLOOR}Z" fill="#14507a" opacity=".8"/>`
99
100// ---- plants and corals ----------------------------------------------------------------
101const kelp = (x: number, ht: number, color: string, dur: number, begin: number) =>
102 `<g>${sway(3.2, x, FLOOR, dur, -begin)}` +
103 `<path d="M${x} ${FLOOR} q-14 ${-ht / 4} 0 ${-ht / 2} q14 ${-ht / 4} 0 ${-ht / 2}" fill="none" stroke="${color}" stroke-width="8" stroke-linecap="round"/>` +
104 `<path d="M${x + 1} ${FLOOR} q-14 ${-ht / 4} 0 ${-ht / 2} q14 ${-ht / 4} 0 ${-ht / 2}" fill="none" stroke="#fff" stroke-opacity=".18" stroke-width="2.4" stroke-linecap="round"/></g>`
105
106const stag = (x: number, y: number, ang: number, len: number, depth: number): string => {
107 const x2 = x + Math.sin(ang) * len
108 const y2 = y - Math.cos(ang) * len
109 const color = ['#ffa583', '#f58a5e', '#e8693c', '#d4562e', '#c04a28'][Math.min(depth, 4)]
110 let out = `<path d="M${n(x)} ${n(y)} L${n(x2)} ${n(y2)}" fill="none" stroke="${color}" stroke-width="${n(2.2 + depth * 1.9)}" stroke-linecap="round"/>`
111 if (depth > 0) {
112 for (const da of depth >= 2 ? [-0.55, 0.02, 0.5] : [-0.5, 0.45]) {
113 out += stag(x2, y2, ang + da, len * 0.74, depth - 1)
114 }
115 }
116
117 return out
118}
119
120const STAGHORN =
121 `<g>${sway(1.6, 55, FLOOR, 8, 0)}` +
122 [
123 [28, -0.32, 54],
124 [56, 0.04, 62],
125 [82, 0.34, 52],
126 ]
127 .map(([x, a, l]) => stag(x, FLOOR + 2, a, l, 3))
128 .join('') +
129 '</g>'
130
131const SQUIGGLES = Array.from(
132 { length: 9 },
133 (_, i) => `<path d="M170 ${898 + i * 8} q8 -8 16 0${' t16 0'.repeat(6)}" fill="none" stroke="#a8682f" stroke-width="2.2" stroke-linecap="round"/>`,
134).join('')
135
136const BRAIN =
137 '<path d="M176 962 C176 904 198 888 219 888 C240 888 262 904 262 962Z" fill="#dc9f5c"/>' +
138 `<g clip-path="url(#brain)">${SQUIGGLES}<ellipse cx="206" cy="900" rx="22" ry="8" fill="#fff" opacity=".2"/></g>`
139
140const TUBES = [
141 [346, 90],
142 [357, 134],
143 [368, 72],
144 [379, 114],
145 [389, 62],
146]
147 .map(
148 ([x, ht], i) =>
149 `<g>${sway(1.5, x, FLOOR, 5 + i * 0.6, i)}<rect x="${x - 4.5}" y="${FLOOR - ht}" width="9" height="${ht + 2}" rx="4.5" fill="url(#tube)"/>` +
150 `<ellipse cx="${x}" cy="${FLOOR - ht + 1}" rx="3.6" ry="2" fill="#7a4a14"/>` +
151 `<path d="M${x - 3} ${FLOOR - ht - 1} l-1 -5 M${x} ${FLOOR - ht - 1} l0 -6 M${x + 3} ${FLOOR - ht - 1} l1 -5" stroke="#ff9fb8" stroke-width="1.4" stroke-linecap="round"/></g>`,
152 )
153 .join('')
154
155const ANEMONE = (front: boolean) => {
156 if (!front) {
157 return '<ellipse cx="320" cy="960" rx="24" ry="7" fill="#8c3a73"/>'
158 }
159
160 return Array.from({ length: 17 }, (_, i) => {
161 const x = 300 + i * 2.5
162 const len = 38 + ((i * 7) % 5) * 5
163 const lean = (i - 8) * 1.6
164
165 return (
166 `<g>${sway(7, x, 958, 2.2 + (i % 4) * 0.5, -i * 0.3)}` +
167 `<path d="M${x} 958 q${n(lean * 0.3)} ${-len / 2} ${n(lean)} ${-len}" fill="none" stroke="#e0529c" stroke-width="3.2" stroke-linecap="round"/>` +
168 `<circle cx="${n(x + lean)}" cy="${958 - len}" r="2.6" fill="#ffd0e6"/></g>`
169 )
170 }).join('')
171}
172
173const star = (cx: number, cy: number, r: number, fill: string) => {
174 const pts = Array.from({ length: 10 }, (_, i) => {
175 const rr = i % 2 === 0 ? r : r * 0.45
176 const a = (Math.PI / 5) * i - Math.PI / 2
177
178 return `${n(cx + Math.cos(a) * rr)},${n(cy + Math.sin(a) * rr)}`
179 }).join(' ')
180
181 return `<polygon points="${pts}" fill="${fill}" stroke="#a63c22" stroke-width=".8" stroke-linejoin="round"/>`
182}
183
184const urchin = (cx: number, cy: number) =>
185 Array.from({ length: 22 }, (_, i) => {
186 const a = (Math.PI * 2 * i) / 22
187
188 return `<path d="M${n(cx)} ${n(cy)} L${n(cx + Math.cos(a) * 12)} ${n(cy + Math.sin(a) * 12)}" stroke="#3b2a55" stroke-width="1.4" stroke-linecap="round"/>`
189 }).join('') + `<circle cx="${cx}" cy="${cy}" r="6" fill="#5a3f86"/>`
190
191const FOREGROUND =
192 star(150, 986, 11, '#f2653f') +
193 urchin(92, 978) +
194 '<path d="M200 988 q-9 -2 -8 -9 q4 -7 11 -3 q6 4 1 9 q-3 3 -6 0" fill="#f1d8c4" stroke="#c79a7a" stroke-width="1.1"/>' +
195 '<ellipse cx="180" cy="991" rx="4" ry="2.4" fill="#9d8a66"/><ellipse cx="305" cy="990" rx="3" ry="2" fill="#a29072"/><ellipse cx="122" cy="992" rx="3.4" ry="2" fill="#a29072"/>'
196
197// ---- the air stone, the chest and the pearls it keeps ------------------------------------
198// A burst: the bubbles leave one after another, rise for `share` of the cycle, and the rest is quiet.
199const SHARE = 0.5
200const BUBBLER = (x: number, y: number, count: number, cycle: number, begin: number, share = SHARE, gap = 0.45) =>
201 Array.from({ length: count }, (_, i) => {
202 const r = 1.8 + ((i * 5) % 4) * 0.8
203 const b = begin + i * gap
204 const steps = Array.from({ length: 12 }, (_, j) => j)
205 const wob = [...steps.map(j => n(x + Math.sin(j * 1.3 + i) * 5)), n(x)].join(';')
206 const wobTimes = [...steps.map(j => Math.round((j / 11) * share * 1000) / 1000), 1].join(';')
207
208 return (
209 `<circle cx="${x}" cy="${y}" r="0" fill="#fff" fill-opacity=".22" stroke="#fff" stroke-opacity=".85" stroke-width=".8" opacity="0">` +
210 `<animate attributeName="cy" values="${y};52;52" keyTimes="0;${share};1" dur="${cycle}s" begin="${n(b)}s" repeatCount="indefinite"/>` +
211 `<animate attributeName="cx" values="${wob}" keyTimes="${wobTimes}" dur="${cycle}s" begin="${n(b)}s" repeatCount="indefinite"/>` +
212 `<animate attributeName="r" values="${n(r * 0.5)};${n(r * 1.5)};${n(r * 1.5)}" keyTimes="0;${share};1" dur="${cycle}s" begin="${n(b)}s" repeatCount="indefinite"/>` +
213 `<animate attributeName="opacity" values="0;.9;.9;0;0" keyTimes="0;.03;${Math.round((share - 0.05) * 1000) / 1000};${share};1" dur="${cycle}s" begin="${n(b)}s" repeatCount="indefinite"/></circle>`
214 )
215 }).join('')
216
217const AIRSTONE =
218 '<ellipse cx="28" cy="987" rx="7" ry="3" fill="#8a949c"/><path d="M28 984 q4 -12 -2 -22" fill="none" stroke="#c9d3d9" stroke-width="1.2" opacity=".5"/>'
219
220const LID =
221 '<path d="M262 975 q0 -12 17 -12 q17 0 17 12Z" fill="#a56a31"/><path d="M262 973 q17 -3 34 0" fill="none" stroke="#e1b84a" stroke-width="2"/>'
222
223// Resting, the lid opens now and then to let a burst out; at work it stands open and glows.
224const chest = (isWorking: boolean) =>
225 '<rect x="262" y="975" width="34" height="15" rx="2" fill="#8b5a2b"/><rect x="262" y="980" width="34" height="2.4" fill="#e1b84a"/>' +
226 '<rect x="266" y="972" width="26" height="4" fill="#f2c94c"/>' +
227 (isWorking
228 ? '<circle cx="279" cy="968" r="26" fill="#fff3b0" opacity=".4"><animate attributeName="opacity" values=".22;.55;.22" dur="2.4s" repeatCount="indefinite"/></circle>' +
229 `<g transform="rotate(-40 262 975)">${LID}</g>`
230 : `<g><animateTransform attributeName="transform" type="rotate" values="0 262 975;-40 262 975;-40 262 975;0 262 975;0 262 975" keyTimes="0;.03;.35;.4;1" dur="31s" begin="12s" repeatCount="indefinite"/>${LID}</g>`)
231
232const PEARL_POS: Pt[] = [
233 [224, 987],
234 [231, 987],
235 [238, 987],
236 [245, 987],
237 [252, 987],
238 [227.5, 980.5],
239 [234.5, 980.5],
240 [241.5, 980.5],
241 [248.5, 980.5],
242 [231, 974],
243 [238, 974],
244 [245, 974],
245]
246
247const pearl = (x: number, y: number, pop = false) =>
248 `<g transform="translate(${x} ${y})"><g>${pop ? '<animateTransform attributeName="transform" type="scale" values="0;1.6;1" keyTimes="0;.6;1" dur="1.3s" fill="freeze"/>' : ''}` +
249 '<circle r="3.2" fill="#f6f1ea" stroke="#d3c9bd" stroke-width=".6"/><circle cx="-1" cy="-1.1" r="1.05" fill="#fff"/></g></g>'
250
251const pearlCount = (done: number) => (done === 0 ? 0 : ((done - 1) % PEARL_POS.length) + 1)
252
253// ---- fish (drawn at 0,0 facing +x) -------------------------------------------------------
254const tail = (root: number, deg: number, dur: number, shape: string) =>
255 `<g><animateTransform attributeName="transform" type="rotate" values="${-deg} ${root} 0;${deg} ${root} 0;${-deg} ${root} 0" dur="${dur}s" repeatCount="indefinite"/>${shape}</g>`
256
257const eye = (x: number, y = -2, r = 1.7) =>
258 `<circle cx="${x}" cy="${y}" r="${r + 0.9}" fill="#fff"/><circle cx="${x + 0.3}" cy="${y}" r="${r}" fill="#1c2430"/>`
259
260const clown = () => {
261 const id = nextId('cf')
262
263 return (
264 `<clipPath id="${id}"><ellipse rx="14" ry="8"/></clipPath>` +
265 tail(-12, 14, 0.4, '<path d="M-12 0 L-23 -8 Q-19.5 0 -23 8Z" fill="#f58a2f" stroke="#1f1a17" stroke-width="1"/>') +
266 '<path d="M-4 -7 Q2 -13 8 -6Z" fill="#f58a2f" stroke="#1f1a17" stroke-width="1"/>' +
267 '<ellipse rx="14" ry="8" fill="#f58a2f"/>' +
268 `<g clip-path="url(#${id})"><rect x="-6" y="-9" width="4.6" height="18" fill="#fff" stroke="#1f1a17" stroke-width=".8"/><rect x="6" y="-9" width="3.2" height="18" fill="#fff" stroke="#1f1a17" stroke-width=".8"/></g>` +
269 '<ellipse rx="14" ry="8" fill="none" stroke="#1f1a17" stroke-width="1"/>' +
270 eye(9, -2.2, 1.5)
271 )
272}
273
274const BLUE_TANG = () =>
275 tail(-14, 13, 0.45, '<path d="M-14 0 L-26 -9 Q-22 0 -26 9Z" fill="#ffd21f"/>') +
276 '<path d="M-8 -9 Q2 -17 12 -6Z" fill="#1d55b8"/><path d="M-8 9 Q2 15 10 6Z" fill="#1d55b8"/>' +
277 '<ellipse rx="17" ry="10.5" fill="#2f78e0"/>' +
278 '<path d="M-12 -4 Q-2 -9 6 -3 Q-1 1 4 6 Q-7 8 -12 2Z" fill="#14204a"/>' +
279 '<path d="M9 -7 q6 3 6 9" fill="none" stroke="#14204a" stroke-width="1.4"/>' +
280 '<ellipse cx="3" cy="3" rx="3" ry="2" fill="#ffd21f" opacity=".9"/>' +
281 eye(11, -2.5, 1.5)
282
283const YELLOW_TANG = () =>
284 tail(-14, 13, 0.45, '<path d="M-14 0 L-25 -9 Q-21 0 -25 9Z" fill="#f4cc1a"/>') +
285 '<path d="M-10 -9 Q2 -18 13 -6Z" fill="#f2c514"/><path d="M-8 9 Q2 16 10 6Z" fill="#f2c514"/>' +
286 '<ellipse rx="16" ry="10.5" fill="#f9dc2e"/><ellipse cx="-1" cy="-4" rx="9" ry="3.4" fill="#fff" opacity=".28"/>' +
287 '<circle cx="-12" cy="0.5" r="1.4" fill="#fff"/>' +
288 '<path d="M14 -1 L19 1 L14 3Z" fill="#f4cc1a"/>' +
289 eye(9, -2.5, 1.5)
290
291const ANGELFISH = () =>
292 tail(-9, 10, 0.55, '<path d="M-9 0 L-20 -7 Q-17 0 -20 7Z" fill="#c8d4e0"/>') +
293 '<path d="M-8 -11 Q-12 -22 -24 -28 Q-6 -27 6 -13Z" fill="#cfd9e6" opacity=".92"/>' +
294 '<path d="M-8 11 Q-12 21 -24 26 Q-6 25 6 13Z" fill="#cfd9e6" opacity=".92"/>' +
295 '<path d="M-11 0 Q-2 -22 12 -3 Q-2 21 -11 0Z" fill="#e9eef4"/>' +
296 '<path d="M-3 -10 L-3 10 M3 -9 L3 9 M-8 -4 L-8 4" stroke="#2d3a52" stroke-width="2.2" stroke-linecap="round"/>' +
297 '<path d="M6 -2 Q12 -1 12 -3 Q9 1 6 2Z" fill="#f2c94c"/>' +
298 eye(7, -2.2, 1.3)
299
300const BUTTERFLYFISH = () =>
301 tail(-11, 14, 0.4, '<path d="M-11 0 L-19 -7 Q-16 0 -19 7Z" fill="#f08a2f"/>') +
302 '<ellipse rx="12" ry="11" fill="#f6efc8"/><path d="M-10 -4 Q0 -14 11 -4 L11 -9 Q0 -16 -10 -9Z" fill="#f2c53a"/>' +
303 '<path d="M-9 -2 Q0 -11 10 -2 Q0 -6 -9 -2Z" fill="#f2c53a"/>' +
304 '<path d="M6 -11 L6 11" stroke="#1c2430" stroke-width="2.6" opacity=".9"/>' +
305 '<path d="M11 -2 L18 0 L11 2Z" fill="#f2c53a"/>' +
306 eye(7.4, -2.2, 1.2)
307
308const PARROTFISH = () =>
309 tail(-20, 12, 0.5, '<path d="M-19 0 L-31 -9 L-27 0 L-31 9Z" fill="#2fa58a"/>') +
310 '<path d="M-14 -8 Q-2 -17 10 -7Z" fill="#35b698"/>' +
311 '<ellipse rx="21" ry="9" fill="#3fbf9f"/><path d="M-19 1 Q0 14 19 3 Q0 9 -19 1Z" fill="#ec6fa6"/>' +
312 '<ellipse cx="-4" cy="-2" rx="9" ry="3" fill="#fff" opacity=".25"/>' +
313 '<path d="M19 -3 q5 3 3 7 q-2 2 -4 -1Z" fill="#f3ecc8"/>' +
314 eye(12, -2.5, 1.5)
315
316const BETTA = () =>
317 tail(-10, 11, 0.7, '<path d="M-10 0 C-24 -22 -44 -16 -42 0 C-44 16 -24 22 -10 0Z" fill="#e24a8d" opacity=".85"/><path d="M-12 0 C-24 -10 -34 -8 -36 0 C-34 8 -24 10 -12 0Z" fill="#7a2ea8" opacity=".7"/>') +
318 '<path d="M-6 -7 C0 -20 14 -18 12 -6Z" fill="#e24a8d" opacity=".85"/><path d="M-4 7 C2 18 12 16 10 6Z" fill="#e24a8d" opacity=".85"/>' +
319 '<ellipse rx="14" ry="6.6" fill="#b8326e"/><ellipse cx="-2" cy="-2" rx="8" ry="2.2" fill="#fff" opacity=".22"/>' +
320 eye(9, -1.6, 1.4)
321
322const tetra = () =>
323 tail(-6, 16, 0.3, '<path d="M-6 0 L-12 -4 L-12 4Z" fill="#cfd9e8"/>') +
324 '<ellipse rx="8" ry="3.3" fill="#d9e3f0"/><path d="M-7 -.6 L8 -.6" stroke="#2fc2ff" stroke-width="1.8"/>' +
325 '<path d="M-6 1.2 Q0 3.4 8 1.2 L7 3 Q0 4 -6 2.6Z" fill="#e5483a"/>' +
326 '<circle cx="5.4" cy="-1" r="1" fill="#1c2430"/>'
327
328const SCHOOL = () =>
329 (
330 [
331 [0, 0],
332 [-12, 6],
333 [-10, -7],
334 [-24, 2],
335 [-22, -9],
336 [-33, 7],
337 ] as Pt[]
338 )
339 .map(([x, y]) => `<g transform="translate(${x} ${y})">${tetra()}</g>`)
340 .join('')
341
342// A smooth S through `from` and `to`, level where it meets a point: the fish never pitches steeply.
343const cubic = (p: Pt, q: Pt) => {
344 const dx = (q[0] - p[0]) * 0.38
345
346 return `C${n(p[0] + dx)} ${n(p[1])} ${n(q[0] - dx)} ${n(q[1])} ${n(q[0])} ${n(q[1])}`
347}
348
349const cubicLength = (p: Pt, q: Pt) => {
350 const dx = (q[0] - p[0]) * 0.38
351 let total = 0
352 let [px, py] = p
353 for (let i = 1; i <= 24; i++) {
354 const t = i / 24
355 const u = 1 - t
356 const x = u * u * u * p[0] + 3 * u * u * t * (p[0] + dx) + 3 * u * t * t * (q[0] - dx) + t * t * t * q[0]
357 const y = u * u * u * p[1] + 3 * u * u * t * p[1] + 3 * u * t * t * q[1] + t * t * t * q[1]
358 total += Math.hypot(x - px, y - py)
359 ;[px, py] = [x, y]
360 }
361
362 return total
363}
364
365// Out to the right through `m1` to `b`, and back to the left through `m2`: the fish faces
366// the way it swims, flipped on the way back, and rests a moment where it turns.
367const swim = (draw: string, a: Pt, m1: Pt, b: Pt, m2: Pt, dur: number, begin: number, scale = 1, upright = false) => {
368 const out = cubicLength(a, m1) + cubicLength(m1, b)
369 const back = cubicLength(b, m2) + cubicLength(m2, a)
370 const turn = n(out / (out + back))
371 const path = `M${a[0]} ${a[1]} ${cubic(a, m1)} ${cubic(m1, b)} ${cubic(b, m2)} ${cubic(m2, a)}`
372
373 return (
374 '<g>' +
375 `<animateMotion path="${path}" dur="${dur}s" begin="${-begin}s" repeatCount="indefinite" calcMode="spline" keyPoints="0;${turn};1" keyTimes="0;.5;1" keySplines=".4 0 .6 1;.4 0 .6 1"${upright ? '' : ' rotate="auto"'}/>` +
376 `<g transform="scale(${scale})"><g><animateTransform attributeName="transform" type="scale" values="${upright ? '1 1;-1 1' : '1 1;1 -1'}" keyTimes="0;.5" calcMode="discrete" dur="${dur}s" begin="${-begin}s" repeatCount="indefinite"/>${draw}</g></g></g>`
377 )
378}
379
380// A closed, smooth loop through the points, for things that drift rather than swim.
381const smoothLoop = (pts: Pt[]) => {
382 const m = pts.length
383 let d = `M${pts[0][0]} ${pts[0][1]}`
384 for (let i = 0; i < m; i++) {
385 const p0 = pts[(i - 1 + m) % m]
386 const p1 = pts[i]
387 const p2 = pts[(i + 1) % m]
388 const p3 = pts[(i + 2) % m]
389 d += ` C${n(p1[0] + (p2[0] - p0[0]) / 6)} ${n(p1[1] + (p2[1] - p0[1]) / 6)} ${n(p2[0] - (p3[0] - p1[0]) / 6)} ${n(p2[1] - (p3[1] - p1[1]) / 6)} ${p2[0]} ${p2[1]}`
390 }
391
392 return d
393}
394
395// ---- seahorse: upright, drifting slowly, with a rocking body and a fluttering fin --------------------
396const SEAHORSE = () =>
397 '<g><animateTransform attributeName="transform" type="rotate" values="-5 0 8;5 0 8;-5 0 8" keyTimes="0;.5;1" calcMode="spline" keySplines=".45 0 .55 1;.45 0 .55 1" dur="3.4s" repeatCount="indefinite"/>' +
398 // curled tail, then the belly stroke
399 '<path d="M3 16 C-4 24 3 31 -5 33 C-12 35 -14 28 -9 26 C-7 25 -5 28 -7 29" fill="none" stroke="#e8902c" stroke-width="5.5" stroke-linecap="round"/>' +
400 '<path d="M4 -14 C11 -6 9 6 3 15" fill="none" stroke="#f4a542" stroke-width="11.5" stroke-linecap="round"/>' +
401 '<path d="M-1 -4 q7 3 13 0 M-1 3 q7 3 12 0 M0 10 q6 3 10 0" fill="none" stroke="#c97420" stroke-width="1.4" stroke-linecap="round" opacity=".7"/>' +
402 // dorsal fin, fluttering
403 '<g><animateTransform attributeName="transform" type="scale" values="1 1;.55 1;1 1" dur=".35s" repeatCount="indefinite" additive="sum"/>' +
404 '<path d="M-1 -2 C-14 -1 -14 9 -3 11Z" fill="#fbd592" opacity=".85"/></g>' +
405 // head, crest, snout
406 '<ellipse cx="4" cy="-19" rx="8" ry="7" fill="#f4a542"/>' +
407 '<path d="M8 -21 L22 -18.5 L22 -15.5 L8 -14Z" fill="#f4a542"/>' +
408 '<path d="M-2 -24 l1.5 -6 l3 5 l3 -5 l1.4 6" fill="#e8902c" stroke="#e8902c" stroke-width="1" stroke-linejoin="round"/>' +
409 '<path d="M6 -10 q5 3 3 8" fill="none" stroke="#fbd592" stroke-width="2.4" stroke-linecap="round" opacity=".9"/>' +
410 eye(6.4, -20, 1.5) +
411 '</g>'
412
413// ---- jellyfish: the bell pulses, jets up, and sinks while the tentacles wave ----------------------------
414const jelly = (inner: string, rim: string, scale: number, dur: number, begin: number, loop: Pt[]) => {
415 const id = nextId('jf')
416 const tent = Array.from({ length: 9 }, (_, i) => {
417 const x = -16 + i * 4
418 const len = 24 + ((i * 7) % 4) * 8
419 const a = `M${x} 0 q${i % 2 ? 4 : -4} ${len / 2} 0 ${len} q${i % 2 ? -4 : 4} ${len / 2} 0 ${len}`
420 const b = `M${x} 0 q${i % 2 ? -4 : 4} ${len / 2} 0 ${len} q${i % 2 ? 4 : -4} ${len / 2} 0 ${len}`
421
422 return `<path d="${a}" fill="none" stroke="${rim}" stroke-width="1.2" stroke-linecap="round" opacity=".6"><animate attributeName="d" values="${a};${b};${a}" dur="${2.6 + (i % 3) * 0.5}s" begin="${-i * 0.4}s" repeatCount="indefinite"/></path>`
423 }).join('')
424 const arm = [-7, -2, 3, 8]
425 .map(
426 (x, i) =>
427 `<path d="M${x} 0 q-5 10 0 20 q5 10 0 20" fill="none" stroke="${rim}" stroke-width="3.6" stroke-linecap="round" opacity=".5"><animate attributeName="d" values="M${x} 0 q-5 10 0 20 q5 10 0 20;M${x} 0 q5 10 0 20 q-5 10 0 20;M${x} 0 q-5 10 0 20 q5 10 0 20" dur="${3 + i * 0.4}s" begin="${-i}s" repeatCount="indefinite"/></path>`,
428 )
429 .join('')
430 const d = smoothLoop(loop)
431
432 return (
433 `<radialGradient id="${id}" cx=".5" cy=".35" r=".75"><stop offset="0" stop-color="#fff" stop-opacity=".9"/><stop offset=".35" stop-color="${inner}" stop-opacity=".78"/><stop offset="1" stop-color="${rim}" stop-opacity=".5"/></radialGradient>` +
434 `<g><animateMotion path="${d}" dur="${dur}s" begin="${-begin}s" repeatCount="indefinite"/>` +
435 `<g transform="scale(${scale})"><g><animateTransform attributeName="transform" type="translate" values="0 0;0 -16;0 0" keyTimes="0;.3;1" calcMode="spline" keySplines=".3 0 .2 1;.5 0 .5 1" dur="2.8s" begin="${-begin}s" repeatCount="indefinite"/>` +
436 tent +
437 arm +
438 '<g><animateTransform attributeName="transform" type="scale" values="1 1;1.14 .8;1 1" keyTimes="0;.3;1" calcMode="spline" keySplines=".3 0 .2 1;.5 0 .5 1" dur="2.8s" begin="' + `${-begin}s` + '" repeatCount="indefinite"/>' +
439 `<path d="M-21 0 C-21 -32 21 -32 21 0 C15 -5 8 2 0 -3 C-8 2 -15 -5 -21 0Z" fill="url(#${id})" stroke="${rim}" stroke-opacity=".75" stroke-width="1.2"/>` +
440 '<ellipse cx="-5" cy="-17" rx="8" ry="5" fill="#fff" opacity=".35"/><circle cx="6" cy="-8" r="1.4" fill="#fff" opacity=".6"/><circle cx="-8" cy="-6" r="1" fill="#fff" opacity=".5"/>' +
441 '</g></g></g></g>'
442 )
443}
444
445const CLOWNS =
446 swim(clown(), [292, 925], [318, 905], [346, 925], [320, 942], 14, 3, 0.85) +
447 swim(clown(), [296, 940], [322, 920], [344, 936], [318, 952], 17, 11, 0.7)
448
449const FREE =
450 swim(BUTTERFLYFISH(), [60, 160], [200, 120], [340, 200], [190, 270], 34, 20, 1.1) +
451 swim(SCHOOL(), [60, 380], [200, 340], [340, 410], [200, 450], 28, 8, 1.1) +
452 swim(SCHOOL(), [50, 250], [190, 215], [345, 270], [200, 310], 33, 18, 1.0) +
453 swim(BUTTERFLYFISH(), [50, 650], [190, 700], [340, 630], [200, 590], 36, 3, 1.15) +
454 swim(BLUE_TANG(), [50, 560], [180, 640], [340, 530], [190, 470], 38, 5, 1.25) +
455 swim(YELLOW_TANG(), [40, 790], [190, 860], [350, 770], [200, 710], 32, 12, 1.1) +
456 swim(BETTA(), [70, 960], [190, 1030], [330, 940], [180, 890], 42, 9, 1.2) +
457 swim(ANGELFISH(), [60, 1100], [200, 1040], [330, 1110], [190, 1170], 46, 17, 1.0) +
458 swim(PARROTFISH(), [50, 1210], [190, 1260], [350, 1200], [200, 1160], 50, 26, 1.3) +
459 swim(YELLOW_TANG(), [60, 1300], [190, 1270], [340, 1320], [200, 1350], 40, 30, 1.0) +
460 swim(SEAHORSE(), [150, 880], [210, 840], [270, 920], [210, 980], 70, 14, 1.15, true) +
461 jelly('#ffd0ef', '#ff8fd0', 1.1, 96, 10, [[290, 1240], [130, 960], [210, 560], [300, 860]]) +
462 jelly('#e3d4ff', '#a387f5', 0.8, 120, 55, [[110, 420], [260, 250], [330, 520], [190, 700]]) +
463 jelly('#d2f4ff', '#62c9f2', 1.3, 110, 80, [[200, 1150], [330, 820], [150, 380], [90, 800]])
464
465// ---- what Claude is doing: the lights, the submarine, the pearl -----------------------------------------
466type Look = { working: boolean; prev: boolean; done: number; fresh: boolean; ran?: number }
467
468// Every animation here repeats, so shifting each `begin` back by the time already spent keeps
469// the fish where they were when the picture is drawn again for a new state.
470const retime = (svg: string, elapsed: number) =>
471 svg.replace(/<(animate|animateTransform|animateMotion)\b[^>]*>/g, tag => {
472 const found = / begin="(-?[\d.]+)s"/.exec(tag)
473 const shifted = ` begin="${n((found ? parseFloat(found[1]) : 0) - elapsed)}s"`
474
475 return found ? tag.replace(found[0], shifted) : tag.replace(/^<(\w+)/, `<$1${shifted}`)
476 })
477
478const SUB_AT: Pt = [165, 1110]
479
480// The classic yellow submarine, seen from the side and facing right (about 90 units long).
481const subSprite = (beam: boolean) =>
482 // propeller at the stern: the blades turn edge-on and flat in turn
483 '<g transform="translate(-50 0)"><circle r="3" fill="#8a6a00"/>' +
484 '<g><animateTransform attributeName="transform" type="scale" values="1 1;1 .15;1 1" dur=".28s" repeatCount="indefinite"/>' +
485 '<ellipse cx="-2" cy="-9" rx="2.6" ry="9" fill="#9a7400"/><ellipse cx="-2" cy="9" rx="2.6" ry="9" fill="#9a7400"/></g></g>' +
486 // tail fins
487 '<path d="M-36 -11 L-50 -20 L-45 -4Z" fill="#e5b00e" stroke="#b88a00" stroke-width="1.2" stroke-linejoin="round"/>' +
488 '<path d="M-36 11 L-50 20 L-45 4Z" fill="#e5b00e" stroke="#b88a00" stroke-width="1.2" stroke-linejoin="round"/>' +
489 // conning tower and periscope
490 '<path d="M-13 -15 L-9 -27 L10 -27 L14 -15Z" fill="#f2bd10" stroke="#b88a00" stroke-width="1.5" stroke-linejoin="round"/>' +
491 '<path d="M3 -27 V-37 H12" fill="none" stroke="#8a6a00" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>' +
492 '<rect x="11" y="-39.5" width="3.4" height="5" rx="1" fill="#bfe8ff" stroke="#8a6a00" stroke-width=".8"/>' +
493 // hull
494 '<ellipse rx="44" ry="17.5" fill="#f7cb1e" stroke="#b88a00" stroke-width="1.7"/>' +
495 '<path d="M-41 7 Q0 15 41 7" fill="none" stroke="#e0a000" stroke-width="2.4"/>' +
496 '<ellipse cx="-4" cy="-9.5" rx="31" ry="3.8" fill="#fff" opacity=".35"/>' +
497 '<g fill="#8a6a00"><circle cx="-33" cy="-8" r=".9"/><circle cx="-33" cy="8" r=".9"/><circle cx="33" cy="-8" r=".9"/><circle cx="33" cy="8" r=".9"/></g>' +
498 // portholes, glowing a little
499 [-19, 0, 19]
500 .map(
501 (x, i) =>
502 `<g transform="translate(${x} -1)"><circle r="6.2" fill="#8a6a00"/><circle r="4.6" fill="#a9e0ff"/>` +
503 `<circle r="3" fill="#fff6c8" opacity=".6"><animate attributeName="opacity" values=".35;.8;.35" dur="${2 + i * 0.4}s" repeatCount="indefinite"/></circle>` +
504 '<circle cx="-1.4" cy="-1.6" r="1.1" fill="#fff"/></g>',
505 )
506 .join('') +
507 // headlight and its beam
508 '<circle cx="41" cy="-1" r="3.6" fill="#fffbd0" stroke="#b88a00" stroke-width="1"/>' +
509 (beam
510 ? '<g opacity="0"><animate attributeName="opacity" values="0;1" begin="11s" dur="1.5s" fill="freeze"/>' +
511 `<g><animateTransform attributeName="transform" type="rotate" values="-5 43 -1;6 43 -1;-5 43 -1" keyTimes="0;.5;1" calcMode="spline" keySplines="${EASE};${EASE}" dur="7s" begin="12s" repeatCount="indefinite"/>` +
512 '<polygon points="44,-1 190,-44 190,44" fill="url(#beam)"/></g></g>'
513 : '')
514
515const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
516const ease = (x: number) => x * x * (3 - 2 * x)
517const bez = (p0: Pt, p1: Pt, p2: Pt, p3: Pt, u: number): Pt => {
518 const v = 1 - u
519
520 return [
521 v * v * v * p0[0] + 3 * v * v * u * p1[0] + 3 * v * u * u * p2[0] + u * u * u * p3[0],
522 v * v * v * p0[1] + 3 * v * v * u * p1[1] + 3 * v * u * u * p2[1] + u * u * u * p3[1],
523 ]
524}
525
526const SINK = 12 // seconds to reach the floor
527const PATROL = 30 // seconds for one climb and descent
528const RISE = 490 // how far the patrol climbs
529
530// Where the submarine is `ran` seconds into a turn, so it can leave from there when the turn ends.
531const subAt = (ran: number): Pt => {
532 const [ax, ay] = SUB_AT
533 if (ran < SINK) {
534 const e = ease(clamp(ran / SINK, 0, 1))
535
536 return e < 0.5
537 ? bez([250, 70], [285, 300], [60, 480], [150, 760], e * 2)
538 : bez([150, 760], [215, 960], [120, 1030], [ax, ay], (e - 0.5) * 2)
539 }
540 const p = ((ran - SINK) % PATROL) / PATROL
541 const [x, y] = bez([ax, ay], [ax + 80, ay - 130], [ax - 80, ay - 330], [ax, ay - RISE], ease(p < 0.5 ? p * 2 : (1 - p) * 2))
542
543 return [p < 0.5 ? x : 2 * ax - x, y]
544}
545
546// At work the submarine sinks to the floor, then patrols up and down with its light on; when
547// the turn ends it rises from wherever it is.
548const diver = (w: Look) => {
549 const [ax, ay] = SUB_AT
550 if (w.working) {
551 const down = `M250 70 C285 300 60 480 150 760 C215 960 120 1030 ${ax} ${ay}`
552 const patrol = `M${ax} ${ay} C${ax + 80} ${ay - 130} ${ax - 80} ${ay - RISE + 160} ${ax} ${ay - RISE} C${ax + 80} ${ay - RISE + 160} ${ax - 80} ${ay - 130} ${ax} ${ay}`
553 const tilt = '0 0 0;-17 0 0;-17 0 0;11 0 0;11 0 0;0 0 0'
554 const puffs = [0, 1, 2, 3, 4]
555 .map(
556 i =>
557 '<circle cx="-64" cy="0" r="0" fill="#fff" fill-opacity=".25" stroke="#fff" stroke-opacity=".85" stroke-width=".8" opacity="0">' +
558 `<animate attributeName="cy" values="0;-150" dur="3.5s" begin="${SINK + i * 0.7}s" repeatCount="indefinite"/>` +
559 `<animate attributeName="r" values="1;3" dur="3.5s" begin="${SINK + i * 0.7}s" repeatCount="indefinite"/>` +
560 `<animate attributeName="opacity" values="0;.9;0" dur="3.5s" begin="${SINK + i * 0.7}s" repeatCount="indefinite"/></circle>`,
561 )
562 .join('')
563
564 return (
565 `<g><animateMotion path="${down}" dur="${SINK}s" fill="freeze" calcMode="spline" keyPoints="0;1" keyTimes="0;1" keySplines=".4 0 .3 1"/>` +
566 `<animateMotion path="${patrol}" dur="${PATROL}s" begin="${SINK}s" repeatCount="indefinite" calcMode="spline" keyPoints="0;.5;1" keyTimes="0;.5;1" keySplines="${EASE};${EASE}"/>` +
567 `<g><animateTransform attributeName="transform" type="rotate" values="32 0 0;32 0 0;3 0 0" keyTimes="0;.65;1" dur="${SINK}s" fill="freeze"/>` +
568 `<g><animateTransform attributeName="transform" type="rotate" values="${tilt}" keyTimes="0;.04;.46;.54;.96;1" calcMode="spline" keySplines="${Array(5).fill(EASE).join(';')}" dur="${PATROL}s" begin="${SINK}s" repeatCount="indefinite"/>` +
569 `<g transform="scale(1.2)">${subSprite(true)}</g></g></g>` +
570 puffs +
571 '</g>'
572 )
573 }
574 if (w.prev) {
575 const [sx, sy] = subAt(w.ran ?? SINK)
576 const up = `M${n(sx)} ${n(sy)} C${n(sx)} ${n(sy - 300)} 250 400 250 40`
577 const dur = n(clamp(((sy - 40) / 1070) * 10, 3, 12))
578
579 return (
580 `<g><animateMotion path="${up}" dur="${dur}s" fill="freeze" calcMode="spline" keyPoints="0;1" keyTimes="0;1" keySplines=".5 0 .4 1"/>` +
581 `<animate attributeName="opacity" values="1;1;0" keyTimes="0;.88;1" dur="${dur}s" fill="freeze"/>` +
582 `<g><animateTransform attributeName="transform" type="rotate" values="3 0 0;3 0 0;-32 0 0" keyTimes="0;.08;.3" dur="${dur}s" fill="freeze"/>` +
583 `<g transform="scale(1.2)">${subSprite(false)}</g></g></g>`
584 )
585 }
586
587 return ''
588}
589
590const STATIC_BOTTOM =
591 BACK +
592 kelp(112, 420, '#3f9a5a', 7, 0) +
593 kelp(124, 330, '#58b06b', 8, 2) +
594 kelp(272, 360, '#3f9a5a', 7.5, 1) +
595 kelp(283, 250, '#58b06b', 9, 3) +
596 `<rect x="8" y="${FLOOR - 4}" width="384" height="46" fill="url(#sand)"/>` +
597 `<path d="M8 ${FLOOR + 6} C60 ${FLOOR - 6} 120 ${FLOOR + 4} 190 ${FLOOR - 2} C250 ${FLOOR - 8} 330 ${FLOOR + 2} 392 ${FLOOR - 4} V${H}H8Z" fill="#dfcd9a"/>` +
598 STAGHORN +
599 BRAIN +
600 ANEMONE(false) +
601 TUBES +
602 CLOWNS +
603 ANEMONE(true) +
604 FOREGROUND
605
606const FRAME =
607 `<rect x="0" y="0" width="8" height="${H}" fill="#2b3640"/><rect x="392" y="0" width="8" height="${H}" fill="#2b3640"/>` +
608 `<rect x="8" y="26" width="3" height="${H - 26}" fill="#fff" opacity=".12"/><rect x="389" y="26" width="3" height="${H - 26}" fill="#fff" opacity=".08"/>` +
609 '<rect width="400" height="26" fill="#252d35"/><rect x="40" y="18" width="320" height="6" rx="3" fill="url(#lid)"/>' +
610 `<rect x="0" y="${H - 10}" width="400" height="10" fill="#252d35"/>` +
611 '<path d="M24 60 v640" stroke="#fff" stroke-opacity=".16" stroke-width="5" stroke-linecap="round"/>'
612
613// Resting, the light swells and fades on a slow cycle. At work it holds darker than that, so the
614// submarine's light and the chest stand out; the pump runs without a pause and the
615// submarine goes down.
616const DARK = 0.36 // the swell's darkest, resting
617const LID_DARK = 0.85
618const WORK_DARK = 0.6 // darker still while Claude works
619const WORK_LID = 0.95
620const CYCLE = 36
621
622// Where the swell stands `t` seconds in (close to the animation's own easing): the veil that
623// follows a change of state starts from it, so the light never jumps.
624const swellAt = (t: number) => {
625 const p = (((t % CYCLE) + CYCLE) % CYCLE) / CYCLE
626 const half = p < 0.5 ? p * 2 : (1 - p) * 2
627
628 return half * half * (3 - 2 * half)
629}
630
631const r3 = (v: number) => String(Math.round(v * 1000) / 1000)
632
633const buildScene = (w: Look, elapsed: number) => {
634 const count = pearlCount(w.done)
635 const kept = PEARL_POS.slice(0, Math.max(0, count - 1))
636 .map(([x, y]) => pearl(x, y))
637 .join('')
638
639 const swell = (low: number, high: number) =>
640 `values="${low};${high};${low}" keyTimes="0;.5;1" calcMode="spline" keySplines="${EASE};${EASE}" dur="${CYCLE}s" repeatCount="indefinite"`
641 const breathe = w.working
642 ? ''
643 : `<rect x="8" y="26" width="384" height="${H - 26}" fill="#071a33" opacity="0"><animate attributeName="opacity" ${swell(0, DARK)}/></rect>` +
644 `<rect x="8" y="26" width="384" height="760" fill="url(#glow)" opacity=".2"><animate attributeName="opacity" ${swell(0.2, 0)}/></rect>`
645 const breatheLid = w.working
646 ? ''
647 : `<rect x="40" y="18" width="320" height="6" rx="3" fill="#252d35" opacity="0"><animate attributeName="opacity" ${swell(0, LID_DARK)}/></rect>`
648
649 const tank =
650 '<g clip-path="url(#tank)">' +
651 `<rect x="8" y="26" width="384" height="${H - 26}" fill="url(#water)"/>` +
652 rays +
653 `<g transform="translate(0 ${DY})">${STATIC_BOTTOM}${AIRSTONE}${chest(w.working)}${kept}</g>` +
654 FREE +
655 BUBBLER(28, 978 + DY, 12, 30, 4) +
656 BUBBLER(279, 970 + DY, 14, 31, 12) +
657 (w.working ? BUBBLER(28, 978 + DY, 9, 14, 0, 1, 14 / 9) + BUBBLER(279, 970 + DY, 8, 15, 0.7, 1, 15 / 8) : '') +
658 plankton +
659 breathe +
660 '</g>' +
661 surface
662
663 // A veil over the swell: going to work it sinks from wherever the swell was to its work darkness and
664 // stays; coming back it lifts, and the swell takes over again.
665 const now = swellAt(elapsed)
666 const veilRect = (x: number, y: number, width: number, height: number, fill: string, a: number, b: number) =>
667 `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${fill}" opacity="${r3(b)}"><animate attributeName="opacity" values="${r3(a)};${r3(b)}" dur="1.4s" fill="freeze"/></rect>`
668 const lift = (target: number, covered: number) => Math.max(0, (target - covered) / (1 - covered))
669 let veil = ''
670 let lidVeil = ''
671 if (w.working) {
672 veil = veilRect(8, 26, 384, H - 26, '#071a33', DARK * now, WORK_DARK)
673 lidVeil = veilRect(40, 18, 320, 6, '#252d35', LID_DARK * now, WORK_LID)
674 } else if (w.prev) {
675 veil = veilRect(8, 26, 384, H - 26, '#071a33', lift(WORK_DARK, DARK * now), 0)
676 lidVeil = veilRect(40, 18, 320, 6, '#252d35', lift(WORK_LID, LID_DARK * now), 0)
677 }
678
679 const newest = count > 0 ? pearl(PEARL_POS[count - 1][0], PEARL_POS[count - 1][1] + DY, w.fresh) : ''
680 const overlay = '<g clip-path="url(#tank)">' + veil + newest + diver(w) + '</g>'
681
682 return (
683 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="xMidYMax slice">${DEFS}` +
684 `<rect width="${W}" height="${H}" fill="#1b242c"/>` +
685 retime(tank, elapsed) +
686 overlay +
687 FRAME +
688 retime(breatheLid, elapsed) +
689 lidVeil +
690 '</svg>'
691 )
692}
693// ---scene-end
694
695const TERMINAL_ART: [string, string][] = [
696 [' o', '#bfe6f2'],
697 [' o', '#bfe6f2'],
698 [' ><(((º>', '#f58a2f'],
699 [' <º)))><', '#2f78e0'],
700 [' ><> ', '#f9dc2e'],
701 [' ✿ ψ ❀ ✾', '#e2735a'],
702]
703
704const clock = (ms: number) => {
705 const s = Math.max(0, Math.floor(ms / 1000))
706
707 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
708}
709
710const reef = atom(
711 { plugin: 'reef-tank', key: 'reef' } as const,
712 { working: false, prev: false, done: 0, fresh: false, tools: 0, startedAt: 0, tick: 0 } as Reef,
713)
714
715// The picture is built again only when the state it draws changes; the same text keeps it running.
716let t0 = 0
717let cache = { key: '', svg: '' }
718
719export const register: Register = (on, options) => {
720 // `language` is a field of the plugin's userConfig: 'en' (default) or 'ko'
721 const T: Words = STRINGS[options.language === 'ko' ? 'ko' : 'en']
722
723 on('session.start', async ($, e, next) => {
724 t0 = await $.clock.now()
725 await $.command.register({
726 name: 'reef',
727 description: 'Open the aquarium pane',
728 })
729 // Wakes the pane each second while Claude works, for the stopwatch in its top line.
730 $.clock.every(1000, () => {
731 void read($, reef)
732 .then(r => (r.working ? update($, reef, v => ({ ...v, tick: v.tick + 1 })) : undefined))
733 .catch(() => {})
734 })
735
736 return next(e)
737 })
738
739 on('command.run', { command: 'reef' }, async $ => {
740 await $.ui.open({ id: PANE, title: T.title })
741
742 return { text: T.opened }
743 })
744
745 // The main loop's turns only: a subagent's run is not Claude at work.
746 on('turn.start', async ($, e, next) => {
747 if (e.agentId === undefined) {
748 const now = await $.clock.now()
749 await update($, reef, v => ({ ...v, prev: false, working: true, fresh: false, tools: 0, startedAt: now }))
750 }
751
752 return next(e)
753 }).catch(($, e, next) => next(e))
754
755 on('turn.complete', async ($, e, next) => {
756 if (e.agentId === undefined) {
757 const isAnswer = e.reason === 'answer'
758 await update($, reef, v => ({ ...v, prev: true, working: false, fresh: isAnswer, done: v.done + (isAnswer ? 1 : 0) }))
759 }
760
761 return next(e)
762 }).catch(($, e, next) => next(e))
763
764 on('tool.call', async ($, e, next) => {
765 await update($, reef, v => ({ ...v, tools: v.tools + 1 }))
766
767 return next(e)
768 }).catch(($, e, next) => next(e))
769
770 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
771 const els = $.ui.resolve(e)
772 const w = await read($, reef)
773 const now = await $.clock.now()
774 if (t0 === 0) t0 = now
775 const cols = e.props?.bodyColumns ?? 40
776 const rows = e.props?.scroll?.bodyRows ?? 20
777 const line = w.working ? T.working(clock(now - w.startedAt), w.tools) : T.idle(w.done)
778
779 if (e.surface !== 'terminal') {
780 const { Box, Text, Svg } = els as ElementTable<'desktop'>
781 const key = `${w.working}|${w.prev}|${w.fresh}|${w.done}|${w.startedAt}`
782 if (cache.key !== key) cache = { key, svg: buildScene({ ...w, ran: Math.max(0, (now - w.startedAt) / 1000) }, (now - t0) / 1000) }
783 // The pane's px: its cells over the code font's advance (about 7.8 px) and line height
784 // (about 19.5 px). Short of the whole picture, the top of the water is cut, never the floor.
785 const width = Math.max(160, Math.round(cols * 7.8))
786 const full = Math.round((width * H) / W)
787 const tall = e.props?.scroll?.bodyRows ? Math.round((rows - 1) * 19.5) : full
788 const height = Math.min(full, tall)
789
790 return (
791 <Box flexDirection="column" justifyContent="flex-end" minHeight={rows}>
792 <Box justifyContent="center">
793 <Text bold={w.working} dimColor={!w.working}>
794 {line}
795 </Text>
796 </Box>
797 <Svg source={cache.svg} alt={T.alt} width={width} height={height} isInteractive />
798 </Box>
799 )
800 }
801
802 const { Box, Text } = els as ElementTable<'terminal'>
803
804 return (
805 <Box flexDirection="column">
806 {TERMINAL_ART.map(([row, color]) => (
807 <Text color={color}>{row}</Text>
808 ))}
809 <Text>{line}</Text>
810 <Text dimColor>{T.note}</Text>
811 </Box>
812 )
813 })
814}
815types/index.d.ts 16 lines1export type Reef = {
2 working: boolean
3 prev: boolean
4 done: number
5 fresh: boolean
6 tools: number
7 startedAt: number
8 tick: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'reef-tank': { reef: Reef }
14 }
15}
16