A tall pane on the Moon looking at the Earth: a lander and a bouncing astronaut on the surface below, and above them stars with supernovae, a black hole…

<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 546 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { Moon } from '../types'
5
6const PANE = 'moon-view'
7const STRINGS = {
8 en: {
9 title: 'Moon',
10 alt: 'The universe seen from the Moon. An astronaut bounces beside the lander, the Earth hangs in the sky, and shooting stars, rockets, satellites, UFOs and planets pass at random.',
11 working: (clock: string, tools: number) => `Working ${clock} · tools ${tools}`,
12 idle: 'Idle',
13 opened: 'Opened the moon pane.',
14 note: 'The animation shows in the desktop app.',
15 },
16 ko: {
17 title: '달',
18 alt: '달 표면에서 본 우주. 달착륙선 옆에서 우주인이 통통 뛰고, 하늘에는 지구가 떠 있으며, 별똥별과 로켓, 위성, UFO, 행성이 무작위로 지나갑니다.',
19 working: (clock: string, tools: number) => `작업 중 ${clock} · 도구 ${tools}회`,
20 idle: '쉬는 중',
21 opened: '달 표면을 열었습니다.',
22 note: '그림 움직임은 데스크톱 앱에서 보입니다.',
23 },
24}
25
26type Words = (typeof STRINGS)['en']
27
28// ---scene-start
29const W = 400
30const H = 1200
31const HORIZON = 960
32const GY = 1068 // where the astronaut's boots land
33const KINDS = 15
34const HORIZON_SECONDS = 800 // how far ahead visits are laid out
35
36const n = (v: number) => String(Math.round(v * 10) / 10)
37const EASE = '.45 0 .55 1'
38const pick = <T,>(list: T[], s: number): T => list[((s % list.length) + list.length) % list.length]
39const rnd = (s: number) => {
40 const v = Math.sin(s * 127.1 + 311.7) * 43758.5453
41
42 return v - Math.floor(v)
43}
44
45// Looping motion shifts back by the time already elapsed, so redrawing the picture never makes it jump.
46const retime = (svg: string, elapsed: number) =>
47 svg.replace(/<(animate|animateTransform|animateMotion)\b[^>]*>/g, tag => {
48 const found = / begin="(-?[\d.]+)s"/.exec(tag)
49 const shifted = ` begin="${n((found ? parseFloat(found[1]) : 0) - elapsed)}s"`
50
51 return found ? tag.replace(found[0], shifted) : tag.replace(/^<(\w+)/, `<$1${shifted}`)
52 })
53
54// ---- space: stars, a faint Milky Way, and the Earth ----------------------------------------------------------------
55const STARS = Array.from({ length: 110 }, (_, k) => {
56 const y = rnd(k * 2 + 1) * 950
57 const r = 0.5 + rnd(k + 90) * 1.4
58 const twinkle = k % 2 === 0 ? `<animate attributeName="opacity" values=".3;1;.3" dur="${n(2 + rnd(k + 5) * 4)}s" begin="${n(-rnd(k + 9) * 6)}s" repeatCount="indefinite"/>` : ''
59
60 return `<circle cx="${n(rnd(k * 2) * W)}" cy="${n(y)}" r="${n(r)}" fill="${pick(['#fff', '#cfe0ff', '#fff3c8', '#ffd9d0'], k)}" opacity=".85">${twinkle}</circle>`
61}).join('')
62
63const MILKY = '<polygon points="-20,300 120,120 220,60 260,100 140,260 20,420" fill="#aab4ff" opacity=".06"/><polygon points="40,520 200,330 330,240 350,280 220,420 90,600" fill="#c8b4ff" opacity=".05"/>'
64
65const EARTH_AT: [number, number] = [262, 620]
66const R = 100
67// continents and clouds slide across the disc, so the Earth turns
68const strip = (blobs: number, seed: number, colors: string[], opacity: number, dur: number) => {
69 const one = Array.from({ length: blobs }, (_, k) => {
70 const x = rnd(seed + k) * 400
71 const y = EARTH_AT[1] - R + 16 + rnd(seed + k + 40) * (2 * R - 32)
72
73 return `<ellipse cx="${n(x)}" cy="${n(y)}" rx="${n(16 + rnd(seed + k + 80) * 34)}" ry="${n(7 + rnd(seed + k + 120) * 20)}" fill="${pick(colors, k)}" opacity="${opacity}"/>`
74 }).join('')
75
76 return `<g><animateTransform attributeName="transform" type="translate" values="-100 0;-500 0" dur="${dur}s" repeatCount="indefinite"/>${one}<g transform="translate(400 0)">${one}</g></g>`
77}
78
79const EARTH =
80 `<circle cx="${EARTH_AT[0]}" cy="${EARTH_AT[1]}" r="${R + 14}" fill="#7fb8ff" opacity=".12"/><circle cx="${EARTH_AT[0]}" cy="${EARTH_AT[1]}" r="${R + 6}" fill="#7fb8ff" opacity=".16"/>` +
81 `<g clip-path="url(#earth)"><rect x="${EARTH_AT[0] - R}" y="${EARTH_AT[1] - R}" width="${2 * R}" height="${2 * R}" fill="url(#ocean)"/>` +
82 `<g transform="translate(${EARTH_AT[0] - R - 100} 0)">${strip(9, 10, ['#4f9a4a', '#3f8a3f', '#c9a45a', '#5aa55a'], 0.95, 180)}</g>` +
83 `<ellipse cx="${EARTH_AT[0]}" cy="${EARTH_AT[1] - R + 4}" rx="60" ry="9" fill="#fff" opacity=".85"/><ellipse cx="${EARTH_AT[0]}" cy="${EARTH_AT[1] + R - 4}" rx="56" ry="8" fill="#fff" opacity=".85"/>` +
84 `<g transform="translate(${EARTH_AT[0] - R - 100} 0)">${strip(14, 200, ['#fff'], 0.62, 120)}</g>` +
85 `<rect x="${EARTH_AT[0] - R}" y="${EARTH_AT[1] - R}" width="${2 * R}" height="${2 * R}" fill="url(#shade)"/></g>` +
86 `<circle cx="${EARTH_AT[0]}" cy="${EARTH_AT[1]}" r="${R}" fill="none" stroke="#9fd0ff" stroke-width="3" opacity=".5"/>`
87
88// ---- the surface -----------------------------------------------------------------------------------------------------------------
89const GROUND =
90 `<path d="M0 ${HORIZON} Q200 ${HORIZON - 34} 400 ${HORIZON} V${H} H0Z" fill="url(#regolith)"/>` +
91 `<path d="M0 ${HORIZON} Q200 ${HORIZON - 34} 400 ${HORIZON}" fill="none" stroke="#e8e8f0" stroke-width="2" opacity=".5"/>` +
92 Array.from({ length: 10 }, (_, k) => {
93 const x = 20 + rnd(k + 400) * 360
94 const y = HORIZON + 24 + rnd(k + 420) * (H - HORIZON - 50)
95 const rx = 14 + rnd(k + 440) * 36
96 const ry = rx * (0.22 + rnd(k + 460) * 0.12)
97
98 return (
99 `<ellipse cx="${n(x)}" cy="${n(y)}" rx="${n(rx)}" ry="${n(ry)}" fill="#6a6a76" opacity=".55"/>` +
100 `<ellipse cx="${n(x)}" cy="${n(y + ry * 0.2)}" rx="${n(rx * 0.86)}" ry="${n(ry * 0.8)}" fill="#7e7e8a" opacity=".7"/>` +
101 `<path d="M${n(x - rx)} ${n(y)} Q${n(x)} ${n(y - ry * 1.4)} ${n(x + rx)} ${n(y)}" fill="none" stroke="#c9c9d4" stroke-width="1.4" opacity=".6"/>`
102 )
103 }).join('') +
104 Array.from({ length: 14 }, (_, k) => `<ellipse cx="${n(rnd(k + 500) * W)}" cy="${n(HORIZON + 14 + rnd(k + 520) * (H - HORIZON - 30))}" rx="${n(3 + rnd(k + 540) * 8)}" ry="${n(1.6 + rnd(k + 560) * 3)}" fill="${pick(['#5a5a66', '#8a8a96', '#6a6a76'], k)}"/>`).join('') +
105 // the astronaut's tracks
106 Array.from({ length: 16 }, (_, k) => `<ellipse cx="${n(176 + k * 10)}" cy="${GY + 6 + (k % 2) * 3}" rx="3" ry="1.2" fill="#55555f" opacity=".55"/>`).join('')
107
108const FLAG =
109 `<g transform="translate(372 ${GY + 2})"><rect x="-1" y="-62" width="2" height="62" fill="#e8e8f0"/><rect x="1" y="-62" width="26" height="16" fill="#f4f5f8"/><path d="M5 -58 l2 4 l4 -4 l-4 1 l-2 -3Z" fill="#d94f4f"/><rect x="1" y="-62" width="26" height="3" fill="#3f6fd0"/><ellipse cy="1" rx="8" ry="2.4" fill="#55555f" opacity=".5"/></g>`
110
111// The two windows of the ascent stage look like eyes: they blink bright now and then, and flicker while Claude works.
112const eye = (x: number, working: boolean) => {
113 const flash = working ? '#fff2a8' : '#cfe8ff'
114 const dur = working ? 0.7 : 5
115 const times = working ? '0;.3;.4;.7;.8;1' : '0;.9;.93;.97;1'
116 const fills = working ? `#2a3550;${flash};#2a3550;${flash};#2a3550;#2a3550` : `#2a3550;#2a3550;${flash};${flash};#2a3550`
117 const glow = working ? '0;.8;0;.8;0;0' : '0;0;.7;.7;0'
118
119 return (
120 `<circle cx="${x}" cy="-68" r="${working ? 12 : 10}" fill="${flash}" opacity="0"><animate attributeName="opacity" values="${glow}" keyTimes="${times}" dur="${dur}s" repeatCount="indefinite"/></circle>` +
121 `<polygon points="${x - 6},-72 ${x + 6},-72 ${x},-62" fill="#2a3550"><animate attributeName="fill" values="${fills}" keyTimes="${times}" dur="${dur}s" repeatCount="indefinite"/></polygon>`
122 )
123}
124
125// the lander, gold-wrapped below and grey above; its lamp is lit while Claude works
126const lander = (working: boolean) =>
127 '<ellipse cx="0" cy="2" rx="62" ry="5" fill="#000" opacity=".28"/>' +
128 '<path d="M-30 -30 L-54 -2 M30 -30 L54 -2 M-30 -30 L-18 -4 M30 -30 L18 -4" stroke="#aab0bc" stroke-width="3" fill="none" stroke-linecap="round"/>' +
129 '<ellipse cx="-54" cy="0" rx="10" ry="3.4" fill="#8a8f9b"/><ellipse cx="54" cy="0" rx="10" ry="3.4" fill="#8a8f9b"/><ellipse cx="-18" cy="-1" rx="7" ry="2.6" fill="#8a8f9b"/><ellipse cx="18" cy="-1" rx="7" ry="2.6" fill="#8a8f9b"/>' +
130 '<path d="M-50 -6 L-62 -34 M-62 -34 H-54 M-62 -22 H-54 M-62 -12 H-54" stroke="#d6dae4" stroke-width="1.6" fill="none"/>' +
131 '<rect x="-34" y="-52" width="68" height="26" rx="3" fill="#d9a441"/><path d="M-34 -44 H34 M-34 -36 H34 M-14 -52 V-26 M10 -52 V-26" stroke="#b8862c" stroke-width="1.2" fill="none"/>' +
132 '<polygon points="-24,-52 24,-52 30,-72 14,-86 -14,-86 -30,-72" fill="#c7ccd6" stroke="#8a8f9b" stroke-width="1.2"/>' +
133 eye(-12, working) + eye(12, working) +
134 '<path d="M20 -86 V-98 M20 -98 a8 4 0 1 0 0.01 0" stroke="#e8eaf0" stroke-width="1.6" fill="none"/><path d="M-6 -86 V-96" stroke="#e8eaf0" stroke-width="1.6"/>' +
135 `<circle cx="-34" cy="-46" r="${working ? 9 : 4}" fill="${working ? '#fff2a8' : '#6a7080'}" opacity="${working ? 0.4 : 0.6}"/><circle cx="-34" cy="-46" r="2.6" fill="${working ? '#fff2a8' : '#6a7080'}"/>`
136
137// the astronaut: a long, slow hop in low gravity, three hops out and three back
138// Three hops out and three back. Working, he leaves the ground much higher and cannot keep his balance.
139const hopPath = (lift: number) => {
140 const right = [0, 1, 2].map(k => `Q${197 + 54 * k} ${GY - lift} ${224 + 54 * k} ${GY}`).join(' ')
141 const left = [0, 1, 2].map(k => `Q${305 - 54 * k} ${GY - lift} ${278 - 54 * k} ${GY}`).join(' ')
142
143 return `M170 ${GY} ${right} ${left}`
144}
145
146const astronaut = (working: boolean) => {
147 const swing = working ? 0.4 : 2.4
148 const kick = working ? 0.35 : 0
149 const body =
150 '<rect x="-12" y="-36" width="9" height="18" rx="3" fill="#cfd4de"/><rect x="-6" y="-34" width="13" height="20" rx="6" fill="#f4f5f8"/><rect x="-6" y="-22" width="13" height="3" fill="#d94f4f"/>' +
151 (kick
152 ? `<g><animateTransform attributeName="transform" type="rotate" values="-40 -3 -15;40 -3 -15;-40 -3 -15" dur="${kick}s" repeatCount="indefinite"/><rect x="-5" y="-15" width="4.4" height="15" rx="2" fill="#e6e8ee"/><rect x="-6" y="-3" width="6.4" height="3.6" rx="1.8" fill="#8a8f9b"/></g>` +
153 `<g><animateTransform attributeName="transform" type="rotate" values="40 3 -15;-40 3 -15;40 3 -15" dur="${kick}s" repeatCount="indefinite"/><rect x="1.4" y="-15" width="4.4" height="15" rx="2" fill="#e6e8ee"/><rect x="1" y="-3" width="6.4" height="3.6" rx="1.8" fill="#8a8f9b"/></g>`
154 : '<rect x="-5" y="-15" width="4.4" height="15" rx="2" fill="#e6e8ee"/><rect x="1.4" y="-15" width="4.4" height="15" rx="2" fill="#e6e8ee"/><rect x="-6" y="-3" width="6.4" height="3.6" rx="1.8" fill="#8a8f9b"/><rect x="1" y="-3" width="6.4" height="3.6" rx="1.8" fill="#8a8f9b"/>') +
155 `<g><animateTransform attributeName="transform" type="rotate" values="-35 0 -30;30 0 -30;-35 0 -30" dur="${swing}s" repeatCount="indefinite"/><rect x="-2.2" y="-31" width="4.4" height="15" rx="2.2" fill="#f4f5f8"/></g>` +
156 (working ? `<g><animateTransform attributeName="transform" type="rotate" values="35 0 -30;-30 0 -30;35 0 -30" dur="${swing * 1.3}s" repeatCount="indefinite"/><rect x="-2.2" y="-31" width="4.4" height="15" rx="2.2" fill="#f4f5f8"/></g>` : '') +
157 '<circle cx="1" cy="-41" r="9" fill="#f4f5f8"/><ellipse cx="3.4" cy="-41" rx="6" ry="5.4" fill="url(#visor)"/><ellipse cx="1.6" cy="-43" rx="2.4" ry="1.6" fill="#fff" opacity=".7"/>'
158 // working, the whole body turns over and over about its middle
159 const turning = working
160 ? `<g transform="translate(0 -22)"><g><animateTransform attributeName="transform" type="rotate" values="0;360" dur="1.5s" repeatCount="indefinite"/><g transform="translate(0 22)">${body}</g></g></g>`
161 : body
162
163 return `<g><animateTransform attributeName="transform" type="scale" values="1 1;-1 1" keyTimes="0;.5" calcMode="discrete" dur="34s" repeatCount="indefinite"/>${turning}</g>`
164}
165
166const ASTRO_SHADOW = `<ellipse cx="0" cy="${GY + 4}" rx="12" ry="3" fill="#000" opacity=".3"><animateTransform attributeName="transform" type="translate" values="170 0;332 0;170 0" dur="34s" repeatCount="indefinite"/></ellipse>`
167
168// ---- visitors, each drawn for its own time and then gone ------------------------------------------------------------------------------------------
169type Slot = { id: number; kind: number; seed: number }
170
171const LIFE = [3, 9, 18, 12, 18, 14, 22, 16, 22, 18, 10, 22, 20, 12, 14]
172
173// ---- a long time ago, in a galaxy far, far away: plain look-alikes ----------------------------------------------------------------------------------
174const deathStar = () =>
175 '<circle r="34" fill="#cfe0ff" opacity=".08"/><circle r="28" fill="#a8adb8"/><circle r="28" fill="none" stroke="#6a707c" stroke-width="1.4"/>' +
176 '<path d="M-28 2 Q0 8 28 2" fill="none" stroke="#5a5f6c" stroke-width="2.4"/><path d="M-20 -14 Q0 -8 20 -16 M-24 12 Q0 20 24 12" fill="none" stroke="#8a909c" stroke-width="1"/>' +
177 '<circle cx="-9" cy="-9" r="7.4" fill="#7a808c"/><circle cx="-9" cy="-9" r="4.6" fill="#5a606c"/><circle cx="-9" cy="-9" r="1.8" fill="#7dff7d"><animate attributeName="opacity" values=".3;1;.3" dur="2.4s" repeatCount="indefinite"/></circle>'
178
179const starDestroyer = () =>
180 '<polygon points="70,0 -64,-17 -64,17" fill="#8e94a2"/><polygon points="70,0 -64,-17 -40,0" fill="#a9afbc"/><path d="M-46 -12 L50 -1 M-46 12 L50 1 M-20 -6 L-20 6 M10 -3 L10 3" stroke="#6a707c" stroke-width="1" fill="none"/>' +
181 '<rect x="-58" y="-5" width="16" height="10" fill="#7a808c"/><rect x="-54" y="-9" width="5" height="4" fill="#6a707c"/><rect x="-48" y="-9" width="5" height="4" fill="#6a707c"/>' +
182 [-9, 0, 9].map(y => `<circle cx="-66" cy="${y}" r="3.4" fill="#7fd0ff"><animate attributeName="opacity" values=".6;1;.6" dur=".8s" repeatCount="indefinite"/></circle>`).join('')
183
184const xWing = () =>
185 '<path d="M-9 -2 L-3 -2 L-5 -19 L-13 -19Z M-9 2 L-3 2 L-5 19 L-13 19Z" fill="#cfd4de"/><rect x="-14" y="-21" width="9" height="3" rx="1" fill="#8a909c"/><rect x="-14" y="18" width="9" height="3" rx="1" fill="#8a909c"/>' +
186 '<path d="M18 0 L2 -3.4 L-10 -3 L-12 0 L-10 3 L2 3.4Z" fill="#e8eaf0"/><ellipse cx="6" cy="0" rx="3" ry="1.6" fill="#2a3550"/><path d="M-3 -19 l3 0 M-3 19 l3 0" stroke="#d94f4f" stroke-width="2"/>' +
187 '<circle cx="-13" cy="0" r="2.6" fill="#ff8a5c"><animate attributeName="opacity" values="1;.4;1" dur=".2s" repeatCount="indefinite"/></circle>'
188
189const tieFighter = () =>
190 '<path d="M-11 0 H11" stroke="#5a606c" stroke-width="2"/>' +
191 '<polygon points="-11,-14 -4,-7 -4,7 -11,14 -18,7 -18,-7" fill="#3a3f4c" stroke="#8a909c" stroke-width="1"/><polygon points="11,-14 18,-7 18,7 11,14 4,7 4,-7" fill="#3a3f4c" stroke="#8a909c" stroke-width="1"/>' +
192 '<path d="M-11 -14 V14 M-18 0 H-4 M11 -14 V14 M4 0 H18" stroke="#8a909c" stroke-width=".8"/>' +
193 '<circle r="5.6" fill="#6a707c" stroke="#aab0bc" stroke-width="1"/><circle r="2.4" fill="#1a1e2a"/>'
194
195const bolts = (lag: number) =>
196 [0, 1].map(k => `<path d="M0 0 H12" stroke="#ff4040" stroke-width="2.4" stroke-linecap="round" opacity="0"><animateTransform attributeName="transform" type="translate" values="16 ${k ? 4 : -4};110 ${k ? 4 : -4}" dur=".7s" begin="${n(lag + k * 0.35)}s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;1;1;0" dur=".7s" begin="${n(lag + k * 0.35)}s" repeatCount="indefinite"/></path>`).join('')
197
198const falcon = () =>
199 '<ellipse rx="15" ry="13" fill="#b8bcc6" stroke="#7a808c" stroke-width="1.2"/><path d="M13 -7 L28 -5 L28 -2 L14 -2Z M13 7 L28 5 L28 2 L14 2Z" fill="#a0a5b0" stroke="#7a808c" stroke-width="1"/>' +
200 '<path d="M-10 -9 L10 -5 M-10 9 L10 5 M-12 0 H10 M0 -12 V12" stroke="#8a909c" stroke-width=".9" fill="none"/><circle cx="2" cy="0" r="4" fill="#9aa0ac" stroke="#7a808c" stroke-width="1"/>' +
201 '<rect x="6" y="10" width="14" height="5" rx="2.4" fill="#8a909c"/><circle cx="19" cy="12.4" r="1.8" fill="#2a3550"/>' +
202 '<rect x="-19" y="-7" width="5" height="14" fill="#7fd0ff"><animate attributeName="opacity" values="1;.5;1" dur=".3s" repeatCount="indefinite"/></rect>'
203
204const wrap = (kind: number, starts: number[], body: string) => {
205 const life = LIFE[kind]
206 const begin = starts.map(t => `${n(t)}s`).join(';')
207
208 return (
209 `<g data-visitor="${kind}" display="none"><animate attributeName="display" values="inline;inline" calcMode="discrete" dur="${life}s" begin="${begin}"/>` +
210 `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.08;.9;1" dur="${life}s" begin="${begin}" fill="freeze"/>${body}</g></g>`
211 )
212}
213
214const visitor = (v: Slot, starts: number[], y0: number) => {
215 const B = (lag = 0) => starts.map(t => `${n(t + lag)}s`).join(';')
216 const r = (k: number) => rnd(v.seed + k)
217 const life = LIFE[v.kind]
218 const dur = life - 0.6
219 const top = Math.max(y0 + 50, 60)
220 const px = 50 + r(1) * 300
221 // the Earth fills the middle of the sky, so things pass above it or in the strip under it
222 const useUpper = top < 380 && r(2) < 0.82
223 const lowerFrom = Math.max(top, 800)
224 const py = useUpper ? top + r(6) * (400 - top) : lowerFrom + r(6) * (890 - lowerFrom)
225 const dir = r(3) < 0.5 ? 1 : -1
226 const [from, to] = dir === 1 ? [-50, 450] : [450, -50]
227
228 switch (v.kind) {
229 case 0: {
230 const x = 80 + r(4) * 280
231
232 return wrap(
233 0,
234 starts,
235 `<g><animateTransform attributeName="transform" type="translate" values="${n(x)} ${n(py)};${n(x - 190)} ${n(py + 130)}" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
236 '<path d="M0 0 L40 -28" stroke="#fff" stroke-width="2.4" stroke-linecap="round" opacity=".8"/><path d="M0 0 L90 -63" stroke="#cfe0ff" stroke-width="1" opacity=".4"/><circle r="2.6" fill="#fff"/></g>',
237 )
238 }
239 case 1:
240 return wrap(
241 1,
242 starts,
243 `<g transform="translate(${n(px)} ${n(py)})"><g><animateTransform attributeName="transform" type="scale" values="0 0;1 1;1.3 1.3;1.1 1.1" keyTimes="0;.15;.4;1" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
244 '<circle r="40" fill="#ffe9a8" opacity=".25"/><circle r="22" fill="#fff6c8" opacity=".6"/><circle r="9" fill="#fff"/>' +
245 Array.from({ length: 8 }, (_, k) => `<path d="M0 0 L${n(Math.cos((k * Math.PI) / 4) * 64)} ${n(Math.sin((k * Math.PI) / 4) * 64)}" stroke="${k % 2 ? '#9fd0ff' : '#fff3c8'}" stroke-width="${k % 2 ? 1.4 : 2.4}" opacity=".7"/>`).join('') +
246 `</g><circle r="6" fill="none" stroke="#9fd0ff" stroke-width="2"><animate attributeName="r" values="6;110" dur="${dur}s" begin="${B()}" fill="freeze"/><animate attributeName="opacity" values=".9;0" dur="${dur}s" begin="${B()}" fill="freeze"/></circle></g>`,
247 )
248 case 2:
249 return wrap(
250 2,
251 starts,
252 `<g transform="translate(${n(px)} ${n(py)}) rotate(${n(-18 + r(5) * 36)})"><g><animateTransform attributeName="transform" type="scale" values="0 0;1 1;1 1;0 0" keyTimes="0;.15;.85;1" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
253 '<circle r="40" fill="#fff" opacity=".07"/><ellipse rx="62" ry="17" fill="none" stroke="#ff9a3c" stroke-width="7" opacity=".85"/><ellipse rx="62" ry="17" fill="none" stroke="#fff2c8" stroke-width="2.4"/>' +
254 '<circle r="25" fill="#000" stroke="#fff" stroke-width="1" opacity=".95"/><path d="M-62 0 A62 17 0 0 0 62 0" fill="none" stroke="#ffd27a" stroke-width="5"/><path d="M-62 0 A62 17 0 0 0 62 0" fill="none" stroke="#fff" stroke-width="1.6"/>' +
255 '<circle r="36" fill="none" stroke="#cfe0ff" stroke-width="1" stroke-dasharray="2 6" opacity=".6"/></g></g>',
256 )
257 case 3: {
258 const path = `M${from} ${n(py + 60)} C${n(from + (to - from) * 0.3)} ${n(py - 60)} ${n(from + (to - from) * 0.6)} ${n(py + 80)} ${to} ${n(py - 40)}`
259
260 return wrap(
261 3,
262 starts,
263 `<g><animateMotion path="${path}" rotate="auto" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(1 ${dir})">` +
264 '<path d="M-12 -4 L-20 -9 L-20 -3Z M-12 4 L-20 9 L-20 3Z" fill="#d94f4f"/><rect x="-14" y="-4" width="22" height="8" rx="2" fill="#f4f1ea"/><path d="M8 -4 Q18 0 8 4Z" fill="#d94f4f"/><circle cx="0" cy="0" r="1.8" fill="#7fd0ff"/>' +
265 '<path d="M-14 0 L-34 -3 L-30 0 L-34 3Z" fill="#ffb347"><animate attributeName="opacity" values="1;.4;1" dur=".22s" repeatCount="indefinite"/></path></g></g>',
266 )
267 }
268 case 4:
269 return wrap(
270 4,
271 starts,
272 `<g><animateTransform attributeName="transform" type="translate" values="${from} ${n(py)};${to} ${n(py + 30)}" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(${dir} 1)"><g><animateTransform attributeName="transform" type="rotate" values="-8;8;-8" dur="9s" repeatCount="indefinite"/>` +
273 '<rect x="-5" y="-4" width="10" height="8" rx="1.5" fill="#cfd6e4"/><path d="M-5 0 H-22 M5 0 H22" stroke="#8a96ad" stroke-width="1.2"/><rect x="-34" y="-5" width="12" height="10" fill="#3f6fd0"/><rect x="22" y="-5" width="12" height="10" fill="#3f6fd0"/>' +
274 '<circle cy="-8" r="2.6" fill="#e8eaf0"/><circle cy="4" r="1.2" fill="#ff6a6a"><animate attributeName="opacity" values="1;.1;1" dur="1.6s" repeatCount="indefinite"/></circle></g></g></g>',
275 )
276 case 5: {
277 const path = `M${from} ${n(py)} C${n(from + (to - from) * 0.2)} ${n(py - 80)} ${n(from + (to - from) * 0.32)} ${n(py + 80)} ${n(from + (to - from) * 0.5)} ${n(py)} C${n(from + (to - from) * 0.66)} ${n(py - 70)} ${n(from + (to - from) * 0.8)} ${n(py + 60)} ${to} ${n(py - 20)}`
278
279 return wrap(
280 5,
281 starts,
282 `<g><animateMotion path="${path}" dur="${dur}s" begin="${B()}" fill="freeze"/><g><animateTransform attributeName="transform" type="rotate" values="-8;8;-8" dur="1.8s" repeatCount="indefinite"/>` +
283 '<polygon points="-4,6 4,6 22,70 -22,70" fill="#b4ffb4" opacity="0"><animate attributeName="opacity" values="0;0;.35;.35;0;0" keyTimes="0;.4;.45;.6;.65;1" dur="' + dur + 's" begin="' + B() + '" fill="freeze"/></polygon>' +
284 '<ellipse cx="0" cy="-5" rx="11" ry="8" fill="#7fe8ff" opacity=".8"/><ellipse rx="26" ry="7" fill="#aab4c8"/><ellipse cx="0" cy="3" rx="22" ry="3" fill="#7a849a"/>' +
285 [-16, -8, 0, 8, 16].map((x, k) => `<circle cx="${x}" cy="1" r="1.8" fill="${pick(['#ff6a6a', '#ffd45e', '#6aff9a'], k)}"><animate attributeName="opacity" values="1;.2;1" dur="${n(0.8 + k * 0.15)}s" repeatCount="indefinite"/></circle>`).join('') +
286 '</g></g>',
287 )
288 }
289 case 6:
290 return wrap(
291 6,
292 starts,
293 `<g><animateTransform attributeName="transform" type="translate" values="${n(px)} ${n(py)};${n(px - 36)} ${n(py + 6)}" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="rotate(-18)">` +
294 '<path d="M-40 0 A40 10 0 0 1 40 0" fill="none" stroke="#c9b27a" stroke-width="4"/><circle r="21" fill="#e8d3a0"/><path d="M-20 -6 Q0 -2 20 -6 M-21 4 Q0 8 21 4 M-16 12 Q0 15 16 12" stroke="#c9a96a" stroke-width="2" fill="none"/>' +
295 '<path d="M-40 0 A40 10 0 0 0 40 0" fill="none" stroke="#d9c38a" stroke-width="4"/><path d="M-30 0 A30 7 0 0 0 30 0" fill="none" stroke="#f0e2b4" stroke-width="2"/></g></g>',
296 )
297 case 7:
298 return wrap(
299 7,
300 starts,
301 `<g><animateTransform attributeName="transform" type="translate" values="${n(px)} ${n(py)};${n(px + 30)} ${n(py - 8)}" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
302 '<circle r="13" fill="#c9553a"/><ellipse cx="-3" cy="2" rx="6" ry="3" fill="#9a3a2a"/><ellipse cx="5" cy="-4" rx="4" ry="2" fill="#9a3a2a"/><ellipse cy="-12" rx="5" ry="1.6" fill="#fff" opacity=".85"/><circle cx="-5" cy="-5" r="3" fill="#fff" opacity=".12"/></g>',
303 )
304 case 8: {
305 const id = `jp${v.id}`
306
307 return wrap(
308 8,
309 starts,
310 `<clipPath id="${id}"><circle r="27"/></clipPath><g><animateTransform attributeName="transform" type="translate" values="${n(px)} ${n(py)};${n(px - 30)} ${n(py + 8)}" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
311 `<g clip-path="url(#${id})"><circle r="27" fill="#e0b88a"/>${[-18, -9, 0, 10, 19].map((y, k) => `<rect x="-28" y="${y - 3}" width="56" height="${5 + (k % 2) * 2}" fill="${pick(['#b8794a', '#e8d0a8', '#a8683a', '#d8a878'], k)}"/>`).join('')}<ellipse cx="9" cy="6" rx="7" ry="4" fill="#c0452f"/></g></g>`,
312 )
313 }
314 case 9:
315 return wrap(
316 9,
317 starts,
318 `<g><animateTransform attributeName="transform" type="translate" values="${n(px)} ${n(py)};${n(px + 26)} ${n(py + 10)}" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
319 '<circle r="17" fill="#3f6fd0"/><circle r="17" fill="none" stroke="#9fc0ff" stroke-width="1.6" opacity=".7"/><path d="M-14 -4 Q0 -8 14 -4" stroke="#cfe0ff" stroke-width="2" fill="none"/><ellipse cx="4" cy="5" rx="5" ry="3" fill="#264a9a"/>' +
320 '<circle cx="28" cy="-6" r="2.4" fill="#cfd4de"><animateTransform attributeName="transform" type="rotate" values="0;360" dur="9s" repeatCount="indefinite"/></circle></g>',
321 )
322 case 11:
323 return wrap(
324 11,
325 starts,
326 `<g><animateTransform attributeName="transform" type="translate" values="${n(px)} ${n(py)};${n(px - 30)} ${n(py + 8)}" dur="${dur}s" begin="${B()}" fill="freeze"/>${deathStar()}` +
327 `<path d="M-9 -9 L-260 -80" stroke="#7dff7d" stroke-width="3.4" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;0;1;.8;0;0" keyTimes="0;.55;.58;.66;.7;1" dur="${dur}s" begin="${B()}" fill="freeze"/></path>` +
328 `<circle cx="-9" cy="-9" r="11" fill="#7dff7d" opacity="0"><animate attributeName="opacity" values="0;0;.8;0;0" keyTimes="0;.52;.58;.7;1" dur="${dur}s" begin="${B()}" fill="freeze"/></circle></g>`,
329 )
330 case 12:
331 return wrap(
332 12,
333 starts,
334 `<g><animateTransform attributeName="transform" type="translate" values="${from} ${n(py)};${to} ${n(py + 24)}" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(${dir} 1)">${starDestroyer()}</g></g>`,
335 )
336 case 13: {
337 const path = `M${from} ${n(py + 40)} C${n(from + (to - from) * 0.3)} ${n(py - 50)} ${n(from + (to - from) * 0.6)} ${n(py + 60)} ${to} ${n(py - 30)}`
338 const ship = (draw: string, lag: number, dy: number, shooting = false) =>
339 `<g transform="translate(0 ${dy})"><g><animateMotion path="${path}" rotate="auto" dur="${dur}s" begin="${B(lag)}" fill="freeze"/><g transform="scale(1 ${dir})">${draw}${shooting ? bolts(0) : ''}</g></g></g>`
340
341 return wrap(13, starts, ship(xWing(), -1.8, 0) + ship(tieFighter(), -0.9, 10, true) + ship(tieFighter(), 0, -8, true))
342 }
343 case 14:
344 return wrap(
345 14,
346 starts,
347 `<g><animateTransform attributeName="transform" type="translate" values="${from} ${n(py)};${to} ${n(py - 40)}" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(${dir} 1)"><g><animateTransform attributeName="transform" type="rotate" values="-10;10;-10" dur="3s" repeatCount="indefinite"/>${falcon()}</g></g></g>`,
348 )
349 default: {
350 const x = 90 + r(4) * 250
351
352 return wrap(
353 10,
354 starts,
355 `<g><animateTransform attributeName="transform" type="translate" values="${n(x + 120)} ${n(py - 80)};${n(x - 160)} ${n(py + 120)}" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
356 '<path d="M0 0 L120 -80 L110 -92Z" fill="#cfe8ff" opacity=".35"/><path d="M0 0 L80 -52" stroke="#fff" stroke-width="3" opacity=".6" stroke-linecap="round"/><circle r="14" fill="#9fd0ff" opacity=".3"/><circle r="5.6" fill="#fff"/></g>',
357 )
358 }
359 }
360}
361
362const buildScene = (w: Moon, elapsed: number, vh: number) => {
363 const view = Math.min(H, Math.max(vh, 300))
364 const y0 = H - view
365 // visitors come more often while Claude works
366 const gap: [number, number] = w.working ? [40, 40] : [140, 120]
367
368 // Lay out every visit, then thin them out so that at most a few are ever on screen together.
369 const MOST = w.working ? 7 : 3
370 const events: { kind: number; t: number }[] = []
371 for (let kind = 0; kind < KINDS; kind++) {
372 for (let t = Math.random() * gap[0] * 1.4; t < HORIZON_SECONDS; t += gap[0] + Math.random() * gap[1]) events.push({ kind, t })
373 }
374 events.sort((x, y) => x.t - y.t)
375 // one is due at once, so the sky is never empty
376 const first = Math.floor(Math.random() * KINDS)
377 const kept: { kind: number; t: number }[] = [{ kind: first, t: 0.4 }]
378 for (const e of events) {
379 const end = e.t + LIFE[e.kind]
380 // never two of the same kind together
381 if (kept.some(k => k.kind === e.kind && k.t < end && e.t < k.t + LIFE[k.kind])) continue
382 // the busiest moment of its stay, counting what is already kept
383 const edges = [e.t, ...kept.filter(k => k.t > e.t && k.t < end).map(k => k.t)]
384 const busiest = Math.max(...edges.map(at => kept.filter(k => k.t <= at && at < k.t + LIFE[k.kind]).length + 1))
385 if (busiest <= MOST) kept.push(e)
386 }
387 const slots: { v: Slot; starts: number[] }[] = []
388 for (let kind = 0; kind < KINDS; kind++) {
389 const starts = kept.filter(k => k.kind === kind).map(k => k.t)
390 slots.push({ v: { id: kind, kind, seed: Math.floor(Math.random() * 1_000_000) }, starts })
391 }
392 const drawn = slots.map(x => visitor(x.v, x.starts, y0)).join('')
393
394 const defs =
395 '<defs>' +
396 '<linearGradient id="space" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#02030c"/><stop offset=".6" stop-color="#070a1c"/><stop offset="1" stop-color="#0d1230"/></linearGradient>' +
397 '<linearGradient id="ocean" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#3f9be0"/><stop offset="1" stop-color="#1a4fa0"/></linearGradient>' +
398 '<linearGradient id="shade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset=".55" stop-color="#000" stop-opacity=".05"/><stop offset="1" stop-color="#000" stop-opacity=".62"/></linearGradient>' +
399 '<linearGradient id="regolith" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#a4a4b0"/><stop offset="1" stop-color="#55555f"/></linearGradient>' +
400 '<linearGradient id="visor" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#e8c25a"/><stop offset="1" stop-color="#8a5a1a"/></linearGradient>' +
401 `<clipPath id="earth"><circle cx="${EARTH_AT[0]}" cy="${EARTH_AT[1]}" r="${R}"/></clipPath>` +
402 '</defs>'
403
404 const sky = `<rect x="0" y="0" width="${W}" height="${H}" fill="url(#space)"/>` + MILKY + STARS + EARTH
405 const ground =
406 GROUND +
407 FLAG +
408 `<g transform="translate(70 ${GY})">${lander(w.working)}</g>` +
409 ASTRO_SHADOW +
410 `<g><animateMotion path="${hopPath(w.working ? 270 : 130)}" dur="34s" repeatCount="indefinite" calcMode="linear"/>${astronaut(w.working)}</g>`
411
412 return (
413 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${n(y0)} ${W} ${n(view)}" width="${W}" height="${n(view)}" preserveAspectRatio="xMidYMax slice">${defs}` +
414 `<rect x="0" y="${n(y0)}" width="${W}" height="${n(view)}" fill="#02030c"/>` +
415 retime(sky, elapsed) +
416 drawn +
417 retime(ground, elapsed) +
418 '</svg>'
419 )
420}
421// ---scene-end
422
423const TERMINAL_ART: [string, string][] = [
424 [' ✦ ✧ ✦', '#cfd8ff'],
425 [' ✧ ◖██◗ ✦', '#4f9be0'],
426 [' ✦ ◖██◗', '#4f9be0'],
427 [' ✧ ✦ ☄', '#cfd8ff'],
428 [' 🛸', '#b4ffb4'],
429 [' ▁▂▃▂▁ ⛺ 🧑🚀 ▁▂▃▂▁', '#a4a4b0'],
430 ['▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', '#7a7a86'],
431]
432
433const clock = (ms: number) => {
434 const s = Math.max(0, Math.floor(ms / 1000))
435
436 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
437}
438
439const label = (w: Moon, now: number, T: Words) => (w.working ? T.working(clock(now - w.startedAt), w.tools) : T.idle)
440
441const moon = atom(
442 { plugin: 'moon-view', key: 'surface' } as const,
443 { working: false, prev: false, tools: 0, startedAt: 0, endedAt: 0, tick: 0 } as Moon,
444)
445
446// The picture is built again only when the state it draws changes; the same text keeps it running.
447let t0 = 0
448let cache = { key: '', svg: '' }
449
450export const register: Register = (on, options) => {
451 // `language` is a field of the plugin's userConfig: 'en' (default) or 'ko'
452 const T: Words = STRINGS[options.language === 'ko' ? 'ko' : 'en']
453
454 on('session.start', async ($, e, next) => {
455 t0 = await $.clock.now()
456 await $.command.register({
457 name: 'moon',
458 description: 'Open the Moon pane',
459 })
460 // Wakes the pane each second while Claude works, for the stopwatch in its top line.
461 $.clock.every(1000, () => {
462 void read($, moon)
463 .then(r => (r.working ? update($, moon, v => ({ ...v, tick: v.tick + 1 })) : undefined))
464 .catch(() => {})
465 })
466
467 return next(e)
468 })
469
470 on('command.run', { command: 'moon' }, async $ => {
471 await $.ui.open({ id: PANE, title: T.title })
472
473 return { text: T.opened }
474 })
475
476 // The main loop's turns only: a subagent's run is not Claude at work.
477 on('turn.start', async ($, e, next) => {
478 if (e.agentId === undefined) {
479 const now = await $.clock.now()
480 await update($, moon, v => ({ ...v, prev: false, working: true, tools: 0, startedAt: now }))
481 }
482
483 return next(e)
484 }).catch(($, e, next) => next(e))
485
486 on('turn.complete', async ($, e, next) => {
487 if (e.agentId === undefined) {
488 const now = await $.clock.now()
489 await update($, moon, v => ({ ...v, prev: true, working: false, endedAt: now }))
490 }
491
492 return next(e)
493 }).catch(($, e, next) => next(e))
494
495 on('tool.call', async ($, e, next) => {
496 await update($, moon, v => ({ ...v, tools: v.tools + 1 }))
497
498 return next(e)
499 }).catch(($, e, next) => next(e))
500
501 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
502 const els = $.ui.resolve(e)
503 const w = await read($, moon)
504 const now = await $.clock.now()
505 if (t0 === 0) t0 = now
506 const cols = e.props?.bodyColumns ?? 40
507 const rows = e.props?.scroll?.bodyRows ?? 20
508 const line = label(w, now, T)
509
510 if (e.surface !== 'terminal') {
511 const { Box, Text, Svg } = els as ElementTable<'desktop'>
512 // The pane's px: its cells over the code font's advance (about 7.8 px) and line height
513 // (about 19.5 px). Short of the whole picture, the top of the sky is cut, never the surface.
514 const width = Math.max(160, Math.round(cols * 7.8))
515 const full = Math.round((width * H) / W)
516 const tall = e.props?.scroll?.bodyRows ? Math.round((rows - 1) * 19.5) : full
517 const height = Math.min(full, tall)
518 const key = `${w.working}|${w.startedAt}|${w.endedAt}|${height}|${width}`
519 if (cache.key !== key) cache = { key, svg: buildScene(w, (now - t0) / 1000, (height * W) / width) }
520
521 return (
522 <Box flexDirection="column" justifyContent="flex-end" minHeight={rows}>
523 <Box justifyContent="center">
524 <Text bold={w.working} dimColor={!w.working}>
525 {line}
526 </Text>
527 </Box>
528 <Svg source={cache.svg} alt={T.alt} width={width} height={height} isInteractive />
529 </Box>
530 )
531 }
532
533 const { Box, Text } = els as ElementTable<'terminal'>
534
535 return (
536 <Box flexDirection="column">
537 {TERMINAL_ART.map(([row, color]) => (
538 <Text color={color}>{row}</Text>
539 ))}
540 <Text>{line}</Text>
541 <Text dimColor>{T.note}</Text>
542 </Box>
543 )
544 })
545}
546types/index.d.ts 15 lines1export type Moon = {
2 working: boolean
3 prev: boolean
4 tools: number
5 startedAt: number
6 endedAt: number
7 tick: number
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'moon-view': { surface: Moon }
13 }
14}
15