A bubbling ooze canister and a lab log that fill live while rsync moves files to and from your sites, plus a deploy light. Bring your own mascot.

A lab bench for your file transfers, drawn live inside Claude Code. When you move files to or from a site, a canister of green ooze fills up, a CRT lab log types out what's happening, and your specimen watches the whole thing. At 100% it dings.

--delete in your args, a dry run, or an upload to a live server23 means some files didn't make it)The full bench draws in the Claude desktop app. A terminal session gets a simple progress bar.
You need Claude Code, rsync, and python3 (macOS and Linux ship both).
1. Add the plugin. Type this at a Claude Code terminal prompt:
/plugin install ooze-o-meter --marketplace 1upcreative/ooze-o-meter
Answer y to add the marketplace, then pick the user scope.
2. Get the ooze-xfer and ooze-deploy commands. In a terminal:
git clone https://github.com/1upcreative/ooze-o-meter.git ~/.ooze-o-meter
~/.ooze-o-meter/install.sh
The installer links ooze-xfer and ooze-deploy into ~/.local/bin. If that folder isn't on your PATH, it tells you the line to add.
Use ooze-xfer anywhere you'd use rsync. It takes the same arguments and returns the same exit code.
ooze-xfer --label "Pull uploads" -az me@myhost:/var/www/site/uploads/ ./uploads/
The pane pops open, the ooze fills, and the log reports as it goes. --label is optional. Without it, the bench names the transfer after the destination folder.
Two commands live inside Claude Code:
/ooze opens the bench./ooze-demo runs a fake 8-second transfer so you can see it work.If you type a plain rsync or scp to a remote host in Claude Code, the bench still opens. It can't measure bytes that way, so the ooze sloshes until the copy finishes.
A strip under the bench shows your last deploy at a glance:
The bench pops open when a deploy starts, and a toast tells you when it ends.
Wrap the deploy script you already have. You don't need to change it:
ooze-deploy run "My site" --note "v1.2" -- ./deploy.sh
Lines your script prints that start with →, ==> or Step become the step on the lamp. Everything still prints to your terminal, and the exit code is your script's. Set OOZE_STEP_RE to match a different pattern.
If you'd rather drive it by hand from inside a script:
ooze-deploy start "My site" --note "v1.2"
ooze-deploy step "Backing up"
ooze-deploy done # or: ooze-deploy fail --note "why"
Use ooze-xfer for the file copies inside the deploy and the canister fills for each one while the lamp stays amber.
The crab is a stand-in. Make your own mascot pack: a folder with a mascot.json, five face pictures, and a sound.
~/.claude/ooze/mascot/
mascot.json
faces/base.jpg watching the canister
faces/blink.jpg same pose, eyes shut (flashes now and then)
faces/excited.jpg past 90%
faces/done.jpg job's done
faces/warm.jpg standing by, looking at you
done.mp3 plays at 100%
mascot.json:
{
"name": "SERVO-CRAB",
"lab": "OOZE LAB · EXPERIMENT 001",
"moods": {
"standby": "ON STANDBY",
"observing": "OBSERVING",
"nearly": "NEARLY THERE!",
"done": "VICTORIOUS",
"error": "SHELL-SHOCKED"
},
"standby": ["OOZE-O-METER READY", "PIPE TRANSFERS THROUGH OOZE-XFER"],
"faces": {
"base": "faces/base.jpg",
"blink": "faces/blink.jpg",
"excited": "faces/excited.jpg",
"done": "faces/done.jpg",
"warm": "faces/warm.jpg"
},
"sound": "done.mp3"
}
Tips for the pictures:
base and excited. That's where the canister sits.base and blink, or the blink jumps.The bench looks for a pack in this order: the folder named by OOZE_MASCOT, then ~/.claude/ooze/mascot, then the Servo-Crab that ships here. Leave out any field and the default fills in.
ooze-xfer runs rsync twice. The first pass is a dry run that adds up how many bytes need to move. The second pass moves them with --progress, and a small Python script turns that output into ~/.claude/ooze/transfer.json. The plugin reads that file a few times a second and redraws the bench.
ooze-deploy writes ~/.claude/ooze/deploy.json: the deploy's label, note, state and steps, plus the last five that finished. The plugin checks it once a second.
The plugin is a Claude Code function-hooks mod. Validate and test it from the repo root:
claude plugin validate .
claude plugin test .
MIT. See LICENSE. Made by 1UP Creative.
hooks/register.tsx 312 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Deploy, Xfer } from '../types'
4import { DEFAULT_TEXT, oozeSvg } from './art'
5import type { FaceName, Faces, MascotText } from './art'
6import { size } from './format'
7import { advanceLog } from './log'
8
9const PANE = 'ooze-o-meter'
10const TITLE = 'Ooze-o-Meter'
11const XFER = { plugin: 'ooze-o-meter', key: 'xfer' } as const
12const DINGED = { plugin: 'ooze-o-meter', key: 'dinged' } as const
13const LOG = { plugin: 'ooze-o-meter', key: 'log' } as const
14const DEPLOY = { plugin: 'ooze-o-meter', key: 'deploy' } as const
15const DEPLOY_DINGED = { plugin: 'ooze-o-meter', key: 'deployDinged' } as const
16
17const FACE_NAMES: FaceName[] = ['base', 'blink', 'excited', 'done', 'warm']
18
19/**
20 * A raw rsync/scp to or from a host, typed straight into Bash: some segment of the command
21 * line (split on && || ; | and newlines) must START with rsync or scp and name a host:path.
22 * The word "rsync" inside a commit message or a description is not a transfer.
23 */
24function isRawTransfer(command: string): boolean {
25 return command.split(/&&|\|\||[;|\n]/).some(segment =>
26 /^\s*(?:sudo\s+|time\s+|[A-Z_]+=\S+\s+)*(rsync|scp)\s/.test(segment) && /\s[\w.@-]+:\S*/.test(segment),
27 )
28}
29
30/** A loaded mascot pack: its words, its face frames as data: URIs, its finish sound. */
31type Mascot = { text: MascotText; faces: Faces; sound?: { base64: string; mime: string } }
32
33/** What a pack's mascot.json may say; anything left out falls back to the defaults. */
34type MascotFile = Partial<Omit<MascotText, 'moods'>> & {
35 moods?: Partial<MascotText['moods']>
36 faces?: Partial<Record<FaceName, string>>
37 sound?: string
38}
39
40/**
41 * The mascot pack, first found of: $OOZE_MASCOT, ~/.claude/ooze/mascot, the one bundled
42 * here. A pack is a folder with mascot.json plus the faces and sound it names, so anyone
43 * can bring their own specimen without touching the code.
44 */
45async function loadMascot($: EngineInterface): Promise<Mascot> {
46 const home = (await $.env.get('HOME')) ?? ''
47 const chosen = await $.env.get('OOZE_MASCOT')
48 const dirs = [chosen, `${home}/.claude/ooze/mascot`, `${$.plugin.root}/mascot`].filter((d): d is string => !!d)
49 for (const dir of dirs) {
50 let file: MascotFile
51 try {
52 file = JSON.parse(await $.fs.read(`${dir}/mascot.json`)) as MascotFile
53 } catch {
54 continue
55 }
56 const faces: Faces = {}
57 for (const name of FACE_NAMES) {
58 const rel = file.faces?.[name]
59 if (rel === undefined) continue
60 try {
61 const { base64 } = await $.fs.read(`${dir}/${rel}`, { as: 'bytes' })
62 faces[name] = `data:image/${rel.endsWith('.png') ? 'png' : 'jpeg'};base64,${base64}`
63 } catch {
64 // a missing frame falls back to the ring
65 }
66 }
67 let sound: Mascot['sound']
68 if (file.sound !== undefined) {
69 try {
70 const { base64 } = await $.fs.read(`${dir}/${file.sound}`, { as: 'bytes' })
71 sound = { base64, mime: file.sound.endsWith('.wav') ? 'audio/wav' : 'audio/mpeg' }
72 } catch {
73 // no sound, no ding
74 }
75 }
76 const text: MascotText = {
77 name: file.name ?? DEFAULT_TEXT.name,
78 lab: file.lab ?? DEFAULT_TEXT.lab,
79 moods: { ...DEFAULT_TEXT.moods, ...file.moods },
80 standby: file.standby ?? DEFAULT_TEXT.standby,
81 }
82 return { text, faces, sound }
83 }
84 return { text: DEFAULT_TEXT, faces: {} }
85}
86
87export { isRawTransfer }
88
89export const register: Register = on => {
90 // Module-level poll bookkeeping only; anything a drawing reads lives in $.state.
91 let lastSeen = 0
92 let lastPushed = 0
93 let openedFor = ''
94 let deploySeen = 0
95 let deployOpenedFor = ''
96 // The mascot pack, read once per load (a reload just reads it again).
97 let mascot: Mascot | null = null
98
99 on('session.start', async ($, e, next) => {
100 await $.command.register({ name: 'ooze', description: 'Open the Ooze-o-Meter transfer pane' })
101 await $.command.register({ name: 'ooze-demo', description: 'Fill the Ooze-o-Meter with a fake 8-second transfer' })
102
103 const home = (await $.env.get('HOME')) ?? ''
104 const feed = `${home}/.claude/ooze/transfer.json`
105 const deployFeed = `${home}/.claude/ooze/deploy.json`
106
107 // The deploy light: amber while ooze-deploy runs, then green or red, with one toast at the end.
108 $.clock.every(1000, async () => {
109 let d: Deploy
110 try {
111 d = JSON.parse(await $.fs.read(deployFeed)) as Deploy
112 } catch {
113 return
114 }
115 if (d.updatedAt === deploySeen) return
116 deploySeen = d.updatedAt
117 await $.state.set(DEPLOY, d)
118 const now = await $.clock.now()
119 const isFresh = now - d.updatedAt < 20000
120 if (d.state === 'running' && isFresh && deployOpenedFor !== d.id) {
121 deployOpenedFor = d.id
122 await $.ui.open({ id: PANE, title: TITLE })
123 }
124 if (d.state !== 'running' && isFresh) {
125 const { value: dinged } = await $.state.get(DEPLOY_DINGED)
126 if (dinged === d.id) return
127 await $.state.set(DEPLOY_DINGED, d.id)
128 $.ui.toast(d.state === 'done' ? `Deployed! ${d.label}${d.note ? ` · ${d.note}` : ''}` : `Deploy failed: ${d.label}${d.note ? ` (${d.note})` : ''}`)
129 }
130 })
131
132 $.clock.every(400, async () => {
133 let x: Xfer
134 try {
135 x = JSON.parse(await $.fs.read(feed)) as Xfer
136 } catch {
137 return
138 }
139 const now = await $.clock.now()
140 if (x.updatedAt === lastSeen) {
141 // A running feed that stopped moving: let the log say it looks stalled (within 10 minutes).
142 const quiet = now - x.updatedAt
143 if (x.state === 'running' && quiet > 30000 && quiet < 600000) {
144 const { value: log = null } = await $.state.get(LOG)
145 const updated = advanceLog(log, x, now)
146 if (updated !== log) await $.state.set(LOG, updated)
147 }
148 return
149 }
150 lastSeen = x.updatedAt
151 const isLive = x.state === 'counting' || x.state === 'running'
152 const isFresh = now - x.updatedAt < 20000
153
154 // Push to the drawing on a new whole percent, a state change, or once a second.
155 const { value: current } = await $.state.get(XFER)
156 const isNewStep =
157 current?.id !== x.id || current.state !== x.state || Math.floor(current.pct) !== Math.floor(x.pct)
158 if (isNewStep || now - lastPushed > 1000) {
159 lastPushed = now
160 await $.state.set(XFER, x)
161 }
162 if (isFresh) {
163 const { value: log = null } = await $.state.get(LOG)
164 const updated = advanceLog(log, x, now)
165 if (updated !== log) await $.state.set(LOG, updated)
166 }
167
168 if (isLive && openedFor !== x.id && now - x.updatedAt < 15000) {
169 openedFor = x.id
170 await $.ui.open({ id: PANE, title: TITLE })
171 }
172
173 if ((x.state === 'done' || x.state === 'error') && isFresh) {
174 const { value: dinged } = await $.state.get(DINGED)
175 if (dinged === x.id) return
176 await $.state.set(DINGED, x.id)
177 if (x.state === 'done') {
178 $.ui.toast(`Job's done! ${x.label} — ${size(x.total)}`)
179 mascot ??= await loadMascot($)
180 if (mascot.sound) await $.audio.play(mascot.sound).catch(() => undefined)
181 } else {
182 $.ui.toast(`Spilt the ooze: ${x.label} failed (rsync exit ${x.code})`)
183 }
184 }
185 })
186
187 return next(e)
188 })
189
190 on('command.run', { command: 'ooze' }, async $ => {
191 await $.ui.open({ id: PANE, title: TITLE })
192 return { text: 'Ooze-o-Meter open.' }
193 })
194
195 on('command.run', { command: 'ooze-demo' }, async $ => {
196 const home = (await $.env.get('HOME')) ?? ''
197 const feed = `${home}/.claude/ooze/transfer.json`
198 const startedAt = await $.clock.now()
199 const id = `demo-${startedAt}`
200 const total = 734003200
201 let step = 0
202 const tick = $.clock.every(250, async () => {
203 step++
204 const pct = Math.min(100, step * 3.2)
205 const isDone = pct >= 100
206 const x: Xfer = {
207 id,
208 label: 'Demo haul',
209 direction: 'down',
210 state: isDone ? 'done' : 'running',
211 pct,
212 done: Math.round((total * pct) / 100),
213 total,
214 rate: 92000000,
215 eta: Math.max(0, Math.round((100 - pct) / 12.8)),
216 file: 'wp-content/uploads/2025/10/hero-ooze.jpg',
217 code: 0,
218 updatedAt: await $.clock.now(),
219 startedAt,
220 }
221 await $.fs.write(feed, JSON.stringify(x))
222 if (isDone) tick.cancel()
223 })
224 return { text: 'Demo ooze flowing.' }
225 })
226
227 // Raw rsync/scp straight to a host: no % to read, so the ooze sloshes until it ends.
228 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
229 const isRaw = isRawTransfer(e.command) && !/ooze-xfer/.test(e.command) && !e.run_in_background
230 if (!isRaw) return next(e)
231
232 const start = await $.clock.now()
233 const base: Xfer = {
234 id: `raw-${e.tool_use_id}`,
235 label: (e.description ?? e.command).slice(0, 60),
236 direction: 'local',
237 state: 'raw',
238 pct: 0,
239 done: 0,
240 total: 0,
241 rate: 0,
242 eta: 0,
243 file: '',
244 code: 0,
245 updatedAt: start,
246 startedAt: start,
247 }
248 await $.state.set(XFER, base)
249 await $.state.set(LOG, advanceLog(null, base, start))
250 await $.ui.open({ id: PANE, title: TITLE })
251
252 const ran = await next(e)
253 const isOk = ran.deny === undefined && ran.isError !== true
254 const end = await $.clock.now()
255 const finished: Xfer = { ...base, state: isOk ? 'done' : 'error', pct: isOk ? 100 : 0, code: isOk ? 0 : 1, updatedAt: end }
256 await $.state.set(XFER, finished)
257 const { value: log = null } = await $.state.get(LOG)
258 await $.state.set(LOG, advanceLog(log, finished, end))
259 if (isOk) {
260 $.ui.toast(`Job's done! ${base.label}`)
261 mascot ??= await loadMascot($)
262 if (mascot.sound) void $.audio.play(mascot.sound).catch(() => undefined)
263 }
264 return ran
265 })
266
267 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
268 const { value: x = null } = await $.state.get(XFER)
269 const { value: deploy = null } = await $.state.get(DEPLOY)
270 const now = await $.clock.now()
271
272 if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
273 const { Svg } = $.ui.resolve(e)
274 const { value: log = null } = await $.state.get(LOG)
275 mascot ??= await loadMascot($)
276 // The bench carries every readout; alt says it for screen readers.
277 const alt = x === null
278 ? `${mascot.text.name}, standing by`
279 : x.state === 'done'
280 ? `Job's done: ${x.label}, ${size(x.total)}`
281 : x.state === 'error'
282 ? `${x.label} failed, rsync exit ${x.code}`
283 : `${x.label}: ${Math.floor(x.pct)} percent, ${size(x.done)} of ${size(x.total)}`
284 // Drawn as an image, not an interactive frame: the frame blocks the painted face
285 // frames, while an SVG image keeps its data: images and still runs SMIL.
286 const deployAlt = deploy === null ? '' : deploy.state === 'running' ? `. Deploying ${deploy.label}` : deploy.state === 'done' ? `. Last deploy ${deploy.label} succeeded` : `. Last deploy ${deploy.label} failed`
287 return <Svg source={oozeSvg(x, now, mascot.faces, log, mascot.text, deploy)} alt={alt + deployAlt} />
288 }
289
290 const { Box, Text } = $.ui.resolve(e)
291 const lamp = deploy === null ? null : (
292 <Text color={deploy.state === 'running' ? '#ffc24a' : deploy.state === 'done' ? '#39ff14' : '#ff5a4f'}>
293 ● {deploy.state === 'running' ? `Deploying ${deploy.label}${deploy.step ? ` · ${deploy.step}` : ''}` : deploy.state === 'done' ? `Deployed ${deploy.label}${deploy.note ? ` · ${deploy.note}` : ''}` : `Deploy failed: ${deploy.label}`}
294 </Text>
295 )
296 if (!x) return <Box flexDirection="column"><Text dimColor>Ooze-o-Meter standing by.</Text>{lamp}</Box>
297 const cols = Math.max(10, Math.min(48, (e.props.bodyColumns ?? 40) - 8))
298 const filled = x.state === 'done' ? cols : Math.round((cols * x.pct) / 100)
299 return (
300 <Box flexDirection="column">
301 <Text color="#39ff14" bold>{x.state === 'done' ? "Job's done! " : ''}{x.label}</Text>
302 <Text>
303 <Text color="#39ff14">{'█'.repeat(filled)}</Text>
304 <Text dimColor>{'░'.repeat(cols - filled)}</Text> {Math.floor(x.pct)}%
305 </Text>
306 <Text dimColor>{size(x.done)} / {size(x.total)}</Text>
307 {lamp}
308 </Box>
309 )
310 })
311}
312hooks/art.ts 483 lines1// The Ooze-o-Meter as one SVG string: a lab bench laid out as a bento grid of screwed-on
2// panels over graph paper, the props drawn in the same comic-ink style as the mascot's
3// painted portrait (thick black outlines, flat cel shading, hard highlights).
4//
5// header ··························· big %
6// [ SPECIMEN: mascot ]─electrode─[ CONTAINMENT: canister ]
7// ║ pipe
8// [ LAB LOG: CRT typing the experiment log ]
9// [ FLOW RATE ] [ PAYLOAD ] [ TIME ]
10//
11// SMIL does all the motion. Loops start at a negative offset taken from the clock, so a
12// redraw on a new percent picks them up mid-stride. One-shot effects (a log line typing
13// in, its static burst, the drips) are drawn at their finished look, and the animation is
14// only added while it is still playing: Chromium never applies the frozen end of a
15// one-shot that finished before the drawing loaded, so a finished look must not need it.
16
17import type { Deploy, Log, LogLine, Xfer } from '../types'
18import { clockText, elapsedSeconds, size } from './format'
19import { visibleLines } from './log'
20
21export type FaceName = 'base' | 'blink' | 'excited' | 'done' | 'warm'
22/** Face frames as data: URIs (painted portraits, ~9KB each); a missing one falls back to a ring. */
23export type Faces = Partial<Record<FaceName, string>>
24
25/** The words a mascot pack brings: its name, the lab line, its moods and its standby log. */
26export type MascotText = {
27 name: string
28 lab: string
29 moods: Record<'standby' | 'observing' | 'nearly' | 'done' | 'error', string>
30 standby: string[]
31}
32
33export const DEFAULT_TEXT: MascotText = {
34 name: 'SPECIMEN',
35 lab: 'OOZE LAB · EXPERIMENT 001',
36 moods: { standby: 'ON STANDBY', observing: 'OBSERVING', nearly: 'NEARLY THERE!', done: 'VICTORIOUS', error: 'SHELL-SHOCKED' },
37 standby: ['OOZE-O-METER READY', 'PIPE TRANSFERS THROUGH OOZE-XFER'],
38}
39
40const W = 420
41const H = 614
42const INK = '#111'
43const OOZE = '#39ff14'
44const PHOSPHOR = '#7dff5a'
45const RED = '#ff5a4f'
46const AMBER = '#ffc24a'
47const MONO = "Menlo, 'Courier New', monospace"
48const SANS = "'Helvetica Neue', Arial, sans-serif"
49const IMPACT = "Impact, 'Arial Black', sans-serif"
50
51const SPECIMEN = { x: 10, y: 74, w: 196, h: 226 }
52const CONTAIN = { x: 214, y: 74, w: 196, h: 226 }
53const LOG = { x: 10, y: 312, w: 400, h: 150 }
54const TILES_Y = 470
55const TILE_H = 82
56const DEPLOY = { x: 10, y: 560, w: 400, h: 46 }
57const FACE = { cx: 108, cy: 186, r: 76 }
58const TUBE = { x: 262, y: 128, w: 96, h: 136 }
59const SCREEN = { x: 22, y: 338, w: 376, h: 112 }
60const LOG_LINES = 6
61const LOG_CHARS = 47
62
63// Deterministic scatter so redraws keep the same bubbles.
64function rand(seed: number): () => number {
65 let s = seed
66 return () => {
67 s = (s * 16807) % 2147483647
68 return (s - 1) / 2147483646
69 }
70}
71
72/** A loop's `begin`: the negative offset that keeps it in phase across redraws. */
73function phase(now: number, dur: number, shift = 0): string {
74 return `-${(((now / 1000 + shift) % dur) + dur) % dur}s`
75}
76
77/**
78 * A one-shot animation that started `delay`s after `at` and runs `dur`s: `make(begin)`
79 * while it is still playing (begin negative when it is mid-run), '' once it has played.
80 * The element it animates is drawn at its finished look, which is all that remains after.
81 */
82function oneShot(now: number, at: number, delay: number, dur: number, make: (begin: string) => string): string {
83 const start = (at - now) / 1000 + delay
84 return start + dur <= 0 ? '' : make(`${start.toFixed(2)}s`)
85}
86
87function esc(text: string): string {
88 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
89}
90
91function fit(text: string, n: number): string {
92 return text.length <= n ? text : `${text.slice(0, n - 1)}…`
93}
94
95const isLive = (x: Xfer | null) => x !== null && (x.state === 'running' || x.state === 'counting' || x.state === 'raw')
96
97// ── Panels ─────────────────────────────────────────────────────────────────────
98
99function screw(cx: number, cy: number): string {
100 return `<circle cx="${cx}" cy="${cy}" r="3" fill="#3b4a3b" stroke="#0a0d0a" stroke-width="1"/><path d="M${cx - 2} ${cy + 2} l4 -4" stroke="#0a0d0a" stroke-width="1.2"/>`
101}
102
103/** A screwed-on lab panel with a small caps title (and an optional right-hand tag). */
104function panel(p: { x: number; y: number; w: number; h: number }, title: string, tag = ''): string {
105 const { x, y, w, h } = p
106 return `
107 <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="14" fill="#10161280" stroke="#2a3b2a" stroke-width="1.5"/>
108 <rect x="${x + 1}" y="${y + 1}" width="${w - 2}" height="26" rx="13" fill="#ffffff" opacity="0.025"/>
109 ${screw(x + 9, y + 9)}${screw(x + w - 9, y + 9)}${screw(x + 9, y + h - 9)}${screw(x + w - 9, y + h - 9)}
110 <text x="${x + 20}" y="${y + 20}" font-family="${MONO}" font-size="9.5" font-weight="bold" letter-spacing="1.6" fill="${PHOSPHOR}" opacity="0.9">${esc(title)}</text>
111 ${tag}`
112}
113
114// ── Header ─────────────────────────────────────────────────────────────────────
115
116function header(x: Xfer | null, who: MascotText): string {
117 const hero = x === null ? '—'
118 : x.state === 'done' ? '100%'
119 : x.state === 'error' ? 'ERR'
120 : x.state === 'raw' ? '≈'
121 : x.state === 'counting' ? '··'
122 : `${Math.floor(x.pct)}%`
123 const verb = x === null ? 'IDLE · STANDING BY'
124 : x.state === 'done' ? "✓ JOB'S DONE"
125 : x.state === 'error' ? '✕ SPILT'
126 : x.direction === 'up' ? '▲ UPLOADING' : x.direction === 'down' ? '▼ DOWNLOADING' : '◆ MOVING'
127 const status = x === null ? verb : `${verb} · ${x.label.toUpperCase()}`
128 const color = x?.state === 'error' ? RED : x?.state === 'done' ? OOZE : '#f2fff0'
129 return `
130 <text x="14" y="38" font-family="${IMPACT}" font-size="28" fill="#eaffe2" letter-spacing="0.5">OOZE-O-METER</text>
131 <text x="14" y="56" font-family="${MONO}" font-size="8.5" fill="#8fb88a" letter-spacing="1">${esc(who.lab)}</text>
132 <text x="406" y="44" text-anchor="end" font-family="${SANS}" font-weight="200" font-size="40" fill="${color}">${hero}</text>
133 <text x="406" y="60" text-anchor="end" font-family="${MONO}" font-size="8.5" fill="${x?.state === 'error' ? RED : PHOSPHOR}" letter-spacing="0.8">${esc(fit(status, 34))}</text>`
134}
135
136// ── Specimen: the mascot ───────────────────────────────────────────────────────
137
138function frame(href: string | undefined, extra = '', attrs = ''): string {
139 if (href === undefined) return ''
140 const { cx, cy, r } = FACE
141 return `<image ${attrs} href="${href}" x="${cx - r}" y="${cy - r}" width="${r * 2}" height="${r * 2}" clip-path="url(#face)" preserveAspectRatio="xMidYMid slice">${extra}</image>`
142}
143
144// Watches while it fills, blinks now and then, perks up past 90%, thumbs-up at the
145// finish, winces on a spill, warm resting face on standby. The whole head bobs.
146function specimen(x: Xfer | null, now: number, faces: Faces, who: MascotText): string {
147 const { cx, cy, r } = FACE
148 const failed = x?.state === 'error'
149 const done = x?.state === 'done'
150 const nearly = x?.state === 'running' && x.pct >= 90
151 const main = x === null ? faces.warm : done ? faces.done : failed ? faces.blink : nearly ? faces.excited : faces.base
152 const blink = `<animate attributeName="opacity" values="0;1;0" keyTimes="0;0.93;0.97" calcMode="discrete" dur="4.3s" begin="${phase(now, 4.3)}" repeatCount="indefinite"/>`
153 const blinker = x === null || done || failed ? '' : frame(faces.blink, blink, 'opacity="0"')
154 const bob = done
155 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -6;0 0" dur="0.55s" repeatCount="5"/>`
156 : `<animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="3.2s" begin="${phase(now, 3.2)}" repeatCount="indefinite"/>`
157 const ring = failed ? RED : OOZE
158 const fallback = main === undefined
159 ? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#1d2a1a"/><text x="${cx}" y="${cy + 10}" text-anchor="middle" font-family="${IMPACT}" font-size="30" fill="${OOZE}">?</text>`
160 : ''
161 const m = who.moods
162 const mood = x === null ? m.standby : done ? m.done : failed ? m.error : nearly ? m.nearly : m.observing
163 return `
164 ${panel(SPECIMEN, 'SPECIMEN')}
165 <g>${bob}
166 <circle cx="${cx}" cy="${cy}" r="${r + 6}" fill="${ring}" opacity="0.3" filter="url(#glow)"/>
167 <circle cx="${cx}" cy="${cy}" r="${r + 3}" fill="${INK}"/>
168 ${fallback}${frame(main)}${blinker}
169 <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${ring}" stroke-width="3">
170 <animate attributeName="stroke-opacity" values="0.55;1;0.55" dur="1.8s" begin="${phase(now, 1.8)}" repeatCount="indefinite"/>
171 </circle>
172 </g>
173 <text x="${cx}" y="${SPECIMEN.y + SPECIMEN.h - 12}" text-anchor="middle" font-family="${MONO}" font-size="8.5" letter-spacing="1.2" fill="${failed ? RED : '#b9d8b4'}">${esc(fit(`${who.name} · ${mood}`, 30))}</text>`
174}
175
176// ── Containment: the canister ──────────────────────────────────────────────────
177
178function rivets(x0: number, x1: number, y: number, n: number): string {
179 return Array.from({ length: n }, (_, i) => {
180 const x = x0 + ((x1 - x0) * i) / (n - 1)
181 return `<circle cx="${x.toFixed(1)}" cy="${y}" r="2.4" fill="#d3dadf" stroke="${INK}" stroke-width="1.4"/>`
182 }).join('')
183}
184
185// A steel band: flat base, cel shadow on the lower half, a hard highlight on top.
186function cap(x: number, y: number, w: number, h: number, extra = ''): string {
187 return `
188 <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="6" fill="#8d98a0"/>
189 <rect x="${x}" y="${y + h * 0.55}" width="${w}" height="${h * 0.45}" rx="4" fill="#626d75"/>
190 ${extra}
191 <rect x="${x + 6}" y="${y + 3}" width="${w - 12}" height="3" rx="1.5" fill="#e3e9ec"/>
192 <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="6" fill="none" stroke="${INK}" stroke-width="4"/>`
193}
194
195function containment(x: Xfer | null, now: number, doneAt: number): string {
196 const { x: tx, y: ty, w, h } = TUBE
197 const inner = { x: tx + 6, y: ty + 4, w: w - 12, h: h - 8 }
198 const state = x?.state
199 const raw = state === 'raw'
200 const done = state === 'done'
201 const level = x === null ? 0.08 : done ? 1 : raw ? 0.5 : Math.max(0.04, Math.min(1, x.pct / 100))
202 const top = inner.y + inner.h * (1 - level)
203 const r = rand(42)
204 const bubbles = Array.from({ length: x === null ? 4 : 12 }, (_, i) => {
205 const cx = inner.x + 10 + r() * (inner.w - 20)
206 const rad = 2 + r() * 4
207 const dur = 1.6 + r() * 2.4
208 const from = inner.y + inner.h + rad
209 return `<circle cx="${cx.toFixed(1)}" r="${rad.toFixed(1)}" fill="#e2ffd0" stroke="#0b4a00" stroke-width="1" opacity="0">
210 <animate attributeName="cy" values="${from.toFixed(1)};${(top + 6).toFixed(1)}" dur="${dur.toFixed(2)}s" begin="${phase(now, dur, i * 0.37)}" repeatCount="indefinite"/>
211 <animate attributeName="opacity" values="0;0.9;0.9;0" keyTimes="0;0.15;0.85;1" dur="${dur.toFixed(2)}s" begin="${phase(now, dur, i * 0.37)}" repeatCount="indefinite"/>
212 </circle>`
213 }).join('')
214 const wave = (amp: number, len: number) => {
215 let d = `M${inner.x - len * 2} ${top}`
216 for (let k = 0; k < 8; k++) d += ` q${len / 4} ${-amp} ${len / 2} 0 t${len / 2} 0`
217 return `${d} V${inner.y + inner.h + 20} H${inner.x - len * 2} Z`
218 }
219 const slosh = raw
220 ? `<animateTransform attributeName="transform" type="translate" values="0 ${inner.h * 0.35};0 ${inner.h * 0.1};0 ${inner.h * 0.35}" dur="3s" begin="${phase(now, 3)}" repeatCount="indefinite"/>`
221 : ''
222 // Beaker graduations inside the glass, the scale printed beside it.
223 const ticks = Array.from({ length: 9 }, (_, i) => {
224 const y = inner.y + (inner.h * (i + 1)) / 10
225 return `<path d="M${inner.x + 4} ${y.toFixed(1)} h${i === 4 ? 14 : 8}" stroke="#e2ffd0" stroke-width="2" opacity="0.55"/>`
226 }).join('')
227 const scale = [[100, inner.y], [50, inner.y + inner.h / 2], [0, inner.y + inner.h]]
228 .map(([n, y]) => `<text x="${tx + w + 12}" y="${(y as number) + 3}" font-family="${MONO}" font-size="8" fill="#8fb88a">${n}</text><path d="M${tx + w + 3} ${y} h6" stroke="#8fb88a" stroke-width="1.5"/>`)
229 .join('')
230 // The red valve wheel up top spins while ooze is being pumped.
231 const wheel = { cx: tx + w - 20, cy: ty - 26 }
232 const spin = isLive(x)
233 ? `<animateTransform attributeName="transform" type="rotate" values="0 ${wheel.cx} ${wheel.cy};360 ${wheel.cx} ${wheel.cy}" dur="1.8s" begin="${phase(now, 1.8)}" repeatCount="indefinite"/>`
234 : ''
235 // On "job's done" the ooze brims over the top cap and drips down the glass.
236 const drips = done
237 ? [tx - 1, tx + w + 1].map((dx, i) => {
238 const fall = 60 + i * 30
239 const dur = 1.6 + i * 0.5
240 return `
241 <path d="M${dx} ${ty - 2} q5 0 5 8 v6 q0 7 -5 7 q-5 0 -5 -7 v-6 q0 -8 5 -8Z" fill="${OOZE}" stroke="${INK}" stroke-width="2.5" transform="translate(0 ${fall})">
242 ${oneShot(now, doneAt, 0.3 + i * 0.4, dur, begin => `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${fall}" dur="${dur}s" begin="${begin}"/>`)}
243 </path>`
244 }).join('')
245 + `<path d="M${tx + 4} ${ty - 6} q12 -12 24 -2 q14 -14 26 0 q12 -12 24 -1 q8 -6 14 3 v6 h-88Z" fill="${OOZE}" stroke="${INK}" stroke-width="3"/>`
246 : ''
247 return `
248 ${panel(CONTAIN, 'CONTAINMENT')}
249 <rect x="${wheel.cx - 5}" y="${wheel.cy}" width="10" height="16" fill="#8d98a0" stroke="${INK}" stroke-width="3"/>
250 <g>${spin}
251 <circle cx="${wheel.cx}" cy="${wheel.cy}" r="10" fill="none" stroke="${INK}" stroke-width="7"/>
252 <circle cx="${wheel.cx}" cy="${wheel.cy}" r="10" fill="none" stroke="#e0352b" stroke-width="4"/>
253 <path d="M${wheel.cx - 10} ${wheel.cy} h20 M${wheel.cx} ${wheel.cy - 10} v20" stroke="${INK}" stroke-width="5"/>
254 <path d="M${wheel.cx - 9} ${wheel.cy} h18 M${wheel.cx} ${wheel.cy - 9} v18" stroke="#e0352b" stroke-width="2.5"/>
255 <circle cx="${wheel.cx}" cy="${wheel.cy}" r="3.5" fill="#e0352b" stroke="${INK}" stroke-width="2"/>
256 </g>
257 <rect x="${tx}" y="${ty}" width="${w}" height="${h}" fill="#0a1a0c"/>
258 <g clip-path="url(#tube)" filter="url(#glow)">
259 <g>${slosh}
260 <path d="${wave(5, 52)}" fill="url(#ooze)">
261 <animateTransform attributeName="transform" type="translate" values="0 0;52 0" dur="1.4s" begin="${phase(now, 1.4)}" repeatCount="indefinite"/>
262 </path>
263 <path d="${wave(4, 70)}" fill="#a8ff7a" opacity="0.35">
264 <animateTransform attributeName="transform" type="translate" values="70 0;0 0" dur="2.1s" begin="${phase(now, 2.1)}" repeatCount="indefinite"/>
265 </path>
266 ${bubbles}
267 </g>
268 </g>
269 <g clip-path="url(#tube)">
270 <rect x="${tx + w * 0.62}" y="${ty}" width="${w * 0.38}" height="${h}" fill="#000" opacity="0.22"/>
271 ${ticks}
272 <path d="M${tx + 14} ${ty + 10} l10 0 l-8 ${h - 26} l-10 0Z" fill="#fff" opacity="0.28"/>
273 <path d="M${tx + 30} ${ty + 10} l4 0 l-8 ${h - 26} l-4 0Z" fill="#fff" opacity="0.18"/>
274 </g>
275 <rect x="${tx}" y="${ty}" width="${w}" height="${h}" fill="none" stroke="${INK}" stroke-width="4"/>
276 ${scale}
277 ${cap(tx - 8, ty - 14, w + 16, 18, rivets(tx - 1, tx + w + 1, ty - 7, 6))}
278 ${cap(tx - 8, ty + h - 4, w + 16, 22, `<rect x="${tx - 4}" y="${ty + h + 3}" width="${w + 8}" height="8" fill="url(#hazard)" stroke="${INK}" stroke-width="1.5"/>`)}
279 ${drips}`
280}
281
282// ── Plumbing: the electrode on the specimen, the pipe into the log ─────────────
283
284function plumbing(x: Xfer | null, now: number): string {
285 const live = isLive(x)
286 const electrode = `M${FACE.cx + 62} ${FACE.cy - 44} C ${FACE.cx + 108} ${FACE.cy - 70}, ${TUBE.x - 40} ${TUBE.y + 70}, ${TUBE.x - 4} ${TUBE.y + 64}`
287 const pulse = (d: string) => live
288 ? `<path d="${d}" fill="none" stroke="#d7ffc8" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="3 14">
289 <animate attributeName="stroke-dashoffset" values="0;-34" dur="0.7s" begin="${phase(now, 0.7)}" repeatCount="indefinite"/>
290 </path>`
291 : ''
292 const pipe = { x: TUBE.x + TUBE.w / 2 - 9, y: TUBE.y + TUBE.h + 18, w: 18, h: LOG.y - (TUBE.y + TUBE.h + 18) + 14 }
293 const flow = live
294 ? `<rect x="${pipe.x + 6}" y="${pipe.y}" width="6" height="${pipe.h}" fill="url(#flow)">
295 <animate attributeName="y" values="${pipe.y - 12};${pipe.y}" dur="0.5s" begin="${phase(now, 0.5)}" repeatCount="indefinite"/>
296 </rect>`
297 : ''
298 return `
299 <rect x="${pipe.x}" y="${pipe.y}" width="${pipe.w}" height="${pipe.h}" fill="#626d75" stroke="${INK}" stroke-width="3"/>
300 <rect x="${pipe.x + 3}" y="${pipe.y}" width="3" height="${pipe.h}" fill="#c9d1d6"/>
301 <g clip-path="url(#pipe)">${flow}</g>
302 <rect x="${pipe.x - 4}" y="${LOG.y - 4}" width="${pipe.w + 8}" height="8" rx="2" fill="#8d98a0" stroke="${INK}" stroke-width="2.5"/>
303 <path d="${electrode}" fill="none" stroke="${INK}" stroke-width="9" stroke-linecap="round"/>
304 <path d="${electrode}" fill="none" stroke="#2fbf1a" stroke-width="5" stroke-linecap="round"/>
305 ${pulse(electrode)}
306 <circle cx="${FACE.cx + 62}" cy="${FACE.cy - 44}" r="6" fill="#c9d1d6" stroke="${INK}" stroke-width="2.5"/>
307 <circle cx="${TUBE.x - 4}" cy="${TUBE.y + 64}" r="5" fill="#c9d1d6" stroke="${INK}" stroke-width="2.5"/>`
308}
309
310// ── Lab log: the CRT ───────────────────────────────────────────────────────────
311
312function labLog(x: Xfer | null, log: Log | null, now: number, who: MascotText): string {
313 const { x: sx, y: sy, w: sw, h: sh } = SCREEN
314 const standby: LogLine[] = who.standby.map(text => ({ text, at: 0, t: '0:00' }))
315 const lines = x === null ? standby : log?.id === x.id ? visibleLines(log.lines, LOG_LINES) : []
316 const latest = lines.length > 0 ? Math.max(...lines.map(l => l.at)) : 0
317 const failed = x?.state === 'error'
318 const typed = lines.map((ln, i) => {
319 const y = sy + 18 + i * 17
320 const isDone = ln.text.startsWith("JOB'S DONE")
321 const color = ln.level === 'error' ? RED : ln.level === 'warn' ? AMBER : isDone ? '#c8ffb0' : PHOSPHOR
322 const mark = ln.level === 'error' ? '✕ ' : ln.level === 'warn' ? '⚠ ' : ''
323 const flash = isDone
324 ? oneShot(now, ln.at, 0.8, 0.9, begin => `<animate attributeName="opacity" values="1;0.2;1;0.2;1" dur="0.9s" begin="${begin}"/>`)
325 : ''
326 // The print head: a strip of screen that slides off to the right, uncovering the line.
327 const head = oneShot(now, ln.at, 0.3, 0.55, begin => `
328 <rect x="${sx + sw}" y="${y - 13}" width="${sw}" height="18" fill="#061306">
329 <animate attributeName="x" values="${sx};${sx + sw}" dur="0.55s" begin="${begin}"/>
330 </rect>`)
331 return `
332 <text x="${sx + 10}" y="${y}" font-family="${MONO}" font-size="11" font-weight="bold" fill="${color}" filter="url(#phosphor)"><tspan fill="#4f9a45">[${ln.t}]</tspan> ${esc(fit(mark + ln.text, LOG_CHARS))}${flash}</text>${head}`
333 }).join('')
334 const last = lines[lines.length - 1]
335 const cursor = last !== undefined
336 ? `<rect x="${sx + 10 + Math.min(`[${last.t}] ${last.level === 'warn' || last.level === 'error' ? '! ' : ''}${last.text}`.length, LOG_CHARS + 8) * 6.62 + 3}" y="${sy + 18 + (lines.length - 1) * 17 - 10}" width="7" height="12" fill="${PHOSPHOR}" opacity="0">
337 <animate attributeName="opacity" values="1;0" dur="1s" calcMode="discrete" begin="${phase(now, 1)}" repeatCount="indefinite"/>
338 </rect>`
339 : ''
340 const rec = isLive(x)
341 ? `<circle cx="${LOG.x + LOG.w - 52}" cy="${LOG.y + 17}" r="4" fill="${RED}"><animate attributeName="opacity" values="1;0.2;1" dur="1.1s" begin="${phase(now, 1.1)}" repeatCount="indefinite"/></circle>
342 <text x="${LOG.x + LOG.w - 44}" y="${LOG.y + 20}" font-family="${MONO}" font-size="9" font-weight="bold" fill="${RED}">REC</text>`
343 : `<rect x="${LOG.x + LOG.w - 56}" y="${LOG.y + 13}" width="8" height="8" fill="${failed ? RED : '#8fb88a'}"/>
344 <text x="${LOG.x + LOG.w - 44}" y="${LOG.y + 20}" font-family="${MONO}" font-size="9" font-weight="bold" fill="#8fb88a">${x === null ? 'IDLE' : 'STOP'}</text>`
345 return `
346 ${panel(LOG, 'LAB LOG', rec)}
347 <rect x="${sx - 4}" y="${sy - 4}" width="${sw + 8}" height="${sh + 8}" rx="14" fill="#1d211d" stroke="${INK}" stroke-width="3"/>
348 <g clip-path="url(#screen)">
349 <rect x="${sx}" y="${sy}" width="${sw}" height="${sh}" fill="#061306"/>
350 <g>
351 <animate attributeName="opacity" values="1;0.9;1;0.96;1;0.93;1" dur="2.6s" begin="${phase(now, 2.6)}" repeatCount="indefinite"/>
352 ${typed}
353 ${cursor}
354 </g>
355 <rect x="${sx}" y="${sy}" width="${sw}" height="${sh}" fill="url(#scan)"/>
356 <rect x="${sx}" y="${sy - 20}" width="${sw}" height="20" fill="${PHOSPHOR}" opacity="0.06">
357 <animate attributeName="y" values="${sy - 20};${sy + sh}" dur="3.6s" begin="${phase(now, 3.6)}" repeatCount="indefinite"/>
358 </rect>
359 <rect x="${sx}" y="${sy}" width="${sw}" height="${sh}" filter="url(#static)" opacity="0.06">
360 ${latest > 0 ? oneShot(now, latest, 0, 0.6, begin => `<animate attributeName="opacity" values="0.8;0.4;0.06" keyTimes="0;0.4;1" dur="0.6s" begin="${begin}"/>`) : ''}
361 </rect>
362 <rect x="${sx}" y="${sy}" width="${sw}" height="${sh}" fill="url(#vignette)"/>
363 <path d="M${sx + 8} ${sy + 8} q${sw * 0.35} -6 ${sw * 0.6} 3 l-4 7 q-${sw * 0.25} -8 -${sw * 0.56} -3Z" fill="#fff" opacity="0.06"/>
364 </g>`
365}
366
367// ── Readout tiles ──────────────────────────────────────────────────────────────
368
369function tile(i: number, title: string, value: string, unit: string, note: string): string {
370 const w = (400 - 16) / 3
371 const p = { x: 10 + i * (w + 8), y: TILES_Y, w, h: TILE_H }
372 return `
373 ${panel(p, title)}
374 <text x="${p.x + 18}" y="${p.y + 56}" font-family="${SANS}" font-weight="300" font-size="25" fill="#f2fff0">${esc(value)}<tspan font-size="11" font-weight="500" fill="#8fb88a" dx="4">${esc(unit)}</tspan></text>
375 <text x="${p.x + 18}" y="${p.y + 72}" font-family="${MONO}" font-size="8" fill="#8fb88a" letter-spacing="0.8">${esc(note)}</text>`
376}
377
378function split(n: number): [string, string] {
379 const [v = '0', u = 'B'] = size(n).split(' ')
380 return [v, u]
381}
382
383function tiles(x: Xfer | null): string {
384 if (x === null) return tile(0, 'FLOW RATE', '—', '', 'NO FLOW') + tile(1, 'PAYLOAD', '—', '', 'EMPTY') + tile(2, 'TIME', '—', '', 'IDLE')
385 const elapsed = elapsedSeconds(x.startedAt, x.updatedAt)
386 const avg = elapsed > 0 ? x.total / elapsed : 0
387 const [rate, rateUnit] = split(x.state === 'done' ? avg : x.rate)
388 const [moved, movedUnit] = split(x.state === 'done' ? x.total : x.done)
389 const flowNote = x.state === 'done' ? 'AVERAGE' : x.state === 'error' ? 'LAST SEEN' : x.state === 'raw' ? 'UNMETERED' : 'LIVE'
390 return tile(0, 'FLOW RATE', x.state === 'raw' || x.state === 'counting' ? '—' : rate, x.state === 'raw' || x.state === 'counting' ? '' : `${rateUnit}/s`, flowNote)
391 + tile(1, 'PAYLOAD', x.state === 'raw' ? '—' : moved, x.state === 'raw' ? '' : movedUnit, x.total > 0 ? `OF ${size(x.total)}` : 'COUNTING')
392 + tile(2, 'TIME', clockText(elapsed), '', x.state === 'running' && x.eta > 0 ? `ETA ${clockText(x.eta)}` : x.state === 'done' ? 'TOTAL' : 'ELAPSED')
393}
394
395// ── Deploy light ───────────────────────────────────────────────────────────────
396
397function clock(ms: number): string {
398 return new Date(ms).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
399}
400
401function took(d: Deploy): string {
402 const s = Math.max(0, Math.round((d.endedAt - d.startedAt) / 1000))
403 return s < 60 ? `${s}S` : clockText(s)
404}
405
406/** One strip under the tiles: a lamp (amber while a deploy runs, green or red when it ends) and what it says. */
407function deployLight(d: Deploy | null, now: number): string {
408 const p = DEPLOY
409 const color = d === null ? '#4a5a4a' : d.state === 'running' ? AMBER : d.state === 'done' ? OOZE : RED
410 const head = d === null ? 'DEPLOY LIGHT · NO DEPLOYS YET'
411 : d.state === 'running' ? `DEPLOYING · ${d.label}`
412 : d.state === 'done' ? `✓ DEPLOYED · ${d.label}${d.note ? ` · ${d.note}` : ''}`
413 : `✕ DEPLOY FAILED · ${d.label}`
414 const sub = d === null ? 'RUN A DEPLOY THROUGH OOZE-DEPLOY'
415 : d.state === 'running' ? (d.step || 'WARMING UP...')
416 : d.state === 'done' ? `${clock(d.endedAt)} · TOOK ${took(d)}`
417 : `${clock(d.endedAt)}${d.note ? ` · ${d.note}` : ''}`
418 const pulse = d?.state === 'running'
419 ? `<animate attributeName="opacity" values="1;0.35;1" dur="1.2s" begin="${phase(now, 1.2)}" repeatCount="indefinite"/>`
420 : ''
421 const cx = p.x + 26
422 const cy = p.y + p.h / 2
423 return `
424 ${panel(p, '')}
425 <circle cx="${cx}" cy="${cy}" r="10" fill="${INK}" stroke="#2a3b2a" stroke-width="1.5"/>
426 <circle cx="${cx}" cy="${cy}" r="6.5" fill="${color}" ${d === null ? '' : 'filter="url(#glow)"'}>${pulse}</circle>
427 <circle cx="${cx - 2}" cy="${cy - 2}" r="1.8" fill="#fff" opacity="${d === null ? 0.15 : 0.55}"/>
428 <text x="${cx + 20}" y="${cy - 3}" font-family="${MONO}" font-size="9.5" font-weight="bold" letter-spacing="1.2" fill="${d?.state === 'error' ? RED : PHOSPHOR}">${esc(fit(head.toUpperCase(), 52))}</text>
429 <text x="${cx + 20}" y="${cy + 11}" font-family="${MONO}" font-size="8" letter-spacing="0.8" fill="#8fb88a">${esc(fit(sub.toUpperCase(), 58))}</text>`
430}
431
432// ── Assembly ───────────────────────────────────────────────────────────────────
433
434/**
435 * The full bench. `x` null is standby (warm face, idle ooze, log standing by).
436 * `log` is the transfer's lab log; its lines' times anchor their typing and static.
437 * `who` is the mascot pack's words; the faces are its pictures.
438 */
439export function oozeSvg(x: Xfer | null, now: number, faces: Faces = {}, log: Log | null = null, who: MascotText = DEFAULT_TEXT, deploy: Deploy | null = null): string {
440 const doneAt = log?.lines.find(l => l.text.startsWith("JOB'S DONE"))?.at ?? now
441 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
442 <defs>
443 <pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M10 0H0V10" fill="none" stroke="${OOZE}" stroke-width="0.4" opacity="0.07"/></pattern>
444 <pattern id="grid5" width="50" height="50" patternUnits="userSpaceOnUse"><path d="M50 0H0V50" fill="none" stroke="${OOZE}" stroke-width="0.7" opacity="0.1"/></pattern>
445 <linearGradient id="ooze" x1="0" y1="0" x2="0" y2="1">
446 <stop offset="0" stop-color="#b6ff3b"/><stop offset="0.25" stop-color="${OOZE}"/><stop offset="1" stop-color="#0f9d00"/>
447 </linearGradient>
448 <linearGradient id="flow" x1="0" y1="0" x2="0" spreadMethod="repeat" gradientUnits="userSpaceOnUse" y2="12">
449 <stop offset="0" stop-color="${OOZE}"/><stop offset="0.5" stop-color="#0f9d00"/><stop offset="1" stop-color="${OOZE}"/>
450 </linearGradient>
451 <radialGradient id="vignette" cx="0.5" cy="0.5" r="0.75">
452 <stop offset="0.55" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.75"/>
453 </radialGradient>
454 <pattern id="scan" width="4" height="3" patternUnits="userSpaceOnUse"><rect width="4" height="1" fill="#000" opacity="0.22"/></pattern>
455 <pattern id="hazard" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
456 <rect width="12" height="12" fill="#ffcc00"/><rect width="6" height="12" fill="${INK}"/>
457 </pattern>
458 <clipPath id="tube"><rect x="${TUBE.x}" y="${TUBE.y}" width="${TUBE.w}" height="${TUBE.h}"/></clipPath>
459 <clipPath id="pipe"><rect x="${TUBE.x + TUBE.w / 2 - 6}" y="${TUBE.y + TUBE.h + 18}" width="12" height="${LOG.y - TUBE.y - TUBE.h}"/></clipPath>
460 <clipPath id="face"><circle cx="${FACE.cx}" cy="${FACE.cy}" r="${FACE.r}"/></clipPath>
461 <clipPath id="screen"><rect x="${SCREEN.x}" y="${SCREEN.y}" width="${SCREEN.w}" height="${SCREEN.h}" rx="12"/></clipPath>
462 <filter id="glow" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="3.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
463 <filter id="phosphor" x="-5%" y="-40%" width="110%" height="180%"><feGaussianBlur stdDeviation="1.4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
464 <filter id="static" x="0" y="0" width="100%" height="100%">
465 <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="1" seed="1">
466 <animate attributeName="seed" values="1;7;3;9;5;2" dur="0.3s" calcMode="discrete" repeatCount="indefinite"/>
467 </feTurbulence>
468 <feColorMatrix type="saturate" values="0"/>
469 </filter>
470 </defs>
471 <rect width="${W}" height="${H}" rx="16" fill="#0a0f0b"/>
472 <rect width="${W}" height="${H}" rx="16" fill="url(#grid)"/>
473 <rect width="${W}" height="${H}" rx="16" fill="url(#grid5)"/>
474 ${header(x, who)}
475 ${specimen(x, now, faces, who)}
476 ${containment(x, now, doneAt)}
477 ${labLog(x, log, now, who)}
478 ${plumbing(x, now)}
479 ${tiles(x)}
480 ${deployLight(deploy, now)}
481</svg>`
482}
483hooks/format.ts 20 lines1// Shared number formatting for the drawing, the log and the toasts.
2
3export function size(n: number): string {
4 const units = ['B', 'KB', 'MB', 'GB', 'TB']
5 let i = 0
6 while (n >= 1024 && i < units.length - 1) {
7 n /= 1024
8 i++
9 }
10 return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
11}
12
13export function clockText(s: number): string {
14 return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
15}
16
17export function elapsedSeconds(startedAt: number | undefined, at: number): number {
18 return startedAt === undefined ? 0 : Math.max(0, Math.round((at - startedAt) / 1000))
19}
20hooks/log.ts 76 lines1// The lab log: each milestone a transfer reaches becomes one stamped line, written once,
2// and so does anything worth knowing: pre-flight warnings, stalls, a crawling link,
3// rsync's own complaints and what its exit code means.
4
5import type { Log, LogLine, NoteLevel, Xfer } from '../types'
6import { clockText, elapsedSeconds, size } from './format'
7
8const KEEP = 40
9const STALL_MS = 30000
10const SLOW_RATE = 100 * 1024
11const SLOW_AFTER_S = 10
12const MILESTONES: [number, string][] = [
13 [25, 'QUARTER FULL'],
14 [50, 'HALFWAY — STEADY AS SHE GOES'],
15 [75, 'THREE-QUARTERS, OOZE RISING'],
16 [90, 'NEARLY THERE!'],
17]
18
19// rsync's exit codes in plain words (the ones a site transfer actually hits).
20const EXIT_MEANING: Record<number, string> = {
21 1: 'BAD ARGUMENTS',
22 10: 'COULD NOT REACH THE OTHER END',
23 11: 'FILE I/O ERROR',
24 12: 'THE LINK DROPPED MID-STREAM',
25 20: 'STOPPED (INTERRUPTED)',
26 23: 'SOME FILES DID NOT MAKE IT',
27 24: 'SOME FILES VANISHED MID-COPY',
28 30: 'TIMED OUT',
29 35: 'TIMED OUT CONNECTING',
30 255: 'SSH FAILED (KEY, HOST OR NETWORK)',
31}
32
33/** The log after seeing `x` at `now`; the same object back when nothing new happened. */
34export function advanceLog(prev: Log | null, x: Xfer, now: number): Log {
35 const isSame = prev !== null && prev.id === x.id
36 const lines = isSame ? [...prev.lines] : []
37 const elapsed = elapsedSeconds(x.startedAt, now)
38 const t = clockText(elapsed)
39 const add = (text: string, level: NoteLevel = 'info') => {
40 if (!lines.some(line => line.text === text)) lines.push({ text, at: now, t, level })
41 }
42 const arrow = x.direction === 'up' ? '▲ UP' : x.direction === 'down' ? '▼ DOWN' : '◆'
43
44 for (const note of x.notes ?? []) add(note.text, note.level)
45 if (x.state === 'counting') add('COUNTING THE GOODS...')
46 if (x.state === 'raw') add('RAW RSYNC/SCP — NO BYTE COUNT, SLOSHING')
47 if (x.total > 0 && (x.state === 'running' || x.state === 'done')) add(`PUMPING ${size(x.total)} ${arrow}`)
48 if (x.state === 'running') {
49 for (const [pct, text] of MILESTONES) if (x.pct >= pct) add(text)
50 if (now - x.updatedAt > STALL_MS) add(`STALLED? NO DATA FOR ${Math.round((now - x.updatedAt) / 1000)}S`, 'warn')
51 if (elapsed > SLOW_AFTER_S && x.rate > 0 && x.rate < SLOW_RATE) add(`SLOW LINK: ${size(x.rate)}/S`, 'warn')
52 }
53 if (x.state === 'done') {
54 const heads = lines.filter(line => line.level === 'warn' || line.level === 'error').length
55 const tail = heads > 0 ? ` · ${heads} HEADS-UP${heads === 1 ? '' : 'S'} ABOVE` : ''
56 add(`JOB'S DONE! ${x.total > 0 ? size(x.total) : ''}${tail}`.replace(' ', ' ').trim())
57 }
58 if (x.state === 'error') {
59 const meaning = EXIT_MEANING[x.code]
60 add(`SPILT — EXIT ${x.code}${meaning ? `: ${meaning}` : ''}`, 'error')
61 }
62
63 if (isSame && lines.length === prev.lines.length) return prev
64 return { id: x.id, lines: lines.slice(-KEEP) }
65}
66
67/** The `n` lines a screen shows, in order: routine progress scrolls off first, heads-ups stay put. */
68export function visibleLines(lines: LogLine[], n: number): LogLine[] {
69 const shown = [...lines]
70 while (shown.length > n) {
71 const routine = shown.findIndex(line => line.level !== 'warn' && line.level !== 'error')
72 shown.splice(routine === -1 ? 0 : routine, 1)
73 }
74 return shown
75}
76types/index.d.ts 52 lines1export type XferState = 'counting' | 'running' | 'done' | 'error' | 'raw'
2
3/** How loud a lab-log line is: plain progress, a heads-up, or a buggum. */
4export type NoteLevel = 'info' | 'warn' | 'error'
5
6/** Something the wrapper wants said: pre-flight checks, rsync's own complaints. */
7export type Note = { level: NoteLevel; text: string }
8
9export type Xfer = {
10 id: string
11 label: string
12 direction: 'up' | 'down' | 'local'
13 state: XferState
14 pct: number
15 done: number
16 total: number
17 rate: number
18 eta: number
19 file: string
20 code: number
21 updatedAt: number
22 startedAt?: number
23 notes?: Note[]
24}
25
26/** One line of the lab log: what happened, when it was seen (ms), and the elapsed stamp. */
27export type LogLine = { text: string; at: number; t: string; level?: NoteLevel }
28
29/** The lab log of one transfer, newest last. */
30export type Log = { id: string; lines: LogLine[] }
31
32/** The deploy light: one deploy run via ooze-deploy, plus the last few that finished. */
33export type DeployState = 'running' | 'done' | 'error'
34export type Deploy = {
35 id: string
36 label: string
37 note: string
38 state: DeployState
39 step: string
40 steps: { at: number; text: string }[]
41 startedAt: number
42 endedAt: number
43 updatedAt: number
44 history?: { label: string; note: string; state: DeployState; startedAt: number; endedAt: number }[]
45}
46
47declare module 'claude-code' {
48 interface PluginState {
49 'ooze-o-meter': { xfer: Xfer | null; dinged: string | null; log: Log | null; deploy: Deploy | null; deployDinged: string | null }
50 }
51}
52