Mission Control for Claude Code: a HUD band above the prompt, a live agents pane (/missioncontrol), skins, pixel-art casts and Night City easter eggs.

<img src="docs/hero.png" alt="Mission Control, a mod for Claude Code. Left: Crab Classic crabs. Right: the Cyberpunk Skins cast in Night City." width="100%">
<h3 align="center">See everything Claude is doing. Live.</h3>
A live HUD and dashboard for <a href="https://claude.com/claude-code">Claude Code</a> (desktop app and CLI):<br> who's working, on what, how far along, and what it costs.
<a href="#-choose-your-edition">Download</a> · <a href="#-what-mission-control-does">Features</a> · <a href="#-what-the-cyberpunk-skins-add">Cyberpunk Skins</a> · <a href="#-skin-showcase">Skins</a> · <a href="#-install">Install</a> · <a href="#-commands">Commands</a>
<img alt="MIT license" src="https://img.shields.io/badge/license-MIT-2B2320?style=flat-square"> <img alt="Platforms" src="https://img.shields.io/badge/Windows%20%C2%B7%20macOS%20%C2%B7%20Linux-E76F51?style=flat-square&labelColor=2B2320&label=runs%20on"> <img alt="Skins" src="https://img.shields.io/badge/skins-5%20%2B%201%20hidden-FCEE0A?style=flat-square&labelColor=0A0A0F">
<table align="center"> <tr> <td align="center" width="50%" valign="top"> <a href="https://github.com/Chinchuan24/MIssonControl-Skins/releases/latest/download/mission-control-core.zip"> <img src="docs/edition-core.png" alt="Mission Control Core: default and Crab Classic skins" width="100%"> </a> </td> <td align="center" width="50%" valign="top"> <a href="https://github.com/Chinchuan24/MIssonControl-Skins/releases/latest/download/mission-control-cyberpunk.zip"> <img src="docs/edition-cyberpunk.png" alt="Cyberpunk Skins: Cyberpunk 2077, Lucy Edgerunners, Arasaka and a hidden skin" width="100%"> </a> </td> </tr> <tr> <td align="center" valign="top"> <b>The live dashboard.</b><br>Clean, light, free. </td> <td align="center" valign="top"> <b>Jack in, choom.</b><br>Your crew just moved to Night City. </td> </tr> <tr> <td align="center" valign="top"> <a href="https://github.com/Chinchuan24/MIssonControl-Skins/releases/latest/download/mission-control-core.zip"> <img src="docs/btn-core.png" alt="Download Core" width="300"> </a> </td> <td align="center" valign="top"> <a href="https://github.com/Chinchuan24/MIssonControl-Skins/releases/latest/download/mission-control-cyberpunk.zip"> <img src="docs/btn-cyberpunk.png" alt="Download Core + Cyberpunk" width="300"> </a><br> <sub>Have Core already? <a href="https://github.com/Chinchuan24/MIssonControl-Skins/releases/latest/download/cyberpunk-skins-addon.zip">Get the add-on only</a>.</sub> </td> </tr> </table>
Unofficial, non-commercial fan project. Not affiliated with, endorsed by or sponsored by CD PROJEKT S.A., CD PROJEKT RED, Studio Trigger or Netflix. Cyberpunk 2077, Cyberpunk: Edgerunners and all related names, characters and terms belong to their respective owners and are used as fan homage only. The MIT license covers this project's code, not those properties. Removal requests: open an issue.
Claude works in steps, calls tools and spins up sub-agents. Mission Control shows it all at a glance.

/tokens warm on)default (the app's own look) and Crab Classic (a crab in costume per role), or make your own| Section | What's in it |
|---|---|
| Usage | Your limits, plus tokens per model for this session and all time |
| Today | Tasks, agents, tokens and cost |
| Workflow | The plan's steps, live |
| Manager | The boss card: model, effort, sub-agents, cost and time |
| Token Manager | Burn rate, cost per task, cache hits, budget warnings, optional Haiku autopilot and Smart Keep-Warm |
| Crew | Every agent by role in one long list, however big the crew, plus a hierarchy from the manager down. Collapse folds the whole list into a short view; ▾ Finished folds finished agents and steps into one line each. Both are remembered. |
| Memory | Answer rules Claude follows in every session (/prefs) |
| Settings | ☑ open on start · ☑ crew mode · currency · ☑ sounds · change skin |
Claude's prompt cache keeps your conversation for an hour after each answer. Reading it costs about a tenth of the normal price; once it expires, your next prompt pays to write the whole conversation again (about 2× the normal price). Keep-Warm sends one tiny side request a few minutes before the hour runs out, so the cache stays warm.
/compact is coming, which rewrites the cache anyway./tokens totals them.Off by default: turn it on with /tokens warm on. On a Pro or Max plan there's no per-token bill, so each refresh uses a little quota instead; the gain is mostly speed.
The add-on swaps the crabs for a pixel-art cast, moves the HUD to Night City and adds a progression system.

| Feature | Core | Cyberpunk |
|---|---|---|
| HUD band, pane, crew, Token Manager, crew mode, done chime, rich replies | ✓ | ✓ |
default and Crab Classic skins, custom JSON skins | ✓ | ✓ |
| Cyberpunk 2077, Lucy · Edgerunners, Arasaka skins + a hidden one 🎸 | — | ✓ |
| Pixel-art cast: David, Lucy, Rebecca, Maine, Kiwi, Johnny… Saburo and Adam Smasher on Arasaka | — | ✓ |
| Street Cred: XP per job, ranks NOBODY → LEGEND OF THE AFTERLIFE, 9 badges | — | ✓ |
| Braindance: replay the session, every tool call a mark | — | ✓ |
| Breach Protocol card, NCPD heat stars, CYBERPSYCHOSIS / MAXTAC warnings | — | ✓ |
| Night City skyline, Relic glitch, Sandevistan afterimages, the Afterlife | — | ✓ |
| Per-skin sound packs, eddies (€$), radio, street slang | — | ✓ |
Pick one with /skins (a live picker) or /skin <id>. Each skin restyles the band, the pane, the chat's tool rows and the spinner.
| Skin | id | One line |
|---|---|---|
| default | default | Keeps the app's own look. Just the data. |
| 🦀 Crab Classic | crab-classic | The original. A working crab in costume for every role. |
| 🟡 Cyberpunk 2077 | cyberpunk-2077 | Neon yellow, cyan and hot red. David leads the crew. |
| 🌙 Lucy · Edgerunners | lucy-edgerunners | Rose-pink and cyan under the moon. Lucy runs the net. |
| 🔴 Arasaka | arasaka | Corporate red and black. Saburo at the top, Smasher on call. |
| 🎸 ??? | — | Not in the picker. Wake up… |

Night City HUD: neon yellow, cyan and hot red, cut corners and scanlines. David leads. Lucy, Rebecca, Maine, Dorio, Kiwi, Pilar, Falco and Johnny take the roles; chooms and NPCs do the work.

Moonlit netrunner: rose-pink and cyan on deep violet, with the moon in the skyline. Lucy leads, and David rides with the crew.

Corporate red and black. Saburo at the top. Adam Smasher, handlers, agents, netrunners, guards, a cyborg and a captive Johnny under him. Troopers, salarymen and drones on the floor. The tower stands in the skyline.

Skins are small .json files; see skins/README.md for every field. They load from, in order:
Custom Claude Skins/ folder in your working folderskins/ examplesCopy an example, give it a new id, edit it, then run /skin reload and /skin <id>.
1. Get it (one of):
mission-control/ (the one with .claude-plugin/ inside): that is the folder to point Claude Code at. Windows' "Extract All" may wrap it in a second folder; use the inner one. Or git clone https://github.com/Chinchuan24/MIssonControl-Skins.git ` A clone has no mission-control/ folder: point Claude Code at the cloned MIssonControl-Skins/ folder itself (it holds .claude-plugin/`).2. Point Claude Code at the folder in ~/.claude/settings.json (Windows: C:\Users\<you>\.claude\settings.json):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/mission-control",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
"C:/Users/<you>/mission-control" or "C:\\Users\\<you>\\mission-control". A single \ breaks the JSON.; on Windows, : on macOS / Linux.~/.claude/settings.json, : separators and /mnt/c/... paths.\\wsl.localhost paths can hide the skins and the add-on.CLAUDE_CODE_PLUGIN_DIR_WATCH=1 reloads the mod when its files change.3. Restart Claude Code, then run /missioncontrol and /skins. On a Mac, quit the desktop app with ⌘Q and open it again.
Unzip cyberpunk-skins-addon.zip into your Mission Control folder. It creates addons/cyberpunk/. Restart Claude Code, then pick a skin with /skins.
On a Mac, double-clicking the zip unzips it into a folder of its own, and dragging addons/ onto your install in Finder replaces the folder instead of merging it. Unzip it from Terminal instead:
unzip -o ~/Downloads/cyberpunk-skins-addon.zip -d /path/to/mission-control
On Windows, "Extract All" also makes a folder of its own. In PowerShell instead:
Expand-Archive -Force "$env:USERPROFILE\Downloads\cyberpunk-skins-addon.zip" -DestinationPath "C:\path\to\mission-control"
| Command | What it does | |||
|---|---|---|---|---|
/missioncontrol | Open Mission Control (Settings at the bottom) | |||
/missioncontrol currency <code> [rate] | Costs in USD (the default), AUTO (eddies on the cyberpunk skins), EDDIES, EUR, GBP, JPY, MYR or SGD; optional rate per US dollar | |||
/skins | The skin picker, every skin as a live preview | |||
/skin | /skin to list, /skin <id> to switch, /skin off, /skin reload | |||
/autocrew | Crew mode: state, on, off, or <context %> to call the crew | |||
/tokens | Token Manager report, `budget <dollars\ | off>, autopilot <on\ | off>, warm <on\ | off>` (Smart Keep-Warm) |
/prefs | How Claude should answer you, in every session: add <rule>, remove <n>, clear |
hooks/register.tsx: hooks, UI and commandshooks/cast.ts: the pixel castshooks/eggs.ts: the easter eggshooks/richtext.ts: diffs, tables, math and mermaid in replieshooks/warm.ts: Smart Keep-Warm's arithmetic (when a refresh pays)claude plugin test /path/to/MIssonControl-Skins (and claude plugin validate …).claude-plugin/types/ is generated on each machine by the CLI and is git-ignored.assets/done.wav) was synthesised for this project.hooks/register.tsx 6335 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { CASTS, CAST_CSS, hashOf, spriteMarkup } from './cast'
5import { BREACH_H, BREACH_W, CJK_FONT, EGG_CSS, EGG_TOOLS, EGG_WORDS, GREETING, KATAKANA, breachCard, breachTicker, celebration, heatStars, legendsCard, legendsHeight, psychoChip, radio, relicText, sandevistan, skyline, sysTag } from './eggs'
6import type { Egg, Legend } from './eggs'
7import { BEATS_KEPT, BLANK_CRED, BOOT_H, award, rockerRankOf, bdHeight, bootStrip, braindanceCard, cleanCred, credCard, credHeight, levelOf, rankOf, shiftPick, stepPick } from './extras'
8import type { Beat, Cred } from './extras'
9import { hasInlineMath, inlineMath, mermaidChart, splitBlocks, texToUnicode, unifiedDiff } from './richtext'
10import { LIVE_N, glide, isNewReading, isSame, liveText, liveWidth, pin, steady, steadyTree, wallMinute } from './smooth'
11import { WARM_PROMPT, WARM_TAG, isWarmDue, isWarmed, warmCall, warmLimitHit } from './warm'
12import type { AgentRun, AgentStatus, AllTime, Background, BgCron, BgTask, Env, Flow, Manager, Panel, Phase, PlanStep, PlannedTask, SessionStats, Today, Watch } from '../types'
13import type { On } from 'claude-code'
14import type { Timekeeper } from '../types'
15
16// Note: `update` writes, and redraws every reader, even when its change hands back
17// the same value: guard no-op writes with a read first.
18const flow = atom({ plugin: 'crab-progress', key: 'flow' } as const, null)
19const agents = atom({ plugin: 'crab-progress', key: 'agents' } as const, [])
20const panel = atom({ plugin: 'crab-progress', key: 'panel' } as const, {
21 isCompact: false,
22 isDoneCollapsed: false,
23 autoOpenedFor: '',
24})
25const now = atom({ plugin: 'crab-progress', key: 'now' } as const, 0)
26// The desktop's ticks: written only when what the band, or the pane, draws from the
27// time has changed (a minute, a threshold, a label), never just because a second passed.
28const bandTick = atom({ plugin: 'crab-progress', key: 'bandTick' } as const, 0)
29const paneTick = atom({ plugin: 'crab-progress', key: 'paneTick' } as const, 0)
30const memory = atom({ plugin: 'crab-progress', key: 'memory' } as const, [])
31const BLANK_ENV: Env = { cwd: '', model: '', surface: '', contextPct: 0, contextTokens: 0, contextWindow: 0, costUsd: 0, connectors: [], limits: [] }
32const env = atom({ plugin: 'crab-progress', key: 'env' } as const, BLANK_ENV)
33const BLANK_MANAGER: Manager = { task: '', isWorking: false, startedAt: 0 }
34const manager = atom({ plugin: 'crab-progress', key: 'manager' } as const, BLANK_MANAGER)
35const BLANK_WARM: Watch['warm'] = { on: false, idle: 0, busy: 0, halted: '', count: 0, spentUsd: 0, savedUsd: 0 }
36const BLANK_WATCH: Watch = { burn: [], cacheRead: 0, promptAll: 0, autopilot: false, budgetUsd: 0, warm: BLANK_WARM }
37const watch = atom({ plugin: 'crab-progress', key: 'watch' } as const, BLANK_WATCH)
38const BLANK_TODAY: Today = { date: '', tasks: 0, agents: 0, tokens: 0, costUsd: 0 }
39const today = atom({ plugin: 'crab-progress', key: 'today' } as const, BLANK_TODAY)
40const allTime = atom({ plugin: 'crab-progress', key: 'allTime' } as const, { since: '', models: {} } as AllTime)
41const BLANK_SESSION: SessionStats = { turn: 0, speed: null, costAtStart: 0, cacheUntil: null, cacheLeft: null, isBusy: false, warned: 0 }
42const session = atom({ plugin: 'crab-progress', key: 'session' } as const, BLANK_SESSION)
43// Claude's background work. The engine lists it only on Stop and SubagentStop, so
44// `at` keeps when that was: the pane says how fresh the list is.
45const BLANK_BG: Background = { tasks: [], crons: [], at: 0 }
46const background = atom({ plugin: 'crab-progress', key: 'background' } as const, BLANK_BG)
47// Today and All-time as this session last read or wrote them in the store.
48const saved = atom({ plugin: 'crab-progress', key: 'saved' } as const, { today: BLANK_TODAY, allTime: { since: '', models: {} } as AllTime })
49// Crew mode: a big job is split across sub-agents. On by default; `/autocrew` turns
50// it off or moves the context line. Kept in the store, so it holds across sessions.
51type AutoCrew = { on: boolean; ctxPct: number }
52const AUTO_CREW: AutoCrew = { on: true, ctxPct: 60 }
53const readAutoCrew = async ($: EngineInterface): Promise<AutoCrew> => {
54 const v = (await $.store.get('autoCrew').catch(() => null)) as Partial<AutoCrew> | null
55 return { on: v?.on !== false, ctxPct: typeof v?.ctxPct === 'number' && v.ctxPct >= 10 && v.ctxPct <= 95 ? v.ctxPct : AUTO_CREW.ctxPct }
56}
57
58// Why a request is a big job, or '' when it is not: a long brief, a list of asks,
59// many files named, a sweep over a whole codebase, a long plan, or a full context.
60const bigJobReason = (text: string, ctxPct: number, planSteps: number, ctxLine: number): string => {
61 const asks = text.split('\n').filter(l => /^\s*(?:[-*•]|\d+[.)])\s+\S/.test(l)).length
62 const files = new Set(text.match(/[\w./-]+\.(?:tsx?|jsx?|py|go|rs|java|kt|swift|rb|php|cs|cpp|c|h|css|html|json|md|ya?ml|sql)\b/g) ?? []).size
63 if (text.length >= 1500) return 'a long brief'
64 if (asks >= 4) return `${asks} asks in one request`
65 if (files >= 6) return `${files} files named`
66 if (text.length >= 300 && /\b(?:whole|entire|every|all (?:the )?(?:files|pages|modules|components|tests))\b|\b(?:refactor|migrate|port|rewrite|audit)\b/i.test(text)) return 'a sweep over the codebase'
67 if (planSteps >= 6) return `a ${planSteps}-step plan`
68 if (ctxPct >= ctxLine) return `context at ${ctxPct}%`
69 return ''
70}
71
72const crewText = (reason: string): string => `# Crew mode (Mission Control)
73This task is a big job (${reason}). Split it instead of carrying it all in this context; it is faster, and it keeps this context, and its cost, small.
74- Break the work into independent pieces. Give each to its own sub-agent with the Agent tool, and start the independent ones together, in one message, so they run in parallel.
75- Brief each one fully: the files, the goal, what done looks like. Name it "<Role>: <piece>" (e.g. "Engineer: wire the parser", "Researcher: map the API") so Mission Control files it under its role.
76- Pick the worker by the job: mc-* agents when they are listed, else general-purpose for building and editing; Explore for read-only searching and summing up.
77- Ask workers for conclusions, not file dumps; do not read big files here that a worker can read.
78- Pieces that edit the same files go to one worker, or run one after another.
79- Keep small things here: a few tool calls are not worth a sub-agent.
80- Then review and merge what comes back, and verify it.`
81
82// The prompt cache lives an hour after an answer; warn at ten and five minutes left.
83const CACHE_TTL_MS = 60 * 60 * 1000
84const CACHE_WARN_MIN = [10, 5] as const
85// Responses this short, or this fast, are tool stubs and bursts: their timing is noise.
86const SPEED_MIN_TOKENS = 20
87const SPEED_MIN_SECONDS = 0.5
88
89// Every session's tokens per model. The first run seeds it from assets/history.json,
90// counted from the session history files; each model request adds to it after.
91async function loadAllTime($: EngineInterface): Promise<void> {
92 const kept = (await $.store.get('allTime').catch(() => undefined)) as AllTime | undefined
93 if (kept && typeof kept === 'object' && kept.models) {
94 await update($, allTime, () => kept)
95 await update($, saved, prev => ({ ...prev, allTime: kept }))
96 return
97 }
98 try {
99 const seed = parseJson(await $.fs.read(`${$.plugin.root}/assets/history.json`)) as { since?: string; models?: Record<string, number> }
100 const models = Object.fromEntries(Object.entries(seed.models ?? {}).filter(([, n]) => typeof n === 'number' && n > 0))
101 const value: AllTime = { since: typeof seed.since === 'string' ? seed.since : '', models }
102 await update($, allTime, () => value)
103 await $.store.set('allTime', value)
104 await update($, saved, prev => ({ ...prev, allTime: value }))
105 } catch {
106 // No seed: the totals start from this session.
107 }
108}
109
110// A day by the local calendar, for the daily scoreboard.
111const dayOf = (at: number): string => {
112 const d = new Date(at)
113 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
114}
115// Today's totals, rolled over at midnight; persisted at each turn's end.
116async function bumpToday($: EngineInterface, change: Partial<Omit<Today, 'date'>>): Promise<void> {
117 const at = await $.clock.now()
118 const date = dayOf(at)
119 await update($, today, prev => {
120 const base = prev.date === date ? prev : { ...BLANK_TODAY, date }
121 return {
122 ...base,
123 tasks: base.tasks + (change.tasks ?? 0),
124 agents: base.agents + (change.agents ?? 0),
125 tokens: base.tokens + (change.tokens ?? 0),
126 costUsd: base.costUsd + Math.max(0, change.costUsd ?? 0),
127 }
128 })
129}
130
131// What the day's card shows: past midnight with nothing new yet, the new day's empty count.
132const todayOn = (d: Today, at: number): Today => (at && d.date && d.date !== dayOf(at) ? { ...BLANK_TODAY, date: dayOf(at) } : d)
133
134// Two sessions open at once share the store: a save adds this session's own share since
135// it last read or wrote the store onto what is there now, never its whole snapshot.
136let saveQueue: Promise<unknown> = Promise.resolve()
137function saveTotals($: EngineInterface): Promise<void> {
138 const run = saveQueue.then(() => saveTotalsNow($))
139 saveQueue = run.catch(() => undefined)
140 return run.catch(() => undefined)
141}
142const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
143async function saveTotalsNow($: EngineInterface): Promise<void> {
144 const [day, every, base] = await Promise.all([read($, today), read($, allTime), read($, saved)])
145 if (day.date) {
146 const was = base.today.date === day.date ? base.today : { ...BLANK_TODAY, date: day.date }
147 const kept = (await $.store.get('today').catch(() => undefined)) as Today | undefined
148 const on = kept && typeof kept === 'object' && kept.date === day.date ? kept : { ...BLANK_TODAY, date: day.date }
149 const keys = ['tasks', 'agents', 'tokens', 'costUsd'] as const
150 const merged = { ...BLANK_TODAY, date: day.date }
151 // No base yet (a hot reload into this version): the store already holds this
152 // session's earlier saves, so the larger count stands rather than both added.
153 for (const k of keys) merged[k] = base.today.date ? num(on[k]) + Math.max(0, day[k] - was[k]) : Math.max(num(on[k]), day[k])
154 await $.store.set('today', merged)
155 // What came in meanwhile stays on top of the merged count.
156 await update($, today, prev => (prev.date !== day.date ? prev : { ...prev, ...Object.fromEntries(keys.map(k => [k, merged[k] + prev[k] - day[k]])) }))
157 await update($, saved, prev => ({ ...prev, today: merged }))
158 }
159 const keptAll = (await $.store.get('allTime').catch(() => undefined)) as AllTime | undefined
160 const onAll = keptAll && typeof keptAll === 'object' && keptAll.models ? keptAll : { since: '', models: {} }
161 const models: Record<string, number> = { ...onAll.models }
162 const hasBase = Boolean(base.allTime.since) || Object.keys(base.allTime.models).length > 0
163 for (const [m, n] of Object.entries(every.models)) models[m] = hasBase ? num(models[m]) + Math.max(0, n - num(base.allTime.models[m])) : Math.max(num(models[m]), n)
164 const mergedAll: AllTime = { since: onAll.since || every.since, models }
165 await $.store.set('allTime', mergedAll)
166 await update($, allTime, prev => ({
167 since: mergedAll.since,
168 models: Object.fromEntries([...new Set([...Object.keys(mergedAll.models), ...Object.keys(prev.models)])].map(m => [m, num(mergedAll.models[m]) + num(prev.models[m]) - num(every.models[m])])),
169 }))
170 await update($, saved, prev => ({ ...prev, allTime: mergedAll }))
171}
172
173// A running agent with no model request or tool call for this long is flagged.
174const STUCK_MS = 120_000
175// A background agent's end queues its report for the main loop: the job waits this
176// long for the turn that answers it before it may finish without one.
177const REPORT_WAIT_MS = 30_000
178const CELEBRATE_MS = 6_000
179
180// ---------------------------------------------------------------------------
181// Timekeeper: every job and every sub-agent runs on a time budget. The plan's
182// `minutes` set it, else the person's defaults; the 1 s tick flags overruns and
183// alerts once each. The alert marks live in `$.state`, so a hot reload never re-alerts.
184
185const TK_KEY = 'timekeeper'
186const TK_MAX_MIN = 1440
187const TK_WARN = 0.8
188const BLANK_TK: Timekeeper = { on: true, jobMin: 60, agentMin: 20, warnedFor: 0, overFor: 0 }
189const timekeeper = atom({ plugin: 'crab-progress', key: 'timekeeper' } as const, BLANK_TK)
190const TK_USAGE = 'Usage: /timekeeper for its state, /timekeeper on|off, /timekeeper job <minutes>, /timekeeper agent <minutes> (whole minutes, 1–1440)'
191
192const TK_STRINGS = {
193 en: {
194 warn: (task: string, min: number) => `⏱ ${task} has used 80% of its ${min} min budget`,
195 over: (task: string, min: number) => `⏱ Over budget: ${task} has run past its ${min} min. Ask Claude for a new estimate.`,
196 agentOver: (task: string, min: number) => `⏱ "${task}" is over its ${min} min budget`,
197 agentsOver: (n: number, tasks: string) => `⏱ ${n} sub-agents are over budget: ${tasks}`,
198 overWord: 'over budget',
199 setting: (job: number, agent: number) => `Timekeeper: ${job} min per job, ${agent} min per sub-agent`,
200 },
201 ru: {
202 warn: (task: string, min: number) => `⏱ ${task}: израсходовано 80% бюджета в ${min} мин`,
203 over: (task: string, min: number) => `⏱ Бюджет превышен: ${task} идёт дольше ${min} мин. Попросите у Claude новую оценку.`,
204 agentOver: (task: string, min: number) => `⏱ «${task}» превысил бюджет в ${min} мин`,
205 agentsOver: (n: number, tasks: string) => `⏱ Субагентов сверх бюджета: ${n} — ${tasks}`,
206 overWord: 'бюджет превышен',
207 setting: (job: number, agent: number) => `Хронометраж: ${job} мин на задачу, ${agent} мин на субагента`,
208 },
209 he: {
210 warn: (task: string, min: number) => `⏱ ${task} ניצל 80% מתקציב של ${min} דק׳`,
211 over: (task: string, min: number) => `⏱ חריגה מהתקציב: ${task} עבר את ${min} הדקות. בקשו מ-Claude הערכה חדשה.`,
212 agentOver: (task: string, min: number) => `⏱ "${task}" חרג מתקציב של ${min} דק׳`,
213 agentsOver: (n: number, tasks: string) => `⏱ ${n} סוכנים חרגו מהתקציב: ${tasks}`,
214 overWord: 'חריגה מהתקציב',
215 setting: (job: number, agent: number) => `שומר זמן: ${job} דק׳ למשימה, ${agent} דק׳ לסוכן`,
216 },
217}
218const tkStr = () => TK_STRINGS[lang]
219
220// A budget is whole minutes, one to a day; anything else is no budget.
221const cleanMin = (v: unknown): number | undefined =>
222 typeof v === 'number' && Number.isFinite(v) && v >= 1 ? Math.min(TK_MAX_MIN, Math.round(v)) : undefined
223
224// The plan's own budget: its `minutes`, else the sum of its tasks'.
225const planBudget = (f: Flow | null): number | undefined => {
226 if (!f) return undefined
227 if (f.minutes) return f.minutes
228 const sum = f.tasks.reduce((n, t) => n + (t.minutes ?? 0), 0)
229 return sum > 0 ? Math.min(TK_MAX_MIN, sum) : undefined
230}
231
232// A run's budget: its task's when the plan names it (matched as the panel matches runs), else the default.
233const agentBudget = (a: AgentRun, f: Flow | null, tk: Timekeeper): number =>
234 f?.tasks.find(t => norm(t.title) === norm(a.description))?.minutes ?? tk.agentMin
235
236// "12/45m": whole minutes used of the budget. Drawn calm, warn past 80%, alert past 100%.
237const budgetLabel = (usedMs: number, min: number): string => `${Math.floor(Math.max(0, usedMs) / 60_000)}/${min}m`
238const budgetTone = (usedMs: number, min: number, calm = ''): string => {
239 const share = usedMs / (min * 60_000)
240 return share > 1 ? SKIN.colors.alert : share >= TK_WARN ? SKIN.colors.warn : calm
241}
242// Flagged only while the timekeeper is on: turning it off hides the flag, the alert mark stays.
243const isOver = (a: AgentRun): boolean => Boolean(a.isOverBudget && a.budgetMin)
244// The band's reading while a job runs; none before a request, after it is done, or when off.
245const bandBudget = (boss: Manager, at: number): { usedMs: number; min: number } | undefined =>
246 boss.budgetMin && boss.task && boss.startedAt && !boss.doneAt ? { usedMs: Math.max(0, at - boss.startedAt), min: boss.budgetMin } : undefined
247// The band's clock, the last of its header's right-hand parts, in the budget's tone.
248const budgetClock = (parts: string[], clock: string, b: { usedMs: number; min: number }): string => {
249 const tone = budgetTone(b.usedMs, b.min)
250 const head = parts.slice(0, -1).join(' ')
251 return `${xml(head ? `${head} ` : '')}<tspan font-weight="700"${tone ? ` fill="${tone}"` : ''}>${xml(clock)}</tspan>`
252}
253
254const readTimekeeper = async ($: EngineInterface): Promise<Pick<Timekeeper, 'on' | 'jobMin' | 'agentMin'>> => {
255 const v = (await $.store.get(TK_KEY).catch(() => null)) as Partial<Timekeeper> | null
256 return { on: v?.on !== false, jobMin: cleanMin(v?.jobMin) ?? BLANK_TK.jobMin, agentMin: cleanMin(v?.agentMin) ?? BLANK_TK.agentMin }
257}
258
259async function setTimekeeper($: EngineInterface, change: Partial<Pick<Timekeeper, 'on' | 'jobMin' | 'agentMin'>>): Promise<Timekeeper> {
260 const saved = { ...(await readTimekeeper($)), ...change }
261 await $.store.set(TK_KEY, saved).catch(() => undefined)
262 const tk = await update($, timekeeper, prev => ({ ...prev, ...saved }))
263 if (saved.on) await keepTime($, await $.clock.now().catch(() => 0))
264 else await clearBudgets($)
265 return tk
266}
267
268// Off: the drawings lose every budget; the next tick after turning it on puts them back.
269async function clearBudgets($: EngineInterface): Promise<void> {
270 if ((await read($, manager)).budgetMin !== undefined) await update($, manager, prev => ({ ...prev, budgetMin: undefined }))
271 if ((await read($, agents)).some(a => a.budgetMin !== undefined)) await update($, agents, list => list.map(a => (a.budgetMin === undefined ? a : { ...a, budgetMin: undefined })))
272}
273
274// Claimed in one atomic step, as finishIfIdle claims the cheer: two ticks never alert twice.
275async function claimAlert($: EngineInterface, mark: 'warnedFor' | 'overFor', job: number): Promise<boolean> {
276 let isMine = false
277 await update($, timekeeper, prev => {
278 isMine = prev[mark] !== job
279 // Past 100% the 80% warning is spent too: a job that jumps the line gets one alert.
280 return isMine ? { ...prev, [mark]: job, ...(mark === 'overFor' ? { warnedFor: job } : {}) } : prev
281 })
282 return isMine
283}
284
285// Two short beeps, made here: the core skins ship no alert sound of their own.
286let BEEP = ''
287const beepWav = (): string => {
288 if (BEEP) return BEEP
289 const rate = 8000
290 const n = Math.round(rate * 0.34)
291 const bytes = new Uint8Array(44 + n)
292 const v = new DataView(bytes.buffer)
293 const ascii = (at: number, s: string) => [...s].forEach((ch, i) => v.setUint8(at + i, ch.charCodeAt(0)))
294 ascii(0, 'RIFF')
295 v.setUint32(4, 36 + n, true)
296 ascii(8, 'WAVEfmt ')
297 v.setUint32(16, 16, true)
298 v.setUint16(20, 1, true)
299 v.setUint16(22, 1, true)
300 v.setUint32(24, rate, true)
301 v.setUint32(28, rate, true)
302 v.setUint16(32, 1, true)
303 v.setUint16(34, 8, true)
304 ascii(36, 'data')
305 v.setUint32(40, n, true)
306 for (let i = 0; i < n; i++) {
307 const t = i / rate
308 bytes[44 + i] = t < 0.12 || (t >= 0.2 && t < 0.32) ? 128 + Math.round(56 * Math.sin(2 * Math.PI * 880 * t)) : 128
309 }
310 const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
311 let out = ''
312 for (let i = 0; i < bytes.length; i += 3) {
313 const w = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
314 out += `${B64[(w >> 18) & 63]}${B64[(w >> 12) & 63]}${i + 1 < bytes.length ? B64[(w >> 6) & 63] : '='}${i + 2 < bytes.length ? B64[w & 63] : '='}`
315 }
316 return (BEEP = out)
317}
318
319// The skin's alert: MaxTac's siren on the cyberpunk skins, the beeps elsewhere.
320async function playAlert($: EngineInterface): Promise<void> {
321 if (!(await read($, settings)).sounds) return
322 await $.audio.play(eggOf() ? { asset: `${ADDON}/assets/maxtac.wav` } : { base64: beepWav(), mime: 'audio/wav' }).catch(() => undefined)
323}
324
325// Each tick: keep every budget current, then alert once per job at 80% and 100%, and
326// once per agent past its own. Writes only when something changed.
327async function keepTime($: EngineInterface, at: number): Promise<void> {
328 try {
329 const tk = await read($, timekeeper)
330 if (!tk.on) return
331 const [boss, list, f] = await Promise.all([read($, manager), read($, agents), read($, flow)])
332 const jobMin = boss.task ? (boss.planMin ?? tk.jobMin) : undefined
333 if (boss.budgetMin !== jobMin) await update($, manager, prev => (prev.startedAt === boss.startedAt ? { ...prev, budgetMin: jobMin } : prev))
334 if (jobMin && boss.startedAt && !boss.doneAt) {
335 const job = boss.startedAt
336 const share = (at - job) / (jobMin * 60_000)
337 const task = `"${clip(boss.task.replace(/\s+/g, ' '), 40)}"`
338 if (share > 1 && tk.overFor !== job) {
339 if (await claimAlert($, 'overFor', job)) {
340 $.ui.toast(tkStr().over(task, jobMin), { timeoutMs: 30000 })
341 void playAlert($)
342 }
343 } else if (share >= TK_WARN && tk.warnedFor !== job && tk.overFor !== job) {
344 if (await claimAlert($, 'warnedFor', job)) $.ui.toast(tkStr().warn(task, jobMin), { timeoutMs: 30000 })
345 }
346 }
347 const isDue = (a: AgentRun) => {
348 const min = agentBudget(a, f, tk)
349 return a.status === 'running' && (a.budgetMin !== min || (!a.isOverBudget && elapsed(a, at) > min * 60_000))
350 }
351 if (!list.some(isDue)) return
352 const fresh: AgentRun[] = []
353 await update($, agents, l => {
354 fresh.length = 0
355 return l.map(a => {
356 if (!isDue(a)) return a
357 const min = agentBudget(a, f, tk)
358 const isOverNow = elapsed(a, at) > min * 60_000
359 if (isOverNow && !a.isOverBudget) fresh.push({ ...a, budgetMin: min })
360 return { ...a, budgetMin: min, ...(isOverNow ? { isOverBudget: true } : {}) }
361 })
362 })
363 if (!fresh.length) return
364 // Several over in one tick share one toast and one sound: a big crew never makes a din.
365 const [one] = fresh
366 if (one && fresh.length === 1) $.ui.toast(tkStr().agentOver(clip(one.description || one.type, 40), one.budgetMin ?? tk.agentMin), { timeoutMs: 30000 })
367 else $.ui.toast(tkStr().agentsOver(fresh.length, clip(fresh.map(a => a.description || a.type).join(', '), 120)), { timeoutMs: 30000 })
368 void playAlert($)
369 } catch {
370 // A nudge: never in the way of the tick.
371 }
372}
373
374// The plan's `minutes` become the job's budget; a plan without them leaves the default.
375// Never in the way of the progress call itself.
376async function notePlanBudget($: EngineInterface, f: Flow | null): Promise<void> {
377 try {
378 const min = planBudget(f)
379 if ((await read($, manager)).planMin !== min) await update($, manager, prev => ({ ...prev, planMin: min }))
380 await keepTime($, await $.clock.now())
381 } catch {
382 // The next tick catches up.
383 }
384}
385
386// The standing rule, sent while the timekeeper is on. Short and imperative: it rides every request.
387const timekeeperText = (tk: Timekeeper): string => `# Timekeeper (Mission Control)
388Every job runs on a time budget. Follow these strictly:
389- Any job longer than a few minutes, or one that uses sub-agents: before the work starts, report the plan with \`${TOOL}\`, giving \`minutes\` for each task and for the whole job.
390- Keep to the budgets. As each task finishes, check the time spent against its budget.
391- The moment a task overruns, stalls or needs more time, tell the user plainly, with a new estimate. Never extend a budget silently.
392- For multi-step work keep a short written tracker: task, owner, budget, status.
393Without a plan the budgets are ${tk.jobMin} min per job and ${tk.agentMin} min per sub-agent.`
394
395const timekeeperStatus = async ($: EngineInterface, tk: Timekeeper): Promise<string> => {
396 if (!tk.on) return `Timekeeper off: no budgets, alerts or standing rule.\n${TK_USAGE}`
397 const boss = await read($, manager)
398 const at = await $.clock.now()
399 const job = boss.task && boss.budgetMin ? `\nThis job: ${budgetLabel((boss.doneAt ?? at) - boss.startedAt, boss.budgetMin)}${boss.planMin ? ' (from its plan)' : ''}` : ''
400 return `Timekeeper on: ${tk.jobMin} min per job, ${tk.agentMin} min per sub-agent, unless the plan gives \`minutes\`.${job}\n${TK_USAGE}`
401}
402
403const TIMEKEEPER_COMMAND = {
404 name: 'timekeeper',
405 description: 'Timekeeper: time budgets for every job and sub-agent. /timekeeper, /timekeeper on|off, /timekeeper job <minutes>, /timekeeper agent <minutes>',
406}
407
408// At session start: the command, and the settings. A reload keeps the alert marks
409// $.state holds; only the settings come from the store.
410async function startTimekeeper($: EngineInterface): Promise<void> {
411 await $.command.register(TIMEKEEPER_COMMAND).catch(() => undefined)
412 const saved = await readTimekeeper($)
413 await update($, timekeeper, prev => ({ ...prev, ...saved }))
414 if (!saved.on) await clearBudgets($)
415}
416
417// The standing rule rides at the end of the system prompt, as the memory rules do, while it is on.
418const timekeeperSections = async ($: EngineInterface) => {
419 const tk = await read($, timekeeper)
420 return tk.on ? [{ id: 'crab-progress:timekeeper', text: timekeeperText(tk), scope: 'session' as const }] : []
421}
422
423// Its command, beside the rest.
424function timekeeperHooks(on: On): void {
425 on('command.run', { command: 'timekeeper' }, async ($, e) => {
426 const [verb = '', arg, ...extra] = (e.args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean)
427 if (!verb || verb === 'status') return { text: await timekeeperStatus($, await read($, timekeeper)) }
428 if ((verb === 'on' || verb === 'off') && arg === undefined) {
429 const tk = await setTimekeeper($, { on: verb === 'on' })
430 return { text: await timekeeperStatus($, tk) }
431 }
432 // A bad number is answered, not clamped: a typo must not set a day-long budget.
433 const n = arg !== undefined && /^\d+$/.test(arg) ? Number(arg) : NaN
434 if ((verb === 'job' || verb === 'agent') && !extra.length && n >= 1 && n <= TK_MAX_MIN) {
435 const tk = await setTimekeeper($, verb === 'job' ? { jobMin: n } : { agentMin: n })
436 return { text: verb === 'job' ? `Job budget: ${tk.jobMin} min, unless the plan gives \`minutes\`.` : `Sub-agent budget: ${tk.agentMin} min, unless its task gives \`minutes\`.` }
437 }
438 return { text: TK_USAGE }
439 })
440}
441
442// ---------------------------------------------------------------------------
443// The manager's own task list: the desktop app's plan tool, read as it is called.
444// `items` replaces the list; `advance` closes the step in progress and opens the
445// next; `done` / `start` name steps by number, from 1.
446
447const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
448const WRAP_TOOL = 'mcp__ccd_turn__wrap_up'
449const MAX_PLAN = 30
450
451type PlanInput = { items?: { content?: string; status?: string }[]; advance?: boolean; done?: number[]; start?: number }
452
453const applyPlan = (prev: PlanStep[], input: PlanInput): PlanStep[] => {
454 let steps = prev
455 if (Array.isArray(input.items)) {
456 steps = input.items
457 .filter(i => typeof i?.content === 'string' && i.content.trim())
458 .slice(0, MAX_PLAN)
459 .map(i => ({
460 content: clip((i.content ?? '').trim(), 120),
461 status: i.status === 'completed' || i.status === 'in_progress' ? i.status : 'pending',
462 }))
463 }
464 if (input.advance) {
465 const at = steps.findIndex(st => st.status === 'in_progress')
466 steps = steps.map((st, i) => (i === at ? { ...st, status: 'completed' } : st))
467 const next = steps.findIndex((st, i) => i > at && st.status === 'pending')
468 if (next >= 0) steps = steps.map((st, i) => (i === next ? { ...st, status: 'in_progress' } : st))
469 }
470 if (Array.isArray(input.done)) {
471 const done = new Set(input.done.filter(n => Number.isInteger(n)).map(n => n - 1))
472 steps = steps.map((st, i) => (done.has(i) ? { ...st, status: 'completed' } : st))
473 }
474 if (Number.isInteger(input.start)) {
475 const at = (input.start as number) - 1
476 steps = steps.map((st, i) => (i === at ? { ...st, status: 'in_progress' } : st))
477 }
478 return steps
479}
480
481// Finished steps count whole, the one in progress half: a bar that moves as work does.
482const planProgress = (plan: PlanStep[] | undefined): number | null => {
483 if (!plan?.length) return null
484 const done = plan.filter(st => st.status === 'completed').length
485 const half = plan.some(st => st.status === 'in_progress') ? 0.5 : 0
486 return Math.min(1, (done + half) / plan.length)
487}
488
489// ---------------------------------------------------------------------------
490// Session card: where Claude runs, how full the context is, what it is connected to.
491
492// `mcp__<server>__<tool>`: the server is the middle part. A plugin's server reads
493// `plugin_<plugin>_<name>`; claude.ai connectors are bare UUIDs and count apart.
494const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-/i
495const serverOf = (tool: string): string | null => {
496 const m = /^mcp__(.+?)__/.exec(tool)
497 return m ? (m[1] ?? null) : null
498}
499const prettyServer = (s: string): string => {
500 const plugin = /^plugin_([^_]+(?:-[^_]+)*)_(.+)$/.exec(s)
501 return (plugin ? plugin[2] ?? s : s).replace(/[_-]+/g, ' ')
502}
503const connectorsOf = (tools: readonly string[]): string[] => {
504 const servers = [...new Set(tools.map(serverOf).filter((s): s is string => Boolean(s)))]
505 const named = servers.filter(s => !UUID.test(s)).map(prettyServer)
506 const linked = servers.length - named.length
507 return [...new Set(named)].sort().concat(linked ? [`${linked} claude.ai`] : [])
508}
509
510const ENV_EVERY_MS = 1000
511// One at a time, in order: two readings racing could count the same spend twice.
512let envQueue: Promise<unknown> = Promise.resolve()
513// When the last reading was taken, and whether a push came in since that waits for the tick.
514let envReadAt = -Infinity
515let isEnvStale = false
516function refreshEnv($: EngineInterface, isBaseline = false): Promise<void> {
517 isEnvStale = false
518 const run = envQueue.then(async () => {
519 envReadAt = await $.clock.now().catch(() => envReadAt)
520 return refreshEnvNow($, isBaseline)
521 })
522 envQueue = run.catch(() => undefined)
523 return run.catch(() => undefined)
524}
525// The session start's reading is the baseline (a resumed session's spend is not
526// today's again); every later rise is new spend, from $0 too.
527async function refreshEnvNow($: EngineInterface, isBaseline: boolean): Promise<void> {
528 const before = (await read($, env)).costUsd
529 const [cwd, model, surface, usage] = await Promise.all([
530 $.session.cwd().catch(() => ''),
531 $.session.model().catch(() => ''),
532 $.session.surface().catch(() => null),
533 $.session.usage().catch(() => null),
534 ])
535 const ctx = usage?.context
536 const window = ctx?.window ?? 0
537 const tokens = ctx?.tokens ?? 0
538 const pct = ctx?.percent ?? (window ? (tokens / window) * 100 : 0)
539 // Each model request asks again, mostly for the same readings: an equal one is not written.
540 const fresh = (prev: Env): Env => ({
541 ...prev,
542 cwd,
543 model,
544 surface: surface ?? '',
545 contextPct: Math.max(0, Math.min(100, Math.round(pct))),
546 contextTokens: tokens,
547 contextWindow: window,
548 costUsd: usage?.cost?.usd ?? prev.costUsd,
549 // A response without readings keeps the last ones rather than blanking the bars.
550 limits: usage?.rateLimits?.length ? usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })) : prev.limits ?? [],
551 })
552 const was = await read($, env)
553 if (!isSame(fresh(was), was)) await update($, env, fresh)
554 const cost = usage?.cost?.usd
555 if (isBaseline || typeof cost !== 'number') return
556 // The ledger starts over at /clear: after a drop, the whole new total is new spend.
557 const spent = cost >= before ? cost - before : cost
558 if (spent > 0) await bumpToday($, { costUsd: spent })
559}
560
561// A window whose reset time has passed reads empty until the next reply says otherwise.
562const liveEnv = (info: Env, at: number): Env => {
563 const isPast = (l: Env['limits'][number]) => Boolean(at && l.resetsAt && Date.parse(l.resetsAt) <= at)
564 const limits = info.limits ?? []
565 return limits.some(isPast) ? { ...info, limits: limits.map(l => (isPast(l) ? { ...l, percentUsed: 0, resetsAt: undefined } : l)) } : info
566}
567
568// ---------------------------------------------------------------------------
569// Memory: answer-style rules kept in `$.store` across sessions and added to every
570// system prompt, so no session has to remember to look them up.
571
572const MEMORY_PANE = 'crab-memory'
573const MEMORY_KEY = 'prefs'
574const MAX_RULES = 30
575const MAX_RULE = 500
576const DEFAULT_RULES = [
577 'Keep every answer short and skimmable: lead with the answer, then at most about 5 bullets or a few short lines. No long explanations, background or recaps unless asked. The user has ADHD.',
578]
579
580const cleanRules = (v: unknown): string[] =>
581 Array.isArray(v) ? v.filter((r): r is string => typeof r === 'string' && r.trim() !== '').map(r => clip(r.trim(), MAX_RULE)).slice(0, MAX_RULES) : []
582
583// Never set before means first run: seed the default rule. An empty list stays empty.
584async function loadRules($: EngineInterface): Promise<string[]> {
585 const stored = await $.store.get(MEMORY_KEY).catch(() => DEFAULT_RULES)
586 if (stored === undefined) {
587 await $.store.set(MEMORY_KEY, DEFAULT_RULES)
588 return [...DEFAULT_RULES]
589 }
590 return cleanRules(stored)
591}
592
593async function saveRules($: EngineInterface, rules: string[]): Promise<string[]> {
594 const clean = cleanRules(rules)
595 await $.store.set(MEMORY_KEY, clean)
596 await update($, memory, () => clean)
597 return clean
598}
599
600const memoryText = (rules: string[]): string =>
601 ['# How this user wants answers', 'Follow these in every reply, over any default style:', ...rules.map(r => `- ${r}`)].join('\n')
602
603const listRules = (rules: string[]): string =>
604 rules.length ? rules.map((r, i) => `${i + 1}. ${r}`).join('\n') : 'No rules saved. Add one with /prefs add <rule>.'
605
606const TOOL = 'mcp__crab-progress__progress'
607const STEP_TOOL = 'mcp__crab-progress__step'
608// Added to the end of each Mission Control worker's brief. Their system prompt words step
609// reporting as a condition ("if the step tool appears among your tools") and they
610// skip it even with the tool listed; the same request in the brief is followed.
611const STEP_BRIEF = `
612
613Progress reporting: before any other work, call \`${STEP_TOOL}\` with \`total\` (your plan in 3-8 steps) and \`done: 0\`; call it again with \`done\` and a few-word \`note\` as each step finishes. It only draws a progress bar.`
614const PANE = 'crab-agents'
615const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
616const TIERS: readonly string[] = ['fable', 'heavy', 'careful', 'medium', 'light']
617// The tools' schemas are advice to the model, not a check: what a call may ask
618// the band and the panel to draw is bounded here.
619const MAX_TASKS = 200
620const MAX_AFTER = 20
621const MAX_TITLE = 120
622let ACCENT = '#8f8cf4'
623let DONE = '#5fbf8f'
624
625type ProgressInput = {
626 title?: string
627 total?: number
628 done?: number
629 phase?: Phase
630 finished?: boolean
631 tasks?: { title?: string; tier?: string; after?: number[]; minutes?: number }[]
632 minutes?: number
633}
634
635// ---------------------------------------------------------------------------
636// Skins: every colour, the font, the card shape and the overlay effects come from
637// the active skin. Built-ins live here; more are JSON files in the person's
638// "Custom Claude Skins" folder, read at session start and by /skin reload.
639
640type SkinColors = {
641 accent: string
642 accent2: string
643 done: string
644 warn: string
645 alert: string
646 meter: string
647 text: string
648 subtext: string
649 muted: string
650 tile: string
651 track: string
652 line: string
653 grid: string
654}
655type Skin = {
656 id: string
657 name: string
658 description: string
659 font: string
660 corners: 'round' | 'chamfer'
661 /** A dark-only skin draws its own colours whatever the system scheme. */
662 dark: boolean
663 colors: SkinColors
664 /** The boss frame's ring, five stops around the card. */
665 boss: string[]
666 /** Colours handed to roles named in a task ("Designer: ..."). */
667 roles: string[]
668 /** The whole pane's background: a fill (empty: see-through) and its pattern. */
669 backdrop: { fill: string; pattern: 'dots' | 'lines' | 'stars' | 'none'; ink: string }
670 effects: { scanlines: boolean; glitch: boolean }
671 /** Restyle the chat's tool rows and spinner; off leaves the app's own drawing. */
672 chat: boolean
673 /** Spinner words while a turn thinks or waits ("Jacking in"). */
674 words: string[]
675 /** A themed verb per tool row ("Read" becomes "Scan"). */
676 toolWords: Record<string, string>
677 /** The panes' tab titles: the app draws the tab, the skin names it. */
678 tabs: { mission: string; skins: string }
679 /** Characters in place of the crabs: a key of CASTS ("runners", "corpo", "netrun"). */
680 cast?: string
681 /** Left out of the picker and the list until it is worn: an easter egg. */
682 hidden?: boolean
683}
684
685const CRAB_SKIN: Skin = {
686 id: 'crab-classic',
687 name: 'Crab Classic',
688 description: 'The original: soft violet, gold boss, rounded cards.',
689 font: "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
690 corners: 'round',
691 dark: false,
692 colors: {
693 accent: '#8f8cf4',
694 accent2: '#5EC8D8',
695 done: '#5fbf8f',
696 warn: '#E9B44C',
697 alert: '#E5675E',
698 meter: '#3B82F6',
699 text: '#ececec',
700 subtext: '#a8a8a4',
701 muted: '#9A9A96',
702 tile: '#262625',
703 track: '#2c2c2b',
704 line: '#333331',
705 grid: '#3a3a38',
706 },
707 boss: ['#F5C542', '#E76F51', '#B58BE5', '#5EC8D8', '#F5C542'],
708 roles: ['#E58FB5', '#5EC8D8', '#E9B44C', '#9CCB6B', '#B58BE5', '#E5875E'],
709 effects: { scanlines: false, glitch: false },
710 chat: true,
711 words: ['Scuttling', 'Pinching through', 'Clawing along', 'Sidestepping'],
712 toolWords: {},
713 backdrop: { fill: '', pattern: 'dots', ink: '' },
714 tabs: { mission: '🦀 Mission Control', skins: 'Skins' },
715}
716
717// Night City HUD: neon yellow, hot red, electric cyan on near-black, cut corners,
718// scanlines and a glitch. Rajdhani is the game's HUD face; Bahnschrift stands in on Windows.
719const CYBER_SKIN: Skin = {
720 id: 'cyberpunk-2077',
721 cast: 'runners',
722 name: 'Cyberpunk 2077',
723 description: 'Night City HUD: neon yellow, hot red, electric cyan, cut corners, scanlines.',
724 font: "Rajdhani,Orbitron,-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Bahnschrift,'Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
725 corners: 'chamfer',
726 dark: true,
727 colors: {
728 accent: '#FCEE0A',
729 accent2: '#00F0FF',
730 done: '#00FF9F',
731 warn: '#FF8A00',
732 alert: '#FF003C',
733 meter: '#00F0FF',
734 text: '#F3EFE0',
735 subtext: '#9FB4BA',
736 muted: '#74808A',
737 tile: '#0B0C10',
738 track: '#1A1D24',
739 line: '#2B2F38',
740 grid: '#1C2129',
741 },
742 boss: ['#FCEE0A', '#FF003C', '#B000FF', '#00F0FF', '#FCEE0A'],
743 roles: ['#FCEE0A', '#00F0FF', '#FF003C', '#FF5CF0', '#00FF9F', '#FF8A00'],
744 effects: { scanlines: true, glitch: true },
745 chat: true,
746 words: ['Jacking in', 'Breaching ICE', 'Netrunning', 'Overclocking', 'Uploading quickhack', 'Scanning Night City'],
747 toolWords: { Read: 'Scan', Edit: 'Patch', MultiEdit: 'Patch', Write: 'Burn', Bash: 'Exec', PowerShell: 'Exec', Grep: 'Trace', Glob: 'Index', Agent: 'Deploy', WebFetch: 'Jack in', WebSearch: 'Netsearch', ToolSearch: 'Load chrome' },
748 backdrop: { fill: '#050608', pattern: 'lines', ink: 'rgba(0,240,255,.07)' },
749 tabs: { mission: '◢ Mission Control', skins: '◢ Skins' },
750}
751
752// Arasaka: the corporation's own look. Blood red on black, steel grey, hard edges, no noise.
753const ARASAKA_SKIN: Skin = {
754 id: 'arasaka',
755 cast: 'corpo',
756 name: 'Arasaka',
757 description: 'Corporate steel: blood red on black, hard edges, no noise.',
758 font: "Rajdhani,-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Bahnschrift,'Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
759 corners: 'chamfer',
760 dark: true,
761 colors: {
762 accent: '#E3001B',
763 accent2: '#C9CED6',
764 done: '#3DDC97',
765 warn: '#FFB000',
766 alert: '#FF1E3C',
767 meter: '#E3001B',
768 text: '#F2F2F2',
769 subtext: '#9A9EA6',
770 muted: '#7A7E86',
771 tile: '#0A0A0B',
772 track: '#1B1C1F',
773 line: '#2A2C31',
774 grid: '#18191C',
775 },
776 boss: ['#E3001B', '#FFFFFF', '#7A0010', '#C9CED6', '#E3001B'],
777 roles: ['#E3001B', '#C9CED6', '#FFFFFF', '#FF5A6E', '#8A9099', '#B3001A'],
778 effects: { scanlines: false, glitch: false },
779 chat: true,
780 words: ['Processing', 'Securing assets', 'Auditing', 'Executing protocol', 'Consulting the board'],
781 toolWords: { Read: 'Audit', Edit: 'Amend', MultiEdit: 'Amend', Write: 'File', Bash: 'Execute', PowerShell: 'Execute', Grep: 'Investigate', Glob: 'Catalogue', Agent: 'Dispatch', WebFetch: 'Retrieve', WebSearch: 'Surveil' },
782 backdrop: { fill: '#060607', pattern: 'lines', ink: 'rgba(227,0,27,.07)' },
783 tabs: { mission: 'Arasaka · Mission Control', skins: 'Arasaka · Skins' },
784}
785
786// Lucy (Edgerunners): the moonlit netrunner. Rose-pink and cyan on deep violet, soft panels.
787const LUCY_SKIN: Skin = {
788 id: 'lucy-edgerunners',
789 cast: 'netrun',
790 name: 'Lucy · Edgerunners',
791 description: 'Moonlit netrunner: rose-pink and cyan on deep violet, soft glow.',
792 font: "Rajdhani,-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Bahnschrift,'Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
793 corners: 'round',
794 dark: true,
795 colors: {
796 accent: '#FF5CF0',
797 accent2: '#5CE1FF',
798 done: '#7DFFCF',
799 warn: '#FFD166',
800 alert: '#FF3D7F',
801 meter: '#B79CFF',
802 text: '#F4ECFF',
803 subtext: '#B9A9D9',
804 muted: '#8A7DB0',
805 tile: '#100B1A',
806 track: '#1E1530',
807 line: '#2C2244',
808 grid: '#1A1428',
809 },
810 boss: ['#FF5CF0', '#5CE1FF', '#B79CFF', '#FFFFFF', '#FF5CF0'],
811 roles: ['#FF5CF0', '#5CE1FF', '#B79CFF', '#7DFFCF', '#FFD166', '#FF8AD8'],
812 effects: { scanlines: true, glitch: true },
813 chat: true,
814 words: ['Diving', 'Drifting through the Net', 'Moonwalking', 'Netrunning', 'Chasing the moon'],
815 toolWords: { Read: 'Dive', Edit: 'Rewrite', MultiEdit: 'Rewrite', Write: 'Leave a trace', Bash: 'Run', PowerShell: 'Run', Grep: 'Search', Glob: 'Map', Agent: 'Call in', WebFetch: 'Pull', WebSearch: 'Search the Net' },
816 backdrop: { fill: '#0A0712', pattern: 'stars', ink: 'rgba(244,236,255,.35)' },
817 tabs: { mission: '☾ Mission Control', skins: '☾ Skins' },
818}
819
820// Default: no skin at all. Neutral greys, the system font, rounded cards, no effects,
821// following the system's light or dark scheme.
822const DEFAULT_SKIN: Skin = {
823 id: 'default',
824 name: 'Default',
825 description: 'No skin: plain greys, system font, no effects.',
826 font: "-apple-system,BlinkMacSystemFont,'Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
827 corners: 'round',
828 dark: false,
829 colors: {
830 accent: '#9A9A96',
831 accent2: '#B4B4B0',
832 done: '#6FA287',
833 warn: '#C9A35A',
834 alert: '#C46A62',
835 meter: '#8C8C88',
836 text: '#ececec',
837 subtext: '#a8a8a4',
838 muted: '#9A9A96',
839 tile: '#262625',
840 track: '#2c2c2b',
841 line: '#333331',
842 grid: '#3a3a38',
843 },
844 boss: ['#B4B4B0', '#8C8C88', '#6E6E6A', '#B4B4B0', '#B4B4B0'],
845 roles: ['#A7A7A3', '#8E9AA6', '#A69A8E', '#8EA696', '#9A8EA6', '#A6A08E'],
846 effects: { scanlines: false, glitch: false },
847 chat: false,
848 words: [],
849 toolWords: {},
850 backdrop: { fill: '', pattern: 'dots', ink: '' },
851 tabs: { mission: 'Mission Control', skins: 'Skins' },
852}
853
854// SAMURAI: the secret skin. Johnny's band, red on black, a stage in the skyline.
855// Not in the picker or the list: `/skin samurai` finds it.
856const SAMURAI_SKIN: Skin = {
857 id: 'samurai',
858 cast: 'samurai',
859 hidden: true,
860 name: 'SAMURAI',
861 description: "Johnny's band: red on black, stage lights, never fade away.",
862 font: "Rajdhani,Orbitron,-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Bahnschrift,'Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
863 corners: 'chamfer',
864 dark: true,
865 colors: {
866 accent: '#E10600',
867 accent2: '#FFD400',
868 done: '#FFD400',
869 warn: '#FF8A00',
870 alert: '#FF2E3E',
871 meter: '#D9D9D9',
872 text: '#F4F4F4',
873 subtext: '#B3A5A5',
874 muted: '#8A7878',
875 tile: '#0E0909',
876 track: '#241515',
877 line: '#3A2020',
878 grid: '#1C1010',
879 },
880 boss: ['#E10600', '#FFD400', '#D9D9D9', '#E10600', '#FFD400'],
881 roles: ['#E10600', '#FFD400', '#D9D9D9', '#FF8A00', '#FF5C5C', '#B3A5A5'],
882 effects: { scanlines: true, glitch: true },
883 chat: true,
884 words: ['Never fading away', 'Shredding', 'Riffing'],
885 toolWords: {},
886 backdrop: { fill: '#080505', pattern: 'lines', ink: 'rgba(225,6,0,.25)' },
887 tabs: { mission: '♪ Mission Control', skins: '♪ Skins' },
888}
889
890const BUILT_IN_SKINS: Skin[] = [DEFAULT_SKIN, CRAB_SKIN, CYBER_SKIN, ARASAKA_SKIN, LUCY_SKIN, SAMURAI_SKIN]
891
892// Only plain colour and font values reach the drawings: a skin file cannot
893// smuggle markup into an Svg.
894const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\))$/i
895const cleanColor = (v: unknown, fallback: string): string => (typeof v === 'string' && COLOR_RE.test(v.trim()) ? v.trim() : fallback)
896// A JSON file's text, without the byte-order mark Windows editors may write.
897const parseJson = (text: unknown): unknown => JSON.parse(String(text).replace(/^\uFEFF/, ''))
898
899const cleanFont = (v: unknown, fallback: string): string =>
900 typeof v === 'string' && v.trim() && !/["<>&;{}]/.test(v) ? v.trim().slice(0, 200) : fallback
901
902const readSkin = (raw: unknown, fallbackId: string): Skin | null => {
903 if (!raw || typeof raw !== 'object') return null
904 const r = raw as Record<string, unknown>
905 const base = CRAB_SKIN
906 const colors = (r.colors ?? {}) as Record<string, unknown>
907 const effects = (r.effects ?? {}) as Record<string, unknown>
908 const id = typeof r.id === 'string' && /^[a-z0-9-]{1,40}$/.test(r.id) ? r.id : fallbackId
909 const list = (v: unknown, fb: string[], n: number) =>
910 Array.isArray(v) && v.length ? v.slice(0, n).map((c, i) => cleanColor(c, fb[i % fb.length] ?? '#888888')) : fb
911 return {
912 id,
913 name: typeof r.name === 'string' ? clip(r.name.trim(), 40) : id,
914 description: typeof r.description === 'string' ? clip(r.description.trim(), 120) : '',
915 font: cleanFont(r.font, base.font),
916 corners: r.corners === 'chamfer' ? 'chamfer' : 'round',
917 dark: r.dark === true,
918 colors: Object.fromEntries(Object.entries(base.colors).map(([k, v]) => [k, cleanColor(colors[k], v)])) as SkinColors,
919 boss: list(r.boss, base.boss, 5),
920 roles: list(r.roles, base.roles, 12),
921 effects: { scanlines: effects.scanlines === true, glitch: effects.glitch === true },
922 backdrop: (() => {
923 const bd = (r.backdrop ?? {}) as Record<string, unknown>
924 const pattern = bd.pattern === 'lines' || bd.pattern === 'stars' || bd.pattern === 'none' ? bd.pattern : 'dots'
925 return { fill: bd.fill ? cleanColor(bd.fill, '') : '', pattern, ink: bd.ink ? cleanColor(bd.ink, '') : '' } as Skin['backdrop']
926 })(),
927 tabs: (() => {
928 const tb = (r.tabs ?? {}) as Record<string, unknown>
929 const name = (v: unknown, fb: string) => (typeof v === 'string' && v.trim() ? clip(v.trim(), 40) : fb)
930 return { mission: name(tb.mission, 'Mission Control'), skins: name(tb.skins, 'Skins') }
931 })(),
932 cast: typeof r.cast === 'string' && Object.hasOwn(CASTS, r.cast) ? r.cast : undefined,
933 chat: r.chat !== false,
934 words: Array.isArray(r.words) ? r.words.filter((w): w is string => typeof w === 'string' && w.trim() !== '').map(w => clip(w.trim(), 32)).slice(0, 20) : base.words,
935 toolWords: Object.fromEntries(
936 Object.entries((r.toolWords ?? {}) as Record<string, unknown>)
937 .filter(([k, v]) => /^[A-Za-z_]{1,40}$/.test(k) && typeof v === 'string' && v.trim())
938 .map(([k, v]) => [k, clip(String(v).trim(), 20)]),
939 ),
940 }
941}
942
943const skin = atom({ plugin: 'crab-progress', key: 'skin' } as const, CRAB_SKIN.id)
944// Settings the pane's checkboxes change: kept in the store, mirrored here to redraw.
945const settings = atom({ plugin: 'crab-progress', key: 'settings' } as const, { openOnStart: true, crewMode: true, currency: 'USD', sounds: true })
946const cred = atom({ plugin: 'crab-progress', key: 'cred' } as const, BLANK_CRED)
947const timeline = atom({ plugin: 'crab-progress', key: 'timeline' } as const, [] as Beat[])
948const bdPick = atom({ plugin: 'crab-progress', key: 'bdPick' } as const, -1)
949const readSettings = async ($: EngineInterface) => {
950 const saved = (await $.store.get('currency').catch(() => undefined)) as { code?: string; rate?: number } | undefined
951 // Nothing chosen yet is US dollars. A store written by hand or by an older build may
952 // hold anything: a bad code is dollars too.
953 CURRENCY = currencyOf(typeof saved?.code === 'string' ? saved.code : 'USD', typeof saved?.rate === 'number' ? saved.rate : undefined)
954 return {
955 openOnStart: (await $.store.get('openOnStart').catch(() => undefined)) !== false,
956 crewMode: (await readAutoCrew($)).on,
957 currency: CURRENCY.code,
958 sounds: (await $.store.get('sounds').catch(() => undefined)) !== false,
959 }
960}
961
962// The pane's two folds, kept in the store so a reload or a new session opens the
963// list as the person left it: long by default.
964const PANE_VIEW = 'paneView'
965const readPaneView = async ($: EngineInterface): Promise<Pick<Panel, 'isCompact' | 'isDoneCollapsed'>> => {
966 const v = (await $.store.get(PANE_VIEW).catch(() => undefined)) as Partial<Panel> | undefined
967 return { isCompact: v?.isCompact === true, isDoneCollapsed: v?.isDoneCollapsed === true }
968}
969const setPaneView = async ($: EngineInterface, change: (p: Pick<Panel, 'isCompact' | 'isDoneCollapsed'>) => Partial<Panel>): Promise<void> => {
970 const prev = await read($, panel)
971 const next = { isCompact: prev.isCompact, isDoneCollapsed: prev.isDoneCollapsed, ...change(prev) }
972 await update($, panel, p => ({ ...p, ...next }))
973 await $.store.set(PANE_VIEW, next).catch(() => undefined)
974}
975
976// A sound, when the person has sounds on. Never in the way: a failure is silent.
977async function play($: EngineInterface, asset: string): Promise<void> {
978 if (!(await read($, settings)).sounds) return
979 await $.audio.play({ asset }).catch(() => undefined)
980}
981
982// Street Cred: change the counts, award what they earn, cheer a level or a badge.
983// The store's read-modify-write is not atomic, so the bumps run one at a time: five
984// agents spawned at once count five. `quiet` leaves the chime to the caller's own.
985// Resolves true when the bump earned a level or a badge.
986let credQueue: Promise<unknown> = Promise.resolve()
987function bumpCred($: EngineInterface, change: (c: Cred) => Cred, quiet = false): Promise<boolean> {
988 const run = credQueue.then(() => bumpCredNow($, change, quiet))
989 credQueue = run.catch(() => false)
990 return run
991}
992async function bumpCredNow($: EngineInterface, change: (c: Cred) => Cred, quiet: boolean): Promise<boolean> {
993 let saved: unknown
994 try {
995 saved = await $.store.get('cred')
996 } catch {
997 return false // An unreadable store is left alone rather than overwritten with a blank.
998 }
999 const before = cleanCred(saved)
1000 const { cred: next, fresh } = award(change(before))
1001 if (next === before) return false
1002 await $.store.set('cred', next).catch(() => undefined)
1003 await update($, cred, () => next)
1004 const was = levelOf(before.xp)
1005 const now = levelOf(next.xp)
1006 // Street Cred is a cyberpunk add-on: other skins keep counting quietly, with no cheer.
1007 if (!eggOf()) return false
1008 if (now > was) $.ui.toast(`${eggOf() === 'samurai' ? 'ROCKERBOY CRED' : 'STREET CRED'} ↑ ${now} · ${eggOf() === 'samurai' ? rockerRankOf(now) : rankOf(now)}`)
1009 for (const b of fresh) $.ui.toast(`${b.icon} Badge unlocked: ${b.name}`)
1010 const isCheer = now > was || fresh.length > 0
1011 if (!quiet && isCheer) void play($, `${ADDON}/assets/levelup.wav`)
1012 return isCheer
1013}
1014// The next currency in the list; a custom rate is kept only for the one it was set on.
1015const setCurrency = async ($: EngineInterface, c: Currency) => {
1016 CURRENCY = c
1017 await $.store.set('currency', { code: c.code, rate: c.rate }).catch(() => undefined)
1018 await update($, settings, prev => ({ ...prev, currency: c.code }))
1019}
1020const SKINS = new Map<string, Skin>(BUILT_IN_SKINS.map(k => [k.id, k]))
1021// The Cyberpunk Skins add-on: its skins (and their sounds) ship in addons/cyberpunk/.
1022// Without that folder Mission Control is the core edition: default and Crab Classic.
1023const ADDON = 'addons/cyberpunk'
1024const ADDON_URL = 'https://github.com/Chinchuan24/MIssonControl-Skins#-choose-your-edition'
1025const ADDON_CASTS = new Set(['runners', 'netrun', 'corpo', 'samurai'])
1026let HAS_CYBER = false
1027const checkAddon = async ($: EngineInterface): Promise<boolean> => {
1028 HAS_CYBER = await $.fs.exists(`${$.plugin.root}/${ADDON}/addon.json`).catch(() => false)
1029 return HAS_CYBER
1030}
1031const isAddonSkin = (k: Skin): boolean => ADDON_CASTS.has(String(k.cast ?? ''))
1032const canWear = (k: Skin): boolean => HAS_CYBER || !isAddonSkin(k)
1033const ADDON_PITCH = `Cyberpunk Skins add-on: Cyberpunk 2077, Lucy · Edgerunners and Arasaka. Get it: ${ADDON_URL}`
1034let SKIN: Skin = CRAB_SKIN
1035const SKINS_FOLDER = 'Custom Claude Skins'
1036
1037// The folder: remembered once found, else "Custom Claude Skins" in this session's
1038// folder, else the example skins shipped with the mod (never remembered, so a
1039// folder of your own found later still wins).
1040async function loadSkins($: EngineInterface): Promise<{ folder: string; loaded: number; skipped: string[] }> {
1041 const remembered = await $.store.get('skinsFolder').catch(() => undefined)
1042 const cwd = await $.session.cwd().catch(() => '')
1043 const bundled = `${$.plugin.root}/skins`
1044 const candidates = [typeof remembered === 'string' ? remembered : '', cwd ? `${cwd.replace(/[\\/]+$/, '')}/${SKINS_FOLDER}` : '', bundled].filter(Boolean)
1045 for (const folder of candidates) {
1046 if (!(await $.fs.exists(folder).catch(() => false))) continue
1047 if (folder !== remembered && folder !== bundled) await $.store.set('skinsFolder', folder).catch(() => undefined)
1048 let loaded = 0
1049 const skipped: string[] = []
1050 const files = await $.fs.list(folder).catch((err: unknown) => {
1051 skipped.push(`folder: ${String(err).slice(0, 80)}`)
1052 return []
1053 })
1054 for (const f of files) {
1055 // Dotfiles are skipped: macOS leaves `._name.json` beside files copied from other disks.
1056 if (f.kind !== 'file' || f.name.startsWith('.') || !f.name.toLowerCase().endsWith('.json')) continue
1057 try {
1058 const k = readSkin(parseJson(await $.fs.read(`${folder}/${f.name}`)), f.name.replace(/\.json$/i, '').toLowerCase())
1059 if (k) {
1060 SKINS.set(k.id, k)
1061 loaded += 1
1062 }
1063 } catch (err) {
1064 // A broken file is skipped, and /skin reload says why.
1065 skipped.push(`${f.name}: ${String(err).slice(0, 80)}`)
1066 }
1067 }
1068 return { folder, loaded, skipped }
1069 }
1070 return { folder: '', loaded: 0, skipped: [] }
1071}
1072
1073function applySkin(k: Skin): void {
1074 SKIN = k
1075 ACCENT = k.colors.accent
1076 DONE = k.colors.done
1077 FONT = k.font
1078 TIDE = k.colors.accent2
1079 KELP = k.colors.done
1080 ROLE_PALETTE = k.roles
1081 MOOD_COLOR = [k.colors.done, k.colors.warn, k.colors.alert]
1082 PANE_CSS = paneCss(k)
1083}
1084
1085// An open pane behind another tab is not raised by a second ui.open: close it, then open it.
1086async function showPane($: EngineInterface, id: string, title: string): ReturnType<EngineInterface['ui']['open']> {
1087 const all = await $.ui.panes().catch(() => [])
1088 const row = all.find(p => p.id === id)
1089 if (row && !row.isShown) await $.ui.close({ id }).catch(() => undefined)
1090 return $.ui.open({ id, title })
1091}
1092
1093// What the desktop app counts against its 262,144-character limit for one drawing:
1094// every text, an Svg's source and alt, each other element's prop names and string
1095// values. Past the limit it draws a blank pane; DRAW_MAX leaves room for its own count.
1096const DRAW_MAX = 220_000
1097const drawnChars = (n: unknown): number => {
1098 if (typeof n === 'string') return n.length
1099 if (Array.isArray(n)) return n.reduce((sum: number, c) => sum + drawnChars(c), 0)
1100 if (!n || typeof n !== 'object') return 0
1101 const el = n as { type?: unknown; props?: Record<string, unknown>; children?: unknown }
1102 const props = el.props ?? {}
1103 const own =
1104 el.type === 'Svg'
1105 ? String(props.source ?? '').length + String(props.alt ?? '').length
1106 : Object.entries(props).reduce((sum, [k, v]) => (k === 'children' ? sum : sum + k.length + (typeof v === 'string' ? v.length : 0)), 0)
1107 return own + drawnChars(el.children ?? props.children)
1108}
1109
1110// After a switch, the open panes take the new skin's tab titles.
1111async function retitle($: EngineInterface): Promise<void> {
1112 const open = await $.ui.panes().catch(() => [])
1113 for (const p of open) {
1114 if (p.id === PANE) await $.ui.open({ id: PANE, title: SKIN.tabs.mission }).catch(() => undefined)
1115 if (p.id === SKINS_PANE) await $.ui.open({ id: SKINS_PANE, title: SKIN.tabs.skins }).catch(() => undefined)
1116 }
1117}
1118
1119// Every drawing calls this first: it follows the skin atom, so a switch redraws all.
1120async function useSkin($: EngineInterface): Promise<Skin> {
1121 const id = await read($, skin)
1122 if (id !== SKIN.id) applySkin(SKINS.get(id) ?? CRAB_SKIN)
1123 return SKIN
1124}
1125
1126// ---------------------------------------------------------------------------
1127// Language: the `language` option, else Claude Code's `language` setting, else the
1128// process locale; English when nothing says Hebrew or Russian.
1129
1130type Lang = 'en' | 'ru' | 'he'
1131
1132const STRINGS = {
1133 en: {
1134 pane: 'Mission Control',
1135 cost: 'Cost',
1136 tokens: 'Tokens',
1137 time: 'Time',
1138 collapse: 'Collapse',
1139 expand: 'Expand',
1140 running: 'Running',
1141 finished: 'Finished',
1142 planned: 'Planned',
1143 empty: 'No subagents yet.',
1144 round: 'round',
1145 failed: 'error',
1146 stopped: 'stopped',
1147 after: 'after',
1148 tokensWord: 'tokens',
1149 agentsCount: 'agents',
1150 isRunning: 'running',
1151 isFinished: 'finished',
1152 isPlanned: 'planned',
1153 opened: 'Mission Control opened.',
1154 closed: 'Mission Control closed.',
1155 done: 'Done',
1156 plan: 'Plan',
1157 design: 'Design',
1158 tasks: 'Tasks',
1159 review: 'Review',
1160 busy: 'running',
1161 idle: 'Idle',
1162 working: 'Working…',
1163 ctx: 'ctx',
1164 bgTitle: 'Background',
1165 bgKind: { shell: 'Shell', subagent: 'Agent', monitor: 'Monitor', workflow: 'Workflow', scheduled: 'Scheduled' },
1166 bgStatus: { running: 'running', pending: 'pending', repeats: 'repeats', once: 'once', released: 'released' },
1167 bgMore: 'more',
1168 bgBand: 'in the background',
1169 bgWait: 'Waiting',
1170 bgWaiting: (n: number): string => `Waiting on ${n} background ${n === 1 ? 'task' : 'tasks'}`,
1171 bgAgo: (min: number): string => (min < 1 ? 'updated just now' : min < 60 ? `updated ${min} min ago` : `updated ${Math.floor(min / 60)} h ago`),
1172 tier: { light: 'light', medium: 'medium', careful: 'careful', heavy: 'heavy', fable: 'fable', other: 'other' },
1173 effort: { low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' },
1174 },
1175 ru: {
1176 pane: 'Центр управления',
1177 cost: 'Стоимость',
1178 tokens: 'Токены',
1179 time: 'Время',
1180 collapse: 'Свернуть',
1181 expand: 'Развернуть',
1182 running: 'Работают',
1183 finished: 'Завершены',
1184 planned: 'Запланированы',
1185 empty: 'Субагентов пока нет.',
1186 round: 'раунд',
1187 failed: 'ошибка',
1188 stopped: 'остановлен',
1189 after: 'после',
1190 tokensWord: 'токенов',
1191 agentsCount: 'агентов',
1192 isRunning: 'работает',
1193 isFinished: 'завершён',
1194 isPlanned: 'запланирована',
1195 opened: 'Центр управления открыт.',
1196 closed: 'Центр управления закрыт.',
1197 done: 'Готово',
1198 plan: 'План',
1199 design: 'Дизайн',
1200 tasks: 'Задачи',hooks/cast.ts 1208 lines1// Casts: pixel characters a skin can put in place of the crabs.
2//
3// Drawn in one style: no black outline, soft colour blocks, a small faceless head,
4// realistic proportions about 46 pixels tall. Generated as pixel-art sheets, then
5// cut out, shrunk to the grid and quantised (scripts kept outside the mod).
6// The street crew and the corporation are fan homages to Cyberpunk: Edgerunners
7// and Cyberpunk 2077 (CD PROJEKT RED / Studio Trigger). No logos are drawn.
8//
9// A sprite is rows of letters, one per pixel; `.` is empty. `t` takes the tint it
10// is drawn with (its role's colour). Letters named in `cls` move on their own:
11// `hair` sways, `vis` (a visor, a screen, a cyber-eye) flickers, `led` pulses.
12// The rows under the gap between the legs step left and right while it works.
13
14export type Sprite = {
15 rows: string[]
16 pal: Record<string, string>
17 cls?: Record<string, 'hair' | 'vis' | 'led'>
18 /** Hovers instead of walking. */
19 float?: boolean
20}
21
22export type Cast = {
23 name: string
24 /** What the band calls the boss in place of "Claude". */
25 bossName: string
26 boss: Sprite
27 /** The main characters, by the crab costume a role would have worn. */
28 roles: Record<string, Sprite>
29 /** The workers: one is picked per role, so a role's agents look alike. */
30 crew: Sprite[]
31}
32
33const AGENT: Sprite = {
34 rows: [
35 "......vqq.......",
36 ".....qqqqq......",
37 ".....qququq.....",
38 ".....qqqqut.....",
39 ".....qaacl......",
40 ".....eaaak......",
41 "....uvaaavq.....",
42 "....ulqqqluq....",
43 "....quqqqquq....",
44 "..qhquuquqqqq...",
45 "..uuuudddquqqu..",
46 ".qjqhqddduhqqj..",
47 ".juqqqsdsuhquj..",
48 ".huqquuhhhqquu..",
49 ".huvqqsssuqtuh..",
50 "uqqvqqpppqutqu..",
51 "uhhqqqq..qqqquu.",
52 ".qhhqqq..qqvhhq.",
53 "..qqqqqqqqqqhh..",
54 "...tqqqqqvqquq..",
55 "...vvuqttsvuq...",
56 "...qqvqqqqtqu...",
57 "...uuuqqqqsqt...",
58 "..qhuuuquroqq...",
59 "..qhqquvqqidq...",
60 "..uhqquthqugq...",
61 "..uuqqqvqqqqqq..",
62 "..ufqqqvvvqqvq..",
63 ".uuq..qvq..qqs..",
64 ".uuq..vqv..qqqd.",
65 ".uuq..vqv..ufqq.",
66 ".unqqqvqvquqtutd",
67 ".unnqqvqqqqqtuqt",
68 "uhthqqqqqqquthqq",
69 "uhqqqqqqqquqvhq.",
70 ".uquuqqhvquuq...",
71 "..uuu....qhuu...",
72 "..quu.....uuu...",
73 "..qqq.....qqq...",
74 "..tqt.....ttt...",
75 "..qqt.....qqv...",
76 "..qq.......qt...",
77 "..qq.......qu...",
78 ".tqq.......qt...",
79 ".uuv.......quuq.",
80 "qqqt.......mqqb.",
81 ],
82 pal: { a: '#FECDAF', b: '#0F0F15', c: '#E51C28', d: '#52505D', e: '#D38971', f: '#981A28', g: '#51212C', h: '#272731', i: '#683243', j: '#C4232E', k: '#4D3C43', l: '#25141C', m: '#58121D', n: '#AF1F2D', o: '#3A212A', p: '#45444F', q: '#1E1E27', r: '#553443', s: '#4B4B56', t: '#14141C', u: '#25212B', v: '#1A1922' },
83 cls: { c: 'vis' },
84}
85
86const CHOOM: Sprite = {
87 rows: [
88 ".............",
89 ".............",
90 ".............",
91 ".............",
92 "...kkkkkvvv..",
93 "...vbrrrrbv..",
94 "..vvrarrrbvt.",
95 "..vvrarrabvd.",
96 "..vvrrrrrbkc.",
97 "...vbrrrrbv..",
98 "...vvvvuvv...",
99 "...lsliise...",
100 "..hlffsllst..",
101 ".thlffnffitt.",
102 ".gclffffflhq.",
103 ".dqlfffffetd.",
104 "ccqlfffflechj",
105 "ddcsiffliecdh",
106 "cj..ullpuuukc",
107 ".....uuuu....",
108 ".............",
109 ".............",
110 ".............",
111 "...mu...hh...",
112 "...vv...vv...",
113 "...uu...uu...",
114 "..ooo...ogo..",
115 "..hmc...cmh..",
116 ],
117 pal: { a: '#43F8FA', b: '#1B2038', c: '#7F8298', d: '#B8BCC8', e: '#2B5999', f: '#4A91DC', g: '#3F445B', h: '#9A9EB3', i: '#3B73B6', j: '#656B84', k: '#515672', l: '#4382C9', m: '#A8ADBC', n: '#34EBF6', o: '#434B69', p: '#3261A3', q: '#878BA0', r: '#1C2544', s: '#336BB2', t: '#B0B3C2', u: '#23283A', v: '#4B506C' },
118 cls: { a: 'vis', n: 'vis' },
119}
120
121const CYBORG: Sprite = {
122 rows: [
123 "......ppp.......",
124 ".....ppppi......",
125 ".....ppppui.....",
126 ".....bfmilq.....",
127 ".....vffluq.....",
128 ".....ffffi......",
129 ".....pbffp......",
130 ".....jbbjbp.....",
131 ".....pppppppnu..",
132 ".....pp....ikkl.",
133 "......p....plkt.",
134 "......ppp..ppts.",
135 "......ppp..puak.",
136 ".....pjpppppiuu.",
137 "..ppppppppjppio.",
138 "..pppppjjjjjjqpu",
139 "ffffppppjppppsnk",
140 "ffffpppddppppinu",
141 "fffpppppdddrjsno",
142 "jffppppppppbppns",
143 "pp..ppj..ppppppq",
144 "pj..jjj......nku",
145 "pj....ppp....nni",
146 "pbbp..ppppppppp.",
147 ".fppjjjpppkiiig.",
148 "..ppjpp..uuku...",
149 "..jpppp..siul...",
150 "..jppjp..iiis...",
151 "..ppjjj..qiis...",
152 "..ppppp..olnl...",
153 ".....pp...unk...",
154 ".....p....qtt...",
155 "...pjp....stc...",
156 "...ppp....skcu..",
157 "....jj....skks..",
158 "....pp....tkk...",
159 "..pjjp.....kk...",
160 "..pjjj.....ou...",
161 "...ppp.....lk...",
162 "...pp......sk...",
163 "...pp......ppe..",
164 "...pp......ckk..",
165 "..pjp......ppp..",
166 ".jjpp......pjjp.",
167 "pojp.......pjhp.",
168 ],
169 pal: { a: '#F8F7F7', b: '#0C0A0E', c: '#807D8B', d: '#F5C642', e: '#9E0F1C', f: '#FCC3A4', g: '#CF7F6A', h: '#4D4E59', i: '#B0AEB7', j: '#282932', k: '#D1CFD3', l: '#9995A3', m: '#DB9B83', n: '#E4E3E5', o: '#6D697D', p: '#1A1A20', q: '#5A576D', r: '#E8B53B', s: '#8B8897', t: '#A3A1AC', u: '#C7C4CB', v: '#F6B79A' },
170 cls: { d: 'vis', r: 'vis' },
171}
172
173const DAVID: Sprite = {
174 rows: [
175 "...............",
176 ".....pppppp....",
177 ".....pppppp....",
178 ".....epppe.....",
179 ".....efpfe.....",
180 ".....fffff.....",
181 ".....fffff.....",
182 "....kddffdk....",
183 "...nllsosllu...",
184 "...nnlbfblnv...",
185 "...nibbbbbvnn..",
186 "..nninbbbninn..",
187 "..cttcbbbcttjn.",
188 ".hjtttbbbtttjn.",
189 ".jntttbbbtttrt.",
190 "ntjitnbbbntijtn",
191 "njiinhbbbjrvirr",
192 "nncvnnbbbnnvnnn",
193 "nnnvunbbbnnvnnn",
194 ".nnvttbbbttvnn.",
195 ".nnttnbbbnttcn.",
196 "..vtrnbbbnntm..",
197 "..jnnnbbbnnur..",
198 "..nccnqqqnccc..",
199 "....qgggggg....",
200 "....ggggggg....",
201 "....gggqggg....",
202 "...gggggggg....",
203 "...gggqqqggg...",
204 "..gggggqgggg...",
205 "..gggggggggq...",
206 "..gggg..ggg....",
207 "...ggg..ggg....",
208 "...ggg..ggg....",
209 "...ggg..gggg...",
210 "..gggg...gggg..",
211 "..ggg....gggg..",
212 "..ggq...ggggg..",
213 ".gggg...ggggq..",
214 ".gggg...gqgg...",
215 "..gggg..qggq...",
216 "..gggg...ggg...",
217 "..grrr...grr...",
218 "..aari...rraj..",
219 "..aarj...jraa..",
220 "..jij.....jij..",
221 ],
222 pal: { a: '#FAFAFA', b: '#1C1C21', c: '#E6B20C', d: '#4492DA', e: '#9C8174', f: '#F3BB99', g: '#35466A', h: '#F6D954', i: '#AEACAE', j: '#D4D4D4', k: '#C8B66C', l: '#5BBEF6', m: '#C48C24', n: '#FBD427', o: '#C88369', p: '#452619', q: '#202C45', r: '#CBC5B5', s: '#DB9B7B', t: '#E8E7E7', u: '#D0C388', v: '#D49B0F' },
223 cls: { d: 'vis', l: 'vis', v: 'vis', c: 'vis', h: 'vis' },
224}
225
226const DORIO: Sprite = {
227 rows: [
228 "..........jj........",
229 "........jjjjj.......",
230 "........jjjjj.......",
231 "........jhhhj.......",
232 "........jhhhj.......",
233 "........jhhhi.......",
234 ".......cuddkiq......",
235 ".......qosssqc......",
236 ".......chhhhhv......",
237 ".......chhhhhcv.....",
238 ".......chhhhhcv.....",
239 "........hhhhh.......",
240 "........sshso.......",
241 "........dhshs.......",
242 "..qq....sssss.......",
243 ".btr....sssss...rnl.",
244 ".lee....shsss...egl.",
245 ".brr....agmaa...rlbl",
246 "rlrt............trlr",
247 "rlr..............rrr",
248 "llt.....avva.....frr",
249 "rl......vaaa......rl",
250 "rl..hhhhaaashhhh..lr",
251 "rlr.hhhhs..shhhh.llr",
252 "rer.srrrs..ssrrh.rmr",
253 ".ll.frrro..sorrf.ll.",
254 ".rm.frrro..orrrf.er.",
255 "....frrr....rrrf....",
256 "....rfrr....rrrr....",
257 "....pppn....nree....",
258 "....gfg......gfg....",
259 "....gmg......mgg....",
260 "....rme......egr....",
261 "...rrpf......terr...",
262 "...rrff......frrf...",
263 "...trrf......frrf...",
264 "...tftn......ttft...",
265 "...tft........tft...",
266 "...tft........tft...",
267 "...ggm........ggm...",
268 "...gg.........ggm...",
269 "...rr.........ffe...",
270 "...ntg........ftt...",
271 "...ffm........nff...",
272 "..ftfm........mff...",
273 "..ffte........fftt..",
274 "..gnne........nrpg..",
275 "..gnga........vnmg..",
276 ],
277 pal: { a: '#1C1C24', b: '#F8F8F7', c: '#4D8CC5', d: '#D1826D', e: '#565867', f: '#BCBABC', g: '#888790', h: '#F4BA93', i: '#265B99', j: '#F7E4BC', k: '#1A3259', l: '#DCDCDC', m: '#6F6E7D', n: '#A09FA5', o: '#CD9E88', p: '#404252', q: '#3C75AD', r: '#CDCCCB', s: '#E5A988', t: '#ADADB1', u: '#193B6D', v: '#272732' },
278}
279
280const DRONE: Sprite = {
281 rows: [
282 ".eips................ffll",
283 ".bb..................bb..",
284 ".ku.......ddv........kq..",
285 ".uu.......ooj........uuu.",
286 "gsuugqg...ddo....usuugsq.",
287 "kkqqkqg..ouuuu...qqqqkk..",
288 ".qq..ug..qmnnu...u..qq...",
289 ".....qg.kgpathq..q.......",
290 ".....qu.kgprthq..q.......",
291 ".....qu..ucccuqquu.......",
292 ".........qqqqqqqu........",
293 ".........ss..smgu........",
294 ".........um..uuu.........",
295 ".........kuuukk..........",
296 ".........quuuk...........",
297 "..........ukqk...........",
298 ],
299 pal: { a: '#FDCEC8', b: '#0C0C0F', c: '#FB1F21', d: '#5A5869', e: '#FA887F', f: '#42131E', g: '#21212C', h: '#DC0E13', i: '#FB383A', j: '#484655', k: '#1B1A20', l: '#3A1C26', m: '#2A2931', n: '#E60E13', o: '#4C4A5B', p: '#F62326', q: '#201C26', r: '#FE8985', s: '#28252C', t: '#F51E20', u: '#212127', v: '#585566' },
300 cls: { i: 'vis', p: 'vis', n: 'vis', t: 'vis', h: 'vis', c: 'vis' },
301 float: true,
302}
303
304const FALCO: Sprite = {
305 rows: [
306 "......ssss.....",
307 ".....ssssss....",
308 ".....ssrrss....",
309 ".....sggggs....",
310 ".....bgggrs....",
311 ".....sbrrbs....",
312 "......pttpp....",
313 "......ukkus....",
314 "..aqjjukkuskkk.",
315 "..afjsuuuusukk.",
316 "..qqujuuuuuukk.",
317 "..qnujuuuujukk.",
318 ".qqnuuuuuujjkkk",
319 ".qqnjuuuuuujlkk",
320 ".hhhsjuuuuuskkk",
321 ".qfdjuuuuuuulkk",
322 "qqffcjuuuuujvkk",
323 "fqffbcccccssskk",
324 "fqd..ssbsjsbbkk",
325 "nq...sssssbbbrg",
326 "fd..sssssssbbrg",
327 "qqf.sss....bbrr",
328 "fmq.sss....bbrr",
329 "qqq.ssssssssbrr",
330 "qq..sss..sss...",
331 "....sss..sss...",
332 "....sss..sss...",
333 "....sss..sss...",
334 "....sss..sss...",
335 "....sss..sss...",
336 "....ss....ss...",
337 "....ss....ss...",
338 "....ss....sss..",
339 "...sss....sss..",
340 "...sss....sss..",
341 "...ss......ss..",
342 "...........sss.",
343 "...........sss.",
344 "..sss......sss.",
345 "..sss......sss.",
346 "..ses......ses.",
347 "..ooes....eeoe.",
348 ".oiooe....eeooo",
349 ".ooob......boos",
350 ],
351 pal: { a: '#F4F4F5', b: '#291B1B', c: '#E2A32B', d: '#95979E', e: '#7F5A42', f: '#C6C6C8', g: '#D89B74', h: '#414252', i: '#AE7F5A', j: '#533926', k: '#EDDFCA', l: '#C8B6A1', m: '#595867', n: '#B0B2B7', o: '#976C4E', p: '#DFCEB8', q: '#E2E3E3', r: '#CC8A64', s: '#3D2825', t: '#B16D4C', u: '#5D4038', v: '#D3C3AC' },
352 cls: { c: 'vis' },
353}
354
355const GANGER: Sprite = {
356 rows: [
357 "............",
358 "....fff.....",
359 "...ffffff...",
360 "...fffffff..",
361 "..qfffffft..",
362 "..qqtlflit..",
363 "...irdddg...",
364 "...oooooo...",
365 "...hhbboh...",
366 "...hhbbhh...",
367 "..hhhocchh..",
368 ".jhhhjjhhhj.",
369 ".jhhhhhhhhh.",
370 ".hhhhhhhhhh.",
371 "jhhhhjjhhhhj",
372 "jhhhhjjhhhhj",
373 "...hhjjhho..",
374 "...hhcccho..",
375 "....oooooo..",
376 "....sssss...",
377 "...ssooss...",
378 "..ossooss...",
379 "..sssooss...",
380 "..oss..sso..",
381 "..sss...ss..",
382 "............",
383 "............",
384 "............",
385 "..kk....pm..",
386 ".eaun...vaa.",
387 ".aaam...aaa.",
388 ],
389 pal: { a: '#FCFCFC', b: '#1B1C24', c: '#E29D1D', d: '#59F8F9', e: '#868B99', f: '#FB3E8B', g: '#F4B79C', h: '#53406C', i: '#B72169', j: '#F9CC41', k: '#E4DCCF', l: '#233C59', m: '#6C6F7E', n: '#E5B339', o: '#2B203E', p: '#F5E1B9', q: '#E43580', r: '#25506A', s: '#3C4563', t: '#CC2C71', u: '#545868', v: '#5D6272' },
390 cls: { q: 'vis', d: 'vis', c: 'vis', n: 'vis' },
391}
392
393const GUARD: Sprite = {
394 rows: [
395 ".......................",
396 ".............es........",
397 ".........meeeem........",
398 ".........msaajm........",
399 ".........ussssm........",
400 ".........tmsssm........",
401 ".......cctbssssmn......",
402 "...eioscclsbbsmccsii...",
403 "...ddqqlimmssmsnvvvi...",
404 "..mqqsmsss....ssmmq....",
405 "..lmustttt......mms....",
406 "..iselsllq......muuss..",
407 ".ssessssssssssesmtsso..",
408 ".svvtsqsssumssmsmtssii.",
409 "ssrooqbtsmmfsmssmvsssm.",
410 "mmseeeqsqssqstsmllvskmm",
411 "vummsmseqqssppmtvmsssss",
412 "..vvvvtqemtt...bbmeesmh",
413 "......vvutut...ggstsml.",
414 "......musuqq..mtqqftt..",
415 "......vvultttttmmmmmm..",
416 "......msssmtbmmvmumss..",
417 "......vssesvssssumtls..",
418 "......msesmsessesvv....",
419 ".....mmsesshhseessm....",
420 ".....qvseesvmsesesq....",
421 ".....mmseesvvssssmm....",
422 ".....ssseessvssemmm....",
423 ".....qmsesm..usessq....",
424 ".....vssssh..vssssv....",
425 ".....msmseu..sssvsm....",
426 "......msmm....hssm.....",
427 "......mmms....mmsm.....",
428 ".....smumm....mmmms....",
429 ".....esuss....sshse....",
430 ".....eeses....meses....",
431 ".....meesv....vsesh....",
432 ".....seemh....hsees....",
433 ".....sseeu....hesss....",
434 ".....mqus......mmmv....",
435 ".....misu......sqqm....",
436 ".....vsu.......vmmm....",
437 ".....msm.......vmss....",
438 "....mmssl......vmss....",
439 "....sssml......lmsss...",
440 "....ssmt.......ssses...",
441 ],
442 pal: { a: '#FB1B1D', b: '#09090D', c: '#5C5B6A', d: '#543236', e: '#24242D', f: '#434451', g: '#E6080A', h: '#16161E', i: '#524E5C', j: '#F70507', k: '#DD1215', l: '#101015', m: '#1D1C26', n: '#565564', o: '#4B4A56', p: '#E51212', q: '#282833', r: '#E10D11', s: '#212029', t: '#0C0D11', u: '#1A1A21', v: '#131419' },
443 cls: { a: 'vis', j: 'vis', r: 'vis', k: 'vis', p: 'vis', g: 'vis' },
444}
445
446const HANDLER: Sprite = {
447 rows: [
448 ".....nnn.....",
449 "....nnvin....",
450 "....snnnn....",
451 "....pffff....",
452 "....cffff....",
453 ".....ffff....",
454 ".....ffff....",
455 ".....utjv....",
456 ".....aku.....",
457 ".....ukj.....",
458 ".....jqj.....",
459 "......kqerrre",
460 "......kvsssom",
461 ".....lnvvvssn",
462 "...nffvvvvssv",
463 "...afvvvvvfff",
464 "...nvnvvvvlhi",
465 "...niiniinbnn",
466 "......n..vn..",
467 "......b...n..",
468 "......nn..v..",
469 "......vv..i..",
470 "..n...vvnnn..",
471 "......nn.....",
472 ".......v.....",
473 ".......n.....",
474 ".............",
475 ".............",
476 ".............",
477 ".............",
478 ".............",
479 ".............",
480 "..vvv....vv..",
481 "..vvv....vv..",
482 ".............",
483 ".............",
484 ".............",
485 ".............",
486 ".............",
487 "..vv.........",
488 "..bv......nn.",
489 "..nn......nn.",
490 ".nvb.....bnn.",
491 ".vnb.....bnnn",
492 "bdng......bnb",
493 ],
494 pal: { a: '#F8F7F6', b: '#12121A', c: '#FA826F', d: '#A1212A', e: '#4F3C4A', f: '#FDCCAD', g: '#52121C', h: '#E29F83', i: '#292833', j: '#E4E1E5', k: '#83202A', l: '#F6B99B', m: '#37131D', n: '#1E1D26', o: '#521E2A', p: '#3B1F29', q: '#92202A', r: '#5B1623', s: '#2A1F2B', t: '#DE977D', u: '#E8E7EA', v: '#22222C' },
495 cls: { c: 'vis' },
496}
497
498const JOHNNY: Sprite = {
499 rows: [
500 ".....ttttt.....",
501 "....ttttttt....",
502 "....ttdmdtt....",
503 "....ttlmttt....",
504 "....tttdttt....",
505 "....tbdmdtt....",
506 "....tbnnnbt....",
507 "....lbdndbt....",
508 "....ttdddlthh..",
509 "..mdlldlltttqa.",
510 ".mmm...t..lttf.",
511 ".ket...s..lttt.",
512 ".vjtttlfflttae.",
513 ".mmtttttttttaq.",
514 ".kdtltttlttttc.",
515 ".mddttltltllttt",
516 "dmppttttltttttt",
517 "ddddtlltllttrat",
518 "ggddttttttttrao",
519 "dddtttttttt..ac",
520 "kddttltttlt..ic",
521 "nmmtttltllttttt",
522 "dmmteltttlttoto",
523 "ppmteltttllthto",
524 "pmmtlttttttttfu",
525 "...tltttttlt...",
526 "...tltttttlt...",
527 "...tlt..ttlt...",
528 "...ttt..ttlt...",
529 "...ttt..ttlt...",
530 "...tlt...tlt...",
531 "..tttt...tlt...",
532 "..tttt...ttt...",
533 "..tltt...tttt..",
534 "..tttt...tttt..",
535 "..ttt....tttt..",
536 "..ttt....tttt..",
537 "..ttt....tttt..",
538 "..ttt....tttt..",
539 "..bbt.....ttb..",
540 "..btt.....ttb..",
541 "..ttb.....ttt..",
542 "..ttb.....ttt..",
543 ".tttb.....ttt..",
544 "ttttt.....tttt.",
545 "tttt.......ttt.",
546 ],
547 pal: { a: '#F9FAFB', b: '#13141A', c: '#71869E', d: '#EAB18C', e: '#4C4C5B', f: '#BBC4D2', g: '#9E8071', h: '#9CA2B1', i: '#576981', j: '#C89980', k: '#7E6862', l: '#242835', m: '#F7C39D', n: '#6B5955', o: '#8992A3', p: '#E0A17B', q: '#859FB8', r: '#4E586E', s: '#B4B8C5', t: '#1C1D28', u: '#686D80', v: '#6C6263' },
548}
549
550const KIWI: Sprite = {
551 rows: [
552 "....aaaa....",
553 "....aaaaa...",
554 "...aaaaaa...",
555 "...aarrra...",
556 "...arjjrl...",
557 "...lfkjfe...",
558 "...apfjpo...",
559 "...uppqsh...",
560 "...hd..sh...",
561 "..moo..ohmm.",
562 "..hhh..ooum.",
563 ".mhco..ochm.",
564 ".mhhs..dhhh.",
565 ".uuhd..qosm.",
566 ".ushh..uhuu.",
567 ".hsdh..hvdh.",
568 ".mvuh..hhdm.",
569 ".mvhm..uudm.",
570 ".mduu..uudu.",
571 "huouu..puhmu",
572 "huou....uhsh",
573 "udhu....uhdm",
574 "rruuhqq.uuii",
575 "rrhuuqq.uurr",
576 "rshuuqt.uudr",
577 ".duuutt.uud.",
578 ".duuutt.uuv.",
579 ".huuutt.uuh.",
580 ".ouuutt.uuh.",
581 ".vuuhtttuuo.",
582 ".vuuhtttuuo.",
583 ".ouuhttqhuh.",
584 ".ouohttqhuh.",
585 ".vuoqttqouuo",
586 "ohuoogtthuuh",
587 "ouuhhgggouuh",
588 "duuhgggtouuu",
589 "huuoggggouuh",
590 "hhuoggggohuh",
591 "huvvggggqohh",
592 ".uvb....bvh.",
593 "..bb....bb..",
594 "..bq....qb..",
595 "..bq....qb..",
596 "..qqb...qq..",
597 "..qqb...qq..",
598 "..qqb...qq..",
599 "..qb....qqn.",
600 ],
601 pal: { a: '#F5E0B8', b: '#1A1D28', c: '#C43440', d: '#721D2B', e: '#D3B788', f: '#9C2257', g: '#451524', h: '#9B2131', i: '#F9C2A4', j: '#B72A65', k: '#501E3C', l: '#E1C797', m: '#B02A38', n: '#642B36', o: '#862232', p: '#581B29', q: '#282732', r: '#FCCEB2', s: '#661929', t: '#391521', u: '#A42836', v: '#7D1D2D' },
602}
603
604const LUCY: Sprite = {
605 rows: [
606 ".....jjjj....",
607 "....jjjjj....",
608 "....jjjjbj...",
609 "...jjhhhjj...",
610 "...jjhhhju...",
611 "...ujhhhjl...",
612 ".....gggj....",
613 ".....nmpp....",
614 "...hapddahh..",
615 "..hh.....hh..",
616 ".vmm.....omm.",
617 ".oooommmoooo.",
618 ".oooommvbooo.",
619 ".ooeommmoeoo.",
620 "oqoovmmmvvooo",
621 "oooqhmmmhhqoo",
622 "ooehhmmmhheeo",
623 "ooqhcmmmchooo",
624 "seecccvcrccee",
625 ".oocccccccco.",
626 ".hrcckkcccrh.",
627 ".hccccfccchh.",
628 "...ccctcccch.",
629 "...ccccccc...",
630 "...kccccck...",
631 "...ccccccc...",
632 "...ccccccc...",
633 "...ccccccc...",
634 "...cccckcc...",
635 "...ccc..cc...",
636 "...ccr..cc...",
637 "..cccc..cc...",
638 "..cccc..cc...",
639 "...cc...cc...",
640 "...cc...cc...",
641 "...ct...kk...",
642 "...dd...ddd..",
643 "...aa...aia..",
644 "...ia...vi...",
645 "...iv...vi...",
646 "...av...av...",
647 "...vv...vvv..",
648 "..avv...ava..",
649 "..ddd...ivd..",
650 ],
651 pal: { a: '#1A1B28', b: '#FAFAFB', c: '#897E9F', d: '#9E2637', e: '#C3B8D0', f: '#4D4366', g: '#D48B73', h: '#FDCFB4', i: '#541E2A', j: '#E0D3EC', k: '#655981', l: '#B594D5', m: '#25283E', n: '#832535', o: '#EBEBEB', p: '#642738', q: '#D1C6DA', r: '#7B7196', s: '#E0DFE3', t: '#554A70', u: '#BD9EDB', v: '#1E2134' },
652}
653
654const MAINE: Sprite = {
655 rows: [
656 "............gjj.............",
657 "............gjjj............",
658 "............ggjj............",
659 "............urrr............",
660 "............qqqq............",
661 "..........hikmrroo..........",
662 "........hkooqmrooooo........",
663 ".....ffqqokoooooooooiff.....",
664 "....fffpiqokoookoooiifff....",
665 "...cffcpppqokkkooopppcfff...",
666 "...cfccpippqoooooppppccfff..",
667 "..cffciqppppqoooppppqiqcfc..",
668 "..cfqqqqppppqqoqippqqqqqfc..",
669 "...qdaaqqqppqqqqiiqqqaadqq..",
670 "...daaaqqqppqqqqpiqqqaaavqj.",
671 ".svvaallqpppqqqqpppqqlaavvj.",
672 "sttvqq..qpppqqqqppiq..qqvats",
673 "dttadd..qqiiqqqqppiq..ddqadd",
674 ".dtaaa...qqqmmmqqqi...iaatdd",
675 ".dtaan...iqqqmmqpqq...naatvv",
676 "ddaann...pqqqqqqqqp...nnjadd",
677 "ddaaa....pppqqqqpppp...dtadd",
678 "ldqqd...eppppqqpppqp...vqavd",
679 "jjaaq...ejjppqqpppps...paaj.",
680 ".jaaa..eej..q..q..jas..paaj.",
681 ".jaqb..ejj........jjs..bqjj.",
682 ".sddd..ejj........jje..dvqj.",
683 "..vv...djjjq....qjjjd.......",
684 ".......vvqjq....qjjvv.......",
685 "......jvvqv......dqvvj......",
686 "......jjvvd......dq..j......",
687 "......jjjqd......dq..j......",
688 ".....ejeeje......njeejj.....",
689 ".....sjejj........ttejj.....",
690 ".....ijjjq........qjjj......",
691 ".....ijjjiq......qijjj......",
692 "......jjjdd......dijjj......",
693 "......jjjd........djjj......",
694 "......vvjd........djvv......",
695 ".....vvvje........sjvvj.....",
696 ".....bbqje........ejbbb.....",
697 ".....qqdbs........ebbqq.....",
698 "....qpppq.........qqqdd.....",
699 "....vvqqq.........qqqqv.....",
700 ".....qqiqq........qqqq......",
701 ".....qqiiq........qi........",
702 ],
703 pal: { a: '#D5D0CA', b: '#10131A', c: '#A46B41', d: '#4A4A56', e: '#999797', f: '#D19258', g: '#807A6A', h: '#7E443B', i: '#34322D', j: '#B8B4AF', k: '#5C3435', l: '#63626E', m: '#6A5A50', n: '#888587', o: '#99503D', p: '#44423D', q: '#202228', r: '#966A55', s: '#A9A5A3', t: '#C7C2BC', u: '#624945', v: '#575661' },
704 cls: { f: 'vis' },
705}
706
707const NEON: Sprite = {
708 rows: [
709 ".....kkkk....",
710 "....kskdkk...",
711 "...kkkkkss...",
712 "...kkrkrkk...",
713 "...dkuuukps..",
714 "..spkuuuups..",
715 "..spkeammps..",
716 "...vqvhmqv...",
717 "...qqbbbvq...",
718 "..fqqqqqqqi..",
719 "..qqqbbbqqq..",
720 ".rqqfvubvqqi.",
721 ".qqqmuuumvqq.",
722 "iqqvbbgbbvqqr",
723 "qqqbbbbbbvqqq",
724 "vqvb....bbmqv",
725 ".fmm....bbbf.",
726 ".abln...noua.",
727 "...uuh.nuoou.",
728 "...uaa.nuu...",
729 "...uau.nuu...",
730 "...nu...uu...",
731 "...nu...hu...",
732 "...un...nu...",
733 "...au...uu...",
734 "..bjb...btb..",
735 "..bvb...bbb..",
736 "...mb...bb...",
737 "...qb...bq...",
738 "...bb...bb...",
739 "..bbb...vcb..",
740 ],
741 pal: { a: '#F7C7AC', b: '#1C1F2D', c: '#E7345A', d: '#F4AF26', e: '#AB6312', f: '#821B3D', g: '#4C5068', h: '#D18370', i: '#FA3BAB', j: '#FA7FBA', k: '#FEDA55', l: '#9F6D65', m: '#520F2A', n: '#E69F86', o: '#672A52', p: '#E3941A', q: '#A32143', r: '#E62A9B', s: '#FBC43A', t: '#F79CB7', u: '#F4AF98', v: '#6A1534' },
742 cls: { s: 'vis', d: 'vis', r: 'vis', p: 'vis', i: 'vis', c: 'vis' },
743}
744
745const NETRUNNER: Sprite = {
746 rows: [
747 "..............",
748 "..............",
749 "......oo......",
750 ".....bskb.....",
751 "....bnllno....",
752 "....ooaabk....",
753 "....mbeebm....",
754 "...umbsmoms...",
755 "...mpsmmoqm...",
756 "....pgmmmgj...",
757 "....ffmmmct...",
758 "....iimmmpt...",
759 "....ggmmmtvk..",
760 "....nnmmmppk..",
761 "....ccsmmpps..",
762 "....ccsssppm..",
763 "......msshhm..",
764 "......mso.....",
765 "......sso.....",
766 ".....ommo.....",
767 "..mmmommkkmm..",
768 "..cmsm..mmkv..",
769 "..sm......mh..",
770 "......mm......",
771 "......mm......",
772 "......ss......",
773 "......mm......",
774 "..............",
775 "..............",
776 "..............",
777 "..............",
778 "..............",
779 "..............",
780 "..............",
781 "..............",
782 "..............",
783 "..............",
784 "..............",
785 "..mmk....km...",
786 "..mmk....mm...",
787 "..mm......mk..",
788 "..mg......gm..",
789 "mmmm......mmmm",
790 "rrsm......mmrd",
791 ],
792 pal: { a: '#FCCBAA', b: '#08080B', c: '#02E6F3', d: '#484957', e: '#CB7B6D', f: '#20B2CC', g: '#252832', h: '#07C7E0', i: '#21F5F8', j: '#03B0CA', k: '#15151D', l: '#08DDE6', m: '#1C2027', n: '#0CD1E6', o: '#0F0F14', p: '#0EE6F5', q: '#0CB0C8', r: '#444451', s: '#191A22', t: '#04DEED', u: '#1E2935', v: '#05CFE5' },
793 cls: { n: 'vis', l: 'vis', p: 'vis', c: 'vis', t: 'vis', i: 'vis', v: 'vis', h: 'vis' },
794}
795
796const PILAR: Sprite = {
797 rows: [
798 ".................",
799 ".......ppb.......",
800 ".......bbb.......",
801 ".......bbb.......",
802 ".......hhh.......",
803 "......edtdq......",
804 "......rmmmr......",
805 ".......lull......",
806 ".......blbp......",
807 "......bbgrb......",
808 "......ppmpb......",
809 "...hh.......hh...",
810 "...hr.......mh...",
811 "...hh.......pm...",
812 "...hhp......bh...",
813 "...hhp.......h...",
814 "..hhhb.......hm..",
815 "..hrrb.......rh..",
816 "..hmmp.......mh..",
817 ".............kkk.",
818 ".mnl..bojjpp.lur.",
819 ".mac.ffvvffv.rar.",
820 "munl.vvfffff.lumr",
821 "mauuuffvfffffuuur",
822 "runnnffjjffffuuuk",
823 "rnuujfffffffjamul",
824 "mnnnjffvvfffjmmur",
825 "iuulfffvvvfffrrur",
826 ".uuuffffffffffsl.",
827 "....ffff..ffffll.",
828 "....fffj..fff....",
829 "....fff...fff....",
830 "....fff...fff....",
831 "....ffj...off....",
832 "....fff...jvfo...",
833 "...ffff...fffj...",
834 "...fffj...jfff...",
835 "...fffj...jfff...",
836 "...ffjj...jfff...",
837 "...fjjj...ffvf...",
838 "...ffjj...fjfv...",
839 "...jffj...jfvj...",
840 "...ffvj...jvffj..",
841 "...kkv....jvrrv..",
842 "...ruk.....ruuu..",
843 "..nnlr.....rrnn..",
844 "..lllm.....mlll..",
845 ],
846 pal: { a: '#F8F8F8', b: '#1C1D27', c: '#898B92', d: '#F6C665', e: '#BA7C24', f: '#3B5076', g: '#C1BDC1', h: '#DCDCDB', i: '#6D707B', j: '#2A3855', k: '#ACACAD', l: '#9A9BA0', m: '#CFCECC', n: '#EBEAEA', o: '#334264', p: '#282731', q: '#B47C34', r: '#B6B6B7', s: '#7F8388', t: '#F1BC5A', u: '#E3E3E3', v: '#384A6C' },
847 cls: { d: 'vis', t: 'vis' },
848}
849
850const REBECCA: Sprite = {
851 rows: [
852 "...............",
853 "....ubbbbeuu...",
854 "...ucbcccbkcu..",
855 "...cckuccuucu..",
856 "...ukraaackku..",
857 "...ukkaaauuuu..",
858 "......bbbb.....",
859 "......bbbb.....",
860 "......bbb......",
861 "...eb..e.......",
862 "...eb..p..b....",
863 "....b..b..b....",
864 "....b..b..b....",
865 "....b..e..bb...",
866 ".bbbb..b..bbbb.",
867 "dddbb..e..ebbit",
868 "bvqhb..b..bbmtf",
869 "ddqhbbbbbbbfmmt",
870 "ddbbbllellbbbtt",
871 "vdqqaaaasaaamij",
872 "dbbqaaaaaaammbt",
873 ".dnnaaaaaaaattm",
874 ".bnnaaa..aaatf.",
875 "....aa...aa....",
876 "....aa...ss....",
877 "....as...sa....",
878 "...aaa...saa...",
879 "...aaa...aaa...",
880 "....a.....aa...",
881 "....a..........",
882 "....b..........",
883 "....bb.........",
884 "...bbb....bb...",
885 "...bbb....bb...",
886 "...bbb.........",
887 "..oaag...gaao..",
888 ],
889 pal: { a: '#FDDECF', b: '#191B29', c: '#0FE2B8', d: '#E12641', e: '#94E152', f: '#206A9D', g: '#AFABA3', h: '#840F26', i: '#4EB5E8', j: '#10416E', k: '#03A19F', l: '#EAAEA2', m: '#218ACA', n: '#511B28', o: '#D0CCC3', p: '#B6F26D', q: '#B20F2B', r: '#0A8488', s: '#F4C5BA', t: '#37A0DB', u: '#04C3B1', v: '#690D25' },
890 cls: { e: 'vis', c: 'vis', d: 'vis', i: 'vis', t: 'vis' },
891}
892
893const SABURO: Sprite = {
894 rows: [
895 ".....iii.....",
896 "....shhhh....",
897 "....hhhhh....",
898 "....mohohe...",
899 "....hhhhhe...",
900 "....hhhhh....",
901 "....hhhhh....",
902 "....lqkcl....",
903 "..fflgcqlrr..",
904 ".ffrlqgglrrr.",
905 ".frrlggglrrr.",
906 ".ftrlggglrlf.",
907 "fftrrqqglrtf.",
908 "frrrrqgglrrrr",
909 "rrttlguglttfr",
910 "rrrttpvqltlrr",
911 "rrrrtfgjtffrr",
912 "rrrrrrqlrrrrr",
913 "rrrrrrprrrrrr",
914 ".rrrrhhhrrrr.",
915 ".rrbbhhhtrrr.",
916 ".trbtfffttrt.",
917 ".ttrtjfplrtt.",
918 ".lrrtqjqlrrl.",
919 ".rrrlqqqlrrr.",
920 ".rrrlqqqlrrr.",
921 ".rrrrqqjrrrr.",
922 "..ttjqqjptt..",
923 "..lljjqqjll..",
924 "..lqqqqqqql..",
925 "..qqqqqqqqq..",
926 "..qqqqqqjqq..",
927 "..jqqqqqjqq..",
928 "..pqqqqqjjq..",
929 "..jqqqqqqpq..",
930 "..qqpqqqqjj..",
931 "..qqpqqqqqj..",
932 "..qqjjqqqqq..",
933 "...qjjjqqqj..",
934 "...aannnaa...",
935 "...aaaabaa...",
936 "..ddddddddd..",
937 ],
938 pal: { a: '#F3E6C8', b: '#39281F', c: '#B39572', d: '#855B35', e: '#EAB086', f: '#57413B', g: '#846863', h: '#F9C8A2', i: '#C7A98A', j: '#6D554F', k: '#DC9A6F', l: '#45352E', m: '#81644E', n: '#CCB796', o: '#453A40', p: '#604C46', q: '#785E58', r: '#4F3B33', s: '#BA987C', t: '#3C2E28', u: '#614A3B', v: '#896C5A' },
939}
940
941const SALARYMAN: Sprite = {
942 rows: [
943 "....lll....",
944 "....llqq...",
945 "....bqjq...",
946 "....ufff...",
947 "....fuu....",
948 "....add....",
949 "..reraaer..",
950 "..rrepprrr.",
951 "..rercrrrr.",
952 ".rrrrrrrrr.",
953 ".rrrmrrrrrr",
954 ".rrrrrrrror",
955 ".rhrrrrrrmr",
956 ".rrrrrrrrmm",
957 ".rrrrrmmmrr",
958 ".rorrrrrrrr",
959 ".nurrorrrun",
960 ".nnmmhomrjf",
961 ".gommmmmm..",
962 "gkirmmmmm..",
963 "ggirrrrmm..",
964 "tiirr..mm..",
965 "ttirr..mm..",
966 "vtioo..mo..",
967 "..irm..mm..",
968 "..mmm..mm..",
969 "..mmh..mm..",
970 "..hoo..so..",
971 "..oo...oo..",
972 "..ll...qll.",
973 ".qqq....lq.",
974 ],
975 pal: { a: '#FBFBFA', b: '#151419', c: '#DD1B21', d: '#E8997D', e: '#625C6B', f: '#FCCDAE', g: '#543631', h: '#4A4554', i: '#392423', j: '#FBBB92', k: '#6D433A', l: '#22212A', m: '#565160', n: '#F8C3A1', o: '#514B59', p: '#F4F5F5', q: '#1E1B23', r: '#5C5666', s: '#45414E', t: '#4D322E', u: '#FDC8A5', v: '#3D2828' },
976 cls: { c: 'vis' },
977}
978
979const SMASHER: Sprite = {
980 rows: [
981 "...................................",
982 "..............jjjj.....cbqo........",
983 "..............aajaqqbbbqqqq........",
984 ".............qaaaiqjbbbcqqn........",
985 "........qqqqqbqrrqqbjqbbbbb........",
986 ".......uqqqmmbqjjqqqgjqbbdd........",
987 ".....usssbqvvqqbbqqqgjqkssdss......",
988 "....ddsssqjvkqqqqqddvdqssssss......",
989 "....ssssqbdddssdqdssddsqqssss......",
990 "....ssjqqssddsssvksdddsqqqqqd......",
991 "....qqqbbqdkdkdsedkdddqqbbqtq......",
992 "...dlntqbbqqsksqqskssqqbbqjnlds....",
993 "..sdqjjqqjqqqkqqqqqqvqqbqjjjhdd....",
994 ".ddsqqqqqqjjvqqjjjqqvqqqqqjqqdsd...",
995 "ddjsqqqqqqqjqqqqqqqqqqqqqqqqqdks...",
996 "ddddddqbqqqqqqqqqqqqqq...qqqqdqj...",
997 "ddssddqqqqqqqqqqqbjqqq....bqspjj...",
998 ".qqsssddqqbbqqqqqqqqjqj...qddjus...",
999 "..qqssssqkkkbqqqqqqqqqq..qqdfkus...",
1000 ".....kqqqqvvqqqqqqqqqqqqqqqskkss...",
1001 "......qbqccqqqqbqqqqqqqqqqjqkssj...",
1002 "........qqqqqqbbbqqqqvvqqqqjqsj....",
1003 ".........qqqqbjjskbqqqqqqqqqqqj....",
1004 ".........qqbbbqq..bqqqqbbqqqqqq....",
1005 ".........qqqnqbj..bbnqqqqqbqqqqqb..",
1006 ".........qqqvqbq..bqqqqqqqqqbqqqb..",
1007 ".........qsqqqbqqqbqqjsqqqqqqbqbqqb",
1008 "........ssssqqbbqqbqjqsdqqqbqqqbqqb",
1009 "........ddssqqqbbbbqqdsdds....bbqb.",
1010 ".......ddkssqqb...bbqsdkks.........",
1011 ".......dkkdssqq...bq..dkkdd........",
1012 ".......dsss..qq.......dskdd........",
1013 ".......dtjq...........qqjv.........",
1014 ".......vjqq...........qqjq.........",
1015 ".......jjqqq.........qqqjjj........",
1016 "......qjjqqqq.......bbqqqjq........",
1017 "......qjqqsqq.......qqsqqjqs.......",
1018 ".....dqqqsffq.......qqvsqqqs.......",
1019 ".....sqqqsffq.......q..vsqqds......",
1020 ".....sqqss..........q..vsqqdd......",
1021 ".....dsqds..........j..dsqqdd......",
1022 ".....sjjqs.............ssqjkd......",
1023 "......pjjk..............jjjj.......",
1024 "......ffqq..............qqvs.......",
1025 ".....kssqbj............qqqfs.......",
1026 ".....svjqqb...........qbqqvs.......",
1027 "....ssjjqqb...........bqqqqss......",
1028 "....sqqqstq...........qqdqqqs......",
1029 "....qqqqktq...........qqvsbqb......",
1030 "......qsqqq...........qqqskks......",
1031 "......ssqqqc..........qqqqs........",
1032 "......qqqbqq.........qqbbqq........",
1033 "..qjjjqqqbqq.........qqbbqjjjjq....",
1034 "..jjoojqqbqq.........qqbqqjjojq....",
1035 ],
1036 pal: { a: '#B09B9A', b: '#14131D', c: '#C90C29', d: '#5D5363', e: '#701023', f: '#E23236', g: '#4C2736', h: '#9D0B24', i: '#9A8384', j: '#292731', k: '#49414E', l: '#3D1324', m: '#6D2334', n: '#DC1D2D', o: '#B20527', p: '#CD2836', q: '#261924', r: '#A39293', s: '#524B59', t: '#361E2A', u: '#655A68', v: '#E42733' },
1037 cls: { n: 'vis', v: 'vis', p: 'vis', f: 'vis' },
1038}
1039
1040const TROOPER: Sprite = {
1041 rows: [
1042 ".............",
1043 ".......os....",
1044 "....ncvou....",
1045 "....hrern....",
1046 "....hofok....",
1047 "....ubhbu....",
1048 "....nnunss...",
1049 "...nnvnnnnna.",
1050 ".nsjjjvvvsnn.",
1051 ".llnjjjsjsst.",
1052 "dunnljnvvnss.",
1053 "gnnjnniunuunq",
1054 ".....ssuuuumn",
1055 ".....usstnvs.",
1056 "...uuugkvnu..",
1057 "...vjjpnun...",
1058 ".............",
1059 ".............",
1060 ".............",
1061 ".............",
1062 "...jj....nj..",
1063 "...dn...ndp..",
1064 "...gg...ugg..",
1065 "..nju...nkn..",
1066 "..jnj...jss..",
1067 "........jm...",
1068 ".............",
1069 ".............",
1070 "..nn.....sn..",
1071 "..ju.....ujn.",
1072 ".nnu.....hnn.",
1073 ],
1074 pal: { a: '#FD887E', b: '#0E0D12', c: '#CB0B12', d: '#5C5A6A', e: '#FA2029', f: '#292637', g: '#444250', h: '#1C1A24', i: '#4E4D5B', j: '#24232A', k: '#15141A', l: '#565564', m: '#2A2931', n: '#201F26', o: '#262432', p: '#4B4A58', q: '#595768', r: '#F72026', s: '#211F2A', t: '#4F4A58', u: '#1E1D24', v: '#282732' },
1075 cls: { r: 'vis', e: 'vis' },
1076}
1077
1078// The crew's roles, by the costume a crab would have worn for them.
1079const CREW_ROLES: Record<string, Sprite> = {
1080 explore: KIWI,
1081 fable: LUCY,
1082 heavy: MAINE,
1083 careful: PILAR,
1084 medium: DORIO,
1085 light: REBECCA,
1086 artist: JOHNNY,
1087 writer: FALCO,
1088 banker: FALCO,
1089 other: JOHNNY,
1090}
1091
1092export const CASTS: Record<string, Cast> = {
1093 // David leads; Lucy, Maine, Rebecca, Kiwi, Dorio, Pilar, Falco and Johnny take the roles.
1094 runners: { name: 'Edgerunners', bossName: 'David', boss: DAVID, roles: CREW_ROLES, crew: [CHOOM, GANGER, NEON] },
1095 // Lucy leads; David rides along as one of the crew.
1096 netrun: { name: 'Edgerunners', bossName: 'Lucy', boss: LUCY, roles: { ...CREW_ROLES, fable: DAVID }, crew: [CHOOM, NEON, GANGER] },
1097 // SAMURAI, the secret skin: Johnny fronts the band, the crew backs him up.
1098 samurai: {
1099 name: 'SAMURAI',
1100 bossName: 'Johnny',
1101 boss: JOHNNY,
1102 roles: { ...CREW_ROLES, artist: LUCY, other: DORIO },
1103 crew: [GANGER, NEON, CHOOM],
1104 },
1105 // The old man at the top; Smasher, his officers and a captive rockerboy under him;
1106 // the guards and staff on the floor.
1107 corpo: {
1108 name: 'Arasaka',
1109 bossName: 'Saburo',
1110 boss: SABURO,
1111 roles: {
1112 explore: NETRUNNER,
1113 fable: HANDLER,
1114 heavy: SMASHER,
1115 careful: CYBORG,
1116 medium: AGENT,
1117 light: GUARD,
1118 artist: JOHNNY,
1119 writer: HANDLER,
1120 banker: HANDLER,
1121 other: AGENT,
1122 },
1123 crew: [TROOPER, SALARYMAN, DRONE, TROOPER, SALARYMAN],
1124 },
1125}
1126
1127// A stable pick from a word, so every agent of a role gets the same worker.
1128export const hashOf = (word: string): number => [...word].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 7)
1129
1130// Sprite pixels to the crab's 30×28 box: the tallest of the crew (48 rows) stands
1131// 27 high; Smasher towers over the box.
1132const PIXEL = 0.5625
1133// A tone just off the dark backgrounds, around the silhouette: no hard outline,
1134// but the dark clothes still read against the band.
1135const RIM = '#2A2B3A'
1136
1137// The sprite as one path per colour and motion group: small enough that a crowd
1138// of them stays inside the drawing's size limit. Centred in the box, standing on
1139// its floor; the rows under the gap between the legs are the legs.
1140export const spriteMarkup = (sp: Sprite, tint: string, box = 30, floor = 27): string => {
1141 const width = Math.max(...sp.rows.map(r => r.length))
1142 const height = sp.rows.length
1143 const mid = Math.floor(width / 2)
1144 let legFrom = height
1145 while (legFrom > 0 && (sp.rows[legFrom - 1]?.[mid] ?? '.') === '.' && !sp.float) legFrom--
1146 if (height - legFrom < 2) legFrom = height
1147 const colorOf = (ch: string | undefined) => (ch === undefined || ch === '.' ? undefined : ch === 't' && !sp.pal.t ? tint : sp.pal[ch])
1148 const isFilled = (x: number, y: number) => colorOf(sp.rows[y]?.[x]) !== undefined
1149 const paths = new Map<string, Map<string, string[]>>()
1150 const add = (group: string, color: string, x: number, y: number, w: number) => {
1151 if (!paths.has(group)) paths.set(group, new Map())
1152 const byColor = paths.get(group) as Map<string, string[]>
1153 if (!byColor.has(color)) byColor.set(color, [])
1154 byColor.get(color)?.push(`M${x} ${y}h${w}v1h${-w}z`)
1155 }
1156 const side = (x: number) => (x < width / 2 ? 'la' : 'lb')
1157 // The head: the top sixth, which nods a beat after the breath.
1158 const headTo = sp.float ? 0 : Math.round(height / 6)
1159 for (let y = -1; y <= height; y++) {
1160 const row = sp.rows[y] ?? ''
1161 for (let x = -1; x <= width; x++) {
1162 const color = colorOf(row[x])
1163 if (color === undefined) {
1164 // The rim: an empty pixel touching the silhouette.
1165 if (isFilled(x + 1, y) || isFilled(x - 1, y) || isFilled(x, y + 1) || isFilled(x, y - 1)) {
1166 add(y >= legFrom ? side(x) : y < headTo ? 'hdrim' : 'rim', RIM, x, y, 1)
1167 }
1168 continue
1169 }
1170 // A run of one letter, split at the middle on the legs so each steps alone.
1171 const ch = row[x] as string
1172 let n = 1
1173 while (row[x + n] === ch && (y < legFrom || side(x + n) === side(x))) n++
1174 add(y >= legFrom ? side(x) : (sp.cls?.[ch] ?? (y < headTo ? 'hd' : 'bd')), color, x, y, n)
1175 x += n - 1
1176 }
1177 }
1178 const draw = (group: string) =>
1179 [...(paths.get(group) ?? new Map<string, string[]>()).entries()].map(([color, d]) => `<path d="${d.join('')}" fill="${color}"/>`).join('')
1180 const inner = ['hair', 'vis', 'led'].map(g => (paths.has(g) ? `<g class="${g}">${draw(g)}</g>` : '')).join('')
1181 // The tallest (Smasher) is shrunk to fit the box rather than poke out of it.
1182 const pixel = Math.min(PIXEL, floor / height)
1183 const x0 = +((box - width * pixel) / 2).toFixed(2)
1184 const y0 = +(floor - height * pixel).toFixed(2)
1185 return `<g transform="translate(${x0},${y0}) scale(${+pixel.toFixed(4)})"><g class="bd">${draw('rim')}${draw('bd')}<g class="hd">${draw('hdrim')}${draw('hd')}</g>${inner}</g><g class="la">${draw('la')}</g><g class="lb">${draw('lb')}</g></g>`
1186}
1187
1188// How the cast moves: a breath at rest, hair that sways, visors that flicker,
1189// lights that pulse; a brisker step and flicker while it works.
1190export const CAST_CSS = `.ch g{transform-box:fill-box}
1191.ch .bd{animation:cbreathe 2.6s steps(1) infinite}
1192@keyframes cbreathe{50%{transform:translateY(1px)}}
1193.ch.run .bd{animation:bob .5s steps(1) infinite -.125s}
1194.ch .hd{animation:cnod 2.6s steps(1) infinite -.65s}
1195@keyframes cnod{50%{transform:translateY(.6px)}}
1196.ch.run .hd{animation:cnod .5s steps(1) infinite -.25s}
1197.ch .hair{animation:csway 1.8s steps(1) infinite}
1198@keyframes csway{50%{transform:translateX(1px)}}
1199.ch .vis{animation:cvis 2.2s ease-in-out infinite}
1200@keyframes cvis{50%{opacity:.5}}hooks/eggs.ts 280 lines1// Easter eggs for the cyberpunk skins: small references a Cyberpunk 2077 or
2// Edgerunners fan will catch. Pure SVG strings and CSS, no state: the hooks module
3// decides when each shows. Only short words and names are used, never lyrics.
4//
5// Three flavours, one per cast: `runners` (Night City yellow), `netrun` (Lucy's
6// pink and the moon), `corpo` (Arasaka red).
7
8import { inkOn, textW } from './extras'
9
10export type Egg = 'runners' | 'netrun' | 'corpo' | 'samurai'
11
12// `bg` is the card ground: each skin's tile colour.
13export type EggColors = { a: string; b: string; c: string; text: string; dim: string; bg: string }
14
15const COLORS: Record<Egg, EggColors> = {
16 runners: { a: '#FCEE0A', b: '#00F0FF', c: '#FF003C', text: '#F4F4F4', dim: '#7E8696', bg: '#0B0C10' },
17 netrun: { a: '#FF5CF0', b: '#5CE1FF', c: '#B8F2E0', text: '#F4F0FF', dim: '#8A7EA0', bg: '#100B1A' },
18 corpo: { a: '#E3001B', b: '#F2F2F2', c: '#8A0A24', text: '#F2F2F2', dim: '#7E7F86', bg: '#0A0A0B' },
19 samurai: { a: '#E10600', b: '#EDEDED', c: '#FFD400', text: '#F4F4F4', dim: '#8A7A7A', bg: '#0E0909' },
20}
21export const eggColors = (egg: Egg): EggColors => COLORS[egg]
22
23const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
24let uid = 0
25const id = (p: string) => `${p}${(uid = (uid + 1) % 100000)}`
26
27// --- motion: the glitch bursts, the breach band, the heat flash, the trails ---
28export const EGG_CSS = `.rg{animation:rgx 4.2s steps(1) infinite}.rg2{animation:rgx 4.2s steps(1) infinite -.06s}
29@keyframes rgx{0%,90%,100%{transform:translate(0,0);opacity:0}91%{transform:translate(-2px,0);opacity:.9}92%{transform:translate(2px,1px);opacity:.8}94%{transform:translate(-1px,-1px);opacity:.9}95%{transform:translate(0,0);opacity:0}}
30.rgs{animation:rgs 4.2s steps(1) infinite}@keyframes rgs{0%,90%,96%,100%{transform:translateX(0)}91%{transform:translateX(3px)}93%{transform:translateX(-2px)}}
31.bx{animation:bx 3s steps(6) infinite}@keyframes bx{to{transform:translateX(var(--bw))}}
32.bslot{animation:cur 1s steps(1) infinite}
33.heat{animation:heat .5s steps(1) infinite}@keyframes heat{50%{fill:#2F6BFF}}
34.psy{animation:psy 1.3s steps(1) infinite}@keyframes psy{0%,100%{opacity:1;transform:translateX(0)}20%{opacity:.4;transform:translateX(1px)}24%{opacity:1;transform:translateX(-1px)}60%{opacity:.85}}
35.sandy{animation:sandy 1.2s ease-in-out infinite}@keyframes sandy{50%{opacity:.08}}
36.win{animation:win 3.4s steps(1) infinite}@keyframes win{50%{opacity:.25}}
37.av{animation:av 14s linear infinite}@keyframes av{from{transform:translateX(-60px)}to{transform:translateX(var(--sw))}}
38.beacon{animation:led 1.4s steps(1) infinite}@keyframes led{50%{opacity:.2}}
39.moon{animation:moon 6s ease-in-out infinite}@keyframes moon{50%{transform:translateY(-2px)}}
40.rise{animation:rise 2.4s cubic-bezier(.2,.8,.2,1) both}@keyframes rise{from{transform:translateY(40px);opacity:0}}
41.flash{animation:flash .9s steps(1) 3}@keyframes flash{33%{opacity:.15}66%{opacity:.9}}
42.tick{animation:tick 1.6s ease-in-out infinite}
43@media (prefers-reduced-motion: reduce){.rg,.rg2,.rgs,.bx,.bslot,.heat,.psy,.sandy,.win,.av,.beacon,.moon,.rise,.flash,.tick{animation:none}}`
44
45// The Relic's malfunction on a line of text: a red and a cyan copy that tear
46// sideways for a moment every few seconds, over a white core that jumps a slice.
47export const relicText = (x: number, y: number, text: string, attrs: string, egg: Egg): string => {
48 const c = COLORS[egg]
49 const t = esc(text)
50 return `<g class="rgs"><text x="${x}" y="${y}" ${attrs}>${t}</text></g>
51<text class="rg" opacity="0" x="${x}" y="${y}" ${attrs.replace(/fill="[^"]*"/, '')} fill="${c.c}" style="mix-blend-mode:screen">${t}</text>
52<text class="rg2" opacity="0" x="${x}" y="${y}" ${attrs.replace(/fill="[^"]*"/, '')} fill="${c.b}" style="mix-blend-mode:screen">${t}</text>`
53}
54
55// Breach Protocol: a row of hex codes with the lit band snapping cell to cell while
56// the job runs, and a buffer of slots filled as steps are done.
57const CODES = ['1C', 'BD', '55', 'E9', '7A', 'FF']
58export const breachTicker = (x: number, y: number, isWorking: boolean, done: number, total: number, egg: Egg, font: string): string => {
59 const c = COLORS[egg]
60 const cell = 17
61 const cells = CODES.map((code, i) => `<text x="${x + 44 + i * cell + cell / 2}" y="${y}" text-anchor="middle" font-family="${font}" font-size="9.5" font-weight="700" fill="${isWorking ? c.a : c.dim}" fill-opacity="${isWorking ? 0.9 : 0.6}">${code}</text>`).join('')
62 const band = isWorking
63 ? `<rect class="bx" style="--bw:${cell * 6}px" x="${x + 44}" y="${y - 9}" width="${cell}" height="12" fill="${c.a}" fill-opacity=".18" stroke="${c.a}" stroke-opacity=".7" stroke-width=".8"/>`
64 : ''
65 const slots = Math.max(1, Math.min(6, total || 6))
66 const filled = total ? Math.min(slots, Math.round((done / total) * slots)) : 0
67 const buf = Array.from({ length: slots }, (_, i) => {
68 const bx = x + 44 + cell * 6 + 10 + i * 7
69 return i < filled
70 ? `<rect x="${bx}" y="${y - 7}" width="5" height="8" fill="${c.a}"/>`
71 : `<rect${i === filled && isWorking ? ' class="bslot"' : ''} x="${bx + 0.5}" y="${y - 6.5}" width="4" height="7" fill="none" stroke="${c.a}" stroke-opacity=".6"/>`
72 }).join('')
73 return `<text x="${x}" y="${y}" font-family="${font}" font-size="9" font-weight="800" letter-spacing=".6" fill="${c.dim}">BREACH</text>${band}${cells}${buf}`
74}
75export const BREACH_W = 44 + 17 * 6 + 10 + 6 * 7
76
77// NCPD heat: up to five stars, lit by how hot the run is; five flash red and blue.
78export const heatStars = (x: number, y: number, level: number, egg: Egg): string => {
79 const c = COLORS[egg]
80 const lit = Math.max(0, Math.min(5, Math.round(level)))
81 return Array.from({ length: 5 }, (_, i) => {
82 const on = i < lit
83 return `<text${on && lit === 5 ? ' class="heat"' : ''} x="${x + i * 9}" y="${y}" font-size="9" fill="${on ? c.c : c.dim}" fill-opacity="${on ? 1 : 0.5}">★</text>`
84 }).join('')
85}
86
87// Cyberpsychosis: past 85% context the chrome starts to bite; past 95% MaxTac rolls.
88export const psychoChip = (x: number, y: number, ctxPct: number, font: string): { svg: string; w: number } => {
89 if (ctxPct < 85) return { svg: '', w: 0 }
90 const label = ctxPct >= 95 ? 'MAXTAC INBOUND' : 'CYBERPSYCHOSIS RISK'
91 const w = textW(label, 9.5, 0.5) + 14
92 return {
93 w,
94 svg: `<g class="psy"><rect x="${x}" y="${y - 10}" width="${w}" height="13" rx="1" fill="#FF003C"/><text x="${x + w / 2}" y="${y - 0.5}" text-anchor="middle" font-family="${font}" font-size="9.5" font-weight="800" letter-spacing=".5" fill="${inkOn('#FF003C')}">${label}</text></g>`,
95 }
96}
97
98// Sandevistan: three fading afterimages trailing the figure while it works, tinted
99// like David's time-slow trails.
100export const sandevistan = (figure: string, egg: Egg): { defs: string; svg: string } => {
101 const f = id('sv')
102 const tint = egg === 'corpo' ? '0.9 0 0 0 0.25 0 0.1 0 0 0 0 0.1 0 0 0' : egg === 'netrun' ? '0.6 0 0 0 0.3 0 0.2 0 0 0.2 0 0 0.8 0 0.4' : '0 0 0 0 0 0 0.9 0 0 0.35 0 0 0.6 0 0.45'
103 return {
104 defs: `<filter id="${f}"><feColorMatrix type="matrix" values="${tint} 0 0 0 1 0"/></filter>`,
105 svg: [9, 6, 3]
106 .map((dx, i) => `<g class="sandy" style="animation-delay:${-i * 0.2}s" opacity="${(0.12 + i * 0.1).toFixed(2)}" filter="url(#${f})" transform="translate(${-dx} 0)">${figure}</g>`)
107 .join(''),
108 }
109}
110
111// Night City along the bottom of a card: towers with blinking windows and an AV
112// crossing; Arasaka's tower over the corpo skin, Lucy's moon over hers.
113export const skyline = (W: number, y0: number, h: number, egg: Egg): string => {
114 const c = COLORS[egg]
115 let seed = 7
116 const rnd = () => ((seed = (seed * 9301 + 49297) % 233280) / 233280)
117 const towers: string[] = []
118 const windows: string[] = []
119 let x = 0
120 while (x < W) {
121 const w = 10 + Math.floor(rnd() * 22)
122 const th = 8 + Math.floor(rnd() * (h - 10))
123 towers.push(`<rect x="${x}" y="${y0 + h - th}" width="${w - 2}" height="${th}"/>`)
124 for (let k = 0; k < 2; k++) {
125 if (rnd() < 0.6) windows.push(`<rect class="win" style="animation-delay:${(rnd() * -3).toFixed(1)}s" x="${x + 2 + Math.floor(rnd() * (w - 6))}" y="${y0 + h - th + 3 + Math.floor(rnd() * Math.max(1, th - 6))}" width="2" height="2" fill="${rnd() < 0.5 ? c.a : c.b}"/>`)
126 }
127 x += w
128 }
129 let extra = ''
130 if (egg === 'corpo') {
131 // The tower: tall, stepped, a red beacon on top.
132 const tx = Math.round(W * 0.72)
133 extra = `<path d="M${tx} ${y0 + h}V${y0 + 6}l6 -6l6 6V${y0 + h}Z"/><rect class="beacon" x="${tx + 5}" y="${y0 - 2}" width="2" height="2" fill="#FF1E3C"/>`
134 } else if (egg === 'samurai') {
135 // A stage: two spotlights sweeping down on the band.
136 const sx = Math.round(W * 0.7)
137 extra = `<path d="M${sx} ${y0 - 4}l-26 ${h + 4}h20z" fill="#FFD400" fill-opacity=".18"/><path d="M${sx + 40} ${y0 - 4}l26 ${h + 4}h-20z" fill="#E10600" fill-opacity=".22"/><rect x="${sx - 30}" y="${y0 + h - 4}" width="100" height="4" fill="#2A0A0A"/>`
138 } else if (egg === 'netrun') {
139 // Clear of the CACHE readout on the right, and small enough for a 14px band.
140 const mx = Math.round(W * 0.55)
141 const r = Math.min(h * 0.36, 8)
142 const my = y0 + r + 1
143 extra = `<g class="moon"><circle cx="${mx}" cy="${my.toFixed(1)}" r="${r.toFixed(1)}" fill="#FFD6F2" fill-opacity=".55"/><circle cx="${(mx - r * 0.3).toFixed(1)}" cy="${(my - r * 0.25).toFixed(1)}" r="${(r * 0.3).toFixed(1)}" fill="#E6A8D8" fill-opacity=".5"/></g>`
144 }
145 const av = `<g class="av" style="--sw:${W + 60}px"><rect x="0" y="${y0 + 3}" width="9" height="2" rx="1" fill="${c.dim}"/><rect x="8" y="${y0 + 3}" width="2" height="2" fill="${c.c}"/></g>`
146 return `<g opacity=".22" fill="${egg === 'corpo' ? '#2A0A0F' : egg === 'samurai' ? '#2A0C0C' : egg === 'netrun' ? '#2A1A3A' : '#1A2230'}">${extra}${towers.join('')}</g><g opacity=".5">${windows.join('')}</g>${av}`
147}
148
149// The cheer when a job is done: Night City's "preem", the moon, the corporation's stamp.
150export const celebration = (W: number, H: number, egg: Egg, font: string): string => {
151 const c = COLORS[egg]
152 if (egg === 'netrun') {
153 return `<g class="rise"><circle cx="${W - 70}" cy="${H / 2}" r="${H * 0.36}" fill="#FFD6F2"/><circle cx="${W - 78}" cy="${H / 2 - 6}" r="${H * 0.08}" fill="#E6A8D8"/></g>
154<text class="rise" x="${W - 130}" y="${H / 2 + 5}" text-anchor="end" font-family="${font}" font-size="15" font-weight="800" letter-spacing="1.5" fill="${c.a}">TO THE MOON ☾</text>`
155 }
156 const label = egg === 'corpo' ? 'PROTOCOL COMPLETE · 任務完了' : egg === 'samurai' ? 'NEVER FADE AWAY' : 'PREEM. JOB DONE, CHOOM'
157 const w = textW(label, 15, 1.5) + 30
158 if (egg === 'corpo') font = `${font.replace(/,?sans-serif$/, '')},${CJK_FONT}`
159 return `<g class="flash"><rect x="${(W - w) / 2}" y="${H / 2 - 13}" width="${w}" height="24" fill="${c.a}" transform="rotate(-2 ${W / 2} ${H / 2})"/>
160<text x="${W / 2}" y="${H / 2 + 5}" text-anchor="middle" font-family="${font}" font-size="15" font-weight="900" letter-spacing="1.5" fill="${inkOn(c.a)}" transform="rotate(-2 ${W / 2} ${H / 2})">${esc(label)}</text></g>`
161}
162
163// A tiny code tag for a corner: SYS.2077 // 0x3F, and a katakana line.
164export const sysTag = (x: number, y: number, egg: Egg, font: string, anchor = 'end'): string => {
165 const c = COLORS[egg]
166 const code = egg === 'corpo' ? 'ARS.2077 // 0x0A' : egg === 'netrun' ? 'NET.2076 // 0x4C' : egg === 'samurai' ? 'SAM.2013 // 0x77' : 'SYS.2077 // 0x3F'
167 return `<text x="${x}" y="${y}" text-anchor="${anchor}" font-family="${font}" font-size="7" letter-spacing=".8" fill="${c.dim}" fill-opacity=".8">${code}</text>`
168}
169// Japanese text needs a Japanese face: macOS, Windows, then Linux's Noto.
170export const CJK_FONT = "'Hiragino Sans','Hiragino Kaku Gothic ProN','PingFang SC','Yu Gothic UI','Meiryo','Noto Sans CJK JP',sans-serif"
171export const KATAKANA: Record<Egg, string> = { runners: 'ミッション・コントロール', netrun: 'ネットランナー', corpo: 'アラサカ', samurai: 'サムライ' }
172
173// Night City radio, one station an hour (names only).
174const STATIONS = ['89.7 GROWL FM', '88.9 PACIFIC DREAMS', '91.9 ROYAL BLUE', '92.9 NIGHT FM', '95.2 SAMIZDAT', '96.1 RITUAL FM', '98.7 BODY HEAT', '99.9 IMPULSE', '101.9 THE DIRGE', '103.5 RADIO PEBKAC', '106.9 30 PRINCIPALES', '107.3 MORRO ROCK', '107.5 DARK STAR', '89.3 RADIO VEXELSTROM']
175export const radio = (at: number): string => STATIONS[Math.floor(at / 3_600_000) % STATIONS.length] as string
176
177// Breach Protocol card for Mission Control: a 5×5 code matrix with the valid row lit,
178// a buffer of the job's steps and the daemons it uploads.
179const MATRIX = [
180 ['1C', 'BD', '55', 'E9', '7A'],
181 ['55', '7A', '1C', 'FF', 'BD'],
182 ['E9', '1C', 'BD', '55', 'FF'],
183 ['FF', '55', '7A', '1C', 'E9'],
184 ['BD', 'E9', 'FF', '7A', '1C'],
185]
186export const BREACH_H = 132
187export const breachCard = (W: number, done: number, total: number, isWorking: boolean, egg: Egg, font: string, frame: (x: number, y: number, w: number, h: number, attrs: string, r?: number) => string): string => {
188 const c = COLORS[egg]
189 const x0 = 18
190 const y0 = 36
191 const cell = 32
192 const rh = 17
193 const row = total ? Math.min(4, done % 5) : 0
194 const grid = MATRIX.map((r, j) =>
195 r.map((code, i) => {
196 const lit = j === row
197 return `<text x="${x0 + i * cell + cell / 2}" y="${y0 + j * rh + 12.5}" text-anchor="middle" font-family="${font}" font-size="13" font-weight="700" fill="${lit ? c.a : c.dim}" fill-opacity="${lit ? 1 : 0.55}">${code}</text>`
198 }).join(''),
199 ).join('')
200 const band = `<rect x="${x0 - 2}" y="${y0 + row * rh}" width="${cell * 5 + 4}" height="${rh}" fill="${c.a}" fill-opacity=".12" stroke="${c.a}" stroke-opacity=".6"/>`
201 const sweep = isWorking ? `<rect class="bx" style="--bw:${cell * 5}px" x="${x0}" y="${y0 + row * rh}" width="${cell}" height="${rh}" fill="${c.a}" fill-opacity=".25"/>` : ''
202 const bx = x0 + cell * 5 + 36
203 const slots = Math.max(1, Math.min(8, total || 6, Math.floor((W - bx - 14) / 26)))
204 const fill = total ? Math.min(slots, Math.round((done / total) * slots)) : 0
205 const hasDaemons = W - bx >= 130
206 const buf = Array.from({ length: slots }, (_, i) =>
207 i < fill
208 ? `<rect x="${bx + i * 26}" y="${y0}" width="22" height="16" fill="${c.a}" fill-opacity=".9"/><text x="${bx + i * 26 + 11}" y="${y0 + 12}" text-anchor="middle" font-family="${font}" font-size="10" font-weight="800" fill="${inkOn(c.a)}">${(MATRIX[i % 5] as string[])[i % 5]}</text>`
209 : `<rect${i === fill && isWorking ? ' class="bslot"' : ''} x="${bx + i * 26 + 0.5}" y="${y0 + 0.5}" width="21" height="15" fill="none" stroke="${c.a}" stroke-opacity=".6" stroke-dasharray="3 2"/>`,
210 ).join('')
211 const daemons = (hasDaemons ? ['DATAMINE_V1', 'DATAMINE_V2', 'DATAMINE_V3'] : [])
212 .map((d, i) => {
213 const ok = total > 0 && done >= Math.ceil(((i + 1) * total) / 3)
214 return `<text x="${bx}" y="${y0 + 42 + i * 18}" font-family="${font}" font-size="11.5" font-weight="700" letter-spacing=".6" fill="${ok ? c.a : c.dim}">${ok ? '✓ ' : '▫\uFE0E '}${d}</text>`
215 })
216 .join('')
217 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${BREACH_H}" viewBox="0 0 ${W} ${BREACH_H}"><style>${EGG_CSS}.cur{}@keyframes cur{50%{opacity:0}}.bslot{animation:cur 1s steps(1) infinite}@media (prefers-reduced-motion: reduce){.bslot{animation:none}}</style>
218${frame(0.5, 0.5, W - 1, BREACH_H - 1, `fill="${c.bg}" fill-opacity=".85" stroke="${c.a}" stroke-opacity=".35"`, 10)}
219<rect x="16" y="11" width="3" height="14" fill="${c.a}"/>
220<text x="25" y="23" font-family="${font}" font-size="14" font-weight="800" letter-spacing=".8" fill="${c.text}">BREACH PROTOCOL</text>
221${sysTag(W - 14, 22, egg, font)}
222${band}${sweep}${grid}
223<text x="${bx}" y="${y0 - 6}" font-family="${font}" font-size="9" font-weight="800" letter-spacing=".8" fill="${c.dim}">BUFFER ${fill}/${slots}</text>
224${buf}${daemons}
225<text x="${W - 14}" y="${BREACH_H - 10}" text-anchor="end" font-family="${font}" font-size="9" letter-spacing=".6" fill="${c.dim}">${isWorking ? 'UPLOADING DAEMONS…' : total && done >= total ? 'BREACH SUCCESSFUL' : 'AWAITING JACK-IN'}</text>
226</svg>`
227}
228
229// The Afterlife names a drink after every legend; Arasaka files the engram in Mikoshi.
230export type Legend = { task: string; costUsd: number; at: number; ms: number }
231export const LEGENDS_ROW = 18
232export const legendsHeight = (n: number): number => 46 + Math.max(1, n) * LEGENDS_ROW + 8
233export const legendsCard = (W: number, list: Legend[], egg: Egg, font: string, money: (usd: number) => string, frame: (x: number, y: number, w: number, h: number, attrs: string, r?: number) => string): string => {
234 const c = COLORS[egg]
235 const H = legendsHeight(list.length)
236 // 7. a narrow card keeps just the place's name
237 const title = W < 460 ? (egg === 'corpo' ? 'MIKOSHI' : 'AFTERLIFE') : egg === 'corpo' ? 'MIKOSHI · ENGRAMS ON FILE' : 'AFTERLIFE · DRINKS NAMED AFTER LEGENDS'
238 const rows = list.length
239 ? list
240 .map((l, i) => {
241 const y = 52 + i * LEGENDS_ROW
242 const name = egg === 'corpo' ? `ENGRAM ${String(i + 1).padStart(3, '0')} — ${l.task}` : l.task
243 const room = Math.max(10, Math.floor((W - 160) / 6.6))
244 const chars = [...name]
245 const clipped = chars.length > room ? `${chars.slice(0, room - 1).join('')}…` : name
246 return `<text x="18" y="${y}" font-family="${font}" font-size="12" font-weight="600" fill="${c.text}">${egg === 'corpo' ? '▣' : '🍸'} ${esc(clipped)}</text>
247<line x1="18" y1="${y + 5}" x2="${W - 18}" y2="${y + 5}" stroke="${c.dim}" stroke-opacity=".35" stroke-dasharray="1 3"/>
248<text x="${W - 18}" y="${y}" text-anchor="end" font-family="${font}" font-size="12" font-weight="800" fill="${c.a}">${money(l.costUsd)}</text>`
249 })
250 .join('')
251 : `<text x="18" y="52" font-family="${font}" font-size="12" fill="${c.dim}">${egg === 'corpo' ? 'No engrams yet. Finish a job.' : 'No legends yet. Finish a job, choom.'}</text>`
252 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
253${frame(0.5, 0.5, W - 1, H - 1, `fill="${c.bg}" fill-opacity=".85" stroke="${c.a}" stroke-opacity=".3"`, 10)}
254<rect x="16" y="11" width="3" height="14" fill="${c.a}"/>
255<text x="25" y="23" font-family="${font}" font-size="14" font-weight="800" letter-spacing=".8" fill="${c.text}">${title}</text>
256${sysTag(W - 14, 22, egg, font)}
257${rows}
258</svg>`
259}
260
261// Words: the spinner talks like Night City, and the cheer on a long job.
262export const EGG_WORDS: Record<Egg, string[]> = {
263 runners: ['Jacking in', 'Breaching ICE', 'Uploading daemon', 'Pinging the subnet', 'Chipping in', 'Calling a Delamain', 'Asking the fixer', 'Overclocking', 'Dodging NetWatch', 'Hitting the Afterlife', 'Checking the chrome', 'Scanning with Kiroshis'],
264 netrun: ['Diving into the Net', 'Chasing the moon', 'Slipping past ICE', 'Deep diving', 'Drifting through the Net', 'Riding the subway', 'Jacking in, choom', 'Cutting the cables', 'Breaching the Blackwall… not today'],
265 samurai: ['Tuning up', 'Plugging in the amp', 'Soundcheck', 'Burning corpo towers', 'Writing a riff', 'Playing the Atlantis', 'Chipping in', 'Loading the Malorian'],
266 corpo: ['Processing', 'Executing protocol', 'Consulting the board', 'Filing the engram', 'Securing assets', 'Dispatching Smasher', 'Reporting to Saburo', 'Running Soulkiller… diagnostics', 'Auditing the floor'],
267}
268export const EGG_TOOLS: Record<Egg, Record<string, string>> = {
269 runners: { Read: 'Scan', Edit: 'Rewire', MultiEdit: 'Rewire', Write: 'Burn chip', Bash: 'Quickhack', PowerShell: 'Quickhack', Grep: 'Ping', Glob: 'Datamine', Agent: 'Call a fixer', WebFetch: 'Jack in', WebSearch: 'Netsearch', ToolSearch: 'Visit Vik' },
270 netrun: { Read: 'Dive', Edit: 'Rewrite', MultiEdit: 'Rewrite', Write: 'Leave a trace', Bash: 'Breach', PowerShell: 'Breach', Grep: 'Ping', Glob: 'Map the Net', Agent: 'Call the crew', WebFetch: 'Pull', WebSearch: 'Search the Net', ToolSearch: 'Swap chrome' },
271 samurai: { Read: 'Read the setlist', Edit: 'Rewrite the riff', MultiEdit: 'Rewrite the riff', Write: 'Cut a track', Bash: 'Plug in', PowerShell: 'Plug in', Grep: 'Find the hook', Glob: 'Map the venue', Agent: 'Call the band', WebFetch: 'Pull a sample', WebSearch: 'Scout the crowd', ToolSearch: 'Swap guitars' },
272 corpo: { Read: 'Audit', Edit: 'Amend', MultiEdit: 'Amend', Write: 'File', Bash: 'Execute', PowerShell: 'Execute', Grep: 'Surveil', Glob: 'Catalogue', Agent: 'Dispatch', WebFetch: 'Retrieve', WebSearch: 'Surveil the Net', ToolSearch: 'Requisition' },
273}
274export const GREETING: Record<Egg, string> = {
275 runners: 'Wake up, Samurai. We have a city to burn.',
276 netrun: 'Wake up, choom. The moon is waiting.',
277 corpo: 'Arasaka welcomes you, employee. Loyalty is rewarded.',
278 samurai: '🎸 SAMURAI reunited. Never fade away.',
279}
280hooks/extras.ts 433 lines1// Extras for Mission Control: Street Cred (XP, levels, badges), the Braindance
2// replay of a session's tool calls, and the boot-up strip. Pure functions and SVG
3// strings; the hooks module keeps the state and decides when each shows.
4
5// --- Street Cred ---
6
7export type Cred = {
8 xp: number
9 jobs: number
10 longJobs: number
11 agents: number
12 crewJobs: number
13 nightJobs: number
14 chromed: boolean
15 samurai: boolean
16 badges: string[]
17}
18export const BLANK_CRED: Cred = { xp: 0, jobs: 0, longJobs: 0, agents: 0, crewJobs: 0, nightJobs: 0, chromed: false, samurai: false, badges: [] }
19
20// Level L needs 25·L·(L−1) XP: 0, 50, 150, 300, 500, 750…
21export const levelOf = (xp: number): number => Math.max(1, Math.floor((1 + Math.sqrt(1 + Math.max(0, xp) / 6.25)) / 2))
22export const xpFor = (level: number): number => 25 * level * (level - 1)
23
24// Badge ids are what the store keeps; the names are what Night City calls them.
25export type Badge = { id: string; name: string; icon: string; how: string; progress: (c: Cred) => number }
26const share = (n: number, of: number) => Math.max(0, Math.min(1, n / of))
27export const BADGES: Badge[] = [
28 { id: 'choom', name: 'FIRST GIG', icon: '✦', how: 'Finish your first gig', progress: c => share(c.jobs, 1) },
29 { id: 'merc', name: "FIXER'S FAVORITE", icon: '◆', how: 'Finish 10 gigs', progress: c => share(c.jobs, 10) },
30 { id: 'edgerunner', name: 'ICONIC', icon: '★', how: 'Finish 50 gigs', progress: c => share(c.jobs, 50) },
31 { id: 'legend', name: 'THE BIG LEAGUES', icon: '♛', how: '10 major gigs (3+ minutes each)', progress: c => share(c.longJobs, 10) },
32 { id: 'netrunner', name: 'NETRUNNER', icon: '⌁', how: 'Call 100 sub-agents', progress: c => share(c.agents, 100) },
33 { id: 'crewboss', name: 'FIXER', icon: '⚑', how: 'Run a gig with 5+ sub-agents', progress: c => share(c.crewJobs, 1) },
34 { id: 'chromed', name: 'CYBERPSYCHO', icon: '⚙', how: 'Push the context past 90%', progress: c => (c.chromed ? 1 : 0) },
35 { id: 'owl', name: 'GRAVEYARD SHIFT', icon: '☾', how: 'Finish a gig after midnight', progress: c => share(c.nightJobs, 1) },
36 { id: 'samurai', name: 'SAMURAI', icon: '♪', how: 'Find the secret skin', progress: c => (c.samurai ? 1 : 0) },
37]
38
39// The badges a cred has just earned, and the cred with them recorded.
40export const award = (c: Cred): { cred: Cred; fresh: Badge[] } => {
41 const fresh = BADGES.filter(b => !c.badges.includes(b.id) && b.progress(c) >= 1)
42 return { cred: fresh.length ? { ...c, badges: [...c.badges, ...fresh.map(b => b.id)] } : c, fresh }
43}
44
45export const cleanCred = (v: unknown): Cred => {
46 const r = (v && typeof v === 'object' ? v : {}) as Partial<Cred>
47 const n = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0)
48 return {
49 xp: n(r.xp),
50 jobs: n(r.jobs),
51 longJobs: n(r.longJobs),
52 agents: n(r.agents),
53 crewJobs: n(r.crewJobs),
54 nightJobs: n(r.nightJobs),
55 chromed: r.chromed === true,
56 samurai: r.samurai === true,
57 badges: Array.isArray(r.badges) ? [...new Set(r.badges.filter((b): b is string => typeof b === 'string' && BADGES.some(x => x.id === b)))] : [],
58 }
59}
60
61export type Frame = (x: number, y: number, w: number, h: number, attrs: string, r?: number) => string
62type Theme = { accent: string; accent2: string; done: string; text: string; subtext: string; muted: string; tile: string; track: string; line: string; alert?: string }
63
64const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
65
66// '#rgb' or '#rrggbb' as [r, g, b] in 0..255; null for anything else.
67const rgbOf = (hex: string): [number, number, number] | null => {
68 const m = /^#?([\da-f]{3}|[\da-f]{6})$/i.exec(hex.trim())
69 if (!m) return null
70 const h = (m[1] as string).length === 3 ? [...(m[1] as string)].map(ch => ch + ch).join('') : (m[1] as string)
71 return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)) as [number, number, number]
72}
73// WCAG relative luminance, 0 (black) .. 1 (white).
74const luminance = (hex: string): number => {
75 const rgb = rgbOf(hex)
76 if (!rgb) return 0
77 const [r, g, b] = rgb.map(v => {
78 const s = v / 255
79 return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
80 }) as [number, number, number]
81 return 0.2126 * r + 0.7152 * g + 0.0722 * b
82}
83// The ink for text drawn on a solid `bg`: near-black on light fills, white on dark ones.
84export const inkOn = (bg: string): string => (luminance(bg) > 0.18 ? '#111111' : '#FFFFFF')
85// Two colours a glance can tell apart ("redmean" distance; unparseable ones count as apart).
86const isApart = (a: string, b: string): boolean => {
87 const x = rgbOf(a)
88 const y = rgbOf(b)
89 if (!x || !y) return a.toLowerCase() !== b.toLowerCase()
90 const rm = (x[0] + y[0]) / 2
91 const [dr, dg, db] = [x[0] - y[0], x[1] - y[1], x[2] - y[2]]
92 return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db) >= 150
93}
94// Glyphs macOS may draw as colour emoji, held to their text form with U+FE0E.
95const asText = (s: string): string => s.replace(/([⚙▶◀⏮⚠▫‼])(?!\uFE0E)/gu, '$1\uFE0E')
96
97// Rough advance of UI text in px (bold, a touch wide on purpose): enough to keep
98// a label inside its slot at narrow widths.
99const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿가-힣豈-!-⦆☀-➿\u{1F300}-\u{1FAFF}]/u
100export const textW = (s: string, size: number, ls = 0): number =>
101 [...s].reduce((w, ch) => (ch === '\uFE0E' ? w : w + (WIDE.test(ch) ? 1 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.32 : /[A-Z@%MW]/.test(ch) ? 0.74 : 0.6) * size + ls), 0)
102// `s` cut to fit `maxW`, with an ellipsis when cut; '' when not even that fits.
103export const clipW = (s: string, size: number, maxW: number, ls = 0): string => {
104 if (textW(s, size, ls) <= maxW) return s
105 const chars = [...s]
106 while (chars.length && textW(`${chars.join('')}…`, size, ls) > maxW) chars.pop()
107 return chars.length ? `${chars.join('').trimEnd()}…` : ''
108}
109// The largest size from `max` down to `min` at which `s` fits `maxW`.
110const fitSize = (s: string, max: number, min: number, maxW: number, ls = 0): number => {
111 let size = max
112 while (size > min && textW(s, size, ls) > maxW) size -= 0.5
113 return size
114}
115
116// The replay's selection when the timeline drops its `dropped` oldest beats: it
117// follows its beat, and holds on the oldest kept one if its own was dropped.
118export const shiftPick = (pick: number, dropped: number): number => (pick < 0 || dropped <= 0 ? pick : Math.max(0, pick - dropped))
119// One step back (-1) or forward (+1) through `n` beats; -1 is "live" (the newest).
120export const stepPick = (pick: number, n: number, d: -1 | 1): number => {
121 if (n <= 0) return -1
122 const at = pick < 0 || pick >= n ? n - 1 : pick
123 if (d > 0) return pick < 0 || at + 1 >= n ? -1 : at + 1
124 return Math.max(0, at - 1)
125}
126
127// Ranks by level, the way Night City sizes you up.
128export const rankOf = (level: number): string =>
129 level >= 20 ? 'LEGEND OF THE AFTERLIFE' : level >= 15 ? 'EDGERUNNER' : level >= 10 ? 'SOLO' : level >= 7 ? 'MERC' : level >= 4 ? 'PUNK' : level >= 2 ? 'STREET RAT' : 'NOBODY'
130// SAMURAI climbs a Rockerboy's ladder instead.
131export const rockerRankOf = (level: number): string =>
132 level >= 20 ? 'LEGEND' : level >= 10 ? 'ROCKERBOY' : level >= 4 ? 'HEADLINER' : level >= 2 ? 'ROADIE' : 'OPENING ACT'
133
134const hexPath = (cx: number, cy: number, r: number): string =>
135 Array.from({ length: 6 }, (_, i) => {
136 const a = (Math.PI / 3) * i - Math.PI / 2
137 return `${i ? 'L' : 'M'}${(cx + r * Math.cos(a)).toFixed(1)} ${(cy + r * Math.sin(a)).toFixed(1)}`
138 }).join('') + 'Z'
139
140const CRED_CSS = `.spin{animation:spin 9s linear infinite;transform-box:fill-box;transform-origin:center}@keyframes spin{to{transform:rotate(360deg)}}
141.pulse{animation:pulse 2.2s ease-in-out infinite}@keyframes pulse{50%{opacity:.45}}
142.sweep{animation:sweep 2.8s ease-in-out infinite}@keyframes sweep{from{transform:translateX(-40px);opacity:0}25%{opacity:.9}to{transform:translateX(var(--sw));opacity:0}}
143.fresh{animation:fresh 1.4s ease-in-out infinite}@keyframes fresh{50%{opacity:.35}}
144.gl1{animation:gl 5s steps(1) infinite}@keyframes gl{0%,93%,100%{transform:translate(0,0)}94%{transform:translate(-2px,0)}96%{transform:translate(2px,1px)}}
145@media (prefers-reduced-motion: reduce){.spin,.pulse,.sweep,.fresh,.gl1{animation:none}.sweep{opacity:0}}`
146
147const MED = 46
148// Below this width the stats leave the emblem's side for two rows under it.
149const NARROW = 360
150// The head: emblem, rank, bar and stats; the medallions follow in cells at least
151// 100px wide, so the longest name ("AFTERLIFE LEGEND") keeps clear of its neighbours.
152const credHead = (W: number): number => (W < NARROW ? 206 : 146)
153// (the medallion rows start a little lower, under the "NEXT" line)
154const credPer = (W: number): number => Math.max(1, Math.floor((W - 28) / 100))
155export const credHeight = (W: number): number => credHead(W) + 20 + Math.ceil(BADGES.length / credPer(W)) * 70
156export const credCard = (W: number, c: Cred, t: Theme, font: string, frame: Frame, title: string, kana = '', ranks: (level: number) => string = rankOf): string => {
157 const H = credHeight(W)
158 const head = credHead(W)
159 const isNarrow = W < NARROW
160 const level = levelOf(c.xp)
161 const lo = xpFor(level)
162 const hi = xpFor(level + 1)
163 const share = Math.max(0, Math.min(1, (c.xp - lo) / Math.max(1, hi - lo)))
164 // The emblem: a hex with the level, a ring of 12 segments lit by the progress, a scan ring turning.
165 const cx = 66
166 const cy = 72
167 const segs = Array.from({ length: 12 }, (_, i) => {
168 const a0 = (Math.PI * 2 * i) / 12 - Math.PI / 2 + 0.06
169 const a1 = (Math.PI * 2 * (i + 1)) / 12 - Math.PI / 2 - 0.06
170 const r = 46
171 const on = i < Math.round(share * 12)
172 return `<path d="M${(cx + r * Math.cos(a0)).toFixed(1)} ${(cy + r * Math.sin(a0)).toFixed(1)}A${r} ${r} 0 0 1 ${(cx + r * Math.cos(a1)).toFixed(1)} ${(cy + r * Math.sin(a1)).toFixed(1)}" fill="none" stroke="${on ? t.accent : t.line}" stroke-width="5"${on && i === Math.round(share * 12) - 1 ? ' class="pulse"' : ''}/>`
173 }).join('')
174 const emblem = `<g filter="url(#cg)"><path d="${hexPath(cx, cy, 34)}" fill="${t.accent}" fill-opacity=".14" stroke="${t.accent}" stroke-width="2"/></g>
175<path d="${hexPath(cx, cy, 27)}" fill="none" stroke="${t.accent}" stroke-opacity=".35"/>
176<circle class="spin" cx="${cx}" cy="${cy}" r="54" fill="none" stroke="${t.accent2}" stroke-opacity=".55" stroke-width="1" stroke-dasharray="3 9 22 9"/>
177${segs}
178<text x="${cx + 1.5}" y="${cy + 13}" text-anchor="middle" font-family="${font}" font-size="34" font-weight="900" fill="${t.accent2}" fill-opacity=".55" font-variant-numeric="tabular-nums">${level}</text>
179<text x="${cx}" y="${cy + 12}" text-anchor="middle" font-family="${font}" font-size="34" font-weight="900" fill="${t.text}" font-variant-numeric="tabular-nums">${level}</text>
180<text x="${cx}" y="${cy - 17}" text-anchor="middle" font-family="${font}" font-size="9" font-weight="900" letter-spacing="1.5" fill="${t.accent}">LVL</text>`
181 // The rank and the XP: an angled, segmented bar with a light running along it.
182 const rx = 136
183 const barW = Math.max(80, W - rx - 18)
184 const n = Math.max(10, Math.floor(barW / 12))
185 const sw = barW / n
186 const lit = Math.round(share * n)
187 const bar = Array.from({ length: n }, (_, i) => {
188 const x = rx + i * sw
189 const on = i < lit
190 return `<path d="M${(x + 3).toFixed(1)} 84h${(sw - 3).toFixed(1)}l-3 10h-${(sw - 3).toFixed(1)}z" fill="${on ? t.accent : t.track}"${on ? ` fill-opacity="${(0.5 + 0.5 * ((i + 1) / Math.max(1, lit))).toFixed(2)}"` : ''}/>`
191 }).join('')
192 const rank = ranks(level)
193 const stat = (x: number, y: number, slot: number, k: string, v: string) =>
194 `<text x="${x.toFixed(1)}" y="${y}" font-family="${font}" font-size="9" font-weight="800" letter-spacing=".8" fill="${t.muted}">${esc(clipW(k, 9, slot - 6, 0.8))}</text><text x="${x.toFixed(1)}" y="${y + 13}" font-family="${font}" font-size="13" font-weight="800" fill="${v === '0' ? t.muted : t.text}" font-variant-numeric="tabular-nums">${v === '0' ? '—' : esc(clipW(v, 13, slot - 6))}</text>`
195 const statList: [string, string][] = [
196 ['GIGS', `${c.jobs}`],
197 ['MAJOR GIGS', `${c.longJobs}`],
198 ['AGENTS', `${c.agents}`],
199 ['BADGES', `${c.badges.length}/${BADGES.length}`],
200 ]
201 // Wide: one row beside the emblem. Narrow: two rows of two under it, edge to edge.
202 const stats = isNarrow
203 ? statList.map(([k, v], i) => stat(14 + (i % 2) * ((W - 28) / 2), 152 + Math.floor(i / 2) * 28, (W - 28) / 2, k, v)).join('')
204 : statList.map(([k, v], i) => stat(rx + i * Math.min(110, barW / 4), 116, Math.min(110, barW / 4), k, v)).join('')
205 // The right column's text, sized to its room: shrunk first, then cut.
206 const room = W - rx - 14
207 const rankSize = fitSize(rank, rank.length > 14 ? 17 : 24, 10, room, 1.5)
208 const rankText = clipW(rank, rankSize, room, 1.5)
209 const titleSize = fitSize(title, 10, 7, room, 2)
210 const titleText = clipW(title, titleSize, room, 2)
211 const showKana = kana !== '' && textW(titleText, titleSize, 2) + 8 + textW(kana, 7, 1) <= room
212 const toGo = hi - c.xp
213 const xpTail = textW(`${c.xp} XP · ${toGo} to level ${level + 1}`, 11) <= room ? ` XP · ${toGo} to level ${level + 1}` : textW(`${c.xp} XP · +${toGo}`, 11) <= room ? ` XP · +${toGo}` : ' XP'
214 // The medallions: hexes with an icon; earned ones glow, locked ones are dark with a "?".
215 const per = credPer(W)
216 const cell = (W - 28) / per
217 const meds = BADGES.map((b, i) => {
218 const on = c.badges.includes(b.id)
219 const mx = 14 + (i % per) * cell + cell / 2
220 const my = head + 40 + Math.floor(i / per) * 70
221 const label = on ? b.name : 'CLASSIFIED'
222 const labelSize = fitSize(label, 8.5, 6.5, cell - 8, 0.6)
223 const p = on ? 1 : b.progress(c)
224 const ring = !on && p > 0 ? `<path d="${hexPath(mx, my, MED / 2)}" pathLength="100" fill="none" stroke="${t.accent}" stroke-width="2" stroke-dasharray="${(p * 100).toFixed(1)} 100"/>` : ''
225 return `<g${on ? ' filter="url(#cg)"' : ''}><path d="${hexPath(mx, my, MED / 2)}" fill="${on ? t.accent : t.tile}" fill-opacity="${on ? 0.2 : 1}" stroke="${on ? t.accent : t.line}" stroke-width="${on ? 1.8 : 1}"${on ? '' : ' stroke-dasharray="3 3"'}/></g>${ring}
226<text x="${mx}" y="${my + 7}" text-anchor="middle" font-family="'Apple Symbols','Segoe UI Symbol','Noto Sans Symbols 2',sans-serif" font-size="19" fill="${on ? t.accent : t.text}" fill-opacity="${on ? 1 : 0.13}">${asText(b.icon)}<title>${esc(b.name)}: ${esc(b.how)}${on ? '' : ` (${Math.round(p * 100)}%)`}</title></text>
227<text x="${mx}" y="${my + MED / 2 + 15}" text-anchor="middle" font-family="${font}" font-size="${labelSize}" font-weight="800" letter-spacing=".6" fill="${on ? t.text : t.muted}">${esc(clipW(label, labelSize, cell - 8, 0.6))}</text>`
228 }).join('')
229 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><style>${CRED_CSS}</style>
230<defs><filter id="cg" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="2.4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
231<radialGradient id="cbg" cx="${cx}" cy="${cy}" r="80" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${t.accent}" stop-opacity=".12"/><stop offset="1" stop-color="${t.accent}" stop-opacity="0"/></radialGradient>
232<clipPath id="cxb"><rect x="${rx}" y="84" width="${barW.toFixed(1)}" height="10"/></clipPath></defs>
233${frame(0.5, 0.5, W - 1, H - 1, `fill="${t.tile}" stroke="${t.accent}" stroke-opacity=".35"`, 10)}
234<circle cx="${cx}" cy="${cy}" r="80" fill="url(#cbg)"/>
235${emblem}
236<text x="${rx}" y="26" font-family="${font}" font-size="${titleSize}" font-weight="800" letter-spacing="2" fill="${t.accent2}">${esc(titleText)}${showKana ? `<tspan dx="8" font-size="7" letter-spacing="1" fill-opacity=".55">${esc(kana)}</tspan>` : ''}</text>
237<g class="gl1"><text x="${rx}" y="56" font-family="${font}" font-size="${rankSize}" font-weight="900" letter-spacing="1.5" fill="${t.accent}">${esc(rankText)}</text></g>
238<text x="${rx}" y="77" font-family="${font}" font-size="11" fill="${t.subtext}" font-variant-numeric="tabular-nums"><tspan font-weight="800" fill="${t.text}">${c.xp}</tspan>${xpTail}</text>
239${bar}
240<g clip-path="url(#cxb)"><rect class="sweep" style="--sw:${barW + 40}px" x="${rx}" y="83" width="28" height="12" fill="#ffffff" fill-opacity=".5" transform="skewX(-16)"/></g>
241${stats}
242<line x1="14" y1="${head + 0.5}" x2="${W - 14}" y2="${head + 0.5}" stroke="${t.line}"/>
243${(() => {
244 const next = BADGES.filter(b => !c.badges.includes(b.id)).sort((a, b2) => b2.progress(c) - a.progress(c))[0]
245 const line = next ? `NEXT: ${next.name} · ${next.how}` : 'ALL BADGES EARNED. PREEM.'
246 return `<text x="${W - 14}" y="${head + 12}" text-anchor="end" font-family="${font}" font-size="8.5" font-weight="800" letter-spacing=".8" fill="${t.muted}">${esc(clipW(line, 8.5, W - 28, 0.8))}</text>`
247})()}
248${meds}
249</svg>`
250}
251
252// --- Braindance: a replay of the session's tool calls, styled like the BD editor ---
253
254export type Beat = { at: number; tool: string; text: string; who: string }
255export const BEATS_KEPT = 160
256
257const kindOf = (tool: string): 'read' | 'edit' | 'run' | 'agent' | 'web' | 'other' =>
258 /^(Read|Grep|Glob|LS)$/.test(tool) ? 'read'
259 : /^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool) ? 'edit'
260 : /^(Bash|PowerShell)$/.test(tool) ? 'run'
261 : tool === 'Agent' ? 'agent'
262 : /^Web/.test(tool) ? 'web'
263 : 'other'
264
265// The BD editor's layers: what you saw, what you heard, what ran hot.
266type Layer = 'VISUAL' | 'AUDIO' | 'THERMAL'
267const LAYER: Record<string, Layer> = { read: 'VISUAL', other: 'VISUAL', web: 'AUDIO', agent: 'AUDIO', edit: 'THERMAL', run: 'THERMAL' }
268const layerName = (kind: string): Layer => LAYER[kind] ?? 'VISUAL'
269// The game's own colours, for a caller without a theme.
270const GAME: Record<Layer, string> = { VISUAL: '#FCEE0A', AUDIO: '#2DE2E6', THERMAL: '#FF5A1F' }
271// The skin's colours for the layers: accent, accent2, alert (or accent). When one
272// would read as another's colour, it takes the text colour instead.
273export const layerColors = (t: Theme): Record<Layer, string> => {
274 const visual = t.accent
275 const audio = isApart(t.accent2, visual) ? t.accent2 : t.text
276 const hot = t.alert ?? t.accent
277 const thermal = isApart(hot, visual) && isApart(hot, audio) ? hot : t.text
278 return { VISUAL: visual, AUDIO: audio, THERMAL: thermal }
279}
280export const layerOf = (tool: string, t?: Theme): { name: string; color: string } => {
281 const name = layerName(kindOf(tool))
282 return { name, color: t ? layerColors(t)[name] : GAME[name] }
283}
284
285// Up to five lanes: the Manager's, then whoever acted, in order of appearance.
286const bdRows = (beats: Beat[]): string[] => ['Manager', ...[...new Set(beats.map(b => b.who))].filter(w => w !== 'Manager')].slice(0, 5)
287// The card's height for these beats: header and ruler, one 17px lane per row shown, the detail line.
288export const bdHeight = (beats: Beat[]): number => 91 + 17 * bdRows(beats).length
289// The tallest card (five lanes); the default room to keep for it.
290export const BD_H = 176
291export const braindanceCard = (W: number, beats: Beat[], pick: number, t: Theme, font: string, frame: Frame, title: string, _tone?: (k: string) => string): string => {
292 // The layers in the skin's colours; a caller may pass its own.
293 const colors = layerColors(t)
294 const tone = (k: string) => (_tone ? _tone(k) : colors[layerName(k)])
295 const H = bdHeight(beats)
296 const x0 = 104
297 const x1 = W - 16
298 const trackW = Math.max(40, x1 - x0)
299 const rows = bdRows(beats)
300 const t0 = beats[0]?.at ?? 0
301 const t1 = Math.max(t0 + 1, beats.at(-1)?.at ?? t0 + 1)
302 const xOf = (at: number) => x0 + ((at - t0) / (t1 - t0)) * trackW
303 const yOf = (w: string) => 58 + Math.max(0, rows.indexOf(w)) * 17
304 const sel = beats.length ? Math.max(0, Math.min(beats.length - 1, pick < 0 ? beats.length - 1 : pick)) : -1
305 // Timecode from the start of the recording, like the editor's: 00:06:25.
306 const time = (at: number) => {
307 const sec = Math.max(0, Math.round((at - t0) / 1000))
308 return `${String(Math.floor(sec / 3600)).padStart(2, '0')}:${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`
309 }
310 // A ruler with ticks and times, like the editor's.
311 const ticks = Array.from({ length: 11 }, (_, i) => {
312 const x = x0 + (trackW * i) / 10
313 const major = i % 5 === 0
314 const isLabelled = major && beats.length > 0 && (i !== 5 || trackW >= 150)
315 return `<line x1="${x.toFixed(1)}" y1="${major ? 38 : 41}" x2="${x.toFixed(1)}" y2="45" stroke="${t.muted}" stroke-opacity="${major ? 0.8 : 0.4}"/>${isLabelled ? `<text x="${x.toFixed(1)}" y="35" text-anchor="${i === 0 ? 'start' : i === 10 ? 'end' : 'middle'}" font-family="${font}" font-size="8.5" fill="${t.muted}" font-variant-numeric="tabular-nums">${time(t0 + ((t1 - t0) * i) / 10)}</text>` : ''}`
316 }).join('')
317 const lanes = rows
318 .map((w, i) => {
319 const y = yOf(w)
320 return `<rect x="${x0}" y="${y - 7}" width="${trackW}" height="14" fill="${t.track}" fill-opacity="${i % 2 ? 0.35 : 0.6}"/>
321<text x="16" y="${y + 3.5}" font-family="${font}" font-size="10" font-weight="700" letter-spacing=".4" fill="${t.subtext}">${esc(clipW(w.toUpperCase(), 10, x0 - 22, 0.4))}</text>`
322 })
323 .join('')
324 const s = sel >= 0 ? (beats[sel] as Beat) : null
325 // Each layer has its own mark, so it reads without its colour: a bar for VISUAL,
326 // a diamond for AUDIO, a triangle for THERMAL. The picked beat's is larger and outlined.
327 const mark = (b: Beat, isPicked: boolean): string => {
328 const kind = kindOf(b.tool)
329 const x = xOf(b.at)
330 const y = yOf(b.who)
331 const fill = `fill="${tone(kind)}"${isPicked ? ` stroke="${t.text}" stroke-width="1"` : ' fill-opacity=".85"'}`
332 const hh = isPicked ? 7 : 6
333 const layer = layerName(kind)
334 if (layer === 'AUDIO') {
335 const hw = isPicked ? 4 : 3
336 return `<path d="M${x.toFixed(1)} ${y - hh}l${hw} ${hh}l-${hw} ${hh}l-${hw} -${hh}z" ${fill}/>`
337 }
338 if (layer === 'THERMAL') {
339 const hw = isPicked ? 4.5 : 3.5
340 return `<path d="M${x.toFixed(1)} ${y - hh}l${hw} ${hh * 2}h-${hw * 2}z" ${fill}/>`
341 }
342 const w = isPicked ? 5 : 3
343 return `<rect x="${(x - w / 2).toFixed(1)}" y="${y - hh}" width="${w}" height="${hh * 2}" rx="1" ${fill}/>`
344 }
345 const marks = beats
346 .filter(b => rows.includes(b.who))
347 .map(b => mark(b, b === s))
348 .join('')
349 const bottom = 58 + (rows.length - 1) * 17 + 9
350 const head = s
351 ? `<rect x="${(xOf(s.at) - 8).toFixed(1)}" y="46" width="16" height="${bottom - 46}" fill="${t.accent}" fill-opacity=".10"/>
352<line x1="${xOf(s.at).toFixed(1)}" y1="44" x2="${xOf(s.at).toFixed(1)}" y2="${bottom}" stroke="${t.accent}" stroke-width="1.6" filter="url(#bg)"/>
353<path d="M${xOf(s.at).toFixed(1)} 46l-5 -7h10z" fill="${t.accent}"/>`
354 : ''
355 // The selected beat's line, fitted to the box: the index always, then the tool,
356 // who did it, the time, and the text with whatever room is left.
357 const detail = (() => {
358 const box = `<rect x="14" y="${H - 34}" width="${W - 28}" height="22" rx="3" fill="${t.accent}" fill-opacity=".08" stroke="${t.accent}" stroke-opacity=".3"/>`
359 if (!s) {
360 const empty = W >= 520 ? 'No recording yet: every tool call lands on the timeline as the session runs.' : 'No recording yet.'
361 return `<text x="22" y="${H - 19}" font-family="${font}" font-size="12" fill="${t.muted}">${esc(clipW(empty, 12, W - 44))}</text>`
362 }
363 const SEP = 10
364 let left = W - 44
365 const idx = `◆ ${sel + 1}/${beats.length}`
366 left -= textW(idx, 12) + SEP
367 const tool = clipW(s.tool, 12, Math.max(0, Math.min(150, left * 0.5)))
368 if (tool) left -= textW(tool, 12) + SEP
369 const who = left >= 40 ? clipW(s.who, 12, Math.min(110, left * 0.6)) : ''
370 if (who) left -= textW(who, 12) + SEP
371 const clock = time(s.at)
372 const showTime = left >= textW(clock, 12) + SEP + 60
373 if (showTime) left -= textW(clock, 12) + SEP
374 const text = s.text && left >= 30 ? clipW(s.text, 12, left) : ''
375 const parts = [
376 `<tspan fill="${t.accent}" font-weight="900">${idx}</tspan>`,
377 showTime ? `<tspan fill="${t.subtext}" font-variant-numeric="tabular-nums">${clock}</tspan>` : '',
378 who ? `<tspan font-weight="800">${esc(who)}</tspan>` : '',
379 tool ? `<tspan fill="${tone(kindOf(s.tool))}" font-weight="700">${esc(tool)}</tspan>` : '',
380 text ? esc(text) : '',
381 ].filter(Boolean)
382 return `${box}
383<text x="22" y="${H - 19}" font-family="${font}" font-size="12" fill="${t.text}" xml:space="preserve">${parts.join(' ')}</text>`
384 })()
385 // The layer tabs; the selected beat's layer is lit.
386 const active = s ? layerOf(s.tool).name : 'VISUAL'
387 const legend = (['VISUAL', 'AUDIO', 'THERMAL'] as const)
388 .map((name, i) => {
389 const color = tone(name === 'VISUAL' ? 'read' : name === 'AUDIO' ? 'web' : 'edit')
390 const x = W - 14 - (3 - i) * 74
391 const on = name === active
392 return `<path d="M${x + 4} 10h64l-4 14h-64z" fill="${on ? color : 'none'}" fill-opacity="${on ? 0.9 : 0}" stroke="${color}" stroke-opacity="${on ? 1 : 0.55}"/><text x="${x + 34}" y="21" text-anchor="middle" font-family="${font}" font-size="9.5" font-weight="900" letter-spacing="1" fill="${on ? inkOn(color) : color}">${name}</text>`
393 })
394 .join('')
395 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><style>.rec{animation:rec 1.2s steps(1) infinite}@keyframes rec{50%{opacity:.2}}@media (prefers-reduced-motion: reduce){.rec{animation:none}}</style>
396<defs><filter id="bg" x="-200%" y="-10%" width="500%" height="120%"><feGaussianBlur stdDeviation="1.6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
397${frame(0.5, 0.5, W - 1, H - 1, `fill="${t.tile}" stroke="${t.accent}" stroke-opacity=".35"`, 10)}
398<rect x="16" y="11" width="3" height="14" fill="${t.accent}"/>
399<text x="25" y="23" font-family="${font}" font-size="14" font-weight="800" letter-spacing=".8" fill="${t.text}">${esc(title)}</text>
400<text class="rec" x="${(25 + textW(title, 14, 0.8) + 12).toFixed(1)}" y="22.5" font-family="${font}" font-size="9" font-weight="900" letter-spacing="1" fill="${t.accent2}">▶\uFE0E PLAYBACK</text>
401${W >= 520 && W - 236 > 25 + textW(title, 14, 0.8) + 100 ? legend : ''}
402${ticks}
403${lanes}${marks}${head}
404${detail}
405</svg>`
406}
407
408// --- Boot-up: a strip that plays its jack-in once, when the pane is drawn, then rests.
409// Its source never changes between redraws, so the animation does not restart.
410export const BOOT_H = 30
411export const bootStrip = (W: number, t: Theme, font: string, line: string, done: string): string => {
412 // The loading bar keeps to wide panes; the words shrink, then cut, to their room.
413 const hasBar = W >= 360
414 const room = (hasBar ? W - 132 - 12 : W - 12) - 12
415 const lineSize = fitSize(line, 11, 8, room - 10, 1.2)
416 const doneSize = fitSize(done, 11, 8, room, 1.2)
417 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${BOOT_H}" viewBox="0 0 ${W} ${BOOT_H}"><style>
418.bt1{animation:bt1 1.8s steps(1) both}@keyframes bt1{0%{opacity:1}99%{opacity:1}100%{opacity:0}}
419.bt2{animation:bt2 1.8s steps(1) both}@keyframes bt2{0%{opacity:0}99%{opacity:0}100%{opacity:1}}
420.bar{animation:bar 1.6s steps(12) both}@keyframes bar{from{transform:scaleX(0)}to{transform:scaleX(1)}}
421.bc{animation:bc 1s steps(1) infinite}@keyframes bc{50%{opacity:0}}
422.ty{transform-origin:0 0;animation:ty 1.4s steps(28) 1.8s both}@keyframes ty{from{transform:scaleX(0)}to{transform:scaleX(1)}}
423@media (prefers-reduced-motion: reduce){.bt1{animation:none;opacity:0}.bt2,.bar,.bc,.ty{animation:none}.bt2{opacity:1}}
424</style>
425<rect x="0.5" y="0.5" width="${W - 1}" height="${BOOT_H - 1}" rx="4" fill="${t.tile}" stroke="${t.line}"/>
426<g class="bt1"><text x="12" y="19" font-family="${font}" font-size="${lineSize}" font-weight="800" letter-spacing="1.2" fill="${t.accent2}">${esc(clipW(line, lineSize, room - 10, 1.2))}<tspan class="bc">▮</tspan></text></g>
427<defs><clipPath id="bty"><rect class="ty" x="0" y="0" width="${W}" height="${BOOT_H}"/></clipPath></defs>
428<g class="bt2" clip-path="url(#bty)"><text x="12" y="19" font-family="${font}" font-size="${doneSize}" font-weight="800" letter-spacing="1.2" fill="${t.done}">${esc(clipW(done, doneSize, room, 1.2))}</text></g>
429${hasBar ? `<rect x="${W - 132}" y="11" width="120" height="8" rx="1" fill="${t.track}"/>
430<rect class="bar" style="transform-origin:${W - 132}px 15px" x="${W - 132}" y="11" width="120" height="8" rx="1" fill="${t.accent}"/>` : ''}
431</svg>`
432}
433hooks/richtext.ts 393 lines1// Rich chat blocks for the skins: edit diffs, a reply split into its fences,
2// tables and math, LaTeX as Unicode, and Mermaid flowcharts and pies as SVG.
3// Pure functions: no engine calls, so they are tested on their own.
4
5// ---------------------------------------------------------------------------
6// Edit diffs: one unified hunk from an edit's old and new text, the lines both
7// share at the ends kept as context.
8
9export type Diff = { source: string; added: number; removed: number }
10
11export const unifiedDiff = (oldText: string, newText: string, context = 2): Diff => {
12 const a = oldText.split(/\r?\n/)
13 const b = newText.split(/\r?\n/)
14 let head = 0
15 while (head < a.length && head < b.length && a[head] === b[head]) head++
16 let tail = 0
17 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
18 const pre = a.slice(Math.max(0, head - context), head)
19 const removed = a.slice(head, a.length - tail)
20 const added = b.slice(head, b.length - tail)
21 const post = a.slice(a.length - tail, a.length - tail + context)
22 const start = head - pre.length + 1
23 const lines = [...pre.map(l => ` ${l}`), ...removed.map(l => `-${l}`), ...added.map(l => `+${l}`), ...post.map(l => ` ${l}`)]
24 const oldCount = pre.length + removed.length + post.length
25 const newCount = pre.length + added.length + post.length
26 return {
27 source: `@@ -${start},${oldCount} +${start},${newCount} @@\n${lines.join('\n')}`,
28 added: added.length,
29 removed: removed.length,
30 }
31}
32
33// ---------------------------------------------------------------------------
34// A reply, split into the blocks the skins draw themselves.
35
36export type Block =
37 | { kind: 'md'; text: string }
38 | { kind: 'code'; text: string; lang: string }
39 | { kind: 'mermaid'; text: string }
40 | { kind: 'math'; text: string }
41 | { kind: 'table'; text: string }
42
43const isTableRow = (l: string): boolean => /^\s*\|.*\|\s*$/.test(l)
44const isTableRule = (l: string): boolean => /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/.test(l)
45
46export const splitBlocks = (text: string): Block[] => {
47 const out: Block[] = []
48 const lines = text.split(/\r?\n/)
49 let md: string[] = []
50 const flush = () => {
51 if (md.join('').trim()) out.push({ kind: 'md', text: md.join('\n') })
52 md = []
53 }
54 for (let i = 0; i < lines.length; i++) {
55 const line = lines[i] ?? ''
56 const fence = /^(\s*)(`{3,}|~{3,})\s*([\w+#.-]*)\s*$/.exec(line)
57 if (fence) {
58 const mark = fence[2] ?? '```'
59 const lang = (fence[3] ?? '').toLowerCase()
60 const body: string[] = []
61 let j = i + 1
62 while (j < lines.length && !(lines[j] ?? '').trim().startsWith(mark)) body.push(lines[j++] ?? '')
63 if (j >= lines.length) {
64 // An open fence (a reply still streaming) stays markdown.
65 md.push(line)
66 continue
67 }
68 flush()
69 out.push(lang === 'mermaid' ? { kind: 'mermaid', text: body.join('\n') } : lang === 'math' || lang === 'latex' || lang === 'tex' ? { kind: 'math', text: body.join('\n') } : { kind: 'code', text: body.join('\n'), lang })
70 i = j
71 continue
72 }
73 if (line.trim().startsWith('$$')) {
74 const body: string[] = []
75 const first = line.trim().slice(2)
76 if (first.endsWith('$$') && first.length > 2) {
77 flush()
78 out.push({ kind: 'math', text: first.slice(0, -2) })
79 continue
80 }
81 if (first) body.push(first)
82 let j = i + 1
83 while (j < lines.length && !(lines[j] ?? '').includes('$$')) body.push(lines[j++] ?? '')
84 if (j < lines.length) {
85 const last = (lines[j] ?? '').replace('$$', '').trim()
86 if (last) body.push(last)
87 flush()
88 out.push({ kind: 'math', text: body.join('\n') })
89 i = j
90 continue
91 }
92 }
93 if (isTableRow(line) && isTableRule(lines[i + 1] ?? '')) {
94 flush()
95 const rows = [line]
96 let j = i + 1
97 while (j < lines.length && (isTableRow(lines[j] ?? '') || isTableRule(lines[j] ?? ''))) rows.push(lines[j++] ?? '')
98 out.push({ kind: 'table', text: rows.join('\n') })
99 i = j - 1
100 continue
101 }
102 md.push(line)
103 }
104 flush()
105 return out
106}
107
108// ---------------------------------------------------------------------------
109// LaTeX as Unicode: Greek, operators, arrows, sub- and superscripts, fractions
110// and roots, so a formula reads as one in plain text.
111
112const SYMBOLS: Record<string, string> = {
113 alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', varepsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι', kappa: 'κ',
114 lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π', rho: 'ρ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω',
115 Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
116 sum: '∑', prod: '∏', int: '∫', oint: '∮', infty: '∞', partial: '∂', nabla: '∇', sqrt: '√',
117 cdot: '·', times: '×', div: '÷', pm: '±', mp: '∓', leq: '≤', le: '≤', geq: '≥', ge: '≥', neq: '≠', ne: '≠', approx: '≈', equiv: '≡', sim: '∼', propto: '∝',
118 to: '→', rightarrow: '→', leftarrow: '←', Rightarrow: '⇒', Leftarrow: '⇐', leftrightarrow: '↔', iff: '⇔', implies: '⇒', mapsto: '↦',
119 in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆', supset: '⊃', cup: '∪', cap: '∩', emptyset: '∅', forall: '∀', exists: '∃', neg: '¬', land: '∧', lor: '∨',
120 ldots: '…', cdots: '⋯', dots: '…', circ: '∘', degree: '°', angle: '∠', perp: '⊥', parallel: '∥', hbar: 'ℏ', ell: 'ℓ',
121 quad: ' ', qquad: ' ', ',': ' ', ';': ' ', '!': '', left: '', right: '', displaystyle: '', limits: '',
122}
123const BLACKBOARD: Record<string, string> = { R: 'ℝ', N: 'ℕ', Z: 'ℤ', Q: 'ℚ', C: 'ℂ', P: 'ℙ' }
124const SUP: Record<string, string> = {
125 '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', '+': '⁺', '-': '⁻', '=': '⁼', '(': '⁽', ')': '⁾',
126 a: 'ᵃ', b: 'ᵇ', c: 'ᶜ', d: 'ᵈ', e: 'ᵉ', f: 'ᶠ', g: 'ᵍ', h: 'ʰ', i: 'ⁱ', j: 'ʲ', k: 'ᵏ', l: 'ˡ', m: 'ᵐ', n: 'ⁿ', o: 'ᵒ', p: 'ᵖ', r: 'ʳ', s: 'ˢ', t: 'ᵗ', u: 'ᵘ', v: 'ᵛ', w: 'ʷ', x: 'ˣ', y: 'ʸ', z: 'ᶻ', T: 'ᵀ',
127}
128const SUB: Record<string, string> = {
129 '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉', '+': '₊', '-': '₋', '=': '₌', '(': '₍', ')': '₎',
130 a: 'ₐ', e: 'ₑ', h: 'ₕ', i: 'ᵢ', j: 'ⱼ', k: 'ₖ', l: 'ₗ', m: 'ₘ', n: 'ₙ', o: 'ₒ', p: 'ₚ', r: 'ᵣ', s: 'ₛ', t: 'ₜ', u: 'ᵤ', v: 'ᵥ', x: 'ₓ',
131}
132
133// The group after a script mark: `{...}` or one character.
134const takeGroup = (s: string, at: number): [string, number] => {
135 if (s[at] !== '{') return [s[at] ?? '', at + 1]
136 let depth = 0
137 for (let i = at; i < s.length; i++) {
138 if (s[i] === '{') depth++
139 else if (s[i] === '}' && --depth === 0) return [s.slice(at + 1, i), i + 1]
140 }
141 return [s.slice(at + 1), s.length]
142}
143
144const script = (text: string, table: Record<string, string>, mark: string): string => {
145 const plain = texToUnicode(text)
146 return [...plain].every(ch => table[ch]) ? [...plain].map(ch => table[ch]).join('') : `${mark}(${plain})`
147}
148
149export const texToUnicode = (src: string): string => {
150 let s = src
151 // Fractions and roots first: their groups may hold anything.
152 for (let guard = 0; guard < 20 && /\\(d|t)?frac\s*\{/.test(s); guard++) {
153 s = s.replace(/\\(?:d|t)?frac\s*/, '\u0000')
154 const at = s.indexOf('\u0000')
155 const [num, i1] = takeGroup(s, at + 1)
156 const [den, i2] = takeGroup(s, i1)
157 const wrap = (x: string) => (/^[\w.αβγδθλμπσφω∞]+$/.test(texToUnicode(x)) ? texToUnicode(x) : `(${texToUnicode(x)})`)
158 s = s.slice(0, at) + `${wrap(num)}⁄${wrap(den)}` + s.slice(i2)
159 }
160 for (let guard = 0; guard < 20 && /\\sqrt\s*\{/.test(s); guard++) {
161 s = s.replace(/\\sqrt\s*/, '\u0000')
162 const at = s.indexOf('\u0000')
163 const [body, end] = takeGroup(s, at + 1)
164 const inner = texToUnicode(body)
165 s = s.slice(0, at) + (inner.length === 1 ? `√${inner}` : `√(${inner})`) + s.slice(end)
166 }
167 s = s.replace(/\\mathbb\s*\{(\w)\}/g, (_, ch: string) => BLACKBOARD[ch] ?? ch)
168 s = s.replace(/\\(?:mathrm|mathbf|mathit|text|textbf|operatorname)\s*\{([^{}]*)\}/g, '$1')
169 s = s.replace(/\\([A-Za-z]+|[,;!])/g, (all, name: string) => (name in SYMBOLS ? (SYMBOLS[name] ?? '') : all))
170 let out = ''
171 for (let i = 0; i < s.length; ) {
172 const ch = s[i]
173 if (ch === '^' || ch === '_') {
174 const [group, next] = takeGroup(s, i + 1)
175 out += script(group, ch === '^' ? SUP : SUB, ch)
176 i = next
177 continue
178 }
179 if (ch === '{' || ch === '}') {
180 i++
181 continue
182 }
183 out += ch
184 i++
185 }
186 return out.replace(/[ \t]+/g, ' ').trim()
187}
188
189// Inline `$...$` in prose becomes Unicode; a `$` that reads as money is left alone.
190export const inlineMath = (md: string): string =>
191 md.replace(/(^|[^$\\\w])\$(?!\s)([^$\n]{1,200}?)(?<!\s)\$(?!\d)/g, (all, lead: string, body: string) =>
192 /[\\^_{}]|^[A-Za-z]$/.test(body) ? `${lead}${texToUnicode(body)}` : all,
193 )
194
195export const hasInlineMath = (md: string): boolean => inlineMath(md) !== md
196
197// ---------------------------------------------------------------------------
198// Mermaid, drawn: flowcharts (graph / flowchart, any direction) and pies. Any
199// other diagram returns null and is shown as code.
200
201export type ChartPalette = {
202 font: string
203 tile: string
204 text: string
205 subtext: string
206 line: string
207 accent: string
208 colors: string[]
209 chamfer: boolean
210}
211
212export type Chart = { svg: string; height: number }
213
214const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
215const textW = (s: string, size: number): number => [...s].length * size * 0.56
216
217type Node = { id: string; label: string; shape: 'box' | 'round' | 'diamond' | 'circle' }
218type Edge = { from: string; to: string; label: string; dashed: boolean }
219
220const NODE_RE = /^([A-Za-z0-9_\-]+)\s*(?:(\(\(|\(\[|\[\[|\[\(|\{\{|\[|\(|\{|>)(.*?)(\)\)|\]\)|\]\]|\)\]|\}\}|\]|\)|\}))?\s*$/
221const ARROW_RE = /\s*(-\.->|-\.-|==>|-->|---|--\s*[^->|]+?\s*-->)\s*(?:\|([^|]*)\|)?\s*/
222
223const parseNode = (token: string, nodes: Map<string, Node>): string | null => {
224 const m = NODE_RE.exec(token.trim())
225 if (!m) return null
226 const id = m[1] ?? ''
227 const open = m[2] ?? ''
228 const label = (m[3] ?? '').replace(/^["']|["']$/g, '').trim()
229 const shape: Node['shape'] = open === '{' || open === '{{' ? 'diamond' : open === '((' ? 'circle' : open === '(' || open === '([' ? 'round' : 'box'
230 const known = nodes.get(id)
231 if (!known || (label && known.label === known.id)) nodes.set(id, { id, label: label || known?.label || id, shape: open ? shape : known?.shape ?? 'box' })
232 return id
233}
234
235const flowchart = (src: string, W: number, p: ChartPalette): Chart | null => {
236 const lines = src.split(/\n|;/).map(l => l.trim()).filter(l => l && !l.startsWith('%%'))
237 const head = /^(graph|flowchart)\s*(TD|TB|BT|LR|RL)?/i.exec(lines[0] ?? '')
238 if (!head) return null
239 const dir = (head[2] ?? 'TD').toUpperCase()
240 const isAcross = dir === 'LR' || dir === 'RL'
241 const nodes = new Map<string, Node>()
242 const edges: Edge[] = []
243 for (const line of lines.slice(1)) {
244 if (/^(subgraph|end|classDef|class|style|linkStyle|click|direction)\b/.test(line)) continue
245 const parts = line.split(ARROW_RE)
246 // parts: node, arrow, label, node, arrow, label, node...
247 let prev = parseNode(parts[0] ?? '', nodes)
248 for (let i = 1; i + 2 < parts.length; i += 3) {
249 const arrow = parts[i] ?? ''
250 const inline = /--\s*([^->|]+?)\s*-->/.exec(arrow)?.[1] ?? ''
251 const label = (parts[i + 1] ?? inline ?? '').trim()
252 const next = parseNode(parts[i + 2] ?? '', nodes)
253 if (prev && next) edges.push({ from: prev, to: next, label, dashed: arrow.includes('.') })
254 prev = next
255 }
256 }
257 if (!nodes.size || nodes.size > 40) return null
258 // Layers: the longest path from a source, cycles cut.
259 const layer = new Map<string, number>([...nodes.keys()].map(id => [id, 0]))
260 for (let pass = 0; pass < nodes.size; pass++) {
261 let moved = false
262 for (const e of edges) {
263 const want = (layer.get(e.from) ?? 0) + 1
264 if (e.from !== e.to && want > (layer.get(e.to) ?? 0) && want < nodes.size) {
265 layer.set(e.to, want)
266 moved = true
267 }
268 }
269 if (!moved) break
270 }
271 const layers: string[][] = []
272 for (const id of nodes.keys()) (layers[layer.get(id) ?? 0] ??= []).push(id)
273 const H_NODE = 34
274 const size = (n: Node) => ({ w: Math.max(64, Math.min(200, textW(n.label, 12.5) + 28)), h: n.shape === 'diamond' ? 44 : H_NODE })
275 const pos = new Map<string, { x: number; y: number; w: number; h: number }>()
276 const gapMain = isAcross ? Math.max(150, Math.min(240, W / Math.max(1, layers.length))) : 86
277 layers.forEach((ids, li) => {
278 ids.forEach((id, k) => {
279 const n = nodes.get(id) as Node
280 const { w, h } = size(n)
281 if (isAcross) {
282 const x = 16 + li * gapMain + (gapMain - 32 - w) / 2 + w / 2
283 const y = 30 + k * 62 + h / 2
284 pos.set(id, { x: dir === 'RL' ? W - x : x, y, w, h })
285 } else {
286 const slot = W / ids.length
287 const x = slot * k + slot / 2
288 const y = 24 + li * gapMain + h / 2
289 pos.set(id, { x, y, w, h })
290 }
291 })
292 })
293 const maxY = Math.max(...[...pos.values()].map(q => q.y + q.h / 2)) + 24
294 if (dir === 'BT') for (const q of pos.values()) q.y = maxY - q.y
295 const height = Math.min(4000, Math.round(isAcross ? Math.max(...[...pos.values()].map(q => q.y + q.h / 2)) + 24 : layers.length * gapMain + 30))
296 const colorOf = (id: string) => p.colors[(layer.get(id) ?? 0) % p.colors.length] ?? p.accent
297 const arrows = edges
298 .map(e => {
299 const a = pos.get(e.from)
300 const b = pos.get(e.to)
301 if (!a || !b) return ''
302 const [x1, y1, x2, y2] = isAcross
303 ? [a.x + (b.x >= a.x ? a.w / 2 : -a.w / 2), a.y, b.x - (b.x >= a.x ? b.w / 2 : -b.w / 2), b.y]
304 : [a.x, a.y + (b.y >= a.y ? a.h / 2 : -a.h / 2), b.x, b.y - (b.y >= a.y ? b.h / 2 : -b.h / 2)]
305 const mid = isAcross ? `C${(x1 + x2) / 2} ${y1} ${(x1 + x2) / 2} ${y2}` : `C${x1} ${(y1 + y2) / 2} ${x2} ${(y1 + y2) / 2}`
306 const label = e.label
307 ? `<text x="${(x1 + x2) / 2}" y="${(y1 + y2) / 2 - 4}" text-anchor="middle" font-family="${p.font}" font-size="11" fill="${p.subtext}" paint-order="stroke" stroke="${p.tile}" stroke-width="4">${esc(e.label)}</text>`
308 : ''
309 return `<path d="M${x1} ${y1}${mid} ${x2} ${y2}" fill="none" stroke="${colorOf(e.to)}" stroke-opacity=".75" stroke-width="1.6"${e.dashed ? ' stroke-dasharray="5 4"' : ''} marker-end="url(#ah)"/>${label}`
310 })
311 .join('\n')
312 const boxes = [...nodes.values()]
313 .map(n => {
314 const q = pos.get(n.id)
315 if (!q) return ''
316 const c = colorOf(n.id)
317 const x = q.x - q.w / 2
318 const y = q.y - q.h / 2
319 const fill = `fill="${p.tile}" stroke="${c}" stroke-width="1.6"`
320 const shape =
321 n.shape === 'diamond'
322 ? `<path d="M${q.x} ${y}L${x + q.w} ${q.y}L${q.x} ${y + q.h}L${x} ${q.y}Z" ${fill}/>`
323 : n.shape === 'circle'
324 ? `<ellipse cx="${q.x}" cy="${q.y}" rx="${q.w / 2}" ry="${q.h / 2 + 4}" ${fill}/>`
325 : p.chamfer && n.shape === 'box'
326 ? `<path d="M${x + 8} ${y}H${x + q.w}V${y + q.h - 8}L${x + q.w - 8} ${y + q.h}H${x}V${y + 8}Z" ${fill}/>`
327 : `<rect x="${x}" y="${y}" width="${q.w}" height="${q.h}" rx="${n.shape === 'round' ? q.h / 2 : 6}" ${fill}/>`
328 const label = n.label.length > 26 ? `${n.label.slice(0, 25)}…` : n.label
329 return `${shape}<text x="${q.x}" y="${q.y + 4.5}" text-anchor="middle" font-family="${p.font}" font-size="12.5" font-weight="600" fill="${p.text}">${esc(label)}</text>`
330 })
331 .join('\n')
332 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${height}" viewBox="0 0 ${W} ${height}">
333<defs><marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="${p.subtext}"/></marker></defs>
334${arrows}
335${boxes}
336</svg>`
337 return { svg, height }
338}
339
340const pie = (src: string, W: number, p: ChartPalette): Chart | null => {
341 const lines = src.split('\n').map(l => l.trim()).filter(Boolean)
342 if (!/^pie\b/i.test(lines[0] ?? '')) return null
343 const title = (/title\s+(.+)$/i.exec(lines[0] ?? '')?.[1] ?? lines.find(l => /^title\s+/i.test(l))?.replace(/^title\s+/i, '') ?? '').trim()
344 const slices = lines
345 .map(l => /^"([^"]+)"\s*:\s*([\d.]+)/.exec(l))
346 .filter((m): m is RegExpExecArray => Boolean(m))
347 .map(m => ({ label: m[1] ?? '', value: Number(m[2]) }))
348 .filter(sl => sl.value > 0)
349 .slice(0, 12)
350 if (!slices.length) return null
351 const total = slices.reduce((a, b) => a + b.value, 0)
352 const height = Math.max(180, 40 + slices.length * 24)
353 const r = 70
354 const cx = 24 + r
355 const cy = height / 2 + (title ? 8 : 0)
356 let angle = -Math.PI / 2
357 const arcs = slices
358 .map((sl, i) => {
359 const sweep = (sl.value / total) * Math.PI * 2
360 const a2 = angle + sweep
361 const large = sweep > Math.PI ? 1 : 0
362 const pt = (a: number, rr: number) => `${(cx + rr * Math.cos(a)).toFixed(1)} ${(cy + rr * Math.sin(a)).toFixed(1)}`
363 const d =
364 slices.length === 1
365 ? `M${cx - r} ${cy}A${r} ${r} 0 1 1 ${cx + r} ${cy}A${r} ${r} 0 1 1 ${cx - r} ${cy}Z`
366 : `M${pt(angle, r)}A${r} ${r} 0 ${large} 1 ${pt(a2, r)}L${pt(a2, r * 0.58)}A${r * 0.58} ${r * 0.58} 0 ${large} 0 ${pt(angle, r * 0.58)}Z`
367 angle = a2
368 return `<path d="${d}" fill="${p.colors[i % p.colors.length] ?? p.accent}" stroke="${p.tile}" stroke-width="2"/>`
369 })
370 .join('')
371 const legendX = cx + r + 32
372 const legend = slices
373 .map((sl, i) => {
374 const y = cy - (slices.length * 24) / 2 + i * 24 + 16
375 return `<rect x="${legendX}" y="${y - 9}" width="11" height="11" rx="${p.chamfer ? 1 : 3}" fill="${p.colors[i % p.colors.length] ?? p.accent}"/>
376<text x="${legendX + 18}" y="${y}" font-family="${p.font}" font-size="12.5" fill="${p.text}">${esc(sl.label)}<tspan fill="${p.subtext}"> ${Math.round((sl.value / total) * 100)}%</tspan></text>`
377 })
378 .join('\n')
379 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${height}" viewBox="0 0 ${W} ${height}">
380${title ? `<text x="16" y="22" font-family="${p.font}" font-size="13" font-weight="700" fill="${p.accent}">${esc(title)}</text>` : ''}
381${arcs}
382${legend}
383</svg>`
384 return { svg, height }
385}
386
387export const mermaidChart = (src: string, W: number, p: ChartPalette): Chart | null => {
388 const first = src.trim().split('\n')[0]?.trim() ?? ''
389 if (/^(graph|flowchart)\b/i.test(first)) return flowchart(src.trim(), W, p)
390 if (/^pie\b/i.test(first)) return pie(src.trim(), W, p)
391 return null
392}
393hooks/smooth.ts 213 lines1// Smooth drawing on the desktop. There an Svg is an image drawn from its source: a
2// new source is a new image, decoded again, its animations started from zero. So a
3// drawing keeps its source while what it shows holds (`steady`), a new one carries on
4// its animations' phase (`phased`), and what moves each second moves inside the image
5// (`liveText`, `liveWidth`) instead of asking for a redraw.
6
7// Every phased animation counts from here: fixed for the module's life, so two images
8// drawn a second apart agree on the phase. Kept small: a whole minute near the first draw.
9let EPOCH: number | null = null
10const epoch = (at: number): number => (EPOCH ??= at - (at % 60_000))
11
12const TIME = /^(-?(?:\d+\.?\d*|\.\d+))(m?s)$/
13const seconds = (tok: string): number | null => {
14 const m = TIME.exec(tok.trim())
15 return m ? Number(m[1]) / (m[2] === 'ms' ? 1000 : 1) : null
16}
17const sec = (n: number): string => `${Math.round(n * 1000) / 1000}s`
18
19// An endless animation's shorthand with its delay moved by `shift`; one that ends
20// (an entrance, a strip) plays from its own start, so it is left alone.
21const shiftShorthand = (value: string, shift: number): string => {
22 if (!/\binfinite\b/.test(value)) return value
23 const parts = value.trim().split(/\s+/)
24 const times = parts.flatMap((p, i) => (seconds(p) === null ? [] : [i]))
25 const at = times[1]
26 if (at === undefined) return `${parts.join(' ')} ${sec(shift)}`
27 parts[at] = sec((seconds(parts[at] ?? '') ?? 0) + shift)
28 return parts.join(' ')
29}
30
31// `@@T<ms>@@` stands for "seconds from this image's start to that moment": how a
32// strip or a countdown is pinned to the wall clock.
33const PIN = /@@T(-?\d+)@@/g
34export const pin = (ms: number): string => `@@T${Math.round(ms)}@@`
35
36/**
37 * The source as drawn at `at`: each endless CSS or SMIL animation is delayed by the
38 * time since the epoch, so a new image picks up where the one it replaces was, and
39 * each pin is resolved. Animations that end are left to play from their start.
40 */
41export const phased = (content: string, at: number): string => {
42 const shift = (epoch(at) - at) / 1000
43 return content
44 .replace(/animation:([^;}"]+)/g, (_, v: string) => `animation:${shiftShorthand(v, shift)}`)
45 .replace(/animation-delay:([^;}"]+)/g, (all, v: string) => {
46 const s = seconds(v)
47 return s === null ? all : `animation-delay:${sec(s + shift)}`
48 })
49 .replace(/<(animate|animateTransform|animateMotion)\b([^>]*)>/g, (all, tag: string, attrs: string) => {
50 if (!/repeatCount="indefinite"/.test(attrs)) return all
51 const begin = /\bbegin="([^"]*)"/.exec(attrs)
52 if (!begin) return `<${tag} begin="${sec(shift)}"${attrs}>`
53 const s = seconds(begin[1] ?? '')
54 return s === null ? all : `<${tag}${attrs.replace(begin[0], `begin="${sec(s + shift)}"`)}>`
55 })
56 .replace(PIN, (_, ms: string) => sec((Number(ms) - at) / 1000))
57}
58
59// `@@G<id>:<width>@@` inside a bar's rect: drawn as a short glide from the width the
60// slot showed last, so a bar grows to its new value instead of jumping.
61const GLIDE = /@@G([\w-]+):(-?[\d.]+)@@/g
62export const glide = (id: string, width: number): string => `@@G${id.replace(/[^\w-]/g, '_')}:${Math.round(width * 10) / 10}@@`
63const glides = (content: string): Map<string, string> => new Map([...content.matchAll(GLIDE)].map(m => [m[1] ?? '', m[2] ?? '']))
64
65// --- fewer bytes for the same picture.
66
67const STYLE = /<style>([\s\S]*?)<\/style>/g
68const RULE = /[^{}]+\{[^{}]*\}/g
69const FAMILY = / font-family="([^"]*)"/g
70
71/**
72 * The same drawing in fewer bytes: each new source is sent to the desktop and decoded
73 * there, so a big crew's every redraw paid for kilobytes nothing reads. Rules whose
74 * every selector starts with a class the markup never uses are dropped (a card with no
75 * crab carries no crab CSS), and a drawing set in one font names it once, on its root.
76 */
77export const compact = (content: string): string => {
78 if (!content.startsWith('<svg')) return content
79 const markup = content.replace(STYLE, '')
80 const used = new Set<string>()
81 for (const m of markup.matchAll(/\bclass="([^"]*)"/g)) for (const c of (m[1] ?? '').split(/\s+/)) if (c) used.add(c)
82 const isDead = (rule: string): boolean =>
83 rule
84 .slice(0, rule.indexOf('{'))
85 .split(',')
86 .every(sel => {
87 const m = /^\s*\.([\w-]+)/.exec(sel)
88 return Boolean(m && !used.has(m[1] ?? ''))
89 })
90 let out = content.replace(STYLE, (all, css: string) => {
91 const kept = css
92 .split('\n')
93 .map(line => {
94 const rules = line.match(RULE)
95 return rules && rules.join('') === line ? rules.filter(r => !isDead(r)).join('') : line
96 })
97 .filter(Boolean)
98 .join('\n')
99 return kept.trim() ? `<style>${kept}</style>` : ''
100 })
101 // One font named once: only when it is the only one, attributes and styles alike.
102 const families = new Set([...out.matchAll(FAMILY)].map(m => m[1]))
103 const root = /^<svg\b[^>]*>/.exec(out)?.[0] ?? ''
104 const rootFamily = / font-family="([^"]*)"/.exec(root)?.[1]
105 if (families.size === 1 && !out.includes('font-family:')) {
106 const family = [...families][0] as string
107 if (rootFamily === undefined || rootFamily === family) {
108 const body = out.slice(root.length).split(` font-family="${family}"`).join('')
109 out = `${rootFamily === undefined ? root.replace(/^<svg\b/, `<svg font-family="${family}"`) : root}${body}`
110 }
111 }
112 return out
113}
114
115type Kept = { content: string; source: string; widths: Map<string, string> }
116const kept = new Map<string, Kept>()
117const KEPT_MAX = 400
118
119/**
120 * The source for one slot of a drawing (`slot` names it across redraws): the very
121 * same string while the content is the same, so the image is neither decoded again
122 * nor restarted; a new content is phased at `at`, its bars gliding from their last widths.
123 */
124export const steady = (slot: string, content: string, at: number): string => {
125 const was = kept.get(slot)
126 if (was && was.content === content) return was.source
127 const widths = glides(content)
128 const source = phased(compact(content), at).replace(GLIDE, (_, id: string, to: string) => {
129 const from = was?.widths.get(id)
130 return from === undefined || from === to
131 ? ''
132 : `<animate attributeName="width" from="${from}" to="${to}" dur=".45s" calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1" fill="freeze"/>`
133 })
134 kept.delete(slot)
135 kept.set(slot, { content, source, widths })
136 if (kept.size > KEPT_MAX) kept.delete(kept.keys().next().value as string)
137 return source
138}
139
140type Node = { type?: string; key?: unknown; props?: Record<string, unknown>; children?: unknown[] }
141const keyOf = (n: Node): string => String(n.key ?? n.props?.key ?? '')
142
143/**
144 * A whole drawing's sources made steady: each Svg's slot is its own key, else its
145 * nearest keyed box's (an agent's row), so a card keeps its slot as rows come and go.
146 */
147export const steadyTree = <T>(tree: T, scope: string, at: number): T => {
148 const seen = new Map<string, number>()
149 const walk = (x: unknown, near: string): unknown => {
150 if (Array.isArray(x)) return x.map(c => walk(c, near))
151 if (!x || typeof x !== 'object') return x
152 const n = x as Node
153 const name = keyOf(n) || near
154 if (n.type === 'Svg' && typeof n.props?.source === 'string') {
155 const k = seen.get(name) ?? 0
156 seen.set(name, k + 1)
157 return { ...n, props: { ...n.props, source: steady(`${scope}:${name}#${k}`, n.props.source, at) } }
158 }
159 return Array.isArray(n.children) ? { ...n, children: n.children.map(c => walk(c, name)) } : n
160 }
161 return walk(tree, '') as T
162}
163
164// --- what moves each second, drawn once a minute.
165
166/** Seconds a strip covers: the wall minute and a few past it, for a redraw that comes late. */
167export const LIVE_N = 66
168const minuteOf = (at: number): number => at - (at % 60_000)
169const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
170const r1 = (n: number): number => Math.round(n * 10) / 10
171
172/**
173 * A text that changes each second (a clock, a countdown, an estimate), as one line per
174 * second of the wall minute stepped through by CSS, so it reads right with no redraw.
175 * The same minute gives the same markup. `attrs` go on the line group (x via `x`);
176 * `fill` is repeated on each line, so a toned value still reads as one element.
177 */
178export const liveText = (x: number, y: number, at: number, size: number, attrs: string, value: (t: number) => string, fill = ''): string => {
179 const t0 = minuteOf(at)
180 const step = Math.ceil(size * 1.6)
181 const lines: string[] = []
182 for (let i = 0; i < LIVE_N; i++) lines.push(`<text y="${i * step}"${fill ? ` fill="${fill}"` : ''}>${esc(value(t0 + i * 1000 + 500))}</text>`)
183 const name = `lv${step}`
184 return `<svg x="0" y="${r1(y - size * 1.1)}" width="100%" height="${r1(size * 1.5)}" overflow="hidden"><style>@keyframes ${name}{to{transform:translateY(-${(LIVE_N - 1) * step}px)}}</style><g transform="translate(${r1(x)} ${r1(size * 1.1)})" ${attrs}><g style="animation:${name} ${LIVE_N - 1}s steps(${LIVE_N - 1}) forwards;animation-delay:${pin(t0)}">${lines.join('')}</g></g></svg>`
185}
186
187/** A bar's width over the same minute, sampled each second: it glides as the time goes. */
188export const liveWidth = (at: number, width: (t: number) => number): { start: number; animate: string } => {
189 const t0 = minuteOf(at)
190 const values = Array.from({ length: LIVE_N }, (_, i) => r1(width(t0 + i * 1000)))
191 return {
192 start: values[0] ?? 0,
193 animate: `<animate attributeName="width" values="${values.join(';')}" dur="${LIVE_N - 1}s" begin="${pin(t0)}" fill="freeze"/>`,
194 }
195}
196
197/** The wall minute: what a strip's markup is keyed on. */
198export const wallMinute = (at: number): number => Math.floor(at / 60_000)
199
200// --- writes that redraw only when something drawn changed.
201
202/** Equal as data: a write of an equal value would still redraw every reader. */
203export const isSame = (a: unknown, b: unknown): boolean => a === b || JSON.stringify(a) === JSON.stringify(b)
204
205// The last drawn-time reading per tick: a tick is written only when it moved.
206const readings = new Map<string, string>()
207/** True, once, when `reading` differs from the last one under `name`. */
208export const isNewReading = (name: string, reading: string): boolean => {
209 if (readings.get(name) === reading) return false
210 readings.set(name, reading)
211 return true
212}
213hooks/warm.ts 69 lines1// Smart Keep-Warm: the Token Manager refreshes the prompt cache shortly before it
2// lapses, when the refresh is expected to save more than it costs.
3//
4// The approach follows paulbkim-dev/claude-code-cache-warmer (MIT, after Pi's cache
5// warmer): a side request over the session's own transcript ($.model.fork), which
6// never enters the conversation; an expected-value rule; and a cap on idle refreshes.
7// Mission Control's own additions: work still running in the background counts as a
8// sure return; a context about to be compacted is not warmed; and the outcome shows
9// in the Token Manager instead of a pane of its own.
10//
11// Pure arithmetic here, so it is tested on its own.
12
13export const WARM_TAG = '[mission-control keep-warm]'
14export const WARM_PROMPT = `${WARM_TAG} Automated prompt cache refresh by Mission Control, not a message from the user. Reply with the single word ok.`
15
16/** A refresh goes out only when it is expected to save at least this much. */
17export const MIN_SAVINGS_USD = 0.05
18/** The chance a prompt comes before an idle cache lapses (Pi's measured figure). */
19export const IDLE_CHANCE = 0.15
20/** A refresh's reply is a word, but a thinking model spends a little before it. */
21export const WARM_OUTPUT_TOKENS = 200
22/** Idle refreshes per prompt: with the hour-long cache, close to three more warm hours. */
23export const IDLE_LIMIT = 3
24/** Refreshes per prompt while agents or background tasks still run. */
25export const BUSY_LIMIT = 12
26/** The refresh goes this long before the cache lapses, and not in its last seconds. */
27export const LEAD_MS = 6 * 60_000
28export const LATE_MS = 15_000
29/** A context this full is about to be compacted, which rewrites the cache anyway. */
30export const COMPACT_PCT = 85
31
32/** USD per million tokens: input, output, cache read, cache write (the hour-long write). */
33export type WarmPrice = readonly [input: number, output: number, cacheRead: number, cacheWrite: number]
34
35export type WarmCall = {
36 isWarm: boolean
37 /** What the refresh costs: reading the prefix, and its short reply. */
38 warmUsd: number
39 /** What a lapsed cache adds to the next prompt: writing the prefix instead of reading it. */
40 missUsd: number
41 /** The chance a prompt comes while the refresh still holds. */
42 chance: number
43 expectedUsd: number
44 /** Why not, when a refresh does not pay. */
45 reason?: string
46}
47
48export const warmCall = (price: WarmPrice, promptTokens: number, isLikely: boolean, contextPct: number, outputTokens = WARM_OUTPUT_TOKENS): WarmCall => {
49 const [, output, cacheRead, cacheWrite] = price
50 const warmUsd = (promptTokens * cacheRead + outputTokens * output) / 1e6
51 const missUsd = Math.max(0, (promptTokens * (cacheWrite - cacheRead)) / 1e6)
52 const chance = isLikely ? 1 : IDLE_CHANCE
53 const expectedUsd = chance * missUsd - warmUsd
54 if (contextPct >= COMPACT_PCT) return { isWarm: false, warmUsd, missUsd, chance, expectedUsd, reason: `context ${contextPct}%: a /compact rewrites the cache anyway` }
55 if (promptTokens <= 0) return { isWarm: false, warmUsd, missUsd, chance, expectedUsd, reason: 'nothing cached yet' }
56 if (expectedUsd < MIN_SAVINGS_USD) return { isWarm: false, warmUsd, missUsd, chance, expectedUsd, reason: `too small to pay (saves under $${MIN_SAVINGS_USD.toFixed(2)})` }
57 return { isWarm: true, warmUsd, missUsd, chance, expectedUsd }
58}
59
60/** Whether a refresh is due: inside the lead before the cache lapses, not too late. */
61export const isWarmDue = (cacheUntil: number | null, at: number): boolean =>
62 cacheUntil !== null && at >= cacheUntil - LEAD_MS && at < cacheUntil - LATE_MS
63
64/** A refresh that read under half the prefix found the cache gone and wrote it again. */
65export const isWarmed = (cacheRead: number, promptTokens: number): boolean => promptTokens > 0 && cacheRead >= promptTokens / 2
66
67/** The refreshes left before the cap: a busy session gets more than an idle one. */
68export const warmLimitHit = (isLikely: boolean, idle: number, busy: number): boolean => (isLikely ? busy >= BUSY_LIMIT : idle >= IDLE_LIMIT)
69types/index.d.ts 261 lines1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4 title: string
5 tier: string
6 after: number[]
7 /** The timekeeper's budget for this task, as the plan gave it. */
8 minutes?: number
9}
10
11export type Flow = {
12 title: string
13 total: number
14 done: number
15 running: number
16 phase: Phase
17 isFinished: boolean
18 tasks: PlannedTask[]
19 /** The timekeeper's budget for the whole plan, as the plan gave it. */
20 minutes?: number
21}
22
23/** `stopped`: cut short on purpose (Esc, a release), shown neutral, never as an error. */
24export type AgentStatus = 'running' | 'done' | 'failed' | 'stopped'
25
26export type AgentRun = {
27 id: string
28 agentId?: string
29 type: string
30 description: string
31 model: string
32 effort?: string
33 status: AgentStatus
34 startedAt: number
35 endedAt?: number
36 contextTokens: number
37 contextMax: number
38 tokens: number
39 costUsd: number
40 steps: number
41 round: number
42 /** Self-reported by the worker through the `step` tool. */
43 stepDone?: number
44 stepTotal?: number
45 stepNote?: string
46 /** The agent that started this one; absent when the main session did. */
47 parentAgentId?: string
48 /** What it is doing right now, from its latest tool call ("reading register.tsx"). */
49 activity?: string
50 /** When it last showed a sign of life: a model request or a tool call. */
51 lastProgressAt?: number
52 /** Quiet too long while running; the panel flags it and pings once. */
53 isStuck?: boolean
54 /** The timekeeper's budget for this run, in minutes; absent while the timekeeper is off. */
55 budgetMin?: number
56 /** Past its budget: flagged like a stuck one, and alerted once. */
57 isOverBudget?: boolean
58 /** When Mission Control last checked in with the person about this quiet agent. */
59 checkedInAt?: number
60 /** Started in the background: its end queues a report the main loop still has to answer. */
61 isBackground?: boolean
62 /** Its tool calls still in flight: a long build or a permission prompt is no silence. */
63 toolsInFlight?: number
64}
65
66export type Panel = {
67 isCompact: boolean
68 isDoneCollapsed: boolean
69 /** Finished agents the person opened again; every other finished agent shows folded. */
70 expanded?: string[]
71 autoOpenedFor: string
72}
73
74/** What the session card shows: where Claude runs, how full its context is, what it is connected to. */
75export type Env = {
76 cwd: string
77 model: string
78 surface: string
79 contextPct: number
80 contextTokens: number
81 contextWindow: number
82 costUsd: number
83 connectors: string[]
84 /** Tokens each model has burned this session, the main session's and every sub-agent's. */
85 modelTokens?: Record<string, number>
86 /** The main session's effort level, as its last model request carried it. */
87 effort?: string
88 /** Usage windows as the last API response reported them: `five_hour`, `seven_day`. */
89 limits: { kind: string; percentUsed: number; resetsAt?: string }[]
90}
91
92/** The main session: what the person last asked for, and whether it is at work. */
93export type PlanStep = { content: string; status: 'pending' | 'in_progress' | 'completed' }
94
95export type Manager = {
96 task: string
97 isWorking: boolean
98 startedAt: number
99 /** The manager's own task list, as the session's plan tool last set it. */
100 plan?: PlanStep[]
101 /** What the main session is doing right now. */
102 activity?: string
103 /** Until when the crew celebrates a finished job. */
104 celebrateUntil?: number
105 /** When the whole job finished: the turn over and no agent of its crew still running. */
106 doneAt?: number
107 /** The session's cost and tokens when this task began: the task's own spend is the difference. */
108 costAtStart?: number
109 tokensAtStart?: number
110 /** The job's budget from its plan's `minutes`, when the progress tool gave one. */
111 planMin?: number
112 /** The job's budget in minutes: the plan's, else the default; absent while the timekeeper is off. */
113 budgetMin?: number
114 /** How the job ended short: Esc (`stopped`) or an API error or refusal (`error`). Never cheered. */
115 outcome?: 'stopped' | 'error'
116 /** When a background agent of the job ended: its report waits on a main turn to answer it. */
117 reportDue?: number
118 /** An earlier job still held open by its background work when this prompt came: the one that finishes. */
119 carried?: { task: string; startedAt: number; costAtStart?: number; ids: string[] }
120 /** Reopened by a turn Claude started after the job was done: it ends again quietly. */
121 isFollowUp?: boolean
122}
123
124/** The timekeeper: its settings (kept in `$.store`) and which job it already alerted for. */
125export type Timekeeper = {
126 on: boolean
127 jobMin: number
128 agentMin: number
129 /** The `startedAt` of the job already warned at 80%, and of the one alerted past 100%. */
130 warnedFor: number
131 overFor: number
132}
133
134/** What the token manager watches: recent burn, how much was cached, its settings. */
135export type Watch = {
136 burn: { at: number; n: number }[]
137 cacheRead: number
138 promptAll: number
139 autopilot: boolean
140 budgetUsd: number
141 /** Smart Keep-Warm: refreshes the prompt cache before it lapses (/tokens warm on). */
142 warm: KeepWarm
143}
144
145export type KeepWarm = {
146 on: boolean
147 /** Refreshes since the last prompt, while idle and while work ran in the background. */
148 idle: number
149 busy: number
150 /** Why warming stopped until the next prompt; empty while it runs. */
151 halted: string
152 /** This session: refreshes sent, what they cost, and what they saved against a rewrite. */
153 count: number
154 spentUsd: number
155 savedUsd: number
156}
157
158/** Tokens per model over every session: seeded once from the history files, then kept up. */
159export type AllTime = {
160 since: string
161 models: Record<string, number>
162}
163
164/** This session's turn stats, as the session band used to show them. */
165export type SessionStats = {
166 turn: number
167 /** Output tokens per second of the main session's last timed response. */
168 speed: number | null
169 costAtStart: number
170 /** When the prompt cache goes cold, an hour after the last answer. */
171 cacheUntil: number | null
172 /** The main thread's last request: its prompt size and model, what a refresh would read. */
173 promptTokens?: number
174 model?: string
175 /** Seconds left on it, kept by the clock so the band redraws. */
176 cacheLeft: number | null
177 isBusy: boolean
178 /** The smallest warning minute already toasted since the last answer; 0 none. */
179 warned: number
180 /** Why this task runs in crew mode (a big job), or empty. */
181 crewMode?: string
182}
183
184/** Today's totals, kept across sessions. */
185export type Today = {
186 date: string
187 tasks: number
188 agents: number
189 tokens: number
190 costUsd: number
191}
192
193/** One piece of background work, as the last Stop or SubagentStop hook listed it. */
194export type BgTask = {
195 id: string
196 /** 'shell', 'subagent', 'monitor', 'workflow', or the engine's own word for another kind. */
197 type: string
198 status: string
199 description: string
200 /** A shell's command line. */
201 command?: string
202 /** A subagent's type. */
203 agentType?: string
204 /** A monitor's MCP server and tool. */
205 server?: string
206 tool?: string
207 /** A workflow's name. */
208 name?: string
209 /** Since when it has stood at this status: first listed, or its status last changed. */
210 seenAt?: number
211 /** When Mission Control last checked in with the person about it, quiet an hour. */
212 checkedInAt?: number
213 /** Let go by the person (/missioncontrol release): still listed, no longer holding the job. */
214 released?: boolean
215}
216
217/** A scheduled wake-up (CronCreate, ScheduleWakeup, /loop) that will start a turn later. */
218export type BgCron = { id: string; schedule: string; recurring: boolean; prompt: string }
219
220/** Claude's background work. The engine lists it only as a turn or a subagent stops: `at` says when. */
221export type Background = { tasks: BgTask[]; crons: BgCron[]; at: number }
222
223declare module 'claude-code' {
224 interface PluginState {
225 'crab-progress': {
226 flow: Flow | null
227 agents: AgentRun[]
228 panel: Panel
229 now: number
230 /** The band's tick: moves only when something the band draws from the time changes. */
231 bandTick: number
232 /** The pane's tick, the same for Mission Control's desktop drawing. */
233 paneTick: number
234 /** The person's answer-style rules, mirrored from `$.store` for drawing. */
235 memory: string[]
236 env: Env
237 manager: Manager
238 watch: Watch
239 today: Today
240 allTime: AllTime
241 /** The id of the skin Mission Control and the band are drawn in. */
242 skin: string
243 /** Mission Control's settings, mirrored from `$.store` so the pane redraws. */
244 settings: { openOnStart: boolean; crewMode: boolean; currency: string; sounds: boolean }
245 /** Street Cred: XP, the counts badges are earned on, the badges. Mirrored from `$.store`. */
246 cred: { xp: number; jobs: number; longJobs: number; agents: number; crewJobs: number; nightJobs: number; chromed: boolean; samurai: boolean; badges: string[] }
247 /** The session's tool calls, for the Braindance replay. */
248 timeline: { at: number; tool: string; text: string; who: string }[]
249 /** The beat the replay shows; -1 follows the newest. */
250 bdPick: number
251 session: SessionStats
252 /** The timekeeper's settings and alert marks; the marks live here so a hot reload never re-alerts. */
253 timekeeper: Timekeeper
254 /** Background tasks and scheduled wake-ups, as the last Stop or SubagentStop listed them. */
255 background: Background
256 /** Today and All-time as this session last read or wrote them: a save adds only its own share. */
257 saved: { today: Today; allTime: AllTime }
258 }
259 }
260}
261