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…

newpanebandrowscommandtoast
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

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in mods/

Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.

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