SLOPSHOPPER

mascot

Publishes a pixel Clawd acting out what Claude is doing right now, for the desktop bar to paint.

newguardtimer
★ 1v0.1.0MITupdated 2026-10-15isneru/flake/modules/claude/mascot
A shopper browsing a rack in a slop shop
README

flake

NixOS + Home Manager configuration for victus, structured as a dendritic flake-parts tree: every .nix file under modules/ is a self-contained, self-registering module, auto-discovered by import-tree - there's no manual imports = [ ... ] list anywhere in modules/. A feature that spans both layers (e.g. Hyprland: compositor enable at the NixOS level, config + theme registration at the Home Manager level) declares both in one file instead of being split across a system/user directory boundary.

Hyprland under UWSM, SDDM wearing the same face as the lock screen, and a Quickshell shell that owns the desktop's notifications, lock screen, polkit prompts, credential prompts, screen-share picker and tray. It is deliberately plain: one status line along the bottom, a themed wmenu for every choice, and flat cards only where something needs an answer. The Omen key switches the whole desktop between a flat look (square, translucent, blurred, still layers) and a soft one (rounded, opaque, animated layers). Colours are runtime, not build-time: theme-set <name> repaints almost everything without a rebuild.

Structure

flake.nix                                      # entry point: inputs + flake-parts + import-tree ./modules
hosts/victus/                                  # host identity + hardware - not a pooled aspect
  default.nix                                  # hostName, networking, users, GPU workarounds
  hardware.nix                                 # nixos-generate-config output
modules/                                       # one directory per aspect: <name>/<name>.nix
                                               # plus whatever files it reads or symlinks
  flake/                                       # the flake's own assembly, not aspects
    system.nix                                 # nixosConfigurations.victus assembly
    checks.nix                                 # theme-render check (see below)
    formatter.nix                              # treefmt (nixfmt, deadnix, stylua, shfmt, keep-sorted)
    apps.nix, lib.nix                          # nix run .#<app> standalone previews
  boot/, locales/, nix/, coredump/, secrets/   # nixos-only aspects
  desktop/                                     # portals, power, bluetooth
  networking/                                  # NetworkManager, BBR, wireshark
  virtualisation/                              # libvirt + virt-manager (the Proxmox lab VM)
  sddm/                                        # SDDM (Xorg-hosted greeter) wearing a theme
                                               # built from the lock screen's own QML
  auth/                                        # bare-askpass: ssh, git and sudo prompts
                                               # routed into the shell (nixos + homeManager)
  screencast/                                  # bare-share-picker: the screen-share
                                               # picker xdg-desktop-portal-hyprland raises
  hyprland/                                    # compositor: nixos enable + homeManager
                                               # config (Lua, live-edited via symlink)
  theme-engine/                                # runtime theming system - theme-set CLI,
                                               # theme_engine.py, themes/*.toml
  quickshell/                                  # the shell (QML) - see below, plus its
                                               # lock screen's PAM service and hypridle
  bare/                                        # the menus: bare-menu (themed wmenu,
                                               # patched for image previews), bare-pick
                                               # (the menus), bare-look (flat / soft)
  gaming/                                      # RTX 3050 Ti under PRIME offload, Steam
                                               # and gamescope, the gamerun wrapper
  calendar/                                    # Google Calendar sync (gcal CLI + timer)
                                               # feeding the agenda, and course deadlines
                                               # pushed from Markdown files on save
  audio/, files/, localsend/                   # remaining service aspects
  cli/, packages/, appearance/, cursor/,
  icons/, scripts/                             # shell/fonts/cursor/icons/ad-hoc packages,
                                               # writeShellApplication wrappers
  neovim/                                      # nixvim, and the $EDITOR/$VISUAL contract
  music/                                       # mpd + rmpc, local files, MPRIS into the shell
  kitty/, browser/, gtk/, qt/, starship/,
  vesktop/                                     # remaining homeManager-only aspects
  utils/                                       # exposes `utils` module arg
                                               # (dotfiles path + symlink helper)

The shell

modules/quickshell/ is a Quickshell/QML shell with one status line along the bottom: workspaces, the focused app (or qutebrowser's and yazi's own status line while they have focus), and on the right whatever is going on - a recording, a transfer, a download, the next event, the timer, muted, do not disturb, night light, the mic in use - then weather, battery, network and the clock. Every segment is clickable. It owns the desktop's notification server, lock screen, polkit agent, screen-share picker and system tray, and every part of it is backed by a real service - PipeWire, MPRIS, UPower, NetworkManager, bluez, cliphist, StatusNotifier, open-meteo, PAM, polkit, xdg-desktop-portal, Hyprland's IPC - with nothing mocked.

  • Choices are menus. bare-pick <source> opens a themed wmenu for each of them: apps (or a web search), windows, clipboard, wallpaper, theme, media controls, power and profiles, Wi-Fi, Bluetooth, audio, displays, calendar, course deadlines, notes, the timer, the files shelf, LocalSend, the tray (an app, then its own right-click menu), and a calculator REPL. SUPER+B lists them all.
  • wmenu is patched to draw an image preview beside the list, to centre a list as a box, and to show a transcript without filtering it.
  • Things that need an answer are flat cards: password prompts, the screen-share picker and incoming files. The lock screen has its own face. Notifications and OSDs draw above fullscreen windows; there is no notification history.
  • Things with nothing to pick are popups above the bar: the clock, the weather, battery details, the system monitor, network stats. SUPER+? opens a cheatsheet card.
shell.qml       # ShellRoot: the bar per screen, the OSD and cards, the session lock, every IPC target
singletons/     # state, theme tokens, and the service wrappers
bare/           # the surfaces: bar, OSD, popup, prompt cards, cheatsheet
lock/           # the session lock surface and its face (also the SDDM greeter's)
components/     # the icon glyph the Moodle app shares

Driven by keybinds or over IPC:

KeysAction
SUPER+Bevery menu
SUPER+spacelauncher, or ?query to search
Calculatorcalculator
SUPER+V / SUPER+SHIFT+Vclipboard / files shelf
SUPER+Awallpaper
SUPER+SHIFT+Ttimer
SUPER+M / SUPER+Omedia controls / windows
SUPER+Escapepower and profiles
SUPER+Ttray
SUPER+?keybinding cheatsheet
SUPER+CTRL+Llock
Omenthe flat look and the soft one
SUPER+DDiscord and music pad
bare-pick                         # list every menu
bare-pick wifi                    # open one from a terminal
quickshell ipc call bare osd vol
quickshell ipc call look toggle
quickshell ipc call timer start 300

QML is symlinked from the repo, so edits apply on save with no rebuild.

Theming

The theme engine (theme-set <name>) renders each app's config from a template and reloads it in place. Hyprland, kitty, Neovim, vesktop, rmpc, GTK, Qt, SDDM and the shell all follow it without a rebuild.

A theme is a palette and a font, nothing else - no wallpaper, no radius, no light/dark flag. The wallpaper is your own pick (theme-set wallpaper <path>, or the wallpaper menu) and persists across theme switches, re-tinted to each theme's accent unless you pass --no-recolor. The wallpaper menu also lists Gustave Doré's engravings, which are always dithered to two tones of the current theme.

Adding a theme means dropping a .toml into modules/theme-engine/themes/; just check renders every theme against every template and fails on a missing key or a leftover {{token}}.

Installing on a new machine

The flake describes one machine (victus) and one user (neru). To install it somewhere else, rename those and delete the parts that only make sense here.

  1. Boot the NixOS installer, partition, and mount the target at /mnt.
  2. Clone into the new user's home - it has to be ~/flake, because the shell, Hyprland and the other live-edited configs are symlinks into that checkout: ``sh git clone https://github.com/isneru/flake /mnt/home/<user>/flake cd /mnt/home/<user>/flake ``
  3. Delete what is personal to this machine's owner - each is one directory: ``sh git rm -r modules/{calendar,isep-site,phone,moodle,java,mic,wireguard} modules/packages/personal.nix modules/packages/*-monitor.sh ` and, unless you set them up, modules/secureboot (needs sbctl create-keys and the keys enrolled in firmware) and modules/gaming (needs NVIDIA hybrid graphics). Without calendar and isep-site nothing reads secrets/, so no sops key is needed. modules/whatsapp (a WhatsApp client you link to your own account) and modules/claude (Claude Code skills) are optional - delete them if you do not use them. modules/packages/optional.nix is tools nothing else in the flake calls - keep, trim or empty it to taste; core.nix` beside it is what the shell and scripts need.
  4. Rename the user and the host. Every place that needs it: ``sh git mv hosts/victus hosts/<host> sed -i 's/\bneru\b/<user>/g' hosts/<host>/default.nix modules/flake/system.nix \ modules/sddm/sddm.nix modules/virtualisation/virtualisation.nix sed -i 's/\bvictus\b/<host>/g' hosts/<host>/default.nix modules/flake/system.nix \ modules/neovim/neovim.nix ` If you kept calendar, isep-site or phone, rename in modules/calendar/calendar.nix, modules/isep-site/isep-site.nix and modules/phone/phone.nix too. Other matches for neru (lua/neru/ in Neovim, --neru-surface` in the Vesktop theme) are internal names - leave them.
  5. Replace the hardware file, and edit hosts/<host>/default.nix: timezone, keyboard layout, and drop what belongs to this laptop (the hardware.nvidia.prime bus IDs, i915 and its enable_fbc=0, the nouveau blacklist): ``sh nixos-generate-config --root /mnt --show-hardware-config > hosts/<host>/hardware.nix ``
  6. git add -A (the flake cannot see untracked files), then install and give the user a password (SDDM logs in automatically, but the lock screen and sudo ask for it): ``sh nixos-install --flake .#<host> nixos-enter --root /mnt -c 'passwd <user>' nixos-enter --root /mnt -c 'chown -R <user>:users /home/<user>/flake' ``

Commands

just switch          # nixos-rebuild switch --flake . (needs a password - run it yourself)
just build           # nixos-rebuild build (no activation) - safe to verify a change
just check           # nix flake check
just fmt             # format all files (treefmt)
just update          # update flake inputs, commit the lockfile
just clean           # gc generations older than 7 days, optimise the store
just repair          # verify and repair the Nix store (alias: just fix)
just deploy <host>   # switch a remote host over SSH
just secrets         # decrypt/edit secrets/secrets.yaml with sops
just rotate          # rotate SOPS secrets (secrets/*.yaml)

Run just with no arguments to list all recipes.

Notes

  • Source files carry no comments at all.
  • App configs are symlinked directly from the repo via mkOutOfStoreSymlink (utils.create_symlink), so structural edits take effect immediately; Nix-generated and templated files still need a rebuild or theme-set reapply.
  • nix run github:isneru/flake#kitty (also #quickshell, #btop, #shell) starts either app with this config and a theme of your choice, with no checkout and nothing written to your $HOME.
  • The dGPU is opt-in per launcher: prefix a game with gamerun (or gamerun %command% in Steam). Nothing infers "this is a game", and the launcher deliberately wraps nothing.
  • Screen sharing is the shell's too: xdg-desktop-portal-hyprland is pointed at a picker that draws in the shell. It also absorbs the burst - a Chromium app opens two or three portal sessions for one share, so picking a source used to mean answering the picker two or three times over.
  • Vesktop uses a custom Vencord build patched at build time with userplugins pulled from the vesktop-plugins flake input (isneru/vesktop-custom-plugins).
  • Window frames, the Umber, Vellum, Orpiment and Cinnabar themes and the dithered Doré plates are modelled on athanor by Script Wizards (MIT). Its Hyprland plugin is kept in modules/hyprland/frame-plugin/ with its licence and built against the system's own Hyprland, so it never needs rebuilding by hand; the plates (public domain, sources in plates.toml) are in modules/theme-engine/plates/.
  • Secrets are SOPS-encrypted in secrets/. Rotate with just rotate.
Source 2 files
hooks/register.tsx 222 lines
1import type { EngineInterface, Register, TurnStepChunk } from 'claude-code'
2
3import { drawMiniScene, recolor, type Scene, type Tint } from './scene'
4
5type Family = 'read' | 'edit' | 'bash' | 'web' | 'agent' | 'ask' | 'other'
6type Kind = 'idle' | 'waiting' | 'thinking' | 'writing' | 'drafting' | 'tool' | 'compacting' | 'done' | 'error'
7type Activity = { kind: Kind; family?: Family }
8
9const TICK_MS = 150
10const SETTLE_MS = 4000
11const SLEEP_AFTER_MS = 10 * 60_000
12const HEARTBEAT_MS = 1000
13
14const IDLE: Activity = { kind: 'idle' }
15const WAITING: Activity = { kind: 'waiting' }
16const THINKING: Activity = { kind: 'thinking' }
17const WRITING: Activity = { kind: 'writing' }
18const COMPACTING: Activity = { kind: 'compacting' }
19const DONE: Activity = { kind: 'done' }
20
21const FAMILIES: Record<string, Family> = {
22  Read: 'read',
23  Grep: 'read',
24  Glob: 'read',
25  LS: 'read',
26  LSP: 'read',
27  ToolSearch: 'read',
28  Edit: 'edit',
29  MultiEdit: 'edit',
30  Write: 'edit',
31  NotebookEdit: 'edit',
32  Bash: 'bash',
33  BashOutput: 'bash',
34  KillShell: 'bash',
35  PowerShell: 'bash',
36  Monitor: 'bash',
37  TaskStop: 'bash',
38  WebFetch: 'web',
39  WebSearch: 'web',
40  Agent: 'agent',
41  Task: 'agent',
42  SendMessage: 'agent',
43  AskUserQuestion: 'ask',
44  ExitPlanMode: 'ask',
45}
46
47let activity = IDLE
48let activitySince = 0
49let isActivityStamped = false
50let isTurnActive = false
51let frame = 0
52let callCount = 0
53
54const running = new Map<string, Activity>()
55const bar = { path: undefined as string | undefined, key: '', writtenAt: 0, id: '', tintsPath: '', tintReadAt: 0 }
56let kitty: { pid: number; window: number } | undefined
57let tint: Tint | undefined
58
59const familyOf = (tool: string): Family =>
60  FAMILIES[tool] ?? (/browser|chrome|fetch/i.test(tool) ? 'web' : 'other')
61
62function become(next: Activity): void {
63  if (next.kind !== activity.kind || next.family !== activity.family) isActivityStamped = false
64  activity = next
65}
66
67function stamp(now: number): void {
68  if (!isActivityStamped) {
69    activitySince = now
70    isActivityStamped = true
71  }
72  const isSettling = activity.kind === 'done' || activity.kind === 'error'
73  if (isSettling && now - activitySince > SETTLE_MS) {
74    activity = IDLE
75    activitySince = now
76  }
77}
78
79function sceneAt(now: number): Scene {
80  switch (activity.kind) {
81    case 'idle':
82      return now - activitySince > SLEEP_AFTER_MS ? 'sleep' : 'idle'
83    case 'drafting':
84    case 'tool':
85      return activity.family ?? 'other'
86    case 'done':
87      return 'hop'
88    case 'error':
89      return 'sweat'
90    default:
91      return activity.kind
92  }
93}
94
95function watch(chunk: TurnStepChunk): void {
96  switch (chunk.kind) {
97    case 'engine':
98      if (activity.kind === 'waiting') become(THINKING)
99      break
100    case 'thinking':
101      become(THINKING)
102      break
103    case 'text':
104      become(WRITING)
105      break
106    case 'tool':
107      become({ kind: 'drafting', family: familyOf(chunk.name) })
108      break
109  }
110}
111
112async function publish($: EngineInterface, path: string, now: number, scene: Scene): Promise<void> {
113  const { width, height, pixels } = recolor(drawMiniScene(scene, frame), tint)
114  const fields = { width, height, pixels: Array.from(pixels) }
115  const key = JSON.stringify(fields)
116  if (key === bar.key && now - bar.writtenAt < HEARTBEAT_MS) return
117  bar.key = key
118  bar.writtenAt = now
119  await $.fs.write(path, JSON.stringify({ updatedAt: now, kitty, ...fields }))
120}
121
122async function readTint($: EngineInterface): Promise<Tint | undefined> {
123  try {
124    const entry = JSON.parse(await $.fs.read(bar.tintsPath))[bar.id]
125    return entry && { body: parseInt(entry.body.slice(1), 16), shade: parseInt(entry.shade.slice(1), 16) }
126  } catch {
127    return tint
128  }
129}
130
131async function tick($: EngineInterface, path: string): Promise<void> {
132  const now = await $.clock.now()
133  stamp(now)
134  frame += 1
135  if (now - bar.tintReadAt >= HEARTBEAT_MS) {
136    bar.tintReadAt = now
137    tint = await readTint($)
138  }
139  await publish($, path, now, sceneAt(now))
140}
141
142export const register: Register = on => {
143  on('session.start', async ($, e, next) => {
144    const runtimeDir = await $.env.get('XDG_RUNTIME_DIR')
145    if (runtimeDir) {
146      bar.id = await $.session.id()
147      bar.path = `${runtimeDir}/claude-mascot/${bar.id}.json`
148      bar.tintsPath = `${runtimeDir}/claude-mascot/.tints`
149      const [pid, window] = [await $.env.get('KITTY_PID'), await $.env.get('KITTY_WINDOW_ID')]
150      kitty = pid && window ? { pid: Number(pid), window: Number(window) } : undefined
151      let isTicking = false
152      $.clock.every(TICK_MS, () => {
153        if (isTicking || bar.path === undefined) return
154        isTicking = true
155        void tick($, bar.path).finally(() => {
156          isTicking = false
157        })
158      })
159    }
160    return next(e)
161  })
162
163  on('session.end', async ($, e, next) => {
164    if (bar.path !== undefined && e.reason !== 'clear' && e.reason !== 'resume') {
165      const path = bar.path
166      bar.path = undefined
167      await $.fs.write(path, JSON.stringify({ updatedAt: await $.clock.now(), isEnded: true }))
168    }
169    return next(e)
170  })
171
172  on('turn.start', async ($, e, next) => {
173    isTurnActive = true
174    running.clear()
175    become(WAITING)
176    return next(e)
177  })
178
179  on('turn.step', async function* ($, e, next) {
180    const isMain = e.agentId === undefined
181    if (isMain) become(WAITING)
182    for await (const chunk of next(e)) {
183      if (isMain) watch(chunk)
184      yield chunk
185    }
186  })
187
188  on('tool.call', async ($, e, next) => {
189    if (e.agentId !== undefined) return next(e)
190    const call: Activity = { kind: 'tool', family: familyOf(e.tool) }
191    const id = e.tool_use_id ?? `call-${++callCount}`
192    running.set(id, call)
193    become(call)
194    try {
195      return await next(e)
196    } finally {
197      running.delete(id)
198      become([...running.values()].at(-1) ?? WAITING)
199    }
200  })
201
202  on('session.compact', async ($, e, next) => {
203    if (e.agentId !== undefined) return next(e)
204    const before = activity
205    become(COMPACTING)
206    try {
207      return await next(e)
208    } finally {
209      become(isTurnActive ? before : DONE)
210    }
211  })
212
213  on('turn.complete', async ($, e, next) => {
214    if (e.agentId === undefined) {
215      isTurnActive = false
216      running.clear()
217      become(e.reason === 'answer' ? DONE : { kind: 'error' })
218    }
219    return next(e)
220  })
221}
222
hooks/scene.ts 276 lines
1export type Scene =
2  | 'idle'
3  | 'sleep'
4  | 'waiting'
5  | 'thinking'
6  | 'writing'
7  | 'read'
8  | 'edit'
9  | 'bash'
10  | 'web'
11  | 'agent'
12  | 'ask'
13  | 'other'
14  | 'compacting'
15  | 'hop'
16  | 'sweat'
17
18type Canvas = { width: number; height: number; pixels: Int32Array }
19type Palette = Record<string, number>
20
21const MINI_WIDTH = 20
22const MINI_HEIGHT = 8
23const CLEAR = -1
24
25const ORANGE = 0xd97757
26const ORANGE_DARK = 0x9e4a33
27
28export type Tint = { body: number; shade: number }
29const EYE = 0x1a1a1a
30const PAPER = 0xede8dc
31const INK = 0x8a8378
32const BULB_ON = 0xffd24a
33const BULB_OFF = 0x8a7f5a
34const METAL = 0x9a9a9a
35const METAL_DARK = 0x4a4a4a
36const SCREEN = 0x111418
37const CODE = 0x4ee07a
38const CODE_DIM = 0x1f7a3c
39const OCEAN = 0x3b82c4
40const LAND_GREEN = 0x6bbf59
41const WOOD = 0x8b5e3c
42const GLASS = 0xcfe8f7
43const ERASER = 0xf28ca6
44const PENCIL_BODY = 0xf2c14e
45const LEAD = 0x3a2e2a
46const MUG = 0xe9e1d3
47const TEA = 0x8b5a2b
48const STEAM = 0xbfc5cc
49const SNOOZE = 0x9cc9f5
50const DROP = 0x7cc4f5
51const DOT_ON = 0xe6e6e6
52const DOT_OFF = 0x5a5a5a
53const DUST = 0xa89f91
54
55const MINI_BODY = ['.OOOOOO.', '.OOOOOO.', 'OOOOOOOO', '..O..O..']
56const CROWN = ['Y.YY.Y', 'YYYYYY']
57const MINI_Z = ['ZZZ', '..Z', '.Z.', 'ZZZ']
58const SPARKLE = ['.Y.', 'YYY', '.Y.']
59const MINI_CUP = ['MTM.', 'MMMM', 'MMM.']
60const MINI_BULB = ['YYY', 'YYY', '.G.']
61const MINI_LENS = ['GGG.', 'GlG.', 'GGG.', '...H']
62const MINI_PENCIL = ['..P', '.U.', 'k..']
63const MINI_QUESTION = ['YY.', '..Y', '.Y.', '...', '.Y.']
64const MINI_MONITOR = ['FFFFFFF', 'FSSSSSF', 'FSSSSSF', 'FSSSSSF', 'FFFFFFF', '..FFF..']
65const MINI_GLOBE = ['.XXX.', 'XXXXX', 'XXXXX', 'XXXXX', '.XXX.']
66const LAND = [
67  '..NN......N...',
68  '.NNNN....NNN..',
69  'NNNNN...NNNNN.',
70  '.NNN.....NNNN.',
71  '..NN......NN..',
72  '...N.......N..',
73  '..............',
74]
75
76function put(c: Canvas, x: number, y: number, color: number): void {
77  if (x >= 0 && x < c.width && y >= 0 && y < c.height) c.pixels[y * c.width + x] = color
78}
79
80function block(c: Canvas, x: number, y: number, w: number, h: number, color: number): void {
81  for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) put(c, x + dx, y + dy, color)
82}
83
84function sprite(c: Canvas, rows: readonly string[], x: number, y: number, palette: Palette): void {
85  rows.forEach((row, dy) => {
86    for (let dx = 0; dx < row.length; dx++) {
87      const color = palette[row[dx]!]
88      if (color !== undefined) put(c, x + dx, y + dy, color)
89    }
90  })
91}
92
93type Pose = {
94  y?: number
95  eyes?: 'open' | 'closed' | 'left' | 'right'
96  wave?: 'left' | 'right' | 'both'
97  step?: boolean
98  isCrowned?: boolean
99}
100
101function miniClawd(c: Canvas, pose: Pose = {}, x = 0): void {
102  const y = pose.y ?? 4
103  sprite(c, MINI_BODY, x, y, { O: ORANGE })
104  const shift = pose.eyes === 'left' ? -1 : pose.eyes === 'right' ? 1 : 0
105  const eye = pose.eyes === 'closed' ? ORANGE_DARK : EYE
106  put(c, x + 2 + shift, y + 1, eye)
107  put(c, x + 5 + shift, y + 1, eye)
108  if (pose.wave === 'left' || pose.wave === 'both') {
109    put(c, x, y + 2, CLEAR)
110    put(c, x, y + 1, ORANGE)
111  }
112  if (pose.wave === 'right' || pose.wave === 'both') {
113    put(c, x + 7, y + 2, CLEAR)
114    put(c, x + 7, y + 1, ORANGE)
115  }
116  if (pose.step) {
117    put(c, x + 2, y + 3, CLEAR)
118    put(c, x + 5, y + 3, CLEAR)
119    put(c, x + 1, y + 3, ORANGE)
120    put(c, x + 6, y + 3, ORANGE)
121  }
122  if (pose.isCrowned) sprite(c, CROWN, x + 1, y - 2, { Y: BULB_ON })
123}
124
125function miniPaper(c: Canvas, lineLengths: readonly number[]): void {
126  block(c, 10, 2, 5, 6, PAPER)
127  lineLengths.forEach((length, i) => block(c, 11, 3 + i * 2, length, 1, INK))
128}
129
130const clamp = (n: number, low: number, high: number) => Math.min(high, Math.max(low, n))
131const every = (f: number, ticks: number) => Math.floor(f / ticks)
132
133const MINI_SCENES: Record<Scene, (c: Canvas, f: number) => void> = {
134  idle(c, f) {
135    const look = (['open', 'open', 'left', 'open', 'right', 'open'] as const)[every(f, 30) % 6]
136    miniClawd(c, { eyes: f % 40 === 0 ? 'closed' : look })
137    sprite(c, MINI_CUP, 11, 5, { M: MUG, T: TEA })
138    const rise = every(f, 3) % 3
139    put(c, 12 + (rise % 2), 4 - rise, STEAM)
140  },
141  sleep(c, f) {
142    miniClawd(c, { eyes: 'closed' })
143    const rise = every(f, 3) % 8
144    if (rise < 6) {
145      sprite(c, MINI_Z, 8 + Math.floor(rise / 2), 3 - Math.floor((rise + 1) / 2), { Z: SNOOZE })
146    }
147  },
148  waiting(c, f) {
149    miniClawd(c, { eyes: 'right', step: every(f, 2) % 2 === 1 })
150    const lit = every(f, 3) % 4
151    for (let i = 0; i < 3; i++) put(c, 10 + i * 2, 6, i < lit ? DOT_ON : DOT_OFF)
152  },
153  thinking(c, f) {
154    const beat = every(f, 2) % 12
155    miniClawd(c, { eyes: beat < 6 ? 'right' : 'open' })
156    if (beat >= 1) put(c, 8, 3, DOT_ON)
157    if (beat >= 2) put(c, 10, 1, DOT_ON)
158    if (beat >= 3) {
159      const isLit = beat >= 5 && beat <= 9
160      sprite(c, MINI_BULB, 12, 0, { Y: isLit ? BULB_ON : BULB_OFF, G: METAL })
161      if (isLit && f % 2 === 0) {
162        for (const [x, y] of [[16, 0], [16, 2]] as const) put(c, x, y, BULB_ON)
163      }
164    }
165  },
166  writing(c, f) {
167    const progress = f % 8
168    miniClawd(c, { eyes: 'right', wave: f % 2 === 0 ? 'right' : undefined })
169    miniPaper(c, [0, 1].map(i => clamp(progress - i * 4, 0, 3)))
170  },
171  read(c, f) {
172    miniClawd(c, { eyes: 'right' })
173    miniPaper(c, [3, 2])
174    const sweep = every(f, 2) % 4
175    sprite(c, MINI_LENS, 9 + (sweep < 2 ? sweep : 3 - sweep) * 2, every(f, 8) % 2 === 0 ? 2 : 4, {
176      G: METAL_DARK,
177      l: GLASS,
178      H: WOOD,
179    })
180  },
181  edit(c, f) {
182    const progress = f % 8
183    const line = Math.floor(progress / 4)
184    const length = progress % 4
185    miniClawd(c, { eyes: 'right' })
186    miniPaper(c, [0, 1].map(i => (i < line ? 3 : i === line ? length : 0)))
187    sprite(c, MINI_PENCIL, 11 + length, 1 + line * 2, { P: ERASER, U: PENCIL_BODY, k: LEAD })
188  },
189  bash(c, f) {
190    miniClawd(c, { eyes: 'right', wave: f % 2 === 0 ? 'right' : 'left' })
191    sprite(c, MINI_MONITOR, 10, 2, { F: METAL_DARK, S: SCREEN })
192    for (let i = 0; i < 5; i += 2) {
193      const head = (f + i * 2) % 5
194      if (head < 3) put(c, 11 + i, 3 + head, CODE)
195      if (head >= 1 && head <= 3) put(c, 11 + i, 2 + head, CODE_DIM)
196    }
197  },
198  web(c, f) {
199    miniClawd(c, { eyes: 'right' })
200    const spin = every(f, 2)
201    MINI_GLOBE.forEach((row, y) => {
202      for (let x = 0; x < row.length; x++) {
203        if (row[x] === 'X') put(c, 11 + x, 2 + y, LAND[y + 1]![(x + spin) % 14] === 'N' ? LAND_GREEN : OCEAN)
204      }
205    })
206    block(c, 12, 7, 3, 1, WOOD)
207  },
208  agent(c, f) {
209    miniClawd(c, { eyes: 'right', wave: every(f, 2) % 2 === 0 ? 'right' : undefined, isCrowned: true })
210    const x = 9 + (f % 14)
211    miniClawd(c, { step: f % 2 === 1 }, x)
212    if (f % 2 === 1) put(c, x - 1, 7, DUST)
213  },
214  ask(c, f) {
215    miniClawd(c, { eyes: 'right', wave: every(f, 3) % 2 === 0 ? 'right' : undefined })
216    if (every(f, 4) % 4 !== 3) sprite(c, MINI_QUESTION, 11, 1, { Y: BULB_ON })
217  },
218  other(c, f) {
219    const phase = (['up', 'up', 'mid', 'down', 'down', 'mid'] as const)[f % 6]
220    miniClawd(c, { eyes: 'right', wave: phase === 'down' ? 'right' : undefined })
221    block(c, 9, 7, 6, 1, WOOD)
222    put(c, 11, 6, METAL)
223    if (phase === 'up') {
224      block(c, 15, 0, 3, 2, METAL_DARK)
225      block(c, 16, 2, 1, 3, WOOD)
226    } else if (phase === 'mid') {
227      block(c, 12, 0, 2, 2, METAL_DARK)
228      for (const [x, y] of [[14, 2], [15, 3], [16, 4]] as const) put(c, x, y, WOOD)
229    } else {
230      block(c, 11, 3, 2, 3, METAL_DARK)
231      block(c, 13, 4, 4, 1, WOOD)
232      if (f % 6 === 3) for (const [x, y] of [[10, 5], [13, 6]] as const) put(c, x, y, BULB_ON)
233    }
234  },
235  compacting(c, f) {
236    const height = 3 - (every(f, 2) % 3)
237    miniClawd(c, { eyes: 'right', wave: 'both' })
238    block(c, 10, 2, 6, 1, METAL_DARK)
239    block(c, 10, 3, 1, 5, METAL_DARK)
240    block(c, 15, 3, 1, 5, METAL_DARK)
241    for (let i = 0; i < height; i++) block(c, 11, 7 - i, 4, 1, i % 2 === 0 ? PAPER : INK)
242    block(c, 11, 7 - height, 4, 1, METAL)
243    block(c, 12, 3, 2, 4 - height, METAL)
244  },
245  hop(c, f) {
246    const y = [4, 3, 2, 2, 3, 4][f % 6]!
247    miniClawd(c, { y, wave: y < 4 ? 'both' : undefined })
248    if (f % 2 === 0) {
249      sprite(c, SPARKLE, 10, 1, { Y: BULB_ON })
250      sprite(c, SPARKLE, 15, 3, { Y: BULB_ON })
251    } else {
252      sprite(c, SPARKLE, 12, 2, { Y: BULB_ON })
253    }
254  },
255  sweat(c, f) {
256    miniClawd(c, { eyes: f % 20 === 0 ? 'closed' : 'open' })
257    block(c, 8, 2 + (every(f, 3) % 3), 1, 2, DROP)
258  },
259}
260
261export function recolor(c: Canvas, tint: Tint | undefined): Canvas {
262  if (tint !== undefined) {
263    c.pixels.forEach((color, i) => {
264      if (color === ORANGE) c.pixels[i] = tint.body
265      else if (color === ORANGE_DARK) c.pixels[i] = tint.shade
266    })
267  }
268  return c
269}
270
271export function drawMiniScene(scene: Scene, frame: number): Canvas {
272  const canvas = { width: MINI_WIDTH, height: MINI_HEIGHT, pixels: new Int32Array(MINI_WIDTH * MINI_HEIGHT).fill(CLEAR) }
273  MINI_SCENES[scene](canvas, frame)
274  return canvas
275}
276