Otto, a blue octopus, forecasts your context window on one line above the prompt, in the Claude Code desktop app, and in text in the terminal: weather scaled…

Otto, a blue octopus, forecasts your context window, above the prompt. A fork of Anthropic's Token Weather.
Drawn in full in the Claude Code desktop app. In the terminal, the same line in text, without Otto or tooltips: a glyph for the forecast, block bars for the prompts and the limits.
/otto-hud demo plays every animation in turn, about 90 s, each with its tooltip; /otto-hud <animation> plays one (thinking, puzzled, detective, laptop, desktop, dejected, ink, crow, lightbulb, jump, dance, twirl, lookaround, bubbles, wave, shout, tap, settle, dizzy).summary), no token-count request./config (en or fr). auto follows Claude Code's language setting, else English.Reads $.session.usage(), watches turns, compactions and tool calls (only their names and whether they failed: it never changes a call or its result), reads Claude Code's language setting, and keeps the latest limits reading in the plugin's local storage. No network, no files, no commands, no environment variables: nothing leaves your machine, and nothing about you is collected.
hooks/otto-hud.mjs 782 lines1// Copyright 2026 Anthropic PBC (Token Weather), changes by Manuel
2// SPDX-License-Identifier: Apache-2.0
3//
4// Otto HUD: Otto, a blue octopus, forecasts your context window, above the prompt.
5//
6// [Otto] ☂ 60% ━━━━╎━╎━ 5h ━━━▒▒╎─ 32% 7d ━━━━━╎▒ 59%
7//
8// Icons and bars, their details in a card on hover, drawn with Svg (the desktop app, the editor, mobile). The
9// terminal gets the same line in text, glyphs and block bars, without Otto or cards:
10//
11// ☂ 60% ▂▃▅█ +6.3k 5h ███▎██████ 32% 7d █████▊████ 59% (used, faded on to the pace's end, the track)
12//
13// The weather after Anthropic's Token Weather. What is new: Otto, the forecast scaled to
14// auto-compact, what the context is made of, and the account limits.
15//
16// session.start / turn.complete: read $.session.usage() (context, breakdown "summary" = local
17// estimates, rate limits). session.measure: a fresher reading of the limits. turn.start, tool.call,
18// turn.complete: Otto's mood. ui.render (AbovePrompt): the line, built from blocks; the least
19// important ones go first when the band is narrow.
20//
21// The host reads on(...) and $.noun.method(...) from source, so they are spelled literally, and
22// helpers that take $ are top-level functions.
23
24import { HOLD_MS, OTTO, OTTO_COLOR, PLAY_MS, ottoSvg } from "./otto.mjs";
25import { WEATHER } from "./weather.mjs";
26
27// ---------- Texts ----------
28
29const TEXT = {
30 en: {
31 sky: { clear: "Clear", cloudy: "Cloudy", showers: "Showers", storm: "Storm", compact: "Compact soon" },
32 // Each limit: its label on the line, its name in the tooltip.
33 limits: { five_hour: ["5h", "5-hour limit"], seven_day: ["7d", "7-day limit"], spend_limit: ["$", "Spend limit"] },
34 days: "d",
35 weekday: (ms) => new Date(ms).toLocaleDateString("en", { weekday: "short" }),
36 pct: (n) => n + "%",
37 categories: {},
38 demo: {
39 description: "Otto HUD: /otto-hud demo plays every Otto animation in the band, /otto-hud <animation> one of them",
40 usage: (names) => `Usage: /otto-hud demo, or /otto-hud <animation>: ${names}`,
41 one: (name) => `Otto plays ${name} above the prompt.`,
42 started: "Otto plays all his animations above the prompt (about 75 s). Hover him to read each one's tooltip.",
43 },
44 moods: {
45 idle: "Otto is resting",
46 dizzy: "Otto is dizzy: compaction is near",
47 thinking: "Claude is thinking",
48 working: (tool) => `Claude is using ${tool}`,
49 agents: (n) => (n === 1 ? "A subagent is working" : `${n} subagents are working`),
50 error: (tool) => `${tool} failed`,
51 done: "Turn done",
52 failed: "The turn stopped on an error",
53 },
54 // A title, then one line per fact.
55 tips: {
56 weather: (word, used, toCompact, at, tokens, window) =>
57 `${word}\n${used}% of the context used` + (at ? `\n${toCompact}% of the way to auto-compact (at ${at})` : "") + `\n${tokens} of ${window} tokens used`,
58 unmeasured: "Not measured yet",
59 turns: (list) => `Tokens added by each recent prompt\n${list || "None measured yet"}`,
60 stack: (clear) => (clear ? "What you see in the sky" : "What covers the sky"),
61 // The lines a part of the gauge stands for say which (mark), for their legend.
62 gauge: (name, used, elapsed, pace, left, at) => [
63 name,
64 { mark: "used", text: `${used} used` },
65 elapsed !== null && { mark: "elapsed", text: `${elapsed}% of the time elapsed` },
66 pace && { mark: "pace", text: pace.hit ? `100% in ${pace.hit} at this pace` : `${pace.end} by the reset at this pace` },
67 left && `Resets in ${left} at ${at}`,
68 ].filter(Boolean),
69 },
70 },
71 fr: {
72 sky: { clear: "Clair", cloudy: "Nuageux", showers: "Averses", storm: "Orage", compact: "Compacter bientôt" },
73 limits: { five_hour: ["5h", "Limite 5 heures"], seven_day: ["7j", "Limite 7 jours"], spend_limit: ["$", "Limite de dépense"] },
74 days: "j",
75 weekday: (ms) => new Date(ms).toLocaleDateString("fr", { weekday: "short" }),
76 pct: (n) => n + "%",
77 categories: {
78 "system prompt": "prompt système",
79 "system tools": "outils système",
80 "mcp tools": "outils MCP",
81 "memory files": "mémoire",
82 "custom agents": "agents",
83 },
84 demo: {
85 description: "Otto HUD : /otto-hud demo joue toutes les animations d'Otto dans la barre, /otto-hud <animation> l'une d'elles",
86 usage: (names) => `Usage : /otto-hud demo, ou /otto-hud <animation> : ${names}`,
87 one: (name) => `Otto joue ${name} au-dessus du prompt.`,
88 started: "Otto joue toutes ses animations au-dessus du prompt (environ 75 s). Survolez-le pour lire la bulle de chacune.",
89 },
90 moods: {
91 idle: "Otto se repose",
92 dizzy: "Otto a le tournis : le compactage approche",
93 thinking: "Claude réfléchit",
94 working: (tool) => `Claude utilise ${tool}`,
95 agents: (n) => (n === 1 ? "Un sous-agent travaille" : `${n} sous-agents travaillent`),
96 error: (tool) => `${tool} a échoué`,
97 done: "Tour terminé",
98 failed: "Le tour s'est arrêté sur une erreur",
99 },
100 tips: {
101 weather: (word, used, toCompact, at, tokens, window) =>
102 `${word}\n${used}% du contexte utilisé` + (at ? `\n${toCompact}% du chemin vers l'auto-compact (à ${at})` : "") + `\n${tokens} sur ${window} tokens utilisés`,
103 unmeasured: "Pas encore mesurée",
104 turns: (list) => `Tokens ajoutés par chaque prompt récent\n${list || "Aucun mesuré pour l'instant"}`,
105 stack: (clear) => (clear ? "Ce qu'on voit dans le ciel" : "Ce qui couvre le ciel"),
106 gauge: (name, used, elapsed, pace, left, at) => [
107 name,
108 { mark: "used", text: `${used} utilisés` },
109 elapsed !== null && { mark: "elapsed", text: `${elapsed}% du temps écoulé` },
110 pace && { mark: "pace", text: pace.hit ? `100% dans ${pace.hit} à ce rythme` : `${pace.end} à la remise à zéro à ce rythme` },
111 left && `Remise à zéro dans ${left} à ${at}`,
112 ].filter(Boolean),
113 },
114 },
115};
116let T = TEXT.en;
117
118// ---------- Forecast ----------
119
120// The tokens of the last `bars` prompts, so one reading more; a second one also tells that a turn has been measured.
121// Always the room of every bar and of the longest change (+99.9k), so the gauges after them never move.
122const SPARK = { bars: 8, height: 14, bar: 5.5, gap: 2, trend: 6 };
123const SPARK_WIDTH = SPARK.bars * SPARK.bar + (SPARK.bars - 1) * SPARK.gap;
124const HISTORY = SPARK.bars + 1;
125
126// By percent of the way to auto-compact (of the window when auto-compact is off).
127const FORECAST = [
128 { upTo: 25, id: "clear" },
129 { upTo: 50, id: "cloudy" },
130 { upTo: 75, id: "showers" },
131 { upTo: 90, id: "storm" },
132 { upTo: Infinity, id: "compact" },
133];
134
135// ---------- Account limits (5 hours, 7 days) ----------
136
137const H = 3_600_000;
138// Each limit's window, for the share of time gone (a spend cap has none), and its place on the line.
139const LIMIT_SPAN = { five_hour: 5 * H, seven_day: 168 * H };
140const LIMIT_PLACE = { five_hour: 0, seven_day: 1, spend_limit: 2 };
141// The pace: the share used by the reset if usage goes on as it went. Over 100: amber; over
142// `alert`, or past `used` percent already: red. Before `settle` percent of the window has gone,
143// too little time to tell a pace.
144const PACE = { settle: 10, alert: 130, used: 90 };
145// The latest limits any session measured, shared through $.store.
146const LIMITS_KEY = "rateLimits";
147const TONES = { calm: "#3fa66b", fast: "#d9962b", alert: "#d64545" };
148// The share projected, faded: its tone at this opacity (an alpha in hex).
149const FADED = "59";
150const grey = (alpha) => `rgba(128,128,128,${alpha})`;
151// A gauge: its bar, and how far the mark where the time stands goes past it above and below (px).
152const GAUGE = { width: 72, height: 9, overhang: 3 };
153// A tooltip's legend: all the same box, so the lines' texts line up, as tall as a gauge's drawing (within a line).
154const LEGEND = { width: 9, height: GAUGE.height + 2 * GAUGE.overhang };
155
156// ---------- Drawings ----------
157
158// The weather icon is bigger: an animated drawing reads better with room.
159const WEATHER_SIZE = 22;
160// Tooltip cards: theme keys, not raw colors, so they follow the light or dark theme.
161const TIP = { back: "userMessageBackground", text: "text" };
162// On the terminal: a glyph per forecast, Token Weather's own single-width symbols, in its tint; eighths of a cell, from the bottom for the prompts' bars and
163// from the left for the limits'; a limit's bar at the least, in cells, and its track's color (a grey that shows on
164// dark and light themes alike).
165const GLYPHS = { clear: "☀", cloudy: "☁", showers: "☂", storm: "☇", compact: "↯" };
166const EIGHTHS = "▁▂▃▄▅▆▇█", LEFT_EIGHTHS = " ▏▎▍▌▋▊▉█";
167const TEXT_GAUGE = 10, TEXT_TRACK = "#5f6368";
168const WEATHER_COLORS = { clear: "#e0b000", cloudy: "#8ea3b8", showers: "#2f68c0", storm: "#b04fc0", compact: "#d64545" };
169
170// Otto, drawn whole in each mood's SVG (otto.mjs).
171const OTTO_CELLS = Math.ceil(OTTO.width / 8);
172// At rest between two quiet animations.
173const PLAY_EVERY = 45_000;
174const pick = (list) => list[Math.floor(Math.random() * list.length)];
175
176// ---------- State ----------
177
178// Readings: { tokens, window, percent, threshold }, oldest first; kept by the host for the session,
179// so a reload of the mod (an edit, an update) goes on with the same history.
180let readings = [];
181const READINGS = { plugin: "otto-hud", key: "readings" };
182// The context as the last response left it, between readings ({ tokens, window, percent } or null): what
183// session.measure reports after each response, for the forecast and its percent to follow it live. Not a reading, so
184// the prompts' bars only move at the end of a turn.
185let live = null;
186// The latest breakdown: { categories } or null.
187let breakdown = null;
188// The account's limits as last measured: { at (ms), list: SessionRateLimit[] }.
189let account = { at: 0, list: [] };
190let minuteTimer = null;
191// What Otto is doing: { name, look (the drawing), tip, at, then, ms }; see moodOf.
192let mood = null;
193// The subagents at work, by agentId: from their first tool call to the end of their turn. While
194// any is, Otto at rest waits on them, without the pause of a true rest (the main turn may be over,
195// waiting on background agents).
196const agents = new Set();
197let moodTimer = null;
198
199export function register(on, options) {
200 on("session.start", async ($, e, next) => {
201 const started = await next(e);
202 T = TEXT[await languageOf($, options?.language)];
203 [breakdown, account] = [null, { at: 0, list: [] }];
204 readings = (await $.state.get(READINGS)).value ?? [];
205 agents.clear();
206 await syncLimits($);
207 // A reload's reading stands for the last one, not for another turn.
208 await takeReading($, false);
209 await setMood($, "idle");
210 await $.command.register({ name: "otto-hud", description: T.demo.description, argumentHint: "demo | thinking | laptop | … | dizzy" });
211 // Once a minute the time gone moves on, and another session may have measured the limits since.
212 minuteTimer?.cancel();
213 minuteTimer = $.clock.every(60_000, async () => {
214 await syncLimits($);
215 $.ui.invalidate("ui.render");
216 });
217 return started;
218 });
219
220 // /otto-hud demo plays them all, /otto-hud <animation> just one.
221 on("command.run", { command: "otto-hud" }, async ($, e) => {
222 const arg = e.args.trim().toLowerCase();
223 if (arg === "demo") {
224 await demo($, 0, DEMO.length);
225 return { text: T.demo.started };
226 }
227 const i = DEMO.findIndex(([, , look]) => look === arg);
228 if (i < 0) return { text: T.demo.usage(DEMO.map(([, , look]) => look).join(", ")) };
229 await demo($, i, i + 1);
230 return { text: T.demo.one(DEMO[i][2]) };
231 });
232
233 on("turn.start", async ($, e, next) => {
234 demoTimer?.cancel();
235 await setMood($, "thinking");
236 return next(e);
237 });
238
239 // The main loop's tools: Otto at his computer, and a reaction when one fails.
240 on("tool.call", async ($, e, next) => {
241 if (e.agentId) {
242 if (!agents.has(e.agentId)) await agentsChanged($, () => agents.add(e.agentId));
243 return next(e);
244 }
245 const tool = String(e.tool).replace(/^mcp__.+?__/, "");
246 if (mood?.name === "working") {
247 mood.tip = T.moods.working(tool);
248 $.ui.invalidate("ui.render");
249 } else await setMood($, "working", tool);
250 const result = await next(e);
251 if (result?.isError) await setMood($, "error", tool);
252 return result;
253 });
254
255 on("turn.complete", async ($, e, next) => {
256 const done = await next(e);
257 if (e.agentId) await agentsChanged($, () => agents.delete(e.agentId));
258 else {
259 await takeReading($);
260 await setMood($, { answer: "done", aborted: "idle" }[e.reason] ?? "failed");
261 }
262 return done;
263 });
264
265 // A compaction of the main conversation is no turn, but it moves the context: read it again.
266 on("session.compact", async ($, e, next) => {
267 const done = await next(e);
268 if (!e.agentId && e.trigger !== "precompute" && done?.messages) await takeReading($);
269 return done;
270 });
271
272 on("session.measure", async ($, e, next) => {
273 if (e.changed.includes("rateLimits") && e.rateLimits.length) await syncLimits($, e.rateLimits);
274 // The figures come with the event: nothing to ask, no token spent.
275 const c = e.context;
276 if (e.changed.includes("context") && c.tokens > 0) live = { tokens: c.tokens, window: c.window, percent: Math.round(c.percent ?? (c.tokens / c.window) * 100) };
277 $.ui.invalidate("ui.render");
278 return next(e);
279 });
280
281 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
282 // The terminal draws the line in text: no Svg there (the test kit's table has one, hence the surface).
283 const resolved = $.ui.resolve(e);
284 const el = e.surface === "terminal" ? { ...resolved, Svg: undefined } : resolved;
285 const idle = readings.length === 0 && account.list.length === 0;
286 if (e.props.hasSurvey || idle) return next(e);
287 const line = drawLine(el, e.props.bodyColumns ?? 80, await $.clock.now());
288 // The mods after us go under our line, without a blank row when they draw nothing.
289 const under = await next(e);
290 return isEmpty(under) ? line : el.Box({ flexDirection: "column", children: [line, under] });
291 });
292}
293
294// The language option when it is "en" or "fr". Otherwise Claude Code's language setting ("french",
295// "français"…): French when it says so, else English. No locale variables: the desktop app passes none.
296async function languageOf($, option) {
297 if (option in TEXT) return option;
298 const setting = await $.config.list().then((rows) => rows.find((row) => row.key === "language")?.value, () => "");
299 return String(setting ?? "").toLowerCase().startsWith("fr") ? "fr" : "en";
300}
301
302// ---------- Readings ----------
303
304async function takeReading($, isTurn = true) {
305 try {
306 const usage = await $.session.usage({ breakdown: "summary" });
307 const context = usage.context;
308 if (context && context.window) {
309 const tokens = context.tokens ?? 0;
310 const percent = Math.round(context.percent ?? (tokens / context.window) * 100);
311 const threshold = context.breakdown?.autoCompactThreshold ?? 0;
312 // The session.start reading is 0 before any response; drop it once real readings arrive.
313 const kept = isTurn ? readings : readings.slice(0, -1);
314 readings = [...kept.filter((r) => r.tokens > 0), { tokens, window: context.window, percent, threshold }].slice(-HISTORY);
315 live = null;
316 await $.state.set(READINGS, readings);
317 breakdown = context.breakdown ? { categories: context.breakdown.categories ?? [] } : null;
318 }
319 // On start the local reading may be stale (an idle session): the shared one wins, and the local
320 // one is published only when none exists yet. After a turn the local one is the freshest.
321 const list = usage.rateLimits ?? [];
322 if (list.length > 0 && (account.list.length === 0 || readings.length > 1)) await syncLimits($, list);
323 } catch {
324 // No reading this turn; the line keeps the last one.
325 }
326 $.ui.invalidate("ui.render");
327}
328
329// Share of the way to auto-compact, or of the window when auto-compact is off.
330function weatherPercent(r) {
331 return r.threshold > 0 ? Math.round((r.tokens / r.threshold) * 100) : r.percent;
332}
333
334// What each recent prompt added, oldest first, and the forecast it left; a compaction (the context
335// shrinks) adds 0.
336function turnDeltas() {
337 return readings.slice(1).map((r, i) => ({ added: Math.max(0, r.tokens - readings[i].tokens), id: forecastFor(weatherPercent(r)).id }));
338}
339
340// How the context moved on the last turn: +6.3k, −40k (a compaction), or =.
341function lastTurn() {
342 const change = readings.at(-1).tokens - readings.at(-2).tokens;
343 return change === 0 ? "=" : (change > 0 ? "+" : "−") + short(Math.abs(change));
344}
345
346function forecastFor(percent) {
347 return FORECAST.find((band) => percent < band.upTo) ?? FORECAST[FORECAST.length - 1];
348}
349
350// The latest reading, with the context as the last response left it (live), or null before any reading: the
351// threshold stays the reading's, session.measure has none.
352function current() {
353 const r = readings.at(-1);
354 return r && live ? { ...r, ...live } : (r ?? null);
355}
356
357// The forecast for the context now, or null before any reading.
358function forecastNow() {
359 const cur = current();
360 return cur ? forecastFor(weatherPercent(cur)) : null;
361}
362
363// ---------- Limits ----------
364
365// The limits are the account's, so the freshest reading of any session wins. With `fresh` (this
366// session just measured them): keep them, and store them unless the store holds a later one.
367// Without: take the stored ones when they are later than ours. An unreadable store changes nothing.
368async function syncLimits($, fresh) {
369 const stored = await $.store.get(LIMITS_KEY).catch(() => null);
370 if (fresh) {
371 account = { at: await $.clock.now(), list: fresh };
372 if (!(stored?.at > account.at)) await $.store.set(LIMITS_KEY, account);
373 } else if (stored?.at > account.at && Array.isArray(stored.list)) account = stored;
374}
375
376// The limits as they stand now. A window past its reset is empty until it is used again, and a
377// subscription always has both windows: one the reading leaves out is empty too. Empty is 0%,
378// with no time to tell until the window starts again.
379function currentLimits(list, now) {
380 const empty = (kind) => ({ kind, percentUsed: 0 });
381 const limits = list.map((limit) => (Date.parse(limit.resetsAt ?? "") <= now ? empty(limit.kind) : limit));
382 // Before any reading of them (a session's start), the 5-hour and 7-day gauges hold their place, empty and unknown.
383 const known = limits.some((limit) => limit.kind in LIMIT_SPAN);
384 for (const kind of Object.keys(LIMIT_SPAN)) if (!limits.some((limit) => limit.kind === kind)) limits.push(known ? empty(kind) : { ...empty(kind), unknown: true });
385 return limits;
386}
387
388// One limit's numbers: share used, share of the window's time gone and share projected by the reset
389// (null without a window), tone, ms left before the reset (null once past) and before the limit
390// runs out at this pace (null if it does not).
391function gaugeOf(limit, now) {
392 const used = Math.max(0, limit.percentUsed);
393 const reset = Date.parse(limit.resetsAt ?? "");
394 const left = reset > now ? reset - now : null;
395 const span = LIMIT_SPAN[limit.kind];
396 const elapsed = span && left !== null ? clamp(100 - (left / span) * 100) : null;
397 const projected = elapsed >= PACE.settle ? (used / elapsed) * 100 : null;
398 const tone = used >= PACE.used || projected > PACE.alert ? "alert" : projected > 100 ? "fast" : "calm";
399 // At the same pace, the rest of the limit lasts (100 - used) / used of the time gone.
400 const runsOut = projected > 100 && used < 100 ? ((100 - used) / used) * (span - left) : null;
401 return { kind: limit.kind, used, elapsed, projected, tone, left, reset, runsOut, unknown: !!limit.unknown };
402}
403
404// 42 min, 3h02, 2d23h (2j23h in French).
405function duration(ms) {
406 const min = Math.round(ms / 60_000);
407 const two = (n) => String(n).padStart(2, "0");
408 if (min < 60) return `${min} min`;
409 if (min < 1440) return `${Math.floor(min / 60)}h${two(min % 60)}`;
410 return `${Math.floor(min / 1440)}${T.days}${two(Math.floor(min / 60) % 24)}h`;
411}
412
413// 16:05, in the machine's time zone.
414function clockTime(ms) {
415 return new Date(ms).toTimeString().slice(0, 5);
416}
417
418function clamp(percent) {
419 return Math.max(0, Math.min(100, percent));
420}
421
422// ---------- The line ----------
423
424// Each block: { key, tip, rank, cells, parts, grow?, gap? }, gap the cells before it (3, or fewer
425// for a block that goes with the one before). When the line is wider than the band, the
426// lowest ranks go first. The bars (grow) then flex into the room left, so the line fills the band: grow is true where
427// they flex, or in text a function drawing the block with its bar that many cells wide. Without Svg
428// (the terminal) there is no Otto, and no card: the band is one row there, which would cut a card to its first line.
429function drawLine(elements, columns, now) {
430 const { Box, Svg } = elements;
431 const cur = current();
432 const blocks = cur ? [weatherBlock(elements, forecastNow(), cur, breakdown?.categories ?? [], now)] : [];
433 if (cur) blocks.push(turnsBlock(elements));
434 // In line order.
435 const place = (limit) => LIMIT_PLACE[limit.kind] ?? 9;
436 currentLimits(account.list, now)
437 .sort((a, b) => place(a) - place(b))
438 .forEach((limit, i) => blocks.push(gaugeBlock(elements, gaugeOf(limit, now), now, 80 - i)));
439
440 // The blocks and the gaps between them, Otto and the gap after him, and the padding 1 on the right, as much as
441 // the room Otto keeps on his left.
442 const ottoCells = Svg ? OTTO_CELLS + 2 : 0;
443 const gapOf = (b, i) => (i === 0 ? 0 : (b.gap ?? 3));
444 let kept = blocks;
445 const width = (list) => list.reduce((sum, b, i) => sum + b.cells + gapOf(b, i), 0) + 1 + ottoCells;
446 while (kept.length > 1 && width(kept) > columns) {
447 const lowest = kept.reduce((a, b) => (b.rank < a.rank ? b : a));
448 kept = kept.filter((b) => b !== lowest);
449 }
450
451 // In text the bars cannot flex: those that grow (a function of their bar's cells) share out the cells left. The
452 // band is drawn again at each width, so they follow the terminal's.
453 const growing = kept.filter((b) => typeof b.grow === "function");
454 const spare = Math.max(0, columns - width(kept));
455 const partsOf = (b) => (typeof b.grow !== "function" ? b.parts : b.grow(TEXT_GAUGE + Math.floor(spare / growing.length) + (growing.indexOf(b) < spare % growing.length ? 1 : 0)));
456
457 // Back in display order.
458 const children = blocks
459 .filter((b) => kept.includes(b))
460 .map((b, i) => withTip(elements, b.key, partsOf(b), Svg ? b.tip : null, gapOf(b, i), b.grow === true));
461 const m = mood ?? { look: "idle", tip: T.moods.idle, at: now };
462 const otto = Svg ? [Box({ flexShrink: 0, children: [withTip(elements, "otto", [Svg({ key: "svg", source: ottoSvg(m.look, now - m.at), alt: m.tip, width: OTTO.width, height: OTTO.height })], m.tip)] })] : [];
463 return Box({
464 width: "100%",
465 flexDirection: "row",
466 alignItems: "stretch",
467 paddingRight: 1,
468 columnGap: 2,
469 // Otto's picture holds his head room (thought bubble, light bulb) above him: not lifted, so he stands in the band.
470 children: [...otto, Box({ key: "blocks", flexGrow: 1, minWidth: 0, flexDirection: "row", alignItems: "stretch", children })],
471 });
472}
473
474// A tooltip: a string with a line per fact, or an array of lines (empty: a blank line).
475// A line is a string (empty: a blank line) or { text, color, mark? }, which has a legend: a square of the color, or
476// with `mark` the gauges' time mark.
477const lines = (tip) => (Array.isArray(tip) ? tip : tip.split("\n"));
478// A tooltip as plain text, for an Svg's alt.
479const plain = (tip) => lines(tip).map((line) => line.text ?? line).join("\n");
480
481// Every block carries its details in a card shown on hover anywhere over it, the band's full height
482// (the line stretches its blocks), laid over the band. Plain images, no interactive frame: the
483// desktop rebuilds the band on every redraw, and frames flash as they reload. A null tip: no card.
484function withTip({ Box, Svg, Text }, key, parts, tip, gap = 0, grow = false) {
485 const card = tip !== null && Box({
486 position: "absolute",
487 top: 0,
488 left: 0,
489 display: "none",
490 hover: { display: "flex" },
491 flexDirection: "column",
492 backgroundColor: TIP.back,
493 paddingX: 1,
494 // One line each, the first the title when there are more.
495 children: lines(tip).map((line, i, all) => {
496 const text = Text({ color: TIP.text, bold: i === 0 && all.length > 1, wrap: "truncate-end", children: line.text ?? (line || " ") });
497 if (!line.color) return text;
498 // The desktop drops an Svg whose alt is empty (the test kit takes it): the shape it draws, as a glyph.
499 const legend = Svg({ key: "legend", source: legendSvg(line.color, line.mark), alt: line.mark ? "│" : "■", width: LEGEND.width, height: LEGEND.height });
500 return Box({ flexDirection: "row", columnGap: 1, alignItems: "center", children: [legend, text] });
501 }),
502 });
503 return Box({ key, marginLeft: gap, ...(grow ? { flexGrow: 1, flexShrink: 1, minWidth: 0 } : { flexShrink: 0 }), flexDirection: "row", columnGap: 1, alignItems: "center", children: card ? [...parts, card] : parts });
504}
505
506// The forecast, set by the way to auto-compact, and the share of the window used beside it; what
507// fills the context in its tooltip, consulted when needed rather than always in view.
508function weatherBlock({ Svg, Text }, f, cur, categories, now) {
509 const pct = T.pct(cur.percent);
510 const at = cur.threshold > 0 ? short(cur.threshold) : "";
511 const rows = contents(categories);
512 const tip = [...lines(T.tips.weather(T.sky[f.id], cur.percent, weatherPercent(cur), at, short(cur.tokens), short(cur.window))), ...(rows.length > 0 ? ["", T.tips.stack(f.id === "clear"), ...rows] : [])];
513 const icon = Svg
514 ? Svg({ key: "svg", source: weatherSvg(f.id, now), alt: plain(tip), width: WEATHER_SIZE, height: WEATHER_SIZE })
515 : Text({ key: "svg", color: WEATHER_COLORS[f.id], children: GLYPHS[f.id] });
516 const parts = [icon, Text({ key: "pct", children: pct })];
517 return { key: "weather", tip, rank: 100, cells: (Svg ? Math.ceil(WEATHER_SIZE / 8) : 1) + 1 + pct.length, parts };
518}
519
520// What each recent prompt added, as bars, and the last turn's change beside them; the same room,
521// and a dash for the change, before the first turn is measured.
522function turnsBlock({ Box, Svg, Text }) {
523 const deltas = turnDeltas();
524 const change = readings.length >= 2 ? lastTurn() : "—";
525 const trend = Text({ dimColor: true, children: change });
526 const tip = T.tips.turns(deltas.map((d) => `+${short(d.added)}`).join(" "));
527 const parts = [
528 Svg
529 ? Svg({ key: "svg", source: turnsSvg(deltas), alt: tip, width: SPARK_WIDTH, height: SPARK.height })
530 : Box({ key: "svg", flexShrink: 0, children: textRuns(Text, turnsText(deltas)) }),
531 // As wide as its text: the bars after it take up the difference.
532 Box({ key: "d", flexShrink: 0, children: [trend] }),
533 ];
534 // Counted at its widest where the bars flex, so the line holds still; at its own width in text, where the gauges
535 // take every cell the line leaves.
536 return { key: "turns", tip, rank: 40, cells: (Svg ? Math.ceil(SPARK_WIDTH / 8) + 1 + SPARK.trend : SPARK.bars + 1 + change.length), parts };
537}
538
539// What the context is made of, largest first: a line per category with its share and tokens.
540function contents(categories) {
541 const used = categories.filter((c) => c.kind === "used" && c.tokens > 0).sort((a, b) => b.tokens - a.tokens);
542 const total = used.reduce((sum, c) => sum + c.tokens, 0);
543 const share = (c) => T.pct(Math.round((c.tokens / total) * 100));
544 return used.map((c) => `${share(c)} · ${short(c.tokens)} · ${nameOf(c.name)}`);
545}
546
547// The engine's row label, lower-cased, translated when known.
548function nameOf(name) {
549 const lower = String(name).toLowerCase();
550 return T.categories[lower] ?? lower;
551}
552
553// A limit (gaugeOf's numbers) in words: its label, value and tooltip, around its gauge.
554function gaugeBlock({ Box, Svg, Text }, g, now, rank) {
555 const [label, name] = T.limits[g.kind] ?? [g.kind, g.kind];
556 const value = g.unknown ? "—" : T.pct(Math.round(g.used));
557 // The 5-hour limit is near enough to say at what time it runs out.
558 const when = (ms) => (g.kind === "five_hour" ? `${duration(ms)} (${clockTime(now + ms)})` : duration(ms));
559 const pace = g.projected === null ? null : { end: T.pct(Math.round(g.projected)), hit: g.runsOut === null ? null : when(g.runsOut) };
560 // The reset's time, and its day when it is a day or more away.
561 const at = g.left === null ? "" : (g.left >= 24 * H ? T.weekday(g.reset) + " " : "") + clockTime(g.reset);
562 const left = g.left === null ? "" : duration(g.left);
563 // The lines on the share used, the time elapsed and the pace have the colors they have on the gauge as legends.
564 const legend = { used: TONES[g.tone], elapsed: OTTO_COLOR, pace: TONES[g.tone] + FADED };
565 const tip = g.unknown
566 ? `${name}\n${T.tips.unmeasured}`
567 : T.tips.gauge(name, value, g.elapsed === null ? null : Math.round(g.elapsed), pace, left, at).map((line) => (line.mark ? { text: line.text, color: legend[line.mark], mark: line.mark === "elapsed" } : line));
568 // Only the bar is colored, and the value turns red on alert: the label keeps the theme's color.
569 const valueText = Text({ key: "v", bold: true, children: value, ...(g.tone === "alert" && { color: "red" }) });
570 const labelText = Text({ key: "l", dimColor: true, children: label });
571 // The bar flexes: its box takes the room left, and the drawing fills its box. In text it is drawn at the cells
572 // drawLine gives it.
573 const svgBar = () => Box({ key: "bar", flexGrow: 1, flexShrink: 1, minWidth: 0, children: [Svg({ key: "svg", source: gaugeSvg(g), alt: plain(tip), height: GAUGE.height + 2 * GAUGE.overhang })] });
574 const textBar = (n) => Box({ key: "bar", flexShrink: 0, children: textRuns(Text, gaugeText(g, n)) });
575 const cells = label.length + 1 + (Svg ? Math.ceil(GAUGE.width / 8) : TEXT_GAUGE) + 1 + value.length;
576 // Only the bar gives: its label and value keep their room, on one line.
577 const fixed = (key, text) => Box({ key, flexShrink: 0, children: [text] });
578 const partsAt = (bar) => [fixed("label", labelText), bar, fixed("value", valueText)];
579 return { key: "limit-" + label, tip, rank, cells, grow: Svg ? true : (n) => partsAt(textBar(n)), parts: partsAt(Svg ? svgBar() : textBar(TEXT_GAUGE)) };
580}
581
582// ---------- Otto ----------
583
584// The drawing for what Claude is doing, its tooltip, and for a reaction (`then`) the mood it goes on to once played.
585function moodOf(name, tool, forced) {
586 const choose = (list) => forced ?? pick(list);
587 const play = (look, tip, then, ms = PLAY_MS[look]) => ({ look, tip, then, ms });
588 if (name === "thinking") return play(choose(["thinking", "puzzled", "detective"]), T.moods.thinking);
589 if (name === "working") return play(choose(["laptop", "desktop"]), T.moods.working(tool));
590 if (name === "error") return play(choose(["dejected", "ink", "crow"]), T.moods.error(tool), "working");
591 if (name === "done") return play(choose(["lightbulb", "jump", "dance", "twirl"]), T.moods.done, "idle");
592 if (name === "failed") return play("dejected", T.moods.failed, "idle");
593 // At rest while subagents work: sitting, waiting on them.
594 if (agents.size > 0 && !forced && (name === "idle" || name === "rest")) return play("settle", T.moods.agents(agents.size));
595 // Dizzy when compaction is near.
596 if (forced === "dizzy" || (!forced && forecastNow()?.id === "compact")) return play("dizzy", T.moods.dizzy);
597 // At rest: standing a while, then now and then a quiet animation (rarely a twirl), then standing again.
598 if (name === "rest") return play("idle", T.moods.idle, "idle", PLAY_EVERY);
599 const look = forced ?? (Math.random() < 0.15 ? "twirl" : pick(["lookaround", "bubbles", "wave", "shout", "tap"]));
600 return play(look, T.moods.idle, forced ? undefined : "rest");
601}
602
603// A subagent started or ended: Otto at rest takes up the work, or lays it down, with the count.
604async function agentsChanged($, change) {
605 change();
606 if (mood?.name === "idle" || mood?.name === "rest") await setMood($, "idle");
607}
608
609async function setMood($, name, tool, forced) {
610 moodTimer?.cancel();
611 const now = await $.clock.now();
612 // A mood that holds the band (HOLD_MS) is not cut short: the next one waits, unless asked for by name.
613 const wait = mood && !forced ? (HOLD_MS[mood.look] ?? 0) - (now - mood.at) : 0;
614 if (wait > 0) {
615 moodTimer = $.clock.after(wait, () => setMood($, name, tool));
616 return;
617 }
618 mood = { name, tool, at: now, ...moodOf(name, tool, forced) };
619 if (mood.then) moodTimer = $.clock.after(mood.ms, () => setMood($, mood.then, mood.tool));
620 $.ui.invalidate("ui.render");
621}
622
623// /otto-hud demo: every animation in turn, each with the tooltip it has in a session; or one of them.
624const DEMO = [
625 ["thinking", "", "thinking"],
626 ["thinking", "", "puzzled"],
627 ["thinking", "", "detective"],
628 ["working", "Bash", "laptop"],
629 ["working", "Edit", "desktop"],
630 ["error", "Bash", "dejected"],
631 ["error", "Bash", "ink"],
632 ["error", "Bash", "crow"],
633 ["done", "", "lightbulb"],
634 ["done", "", "jump"],
635 ["done", "", "dance"],
636 ["done", "", "twirl"],
637 ["idle", "", "lookaround"],
638 ["idle", "", "bubbles"],
639 ["idle", "", "wave"],
640 ["idle", "", "shout"],
641 ["idle", "", "tap"],
642 ["idle", "", "settle"],
643 ["idle", "", "dizzy"],
644];
645let demoTimer = null;
646
647async function demo($, i, end) {
648 demoTimer?.cancel();
649 if (i >= end) return setMood($, "idle");
650 const [name, tool, look] = DEMO[i];
651 await setMood($, name, tool, look);
652 moodTimer?.cancel();
653 demoTimer = $.clock.after(Math.max(mood.ms, 2500) + 500, () => demo($, i + 1, end));
654}
655
656// ---------- SVG ----------
657
658// A Meteocons icon at its size, in the weather's tint. Its loops all divide 18 s (the sun's slowed
659// spin), so starting each one where the wall clock says carries the animation on across redraws.
660function weatherSvg(id, now) {
661 const phase = now % 18000;
662 return WEATHER[id]
663 .replace("<svg ", `<svg width="${WEATHER_SIZE}" height="${WEATHER_SIZE}" `)
664 .replaceAll('"black"', `"${WEATHER_COLORS[id]}"`)
665 .replace(/begin="([\d.]+)s"/g, (_, s) => `begin="${Math.round(s * 1000 - phase)}ms"`);
666}
667
668// One rounded bar per prompt in the tint of the forecast it left, the latest on the right, earlier
669// ones faded, each as tall as its share of the largest; a slot no prompt fills yet is a faint stub.
670function turnsSvg(deltas) {
671 const most = Math.max(1, ...deltas.map((d) => d.added));
672 const empty = SPARK.bars - deltas.length;
673 const bars = Array.from({ length: SPARK.bars }, (_, i) => {
674 const d = deltas[i - empty];
675 const h = d === undefined ? 2 : Math.max(1, (d.added / most) * SPARK.height);
676 const fill = d === undefined ? grey(0.2) : WEATHER_COLORS[d.id] + (i === SPARK.bars - 1 ? "" : "80");
677 return `<rect x="${i * (SPARK.bar + SPARK.gap)}" y="${(SPARK.height - h).toFixed(1)}" width="${SPARK.bar}" height="${h.toFixed(1)}" rx="1.5" fill="${fill}"/>`;
678 });
679 return `<svg xmlns="http://www.w3.org/2000/svg" width="${SPARK_WIDTH}" height="${SPARK.height}" viewBox="0 0 ${SPARK_WIDTH} ${SPARK.height}">${bars.join("")}</svg>`;
680}
681
682// A limit: solid up to the share used, faded on up to the share projected by the reset (to the end
683// when the pace runs past the limit), and marked in Otto's blue where the time stands.
684function gaugeSvg(g) {
685 const color = TONES[g.tone];
686 const body = span(0, clamp(g.projected ?? 0), color + FADED) + span(0, clamp(g.used), color);
687 return barSvg(GAUGE.height, body, g.elapsed === null ? [] : [clamp(g.elapsed)]);
688}
689
690// A bar with round ends as wide as its box (no viewBox: positions in %, the ends' radius in px): a
691// grey track and `body` on it, and over it 2 px marks at `marks` %, in Otto's blue, that go past
692// the bar by GAUGE.overhang above and below. Ids carry what tells the drawings apart, in case a
693// page holds several.
694function barSvg(height, body, marks = []) {
695 const ends = `ends${height}`;
696 const over = GAUGE.overhang, full = height + 2 * over;
697 const lines = marks.map((n) => `<rect x="${n.toFixed(1)}%" width="2" height="${full}" rx="1" transform="translate(-1 0)" fill="${OTTO_COLOR}"/>`).join("");
698 return (
699 `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${full}">` +
700 `<svg y="${over}" width="100%" height="${height}"><defs><clipPath id="${ends}"><rect width="100%" height="${height}" rx="${height / 2}"/></clipPath></defs>` +
701 `<g clip-path="url(#${ends})">${span(0, 100, grey(0.2))}${body}</g></svg>${lines}</svg>`
702 );
703}
704
705// A legend in a tooltip: a square as tall as a gauge's bar, or (mark) its time mark, 2 px wide and as tall as the
706// gauge's drawing, both centered in the same box.
707function legendSvg(color, mark) {
708 const { width, height } = LEGEND;
709 const shape = mark
710 ? `<rect x="${(width - 2) / 2}" width="2" height="${height}" rx="1" fill="${color}"/>`
711 : `<rect y="${GAUGE.overhang}" width="${width}" height="${GAUGE.height}" rx="1.5" fill="${color}"/>`;
712 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${shape}</svg>`;
713}
714
715// A full-height slice of a bar, from x0 to x1 %.
716function span(x0, x1, fill) {
717 return x1 > x0 ? `<rect x="${x0.toFixed(1)}%" width="${(x1 - x0).toFixed(1)}%" height="100%" fill="${fill}"/>` : "";
718}
719
720// ---------- Text ----------
721
722// The prompts' bars in eighths of a cell, as turnsSvg draws them: the latest bright, the earlier dim, an empty slot
723// the lowest eighth, dim.
724function turnsText(deltas) {
725 const most = Math.max(1, ...deltas.map((d) => d.added));
726 const empty = SPARK.bars - deltas.length;
727 return Array.from({ length: SPARK.bars }, (_, i) => {
728 const d = deltas[i - empty];
729 if (d === undefined) return [EIGHTHS[0], { dimColor: true }];
730 return [EIGHTHS[Math.round((d.added / most) * (EIGHTHS.length - 1))], { color: WEATHER_COLORS[d.id], dimColor: i < SPARK.bars - 1 }];
731 });
732}
733
734// A limit as gaugeSvg draws it, `cells` wide, in full cells: solid up to the share used, to the eighth of a cell;
735// faded on up to the share projected; the track after. The cell the share used ends in takes, behind its eighths,
736// the color of what follows, so no gap shows there.
737function gaugeText(g, cells) {
738 const color = TONES[g.tone], faded = mix(color, TEXT_TRACK, 0.55);
739 const at = (percent) => (clamp(percent) / 100) * cells;
740 const used = at(g.used), ahead = at(g.projected ?? 0);
741 return Array.from({ length: cells }, (_, i) => {
742 const after = i + 0.5 < ahead ? faded : TEXT_TRACK;
743 const part = Math.round(Math.min(1, Math.max(0, used - i)) * 8);
744 if (part === 8) return ["█", { color }];
745 if (part > 0) return [LEFT_EIGHTHS[part], { color, backgroundColor: after }];
746 return ["█", { color: after }];
747 });
748}
749
750// Between two #rrggbb colors, `t` of the way from a to b.
751function mix(a, b, t) {
752 const channel = (hex, k) => parseInt(hex.slice(1 + 2 * k, 3 + 2 * k), 16);
753 return "#" + [0, 1, 2].map((k) => Math.round(channel(a, k) + (channel(b, k) - channel(a, k)) * t).toString(16).padStart(2, "0")).join("");
754}
755
756// [glyph, props] cells as Texts, one per run of the same props.
757function textRuns(Text, cells) {
758 const runs = [];
759 for (const [glyph, props] of cells) {
760 const last = runs.at(-1);
761 if (last && JSON.stringify(last[1]) === JSON.stringify(props)) last[0] += glyph;
762 else runs.push([glyph, props]);
763 }
764 return runs.map(([children, props], i) => Text({ key: String(i), ...props, children }));
765}
766
767// ---------- Helpers ----------
768
769// 950, 6.3k, 98.3k, 107k, 1M, 1.2M: a decimal only below 100 of the unit.
770function short(n) {
771 const [unit, size] = n >= 1e6 ? ["M", 1e6] : n >= 1e3 ? ["k", 1e3] : ["", 1];
772 const v = n / size;
773 return (size === 1 || v >= 100 ? Math.round(v) : +v.toFixed(1)) + unit;
774}
775
776// Nothing to show: no node, blank text, or boxes and texts that hold only that.
777function isEmpty(node) {
778 if (Array.isArray(node)) return node.every(isEmpty);
779 if (node && typeof node === "object") return (node.type === "Box" || node.type === "Text") && isEmpty(node.props?.children);
780 return !String(node ?? "").trim() || node === false;
781}
782hooks/otto.mjs 811 lines1// Copyright 2026 Manuel Chamorro. All rights reserved: Otto is not under the Apache License, see OTTO-LICENSE.
2// SPDX-License-Identifier: LicenseRef-Otto
3//
4// Otto, a blue octopus, blocky, drawn in vectors and animated with SMIL: a mood is a whole SVG. viewBox 98×70, shown
5// at 49×35 in the band. Every mood starts from the rest pose (idle at its start), so a change of mood never jumps;
6// what plays once (a start) then hands over to loops, or, for the rest moods in ONCE, comes back to the rest pose.
7
8// Otto's colors: his body, his outline, the whites of his eyes, his pupils and mouth, his cheeks, the shine on his
9// head and in his eyes, his suckers. The ink is his cloud's, and the crow's.
10const C = {
11 body: "#4a8fe0",
12 line: "#1f4a8a",
13 eye: "#fff",
14 pupil: "#17233b",
15 cheek: "#ff8fb0",
16 highlight: "#fff",
17 glint: "#fff",
18 sucker: "#8fbcf0",
19 ink: "#1d2433",
20};
21const W = 98, H = 70, X = 40, LEFT = X - 46; // X: the body's center, the anchor of every mood; LEFT just left of the widest pose (desktop's), what flies in aside
22const FLOOR = 62, GROUND = 56, SCALE = 1.2; // the body is drawn in its own units, its GROUND set on the canvas' FLOOR
23
24const loop = `repeatCount="indefinite"`;
25const anim = (attr, values, dur, extra = "") => `<animate attributeName="${attr}" values="${values}" dur="${dur}s" ${loop} ${extra}/>`;
26const turn = (values, dur, extra = "") => `<animateTransform attributeName="transform" type="rotate" values="${values}" dur="${dur}s" ${loop} ${extra}/>`;
27const move = (values, dur, extra = "") => `<animateTransform attributeName="transform" type="translate" values="${values}" dur="${dur}s" ${loop} ${extra}/>`;
28const spline = (n) => `calcMode="spline" keySplines="${Array(n).fill(".45 0 .55 1").join(";")}"`;
29
30// A limb: an outlined stroke; `inner` animates both strokes the same way.
31// A limb of the body's color is an arm: its outline is masked around where it starts, so it joins the body without a
32// border and keeps its outline everywhere else. Give arms coordinates in the head's own space so the mask lines up.
33const ROOTS = new Set(); // the arms' starting points in the drawing being built; svg() writes their masks
34const rootId = (x, y) => `root${x}_${y}`.replace(/\./g, "d").replace(/-/g, "m");
35const limb = (d, inner = "", color = C.body, thick = 1) => {
36 const path = (c, w) => (w *= thick) && `<path d="${d}" fill="none" stroke="${c}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round">${inner}</path>`;
37 if (color !== C.body) return path(C.line, 6.4) + path(color, 4.2);
38 const [, x, y] = d.match(/^M\s*(-?[\d.]+)[ ,](-?[\d.]+)/);
39 ROOTS.add(`${x} ${y}`);
40 return `<g mask="url(#${rootId(x, y)})">${path(C.line, 6.4)}</g>${path(color, 4.2)}`;
41};
42
43// A tentacle drops from its root, folds outward at the tip, and sways around the root.
44function tentacle(x, side, { sway = 6, dur = 2.4, delay = 0, splay = 0 } = {}, [color, width]) {
45 const s = side;
46 const d = `M0 0 V9 Q0 13 ${4 * s} 13 H${7 * s} V9`;
47 return `<g transform="translate(${x} 41) rotate(${(-splay / 3) * s})"><g>${turn(`${-sway};${sway};${-sway}`, dur, `begin="${-delay}s" ${spline(2)}`)}<path d="${d}" fill="none" stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"/></g></g>`;
48}
49// 0, 1 in the middle, behind; 2 to 7 left to right.
50const TENTACLES = [
51 [-4, -1, { delay: 0.3, splay: 8 }],
52 [4, 1, { delay: 0.9, splay: 8 }],
53 [-13, -1, { delay: 0.1, splay: 20 }],
54 [-8, -1, { delay: 0.6, sway: 5, splay: 16 }],
55 [-3, -1, { delay: 1.1, sway: 4, splay: 2 }],
56 [3, 1, { delay: 0.4, sway: 4, splay: 2 }],
57 [8, 1, { delay: 1.4, sway: 5, splay: 16 }],
58 [13, 1, { delay: 0.8, splay: 20 }],
59];
60
61const HEAD = `M${X - 18} 30 Q${X - 18} 19 ${X - 7} 19 H${X + 7} Q${X + 18} 19 ${X + 18} 30 V39 Q${X + 18} 43 ${X + 14} 43 H${X - 14} Q${X - 18} 43 ${X - 18} 39Z`;
62const HIGHLIGHT = `<rect x="${X - 11}" y="22" width="7" height="3" rx="1.5" fill="${C.highlight}" opacity=".35"/>`;
63// Each tentacle outlined, but no line between them and the head: the head's outline goes under them, its fill over their roots.
64// wrapHead moves the head alone (its outline and its fill), wrapLegs the tentacles, wrapLeg one of them by its index.
65const body = (skip = [], { wrapHead = (s) => s, wrapLegs = (s) => s, wrapLeg = (i, s) => s } = {}) => {
66 const legs = TENTACLES.map(([x, s, o], i) => (skip.includes(i) ? "" : wrapLeg(i, tentacle(X + x, s, o, [C.line, 6.4]) + tentacle(X + x, s, o, [C.body, 4.2])))).join("");
67 return wrapHead(`<path d="${HEAD}" fill="${C.line}" stroke="${C.line}" stroke-width="2.2"/>`) + wrapLegs(legs) + wrapHead(`<path d="${HEAD}" fill="${C.body}"/>${HIGHLIGHT}`);
68};
69// Animations that scale around the ground point.
70const onGround = (anims, inner) => `<g transform="translate(${X} ${GROUND})"><g>${anims}<g transform="translate(${-X} ${-GROUND})">${inner}</g></g></g>`;
71
72const EYE = 1.2; // the eyes' size, against how they are drawn below
73// Eyes blink every few seconds. look shifts the pupils, roam animates them; closed is "happy", "sad" or "flat";
74// droop lowers sad eyelids over them, lower at their outer corners.
75function eyes({ look = [0, 0], roam = "", closed = "", spiral = false, droop = false } = {}) {
76 const one = (x, side) => {
77 if (closed) {
78 const d = { happy: `M${x - 3} 31.5 l3 -3 l3 3`, sad: `M${x - 3} 29.5 q3 2.5 6 0`, flat: `M${x - 3} 30 h6` }[closed];
79 return `<g transform="translate(${x} 30) scale(${EYE}) translate(${-x} -30)"><path d="${d}" fill="none" stroke="${C.pupil}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g>`;
80 }
81 const pupil = spiral
82 ? `<g transform="translate(${x} 30)"><path d="M0 0 m-.6 0 a.6 .6 0 1 1 1.2 0 a1.4 1.4 0 1 1 -2.4 .3 a2.2 2.2 0 1 1 3.4 -.8" fill="none" stroke="${C.pupil}" stroke-width=".9">${turn("0;360", 1.2)}</path></g>`
83 : `<g>${roam}<rect x="${x + look[0] - 1.7}" y="${28 + look[1]}" width="3.4" height="4" rx=".6" fill="${C.pupil}"/><rect x="${x + look[0]}" y="${28.5 + look[1]}" width="1" height="1" fill="${C.glint}"/></g>`;
84 const blink = spiral ? "" : `<animateTransform attributeName="transform" type="scale" values="1 1;1 1;1 .1;1 1" keyTimes="0;.93;.96;1" dur="4.5s" ${loop}/>`;
85 const [outer, inner] = [x + 3.7 * side, x - 3.7 * side];
86 const lid = droop
87 ? `<path d="M${outer} 25.3 L${inner} 25.3 L${inner} 29.2 L${outer} 31Z" fill="${C.body}"/><path d="M${inner} 29.2 L${outer} 31" stroke="${C.pupil}" stroke-width="1.3" stroke-linecap="round"/>`
88 : "";
89 return `<g transform="translate(${x} 30) scale(${EYE})"><g>${blink}<g transform="translate(${-x} -30)"><rect x="${x - 3.3}" y="26" width="6.6" height="8" rx="1.6" fill="${C.eye}"/>${pupil}${lid}</g></g></g>`;
90 };
91 return one(X - 6, -1) + one(X + 6, 1);
92}
93
94const cheeks = `<rect x="${X - 16}" y="33.8" width="6.5" height="3" rx="1.5" fill="${C.cheek}" opacity=".85"/><rect x="${X + 9.5}" y="33.8" width="6.5" height="3" rx="1.5" fill="${C.cheek}" opacity=".85"/>`;
95const stroke = (d) => `<path d="${d}" fill="none" stroke="${C.pupil}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`;
96const MOUTH = {
97 smile: stroke(`M${X - 3} 35.3 q3 2.6 6 0`),
98 flat: stroke(`M${X - 2.3} 36.2 h4.6`),
99 frown: stroke(`M${X - 3.5} 37.6 q3.5 -2.6 7 0`),
100 wavy: stroke(`M${X - 4} 36.2 q2 -1.7 4 0 q2 1.7 4 0`),
101 o: `<rect x="${X - 1.8}" y="34.6" width="3.6" height="4" rx="1.7" fill="${C.pupil}"/>`,
102 grin: `<path d="M${X - 4} 34.8 h8 q0 4.4 -4 4.4 q-4 0 -4 -4.4Z" fill="${C.pupil}"/>`,
103 shout: `<ellipse cx="${X}" cy="37.4" rx="2.6" ry="3" fill="${C.pupil}"/>`,
104 pout: stroke(`M${X - 2.8} 37.6 q1.4 -2.4 2.8 -1 q1.4 -1.4 2.8 1`),
105};
106const face = (eyeOptions = {}, mouth = "smile") => eyes(eyeOptions) + cheeks + MOUTH[mouth];
107
108// The body, grown around the ground; bob makes it float.
109const grow = (inner) => `<g transform="translate(${X} ${FLOOR}) scale(${SCALE}) translate(${-X} ${-GROUND})">${inner}</g>`;
110const bob = (inner, amp = 1.5, dur = 3) => `<g>${move(`0 0;0 ${-amp};0 0`, dur, spline(2))}${grow(inner)}</g>`;
111// A point of the body, in the canvas' units.
112const at = (x, y) => [X + (x - X) * SCALE, FLOOR + (y - GROUND) * SCALE];
113
114const SHADOW = 1.3; // the shadow's width, against the rx values the moods give
115const shadowOf = (rx = "22;19;22", dur = 3, extra = spline(2)) =>
116 `<ellipse cx="${X}" cy="65" rx="${22 * SHADOW}" ry="3.2" fill="#000" opacity=".18">${anim("rx", rx.split(";").map((r) => +r * SHADOW).join(";"), dur, extra)}</ellipse>`;
117// Around where each arm leaves the body, its outline stays hidden.
118const rootMasks = () => {
119 const masks = [...ROOTS].map((p) => {
120 const [x, y] = p.split(" ");
121 return `<mask id="${rootId(x, y)}" maskUnits="userSpaceOnUse" x="-60" y="-60" width="220" height="200"><rect x="-60" y="-60" width="220" height="200" fill="#fff"/><circle cx="${x}" cy="${y}" r="4" fill="#000"/></mask>`;
122 });
123 ROOTS.clear();
124 return masks.length ? `<defs>${masks.join("")}</defs>` : "";
125};
126const svg = (inner, shadow = shadowOf()) =>
127 `<svg xmlns="http://www.w3.org/2000/svg" width="${W / 2}" height="${H / 2}" viewBox="${LEFT} 0 ${W} ${H}">${rootMasks()}${shadow}${inner}</svg>`;
128
129// Every mood starts from the rest pose, as idle stands at its start, and gets into its own within T0, so that a
130// change of mood never jumps: faces cross-fade, arms rise from where the tentacle hung, props pop or fade in, and
131// the mood's loops start at T0.
132const T0 = 0.35;
133const fade = (at, on, dur = 0.2) => `<animate attributeName="opacity" values="${on ? "0;1" : "1;0"}" dur="${dur}s" begin="${at}s" fill="freeze"/>`;
134const faceIn = (options = {}, mouth = "smile", at = 0.05) => `<g>${fade(at, false)}${face()}</g><g opacity="0">${fade(at, true)}${face(options, mouth)}</g>`;
135// The same for a mood that plays once: the rest face comes back at `end`.
136const faceFor = (options, mouth, end) =>
137 `<g>${fade(0.05, false)}${fade(end, true)}${face()}</g><g opacity="0">${fade(0.05, true)}${fade(end, false)}${face(options, mouth)}</g>`;
138const fadeIn = (inner, at = T0) => `<g opacity="0">${fade(at, true)}${inner}</g>`;
139const popIn = ([cx, cy], inner, at = T0) =>
140 `<g transform="translate(${cx} ${cy})"><g transform="scale(0)"><animateTransform attributeName="transform" type="scale" values="0;1.12;1" keyTimes="0;.7;1" dur=".3s" begin="${at}s" fill="freeze" calcMode="spline" keySplines=".4 0 .6 1;.4 0 .6 1"/><g transform="translate(${-cx} ${-cy})">${inner}</g></g></g>`;
141// A tentacle hanging at rest, as an arm's path (M Q Q) from its root at x, on her left (-1) or right (1).
142const restArm = (x, side) => `M${x} 41 Q${x} 48 ${x + 2 * side} 51 Q${x + 4 * side} 53.5 ${x + 7 * side} 52`;
143const raise = (from, to, at = 0.05) => `<animate attributeName="d" values="${from};${to}" dur="${T0 - at}s" begin="${at}s" fill="freeze" calcMode="spline" keySplines=".4 0 .6 1"/>`;
144// An arm goes back down to where it hung, from `at`.
145const lower = (from, to, at) => `<animate attributeName="d" values="${from};${to}" dur="${T0}s" begin="${at}s" fill="freeze" calcMode="spline" keySplines=".4 0 .6 1"/>`;
146const later = (extra = "", at = T0) => `begin="${at}s" ${extra}`;
147
148// Props.
149// A thought bubble that floats gently up and down, its small bubbles a beat behind; it sits at the top of the picture,
150// so it floats down from there and back.
151const bubbleCloud = (dots) => {
152 const float = (b) => move("0 0;0 4;0 0", 2.6, `begin="${-b}s" ${spline(2)}`);
153 const small = (cx, cy, r, b, at) => popIn([cx, cy], `<g>${float(b)}<circle cx="${cx}" cy="${cy}" r="${r}"/></g>`, at);
154 return `<g fill="#fff" stroke="${C.line}" stroke-width="1.4">${small(62.5, 21, 2.2, 0.6, T0)}${small(66.5, 15.5, 3, 0.3, T0 + 0.08)}</g>` +
155 popIn([78, 9.2], `<g>${float(0)}<ellipse cx="78" cy="9.2" rx="13.5" ry="8.3" fill="#fff" stroke="${C.line}" stroke-width="1.4"/>${dots}</g>`, T0 + 0.16);
156};
157const star = (fill = "#ffd166") => `<path d="M0 -2.6 L.8 -.8 2.6 0 .8 .8 0 2.6 -.8 .8 -2.6 0 -.8 -.8Z" fill="${fill}" stroke="${C.line}" stroke-width=".5"/>`;
158const note = `${limb("M0 0 v-6 l4 -1.5 v6", "", "#ffd166").replace(/6\.4/, "3.4").replace(/4\.2/, "1.4")}<rect x="-2.6" y="-1.6" width="3.4" height="2.8" rx="1.2" fill="#ffd166" stroke="${C.line}" stroke-width=".8"/><rect x="1.4" y="-3.1" width="3.4" height="2.8" rx="1.2" fill="#ffd166" stroke="${C.line}" stroke-width=".8"/>`;
159
160// Sinking onto the ground, as in settle and dejected: the head comes down, the tentacles flatten and spread, then she
161// breathes heavily.
162const sunk = () => {
163 const drop = 6.5, keys = `calcMode="spline" keySplines=".4 0 .6 1"`;
164 const headMove =
165 `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${drop}" dur=".9s" begin=".1s" fill="freeze" ${keys}/>` +
166 `<animateTransform attributeName="transform" type="translate" values="0 ${drop};0 ${drop - 1};0 ${drop}" dur="3.2s" begin="1s" ${loop} ${spline(2)}/>`;
167 const squash =
168 `<animateTransform attributeName="transform" type="scale" values="1 1;1.14 .56" dur=".9s" begin=".1s" fill="freeze" ${keys}/>` +
169 `<animateTransform attributeName="transform" type="scale" values="1.14 .56;1.12 .62;1.14 .56" dur="3.2s" begin="1s" ${loop} ${spline(2)}/>`;
170 return {
171 wrapHead: (s) => `<g>${headMove}${s}</g>`,
172 wrapLegs: (s) => onGround(squash, s),
173 shadow: `<ellipse cx="${X}" cy="65" rx="${22 * SHADOW}" ry="3.2" fill="#000" opacity=".18"><animate attributeName="rx" values="${22 * SHADOW};${26 * SHADOW}" dur=".9s" begin=".1s" fill="freeze"/></ellipse>`,
174 };
175};
176
177// How long the moods that play once at rest last, in seconds, from the rest pose back to it.
178const ONCE = { wave: 5, shout: 3.6, tap: 5 };
179
180export const MOODS = {
181 // At rest.
182 idle: svg(bob(body() + face())),
183 lookaround: svg(
184 bob(
185 `<g>${turn(`0 ${X} 50;-3 ${X} 50;-3 ${X} 50;3 ${X} 50;3 ${X} 50;0 ${X} 50`, 5, later(`keyTimes="0;.1;.4;.5;.85;1"`))}` +
186 body() +
187 faceIn({ roam: move("0 0;-1.4 0;-1.4 0;1.4 0;1.4 0;0 0", 5, later(`keyTimes="0;.1;.4;.5;.85;1"`)) }, "flat") +
188 "</g>",
189 ),
190 ),
191 bubbles: svg(
192 bob(body() + faceIn({ look: [0.6, -0.6] }, "o")) +
193 [[3.2, 0], [4.4, 0.9], [2.6, 1.8]]
194 .map(([r, b]) => {
195 const [x, y] = at(X + 2, 35);
196 return `<circle cx="${x}" cy="${y}" r="${r}" fill="#dff1ff" stroke="${C.line}" stroke-width="1" opacity="0">${move("0 0;14 2;26 -26", 2.7, `begin="${b + T0}s" ${spline(2)}`)}${anim("opacity", "0;1;1;0", 2.7, `keyTimes="0;.15;.8;1" begin="${b + T0}s"`)}</circle>`;
197 })
198 .join(""),
199 ),
200 // She waves from afar: a tentacle rises high over her head and swings from side to side, her eyes smiling, five
201 // times; then it comes back down. Plays once.
202 wave: (() => {
203 const WAVES = 5, END = ONCE.wave - T0, WAVE = (END - T0) / WAVES;
204 const rest = restArm(X + 13, 1);
205 const a = `M${X + 13} 41 Q${X + 25} 40 ${X + 24} 27 Q${X + 23} 16 ${X + 17} 9`;
206 const b = `M${X + 13} 41 Q${X + 25} 40 ${X + 25} 27 Q${X + 25} 16 ${X + 31} 11`;
207 const swings = `<animate attributeName="d" values="${a};${b};${a}" dur="${WAVE.toFixed(3)}s" begin="${T0}s" repeatCount="${WAVES}" ${spline(2)}/>`;
208 // Her body goes with the tentacle: it leans away as the tentacle rises, rocks out with each swing and bounces
209 // at either end of it, then stands up again as the tentacle comes down.
210 const tilt = (values, dur, begin, extra) => `<animateTransform attributeName="transform" type="rotate" values="${values.map((v) => `${v} ${X} ${FLOOR}`).join(";")}" dur="${dur}s" begin="${begin}s" ${extra}/>`;
211 const ease = `calcMode="spline" keySplines=".4 0 .6 1"`;
212 const rock =
213 tilt([0, -4], T0 - 0.05, 0.05, `fill="freeze" ${ease}`) +
214 tilt([-4, 6, -4], WAVE.toFixed(3), T0, `repeatCount="${WAVES}" ${spline(2)}`) +
215 tilt([-4, 0], T0, END, `fill="freeze" ${ease}`);
216 const bounce = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -1.6;0 0" dur="${(WAVE / 2).toFixed(3)}s" begin="${T0}s" repeatCount="${WAVES * 2}" ${spline(2)}/>`;
217 return svg(`<g>${rock}<g>${bounce}${bob(body([7]) + faceFor({ closed: "happy" }, "grin", END + 0.1) + limb(rest, raise(rest, a) + swings + lower(a, rest, END)))}</g></g>`);
218 })(),
219 // She calls out: two tentacles cup her mouth like a megaphone, she bends forward and leans into each call, and waves of sound go out
220 // on both sides, twice; then her tentacles come back down. Plays once.
221 shout: (() => {
222 const CALLS = 2, END = ONCE.shout - T0, CALL = (END - T0) / CALLS; // a call, then its echo dies down
223 const cup = (side) => {
224 const r = X + 13 * side, rest = restArm(r, side);
225 const up = `M${r} 41 Q${X + 12 * side} 45 ${X + 9.5 * side} 42 Q${X + 7.5 * side} 39.5 ${X + 6.4 * side} 37`;
226 return limb(rest, raise(rest, up) + lower(up, rest, END));
227 };
228 const [mx, my] = at(X, 36.5);
229 const waves = [-1, 1]
230 .map((side) =>
231 [0, 0.15, 0.3]
232 .map((b) => {
233 const x = mx + 25 * side;
234 return `<path d="M${x} ${my - 4.5} q${2.6 * side} 4.5 0 9" fill="none" stroke="${C.line}" stroke-width="1.6" stroke-linecap="round" opacity="0">${move(`0 0;${9 * side} 0;${9 * side} 0`, CALL, later(`keyTimes="0;.5;1"`, T0 + b))}${anim("opacity", "0;1;0;0", CALL, later(`keyTimes="0;.1;.5;1"`, T0 + b))}</path>`;
235 })
236 .join(""),
237 )
238 .join("")
239 .replaceAll(loop, `repeatCount="${CALLS}"`);
240 // She bends forward to call, for the whole of it: her head comes down and nearer, her tentacles give.
241 const ease = `calcMode="spline" keySplines=".4 0 .6 1"`;
242 const bend = (type, from, to) =>
243 `<animateTransform attributeName="transform" type="${type}" values="${from};${to}" dur="${T0 - 0.05}s" begin=".05s" fill="freeze" ${ease}/>` +
244 `<animateTransform attributeName="transform" type="${type}" values="${to};${from}" dur="${T0}s" begin="${END}s" fill="freeze" ${ease}/>`;
245 const wrapHead = (s) => `<g>${bend("translate", "0 0", "0 2.5")}<g transform="translate(${X} 43)"><g>${bend("scale", "1", "1.08")}<g transform="translate(${-X} -43)">${s}</g></g></g></g>`;
246 const wrapLegs = (s) => onGround(bend("scale", "1 1", "1.06 .86"), s);
247 const lean = `<animateTransform attributeName="transform" type="scale" values="1;1.05;1;1" keyTimes="0;.12;.5;1" dur="${CALL}s" ${later(`repeatCount="${CALLS}" calcMode="spline" keySplines=".3 0 .5 1;.45 0 .55 1;0 0 1 1"`)}/>`;
248 return svg(
249 `<g transform="translate(${X} ${FLOOR})"><g>${lean}<g transform="translate(${-X} ${-FLOOR})">` +
250 grow(body([2, 7], { wrapHead, wrapLegs }) + wrapHead(faceFor({ closed: "happy" }, "shout", END + 0.1) + cup(-1) + cup(1))) +
251 `</g></g></g>` +
252 waves,
253 shadowOf("22;22", 1),
254 );
255 })(),
256 // She knocks at the window: she walks up to it on her tentacles as it comes in from beyond the picture onto its
257 // edges, leans on its sill with two tentacles and, looking at us, her head tilted, knocks on the glass with the side of a third, from
258 // its bend to its tip, flattened against it at each knock, three times two knocks; then she walks back to her rest pose and the window goes.
259 // Plays once.
260 tap: (() => {
261 const D = ONCE.tap, NEAR = 1.25; // how much bigger she looks up at the window
262 // An animation over the whole play from its [time, value] steps, eased between them.
263 const track = (attr, steps, type) => {
264 const k = `values="${steps.map(([, v]) => v).join(";")}" keyTimes="${steps.map(([t]) => +(t / D).toFixed(3)).join(";")}" dur="${D}s" begin="0s" fill="freeze" calcMode="spline" keySplines="${Array(steps.length - 1).fill(".4 0 .6 1").join(";")}"`;
265 return type ? `<animateTransform attributeName="transform" type="${type}" ${k}/>` : `<animate attributeName="${attr}" ${k}/>`;
266 };
267 const WALK = 0.95, STEPS = 4, STEP = WALK / STEPS, IN = 0.05 + WALK, OUT = D - 0.05 - WALK, PRESS = 0.1;
268 const KNOCKS = [1.45, 1.75, 2.45, 2.75, 3.45, 3.75];
269 // From `from` to `to` between a and b, back between c and d.
270 const stay = (from, to, [a, b] = [0.05, IN], [c, d] = [OUT, D - 0.05]) => [[0, from], [a, from], [b, to], [c, to], [d, from], [D, from]];
271 const knocks = (rest, pressed) => [[0, rest], ...KNOCKS.flatMap((t) => [[t - PRESS, rest], [t, pressed], [t + PRESS, rest]]), [D, rest]];
272 // Walking, there and back: each step lifts every other tentacle (`parity`), or every step, her whole body.
273 const walk = (rest, lifted, parity) => [
274 [0, rest],
275 ...[0.05, OUT].flatMap((w) =>
276 Array.from({ length: STEPS }, (_, k) => k).filter((k) => parity === undefined || k % 2 === parity)
277 .flatMap((k) => [[w + k * STEP, rest], [w + (k + 0.5) * STEP, lifted], ...(parity === undefined && k < STEPS - 1 ? [] : [[w + (k + 1) * STEP, rest]])]),
278 ),
279 [D, rest],
280 ];
281 const wrapLeg = (i, s) => `<g>${track("", walk("0 0", "0 -2.6", [0, 3, 5].includes(i) ? 0 : 1), "translate")}${s}</g>`;
282 // The window, around the picture's edges: a wooden frame, a sill along the bottom (its top seen from above, then
283 // its front), two glints on the glass.
284 const [L, T, R, B] = [LEFT, 0, LEFT + W, H], F = 3.5, SILL = 63, LIP = 65.5;
285 const WOOD = "#c49a5a", DARK = "#a87a40", line = `stroke="${C.line}" stroke-width="1.2" stroke-linejoin="round"`;
286 const sillTop = `<path d="M${L + F} ${SILL} H${R - F} L${R} ${LIP} H${L}Z" fill="${WOOD}" ${line}/>`;
287 const frame =
288 `<path d="M${L} ${T} H${R} V${LIP} H${L}Z M${L + F} ${T + F} V${SILL} H${R - F} V${T + F}Z" fill="${DARK}" fill-rule="evenodd" ${line}/>` +
289 `<rect x="${L}" y="${LIP}" width="${W}" height="${B - LIP}" fill="${DARK}" ${line}/>` +
290 `<path d="M${R - 16} ${T + 7} l-7 7 M${R - 11} ${T + 8} l-4 4" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity=".7"/>`;
291 const comes = track("", stay("2", "1"), "scale");
292 const [cx, cy] = [L + W / 2, H / 2];
293 const window = (inner) => `<g transform="translate(${cx} ${cy})"><g>${comes}<g transform="translate(${-cx} ${-cy})">${inner}</g></g></g>`;
294 // Her arms, once she is there: two from hanging to lying along the sill, over it; one up to the glass, bent.
295 const ARMS = [[IN - 0.25, IN + 0.1], [OUT, OUT + 0.4]];
296 const lean = (x, s) => `M${x} 41 Q${x + 1 * s} 53 ${x + 4 * s} 56.6 Q${x + 6 * s} 58.4 ${x + 12 * s} 58.4`;
297 const leans = [-1, 1].map((s) => limb(restArm(X + 13 * s, s), track("d", stay(restArm(X + 13 * s, s), lean(X + 13 * s, s), ...ARMS)))).join("");
298 const knock = limb(restArm(X + 8, 1), track("d", stay(restArm(X + 8, 1), `M${X + 8} 41 Q${X + 20} 46 ${X + 19.5} 40 Q${X + 19.5} 35 ${X + 19.5} 29.5`, ...ARMS)));
299 // Its side, from the bend to the tip, flattened on the glass: it shows once the arm is up and spreads at each
300 // knock, its suckers against the glass, short lines going out from it.
301 const [px, py] = [X + 19.5, 35];
302 const side =
303 `<g opacity="0">${track("opacity", [[0, 0], [ARMS[0][1] - 0.05, 0], [ARMS[0][1], 1], [OUT, 1], [OUT + 0.05, 0], [D, 0]])}<g transform="translate(${px} ${py})">` +
304 `<g>${track("", knocks("1 1", "1.4 1.15"), "scale")}<rect x="-2.9" y="-9.1" width="5.8" height="15.5" rx="2.9" fill="${C.body}" stroke="${C.line}" stroke-width="1.1"/>` +
305 [-6.7, -4, -1.3, 1.4, 4.1].map((y) => `<circle cy="${y}" r=".95" fill="${C.sucker}" stroke="${C.line}" stroke-width=".5"/>`).join("") + `</g>` +
306 `<g opacity="0">${track("opacity", knocks(0, 1))}<path d="M6 -5.5 l2.4 -1.4 M6.6 0 h2.8 M6 5.5 l2.4 1.4" stroke="${C.line}" stroke-width="1.1" stroke-linecap="round"/></g></g></g>`;
307 // Her head tilts a little to her own right (our left) as she knocks, and straightens a little at each knock; the
308 // knocking tentacle stays where it is.
309 const tilt = track("", stay(`0 ${X} 43`, `-8 ${X} 43`, ...ARMS), "rotate");
310 const nod = track("", knocks(`0 ${X} 43`, `1.75 ${X} 43`), "rotate");
311 const wrapHead = (s) => `<g>${tilt}<g>${nod}${s}</g></g>`;
312 const near = track("", stay("1", `${NEAR}`), "scale");
313 const steps = track("", walk("0 0", "0 -1.2"), "translate");
314 const her = (inner) => `<g>${steps}<g transform="translate(${X} ${FLOOR})"><g>${near}<g transform="translate(${-X} ${-FLOOR})">${grow(inner)}</g></g></g></g>`;
315 return svg(window(sillTop) + her(body([2, 7], { wrapHead, wrapLeg }) + wrapHead(faceFor({}, "o", OUT + 0.2)) + knock + side) + window(frame) + her(leans));
316 })(),
317
318 // She settles on the ground: the tentacles spread and flatten, the head comes down, the eyes close; then she breathes.
319 settle: (() => {
320 const drop = 6.5, keys = `calcMode="spline" keySplines=".4 0 .6 1"`;
321 const headMove =
322 `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${drop}" dur=".9s" begin=".3s" fill="freeze" ${keys}/>` +
323 `<animateTransform attributeName="transform" type="translate" values="0 ${drop};0 ${drop - 0.8};0 ${drop}" dur="3.2s" begin="1.2s" ${loop} ${spline(2)}/>`;
324 const squash =
325 `<animateTransform attributeName="transform" type="scale" values="1 1;1.14 .56" dur=".9s" begin=".3s" fill="freeze" ${keys}/>` +
326 `<animateTransform attributeName="transform" type="scale" values="1.14 .56;1.12 .61;1.14 .56" dur="3.2s" begin="1.2s" ${loop} ${spline(2)}/>`;
327 const swap = (on) => fade(1.4, on, 0.25);
328 return svg(
329 grow(
330 body([], { wrapHead: (s) => `<g>${headMove}${s}</g>`, wrapLegs: (s) => onGround(squash, s) }) +
331 `<g>${headMove}<g>${swap(false)}${face()}</g><g opacity="0">${swap(true)}${face({ closed: "sad" })}</g></g>`,
332 ),
333 `<ellipse cx="${X}" cy="65" rx="${22 * SHADOW}" ry="3.2" fill="#000" opacity=".18"><animate attributeName="rx" values="${22 * SHADOW};${26 * SHADOW}" dur=".9s" begin=".3s" fill="freeze"/></ellipse>`,
334 );
335 })(),
336
337 // She rises a little and twirls three times on herself, her tentacles flung out, then comes back down and rests.
338 // Seen from the front, a turn narrows her to a line and widens her again; her back, without a face, shows for the
339 // other half. Plays once.
340 twirl: (() => {
341 const UP = 0.5, DOWN = 2.7, LAND = 3.2, D = LAND, TURNS = 3, STEP = 0.04;
342 const show = (at, on = true) => `<set attributeName="opacity" to="${on ? 1 : 0}" begin="${at}s" fill="freeze"/>`;
343 const smooth = (u) => u * u * (3 - 2 * u);
344 const clamp = (u) => Math.min(1, Math.max(0, u));
345 const turn = (t) => TURNS * smooth(clamp((t - UP) / (DOWN - UP)));
346 const lift = (t) => smooth(clamp(t / UP)) - smooth(clamp((t - DOWN) / (LAND - DOWN)));
347 const times = Array.from({ length: Math.round(D / STEP) + 1 }, (_, i) => +(i * STEP).toFixed(2));
348 const keys = `keyTimes="${times.map((t) => +(t / D).toFixed(4)).join(";")}"`;
349 const track = (fn) => times.map((t) => fn(t)).join(";");
350 const width = (t) => Math.cos(2 * Math.PI * turn(t)).toFixed(3);
351 const facing = (front) => `<animate attributeName="opacity" values="${track((t) => (Math.cos(2 * Math.PI * turn(t)) >= 0) === front ? 1 : 0)}" ${keys} calcMode="discrete" dur="${D}s" fill="freeze"/>`;
352 const fling = (t) => { const f = lift(t); return `${(1 + 0.18 * f).toFixed(3)} ${(1 - 0.1 * f).toFixed(3)}`; };
353 const spinning = `<animateTransform attributeName="transform" type="scale" values="${track((t) => `${width(t)} 1`)}" ${keys} dur="${D}s" fill="freeze"/>`;
354 const wrapLegs = (s) => onGround(`<animateTransform attributeName="transform" type="scale" values="${track(fling)}" ${keys} dur="${D}s" fill="freeze"/>`, s);
355 return svg(
356 `<g>${`<animateTransform attributeName="transform" type="translate" values="${track((t) => `0 ${(-7 * lift(t)).toFixed(2)}`)}" ${keys} dur="${D}s" fill="freeze"/>`}` +
357 `<g transform="translate(${X} 0)"><g>${spinning}<g transform="translate(${-X} 0)">` +
358 grow(`<g>${facing(true)}${body([], { wrapLegs })}<g>${show(LAND, false)}${faceIn({ closed: "happy" }, "smile", 0.1)}</g><g opacity="0">${show(LAND)}${face()}</g></g><g opacity="0">${facing(false)}${body([], { wrapLegs })}</g>`) +
359 `</g></g></g></g>`,
360 `<ellipse cx="${X}" cy="65" rx="${22 * SHADOW}" ry="3.2" fill="#000" opacity=".18"><animate attributeName="rx" values="${track((t) => (22 * SHADOW * (1 - 0.3 * lift(t))).toFixed(1))}" ${keys} dur="${D}s" fill="freeze"/></ellipse>`,
361 );
362 })(),
363
364 // Thinking.
365 thinking: svg(
366 bob(
367 body([2]) +
368 faceIn({ look: [0.9, -1.1] }, "flat") +
369 (() => {
370 // A tentacle rises from where it hung, goes out on her left and scratches the side of her head: three quick
371 // strokes, a pause.
372 const rest = restArm(X - 13, -1);
373 const a = `M${X - 13} 41 Q${X - 26} 44 ${X - 26} 33 Q${X - 26} 22 ${X - 18.5} 23`;
374 const b = `M${X - 13} 41 Q${X - 26} 44 ${X - 26} 36 Q${X - 26} 30 ${X - 18.5} 30.5`;
375 return limb(rest, raise(rest, a) + anim("d", [a, b, a, b, a, b, a, a].join(";"), 2, later(`keyTimes="0;.09;.18;.27;.36;.45;.54;1" calcMode="spline" keySplines="${Array(7).fill(".45 0 .55 1").join(";")}"`)));
376 })(),
377 1,
378 4,
379 ) +
380 bubbleCloud(
381 `<g fill="${C.line}">${[71, 78, 85].map((x, i) => `<rect x="${x - 2.1}" y="7.1" width="4.2" height="4.2" rx="1.1" opacity=".25">${anim("opacity", ".25;1;.25;.25", 1.5, `keyTimes="0;.2;.45;1" begin="${i * 0.25}s"`)}</rect>`).join("")}</g>`,
382 ),
383 ),
384 // She scratches her head on her left; the question mark on her right.
385 puzzled: svg(
386 bob(
387 body([2]) +
388 faceIn({ look: [1, -1.2] }, "wavy") +
389 (() => {
390 const rest = restArm(X - 14, -1);
391 const up = `M${X - 14} 41 Q${X - 25} 42 ${X - 25} 33 Q${X - 25} 24 ${X - 21} 24`;
392 return limb(rest, raise(rest, up) + turn([0, 5, 0, 5, 0, 0].map((a) => `${a} ${X - 14} 41`).join(";"), 1.8, later(`keyTimes="0;.12;.24;.36;.48;1"`)));
393 })(),
394 1,
395 4,
396 ) +
397 popIn(
398 [77, 18.5],
399 `<g transform="translate(77 18.5) scale(1.35)"><g>${turn("-20;20;-20", 1.8, spline(2))}${limb("M-4 -4 q0 -5 4.5 -5 q4.5 0 4.5 4.2 q0 3 -4 4.5 v2.5", "", "#ffd166")}<rect x="-2" y="5" width="4" height="4" rx="1.2" fill="#ffd166" stroke="${C.line}" stroke-width="1"/></g></g>`,
400 ),
401 ),
402
403 // A tentacle goes behind her back and comes out over her head with a detective's cap, which it puts on her; then
404 // again with a magnifier, which it brings to her eye as she bends over the ground, her eye huge in the lens. Both
405 // grow out of her back, hidden until they rise over her head. The start plays once, then she sways as she searches.
406 detective: (() => {
407 const ease = (n) => `calcMode="spline" keySplines="${Array(n).fill(".4 0 .6 1").join(";")}"`;
408 const once = (type, values, dur, begin) =>
409 `<animateTransform attributeName="transform" type="${type}" values="${values}" dur="${dur}s" begin="${begin}s" fill="freeze" ${ease(values.split(";").length - 1)}/>`;
410 const show = (at, on = true) => `<set attributeName="opacity" to="${on ? 1 : 0}" begin="${at}s" fill="freeze"/>`;
411 // A cap turned a little to her right: a low crown, the visor over her forehead.
412 const cap = `<path d="M${X - 15} 24 Q${X - 15} 14 ${X} 14 Q${X + 15} 14 ${X + 15} 24 Q${X} 21.5 ${X - 15} 24Z" fill="#c49a5a" stroke="${C.line}" stroke-width="1"/>
413 <path d="M${X - 2} 14.2 Q${X - 3} 18 ${X - 2} 22.8 M${X + 8} 15 Q${X + 9} 19 ${X + 8} 22.8 M${X - 14.6} 19 Q${X} 17 ${X + 14.6} 19" fill="none" stroke="#8a6436" stroke-width=".8"/>
414 <path d="M${X - 3} 23.2 Q${X + 10} 21.2 ${X + 20} 23 Q${X + 24} 24.2 ${X + 22} 25.6 Q${X + 10} 26.8 ${X - 3} 25.2Z" fill="#a87a40" stroke="${C.line}" stroke-width="1" stroke-linejoin="round"/>
415 <rect x="${X + 1}" y="12.6" width="2.8" height="2.4" rx="1" fill="#a87a40" stroke="${C.line}" stroke-width=".8"/>`;
416 // The magnifier, held at 0 0, GRIP from the lens' center, the rest of its handle (to HANDLE) showing below the grip.
417 const RADIUS = 11, GRIP = 20, HANDLE = 26, K = RADIUS / 7, u = [Math.cos((62 * Math.PI) / 180), Math.sin((62 * Math.PI) / 180)];
418 const along = (k) => `${((k - GRIP) * u[0]).toFixed(2)} ${((k - GRIP) * u[1]).toFixed(2)}`; // k from the lens' center
419 const [lx, ly] = [-GRIP * u[0], -GRIP * u[1]].map((n) => +n.toFixed(2));
420 const T = { behind: 0.3, cap: 0.95, loupe: 2.25, look: 2.75, search: 2.9 }; // the tentacle goes behind her, the cap comes forward, the magnifier, the lens reaches her eye, the search loops
421 const loupe = (front) =>
422 `<path d="M${along(HANDLE)} L${along(RADIUS + 1)}" stroke="#6b4a2b" stroke-width="3.6" stroke-linecap="round"/><path d="M${along(RADIUS + 0.8)} L${along(RADIUS + 3.4)}" stroke="#4a5563" stroke-width="4.3"/>
423 <circle cx="${lx}" cy="${ly}" r="${RADIUS}" fill="#e6f4ff" fill-opacity=".3" stroke="#4a5563" stroke-width="2.6">${front ? `<animate attributeName="fill-opacity" values=".3;.95" dur=".25s" begin="${T.look}s" fill="freeze"/>` : ""}</circle>` +
424 (front
425 ? `<g opacity="0"><animate attributeName="opacity" values="0;1" dur=".25s" begin="${T.look}s" fill="freeze"/><g transform="translate(${lx} ${ly}) scale(${K.toFixed(3)})"><g>
426 <animateTransform attributeName="transform" type="scale" values="1 1;1 1;1 .1;1 1" keyTimes="0;.93;.96;1" dur="4.5s" ${loop}/>
427 <rect x="-4.2" y="-4.8" width="8.4" height="9.6" rx="2.4" fill="${C.eye}"/>
428 <g>${move("0 0;.9 .3;0 0;-1.5 -.45;0 0", 3.2, `begin="${T.search}s" ${spline(4)}`)}<rect x="-1.2" y="-1" width="4.4" height="5.2" rx="1" fill="${C.pupil}"/><rect x="1.4" y="-.4" width="1.4" height="1.4" fill="${C.glint}"/></g></g></g></g>`
429 : "") +
430 `<path transform="translate(${lx} ${ly}) scale(${K.toFixed(3)})" d="M-4.6 -1.2 q.5 -2.8 3.4 -3.6" fill="none" stroke="#fff" stroke-width="1" stroke-linecap="round" opacity=".9"/>`;
431 // At her eye she holds it by the side, the handle out past her right: turned SIDE degrees from how it comes out.
432 const SIDE = 32, aside = [Math.cos(((62 - SIDE) * Math.PI) / 180), Math.sin(((62 - SIDE) * Math.PI) / 180)];
433 const held = `${(X + 8 + GRIP * aside[0]).toFixed(2)} ${(30.5 + GRIP * aside[1]).toFixed(2)}`;
434 // The tentacle doing it all: hanging; at her side; behind her; over her head with the cap; setting it on her head;
435 // behind her again; over her head with the magnifier; out to her right with it; holding it under her face.
436 const P = (dx, y) => `${X + dx} ${y}`;
437 const arm = {
438 hang: `M${P(8, 41)} Q${P(8, 48)} ${P(10, 51)} Q${P(12, 53.5)} ${P(15, 52)}`,
439 side: `M${P(8, 41)} Q${P(17, 44)} ${P(20, 38)} Q${P(22, 34)} ${P(21, 30)}`,
440 behind: `M${P(8, 41)} Q${P(17, 44)} ${P(20, 37)} Q${P(22, 31)} ${P(8, 30)}`,
441 capUp: `M${P(8, 41)} Q${P(18, 43)} ${P(20, 34)} Q${P(22, 24)} ${P(14, 14)}`,
442 capOn: `M${P(8, 41)} Q${P(18, 43)} ${P(20, 35)} Q${P(22, 28)} ${P(14, 21)}`,
443 behindAgain: `M${P(8, 41)} Q${P(17, 44)} ${P(20, 39)} Q${P(22, 35)} ${P(8, 36)}`,
444 loupeUp: `M${P(8, 41)} Q${P(17, 44)} ${P(22, 42)} Q${P(28, 40)} ${P(34, 40)}`,
445 pull: `M${P(8, 41)} Q${P(18, 45)} ${P(26, 43)} Q${P(34, 41)} ${P(39.3, 41.2)}`,
446 hold: `M${P(8, 41)} Q${P(14, 47)} ${P(19, 46)} Q${P(24, 45)} ${held}`,
447 };
448 const tip = (k) => arm[k].split(" ").slice(-2).join(" ");
449 const steps = [
450 ["side", 0.3, 0], ["behind", 0.2, T.behind], ["capUp", 0.45, 0.5], ["capOn", 0.3, T.cap], ["behindAgain", 0.2, 1.25],
451 ];
452 // Bringing the magnifier out is one flowing move: it slows at its ends, never at the poses between.
453 const FLOW = `keyTimes="0;.36;.57;1" calcMode="spline" keySplines=".45 0 .6 .75;.3 .35 .7 .7;.3 .3 .55 1"`;
454 const armMoves =
455 steps
456 .map(([to, dur, begin], i) => `<animate attributeName="d" values="${arm[i ? steps[i - 1][0] : "hang"]};${arm[to]}" dur="${dur}s" begin="${begin}s" fill="freeze" ${ease(1)}/>`)
457 .join("") + `<animate attributeName="d" values="${["behindAgain", "loupeUp", "pull", "hold"].map((k) => arm[k]).join(";")}" dur="1.4s" begin="1.45s" fill="freeze" ${FLOW}/>`;
458 // An item at the tentacle's tip, from small and hidden behind her head to full size over it.
459 const grows = (from, to, dur, begin, item) =>
460 `<g transform="translate(${tip(from)})">${once("translate", `${tip(from)};${tip(to)}`, dur, begin)}<g transform="scale(.6)">${once("scale", ".6;1", dur, begin)}${item}</g></g>`;
461 const CAP_HOLD = [X + 14, 21]; // where the tentacle holds the cap: the right of its crown
462 // Bending over: the head comes down and tilts towards the lens, the tentacles give a little; then she sways.
463 const PIVOT = `${X} 43`;
464 const bend =
465 once("translate", "0 0;0 3", 0.6, T.loupe) +
466 `<g>${once("rotate", `0 ${PIVOT};14 ${PIVOT}`, 0.6, T.loupe)}<animateTransform attributeName="transform" type="rotate" values="14 ${PIVOT};18 ${PIVOT};14 ${PIVOT};10 ${PIVOT};14 ${PIVOT}" dur="3.2s" begin="${T.search}s" ${loop} ${spline(4)}/>`;
467 const wrapHead = (s) => `<g>${bend}${s}</g></g>`;
468 // Her eyes follow: the tentacle going behind her, the cap above, the tentacle again, the magnifier, then down through the lens.
469 const roam =
470 `<animateTransform attributeName="transform" type="translate" values="-1 -1.5;.5 -1.5;-.4 -3;-.4 -3;.5 -1.5;.2 -2.6;0 0" keyTimes="0;.18;.33;.44;.51;.79;1" dur="2.85s" fill="freeze"/>` +
471 move("0 0;.6 .2;0 0;-1 -.3;0 0", 3.2, `begin="${T.search}s" ${spline(4)}`);
472 return svg(
473 grow(
474 // Behind her: the tentacle, the cap, the magnifier.
475 `<g opacity="0">${show(T.behind)}${show(T.loupe, false)}${limb(arm.side, armMoves)}</g>` +
476 `<g opacity="0">${show(0.5)}${show(T.cap, false)}${grows("behind", "capUp", 0.45, 0.5, `<g transform="translate(${-CAP_HOLD[0]} ${-CAP_HOLD[1]})">${cap}</g>`)}</g>` +
477 `<g opacity="0">${show(1.45)}${show(T.loupe, false)}<g transform="translate(${tip("behindAgain")})"><animateTransform attributeName="transform" type="translate" values="${tip("behindAgain")};${tip("loupeUp")};${tip("pull")}" keyTimes="0;.625;1" dur=".8s" begin="1.45s" fill="freeze" calcMode="spline" keySplines=".45 0 .6 .75;.3 .35 .7 .7"/><g transform="rotate(-35)"><animateTransform attributeName="transform" type="rotate" values="-35;10;3" keyTimes="0;.625;1" dur=".8s" begin="1.45s" fill="freeze" calcMode="spline" keySplines=".45 0 .6 .75;.3 .35 .7 .7"/><g transform="scale(.45)">${once("scale", ".45;1", 0.5, 1.45)}${loupe(false)}</g></g></g></g>` +
478 body([6], { wrapHead, wrapLegs: (s) => onGround(once("scale", "1 1;1.06 .8", 0.6, T.loupe), s) }) +
479 // In front: the cap set on her head, the magnifier brought to her eye, the tentacle.
480 wrapHead(
481 faceIn({ look: [1, 1.5], roam }, "o") +
482 `<g opacity="0">${show(T.cap)}<g transform="translate(0 -7)">${once("translate", "0 -7;0 0", 0.3, T.cap)}${cap}</g></g>` +
483 `<g opacity="0">${show(T.loupe)}<g transform="translate(${P(39.3, 41.2)})"><animateTransform attributeName="transform" type="translate" values="${P(39.3, 41.2)};${held}" dur=".6s" begin="${T.loupe}s" fill="freeze" calcMode="spline" keySplines=".3 .3 .55 1"/><g transform="rotate(3)"><animateTransform attributeName="transform" type="rotate" values="3;${-SIDE - 6};${-SIDE}" keyTimes="0;.6;1" dur=".6s" begin="${T.loupe}s" fill="freeze" ${ease(2)}/>${loupe(true)}</g></g></g>` +
484 `<g>${show(T.behind, false)}${show(T.loupe)}${limb(arm.hang, armMoves)}</g>`,
485 ),
486 ),
487 shadowOf("22;22", 1),
488 );
489 })(),
490
491 // Tools.
492 // From her rest pose she sits down, a tentacle goes behind her back and comes out over her head with a closed
493 // laptop, grown from small and hidden until then; she brings it down in front of her, opens it, and types with two
494 // tentacles reaching over its screen, which hides her up to just under her mouth, happily rocking her head. The
495 // start plays once.
496 laptop: (() => {
497 const ease = (n) => `calcMode="spline" keySplines="${Array(n).fill(".4 0 .6 1").join(";")}"`;
498 const once = (type, values, dur, begin) =>
499 `<animateTransform attributeName="transform" type="${type}" values="${values}" dur="${dur}s" begin="${begin}s" fill="freeze" ${ease(values.split(";").length - 1)}/>`;
500 const show = (at, on = true) => `<set attributeName="opacity" to="${on ? 1 : 0}" begin="${at}s" fill="freeze"/>`;
501 const DROP = 3; // sitting, the head comes down this much; arms are drawn with the head, the laptop on the ground
502 const T = { sat: 0.6, behind: 0.7, out: 0.9, front: 1.4, down: 2.0, open: 2.3, type: 2.6 };
503 // The laptop, held at the right end of its base (0 0): closed, a slab; opening, its screen rises.
504 const laptop = (opens) =>
505 `<rect x="-38" y="-3.4" width="38" height="2" rx="1" fill="#d8dee6" stroke="${C.line}" stroke-width="1.2">${
506 opens
507 ? `<animate attributeName="y" values="-3.4;-17.1" dur=".3s" begin="${T.down}s" fill="freeze" ${ease(1)}/><animate attributeName="height" values="2;16.5" dur=".3s" begin="${T.down}s" fill="freeze" ${ease(1)}/>`
508 : ""
509 }</rect>` +
510 (opens ? `<rect x="-21.2" y="-11.05" width="4.4" height="4.4" rx="1.1" fill="#9aa7b6" opacity="0"><animate attributeName="opacity" values="0;1" dur=".15s" begin="${T.down + 0.15}s" fill="freeze"/></rect>` : "") +
511 `<rect x="-38" y="-1.6" width="38" height="3.2" rx="1.6" fill="#b9c3cf" stroke="${C.line}" stroke-width="1"/>`;
512 const HOLD = { hidden: [X + 9, 32], up: [X + 27, 29], placed: [X + 19, 58.6] }; // on the ground's coordinates; placed a little in front of her, nearer to us
513 const TILT = 28; // out on her right, held up at a slant, its far end over her head
514 const at = ([x, y]) => `${x} ${y}`;
515 // The right tentacle, in the head's coordinates (DROP above the ground's).
516 const R = `M${X + 8} 41`;
517 const arm = {
518 hang: `${R} Q${X + 8} 48 ${X + 10} 51 Q${X + 12} 53.5 ${X + 15} 52`,
519 side: `${R} Q${X + 17} 44 ${X + 20} 38 Q${X + 22} 34 ${X + 21} 30`,
520 behind: `${R} Q${X + 17} 44 ${X + 20} 37 Q${X + 22} 31 ${X + 9} 29`,
521 up: `${R} Q${X + 19} 44 ${X + 25} 38 Q${X + 28} 33 ${X + 27} ${29 - DROP}`,
522 down: `${R} Q${X + 17} 43 ${X + 20} 46 Q${X + 21} 49 ${X + 19} ${58.6 - DROP}`,
523 };
524 // Typing: a hump over the screen's edge, the tip behind it; each side taps in turn.
525 const typing = (side) => {
526 const x = (dx) => X + dx * side;
527 return {
528 low: `M${x(8)} 41 Q${x(10)} 42 ${x(10)} 43 Q${x(10)} 44 ${x(7.5)} 45`,
529 up: `M${x(8)} 41 Q${x(10.5)} 36.75 ${x(10.38)} 36.5 Q${x(10.25)} 36.25 ${x(7.5)} 40`,
530 down: `M${x(8)} 41 Q${x(10)} 38.25 ${x(9.75)} 38.63 Q${x(9.5)} 39 ${x(7)} 42.5`,
531 };
532 };
533 const [left, right] = [typing(-1), typing(1)];
534 const d = (from, to, dur, begin) => `<animate attributeName="d" values="${from};${to}" dur="${dur}s" begin="${begin}s" fill="freeze" ${ease(1)}/>`;
535 const tap = (k, begin) => `<animate attributeName="d" values="${k.up};${k.down};${k.up}" dur=".3s" begin="${begin}s" ${loop}/>`;
536 const rightMoves =
537 d(arm.hang, arm.side, 0.3, 0.4) + d(arm.side, arm.behind, 0.2, T.behind) + d(arm.behind, arm.up, 0.5, T.out) +
538 d(arm.up, arm.down, 0.6, T.front) + d(arm.down, right.up, 0.3, T.open) + tap(right, T.type);
539 const leftMoves = d(left.low, left.up, 0.3, T.open) + tap(left, T.type + 0.15);
540 // Sitting: the head comes down, the tentacles give. Typing, she happily rocks her head from side to side.
541 // It slows only at each side: through the middle it keeps its speed (the end splines start and finish at the
542 // middle one's slope), so it never stops there.
543 const rock = [0, 12, -12, 0].map((a) => `${a} ${X} 43`).join(";");
544 const wrapHead = (s) =>
545 `<g>${once("translate", `0 0;0 ${DROP}`, T.sat, 0)}<g><animateTransform attributeName="transform" type="rotate" values="${rock}" keyTimes="0;.25;.75;1" dur="1.6s" begin="${T.type}s" ${loop} calcMode="spline" keySplines=".3 .545 .55 1;.45 0 .55 1;.45 0 .7 .455"/>${s}</g></g>`;
546 const wrapLegs = (s) => onGround(once("scale", "1 1;1.05 .8", T.sat, 0), s);
547 // Her eyes: ahead, then the tentacle going behind her, the laptop over her head, then down on it.
548 const roam = `<animateTransform attributeName="transform" type="translate" values="0 -1.3;0 -1.3;1 -1.3;.5 -3;.5 -3;0 0" keyTimes="0;.2;.35;.5;.7;1" dur="2s" fill="freeze"/>`;
549 return svg(
550 grow(
551 // Behind her: the tentacle and the closed laptop.
552 wrapHead(`<g opacity="0">${show(T.behind)}${show(T.front, false)}${limb(arm.side, rightMoves)}</g>`) +
553 `<g opacity="0">${show(T.out)}${show(T.front, false)}<g transform="translate(${at(HOLD.hidden)})">${once("translate", `${at(HOLD.hidden)};${at(HOLD.up)}`, 0.5, T.out)}<g>${once("rotate", `0;${TILT}`, 0.5, T.out)}<g transform="scale(.5)">${once("scale", ".5;1", 0.5, T.out)}${laptop(false)}</g></g></g></g>` +
554 body([6], { wrapHead, wrapLegs }) +
555 wrapHead(
556 `<g>${fade(T.type, false)}${faceIn({ look: [0, 1.3], roam }, "flat")}</g><g opacity="0">${fade(T.type, true)}${face({ closed: "happy" })}</g>` +
557 `<g>${show(T.behind, false)}${show(T.front)}${limb(arm.hang, rightMoves)}</g>` +
558 `<g opacity="0">${show(T.open)}${limb(left.low, leftMoves)}</g>`,
559 ) +
560 // In front: the laptop brought down to the ground and opened.
561 `<g opacity="0">${show(T.front)}<g transform="translate(${at(HOLD.up)})">${once("translate", `${at(HOLD.up)};${at(HOLD.placed)}`, 0.6, T.front)}<g transform="rotate(${TILT})">${once("rotate", `${TILT};0`, 0.6, T.front)}<g><animateTransform attributeName="transform" type="translate" values="0 0;.4 .8;0 0;-.4 .8;0 0" keyTimes="0;.15;.5;.65;1" dur=".3s" begin="${T.type}s" ${loop}/>${laptop(true)}</g></g></g></g>`,
562 ),
563 shadowOf("22;22", 1),
564 );
565 })(),
566 // A retro all-in-one computer, seen from her side: it falls on her right from above and bounces, her eyes follow it
567 // down, then she turns to it, leans in and types with a tentacle from each side while code writes itself on the
568 // screen. The start plays once.
569 desktop: (() => {
570 const ease = (n) => `calcMode="spline" keySplines="${Array(n).fill(".4 0 .6 1").join(";")}"`;
571 const T = { land: 0.8, reach: 0.8, type: 1.2 };
572 const SHELL = "#e9e7df", SHADE = "#c9c6bb", SURF = "#6cc2ab", SURF_DARK = "#4a9e88", SCREEN = "#12302b";
573 // The screen faces her, a little turned to us: its lines slant with its edges.
574 const slope = -2.9 / 14;
575 const line = (x, y, w, color, i) => {
576 const len = Math.hypot(w, w * slope).toFixed(2);
577 return `<path d="M${x} ${y} l${w} ${(w * slope).toFixed(2)}" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="${len}" stroke-dashoffset="${len}">
578 <animate attributeName="stroke-dashoffset" values="${len};${len};0;0;${len}" keyTimes="0;${(i * 0.15).toFixed(2)};${(i * 0.15 + 0.15).toFixed(2)};.9;1" dur="3.2s" begin="${T.type}s" ${loop}/></path>`;
579 };
580 // After the photo: seen three-quarters from her side, a near-square greyed-white front with a big screen faces
581 // her, edged in surf green; the green shell bulges round to its back; two little green speakers and a disc slot
582 // under the screen; a green foot; a keyboard of grey keys in a green frame, and a round mouse on its cable.
583 const computer =
584 `<path d="M63 56.5 L79 54.5 L84 57.2 L67.5 59.6Z" fill="${SURF_DARK}" stroke="${C.line}" stroke-width="1" stroke-linejoin="round"/>
585 <path d="M80 21.5 Q88.5 22.5 90.6 32 Q92 40 89.5 46 Q86.5 52.5 80 53.5Z" fill="${SURF}" stroke="${C.line}" stroke-width="1.2" stroke-linejoin="round"/>
586 <path d="M82.5 24.5 Q87.5 27 88.6 34" fill="none" stroke="#fff" stroke-width="1.3" stroke-linecap="round" opacity=".55"/>
587 <path d="M60.5 25.5 L80.5 21.5 L80.5 54 L60.5 52Z" fill="${SURF}" stroke="${C.line}" stroke-width="1.2" stroke-linejoin="round"/>
588 <path d="M61.8 26.7 L79.3 23.2 L79.3 52.8 L61.8 50.9Z" fill="${SHELL}" stroke-linejoin="round"/>
589 <path d="M63.6 29 L77.6 26.1 L77.6 44.4 L63.6 45.2Z" fill="${SCREEN}" stroke="${SHADE}" stroke-width=".9" stroke-linejoin="round"/>
590 <ellipse cx="64.9" cy="48" rx="1.7" ry="1.1" fill="${SURF}"/><ellipse cx="76.5" cy="47.6" rx="1.7" ry="1.1" fill="${SURF}"/>
591 <path d="M68.6 48.6 L72.8 48.1" stroke="${SURF_DARK}" stroke-width="1" stroke-linecap="round"/>` +
592 [[64.6, 32, 10, "#9ff0c8"], [65.8, 35.2, 6.5, "#ffd166"], [65.8, 38.4, 8.5, "#9ff0c8"], [64.6, 41.6, 5, "#9ff0c8"]].map(([x, y, w, c], i) => line(x, y, w, c, i)).join("") +
593 // The keyboard and the mouse in front of it.
594 `<path d="M73 61.2 Q76 63 79 58" fill="none" stroke="${SHADE}" stroke-width=".8"/>
595 <rect x="51" y="58.4" width="18" height="3.8" rx="1.4" fill="${SURF}" stroke="${C.line}" stroke-width="1"/><rect x="52.3" y="59.4" width="15.4" height="1.8" rx=".6" fill="${SHADE}"/>
596 <path d="M53 60.3 h14" stroke="${SHELL}" stroke-width=".9" stroke-dasharray="1.4 .7"/>
597 <ellipse cx="72" cy="61.2" rx="2.4" ry="1.4" fill="${SURF}" stroke="${C.line}" stroke-width=".9"/>`;
598 // It falls from above the picture and bounces twice on the ground before settling.
599 const pop = `<g transform="translate(0 -80)"><animateTransform attributeName="transform" type="translate" values="0 -80;0 0;0 -5;0 0;0 -1.5;0 0" keyTimes="0;.55;.7;.82;.9;1" dur="${T.land}s" fill="freeze" calcMode="spline" keySplines=".5 0 1 1;0 0 .5 1;.5 0 1 1;0 0 .5 1;.5 0 1 1"/><g transform="translate(91 62) scale(1.25) translate(-91 -62)">${computer}</g></g>`;
600 // Once it has landed she turns to it: her body narrows a little, her face moves over to the side towards it and
601 // narrows more; she leans a little towards it.
602 const TURN = 0.4, BODY = 0.9, FACE = 0.8, FACE_SHIFT = 5;
603 const turnTo = (scale, shift, s) =>
604 `<g><animateTransform attributeName="transform" type="translate" values="0 0;${shift} 0" dur="${TURN}s" begin="${T.land}s" fill="freeze" ${ease(1)}/>` +
605 `<g transform="translate(${X} 0)"><g><animateTransform attributeName="transform" type="scale" values="1 1;${scale} 1" dur="${TURN}s" begin="${T.land}s" fill="freeze" ${ease(1)}/><g transform="translate(${-X} 0)">${s}</g></g></g></g>`;
606 const lean = (s) => `<g><animateTransform attributeName="transform" type="rotate" values="0 ${X + 10} ${GROUND};8 ${X + 10} ${GROUND}" dur="${TURN}s" begin="${T.land}s" fill="freeze" ${ease(1)}/>${s}</g>`;
607 // A tentacle from each side goes to the keyboard, the left one across her front; they tap in turn.
608 const ARMS = [[-8, 55, 3], [13, 61, 7]]; // root offset, tip x on the keyboard, the tentacle it replaces
609 const arms = ARMS.map(([dx, tip], i) => {
610 const r = +(X + dx * BODY).toFixed(2); // where its root is once she has turned
611 const hang = `M${r} 41 Q${r} 48 ${r + 2} 51 Q${r + 4} 53.5 ${r + 7} 52`;
612 const rest = dx < 0 ? `M${r} 41 Q${r + 4} 50 ${r + 13} 50.5 Q${tip - 4} 51 ${tip} 52.6` : `M${r} 41 Q${r + 6} 44 ${r + 8} 48 Q${tip + 0.5} 51 ${tip} 52.6`;
613 const lift = dx < 0 ? `M${r} 41 Q${r + 4} 47.5 ${r + 12} 46.5 Q${tip - 3} 45 ${tip} 47.2` : `M${r} 41 Q${r + 6} 42.5 ${r + 8.5} 45 Q${tip + 1} 46 ${tip} 47.2`;
614 return limb(
615 hang,
616 `<animate attributeName="d" values="${hang};${rest}" dur=".4s" begin="${T.reach}s" fill="freeze" ${ease(1)}/>` +
617 `<animate attributeName="d" values="${rest};${lift};${rest}" dur=".3s" begin="${T.type + i * 0.15}s" ${loop} ${spline(2)}/>`,
618 );
619 }).join("");
620 const back = `<animateTransform attributeName="transform" type="translate" values="0 0;-14 0" dur=".45s" begin=".25s" fill="freeze" ${ease(1)}/>`;
621 // Her eyes follow it down, then stay on the screen.
622 const roam = `<animateTransform attributeName="transform" type="translate" values="-1.6 -.3;0 -2.2;.4 0;0 0" keyTimes="0;.35;.75;1" dur="1s" fill="freeze"/>`;
623 return svg(
624 // As it lands she backs away to the left, to make room for it.
625 pop + `<g>${back}` + grow(lean(turnTo(BODY, 0, body(ARMS.map(([, , k]) => k)) + turnTo(FACE / BODY, FACE_SHIFT, faceIn({ look: [1.6, 0.3], roam }, "smile"))) + arms)) + `</g>`,
626 `<g>${back}${shadowOf("22;22", 1)}</g>`,
627 );
628 })(),
629
630 // A tool failed.
631 // She sinks onto the ground as in settle, crestfallen: eyelids drooping over eyes cast down, a frown; then breathes
632 // heavily while a big drop of sweat slides down the side of her head, again and again.
633 dejected: (() => {
634 const { wrapHead, wrapLegs, shadow } = sunk();
635 // The drop, three times the size it had: it appears high on her right, slides down and fades, over and over.
636 const sweat =
637 `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.12;.78;1" dur="3.2s" begin="1.1s" ${loop}/>` +
638 `<g>${move("0 0;0 0;0 6;0 6", 3.2, `keyTimes="0;.12;.85;1" begin="1.1s" ${spline(3)}`)}` +
639 // A teardrop: pointed at the top, its bottom a half circle.
640 `<path d="M${X + 19} 11 C${X + 20.5} 19 ${X + 24.4} 24 ${X + 24.4} 29.9 A5.4 5.4 0 0 1 ${X + 13.6} 29.9 C${X + 13.6} 24 ${X + 17.5} 19 ${X + 19} 11Z" fill="#9fd3ff" stroke="${C.line}" stroke-width="1.4"/>` +
641 `<path d="M${X + 16} 31.5 q-.5 -3.8 1.4 -7" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity=".85"/></g></g>`;
642 return svg(
643 grow(
644 body([], { wrapHead, wrapLegs }) + wrapHead(faceIn({ look: [0, 1.6], droop: true }, "frown") + sweat),
645 ),
646 shadow,
647 );
648 })(),
649 // A cloud of ink grows all around her, from nothing to its full size, and fades, while she jolts and winces. It has
650 // a white edge, to show on a dark background too.
651 ink: (() => {
652 const D = 2.6, CENTER = [X, 37];
653 const puffs = Array.from({ length: 12 }, (_, i) => {
654 const a = (i * 30 * Math.PI) / 180, d = 22 + (i % 2) * 5, r = 9 + (i % 3) * 2.2;
655 return [CENTER[0] + d * Math.cos(a), CENTER[1] + d * Math.sin(a) * 0.75, r];
656 });
657 const grows = later(`keyTimes="0;.55;1" calcMode="spline" keySplines=".25 .6 .45 1;0 0 1 1"`);
658 // Drawn twice: a little bigger in white, then in ink, for a white edge round the whole cloud.
659 const cloud = (grown, color) =>
660 puffs.map(([x, y, r]) => `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="0" fill="${color}">${anim("r", `0;${r + grown + 1.5};${r + grown + 1.5}`, D, grows)}</circle>`).join("");
661 return svg(
662 `<g opacity=".88">${anim("opacity", ".88;.88;0;0", D, later(`keyTimes="0;.5;.85;1"`))}${cloud(1.3, "#fff")}${cloud(0, C.ink)}</g>` +
663 `<g>${move("0 0;2 -2;0 0;0 0", D, later(`keyTimes="0;.08;.3;1"`))}${grow(body() + faceIn({ closed: "sad" }, "wavy"))}</g>`,
664 );
665 })(),
666
667 // Sunk onto the ground as in dejected, she sulks: eyelids low, a pout. The silence crow of anime flies across
668 // above her head and leaves three dots behind it, which fade once it has gone.
669 crow: (() => {
670 const D = 10.5, FLIGHT = 7, START = 1, FROM = -42, TO = 112, Y = 14, SIZE = 1.3; // a slow, leisurely pass
671 const ink = C.ink, edge = `stroke="#fff" stroke-width=".9" stroke-linejoin="round"`;
672 // Our own round crow, facing left: a fan of tail feathers, a round body, a big round eye, an open yellow beak,
673 // and a notched wing that flaps.
674 const up = `M1 -2 Q4 -10.5 9.5 -9.5 Q7.5 -7 8.5 -5 Q5.5 -4.5 2.5 -1Z`;
675 const down = `M1 1 Q4 9.5 9.5 8.5 Q7.5 6 8.5 4 Q5.5 3.5 2.5 1Z`;
676 const bird =
677 [[9, -2.6, -18], [10.2, 0, 0], [9, 2.6, 18]].map(([x, y, a]) => `<ellipse cx="${x}" cy="${y}" rx="3.4" ry="1.5" transform="rotate(${a} ${x} ${y})" fill="${ink}" ${edge}/>`).join("") +
678 `<circle r="6.4" fill="${ink}" ${edge}/>` +
679 `<path d="M-5.6 -.9 L-10.6 .1 L-5.4 1.1Z" fill="#ffc940" stroke="${ink}" stroke-width=".5" stroke-linejoin="round"/>` +
680 `<path d="M-5.4 1.7 L-9.3 3 L-5 3.5Z" fill="#ffc940" stroke="${ink}" stroke-width=".5" stroke-linejoin="round"/>` +
681 `<circle cx="-2.4" cy="-1.8" r="2.9" fill="#fff" stroke="${ink}" stroke-width=".5"/><circle cx="-3.1" cy="-1.7" r="1.15" fill="${ink}"/>` +
682 `<path d="${up}" fill="${ink}" ${edge}>${anim("d", `${up};${down};${up}`, 0.6, spline(2))}</path>`;
683 // From out of sight on the right it flies across with a slight wave, then stays out of sight on the left until
684 // the next pass (the motion adds to where the group stands).
685 const flight = `<animateMotion path="M0 0 Q${(TO - FROM) / 4} -3 ${(TO - FROM) / 2} 1 T${TO - FROM} 0" dur="${D}s" keyPoints="0;1;1" keyTimes="0;${(FLIGHT / D).toFixed(2)};1" calcMode="linear" begin="${START}s" ${loop}/>`;
686 // Its dots, all along its way across the picture: each appears once the crow has passed it, and all fade together
687 // after it has gone.
688 const dots = Array.from({ length: 10 }, (_, i) => -6 + i * 10)
689 .map((x) => {
690 const t = ((x + 10 - FROM) / (TO - FROM)) * FLIGHT; // once its tail is past
691 const k = (n) => +(n / D).toFixed(3);
692 return `<circle cx="${x}" cy="${Y + 2}" r="2.3" fill="${ink}" ${edge} opacity="0"><animate attributeName="opacity" values="0;0;1;1;0;0" keyTimes="0;${k(t)};${k(t + 0.15)};${k(FLIGHT + 0.3)};${k(FLIGHT + 0.9)};1" dur="${D}s" begin="${START}s" ${loop}/></circle>`;
693 })
694 .join("");
695 const { wrapHead, wrapLegs, shadow } = sunk();
696 return svg(
697 grow(body([], { wrapHead, wrapLegs }) + wrapHead(faceIn({ look: [-0.6, 0.6], droop: true }, "pout"))) +
698 dots +
699 `<g transform="translate(${FROM} ${Y})">${flight}<g transform="scale(${-SIZE} ${SIZE})">${bird}</g></g>`,
700 shadow,
701 );
702 })(),
703
704 // The turn is done.
705 lightbulb: svg(
706 bob(
707 body([7]) +
708 faceIn({ look: [1, -1.2] }, "grin") +
709 (() => {
710 const rest = restArm(X + 13, 1);
711 const up = `M${X + 13} 41 Q${X + 23} 41 ${X + 23} 33 Q${X + 23} 29 ${X + 23} 25`;
712 return limb(rest, raise(rest, up) + turn(`0 ${X + 13} 41;-4 ${X + 13} 41;0 ${X + 13} 41`, 1.2, later(spline(2))));
713 })(),
714 1,
715 3,
716 ) +
717 // The bulb in the picture's top right corner, its rays against the edges, by her raised tentacle: it lights up,
718 // its rays flash, a filament glows.
719 popIn(
720 [77.6, 14.4],
721 `<g transform="translate(77.6 14.4) scale(.8)">
722 <g stroke="#e0b000" stroke-width="2" stroke-linecap="round" opacity="0">${anim("opacity", "0;0;1;1;0", 2.4, later(`keyTimes="0;.2;.3;.9;1"`))}<path d="M0 -14 v-3 M14 0 h3 M-14 0 h-3 M9.9 -9.9 l2.2 -2.2 M-9.9 -9.9 l-2.2 -2.2"/></g>
723 <rect x="-11" y="-11" width="22" height="22" rx="9" fill="#ffe27a" stroke="${C.line}" stroke-width="1.6" fill-opacity=".2">${anim("fill-opacity", ".2;.2;1;1;.2", 2.4, later(`keyTimes="0;.2;.3;.9;1"`))}</rect>
724 <path d="M-4 3 l2 -4 l2 4 l2 -4 l2 4" fill="none" stroke="#b08a00" stroke-width="1.2" stroke-linejoin="round" opacity=".5">${anim("opacity", ".5;.5;1;1;.5", 2.4, later(`keyTimes="0;.2;.3;.9;1"`))}</path>
725 <rect x="-6" y="11" width="12" height="8" rx="2" fill="#b9c3cf" stroke="${C.line}" stroke-width="1.3"/><path d="M-6 14.5 h12" stroke="${C.line}" stroke-width=".9"/></g>`,
726 ),
727 ),
728 jump: (() => {
729 const keys = `keyTimes="0;.15;.4;.65;.75;1"`;
730 return svg(
731 `<g>${move("0 0;0 1.5;0 -9;0 0;0 1;0 0", 1.4, later(keys))}${grow(body() + faceIn({ closed: "happy" }, "grin"))}</g>`,
732 shadowOf("22;23;15;22;23;22", 1.4, later(keys)),
733 );
734 })(),
735 dance: svg(
736 `<g>${turn([0, 7, 0, -7, 0].map((a) => `${a} ${X} ${FLOOR}`).join(";"), 1.2, later(spline(4)))}` +
737 grow(
738 body([2, 7]) +
739 faceIn({ closed: "happy" }, "grin") +
740 [-1, 1]
741 .map((side) => {
742 const r = X + 13 * side, rest = restArm(r, side);
743 const up = `M${r} 41 Q${r + 10 * side} 41 ${r + 10 * side} 35.5 Q${r + 10 * side} 33 ${r + 10 * side} 30`;
744 // Each arm waves in turn, the right half a beat later.
745 return limb(rest, raise(rest, up) + turn([0, -25 * side, 0].map((a) => `${a} ${r} 41`).join(";"), 0.6, later(spline(2), side < 0 ? T0 : T0 + 0.3)));
746 })
747 .join(""),
748 ) +
749 `</g>` +
750 // Two notes, twice their first size, rise and fade by turns.
751 [[67, 36, 0], [79, 44, 0.8]]
752 .map(([x, y, b]) => `<g transform="translate(${x} ${y})" opacity="0"><g>${move("0 0;3 -14", 1.6, `begin="${b + T0}s"`)}<g transform="scale(2)">${note}</g></g>${anim("opacity", "0;1;1;0", 1.6, `keyTimes="0;.2;.7;1" begin="${b + T0}s"`)}</g>`)
753 .join(""),
754 shadowOf("22;20;22", 0.6),
755 ),
756
757 // Compaction is near.
758 dizzy: svg(
759 `<g>${turn([0, 6, 0, -6, 0].map((a) => `${a} ${X} ${FLOOR}`).join(";"), 1.8, later(spline(4)))}${grow(body() + faceIn({ spiral: true }, "wavy"))}</g>` +
760 fadeIn([0, 0.6, 1.2].map((b) => `<g><animateMotion dur="1.8s" ${loop} begin="-${b}s" path="M${X} 8.8 a19 2 0 1 0 0.1 0Z"/><g transform="scale(2.6)">${star()}</g></g>`).join("")),
761 shadowOf("22;20;22", 0.9),
762 ),
763
764};
765
766// The size Otto is drawn at in the band, in px.
767export const OTTO = { width: W / 2, height: H / 2 };
768
769// Otto's blue, for what the band marks in his color.
770export const OTTO_COLOR = C.body;
771
772// How long each mood plays before a reaction moves on (ms): its start and about a loop of it.
773export const PLAY_MS = {
774 idle: 3000, lookaround: 5400, bubbles: 3500, settle: 4500, twirl: 3600,
775 ...Object.fromEntries(Object.entries(ONCE).map(([name, s]) => [name, s * 1000])),
776 thinking: 4000, puzzled: 4000, detective: 5500,
777 laptop: 4500, desktop: 4500,
778 dejected: 4500, ink: 3000, crow: 8500,
779 lightbulb: 3200, jump: 3200, dance: 3200, dizzy: 4000,
780};
781
782// How long a mood holds the band before Claude's next move may replace it: the detective brings the magnifier to her
783// eye (2.75 s) and looks through it a second.
784export const HOLD_MS = { detective: 3750 };
785
786// The mood's SVG, `along` ms after it began: the desktop rebuilds the picture on every redraw, so every animation
787// begins that much earlier and the drawing carries on where it was. A browser shows nothing of an animation that
788// played once and ended before the picture appeared, not even its frozen end: such an animation begins just early
789// enough to end a millisecond after, so it freezes where it should.
790export function ottoSvg(name, along) {
791 const TAG = /<(?:animate|animateTransform|animateMotion|set)\b[^>]*>/g;
792 // A run of animation tags animates one element; the later begin wins an attribute, so only the last on each
793 // attribute is clamped: clamping one it hands over to (an arm's earlier moves) would put it back on top.
794 return MOODS[name].replace(new RegExp(`(?:${TAG.source})+`, "g"), (run) => {
795 const tags = run.match(TAG).map((tag) => {
796 const begin = tag.match(/begin="(-?[\d.]+)s"/);
797 return { tag, begin, at: begin ? begin[1] * 1000 : 0, attr: tag.match(/attributeName="([^"]+)"/)?.[1] };
798 });
799 return tags
800 .map(({ tag, begin, at, attr }) => {
801 const last = !tags.some((t) => t.attr === attr && t.at > at);
802 at -= along;
803 const dur = tag.match(/dur="([\d.]+)s"/);
804 if (last && dur && tag.includes('fill="freeze"') && !tag.includes("repeatCount")) at = Math.max(at, 1 - dur[1] * 1000);
805 const value = `begin="${Math.round(at)}ms"`;
806 return begin ? tag.replace(begin[0], value) : tag.replace(/\/?>$/, (end) => ` ${value}${end}`);
807 })
808 .join("");
809 });
810}
811hooks/weather.mjs 150 lines1// Weather icons: Meteocons by Bas Milius (https://github.com/basmilius/meteocons, @meteocons/svg 0.1.0,
2// monochrome style), MIT License, Copyright (c) 2020-present Bas Milius. Animated with SMIL, every
3// loop a divisor of 6 s. Changes: one tag per line, viewBox cropped to the drawing.
4// The drawing is black, recolored when drawn.
5
6export const WEATHER = {
7 // clear-day
8 clear: `<svg viewBox="12 12 104 104" fill="none" xmlns="http://www.w3.org/2000/svg">
9<g id="clear-day">
10<g id="Sun">
11<circle id="Core" cx="64" cy="63.9999" r="18" stroke="black" stroke-width="4"/>
12<path id="Rays" fill-rule="evenodd" clip-rule="evenodd" d="M64 16C65.1046 16 66 16.8954 66 18V30C66 31.1046 65.1046 32 64 32C62.8954 32 62 31.1046 62 30V18C62 16.8954 62.8954 16 64 16ZM30.0589 30.0589C30.8399 29.2778 32.1062 29.2778 32.8873 30.0589L41.3726 38.5442C42.1536 39.3252 42.1536 40.5915 41.3726 41.3726C40.5915 42.1536 39.3252 42.1536 38.5441 41.3726L30.0589 32.8873C29.2778 32.1062 29.2778 30.8399 30.0589 30.0589ZM97.9411 30.0589C98.7222 30.8399 98.7222 32.1062 97.9411 32.8873L89.4558 41.3726C88.6748 42.1536 87.4085 42.1536 86.6274 41.3726C85.8464 40.5915 85.8464 39.3252 86.6274 38.5442L95.1127 30.0589C95.8937 29.2778 97.1601 29.2778 97.9411 30.0589ZM16 64C16 62.8954 16.8954 62 18 62H30C31.1046 62 32 62.8954 32 64C32 65.1046 31.1046 66 30 66H18C16.8954 66 16 65.1046 16 64ZM96 64C96 62.8954 96.8954 62 98 62H110C111.105 62 112 62.8954 112 64C112 65.1046 111.105 66 110 66H98C96.8954 66 96 65.1046 96 64ZM41.3726 86.6274C42.1536 87.4085 42.1536 88.6748 41.3726 89.4558L32.8873 97.9411C32.1062 98.7222 30.8399 98.7222 30.0589 97.9411C29.2778 97.1601 29.2778 95.8937 30.0589 95.1127L38.5441 86.6274C39.3252 85.8464 40.5915 85.8464 41.3726 86.6274ZM86.6274 86.6274C87.4085 85.8464 88.6748 85.8464 89.4558 86.6274L97.9411 95.1127C98.7222 95.8937 98.7222 97.1601 97.9411 97.9411C97.1601 98.7222 95.8937 98.7222 95.1127 97.9411L86.6274 89.4558C85.8464 88.6748 85.8464 87.4085 86.6274 86.6274ZM64 96C65.1046 96 66 96.8954 66 98V110C66 111.105 65.1046 112 64 112C62.8954 112 62 111.105 62 110V98C62 96.8954 62.8954 96 64 96Z" fill="black">
13<animateTransform attributeName="transform" type="rotate" values="0 64.0 64.0;360 64.0 64.0" dur="18s" begin="0s" repeatCount="indefinite"/>
14</path>
15</g>
16</g>
17</svg>`,
18 // overcast
19 cloudy: `<svg viewBox="12 12 104 104" fill="none" xmlns="http://www.w3.org/2000/svg">
20<g id="overcast" clip-path="url(#clip0_2038_14016)">
21<g id="Sky">
22<g id="Clouds" clip-path="url(#clip1_2038_14016)">
23<g id="Mask group">
24<mask id="mask0_2038_14016" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
25<g id="Cloud Mask">
26<path id="Subtract" fill-rule="evenodd" clip-rule="evenodd" d="M128 0H0V128H128V0ZM37.9519 93H90.9752C100.227 93 107.998 85.353 107.998 76.0281C107.998 68.0217 102.305 61.3501 94.9248 59.5512C95.3619 49.9005 89.6744 40.6093 80.5509 36.7922C71.1071 32.8411 60.0664 35.6119 53.5305 43.2384C48.5702 41.5956 42.9815 42.2957 38.5741 45.2907C34.1459 48.2998 31.4305 53.2809 31.1846 58.5379C24.0633 61.463 19.3278 68.8506 20.0776 76.7839C20.942 85.9295 28.8285 93.0018 37.9519 93Z" fill="black">
27<animateTransform attributeName="transform" type="translate" values="0 -3;0 0;0 -3" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
28</path>
29<animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
30</g>
31</mask>
32<g mask="url(#mask0_2038_14016)">
33<g id="Secondary Cloud">
34<path id="Cloud" d="M101.194 55.5696C102.367 51.0603 99.7602 46.4356 95.5043 44.7274C91.1919 42.9965 85.9612 44.4886 83.4186 48.4349C81.2648 47.1923 78.496 47.2622 76.4119 48.6208C74.3808 49.945 73.2434 52.3815 73.675 54.7869C70.2998 55.3987 67.6874 58.4746 68.0307 61.9597C68.3748 65.4526 71.5394 68.0008 74.9767 68C83.8126 68 92.6514 67.9924 101.488 68C104.911 68 108 65.2865 108 61.7777C108 58.1488 104.721 55.4219 101.194 55.5696Z" fill="black"/>
35<animateTransform attributeName="transform" type="translate" values="0 -3;0 0;0 -3" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
36</g>
37</g>
38<animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
39</g>
40<g id="Cloud_2">
41<path id="Cloud_3" fill-rule="evenodd" clip-rule="evenodd" d="M54.8371 48.2115C51.0739 45.9483 46.3457 45.7826 42.4415 47.6664C41.8837 47.9355 41.3428 48.2465 40.8239 48.5991C36.6826 51.4133 34.4998 56.5151 35.3499 61.454C28.3907 62.7689 23.3936 69.3412 24.0614 76.4076C24.7293 83.474 30.8678 89.0011 37.9519 89C37.9516 89 37.9522 89 37.9519 89H90.9767C91.8608 89 92.7273 88.908 93.5669 88.7333C95.0531 88.4239 96.4547 87.855 97.7196 87.0774C99.3131 86.0979 100.689 84.787 101.744 83.2465C102.32 82.4049 102.801 81.4947 103.168 80.5324C103.705 79.125 104 77.6063 104 76.0281C104 75.9138 103.998 75.7997 103.995 75.6861C103.84 69.9006 99.7434 65.0366 94.3906 63.5447C93.1158 63.1894 91.7697 63.0253 90.3886 63.0856C90.7211 61.752 90.9017 60.4069 90.9409 59.0706C91.1716 51.1861 86.4764 43.6067 79.0085 40.4823C70.2648 36.824 59.8274 40.138 54.8371 48.2115ZM90.9767 84.9973C95.8649 84.9973 100 80.8788 100 76.0281C100 71.6531 96.6498 67.9178 92.4216 67.2003C92.1196 67.1491 91.8131 67.1132 91.503 67.0937C91.1929 67.0741 90.8793 67.0708 90.5629 67.0846L87.8866 67.2014C87.2562 67.229 86.6497 66.9571 86.2505 66.4681C85.8513 65.979 85.7062 65.3301 85.859 64.7174L86.5076 62.1165C86.6209 61.6622 86.7117 61.206 86.7808 60.7491C87.8172 53.8959 83.9585 46.8917 77.4656 44.1752C70.5246 41.2712 62.1884 43.9274 58.239 50.3171L57.2003 51.9975C57.2001 51.998 57.1998 51.9984 57.1995 51.9989C56.6234 52.9294 55.4069 53.2241 54.4692 52.6602L52.7767 51.6424C49.8116 49.8592 45.9319 49.9664 43.071 51.9105C42.7147 52.1526 42.3793 52.4199 42.0664 52.7088C39.8756 54.7312 38.7824 57.8152 39.2918 60.7745L39.6259 62.7154C39.626 62.7158 39.626 62.7162 39.6261 62.7166C39.8107 63.7928 39.0991 64.8185 38.0269 65.0216C38.0266 65.0217 38.0271 65.0216 38.0269 65.0216L36.092 65.3872C31.1413 66.3226 27.5724 71.0449 28.0437 76.0307C28.5152 81.0199 32.9092 84.9983 37.9519 84.9973H90.9767Z" fill="black"/>
42<animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
43</g>
44</g>
45</g>
46</g>
47<defs>
48<clipPath id="clip0_2038_14016">
49<rect width="128" height="128" fill="white"/>
50</clipPath>
51<clipPath id="clip1_2038_14016">
52<rect width="128" height="128" fill="white"/>
53</clipPath>
54</defs>
55</svg>`,
56 // rain
57 showers: `<svg viewBox="12 12 104 104" fill="none" xmlns="http://www.w3.org/2000/svg">
58<g id="rain" clip-path="url(#clip0_2038_14032)">
59<g id="Sky">
60<g id="Clouds">
61<g id="Cloud">
62<path id="Cloud_2" fill-rule="evenodd" clip-rule="evenodd" d="M54.8371 48.2115C59.8274 40.138 70.2648 36.824 79.0085 40.4823C87.7416 44.136 92.6836 53.8827 90.3886 63.0856C97.6828 62.7671 104 68.7067 104 76.0281C104 83.1158 98.0476 89 90.9767 89C89.8721 89 88.9767 88.104 88.9767 86.9986C88.9767 85.8933 89.8721 84.9973 90.9767 84.9973C95.8637 84.9973 100 80.8799 100 76.0281C100 71.0262 95.6222 66.8637 90.5629 67.0846L87.8866 67.2014C87.2562 67.229 86.6497 66.9571 86.2505 66.4681C85.8513 65.979 85.7062 65.3301 85.859 64.7174L86.5076 62.1165C88.3201 54.8484 84.391 47.0727 77.4656 44.1752C70.5248 41.2713 62.1886 43.9272 58.239 50.3171L57.2003 51.9975C56.6245 52.9291 55.4074 53.2244 54.4692 52.6602L52.7767 51.6424C49.8116 49.8592 45.9319 49.9664 43.071 51.9105C40.2208 53.8474 38.7096 57.3922 39.2918 60.7745L39.6259 62.7154C39.8113 63.7924 39.0991 64.819 38.026 65.0218L36.092 65.3872C31.1421 66.3225 27.5723 71.0441 28.0437 76.0307C28.5153 81.0207 32.91 84.9983 37.9519 84.9973C39.0565 84.997 39.9521 85.8929 39.9524 86.9982C39.9526 88.1035 39.0573 88.9998 37.9528 89C30.8695 89.0015 24.7294 83.4755 24.0614 76.4076C23.3936 69.3412 28.3907 62.7689 35.3499 61.454C34.4997 56.5148 36.6828 51.4131 40.8239 48.5991C44.9752 45.7781 50.5363 45.625 54.8371 48.2115Z" fill="black"/>
63</g>
64<animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
65</g>
66</g>
67<g id="Precipitation">
68<g id="Raindrops">
69<path id="Raindrop 1" d="M52 83V95" stroke="black" stroke-width="4" stroke-linecap="round">
70<animateTransform attributeName="transform" type="translate" values="0 0;0 20" dur="1s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 1 1"/>
71<animate attributeName="opacity" values="0;1;1;0" dur="1s" begin="0s" repeatCount="indefinite" keyTimes="0;0.15;0.85;1"/>
72</path>
73<path id="Raindrop 2" d="M64 83V95" stroke="black" stroke-width="4" stroke-linecap="round" opacity="0">
74<animateTransform attributeName="transform" type="translate" values="0 0;0 20" dur="1s" begin="0.4s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 1 1"/>
75<animate attributeName="opacity" values="0;1;1;0" dur="1s" begin="0.4s" repeatCount="indefinite" keyTimes="0;0.15;0.85;1"/>
76</path>
77<path id="Raindrop 3" d="M76 83V95" stroke="black" stroke-width="4" stroke-linecap="round" opacity="0">
78<animateTransform attributeName="transform" type="translate" values="0 0;0 20" dur="1s" begin="0.8s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 1 1"/>
79<animate attributeName="opacity" values="0;1;1;0" dur="1s" begin="0.8s" repeatCount="indefinite" keyTimes="0;0.15;0.85;1"/>
80</path>
81</g>
82</g>
83</g>
84<defs>
85<clipPath id="clip0_2038_14032">
86<rect width="128" height="128" fill="white"/>
87</clipPath>
88</defs>
89</svg>`,
90 // thunderstorms-rain
91 storm: `<svg viewBox="12 12 104 104" fill="none" xmlns="http://www.w3.org/2000/svg">
92<g id="thunderstorms-rain" clip-path="url(#clip0_2038_14329)">
93<g id="Sky">
94<g id="Clouds">
95<g id="Cloud">
96<path id="Cloud_2" fill-rule="evenodd" clip-rule="evenodd" d="M54.837 48.2115C59.8272 40.138 70.2646 36.824 79.0084 40.4823C87.7415 44.136 92.6835 53.8827 90.3884 63.0856C97.6827 62.7671 104 68.7067 104 76.0281C104 83.1158 98.0475 89 90.9766 89C89.872 89 88.9766 88.104 88.9766 86.9986C88.9766 85.8933 89.872 84.9973 90.9766 84.9973C95.8636 84.9973 99.9999 80.8799 99.9999 76.0281C99.9999 71.0262 95.6221 66.8637 90.5628 67.0846L87.8865 67.2014C87.2561 67.229 86.6496 66.9571 86.2504 66.4681C85.8512 65.979 85.706 65.3301 85.8588 64.7174L86.5075 62.1165C88.32 54.8484 84.3908 47.0727 77.4654 44.1752C70.5247 41.2713 62.1884 43.9272 58.2389 50.3171L57.2002 51.9975C56.6244 52.9291 55.4073 53.2244 54.4691 52.6602L52.7766 51.6424C49.8115 49.8592 45.9317 49.9664 43.0709 51.9105C40.2206 53.8474 38.7095 57.3922 39.2917 60.7745L39.6258 62.7154C39.8112 63.7924 39.099 64.819 38.0259 65.0218L36.0919 65.3872C31.142 66.3225 27.5722 71.0441 28.0435 76.0307C28.5152 81.0207 32.9099 84.9983 37.9518 84.9973C39.0564 84.997 39.952 85.8929 39.9522 86.9982C39.9525 88.1035 39.0572 88.9998 37.9527 89C30.8694 89.0015 24.7293 83.4755 24.0613 76.4076C23.3934 69.3412 28.3905 62.7689 35.3498 61.454C34.4996 56.5148 36.6827 51.4131 40.8237 48.5991C44.975 45.7781 50.5361 45.625 54.837 48.2115Z" fill="black"/>
97</g>
98<animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
99</g>
100</g>
101<g id="Precipitation">
102<g id="Raindrops">
103<path id="Raindrop 1" d="M52 83V95" stroke="black" stroke-width="4" stroke-linecap="round">
104<animateTransform attributeName="transform" type="translate" values="0 0;0 20" dur="1s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 1 1"/>
105<animate attributeName="opacity" values="0;1;1;0" dur="1s" begin="0s" repeatCount="indefinite" keyTimes="0;0.15;0.85;1"/>
106</path>
107<path id="Raindrop 2" d="M64 83V95" stroke="black" stroke-width="4" stroke-linecap="round" opacity="0">
108<animateTransform attributeName="transform" type="translate" values="0 0;0 20" dur="1s" begin="0.4s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 1 1"/>
109<animate attributeName="opacity" values="0;1;1;0" dur="1s" begin="0.4s" repeatCount="indefinite" keyTimes="0;0.15;0.85;1"/>
110</path>
111<path id="Raindrop 3" d="M76 83V95" stroke="black" stroke-width="4" stroke-linecap="round" opacity="0">
112<animateTransform attributeName="transform" type="translate" values="0 0;0 20" dur="1s" begin="0.8s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 1 1"/>
113<animate attributeName="opacity" values="0;1;1;0" dur="1s" begin="0.8s" repeatCount="indefinite" keyTimes="0;0.15;0.85;1"/>
114</path>
115</g>
116</g>
117<g id="Lightning">
118<path id="Lightning Bolt" d="M60.0003 68L52 90.9092H60.0003L55.9995 110L76 83.2728H63.9996L71.9999 68H60.0003Z" fill="black">
119<animate attributeName="opacity" values="1;1;0;1;0;1;0;1;1" dur="2s" begin="0s" repeatCount="indefinite" keyTimes="0;0.25;0.33;0.42;0.5;0.57;0.63;0.67;1"/>
120</path>
121</g>
122</g>
123<defs>
124<clipPath id="clip0_2038_14329">
125<rect width="128" height="128" fill="white"/>
126</clipPath>
127</defs>
128</svg>`,
129 // tornado
130 compact: `<svg viewBox="12 12 104 104" fill="none" xmlns="http://www.w3.org/2000/svg">
131<g id="tornado">
132<path id="Line 5" d="M56 90H72" stroke="black" stroke-width="4" stroke-miterlimit="10" stroke-linecap="round">
133<animateTransform attributeName="transform" type="translate" values="0 0;2 0;0 0" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
134</path>
135<path id="Line 4" d="M47 77H81" stroke="black" stroke-width="4" stroke-miterlimit="10" stroke-linecap="round">
136<animateTransform attributeName="transform" type="translate" values="0 0;4 0;0 0" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
137</path>
138<path id="Line 3" d="M42 64H87" stroke="black" stroke-width="4" stroke-miterlimit="10" stroke-linecap="round">
139<animateTransform attributeName="transform" type="translate" values="0 0;6 0;0 0" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
140</path>
141<path id="Line 2" d="M37 51H91" stroke="black" stroke-width="4" stroke-miterlimit="10" stroke-linecap="round">
142<animateTransform attributeName="transform" type="translate" values="0 0;8 0;0 0" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
143</path>
144<path id="Line 1" d="M32 38H96" stroke="black" stroke-width="4" stroke-miterlimit="10" stroke-linecap="round">
145<animateTransform attributeName="transform" type="translate" values="0 0;10 0;0 0" dur="3s" begin="0s" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1; .42 0 .58 1"/>
146</path>
147</g>
148</svg>`,
149};
150types/index.d.ts 8 lines1export type Reading = { tokens: number; window: number; percent: number; threshold: number };
2
3declare module "claude-code" {
4 interface PluginState {
5 "otto-hud": { readings: Reading[] };
6 }
7}
8