An unofficial fan mod: a cozy pixel-art band above the prompt where Clawd acts out what Claude is doing, beside a scene of session meters

<h1 align="center">Cozy Clawd</h1>
<img src="docs/images/band.gif" alt="The Cozy Clawd band: Clawd thinks, reads, runs a command, edits, and writes, then juggles while it waits, beside a balcony with a watering can, a daisy, a bird feeder, and a jar of honey">
<b>A cozy pixel-art band above the prompt in Claude Code.</b><br> Clawd acts out what Claude is doing, beside a little scene of your session's context, prompt cache, and usage limits.
<img src="https://img.shields.io/badge/Claude_Code-2.1.287%2B-D97757" alt="Claude Code 2.1.287 or later"> <img src="https://img.shields.io/badge/license-MIT-788C5D" alt="MIT license">
Cozy Clawd is a mod for Claude Code: a plugin whose code runs inside Claude Code and draws on its screen. It adds a band of pixel art above the prompt, in the Code tab of the Claude desktop app and in the terminal. On the left, Clawd acts out each step of a turn. On the right, four objects work as meters of your session: the context left, the minutes left on the prompt cache, and what's left of your five-hour and weekly usage limits.
Cozy Clawd is an unofficial fan project. It isn't affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, and Clawd is Anthropic's character; the MIT License covers this project's code, not Anthropic's marks.
Cozy Clawd needs the following:
Note: A mod is code that runs inside Claude Code with your permissions. For what Cozy Clawd reads, keeps, and runs, see What the mod does on your machine. To list what it hooks and calls, clone the repository and run
claude plugin validate .from its root; it passes with a warning aboutCLAUDE.mdat the plugin root, which is expected. For more information, see Decide whether to trust a mod.
Installing takes two steps: add orihuelaconde, the plugin marketplace that lists OrihuelaConde's plugins, and then install the cozy-clawd plugin from it. You install the mod once for your user account: the terminal and the desktop app on one computer share installed plugins, so the band appears in both. Follow the steps for where you use Claude Code.
To install the mod, run the following commands in a terminal, such as PowerShell on Windows or Terminal on macOS:
claude plugin marketplace add OrihuelaConde/claude-plugins
claude plugin install cozy-clawd@orihuelaconde
The first command prints Successfully added marketplace: orihuelaconde, and the second prints Successfully installed plugin: cozy-clawd@orihuelaconde (scope: user). The band appears in the next session you start. To load the mod in a session that's already open, run /reload-plugins in it.
To install from inside a Claude Code session instead, run /plugin install cozy-clawd --marketplace OrihuelaConde/claude-plugins, press y to add the marketplace, and then select Install for you (user scope).
If the install fails with Host key verification failed, see The install fails with "Host key verification failed".
In the Claude desktop app, you add the marketplace and install the mod from the Plugins page. To install the mod, do the following:
OrihuelaConde/claude-plugins, and then select Sync.cozy. Next to Cozy clawd, open the arrow beside Add, and then select the option that installs to ~/.claude/, which makes the mod available in all your projects./reload-plugins in the session that's open. The band appears above the prompt.By default, Claude Code doesn't update the mod. To update it, run the following command:
claude plugin update cozy-clawd@orihuelaconde
Then start a new session, or run /reload-plugins in a session that's already open.
To install updates automatically, run /plugin in a terminal session, go to the Marketplaces tab, select orihuelaconde, and then select Enable auto-update.
To turn the mod off and keep it installed, run claude plugin disable cozy-clawd@orihuelaconde. To uninstall it, run claude plugin uninstall cozy-clawd@orihuelaconde.
Each step of a turn has its own scene, and each family of tools has its own prop. Beside Clawd, the band says what Claude is doing, in the tool's own words when it knows the tool, such as "Reading a file…" or "Searching the code…".
<table> <tr> <td align="center"><img src="docs/images/clawd/thinking.gif" width="198" alt="Thought dots rise to a light bulb"><br><b>Thinking</b><br>Thought dots light a bulb</td> <td align="center"><img src="docs/images/clawd/requesting.gif" width="198" alt="Clawd stacks colored blocks"><br><b>Working on the answer</b><br>Clawd stacks blocks</td> <td align="center"><img src="docs/images/clawd/responding.gif" width="198" alt="Clawd types, a hand at a time, while lines of text appear"><br><b>Writing the answer</b><br>Lines of text appear</td> </tr> <tr> <td align="center"><img src="docs/images/clawd/tool-look.gif" width="198" alt="A magnifying glass sweeps over a page"><br><b>Reading files</b><br>A magnifier sweeps a page</td> <td align="center"><img src="docs/images/clawd/tool-write.gif" width="198" alt="A pencil writes line after line"><br><b>Editing files</b><br>A pencil writes</td> <td align="center"><img src="docs/images/clawd/tool-shell.gif" width="198" alt="Clawd types at a monitor with green code raining down"><br><b>Running a command</b><br>Code rains on a monitor</td> </tr> <tr> <td align="center"><img src="docs/images/clawd/tool-web.gif" width="198" alt="A desk globe turns on its stand"><br><b>Browsing the web</b><br>A desk globe turns</td> <td align="center"><img src="docs/images/clawd/tool-agent.gif" width="198" alt="A small Clawd runs off while the big one waves"><br><b>Launching a subagent</b><br>A small Clawd runs off</td> <td align="center"><img src="docs/images/clawd/tool-other.gif" width="198" alt="Clawd hammers away"><br><b>Any other tool</b><br>Clawd hammers away</td> </tr> <tr> <td align="center"><img src="docs/images/clawd/compacting.gif" width="198" alt="Loose sheets are pressed into a golden block"><br><b>Compacting</b><br>Sheets pressed into a block</td> <td align="center"><img src="docs/images/clawd/compacted.gif" width="198" alt="Clawd hops for joy among sparkles"><br><b>Compacted</b><br>A hop for joy</td> <td align="center"><img src="docs/images/clawd/waiting.gif" width="198" alt="Clawd rests, then looks around, whistles, juggles, plays with a yo-yo, blows bubbles, reads, dances, and follows a ladybug"><br><b>Waiting</b><br>Rest and pastimes</td> </tr> <tr> <td align="center"><img src="docs/images/clawd/worried.gif" width="198" alt="Clawd frets with a drop of sweat"><br><b>Cache running out</b><br>A drop of sweat</td> <td align="center"><img src="docs/images/clawd/yawning.gif" width="198" alt="Clawd yawns"><br><b>Cache almost out</b><br>A yawn</td> <td align="center"><img src="docs/images/clawd/sleeping.gif" width="198" alt="Clawd sleeps with z's drifting up"><br><b>Cache expired</b><br>Asleep</td> </tr> </table>
Between turns, Clawd rests, breathing slowly, blinking, and glancing around. For 9 seconds of every 39, it takes up a pastime picked at random: looking around, whistling, juggling, playing with a yo-yo, blowing soap bubbles, reading, dancing, or following a ladybug. While Claude waits for your approval or your answer, Clawd goes through its pastimes one after another, in random order, with a short rest between them.
When 10 minutes or less are left on an hour's prompt cache, Clawd frets with a drop of sweat. With 2 minutes or less, it yawns. A five-minute cache gets the same shares of its life: 50 and 10 seconds. Once the cache expires, Clawd falls asleep.
The right side of the band shows four meters of your session, each an object with its number underneath in a pixel font. From left to right, they show the following:
The cache meter counts down the length Claude Code keeps the cache for: an hour when you sign in with a Claude subscription, and five minutes with an API key or once you use up a usage limit. With an API key, Claude Code reports no usage limits, so the two usage-limit meters show --. For more information, see What the meters measure.
Each scene draws the meters as objects of its own. The images in this README show the balcony scene, which the band shows until you choose another: a watering can, a daisy in a pot, a bird feeder, and a jar of honey. For all eight, see Choose a scene.
When 25% or less of the context is left, a Compact button appears next to the scene. It asks you to confirm, and then compacts the conversation. While Claude compacts, the context meter fills up again: on the balcony, it rains into the watering can.
<img src="docs/images/compact.gif" alt="With 18% of the context left, the Compact button asks for confirmation; while the conversation is compacted, it rains into the watering can, and Clawd hops for joy">
The /cozy-clawd panel holds the mod's settings. A change applies at once, and later sessions start with it.
| Setting | Choices | Default | Details |
|---|---|---|---|
| Scene | Eight scenes | balcony | Scenes |
| Size, in a terminal only | Small or Large | Small | The band in a terminal |
| Language | Ten languages, or Automatic | Automatic | Languages |
To choose a scene, open the /cozy-clawd panel and select Use under the scene you want. In a terminal, the panel has a Scene picker instead. You can also switch the scene from the prompt with the following command:
/cozy-clawd-scene SCENE_NAME
Replace SCENE_NAME with the name of a scene, such as mate. To see the current scene and the names available, run /cozy-clawd-scene with no name.
<table> <tr> <td align="center"><a href="docs/scenes.md#balcony"><img src="docs/images/scenes/balcony.gif" width="288" alt="A watering can, a daisy, a bird feeder, and a jar of honey on a balcony"></a><br><b>Balcony</b> <code>balcony</code> (default)</td> <td align="center"><a href="docs/scenes.md#mate"><img src="docs/images/scenes/mate.gif" width="288" alt="A thermos, a mate, a plate of medialunas, and a pack of yerba on a sky blue and white checked tablecloth"></a><br><b>Mate</b> <code>mate</code></td> </tr> <tr> <td align="center"><a href="docs/scenes.md#teatime"><img src="docs/images/scenes/teatime.gif" width="288" alt="A mug of tea, a brass clock with an orange timer disc, a cookie jar, and a box of tea bags on a wooden shelf"></a><br><b>Teatime</b> <code>teatime</code></td> <td align="center"><a href="docs/scenes.md#night-window"><img src="docs/images/scenes/window.gif" width="288" alt="A candle, a window with the moon, a cup of cocoa, and a ball of yarn on a desk at night"></a><br><b>Night window</b> <code>window</code></td> </tr> <tr> <td align="center"><a href="docs/scenes.md#adventure"><img src="docs/images/scenes/adventure.gif" width="288" alt="A mana potion, an hourglass, a chest of gold, and a quiver on a stone floor"></a><br><b>Adventure</b> <code>adventure</code></td> <td align="center"><a href="docs/scenes.md#gamer"><img src="docs/images/scenes/gamer.gif" width="288" alt="A monitor with three hearts, an arcade cabinet, cans of energy drink, and a gamepad on a desk"></a><br><b>Gamer</b> <code>gamer</code></td> </tr> <tr> <td align="center"><a href="docs/scenes.md#cyberpunk"><img src="docs/images/scenes/cyberpunk.gif" width="288" alt="A power cell, a neon noodle sign, a stack of credit chips, and signal bars on a rainy rooftop"></a><br><b>Cyberpunk</b> <code>cyberpunk</code></td> <td align="center"><a href="docs/scenes.md#steampunk"><img src="docs/images/scenes/steampunk.gif" width="288" alt="A boiler, a pocket watch, a scuttle of coal, and an airship in a workshop"></a><br><b>Steampunk</b> <code>steampunk</code></td> </tr> </table>
For what each object shows, see Scenes.
A terminal can't show the images the desktop app draws, so there the mod paints each scene in block characters and animates it frame by frame. The scenes, the cache's countdown, and the Compact button work as they do in the desktop app.
In a terminal, the band comes in two sizes. The small size, the default, has drawings of its own and takes 5 rows and at least 75 columns. The large size shows the desktop app's drawings and takes 9 rows and at least 109 columns. To change the size, use the Size picker in the /cozy-clawd panel. In the following image, the small size is above and the large size is below:
<img src="docs/images/terminal.gif" alt="A terminal shows the band in two sizes as Clawd thinks, reads a file, runs a command, edits a file, writes, and juggles. Above, the small size: Clawd as on Claude Code's welcome screen, beside a small balcony with the meters' numbers as text. Below, the large size, with the desktop app's drawings in block characters">
For what each size shows, how the band fits a narrow terminal, and how to get true colors in Windows Terminal or tmux, see The band in a terminal.
Until October 6, 2026, the orihuelaconde marketplace listed the mod in a form that Claude Code on Windows clones over SSH, so the install failed for anyone without an SSH key for GitHub, with an error that ends in Host key verification failed. Since then, the marketplace lists the mod by its HTTPS address. If you added the marketplace before that date, refresh its listing, and then install the mod again. To do it in a terminal, run the following commands:
claude plugin marketplace update orihuelaconde
claude plugin install cozy-clawd@orihuelaconde
In the desktop app, refresh the marketplace from Customize > Plugins > Add > Manage marketplaces, and then install the mod again from the Discover tab.
Check the following:
claude --version./reload-plugins in it.claude plugin enable cozy-clawd@orihuelaconde.With fewer than 24-bit colors, Claude Code approximates the scenes' colors, which shifts them. To fix it, see Get true colors in Windows Terminal or Get true colors in tmux.
The terminal is too narrow, or too short, for the drawings. The small size needs 5 rows and at least 75 columns, and the large size needs 9 rows and at least 109 columns, plus a few columns while the Compact button shows. Enlarge the window, or choose the small size in the /cozy-clawd panel.
Cozy Clawd makes no network requests, reads and writes no files, and sets no environment variables. It keeps three choices in Claude Code's plugin store: the scene, the language, and the size. It watches each turn's events only to know what to draw, and passes them on unchanged. To find your language, it can run reg.exe query on Windows or defaults read elsewhere. For the full list, see What Cozy Clawd does on your machine.
To report a problem or suggest an idea, open an issue. The texts in languages other than English and Spanish are machine translations; to suggest a better one, open an issue or a pull request that edits the language's file in hooks/languages/. To work on the mod, see Contribute to Cozy Clawd.
Cozy Clawd is released under the MIT License.
hooks/register.tsx 2015 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionCompactResult, SessionUsage, Timer } from 'claude-code'
3
4import type { ClawdMode, Size, Stats } from '../types'
5import { smallScene, smallSvg } from './small'
6import type { ScenePick } from './small'
7import { DEFAULT_METER_SCENE, METER_SCENES, meterSceneNamed, metersAlt, numbersLine } from './scenes/index'
8import type { Meters } from './scenes/index'
9import { INK, LOW_AT } from './scenes/pixels'
10import { DEFAULT_LANG, ENGLISH_NAMES, LANG_NAMES, LANGS, langOf, TEXTS, wordsOf } from './language'
11import type { Lang, LangChoice, Words } from './language'
12import { columnsOf, isMeasuredShort, pictureOf, textCells } from './raster'
13import type { Picture } from './raster'
14
15const PANEL = 'clawd'
16// The slash commands: the panel, and the scene by name.
17const PANEL_COMMAND = 'cozy-clawd'
18const SCENE_COMMAND = 'cozy-clawd-scene'
19const mode = atom({ plugin: 'cozy-clawd', key: 'mode' } as const, 'idle')
20const tool = atom({ plugin: 'cozy-clawd', key: 'tool' } as const, null)
21// Claude Code keeps the prompt cache an hour or five minutes (cacheTtlOf);
22// an hour until the session says otherwise.
23const HOUR_S = 60 * 60
24const FIVE_MINUTES_S = 5 * 60
25const NO_STATS: Stats = { contextLeft: null, fiveHour: null, week: null, fiveHourResetsAt: null, weekResetsAt: null, cacheAt: null, cacheTtl: HOUR_S }
26const stats = atom({ plugin: 'cozy-clawd', key: 'stats' } as const, NO_STATS)
27const isConfirming = atom({ plugin: 'cozy-clawd', key: 'isConfirming' } as const, false)
28const redraws = atom({ plugin: 'cozy-clawd', key: 'redraws' } as const, 0)
29const sceneName = atom({ plugin: 'cozy-clawd', key: 'sceneName' } as const, DEFAULT_METER_SCENE.name)
30const langChoice = atom({ plugin: 'cozy-clawd', key: 'langChoice' } as const, 'auto')
31const sizeName = atom({ plugin: 'cozy-clawd', key: 'sizeName' } as const, 'small')
32
33const BODY = '#D97757'
34const EYE = '#1F1E1D'
35const SPARK = '#F5C26B'
36const DOT = '#B0AEA5'
37const HANDLE = '#A0785A'
38const STEEL = '#9A9A9A'
39const SHELL = '#E0453A'
40const WING = '#E8E6DC'
41const SKY = '#8FB8DE'
42const LEAF = '#9BC48A'
43
44// CSS pixels per sprite pixel across; a sprite pixel is twice as tall.
45const SCALE = 3
46// The scene's box in sprite pixels: room for props on the right and above.
47const VIEW_W = 25
48const VIEW_H = 8
49// Sprite rows above Clawd's head, where flying and floating props go.
50const HEADROOM = 3
51
52// Clawd as drawn on the terminal's welcome screen: 18 x 5 pixels, each twice
53// as tall as wide like a terminal's quarter-block cell. Coordinates are in
54// sprite pixels with Clawd's head at y 0; a prop's square pixel is 1 x 0.5.
55const px = (x: number, y: number, w = 1, h = 1) =>
56 `<rect x="${x}" y="${y}" width="${w}" height="${h}"/>`
57
58const sprite = (eyes: string) => `
59 <g class="body" fill="${BODY}">${px(3, 0, 12, 4)}</g>
60 <g class="arm-l" fill="${BODY}">${px(1, 2, 2, 1)}</g>
61 <g class="arm-r" fill="${BODY}">${px(15, 2, 2, 1)}</g>
62 <g class="legs-a" fill="${BODY}">${px(4, 4)}${px(11, 4)}</g>
63 <g class="legs-b" fill="${BODY}">${px(6, 4)}${px(13, 4)}</g>
64 <g class="eyes" fill="${EYE}">${eyes}</g>`
65
66const OPEN_EYES = px(5, 1) + px(12, 1)
67const CLOSED_EYES = px(5, 1.6, 1, 0.4) + px(12, 1.6, 1, 0.4)
68
69// A "z" three pixels wide and five half-pixels tall, its stroke on the diagonal.
70const zee = (x: number, y: number) =>
71 px(x, y, 3, 0.5) + px(x + 2, y + 0.5, 1, 0.5) + px(x + 1, y + 1, 1, 0.5) + px(x, y + 1.5, 1, 0.5) + px(x, y + 2, 3, 0.5)
72
73// What Clawd does in one mode. `body` stands in for the one sprite when the
74// scene draws several Clawds of its own (the pastimes, taking turns).
75type Scene = { label: Words; eyes?: string; extra: string; css: string; body?: string }
76
77const BLINK = `
78 @keyframes blink { 0%, 92% { opacity: 1; } 93%, 100% { opacity: 0; } }`
79
80// Hammering in three frames: raised, swinging, struck with sparks.
81const toolUse: Scene = {
82 label: wordsOf(t => t.states.working),
83 extra: `
84 <g class="up">
85 <g fill="${HANDLE}">${px(17, -1, 1, 3)}</g>
86 <g fill="${STEEL}">${px(16, -2, 3, 1)}</g>
87 </g>
88 <g class="mid">
89 <g fill="${HANDLE}">${px(17, 1.5, 1, 0.5)}${px(18, 1, 1, 0.5)}${px(19, 0.5, 1, 0.5)}</g>
90 <g fill="${STEEL}">${px(19.5, -0.5, 2, 1)}</g>
91 </g>
92 <g class="down">
93 <g fill="${HANDLE}">${px(17, 2, 3, 0.5)}</g>
94 <g fill="${STEEL}">${px(20, 1, 1, 3)}</g>
95 <g fill="${SPARK}">${px(22, 0.5, 1, 0.5)}${px(22, 3.5, 1, 0.5)}${px(23, 2, 1, 0.5)}</g>
96 </g>`,
97 css: `
98 .up { animation: up 0.7s steps(1) infinite; }
99 .mid { animation: mid 0.7s steps(1) infinite; }
100 .down { animation: down 0.7s steps(1) infinite; }
101 .arm-r { animation: lift 0.7s steps(1) infinite; }
102 .clawd { animation: thud 0.7s steps(1) infinite; }
103 @keyframes up { 0%, 39.9% { opacity: 1; } 40%, 100% { opacity: 0; } }
104 @keyframes mid { 0%, 39.9% { opacity: 0; } 40%, 54.9% { opacity: 1; } 55%, 100% { opacity: 0; } }
105 @keyframes down { 0%, 54.9% { opacity: 0; } 55%, 100% { opacity: 1; } }
106 @keyframes lift { 0%, 39.9% { transform: translate(0, -1px); } 40%, 54.9% { transform: translate(0, -0.5px); } 55%, 100% { transform: translate(0, 0); } }
107 @keyframes thud { 0%, 54.9% { transform: translate(0, 0); } 55%, 69.9% { transform: translate(0, 0.5px); } 70%, 100% { transform: translate(0, 0); } }`,
108}
109
110const scenes: Record<Exclude<ClawdMode, 'waiting'>, Scene> = {
111 // Nothing to do and the prompt cache expired: asleep, breathing slowly, z's
112 // drifting up. With the cache still warm Clawd passes the time instead.
113 idle: {
114 label: wordsOf(t => t.states.sleeping),
115 eyes: CLOSED_EYES,
116 extra: `
117 <g fill="${DOT}">
118 <g class="z1">${zee(18, -0.5)}</g>
119 <g class="z2">${zee(21, -2.5)}</g>
120 </g>`,
121 css: `
122 .clawd { animation: breathe 3s steps(1) infinite; }
123 .z1 { animation: drift 3s linear infinite; }
124 .z2 { animation: drift 3s linear 1.5s infinite; opacity: 0; }
125 @keyframes breathe { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(0, 0.25px); } }
126 @keyframes drift { 0% { opacity: 0; transform: translate(0, 0.5px); } 30% { opacity: 1; } 100% { opacity: 0; transform: translate(1px, -0.5px); } }`,
127 },
128 // A model request in flight: the engine hands the response over whole, so
129 // this covers waiting and generating alike. Clawd stacks blocks one by one,
130 // lifting an arm to place each; the finished stack sparkles and clears.
131 requesting: {
132 label: wordsOf(t => t.states.workingOnAnswer),
133 extra: `
134 <g class="b1" fill="${SPARK}">${px(18, 4, 2, 1)}</g>
135 <g class="b2" fill="${SKY}">${px(20, 4, 2, 1)}</g>
136 <g class="b3" fill="${LEAF}">${px(19, 3, 2, 1)}</g>
137 <g class="done" fill="${WING}">${px(17, 2.5, 1, 0.5)}${px(22, 2, 1, 0.5)}${px(23, 3.5, 1, 0.5)}</g>`,
138 css: `
139 .eyes { transform: translate(1px, 0); }
140 .eyes rect { animation: blink 3s steps(1) infinite; }
141 .arm-r { animation: place 2.4s steps(1) infinite; }
142 .b1 { animation: drop1 2.4s steps(48) infinite; }
143 .b2 { animation: drop2 2.4s steps(48) infinite; }
144 .b3 { animation: drop3 2.4s steps(48) infinite; }
145 .done { animation: done 2.4s steps(1) infinite; }
146 @keyframes place {
147 0%, 11.9% { transform: translate(0, -1px); } 12%, 19.9% { transform: translate(0, 0); }
148 20%, 31.9% { transform: translate(0, -1px); } 32%, 39.9% { transform: translate(0, 0); }
149 40%, 51.9% { transform: translate(0, -1px); } 52%, 100% { transform: translate(0, 0); }
150 }
151 @keyframes drop1 { 0% { transform: translate(0, -5px); } 12%, 80% { transform: translate(0, 0); opacity: 1; } 81%, 100% { opacity: 0; } }
152 @keyframes drop2 { 0%, 19% { transform: translate(0, -5px); opacity: 0; } 20% { transform: translate(0, -5px); opacity: 1; } 32%, 80% { transform: translate(0, 0); opacity: 1; } 81%, 100% { opacity: 0; } }
153 @keyframes drop3 { 0%, 39% { transform: translate(0, -4px); opacity: 0; } 40% { transform: translate(0, -4px); opacity: 1; } 52%, 80% { transform: translate(0, 0); opacity: 1; } 81%, 100% { opacity: 0; } }
154 @keyframes done { 0%, 55.9% { opacity: 0; } 56%, 63.9% { opacity: 1; } 64%, 69.9% { opacity: 0; } 70%, 77.9% { opacity: 1; } 78%, 100% { opacity: 0; } }
155 ${BLINK}`,
156 },
157 // Compacting the conversation: three loose sheets are pressed together,
158 // Clawd's arm pushing down, until they are one small golden block.
159 compacting: {
160 label: wordsOf(t => t.states.compacting),
161 extra: `
162 <g class="sheets" fill="${WING}">
163 <g class="s1">${px(18, 1.5, 4, 0.5)}</g>
164 <g class="s2">${px(19, 2.5, 4, 0.5)}</g>
165 <g class="s3">${px(18, 3.5, 4, 0.5)}</g>
166 </g>
167 <g class="cube" fill="${SPARK}">${px(19, 3, 2, 1)}</g>
168 <g class="cube" fill="#FFFFFF" opacity="0.6">${px(19, 3, 1, 0.5)}</g>`,
169 css: `
170 .eyes { transform: translate(1px, 0.5px); }
171 .arm-r { animation: press 2.4s steps(1) infinite; }
172 .s1 { animation: s1 2.4s steps(1) infinite; }
173 .s2 { animation: s2 2.4s steps(1) infinite; }
174 .sheets { animation: sheets 2.4s steps(1) infinite; }
175 .cube { animation: cube 2.4s steps(1) infinite; }
176 @keyframes press { 0%, 34.9% { transform: translate(0, -1px); } 35%, 69.9% { transform: translate(0, 0.5px); } 70%, 100% { transform: translate(0, 0); } }
177 @keyframes s1 { 0%, 34.9% { transform: translate(0, 0); } 35%, 49.9% { transform: translate(0, 1px); } 50%, 100% { transform: translate(0, 2px); } }
178 @keyframes s2 { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(-1px, 1px); } }
179 @keyframes sheets { 0%, 69.9% { opacity: 1; } 70%, 100% { opacity: 0; } }
180 @keyframes cube { 0%, 69.9% { opacity: 0; } 70%, 100% { opacity: 1; } }`,
181 },
182 // A compaction just finished: Clawd hops twice for joy, arms up, among sparkles.
183 compacted: {
184 label: wordsOf(t => t.states.compacted),
185 eyes: px(5, 1, 1, 0.5) + px(12, 1, 1, 0.5),
186 extra: `
187 <g fill="${SPARK}">
188 <g class="k1">${px(17, -1, 1, 0.5)}${px(1, -1.5, 1, 0.5)}</g>
189 <g class="k2">${px(19, 1, 1, 0.5)}${px(0, 0.5, 1, 0.5)}</g>
190 <g class="k3">${px(18, -2.5, 1, 0.5)}${px(2, -2.5, 1, 0.5)}</g>
191 </g>`,
192 css: `
193 .clawd { animation: jump 1.2s steps(1) infinite; }
194 .arm-l, .arm-r { animation: cheer 1.2s steps(1) infinite; }
195 .k1 { animation: twinkle 0.6s steps(1) infinite; }
196 .k2 { animation: twinkle 0.6s steps(1) -0.2s infinite; }
197 .k3 { animation: twinkle 0.6s steps(1) -0.4s infinite; }
198 @keyframes jump { 0%, 19.9% { transform: translate(0, -1px); } 20%, 49.9% { transform: translate(0, 0); } 50%, 69.9% { transform: translate(0, -1px); } 70%, 100% { transform: translate(0, 0); } }
199 @keyframes cheer { 0%, 19.9% { transform: translate(0, -1px); } 20%, 49.9% { transform: translate(0, -0.5px); } 50%, 69.9% { transform: translate(0, -1px); } 70%, 100% { transform: translate(0, 0); } }
200 @keyframes twinkle { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }`,
201 },
202 // Thinking: two thought dots, then a light bulb that switches on.
203 thinking: {
204 label: wordsOf(t => t.states.thinking),
205 extra: `
206 <g fill="${DOT}">
207 <g class="d1">${px(16, 0, 1, 0.5)}</g>
208 <g class="d2">${px(17, -1, 1, 0.5)}</g>
209 </g>
210 <g class="bulb">
211 <g class="glass">${px(20, -2, 3, 0.5)}${px(19, -1.5, 5, 1)}${px(20, -0.5, 3, 0.5)}</g>
212 <g fill="${STEEL}">${px(20, 0, 3, 0.5)}${px(21, 0.5, 1, 0.5)}</g>
213 <g class="lit">
214 <g fill="#FFFFFF">${px(20, -1.5, 1, 0.5)}</g>
215 <g class="rays" fill="${SPARK}">${px(18, -2.5, 1, 0.5)}${px(24, -2.5, 1, 0.5)}${px(24, -0.5, 1, 0.5)}</g>
216 </g>
217 </g>`,
218 css: `
219 .eyes { transform: translate(1px, -0.5px); }
220 .eyes rect { animation: blink 3s steps(1) infinite; }
221 .d1 { animation: d1 3s steps(1) infinite; }
222 .d2 { animation: d2 3s steps(1) infinite; }
223 .bulb { animation: bulb 3s steps(1) infinite; }
224 .glass { animation: glass 3s steps(1) infinite; }
225 .lit { animation: lit 3s steps(1) infinite; }
226 .rays { animation: rays 0.3s steps(1) infinite; }
227 @keyframes d1 { 0%, 9.9% { opacity: 0; } 10%, 100% { opacity: 1; } }
228 @keyframes d2 { 0%, 24.9% { opacity: 0; } 25%, 100% { opacity: 1; } }
229 @keyframes bulb { 0%, 39.9% { opacity: 0; } 40%, 100% { opacity: 1; } }
230 @keyframes glass { 0%, 59.9% { fill: ${DOT}; } 60%, 100% { fill: ${SPARK}; } }
231 @keyframes lit { 0%, 59.9% { opacity: 0; } 60%, 100% { opacity: 1; } }
232 @keyframes rays { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0.3; } }
233 ${BLINK}`,
234 },
235 // The model is writing a tool call's arguments: already swinging.
236 'tool-input': { ...toolUse, label: wordsOf(t => t.states.preparingTool) },
237 'tool-use': { ...toolUse, label: wordsOf(t => t.states.usingTool) },
238 // Writing the answer: types away, a hand at a time, while lines of text
239 // appear beside it.
240 responding: {
241 label: wordsOf(t => t.states.writing),
242 extra: `
243 <g fill="${DOT}">
244 <g class="l1">${px(19, 0, 4, 0.5)}</g>
245 <g class="l2">${px(19, 1.25, 3, 0.5)}</g>
246 <g class="l3">${px(19, 2.5, 4, 0.5)}</g>
247 <g class="l4">${px(19, 3.75, 2, 0.5)}</g>
248 </g>`,
249 css: `
250 .arm-l { animation: typeL 0.3s steps(1) infinite; }
251 .arm-r { animation: typeR 0.3s steps(1) infinite; }
252 .eyes { animation: blink 2.6s steps(1) infinite; }
253 .l1 { animation: line1 2.4s steps(1) infinite; }
254 .l2 { animation: line2 2.4s steps(1) infinite; }
255 .l3 { animation: line3 2.4s steps(1) infinite; }
256 .l4 { animation: line4 2.4s steps(1) infinite; }
257 @keyframes typeL { 0%, 49.9% { transform: translate(0, -0.5px); } 50%, 100% { transform: translate(0, 0); } }
258 @keyframes typeR { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, -0.5px); } }
259 @keyframes line1 { 0%, 9.9% { opacity: 0; } 10%, 100% { opacity: 1; } }
260 @keyframes line2 { 0%, 29.9% { opacity: 0; } 30%, 100% { opacity: 1; } }
261 @keyframes line3 { 0%, 49.9% { opacity: 0; } 50%, 100% { opacity: 1; } }
262 @keyframes line4 { 0%, 69.9% { opacity: 0; } 70%, 100% { opacity: 1; } }
263 ${BLINK}`,
264 },
265}
266
267// A class that shows from `from`% of the cycle until 95%, then clears with the rest.
268const appear = (name: string, from: number) =>
269 `.${name} { animation: ${name} var(--cycle) steps(1) infinite; }
270 @keyframes ${name} { 0%, ${from - 0.1}% { opacity: 0; } ${from}%, 94.9% { opacity: 1; } 95%, 100% { opacity: 0; } }`
271
272// A sheet of paper beside Clawd, x 19 to 23, its top at y 1.
273const PAPER = `<g fill="${WING}">${px(19, 1, 5, 4)}</g>`
274
275// A round globe six pixels across, x 18 to 23, from y 0 to 3.
276const GLOBE = px(20, 0, 2, 0.5) + px(19, 0.5, 4, 0.5) + px(18, 1, 6, 1) + px(19, 2, 4, 0.5) + px(20, 2.5, 2, 0.5)
277
278// The metal ring around the globe's right half, one pixel out, from the north
279// pole over to the south pole, where it meets the stand.
280const MERIDIAN =
281 px(20, -0.5, 2, 0.5) + px(22, 0, 1, 0.5) + px(23, 0.5, 1, 0.5) + px(24, 1, 1, 1) + px(23, 2, 1, 0.5) + px(22, 2.5, 1, 0.5)
282
283// Land on one turn of the globe, as [x within the six columns, row of half a pixel].
284const LAND: [number, number][] = [
285 [1, 1], [2, 1], [0, 2], [1, 2], [1, 3], [2, 4],
286 [4, 1], [4, 2], [5, 2], [3, 3], [4, 3], [4, 4],
287]
288
289// The props each family of tools hands Clawd while it runs.
290type ToolKind = 'look' | 'write' | 'shell' | 'web' | 'agent' | 'wait' | 'other'
291
292const toolScenes: Record<Exclude<ToolKind, 'wait'>, Scene> = {
293 // Reading or searching: the page of the editing scene, already written, and a
294 // magnifying glass that sweeps down and up over it while Clawd watches.
295 look: {
296 label: wordsOf(t => t.states.reading),
297 extra: `
298 ${PAPER}
299 <g fill="${DOT}">${px(20, 1.5, 3, 0.5)}${px(20, 2.5, 2, 0.5)}${px(20, 3.5, 3, 0.5)}</g>
300 <g transform="translate(19 0)"><g class="scan">
301 <g fill="${HANDLE}">${px(-1, 2, 1, 0.5)}${px(-2, 2.5, 1, 0.5)}</g>
302 <g fill="${STEEL}">${px(0, 1.5, 1, 0.5)}</g>
303 <g fill="${SKY}" opacity="0.55">${px(1, 0.5, 2, 1)}</g>
304 <g fill="${STEEL}">${px(1, 0, 2, 0.5)}${px(1, 1.5, 2, 0.5)}${px(0, 0.5, 1, 1)}${px(3, 0.5, 1, 1)}</g>
305 <g fill="#FFFFFF">${px(1, 0.5, 1, 0.5)}</g>
306 </g></g>`,
307 css: `
308 .eyes { transform: translate(1px, 0.5px); }
309 .eyes rect { animation: blink 3s steps(1) infinite; }
310 .scan { animation: scan 2s steps(1) infinite; }
311 @keyframes scan {
312 0%, 24.9% { transform: translate(0, 0); } 25%, 49.9% { transform: translate(1px, 0.5px); }
313 50%, 74.9% { transform: translate(0, 1.5px); } 75%, 100% { transform: translate(1px, 2px); }
314 }
315 ${BLINK}`,
316 },
317 // Editing or writing a file: a pencil writes line after line on a page.
318 write: {
319 label: wordsOf(t => t.states.editing),
320 extra: `
321 ${PAPER}
322 <g fill="${EYE}">
323 <g class="i1">${px(19, 2, 1, 0.5)}</g><g class="i2">${px(20, 2, 1, 0.5)}</g><g class="i3">${px(21, 2, 1, 0.5)}</g>
324 <g class="i4">${px(19, 3, 1, 0.5)}</g><g class="i5">${px(20, 3, 1, 0.5)}</g><g class="i6">${px(21, 3, 1, 0.5)}</g>
325 <g class="i7">${px(19, 4, 1, 0.5)}</g><g class="i8">${px(20, 4, 1, 0.5)}</g>
326 </g>
327 <g transform="translate(19 1.5)"><g class="pencil">
328 <g fill="${EYE}">${px(0, 0, 1, 0.5)}</g>
329 <g fill="${SPARK}">${px(1, -0.5, 1, 0.5)}${px(2, -1, 1, 0.5)}</g>
330 <g fill="#E8A0A0">${px(3, -1.5, 1, 0.5)}</g>
331 </g></g>`,
332 css: `
333 svg { --cycle: 3s; }
334 .eyes { transform: translate(1px, 0.5px); }
335 .pencil { animation: pencil 3s steps(1) infinite; }
336 @keyframes pencil {
337 0%, 9.9% { transform: translate(0, 0); } 10%, 19.9% { transform: translate(1px, 0); } 20%, 29.9% { transform: translate(2px, 0); }
338 30%, 39.9% { transform: translate(0, 1px); } 40%, 49.9% { transform: translate(1px, 1px); } 50%, 59.9% { transform: translate(2px, 1px); }
339 60%, 69.9% { transform: translate(0, 2px); } 70%, 100% { transform: translate(1px, 2px); }
340 }
341 ${appear('i1', 10)} ${appear('i2', 20)} ${appear('i3', 30)}
342 ${appear('i4', 40)} ${appear('i5', 50)} ${appear('i6', 60)}
343 ${appear('i7', 70)} ${appear('i8', 80)}`,
344 },
345 // Running a command: Clawd types at a monitor where green code rains down,
346 // each column at its own pace: a bright head and a trail that fades.
347 shell: {
348 label: wordsOf(t => t.states.runningCommand),
349 extra: `
350 <defs><clipPath id="screen">${px(19, 0.5, 4, 2)}</clipPath></defs>
351 <g fill="${STEEL}">${px(18, 0, 6, 3)}${px(20, 3, 2, 1.5)}${px(19, 4.5, 4, 0.5)}</g>
352 <g fill="#0B140D">${px(19, 0.5, 4, 2)}</g>
353 <g clip-path="url(#screen)">
354 ${[19, 20, 21, 22].map(x => `<g class="rain c${x}">
355 <g fill="#0F4D20">${px(x, -1.5, 1, 0.5)}</g>
356 <g fill="#1E8A3C">${px(x, -1, 1, 0.5)}</g>
357 <g fill="#3FD46A">${px(x, -0.5, 1, 0.5)}</g>
358 <g fill="#D2FFD2">${px(x, 0, 1, 0.5)}</g>
359 </g>`).join('')}
360 </g>`,
361 css: `
362 .eyes { transform: translate(1px, 0); }
363 .arm-l { animation: tapL 0.3s steps(1) infinite; }
364 .arm-r { animation: tapR 0.3s steps(1) infinite; }
365 .rain { animation: rain 1.2s steps(7) infinite; }
366 .c19 { animation-duration: 1.1s; animation-delay: -0.3s; }
367 .c20 { animation-duration: 1.5s; animation-delay: -0.9s; }
368 .c21 { animation-duration: 0.9s; animation-delay: -0.1s; }
369 .c22 { animation-duration: 1.3s; animation-delay: -0.6s; }
370 @keyframes tapL { 0%, 49.9% { transform: translate(0, -0.5px); } 50%, 100% { transform: translate(0, 0); } }
371 @keyframes tapR { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, -0.5px); } }
372 @keyframes rain { from { transform: translate(0, 0); } to { transform: translate(0, 3.5px); } }`,
373 },
374 // On the web: a desk globe as tall as Clawd turns on its stand.
375 web: {
376 label: wordsOf(t => t.states.browsing),
377 extra: `
378 <defs><clipPath id="globe">${GLOBE}</clipPath></defs>
379 <g fill="${SKY}">${GLOBE}</g>
380 <g clip-path="url(#globe)"><g class="spin" fill="${LEAF}">
381 ${[0, 6].map(dx => LAND.map(([x, row]) => px(18 + dx + x, row * 0.5, 1, 0.5)).join('')).join('')}
382 </g></g>
383 <g fill="#FFFFFF" opacity="0.5">${px(19, 1, 1, 0.5)}</g>
384 <g fill="${STEEL}">${MERIDIAN}${px(20, 3, 2, 1.5)}${px(19, 4.5, 4, 0.5)}</g>`,
385 css: `
386 .eyes { transform: translate(1px, 0); }
387 .eyes rect { animation: blink 3s steps(1) infinite; }
388 .arm-r { transform: translate(0, -0.5px); }
389 .spin { animation: spin 2.4s steps(6) infinite; }
390 @keyframes spin { from { transform: translate(0, 0); } to { transform: translate(-6px, 0); } }
391 ${BLINK}`,
392 },
393 // Launching a subagent: a small Clawd runs off while the big one waves.
394 agent: {
395 label: wordsOf(t => t.states.launchingSubagent),
396 extra: `
397 <g transform="translate(17 3)"><g class="mini">
398 <g fill="${BODY}">${px(0, 0, 5, 1.5)}</g>
399 <g fill="${EYE}">${px(1, 0.5, 1, 0.5)}${px(3, 0.5, 1, 0.5)}</g>
400 <g class="mini-a" fill="${BODY}">${px(1, 1.5, 1, 0.5)}</g>
401 <g class="mini-b" fill="${BODY}">${px(3, 1.5, 1, 0.5)}</g>
402 </g></g>`,
403 css: `
404 .eyes { transform: translate(1px, 0); }
405 .arm-r { animation: wave 0.4s steps(1) infinite; }
406 .mini { animation: run 1.6s steps(1) infinite; }
407 .mini-a { animation: flap 0.2s steps(1) infinite; }
408 .mini-b { animation: flap 0.2s steps(1) infinite reverse; }
409 @keyframes wave { 0%, 49.9% { transform: translate(0, -1px); } 50%, 100% { transform: translate(0, 0); } }
410 @keyframes flap { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
411 @keyframes run {
412 0% { transform: translate(0, 0); opacity: 1; } 10% { transform: translate(1px, 0); } 20% { transform: translate(2px, 0); }
413 30% { transform: translate(3px, 0); } 40% { transform: translate(4px, 0); } 50% { transform: translate(5px, 0); }
414 60% { transform: translate(6px, 0); } 70% { transform: translate(7px, 0); } 80% { transform: translate(9px, 0); opacity: 1; }
415 80.1%, 100% { transform: translate(9px, 0); opacity: 0; }
416 }`,
417 },
418 // Any other tool: the hammer.
419 other: toolUse,
420}
421
422// Each tool by name: its family and what the band says while it runs.
423const TOOLS: Record<string, [ToolKind, Words]> = {
424 Read: ['look', wordsOf(t => t.tools.readingFile)],
425 Grep: ['look', wordsOf(t => t.tools.searchingCode)],
426 Glob: ['look', wordsOf(t => t.tools.findingFiles)],
427 LS: ['look', wordsOf(t => t.tools.lookingAtFolder)],
428 Edit: ['write', wordsOf(t => t.tools.editingFile)],
429 MultiEdit: ['write', wordsOf(t => t.tools.editingFile)],
430 NotebookEdit: ['write', wordsOf(t => t.tools.editingNotebook)],
431 Write: ['write', wordsOf(t => t.tools.writingFile)],
432 Bash: ['shell', wordsOf(t => t.states.runningCommand)],
433 PowerShell: ['shell', wordsOf(t => t.states.runningCommand)],
434 WebFetch: ['web', wordsOf(t => t.tools.readingWebPage)],
435 WebSearch: ['web', wordsOf(t => t.tools.searchingWeb)],
436 Agent: ['agent', wordsOf(t => t.states.launchingSubagent)],
437 Task: ['agent', wordsOf(t => t.states.launchingSubagent)],
438 AskUserQuestion: ['wait', wordsOf(t => t.states.waitingForAnswer)],
439 ExitPlanMode: ['wait', wordsOf(t => t.tools.waitingForPlan)],
440}
441
442// An MCP tool's own name, without the `mcp__<server>__` prefix.
443const shortName = (tool: string) => (tool.startsWith('mcp__') ? tool.split('__').slice(2).join('__') || tool : tool)
444
445// What the band shows for a mode, and for the tool when one runs. In the
446// waiting mode `tool` says what is awaited: `answer` (a form a connector
447// asked for) or anything else, a tool waiting for the person's approval.
448// While Clawd waits on the person it passes the time with one pastime after
449// another, from the moment the wait began (`since`) to `now`.
450// `pick` names the scene for the small size (hooks/small.ts).
451const sceneFor = (m: ClawdMode, tool: string | null, since = 0, now = since): { scene: Scene; label: Words; pick: ScenePick } => {
452 const turns = waitTurns(since, now)
453 if (m === 'waiting') {
454 return {
455 scene: roundScene(turns),
456 label: tool === 'answer' ? wordsOf(t => t.states.waitingForAnswer) : wordsOf(t => t.states.waitingForApproval),
457 pick: { kind: 'round', turns },
458 }
459 }
460 if ((m === 'tool-use' || m === 'tool-input') && tool !== null) {
461 const [kind, label] = TOOLS[tool] ?? ['other', wordsOf(t => t.tools.using(shortName(tool)))]
462 return kind === 'wait'
463 ? { scene: roundScene(turns), label, pick: { kind: 'round', turns } }
464 : { scene: toolScenes[kind], label, pick: { kind: 'tool', key: kind } }
465 }
466 const scene = scenes[m] ?? scenes.requesting
467 return { scene, label: scene.label, pick: { kind: 'mode', key: m } }
468}
469
470// What Clawd does as the prompt cache runs out, between waiting and sleeping.
471const cacheScenes: Record<'worry' | 'yawn', Scene> = {
472 // Clawd stretches its arms up in a big yawn, then nods off: a thirtieth of the
473 // cache's life or less is left, two minutes of an hour.
474 yawn: {
475 label: wordsOf(t => t.states.yawning),
476 eyes: `<g class="drowsy">${px(5, 1.5, 1, 0.5)}${px(12, 1.5, 1, 0.5)}</g><g class="shut">${CLOSED_EYES}</g>`,
477 extra: `
478 <g class="mouth" fill="${EYE}"><g class="gape">${px(8, 2, 2, 1)}</g><g class="ajar">${px(8, 2.5, 2, 0.5)}</g></g>
479 <g fill="${DOT}"><g class="z1">${zee(18, -0.5)}</g></g>`,
480 css: `
481 .arm-l, .arm-r { animation: stretchup 4s steps(1) infinite; }
482 .drowsy { animation: drowsy 4s steps(1) infinite; }
483 .shut { animation: shut 4s steps(1) infinite; }
484 .ajar { animation: ajar 4s steps(1) infinite; }
485 .gape { animation: gape 4s steps(1) infinite; }
486 .z1 { animation: doze 4s linear infinite; }
487 @keyframes stretchup { 0%, 14.9% { transform: translate(0, 0); } 15%, 44.9% { transform: translate(0, -1px); } 45%, 100% { transform: translate(0, 0); } }
488 @keyframes drowsy { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
489 @keyframes shut { 0%, 49.9% { opacity: 0; } 50%, 100% { opacity: 1; } }
490 @keyframes ajar { 0%, 14.9% { opacity: 0; } 15%, 19.9% { opacity: 1; } 20%, 39.9% { opacity: 0; } 40%, 44.9% { opacity: 1; } 45%, 100% { opacity: 0; } }
491 @keyframes gape { 0%, 19.9% { opacity: 0; } 20%, 39.9% { opacity: 1; } 40%, 100% { opacity: 0; } }
492 @keyframes doze { 0%, 59.9% { opacity: 0; transform: translate(0, 0.5px); } 70% { opacity: 1; } 100% { opacity: 0; transform: translate(1px, -0.5px); } }`,
493 },
494 // Clawd keeps an anxious eye on the scene, a drop of sweat running down its
495 // side: a sixth of the cache's life or less is left, ten
496 // minutes of an hour.
497 worry: {
498 label: wordsOf(t => t.states.worried),
499 extra: `<g class="sweat" fill="${SKY}">${px(15, 0, 1, 0.5)}</g>`,
500 css: `
501 .eyes { transform: translate(1px, 0); }
502 .eyes rect { animation: blink 2.4s steps(1) infinite; }
503 .clawd { animation: fret 0.8s steps(1) infinite; }
504 .sweat { animation: sweat 1.6s steps(4) infinite; }
505 @keyframes fret { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, 0.25px); } }
506 @keyframes sweat { 0% { opacity: 0; transform: translate(0, 0); } 25% { opacity: 1; } 100% { opacity: 0; transform: translate(0, 1.5px); } }
507 ${BLINK}`,
508 },
509}
510
511// One of the things Clawd does to pass the time between turns: its eyes, its
512// props and its motions, each rule of its css under its own class.
513type Pastime = { name: string; eyes?: string; extra: string; css: string }
514
515// A note of music: a head, a stem and a flag hanging off its top, the head's
516// left at (x, y).
517const note = (x: number, y: number) => px(x, y, 2, 0.5) + px(x + 1, y - 1.5, 1, 1.5) + px(x + 2, y - 1, 1, 0.5)
518
519// Keyframes that jump a thing from point to point, each held an equal share
520// of the cycle; the points are offsets from where it is drawn.
521const hops = (name: string, points: [number, number][]) =>
522 `@keyframes ${name} { ${points
523 .map(([x, y], i) => `${((i / points.length) * 100).toFixed(2)}% { transform: translate(${x}px, ${y}px); }`)
524 .join(' ')} 100% { transform: translate(${points[0]?.[0] ?? 0}px, ${points[0]?.[1] ?? 0}px); } }`
525
526// Where a juggled ball goes, as an offset from the left hand: up and over the
527// head to the right hand, then back low.
528const JUGGLE: [number, number][] = [
529 [0, 0], [1, -2], [2, -3.5], [5, -4.5], [8, -4.5], [11, -4.5], [13, -3.5], [14, -2], [15, 0], [12, -2.5], [7, -3], [3, -2.5],
530]
531
532// Where a soap bubble drifts from the wand's ring, up and to the right.
533const BUBBLE: [number, number][] = [[0, 0], [1, -0.5], [1, -1], [2, -1.5], [2, -2], [3, -2.5], [3, -3], [3, -3]]
534
535const PASTIMES: readonly Pastime[] = [
536 // Looks around, this way and that, tapping a foot.
537 {
538 name: 'gaze',
539 eyes: `<g class="gaze-look">${OPEN_EYES}</g>`,
540 extra: '',
541 css: `
542 .gaze .eyes rect { animation: blink 3s steps(1) infinite; }
543 .gaze .gaze-look { animation: gaze-look 4.5s steps(1) infinite; }
544 .gaze .legs-b { animation: gaze-tap 0.6s steps(1) infinite; }
545 @keyframes gaze-look {
546 0%, 24.9% { transform: translate(-1px, 0); } 25%, 37.9% { transform: translate(0, 0); }
547 38%, 64.9% { transform: translate(1px, 0); } 65%, 79.9% { transform: translate(1px, -0.5px); } 80%, 100% { transform: translate(0, 0); }
548 }
549 @keyframes gaze-tap { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, -0.5px); } }`,
550 },
551 // Whistles a tune, eyes half shut, notes floating off.
552 {
553 name: 'whistle',
554 eyes: px(5, 1.5, 1, 0.5) + px(12, 1.5, 1, 0.5) + px(9, 2.5, 1, 0.5),
555 extra: `
556 <g class="whistle-n1" fill="${SPARK}">${note(17, 1)}</g>
557 <g class="whistle-n2" fill="${SKY}">${note(19, 0)}</g>`,
558 css: `
559 .whistle .clawd { animation: whistle-bob 1.2s steps(1) infinite; }
560 .whistle-n1 { animation: whistle-float 2.4s steps(4) infinite; }
561 .whistle-n2 { animation: whistle-float 2.4s steps(4) -1.2s infinite; }
562 @keyframes whistle-bob { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, 0.5px); } }
563 @keyframes whistle-float { 0% { opacity: 0; transform: translate(0, 0.5px); } 25% { opacity: 1; } 75% { opacity: 1; } 100% { opacity: 0; transform: translate(2px, -1.5px); } }`,
564 },
565 // Juggles three balls over its head, the hands taking turns.
566 {
567 name: 'juggle',
568 eyes: `<g class="juggle-up">${OPEN_EYES}</g>`,
569 extra: `
570 <g class="juggle-b1" fill="${SHELL}">${px(1, 1.5, 1, 0.5)}</g>
571 <g class="juggle-b2" fill="${SKY}">${px(1, 1.5, 1, 0.5)}</g>
572 <g class="juggle-b3" fill="${LEAF}">${px(1, 1.5, 1, 0.5)}</g>`,
573 css: `
574 .juggle-up { transform: translate(0, -0.5px); }
575 .juggle .arm-l { animation: juggle-pump 0.6s steps(1) infinite; }
576 .juggle .arm-r { animation: juggle-pump 0.6s steps(1) -0.3s infinite; }
577 .juggle-b1 { animation: juggle-ball 1.8s steps(1) infinite; }
578 .juggle-b2 { animation: juggle-ball 1.8s steps(1) -0.6s infinite; }
579 .juggle-b3 { animation: juggle-ball 1.8s steps(1) -1.2s infinite; }
580 @keyframes juggle-pump { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, -0.5px); } }
581 ${hops('juggle-ball', JUGGLE)}`,
582 },
583 // Plays with a yo-yo: down on its string, a spin at the bottom, back up.
584 {
585 name: 'yoyo',
586 eyes: `<g class="yoyo-look">${OPEN_EYES}</g>`,
587 extra: `
588 <g class="yoyo-string" fill="${WING}">${px(17, 1.5, 1, 2.5)}</g>
589 <g class="yoyo-toy">
590 <g fill="${SHELL}">${px(16, 2, 3, 1)}</g>
591 <g class="yoyo-spin" fill="${WING}">${px(16, 2, 1, 0.5)}</g>
592 <g class="yoyo-spin2" fill="${WING}">${px(18, 2.5, 1, 0.5)}</g>
593 </g>`,
594 css: `
595 .yoyo .arm-r { transform: translate(0, -1px); }
596 .yoyo-look { animation: yoyo-look 2s steps(1) infinite; }
597 .yoyo-toy { animation: yoyo-drop 2s steps(1) infinite; }
598 .yoyo-string { animation: yoyo-reel 2s steps(1) infinite; }
599 .yoyo-spin { animation: yoyo-spin 0.3s steps(1) infinite; }
600 .yoyo-spin2 { animation: yoyo-spin 0.3s steps(1) -0.15s infinite; }
601 @keyframes yoyo-drop {
602 0%, 9.9% { transform: translate(0, 0); } 10%, 19.9% { transform: translate(0, 0.5px); } 20%, 29.9% { transform: translate(0, 1px); }
603 30%, 39.9% { transform: translate(0, 1.5px); } 40%, 59.9% { transform: translate(0, 2px); } 60%, 69.9% { transform: translate(0, 1.5px); }
604 70%, 79.9% { transform: translate(0, 1px); } 80%, 89.9% { transform: translate(0, 0.5px); } 90%, 100% { transform: translate(0, 0); }
605 }
606 @keyframes yoyo-reel {
607 0%, 9.9% { clip-path: inset(0 0 2px 0); } 10%, 19.9% { clip-path: inset(0 0 1.5px 0); } 20%, 29.9% { clip-path: inset(0 0 1px 0); }
608 30%, 39.9% { clip-path: inset(0 0 0.5px 0); } 40%, 59.9% { clip-path: inset(0 0 0 0); } 60%, 69.9% { clip-path: inset(0 0 0.5px 0); }
609 70%, 79.9% { clip-path: inset(0 0 1px 0); } 80%, 89.9% { clip-path: inset(0 0 1.5px 0); } 90%, 100% { clip-path: inset(0 0 2px 0); }
610 }
611 @keyframes yoyo-look { 0%, 29.9% { transform: translate(1px, 0); } 30%, 69.9% { transform: translate(1px, 0.5px); } 70%, 100% { transform: translate(1px, 0); } }
612 @keyframes yoyo-spin { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }`,
613 },
614 // Blows soap bubbles through a wand and watches them drift off.
615 {
616 name: 'bubbles',
617 eyes: `<g class="bubbles-look">${OPEN_EYES}</g>`,
618 extra: `
619 <g fill="${HANDLE}">${px(17, 2, 1, 0.5)}${px(18, 1.5, 1, 0.5)}</g>
620 <g fill="${STEEL}">${px(19, 0.5, 1, 0.5)}${px(20, 0, 1, 0.5)}${px(21, 0.5, 1, 0.5)}${px(20, 1, 1, 0.5)}</g>
621 <g class="bubbles-b1"><g fill="${SKY}" opacity="0.6">${px(20, 0, 2, 1)}</g><g fill="#FFFFFF" opacity="0.8">${px(20, 0, 1, 0.5)}</g></g>
622 <g class="bubbles-b2" fill="${SKY}" opacity="0.8">${px(20, 0.5, 1, 0.5)}</g>
623 <g class="bubbles-b3" fill="${SKY}" opacity="0.8">${px(20, 0.5, 1, 0.5)}</g>`,
624 css: `
625 .bubbles-look { animation: bubbles-look 3s steps(1) infinite; }
626 .bubbles-b1 { animation: bubbles-rise 3s steps(1) infinite, bubbles-pop 3s steps(1) infinite; }
627 .bubbles-b2 { animation: bubbles-rise 3s steps(1) -1s infinite, bubbles-pop 3s steps(1) -1s infinite; }
628 .bubbles-b3 { animation: bubbles-rise 3s steps(1) -2s infinite, bubbles-pop 3s steps(1) -2s infinite; }
629 ${hops('bubbles-rise', BUBBLE)}
630 @keyframes bubbles-pop { 0%, 12.4% { opacity: 0; } 12.5%, 87.4% { opacity: 1; } 87.5%, 100% { opacity: 0; } }
631 @keyframes bubbles-look { 0%, 39.9% { transform: translate(1px, 0); } 40%, 100% { transform: translate(1px, -0.5px); } }`,
632 },
633 // Reads a book held out to the right, now and then turning a page.
634 {
635 name: 'read',
636 eyes: `<g class="read-eyes">${OPEN_EYES}</g>`,
637 extra: `
638 <g fill="${SHELL}">${px(17, 3, 5, 0.5)}</g>
639 <g fill="${WING}">${px(17, 1, 2, 2)}${px(20, 1, 2, 2)}</g>
640 <g fill="${DOT}">${px(17, 1.5, 2, 0.5)}${px(17, 2.5, 1, 0.5)}${px(20, 1.5, 2, 0.5)}${px(20, 2.5, 2, 0.5)}${px(19, 1, 1, 2)}</g>
641 <g fill="${WING}">
642 <g class="read-p1">${px(20, 0.5, 1, 2)}</g>
643 <g class="read-p2">${px(19, 0, 1, 2)}</g>
644 <g class="read-p3">${px(18, 0.5, 1, 2)}</g>
645 </g>`,
646 css: `
647 .read-eyes { transform: translate(1px, 0.5px); }
648 .read .eyes rect { animation: blink 3.5s steps(1) infinite; }
649 .read .clawd { animation: read-nod 3s steps(1) infinite; }
650 .read-p1 { animation: read-p1 3s steps(1) infinite; }
651 .read-p2 { animation: read-p2 3s steps(1) infinite; }
652 .read-p3 { animation: read-p3 3s steps(1) infinite; }
653 @keyframes read-nod { 0%, 69.9% { transform: translate(0, 0); } 70%, 100% { transform: translate(0, 0.25px); } }
654 @keyframes read-p1 { 0%, 79.9% { opacity: 0; } 80%, 84.9% { opacity: 1; } 85%, 100% { opacity: 0; } }
655 @keyframes read-p2 { 0%, 84.9% { opacity: 0; } 85%, 89.9% { opacity: 1; } 90%, 100% { opacity: 0; } }
656 @keyframes read-p3 { 0%, 89.9% { opacity: 0; } 90%, 94.9% { opacity: 1; } 95%, 100% { opacity: 0; } }`,
657 },
658 // Dances: sways side to side, arms up by turns, notes twinkling above.
659 {
660 name: 'dance',
661 eyes: px(5, 1, 1, 0.5) + px(12, 1, 1, 0.5),
662 extra: `
663 <g class="dance-n1" fill="${SPARK}">${note(1, -1.5)}</g>
664 <g class="dance-n2" fill="${LEAF}">${note(17, -1)}</g>`,
665 css: `
666 .dance .clawd { animation: dance-sway 1s steps(1) infinite; }
667 .dance .arm-l { animation: dance-arm 1s steps(1) infinite; }
668 .dance .arm-r { animation: dance-arm 1s steps(1) -0.5s infinite; }
669 .dance .legs-a { animation: dance-step 0.5s steps(1) infinite; }
670 .dance .legs-b { animation: dance-step 0.5s steps(1) -0.25s infinite; }
671 .dance-n1 { animation: dance-twinkle 1s steps(1) infinite; }
672 .dance-n2 { animation: dance-twinkle 1s steps(1) -0.5s infinite; }
673 @keyframes dance-sway { 0%, 49.9% { transform: translate(-1px, 0); } 50%, 100% { transform: translate(1px, 0); } }
674 @keyframes dance-arm { 0%, 49.9% { transform: translate(0, -1px); } 50%, 100% { transform: translate(0, 0); } }
675 @keyframes dance-step { 0%, 49.9% { transform: translate(0, -0.5px); } 50%, 100% { transform: translate(0, 0); } }
676 @keyframes dance-twinkle { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }`,
677 },
678 // Follows a ladybug that flies past overhead, bobbing.
679 {
680 name: 'ladybug',
681 eyes: `<g class="ladybug-follow">${OPEN_EYES}</g>`,
682 extra: `
683 <g class="ladybug-flight">
684 <g fill="${SHELL}">${px(1, 0, 3, 0.5)}${px(0, 0.5, 5, 1)}${px(1, 1.5, 3, 0.5)}</g>
685 <g fill="${EYE}">${px(1, 0.5, 1, 0.5)}${px(3, 0.5, 1, 0.5)}${px(2, 1, 1, 0.5)}</g>
686 <g fill="${STEEL}">${px(5, 0.5, 1, 1)}</g>
687 <g class="ladybug-wings" fill="${WING}" opacity="0.8">${px(1, -0.5, 1, 0.5)}${px(3, -0.5, 1, 0.5)}</g>
688 </g>`,
689 css: `
690 .ladybug .clawd { animation: ladybug-bob 1.2s steps(1) infinite; }
691 .ladybug .ladybug-follow { animation: ladybug-follow 4s steps(1) infinite; }
692 .ladybug .ladybug-flight { animation: ladybug-fly 4s steps(48) infinite; }
693 .ladybug .ladybug-wings { animation: ladybug-flap 0.2s steps(1) infinite; }
694 @keyframes ladybug-bob { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(0, 0.5px); } }
695 @keyframes ladybug-follow {
696 0%, 24.9% { transform: translate(-1px, -0.5px); }
697 25%, 49.9% { transform: translate(0, -0.5px); }
698 50%, 79.9% { transform: translate(1px, -0.5px); }
699 80%, 100% { transform: translate(0, 0); }
700 }
701 @keyframes ladybug-fly {
702 0% { transform: translate(-6px, -2.5px); }
703 20% { transform: translate(1px, -3px); }
704 40% { transform: translate(8px, -2.5px); }
705 60% { transform: translate(15px, -3px); }
706 80% { transform: translate(26px, -2.5px); }
707 100% { transform: translate(26px, -2.5px); }
708 }
709 @keyframes ladybug-flap { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }`,
710 },
711]
712
713// Clawd at rest between pastimes: breathing slowly, blinking now and then,
714// glancing one way and the other. It asks for no attention.
715const REST: Pastime = {
716 name: 'rest',
717 eyes: `<g class="rest-look">${OPEN_EYES}</g>`,
718 extra: '',
719 css: `
720 .rest .clawd { animation: rest-breathe 4s steps(1) infinite; }
721 .rest .eyes rect { animation: rest-blink 5s steps(1) infinite; }
722 .rest .rest-look { animation: rest-look 13s steps(1) infinite; }
723 @keyframes rest-breathe { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, 0.25px); } }
724 @keyframes rest-blink { 0%, 95.9% { opacity: 1; } 96%, 100% { opacity: 0; } }
725 @keyframes rest-look {
726 0%, 54.9% { transform: translate(0, 0); } 55%, 67.9% { transform: translate(1px, 0); }
727 68%, 79.9% { transform: translate(0, 0); } 80%, 91.9% { transform: translate(-1px, 0); } 92%, 100% { transform: translate(0, 0); }
728 }`,
729}
730
731// How long Clawd rests before a pastime, and how long the pastime lasts.
732const REST_S = 30
733const PASTIME_S = 9
734
735// How many times each pastime comes up in one round, in a shuffled order.
736const ROUND_SHUFFLES = 2
737
738// A number in [0, 1) for each whole number, the same every time
739// (mulberry32): the round's order and a wait's pastime come from it, so a
740// redraw keeps them as they were.
741const chance = (n: number) => {
742 let t = Math.imul(n + 1, 0x6d2b79f5) >>> 0
743 t = Math.imul(t ^ (t >>> 15), t | 1)
744 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
745 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
746}
747
748// The pastimes' order in round `round`: each of them `ROUND_SHUFFLES` times,
749// shuffled, never the same one twice in a row, the round's last and first
750// included.
751const roundOrder = (round: number) => {
752 const order: number[] = []
753 for (let k = 0; k < ROUND_SHUFFLES; k++) {
754 const deck = PASTIMES.map((_, i) => i)
755 for (let i = deck.length - 1; i > 0; i--) {
756 const j = Math.floor(chance(round * 100 + k * 10 + i) * (i + 1))
757 ;[deck[i], deck[j]] = [deck[j] ?? 0, deck[i] ?? 0]
758 }
759 if (deck[0] === order[order.length - 1]) {
760 ;[deck[0], deck[1]] = [deck[1] ?? 0, deck[0] ?? 0]
761 }
762 order.push(...deck)
763 }
764 if (order[order.length - 1] === order[0]) {
765 const n = order.length
766 ;[order[n - 1], order[n - 2]] = [order[n - 2] ?? 0, order[n - 1] ?? 0]
767 }
768 return order
769}
770
771// A group of the round that shows in `windows` (from and to, in seconds of
772// the cycle) and is hidden the rest of it.
773const windowsCss = (name: string, windows: [number, number][], cycle: number, at: number) => {
774 const pct = (s: number) => `${((s / cycle) * 100).toFixed(3)}%`
775 const frames = windows.map(([from, to]) => `${pct(from)} { opacity: 1; } ${pct(to)} { opacity: 0; }`).join(' ')
776 return `.turn-${name} { animation: turn-${name} ${cycle}s steps(1) -${at.toFixed(1)}s infinite; }
777 @keyframes turn-${name} { 0% { opacity: 0; } ${frames} 100% { opacity: 0; } }`
778}
779
780// Clawd in a group of its own: its pose, its eyes and its props.
781const pastimeBody = (p: Pastime, turn = '') =>
782 `<g class="${turn} ${p.name}"><g class="clawd">${sprite(p.eyes ?? OPEN_EYES)}</g>${p.extra}</g>`
783
784// One pastime by itself, as the preview shows it and as Clawd waits on the person.
785const pastimeScene = (p: Pastime): Scene => ({
786 label: wordsOf(t => t.states.waiting),
787 body: pastimeBody(p),
788 extra: '',
789 css: `${p.css}
790 ${BLINK}`,
791})
792
793// A round of rest and pastimes, all in one image so no redraw is needed to
794// change them: `turns` says when each shows.
795const roundScene = (turns: string): Scene => ({
796 label: wordsOf(t => t.states.waiting),
797 body: [pastimeBody(REST, `turn-${REST.name}`), ...PASTIMES.map(p => pastimeBody(p, `turn-${p.name}`))].join(''),
798 extra: '',
799 css: `${turns}
800 ${[REST, ...PASTIMES].map(p => p.css).join('')}
801 ${BLINK}`,
802})
803
804// How long a round lasts, in seconds, with `restS` of rest before each pastime.
805const roundLength = (restS: number) => PASTIMES.length * ROUND_SHUFFLES * (restS + PASTIME_S)
806
807// A round's CSS `atS` seconds into it, the pastimes in `order`, each after
808// `restS` of rest: when each of the rest and the pastimes shows, the same in
809// either size.
810const roundTurns = (atS: number, order: readonly number[], restS: number) => {
811 const slot = restS + PASTIME_S
812 const cycle = roundLength(restS)
813 const rests = order.map((_, k): [number, number] => [k * slot, k * slot + restS])
814 const turns = PASTIMES.map((_, i) =>
815 order.flatMap((p, k): [number, number][] => (p === i ? [[k * slot + restS, (k + 1) * slot]] : [])),
816 )
817 const at = atS % cycle
818 return `${windowsCss(REST.name, rests, cycle, at)}
819 ${PASTIMES.map((p, i) => windowsCss(p.name, turns[i] ?? [], cycle, at)).join('\n ')}`
820}
821
822// Clawd between turns with the prompt cache warm: at rest, and every so
823// often a pastime picked at random. The round goes by the clock (`atS`, in
824// seconds), so a redraw takes it up where it was rather than from the start.
825const waitingTurns = (atS: number) => roundTurns(atS, roundOrder(Math.floor(atS / roundLength(REST_S))), REST_S)
826const waitingScene = (atS: number): Scene => roundScene(waitingTurns(atS))
827
828// How long Clawd rests between pastimes while it waits on the person.
829const WAIT_REST_S = 3
830
831// While Clawd waits on the person it passes the time with one pastime after
832// another, a short rest between them, from the moment the wait began
833// (`since`, in milliseconds, which also picks their order) to `now`. It
834// starts with a pastime rather than a rest.
835const waitTurns = (since: number, now: number) =>
836 roundTurns(WAIT_REST_S + Math.max(0, now - since) / 1000, roundOrder(Math.floor(since / 1000)), WAIT_REST_S)
837
838// Each scene enters with a small hop of Clawd while its props fade in, so a
839// change of mode reads as a transition rather than a cut.
840const ENTER = `
841 .enter { animation: enter 0.3s steps(3) 1 both; }
842 .props { animation: fadein 0.4s ease-out 1 both; }
843 @keyframes enter { 0% { transform: translate(0, 0.5px); } 50% { transform: translate(0, -0.5px); } 100% { transform: translate(0, 0); } }
844 @keyframes fadein { from { opacity: 0; } to { opacity: 1; } }`
845
846// The sprite's y is stretched by two, so the viewBox is twice as tall in units.
847const svgFor = (scene: Scene) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEW_W} ${VIEW_H * 2}" shape-rendering="crispEdges">
848 <style>
849 g { transform-box: view-box; }
850 ${ENTER}
851 ${scene.css}
852 </style>
853 <g transform="translate(0 ${HEADROOM * 2}) scale(1 2)">
854 <g class="enter">${scene.body ?? `<g class="clawd">${sprite(scene.eyes ?? OPEN_EYES)}</g>`}</g>
855 <g class="props">${scene.extra}</g>
856 </g>
857</svg>`
858
859// Each mode stays on screen at least this long: the engine hands a response's
860// text over in one burst, often right before a tool call, so without a floor
861// a mode can last a few milliseconds and never be seen.
862const MIN_MS = 1500
863
864// The compact button shows once this much of the context, or less, is free:
865// where the scenes show the context low.
866const COMPACT_AT = LOW_AT
867
868// The picks of the panel's language row, in its order.
869const LANG_CHOICES: readonly LangChoice[] = ['auto', ...LANGS]
870
871type Shown = { mode: ClawdMode; tool: string | null }
872
873// The module's own copy of what is shown, so a stream writes state only on a change.
874let current: Shown = { mode: 'idle', tool: null }
875let shownAt = 0
876let pending: Shown | null = null
877let timer: Timer | null = null
878
879// Whether the main turn runs: from its first model request until it completes.
880let isTurnRunning = false
881
882const same = (a: Shown, b: Shown) => a.mode === b.mode && a.tool === b.tool
883
884async function apply($: EngineInterface, next: Shown) {
885 current = next
886 shownAt = await $.clock.now()
887 await update($, tool, () => next.tool)
888 await update($, mode, () => next.mode)
889}
890
891// Applies what the last call asked for once the current one has had its time.
892async function settle($: EngineInterface) {
893 timer = null
894 const next = pending
895 pending = null
896 if (next !== null && !same(next, current)) {
897 await apply($, next)
898 }
899}
900
901async function show($: EngineInterface, nextMode: ClawdMode, nextTool: string | null = null) {
902 const next: Shown = { mode: nextMode, tool: nextTool }
903 if (nextMode === 'idle') {
904 timer?.cancel()
905 timer = null
906 pending = null
907 if (current.mode !== 'idle') {
908 await apply($, next)
909 }
910 return
911 }
912 if (timer !== null) {
913 pending = next
914 return
915 }
916 if (same(next, current)) {
917 return
918 }
919 const wait = shownAt + MIN_MS - (await $.clock.now())
920 if (wait > 0) {
921 pending = next
922 timer = $.clock.after(wait, () => settle($))
923 return
924 }
925 await apply($, next)
926}
927
928// With this share of the prompt cache's life left Clawd frets, ten minutes
929// of an hour; with this share, two minutes of an hour, it yawns.
930const worryS = (ttl: number) => ttl / 6
931const yawnS = (ttl: number) => ttl / 30
932
933// The meters as the session's state holds them; a state written by an
934// older version of the mod lacks what it did not keep.
935async function statsNow($: EngineInterface): Promise<Stats> {
936 return { ...NO_STATS, ...(await read($, stats)) }
937}
938
939// The cache's figure and minutes run down by themselves, and the meters of
940// the usage limits stay as they were read; the band is drawn again only as
941// the cache runs out (when Clawd starts to fret, when it yawns, and when the
942// cache expires, to show it gone) and as a limit's window starts over.
943// After a hot reload, which cancels the old module's timers, they are
944// scheduled again from the state, which stays.
945let statTimers: Timer[] = []
946
947async function scheduleRedraws($: EngineInterface, s: Stats) {
948 for (const t of statTimers) {
949 t.cancel()
950 }
951 statTimers = []
952 const now = await $.clock.now()
953 const cacheAt = s.cacheAt
954 const cacheSteps = cacheAt === null ? [] : [s.cacheTtl - worryS(s.cacheTtl), s.cacheTtl - yawnS(s.cacheTtl), s.cacheTtl].map(atS => cacheAt + atS * 1000)
955 for (const at of [...cacheSteps, s.fiveHourResetsAt, s.weekResetsAt]) {
956 if (at !== null && at > now) {
957 statTimers.push($.clock.after(at - now + 500, () => redrawBand($)))
958 }
959 }
960}
961
962// An environment variable Claude Code reads as on.
963const isOn = (value: string | undefined) => value !== undefined && /^(1|true|yes|on)$/i.test(value.trim())
964
965const ttlNamed = (value: unknown) => (value === '1h' ? HOUR_S : value === '5m' ? FIVE_MINUTES_S : null)
966
967// How long the prompt cache keeps the main conversation, in seconds, as
968// Claude Code picks it: five minutes when FORCE_PROMPT_CACHING_5M is on;
969// the one CLAUDE_CODE_PROMPT_CACHE_TTL, or else the promptCacheTtl setting,
970// names; an hour when ENABLE_PROMPT_CACHING_1H is on (on Bedrock,
971// ENABLE_PROMPT_CACHING_1H_BEDROCK). Otherwise an hour on a Claude
972// subscription within its usage limits and five minutes on anything else.
973// No event names the length itself (only a model switch's), so the mod
974// tells a subscription by its usage limits, which only a subscription
975// reports, and a request past them by a limit used up. Before any answer
976// of this session the limits have no reading yet: null, unknown.
977async function cacheTtlOf($: EngineInterface, usage: SessionUsage | null, isAnswer: boolean): Promise<number | null> {
978 if (isOn(await $.env.get('FORCE_PROMPT_CACHING_5M').catch(() => undefined))) {
979 return FIVE_MINUTES_S
980 }
981 const named =
982 ttlNamed(await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(() => undefined)) ??
983 ttlNamed((await $.settings.read().catch(() => ({}) as Record<string, unknown>)).promptCacheTtl)
984 if (named !== null) {
985 return named
986 }
987 const isBedrock = isOn(await $.env.get('CLAUDE_CODE_USE_BEDROCK').catch(() => undefined))
988 if (
989 isOn(await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(() => undefined)) ||
990 (isBedrock && isOn(await $.env.get('ENABLE_PROMPT_CACHING_1H_BEDROCK').catch(() => undefined)))
991 ) {
992 return HOUR_S
993 }
994 const limits = (usage?.rateLimits ?? []).filter(r => r.kind === 'five_hour' || r.kind === 'seven_day')
995 if (limits.length === 0) {
996 return isAnswer ? FIVE_MINUTES_S : null
997 }
998 return limits.some(r => r.percentUsed >= 100) ? FIVE_MINUTES_S : HOUR_S
999}
1000
1001// Whether Claude Code caches no prompt at all.
1002async function isCacheOff($: EngineInterface) {
1003 return isOn(await $.env.get('DISABLE_PROMPT_CACHING').catch(() => undefined))
1004}
1005
1006// The context left in a window the engine has no reading of yet (a new
1007// session, or one just compacted or cleared, until its next response): an
1008// estimate of what the next request will send, as /context makes it
1009// without asking the API; null where the host has none.
1010async function estimatedContextLeft($: EngineInterface, window: number) {
1011 const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => null)
1012 const tokens = usage?.context.breakdown?.totalTokens
1013 return tokens === undefined || window <= 0 ? null : Math.max(0, 100 - (tokens / window) * 100)
1014}
1015
1016// When a limit's window starts over, from the engine's timestamp.
1017const resetOf = (resetsAt: string | undefined) => {
1018 const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
1019 return Number.isNaN(at) ? null : at
1020}
1021
1022// Reads the context window and the usage limits as the engine last saw them,
1023// leaving the meters as they were when it can't. After an answer the cache
1024// starts over, as long as Claude Code says it keeps it, and its last minutes
1025// are scheduled anew.
1026async function refreshStats($: EngineInterface, isAnswer = false) {
1027 const usage = await $.session.usage().catch(() => null)
1028 const now = await $.clock.now()
1029 const ttl = isAnswer ? await cacheTtlOf($, usage, true) : null
1030 const isOff = isAnswer && (await isCacheOff($))
1031 const percent = usage?.context.percent
1032 const contextLeft = usage === null ? undefined : percent !== undefined ? 100 - percent : await estimatedContextLeft($, usage.context.window)
1033 const limit = (kind: string) => usage?.rateLimits.find(r => r.kind === kind)
1034 await update($, stats, old => {
1035 const s = { ...NO_STATS, ...old }
1036 return {
1037 ...s,
1038 ...(usage === null
1039 ? {}
1040 : {
1041 contextLeft: contextLeft ?? null,
1042 fiveHour: limit('five_hour')?.percentUsed ?? null,
1043 week: limit('seven_day')?.percentUsed ?? null,
1044 fiveHourResetsAt: resetOf(limit('five_hour')?.resetsAt),
1045 weekResetsAt: resetOf(limit('seven_day')?.resetsAt),
1046 }),
1047 ...(isAnswer ? { cacheAt: isOff ? null : now, cacheTtl: ttl ?? s.cacheTtl } : {}),
1048 }
1049 })
1050 await scheduleRedraws($, await statsNow($))
1051}
1052
1053// The conversation the session shows changed: after /clear its cache is not
1054// the old one's; a conversation resumed or forked was answered when the
1055// engine says, and its cache likely expired or not as the engine reckons it.
1056// From that the cache's length, unknown before an answer, may follow: still
1057// warm after more than five minutes, it keeps an hour; expired within the
1058// hour, five minutes.
1059async function restartCache($: EngineInterface, source: string, sinceS: number | undefined, isExpired: boolean | undefined) {
1060 const now = await $.clock.now()
1061 const usage = await $.session.usage().catch(() => null)
1062 const ruled = await cacheTtlOf($, usage, false)
1063 await update($, stats, old => {
1064 const s = { ...NO_STATS, ...old }
1065 if (source === 'clear' || sinceS === undefined) {
1066 return { ...s, cacheAt: null }
1067 }
1068 const told = isExpired === false && sinceS > FIVE_MINUTES_S ? HOUR_S : isExpired === true && sinceS < HOUR_S ? FIVE_MINUTES_S : null
1069 return { ...s, cacheAt: now - sinceS * 1000, cacheTtl: told ?? ruled ?? s.cacheTtl }
1070 })
1071 await scheduleRedraws($, await statsNow($))
1072}
1073
1074async function redrawBand($: EngineInterface) {
1075 await update($, redraws, n => n + 1)
1076}
1077
1078// How long Clawd hops for joy after a compaction.
1079const CELEBRATE_MS = 2400
1080
1081// After a compaction Clawd celebrates a moment, then sleeps, unless the turn
1082// the compaction was part of has moved on by then.
1083async function celebrate($: EngineInterface) {
1084 await show($, 'compacted')
1085 // A quick compaction leaves the celebration waiting out the compacting
1086 // scene's MIN_MS first; it lasts its while from when it shows.
1087 const wait = current.mode === 'compacted' ? 0 : Math.max(0, shownAt + MIN_MS - (await $.clock.now()))
1088 $.clock.after(wait + CELEBRATE_MS, () => endCelebration($))
1089}
1090
1091async function endCelebration($: EngineInterface) {
1092 if (current.mode === 'compacted' && timer === null) {
1093 await show($, 'idle')
1094 }
1095}
1096
1097// Whether a press of Yes is compacting already: a second press before the
1098// band draws again compacts nothing more.
1099let isCompactPressed = false
1100
1101// Compacting in the middle of a turn would cut it short: the button only
1102// says so, in the language of the band pressed.
1103async function compactNow($: EngineInterface, isWorking: boolean, lang: Lang) {
1104 if (isCompactPressed) {
1105 return
1106 }
1107 isCompactPressed = true
1108 try {
1109 await update($, isConfirming, () => false)
1110 if (isWorking) {
1111 $.ui.toast(TEXTS[lang].waitToCompact)
1112 return
1113 }
1114 // The call runs every session.compact hook but this plugin's own, so the
1115 // band shows the compaction from here.
1116 await whileCompacting($, () => $.session.compact())
1117 } finally {
1118 isCompactPressed = false
1119 }
1120}
1121
1122// Clawd compacting while `compact` runs, then celebrating once it is done,
1123// or back to idle if a hook skipped it. A compaction from anywhere answers
1124// the compact button's question.
1125async function whileCompacting($: EngineInterface, compact: () => Promise<SessionCompactResult>) {
1126 await update($, isConfirming, () => false)
1127 await show($, 'compacting')
1128 let isDone = false
1129 try {
1130 const result = await compact()
1131 isDone = result.skip === undefined
1132 return result
1133 } finally {
1134 // The fresh meters first, so the celebration shows the scene refilled.
1135 await refreshStats($)
1136 await (isDone ? celebrate($) : show($, 'idle'))
1137 }
1138}
1139
1140// Switches the scene the band draws on its right: in the session's state,
1141// which draws the band and the panel again, and in the plugin's store, which
1142// the next session starts from. Not a userConfig field: the desktop app lists
1143// no plugin rows in /config, so $.config.set cannot reach one there.
1144async function chooseMeterScene($: EngineInterface, name: string) {
1145 await update($, sceneName, () => name)
1146 await $.store.set('scene', name)
1147}
1148
1149// Whether the person had given the panel the keyboard, as it was last drawn or
1150// focused. On the desktop, a click on a Button of a panel without the keyboard
1151// only hands it the keyboard: `ui.focus` lands on the Button, and no press.
1152let isPanelFocused = false
1153
1154// The scene the last pick left in the store, for a new session.
1155async function loadMeterScene($: EngineInterface) {
1156 const stored = await $.store.get('scene').catch(() => undefined)
1157 if (typeof stored === 'string') {
1158 await update($, sceneName, () => meterSceneNamed(stored).name)
1159 }
1160}
1161
1162// The language Claude Code shows the person, as far as a plugin can see it;
1163// worked out once a session, and again when the Language row changes.
1164let detected: Lang | null = null
1165
1166// Claude Code hands a plugin neither the app's language nor its translated
1167// texts. The Language row of /config (the language Claude answers in) says
1168// it when the person set one; then the locale variables, in the order a
1169// program reads them; then the operating system's own setting, as a desktop
1170// app starts without those variables; English when none names a language
1171// the mod speaks. A read the host does not answer counts as unset.
1172async function detectLang($: EngineInterface): Promise<Lang> {
1173 const rows = await $.config.list().catch(() => [])
1174 const locales = [
1175 rows.find(row => row.key === 'language')?.value,
1176 await $.env.get('LC_ALL').catch(() => undefined),
1177 await $.env.get('LC_MESSAGES').catch(() => undefined),
1178 await $.env.get('LANG').catch(() => undefined),
1179 ]
1180
1181 return locales.map(langOf).find(lang => lang !== null) ?? langOf(await systemLocale($)) ?? DEFAULT_LANG
1182}
1183
1184// Where Windows keeps the person's languages: the list in the order they set
1185// it, its first the display language, then the regional format.
1186const WINDOWS_LOCALES = [
1187 ['HKCU\\Control Panel\\International\\User Profile', 'Languages'],
1188 ['HKCU\\Control Panel\\International', 'LocaleName'],
1189] as const
1190
1191// How long the session's start waits on a system command for the language.
1192const LOCALE_TIMEOUT_MS = 3000
1193
1194// The language the operating system shows the person, as a code (`es-AR`):
1195// on Windows read from the registry, on macOS the first of AppleLanguages;
1196// null where neither answers. Each command is run by its full path, from its
1197// own folder, never looked up in the project's: a project could hold a
1198// program by that name.
1199async function systemLocale($: EngineInterface): Promise<string | null> {
1200 if ((await $.env.get('OS').catch(() => undefined)) === 'Windows_NT') {hooks/small.ts 636 lines1// Clawd's scenes in the small size, which the terminal band shows unless the
2// person picks the large one. Clawd is drawn as Claude Code's welcome screen
3// draws it, a pixel a quadrant of a cell (hooks/raster.ts, `quadrants`):
4// pixels twice as tall as wide, two colors to a cell. A scene is 30 pixels
5// across and 10 down, 15 columns and 5 rows; Clawd stands in its bottom three
6// rows, what floats over it goes in the two above, and what it handles to its
7// right.
8//
9// Coordinates are in pixels with Clawd's head at y 0, so what floats has a
10// negative y. A cell is the pixels at x 2k and 2k + 1 and at y 2k and 2k + 1:
11// a drawing keeps to two colors in each, the terminal's own counting as one,
12// or the cell shows the two it has most of. What moves moves by whole pixels,
13// and by two where it must keep to its cells.
14//
15// The same scenes as the large size, under the same names (register.tsx);
16// the band picks one with a `ScenePick` and draws it here.
17
18// The large size's palette (register.tsx).
19const BODY = '#D97757'
20const EYE = '#1F1E1D'
21const SPARK = '#F5C26B'
22const DOT = '#B0AEA5'
23const HANDLE = '#A0785A'
24const STEEL = '#9A9A9A'
25const SHELL = '#E0453A'
26const WING = '#E8E6DC'
27const SKY = '#8FB8DE'
28const LEAF = '#9BC48A'
29
30// What the band shows, as both sizes read it: a mode's own scene, a family of
31// tools, a step of the cache running out, the round of rest and pastimes
32// (`turns`, the CSS that shows each in its turn), or one pastime.
33export type ScenePick =
34 | { kind: 'mode'; key: string }
35 | { kind: 'tool'; key: string }
36 | { kind: 'cache'; key: string }
37 | { kind: 'round'; turns: string }
38 | { kind: 'pastime'; key: string }
39
40type Scene = { eyes?: string; extra: string; css: string; body?: string }
41
42const px = (x: number, y: number, w = 1, h = 1) => `<rect x="${x}" y="${y}" width="${w}" height="${h}"/>`
43
44// A character, in the cell that holds pixel (x, y).
45const char = (x: number, y: number, c: string) => `<text x="${x}" y="${y}">${c}</text>`
46
47// Clawd as the welcome screen draws it: a body 13 pixels wide, so its eyes
48// fall in the same half of their cells, arms out to the sides, four legs.
49const sprite = (eyes: string) => `
50 <g class="body" fill="${BODY}">${px(2, 0, 13, 4)}</g>
51 <g class="arm-l" fill="${BODY}">${px(0, 2, 2, 1)}</g>
52 <g class="arm-r" fill="${BODY}">${px(15, 2, 2, 1)}</g>
53 <g class="legs-a" fill="${BODY}">${px(2, 4)}${px(12, 4)}</g>
54 <g class="legs-b" fill="${BODY}">${px(4, 4)}${px(14, 4)}</g>
55 <g class="eyes" fill="${EYE}">${eyes}</g>`
56
57const OPEN_EYES = px(4, 1) + px(12, 1)
58
59// Shut eyes, too thin for a pixel: a low line in each eye's cell.
60const CLOSED_EYES = char(4, 1, '_') + char(12, 1, '_')
61
62const BLINK = `
63 @keyframes blink { 0%, 92% { opacity: 1; } 93%, 100% { opacity: 0; } }`
64
65// Hammering in three frames: raised, swinging, struck with sparks. Clawd's
66// hand reaches a pixel further to hold the handle, which keeps them in
67// cells of their own.
68const toolUse: Scene = {
69 extra: `
70 <g class="hand" fill="${BODY}">${px(17, 2)}</g>
71 <g class="up"><g fill="${HANDLE}">${px(18, -2, 1, 4)}</g><g fill="${STEEL}">${px(17, -3, 3, 1)}</g></g>
72 <g class="mid"><g fill="${HANDLE}">${px(18, 1)}${px(19, 0)}</g><g fill="${STEEL}">${px(20, -1, 2, 1)}</g></g>
73 <g class="down">
74 <g fill="${HANDLE}">${px(18, 2, 2, 1)}</g>
75 <g fill="${STEEL}">${px(20, 1, 1, 3)}</g>
76 <g fill="${SPARK}">${px(22, 1)}${px(22, 3)}${px(23, 2)}</g>
77 </g>`,
78 css: `
79 .eyes { transform: translate(1px, 0); }
80 .up { animation: up 0.7s steps(1) infinite; }
81 .mid { animation: mid 0.7s steps(1) infinite; }
82 .down { animation: down 0.7s steps(1) infinite; }
83 .arm-r, .hand { animation: lift 0.7s steps(1) infinite; }
84 @keyframes up { 0%, 39.9% { opacity: 1; } 40%, 100% { opacity: 0; } }
85 @keyframes mid { 0%, 39.9% { opacity: 0; } 40%, 54.9% { opacity: 1; } 55%, 100% { opacity: 0; } }
86 @keyframes down { 0%, 54.9% { opacity: 0; } 55%, 100% { opacity: 1; } }
87 @keyframes lift { 0%, 54.9% { transform: translate(0, -1px); } 55%, 100% { transform: translate(0, 0); } }`,
88}
89
90const scenes: Record<string, Scene> = {
91 // Asleep, the cache expired: eyes shut, a small and a big z drifting up.
92 idle: {
93 eyes: CLOSED_EYES,
94 extra: `
95 <g fill="${DOT}">
96 <g class="z1">${char(17, -1, 'z')}</g>
97 <g class="z2">${char(17, -1, 'Z')}</g>
98 </g>`,
99 css: `
100 .z1 { animation: drift 3s steps(1) infinite; }
101 .z2 { animation: drift 3s steps(1) -1.5s infinite; }
102 @keyframes drift {
103 0%, 9.9% { opacity: 0; transform: translate(0, 0); } 10%, 39.9% { opacity: 1; transform: translate(0, 0); }
104 40%, 69.9% { opacity: 1; transform: translate(2px, -2px); } 70%, 84.9% { opacity: 1; transform: translate(4px, -2px); }
105 85%, 100% { opacity: 0; transform: translate(4px, -2px); }
106 }`,
107 },
108 // A model request in flight: Clawd stacks three blocks, lifting an arm to
109 // place each; the stack sparkles and clears.
110 requesting: {
111 extra: `
112 <g class="b1" fill="${SPARK}">${px(18, 4, 2, 1)}</g>
113 <g class="b2" fill="${SKY}">${px(20, 4, 2, 1)}</g>
114 <g class="b3" fill="${LEAF}">${px(19, 3, 2, 1)}</g>
115 <g class="done" fill="${WING}">${px(17, 0)}${px(22, 1)}${px(23, 3)}</g>`,
116 css: `
117 .eyes { transform: translate(1px, 0); }
118 .eyes rect { animation: blink 3s steps(1) infinite; }
119 .arm-r { animation: place 2.4s steps(1) infinite; }
120 .b1 { animation: drop1 2.4s steps(1) infinite; }
121 .b2 { animation: drop2 2.4s steps(1) infinite; }
122 .b3 { animation: drop3 2.4s steps(1) infinite; }
123 .done { animation: done 2.4s steps(1) infinite; }
124 @keyframes place {
125 0%, 11.9% { transform: translate(0, -1px); } 12%, 19.9% { transform: translate(0, 0); }
126 20%, 31.9% { transform: translate(0, -1px); } 32%, 39.9% { transform: translate(0, 0); }
127 40%, 51.9% { transform: translate(0, -1px); } 52%, 100% { transform: translate(0, 0); }
128 }
129 @keyframes drop1 { 0% { transform: translate(0, -5px); } 4% { transform: translate(0, -3px); } 8% { transform: translate(0, -1px); } 12%, 80% { transform: translate(0, 0); opacity: 1; } 81%, 100% { opacity: 0; } }
130 @keyframes drop2 { 0%, 19.9% { opacity: 0; } 20% { opacity: 1; transform: translate(0, -5px); } 24% { transform: translate(0, -3px); } 28% { transform: translate(0, -1px); } 32%, 80% { transform: translate(0, 0); opacity: 1; } 81%, 100% { opacity: 0; } }
131 @keyframes drop3 { 0%, 39.9% { opacity: 0; } 40% { opacity: 1; transform: translate(0, -4px); } 44% { transform: translate(0, -2px); } 48% { transform: translate(0, -1px); } 52%, 80% { transform: translate(0, 0); opacity: 1; } 81%, 100% { opacity: 0; } }
132 @keyframes done { 0%, 55.9% { opacity: 0; } 56%, 63.9% { opacity: 1; } 64%, 69.9% { opacity: 0; } 70%, 77.9% { opacity: 1; } 78%, 100% { opacity: 0; } }
133 ${BLINK}`,
134 },
135 // Compacting the conversation: three loose sheets are pressed together,
136 // Clawd's arm pushing down, until they are one small golden block.
137 compacting: {
138 extra: `
139 <g class="sheets" fill="${WING}">
140 <g class="s1">${px(18, -1, 4, 1)}</g>
141 <g class="s2">${px(19, 1, 4, 1)}</g>
142 <g class="s3">${px(18, 3, 4, 1)}</g>
143 </g>
144 <g class="cube"><g fill="${SPARK}">${px(18, 2, 2, 2)}</g><g fill="#FFFFFF">${px(18, 2)}</g></g>`,
145 css: `
146 .eyes { transform: translate(1px, 0); }
147 .arm-r { animation: press 2.4s steps(1) infinite; }
148 .s1 { animation: s1 2.4s steps(1) infinite; }
149 .s2 { animation: s2 2.4s steps(1) infinite; }
150 .sheets { animation: sheets 2.4s steps(1) infinite; }
151 .cube { animation: cube 2.4s steps(1) infinite; }
152 @keyframes press { 0%, 34.9% { transform: translate(0, -1px); } 35%, 100% { transform: translate(0, 0); } }
153 @keyframes s1 { 0%, 34.9% { transform: translate(0, 0); } 35%, 49.9% { transform: translate(0, 1px); } 50%, 100% { transform: translate(0, 2px); } }
154 @keyframes s2 { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(-1px, 1px); } }
155 @keyframes sheets { 0%, 69.9% { opacity: 1; } 70%, 100% { opacity: 0; } }
156 @keyframes cube { 0%, 69.9% { opacity: 0; } 70%, 100% { opacity: 1; } }`,
157 },
158 // A compaction just finished: Clawd hops twice for joy, arms up, among sparkles.
159 compacted: {
160 extra: `
161 <g fill="${SPARK}">
162 <g class="k1">${px(18, -2)}${px(1, -3)}</g>
163 <g class="k2">${px(19, 0)}${px(0, -1)}</g>
164 <g class="k3">${px(18, -4)}${px(3, -4)}</g>
165 </g>`,
166 css: `
167 .clawd { animation: jump 1.2s steps(1) infinite; }
168 .arm-l, .arm-r { animation: cheer 1.2s steps(1) infinite; }
169 .k1 { animation: twinkle 0.6s steps(1) infinite; }
170 .k2 { animation: twinkle 0.6s steps(1) -0.2s infinite; }
171 .k3 { animation: twinkle 0.6s steps(1) -0.4s infinite; }
172 @keyframes jump { 0%, 19.9% { transform: translate(0, -1px); } 20%, 49.9% { transform: translate(0, 0); } 50%, 69.9% { transform: translate(0, -1px); } 70%, 100% { transform: translate(0, 0); } }
173 @keyframes cheer { 0%, 19.9% { transform: translate(0, -1px); } 20%, 49.9% { transform: translate(0, 0); } 50%, 69.9% { transform: translate(0, -1px); } 70%, 100% { transform: translate(0, 0); } }
174 @keyframes twinkle { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }`,
175 },
176 // Thinking: two thought dots, then a light bulb that switches on.
177 thinking: {
178 extra: `
179 <g fill="${DOT}">
180 <g class="d1">${px(15, -1)}</g>
181 <g class="d2">${px(17, -2)}</g>
182 </g>
183 <g class="bulb">
184 <g class="glass">${px(20, -4, 4, 1)}${px(19, -3, 6, 1)}${px(20, -2, 4, 1)}</g>
185 <g fill="${STEEL}">${px(20, -1, 4, 1)}</g>
186 <g class="lit">
187 <g fill="#FFFFFF">${px(20, -3)}</g>
188 <g class="rays" fill="${SPARK}">${px(18, -4)}${px(25, -4)}${px(25, -2)}</g>
189 </g>
190 </g>`,
191 css: `
192 .eyes { transform: translate(1px, 0); }
193 .eyes rect { animation: blink 3s steps(1) infinite; }
194 .d1 { animation: d1 3s steps(1) infinite; }
195 .d2 { animation: d2 3s steps(1) infinite; }
196 .bulb { animation: bulb 3s steps(1) infinite; }
197 .glass { animation: glass 3s steps(1) infinite; }
198 .lit { animation: lit 3s steps(1) infinite; }
199 .rays { animation: rays 0.3s steps(1) infinite; }
200 @keyframes d1 { 0%, 9.9% { opacity: 0; } 10%, 100% { opacity: 1; } }
201 @keyframes d2 { 0%, 24.9% { opacity: 0; } 25%, 100% { opacity: 1; } }
202 @keyframes bulb { 0%, 39.9% { opacity: 0; } 40%, 100% { opacity: 1; } }
203 @keyframes glass { 0%, 59.9% { fill: ${DOT}; } 60%, 100% { fill: ${SPARK}; } }
204 @keyframes lit { 0%, 59.9% { opacity: 0; } 60%, 100% { opacity: 1; } }
205 @keyframes rays { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
206 ${BLINK}`,
207 },
208 'tool-input': toolUse,
209 'tool-use': toolUse,
210 // Writing the answer: types away, a hand at a time, while lines of text
211 // appear beside it.
212 responding: {
213 extra: `
214 <g fill="${DOT}">
215 <g class="l1">${px(19, -1, 4, 1)}</g>
216 <g class="l2">${px(19, 1, 3, 1)}</g>
217 <g class="l3">${px(19, 3, 4, 1)}</g>
218 </g>`,
219 css: `
220 .arm-l { animation: tap 0.3s steps(1) infinite; }
221 .arm-r { animation: tap 0.3s steps(1) -0.15s infinite; }
222 .eyes { transform: translate(1px, 0); }
223 .eyes rect { animation: blink 2.6s steps(1) infinite; }
224 .l1 { animation: line1 2.4s steps(1) infinite; }
225 .l2 { animation: line2 2.4s steps(1) infinite; }
226 .l3 { animation: line3 2.4s steps(1) infinite; }
227 @keyframes tap { 0%, 49.9% { transform: translate(0, -1px); } 50%, 100% { transform: translate(0, 0); } }
228 @keyframes line1 { 0%, 14.9% { opacity: 0; } 15%, 100% { opacity: 1; } }
229 @keyframes line2 { 0%, 39.9% { opacity: 0; } 40%, 100% { opacity: 1; } }
230 @keyframes line3 { 0%, 64.9% { opacity: 0; } 65%, 100% { opacity: 1; } }
231 ${BLINK}`,
232 },
233}
234
235// A round globe six pixels across and four down, x 18 to 23, from y 0 to 3.
236const GLOBE = px(19, 0, 4, 1) + px(18, 1, 6, 2) + px(19, 3, 4, 1)
237
238// Land on one turn of the globe, as [x within its six columns, y].
239const LAND: [number, number][] = [[1, 0], [0, 1], [1, 1], [4, 1], [4, 2], [5, 2], [1, 2], [3, 3]]
240
241// Where the land shows as it turns: the globe's middle cells, all sky, so
242// each holds two colors; its rounded sides already hold the terminal's own.
243const LAND_WINDOW = px(20, 0, 2, 4)
244
245// A sheet of paper beside Clawd, x 18 to 23, its top at y 0.
246const PAPER = `<g fill="${WING}">${px(18, 0, 6, 4)}</g>`
247
248const toolScenes: Record<string, Scene> = {
249 // Reading or searching: a written page, and a lens that moves over it a
250 // cell at a time while Clawd watches.
251 look: {
252 extra: `
253 ${PAPER}
254 <g fill="${DOT}">${px(18, 1, 6, 1)}${px(18, 3, 4, 1)}</g>
255 <g class="scan">
256 <g fill="${SKY}">${px(18, 0, 2, 2)}</g>
257 <g fill="#FFFFFF">${px(18, 0)}</g>
258 </g>`,
259 css: `
260 .eyes { transform: translate(1px, 0); }
261 .eyes rect { animation: blink 3s steps(1) infinite; }
262 .scan { animation: scan 2s steps(1) infinite; }
263 @keyframes scan {
264 0%, 24.9% { transform: translate(0, 0); } 25%, 49.9% { transform: translate(2px, 0); }
265 50%, 74.9% { transform: translate(4px, 2px); } 75%, 100% { transform: translate(0, 2px); }
266 }
267 ${BLINK}`,
268 },
269 // Editing or writing a file: line after line of ink on a page, a pencil beside it.
270 write: {
271 extra: `
272 ${PAPER}
273 <g fill="${EYE}">
274 <g class="i1">${px(18, 1)}</g><g class="i2">${px(19, 1)}</g><g class="i3">${px(20, 1)}</g><g class="i4">${px(21, 1)}</g>
275 <g class="i5">${px(18, 3)}</g><g class="i6">${px(19, 3)}</g><g class="i7">${px(20, 3)}</g>
276 </g>
277 <g class="pencil">
278 <g fill="#E8A0A0">${px(25, -1)}</g>
279 <g fill="${SPARK}">${px(25, 0, 1, 4)}</g>
280 <g fill="${EYE}">${px(25, 4)}</g>
281 </g>`,
282 css: `
283 svg { --cycle: 3s; }
284 .eyes { transform: translate(1px, 0); }
285 ${[10, 20, 30, 40, 55, 65, 75].map((from, i) => appear(`i${i + 1}`, from)).join('\n ')}`,
286 },
287 // Running a command: Clawd types at a monitor where green drops of code
288 // fall, each column at its own pace.
289 shell: {
290 extra: `
291 <defs><clipPath id="screen">${px(19, 0, 8, 2)}</clipPath></defs>
292 <g fill="${STEEL}">${px(18, -1, 10, 1)}${px(18, 0, 1, 2)}${px(27, 0, 1, 2)}${px(18, 2, 10, 1)}${px(22, 3, 2, 1)}${px(20, 4, 6, 1)}</g>
293 <g fill="#0B140D">${px(19, 0, 8, 2)}</g>
294 <g clip-path="url(#screen)" fill="#3FD46A">
295 ${[20, 21, 22, 23, 24, 25].map(x => `<g class="rain c${x}">${px(x, -1)}</g>`).join('')}
296 </g>`,
297 css: `
298 .eyes { transform: translate(1px, 0); }
299 .arm-l { animation: tap 0.3s steps(1) infinite; }
300 .arm-r { animation: tap 0.3s steps(1) -0.15s infinite; }
301 .rain { animation: rain 1.2s steps(3) infinite; }
302 .c20 { animation-duration: 1.1s; animation-delay: -0.3s; }
303 .c21 { animation-duration: 1.5s; animation-delay: -0.9s; }
304 .c22 { animation-duration: 0.9s; animation-delay: -0.1s; }
305 .c23 { animation-duration: 1.3s; animation-delay: -0.6s; }
306 .c24 { animation-duration: 1.2s; animation-delay: -0.4s; }
307 .c25 { animation-duration: 1s; animation-delay: -0.8s; }
308 @keyframes tap { 0%, 49.9% { transform: translate(0, -1px); } 50%, 100% { transform: translate(0, 0); } }
309 @keyframes rain { from { transform: translate(0, 0); } to { transform: translate(0, 3px); } }`,
310 },
311 // On the web: a desk globe turns on its stand.
312 web: {
313 extra: `
314 <defs><clipPath id="globe">${LAND_WINDOW}</clipPath></defs>
315 <g fill="${SKY}">${GLOBE}</g>
316 <g clip-path="url(#globe)"><g class="spin" fill="${LEAF}">
317 ${[0, 6].map(dx => LAND.map(([x, y]) => px(18 + dx + x, y)).join('')).join('')}
318 </g></g>
319 <g fill="${STEEL}">${px(24, 1, 1, 2)}${px(20, 4, 2, 1)}${px(19, 5, 4, 1)}</g>`,
320 css: `
321 .eyes { transform: translate(1px, 0); }
322 .eyes rect { animation: blink 3s steps(1) infinite; }
323 .arm-r { transform: translate(0, -1px); }
324 .spin { animation: spin 2.4s steps(6) infinite; }
325 @keyframes spin { from { transform: translate(0, 0); } to { transform: translate(-6px, 0); } }
326 ${BLINK}`,
327 },
328 // Launching a subagent: a small Clawd runs off while the big one waves. The
329 // small one's eyes sit a row down its body, so the body frames them; its
330 // legs go apart and together at each step.
331 agent: {
332 extra: `
333 <g class="mini">
334 <g fill="${BODY}">${px(18, 1, 5, 3)}</g>
335 <g fill="${EYE}">${px(19, 2)}${px(21, 2)}</g>
336 <g class="mini-apart" fill="${BODY}">${px(18, 4)}${px(22, 4)}</g>
337 <g class="mini-together" fill="${BODY}">${px(19, 4)}${px(21, 4)}</g>
338 </g>`,
339 css: `
340 .eyes { transform: translate(1px, 0); }
341 .arm-r { animation: wave 0.4s steps(1) infinite; }
342 .mini { animation: run 2s steps(1) infinite; }
343 .mini-apart { animation: stride 0.4s steps(1) infinite; }
344 .mini-together { animation: stride 0.4s steps(1) -0.2s infinite; }
345 @keyframes wave { 0%, 49.9% { transform: translate(0, -1px); } 50%, 100% { transform: translate(0, 0); } }
346 @keyframes stride { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
347 @keyframes run {
348 0%, 19.9% { transform: translate(0, 0); opacity: 1; } 20%, 39.9% { transform: translate(2px, 0); }
349 40%, 59.9% { transform: translate(4px, 0); } 60%, 79.9% { transform: translate(6px, 0); opacity: 1; }
350 80%, 100% { transform: translate(6px, 0); opacity: 0; }
351 }`,
352 },
353 // Any other tool: the hammer.
354 other: toolUse,
355}
356
357// A class that shows from `from`% of the cycle until 95%, then clears with the rest.
358function appear(name: string, from: number) {
359 return `.${name} { animation: ${name} var(--cycle) steps(1) infinite; }
360 @keyframes ${name} { 0%, ${from - 0.1}% { opacity: 0; } ${from}%, 94.9% { opacity: 1; } 95%, 100% { opacity: 0; } }`
361}
362
363const cacheScenes: Record<string, Scene> = {
364 // Clawd stretches its arms up in a big yawn, then nods off: a thirtieth of the
365 // cache's life or less is left, two minutes of an hour.
366 yawn: {
367 eyes: `<g class="drowsy">${OPEN_EYES}</g><g class="shut">${CLOSED_EYES}</g>`,
368 extra: `
369 <g class="gape" fill="${EYE}">${px(8, 2, 2, 1)}</g>
370 <g fill="${DOT}"><g class="z1">${char(17, -1, 'z')}</g></g>`,
371 css: `
372 .arm-l, .arm-r { animation: stretchup 4s steps(1) infinite; }
373 .drowsy { animation: drowsy 4s steps(1) infinite; }
374 .shut { animation: shut 4s steps(1) infinite; }
375 .gape { animation: gape 4s steps(1) infinite; }
376 .z1 { animation: doze 4s steps(1) infinite; }
377 @keyframes stretchup { 0%, 14.9% { transform: translate(0, 0); } 15%, 44.9% { transform: translate(0, -1px); } 45%, 100% { transform: translate(0, 0); } }
378 @keyframes drowsy { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
379 @keyframes shut { 0%, 49.9% { opacity: 0; } 50%, 100% { opacity: 1; } }
380 @keyframes gape { 0%, 19.9% { opacity: 0; } 20%, 39.9% { opacity: 1; } 40%, 100% { opacity: 0; } }
381 @keyframes doze { 0%, 59.9% { opacity: 0; transform: translate(0, 0); } 60%, 79.9% { opacity: 1; transform: translate(0, 0); } 80%, 94.9% { opacity: 1; transform: translate(2px, -2px); } 95%, 100% { opacity: 0; } }`,
382 },
383 // Clawd keeps an anxious eye on the scene, a drop of sweat running down its
384 // side: a sixth of the cache's life or less is left, ten
385 // minutes of an hour.
386 worry: {
387 extra: `<g class="sweat" fill="${SKY}">${px(16, -2)}</g>`,
388 css: `
389 .eyes { transform: translate(1px, 0); }
390 .eyes rect { animation: blink 2.4s steps(1) infinite; }
391 .sweat { animation: sweat 1.6s steps(1) infinite; }
392 @keyframes sweat {
393 0%, 14.9% { opacity: 0; transform: translate(0, 0); } 15%, 39.9% { opacity: 1; transform: translate(0, 1px); }
394 40%, 64.9% { opacity: 1; transform: translate(0, 2px); } 65%, 89.9% { opacity: 1; transform: translate(0, 3px); } 90%, 100% { opacity: 0; }
395 }
396 ${BLINK}`,
397 },
398}
399
400// One of the things Clawd does to pass the time between turns, its css under
401// its own class; the same names as the large size's, in the same order.
402type Pastime = { name: string; eyes?: string; extra: string; css: string }
403
404// Keyframes that jump a thing from point to point, each held an equal share
405// of the cycle; the points are offsets from where it is drawn.
406const hops = (name: string, points: [number, number][]) =>
407 `@keyframes ${name} { ${points
408 .map(([x, y], i) => `${((i / points.length) * 100).toFixed(2)}% { transform: translate(${x}px, ${y}px); }`)
409 .join(' ')} 100% { transform: translate(${points[0]?.[0] ?? 0}px, ${points[0]?.[1] ?? 0}px); } }`
410
411// Where a juggled ball goes, from the left hand: up and over the head to the
412// right hand, then back low.
413const JUGGLE: [number, number][] = [[0, 0], [1, -2], [3, -4], [6, -5], [9, -5], [12, -5], [14, -4], [15, -2], [16, 0], [13, -2], [8, -3], [3, -2]]
414
415export const PASTIMES: readonly Pastime[] = [
416 // Looks around, this way and that. (The large size taps a foot too, which a
417 // pixel this tall can't show.)
418 {
419 name: 'gaze',
420 eyes: `<g class="gaze-look">${OPEN_EYES}</g>`,
421 extra: '',
422 css: `
423 .gaze .eyes rect { animation: blink 3s steps(1) infinite; }
424 .gaze .gaze-look { animation: gaze-look 4.5s steps(1) infinite; }
425 @keyframes gaze-look {
426 0%, 24.9% { transform: translate(-1px, 0); } 25%, 37.9% { transform: translate(0, 0); }
427 38%, 64.9% { transform: translate(1px, 0); } 65%, 79.9% { transform: translate(1px, -1px); } 80%, 100% { transform: translate(0, 0); }
428 }`,
429 },
430 // Whistles a tune, notes floating off.
431 {
432 name: 'whistle',
433 eyes: `${OPEN_EYES}<g fill="${EYE}">${px(8, 2)}</g>`,
434 extra: `
435 <g class="whistle-n1" fill="${SPARK}">${char(18, -1, '♪')}</g>
436 <g class="whistle-n2" fill="${SKY}">${char(18, -1, '♫')}</g>`,
437 css: `
438 .whistle-n1 { animation: whistle-float 2.4s steps(1) infinite; }
439 .whistle-n2 { animation: whistle-float 2.4s steps(1) -1.2s infinite; }
440 @keyframes whistle-float {
441 0%, 39.9% { opacity: 1; transform: translate(0, 0); } 40%, 79.9% { opacity: 1; transform: translate(2px, -2px); }
442 80%, 100% { opacity: 0; transform: translate(2px, -2px); }
443 }`,
444 },
445 // Juggles three balls over its head, the hands taking turns.
446 {
447 name: 'juggle',
448 eyes: `<g class="juggle-up">${OPEN_EYES}</g>`,
449 extra: `
450 <g class="juggle-b1" fill="${SHELL}">${px(0, 1)}</g>
451 <g class="juggle-b2" fill="${SKY}">${px(0, 1)}</g>
452 <g class="juggle-b3" fill="${LEAF}">${px(0, 1)}</g>`,
453 css: `
454 .juggle-up { transform: translate(0, -1px); }
455 .juggle .arm-l { animation: juggle-pump 0.6s steps(1) infinite; }
456 .juggle .arm-r { animation: juggle-pump 0.6s steps(1) -0.3s infinite; }
457 .juggle-b1 { animation: juggle-ball 1.8s steps(1) infinite; }
458 .juggle-b2 { animation: juggle-ball 1.8s steps(1) -0.6s infinite; }
459 .juggle-b3 { animation: juggle-ball 1.8s steps(1) -1.2s infinite; }
460 @keyframes juggle-pump { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, -1px); } }
461 ${hops('juggle-ball', JUGGLE)}`,
462 },
463 // Plays with a yo-yo: down on its string and back up, spinning at the
464 // bottom. The hand holding the string is a pixel of Clawd's, so its cell
465 // holds two colors.
466 {
467 name: 'yoyo',
468 eyes: `<g class="yoyo-look">${OPEN_EYES}</g>`,
469 extra: `
470 <g fill="${BODY}">${px(17, 1)}</g>
471 <g class="yoyo-up">
472 <g class="yoyo-toy" fill="${SHELL}">${px(17, 2, 2, 2)}</g>
473 </g>
474 <g class="yoyo-down">
475 <g fill="${WING}">${px(17, 2, 1, 2)}</g>
476 <g class="yoyo-toy" fill="${SHELL}">${px(17, 4, 2, 2)}</g>
477 </g>`,
478 css: `
479 .yoyo .arm-r { transform: translate(0, -1px); }
480 .yoyo-look { animation: yoyo-look 2s steps(1) infinite; }
481 .yoyo-up { animation: yoyo-up 2s steps(1) infinite; }
482 .yoyo-down { animation: yoyo-down 2s steps(1) infinite; }
483 .yoyo-toy { animation: yoyo-spin 0.3s steps(1) infinite; }
484 @keyframes yoyo-up { 0%, 29.9% { opacity: 1; } 30%, 69.9% { opacity: 0; } 70%, 100% { opacity: 1; } }
485 @keyframes yoyo-down { 0%, 29.9% { opacity: 0; } 30%, 69.9% { opacity: 1; } 70%, 100% { opacity: 0; } }
486 @keyframes yoyo-look { 0%, 29.9% { transform: translate(1px, 0); } 30%, 69.9% { transform: translate(1px, 0); } 70%, 100% { transform: translate(0, 0); } }
487 @keyframes yoyo-spin { 0%, 49.9% { fill: ${SHELL}; } 50%, 100% { fill: #A8322A; } }`,
488 },
489 // Blows soap bubbles through a wand and watches them drift off.
490 {
491 name: 'bubbles',
492 eyes: `<g class="bubbles-look">${OPEN_EYES}</g>`,
493 extra: `
494 <g fill="${HANDLE}">${px(17, 1)}${px(18, 0)}</g>
495 <g fill="${STEEL}">${px(19, -1)}</g>
496 <g fill="${SKY}">
497 <g class="bubbles-b1">${char(20, -2, 'o')}</g>
498 <g class="bubbles-b2">${char(20, -2, '°')}</g>
499 <g class="bubbles-b3">${char(20, -2, 'o')}</g>
500 </g>`,
501 css: `
502 .bubbles-look { animation: bubbles-look 3s steps(1) infinite; }
503 .bubbles-b1 { animation: bubbles-rise 3s steps(1) infinite; }
504 .bubbles-b2 { animation: bubbles-rise 3s steps(1) -1s infinite; }
505 .bubbles-b3 { animation: bubbles-rise 3s steps(1) -2s infinite; }
506 @keyframes bubbles-rise {
507 0%, 12.4% { opacity: 0; transform: translate(0, 0); } 12.5%, 37.4% { opacity: 1; transform: translate(0, 0); }
508 37.5%, 62.4% { opacity: 1; transform: translate(2px, -2px); } 62.5%, 87.4% { opacity: 1; transform: translate(4px, -2px); }
509 87.5%, 100% { opacity: 0; transform: translate(4px, -2px); }
510 }
511 @keyframes bubbles-look { 0%, 100% { transform: translate(1px, 0); } }`,
512 },
513 // Reads a book held out to the right, now and then turning a page.
514 {
515 name: 'read',
516 eyes: `<g class="read-eyes">${OPEN_EYES}</g>`,
517 extra: `
518 <g fill="${SHELL}">${px(18, 3, 4, 1)}</g>
519 <g fill="${WING}">${px(18, 0, 4, 3)}</g>
520 <g fill="${DOT}">${px(18, 1, 4, 1)}</g>
521 <g fill="${WING}">
522 <g class="read-p1">${px(21, -1, 1, 3)}</g>
523 <g class="read-p2">${px(20, -1, 1, 3)}</g>
524 <g class="read-p3">${px(19, -1, 1, 3)}</g>
525 </g>`,
526 css: `
527 .read-eyes { transform: translate(1px, 0); }
528 .read .eyes rect { animation: blink 3.5s steps(1) infinite; }
529 .read-p1 { animation: read-p1 3s steps(1) infinite; }
530 .read-p2 { animation: read-p2 3s steps(1) infinite; }
531 .read-p3 { animation: read-p3 3s steps(1) infinite; }
532 @keyframes read-p1 { 0%, 79.9% { opacity: 0; } 80%, 84.9% { opacity: 1; } 85%, 100% { opacity: 0; } }
533 @keyframes read-p2 { 0%, 84.9% { opacity: 0; } 85%, 89.9% { opacity: 1; } 90%, 100% { opacity: 0; } }
534 @keyframes read-p3 { 0%, 89.9% { opacity: 0; } 90%, 94.9% { opacity: 1; } 95%, 100% { opacity: 0; } }`,
535 },
536 // Dances: sways side to side, arms up by turns, notes twinkling above.
537 {
538 name: 'dance',
539 extra: `
540 <g class="dance-n1" fill="${SPARK}">${char(2, -3, '♪')}</g>
541 <g class="dance-n2" fill="${LEAF}">${char(18, -2, '♫')}</g>`,
542 css: `
543 .dance .clawd { animation: dance-sway 1s steps(1) infinite; }
544 .dance .arm-l { animation: dance-arm 1s steps(1) infinite; }
545 .dance .arm-r { animation: dance-arm 1s steps(1) -0.5s infinite; }
546 .dance .legs-a { animation: dance-step 0.5s steps(1) infinite; }
547 .dance .legs-b { animation: dance-step 0.5s steps(1) -0.25s infinite; }
548 .dance-n1 { animation: dance-twinkle 1s steps(1) infinite; }
549 .dance-n2 { animation: dance-twinkle 1s steps(1) -0.5s infinite; }
550 @keyframes dance-sway { 0%, 49.9% { transform: translate(-1px, 0); } 50%, 100% { transform: translate(1px, 0); } }
551 @keyframes dance-arm { 0%, 49.9% { transform: translate(0, -1px); } 50%, 100% { transform: translate(0, 0); } }
552 @keyframes dance-step { 0%, 49.9% { opacity: 0; } 50%, 100% { opacity: 1; } }
553 @keyframes dance-twinkle { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }`,
554 },
555 // Follows a ladybug that flies past high overhead: a red shell with black
556 // spots, its black head forward.
557 {
558 name: 'ladybug',
559 eyes: `<g class="ladybug-follow">${OPEN_EYES}</g>`,
560 extra: `
561 <g class="ladybug-flight">
562 <g fill="${SHELL}">${px(0, -4, 5, 2)}</g>
563 <g fill="${EYE}">${px(5, -4, 1, 2)}${px(1, -4)}${px(3, -4)}${px(2, -3)}</g>
564 </g>`,
565 css: `
566 .ladybug .ladybug-follow { animation: ladybug-follow 4s steps(1) infinite; }
567 .ladybug .ladybug-flight { animation: ladybug-fly 4s steps(1) infinite; }
568 @keyframes ladybug-follow {
569 0%, 24.9% { transform: translate(-1px, -1px); } 25%, 49.9% { transform: translate(0, -1px); }
570 50%, 79.9% { transform: translate(1px, -1px); } 80%, 100% { transform: translate(0, 0); }
571 }
572 @keyframes ladybug-fly {
573 0%, 9.9% { opacity: 0; transform: translate(0, 0); } 10%, 19.9% { opacity: 1; transform: translate(0, 0); }
574 20%, 29.9% { transform: translate(4px, 0); } 30%, 39.9% { transform: translate(8px, 0); }
575 40%, 49.9% { transform: translate(12px, 0); } 50%, 59.9% { transform: translate(16px, 0); }
576 60%, 69.9% { transform: translate(20px, 0); } 70%, 79.9% { opacity: 1; transform: translate(24px, 0); }
577 80%, 100% { opacity: 0; transform: translate(28px, 0); }
578 }`,
579 },
580]
581
582// Clawd at rest between pastimes: blinking now and then, glancing one way
583// and the other.
584export const REST: Pastime = {
585 name: 'rest',
586 eyes: `<g class="rest-look">${OPEN_EYES}</g>`,
587 extra: '',
588 css: `
589 .rest .eyes rect { animation: rest-blink 5s steps(1) infinite; }
590 .rest .rest-look { animation: rest-look 13s steps(1) infinite; }
591 @keyframes rest-blink { 0%, 95.9% { opacity: 1; } 96%, 100% { opacity: 0; } }
592 @keyframes rest-look {
593 0%, 54.9% { transform: translate(0, 0); } 55%, 67.9% { transform: translate(1px, 0); }
594 68%, 79.9% { transform: translate(0, 0); } 80%, 91.9% { transform: translate(-1px, 0); } 92%, 100% { transform: translate(0, 0); }
595 }`,
596}
597
598// Clawd in a group of its own: its pose, its eyes and its props.
599const pastimeBody = (p: Pastime, turn = '') =>
600 `<g class="${turn} ${p.name}"><g class="clawd">${sprite(p.eyes ?? OPEN_EYES)}</g>${p.extra}</g>`
601
602const pastimeNamed = (name: string) => PASTIMES.find(p => p.name === name) ?? REST
603
604// The scene a pick names, in the small size.
605export const smallScene = (pick: ScenePick): Scene => {
606 if (pick.kind === 'round') {
607 return {
608 body: [pastimeBody(REST, `turn-${REST.name}`), ...PASTIMES.map(p => pastimeBody(p, `turn-${p.name}`))].join(''),
609 extra: '',
610 css: `${pick.turns}
611 ${[REST, ...PASTIMES].map(p => p.css).join('')}
612 ${BLINK}`,
613 }
614 }
615 if (pick.kind === 'pastime') {
616 const p = pastimeNamed(pick.key)
617 return { body: pastimeBody(p), extra: '', css: `${p.css}${BLINK}` }
618 }
619 const table = pick.kind === 'tool' ? toolScenes : pick.kind === 'cache' ? cacheScenes : scenes
620 return table[pick.key] ?? scenes.requesting ?? toolUse
621}
622
623// The small scene's image, the head at y 4 of the 10 rows of pixels.
624export const smallSvg = (scene: Scene) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 10" shape-rendering="crispEdges">
625 <style>
626 g { transform-box: view-box; }
627 .props { animation: fadein 0.4s steps(2) 1 both; }
628 @keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
629 ${scene.css}
630 </style>
631 <g transform="translate(0 4)">
632 ${scene.body ?? `<g class="clawd">${sprite(scene.eyes ?? OPEN_EYES)}</g>`}
633 <g class="props">${scene.extra}</g>
634 </g>
635</svg>`
636hooks/scenes/index.ts 84 lines1// The scenes the band's right side can be drawn as. Each one draws the same
2// meters of the session its own way, as one plain SVG image; the one picked
3// last is kept in the plugin's store under `scene`.
4
5import { adventureScene } from './adventure'
6import { balconyScene } from './balcony'
7import { cyberpunkScene } from './cyberpunk'
8import { gamerScene } from './gamer'
9import { TEXTS, type Lang, type Words } from '../language'
10import { minutesLeft } from './pixels'
11import { mateScene } from './mate'
12import { teatimeScene } from './teatime'
13import { steampunkScene } from './steampunk'
14import { windowScene } from './window'
15
16// The meters every scene shows.
17export type Meters = {
18 // Percent of the context window still free.
19 contextLeft: number | null
20 // Percent used of the five-hour and seven-day limits.
21 fiveHour: number | null
22 week: number | null
23 // Seconds left on the prompt cache; 0 once expired, null before any answer.
24 cacheLeft: number | null
25 // The cache's whole life, in seconds.
26 cacheTtl: number
27 // The conversation is being compacted right now: each scene refills its
28 // context meter while it lasts.
29 isCompacting: boolean
30}
31
32export type MeterScene = {
33 // What /cozy-clawd-scene takes and the plugin's store keeps.
34 name: string
35 // The name the picker shows.
36 label: Words
37 // The drawing's size in its own pixels, and CSS pixels per pixel.
38 width: number
39 height: number
40 scale: number
41 svg: (f: Meters) => string
42 // The scene in the terminal's small size: square pixels half a cell each
43 // (hooks/raster.ts, `halves`), with no numbers, which the band writes
44 // under it as text, each centered at its meter's middle (`centers`, in
45 // pixels from the left).
46 small?: { svg: (f: Meters) => string; centers: readonly number[] }
47}
48
49export const METER_SCENES: readonly MeterScene[] = [balconyScene, mateScene, teatimeScene, windowScene, adventureScene, gamerScene, cyberpunkScene, steampunkScene]
50
51export const DEFAULT_METER_SCENE = balconyScene
52
53// The scene a stored name names; the default for one no scene has.
54export const meterSceneNamed = (name: unknown) => METER_SCENES.find(s => s.name === name) ?? DEFAULT_METER_SCENE
55
56// The meters' numbers as a small scene shows them: a line `columns` wide,
57// each number centered at its meter's middle; the cache's minutes
58// `elapsed` seconds after `f` was read, as the large scenes' countdown reads
59// them.
60export const numbersLine = (f: Meters, centers: readonly number[], columns: number, elapsed: number) => {
61 const pct = (n: number | null) => (n === null ? '--' : `${Math.round(n)}%`)
62 const left = f.cacheLeft === null ? null : f.cacheLeft - elapsed
63 const minutes = left === null ? '--' : left <= 0 ? '0m' : `${String(minutesLeft(left)).padStart(2, '0')}m`
64 const texts = [pct(f.contextLeft), minutes, pct(f.fiveHour === null ? null : 100 - f.fiveHour), pct(f.week === null ? null : 100 - f.week)]
65 const line = Array<string>(columns).fill(' ')
66 texts.forEach((text, i) => {
67 const start = Math.round((centers[i] ?? 0) - text.length / 2)
68 ;[...text].forEach((char, k) => {
69 if (start + k >= 0 && start + k < columns) {
70 line[start + k] = char
71 }
72 })
73 })
74 return line.join('')
75}
76
77// The meters in words, for a reader that cannot see the scene.
78export const metersAlt = (f: Meters, lang: Lang) => {
79 const words = TEXTS[lang].meters
80 const pct = (n: number | null) => (n === null ? words.none : `${Math.round(n)}%`)
81 const cache = f.cacheLeft === null ? words.none : f.cacheLeft > 0 ? words.minutes(minutesLeft(f.cacheLeft)) : words.expired
82 return words.line(pct(f.contextLeft), cache, pct(f.fiveHour === null ? null : 100 - f.fiveHour), pct(f.week === null ? null : 100 - f.week))
83}
84hooks/scenes/pixels.ts 176 lines1// What the meter scenes share: the layout of four slots, one per meter, with
2// its number underneath; the 3x5 pixel font of the numbers; and the cache's
3// minutes, which count down by themselves. Square pixels, three CSS pixels each.
4// Also what their small drawings share: the image's size, a meter's steps,
5// and the cache's life in steps.
6
7import type { Meters } from './index'
8
9// CSS pixels per scene pixel.
10export const SCALE = 3
11// Each meter has a slot this wide; a scene is four slots across.
12export const SLOT = 16
13export const WIDTH = SLOT * 4
14// Two rows above y 0, for flames and steam, then sixteen rows: objects stand
15// on whatever the scene puts at y 9 or so, and the numbers sit at y 11 to 15.
16export const TOP = -2
17export const HEIGHT = 18
18export const DIGITS_Y = 11
19
20export const INK = '#B0AEA5'
21
22export const px = (x: number, y: number, w = 1, h = 1) => `<rect x="${x}" y="${y}" width="${w}" height="${h}"/>`
23
24const DASH = ['000', '000', '111', '000', '000']
25
26// A 3x5 pixel font for the numbers: each glyph is five rows of three bits.
27const FONT: Record<string, string[]> = {
28 '0': ['111', '101', '101', '101', '111'],
29 '1': ['010', '110', '010', '010', '111'],
30 '2': ['111', '001', '111', '100', '111'],
31 '3': ['111', '001', '111', '001', '111'],
32 '4': ['101', '101', '111', '001', '001'],
33 '5': ['111', '100', '111', '001', '111'],
34 '6': ['111', '100', '111', '101', '111'],
35 '7': ['111', '001', '001', '010', '010'],
36 '8': ['111', '101', '111', '101', '111'],
37 '9': ['111', '101', '111', '001', '111'],
38 '%': ['101', '001', '010', '100', '101'],
39 'm': ['000', '110', '111', '101', '101'],
40 '-': DASH,
41}
42
43export const glyph = (ch: string, x: number, y: number) =>
44 (FONT[ch] ?? DASH)
45 .map((row, r) => [...row].map((bit, c) => (bit === '1' ? px(x + c, y + r) : '')).join(''))
46 .join('')
47
48// Text in the pixel font, centered in a slot: four pixels per character.
49export const pixelText = (text: string, slotX: number) => {
50 const width = text.length * 4 - 1
51 const x0 = slotX + Math.floor((SLOT - width) / 2)
52 return `<g fill="${INK}">${[...text].map((ch, i) => glyph(ch, x0 + i * 4, DIGITS_Y)).join('')}</g>`
53}
54
55// The percent of the context left at which every scene shows it low (a
56// kettle, a teapot, a blinking heart) and the band offers to compact.
57export const LOW_AT = 25
58
59// Whether a meter's percent left runs low.
60export const runsLow = (left: number | null) => left !== null && left <= LOW_AT
61
62// What a meter with no reading draws: full, its number `--`. Before the
63// first answer the context is all but free, and an API key has no usage
64// limits to use up.
65export const FULL = 100
66
67// How many of a meter's `n` steps the percent left fills: one as soon as
68// there is anything, all only near full. From five steps up, 100, 75, 50, 25,
69// 10 and 0 each fill a different number. No reading fills them all.
70export const level = (left: number | null, n: number) => {
71 const shown = left ?? FULL
72 return shown <= 0 ? 0 : Math.min(n, Math.ceil((shown / 100) * n - 1e-9))
73}
74
75// A meter's step in a small scene, from the percent left: 3 above half or
76// with no reading, 2 above a quarter, 1 above nothing, and 0 at nothing.
77export const stageOf = (left: number | null) => (left === null ? 3 : left <= 0 ? 0 : left > 50 ? 3 : left > LOW_AT ? 2 : 1)
78
79// The cache's whole minutes left, as both the drawing and the words count
80// them: a minute begun counts, so an hour reads 60 and its last seconds 1.
81export const minutesLeft = (left: number) => Math.max(0, Math.ceil(left / 60 - 1e-9))
82
83// A small scene's image: 40 pixels across and eight down, two to a cell,
84// its pixels below a style.
85export const smallSceneSvg = (css: string, body: string) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 8" shape-rendering="crispEdges">
86 <style>
87 g { transform-box: view-box; }
88 ${HOUR_CSS}${css}
89 </style>${body}
90</svg>`
91
92// Keyframes that jump a thing from point to point, each held an equal share
93// of the cycle; the points are offsets from where it is drawn.
94export const hops = (name: string, points: readonly (readonly [number, number])[]) =>
95 `@keyframes ${name} { ${points
96 .map(([x, y], i) => `${((i / points.length) * 100).toFixed(2)}% { transform: translate(${x}px, ${y}px); }`)
97 .join(' ')} 100% { transform: translate(${points[0]?.[0] ?? 0}px, ${points[0]?.[1] ?? 0}px); } }`
98
99// The steps of the cache's life in a small scene, each a group shown in its
100// turn by itself (hourSteps).
101const HOUR_CSS = `
102 .h3, .h2, .h1 { animation-timing-function: steps(1); animation-fill-mode: forwards; }
103 .h3 { animation-name: h3; }
104 .h2 { animation-name: h2; }
105 .h1 { animation-name: h1; }
106 @keyframes h3 { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
107 @keyframes h2 { 0%, 49.9% { opacity: 0; } 50%, 74.9% { opacity: 1; } 75%, 100% { opacity: 0; } }
108 @keyframes h1 { 0%, 74.9% { opacity: 0; } 75%, 100% { opacity: 1; } }`
109
110// A cache meter in a small scene: `steps[0]` while more than half the
111// hour is left, `steps[1]` while more than a quarter is, `steps[2]` to the
112// end, going from one to the next by themselves; `expired` once the cache
113// has, and `steps[0]` before the first answer.
114export const hourSteps = (f: Meters, steps: readonly [string, string, string], expired: string) => {
115 const left = f.cacheLeft
116 if (left === null) {
117 return steps[0]
118 }
119 if (left <= 0) {
120 return expired
121 }
122 const style = `animation-duration: ${f.cacheTtl}s; animation-delay: -${(f.cacheTtl - left).toFixed(1)}s`
123 return steps.map((drawing, i) => `<g class="h${3 - i}" style="${style}">${drawing}</g>`).join('')
124}
125
126// A meter's percent, or dashes before the first reading.
127export const percentText = (n: number | null, slotX: number) => pixelText(n === null ? '--' : `${Math.round(n)}%`, slotX)
128
129// The cache's minutes counting down by themselves, as minutesLeft counts
130// them: each digit is a window over a strip of glyphs, 9 (6 for the tens)
131// down to 0, rolled one glyph per step by an animation whose negative delay
132// starts it at the minutes left. The digits read 00 for a minute after the
133// cache expires, by when the band has drawn the scene expired. Needs
134// COUNTDOWN_CSS in the scene's style.
135export const countdown = (left: number, slotX: number) => {
136 const x0 = slotX + Math.floor((SLOT - 11) / 2)
137 // The seconds whose whole minutes are the minutes left: a minute ahead.
138 const shown = Math.min(left + 60 - 0.001, 70 * 60 - 0.001)
139 const digit = (x: number, count: number, step: number) => {
140 const period = count * step
141 const delay = (((period - shown - 0.001) % period) + period) % period
142 const strip = Array.from({ length: count }, (_, i) => glyph(String(count - 1 - i), x, DIGITS_Y + i * 6)).join('')
143 return `<svg x="${x}" y="${DIGITS_Y}" width="3" height="5" viewBox="${x} ${DIGITS_Y} 3 5" overflow="hidden">
144 <g style="animation: roll${count} ${period}s steps(${count}) -${delay.toFixed(3)}s infinite">${strip}</g></svg>`
145 }
146 return `<g fill="${INK}">
147 ${digit(x0, 7, 600)}${digit(x0 + 4, 10, 60)}${glyph('m', x0 + 8, DIGITS_Y)}
148 </g>`
149}
150
151export const COUNTDOWN_CSS = `
152 @keyframes roll10 { from { transform: translateY(0); } to { transform: translateY(-60px); } }
153 @keyframes roll7 { from { transform: translateY(0); } to { transform: translateY(-42px); } }`
154
155// A meter refilling while the conversation is compacted: the group holding it
156// full is uncovered from the bottom up, from `short` rows below the top, over
157// two seconds. Needs REFILL_CSS in the scene's style.
158export const refill = (short: number) => `animation: refill${short} 2s steps(${Math.max(1, short)}) both`
159
160export const REFILL_CSS = Array.from({ length: 9 }, (_, n) =>
161 `@keyframes refill${n} { from { clip-path: inset(${n}px 0 0 0); } to { clip-path: inset(0 0 0 0); } }`).join('\n ')
162
163// What sits on top of a refilling meter, a flame or a float, rising with it
164// from `short` rows down over the same two seconds. Needs RISE_CSS.
165export const rise = (short: number) => `animation: rise${short} 2s steps(${Math.max(1, short)}) both`
166
167export const RISE_CSS = Array.from({ length: 9 }, (_, n) =>
168 `@keyframes rise${n} { from { transform: translate(0, ${n}px); } to { transform: translate(0, 0); } }`).join('\n ')
169
170// The scene's image: its pixels below a style; `g` transforms work in scene pixels.
171export const sceneSvg = (css: string, body: string) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${TOP} ${WIDTH} ${HEIGHT}" shape-rendering="crispEdges">
172 <style>
173 g { transform-box: view-box; }${css}
174 </style>${body}
175</svg>`
176hooks/language.ts 84 lines1// The languages the mod speaks, and how it tells which one the person uses.
2//
3// Claude Code hands a plugin neither the app's language nor the app's own
4// translated texts, so the mod carries its texts in each language, one file
5// each under `languages/`, and goes by what it can see: the Language row of
6// /config, then the locale variables. It speaks the languages the Claude
7// desktop app shows.
8
9import type { Lang } from '../types'
10import { de } from './languages/de'
11import { en, type Texts } from './languages/en'
12import { es } from './languages/es'
13import { fr } from './languages/fr'
14import { hi } from './languages/hi'
15import { id } from './languages/id'
16import { it } from './languages/it'
17import { ja } from './languages/ja'
18import { ko } from './languages/ko'
19import { pt } from './languages/pt'
20
21export type { Lang, LangChoice } from '../types'
22export type { Texts } from './languages/en'
23
24// A text in every language the mod speaks.
25export type Words = Record<Lang, string>
26
27// The languages in the order the panel offers them.
28export const LANGS: readonly Lang[] = ['es', 'en', 'fr', 'de', 'it', 'pt', 'id', 'hi', 'ja', 'ko']
29
30// Every text the mod shows, by language.
31export const TEXTS: Record<Lang, Texts> = { es, en, fr, de, it, pt, id, hi, ja, ko }
32
33// One text in every language, picked from each language's texts.
34export const wordsOf = (pick: (t: Texts) => string) => Object.fromEntries(LANGS.map(lang => [lang, pick(TEXTS[lang])])) as Words
35
36// Each language by its own name, as the picker shows it.
37export const LANG_NAMES: Words = wordsOf(t => t.name)
38
39// Each language by its name in English, as the terminal's picker shows a
40// name Claude Code would measure short (register.tsx, `langName`).
41export const ENGLISH_NAMES: Words = {
42 es: 'Spanish',
43 en: 'English',
44 fr: 'French',
45 de: 'German',
46 it: 'Italian',
47 pt: 'Portuguese',
48 id: 'Indonesian',
49 hi: 'Hindi',
50 ja: 'Japanese',
51 ko: 'Korean',
52}
53
54// The language the mod falls back on when nothing names one.
55export const DEFAULT_LANG: Lang = 'en'
56
57// How a setting names each language: by code, alone or with a region or an
58// encoding (`es`, `es_AR.UTF-8`, `pt-BR`), or by name in English, in the
59// language itself, or in Spanish. Indonesian also goes by `in`, its old code.
60const NAMES: readonly [Lang, RegExp][] = [
61 ['es', /^es([-_.@]|$)|^(spanish|espa[nñ]ol|castellano)/],
62 ['en', /^en([-_.@]|$)|^(english|ingl[eé]s)/],
63 ['fr', /^fr([-_.@]|$)|^(french|fran[cç]ais|franc[eé]s)/],
64 ['de', /^de([-_.@]|$)|^(german|deutsch|alem[aá]n)/],
65 ['it', /^it([-_.@]|$)|^(italian|italiano)/],
66 ['pt', /^pt([-_.@]|$)|^(portuguese|portugu[eéê]s)/],
67 ['id', /^(id|in)([-_.@]|$)|^(indonesian|bahasa indonesia|indonesio)/],
68 ['hi', /^hi([-_.@]|$)|^(hindi|हिन्दी|हिंदी)/],
69 ['ja', /^ja([-_.@]|$)|^(japanese|日本語|japon[eé]s)/],
70 ['ko', /^ko([-_.@]|$)|^(korean|한국어|coreano)/],
71]
72
73// The language a setting names, by code or by name; null for any other, and
74// for a default (`Default (English)`, the row's value while the person has
75// not set one).
76export const langOf = (setting: unknown): Lang | null => {
77 if (typeof setting !== 'string') {
78 return null
79 }
80 const s = setting.trim().toLowerCase()
81
82 return NAMES.find(([, name]) => name.test(s))?.[0] ?? null
83}
84hooks/raster.ts 1083 lines1// The terminal draws no SVG, so there the band paints the scenes itself: a
2// scene's image is read once into its groups and rects, with their classes and
3// CSS animations, then painted at any moment of those animations into the
4// cells of a Raster, in one of two layouts:
5//
6// - `halves`: a cell holds two square pixels, the upper and lower halves of a
7// block character, so a unit of the image's viewBox is a column across and
8// half a row down. Each pixel keeps its color.
9// - `quadrants`: a cell holds four pixels, each twice as tall as wide, as
10// Claude Code draws Clawd on its welcome screen, so a unit is half a column
11// across and half a row down. A cell shows two colors: where its pixels
12// have more, each takes the nearer of the two it has most of.
13//
14// It reads the SVG and CSS the scenes are drawn with, and no more: `svg` (a
15// nested one is a window over its content), `g`, `rect`, `text` of one
16// character (painted as that character, in the cell that holds its x and
17// y), and `clipPath` in `defs`; type, class and descendant selectors; fill,
18// opacity, transform (translate and scale), clip-path (`url()` or
19// `inset()`), custom properties through `var()`, and animations with their
20// keyframes, `steps()` and the cubic timing functions. A scene drawn with
21// anything more needs it here too.
22
23// A scene's image as the terminal paints it: its size in cells, and its cells
24// at `t` seconds after it was drawn, as a Raster's `cells` (RasterProps).
25// `background` is the color translucent pixels are laid over. `nextChange`
26// says when after `t`, in seconds after it was drawn, what it paints may
27// next change: Infinity once it holds still for good.
28export type Picture = {
29 columns: number
30 rows: number
31 paint: (t: number, background: number) => string
32 nextChange: (t: number) => number
33}
34
35// x' = a·x + e and y' = d·y + f: the scenes only move and stretch.
36type Affine = { a: number; d: number; e: number; f: number }
37
38// An axis-aligned box, from (x0, y0) to (x1, y1).
39type Box = { x0: number; y0: number; x1: number; y1: number }
40
41type Clip = { kind: 'none' } | { kind: 'url'; id: string } | { kind: 'inset'; sides: [number, number, number, number] }
42
43// What a node shows at one moment. A fill of null is `none`.
44type Values = { fill: number | null; opacity: number; transform: Affine; clip: Clip }
45
46type Prop = 'fill' | 'opacity' | 'transform' | 'clip'
47
48type Track = { offset: number; value: Values[Prop] }[]
49
50type Animation = {
51 duration: number
52 delay: number
53 count: number
54 direction: string
55 fillMode: string
56 timing: (q: number) => number
57 tracks: Partial<Record<Prop, Track>>
58 changes: Changes
59}
60
61// Where in an iteration an animation's values may change, in progress from 0
62// to 1: where a step or a keyframe falls (`breaks`), and the stretches over
63// which they move smoothly.
64type Changes = { breaks: number[]; smooth: [number, number][] }
65
66// How a timing function moves: in `steps` (and whether each step falls at
67// the start of its interval), or smoothly, with `steps` null.
68type Stepping = { steps: number | null; isStart: boolean }
69
70type Node = {
71 tag: string
72 classes: string[]
73 parent: Node | null
74 children: Node[]
75 // What the cascade gives it, before any animation; a fill left undefined is inherited.
76 fill: number | null | undefined
77 opacity: number
78 transform: Affine
79 clip: Clip
80 animations: Animation[]
81 // A rect's geometry, in its own units.
82 rect?: Box
83 // A text's one character and where it stands, in its own units.
84 char?: { x: number; y: number; code: number }
85 // A nested svg: how its content maps into its parent, and the window it shows.
86 viewport?: { map: Affine; window: Box | null }
87}
88
89type Raw = { tag: string; attrs: Record<string, string>; children: Raw[]; text: string }
90
91type Decl = [string, string]
92
93// One compound of a selector: a type, or any, with classes.
94type Compound = { tag: string | null; classes: string[] }
95
96type Rule = { selector: Compound[]; specificity: number; order: number; decls: Decl[] }
97
98type Frame = { offsets: number[]; decls: Decl[] }
99
100const IDENTITY: Affine = { a: 1, d: 1, e: 0, f: 0 }
101const NO_CLIP: Clip = { kind: 'none' }
102
103// A translucent pixel this faint over nothing is left out: laid over a
104// background that may not be the terminal's own, it would show as a smudge.
105const MIN_ALPHA = 0.1
106
107// The terminal's own color, where nothing is painted.
108const DEFAULT_COLOR = 0x01000000
109
110const SPACE = 0x20
111const UPPER_HALF = 0x2580
112const LOWER_HALF = 0x2584
113const FULL_BLOCK = 0x2588
114
115// The quadrant characters by the pixels they fill: bit 0 the top left, bit 1
116// the top right, bit 2 the bottom left, bit 3 the bottom right.
117const QUADRANTS = [SPACE, 0x2598, 0x259d, UPPER_HALF, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, LOWER_HALF, 0x2599, 0x259f, FULL_BLOCK]
118
119// How a picture lays its pixels in cells (see the top of this file).
120export type Layout = 'halves' | 'quadrants'
121
122const distance = (a: number, b: number) => [16, 8, 0].reduce((sum, shift) => sum + (((a >> shift) & 0xff) - ((b >> shift) & 0xff)) ** 2, 0)
123
124// `n` applied first, then `m`.
125const compose = (m: Affine, n: Affine): Affine => ({ a: m.a * n.a, d: m.d * n.d, e: m.a * n.e + m.e, f: m.d * n.f + m.f })
126
127const mapBox = (m: Affine, b: Box): Box => {
128 const xs = [m.a * b.x0 + m.e, m.a * b.x1 + m.e]
129 const ys = [m.d * b.y0 + m.f, m.d * b.y1 + m.f]
130 return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }
131}
132
133const union = (a: Box | null, b: Box | null): Box | null =>
134 a === null ? b : b === null ? a : { x0: Math.min(a.x0, b.x0), y0: Math.min(a.y0, b.y0), x1: Math.max(a.x1, b.x1), y1: Math.max(a.y1, b.y1) }
135
136const numbers = (text: string) =>
137 text
138 .split(/[\s,]+/)
139 .filter(Boolean)
140 .map(n => parseFloat(n))
141
142// A transform list (`translate(1px, 0) scale(1 2)`) as one affine map; a
143// function other than translate and scale is left out.
144const transformOf = (text: string): Affine => {
145 let m = IDENTITY
146 for (const [, fn, args] of text.matchAll(/([a-zA-Z]+)\(([^)]*)\)/g)) {
147 const [x = 0, y] = numbers(args ?? '')
148 const step: Affine | null =
149 fn === 'translate'
150 ? { a: 1, d: 1, e: x, f: y ?? 0 }
151 : fn === 'translateX'
152 ? { a: 1, d: 1, e: x, f: 0 }
153 : fn === 'translateY'
154 ? { a: 1, d: 1, e: 0, f: x }
155 : fn === 'scale'
156 ? { a: x, d: y ?? x, e: 0, f: 0 }
157 : fn === 'scaleX'
158 ? { a: x, d: 1, e: 0, f: 0 }
159 : fn === 'scaleY'
160 ? { a: 1, d: x, e: 0, f: 0 }
161 : null
162 if (step !== null) {
163 m = compose(m, step)
164 }
165 }
166 return m
167}
168
169const NAMED_COLORS: Record<string, number> = { black: 0x000000, white: 0xffffff }
170
171// A fill: a color, null for none, undefined for what this reader does not know.
172const colorOf = (text: string): number | null | undefined => {
173 const v = text.trim().toLowerCase()
174 if (v === 'none' || v === 'transparent') {
175 return null
176 }
177 const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(v)?.[1]
178 if (hex !== undefined) {
179 return parseInt(hex.length === 3 ? [...hex].map(c => c + c).join('') : hex, 16)
180 }
181 return NAMED_COLORS[v]
182}
183
184const clipOf = (text: string): Clip => {
185 const url = /url\(\s*#([^)\s]+)\s*\)/.exec(text)?.[1]
186 if (url !== undefined) {
187 return { kind: 'url', id: url }
188 }
189 const inset = /inset\(([^)]*)\)/.exec(text)?.[1]
190 if (inset !== undefined) {
191 const [top = 0, right = top, bottom = top, left = right] = numbers(inset)
192 return { kind: 'inset', sides: [top, right, bottom, left] }
193 }
194 return NO_CLIP
195}
196
197const seconds = (text: string) => (text.endsWith('ms') ? parseFloat(text) / 1000 : parseFloat(text) || 0)
198
199// The timing functions, as maps of an interval's progress.
200const CUBIC: Record<string, [number, number, number, number]> = {
201 ease: [0.25, 0.1, 0.25, 1],
202 'ease-in': [0.42, 0, 1, 1],
203 'ease-out': [0, 0, 0.58, 1],
204 'ease-in-out': [0.42, 0, 0.58, 1],
205}
206
207const bezier = (x1: number, y1: number, x2: number, y2: number) => {
208 const at = (p1: number, p2: number, s: number) => 3 * p1 * s * (1 - s) ** 2 + 3 * p2 * s * s * (1 - s) + s ** 3
209 return (q: number) => {
210 let lo = 0
211 let hi = 1
212 for (let i = 0; i < 24; i++) {
213 const mid = (lo + hi) / 2
214 if (at(x1, x2, mid) < q) {
215 lo = mid
216 } else {
217 hi = mid
218 }
219 }
220 return at(y1, y2, (lo + hi) / 2)
221 }
222}
223
224const stepsOf = (n: number, position: string) => (q: number) => {
225 const jumps = position === 'jump-none' ? n - 1 : position === 'jump-both' ? n + 1 : n
226 let step = Math.floor(q * n)
227 if (position === 'start' || position === 'jump-start' || position === 'jump-both') {
228 step += 1
229 }
230 return Math.min(Math.max(step, 0), jumps) / Math.max(jumps, 1)
231}
232
233const steppingOf = (text: string): Stepping => {
234 const steps = /^steps\(\s*(\d+)\s*(?:,\s*([\w-]+)\s*)?\)$/.exec(text)
235 if (steps !== null) {
236 const position = steps[2] ?? 'end'
237 return { steps: Number(steps[1]), isStart: position === 'start' || position === 'jump-start' || position === 'jump-both' }
238 }
239 return text === 'step-start' || text === 'step-end' ? { steps: 1, isStart: text === 'step-start' } : { steps: null, isStart: false }
240}
241
242const timingOf = (text: string): ((q: number) => number) => {
243 const steps = /^steps\(\s*(\d+)\s*(?:,\s*([\w-]+)\s*)?\)$/.exec(text)
244 if (steps !== null) {
245 return stepsOf(Number(steps[1]), steps[2] ?? 'end')
246 }
247 if (text === 'step-start' || text === 'step-end') {
248 return stepsOf(1, text === 'step-start' ? 'start' : 'end')
249 }
250 if (text === 'linear') {
251 return q => q
252 }
253 const cubic = /^cubic-bezier\(([^)]*)\)$/.exec(text)?.[1]
254 const [x1 = 0, y1 = 0, x2 = 1, y2 = 1] = cubic !== undefined ? numbers(cubic) : (CUBIC[text] ?? CUBIC.ease ?? [])
255 return bezier(x1, y1, x2, y2)
256}
257
258// Commas between animations, not those inside a function's parentheses.
259const layers = (text: string) => {
260 const out: string[] = []
261 let depth = 0
262 let start = 0
263 for (let i = 0; i < text.length; i++) {
264 const c = text[i]
265 depth += c === '(' ? 1 : c === ')' ? -1 : 0
266 if (c === ',' && depth === 0) {
267 out.push(text.slice(start, i).trim())
268 start = i + 1
269 }
270 }
271 out.push(text.slice(start).trim())
272 return out.filter(Boolean)
273}
274
275const TIMINGS = /^(linear|ease|ease-in|ease-out|ease-in-out|step-start|step-end|steps\(.*\)|cubic-bezier\(.*\))$/
276const DIRECTIONS = new Set(['normal', 'reverse', 'alternate', 'alternate-reverse'])
277const FILL_MODES = new Set(['none', 'forwards', 'backwards', 'both'])
278
279// The animation properties of a node, one list each, as the longhands are.
280type AnimationLists = { names: string[]; durations: string[]; delays: string[]; timings: string[]; counts: string[]; directions: string[]; fillModes: string[] }
281
282const NO_ANIMATIONS: AnimationLists = { names: [], durations: ['0s'], delays: ['0s'], timings: ['ease'], counts: ['1'], directions: ['normal'], fillModes: ['none'] }
283
284// The `animation` shorthand: every longhand set again, as CSS does.
285const shorthand = (text: string): AnimationLists => {
286 const lists: AnimationLists = { names: [], durations: [], delays: [], timings: [], counts: [], directions: [], fillModes: [] }
287 for (const layer of layers(text)) {
288 const tokens = layer.match(/[\w.-]+\([^)]*\)|[^\s]+/g) ?? []
289 let duration = '0s'
290 let delay = '0s'
291 let times = 0
292 let timing = 'ease'
293 let count = '1'
294 let direction = 'normal'
295 let fillMode = 'none'
296 let name = 'none'
297 for (const token of tokens) {
298 if (/^-?[\d.]+m?s$/.test(token)) {
299 if (times++ === 0) {
300 duration = token
301 } else {
302 delay = token
303 }
304 } else if (TIMINGS.test(token)) {
305 timing = token
306 } else if (token === 'infinite' || /^[\d.]+$/.test(token)) {
307 count = token
308 } else if (DIRECTIONS.has(token)) {
309 direction = token
310 } else if (FILL_MODES.has(token) && token !== 'none') {
311 fillMode = token
312 } else if (token !== 'running' && token !== 'paused') {
313 name = token
314 }
315 }
316 lists.names.push(name)
317 lists.durations.push(duration)
318 lists.delays.push(delay)
319 lists.timings.push(timing)
320 lists.counts.push(count)
321 lists.directions.push(direction)
322 lists.fillModes.push(fillMode)
323 }
324 return lists
325}
326
327const LONGHANDS: Record<string, keyof AnimationLists> = {
328 'animation-name': 'names',
329 'animation-duration': 'durations',
330 'animation-delay': 'delays',
331 'animation-timing-function': 'timings',
332 'animation-iteration-count': 'counts',
333 'animation-direction': 'directions',
334 'animation-fill-mode': 'fillModes',
335}
336
337const parseDecls = (text: string): Decl[] =>
338 text.split(';').flatMap((d): Decl[] => {
339 const i = d.indexOf(':')
340 return i < 0 ? [] : [[d.slice(0, i).trim(), d.slice(i + 1).trim()]]
341 })
342
343// A selector of compounds joined by spaces; null for one this reader cannot match.
344const selectorOf = (text: string): Compound[] | null => {
345 const compounds = text.trim().split(/\s+/).map(part => /^([a-zA-Z*][\w-]*)?((?:\.[\w-]+)*)$/.exec(part))
346 if (compounds.some(c => c === null || c[0] === '')) {
347 return null
348 }
349 return compounds.map(c => ({ tag: c?.[1] ?? null, classes: (c?.[2] ?? '').split('.').filter(Boolean) }))
350}
351
352const offsetsOf = (text: string) =>
353 text.split(',').map(s => {
354 const v = s.trim()
355 return v === 'from' ? 0 : v === 'to' ? 1 : parseFloat(v) / 100
356 })
357
358const parseCss = (text: string) => {
359 const css = text.replace(/\/\*[\s\S]*?\*\//g, '')
360 const rules: Rule[] = []
361 const keyframes = new Map<string, Frame[]>()
362 let i = 0
363 while (i < css.length) {
364 const open = css.indexOf('{', i)
365 if (open < 0) {
366 break
367 }
368 const head = css.slice(i, open).trim()
369 if (head.startsWith('@keyframes')) {
370 const frames: Frame[] = []
371 let j = open + 1
372 for (;;) {
373 const close = css.indexOf('}', j)
374 const inner = css.indexOf('{', j)
375 if (close < 0) {
376 j = css.length
377 break
378 }
379 if (inner < 0 || close < inner) {
380 j = close + 1
381 break
382 }
383 const end = css.indexOf('}', inner)
384 frames.push({ offsets: offsetsOf(css.slice(j, inner)), decls: parseDecls(css.slice(inner + 1, end < 0 ? css.length : end)) })
385 j = end < 0 ? css.length : end + 1
386 }
387 keyframes.set(head.slice('@keyframes'.length).trim(), frames)
388 i = j
389 } else {
390 const close = css.indexOf('}', open)
391 const end = close < 0 ? css.length : close
392 const decls = parseDecls(css.slice(open + 1, end))
393 for (const part of head.split(',')) {
394 const selector = selectorOf(part)
395 if (selector !== null) {
396 const specificity = selector.reduce((n, c) => n + c.classes.length * 100 + (c.tag !== null && c.tag !== '*' ? 1 : 0), 0)
397 rules.push({ selector, specificity, order: rules.length, decls })
398 }
399 }
400 i = end + 1
401 }
402 }
403 rules.sort((a, b) => a.specificity - b.specificity || a.order - b.order)
404 return { rules, keyframes }
405}
406
407const parseXml = (text: string): Raw => {
408 const root: Raw = { tag: '#document', attrs: {}, children: [], text: '' }
409 const stack = [root]
410 const tokens = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<(\/?)([a-zA-Z][\w:-]*)((?:\s+[\w:-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)/g
411 for (const m of text.matchAll(tokens)) {
412 const top = stack[stack.length - 1] ?? root
413 if (m[5] !== undefined) {
414 top.text += m[5]
415 } else if (m[2] === undefined) {
416 continue
417 } else if (m[1] === '/') {
418 if (stack.length > 1) {
419 stack.pop()
420 }
421 } else {
422 const attrs: Record<string, string> = {}
423 for (const a of (m[3] ?? '').matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
424 attrs[a[1] ?? ''] = a[2] ?? a[3] ?? ''
425 }
426 const node: Raw = { tag: m[2], attrs, children: [], text: '' }
427 top.children.push(node)
428 if (m[4] !== '/') {
429 stack.push(node)
430 }
431 }
432 }
433 return root
434}
435
436const matchesCompound = (c: Compound, node: Node) =>
437 (c.tag === null || c.tag === '*' || c.tag === node.tag) && c.classes.every(k => node.classes.includes(k))
438
439const matches = (selector: Compound[], node: Node) => {
440 let k = selector.length - 1
441 const last = selector[k]
442 if (last === undefined || !matchesCompound(last, node)) {
443 return false
444 }
445 let n = node.parent
446 for (k--; k >= 0; k--) {
447 const c = selector[k]
448 while (n !== null && c !== undefined && !matchesCompound(c, n)) {
449 n = n.parent
450 }
451 if (n === null) {
452 return false
453 }
454 n = n.parent
455 }
456 return true
457}
458
459const boxOf = (attrs: Record<string, string>, x: string, y: string, w: string, h: string): Box => {
460 const x0 = parseFloat(attrs[x] ?? '0') || 0
461 const y0 = parseFloat(attrs[y] ?? '0') || 0
462 return { x0, y0, x1: x0 + (parseFloat(attrs[w] ?? '0') || 0), y1: y0 + (parseFloat(attrs[h] ?? '0') || 0) }
463}
464
465const valueOf = (prop: Prop, text: string): Values[Prop] | undefined =>
466 prop === 'fill' ? colorOf(text) : prop === 'opacity' ? Math.min(1, Math.max(0, parseFloat(text))) : prop === 'transform' ? (text === 'none' ? IDENTITY : transformOf(text)) : clipOf(text)
467
468const PROPS: Record<string, Prop> = { fill: 'fill', opacity: 'opacity', transform: 'transform', 'clip-path': 'clip' }
469
470// The tracks of a set of keyframes, one per property, each in order of offset.
471const tracksOf = (frames: Frame[]) => {
472 const tracks: Partial<Record<Prop, Track>> = {}
473 for (const frame of frames) {
474 for (const [name, text] of frame.decls) {
475 const prop = PROPS[name]
476 const value = prop === undefined ? undefined : valueOf(prop, text)
477 if (prop === undefined || value === undefined || Number.isNaN(value)) {
478 continue
479 }
480 const track = (tracks[prop] ??= [])
481 for (const offset of frame.offsets) {
482 const same = track.findIndex(k => k.offset === offset)
483 if (same >= 0) {
484 track.splice(same, 1)
485 }
486 track.push({ offset, value })
487 }
488 }
489 }
490 for (const track of Object.values(tracks)) {
491 track.sort((a, b) => a.offset - b.offset)
492 }
493 return tracks
494}
495
496const lerp = (a: number, b: number, q: number) => a + (b - a) * q
497
498const lerpColor = (a: number, b: number, q: number) =>
499 [16, 8, 0].reduce((c, shift) => c | (Math.round(lerp((a >> shift) & 0xff, (b >> shift) & 0xff, q)) << shift), 0)
500
501const interpolate = (prop: Prop, a: Values[Prop], b: Values[Prop], q: number): Values[Prop] => {
502 if (prop === 'opacity' && typeof a === 'number' && typeof b === 'number') {
503 return lerp(a, b, q)
504 }
505 if (prop === 'fill' && typeof a === 'number' && typeof b === 'number') {
506 return lerpColor(a, b, q)
507 }
508 if (prop === 'transform' && typeof a === 'object' && a !== null && 'a' in a && typeof b === 'object' && b !== null && 'a' in b) {
509 return { a: lerp(a.a, b.a, q), d: lerp(a.d, b.d, q), e: lerp(a.e, b.e, q), f: lerp(a.f, b.f, q) }
510 }
511 if (prop === 'clip' && typeof a === 'object' && a !== null && 'kind' in a && typeof b === 'object' && b !== null && 'kind' in b) {
512 if (a.kind === 'inset' && b.kind === 'inset') {
513 return { kind: 'inset', sides: [0, 1, 2, 3].map(i => lerp(a.sides[i] ?? 0, b.sides[i] ?? 0, q)) as [number, number, number, number] }
514 }
515 // An inset of nothing stands in for no clip, so a clip can grow from none.
516 const none: Clip = { kind: 'inset', sides: [0, 0, 0, 0] }
517 if (a.kind === 'inset' && b.kind === 'none') {
518 return interpolate(prop, a, none, q)
519 }
520 if (a.kind === 'none' && b.kind === 'inset') {
521 return interpolate(prop, none, b, q)
522 }
523 }
524 return q < 0.5 ? a : b
525}
526
527// Where a set of keyframes may change what it shows, moved by a timing
528// function: within each interval whose two keyframes differ, at each of its
529// steps, or all along it when it moves smoothly; and at an iteration's end.
530// An interval missing a keyframe takes the underlying value, which may
531// differ.
532const changesOf = (tracks: Partial<Record<Prop, Track>>, stepping: Stepping): Changes => {
533 const breaks = new Set<number>([1])
534 const smooth: [number, number][] = []
535 for (const track of Object.values(tracks)) {
536 const offsets = [...new Set([0, ...track.map(k => k.offset), 1])].sort((a, b) => a - b)
537 for (let i = 0; i + 1 < offsets.length; i++) {
538 const a = offsets[i] ?? 0
539 const b = offsets[i + 1] ?? 1
540 const from = track.find(k => k.offset === a)
541 const to = track.find(k => k.offset === b)
542 if (b <= a || (from !== undefined && to !== undefined && JSON.stringify(from.value) === JSON.stringify(to.value))) {
543 continue
544 }
545 if (stepping.steps === null) {
546 smooth.push([a, b])
547 continue
548 }
549 for (let k = stepping.isStart ? 0 : 1; k <= stepping.steps; k++) {
550 breaks.add(a + ((b - a) * k) / stepping.steps)
551 }
552 }
553 }
554 return { breaks: [...breaks].sort((a, b) => a - b), smooth }
555}
556
557// How finely a smooth motion is followed: this many frames to a stretch.
558const SMOOTH_FRAMES = 64
559
560// When at `t` or after an animation may next change what it shows, in the
561// picture's seconds; Infinity once it never will.
562const nextChangeOf = (anim: Animation, t: number): number => {
563 const local = t - anim.delay
564 if (local < 0) {
565 return anim.delay
566 }
567 const active = anim.duration > 0 ? anim.duration * anim.count : 0
568 if (local >= active) {
569 return Infinity
570 }
571 const iteration = Math.floor(local / anim.duration)
572 const frac = local / anim.duration - iteration
573 const isOdd = iteration % 2 === 1
574 const isReverse =
575 anim.direction === 'reverse' || (anim.direction === 'alternate' && isOdd) || (anim.direction === 'alternate-reverse' && !isOdd)
576 const p = isReverse ? 1 - frac : frac
577 const EPS = 1e-9
578 let next = 1
579 for (const [a, b] of anim.changes.smooth) {
580 if (p >= a - EPS && p < b - EPS) {
581 next = Math.min(next, frac + (b - a) / SMOOTH_FRAMES)
582 }
583 }
584 for (const at of anim.changes.breaks) {
585 const f = isReverse ? 1 - at : at
586 if (f > frac + EPS) {
587 next = Math.min(next, f)
588 }
589 }
590 return Math.min(anim.delay + (iteration + next) * anim.duration, anim.delay + active)
591}
592
593// Where an animation is at `t`, as its directed progress through the current
594// iteration from 0 to 1; null while it has no effect.
595const progressOf = (anim: Animation, t: number): number | null => {
596 const local = t - anim.delay
597 const active = anim.duration > 0 ? anim.duration * anim.count : 0
598 let iteration: number
599 let frac: number
600 if (local < 0) {
601 if (anim.fillMode !== 'backwards' && anim.fillMode !== 'both') {
602 return null
603 }
604 iteration = 0
605 frac = 0
606 } else if (local >= active) {
607 if (anim.fillMode !== 'forwards' && anim.fillMode !== 'both') {
608 return null
609 }
610 const isWhole = anim.count % 1 === 0 || anim.duration <= 0
611 iteration = isWhole ? Math.max(0, anim.count - 1) : Math.floor(anim.count)
612 frac = isWhole ? 1 : anim.count % 1
613 } else {
614 iteration = Math.floor(local / anim.duration)
615 frac = local / anim.duration - iteration
616 }
617 const isOdd = iteration % 2 === 1
618 const isReverse =
619 anim.direction === 'reverse' || (anim.direction === 'alternate' && isOdd) || (anim.direction === 'alternate-reverse' && !isOdd)
620 return isReverse ? 1 - frac : frac
621}
622
623const sample = (prop: Prop, track: Track, p: number, underlying: Values[Prop], timing: (q: number) => number) => {
624 const frames = [...track]
625 if ((frames[0]?.offset ?? 1) > 0) {
626 frames.unshift({ offset: 0, value: underlying })
627 }
628 if ((frames[frames.length - 1]?.offset ?? 0) < 1) {
629 frames.push({ offset: 1, value: underlying })
630 }
631 let i = 0
632 while (i < frames.length - 2 && (frames[i + 1]?.offset ?? 1) <= p) {
633 i++
634 }
635 const from = frames[i]
636 const to = frames[i + 1] ?? from
637 if (from === undefined || to === undefined) {
638 return underlying
639 }
640 const span = to.offset - from.offset
641 const q = span > 0 ? Math.min(1, Math.max(0, (p - from.offset) / span)) : 1
642 return interpolate(prop, from.value, to.value, timing(q))
643}
644
645const valuesAt = (node: Node, t: number, inherited: number | null): Values => {
646 const values: Values = { fill: node.fill === undefined ? inherited : node.fill, opacity: node.opacity, transform: node.transform, clip: node.clip }
647 for (const anim of node.animations) {
648 const p = progressOf(anim, t)
649 if (p === null) {
650 continue
651 }
652 for (const [prop, track] of Object.entries(anim.tracks) as [Prop, Track][]) {
653 const v = sample(prop, track, p, values[prop], anim.timing)
654 if (prop === 'fill') {
655 values.fill = v as Values['fill']
656 } else if (prop === 'opacity') {
657 values.opacity = v as number
658 } else if (prop === 'transform') {
659 values.transform = v as Affine
660 } else {
661 values.clip = v as Clip
662 }
663 }
664 }
665 return values
666}
667
668// Turns the parsed image into nodes with what the cascade gives each.
669const build = (raw: Raw, parent: Node | null, sheet: ReturnType<typeof parseCss>, vars: Map<string, string>, ids: Map<string, Node>, trackCache: Map<string, Partial<Record<Prop, Track>>>): Node => {
670 const node: Node = {
671 tag: raw.tag,
672 classes: (raw.attrs.class ?? '').split(/\s+/).filter(Boolean),
673 parent,
674 children: [],
675 fill: undefined,
676 opacity: 1,
677 transform: IDENTITY,
678 clip: NO_CLIP,
679 animations: [],
680 }
681 // Presentation attributes first: any rule or style overrides them.
682 const decls: Decl[] = []
683 for (const attr of ['fill', 'opacity', 'transform', 'clip-path']) {
684 const value = raw.attrs[attr]
685 if (value !== undefined) {
686 decls.push([attr, value])
687 }
688 }
689 for (const rule of sheet.rules) {
690 if (matches(rule.selector, node)) {
691 decls.push(...rule.decls)
692 }
693 }
694 decls.push(...parseDecls(raw.attrs.style ?? ''))
695
696 const ownVars = new Map(vars)
697 for (const [prop, value] of decls) {
698 if (prop.startsWith('--')) {
699 ownVars.set(prop, value)
700 }
701 }
702 const resolve = (text: string) => text.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)/g, (_, name: string, fallback?: string) => ownVars.get(name) ?? fallback ?? '')
703
704 let lists = NO_ANIMATIONS
705 for (const [prop, raw] of decls) {
706 const value = resolve(raw)
707 const longhand = LONGHANDS[prop]
708 if (prop === 'fill') {
709 const color = colorOf(value)
710 if (color !== undefined) {
711 node.fill = color
712 }
713 } else if (prop === 'opacity') {
714 const n = parseFloat(value)
715 if (!Number.isNaN(n)) {
716 node.opacity = Math.min(1, Math.max(0, n))
717 }
718 } else if (prop === 'transform') {
719 node.transform = value === 'none' ? IDENTITY : transformOf(value)
720 } else if (prop === 'clip-path') {
721 node.clip = clipOf(value)
722 } else if (prop === 'animation') {
723 lists = shorthand(value)
724 } else if (longhand !== undefined) {
725 lists = { ...lists, [longhand]: layers(value) }
726 }
727 }
728 lists.names.forEach((name, i) => {
729 const frames = sheet.keyframes.get(name)
730 if (frames === undefined) {
731 return
732 }
733 const pick = (list: string[], fallback: string) => list[i % Math.max(1, list.length)] ?? fallback
734 let tracks = trackCache.get(name)
735 if (tracks === undefined) {
736 tracks = tracksOf(frames)
737 trackCache.set(name, tracks)
738 }
739 const count = pick(lists.counts, '1')
740 const timing = pick(lists.timings, 'ease')
741 node.animations.push({
742 duration: seconds(pick(lists.durations, '0s')),
743 delay: seconds(pick(lists.delays, '0s')),
744 count: count === 'infinite' ? Infinity : parseFloat(count) || 0,
745 direction: pick(lists.directions, 'normal'),
746 fillMode: pick(lists.fillModes, 'none'),
747 timing: timingOf(timing),
748 tracks,
749 changes: changesOf(tracks, steppingOf(timing)),
750 })
751 })
752
753 if (raw.tag === 'rect') {
754 node.rect = boxOf(raw.attrs, 'x', 'y', 'width', 'height')
755 }
756 const code = raw.tag === 'text' ? raw.text.trim().codePointAt(0) : undefined
757 if (code !== undefined) {
758 node.char = { x: parseFloat(raw.attrs.x ?? '0') || 0, y: parseFloat(raw.attrs.y ?? '0') || 0, code }
759 }
760 if (raw.tag === 'svg' && parent !== null) {
761 const port = boxOf(raw.attrs, 'x', 'y', 'width', 'height')
762 const view = numbers(raw.attrs.viewBox ?? '')
763 const [vx = 0, vy = 0, vw = port.x1 - port.x0, vh = port.y1 - port.y0] = view
764 const map = { a: vw > 0 ? (port.x1 - port.x0) / vw : 1, d: vh > 0 ? (port.y1 - port.y0) / vh : 1, e: 0, f: 0 }
765 map.e = port.x0 - vx * map.a
766 map.f = port.y0 - vy * map.d
767 node.viewport = { map, window: raw.attrs.overflow === 'visible' ? null : port }
768 }
769 const id = raw.attrs.id
770 if (id !== undefined) {
771 ids.set(id, node)
772 }
773 node.children = raw.children.map(child => build(child, node, sheet, ownVars, ids, trackCache))
774 return node
775}
776
777// Nothing of these is painted where it stands.
778const UNPAINTED = new Set(['defs', 'clipPath', 'style', 'title', 'desc', 'mask', 'linearGradient', 'radialGradient'])
779
780// A node's extent in its own coordinates at `t`: what an inset() clips.
781const extentOf = (node: Node, t: number, fill: number | null): Box | null => {
782 let box: Box | null = node.rect ?? null
783 for (const child of node.children) {
784 if (UNPAINTED.has(child.tag)) {
785 continue
786 }
787 const v = valuesAt(child, t, fill)
788 const inner = extentOf(child, t, v.fill)
789 const map = child.viewport === undefined ? v.transform : compose(v.transform, child.viewport.map)
790 box = union(box, inner === null ? null : mapBox(map, inner))
791 }
792 return box
793}
794
795// The pixels whose centers lie in any of `boxes` (in pixels), and in `within`.
796const maskOf = (boxes: Box[], width: number, height: number, within: Uint8Array | null) => {
797 const mask = new Uint8Array(width * height)
798 for (const b of boxes) {
799 for (let y = Math.max(0, Math.ceil(b.y0 - 0.5 - 1e-9)); y < Math.min(height, Math.ceil(b.y1 - 0.5 - 1e-9)); y++) {
800 for (let x = Math.max(0, Math.ceil(b.x0 - 0.5 - 1e-9)); x < Math.min(width, Math.ceil(b.x1 - 0.5 - 1e-9)); x++) {
801 mask[y * width + x] = within === null ? 1 : (within[y * width + x] ?? 0)
802 }
803 }
804 }
805 return mask
806}
807
808// The pixels painted so far, and the characters to lay over them, in pixels;
809// `charAlpha`, the opacity the canvas will be laid with (a group's own, see
810// paintNode), which a character needs half of to show.
811type Canvas = {
812 width: number
813 height: number
814 rgba: Float64Array
815 ids: Map<string, Node>
816 chars: { x: number; y: number; code: number; color: number }[]
817 charAlpha: number
818}
819
820const paintNode = (canvas: Canvas, node: Node, t: number, m: Affine, alpha: number, fill: number | null, within: Uint8Array | null) => {
821 if (UNPAINTED.has(node.tag)) {
822 return
823 }
824 const v = valuesAt(node, t, fill)
825 const a = alpha * v.opacity
826 if (a <= 1e-4) {
827 return
828 }
829 const own = compose(m, v.transform)
830 let mask = within
831 if (node.viewport?.window) {
832 mask = maskOf([mapBox(own, node.viewport.window)], canvas.width, canvas.height, mask)
833 }
834 const inner = node.viewport === undefined ? own : compose(own, node.viewport.map)
835 if (v.clip.kind === 'url') {
836 const ref = canvas.ids.get(v.clip.id)
837 const boxes = (ref?.children ?? []).flatMap(c => (c.rect === undefined ? [] : [mapBox(compose(inner, c.transform), c.rect)]))
838 mask = maskOf(boxes, canvas.width, canvas.height, mask)
839 } else if (v.clip.kind === 'inset') {
840 const box = extentOf(node, t, v.fill)
841 if (box === null) {
842 return
843 }
844 const [top, right, bottom, left] = v.clip.sides
845 mask = maskOf([mapBox(inner, { x0: box.x0 + left, y0: box.y0 + top, x1: box.x1 - right, y1: box.y1 - bottom })], canvas.width, canvas.height, mask)
846 }
847 // A group seen through is painted as SVG paints it: its children together
848 // on a layer of their own, then the layer over what lies beneath at the
849 // group's opacity, so where they overlap they don't show through each
850 // other.
851 if (node.rect === undefined && node.char === undefined && v.opacity < 1 && node.children.length > 0) {
852 const layer: Canvas = { ...canvas, rgba: new Float64Array(canvas.rgba.length), charAlpha: canvas.charAlpha * a }
853 for (const child of node.children) {
854 paintNode(layer, child, t, inner, 1, v.fill, mask)
855 }
856 const rgba = canvas.rgba
857 for (let p = 0; p < rgba.length; p += 4) {
858 const la = (layer.rgba[p + 3] ?? 0) * a
859 if (la <= 0) {
860 continue
861 }
862 for (let i = 0; i < 3; i++) {
863 rgba[p + i] = (layer.rgba[p + i] ?? 0) * a + (rgba[p + i] ?? 0) * (1 - la)
864 }
865 rgba[p + 3] = la + (rgba[p + 3] ?? 0) * (1 - la)
866 }
867 return
868 }
869 if (node.rect !== undefined && v.fill !== null) {
870 const b = mapBox(inner, node.rect)
871 const r = (v.fill >> 16) & 0xff
872 const g = (v.fill >> 8) & 0xff
873 const bl = v.fill & 0xff
874 for (let y = Math.max(0, Math.ceil(b.y0 - 0.5 - 1e-9)); y < Math.min(canvas.height, Math.ceil(b.y1 - 0.5 - 1e-9)); y++) {
875 for (let x = Math.max(0, Math.ceil(b.x0 - 0.5 - 1e-9)); x < Math.min(canvas.width, Math.ceil(b.x1 - 0.5 - 1e-9)); x++) {
876 const k = y * canvas.width + x
877 if (mask !== null && !mask[k]) {
878 continue
879 }
880 const p = k * 4
881 const rgba = canvas.rgba
882 rgba[p] = r * a + (rgba[p] ?? 0) * (1 - a)
883 rgba[p + 1] = g * a + (rgba[p + 1] ?? 0) * (1 - a)
884 rgba[p + 2] = bl * a + (rgba[p + 2] ?? 0) * (1 - a)
885 rgba[p + 3] = a + (rgba[p + 3] ?? 0) * (1 - a)
886 }
887 }
888 }
889 // A character shows whole or not at all.
890 if (node.char !== undefined && v.fill !== null && a * canvas.charAlpha >= 0.5) {
891 const at = mapBox(inner, { x0: node.char.x, y0: node.char.y, x1: node.char.x, y1: node.char.y })
892 canvas.chars.push({ x: at.x0, y: at.y0, code: node.char.code, color: v.fill })
893 }
894 for (const child of node.children) {
895 paintNode(canvas, child, t, inner, a, v.fill, mask)
896 }
897}
898
899const toBase64 = (bytes: Uint8Array) => {
900 const own = (bytes as unknown as { toBase64?: () => string }).toBase64
901 if (typeof own === 'function') {
902 return own.call(bytes)
903 }
904 let text = ''
905 for (let i = 0; i < bytes.length; i += 0x8000) {
906 text += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
907 }
908 return (globalThis as unknown as { btoa: (s: string) => string }).btoa(text)
909}
910
911// The character and colors of a cell of quadrants, from its four pixels: the
912// two colors most of them have, the others taking the nearer of those. Only
913// the background can be the terminal's own color, which counts as
914// `background` for nearness.
915const quadrantCell = (pixels: number[], background: number): [number, number, number] => {
916 const counts = new Map<number, number>()
917 for (const c of pixels) {
918 counts.set(c, (counts.get(c) ?? 0) + 1)
919 }
920 const [most = DEFAULT_COLOR, other = most] = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c)
921 const [fg, bg] = most === DEFAULT_COLOR ? [other, most] : [most, other]
922 if (fg === DEFAULT_COLOR) {
923 return [SPACE, DEFAULT_COLOR, DEFAULT_COLOR]
924 }
925 const near = (c: number) => (c === DEFAULT_COLOR ? background : c)
926 const mask = pixels.reduce((m, c, i) => (c === fg || (c !== bg && distance(near(c), near(fg)) < distance(near(c), near(bg))) ? m | (1 << i) : m), 0)
927 return mask === 0b1111 ? [FULL_BLOCK, fg, fg] : [QUADRANTS[mask] ?? SPACE, fg, bg]
928}
929
930// Reads a scene's image into a picture the terminal can paint.
931export const compile = (svg: string, layout: Layout = 'halves'): Picture => {
932 const doc = parseXml(svg)
933 const rootRaw = doc.children.find(c => c.tag === 'svg') ?? { tag: 'svg', attrs: {}, children: [], text: '' }
934 const styles: string[] = []
935 const collect = (raw: Raw) => {
936 if (raw.tag === 'style') {
937 styles.push(raw.text)
938 }
939 raw.children.forEach(collect)
940 }
941 collect(rootRaw)
942 const sheet = parseCss(styles.join('\n'))
943 const ids = new Map<string, Node>()
944 const root = build(rootRaw, null, sheet, new Map(), ids, new Map())
945 const [vx = 0, vy = 0, vw = 0, vh = 0] = numbers(rootRaw.attrs.viewBox ?? '')
946 const width = Math.max(1, Math.round(vw))
947 const height = Math.max(2, Math.round(vh))
948 const across = layout === 'quadrants' ? 2 : 1
949 const columns = Math.ceil(width / across)
950 const rows = Math.ceil(height / 2)
951 const origin: Affine = { a: 1, d: 1, e: -vx, f: -vy }
952
953 const paint = (t: number, background: number) => {
954 const canvas: Canvas = { width: columns * across, height: rows * 2, rgba: new Float64Array(columns * across * rows * 2 * 4), ids, chars: [], charAlpha: 1 }
955 paintNode(canvas, root, t, origin, 1, 0x000000, null)
956 const base = [(background >> 16) & 0xff, (background >> 8) & 0xff, background & 0xff]
957 const colorAt = (x: number, y: number) => {
958 // The cells round the image up; what lies beyond its viewBox stays unpainted.
959 if (x >= width || y >= height) {
960 return DEFAULT_COLOR
961 }
962 const p = (y * canvas.width + x) * 4
963 const a = canvas.rgba[p + 3] ?? 0
964 if (a < MIN_ALPHA) {
965 return DEFAULT_COLOR
966 }
967 return [0, 1, 2].reduce((c, i) => (c << 8) | Math.min(255, Math.round((canvas.rgba[p + i] ?? 0) + (base[i] ?? 0) * (1 - a))), 0)
968 }
969 const cells: [number, number, number][] = []
970 for (let row = 0; row < rows; row++) {
971 for (let column = 0; column < columns; column++) {
972 if (layout === 'quadrants') {
973 const [x, y] = [column * 2, row * 2]
974 cells.push(quadrantCell([colorAt(x, y), colorAt(x + 1, y), colorAt(x, y + 1), colorAt(x + 1, y + 1)], background))
975 continue
976 }
977 const top = colorAt(column, row * 2)
978 const bottom = colorAt(column, row * 2 + 1)
979 cells.push(
980 top === DEFAULT_COLOR && bottom === DEFAULT_COLOR
981 ? [SPACE, DEFAULT_COLOR, DEFAULT_COLOR]
982 : top === bottom
983 ? [FULL_BLOCK, top, top]
984 : top === DEFAULT_COLOR
985 ? [LOWER_HALF, bottom, DEFAULT_COLOR]
986 : [UPPER_HALF, top, bottom],
987 )
988 }
989 }
990 // A character takes its cell, over the cell's color where it has one.
991 for (const char of canvas.chars) {
992 const [column, row] = [Math.floor(char.x / across), Math.floor(char.y / 2)]
993 const under = cells[row * columns + column]
994 if (column >= 0 && column < columns && row >= 0 && row < rows && under !== undefined) {
995 cells[row * columns + column] = [char.code, char.color, under[0] === SPACE ? DEFAULT_COLOR : under[1]]
996 }
997 }
998 const view = new DataView(new ArrayBuffer(columns * rows * 12))
999 cells.forEach(([code, fg, bg], i) => {
1000 view.setUint32(i * 12, code, true)
1001 view.setUint32(i * 12 + 4, fg, true)
1002 view.setUint32(i * 12 + 8, bg, true)
1003 })
1004 return toBase64(new Uint8Array(view.buffer))
1005 }
1006
1007 // Each animation, with the groups around its node whose opacity moves:
1008 // while one of them is hidden, what the animation moves doesn't show (a
1009 // pastime of Clawd's waiting its turn), and the group showing again is a
1010 // change of its own.
1011 const timed: { anim: Animation; gates: Node[] }[] = []
1012 const collectTimed = (node: Node, gates: Node[]) => {
1013 for (const anim of node.animations) {
1014 timed.push({ anim, gates })
1015 }
1016 const inner = node.animations.some(anim => anim.tracks.opacity !== undefined) ? [...gates, node] : gates
1017 node.children.forEach(child => collectTimed(child, inner))
1018 }
1019 collectTimed(root, [])
1020 const isHidden = (gates: Node[], t: number) => gates.some(gate => valuesAt(gate, t, null).opacity <= 1e-4)
1021 // A frame is painted a moment after a change; what is hidden then is.
1022 const nextChange = (t: number) =>
1023 timed.reduce((soonest, { anim, gates }) => {
1024 const at = nextChangeOf(anim, t)
1025 return at < soonest && !isHidden(gates, at + 0.001) ? at : soonest
1026 }, Infinity)
1027
1028 return { columns, rows, paint, nextChange }
1029}
1030
1031// A line of text as a Raster's cells, a column a character, in `color` over
1032// the terminal's own.
1033export const textCells = (line: string, color: number) => {
1034 const chars = [...line]
1035 const view = new DataView(new ArrayBuffer(chars.length * 12))
1036 chars.forEach((char, i) => {
1037 view.setUint32(i * 12, char.codePointAt(0) ?? SPACE, true)
1038 view.setUint32(i * 12 + 4, color, true)
1039 view.setUint32(i * 12 + 8, DEFAULT_COLOR, true)
1040 })
1041 return toBase64(new Uint8Array(view.buffer))
1042}
1043
1044// The pictures read last, so a band drawn again with the same image reads it once.
1045const pictures = new Map<string, Picture>()
1046const KEPT_PICTURES = 24
1047
1048export const pictureOf = (svg: string, layout: Layout = 'halves'): Picture => {
1049 const key = `${layout} ${svg}`
1050 const kept = pictures.get(key)
1051 if (kept !== undefined) {
1052 return kept
1053 }
1054 const picture = compile(svg, layout)
1055 pictures.set(key, picture)
1056 if (pictures.size > KEPT_PICTURES) {
1057 const oldest = pictures.keys().next().value
1058 if (oldest !== undefined) {
1059 pictures.delete(oldest)
1060 }
1061 }
1062 return picture
1063}
1064
1065// How many columns a text takes in a terminal: two for a wide character
1066// (Chinese, Japanese and Korean script, fullwidth forms, most emoji), none for
1067// a mark that sits on the character before it, one for the rest.
1068const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩\u{1f300}-\u{1f64f}\u{1f900}-\u{1f9ff}\u{20000}-\u{3fffd}]/u
1069const ZERO_WIDTH = /[\p{Mn}\p{Me}-]/u
1070
1071export const columnsOf = (text: string) => {
1072 let columns = 0
1073 for (const char of text) {
1074 columns += ZERO_WIDTH.test(char) ? 0 : WIDE.test(char) ? 2 : 1
1075 }
1076 return columns
1077}
1078
1079// Whether Claude Code counts the text shorter than a terminal draws it: it
1080// lays out a spacing mark (Devanagari's ि or ा) as taking no column, where a
1081// terminal gives it one.
1082export const isMeasuredShort = (text: string) => /\p{Mc}/u.test(text)
1083hooks/scenes/adventure.ts 249 lines1// The adventure scene: the session's meters as an adventurer's gear on the
2// stone floor of a keep: a mana potion for the context, an hourglass for the
3// prompt cache, a chest of gold for the five-hour limit, and a quiver of
4// arrows for the week. Each has its number underneath in the 3x5 pixel font.
5//
6// The hourglass runs by itself over the cache's life, so the band need not be
7// redrawn to keep it current. At a quarter of the context or less a spare
8// vial waits beside the potion; while the conversation is compacted the
9// potion fills up again, sparkling.
10
11import { wordsOf } from '../language'
12import type { MeterScene, Meters } from './index'
13import { countdown, COUNTDOWN_CSS, HEIGHT, hourSteps, level, percentText, pixelText, px, refill, REFILL_CSS, runsLow, SCALE, sceneSvg, SLOT, smallSceneSvg, stageOf, WIDTH } from './pixels'
14
15// Objects stand on the stone floor at y 9.
16const FLOOR_Y = 9
17
18const STONE = '#6E6A63'
19const STONE_DARK = '#57534D'
20const GLASS = '#A8C8D8'
21const CORK = '#A0785A'
22const MANA = '#4F74D9'
23const MANA_LIGHT = '#8FB0FF'
24const SPARK = '#F5E6A8'
25const WOOD = '#8B5A2B'
26const WOOD_DARK = '#6B4220'
27const SAND = '#E0C080'
28const IRON = '#5A5A5A'
29const GOLD = '#F2C14E'
30const GOLD_DARK = '#C8962E'
31const LEATHER = '#9A6438'
32const LEATHER_DARK = '#74482A'
33const SHAFT = '#C8A878'
34const FEATHER = '#C0443A'
35const FEATHER_LIGHT = '#E8E6DC'
36
37// The potion's inside, row by row from the bottom: [y, first x, last x].
38const POTION: [number, number, number][] = [[7, 6, 9], [6, 5, 10], [5, 5, 10], [4, 5, 10], [3, 6, 9]]
39
40const potionRows = (x: number, rows: number) =>
41 POTION.slice(0, rows).map(([y, a, b]) => px(x + a, y, b - a + 1, 1)).join('')
42
43// Context: a mana potion, as full as the context left, bubbling. At a quarter
44// or less a spare vial stands by; while the conversation is compacted the
45// potion fills up again with sparkles over it.
46const potion = (left: number | null, x: number, isCompacting: boolean) => {
47 const rows = level(left, POTION.length)
48 const isLow = runsLow(left)
49 const mana = isCompacting
50 ? `<g style="${refill(POTION.length - rows)}">${potionRows(x, POTION.length)}</g>`
51 : potionRows(x, rows)
52 const top = POTION[Math.max(0, (isCompacting ? POTION.length : rows) - 1)]?.[0] ?? 8
53 return `
54 <g fill="${MANA}">${mana}</g>
55 ${rows > 1 || isCompacting ? `<g fill="${MANA_LIGHT}"><g class="bubble1">${px(x + 6, 6)}</g><g class="bubble2">${px(x + 8, 7)}</g></g>` : ''}
56 <g fill="${GLASS}">
57 ${px(x + 6, 0, 1, 3)}${px(x + 9, 0, 1, 3)}${px(x + 5, 3)}${px(x + 10, 3)}
58 ${px(x + 4, 4, 1, 3)}${px(x + 11, 4, 1, 3)}${px(x + 5, 7)}${px(x + 10, 7)}${px(x + 6, 8, 4, 1)}
59 </g>
60 <g fill="#FFFFFF" opacity="0.5">${px(x + 6, 4, 1, 2)}</g>
61 <g fill="${CORK}">${px(x + 7, -1, 2, 2)}</g>
62 ${isLow && !isCompacting ? `<g fill="${GLASS}">${px(x + 12, 5, 1, 4)}${px(x + 14, 5, 1, 4)}${px(x + 13, 8)}</g><g fill="${MANA}">${px(x + 13, 6, 1, 2)}</g><g fill="${CORK}">${px(x + 13, 4)}</g>` : ''}
63 ${isCompacting ? `<g fill="${SPARK}"><g class="spark1">${px(x + 5, Math.min(top, 3) - 2)}</g><g class="spark2">${px(x + 10, Math.min(top, 3) - 3)}</g><g class="spark3">${px(x + 12, 1)}</g></g>` : ''}
64 ${percentText(left, x)}`
65}
66
67// The sand in the hourglass's bulbs: the top one's four rows, the last in the
68// neck, and the bottom one's three. The glass is centred on x 7.
69const topSand = (x: number) => `${px(x + 5, 0, 5, 2)}${px(x + 6, 2, 3, 1)}${px(x + 7, 3)}`
70const bottomSand = (x: number) => `${px(x + 6, 5, 3, 1)}${px(x + 5, 6, 5, 2)}`
71
72// Cache: an hourglass whose sand runs by itself over the cache's life, the top
73// bulb draining and the bottom one filling, one grain at a time falling
74// through the neck; it has run out once the cache expires.
75const hourglass = (left: number | null, ttl: number, x: number) => {
76 const isOut = left !== null && left <= 0
77 const timing = left === null || isOut ? '' : `animation-duration: ${ttl}s; animation-delay: -${ttl - left}s`
78 const sand = left === null
79 ? `<g fill="${SAND}">${topSand(x)}</g>`
80 : isOut
81 ? `<g fill="${SAND}">${bottomSand(x)}</g>`
82 : `<g fill="${SAND}"><g class="drain" style="${timing}">${topSand(x)}</g><g class="pile" style="${timing}">${bottomSand(x)}</g><g class="fall">${px(x + 7, 4)}</g></g>`
83 return `
84 <g fill="${GLASS}" opacity="0.6">
85 ${px(x + 4, 0, 1, 2)}${px(x + 10, 0, 1, 2)}${px(x + 5, 2)}${px(x + 9, 2)}
86 ${px(x + 6, 3, 1, 2)}${px(x + 8, 3, 1, 2)}
87 ${px(x + 5, 5)}${px(x + 9, 5)}${px(x + 4, 6, 1, 2)}${px(x + 10, 6, 1, 2)}
88 </g>
89 ${sand}
90 <g fill="${WOOD}">${px(x + 2, -1, 11, 1)}${px(x + 2, 8, 11, 1)}</g>
91 <g fill="${WOOD_DARK}">${px(x + 2, 0, 1, 8)}${px(x + 12, 0, 1, 8)}</g>
92 ${left === null ? pixelText('--', x) : isOut ? pixelText('0m', x) : countdown(left, x)}`
93}
94
95// The gold heaped in the chest, row by row from the rim up: [y, first x, last x].
96const HEAP: [number, number, number][] = [[4, 4, 11], [3, 5, 10], [2, 6, 9], [1, 7, 8]]
97
98// The last few coins, before the chest is empty.
99const FEW_COINS: [number, number, number] = [4, 6, 8]
100
101// Five-hour limit: an open chest whose heap of gold is the window still free,
102// down to a few coins, a glint on it now and then.
103const chest = (used: number | null, x: number) => {
104 const left = used === null ? null : 100 - used
105 const steps = left === null ? HEAP.length + 1 : level(left, HEAP.length + 1)
106 const heap = steps === 0 ? [] : steps === 1 ? [FEW_COINS] : HEAP.slice(0, steps - 1)
107 const top = heap[heap.length - 1]
108 return `
109 <g fill="${WOOD_DARK}">${px(x + 3, 1, 10, 4)}${px(x + 4, 0, 8, 1)}</g>
110 <g fill="${IRON}">${px(x + 3, 2, 10, 1)}</g>
111 <g fill="${GOLD}">${heap.map(([y, a, b]) => px(x + a, y, b - a + 1, 1)).join('')}</g>
112 <g fill="${GOLD_DARK}">${heap.map(([y, , b]) => px(x + b, y)).join('')}${heap.length > 1 ? px(x + 6, 4) + px(x + 9, 3) : ''}</g>
113 ${top ? `<g class="glint" fill="#FFFFFF">${px(x + top[1] + 1, top[0])}</g>` : ''}
114 <g fill="${WOOD}">${px(x + 3, 5, 10, 4)}</g>
115 <g fill="${IRON}">${px(x + 3, 5, 10, 1)}${px(x + 5, 6, 1, 3)}${px(x + 10, 6, 1, 3)}</g>
116 <g fill="${GOLD}">${px(x + 7, 6, 2, 2)}</g>
117 <g fill="${WOOD_DARK}">${px(x + 7, 7)}</g>
118 ${percentText(left, x)}`
119}
120
121// Where each arrow stands in the quiver, as [x, the top of its fletching],
122// the first ones drawn last to go.
123const ARROWS: [number, number][] = [[7, -2], [9, -1], [5, -1], [8, 0], [6, 0], [10, 0]]
124
125// Weekly limit: a quiver with an arrow for every sixth of the week still free.
126const quiver = (used: number | null, x: number) => {
127 const left = used === null ? null : 100 - used
128 const count = left === null ? ARROWS.length : Math.max(left > 0 ? 1 : 0, Math.round((left / 100) * ARROWS.length))
129 const arrows = ARROWS.slice(0, count)
130 return `
131 <g fill="${SHAFT}">${arrows.map(([ax, ty]) => px(x + ax, ty + 2, 1, 3 - ty)).join('')}</g>
132 ${arrows.map(([ax, ty], i) => `<g fill="${i % 2 === 0 ? FEATHER : FEATHER_LIGHT}">${px(x + ax, ty, 1, 2)}</g>`).join('')}
133 <g fill="${LEATHER}">${px(x + 5, 3, 6, 5)}${px(x + 6, 8, 4, 1)}</g>
134 <g fill="${LEATHER_DARK}">${px(x + 5, 3, 6, 1)}${px(x + 5, 6, 6, 1)}${px(x + 10, 4, 1, 4)}</g>
135 <g fill="${LEATHER_DARK}">${px(x + 11, 2)}${px(x + 12, 1)}${px(x + 4, 7)}${px(x + 3, 8)}</g>
136 ${percentText(left, x)}`
137}
138
139// Stone slabs, each four pixels wide, a darker seam between them.
140const floor = () =>
141 Array.from({ length: WIDTH / 4 }, (_, i) => `<g fill="${i % 2 === 0 ? STONE : STONE_DARK}">${px(i * 4, FLOOR_Y, 4, 1)}</g>`).join('')
142
143const adventureSvg = (f: Meters) =>
144 sceneSvg(
145 `${COUNTDOWN_CSS}
146 ${REFILL_CSS}
147 .bubble1 { animation: bubble 1.8s steps(3) infinite; }
148 .bubble2 { animation: bubble 1.8s steps(3) -0.9s infinite; }
149 @keyframes bubble { 0% { transform: translate(0, 0); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(0, -3px); opacity: 0; } }
150 .spark1 { animation: twinkle 0.6s steps(1) infinite; }
151 .spark2 { animation: twinkle 0.6s steps(1) -0.2s infinite; }
152 .spark3 { animation: twinkle 0.6s steps(1) -0.4s infinite; }
153 @keyframes twinkle { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
154 .drain { animation-name: drain; animation-timing-function: steps(4); animation-fill-mode: forwards; }
155 .pile { animation-name: pile; animation-timing-function: steps(3); animation-fill-mode: forwards; }
156 @keyframes drain { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(4px 0 0 0); } }
157 @keyframes pile { from { clip-path: inset(3px 0 0 0); } to { clip-path: inset(0 0 0 0); } }
158 .fall { animation: fall 1.8s steps(3) infinite; }
159 @keyframes fall { 0% { transform: translate(0, 0); opacity: 1; } 100% { transform: translate(0, 3px); opacity: 0.5; } }
160 .glint { animation: glint 2.6s steps(1) infinite; }
161 @keyframes glint { 0%, 79.9% { opacity: 0; } 80%, 89.9% { opacity: 1; } 90%, 100% { opacity: 0; } }`,
162 `
163 ${floor()}
164 ${potion(f.contextLeft, 0, f.isCompacting)}
165 ${hourglass(f.cacheLeft, f.cacheTtl, SLOT)}
166 ${chest(f.fiveHour, SLOT * 2)}
167 ${quiver(f.week, SLOT * 3)}`,
168 )
169
170// The scene in the small size, on the stone floor: the mana potion, as full
171// as the context left, a spare vial beside it at a quarter or less, filling up
172// while compacting; the hourglass, its sand running over the cache's life, a
173// grain falling through the neck now and then while it runs; the chest, its heap of gold going with the five-hour limit; and the quiver,
174// losing its arrows over the week.
175const CENTERS = [4, 14, 25.5, 35]
176
177// The potion's inside, row by row from the bottom: [y, first x, last x].
178const SMALL_FLASK: readonly (readonly [number, number, number])[] = [[6, 3, 5], [5, 2, 6], [4, 2, 6], [3, 2, 6], [2, 3, 5]]
179
180const adventureSmall = (f: Meters) => {
181 const mana = f.isCompacting ? 5 : ([0, 1, 3, 5][stageOf(f.contextLeft)] ?? 0)
182 const isLow = !f.isCompacting && runsLow(f.contextLeft)
183 const inside = (rows: readonly (readonly [number, number, number])[]) => rows.map(([y, a, b]) => px(a, y, b - a + 1, 1)).join('')
184 const potion = `
185 <g fill="${CORK}">${px(4, 0)}</g>
186 <g fill="${GLASS}">${px(4, 1)}${inside(SMALL_FLASK)}</g>
187 ${mana > 0 ? `<g${f.isCompacting ? ' class="fill"' : ''} fill="${MANA}">${inside(SMALL_FLASK.slice(0, mana))}</g>` : ''}
188 ${mana > 1 ? `<g fill="${MANA_LIGHT}">${px(3, 7 - mana)}</g><g class="bubble" fill="${SPARK}">${px(4, 5)}</g>` : ''}
189 ${isLow ? `<g fill="${CORK}">${px(8, 4)}</g><g fill="${GLASS}">${px(8, 5)}</g><g fill="${MANA}">${px(8, 6)}</g>` : ''}`
190
191 const glass = `<g fill="${GLASS}">${px(13, 1, 3, 2)}${px(14, 3)}${px(13, 4, 3, 2)}</g>`
192 const frame = `<g fill="${WOOD_DARK}">${px(12, 0, 5, 1)}${px(12, 6, 5, 1)}</g><g fill="${WOOD}">${px(12, 1, 1, 5)}${px(16, 1, 1, 5)}</g>`
193 const isRunning = f.cacheLeft !== null && f.cacheLeft > 0
194 const hourglass = `${hourSteps(
195 f,
196 [
197 `${glass}<g fill="${SAND}">${px(13, 1, 3, 2)}${px(14, 5)}</g>${frame}`,
198 `${glass}<g fill="${SAND}">${px(13, 2, 3, 1)}${px(13, 5, 3, 1)}</g>${frame}`,
199 `${glass}<g fill="${SAND}">${px(14, 2)}${px(13, 5, 3, 1)}${px(14, 4)}</g>${frame}`,
200 ],
201 `${glass}<g fill="${SAND}">${px(13, 4, 3, 2)}</g>${frame}`,
202 )}${isRunning ? `<g class="grain" fill="${SAND}">${px(14, 3)}</g>` : ''}`
203
204 const fiveLeft = f.fiveHour === null ? null : 100 - f.fiveHour
205 const step = stageOf(fiveLeft)
206 const gold = [
207 '',
208 px(25, 2, 2, 1),
209 px(23, 2, 6, 1),
210 `${px(24, 1, 4, 1)}${px(23, 2, 6, 1)}`,
211 ][step]
212 const chest = `
213 <g fill="${WOOD_DARK}">${px(23, 0, 6, 1)}${px(22, 3, 8, 1)}</g>
214 ${gold ? `<g fill="${GOLD}">${gold}</g><g fill="${GOLD_DARK}">${step > 1 ? px(24, 2) + px(27, 2) : px(26, 2)}</g>` : ''}
215 <g fill="${WOOD}">${px(22, 4, 8, 3)}</g>
216 <g fill="${IRON}">${px(22, 4, 1, 3)}${px(29, 4, 1, 3)}</g>
217 <g fill="${GOLD}">${px(25, 5, 2, 1)}</g>`
218
219 const weekLeft = f.week === null ? null : 100 - f.week
220 const arrows = [0, 1, 2, 3][stageOf(weekLeft)] ?? 0
221 const quiver = `
222 ${[35, 34, 36].slice(0, arrows).map(x => `<g fill="${SHAFT}">${px(x, 1, 1, 2)}</g><g fill="${x === 35 ? FEATHER_LIGHT : FEATHER}">${px(x, 0)}</g>`).join('')}
223 <g fill="${LEATHER}">${px(34, 3, 3, 4)}</g>
224 <g fill="${LEATHER_DARK}">${px(36, 3, 1, 4)}${px(34, 4, 2, 1)}</g>`
225
226 const floor = `<g fill="${STONE}">${px(0, 7, 40, 1)}</g><g fill="${STONE_DARK}">${Array.from({ length: 13 }, (_, i) => px(i * 3 + 2, 7)).join('')}</g>`
227
228 return smallSceneSvg(
229 `
230 .fill { animation: fill 2s steps(5) both; }
231 @keyframes fill { from { clip-path: inset(5px 0 0 0); } to { clip-path: inset(0 0 0 0); } }
232 .bubble { animation: bubble 1.2s steps(1) infinite; }
233 @keyframes bubble { 0%, 32.9% { transform: translate(0, 0); } 33%, 65.9% { transform: translate(0, -1px); } 66%, 100% { opacity: 0; } }
234 .grain { animation: grain 3s steps(1) infinite; }
235 @keyframes grain { 0%, 33.2% { transform: translate(0, 0); } 33.3%, 66.5% { transform: translate(0, 1px); } 66.6%, 100% { opacity: 0; } }`,
236 `${floor}${potion}${hourglass}${chest}${quiver}`,
237 )
238}
239
240export const adventureScene: MeterScene = {
241 name: 'adventure',
242 label: wordsOf(t => t.scenes.adventure),
243 width: WIDTH,
244 height: HEIGHT,
245 scale: SCALE,
246 svg: adventureSvg,
247 small: { svg: adventureSmall, centers: CENTERS },
248}
249hooks/scenes/balcony.ts 309 lines1// The balcony scene: the session's meters as things on a sunny balcony: a
2// watering can for the context, a daisy in a pot for the prompt cache, a bird
3// feeder for the five-hour limit, and a jar of honey for the week. Each has its
4// number underneath in the 3x5 pixel font.
5//
6// The daisy wilts by itself over the cache's life, so the band need not be
7// redrawn to keep it current. At a quarter of the context or less a rain cloud
8// gathers over the watering can; while the conversation is compacted it rains
9// and the can fills up.
10
11import { wordsOf } from '../language'
12import type { MeterScene, Meters } from './index'
13import { countdown, COUNTDOWN_CSS, HEIGHT, hops, hourSteps, level, percentText, pixelText, px, refill, REFILL_CSS, runsLow, SCALE, sceneSvg, SLOT, smallSceneSvg, stageOf, WIDTH } from './pixels'
14
15// Objects stand on the floor tiles at y 9.
16const FLOOR_Y = 9
17
18const TILE = '#B5674A'
19const TILE_DARK = '#8F4E37'
20const RAIL = '#3A332D'
21const CAN = '#5E8C6A'
22const CAN_DARK = '#476B51'
23const WATER = '#8FD3F0'
24const CLOUD = '#B8C4D6'
25const CLOUD_DARK = '#8E9AB0'
26const POT = '#C2693E'
27const POT_DARK = '#9A4F2E'
28const SOIL = '#5A3A24'
29const STEM = '#6FA35A'
30const STEM_DRY = '#8A7A4A'
31const PETAL = '#F4F1E6'
32const PETAL_DRY = '#A07850'
33const HEART = '#F5C26B'
34const WOOD = '#A0785A'
35const WOOD_DARK = '#7A5A42'
36const GLASS = '#8C8A84'
37const SEED = '#D9B26A'
38const SEED_DARK = '#A07840'
39const BIRD = '#5B8FD6'
40const BIRD_DARK = '#3F6DAE'
41const BIRD_BELLY = '#A9C6EE'
42const BEAK = '#F5A623'
43const EYE = '#F4F1E6'
44const STRIPE = '#7A5520'
45const HONEY = '#E0A030'
46const HONEY_LIGHT = '#F2C45A'
47const CLOTH = '#B5483E'
48const CREAM = '#E8E6DC'
49const BEE = '#F5C26B'
50
51// The can holds this many rows of water.
52const CAN_ROWS = 5
53
54// A small rain cloud over the can's mouth. While the conversation is
55// compacted, rain falls from it into the can.
56const cloud = (x: number, isRaining: boolean) => `
57 <g class="drift">
58 <g fill="${CLOUD}">${px(x + 6, -2, 4, 1)}${px(x + 5, -1, 7, 1)}</g>
59 <g fill="${CLOUD_DARK}">${px(x + 5, -1)}${px(x + 11, -1)}</g>
60 </g>
61 ${isRaining ? `<g fill="${WATER}">
62 <g class="rain1">${px(x + 6, 0)}</g><g class="rain2">${px(x + 8, 0)}</g><g class="rain3">${px(x + 10, 0)}</g>
63 </g>` : ''}`
64
65// Context: a watering can, open at the top and seen through, its handle at
66// the back and its spout rising to the left; its water level is the context
67// left. A drop falls from its rose now and then while there is water to spare. At a
68// quarter or less a rain cloud gathers over it; while the conversation is
69// compacted the cloud rains and the can fills up.
70const wateringCan = (left: number | null, x: number, isCompacting: boolean) => {
71 const rows = level(left, CAN_ROWS)
72 const isLow = runsLow(left)
73 const water = isCompacting
74 ? `<g style="${refill(CAN_ROWS - rows)}">${px(x + 6, 8 - CAN_ROWS, 5, CAN_ROWS)}</g>`
75 : rows > 0 ? px(x + 6, 8 - rows, 5, rows) : ''
76 return `
77 <g fill="${WATER}">${water}</g>
78 <g fill="${CAN}">${px(x + 5, 3, 1, 6)}${px(x + 11, 3, 1, 6)}${px(x + 6, 8, 5, 1)}${px(x + 4, 6)}${px(x + 3, 5)}${px(x + 2, 4)}</g>
79 <g fill="${CAN_DARK}">${px(x + 4, 2, 2, 1)}${px(x + 11, 2, 2, 1)}${px(x + 13, 3)}${px(x + 14, 4, 1, 2)}${px(x + 13, 6)}${px(x + 12, 6)}${px(x + 1, 3)}${px(x, 2, 1, 2)}</g>
80 ${rows > 1 && !isCompacting ? `<g fill="${WATER}"><g class="drip">${px(x, 4)}</g></g>` : ''}
81 ${isLow || isCompacting ? cloud(x, isCompacting) : ''}
82 ${percentText(left, x)}`
83}
84
85// A daisy's head around (cx, cy): petals five pixels across around a
86// one-pixel heart.
87const bloom = (cx: number, cy: number, petal: string, heart: string, petalOpacity = 1) => `
88 <g fill="${petal}" opacity="${petalOpacity}">${px(cx, cy - 2)}${px(cx - 1, cy - 1, 3, 1)}${px(cx - 2, cy, 2, 1)}${px(cx + 1, cy, 2, 1)}${px(cx - 1, cy + 1, 3, 1)}${px(cx, cy + 2)}</g>
89 <g fill="${heart}">${px(cx, cy)}</g>`
90
91// The daisy in its four stages over the cache's life: upright, nodding,
92// drooping, wilted; each a group shown for its quarter.
93const daisy = (x: number, timing: string) => `
94 <g class="stage0" style="${timing}">
95 <g fill="${STEM}">${px(x + 8, 3, 1, 2)}${px(x + 9, 3)}${px(x + 7, 4)}</g>
96 ${bloom(x + 8, 0, PETAL, HEART)}
97 </g>
98 <g class="stage1" style="${timing}">
99 <g fill="${STEM}">${px(x + 8, 3, 1, 2)}${px(x + 9, 3)}${px(x + 7, 4)}</g>
100 ${bloom(x + 10, 0, PETAL, HEART)}
101 </g>
102 <g class="stage2" style="${timing}">
103 <g fill="${STEM}">${px(x + 8, 1, 1, 4)}${px(x + 9, 0)}${px(x + 10, 0)}${px(x + 7, 4)}</g>
104 ${bloom(x + 12, 2, PETAL, HEART, 0.8)}
105 </g>
106 <g class="stage3" style="${timing}">
107 <g fill="${STEM_DRY}">${px(x + 8, 2, 1, 3)}${px(x + 9, 1)}${px(x + 10, 1)}${px(x + 11, 2)}${px(x + 7, 4)}</g>
108 <g fill="${PETAL_DRY}">${px(x + 12, 3)}${px(x + 11, 4)}${px(x + 13, 4)}${px(x + 12, 5, 2, 1)}</g>
109 <g fill="${HEART}" opacity="0.7">${px(x + 12, 4)}</g>
110 </g>`
111
112// The pot the daisy grows in.
113const pot = (x: number) => `
114 <g fill="${POT}">${px(x + 5, 5, 7, 1)}${px(x + 6, 6, 5, 3)}</g>
115 <g fill="${POT_DARK}">${px(x + 5, 5, 7, 1)}${px(x + 10, 6, 1, 3)}</g>
116 <g fill="${SOIL}">${px(x + 6, 5, 5, 1)}</g>`
117
118// Cache: a daisy in a pot. It wilts by itself over the cache's life, one stage
119// a quarter, and stays wilted, with a fallen petal, once the cache expires.
120const flowerPot = (left: number | null, ttl: number, x: number) => {
121 if (left === null) {
122 return `${pot(x)}<g fill="${STEM}">${px(x + 8, 3, 1, 2)}${px(x + 9, 3)}</g>${pixelText('--', x)}`
123 }
124 if (left <= 0) {
125 return `${pot(x)}
126 <g fill="${STEM_DRY}">${px(x + 8, 3, 1, 2)}${px(x + 9, 2)}${px(x + 10, 2)}${px(x + 11, 3)}${px(x + 7, 4)}</g>
127 <g fill="${PETAL_DRY}">${px(x + 11, 4)}${px(x + 12, 5)}${px(x + 13, 8)}</g>
128 ${pixelText('0m', x)}`
129 }
130 return `
131 ${daisy(x, `animation-duration: ${ttl}s; animation-delay: -${ttl - left}s`)}
132 ${pot(x)}
133 ${countdown(left, x)}`
134}
135
136// Where the seeds sit in the feeder's glass, from the bottom.
137const SEED_ROWS = 5
138
139// Five-hour limit: a bird feeder whose glass holds the seeds left, a row for
140// every fifth of the window, a bluebird pecking at the tray. With the seeds
141// gone, so is the bird.
142const feeder = (used: number | null, x: number) => {
143 const left = used === null ? null : 100 - used
144 const rows = left === null ? SEED_ROWS : level(left, SEED_ROWS)
145 return `
146 <g fill="${WOOD_DARK}">${px(x + 9, 6, 1, 2)}${px(x + 8, 8, 3, 1)}${px(x + 8, -2, 3, 1)}</g>
147 <g fill="${WOOD}">${px(x + 6, -1, 7, 1)}${px(x + 1, 5, 14, 1)}${px(x + 14, 4)}</g>
148 <g fill="${GLASS}">${px(x + 7, 0, 1, 5)}${px(x + 11, 0, 1, 5)}</g>
149 ${rows > 0 ? `<g fill="${SEED}">${px(x + 8, 5 - rows, 3, rows)}</g>
150 <g fill="${SEED_DARK}">${Array.from({ length: rows }, (_, i) => px(x + 8 + ((i * 2) % 3), 4 - i)).join('')}${px(x + 6, 4)}${px(x + 12, 4)}</g>
151 <g fill="${BIRD}">${px(x + 2, 3, 3, 2)}</g>
152 <g fill="${BIRD_DARK}">${px(x + 2, 3, 2, 1)}${px(x + 1, 3)}${px(x, 2)}</g>
153 <g fill="${BIRD_BELLY}">${px(x + 3, 4, 2, 1)}</g>
154 <g class="peck">
155 <g fill="${BIRD}">${px(x + 4, 2, 2, 1)}${px(x + 5, 3)}</g>
156 <g fill="${EYE}">${px(x + 5, 2)}</g>
157 <g fill="${BEAK}">${px(x + 6, 3)}</g>
158 </g>` : ''}
159 ${percentText(left, x)}`
160}
161
162// Where the honey sits in the jar, from the bottom.
163const HONEY_ROWS = 5
164
165// Weekly limit: a jar of honey under a checked cloth that empties as the
166// week's limit is used, a row for every fifth of the week, a bee buzzing
167// around it while there is honey.
168const honeyJar = (used: number | null, x: number) => {
169 const left = used === null ? null : 100 - used
170 const rows = left === null ? HONEY_ROWS : level(left, HONEY_ROWS)
171 return `
172 ${rows > 0 ? `<g fill="${HONEY}">${px(x + 6, 8 - rows, 5, rows)}</g><g fill="${HONEY_LIGHT}">${px(x + 7, 8 - rows, 1, rows)}</g>` : ''}
173 <g fill="${GLASS}">${px(x + 5, 3, 1, 6)}${px(x + 11, 3, 1, 6)}${px(x + 6, 8, 5, 1)}</g>
174 <g fill="${CREAM}">${px(x + 4, 1, 9, 1)}${px(x + 4, 2)}${px(x + 12, 2)}</g>
175 <g fill="${CLOTH}">${px(x + 5, 1)}${px(x + 7, 1)}${px(x + 9, 1)}${px(x + 11, 1)}${px(x + 5, 2, 7, 1)}</g>
176 <g fill="#FFFFFF" opacity="0.4">${px(x + 10, 4, 1, 2)}</g>
177 ${rows > 0 ? `<g class="bee">
178 <g fill="${BEE}">${px(x + 13, -1)}${px(x + 15, -1)}</g><g fill="${STRIPE}">${px(x + 14, -1)}</g>
179 <g class="wings" fill="${CREAM}" opacity="0.8">${px(x + 14, -2)}</g>
180 </g>` : ''}
181 ${percentText(left, x)}`
182}
183
184// The balcony: a dim railing behind everything and a row of floor tiles. The
185// balusters stand where no glass or open can lets them show through.
186const railing = () => `
187 <g fill="${RAIL}">${px(0, 0, WIDTH, 1)}${Array.from({ length: WIDTH / 8 }, (_, i) => px(i * 8 + 3, 1, 1, FLOOR_Y - 1)).join('')}</g>`
188
189const floor = () =>
190 Array.from({ length: WIDTH / 4 }, (_, i) => `<g fill="${i % 2 === 0 ? TILE : TILE_DARK}">${px(i * 4, FLOOR_Y, 4, 1)}</g>`).join('')
191
192const balconySvg = (f: Meters) =>
193 sceneSvg(
194 `${COUNTDOWN_CSS}
195 ${REFILL_CSS}
196 .drift { animation: drift 4s steps(1) infinite; }
197 @keyframes drift { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(1px, 0); } }
198 .rain1 { animation: rain 0.6s steps(3) infinite; }
199 .rain2 { animation: rain 0.6s steps(3) -0.2s infinite; }
200 .rain3 { animation: rain 0.6s steps(3) -0.4s infinite; }
201 @keyframes rain { from { transform: translate(0, 0); } to { transform: translate(0, 3px); } }
202 .drip { animation: drip 3s steps(1) infinite; }
203 @keyframes drip { 0%, 69.9% { opacity: 0; transform: translate(0, 0); } 70%, 79.9% { opacity: 1; transform: translate(0, 1px); } 80%, 89.9% { opacity: 1; transform: translate(0, 3px); } 90%, 100% { opacity: 0; transform: translate(0, 4px); } }
204 .stage0 { animation-name: stage0; }
205 .stage1 { animation-name: stage1; }
206 .stage2 { animation-name: stage2; }
207 .stage3 { animation-name: stage3; }
208 .stage0, .stage1, .stage2, .stage3 { animation-timing-function: steps(1); animation-fill-mode: forwards; }
209 @keyframes stage0 { 0%, 24.9% { opacity: 1; } 25%, 100% { opacity: 0; } }
210 @keyframes stage1 { 0%, 24.9% { opacity: 0; } 25%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
211 @keyframes stage2 { 0%, 49.9% { opacity: 0; } 50%, 74.9% { opacity: 1; } 75%, 100% { opacity: 0; } }
212 @keyframes stage3 { 0%, 74.9% { opacity: 0; } 75%, 100% { opacity: 1; } }
213 .peck { animation: peck 1.4s steps(1) infinite; }
214 @keyframes peck { 0%, 59.9% { transform: translate(0, 0); } 60%, 69.9% { transform: translate(0, 1px); } 70%, 79.9% { transform: translate(0, 0); } 80%, 89.9% { transform: translate(0, 1px); } 90%, 100% { transform: translate(0, 0); } }
215 .bee { animation: buzz 3.2s steps(16) infinite; }
216 @keyframes buzz { 0% { transform: translate(0, 0); } 25% { transform: translate(-3px, 1px); } 50% { transform: translate(-6px, 0); } 75% { transform: translate(-3px, -1px); } 100% { transform: translate(0, 0); } }
217 .wings { animation: flap 0.2s steps(1) infinite; }
218 @keyframes flap { 0%, 49.9% { opacity: 0.8; } 50%, 100% { opacity: 0; } }`,
219 `
220 ${railing()}
221 ${floor()}
222 ${wateringCan(f.contextLeft, 0, f.isCompacting)}
223 ${flowerPot(f.cacheLeft, f.cacheTtl, SLOT)}
224 ${feeder(f.fiveHour, SLOT * 2)}
225 ${honeyJar(f.week, SLOT * 3)}`,
226 )
227
228// The scene in the small size, on the tiles: the watering can, as full as
229// the context left, a rain cloud over it at a quarter or less that rains into
230// it while compacting; the daisy, upright, nodding, wilting and fallen over
231// the cache's life; the feeder, its seeds going with the five-hour limit, the
232// bluebird pecking at it; and the jar of honey, emptying over the week, the
233// bee buzzing around it.
234const CENTERS = [5, 13, 23, 33]
235
236// Where the bee flies, from where it is drawn: around the jar's right side,
237// then over its lid and back.
238const BEE_PATH: [number, number][] = [[0, 0], [1, 1], [1, 3], [0, 4], [1, 2], [-2, 0], [-4, 0], [-2, 0]]
239
240const balconySmall = (f: Meters) => {
241 const water = f.isCompacting ? 3 : ([0, 1, 2, 3][stageOf(f.contextLeft)] ?? 0)
242 const isLow = f.isCompacting || runsLow(f.contextLeft)
243 const can = `
244 <g fill="${CAN_DARK}">${px(4, 3, 3, 3)}${px(8, 3, 1, 2)}</g>
245 ${water > 0 ? `<g${f.isCompacting ? ' class="fill"' : ''} fill="${WATER}">${px(4, 6 - water, 3, water)}</g>` : ''}
246 <g fill="${CAN}">${px(3, 3, 1, 4)}${px(7, 3, 1, 4)}${px(4, 6, 3, 1)}${px(2, 4)}${px(1, 3)}</g>
247 ${isLow ? `<g fill="${CLOUD}">${px(5, 0, 3, 1)}${px(4, 1, 5, 1)}</g><g fill="${CLOUD_DARK}">${px(5, 1, 3, 1)}</g>` : ''}
248 ${f.isCompacting ? `<g fill="${WATER}"><g class="drop0">${px(4, 2)}</g><g class="drop1">${px(6, 2)}</g></g>` : ''}`
249
250 const pot = `<g fill="${POT}">${px(11, 5, 5, 1)}</g><g fill="${POT_DARK}">${px(12, 6, 3, 1)}</g>`
251 const head = (x: number, y: number, petal: string) =>
252 `<g fill="${petal}">${px(x - 1, y)}${px(x + 1, y)}${px(x, y - 1)}${px(x, y + 1)}</g><g fill="${HEART}">${px(x, y)}</g>`
253 const daisy = hourSteps(
254 f,
255 [
256 `${pot}<g fill="${STEM}">${px(13, 3, 1, 2)}</g>${head(13, 1, PETAL)}`,
257 `${pot}<g fill="${STEM}">${px(13, 4)}${px(14, 3)}</g>${head(15, 2, PETAL)}`,
258 `${pot}<g fill="${STEM_DRY}">${px(13, 4)}${px(14, 3)}</g>${head(15, 4, PETAL_DRY)}`,
259 ],
260 `${pot}<g fill="${STEM_DRY}">${px(13, 3, 1, 2)}</g><g fill="${PETAL_DRY}">${px(16, 6)}${px(17, 6)}</g>`,
261 )
262
263 const fiveLeft = f.fiveHour === null ? null : 100 - f.fiveHour
264 const seeds = [0, 1, 2, 3][stageOf(fiveLeft)] ?? 0
265 const feeder = `
266 <g fill="${GLASS}">${px(24, 2, 3, 3)}</g>
267 ${seeds > 0 ? `<g fill="${SEED}">${px(24, 5 - seeds, 3, seeds)}</g><g fill="${SEED_DARK}">${px(25, 4)}</g>` : ''}
268 <g fill="${WOOD_DARK}">${px(23, 1, 5, 1)}${px(25, 6)}</g>
269 <g fill="${WOOD}">${px(23, 5, 6, 1)}</g>
270 <g fill="${BIRD_DARK}">${px(18, 4)}</g>
271 <g fill="${BIRD}">${px(19, 4, 2, 1)}</g>
272 <g fill="${BIRD_BELLY}">${px(19, 5, 2, 1)}</g>
273 <g class="peck"><g fill="${BIRD}">${px(21, 3)}</g><g fill="${BEAK}">${px(22, 3)}</g></g>`
274
275 const weekLeft = f.week === null ? null : 100 - f.week
276 const honey = [0, 1, 2, 4][stageOf(weekLeft)] ?? 0
277 const jar = `
278 ${honey > 0 ? `<g fill="${HONEY}">${px(32, 6 - honey, 3, honey)}</g><g fill="${HONEY_LIGHT}">${px(32, 6 - honey, 3, 1)}</g>` : ''}
279 <g fill="${GLASS}">${px(31, 2, 1, 5)}${px(35, 2, 1, 5)}${px(32, 6, 3, 1)}</g>
280 <g fill="${CLOTH}">${px(31, 1, 5, 1)}</g><g fill="${CREAM}">${px(32, 1)}${px(34, 1)}</g>
281 <g class="bee"><g fill="${BEE}">${px(37, 0)}</g><g fill="${STRIPE}">${px(38, 0)}</g></g>`
282
283 const tiles = `<g fill="${TILE}">${px(0, 7, 40, 1)}</g><g fill="${TILE_DARK}">${[3, 7, 11, 15, 19, 23, 27, 31, 35, 39].map(x => px(x, 7)).join('')}</g>`
284
285 return smallSceneSvg(
286 `
287 .fill { animation: fill 2s steps(3) both; }
288 @keyframes fill { from { clip-path: inset(3px 0 0 0); } to { clip-path: inset(0 0 0 0); } }
289 .drop0 { animation: drop 0.6s steps(1) infinite; }
290 .drop1 { animation: drop 0.6s steps(1) -0.3s infinite; }
291 @keyframes drop { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, 1px); } }
292 .peck { animation: peck 1.4s steps(1) infinite; }
293 @keyframes peck { 0%, 69.9% { transform: translate(0, 0); } 70%, 84.9% { transform: translate(0, 1px); } 85%, 100% { transform: translate(0, 0); } }
294 .bee { animation: buzz 2.4s steps(1) infinite; }
295 ${hops('buzz', BEE_PATH)}`,
296 `${tiles}${can}${daisy}${feeder}${jar}`,
297 )
298}
299
300export const balconyScene: MeterScene = {
301 name: 'balcony',
302 label: wordsOf(t => t.scenes.balcony),
303 width: WIDTH,
304 height: HEIGHT,
305 scale: SCALE,
306 svg: balconySvg,
307 small: { svg: balconySmall, centers: CENTERS },
308}
309hooks/scenes/cyberpunk.ts 236 lines1// The cyberpunk scene: the session's meters as neon things on a rooftop in
2// the rain, a dark skyline behind: a power cell for the context, a neon sign
3// of a bowl of noodles for the prompt cache, a stack of credit chips for the
4// five-hour limit, and signal bars for the week. Each has its number
5// underneath in the 3x5 pixel font.
6//
7// The sign's tubes go out by themselves over the cache's life, so the band
8// need not be redrawn to keep it current; once the cache expires the sign is
9// dark. At a quarter of the context or less the cell's charge flickers pink
10// beside a loose charging cable; while the conversation is compacted the
11// cable is plugged in and the cell charges.
12
13import { wordsOf } from '../language'
14import type { MeterScene, Meters } from './index'
15import { countdown, COUNTDOWN_CSS, HEIGHT, hourSteps, level, percentText, pixelText, px, refill, REFILL_CSS, runsLow, SCALE, sceneSvg, SLOT, smallSceneSvg, stageOf, WIDTH } from './pixels'
16
17// Objects stand on the rooftop at y 9.
18const ROOF_Y = 9
19
20const ROOF = '#3A3550'
21const ROOF_DARK = '#2E2A40'
22const SKYLINE = '#241C38'
23const RAIN = '#7FA8D8'
24const CYAN = '#3FF0E8'
25const PINK = '#FF4FA3'
26const YELLOW = '#F5E04A'
27const TUBE_OFF = '#3A3A4A'
28const METAL = '#5A5470'
29const METAL_LIGHT = '#8A8AA0'
30const WINDOW = '#141020'
31const CHIP = '#F5E04A'
32const CHIP_DARK = '#C9A82E'
33const BAR_OFF = '#2A3A4A'
34
35// The cell holds this many rows of charge.
36const CHARGE_ROWS = 6
37
38// Context: a power cell whose window shows its charge, the context left. At a
39// quarter or less the charge flickers pink and a charging cable lies beside
40// it; while the conversation is compacted the cable is plugged in, a bolt
41// flashes over the cell and the charge rises.
42const powerCell = (left: number | null, x: number, isCompacting: boolean) => {
43 const rows = left === null ? CHARGE_ROWS : Math.max(left > 0 ? 1 : 0, Math.round((left / 100) * CHARGE_ROWS))
44 const isLow = runsLow(left)
45 const charge = isCompacting
46 ? `<g fill="${CYAN}" style="${refill(CHARGE_ROWS - rows)}">${px(x + 6, 8 - CHARGE_ROWS, 4, CHARGE_ROWS)}</g>`
47 : rows > 0 ? `<g fill="${isLow ? PINK : CYAN}"${isLow ? ' class="flicker"' : ''}>${px(x + 6, 8 - rows, 4, rows)}</g>` : ''
48 const cable = isCompacting
49 ? `<g fill="${METAL_LIGHT}">${px(x + 11, 4, 2, 1)}${px(x + 13, 5, 1, 4)}</g>
50 <g class="bolt" fill="${YELLOW}">${px(x + 8, -2)}${px(x + 7, -1)}${px(x + 8, -1)}${px(x + 7, 0)}</g>`
51 : isLow ? `<g fill="${METAL_LIGHT}">${px(x + 11, 8, 4, 1)}${px(x + 14, 7)}</g>` : ''
52 return `
53 <g fill="${METAL_LIGHT}">${px(x + 7, 0, 2, 1)}</g>
54 <g fill="${METAL}">${px(x + 5, 1, 6, 8)}</g>
55 <g fill="${WINDOW}">${px(x + 6, 2, 4, 6)}</g>
56 ${charge}
57 <g fill="${WINDOW}" opacity="0.6">${px(x + 6, 4, 4, 1)}${px(x + 6, 6, 4, 1)}</g>
58 ${cable}
59 ${percentText(left, x)}`
60}
61
62// The sign's three parts: the bowl, the chopsticks in it and the steam over it.
63const bowl = (x: number) => `${px(x + 3, 4, 10, 1)}${px(x + 3, 5)}${px(x + 12, 5)}${px(x + 4, 6)}${px(x + 11, 6)}${px(x + 5, 7, 6, 1)}`
64const chopsticks = (x: number) => `${px(x + 9, 3)}${px(x + 10, 2)}${px(x + 11, 1)}${px(x + 12, 0)}${px(x + 10, 3)}${px(x + 11, 2)}${px(x + 12, 1)}${px(x + 13, 0)}`
65const steam = (x: number) => `${px(x + 4, 2)}${px(x + 5, 1)}${px(x + 4, 0)}${px(x + 7, 2)}${px(x + 8, 1)}${px(x + 7, 0)}`
66
67// Cache: a neon sign of a bowl of noodles whose tubes go out by themselves
68// over the cache's life: the steam first, then the chopsticks, then the bowl
69// flickers; once the cache has expired the sign is dark but for a spark.
70const neonSign = (left: number | null, ttl: number, x: number) => {
71 const isOut = left !== null && left <= 0
72 const timing = left === null || isOut ? '' : `animation-duration: ${ttl}s; animation-delay: -${ttl - left}s`
73 const lit = (cls: string, fill: string, tube: string) => `<g fill="${fill}" class="${timing ? cls : ''}" style="${timing}">${tube}</g>`
74 const tubes = isOut
75 ? `<g class="spark" fill="${YELLOW}">${px(x + 12, 4)}</g>`
76 : `${lit('until25', YELLOW, steam(x))}${lit('until50', CYAN, chopsticks(x))}${lit('until75', PINK, bowl(x))}
77 ${timing ? `<g class="from75" style="${timing}"><g class="buzz" fill="${PINK}">${bowl(x)}</g></g>` : ''}`
78 return `
79 <g fill="${TUBE_OFF}">${steam(x)}${chopsticks(x)}${bowl(x)}</g>
80 ${tubes}
81 <g fill="${METAL}">${px(x + 4, 8, 8, 1)}</g>
82 ${left === null ? pixelText('--', x) : isOut ? pixelText('0m', x) : countdown(left, x)}`
83}
84
85// The chips stack this high at most.
86const CHIP_ROWS = 6
87
88// Five-hour limit: a stack of credit chips on a lit pad, a chip for every
89// sixth of the window still free.
90const credits = (used: number | null, x: number) => {
91 const left = used === null ? null : 100 - used
92 const rows = left === null ? CHIP_ROWS : Math.max(left > 0 ? 1 : 0, Math.round((left / 100) * CHIP_ROWS))
93 return `
94 ${Array.from({ length: rows }, (_, i) => `<g fill="${i % 2 === 0 ? CHIP : CHIP_DARK}">${px(x + 5, 7 - i, 6, 1)}</g>`).join('')}
95 ${rows > 0 ? `<g fill="${CHIP_DARK}">${px(x + 5, 8 - rows)}${px(x + 10, 8 - rows)}</g>` : ''}
96 <g fill="${METAL}">${px(x + 3, 8, 10, 1)}</g>
97 <g fill="${CYAN}">${px(x + 4, 8, 8, 1)}</g>
98 ${percentText(left, x)}`
99}
100
101// Signal bars, each two pixels wide: [x, height].
102const BARS: [number, number][] = [[3, 2], [6, 4], [9, 6], [12, 8]]
103
104// Weekly limit: signal bars, one lit for every quarter of the week still
105// free; the last one blinks pink.
106const signal = (used: number | null, x: number) => {
107 const left = used === null ? null : 100 - used
108 const lit = level(left, BARS.length)
109 const isLow = lit === 1
110 return `
111 ${BARS.map(([bx, h], i) => `<g fill="${i < lit ? (isLow ? PINK : CYAN) : BAR_OFF}"${i < lit && isLow ? ' class="flicker"' : ''}>${px(x + bx, 9 - h, 2, h)}</g>`).join('')}
112 <g fill="${METAL_LIGHT}">${px(x + 13, -1, 1, 2)}</g>
113 <g class="beacon" fill="${PINK}">${px(x + 13, -2)}</g>
114 ${percentText(left, x)}`
115}
116
117// The skyline behind, as [x, width, top], with a lit window here and there.
118const TOWERS: [number, number, number][] = [
119 [0, 4, 3], [4, 5, 1], [9, 4, 4], [13, 6, 0], [19, 4, 2], [23, 5, 5], [28, 4, 1], [32, 6, 3],
120 [38, 4, 0], [42, 5, 4], [47, 4, 2], [51, 6, 5], [57, 3, 1], [60, 4, 3],
121]
122const LIGHTS: [number, number, string][] = [[1, 4, CYAN], [6, 2, PINK], [15, 1, CYAN], [21, 3, PINK], [30, 2, CYAN], [40, 1, PINK], [49, 3, CYAN], [58, 2, PINK], [62, 4, CYAN]]
123
124const skyline = () => `
125 <g fill="${SKYLINE}">${TOWERS.map(([tx, w, top]) => px(tx, top, w, ROOF_Y - top)).join('')}</g>
126 ${LIGHTS.map(([lx, ly, c], i) => `<g class="window${i % 3}" fill="${c}" opacity="0.5">${px(lx, ly)}</g>`).join('')}`
127
128// Rain falling across the scene, as [x, period in seconds, delay].
129const DROPS: [number, number, number][] = [[2, 0.9, 0], [11, 1.1, 0.4], [19, 1, 0.7], [26, 0.95, 0.2], [35, 1.05, 0.5], [43, 0.9, 0.8], [50, 1.1, 0.1], [58, 1, 0.6]]
130
131const rain = () =>
132 `<g fill="${RAIN}" opacity="0.35">${DROPS.map(([dx, period, delay]) => `<g class="drop" style="animation-duration: ${period}s; animation-delay: -${delay}s">${px(dx, -2, 1, 1)}</g>`).join('')}</g>`
133
134const roof = () =>
135 Array.from({ length: WIDTH / 4 }, (_, i) => `<g fill="${i % 2 === 0 ? ROOF : ROOF_DARK}">${px(i * 4, ROOF_Y, 4, 1)}</g>`).join('')
136
137const cyberpunkSvg = (f: Meters) =>
138 sceneSvg(
139 `${COUNTDOWN_CSS}
140 ${REFILL_CSS}
141 .drop { animation-name: fall; animation-timing-function: steps(11); animation-iteration-count: infinite; }
142 @keyframes fall { from { transform: translate(0, 0); } to { transform: translate(-1px, 11px); } }
143 .window0 { animation: twinkle 3.1s steps(1) infinite; }
144 .window1 { animation: twinkle 4.3s steps(1) -1.5s infinite; }
145 .window2 { animation: twinkle 3.7s steps(1) -2.6s infinite; }
146 @keyframes twinkle { 0%, 69.9% { opacity: 0.5; } 70%, 100% { opacity: 0; } }
147 .flicker { animation: flicker 1.3s steps(1) infinite; }
148 @keyframes flicker { 0%, 59.9% { opacity: 1; } 60%, 64.9% { opacity: 0.3; } 65%, 79.9% { opacity: 1; } 80%, 84.9% { opacity: 0.3; } 85%, 100% { opacity: 1; } }
149 .bolt { animation: flash 0.5s steps(1) infinite; }
150 @keyframes flash { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
151 .until25, .until50, .until75, .from75 { animation-timing-function: steps(1); animation-fill-mode: forwards; }
152 .until25 { animation-name: until25; }
153 .until50 { animation-name: until50; }
154 .until75 { animation-name: until75; }
155 .from75 { animation-name: from75; }
156 @keyframes until25 { 0%, 24.9% { opacity: 1; } 25%, 100% { opacity: 0; } }
157 @keyframes until50 { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
158 @keyframes until75 { 0%, 74.9% { opacity: 1; } 75%, 100% { opacity: 0; } }
159 @keyframes from75 { 0%, 74.9% { opacity: 0; } 75%, 100% { opacity: 1; } }
160 .buzz { animation: flicker 0.9s steps(1) infinite; }
161 .spark { animation: spark 2.3s steps(1) infinite; }
162 @keyframes spark { 0%, 89.9% { opacity: 0; } 90%, 94.9% { opacity: 1; } 95%, 100% { opacity: 0; } }
163 .beacon { animation: flash 1.6s steps(1) infinite; }`,
164 `
165 ${skyline()}
166 ${roof()}
167 ${powerCell(f.contextLeft, 0, f.isCompacting)}
168 ${neonSign(f.cacheLeft, f.cacheTtl, SLOT)}
169 ${credits(f.fiveHour, SLOT * 2)}
170 ${signal(f.week, SLOT * 3)}
171 ${rain()}`,
172 )
173
174// The scene in the small size, on the rooftop in the rain: the power cell,
175// charged as far as the context left, flickering pink beside a loose cable at
176// a quarter or less, plugged in and charging while compacting; the neon bowl
177// of noodles, its tubes going out over the cache's life; the stack of credit
178// chips, lower as the five-hour limit goes; and the signal bars, dropping
179// over the week.
180const CENTERS = [4, 15, 26, 36]
181
182const cyberpunkSmall = (f: Meters) => {
183 const charge = f.isCompacting ? 4 : ([0, 1, 2, 4][stageOf(f.contextLeft)] ?? 0)
184 const isLow = !f.isCompacting && runsLow(f.contextLeft)
185 const cell = `
186 <g fill="${METAL_LIGHT}">${px(4, 0)}</g>
187 <g fill="${METAL}">${px(2, 1, 5, 6)}</g>
188 <g fill="${WINDOW}">${px(3, 2, 3, 4)}</g>
189 ${charge > 0 ? `<g${f.isCompacting ? ' class="fill"' : isLow ? ' class="flicker"' : ''} fill="${isLow ? PINK : CYAN}">${px(3, 6 - charge, 3, charge)}</g>` : ''}
190 ${isLow ? `<g fill="${PINK}">${px(7, 6)}${px(8, 5)}</g>` : ''}
191 ${f.isCompacting ? `<g fill="${CYAN}">${px(7, 3)}${px(8, 3, 1, 4)}</g>` : ''}`
192
193 const tubes = (steam: string, sticks: string, bowl: string) => `
194 <g fill="${steam}">${px(13, 1)}${px(14, 0)}${px(14, 2)}</g>
195 <g fill="${sticks}">${px(16, 1, 1, 3)}${px(17, 0, 1, 3)}</g>
196 <g fill="${bowl}">${px(12, 4, 7, 1)}${px(13, 5, 5, 1)}</g>`
197 const sign = hourSteps(f, [tubes(CYAN, YELLOW, PINK), tubes(TUBE_OFF, YELLOW, PINK), tubes(TUBE_OFF, TUBE_OFF, PINK)], tubes(TUBE_OFF, TUBE_OFF, TUBE_OFF))
198
199 const fiveLeft = f.fiveHour === null ? null : 100 - f.fiveHour
200 const chips = [0, 1, 2, 4][stageOf(fiveLeft)] ?? 0
201 const stack = `
202 <g fill="${CYAN}">${px(23, 6, 7, 1)}</g>
203 ${Array.from({ length: chips }, (_, i) => `<g fill="${CHIP}">${px(24, 5 - i, 5, 1)}</g><g fill="${CHIP_DARK}">${px(24, 5 - i)}${px(28, 5 - i)}</g>`).join('')}`
204
205 const weekLeft = f.week === null ? null : 100 - f.week
206 const bars = [0, 1, 2, 4][stageOf(weekLeft)] ?? 0
207 const signal = [33, 35, 37, 39].map((x, i) => `<g fill="${i < bars ? (bars === 1 ? PINK : CYAN) : BAR_OFF}">${px(x, 5 - i, 1, 2 + i)}</g>`).join('')
208
209 const skyline = `<g fill="${SKYLINE}">${px(9, 3, 2, 4)}${px(20, 2, 2, 5)}${px(31, 4, 1, 3)}</g><g fill="${YELLOW}">${px(9, 4)}${px(21, 3)}</g>`
210 const rain = `<g fill="${RAIN}">${[1, 10, 19, 22, 30, 38].map((x, i) => `<g class="rain r${i % 3}">${px(x, 0)}</g>`).join('')}</g>`
211 const roof = `<g fill="${ROOF}">${px(0, 7, 40, 1)}</g><g fill="${ROOF_DARK}">${Array.from({ length: 8 }, (_, i) => px(i * 5 + 4, 7)).join('')}</g>`
212
213 return smallSceneSvg(
214 `
215 .fill { animation: fill 2s steps(4) both; }
216 @keyframes fill { from { clip-path: inset(4px 0 0 0); } to { clip-path: inset(0 0 0 0); } }
217 .flicker { animation: flicker 1.1s steps(1) infinite; }
218 @keyframes flicker { 0%, 69.9% { opacity: 1; } 70%, 79.9% { opacity: 0.3; } 80%, 89.9% { opacity: 1; } 90%, 100% { opacity: 0.3; } }
219 .rain { animation: rain 0.9s steps(7) infinite; }
220 .r1 { animation-delay: -0.3s; }
221 .r2 { animation-delay: -0.6s; }
222 @keyframes rain { from { transform: translate(0, 0); } to { transform: translate(0, 7px); } }`,
223 `${skyline}${rain}${roof}${cell}${sign}${stack}${signal}`,
224 )
225}
226
227export const cyberpunkScene: MeterScene = {
228 name: 'cyberpunk',
229 label: wordsOf(t => t.scenes.cyberpunk),
230 width: WIDTH,
231 height: HEIGHT,
232 scale: SCALE,
233 svg: cyberpunkSvg,
234 small: { svg: cyberpunkSmall, centers: CENTERS },
235}
236hooks/scenes/gamer.ts 269 lines1// The gamer scene: the session's meters as a gaming desk lit by an RGB strip:
2// a monitor's health hearts for the context, an arcade cabinet for the prompt
3// cache, cans of energy drink for the five-hour limit, and a gamepad's battery
4// for the week. Each has its number underneath in the 3x5 pixel font.
5//
6// The arcade's little hero walks to the flag by itself over the cache's life,
7// so the band need not be redrawn to keep it current; once the cache expires
8// it is game over. At a quarter of the context or less the last heart blinks;
9// while the conversation is compacted the hearts heal back to full.
10
11import { wordsOf } from '../language'
12import type { MeterScene, Meters } from './index'
13import { countdown, COUNTDOWN_CSS, HEIGHT, hourSteps, level, percentText, pixelText, px, runsLow, SCALE, sceneSvg, SLOT, smallSceneSvg, stageOf, WIDTH } from './pixels'
14
15// Objects stand on the desk's lit edge at y 9.
16const DESK_Y = 9
17
18const RGB = ['#E8445A', '#F5C26B', '#5BD16E', '#5B8FD6', '#A86BE0']
19const BEZEL = '#3A3A44'
20const SCREEN = '#141B2E'
21const HEART = '#E8445A'
22const HEART_LIGHT = '#FF9AAA'
23const HEART_EMPTY = '#4A2A33'
24const CABINET = '#3B4FB8'
25const CABINET_DARK = '#2C3A8A'
26const PANEL = '#5A6FD8'
27const MARQUEE = '#F5C26B'
28const GROUND = '#5BD16E'
29const HERO = '#F4F1E6'
30const FLAG = '#E8445A'
31const COIN = '#F5A623'
32const CAN = '#46C46A'
33const CAN_DARK = '#2E8A4A'
34const BOLT = '#F5E04A'
35const RIM = '#B0B0B8'
36const CRUSHED = '#6E8A74'
37const PAD = '#4A4A55'
38const PAD_LIGHT = '#6A6A78'
39const BUTTON_A = '#E8445A'
40const BUTTON_B = '#5B8FD6'
41const SHELL = '#C8C8C8'
42const CELL = '#5BD16E'
43const CELL_LOW = '#E8445A'
44
45// A heart three pixels across: its left half (with the point) and its right.
46const heartLeft = (x: number, y: number) => `${px(x, y, 1, 2)}${px(x + 1, y + 1, 1, 2)}`
47const heartRight = (x: number, y: number) => px(x + 2, y, 1, 2)
48
49// Where the hearts sit on the screen, from the left.
50const HEARTS = [2, 6, 10]
51
52// The pixels of the health still in the hearts: two halves to a heart.
53const health = (x: number, halves: number) =>
54 HEARTS.map((hx, i) => (halves >= 2 * i + 1 ? heartLeft(x + hx, 2) : '') + (halves >= 2 * i + 2 ? heartRight(x + hx, 2) : '')).join('')
55
56// That health with a glint on each heart it reaches, so the glint blinks and
57// heals with it.
58const glinting = (x: number, halves: number) => `${health(x, halves)}
59 <g fill="${HEART_LIGHT}">${HEARTS.filter((_, i) => halves >= 2 * i + 1).map(hx => px(x + hx, 2)).join('')}</g>`
60
61// Context: a monitor showing three hearts of health, half a heart for every
62// sixth of the context left. At a quarter or less what is left blinks; while
63// the conversation is compacted the hearts heal back to full, from the left.
64const monitor = (left: number | null, x: number, isCompacting: boolean) => {
65 const halves = left === null ? 6 : Math.max(left > 0 ? 1 : 0, Math.round((left / 100) * 6))
66 const isLow = runsLow(left)
67 // How many columns of the hearts' eleven the health leaves bare.
68 const last = halves === 0 ? null : (HEARTS[Math.ceil(halves / 2) - 1] ?? 0) + (halves % 2 === 1 ? 1 : 2)
69 const bare = last === null ? 11 : 11 - (last - HEARTS[0]! + 1)
70 const hearts = isCompacting
71 ? `<g style="animation: heal${bare} 2s steps(${Math.max(1, bare)}) both">${glinting(x, 6)}</g>`
72 : `<g${isLow ? ' class="lowhp"' : ''}>${glinting(x, halves)}</g>`
73 return `
74 <g fill="${BEZEL}">${px(x, 0, 15, 7)}${px(x + 7, 7)}${px(x + 5, 8, 5, 1)}</g>
75 <g fill="${SCREEN}">${px(x + 1, 1, 13, 5)}</g>
76 <g fill="${HEART_EMPTY}">${health(x, 6)}</g>
77 <g fill="${HEART}">${hearts}</g>
78 ${percentText(left, x)}`
79}
80
81// Where the arcade's hero stands as the cache's life goes by, from the screen's left.
82const STRIDES = 5
83
84// Cache: an arcade cabinet whose little hero walks to the flag by itself over
85// the cache's life; once the cache expires the screen goes dark and the coin
86// slot blinks for another token.
87const arcade = (left: number | null, ttl: number, x: number) => {
88 const isOver = left !== null && left <= 0
89 const timing = left === null || isOver ? '' : `animation-duration: ${ttl}s; animation-delay: -${ttl - left}s`
90 const screen = isOver
91 ? ''
92 : `<g fill="${GROUND}">${px(x + 5, 2, 6, 1)}</g>
93 <g fill="${FLAG}">${px(x + 10, -1)}</g><g fill="${HERO}">${px(x + 10, 0, 1, 2)}</g>
94 <g class="${timing ? 'walk' : ''}" style="${timing}"><g class="${timing ? 'hop' : ''}" fill="${HERO}">${px(x + 5, 1)}</g></g>`
95 return `
96 <g fill="${MARQUEE}">${px(x + 4, -2, 8, 1)}</g>
97 <g fill="${CABINET}">${px(x + 4, -1, 1, 10)}${px(x + 11, -1, 1, 10)}${px(x + 5, 3, 6, 1)}${px(x + 5, 5, 6, 4)}</g>
98 <g fill="${SCREEN}">${px(x + 5, -1, 6, 4)}</g>
99 ${screen}
100 <g fill="${PANEL}">${px(x + 3, 4, 10, 1)}</g>
101 <g fill="${HEART}">${px(x + 5, 3)}</g>
102 <g fill="${BUTTON_B}">${px(x + 9, 3)}</g><g fill="${GROUND}">${px(x + 10, 3)}</g>
103 <g fill="${CABINET_DARK}">${px(x + 6, 6, 4, 2)}</g>
104 <g fill="${COIN}" class="${isOver ? 'coinslot' : ''}">${px(x + 7, 6, 2, 1)}</g>
105 ${left === null ? pixelText('--', x) : isOver ? pixelText('0m', x) : countdown(left, x)}`
106}
107
108// Where the cans stand, from the left.
109const CANS = [1, 5, 9, 13]
110
111// An energy drink can, three pixels across, a bolt on its band.
112const can = (x: number) => `
113 <g fill="${RIM}">${px(x, 3, 3, 1)}${px(x + 1, 2)}</g>
114 <g fill="${CAN}">${px(x, 4, 3, 5)}</g>
115 <g fill="${CAN_DARK}">${px(x, 6, 3, 1)}${px(x + 2, 4, 1, 5)}</g>
116 <g fill="${BOLT}">${px(x + 1, 6)}</g>`
117
118// A can drunk and crushed.
119const crushed = (x: number) => `<g fill="${CRUSHED}">${px(x, 8, 3, 1)}${px(x + 1, 7)}</g>`
120
121// Five-hour limit: a can of energy drink for every quarter of the window
122// still free; the drunk ones lie crushed.
123const cans = (used: number | null, x: number) => {
124 const left = used === null ? null : 100 - used
125 const count = left === null ? CANS.length : Math.min(CANS.length, Math.max(0, Math.ceil(left / 25)))
126 return `
127 ${CANS.map((cx, i) => (i < count ? can(x + cx) : crushed(x + cx))).join('')}
128 ${percentText(left, x)}`
129}
130
131// Weekly limit: a gamepad under a battery with a cell for every quarter of the
132// week still free; the last one blinks red.
133const gamepad = (used: number | null, x: number) => {
134 const left = used === null ? null : 100 - used
135 const cells = level(left, 4)
136 const isLow = cells === 1
137 return `
138 <g fill="${SHELL}">${px(x + 3, 0, 10, 1)}${px(x + 3, 3, 10, 1)}${px(x + 3, 1, 1, 2)}${px(x + 12, 1, 1, 2)}${px(x + 13, 1, 1, 2)}</g>
139 <g fill="${isLow ? CELL_LOW : CELL}"${isLow ? ' class="lowcell"' : ''}>${Array.from({ length: cells }, (_, i) => px(x + 4 + i * 2, 1, 2, 2)).join('')}</g>
140 <g fill="${PAD_LIGHT}">${px(x + 4, 5, 8, 1)}</g>
141 <g fill="${PAD}">${px(x + 3, 6, 10, 1)}${px(x + 2, 7, 12, 1)}${px(x + 2, 8, 3, 1)}${px(x + 11, 8, 3, 1)}</g>
142 <g fill="${SCREEN}">${px(x + 4, 6, 1, 2)}${px(x + 3, 7, 3, 1)}</g>
143 <g fill="${BUTTON_A}">${px(x + 11, 6)}</g><g fill="${BUTTON_B}">${px(x + 12, 7)}</g><g fill="${GROUND}">${px(x + 10, 7)}</g>
144 ${percentText(left, x)}`
145}
146
147// The desk's edge: an RGB strip whose colors chase each other along it.
148const strip = () =>
149 Array.from({ length: WIDTH / 4 }, (_, i) => `<g class="rgb" style="animation-delay: -${(i * 0.4).toFixed(1)}s">${px(i * 4, DESK_Y, 4, 1)}</g>`).join('')
150
151// The hearts' eleven columns uncovered from the left, from `n` bare to none.
152const HEAL_CSS = Array.from({ length: 12 }, (_, n) =>
153 `@keyframes heal${n} { from { clip-path: inset(0 ${n}px 0 0); } to { clip-path: inset(0 0 0 0); } }`).join('\n ')
154
155const gamerSvg = (f: Meters) =>
156 sceneSvg(
157 `${COUNTDOWN_CSS}
158 ${HEAL_CSS}
159 .rgb { animation: rgb 4s steps(1) infinite; }
160 @keyframes rgb { ${RGB.map((c, i) => `${((i / RGB.length) * 100).toFixed(1)}% { fill: ${c}; }`).join(' ')} 100% { fill: ${RGB[0]}; } }
161 .lowhp, .lowcell { animation: lowblink 0.8s steps(1) infinite; }
162 @keyframes lowblink { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0.2; } }
163 .walk { animation-name: walk; animation-timing-function: steps(${STRIDES}); animation-fill-mode: forwards; }
164 @keyframes walk { from { transform: translate(0, 0); } to { transform: translate(${STRIDES}px, 0); } }
165 .hop { animation: hop 0.8s steps(1) infinite; }
166 @keyframes hop { 0%, 79.9% { transform: translate(0, 0); } 80%, 100% { transform: translate(0, -1px); } }
167 .coinslot { animation: lowblink 1s steps(1) infinite; }`,
168 `
169 ${strip()}
170 ${monitor(f.contextLeft, 0, f.isCompacting)}
171 ${arcade(f.cacheLeft, f.cacheTtl, SLOT)}
172 ${cans(f.fiveHour, SLOT * 2)}
173 ${gamepad(f.week, SLOT * 3)}`,
174 )
175
176// The scene in the small size, on the desk's RGB strip: the monitor, a
177// heart of health for each step of the context left, the last one blinking
178// at a quarter or less, healing while compacting; the arcade cabinet, its
179// hero walking to the flag over the cache's life, game over once it expires;
180// the cans of energy drink, crushed as the five-hour limit goes; and the
181// gamepad's battery, running down over the week.
182const CENTERS = [4.5, 15.5, 25.5, 36]
183
184// The strip's colors, repeated along it and shifting by themselves.
185const STRIP = [HEART, MARQUEE, GROUND, BUTTON_B, PANEL, HEART_LIGHT]
186
187const gamerSmall = (f: Meters) => {
188 const hearts = f.isCompacting ? 3 : ([0, 1, 2, 3][stageOf(f.contextLeft)] ?? 0)
189 const heart = (i: number) => {
190 const x = 1 + i * 3
191 if (f.isCompacting) {
192 return `<g fill="${HEART_EMPTY}">${px(x, 2, 2, 2)}</g><g class="heal${i}"><g fill="${HEART}">${px(x, 2, 2, 2)}</g><g fill="${HEART_LIGHT}">${px(x, 2)}</g></g>`
193 }
194 if (i >= hearts) {
195 return `<g fill="${HEART_EMPTY}">${px(x, 2, 2, 2)}</g>`
196 }
197 const full = `<g fill="${HEART}">${px(x, 2, 2, 2)}</g><g fill="${HEART_LIGHT}">${px(x, 2)}</g>`
198 return hearts === 1 ? `<g fill="${HEART_EMPTY}">${px(x, 2, 2, 2)}</g><g class="blink">${full}</g>` : full
199 }
200 const monitor = `
201 <g fill="${BEZEL}">${px(0, 0, 10, 6)}${px(4, 6, 2, 1)}</g>
202 <g fill="${SCREEN}">${px(1, 1, 8, 4)}</g>
203 ${[0, 1, 2].map(heart).join('')}`
204
205 const cabinet = (screen: string) => `
206 <g fill="${CABINET}">${px(12, 0, 7, 7)}</g>
207 <g fill="${CABINET_DARK}">${px(19, 0, 1, 7)}</g>
208 <g fill="${MARQUEE}">${px(13, 0, 5, 1)}</g>
209 <g fill="${SCREEN}">${px(13, 1, 5, 3)}</g>
210 ${screen}
211 <g fill="${PANEL}">${px(13, 5, 5, 1)}</g>
212 <g fill="${BUTTON_A}">${px(14, 5)}</g><g fill="${BUTTON_B}">${px(16, 5)}</g>`
213 const screenAt = (hero: number) => `
214 <g fill="${GROUND}">${px(13, 3, 5, 1)}</g>
215 <g fill="${FLAG}">${px(17, 1)}</g><g fill="${RIM}">${px(17, 2)}</g>
216 <g class="walk" fill="${HERO}">${px(hero, 2)}</g>`
217 const arcade = hourSteps(
218 f,
219 [cabinet(screenAt(13)), cabinet(screenAt(14)), cabinet(screenAt(16))],
220 cabinet(`<g fill="${HEART}">${px(14, 1)}${px(16, 1)}${px(15, 2)}${px(14, 3)}${px(16, 3)}</g>`),
221 )
222
223 const fiveLeft = f.fiveHour === null ? null : 100 - f.fiveHour
224 const standing = [0, 1, 2, 3][stageOf(fiveLeft)] ?? 0
225 const can = (x: number, i: number) =>
226 i < standing
227 ? `<g fill="${RIM}">${px(x, 3, 2, 1)}</g><g fill="${CAN}">${px(x, 4, 1, 3)}</g><g fill="${CAN_DARK}">${px(x + 1, 4, 1, 3)}</g><g fill="${BOLT}">${px(x, 5)}</g>`
228 : `<g fill="${CRUSHED}">${px(x, 6, 2, 1)}</g>`
229 const cans = [22, 25, 28].map(can).join('')
230
231 const weekLeft = f.week === null ? null : 100 - f.week
232 const cells = [0, 1, 2, 3][stageOf(weekLeft)] ?? 0
233 const gamepad = `
234 <g fill="${SHELL}">${px(34, 1, 5, 1)}${px(34, 3, 5, 1)}${px(34, 2)}${px(38, 2)}${px(39, 2)}</g>
235 ${cells > 0 ? `<g fill="${cells === 1 ? CELL_LOW : CELL}">${px(35, 2, cells, 1)}</g>` : ''}
236 <g fill="${PAD_LIGHT}">${px(34, 5, 5, 1)}</g>
237 <g fill="${PAD}">${px(33, 6, 7, 1)}${px(35, 5)}</g>
238 <g fill="${BUTTON_A}">${px(37, 5)}</g><g fill="${BUTTON_B}">${px(38, 6)}</g>`
239
240 const strip = Array.from({ length: 46 }, (_, i) => `<g fill="${STRIP[i % STRIP.length]}">${px(i, 7)}</g>`).join('')
241
242 return smallSceneSvg(
243 `
244 .blink { animation: blink 0.8s steps(1) infinite; }
245 @keyframes blink { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
246 .heal0, .heal1, .heal2 { animation: heal 2s steps(1) both; }
247 .heal1 { animation-name: heal1; }
248 .heal2 { animation-name: heal2; }
249 @keyframes heal { 0%, 32.9% { opacity: 0; } 33%, 100% { opacity: 1; } }
250 @keyframes heal1 { 0%, 65.9% { opacity: 0; } 66%, 100% { opacity: 1; } }
251 @keyframes heal2 { 0%, 98.9% { opacity: 0; } 99%, 100% { opacity: 1; } }
252 .walk { animation: walk 0.8s steps(1) infinite; }
253 @keyframes walk { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, -1px); } }
254 .strip { animation: strip 1.2s steps(6) infinite; }
255 @keyframes strip { from { transform: translate(0, 0); } to { transform: translate(-6px, 0); } }`,
256 `<g class="strip">${strip}</g>${monitor}${arcade}${cans}${gamepad}`,
257 )
258}
259
260export const gamerScene: MeterScene = {
261 name: 'gamer',
262 label: wordsOf(t => t.scenes.gamer),
263 width: WIDTH,
264 height: HEIGHT,
265 scale: SCALE,
266 svg: gamerSvg,
267 small: { svg: gamerSmall, centers: CENTERS },
268}
269hooks/scenes/mate.ts 245 lines1// The mate scene: the session's meters as things on a tablecloth checked sky
2// blue and white, like Argentina's flag, for a mate: a thermos of hot water for
3// the context, the mate for the prompt cache, a plate of medialunas for the
4// five-hour limit, and a pack of yerba for the week. Each has its number
5// underneath in the 3x5 pixel font.
6//
7// The mate's steam fades by itself over the cache's life, so the band need not
8// be redrawn to keep it current; once the cache expires the yerba is washed
9// out. While the conversation is compacted, the kettle boils and the thermos
10// fills up.
11
12import { wordsOf } from '../language'
13import type { MeterScene, Meters } from './index'
14import { countdown, COUNTDOWN_CSS, HEIGHT, hourSteps, percentText, pixelText, px, refill, REFILL_CSS, runsLow, SCALE, sceneSvg, SLOT, smallSceneSvg, stageOf, WIDTH } from './pixels'
15
16// Objects stand on the tablecloth, which takes rows 8 and 9.
17const CLOTH_Y = 8
18
19// The tablecloth's checks, the flag's sky blue and white.
20const CLOTH = '#74ACDF'
21const CLOTH_WHITE = '#F4F7FA'
22const CREAM = '#E8E6DC'
23const SHADOW = '#8C8A84'
24const STEEL = '#C8C8C8'
25const STEEL_DARK = '#9A9A9A'
26const THERMOS = '#4F7B5A'
27const THERMOS_DARK = '#3B5E45'
28const WATER = '#8FD3F0'
29const GAUGE = '#2B3A40'
30const GOURD = '#8B5A2B'
31const GOURD_DARK = '#6B4220'
32const GOURD_LIGHT = '#B07A45'
33const YERBA = '#7FA65A'
34const WASHED = '#9A9A8A'
35const PASTRY = '#D99A4E'
36const PASTRY_DARK = '#B5763A'
37const PACK = '#6B8E4E'
38const PACK_DARK = '#557341'
39const SEAL = '#B5483E'
40
41// A kettle ready to refill the thermos: round, its handle arched over the lid
42// and its spout rising to the left, steaming. Boiling, its lid rattles and the
43// spout puffs hard.
44const kettle = (x: number, isBoiling: boolean) => `
45 <g fill="${STEEL_DARK}">${px(x + 3, 1, 3, 1)}${px(x + 2, 2)}${px(x + 6, 2)}${px(x + 3, 7, 3, 1)}</g>
46 <g fill="${STEEL}">${px(x, 3)}${px(x + 1, 4, 6, 1)}${px(x + 2, 5, 5, 2)}</g>
47 <g fill="${STEEL}"${isBoiling ? ' class="rattle"' : ''}>${px(x + 3, 3, 3, 1)}</g>
48 <g fill="${CREAM}" opacity="${isBoiling ? 0.85 : 0.6}">${
49 isBoiling
50 ? `<g class="boil1">${px(x, 1)}</g><g class="boil2">${px(x + 1, 0)}</g><g class="boil3">${px(x, 0)}</g>`
51 : `<g class="kettle-steam">${px(x, 1)}</g>`
52 }</g>`
53
54// How far the thermos steps right to make room for the kettle.
55const ASIDE = 2
56
57// The water strip's rows, from the bottom.
58const STRIP = 5
59
60// Context: a thermos with a strip of water down its side that drops as the
61// context fills. At a quarter or less, a kettle waits beside it to refill it,
62// and the thermos steps aside for it. While the conversation is compacted the
63// kettle boils and the water rises to the top.
64const thermos = (left: number | null, x: number, isCompacting: boolean) => {
65 const rows = left === null ? STRIP : Math.max(left > 0 ? 1 : 0, Math.round((left / 100) * STRIP))
66 const isLow = runsLow(left)
67 const hasKettle = isLow || isCompacting
68 const at = hasKettle ? x + ASIDE : x
69 const water = isCompacting
70 ? `<g style="${refill(STRIP - rows)}">${px(at + 7, 7 - STRIP, 1, STRIP)}</g>`
71 : rows > 0 ? px(at + 7, 7 - rows, 1, rows) : ''
72 return `
73 <g fill="${STEEL}">${px(at + 7, -2)}${px(at + 6, -1, 3, 1)}${px(at + 5, 0, 5, 1)}</g>
74 <g fill="${THERMOS}">${px(at + 5, 1, 5, 7)}</g>
75 <g fill="${THERMOS_DARK}">${px(at + 9, 1, 1, 7)}${px(at + 10, 2)}${px(at + 10, 5)}${px(at + 11, 2, 1, 4)}</g>
76 <g fill="${GAUGE}">${px(at + 7, 2, 1, 5)}</g>
77 <g fill="${WATER}">${water}</g>
78 ${hasKettle ? kettle(x, isCompacting) : ''}
79 ${percentText(left, x)}`
80}
81
82// Cache: the mate and its bombilla. It steams in a loop that fades by itself
83// over the cache's life; once the cache has expired the yerba is washed out.
84const mate = (left: number | null, ttl: number, x: number) => {
85 const isWashed = left !== null && left <= 0
86 const gourd = `
87 <g fill="${isWashed ? WASHED : YERBA}">${px(x + 6, 1, 4, 1)}${px(x + 7, 0, 2, 1)}</g>
88 <g fill="${STEEL}">${px(x + 9, 1)}${px(x + 10, 0)}${px(x + 11, -1)}${px(x + 12, -2)}</g>
89 <g fill="${STEEL_DARK}">${px(x + 5, 2, 6, 1)}</g>
90 <g fill="${GOURD}">${px(x + 4, 3, 8, 1)}${px(x + 3, 4, 10, 2)}${px(x + 4, 6, 8, 1)}${px(x + 5, 7, 6, 1)}</g>
91 <g fill="${GOURD_DARK}">${px(x + 10, 3, 2, 1)}${px(x + 11, 4, 2, 2)}${px(x + 10, 6, 2, 1)}${px(x + 9, 7, 2, 1)}</g>
92 <g fill="${GOURD_LIGHT}">${px(x + 5, 4)}</g>`
93 if (left === null || isWashed) {
94 return `${gourd}${pixelText(left === null ? '--' : '0m', x)}`
95 }
96 return `${gourd}
97 <g class="warm" style="animation-duration: ${ttl}s; animation-delay: -${ttl - left}s">
98 <g fill="${CREAM}" opacity="0.6">
99 <g class="steam1">${px(x + 6, -1)}${px(x + 5, -2)}</g>
100 <g class="steam2">${px(x + 8, -1)}${px(x + 9, -2)}</g>
101 </g>
102 </g>
103 ${countdown(left, x)}`
104}
105
106// One medialuna, four pixels across and three tall: an arch whose tips curl down.
107const medialuna = (x: number, y: number) => `
108 <g fill="${PASTRY}">${px(x + 1, y, 2, 1)}${px(x, y + 1, 4, 1)}</g>
109 <g fill="${PASTRY_DARK}">${px(x + 2, y + 1)}${px(x, y + 2)}${px(x + 3, y + 2)}</g>`
110
111// Where each medialuna sits on the plate, the last one eaten first.
112const PILE: [number, number][] = [[2, 3], [6, 3], [10, 3], [6, 0]]
113
114// Five-hour limit: a plate with a medialuna for every quarter of the window
115// still free; the eaten ones leave crumbs.
116const plate = (used: number | null, x: number) => {
117 const left = used === null ? null : 100 - used
118 const count = left === null ? PILE.length : Math.min(PILE.length, Math.max(0, Math.ceil(left / 25)))
119 const crumbs = count < 3 ? `<g fill="${PASTRY_DARK}">${px(x + 11, 5)}${px(x + 13, 5)}${count < 2 ? px(x + 7, 5) : ''}${count < 1 ? px(x + 3, 5) : ''}</g>` : ''
120 return `
121 <g fill="${CREAM}">${px(x + 1, 6, 14, 1)}</g>
122 <g fill="${SHADOW}">${px(x + 3, 7, 10, 1)}</g>
123 ${crumbs}
124 ${PILE.slice(0, count).map(([dx, y]) => medialuna(x + dx, y)).join('')}
125 ${percentText(left, x)}`
126}
127
128// Weekly limit: a pack of yerba that gets flatter, from the top down, as the
129// week's limit is used, with yerba in it while any is left and flat and empty
130// once it's used up.
131const pack = (used: number | null, x: number) => {
132 const left = used === null ? null : 100 - used
133 const rows = left === null ? 8 : left <= 0 ? 1 : Math.max(2, Math.round((left / 100) * 8))
134 const top = CLOTH_Y - rows
135 // The label sits low on the pack, so it goes last as the pack flattens.
136 const label = [4, 5].filter(y => y > top)
137 return `
138 <g fill="${PACK}">${px(x + 5, top, 6, rows)}</g>
139 <g fill="${PACK_DARK}">${px(x + 5, top, 6, 1)}${px(x + 10, top, 1, rows)}</g>
140 <g fill="${CREAM}">${label.map(y => px(x + 5, y, 5, 1)).join('')}</g>
141 <g fill="${SEAL}">${label.length === 2 ? px(x + 7, 4) : ''}</g>
142 ${percentText(left, x)}`
143}
144
145// A gingham strip in sky blue and white: cells two pixels wide, the rows
146// offset by one cell.
147const tablecloth = () =>
148 [0, 1]
149 .map(row =>
150 Array.from({ length: WIDTH / 2 }, (_, i) => `<g fill="${(i + row) % 2 === 0 ? CLOTH : CLOTH_WHITE}">${px(i * 2, CLOTH_Y + row, 2, 1)}</g>`).join(''),
151 )
152 .join('')
153
154const mateSvg = (f: Meters) =>
155 sceneSvg(
156 `${COUNTDOWN_CSS}
157 .steam1 { animation: steam 2s steps(4) infinite; }
158 .steam2 { animation: steam 2s steps(4) -1s infinite; }
159 .kettle-steam { animation: steam 2s steps(4) -0.5s infinite; }
160 .boil1 { animation: steam 0.9s steps(3) infinite; }
161 .boil2 { animation: steam 0.9s steps(3) -0.3s infinite; }
162 .boil3 { animation: steam 0.9s steps(3) -0.6s infinite; }
163 .rattle { animation: rattle 0.4s steps(1) infinite; }
164 @keyframes rattle { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, -1px); } }
165 ${REFILL_CSS}
166 @keyframes steam { 0% { transform: translate(0, 2px); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(0, -1px); opacity: 0; } }
167 .warm { animation-name: cool; animation-timing-function: linear; animation-fill-mode: forwards; }
168 @keyframes cool { from { opacity: 1; } to { opacity: 0; } }`,
169 `
170 ${tablecloth()}
171 ${thermos(f.contextLeft, 0, f.isCompacting)}
172 ${mate(f.cacheLeft, f.cacheTtl, SLOT)}
173 ${plate(f.fiveHour, SLOT * 2)}
174 ${pack(f.week, SLOT * 3)}`,
175 )
176
177// The scene in the small size, on a row of the tablecloth: the thermos, its
178// strip of water as full as the context left (no kettle: while compacting the
179// strip fills up); the mate, its steam thinning over the cache's life, the
180// yerba washed out once it expires; the plate with three medialunas, two, one
181// or crumbs; and the pack of yerba, flatter as the week is used.
182const CENTERS = [4, 15.5, 26, 35.5]
183
184const mateSmall = (f: Meters) => {
185 const water = f.isCompacting ? 5 : ([0, 1, 3, 5][stageOf(f.contextLeft)] ?? 0)
186 const thermos = `
187 <g fill="${STEEL_DARK}">${px(3, 0, 2, 1)}</g>
188 <g fill="${STEEL}">${px(2, 1, 4, 1)}</g>
189 <g fill="${THERMOS}">${px(2, 2, 3, 5)}</g>
190 <g fill="${THERMOS_DARK}">${px(6, 3, 1, 2)}</g>
191 <g fill="${GAUGE}">${px(5, 2, 1, 5)}</g>
192 ${water > 0 ? `<g${f.isCompacting ? ' class="fill"' : ''} fill="${WATER}">${px(5, 7 - water, 1, water)}</g>` : ''}`
193
194 const gourd = (yerba: string) => `
195 <g fill="${GOURD}">${px(13, 4, 5, 2)}${px(14, 6, 3, 1)}</g>
196 <g fill="${GOURD_LIGHT}">${px(14, 4)}</g>
197 <g fill="${GOURD_DARK}">${px(17, 4, 1, 2)}</g>
198 <g fill="${yerba}">${px(14, 3, 3, 1)}</g>
199 <g fill="${STEEL}">${px(16, 2)}${px(17, 1)}${px(18, 0)}</g>`
200 const steam = (wisps: number, opacity: number) =>
201 `<g fill="${CREAM}" opacity="${opacity}">${[px(14, 2), px(15, 1)]
202 .slice(0, wisps)
203 .map((p, i) => `<g class="wisp${i}">${p}</g>`)
204 .join('')}</g>`
205 const mate = hourSteps(f, [gourd(YERBA) + steam(2, 0.6), gourd(YERBA) + steam(1, 0.6), gourd(YERBA) + steam(1, 0.3)], gourd(WASHED))
206
207 const fiveLeft = f.fiveHour === null ? null : 100 - f.fiveHour
208 const count = [0, 1, 2, 3][stageOf(fiveLeft)] ?? 0
209 const medialuna = ([x, y]: readonly [number, number]) =>
210 `<g fill="${PASTRY}">${px(x + 1, y, 1, 2)}</g><g fill="${PASTRY_DARK}">${px(x, y + 1)}${px(x + 2, y + 1)}</g>`
211 const plate = `
212 <g fill="${CREAM}">${px(22, 6, 9, 1)}</g>
213 ${([[23, 4], [27, 4], [25, 2]] as const).slice(0, count).map(medialuna).join('')}
214 ${fiveLeft !== null && count === 0 ? `<g fill="${PASTRY_DARK}">${px(24, 5)}${px(28, 5)}</g>` : ''}`
215
216 const weekLeft = f.week === null ? null : 100 - f.week
217 const top = [6, 5, 3, 1][stageOf(weekLeft)] ?? 6
218 const pack = `
219 <g fill="${PACK}">${px(34, top, 3, 7 - top)}</g>
220 <g fill="${PACK_DARK}">${px(37, top, 1, 7 - top)}</g>
221 ${top <= 3 ? `<g fill="${CREAM}">${px(34, top + 2, 3, top === 1 ? 2 : 1)}</g>` : ''}`
222
223 const cloth = Array.from({ length: 10 }, (_, i) => `<g fill="${i % 2 === 0 ? CLOTH : CLOTH_WHITE}">${px(i * 4, 7, 2, 1)}</g><g fill="${i % 2 === 0 ? CLOTH_WHITE : CLOTH}">${px(i * 4 + 2, 7, 2, 1)}</g>`).join('')
224
225 return smallSceneSvg(
226 `
227 .fill { animation: fill 2s steps(5) both; }
228 @keyframes fill { from { clip-path: inset(5px 0 0 0); } to { clip-path: inset(0 0 0 0); } }
229 .wisp0 { animation: wisp 1.6s steps(1) infinite; }
230 .wisp1 { animation: wisp 1.6s steps(1) -0.8s infinite; }
231 @keyframes wisp { 0%, 49.9% { transform: translate(0, 0); } 50%, 100% { transform: translate(0, -1px); } }`,
232 `${cloth}${thermos}${mate}${plate}${pack}`,
233 )
234}
235
236export const mateScene: MeterScene = {
237 name: 'mate',
238 label: wordsOf(t => t.scenes.mate),
239 width: WIDTH,
240 height: HEIGHT,
241 scale: SCALE,
242 svg: mateSvg,
243 small: { svg: mateSmall, centers: CENTERS },
244}
245hooks/scenes/teatime.ts 309 lines1// The teatime scene: one pixel-art object per meter of the session, standing on
2// a wooden shelf, each with its number underneath in a 3x5 pixel font: a mug
3// of tea for the context, a clock that times the tea hour for the prompt
4// cache, a cookie jar for the five-hour limit, and a box of tea bags for the
5// week. Drawn as one plain SVG image of square pixels.
6//
7// Everything that moves runs on its own CSS animation, including the cache's
8// timer and minutes, so the band need not be redrawn to keep them current.
9// At a quarter of the context or less a teapot stands beside the mug; while
10// the conversation is compacted it pours and the mug fills up again.
11
12import { wordsOf } from '../language'
13import type { MeterScene, Meters } from './index'
14import { countdown, COUNTDOWN_CSS, HEIGHT, hourSteps, level, percentText, pixelText, px, refill, REFILL_CSS, runsLow, SCALE, sceneSvg, SLOT, smallSceneSvg, stageOf, WIDTH } from './pixels'
15
16// Objects stand on the shelf, whose top is at y 9; the numbers sit under it.
17const BOARD_Y = 9
18
19const WOOD = '#A0785A'
20const WOOD_DARK = '#7A5A42'
21const GRAIN = '#86634A'
22const IRON = '#56565E'
23const CERAMIC = '#E8E6DC'
24const CERAMIC_DARK = '#C4BEAE'
25const INSIDE = '#423D37'
26const STRIPE = '#D97757'
27const TEA = '#C98B4F'
28const TEA_TOP = '#DDA468'
29const STRING = '#D8D2C0'
30const TAG = '#F5C26B'
31const POT = '#6A9AB0'
32const POT_DARK = '#4F7A8E'
33const POT_LIGHT = '#8DB8CB'
34const BRASS = '#C9A54A'
35const BRASS_DARK = '#9C7E34'
36const FACE = '#F4EEDC'
37const HAND = '#423D37'
38const GLASS = '#9FB8C4'
39const COOKIE = '#C98B4F'
40const COOKIE_LIGHT = '#DDA468'
41const COOKIE_DARK = '#A06A3A'
42const CHIP = '#5A3A24'
43const BOX = '#5E8C6A'
44const BOX_DARK = '#46705A'
45const BAG = '#EFE3C8'
46const BAG_DARK = '#D9C9A8'
47
48// The shelf: a board with a little grain on two iron brackets, a row clear
49// of the numbers.
50const shelf = () => `
51 <g fill="${WOOD}">${px(0, BOARD_Y, WIDTH, 1)}</g>
52 <g fill="${GRAIN}">${[4, 5, 13, 22, 23, 30, 38, 45, 46, 53, 60].map(x => px(x, BOARD_Y)).join('')}</g>
53 <g fill="${IRON}">${px(SLOT - 1, BOARD_Y + 1, 2, 1)}${px(WIDTH - SLOT - 1, BOARD_Y + 1, 2, 1)}</g>`
54
55// The mug holds this many rows of tea.
56const MUG_ROWS = 5
57// How far the mug steps right to make room for the teapot.
58const ASIDE = 5
59
60// A teapot, its handle to the left and its spout rising to the right toward
61// the mug. Low on tea it steams beside the mug; pouring, it is lifted for two
62// seconds and a stream of tea runs from its spout into the mug.
63const teapot = (x: number, isPouring: boolean) => `
64 <g${isPouring ? ' class="lift"' : ''}>
65 <g fill="${POT}">${px(x + 1, 4, 5, 1)}${px(x + 1, 5, 4, 3)}${px(x + 6, 5, 1, 2)}${px(x + 7, 3, 1, 2)}</g>
66 <g fill="${POT_DARK}">${px(x + 3, 2)}${px(x + 2, 3, 3, 1)}${px(x, 4, 1, 3)}${px(x + 5, 5, 1, 3)}${px(x + 2, 8, 3, 1)}</g>
67 <g fill="${POT_LIGHT}">${px(x + 2, 5)}${px(x + 2, 4, 2, 1)}</g>
68 </g>
69 ${isPouring
70 ? `<g class="pour" fill="${TEA_TOP}">${px(x + 8, 1)}${px(x + 9, 2, 1, 6)}</g>`
71 : `<g fill="${CERAMIC}" opacity="0.6"><g class="pot-steam">${px(x + 7, 2)}${px(x + 8, 1)}</g></g>`}`
72
73// Context: a mug of tea whose level is the context left, a tea bag's string
74// over its rim; it steams while warm and goes cold (no steam) at a quarter or
75// less, when a teapot comes to stand beside it. While the conversation is
76// compacted the teapot pours and the mug fills up with hot tea.
77const mug = (left: number | null, x: number, isCompacting: boolean) => {
78 const rows = level(left, MUG_ROWS)
79 const hasTeapot = isCompacting || runsLow(left)
80 const isWarm = isCompacting || !runsLow(left)
81 // The mug's left wall; the inside runs five columns from the next.
82 const m = x + 3 + (hasTeapot ? ASIDE : 0)
83 const fill = (top: number, h: number) => `<g fill="${TEA}">${px(m + 1, top, 5, h)}</g><g fill="${TEA_TOP}">${px(m + 1, top, 5, 1)}</g>`
84 const tea = isCompacting
85 ? `<g style="${refill(MUG_ROWS - rows)}">${fill(8 - MUG_ROWS, MUG_ROWS)}</g>`
86 : rows > 0 ? fill(8 - rows, rows) : ''
87 return `
88 <g fill="${INSIDE}">${px(m + 1, 2, 5, 6)}</g>
89 ${tea}
90 <g fill="${CERAMIC}">${px(m, 2, 1, 7)}${px(m, 8, 7, 1)}${px(m + 7, 5)}${px(m + 8, 5, 1, 3)}${px(m + 7, 7)}</g>
91 <g fill="${CERAMIC_DARK}">${px(m + 6, 2, 1, 7)}${px(m + 1, 8, 6, 1)}</g>
92 <g fill="${STRIPE}">${px(m, 6)}${px(m + 6, 6)}</g>
93 <g fill="${STRING}">${px(m + 5, 2)}${px(m + 6, 1, 2, 1)}</g>
94 <g fill="${TAG}">${px(m + 7, 2, 2, 2)}</g>
95 ${isWarm ? `<g fill="${CERAMIC}" opacity="0.6">
96 <g class="steam1">${px(m + 2, 1)}${px(m + 1, 0)}</g>
97 <g class="steam2">${px(m + 4, 1)}${px(m + 5, 0)}</g>
98 </g>` : ''}
99 ${hasTeapot ? teapot(x, isCompacting) : ''}
100 ${pixelText(left === null ? '--' : `${left}%`, x)}`
101}
102
103// The timer's disc goes by in eighths of the cache's life.
104const EIGHTHS = 8
105
106// A brass clock's case and cream face, nine pixels across, centered on
107// (cx, cy), with the marks at twelve, three, six and nine.
108const clockFace = (cx: number, cy: number) => `
109 <g fill="${BRASS}">${px(cx - 1, cy - 4, 3, 1)}${px(cx - 2, cy - 3)}${px(cx - 3, cy - 2)}${px(cx - 4, cy - 1, 1, 3)}${px(cx - 3, cy + 2)}${px(cx - 2, cy + 3)}${px(cx + 2, cy - 3)}${px(cx + 3, cy - 2)}</g>
110 <g fill="${BRASS_DARK}">${px(cx + 4, cy - 1, 1, 3)}${px(cx + 3, cy + 2)}${px(cx + 2, cy + 3)}${px(cx - 1, cy + 4, 3, 1)}${px(cx, cy - 5)}${px(cx - 3, cy + 5, 7, 1)}</g>
111 <g fill="${FACE}">${px(cx - 1, cy - 3, 3, 1)}${px(cx - 2, cy - 2, 5, 1)}${px(cx - 3, cy - 1, 7, 3)}${px(cx - 2, cy + 2, 5, 1)}${px(cx - 1, cy + 3, 3, 1)}</g>
112 <g fill="${CERAMIC_DARK}">${px(cx, cy - 3)}${px(cx + 3, cy)}${px(cx, cy + 3)}${px(cx - 3, cy)}</g>`
113
114// The face's pixels, as [row, first column, last column] from its center:
115// seven across in the large size, five in the small one.
116type FaceRows = readonly (readonly [number, number, number])[]
117const FACE_ROWS: FaceRows = [[-3, -1, 1], [-2, -2, 2], [-1, -3, 3], [0, -3, 3], [1, -3, 3], [2, -2, 2], [3, -1, 1]]
118const SMALL_FACE_ROWS: FaceRows = [[-2, -1, 1], [-1, -2, 2], [0, -2, 2], [1, -2, 2], [2, -1, 1]]
119
120// What is left of the tea hour on a face, as a timer's colored disc: the
121// pixels at or past `from` (a share of the hour, clockwise from twelve) round
122// to twelve, the center aside.
123const timeLeft = (rows: FaceRows, cx: number, cy: number, from: number) =>
124 `<g fill="${STRIPE}">${rows
125 .flatMap(([dy, a, b]) =>
126 Array.from({ length: b - a + 1 }, (_, k) => a + k)
127 .filter(dx => (dx !== 0 || dy !== 0) && ((Math.atan2(dx, -dy) / (2 * Math.PI) + 1) % 1) >= from - 1e-9)
128 .map(dx => px(cx + dx, cy + dy)),
129 )
130 .join('')}</g>`
131
132// Cache: the hour of tea on a brass clock that works as a timer. While the
133// cache lives a colored disc on its face shows the time left, an eighth of
134// it going by itself at each eighth of the cache's life, clockwise from
135// twelve; once the cache expires the face is bare and says six: the tea hour
136// is over. Before the first answer the disc is whole.
137const clock = (left: number | null, ttl: number, x: number) => {
138 const cx = x + 7
139 const cy = 3
140 const pin = `<g fill="${HAND}">${px(cx, cy)}</g>`
141 if (left === null) {
142 return `${clockFace(cx, cy)}${timeLeft(FACE_ROWS, cx, cy, 0)}${pin}${pixelText('--', x)}`
143 }
144 if (left <= 0) {
145 return `${clockFace(cx, cy)}<g fill="${HAND}">${px(cx, cy - 2, 1, 5)}</g>${pixelText('0m', x)}`
146 }
147 const timing = `animation-duration: ${ttl}s; animation-delay: -${ttl - left}s`
148 return `
149 ${clockFace(cx, cy)}
150 ${Array.from({ length: EIGHTHS }, (_, i) => `<g class="e${i}" style="${timing}">${timeLeft(FACE_ROWS, cx, cy, i / EIGHTHS)}</g>`).join('')}
151 ${pin}
152 ${countdown(left, x)}`
153}
154
155// The keyframes that show each eighth of the timer's disc going for its
156// eighth of the cache's life, the last one to the end.
157const EIGHTHS_CSS = Array.from({ length: EIGHTHS }, (_, i) => {
158 const from = (i * 100) / EIGHTHS
159 const to = ((i + 1) * 100) / EIGHTHS
160 const shown = i === EIGHTHS - 1 ? `${from}%, 100% { opacity: 1; }` : `${from}%, ${to - 0.1}% { opacity: 1; } ${to}%, 100% { opacity: 0; }`
161 return `
162 .e${i} { animation-name: e${i}; animation-timing-function: steps(1); animation-fill-mode: forwards; }
163 @keyframes e${i} { ${i === 0 ? '' : `0%, ${from - 0.1}% { opacity: 0; } `}${shown} }`
164}).join('')
165
166// Where each cookie sits in the jar, by its top left corner, the last one
167// eaten first: two on the bottom, one on them, and two on top.
168const COOKIES: [number, number][] = [[3, 5], [8, 5], [6, 3], [3, 2], [8, 2]]
169
170// The crumbs on the jar's bottom with no, one or two cookies left.
171const CRUMBS: number[][] = [[4, 7, 10], [7, 10], [7]]
172
173// One round cookie, four pixels across and three tall, with a chip; every
174// other cookie a shade lighter, so a pile of them reads as separate cookies.
175const cookie = (x: number, y: number, i: number) => `
176 <g fill="${i % 2 === 0 ? COOKIE : COOKIE_LIGHT}">${px(x + 1, y, 2, 1)}${px(x, y + 1, 4, 1)}${px(x + 1, y + 2, 2, 1)}</g>
177 <g fill="${CHIP}">${px(x + (i % 2 === 0 ? 2 : 1), y + 1)}</g>`
178
179// Five-hour limit: a glass jar with a cookie for every fifth of the window
180// still free; with two or fewer left, crumbs lie on its bottom.
181const jar = (used: number | null, x: number) => {
182 const left = used === null ? null : 100 - used
183 const count = level(left, COOKIES.length)
184 return `
185 <g fill="${GLASS}" opacity="0.15">${px(x + 3, 2, 9, 6)}</g>
186 ${COOKIES.slice(0, count).map(([dx, y], i) => cookie(x + dx, y, i)).join('')}
187 <g fill="${COOKIE_DARK}">${(CRUMBS[count] ?? []).map(dx => px(x + dx, 7)).join('')}</g>
188 <g fill="${GLASS}">${px(x + 2, 2, 1, 6)}${px(x + 12, 2, 1, 6)}${px(x + 2, 8, 11, 1)}</g>
189 <g fill="${WOOD}">${px(x + 4, 0, 7, 1)}</g><g fill="${WOOD_DARK}">${px(x + 7, -1)}${px(x + 3, 1, 9, 1)}</g>
190 <g fill="#FFFFFF" opacity="0.5">${px(x + 11, 5, 1, 2)}</g>
191 ${percentText(left, x)}`
192}
193
194// Where each tea bag stands in the box, by its left column, the last one
195// taken first.
196const BAGS = [3, 6, 9]
197
198// Weekly limit: a box of tea bags, their tops showing over its rim and their
199// tags hanging down its front, fewer as the week's limit is used.
200const teaBox = (used: number | null, x: number) => {
201 const left = used === null ? null : 100 - used
202 const bags = BAGS.slice(0, level(left, BAGS.length))
203 return `
204 <g fill="${INSIDE}">${px(x + 3, 2, 9, 1)}</g>
205 ${bags.map((dx, i) => `<g fill="${i % 2 === 0 ? BAG : BAG_DARK}">${px(x + dx, i === 1 ? 0 : 1, 3, i === 1 ? 3 : 2)}</g><g fill="${STRING}">${px(x + dx + 1, i === 1 ? -1 : 0)}</g>`).join('')}
206 <g fill="${BOX}">${px(x + 2, 3, 11, 6)}</g>
207 <g fill="${BOX_DARK}">${px(x + 2, 3, 11, 1)}${px(x + 2, 8, 11, 1)}</g>
208 <g fill="${STRING}">${bags.map(dx => px(x + dx + 1, 4)).join('')}</g>
209 <g fill="${TAG}">${bags.map(dx => px(x + dx + 1, 5, 1, 2)).join('')}</g>
210 ${percentText(left, x)}`
211}
212
213const teatimeSvg = (f: Meters) =>
214 sceneSvg(
215 `${COUNTDOWN_CSS}
216 ${REFILL_CSS}
217 .steam1 { animation: steam 2s steps(4) infinite; }
218 .steam2 { animation: steam 2s steps(4) -1s infinite; }
219 .pot-steam { animation: steam 2s steps(4) -0.5s infinite; }
220 @keyframes steam { 0% { transform: translate(0, 2px); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(0, -1px); opacity: 0; } }
221 .lift { animation: lift 2s steps(1) both; }
222 @keyframes lift { from { transform: translate(0, -2px); } to { transform: translate(0, 0); } }
223 .pour { animation: pour 2s steps(1) both; }
224 @keyframes pour { from { opacity: 1; } to { opacity: 0; } }
225 ${EIGHTHS_CSS}`,
226 `
227 ${shelf()}
228 ${mug(f.contextLeft, 0, f.isCompacting)}
229 ${clock(f.cacheLeft, f.cacheTtl, SLOT)}
230 ${jar(f.fiveHour, SLOT * 2)}
231 ${teaBox(f.week, SLOT * 3)}`,
232 )
233
234// The scene in the small size, eight pixels tall: the same four objects
235// drawn small, four pixels apart, on the board at the bottom. Each shows
236// four steps of its meter: more than half, down to half, down to a quarter,
237// and nothing. No teapot: the mug alone fills up while compacting.
238const smallTeatimeSvg = (f: Meters) => {
239 const tea = [0, 1, 2, 4][stageOf(f.contextLeft)] ?? 0
240 const isWarm = f.isCompacting || !runsLow(f.contextLeft)
241 const mugTea = f.isCompacting
242 ? `<g class="refill"><g fill="${TEA}">${px(2, 2, 4, 4)}</g><g fill="${TEA_TOP}">${px(2, 2, 4, 1)}</g></g>`
243 : tea > 0
244 ? `<g fill="${TEA}">${px(2, 6 - tea, 4, tea)}</g><g fill="${TEA_TOP}">${px(2, 6 - tea, 4, 1)}</g>`
245 : ''
246 const mug = `
247 <g fill="${INSIDE}">${px(2, 2, 4, 4)}</g>
248 ${mugTea}
249 <g fill="${CERAMIC}">${px(1, 2, 1, 5)}${px(7, 3, 2, 1)}${px(8, 4)}${px(7, 5, 2, 1)}</g>
250 <g fill="${CERAMIC_DARK}">${px(6, 2, 1, 5)}${px(2, 6, 4, 1)}</g>
251 <g fill="${STRIPE}">${px(1, 4)}${px(6, 4)}</g>
252 ${isWarm ? `<g fill="${CERAMIC}" opacity="0.6"><g class="steam1">${px(3, 1)}</g><g class="steam2">${px(5, 1)}</g></g>` : ''}`
253
254 // The clock: its disc whole before the first answer, then half and a
255 // quarter of it as the cache's life goes by, and a bare face at six once it
256 // expires.
257 const face = `
258 <g fill="${BRASS}">${px(15, 0, 3, 1)}${px(14, 1)}${px(13, 2, 1, 3)}${px(14, 5)}${px(18, 1)}</g>
259 <g fill="${BRASS_DARK}">${px(19, 2, 1, 3)}${px(18, 5)}${px(15, 6, 3, 1)}</g>
260 <g fill="${FACE}">${px(15, 1, 3, 1)}${px(14, 2, 5, 3)}${px(15, 5, 3, 1)}</g>`
261 const timer = (from: number) => `${face}${timeLeft(SMALL_FACE_ROWS, 16, 3, from)}<g fill="${HAND}">${px(16, 3)}</g>`
262 const atSix = `${face}<g fill="${HAND}">${px(16, 1, 1, 5)}</g>`
263 const clock = f.cacheLeft === null ? timer(0) : hourSteps(f, [timer(0), timer(0.5), timer(0.75)], atSix)
264
265 const fiveLeft = f.fiveHour === null ? null : 100 - f.fiveHour
266 const count = [0, 1, 3, 5][stageOf(fiveLeft)] ?? 0
267 const cookies = ([[25, 5], [27, 5], [25, 4], [27, 4], [26, 3]] as const)
268 .slice(0, count)
269 .map(([x, y], i) => `<g fill="${i % 2 === 0 ? COOKIE : COOKIE_LIGHT}">${px(x, y, 2, 1)}</g>`)
270 .join('')
271 const crumbs = fiveLeft === null || count > 1 ? '' : `<g fill="${COOKIE_DARK}">${(count === 0 ? [25, 28] : [28]).map(x => px(x, 5)).join('')}</g>`
272 const jar = `
273 ${cookies}${crumbs}
274 <g fill="${GLASS}">${px(24, 3, 1, 4)}${px(29, 3, 1, 4)}${px(25, 6, 4, 1)}</g>
275 <g fill="${WOOD_DARK}">${px(24, 2, 6, 1)}</g><g fill="${WOOD}">${px(25, 1, 4, 1)}</g>`
276
277 const weekLeft = f.week === null ? null : 100 - f.week
278 const bags = [35, 37, 36].slice(0, [0, 1, 2, 3][stageOf(weekLeft)] ?? 0)
279 const box = `
280 <g fill="${INSIDE}">${px(34, 2, 5, 1)}</g>
281 <g fill="${BAG}">${bags.map(x => px(x, x === 36 ? 1 : 2, 1, x === 36 ? 2 : 1)).join('')}</g>
282 <g fill="${BOX}">${px(33, 3, 7, 4)}</g>
283 <g fill="${BOX_DARK}">${px(33, 3, 7, 1)}</g>
284 <g fill="${STRING}">${bags.map(x => px(x, 4)).join('')}</g>
285 <g fill="${TAG}">${bags.map(x => px(x, 5)).join('')}</g>`
286
287 return smallSceneSvg(
288 `
289 .steam1 { animation: steam 2s steps(1) infinite; }
290 .steam2 { animation: steam 2s steps(1) -1s infinite; }
291 @keyframes steam { 0%, 24.9% { opacity: 0; transform: translate(0, 0); } 25%, 49.9% { opacity: 1; transform: translate(0, 0); } 50%, 74.9% { opacity: 1; transform: translate(0, -1px); } 75%, 100% { opacity: 0; } }
292 .refill { animation: refill 2s steps(4) both; }
293 @keyframes refill { from { clip-path: inset(4px 0 0 0); } to { clip-path: inset(0 0 0 0); } }`,
294 `<g fill="${WOOD}">${px(0, 7, 40, 1)}</g>
295 <g fill="${GRAIN}">${[3, 12, 17, 26, 33, 38].map(x => px(x, 7)).join('')}</g>
296 ${mug}${clock}${jar}${box}`,
297 )
298}
299
300export const teatimeScene: MeterScene = {
301 name: 'teatime',
302 label: wordsOf(t => t.scenes.teatime),
303 width: WIDTH,
304 height: HEIGHT,
305 scale: SCALE,
306 svg: teatimeSvg,
307 small: { svg: smallTeatimeSvg, centers: [4.5, 16, 26.5, 36] },
308}
309