SLOPSHOPPER

textless

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…

panebandrowscommandtoastprompt
v0.15.16MITupdated 2026-10-16rafaeljorge-ai/effortshift/textless/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · textless
│ ┃ textless-settings ✕ › fix the failing auth test and add an ╭───────────────────────────╮ │ ┃ │ textless │ │ ┃ [ Settings ] [ Community ] ⏺ Read(src/auth.ts) │ textless off in this chat │ │ ┃ ⎿ Read 6 lines ╰───────────────────────────╯ │ ┃ Hide the text while Claude works [ Off ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Set ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Clawd ] [ Quotes ] │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Scene │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ● A different one each turn │ ┃ ○ scene 1 › /textless │ ┃ ○ scene 2 ⎿ textless: textless off in this chat. │ ┃ ○ scene 3 │ ┃ ○ scene 4 │ ┃ ○ scene 5 │ ┃ │ ┃ Your own scenes │ ┃ │ ┃ Drop GIF, PNG, WebP, JPG or SVG files │ ┃ (under about 95 KB) in this folder and │ ┃ they join the list. │ ┃ │ ┃ /Users/dev/.claude/textless/scenes ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · textless-settings
[ Settings ] [ Community ] Hide the text while Claude works [ Off ] Set [ Clawd ] [ Quotes ] Scene ● A different one each turn ○ scene 1 ○ scene 2 ○ scene 3 ○ scene 4 ○ scene 5 Your own scenes Drop GIF, PNG, WebP, JPG or SVG files (under about 95 KB) in this folder and they join the list. /Users/dev/.claude/textless/scenes [ Open folder ] [ Copy path ]
README

EffortShift

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.

RecursoClaude CodePlugin nativo CodexClientes próprios Codex
Instalação como pluginSimSimExecutados com Node
Esforço automático aplicadoRuntime original do EffortlessNão; recomendação consultivaSim, por turn/start
VisualPainel nativo originalFaixa visual Unicode por tarefaPainel terminal e web
ClassificaçãoAvaliador Haiku/Jev do upstreamRegras locaisRegras locais
Modelos/esforços suportadosRuntime ClaudeCatálogo indisponível no hookCatálogo real do App Server

Instalar no Codex

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.

Instalar no Claude Code

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.

Clientes visuais Codex com esforço aplicado

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.

Desenvolvimento e verificação

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.

Atualizações e distribuição

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.

Source 11 files
hooks/register.tsx 3033 lines
1import 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 = false
hooks/art.ts 227 lines
1// 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 }
227
hooks/terminal-art.ts 268 lines
1// 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}
268
hooks/chess-engine.ts 155 lines
1// 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}
155
hooks/chess-rules.ts 217 lines
1// 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}
217
hooks/market-rules.ts 137 lines
1// @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}
137
hooks/quill.ts 115 lines
1// 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, '&amp;').replace(/</g, '&lt;')
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}
115
hooks/quotes-list.ts 205 lines
1// 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]
205
hooks/update.tsx 249 lines
1// 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}
249
hooks/quill-data.ts 6 lines
1// 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\"/>"
6
hooks/logo.ts 4 lines
1// 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