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

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.

| Subagent type | Worker | Station |
|---|---|---|
Explore | Miner: hard hat, headlamp, dead canary | swings a pickaxe at a coal face of { } ; < > ore |
Plan | Draftsman: round specs, pencil behind ear | drafting table with a blueprint |
general-purpose | Line smith: flat cap, apron | hammers widgets on an anvil |
| reviewers / verifiers / testers | Quality inspector: bowler hat, monocle | stamps APPROVED / REJECTED |
| guides / docs | Archivist: green visor, quill | lectern with a giant RTFM ledger |
| anything else | Boiler stoker: goggles, soot | shovels 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:
| Victorian mill | Silent film | Neon 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.
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.
▐▛███▜▌ Clawd.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.$.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.
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.
hooks/register.tsx 280 lines1import { 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}
280hooks/art/compose.ts 180 lines1import 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}
180hooks/ascii.ts 431 lines1import 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}
431hooks/shouts.ts 837 lines1import 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}
837hooks/art/types.ts 97 lines1// 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
97hooks/art/palette.ts 30 lines1import 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}
30hooks/art/scene.ts 473 lines1import 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}
473hooks/art/foreman.ts 313 lines1import 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)">#@%&!` +
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}
313hooks/art/roles/index.ts 17 lines1import 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}
17hooks/art/clawd.ts 140 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
140hooks/art/roles/explore.ts 243 lines1import 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}
243hooks/art/roles/plan.ts 325 lines1import 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