A tall snowy-cabin pane: snow falls softly on a white forest below snowy peaks, and a white rabbit, a white fox and a magpie come by. While Claude works night…

<img src="images/hero-en.gif" alt="Claude Code on the left works through a task while the reef-tank pane on the right dims and a submarine comes down" width="720">
Hand Claude Code a task, and zone out at the fish while it works. (The chat window here is a mock-up; the tank is the real mod.)
Animated side panels for Claude Code that change when Claude is idle vs. working, plus two small prompt bands. I'm learning Claude Code mods by building things, and this is what came out so far.
Every scene is a hand-written SVG animation (no image files). The previews below are the real pictures, captured with the idle state on the left and the working state on the right (an 8-second loop).
한국어 설명은 아래에 있습니다.
This repository is a marketplace. Inside Claude Code:
/plugin marketplace add Gnosis333/mod_gallery
/plugin install night-train@mod-gallery
Or from a shell:
claude plugin marketplace add Gnosis333/mod_gallery
claude plugin install night-train@mod-gallery --scope user
Swap night-train for any mod below. Panels open only when you run their slash command (they don't open by themselves, to save CPU). After installing, run /reload-plugins or start a new session.
To try one mod without installing: claude --plugin-dir mods/night-train.
Labels (the status line, descriptions, replies) are English by default. Every mod also has a Korean (한국어) mode through its language option:
echo '{"language":"ko"}' | claude plugin configure night-train@mod-gallery --values-stdin
Then restart Claude Code. You can also run /plugin configure night-train@mod-gallery inside Claude Code, and claude plugin configure night-train@mod-gallery shows whether it is set. Use en to switch back.
| Mod | Command | What it is |
|---|---|---|
night-train | /train | A night-train window |
beach-day | /beach | A top-down beach |
snow-cabin | /snow | A snowy cabin, day and night |
deep-sub | /deep | A deep-sea submersible |
campfire | /fire | A campfire to stare at |
jungle-falls | /falls | A jungle waterfall with 24 kinds of animals |
rain-window | /rain | A rainy window |
moon-view | /moon | The universe seen from the Moon |
tower-cut | /tower | A building cross-section |
desk-garden | /garden | A desk plant and a workshop |
reef-tank | /reef | An aquarium |
usage-band | automatic | Context, limits and cost on one line |
haiku-warning | automatic | A warning while a Haiku model is selected |
<img src="images/night-train.gif" alt="night-train" width="460">
A tall train window. At rest the train waits at STATION under lit lamp posts; when work starts the station slides away and hills, trees and poles stream past at different speeds. Now and then it runs through a long tunnel, and flocks of migrating birds cross the sky. The steam from the cup blows sideways, and when the turn ends the train rolls back into the station.
<img src="images/beach-day.gif" alt="beach-day" width="460">
A beach seen from above. Gentle waves wash the shore, and people in sunglasses lie on sunbeds under parasols (bikinis, one-pieces and trunks). Out at sea swimmers float on all kinds of rings, a pink unicorn among them. While Claude works a jet ski and a banana boat go by, and crabs scuttle across the sand.
<img src="images/snow-cabin.gif" alt="snow-cabin" width="460">
Snow falls softly over a white forest, snowy peaks and a log cabin. It's daytime at rest; while Claude works night falls, the windows glow, the chimney smokes, and a snowman is built every 15 seconds. A white rabbit, a white fox, a magpie and a husky come by.
<img src="images/deep-sub.gif" alt="deep-sub" width="460">
A yellow submersible drifts through the dark among glowing jellyfish, an anglerfish, lanternfish, a dumbo octopus and a gulper eel, while something vast and unknowable stirs faintly far below. While Claude works the searchlight comes on, sonar pings, and now and then the shadow of a giant squid sweeps past.
<img src="images/campfire.gif" alt="campfire" width="460">
A campfire in a dense forest clearing under the stars. Flames lick, sparks crackle up, and a ring of logs and six kinds of flowers surround the fire. A fox, an owl (it swivels its head), a deer, a raccoon and a hedgehog drop by. The fire roars up while Claude works and dies back afterwards.
<img src="images/jungle-falls.gif" alt="jungle-falls" width="460">
A terraced waterfall winds down into a lily pool. 24 kinds of animals visit at random (birds, monkeys, a jaguar, a crocodile, frogs, otters and more) and fish leap up the falls. It's day at rest and night while Claude works. This is the heaviest mod on CPU.
<img src="images/rain-window.gif" alt="rain-window" width="460">
A rainy window: drops run down the glass and merge, city lights blur outside and a cat dozes on the sill. While Claude works a storm comes in, and when lightning strikes the cat opens its eyes and the desk lamp flickers. When the turn ends a rainbow comes out and fades away.
<img src="images/moon-view.gif" alt="moon-view" width="460">
The Earth seen from the Moon. The lander's "eyes" flash and an astronaut bounces about (and tumbles around while Claude works). Stars, supernovae, a black hole, rockets, satellites, UFOs, planets and a few Star Wars-like ships pass at random.
<img src="images/tower-cut.gif" alt="tower-cut" width="460">
A building cross-section. One floor goes up with every finished turn, and at 20 floors the tower tops out with fireworks. A new session starts over. While Claude works the crane moves and raises the frame of the next floor.
<img src="images/desk-garden.gif" alt="desk-garden" width="460">
Three pictures stacked: a flowerpot by a window (one more flower blooms with every finished turn), a dozing cat by a fish tank, and a workshop where Claude's character works. While Claude works the character types at the keyboard.
<img src="images/reef-tank.gif" alt="reef-tank" width="460">
A tall aquarium with fish, a seahorse and jellyfish. While Claude works the lights dim and a yellow submarine dives; every finished turn adds a pearl to the treasure chest.
<img src="images/usage-band.png" alt="usage-band" width="720">
One line above the prompt: context window fill, the 5-hour and 7-day plan limits, and session cost. It turns amber at 70% and brick red at 85%, and each limit shows when it resets (↻). The image is an illustration of the look.
<img src="images/haiku-warning.png" alt="haiku-warning" width="720">
Shows a red warning above the prompt while a Haiku model is selected, as a reminder to switch before complex work. The image is an illustration of the look.
jungle-falls, snow-cabin and campfire are larger pictures and relatively heavier.mods/<name>/ with .claude-plugin/plugin.json, hooks/register.tsx (the behaviour), a test file and a state type. The part of register.tsx between // ---scene-start and // ---scene-end is what draws the picture.language option (see Language). The pictures themselves contain no text, except a few English words such as STATION and the speech bubble in desk-garden.Built with Claude Code.
MIT. Use, change and share freely.
<img src="images/hero-ko.gif" alt="왼쪽 Claude Code가 일하는 동안 오른쪽 어항 패널이 어두워지고 잠수함이 내려오는 모습" width="720">
Claude Code에 일을 맡기고, 일하는 동안엔 어항 보며 멍~ (왼쪽 대화창은 비슷하게 그린 화면이고, 어항은 실제 모드입니다.)
Claude Code 옆에 띄워 두는 그림 패널과 입력창 위 상태 띠를 모았습니다. 패널은 Claude가 쉬고 있을 때와 일하고 있을 때 모습이 달라집니다. Claude Code의 모드를 공부하면서 이것저것 만들어 본 결과물입니다. 위 영어 부분의 그림(왼쪽 쉬는 중, 오른쪽 작업 중)이 그대로 해당합니다.
/plugin marketplace add Gnosis333/mod_gallery
/plugin install night-train@mod-gallery
night-train 자리에 위 표의 모드 이름을 넣으면 됩니다. 패널은 슬래시 명령으로 열 때만 열립니다(CPU를 아끼려고 자동으로 열지 않습니다). 설치 후 /reload-plugins를 입력하거나 새 세션을 여세요.
한글 라벨로 보려면 모드마다 language 옵션을 ko로 정하고 Claude Code를 다시 시작하세요(기본은 영어).
echo '{"language":"ko"}' | claude plugin configure night-train@mod-gallery --values-stdin
/plugin configure night-train@mod-gallery로도 정할 수 있습니다. 영어로 되돌리려면 en을 쓰세요.
/train): 야간열차 창가. 쉴 때는 STATION에 정차해 가로등이 켜져 있고, 작업 중에는 산·나무·전신주가 서로 다른 속도로 흘러가며 가끔 긴 터널을 지납니다. 하늘에는 철새떼가 날아가고, 턴이 끝나면 다시 역으로 들어옵니다./beach): 위에서 내려다본 해변. 파라솔 아래 썬베드에 선글라스를 낀 사람들이 누워 있고, 바다에는 핑크 유니콘 튜브를 비롯한 여러 튜브가 떠 있습니다. 작업 중에는 제트스키, 바나나보트, 꽃게가 나옵니다./snow): 눈이 소복소복 내리는 흰 숲과 설산, 통나무 오두막. 쉴 때는 낮, 작업 중에는 밤이 되어 창에 불이 켜지고 눈사람이 15초마다 하나씩 생깁니다. 흰 토끼, 흰 여우, 까치, 허스키가 찾아옵니다./deep): 어둠 속 노란 잠수정과 야광 해파리, 아귀, 덤보 문어, 풍선장어, 그리고 저 아래 희미하게 움직이는 정체불명의 거대한 것. 작업 중에는 탐조등과 소나가 켜지고 가끔 거대 오징어 그림자가 스칩니다./fire): 별이 가득한 밤 숲속 공터의 모닥불. 불꽃과 불티, 통나무 원, 꽃 여섯 종류, 여우·올빼미(머리를 돌립니다)·사슴·너구리·고슴도치. 작업 중에는 불이 커지고 끝나면 잦아듭니다./falls): 계단식 폭포와 연꽃 연못, 동물 24종이 무작위로 방문. 쉴 때는 낮, 작업 중에는 밤. CPU를 가장 많이 씁니다./rain): 비 오는 창가. 작업 중에는 폭풍, 번개가 치면 고양이가 눈을 뜨고 스탠드가 깜빡이며, 끝나면 무지개가 뜹니다./moon): 달에서 본 지구. 통통 뛰는 우주인(작업 중에는 빙빙 돕니다), 무작위로 지나가는 우주 물체와 스타워즈풍 우주선./tower): 빌딩 단면. 턴이 끝날 때마다 한 층씩 올라가고 20층이면 준공 불꽃놀이./garden): 창가 화분(턴마다 꽃이 핍니다), 졸고 있는 고양이와 수조, Claude가 일하는 작업실./reef): 수족관. 작업 중에는 조명이 어두워지고 노란 잠수함이 내려오며, 턴이 끝날 때마다 진주가 쌓입니다.↻ 포함), 비용을 보여줍니다.jungle-falls, snow-cabin, campfire는 그림이 커서 상대적으로 무겁습니다.language 옵션을 ko로 하면 한글입니다. 그림 속 글자는 STATION과 desk-garden의 말풍선 정도입니다(말풍선은 옵션을 따릅니다).모두 Claude Code로 만들었습니다.
hooks/register.tsx 595 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { Cabin } from '../types'
5
6const PANE = 'snow-cabin'
7const STRINGS = {
8 en: {
9 title: 'Snowy cabin',
10 alt: 'A cabin in a snowy forest with mountains behind. Snow falls softly, and a white rabbit, a white fox, a magpie and a husky come by.',
11 working: (clock: string, tools: number) => `Working ${clock} · tools ${tools}`,
12 idle: 'Idle',
13 opened: 'Opened the snowy cabin 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: '쉬는 중',
21 opened: '눈 오는 오두막을 열었습니다.',
22 note: '그림 움직임은 데스크톱 앱에서 보입니다.',
23 },
24}
25
26type Words = (typeof STRINGS)['en']
27
28const MAX = 6 // snowmen the yard holds
29const FIRST = 2 // seconds into a turn the first snowman starts
30const EVERY = 15 // and a new one after that
31
32// ---scene-start
33type Yard = { working: boolean; startedAt: number; endedAt: number; built: number; base: number }
34type Key = [number, number, number] // seconds into the loop, x, y
35
36const W = 400
37const H = 1200
38const GROUND = 700
39
40const n = (v: number) => String(Math.round(v * 10) / 10)
41const r3 = (v: number) => String(Math.round(v * 1000) / 1000)
42const EASE = '.45 0 .55 1'
43const rnd = (s: number) => {
44 const v = Math.sin(s * 127.1 + 311.7) * 43758.5453
45
46 return v - Math.floor(v)
47}
48
49// Looping motion shifts back by the time already elapsed, so redrawing the picture never makes it jump.
50// Tags marked data-once (nightfall, a snowman being built) are timed from now and left alone.
51const retime = (svg: string, elapsed: number) =>
52 svg.replace(/<(animate|animateTransform|animateMotion)\b[^>]*>/g, tag => {
53 if (tag.includes('data-once')) {
54 return tag
55 }
56 const found = / begin="(-?[\d.]+)s"/.exec(tag)
57 const shifted = ` begin="${n((found ? parseFloat(found[1] ?? '0') : 0) - elapsed)}s"`
58
59 return found ? tag.replace(found[0], shifted) : tag.replace(/^<(\w+)/, `<$1${shifted}`)
60 })
61
62// a translate through timed points, looping
63const track = (keys: Key[], period: number, begin: number) =>
64 `<animateTransform attributeName="transform" type="translate" values="${keys.map(([, x, y]) => `${n(x)} ${n(y)}`).join(';')}" keyTimes="${keys.map(([t]) => r3(t / period)).join(';')}" dur="${period}s" begin="${begin}s" repeatCount="indefinite"/>`
65
66// ---- the sky and the far peaks ---------------------------------------------------------------------------
67const SKY =
68 `<rect x="0" y="0" width="${W}" height="${GROUND + 20}" fill="url(#sky)"/>` +
69 '<circle cx="300" cy="150" r="70" fill="#fff" opacity=".25"/><circle cx="300" cy="150" r="30" fill="#fffbea" opacity=".9"/>'
70
71const MOUNTAINS =
72 // the far range, pale; then the near one, with its shadowed faces and snowfields
73 '<path d="M0 520 L60 450 L110 488 L180 400 L250 470 L310 420 L400 500 V660 H0Z" fill="#d6e2ee"/>' +
74 '<path d="M180 400 L215 445 L200 470 L250 470Z M310 420 L340 450 L330 470 L400 500 V520 L330 480Z" fill="#bccdde"/>' +
75 '<path d="M-20 600 L70 500 L120 540 L210 450 L300 545 L350 510 L420 580 V680 H-20Z" fill="#eef4fa"/>' +
76 '<path d="M210 450 L250 494 L236 520 L300 545 L270 560 L230 530Z M70 500 L95 530 L86 550 L120 540 L100 570Z M350 510 L380 545 L420 580 L380 590Z" fill="#c4d4e4"/>' +
77 '<path d="M196 466 L210 450 L224 466 L216 470 L210 464 L203 471Z" fill="#fff"/>'
78
79// ---- the white forest -------------------------------------------------------------------------------------
80// far trees skip the grey edge on their snow: too small to need it, and it keeps the picture light
81const pine = (x: number, base: number, ht: number, w: number, dark: string, k: number, edged = true) => {
82 const tiers = [0, 1, 2, 3]
83 .map(i => {
84 const top = base - ht + i * ht * 0.2
85 const bot = top + ht * 0.36
86 const hw = (w / 2) * (0.42 + 0.2 * i)
87 const mid = top + (bot - top) * 0.62
88
89 return (
90 `<path d="M${n(x)} ${n(top)} L${n(x + hw)} ${n(bot)} Q${n(x)} ${n(bot + 7)} ${n(x - hw)} ${n(bot)}Z" fill="${dark}"/>` +
91 // snow lying on the tier: a cap, and a soft rim along its lower edge
92 `<path d="M${n(x)} ${n(top)} L${n(x + hw * 0.66)} ${n(mid)} Q${n(x + hw * 0.3)} ${n(mid - 5 + rnd(k + i) * 4)} ${n(x + hw * 0.05)} ${n(mid + 2)} Q${n(x - hw * 0.3)} ${n(mid - 6)} ${n(x - hw * 0.66)} ${n(mid)}Z" fill="#fbfdff"${edged ? ' stroke="#a9bccf" stroke-width="1"' : ''}/>` +
93 (edged
94 ? [
95 ['#a9bccf', Math.max(2, w / 22) + 2],
96 ['#fbfdff', Math.max(2, w / 22)],
97 ]
98 : [['#fbfdff', Math.max(2, w / 22)]]
99 )
100 .map(([c, sw]) => `<path d="M${n(x - hw * 0.95)} ${n(bot - 1)} Q${n(x - hw * 0.5)} ${n(bot - 6)} ${n(x)} ${n(bot + 3)} Q${n(x + hw * 0.5)} ${n(bot - 6)} ${n(x + hw * 0.95)} ${n(bot - 1)}" fill="none" stroke="${c}" stroke-width="${n(sw as number)}" stroke-linecap="round"/>`)
101 .join('')
102 )
103 })
104 .join('')
105
106 return `<rect x="${n(x - w * 0.05)}" y="${n(base - 6)}" width="${n(w * 0.1)}" height="12" fill="#5a4636"/>${tiers}`
107}
108
109const birch = (x: number, base: number, ht: number) =>
110 `<rect x="${x - 3.5}" y="${base - ht}" width="7" height="${ht}" fill="#f4f2ec"/><rect x="${x + 1.5}" y="${base - ht}" width="2" height="${ht}" fill="#d8d4ca"/>` +
111 Array.from({ length: Math.floor(ht / 18) }, (_, k) => `<path d="M${x - 3.5} ${base - ht + 10 + k * 18 + rnd(x + k) * 6} ht${n(3 + rnd(x + k + 5) * 4)}" stroke="#3a3530" stroke-width="1.6"/>`).join('') +
112 `<path d="M${x} ${base - ht * 0.7} l-22 -26 M${x} ${base - ht * 0.5} l20 -22 M${x} ${base - ht * 0.85} l14 -20" stroke="#8a8278" stroke-width="1.4" fill="none"/>` +
113 `<path d="M${x - 22} ${base - ht * 0.7 - 26} l8 9 M${x + 20} ${base - ht * 0.5 - 22} l-7 8" stroke="#fbfdff" stroke-width="2.4" stroke-linecap="round"/>`
114
115const FAR_FOREST = Array.from({ length: 19 }, (_, k) => {
116 const x = k * 22 - 4 + rnd(k) * 8
117 const ht = 30 + rnd(k + 40) * 26
118
119 return pine(x, GROUND + 4 + rnd(k + 80) * 6, ht, ht * 0.62, '#7891a6', k, false)
120}).join('')
121
122const MID_FOREST = [
123 [104, 772, 130],
124 [352, 774, 140],
125 [380, 790, 110],
126 [70, 790, 100],
127]
128 .map(([x, b, ht], k) => pine(x, b, ht, ht * 0.6, '#3d6658', k + 100))
129 .join('')
130
131const NEAR_TREES = pine(34, 960, 300, 150, '#24483c', 200) + pine(392, 990, 320, 160, '#24483c', 210) + birch(124, 900, 150) + birch(350, 916, 120)
132
133// ---- the ground: soft drifts, rabbit tracks, a fence and a woodpile ----------------------------------------
134const GROUND_SNOW =
135 `<path d="M0 ${GROUND} Q100 ${GROUND - 12} 200 ${GROUND - 2} T400 ${GROUND - 6} V${H} H0Z" fill="#e8eff7"/>` +
136 [
137 [70, 820, 120, 14],
138 [300, 900, 140, 16],
139 [140, 1000, 170, 18],
140 [330, 1090, 150, 18],
141 [80, 1150, 140, 16],
142 ]
143 .map(([x, y, rx, ry]) => `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="#d2deea"/><ellipse cx="${x - 10}" cy="${y - 6}" rx="${rx * 0.9}" ry="${ry * 0.8}" fill="#eef3f9"/>`)
144 .join('') +
145 Array.from({ length: 12 }, (_, k) => `<g fill="#b9c8d8"><ellipse cx="${30 + k * 30}" cy="${n(940 + Math.sin(k) * 6)}" rx="2.2" ry="3"/><ellipse cx="${36 + k * 30}" cy="${n(944 + Math.sin(k) * 6)}" rx="2.2" ry="3"/><ellipse cx="${44 + k * 30}" cy="${n(938 + Math.sin(k) * 6)}" rx="3" ry="5"/></g>`).join('')
146
147const FENCE =
148 '<path d="M-4 874 H150 M-4 892 H150" stroke="#6a5240" stroke-width="4"/><path d="M-4 871 H150 M-4 889 H150" stroke="#fbfdff" stroke-width="3" stroke-linecap="round"/>' +
149 [8, 38, 68, 98, 128].map(x => `<rect x="${x - 4}" y="860" width="8" height="44" fill="#7a5e48"/><ellipse cx="${x}" cy="860" rx="6.6" ry="4" fill="#fbfdff"/>`).join('')
150
151const WOODPILE =
152 '<g transform="translate(336 852)"><rect x="-4" y="-30" width="58" height="34" fill="#5a4030"/>' +
153 [0, 1, 2]
154 .map(r => [0, 1, 2, 3].map(c => `<circle cx="${4 + c * 14 + (r % 2) * 6}" cy="${-4 - r * 10}" r="5.6" fill="#b88a5a" stroke="#7a5638" stroke-width="1.2"/>`).join(''))
155 .join('') +
156 '<path d="M-6 -32 Q10 -42 26 -36 Q42 -44 58 -32 Z" fill="#fbfdff"/></g>'
157
158// ---- the cabin ---------------------------------------------------------------------------------------------
159const WINDOWS: [number, number, number, number][] = [
160 [172, 790, 34, 30],
161 [270, 790, 34, 30],
162]
163
164const CABIN =
165 // log walls
166 '<rect x="152" y="758" width="176" height="104" fill="#7a5238"/>' +
167 Array.from({ length: 8 }, (_, k) => `<path d="M152 ${770 + k * 12} H328" stroke="#5e3e2a" stroke-width="2"/><circle cx="152" cy="${764 + k * 12}" r="5" fill="#a87c58"/><circle cx="328" cy="${764 + k * 12}" r="5" fill="#a87c58"/>`).join('') +
168 // gable, roof, and the thick snow on it with icicles
169 '<path d="M160 760 L240 700 L320 760Z" fill="#6a4630"/><circle cx="240" cy="736" r="10" fill="#3a3040"/><circle cx="240" cy="736" r="10" fill="none" stroke="#5a3e2a" stroke-width="3"/>' +
170 '<path d="M130 772 L240 686 L350 772 L338 776 L240 700 L142 776Z" fill="#4a3428"/>' +
171 '<path d="M124 774 Q130 760 140 762 L240 680 L340 762 Q352 760 356 774 Q340 782 324 776 Q300 784 280 776 Q260 784 240 778 Q220 786 200 777 Q178 785 160 777 Q140 784 124 774Z" fill="#fbfdff" stroke="#b8c8da" stroke-width="1.4"/>' +
172 '<path d="M240 680 L340 762 Q300 740 240 700 Q180 740 140 762Z" fill="#e6eef6"/>' +
173 [148, 176, 206, 232, 262, 290, 318, 342].map((x, k) => `<path d="M${x - 3} ${778 + (k % 2)} L${x} ${790 + (k % 3) * 4} L${x + 3} ${778 + (k % 2)}Z" fill="#dff0ff"/>`).join('') +
174 // chimney
175 '<rect x="284" y="694" width="24" height="48" fill="#8a8590"/><path d="M284 704 H308 M284 716 H308 M284 728 H308 M296 694 V704 M290 704 V716 M302 716 V728" stroke="#6e6a74" stroke-width="1.4"/>' +
176 '<path d="M280 696 Q284 684 296 688 Q308 682 312 696Z" fill="#fbfdff"/>' +
177 // door with a wreath, the step, and the windows' frames and snowy sills
178 '<rect x="226" y="806" width="30" height="56" rx="3" fill="#5a3826"/><circle cx="250" cy="836" r="2" fill="#d8b04a"/>' +
179 '<circle cx="241" cy="822" r="7" fill="none" stroke="#3f7a4a" stroke-width="3.4"/><circle cx="241" cy="816" r="1.8" fill="#d8403a"/>' +
180 '<rect x="218" y="860" width="46" height="8" fill="#fbfdff"/>' +
181 WINDOWS.map(([x, y, w, ht]) => `<rect x="${x}" y="${y}" width="${w}" height="${ht}" fill="#3a4a64"/><path d="M${x + 2} ${y + ht - 4} L${x + w * 0.45} ${y + 2}" stroke="#fff" stroke-opacity=".25" stroke-width="5"/>`).join('')
182
183const FRAMES = WINDOWS.map(
184 ([x, y, w, ht]) =>
185 `<rect x="${x}" y="${y}" width="${w}" height="${ht}" fill="none" stroke="#e8dcc8" stroke-width="3"/><path d="M${x + w / 2} ${y} V${y + ht} M${x} ${y + ht / 2} H${x + w}" stroke="#e8dcc8" stroke-width="2"/>` +
186 `<path d="M${x - 4} ${y + ht + 2} Q${x + w / 2} ${y + ht - 6} ${x + w + 4} ${y + ht + 2}Z" fill="#fbfdff"/>`,
187).join('')
188
189// warm light in the windows and spilling on the snow, and the lantern by the door
190const LIGHTS =
191 WINDOWS.map(
192 ([x, y, w, ht]) =>
193 `<rect x="${x}" y="${y}" width="${w}" height="${ht}" fill="#ffd27a"><animate attributeName="fill" values="#ffd27a;#ffc860;#ffd27a" dur="3s" repeatCount="indefinite"/></rect>` +
194 `<ellipse cx="${x + w / 2}" cy="${y + ht / 2}" rx="${w}" ry="${ht}" fill="#ffd27a" opacity=".22"/><path d="M${x - 6} 868 L${x + w + 6} 868 L${x + w + 20} 910 L${x - 20} 910Z" fill="#ffd27a" opacity=".16"/>`,
195 ).join('') +
196 '<circle cx="240" cy="736" r="8" fill="#ffd27a"/>' +
197 '<rect x="262" y="808" width="8" height="12" rx="2" fill="#ffe2a0"/><circle cx="266" cy="814" r="22" fill="#ffd27a" opacity=".2"/>'
198
199// a few wisps by day; at night the stove is going and the smoke rolls out
200const smoke = (busy: boolean) =>
201 Array.from({ length: busy ? 6 : 2 }, (_, k) => {
202 const dur = busy ? 6 : 9
203 const begin = n((-k * dur) / (busy ? 6 : 2))
204 const r = busy ? 9 : 5
205
206 return (
207 `<circle cx="296" cy="684" r="${r}" fill="#eef1f6" opacity="0">` +
208 `<animate attributeName="opacity" values="0;${busy ? '.5' : '.4'};0" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>` +
209 `<animate attributeName="r" values="${r};${r * 3.2}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>` +
210 `<animateTransform attributeName="transform" type="translate" values="0 0;-14 -70;-44 -180" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></circle>`
211 )
212 }).join('')
213
214// ---- visitors: a white rabbit, a white fox and a magpie ---------------------------------------------------
215const RABBIT_ART =
216 '<ellipse cx="0" cy="1" rx="17" ry="3.6" fill="#8ea2b8" opacity=".45"/>' +
217 '<ellipse cx="0" cy="-10" rx="13" ry="10" fill="#fbfdff" stroke="#8396ac" stroke-width="1.5"/><circle cx="-12" cy="-8" r="4.4" fill="#fbfdff" stroke="#8396ac" stroke-width="1.5"/>' +
218 '<ellipse cx="6" cy="-2" rx="7" ry="3.4" fill="#d6e0ea"/><circle cx="12" cy="-18" r="7" fill="#fbfdff" stroke="#8396ac" stroke-width="1.5"/>' +
219 '<g><animateTransform attributeName="transform" type="rotate" values="0 10 -24;0 10 -24;-14 10 -24;0 10 -24" keyTimes="0;.8;.9;1" dur="2.6s" repeatCount="indefinite"/>' +
220 '<ellipse cx="8" cy="-34" rx="3.4" ry="11" fill="#fbfdff" stroke="#8396ac" stroke-width="1.5" transform="rotate(-12 8 -34)"/><ellipse cx="8" cy="-34" rx="1.6" ry="8" fill="#f6c6d0" transform="rotate(-12 8 -34)"/>' +
221 '<ellipse cx="14" cy="-33" rx="3.2" ry="10" fill="#fbfdff" stroke="#8396ac" stroke-width="1.5" transform="rotate(10 14 -33)"/></g>' +
222 '<circle cx="15" cy="-19" r="1.5" fill="#2a2030"/><circle cx="19" cy="-16" r="1.2" fill="#f2a0b0"/>'
223
224// hop along: [from x, hops, length of each], then sit a while, then hop off
225const hopKeys = (y: number, start: number, legs: [number, number][], sit: number, period: number): Key[] => {
226 const keys: Key[] = [[0, -40, y], [start, -40, y]]
227 let t = start
228 let x = -40
229 legs.forEach(([count, step], i) => {
230 for (let k = 0; k < count; k++) {
231 keys.push([t + 0.22, x + step / 2, y - 14], [t + 0.45, x + step, y])
232 t += 0.45
233 x += step
234 }
235 if (i < legs.length - 1) {
236 t += sit
237 keys.push([t, x, y])
238 }
239 })
240 keys.push([period, x, y])
241
242 return keys
243}
244
245const RABBIT = `<g>${track(hopKeys(946, 2, [[9, 20], [14, 20]], 4, 34), 34, -10)}${RABBIT_ART}</g>`
246
247const FOX_ART =
248 // facing left: a full white tail, a dark-tipped muzzle and amber eyes
249 '<ellipse cx="4" cy="9" rx="30" ry="4" fill="#8ea2b8" opacity=".45"/>' +
250 '<path d="M16 -14 Q40 -22 44 -6 Q38 2 18 -4Z" fill="#fbfdff" stroke="#8396ac" stroke-width="1.5"/>' +
251 '<g><animateTransform attributeName="transform" type="rotate" values="-6;6;-6" dur=".5s" repeatCount="indefinite"/>' +
252 '<path d="M-10 -4 V8 M10 -4 V8" stroke="#b8c6d6" stroke-width="4.4" stroke-linecap="round"/></g>' +
253 '<path d="M-6 -4 V8 M14 -4 V8" stroke="#8396ac" stroke-width="6" stroke-linecap="round"/><path d="M-6 -4 V8 M14 -4 V8" stroke="#fbfdff" stroke-width="3.4" stroke-linecap="round"/>' +
254 '<ellipse cx="2" cy="-10" rx="18" ry="9" fill="#fbfdff" stroke="#8396ac" stroke-width="1.5"/><path d="M-12 -4 Q2 0 18 -5" stroke="#c8d4e2" stroke-width="3" fill="none" stroke-linecap="round"/>' +
255 '<path d="M-14 -14 Q-20 -28 -30 -18 L-36 -14 Q-30 -8 -18 -6Z" fill="#fbfdff" stroke="#8396ac" stroke-width="1.5"/>' +
256 '<path d="M-22 -24 L-20 -34 L-14 -24Z M-14 -22 L-10 -31 L-8 -21Z" fill="#fbfdff" stroke="#8396ac" stroke-width="1.5"/>' +
257 '<circle cx="-36" cy="-14" r="1.8" fill="#2a2030"/><circle cx="-22" cy="-19" r="1.6" fill="#c88a2a"/>'
258
259const FOX = `<g>${track(
260 [
261 [0, 450, 902],
262 [9, 450, 902],
263 [15, 250, 896],
264 [19, 250, 896],
265 [26, -60, 904],
266 [47, -60, 904],
267 ],
268 47,
269 -20,
270)}<g transform="scale(.95)"><g><animateTransform attributeName="transform" type="translate" values="0 0;0 -2;0 0" dur=".5s" repeatCount="indefinite"/>${FOX_ART}</g></g></g>`
271
272const HUSKY_ART =
273 '<ellipse cx="4" cy="1" rx="30" ry="4" fill="#8ea2b8" opacity=".45"/>' +
274 // the curled tail, wagging
275 '<g><animateTransform attributeName="transform" type="rotate" values="-10 -20 -20;14 -20 -20;-10 -20 -20" dur=".5s" repeatCount="indefinite"/>' +
276 '<path d="M-20 -20 Q-34 -24 -30 -38 Q-24 -46 -16 -38" fill="none" stroke="#4e5868" stroke-width="7" stroke-linecap="round"/><path d="M-17 -40 Q-14 -36 -16 -32" stroke="#f4f6fa" stroke-width="4" stroke-linecap="round" fill="none"/></g>' +
277 // the far legs, then the body, then the near legs, swinging as it trots
278 '<g><animateTransform attributeName="transform" type="rotate" values="12 0 -12;-12 0 -12;12 0 -12" dur=".42s" repeatCount="indefinite"/><path d="M-12 -8 V2 M16 -8 V2" stroke="#c8d0dc" stroke-width="5" stroke-linecap="round"/></g>' +
279 '<ellipse cx="0" cy="-16" rx="23" ry="11" fill="#4e5868" stroke="#2e3644" stroke-width="1.2"/><path d="M-20 -10 Q0 -2 22 -11 Q12 -6 0 -6 Q-12 -6 -20 -10Z" fill="#f4f6fa"/>' +
280 '<g><animateTransform attributeName="transform" type="rotate" values="-12 0 -12;12 0 -12;-12 0 -12" dur=".42s" repeatCount="indefinite"/><path d="M-16 -8 V3 M12 -8 V3" stroke="#f4f6fa" stroke-width="5.4" stroke-linecap="round"/></g>' +
281 // the head: grey cap and ears, a white mask, blue eye, black nose, and a lolling tongue
282 '<path d="M14 -22 Q18 -34 26 -36 L30 -26 Z" fill="#4e5868"/>' +
283 '<circle cx="26" cy="-32" r="10" fill="#4e5868" stroke="#2e3644" stroke-width="1.2"/>' +
284 '<path d="M19 -40 L21 -52 L27 -41Z M25 -41 L30 -52 L32 -39Z" fill="#4e5868" stroke="#2e3644" stroke-width="1"/><path d="M21.4 -42 L22 -48 L25 -42Z" fill="#f2b8c4"/>' +
285 '<path d="M18 -30 Q22 -26 26 -30 Q28 -34 32 -34 Q38 -32 40 -27 Q38 -21 30 -21 Q22 -21 18 -26Z" fill="#f4f6fa"/><path d="M22 -34 Q26 -36 29 -34" stroke="#f4f6fa" stroke-width="2.4" fill="none"/>' +
286 '<circle cx="27.6" cy="-31.4" r="2.2" fill="#6ac6ff"/><circle cx="28" cy="-31.4" r="1" fill="#14202e"/><circle cx="40" cy="-27.6" r="2.2" fill="#14202e"/>' +
287 '<path d="M33 -22 Q35 -16 38 -19 Q37 -22 35 -22Z" fill="#ff8aa0"/>'
288
289// it trots in, stops to look about with its tail going, then bounds off
290const HUSKY = `<g>${track(
291 [
292 [0, -70, 1072],
293 [4, -70, 1072],
294 [9, 118, 1066],
295 [15, 118, 1066],
296 [18, 470, 1078],
297 [38, 470, 1078],
298 ],
299 38,
300 -12,
301)}<g><animateTransform attributeName="transform" type="translate" values="0 0;0 -2.4;0 0" dur=".42s" repeatCount="indefinite"/>${HUSKY_ART}</g></g>`
302
303const MAGPIE_PERIOD = 26
304const MAGPIE_FLYING = [3, 6, 14, 18] // flies in between the first two, away between the last two
305const MAGPIE_ART = (() => {
306 const shown = (flying: boolean) => {
307 const [a, b, c, d] = MAGPIE_FLYING.map(t => r3(t / MAGPIE_PERIOD))
308
309 return `<animate attributeName="opacity" values="${flying ? '0;1;0;1;0' : '1;0;1;0;1'}" keyTimes="0;${a};${b};${c};${d}" calcMode="discrete" dur="${MAGPIE_PERIOD}s" begin="-4s" repeatCount="indefinite"/>`
310 }
311
312 return (
313 // a long blue-black tail, white belly, black head
314 '<g><animateTransform attributeName="transform" type="rotate" values="0;0;-16;0" keyTimes="0;.7;.8;1" dur="2.2s" repeatCount="indefinite"/><path d="M-6 -4 L-30 4 L-28 8 L-4 2Z" fill="#1f2a4a"/></g>' +
315 '<ellipse cx="0" cy="-6" rx="10" ry="6.4" fill="#141418"/><ellipse cx="2" cy="-4" rx="6" ry="4" fill="#fbfdff"/><circle cx="9" cy="-12" r="5" fill="#141418"/><path d="M13 -12 L19 -11 L13 -10Z" fill="#141418"/><circle cx="10" cy="-13" r=".9" fill="#fff"/>' +
316 `<g opacity="1">${shown(false)}<path d="M-6 -8 Q0 -12 6 -7 Q0 -4 -6 -5Z" fill="#1f2a4a"/><path d="M-2 -8 Q2 -9 4 -7" stroke="#fbfdff" stroke-width="1.6" fill="none"/><path d="M-2 0 V6 M3 0 V6" stroke="#141418" stroke-width="1.4"/></g>` +
317 `<g opacity="0">${shown(true)}<path d="M-4 -8 Q-10 -26 4 -30 Q2 -18 6 -8Z" fill="#1f2a4a"><animate attributeName="d" values="M-4 -8 Q-10 -26 4 -30 Q2 -18 6 -8Z;M-4 -6 Q-8 8 4 12 Q2 2 6 -6Z;M-4 -8 Q-10 -26 4 -30 Q2 -18 6 -8Z" dur=".36s" repeatCount="indefinite"/></path><path d="M-2 -14 Q0 -22 3 -24" stroke="#fbfdff" stroke-width="2" fill="none"/></g>`
318 )
319})()
320
321const MAGPIE = `<g>${track(
322 [
323 [0, -40, 640],
324 [3, -40, 640],
325 [6, 98, 856],
326 [9, 98, 856],
327 [9.3, 98, 850],
328 [9.6, 98, 856],
329 [14, 98, 856],
330 [18, 440, 600],
331 [MAGPIE_PERIOD, 440, 600],
332 ],
333 MAGPIE_PERIOD,
334 -4,
335)}${MAGPIE_ART}</g>`
336
337// ---- snowmen ----------------------------------------------------------------------------------------------
338const SPOTS: [number, number, number][] = [
339 [104, 1010, 1],
340 [296, 1046, 1.05],
341 [196, 1124, 1.15],
342 [350, 1150, 1.2],
343 [56, 1150, 1.15],
344 [250, 968, 0.85],
345]
346
347const HATS = [
348 '<rect x="-10" y="-6" width="20" height="3" fill="#1a1a22"/><rect x="-7" y="-20" width="14" height="15" fill="#1a1a22"/><rect x="-7" y="-9" width="14" height="3" fill="#c8403a"/>',
349 '<path d="M-9 -4 L-7 -16 H7 L9 -4Z" fill="#d8403a"/><rect x="-10" y="-5" width="20" height="3" rx="1" fill="#b83030"/>',
350 '<path d="M-10 -3 Q-10 -18 0 -18 Q10 -18 10 -3Z" fill="#3a7ad8"/><rect x="-10" y="-5" width="20" height="4" fill="#fbfdff"/><circle cy="-20" r="4" fill="#fbfdff"/>',
351]
352const SCARVES = ['#d8403a', '#3a9a5a', '#e8a63c', '#7a5ad8', '#3a7ad8', '#d84a8a']
353
354// built in four steps, each popping into place: the base, the body, the head, then the dressing
355const snowman = (k: number, at: number | null) => {
356 const [x, y, s] = SPOTS[k]!
357 // a step that finished before the picture was drawn is drawn in place: an animation that ended
358 // before time zero does not hold its last frame
359 const step =(cx: number, cy: number, inner: string, i: number) =>
360 at === null || at + i * 0.8 + 0.7 <= 0
361 ? `<g transform="translate(${cx} ${cy})">${inner}</g>`
362 : `<g transform="translate(${cx} ${cy})"><g transform="scale(0)"><animateTransform data-once="1" attributeName="transform" type="scale" values="0;1.14;1" keyTimes="0;.7;1" dur=".7s" begin="${n(at + i * 0.8)}s" fill="freeze"/>${inner}</g></g>`
363 const ball = (r: number) => `<circle r="${r}" fill="#fbfdff" stroke="#aabdd2" stroke-width="1.4"/><path d="M${n(-r * 0.2)} ${n(r * 0.9)} A${r} ${r} 0 0 0 ${r} 0 A${r * 0.9} ${r * 0.9} 0 0 1 ${n(-r * 0.2)} ${n(r * 0.9)}Z" fill="#dbe5ef"/>`
364
365 return (
366 `<g transform="translate(${x} ${y}) scale(${s})"><ellipse cx="4" cy="0" rx="28" ry="6" fill="#c9d6e4"/>` +
367 step(0, -20, ball(22), 0) +
368 step(0, -50, ball(15) + '<circle cy="-6" r="1.8" fill="#2a2a30"/><circle cy="2" r="1.8" fill="#2a2a30"/><circle cy="9" r="1.8" fill="#2a2a30"/>', 1) +
369 step(0, -72, ball(11) + '<circle cx="-3.6" cy="-2" r="1.5" fill="#2a2a30"/><circle cx="3.6" cy="-2" r="1.5" fill="#2a2a30"/><path d="M0 1 L11 3 L0 4Z" fill="#f08a2a"/><path d="M-4 5 q4 3 8 0" stroke="#2a2a30" stroke-width="1" fill="none" stroke-dasharray="1 1.6"/>', 2) +
370 step(
371 0,
372 -62,
373 `<path d="M-12 -2 Q0 4 12 -2 L12 2 Q0 8 -12 2Z" fill="${SCARVES[k]}"/><path d="M6 1 L9 16 L4 16 L2 3Z" fill="${SCARVES[k]}"/>` +
374 '<path d="M-13 10 L-32 -2 M-26 2 L-30 -6 M13 10 L32 0 M26 4 L30 -4" stroke="#6a4a30" stroke-width="2" stroke-linecap="round"/>' +
375 `<g transform="translate(0 -19)">${HATS[k % HATS.length]}</g>`,
376 3,
377 ) +
378 '</g>'
379 )
380}
381
382// ---- falling snow, near and far, drifting as it comes down -------------------------------------------
383const SNOW = Array.from({ length: 84 }, (_, k) => {
384 const near = k % 3 === 0
385 const x = rnd(k + 2000) * 420 - 10
386 const r = near ? 2.4 + rnd(k + 2100) * 1.6 : 1 + rnd(k + 2200) * 1
387 const dur = near ? 10 + rnd(k + 2300) * 4 : 18 + rnd(k + 2400) * 8
388 const sway = (near ? 16 : 9) * (rnd(k + 2500) < 0.5 ? 1 : -1)
389
390 return (
391 `<circle r="${n(r)}" fill="#fff" opacity="${near ? 0.95 : 0.75}">` +
392 `<animateTransform attributeName="transform" type="translate" values="${n(x)} -10;${n(x + sway)} ${H * 0.35};${n(x - sway * 0.5)} ${H * 0.7};${n(x + sway * 0.3)} ${H + 10}" calcMode="spline" keyTimes="0;.33;.66;1" keySplines="${EASE};${EASE};${EASE}" dur="${n(dur)}s" begin="${n(-rnd(k + 2600) * dur)}s" repeatCount="indefinite"/></circle>`
393 )
394}).join('')
395
396// ---- nightfall --------------------------------------------------------------------------------------------
397const STARS = Array.from({ length: 30 }, (_, k) => `<circle cx="${n(rnd(k + 3000) * 400)}" cy="${n(20 + rnd(k + 3100) * 420)}" r="${n(0.8 + rnd(k + 3200) * 1)}" fill="#fff"/>`).join('')
398const MOON = '<circle cx="300" cy="150" r="54" fill="#fff6d8" opacity=".12"/><circle cx="300" cy="150" r="24" fill="#fff6d8"/><circle cx="292" cy="144" r="4" fill="#ece0b8"/><circle cx="306" cy="158" r="3" fill="#ece0b8"/>'
399
400// how dark it is: [from, to]. A rebuilt picture carries on from where the fade already is.
401const nightSpan = (w: Yard, now: number): [number, number] => {
402 const since = (now - w.startedAt) / 1000
403 const sinceEnd = (now - w.endedAt) / 1000
404 if (w.working) {
405 return [Math.min(1, since / 1.6), 1]
406 }
407
408 return w.endedAt > 0 && sinceEnd < 1.6 ? [1 - sinceEnd / 1.6, 0] : [0, 0]
409}
410
411const fade = ([a0, a1]: [number, number], inner: string) =>
412 a0 === a1
413 ? a1 > 0
414 ? inner
415 : ''
416 : `<g opacity="${r3(a0)}"><animate data-once="1" attributeName="opacity" values="${r3(a0)};${a1}" dur="${n(1.6 * Math.abs(a1 - a0))}s" fill="freeze"/>${inner}</g>`
417
418// ---- the whole picture --------------------------------------------------------------------------------
419const buildScene = (w: Yard, elapsed: number, vh: number, now: number) => {
420 const view = Math.min(H, Math.max(vh, 240))
421 const y0 = H - view
422 const span = nightSpan(w, now)
423 const since = (now - w.startedAt) / 1000
424
425 const defs =
426 '<defs>' +
427 '<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9cc0de"/><stop offset=".7" stop-color="#d8e6f2"/><stop offset="1" stop-color="#f2f6fa"/></linearGradient>' +
428 '</defs>'
429
430 // snowmen already standing, and while Claude works, the ones still to come, each in its own time
431 const men = SPOTS.map((_, k) =>
432 k < (w.working ? w.base : w.built) ? snowman(k, null) : w.working ? snowman(k, FIRST + (k - w.base) * EVERY - since) : '',
433 ).join('')
434
435 const land =
436 SKY +
437 MOUNTAINS +
438 FAR_FOREST +
439 GROUND_SNOW +
440 MID_FOREST +
441 CABIN +
442 WOODPILE +
443 FENCE +
444 FRAMES +
445 RABBIT +
446 FOX +
447 NEAR_TREES +
448 men +
449 HUSKY +
450 MAGPIE
451
452 const night = `<rect x="0" y="0" width="${W}" height="${H}" fill="#0c1838" opacity=".72"/><g>${STARS}${MOON}</g>`
453
454 return (
455 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${n(y0)} ${W} ${n(view)}" width="${W}" height="${n(view)}" preserveAspectRatio="xMidYMax slice">${defs}` +
456 retime(land, elapsed) +
457 // by night the land darkens, stars come out, and the windows and lantern glow; the snow falls over it all
458 fade(span, night) +
459 fade(span, LIGHTS + FRAMES) +
460 retime(smoke(w.working) + SNOW, elapsed) +
461 '</svg>'
462 )
463}
464// ---scene-end
465
466const TERMINAL_ART: [string, string][] = [
467 [' * ▲ * ▲▲ *', '#d6e2ee'],
468 [' ▲ * ▲ ⌂ * ▲ ', '#8fa8b8'],
469 [' ▲▲ * /██\\ * ▲▲', '#fbfdff'],
470 [' * |▯▯| ☃ * ', '#ffd27a'],
471 [' ░░░░░░░░░░░░░░░░░░', '#f6f9fc'],
472]
473
474const clock = (ms: number) => {
475 const s = Math.max(0, Math.floor(ms / 1000))
476
477 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
478}
479
480const label = (w: Cabin, now: number, T: Words) => (w.working ? T.working(clock(now - w.startedAt), w.tools) : T.idle)
481
482const yard = atom({ plugin: 'snow-cabin', key: 'yard' } as const, { working: false, tools: 0, startedAt: 0, endedAt: 0, built: 0, base: 0, tick: 0 } as Cabin)
483
484// The picture is built again only when the state it draws changes; the same text keeps it running.
485let t0 = 0
486let cache = { key: '', svg: '' }
487
488export const register: Register = (on, options) => {
489 // `language` is a field of the plugin's userConfig: 'en' (default) or 'ko'
490 const T: Words = STRINGS[options.language === 'ko' ? 'ko' : 'en']
491
492 on('session.start', async ($, e, next) => {
493 t0 = await $.clock.now()
494 await $.command.register({
495 name: 'snow',
496 description: 'Open the snowy cabin pane',
497 })
498 // Wakes the pane each second while Claude works, for the stopwatch in its top line.
499 $.clock.every(1000, () => {
500 void read($, yard)
501 .then(r => (r.working ? update($, yard, v => ({ ...v, tick: v.tick + 1 })) : undefined))
502 .catch(() => {})
503 })
504
505 return next(e)
506 })
507
508 on('command.run', { command: 'snow' }, async $ => {
509 await $.ui.open({ id: PANE, title: T.title })
510
511 return { text: T.opened }
512 })
513
514 // The main loop's turns only: a subagent's run is not Claude at work.
515 // A turn adds snowmen to those standing; once the yard is full it starts again from none.
516 on('turn.start', async ($, e, next) => {
517 if (e.agentId === undefined) {
518 const now = await $.clock.now()
519 await update($, yard, v => (v.working ? v : { ...v, working: true, tools: 0, startedAt: now, base: v.built >= MAX ? 0 : v.built }))
520 }
521
522 return next(e)
523 }).catch(($, e, next) => next(e))
524
525 on('turn.complete', async ($, e, next) => {
526 if (e.agentId === undefined) {
527 const now = await $.clock.now()
528 await update($, yard, v => {
529 if (!v.working) {
530 return v
531 }
532 const since = (now - v.startedAt) / 1000
533 const made = since < FIRST ? 0 : Math.floor((since - FIRST) / EVERY) + 1
534
535 return { ...v, working: false, endedAt: now, built: Math.min(MAX, v.base + made) }
536 })
537 }
538
539 return next(e)
540 }).catch(($, e, next) => next(e))
541
542 on('tool.call', async ($, e, next) => {
543 await update($, yard, v => ({ ...v, tools: v.tools + 1 }))
544
545 return next(e)
546 }).catch(($, e, next) => next(e))
547
548 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
549 const els = $.ui.resolve(e)
550 const w = await read($, yard)
551 const now = await $.clock.now()
552 if (t0 === 0) t0 = now
553 const cols = e.props?.bodyColumns ?? 40
554 const rows = e.props?.scroll?.bodyRows ?? 20
555 const line = label(w, now, T)
556
557 if (e.surface !== 'terminal') {
558 const { Box, Text, Svg } = els as ElementTable<'desktop'>
559 // The pane's px: its cells over the code font's advance (about 7.8 px) and line height
560 // (about 19.5 px). Short of the whole picture, the sky is cut, never the yard.
561 const width = Math.max(160, Math.round(cols * 7.8))
562 const full = Math.round((width * H) / W)
563 const tall = e.props?.scroll?.bodyRows ? Math.round((rows - 1) * 19.5) : full
564 const height = Math.min(full, tall)
565 // nightfall and daybreak take 1.6 s; a rebuild after that draws them settled
566 const fading = w.working ? (now - w.startedAt) / 1000 < 1.6 : w.endedAt > 0 && (now - w.endedAt) / 1000 < 1.6
567 const key = `${w.working}|${fading}|${w.startedAt}|${w.endedAt}|${w.built}|${height}|${width}`
568 if (cache.key !== key) cache = { key, svg: buildScene(w, (now - t0) / 1000, (height * W) / width, now) }
569
570 return (
571 <Box flexDirection="column" justifyContent="center" minHeight={rows}>
572 <Box justifyContent="center">
573 <Text bold={w.working} dimColor={!w.working}>
574 {line}
575 </Text>
576 </Box>
577 <Svg source={cache.svg} alt={T.alt} width={width} height={height} isInteractive />
578 </Box>
579 )
580 }
581
582 const { Box, Text } = els as ElementTable<'terminal'>
583
584 return (
585 <Box flexDirection="column">
586 {TERMINAL_ART.map(([row, color]) => (
587 <Text color={color}>{row}</Text>
588 ))}
589 <Text>{line}</Text>
590 <Text dimColor>{T.note}</Text>
591 </Box>
592 )
593 })
594}
595types/index.d.ts 17 lines1export type Cabin = {
2 working: boolean
3 tools: number
4 startedAt: number
5 endedAt: number
6 // snowmen standing now, and how many stood when this turn began
7 built: number
8 base: number
9 tick: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'snow-cabin': { yard: Cabin }
15 }
16}
17