SLOPSHOPPER

ooze-o-meter

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.

newpaneguardcommandtoasttimer
v0.2.0MITupdated 2026-10-151upcreative/ooze-o-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ooze-o-meter
│ ┃ Ooze-o-Meter ✕ › fix the failing auth test and add an audit log call │ ┃ Ooze-o-Meter standing by. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ooze │ ⎿ ooze-o-meter: Ooze-o-Meter open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ooze-o-Meter
Ooze-o-Meter standing by.
README

Ooze-o-Meter

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.

The Ooze-o-Meter mid-upload, with the Servo-Crab specimen

What you get

  • Live progress for any rsync transfer: percent, speed, bytes moved, time left.
  • A lab log that flags trouble before it bites you:
  • ⚠ --delete in your args, a dry run, or an upload to a live server
  • ⚠ a stalled transfer (no data for 30 seconds) or a slow link
  • ✕ rsync's own errors, with the exit code in plain words (23 means some files didn't make it)
  • A specimen with moods. The bundled one is the Servo-Crab. You can swap in your own.
  • A ding when the job's done.

The full bench draws in the Claude desktop app. A terminal session gets a simple progress bar.

Install

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 it

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.

Deploy light

A strip under the bench shows your last deploy at a glance:

  • Grey: no deploys yet.
  • Amber, pulsing: a deploy is running. The step it's on shows underneath.
  • Green: it finished. You see when, and how long it took.
  • Red: it failed. You see when, and the exit code.

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.

Bring your own specimen

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:

  • Square, about 200 by 200 pixels, JPEG or PNG. Keep each under 15 KB or so.
  • Put your character in the middle. The bench crops them into a circle.
  • Have them glance to the right in base and excited. That's where the canister sits.
  • Keep the pose the same between 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.

How it works

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.

Development

The plugin is a Claude Code function-hooks mod. Validate and test it from the repo root:

claude plugin validate .
claude plugin test .

License

MIT. See LICENSE. Made by 1UP Creative.

Source 5 files
hooks/register.tsx 312 lines
1import 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}
312
hooks/art.ts 483 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
483
hooks/format.ts 20 lines
1// 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}
20
hooks/log.ts 76 lines
1// 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}
76
types/index.d.ts 52 lines
1export 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