SLOPSHOPPER

beach-day

A tall top-down beach pane: gentle waves wash the shore, people in sunglasses lie on sunbeds under parasols, and swimmers float on rings, a pink unicorn among…

newpaneguardcommandtimer
v0.1.0MITupdated 2026-10-24Gnosis333/mod_gallery/mods/beach-day
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · beach-day
│ ┃ Beach ✕ › fix the failing auth test and add an audit log call │ ┃ ≈≈≈≈≈ ◎ ≈≈≈≈ ◎ ≈≈≈≈ │ ┃ ≈≈ ◎ ≈≈≈≈≈≈≈ ◎ ≈≈≈ ⏺ Read(src/auth.ts) │ ┃ ~~~~~~~~~~~~~~~~~~~ ⎿ Read 6 lines │ ┃ ✺ ▭ ▭ ✺ ▭ ▭ ⏺ Update(src/auth.ts) │ ┃ ░░░░░░░░░░░░░░░░░░░ ⎿ Added 2 lines, removed 1 line │ ┃ Idle ⏺ Bash(bun test) │ ┃ The animation shows in the desktop app. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /beach │ ⎿ beach-day: Opened the beach pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Beach
≈≈≈≈≈ ◎ ≈≈≈≈ ◎ ≈≈≈≈ ≈≈ ◎ ≈≈≈≈≈≈≈ ◎ ≈≈≈ ~~~~~~~~~~~~~~~~~~~ ✺ ▭ ▭ ✺ ▭ ▭ ░░░░░░░░░░░░░░░░░░░ Idle The animation shows in the desktop app.
README

Claude Code Mod Gallery

<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).

한국어 설명은 아래에 있습니다.

Requirements

  • Claude Code 2.1.287 or later. Mods are an early-access feature, so the API may change between versions.
  • The animations show in the desktop app. In a terminal you get a still text picture instead.

Install

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.

Language

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.

Mods

ModCommandWhat it is
night-train/trainA night-train window
beach-day/beachA top-down beach
snow-cabin/snowA snowy cabin, day and night
deep-sub/deepA deep-sea submersible
campfire/fireA campfire to stare at
jungle-falls/fallsA jungle waterfall with 24 kinds of animals
rain-window/rainA rainy window
moon-view/moonThe universe seen from the Moon
tower-cut/towerA building cross-section
desk-garden/gardenA desk plant and a workshop
reef-tank/reefAn aquarium
usage-bandautomaticContext, limits and cost on one line
haiku-warningautomaticA warning while a Haiku model is selected

Panels

night-train

<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.

beach-day

<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.

snow-cabin

<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.

deep-sub

<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.

campfire

<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.

jungle-falls

<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.

rain-window

<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.

moon-view

<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.

tower-cut

<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.

desk-garden

<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.

reef-tank

<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.


Prompt bands

usage-band

<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.

haiku-warning

<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.


Good to know

  • CPU: moving pictures redraw constantly, so several panels open at once add up. Open only what you use. They don't affect model response speed, only drawing, heat and battery. jungle-falls, snow-cabin and campfire are larger pictures and relatively heavier.
  • Layout: each mod lives in 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.
  • Languages: English (default) and Korean, chosen per mod with the 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.

License

MIT. Use, change and share freely.


한국어

<img src="images/hero-ko.gif" alt="왼쪽 Claude Code가 일하는 동안 오른쪽 어항 패널이 어두워지고 잠수함이 내려오는 모습" width="720">

Claude Code에 일을 맡기고, 일하는 동안엔 어항 보며 멍~ (왼쪽 대화창은 비슷하게 그린 화면이고, 어항은 실제 모드입니다.)

Claude Code 옆에 띄워 두는 그림 패널과 입력창 위 상태 띠를 모았습니다. 패널은 Claude가 쉬고 있을 때와 일하고 있을 때 모습이 달라집니다. Claude Code의 모드를 공부하면서 이것저것 만들어 본 결과물입니다. 위 영어 부분의 그림(왼쪽 쉬는 중, 오른쪽 작업 중)이 그대로 해당합니다.

요구 사항과 설치

  • Claude Code 2.1.287 이상(모드는 얼리 액세스 기능이라 버전마다 API가 바뀔 수 있습니다)
  • 그림 움직임은 데스크톱 앱에서 보입니다. 터미널에서는 글자 그림으로 나옵니다.
/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을 쓰세요.

모드 설명

  • night-train (/train): 야간열차 창가. 쉴 때는 STATION에 정차해 가로등이 켜져 있고, 작업 중에는 산·나무·전신주가 서로 다른 속도로 흘러가며 가끔 긴 터널을 지납니다. 하늘에는 철새떼가 날아가고, 턴이 끝나면 다시 역으로 들어옵니다.
  • beach-day (/beach): 위에서 내려다본 해변. 파라솔 아래 썬베드에 선글라스를 낀 사람들이 누워 있고, 바다에는 핑크 유니콘 튜브를 비롯한 여러 튜브가 떠 있습니다. 작업 중에는 제트스키, 바나나보트, 꽃게가 나옵니다.
  • snow-cabin (/snow): 눈이 소복소복 내리는 흰 숲과 설산, 통나무 오두막. 쉴 때는 낮, 작업 중에는 밤이 되어 창에 불이 켜지고 눈사람이 15초마다 하나씩 생깁니다. 흰 토끼, 흰 여우, 까치, 허스키가 찾아옵니다.
  • deep-sub (/deep): 어둠 속 노란 잠수정과 야광 해파리, 아귀, 덤보 문어, 풍선장어, 그리고 저 아래 희미하게 움직이는 정체불명의 거대한 것. 작업 중에는 탐조등과 소나가 켜지고 가끔 거대 오징어 그림자가 스칩니다.
  • campfire (/fire): 별이 가득한 밤 숲속 공터의 모닥불. 불꽃과 불티, 통나무 원, 꽃 여섯 종류, 여우·올빼미(머리를 돌립니다)·사슴·너구리·고슴도치. 작업 중에는 불이 커지고 끝나면 잦아듭니다.
  • jungle-falls (/falls): 계단식 폭포와 연꽃 연못, 동물 24종이 무작위로 방문. 쉴 때는 낮, 작업 중에는 밤. CPU를 가장 많이 씁니다.
  • rain-window (/rain): 비 오는 창가. 작업 중에는 폭풍, 번개가 치면 고양이가 눈을 뜨고 스탠드가 깜빡이며, 끝나면 무지개가 뜹니다.
  • moon-view (/moon): 달에서 본 지구. 통통 뛰는 우주인(작업 중에는 빙빙 돕니다), 무작위로 지나가는 우주 물체와 스타워즈풍 우주선.
  • tower-cut (/tower): 빌딩 단면. 턴이 끝날 때마다 한 층씩 올라가고 20층이면 준공 불꽃놀이.
  • desk-garden (/garden): 창가 화분(턴마다 꽃이 핍니다), 졸고 있는 고양이와 수조, Claude가 일하는 작업실.
  • reef-tank (/reef): 수족관. 작업 중에는 조명이 어두워지고 노란 잠수함이 내려오며, 턴이 끝날 때마다 진주가 쌓입니다.
  • usage-band: 입력창 위 한 줄로 컨텍스트, 5시간·7일 한도(초기화 시각 ↻ 포함), 비용을 보여줍니다.
  • haiku-warning: Haiku 모델이 선택돼 있으면 입력창 위에 빨간 경고를 띄웁니다.

알아 둘 것

  • 움직이는 그림은 화면을 계속 그리므로 패널을 여러 개 동시에 열면 CPU 부담이 합쳐집니다. 쓰는 것만 여세요. 모델 응답 속도와는 무관하고, 화면 그리기와 발열·배터리에만 영향이 있습니다. jungle-falls, snow-cabin, campfire는 그림이 커서 상대적으로 무겁습니다.
  • 글자(상태 줄, 그림 설명, 열기 답장)는 기본이 영어이고, language 옵션을 ko로 하면 한글입니다. 그림 속 글자는 STATION과 desk-garden의 말풍선 정도입니다(말풍선은 옵션을 따릅니다).
  • 라이선스는 MIT입니다. 누구나 자유롭게 쓰고, 고치고, 다시 배포해도 됩니다.

모두 Claude Code로 만들었습니다.

Source 2 files
hooks/register.tsx 481 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { Beach } from '../types'
5
6const PANE = 'beach-day'
7const STRINGS = {
8  en: {
9    title: 'Beach',
10    alt: 'A top-down beach. Gentle waves wash the shore, people in sunglasses lie on sunbeds under parasols, and swimmers float on rings out at sea, a pink unicorn among them.',
11    working: (clock: string, tools: number) => `Working ${clock} · tools ${tools}`,
12    idle: 'Idle',
13    opened: 'Opened the beach 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
28// ---scene-start
29type Ride = { working: boolean }
30type Look = { skin: string; hair: string; long?: boolean; suit: string; style: 'bikini' | 'one' | 'trunks'; shades?: boolean }
31
32const W = 400
33const H = 1100
34const SHORE = 660 // where the sea meets the sand
35
36const n = (v: number) => String(Math.round(v * 10) / 10)
37const EASE = '.45 0 .55 1'
38const rnd = (s: number) => {
39  const v = Math.sin(s * 127.1 + 311.7) * 43758.5453
40
41  return v - Math.floor(v)
42}
43
44// Looping motion shifts back by the time already elapsed, so redrawing the picture never makes it jump.
45const retime = (svg: string, elapsed: number) =>
46  svg.replace(/<(animate|animateTransform|animateMotion)\b[^>]*>/g, tag => {
47    const found = / begin="(-?[\d.]+)s"/.exec(tag)
48    const shifted = ` begin="${n((found ? parseFloat(found[1] ?? '0') : 0) - elapsed)}s"`
49
50    return found ? tag.replace(found[0], shifted) : tag.replace(/^<(\w+)/, `<$1${shifted}`)
51  })
52
53// ---- people, seen from above ----------------------------------------------------------------------------
54const SHADES =
55  '<ellipse cx="-2.6" cy=".6" rx="2.6" ry="1.8" fill="#1a1a22"/><ellipse cx="2.6" cy=".6" rx="2.6" ry="1.8" fill="#1a1a22"/>' +
56  '<path d="M-.4 .3 H.4" stroke="#1a1a22" stroke-width="1"/><circle cx="-3.4" cy="0" r=".6" fill="#fff" opacity=".7"/><circle cx="1.8" cy="0" r=".6" fill="#fff" opacity=".7"/>'
57
58const head = (p: Look) =>
59  (p.long ? `<ellipse cx="0" cy="-3.4" rx="8.8" ry="10" fill="${p.hair}"/>` : `<circle cx="0" cy="-1.8" r="7" fill="${p.hair}"/>`) +
60  `<circle cx="0" cy="1" r="6" fill="${p.skin}"/>` +
61  (p.shades ? SHADES : '<path d="M-3.6 .4 h2 M1.6 .4 h2" stroke="#3a2a20" stroke-width="1" stroke-linecap="round"/>') +
62  '<path d="M-1.6 4 q1.6 1.2 3.2 0" stroke="#a0522d" stroke-width=".8" fill="none" stroke-linecap="round"/>'
63
64// lying on the back, head at the origin and feet toward +y
65const lying = (p: Look) => {
66  const suit =
67    p.style === 'bikini'
68      ? `<path d="M-8.4 14 Q-4.4 9.4 -.6 14 Q-4.4 19 -8.4 14Z M8.4 14 Q4.4 9.4 .6 14 Q4.4 19 8.4 14Z" fill="${p.suit}"/><path d="M-9 12.6 H9" stroke="${p.suit}" stroke-width="1"/>` +
69        `<path d="M-8.8 28 H8.8 L5 36 H-5Z" fill="${p.suit}"/>`
70      : p.style === 'one'
71        ? `<path d="M-8.6 10 Q0 15 8.6 10 L8.8 30 L5 37 H-5 L-8.8 30Z" fill="${p.suit}"/><path d="M-8.6 22 H8.6" stroke="#fff" stroke-width="1.4" opacity=".6"/>`
72        : `<rect x="-10" y="27" width="20" height="15" rx="3" fill="${p.suit}"/><path d="M0 30 V42" stroke="#000" stroke-opacity=".25"/><path d="M-10 31 H10" stroke="#fff" stroke-width="1.2" opacity=".5"/>`
73
74  return (
75    `<rect x="-6.6" y="30" width="5.6" height="38" rx="2.8" fill="${p.skin}"/><rect x="1" y="30" width="5.6" height="38" rx="2.8" fill="${p.skin}"/>` +
76    `<rect x="-14.4" y="9" width="4.8" height="29" rx="2.4" fill="${p.skin}"/><rect x="9.6" y="9" width="4.8" height="29" rx="2.4" fill="${p.skin}"/>` +
77    `<rect x="-9.8" y="6" width="19.6" height="31" rx="8" fill="${p.skin}"/>` +
78    suit +
79    head(p)
80  )
81}
82
83// in a ring: only the shoulders and head above the water, arms resting on the ring
84const legsBelow = (p: Look) => `<path d="M-4 6 L-7 28 M4 6 L7 28" stroke="${p.skin}" stroke-width="5" stroke-linecap="round" opacity=".35"/>`
85
86const bather = (p: Look) => {
87  const top =
88    p.style === 'bikini'
89      ? `<ellipse cx="-3.6" cy="3" rx="3.1" ry="2.5" fill="${p.suit}"/><ellipse cx="3.6" cy="3" rx="3.1" ry="2.5" fill="${p.suit}"/>`
90      : p.style === 'one'
91        ? `<path d="M-8 1 Q0 6 8 1 L7 9 Q0 12 -7 9Z" fill="${p.suit}"/>`
92        : ''
93
94  return (
95    `<path d="M-7 0 L-20 -8 M7 0 L20 -8" stroke="${p.skin}" stroke-width="4.6" stroke-linecap="round"/>` +
96    `<ellipse cx="0" cy="3" rx="9.4" ry="7.4" fill="${p.skin}"/>` +
97    top +
98    `<g transform="translate(0 -8)">${head(p)}</g>`
99  )
100}
101
102// ---- the floats ---------------------------------------------------------------------------------------
103const ring = (color: string, extra = '') =>
104  `<circle r="20" fill="none" stroke="${color}" stroke-width="11"/>${extra}<circle r="20" fill="none" stroke="#fff" stroke-width="2.4" stroke-dasharray="9 24" stroke-linecap="round" opacity=".55" transform="rotate(-40)"/>`
105
106const RAINBOW = ['#ff5a6a', '#ffa64a', '#ffe04a', '#5ad27a', '#4ab0ff', '#9a6aff']
107
108const unicorn = (p: Look) =>
109  legsBelow(p) +
110  // the rainbow mane down its back, along the ring, and its tail
111  RAINBOW.map((c, k) => `<path d="M${n(-7 - k * 1.4)} ${n(-26 + k * 0.6)} A${n(25 + k * 1.4)} ${n(25 + k * 1.4)} 0 0 0 ${n(-24 - k)} ${n(4 + k)}" fill="none" stroke="${c}" stroke-width="2.6" stroke-linecap="round"/>`).join('') +
112  ring('#ffa6d2') +
113  RAINBOW.map((c, k) => `<path d="M0 22 L${n(-10 + k * 4)} 38" stroke="${c}" stroke-width="2.8" stroke-linecap="round"/>`).join('') +
114  // its head, rising off the front of the ring
115  '<path d="M-7 -20 Q-9 -34 0 -38 Q9 -34 7 -20Z" fill="#ffc4e2"/><ellipse cx="0" cy="-40" rx="9" ry="10" fill="#fff4fb"/><ellipse cx="0" cy="-48" rx="6.4" ry="5" fill="#ffd6ea"/>' +
116  '<circle cx="-2.4" cy="-48.4" r=".9" fill="#c46a9a"/><circle cx="2.4" cy="-48.4" r=".9" fill="#c46a9a"/>' +
117  '<path d="M-6 -41 q2 1.6 4 0 M2 -41 q2 1.6 4 0" stroke="#3a2a40" stroke-width="1.1" fill="none" stroke-linecap="round"/><circle cx="-6.6" cy="-37.6" r="1.8" fill="#ff9ec8" opacity=".7"/><circle cx="6.6" cy="-37.6" r="1.8" fill="#ff9ec8" opacity=".7"/>' +
118  '<path d="M-8 -45 L-5 -52 L-3 -46Z M8 -45 L5 -52 L3 -46Z" fill="#fff4fb"/>' +
119  '<path d="M-2.8 -44 L0 -62 L2.8 -44Z" fill="#ffd34a"/><path d="M-2 -48 L2 -50 M-1.4 -53 L1.4 -55 M-.8 -58 L.8 -59" stroke="#e0a020" stroke-width="1"/>' +
120  bather(p)
121
122const donut = (p: Look) =>
123  legsBelow(p) +
124  `<circle r="20" fill="none" stroke="#e8b070" stroke-width="12"/><circle r="20" fill="none" stroke="#ff8fbf" stroke-width="8"/>` +
125  Array.from({ length: 16 }, (_, k) => {
126    const a = (k / 16) * Math.PI * 2 + rnd(k) * 0.2
127    const rr = 18 + rnd(k + 9) * 4
128
129    return `<rect x="-2" y="-.7" width="4" height="1.4" rx=".7" fill="${RAINBOW[k % 6]}" transform="translate(${n(Math.cos(a) * rr)} ${n(Math.sin(a) * rr)}) rotate(${Math.round(rnd(k + 20) * 180)})"/>`
130  }).join('') +
131  bather(p)
132
133const duck = (p: Look) =>
134  legsBelow(p) +
135  ring('#ffd23f') +
136  '<path d="M-5 20 L0 30 L5 20Z" fill="#ffd23f"/>' +
137  '<circle cx="0" cy="-32" r="9.4" fill="#ffd23f"/><ellipse cx="0" cy="-42" rx="5.6" ry="3.6" fill="#ff8a2a"/><circle cx="-3.6" cy="-34" r="1.3" fill="#1a1a22"/><circle cx="3.6" cy="-34" r="1.3" fill="#1a1a22"/>' +
138  bather(p)
139
140const striped = (p: Look) =>
141  legsBelow(p) + ring('#e84a4a', '<circle r="20" fill="none" stroke="#fff" stroke-width="11" stroke-dasharray="7.85 7.85"/>') + bather(p)
142
143const flamingo = (p: Look) =>
144  legsBelow(p) +
145  ring('#ff7aa8') +
146  '<path d="M0 -24 Q-8 -34 0 -40 Q8 -46 2 -52" fill="none" stroke="#ff7aa8" stroke-width="5" stroke-linecap="round"/><circle cx="1" cy="-54" r="5" fill="#ff8fb4"/>' +
147  '<path d="M5 -54 Q10 -54 10 -50 L7 -51Z" fill="#1a1a22"/><path d="M5 -55 L8 -55 L7.6 -52Z" fill="#fff"/><circle cx="0" cy="-55.4" r="1" fill="#1a1a22"/>' +
148  '<path d="M-6 22 L-10 30 M0 22 L0 31 M6 22 L10 30" stroke="#ff9ec0" stroke-width="3" stroke-linecap="round"/>' +
149  bather(p)
150
151// a watermelon-slice lilo and a striped one, each with someone stretched out on it (head toward -x)
152const lilo = (p: Look, kind: 'melon' | 'stripes') =>
153  (kind === 'melon'
154    ? '<rect x="-50" y="-19" width="100" height="38" rx="18" fill="#3fae4a"/><rect x="-46" y="-15" width="92" height="30" rx="14" fill="#c8f0a0"/><rect x="-43" y="-13" width="86" height="26" rx="12" fill="#ff5a6a"/>' +
155      Array.from({ length: 10 }, (_, k) => `<ellipse cx="${-36 + k * 8}" cy="${k % 2 ? 6 : -6}" rx="1.2" ry="2" fill="#2a1a1a"/>`).join('')
156    : '<rect x="-50" y="-18" width="100" height="36" rx="10" fill="#4ab0ff"/>' +
157      [-30, -10, 10, 30].map(x => `<rect x="${x - 4}" y="-18" width="8" height="36" fill="#fff" opacity=".8"/>`).join('') +
158      '<rect x="-50" y="-18" width="22" height="36" rx="10" fill="#2f8ad8"/>') +
159  `<g transform="translate(-38 0) rotate(-90)">${lying(p)}</g>`
160
161// bobbing on the swell, turning a little, with a ripple spreading round it
162const float = (x: number, y: number, k: number, inner: string, rot = 0, scale = 1) =>
163  `<g transform="translate(${x} ${y})">` +
164  `<circle r="30" fill="none" stroke="#fff" stroke-width="1.6" opacity="0"><animate attributeName="r" values="${n(26 * scale)};${n(48 * scale)}" dur="3.6s" begin="${n(-k * 0.7)}s" repeatCount="indefinite"/><animate attributeName="opacity" values=".5;0" dur="3.6s" begin="${n(-k * 0.7)}s" repeatCount="indefinite"/></circle>` +
165  `<g><animateTransform attributeName="transform" type="translate" values="0 0;3 -2;0 2;-2 0;0 0" dur="${7 + (k % 3)}s" begin="${n(-k * 1.3)}s" repeatCount="indefinite"/>` +
166  `<g transform="scale(${scale})"><g><animateTransform attributeName="transform" type="rotate" values="${rot - 5};${rot + 5};${rot - 5}" calcMode="spline" keyTimes="0;.5;1" keySplines="${EASE};${EASE}" dur="${11 + k}s" begin="${n(-k * 2)}s" repeatCount="indefinite"/>` +
167  `<ellipse cx="6" cy="7" rx="28" ry="26" fill="#0a3a5a" opacity=".16"/>${inner}</g></g></g></g>`
168
169const SWIMMERS =
170  float(84, 210, 0, flamingo({ skin: '#f1c7a0', hair: '#e8c070', long: true, suit: '#ff4a8a', style: 'bikini', shades: true }), 10) +
171  float(236, 214, 1, lilo({ skin: '#d9a074', hair: '#2a1a10', long: true, suit: '#ffd23f', style: 'bikini', shades: true }, 'melon'), 0) +
172  float(300, 286, 2, duck({ skin: '#f6d5b8', hair: '#6a3a1a', long: true, suit: '#2fb8a8', style: 'one' }), -8) +
173  float(354, 330, 3, striped({ skin: '#d9a074', hair: '#111', suit: '#2a5a9a', style: 'trunks', shades: true }), 20) +
174  float(150, 392, 4, unicorn({ skin: '#f1c7a0', hair: '#c4572a', long: true, suit: '#9a4aff', style: 'bikini', shades: true }), 0, 1.15) +
175  float(296, 462, 5, donut({ skin: '#a8724a', hair: '#111', suit: '#e84a4a', style: 'trunks' }), 30) +
176  float(96, 540, 6, lilo({ skin: '#f1c7a0', hair: '#6a3a1a', suit: '#ff8a2a', style: 'trunks', shades: true }, 'stripes'), 6)
177
178// someone just swimming near the shore, arms turning over
179const SWIMMER =
180  '<g transform="translate(330 572)"><animateTransform attributeName="transform" type="translate" values="330 572;250 566;330 572" dur="40s" repeatCount="indefinite"/>' +
181  '<path d="M0 0 L22 2" stroke="#fff" stroke-width="5" stroke-linecap="round" opacity=".35"/>' +
182  '<g><animateTransform attributeName="transform" type="rotate" values="0;360" dur="1.6s" repeatCount="indefinite"/><path d="M0 -12 V12" stroke="#d9a074" stroke-width="4" stroke-linecap="round"/></g>' +
183  '<ellipse cx="2" cy="0" rx="8" ry="5" fill="#d9a074"/><circle cx="-4" cy="0" r="5.4" fill="#2a1a10"/><path d="M-8 -2 a4 4 0 0 1 0 4" fill="#ffd23f"/></g>'
184
185// ---- the sea: deep water, the swell rolling in, glints, the shore washing up and back --------------------
186const SPARKLES = Array.from({ length: 22 }, (_, k) => {
187  const x = 10 + rnd(k + 600) * 380
188  const y = 20 + rnd(k + 640) * (SHORE - 90)
189
190  return `<path d="M${n(x)} ${n(y - 3)} L${n(x + 1)} ${n(y)} L${n(x)} ${n(y + 3)} L${n(x - 1)} ${n(y)}Z" fill="#fff" opacity="0"><animate attributeName="opacity" values="0;.9;0;0" keyTimes="0;.1;.2;1" dur="${n(3 + rnd(k + 680) * 4)}s" begin="${n(-rnd(k + 700) * 6)}s" repeatCount="indefinite"/></path>`
191}).join('')
192
193const crest = (k: number, dur: number) =>
194  `<path d="M-40 0 Q10 -7 60 0 T160 0 T260 0 T360 0 T460 0" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" opacity="0">` +
195  `<animateTransform attributeName="transform" type="translate" values="${k % 2 ? -20 : 0} 300;${k % 2 ? 0 : -20} ${SHORE - 14}" dur="${dur}s" begin="${n((-k * dur) / 4)}s" repeatCount="indefinite"/>` +
196  `<animate attributeName="opacity" values="0;.5;.5;0" keyTimes="0;.2;.85;1" dur="${dur}s" begin="${n((-k * dur) / 4)}s" repeatCount="indefinite"/></path>`
197
198const shoreWash = (dur: number) =>
199  `<g><animateTransform attributeName="transform" type="translate" values="0 -8;0 16;0 -8" keyTimes="0;.5;1" calcMode="spline" keySplines="${EASE};${EASE}" dur="${dur}s" repeatCount="indefinite"/>` +
200  `<path d="M0 ${SHORE - 40} V${SHORE + 6} Q50 ${SHORE + 16} 100 ${SHORE + 8} T200 ${SHORE + 12} T300 ${SHORE + 6} T400 ${SHORE + 12} V${SHORE - 40}Z" fill="#8fe3d6" opacity=".85"/>` +
201  `<path d="M0 ${SHORE + 6} Q50 ${SHORE + 16} 100 ${SHORE + 8} T200 ${SHORE + 12} T300 ${SHORE + 6} T400 ${SHORE + 12}" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" opacity=".9"/>` +
202  `<path d="M0 ${SHORE - 2} Q60 ${SHORE + 6} 120 ${SHORE} T240 ${SHORE + 4} T400 ${SHORE}" fill="none" stroke="#fff" stroke-width="2" stroke-dasharray="6 9" opacity=".7"/></g>`
203
204// ---- the sand ---------------------------------------------------------------------------------------------
205const SAND_DOTS = Array.from({ length: 70 }, (_, k) => `<circle cx="${n(rnd(k + 800) * 400)}" cy="${n(SHORE + 40 + rnd(k + 900) * (H - SHORE - 40))}" r="${n(0.8 + rnd(k + 1000))}" fill="#c9a86a" opacity=".5"/>`).join('')
206
207const parasol = (x: number, y: number, a: string, b: string, k: number, r = 44) => {
208  const wedges = Array.from({ length: 8 }, (_, i) => {
209    const a0 = (i / 8) * Math.PI * 2
210    const a1 = ((i + 1) / 8) * Math.PI * 2
211
212    return `<path d="M0 0 L${n(Math.cos(a0) * r)} ${n(Math.sin(a0) * r)} A${r} ${r} 0 0 1 ${n(Math.cos(a1) * r)} ${n(Math.sin(a1) * r)}Z" fill="${i % 2 ? b : a}"/>`
213  }).join('')
214
215  return (
216    `<ellipse cx="${x + 16}" cy="${y + 14}" rx="${r}" ry="${r * 0.92}" fill="#8a6a3a" opacity=".28"/>` +
217    `<g transform="translate(${x} ${y})"><g><animateTransform attributeName="transform" type="rotate" values="-3;3;-3" calcMode="spline" keyTimes="0;.5;1" keySplines="${EASE};${EASE}" dur="${8 + k}s" begin="${-k * 2}s" repeatCount="indefinite"/>` +
218    `${wedges}<circle r="${r}" fill="none" stroke="#000" stroke-opacity=".12" stroke-width="1.4"/>` +
219    Array.from({ length: 8 }, (_, i) => `<path d="M0 0 L${n(Math.cos((i / 8) * Math.PI * 2) * r)} ${n(Math.sin((i / 8) * Math.PI * 2) * r)}" stroke="#000" stroke-opacity=".1"/>`).join('') +
220    '<circle r="4" fill="#fff"/><circle r="2" fill="#bbb"/></g></g>'
221  )
222}
223
224const sunbed = (x: number, y: number, cushion: string, p: Look) =>
225  `<rect x="${x - 15}" y="${y + 6}" width="34" height="92" rx="4" fill="#8a6a3a" opacity=".25"/>` +
226  `<rect x="${x - 18}" y="${y}" width="36" height="92" rx="4" fill="#f4f4f0"/><rect x="${x - 15}" y="${y + 3}" width="30" height="86" rx="3" fill="${cushion}"/>` +
227  Array.from({ length: 6 }, (_, k) => `<path d="M${x - 15} ${y + 16 + k * 13} H${x + 15}" stroke="#fff" stroke-opacity=".35" stroke-width="1.2"/>`).join('') +
228  `<g transform="translate(${x} ${y + 14})">${lying(p)}</g>`
229
230const towel = (x: number, y: number, a: string, b: string, p: Look) =>
231  `<g transform="translate(${x} ${y}) rotate(-6)"><rect x="-19" y="0" width="38" height="92" rx="2" fill="${a}"/>` +
232  [14, 34, 54, 74].map(t => `<rect x="-19" y="${t}" width="38" height="8" fill="${b}"/>`).join('') +
233  `<g transform="translate(0 14)">${lying(p)}</g></g>`
234
235const BEACH_THINGS =
236  // a sandcastle with a flag, a bucket and spade, a beach ball, a cooler, flip-flops and footprints
237  '<g transform="translate(244 1000)"><rect x="-22" y="-14" width="44" height="28" rx="3" fill="#e2bf7c"/><rect x="-26" y="-18" width="14" height="14" fill="#d8b06a"/><rect x="12" y="-18" width="14" height="14" fill="#d8b06a"/><rect x="-26" y="4" width="14" height="14" fill="#d8b06a"/><rect x="12" y="4" width="14" height="14" fill="#d8b06a"/><rect x="-8" y="-8" width="16" height="16" fill="#cfa45c"/><path d="M0 -8 V-24" stroke="#6a4a2a" stroke-width="1.4"/><path d="M0 -24 L10 -20 L0 -16Z" fill="#e84a4a"><animate attributeName="d" values="M0 -24 L10 -20 L0 -16Z;M0 -24 L9 -22 L0 -16Z;M0 -24 L10 -20 L0 -16Z" dur="1.2s" repeatCount="indefinite"/></path></g>' +
238  '<g transform="translate(292 1028)"><circle r="10" fill="#4ab0ff"/><circle r="7" fill="#7fd0ff"/><path d="M8 -6 L22 -16" stroke="#ffd23f" stroke-width="3" stroke-linecap="round"/><rect x="18" y="-22" width="8" height="10" rx="2" fill="#ffd23f" transform="rotate(30 22 -17)"/></g>' +
239  '<g transform="translate(340 960)"><circle r="13" fill="#fff"/><path d="M0 -13 A13 13 0 0 1 11.3 6.5 L0 0Z" fill="#e84a4a"/><path d="M11.3 6.5 A13 13 0 0 1 -11.3 6.5 L0 0Z" fill="#4ab0ff"/><circle r="3" fill="#ffd23f"/><circle cx="-4" cy="-6" r="2.4" fill="#fff" opacity=".7"/></g>' +
240  '<g transform="translate(352 1052) rotate(12)"><rect x="-20" y="-13" width="40" height="26" rx="4" fill="#2f8ad8"/><rect x="-20" y="-13" width="40" height="9" rx="3" fill="#fff"/><rect x="-6" y="-16" width="12" height="4" rx="2" fill="#ddd"/></g>' +
241  '<g transform="translate(206 1066)"><ellipse cx="0" cy="0" rx="4.6" ry="10" fill="#ff5a8a" transform="rotate(-10)"/><ellipse cx="11" cy="2" rx="4.6" ry="10" fill="#ff5a8a" transform="rotate(14 11 2)"/></g>' +
242  Array.from({ length: 9 }, (_, k) => `<ellipse cx="${n(210 + k * 4 + (k % 2) * 8)}" cy="${SHORE + 40 + k * 26}" rx="3" ry="5" fill="#c9a86a" opacity=".55"/>`).join('')
243
244// ---- overhead: gulls and their shadows; out at sea a sailing boat ---------------------------------------
245const gull = (path: string, dur: number, begin: number) =>
246  [0, 1]
247    .map(s => {
248      const wing = s ? '#6a5a3a' : '#fff'
249      const off = s ? 'translate(18 24)' : ''
250
251      return (
252        `<g transform="${off}" opacity="${s ? 0.18 : 1}"><g><animateMotion path="${path}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>` +
253        `<path d="M-11 0 Q-5 -6 0 0 Q5 -6 11 0" fill="none" stroke="${wing}" stroke-width="2.4" stroke-linecap="round"><animate attributeName="d" values="M-11 0 Q-5 -6 0 0 Q5 -6 11 0;M-11 -3 Q-5 2 0 0 Q5 2 11 -3;M-11 0 Q-5 -6 0 0 Q5 -6 11 0" dur=".8s" repeatCount="indefinite"/></path>${s ? '' : '<circle r="2" fill="#fff"/>'}</g></g>`
254      )
255    })
256    .join('')
257
258const GULLS =
259  gull('M-30 900 C120 760 260 820 440 620 L440 620 L440 620 L440 620', 34, -6) +
260  gull('M430 300 C300 380 160 300 -40 420 L-40 420 L-40 420', 46, -20)
261
262const SAILBOAT =
263  '<g><animateTransform attributeName="transform" type="translate" values="-40 46;440 30" dur="160s" begin="-50s" repeatCount="indefinite"/>' +
264  '<path d="M-12 0 Q0 6 14 0 L10 -3 H-10Z" fill="#fff"/><path d="M0 -2 L-2 -24 L12 -4Z" fill="#fff"/><path d="M-1 -4 L-3 -18 L-10 -5Z" fill="#ffd23f"/>' +
265  '<path d="M-14 1 L-40 3" stroke="#fff" stroke-width="1.6" opacity=".5"/></g>'
266
267// ---- while Claude works: a jet ski cuts across and a banana boat is towed past --------------------------
268const JETSKI =
269  '<g><animateTransform attributeName="transform" type="translate" values="-90 132;-90 132;600 120;600 120" keyTimes="0;.1;.55;1" dur="15s" begin="-3s" repeatCount="indefinite"/>' +
270  '<path d="M-16 0 L-110 -18 M-16 0 L-110 18" stroke="#fff" stroke-width="2" opacity=".6"/><path d="M-16 0 H-90" stroke="#fff" stroke-width="7" stroke-linecap="round" opacity=".7"/>' +
271  '<path d="M-16 -7 H8 Q18 0 8 7 H-16Z" fill="#e84a4a"/><path d="M-12 -4 H2 V4 H-12Z" fill="#fff"/>' +
272  '<ellipse cx="-6" cy="0" rx="6" ry="5" fill="#2a5a9a"/><circle cx="-4" cy="0" r="4" fill="#2a1a10"/><path d="M-2 -5 L4 -7 M-2 5 L4 7" stroke="#d9a074" stroke-width="2.6" stroke-linecap="round"/></g>'
273
274const BANANA =
275  '<g><animateTransform attributeName="transform" type="translate" values="520 64;520 64;-260 80;-260 80" keyTimes="0;.15;.75;1" dur="24s" begin="-2s" repeatCount="indefinite"/>' +
276  '<path d="M18 0 L100 -12 M18 0 L100 12" stroke="#fff" stroke-width="2" opacity=".6"/><path d="M-12 -6 H12 Q22 0 12 6 H-12Z" fill="#fff"/><path d="M-22 0 Q-16 -10 -8 -6 L-8 6 Q-16 10 -22 0Z" fill="#fff"/><circle cx="2" cy="0" r="4" fill="#111"/>' +
277  '<path d="M14 0 H54" stroke="#ddd" stroke-width="1"/>' +
278  '<g transform="translate(90 0)"><path d="M-36 -8 Q0 -14 38 -6 Q44 0 38 6 Q0 14 -36 8 Q-42 0 -36 -8Z" fill="#ffd23f"/><path d="M-30 0 H32" stroke="#e8b020" stroke-width="2"/>' +
279  [-22, -6, 10, 26].map((x, k) => `<circle cx="${x}" cy="0" r="5.4" fill="${['#2a1a10', '#e8c070', '#6a3a1a', '#111'][k]}"/><path d="M${x - 4} -6 L${x + 2} -10 M${x - 4} 6 L${x + 2} 10" stroke="${['#f1c7a0', '#f6d5b8', '#d9a074', '#a8724a'][k]}" stroke-width="2.4" stroke-linecap="round"/>`).join('') +
280  '</g></g>'
281
282// ---- while Claude works: crabs scuttle sideways over the sand, stopping now and then ----------------
283const crab = (x: number, y: number, span: number, dur: number, begin: number, scale = 1) => {
284  const legs = [-1, 1]
285    .map(s =>
286      [0, 1, 2]
287        .map(i => `<path d="M${s * 7} ${-1 + i * 3} q${s * 6} ${-2 + i * 2} ${s * 9} ${3 + i * 2}" fill="none" stroke="#c8401f" stroke-width="1.6" stroke-linecap="round"/>`)
288        .join(''),
289    )
290    .map((g, k) => `<g><animateTransform attributeName="transform" type="rotate" values="${k ? -8 : 8};${k ? 8 : -8};${k ? -8 : 8}" dur=".24s" repeatCount="indefinite"/>${g}</g>`)
291    .join('')
292  const claws = [-1, 1]
293    .map(
294      s =>
295        `<path d="M${s * 5} -5 L${s * 9} -11" stroke="#e8502e" stroke-width="2.2" stroke-linecap="round"/>` +
296        `<g transform="translate(${s * 10} -13)"><ellipse rx="3.6" ry="3" fill="#e8502e"/><path d="M${s * -1} -2 L${s * 1} -5.4 L${s * 2.6} -2" fill="#c8401f">` +
297        `<animateTransform attributeName="transform" type="rotate" values="0;${s * 14};0" dur="1.1s" begin="${n(-begin)}s" repeatCount="indefinite"/></path></g>`,
298    )
299    .join('')
300
301  return (
302    `<g transform="translate(${x} ${y}) scale(${scale})"><g>` +
303    `<animateTransform attributeName="transform" type="translate" values="0 0;${span} 0;${span} 0;${n(span * 0.4)} 0;${n(span * 0.4)} 0;0 0" keyTimes="0;.3;.45;.65;.8;1" calcMode="spline" keySplines="${EASE};${EASE};${EASE};${EASE};${EASE}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>` +
304    `<ellipse cx="2" cy="3" rx="11" ry="7.4" fill="#8a6a3a" opacity=".22"/>${legs}${claws}` +
305    '<ellipse rx="9.4" ry="6.8" fill="#e8502e"/><ellipse cx="-2" cy="-1.6" rx="5" ry="2.6" fill="#ff7a52" opacity=".7"/>' +
306    '<path d="M-3 -6 V-9 M3 -6 V-9" stroke="#c8401f" stroke-width="1.2"/><circle cx="-3" cy="-9.6" r="1.6" fill="#1a1a22"/><circle cx="3" cy="-9.6" r="1.6" fill="#1a1a22"/>' +
307    '<circle cx="-3.4" cy="-10" r=".5" fill="#fff"/><circle cx="2.6" cy="-10" r=".5" fill="#fff"/></g></g>'
308  )
309}
310
311// on the wet sand by the water, between the sunbeds, and by the sandcastle
312const CRABS = crab(176, 716, 90, 9, -1) + crab(210, 880, -70, 11, -5, 0.9) + crab(320, 1082, -70, 8, -3, 0.8)
313
314// ---- the whole picture --------------------------------------------------------------------------------
315const buildScene = (w: Ride, elapsed: number, vh: number) => {
316  const view = Math.min(H, Math.max(vh, 240))
317  // a short pane trims the open sea above and the sand below in equal measure
318  const y0 = (H - view) / 2
319  const busy = w.working
320
321  const defs =
322    '<defs>' +
323    `<linearGradient id="sea" x1="0" y1="0" x2="0" y2="${SHORE}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1c6aa8"/><stop offset=".55" stop-color="#22a4c4"/><stop offset="1" stop-color="#5fd6cc"/></linearGradient>` +
324    '</defs>'
325
326  const sea =
327    `<rect x="0" y="0" width="${W}" height="${SHORE + 30}" fill="url(#sea)"/>` +
328    // light netting on the sandy bottom of the shallows
329    Array.from({ length: 6 }, (_, k) => `<path d="M-20 ${SHORE - 150 + k * 22} Q60 ${SHORE - 160 + k * 22} 140 ${SHORE - 148 + k * 22} T300 ${SHORE - 150 + k * 22} T460 ${SHORE - 148 + k * 22}" fill="none" stroke="#fff" stroke-opacity=".12" stroke-width="1.4"/>`).join('') +
330    SPARKLES +
331    [0, 1, 2, 3].map(k => crest(k, busy ? 7 : 11)).join('') +
332    SAILBOAT +
333    (busy ? BANANA + JETSKI : '') +
334    SWIMMERS +
335    SWIMMER
336
337  const sand =
338    `<rect x="0" y="${SHORE}" width="${W}" height="${H - SHORE}" fill="#f1d9a6"/>` +
339    `<path d="M0 ${SHORE} V${SHORE + 34} Q60 ${SHORE + 42} 130 ${SHORE + 34} T260 ${SHORE + 36} T400 ${SHORE + 32} V${SHORE}Z" fill="#d9b97c"/>` +
340    SAND_DOTS +
341    shoreWash(busy ? 5 : 7) +
342    BEACH_THINGS +
343    (busy ? CRABS : '') +
344    // each parasol stands just above the heads, so the faces and sunglasses show
345    sunbed(66, 764, '#4ab0ff', { skin: '#f1c7a0', hair: '#2a1a10', long: true, suit: '#ff4a8a', style: 'bikini', shades: true }) +
346    sunbed(124, 764, '#4ab0ff', { skin: '#d9a074', hair: '#111', suit: '#2a5a9a', style: 'trunks', shades: true }) +
347    parasol(95, 726, '#e84a4a', '#fff', 0, 40) +
348    sunbed(282, 806, '#ffd23f', { skin: '#f6d5b8', hair: '#e8c070', long: true, suit: '#2fb8a8', style: 'one', shades: true }) +
349    sunbed(340, 806, '#ffd23f', { skin: '#a8724a', hair: '#2a1a10', long: true, suit: '#ffa64a', style: 'bikini', shades: true }) +
350    parasol(311, 768, '#2f8ad8', '#ffd23f', 1, 40) +
351    sunbed(84, 960, '#5ad27a', { skin: '#a8724a', hair: '#111', suit: '#e84a4a', style: 'trunks', shades: true }) +
352    towel(150, 968, '#9a4aff', '#fff', { skin: '#f1c7a0', hair: '#c4572a', long: true, suit: '#1a1a22', style: 'one', shades: true }) +
353    parasol(116, 922, '#5ad27a', '#fff', 2, 40) +
354    GULLS
355
356  return (
357    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${n(y0)} ${W} ${n(view)}" width="${W}" height="${n(view)}" preserveAspectRatio="xMidYMid slice">${defs}` +
358    retime(sea + sand, elapsed) +
359    '</svg>'
360  )
361}
362// ---scene-end
363
364const TERMINAL_ART: [string, string][] = [
365  ['  ≈≈≈≈≈ ◎ ≈≈≈≈ ◎ ≈≈≈≈', '#4ab0ff'],
366  ['  ≈≈ ◎ ≈≈≈≈≈≈≈ ◎ ≈≈≈', '#22a4c4'],
367  ['  ~~~~~~~~~~~~~~~~~~~', '#ffffff'],
368  ['   ✺  ▭ ▭      ✺ ▭ ▭', '#e84a4a'],
369  ['  ░░░░░░░░░░░░░░░░░░░', '#f1d9a6'],
370]
371
372const clock = (ms: number) => {
373  const s = Math.max(0, Math.floor(ms / 1000))
374
375  return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
376}
377
378const label = (w: Beach, now: number, T: Words) => (w.working ? T.working(clock(now - w.startedAt), w.tools) : T.idle)
379
380const shore = atom({ plugin: 'beach-day', key: 'shore' } as const, { working: false, tools: 0, startedAt: 0, endedAt: 0, tick: 0 } as Beach)
381
382// The picture is built again only when the state it draws changes; the same text keeps it running.
383let t0 = 0
384let cache = { key: '', svg: '' }
385
386export const register: Register = (on, options) => {
387  // `language` is a field of the plugin's userConfig: 'en' (default) or 'ko'
388  const T: Words = STRINGS[options.language === 'ko' ? 'ko' : 'en']
389
390  on('session.start', async ($, e, next) => {
391    t0 = await $.clock.now()
392    await $.command.register({
393      name: 'beach',
394      description: 'Open the beach pane',
395    })
396    // Wakes the pane each second while Claude works, for the stopwatch in its top line.
397    $.clock.every(1000, () => {
398      void read($, shore)
399        .then(r => (r.working ? update($, shore, v => ({ ...v, tick: v.tick + 1 })) : undefined))
400        .catch(() => {})
401    })
402
403    return next(e)
404  })
405
406  on('command.run', { command: 'beach' }, async $ => {
407    await $.ui.open({ id: PANE, title: T.title })
408
409    return { text: T.opened }
410  })
411
412  // The main loop's turns only: a subagent's run is not Claude at work.
413  on('turn.start', async ($, e, next) => {
414    if (e.agentId === undefined) {
415      const now = await $.clock.now()
416      await update($, shore, v => ({ ...v, working: true, tools: 0, startedAt: now }))
417    }
418
419    return next(e)
420  }).catch(($, e, next) => next(e))
421
422  on('turn.complete', async ($, e, next) => {
423    if (e.agentId === undefined) {
424      const now = await $.clock.now()
425      await update($, shore, v => ({ ...v, working: false, endedAt: now }))
426    }
427
428    return next(e)
429  }).catch(($, e, next) => next(e))
430
431  on('tool.call', async ($, e, next) => {
432    await update($, shore, v => ({ ...v, tools: v.tools + 1 }))
433
434    return next(e)
435  }).catch(($, e, next) => next(e))
436
437  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
438    const els = $.ui.resolve(e)
439    const w = await read($, shore)
440    const now = await $.clock.now()
441    if (t0 === 0) t0 = now
442    const cols = e.props?.bodyColumns ?? 40
443    const rows = e.props?.scroll?.bodyRows ?? 20
444    const line = label(w, now, T)
445
446    if (e.surface !== 'terminal') {
447      const { Box, Text, Svg } = els as ElementTable<'desktop'>
448      // The pane's px: its cells over the code font's advance (about 7.8 px) and line height (about 19.5 px).
449      const width = Math.max(160, Math.round(cols * 7.8))
450      const full = Math.round((width * H) / W)
451      const tall = e.props?.scroll?.bodyRows ? Math.round((rows - 1) * 19.5) : full
452      const height = Math.min(full, tall)
453      const key = `${w.working}|${height}|${width}`
454      if (cache.key !== key) cache = { key, svg: buildScene(w, (now - t0) / 1000, (height * W) / width) }
455
456      return (
457        <Box flexDirection="column" justifyContent="center" minHeight={rows}>
458          <Box justifyContent="center">
459            <Text bold={w.working} dimColor={!w.working}>
460              {line}
461            </Text>
462          </Box>
463          <Svg source={cache.svg} alt={T.alt} width={width} height={height} isInteractive />
464        </Box>
465      )
466    }
467
468    const { Box, Text } = els as ElementTable<'terminal'>
469
470    return (
471      <Box flexDirection="column">
472        {TERMINAL_ART.map(([row, color]) => (
473          <Text color={color}>{row}</Text>
474        ))}
475        <Text>{line}</Text>
476        <Text dimColor>{T.note}</Text>
477      </Box>
478    )
479  })
480}
481
types/index.d.ts 14 lines
1export type Beach = {
2  working: boolean
3  tools: number
4  startedAt: number
5  endedAt: number
6  tick: number
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'beach-day': { shore: Beach }
12  }
13}
14