SLOPSHOPPER

Cozy Clawd

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

newpanebandguardcommandtoast
★ 2v1.0.0MITupdated 2026-10-06OrihuelaConde/cozy-clawd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cozy-clawd
│ ┃ Scenes ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting │ ┃ Scene: Balcony ▾ ⏺ Read(src/auth.ts) │ ┃ Language: Automatic (English) ▾ ⎿ Read 6 lines │ ┃ Size: Small ▾ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cozy-clawd │ ⎿ cozy-clawd: Scenes panel opened. │ │ Waiting ▄▀▄ ▄▄▄▄▄ ▄▄▄▄▄ ▀▀ ▄ ▄▄▄▄▄▄ ▀ ▄▄ ███ █▀▀▀█ █▜███▜▌ ▀█████▀ ▄▄▀▄▄ ▀▀▀ ▄▀▀▀▄▄ █████ ▀██████▛▘ ▄▄▄▀▀▀▀▀▄▄▄▄▀▀▀▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▀▀▀▀▀▄▄▄▄ ▘▘ ▘▘ 51% -- 69% -- ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Waiting ▄▀▄ ▄▄▄▄▄ ▄▄▄▄▄ ▀▀ ▄ ▄▄▄▄▄▄ ▀ ▄▄ ███ █▀▀▀█ █▜███▜▌ ▀█████▀ ▄▄▀▄▄ ▀▀▀ ▄▀▀▀▄▄ █████ ▀██████▛▘ ▄▄▄▀▀▀▀▀▄▄▄▄▀▀▀▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▀▀▀▀▀▄▄▄▄ ▘▘ ▘▘ 51% -- 69% --
Pane · Scenes
Waiting Scene: Balcony ▾ Language: Automatic (English) ▾ Size: Small ▾
README

<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.

Install

Cozy Clawd needs the following:

  • Claude Code 2.1.287 or later, where mods are on by default.
  • The Code tab of the Claude desktop app, or a terminal. For the scenes' true colors, use a terminal with 24-bit color (truecolor), such as Windows Terminal or iTerm2; other terminals show approximate colors.

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 about CLAUDE.md at 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.

Install from a terminal

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".

Install from the desktop app

In the Claude desktop app, you add the marketplace and install the mod from the Plugins page. To install the mod, do the following:

  1. Open Customize > Plugins.
  2. Select Add > Add marketplace, and then choose to add it from a repository.
  3. In URL, enter OrihuelaConde/claude-plugins, and then select Sync.
  4. On the Discover tab, search for 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.
  5. Start a new session in the Code tab, or send /reload-plugins in the session that's open. The band appears above the prompt.

Update or uninstall the mod

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.

What Clawd does

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.

Read the meters

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:

  1. The context window left, as a percentage.
  2. The minutes left on the prompt cache, counted down from Claude's last answer.
  3. What's left of your five-hour usage limit.
  4. What's left of your weekly usage limit.

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">

Customize the band

The /cozy-clawd panel holds the mod's settings. A change applies at once, and later sessions start with it.

SettingChoicesDefaultDetails
SceneEight scenesbalconyScenes
Size, in a terminal onlySmall or LargeSmallThe band in a terminal
LanguageTen languages, or AutomaticAutomaticLanguages

Choose a scene

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.

The band in a terminal

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.

Troubleshoot

The install fails with "Host key verification failed"

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.

The band doesn't appear

Check the following:

  • Claude Code is version 2.1.287 or later. To see the version, run claude --version.
  • The session started after you installed the mod. To load the mod in a session that was already open, run /reload-plugins in it.
  • The mod is turned on. To turn it back on, run claude plugin enable cozy-clawd@orihuelaconde.

The colors look off in a terminal

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 band shows words instead of pictures

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.

What the mod does on your machine

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.

Contribute

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.

License

Cozy Clawd is released under the MIT License.

Source 25 files
hooks/register.tsx 2015 lines
1import { 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 lines
1// 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>`
636
hooks/scenes/index.ts 84 lines
1// 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}
84
hooks/scenes/pixels.ts 176 lines
1// 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>`
176
hooks/language.ts 84 lines
1// 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}
84
hooks/raster.ts 1083 lines
1// 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)
1083
hooks/scenes/adventure.ts 249 lines
1// 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}
249
hooks/scenes/balcony.ts 309 lines
1// 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}
309
hooks/scenes/cyberpunk.ts 236 lines
1// 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}
236
hooks/scenes/gamer.ts 269 lines
1// 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}
269
hooks/scenes/mate.ts 245 lines
1// 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}
245
hooks/scenes/teatime.ts 309 lines
1// 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