While Claude works, the chat shows one tall pixel-art card of Clawd instead of every step. Its buttons sit on the card, which minimizes to one line, and under…

<h3 align="center">Two mods for Claude Code, by HeyCubit</h3>
<table> <tr> <td width="50%" align="center" valign="top"> <a href="#effortless"><img src="docs/readme/banner.png" alt="effortless" width="100%"></a> </td> <td width="50%" align="center" valign="top"> <a href="#textless"><img src="textless/brand/readme-banner.png" alt="textless" width="100%"></a> </td> </tr> </table>
<a href="#install"><b>Install</b></a> · <a href="#what-the-bar-shows"><b>What it shows</b></a> · <a href="#pick-your-judge"><b>Judges</b></a>
https://github.com/user-attachments/assets/9b5af48e-2ad8-4826-9443-4aeea1306664
A Claude Code mod that picks the reasoning effort for every prompt. Quick questions get Low, hard jobs get High, and you never touch the Effort control. On real coding tasks it cut the cost of Opus by 21% against Anthropic's recommended medium, 29% against high and 42% against xhigh, with every answer still right (the benchmark). One bar above the prompt also shows how full the chat is, how long the prompt cache stays warm, and hands off or compacts in one click when a chat gets heavy.
<img src="docs/readme/story.gif" alt="effortless in a Claude Code chat: effort per prompt, Handoff, cold cache, heavy chat, limits and settings" width="100%">
Haiku 5.5 costs about 75% less than Haiku 4.5, and Anthropic names compaction and quick, well-defined work among what it is built for. effortless uses it in three places:
| The judge | reads each prompt and picks the effort, in about a second, on your own Claude login |
| Cheaper model when it can | experimental, off by default. When on, a prompt the judge calls simple may run on Haiku 5.5 (or Sonnet), never above your chat's model, but only when the switch pays: each model keeps its own prompt cache, so in a chat that is in use the switch usually costs more than it saves (why) |
| Compaction | every compaction, /compact and the automatic one included, is summarized by Haiku 5.5. If Haiku fails, Claude Code compacts as usual |
The cheaper model is switched on in Settings → Model; compaction is switched in Settings → Handoff → Compact with.
Two ways. Both take a minute and need no terminal.
1. Ask Claude (easiest). Paste this into Claude Code and press Enter:
Install the effortless plugin for me: run `claude plugin marketplace add HeyCubit/effortless` and then `claude plugin install effortless@effortless`. When both succeed, tell me to run /reload-plugins.
2. Commands. Type these two lines in Claude Code's chat box:
/plugin marketplace add HeyCubit/effortless
/plugin install effortless@effortless
Or in a terminal, in one line:
claude plugin marketplace add HeyCubit/effortless; claude plugin install effortless@effortless
Then run /reload-plugins (or restart Claude Code). A short setup opens above the prompt: keep Haiku alone (one click, no key, runs on your own Claude login) or add Jev (a TypeSafe key, about 4x faster effort calls), lean cheaper or smarter, and pick how handoffs are written. Run /effortless setup to go through it again, or change any of it in ⚙.
Updates come to you: when a new version is out, a card above the prompt offers Update or Later. Update loads the new version in the chat you pressed it in, with nothing to type.
| On the bar | Means |
|---|---|
| High · Opus | the effort Auto picked for this prompt, and the model the chat runs on. Deciding while the judge thinks; a switch flashes violet and fades to white |
| ◔ 38% | how full the chat's context is |
| cache 59:00 | time until the prompt cache goes cold, after which the next message pays full price to re-read the chat |
| Haiku: a refactor… | who judged and why |
| Auto | switches the judge on and off. Changing effort in the app yourself also turns Auto off at your next message, and says so: you always win |
| Compact | compacts the chat. It turns into a glowing Handoff when Haiku says a fresh chat would pay off, with Compact as quiet text beside it. Set Settings → Handoff → Handoff button to Always to keep Handoff on the bar |
| ⚙ | settings |
Prefer it quiet? Settings → Customize has a Minimal look with no bar. It also sets the Appearance (match Claude Code, dark or light) and the Theme (violet, Claude orange or cherry blossom).
When the cache has gone cold on a big chat, the bar turns to ice with Compact and Handoff:
Handoff writes a summary of the chat and carries on in a clean one. Quick takes a few seconds; Full checks git and saves HANDOFF.md (or runs your own skill). Then clear and carry on, clear and wait, or keep the chat and copy.
Compact takes an optional note for what the summary should keep. Every compaction, also /compact and the automatic one, is written by Haiku 5.5 by default, which Anthropic recommends for compaction at a fraction of the chat model's price; if Haiku fails, Claude Code compacts as usual. Switch it in Settings → Handoff → Compact with.
The bar also warns when a chat is getting swamped (each message re-reads a lot of context), when a 5-hour or weekly limit passes 80% (with a Save mode that caps effort at Medium), and when your judge stops answering.
Haiku 5.5 runs on your own Claude login and needs no key. It always makes the handoff call: from 30% of context, every second message, it reads what the chat was for, the trail of topics, the last reply and how full the context is, and says a fresh chat would suit only for a clear reason. Then the Compact button turns into a lit Handoff with the reason. Want Handoff on the bar all the time? Settings → Handoff → Handoff button → Always.
Haiku also picks the effort, unless you add Jev, TypeSafe's faster judge (about 0.25 s against about 1 s). With a key, Jev answers the effort first and Haiku steps in whenever Jev is unsure. Run /plugin configure effortless@effortless in Claude Code, or use the setup guide or the Judge card in Settings.
| Setting | What it does |
|---|---|
auto (default) | Haiku, and Jev too when a TypeSafe key is set in the settings or TYPESAFE_API_KEY |
haiku | Haiku only, a key is never used |
jev | Haiku and Jev, and the key may also come from ~/.config/jev/.env |
A key set with /plugin configure is kept in Claude Code's secure storage. A key pasted in the setup guide or the Judge card is written in plain text to ~/.config/jev/.env (the file the jev skills read), never to a file of this repo. If Jev fails or takes longer than 3 seconds, Haiku judges that prompt and effortless tells you why once per session (out of credits, key rejected, no answer).
Short follow-ups such as "go", "ok" or "yes" keep the effort already picked and ask no judge.
/plugin configure, never on the command line, so it stays out of your shell history: Claude Code then keeps it in its secure storage. A key pasted in the setup guide or the Judge card is saved in plain text to ~/.config/jev/.env instead, so prefer /plugin configure on a shared machine.hooks/: read it before you install if you do not know the author. The programs it starts are claude itself (to update or uninstall the mod when you press those buttons), git (to see if a new version is out) and, on Update, a plain file copy of the new version into the folder your open chat runs from.Four real coding tasks in a fresh copy of this repo, each run 3 times with effortless on and with it off, through Anthropic's API so the cost is real. Every answer was checked. Run 2026-10-10, 96 runs:
| Opus 5.5 | Correct | Cost per task | Requests | Time |
|---|---|---|---|---|
| medium (recommended), no effortless | 12 of 12 | $0.190 | 7.3 | 24 s |
| high, no effortless | 12 of 12 | $0.209 | 8.0 | 27 s |
| xhigh, no effortless | 12 of 12 | $0.257 | 9.9 | 44 s |
| effortless on | 12 of 12 | $0.149 | 5.8 | 20 s |
That is 21% less than medium, 29% less than high and 42% less than xhigh. On Sonnet it came out even against medium (Sonnet at medium is already lean) and saved 10% against high and 35% against xhigh.
The saving comes from fewer steps: every request rereads the whole chat, and at a lower effort the model makes fewer of them. Over 80k requests of real Claude Code use, about 76% of the cost was that reread, 16% cache writes and 8% output. Keeping chats short and compacting before the cache goes cold saves on the same 76%; that is what the countdown is for.
Limits: four tasks, three runs each, mostly easy work where effortless goes low. On hard work it picks more effort and saves less. Fresh chats through the API, not long chats on a plan. Run it again with bash bench/agentic/run.sh; all numbers and limits are in bench/RESULTS.md.
Moving a simple prompt to Haiku looks cheap on a single prompt: the cheaper answers were good enough in 27 of 30 and cost 60 to 77% less. But each model keeps its own prompt cache, so a prompt moved mid-chat has to write the whole chat into the other model's cache first. Measured on an 80k-token chat, that costs about 5 times what staying on a warm Opus does (10 times for Sonnet). So Cheaper model when it can is off by default and marked experimental. Details.
/effortless bench runs labelled prompts through each judge you have set up, using the same code a real prompt goes through, and scores them against a fixed effort. Each case lists the efforts a careful person would accept for that message on that model. The cases are in bench/judge-cases.json; run it yourself.
Two runs on version 1.5.1, 73 cases, Sonnet and Opus 5.5:
| Judge | Right, all 73 | Right, 20 held out | Too low | Too high | Median time |
|---|---|---|---|---|---|
| Always medium | 44% | 40% | 17 | 24 | - |
| Always high | 51% | 50% | 0 | 36 | - |
| Haiku | 92-93% | 95% | 1 | 4-5 | 0.75 s |
| Jev | 99% | 100% | 0 | 1 | 0.24 s |
What this does and does not show:
Effort only changes on Opus 5.5 and Sonnet 5.5. On Fable 5.1 and older models a change of effort between requests rewrote most of the prompt cache, which costs more than it saves, so Auto pauses there and the footer says Paused. Haiku takes no effort setting.
In the desktop app's split view, Claude Code draws plugin bars only in the left pane. The right pane still draws replies, so when a chat went cold, is getting swamped or runs hot, a small card in the bar's colours hangs under the newest reply, naming the command that does what the bar's button would: /compact, /effortless handoff or /effortless save.
effortless draws in the terminal CLI too, with moving pixel art in its bands. Claude Code may not load a plugin's code there yet: if /effortless says the mod is not loaded after a restart, add this to ~/.claude/settings.json and restart:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
Tested on Claude Code 2.1.285 (stable), 2.1.286 and 2.1.288 at 80 and 120 columns. Below 90 columns the bands leave their art out.
| Command | Does |
|---|---|
/effortless settings | opens the settings panel |
/effortless setup | runs the setup again |
/effortless auto | Auto on or off |
/effortless handoff / handoff full | hands off without the bar, with your last choice of what follows |
/effortless stats | what the prompts Auto steered cost this session, per effort, and what the judge took |
/effortless cold, swamp, hot, down | shows that bar now, to try it |
/effortless bench | scores each judge you have on the labelled prompts in bench/judge-cases.json |
/effortless debug | what the mod sees, and on its last line the model and effort the last request ran on |
/effortless update | checks for a new version now |
While Claude works, Claude Code shows every step it takes. textless puts one picture there instead, and when Claude is done you get the answer, with a pixel word that says whether it is done, needs you or stopped.
~/.claude/textless/scenes./textless get <id>./textless.Website, scenes and Marketplace: heycubit.github.io/effortless/textless. The code is in textless/.
effortless is MIT licensed. Like any Claude Code plugin it runs code on your machine, so the whole mod is here to read: hooks/. It sends nothing anywhere except your prompt to the judge you picked.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
tools/render-band draws the bar the way the desktop app does, without opening it; the images in this README come from it.
hooks/register.tsx 2978 lines1import type { Register } from 'claude-code'
2import { SCENES } from './art'
3import { sceneCells } from './terminal-art'
4import { bestMoveAsync, CHESS_LEVELS, type ChessLevel } from './chess-engine'
5import { describe as describeMove, inCheck, legalMoves, type Move, outcome, type Outcome, parseFen, picture, play, type Position, START, squareName, toFen, uci } from './chess-rules'
6import { checkFile } from './market-rules'
7import { onceMs, quoteCard, stillCard } from './quill'
8import { QUOTES } from './quotes-list'
9import { GITHUB_OVER_HTTPS, isNewer, latestRelease, NEW_CHAT, noClaudeHint, pluginDirIn, RELEASES_PATH, RELEASES_URL, RELOAD_ARGS, type Release, SITE_URL, syncPlan, UPDATE_CHECK_MS, UPDATE_LOAD_WAIT_MS, UPDATE_LOADING, UPDATED_CARD_MS, type UpdateCard, updateCardTree, updateFailure, updateSnoozed, claudeCandidates } from './update'
10
11// textless: once a turn shows it is more than a quick answer (its first tool call, or 5 s passed), the
12// turn's first step is drawn as a tall card with art and its later steps and text as nothing. When the turn ends
13// every row is drawn whole again. The desktop gives the spinner a one-line slot, so the card lives in a tool row.
14const LONG_AFTER_MS = 3000
15const BASELINE_MS = 1500
16const ART_PX = 440
17// The card always stands this tall, whatever the pane's width: a narrow pane crops the scene's sides (most fade out
18// there) instead of shrinking it. Taller and it would scroll inside its own row.
19const ART_H = 420
20
21// One module serves every open chat, so everything about a running turn is kept per chat, keyed by its session id.
22type Chat = {
23 // textless on or off in this chat pane only; kept per chat in the store, on unless switched off there.
24 on: boolean
25 // A turn is running, whether or not the card is on; `working` is that and the card being on.
26 running: boolean
27 working: boolean
28 long: boolean
29 // The prompt that started this turn, and whether the card hangs under it: only when no earlier message reads the
30 // same, since a row is known by its text alone. Otherwise the card waits for the turn's first step.
31 prompt: string
32 underPrompt: boolean
33 // This turn's own text blocks, and the answer and length the turn ended with: what the done line keeps folded.
34 turnTexts: Set<string>
35 lastText: string
36 // Answers a Stop hook sent back for more work: the turn goes on, but each was an answer the person has to see.
37 kept: string[]
38 // Until then, a row drawn that is not running is taken as already in the chat: right after a reload the mod has
39 // seen none of the chat's rows, and the redraw that shows the card would otherwise take them all as this turn's.
40 baselineUntil: number
41 answer: string
42 durationMs: number
43 // Subagents still working for this chat: a turn whose agents run in the background ends before they do.
44 agents: Set<string>
45 // The main turn ended while agents still ran; the card goes when the last one is done.
46 mainDone: boolean
47 // The turn ended without an answer: stopped by the person, an API error or a refusal.
48 stopped: boolean
49 // When the card first showed this turn, the zero of its animation.
50 longAt: number
51 turns: number
52 // Messages the person sent in this chat, and the one that last carried the note on questions.
53 prompts: number
54 hintedAt: number | null
55 // Cover mode: the text block the big card takes the place of, and its word. undefined: not read from the store yet.
56 cover?: { host: string; word: string; asks?: string[]; recs?: string[] } | null
57 // Show the chat was pressed: this chat stays uncovered, with textless on, until Claude starts on the next message.
58 uncovered?: boolean
59 // The chat's title as the app names it, for the cover card: which chat is this.
60 title?: string
61 // A planning turn (a document written, plan mode left): textless steps aside for it. The step that showed it carries
62 // the small label.
63 planKey?: string
64 startedAt: number
65 // Steps that ran during this turn, oldest first; only these can carry the card. Rows of earlier turns are redrawn
66 // by the same invalidate, and they never ran in this turn, so they draw as usual.
67 steps: string[]
68 // The step whose row holds the card: the turn's first, then the newest whenever the card scrolls out of view.
69 cardKey: string
70 timer: { cancel: () => void } | null
71 ticker: { cancel: () => void } | null
72 // The last finished turn's answer: textless's buttons sit under it, on or off. Kept per chat in the store.
73 newest: string
74 // The folded turn that ended last (its index in `done`), or -1 when the last turn was a quick one: the buttons sit on
75 // its row even when it has no answer to hang them under (a stopped turn).
76 newestAt: number
77 // The scene this turn shows (an index into sceneList), chosen when the turn starts; -1 before that.
78 sceneAt: number
79 // Background work still running when the turn's answer came (from the Stop hook), by id: the newest answer's row
80 // names it until each task's notification says it ended.
81 background: { id: string; description: string }[]
82 // Claude keeps a task list this turn: the app draws it under the card, so the card goes to one line unless the
83 // person asked for full size this turn.
84 listed?: boolean
85 fullAnyway?: boolean
86}
87const chats = new Map<string, Chat>()
88// Off for every chat when the person turns it off with the icon or /textless; kept in the mod's store.
89let registered = false
90// Text blocks drawn while no turn ran: earlier replies, which stay visible. Any other text block is this turn's, and is
91// hidden once the card shows, so only the finished reply is read.
92const oldTexts = new Set<string>()
93// The person's messages already in the chat, for telling the turn's own prompt apart from an earlier one.
94const oldPrompts = new Set<string>()
95// Steps already in the chat when no turn ran; any other step is this turn's, running or already done (a quick read
96// can finish before it is ever drawn running).
97const oldTools = new Set<string>()
98// Finished turns stay folded: their steps and in-between text are kept hidden, and a line under the prompt says how
99// many steps and how long, with a button that opens them. Kept for this session; older turns are left as they are.
100// first: the step whose row draws the done line when there is no answer to hang the done card under.
101// stopped: the turn did not end on an answer (the person stopped it, an API error, a refusal).
102type Done = { first: string; steps: number; ms: number; open: boolean; answer: string; scene: number; stopped: boolean; labels: string[] }
103const done: Done[] = []
104// A finished turn's answer text, to its entry in done: the done card hangs under that block.
105const doneAnswers = new Map<string, number>()
106// What each step did, in a few words, by its tool_use_id: "show steps" lists these inside the done card, because the
107// app does not reliably redraw the old step rows when asked.
108const stepLabels = new Map<string, string>()
109function stepLabel(tool: string, input: any) {
110 const i = input && typeof input === 'object' ? input : {}
111 // A path shows its last two parts: the file and its folder, not a drive prefix that hides the name.
112 const file = i.file_path ?? i.path
113 const short = typeof file === 'string' ? file.split(/[\\/]+/).filter(Boolean).slice(-2).join('/') : undefined
114 const what = i.description ?? i.command ?? short ?? i.pattern ?? i.url ?? i.query ?? i.prompt ?? ''
115 const one = String(what).replace(/\s+/g, ' ').trim()
116 return one ? `${tool} ${one.length > 80 ? one.slice(0, 79) + '…' : one}` : tool
117}
118const foldedTools = new Map<string, number>()
119const foldedTexts = new Map<string, number>()
120
121// The person's own scenes: any GIF, PNG, WebP, JPG or SVG dropped in ~/.claude/textless/scenes joins the
122// rotation. A picture is wrapped in an SVG as a data URI, so it has to fit the engine's 131072-character cap.
123const SVG_CAP = 131072
124const IMAGE_TYPES: Record<string, string> = { gif: 'image/gif', png: 'image/png', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg' }
125type Own = { name: string; mtimeMs: number; svg: string }
126let own: Own[] = []
127// The drawn scenes the mod ships, read once. Files straight in hooks/scenes are the Clawd set; each folder in it is
128// another set, named after the folder (hooks/scenes/abstract is the Abstract set).
129type Scene = { name: string; svg: string; set: string; file?: string }
130let builtIn: Scene[] | null = null
131const CLAWD_SET = 'clawd'
132// The set the card rotates through, for every chat. The person's own scenes are their own set, 'yours'.
133let set = CLAWD_SET
134// The scene the person picked with the next button, by place in the list; null rotates one per turn.
135let pick: number | null = null
136// The person's own sets, made on the settings page: a name and scenes from any set, each "<set>/<scene>".
137type Mix = { id: string; name: string; scenes: string[] }
138let mixes: Mix[] = []
139function keptMixes(raw: unknown): Mix[] {
140 if (!Array.isArray(raw)) return []
141 return raw.filter((m: any) => m && typeof m.id === 'string' && /^mix-[a-z0-9]{1,24}$/.test(m.id) && typeof m.name === 'string' && Array.isArray(m.scenes))
142 .map((m: any) => ({ id: m.id, name: String(m.name).slice(0, 32), scenes: m.scenes.map(String).slice(0, 60) }))
143}
144
145async function loadBuiltIn($: any) {
146 if (builtIn) return
147 const root = `${$.plugin.root}/hooks/scenes`
148 const top = ((await $.fs.list(root).catch(() => [])) as { name: string; kind: string }[])
149 const folders = [{ dir: root, set: CLAWD_SET }, ...top.filter(f => f.kind === 'dir').map(f => ({ dir: `${root}/${f.name}`, set: f.name }))]
150 const found: Scene[] = []
151 for (const { dir, set: name } of folders) {
152 const files = ((await $.fs.list(dir).catch(() => [])) as { name: string; kind: string }[])
153 .filter(f => f.kind === 'file' && f.name.endsWith('.svg'))
154 .sort((a, b) => a.name.localeCompare(b.name))
155 const read = await Promise.all(files.map(f => $.fs.read(`${dir}/${f.name}`).catch(() => '')))
156 files.forEach((f, i) => found.push({ name: f.name.replace(/\.svg$/, ''), svg: String(read[i]), set: name }))
157 }
158 // The chess scene is only the settings page's tile: chess is started with /chess, never drawn by the card's rotation.
159 builtIn = found.filter(b => b.set !== INTERACTIVE_CHESS && b.svg.includes('<svg') && b.svg.length <= SVG_CAP)
160 // The quotes set is drawn, not read: each quote's card is made the first time something asks for it, then kept.
161 for (const [id, text, author] of QUOTES) {
162 let made: string | null = null
163 builtIn.push({ name: id, set: 'quotes', get svg() { return (made ??= quoteCard(text, author)) } })
164 }
165}
166
167// When each play-once scene (a quote being written) was first on the card, by set and name. Once it has had its full
168// time it is shown finished from then on, so a redraw or a later turn never writes it again.
169const firstShown = new Map<string, number>()
170
171// The on/off switch in the model strip: a pill in Clawd's orange with the knob right when on, grey with the knob left
172// when off. The knob carries a small T, struck through while the text is hidden.
173function switchSvg(on: boolean) {
174 const pill = on ? '<rect x=".5" y=".5" width="29" height="15" rx="7.5" fill="#d77757" stroke="#d77757"/>' : '<rect x=".5" y=".5" width="29" height="15" rx="7.5" fill="#2c2c31" stroke="#4a4a52"/>'
175 const kx = on ? 22 : 8
176 const ink = on ? '#d77757' : '#2c2c31'
177 const t = `<rect x="${kx - 2.5}" y="4.5" width="5" height="1.4" fill="${ink}"/><rect x="${kx - 0.7}" y="4.5" width="1.4" height="7" fill="${ink}"/>`
178 const strike = on ? `<rect x="${kx - 4}" y="7.3" width="8" height="1.3" fill="${ink}" transform="rotate(-35 ${kx} 8)"/>` : ''
179 return `<svg xmlns="http://www.w3.org/2000/svg" width="30" height="16" viewBox="0 0 30 16">${pill}<circle cx="${kx}" cy="8" r="6" fill="${on ? '#ffffff' : '#8b8b93'}"/>${t}${strike}</svg>`
180}
181const tooBig = new Set<string>()
182
183async function scenesDir($: any) {
184 const home = String((await $.env.get('USERPROFILE').catch(() => '')) || (await $.env.get('HOME').catch(() => '')) || '')
185 const sep = home.includes('\\') ? '\\' : '/'
186 return [home, '.claude', 'textless', 'scenes'].join(sep)
187}
188
189function wrapImage(mime: string, base64: string, width = ART_PX, height = ART_PX) {
190 // Drawn at the card's size, kept in proportion, with hard pixel edges so pixel art stays crisp when scaled.
191 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><image href="data:${mime};base64,${base64}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet" style="image-rendering:pixelated"/></svg>`
192}
193
194// Reads the folder again, reusing files that did not change. A file too big for the card is skipped and named once.
195async function loadOwn($: any) {
196 const dir = await scenesDir($)
197 const entries = ((await $.fs.list(dir).catch(() => [])) as { name: string; kind: string; mtimeMs: number }[])
198 .filter(f => f.kind === 'file')
199 .sort((a, b) => a.name.localeCompare(b.name))
200 const next: Own[] = []
201 for (const f of entries) {
202 const ext = f.name.split('.').pop()?.toLowerCase() ?? ''
203 if (ext !== 'svg' && !IMAGE_TYPES[ext]) continue
204 const kept = own.find(o => o.name === f.name && o.mtimeMs === f.mtimeMs)
205 if (kept) {
206 next.push(kept)
207 continue
208 }
209 const path = `${dir}/${f.name}`
210 const svg = ext === 'svg'
211 ? String(await $.fs.read(path).catch(() => ''))
212 : wrapImage(IMAGE_TYPES[ext], String(((await $.fs.read(path, { as: 'bytes' }).catch(() => null)) as { base64?: string } | null)?.base64 ?? ''))
213 if (!svg || svg.length > SVG_CAP || !svg.includes('<svg')) {
214 if (!tooBig.has(f.name)) {
215 tooBig.add(f.name)
216 void Promise.resolve().then(() => $.ui.toast(`textless: ${f.name} is too big or not a picture (keep it under about 95 KB)`)).catch(() => undefined)
217 }
218 continue
219 }
220 next.push({ name: f.name, mtimeMs: f.mtimeMs, svg })
221 }
222 own = next
223}
224
225async function openFolder($: any) {
226 const dir = await scenesDir($)
227 await $.fs.write(`${dir}/README.txt`, 'Drop GIF, PNG, WebP, JPG or SVG files here (under about 95 KB each). They show in the textless card while Claude works.\n').catch(() => undefined)
228 // Explorer answers 1 even when it opens the window, so any answer counts; only a refusal to start moves on to the
229 // next way. If none starts, the person is told, and the panel's Copy path still gets them there.
230 const ways = dir.includes('\\')
231 ? [['explorer.exe', dir], ['cmd', '/c', 'start', '""', dir]]
232 : [['open', dir], ['xdg-open', dir]]
233 for (const argv of ways) {
234 const started = await $.process.run(argv, { timeoutMs: 5000 }).then(() => true, () => false)
235 if (started) return dir
236 }
237 void Promise.resolve().then(() => $.ui.toast(`textless: could not open the folder. It is at ${dir}`)).catch(() => undefined)
238 return dir
239}
240
241// The scene market (market/ in this repo): community scenes, one click to use. TEXTLESS_MARKET_API and
242// TEXTLESS_MARKET_SITE point the plugin elsewhere, such as the local dev Worker (http://127.0.0.1:8788).
243const MARKET_API = 'https://textless-market.isac-ehrstedt.workers.dev'
244const MARKET_SITE = 'https://textless-preview.isac-ehrstedt.workers.dev/market/'
245type Listed = { id: string; name: string; author: string; link?: string; description?: string; width: number; height: number; created: string; file: string; stars?: number }
246// Ids name the saved file, so only plain ones are taken: never a name or path the server sends.
247const SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/
248type Market = { scenes: Listed[]; error: string; loading: boolean; at: number }
249let market: Market = { scenes: [], error: '', loading: false, at: 0 }
250// Thumbnails for the Community tab, checked like a download before they are drawn; '' marks one that failed.
251const thumbs = new Map<string, string>()
252// The market names of saved scenes, by id, so the scene list says "Rain cloud" and not the id.
253let marketNames: Record<string, { name: string; author: string }> = {}
254let paneTab: 'settings' | 'community' = 'settings'
255
256async function marketUrls($: any) {
257 const api = String((await $.env.get('TEXTLESS_MARKET_API').catch(() => '')) || MARKET_API).replace(/\/+$/, '')
258 const site = String((await $.env.get('TEXTLESS_MARKET_SITE').catch(() => '')) || MARKET_SITE)
259 return { api, site }
260}
261
262async function listMarket($: any): Promise<Listed[]> {
263 const { api } = await marketUrls($)
264 const res = await $.http.fetch(`${api}/scenes`)
265 if (!res.ok) throw new Error(`the market answered ${res.status}`)
266 const data = JSON.parse(res.text)
267 // Most starred first; the market already lists newest first, and sort keeps that order among equal stars.
268 return (Array.isArray(data?.scenes) ? data.scenes : [])
269 .filter((s: any) => typeof s?.id === 'string' && SAFE_ID.test(s.id))
270 .sort((a: any, b: any) => (Number(b.stars) || 0) - (Number(a.stars) || 0))
271}
272
273function bytesOf(base64: string) {
274 const bin = atob(base64)
275 const out = new Uint8Array(bin.length)
276 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
277 return out
278}
279
280// Downloads one listed scene and runs the market's own checks on it again. Returns it as the SVG the card draws: an
281// SVG as it is, a picture wrapped as a data URI (the plugin can only write text). Throws with the reason to tell.
282async function downloadScene($: any, s: Listed) {
283 const { api } = await marketUrls($)
284 const ext = String(s.file ?? '').split(/[?#]/)[0].split('.').pop()?.toLowerCase() ?? ''
285 if (ext !== 'svg' && !IMAGE_TYPES[ext]) throw new Error('it is not a picture the card can show')
286 // Fetched from the market's own file route by id, whatever address the listing gives.
287 const url = `${api}/f/${s.id}.${ext}`
288 let svg: string
289 if (ext === 'svg') {
290 const res = await $.http.fetch(url)
291 if (!res.ok) throw new Error(`the market answered ${res.status}`)
292 const verdict = checkFile(`${s.id}.svg`, new TextEncoder().encode(res.text))
293 if (verdict.problems.length) throw new Error(verdict.problems.join('; '))
294 svg = res.text
295 } else {
296 const res = await $.http.fetch(`${url}?as=base64`)
297 if (!res.ok) throw new Error(`the market answered ${res.status}`)
298 const base64 = res.text.replace(/\s+/g, '')
299 if (!/^[A-Za-z0-9+/]+=*$/.test(base64)) throw new Error('the market sent something that is not a picture')
300 const verdict = checkFile(`${s.id}.${ext}`, bytesOf(base64))
301 if (verdict.problems.length) throw new Error(verdict.problems.join('; '))
302 svg = wrapImage(IMAGE_TYPES[ext], base64, verdict.width, verdict.height)
303 }
304 if (svg.length > SVG_CAP) throw new Error('it is too big for the card (pictures have to stay under about 95 KB)')
305 return svg
306}
307
308// Saves a listed scene as ~/.claude/textless/scenes/<id>.svg and makes it the scene every chat shows.
309async function useScene($: any, s: Listed) {
310 const svg = await downloadScene($, s)
311 const dir = await scenesDir($)
312 await $.fs.write(`${dir}/${s.id}.svg`, svg)
313 marketNames = { ...marketNames, [s.id]: { name: String(s.name).slice(0, 40), author: String(s.author).slice(0, 40) } }
314 await $.store.set('marketNames', marketNames).catch(() => undefined)
315 await loadOwn($)
316 await loadBuiltIn($)
317 await setSet($, 'yours')
318 await setPick($, Math.max(0, sceneList().findIndex(sc => sc.file === `${s.id}.svg`)))
319 return `${s.name} by ${s.author} is on. It plays the next time Claude works.`
320}
321
322async function getScene($: any, id: string) {
323 if (!SAFE_ID.test(id)) return `"${id}" is not a scene id. The site's Use in textless button copies the right command.`
324 let scenes: Listed[]
325 try {
326 scenes = await listMarket($)
327 } catch {
328 return 'The scene market could not be reached. Your scenes keep working; try again later.'
329 }
330 const s = scenes.find(x => x.id === id)
331 if (!s) return `No scene ${id} in the market. It was not found or was taken down.`
332 try {
333 return await useScene($, s)
334 } catch (err) {
335 return `${s.name} was not added: ${String((err as any)?.message ?? err)}.`
336 }
337}
338
339// Fills the Community tab: the listing, then each thumbnail as it arrives. Kept for a minute between opens.
340async function refreshMarket($: any, force = false) {
341 if (market.loading || (!force && market.at && Date.now() - market.at < 60000)) return
342 market = { ...market, loading: true }
343 $.ui.invalidate('ui.render')
344 try {
345 market = { scenes: await listMarket($), error: '', loading: false, at: Date.now() }
346 } catch {
347 market = { ...market, error: 'The scene market could not be reached. Your scenes keep working.', loading: false, at: Date.now() }
348 }
349 $.ui.invalidate('ui.render')
350 for (const s of market.scenes.slice(0, 24)) {
351 if (thumbs.has(s.id)) continue
352 thumbs.set(s.id, await downloadScene($, s).catch(() => ''))
353 $.ui.invalidate('ui.render')
354 }
355}
356
357async function useFromPane($: any, s: Listed) {
358 const said = await useScene($, s).catch(err => `${s.name} was not added: ${String((err as any)?.message ?? err)}.`)
359 void Promise.resolve().then(() => $.ui.toast(`textless: ${said}`)).catch(() => undefined)
360 $.ui.invalidate('ui.render')
361}
362
363async function chatOf($: any): Promise<Chat> {
364 const id = String(await $.session.id().catch(() => ''))
365 let chat = chats.get(id)
366 if (!chat) {
367 chat = { on: true, running: false, working: false, long: false, prompt: '', underPrompt: false, turnTexts: new Set<string>(), lastText: '', kept: [], baselineUntil: 0, answer: '', durationMs: 0, longAt: 0, agents: new Set<string>(), mainDone: false, stopped: false, turns: 0, prompts: 0, hintedAt: null, startedAt: 0, steps: [], cardKey: '', timer: null, ticker: null, newest: '', newestAt: -1, sceneAt: -1, background: [] }
368 chats.set(id, chat)
369 chat.on = (await $.store.get(`on:${id}`).catch(() => null)) !== false
370 const newest = await $.store.get(`newest:${id}`).catch(() => null)
371 chat.newest = typeof newest === 'string' ? newest : ''
372 }
373 return chat
374}
375
376// Debug log: what the mod did and when, one file per chat as ~/.claude/textless/debug-<chat>.log, the last 300 lines.
377// One file per chat because every chat keeps its own buffer and would overwrite the others.
378const logLines: string[] = []
379// Off unless switched on with /textless debug (kept in the store, every pane) or TEXTLESS_DEBUG=1; then it writes the
380// log below.
381let debugOn: boolean | null = null
382function dbg($: any, line: string) {
383 if (debugOn === null) {
384 debugOn = false
385 void Promise.all([$.store.get('debug').catch(() => null), Promise.resolve($.env.get('TEXTLESS_DEBUG')).catch(() => '')])
386 .then(([kept, env]: any[]) => { debugOn = kept === true || String(env ?? '') === '1' })
387 .catch(() => undefined)
388 }
389 if (!debugOn) return
390 logLines.push(`${new Date().toISOString().slice(11, 23)} ${line}`)
391 if (logLines.length > 300) logLines.shift()
392 void Promise.all([scenesDir($), $.session.id().catch(() => 'unknown')])
393 .then(([dir, id]) => $.fs.write(dir.replace(/scenes$/, `debug-${String(id).slice(0, 8)}.log`), logLines.join('\n')))
394 .catch(() => undefined)
395}
396
397function goLong($: any, chat: Chat) {
398 dbg($, `goLong working=${chat.working} long=${chat.long} steps=${chat.steps.length}`)
399 if (!chat.working || chat.long) return
400 chat.long = true
401 chat.longAt = Date.now()
402 // No redraw on a clock: every redraw of an image starts its animation over, which reads as a stutter.
403 $.ui.invalidate('ui.render')
404}
405
406// Marks a step as part of this turn when the app reports it running, and says where it stands in the turn.
407function track($: any, chat: Chat, key: string, isRunning: boolean) {
408 if (!key) return 'other'
409 if ((isRunning || !oldTools.has(key)) && !chat.steps.includes(key)) {
410 chat.steps.push(key)
411 dbg($, `step ${chat.steps.length} ${key.slice(-8)} running=${isRunning}`)
412 goLong($, chat)
413 // A new step may be the one that puts the card far enough up to move it down.
414 if (chat.long && chat.cardKey) reanchor($, chat, undefined)
415 }
416 if (!chat.steps.includes(key)) return 'other'
417 // The card stays in the turn's first step: the app does not redraw rows it already drew, so a card moved to the
418 // newest step left its old copy behind (two cards) and rebuilt itself every step (flicker).
419 if (!chat.cardKey) chat.cardKey = chat.steps[0] ?? ''
420 return key === chat.cardKey ? 'card' : 'later'
421}
422
423// The rows hidden under the card still take the app's spacing, and its own "Ran 3 commands" lines come in below, so
424// a long turn pushes the card up and out of view. Where the app says which rows the viewport shows, the card moves
425// once more than half of its row has scrolled away. The desktop does not say (onScreen stays undefined), so there it
426// moves once REANCHOR_STEPS steps have come in below it, about when it starts to leave the screen. It moves to the
427// newest step's row, at the bottom; the old row redraws to nothing.
428const REANCHOR_STEPS = 3
429function reanchor($: any, chat: Chat, onScreen: any) {
430 const newest = chat.steps[chat.steps.length - 1]
431 if (!newest || newest === chat.cardKey) return false
432 const below = chat.steps.length - 1 - chat.steps.indexOf(chat.cardKey)
433 const away = onScreen === undefined
434 ? below >= REANCHOR_STEPS
435 : onScreen === null || (typeof onScreen?.first === 'number' && typeof onScreen?.of === 'number' && onScreen.first > onScreen.of / 2)
436 if (!away) return false
437 dbg($, `card moves down ${chat.cardKey.slice(-8)} -> ${newest.slice(-8)} below=${below} onScreen=${JSON.stringify(onScreen)}`)
438 chat.cardKey = newest
439 void Promise.resolve().then(() => $.ui.invalidate('ui.render'))
440 return true
441}
442
443const clock = (ms: number) => {
444 const s = Math.max(0, Math.round(ms / 1000))
445 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
446}
447
448// Every scene there is, by set: the person's own as 'yours', then the shipped ones (the old drawn-in-code ones only
449// if those cannot be read).
450function allScenes(): Scene[] {
451 // The drawn quotes are always there; the old drawn-in-code scenes stand in only when no scene file could be read.
452 const files = (builtIn ?? []).filter(b => b.set !== 'quotes')
453 const shipped = files.length > 0 ? (builtIn as Scene[]) : [...SCENES.map((svg, i) => ({ name: `scene ${i + 1}`, svg, set: CLAWD_SET })), ...(builtIn ?? [])]
454 const label = (file: string) => marketNames[file.replace(/\.svg$/, '')]?.name ?? file.replace(/\.[^.]+$/, '')
455 const base = [...own.map(o => ({ name: label(o.name), svg: o.svg, set: 'yours', file: o.name })), ...shipped]
456 // Each of the person's sets holds the scenes it names that this computer has, in the order they were ticked.
457 const made = mixes.flatMap(m => m.scenes.flatMap(ref => base.filter(b => `${b.set}/${b.name}` === ref).slice(0, 1).map(b => ({ ...b, set: m.id }))))
458 return [...base, ...made]
459}
460
461// The sets there are, the Clawd set first, the person's own last.
462function setList() {
463 const names = [...new Set(allScenes().map(s => s.set))]
464 // Clawd first, then the shipped sets, the person's own folder, and the sets they made last.
465 const rank = (n: string) => (n === CLAWD_SET ? 0 : n.startsWith('mix-') ? 3 : n === 'yours' ? 2 : 1)
466 return names.sort((a, b) => rank(a) - rank(b) || (a.startsWith('mix-') ? 0 : a.localeCompare(b)))
467}
468
469const setLabel = (name: string) => (name === 'yours' ? 'Your own' : mixes.find(m => m.id === name)?.name ?? name[0].toUpperCase() + name.slice(1))
470
471// The scenes the card rotates through: the chosen set, or the Clawd set if that one is gone.
472function sceneList() {
473 const all = allScenes()
474 const inSet = all.filter(s => s.set === set)
475 return inSet.length > 0 ? inSet : all.filter(s => s.set === CLAWD_SET).length > 0 ? all.filter(s => s.set === CLAWD_SET) : all
476}
477
478function sceneIndex(chat: Chat) {
479 const n = sceneList().length
480 if (pick !== null) return pick % n
481 return chat.sceneAt >= 0 && chat.sceneAt < n ? chat.sceneAt : (Math.max(1, chat.turns) - 1) % n
482}
483
484// Which scene a new turn shows: random, but never one of the last RECENT_SCENES shown in any chat (at most half the
485// set, so a set of three still changes every turn). A fixed order by turn number gave every new chat the same first
486// few scenes and left the end of a long set unseen. A scene picked by hand (pick) wins and is not drawn here.
487const RECENT_SCENES = 50
488let recentScenes: string[] = []
489function chooseScene($: any): number {
490 const list = sceneList()
491 const key = (i: number) => `${list[i].set}/${list[i].name}`
492 const window = Math.min(RECENT_SCENES, Math.floor(list.length / 2))
493 const avoid = new Set(recentScenes.slice(-window))
494 const fresh = list.map((_, i) => i).filter(i => !avoid.has(key(i)))
495 const pool = fresh.length > 0 ? fresh : list.map((_, i) => i)
496 const at = pool[Math.floor(Math.random() * pool.length)]
497 recentScenes = [...recentScenes.filter(k => k !== key(at)), key(at)].slice(-200)
498 void $.store.set('recentScenes', recentScenes).catch(() => undefined)
499 return at
500}
501
502// SMIL's own clock: each animation begins that many seconds in the past. One that names its own begin keeps it.
503function inPhase(svg: string, elapsedMs: number) {
504 const ago = `-${(Math.max(0, elapsedMs) / 1000).toFixed(2)}s`
505 return svg.replace(/<(animate\w*|set)([^>]*?)(\/?)>/g, (tag, name, attrs, close) => (attrs.includes('begin=') ? tag : `<${name} begin="${ago}"${attrs}${close}>`))
506}
507
508async function card($: any, e: any, chat: Chat, step: string) {
509 // Minimized: one line in the card's place. (The app's working line under the chat was tried: the desktop does not
510 // draw a mod's tree there, so textless vanished.)
511 if (isMini(chat)) return miniLine($, e, chat)
512 if (chessGame && chessAnchor === 'chat' && (await chessOwns($))) return chessCard($, e, chat, step)
513 try {
514 const out = await drawCard($, e, chat, step)
515 dbg($, `card drawn scenes=${sceneList().length} svg=${Boolean(($.ui.resolve(e) as any).Svg)}`)
516 return out
517 } catch (err) {
518 dbg($, `card FAILED ${String((err as any)?.stack ?? err).slice(0, 300)}`)
519 const { Text } = $.ui.resolve(e) as Record<string, any>
520 return <Text color="#d97757">Claude is working</Text>
521 }
522}
523
524// Sets that are played, not watched. The settings page lists them under Interactive sets.
525const INTERACTIVE_CHESS = 'chess'
526
527async function drawCard($: any, e: any, chat: Chat, step: string) {
528 const { Box, Text, Svg, Button, Raster } = $.ui.resolve(e) as Record<string, any>
529 await refreshTheme($)
530 // Every redraw starts the image's animation over; starting each one as far in as the card has been showing makes
531 // the restart land where the last drawing was, so it cannot be seen.
532 const scene = sceneList()[sceneIndex(chat)]
533 const now = await $.clock.now()
534 let src = scene.svg
535 const once = onceMs(src)
536 if (once !== null) {
537 const key = `${scene.set}/${scene.name}`
538 const first = firstShown.get(key) ?? now
539 firstShown.set(key, first)
540 if (now - first >= once) src = stillCard(src)
541 }
542 const art = inPhase(src, now - chat.longAt)
543 // A scene from the marketplace names who drew it, under the card's words. Shipped scenes and the person's own don't.
544 const maker = scene.file ? marketNames[scene.file.replace(/\.svg$/, '')] : undefined
545 // Square art with the words under it. Nothing here changes on a clock, so the drawing is only redrawn when a step
546 // changes and its animation runs on undisturbed.
547 // Each scene keeps its own shape: drawn at its own width and height, no wider than the card allows.
548 const size = art.match(/<svg[^>]*\swidth="(\d+)"[^>]*\sheight="(\d+)"/)
549 const artW = size ? Number(size[1]) : ART_PX
550 const artH = size ? Number(size[2]) : ART_PX
551 const fitW = Math.round((ART_H * artW) / artH)
552 // Fill the box and crop what does not fit, centred, so a pane narrower than fitW keeps the full height. A scene whose
553 // root says data-fit="contain" (a word that must stay whole) is fitted inside instead; it paints its own background
554 // past its viewBox so the box still looks full.
555 // A plain terminal cannot draw an SVG: pixel-art scenes are painted as coloured half blocks, a quote as its words.
556 const terminal = e.surface === 'terminal'
557 let terminalArt: any = null
558 if (terminal) {
559 const columns = Number(e.viewport?.columns) || 80
560 const rows = Number(e.viewport?.rows) || 30
561 const cells = Raster ? sceneCells(art, Math.min(64, columns - 4), Math.max(6, Math.min(20, rows - 12))) : null
562 const quote = scene.set === 'quotes' ? QUOTES.find(q => q[0] === scene.name) : undefined
563 if (cells) terminalArt = <Raster key="textless-art" columns={cells.columns} rows={cells.rows} cells={cells.cells} />
564 else if (quote) {
565 terminalArt = (
566 <Box key="textless-quote" flexDirection="column" alignItems="center" paddingX={2} marginY={1}>
567 <Text color="#d97757" italic wrap="wrap">{quote[1]}</Text>
568 <Text dimColor>{`— ${quote[2]}`}</Text>
569 </Box>
570 )
571 }
572 }
573 const contain = /<svg\b[^>]*\sdata-fit="contain"/.test(art)
574 const fit = contain ? 'xMidYMid meet' : 'xMidYMid slice'
575 const cover = art.replace(/<svg\b([^>]*?)\s*preserveAspectRatio="[^"]*"/, '<svg$1').replace(/<svg\b/, `<svg preserveAspectRatio="${fit}"`)
576 return (
577 <Box key="textless" flexDirection="column" alignItems="center" paddingBottom={1}>
578 {/* The switch, the settings (another scene mid-turn) and minimize sit in the row the top padding took, so the card is no taller for them. */}
579 <Box key="textless-top" flexDirection="row" justifyContent="flex-end" width="100%" height={1}>
580 {Button ? controls($, e) : null}
581 {Button ? sizeButton($, e, 'minimize') : null}
582 </Box>
583 {terminal ? terminalArt : Svg ? <Svg source={cover} alt="Claude working" width={fitW} height={ART_H} /> : null}
584 {/* Answered while a background task runs: Claude still has work under way, so it is not the person's turn yet. */}
585 <Text color="#d97757" bold wrap="truncate">Claude is working</Text>
586 <Text dimColor wrap="truncate">{chat.mainDone ? waitingFor(chat) : step}</Text>
587 {maker ? <Text key="textless-credit" color="#6e6660" wrap="truncate">{`${maker.name} · by ${maker.author}`}</Text> : null}
588 </Box>
589 )
590}
591
592// The turn has answered while background work runs: the card hangs under the answer, not up among the steps.
593function cardUnderAnswer(chat: Chat) {
594 return chat.mainDone && chat.long && chat.answer.trim() !== ''
595}
596
597// What the turn still waits on once its answer is in: the background tasks by name, or the subagents.
598function waitingFor(chat: Chat) {
599 const n = chat.background.length
600 if (n === 0) return 'Waiting for a background task'
601 return `Waiting for ${n} background task${n === 1 ? '' : 's'}: ${chat.background.map(t => t.description).join(', ')}`
602}
603// The same, after the turn: on the newest answer's row, until the last one ends.
604function backgroundLine($: any, e: any, chat: Chat) {
605 const { Box, Text } = $.ui.resolve(e) as Record<string, any>
606 const n = chat.background.length
607 if (n === 0) return null
608 return (
609 <Box key="textless-background" flexShrink={1} minWidth={0} marginLeft={1}>
610 <Text dimColor wrap="truncate">{`${n} background task${n === 1 ? '' : 's'} running: ${chat.background.map(t => t.description).join(', ')}`}</Text>
611 </Box>
612 )
613}
614
615// Minimize (two arrows pointing in) and full size (two arrows pointing out), drawn, in the same spot on both looks.
616// Like "Aa": the icon in the flow, a blank button over it for the click, its hover clear and a lighter copy of the icon
617// shown under the pointer instead.
618function sizeIcon(kind: 'minimize' | 'expand', color: string) {
619 const paths = kind === 'minimize'
620 ? 'M14 2 9.5 6.5M9.5 3v3.5H13M2 14l4.5-4.5M3 9.5h3.5V13'
621 : 'M9.5 6.5 14 2M10 2h4v4M6.5 9.5 2 14M2 10v4h4'
622 return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="${paths}" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`
623}
624function sizeButton($: any, e: any, kind: 'minimize' | 'expand') {
625 const { Box, Svg, Button } = $.ui.resolve(e) as Record<string, any>
626 const key = `textless-${kind}`
627 const press = async () => {
628 const chat = await chatOf($)
629 // Full size on a turn with a task list holds for this turn; the choice for every turn stays as it was.
630 if (kind === 'expand' && !mini) chat.fullAnyway = true
631 else if (kind === 'minimize' && chat.listed) chat.fullAnyway = false
632 if (kind === 'expand' && mini) await setMini($, false)
633 else if (kind === 'minimize' && !chat.listed) await setMini($, true)
634 else $.ui.invalidate('ui.render')
635 }
636 if (!Svg) return <Button key={key} plain dimColor label={kind === 'minimize' ? ' ⤡ ' : ' ⤢ '} onPress={press} />
637 return (
638 <Box key={`${key}-box`} flexDirection="row" alignItems="center" justifyContent="center" paddingX={1}>
639 <Svg source={sizeIcon(kind, isLight ? '#8a8580' : '#8b8b93')} alt={kind === 'minimize' ? 'Minimize' : 'Full size'} width={14} height={14} />
640 <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center" display="none" hover={{ scope: key, display: 'flex' }}>
641 <Svg source={sizeIcon(kind, isLight ? '#1a1a1a' : '#f2f2f2')} alt={kind === 'minimize' ? 'Minimize' : 'Full size'} width={14} height={14} />
642 </Box>
643 <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
644 <Button key={key} plain label={' '.repeat(3)} hover={{ scope: key, backgroundColor: '#00000000' }} onPress={press} />
645 </Box>
646 </Box>
647 )
648}
649
650async function miniLine($: any, e: any, chat: Chat) {
651 await refreshTheme($)
652 const { Box, Text } = $.ui.resolve(e) as Record<string, any>
653 return (
654 <Box key="textless" flexDirection="row" alignItems="center" gap={1}>
655 <Text color="#d97757" bold>textless</Text>
656 <Box flexShrink={1} minWidth={0}>
657 <Text dimColor wrap="truncate">{chat.mainDone ? waitingFor(chat) : 'minimized'}</Text>
658 </Box>
659 <Box flexGrow={1} />
660 {/* No gap between the buttons, as on the big card: the row's own gap spread them. */}
661 <Box flexDirection="row" alignItems="center">
662 {controls($, e)}
663 {sizeButton($, e, 'expand')}
664 </Box>
665 </Box>
666 )
667}
668
669// Text said between steps is hidden, but the app can keep a row it drew while the text was still arriving (a card on
670// top and the text under it until something redraws). Once the text stops changing, every row is drawn once more.
671let settling: { cancel: () => void } | null = null
672function settleText($: any) {
673 settling?.cancel()
674 settling = $.clock.after(700, () => { settling = null; $.ui.invalidate('ui.render') })
675}
676
677// Switched off while Claude works: the row the card sat in is drawn as the app draws it, with one line under it holding
678// the grey Aa and the gear, so the way back on stays where the card was instead of moving up the chat.
679async function offRow($: any, e: any, chat: Chat, isCardRow: boolean, next: any) {
680 const inner = await next(e)
681 if (!chat.running || !isCardRow) return inner
682 await refreshTheme($)
683 const { Box } = $.ui.resolve(e) as Record<string, any>
684 const drawn = { chat, id: String(await $.session.id().catch(() => '')) }
685 return (
686 <Box key="textless-off-row" flexDirection="column">
687 {inner}
688 <Box flexDirection="row" alignItems="center">
689 <Box flexGrow={1} />
690 {controls($, e, drawn)}
691 </Box>
692 </Box>
693 )
694}
695
696// The card minimized to one line, for every chat; kept in the store.
697let mini = false
698// One line now: minimized, or a turn with a task list (the app's own, drawn under the card).
699const LIST_TOOLS = /^(TodoWrite|TaskCreate|TaskUpdate)$/
700function isMini(chat: Chat) {
701 return mini || (Boolean(chat.listed) && !chat.fullAnyway)
702}
703function noteList($: any, chat: Chat, tool: unknown) {
704 if (!chat.working || chat.listed || !LIST_TOOLS.test(String(tool ?? ''))) return
705 chat.listed = true
706 void Promise.resolve().then(() => $.ui.invalidate('ui.render')).catch(() => undefined)
707}
708async function setMini($: any, on: boolean) {
709 mini = on
710 await $.store.set('mini', on).catch(() => undefined)
711 $.ui.invalidate('ui.render')
712}
713
714// textless's two buttons: "Aa" switches it on or off in this chat, the gear opens the settings. They sit on the
715// minimized card and under the newest answer, never in a row of their own under the prompt.
716// A Button cannot be coloured, so "Aa" is text (Clawd orange, bold and struck through while textless hides the steps,
717// plain grey while off) with a blank button laid over it to take the click.
718// The button's own hover box is clear: drawn over the text it hid it; the text lights up instead.
719function controls($: any, e: any, drawn?: { chat: Chat; id: string }) {
720 const { Box, Text, Button } = $.ui.resolve(e) as Record<string, any>
721 const on = drawn ? drawn.chat.on : true
722 return [
723 <Box key="textless-switch-box" flexDirection="row" alignItems="center" paddingX={1}>
724 <Text color={on ? '#d77757' : undefined} dimColor={!on} bold={on} strikethrough={on} hover={{ scope: 'textless-switch', color: on ? '#f0a080' : hoverText() }}>Aa</Text>
725 <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
726 <Button key="textless-switch" plain label={' '.repeat(3)} hover={{ scope: 'textless-switch', backgroundColor: '#00000000' }} onPress={() => toggle($, undefined, drawn)} />
727 </Box>
728 </Box>,
729 <Button key="textless-settings" plain dimColor label=" ⚙︎ " hover={{ scope: 'textless-settings', backgroundColor: hoverBg() }} onPress={() => openWebSettings($)} />,
730 ]
731}
732
733// Under the newest answer: the answer as drawn, then one row with the buttons at its end ("Aa" says on or off).
734function isNewest(chat: Chat, text: string) {
735 const answer = chat.newest.trim()
736 const block = text.trim()
737 return Boolean(answer && block && answer.endsWith(block))
738}
739async function newestRow($: any, e: any, inner: unknown, left?: unknown) {
740 await setUp($)
741 const { Box } = $.ui.resolve(e) as Record<string, any>
742 await refreshTheme($)
743 const drawn = { chat: await chatOf($), id: String(await $.session.id().catch(() => '')) }
744 const row = (
745 <Box key="textless-newest" flexDirection="row" alignItems="center">
746 {left ?? null}
747 {backgroundLine($, e, drawn.chat)}
748 <Box flexGrow={1} />
749 {controls($, e, drawn)}
750 </Box>
751 )
752 return (
753 <Box key="textless-answer" flexDirection="column" gap={1}>
754 {inner}
755 {row}
756 {await updBelow($, e)}
757 {chessBelow($, e)}
758 </Box>
759 )
760}
761
762async function openSettings($: any) {
763 await loadOwn($)
764 await loadBuiltIn($)
765 await $.ui.open({ id: 'textless-settings', title: 'textless' })
766}
767
768// The settings page on the website. This computer gets a random key the first time; the page saves under it on the
769// market Worker and every chat reads it back from there, so nothing has to be pasted.
770// Parked: textless draws art only and leaves answers alone. The cover card, the questions, links and next step under
771// answers, and the note telling Claude how to word questions all stay off while this is false. The full version lives
772// on the branch backup/answer-cards (tag answer-cards-backup).
773export const ANSWERS = false
774// Questions, links and the next step under a finished answer; /textless summary off leaves only the done line.
775let summaryOn = ANSWERS
776// Cover mode: once Claude is done, the whole chat hides behind one big card saying whose move it is. Off by default.
777let coverOn = false
778let syncKey = ''
779let remoteUpdated = 0
780let watchUntil = 0
781let lastPull = 0
782
783async function keyOf($: any) {
784 if (syncKey) return syncKey
785 const kept = await $.store.get('syncKey').catch(() => null)
786 if (typeof kept === 'string' && /^[0-9a-f]{32}$/.test(kept)) return (syncKey = kept)
787 const bytes = globalThis.crypto?.getRandomValues ? globalThis.crypto.getRandomValues(new Uint8Array(16)) : Uint8Array.from({ length: 16 }, () => Math.floor(Math.random() * 256))
788 syncKey = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
789 await $.store.set('syncKey', syncKey).catch(() => undefined)
790 return syncKey
791}
792
793// The page opens on what the card shows now: the set, and the picked scene's name if one is kept.
794async function openWebSettings($: any) {
795 await loadOwn($)
796 await loadBuiltIn($)
797 const { site } = await marketUrls($)
798 const scene = pick !== null ? sceneList()[pick % sceneList().length]?.name ?? '' : ''
799 const page = new URL('../settings/', site).href
800 const hash = `k=${await keyOf($)}&set=${encodeURIComponent(set)}&scene=${encodeURIComponent(scene)}&summary=${summaryOn ? 1 : 0}&cover=${coverOn ? 1 : 0}`
801 let url = `${page}#${hash}`
802 // The page cannot read this computer, so the plugin writes a copy of it here with the person's own scenes in it
803 // and opens that: their pictures never leave the computer, only the pick is saved (as from the site). Offline, or
804 // with no own scenes, the site itself opens.
805 const mine = allScenes().filter(s => s.set === 'yours')
806 // Written even with no own scenes yet, so the page can show where the folder is.
807 {
808 const res = await $.http.fetch(page).catch(() => null)
809 if (res?.ok && String(res.text).includes('var API')) {
810 const ownList = mine.slice(0, 60).map(s => ({ id: s.name, name: s.name, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(s.svg)}` }))
811 const folder = await scenesDir($)
812 const data = `<script>window.TEXTLESS_HASH=${JSON.stringify(hash)};window.TEXTLESS_DIR=${JSON.stringify(folder)};window.TEXTLESS_OWN=${JSON.stringify(ownList).replace(/</g, '\\u003c')};</script>`
813 const html = String(res.text).replace('<head>', `<head><base href="${page}">`).replace('<script>\nvar API', `${data}\n<script>\nvar API`)
814 const dir = (await scenesDir($)).replace(/[\\/]scenes$/, '')
815 const file = `${dir}${dir.includes('\\') ? '\\' : '/'}settings.html`
816 if (await $.fs.write(file, html).then(() => true, () => false)) url = file
817 }
818 }
819 // Watch closely for a while: the person is on the page and expects a save to show at once.
820 watchUntil = Number(await $.clock.now()) + 20 * 60_000
821 // From the plugin's own path, not from USERPROFILE: the app sets that on a Mac too, and rundll32 does not exist there.
822 const windows = /^[A-Za-z]:|\\/.test(String($.plugin.root))
823 // rundll32 hands the address to the default browser and answers 0; explorer answers 1 even when it opens one.
824 const ways = windows ? [['rundll32', 'url.dll,FileProtocolHandler', url], ['explorer.exe', url]] : [['open', url], ['xdg-open', url]]
825 for (const argv of ways) {
826 const r = await $.process.run(argv, { timeoutMs: 5000 }).then((x: any) => x, (err: any) => ({ exitCode: -1, stderr: String(err) }))
827 dbg($, `settings open ${argv[0]} exit=${r.exitCode} ${String(r.stderr ?? '').slice(0, 120)}`)
828 if (r.exitCode !== -1) return
829 }
830 // No browser would start: the settings in the app still work.
831 $.ui.toast('textless: no browser would open, so the small settings panel is shown. /textless debug logs why.')
832 await openSettings($)
833}
834
835// What the page saved, if it is newer than what this chat last took. Every 5 s while the page was just opened, every
836// minute otherwise, and never before the page was first opened on this computer.
837async function pullSettings($: any) {
838 if (!syncKey) {
839 const kept = await $.store.get('syncKey').catch(() => null)
840 if (typeof kept !== 'string' || !/^[0-9a-f]{32}$/.test(kept)) return
841 syncKey = kept
842 }
843 const now = Number(await $.clock.now())
844 if (lastPull && now - lastPull < (now < watchUntil ? 5000 : 60_000)) return
845 lastPull = now || 1
846 const { api } = await marketUrls($)
847 const res = await $.http.fetch(`${api}/settings/${syncKey}`).catch(() => null)
848 if (!res?.ok) return
849 let got: any = null
850 try { got = JSON.parse(String(res.text)) } catch { return }
851 if (!got || typeof got.set !== 'string' || typeof got.updated !== 'number') return
852 const taken = Number(await $.store.get('remoteUpdated').catch(() => 0)) || 0
853 remoteUpdated = Math.max(remoteUpdated, taken)
854 if (got.updated <= remoteUpdated) return
855 remoteUpdated = got.updated
856 await $.store.set('remoteUpdated', got.updated).catch(() => undefined)
857 await loadOwn($)
858 await loadBuiltIn($)
859 // The questions list under answers, on or off for every chat, when the page sent it.
860 if (ANSWERS && typeof got.summary === 'boolean' && got.summary !== summaryOn) {
861 summaryOn = got.summary
862 await $.store.set('summary', summaryOn).catch(() => undefined)
863 }
864 if (ANSWERS && typeof got.cover === 'boolean' && got.cover !== coverOn) {
865 coverOn = got.cover
866 await $.store.set('cover', coverOn).catch(() => undefined)
867 }
868 if (Array.isArray(got.mixes)) {
869 mixes = keptMixes(got.mixes)
870 await $.store.set('mixes', mixes).catch(() => undefined)
871 }
872 if (!setList().includes(got.set)) return
873 set = got.set
874 const at = typeof got.scene === 'string' ? sceneList().findIndex(s => s.name === got.scene) : -1
875 pick = at >= 0 ? at : null
876 await $.store.set('set', set).catch(() => undefined)
877 await $.store.set('pick', pick).catch(() => undefined)
878 $.ui.invalidate('ui.render')
879}
880
881// A scene picked in the settings, for every chat; null takes one per turn in turn.
882async function setPick($: any, value: number | null) {
883 pick = value
884 await $.store.set('pick', value).catch(() => undefined)
885 $.ui.invalidate('ui.render')
886}
887
888// A set picked in the settings, for every chat; the card then rotates through that set from its first scene.
889async function setSet($: any, name: string) {
890 set = name
891 pick = null
892 await $.store.set('set', name).catch(() => undefined)
893 await $.store.set('pick', null).catch(() => undefined)
894 $.ui.invalidate('ui.render')
895}
896
897// The next button: the scene after the one showing, kept for every chat until pressed again.
898async function nextScene($: any) {
899 const chat = await chatOf($)
900 await loadOwn($)
901 await loadBuiltIn($)
902 const list = sceneList()
903 pick = (sceneIndex(chat) + 1) % list.length
904 await $.store.set('pick', pick).catch(() => undefined)
905 void Promise.resolve().then(() => $.ui.toast(`textless: ${list[pick!].name} (${pick! + 1} of ${list.length})`)).catch(() => undefined)
906 $.ui.invalidate('ui.render')
907}
908
909// On or off for this chat pane only: other panes keep their own switch. Switched during a turn, the card goes or comes
910// at once: off draws the rows and text whole again.
911// The strip passes the chat and id it was drawn for: a press runs later, outside the drawing, and looking the chat up
912// again there could land on another one.
913async function toggle($: any, want?: boolean, drawn?: { chat: Chat; id: string }) {
914 const chat = drawn?.chat ?? (await chatOf($))
915 const id = drawn?.id ?? String(await $.session.id().catch(() => ''))
916 const was = chat.on
917 chat.on = want ?? !chat.on
918 chat.working = chat.running && chat.on
919 if (!chat.working) chat.long = false
920 else if (!was) {
921 // Back on in the middle of a turn: the rows drawn while off stay as they are (the app does not redraw them), so
922 // the card starts over at the next step, under them, instead of going back up to the turn's first step.
923 for (const k of chat.steps) oldTools.add(k)
924 chat.steps = []
925 chat.cardKey = ''
926 chat.long = false
927 } else if (chat.steps.length > 0) goLong($, chat)
928 void Promise.resolve().then(() => $.ui.toast(chat.on ? 'textless on in this chat' : 'textless off in this chat')).catch(() => undefined)
929 await $.store.set(`on:${id}`, chat.on).catch(() => undefined)
930 $.ui.invalidate('ui.render')
931}
932
933async function setUp($: any) {
934 if (registered) return
935 registered = true
936 const kept = await $.store.get('pick').catch(() => null)
937 mini = (await $.store.get('mini').catch(() => null)) === true
938 const seen = await $.store.get('recentScenes').catch(() => null)
939 recentScenes = Array.isArray(seen) ? seen.filter((k: unknown) => typeof k === 'string').slice(-200) : []
940 pick = typeof kept === 'number' ? kept : null
941 const keptSet = await $.store.get('set').catch(() => null)
942 set = typeof keptSet === 'string' && keptSet ? keptSet : CLAWD_SET
943 summaryOn = ANSWERS && (await $.store.get('summary').catch(() => null)) !== false
944 coverOn = ANSWERS && (await $.store.get('cover').catch(() => null)) === true
945 mixes = keptMixes(await $.store.get('mixes').catch(() => null))
946 const keptNames = await $.store.get('marketNames').catch(() => null)
947 marketNames = keptNames && typeof keptNames === 'object' ? keptNames : {}
948 void $.command.register({ name: 'textless', description: 'textless on or off in this chat.' }).catch(() => undefined)
949 // The scene pick and set are shared by every chat pane: one picked in a pane reaches the others through the store,
950 // read here every 2 s. On or off stays per pane.
951 $.clock.every(2000, () => { void syncSettings($).catch(() => undefined); void pullSettings($).catch(() => undefined) })
952}
953
954async function syncSettings($: any) {
955 const keptPick = await $.store.get('pick').catch(() => null)
956 const nextPick = typeof keptPick === 'number' ? keptPick : null
957 const keptSet = await $.store.get('set').catch(() => null)
958 const nextSet = typeof keptSet === 'string' && keptSet ? keptSet : CLAWD_SET
959 if (nextPick === pick && nextSet === set) return
960 const setMoved = nextSet !== set
961 pick = nextPick
962 set = nextSet
963 if (setMoved) await loadBuiltIn($)
964 $.ui.invalidate('ui.render')
965}
966
967// A hidden row takes no height: an empty Text still drew a line plus the row's gap, so hidden steps stacked up empty
968// space under the card, scrolled it out of view and left a hole above the answer.
969function blank($: any, e: any) {
970 const { Box } = $.ui.resolve(e)
971 return <Box height={0} overflow="hidden" />
972}
973
974// Background agents can outlive the turn that started them; past this the card goes even if one never reports back.
975const AGENT_WAIT_MS = 10 * 60 * 1000
976
977// A text block is part of the turn's final answer: the app draws a long answer as several blocks.
978function inAnswer(chat: Chat, text: string) {
979 const t = text.trim().replace(/\s+/g, ' ').slice(0, 200)
980 return t.length > 0 && chat.answer.replace(/\s+/g, ' ').includes(t)
981}
982
983// A text block is part of an answer a Stop hook sent back (and the shorter versions of it drawn while it streamed).
984function isKept(chat: Chat, text: string) {
985 const t = text.trim().replace(/\s+/g, ' ').slice(0, 200)
986 return t.length > 0 && chat.kept.some(k => k.includes(t))
987}
988
989// Done cards live in memory, so a reload of the plugin (an update, a restart) would drop them and the answer would
990// show bare, with its steps and in-between text back. Each chat keeps its last turns in the store and reads them
991// back the first time it draws after a reload.
992type SavedTurn = { d: Done; tools: string[]; texts: string[] }
993const SAVED_TURNS = 30
994const loadedDone = new Set<string>()
995
996async function saveDone($: any, index: number) {
997 const id = String(await $.session.id().catch(() => ''))
998 if (!id) return
999 const tools = [...foldedTools].filter(([, i]) => i === index).map(([k]) => k)
1000 const texts = [...foldedTexts].filter(([, i]) => i === index).map(([k]) => k).filter(t => t.length <= 4000)
1001 const kept = ((await $.store.get(`done:${id}`).catch(() => null)) as SavedTurn[] | null) ?? []
1002 const turn: SavedTurn = { d: { ...done[index], open: false, labels: done[index].labels.slice(0, 40) }, tools, texts }
1003 await $.store.set(`done:${id}`, [...(Array.isArray(kept) ? kept : []), turn].slice(-SAVED_TURNS)).catch(() => undefined)
1004}
1005
1006async function loadDone($: any) {
1007 const id = String(await $.session.id().catch(() => ''))
1008 if (!id || loadedDone.has(id)) return
1009 loadedDone.add(id)
1010 const kept = (await $.store.get(`done:${id}`).catch(() => null)) as SavedTurn[] | null
1011 if (!Array.isArray(kept)) return
1012 for (const t of kept) {
1013 if (!t?.d || (t.d.answer && doneAnswers.has(t.d.answer))) continue
1014 const index = done.length
1015 done.push({ ...t.d, open: false })
1016 if (t.d.answer) doneAnswers.set(t.d.answer, index)
1017 for (const k of t.tools ?? []) if (!foldedTools.has(k)) foldedTools.set(k, index)
1018 for (const k of t.texts ?? []) if (!foldedTexts.has(k)) foldedTexts.set(k, index)
1019 }
1020 if (kept.length) $.ui.invalidate('ui.render')
1021}
1022
1023// Cover mode is on for this chat: textless is on here and the setting is on.
1024// Like the work card, only a turn that ran long enough for the card is covered: a quick answer leaves the chat as it is.
1025const covering = (chat: Chat) => chat.on && coverOn && !chat.uncovered && (chat.working ? chat.long : !!chat.cover)
1026async function saveCover($: any, chat: Chat) {
1027 const id = String(await $.session.id().catch(() => ''))
1028 if (id) await $.store.set(`cover:${id}`, chat.cover ?? null)
1029}
1030// After a reload the chat's cover is read back once, so the card stays where it was.
1031async function loadCover($: any, chat: Chat) {
1032 if (chat.cover !== undefined) return
1033 const id = String(await $.session.id().catch(() => ''))
1034 const kept = id ? ((await $.store.get(`cover:${id}`).catch(() => null)) as { host?: unknown; word?: unknown } | null) : null
1035 chat.cover = kept && typeof kept.host === 'string' && typeof kept.word === 'string' ? { host: kept.host, word: kept.word, ...(Array.isArray((kept as any).asks) ? { asks: (kept as any).asks.map(String).slice(0, 5) } : {}), ...(Array.isArray((kept as any).recs) ? { recs: (kept as any).recs.map(String).slice(0, 5) } : {}) } : null
1036}
1037// The big card that covers a finished chat: the word in large pixels, and the way back to the chat.
1038function coverCard($: any, e: any, chat: Chat, drawn: { chat: Chat; id: string }) {
1039 const { Box, Text, Button, Svg } = $.ui.resolve(e) as Record<string, any>
1040 const word = chat.cover?.word ?? '▶ YOUR TURN'
1041 const said = word.slice(2).toLowerCase().replace(/^./, c => c.toUpperCase())
1042 // Shown with the chat above it, the card stays at the bottom, so the chat's end stays where the view already is.
1043 const open = !!chat.uncovered
1044 return (
1045 <Box key="textless-cover" flexDirection="column" alignItems="center" justifyContent="center" gap={1} paddingY={open ? 2 : 6}>
1046 {chat.title ? <Text key="textless-cover-title" dimColor wrap="truncate">{chat.title}</Text> : null}
1047 {Svg
1048 ? <Svg source={pixelWord(word, '#d97757')} alt={said} width={pixelWidth(word) * 3} height={PIXEL_H * 3} />
1049 : <Text color="#d97757" bold>{said}</Text>}
1050 {/* What Claude asks, drawn as under answers: an orange ? and the question, an orange letter per option. */}
1051 {(chat.cover?.asks ?? []).length
1052 ? <Box key="textless-cover-asks" flexDirection="column" gap={1} paddingX={2}>
1053 {(chat.cover?.asks ?? []).map((text, i, all) => {
1054 const c = choices(text)
1055 return (
1056 <Box key={`textless-cover-ask-${i}`} flexDirection="row" alignItems="flex-start" gap={1}>
1057 {all.length > 1 ? <Text color="#d97757" bold>{`${i + 1}`}</Text> : null}
1058 {Svg
1059 ? <Svg source={badgeSvg('?', '#d97757')} alt="Question" width={9 * BADGE_PX} height={9 * BADGE_PX} />
1060 : <Text color="#d97757">?</Text>}
1061 <Box flexDirection="column">
1062 <Text bold wrap="wrap">{c.stem || text}</Text>
1063 {chat.cover?.recs?.[i] ? recommendedLine($, e, chat.cover.recs[i]) : null}
1064 {c.options.map((o, j) => (
1065 <Box key={`textless-cover-ask-${i}-${j}`} flexDirection="row" gap={1}>
1066 <Text color="#d97757" bold>{String.fromCharCode(65 + j)}</Text>
1067 <Text wrap="wrap">{o}</Text>
1068 </Box>
1069 ))}
1070 </Box>
1071 </Box>
1072 )
1073 })}
1074 </Box>
1075 : null}
1076 {open ? null : <Text dimColor>The chat is hidden while textless covers it.</Text>}
1077 <Button key="textless-cover-show" plain label={open ? 'Hide the chat' : 'Show the chat'} onPress={() => {
1078 drawn.chat.uncovered = !open
1079 $.ui.invalidate('ui.render')
1080 }} />
1081 </Box>
1082 )
1083}
1084
1085// Planning: a spec, plan or notes being written (a document edited), or Claude leaving plan mode. The person reads
1086// those turns, so folding them away hides what they are asked about.
1087const DOC = /\.(md|mdx|markdown|txt|rst|adoc)$/i
1088export function planningStep(tool: string, input: any): boolean {
1089 if (/^(ExitPlanMode|EnterPlanMode)$/.test(tool)) return true
1090 if (!/^(Write|Edit|MultiEdit|NotebookEdit)$/.test(tool)) return false
1091 return DOC.test(String(input?.file_path ?? input?.path ?? input?.notebook_path ?? ''))
1092}
1093// The turn shows as Claude Code draws it from here on: the card goes, nothing folds, and it is not covered.
1094function stepAside($: any, chat: Chat, id: string) {
1095 if (!chat.working) return
1096 dbg($, `planning detected at ${id.slice(-8)}: textless steps aside for this turn`)
1097 chat.working = false
1098 chat.long = false
1099 chat.cover = null
1100 chat.planKey = id
1101 chat.timer?.cancel()
1102 chat.timer = null
1103 void saveCover($, chat).catch(() => undefined)
1104 void Promise.resolve().then(() => $.ui.invalidate('ui.render'))
1105}
1106// The small label on the step that showed the planning.
1107function planLabel($: any, e: any, inner: unknown) {
1108 const { Box, Text } = $.ui.resolve(e) as Record<string, any>
1109 return (
1110 <Box flexDirection="column">
1111 {inner as never}
1112 <Box key="textless-planning" flexDirection="row" gap={1}>
1113 <Text color="#d97757">✎ Planning detected</Text>
1114 <Text dimColor>textless shows this turn as it is, so you can read the plan.</Text>
1115 </Box>
1116 </Box>
1117 )
1118}
1119
1120async function keepTitle($: any, raw: unknown) {
1121 const title = String(raw ?? '').trim()
1122 if (title) (await chatOf($)).title = title.slice(0, 80)
1123}
1124
1125function endTurn($: any, chat: Chat) {
1126 dbg($, `endTurn working=${chat.working} long=${chat.long} steps=${chat.steps.length}`)
1127 // A turn the card covered stays folded once it is done: only its answer shows.
1128 chat.newestAt = -1
1129 if (chat.working && chat.long && chat.steps.length > 0) {
1130 const index = done.length
1131 chat.newestAt = index
1132 const answer = chat.lastText.trim() ? chat.lastText : ''
1133 done.push({ first: chat.steps[chat.steps.length - 1], steps: chat.steps.length, ms: chat.durationMs, open: false, answer, scene: sceneIndex(chat), stopped: chat.stopped, labels: chat.steps.map(id => stepLabels.get(id) ?? 'step') })
1134 if (answer) doneAnswers.set(answer, index)
1135 for (const id of chat.steps) foldedTools.set(id, index)
1136 // The answer is the turn's last text block (and the shorter versions of it drawn while it streamed), and any block
1137 // found in the final answer, which the app splits into several blocks when long. Only the text said along the way
1138 // folds.
1139 for (const t of chat.turnTexts) if (t.trim() && !chat.lastText.startsWith(t) && !inAnswer(chat, t) && !isKept(chat, t)) foldedTexts.set(t, index)
1140 void saveDone($, index).catch(() => undefined)
1141 }
1142 if (chat.working && !chat.long) {
1143 chat.cover = null
1144 void saveCover($, chat).catch(() => undefined)
1145 } else if (chat.working) {
1146 const only = asks(chat.answer) && questions(chat.answer).every(q => q.asks)
1147 chat.cover = { host: chat.lastText, word: chat.stopped ? '■ STOPPED' : only ? '? NEED YOUR INPUT' : '▶ YOUR TURN', asks: questions(chat.answer).filter(q => q.asks).map(q => q.text).slice(0, 5), recs: questions(chat.answer).filter(q => q.asks).map(q => recommendation(chat.answer, q.raw || q.text)).slice(0, 5) }
1148 void saveCover($, chat).catch(() => undefined)
1149 }
1150 chat.turnTexts = new Set<string>()
1151 chat.kept = []
1152 chat.lastText = ''
1153 chat.running = false
1154 chat.working = false
1155 chat.long = false
1156 chat.mainDone = false
1157 chat.agents.clear()
1158 chat.timer?.cancel()
1159 chat.ticker?.cancel()
1160 chat.timer = chat.ticker = null
1161 $.ui.invalidate('ui.render')
1162}
1163
1164// A finished turn's rows: its first step's row says what was done and opens the rest; its other rows draw nothing
1165// while folded. Rows of turns textless did not cover draw as usual.
1166// A finished turn with no answer to hang the card under (often a stopped one): its last step's row draws the card's
1167// row itself. Its other rows draw nothing.
1168async function folded($: any, e: any, next: any, ids: string[]) {
1169 const at = ids.map(id => foldedTools.get(id)).find(i => i !== undefined)
1170 if (at === undefined) return next(e)
1171 const d = done[at]
1172 if (!ids.includes(d.first) || d.answer) return blank($, e)
1173 return doneRow($, e, at)
1174}
1175
1176function isFolded(ids: string[]) {
1177 return ids.some(id => {
1178 const at = foldedTools.get(id)
1179 return at !== undefined && !done[at].open
1180 })
1181}
1182
1183function unfold($: any, at: number) {
1184 done[at].open = !done[at].open
1185 $.ui.invalidate('ui.render')
1186}
1187
1188// A 5x7 pixel font for the few words the cards say in pixels, drawn bold (each stroke doubled) with a darker drop
1189// one pixel down; each pixel PIXEL_PX square. '#' is ink.
1190const GLYPHS: Record<string, string[]> = {
1191 D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
1192 O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
1193 N: ['#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#', '#...#'],
1194 E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
1195 S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
1196 Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'],
1197 U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
1198 T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
1199 P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
1200 R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],hooks/art.ts 227 lines1// Working-card scenes: Clawd, the Claude Code pixel creature, at work. Pixel art on a grid, animated frame by frame
2// (discrete steps, no tweening), on a transparent background so the card has no box.
3const P = 14 // screen pixels per art pixel
4const GW = 64 // grid width
5const GH = 30 // grid height
6const W = GW * P
7const H = GH * P
8
9const PALETTE: Record<string, string> = {
10 '#': '#d77757', // Clawd
11 k: '#1e1b1a', // screen / dark
12 g: '#4a4340', // grey frame
13 l: '#8a7f78', // light grey
14 w: '#e8e0da', // white
15 o: '#d77757', // orange accent
16 b: '#6b5d57', // block
17}
18
19// Draws a sprite of rows of palette letters at grid (ox, oy); '.' and ' ' are empty. Same-colour pixels in a row are
20// merged into one run, and each colour is one path in grid units, which keeps the SVG well under the engine's cap.
21const sprite = (rows: string[], ox: number, oy: number) => {
22 const paths: Record<string, string> = {}
23 rows.forEach((row, y) => {
24 let x = 0
25 while (x < row.length) {
26 const c = row[x]
27 let end = x + 1
28 while (end < row.length && row[end] === c) end++
29 if (PALETTE[c]) paths[c] = (paths[c] ?? '') + `M${ox + x} ${oy + y}h${end - x}v1h-${end - x}z`
30 x = end
31 }
32 })
33 return Object.entries(paths).map(([c, d]) => `<path fill="${PALETTE[c]}" d="${d}"/>`).join('')
34}
35
36// Shows each frame in turn for an equal share of `dur` seconds, with hard cuts between them.
37const frames = (list: string[], dur: number) => {
38 const n = list.length
39 const keyTimes = list.map((_, i) => (i / n).toFixed(4)).join(';')
40 return list
41 .map((body, i) => {
42 const values = list.map((_, j) => (j === i ? 1 : 0)).join(';')
43 return `<g opacity="${i === 0 ? 1 : 0}">${body}<animate attributeName="opacity" values="${values}" keyTimes="${keyTimes}" calcMode="discrete" dur="${dur}s" repeatCount="indefinite"/></g>`
44 })
45 .join('')
46}
47
48// Clawd, 18 x 5, from the welcome screen's block glyphs. Eye holes on row 1; arms on row 2 by default.
49const clawd = (o: { blink?: boolean; armL?: 1 | 2 | 3; armR?: 1 | 2 | 3; step?: boolean } = {}) => {
50 const rows = [
51 '...############...',
52 o.blink ? '...############...' : '...##.######.##...',
53 '..##############..',
54 '...############...',
55 o.step ? '...#.#......#.#...' : '....#.#....#.#....',
56 ].map(r => [...r])
57 const armL = o.armL ?? 2
58 const armR = o.armR ?? 2
59 rows[armL][1] = '#'
60 rows[armR][16] = '#'
61 // Terminal glyph quadrants are twice as tall as wide, so each row is drawn twice.
62 return rows.flatMap(r => [r.join(''), r.join('')])
63}
64
65const wrap = (body: string) =>
66 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${GW} ${GH}" shape-rendering="crispEdges">${body}</svg>`
67
68// Scene 1: typing at a laptop, arms tapping, code filling the screen line by line.
69const LAPTOP = [
70 'gggggggggggggggg',
71 'gkkkkkkkkkkkkkkg',
72 'gkkkkkkkkkkkkkkg',
73 'gkkkkkkkkkkkkkkg',
74 'gkkkkkkkkkkkkkkg',
75 'gkkkkkkkkkkkkkkg',
76 'gkkkkkkkkkkkkkkg',
77 'gkkkkkkkkkkkkkkg',
78 'gggggggggggggggg',
79 'llllllllllllllllll',
80]
81const CODE = ['oooo.lllll', 'lllllll', '..oooll', '..llllllll', 'ooo', 'llll.oo']
82const codeUpTo = (n: number) => sprite(CODE.slice(0, n), 34, 14)
83const typingClawd = (i: number) =>
84 sprite(clawd({ armL: i % 2 ? 3 : 2, armR: i % 2 ? 2 : 3, blink: i === 9 }), 12, 12)
85const typing = wrap(
86 sprite(LAPTOP, 32, 12) +
87
88 frames(
89 Array.from({ length: 12 }, (_, i) => codeUpTo(Math.min(CODE.length, Math.floor(i / 2) + 1)) + typingClawd(i)),
90 2.4,
91 ),
92)
93
94// Scene 2: building, blocks landing one by one on a stack while Clawd swings a hammer.
95const BLOCK = ['bbbb', 'bbbb', 'bbbb']
96const BLOCK_O = ['oooo', 'oooo', 'oooo']
97const STACK: [string[], number, number][] = [
98 [BLOCK, 34, 21],
99 [BLOCK_O, 39, 21],
100 [BLOCK, 44, 21],
101 [BLOCK_O, 36, 18],
102 [BLOCK, 41, 18],
103 [BLOCK_O, 38, 15],
104]
105const HAMMER_UP = ['.ww', '.ww', '.l.', '.l.', '.l.']
106const HAMMER_DOWN = ['...', 'lllww', '....ww']
107const building = wrap(
108 sprite(['llllllllllllllllllllllll'], 30, 24) +
109 frames(
110 Array.from({ length: 14 }, (_, i) => {
111 const placed = Math.min(STACK.length, Math.floor(i / 2) + 1)
112 const blocks = STACK.slice(0, placed)
113 .map(([b, x, y], k) => sprite(b, x, k === placed - 1 && i % 2 === 0 && i < 12 ? y - 2 : y))
114 .join('')
115 const down = i % 2 === 1
116 return blocks + sprite(clawd({ armR: down ? 3 : 1, blink: i === 11 }), 8, 14) + sprite(down ? HAMMER_DOWN : HAMMER_UP, down ? 24 : 24, down ? 19 : 11)
117 }),
118 2.8,
119 ),
120)
121
122// Scene 3: searching a page, Clawd holding a magnifier that hops across the lines.
123const PAGE = [
124 'wwwwwwwwwwww',
125 'wkkkkk.kkk.w',
126 'w..........w',
127 'wkkkk.kkkkkw',
128 'w..........w',
129 'wkkkkkkk...w',
130 'w..........w',
131 'wkk.kkkkkk.w',
132 'w..........w',
133 'wkkkkk.kk..w',
134 'wwwwwwwwwwww',
135].map(r => r.replace(/\./g, 'w').replace(/k/g, 'l'))
136const GLASS = ['.ggg.', 'g...g', 'g...g', 'g...g', '.ggg.', '.....l', '......l']
137const SPOTS: [number, number][] = [
138 [35, 9],
139 [40, 10],
140 [37, 12],
141 [41, 14],
142 [36, 15],
143 [39, 11],
144]
145const searching = wrap(
146 sprite(PAGE, 34, 9) +
147 frames(
148 SPOTS.map(([x, y], i) => sprite(GLASS, x, y) + sprite(clawd({ armR: i % 2 ? 1 : 2, step: i % 2 === 1, blink: i === 4 }), 12, 13)),
149 3,
150 ),
151)
152
153
154// Environment scenes: a pixel panel with its own sky and ground, Clawd taking it easy in it.
155const box = (x: number, y: number, w: number, h: number, fill: string) => `<path fill="${fill}" d="M${x} ${y}h${w}v${h}h-${w}z"/>`
156const disc = (cx: number, cy: number, r: number, fill: string) =>
157 Array.from({ length: r * 2 + 1 }, (_, i) => {
158 const dy = i - r
159 const half = Math.floor(Math.sqrt(r * r - dy * dy + r * 0.8))
160 return box(cx - half, cy + dy, half * 2 + 1, 1, fill)
161 }).join('')
162// The panel: x 8..55, y 2..25, with its corner pixels cut, coloured row by row.
163const PX = 8
164const PY = 2
165const PW = 48
166const PH = 24
167const panel = (colourOf: (row: number) => string) =>
168 Array.from({ length: PH }, (_, r) => {
169 const inset = r === 0 || r === PH - 1 ? 1 : 0
170 return box(PX + inset, PY + r, PW - inset * 2, 1, colourOf(r))
171 }).join('')
172
173// Scene 4: surfing at sunset, waves rolling under the board.
174const SKY_DUSK = ['#2a1f3d', '#2a1f3d', '#2a1f3d', '#2a1f3d', '#3d2747', '#3d2747', '#3d2747', '#3d2747', '#5e3450', '#5e3450', '#5e3450', '#8a4652', '#8a4652', '#8a4652']
175const SEA = ['#1d3557', '#1d3557', '#1d3557', '#18304d', '#18304d', '#18304d', '#18304d', '#14273f', '#14273f', '#14273f']
176const BOARD = ['..wwwwwwwwwwwwwwww', '.wwwwwwwwwwwwwwwwww', '..llllllllllllllll']
177const waves = (f: number) =>
178 Array.from({ length: 6 }, (_, k) => {
179 const x = PX + 1 + ((k * 9 + f * 2) % (PW - 4))
180 return box(x, PY + 17, 3, 1, '#9fc4e0') + box(x + 1, PY + 16, 1, 1, '#9fc4e0')
181 }).join('')
182const glints = (f: number) =>
183 [0, 2, 4].map(k => box(PX + 35 + ((k + f) % 3) * 2, PY + 15 + k, 3 - (k % 2), 1, '#e8935f')).join('')
184const surfing = wrap(
185 panel(r => (r < SKY_DUSK.length ? SKY_DUSK[r] : SEA[r - SKY_DUSK.length])) +
186 // the sun sets behind the sea: drawn over the sky, then the sea rows cover its lower half
187 disc(PX + 37, PY + 13, 5, '#e8935f') +
188 SEA.map((c, i) => box(PX + (i === SEA.length - 1 ? 1 : 0), PY + SKY_DUSK.length + i, PW - (i === SEA.length - 1 ? 2 : 0), 1, c)).join('') +
189 frames(
190 Array.from({ length: 4 }, (_, f) => {
191 const bob = f % 2
192 return waves(f) + glints(f) + sprite(BOARD, PX + 13, PY + 18 + bob) + sprite(clawd({ armL: 1, armR: 1, blink: f === 3 }), PX + 13, PY + 8 + bob)
193 }),
194 1.6,
195 ),
196)
197
198// Scene 5: lying in a field at night, stars twinkling, a cloud drifting, grass swaying.
199const NIGHT = ['#141830', '#141830', '#141830', '#181d3a', '#181d3a', '#181d3a', '#1d2344', '#1d2344', '#1d2344', '#232a4d', '#232a4d', '#262d50', '#262d50', '#262d50']
200const GROUND = ['#1f3a2a', '#24452e', '#24452e', '#24452e', '#2d5737', '#2d5737', '#2d5737', '#2d5737', '#2d5737', '#2d5737']
201const STARS: [number, number][] = [[5, 2], [12, 4], [19, 1], [27, 3], [34, 5], [41, 2], [9, 8], [30, 9], [44, 7], [16, 10]]
202const CLOUD = ['..llll....', '.llllllll.', 'llllllllll']
203const FLOWERS: [number, number, string][] = [[4, 19, '#e8935f'], [11, 21, '#e8e0da'], [37, 20, '#e8935f'], [43, 22, '#e8e0da'], [31, 22, '#e8935f']]
204const blades = (f: number) =>
205 Array.from({ length: 16 }, (_, k) => {
206 const x = PX + 1 + k * 3
207 const lean = (k + f) % 2
208 return box(x + lean, PY + 13, 1, 1, '#3f7a4a') + box(x, PY + 14, 1, 1, '#3f7a4a')
209 }).join('')
210const field = wrap(
211 panel(r => (r < NIGHT.length ? NIGHT[r] : GROUND[r - NIGHT.length])) +
212 disc(PX + 40, PY + 5, 3, '#e8e0da') +
213 FLOWERS.map(([x, y, c]) => box(PX + x, PY + y, 1, 1, c)).join('') +
214 frames(
215 Array.from({ length: 6 }, (_, f) =>
216 STARS.filter((_, k) => (k + f) % 3 !== 0).map(([x, y]) => box(PX + x, PY + y, 1, 1, '#cfd6ff')).join('') +
217 sprite(CLOUD, PX + 2 + f * 2, PY + 3) +
218 blades(f) +
219 sprite(clawd({ armL: 1, armR: 1, blink: f >= 4 }), PX + 15, PY + 9),
220 ),
221 3,
222 ),
223)
224
225export const SCENES = [surfing, typing, field, building, searching]
226export const SCENE_SIZE = { width: W, height: H }
227hooks/terminal-art.ts 192 lines1// Pixel-art scenes for a plain terminal. A terminal cannot draw an SVG, but it can paint cells with a glyph, a foreground
2// and a background colour: this reads the shapes of a scene (rectangles, alone or as paths of rectangles) into a grid of
3// pixels, shrinks it to the room there is, and packs two pixel rows into each row of half-block cells. Pure functions, no
4// `$`: the card hands the result to the terminal's Raster element.
5
6type Pixel = { r: number; g: number; b: number; a: number }
7
8const NAMED: Record<string, string> = { black: '#000000', white: '#ffffff', red: '#ff0000', none: '' }
9
10function colour(value: string | undefined): [number, number, number] | null {
11 const v = String(value ?? '').trim().toLowerCase()
12 const hex = (NAMED[v] ?? v).replace('#', '')
13 if (/^[0-9a-f]{3}$/.test(hex)) return [parseInt(hex[0] + hex[0], 16), parseInt(hex[1] + hex[1], 16), parseInt(hex[2] + hex[2], 16)]
14 if (/^[0-9a-f]{6}$/.test(hex)) return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
15 return null
16}
17
18const attr = (attrs: string, name: string) => attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`))?.[1]
19
20/** The rectangles of a path's data: every subpath that is an axis-aligned box (M, H, V, L, Z in either case). */
21function pathBoxes(d: string): [number, number, number, number][] {
22 const boxes: [number, number, number, number][] = []
23 let x = 0
24 let y = 0
25 let pts: [number, number][] = []
26 const flush = () => {
27 if (pts.length >= 3) {
28 const xs = pts.map(p => p[0])
29 const ys = pts.map(p => p[1])
30 const x0 = Math.min(...xs)
31 const x1 = Math.max(...xs)
32 const y0 = Math.min(...ys)
33 const y1 = Math.max(...ys)
34 if (pts.every(p => (p[0] === x0 || p[0] === x1) && (p[1] === y0 || p[1] === y1))) boxes.push([x0, y0, x1 - x0, y1 - y0])
35 }
36 pts = []
37 }
38 for (const m of d.matchAll(/([MmHhVvLlZz])([^MmHhVvLlZz]*)/g)) {
39 const cmd = m[1]
40 const n = (m[2].match(/-?\d*\.?\d+(?:e-?\d+)?/gi) ?? []).map(Number)
41 const rel = cmd === cmd.toLowerCase()
42 const up = cmd.toUpperCase()
43 if (up === 'M') {
44 flush()
45 x = rel ? x + n[0] : n[0]
46 y = rel ? y + n[1] : n[1]
47 pts.push([x, y])
48 } else if (up === 'H') {
49 x = rel ? x + n[0] : n[0]
50 pts.push([x, y])
51 } else if (up === 'V') {
52 y = rel ? y + n[0] : n[0]
53 pts.push([x, y])
54 } else if (up === 'L') {
55 x = rel ? x + n[0] : n[0]
56 y = rel ? y + n[1] : n[1]
57 pts.push([x, y])
58 } else if (up === 'Z') flush()
59 }
60 flush()
61 return boxes
62}
63
64/** The scene's first frame as pixels: a grid of the viewBox's units. Groups that start hidden are skipped, groups an
65 * opacity animation starts at 1 are shown; transforms and animations are otherwise ignored. */
66export function sceneGrid(svg: string): { w: number; h: number; px: Pixel[] } | null {
67 const root = svg.match(/<svg\b([^>]*)>/)
68 if (!root) return null
69 const vb = attr(root[1], 'viewBox')?.trim().split(/[\s,]+/).map(Number)
70 const w = Math.round(vb?.[2] ?? Number(attr(root[1], 'width')))
71 const h = Math.round(vb?.[3] ?? Number(attr(root[1], 'height')))
72 if (!w || !h || w > 1024 || h > 1024) return null
73 const px: Pixel[] = Array.from({ length: w * h }, () => ({ r: 0, g: 0, b: 0, a: 0 }))
74 const paint = (x0: number, y0: number, bw: number, bh: number, rgb: [number, number, number], alpha: number) => {
75 for (let y = Math.max(0, Math.round(y0)); y < Math.min(h, Math.round(y0 + bh)); y++) {
76 for (let x = Math.max(0, Math.round(x0)); x < Math.min(w, Math.round(x0 + bw)); x++) {
77 const p = px[y * w + x]
78 const out = alpha + p.a * (1 - alpha)
79 if (out <= 0) continue
80 p.r = (rgb[0] * alpha + p.r * p.a * (1 - alpha)) / out
81 p.g = (rgb[1] * alpha + p.g * p.a * (1 - alpha)) / out
82 p.b = (rgb[2] * alpha + p.b * p.a * (1 - alpha)) / out
83 p.a = out
84 }
85 }
86 }
87 // One entry per open <g>: shown or not, and its opacity.
88 const stack: { shown: boolean; opacity: number }[] = []
89 const hidden = () => stack.some(g => !g.shown)
90 const opacity = () => stack.reduce((o, g) => o * g.opacity, 1)
91 for (const m of svg.matchAll(/<(\/?)([A-Za-z][\w:-]*)([^>]*?)(\/?)>/g)) {
92 const closing = m[1] === '/'
93 const name = m[2]
94 const attrs = m[3]
95 if (name === 'g') {
96 if (closing) stack.pop()
97 else if (m[4] !== '/') {
98 const o = attr(attrs, 'opacity')
99 stack.push({ shown: o === undefined || Number(o) > 0, opacity: o === undefined ? 1 : Number(o) || 0 })
100 }
101 continue
102 }
103 if (closing) continue
104 if (name === 'animate' && attr(attrs, 'attributeName') === 'opacity' && stack.length) {
105 // Its first value is how the group starts.
106 const first = Number((attr(attrs, 'values') ?? '').split(';')[0])
107 const top = stack[stack.length - 1]
108 if (!Number.isNaN(first)) {
109 top.shown = first > 0
110 top.opacity = first > 0 ? 1 : 0
111 }
112 continue
113 }
114 if (hidden()) continue
115 const rgb = colour(attr(attrs, 'fill'))
116 if (!rgb) continue
117 const alpha = Math.min(1, Number(attr(attrs, 'fill-opacity') ?? 1) * Number(attr(attrs, 'opacity') ?? 1) * opacity())
118 if (alpha <= 0) continue
119 if (name === 'rect') {
120 const rw = Number(attr(attrs, 'width'))
121 const rh = Number(attr(attrs, 'height'))
122 if (rw > 0 && rh > 0) paint(Number(attr(attrs, 'x') ?? 0), Number(attr(attrs, 'y') ?? 0), rw, rh, rgb, alpha)
123 } else if (name === 'path') {
124 for (const [bx, by, bw, bh] of pathBoxes(attr(attrs, 'd') ?? '')) paint(bx, by, bw, bh, rgb, alpha)
125 }
126 }
127 return px.some(p => p.a > 0) ? { w, h, px } : null
128}
129
130const base64 = (bytes: Uint8Array) => {
131 const abc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
132 let out = ''
133 for (let i = 0; i < bytes.length; i += 3) {
134 const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
135 out += abc[(n >> 18) & 63] + abc[(n >> 12) & 63] + (i + 1 < bytes.length ? abc[(n >> 6) & 63] : '=') + (i + 2 < bytes.length ? abc[n & 63] : '=')
136 }
137 return out
138}
139
140const DEFAULT = 0x01000000
141const rgbInt = (p: Pixel) => (Math.round(p.r) << 16) | (Math.round(p.g) << 8) | Math.round(p.b)
142
143/** The scene as terminal cells: shrunk by whole pixels to fit `maxCols` columns and `maxRows` rows, two pixel rows to a
144 * row of half blocks. Null when the scene has nothing this can read (a quote, a picture). */
145export function sceneCells(svg: string, maxCols: number, maxRows: number): { columns: number; rows: number; cells: string } | null {
146 const grid = sceneGrid(svg)
147 if (!grid) return null
148 const k = Math.max(1, Math.ceil(grid.w / Math.max(8, maxCols)), Math.ceil(grid.h / (Math.max(4, maxRows) * 2)))
149 const columns = Math.ceil(grid.w / k)
150 const pixelRows = Math.ceil(grid.h / k)
151 const rows = Math.ceil(pixelRows / 2)
152 // Each shrunk pixel averages its block, weighted by how much of it is painted.
153 const small: Pixel[] = []
154 for (let y = 0; y < rows * 2; y++) {
155 for (let x = 0; x < columns; x++) {
156 let r = 0
157 let g = 0
158 let b = 0
159 let a = 0
160 let n = 0
161 for (let dy = 0; dy < k; dy++) {
162 for (let dx = 0; dx < k; dx++) {
163 const sx = x * k + dx
164 const sy = y * k + dy
165 if (sx >= grid.w || sy >= grid.h) continue
166 const p = grid.px[sy * grid.w + sx]
167 n++
168 a += p.a
169 r += p.r * p.a
170 g += p.g * p.a
171 b += p.b * p.a
172 }
173 }
174 small.push(a > 0 ? { r: r / a, g: g / a, b: b / a, a: a / Math.max(1, n) } : { r: 0, g: 0, b: 0, a: 0 })
175 }
176 }
177 const words = new Uint32Array(columns * rows * 3)
178 const solid = (p: Pixel) => p.a >= 0.5
179 for (let row = 0; row < rows; row++) {
180 for (let col = 0; col < columns; col++) {
181 const top = small[row * 2 * columns + col]
182 const bottom = small[(row * 2 + 1) * columns + col]
183 const i = (row * columns + col) * 3
184 if (solid(top) && solid(bottom)) { words[i] = 0x2580; words[i + 1] = rgbInt(top); words[i + 2] = rgbInt(bottom) }
185 else if (solid(top)) { words[i] = 0x2580; words[i + 1] = rgbInt(top); words[i + 2] = DEFAULT }
186 else if (solid(bottom)) { words[i] = 0x2584; words[i + 1] = rgbInt(bottom); words[i + 2] = DEFAULT }
187 else { words[i] = 0x20; words[i + 1] = DEFAULT; words[i + 2] = DEFAULT }
188 }
189 }
190 return { columns, rows, cells: base64(new Uint8Array(words.buffer)) }
191}
192hooks/chess-engine.ts 155 lines1// A small chess engine for the opponent in the chat: it needs no model and no network, answers in a fraction of a
2// second and plays sensibly rather than well. Negamax with alpha-beta, material and a small pull towards the centre,
3// captures tried first. Equal moves are told apart by chance, so no two games open the same way.
4import { inCheck, legalMoves, play, type Move, type Position } from './chess-rules'
5
6const VALUE: Record<string, number> = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 0 }
7const MATE = 30000
8
9// How far a square is from the middle of the board, 0 in the centre to 3 at the corner: pieces like the middle.
10const centre = (sq: number) => {
11 const f = sq & 7
12 const r = sq >> 3
13 return Math.min(f, 7 - f) + Math.min(r, 7 - r)
14}
15
16// The position as the side to move sees it: positive is good for them.
17function evaluate(p: Position): number {
18 let score = 0
19 for (let sq = 0; sq < 64; sq++) {
20 const piece = p.board[sq]
21 if (!piece) continue
22 const kind = piece.toLowerCase()
23 let v = VALUE[kind]
24 if (kind === 'n' || kind === 'b') {
25 v += centre(sq) * 8
26 // A minor piece still on its first rank has not been brought out: castling and attacks come after.
27 if ((piece === piece.toUpperCase() ? sq >> 3 === 0 : sq >> 3 === 7)) v -= 14
28 } else if (kind === 'p') {
29 const advanced = piece === 'P' ? sq >> 3 : 7 - (sq >> 3)
30 v += advanced * 6 + centre(sq) * 3
31 } else if (kind === 'q') v += centre(sq) * 2
32 else if (kind === 'k') {
33 // A king tucked in a corner of its own first rank is safe in the opening and middle game.
34 const home = piece === 'K' ? sq >> 3 === 0 : sq >> 3 === 7
35 const file = sq & 7
36 if (home && (file <= 2 || file >= 6)) v += 25
37 }
38 score += piece === piece.toUpperCase() ? v : -v
39 }
40 return p.turn === 'w' ? score : -score
41}
42
43// Captures first, the bigger prize for the smaller piece best, then promotions: the cut-offs come sooner that way.
44function ordered(p: Position, moves: Move[]): Move[] {
45 const key = (m: Move) => {
46 const victim = p.board[m.to]
47 const mover = p.board[m.from]
48 return (victim ? 10 * VALUE[victim.toLowerCase()] - VALUE[mover.toLowerCase()] : 0) + (m.promo ? 800 : 0)
49 }
50 return [...moves].sort((a, b) => key(b) - key(a))
51}
52
53// At the end of the search only captures are played out, so a piece is never counted as won when the next move takes it
54// back: that horizon mistake is where a shallow search looks foolish.
55function quiet(p: Position, alpha: number, beta: number, ply: number): number {
56 const stand = evaluate(p)
57 if (stand >= beta) return stand
58 if (stand > alpha) alpha = stand
59 if (ply > 8) return stand
60 const moves = legalMoves(p)
61 if (moves.length === 0) return inCheck(p) ? -MATE + ply : 0
62 for (const m of ordered(p, moves.filter(x => p.board[x.to] || x.promo))) {
63 const score = -quiet(play(p, m), -beta, -alpha, ply + 1)
64 if (score >= beta) return score
65 if (score > alpha) alpha = score
66 }
67 return alpha
68}
69
70let deadline = Infinity
71let expired = false
72
73function search(p: Position, depth: number, alpha: number, beta: number, ply: number): number {
74 if (Date.now() > deadline) {
75 expired = true
76 return 0
77 }
78 const moves = legalMoves(p)
79 if (moves.length === 0) return inCheck(p) ? -MATE + ply : 0
80 if (depth === 0) return quiet(p, alpha, beta, ply)
81 let best = -Infinity
82 for (const m of ordered(p, moves)) {
83 const score = -search(play(p, m), depth - 1, -beta, -alpha, ply + 1)
84 if (score > best) best = score
85 if (best > alpha) alpha = best
86 if (alpha >= beta) break
87 if (expired) break
88 }
89 return best
90}
91
92export type ChessLevel = 'easy' | 'normal' | 'hard' | 'master'
93export const CHESS_LEVELS: ChessLevel[] = ['easy', 'normal', 'hard', 'master']
94// How deep each level looks, how long it may think, and how often it plays a random legal move instead.
95const LEVELS: Record<ChessLevel, { depth: number; ms: number; slip: number }> = {
96 easy: { depth: 1, ms: 400, slip: 0.35 },
97 normal: { depth: 2, ms: 800, slip: 0 },
98 hard: { depth: 3, ms: 1500, slip: 0 },
99 master: { depth: 6, ms: 3000, slip: 0 },
100}
101
102/** The engine's move for the side to move at a level, or null when there is none. Deepens one move at a time and keeps
103 * the last depth that finished in time, so a crowded position cannot hold the chat up. */
104function* steps(p: Position, level: ChessLevel | number, random: () => number): Generator<void, Move | null> {
105 const all = legalMoves(p)
106 if (all.length === 0) return null
107 const cfg = typeof level === 'number' ? { depth: level, ms: 60_000, slip: 0 } : LEVELS[level]
108 if (cfg.slip > 0 && random() < cfg.slip) return all[Math.floor(random() * all.length)]
109 deadline = Date.now() + cfg.ms
110 expired = false
111 const moves = ordered(p, all)
112 let picks: Move[] = [moves[0]]
113 for (let depth = 1; depth <= cfg.depth; depth++) {
114 let best = -Infinity
115 let found: Move[] = []
116 for (const m of moves) {
117 // A window just under the best so far keeps ties in play while still cutting everything clearly worse.
118 const score = -search(play(p, m), depth - 1, -MATE * 2, -(best - 1), 1)
119 if (expired) break
120 if (score > best) {
121 best = score
122 found = [m]
123 } else if (score === best) found.push(m)
124 yield
125 }
126 if (expired) break
127 picks = found
128 if (best > MATE - 100) break
129 }
130 return picks[Math.floor(picks.length * random())]
131}
132
133export function bestMove(p: Position, level: ChessLevel | number = 'hard', random: () => number = Math.random): Move | null {
134 const run = steps(p, level, random)
135 for (;;) {
136 const r = run.next()
137 if (r.done) return r.value
138 }
139}
140
141/** The same move, found in slices: after each root move that took long, `pause` runs, so the rest of the app is not held
142 * for the whole think (the plugin runs on one thread, and a long think left buttons dead). */
143export async function bestMoveAsync(p: Position, level: ChessLevel | number, pause: () => Promise<void>, random: () => number = Math.random): Promise<Move | null> {
144 const run = steps(p, level, random)
145 let last = Date.now()
146 for (;;) {
147 const r = run.next()
148 if (r.done) return r.value
149 if (Date.now() - last > 25) {
150 await pause()
151 last = Date.now()
152 }
153 }
154}
155hooks/chess-rules.ts 217 lines1// The rules of chess, small and on their own: a position, its legal moves, playing one, and how a game ends.
2// Squares are 0..63, a1 = 0, h1 = 7, a8 = 56. White pieces are capitals (PNBRQK), black ones lower case, '' is empty.
3
4export type Side = 'w' | 'b'
5export type Position = { board: string[]; turn: Side; castle: string; ep: number; half: number; full: number }
6export type Move = { from: number; to: number; promo?: string }
7
8export const START = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
9
10const KNIGHT = [[1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, -1], [-2, 1], [-1, 2]]
11const KING = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]]
12const ROOK = [[1, 0], [-1, 0], [0, 1], [0, -1]]
13const BISHOP = [[1, 1], [1, -1], [-1, 1], [-1, -1]]
14
15const fileOf = (sq: number) => sq & 7
16const rankOf = (sq: number) => sq >> 3
17const at = (f: number, r: number) => (f < 0 || f > 7 || r < 0 || r > 7 ? -1 : r * 8 + f)
18const sideOf = (p: string): Side | '' => (p === '' ? '' : p === p.toUpperCase() ? 'w' : 'b')
19
20export const squareName = (sq: number) => 'abcdefgh'[fileOf(sq)] + String(rankOf(sq) + 1)
21export const squareOf = (name: string) => at('abcdefgh'.indexOf(name[0]), Number(name[1]) - 1)
22export const uci = (m: Move) => squareName(m.from) + squareName(m.to) + (m.promo ?? '')
23
24export function parseFen(fen: string): Position {
25 const [rows, turn, castle, ep, half, full] = fen.trim().split(/\s+/)
26 const board: string[] = new Array(64).fill('')
27 rows.split('/').forEach((row, i) => {
28 let f = 0
29 for (const ch of row) {
30 if (/\d/.test(ch)) f += Number(ch)
31 else board[(7 - i) * 8 + f++] = ch
32 }
33 })
34 return { board, turn: turn === 'b' ? 'b' : 'w', castle: castle === '-' ? '' : castle, ep: ep && ep !== '-' ? squareOf(ep) : -1, half: Number(half ?? 0), full: Number(full ?? 1) }
35}
36
37export function toFen(p: Position): string {
38 const rows: string[] = []
39 for (let r = 7; r >= 0; r--) {
40 let row = ''
41 let gap = 0
42 for (let f = 0; f < 8; f++) {
43 const piece = p.board[r * 8 + f]
44 if (!piece) gap++
45 else {
46 row += (gap ? String(gap) : '') + piece
47 gap = 0
48 }
49 }
50 rows.push(row + (gap ? String(gap) : ''))
51 }
52 return `${rows.join('/')} ${p.turn} ${p.castle || '-'} ${p.ep >= 0 ? squareName(p.ep) : '-'} ${p.half} ${p.full}`
53}
54
55// Whether `by` attacks `sq`: looked at from the square outwards, as each kind of piece would reach it.
56export function attacked(p: Position, sq: number, by: Side): boolean {
57 const f = fileOf(sq)
58 const r = rankOf(sq)
59 const own = (piece: string) => (by === 'w' ? piece.toUpperCase() : piece.toLowerCase())
60 const pr = by === 'w' ? r - 1 : r + 1
61 for (const df of [-1, 1]) if (p.board[at(f + df, pr)] === own('p')) return true
62 for (const [df, dr] of KNIGHT) if (p.board[at(f + df, r + dr)] === own('n')) return true
63 for (const [df, dr] of KING) if (p.board[at(f + df, r + dr)] === own('k')) return true
64 const slide = (dirs: number[][], kinds: string[]) => {
65 for (const [df, dr] of dirs) {
66 for (let k = 1; ; k++) {
67 const s = at(f + df * k, r + dr * k)
68 if (s < 0) break
69 const piece = p.board[s]
70 if (piece) {
71 if (kinds.some(x => piece === own(x))) return true
72 break
73 }
74 }
75 }
76 return false
77 }
78 return slide(ROOK, ['r', 'q']) || slide(BISHOP, ['b', 'q'])
79}
80
81export const kingOf = (p: Position, side: Side) => p.board.indexOf(side === 'w' ? 'K' : 'k')
82export const inCheck = (p: Position, side: Side = p.turn) => attacked(p, kingOf(p, side), side === 'w' ? 'b' : 'w')
83
84function pseudo(p: Position): Move[] {
85 const out: Move[] = []
86 const me = p.turn
87 const foe: Side = me === 'w' ? 'b' : 'w'
88 for (let sq = 0; sq < 64; sq++) {
89 const piece = p.board[sq]
90 if (sideOf(piece) !== me) continue
91 const f = fileOf(sq)
92 const r = rankOf(sq)
93 const kind = piece.toLowerCase()
94 const add = (to: number) => out.push({ from: sq, to })
95 if (kind === 'p') {
96 const dir = me === 'w' ? 1 : -1
97 const last = me === 'w' ? 7 : 0
98 const push = (to: number) => (rankOf(to) === last ? ['q', 'r', 'b', 'n'].forEach(promo => out.push({ from: sq, to, promo })) : add(to))
99 const one = at(f, r + dir)
100 if (one >= 0 && !p.board[one]) {
101 push(one)
102 const two = at(f, r + 2 * dir)
103 if (r === (me === 'w' ? 1 : 6) && !p.board[two]) add(two)
104 }
105 for (const df of [-1, 1]) {
106 const to = at(f + df, r + dir)
107 if (to < 0) continue
108 if (sideOf(p.board[to]) === foe) push(to)
109 else if (to === p.ep) add(to)
110 }
111 } else if (kind === 'n' || kind === 'k') {
112 for (const [df, dr] of kind === 'n' ? KNIGHT : KING) {
113 const to = at(f + df, r + dr)
114 if (to >= 0 && sideOf(p.board[to]) !== me) add(to)
115 }
116 if (kind === 'k') {
117 const home = me === 'w' ? 4 : 60
118 const rights = me === 'w' ? p.castle.replace(/[a-z]/g, '') : p.castle.replace(/[A-Z]/g, '').toUpperCase()
119 if (sq === home && !inCheck(p, me)) {
120 if (rights.includes('K') && !p.board[home + 1] && !p.board[home + 2] && !attacked(p, home + 1, foe)) add(home + 2)
121 if (rights.includes('Q') && !p.board[home - 1] && !p.board[home - 2] && !p.board[home - 3] && !attacked(p, home - 1, foe)) add(home - 2)
122 }
123 }
124 } else {
125 const dirs = kind === 'r' ? ROOK : kind === 'b' ? BISHOP : [...ROOK, ...BISHOP]
126 for (const [df, dr] of dirs) {
127 for (let k = 1; ; k++) {
128 const to = at(f + df * k, r + dr * k)
129 if (to < 0) break
130 const s = sideOf(p.board[to])
131 if (s === me) break
132 add(to)
133 if (s === foe) break
134 }
135 }
136 }
137 }
138 return out
139}
140
141export function play(p: Position, m: Move): Position {
142 const board = [...p.board]
143 const piece = board[m.from]
144 const kind = piece.toLowerCase()
145 const captured = board[m.to]
146 board[m.to] = m.promo ? (p.turn === 'w' ? m.promo.toUpperCase() : m.promo.toLowerCase()) : piece
147 board[m.from] = ''
148 if (kind === 'p' && m.to === p.ep) board[m.to + (p.turn === 'w' ? -8 : 8)] = ''
149 if (kind === 'k' && Math.abs(m.to - m.from) === 2) {
150 const kingside = m.to > m.from
151 const rookFrom = kingside ? m.from + 3 : m.from - 4
152 board[kingside ? m.from + 1 : m.from - 1] = board[rookFrom]
153 board[rookFrom] = ''
154 }
155 let castle = p.castle
156 const drop = (sq: number) => {
157 if (sq === 4) castle = castle.replace(/[KQ]/g, '')
158 if (sq === 60) castle = castle.replace(/[kq]/g, '')
159 if (sq === 0) castle = castle.replace('Q', '')
160 if (sq === 7) castle = castle.replace('K', '')
161 if (sq === 56) castle = castle.replace('q', '')
162 if (sq === 63) castle = castle.replace('k', '')
163 }
164 drop(m.from)
165 drop(m.to)
166 const ep = kind === 'p' && Math.abs(m.to - m.from) === 16 ? (m.from + m.to) / 2 : -1
167 return {
168 board,
169 turn: p.turn === 'w' ? 'b' : 'w',
170 castle,
171 ep,
172 half: kind === 'p' || captured ? 0 : p.half + 1,
173 full: p.full + (p.turn === 'b' ? 1 : 0),
174 }
175}
176
177export function legalMoves(p: Position): Move[] {
178 return pseudo(p).filter(m => !inCheck(play(p, m), p.turn))
179}
180
181// Counts the move tree to `depth`: the standard check that move generation is right.
182export function perft(p: Position, depth: number): number {
183 if (depth === 0) return 1
184 let n = 0
185 for (const m of legalMoves(p)) n += perft(play(p, m), depth - 1)
186 return n
187}
188
189export type Outcome = 'checkmate' | 'stalemate' | 'fifty' | 'material' | null
190
191export function outcome(p: Position): Outcome {
192 if (legalMoves(p).length === 0) return inCheck(p) ? 'checkmate' : 'stalemate'
193 if (p.half >= 100) return 'fifty'
194 const left = p.board.filter(x => x && x.toLowerCase() !== 'k')
195 if (left.length === 0 || (left.length === 1 && /[nbNB]/.test(left[0]))) return 'material'
196 return null
197}
198
199const NAMES: Record<string, string> = { p: 'pawn', n: 'knight', b: 'bishop', r: 'rook', q: 'queen', k: 'king' }
200
201// A move in words for the chat: "knight g1 to f3", "pawn e4 takes d5", "castles kingside".
202export function describe(p: Position, m: Move): string {
203 const piece = p.board[m.from].toLowerCase()
204 if (piece === 'k' && Math.abs(m.to - m.from) === 2) return m.to > m.from ? 'castles kingside' : 'castles queenside'
205 const takes = p.board[m.to] || (piece === 'p' && m.to === p.ep)
206 const promo = m.promo ? `, becomes a ${NAMES[m.promo]}` : ''
207 return `${NAMES[piece]} ${squareName(m.from)} ${takes ? 'takes' : 'to'} ${squareName(m.to)}${promo}`
208}
209
210// The board as text, white at the bottom, for the model to read.
211export function picture(p: Position): string {
212 const lines: string[] = []
213 for (let r = 7; r >= 0; r--) lines.push(`${r + 1} ${p.board.slice(r * 8, r * 8 + 8).map(x => x || '.').join(' ')}`)
214 lines.push(' a b c d e f g h')
215 return lines.join('\n')
216}
217hooks/market-rules.ts 137 lines1// @ts-nocheck
2// A copy of market/rules.mjs, written by scripts/sync-market-rules.mjs. Edit that file, not this one.
3// The safety rules every community scene must pass before anyone else can get it. Pure functions on bytes and
4// text, no file system, so the upload Worker and the plugin can both run them. Anything that could run code, load
5// something from the internet or slow the chat down is refused, never silently cleaned: the uploader is told what
6// to fix.
7
8export const LIMITS = { svgBytes: 300 * 1024, rasterBytes: 95 * 1024, maxSide: 1024, minSide: 32 }
9export const TYPES = { svg: "image/svg+xml", gif: "image/gif", png: "image/png", webp: "image/webp", jpg: "image/jpeg", jpeg: "image/jpeg" }
10// Recommended shapes, the ones the built-in scenes use.
11export const SIZES = { wide: [560, 350], square: [560, 560], tall: [560, 840] }
12
13// SVG elements a scene may use: shapes, paint, masks, filters and SMIL animation. Everything else is refused,
14// including script, foreignObject (HTML inside SVG), iframe, audio and video.
15const ELEMENTS = new Set([
16 "svg", "g", "defs", "title", "desc", "symbol", "use", "rect", "circle", "ellipse", "line", "polyline", "polygon",
17 "path", "text", "tspan", "image", "linearGradient", "radialGradient", "stop", "pattern", "mask", "clipPath",
18 "marker", "style", "filter", "feBlend", "feColorMatrix", "feComponentTransfer", "feComposite", "feConvolveMatrix",
19 "feDiffuseLighting", "feDisplacementMap", "feDistantLight", "feDropShadow", "feFlood", "feFuncA", "feFuncB",
20 "feFuncG", "feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode", "feMorphology", "feOffset",
21 "fePointLight", "feSpecularLighting", "feSpotLight", "feTile", "feTurbulence", "animate", "animateMotion",
22 "animateTransform", "set", "mpath",
23])
24// A link may point inside the file (#id) or hold a small embedded raster image; nothing may be fetched.
25const SAFE_HREF = /^(#[\w.:-]+|data:image\/(png|gif|webp|jpeg);base64,[A-Za-z0-9+/=\s]+)$/
26// url(...) in styles and paint may only point inside the file.
27const BAD_URL = /url\(\s*(['"]?)(?!#)[^)]*\)/i
28
29function side(w, h) {
30 const out = []
31 if (Math.max(w, h) > LIMITS.maxSide) out.push(`the image is ${w}x${h}; the longest side may be ${LIMITS.maxSide} px`)
32 if (Math.min(w, h) < LIMITS.minSide) out.push(`the image is ${w}x${h}; the shortest side must be at least ${LIMITS.minSide} px`)
33 return out
34}
35
36// Returns { problems, width, height } for an SVG's text.
37export function checkSvg(text) {
38 const problems = []
39 const bytes = new TextEncoder().encode(text).length
40 if (bytes > LIMITS.svgBytes) problems.push(`the SVG is ${Math.round(bytes / 1024)} KB; the limit is ${LIMITS.svgBytes / 1024} KB`)
41 if (/<!DOCTYPE|<!ENTITY/i.test(text)) problems.push("DOCTYPE and ENTITY declarations are not allowed")
42 if (/<\?(?!xml\s)/i.test(text)) problems.push("processing instructions other than <?xml ...?> are not allowed")
43 const body = text.replace(/<!--[\s\S]*?-->/g, "").replace(/<\?xml[^>]*\?>/i, "")
44 if (/<!\[CDATA\[/i.test(body)) problems.push("CDATA sections are not allowed")
45 const tags = [...body.matchAll(/<\s*([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g)]
46 if (!tags.length || tags[0][1] !== "svg") problems.push("the file must start with an <svg> element")
47 for (const [, name, attrs] of tags) {
48 const local = name.replace(/^svg:/, "")
49 if (!ELEMENTS.has(local)) { problems.push(`<${name}> is not allowed`); continue }
50 for (const m of attrs.matchAll(/([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
51 const key = m[1], value = m[3] ?? m[4] ?? "", k = key.toLowerCase()
52 if (k.startsWith("on")) problems.push(`<${name}> has an event handler (${key}); scenes cannot run code`)
53 else if (/(^|:)(href|src)$/.test(k)) {
54 if (!SAFE_HREF.test(value.trim())) problems.push(`<${name} ${key}> points outside the file; only #ids and embedded PNG, GIF, WebP or JPEG are allowed`)
55 } else if (BAD_URL.test(value)) problems.push(`<${name} ${key}> uses url() outside the file`)
56 else if (/javascript:|expression\s*\(/i.test(value)) problems.push(`<${name} ${key}> holds code`)
57 if ((local === "set" || local.startsWith("animate")) && k === "attributename" && /(^|:)(href|on\w+)$/i.test(value)) problems.push(`<${name}> may not animate ${value}`)
58 }
59 }
60 for (const [, css] of body.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
61 if (/@import/i.test(css)) problems.push("@import in <style> is not allowed")
62 if (BAD_URL.test(css)) problems.push("url() in <style> may only point inside the file")
63 }
64 const root = tags[0]?.[2] ?? ""
65 const width = Number(root.match(/\swidth\s*=\s*["']([\d.]+)/)?.[1])
66 const height = Number(root.match(/\sheight\s*=\s*["']([\d.]+)/)?.[1])
67 if (!width || !height) problems.push('the <svg> element needs width and height in pixels, for example width="560" height="350"')
68 else problems.push(...side(width, height))
69 return { problems: [...new Set(problems)], width, height }
70}
71
72// Reads width and height from a raster file's own header; null when the bytes are not that type.
73export function rasterSize(bytes, ext) {
74 const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
75 const v = new DataView(b.buffer, b.byteOffset, b.byteLength)
76 const str = (from, to) => String.fromCharCode(...b.subarray(from, to))
77 const type = TYPES[ext]
78 if (type === "image/png" && b.length > 24 && [137, 80, 78, 71, 13, 10, 26, 10].every((x, i) => b[i] === x)) return [v.getUint32(16), v.getUint32(20)]
79 if (type === "image/gif" && b.length > 10 && /^GIF8[79]a$/.test(str(0, 6))) return [v.getUint16(6, true), v.getUint16(8, true)]
80 if (type === "image/webp" && b.length > 30 && str(0, 4) === "RIFF" && str(8, 12) === "WEBP") {
81 const chunk = str(12, 16)
82 const u24 = o => b[o] | (b[o + 1] << 8) | (b[o + 2] << 16)
83 if (chunk === "VP8X") return [1 + u24(24), 1 + u24(27)]
84 if (chunk === "VP8 ") return [v.getUint16(26, true) & 0x3fff, v.getUint16(28, true) & 0x3fff]
85 if (chunk === "VP8L") { const x = v.getUint32(21, true); return [1 + (x & 0x3fff), 1 + ((x >>> 14) & 0x3fff)] }
86 }
87 if (type === "image/jpeg" && b[0] === 0xff && b[1] === 0xd8) {
88 for (let i = 2; i < b.length - 9;) {
89 if (b[i] !== 0xff) { i++; continue }
90 const marker = b[i + 1], len = v.getUint16(i + 2)
91 if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return [v.getUint16(i + 7), v.getUint16(i + 5)]
92 i += 2 + len
93 }
94 }
95 return null
96}
97
98export function checkRaster(bytes, ext) {
99 const problems = []
100 if (bytes.byteLength > LIMITS.rasterBytes) problems.push(`the image is ${Math.round(bytes.byteLength / 1024)} KB; the limit is ${LIMITS.rasterBytes / 1024} KB`)
101 const size = rasterSize(bytes, ext)
102 if (!size) problems.push(`the file is not a real .${ext} image`)
103 else problems.push(...side(size[0], size[1]))
104 return { problems, width: size?.[0], height: size?.[1] }
105}
106
107// One uploaded file, judged by its name's extension and its bytes.
108export function checkFile(filename, bytes) {
109 const ext = String(filename).toLowerCase().split(".").pop()
110 if (!TYPES[ext] || !/\./.test(filename)) return { problems: ["the file must be an .svg, .gif, .png, .webp or .jpg"], ext }
111 const out = ext === "svg" ? checkSvg(new TextDecoder("utf-8", { fatal: false }).decode(bytes)) : checkRaster(bytes, ext)
112 return { ...out, ext: ext === "jpeg" ? "jpg" : ext, type: TYPES[ext] }
113}
114
115// The text fields of an upload. Returns problems and the cleaned values.
116export function checkFields(f) {
117 const problems = []
118 const text = (k, max, required) => {
119 const v = String(f[k] ?? "").trim().replace(/\s+/g, " ")
120 if (!v) { if (required) problems.push(`${k} is required`); return "" }
121 if (v.length > max) problems.push(`${k} may be at most ${max} characters`)
122 if (/[<>]/.test(v)) problems.push(`${k} may not contain < or >`)
123 return v
124 }
125 const name = text("name", 40, true)
126 const author = text("author", 40, true)
127 const description = text("description", 140, false)
128 let link = String(f.link ?? "").trim()
129 if (link) {
130 try { const u = new URL(link); if (u.protocol !== "https:" || link.length > 200) throw 0; link = u.href } catch { problems.push("link must be an https address"); link = "" }
131 }
132 let email = String(f.email ?? "").trim()
133 if (email && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 120)) { problems.push("email does not look right"); email = "" }
134 if (f.consent !== "yes" && f.consent !== true) problems.push("tick the box to confirm you made this and share it under CC BY 4.0")
135 return { problems, values: { name, author, description, link, email } }
136}
137hooks/quill.ts 115 lines1// The quotes set, drawn when a quote is shown instead of shipped as one file each: the line in italic Georgia settles
2// in word by word while a pixel quill dips once in its inkwell, then the author's name shows. Pure (no $), so the
3// card and the tests both call it.
4//
5// The SVG's own attributes are the finished card (every letter inked, the quill resting in the inkwell); the
6// animations only play the words in up to it. Taking the animations out, as stillCard does, leaves the finished card,
7// and the root's data-once says after how many seconds that is the right thing to show.
8import { INKWELL, ITALIC, QUILL, UPRIGHT } from './quill-data'
9
10const VW = 560
11const VH = 350
12const INK = '#d97757'
13const GREY = '#8a8780'
14const FAMILY = "Georgia, 'Times New Roman', serif"
15const WELL_X = 52 // bottom left, clear of the author on the right; the tilted feather stays inside the card
16const WELL_Y = VH - 40
17const REST_ANGLE = 14
18
19const num = (v: number) => {
20 const s = v.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')
21 return s === '' || s === '-0' ? '0' : s
22}
23const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<')
24
25// A line's width at a font size: the sum of its letters' advances (no kerning), measured from Georgia once.
26function measure(table: Record<string, number>, text: string, size: number) {
27 let w = 0
28 for (const ch of text) w += table[ch] ?? 500
29 return (w * size) / 1000
30}
31
32function wrap(text: string, size: number, limit: number): [string, number][] {
33 const lines: string[] = []
34 let line = ''
35 for (const word of text.split(/\s+/).filter(Boolean)) {
36 const trial = line ? `${line} ${word}` : word
37 if (measure(ITALIC, trial, size) <= limit) line = trial
38 else {
39 lines.push(line)
40 line = word
41 }
42 }
43 lines.push(line)
44 return lines.map(l => [l, measure(ITALIC, l, size)])
45}
46
47// The biggest size whose wrapped lines fit four rows in the writing area, with no lonely last word.
48export function quoteLayout(text: string): { size: number; lines: [string, number][] } {
49 for (let size = 46; size >= 18; size--) {
50 const lines = wrap(text, size, 470)
51 const widest = Math.max(...lines.map(([, w]) => w))
52 // at most four lines, and never so tall that the last one reaches down to the quill in its corner
53 const tall = size * 1.35 * (lines.length - 1) + size
54 if (lines.length <= 4 && tall <= 190 && !(lines.length > 1 && lines[lines.length - 1][1] < 0.3 * widest)) return { size, lines }
55 }
56 return { size: 18, lines: wrap(text, 18, 430) }
57}
58
59export function quoteCard(text: string, author: string): string {
60 const { size, lines } = quoteLayout(text)
61 const lead = size * 1.35
62 const blockW = Math.max(...lines.map(([, w]) => w))
63 const blockH = lead * (lines.length - 1) + size
64 const authSize = Math.max(14, Math.round(size * 0.55))
65 const top = (VH - (blockH + authSize * 1.8)) / 2 + size * 0.8 // baseline of the first line
66 const left = (VW - blockW) / 2
67
68 // The quill stays in its inkwell and dips once; the words settle in one after another, left to right, and the
69 // author follows. Quick and quiet: nothing crosses the line while it is read.
70 const words = lines.flatMap(([line]) => line.split(' '))
71 const step = Math.min(0.08, 1.1 / words.length)
72 const start = 0.35
73 const fade = 0.45
74 const inked = start + step * (words.length - 1) + fade
75 const total = Math.max(2, inked + 0.6)
76 const dur = `${num(total)}s`
77 const k = (v: number) => num(Math.min(1, v / total))
78
79 let w = 0
80 const rows = lines.map(([line, lw], i) => {
81 const spans = line.split(' ').map((word, j) => {
82 const t0 = start + step * w++
83 const show = `values="0;0;1;1" keyTimes="0;${k(t0)};${k(t0 + fade)};1" dur="${dur}" fill="freeze"`
84 return `<tspan>${j ? ' ' : ''}${esc(word)}<animate attributeName="fill-opacity" ${show}/>` +
85 `<animate attributeName="stroke-opacity" ${show}/></tspan>`
86 })
87 return `<text x="${num((VW - lw) / 2)}" y="${num(top + i * lead)}" xml:space="preserve">${spans.join('')}</text>`
88 })
89 const ay = top + (lines.length - 1) * lead + authSize * 1.9
90 // The author sits under the quote's right edge, moved right if a long name would reach the inkwell corner.
91 const aw = measure(UPRIGHT, author, authSize) + author.length
92 const ax = Math.min(VW - 24, Math.max(left + blockW, 150 + aw))
93 const sign = `<text x="${num(ax)}" y="${num(ay)}" text-anchor="end" font-size="${authSize}" fill="${GREY}" stroke="none" ` +
94 `font-style="normal" letter-spacing="1">${esc(author)}<animate attributeName="opacity" values="0;0;1;1" ` +
95 `keyTimes="0;${k(inked - 0.2)};${k(inked + 0.3)};1" dur="${dur}" fill="freeze"/></text>`
96 // one dip: down into the ink and back, tilting a little, at the start
97 const dip = `<animateTransform attributeName="transform" type="translate" values="0,0;0,7;0,0;0,0" keyTimes="0;${k(0.25)};${k(0.55)};1" dur="${dur}" fill="freeze"/>`
98 const tilt = `<animateTransform attributeName="transform" type="rotate" values="${REST_ANGLE};${REST_ANGLE - 6};${REST_ANGLE};${REST_ANGLE}" keyTimes="0;${k(0.25)};${k(0.55)};1" dur="${dur}" fill="freeze"/>`
99 return `<svg xmlns="http://www.w3.org/2000/svg" data-fit="contain" data-once="${num(total)}" width="${VW}" height="${VH}" viewBox="0 0 ${VW} ${VH}">` +
100 `<g font-family="${FAMILY}" font-style="italic" font-size="${size}" fill="${INK}" stroke="${INK}" stroke-width="0.8" stroke-linejoin="round">${rows.join('')}${sign}</g>` +
101 `<g shape-rendering="crispEdges"><g transform="translate(${WELL_X},${WELL_Y})">${INKWELL}</g>` +
102 `<g transform="translate(${WELL_X},${WELL_Y})"><g>${dip}<g transform="rotate(${REST_ANGLE})">${tilt}${QUILL}</g></g></g></g></svg>`
103}
104
105// A play-once card with its animations taken out: the finished card, as it should stay once it has been written.
106export function stillCard(svg: string): string {
107 return svg.replace(/<(animate\w*|set)\b[^>]*\/>/g, '').replace(/<(animate\w*|set)\b[^>]*>[\s\S]*?<\/\1>/g, '')
108}
109
110// How long a play-once card takes, in milliseconds, or null for a scene that loops.
111export function onceMs(svg: string): number | null {
112 const m = svg.match(/<svg\b[^>]*\sdata-once="([\d.]+)"/)
113 return m ? Number(m[1]) * 1000 : null
114}
115hooks/quotes-list.ts 205 lines1// The quotes set: [id, quote, author]. The id names the scene (quotes/<id>), so an id never changes once shipped:
2// people's own sets point at it. Edit here; art-src/quotes.py reads this same list for the website previews.
3export const QUOTES: [string, string, string][] = [
4 ["napoleon", "Impossible is a word to be found only in the dictionary of fools.", "Napoleon Bonaparte"],
5 ["alexander", "There is nothing impossible to him who will try.", "Alexander the Great"],
6 ["stoic", "What stands in the way becomes the way.", "Marcus Aurelius"],
7 ["seneca", "We suffer more often in imagination than in reality.", "Seneca"],
8 ["river", "No man ever steps in the same river twice.", "Heraclitus"],
9 ["temptation", "I can resist everything except temptation.", "Oscar Wilde"],
10 ["pascal", "If I had more time, I would have written a shorter letter.", "Blaise Pascal"],
11 ["edison", "I have not failed. I've just found 10,000 ways that won't work.", "Thomas Edison"],
12 ["franklin", "Well done is better than well said.", "Benjamin Franklin"],
13 ["ford", "Nothing is particularly hard if you divide it into small jobs.", "Henry Ford"],
14 ["caesar", "I came, I saw, I conquered.", "Julius Caesar"],
15 ["caesar-teacher", "Experience is the teacher of all things.", "Julius Caesar"],
16 ["napoleon-enemy", "Never interrupt your enemy when he is making a mistake.", "Napoleon Bonaparte"],
17 ["alexander-lions", "I am not afraid of an army of lions led by a sheep; I am afraid of an army of sheep led by a lion.", "Alexander the Great"],
18 ["hannibal", "I will either find a way or make one.", "Hannibal"],
19 ["sunzi", "In the midst of chaos, there is also opportunity.", "Sun Tzu"],
20 ["socrates", "The unexamined life is not worth living.", "Socrates"],
21 ["plato", "The beginning is the most important part of the work.", "Plato"],
22 ["aristotle", "Well begun is half done.", "Aristotle"],
23 ["archimedes", "Give me a place to stand, and I will move the earth.", "Archimedes"],
24 ["diogenes", "I am a citizen of the world.", "Diogenes"],
25 ["heraclitus-change", "The only constant in life is change.", "Heraclitus"],
26 ["epictetus", "First say to yourself what you would be; and then do what you have to do.", "Epictetus"],
27 ["marcus-thoughts", "The happiness of your life depends upon the quality of your thoughts.", "Marcus Aurelius"],
28 ["seneca-luck", "Luck is what happens when preparation meets opportunity.", "Seneca"],
29 ["confucius", "It does not matter how slowly you go as long as you do not stop.", "Confucius"],
30 ["laotzu", "A journey of a thousand miles begins with a single step.", "Lao Tzu"],
31 ["rumi", "What you seek is seeking you.", "Rumi"],
32 ["aesop", "No act of kindness, no matter how small, is ever wasted.", "Aesop"],
33 ["davinci", "Learning never exhausts the mind.", "Leonardo da Vinci"],
34 ["galileo", "And yet it moves.", "Galileo Galilei"],
35 ["newton", "If I have seen further it is by standing on the shoulders of giants.", "Isaac Newton"],
36 ["descartes", "I think, therefore I am.", "René Descartes"],
37 ["voltaire", "The perfect is the enemy of the good.", "Voltaire"],
38 ["shakespeare", "We know what we are, but know not what we may be.", "William Shakespeare"],
39 ["brevity", "Brevity is the soul of wit.", "William Shakespeare"],
40 ["goethe", "Knowing is not enough; we must apply.", "Johann Wolfgang von Goethe"],
41 ["franklin-time", "Lost time is never found again.", "Benjamin Franklin"],
42 ["franklin-knowledge", "An investment in knowledge pays the best interest.", "Benjamin Franklin"],
43 ["lincoln", "Whatever you are, be a good one.", "Abraham Lincoln"],
44 ["emerson", "The only way to have a friend is to be one.", "Ralph Waldo Emerson"],
45 ["thoreau", "Go confidently in the direction of your dreams.", "Henry David Thoreau"],
46 ["austen", "There is no charm equal to tenderness of heart.", "Jane Austen"],
47 ["dickens", "No one is useless in this world who lightens the burdens of another.", "Charles Dickens"],
48 ["twain", "The secret of getting ahead is getting started.", "Mark Twain"],
49 ["twain-tomorrow", "Never put off till tomorrow what may be done day after tomorrow just as well.", "Mark Twain"],
50 ["twain-kindness", "Kindness is the language which the deaf can hear and the blind can see.", "Mark Twain"],
51 ["wilde-enemies", "Always forgive your enemies; nothing annoys them so much.", "Oscar Wilde"],
52 ["wilde-experience", "Experience is simply the name we give our mistakes.", "Oscar Wilde"],
53 ["einstein", "Life is like riding a bicycle. To keep your balance you must keep moving.", "Albert Einstein"],
54 ["caesar-die-cast", "The die is cast.", "Julius Caesar"],
55 ["caesar-men-willingly", "Men willingly believe what they wish.", "Julius Caesar"],
56 ["napoleon-leader-dealer", "A leader is a dealer in hope.", "Napoleon Bonaparte"],
57 ["napoleon-victory-belongs", "Victory belongs to the most persevering.", "Napoleon Bonaparte"],
58 ["napoleon-ten-people", "Ten people who speak make more noise than ten thousand who are silent.", "Napoleon Bonaparte"],
59 ["alexander-toil-risk", "Toil and risk are the price of glory.", "Alexander the Great"],
60 ["alexander-upon-conduct", "Upon the conduct of each depends the fate of all.", "Alexander the Great"],
61 ["sunzi-supreme-art", "The supreme art of war is to subdue the enemy without fighting.", "Sun Tzu"],
62 ["sunzi-opportunities-multiply", "Opportunities multiply as they are seized.", "Sun Tzu"],
63 ["sunzi-instance-nation", "There is no instance of a nation benefitting from prolonged warfare.", "Sun Tzu"],
64 ["vegetius-want-peace", "If you want peace, prepare for war.", "Vegetius"],
65 ["lincoln-slow-walker", "I am a slow walker, but I never walk back.", "Abraham Lincoln"],
66 ["roosevelt-speak-softly", "Speak softly and carry a big stick.", "Theodore Roosevelt"],
67 ["roosevelt-where", "Do what you can, with what you have, where you are.", "Theodore Roosevelt"],
68 ["roosevelt-believe-halfway", "Believe you can and you're halfway there.", "Theodore Roosevelt"],
69 ["zapata-would-rather", "I would rather die on my feet than live on my knees.", "Emiliano Zapata"],
70 ["elizabeth-heart-stomach", "I have the heart and stomach of a king.", "Elizabeth I"],
71 ["frederick-defends-everything", "He who defends everything defends nothing.", "Frederick the Great"],
72 ["grant-propose-fight", "I propose to fight it out on this line if it takes all summer.", "Ulysses S. Grant"],
73 ["joan-afraid-born", "I am not afraid; I was born to do this.", "Joan of Arc"],
74 ["delphi-know-thyself", "Know thyself.", "Temple of Apollo at Delphi"],
75 ["socrates-know-know", "I know that I know nothing.", "Socrates"],
76 ["socrates-wonder-beginning", "Wonder is the beginning of wisdom.", "Socrates"],
77 ["socrates-beginning-wisdom", "The beginning of wisdom is the definition of terms.", "Socrates"],
78 ["plato-courage-knowing", "Courage is knowing what not to fear.", "Plato"],
79 ["plato-wise-men", "Wise men speak because they have something to say; fools because they have to say something.", "Plato"],
80 ["aristotle-roots-education", "The roots of education are bitter, but the fruit is sweet.", "Aristotle"],
81 ["aristotle-mark-educated", "It is the mark of an educated mind to entertain a thought without accepting it.", "Aristotle"],
82 ["aristotle-happiness-depends", "Happiness depends upon ourselves.", "Aristotle"],
83 ["heraclitus-character-destiny", "Character is destiny.", "Heraclitus"],
84 ["heraclitus-much-learning", "Much learning does not teach understanding.", "Heraclitus"],
85 ["laotzu-knows-others", "He who knows others is wise; he who knows himself is enlightened.", "Lao Tzu"],
86 ["laotzu-nature-does", "Nature does not hurry, yet everything is accomplished.", "Lao Tzu"],
87 ["laotzu-when-let", "When I let go of what I am, I become what I might be.", "Lao Tzu"],
88 ["confucius-real-knowledge", "Real knowledge is to know the extent of one's ignorance.", "Confucius"],
89 ["confucius-greatest-glory", "Our greatest glory is not in never falling, but in rising every time we fall.", "Confucius"],
90 ["confucius-man-moves", "The man who moves a mountain begins by carrying away small stones.", "Confucius"],
91 ["confucius-everything-has", "Everything has beauty, but not everyone sees it.", "Confucius"],
92 ["epicurus-enjoy-constitutes", "Not what we have but what we enjoy constitutes our abundance.", "Epicurus"],
93 ["epicurus-spoil-desiring", "Do not spoil what you have by desiring what you have not.", "Epicurus"],
94 ["epicurus-greater-difficulty", "The greater the difficulty, the more glory in surmounting it.", "Epicurus"],
95 ["protagoras-man-measure", "Man is the measure of all things.", "Protagoras"],
96 ["pericles-time-wisest", "Time is the wisest counsellor of all.", "Pericles"],
97 ["thucydides-secret-happiness", "The secret of happiness is freedom, and the secret of freedom is courage.", "Thucydides"],
98 ["plutarch-mind-vessel", "The mind is not a vessel to be filled, but a fire to be kindled.", "Plutarch"],
99 ["epictetus-man-free", "No man is free who is not master of himself.", "Epictetus"],
100 ["epictetus-wealth-consists", "Wealth consists not in having great possessions, but in having few wants.", "Epictetus"],
101 ["epictetus-only-educated", "Only the educated are free.", "Epictetus"],
102 ["epictetus-two-ears", "We have two ears and one mouth so that we can listen twice as much as we speak.", "Epictetus"],
103 ["marcus-waste-more", "Waste no more time arguing about what a good man should be. Be one.", "Marcus Aurelius"],
104 ["marcus-very-little", "Very little is needed to make a happy life.", "Marcus Aurelius"],
105 ["marcus-best-revenge", "The best revenge is not to be like your enemy.", "Marcus Aurelius"],
106 ["marcus-loss-nothing", "Loss is nothing else but change, and change is Nature's delight.", "Marcus Aurelius"],
107 ["marcus-right-true", "If it is not right, do not do it; if it is not true, do not say it.", "Marcus Aurelius"],
108 ["seneca-while-wait", "While we wait for life, life passes.", "Seneca"],
109 ["seneca-difficulties-strengthen", "Difficulties strengthen the mind, as labour does the body.", "Seneca"],
110 ["seneca-short-time", "It is not that we have a short time to live, but that we waste a lot of it.", "Seneca"],
111 ["seneca-every-new", "Every new beginning comes from some other beginning's end.", "Seneca"],
112 ["seneca-brave-free", "He who is brave is free.", "Seneca"],
113 ["seneca-begin-once", "Begin at once to live.", "Seneca"],
114 ["pasteur-fortune-favours", "Fortune favours the prepared mind.", "Louis Pasteur"],
115 ["curie-nothing-life", "Nothing in life is to be feared, it is only to be understood.", "Marie Curie"],
116 ["curie-less-curious", "Be less curious about people and more curious about ideas.", "Marie Curie"],
117 ["edison-genius-percent", "Genius is one percent inspiration and ninety-nine percent perspiration.", "Thomas Edison"],
118 ["einstein-imagination-more", "Imagination is more important than knowledge.", "Albert Einstein"],
119 ["einstein-important-thing", "The important thing is not to stop questioning.", "Albert Einstein"],
120 ["newton-nature-pleased", "Nature is pleased with simplicity.", "Isaac Newton"],
121 ["galileo-measure-measurable", "Measure what is measurable, and make measurable what is not so.", "Galileo Galilei"],
122 ["galileo-truths-easy", "All truths are easy to understand once they are discovered.", "Galileo Galilei"],
123 ["davinci-art-never", "Art is never finished, only abandoned.", "Leonardo da Vinci"],
124 ["davinci-noblest-pleasure", "The noblest pleasure is the joy of understanding.", "Leonardo da Vinci"],
125 ["darwin-man-dares", "A man who dares to waste one hour of time has not discovered the value of life.", "Charles Darwin"],
126 ["tesla-present-theirs", "The present is theirs; the future, for which I really worked, is mine.", "Nikola Tesla"],
127 ["archimedes-eureka", "Eureka!", "Archimedes"],
128 ["kant-science-organized", "Science is organized knowledge. Wisdom is organized life.", "Immanuel Kant"],
129 ["virgil-fortune-favours", "Fortune favours the bold.", "Virgil"],
130 ["virgil-love-conquers", "Love conquers all.", "Virgil"],
131 ["horace-seize-day", "Seize the day.", "Horace"],
132 ["pope-err-human", "To err is human, to forgive divine.", "Alexander Pope"],
133 ["pope-hope-springs", "Hope springs eternal in the human breast.", "Alexander Pope"],
134 ["shakespeare-glitters-gold", "All that glitters is not gold.", "William Shakespeare"],
135 ["shakespeare-course-true", "The course of true love never did run smooth.", "William Shakespeare"],
136 ["shakespeare-some-born", "Some are born great, some achieve greatness, and some have greatness thrust upon them.", "William Shakespeare"],
137 ["shakespeare-above-thine", "This above all: to thine own self be true.", "William Shakespeare"],
138 ["dickens-best-times", "It was the best of times, it was the worst of times.", "Charles Dickens"],
139 ["dickinson-hope-thing", "Hope is the thing with feathers.", "Emily Dickinson"],
140 ["dickinson-forever-composed", "Forever is composed of nows.", "Emily Dickinson"],
141 ["frost-took-less", "I took the one less traveled by, and that has made all the difference.", "Robert Frost"],
142 ["tennyson-tis-better", "'Tis better to have loved and lost than never to have loved at all.", "Alfred Tennyson"],
143 ["tennyson-strive-seek", "To strive, to seek, to find, and not to yield.", "Alfred Tennyson"],
144 ["keats-thing-beauty", "A thing of beauty is a joy for ever.", "John Keats"],
145 ["keats-beauty-truth", "Beauty is truth, truth beauty.", "John Keats"],
146 ["bulwer-pen-mightier", "The pen is mightier than the sword.", "Edward Bulwer-Lytton"],
147 ["emily-bronte-whatever-souls", "Whatever our souls are made of, his and mine are the same.", "Emily Brontë"],
148 ["charlotte-bronte-bird-net", "I am no bird; and no net ensnares me.", "Charlotte Brontë"],
149 ["tolstoy-happy-families", "Happy families are all alike; every unhappy family is unhappy in its own way.", "Leo Tolstoy"],
150 ["dostoevsky-beauty-save", "Beauty will save the world.", "Fyodor Dostoevsky"],
151 ["cicero-room-without", "A room without books is like a body without a soul.", "Cicero"],
152 ["cicero-garden-library", "If you have a garden and a library, you have everything you need.", "Cicero"],
153 ["eliot-dare-disturb", "Do I dare disturb the universe?", "T. S. Eliot"],
154 ["wilde-clever-sometimes", "I am so clever that sometimes I don't understand a single word of what I am saying.", "Oscar Wilde"],
155 ["wilde-love-oneself", "To love oneself is the beginning of a lifelong romance.", "Oscar Wilde"],
156 ["wilde-only-thing", "The only thing to do with good advice is to pass it on.", "Oscar Wilde"],
157 ["wilde-work-curse", "Work is the curse of the drinking classes.", "Oscar Wilde"],
158 ["wilde-young-enough", "I am not young enough to know everything.", "Oscar Wilde"],
159 ["wilde-only-two", "There are only two tragedies in life: one is not getting what one wants, and the other is getting it.", "Oscar Wilde"],
160 ["twain-get-facts", "Get your facts first, then you can distort them as you please.", "Mark Twain"],
161 ["twain-report-death", "The report of my death was an exaggeration.", "Mark Twain"],
162 ["twain-clothes-make", "Clothes make the man. Naked people have little or no influence on society.", "Mark Twain"],
163 ["twain-tell-truth", "If you tell the truth, you don't have to remember anything.", "Mark Twain"],
164 ["twain-careful-about", "Be careful about reading health books. You may die of a misprint.", "Mark Twain"],
165 ["voltaire-common-sense", "Common sense is not so common.", "Voltaire"],
166 ["voltaire-secret-being", "The secret of being a bore is to tell everything.", "Voltaire"],
167 ["voltaire-let-read", "Let us read, and let us dance; these two amusements will never do any harm to the world.", "Voltaire"],
168 ["franklin-early-bed", "Early to bed and early to rise makes a man healthy, wealthy, and wise.", "Benjamin Franklin"],
169 ["franklin-fish-visitors", "Fish and visitors stink after three days.", "Benjamin Franklin"],
170 ["franklin-world-nothing", "In this world nothing can be said to be certain, except death and taxes.", "Benjamin Franklin"],
171 ["hubbard-take-life", "Do not take life too seriously. You will never get out of it alive.", "Elbert Hubbard"],
172 ["rogers-diplomacy-art", "Diplomacy is the art of saying \"nice doggie\" until you can find a rock.", "Will Rogers"],
173 ["wilde-never-travel", "I never travel without my diary. One should always have something sensational to read in the train.", "Oscar Wilde"],
174 ["maclaren-kind-everyone", "Be kind, for everyone you meet is fighting a hard battle.", "Ian Maclaren"],
175 ["keller-best-most", "The best and most beautiful things in the world must be felt with the heart.", "Helen Keller"],
176 ["keller-alone-little", "Alone we can do so little; together we can do so much.", "Helen Keller"],
177 ["keller-keep-face", "Keep your face to the sunshine and you cannot see a shadow.", "Helen Keller"],
178 ["pike-ourselves-dies", "What we do for ourselves dies with us. What we do for others remains.", "Albert Pike"],
179 ["seneca-wherever-human", "Wherever there is a human being, there is an opportunity for a kindness.", "Seneca"],
180 ["aristotle-friendship-single", "Friendship is a single soul dwelling in two bodies.", "Aristotle"],
181 ["hubbard-friend-someone", "A friend is someone who knows all about you and still loves you.", "Elbert Hubbard"],
182 ["laotzu-kindness-words", "Kindness in words creates confidence.", "Lao Tzu"],
183 ["proust-let-grateful", "Let us be grateful to the people who make us happy; they are the charming gardeners who make our souls blossom.", "Marcel Proust"],
184 ["cicero-gratitude-only", "Gratitude is not only the greatest of virtues, but the parent of all the others.", "Cicero"],
185 ["shakespeare-love-trust", "Love all, trust a few, do wrong to none.", "William Shakespeare"],
186 ["gandhi-where-love", "Where there is love there is life.", "Mahatma Gandhi"],
187 ["corinthians-love-patient", "Love is patient, love is kind.", "1 Corinthians 13:4"],
188 ["confucius-wherever-heart", "Wherever you go, go with all your heart.", "Confucius"],
189 ["lincoln-give-six", "Give me six hours to chop down a tree and I will spend the first four sharpening the axe.", "Abraham Lincoln"],
190 ["heywood-rome-built", "Rome was not built in a day.", "John Heywood"],
191 ["rousseau-patience-bitter", "Patience is bitter, but its fruit is sweet.", "Jean-Jacques Rousseau"],
192 ["franklin-today-worth", "One today is worth two tomorrows.", "Benjamin Franklin"],
193 ["aristotle-pleasure-job", "Pleasure in the job puts perfection in the work.", "Aristotle"],
194 ["franklin-energy-persistence", "Energy and persistence conquer all things.", "Benjamin Franklin"],
195 ["yeats-wait-strike", "Do not wait to strike till the iron is hot; but make it hot by striking.", "William Butler Yeats"],
196 ["franklin-little-strokes", "Little strokes fell great oaks.", "Benjamin Franklin"],
197 ["aesop-slow-steady", "Slow and steady wins the race.", "Aesop"],
198 ["periander-practice-everything", "Practice is everything.", "Periander"],
199 ["augustus-make-haste", "Make haste slowly.", "Augustus"],
200 ["proverb-measure-twice", "Measure twice, cut once.", "Old proverb"],
201 ["horace-has-begun", "He who has begun is half done; dare to be wise.", "Horace"],
202 ["vangogh-great-things", "Great things are done by a series of small things brought together.", "Vincent van Gogh"],
203 ["vangogh-always-doing", "I am always doing that which I cannot do, in order that I may learn how to do it.", "Vincent van Gogh"],
204]
205hooks/update.tsx 249 lines1// The update card above the prompt, as effortless has it: a new public version is offered, one press updates it (the
2// marketplace, the plugin, then a reload of the plugins), and "Updated" shows with What's new, Share and the repo.
3// Where textless learns of a new version: the newest entry of plugin/public.json, written only by scripts/publish.sh.
4import { logoSvg } from './logo'
5
6// textless lives in the effortless repo (folder textless/), installed as textless@effortless.
7export const REPO_URL = 'https://github.com/HeyCubit/effortless'
8export const SITE_URL = 'https://heycubit.github.io/effortless/textless/'
9export const WHATS_NEW_URL = 'https://heycubit.github.io/effortless/textless/whats-new/'
10// The API, not raw.githubusercontent.com: the raw file sits behind a cache that lagged releases behind.
11export const RELEASES_URL = 'https://api.github.com/repos/HeyCubit/effortless/contents/textless/plugin/public.json?ref=main'
12export const RELEASES_PATH = 'textless/plugin/public.json'
13// How often a chat looks for a new version, how long Later keeps one away, and how long "Updated" stays.
14export const UPDATE_CHECK_MS = 6 * 3600_000
15const UPDATE_SNOOZE_MS = 24 * 3600_000
16export const UPDATED_CARD_MS = 30_000
17export const UPDATE_LOAD_WAIT_MS = 15_000
18// The reload is queued until Claude is idle: pressed mid-reply, it waits for the reply to finish.
19export const UPDATE_LOADING = 'Installed. Loads as soon as Claude is idle.'
20export const NEW_CHAT = 'Installed. Open a new chat to load it.'
21// A plain /reload-plugins holds back changes it thinks need a new chat and keeps the old code running.
22export const RELOAD_ARGS = '--force'
23// The repo is public, so HTTPS needs no key. Without it, git reaches GitHub over SSH and fails for anyone with no SSH
24// key there.
25export const GITHUB_OVER_HTTPS = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'url.https://github.com/HeyCubit/.insteadOf', GIT_CONFIG_VALUE_0: 'git@github.com:HeyCubit/' }
26
27export type UpdateCard = { stage: 'offer' | 'updating' | 'done' | 'failed'; version: string; note: string; at: number; detail?: string }
28export type Release = { version: string; note: string }
29
30/** Whether version `a` is newer than `b`, both `major.minor.patch`. */
31export function isNewer(a: string, b: string): boolean {
32 const pa = a.split('.').map(Number), pb = b.split('.').map(Number)
33 for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0)
34 return false
35}
36/** The newest release in public.json's text (newest first), or nothing when it does not parse. */
37export function latestRelease(text: string): Release | undefined {
38 try {
39 const list = JSON.parse(text) as { version?: unknown; note?: unknown }[]
40 const top = Array.isArray(list) ? list[0] : undefined
41 if (!top || typeof top.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(top.version)) return undefined
42 return { version: top.version, note: typeof top.note === 'string' ? top.note : '' }
43 } catch {
44 return undefined
45 }
46}
47/** Whether Later still keeps `version` away at `now`. */
48export function updateSnoozed(hidden: { version: string; at: number } | null, version: string, now: number): boolean {
49 return !!hidden && hidden.version === version && now - hidden.at < UPDATE_SNOOZE_MS
50}
51/** The line of a failed `claude plugin` run that says what went wrong. */
52export function updateFailure(output: string): string {
53 const lines = output.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
54 const line = lines.find(l => /^(fatal|error):/i.test(l)) ?? lines.find(l => l.startsWith('✘')) ?? lines.at(-1) ?? ''
55 const why = line.replace(/^(fatal|error):\s*/i, '').replace(/^✘\s*/, '')
56 return why.length > 160 ? `${why.slice(0, 159)}…` : why
57}
58
59/** Where `claude` may be: on PATH, then where the official installers put it. */
60export function claudeCandidates(home: string | undefined): string[] {
61 if (!home) return ['claude']
62 const win = /^[A-Za-z]:/.test(home)
63 const sep = home.includes('\\') ? '\\' : '/'
64 return ['claude', [home.replace(/[\\/]+$/, ''), '.local', 'bin', win ? 'claude.exe' : 'claude'].join(sep)]
65}
66const NOT_STARTED = /ENOENT|not found or is in an unsafe location|failed to start/i
67/** For a `claude` that would not start: what to install, in the person's own shell. */
68export function noClaudeHint(error: string, home: string | undefined): string | undefined {
69 if (!NOT_STARTED.test(error)) return undefined
70 const install = home && /^[A-Za-z]:/.test(home) ? 'irm https://claude.ai/install.ps1 | iex' : 'curl -fsSL https://claude.ai/install.sh | bash'
71 return `This needs the Claude Code CLI. Install it with ${install}, then reopen the app.`
72}
73/** The folder of this plugin inside its marketplace's copy: the marketplace's own list says where textless sits. */
74export function pluginDirIn(marketplaceJson: string): string | undefined {
75 try {
76 const m = JSON.parse(marketplaceJson) as { plugins?: { name?: string; source?: unknown }[] }
77 const entry = m.plugins?.find(p => p.name === 'textless')
78 const src = entry?.source
79 const path = typeof src === 'string' ? src : src && typeof src === 'object' ? (src as { path?: unknown }).path : undefined
80 return typeof path === 'string' ? path.replace(/^\.\//, '').replace(/[\\/]+$/, '') : undefined
81 } catch {
82 return undefined
83 }
84}
85/** The command that puts the installed version's files over the running copy, or nothing when they are one folder. */
86export function syncPlan(running: string, installed: string | undefined): string[] | undefined {
87 const norm = (x: string) => x.replace(/[\\/]+$/, '').replaceAll('\\', '/')
88 const cached = (x: string) => /\/cache\//.test(norm(x))
89 if (!installed || norm(installed) === norm(running) || !cached(installed) || !cached(running)) return undefined
90 return /^[A-Za-z]:|\\/.test(running)
91 ? ['robocopy', installed, running, '/E', '/NFL', '/NDL', '/NJH', '/NJS', '/NP']
92 : ['cp', '-R', `${installed}/.`, running]
93}
94// The card's look. textless's orange on the site's warm near black; green once updated, as effortless does.
95const ORANGE = '#d97757'
96const BRAND_BG = '#1a1614'
97const BRAND_EDGE = '#6b3d2c'
98const DONE_ACCENT = '#7fe0a4'
99const UPDATED_BG = '#08090a'
100const UPDATED_EDGE = '#1f4a33'
101const UPDATED_HOVER = '#ffffff1f'
102const CLOSE_HOVER = '#00000040'
103const ART_W = 440
104const ART_H = 64
105const TITLE_MARK = 16
106
107// A small fixed random, so the dither is the same on every draw (a changing source makes the app flicker).
108function rand(seed: number) {
109 let s = seed
110 return () => {
111 s = (s * 1664525 + 1013904223) % 4294967296
112 return s / 4294967296
113 }
114}
115// Pixels in the brand colour, thicker to the right, on a 360x30 field like effortless's bands.
116function dither(color: string, seed: number) {
117 const r = rand(seed)
118 const px: string[] = []
119 for (let x = 150; x < 360; x += 2)
120 for (let y = 0; y < 30; y += 2) {
121 const t = (x - 150) / 210
122 if (r() < t * t * 0.55) px.push(`<rect x="${x}" y="${y}" width="1" height="1" fill="${color}" fill-opacity="${(0.25 + r() * 0.5).toFixed(2)}"/>`)
123 }
124 return px.join('')
125}
126// Clawd, the textless mascot: body, eyes, arms, and legs in two steps so he can walk.
127const CLAWD_BODY = ['..########..', '..#e####e#..', '############', '############', '..########..']
128const LEGS_A = ['..#.#..#.#..', '..#.#..#.#..']
129const LEGS_B = ['...#.#..#.#.', '..#.#..#.#..']
130function rows(lines: string[], x0: number, y0: number, color: string, eye = '#1a1614') {
131 return lines.flatMap((line, y) => [...line].map((c, x) => (c === '#' ? `<rect x="${x0 + x}" y="${y0 + y}" width="1" height="1" fill="${color}"/>` : c === 'e' ? `<rect x="${x0 + x}" y="${y0 + y}" width="1" height="1" fill="${eye}"/>` : ''))).join('')
132}
133const SPARKS = [[214, 6], [252, 21], [281, 9], [306, 24], [236, 15], [270, 3]]
134function sparkles(color: string) {
135 return SPARKS.map(([x, y], i) => `<g class="tw" style="animation-delay:${(i * 0.37).toFixed(2)}s"><rect x="${x}" y="${y - 1}" width="1" height="3" fill="${color}"/><rect x="${x - 1}" y="${y}" width="3" height="1" fill="${color}"/></g>`).join('')
136}
137function art(o: { color: string; light: string; walking: boolean; mirrored?: boolean; clawd?: boolean; seed: number }) {
138 const style = '<style>svg{background:transparent;display:block}.tw{animation:tw 2.6s steps(1) infinite;opacity:0}@keyframes tw{0%,55%{opacity:0}60%,85%{opacity:1}}'
139 + '.la{animation:la .36s steps(1) infinite}.lb{animation:lb .36s steps(1) infinite}@keyframes la{0%{opacity:1}50%{opacity:0}}@keyframes lb{0%{opacity:0}50%{opacity:1}}'
140 + '.bob{animation:bob .36s steps(1) infinite}@keyframes bob{50%{transform:translateY(-1px)}}'
141 + '.dust{animation:dust .9s linear infinite}@keyframes dust{from{transform:translateX(0)}to{transform:translateX(-60px)}}</style>'
142 const fade = `<defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="${o.color}" stop-opacity="0"/><stop offset="1" stop-color="${o.color}" stop-opacity=".22"/></linearGradient></defs><rect width="360" height="30" fill="url(#g)"/>`
143 const clawdX = 334, clawdY = 11
144 const legs = o.walking
145 ? `<g class="la">${rows(LEGS_A, clawdX, clawdY + 5, o.color)}</g><g class="lb">${rows(LEGS_B, clawdX, clawdY + 5, o.color)}</g>`
146 : rows(LEGS_A, clawdX, clawdY + 5, o.color)
147 // While it updates Clawd runs on the spot and pixels stream past behind him.
148 const dust = o.walking ? `<g class="dust">${[0, 60, 120, 180].map(dx => [[300, 20], [312, 13], [289, 25], [318, 23], [296, 8]].map(([x, y]) => `<rect x="${x - dx}" y="${y}" width="2" height="1" fill="${o.light}" fill-opacity=".7"/>`).join('')).join('')}</g>` : ''
149 const clawd = o.clawd === false ? '' : `<g class="${o.walking ? 'bob' : ''}">${rows(CLAWD_BODY, clawdX, clawdY, o.color)}${legs}</g>`
150 const body = `${fade}${dither(o.color, o.seed)}${sparkles(o.light)}${dust}${clawd}`
151 const inner = o.mirrored ? `<g transform="translate(360 0) scale(-1 1)">${body}</g>` : body
152 return `<svg xmlns="http://www.w3.org/2000/svg" width="${ART_W}" height="${ART_H}" viewBox="0 0 360 30" preserveAspectRatio="${o.mirrored ? 'xMinYMid' : 'xMaxYMid'} slice" shape-rendering="crispEdges">${style}${inner}</svg>`
153}
154const OFFER_ART = art({ color: ORANGE, light: '#ffc9b0', walking: false, seed: 7 })
155const UPDATING_ART = art({ color: ORANGE, light: '#ffc9b0', walking: true, seed: 7 })
156// Updated: the art in green behind the title on the left, with no Clawd: the links sit on the right.
157const DONE_ART = art({ color: '#3fae6e', light: '#a8ecc2', walking: false, mirrored: true, clawd: false, seed: 7 })
158const MARK = logoSvg(ORANGE)
159const DONE_MARK = logoSvg(DONE_ACCENT)
160const SHARE_MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16"><path fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M8 10V1.8M4.8 4.8L8 1.6l3.2 3.2M2.5 8.5v4.8c0 .4.3.7.7.7h9.6c.4 0 .7-.3.7-.7V8.5"/></svg>`
161const DIVIDER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="18" viewBox="0 0 1 18"><rect width="1" height="18" fill="#ffffff" fill-opacity=".16"/></svg>`
162const GITHUB_MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#ffffff" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>`
163
164/** The update card: the offer, Clawd running while it updates, green once done. */
165export type CardActions = { update: () => void; hide: () => void; share: () => void; copy: (text: string) => void; id: string }
166export function updateCardTree(els: Record<string, any>, c: UpdateCard, act: CardActions) {
167 const { Box, Text, Svg, Button, Link } = els
168 const landed = c.stage === 'done'
169 const words = {
170 offer: [`textless ${c.version} is out`, c.note || 'A new version is ready.'],
171 updating: ['Updating…', `Getting ${c.version}. Takes a few seconds.`],
172 done: [`Updated to ${c.version}`, c.detail ?? 'Loaded in this chat.'],
173 failed: ['Update failed', c.detail ?? `Try again, or run claude plugin update ${act.id}.`],
174 }[c.stage]
175 const divider = (key: string) => (
176 <Box key={key} flexShrink={0} marginX={1}>
177 <Svg source={DIVIDER_SVG} alt="divider" width={1} height={18} />
178 </Box>
179 )
180 // Each control in a box of its own: under one shared box, hovering one lit the other too.
181 const own = (key: string, el: unknown) => (
182 <Box key={key} position="relative" flexShrink={0}>
183 <Box position="absolute" top={0} left={0} />
184 {el as never}
185 </Box>
186 )
187 const installLine = c.stage === 'failed' ? c.detail?.match(/(irm \S+ \| iex|curl -fsSL \S+ \| bash)/)?.[1] : undefined
188 const controls =
189 c.stage === 'offer'
190 ? [
191 own('tl-update-go-box', <Button key="tl-update-go" variant="primary" label="Update" onPress={act.update} />),
192 own('tl-update-later-box', <Button key="tl-update-later" variant="secondary" label="Later" onPress={act.hide} />),
193 ]
194 : c.stage === 'failed'
195 ? [
196 ...(installLine
197 ? [own('tl-update-copy-box', <Button key="tl-update-copy" variant="secondary" label="Copy install command" onPress={() => act.copy(installLine)} />)]
198 : []),
199 own('tl-update-go-box', <Button key="tl-update-go" variant="primary" label="Try again" onPress={act.update} />),
200 own('tl-update-close-box', <Button key="tl-update-close" plain label="✕" hover={{ backgroundColor: CLOSE_HOVER }} onPress={act.hide} />),
201 ]
202 : landed
203 ? [
204 own('tl-update-link-box', <Text color="#ffffff" bold><Link href={WHATS_NEW_URL} label="What's new →" /></Text>),
205 divider('tl-update-div-1'),
206 own('tl-update-share-box', (
207 // The icon and the word, with a blank button laid over both: its hover veil covers the icon too.
208 <Box key="tl-update-share" position="relative" flexDirection="row" alignItems="center" gap={1} paddingX={1} flexShrink={0}>
209 <Svg source={SHARE_MARK_SVG} alt="Share" width={14} height={14} />
210 <Text>Share</Text>
211 <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
212 <Button key="tl-update-share-go" plain hover={{ backgroundColor: UPDATED_HOVER }} label={' '.repeat(23)} onPress={act.share} />
213 </Box>
214 </Box>
215 )),
216 divider('tl-update-div-2'),
217 own('tl-update-star-box', (
218 <Box key="tl-update-star" flexDirection="row" alignItems="center" gap={1}>
219 <Svg source={GITHUB_MARK_SVG} alt="GitHub" width={14} height={14} />
220 <Link href={REPO_URL} label="Star on GitHub" />
221 </Box>
222 )),
223 own('tl-update-close-box', <Button key="tl-update-close" plain label="✕" hover={{ backgroundColor: UPDATED_HOVER }} onPress={act.hide} />),
224 ]
225 : []
226 return (
227 <Box key="tl-update-card" position="relative" flexDirection="row" alignItems="center" paddingX={1} overflow="hidden"
228 backgroundColor={landed ? UPDATED_BG : BRAND_BG} borderStyle="round" borderColor={landed ? UPDATED_EDGE : BRAND_EDGE}>
229 <Box key="tl-update-art" position="absolute" top={-1} {...(landed ? { left: 0 } : { right: 0 })} bottom={-1}>
230 <Svg source={landed ? DONE_ART : c.stage === 'updating' ? UPDATING_ART : OFFER_ART} alt={c.stage === 'updating' ? 'updating' : 'textless'} width={ART_W * 2} height={ART_H * 2} />
231 </Box>
232 <Box key="tl-update-words" position="relative" flexDirection="column" flexShrink={1} minWidth={0}>
233 <Box key="tl-update-title" flexDirection="row" alignItems="center" flexShrink={1} minWidth={0}>
234 <Box flexShrink={0} marginRight={1} alignItems="center">
235 <Svg source={landed ? DONE_MARK : MARK} alt="textless" width={TITLE_MARK} height={TITLE_MARK} />
236 </Box>
237 <Text color={landed ? DONE_ACCENT : ORANGE} bold wrap="truncate">{words[0]}</Text>
238 </Box>
239 <Text wrap="truncate">{words[1]}</Text>
240 </Box>
241 <Box key="tl-update-controls" position="relative" flexGrow={1} flexDirection="row" justifyContent="flex-end" alignItems="center" gap={1}>
242 {/* Positioned by an empty absolute child, so it paints over the art and takes the clicks. */}
243 <Box position="absolute" top={0} left={0} />
244 {controls}
245 </Box>
246 </Box>
247 )
248}
249hooks/quill-data.ts 6 lines1// Made by art-src/quotes.py from the Georgia fonts; do not edit by hand.
2export const ITALIC: Record<string, number> = {" ": 241, "!": 331, "\"": 412, "'": 215, ",": 270, "-": 374, ".": 270, "0": 614, "1": 430, ":": 384, ";": 384, "?": 479, "A": 671, "B": 654, "C": 642, "D": 749, "E": 653, "F": 599, "G": 725, "H": 815, "I": 390, "K": 694, "L": 604, "M": 927, "N": 767, "O": 730, "P": 610, "R": 702, "S": 561, "T": 619, "U": 756, "V": 667, "W": 976, "Y": 615, "a": 573, "b": 554, "c": 454, "d": 575, "e": 472, "f": 329, "g": 573, "h": 563, "i": 297, "j": 291, "k": 528, "l": 285, "m": 879, "n": 590, "o": 537, "p": 578, "q": 555, "r": 461, "s": 431, "t": 347, "u": 575, "v": 538, "w": 822, "x": 501, "y": 560, "z": 444}
3export const UPRIGHT: Record<string, number> = {" ": 241, "-": 374, ".": 270, "1": 430, "3": 552, "4": 565, ":": 313, "A": 671, "B": 654, "C": 642, "D": 749, "E": 653, "F": 599, "G": 725, "H": 815, "I": 390, "J": 518, "K": 694, "L": 604, "M": 927, "N": 767, "O": 744, "P": 610, "R": 702, "S": 561, "T": 619, "U": 756, "V": 667, "W": 976, "Y": 615, "Z": 602, "a": 504, "b": 560, "c": 454, "d": 574, "e": 483, "f": 325, "g": 509, "h": 582, "i": 293, "j": 292, "k": 536, "l": 286, "m": 881, "n": 591, "o": 539, "p": 571, "q": 560, "r": 410, "s": 432, "t": 345, "u": 575, "v": 497, "w": 737, "x": 505, "y": 492, "z": 444, "é": 483, "ë": 483}
4export const QUILL = "<rect x=\"44\" y=\"-68\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"36\" y=\"-64\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"40\" y=\"-64\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"44\" y=\"-64\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"28\" y=\"-60\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"32\" y=\"-60\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"36\" y=\"-60\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"40\" y=\"-60\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"44\" y=\"-60\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"24\" y=\"-56\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"28\" y=\"-56\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"32\" y=\"-56\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"36\" y=\"-56\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"40\" y=\"-56\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"44\" y=\"-56\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"20\" y=\"-52\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"24\" y=\"-52\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"28\" y=\"-52\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"32\" y=\"-52\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"36\" y=\"-52\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"40\" y=\"-52\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"44\" y=\"-52\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"16\" y=\"-48\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"20\" y=\"-48\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"24\" y=\"-48\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"28\" y=\"-48\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"32\" y=\"-48\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"36\" y=\"-48\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"40\" y=\"-48\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"44\" y=\"-48\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"16\" y=\"-44\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"20\" y=\"-44\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"24\" y=\"-44\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"28\" y=\"-44\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"32\" y=\"-44\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"36\" y=\"-44\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"40\" y=\"-44\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"16\" y=\"-40\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"20\" y=\"-40\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"24\" y=\"-40\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"28\" y=\"-40\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"32\" y=\"-40\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"36\" y=\"-40\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"40\" y=\"-40\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"12\" y=\"-36\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"16\" y=\"-36\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"20\" y=\"-36\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"24\" y=\"-36\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"28\" y=\"-36\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"32\" y=\"-36\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"36\" y=\"-36\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"12\" y=\"-32\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"16\" y=\"-32\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"20\" y=\"-32\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"24\" y=\"-32\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"28\" y=\"-32\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"32\" y=\"-32\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"36\" y=\"-32\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"12\" y=\"-28\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"16\" y=\"-28\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"20\" y=\"-28\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"24\" y=\"-28\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"28\" y=\"-28\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"32\" y=\"-28\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"12\" y=\"-24\" width=\"4\" height=\"4\" fill=\"#d97757\"/><rect x=\"16\" y=\"-24\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"20\" y=\"-24\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"24\" y=\"-24\" width=\"4\" height=\"4\" fill=\"#eba88d\"/><rect x=\"12\" y=\"-20\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"8\" y=\"-16\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"12\" y=\"-16\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"8\" y=\"-12\" width=\"4\" height=\"4\" fill=\"#b0563a\"/><rect x=\"4\" y=\"-8\" width=\"4\" height=\"4\" fill=\"#3d3929\"/><rect x=\"0\" y=\"-4\" width=\"4\" height=\"4\" fill=\"#3d3929\"/><rect x=\"4\" y=\"-4\" width=\"4\" height=\"4\" fill=\"#3d3929\"/><rect x=\"0\" y=\"0\" width=\"4\" height=\"4\" fill=\"#3d3929\"/>"
5export const INKWELL = "<rect x=\"-12\" y=\"0\" width=\"24\" height=\"4\" fill=\"#8a8780\"/><rect x=\"-8\" y=\"-4\" width=\"16\" height=\"4\" fill=\"#8a8780\"/><rect x=\"-16\" y=\"4\" width=\"32\" height=\"16\" fill=\"#4a4540\"/><rect x=\"-12\" y=\"4\" width=\"8\" height=\"12\" fill=\"#6b655c\"/><rect x=\"-20\" y=\"20\" width=\"40\" height=\"4\" fill=\"#4a4540\"/>"
6hooks/logo.ts 4 lines1// The textless mark (site/assets/logo.svg) for the model strip: the pixel Aa with its strike bar. The mask that cuts
2// the gaps around the bar is baked in (rows 35 and 39 left out), since the app's image path may drop masks.
3export const logoSvg = (fill: string) => "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"2 2 60 60\" shape-rendering=\"crispEdges\"><g fill=\"FILL\"><g><rect x=\"19\" y=\"17\" width=\"4\" height=\"1\"/><rect x=\"18\" y=\"18\" width=\"6\" height=\"1\"/><rect x=\"18\" y=\"19\" width=\"6\" height=\"1\"/><rect x=\"18\" y=\"20\" width=\"7\" height=\"1\"/><rect x=\"17\" y=\"21\" width=\"8\" height=\"1\"/><rect x=\"17\" y=\"22\" width=\"8\" height=\"1\"/><rect x=\"16\" y=\"23\" width=\"10\" height=\"1\"/><rect x=\"16\" y=\"24\" width=\"10\" height=\"1\"/><rect x=\"15\" y=\"25\" width=\"12\" height=\"1\"/><rect x=\"15\" y=\"26\" width=\"12\" height=\"1\"/><rect x=\"44\" y=\"26\" width=\"4\" height=\"1\"/><rect x=\"15\" y=\"27\" width=\"5\" height=\"1\"/><rect x=\"22\" y=\"27\" width=\"5\" height=\"1\"/><rect x=\"41\" y=\"27\" width=\"9\" height=\"1\"/><rect x=\"52\" y=\"27\" width=\"5\" height=\"1\"/><rect x=\"14\" y=\"28\" width=\"6\" height=\"1\"/><rect x=\"22\" y=\"28\" width=\"6\" height=\"1\"/><rect x=\"40\" y=\"28\" width=\"17\" height=\"1\"/><rect x=\"14\" y=\"29\" width=\"6\" height=\"1\"/><rect x=\"23\" y=\"29\" width=\"5\" height=\"1\"/><rect x=\"39\" y=\"29\" width=\"18\" height=\"1\"/><rect x=\"13\" y=\"30\" width=\"6\" height=\"1\"/><rect x=\"23\" y=\"30\" width=\"6\" height=\"1\"/><rect x=\"38\" y=\"30\" width=\"19\" height=\"1\"/><rect x=\"13\" y=\"31\" width=\"6\" height=\"1\"/><rect x=\"23\" y=\"31\" width=\"6\" height=\"1\"/><rect x=\"37\" y=\"31\" width=\"9\" height=\"1\"/><rect x=\"48\" y=\"31\" width=\"9\" height=\"1\"/><rect x=\"13\" y=\"32\" width=\"5\" height=\"1\"/><rect x=\"24\" y=\"32\" width=\"5\" height=\"1\"/><rect x=\"37\" y=\"32\" width=\"7\" height=\"1\"/><rect x=\"50\" y=\"32\" width=\"7\" height=\"1\"/><rect x=\"12\" y=\"33\" width=\"6\" height=\"1\"/><rect x=\"24\" y=\"33\" width=\"6\" height=\"1\"/><rect x=\"37\" y=\"33\" width=\"6\" height=\"1\"/><rect x=\"51\" y=\"33\" width=\"6\" height=\"1\"/><rect x=\"12\" y=\"34\" width=\"6\" height=\"1\"/><rect x=\"24\" y=\"34\" width=\"6\" height=\"1\"/><rect x=\"36\" y=\"34\" width=\"6\" height=\"1\"/><rect x=\"52\" y=\"34\" width=\"5\" height=\"1\"/><rect x=\"11\" y=\"36\" width=\"6\" height=\"1\"/><rect x=\"25\" y=\"36\" width=\"6\" height=\"1\"/><rect x=\"36\" y=\"36\" width=\"6\" height=\"1\"/><rect x=\"52\" y=\"36\" width=\"5\" height=\"1\"/><rect x=\"11\" y=\"37\" width=\"20\" height=\"1\"/><rect x=\"36\" y=\"37\" width=\"6\" height=\"1\"/><rect x=\"52\" y=\"37\" width=\"5\" height=\"1\"/><rect x=\"10\" y=\"38\" width=\"22\" height=\"1\"/><rect x=\"36\" y=\"38\" width=\"6\" height=\"1\"/><rect x=\"52\" y=\"38\" width=\"5\" height=\"1\"/><rect x=\"9\" y=\"40\" width=\"24\" height=\"1\"/><rect x=\"37\" y=\"40\" width=\"6\" height=\"1\"/><rect x=\"51\" y=\"40\" width=\"6\" height=\"1\"/><rect x=\"9\" y=\"41\" width=\"24\" height=\"1\"/><rect x=\"37\" y=\"41\" width=\"7\" height=\"1\"/><rect x=\"50\" y=\"41\" width=\"7\" height=\"1\"/><rect x=\"9\" y=\"42\" width=\"6\" height=\"1\"/><rect x=\"27\" y=\"42\" width=\"6\" height=\"1\"/><rect x=\"38\" y=\"42\" width=\"19\" height=\"1\"/><rect x=\"8\" y=\"43\" width=\"6\" height=\"1\"/><rect x=\"28\" y=\"43\" width=\"6\" height=\"1\"/><rect x=\"38\" y=\"43\" width=\"19\" height=\"1\"/><rect x=\"8\" y=\"44\" width=\"6\" height=\"1\"/><rect x=\"28\" y=\"44\" width=\"6\" height=\"1\"/><rect x=\"39\" y=\"44\" width=\"18\" height=\"1\"/><rect x=\"7\" y=\"45\" width=\"6\" height=\"1\"/><rect x=\"29\" y=\"45\" width=\"6\" height=\"1\"/><rect x=\"40\" y=\"45\" width=\"17\" height=\"1\"/><rect x=\"7\" y=\"46\" width=\"6\" height=\"1\"/><rect x=\"29\" y=\"46\" width=\"6\" height=\"1\"/><rect x=\"42\" y=\"46\" width=\"8\" height=\"1\"/><rect x=\"52\" y=\"46\" width=\"5\" height=\"1\"/></g><rect x=\"4\" y=\"36\" width=\"56\" height=\"3\"/></g></svg>".replace('FILL', fill)
4