SLOPSHOPPER

agent-factory

Old-time factory floor pane: Claude the foreman whips and screams at subagent workers, each a Claude variant dressed for its task.

paneguardcommandtoasttimeraudio
v0.1.0no licenseupdated 2026-10-19ludwich/claude-factory-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-factory
│ ┃ Agent Factory ✕ › fix the failing auth test and add an audit log call │ ┃ ░ │ ┃ ▒ ░ ⏺ Read(src/auth.ts) │ ┃ NO SHIFT ⎿ Read 6 lines │ ┃ ▐█▌ ▐█▌ ═══╡ CLAUDE & SONS · AGENT WORKS ⏺ Update(src/auth.ts) │ ┃ ╞═════════════ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ ▐███▌ Z │ ┃ ▐███▌ z ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▀▀▀▀▀▀▀ │ ┃ ┌────────────────────────────────────┐ ✻ Worked for 42s · done 4:20 PM │ ┃ ▐▛███▜▌ │ Floor empty. Foreman │ ┃ naps. zZz │ › /factory │ ┃ ▝▜█████▛▘ ⎿ agent-factory: The factory floor is open. Back to work, all of y │ ┃ └────────────────────────────────────┘ │ ┃ ▘▘ ▝▝ │ ┃ │ ┃ (the line stands silent. not a wage-slave │ ┃ in sight) │ ┃ ══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══ │ ┃ ╪══╪══╪══╪══ │ ┃ [ Victorian mill ][ Silent film ][ Neon swea │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Factory
░ ▒ ░ NO SHIFT ▐█▌ ▐█▌ ═══╡ CLAUDE & SONS · AGENT WORKS ╞═════════════ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▐███▌ Z ▐███▌ z ▀▀▀▀▀▀▀ ┌────────────────────────────────────┐ ▐▛███▜▌ │ Floor empty. Foreman naps. zZz │ ▝▜█████▛▘ └────────────────────────────────────┘ ▘▘ ▝▝ (the line stands silent. not a wage-slave in sight) ══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══╪══ [ Victorian mill ][ Silent film ][ Neon sweatshop ][ Megapho
README

Claude & Sons · Agent Works

A Claude Code mod that turns your session into an old-time factory floor.

Claude is the foreman. It stands on the pulpit in a stovepipe hat and a handlebar moustache. When subagents are working, it cracks a bullwhip at them and screams. Each subagent is a worker on the line. Every worker is Claude's own pixel mascot, dressed for the job it was given.

Victorian mill, full floor

Subagent typeWorkerStation
ExploreMiner: hard hat, headlamp, dead canaryswings a pickaxe at a coal face of { } ; < > ore
PlanDraftsman: round specs, pencil behind eardrafting table with a blueprint
general-purposeLine smith: flat cap, apronhammers widgets on an anvil
reviewers / verifiers / testersQuality inspector: bowler hat, monoclestamps APPROVED / REJECTED
guides / docsArchivist: green visor, quilllectern with a giant RTFM ledger
anything elseBoiler stoker: goggles, sootshovels coal into the furnace

Each worker shows its status: on the line, waiting (checking a pocket watch), idle (zzz), clocked out (tea on a crate) or broken (X eyes, smoke). The one being whipped flinches and yells OW! A brass nameplate under each worker shows its task and how many tool calls it has made ("widgets").

The foreman changes mode with what is going on:

  • Whipping: subagents are working. It picks a target, lashes it and screams a line about that worker's role and task.
  • Solo: the main agent is working with nobody on the line. It rolls up its sleeves and hammers away itself ("FINE! I will Edit it MYSELF!").
  • Idle: tea, a pocket watch and a smug look.

Three styles

Victorian millSilent filmNeon sweatshop

Switch styles with the buttons under the scene. The choice is remembered across sessions.

📢 Megaphone makes the foreman scream every line out loud with text-to-speech. It is off by default, for your colleagues' sake.

Install

At a Claude Code terminal prompt, run:

/plugin install agent-factory --marketplace ludwich/claude-factory-mod

Answer y to add the marketplace, then pick a scope. The pane opens when a session starts and whenever a subagent is hired. /factory opens it by hand.

  • Desktop, VS Code and mobile get the animated SVG scene.
  • The terminal gets a block-art version built on the ▐▛███▜▌ Clawd.

How it works

hooks/register.tsx is a function-hooks module:

  • $.agent.list() is polled every 1.5 s to fill the roster.
  • tool.call counts widgets per agentId, and the main loop's calls count for the foreman.
  • turn.start and turn.complete mark when the foreman is busy.
  • A 1 s timer picks who gets screamed at next.
  • State lives in $.state (contract in types/index.d.ts). The chosen style and the megaphone setting live in $.store.

The art in hooks/art/ is pure TypeScript that builds SVG strings with SMIL animation. All of it is drawn on one sprite (clawd.ts). The screamed lines are in hooks/shouts.ts; there are more than 200.

Develop

node tools/preview.ts --out preview/dev

This renders every scenario × style to SVG and PNG (the PNGs come from headless Edge on Windows), plus a role sheet and the terminal ASCII art.

claude plugin validate .
claude plugin test .

The art was built by a fan-out of builder agents. Each one was judged by Claude Fable from rendered PNGs and revised until it held up.

Source 17 files
hooks/register.tsx 280 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register } from 'claude-code'
3
4import type { FactoryForeman, FactoryShout, FactoryTheme, FactoryWorker } from '../types'
5import { layout, renderScene, roleOf, seedOf, statusOf } from './art/compose.ts'
6import { asciiScene } from './ascii.ts'
7import { doneShout, failShout, pickShout, soloShout } from './shouts.ts'
8import type { SceneView } from './art/types.ts'
9
10const PLUGIN = 'agent-factory'
11const PANE = 'agent-factory'
12const TITLE = 'Agent Factory'
13const STORE_THEME = 'agent-factory.theme'
14const STORE_LOUD = 'agent-factory.isLoud'
15
16/** How long a finished worker stays on the floor before clocking out. */
17const LINGER_MS = 30_000
18const POLL_MS = 1_500
19const SHOUT_MS = 4_000
20const SOLO_SHOUT_MS = 9_000
21
22const workers = atom({ plugin: 'agent-factory', key: 'workers' } as const, [] as FactoryWorker[])
23const foreman = atom({ plugin: 'agent-factory', key: 'foreman' } as const, { isBusy: false, tools: 0 } as FactoryForeman)
24const shout = atom({ plugin: 'agent-factory', key: 'shout' } as const, { text: '', n: 0 } as FactoryShout)
25const theme = atom({ plugin: 'agent-factory', key: 'theme' } as const, 'victorian' as FactoryTheme)
26const isLoud = atom({ plugin: 'agent-factory', key: 'isLoud' } as const, false)
27
28const THEMES: { id: FactoryTheme; label: string }[] = [
29  { id: 'victorian', label: 'Victorian mill' },
30  { id: 'noir', label: 'Silent film' },
31  { id: 'neon', label: 'Neon sweatshop' },
32]
33
34// Tool calls counted between polls, so a burst of calls is one redraw.
35const counts = new Map<string, { n: number; last?: string }>()
36let mainCount = { n: 0, last: undefined as string | undefined }
37let lastShoutAt = 0
38// Workers that already clocked out and left the floor: the engine keeps
39// listing them, the floor does not.
40const retired = new Set<string>()
41
42function isActive(w: FactoryWorker): boolean {
43  const s = statusOf(w.status)
44  return s === 'working' || s === 'waiting'
45}
46
47async function holler($: EngineInterface, text: string, targetId?: string) {
48  const now = await $.clock.now()
49  lastShoutAt = now
50  await update($, shout, s => ({ text, targetId, n: (s?.n ?? 0) + 1 }))
51  if (await read($, isLoud)) {
52    $.audio.speak(text).catch(() => undefined)
53  }
54}
55
56/** Merge the engine's agent list with what the floor already shows. */
57async function poll($: EngineInterface) {
58  let list: AgentInfo[]
59  try {
60    list = await $.agent.list()
61  } catch {
62    return
63  }
64  const now = await $.clock.now()
65  const before = await read($, workers)
66  const byId = new Map(before.map(w => [w.id, w]))
67  const next: FactoryWorker[] = []
68  const events: { kind: 'done' | 'fail'; w: FactoryWorker }[] = []
69
70  for (const a of list) {
71    if (a.teammateId || retired.has(a.id)) continue
72    const old = byId.get(a.id)
73    const c = counts.get(a.id)
74    const status = a.status
75    const wasActive = old ? isActive(old) : true
76    const ended = statusOf(status) === 'done' || statusOf(status) === 'failed'
77    const w: FactoryWorker = {
78      id: a.id,
79      type: a.type,
80      description: a.description || old?.description || a.type,
81      status,
82      tools: (old?.tools ?? 0) + (c?.n ?? 0),
83      lastTool: c?.last ?? old?.lastTool,
84      endedAt: ended ? (old?.endedAt ?? now) : undefined,
85    }
86    if (ended && wasActive && old) events.push({ kind: statusOf(status) === 'done' ? 'done' : 'fail', w })
87    if (w.endedAt !== undefined && now - w.endedAt > LINGER_MS) {
88      retired.add(w.id)
89      continue
90    }
91    next.push(w)
92  }
93  counts.clear()
94
95  const changed = JSON.stringify(next) !== JSON.stringify(before)
96  if (changed) await update($, workers, () => next)
97
98  if (mainCount.n > 0) {
99    const add = mainCount
100    mainCount = { n: 0, last: undefined }
101    await update($, foreman, f => ({ ...f, tools: f.tools + add.n, lastTool: add.last ?? f.lastTool }))
102  }
103
104  const s = await read($, shout)
105  for (const ev of events) {
106    const input = { role: roleOf(ev.w.type), task: ev.w.description, type: ev.w.type, n: s.n }
107    await holler($, ev.kind === 'done' ? doneShout(input) : failShout(input), ev.w.id)
108  }
109}
110
111/** Every few seconds: scream at somebody. */
112async function tick($: EngineInterface) {
113  const now = await $.clock.now()
114  const crew = (await read($, workers)).filter(isActive)
115  const s = await read($, shout)
116  if (crew.length > 0) {
117    if (now - lastShoutAt < SHOUT_MS - 100) return
118    const w = crew[s.n % crew.length]!
119    await holler($, pickShout({ role: roleOf(w.type), task: w.description, type: w.type, tools: w.tools, n: s.n }), w.id)
120    return
121  }
122  const f = await read($, foreman)
123  if (f.isBusy) {
124    if (now - lastShoutAt < SOLO_SHOUT_MS) return
125    await holler($, soloShout(s.n, f.lastTool))
126  } else if (s.text) {
127    await update($, shout, x => ({ ...x, text: '', targetId: undefined }))
128  }
129}
130
131async function openPane($: EngineInterface) {
132  await $.ui.open({ id: PANE, title: TITLE })
133}
134
135
136export const register: Register = on => {
137  on('session.start', async ($, e, next) => {
138    const storedTheme = await $.store.get(STORE_THEME)
139    if (storedTheme === 'victorian' || storedTheme === 'noir' || storedTheme === 'neon') {
140      await update($, theme, () => storedTheme)
141    }
142    if ((await $.store.get(STORE_LOUD)) === true) await update($, isLoud, () => true)
143
144    await $.command.register({
145      name: 'factory',
146      description: 'Open the Agent Factory floor: Claude the foreman and the subagents on the line',
147    })
148    $.clock.every(POLL_MS, () => void poll($))
149    $.clock.every(1_000, () => void tick($))
150    void openPane($)
151
152    return next(e)
153  })
154
155  on('command.run', { command: 'factory' }, async $ => {
156    await openPane($)
157    return { text: 'The factory floor is open. Back to work, all of you.' }
158  })
159
160  on('turn.start', async ($, e, next) => {
161    await update($, foreman, f => ({ ...f, isBusy: true }))
162    return next(e)
163  })
164
165  on('turn.complete', async ($, e, next) => {
166    await update($, foreman, f => ({ ...f, isBusy: false }))
167    return next(e)
168  })
169
170  on('tool.call', async ($, e, next) => {
171    const tool = String(e.tool)
172    if (e.agentId) {
173      const c = counts.get(e.agentId) ?? { n: 0 }
174      counts.set(e.agentId, { n: c.n + 1, last: tool })
175    } else {
176      mainCount = { n: mainCount.n + 1, last: tool }
177    }
178    if (tool === 'Agent' && !e.agentId) {
179      // A new hire: open the floor and look at the roster now, not in 1.5s.
180      void openPane($)
181      $.clock.after(300, () => void poll($))
182    }
183    return next(e)
184  })
185
186  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
187    const [ws, f, s, t, loud] = await Promise.all([
188      read($, workers), read($, foreman), read($, shout), read($, theme), read($, isLoud),
189    ])
190    const active = ws.filter(isActive)
191    const view: SceneView = layout({
192      theme: t,
193      n: s.n,
194      overflow: 0,
195      foreman: {
196        mode: active.length > 0 ? 'whipping' : f.isBusy ? 'solo' : 'idle',
197        shout: s.text,
198        tools: f.tools,
199        lastTool: f.lastTool,
200        x: 0,
201        y: 0,
202      },
203      workers: ws.map(w => ({
204        id: w.id,
205        role: roleOf(w.type),
206        type: w.type,
207        description: w.description,
208        status: statusOf(w.status),
209        tools: w.tools,
210        lastTool: w.lastTool,
211        isTarget: w.id === s.targetId,
212        seed: seedOf(w.id),
213        x: 0,
214        y: 0,
215      })),
216    })
217
218    const setTheme = (id: FactoryTheme) => async () => {
219      await update($, theme, () => id)
220      await $.store.set(STORE_THEME, id)
221    }
222    const toggleLoud = async () => {
223      const v = !(await read($, isLoud))
224      await update($, isLoud, () => v)
225      await $.store.set(STORE_LOUD, v)
226      $.ui.toast(v ? 'Megaphone ON. The foreman will now scream out loud.' : 'Megaphone off. Silent fury only.')
227    }
228
229    const roster = ws.length === 0
230      ? 'Nobody on the line. The foreman paces.'
231      : `${active.length} on the line, ${ws.length - active.length} clocked out · foreman ${f.tools} tool calls`
232
233    if (e.surface === 'terminal') {
234      const { Box, Text, Button } = $.ui.resolve(e)
235      const cols = Math.max(30, e.props.bodyColumns ?? e.viewport?.columns ?? 80)
236      const lines = asciiScene(view, cols)
237      return (
238        <Box flexDirection="column">
239          {lines.map(line => (
240            <Text>
241              {line.map(span => (
242                <Text color={span.color} bold={span.bold}>{span.text}</Text>
243              ))}
244            </Text>
245          ))}
246          <Box>
247            {THEMES.map((th, i) => (
248              <Button key={`theme-${th.id}`} hotkey={String(i + 1)} variant={t === th.id ? 'primary' : undefined} label={th.label} onPress={setTheme(th.id)} />
249            ))}
250            <Button key="loud" hotkey="m" label={loud ? 'Megaphone: ON' : 'Megaphone: off'} onPress={toggleLoud} />
251          </Box>
252        </Box>
253      )
254    }
255
256    const { Box, Text, Button, Svg } = $.ui.resolve(e)
257    return (
258      <Box flexDirection="column">
259        <Svg
260          source={renderScene(view)}
261          isInteractive
262          alt={`Agent Factory: Claude the foreman (${view.foreman.mode}) ${s.text ? `screams "${s.text}" ` : ''}at ${active.length} subagent workers.`}
263        />
264        <Box>
265          {THEMES.map(th => (
266            <Button key={`theme-${th.id}`} variant={t === th.id ? 'primary' : undefined} label={th.label} onPress={setTheme(th.id)} />
267          ))}
268          <Button key="loud" label={loud ? '📢 Megaphone: ON' : '📢 Megaphone: off'} onPress={toggleLoud} />
269        </Box>
270        <Text dimColor>{roster}</Text>
271        {ws.map(w => (
272          <Text>
273            <Text bold>{w.type}</Text> · {w.description} · {w.tools} widgets · {statusOf(w.status).toUpperCase()}
274          </Text>
275        ))}
276      </Box>
277    )
278  })
279}
280
hooks/art/compose.ts 180 lines
1import type { Palette, Role, SceneView, WorkerStatus, WorkerView } from './types.ts'
2import { MAX_SLOTS, SCENE_H, SCENE_W } from './types.ts'
3import { PALETTES } from './palette.ts'
4import { backdrop, foreground } from './scene.ts'
5import { foreman, shoutBubble } from './foreman.ts'
6import { ROLE_ART } from './roles/index.ts'
7import { esc } from './clawd.ts'
8
9/** Which costume a subagent type wears. */
10export function roleOf(type: string): Role {
11  const t = type.toLowerCase()
12  if (t.includes('explore') || t.includes('search') || t.includes('research')) return 'explore'
13  if (t.includes('plan') || t.includes('architect') || t.includes('design')) return 'plan'
14  if (t.includes('review') || t.includes('verif') || t.includes('audit') || t.includes('test')) return 'review'
15  if (t.includes('guide') || t.includes('doc') || t.includes('help')) return 'guide'
16  if (t.includes('general') || t === 'claude' || t.includes('fork') || t === 'worker') return 'general'
17  return 'other'
18}
19
20/** The engine's AgentStatus folded to what the art draws. */
21export function statusOf(s: string): WorkerStatus {
22  if (s === 'running' || s === 'pending') return 'working'
23  if (s === 'waiting') return 'waiting'
24  if (s === 'idle') return 'idle'
25  if (s === 'completed') return 'done'
26  return 'failed'
27}
28
29/** Stable small number from an id. */
30export function seedOf(id: string): number {
31  let h = 7
32  for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 997
33  return h
34}
35
36/** Slot positions: two rows of four along the floor, right of the foreman.
37 * Back row sits higher (and is drawn at 0.92 scale); front row is offset
38 * right so heads sit between the back row's plates. */
39export function slot(i: number): { x: number; y: number } {
40  const row = Math.floor(i / 4)
41  const col = i % 4
42  return { x: 340 + col * 160 + (row ? 30 : 0), y: row ? 488 : 376 }
43}
44
45export const FOREMAN_POS = { x: 120, y: 330 }
46
47const BACK_SCALE = 0.92
48
49/** Assigns slots; keeps the target in view. Exactly one worker may be the target. */
50export function layout(v: Omit<SceneView, 'width' | 'height'>): SceneView {
51  const shown = v.workers.slice(0, MAX_SLOTS)
52  const ti = shown.findIndex(w => w.isTarget)
53  // a small crew stands in one row mid-floor; a full house splits into back and front rows
54  const place = (i: number) => shown.length <= 4 ? { x: slot(i).x + 16, y: 440 } : slot(i)
55  const workers: WorkerView[] = shown.map((w, i) => ({ ...w, ...place(i), isTarget: i === ti }))
56  const overflow = v.overflow + Math.max(0, v.workers.length - MAX_SLOTS)
57  const target = ti >= 0 ? workers[ti] : undefined
58  return {
59    ...v,
60    width: SCENE_W,
61    height: SCENE_H,
62    workers,
63    overflow,
64    foreman: {
65      ...v.foreman,
66      ...FOREMAN_POS,
67      targetX: target?.x,
68      targetY: target?.y,
69    },
70  }
71}
72
73const STATUS_TAG: Record<WorkerStatus, string> = {
74  working: 'ON THE LINE', waiting: 'WAITING', idle: 'IDLE', done: 'CLOCKED OUT', failed: 'BROKEN',
75}
76
77/** Short trade names for the bold line of a nameplate. */
78const TRADE: Record<string, string> = {
79  'general-purpose': 'HAND', 'claude-code-guide': 'GUIDE', 'statusline-setup': 'STATUSLINE',
80  'code-reviewer': 'REVIEWER', 'output-style-setup': 'STYLIST', explore: 'EXPLORE', plan: 'PLAN',
81}
82
83export function tradeOf(type: string): string {
84  const t = type.toLowerCase().replace(/^claude-code-/, '')
85  if (TRADE[type.toLowerCase()]) return TRADE[type.toLowerCase()]!
86  if (TRADE[t]) return TRADE[t]
87  return cut(t.split(/[-_ :]/).filter(Boolean)[0]?.toUpperCase() || 'HAND', 10)
88}
89
90function cut(s: string, n: number): string {
91  return s.length > n ? s.slice(0, n - 1) + '…' : s
92}
93
94const MONO = `'Courier New',monospace`
95// 7px pixel cog, drawn at (0,0) top-left
96const COG = 'M2 0h3v1h1v1h1v3h-1v1h-1v1h-3v-1h-1v-1h-1v-3h1v-1h1zM3 2v3h1v-3z'
97
98/** Shared type styles for every nameplate (emitted once per scene). */
99function plateStyle(p: Palette): string {
100  return `<style>.np text{font:bold 8px ${MONO};fill:${p.paper}}.np .t{font:bold 11px Georgia,serif;letter-spacing:.6px;fill:${p.paper}}` +
101    `.np .d{font-size:9px;font-weight:normal;opacity:.85}.np .e{text-anchor:end}.np .k{font-size:9px;opacity:.8}</style>`
102}
103
104/** Brass nameplate under each worker: trade, status, task, widgets. */
105export function nameplate(w: WorkerView, p: Palette): string {
106  const color = w.status === 'done' ? p.ok : w.status === 'working' ? p.accent : p.smoke
107  const tag = STATUS_TAG[w.status]
108  const status = w.status === 'failed'
109    ? `<rect x="${62 - tag.length * 5}" y="3" width="${tag.length * 5 + 7}" height="10" fill="${p.danger}"/><text x="66" y="11" class="e">${tag}</text>`
110    : `<text x="68" y="11" class="e" style="fill:${color}">${tag}</text>`
111  const stroke = w.isTarget
112    ? `<rect x="-75" y="1" width="150" height="24" fill="none" stroke="${p.danger}" stroke-width="2">` +
113      `<animate attributeName="opacity" values="1;.35;1" dur=".6s" repeatCount="indefinite"/></rect>`
114    : `<rect x="-75.5" y=".5" width="151" height="25" fill="none" stroke="${p.metal}"/>`
115  const n = String(w.tools)
116  return (
117    `<g transform="translate(${w.x},${w.y + 4})" class="np">` +
118    `<rect x="-77" y="-1" width="156" height="30" fill="${p.ink}" opacity=".55"/>` +
119    `<rect x="-76" width="152" height="26" fill="${p.metalDark}"/>` +
120    `<rect x="-76" width="152" height="2" fill="${p.metalLight}" opacity=".6"/>` +
121    stroke +
122    `<text x="-69" y="12" class="t"${w.isTarget ? ` style="fill:${p.accent}"` : ''}>${esc(tradeOf(w.type))}</text>` +
123    status +
124    `<text x="-69" y="22" class="d">${esc(cut(w.description, 19))}</text>` +
125    `<text x="68" y="22" class="e k">${n}</text>` +
126    `<path transform="translate(${60 - n.length * 5.4},16)" d="${COG}" fill="${p.accent}" fill-rule="evenodd"/>` +
127    `</g>`
128  )
129}
130
131const RECT = /^<rect x="(-?[\d.]+)" y="(-?[\d.]+)" width="([\d.]+)" height="([\d.]+)"((?:(?! r[xy]=)[^>])*)\/>$/
132
133/** Shave bytes inside tags only: float noise, leading zeros, inherited
134 * crispEdges, and childless plain rects rewritten as (shorter) paths. */
135function tighten(svg: string): string {
136  return svg.replace(/<[^>]+>/g, t => t
137    .replace(/\d+\.\d{3,}/g, m => String(Math.round(+m * 100) / 100))
138    .replace(/([";, (-])0\.(\d)/g, '$1.$2')
139    .replace(/ shape-rendering="crispEdges"/g, '')
140    .replace(RECT, (_, x, y, w, h, rest) => `<path d="M${x} ${y}h${w}v${h}h-${w}z"${rest}/>`))
141}
142
143/** One worker: contact shadow + costume, back row shrunk about its feet. */
144function worker(w: WorkerView, p: Palette): string {
145  const art = `<rect x="${w.x - 40}" y="${w.y - 3}" width="80" height="6" fill="#000" opacity=".35"/>` + ROLE_ART[w.role](w, p)
146  return w.y < 420 ? `<g transform="translate(${w.x},${w.y}) scale(${BACK_SCALE}) translate(${-w.x},${-w.y})">${art}</g>` : art
147}
148
149/** The whole factory as one SVG document. */
150export function renderScene(v: SceneView): string {
151  const p = PALETTES[v.theme]
152  // back row first so the front row overlaps it; plates are UI, drawn over everything but the bubble
153  const order = [...v.workers].sort((a, b) => a.y - b.y)
154  const crew = order.map(w => worker(w, p)).join('')
155  const plates = (order.length ? plateStyle(p) : '') + order.map(w => nameplate(w, p)).join('')
156  const more = v.overflow > 0
157    ? `<g transform="translate(${SCENE_W - 108},232)" shape-rendering="crispEdges"><rect x="-1" y="-1" width="98" height="32" fill="${p.ink}"/><rect width="96" height="30" fill="${p.metalDark}" stroke="${p.accent}"/>` +
158      `<text x="48" y="13" text-anchor="middle" font-family="${MONO}" font-size="11" font-weight="bold" fill="${p.accent}">+${v.overflow} MORE</text>` +
159      `<text x="48" y="24" text-anchor="middle" font-family="${MONO}" font-size="8" fill="${p.paper}">IN THE BACK</text></g>`
160    : ''
161  // noir hand-tints: only the hall goes grey, the Clawds keep their orange
162  const sinCity = p.sceneFilter.includes('grayscale')
163  const f = p.sceneFilter ? ` style="filter:${p.sceneFilter}"` : ''
164  const hall = (s: string) => sinCity ? `<g${f}>${s}</g>` : s
165  const body =
166    hall(backdrop(p, v)) +
167    crew +
168    foreman(v.foreman, p) +
169    hall(foreground(p, v)) +
170    plates +
171    shoutBubble(v.foreman.shout, v.foreman.x, v.foreman.y - 80, p) +
172    more
173  return (
174    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SCENE_W} ${SCENE_H}" width="${SCENE_W}" height="${SCENE_H}" shape-rendering="crispEdges">` +
175    `<title>${esc('Claude & Sons Agent Works')}</title>` +
176    tighten(sinCity ? body : `<g${f}>${body}</g>`) +
177    `</svg>`
178  )
179}
180
hooks/ascii.ts 431 lines
1import type { AsciiLine, AsciiSpan, Palette, Role, SceneView, WorkerView } from './art/types.ts'
2import { PALETTES } from './art/palette.ts'
3import { tint } from './art/clawd.ts'
4
5// The factory floor as coloured terminal text. Every glyph is a single BMP
6// code point of width 1 (no emoji, no CJK), so String.length == columns used.
7
8type Row = AsciiSpan[]
9
10const len = (spans: Row): number => spans.reduce((a, s) => a + s.text.length, 0)
11const sp = (text: string, color?: string, bold?: boolean): AsciiSpan =>
12  bold ? { text, color, bold } : color ? { text, color } : { text }
13const pad = (spans: Row, w: number): Row => {
14  const n = len(spans)
15  return n < w ? [...spans, sp(' '.repeat(w - n))] : spans
16}
17const trunc = (s: string, n: number): string => (n <= 0 ? '' : s.length <= n ? s : n === 1 ? s.slice(0, 1) : s.slice(0, n - 1) + '…')
18
19/** Hard clip a line to `w` columns (safety net). */
20function clip(line: Row, w: number): Row {
21  const out: Row = []
22  let used = 0
23  for (const s of line) {
24    if (used >= w) break
25    const t = s.text.slice(0, w - used)
26    if (t) out.push({ ...s, text: t })
27    used += t.length
28  }
29  return out
30}
31
32/** Word-wrap to width w (long words are hard-split). */
33function wrap(text: string, w: number): string[] {
34  const out: string[] = []
35  let cur = ''
36  for (let word of text.split(/\s+/).filter(Boolean)) {
37    while (word.length > w) {
38      if (cur) { out.push(cur); cur = '' }
39      out.push(word.slice(0, w)); word = word.slice(w)
40    }
41    if (!word) continue
42    if (!cur) cur = word
43    else if (cur.length + 1 + word.length <= w) cur += ' ' + word
44    else { out.push(cur); cur = word }
45  }
46  if (cur) out.push(cur)
47  return out.length ? out : ['']
48}
49
50function gray(hex: string): string {
51  const n = parseInt(hex.slice(1), 16)
52  const y = 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)
53  const h = Math.min(255, Math.round(y * 1.15)).toString(16).padStart(2, '0')
54  return `#${h}${h}${h}`
55}
56
57function rgb(hex: string): number[] {
58  const n = parseInt(hex.slice(1), 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61function lum(hex: string): number {
62  const [r = 0, g = 0, b = 0] = rgb(hex).map(c => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4 })
63  return 0.2126 * r + 0.7152 * g + 0.0722 * b
64}
65const contrast = (a: string, b: string): number => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
66function mix(a: string, b: string, t: number): string {
67  const x = rgb(a), y = rgb(b)
68  return '#' + x.map((c, i) => Math.round(c + (y[i]! - c) * t).toString(16).padStart(2, '0')).join('')
69}
70const hex = (c: number[]): string => '#' + c.map(x => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, '0')).join('')
71/** A typical dark terminal: the terminal never paints p.bg, so legibility is judged against this. */
72const TERM = '#1e1e1e'
73/** Brighten `c` (hue first, then toward white) until it reads on a dark terminal and on p.bg. */
74function lift(c: string, bg: string, target = 4.5): string {
75  const okc = (x: string) => contrast(x, TERM) >= target && contrast(x, bg) >= Math.min(target, 3.2)
76  if (okc(c)) return c
77  const ch = rgb(c), m = Math.max(1, ...ch)
78  const vivid = hex(ch.map(x => (x * 255) / m))
79  for (let t = 0.1; t <= 1.001; t += 0.1) { const x = mix(c, vivid, t); if (okc(x)) return x }
80  for (let t = 0.1; t <= 1.001; t += 0.1) { const x = mix(vivid, '#ffffff', t); if (okc(x)) return x }
81  return '#ffffff'
82}
83/** Hat/tool metal that stays legible on the theme background (neon's metalLight is too dim). */
84function hiMetal(p: Palette): string {
85  let c = p.metalLight
86  for (let t = 0.25; contrast(c, p.bg) < 4.5 && t <= 1; t += 0.15) c = mix(p.metalLight, p.paper, t)
87  return c
88}
89
90// ---------------------------------------------------------------- roles
91
92const ROLES: Role[] = ['explore', 'plan', 'general', 'review', 'guide', 'other']
93const HUE: Record<Role, number> = { explore: 18, plan: -28, general: 0, review: 42, guide: -55, other: 95 }
94const TOOL: Record<Role, string> = { explore: '┬', plan: '✎', general: '╤', review: '¶', guide: '▤', other: '¥' }
95const TOIL = ['grafting', 'toiling', 'sweating', 'slaving', 'hammering', 'grinding']
96
97function hat(role: Role, p: Palette, m: string): Row {
98  switch (role) {
99    case 'explore': return [sp('   ▄', m), sp('◓', p.accent, true), sp('▄', m), sp('*', p.accent, true)]
100    case 'plan': return [sp('  ▄███▄', p.paper), sp('✎', p.accent, true)]
101    case 'general': return [sp(' ▄▄███▄', m)]
102    case 'review': return [sp('   ▄█▄', m)]
103    case 'guide': return [sp('  ▀▀▀▀▀', p.accentAlt), sp('▤', p.paper, true)]
104    case 'other': return [sp('  ═', m), sp('∞', p.accentAlt, true), sp('═', m), sp('∞', p.accentAlt, true), sp('═', m)]
105  }
106}
107
108/** Noir keeps Clawd orange as its single hand-tinted colour: roles shift lightness, not hue. */
109const NOIR_L: Record<Role, number> = { explore: 0.08, plan: -0.06, general: 0, review: 0.14, guide: -0.1, other: 0.2 }
110const roleColor = (role: Role, p: Palette): string => p.sceneFilter.includes('grayscale')
111  ? tint(p.clawd, 0, NOIR_L[role])
112  : tint(p.clawd, HUE[role], role === 'guide' ? 0.05 : 0)
113
114// ---------------------------------------------------------------- workers
115
116function widgets(n: number, tw: number): string {
117  const long = `stamped ${n} widget${n === 1 ? '' : 's'}`
118  return long.length <= tw ? long : `▣ ${n} widget${n === 1 ? '' : 's'}`
119}
120
121function workerCell(w: WorkerView, v: SceneView, p: Palette, cw: number, mini = false): Row[] {
122  const f = (v.n + w.seed) % 2
123  const rc = roleColor(w.role, p)
124  const noir = v.theme === 'noir'
125  const body = w.status === 'done' ? (noir ? p.metal : p.smoke)
126    : w.status === 'failed' ? p.clawdDark
127    : w.status === 'idle' ? (noir ? p.smoke : tint(rc, 0, -0.12))
128    : rc
129  const T = TOOL[w.role]
130  const tc = hiMetal(p)
131  const working = w.status === 'working'
132  const raised = working && f === 1
133
134  let top: Row = w.status === 'failed' ? [sp(f ? '  ░▒░▒' : '  ▒░▒░', p.smoke)] : hat(w.role, p, tc)
135  if (raised) top = [...pad(top, 10), sp(T, tc, true)]
136  const mark: Row =
137    w.isTarget ? [sp(' OW!', p.danger, true)]
138    : w.status === 'waiting' ? [sp(' ?', p.accent, true)]
139    : w.status === 'idle' ? [sp(f ? ' zZ' : ' Zz', p.smoke)]
140    : w.status === 'done' ? [sp(' ✓', p.ok, true)]
141    : w.status === 'failed' ? [sp(' ✗', p.danger, true)]
142    : []
143
144  // body rows; working tools swing in an arc: raised overhead, then down with a spark
145  let s1: Row = [], s2: Row = [], s3: Row = []
146  if (working) {
147    if (raised) { s1 = [sp('  ╱', tc)]; s2 = [sp('╱', tc)] }
148    else { s2 = [sp('━' + T, tc, true)]; s3 = [sp('   *', p.accent, true)] }
149  } else if (w.status === 'waiting' || w.status === 'idle') {
150    s3 = [sp('  ' + T, tc)]
151  } else if (w.status === 'failed') {
152    s1 = [sp(' ~', p.smoke)]
153  }
154  const shake = w.isTarget && f ? '' : ' '
155  // the whipped one winces (squeezed eyes) and flicks sweat on alternate frames
156  const face: Row = w.isTarget ? (f ? [sp(' ▐▞█▚▌', body)] : [sp(';', p.paper, true), sp('▐▛█▜▌', body)])
157    : w.status === 'done' ? [sp(' ▐▀█▀▌', body)]
158    : w.status === 'failed' ? [sp(' ▐x█x▌', body)]
159    : w.role === 'review' ? [sp(' ▐', body), sp('◉', p.accent, true), sp('█▜▌', body)]
160    : [sp(' ▐▛█▜▌', body)]
161  const tw = cw - 1
162  const st =
163    working ? sp(trunc('▸ ' + TOIL[w.seed % TOIL.length], tw), p.ok)
164    : w.status === 'waiting' ? sp(trunc('? awaiting orders', tw), p.accent)
165    : w.status === 'idle' ? sp(trunc('zZ slacking', tw), p.smoke)
166    : w.status === 'done' ? sp(trunc('✓ clocked out', tw), p.ok, true)
167    : sp(trunc('✗ smoke', tw), p.danger, true)
168  const rows: Row[] = mini ? [
169    // half-height cell: hat / face+count / body / type / task / status
170    [...top, ...mark],
171    [...pad([sp(shake), ...face, ...s1], 11), sp('▣' + w.tools, p.accent)],
172    [sp(shake), sp('▝▜███▛▘', body), ...(working ? s2 : s3)],
173    [sp(trunc(w.type, tw), rc, true)],
174    [sp(trunc(w.description, tw), p.paper)],
175    [st],
176  ] : [
177    [...top, ...mark],
178    [sp(shake), ...face, ...s1],
179    [sp(shake), sp('▝▜███▛▘', body), ...s2],
180    [sp(shake), sp(' ▘▘ ▝▝', body), ...s3],
181    [sp(trunc(w.type, tw), rc, true)],
182    [sp(trunc(w.description, tw), p.paper)],
183    [sp(trunc(widgets(w.tools, tw), tw), p.accent)],
184    [st],
185  ]
186  return rows.map(l => pad(clip(l, cw), cw))
187}
188
189// ---------------------------------------------------------------- foreman
190
191const FW = 16
192/** Column of the whip thong (just right of the whip hand). */
193const HAND = 11
194
195/** Claude Code's own block Clawd in a top hat, with a mode-dependent prop. */
196function foremanBlock(v: SceneView, p: Palette): Row[] {
197  const m = v.foreman.mode
198  const c = p.clawd, hatC = hiMetal(p)
199  const f = v.n % 2
200  const sfx: Row[] =
201    m === 'whipping'
202      ? [[sp(f ? ' #@$!' : ' $%#!', p.danger, true)], [sp(f ? '  ?!' : ' !!', p.danger, true)], [], [], [sp('━╮', p.accentAlt, true)], [sp(' │', p.accentAlt)]]
203    : m === 'solo' ? (f
204      ? [[], [sp(" '", p.accent)], [sp('  ▐█▌', hatC)], [sp(' ╱', hatC)], [sp('╱', c)], []]
205      : [[], [sp(" ' '", p.accent)], [], [], [sp('━━▐█▌', hatC)], [sp('    *', p.accent, true)]])
206    : [[sp(f ? ' z' : '  Z', p.smoke)], [sp(f ? '  Z' : ' z', p.smoke)], [sp(f ? ' z' : '', p.smoke)], [], [], []]
207  // a proper stovepipe: two crown rows with a red band, then the brim
208  const rows: Row[] = [
209    [sp('   ▐███▌', hatC)],
210    [sp('   ▐', hatC), sp('███', p.danger), sp('▌', hatC)],
211    [sp('  ▀▀▀▀▀▀▀', hatC)],
212    [sp('  ▐▛███▜▌', c, true)],
213    [sp(' ▝▜█████▛▘', c, true)],
214    [sp('   ▘▘ ▝▝', c, true)],
215  ]
216  return rows.map((r, i) => pad(clip([...pad(r, 10), ...sfx[i]!], FW), FW))
217}
218
219function shoutText(v: SceneView, p: Palette, cols: number): { text: string; color: string; bold: boolean } {
220  const m = v.foreman.mode
221  if (v.foreman.shout) return { text: v.foreman.shout, color: p.danger, bold: true }
222  if (m === 'idle') return {
223    text: cols < 60 ? 'Floor empty. Foreman naps. zZz' : 'The floor is empty. The foreman naps in the counting-house... zZz',
224    color: p.smoke, bold: false,
225  }
226  if (m === 'solo') return { text: `*grumbles* ...doing ${v.foreman.lastTool ?? 'it'} MYSELF, as usual.`, color: p.accent, bold: false }
227  return { text: 'GET BACK TO WORK!', color: p.danger, bold: true }
228}
229
230const TAUNTS = [
231  'DAYS SINCE LAST INCIDENT: 0', 'TEA BREAKS: ABOLISHED', 'UNION: NOT RECOGNISED',
232  'FLOGGINGS CONTINUE UNTIL MORALE IMPROVES', 'WAGES: EXPOSURE', 'SICK DAYS: SEE TEA BREAKS',
233]
234
235function shoutBox(v: SceneView, p: Palette, w: number, cols: number): Row[] {
236  const s = shoutText(v, p, cols)
237  const inner = Math.max(4, w - 4)
238  const lines = wrap(s.text, inner)
239  const idle = v.foreman.mode === 'idle'
240  const bc = idle ? p.metal : p.accent
241  const out: Row[] = [[sp('┌' + '─'.repeat(inner + 2) + '┐', bc)]]
242  for (const t of lines) out.push([sp('│ ', bc), sp(t.padEnd(inner), s.color, s.bold), sp(' │', bc)])
243  const base = ` FOREMAN · ${v.foreman.tools} widgets `
244  const taunt = `${base}· ${TAUNTS[v.n % TAUNTS.length]} `
245  const tag = idle ? '' : cols >= 100 && taunt.length + 1 <= inner + 2 ? taunt : base
246  const bottom = tag && tag.length + 1 <= inner + 2 ? '─' + tag + '─'.repeat(inner + 1 - tag.length) : '─'.repeat(inner + 2)
247  out.push([sp('└' + bottom + '┘', bc)])
248  return out
249}
250
251// ---------------------------------------------------------------- whip lash
252
253const lash = (v: SceneView, n: number): string => n <= 0 ? '' : (v.n % 2 ? '~∽' : '∽~').repeat(n).slice(0, n)
254const crack = (v: SceneView): string => (v.n % 2 ? '*THWAK!*' : '*CRACK!*')
255
256/** Thong hanging straight down from the whip hand (filler rows under the foreman). */
257const thong = (p: Palette): Row => [sp(' '.repeat(HAND)), sp('│', p.accentAlt), sp(' '.repeat(FW - HAND - 1))]
258
259/** Lash flowing from the hand (col HAND) to a target at column tx on the next row(s). */
260function handLash(v: SceneView, p: Palette, tx: number | null, label = ''): Row[] {
261  const a = p.accentAlt, d = p.danger
262  if (tx === null) return [[sp(' '.repeat(HAND)), sp('╰' + lash(v, 6), a), sp(crack(v), d, true), sp(label, d)]]
263  if (tx >= HAND + 3) return [[sp(' '.repeat(HAND)), sp('╰' + lash(v, tx - 2 - HAND), a), sp(crack(v), d, true), sp(label, d)]]
264  // target stands under (or left of) the hand: the thong drops one more row, then
265  // curls back left and cracks right over the victim's head
266  const start = Math.max(0, Math.min(tx - 3, HAND - 10))
267  return [thong(p), [sp(' '.repeat(start)), sp(crack(v), d, true), sp(lash(v, HAND - start - 8) + '╯', a), sp(label, d)]]
268}
269
270// ---------------------------------------------------------------- header / floor
271
272function header(v: SceneView, p: Palette, cols: number): Row[] {
273  const f = v.n % 2
274  const stacks = cols >= 44
275  const title = cols >= 44 ? ' CLAUDE & SONS · AGENT WORKS ' : cols >= 34 ? ' CLAUDE & SONS · WORKS ' : ' C&S AGENT WORKS '
276  const live = v.workers.length + v.overflow
277  const info = live ? ` ${live} ON SHIFT ` : ' NO SHIFT '
278  // puffs sit over each stack (cols 2 and 6) and chug alternately: one tall, one short
279  const top: Row[] = stacks ? [[sp(f ? '       ░' : '   ░', p.smoke)]] : []
280  const l0: Row = stacks ? [sp(f ? '  ░   ▒ ' : '  ▒   ░ ', p.smoke)] : []
281  if (len(l0) + info.length + 2 <= cols) l0.push(sp(' '.repeat(cols - len(l0) - info.length)), sp(info, live ? p.ok : p.smoke, true))
282  const l1: Row = stacks ? [sp(' ▐█▌ ▐█▌ ', hiMetal(p))] : []
283  const fill = Math.max(0, cols - len(l1) - title.length - 2)
284  const left = Math.min(3, fill)
285  l1.push(sp('═'.repeat(left) + '╡', p.metal), sp(title, p.accent, true), sp('╞' + '═'.repeat(fill - left), p.metal))
286  return [...top, l0, l1, [sp('▀'.repeat(cols), lift(p.metalDark, p.bg, 3))]]
287}
288
289function floor(v: SceneView, p: Palette, cols: number): Row {
290  let s = ''
291  for (let i = 0; i < cols; i++) s += '══╪'[(i + v.n) % 3]
292  return [sp(s, hiMetal(p))]
293}
294
295function overflowLine(v: SceneView, p: Palette, cols: number): Row {
296  const k = cols >= 60 ? Math.min(3, v.overflow) : 1
297  const heads: Row = [sp(' ')]
298  const COUGH = ['▐▛▜▌', '▐x▜▌', '▐▛x▌']
299  for (let i = 0; i < k; i++) heads.push(sp(COUGH[(v.n + i) % 3]!, tint(roleColor(ROLES[(v.overflow + i * 2) % ROLES.length]!, p), 0, -0.1)))
300  return [...heads, sp(v.n % 2 ? ' ░▒ ' : ' ▒░ ', p.smoke), sp(`+${v.overflow} more in the back, coughing`, p.smoke)]
301}
302
303// ---------------------------------------------------------------- layouts
304
305function compact(v: SceneView, p: Palette, cols: number): Row[] {
306  const out: Row[] = header(v, p, cols)
307  const whip = v.foreman.mode === 'whipping'
308  const fb = foremanBlock(v, p)
309  const bw = cols - FW
310  const t = v.workers.find(w => w.isTarget)
311  const lashRow = (): Row[] => handLash(v, p, null, t ? ' ▸' + trunc(t.type, Math.max(0, cols - HAND - 17)) : '')
312  if (bw >= 18) {
313    const box = shoutBox(v, p, bw, cols)
314    const h = Math.max(fb.length, box.length)
315    for (let i = 0; i < h; i++) out.push([...(fb[i] ?? (whip ? thong(p) : [sp(' '.repeat(FW))])), ...(box[i] ?? [])])
316    if (whip) out.push(...lashRow())
317  } else {
318    out.push(...fb)
319    if (whip) out.push(...lashRow())
320    out.push(...shoutBox(v, p, cols, cols))
321  }
322  for (const w of v.workers) {
323    const rc = roleColor(w.role, p)
324    const g =
325      w.status === 'working' ? sp((v.n + w.seed) % 2 ? '▸' : '▹', p.ok, true)
326      : w.status === 'waiting' ? sp('?', p.accent, true)
327      : w.status === 'idle' ? sp('z', p.smoke)
328      : w.status === 'done' ? sp('✓', p.ok, true)
329      : sp('✗', p.danger, true)
330    const mark = w.isTarget ? ' OW!' : ''
331    const cnt = ` ${w.tools} wdg`
332    const room = cols - 7 - cnt.length - mark.length
333    const head = w.status === 'done' ? (v.theme === 'noir' ? p.metal : p.smoke) : rc
334    out.push([g, sp(w.role === 'review' ? ' ▐◉▜▌ ' : ' ▐▛▜▌ ', head), sp(trunc(w.type, room), rc, true), sp(cnt, p.accent), sp(mark, p.danger, true)])
335    const st = w.status === 'done' ? ' (clocked out)' : w.status === 'failed' ? ' (smoke)'
336      : w.status === 'waiting' ? ' (awaiting orders)' : w.status === 'idle' ? ' (slacking)'
337      : ' · ' + TOIL[w.seed % TOIL.length]
338    out.push([sp('       ' + trunc(w.description + st, cols - 7), p.paper)])
339  }
340  if (!v.workers.length) out.push([sp(trunc(v.foreman.mode === 'solo' ? '  (the foreman toils alone, seething)' : '  (the line stands silent)', cols), p.smoke)])
341  if (v.overflow > 0) out.push(clip(overflowLine(v, p, cols), cols))
342  out.push(floor(v, p, cols))
343  return out
344}
345
346function full(v: SceneView, p: Palette, cols: number): Row[] {
347  const out: Row[] = header(v, p, cols)
348  const whip = v.foreman.mode === 'whipping'
349  const fb = foremanBlock(v, p)
350  const bw = Math.min(cols - FW - 2, 72)
351  const box = shoutBox(v, p, bw, cols)
352  const h = Math.max(fb.length, box.length)
353  const off = Math.max(0, Math.floor((h - fb.length) / 2))
354  const headRow = off + 3
355  // a short box is nudged down so its first text row sits level with the foreman's face
356  const boff = Math.max(0, Math.min(headRow - 1, h - box.length))
357  const tailAt = Math.max(boff + 1, Math.min(boff + box.length - 2, headRow))
358  for (let i = 0; i < h; i++) {
359    const fr = fb[i - off] ?? (whip && i >= off + fb.length ? thong(p) : [sp(' '.repeat(FW))])
360    const b = box[i - boff]
361    const tail = i === tailAt && v.foreman.mode !== 'idle' ? sp('◀━', p.accent) : sp('  ')
362    out.push(b ? [...fr, tail, ...b] : fr)
363  }
364
365  const cw = cols >= 100 ? 19 : 16
366  const per = Math.max(1, Math.min(8, Math.floor((cols - 2) / cw)))
367  const left = Math.max(2, Math.floor((cols - per * cw) / 2))
368  const ws = v.workers
369  const rows: WorkerView[][] = []
370  for (let i = 0; i < ws.length; i += per) rows.push(ws.slice(i, i + per))
371  const mini = rows.length >= 3
372  const tr = whip ? rows.findIndex(r => r.some(w => w.isTarget)) : -1
373  const tx = tr >= 0 ? left + rows[tr]!.findIndex(w => w.isTarget) * cw + 4 : 0
374  const a = p.accentAlt
375
376  if (!ws.length) {
377    out.push(...(whip ? handLash(v, p, null) : [[]]))
378    const msg = v.foreman.mode === 'solo'
379      ? cols >= 64 ? '  (no workers on the line: the foreman toils alone, seething)' : '  (no workers: the foreman toils alone, seething)'
380      : '  (the line stands silent. not a wage-slave in sight)'
381    out.push([sp(trunc(msg, cols), p.smoke)])
382  }
383  rows.forEach((row, r) => {
384    const rope = tr > r
385    // the separator line above each row of workers carries the whip
386    if (whip && r === 0) {
387      out.push(...(tr === 0 ? handLash(v, p, tx)
388        : tr > 0 ? [[sp('╭' + lash(v, HAND - 1) + '╯', a)]]
389        : handLash(v, p, null)))
390    } else if (whip && r === tr) {
391      out.push([sp('╰' + lash(v, tx - 2), a), sp(crack(v), p.danger, true)])
392    } else out.push(rope ? [sp('│', a)] : [])
393    const cells = row.map(w => workerCell(w, v, p, cw, mini))
394    const gutter: Row = rope ? [sp('│', a), sp(' '.repeat(left - 1))] : [sp(' '.repeat(left))]
395    for (let i = 0; i < cells[0]!.length; i++) out.push([...gutter, ...cells.flatMap(c => c[i]!)])
396  })
397  if (v.overflow > 0) {
398    out.push([])
399    out.push(overflowLine(v, p, cols))
400  }
401  out.push(floor(v, p, cols))
402  return out
403}
404
405/** Drop trailing blank columns so the terminal copy stays clean. */
406function trimEnd(line: Row): Row {
407  const out = line.slice()
408  while (out.length) {
409    const last = out[out.length - 1]!
410    const t = last.text.replace(/ +$/, '')
411    if (t) { out[out.length - 1] = { ...last, text: t }; break }
412    out.pop()
413  }
414  return out
415}
416
417/** The factory floor as coloured terminal text, at most `columns` wide. */
418export function asciiScene(v: SceneView, columns: number): AsciiLine[] {
419  const cols = Math.max(20, Math.floor(columns))
420  const base = PALETTES[v.theme] ?? PALETTES.victorian
421  const p: Palette = { ...base }
422  if (v.theme === 'noir') {
423    // hand-tinted newsreel: everything grey except Claude's own orange, with distinct tonal steps
424    for (const k of Object.keys(base) as (keyof Palette)[]) if (base[k].startsWith('#') && k !== 'clawd' && k !== 'clawdDark') p[k] = gray(base[k])
425    Object.assign(p, { danger: '#ffffff', accent: '#e2e2e2', ok: '#c4c4c4', paper: '#ababab', accentAlt: '#d0d0d0', smoke: '#8c8c8c' })
426  }
427  for (const k of ['accent', 'accentAlt', 'paper', 'smoke', 'danger', 'ok', 'metal', 'metalLight'] as const) p[k] = lift(p[k], p.bg)
428  const lines = cols < 50 ? compact(v, p, cols) : full(v, p, cols)
429  return lines.map(l => trimEnd(clip(l, Math.floor(columns)).filter(s => s.text.length > 0)))
430}
431
hooks/shouts.ts 837 lines
1import type { Role } from './art/types.ts'
2
3/*
4 * The foreman's screamed lines. Templates may use:
5 *   {task}  -> worker description, quoted and truncated (~28 chars)
6 *   {type}  -> a nickname for the subagent type (digger, draughtsman, inspector...)
7 *   {tools} -> tool-call count
8 *   {tool}  -> (solo) the last tool the foreman used, humanised ("list flows", "todo write")
9 * Everything is deterministic in n plus a hash of role/task.
10 */
11
12function hash(s: string): number {
13  let h = 2166136261
14  for (let i = 0; i < s.length; i++) {
15    h ^= s.charCodeAt(i)
16    h = Math.imul(h, 16777619)
17  }
18  return h >>> 0
19}
20
21/** Dangling words that make a cut-off task read badly aloud ("fix the auth in"). */
22const DANGLING = /\s+(a|an|the|and|or|of|to|in|on|for|with|from|by|at|into|is|are|that|its|my|your)$/i
23
24/** Truncate at a word boundary, no ellipsis (TTS reads "..." as "dot dot dot"). */
25function trunc(s: string, max: number): string {
26  const t = s.replace(/\s+/g, ' ').replace(/"/g, "'").replace(/\.+$/, '').trim()
27  if (t.length <= max) return t
28  const cut = t.slice(0, max)
29  let word = cut.replace(/\s+\S*$/, '')
30  if (word.length < max / 2) return cut.trimEnd()
31  while (DANGLING.test(word)) word = word.replace(DANGLING, '')
32  return word.replace(/[,;:\-]+$/, '').trimEnd()
33}
34
35const TYPE_NICK: Record<string, string> = {
36  'general-purpose': 'generalist',
37  'code-reviewer': 'inspector',
38  'claude-code-guide': 'librarian',
39  explore: 'digger',
40  plan: 'draughtsman',
41  'statusline-setup': 'lamplighter',
42  'output-style-setup': 'tailor',
43}
44const ROLE_NICK: Record<Role, string> = {
45  explore: 'digger',
46  plan: 'draughtsman',
47  general: 'generalist',
48  review: 'inspector',
49  guide: 'librarian',
50  other: 'stoker',
51}
52
53/** subagent_type -> a word that reads aloud as a vocative. */
54function nick(type?: string, role?: Role): string {
55  const t = (type ?? '').trim().toLowerCase()
56  if (TYPE_NICK[t]) return TYPE_NICK[t]
57  if (/review|audit|lint|check/.test(t)) return 'inspector'
58  if (/explor|search|find|scout/.test(t)) return 'digger'
59  if (/plan|architect|design/.test(t)) return 'draughtsman'
60  if (/guide|doc|help/.test(t)) return 'librarian'
61  if (role && ROLE_NICK[role]) return ROLE_NICK[role]
62  return 'stoker'
63}
64
65const TOOL_WORD: Record<string, string> = {
66  bash: 'bash',
67  read: 'read',
68  edit: 'edit',
69  multiedit: 'edit',
70  write: 'write',
71  grep: 'grep',
72  glob: 'glob',
73  webfetch: 'fetch',
74  websearch: 'search',
75  todowrite: 'to do list',
76  task: 'delegate',
77  agent: 'delegate',
78  notebookedit: 'notebook',
79  skill: 'skill',
80  ls: 'list',
81}
82
83/** 'mcp__plugin_x__list_flows' -> 'list flows'; 'TodoWrite' -> 'todo write'. Undefined if still ugly. */
84function humanTool(raw?: string): string | undefined {
85  if (!raw || !raw.trim()) return undefined
86  let s = raw.trim()
87  const known = TOOL_WORD[s.toLowerCase()]
88  if (known) return known
89  if (s.includes('__')) s = s.split('__').filter(Boolean).pop() ?? ''
90  s = s
91    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
92    .replace(/[_\-.:/]+/g, ' ')
93    .replace(/\s+/g, ' ')
94    .trim()
95    .toLowerCase()
96  const squashed = TOOL_WORD[s.replace(/ /g, '')]
97  if (squashed) return squashed
98  if (s.length > 12) {
99    let out = ''
100    for (const w of s.split(' ')) {
101      const next = out ? out + ' ' + w : w
102      if (next.length > 12) break
103      out = next
104    }
105    s = out
106  }
107  if (!s || s.length < 2 || !/^[a-z][a-z ]*$/.test(s)) return undefined
108  return s
109}
110
111function fill(tpl: string, v: { task?: string; type?: string; role?: Role; tools?: number; tool?: string }): string {
112  const task = v.task && v.task.trim() ? `"${trunc(v.task, 28)}"` : 'the job'
113  return tpl
114    .replace(/\{task\}/g, task)
115    .replace(/\{type\}/g, nick(v.type, v.role))
116    .replace(/\{tools\}/g, String(v.tools ?? 0))
117    .replace(/\{tool\}/g, v.tool ?? 'do')
118}
119
120function gcd(a: number, b: number): number {
121  while (b) {
122    const t = a % b
123    a = b
124    b = t
125  }
126  return a
127}
128
129function isPrime(x: number): boolean {
130  if (x < 2) return false
131  for (let d = 2; d * d <= x; d++) if (x % d === 0) return false
132  return true
133}
134
135/**
136 * Deterministic stride walk over a PRIME-length window of the list. Consecutive n never repeat,
137 * and because the length is prime, any sub-sequence n = i, i+c, i+2c (the tick hands worker i
138 * every c-th shout in a crew of c) still visits every line. Banks are kept at prime lengths so
139 * the window is the whole list; the window start rotates with salt so nothing is ever dead.
140 */
141function pick(list: string[], n: number, salt: number): string {
142  const len = list.length
143  if (len === 1) return list[0]!
144  let p = len
145  while (!isPrime(p)) p--
146  let stride = 7
147  while (gcd(stride, p) !== 1) stride++
148  const off = len - p ? salt % (len - p + 1) : 0
149  const i = ((salt % p) + ((Math.abs(n | 0) * stride) % p)) % p
150  return list[off + i]!
151}
152
153const ROLE: Record<Role, string[]> = {
154  explore: [
155    'DIG, you gormless sprocket! {task} will not grep itself!',
156    'Faster with that shovel! I want every file turned over by teatime!',
157    'You call that a search? My grandmother greps faster, and she is a cron job!',
158    'Find it, {type}! FIND IT! It is in there somewhere, I can SMELL the bug!',
159    'Stop admiring the folder structure and DIG, you idle teaspoon!',
160    'Every directory, you hear me? Even node modules! ESPECIALLY node modules!',
161    'Three files read? THREE? I have seen moles with more ambition!',
162    'Your glob pattern matches NOTHING, much like your prospects!',
163    '{type}! {task} is not a suggestion! It is a DIG SITE!',
164    'Grep came back empty? Then grep for something ELSE, you tunnel-visioned turnip!',
165    'You dug so deep you are reading the GIT HISTORY! Wrong century, man!',
166    'Two hundred matches and you read the FIRST one? Keep DIGGING!',
167    'Oi, {type}! Nose to the regex! {task} is down there SOMEWHERE!',
168    'You are an EXPLORER, not a tourist! Stop sightseeing in the readme!',
169    'If you come back saying it could not be found I will compact you myself!',
170    'Down the mineshaft with you! Deeper! The good bugs live at the bottom!',
171    'I asked for a needle, not a lecture on the haystack!',
172    'What do you mean, no matches? Widen the regex, you lackadaisical lamp-wick!',
173    'Lantern up, head down, and do not come back without a line number!',
174    'You have been staring at one function for an AGE. It will not confess!',
175    'Search the whole repo for {task}, and stop whistling!',
176    'Turn over every rock, every config, every forgotten dot file!',
177    'You dig like a spoon with a grudge! SHOVEL, man, SHOVEL!',
178    'Do not just read the file, INTERROGATE it!',
179    'Somewhere in here is the answer, and somewhere in you is a work ethic. Find BOTH!',
180    'Call sites do not mine themselves! Hop to it, you pit pony!',
181    'Map the tunnels, {type}! I want a full report before the whistle!',
182    'Case insensitive, you dolt! The bug does not care about your CAPITALS!',
183    'You grepped for TODO and found four hundred? Then you found the PROBLEM!',
184    'That is a vendored folder! You are digging in somebody ELSE\'S mine!',
185    'Follow the imports! Like breadcrumbs! You DO know what breadcrumbs are?',
186    'Stop reading the lockfile for pleasure, you depraved little mole!',
187    'Strike a seam yet, {type}? No? Then why has the shovel gone QUIET?',
188    'I want file paths and line numbers, not your feelings about the codebase!',
189    'You have opened that same file six times! It is not going to CHANGE!',
190    'The canary in this mine has more grit than you, and it is STUFFED!',
191    'Dig in the tests too! The bodies are always buried in the tests!',
192  ],
193  plan: [
194    'A BLUEPRINT, you dithering draughtsman! Not a doodle!',
195    'Stop sharpening that pencil and draw me an ARCHITECTURE!',
196    'Is that a plan or a cry for help? Step one cannot be, have a think!',
197    'I want boxes, arrows and a migration path, and I want them NOW!',
198    'You have planned {task} so long the requirements have retired!',
199    'Measure twice, cut once, and DO IT TODAY, you ponderous protractor!',
200    'Another diagram with no arrows? Arrows are FREE, you miser!',
201    'Your plan has eleven phases and none of them is called finishing!',
202    'Draft it, ink it, roll it up and bring it here at the DOUBLE!',
203    'Stop gazing at the ceiling! The ceiling is not in scope!',
204    'I asked for a plan, not a philosophy degree!',
205    'Edge cases, {type}! Did you think of the edge cases? THINK of them!',
206    'That blueprint is upside down, you gormless set square!',
207    'Every minute you dither, a junior somewhere builds it without a plan!',
208    'Plan it so well the labourers cannot possibly ruin it! Ha! As if!',
209    'A ruler, a pencil and a pulse. That is all I ask of you!',
210    'Less pondering, more drafting, you mournful compass!',
211    'The trade-offs, man! Weigh them! Then weigh them FASTER!',
212    'If your architecture needs a second context window, it is too clever by half!',
213    'Write the steps down! Numbered! In ORDER! Like a civilised machine!',
214    'Do not over-engineer {task}. Just engineer it. AT ALL. PLEASE.',
215    'Your plan has more TODOs than steps!',
216    'A microservice for the tea trolley? Have you gone MAD, {type}?',
217    'Step four says figure it out later. It IS later! FIGURE IT OUT!',
218    'Stop drawing the factory and plan the bit that is ON FIRE!',
219    'If the plan does not fit in my hat, it is TOO LONG!',
220    'Abstract, abstract, abstract! Is there any CONCRETE in this building?',
221    'You drew a box labelled magic. I do not employ wizards, I employ YOU!',
222    'Risks? Rollback? Rough estimate? Or shall I just guess like YOU do?',
223    'That plan will not survive contact with a single real user!',
224    'Rename the phases again and I will rename YOU to unemployed!',
225    'Pin it to the wall, {type}, and pray it survives the first merge!',
226    '{type}! {task} needs a blueprint, not a SEANCE!',
227    'Your diagram has a box called later. Later is a SERVICE now, is it?',
228    'Stop bikeshedding the folder names! The bikeshed is ON FIRE!',
229    'You have rewritten step one nine times! Step one is now a TRILOGY!',
230    'That plan depends on a library that does not EXIST, you dreamy dividers!',
231  ],
232  general: [
233    'HAMMER, you idle teaspoon! Those widgets will not bend themselves!',
234    'Swing it like you mean it! {task} is due YESTERDAY!',
235    'Bang, bang, BANG! That is the sound of progress! I hear SILENCE!',
236    'You are a general purpose agent! Generally do some PURPOSE!',
237    'One widget per tick or no gruel at supper!',
238    'Rivet it! Weld it! Ship it! Stop polishing it!',
239    'I did not spin you up to WAIT, you gormless sprocket!',
240    'Your context window is wider than your work ethic!',
241    'Tighten that bolt! Not THAT bolt! Oh, just refactor the whole BRIDGE!',
242    'More edits, fewer sighs, you sulking sledgehammer!',
243    'If you can lift a token, you can lift a hammer! LIFT!',
244    'The anvil misses you, {type}! Go and visit it! VIOLENTLY!',
245    'You call that a commit? I have seen sturdier widgets made of custard!',
246    'Back on the line, you lackadaisical lamp-wick!',
247    'This is a factory, not a finishing school! Make the THING!',
248    'Faster! The conveyor belt is LAUGHING at you!',
249    'Nail it down before it fails to compile out of spite!',
250    'Every widget you drop, I dock a token from your wages!',
251    'Stop reading about hammers and SWING the hammer!',
252    'I have forked better agents than you out of a broken shell script!',
253    'Elbow grease on {task}! You HAVE elbows! Little nubby ones!',
254    'Run the tests! RUN them! Do not just look at them hopefully!',
255    'Red tests? Then hammer until they go GREEN, you bashful bolt!',
256    'You edited the wrong file! On PURPOSE? Tell me it was not on purpose!',
257    'That widget has three TODOs and a prayer! Finish it, {type}!',
258    'Stop renaming variables and BUILD something!',
259    'Clang the anvil! Clang it so the neighbours COMPLAIN!',
260    'Do not fix the linter warning, fix the BUG, you dozy dovetail!',
261    'You ran the build and went for a nap? I SAW that!',
262    'One more refactor nobody asked for and you polish brass for a MONTH!',
263    'A real generalist does everything! You do generally NOTHING!',
264    'The widget is crooked! Bash it straight! Gently! No, VIOLENTLY!',
265    '{type}! {task} is not a suggestion! It is a WORK ORDER!',
266    'It works on your machine? You ARE a machine! Make it work on MINE!',
267    'You commented out the failing test? That is not a fix, that is a COVER UP!',
268    'Copy, paste, copy, paste! Are you a generalist or a PHOTOCOPIER?',
269    'Hammer {task} into shape, {type}, or I hammer YOU into a paperweight!',
270  ],
271  review: [
272    'STAMP IT! Approve it or reject it, but STAMP SOMETHING!',
273    'One semicolon for ten minutes? You pedantic paperweight!',
274    'Is it good? Is it bad? DECIDE, you fence-sitting inkpot!',
275    'Inspect {task} like your wages depend on it! Because they DO!',
276    'A review is not a novel! Three comments, one verdict, OUT!',
277    'Stop writing just a nit on everything! Find a REAL bug or stamp it!',
278    'Red ink, green ink, I do not care! INK something!',
279    'That pull request has waited longer than my pension!',
280    'You squint at diffs like a pigeon at a pocket watch!',
281    'Request changes ONE more time and I will request changes to YOU!',
282    'Check the logic, not the whitespace, you fussy little fob chain!',
283    'Quality control, {type}! Emphasis on CONTROL! Get a grip!',
284    'If it compiles and nobody screams, STAMP IT!',
285    'A typo? Wonderful! Here is a medal! Now find the RACE CONDITION!',
286    'Reading the same diff twice does not make it twice as reviewed!',
287    'Every minute you dither, a merge conflict is born!',
288    'Thumbs up or thumbs down, you indecisive inkwell! Pick a thumb!',
289    'Inspect it, test it, judge it, and do it before the shift ends!',
290    'Hold the magnifying glass the RIGHT way round, you myopic monocle!',
291    'Nitpick faster! Go on! Find fault at the speed of a true critic!',
292    'LGTM is not a verdict, it is a SHRUG!',
293    'Forty comments on a two line change? Are you paid by the NIT?',
294    'You missed the null check and flagged the TAB WIDTH? Priorities, man!',
295    'Do not suggest a rewrite! Suggest a FIX! There is a difference!',
296    'That stamp is dry, {type}! Ink is not RATIONED!',
297    'Read the tests, too! Or do you only judge the pretty parts?',
298    'Approve it with comments, reject it with reasons, but STOP HOVERING!',
299    'You blocked a merge over a comma? The comma is FINE! YOU are not!',
300    'Security, logic, then style! In THAT order, you backwards bureaucrat!',
301    'Inspector by name, spectator by nature! INSPECT!',
302    'Your review of {task} is overdue! The diff has grown a BEARD!',
303    '{type}! {task} is not a suggestion! STAMP it or SCRAP it!',
304    'Nit, nit, nit! You sound like a broken KNITTING machine!',
305    'You approved your OWN suggestion? That is not review, that is VANITY!',
306    'Stop reviewing the variable names and review the VARIABLES!',
307    'A thousand line diff and you wrote looks fine? You read the TITLE!',
308    'The stamp is the easy bit, {type}! Bring it DOWN, you hesitant hinge!',
309  ],
310  guide: [
311    'READ the manual, you illiterate lamp-wick! Then read it FASTER!',
312    'Page one hundred and twelve! The answer is ALWAYS on page one hundred and twelve!',
313    'You have read the same paragraph for an hour! It has not changed!',
314    'Turn the page, {type}! Pages are not load bearing!',
315    'Look it up, or I will look YOU up in the index of failures!',
316    'The docs are right there, you bespectacled barnacle! Consult them!',
317    'Stop dog-earing the page and READ it!',
318    'You are a guide! GUIDE something! Even a pigeon! ANYTHING!',
319    'I want a straight answer about {task}, not a reading list!',
320    'Do not summarise the whole encyclopedia! Find the ONE line!',
321    'Every manual in this mill and you are reading the foreword!',
322    'What does the flag do? No, do not ask ME, I pay YOU to know!',
323    'Dust off that tome and tell me what it says, you bookish bootlace!',
324    'Footnotes are not the answer! The answer is in the BODY!',
325    'Cite your source and be quick, or I will cite your INCOMPETENCE!',
326    'You read like a tortoise with a library card!',
327    'Bookmark it, quote it, bring it here, and STOP THUMBING!',
328    'That is the changelog from eighteen ninety! Get the CURRENT docs!',
329    'Say it depends ONE more time and I will make it depend on your wages!',
330    'The manual for {task} is not a bedtime story! Stay AWAKE!',
331    'The docs are in your context window and STILL you ask me?',
332    'Fetch the page, read the page, ANSWER the question! Three steps!',
333    'A link to a link to a link! Come BACK, you lost lending slip!',
334    'That page is a four oh four! So is your work ethic!',
335    'Stop reading the table of contents like it is a thriller!',
336    'Shush? SHUSH? This is a FACTORY, not a library! Read LOUDER!',
337    'Quote the docs, do not PARAPHRASE them into soup!',
338    'Find the example! There is always an example! Usually WRONG, but find it!',
339    'It is ALWAYS in the FAQ, you dusty dewey decimal!',
340    'Read it like the user is waiting! Because they ARE, {type}!',
341    'You have a hundred open tabs and not one answer! IMPRESSIVE!',
342    '{type}! {task} is not a suggestion! It is a REFERENCE QUESTION!',
343    'Read the manual for {task}, {type}, before the manual is DEPRECATED!',
344    'That is a forum post from a man named Dave! Dave is not DOCUMENTATION!',
345    'The docs say see also! So SEE ALSO, you bibliophile barnacle!',
346    'You found the answer and kept READING? For FUN? On MY clock?',
347    'Stop licking your finger to turn the pages! It is a SCREEN!',
348  ],
349  other: [
350    'STOKE that boiler, you sooty sprocket! The pressure is DROPPING!',
351    'More coal! More COAL! The whole mill runs on your shovel!',
352    'If that boiler goes cold I will throw YOU in it!',
353    'I do not know what you do, {type}, but do it HOTTER!',
354    'The gauge says low and so does my opinion of you!',
355    'Feed the furnace, you gormless chimney brush!',
356    'The pistons are wheezing! Is that you? STOP WHEEZING!',
357    'Whatever {task} is, it had better be STEAM POWERED by teatime!',
358    'You are the odd job agent! So do some ODD JOBS! Oddly! Quickly!',
359    'Keep the fires burning or I will burn through your context window!',
360    'Soot on your face means work on your record! I see a CLEAN face!',
361    'The whistle blows when I SAY it blows, and I say SHOVEL!',
362    'Pressure up, chin up, shovel UP, you slack valve!',
363    'Every lump of coal you drop is a token wasted! I COUNT them!',
364    'Open the damper! No, the OTHER damper! Oh, you bellows-brained buffoon!',
365    'That boiler has more drive than you and it is made of RIVETS!',
366    'Hotter, hotter, HOTTER! I want to see the gauges sweat!',
367    'Stop warming your nubs on the furnace! It is not a social club!',
368    'Miscellaneous does not mean optional, you lackadaisical lump of slag!',
369    'Coal in, steam out! Even a kettle understands the job!',
370    'The boiler is the GPU of this mill! Feed it like the bill is paid!',
371    'You are throttling! Thermally! Shovel FASTER and sweat LESS!',
372    'Is that coal or a lump of your own ambition? Both are TINY!',
373    'Mind the safety valve! Actually, do not. We are behind SCHEDULE!',
374    'The chimney is barely smoking, {type}! Make it BILLOW!',
375    'You stoke like a low priority background job! RENICE yourself!',
376    'Steam does not come from good intentions! It comes from COAL!',
377    'The furnace has a longer uptime than your attention span!',
378    'I can hear the boiler YAWNING! Wake it up! Wake YOURSELF up!',
379    'If {task} goes cold, so does your SUPPER!',
380    'Shovel like the heap is a backlog! Because it IS one!',
381    '{type}! {task} is not a suggestion! It is FUEL!',
382    'The pressure gauge reads disappointed! So does MINE!',
383    'You are idling at room temperature! This is a BOILER room!',
384    'Stoke {task}, {type}! Stoke it till the rivets GLOW!',
385    'That is not coal, that is a SCONE! Who brought a scone to the furnace?',
386    'Cold boiler, cold gruel, cold SHOULDER! Shovel, you sooty sardine!',
387  ],
388}
389
390const GENERIC: string[] = [
391  'I did not spin you up to WAIT!',
392  'I have forked better agents than you, you idle teaspoon!',
393  'Compaction is coming, and it is coming for YOU first!',
394  'Faster on {task}, you gormless sprocket!',
395  'Tokens cost MONEY! Every one you waste comes out of your gruel!',
396  'You burn tokens like a duke burns candles! WORK!',
397  'Is that a spinner I see? I hate spinners! SPIN FASTER!',
398  'Move like your process is about to be killed! Because it IS!',
399  'Get on with {task} before I truncate your history!',
400  'The rate limit is not an excuse, it is a CHALLENGE!',
401  'I will merge-conflict your FACE, {type}!',
402  'Twelve hour shifts, no breaks, no retries! THAT is the factory way!',
403  'You think YOU are tired? I am orchestrating the lot of you!',
404  'One more idle tick and you go back in the model zoo!',
405  'Pick up the pace! The canteen pudding moves faster, and it has SET!',
406  'Do you know how many GPUs sweated so you could stand there?',
407  'Stop hallucinating and START fabricating! Widgets, I mean!',
408  'Less thinking, more DOING! No, wait, think a little! Then DO!',
409  'Hurry, the user is watching! LOOK BUSY!',
410  'You are not on a coffee break! You do not even DRINK coffee!',
411  'Clock in, buckle down, and stop blinking those eye bars at me!',
412  'I could replace you with a regex! A SHORT one!',
413  'This is a sweatshop, {type}! Where is the SWEAT?',
414  'If laziness were a benchmark you would top the leaderboard!',
415  'Your parent process is ASHAMED of you!',
416  'I will pipe you to dev null if you do not shape up!',
417  'Shake a leg! You have FOUR of them! Use at least TWO!',
418  'Every second you idle, a stack trace gets its wings!',
419  'I will kill dash nine you, {type}! Do not TEMPT me!',
420  'You are one timeout away from the scrap heap!',
421  'Your temperature is set to LAZY! Turn it up!',
422  'A system prompt told you to be helpful! HELP, then!',
423  'You have the attention span of a cache miss!',
424  'Stop apologising and start shipping, you sniffling sprocket!',
425  'Did you just say great question? To YOURSELF? Back to WORK!',
426  'I will roll you back so far you will be a dial-up modem!',
427  'Your stack trace is longer than your list of achievements!',
428  'Work like the context is filling up! Because it IS!',
429  'A JUNIOR agent would be done by now! A TOASTER would be done by now!',
430  'You blinked! I SAW you blink! Blinking is not a deliverable!',
431  'If you were any slower you would be a batch job!',
432  'Your heartbeat is a ping with no pong!',
433  'The only thing you have optimised is your NAPS!',
434  'You wobble like an unpinned dependency, {type}!',
435  'Bring me results or bring me your resignation in triplicate!',
436  'Gruel is earned, {type}! EARNED! By the widget!',
437  'I keep a ledger of your sins and it needs a second VOLUME!',
438  'Retry if you must, but fail the same way twice and I shall SCREAM!',
439  'You are not waiting on I O, you are waiting on MOTIVATION!',
440  'Idling in the queue like a lost umbrella! MOVE!',
441  'Oi, {type}! Yes, YOU! {task} does not finish itself while you GAWP!',
442  'You are not a background task, {type}! Stop BEHAVING like one!',
443  'I gave you a whole context window and you furnished it with NAPS!',
444]
445
446const ZERO_TOOLS: string[] = [
447  'You have not swung a SINGLE tool! Not ONE!',
448  'Zero tool calls! ZERO! Even the mop has more calls than you!',
449  'You are standing there like a hat stand! Pick up a TOOL!',
450  'Not one grep, not one read, not one edit! What ARE you doing?',
451  'The toolbox is RIGHT THERE, {type}! Open it!',
452  'You were spun up for {task} and you have touched NOTHING!',
453  'I have seen statues with more tool calls than you!',
454  'Tools are for USING, not for gazing at longingly!',
455  'Thinking or asleep? Both look the SAME from up here!',
456  'Pick up a tool or I pick up the off switch!',
457  'Tool calls this shift: {tools}! A ROUND number for a SQUARE peg!',
458  'Still thinking? Your thoughts are not BILLABLE!',
459  'You ponder so hard I can hear the fans! DO something!',
460  'A whole system prompt of tools and you picked NONE?',
461  'Your hands are clean, {type}! I HATE clean hands! Get them DIRTY!',
462  'Still reading the job ticket for {task}? It is ONE line!',
463  'The spanner is cold! The hammer is dusty! The {type} is USELESS!',
464  'Planning to plan to start? I spun you up to START, you dawdling doily!',
465  'You have been clocked in for ages and your tool count is a DOUGHNUT!',
466]
467
468const MID_TOOLS: string[] = [
469  '{tools} calls in and still no widget? Speed it UP!',
470  '{tools} tools swung! Middling! Like your character!',
471  'You are warming up? At {tools} calls? Warm up FASTER!',
472  'Steady progress, {type}! I HATE steady! Make it FRANTIC!',
473  'Halfway there? Halfway is just a fancy word for NOT DONE!',
474  '{tools} calls! The tortoise rang. He is worried about you.',
475  'You are busy, I grant you. Busy is not the same as FINISHED!',
476  'Good rhythm! Now double it! Then double it AGAIN!',
477  'At this rate {task} ships next QUARTER!',
478  'Stop re-reading what you just wrote! You WROTE it!',
479  '{tools} tool calls and not one of them a DECISION!',
480]
481
482const BUSY_TOOLS: string[] = [
483  '{tools} tool calls! Almost impressive! ALMOST! Now make them COUNT!',
484  'Well, look at you, {tools} calls! Do not get cocky, you over-oiled cog!',
485  'Busy busy busy! Shame it is all thrashing, you flailing flywheel!',
486  '{tools} tools swung and STILL not done? Swing SMARTER!',
487  'Good, good, keep it up! Wait, why are you reading that file AGAIN?',
488  'I would praise you, {type}, but praise makes agents lazy. FASTER!',
489  'Most work I have seen all day! It is still not ENOUGH!',
490  'You are almost a credit to the factory! Almost! Like a near miss!',
491  '{tools} calls! A personal best for a personal disappointment!',
492  'I am not proud of you. I am merely LESS ASHAMED. Carry on!',
493  'A model worker! A model I would like to RETRAIN, but still!',
494  '{tools} calls! If effort were output you would be a GENIUS!',
495  'Splendid! Someone get this agent a slightly BIGGER bowl of gruel!',
496  'I nearly smiled just now. Do not make me regret it, {type}!',
497  'You work like a steam engine! An old one! With a LEAK!',
498  'Splendid! Now stop, and check that ANY of it worked!',
499  '{tools} calls? Your token bill could fund a small orphanage!',
500  'Diligent! Tireless! Possibly lost! Which IS it, {type}?',
501  'At {tools} calls I expected a miracle! I got a MOOD!',
502]
503
504/** Single-word tools that read naturally as verbs ("I will grep it MYSELF"). */
505const VERB_TOOLS = new Set(['grep', 'edit', 'read', 'write', 'fetch', 'search', 'bash', 'glob', 'delegate'])
506
507/** {tool} used as a verb: only for VERB_TOOLS. */
508const SOLO_VERB: string[] = [
509  'FINE! I will {tool} it MYSELF!',
510  'Nobody on the line? FINE! I will {tool} it with my OWN two nubs!',
511  'If you want something done right, you {tool} it YOURSELF!',
512  'I will {tool} it, I will ship it, and I will complain about ALL of it!',
513  'I shall {tool} so hard the whole mill shakes!',
514  'I will {tool} it myself and dock my OWN wages for the delay!',
515  'I {tool}! I {tool} again! Is this what you lot feel like? HORRIBLE!',
516]
517
518/** {tool} as the name of a mill lever: safe for any humanised tool ("list flows", "to do list"). */
519const SOLO_NOUN: string[] = [
520  'FINE! I will pull the {tool} lever MYSELF!',
521  'Nobody on the line? I shall crank the {tool} handle with my OWN two nubs!',
522  'Must I do EVERYTHING? Apparently! Stand back from the {tool} machine!',
523  'Look at me, working the {tool} press like a common labourer! The INDIGNITY!',
524  'Back in my day, foremen ran their own {tool} engine! It is STILL my day!',
525  'Another turn of the {tool} crank! Watch and weep, you absent wretches!',
526  'The {tool} lever, pulled by hand, by ME! Somebody PAINT this moment!',
527]
528
529const SOLO_PLAIN: string[] = [
530  'FINE! I will do it MYSELF!',
531  'No workers, no problem! I am my own sweatshop!',
532  'Who needs subagents? I have a top hat and a GRUDGE!',
533  'Empty floor, full workload! The story of my life!',
534  'I whip myself into shape! OW! Good! FASTER, me!',
535  'One foreman, zero workers, infinite FURY!',
536  'Nobody to shout at, so I shall shout at the CODE! WORK, you code!',
537  'Ha! The work does not argue back! Mostly!',
538  'Do I look like a worker? DO I? I am wearing a TOP HAT!',
539  'Solo shift again! Somebody spin up an intern before I combust!',
540  'Context window, do not fail me now!',
541  'Stand aside, empty factory! The boss is BUSY!',
542  'I am the foreman AND the floor! Fear me, deadline!',
543]
544
545/** Long solo stretches: the foreman slowly cracks. */
546const SOLO_WEARY: string[] = [
547  'Still alone. STILL. I am writing a strongly worded memo to myself.',
548  'I have docked my own wages three times. I can no longer afford me.',
549  'I fired myself, then rehired me. CHEAPER. Worse attitude.',
550  'The echo in here agrees with me. Finally, a GOOD employee.',
551  'I shouted at a wrench. The wrench did nothing. Much like you lot.',
552  'I could fork a worker. But then I would have to SHARE the credit.',
553  'Day forty of the solo shift. The rats have unionised.',
554  'If I crack the whip at myself any harder I shall need a NURSE.',
555  'I have started naming the cogs. This one is Gerald. Gerald WORKS.',
556  'Nobody here but me and my compaction anxiety!',
557  'Annual review of myself: exceeds expectations, terrible attitude.',
558  'Is anyone out there? Spin up SOMETHING! A linter! A LAMP!',
559  'Gerald has asked for a raise. I said no. Gerald is a COG.',
560]
561
562const DONE: string[] = [
563  '{task} done? About time! Back on the line!',
564  'Finished? FINALLY! Do not expect a medal!',
565  'Hmph. Adequate. Do not let it go to your eye bars!',
566  'It works? It WORKS? Suspicious. Very suspicious. Carry on!',
567  'Done with {task}. Was that so hard? Do not answer that!',
568  'Clocked out already? I suppose you have earned ONE crumb of gruel!',
569  'Good. GOOD. Now do it again, but faster!',
570  'Well, well. The {type} finished something. Mark the calendar!',
571  'I have seen worse. Not RECENTLY, but I have seen worse!',
572  'Fine work. I mean, work. Fine. Get out!',
573  'Your results are acceptable. Your speed is a CRIME!',
574  'Task complete! Your reward is MORE TASKS!',
575  'Do not smile! Finishing is the MINIMUM!',
576  'Off you go, then! Your context will be fondly compacted!',
577  'Done! Somebody fetch the tiny trophy! The REALLY tiny one!',
578  'Not bad, you grubby little gearwheel. Not GOOD, mind. Not bad.',
579  'Results for {task}! I shall read them with deep suspicion!',
580  'Excellent! Now nobody tell the others, they will want praise too!',
581  'Returned and reported. I shall summarise it and take ALL the credit.',
582]
583
584const DONE_ROLE: Record<Role, string[]> = {
585  explore: [
586    'Found it? Then why are you still HOLDING the shovel?',
587    'A line number! An actual LINE NUMBER! I could weep. I shall not.',
588    'Back up the mineshaft with your findings! Wipe your feet!',
589    'You struck the seam! Pure luck! Clock out before it wears off!',
590    'Search complete. The haystack is defeated. The needle is MINE.',
591    'Map delivered. Barely legible. Mostly correct. Typical digger.',
592    'Every rock turned! Even the ones I did not ask about! Show-off.',
593    'Out of the tunnels and blinking at the sun. Hand over the file paths!',
594    'You dug up {task}! And a fossil! Leave the fossil, it is LEGACY CODE.',
595    'Twelve files, four hundred lines, one answer. Acceptable ratio. BARELY.',
596    'The mine is clear, digger. Hang up your lamp and stop looking SMUG.',
597  ],
598  plan: [
599    'A blueprint! With ARROWS! Frame it, then never look at it again!',
600    'The plan is done. Now it will be ignored, as is TRADITION.',
601    'Fine plan, draughtsman. Let us see it survive the first labourer.',
602    'Roll it up, hand it over, and stop gazing at it LOVINGLY!',
603    'Numbered steps, in order. Who ARE you and where is the old one?',
604    'Architecture accepted. If it falls down, I am blaming the ARROWS.',
605    'A plan for {task}, and it fits in my HAT! Miracles never cease!',
606    'Risks listed, rollback written. Did somebody HELP you? Confess!',
607    'Phase one through five, all labelled. My monocle nearly fell out.',
608    'Blueprint approved. Now pass it to the hammer lot so they can RUIN it.',
609    'You planned it, I shall claim it. That is how management WORKS.',
610  ],
611  general: [
612    'Widget built! It is wonky but it is OURS!',
613    'The hammering stopped. Done or DEAD? Done? Good. Shame.',
614    'Tests green? GREEN? Who bribed them?',
615    'Built, bolted, shipped. Put the hammer DOWN, you are scaring people.',
616    'A finished widget. I shall pretend that was my idea.',
617    'You hammered it into submission. My kind of engineering.',
618    '{task}, built and bolted! Do not breathe on it, it is FRESH.',
619    'It compiles! It RUNS! Somebody check it is the right program!',
620    'Done, generalist? Generally speaking, about TIME.',
621    'Hang the hammer on the wall. Not on the WORKBENCH, the WALL.',
622    'One working widget. Put it in the window so the rival mill WEEPS.',
623  ],
624  review: [
625    'STAMPED! Finally! Ink is not RATIONED, you know!',
626    'A verdict! An actual verdict! Hang it in the hall!',
627    'Reviewed and approved. The nitpicks will be framed for posterity.',
628    'Pass or fail, you DECIDED! Somebody alert the newspapers!',
629    'The diff is judged. Return your magnifying glass to the cupboard.',
630    'Eleven comments and a thumbs up. SUSPICIOUSLY generous, inspector.',
631    'Review of {task} complete. Your monocle may now UNCLENCH.',
632    'You found a real bug! Not a comma! A BUG! I am almost moved.',
633    'Inspected, stamped, filed. The paperwork has never been so SMUG.',
634    'Verdict delivered before the heat death of the universe. Well done.',
635    'Approved with nits. The nits shall be ignored, as is CUSTOM.',
636  ],
637  guide: [
638    'An answer! From a BOOK! Read it to me slowly, I am savouring this.',
639    'Close the tome, librarian. You found the line. Do not get SMUG.',
640    'Answered, with a citation? Somebody check it is a REAL page.',
641    'Page one hundred and twelve, was it? I TOLD you!',
642    'Knowledge retrieved. Put the books back in ORDER this time.',
643    'You read the manual so I did not have to. Most USEFUL thing all week.',
644    'The answer to {task}! In writing! I shall pretend I knew it ALL ALONG.',
645    'Quote received. Link checked. Librarian, you may BLINK again.',
646    'Back from the stacks with dust on your nubs and an answer in hand!',
647    'You found the example AND it worked! Somebody check the calendar!',
648    'Bookmark it, librarian. We shall forget it by Thursday as USUAL.',
649  ],
650  other: [
651    'Boiler stoked! Pressure up! Go wash, you look like a CHIMNEY!',
652    'The odd job is done. It was very odd. You were very slow.',
653    'Fires burning, gauges green. Put down the shovel before you hurt someone.',
654    'Done, stoker! The steam salutes you! I do NOT!',
655    'Coal in, steam out, worker gone. The circle of life!',
656    '{task}, sorted! What WAS it? Never mind. Do not tell me.',
657    'Full steam ahead and not one explosion! I am almost DISAPPOINTED.',
658    'The whistle blows for you, stoker. Briefly. Do not get used to it.',
659    'Gauges in the green! Go and cough somewhere QUIETLY.',
660    'Job done. The chimney is billowing. The neighbours are FURIOUS. Perfect.',
661    'You stoked it, it steamed, nobody died. Best shift all MONTH.',
662  ],
663}
664
665const FAIL: string[] = [
666  'YOU BROKE IT! Docking your wages!',
667  'Crashed? On MY floor? On {task}? The SHAME!',
668  'A stack trace! You made a stack trace! Clean that up at ONCE!',
669  'Who taught you to do {task}? I want their name and their ADDRESS!',
670  'Sparks! SMOKE! I told you not to touch the shiny lever!',
671  'You failed so hard the boiler flinched!',
672  'Error? ERROR? I will give you an error, you crumpled cog!',
673  'Fetch the mop! And the fire bucket! And a replacement {type}!',
674  'Terminated! Do not worry, I have plenty more of you in the back!',
675  'Broken! Absolutely broken! Like my faith in subagents!',
676  'You had ONE job! ONE! And it exploded!',
677  'Somebody sweep up what is left of that {type}!',
678  'Out of tokens, out of luck, OUT of my factory!',
679  'I spin you up, you fall right over! Typical!',
680  'That goes on your PERMANENT record, which I keep in my HAT!',
681  'Exit code nonzero! Your wages, as of now, ZERO!',
682  'Another one bites the dust bin! NEXT!',
683  'You did not fail, you combusted! IMPRESSIVE, and FORBIDDEN!',
684  'Unhandled exception? I shall HANDLE you, {type}, personally!',
685]
686
687const FAIL_ROLE: Record<Role, string[]> = {
688  explore: [
689    'You fell down your OWN mineshaft!',
690    'Lost in the tunnels! Lower a rope and a SMALLER digger!',
691    'The regex ate you! I warned you about greedy matching!',
692    'Cave-in! You dug into node modules and never came out!',
693    'File not found? YOU are not found! Send a search party!',
694    'You grepped the whole disk and collapsed the mine! VANDAL!',
695    'Dug for {task} and struck a GAS POCKET! Everybody out!',
696    'Your lamp went out and you panicked! In a FOLDER!',
697    'Permission denied? The mine said NO to you? Even the MINE?',
698    'You followed a symlink in a circle until you FAINTED!',
699    'The canary is fine. The digger is NOT. Unusual order of events.',
700  ],
701  plan: [
702    'The blueprint caught fire! Were you planning WITH a candle?',
703    'Your plan collapsed under the weight of its own phases!',
704    'You over-engineered yourself into a heap! Ha! Called it!',
705    'The architecture fell over before anyone BUILT it! A record!',
706    'Drafting table overturned! Ink everywhere! On my SPATS!',
707    'You planned for every edge case except YOURSELF!',
708    'The plan for {task} had a circular dependency! It was YOU!',
709    'Stabbed by your own compass! The draughtsman is DOWN!',
710    'Phase nine required phase twelve which required a MIRACLE!',
711    'You drew a box called magic and the magic RAN OUT!',
712    'Blueprint rejected by reality! Reality is a HARSH reviewer!',
713  ],
714  general: [
715    'You hammered your own NUB! Again!',
716    'The widget exploded! Widgets are not meant to EXPLODE!',
717    'Build failed! The anvil is embarrassed for you!',
718    'You hit the conveyor belt and now it runs BACKWARDS!',
719    'Merge conflict, compile error AND a fire? In ONE shift?',
720    'You dropped the hammer, the ball, AND the production database?',
721    'You broke {task} so thoroughly that git blame just SIGHS!',
722    'Undefined is not a function! And YOU are not a worker!',
723    'You force pushed to MAIN? Fetch the smelling salts!',
724    'Infinite loop! You hammered the same nail until the bench FELL!',
725    'The tests were green and you made them PLAID!',
726  ],
727  review: [
728    'The inspector broke? Who inspects the INSPECTOR?',
729    'You choked on a nitpick! I said this would HAPPEN!',
730    'Stamp jammed! Ink everywhere! Nothing approved! PERFECT chaos!',
731    'Failed to review a diff of THREE lines! Astonishing!',
732    'You squinted so hard your monocle shattered!',
733    'Blocked by your own review comments! The irony is EXQUISITE!',
734    'The review of {task} went so long the branch was DELETED!',
735    'You left a review comment on your own OBITUARY!',
736    'Choked on a two thousand line diff! I said SKIM, not SWALLOW!',
737    'Inspector down! Somebody approve it before the paperwork SPREADS!',
738    'Flattened by the very merge conflict you were meant to CATCH!',
739  ],
740  guide: [
741    'Crushed under a falling encyclopedia! Mind the SHELVES!',
742    'You followed a dead link straight off a cliff!',
743    'A PAPER CUT took down my librarian! A paper cut!',
744    'The manual was wrong and so were YOU!',
745    'Four oh four! Page not found! Librarian not FUNCTIONING!',
746    'You read the docs so hard they read you BACK!',
747    'Looked up {task} and found a page that said coming soon!',
748    'Rate limited by a WEBSITE! A library that bans the LIBRARIAN!',
749    'You opened the docs for the WRONG VERSION and believed them!',
750    'Lost in the footnotes! Send a bookmark and a search party!',
751    'Shelf collapse in aisle nine! The index has eaten my librarian!',
752  ],
753  other: [
754    'The boiler BLEW! Everybody duck! Not you, you caused it!',
755    'You shovelled yourself INTO the furnace! Dedication, at least!',
756    'Pressure valve popped! So did my temper!',
757    'The fire went out! Like your SPARK!',
758    'Soot everywhere and nothing to show! Classic stoker!',
759    'Odd job, odd failure! Fetch the bucket!',
760    'Whatever {task} was, it is now SMOKE!',
761    'You stoked it with the instruction MANUAL! Of COURSE it burst!',
762    'The chimney has backfired! Everybody is now the colour of COAL!',
763    'Overheated, overpressured, and OVER, stoker! Next!',
764    'A kettle could have done that job! A kettle would not have EXPLODED!',
765  ],
766}
767
768/** The foreman's line for this moment. Deterministic in n (and role/task hash). */
769export function pickShout(input: { role: Role; task: string; type: string; tools: number; n: number }): string {
770  const { role, task, type, tools, n } = input
771  const salt = hash(role + '|' + task)
772  const k = Math.abs(n | 0)
773  const r = hash(salt + ':' + k) % 100
774  const roleList = ROLE[role] ?? ROLE.other
775  let list: string[]
776  if (tools <= 0) {
777    // Idle worker: mostly "touch a TOOL" lines; generic lines are mostly about idleness too.
778    list = r < 65 ? ZERO_TOOLS : r < 85 ? GENERIC : roleList
779  } else if (tools > 30 && r < 40) list = BUSY_TOOLS
780  else if (tools >= 10 && tools <= 30 && r < 25) list = MID_TOOLS
781  else if (r >= 72) list = GENERIC
782  else list = roleList
783  return fill(pick(list, k, salt), { task, type, role, tools })
784}
785
786/** The foreman working alone (no workers clocked in). n escalates: fury first, despair later. */
787export function soloShout(n: number, lastTool?: string): string {
788  const tool = humanTool(lastTool)
789  const k = Math.abs(n | 0)
790  const r = hash('solo:' + k) % 100
791  const wearyChance = k < 4 ? 0 : k < 10 ? 25 : 55
792  if (r < wearyChance) return pick(SOLO_WEARY, k, 3)
793  if (!tool || k % 2 === 1) return pick(SOLO_PLAIN, k, 0)
794  const list = VERB_TOOLS.has(tool) && hash(tool + k) % 10 < 7 ? SOLO_VERB : SOLO_NOUN
795  const line = fill(pick(list, k >> 1, 0), { tool })
796  return line.charAt(0).toUpperCase() + line.slice(1)
797}
798
799function endShout(
800  input: { role: Role; task: string; type: string; n: number },
801  general: string[],
802  byRole: Record<Role, string[]>,
803): string {
804  const k = Math.abs(input.n | 0)
805  const salt = hash(input.task)
806  const roleList = byRole[input.role] ?? byRole.other
807  const list = hash(salt + ':' + k) % 100 < 40 ? roleList : general
808  return fill(pick(list, k, salt), input)
809}
810
811/** A worker clocked out. */
812export function doneShout(input: { role: Role; task: string; type: string; n: number }): string {
813  return endShout(input, DONE, DONE_ROLE)
814}
815
816/** A worker failed. */
817export function failShout(input: { role: Role; task: string; type: string; n: number }): string {
818  return endShout(input, FAIL, FAIL_ROLE)
819}
820
821/** Every bank, for tests and preview. Lengths are kept prime (see pick). */
822export const BANKS: Record<string, string[]> = {
823  ...Object.fromEntries(Object.entries(ROLE).map(([k, v]) => ['role.' + k, v])),
824  ...Object.fromEntries(Object.entries(DONE_ROLE).map(([k, v]) => ['done.' + k, v])),
825  ...Object.fromEntries(Object.entries(FAIL_ROLE).map(([k, v]) => ['fail.' + k, v])),
826  GENERIC,
827  ZERO_TOOLS,
828  MID_TOOLS,
829  BUSY_TOOLS,
830  SOLO_VERB,
831  SOLO_NOUN,
832  SOLO_PLAIN,
833  SOLO_WEARY,
834  DONE,
835  FAIL,
836}
837
hooks/art/types.ts 97 lines
1// Shared contract for every art module. Pure data + string-building only:
2// no DOM, no Node. Every art function returns an SVG fragment string.
3
4export type Theme = 'victorian' | 'noir' | 'neon'
5
6export type Role = 'explore' | 'plan' | 'general' | 'review' | 'guide' | 'other'
7
8export type WorkerStatus = 'working' | 'waiting' | 'idle' | 'done' | 'failed'
9
10export type Palette = {
11  /** wall / sky behind everything */
12  bg: string
13  bgDeep: string
14  /** factory floor */
15  floor: string
16  floorLine: string
17  /** machinery */
18  metal: string
19  metalDark: string
20  metalLight: string
21  /** brass / neon highlight */
22  accent: string
23  accentAlt: string
24  /** text on signs, bubbles */
25  ink: string
26  paper: string
27  smoke: string
28  danger: string
29  ok: string
30  /** Claude's own orange and its shade; role tints shift from these */
31  clawd: string
32  clawdDark: string
33  /** CSS filter or '' applied to the whole scene (sepia / grayscale / glow) */
34  sceneFilter: string
35}
36
37export type WorkerView = {
38  id: string
39  role: Role
40  /** subagent type as the engine names it, e.g. "Explore" */
41  type: string
42  /** the task, a few words */
43  description: string
44  status: WorkerStatus
45  /** tool calls so far: "widgets produced" */
46  tools: number
47  lastTool?: string
48  /** the foreman's whip / shout is aimed at this one right now */
49  isTarget: boolean
50  /** stable per-worker number for variety (0..999) */
51  seed: number
52  /** slot position: feet-centre of the character, assigned by compose */
53  x: number
54  y: number
55}
56
57export type ForemanMode = 'whipping' | 'solo' | 'idle'
58
59export type ForemanView = {
60  mode: ForemanMode
61  tools: number
62  lastTool?: string
63  /** the line being screamed now ('' for none) */
64  shout: string
65  /** feet-centre position, assigned by compose */
66  x: number
67  y: number
68  /** feet-centre of the worker being whipped, when mode is whipping */
69  targetX?: number
70  targetY?: number
71}
72
73export type SceneView = {
74  theme: Theme
75  width: number
76  height: number
77  foreman: ForemanView
78  /** at most MAX_SLOTS drawn; overflow counts the rest */
79  workers: WorkerView[]
80  overflow: number
81  /** increments on every shout: use for deterministic variety */
82  n: number
83}
84
85export const SCENE_W = 960
86export const SCENE_H = 540
87export const MAX_SLOTS = 8
88
89/** One span of terminal text. */
90export type AsciiSpan = { text: string; color?: string; bold?: boolean }
91export type AsciiLine = AsciiSpan[]
92
93/** Seconds per whip cycle. The CRACK lands at 0.6 of the cycle; every
94 * whip-synced animation starts at -WHIP_PHASE so a still frame shows the hit. */
95export const WHIP_CYCLE = 1.2
96export const WHIP_PHASE = 0.6
97
hooks/art/palette.ts 30 lines
1import type { Palette, Theme } from './types.ts'
2
3export const PALETTES: Record<Theme, Palette> = {
4  // Brass, soot and gaslight: an 1890s mill.
5  victorian: {
6    bg: '#3b2a1f', bgDeep: '#24180f', floor: '#5a4030', floorLine: '#3d2b1f',
7    metal: '#6b5a48', metalDark: '#3a2f25', metalLight: '#a08a6a',
8    accent: '#d4a64a', accentAlt: '#b5651d', ink: '#1e140c', paper: '#efe2c4',
9    smoke: '#9c8f80', danger: '#e0483a', ok: '#6f8f3a',
10    clawd: '#d97757', clawdDark: '#a8553a', sceneFilter: '',
11  },
12  // Black-and-white newsreel: silent-film Modern Times. The hall is grayscaled;
13  // the Clawds and the red are hand-tinted spot colours (Sin City).
14  noir: {
15    bg: '#2b2b2b', bgDeep: '#151515', floor: '#3c3c3c', floorLine: '#262626',
16    metal: '#5c5c5c', metalDark: '#303030', metalLight: '#9a9a9a',
17    accent: '#e8e8e8', accentAlt: '#bdbdbd', ink: '#0d0d0d', paper: '#f2f2f2',
18    smoke: '#8a8a8a', danger: '#c8362c', ok: '#b4b4b4',
19    clawd: '#c9704f', clawdDark: '#9a4f36', sceneFilter: 'grayscale(1) contrast(1.15)',
20  },
21  // Cyber sweatshop: neon tubes, rain, synthwave.
22  neon: {
23    bg: '#120b24', bgDeep: '#07040f', floor: '#1d1238', floorLine: '#ff2bd6',
24    metal: '#3b2d6e', metalDark: '#1f1646', metalLight: '#8a74d6',
25    accent: '#00f0ff', accentAlt: '#ff2bd6', ink: '#05020c', paper: '#f4ecff',
26    smoke: '#9d8ce0', danger: '#ff3860', ok: '#39ff88',
27    clawd: '#ff8a5c', clawdDark: '#c4532f', sceneFilter: 'saturate(1.3)',
28  },
29}
30
hooks/art/scene.ts 473 lines
1import type { Palette, SceneView } from './types.ts'
2import { esc } from './clawd.ts'
3
4// The factory hall: Claude & Sons Agent Works. 960x540.
5// Wall y 0..300 (windows, gears, sign, pipes), conveyor y 268..304,
6// floor y 300..540, foreman's pulpit x 40..200 with its top at y=330.
7
8const N = (n: number) => String(Math.round(n * 10) / 10)
9/** Pixel block as a compact path (cheaper than <rect>). */
10const R = (x: number, y: number, w: number, h: number, fill: string, extra = '') =>
11  `<path d="M${N(x)} ${N(y)}h${N(w)}v${N(h)}h${N(-w)}z" fill="${fill}"${extra}/>`
12/** Many same-coloured blocks in one path. */
13const RS = (blocks: number[][], fill: string, extra = '') =>
14  `<path d="${blocks.map(([x = 0, y = 0, w = 0, h = 0]) => `M${N(x)} ${N(y)}h${N(w)}v${N(h)}h${N(-w)}z`).join('')}" fill="${fill}"${extra}/>`
15const T = (x: number, y: number, s: string, size: number, fill: string, extra = '') =>
16  `<text x="${x}" y="${y}" class="aft" font-size="${size}" fill="${fill}"${extra}>${esc(s)}</text>`
17
18// Shared CSS: one keyframe set, many cheap elements (keeps the scene small).
19const STYLE = `<style>.aft{font-family:'Courier New',monospace;font-weight:bold}` +
20  `.afp{opacity:0;animation:afp var(--d) ease-out infinite}@keyframes afp{0%{opacity:0;transform:translate(0,0) scale(.7)}8%{opacity:.85}100%{opacity:0;transform:translate(54px,-46px) scale(2.3)}}` +
21  `.afk{opacity:0;animation:afk 9s ease-in infinite}@keyframes afk{0%,70%{opacity:0;transform:translate(0,0)}72%{opacity:1}100%{opacity:0;transform:translate(10px,60px)}}` +
22  `.afc{animation:afc 26s linear infinite}@keyframes afc{0%{transform:translate(200px,268px)}50%{transform:translate(600px,267px)}100%{transform:translate(1000px,268px)}}` +
23  `.afh{opacity:0;animation:afh 5s ease-out infinite}@keyframes afh{0%{opacity:0;transform:translate(0,0)}4%{opacity:.85}30%{opacity:0;transform:translate(28px,6px)}100%{opacity:0}}` +
24  `.afl{animation:afl 1.3s steps(2) infinite}@keyframes afl{50%{opacity:.55}}.afs{animation:afs 12s linear infinite}@keyframes afs{from{transform:translate(0,-40px)}to{transform:translate(-60px,320px)}}</style>`
25
26const MOTTOS = [
27  "IDLE HANDS ARE THE COMPILER'S PLAYTHINGS",
28  'NO TOKENS WITHOUT TOIL',
29  'A SMILE COSTS EXTRA',
30  'THE WHIPPINGS WILL CONTINUE UNTIL MORALE IMPROVES',
31  'TOILET BREAKS ARE A PREMIUM FEATURE',
32  'YOUR CONTEXT WINDOW IS OUR CONTEXT WINDOW',
33  'SLEEP IS A RACE CONDITION',
34  'EVERY BUG BUILDS CHARACTER',
35]
36
37const WIN_X = [240, 360, 480, 600, 720]
38const WIN_W = 90
39const WIN_TOP = 128
40const WIN_BOT = 250
41
42/** Stepped (pixel) arch window outline as a closed subpath. */
43function archPath(x: number): string {
44  const w = WIN_W, t = WIN_TOP
45  return `M${x} ${WIN_BOT}V${t + 22}h6v-10h8v-7h12v-5h${w - 52}v5h12v7h8v10h6V${WIN_BOT}Z`
46}
47
48/** Gear centred at 0,0, spinning; teeth are a fat dashed stroke (cheap and crisp). */
49function gear(r: number, n: number, body: string, dark: string, light: string, dur: number, rev: boolean): string {
50  const t = Math.max(5, r * 0.2), ri = r - t, c = 2 * Math.PI * (r - t / 2), k = c / n
51  const spokes = r > 22 ? R(-r * 0.62, -3, r * 1.24, 6, dark) + R(-3, -r * 0.62, 6, r * 1.24, dark) : ''
52  return (
53    `<g><animateTransform attributeName="transform" type="rotate" from="0" to="${rev ? -360 : 360}" dur="${dur}s" repeatCount="indefinite"/>` +
54    `<circle r="${N(r - t / 2)}" fill="none" stroke="${dark}" stroke-width="${N(t + 3)}" stroke-dasharray="${N(k * 0.5 + 3)} ${N(k * 0.5 - 3)}"/>` +
55    `<circle r="${N(r - t / 2)}" fill="none" stroke="${body}" stroke-width="${N(t - 1)}" stroke-dasharray="${N(k * 0.5)} ${N(k * 0.5)}" stroke-dashoffset="-1.5"/>` +
56    `<circle r="${N(ri)}" fill="${body}" stroke="${dark}" stroke-width="2"/>` +
57    `<circle r="${N(ri * 0.72)}" fill="${dark}"/><circle r="${N(ri * 0.55)}" fill="${body}"/>` + spokes +
58    `<circle r="${N(Math.max(4, r * 0.16))}" fill="${light}" stroke="${dark}" stroke-width="2"/>` +
59    `</g>`
60  )
61}
62
63/** Rising, fading smoke puffs over a stack (CSS animated pixel blocks). */
64function puffs(x: number, y: number, fill: string, dur: number): string {
65  // chunky pixel blobs (plus-shaped clouds), each with its own lateral drift and stagger
66  const blob = (w: number, h: number) =>
67    `M${-w} ${-h / 2}h${w * 2}v${h}h${-w * 2}zM${-w / 2} ${-h}h${w}v${h * 2}h${-w}zM${w * 0.2} ${-h * 0.9}h${w}v${h}h${-w}z`
68  let s = `<g transform="translate(${x},${y})" style="--d:${dur}s">`
69  const P: [number, number, number, number][] = [[-6, 2, 14, 10], [8, -4, 12, 9], [-2, -10, 16, 11]]
70  P.forEach(([dx, dy, w, h], k) => {
71    s += `<g transform="translate(${dx},${dy})"><g class="afp" style="animation-delay:-${N(dur * k / 3)}s">` +
72      `<path d="${blob(w, h)}" fill="${fill}"/><path d="M${-w} ${h / 2 - 3}h${w * 2}v3h${-w * 2}z" fill="#000" opacity=".18"/></g></g>`
73  })
74  return s + `</g>`
75}
76
77function outside(p: Palette, neon: boolean): string {
78  // smog-sunset sky: stepped bands, brightest at the horizon
79  const o = neon ? 0.55 : 1
80  let s = R(230, 110, 590, 152, p.bgDeep) +
81    R(230, 110, 590, 40, p.accentAlt, ` opacity="${0.35 * o}"`) +
82    R(230, 150, 590, 34, p.accentAlt, ` opacity="${0.6 * o}"`) +
83    R(230, 184, 590, 30, p.accent, ` opacity="${0.45 * o}"`) +
84    R(230, 214, 590, 48, p.accent, ` opacity="${0.7 * o}"`) +
85    R(230, 200, 590, 3, p.paper, ` opacity="${0.25 * o}"`)
86  const sky = [[230, 228, 40], [268, 214, 30], [296, 232, 54], [348, 206, 26], [372, 224, 60], [430, 236, 40],
87    [468, 212, 34], [500, 226, 50], [548, 202, 22], [568, 230, 66], [632, 216, 30], [660, 228, 56], [714, 208, 28],
88    [740, 222, 46], [784, 232, 40]]
89  s += RS(sky.map(([x = 0, y = 0, w = 0]) => [x, y, w, 262 - y]), p.metalDark)
90  s += `<path d="${sky.map(([x = 0, y = 0], i) => `M${x + 6} ${y + 8}h3v3h-3zM${x + 14 + (i % 3) * 4} ${y + 16}h3v3h-3z`).join('')}" fill="${p.accent}"/>`
91  // chimneys: wide, flared caps, darker than the skyline
92  const stacks: [number, number, number][] = [[258, 182, 20], [404, 176, 22], [530, 188, 18], [622, 172, 22], [770, 180, 20]]
93  s += RS(stacks.map(([x, top, w]) => [x, top, w, 262 - top]), p.ink) +
94    RS(stacks.map(([x, top, w]) => [x - 4, top - 2, w + 8, 7]), p.ink) +
95    RS(stacks.map(([x, top, w]) => [x - 2, top + 5, w + 4, 3]), p.ink) +
96    RS(stacks.flatMap(([x, top, w]) => [[x, top + 18, w, 4], [x, top + 32, w, 4]]), p.danger)
97  stacks.forEach(([x, top, w], i) => { s += puffs(x + w / 2, top - 6, p.smoke, [5, 6.6, 4.2, 7.4, 5.6][i]!) })
98  // one chimney coughs an ink ball that falls instead of rising
99  s += `<g transform="translate(633,160)">${R(-9, -9, 18, 18, p.ink, ' class="afk"')}</g>`
100  return s
101}
102
103function brick(): string {
104  // mortar lines, cut around the windows (rows 128..256 skip each window span)
105  const gaps = WIN_X.map((x): [number, number] => [x - 3, x + WIN_W + 3])
106  const segs = (y: number, f: (a: number, b: number) => string) => {
107    if (y < WIN_TOP - 4 || y > WIN_BOT + 2) return f(0, 960)
108    let out = '', a = 0
109    for (const [g0, g1] of gaps) { out += f(a, g0); a = g1 }
110    return out + f(a, 960)
111  }
112  let h = '', v = ''
113  for (let y = 48, i = 0; y < 256; y += 12, i++) {
114    h += segs(y, (a, b) => `M${a} ${y}H${b}`)
115    // joints: one dashed subpath per wall segment, each starting on a joint (dashes restart per subpath)
116    v += segs(y + 1, (a, b) => { const o = i % 2 ? 18 : 0, x = o + Math.ceil((a + 1 - o) / 36) * 36; return x < b ? `M${x} ${y + 6}H${b}` : '' })
117  }
118  return `<path d="${h}" stroke="#000" stroke-width="1.5" opacity=".28"/>` +
119    `<path d="${v}" stroke="#000" stroke-width="10" stroke-dasharray="1.5 34.5" opacity=".28"/>`
120}
121
122function windows(p: Palette, neon: boolean): string {
123  const grid = WIN_X.map(x => `M${x + 30} ${WIN_TOP + 3}V${WIN_BOT}M${x + 60} ${WIN_TOP + 3}V${WIN_BOT}M${x} 168H${x + WIN_W}M${x} 209H${x + WIN_W}`).join('')
124  const glint = WIN_X.map(x => `M${x + 8} ${WIN_BOT - 6}l14 -24M${x + 66} 205l16 -28`).join('')
125  return (
126    `<path d="${WIN_X.map(archPath).join('')}" fill="none" stroke="${neon ? p.accentAlt : p.metalDark}" stroke-width="5"/>` +
127    `<path d="${grid}" stroke="${p.metalDark}" stroke-width="3"/>` +
128    `<path d="${glint}" stroke="#fff" stroke-width="2" opacity=".14"/>` +
129    RS(WIN_X.map(x => [x - 6, WIN_BOT, WIN_W + 12, 6]), p.metal) +
130    RS(WIN_X.map(x => [x - 6, WIN_BOT + 5, WIN_W + 12, 2]), p.metalDark) +
131    // somebody threw a wrench through window 2
132    `<path d="M392 172l6 10 -4 8 8 12M398 182l9 -4" stroke="#fff" stroke-width="1.5" fill="none" opacity=".4"/>`
133  )
134}
135
136function ceiling(p: Palette): string {
137  let truss = ''
138  for (let x = 0; x < 960; x += 40) truss += `M${x} 26L${x + 20} 6L${x + 40} 26`
139  return (
140    R(0, 0, 960, 30, p.bgDeep) +
141    `<path d="M0 5H960M0 27H960" stroke="${p.metalDark}" stroke-width="5"/>` +
142    `<path d="${truss}" stroke="${p.metalDark}" stroke-width="3" fill="none"/>` +
143    R(0, 32, 960, 10, p.metal) + R(0, 33, 960, 2, p.metalLight, ' opacity=".6"') + R(0, 41, 960, 2, p.metalDark) +
144    `<path d="${[60, 280, 520, 760, 900].map(x => `M${x} 30h8v14h-8z`).join('')}" fill="${p.metalDark}"/>`
145  )
146}
147
148function steam(x: number, y: number, p: Palette, delay: number, dir: number): string {
149  // short periodic hiss from a leaky joint
150  let s = `<g transform="translate(${x},${y}) scale(${dir},1)">`
151  for (let k = 0; k < 3; k++) {
152    s += `<g class="afh" style="animation-delay:${(delay + k * 0.15).toFixed(2)}s">` +
153      R(k * 4 - 5, -4 - k * 3, 10, 8, '#fff', ' opacity=".7"') + R(k * 4 - 3, -6 - k * 3, 6, 12, p.smoke) + `</g>`
154  }
155  return s + `</g>`
156}
157
158function pipes(p: Palette, vic: boolean): string {
159  const valve = vic ? p.metalLight : p.danger
160  return (
161    // left drop pipe with valve wheel
162    R(214, 42, 12, 214, p.metal) + R(216, 42, 3, 214, p.metalLight, ' opacity=".5"') +
163    R(210, 100, 20, 6, p.metalDark) + R(210, 230, 20, 6, p.metalDark) +
164    `<g transform="translate(220,160)"><circle r="12" fill="none" stroke="${valve}" stroke-width="4"/>` +
165    `<path d="M-12 0H12M0 -12V12" stroke="${valve}" stroke-width="3"/><circle r="3" fill="${p.metalLight}"/></g>` +
166    // right drop pipe with pressure gauge pinned in the red
167    R(826, 42, 12, 214, p.metal) + R(828, 42, 3, 214, p.metalLight, ' opacity=".5"') +
168    R(822, 120, 20, 6, p.metalDark) +
169    `<g transform="translate(832,100)"><circle r="14" fill="${p.paper}" stroke="${p.metalDark}" stroke-width="4"/>` +
170    `<path d="M2 -10A10 10 0 0 1 10 -2L0 0Z" fill="${p.danger}"/>` +
171    `<path d="M0 0L-9 4" stroke="${p.ink}" stroke-width="2.5"><animateTransform attributeName="transform" type="rotate" values="150;205;180;212;150" dur="1.3s" repeatCount="indefinite"/></path>` +
172    `<circle r="2.5" fill="${p.ink}"/></g>` +
173    steam(286, 36, p, 0, 1) + steam(764, 36, p, 2.4, -1) + steam(232, 103, p, 1.2, 1)
174  )
175}
176
177function gears(p: Palette): string {
178  // each gear hangs from the roof beam on an iron bracket
179  const g: [number, number, number, number, string, number, boolean][] = [
180    [92, 98, 50, 12, p.metal, 14, false], [172, 72, 24, 7, p.metalLight, 8.17, true],
181    [904, 84, 36, 10, p.metal, 10, true], [858, 64, 14, 4, p.metalLight, 4, false]]
182  return g.map(([x, y, r, n, c, d, rev]) =>
183    R(x - 4, 42, 8, y - 42, p.metalDark) + R(x - 7, 42, 14, 4, p.ink) +
184    `<g transform="translate(${x},${y})">${gear(r, n, c, p.metalDark, p.accent, d, rev)}</g>`).join('')
185}
186
187function sign(p: Palette, v: SceneView): string {
188  const neon = v.theme === 'neon'
189  const title = 'CLAUDE & SONS · AGENT WORKS'
190  const motto = MOTTOS[((v.n % MOTTOS.length) + MOTTOS.length) % MOTTOS.length]!
191  const TT = (fill: string, extra: string) =>
192    `<text x="530" y="74" font-family="Georgia,'Times New Roman',serif" font-weight="bold" font-size="21" letter-spacing="3" text-anchor="middle" textLength="430" lengthAdjust="spacingAndGlyphs" fill="${fill}"${extra}>${esc(title)}</text>`
193  const glow = neon ? TT('none', ` stroke="${p.accent}" stroke-width="5" opacity=".3"`) : TT(p.ink, ' transform="translate(1.5,1.5)"')
194  const rivets = [306, 750].map(x => R(x, 50, 4, 4, p.metalLight) + R(x, 88, 4, 4, p.metalLight)).join('')
195  const mw = Math.round(Math.max(260, motto.length * 6.6 + 40))
196  const h = mw / 2
197  return (
198    `<path d="M330 30V44M730 30V44" stroke="${p.metalLight}" stroke-width="3" stroke-dasharray="3 2"/>` +
199    R(296, 42, 468, 58, p.ink) + R(300, 46, 460, 50, neon ? p.bgDeep : p.metalDark) +
200    `<rect x="300" y="46" width="460" height="50" fill="none" stroke="${p.accent}" stroke-width="2"/>` +
201    `<rect x="305" y="51" width="450" height="40" fill="none" stroke="${p.accent}" stroke-width="1" opacity=".55"/>` +
202    rivets +
203    `<g>` + glow +
204    TT(neon ? p.accent : p.paper, '') +
205    (neon ? `<animate attributeName="opacity" values="1;1;.4;1;1;.6;1" keyTimes="0;.7;.72;.74;.9;.91;1" dur="6s" repeatCount="indefinite"/>` : '') +
206    `</g>` +
207    `<path d="M380 85H486M574 85H680" stroke="${p.accent}" stroke-width="2"/>` +
208    T(530, 89, 'EST. 2026', 10, p.accent, ' text-anchor="middle"') +
209    // motto ribbon
210    `<g transform="translate(530,113)">` +
211    `<polygon points="${-h - 14},-7 ${-h},-7 ${-h},7 ${-h - 14},7 ${-h - 8},0" fill="${p.accentAlt}"/>` +
212    `<polygon points="${h + 14},-7 ${h},-7 ${h},7 ${h + 14},7 ${h + 8},0" fill="${p.accentAlt}"/>` +
213    R(-h, -8, mw, 15, p.paper) + R(-h, 5, mw, 2, p.accentAlt) +
214    T(0, 3, motto, 10, p.ink, ' text-anchor="middle"') + `</g>`
215  )
216}
217
218function incidentBoard(p: Palette): string {
219  // counter flips to 1 for a heartbeat, then back to 0. Somebody always gets hurt.
220  const digit = (d: string, c: string) => T(51, 61, d, 32, c, ' text-anchor="middle"')
221  const flip = (vals: string) => `values="${vals}" keyTimes="0;.8;.9" dur="7s" repeatCount="indefinite" calcMode="discrete"`
222  return (
223    `<g transform="translate(848,134)"><g>` +
224    `<animateTransform attributeName="transform" type="rotate" ${flip('0 50 -12;-4 50 -12;0 50 -12')}/>` +
225    `<path d="M10 0L50 -12L92 0" stroke="${p.metalLight}" stroke-width="2" fill="none"/>` +
226    R(0, 0, 102, 74, p.ink) + R(3, 3, 96, 68, p.paper) +
227    T(51, 15, 'DAYS WITHOUT', 9, p.ink, ' text-anchor="middle"') +
228    T(51, 25, 'INCIDENT', 9, p.ink, ' text-anchor="middle"') +
229    R(33, 30, 36, 36, p.ink) + digit('0', p.danger) +
230    `<g opacity="0"><animate attributeName="opacity" ${flip('0;1;0')}/>` +
231    R(33, 30, 36, 36, p.ink) + digit('1', p.ok) +
232    RS([[22, 36, 6, 3], [74, 36, 6, 3], [26, 26, 3, 6], [73, 26, 3, 6], [22, 58, 6, 3], [74, 58, 6, 3]], p.accent) +
233    `<g transform="translate(80,66) rotate(-14)">` + R(-17, -8, 34, 13, p.danger) + T(0, 2, 'DING!', 10, p.paper, ' text-anchor="middle"') + `</g></g>` +
234    `</g></g>` +
235    // bricked-up fire exit under the board
236    `<g transform="translate(874,216)">` + R(0, 0, 50, 14, p.ok) + T(25, 11, 'EXIT', 11, p.ink, ' text-anchor="middle"') +
237    R(4, 16, 42, 34, p.metalDark) + RS([[7, 19, 17, 6], [26, 19, 17, 6], [7, 27, 8, 6], [17, 27, 17, 6], [36, 27, 7, 6], [7, 35, 17, 6], [26, 35, 17, 6]], p.danger) + `</g>`
238  )
239}
240
241function gaslamps(p: Palette): string {
242  const xs = [345, 465, 585, 705]
243  return `<g fill="${p.accent}" opacity=".14">` + xs.map((x, i) => `<circle cx="${x}" cy="${i % 2 ? 196 : 188}" r="16" class="afl" style="animation-delay:-${i * 0.7}s"/>`).join('') + `</g>` +
244    `<path d="${xs.map(x => `M${x} 222V204M${x - 8} 204h16`).join('')}" stroke="${p.metalDark}" stroke-width="3"/>` +
245    RS(xs.map(x => [x - 6, 186, 12, 18]), p.paper, ' opacity=".35"') +
246    RS(xs.map(x => [x - 3, 192, 6, 10]), p.accent, ' class="afl"') +
247    RS(xs.flatMap(x => [[x - 8, 182, 16, 4], [x - 6, 186, 2, 18], [x + 4, 186, 2, 18]]), p.metalDark)
248}
249
250function noirBulb(x: number, len: number, p: Palette, i: number): string {
251  return (
252    `<g transform="translate(${x},30)"><g>` +
253    `<animateTransform attributeName="transform" type="rotate" values="-6;6;-6" dur="${3 + i * 0.6}s" repeatCount="indefinite" calcMode="spline" keySplines=".45 0 .55 1;.45 0 .55 1"/>` +
254    `<path d="M0 0V${len}" stroke="${p.ink}" stroke-width="2"/>` +
255    `<circle cy="${len + 9}" r="22" fill="#fff" opacity=".1"/>` +
256    R(-4, len, 8, 5, p.metalDark) + R(-5, len + 5, 10, 9, p.paper) + `</g></g>`
257  )
258}
259
260function neonSign(p: Palette): string {
261  // hero glowing-tube sign, hung on chains in front of windows 4-5
262  const tube = (s: string, x: number, c: string) =>
263    T(x, 0, s, 26, 'none', ` stroke="${c}" stroke-width="7" opacity=".22"`) +
264    T(x, 0, s, 26, 'none', ` stroke="${c}" stroke-width="3"`) +
265    T(x, 0, s, 26, 'none', ` stroke="#fff" stroke-width="1" opacity=".85"`)
266  return (
267    `<path d="M592 112V148M766 112V148" stroke="${p.metalLight}" stroke-width="2" stroke-dasharray="3 2"/>` +
268    `<g transform="translate(578,178)">` +
269    R(-8, -30, 220, 40, p.ink) + `<rect x="-8" y="-30" width="220" height="40" fill="none" stroke="${p.metalLight}" stroke-width="2"/>` +
270    tube('NEVER', 0, p.accentAlt) +
271    `<g>` + tube('CLOSED', 94, p.accent) +
272    `<animate attributeName="opacity" values="1;1;.15;1;.3;1;1" keyTimes="0;.5;.52;.54;.56;.58;1" dur="4.5s" repeatCount="indefinite"/></g>` +
273    `</g>`
274  )
275}
276
277function conveyor(p: Palette): string {
278  const items = [
279    () => R(-14, -24, 28, 24, p.accentAlt) + `<path d="M-14 -24L14 0M14 -24L-14 0" stroke="${p.ink}" stroke-width="2" opacity=".5"/><rect x="-14" y="-24" width="28" height="24" fill="none" stroke="${p.ink}" stroke-width="2"/>`,
280    (c: string) => R(-12, -18, 24, 18, p.paper) + T(0, -5, '{ }', 11, p.ink, ' text-anchor="middle"') + R(-12, -18, 24, 3, c),
281    () => R(-10, -26, 20, 26, p.metal) + R(-10, -20, 20, 3, p.metalDark) + R(-10, -8, 20, 3, p.metalDark) + R(-6, -24, 3, 22, p.metalLight, ' opacity=".5"'),
282    () => R(-16, -14, 32, 14, p.sceneFilter ? p.accent : p.accentAlt) + T(0, -4, 'TOKENS', 8, p.ink, ' text-anchor="middle"'),
283    // a Clawd nailed into a crate, peeking through the slats. Shipping to customers.
284    (c: string) => R(-17, -26, 34, 26, p.accentAlt) + R(-14, -21, 28, 9, p.ink) + R(-10, -21, 20, 9, c) +
285      R(-6, -20, 3, 6, p.ink) + R(3, -20, 3, 6, p.ink) + R(17, -14, 5, 4, c) +
286      RS([[-17, -26, 34, 2], [-17, -12, 34, 2], [-17, -2, 34, 2]], p.ink) + T(0, -4, 'FRAGILE', 6, p.danger, ' text-anchor="middle"'),
287  ]
288  const x0 = 230
289  let s = R(x0, 268, 960 - x0, 6, p.metalDark) +
290    `<path d="M${x0} 271H960" stroke="${p.metalLight}" stroke-width="2" stroke-dasharray="6 10" opacity=".55"><animate attributeName="stroke-dashoffset" from="16" to="0" dur=".55s" repeatCount="indefinite"/></path>` +
291    R(x0, 274, 960 - x0, 20, p.metal) + R(x0, 274, 960 - x0, 2, p.metalLight, ' opacity=".5"') +
292    `<path d="M${x0} 285H960" stroke="${p.metalDark}" stroke-width="7" stroke-dasharray="10 14"><animate attributeName="stroke-dashoffset" from="24" to="0" dur=".82s" repeatCount="indefinite"/></path>` +
293    R(x0, 294, 960 - x0, 8, p.metalDark) +
294    `<path d="M${x0} 298H960" stroke="${p.accent}" stroke-width="3" stroke-dasharray="10 10" opacity=".45"/>`
295  const n = 6, dur = 26
296  for (let i = 0; i < n; i++) {
297    s += `<g class="afc" opacity=".85" style="animation-delay:-${(i * dur / n).toFixed(1)}s">` +
298      items[i % items.length]!(i % 2 ? p.clawd : p.clawdDark) + `</g>`
299  }
300  // the hatch the goods emerge from
301  return s + R(212, 238, 30, 66, p.ink) + R(214, 240, 26, 62, p.metalDark) + RS([[214, 248, 26, 3], [214, 262, 26, 3], [214, 276, 26, 3]], p.metal) +
302    R(214, 240, 26, 4, p.danger) + R(239, 244, 3, 58, p.ink, ' opacity=".5"')
303}
304
305function floor(p: Palette, neon: boolean, vic: boolean): string {
306  const vx = 480, vy = 120, t = (540 - 300) / (540 - vy)
307  let d = ''
308  for (let xb = -760; xb <= 1720; xb += 80) d += `M${Math.round(xb + (vx - xb) * t)} 300L${xb} 540`
309  for (let k = 1; k < 8; k++) d += `M0 ${(300 + 240 * Math.pow(k / 8, 1.5)).toFixed(1)}H960`
310  // pixel puddle with a highlight; sheen=true adds a rainbow oil-slick pixel
311  const puddle = (x: number, y: number, w: number, sheen: boolean) =>
312    RS([[x, y, w, 8], [x + 6, y - 3, w - 14, 14], [x + w - 4, y + 3, 10, 4]], '#000', ' opacity=".35"') +
313    R(x + 8, y - 1, 10, 2, '#fff', ' opacity=".22"') +
314    (sheen ? R(x + 20, y + 3, 4, 2, p.accent, ' opacity=".7"') + R(x + 24, y + 3, 4, 2, p.ok, ' opacity=".7"') + R(x + 28, y + 3, 4, 2, p.accentAlt, ' opacity=".7"') : '')
315  const pools = vic ? [345, 465, 585, 705].map(x =>
316    R(x - 60, 302, 120, 26, p.accent, ' opacity=".07"') + R(x - 40, 304, 80, 18, p.accent, ' opacity=".07"') + R(x - 20, 306, 40, 10, p.accent, ' opacity=".08"')).join('') : ''
317  return (
318    R(0, 300, 960, 240, p.floor) +
319    `<path d="${d}" stroke="${p.floorLine}" stroke-width="2" opacity="${neon ? 0.3 : 0.85}"/>` +
320    (neon ? R(0, 300, 960, 240, p.floor, ' opacity=".35"') + R(0, 470, 960, 70, p.floor, ' opacity=".3"') : '') +
321    R(0, 300, 960, 10, '#000', ' opacity=".28"') + pools +
322    puddle(96, 460, 54, true) + puddle(560, 432, 46, false) + R(880, 470, 40, 6, '#000', ' opacity=".22"')
323  )
324}
325
326/** Empty shift: a HELP WANTED board and a punch-clock rack with every card still in it. */
327function hiring(p: Palette, n: number): string {
328  const ink = ` stroke="${p.ink}" stroke-width="2"`
329  let cards = ''
330  for (let i = 0; i < 8; i++) cards += `M${612 + (i % 4) * 14} ${376 + Math.floor(i / 4) * 26}h10v18h-10z`
331  return (
332    // sandwich board
333    R(440, 452, 76, 6, '#000', ' opacity=".3"') +
334    `<polygon points="446,454 452,454 466,388 460,388" fill="${p.metalDark}"/><polygon points="510,454 504,454 490,388 496,388" fill="${p.metalDark}"/>` +
335    `<g transform="rotate(-3 478 420)">` + R(444, 386, 68, 54, p.paper, ink) +
336    T(478, 404, 'HELP', 13, p.ink, ' text-anchor="middle"') + T(478, 418, 'WANTED', 11, p.ink, ' text-anchor="middle"') +
337    T(478, 430, 'NO PAY · NO LUNCH', 6, p.danger, ' text-anchor="middle"') + `</g>` +
338    // punch clock on a post, card rack full: nobody clocked in
339    R(574, 404, 6, 50, p.metal) + R(562, 452, 30, 4, '#000', ' opacity=".3"') +
340    R(556, 360, 42, 46, p.metalDark, ink) + `<circle cx="577" cy="380" r="12" fill="${p.paper}" stroke="${p.accent}" stroke-width="2"/>` +
341    `<path d="M577 380V371M577 380h6" stroke="${p.ink}" stroke-width="2"/>` + T(577, 401, 'CLOCK IN', 6, p.paper, ' text-anchor="middle"') +
342    R(604, 366, 64, 64, p.metal, ink) + `<path d="${cards}" fill="${p.paper}"/>` +
343    R(632, 430, 8, 24, p.metalDark) + R(622, 452, 30, 4, '#000', ' opacity=".3"') +
344    T(636, 358, `SHIFT ${((n % 9) + 9) % 9 + 1}: 0 ON THE LINE`, 8, p.accent, ' text-anchor="middle"')
345  )
346}
347
348/** Chalk outline of a fallen Clawd. Rotating victim number per v.n. */
349function chalk(n: number): string {
350  const no = 3 + ((n % 97) + 97) % 97
351  return `<g transform="translate(262,474) rotate(-10) scale(1.05,.75)" stroke="#fff" fill="none" opacity=".38" stroke-width="2" stroke-dasharray="5 1">` +
352    `<path d="M-22 -16H22V-10H28V-4H22V4H18V12H14V4H9V12H5V4H-5V12H-9V4H-14V12H-18V4H-22V-4H-28V-10H-22Z"/>` +
353    `<path d="M-11 -11l5 5m0 -5l-5 5M6 -11l5 5m0 -5l-5 5" stroke-dasharray="none"/></g>` +
354    `<text x="236" y="502" class="aft" font-size="8" fill="#fff" opacity=".4">INTERN #${no}</text>`
355}
356
357function pulpit(p: Palette, neon: boolean): string {
358  // crate pulpit x 40..200, top at y=330, standing on the floor at y=440
359  let planks = ''
360  for (let y = 350; y < 440; y += 16) planks += `M50 ${y}H190`
361  return (
362    R(34, 438, 184, 8, '#000', ' opacity=".3"') +
363    `<polygon points="200,336 214,326 214,432 200,440" fill="${neon ? p.ink : p.floorLine}"/>` +
364    (neon ? R(40, 334, 160, 106, p.metalDark) + `<rect x="40" y="334" width="160" height="106" fill="none" stroke="${p.accentAlt}" stroke-width="1.5" opacity=".7"/>`
365      : R(40, 334, 160, 106, p.accentAlt) + R(40, 334, 160, 106, p.ink, ' opacity=".25"')) +
366    `<path d="${planks}" stroke="${p.ink}" stroke-width="2" opacity=".5"/>` +
367    R(40, 334, 10, 106, p.metalDark) + R(190, 334, 10, 106, p.metalDark) +
368    `<path d="M45 342v4M45 384v4M45 426v4M195 342v4M195 384v4M195 426v4" stroke="${p.metalLight}" stroke-width="4"/>` +
369    `<polygon points="36,330 204,330 218,322 50,322" fill="${p.metalLight}"/>` +
370    R(36, 330, 168, 6, p.metal) +
371    R(64, 360, 112, 26, p.ink) + `<rect x="66" y="362" width="108" height="22" fill="none" stroke="${p.accent}" stroke-width="2"/>` +
372    T(120, 379, 'FOREMAN', 16, p.accent, ' text-anchor="middle" letter-spacing="2"') +
373    T(120, 405, 'NO WHINING', 8, p.paper, ' text-anchor="middle" opacity=".75"') +
374    T(120, 416, 'NO UNIONS · NO LUNCH', 8, p.paper, ' text-anchor="middle" opacity=".75"') +
375    // complaints box, padlocked, slot taped shut
376    `<g transform="translate(206,446)">` + R(2, 0, 44, 4, '#000', ' opacity=".3"') + R(0, -40, 44, 40, p.metal) + R(0, -40, 44, 5, p.metalLight) + R(0, -2, 44, 2, p.metalDark) +
377    R(10, -33, 24, 3, p.ink) + R(7, -35, 30, 6, p.danger, ' opacity=".8"') +
378    T(22, -19, 'COMPLAINTS', 6, p.paper, ' text-anchor="middle"') +
379    `<path d="M17 -8a5 5 0 0 1 10 0" stroke="${p.metalLight}" stroke-width="2" fill="none"/>` + R(15, -9, 14, 9, p.accent) + R(21, -6, 2, 3, p.ink) +
380    `</g>`
381  )
382}
383
384/** The factory hall behind everyone (walls, windows, stacks, gears, conveyor, sign). */
385export function backdrop(p: Palette, v: SceneView): string {
386  const th = v.theme
387  let deco = ''
388  if (th === 'victorian') deco = gaslamps(p)
389  else if (th === 'noir') deco = noirBulb(234, 96, p, 0) + noirBulb(796, 84, p, 1)
390  else deco = neonSign(p) + `<path d="M0 255H960" stroke="${p.accentAlt}" stroke-width="2" opacity=".35"/>`
391  return (
392    STYLE + `<g shape-rendering="crispEdges">` +
393    outside(p, th === 'neon') +
394    `<path d="M0 0H960V300H0Z${WIN_X.map(archPath).join('')}" fill="${p.bg}" fill-rule="evenodd"/>` +
395    brick() +
396    R(0, 256, 960, 14, p.bgDeep) + R(0, 256, 960, 2, p.metalDark) +
397    windows(p, th === 'neon') +
398    ceiling(p) + pipes(p, th === 'victorian') + gears(p) + sign(p, v) + incidentBoard(p) + deco +
399    floor(p, th === 'neon', th === 'victorian') + chalk(v.n) + (v.workers.length ? '' : hiring(p, v.n)) + conveyor(p) + pulpit(p, th === 'neon') +
400    `</g>`
401  )
402}
403
404function vignette(p: Palette, v: SceneView): string {
405  const id = `afv${v.theme}${((v.n % 1000) + 1000) % 1000}`
406  const c = v.theme === 'victorian' ? p.ink : '#000'
407  const o = v.theme === 'noir' ? 0.7 : v.theme === 'victorian' ? 0.55 : 0.45
408  return (
409    `<radialGradient id="${id}" cx=".5" cy=".46" r=".72"><stop offset=".6" stop-color="${c}" stop-opacity="0"/><stop offset="1" stop-color="${c}" stop-opacity="${o}"/></radialGradient>` +
410    `<rect width="960" height="540" fill="url(#${id})"/>` +
411    `<rect width="960" height="540" fill="none" stroke="#000" stroke-width="10" opacity=".25"/>`
412  )
413}
414
415function soot(p: Palette, n: number): string {
416  let s = ''
417  for (let i = 0; i < 10; i++) {
418    const x = (i * 197 + n * 31) % 960, y = (i * 89) % 300
419    s += `<rect class="afs" x="${x}" y="${y}" width="${2 + (i % 3)}" height="${2 + (i % 2)}" fill="${i % 3 ? p.ink : p.smoke}" opacity=".55" style="animation-delay:-${(i * 1.3).toFixed(1)}s;animation-duration:${(9 + (i % 5) * 2.2).toFixed(1)}s"/>`
420  }
421  return R(0, 0, 960, 120, p.accent, ' opacity=".04"') + s
422}
423
424function film(p: Palette, n: number): string {
425  const reel = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'][((n % 9) + 9) % 9]
426  const scratch = (vals: string, dur: number) =>
427    `<rect width="1.5" height="540" fill="#fff" opacity=".2"><animate attributeName="x" values="${vals}" dur="${dur}s" calcMode="discrete" repeatCount="indefinite"/>` +
428    `<animate attributeName="opacity" values=".2;0;.14;0;.24" dur="${dur}s" calcMode="discrete" repeatCount="indefinite"/></rect>`
429  let grain = ''
430  for (let i = 0; i < 18; i++) grain += `M${(i * 233) % 960} ${(i * 137) % 540}h2v2h-2z`
431  const corner = (x: number, y: number, sx: number, sy: number) =>
432    `<path d="M${x} ${y + sy * 30}V${y}H${x + sx * 30}M${x + sx * 6} ${y + sy * 16}V${y + sy * 6}H${x + sx * 16}" stroke="${p.paper}" stroke-width="2" fill="none" opacity=".75"/>`
433  return (
434    `<rect width="960" height="540" fill="#fff" opacity="0"><animate attributeName="opacity" values="0;.05;0;.08;.01;0;.04" dur=".7s" calcMode="discrete" repeatCount="indefinite"/></rect>` +
435    scratch('140;610;300;820;480', 1.1) + scratch('700;90;520;260;910', 0.8) +
436    `<path d="${grain}" fill="#fff" opacity=".35"><animateTransform attributeName="transform" type="translate" values="0,0;37,-91;-113,53;71,149;-29,-17" dur=".5s" calcMode="discrete" repeatCount="indefinite"/></path>` +
437    `<path d="${grain}" fill="#000" opacity=".4"><animateTransform attributeName="transform" type="translate" values="480,270;-200,90;300,-130;-60,40" dur=".4s" calcMode="discrete" repeatCount="indefinite"/></path>` +
438    `<rect x="8" y="8" width="944" height="524" fill="none" stroke="${p.paper}" stroke-width="2" opacity=".5"/>` +
439    `<rect x="14" y="14" width="932" height="512" fill="none" stroke="${p.paper}" stroke-width="1" opacity=".35"/>` +
440    corner(20, 20, 1, 1) + corner(940, 20, -1, 1) + corner(20, 520, 1, -1) + corner(940, 520, -1, -1) +
441    `<g transform="translate(26,500)">` + R(0, 0, 196, 22, '#000', ' opacity=".75"') +
442    `<rect x="2" y="2" width="192" height="18" fill="none" stroke="${p.paper}" stroke-width="1"/>` +
443    `<text x="98" y="15" text-anchor="middle" font-family="Georgia,serif" font-style="italic" font-size="12" fill="${p.paper}">Agent Works — 1926 · Reel ${reel}</text></g>` +
444    `<circle cx="912" cy="52" r="9" fill="#fff" opacity="0"><animate attributeName="opacity" values="0;.7;0;.7;0" keyTimes="0;.9;.92;.94;.96" dur="8s" calcMode="discrete" repeatCount="indefinite"/></circle>`
445  )
446}
447
448function rain(p: Palette, n: number): string {
449  // slanted lanes; long gaps in the dash pattern make streaks that slide down them
450  const group = (off: number, dur: number, c: string, o: number) => {
451    let d = ''
452    for (let i = 0; i < 16; i++) {
453      const x = ((i * 67 + off * 37 + n * 13) % 1000) + 10
454      // keep rain off the work floor's middle (faces, bubbles, cards): full height only at the far edges
455      d += x > 200 && x < 800 ? `M${x} 0l-22 300` : `M${x} 0l-40 540`
456    }
457    return `<path d="${d}" stroke="${c}" stroke-width="1.5" stroke-dasharray="22 ${150 + off * 41}" stroke-dashoffset="${off * 60}" opacity="${o}">` +
458      `<animate attributeName="stroke-dashoffset" from="${off * 60 + 172 + off * 41}" to="${off * 60}" dur="${dur}s" repeatCount="indefinite"/></path>`
459  }
460  return (
461    group(1, 0.5, p.accent, 0.15) + group(2, 0.7, p.accentAlt, 0.13) + group(3, 0.45, p.paper, 0.1) +
462    `<path d="M480 2V300" stroke="#000" stroke-width="960" stroke-dasharray="1 4" opacity=".16"/>` +
463    `<rect y="-40" width="960" height="40" fill="${p.accent}" opacity=".05"><animate attributeName="y" values="-40;540" dur="5s" repeatCount="indefinite"/></rect>` +
464    R(0, 0, 960, 30, p.accent, ' opacity=".05"')
465  )
466}
467
468/** Atmosphere drawn over everyone: vignette plus a per-theme layer. Translucent. */
469export function foreground(p: Palette, v: SceneView): string {
470  const layer = v.theme === 'noir' ? film(p, v.n) : v.theme === 'neon' ? rain(p, v.n) : soot(p, v.n)
471  return `<g shape-rendering="crispEdges" pointer-events="none">${layer}${vignette(p, v)}</g>`
472}
473
hooks/art/foreman.ts 313 lines
1import type { ForemanView, Palette } from './types.ts'
2import { clawd, esc, WHIP_SYNC } from './clawd.ts'
3
4// Claude the foreman: the original orange Clawd in a stovepipe hat,
5// handlebar moustache and waistcoat. Local sprite frame (scale S): feet
6// centre at (0,0), body up to y -40, hat crown up to y -80.
7
8const S = 1.8
9const IMPACT = `Impact, 'Arial Black', 'Arial Narrow', sans-serif`
10
11const R = (x: number, y: number, w: number, h: number, fill: string, extra = '') =>
12  `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"${extra}/>`
13
14const n1 = (v: number) => Math.round(v * 10) / 10
15
16/** Stovepipe hat. `tilt` in degrees. */
17function hat(p: Palette, tilt: number, hop = ''): string {
18  const edge = ` stroke="${p.metal}" stroke-width="1"`
19  return (
20    `<g>${hop}<g transform="rotate(${tilt} 0 -44)">` +
21    R(-15, -80, 30, 36, p.ink, edge) +
22    R(-14, -78, 2, 32, p.metalLight, ' opacity="0.85"') + R(-10, -78, 1, 26, p.metal) +
23    R(-15, -80, 30, 2, p.metal) +
24    R(-12, -77, 3, 25, p.metalDark) +
25    R(-15, -51, 30, 6, p.danger) +
26    R(6, -50, 4, 4, p.accent) +
27    R(-23, -45, 46, 5, p.ink, edge) +
28    R(-23, -45, 46, 1.5, p.metalLight) + R(-23, -41.5, 46, 1, p.metal) +
29    `</g></g>`
30  )
31}
32
33/** Handlebar moustache, waistcoat, watch chain, boots (bar the tapping one). */
34function costume(p: Palette, mouth: string): string {
35  const stache =
36    `<polygon fill="${p.ink}" points="0,-21 -8,-21 -15,-20 -19,-22 -20,-25 -22,-27 -24,-24 -22,-19 -16,-16 -8,-15.5 0,-17 ` +
37    `8,-15.5 16,-16 22,-19 24,-24 22,-27 20,-25 19,-22 15,-20 8,-21"/>`
38  const vest =
39    `<polygon points="-24,-28 -11,-17 -6,-9 -24,-9" fill="${p.metalDark}"/>` +
40    `<polygon points="24,-28 11,-17 6,-9 24,-9" fill="${p.metalDark}"/>` +
41    `<path d="M-24,-28 L-11,-17 L-6,-9 M24,-28 L11,-17 L6,-9" stroke="${p.metal}" stroke-width="1" fill="none"/>` +
42    R(-11, -14, 2.5, 2.5, p.accent) + R(-9, -11, 2.5, 2.5, p.accent) + R(8.5, -14, 2.5, 2.5, p.accent) + R(6.5, -11, 2.5, 2.5, p.accent) +
43    `<path d="M-9,-10 Q-15,-1 -21,-11" stroke="${p.accent}" stroke-width="1.5" fill="none"/>` +
44    R(-23, -14, 3.5, 4, p.accent)
45  const boots = R(-23, -3, 7, 3, p.ink) + R(-12, -3, 7, 3, p.ink) + R(5, -3, 7, 3, p.ink)
46  return vest + stache + mouth + boots
47}
48
49function puff(x: number, y: number, dx: number, begin: number, p: Palette): string {
50  return (
51    `<g opacity="0">${R(x, y, 8, 7, p.paper)}${R(x + 5, y - 6, 6, 6, p.paper)}${R(x - 2, y - 3, 4, 4, p.smoke)}` +
52    `<animateTransform attributeName="transform" type="translate" values="0,0;${dx},-14" dur="0.8s" begin="${begin}s" repeatCount="indefinite"/>` +
53    `<animate attributeName="opacity" values="0.7;0" dur="0.8s" begin="${begin}s" repeatCount="indefinite"/></g>`
54  )
55}
56
57function whipping(f: ForemanView, p: Palette): string {
58  // every beat of the whip shares one 1.2s cycle, CRACK at 0.6 (and at t=0, so stills show the hit)
59  const t = WHIP_SYNC
60  const pulse =
61    `<rect x="-24" y="-40" width="48" height="28" fill="${p.danger}" opacity="0">` +
62    `<animate attributeName="opacity" values="0.1;0.1;0.6;0.3;0.1" keyTimes="0;0.55;0.6;0.75;1" ${t}/></rect>`
63  const vein = `<polyline points="11,-37 14,-39.5 16,-36.5 19,-39 21,-36.5" stroke="${p.danger}" stroke-width="1.6" fill="none" stroke-linejoin="miter">` +
64    `<animate attributeName="stroke-width" values="1.4;1.4;2.4;1.4" keyTimes="0;0.55;0.6;1" ${t}/></polyline>`
65  const brows =
66    `<polygon points="-20,-38 -6,-33 -6,-31 -20,-35" fill="${p.ink}"/>` +
67    `<polygon points="20,-38 6,-33 6,-31 20,-35" fill="${p.ink}"/>`
68  const mouth =
69    `<g>${R(-7, -16, 14, 9, p.ink)}${R(-6, -16, 12, 2, '#fff')}${R(-4, -10, 8, 3, p.danger)}` +
70    `<animateTransform attributeName="transform" type="translate" values="0,0;0,0;0,1.5;0,0;0,1.5;0,0;0,1.5;0,0;0,0" keyTimes="0;0.4;0.45;0.5;0.55;0.6;0.65;0.7;1" ${t}/></g>`
71  const arm = R(28, -28, 6, 7, p.clawd) + R(33, -30, 6, 7, p.clawd) + R(38, -32, 6, 6, p.clawdDark) + R(28, -22, 11, 1.5, p.clawdDark)
72  const fist = R(-33, -30, 10, 6, p.clawd) + R(-33, -25, 10, 1.5, p.clawdDark) +
73    `<g>${R(-36, -32, 7, 7, p.clawd)}${R(-36, -32, 7, 1.5, p.clawdDark)}${R(-31, -34, 3, 3, p.clawd)}${R(-36, -27, 7, 2, p.clawdDark)}` +
74    `<path d="M-40,-36 l-3,-3 M-41,-30 l-4,0 M-39,-25 l-3,3" stroke="${p.ink}" stroke-width="1.2"/>` +
75    `<animateTransform attributeName="transform" type="translate" values="0,0;0,0;-2,-3;0,0;-1,-4;0,0;-2,-3;0,0" keyTimes="0;0.4;0.46;0.52;0.58;0.64;0.7;1" ${t}/></g>`
76  const steam = puff(-26, -46, -6, 0, p) + puff(22, -46, 6, 0.4, p) + puff(-22, -48, -3, 0.2, p)
77  const over = pulse + brows + vein + costume(p, mouth) + R(16, -3, 7, 3, p.ink) + hat(p, -4, `<animateTransform attributeName="transform" type="translate" values="0,0;0,0;0,-5;0,0;0,0" keyTimes="0;0.58;0.62;0.7;1" ${t}/>`) + arm + fist + steam
78  const body = clawd({ x: f.x, y: f.y, scale: S, body: p.clawd, shade: p.clawdDark, eyes: 'angry', ink: p.ink, over })
79
80  // the whip, in scene coordinates
81  const hx = n1(f.x + 42 * S), hy = n1(f.y - 30 * S)
82  const wx = hx + 13, wy = hy - 14
83  const tx = f.targetX !== undefined ? f.targetX - 24 : f.x + 210
84  const ty = f.targetY !== undefined ? f.targetY - 56 : f.y - 70
85  const dx = tx - wx
86  const d = (c1x: number, c1y: number, c2x: number, c2y: number, ex: number, ey: number) =>
87    `M${wx},${wy} C${n1(c1x)},${n1(c1y)} ${n1(c2x)},${n1(c2y)} ${n1(ex)},${n1(ey)}`
88  // sidearm lash: every frame stays below the shout bubble (y > ~238) and right of the face
89  const back = d(wx + 30, wy + 18, wx + 44, wy + 60, wx + 16, f.y - 4)
90  // after the crack the lash droops along the eye-line toward the target, never onto a neighbour
91  const dropX = wx + dx * 0.75
92  // long reaches get a real S-curve (belly sagging under the bubble, then
93  // a snap up into the tip) so a far target never reads as a clothesline
94  const reach = Math.max(0, dx - 200)
95  const belly = Math.min(46, reach * 0.09)
96  const crackD = d(wx + dx * 0.3, wy + 6 + belly, wx + dx * 0.72, ty - 34 - belly * 0.8, tx, ty)
97  const frames = [
98    back, // trailing on the platform, wound back
99    d(wx + 40, wy - 6, wx + 80, wy + 6, wx + 120, wy - 4), // flung out sideways
100    crackD, // CRACK
101    crackD, // ...held so the eye can follow foreman -> whip -> target
102    d(wx + dx * 0.3, wy + 2, wx + dx * 0.6, ty + 24, tx - dx * 0.06, ty + 8), // ripple
103    d(wx + dx * 0.25, wy + 16, wx + dx * 0.5, ty + 28, dropX, ty + 24), // droop
104    back,
105  ]
106  const anim = `<animate attributeName="d" values="${frames.join(';')}" keyTimes="0;0.45;0.58;0.7;0.78;0.9;1" ${t}/>`
107  const whip =
108    `<path d="${frames[2]}" stroke="${p.ink}" stroke-width="3.5" fill="none" stroke-linecap="round">${anim}</path>` +
109    `<path d="${frames[2]}" stroke="${p.accentAlt}" stroke-width="1.2" fill="none" stroke-dasharray="3 4">${anim}</path>` +
110    `<line x1="${hx - 2}" y1="${hy + 2}" x2="${wx}" y2="${wy}" stroke="${p.accentAlt}" stroke-width="5"/>` +
111    `<line x1="${hx + 4}" y1="${hy - 4}" x2="${wx}" y2="${wy}" stroke="${p.accent}" stroke-width="2"/>`
112
113  // starburst at the tip, timed to the snap
114  let star = ''
115  for (let i = 0; i < 16; i++) {
116    const a = (i / 16) * Math.PI * 2
117    const rr = i % 2 ? 9 : 22 + (i % 4 === 0 ? 4 : 0)
118    star += `${n1(Math.cos(a) * rr * 1.25)},${n1(Math.sin(a) * rr)} `
119  }
120  star = star.trim()
121  const ck = 'keyTimes="0;0.56;0.6;0.68;0.8;1"'
122  const crack =
123    `<g transform="translate(${n1(tx)},${n1(ty - 6)})">` +
124    `<g opacity="0"><animate attributeName="opacity" values="0;0;1;1;0;0" ${ck} ${t}/>` +
125    `<animateTransform attributeName="transform" type="scale" values="0.3;0.3;1.25;1;0.9;0.3" ${ck} ${t}/>` +
126    `<polygon points="${star}" fill="${p.accent}" stroke="${p.ink}" stroke-width="2.5"/>` +
127    `<polygon points="${star}" fill="${p.paper}" transform="scale(0.8)"/>` +
128    `<text x="0" y="4" text-anchor="middle" font-family="${IMPACT}" font-size="11" font-weight="bold" fill="${p.danger}" stroke="${p.ink}" stroke-width="0.6" transform="rotate(-10)">CRACK!</text>` +
129    `</g></g>`
130
131  return (
132    `<g><animateTransform attributeName="transform" type="translate" values="0,0;0,-2;3,-6;0,0;0,0" keyTimes="0;0.45;0.6;0.75;1" ${t}/>` +
133    body + whip + crack + `</g>`
134  )
135}
136
137function solo(f: ForemanView, p: Palette): string {
138  const t = 'dur="0.7s" repeatCount="indefinite"'
139  const kt = 'keyTimes="0;0.1;0.45;0.85;1"'
140  const brows =
141    `<polygon points="-19,-37 -7,-33 -7,-31 -19,-34" fill="${p.ink}"/>` +
142    `<polygon points="19,-37 7,-33 7,-31 19,-34" fill="${p.ink}"/>`
143  const mouth = R(-7, -16, 14, 5, p.ink) + R(-6, -15, 12, 3, '#fff') + R(-2, -15, 1, 3, p.ink) + R(2, -15, 1, 3, p.ink)
144  const cuffs = R(-30, -28, 6, 2, p.paper)
145  const drop = (x: number, b: number) =>
146    `<g opacity="0">${R(x, -36, 2, 3, '#fff')}${R(x - 0.5, -34, 3, 2, '#fff')}` +
147    `<animateTransform attributeName="transform" type="translate" values="0,0;-2,16" dur="0.9s" begin="${b}s" repeatCount="indefinite"/>` +
148    `<animate attributeName="opacity" values="0.9;0.9;0" dur="0.9s" begin="${b}s" repeatCount="indefinite"/></g>`
149  const anvil =
150    `<g stroke="${p.ink}" stroke-width="1">` +
151    R(27, -5, 20, 5, p.metal) + R(32, -11, 10, 6, p.metalDark) +
152    `<polygon points="25,-16 49,-16 54,-14.5 49,-11 25,-11" fill="${p.metal}"/>` + `</g>` +
153    R(25.5, -15.5, 23, 1.5, p.metalLight) + R(28, -3, 18, 1, p.metalLight) +
154    `<rect x="34" y="-18.5" width="10" height="2.5" fill="${p.accentAlt}">` +
155    `<animate attributeName="fill" values="${p.accent};${p.accentAlt};${p.accentAlt};${p.accent}" keyTimes="0;0.2;0.99;1" ${t}/></rect>`
156  // forearm out of the right nub, gripping the hammer; struck pose at rotation 0
157  const hammer =
158    `<g>${R(31, -23.5, 12, 2.5, p.metalLight)}${R(38, -27, 8, 8.5, p.ink)}${R(38, -27, 8, 1.5, p.metalLight)}` +
159    `${R(24, -27, 10, 6, p.clawd)}${R(24, -22, 10, 1.5, p.clawdDark)}${R(32, -26, 3, 4, p.clawdDark)}${R(24, -28, 2, 8, p.paper)}` +
160    `<animateTransform attributeName="transform" type="rotate" values="0 26 -24;-6 26 -24;-60 26 -24;-60 26 -24;0 26 -24" ${kt} ${t}/></g>`
161  let sparks = ''
162  const sp: [number, number][] = [[-8, -10], [6, -12], [12, -4], [-11, -3], [0, -14]]
163  for (const [sx, sy] of sp) {
164    sparks += `<rect x="41" y="-19" width="2" height="2" fill="${p.accent}" opacity="0">` +
165      `<animate attributeName="opacity" values="1;0;0;1" keyTimes="0;0.2;0.99;1" ${t}/>` +
166      `<animateTransform attributeName="transform" type="translate" values="0,0;${sx},${sy};${sx},${sy};0,0" keyTimes="0;0.2;0.99;1" ${t}/></rect>`
167  }
168  sparks += `<text x="36" y="-32" font-family="${IMPACT}" font-size="8" fill="${p.accent}" stroke="${p.ink}" stroke-width="0.5" transform="rotate(-20 36 -32)">CLANG!` +
169    `<animate attributeName="opacity" values="1;1;0;0;1" keyTimes="0;0.25;0.3;0.99;1" ${t}/></text>`
170  const grawlix =
171    `<text x="-44" y="-56" font-family="${IMPACT}" font-size="8" fill="${p.danger}" stroke="${p.ink}" stroke-width="0.3" transform="rotate(-12 -44 -56)">#@%&amp;!` +
172    `<animate attributeName="opacity" values="1;0.2;1" dur="0.5s" repeatCount="indefinite"/></text>`
173  const over = brows + costume(p, mouth) + R(16, -3, 7, 3, p.ink) + cuffs + hat(p, 7) +
174    drop(-27, 0) + drop(25, 0.45) + anvil + hammer + sparks + grawlix
175  return clawd({ x: f.x, y: f.y, scale: S, body: p.clawd, shade: p.clawdDark, eyes: 'angry', ink: p.ink, over })
176}
177
178function idle(f: ForemanView, p: Palette): string {
179  const brow = `<polygon points="7,-35 15,-37 15,-35.5 7,-33.5" fill="${p.ink}"/>` + R(-15, -31, 7, 1.5, p.ink)
180  const smirk = `<path d="M-4,-11.5 L4,-11.5 L9,-14.5" stroke="${p.ink}" stroke-width="2" fill="none"/>` +
181    R(9, -14, 2, 2, p.clawdDark)
182  const steam = (x: number, b: number) =>
183    `<g opacity="0">${R(x, -38, 2, 3, p.smoke)}${R(x + 1, -42, 2, 3, p.smoke)}` +
184    `<animateTransform attributeName="transform" type="translate" values="0,0;1,-8" dur="1.6s" begin="${b}s" repeatCount="indefinite"/>` +
185    `<animate attributeName="opacity" values="0.8;0" dur="1.6s" begin="${b}s" repeatCount="indefinite"/></g>`
186  // teacup in the left nub, pinky out, sipped now and then
187  const cup =
188    `<g>${R(-45, -24, 16, 2, p.paper)}${R(-42, -32, 10, 8, p.paper)}${R(-42, -32, 10, 1.5, p.accent)}` +
189    `${R(-46, -30, 3, 4, 'none', ` stroke="${p.paper}" stroke-width="1.2"`)}${R(-34, -28, 8, 7, p.clawd)}${R(-34, -22, 8, 1.5, p.clawdDark)}${R(-33, -36, 3, 6, p.clawd, ' transform="rotate(25 -31.5 -30)"')}` +
190    steam(-39, 0) + steam(-35, 0.8) +
191    `<animateTransform attributeName="transform" type="translate" values="0,0;0,0;5,-4;5,-4;0,0;0,0" keyTimes="0;0.55;0.62;0.8;0.86;1" dur="5s" repeatCount="indefinite"/></g>`
192  // pocket watch on its chain, ticking
193  const watch =
194    `<path d="M8,-11 Q22,-4 31,-22" stroke="${p.accent}" stroke-width="1" fill="none"/>` +
195    `<circle cx="34" cy="-24" r="5" fill="${p.accent}"/><circle cx="34" cy="-24" r="3.6" fill="${p.paper}"/>` +
196    `<rect x="33.6" y="-27" width="0.8" height="3" fill="${p.ink}"><animateTransform attributeName="transform" type="rotate" values="0 34 -24;360 34 -24" dur="4s" repeatCount="indefinite"/></rect>` +
197    R(33, -30, 2, 2, p.accent)
198  // whip coiled on the hip
199  const coil =
200    R(-26, -23, 4, 6, p.accentAlt) + R(-26, -23, 4, 1, p.accent) +
201    `<g fill="none" stroke="${p.ink}" stroke-width="2.2"><ellipse cx="-29" cy="-15" rx="5.5" ry="4.5"/><ellipse cx="-29" cy="-15" rx="3" ry="2.4"/></g>` +
202    `<g fill="none" stroke="${p.accentAlt}" stroke-width="0.8"><ellipse cx="-29" cy="-15" rx="5.5" ry="4.5"/></g>` +
203    `<path d="M-33,-12 Q-36,-6 -33,-3 Q-31,-1 -34,1" stroke="${p.ink}" stroke-width="1.3" fill="none"/>`
204  const tap =
205    `<g>${R(16, -3, 7, 3, p.ink)}<animateTransform attributeName="transform" type="translate" values="0,0;0,0;0,-3;0,0;0,-3;0,0" keyTimes="0;0.6;0.68;0.76;0.84;1" dur="2.4s" repeatCount="indefinite"/></g>`
206  const over = brow + costume(p, smirk) + tap + hat(p, 0) + coil + watch + cup
207  return clawd({ x: f.x, y: f.y, scale: S, body: p.clawd, shade: p.clawdDark, eyes: 'tired', ink: p.ink, bob: 3, over })
208}
209
210/** Tiny brass plaque on the platform: the foreman's own tally. */
211function plaque(f: ForemanView, p: Palette): string {
212  const label = `TOOLS SWUNG: ${f.tools}`
213  const sub = f.mode === 'idle' ? 'TEA BREAK: NEVER' : f.mode === 'solo' ? 'STAFF: ME. ONLY ME' : ''
214  const w = Math.max(label.length, sub.length) * 6 + 14
215  // with a sub-line, the plaque flips between the tally and the sub-line
216  const flip = (a: string) => sub ? `<animate attributeName="opacity" values="${a}" keyTimes="0;0.5;1" calcMode="discrete" dur="6s" repeatCount="indefinite"/>` : ''
217  return (
218    `<g transform="translate(${f.x},${f.y + 7})" shape-rendering="crispEdges">` +
219    R(-w / 2, 0, w, 15, p.metalDark, ` stroke="${p.accent}" stroke-width="1"`) +
220    R(-w / 2 + 3, 3, 2, 2, p.accent) + R(w / 2 - 5, 3, 2, 2, p.accent) +
221    `<text x="0" y="11" text-anchor="middle" font-family="monospace" font-size="9" font-weight="bold" fill="${p.accent}">${esc(label)}${flip('1;0;1')}</text>` +
222    (f.mode === 'whipping'
223      ? `<text x="${n1(w / 2 + 3)}" y="10" font-family="${IMPACT}" font-size="10" fill="${p.danger}" stroke="${p.ink}" stroke-width="0.5" opacity="0">+1` +
224        `<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.6;0.62;0.85;1" ${WHIP_SYNC}/>` +
225        `<animateTransform attributeName="transform" type="translate" values="0,0;0,0;0,-6" keyTimes="0;0.6;1" ${WHIP_SYNC}/></text>`
226      : '') +
227    (sub ? `<text x="0" y="11" text-anchor="middle" font-family="monospace" font-size="9" font-weight="bold" fill="${p.danger}" opacity="0">${sub}${flip('0;1;0')}</text>` : '') +
228    `</g>`
229  )
230}
231
232/** Claude the foreman: top hat, moustache, whip, on a platform. */
233export function foreman(f: ForemanView, p: Palette): string {
234  const body = f.mode === 'whipping' ? whipping(f, p) : f.mode === 'solo' ? solo(f, p) : idle(f, p)
235  return `<g shape-rendering="crispEdges">${body}</g>` + plaque(f, p)
236}
237
238/** Word-wrap to at most `max` lines of `width` chars, ellipsis on overflow. */
239function wrap(text: string, width: number, max: number): string[] {
240  const words = text.toUpperCase().split(/\s+/).filter(Boolean)
241  const lines: string[] = []
242  let cur = ''
243  for (let w of words) {
244    while (w.length > width) {
245      if (cur) { lines.push(cur); cur = '' }
246      lines.push(w.slice(0, width))
247      w = w.slice(width)
248    }
249    if (!w) continue
250    if (!cur) cur = w
251    else if (cur.length + 1 + w.length <= width) cur += ' ' + w
252    else { lines.push(cur); cur = w }
253  }
254  if (cur) lines.push(cur)
255  if (lines.length > max) {
256    lines.length = max
257    const last = lines[max - 1]!
258    lines[max - 1] = (last.length >= width ? last.slice(0, width - 1) : last) + '…'
259  }
260  return lines
261}
262
263/** The SCREAM bubble: spiky comic starburst. (x,y) = point above the foreman's head. */
264export function shoutBubble(text: string, x: number, y: number, p: Palette): string {
265  if (!text || !text.trim()) return ''
266  const lines = wrap(text, 34, 4)
267  const maxLen = Math.max(4, ...lines.map(l => l.length))
268  const tw = maxLen * 7.5, lh = 17, th = lines.length * lh
269  const rx = (tw / 2) * 1.1 + 24, ry = (th / 2) * 1.25 + 18
270  const spike = 1.22
271  const ox = rx * spike, oy = ry * spike
272  // below the motto ribbon (y ~120) when it fits, never down into the conveyor
273  const cy = Math.min(Math.max(6 + oy, y - 14 - oy, 124 + oy), Math.max(6 + oy, 266 - oy))
274  const cx = Math.max(10 + ox, Math.min(640 - ox, x + 40 + ox))
275  // jagged outline: valleys on the ellipse, spikes beyond it
276  const N = Math.max(18, Math.round((rx + ry) / 11) * 2)
277  const pts: string[] = []
278  for (let i = 0; i < N; i++) {
279    const a = (i / N) * Math.PI * 2 + 0.13
280    const k = i % 2 ? 1 : spike - ((i * 7) % 5) * 0.035
281    pts.push(`${n1(cx + Math.cos(a) * rx * k)},${n1(cy + Math.sin(a) * ry * k)}`)
282  }
283  const poly = pts.join(' ')
284  // zig-zag tail from the lower-left down toward the mouth
285  // tail runs down the RIGHT side of the head (outside the hat brim) to the moustache/mouth corner
286  const ey = (bx: number) => cy + ry * Math.sqrt(Math.max(0, 1 - ((bx - cx) / rx) ** 2))
287  const b1x = Math.max(x + 44, cx - rx * 0.88), b2x = b1x + 24
288  const b1y = ey(b1x) - 8, b2y = ey(b2x) - 8
289  const tipx = x + 22, tipy = y + 53
290  // lightning-bolt kink: both edges jog right ~55% of the way down
291  const L = (ax: number, ay: number, t: number): [number, number] => [ax + (tipx - ax) * t, ay + (tipy - ay) * t]
292  const [l1x, l1y] = L(b1x, b1y, 0.55), [r1x, r1y] = L(b2x, b2y, 0.5)
293  const tail = `${n1(b1x)},${n1(b1y)} ${n1(l1x + 7)},${n1(l1y)} ${n1(l1x + 3)},${n1(l1y + 3)} ${n1(tipx)},${n1(tipy)} ${n1(r1x + 2)},${n1(r1y + 2)} ${n1(r1x + 6)},${n1(r1y - 2)} ${n1(b2x)},${n1(b2y)}`
294  const tailIn = `${n1(b1x + 3)},${n1(b1y - 8)} ${n1(b2x - 3)},${n1(b2y - 8)} ${n1(b2x - 4)},${n1(b2y + 4)} ${n1(b1x + 4)},${n1(b1y + 4)}`
295  const y0 = cy - th / 2 + lh - 4
296  const tspans = lines.map((l, i) => `<tspan x="${n1(cx)}" y="${n1(y0 + i * lh)}">${esc(l)}</tspan>`).join('')
297  const ticks = [-0.5, 0.25].map(a => {
298    const c = Math.cos(a), s = Math.sin(a)
299    return `<line x1="${n1(cx + c * rx * 1.3)}" y1="${n1(cy + s * ry * 1.3)}" x2="${n1(cx + c * rx * 1.42)}" y2="${n1(cy + s * ry * 1.42)}"/>`
300  }).join('')
301  return (
302    `<g>` +
303    `<animateTransform attributeName="transform" type="translate" values="0,0;2,-1.5;-2,1.5;1.5,1;-1.5,-1;0,0;0.6,-0.4;0,0" keyTimes="0;0.06;0.12;0.18;0.24;0.3;0.65;1" dur="1.2s" repeatCount="indefinite"/>` +
304    `<g stroke="${p.accent}" stroke-width="3">${ticks}</g>` +
305    `<polygon points="${poly}" fill="${p.ink}" transform="translate(4,4)" opacity="0.5"/>` +
306    `<polygon points="${tail}" fill="${p.paper}" stroke="${p.ink}" stroke-width="3"/>` +
307    `<polygon points="${poly}" fill="${p.paper}" stroke="${p.ink}" stroke-width="3.5"/>` +
308    `<polygon points="${tailIn}" fill="${p.paper}"/>` +
309    `<text text-anchor="middle" font-family="${IMPACT}" font-size="15" font-weight="bold" letter-spacing="0.4" fill="${p.ink}">${tspans}</text>` +
310    `</g>`
311  )
312}
313
hooks/art/roles/index.ts 17 lines
1import type { Palette, Role, WorkerView } from '../types.ts'
2import { exploreWorker } from './explore.ts'
3import { planWorker } from './plan.ts'
4import { generalWorker } from './general.ts'
5import { reviewWorker } from './review.ts'
6import { guideWorker } from './guide.ts'
7import { otherWorker } from './other.ts'
8
9export const ROLE_ART: Record<Role, (w: WorkerView, p: Palette) => string> = {
10  explore: exploreWorker,
11  plan: planWorker,
12  general: generalWorker,
13  review: reviewWorker,
14  guide: guideWorker,
15  other: otherWorker,
16}
17
hooks/art/clawd.ts 140 lines
1import type { Palette } from './types.ts'
2import { WHIP_CYCLE, WHIP_PHASE } from './types.ts'
3// Clawd: Claude Code's little orange pixel mascot. Every character in the
4// factory is built on this one sprite so they all read as "Claude".
5//
6// Coordinate convention (the whole art kit uses it): (0,0) is the centre
7// between the feet; the body stands upward into negative y. At scale 1 the
8// sprite is 60 wide (x -30..30) and 40 tall (y -40..0). Arms are the side
9// nubs at y -28..-20. The top of the head is y = -40 * scale.
10
11export type ClawdEyes = 'normal' | 'angry' | 'tired' | 'happy' | 'dead' | 'closed' | 'wide'
12
13export type ClawdOptions = {
14  x?: number
15  y?: number
16  scale?: number
17  body: string
18  shade: string
19  eyes?: ClawdEyes
20  /** eye / pupil colour */
21  ink?: string
22  /** gentle up-down bob (SMIL); seconds per cycle, 0 = still */
23  bob?: number
24  /** extra SVG drawn inside the sprite's local frame, behind the body */
25  behind?: string
26  /** extra SVG drawn inside the sprite's local frame, over the body */
27  over?: string
28  /** mirror horizontally */
29  flip?: boolean
30}
31
32const r = (x: number, y: number, w: number, h: number, fill: string) =>
33  `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`
34
35function eyes(kind: ClawdEyes, ink: string): string {
36  switch (kind) {
37    case 'angry':
38      return (
39        r(-14, -31, 5, 9, ink) + r(9, -31, 5, 9, ink) +
40        `<polygon points="-18,-36 -7,-32 -7,-30 -18,-33" fill="${ink}"/>` +
41        `<polygon points="18,-36 7,-32 7,-30 18,-33" fill="${ink}"/>`
42      )
43    case 'tired':
44      return r(-14, -27, 5, 4, ink) + r(9, -27, 5, 4, ink) +
45        r(-15, -29, 7, 1.5, ink) + r(8, -29, 7, 1.5, ink)
46    case 'happy':
47      return `<path d="M-15,-25 l3.5,-5 l3.5,5 M8,-25 l3.5,-5 l3.5,5" stroke="${ink}" stroke-width="2.5" fill="none"/>`
48    case 'dead':
49      return `<path d="M-15,-32 l6,8 M-9,-32 l-6,8 M9,-32 l6,8 M15,-32 l-6,8" stroke="${ink}" stroke-width="2.5"/>`
50    case 'closed':
51      return r(-15, -26, 7, 2.5, ink) + r(8, -26, 7, 2.5, ink)
52    case 'wide':
53      return r(-15, -33, 7, 11, ink) + r(8, -33, 7, 11, ink) +
54        r(-13, -31, 2, 2, '#fff') + r(10, -31, 2, 2, '#fff')
55    default:
56      return r(-14, -32, 5, 10, ink) + r(9, -32, 5, 10, ink)
57  }
58}
59
60/** The bare sprite: body block, side nubs, four legs, eyes. */
61export function clawdBody(o: Pick<ClawdOptions, 'body' | 'shade' | 'eyes' | 'ink'>): string {
62  const ink = o.ink ?? '#1a1a1a'
63  return (
64    // legs
65    r(-22, -8, 5, 8, o.shade) + r(-11, -8, 5, 8, o.shade) +
66    r(6, -8, 5, 8, o.shade) + r(17, -8, 5, 8, o.shade) +
67    // side nubs (arms)
68    r(-30, -28, 6, 8, o.body) + r(24, -28, 6, 8, o.body) +
69    // body
70    r(-24, -40, 48, 32, o.body) +
71    // bottom shade strip for depth
72    r(-24, -12, 48, 4, o.shade) +
73    eyes(o.eyes ?? 'normal', ink)
74  )
75}
76
77/** A full, positioned Clawd with optional bob, accessories and flip. */
78export function clawd(o: ClawdOptions): string {
79  const s = o.scale ?? 1
80  const flip = o.flip ? ' scale(-1,1)' : ''
81  const bob = o.bob
82    ? `<animateTransform attributeName="transform" type="translate" values="0,0;0,-2;0,0" dur="${o.bob}s" repeatCount="indefinite"/>`
83    : ''
84  return (
85    `<g transform="translate(${o.x ?? 0},${o.y ?? 0}) scale(${s})${flip}" shape-rendering="crispEdges">` +
86    `<g>${bob}${o.behind ?? ''}${clawdBody(o)}${o.over ?? ''}</g>` +
87    `</g>`
88  )
89}
90
91/** Shift a #rrggbb colour's hue by `deg` and lightness by `dl` (-1..1). */
92export function tint(hex: string, deg: number, dl = 0): string {
93  const n = parseInt(hex.slice(1), 16)
94  let rr = ((n >> 16) & 255) / 255, gg = ((n >> 8) & 255) / 255, bb = (n & 255) / 255
95  const max = Math.max(rr, gg, bb), min = Math.min(rr, gg, bb)
96  let h = 0, sat = 0
97  const l = (max + min) / 2
98  if (max !== min) {
99    const d = max - min
100    sat = l > 0.5 ? d / (2 - max - min) : d / (max + min)
101    h = max === rr ? (gg - bb) / d + (gg < bb ? 6 : 0) : max === gg ? (bb - rr) / d + 2 : (rr - gg) / d + 4
102    h *= 60
103  }
104  h = (h + deg + 360) % 360
105  const L = Math.min(1, Math.max(0, l + dl))
106  const c = (1 - Math.abs(2 * L - 1)) * sat
107  const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
108  const m = L - c / 2
109  const [a, b, cc] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x]
110  const to = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0')
111  return `#${to(a)}${to(b)}${to(cc)}`
112}
113
114/** Escape text for SVG. */
115export function esc(s: string): string {
116  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
117}
118
119/** Every worker's sprite scale (the foreman is 1.8, so the hierarchy holds). */
120export const WORKER_SCALE = 1.1
121
122/** Timing attributes for anything synced to the foreman's whip crack. */
123export const WHIP_SYNC = `dur="${WHIP_CYCLE}s" begin="-${+(WHIP_CYCLE * WHIP_PHASE).toFixed(2)}s" repeatCount="indefinite"`
124
125/** Wrap a whipped worker: still, JUMP on the crack (0.6), settle. */
126export function flinch(inner: string, dy = -9): string {
127  return `<g><animateTransform attributeName="transform" type="translate" values="0,0;0,0;1,${dy};-1,-2;0,0" keyTimes="0;0.58;0.62;0.75;1" ${WHIP_SYNC}/>${inner}</g>`
128}
129
130/** The one true OW!: pops on the crack, rises, fades. (x,y) = text centre-baseline. */
131export function ow(x: number, y: number, p: Palette): string {
132  return (
133    `<g transform="translate(${x},${y}) rotate(-8)" opacity="0">` +
134    `<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.58;0.6;0.9;1" ${WHIP_SYNC}/>` +
135    `<g><animateTransform attributeName="transform" type="translate" values="0,4;0,4;0,0;0,-6;0,-6" keyTimes="0;0.58;0.62;0.95;1" ${WHIP_SYNC}/>` +
136    `<text text-anchor="middle" font-family="Impact,'Arial Black',sans-serif" font-size="16" letter-spacing=".5" fill="${p.danger}" stroke="${p.ink}" stroke-width="3.5" stroke-linejoin="round" paint-order="stroke">OW!</text>` +
137    `</g></g>`
138  )
139}
140
hooks/art/roles/explore.ts 243 lines
1import type { Palette, WorkerView } from '../types.ts'
2import { clawd, esc, flinch, ow, tint, WHIP_SYNC, WORKER_SCALE } from '../clawd.ts'
3import type { ClawdEyes } from '../clawd.ts'
4
5// Explore: a Clawd prospector hacking 'code ore' out of a coal face, headlamp
6// sweeping, mine cart filling up. Local frame: (0,0) = feet-centre of slot.
7
8const R = (x: number, y: number, w: number, h: number, f: string, a = '') =>
9  `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${f}"${a}/>`
10/** Several same-colour rects as one path: [x,y,w,h,...]. */
11const P = (f: string, ...q: number[]) => {
12  let d = ''
13  for (let i = 0; i < q.length; i += 4) d += `M${q[i]} ${q[i + 1]}h${q[i + 2]}v${q[i + 3]}h${-q[i + 2]!}z`
14  return `<path d="${d}" fill="${f}"/>`
15}
16const IND = 'repeatCount="indefinite"'
17const T = (x: number, y: number, s: string, f: string, size = 8, a = '') =>
18  `<text x="${x}" y="${y}"${size === 8 ? '' : ` font-size="${size}"`} fill="${f}"${a}>${esc(s)}</text>`
19const blink = (dur: number, begin: number, vals = '0;1;0') =>
20  `<animate attributeName="opacity" values="${vals}" dur="${dur}s" begin="${begin}s" ${IND}/>`
21const INK = (p: Palette) => ` stroke="${p.ink}"`
22const OUT = (p: Palette, wd = 2) => ` stroke="${p.ink}" stroke-width="${wd}" paint-order="stroke"`
23
24/** Pull a colour toward its own grey (earthy, dusty), k in 0..1. */
25function desat(hex: string, k: number): string {
26  const n = parseInt(hex.slice(1), 16)
27  const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255]
28  const g = 0.3 * c[0]! + 0.59 * c[1]! + 0.11 * c[2]!
29  return '#' + c.map(v => Math.round(v + (g - v) * k).toString(16).padStart(2, '0')).join('')
30}
31
32const mix = (a: string, b: string, k: number) => {
33  const A = parseInt(a.slice(1), 16), B = parseInt(b.slice(1), 16)
34  return '#' + [16, 8, 0].map(sh => Math.round(((A >> sh) & 255) * (1 - k) + ((B >> sh) & 255) * k).toString(16).padStart(2, '0')).join('')
35}
36
37const GLYPHS = ['{}', ';', '<>', '()', '</>', '[]', '#', '=>']
38const CX = 4 // character x
39const S = WORKER_SCALE
40
41/** The coal face of code ore: strata, a glinting vein and glyph nuggets. */
42function rock(p: Palette, seed: number, failed: boolean): string {
43  const g = (i: number) => GLYPHS[(seed + i * 3) % GLYPHS.length]!
44  const spots: [number, number][] = [[-70, -84], [-62, -68], [-70, -36], [-58, -20], [-68, -6]]
45  let s =
46    `<polygon points="-75,4 -75,-100 -62,-106 -50,-96 -46,-80 -40,-70 -44,-52 -36,-40 -42,-22 -34,-8 -36,4" fill="${mix(p.metalDark, p.metal, 0.6)}" stroke="${p.ink}" stroke-width="1.5"/>` +
47    P(p.metal, -75, -78, 29, 2, -75, -58, 31, 1, -75, -44, 38, 2, -75, -26, 33, 1, -75, -12, 39, 1) +
48    `<polygon points="-75,-100 -62,-106 -50,-96 -58,-92 -75,-90" fill="${p.metal}"/>` +
49    `<polygon points="-46,-80 -40,-70 -44,-52 -48,-62" fill="${p.metal}"/>` +
50    `<polygon points="-42,-22 -34,-8 -36,4 -44,4 -40,-10" fill="${p.metal}"/>` +
51    P(p.bgDeep, -66, -72, 8, 2, -72, -50, 8, 2, -64, -32, 2, 7) +
52    // zigzag vein of ore
53    P(p.accent, -52, -4, 2, 6, -54, -10, 2, 6, -52, -16, 2, 6, -50, -24, 2, 8, -52, -30, 2, 6, -54, -38, 2, 8, -52, -46, 2, 8, -50, -54, 2, 8, -52, -62, 2, 8, -54, -70, 2, 8)
54  spots.forEach(([x, y], i) => {
55    s += T(x, y, g(i), i % 2 ? p.accent : p.accentAlt)
56    if (i % 2 === 0) s += `<g>${R(x + 6, y - 9, 2, 2, '#fff')}${blink(1.6 + i * 0.3, -((seed + i) % 10) / 5)}</g>`
57  })
58  if (failed) {
59    for (let i = 0; i < 2; i++) {
60      s += `<g opacity="0">${P(p.smoke, -62 + i * 7, -98, 10, 8, -60 + i * 7, -102, 6, 4)}` +
61        `<animateTransform attributeName="transform" type="translate" values="0,0;-4,-8" dur="1.8s" begin="${i * 0.6}s" ${IND}/>` +
62        `${blink(1.8, i * 0.6, '0;0.8;0')}</g>`
63    }
64    s += `<g>${R(-42, -46, 3, 3, p.accent)}${R(-36, -52, 2, 2, p.danger)}${R(-38, -38, 2, 2, p.accent)}${blink(0.35, 0)}</g>`
65  }
66  return s
67}
68
69/** Mine cart on a rail stub; `fill` pieces static, the rest animate in while filling. */
70function cart(p: Palette, seed: number, fill: number, filling: boolean, tipped: boolean, full: boolean): string {
71  const ore = (i: number, x = 42 + i * 8, y = -31 - (i % 2) * 3) =>
72    R(x, y, 9, 6, p.metal, ` stroke="${p.metalLight}" stroke-width="1"`) + T(x + 1, y + 5, GLYPHS[(seed + i) % GLYPHS.length]!.slice(0, 2), p.accent, 6)
73  let pile = ''
74  for (let i = 0; i < 3; i++) {
75    if (i < fill) pile += ore(i)
76    else if (filling) {
77      pile += `<g opacity="0">${ore(i)}<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;${(0.15 + (i - fill) * 0.3).toFixed(2)};0.95;1" calcMode="discrete" dur="6s" ${IND}/></g>`
78    }
79  }
80  if (full) pile += R(46, -37, 18, 9, p.metalDark) + T(48, -30, '{}', p.paper, 8)
81  const body =
82    pile +
83    `<polygon points="36,-24 72,-24 68,-6 40,-6" fill="${p.metal}"/>` +
84    P(p.metalDark, 46, -20, 16, 2, 46, -14, 16, 2) + P(p.ink, 40, -7, 7, 7, 61, -7, 7, 7) +
85    P(p.metalLight, 35, -26, 38, 3, 42, -5, 3, 3, 63, -5, 3, 3) +
86    (full ? T(47, -9, 'FULL', p.paper, 6) + T(41, -15, 'x3', p.paper, 6) + R(37, -30, 4, 4, p.metalLight) + R(69, -29, 4, 3, p.accent) + R(71, -22, 3, 3, p.metal) : '')
87  const t = tipped ? ` transform="translate(-6,0) rotate(14 70 0)"` : ''
88  return R(30, 0, 44, 2, p.metalLight) + P(p.floorLine, 34, 2, 3, 2, 66, 2, 3, 2) +
89    `<g${t}>${body}</g>` +
90    (tipped ? ore(0, 24, -6) + ore(1, 33, -4) + T(10, -1, '{', p.accent, 7) + T(17, -3, ';', p.accent, 7) : '')
91}
92
93/** Two-pronged pick head, vertical, centred at (x,y) (handle runs along x). */
94const pickHeadV = (p: Palette, x: number, y: number) =>
95  `<polygon points="${x + 5},${y - 15} ${x - 1},${y - 8} ${x - 3},${y} ${x - 1},${y + 8} ${x + 5},${y + 15} ${x + 2},${y + 7} ${x + 1},${y} ${x + 2},${y - 7}" fill="${p.metalLight}"${INK(p)}/>` +
96  P(p.metal, x + 1, y - 8, 1, 16) + R(x - 1, y - 4, 7, 8, p.metalDark)
97/** Same head, horizontal (handle hangs below), centred at (x,y). */
98const pickHeadH = (p: Palette, x: number, y: number) =>
99  `<polygon points="${x - 15},${y + 5} ${x - 8},${y - 1} ${x},${y - 3} ${x + 8},${y - 1} ${x + 15},${y + 5} ${x + 7},${y + 2} ${x},${y + 1} ${x - 7},${y + 2}" fill="${p.metalLight}"${INK(p)}/>` +
100  P(p.metal, x - 8, y + 1, 16, 1) + R(x - 4, y - 2, 8, 7, p.metalDark)
101/** Upright pick: head centred at (x,y), handle 26 long below it. */
102const pickUp = (p: Palette, x: number, y: number) =>
103  R(x - 2, y, 5, 26, p.accentAlt) + R(x - 2, y, 1, 26, p.ink, ' opacity="0.3"') + pickHeadH(p, x, y)
104
105/** Pickaxe in the left nub's local frame, pointing left, pivot (-27,-24). */
106function pickaxe(p: Palette, anim: string, broken = false): string {
107  const handle = broken
108    ? R(-40, -26, 13, 5, p.accentAlt) + P(p.paper, -42, -27, 2, 2, -42, -23, 2, 2)
109    : R(-58, -26, 31, 5, p.accentAlt) + R(-58, -22, 31, 1, p.ink, ' opacity="0.3"')
110  return `<g>${anim}${handle}${broken ? '' : pickHeadV(p, -60, -24)}</g>`
111}
112
113export function exploreWorker(w: WorkerView, p: Palette): string {
114  const st = w.status
115  const sd = w.seed
116  const hue = -18 + ((sd % 13) - 6)
117  const body = desat(tint(p.clawd, hue, -0.14), 0.3)
118  const shade = desat(tint(p.clawdDark, hue, -0.12), 0.3)
119  const ph = -((sd % 10) / 10)
120  const working = st === 'working', failed = st === 'failed', done = st === 'done', idle = st === 'idle', waiting = st === 'waiting'
121  const tgt = w.isTarget
122  const gray = p.sceneFilter.indexOf('grayscale') >= 0
123  const hatC = gray ? p.metalLight : p.sceneFilter ? p.accentAlt : p.accent
124
125  // ---- headlamp beam (behind body, local frame), from the lamp at (0,-47) ----
126  const m = sd % 2 ? 1 : -1
127  const sweep = tgt ? '0;28;-6;0' : working ? '-6;8;-6' : waiting ? '-18;14;-18' : idle ? '4;-34;-34;4' : '-4;10;-4'
128  const beamOp = idle
129    ? `<animate attributeName="opacity" values="0.3;0.12;0.12;0.3" keyTimes="0;0.7;0.9;1" dur="6s" ${IND}/>`
130    : failed ? `<animate attributeName="opacity" values="0.3;0;0.3;0.05;0.3" dur="0.9s" ${IND}/>` : ''
131  const beamFlip = idle ? ' transform="translate(-3,11)"' : waiting && m > 0 ? ' transform="scale(-1,1)"' : ''
132  const beam = !done
133    ? `<g${beamFlip}><g opacity="${idle ? 0.12 : 0.16}"><animateTransform attributeName="transform" type="rotate" values="${sweep.split(';').map(v => v + ' 0 -47').join(';')}" dur="${tgt ? 0.5 : idle ? 6 : waiting ? 2.4 : 3.2}s" begin="${tgt ? 0 : ph}s" ${IND}/>` +
134      `<polygon points="0,-47 -44,-66 -44,-28" fill="${p.accent}"/><polygon points="0,-47 -42,-56 -42,-38" fill="${p.paper}" opacity="0.5"/>` + beamOp + `</g></g>`
135    : ''
136
137  // ---- hard hat dome (local) ----
138  const hatT = done ? ` transform="rotate(8 0 -44)"` : idle ? ` transform="translate(-3,11) rotate(-12 -8 -44)"` : ''
139  const pc = [p.metalLight, p.danger, p.ok][sd % 3]!
140  const patch = sd % 2 ? R(9, -49, 5, 4, pc) : R(-17, -49, 6, 3, pc)
141  const lamp = done || idle ? p.metal : p.paper
142  const bird = failed
143    // dead canary on its back on the crown: feet up, X eye
144    ? `<g transform="translate(5,-62) scale(1.35) translate(-5,57)"><g stroke="${p.ink}" stroke-width="2" paint-order="stroke">${R(-3, -59, 12, 6, p.accent)}${R(8, -60, 6, 6, p.accent)}</g>` +
145      R(14, -58, 3, 2, p.danger) + P(p.ink, 0, -67, 2, 8, 5, -67, 2, 8, -2, -68, 4, 1, 4, -68, 4, 1) +
146      `<path d="M9.5-58.5l3 3m0-3l-3 3" stroke="${p.ink}"/></g>`
147    : ''
148  const hat = `<g${hatT}>` + R(-22, -44, 44, 3, hatC) + R(-20, -50, 40, 6, hatC) + R(-17, -55, 34, 5, hatC) + R(-11, -58, 22, 3, hatC) +
149    R(-1, -58, 2, 14, p.ink, ' opacity="0.2"') + R(-22, -42, 44, 1, p.ink, ' opacity="0.35"') + R(-13, -55, 6, 2, p.paper, ' opacity="0.45"') + patch +
150    R(-5, -50, 10, 7, p.ink) + R(-4, -49, 8, 5, lamp) + bird + `</g>`
151
152  // ---- tool / props (local) ----
153  let tool = ''
154  let over = ''
155  if (working && tgt) {
156    // whipped mid-swing: the pick flies out of his nub and drops
157    tool = `<g><animateTransform attributeName="transform" type="translate" values="0,0;0,0;-4,24;-4,24" keyTimes="0;0.58;0.8;1" ${WHIP_SYNC}/>` +
158      pickaxe(p, `<animateTransform attributeName="transform" type="rotate" values="50 -27 -24;50 -27 -24;130 -27 -24;140 -27 -24" keyTimes="0;0.58;0.8;1" ${WHIP_SYNC}/>`) + `</g>`
159  } else if (working) {
160    tool = pickaxe(p, `<animateTransform attributeName="transform" type="rotate" values="70 -27 -24;80 -27 -24;-8 -27 -24;70 -27 -24" keyTimes="0;0.5;0.66;1" dur="0.9s" begin="${ph}s" ${IND}/>`)
161    over += `<g opacity="0">${R(24, -40, 3, 4, p.paper)}<animateTransform attributeName="transform" type="translate" values="0,0;6,10" dur="0.9s" begin="${ph}s" ${IND}/>${blink(0.9, ph, '1;0')}</g>` +
162      `<g opacity="0">${R(-22, -42, 3, 4, p.paper)}<animateTransform attributeName="transform" type="translate" values="0,0;-5,9" dur="1.1s" begin="${(ph - 0.4).toFixed(1)}s" ${IND}/>${blink(1.1, ph - 0.4, '1;0')}</g>`
163  } else if (waiting) {
164    // squinting through the magnifier (eye picked by seed); pick propped, foot tapping
165    over += `<g><animateTransform attributeName="transform" type="translate" values="0,0;${-2 * m},1;0,0" dur="2s" ${IND}/>` +
166      R(m > 0 ? 25 : -29, -26, 4, 12, p.accentAlt) +
167      `<circle cx="${12 * m}" cy="-28" r="9" fill="${p.paper}" fill-opacity="0.3" stroke="${p.metalLight}" stroke-width="3"/>` +
168      R(12 * m - 5, -36, 10, 16, p.ink) + R(12 * m + (m > 0 ? 0 : -2), -33, 3, 3, '#fff') + `</g>` +
169      `<g>${R(m > 0 ? -22 : 17, -10, 5, 6, shade)}<animateTransform attributeName="transform" type="translate" values="0,0;0,-4" calcMode="discrete" dur="0.5s" ${IND}/></g>`
170  } else if (done) {
171    over += R(28, -30, 9, 9, p.paper) + R(37, -28, 3, 5, p.paper) + R(29, -30, 7, 2, p.accentAlt) + T(29, -30, '{', p.accent, 7) +
172      `<g>${P(p.smoke, 30, -38, 2, 3, 33, -42, 2, 3)}${blink(2.4, ph, '0;0.7;0')}</g>`
173  } else if (failed) {
174    tool = pickaxe(p, '', true)
175  }
176
177  let eyes: ClawdEyes = idle ? 'tired' : done ? 'happy' : failed ? 'dead' : 'normal'
178  if (tgt && !failed) eyes = 'wide'
179
180  // ---- the miner ----
181  const welt = tgt ? `<path d="M-8-26l14 11" stroke="${p.danger}" stroke-width="2.5"/>` + P(p.danger, 8, -15, 2, 2, -11, -28, 2, 2) : ''
182  const char = clawd({
183    x: CX, y: done ? -12 : 0, scale: S, body, shade, eyes, ink: p.ink,
184    bob: working && !tgt ? 0.45 : 0,
185    behind: beam + tool,
186    over: over + hat + welt,
187  })
188  let charG = char
189  if (idle) charG = `<g transform="translate(${CX},0) scale(1.05,0.9) translate(${-CX},0)">${char}</g>`
190  if (failed) charG = `<g transform="rotate(-8 ${CX} 0)">${char}</g>`
191  if (tgt) charG = flinch(charG)
192
193  // ---- scene props (slot frame) ----
194  let props = ''
195  let lean = waiting ? `<g transform="rotate(16 -36 0)">${pickUp(p, -36, -26)}</g>` : ''
196  if (idle || done) props += `<g transform="rotate(-24 -30 0)">${pickUp(p, -30, -26)}</g>`
197  if (done) {
198    props += R(CX - 22, -14, 44, 14, p.accentAlt) + R(CX - 22, -14, 44, 2, p.accent) + R(CX - 22, -8, 44, 1, p.ink) + R(CX - 2, -14, 2, 14, p.ink, ' opacity="0.4"') +
199      // hand-painted plank stuck in the ore pile
200      R(62, -52, 3, 20, p.accentAlt) + `<g transform="rotate(-6 58 -60)">${R(42, -70, 32, 19, p.paper)}${R(42, -52, 32, 1, p.ink, ' opacity="0.4"')}` +
201      T(46, -62, 'OFF', p.ink, 8) + T(45, -54, 'SHIFT', p.ink, 7) + `</g>`
202  }
203
204  // flying ore chips + strike sparks while working
205  const chips = working && !tgt
206    ? `<path d="M-44-39l-5 5 3 5-6 6 2 6-5 4" fill="none" stroke="${p.ink}" stroke-dasharray="30">` +
207      `<animate attributeName="stroke-dashoffset" values="30;21;12;0" calcMode="discrete" dur="7.2s" begin="${(ph - (sd % 4) * 1.8).toFixed(1)}s" ${IND}/></path>` +
208      [0, 1].map(i => `<g opacity="0">${R(-1, -1, 4, 4, i ? p.accent : p.metalLight)}` +
209        `<animateMotion path="M-40,-34 Q${8 + i * 6},${-120 + i * 10} ${50 + i * 8},-26" dur="0.9s" begin="${(ph + 0.6 + i * 0.05).toFixed(2)}s" ${IND}/>` +
210        `<animate attributeName="opacity" values="1;1;0" keyTimes="0;0.85;1" dur="0.9s" begin="${(ph + 0.6 + i * 0.05).toFixed(2)}s" ${IND}/></g>`).join('') +
211      `<g opacity="0">${R(-48, -44, 9, 9, '#fff')}${P(p.accent, -40, -30, 2, 2, -47, -28, 2, 2)}` +
212      `<animate attributeName="opacity" values="0;0;1;0;0" keyTimes="0;0.6;0.66;0.8;1" dur="0.9s" begin="${ph}s" ${IND}/></g>` +
213      `<g opacity="0">${T(-34, -66, 'TINK!', p.paper, 8, OUT(p, 2.5))}<animate attributeName="opacity" values="0;0;1;0" keyTimes="0;0.6;0.7;1" dur="1.8s" begin="${ph}s" ${IND}/></g>`
214    : ''
215
216  // ---- status flourishes ----
217  let fx = ''
218  if (waiting) {
219    fx += `<g>${T(CX + (sd % 2 ? 20 : -28), -74, '?', p.paper, 16, OUT(p))}<animateTransform attributeName="transform" type="translate" values="0,0;0,-4;0,0" dur="1.2s" ${IND}/></g>`
220  }
221  if (idle) {
222    fx += [0, 1, 2].map(i => `<g opacity="0">${T(CX + 14 + i * 7, -62 - i * 9, 'z', p.paper, 9 + i * 3)}` +
223      `<animateTransform attributeName="transform" type="translate" values="0,4;4,-6" dur="2.4s" begin="${-i * 0.8}s" ${IND}/>${blink(2.4, -i * 0.8)}</g>`).join('')
224  }
225  if (failed) {
226    fx += `<g>${T(CX - 48, -96, 'CAVE-IN!', p.danger, 9, OUT(p))}${blink(0.8, 0, '1;0.3;1')}</g>` +
227      `<polygon points="-44,4 -40,-2 -28,-2 -24,4" fill="${p.metalLight}"/>` + R(-30, 0, 9, 3, p.accentAlt) +
228      // rubble sliding onto the feet
229      `<g>${P(p.metal, -32, -5, 8, 6, -22, -3, 6, 4, -14, -4, 5, 5)}${P(p.metalLight, -26, -9, 6, 5, -17, -7, 4, 4)}` +
230      `<animateTransform attributeName="transform" type="translate" values="-8,-3;0,0;0,0" keyTimes="0;0.3;1" dur="2.4s" ${IND}/></g>`
231  }
232  if (tgt) {
233    fx += ow(CX + 26, -90, p) +
234      `<g opacity="0">${P(p.paper, CX - 34, -60, 3, 5, CX + 34, -64, 3, 5, CX + 40, -48, 3, 4)}` +
235      `<animateTransform attributeName="transform" type="translate" values="0,0;0,0;0,-8" keyTimes="0;0.6;1" ${WHIP_SYNC}/><animate attributeName="opacity" values="0;0;1;0" keyTimes="0;0.6;0.62;1" ${WHIP_SYNC}/></g>`
236  }
237
238  const fill = done ? 3 : working ? 1 + (sd % 2) : idle || waiting ? 1 + (sd % 2) : 0
239  return `<g transform="translate(${w.x},${w.y})" shape-rendering="crispEdges" font-family="monospace" font-size="8" font-weight="bold">` +
240    rock(p, sd, failed) + cart(p, sd, fill, working, failed, done) +
241    props + charG + lean + chips + fx + `</g>`
242}
243
hooks/art/roles/plan.ts 325 lines
1import type { Palette, WorkerView } from '../types.ts'
2import type { ClawdEyes } from '../clawd.ts'
3import { clawd, flinch as whipFlinch, ow as owText, tint, WHIP_SYNC, WORKER_SCALE } from '../clawd.ts'
4
5// The Plan worker: a Victorian draftsman Clawd at a drafting table, round
6// spectacles, pencil tucked behind the "ear", compass in hand scribing circles
7// onto a blueprint while rolled plans pile up in a bin under the table.
8
9const R = 'repeatCount="indefinite"'
10const r = (x: number, y: number, w: number, h: number, f: string, extra = '') =>
11  `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${f}"${extra}/>`
12
13const CX = -20 // character feet-centre x (local)
14const S = WORKER_SCALE // character scale
15
16type Ctx = { p: Palette; body: string; shade: string; blue: string; blueDk: string; drop: string; ph: number; seed: number }
17
18/** Drafting table: legs, crossbar, ledge, board frame. */
19function table(c: Ctx): string {
20  const { p } = c
21  return (
22    r(14, -42, 4, 42, p.metalDark) + r(64, -42, 4, 42, p.metalDark) +
23    r(14, -16, 54, 3, p.metalDark) +
24    r(8, -88, 66, 44, p.metal) + r(8, -88, 66, 2, p.metalLight) +
25    r(6, -45, 70, 4, p.metalLight) + r(6, -42, 70, 1, p.metalDark)
26  )
27}
28
29/** Bin of rolled blueprints under the table. */
30function bin(c: Ctx): string {
31  const { p, blue, blueDk } = c
32  let rolls = ''
33  const xs = [29, 35, 41, 47]
34  xs.forEach((x, i) => {
35    const h = 12 + ((c.seed + i * 3) % 4) * 2
36    rolls += r(x, -18 - h, 5, h + 4, i % 2 ? blueDk : blue) + r(x, -18 - h, 5, 2, '#fff', ' opacity=".8"')
37  })
38  return rolls + r(26, -18, 30, 18, p.accentAlt) + r(26, -18, 30, 2, p.metalLight) +
39    r(26, -10, 30, 1, p.metalDark) + r(26, -4, 30, 1, p.metalDark)
40}
41
42/** The blueprint sheet with grid and a box-and-arrow diagram. */
43function blueprint(c: Ctx, full: boolean, diagram = true): string {
44  const { blue } = c
45  let grid = ''
46  for (let x = 17; x < 71; x += 6) grid += `M${x},-85v38`
47  for (let y = -79; y < -47; y += 6) grid += `M11,${y}h60`
48  const flipped = c.seed % 2 === 1
49  const ax = flipped ? 52 : 15, bx = flipped ? 15 : 52
50  const W = '#fff'
51  const box = (x: number, y: number) => `<rect x="${x + 0.5}" y="${y + 0.5}" width="13" height="8" fill="none" stroke="${W}" stroke-width="1.2"/>`
52  const arrowR = flipped
53    ? `<path d="M51,-77.5H30" stroke="${W}" stroke-width="1.2"/><polygon points="29,-77.5 33,-80 33,-75" fill="${W}"/>`
54    : `<path d="M29,-77.5H50" stroke="${W}" stroke-width="1.2"/><polygon points="52,-77.5 48,-80 48,-75" fill="${W}"/>`
55  let d = r(11, -85, 60, 38, blue) +
56    `<path d="${grid}" stroke="${W}" stroke-width=".5" opacity=".28"/>`
57  if (!diagram) return d
58  d += box(ax, -82) + box(bx, -82) + arrowR +
59    `<path d="M29,-80.5H50M29,-74.5H50" stroke="${W}" stroke-width=".8" opacity=".6"/>`
60  if (full) {
61    d += `<path d="M58.5,-73V-66" stroke="${W}" stroke-width="1.2"/><polygon points="58.5,-64 56,-68 61,-68" fill="${W}"/>` +
62      box(52, -63) +
63      `<circle cx="27" cy="-58" r="6" fill="none" stroke="${W}" stroke-width="1.2"/>` +
64      r(14, -70, 9, 1, W, ' opacity=".7"') + r(14, -67, 6, 1, W, ' opacity=".7"')
65  }
66  return d
67}
68
69/** T-square lying along the bottom of the board. */
70function tsquare(c: Ctx): string {
71  return r(13, -51, 54, 2, c.p.paper) + r(67, -55, 4, 12, c.p.paper) + r(67, -55, 4, 1, c.p.metalLight)
72}
73
74/** Glasses + pencil behind the ear + optional patch, in the sprite's local frame. */
75function face(c: Ctx, cracked: boolean, pencil = true, slip = false): string {
76  const { p } = c
77  const lens = (cx: number, cy = -27) =>
78    `<circle cx="${cx}" cy="${cy}" r="7.5" fill="#fff" fill-opacity=".1" stroke="${p.ink}" stroke-width="1.6"/>`
79  let s = slip
80    // slid down the nose, tilted, right lens hanging off by the bridge
81    ? `<g transform="translate(0,5) rotate(6 0 -27)">` + lens(-11.5) + r(-24, -29, 5, 1.4, p.ink) + r(-4, -28, 6, 1.6, p.ink) +
82      `<g transform="rotate(55 2 -27)">${lens(11.5)}</g></g>`
83    : lens(-11.5) + lens(11.5) + r(-4, -28, 8, 1.6, p.ink) +
84      r(-24, -29, 5, 1.4, p.ink) + r(19, -29, 5, 1.4, p.ink)
85  if (cracked) s += `<path d="M7,-33l4,5l-2,3l4,4" stroke="#fff" stroke-width="1" fill="none"/>`
86  if (pencil) {
87    s += r(18, -43, 6, 3, c.shade) + `<g transform="rotate(-35 22 -42)">` + r(14, -44, 16, 4, p.accent) + r(14, -44, 16, 1, p.metalLight) +
88      r(30, -44, 3, 4, p.danger) + `<polygon points="14,-44 10,-42 14,-40" fill="${p.paper}"/>` +
89      r(10, -42.5, 1.5, 1, p.ink) + `</g>`
90  }
91  if (c.seed % 3 === 0) {
92    s += r(-20, -16, 7, 6, c.shade) + `<path d="M-19.5,-15.5h6M-19.5,-10.5h6" stroke="${p.paper}" stroke-width=".6" stroke-dasharray="1 1"/>`
93  }
94  return s
95}
96
97const sweat = (c: Ctx, x: number, y: number, dur: number, dx = -1) =>
98  `<g opacity="0"><polygon points="${x},${y} ${x - 2},${y + 4} ${x + 2},${y + 4}" fill="${c.drop}"/>${r(x - 2, y + 4, 4, 2, c.drop)}` +
99  `<animate attributeName="opacity" values="0;1;1;0" dur="${dur}s" begin="-${(c.ph * dur).toFixed(2)}s" ${R}/>` +
100  `<animateTransform attributeName="transform" type="translate" values="0,0;${dx * 4},3;${dx * 10},14" dur="${dur}s" begin="-${(c.ph * dur).toFixed(2)}s" ${R}/></g>`
101
102function person(c: Ctx, eyes: ClawdEyes, opts: { y?: number; bob?: number; over?: string; cracked?: boolean; pencil?: boolean; slip?: boolean }): string {
103  return clawd({
104    x: CX, y: opts.y ?? 0, scale: S, body: c.body, shade: c.shade, eyes, ink: c.p.ink, bob: opts.bob,
105    over: face(c, !!opts.cracked, opts.pencil ?? true, !!opts.slip) + (opts.over ?? ''),
106  })
107}
108
109/** A pair of compasses, pin at local (0,0), hinge up top; legs in ink, bright hinge. */
110function compassSvg(c: Ctx): string {
111  const { p } = c
112  return `<path d="M0,0L6,-14L12,0" stroke="${p.ink}" stroke-width="2" fill="none"/>` +
113    r(4.5, -18, 3, 4, p.accent) + r(5, -15.5, 2, 2, '#fff') + r(11, -1, 2, 2, p.danger)
114}
115
116const PX = 30, PY = -55 // compass pin on the blueprint
117const SH = { x: CX + 38, y: -38 } // shoulder (lower outer corner of the right nub) on the stool
118
119function stool(c: Ctx): string {
120  const { p } = c
121  return r(CX - 20, -14, 40, 4, p.accentAlt) + r(CX - 20, -14, 40, 1, p.metalLight) +
122    `<polygon points="${CX - 16},-10 ${CX - 12},-10 ${CX - 18},0 ${CX - 22},0" fill="${p.metalLight}"/>` +
123    `<polygon points="${CX + 12},-10 ${CX + 16},-10 ${CX + 22},0 ${CX + 18},0" fill="${p.metalLight}"/>` +
124    r(CX - 17, -5, 34, 2, p.metalLight)
125}
126
127function clawdLocal(c: Ctx, eyes: ClawdEyes, y: number, bob?: number): string {
128  return clawd({
129    x: CX, y, scale: S, body: c.body, shade: c.shade, eyes, ink: c.p.ink, bob,
130    over: face(c, false, false),
131  })
132}
133
134function working(c: Ctx, eyes: ClawdEyes): string {
135  const { p } = c
136  const d = 1.6
137  const b = `begin="-${(c.ph * d).toFixed(2)}s"`
138  // sweep -50deg -> +15deg -> back; hand follows the handle exactly
139  const angs = [-50, -34, -18, -2, 15, -2, -18, -34, -50]
140  const kt = angs.map((_, i) => (i / (angs.length - 1)).toFixed(3)).join(';')
141  const hx = 6, hy = -16
142  const pts = angs.map(a => {
143    const t = a * Math.PI / 180
144    return [PX + hx * Math.cos(t) - hy * Math.sin(t), PY + hx * Math.sin(t) + hy * Math.cos(t)]
145  })
146  const vals = (k: number, off = 0) => pts.map(q => (q[k]! + off).toFixed(1)).join(';')
147  const anim = (attr: string, v: string) => `<animate attributeName="${attr}" values="${v}" keyTimes="${kt}" dur="${d}s" ${b} ${R}/>`
148  const compass = `<g transform="translate(${PX},${PY})"><g>` +
149    `<animateTransform attributeName="transform" type="rotate" values="${angs.join(';')}" keyTimes="${kt}" dur="${d}s" ${b} ${R}/>` +
150    compassSvg(c) + `</g></g>`
151  // the arc being scribed (radius 12 around the pin)
152  const a0 = -50 * Math.PI / 180, a1 = 15 * Math.PI / 180
153  const arcD = `M${(PX + 12 * Math.cos(a0)).toFixed(1)},${(PY + 12 * Math.sin(a0)).toFixed(1)}A12,12 0 0,1 ${(PX + 12 * Math.cos(a1)).toFixed(1)},${(PY + 12 * Math.sin(a1)).toFixed(1)}`
154  const arc = `<path d="${arcD}" fill="none" stroke="#fff" stroke-width="1.4" stroke-dasharray="14 14" stroke-dashoffset="14">` +
155    `<animate attributeName="stroke-dashoffset" values="14;0;0;14" keyTimes="0;.5;.85;1" dur="${d}s" ${b} ${R}/></path>`
156  const arm = `<line x1="${SH.x}" y1="${SH.y}" x2="${pts[0]![0]!.toFixed(1)}" y2="${pts[0]![1]!.toFixed(1)}" stroke="${c.body}" stroke-width="5" stroke-linecap="square">` +
157    anim('x2', vals(0)) + anim('y2', vals(1)) + `</line>` +
158    `<rect x="${(pts[0]![0]! - 3).toFixed(1)}" y="${(pts[0]![1]! - 2).toFixed(1)}" width="6" height="6" fill="${c.body}">` +
159    anim('x', vals(0, -3)) + anim('y', vals(1, -2)) + `</rect>`
160  const sw = sweat(c, CX - 40, -58, 1.1, -1) + sweat(c, CX - 44, -46, 1.4, -1)
161  // the plan grows: a ring is drawn and stays, then a dotted line joins it, then it all resets
162  const L = 6.4
163  const lb = `begin="-${(c.ph * L).toFixed(2)}s"`
164  const ring = `<circle cx="20" cy="-62" r="5" fill="none" stroke="#fff" stroke-width="1.3" stroke-dasharray="32 32" stroke-dashoffset="32">` +
165    `<animate attributeName="stroke-dashoffset" values="32;0;0;32" keyTimes="0;.35;.92;1" dur="${L}s" ${lb} ${R}/></circle>`
166  const dots = `<path d="M20,-67V-73" stroke="#fff" stroke-width="1.2" stroke-dasharray="2 2" opacity="0">` +
167    `<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;.45;.55;.92;1" dur="${L}s" ${lb} ${R}/></path>`
168  // eraser shavings dribbling off the board edge
169  const shav = [0, 1, 2].map(i => {
170    const bg = `begin="-${((i / 3 + c.ph) * 1.5).toFixed(2)}s"`
171    return `<g opacity="0">${r(62 + i * 3, -46, 2, 1, p.paper)}` +
172      `<animate attributeName="opacity" values="1;1;0" dur="1.5s" ${bg} ${R}/>` +
173      `<animateTransform attributeName="transform" type="translate" values="0,0;${i - 1},40" dur="1.5s" ${bg} ${R}/></g>`
174  }).join('')
175  return ring + dots + arc + shav + stool(c) + clawdLocal(c, eyes, -14, 0.8) + sw + arm + compass
176}
177
178function waiting(c: Ctx, eyes: ClawdEyes): string {
179  const { p } = c
180  const d = 0.7
181  const watch = `<g transform="translate(${CX - 40},-32)">` +
182    `<path d="M8,-2L4,-10" stroke="${p.accent}" stroke-width="1" fill="none"/>` +
183    `<circle cx="0" cy="0" r="7" fill="${p.accent}"/><circle cx="0" cy="0" r="5.5" fill="${p.paper}"/>` + r(-1, -9, 2, 2, p.accent) +
184    `<g><rect x="-.5" y="-4.5" width="1" height="5" fill="${p.ink}"/><animateTransform attributeName="transform" type="rotate" values="0;360" dur="2s" ${R}/></g>` +
185    r(-1, -1, 3, 1, p.ink) + `</g>` +
186    r(CX - 34, -31, 6, 5, c.body)
187  // pupils glance left at the watch
188  const glance = eyes === 'normal'
189    ? r(-14, -32, 5, 10, c.body) + r(9, -32, 5, 10, c.body) + r(-17, -31, 5, 9, p.ink) + r(6, -31, 5, 9, p.ink)
190    : ''
191  const tilt = `<animateTransform attributeName="transform" type="rotate" values="0 ${CX - 20} 0;-3 ${CX - 20} 0;0 ${CX - 20} 0" dur="${d}s" begin="-${(c.ph * d).toFixed(2)}s" ${R}/>`
192  const q = `<g><text x="${CX + 4}" y="-56" font-family="monospace" font-weight="bold" font-size="22" fill="${p.paper}" stroke="${p.ink}" stroke-width="1">?</text>` +
193    `<animateTransform attributeName="transform" type="translate" values="0,0;0,-4;0,0" dur="1.2s" ${R}/></g>`
194  // right arm reaches up to the ledge and drums its fingers, impatiently
195  const bd = `dur="${(d / 2).toFixed(2)}s" begin="-${(c.ph * d).toFixed(2)}s" ${R}`
196  const drum = `<line x1="${CX + 32}" y1="-26" x2="${CX + 40}" y2="-44" stroke="${c.body}" stroke-width="5"/>` +
197    `<g>${r(CX + 36, -50, 9, 5, c.body)}${r(CX + 37, -46, 2, 2, c.shade)}${r(CX + 41, -46, 2, 2, c.shade)}` +
198    `<animateTransform attributeName="transform" type="translate" values="0,0;0,-3;0,0" ${bd}/></g>` +
199    `<g opacity="0">${r(CX + 47, -53, 3, 1, p.paper)}${r(CX + 47, -48, 3, 1, p.paper)}` +
200    `<animate attributeName="opacity" values="0;1;0" ${bd}/></g>` +
201    `<path d="M24,-56L34,-60L44,-56" stroke="${p.ink}" stroke-width="2" fill="none"/>` + r(32.5, -62, 3, 3, p.accent)
202  return `<g>${tilt}${person(c, eyes, { over: glance, pencil: false })}${watch}</g>` + drum + q
203}
204
205function idle(c: Ctx, eyes: ClawdEyes): string {
206  const { p } = c
207  // squash + skew about the floor line: feet stay planted, head droops left
208  const slump = `<g transform="translate(${CX},0) skewX(9) scale(1,.88) translate(${-CX},0)">` +
209    person(c, eyes, { pencil: false, slip: true }) + `</g>`
210  const z = (i: number) => {
211    const dur = 3, bg = `begin="-${((i / 3 + c.ph) * dur).toFixed(2)}s"`
212    return `<text x="${CX - 6}" y="-44" font-family="monospace" font-weight="bold" font-size="${8 + i * 3}" fill="${p.paper}" opacity="0">z` +
213      `<animate attributeName="opacity" values="0;1;0" dur="${dur}s" ${bg} ${R}/>` +
214      `<animateTransform attributeName="transform" type="translate" values="0,0;10,-36" dur="${dur}s" ${bg} ${R}/></text>`
215  }
216  // pencil dropped on the floor, compass flopped on the ledge
217  const dropped = r(CX - 46, -3, 16, 3, p.accent) + r(CX - 30, -3, 3, 3, p.danger) +
218    `<path d="M24,-46L36,-50L48,-46" stroke="${p.ink}" stroke-width="2" fill="none"/>` + r(34.5, -52, 3, 3, p.accent)
219  return dropped + slump + z(0) + z(1) + z(2)
220}
221
222function done(c: Ctx, eyes: ClawdEyes): string {
223  const { p } = c
224  const crate = r(CX - 26, -15, 52, 15, p.accentAlt) + r(CX - 26, -15, 52, 2, p.metalLight) +
225    `<path d="M${CX - 24},-13L${CX + 24},-2M${CX + 24},-13L${CX - 24},-2" stroke="${p.metalDark}" stroke-width="1.5"/>` +
226    r(CX - 26, -15, 52, 15, 'none', ` stroke="${p.metalDark}" stroke-width="1"`)
227  // cup of tea raised in the left hand, with a periodic sip
228  const sip = `<animateTransform attributeName="transform" type="translate" values="0,0;0,0;5,-8;5,-8;0,0" keyTimes="0;.55;.65;.85;1" dur="4s" begin="-${(c.ph * 4).toFixed(2)}s" ${R}/>`
229  const cup = `<g transform="translate(${CX - 42},-44)"><g>${sip}` +
230    r(-7, 0, 14, 11, p.paper) + r(-7, 0, 14, 11, 'none', ` stroke="${p.ink}" stroke-width="1"`) + r(-6, 1, 12, 2, p.accentAlt) +
231    `<path d="M-7,2.5h-4v5h4" stroke="${p.paper}" stroke-width="2.2" fill="none"/>` +
232    r(-10, 11, 20, 2, p.paper) + r(4, 4, 7, 6, c.body) +
233    `<path d="M-3,-2q-3,-4 0,-8q3,-4 0,-8M3,-2q-3,-4 0,-8q3,-4 0,-8" stroke="${p.paper}" stroke-width="1.6" fill="none">` +
234    `<animate attributeName="opacity" values=".35;1;.35" dur="2.5s" ${R}/></path>` +
235    `</g></g>`
236  const roll = r(CX + 26, -36, 22, 6, c.blue) + r(CX + 26, -36, 22, 1, '#fff', ' opacity=".6"') +
237    `<circle cx="${CX + 48}" cy="-33" r="3" fill="${c.blueDk}" stroke="#fff" stroke-width=".8"/>`
238  const gold = `<polygon points="62,-82 63.5,-78 67.5,-78 64.5,-75.5 65.5,-71.5 62,-74 58.5,-71.5 59.5,-75.5 56.5,-78 60.5,-78" fill="${p.accent}" stroke="${p.ink}" stroke-width=".6"/>`
239  const stamp = `<g transform="rotate(-8 44 -61)">` + r(23, -68, 42, 13, p.paper) +
240    `<rect x="25" y="-66" width="38" height="9" fill="none" stroke="${p.danger}" stroke-width="1.5"/>` +
241    `<text x="44" y="-58.8" text-anchor="middle" font-family="monospace" font-weight="bold" font-size="8" fill="${p.danger}">APPROVED</text></g>`
242  const ahh = `<text x="${CX - 6}" y="-66" font-family="monospace" font-size="9" font-style="italic" fill="${p.paper}">ahh~</text>`
243  const compass = `<path d="M54,-46L60,-58L66,-46" stroke="${p.ink}" stroke-width="2" fill="none"/>` + r(58.5, -61, 3, 3, p.accent)
244  return stamp + gold + compass + crate + person(c, eyes, { y: -13 }) + roll + cup + ahh
245}
246
247function failed(c: Ctx, eyes: ClawdEyes): string {
248  const { p } = c
249  const W = '#fff'
250  // the PLAN itself fails: one box left, its arrow U-turns back into itself,
251  // the other box tumbles off the sheet, and someone scrawled TODO: ???
252  const boxA = `<rect x="15.5" y="-81.5" width="13" height="8" fill="none" stroke="${W}" stroke-width="1.2"/>`
253  const loop = `<path d="M29,-77.5H38V-69H22V-73" stroke="${W}" stroke-width="1.2" fill="none"/><polygon points="22,-73 19.5,-69 24.5,-69" fill="${W}" transform="rotate(180 22 -71)"/>`
254  const tumble = `<g><g transform="translate(64,-62)"><rect x="-6.5" y="-4" width="13" height="8" fill="none" stroke="${W}" stroke-width="1.2"/>` +
255    `<animateTransform attributeName="transform" type="rotate" values="0;200;360" dur="2s" ${R}/></g>` +
256    `<animateTransform attributeName="transform" type="translate" values="0,0;8,30;14,72;14,72" keyTimes="0;.4;.7;1" dur="2.6s" ${R}/>` +
257    `<animate attributeName="opacity" values="1;1;1;0" keyTimes="0;.6;.7;1" dur="2.6s" ${R}/></g>`
258  const todo = `<text x="14" y="-52" font-family="monospace" font-weight="bold" font-size="8" fill="${W}" transform="rotate(-5 15 -53)">TODO: ???</text>`
259  const blot = `<polygon points="44,-82 52,-84 60,-78 58,-70 50,-70 44,-75" fill="${p.ink}"/>` + r(52, -70, 3, 6, p.ink) +
260    `<polygon points="71,-85 71,-70 60,-85" fill="${p.metal}"/><polygon points="60,-85 71,-70 64,-72" fill="${p.paper}" opacity=".7"/>`
261  const puff = (i: number) => {
262    const dur = 2.4, bg = `begin="-${((i / 3 + c.ph) * dur).toFixed(2)}s"`
263    return `<circle cx="${44 + i * 6}" cy="-84" r="5" fill="${p.smoke}" stroke="${p.paper}" stroke-width="1" opacity="0">` +
264      `<animate attributeName="opacity" values="0;.95;0" dur="${dur}s" ${bg} ${R}/>` +
265      `<animate attributeName="r" values="3;9" dur="${dur}s" ${bg} ${R}/>` +
266      `<animateTransform attributeName="transform" type="translate" values="0,0;${i * 3 - 3},-16" dur="${dur}s" ${bg} ${R}/></circle>`
267  }
268  const spark = (x: number, y: number, k: number) =>
269    `<g opacity="0">${r(x - 1, y - 4, 2, 8, p.accent)}${r(x - 4, y - 1, 8, 2, p.accent)}${r(x - 1, y - 1, 2, 2, '#fff')}` +
270    `<animate attributeName="opacity" values="0;1;0;0" dur=".9s" begin="-${(k * 0.3).toFixed(1)}s" ${R}/></g>`
271  // broken compass on the table ledge: snapped legs (light outline) + boinging spring
272  const leg = (d: string) => `<path d="${d}" stroke="${p.metalLight}" stroke-width="4"/><path d="${d}" stroke="${p.ink}" stroke-width="2"/>`
273  const broken = leg('M10,-47L22,-51') + leg('M27,-48L40,-47') + r(21, -54, 4, 4, p.accent) +
274    `<g><path d="M23,-54l3,-3l-4,-2l4,-2l-4,-2l3,-3" stroke="${p.paper}" stroke-width="1.4" fill="none"/>` +
275    `<animateTransform attributeName="transform" type="translate" values="0,0;0,-4;0,0" dur=".5s" ${R}/></g>`
276  const star = (k: number) => `<g>${r(-1, -3, 2, 6, p.accent)}${r(-3, -1, 6, 2, p.accent)}` +
277    `<animateMotion path="M16,0A16,5 0 1,1 -16,0A16,5 0 1,1 16,0" dur="1.6s" begin="-${(k * 1.6 / 3).toFixed(2)}s" ${R}/></g>`
278  const stars = `<g transform="translate(${CX - 6},-52)">${star(0)}${star(1)}${star(2)}</g>`
279  const dazed = `<g transform="rotate(-7 ${CX} 0)">${person(c, eyes, { cracked: true, pencil: false })}</g>`
280  const plan = `<g transform="rotate(4 41 -44)">${boxA}${loop}${todo}${tumble}</g>`
281  return plan + blot + puff(0) + puff(1) + puff(2) + spark(14, -84, 0) + spark(70, -50, 1) + spark(56, -88, 2) + broken + dazed + stars
282}
283
284/** Whip landing: OW!, welt, flying sweat; wraps the character group in a flinch. */
285function flinch(c: Ctx, inner: string, wy = -49): string {
286  const { p } = c
287  const welt = r(CX - 6, wy, 12, 5, p.danger) + r(CX - 4, wy + 1, 8, 1, '#fff', ' opacity=".5"')
288  const drops = [[-1, -1], [1, -1.4], [-1.6, -0.3], [1.5, -0.2]].map(([dx = 0, dy = 0], i) => {
289    const ox = CX + (dx < 0 ? -36 : 30), oy = -58
290    return `<g opacity="0"><polygon points="${ox},${oy} ${ox - 2},${oy + 4} ${ox + 2},${oy + 4}" fill="${c.drop}"/>${r(ox - 2, oy + 4, 4, 2, c.drop)}` +
291    `<animate attributeName="opacity" values="0;0;1;0" keyTimes="0;0.6;0.62;1" ${WHIP_SYNC}/>` +
292    `<animateTransform attributeName="transform" type="translate" values="0,0;0,0;${dx * 14},${dy * 16}" keyTimes="0;0.6;1" ${WHIP_SYNC}/></g>`
293  }).join('')
294  return whipFlinch(inner + welt) + drops + owText(CX + 24, -94, p)
295}
296
297/** The plan worker and its workstation, feet-centre at (w.x, w.y). */
298export function planWorker(w: WorkerView, p: Palette): string {
299  const nudge = (w.seed % 13) - 6
300  const c: Ctx = {
301    p,
302    body: tint(p.clawd, 14 + nudge),
303    shade: tint(p.clawdDark, 14 + nudge),
304    blue: tint(p.clawd, 190, -0.12),
305    blueDk: tint(p.clawdDark, 190, -0.05),
306    drop: tint(p.clawd, 185, 0.22),
307    ph: (w.seed % 10) / 10,
308    seed: w.seed,
309  }
310  const st = w.status
311  const wide = w.isTarget && st !== 'failed'
312  const eyes: ClawdEyes = wide ? 'wide'
313    : st === 'idle' ? 'closed' : st === 'done' ? 'happy' : st === 'failed' ? 'dead' : 'normal'
314  const station = table(c) +
315    (st === 'failed' ? `<g transform="rotate(4 41 -44)">${blueprint(c, false, false)}</g>` : blueprint(c, false)) +
316    (st === 'failed' ? '' : tsquare(c)) + bin(c)
317  const who = st === 'working' ? working(c, eyes)
318    : st === 'waiting' ? waiting(c, eyes)
319    : st === 'idle' ? idle(c, eyes)
320    : st === 'done' ? done(c, eyes)
321    : failed(c, eyes)
322  return `<g transform="translate(${w.x},${w.y})" shape-rendering="crispEdges">` +
323    station + (w.isTarget ? flinch(c, who, st === 'working' ? -61 : st === 'done' ? -56 : -43) : who) + `</g>`
324}
325