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

Draws

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

<h3 align="center">Two mods for Claude Code, by HeyCubit</h3>

<table> <tr> <td width="50%" align="center" valign="top"> <a href="#effortless"><img src="docs/readme/banner.png" alt="effortless" width="100%"></a> </td> <td width="50%" align="center" valign="top"> <a href="#textless"><img src="textless/brand/readme-banner.png" alt="textless" width="100%"></a> </td> </tr> </table>

effortless

<a href="#install"><b>Install</b></a> · <a href="#what-the-bar-shows"><b>What it shows</b></a> · <a href="#pick-your-judge"><b>Judges</b></a>

https://github.com/user-attachments/assets/9b5af48e-2ad8-4826-9443-4aeea1306664

A Claude Code mod that picks the reasoning effort for every prompt. Quick questions get Low, hard jobs get High, and you never touch the Effort control. On real coding tasks it cut the cost of Opus by 21% against Anthropic's recommended medium, 29% against high and 42% against xhigh, with every answer still right (the benchmark). One bar above the prompt also shows how full the chat is, how long the prompt cache stays warm, and hands off or compacts in one click when a chat gets heavy.

<img src="docs/readme/story.gif" alt="effortless in a Claude Code chat: effort per prompt, Handoff, cold cache, heavy chat, limits and settings" width="100%">

Powered by Haiku 5.5

Haiku 5.5 costs about 75% less than Haiku 4.5, and Anthropic names compaction and quick, well-defined work among what it is built for. effortless uses it in three places:

The judgereads each prompt and picks the effort, in about a second, on your own Claude login
Cheaper model when it canexperimental, off by default. When on, a prompt the judge calls simple may run on Haiku 5.5 (or Sonnet), never above your chat's model, but only when the switch pays: each model keeps its own prompt cache, so in a chat that is in use the switch usually costs more than it saves (why)
Compactionevery compaction, /compact and the automatic one included, is summarized by Haiku 5.5. If Haiku fails, Claude Code compacts as usual

The cheaper model is switched on in Settings → Model; compaction is switched in Settings → Handoff → Compact with.

Install

Two ways. Both take a minute and need no terminal.

1. Ask Claude (easiest). Paste this into Claude Code and press Enter:

Install the effortless plugin for me: run `claude plugin marketplace add HeyCubit/effortless` and then `claude plugin install effortless@effortless`. When both succeed, tell me to run /reload-plugins.

2. Commands. Type these two lines in Claude Code's chat box:

/plugin marketplace add HeyCubit/effortless
/plugin install effortless@effortless

Or in a terminal, in one line:

claude plugin marketplace add HeyCubit/effortless; claude plugin install effortless@effortless

Then run /reload-plugins (or restart Claude Code). A short setup opens above the prompt: keep Haiku alone (one click, no key, runs on your own Claude login) or add Jev (a TypeSafe key, about 4x faster effort calls), lean cheaper or smarter, and pick how handoffs are written. Run /effortless setup to go through it again, or change any of it in ⚙.

Updates come to you: when a new version is out, a card above the prompt offers Update or Later. Update loads the new version in the chat you pressed it in, with nothing to type.

What the bar shows

On the barMeans
High · Opusthe effort Auto picked for this prompt, and the model the chat runs on. Deciding while the judge thinks; a switch flashes violet and fades to white
◔ 38%how full the chat's context is
cache 59:00time until the prompt cache goes cold, after which the next message pays full price to re-read the chat
Haiku: a refactor…who judged and why
Autoswitches the judge on and off. Changing effort in the app yourself also turns Auto off at your next message, and says so: you always win
Compactcompacts the chat. It turns into a glowing Handoff when Haiku says a fresh chat would pay off, with Compact as quiet text beside it. Set Settings → Handoff → Handoff button to Always to keep Handoff on the bar
⚙settings

Prefer it quiet? Settings → Customize has a Minimal look with no bar. It also sets the Appearance (match Claude Code, dark or light) and the Theme (violet, Claude orange or cherry blossom).

When a chat gets heavy

When the cache has gone cold on a big chat, the bar turns to ice with Compact and Handoff:

Handoff writes a summary of the chat and carries on in a clean one. Quick takes a few seconds; Full checks git and saves HANDOFF.md (or runs your own skill). Then clear and carry on, clear and wait, or keep the chat and copy.

Compact takes an optional note for what the summary should keep. Every compaction, also /compact and the automatic one, is written by Haiku 5.5 by default, which Anthropic recommends for compaction at a fraction of the chat model's price; if Haiku fails, Claude Code compacts as usual. Switch it in Settings → Handoff → Compact with.

The bar also warns when a chat is getting swamped (each message re-reads a lot of context), when a 5-hour or weekly limit passes 80% (with a Save mode that caps effort at Medium), and when your judge stops answering.

Settings

Haiku, and Jev if you add it

Haiku 5.5 runs on your own Claude login and needs no key. It always makes the handoff call: from 30% of context, every second message, it reads what the chat was for, the trail of topics, the last reply and how full the context is, and says a fresh chat would suit only for a clear reason. Then the Compact button turns into a lit Handoff with the reason. Want Handoff on the bar all the time? Settings → Handoff → Handoff button → Always.

Haiku also picks the effort, unless you add Jev, TypeSafe's faster judge (about 0.25 s against about 1 s). With a key, Jev answers the effort first and Haiku steps in whenever Jev is unsure. Run /plugin configure effortless@effortless in Claude Code, or use the setup guide or the Judge card in Settings.

SettingWhat it does
auto (default)Haiku, and Jev too when a TypeSafe key is set in the settings or TYPESAFE_API_KEY
haikuHaiku only, a key is never used
jevHaiku and Jev, and the key may also come from ~/.config/jev/.env

A key set with /plugin configure is kept in Claude Code's secure storage. A key pasted in the setup guide or the Judge card is written in plain text to ~/.config/jev/.env (the file the jev skills read), never to a file of this repo. If Jev fails or takes longer than 3 seconds, Haiku judges that prompt and effortless tells you why once per session (out of credits, key rejected, no answer).

Short follow-ups such as "go", "ok" or "yes" keep the effort already picked and ask no judge.

Keys and trust

  • The default judge needs no key: Haiku runs on your own Claude login.
  • A key is only needed to add Jev. Enter it with /plugin configure, never on the command line, so it stays out of your shell history: Claude Code then keeps it in its secure storage. A key pasted in the setup guide or the Judge card is saved in plain text to ~/.config/jev/.env instead, so prefer /plugin configure on a shared machine.
  • A key is sent only to TypeSafe, and to nothing else. Use a key with a spending limit if your provider offers one.
  • Like any Claude Code plugin, this mod runs code on your machine. Its code is in hooks/: read it before you install if you do not know the author. The programs it starts are claude itself (to update or uninstall the mod when you press those buttons), git (to see if a new version is out) and, on Update, a plain file copy of the new version into the folder your open chat runs from.

What it saves, measured

Four real coding tasks in a fresh copy of this repo, each run 3 times with effortless on and with it off, through Anthropic's API so the cost is real. Every answer was checked. Run 2026-10-10, 96 runs:

Opus 5.5CorrectCost per taskRequestsTime
medium (recommended), no effortless12 of 12$0.1907.324 s
high, no effortless12 of 12$0.2098.027 s
xhigh, no effortless12 of 12$0.2579.944 s
effortless on12 of 12$0.1495.820 s

That is 21% less than medium, 29% less than high and 42% less than xhigh. On Sonnet it came out even against medium (Sonnet at medium is already lean) and saved 10% against high and 35% against xhigh.

The saving comes from fewer steps: every request rereads the whole chat, and at a lower effort the model makes fewer of them. Over 80k requests of real Claude Code use, about 76% of the cost was that reread, 16% cache writes and 8% output. Keeping chats short and compacting before the cache goes cold saves on the same 76%; that is what the countdown is for.

Limits: four tasks, three runs each, mostly easy work where effortless goes low. On hard work it picks more effort and saves less. Fresh chats through the API, not long chats on a plan. Run it again with bash bench/agentic/run.sh; all numbers and limits are in bench/RESULTS.md.

Why model switching is off

Moving a simple prompt to Haiku looks cheap on a single prompt: the cheaper answers were good enough in 27 of 30 and cost 60 to 77% less. But each model keeps its own prompt cache, so a prompt moved mid-chat has to write the whole chat into the other model's cache first. Measured on an 80k-token chat, that costs about 5 times what staying on a warm Opus does (10 times for Sonnet). So Cheaper model when it can is off by default and marked experimental. Details.

How often the judge is right

/effortless bench runs labelled prompts through each judge you have set up, using the same code a real prompt goes through, and scores them against a fixed effort. Each case lists the efforts a careful person would accept for that message on that model. The cases are in bench/judge-cases.json; run it yourself.

Two runs on version 1.5.1, 73 cases, Sonnet and Opus 5.5:

JudgeRight, all 73Right, 20 held outToo lowToo highMedian time
Always medium44%40%1724-
Always high51%50%036-
Haiku92-93%95%14-50.75 s
Jev99%100%010.24 s

What this does and does not show:

  • It measures whether the judge picks a sensible effort, not how much a session costs or how good the answers are.
  • 73 cases is a small set, and the judges are not fully deterministic: runs differ by a few points.
  • The judge prompts were tuned on 53 of the cases. The 20 held-out cases were never tuned against, so that column is the honest one. They were written after the first run showed which kinds of message miss, so they are not blind.
  • Haiku's remaining misses are mostly "yes" or "thanks" after hard work, where it keeps the effort high.

Models

Effort only changes on Opus 5.5 and Sonnet 5.5. On Fable 5.1 and older models a change of effort between requests rewrote most of the prompt cache, which costs more than it saves, so Auto pauses there and the footer says Paused. Haiku takes no effort setting.

Split view

In the desktop app's split view, Claude Code draws plugin bars only in the left pane. The right pane still draws replies, so when a chat went cold, is getting swamped or runs hot, a small card in the bar's colours hangs under the newest reply, naming the command that does what the bar's button would: /compact, /effortless handoff or /effortless save.

In the terminal

effortless draws in the terminal CLI too, with moving pixel art in its bands. Claude Code may not load a plugin's code there yet: if /effortless says the mod is not loaded after a restart, add this to ~/.claude/settings.json and restart:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

Tested on Claude Code 2.1.285 (stable), 2.1.286 and 2.1.288 at 80 and 120 columns. Below 90 columns the bands leave their art out.

Commands

CommandDoes
/effortless settingsopens the settings panel
/effortless setupruns the setup again
/effortless autoAuto on or off
/effortless handoff / handoff fullhands off without the bar, with your last choice of what follows
/effortless statswhat the prompts Auto steered cost this session, per effort, and what the judge took
/effortless cold, swamp, hot, downshows that bar now, to try it
/effortless benchscores each judge you have on the labelled prompts in bench/judge-cases.json
/effortless debugwhat the mod sees, and on its last line the model and effort the last request ran on
/effortless updatechecks for a new version now

textless

While Claude works, Claude Code shows every step it takes. textless puts one picture there instead, and when Claude is done you get the answer, with a pixel word that says whether it is done, needs you or stopped.

  • Pick what shows: a set (Clawd, Scenery, Paintings, Abstract, Working), one favourite scene, or your own art in ~/.claude/textless/scenes.
  • The Marketplace: scenes other people made, checked before they go live. One command puts one on: /textless get <id>.
  • One switch per chat: the ⏻ next to the model name, or /textless.

Website, scenes and Marketplace: heycubit.github.io/effortless/textless. The code is in textless/.

Open source

effortless is MIT licensed. Like any Claude Code plugin it runs code on your machine, so the whole mod is here to read: hooks/. It sends nothing anywhere except your prompt to the judge you picked.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

tools/render-band draws the bar the way the desktop app does, without opening it; the images in this README come from it.

License

MIT

Source 11 files
hooks/register.tsx 2978 lines
1import type { Register } from 'claude-code'
2import { SCENES } from './art'
3import { sceneCells } from './terminal-art'
4import { bestMoveAsync, CHESS_LEVELS, type ChessLevel } from './chess-engine'
5import { describe as describeMove, inCheck, legalMoves, type Move, outcome, type Outcome, parseFen, picture, play, type Position, START, squareName, toFen, uci } from './chess-rules'
6import { checkFile } from './market-rules'
7import { onceMs, quoteCard, stillCard } from './quill'
8import { QUOTES } from './quotes-list'
9import { GITHUB_OVER_HTTPS, isNewer, latestRelease, NEW_CHAT, noClaudeHint, pluginDirIn, RELEASES_PATH, RELEASES_URL, RELOAD_ARGS, type Release, SITE_URL, syncPlan, UPDATE_CHECK_MS, UPDATE_LOAD_WAIT_MS, UPDATE_LOADING, UPDATED_CARD_MS, type UpdateCard, updateCardTree, updateFailure, updateSnoozed, claudeCandidates } from './update'
10
11// textless: once a turn shows it is more than a quick answer (its first tool call, or 5 s passed), the
12// turn's first step is drawn as a tall card with art and its later steps and text as nothing. When the turn ends
13// every row is drawn whole again. The desktop gives the spinner a one-line slot, so the card lives in a tool row.
14const LONG_AFTER_MS = 3000
15const BASELINE_MS = 1500
16const ART_PX = 440
17// The card always stands this tall, whatever the pane's width: a narrow pane crops the scene's sides (most fade out
18// there) instead of shrinking it. Taller and it would scroll inside its own row.
19const ART_H = 420
20
21// One module serves every open chat, so everything about a running turn is kept per chat, keyed by its session id.
22type Chat = {
23  // textless on or off in this chat pane only; kept per chat in the store, on unless switched off there.
24  on: boolean
25  // A turn is running, whether or not the card is on; `working` is that and the card being on.
26  running: boolean
27  working: boolean
28  long: boolean
29  // The prompt that started this turn, and whether the card hangs under it: only when no earlier message reads the
30  // same, since a row is known by its text alone. Otherwise the card waits for the turn's first step.
31  prompt: string
32  underPrompt: boolean
33  // This turn's own text blocks, and the answer and length the turn ended with: what the done line keeps folded.
34  turnTexts: Set<string>
35  lastText: string
36  // Answers a Stop hook sent back for more work: the turn goes on, but each was an answer the person has to see.
37  kept: string[]
38  // Until then, a row drawn that is not running is taken as already in the chat: right after a reload the mod has
39  // seen none of the chat's rows, and the redraw that shows the card would otherwise take them all as this turn's.
40  baselineUntil: number
41  answer: string
42  durationMs: number
43  // Subagents still working for this chat: a turn whose agents run in the background ends before they do.
44  agents: Set<string>
45  // The main turn ended while agents still ran; the card goes when the last one is done.
46  mainDone: boolean
47  // The turn ended without an answer: stopped by the person, an API error or a refusal.
48  stopped: boolean
49  // When the card first showed this turn, the zero of its animation.
50  longAt: number
51  turns: number
52  // Messages the person sent in this chat, and the one that last carried the note on questions.
53  prompts: number
54  hintedAt: number | null
55  // Cover mode: the text block the big card takes the place of, and its word. undefined: not read from the store yet.
56  cover?: { host: string; word: string; asks?: string[]; recs?: string[] } | null
57  // Show the chat was pressed: this chat stays uncovered, with textless on, until Claude starts on the next message.
58  uncovered?: boolean
59  // The chat's title as the app names it, for the cover card: which chat is this.
60  title?: string
61  // A planning turn (a document written, plan mode left): textless steps aside for it. The step that showed it carries
62  // the small label.
63  planKey?: string
64  startedAt: number
65  // Steps that ran during this turn, oldest first; only these can carry the card. Rows of earlier turns are redrawn
66  // by the same invalidate, and they never ran in this turn, so they draw as usual.
67  steps: string[]
68  // The step whose row holds the card: the turn's first, then the newest whenever the card scrolls out of view.
69  cardKey: string
70  timer: { cancel: () => void } | null
71  ticker: { cancel: () => void } | null
72  // The last finished turn's answer: textless's buttons sit under it, on or off. Kept per chat in the store.
73  newest: string
74  // The folded turn that ended last (its index in `done`), or -1 when the last turn was a quick one: the buttons sit on
75  // its row even when it has no answer to hang them under (a stopped turn).
76  newestAt: number
77  // The scene this turn shows (an index into sceneList), chosen when the turn starts; -1 before that.
78  sceneAt: number
79  // Background work still running when the turn's answer came (from the Stop hook), by id: the newest answer's row
80  // names it until each task's notification says it ended.
81  background: { id: string; description: string }[]
82  // Claude keeps a task list this turn: the app draws it under the card, so the card goes to one line unless the
83  // person asked for full size this turn.
84  listed?: boolean
85  fullAnyway?: boolean
86}
87const chats = new Map<string, Chat>()
88// Off for every chat when the person turns it off with the icon or /textless; kept in the mod's store.
89let registered = false
90// Text blocks drawn while no turn ran: earlier replies, which stay visible. Any other text block is this turn's, and is
91// hidden once the card shows, so only the finished reply is read.
92const oldTexts = new Set<string>()
93// The person's messages already in the chat, for telling the turn's own prompt apart from an earlier one.
94const oldPrompts = new Set<string>()
95// Steps already in the chat when no turn ran; any other step is this turn's, running or already done (a quick read
96// can finish before it is ever drawn running).
97const oldTools = new Set<string>()
98// Finished turns stay folded: their steps and in-between text are kept hidden, and a line under the prompt says how
99// many steps and how long, with a button that opens them. Kept for this session; older turns are left as they are.
100// first: the step whose row draws the done line when there is no answer to hang the done card under.
101// stopped: the turn did not end on an answer (the person stopped it, an API error, a refusal).
102type Done = { first: string; steps: number; ms: number; open: boolean; answer: string; scene: number; stopped: boolean; labels: string[] }
103const done: Done[] = []
104// A finished turn's answer text, to its entry in done: the done card hangs under that block.
105const doneAnswers = new Map<string, number>()
106// What each step did, in a few words, by its tool_use_id: "show steps" lists these inside the done card, because the
107// app does not reliably redraw the old step rows when asked.
108const stepLabels = new Map<string, string>()
109function stepLabel(tool: string, input: any) {
110  const i = input && typeof input === 'object' ? input : {}
111  // A path shows its last two parts: the file and its folder, not a drive prefix that hides the name.
112  const file = i.file_path ?? i.path
113  const short = typeof file === 'string' ? file.split(/[\\/]+/).filter(Boolean).slice(-2).join('/') : undefined
114  const what = i.description ?? i.command ?? short ?? i.pattern ?? i.url ?? i.query ?? i.prompt ?? ''
115  const one = String(what).replace(/\s+/g, ' ').trim()
116  return one ? `${tool}  ${one.length > 80 ? one.slice(0, 79) + '…' : one}` : tool
117}
118const foldedTools = new Map<string, number>()
119const foldedTexts = new Map<string, number>()
120
121// The person's own scenes: any GIF, PNG, WebP, JPG or SVG dropped in ~/.claude/textless/scenes joins the
122// rotation. A picture is wrapped in an SVG as a data URI, so it has to fit the engine's 131072-character cap.
123const SVG_CAP = 131072
124const IMAGE_TYPES: Record<string, string> = { gif: 'image/gif', png: 'image/png', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg' }
125type Own = { name: string; mtimeMs: number; svg: string }
126let own: Own[] = []
127// The drawn scenes the mod ships, read once. Files straight in hooks/scenes are the Clawd set; each folder in it is
128// another set, named after the folder (hooks/scenes/abstract is the Abstract set).
129type Scene = { name: string; svg: string; set: string; file?: string }
130let builtIn: Scene[] | null = null
131const CLAWD_SET = 'clawd'
132// The set the card rotates through, for every chat. The person's own scenes are their own set, 'yours'.
133let set = CLAWD_SET
134// The scene the person picked with the next button, by place in the list; null rotates one per turn.
135let pick: number | null = null
136// The person's own sets, made on the settings page: a name and scenes from any set, each "<set>/<scene>".
137type Mix = { id: string; name: string; scenes: string[] }
138let mixes: Mix[] = []
139function keptMixes(raw: unknown): Mix[] {
140  if (!Array.isArray(raw)) return []
141  return raw.filter((m: any) => m && typeof m.id === 'string' && /^mix-[a-z0-9]{1,24}$/.test(m.id) && typeof m.name === 'string' && Array.isArray(m.scenes))
142    .map((m: any) => ({ id: m.id, name: String(m.name).slice(0, 32), scenes: m.scenes.map(String).slice(0, 60) }))
143}
144
145async function loadBuiltIn($: any) {
146  if (builtIn) return
147  const root = `${$.plugin.root}/hooks/scenes`
148  const top = ((await $.fs.list(root).catch(() => [])) as { name: string; kind: string }[])
149  const folders = [{ dir: root, set: CLAWD_SET }, ...top.filter(f => f.kind === 'dir').map(f => ({ dir: `${root}/${f.name}`, set: f.name }))]
150  const found: Scene[] = []
151  for (const { dir, set: name } of folders) {
152    const files = ((await $.fs.list(dir).catch(() => [])) as { name: string; kind: string }[])
153      .filter(f => f.kind === 'file' && f.name.endsWith('.svg'))
154      .sort((a, b) => a.name.localeCompare(b.name))
155    const read = await Promise.all(files.map(f => $.fs.read(`${dir}/${f.name}`).catch(() => '')))
156    files.forEach((f, i) => found.push({ name: f.name.replace(/\.svg$/, ''), svg: String(read[i]), set: name }))
157  }
158  // The chess scene is only the settings page's tile: chess is started with /chess, never drawn by the card's rotation.
159  builtIn = found.filter(b => b.set !== INTERACTIVE_CHESS && b.svg.includes('<svg') && b.svg.length <= SVG_CAP)
160  // The quotes set is drawn, not read: each quote's card is made the first time something asks for it, then kept.
161  for (const [id, text, author] of QUOTES) {
162    let made: string | null = null
163    builtIn.push({ name: id, set: 'quotes', get svg() { return (made ??= quoteCard(text, author)) } })
164  }
165}
166
167// When each play-once scene (a quote being written) was first on the card, by set and name. Once it has had its full
168// time it is shown finished from then on, so a redraw or a later turn never writes it again.
169const firstShown = new Map<string, number>()
170
171// The on/off switch in the model strip: a pill in Clawd's orange with the knob right when on, grey with the knob left
172// when off. The knob carries a small T, struck through while the text is hidden.
173function switchSvg(on: boolean) {
174  const pill = on ? '<rect x=".5" y=".5" width="29" height="15" rx="7.5" fill="#d77757" stroke="#d77757"/>' : '<rect x=".5" y=".5" width="29" height="15" rx="7.5" fill="#2c2c31" stroke="#4a4a52"/>'
175  const kx = on ? 22 : 8
176  const ink = on ? '#d77757' : '#2c2c31'
177  const t = `<rect x="${kx - 2.5}" y="4.5" width="5" height="1.4" fill="${ink}"/><rect x="${kx - 0.7}" y="4.5" width="1.4" height="7" fill="${ink}"/>`
178  const strike = on ? `<rect x="${kx - 4}" y="7.3" width="8" height="1.3" fill="${ink}" transform="rotate(-35 ${kx} 8)"/>` : ''
179  return `<svg xmlns="http://www.w3.org/2000/svg" width="30" height="16" viewBox="0 0 30 16">${pill}<circle cx="${kx}" cy="8" r="6" fill="${on ? '#ffffff' : '#8b8b93'}"/>${t}${strike}</svg>`
180}
181const tooBig = new Set<string>()
182
183async function scenesDir($: any) {
184  const home = String((await $.env.get('USERPROFILE').catch(() => '')) || (await $.env.get('HOME').catch(() => '')) || '')
185  const sep = home.includes('\\') ? '\\' : '/'
186  return [home, '.claude', 'textless', 'scenes'].join(sep)
187}
188
189function wrapImage(mime: string, base64: string, width = ART_PX, height = ART_PX) {
190  // Drawn at the card's size, kept in proportion, with hard pixel edges so pixel art stays crisp when scaled.
191  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><image href="data:${mime};base64,${base64}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet" style="image-rendering:pixelated"/></svg>`
192}
193
194// Reads the folder again, reusing files that did not change. A file too big for the card is skipped and named once.
195async function loadOwn($: any) {
196  const dir = await scenesDir($)
197  const entries = ((await $.fs.list(dir).catch(() => [])) as { name: string; kind: string; mtimeMs: number }[])
198    .filter(f => f.kind === 'file')
199    .sort((a, b) => a.name.localeCompare(b.name))
200  const next: Own[] = []
201  for (const f of entries) {
202    const ext = f.name.split('.').pop()?.toLowerCase() ?? ''
203    if (ext !== 'svg' && !IMAGE_TYPES[ext]) continue
204    const kept = own.find(o => o.name === f.name && o.mtimeMs === f.mtimeMs)
205    if (kept) {
206      next.push(kept)
207      continue
208    }
209    const path = `${dir}/${f.name}`
210    const svg = ext === 'svg'
211      ? String(await $.fs.read(path).catch(() => ''))
212      : wrapImage(IMAGE_TYPES[ext], String(((await $.fs.read(path, { as: 'bytes' }).catch(() => null)) as { base64?: string } | null)?.base64 ?? ''))
213    if (!svg || svg.length > SVG_CAP || !svg.includes('<svg')) {
214      if (!tooBig.has(f.name)) {
215        tooBig.add(f.name)
216        void Promise.resolve().then(() => $.ui.toast(`textless: ${f.name} is too big or not a picture (keep it under about 95 KB)`)).catch(() => undefined)
217      }
218      continue
219    }
220    next.push({ name: f.name, mtimeMs: f.mtimeMs, svg })
221  }
222  own = next
223}
224
225async function openFolder($: any) {
226  const dir = await scenesDir($)
227  await $.fs.write(`${dir}/README.txt`, 'Drop GIF, PNG, WebP, JPG or SVG files here (under about 95 KB each). They show in the textless card while Claude works.\n').catch(() => undefined)
228  // Explorer answers 1 even when it opens the window, so any answer counts; only a refusal to start moves on to the
229  // next way. If none starts, the person is told, and the panel's Copy path still gets them there.
230  const ways = dir.includes('\\')
231    ? [['explorer.exe', dir], ['cmd', '/c', 'start', '""', dir]]
232    : [['open', dir], ['xdg-open', dir]]
233  for (const argv of ways) {
234    const started = await $.process.run(argv, { timeoutMs: 5000 }).then(() => true, () => false)
235    if (started) return dir
236  }
237  void Promise.resolve().then(() => $.ui.toast(`textless: could not open the folder. It is at ${dir}`)).catch(() => undefined)
238  return dir
239}
240
241// The scene market (market/ in this repo): community scenes, one click to use. TEXTLESS_MARKET_API and
242// TEXTLESS_MARKET_SITE point the plugin elsewhere, such as the local dev Worker (http://127.0.0.1:8788).
243const MARKET_API = 'https://textless-market.isac-ehrstedt.workers.dev'
244const MARKET_SITE = 'https://textless-preview.isac-ehrstedt.workers.dev/market/'
245type Listed = { id: string; name: string; author: string; link?: string; description?: string; width: number; height: number; created: string; file: string; stars?: number }
246// Ids name the saved file, so only plain ones are taken: never a name or path the server sends.
247const SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/
248type Market = { scenes: Listed[]; error: string; loading: boolean; at: number }
249let market: Market = { scenes: [], error: '', loading: false, at: 0 }
250// Thumbnails for the Community tab, checked like a download before they are drawn; '' marks one that failed.
251const thumbs = new Map<string, string>()
252// The market names of saved scenes, by id, so the scene list says "Rain cloud" and not the id.
253let marketNames: Record<string, { name: string; author: string }> = {}
254let paneTab: 'settings' | 'community' = 'settings'
255
256async function marketUrls($: any) {
257  const api = String((await $.env.get('TEXTLESS_MARKET_API').catch(() => '')) || MARKET_API).replace(/\/+$/, '')
258  const site = String((await $.env.get('TEXTLESS_MARKET_SITE').catch(() => '')) || MARKET_SITE)
259  return { api, site }
260}
261
262async function listMarket($: any): Promise<Listed[]> {
263  const { api } = await marketUrls($)
264  const res = await $.http.fetch(`${api}/scenes`)
265  if (!res.ok) throw new Error(`the market answered ${res.status}`)
266  const data = JSON.parse(res.text)
267  // Most starred first; the market already lists newest first, and sort keeps that order among equal stars.
268  return (Array.isArray(data?.scenes) ? data.scenes : [])
269    .filter((s: any) => typeof s?.id === 'string' && SAFE_ID.test(s.id))
270    .sort((a: any, b: any) => (Number(b.stars) || 0) - (Number(a.stars) || 0))
271}
272
273function bytesOf(base64: string) {
274  const bin = atob(base64)
275  const out = new Uint8Array(bin.length)
276  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
277  return out
278}
279
280// Downloads one listed scene and runs the market's own checks on it again. Returns it as the SVG the card draws: an
281// SVG as it is, a picture wrapped as a data URI (the plugin can only write text). Throws with the reason to tell.
282async function downloadScene($: any, s: Listed) {
283  const { api } = await marketUrls($)
284  const ext = String(s.file ?? '').split(/[?#]/)[0].split('.').pop()?.toLowerCase() ?? ''
285  if (ext !== 'svg' && !IMAGE_TYPES[ext]) throw new Error('it is not a picture the card can show')
286  // Fetched from the market's own file route by id, whatever address the listing gives.
287  const url = `${api}/f/${s.id}.${ext}`
288  let svg: string
289  if (ext === 'svg') {
290    const res = await $.http.fetch(url)
291    if (!res.ok) throw new Error(`the market answered ${res.status}`)
292    const verdict = checkFile(`${s.id}.svg`, new TextEncoder().encode(res.text))
293    if (verdict.problems.length) throw new Error(verdict.problems.join('; '))
294    svg = res.text
295  } else {
296    const res = await $.http.fetch(`${url}?as=base64`)
297    if (!res.ok) throw new Error(`the market answered ${res.status}`)
298    const base64 = res.text.replace(/\s+/g, '')
299    if (!/^[A-Za-z0-9+/]+=*$/.test(base64)) throw new Error('the market sent something that is not a picture')
300    const verdict = checkFile(`${s.id}.${ext}`, bytesOf(base64))
301    if (verdict.problems.length) throw new Error(verdict.problems.join('; '))
302    svg = wrapImage(IMAGE_TYPES[ext], base64, verdict.width, verdict.height)
303  }
304  if (svg.length > SVG_CAP) throw new Error('it is too big for the card (pictures have to stay under about 95 KB)')
305  return svg
306}
307
308// Saves a listed scene as ~/.claude/textless/scenes/<id>.svg and makes it the scene every chat shows.
309async function useScene($: any, s: Listed) {
310  const svg = await downloadScene($, s)
311  const dir = await scenesDir($)
312  await $.fs.write(`${dir}/${s.id}.svg`, svg)
313  marketNames = { ...marketNames, [s.id]: { name: String(s.name).slice(0, 40), author: String(s.author).slice(0, 40) } }
314  await $.store.set('marketNames', marketNames).catch(() => undefined)
315  await loadOwn($)
316  await loadBuiltIn($)
317  await setSet($, 'yours')
318  await setPick($, Math.max(0, sceneList().findIndex(sc => sc.file === `${s.id}.svg`)))
319  return `${s.name} by ${s.author} is on. It plays the next time Claude works.`
320}
321
322async function getScene($: any, id: string) {
323  if (!SAFE_ID.test(id)) return `"${id}" is not a scene id. The site's Use in textless button copies the right command.`
324  let scenes: Listed[]
325  try {
326    scenes = await listMarket($)
327  } catch {
328    return 'The scene market could not be reached. Your scenes keep working; try again later.'
329  }
330  const s = scenes.find(x => x.id === id)
331  if (!s) return `No scene ${id} in the market. It was not found or was taken down.`
332  try {
333    return await useScene($, s)
334  } catch (err) {
335    return `${s.name} was not added: ${String((err as any)?.message ?? err)}.`
336  }
337}
338
339// Fills the Community tab: the listing, then each thumbnail as it arrives. Kept for a minute between opens.
340async function refreshMarket($: any, force = false) {
341  if (market.loading || (!force && market.at && Date.now() - market.at < 60000)) return
342  market = { ...market, loading: true }
343  $.ui.invalidate('ui.render')
344  try {
345    market = { scenes: await listMarket($), error: '', loading: false, at: Date.now() }
346  } catch {
347    market = { ...market, error: 'The scene market could not be reached. Your scenes keep working.', loading: false, at: Date.now() }
348  }
349  $.ui.invalidate('ui.render')
350  for (const s of market.scenes.slice(0, 24)) {
351    if (thumbs.has(s.id)) continue
352    thumbs.set(s.id, await downloadScene($, s).catch(() => ''))
353    $.ui.invalidate('ui.render')
354  }
355}
356
357async function useFromPane($: any, s: Listed) {
358  const said = await useScene($, s).catch(err => `${s.name} was not added: ${String((err as any)?.message ?? err)}.`)
359  void Promise.resolve().then(() => $.ui.toast(`textless: ${said}`)).catch(() => undefined)
360  $.ui.invalidate('ui.render')
361}
362
363async function chatOf($: any): Promise<Chat> {
364  const id = String(await $.session.id().catch(() => ''))
365  let chat = chats.get(id)
366  if (!chat) {
367    chat = { on: true, running: false, working: false, long: false, prompt: '', underPrompt: false, turnTexts: new Set<string>(), lastText: '', kept: [], baselineUntil: 0, answer: '', durationMs: 0, longAt: 0, agents: new Set<string>(), mainDone: false, stopped: false, turns: 0, prompts: 0, hintedAt: null, startedAt: 0, steps: [], cardKey: '', timer: null, ticker: null, newest: '', newestAt: -1, sceneAt: -1, background: [] }
368    chats.set(id, chat)
369    chat.on = (await $.store.get(`on:${id}`).catch(() => null)) !== false
370    const newest = await $.store.get(`newest:${id}`).catch(() => null)
371    chat.newest = typeof newest === 'string' ? newest : ''
372  }
373  return chat
374}
375
376// Debug log: what the mod did and when, one file per chat as ~/.claude/textless/debug-<chat>.log, the last 300 lines.
377// One file per chat because every chat keeps its own buffer and would overwrite the others.
378const logLines: string[] = []
379// Off unless switched on with /textless debug (kept in the store, every pane) or TEXTLESS_DEBUG=1; then it writes the
380// log below.
381let debugOn: boolean | null = null
382function dbg($: any, line: string) {
383  if (debugOn === null) {
384    debugOn = false
385    void Promise.all([$.store.get('debug').catch(() => null), Promise.resolve($.env.get('TEXTLESS_DEBUG')).catch(() => '')])
386      .then(([kept, env]: any[]) => { debugOn = kept === true || String(env ?? '') === '1' })
387      .catch(() => undefined)
388  }
389  if (!debugOn) return
390  logLines.push(`${new Date().toISOString().slice(11, 23)} ${line}`)
391  if (logLines.length > 300) logLines.shift()
392  void Promise.all([scenesDir($), $.session.id().catch(() => 'unknown')])
393    .then(([dir, id]) => $.fs.write(dir.replace(/scenes$/, `debug-${String(id).slice(0, 8)}.log`), logLines.join('\n')))
394    .catch(() => undefined)
395}
396
397function goLong($: any, chat: Chat) {
398  dbg($, `goLong working=${chat.working} long=${chat.long} steps=${chat.steps.length}`)
399  if (!chat.working || chat.long) return
400  chat.long = true
401  chat.longAt = Date.now()
402  // No redraw on a clock: every redraw of an image starts its animation over, which reads as a stutter.
403  $.ui.invalidate('ui.render')
404}
405
406// Marks a step as part of this turn when the app reports it running, and says where it stands in the turn.
407function track($: any, chat: Chat, key: string, isRunning: boolean) {
408  if (!key) return 'other'
409  if ((isRunning || !oldTools.has(key)) && !chat.steps.includes(key)) {
410    chat.steps.push(key)
411    dbg($, `step ${chat.steps.length} ${key.slice(-8)} running=${isRunning}`)
412    goLong($, chat)
413    // A new step may be the one that puts the card far enough up to move it down.
414    if (chat.long && chat.cardKey) reanchor($, chat, undefined)
415  }
416  if (!chat.steps.includes(key)) return 'other'
417  // The card stays in the turn's first step: the app does not redraw rows it already drew, so a card moved to the
418  // newest step left its old copy behind (two cards) and rebuilt itself every step (flicker).
419  if (!chat.cardKey) chat.cardKey = chat.steps[0] ?? ''
420  return key === chat.cardKey ? 'card' : 'later'
421}
422
423// The rows hidden under the card still take the app's spacing, and its own "Ran 3 commands" lines come in below, so
424// a long turn pushes the card up and out of view. Where the app says which rows the viewport shows, the card moves
425// once more than half of its row has scrolled away. The desktop does not say (onScreen stays undefined), so there it
426// moves once REANCHOR_STEPS steps have come in below it, about when it starts to leave the screen. It moves to the
427// newest step's row, at the bottom; the old row redraws to nothing.
428const REANCHOR_STEPS = 3
429function reanchor($: any, chat: Chat, onScreen: any) {
430  const newest = chat.steps[chat.steps.length - 1]
431  if (!newest || newest === chat.cardKey) return false
432  const below = chat.steps.length - 1 - chat.steps.indexOf(chat.cardKey)
433  const away = onScreen === undefined
434    ? below >= REANCHOR_STEPS
435    : onScreen === null || (typeof onScreen?.first === 'number' && typeof onScreen?.of === 'number' && onScreen.first > onScreen.of / 2)
436  if (!away) return false
437  dbg($, `card moves down ${chat.cardKey.slice(-8)} -> ${newest.slice(-8)} below=${below} onScreen=${JSON.stringify(onScreen)}`)
438  chat.cardKey = newest
439  void Promise.resolve().then(() => $.ui.invalidate('ui.render'))
440  return true
441}
442
443const clock = (ms: number) => {
444  const s = Math.max(0, Math.round(ms / 1000))
445  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
446}
447
448// Every scene there is, by set: the person's own as 'yours', then the shipped ones (the old drawn-in-code ones only
449// if those cannot be read).
450function allScenes(): Scene[] {
451  // The drawn quotes are always there; the old drawn-in-code scenes stand in only when no scene file could be read.
452  const files = (builtIn ?? []).filter(b => b.set !== 'quotes')
453  const shipped = files.length > 0 ? (builtIn as Scene[]) : [...SCENES.map((svg, i) => ({ name: `scene ${i + 1}`, svg, set: CLAWD_SET })), ...(builtIn ?? [])]
454  const label = (file: string) => marketNames[file.replace(/\.svg$/, '')]?.name ?? file.replace(/\.[^.]+$/, '')
455  const base = [...own.map(o => ({ name: label(o.name), svg: o.svg, set: 'yours', file: o.name })), ...shipped]
456  // Each of the person's sets holds the scenes it names that this computer has, in the order they were ticked.
457  const made = mixes.flatMap(m => m.scenes.flatMap(ref => base.filter(b => `${b.set}/${b.name}` === ref).slice(0, 1).map(b => ({ ...b, set: m.id }))))
458  return [...base, ...made]
459}
460
461// The sets there are, the Clawd set first, the person's own last.
462function setList() {
463  const names = [...new Set(allScenes().map(s => s.set))]
464  // Clawd first, then the shipped sets, the person's own folder, and the sets they made last.
465  const rank = (n: string) => (n === CLAWD_SET ? 0 : n.startsWith('mix-') ? 3 : n === 'yours' ? 2 : 1)
466  return names.sort((a, b) => rank(a) - rank(b) || (a.startsWith('mix-') ? 0 : a.localeCompare(b)))
467}
468
469const setLabel = (name: string) => (name === 'yours' ? 'Your own' : mixes.find(m => m.id === name)?.name ?? name[0].toUpperCase() + name.slice(1))
470
471// The scenes the card rotates through: the chosen set, or the Clawd set if that one is gone.
472function sceneList() {
473  const all = allScenes()
474  const inSet = all.filter(s => s.set === set)
475  return inSet.length > 0 ? inSet : all.filter(s => s.set === CLAWD_SET).length > 0 ? all.filter(s => s.set === CLAWD_SET) : all
476}
477
478function sceneIndex(chat: Chat) {
479  const n = sceneList().length
480  if (pick !== null) return pick % n
481  return chat.sceneAt >= 0 && chat.sceneAt < n ? chat.sceneAt : (Math.max(1, chat.turns) - 1) % n
482}
483
484// Which scene a new turn shows: random, but never one of the last RECENT_SCENES shown in any chat (at most half the
485// set, so a set of three still changes every turn). A fixed order by turn number gave every new chat the same first
486// few scenes and left the end of a long set unseen. A scene picked by hand (pick) wins and is not drawn here.
487const RECENT_SCENES = 50
488let recentScenes: string[] = []
489function chooseScene($: any): number {
490  const list = sceneList()
491  const key = (i: number) => `${list[i].set}/${list[i].name}`
492  const window = Math.min(RECENT_SCENES, Math.floor(list.length / 2))
493  const avoid = new Set(recentScenes.slice(-window))
494  const fresh = list.map((_, i) => i).filter(i => !avoid.has(key(i)))
495  const pool = fresh.length > 0 ? fresh : list.map((_, i) => i)
496  const at = pool[Math.floor(Math.random() * pool.length)]
497  recentScenes = [...recentScenes.filter(k => k !== key(at)), key(at)].slice(-200)
498  void $.store.set('recentScenes', recentScenes).catch(() => undefined)
499  return at
500}
501
502// SMIL's own clock: each animation begins that many seconds in the past. One that names its own begin keeps it.
503function inPhase(svg: string, elapsedMs: number) {
504  const ago = `-${(Math.max(0, elapsedMs) / 1000).toFixed(2)}s`
505  return svg.replace(/<(animate\w*|set)([^>]*?)(\/?)>/g, (tag, name, attrs, close) => (attrs.includes('begin=') ? tag : `<${name} begin="${ago}"${attrs}${close}>`))
506}
507
508async function card($: any, e: any, chat: Chat, step: string) {
509  // Minimized: one line in the card's place. (The app's working line under the chat was tried: the desktop does not
510  // draw a mod's tree there, so textless vanished.)
511  if (isMini(chat)) return miniLine($, e, chat)
512  if (chessGame && chessAnchor === 'chat' && (await chessOwns($))) return chessCard($, e, chat, step)
513  try {
514    const out = await drawCard($, e, chat, step)
515    dbg($, `card drawn scenes=${sceneList().length} svg=${Boolean(($.ui.resolve(e) as any).Svg)}`)
516    return out
517  } catch (err) {
518    dbg($, `card FAILED ${String((err as any)?.stack ?? err).slice(0, 300)}`)
519    const { Text } = $.ui.resolve(e) as Record<string, any>
520    return <Text color="#d97757">Claude is working</Text>
521  }
522}
523
524// Sets that are played, not watched. The settings page lists them under Interactive sets.
525const INTERACTIVE_CHESS = 'chess'
526
527async function drawCard($: any, e: any, chat: Chat, step: string) {
528  const { Box, Text, Svg, Button, Raster } = $.ui.resolve(e) as Record<string, any>
529  await refreshTheme($)
530  // Every redraw starts the image's animation over; starting each one as far in as the card has been showing makes
531  // the restart land where the last drawing was, so it cannot be seen.
532  const scene = sceneList()[sceneIndex(chat)]
533  const now = await $.clock.now()
534  let src = scene.svg
535  const once = onceMs(src)
536  if (once !== null) {
537    const key = `${scene.set}/${scene.name}`
538    const first = firstShown.get(key) ?? now
539    firstShown.set(key, first)
540    if (now - first >= once) src = stillCard(src)
541  }
542  const art = inPhase(src, now - chat.longAt)
543  // A scene from the marketplace names who drew it, under the card's words. Shipped scenes and the person's own don't.
544  const maker = scene.file ? marketNames[scene.file.replace(/\.svg$/, '')] : undefined
545  // Square art with the words under it. Nothing here changes on a clock, so the drawing is only redrawn when a step
546  // changes and its animation runs on undisturbed.
547  // Each scene keeps its own shape: drawn at its own width and height, no wider than the card allows.
548  const size = art.match(/<svg[^>]*\swidth="(\d+)"[^>]*\sheight="(\d+)"/)
549  const artW = size ? Number(size[1]) : ART_PX
550  const artH = size ? Number(size[2]) : ART_PX
551  const fitW = Math.round((ART_H * artW) / artH)
552  // Fill the box and crop what does not fit, centred, so a pane narrower than fitW keeps the full height. A scene whose
553  // root says data-fit="contain" (a word that must stay whole) is fitted inside instead; it paints its own background
554  // past its viewBox so the box still looks full.
555  // A plain terminal cannot draw an SVG: pixel-art scenes are painted as coloured half blocks, a quote as its words.
556  const terminal = e.surface === 'terminal'
557  let terminalArt: any = null
558  if (terminal) {
559    const columns = Number(e.viewport?.columns) || 80
560    const rows = Number(e.viewport?.rows) || 30
561    const cells = Raster ? sceneCells(art, Math.min(64, columns - 4), Math.max(6, Math.min(20, rows - 12))) : null
562    const quote = scene.set === 'quotes' ? QUOTES.find(q => q[0] === scene.name) : undefined
563    if (cells) terminalArt = <Raster key="textless-art" columns={cells.columns} rows={cells.rows} cells={cells.cells} />
564    else if (quote) {
565      terminalArt = (
566        <Box key="textless-quote" flexDirection="column" alignItems="center" paddingX={2} marginY={1}>
567          <Text color="#d97757" italic wrap="wrap">{quote[1]}</Text>
568          <Text dimColor>{`— ${quote[2]}`}</Text>
569        </Box>
570      )
571    }
572  }
573  const contain = /<svg\b[^>]*\sdata-fit="contain"/.test(art)
574  const fit = contain ? 'xMidYMid meet' : 'xMidYMid slice'
575  const cover = art.replace(/<svg\b([^>]*?)\s*preserveAspectRatio="[^"]*"/, '<svg$1').replace(/<svg\b/, `<svg preserveAspectRatio="${fit}"`)
576  return (
577    <Box key="textless" flexDirection="column" alignItems="center" paddingBottom={1}>
578      {/* The switch, the settings (another scene mid-turn) and minimize sit in the row the top padding took, so the card is no taller for them. */}
579      <Box key="textless-top" flexDirection="row" justifyContent="flex-end" width="100%" height={1}>
580        {Button ? controls($, e) : null}
581        {Button ? sizeButton($, e, 'minimize') : null}
582      </Box>
583      {terminal ? terminalArt : Svg ? <Svg source={cover} alt="Claude working" width={fitW} height={ART_H} /> : null}
584      {/* Answered while a background task runs: Claude still has work under way, so it is not the person's turn yet. */}
585      <Text color="#d97757" bold wrap="truncate">Claude is working</Text>
586      <Text dimColor wrap="truncate">{chat.mainDone ? waitingFor(chat) : step}</Text>
587      {maker ? <Text key="textless-credit" color="#6e6660" wrap="truncate">{`${maker.name} · by ${maker.author}`}</Text> : null}
588    </Box>
589  )
590}
591
592// The turn has answered while background work runs: the card hangs under the answer, not up among the steps.
593function cardUnderAnswer(chat: Chat) {
594  return chat.mainDone && chat.long && chat.answer.trim() !== ''
595}
596
597// What the turn still waits on once its answer is in: the background tasks by name, or the subagents.
598function waitingFor(chat: Chat) {
599  const n = chat.background.length
600  if (n === 0) return 'Waiting for a background task'
601  return `Waiting for ${n} background task${n === 1 ? '' : 's'}: ${chat.background.map(t => t.description).join(', ')}`
602}
603// The same, after the turn: on the newest answer's row, until the last one ends.
604function backgroundLine($: any, e: any, chat: Chat) {
605  const { Box, Text } = $.ui.resolve(e) as Record<string, any>
606  const n = chat.background.length
607  if (n === 0) return null
608  return (
609    <Box key="textless-background" flexShrink={1} minWidth={0} marginLeft={1}>
610      <Text dimColor wrap="truncate">{`${n} background task${n === 1 ? '' : 's'} running: ${chat.background.map(t => t.description).join(', ')}`}</Text>
611    </Box>
612  )
613}
614
615// Minimize (two arrows pointing in) and full size (two arrows pointing out), drawn, in the same spot on both looks.
616// Like "Aa": the icon in the flow, a blank button over it for the click, its hover clear and a lighter copy of the icon
617// shown under the pointer instead.
618function sizeIcon(kind: 'minimize' | 'expand', color: string) {
619  const paths = kind === 'minimize'
620    ? 'M14 2 9.5 6.5M9.5 3v3.5H13M2 14l4.5-4.5M3 9.5h3.5V13'
621    : 'M9.5 6.5 14 2M10 2h4v4M6.5 9.5 2 14M2 10v4h4'
622  return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="${paths}" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`
623}
624function sizeButton($: any, e: any, kind: 'minimize' | 'expand') {
625  const { Box, Svg, Button } = $.ui.resolve(e) as Record<string, any>
626  const key = `textless-${kind}`
627  const press = async () => {
628    const chat = await chatOf($)
629    // Full size on a turn with a task list holds for this turn; the choice for every turn stays as it was.
630    if (kind === 'expand' && !mini) chat.fullAnyway = true
631    else if (kind === 'minimize' && chat.listed) chat.fullAnyway = false
632    if (kind === 'expand' && mini) await setMini($, false)
633    else if (kind === 'minimize' && !chat.listed) await setMini($, true)
634    else $.ui.invalidate('ui.render')
635  }
636  if (!Svg) return <Button key={key} plain dimColor label={kind === 'minimize' ? ' ⤡ ' : ' ⤢ '} onPress={press} />
637  return (
638    <Box key={`${key}-box`} flexDirection="row" alignItems="center" justifyContent="center" paddingX={1}>
639      <Svg source={sizeIcon(kind, isLight ? '#8a8580' : '#8b8b93')} alt={kind === 'minimize' ? 'Minimize' : 'Full size'} width={14} height={14} />
640      <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center" display="none" hover={{ scope: key, display: 'flex' }}>
641        <Svg source={sizeIcon(kind, isLight ? '#1a1a1a' : '#f2f2f2')} alt={kind === 'minimize' ? 'Minimize' : 'Full size'} width={14} height={14} />
642      </Box>
643      <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
644        <Button key={key} plain label={' '.repeat(3)} hover={{ scope: key, backgroundColor: '#00000000' }} onPress={press} />
645      </Box>
646    </Box>
647  )
648}
649
650async function miniLine($: any, e: any, chat: Chat) {
651  await refreshTheme($)
652  const { Box, Text } = $.ui.resolve(e) as Record<string, any>
653  return (
654    <Box key="textless" flexDirection="row" alignItems="center" gap={1}>
655      <Text color="#d97757" bold>textless</Text>
656      <Box flexShrink={1} minWidth={0}>
657        <Text dimColor wrap="truncate">{chat.mainDone ? waitingFor(chat) : 'minimized'}</Text>
658      </Box>
659      <Box flexGrow={1} />
660      {/* No gap between the buttons, as on the big card: the row's own gap spread them. */}
661      <Box flexDirection="row" alignItems="center">
662        {controls($, e)}
663        {sizeButton($, e, 'expand')}
664      </Box>
665    </Box>
666  )
667}
668
669// Text said between steps is hidden, but the app can keep a row it drew while the text was still arriving (a card on
670// top and the text under it until something redraws). Once the text stops changing, every row is drawn once more.
671let settling: { cancel: () => void } | null = null
672function settleText($: any) {
673  settling?.cancel()
674  settling = $.clock.after(700, () => { settling = null; $.ui.invalidate('ui.render') })
675}
676
677// Switched off while Claude works: the row the card sat in is drawn as the app draws it, with one line under it holding
678// the grey Aa and the gear, so the way back on stays where the card was instead of moving up the chat.
679async function offRow($: any, e: any, chat: Chat, isCardRow: boolean, next: any) {
680  const inner = await next(e)
681  if (!chat.running || !isCardRow) return inner
682  await refreshTheme($)
683  const { Box } = $.ui.resolve(e) as Record<string, any>
684  const drawn = { chat, id: String(await $.session.id().catch(() => '')) }
685  return (
686    <Box key="textless-off-row" flexDirection="column">
687      {inner}
688      <Box flexDirection="row" alignItems="center">
689        <Box flexGrow={1} />
690        {controls($, e, drawn)}
691      </Box>
692    </Box>
693  )
694}
695
696// The card minimized to one line, for every chat; kept in the store.
697let mini = false
698// One line now: minimized, or a turn with a task list (the app's own, drawn under the card).
699const LIST_TOOLS = /^(TodoWrite|TaskCreate|TaskUpdate)$/
700function isMini(chat: Chat) {
701  return mini || (Boolean(chat.listed) && !chat.fullAnyway)
702}
703function noteList($: any, chat: Chat, tool: unknown) {
704  if (!chat.working || chat.listed || !LIST_TOOLS.test(String(tool ?? ''))) return
705  chat.listed = true
706  void Promise.resolve().then(() => $.ui.invalidate('ui.render')).catch(() => undefined)
707}
708async function setMini($: any, on: boolean) {
709  mini = on
710  await $.store.set('mini', on).catch(() => undefined)
711  $.ui.invalidate('ui.render')
712}
713
714// textless's two buttons: "Aa" switches it on or off in this chat, the gear opens the settings. They sit on the
715// minimized card and under the newest answer, never in a row of their own under the prompt.
716// A Button cannot be coloured, so "Aa" is text (Clawd orange, bold and struck through while textless hides the steps,
717// plain grey while off) with a blank button laid over it to take the click.
718// The button's own hover box is clear: drawn over the text it hid it; the text lights up instead.
719function controls($: any, e: any, drawn?: { chat: Chat; id: string }) {
720  const { Box, Text, Button } = $.ui.resolve(e) as Record<string, any>
721  const on = drawn ? drawn.chat.on : true
722  return [
723    <Box key="textless-switch-box" flexDirection="row" alignItems="center" paddingX={1}>
724      <Text color={on ? '#d77757' : undefined} dimColor={!on} bold={on} strikethrough={on} hover={{ scope: 'textless-switch', color: on ? '#f0a080' : hoverText() }}>Aa</Text>
725      <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
726        <Button key="textless-switch" plain label={' '.repeat(3)} hover={{ scope: 'textless-switch', backgroundColor: '#00000000' }} onPress={() => toggle($, undefined, drawn)} />
727      </Box>
728    </Box>,
729    <Button key="textless-settings" plain dimColor label=" ⚙︎ " hover={{ scope: 'textless-settings', backgroundColor: hoverBg() }} onPress={() => openWebSettings($)} />,
730  ]
731}
732
733// Under the newest answer: the answer as drawn, then one row with the buttons at its end ("Aa" says on or off).
734function isNewest(chat: Chat, text: string) {
735  const answer = chat.newest.trim()
736  const block = text.trim()
737  return Boolean(answer && block && answer.endsWith(block))
738}
739async function newestRow($: any, e: any, inner: unknown, left?: unknown) {
740  await setUp($)
741  const { Box } = $.ui.resolve(e) as Record<string, any>
742  await refreshTheme($)
743  const drawn = { chat: await chatOf($), id: String(await $.session.id().catch(() => '')) }
744  const row = (
745    <Box key="textless-newest" flexDirection="row" alignItems="center">
746      {left ?? null}
747      {backgroundLine($, e, drawn.chat)}
748      <Box flexGrow={1} />
749      {controls($, e, drawn)}
750    </Box>
751  )
752  return (
753    <Box key="textless-answer" flexDirection="column" gap={1}>
754      {inner}
755      {row}
756      {await updBelow($, e)}
757      {chessBelow($, e)}
758    </Box>
759  )
760}
761
762async function openSettings($: any) {
763  await loadOwn($)
764  await loadBuiltIn($)
765  await $.ui.open({ id: 'textless-settings', title: 'textless' })
766}
767
768// The settings page on the website. This computer gets a random key the first time; the page saves under it on the
769// market Worker and every chat reads it back from there, so nothing has to be pasted.
770// Parked: textless draws art only and leaves answers alone. The cover card, the questions, links and next step under
771// answers, and the note telling Claude how to word questions all stay off while this is false. The full version lives
772// on the branch backup/answer-cards (tag answer-cards-backup).
773export const ANSWERS = false
774// Questions, links and the next step under a finished answer; /textless summary off leaves only the done line.
775let summaryOn = ANSWERS
776// Cover mode: once Claude is done, the whole chat hides behind one big card saying whose move it is. Off by default.
777let coverOn = false
778let syncKey = ''
779let remoteUpdated = 0
780let watchUntil = 0
781let lastPull = 0
782
783async function keyOf($: any) {
784  if (syncKey) return syncKey
785  const kept = await $.store.get('syncKey').catch(() => null)
786  if (typeof kept === 'string' && /^[0-9a-f]{32}$/.test(kept)) return (syncKey = kept)
787  const bytes = globalThis.crypto?.getRandomValues ? globalThis.crypto.getRandomValues(new Uint8Array(16)) : Uint8Array.from({ length: 16 }, () => Math.floor(Math.random() * 256))
788  syncKey = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
789  await $.store.set('syncKey', syncKey).catch(() => undefined)
790  return syncKey
791}
792
793// The page opens on what the card shows now: the set, and the picked scene's name if one is kept.
794async function openWebSettings($: any) {
795  await loadOwn($)
796  await loadBuiltIn($)
797  const { site } = await marketUrls($)
798  const scene = pick !== null ? sceneList()[pick % sceneList().length]?.name ?? '' : ''
799  const page = new URL('../settings/', site).href
800  const hash = `k=${await keyOf($)}&set=${encodeURIComponent(set)}&scene=${encodeURIComponent(scene)}&summary=${summaryOn ? 1 : 0}&cover=${coverOn ? 1 : 0}`
801  let url = `${page}#${hash}`
802  // The page cannot read this computer, so the plugin writes a copy of it here with the person's own scenes in it
803  // and opens that: their pictures never leave the computer, only the pick is saved (as from the site). Offline, or
804  // with no own scenes, the site itself opens.
805  const mine = allScenes().filter(s => s.set === 'yours')
806  // Written even with no own scenes yet, so the page can show where the folder is.
807  {
808    const res = await $.http.fetch(page).catch(() => null)
809    if (res?.ok && String(res.text).includes('var API')) {
810      const ownList = mine.slice(0, 60).map(s => ({ id: s.name, name: s.name, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(s.svg)}` }))
811      const folder = await scenesDir($)
812      const data = `<script>window.TEXTLESS_HASH=${JSON.stringify(hash)};window.TEXTLESS_DIR=${JSON.stringify(folder)};window.TEXTLESS_OWN=${JSON.stringify(ownList).replace(/</g, '\\u003c')};</script>`
813      const html = String(res.text).replace('<head>', `<head><base href="${page}">`).replace('<script>\nvar API', `${data}\n<script>\nvar API`)
814      const dir = (await scenesDir($)).replace(/[\\/]scenes$/, '')
815      const file = `${dir}${dir.includes('\\') ? '\\' : '/'}settings.html`
816      if (await $.fs.write(file, html).then(() => true, () => false)) url = file
817    }
818  }
819  // Watch closely for a while: the person is on the page and expects a save to show at once.
820  watchUntil = Number(await $.clock.now()) + 20 * 60_000
821  // From the plugin's own path, not from USERPROFILE: the app sets that on a Mac too, and rundll32 does not exist there.
822  const windows = /^[A-Za-z]:|\\/.test(String($.plugin.root))
823  // rundll32 hands the address to the default browser and answers 0; explorer answers 1 even when it opens one.
824  const ways = windows ? [['rundll32', 'url.dll,FileProtocolHandler', url], ['explorer.exe', url]] : [['open', url], ['xdg-open', url]]
825  for (const argv of ways) {
826    const r = await $.process.run(argv, { timeoutMs: 5000 }).then((x: any) => x, (err: any) => ({ exitCode: -1, stderr: String(err) }))
827    dbg($, `settings open ${argv[0]} exit=${r.exitCode} ${String(r.stderr ?? '').slice(0, 120)}`)
828    if (r.exitCode !== -1) return
829  }
830  // No browser would start: the settings in the app still work.
831  $.ui.toast('textless: no browser would open, so the small settings panel is shown. /textless debug logs why.')
832  await openSettings($)
833}
834
835// What the page saved, if it is newer than what this chat last took. Every 5 s while the page was just opened, every
836// minute otherwise, and never before the page was first opened on this computer.
837async function pullSettings($: any) {
838  if (!syncKey) {
839    const kept = await $.store.get('syncKey').catch(() => null)
840    if (typeof kept !== 'string' || !/^[0-9a-f]{32}$/.test(kept)) return
841    syncKey = kept
842  }
843  const now = Number(await $.clock.now())
844  if (lastPull && now - lastPull < (now < watchUntil ? 5000 : 60_000)) return
845  lastPull = now || 1
846  const { api } = await marketUrls($)
847  const res = await $.http.fetch(`${api}/settings/${syncKey}`).catch(() => null)
848  if (!res?.ok) return
849  let got: any = null
850  try { got = JSON.parse(String(res.text)) } catch { return }
851  if (!got || typeof got.set !== 'string' || typeof got.updated !== 'number') return
852  const taken = Number(await $.store.get('remoteUpdated').catch(() => 0)) || 0
853  remoteUpdated = Math.max(remoteUpdated, taken)
854  if (got.updated <= remoteUpdated) return
855  remoteUpdated = got.updated
856  await $.store.set('remoteUpdated', got.updated).catch(() => undefined)
857  await loadOwn($)
858  await loadBuiltIn($)
859  // The questions list under answers, on or off for every chat, when the page sent it.
860  if (ANSWERS && typeof got.summary === 'boolean' && got.summary !== summaryOn) {
861    summaryOn = got.summary
862    await $.store.set('summary', summaryOn).catch(() => undefined)
863  }
864  if (ANSWERS && typeof got.cover === 'boolean' && got.cover !== coverOn) {
865    coverOn = got.cover
866    await $.store.set('cover', coverOn).catch(() => undefined)
867  }
868  if (Array.isArray(got.mixes)) {
869    mixes = keptMixes(got.mixes)
870    await $.store.set('mixes', mixes).catch(() => undefined)
871  }
872  if (!setList().includes(got.set)) return
873  set = got.set
874  const at = typeof got.scene === 'string' ? sceneList().findIndex(s => s.name === got.scene) : -1
875  pick = at >= 0 ? at : null
876  await $.store.set('set', set).catch(() => undefined)
877  await $.store.set('pick', pick).catch(() => undefined)
878  $.ui.invalidate('ui.render')
879}
880
881// A scene picked in the settings, for every chat; null takes one per turn in turn.
882async function setPick($: any, value: number | null) {
883  pick = value
884  await $.store.set('pick', value).catch(() => undefined)
885  $.ui.invalidate('ui.render')
886}
887
888// A set picked in the settings, for every chat; the card then rotates through that set from its first scene.
889async function setSet($: any, name: string) {
890  set = name
891  pick = null
892  await $.store.set('set', name).catch(() => undefined)
893  await $.store.set('pick', null).catch(() => undefined)
894  $.ui.invalidate('ui.render')
895}
896
897// The next button: the scene after the one showing, kept for every chat until pressed again.
898async function nextScene($: any) {
899  const chat = await chatOf($)
900  await loadOwn($)
901  await loadBuiltIn($)
902  const list = sceneList()
903  pick = (sceneIndex(chat) + 1) % list.length
904  await $.store.set('pick', pick).catch(() => undefined)
905  void Promise.resolve().then(() => $.ui.toast(`textless: ${list[pick!].name} (${pick! + 1} of ${list.length})`)).catch(() => undefined)
906  $.ui.invalidate('ui.render')
907}
908
909// On or off for this chat pane only: other panes keep their own switch. Switched during a turn, the card goes or comes
910// at once: off draws the rows and text whole again.
911// The strip passes the chat and id it was drawn for: a press runs later, outside the drawing, and looking the chat up
912// again there could land on another one.
913async function toggle($: any, want?: boolean, drawn?: { chat: Chat; id: string }) {
914  const chat = drawn?.chat ?? (await chatOf($))
915  const id = drawn?.id ?? String(await $.session.id().catch(() => ''))
916  const was = chat.on
917  chat.on = want ?? !chat.on
918  chat.working = chat.running && chat.on
919  if (!chat.working) chat.long = false
920  else if (!was) {
921    // Back on in the middle of a turn: the rows drawn while off stay as they are (the app does not redraw them), so
922    // the card starts over at the next step, under them, instead of going back up to the turn's first step.
923    for (const k of chat.steps) oldTools.add(k)
924    chat.steps = []
925    chat.cardKey = ''
926    chat.long = false
927  } else if (chat.steps.length > 0) goLong($, chat)
928  void Promise.resolve().then(() => $.ui.toast(chat.on ? 'textless on in this chat' : 'textless off in this chat')).catch(() => undefined)
929  await $.store.set(`on:${id}`, chat.on).catch(() => undefined)
930  $.ui.invalidate('ui.render')
931}
932
933async function setUp($: any) {
934  if (registered) return
935  registered = true
936  const kept = await $.store.get('pick').catch(() => null)
937  mini = (await $.store.get('mini').catch(() => null)) === true
938  const seen = await $.store.get('recentScenes').catch(() => null)
939  recentScenes = Array.isArray(seen) ? seen.filter((k: unknown) => typeof k === 'string').slice(-200) : []
940  pick = typeof kept === 'number' ? kept : null
941  const keptSet = await $.store.get('set').catch(() => null)
942  set = typeof keptSet === 'string' && keptSet ? keptSet : CLAWD_SET
943  summaryOn = ANSWERS && (await $.store.get('summary').catch(() => null)) !== false
944  coverOn = ANSWERS && (await $.store.get('cover').catch(() => null)) === true
945  mixes = keptMixes(await $.store.get('mixes').catch(() => null))
946  const keptNames = await $.store.get('marketNames').catch(() => null)
947  marketNames = keptNames && typeof keptNames === 'object' ? keptNames : {}
948  void $.command.register({ name: 'textless', description: 'textless on or off in this chat.' }).catch(() => undefined)
949  // The scene pick and set are shared by every chat pane: one picked in a pane reaches the others through the store,
950  // read here every 2 s. On or off stays per pane.
951  $.clock.every(2000, () => { void syncSettings($).catch(() => undefined); void pullSettings($).catch(() => undefined) })
952}
953
954async function syncSettings($: any) {
955  const keptPick = await $.store.get('pick').catch(() => null)
956  const nextPick = typeof keptPick === 'number' ? keptPick : null
957  const keptSet = await $.store.get('set').catch(() => null)
958  const nextSet = typeof keptSet === 'string' && keptSet ? keptSet : CLAWD_SET
959  if (nextPick === pick && nextSet === set) return
960  const setMoved = nextSet !== set
961  pick = nextPick
962  set = nextSet
963  if (setMoved) await loadBuiltIn($)
964  $.ui.invalidate('ui.render')
965}
966
967// A hidden row takes no height: an empty Text still drew a line plus the row's gap, so hidden steps stacked up empty
968// space under the card, scrolled it out of view and left a hole above the answer.
969function blank($: any, e: any) {
970  const { Box } = $.ui.resolve(e)
971  return <Box height={0} overflow="hidden" />
972}
973
974// Background agents can outlive the turn that started them; past this the card goes even if one never reports back.
975const AGENT_WAIT_MS = 10 * 60 * 1000
976
977// A text block is part of the turn's final answer: the app draws a long answer as several blocks.
978function inAnswer(chat: Chat, text: string) {
979  const t = text.trim().replace(/\s+/g, ' ').slice(0, 200)
980  return t.length > 0 && chat.answer.replace(/\s+/g, ' ').includes(t)
981}
982
983// A text block is part of an answer a Stop hook sent back (and the shorter versions of it drawn while it streamed).
984function isKept(chat: Chat, text: string) {
985  const t = text.trim().replace(/\s+/g, ' ').slice(0, 200)
986  return t.length > 0 && chat.kept.some(k => k.includes(t))
987}
988
989// Done cards live in memory, so a reload of the plugin (an update, a restart) would drop them and the answer would
990// show bare, with its steps and in-between text back. Each chat keeps its last turns in the store and reads them
991// back the first time it draws after a reload.
992type SavedTurn = { d: Done; tools: string[]; texts: string[] }
993const SAVED_TURNS = 30
994const loadedDone = new Set<string>()
995
996async function saveDone($: any, index: number) {
997  const id = String(await $.session.id().catch(() => ''))
998  if (!id) return
999  const tools = [...foldedTools].filter(([, i]) => i === index).map(([k]) => k)
1000  const texts = [...foldedTexts].filter(([, i]) => i === index).map(([k]) => k).filter(t => t.length <= 4000)
1001  const kept = ((await $.store.get(`done:${id}`).catch(() => null)) as SavedTurn[] | null) ?? []
1002  const turn: SavedTurn = { d: { ...done[index], open: false, labels: done[index].labels.slice(0, 40) }, tools, texts }
1003  await $.store.set(`done:${id}`, [...(Array.isArray(kept) ? kept : []), turn].slice(-SAVED_TURNS)).catch(() => undefined)
1004}
1005
1006async function loadDone($: any) {
1007  const id = String(await $.session.id().catch(() => ''))
1008  if (!id || loadedDone.has(id)) return
1009  loadedDone.add(id)
1010  const kept = (await $.store.get(`done:${id}`).catch(() => null)) as SavedTurn[] | null
1011  if (!Array.isArray(kept)) return
1012  for (const t of kept) {
1013    if (!t?.d || (t.d.answer && doneAnswers.has(t.d.answer))) continue
1014    const index = done.length
1015    done.push({ ...t.d, open: false })
1016    if (t.d.answer) doneAnswers.set(t.d.answer, index)
1017    for (const k of t.tools ?? []) if (!foldedTools.has(k)) foldedTools.set(k, index)
1018    for (const k of t.texts ?? []) if (!foldedTexts.has(k)) foldedTexts.set(k, index)
1019  }
1020  if (kept.length) $.ui.invalidate('ui.render')
1021}
1022
1023// Cover mode is on for this chat: textless is on here and the setting is on.
1024// Like the work card, only a turn that ran long enough for the card is covered: a quick answer leaves the chat as it is.
1025const covering = (chat: Chat) => chat.on && coverOn && !chat.uncovered && (chat.working ? chat.long : !!chat.cover)
1026async function saveCover($: any, chat: Chat) {
1027  const id = String(await $.session.id().catch(() => ''))
1028  if (id) await $.store.set(`cover:${id}`, chat.cover ?? null)
1029}
1030// After a reload the chat's cover is read back once, so the card stays where it was.
1031async function loadCover($: any, chat: Chat) {
1032  if (chat.cover !== undefined) return
1033  const id = String(await $.session.id().catch(() => ''))
1034  const kept = id ? ((await $.store.get(`cover:${id}`).catch(() => null)) as { host?: unknown; word?: unknown } | null) : null
1035  chat.cover = kept && typeof kept.host === 'string' && typeof kept.word === 'string' ? { host: kept.host, word: kept.word, ...(Array.isArray((kept as any).asks) ? { asks: (kept as any).asks.map(String).slice(0, 5) } : {}), ...(Array.isArray((kept as any).recs) ? { recs: (kept as any).recs.map(String).slice(0, 5) } : {}) } : null
1036}
1037// The big card that covers a finished chat: the word in large pixels, and the way back to the chat.
1038function coverCard($: any, e: any, chat: Chat, drawn: { chat: Chat; id: string }) {
1039  const { Box, Text, Button, Svg } = $.ui.resolve(e) as Record<string, any>
1040  const word = chat.cover?.word ?? '▶ YOUR TURN'
1041  const said = word.slice(2).toLowerCase().replace(/^./, c => c.toUpperCase())
1042  // Shown with the chat above it, the card stays at the bottom, so the chat's end stays where the view already is.
1043  const open = !!chat.uncovered
1044  return (
1045    <Box key="textless-cover" flexDirection="column" alignItems="center" justifyContent="center" gap={1} paddingY={open ? 2 : 6}>
1046      {chat.title ? <Text key="textless-cover-title" dimColor wrap="truncate">{chat.title}</Text> : null}
1047      {Svg
1048        ? <Svg source={pixelWord(word, '#d97757')} alt={said} width={pixelWidth(word) * 3} height={PIXEL_H * 3} />
1049        : <Text color="#d97757" bold>{said}</Text>}
1050      {/* What Claude asks, drawn as under answers: an orange ? and the question, an orange letter per option. */}
1051      {(chat.cover?.asks ?? []).length
1052        ? <Box key="textless-cover-asks" flexDirection="column" gap={1} paddingX={2}>
1053            {(chat.cover?.asks ?? []).map((text, i, all) => {
1054              const c = choices(text)
1055              return (
1056                <Box key={`textless-cover-ask-${i}`} flexDirection="row" alignItems="flex-start" gap={1}>
1057                  {all.length > 1 ? <Text color="#d97757" bold>{`${i + 1}`}</Text> : null}
1058                  {Svg
1059                    ? <Svg source={badgeSvg('?', '#d97757')} alt="Question" width={9 * BADGE_PX} height={9 * BADGE_PX} />
1060                    : <Text color="#d97757">?</Text>}
1061                  <Box flexDirection="column">
1062                    <Text bold wrap="wrap">{c.stem || text}</Text>
1063                    {chat.cover?.recs?.[i] ? recommendedLine($, e, chat.cover.recs[i]) : null}
1064                    {c.options.map((o, j) => (
1065                      <Box key={`textless-cover-ask-${i}-${j}`} flexDirection="row" gap={1}>
1066                        <Text color="#d97757" bold>{String.fromCharCode(65 + j)}</Text>
1067                        <Text wrap="wrap">{o}</Text>
1068                      </Box>
1069                    ))}
1070                  </Box>
1071                </Box>
1072              )
1073            })}
1074          </Box>
1075        : null}
1076      {open ? null : <Text dimColor>The chat is hidden while textless covers it.</Text>}
1077      <Button key="textless-cover-show" plain label={open ? 'Hide the chat' : 'Show the chat'} onPress={() => {
1078        drawn.chat.uncovered = !open
1079        $.ui.invalidate('ui.render')
1080      }} />
1081    </Box>
1082  )
1083}
1084
1085// Planning: a spec, plan or notes being written (a document edited), or Claude leaving plan mode. The person reads
1086// those turns, so folding them away hides what they are asked about.
1087const DOC = /\.(md|mdx|markdown|txt|rst|adoc)$/i
1088export function planningStep(tool: string, input: any): boolean {
1089  if (/^(ExitPlanMode|EnterPlanMode)$/.test(tool)) return true
1090  if (!/^(Write|Edit|MultiEdit|NotebookEdit)$/.test(tool)) return false
1091  return DOC.test(String(input?.file_path ?? input?.path ?? input?.notebook_path ?? ''))
1092}
1093// The turn shows as Claude Code draws it from here on: the card goes, nothing folds, and it is not covered.
1094function stepAside($: any, chat: Chat, id: string) {
1095  if (!chat.working) return
1096  dbg($, `planning detected at ${id.slice(-8)}: textless steps aside for this turn`)
1097  chat.working = false
1098  chat.long = false
1099  chat.cover = null
1100  chat.planKey = id
1101  chat.timer?.cancel()
1102  chat.timer = null
1103  void saveCover($, chat).catch(() => undefined)
1104  void Promise.resolve().then(() => $.ui.invalidate('ui.render'))
1105}
1106// The small label on the step that showed the planning.
1107function planLabel($: any, e: any, inner: unknown) {
1108  const { Box, Text } = $.ui.resolve(e) as Record<string, any>
1109  return (
1110    <Box flexDirection="column">
1111      {inner as never}
1112      <Box key="textless-planning" flexDirection="row" gap={1}>
1113        <Text color="#d97757">✎ Planning detected</Text>
1114        <Text dimColor>textless shows this turn as it is, so you can read the plan.</Text>
1115      </Box>
1116    </Box>
1117  )
1118}
1119
1120async function keepTitle($: any, raw: unknown) {
1121  const title = String(raw ?? '').trim()
1122  if (title) (await chatOf($)).title = title.slice(0, 80)
1123}
1124
1125function endTurn($: any, chat: Chat) {
1126  dbg($, `endTurn working=${chat.working} long=${chat.long} steps=${chat.steps.length}`)
1127  // A turn the card covered stays folded once it is done: only its answer shows.
1128  chat.newestAt = -1
1129  if (chat.working && chat.long && chat.steps.length > 0) {
1130    const index = done.length
1131    chat.newestAt = index
1132    const answer = chat.lastText.trim() ? chat.lastText : ''
1133    done.push({ first: chat.steps[chat.steps.length - 1], steps: chat.steps.length, ms: chat.durationMs, open: false, answer, scene: sceneIndex(chat), stopped: chat.stopped, labels: chat.steps.map(id => stepLabels.get(id) ?? 'step') })
1134    if (answer) doneAnswers.set(answer, index)
1135    for (const id of chat.steps) foldedTools.set(id, index)
1136    // The answer is the turn's last text block (and the shorter versions of it drawn while it streamed), and any block
1137    // found in the final answer, which the app splits into several blocks when long. Only the text said along the way
1138    // folds.
1139    for (const t of chat.turnTexts) if (t.trim() && !chat.lastText.startsWith(t) && !inAnswer(chat, t) && !isKept(chat, t)) foldedTexts.set(t, index)
1140    void saveDone($, index).catch(() => undefined)
1141  }
1142  if (chat.working && !chat.long) {
1143    chat.cover = null
1144    void saveCover($, chat).catch(() => undefined)
1145  } else if (chat.working) {
1146    const only = asks(chat.answer) && questions(chat.answer).every(q => q.asks)
1147    chat.cover = { host: chat.lastText, word: chat.stopped ? '■ STOPPED' : only ? '? NEED YOUR INPUT' : '▶ YOUR TURN', asks: questions(chat.answer).filter(q => q.asks).map(q => q.text).slice(0, 5), recs: questions(chat.answer).filter(q => q.asks).map(q => recommendation(chat.answer, q.raw || q.text)).slice(0, 5) }
1148    void saveCover($, chat).catch(() => undefined)
1149  }
1150  chat.turnTexts = new Set<string>()
1151  chat.kept = []
1152  chat.lastText = ''
1153  chat.running = false
1154  chat.working = false
1155  chat.long = false
1156  chat.mainDone = false
1157  chat.agents.clear()
1158  chat.timer?.cancel()
1159  chat.ticker?.cancel()
1160  chat.timer = chat.ticker = null
1161  $.ui.invalidate('ui.render')
1162}
1163
1164// A finished turn's rows: its first step's row says what was done and opens the rest; its other rows draw nothing
1165// while folded. Rows of turns textless did not cover draw as usual.
1166// A finished turn with no answer to hang the card under (often a stopped one): its last step's row draws the card's
1167// row itself. Its other rows draw nothing.
1168async function folded($: any, e: any, next: any, ids: string[]) {
1169  const at = ids.map(id => foldedTools.get(id)).find(i => i !== undefined)
1170  if (at === undefined) return next(e)
1171  const d = done[at]
1172  if (!ids.includes(d.first) || d.answer) return blank($, e)
1173  return doneRow($, e, at)
1174}
1175
1176function isFolded(ids: string[]) {
1177  return ids.some(id => {
1178    const at = foldedTools.get(id)
1179    return at !== undefined && !done[at].open
1180  })
1181}
1182
1183function unfold($: any, at: number) {
1184  done[at].open = !done[at].open
1185  $.ui.invalidate('ui.render')
1186}
1187
1188// A 5x7 pixel font for the few words the cards say in pixels, drawn bold (each stroke doubled) with a darker drop
1189// one pixel down; each pixel PIXEL_PX square. '#' is ink.
1190const GLYPHS: Record<string, string[]> = {
1191  D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
1192  O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
1193  N: ['#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#', '#...#'],
1194  E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
1195  S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
1196  Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'],
1197  U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
1198  T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
1199  P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
1200  R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
hooks/art.ts 227 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 192 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/** The scene's first frame as pixels: a grid of the viewBox's units. Groups that start hidden are skipped, groups an
65 * opacity animation starts at 1 are shown; transforms and animations are otherwise ignored. */
66export function sceneGrid(svg: string): { w: number; h: number; px: Pixel[] } | null {
67  const root = svg.match(/<svg\b([^>]*)>/)
68  if (!root) return null
69  const vb = attr(root[1], 'viewBox')?.trim().split(/[\s,]+/).map(Number)
70  const w = Math.round(vb?.[2] ?? Number(attr(root[1], 'width')))
71  const h = Math.round(vb?.[3] ?? Number(attr(root[1], 'height')))
72  if (!w || !h || w > 1024 || h > 1024) return null
73  const px: Pixel[] = Array.from({ length: w * h }, () => ({ r: 0, g: 0, b: 0, a: 0 }))
74  const paint = (x0: number, y0: number, bw: number, bh: number, rgb: [number, number, number], alpha: number) => {
75    for (let y = Math.max(0, Math.round(y0)); y < Math.min(h, Math.round(y0 + bh)); y++) {
76      for (let x = Math.max(0, Math.round(x0)); x < Math.min(w, Math.round(x0 + bw)); x++) {
77        const p = px[y * w + x]
78        const out = alpha + p.a * (1 - alpha)
79        if (out <= 0) continue
80        p.r = (rgb[0] * alpha + p.r * p.a * (1 - alpha)) / out
81        p.g = (rgb[1] * alpha + p.g * p.a * (1 - alpha)) / out
82        p.b = (rgb[2] * alpha + p.b * p.a * (1 - alpha)) / out
83        p.a = out
84      }
85    }
86  }
87  // One entry per open <g>: shown or not, and its opacity.
88  const stack: { shown: boolean; opacity: number }[] = []
89  const hidden = () => stack.some(g => !g.shown)
90  const opacity = () => stack.reduce((o, g) => o * g.opacity, 1)
91  for (const m of svg.matchAll(/<(\/?)([A-Za-z][\w:-]*)([^>]*?)(\/?)>/g)) {
92    const closing = m[1] === '/'
93    const name = m[2]
94    const attrs = m[3]
95    if (name === 'g') {
96      if (closing) stack.pop()
97      else if (m[4] !== '/') {
98        const o = attr(attrs, 'opacity')
99        stack.push({ shown: o === undefined || Number(o) > 0, opacity: o === undefined ? 1 : Number(o) || 0 })
100      }
101      continue
102    }
103    if (closing) continue
104    if (name === 'animate' && attr(attrs, 'attributeName') === 'opacity' && stack.length) {
105      // Its first value is how the group starts.
106      const first = Number((attr(attrs, 'values') ?? '').split(';')[0])
107      const top = stack[stack.length - 1]
108      if (!Number.isNaN(first)) {
109        top.shown = first > 0
110        top.opacity = first > 0 ? 1 : 0
111      }
112      continue
113    }
114    if (hidden()) continue
115    const rgb = colour(attr(attrs, 'fill'))
116    if (!rgb) continue
117    const alpha = Math.min(1, Number(attr(attrs, 'fill-opacity') ?? 1) * Number(attr(attrs, 'opacity') ?? 1) * opacity())
118    if (alpha <= 0) continue
119    if (name === 'rect') {
120      const rw = Number(attr(attrs, 'width'))
121      const rh = Number(attr(attrs, 'height'))
122      if (rw > 0 && rh > 0) paint(Number(attr(attrs, 'x') ?? 0), Number(attr(attrs, 'y') ?? 0), rw, rh, rgb, alpha)
123    } else if (name === 'path') {
124      for (const [bx, by, bw, bh] of pathBoxes(attr(attrs, 'd') ?? '')) paint(bx, by, bw, bh, rgb, alpha)
125    }
126  }
127  return px.some(p => p.a > 0) ? { w, h, px } : null
128}
129
130const base64 = (bytes: Uint8Array) => {
131  const abc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
132  let out = ''
133  for (let i = 0; i < bytes.length; i += 3) {
134    const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
135    out += abc[(n >> 18) & 63] + abc[(n >> 12) & 63] + (i + 1 < bytes.length ? abc[(n >> 6) & 63] : '=') + (i + 2 < bytes.length ? abc[n & 63] : '=')
136  }
137  return out
138}
139
140const DEFAULT = 0x01000000
141const rgbInt = (p: Pixel) => (Math.round(p.r) << 16) | (Math.round(p.g) << 8) | Math.round(p.b)
142
143/** The scene as terminal cells: shrunk by whole pixels to fit `maxCols` columns and `maxRows` rows, two pixel rows to a
144 * row of half blocks. Null when the scene has nothing this can read (a quote, a picture). */
145export function sceneCells(svg: string, maxCols: number, maxRows: number): { columns: number; rows: number; cells: string } | null {
146  const grid = sceneGrid(svg)
147  if (!grid) return null
148  const k = Math.max(1, Math.ceil(grid.w / Math.max(8, maxCols)), Math.ceil(grid.h / (Math.max(4, maxRows) * 2)))
149  const columns = Math.ceil(grid.w / k)
150  const pixelRows = Math.ceil(grid.h / k)
151  const rows = Math.ceil(pixelRows / 2)
152  // Each shrunk pixel averages its block, weighted by how much of it is painted.
153  const small: Pixel[] = []
154  for (let y = 0; y < rows * 2; y++) {
155    for (let x = 0; x < columns; x++) {
156      let r = 0
157      let g = 0
158      let b = 0
159      let a = 0
160      let n = 0
161      for (let dy = 0; dy < k; dy++) {
162        for (let dx = 0; dx < k; dx++) {
163          const sx = x * k + dx
164          const sy = y * k + dy
165          if (sx >= grid.w || sy >= grid.h) continue
166          const p = grid.px[sy * grid.w + sx]
167          n++
168          a += p.a
169          r += p.r * p.a
170          g += p.g * p.a
171          b += p.b * p.a
172        }
173      }
174      small.push(a > 0 ? { r: r / a, g: g / a, b: b / a, a: a / Math.max(1, n) } : { r: 0, g: 0, b: 0, a: 0 })
175    }
176  }
177  const words = new Uint32Array(columns * rows * 3)
178  const solid = (p: Pixel) => p.a >= 0.5
179  for (let row = 0; row < rows; row++) {
180    for (let col = 0; col < columns; col++) {
181      const top = small[row * 2 * columns + col]
182      const bottom = small[(row * 2 + 1) * columns + col]
183      const i = (row * columns + col) * 3
184      if (solid(top) && solid(bottom)) { words[i] = 0x2580; words[i + 1] = rgbInt(top); words[i + 2] = rgbInt(bottom) }
185      else if (solid(top)) { words[i] = 0x2580; words[i + 1] = rgbInt(top); words[i + 2] = DEFAULT }
186      else if (solid(bottom)) { words[i] = 0x2584; words[i + 1] = rgbInt(bottom); words[i + 2] = DEFAULT }
187      else { words[i] = 0x20; words[i + 1] = DEFAULT; words[i + 2] = DEFAULT }
188    }
189  }
190  return { columns, rows, cells: base64(new Uint8Array(words.buffer)) }
191}
192
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