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…

Controle de esforço para Claude Code e Codex, em um único repositório. Fork MIT de HeyCubit/effortless, baseado na versão upstream 1.71.0. Versão EffortShift: 0.2.0-alpha.1.
Alpha: o plugin nativo Codex recomenda esforço; não o altera. Os clientes próprios Codex aplicam esforço real via App Server. Não há promessa de economia.
| Recurso | Claude Code | Plugin nativo Codex | Clientes próprios Codex |
|---|---|---|---|
| Instalação como plugin | Sim | Sim | Executados com Node |
| Esforço automático aplicado | Runtime original do Effortless | Não; recomendação consultiva | Sim, por turn/start |
| Visual | Painel nativo original | Faixa visual Unicode por tarefa | Painel terminal e web |
| Classificação | Avaliador Haiku/Jev do upstream | Regras locais | Regras locais |
| Modelos/esforços suportados | Runtime Claude | Catálogo indisponível no hook | Catálogo real do App Server |
Requisitos: Node.js 22+ e Codex CLI com plugins/hooks, validado localmente na versão 0.163.0.
codex plugin marketplace add rafaeljorge-ai/effortshift
codex plugin add effortshift@effortshift
codex
Na primeira abertura, revise somente os hooks do EffortShift em /hooks e confie nos que deseja executar. Instalação não concede confiança automaticamente; definições alteradas podem exigir nova revisão. Não use bypass de trust para instalar.
Depois de enviar um prompt, o CLI mostra algo como:
╭─ EffortShift · perfil balanced
│ LOW MEDIUM [HIGH] XHIGH MAX
│ Recomendado: high · não aplicado pelo hook
╰─ Consultivo · catálogo nativo desconhecido
A recomendação não conhece o catálogo nem o esforço atual do modelo. Ajuste no menu nativo de esforço e confirme o valor na interface do Codex. $effortshift abre a ajuda da skill. Não há painel customizado idêntico ao Claude: isso depende de capacidade da interface oficial.
Para instalar a cópia local, troque o primeiro comando por codex plugin marketplace add "$PWD". O catálogo Codex fica em .agents/plugins/marketplace.json; seu pacote autocontido fica em plugins/codex/. Se o marketplace effortshift já existir com outra origem, confira codex plugin marketplace list e remova apenas essa origem antes de registrá-la novamente.
Remover:
codex plugin remove effortshift@effortshift
codex plugin marketplace remove effortshift
Opcionalmente, EFFORTSHIFT_PROFILE=economy codex ou EFFORTSHIFT_PROFILE=quality codex muda o perfil consultivo. O padrão é balanced; riscos críticos reconhecidos mantêm recomendação mínima high. A heurística é lexical, sem garantia de detectar todo risco semântico.
Requer Claude Code com function hooks; validação/testes locais na versão 2.1.292.
claude plugin marketplace add rafaeljorge-ai/effortshift
claude plugin install effortshift@effortshift
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude
Evite carregar Effortless e EffortShift simultaneamente, pois ambos ajustariam esforço. Para testar este fork isolado da raiz, sem editar a configuração global:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude \
--settings '{"enabledPlugins":{"effortless@effortless":false}}' \
--plugin-dir "$PWD"
Use o ID real do original se estiver registrado sob outro marketplace. Abra /effortshift settings ou /effortshift setup para os controles do painel nativo. O fork preserva juiz Haiku/Jev, cache e handoff do upstream; seus custos e permissões continuam sujeitos ao Claude. Se você habilitar o juiz Jev e fornecer uma chave TypeSafe, ele envia o prompt e contexto recente para api.typesafe.ai, um serviço de terceiros. A suíte do plugin simula o runtime, sem comprovar uma sessão interativa autenticada.
git clone https://github.com/rafaeljorge-ai/effortshift.git
cd effortshift
npm start # painel web local
node bin/effortshift.mjs codex # cliente visual de terminal
Os clientes usam Codex App Server oficial via stdio: modelo, esforço por turno, streaming, interrupção e aprovações. São clientes próprios do EffortShift. Não modificam a interface oficial. /auto balanced, /manual high, /models, /status, /stop e /quit são comandos do cliente próprio.
Modo padrão somente leitura. --workspace-write permite escrita explicitamente. Configurações de modelo/esforço valem para os próximos turnos. Conversas dos provedores são separadas. “Aceito pelo servidor” é aceite protocolar, sem inspeção do raciocínio interno.
O servidor escuta em 127.0.0.1; abra a URL autenticada exibida no terminal. O token temporário sai da barra e fica em memória/sessionStorage, enviado no header Bearer. Não compartilhe essa URL. Dados ausentes aparecem como Indisponível, sem estimativas financeiras.
npm run build:codex
npm test
npm run check
npm run check:distribution
npm run test:claude
O build copia o motor compartilhado para o pacote Codex; check detecta divergências. Os testes verificam stdin/stdout dos hooks, pacote isolado, esforço/RPC/concorrência/HTTP e permissões. O runner Claude cria uma árvore temporária exclusiva do plugin e a remove ao terminar. Não são necessários tokens de API nos testes simulados.
Com agent-browser 0.38.1 instalado e seu Chromium disponível, execute npm run test:browser. Esse teste usa RPC explicitamente simulada, sem autenticação/inferência, cobrindo desktop/mobile, controles e interrupção. Smokes autenticados reais são registrados separadamente em QA.
O remoto upstream acompanha HeyCubit/effortless; origin aponta para este fork. O atualizador original que instalaria Effortless sobre o fork foi desativado. O workflow semanal detecta commits novos e abre uma issue de revisão; não instala nem integra alterações automaticamente. Veja sincronização upstream.
O mesmo repositório contém os dois catálogos: .claude-plugin/ para Claude e .agents/plugins/ para Codex. Versões EffortShift seguem SemVer e não representam a versão upstream. Consulte CHANGELOG, limites nativos e LICENSE.
Atribuição e documentação original: HeyCubit/effortless e README upstream preservado. textless/, site e material histórico herdados não integram os novos clientes ou o pacote nativo Codex.
Nos clientes Codex próprios, tarefas reconhecidas como críticas mantêm piso high, inclusive no modo manual; a interface explica quando esse piso altera sua escolha. Regras lexicais podem não reconhecer todo risco.
hooks/register.tsx 3033 lines1import type { Register } from 'claude-code'
2import { SCENES } from './art'
3import { readable, 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
527// A terminal scene plays by being drawn again: each frame is the scene read at the card's age. The redraw runs while a
528// card is being drawn and stops a moment after the last one.
529const readableScenes = new Map<string, boolean>()
530function terminalReadable(scene: { set: string; name: string; svg: string }) {
531 const key = `${scene.set}/${scene.name}`
532 let ok = readableScenes.get(key)
533 if (ok === undefined) {
534 ok = scene.set === 'quotes' || readable(scene.svg)
535 readableScenes.set(key, ok)
536 }
537 return ok
538}
539let termTimer: { cancel: () => void } | null = null
540let termDrawnAt = 0
541function terminalPlay($: any, now: number) {
542 termDrawnAt = now
543 if (termTimer) return
544 termTimer = $.clock.every(250, async () => {
545 if ((await $.clock.now()) - termDrawnAt > 2000) {
546 termTimer?.cancel()
547 termTimer = null
548 return
549 }
550 $.ui.invalidate('ui.render')
551 })
552}
553
554async function drawCard($: any, e: any, chat: Chat, step: string) {
555 const { Box, Text, Svg, Button, Raster } = $.ui.resolve(e) as Record<string, any>
556 await refreshTheme($)
557 // Every redraw starts the image's animation over; starting each one as far in as the card has been showing makes
558 // the restart land where the last drawing was, so it cannot be seen.
559 let scene = sceneList()[sceneIndex(chat)]
560 // A plain terminal reads only scenes built of shapes: one made of embedded pictures is swapped for the next one of its
561 // set that it can read, else any that it can.
562 if (e.surface === 'terminal' && Raster && !terminalReadable(scene)) {
563 const all = sceneList()
564 const at = all.indexOf(scene)
565 const pick = [...all.slice(at + 1), ...all.slice(0, at)]
566 scene = pick.find(c => c.set === scene.set && terminalReadable(c)) ?? pick.find(terminalReadable) ?? scene
567 }
568 const now = await $.clock.now()
569 let src = scene.svg
570 const once = onceMs(src)
571 if (once !== null) {
572 const key = `${scene.set}/${scene.name}`
573 const first = firstShown.get(key) ?? now
574 firstShown.set(key, first)
575 if (now - first >= once) src = stillCard(src)
576 }
577 const art = inPhase(src, now - chat.longAt)
578 // A scene from the marketplace names who drew it, under the card's words. Shipped scenes and the person's own don't.
579 const maker = scene.file ? marketNames[scene.file.replace(/\.svg$/, '')] : undefined
580 // Square art with the words under it. Nothing here changes on a clock, so the drawing is only redrawn when a step
581 // changes and its animation runs on undisturbed.
582 // Each scene keeps its own shape: drawn at its own width and height, no wider than the card allows.
583 const size = art.match(/<svg[^>]*\swidth="(\d+)"[^>]*\sheight="(\d+)"/)
584 const artW = size ? Number(size[1]) : ART_PX
585 const artH = size ? Number(size[2]) : ART_PX
586 const fitW = Math.round((ART_H * artW) / artH)
587 // Fill the box and crop what does not fit, centred, so a pane narrower than fitW keeps the full height. A scene whose
588 // root says data-fit="contain" (a word that must stay whole) is fitted inside instead; it paints its own background
589 // past its viewBox so the box still looks full.
590 // A plain terminal cannot draw an SVG: pixel-art scenes are painted as coloured half blocks, a quote as its words.
591 const terminal = e.surface === 'terminal'
592 let terminalArt: any = null
593 if (terminal) {
594 const columns = Number(e.viewport?.columns) || 80
595 const rows = Number(e.viewport?.rows) || 30
596 const cells = Raster ? sceneCells(src, Math.min(64, columns - 4), Math.max(6, Math.min(20, rows - 12)), Math.max(0, now - chat.longAt) / 1000) : null
597 const quote = scene.set === 'quotes' ? QUOTES.find(q => q[0] === scene.name) : undefined
598 if (cells) {
599 terminalArt = <Raster key="textless-art" columns={cells.columns} rows={cells.rows} cells={cells.cells} />
600 terminalPlay($, now)
601 }
602 else if (quote) {
603 terminalArt = (
604 <Box key="textless-quote" flexDirection="column" alignItems="center" paddingX={2} marginY={1}>
605 <Text color="#d97757" italic wrap="wrap">{quote[1]}</Text>
606 <Text dimColor>{`— ${quote[2]}`}</Text>
607 </Box>
608 )
609 }
610 }
611 const contain = /<svg\b[^>]*\sdata-fit="contain"/.test(art)
612 const fit = contain ? 'xMidYMid meet' : 'xMidYMid slice'
613 const cover = art.replace(/<svg\b([^>]*?)\s*preserveAspectRatio="[^"]*"/, '<svg$1').replace(/<svg\b/, `<svg preserveAspectRatio="${fit}"`)
614 return (
615 <Box key="textless" flexDirection="column" alignItems="center" paddingBottom={1}>
616 {/* 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. */}
617 <Box key="textless-top" flexDirection="row" justifyContent="flex-end" width="100%" height={1}>
618 {Button ? controls($, e) : null}
619 {Button ? sizeButton($, e, 'minimize') : null}
620 </Box>
621 {terminal ? terminalArt : Svg ? <Svg source={cover} alt="Claude working" width={fitW} height={ART_H} /> : null}
622 {/* Answered while a background task runs: Claude still has work under way, so it is not the person's turn yet. */}
623 <Text color="#d97757" bold wrap="truncate">Claude is working</Text>
624 <Text dimColor wrap="truncate">{chat.mainDone ? waitingFor(chat) : step}</Text>
625 {maker ? <Text key="textless-credit" color="#6e6660" wrap="truncate">{`${maker.name} · by ${maker.author}`}</Text> : null}
626 </Box>
627 )
628}
629
630// The turn has answered while background work runs: the card hangs under the answer, not up among the steps.
631function cardUnderAnswer(chat: Chat) {
632 return chat.mainDone && chat.long && chat.answer.trim() !== ''
633}
634
635// What the turn still waits on once its answer is in: the background tasks by name, or the subagents.
636function waitingFor(chat: Chat) {
637 const n = chat.background.length
638 if (n === 0) return 'Waiting for a background task'
639 return `Waiting for ${n} background task${n === 1 ? '' : 's'}: ${chat.background.map(t => t.description).join(', ')}`
640}
641// The same, after the turn: on the newest answer's row, until the last one ends.
642function backgroundLine($: any, e: any, chat: Chat) {
643 const { Box, Text } = $.ui.resolve(e) as Record<string, any>
644 const n = chat.background.length
645 if (n === 0) return null
646 return (
647 <Box key="textless-background" flexShrink={1} minWidth={0} marginLeft={1}>
648 <Text dimColor wrap="truncate">{`${n} background task${n === 1 ? '' : 's'} running: ${chat.background.map(t => t.description).join(', ')}`}</Text>
649 </Box>
650 )
651}
652
653// Minimize (two arrows pointing in) and full size (two arrows pointing out), drawn, in the same spot on both looks.
654// 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
655// shown under the pointer instead.
656function sizeIcon(kind: 'minimize' | 'expand', color: string) {
657 const paths = kind === 'minimize'
658 ? 'M14 2 9.5 6.5M9.5 3v3.5H13M2 14l4.5-4.5M3 9.5h3.5V13'
659 : 'M9.5 6.5 14 2M10 2h4v4M6.5 9.5 2 14M2 10v4h4'
660 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>`
661}
662function sizeButton($: any, e: any, kind: 'minimize' | 'expand') {
663 const { Box, Svg, Button } = $.ui.resolve(e) as Record<string, any>
664 const key = `textless-${kind}`
665 const press = async () => {
666 const chat = await chatOf($)
667 // Full size on a turn with a task list holds for this turn; the choice for every turn stays as it was.
668 if (kind === 'expand' && !mini) chat.fullAnyway = true
669 else if (kind === 'minimize' && chat.listed) chat.fullAnyway = false
670 if (kind === 'expand' && mini) await setMini($, false)
671 else if (kind === 'minimize' && !chat.listed) await setMini($, true)
672 else $.ui.invalidate('ui.render')
673 }
674 if (!Svg || e.surface === 'terminal') return <Button key={key} plain dimColor label={kind === 'minimize' ? ' Minimize ' : ' Expand '} onPress={press} />
675 return (
676 <Box key={`${key}-box`} flexDirection="row" alignItems="center" justifyContent="center" paddingX={1}>
677 <Svg source={sizeIcon(kind, isLight ? '#8a8580' : '#8b8b93')} alt={kind === 'minimize' ? 'Minimize' : 'Full size'} width={14} height={14} />
678 <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center" display="none" hover={{ scope: key, display: 'flex' }}>
679 <Svg source={sizeIcon(kind, isLight ? '#1a1a1a' : '#f2f2f2')} alt={kind === 'minimize' ? 'Minimize' : 'Full size'} width={14} height={14} />
680 </Box>
681 <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
682 <Button key={key} plain label={' '.repeat(3)} hover={{ scope: key, backgroundColor: '#00000000' }} onPress={press} />
683 </Box>
684 </Box>
685 )
686}
687
688async function miniLine($: any, e: any, chat: Chat) {
689 await refreshTheme($)
690 const { Box, Text } = $.ui.resolve(e) as Record<string, any>
691 return (
692 <Box key="textless" flexDirection="row" alignItems="center" gap={1}>
693 <Text color="#d97757" bold>textless</Text>
694 <Box flexShrink={1} minWidth={0}>
695 <Text dimColor wrap="truncate">{chat.mainDone ? waitingFor(chat) : 'minimized'}</Text>
696 </Box>
697 <Box flexGrow={1} />
698 {/* No gap between the buttons, as on the big card: the row's own gap spread them. */}
699 <Box flexDirection="row" alignItems="center">
700 {controls($, e)}
701 {sizeButton($, e, 'expand')}
702 </Box>
703 </Box>
704 )
705}
706
707// Text said between steps is hidden, but the app can keep a row it drew while the text was still arriving (a card on
708// top and the text under it until something redraws). Once the text stops changing, every row is drawn once more.
709let settling: { cancel: () => void } | null = null
710function settleText($: any) {
711 settling?.cancel()
712 settling = $.clock.after(700, () => { settling = null; $.ui.invalidate('ui.render') })
713}
714
715// Switched off while Claude works: the row the card sat in is drawn as the app draws it, with one line under it holding
716// the grey Aa and the gear, so the way back on stays where the card was instead of moving up the chat.
717async function offRow($: any, e: any, chat: Chat, isCardRow: boolean, next: any) {
718 const inner = await next(e)
719 if (!chat.running || !isCardRow) return inner
720 await refreshTheme($)
721 const { Box } = $.ui.resolve(e) as Record<string, any>
722 const drawn = { chat, id: String(await $.session.id().catch(() => '')) }
723 return (
724 <Box key="textless-off-row" flexDirection="column">
725 {inner}
726 <Box flexDirection="row" alignItems="center">
727 <Box flexGrow={1} />
728 {controls($, e, drawn)}
729 </Box>
730 </Box>
731 )
732}
733
734// The card minimized to one line, for every chat; kept in the store.
735let mini = false
736// One line now: minimized, or a turn with a task list (the app's own, drawn under the card).
737const LIST_TOOLS = /^(TodoWrite|TaskCreate|TaskUpdate)$/
738function isMini(chat: Chat) {
739 return mini || (Boolean(chat.listed) && !chat.fullAnyway)
740}
741function noteList($: any, chat: Chat, tool: unknown) {
742 if (!chat.working || chat.listed || !LIST_TOOLS.test(String(tool ?? ''))) return
743 chat.listed = true
744 void Promise.resolve().then(() => $.ui.invalidate('ui.render')).catch(() => undefined)
745}
746async function setMini($: any, on: boolean) {
747 mini = on
748 await $.store.set('mini', on).catch(() => undefined)
749 $.ui.invalidate('ui.render')
750}
751
752// textless's two buttons: "Aa" switches it on or off in this chat, the gear opens the settings. They sit on the
753// minimized card and under the newest answer, never in a row of their own under the prompt.
754// A Button cannot be coloured, so "Aa" is text (Clawd orange, bold and struck through while textless hides the steps,
755// plain grey while off) with a blank button laid over it to take the click.
756// The button's own hover box is clear: drawn over the text it hid it; the text lights up instead.
757function controls($: any, e: any, drawn?: { chat: Chat; id: string }) {
758 const { Box, Text, Button } = $.ui.resolve(e) as Record<string, any>
759 const on = drawn ? drawn.chat.on : true
760 // A plain terminal draws no hover or overlay: the switch and the settings are two labelled buttons.
761 if (e.surface === 'terminal') {
762 return [
763 <Button key="textless-switch" plain dimColor={!on} label={on ? ' Aa ' : ' Aa off '} onPress={() => toggle($, undefined, drawn)} />,
764 <Button key="textless-settings" plain dimColor label=" ⚙ " onPress={() => openWebSettings($)} />,
765 ]
766 }
767 return [
768 <Box key="textless-switch-box" flexDirection="row" alignItems="center" paddingX={1}>
769 <Text color={on ? '#d77757' : undefined} dimColor={!on} bold={on} strikethrough={on} hover={{ scope: 'textless-switch', color: on ? '#f0a080' : hoverText() }}>Aa</Text>
770 <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
771 <Button key="textless-switch" plain label={' '.repeat(3)} hover={{ scope: 'textless-switch', backgroundColor: '#00000000' }} onPress={() => toggle($, undefined, drawn)} />
772 </Box>
773 </Box>,
774 <Button key="textless-settings" plain dimColor label=" ⚙︎ " hover={{ scope: 'textless-settings', backgroundColor: hoverBg() }} onPress={() => openWebSettings($)} />,
775 ]
776}
777
778// Under the newest answer: the answer as drawn, then one row with the buttons at its end ("Aa" says on or off).
779function isNewest(chat: Chat, text: string) {
780 const answer = chat.newest.trim()
781 const block = text.trim()
782 return Boolean(answer && block && answer.endsWith(block))
783}
784async function newestRow($: any, e: any, inner: unknown, left?: unknown) {
785 await setUp($)
786 const { Box } = $.ui.resolve(e) as Record<string, any>
787 await refreshTheme($)
788 const drawn = { chat: await chatOf($), id: String(await $.session.id().catch(() => '')) }
789 const row = (
790 <Box key="textless-newest" flexDirection="row" alignItems="center">
791 {left ?? null}
792 {backgroundLine($, e, drawn.chat)}
793 <Box flexGrow={1} />
794 {controls($, e, drawn)}
795 </Box>
796 )
797 return (
798 <Box key="textless-answer" flexDirection="column" gap={1}>
799 {inner}
800 {row}
801 {await updBelow($, e)}
802 {chessBelow($, e)}
803 </Box>
804 )
805}
806
807async function openSettings($: any) {
808 await loadOwn($)
809 await loadBuiltIn($)
810 await $.ui.open({ id: 'textless-settings', title: 'textless' })
811}
812
813// The settings page on the website. This computer gets a random key the first time; the page saves under it on the
814// market Worker and every chat reads it back from there, so nothing has to be pasted.
815// Parked: textless draws art only and leaves answers alone. The cover card, the questions, links and next step under
816// answers, and the note telling Claude how to word questions all stay off while this is false. The full version lives
817// on the branch backup/answer-cards (tag answer-cards-backup).
818export const ANSWERS = false
819// Questions, links and the next step under a finished answer; /textless summary off leaves only the done line.
820let summaryOn = ANSWERS
821// Cover mode: once Claude is done, the whole chat hides behind one big card saying whose move it is. Off by default.
822let coverOn = false
823let syncKey = ''
824let remoteUpdated = 0
825let watchUntil = 0
826let lastPull = 0
827
828async function keyOf($: any) {
829 if (syncKey) return syncKey
830 const kept = await $.store.get('syncKey').catch(() => null)
831 if (typeof kept === 'string' && /^[0-9a-f]{32}$/.test(kept)) return (syncKey = kept)
832 const bytes = globalThis.crypto?.getRandomValues ? globalThis.crypto.getRandomValues(new Uint8Array(16)) : Uint8Array.from({ length: 16 }, () => Math.floor(Math.random() * 256))
833 syncKey = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
834 await $.store.set('syncKey', syncKey).catch(() => undefined)
835 return syncKey
836}
837
838// The page opens on what the card shows now: the set, and the picked scene's name if one is kept.
839async function openWebSettings($: any) {
840 await loadOwn($)
841 await loadBuiltIn($)
842 const { site } = await marketUrls($)
843 const scene = pick !== null ? sceneList()[pick % sceneList().length]?.name ?? '' : ''
844 const page = new URL('../settings/', site).href
845 const hash = `k=${await keyOf($)}&set=${encodeURIComponent(set)}&scene=${encodeURIComponent(scene)}&summary=${summaryOn ? 1 : 0}&cover=${coverOn ? 1 : 0}`
846 let url = `${page}#${hash}`
847 // The page cannot read this computer, so the plugin writes a copy of it here with the person's own scenes in it
848 // and opens that: their pictures never leave the computer, only the pick is saved (as from the site). Offline, or
849 // with no own scenes, the site itself opens.
850 const mine = allScenes().filter(s => s.set === 'yours')
851 // Written even with no own scenes yet, so the page can show where the folder is.
852 {
853 const res = await $.http.fetch(page).catch(() => null)
854 if (res?.ok && String(res.text).includes('var API')) {
855 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)}` }))
856 const folder = await scenesDir($)
857 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>`
858 const html = String(res.text).replace('<head>', `<head><base href="${page}">`).replace('<script>\nvar API', `${data}\n<script>\nvar API`)
859 const dir = (await scenesDir($)).replace(/[\\/]scenes$/, '')
860 const file = `${dir}${dir.includes('\\') ? '\\' : '/'}settings.html`
861 if (await $.fs.write(file, html).then(() => true, () => false)) url = file
862 }
863 }
864 // Watch closely for a while: the person is on the page and expects a save to show at once.
865 watchUntil = Number(await $.clock.now()) + 20 * 60_000
866 // From the plugin's own path, not from USERPROFILE: the app sets that on a Mac too, and rundll32 does not exist there.
867 const windows = /^[A-Za-z]:|\\/.test(String($.plugin.root))
868 // rundll32 hands the address to the default browser and answers 0; explorer answers 1 even when it opens one.
869 const ways = windows ? [['rundll32', 'url.dll,FileProtocolHandler', url], ['explorer.exe', url]] : [['open', url], ['xdg-open', url]]
870 for (const argv of ways) {
871 const r = await $.process.run(argv, { timeoutMs: 5000 }).then((x: any) => x, (err: any) => ({ exitCode: -1, stderr: String(err) }))
872 dbg($, `settings open ${argv[0]} exit=${r.exitCode} ${String(r.stderr ?? '').slice(0, 120)}`)
873 if (r.exitCode !== -1) return
874 }
875 // No browser would start: the settings in the app still work.
876 $.ui.toast('textless: no browser would open, so the small settings panel is shown. /textless debug logs why.')
877 await openSettings($)
878}
879
880// What the page saved, if it is newer than what this chat last took. Every 5 s while the page was just opened, every
881// minute otherwise, and never before the page was first opened on this computer.
882async function pullSettings($: any) {
883 if (!syncKey) {
884 const kept = await $.store.get('syncKey').catch(() => null)
885 if (typeof kept !== 'string' || !/^[0-9a-f]{32}$/.test(kept)) return
886 syncKey = kept
887 }
888 const now = Number(await $.clock.now())
889 if (lastPull && now - lastPull < (now < watchUntil ? 5000 : 60_000)) return
890 lastPull = now || 1
891 const { api } = await marketUrls($)
892 const res = await $.http.fetch(`${api}/settings/${syncKey}`).catch(() => null)
893 if (!res?.ok) return
894 let got: any = null
895 try { got = JSON.parse(String(res.text)) } catch { return }
896 if (!got || typeof got.set !== 'string' || typeof got.updated !== 'number') return
897 const taken = Number(await $.store.get('remoteUpdated').catch(() => 0)) || 0
898 remoteUpdated = Math.max(remoteUpdated, taken)
899 if (got.updated <= remoteUpdated) return
900 remoteUpdated = got.updated
901 await $.store.set('remoteUpdated', got.updated).catch(() => undefined)
902 await loadOwn($)
903 await loadBuiltIn($)
904 // The questions list under answers, on or off for every chat, when the page sent it.
905 if (ANSWERS && typeof got.summary === 'boolean' && got.summary !== summaryOn) {
906 summaryOn = got.summary
907 await $.store.set('summary', summaryOn).catch(() => undefined)
908 }
909 if (ANSWERS && typeof got.cover === 'boolean' && got.cover !== coverOn) {
910 coverOn = got.cover
911 await $.store.set('cover', coverOn).catch(() => undefined)
912 }
913 if (Array.isArray(got.mixes)) {
914 mixes = keptMixes(got.mixes)
915 await $.store.set('mixes', mixes).catch(() => undefined)
916 }
917 if (!setList().includes(got.set)) return
918 set = got.set
919 const at = typeof got.scene === 'string' ? sceneList().findIndex(s => s.name === got.scene) : -1
920 pick = at >= 0 ? at : null
921 await $.store.set('set', set).catch(() => undefined)
922 await $.store.set('pick', pick).catch(() => undefined)
923 $.ui.invalidate('ui.render')
924}
925
926// A scene picked in the settings, for every chat; null takes one per turn in turn.
927async function setPick($: any, value: number | null) {
928 pick = value
929 await $.store.set('pick', value).catch(() => undefined)
930 $.ui.invalidate('ui.render')
931}
932
933// A set picked in the settings, for every chat; the card then rotates through that set from its first scene.
934async function setSet($: any, name: string) {
935 set = name
936 pick = null
937 await $.store.set('set', name).catch(() => undefined)
938 await $.store.set('pick', null).catch(() => undefined)
939 $.ui.invalidate('ui.render')
940}
941
942// The next button: the scene after the one showing, kept for every chat until pressed again.
943async function nextScene($: any) {
944 const chat = await chatOf($)
945 await loadOwn($)
946 await loadBuiltIn($)
947 const list = sceneList()
948 pick = (sceneIndex(chat) + 1) % list.length
949 await $.store.set('pick', pick).catch(() => undefined)
950 void Promise.resolve().then(() => $.ui.toast(`textless: ${list[pick!].name} (${pick! + 1} of ${list.length})`)).catch(() => undefined)
951 $.ui.invalidate('ui.render')
952}
953
954// On or off for this chat pane only: other panes keep their own switch. Switched during a turn, the card goes or comes
955// at once: off draws the rows and text whole again.
956// The strip passes the chat and id it was drawn for: a press runs later, outside the drawing, and looking the chat up
957// again there could land on another one.
958async function toggle($: any, want?: boolean, drawn?: { chat: Chat; id: string }) {
959 const chat = drawn?.chat ?? (await chatOf($))
960 const id = drawn?.id ?? String(await $.session.id().catch(() => ''))
961 const was = chat.on
962 chat.on = want ?? !chat.on
963 chat.working = chat.running && chat.on
964 if (!chat.working) chat.long = false
965 else if (!was) {
966 // Back on in the middle of a turn: the rows drawn while off stay as they are (the app does not redraw them), so
967 // the card starts over at the next step, under them, instead of going back up to the turn's first step.
968 for (const k of chat.steps) oldTools.add(k)
969 chat.steps = []
970 chat.cardKey = ''
971 chat.long = false
972 } else if (chat.steps.length > 0) goLong($, chat)
973 void Promise.resolve().then(() => $.ui.toast(chat.on ? 'textless on in this chat' : 'textless off in this chat')).catch(() => undefined)
974 await $.store.set(`on:${id}`, chat.on).catch(() => undefined)
975 $.ui.invalidate('ui.render')
976}
977
978async function setUp($: any) {
979 if (registered) return
980 registered = true
981 const kept = await $.store.get('pick').catch(() => null)
982 mini = (await $.store.get('mini').catch(() => null)) === true
983 const seen = await $.store.get('recentScenes').catch(() => null)
984 recentScenes = Array.isArray(seen) ? seen.filter((k: unknown) => typeof k === 'string').slice(-200) : []
985 pick = typeof kept === 'number' ? kept : null
986 const keptSet = await $.store.get('set').catch(() => null)
987 set = typeof keptSet === 'string' && keptSet ? keptSet : CLAWD_SET
988 summaryOn = ANSWERS && (await $.store.get('summary').catch(() => null)) !== false
989 coverOn = ANSWERS && (await $.store.get('cover').catch(() => null)) === true
990 mixes = keptMixes(await $.store.get('mixes').catch(() => null))
991 const keptNames = await $.store.get('marketNames').catch(() => null)
992 marketNames = keptNames && typeof keptNames === 'object' ? keptNames : {}
993 void $.command.register({ name: 'textless', description: 'textless on or off in this chat.' }).catch(() => undefined)
994 // The scene pick and set are shared by every chat pane: one picked in a pane reaches the others through the store,
995 // read here every 2 s. On or off stays per pane.
996 $.clock.every(2000, () => { void syncSettings($).catch(() => undefined); void pullSettings($).catch(() => undefined) })
997}
998
999async function syncSettings($: any) {
1000 const keptPick = await $.store.get('pick').catch(() => null)
1001 const nextPick = typeof keptPick === 'number' ? keptPick : null
1002 const keptSet = await $.store.get('set').catch(() => null)
1003 const nextSet = typeof keptSet === 'string' && keptSet ? keptSet : CLAWD_SET
1004 if (nextPick === pick && nextSet === set) return
1005 const setMoved = nextSet !== set
1006 pick = nextPick
1007 set = nextSet
1008 if (setMoved) await loadBuiltIn($)
1009 $.ui.invalidate('ui.render')
1010}
1011
1012// A hidden row takes no height: an empty Text still drew a line plus the row's gap, so hidden steps stacked up empty
1013// space under the card, scrolled it out of view and left a hole above the answer.
1014function blank($: any, e: any) {
1015 const { Box } = $.ui.resolve(e)
1016 return <Box height={0} overflow="hidden" />
1017}
1018
1019// Background agents can outlive the turn that started them; past this the card goes even if one never reports back.
1020const AGENT_WAIT_MS = 10 * 60 * 1000
1021
1022// A text block is part of the turn's final answer: the app draws a long answer as several blocks.
1023function inAnswer(chat: Chat, text: string) {
1024 const t = text.trim().replace(/\s+/g, ' ').slice(0, 200)
1025 return t.length > 0 && chat.answer.replace(/\s+/g, ' ').includes(t)
1026}
1027
1028// A text block is part of an answer a Stop hook sent back (and the shorter versions of it drawn while it streamed).
1029function isKept(chat: Chat, text: string) {
1030 const t = text.trim().replace(/\s+/g, ' ').slice(0, 200)
1031 return t.length > 0 && chat.kept.some(k => k.includes(t))
1032}
1033
1034// Done cards live in memory, so a reload of the plugin (an update, a restart) would drop them and the answer would
1035// show bare, with its steps and in-between text back. Each chat keeps its last turns in the store and reads them
1036// back the first time it draws after a reload.
1037type SavedTurn = { d: Done; tools: string[]; texts: string[] }
1038const SAVED_TURNS = 30
1039const loadedDone = new Set<string>()
1040
1041async function saveDone($: any, index: number) {
1042 const id = String(await $.session.id().catch(() => ''))
1043 if (!id) return
1044 const tools = [...foldedTools].filter(([, i]) => i === index).map(([k]) => k)
1045 const texts = [...foldedTexts].filter(([, i]) => i === index).map(([k]) => k).filter(t => t.length <= 4000)
1046 const kept = ((await $.store.get(`done:${id}`).catch(() => null)) as SavedTurn[] | null) ?? []
1047 const turn: SavedTurn = { d: { ...done[index], open: false, labels: done[index].labels.slice(0, 40) }, tools, texts }
1048 await $.store.set(`done:${id}`, [...(Array.isArray(kept) ? kept : []), turn].slice(-SAVED_TURNS)).catch(() => undefined)
1049}
1050
1051async function loadDone($: any) {
1052 const id = String(await $.session.id().catch(() => ''))
1053 if (!id || loadedDone.has(id)) return
1054 loadedDone.add(id)
1055 const kept = (await $.store.get(`done:${id}`).catch(() => null)) as SavedTurn[] | null
1056 if (!Array.isArray(kept)) return
1057 for (const t of kept) {
1058 if (!t?.d || (t.d.answer && doneAnswers.has(t.d.answer))) continue
1059 const index = done.length
1060 done.push({ ...t.d, open: false })
1061 if (t.d.answer) doneAnswers.set(t.d.answer, index)
1062 for (const k of t.tools ?? []) if (!foldedTools.has(k)) foldedTools.set(k, index)
1063 for (const k of t.texts ?? []) if (!foldedTexts.has(k)) foldedTexts.set(k, index)
1064 }
1065 if (kept.length) $.ui.invalidate('ui.render')
1066}
1067
1068// Cover mode is on for this chat: textless is on here and the setting is on.
1069// 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.
1070const covering = (chat: Chat) => chat.on && coverOn && !chat.uncovered && (chat.working ? chat.long : !!chat.cover)
1071async function saveCover($: any, chat: Chat) {
1072 const id = String(await $.session.id().catch(() => ''))
1073 if (id) await $.store.set(`cover:${id}`, chat.cover ?? null)
1074}
1075// After a reload the chat's cover is read back once, so the card stays where it was.
1076async function loadCover($: any, chat: Chat) {
1077 if (chat.cover !== undefined) return
1078 const id = String(await $.session.id().catch(() => ''))
1079 const kept = id ? ((await $.store.get(`cover:${id}`).catch(() => null)) as { host?: unknown; word?: unknown } | null) : null
1080 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
1081}
1082// The big card that covers a finished chat: the word in large pixels, and the way back to the chat.
1083function coverCard($: any, e: any, chat: Chat, drawn: { chat: Chat; id: string }) {
1084 const { Box, Text, Button, Svg } = $.ui.resolve(e) as Record<string, any>
1085 const word = chat.cover?.word ?? '▶ YOUR TURN'
1086 const said = word.slice(2).toLowerCase().replace(/^./, c => c.toUpperCase())
1087 // Shown with the chat above it, the card stays at the bottom, so the chat's end stays where the view already is.
1088 const open = !!chat.uncovered
1089 return (
1090 <Box key="textless-cover" flexDirection="column" alignItems="center" justifyContent="center" gap={1} paddingY={open ? 2 : 6}>
1091 {chat.title ? <Text key="textless-cover-title" dimColor wrap="truncate">{chat.title}</Text> : null}
1092 {Svg
1093 ? <Svg source={pixelWord(word, '#d97757')} alt={said} width={pixelWidth(word) * 3} height={PIXEL_H * 3} />
1094 : <Text color="#d97757" bold>{said}</Text>}
1095 {/* What Claude asks, drawn as under answers: an orange ? and the question, an orange letter per option. */}
1096 {(chat.cover?.asks ?? []).length
1097 ? <Box key="textless-cover-asks" flexDirection="column" gap={1} paddingX={2}>
1098 {(chat.cover?.asks ?? []).map((text, i, all) => {
1099 const c = choices(text)
1100 return (
1101 <Box key={`textless-cover-ask-${i}`} flexDirection="row" alignItems="flex-start" gap={1}>
1102 {all.length > 1 ? <Text color="#d97757" bold>{`${i + 1}`}</Text> : null}
1103 {Svg
1104 ? <Svg source={badgeSvg('?', '#d97757')} alt="Question" width={9 * BADGE_PX} height={9 * BADGE_PX} />
1105 : <Text color="#d97757">?</Text>}
1106 <Box flexDirection="column">
1107 <Text bold wrap="wrap">{c.stem || text}</Text>
1108 {chat.cover?.recs?.[i] ? recommendedLine($, e, chat.cover.recs[i]) : null}
1109 {c.options.map((o, j) => (
1110 <Box key={`textless-cover-ask-${i}-${j}`} flexDirection="row" gap={1}>
1111 <Text color="#d97757" bold>{String.fromCharCode(65 + j)}</Text>
1112 <Text wrap="wrap">{o}</Text>
1113 </Box>
1114 ))}
1115 </Box>
1116 </Box>
1117 )
1118 })}
1119 </Box>
1120 : null}
1121 {open ? null : <Text dimColor>The chat is hidden while textless covers it.</Text>}
1122 <Button key="textless-cover-show" plain label={open ? 'Hide the chat' : 'Show the chat'} onPress={() => {
1123 drawn.chat.uncovered = !open
1124 $.ui.invalidate('ui.render')
1125 }} />
1126 </Box>
1127 )
1128}
1129
1130// Planning: a spec, plan or notes being written (a document edited), or Claude leaving plan mode. The person reads
1131// those turns, so folding them away hides what they are asked about.
1132const DOC = /\.(md|mdx|markdown|txt|rst|adoc)$/i
1133export function planningStep(tool: string, input: any): boolean {
1134 if (/^(ExitPlanMode|EnterPlanMode)$/.test(tool)) return true
1135 if (!/^(Write|Edit|MultiEdit|NotebookEdit)$/.test(tool)) return false
1136 return DOC.test(String(input?.file_path ?? input?.path ?? input?.notebook_path ?? ''))
1137}
1138// The turn shows as Claude Code draws it from here on: the card goes, nothing folds, and it is not covered.
1139function stepAside($: any, chat: Chat, id: string) {
1140 if (!chat.working) return
1141 dbg($, `planning detected at ${id.slice(-8)}: textless steps aside for this turn`)
1142 chat.working = false
1143 chat.long = false
1144 chat.cover = null
1145 chat.planKey = id
1146 chat.timer?.cancel()
1147 chat.timer = null
1148 void saveCover($, chat).catch(() => undefined)
1149 void Promise.resolve().then(() => $.ui.invalidate('ui.render'))
1150}
1151// The small label on the step that showed the planning.
1152function planLabel($: any, e: any, inner: unknown) {
1153 const { Box, Text } = $.ui.resolve(e) as Record<string, any>
1154 return (
1155 <Box flexDirection="column">
1156 {inner as never}
1157 <Box key="textless-planning" flexDirection="row" gap={1}>
1158 <Text color="#d97757">✎ Planning detected</Text>
1159 <Text dimColor>textless shows this turn as it is, so you can read the plan.</Text>
1160 </Box>
1161 </Box>
1162 )
1163}
1164
1165async function keepTitle($: any, raw: unknown) {
1166 const title = String(raw ?? '').trim()
1167 if (title) (await chatOf($)).title = title.slice(0, 80)
1168}
1169
1170function endTurn($: any, chat: Chat) {
1171 dbg($, `endTurn working=${chat.working} long=${chat.long} steps=${chat.steps.length}`)
1172 // A turn the card covered stays folded once it is done: only its answer shows.
1173 chat.newestAt = -1
1174 if (chat.working && chat.long && chat.steps.length > 0) {
1175 const index = done.length
1176 chat.newestAt = index
1177 const answer = chat.lastText.trim() ? chat.lastText : ''
1178 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') })
1179 if (answer) doneAnswers.set(answer, index)
1180 for (const id of chat.steps) foldedTools.set(id, index)
1181 // The answer is the turn's last text block (and the shorter versions of it drawn while it streamed), and any block
1182 // found in the final answer, which the app splits into several blocks when long. Only the text said along the way
1183 // folds.
1184 for (const t of chat.turnTexts) if (t.trim() && !chat.lastText.startsWith(t) && !inAnswer(chat, t) && !isKept(chat, t)) foldedTexts.set(t, index)
1185 void saveDone($, index).catch(() => undefined)
1186 }
1187 if (chat.working && !chat.long) {
1188 chat.cover = null
1189 void saveCover($, chat).catch(() => undefined)
1190 } else if (chat.working) {
1191 const only = asks(chat.answer) && questions(chat.answer).every(q => q.asks)
1192 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) }
1193 void saveCover($, chat).catch(() => undefined)
1194 }
1195 chat.turnTexts = new Set<string>()
1196 chat.kept = []
1197 chat.lastText = ''
1198 chat.running = false
1199 chat.working = false
1200 chat.long = falsehooks/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 268 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/** An animation's value at `t` seconds: its values stepped by keyTimes (discrete) or blended between them. Numbers only;
65 * a translate is read as a pair. Null when it has nothing this can read. */
66function animated(attrs: string, t: number): number[] | null {
67 const values = (attr(attrs, 'values') ?? '').split(';').map(v => v.trim().split(/[\s,]+/).map(Number))
68 if (!values.length || values.some(v => v.some(Number.isNaN))) return null
69 const dur = parseFloat(attr(attrs, 'dur') ?? '')
70 if (!(dur > 0)) return values[0]
71 const raw = attr(attrs, 'keyTimes')
72 const times = raw ? raw.split(';').map(Number) : values.map((_, i) => (values.length === 1 ? 0 : i / (values.length - 1)))
73 if (times.length !== values.length) return values[0]
74 const repeat = attr(attrs, 'repeatCount')
75 const done = repeat !== 'indefinite' && t >= dur * (Number(repeat) || 1)
76 const p = done ? 1 : (t % dur) / dur
77 const discrete = attr(attrs, 'calcMode') === 'discrete'
78 let i = 0
79 while (i + 1 < times.length && times[i + 1] <= p) i++
80 if (discrete || i + 1 >= times.length || done) return values[i]
81 const span = times[i + 1] - times[i]
82 const f = span > 0 ? (p - times[i]) / span : 0
83 return values[i].map((v, k) => v + (values[i + 1][k] - v) * f)
84}
85
86/** The scene at `t` seconds as pixels: a grid of the viewBox's units. Groups that start hidden are skipped, groups an
87 * opacity animation starts at 1 are shown; transforms and animations are otherwise ignored. */
88export function sceneGrid(svg: string, t = 0): { w: number; h: number; px: Pixel[] } | null {
89 const root = svg.match(/<svg\b([^>]*)>/)
90 if (!root) return null
91 const vb = attr(root[1], 'viewBox')?.trim().split(/[\s,]+/).map(Number)
92 const w = Math.round(vb?.[2] ?? Number(attr(root[1], 'width')))
93 const h = Math.round(vb?.[3] ?? Number(attr(root[1], 'height')))
94 if (!w || !h || w > 1024 || h > 1024) return null
95 const px: Pixel[] = Array.from({ length: w * h }, () => ({ r: 0, g: 0, b: 0, a: 0 }))
96 const paint = (x0: number, y0: number, bw: number, bh: number, rgb: [number, number, number], alpha: number) => {
97 for (let y = Math.max(0, Math.round(y0)); y < Math.min(h, Math.round(y0 + bh)); y++) {
98 for (let x = Math.max(0, Math.round(x0)); x < Math.min(w, Math.round(x0 + bw)); x++) {
99 const p = px[y * w + x]
100 const out = alpha + p.a * (1 - alpha)
101 if (out <= 0) continue
102 p.r = (rgb[0] * alpha + p.r * p.a * (1 - alpha)) / out
103 p.g = (rgb[1] * alpha + p.g * p.a * (1 - alpha)) / out
104 p.b = (rgb[2] * alpha + p.b * p.a * (1 - alpha)) / out
105 p.a = out
106 }
107 }
108 }
109 // One entry per open <g>: shown or not, its opacity, how far it is moved, and the fill its shapes inherit.
110 type Group = { shown: boolean; opacity: number; dx: number; dy: number; fill?: string; fillOpacity: number }
111 const stack: Group[] = []
112 const hidden = () => stack.some(g => !g.shown)
113 const opacity = () => stack.reduce((o, g) => o * g.opacity, 1)
114 const dx = () => stack.reduce((o, g) => o + g.dx, 0)
115 const dy = () => stack.reduce((o, g) => o + g.dy, 0)
116 const inherited = () => {
117 let fill: string | undefined
118 let fo = 1
119 for (const g of stack) {
120 if (g.fill !== undefined) fill = g.fill
121 fo *= g.fillOpacity
122 }
123 return { fill, fo }
124 }
125 // A shape that holds an animation of its own (its x or y) is painted when it closes, once the animation was read.
126 let open: { name: string; attrs: string; x?: number; y?: number } | null = null
127 const shape = (name: string, attrs: string, over?: { x?: number; y?: number }) => {
128 if (hidden()) return
129 const inh = inherited()
130 const rgb = colour(attr(attrs, 'fill') ?? inh.fill)
131 if (!rgb) return
132 const alpha = Math.min(1, Number(attr(attrs, 'fill-opacity') ?? 1) * inh.fo * Number(attr(attrs, 'opacity') ?? 1) * opacity())
133 if (alpha <= 0) return
134 if (name === 'rect') {
135 const rw = Number(attr(attrs, 'width'))
136 const rh = Number(attr(attrs, 'height'))
137 if (rw > 0 && rh > 0) paint((over?.x ?? Number(attr(attrs, 'x') ?? 0)) + dx(), (over?.y ?? Number(attr(attrs, 'y') ?? 0)) + dy(), rw, rh, rgb, alpha)
138 } else if (name === 'path') {
139 for (const [bx, by, bw, bh] of pathBoxes(attr(attrs, 'd') ?? '')) paint(bx + dx(), by + dy(), bw, bh, rgb, alpha)
140 }
141 }
142 for (const m of svg.matchAll(/<(\/?)([A-Za-z][\w:-]*)([^>]*?)(\/?)>/g)) {
143 const closing = m[1] === '/'
144 const name = m[2]
145 const attrs = m[3]
146 if (name === 'g') {
147 if (closing) stack.pop()
148 else if (m[4] !== '/') {
149 const o = attr(attrs, 'opacity')
150 const tr = attr(attrs, 'transform')?.match(/translate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)?/)
151 stack.push({
152 shown: o === undefined || Number(o) > 0,
153 opacity: o === undefined ? 1 : Number(o) || 0,
154 dx: tr ? Number(tr[1]) : 0,
155 dy: tr ? Number(tr[2] ?? 0) : 0,
156 fill: attr(attrs, 'fill'),
157 fillOpacity: Number(attr(attrs, 'fill-opacity') ?? 1),
158 })
159 }
160 continue
161 }
162 if (closing) {
163 if (open && open.name === name) {
164 shape(open.name, open.attrs, open)
165 open = null
166 }
167 continue
168 }
169 if (name === 'animate' && open && (attr(attrs, 'attributeName') === 'x' || attr(attrs, 'attributeName') === 'y')) {
170 const v = animated(attrs, t)
171 if (v) { if (attr(attrs, 'attributeName') === 'x') open.x = v[0]; else open.y = v[0] }
172 continue
173 }
174 if (name === 'animate' && attr(attrs, 'attributeName') === 'opacity' && stack.length) {
175 // How the group looks at `t`.
176 const v = animated(attrs, t)
177 const top = stack[stack.length - 1]
178 if (v) {
179 top.shown = v[0] > 0
180 top.opacity = Math.max(0, Math.min(1, v[0]))
181 }
182 continue
183 }
184 if (name === 'animateTransform' && attr(attrs, 'type') === 'translate' && stack.length) {
185 const v = animated(attrs, t)
186 const top = stack[stack.length - 1]
187 if (v) { top.dx = v[0]; top.dy = v[1] ?? 0 }
188 continue
189 }
190 if ((name === 'rect' || name === 'path') && m[4] !== '/') open = { name, attrs }
191 else shape(name, attrs)
192 }
193 return px.some(p => p.a > 0) ? { w, h, px } : null
194}
195
196const base64 = (bytes: Uint8Array) => {
197 const abc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
198 let out = ''
199 for (let i = 0; i < bytes.length; i += 3) {
200 const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
201 out += abc[(n >> 18) & 63] + abc[(n >> 12) & 63] + (i + 1 < bytes.length ? abc[(n >> 6) & 63] : '=') + (i + 2 < bytes.length ? abc[n & 63] : '=')
202 }
203 return out
204}
205
206const DEFAULT = 0x01000000
207const rgbInt = (p: Pixel) => (Math.round(p.r) << 16) | (Math.round(p.g) << 8) | Math.round(p.b)
208
209/** The scene as terminal cells: shrunk by whole pixels to fit `maxCols` columns and `maxRows` rows, two pixel rows to a
210 * row of half blocks. Null when the scene has nothing this can read (a quote, a picture). */
211export function sceneCells(svg: string, maxCols: number, maxRows: number, t = 0): { columns: number; rows: number; cells: string } | null {
212 const grid = sceneGrid(svg, t)
213 if (!grid) return null
214 const k = Math.max(1, Math.ceil(grid.w / Math.max(8, maxCols)), Math.ceil(grid.h / (Math.max(4, maxRows) * 2)))
215 const columns = Math.ceil(grid.w / k)
216 const pixelRows = Math.ceil(grid.h / k)
217 const rows = Math.ceil(pixelRows / 2)
218 // Each shrunk pixel takes the colour most of its block is painted in, so a two-pixel eye stays an eye and is not
219 // blended into the body; it counts as painted when at least half the block is.
220 const small: Pixel[] = []
221 for (let y = 0; y < rows * 2; y++) {
222 for (let x = 0; x < columns; x++) {
223 const votes = new Map<number, { n: number; p: Pixel }>()
224 let a = 0
225 let n = 0
226 for (let dy = 0; dy < k; dy++) {
227 for (let dx = 0; dx < k; dx++) {
228 const sx = x * k + dx
229 const sy = y * k + dy
230 if (sx >= grid.w || sy >= grid.h) continue
231 const p = grid.px[sy * grid.w + sx]
232 n++
233 a += p.a
234 if (p.a < 0.5) continue
235 const key = rgbInt(p)
236 const v = votes.get(key)
237 if (v) v.n++
238 else votes.set(key, { n: 1, p })
239 }
240 }
241 let best: { n: number; p: Pixel } | null = null
242 for (const v of votes.values()) if (!best || v.n > best.n) best = v
243 small.push(best ? { r: best.p.r, g: best.p.g, b: best.p.b, a: a / Math.max(1, n) } : { r: 0, g: 0, b: 0, a: 0 })
244 }
245 }
246 const words = new Uint32Array(columns * rows * 3)
247 const solid = (p: Pixel) => p.a >= 0.5
248 for (let row = 0; row < rows; row++) {
249 for (let col = 0; col < columns; col++) {
250 const top = small[row * 2 * columns + col]
251 const bottom = small[(row * 2 + 1) * columns + col]
252 const i = (row * columns + col) * 3
253 if (solid(top) && solid(bottom)) { words[i] = 0x2580; words[i + 1] = rgbInt(top); words[i + 2] = rgbInt(bottom) }
254 else if (solid(top)) { words[i] = 0x2580; words[i + 1] = rgbInt(top); words[i + 2] = DEFAULT }
255 else if (solid(bottom)) { words[i] = 0x2584; words[i + 1] = rgbInt(bottom); words[i + 2] = DEFAULT }
256 else { words[i] = 0x20; words[i + 1] = DEFAULT; words[i + 2] = DEFAULT }
257 }
258 }
259 return { columns, rows, cells: base64(new Uint8Array(words.buffer)) }
260}
261
262/** Whether the terminal can show this scene: it is built of shapes it reads, not of embedded pictures, and something is
263 * painted in it at some moment. */
264export function readable(svg: string): boolean {
265 if (/<image\b/.test(svg)) return false
266 return [0.3, 2.1, 4.7, 7.9].some(t => sceneGrid(svg, t) !== null)
267}
268hooks/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