SLOPSHOPPER

nowloading

The line that animates while Claude works, redrawn as something from before: tape decks, departure boards, an hourglass, a floppy drive, a dot-matrix printer…

newspinnerguardcommandtoasttimer
v0.3.0MITupdated 2026-10-03vgnshiyer/nowloading
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nowloading
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ nowloading │ ⏺ Read(src/auth.ts) │ nowloading: rewind · themes: rewind, │ ⎿ Read 6 lines │ channel3, cassette, platform, arrivals, │ ⏺ Update(src/auth.ts) │ sandglass, floppy, dotmatrix, rally, │ ⎿ 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 › /nowloading ✻ Thinking… ▣ PAUSE ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
✻ Thinking… ▣ PAUSE
README

nowloading

The line that animates while Claude Code works, redrawn as something from before.

It replaces the three little dots with something from before: a VCR's on-screen display, a split-flap departure board, a one-bit hourglass, a floppy drive, a dot-matrix printer, an arcade rally, a filmstrip or a pixel campfire. Each one follows what Claude is actually doing: thinking, running a tool, a tool failing, writing the answer. It works in the desktop app's Code tab and in the terminal, where Ghostty and kitty show it as real pixels.

Theme
rewind: the VCR's on-screen word in your text color. Pause while it thinks, fast-forward through tools, play while it writes. On the desktop, the tape counter is the time.rewind
channel3: the same display on a tiny screen, with scanlines and a slow tracking roll.channel3
cassette: a tape whose reels turn at the speed of the transport.cassette
platform: a turn is a flight. CHECK IN, BOARDING, EN ROUTE, LANDING, and DELAYED when a tool fails. The board riffles only when its word changes.platform
arrivals: the same board at night. On the desktop, both boards keep time on a flap clock.arrivals
sandglass: the wait cursor of an older desktop. The sand runs while it works, the glass turns over when a tool succeeds and tips over when one fails.sandglass
floppy: a floppy disk and its drive. The shutter seeks while thinking, data travels during tools and writing, and a failed tool rattles the disk.floppy
dotmatrix: a print head crosses tractor paper. It prints through tools, parks on success, and creases the paper when a tool jams.dotmatrix
rally: two paddles keep a rally going. A tool speeds up the game; success lands a clean return, and failure misses the paddle.rally
filmstrip: frames and sprocket holes pass through a projector gate. It holds a frame while thinking and stops on a check or a jam after a tool.filmstrip
campfire: a small pixel fire over crossed logs. It flickers gently while thinking, burns faster through tools, sends up a spark on success, and sputters into smoke after a failure.campfire

Install

In Claude Code:

/plugin marketplace add vgnshiyer/mods
/plugin install nowloading@vgnshiyer-mods

Requires a Claude Code version with mods (function hooks). vgnshiyer/mods lists my other mods too.

Use

  • /nowloading: shows the current theme and lists the others.
  • /nowloading arrivals: switches now, mid-turn included, and every session after uses it.
  • /nowloading random: a different theme every turn.

The theme setting in /config picks one too. Whichever you set last wins.

What it touches

Nothing but the drawing. Every hook passes straight through, so Claude's prompts, context and tool calls are untouched, and nothing waits on the animation. Meaningful step text stays, including a message another mod set, along with the real elapsed time. On desktop, the generic “Working” placeholder is omitted; the running tool already has its own description. If a theme ever fails to draw, Claude Code draws its own line instead.

  • Desktop: the turn's line becomes the art, then the time. rewind, channel3, cassette, platform and arrivals draw their own time: the tape counter or the flap clock. A running tool keeps its own line with its description, and a message another mod sets shows beside the art.
  • Terminal: in Ghostty and kitty, the art is a picture at the start of Claude Code's own line, which keeps its verb, time and tokens. Other terminals, and tmux, show the theme's word in its place.

Add a theme

A theme is one look in a family file.

  1. Write themes/<family>.js. Export { key, title, blurb, looks }, where each look is { name, label, note, art(s) } and optionally tail(s).
  2. art(s) returns an SVG string 20px tall and the same width in every state, animated with SMIL. Draw in currentColor so it follows light and dark.
  3. s is { mode, result, running, elapsed }. mode is requesting, thinking, tool-use or responding. result is a number after a tool succeeds and 'error' after it fails.
  4. tail(s) is optional and replaces the time on the desktop. It must show the real elapsed time.
  5. Keep the markup unchanged for an unchanged state so the animation can continue. For terminal playback, use loop durations on a 0.1-second grid with a common period no longer than 3.2 seconds; result reactions should settle within 1.5 seconds.
  6. themes/vhs.js is a complete example.
  7. Add one import and one entry in themes/index.js, a fallback word in hooks/state.ts, and the theme's name to userConfig.theme.options in .claude-plugin/plugin.json.
  8. Build the terminal frames: node art/frames.mjs && python3 art/pack.py.
  9. Look at it: serve the repo (python3 -m http.server) and open art/variations.html. To see every state, light and dark, at 3x and real size, run python3 art/render.py <family> /tmp/sheet.
  10. Record its GIF with node art/gifs.mjs && python3 art/gif.py, which writes docs/<theme>.gif, and add a row to the table above.
  11. Run claude plugin test ., then open a pull request.

art/ needs Node 22+, Python with Pillow, and Google Chrome. The scripts look for Chrome where macOS installs it; set CHROME to its path anywhere else.

To rebuild only selected themes, pass their names to the capture script: node art/frames.mjs floppy dotmatrix rally filmstrip, then python3 art/pack.py. The GIF recorder accepts the same names: node art/gifs.mjs floppy dotmatrix rally filmstrip, then python3 art/gif.py.

License

MIT. All art is original. The themes evoke generic old machines, not any product or brand.

Source 25 files
hooks/register.ts 236 lines
1// nowloading: the line that animates while Claude works, redrawn as something from before.
2// Purely cosmetic. Every hook below observes and passes straight through with next(e): nothing touches
3// Claude's context, prompts or tool calls, and nothing waits. The one "hold" (a finished tool call stays on
4// screen for a moment) lives in the drawing only.
5import type { EngineInterface, Register } from 'claude-code'
6import type { SavedTurn } from '../types/index.d.ts'
7import { NAMES, THEMES } from '../themes/index.js'
8import { FRAMES } from '../themes/frames/index.js'
9import { nativeTime } from '../themes/kit.js'
10import { fallback, frameState, snapshot, words, type Snapshot, type Turn } from './state.ts'
11
12/** A theme: its art where the dots were and, when it draws its own time, a tail in place of the engine's */
13type Look = { name: string; art: (s: Snapshot) => string; tail?: (s: Snapshot) => string }
14const LOOKS: Record<string, Look | undefined> = THEMES
15
16const TICK_MS = 100 // the terminal's frame clock, ten pictures a second
17const HOLD_MS = 1500 // how long a finished tool call stays on screen
18const STORE_KEY = 'theme' // the person's pick, kept across sessions
19const STORE_CONFIG = 'config' // the /config theme at the time of that pick: a later /config change wins
20const SAVED_TURN = { plugin: 'nowloading', key: 'turn' } as const // the turn's start, kept across a hot reload
21
22let configured = 'rewind' // the theme /config names
23let choice = 'rewind' // a theme name, or 'random'
24let current = 'rewind' // the theme drawn this turn; 'random' picks one per turn
25const turn: Turn = { t0: 0, working: false, running: 0, result: null }
26let started = false // turn.start has run for this turn
27let spinnerId = '' // the terminal Spinner's instance, so frames can be swapped in place
28let playing = '' // `${theme}:${state}` the terminal is playing
29let position = 0 // frames into that sequence
30let ticks = 0
31
32const isTheme = (name: unknown): name is string => typeof name === 'string' && (name === 'random' || Object.hasOwn(LOOKS, name))
33const widthOf = (markup: string) => Number(/width="([\d.]+)"/.exec(markup)?.[1] ?? 20)
34
35function pick(previous: string): string {
36  if (choice !== 'random') return choice
37  const others = NAMES.filter(name => name !== previous)
38  return others[Math.floor(Math.random() * others.length)] ?? previous
39}
40
41function sequence(name: string, state: string) {
42  const pack = FRAMES[name as keyof typeof FRAMES]
43  return pack.states[state as keyof typeof pack.states]
44}
45
46// Two states that play the same frames (the board's EN ROUTE while a tool runs and just after) are one picture
47function samePicture(a: string, b: string): boolean {
48  const [an = '', as = ''] = a.split(':'), [bn = '', bs = ''] = b.split(':')
49  if (an !== bn) return false
50  const x = sequence(an, as), y = sequence(bn, bs)
51  return x !== undefined && y !== undefined && String(x.intro) === String(y.intro) && String(x.loop) === String(y.loop)
52}
53
54// The frame `position` steps into a state's sequence: the intro plays once, then the loop repeats
55function frameAt(name: string, state: string, at: number): string {
56  const pack = FRAMES[name as keyof typeof FRAMES]
57  const seq = sequence(name, state)
58  const index = (at < seq.intro.length ? seq.intro[at] : seq.loop[(at - seq.intro.length) % seq.loop.length]) ?? 0
59  return pack.frames[index] ?? ''
60}
61
62function tick($: EngineInterface) {
63  if (!turn.working) return
64  ticks += 1
65  // Once a second: the desktop's clock reads the time, and a held tool result expires
66  if (ticks % 10 === 0) $.ui.invalidate('ui.render')
67  if (spinnerId === '' || playing === '') return
68  const [name = '', state = ''] = playing.split(':')
69  position += 1
70  $.ui.blit({ requestId: spinnerId, key: 'art', source: { png: frameAt(name, state, position) } })
71    .then(r => {
72      // Not mounted (a permission prompt covers the line) or no pictures here: wait for the next drawing
73      if (r.deny) playing = ''
74    })
75    .catch(() => {
76      playing = ''
77    })
78}
79
80export const register: Register = (on, options) => {
81  configured = isTheme(options.theme) ? String(options.theme) : 'rewind'
82  choice = configured
83  current = pick('')
84
85  on('session.start', async ($, e, next) => {
86    // Whichever was set last wins: a /nowloading pick, unless /config has changed since it was made
87    const saved = await $.store.get(STORE_KEY).catch(() => undefined)
88    const savedConfig = await $.store.get(STORE_CONFIG).catch(() => undefined)
89    if (isTheme(saved) && savedConfig === configured) choice = saved
90    current = pick('')
91    // A hot reload mid-turn (a /config change, an update) keeps the turn's real start time
92    const held = await $.state.get(SAVED_TURN).catch(() => undefined)
93    if (held?.value?.working) {
94      turn.working = true
95      started = true
96      turn.t0 = held.value.t0
97    }
98    await $.command.register({
99      name: 'nowloading',
100      description: 'Pick the loading animation: a theme, random, or nothing to list them',
101      argumentHint: '[theme | random]',
102      immediate: true,
103    })
104    $.clock.every(TICK_MS, () => tick($))
105    return next(e)
106  })
107
108  // A subagent's turn runs inside the main one, so only the first start and the main loop's end count.
109  // The time is read without holding the turn; a drawing made before it lands stamps the start itself.
110  on('turn.start', async ($, e, next) => {
111    if (!started) {
112      started = true
113      turn.working = true
114      void $.clock.now().then(now => {
115        if (turn.t0 === 0) turn.t0 = now
116        const saved: SavedTurn = { t0: turn.t0, working: true }
117        return $.state.set(SAVED_TURN, saved)
118      }).catch(() => undefined)
119      $.ui.invalidate('ui.render')
120    }
121    return next(e)
122  })
123
124  on('turn.complete', async ($, e, next) => {
125    if (!e.agentId) {
126      started = false
127      turn.working = false
128      turn.t0 = 0
129      turn.running = 0
130      turn.result = null
131      playing = ''
132      current = pick(current)
133      const saved: SavedTurn = { t0: 0, working: false }
134      void $.state.set(SAVED_TURN, saved).catch(() => undefined)
135    }
136    return next(e)
137  })
138
139  // Observes the main loop's tool calls: the result passes through untouched and is only drawn
140  on('tool.call', async ($, e, next) => {
141    if (e.agentId) return next(e)
142    turn.running += 1
143    $.ui.invalidate('ui.render')
144    try {
145      const r = await next(e)
146      const result = { value: r.deny !== undefined || r.isError ? ('error' as const) : 1, at: Number.POSITIVE_INFINITY }
147      turn.result = result
148      void $.clock.now().then(now => {
149        result.at = now
150      })
151      return r
152    } finally {
153      turn.running = Math.max(0, turn.running - 1)
154      $.ui.invalidate('ui.render')
155    }
156  })
157
158  // /nowloading lists the themes, /nowloading <theme> switches now and for every session, random rotates
159  on('command.run', { command: 'nowloading' }, async ($, e) => {
160    const arg = e.args.trim().toLowerCase()
161    if (arg === '') {
162      $.ui.toast(`nowloading: ${choice} · themes: ${NAMES.join(', ')}, random`)
163      return {}
164    }
165    if (!isTheme(arg)) {
166      $.ui.toast(`nowloading: no theme "${arg}" · themes: ${NAMES.join(', ')}, random`)
167      return {}
168    }
169    choice = arg
170    current = pick(current)
171    await $.store.set(STORE_KEY, choice)
172    await $.store.set(STORE_CONFIG, configured)
173    $.ui.toast(arg === 'random' ? 'nowloading: a new theme every turn' : `nowloading: ${arg}`)
174    $.ui.invalidate('ui.render')
175    return {}
176  })
177
178  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
179    const look = LOOKS[current]
180    if (!look) return next(e)
181    const now = await $.clock.now()
182    // The line is only drawn while a turn runs: a drawing before turn.start (or after a reload) starts the clock
183    if (turn.t0 === 0) turn.t0 = now
184    turn.working = true
185    const s = snapshot(e.props.mode, turn, now, HOLD_MS)
186    const text = words(e.props)
187
188    // The desktop: the art where the dots were, the step text, and the time (the theme's own, or as the
189    // engine writes it), since a drawn row hides the engine's dots and its elapsed time alike
190    if (e.surface === 'desktop') {
191      const { Box, Text, Svg } = $.ui.resolve(e)
192      const art = look.art(s)
193      const tail = look.tail?.(s)
194      const time = nativeTime(s.elapsed)
195      // Real gaps, not spaces: the desktop trims spaces at the edges of each piece. A long step is cut with an
196      // ellipsis so the time never leaves the line.
197      // The desktop's word is a placeholder ("Working") when the row has no step of its own: the running tool
198      // shows its description on its own line, so this line then shows just the art and the time
199      const step = e.props.message === null && e.props.word === 'Working' ? [] : [Text({ wrap: 'truncate-end', children: [text] })]
200      return Box({
201        flexDirection: 'row',
202        alignItems: 'center',
203        columnGap: 1,
204        children: [
205          Svg({ source: art, alt: `${current}: ${fallback(current, s)}`, width: widthOf(art), height: 20 }),
206          ...step,
207          tail ? Svg({ source: tail, alt: time, width: widthOf(tail), height: 20 }) : Text({ children: [time] }),
208        ],
209      })
210    }
211
212    // The terminal: the art as a picture (Ghostty, kitty), then the engine's own line with its verb, time and
213    // tokens. Terminals without pictures draw the alt: the theme's words, dim. The engine's line starts with a
214    // blank row, so the picture starts one row down to sit beside its text.
215    if (e.surface === 'terminal') {
216      const { Box, Text, Image } = $.ui.resolve(e)
217      const state = frameState(s)
218      const key = `${current}:${state}`
219      if (key !== playing && !samePicture(key, playing)) position = 0
220      playing = key
221      spinnerId = e.requestId
222      const pack = FRAMES[current as keyof typeof FRAMES]
223      const picture = Image({
224        key: 'art',
225        source: { png: frameAt(current, state, position) },
226        columns: pack.columns,
227        rows: 1,
228        alt: fallback(current, s),
229      })
230      return Box({ flexDirection: 'row', children: [Box({ marginTop: 1, children: [picture] }), Text({ children: [' '] }), await next(e)] })
231    }
232
233    return next(e)
234  }).catch(($, e, next) => next(e))
235}
236
types/index.d.ts 9 lines
1// nowloading's state contract: the one value it keeps in $.state, so a hot reload mid-turn keeps the real start time.
2export type SavedTurn = { t0: number; working: boolean }
3
4declare module 'claude-code' {
5  interface PluginState {
6    nowloading: { turn: SavedTurn }
7  }
8}
9
themes/index.js 15 lines
1// Every theme, by name. A family is a set of looks that share a source; each look is its own theme.
2// Adding a theme: write themes/<family>.js (see vhs.js), then add one import and one entry below.
3import vhs from './vhs.js'
4import splitflap from './splitflap.js'
5import sandglass from './sandglass.js'
6import floppy from './floppy.js'
7import dotmatrix from './dotmatrix.js'
8import rally from './rally.js'
9import filmstrip from './filmstrip.js'
10import campfire from './campfire.js'
11
12export const FAMILIES = [vhs, splitflap, sandglass, floppy, dotmatrix, rally, filmstrip, campfire]
13export const THEMES = Object.fromEntries(FAMILIES.flatMap(family => family.looks.map(look => [look.name, look])))
14export const NAMES = Object.keys(THEMES)
15
themes/frames/index.js 15 lines
1// Terminal frames for every theme, by name. Generated by art/pack.py. Do not edit.
2import rewind from './rewind.js'
3import channel3 from './channel3.js'
4import cassette from './cassette.js'
5import platform from './platform.js'
6import arrivals from './arrivals.js'
7import sandglass from './sandglass.js'
8import floppy from './floppy.js'
9import dotmatrix from './dotmatrix.js'
10import rally from './rally.js'
11import filmstrip from './filmstrip.js'
12import campfire from './campfire.js'
13
14export const FRAMES = { rewind, channel3, cassette, platform, arrivals, sandglass, floppy, dotmatrix, rally, filmstrip, campfire }
15
themes/kit.js 68 lines
1// nowloading drawing kit: plain helpers every theme draws with. Pure functions, no engine access,
2// so the mod, the browser demo and the frame builder all share them.
3const f = n => +(+n).toFixed(2)
4const svg = (w, body, h = 20) => `<svg xmlns="http://www.w3.org/2000/svg" width="${f(w)}" height="${h}" viewBox="0 0 ${f(w)} ${h}">${body}</svg>`
5let uid = 0
6const id = name => `${name}${++uid}`
7
8function bitmap(rows, x, y, p) {
9  let d = ''
10  rows.forEach((row, ry) => {
11    let run = -1
12    for (let i = 0; i <= row.length; i++) {
13      if (row[i] === '1' && run < 0) run = i
14      if (row[i] !== '1' && run >= 0) {
15        d += `M${f(x + run * p)} ${f(y + ry * p)}h${f((i - run) * p)}v${f(p)}h${f(-(i - run) * p)}z`
16        run = -1
17      }
18    }
19  })
20  return d
21}
22
23// 5x7 on-screen-display font, drawn as pixels
24const OSD = {
25  A: ['01110','10001','10001','11111','10001','10001','10001'], C: ['01110','10001','10000','10000','10000','10001','01110'],
26  E: ['11111','10000','10000','11110','10000','10000','11111'], F: ['11111','10000','10000','11110','10000','10000','10000'],
27  G: ['01110','10001','10000','10111','10001','10001','01111'], I: ['111','010','010','010','010','010','111'],
28  K: ['10001','10010','10100','11000','10100','10010','10001'], L: ['10000','10000','10000','10000','10000','10000','11111'],
29  N: ['10001','11001','10101','10011','10001','10001','10001'], O: ['01110','10001','10001','10001','10001','10001','01110'],
30  P: ['11110','10001','10001','11110','10000','10000','10000'], R: ['11110','10001','10001','11110','10100','10010','10001'],
31  S: ['01111','10000','10000','01110','00001','00001','11110'], T: ['11111','00100','00100','00100','00100','00100','00100'],
32  U: ['10001','10001','10001','10001','10001','10001','01110'], Y: ['10001','10001','01010','00100','00100','00100','00100'],
33  0: ['01110','10001','10011','10101','11001','10001','01110'], 1: ['00100','01100','00100','00100','00100','00100','01110'],
34  2: ['01110','10001','00001','00010','00100','01000','11111'], 3: ['11111','00010','00100','00010','00001','10001','01110'],
35  4: ['00010','00110','01010','10010','11111','00010','00010'], 5: ['11111','10000','11110','00001','00001','10001','01110'],
36  6: ['00110','01000','10000','11110','10001','10001','01110'], 7: ['11111','00001','00010','00100','01000','01000','01000'],
37  8: ['01110','10001','10001','01110','10001','10001','01110'], 9: ['01110','10001','10001','01111','00001','00010','01100'],
38  ':': ['0','0','1','0','1','0','0'], ' ': ['00','00','00','00','00','00','00'],
39  '▶': ['10000','11000','11100','11110','11100','11000','10000'],
40  '‖': ['11011','11011','11011','11011','11011','11011','11011'],
41  '»': ['10001000','11001100','11101110','11111111','11101110','11001100','10001000'],
42  '■': ['00000','11111','11111','11111','11111','11111','00000'],
43}
44const osdWidth = text => [...text].reduce((w, ch) => w + OSD[ch][0].length + 1, -1)
45function osd(text, x, y, p) {
46  let d = '', cx = 0
47  for (const ch of text) { d += bitmap(OSD[ch], x + cx * p, y, p); cx += OSD[ch][0].length + 1 }
48  return d
49}
50
51// Seven-segment digit
52const SEGMENTS = { 0: 'abcdef', 1: 'bc', 2: 'abged', 3: 'abgcd', 4: 'fgbc', 5: 'afgcd', 6: 'afgedc', 7: 'abc', 8: 'abcdefg', 9: 'abcdfg', ' ': '' }
53function seven(ch, x, y, w, h, t, on, off) {
54  const half = h / 2, lit = SEGMENTS[ch] ?? ''
55  const r = {
56    a: [x + t * .65, y, w - t * 1.3, t], d: [x + t * .65, y + h - t, w - t * 1.3, t], g: [x + t * .65, y + half - t / 2, w - t * 1.3, t],
57    f: [x, y + t * .6, t, half - t * .85], b: [x + w - t, y + t * .6, t, half - t * .85],
58    e: [x, y + half + t * .25, t, half - t * .85], c: [x + w - t, y + half + t * .25, t, half - t * .85],
59  }
60  return Object.entries(r).map(([k, [rx, ry, rw, rh]]) =>
61    `<rect x="${f(rx)}" y="${f(ry)}" width="${f(rw)}" height="${f(rh)}" rx="${f(t / 3)}" fill="${lit.includes(k) ? on : off}"/>`).join('')
62}
63
64const tape = sec => { const s = Math.floor(sec); return `${Math.floor(s / 3600)}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` }
65const nativeTime = sec => { const s = Math.floor(sec); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s` }
66
67export { f, svg, id, bitmap, OSD, osdWidth, osd, SEGMENTS, seven, tape, nativeTime }
68
hooks/state.ts 78 lines
1// The plain data every theme draws from. Pure functions, no engine access: register.ts owns `$`.
2
3export type Mode = 'requesting' | 'thinking' | 'tool-use' | 'responding' | 'done'
4/** A finished tool call: a number when it succeeded, 'error' when it failed or was denied */
5export type Result = number | 'error'
6
7/** What the hooks have seen of the current turn */
8export type Turn = {
9  t0: number // when the turn started, ms since the epoch
10  working: boolean
11  running: number // main-loop tool calls in flight
12  result: { value: Result; at: number } | null // the last finished call, and when
13}
14
15/** What a theme's art(s) reads */
16export type Snapshot = { mode: Mode; result?: Result; running: boolean; elapsed: number }
17
18/** The states a terminal theme has frames for */
19export type FrameState = 'requesting' | 'thinking' | 'tool' | 'ok' | 'error' | 'responding'
20
21/**
22 * The turn as a theme sees it at `now`. A finished call keeps showing for `holdMs` (in the drawing only,
23 * nothing waits), so a quick tool still turns the hourglass over or flips the board to DELAYED.
24 */
25export function snapshot(propsMode: string, turn: Turn, now: number, holdMs: number): Snapshot {
26  const held = turn.result !== null && turn.running === 0 && now - turn.result.at < holdMs
27  let mode: Mode = propsMode === 'tool-input' || propsMode === 'tool-use' ? 'tool-use'
28    : propsMode === 'thinking' || propsMode === 'responding' || propsMode === 'requesting' ? propsMode : 'requesting'
29  if (turn.running > 0 || held) mode = 'tool-use'
30  return {
31    mode,
32    result: held ? turn.result!.value : undefined,
33    running: turn.running > 0,
34    elapsed: Math.max(0, (now - turn.t0) / 1000),
35  }
36}
37
38/** The step text exactly as the engine would show it: the message when another mod set one, else the word */
39export function words(props: { word: string; message: string | null; suffix?: string }): string {
40  const text = props.message ?? props.word
41  const suffix = props.suffix ?? '…'
42  return suffix === '…' && /(…|\.\.\.)$/.test(text) ? text : text + suffix
43}
44
45export function frameState(s: Snapshot): FrameState {
46  if (s.mode === 'tool-use') return s.result === 'error' ? 'error' : s.result !== undefined ? 'ok' : 'tool'
47  if (s.mode === 'done') return 'responding'
48  return s.mode
49}
50
51/** What a terminal that shows no pictures draws instead: the theme's own words, short enough for its box */
52export function fallback(name: string, s: Snapshot): string {
53  const state = frameState(s)
54  if (name === 'rewind' || name === 'channel3' || name === 'cassette') {
55    const vcr = { requesting: 'TRACK', thinking: 'PAUSE', tool: '» FF', ok: '» FF', error: '» FF', responding: '▶ PLAY' }
56    return name === 'cassette' ? (state === 'thinking' ? '‖' : '▶') : vcr[state]
57  }
58  if (name === 'platform' || name === 'arrivals') {
59    return { requesting: 'CHECK IN', thinking: 'BOARDING', tool: 'EN ROUTE', ok: 'EN ROUTE', error: 'DELAYED', responding: 'LANDING' }[state]
60  }
61  if (name === 'floppy') {
62    return { requesting: 'LOAD', thinking: 'SEEK', tool: 'READ', ok: 'SAVED', error: 'ERROR', responding: 'WRITE' }[state]
63  }
64  if (name === 'dotmatrix') {
65    return { requesting: 'FEED', thinking: 'WAIT', tool: 'PRINT', ok: 'OK', error: 'JAM', responding: 'WRITE' }[state]
66  }
67  if (name === 'rally') {
68    return { requesting: 'SERVE', thinking: 'WAIT', tool: 'RALLY', ok: 'HIT', error: 'MISS', responding: 'PLAY' }[state]
69  }
70  if (name === 'filmstrip') {
71    return { requesting: 'THREAD', thinking: 'HOLD', tool: 'ROLL', ok: 'CUT', error: 'JAM', responding: 'PLAY' }[state]
72  }
73  if (name === 'campfire') {
74    return { requesting: 'LIT', thinking: 'LOW', tool: 'HOT', ok: 'POP', error: 'OUT', responding: 'ON' }[state]
75  }
76  return '⧗'
77}
78
themes/vhs.js 88 lines
1// Be Kind, Rewind: the on-screen display of a late-80s VCR. Approved 2026-10-02.
2import { f, svg, id, osdWidth, osd, tape } from './kit.js'
3
4function transport(s) {
5  if (s.mode === 'thinking') return '‖ PAUSE'
6  if (s.mode === 'tool-use') return '» FF'
7  if (s.mode === 'responding') return '▶ PLAY'
8  return '■ STOP'
9}
10const OSD_P = 1.25
11const OSD_W = osdWidth('‖ PAUSE') * OSD_P + 2
12// The VCR tracking meter: bars that fill while the picture settles
13function trackingMeter(x, y, p, fill) {
14  let s = ''
15  for (let i = 0; i < 6; i++) {
16    const values = Array.from({ length: 8 }, (_, k) => (k > i ? 1 : 0.18)).join(';')
17    s += `<rect x="${f(x + i * 3 * p)}" y="${f(y + p)}" width="${f(2 * p)}" height="${f(5 * p)}" fill="${fill}" opacity="0.18"><animate attributeName="opacity" values="${values}" dur="1.6s" calcMode="discrete" repeatCount="indefinite"/></rect>`
18  }
19  return s
20}
21function osdMotion(s) {
22  if (s.mode === 'thinking') return `<animate attributeName="opacity" values="1;0.35" keyTimes="0;0.55" calcMode="discrete" dur="1.1s" repeatCount="indefinite"/>`
23  if (s.mode === 'requesting') return `<animateTransform attributeName="transform" type="translate" values="0 0;0.5 0;-0.3 0;0 0;0 0" keyTimes="0;0.1;0.2;0.3;1" dur="1.3s" repeatCount="indefinite"/>`
24  return ''
25}
26function glitch(s) {
27  return s.result === 'error' ? `<animateTransform attributeName="transform" type="translate" values="0 0;1 0;-1 0;0.6 0;0 0" dur="0.14s" repeatCount="4"/>` : ''
28}
29// The little screen's clip and scanline ids, made once: the same state must give the same markup, or the
30// desktop treats every redraw as a new picture and restarts its animation
31const CRT = id('crt'), SCAN = id('scan')
32const counterTail = s => {
33  const text = tape(s.elapsed)
34  return svg(osdWidth(text) * 1.2 + 1, `<path d="${osd(text, 0.5, 10 - 3.5 * 1.2, 1.2)}" fill="currentColor"/>`)
35}
36
37const LOOKS = [
38  {
39    name: 'rewind', label: 'Quiet', note: 'the OSD word, in your text color',
40    art: s => svg(OSD_W, s.mode === 'requesting' ? trackingMeter(1, 10 - 3.5 * OSD_P, OSD_P, 'currentColor')
41      : `<g>${glitch(s)}<path d="${osd(transport(s), 1, 10 - 3.5 * OSD_P, OSD_P)}" fill="currentColor">${osdMotion(s)}</path></g>`),
42    tail: counterTail,
43  },
44  {
45    name: 'channel3', label: 'Classic', note: 'the OSD on a little screen',
46    art: s => {
47      const w = OSD_W + 13, clip = CRT, lines = SCAN
48      const d = s.mode === 'requesting' ? '' : osd(transport(s), 7, 10 - 3.5 * OSD_P, OSD_P)
49      return svg(w,
50        `<defs><clipPath id="${clip}"><rect x="0.5" y="1.5" width="${f(w - 1)}" height="17" rx="3"/></clipPath>` +
51        `<pattern id="${lines}" width="2" height="2" patternUnits="userSpaceOnUse"><rect width="2" height="1" fill="#000" opacity="0.32"/></pattern></defs>` +
52        `<g clip-path="url(#${clip})"><rect x="0" y="0" width="${f(w)}" height="20" fill="#141414"/>` +
53        (s.mode === 'requesting' ? trackingMeter(7, 10 - 3.5 * OSD_P, OSD_P, '#f3f3f0') :
54        `<g>${glitch(s)}${osdMotion(s)}<path d="${d}" transform="translate(-0.6 0)" fill="#ff3b30" opacity="0.55"/><path d="${d}" transform="translate(0.6 0)" fill="#2fd6ff" opacity="0.45"/><path d="${d}" fill="#f3f3f0"/></g>`) +
55        `<rect width="${f(w)}" height="20" fill="url(#${lines})"/>` +
56        `<rect x="0" y="-4" width="${f(w)}" height="3" fill="#fff" opacity="0.14"><animate attributeName="y" values="-4;-4;22" keyTimes="0;0.8;1" dur="3.6s" repeatCount="indefinite"/></rect></g>`)
57    },
58    tail: counterTail,
59  },
60  {
61    name: 'cassette', label: 'Tape', note: 'the reels turn with the transport',
62    art: s => {
63      const speed = { requesting: 3.2, thinking: 0, 'tool-use': 0.38, responding: 1.6, done: 0 }[s.mode]
64      const progress = Math.min(1, s.elapsed / 1800)
65      const reel = (cx, pack) => {
66        const spin = speed ? `<animateTransform attributeName="transform" type="rotate" from="0 ${cx} 12.2" to="360 ${cx} 12.2" dur="${speed}s" repeatCount="indefinite"/>` : ''
67        let teeth = ''
68        for (let k = 0; k < 3; k++) teeth += `<rect x="${f(cx - 0.4)}" y="10.5" width="0.8" height="1.1" fill="#1d1d1d" transform="rotate(${k * 120} ${cx} 12.2)"/>`
69        return `<circle cx="${cx}" cy="12.2" r="${f(pack)}" fill="#4b3427"/><circle cx="${cx}" cy="12.2" r="2" fill="#ece9e2"/><g>${spin}${teeth}</g>`
70      }
71      return svg(34,
72        `<rect x="0.5" y="1.5" width="33" height="17" rx="2.2" fill="#1c1c1c" stroke="#4d4d4d" stroke-width="0.5"/>` +
73        `<rect x="3" y="3.2" width="28" height="3.1" rx="0.7" fill="#ece6d6"/><rect x="3" y="5" width="28" height="0.65" fill="#cf4636"/>` +
74        `<rect x="7" y="8.1" width="20" height="8.2" rx="1.6" fill="#0d0d0d" stroke="#575757" stroke-width="0.5"/>` +
75        reel(12.4, 3.5 - 1.3 * progress) + reel(21.6, 2.2 + 1.3 * progress))
76    },
77    tail: counterTail,
78  },
79]
80
81
82export default {
83  key: 'vhs',
84  title: 'Be Kind, Rewind',
85  blurb: 'Pause while thinking, fast-forward through tools, play while writing. The tape counter is the real time.',
86  looks: LOOKS,
87}
88
themes/splitflap.js 197 lines
1// Now Departing: the split-flap board of a 1970s station hall. Every letter is cut at the seam between its two flaps.
2// A turn is a flight. The board riffles only when the word changes, then holds still; only BOARDING flashes, as on a real board.
3import { svg, bitmap } from './kit.js'
4
5// A 5x8 flap font: four rows above the seam and four below, so the seam never deletes a crossbar
6const FONT = {
7  ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000', '00000'],
8  A: ['01110', '10001', '10001', '10001', '11111', '10001', '10001', '10001'],
9  B: ['11110', '10001', '10001', '11110', '10001', '10001', '10001', '11110'],
10  C: ['01110', '10001', '10000', '10000', '10000', '10000', '10001', '01110'],
11  D: ['11100', '10010', '10001', '10001', '10001', '10001', '10010', '11100'],
12  E: ['11111', '10000', '10000', '11110', '10000', '10000', '10000', '11111'],
13  F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000', '10000'],
14  G: ['01110', '10001', '10000', '10000', '10111', '10001', '10001', '01111'],
15  H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001', '10001'],
16  I: ['111', '010', '010', '010', '010', '010', '010', '111'],
17  J: ['00111', '00010', '00010', '00010', '00010', '10010', '10010', '01100'],
18  K: ['10001', '10010', '10100', '11000', '11000', '10100', '10010', '10001'],
19  L: ['10000', '10000', '10000', '10000', '10000', '10000', '10000', '11111'],
20  M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001', '10001'],
21  N: ['10001', '11001', '11001', '10101', '10101', '10011', '10011', '10001'],
22  O: ['01110', '10001', '10001', '10001', '10001', '10001', '10001', '01110'],
23  P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000', '10000'],
24  Q: ['01110', '10001', '10001', '10001', '10001', '10101', '10010', '01101'],
25  R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001', '10001'],
26  S: ['01111', '10000', '10000', '01110', '00001', '00001', '00001', '11110'],
27  T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100', '00100'],
28  U: ['10001', '10001', '10001', '10001', '10001', '10001', '10001', '01110'],
29  V: ['10001', '10001', '10001', '10001', '10001', '01010', '01010', '00100'],
30  W: ['10001', '10001', '10001', '10001', '10101', '10101', '11011', '10001'],
31  X: ['10001', '10001', '01010', '00100', '00100', '01010', '10001', '10001'],
32  Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100', '00100'],
33  Z: ['11111', '00001', '00010', '00100', '00100', '01000', '10000', '11111'],
34  0: ['01110', '10001', '10001', '10001', '10001', '10001', '10001', '01110'],
35  1: ['00100', '01100', '00100', '00100', '00100', '00100', '00100', '01110'],
36  2: ['01110', '10001', '00001', '00010', '00100', '01000', '10000', '11111'],
37  3: ['11111', '00010', '00100', '01110', '00001', '00001', '10001', '01110'],
38  4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010', '00010'],
39  5: ['11111', '10000', '10000', '11110', '00001', '00001', '10001', '01110'],
40  6: ['00110', '01000', '10000', '11110', '10001', '10001', '10001', '01110'],
41  7: ['11111', '00001', '00001', '00010', '00100', '01000', '01000', '01000'],
42  8: ['01110', '10001', '10001', '01110', '10001', '10001', '10001', '01110'],
43  9: ['01110', '10001', '10001', '10001', '01111', '00001', '00010', '01100'],
44}
45const LETTERS = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ', DIGITS = '0123456789', TENS = '012345'
46
47// Whole pixels everywhere, so the board stays crisp at 1x. The glyph sits on the text baseline.
48const GY = 6                  // glyph rows 6-9 above the seam
49const SEAM = GY + 4           // the seam: one empty pixel row at y=10
50const TOP = GY - 2, FLAP = 6  // upper flap y 4-10, lower flap y 11-17
51const CELLS = 8
52
53// The fold, in discrete frames (scaled pixel art would blur): the upper flap falls, then lands on the lower half
54const FRAME = 0.05, STAGGER = 0.03, LEAD = 0.05   // seconds: one half-fold, the left-to-right cascade, the pause before it
55// Every animation starts at load and plays once: discrete keyTimes, then it holds its last value.
56// The resting attributes are the final board, so anything that ignores SMIL still shows the right word.
57const ms = t => `${Math.round(t * 1000)}ms`
58function once(values, times, tag = 'animate attributeName="opacity"') {
59  const dur = times[times.length - 1] + 0.02
60  return `<${tag} values="${values.join(';')}" keyTimes="${times.map(t => +(t / dur).toFixed(4)).join(';')}" calcMode="discrete" dur="${ms(dur)}" fill="freeze"/>`
61}
62const shown = (t0, t1) => once([0, 1, 0], [0, t0, t1])
63const shownUntil = t => once([1, 0], [0, t])
64const hiddenUntil = t => once([0, 1], [0, t])
65
66const half = (ch, lower) => lower ? FONT[ch].slice(4) : FONT[ch].slice(0, 4)
67const or = (a, b) => [...a].map((c, i) => c === '1' || b[i] === '1' ? '1' : '0').join('')
68const squash = rows => [or(rows[0], rows[1]), or(rows[2], rows[3])]
69const gx = (ch, x, cw) => x + Math.floor((cw - FONT[ch][0].length) / 2)
70const glyph = (ch, x, cw) => bitmap(half(ch, 0), gx(ch, x, cw), GY, 1) + bitmap(half(ch, 1), gx(ch, x, cw), SEAM + 1, 1)
71
72// The letters a flap passes on its way, in the order they hang on the drum: at most three
73function riffle(from, to, order) {
74  if (from === to) return [to]
75  const a = order.indexOf(from), b = order.indexOf(to), n = order.length
76  const steps = Math.min(3, (b - a + n) % n - 1), seq = [from]
77  for (let k = steps; k >= 1; k--) seq.push(order[(b - k + n) % n])
78  return [...seq, to]
79}
80
81// A row of flap cells, each riffling from its previous character. Returns the markup and when the last flap lands.
82// order is the drum every cell hangs on, or one drum per cell.
83function cells(chars, prev, o, order, stagger = STAGGER) {
84  let body = '', end = 0
85  ;[...chars].forEach((ch, i) => {
86    const x = i * o.pitch, seq = riffle(prev[i] ?? ch, ch, typeof order === 'string' ? order : order[i])
87    if (seq.length === 1) { body += `<path d="${glyph(ch, x, o.cw)}" fill="${o.ink}"/>`; return }
88    const start = LEAD + i * stagger
89    body += `<path d="${glyph(seq[0], x, o.cw)}" fill="${o.ink}" opacity="0">${shownUntil(start)}</path>`
90    for (let k = 0; k + 1 < seq.length; k++) {
91      const from = seq[k], to = seq[k + 1], t = start + k * 2 * FRAME
92      // the upper flap falls: the next letter's top shows behind it, the old top squashed onto the seam
93      const falling = bitmap([half(to, 0)[0]], gx(to, x, o.cw), GY, 1) + bitmap(squash(half(from, 0)), gx(from, x, o.cw), SEAM - 2, 1) +
94        bitmap(half(from, 1), gx(from, x, o.cw), SEAM + 1, 1)
95      // it lands: the new bottom unfolds from the seam over the old one
96      const landing = bitmap(half(to, 0), gx(to, x, o.cw), GY, 1) + bitmap(squash(half(to, 1)), gx(to, x, o.cw), SEAM + 1, 1) +
97        bitmap([half(from, 1)[3]], gx(from, x, o.cw), SEAM + 4, 1)
98      body += `<g opacity="0">${shown(t, t + FRAME)}${o.shade(x, SEAM - 3, 3)}<path d="${falling}" fill="${o.ink}"/></g>`
99      body += `<g opacity="0">${shown(t + FRAME, t + 2 * FRAME)}${o.shade(x, SEAM + 1, 3)}<path d="${landing}" fill="${o.ink}"/></g>`
100    }
101    const done = start + (seq.length - 1) * 2 * FRAME
102    end = Math.max(end, done)
103    body += `<path d="${glyph(ch, x, o.cw)}" fill="${o.ink}">${hiddenUntil(done)}</path>`
104  })
105  return { body, end }
106}
107
108// Flap outlines: square, whole pixels, so the 1px gaps between them stay crisp at 1x
109const flaps = (xs, w, lower) => xs.map(x => `M${x} ${lower ? SEAM + 1 : TOP}h${w}v${FLAP}h${-w}z`).join('')
110
111// A turn is a flight
112function word(s) {
113  if (s.mode === 'requesting') return 'CHECK IN'
114  if (s.mode === 'thinking') return 'BOARDING'
115  if (s.mode === 'responding') return 'LANDING'
116  if (s.mode === 'done') return 'ARRIVED'
117  // the whole tool phase is one leg of the flight; only a failure changes the board
118  return s.result === 'error' ? 'DELAYED' : 'EN ROUTE'
119}
120
121// The board only moves when its word changes. The same word returns the same markup, so a redraw never replays the riffle.
122// o: { cw cell width, pitch, inset, ink, shade(x, y, h) under a moving flap, back(width, cellXs) behind the cells }
123function board(o) {
124  let last = null
125  return s => {
126    const w = word(s).padEnd(CELLS, ' ')
127    if (last && last.word === w) return last.markup
128    const prev = !last || (s.mode === 'requesting' && last.word !== w) ? ' '.repeat(CELLS) : last.word
129    const { body, end } = cells(w, prev, o, LETTERS)
130    // a delay lands with one hard clack: the row drops a pixel and settles
131    const clack = s.result === 'error' && end
132      ? once(['0 0', '0 1', '0 0'], [0, end, end + 0.08], 'animateTransform attributeName="transform" type="translate"') : ''
133    // once BOARDING lands it flashes in place, like the status column of a real board; the fold itself never blinks.
134    // Chrome holds a discrete value from its keyTime to the next, so the dim value needs a keyTime before 1.
135    const flash = s.mode === 'thinking'
136      ? `<animate attributeName="opacity" values="1;0.35" keyTimes="0;0.6" calcMode="discrete" dur="1.4s" begin="${ms(end)}" repeatCount="indefinite"/>` : ''
137    const width = (CELLS - 1) * o.pitch + o.cw + 2 * o.inset
138    const xs = Array.from({ length: CELLS }, (_, i) => o.inset + i * o.pitch)
139    last = { word: w, markup: svg(width, `<g>${clack}${o.back(width, xs)}<g transform="translate(${o.inset} 0)">${flash}${body}</g></g>`) }
140    return last.markup
141  }
142}
143
144// Elapsed time as the board's time column: MM:SS, or H:MM:SS past an hour, in your text color beside the board.
145// The seconds flap folds once a second; tens of minutes and seconds hang on a 0-5 drum, so 59 folds straight to 00.
146const two = n => String(n).padStart(2, '0')
147const clock = sec => { const t = Math.max(0, Math.floor(+sec || 0)), h = Math.floor(t / 3600), m = Math.floor(t / 60) % 60
148  return h ? `${h}:${two(m)}:${two(t % 60)}` : `${two(m)}:${two(t % 60)}` }
149function flapClock(o) {
150  let last = null
151  return s => {
152    const text = clock(s.elapsed)
153    if (last && last.text === text) return last.markup
154    const before = clock(Math.floor(+s.elapsed || 0) - 1), prev = before.length === text.length && s.elapsed >= 1 ? before : text
155    const groups = text.split(':')
156    let x = 0, at = 0, body = '', colons = ''
157    groups.forEach((g, gi) => {
158      if (gi) { colons += `M${x + 1} ${GY + 2}h1v1h-1zM${x + 1} ${SEAM + 2}h1v1h-1z`; x += 3; at += 1 }
159      const hours = groups.length === 3 && gi === 0
160      const drums = [...g].map((_, i) => !hours && i === 0 && g.length === 2 ? TENS : DIGITS)
161      body += `<g transform="translate(${x} 0)">${cells(g, prev.slice(at, at + g.length), o, drums, 0).body}</g>`
162      x += (g.length - 1) * o.pitch + o.cw
163      at += g.length
164    })
165    last = { text, markup: svg(x, `<path d="${colons}" fill="${o.ink}"/>` + body) }
166    return last.markup
167  }
168}
169
170// The looks differ only in what the flaps are made of
171const PLATFORM = { cw: 7, pitch: 8, inset: 0, ink: 'currentColor',
172  shade: (x, y, h) => `<rect x="${x}" y="${y}" width="7" height="${h}" fill="currentColor" opacity="0.16"/>`,
173  // Two tones, like the night board: the upper flap catches a little more light, so the split reads at rest
174  back: (width, xs) => `<path d="${flaps(xs, 7, 0)}" fill="currentColor" opacity="0.2"/><path d="${flaps(xs, 7, 1)}" fill="currentColor" opacity="0.15"/>` }
175
176// The one look with its own colors: the board at night, a small dark object like the approved little screen
177const NIGHT = { upper: '#171717', lower: '#202020', gap: '#060606', ink: '#ecebe5', moving: '#0e0e0e' }
178const ARRIVALS = { cw: 7, pitch: 8, inset: 1, ink: NIGHT.ink,
179  shade: (x, y, h) => `<rect x="${x}" y="${y}" width="7" height="${h}" fill="${NIGHT.moving}"/>`,
180  back: (width, xs) => `<rect x="0" y="${TOP - 1}" width="${width}" height="${2 * FLAP + 3}" rx="2" fill="${NIGHT.gap}"/>` +
181    `<path d="${flaps(xs, 7, 0)}" fill="${NIGHT.upper}"/><path d="${flaps(xs, 7, 1)}" fill="${NIGHT.lower}"/>` }
182// Both looks share this clock: plain flap digits in your text color (beside the night board, the line still has one dark object)
183const CLOCK = { cw: 5, pitch: 6, ink: 'currentColor',
184  shade: (x, y, h) => `<rect x="${x}" y="${y}" width="5" height="${h}" fill="currentColor" opacity="0.12"/>` }
185
186const LOOKS = [
187  { name: 'platform', label: 'Classic', note: 'split letters on faint flap tiles, with a flap clock', art: board(PLATFORM), tail: flapClock(CLOCK) },
188  { name: 'arrivals', label: 'Night', note: 'the board after dark, with a flap clock', art: board(ARRIVALS), tail: flapClock(CLOCK) },
189]
190
191export default {
192  key: 'splitflap',
193  title: 'Now Departing',
194  blurb: 'A turn is a flight: check-in, boarding, en route, landing, arrived, and delayed when a tool fails. The board riffles only when the word changes.',
195  looks: LOOKS,
196}
197
themes/sandglass.js 149 lines
1// Please Wait: the one-bit hourglass wait cursor of late-80s and early-90s desktops, drawn on its own pixel grid.
2// Every pixel is a 1px square at an integer position, in currentColor; all motion is discrete frames.
3import { svg } from './kit.js'
4
5const grid = rows => rows.flatMap((r, y) => [...r].flatMap((c, x) => (c === '1' ? [[x, y]] : [])))
6// pixels -> path data, merged into horizontal runs
7function ink(pixels, ox = 0, oy = 0) {
8  const rows = new Map()
9  for (const [x, y] of pixels) rows.set(y, [...(rows.get(y) || []), x])
10  let d = ''
11  for (const [y, xs] of [...rows].sort((a, b) => a[0] - b[0])) {
12    xs.sort((a, b) => a - b)
13    let start = xs[0]
14    for (let i = 1; i <= xs.length; i++) {
15      if (xs[i] === xs[i - 1] + 1) continue
16      const n = xs[i - 1] - start + 1
17      d += `M${ox + start} ${oy + y}h${n}v1h${-n}z`
18      start = xs[i]
19    }
20  }
21  return d
22}
23const pix = (pixels, ox, oy, op = 1) => pixels.length ? `<path d="${ink(pixels, ox, oy)}" fill="currentColor"${op < 1 ? ` opacity="${op}"` : ''}/>` : ''
24const t4 = n => +n.toFixed(4)
25
26// Show content only during spans [[t0, t1], ...] of a T-second timeline, as a discrete opacity animation.
27// Loops rest on their first frame and one-shots on their last, so a still rasterizer draws a sensible frame.
28function show(content, spans, T, loop = true) {
29  if (!content) return ''
30  const at = new Map([[0, 0]])
31  for (const [a, b] of spans) { at.set(t4(a), 1); if (b < T - 1e-6) at.set(t4(b), 0) }
32  const vals = [], keys = []
33  for (const k of [...at.keys()].sort((a, b) => a - b)) {
34    if (vals.length && vals[vals.length - 1] === at.get(k)) continue
35    vals.push(at.get(k)); keys.push(k)
36  }
37  if (vals.length === 1) return vals[0] ? content : ''
38  const rest = loop ? vals[0] : vals[vals.length - 1]
39  return `<g opacity="${rest}"><animate attributeName="opacity" values="${vals.join(';')}" keyTimes="${keys.map(k => t4(k / T)).join(';')}" dur="${t4(T)}s" calcMode="discrete" ${loop ? 'repeatCount="indefinite"' : 'fill="freeze"'}/>${content}</g>`
40}
41// A sequence of frames, each [content, seconds]
42function frames(list, loop = true) {
43  const T = list.reduce((a, [, d]) => a + d, 0)
44  let t = 0, out = ''
45  for (const [content, d] of list) { out += show(content, [[t, t + d]], T, loop); t += d }
46  return out
47}
48// The 1px shake of a stuck cursor: three times right and left, about 70ms a step, then rest
49const SHAKE_S = 0.5
50const SHAKE = `<animateTransform attributeName="transform" type="translate" values="1 0;-1 0;1 0;-1 0;1 0;-1 0;0 0" dur="${SHAKE_S}s" calcMode="discrete" fill="freeze"/>`
51const crisp = body => `<g shape-rendering="crispEdges">${body}</g>`
52
53// ---- sandglass: an 11x15 one-bit hourglass ----
54const GLASS_ROWS = [
55  '11111111111',
56  '01111111110',
57  '01000000010',
58  '01000000010',
59  '00100000100',
60  '00010001000',
61  '00001010000',
62  '00001010000',
63  '00001010000',
64  '00010001000',
65  '00100000100',
66  '01000000010',
67  '01000000010',
68  '01111111110',
69  '11111111111',
70]
71const GW = 11, GH = 15, GLASS = grid(GLASS_ROWS)
72// Sand leaves the top centre first, a symmetric pair at a time, and piles up on the floor as a mound
73const TOP = [[[5, 3]], [[4, 3], [6, 3]], [[3, 3], [7, 3]], [[2, 3], [8, 3]], [[5, 4]], [[4, 4], [6, 4]], [[3, 4], [7, 4]], [[5, 5]], [[4, 5], [6, 5]]]
74const BOTTOM = [[[5, 12]], [[4, 12], [6, 12]], [[5, 11]], [[3, 12], [7, 12]], [[4, 11], [6, 11]], [[5, 10]], [[2, 12], [8, 12]], [[3, 11], [7, 11]], [[4, 10], [6, 10]]]
75const FULL_TOP = TOP.flat(), FULL_BOTTOM = BOTTOM.flat()
76const NECK = 5, FALL = [6, 7, 8, 9, 10, 11, 12] // the grain's column and rows, neck to floor
77const SAND_W = 15, UX = 2, UY = 2 // upright glass in a 15px slot; the turned glass is 15x11
78const SX = 0, SY = UY + (GH - GW) / 2
79const GLASS_OP = 0.8
80const turn = ([x, y]) => [GH - 1 - y, x] // 90 deg clockwise
81const upright = sand => pix(GLASS, UX, UY, GLASS_OP) + pix(sand, UX, UY)
82const sideways = sand => pix(GLASS.map(turn), SX, SY, GLASS_OP) + pix(sand.map(turn), SX, SY)
83const FLIP = 0.09
84
85// Knocked over: the glass lies on its side on the same floor as the upright one, and the sand it held
86// mid-run (8 grains above, 7 below) has slumped flat against the lower wall of each bulb. In the 15x11 turned frame.
87const FALLEN_Y = UY + GH - GW
88const SLUMPED = [[11, 8], [12, 8], [10, 7], [11, 7], [12, 7], [10, 6], [11, 6], [12, 6],
89  [2, 8], [3, 8], [2, 7], [3, 7], [4, 7], [2, 6], [3, 6]]
90const fallen = pix(GLASS.map(turn), SX, FALLEN_Y, GLASS_OP) + pix(SLUMPED, SX, FALLEN_Y)
91
92// One grain falling the length of the neck, 1px per step; the floor heap covers it where it lands
93function grain(step, period) {
94  return FALL.map((r, i) => show(pix([[NECK, r]], UX, UY), [[i * step, (i + 1) * step]], period)).join('')
95}
96// A dotted stream, 1 on and 2 off, marching down 1px per step
97function stream(step) {
98  let out = ''
99  for (let k = 0; k < 3; k++) out += show(pix(FALL.filter(r => (r - FALL[0]) % 3 === k).map(r => [NECK, r]), UX, UY), [[k * step, (k + 1) * step]], 3 * step)
100  return out
101}
102// The glass drains one step every `each` seconds while `falling` runs (a loop of `period` seconds),
103// rests with all the sand below, then turns over; the cycle is a whole number of periods so it loops seamlessly
104function drain(each, falling, period) {
105  const run = TOP.length * each, T = +(Math.ceil((run + 0.25 + FLIP) / period - 1e-9) * period).toFixed(4)
106  let out = show(pix(GLASS, UX, UY, GLASS_OP), [[0, T - FLIP]], T)
107  TOP.forEach((p, i) => { out += show(pix(p, UX, UY), [[0, (i + 1) * each]], T) })
108  BOTTOM.forEach((p, i) => { out += show(pix(p, UX, UY), [[(i + 1) * each, T - FLIP]], T) })
109  out += show(falling, [[0, run]], T)
110  out += show(sideways(FULL_BOTTOM), [[T - FLIP, T]], T)
111  return out
112}
113
114function sandglass(s) {
115  let body
116  if (s.mode === 'requesting') {
117    // The wait cursor turning over and over: a 90 deg frame, then a rest with the sand where it fell
118    body = frames([[upright(FULL_TOP), 1], [sideways(FULL_TOP), 0.17], [upright(FULL_BOTTOM), 1], [sideways(FULL_BOTTOM), 0.17]])
119  } else if (s.mode === 'thinking') {
120    body = drain(5, grain(0.08, 1), 1)
121  } else if (s.mode === 'responding') {
122    body = drain(1.5, stream(0.12), 0.36)
123  } else if (s.mode === 'done') {
124    body = upright(FULL_BOTTOM)
125  } else if (s.result === 'error') {
126    // Jammed: the sand stops with one grain caught at the mouth of the neck, the glass rattles, then tips over and stays down
127    const jammed = `<g>${SHAKE}${upright([...TOP.slice(4).flat(), ...BOTTOM.slice(0, 4).flat(), [NECK, 9]])}</g>`
128    body = frames([[jammed, SHAKE_S], [fallen, FLIP]], false)
129  } else if (s.result !== undefined) {
130    // The job is done: the glass turns over, sand back on top for the next one
131    body = frames([[upright(FULL_BOTTOM), FLIP], [sideways(FULL_BOTTOM), FLIP], [upright(FULL_TOP), FLIP]], false)
132  } else {
133    // A tool is running: a fast dotted pour, but a slow enough drain that most tools end before the glass needs turning
134    body = drain(1, stream(0.06), 0.18)
135  }
136  return svg(SAND_W, crisp(body))
137}
138
139const LOOKS = [
140  { name: 'sandglass', label: 'Hourglass', note: 'the sand runs while it works; the glass turns over when a tool succeeds and tips over when one fails', art: sandglass },
141]
142
143export default {
144  key: 'sandglass',
145  title: 'Please Wait',
146  blurb: 'The one-bit wait cursor, on its own pixel grid. Sand trickles while it thinks, pours through tools, and the glass turns over when a job is done.',
147  looks: LOOKS,
148}
149
themes/floppy.js 51 lines
1// Floppy Drive: original monochrome art, approved 2026-10-02.
2// State-only SVG markup preserves the animation clock across desktop updates.
3import { svg } from './kit.js'
4
5function phase(s) {
6  if (s.mode !== 'tool-use') return s.mode
7  if (s.result === 'error') return 'error'
8  if (typeof s.result === 'number') return 'success'
9  return 'running'
10}
11
12const line = body => `<g fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">${body}</g>`
13const move = (values, duration) => `<animateTransform attributeName="transform" type="translate" values="${values}" dur="${duration}s" repeatCount="indefinite"/>`
14const pulse = (values, duration) => `<animate attributeName="opacity" values="${values}" dur="${duration}s" repeatCount="indefinite"/>`
15const shake = `<animateTransform attributeName="transform" type="translate" values="0 0;0.7 0;-0.7 0;0.7 0;0 0" dur="0.4s" fill="freeze"/>`
16
17function floppy(s) {
18  const p = phase(s), idle = p === 'done' || p === 'success' || p === 'error'
19  const shutter = p === 'thinking' ? move('0 0;1.5 0;0 0;0 0', 2.8)
20    : idle ? '' : move('0 0;1.5 0;1.5 0;0 0;0 0', p === 'running' ? 1.2 : 2.4)
21  const label = p === 'success' ? '<path d="M5.8 13.5l2.7 2.6 4.5-5"/>'
22    : p === 'error' ? '<path d="m6.3 11.5 6 5m0-5-6 5"/>'
23      : '<path d="M5.8 12.5h7.2m-7.2 2.6h4.8" opacity="0.6"/>'
24  const disk = line('<path d="M1.5 1.5H14l3.5 3.5v13.5h-16z"/>' +
25    `<g>${shutter}<path d="M4 1.5V7h8V1.5M9.5 3v2.5"/></g>` +
26    '<path d="M4 18.5v-9h11v9" opacity="0.65"/>' + label)
27  const lamp = p === 'error' ? line('<path d="m46.5 12.5 4 4m0-4-4 4"/>')
28    : `<circle cx="48.5" cy="14.5" r="1.25" fill="currentColor" opacity="${p === 'done' ? 0.22 : 1}">${idle ? '' : pulse('1;0.2;1', p === 'thinking' ? 2.8 : 1.2)}</circle>`
29  const bits = idle ? '' : [0, 1, 2].map(i => {
30    const order = p === 'responding' ? 2 - i : i
31    const values = [0.14, 0.14, 0.14, 0.14]
32    values[order] = 1
33    values[3] = values[0]
34    return `<circle cx="${20.5 + i * 2.6}" cy="9.5" r="0.8" fill="currentColor" opacity="0.14">${pulse(values.join(';'), p === 'thinking' ? 2.8 : 1.2)}</circle>`
35  }).join('')
36  return svg(54, `<g>${p === 'error' ? shake : ''}${disk}</g>` + bits +
37    line('<rect x="28.5" y="5.5" width="24" height="12.5" rx="1.4"/><path d="M31.5 9h18m-18 2h18" opacity="0.7"/><path d="M32 14.5h6" opacity="0.45"/>') + lamp)
38}
39
40export default {
41  key: "floppy",
42  title: "Floppy Drive",
43  blurb: "A floppy shutter seeks while data hops across to the drive. Completed jobs mark the disk with a check; failures leave an X.",
44  looks: [{
45    name: "floppy",
46    label: "Floppy Drive",
47    note: "a seeking shutter, directional data dots, and a drive activity light",
48    art: floppy,
49  }],
50}
51
themes/dotmatrix.js 67 lines
1// Dot Matrix: original monochrome art, approved 2026-10-02.
2// State-only SVG markup preserves the animation clock across desktop updates.
3import { svg } from './kit.js'
4
5function phase(s) {
6  if (s.mode !== 'tool-use') return s.mode
7  if (s.result === 'error') return 'error'
8  if (typeof s.result === 'number') return 'success'
9  return 'running'
10}
11
12const line = body => `<g fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">${body}</g>`
13const move = (values, duration) => `<animateTransform attributeName="transform" type="translate" values="${values}" dur="${duration}s" repeatCount="indefinite"/>`
14const pulse = (values, duration) => `<animate attributeName="opacity" values="${values}" dur="${duration}s" repeatCount="indefinite"/>`
15const shake = `<animateTransform attributeName="transform" type="translate" values="0 0;0.7 0;-0.7 0;0.7 0;0 0" dur="0.4s" fill="freeze"/>`
16
17// One common period per state fits the terminal's 10fps, 3.2s loop budget.
18const DURATION = { requesting: 3.2, thinking: 3, running: 1.4, responding: 2 }
19
20// Perforations and the dot rows are part of the moving paper, not a blinking facade.
21function printerPaper(p) {
22  const feed = p === 'thinking' ? 0 : DURATION[p]
23  let marks = ''
24  for (let y = -2; y < 16; y += 3) {
25    marks += `<rect x="5.5" y="${y}" width="1.4" height="1.2" rx="0.3"/><rect x="38.1" y="${y}" width="1.4" height="1.2" rx="0.3"/>`
26    if (p !== 'success' && p !== 'error') {
27      for (let x = 11; x < 33; x += 2) {
28        // The ink repeats at the same 3px pitch as the tractor holes. Translating
29        // a complete row is visually identical to the start of the next loop.
30        if (((x - 11) / 2) % 5 !== 2) marks += `<rect x="${x}" y="${y + 0.2}" width="1" height="0.9" opacity="0.6"/>`
31      }
32    }
33  }
34  // Feed only after the carriage returns; the spatial repetition hides the reset.
35  const advance = feed ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 0;0 -3" keyTimes="0;0.88;1" dur="${feed}s" repeatCount="indefinite"/>` : ''
36  return `<g clip-path="url(#dotmatrix-paper)"><g fill="currentColor">${advance}${marks}</g></g>`
37}
38
39function dotmatrix(s) {
40  const p = phase(s)
41  const active = !['done', 'success', 'error'].includes(p)
42  // Mechanical carriage: print left to right, return, then pause for the paper feed.
43  const headMotion = !active ? '' : p === 'thinking' ? move('0 0;3 0;0 0;0 0', DURATION[p])
44    : `<animateTransform attributeName="transform" type="translate" values="0 0;20 0;20 0;0 0;0 0" keyTimes="0;0.65;0.72;0.88;1" dur="${DURATION[p]}s" repeatCount="indefinite"/>`
45  const sheet = p === 'error' ? '<path d="M4.5 11.5V1.5h35v10M10 8l6 2 6-2 6 2 6-2"/>'
46    : '<path d="M4.5 11.5V1.5h35v10M9 1.5v10m26-10v10"/>'
47  const result = p === 'success' ? line('<path d="m18 5.8 3 3 6-6" stroke-width="1.6"/>')
48    : p === 'error' ? line('<path d="m19.5 2.8 6 4.7m0-4.7-6 4.7" stroke-width="1.5"/>') : ''
49  return svg(45, '<defs><clipPath id="dotmatrix-paper"><rect x="5" y="2" width="34" height="8.5"/></clipPath></defs>' +
50    `<g>${p === 'error' ? shake : ''}${line(sheet)}${printerPaper(p)}${result}</g>` +
51    line('<path d="M4.5 10.5H2l-1 3v5h43v-5l-1-3h-3.5M1.5 14h42M7 17h5m21 0h5"/>') +
52    `<g>${headMotion}<rect x="10" y="10" width="4.5" height="4" rx="0.6" fill="currentColor"/><path d="M12.2 9v2" stroke="currentColor" stroke-width="1.3"/></g>` +
53    `<circle cx="40.5" cy="16.5" r="0.75" fill="currentColor" opacity="${p === 'done' ? 0.3 : 1}">${active ? pulse('1;0.35;1', DURATION[p]) : ''}</circle>`)
54}
55
56export default {
57  key: "dotmatrix",
58  title: "Dot Matrix",
59  blurb: "The carriage crosses tractor paper as dotted lines feed upward. A completed job prints a check; a failed one jams the sheet.",
60  looks: [{
61    name: "dotmatrix",
62    label: "Dot Matrix",
63    note: "a scanning print head and paper that feeds between passes",
64    art: dotmatrix,
65  }],
66}
67
themes/rally.js 57 lines
1// One More Rally: a miniature table-tennis court. Approved from the browser proposals.
2// Geometry is shared by every state; SMIL owns time so redraws never restart a rally.
3import { svg } from './kit.js'
4
5const state = s => s.mode === 'tool-use'
6  ? s.result === 'error' ? 'error' : typeof s.result === 'number' ? 'success' : 'running'
7  : s.mode
8const animate = (attribute, values, duration, once = false, extra = '') =>
9  `<animate attributeName="${attribute}" values="${values}" dur="${duration}s" ${once ? 'fill="freeze"' : 'repeatCount="indefinite"'} ${extra}/>`
10const court = '<path d="M1 2h34M1 18h34" fill="none" stroke="currentColor" stroke-width="0.7" opacity="0.4"/>' +
11  '<path d="M18 3v2m0 2v2m0 2v2m0 2v2" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2"/>'
12const paddle = (x, y, motion = '') => `<rect x="${x}" y="${y}" width="2" height="5" rx="0.35" fill="currentColor">${motion}</rect>`
13const ball = (x, y, motion = '') => `<rect x="${x}" y="${y}" width="2" height="2" fill="currentColor">${motion}</rect>`
14const tick = '<path d="M15 10l2 2 4-5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>'
15
16function rally(s) {
17  const phase = state(s)
18  let action
19  if (phase === 'done') {
20    action = paddle(3, 7.5) + paddle(31, 7.5) + ball(17, 9)
21  } else if (phase === 'requesting') {
22    // The ball waits for a serve while both paddles stay still.
23    action = paddle(3, 7.5) + paddle(31, 7.5) + ball(17, 9, animate('opacity', '1;0.2;1', 1.6))
24  } else if (phase === 'error') {
25    // A real miss: the right paddle stays low as the ball passes above it and exits.
26    // The resting markup is the final frame, including an invisible departed ball.
27    const travel = animate('x', '5;34;35', 0.7, true, 'keyTimes="0;0.88;1"') +
28      animate('y', '12;4;4', 0.7, true, 'keyTimes="0;0.88;1"')
29    action = paddle(3, 11) + paddle(31, 11) +
30      `<g opacity="0">${animate('opacity', '1;1;0', 0.7, true, 'keyTimes="0;0.9;1"')}${ball(35, 4, travel)}</g>` +
31      `<g opacity="1">${animate('opacity', '0;0;1', 0.8, true, 'keyTimes="0;0.875;1"')}<path d="M15 7l6 6m0-6l-6 6" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></g>`
32  } else if (phase === 'success') {
33    // A clean return contacts the right paddle at 0.3s, then becomes a check.
34    action = paddle(3, 7.5) + paddle(31, 7.5) +
35      `<g opacity="0">${animate('opacity', '1;1;0', 0.6, true, 'keyTimes="0;0.833333;1"')}${ball(17, 9, animate('x', '17;29;17', 0.6, true))}</g>` +
36      `<g opacity="1">${animate('opacity', '0;0;1', 0.7, true, 'keyTimes="0;0.857143;1"')}${tick}</g>`
37  } else {
38    // Full periods fit the 10fps terminal frame grid and stay below its 3.2s cap.
39    const period = { thinking: 2.8, running: 1.4, responding: 2.1 }[phase] ?? 2.8
40    // At t=0 and t=period the ball touches the left paddle at its centre;
41    // at t=period/2 it touches the right. The lower bounce stays inside the rail.
42    action = paddle(3, 3, animate('y', '3;11;3', period)) +
43      paddle(31, 11, animate('y', '11;3;11', period)) +
44      ball(5, 4.5, animate('x', '5;29;5', period) + animate('y', '4.5;15.5;4.5;15.5;4.5', period))
45  }
46  return svg(36, court + action)
47}
48
49export default {
50  key: 'rally',
51  title: 'One More Rally',
52  blurb: 'Two paddles trade a ball while it works. A successful tool makes its return; a failed one misses the paddle and leaves the court.',
53  looks: [
54    { name: 'rally', label: 'Rally', note: 'a miniature court; clean return on success, a missed shot on failure', art: rally },
55  ],
56}
57