SLOPSHOPPER

spell-bar

An animated effort bar for Claude Code: Clawd casts Avada Kedavra at six ranks, one per effort level from low to ultracode, with Voldemort looming behind.

newpanebandcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spell-bar
│ ┃ Spellbook ✕ › fix the failing auth test and add an audit log call │ ┃ Avada Kedavra I · effort low · now │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Avada Kedavra II · effort medium ⎿ 3 pass, 1 fail │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Avada Kedavra III · effort high › /spell │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ spell-bar: Avada Kedavra ranks, by the main loop's effort: │ ┃ ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ spell-bar: › I Avada Kedavra effort low │ ┃ ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ spell-bar: II Avada Kedavra effort medium │ ┃ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ spell-bar: III Avada Kedavra effort high │ ┃ ⎿ spell-bar: IV Avada Kedavra effort xhigh │ ┃ Avada Kedavra IV · effort xhigh ⎿ spell-bar: V Avada Kedavra effort max │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ │ ┃ Avada Kedavra V · effort max ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Avada Kedavra I ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ effort not seen yet ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ✦✧✧✧✧✧ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Avada Kedavra I ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ effort not seen yet ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ✦✧✧✧✧✧ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Pane · Spellbook
Avada Kedavra I · effort low · now ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Avada Kedavra II · effort medium ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Avada Kedavra III · effort high ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Avada Kedavra IV · effort xhigh ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Avada Kedavra V · effort max ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Avada Kedavra VI · ultracode ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▐▛███▜▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▝▜█████▛▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▘▘▀▝▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
README

Spell Bar ⚡ — an Avada Kedavra effort bar for Claude Code

English · 中文

A Claude Code mod that puts an animated bar above the prompt. Clawd raises a wand and casts the Killing Curse at the strength of the session's effort level. There is one rank for each position of the effort picker, and every rank adds layers on top of the one below it. Voldemort watches from the smoke behind. At the top rank, Clawd becomes the Dark Lord.

All six ranks, animated

RankEffortWhat this rank adds
IlowGreen sparks spit from the wand and the curse fizzles. Voldemort is little more than two red eyes.
IImediumA thin, crooked bolt that reaches halfway.
IIIhighThe bolt reaches its target, with shockwave rings, green smoke and a flash. Voldemort's face lights up in the flashes.
IVxhighArcs coil around the bolt, Clawd's eyes turn green, and the screen shakes.
VmaxA thicker, pulsing bolt, forked lightning from the sky, and stronger flash and shake.
VIultracodeThe jet holds for most of the cast, strobing lightning, a burning frame, and Clawd turned Dark Lord: pale green skin, a black robe and Voldemort's bone-white wand.

In every rank the bolt gets thicker, more crooked and more forked, and the flash, shake and hold last longer.

Still frames of the six ranks

It runs in the terminal too, drawn in the half-block pixels of Clawd's own logo:

The terminal version

Features

  • Follows the effort picker live. Switching effort in the Claude desktop app updates the bar within about 1.5 s, without waiting for the next message. See how it works.
  • Tells max from ultracode. Neither of them shows up in the effort field that plugins can read.
  • Desktop and terminal. The desktop app gets an animated SVG banner sized to the band. The terminal gets a 12 fps pixel animation drawn with Raster.
  • Commands:
  • /spell: list the ranks and the current state.
  • /spell 1…/spell 6, /spell IV, /spell max, /spell ultracode: pin a rank so you can look at it.
  • /spell auto: follow the effort again.
  • /spell off and /spell on: hide or show the bar.
  • /spellbook: open a pane with all six ranks playing side by side.

Install

You need a Claude Code build with function-hook mods (2.1.286 or newer), in the desktop app's Code tab or in the terminal.

From the marketplace:

/plugin marketplace add powerofjinbo/claude-code-spell-bar
/plugin install spell-bar@claude-code-spell-bar
/reload-plugins

From a clone, loaded into every session (edits to the clone take effect in new sessions):

git clone https://github.com/powerofjinbo/claude-code-spell-bar.git ~/Documents/GitHub/claude-code-spell-bar

Then add this to the env block of ~/.claude/settings.json. Paths are separated by : on macOS and Linux. The variable is only read from your user settings, never from a project's.

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/Documents/GitHub/claude-code-spell-bar/plugins/spell-bar"
  }
}

For one session only: claude --plugin-dir ./plugins/spell-bar

How the effort is detected

The plugin API has no effort getter and no event for an effort change, so the bar puts the answer together from two sources:

  1. The flag settings, polled every 1.5 s. The desktop app applies the picker through the apply_flag_settings control request, which lands in the session's flag settings, and $.settings.read({ source: 'flag' }) can read those.
  2. low, medium, high and xhigh read as themselves.
  3. The settings schema only accepts those four, so max reads as no level at all. It is recognised by a level disappearing right after the picker had set one.
  4. ultracode is its own boolean. The desktop sends it as { effortLevel: "xhigh", ultracode: true }.
  5. Each model request (turn.step). A request carries the effort it was sent with. This fills in what the flags cannot tell, such as max or the effort a session was launched with.

There is one limitation. A new session receives its starting effort as a launch flag, not through the flag settings. If the session's flags already hold ultracode: false when it starts and the first pick is max, nothing changes that the plugin can see, so the bar catches up at the next message.

Development

plugins/spell-bar/
  .claude-plugin/plugin.json   manifest
  hooks/hooks.json             -> register.tsx
  hooks/register.tsx           hooks: band, pane, commands, effort tracking
  hooks/spells.ts              the six ranks, effort -> rank, picker reading
  hooks/scene.ts               the animated SVG banner (desktop)
  hooks/pixels.ts              the half-block pixel animation (terminal)
  types/index.d.ts             the $.state contract
  tests/                       `claude plugin test` suite
scripts/render-demo.mjs        regenerates docs/demo.gif and docs/terminal.png
claude plugin validate plugins/spell-bar   # what the engine will load and refuse
claude plugin test plugins/spell-bar       # run the tests against the engine
claude --plugin-dir plugins/spell-bar      # try it; saving a file hot-reloads it
node scripts/render-demo.mjs               # redraw the demo images (Chrome, Python + Pillow, macOS fonts)

To have an editor type-check the hooks, run /plugin-types in a session. It writes the API declarations into .claude-plugin/types/ (git-ignored), which tsconfig.json points at.

Disclaimer

This is an unofficial fan project. Harry Potter, Voldemort and the spell names belong to J.K. Rowling and Warner Bros.; Clawd belongs to Anthropic. The project is not affiliated with or endorsed by any of them. All artwork was drawn from scratch in SVG and pixel code for this project.

License

MIT


中文

一个 Claude Code mod,在输入框上方放一条动画 bar:Clawd 举起魔杖施放阿瓦达索命,威力随当前会话的 effort 档位变化。effort 选择器的每个位置对应一档,每档都在下一档的基础上叠加新效果,伏地魔在背景的烟雾里注视着。到最高档 ultracode,Clawd 会变成伏地魔。

档effort新叠加的效果
Ⅰlow杖尖冒出绿色火星,咒语哑火;伏地魔只看得到一双红眼
Ⅱmedium一道细细的锯齿闪电,只射出一半
Ⅲhigh闪电射满全程,加冲击环、绿色烟雾和全屏闪光,伏地魔的脸在闪光中显形
Ⅳxhigh光束外缠绕电弧,Clawd 眼睛变绿,画面震动
Ⅴmax闪电更粗并脉动,天降分叉落雷,闪光和震动更强
Ⅵultracode光束持续轰击,多道落雷频闪,边框燃起绿光,Clawd 变身伏地魔:苍白泛绿的脸、黑袍、骨白魔杖

功能:

  • 实时跟随 effort:在桌面版切换 effort,约 1.5 秒内 bar 就会跟着变,不用等发消息。
  • 区分 max 和 ultracode:插件读到的 effort 字段里这两档都没有,靠另外的信号区分。
  • 桌面版和终端都支持:桌面版是 SVG 动画,终端版是 12 fps 的像素动画。
  • 命令:
  • /spell:列出各档和当前状态;
  • /spell 1~6 或 /spell max:固定显示某一档;
  • /spell auto:恢复跟随 effort;
  • /spell off / /spell on:隐藏或显示 bar;
  • /spellbook:打开面板,6 档同时播放。

安装:

  • 用上面英文部分的 marketplace 命令;
  • 或者 clone 仓库,在 ~/.claude/settings.json 的 env 里设置 CLAUDE_CODE_PLUGIN_DIRS,指向 plugins/spell-bar。之后每个新会话都会自动加载。

原理和局限:

  • 桌面版切换 effort 时,会把档位写进会话的 flag 设置,插件每 1.5 秒读一次。
  • low 到 xhigh 能直接读到。max 会被设置格式丢掉,只能通过"刚才的档位消失了"推断出来。ultracode 是一个单独的布尔开关,可以直接读。
  • 唯一的死角:新会话启动时 flag 里如果已经带着 ultracode: false,第一次切换又正好选 max,插件看不到变化,要等下一条消息才更新。

免责声明:本项目是非官方的同人作品。哈利波特、伏地魔和咒语名称归 J.K. Rowling 与华纳兄弟所有,Clawd 归 Anthropic 所有,本项目与上述各方均无关联。所有图像都是用 SVG 和像素代码从零绘制的。采用 MIT 许可证。

Source 5 files
hooks/register.tsx 364 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementConstructor, EngineInterface, Register, TextProps } from 'claude-code'
3
4import type { Cast } from '../types'
5import { frame, RASTER_ROWS } from './pixels.ts'
6import { scene, sceneBox } from './scene.ts'
7import { levelOf, pickerEffort, spellAt, SPELLS, tierNamed, tierOf } from './spells.ts'
8
9const cast = atom({ plugin: 'spell-bar', key: 'cast' } as const, null)
10const preview = atom({ plugin: 'spell-bar', key: 'preview' } as const, null)
11const isHidden = atom({ plugin: 'spell-bar', key: 'isHidden' } as const, false)
12
13const BOOK = 'spellbook'
14const FPS = 12
15// The desktop measures the band in cells of its code font, about 7.9 CSS pixels
16// each; a little under keeps the banner inside the band. An Svg given no size
17// draws in a 300x150 frame there, so the banner always says its size.
18const PIXELS_PER_COLUMN = 7.8
19// A blit is refused until the engine commits the tree a render hook returned
20// (a Raster drawn for the first time or at a new size), so one refusal does not
21// drop a mount; two seconds of nothing but refusals (a closed pane) does.
22const MISSES = FPS * 2
23
24type Mount = { requestId: string; key: string; tier: number; columns: number; rows: number; misses: number }
25
26function describe(seen: Cast | null): string {
27  if (seen === null) {
28    return 'effort not seen yet'
29  }
30
31  const level =
32    seen.effort === null ? 'no effort setting' : seen.effort === 'ultracode' ? 'ultracode' : `effort ${seen.effort}`
33
34  return `${level} · ${seen.model}`
35}
36
37async function settled<T>(work: Promise<T>): Promise<T | undefined> {
38  try {
39    return await work
40  } catch {
41    return undefined
42  }
43}
44
45// The flag settings, where the desktop's effort picker lands as it moves;
46// undefined when they cannot be read, which says nothing about the picker
47// (`{}` can be a max pick, a refused read is not).
48async function readFlag($: EngineInterface): Promise<Readonly<Record<string, unknown>> | undefined> {
49  return settled($.settings.read({ source: 'flag' }))
50}
51
52async function setEffort($: EngineInterface, effort: string) {
53  const seen = await read($, cast)
54
55  if (seen?.effort === effort) {
56    return
57  }
58
59  const model = await $.session.model()
60  await update($, cast, () => ({ effort, model, source: 'live' }))
61}
62
63async function status($: EngineInterface): Promise<string> {
64  const seen = await read($, cast)
65  const pinned = await read($, preview)
66  const tier = pinned ?? tierOf(seen?.effort ?? null)
67  const rows = SPELLS.map(
68    (spell, i) => `${i === tier ? '›' : ' '} ${spell.rank.padEnd(3)} ${spell.name} ${levelOf(spell)}`,
69  )
70
71  return [
72    "Avada Kedavra ranks, by the main loop's effort:",
73    ...rows,
74    pinned === null ? `Now: ${describe(seen)}.` : `Pinned to rank ${spellAt(pinned).rank}.`,
75    '/spell <1-6|I-VI|low…ultracode> pins a rank · /spell auto follows effort · /spell off hides · /spellbook shows all six',
76  ].join('\n')
77}
78
79function pips(Text: ElementConstructor<TextProps>, tier: number) {
80  return SPELLS.map((spell, i) => (
81    <Text color={i <= tier ? spell.hex : '#4a4a4a'}>{i <= tier ? '✦' : '✧'}</Text>
82  ))
83}
84
85export const register: Register = on => {
86  // The terminal's Rasters on screen, repainted by the clock between redraws.
87  const mounts = new Map<string, Mount>()
88  let ticks = 0
89
90  const remount = (requestId: string, next: Array<Omit<Mount, 'misses'>>) => {
91    for (const [id, mount] of mounts) {
92      if (mount.requestId === requestId) {
93        mounts.delete(id)
94      }
95    }
96
97    for (const mount of next) {
98      mounts.set(`${mount.requestId}/${mount.key}`, { ...mount, misses: 0 })
99    }
100  }
101
102  on('session.start', async ($, e, next) => {
103    await $.command.register({
104      name: 'spell',
105      description: 'Spell bar: list the ranks, pin one, follow the effort again, or hide it',
106      argumentHint: '[1-6 | I-VI | low…ultracode | auto | on | off]',
107    })
108    await $.command.register({
109      name: 'spellbook',
110      description: 'Open all six ranks of the curse in a pane',
111    })
112
113    // The picker moves the flag settings at once; a request says the effort
114    // only when it is sent. Poll the flags and act on a change. `wasSet` says
115    // the flags last showed a level the picker set, so that a level vanishing
116    // from them (max, which the schema drops) can be read as max. Every pick
117    // also carries the ultracode key, so `ultracode: false` first showing up
118    // with no level is a max pick too, the first pick of a session included.
119    // A refused read at start leaves no starting point; the first read that
120    // succeeds becomes it, so unchanged startup flags are never taken for a pick.
121    const flag = await readFlag($)
122    const first = flag === undefined ? undefined : pickerEffort(flag, false)
123    let lastFlag = flag === undefined ? undefined : JSON.stringify(flag)
124    let lastUltracode = flag?.['ultracode']
125    let wasSet = first !== undefined
126
127    if (first !== undefined) {
128      await setEffort($, first)
129    }
130
131    $.clock.every(1500, () => {
132      void readFlag($).then(async now => {
133        if (now === undefined) {
134          return
135        }
136
137        const json = JSON.stringify(now)
138
139        if (json === lastFlag) {
140          return
141        }
142
143        if (lastFlag === undefined) {
144          lastFlag = json
145          lastUltracode = now['ultracode']
146          const start = pickerEffort(now, false)
147          wasSet = start !== undefined
148
149          if (start !== undefined) {
150            await setEffort($, start)
151          }
152
153          return
154        }
155
156        lastFlag = json
157        const isPick = now['ultracode'] === false && lastUltracode !== false
158        lastUltracode = now['ultracode']
159        const effort = pickerEffort(now, wasSet || isPick)
160
161        if (effort !== undefined) {
162          wasSet = effort !== 'max'
163          await setEffort($, effort)
164        }
165      })
166    })
167
168    if ((await $.session.surfaces()).includes('terminal')) {
169      $.clock.every(Math.round(1000 / FPS), () => {
170        ticks += 1
171
172        for (const [id, mount] of mounts) {
173          const cells = frame(mount.tier, mount.columns, mount.rows, ticks / FPS)
174          void $.ui.blit({ requestId: mount.requestId, key: mount.key, cells }).then(result => {
175            if (mounts.get(id) !== mount) {
176              return
177            }
178
179            if (result.deny === undefined) {
180              mount.misses = 0
181            } else if (++mount.misses > MISSES) {
182              mounts.delete(id)
183            }
184          })
185        }
186      })
187    }
188
189    return next(e)
190  })
191
192  // Every model request of the main loop says which effort it asks for.
193  on('turn.step', async function* ($, e, next) {
194    if (e.agentId === undefined) {
195      // The request says what was sent: none for a model that takes no effort
196      // setting, and a level after the engine's own downgrades. The picker only
197      // tells ultracode apart, which goes out as a request at xhigh or max.
198      // A refused read keeps what is already shown.
199      const step = e.effort === undefined ? null : String(e.effort)
200      const seen = await read($, cast)
201      const isHigh = step === 'xhigh' || step === 'max'
202      const flags = isHigh ? await readFlag($) : {}
203      const isUltra = isHigh && (flags === undefined ? seen?.effort === 'ultracode' : pickerEffort(flags, false) === 'ultracode')
204      const effort = isUltra ? 'ultracode' : step
205
206      if (seen === null || seen.effort !== effort || seen.model !== e.model || seen.source !== 'turn') {
207        await update($, cast, () => ({ effort, model: e.model, source: 'turn' }))
208      }
209    }
210
211    return yield* next(e)
212  })
213
214  on('command.run', { command: 'spell' }, async ($, e) => {
215    const arg = e.args.trim().toLowerCase()
216
217    if (arg === '') {
218      return { text: await status($) }
219    }
220
221    if (arg === 'off' || arg === 'hide') {
222      await update($, isHidden, () => true)
223
224      return { text: 'Spell bar hidden; /spell on brings it back.' }
225    }
226
227    if (arg === 'on' || arg === 'show') {
228      await update($, isHidden, () => false)
229
230      return { text: 'Spell bar shown.' }
231    }
232
233    if (arg === 'auto') {
234      await update($, preview, () => null)
235      await update($, isHidden, () => false)
236
237      return { text: 'Spell bar follows the effort again.' }
238    }
239
240    const tier = tierNamed(arg)
241
242    if (tier === null) {
243      return { text: `No rank "${arg}". Try 1-6, I-VI, an effort level, auto, on or off.` }
244    }
245
246    await update($, preview, () => tier)
247    await update($, isHidden, () => false)
248
249    return { text: `Pinned rank ${spellAt(tier).rank}; /spell auto follows the effort again.` }
250  })
251
252  on('command.run', { command: 'spellbook' }, async $ => {
253    const opened = await $.ui.open({ id: BOOK, title: 'Spellbook' })
254
255    return { text: opened.isPlaced ? 'Spellbook open.' : `The spellbook waits for room: ${opened.reason}` }
256  })
257
258  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259    if (e.props.hasSurvey || (await read($, isHidden))) {
260      remount(e.requestId, [])
261
262      return next(e)
263    }
264
265    const seen = await read($, cast)
266    const pinned = await read($, preview)
267    const tier = pinned ?? tierOf(seen?.effort ?? null)
268    const spell = spellAt(tier)
269    const detail = pinned === null ? describe(seen) : 'pinned · /spell auto follows effort'
270
271    if (e.surface === 'terminal') {
272      const { Box, Text, Raster } = $.ui.resolve(e)
273      const isWide = e.props.bodyColumns >= 72
274      const columns = isWide
275        ? Math.min(56, e.props.bodyColumns - 38)
276        : Math.max(16, Math.min(56, e.props.bodyColumns - 2))
277      remount(e.requestId, [{ requestId: e.requestId, key: 'spell', tier, columns, rows: RASTER_ROWS }])
278
279      return (
280        <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 2 : 0}>
281          <Raster key="spell" columns={columns} rows={RASTER_ROWS} cells={frame(tier, columns, RASTER_ROWS, ticks / FPS)} />
282          <Box flexDirection="column">
283            <Text color={spell.hex} bold>
284              {spell.name} {spell.rank}
285            </Text>
286            <Text dimColor wrap="truncate-end">
287              {detail}
288            </Text>
289            <Text>{pips(Text, tier)}</Text>
290          </Box>
291        </Box>
292      )
293    }
294
295    const { Box, Svg } = $.ui.resolve(e)
296    const room = e.props.bodyColumns * PIXELS_PER_COLUMN
297    const box = sceneBox(room)
298
299    return (
300      <Box>
301        <Svg
302          source={scene(tier, { width: room, detail })}
303          alt={`Clawd casts ${spell.name} ${spell.rank} (${detail})`}
304          width={box.width}
305          height={box.height}
306          isInteractive
307        />
308      </Box>
309    )
310  })
311
312  on('ui.render', { component: 'Pane', requestId: BOOK }, async ($, e) => {
313    const seen = await read($, cast)
314    const pinned = await read($, preview)
315    const current = pinned ?? tierOf(seen?.effort ?? null)
316    const caption = (tier: number) => `${levelOf(spellAt(tier))}${tier === current ? ' · now' : ''}`
317
318    if (e.surface === 'terminal') {
319      const { Box, Text, Raster } = $.ui.resolve(e)
320      const columns = Math.max(16, Math.min(60, e.props.bodyColumns - 2))
321      remount(
322        e.requestId,
323        SPELLS.map((_, tier) => ({ requestId: e.requestId, key: `tier-${tier}`, tier, columns, rows: RASTER_ROWS })),
324      )
325
326      return (
327        <Box flexDirection="column" gap={1}>
328          {SPELLS.map((spell, tier) => (
329            <Box flexDirection="column">
330              <Text color={spell.hex} bold={tier === current}>
331                {spell.name} {spell.rank} <Text dimColor>· {caption(tier)}</Text>
332              </Text>
333              <Raster
334                key={`tier-${tier}`}
335                columns={columns}
336                rows={RASTER_ROWS}
337                cells={frame(tier, columns, RASTER_ROWS, ticks / FPS)}
338              />
339            </Box>
340          ))}
341        </Box>
342      )
343    }
344
345    const { Box, Svg } = $.ui.resolve(e)
346    const room = e.props.bodyColumns * PIXELS_PER_COLUMN
347    const box = sceneBox(room)
348
349    return (
350      <Box flexDirection="column" gap={1}>
351        {SPELLS.map((spell, tier) => (
352          <Svg
353            source={scene(tier, { width: room, detail: caption(tier) })}
354            alt={`Clawd casts ${spell.name} ${spell.rank} (${caption(tier)})`}
355            width={box.width}
356            height={box.height}
357            isInteractive
358          />
359        ))}
360      </Box>
361    )
362  })
363}
364
hooks/pixels.ts 301 lines
1// The terminal's band: the same casts as half-block pixels, two to a cell,
2// with Clawd drawn in the logo's own quadrant glyphs.
3import { seeded, spellAt } from './spells.ts'
4
5export const RASTER_ROWS = 4
6
7type RGB = [number, number, number]
8
9const LOGO = [' ▐▛███▜▌ ', '▝▜█████▛▘', '  ▘▘ ▝▝  ']
10const BODY: RGB = [217, 119, 87]
11const WOOD: RGB = [126, 80, 42]
12const BONE: RGB = [226, 219, 196]
13const WHITE: RGB = [255, 255, 255]
14
15// Wand pixels out from the hand, the last one the tip: at rest, raised, aimed.
16const POSES = {
17  rest: [[0, 0], [1, -1], [2, -1], [3, -2]],
18  up: [[0, -1], [0, -2], [1, -3], [1, -4]],
19  aim: [[0, 0], [1, 0], [2, -1], [3, -1]],
20} as const
21
22function rgb(hex: string): RGB {
23  const value = parseInt(hex.slice(1), 16)
24
25  return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
26}
27
28function mixed(a: RGB, b: RGB, k: number): RGB {
29  return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
30}
31
32function pack(c: RGB): number {
33  const byte = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
34
35  return (byte(c[0]) << 16) | (byte(c[1]) << 8) | byte(c[2])
36}
37
38// A cheap, stable noise for a position: the same pixel twinkles the same way.
39function hash(i: number): number {
40  const x = Math.sin(i * 12.9898) * 43758.5453
41
42  return x - Math.floor(x)
43}
44
45class Canvas {
46  width: number
47  height: number
48  data: Float32Array
49
50  constructor(width: number, height: number) {
51    this.width = width
52    this.height = height
53    this.data = new Float32Array(width * height * 3)
54  }
55
56  at(x: number, y: number): RGB {
57    const i = (y * this.width + x) * 3
58
59    return [this.data[i] ?? 0, this.data[i + 1] ?? 0, this.data[i + 2] ?? 0]
60  }
61
62  mix(x: number, y: number, c: RGB, k: number) {
63    const px = Math.round(x)
64    const py = Math.round(y)
65
66    if (px < 0 || py < 0 || px >= this.width || py >= this.height || k <= 0) {
67      return
68    }
69
70    const i = (py * this.width + px) * 3
71    const a = Math.min(1, k)
72
73    for (let j = 0; j < 3; j++) {
74      const now = this.data[i + j] ?? 0
75      this.data[i + j] = now + ((c[j] ?? 0) - now) * a
76    }
77  }
78
79  add(x: number, y: number, c: RGB, k: number) {
80    const px = Math.round(x)
81    const py = Math.round(y)
82
83    if (px < 0 || py < 0 || px >= this.width || py >= this.height || k <= 0) {
84      return
85    }
86
87    const i = (py * this.width + px) * 3
88
89    for (let j = 0; j < 3; j++) {
90      this.data[i + j] = (this.data[i + j] ?? 0) + (c[j] ?? 0) * k
91    }
92  }
93
94  glow(cx: number, cy: number, radius: number, c: RGB, strength: number) {
95    for (let y = Math.floor(cy - radius); y <= Math.ceil(cy + radius); y++) {
96      for (let x = Math.floor(cx - radius); x <= Math.ceil(cx + radius); x++) {
97        const d = Math.hypot(x - cx, y - cy)
98
99        if (d < radius) {
100          this.add(x, y, c, strength * (1 - d / radius) ** 2)
101        }
102      }
103    }
104  }
105
106  line(x0: number, y0: number, x1: number, y1: number, c: RGB, k: number) {
107    const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))))
108
109    for (let s = 0; s <= steps; s++) {
110      this.mix(x0 + ((x1 - x0) * s) / steps, y0 + ((y1 - y0) * s) / steps, c, k)
111    }
112  }
113
114  wash(c: RGB, k: number) {
115    for (let y = 0; y < this.height; y++) {
116      for (let x = 0; x < this.width; x++) {
117        this.mix(x, y, c, k)
118      }
119    }
120  }
121}
122
123const within = (p: number, from: number, to: number) => p >= from && p < to
124const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
125
126type Scene = { c: Canvas; p: number; time: number; tip: { x: number; y: number }; hex: RGB; core: RGB; glow: RGB }
127
128// The curse at rank `level` (0 for I): every rank keeps the layers below it.
129function avada({ c, p, time, tip, hex, core }: Scene, level: number): number {
130  const green: RGB = [15, 122, 56]
131
132  // Voldemort in the sky: a pale head and red eyes, plainer at every rank.
133  const vx = Math.round(tip.x + (c.width - tip.x) * 0.5)
134  const presence = 0.2 + 0.1 * level
135  for (let y = 0; y < 4; y++) {
136    for (let x = 0; x < 5; x++) {
137      if ((y === 0 || y === 3) && (x === 0 || x === 4)) continue
138      c.mix(vx + x, y, [170, 190, 175], presence)
139    }
140  }
141  for (let y = 4; y < c.height; y++) {
142    for (let x = -2 - (y - 4); x < 7 + (y - 4); x++) {
143      c.mix(vx + x, y, [4, 8, 5], 0.5 + 0.06 * level)
144    }
145  }
146  c.mix(vx + 1, 1, [255, 42, 42], 0.5 + 0.1 * level)
147  c.mix(vx + 3, 1, [255, 42, 42], 0.5 + 0.1 * level)
148
149  // III and up: smoke drifting through the sky.
150  if (level >= 2) {
151    for (let y = 0; y < c.height; y++) {
152      for (let x = 0; x < c.width; x++) {
153        if (Math.sin(x * 0.35 + time * 0.8) * Math.cos(y * 0.9 - time * 0.6) > 0.75 - 0.05 * level) {
154          c.add(x, y, green, 0.12 + 0.04 * level)
155        }
156      }
157    }
158  }
159
160  // I: sparks spit from the tip.
161  if (within(p, 0.19, 0.32)) {
162    const k = (p - 0.19) / 0.13
163    for (let i = 0; i < 3 + level; i++) {
164      const a = hash(i + 17) * Math.PI * 2
165      c.add(tip.x + Math.cos(a) * (1 + 4 * k), tip.y + Math.sin(a) * (1 + 2 * k), core, 1 - k)
166    }
167  }
168
169  const hold = [0.3, 0.4, 0.46, 0.52, 0.6, 0.86][level] ?? 0.5
170  let light = 0
171
172  // II: the jet, half way; full length from III, thicker with every rank.
173  if (level >= 1 && within(p, 0.2, hold + 0.04)) {
174    const reach = clamp01((p - 0.2) / 0.025) * (level === 1 ? 0.45 : 1)
175    const fade = p < hold ? 1 : 1 - (p - hold) / 0.04
176    const end = tip.x + (c.width - 1 - tip.x) * reach
177    const pulse = level >= 4 && Math.floor(time * 18) % 2 === 0 ? 1 : 0
178
179    for (let x = tip.x; x <= end; x++) {
180      if (level >= 3) {
181        c.mix(x, tip.y - 1, hex, (0.35 + 0.1 * level + 0.15 * pulse) * fade)
182        c.mix(x, tip.y + 1, hex, (0.35 + 0.1 * level + 0.15 * pulse) * fade)
183      }
184      const rushing = level >= 4 && (((x - time * 40) % 7) + 7) % 7 < 2
185      c.mix(x, tip.y, rushing ? WHITE : level >= 2 ? core : hex, fade)
186    }
187
188    c.glow(end, tip.y, 2 + level, hex, fade)
189    light = (0.2 + 0.1 * level) * fade
190  }
191
192  // IV and up: lightning around the jet; V and up: strikes from the sky.
193  if (level >= 3 && within(p, 0.21, hold)) {
194    const rand = seeded(Math.floor(time * 12) + 31)
195    for (let bolt = 0; bolt < level - 2; bolt++) {
196      let y = tip.y
197      for (let x = tip.x + 2; x < c.width - 2; x++) {
198        y = Math.max(0, Math.min(c.height - 1, y + Math.round(rand() * 2 - 1)))
199        if (rand() < 0.6) c.add(x, y, hex, 0.5)
200      }
201    }
202    if (level >= 4 && rand() < (level === 5 ? 0.5 : 0.25)) {
203      let x = tip.x + 6 + Math.floor(rand() * (c.width - tip.x - 10))
204      for (let y = 0; y < c.height; y++) {
205        x += Math.round(rand() * 2 - 1)
206        c.mix(x, y, core, 0.9)
207      }
208    }
209  }
210
211  // The flash: brighter with every rank, a strobe at VI.
212  const flash = [0.05, 0.12, 0.26, 0.42, 0.6, 0.8][level] ?? 0.4
213  if (level === 5 && within(p, 0.21, hold)) {
214    c.wash(hex, Math.floor(time * 10) % 2 === 0 ? flash * 0.6 : 0.08)
215  } else if (within(p, 0.21, 0.26)) {
216    c.wash(hex, flash * (1 - (p - 0.21) / 0.05))
217  }
218
219  return light
220}
221
222function base64(bytes: Uint8Array): string {
223  const native = (bytes as unknown as { toBase64?: () => string }).toBase64
224
225  if (typeof native === 'function') {
226    return native.call(bytes)
227  }
228
229  let text = ''
230
231  for (let i = 0; i < bytes.length; i++) {
232    text += String.fromCharCode(bytes[i] ?? 0)
233  }
234
235  return btoa(text)
236}
237
238// One frame of `tier`'s cast at `time` seconds, packed as Raster cells.
239export function frame(tier: number, columns: number, rows: number, time: number): string {
240  const spell = spellAt(tier)
241  const height = rows * 2
242  const c = new Canvas(columns, height)
243  const p = (time % spell.seconds) / spell.seconds
244  const top = rows - 3
245  const hand = { x: 10, y: (top + 1) * 2 }
246  const sky0 = rgb(spell.sky[0])
247  const sky1 = rgb(spell.sky[1])
248
249  for (let y = 0; y < height; y++) {
250    const shade = mixed(sky0, sky1, y / Math.max(1, height - 1))
251    for (let x = 0; x < columns; x++) {
252      c.mix(x, y, shade, 1)
253    }
254  }
255
256  const aim = POSES.aim[POSES.aim.length - 1] ?? [0, 0]
257  const tip = { x: hand.x + aim[0], y: hand.y + aim[1] }
258  const hex = rgb(spell.hex)
259  const core = rgb(spell.core)
260  const glow = rgb(spell.glow)
261  const light = avada({ c, p, time, tip, hex, core, glow }, Math.max(0, Math.min(5, tier)))
262
263  // Rank VI is Clawd turned Dark Lord: pale green head, black robe, red eyes,
264  // and Voldemort's bone-white wand.
265  const isDark = tier >= 5
266  const pose = p < 0.1 ? POSES.rest : p < 0.19 ? POSES.up : p < 0.88 ? POSES.aim : POSES.rest
267  pose.forEach(([dx, dy], i) => {
268    c.mix(hand.x + dx, hand.y + dy, i === pose.length - 1 ? core : isDark ? BONE : WOOD, 1)
269  })
270  const end = pose[pose.length - 1] ?? [0, 0]
271  const charge = p < 0.19 ? 0.3 + p * 3.5 : p < 0.3 ? 1 : 0.35
272  c.glow(hand.x + end[0], hand.y + end[1], 3, hex, 0.6 * charge)
273
274  const body = pack(isDark ? mixed([200, 240, 210], hex, light * 0.3) : mixed(BODY, hex, light * 0.3))
275  const robe = pack([30, 51, 38])
276  const words = new Uint32Array(columns * rows * 3)
277
278  for (let r = 0; r < rows; r++) {
279    for (let x = 0; x < columns; x++) {
280      const up = c.at(x, 2 * r)
281      const down = c.at(x, 2 * r + 1)
282      const glyph = LOGO[r - top]?.[x - 1] ?? ' '
283      const i = (r * columns + x) * 3
284
285      if (glyph === ' ') {
286        words[i] = 0x2580
287        words[i + 1] = pack(up)
288        words[i + 2] = pack(down)
289      } else {
290        const row = r - top
291        const isEye = row === 0 && (x === 3 || x === 7)
292        words[i] = glyph.codePointAt(0) ?? 0x2588
293        words[i + 1] = isDark && row > 0 ? robe : body
294        words[i + 2] = isDark && isEye ? pack([255, 42, 42]) : pack(mixed(up, down, 0.5))
295      }
296    }
297  }
298
299  return base64(new Uint8Array(words.buffer))
300}
301
hooks/scene.ts 616 lines
1// The desktop's banner: an animated SVG of Clawd casting the Killing Curse at
2// one of six ranks. Each rank keeps every layer of the rank below and adds
3// its own, and everything that can grow (reach, width, flash, shake, sparks,
4// how long the jet holds) grows with the rank. It all moves by CSS and SMIL
5// inside the SVG, so nothing redraws while it plays.
6import { seeded, spellAt, SPELLS, type Spell } from './spells.ts'
7
8export const SCENE_HEIGHT = 128
9
10type Point = { x: number; y: number }
11
12type Stage = {
13  spell: Spell
14  // 0 for rank I up to 5 for rank VI.
15  level: number
16  W: number
17  // One cast, in seconds.
18  D: number
19  // The curse's room: from the wand tip to short of the title.
20  zx0: number
21  zx1: number
22  rand: () => number
23}
24
25type Layer = {
26  css: string
27  defs: string
28  back: string
29  front: string
30  flash: string
31  eyes: boolean
32  shake: boolean
33}
34
35const H = SCENE_HEIGHT
36
37// Clawd as the logo draws him (▐▛███▜▌ / ▝▜█████▛▘ / ▘▘ ▝▝): quadrant pixels,
38// twice as tall as wide.
39const U = 6
40const V = 12
41const X0 = 26
42const GROUND = 118
43const Y0 = GROUND - 5 * V
44const PIVOT: Point = { x: X0 + 16 * U - 2, y: Y0 + 2.5 * V }
45const WAND = 50
46const AIM = (-14 * Math.PI) / 180
47// Where the wand tip points while the curse leaves it.
48const TIP: Point = {
49  x: PIVOT.x + WAND * Math.cos(AIM),
50  y: PIVOT.y + WAND * Math.sin(AIM),
51}
52
53// Voldemort as the films show him, drawn about a head 58 tall: the bald egg of
54// a skull, a deep V of robe, the black cloth going to smoke at the edges.
55const HEAD =
56  'M0 -28C14 -28 21 -18 21 -4C21 8 15 18 9 25C6 28 3 30 0 30C-3 30 -6 28 -9 25C-15 18 -21 8 -21 -4C-21 -18 -14 -28 0 -28Z'
57const CHEST = 'M-7 22L-9 40L0 64L9 40L7 22Z'
58const ROBE = 'M-8 34C-18 38 -34 42 -48 52C-62 63 -70 90 -78 150L78 150C70 90 62 63 48 52C34 42 18 38 8 34L0 64Z'
59
60// Per rank, I to VI.
61const REACH = [0, 0.45, 1, 1, 1, 1]
62const BEAM = [0, 2.4, 5, 8, 11, 15]
63const HOLD = [0.3, 0.4, 0.46, 0.52, 0.6, 0.86]
64const FLASH = [0.05, 0.12, 0.26, 0.42, 0.6, 0.82]
65const SHAKE = [0, 0, 0, 1.2, 2.2, 3.4]
66
67const n = (value: number) => Math.round(value * 10) / 10
68
69const times = <T>(count: number, make: (index: number) => T): T[] =>
70  Array.from({ length: count }, (_, index) => make(index))
71
72const pct = (fraction: number) => `${Math.round(fraction * 10000) / 100}%`
73
74const delay = (seconds: number) => `animation-delay:${seconds.toFixed(3)}s`
75
76// Shifts an animation so its 0% keyframe lands at fraction `q` of the cast.
77const at = (q: number, D: number) => delay(-(1 - q) * D)
78
79const pick = (list: readonly number[], level: number) => list[level] ?? list[list.length - 1] ?? 0
80
81const escape = (text: string) =>
82  text
83    .replace(/&/g, '&amp;')
84    .replace(/</g, '&lt;')
85    .replace(/>/g, '&gt;')
86    .replace(/"/g, '&quot;')
87
88const textRoom = (W: number) => Math.round(Math.max(210, Math.min(310, W * 0.36)))
89
90// The banner's width for the room it is given, in CSS pixels.
91export function fitWidth(pixels: number): number {
92  return Math.round(Math.max(420, Math.min(2400, pixels)))
93}
94
95// The banner's drawn box for `pixels` of room: the scene laid out at
96// fitWidth(pixels), scaled down (viewBox kept) when the room is narrower than
97// the scene's floor, so it never overflows its slot.
98export function sceneBox(pixels: number): { width: number; height: number } {
99  const W = fitWidth(pixels)
100  const k = Math.min(1, pixels / W)
101
102  return { width: Math.max(1, Math.round(W * k)), height: Math.max(1, Math.round(SCENE_HEIGHT * k)) }
103}
104
105function sparkle(x: number, y: number, r: number): string {
106  const c = `${n(x)} ${n(y)}`
107
108  return `M${n(x)} ${n(y - r)}Q${c} ${n(x + r)} ${n(y)}Q${c} ${n(x)} ${n(y + r)}Q${c} ${n(x - r)} ${n(y)}Q${c} ${n(x)} ${n(y - r)}Z`
109}
110
111// A crooked line from a to b, still at both ends: lightning.
112function jagged(a: Point, b: Point, segments: number, amplitude: number, rand: () => number): Point[] {
113  const dx = b.x - a.x
114  const dy = b.y - a.y
115  const length = Math.hypot(dx, dy) || 1
116
117  return times(segments + 1, i => {
118    const t = i / segments
119    const swing = (rand() - 0.5) * 2 * amplitude * Math.sin(Math.PI * t)
120
121    return { x: a.x + dx * t - (dy / length) * swing, y: a.y + dy * t + (dx / length) * swing }
122  })
123}
124
125const poly = (points: Point[]) => `M${points.map(p => `${n(p.x)} ${n(p.y)}`).join('L')}`
126
127// Keyframes that flicker between `high` and `low` from `from` to `to`, dark outside.
128function flicker(name: string, from: number, to: number, step: number, high: number, low: number): string {
129  const frames = [`0%,${pct(from - 0.01)}{opacity:0}`]
130  let isHigh = true
131
132  for (let p = from; p < to; p += step) {
133    frames.push(`${pct(p)}{opacity:${isHigh ? high : low}}`)
134    isHigh = !isHigh
135  }
136
137  frames.push(`${pct(to + 0.02)},100%{opacity:0}`)
138
139  return `@keyframes ${name}{${frames.join('')}}`
140}
141
142// A screen shake: still until `from`, a jitter until `to`, still again.
143function shake(from: number, to: number, step: number, amplitude: number, rand: () => number): string {
144  const frames = [`0%,${pct(from - 0.002)}{transform:translate(0,0)}`]
145
146  for (let p = from; p < to; p += step) {
147    const x = n((rand() - 0.5) * 2 * amplitude)
148    const y = n((rand() - 0.5) * 1.4 * amplitude)
149    frames.push(`${pct(p)}{transform:translate(${x}px,${y}px)}`)
150  }
151
152  frames.push(`${pct(to)},100%{transform:translate(0,0)}`)
153
154  return `.shake{animation:shake var(--cast) linear infinite}@keyframes shake{${frames.join('')}}`
155}
156
157function clawdRects(x: number, y: number, u: number, v: number): string {
158  // [column, row, width, height] in quadrant pixels: body, arms, four legs.
159  const runs = [
160    [2, 0, 12, 4],
161    [0, 2, 16, 1],
162    [3, 4, 1, 1],
163    [5, 4, 1, 1],
164    [10, 4, 1, 1],
165    [12, 4, 1, 1],
166  ] as const
167
168  return runs
169    .map(
170      ([c, r, w, h]) =>
171        `<rect x="${n(x + c * u)}" y="${n(y + r * v)}" width="${n(w * u)}" height="${n(h * v)}"/>`,
172    )
173    .join('')
174}
175
176function clawdEyes(x: number, y: number, u: number, v: number): string {
177  return [4, 11]
178    .map(c => `<rect x="${n(x + c * u)}" y="${n(y + v)}" width="${n(u)}" height="${n(v)}"/>`)
179    .join('')
180}
181
182function wand(spell: Spell, isBone: boolean): string {
183  const shaft = isBone
184    ? `<path d="M-6 -2.6C-10.5 -3.4 -13.5 -0.4 -12.2 3.2C-11.4 5.2 -8.8 5.6 -8.2 3.6" fill="none" stroke="url(#bone)" stroke-width="2.6" stroke-linecap="round"/>
185<polygon points="-7,-2.8 12,-2.3 ${WAND},-0.8 ${WAND},0.8 12,2.3 -7,2.8" fill="url(#bone)"/>
186<g fill="#ddd5bf" stroke="#8d846c" stroke-width=".5"><ellipse cx="2.5" rx="2.4" ry="3.2"/><ellipse cx="10" rx="2" ry="2.8"/><ellipse cx="22" rx="1.4" ry="1.9"/></g>`
187    : `<polygon points="-6,-2.7 12,-2.3 ${WAND},-0.9 ${WAND},0.9 12,2.3 -6,2.7" fill="url(#wood)"/>
188<rect x="4" y="-3" width="1.6" height="6" fill="#2a160a"/><rect x="8.5" y="-3.1" width="2.2" height="6.2" fill="#2a160a"/>`
189
190  return `<g transform="translate(${n(PIVOT.x)} ${n(PIVOT.y)})"><g class="wand">
191${shaft}
192<g transform="translate(${WAND} 0)"><circle r="9" fill="url(#tipglow)" class="tip"/><circle r="2.1" fill="${spell.core}"/></g>
193</g></g>`
194}
195
196// Rank VI: Clawd turned Dark Lord. Pale green skin, a black robe with a deep V
197// over the body and legs, red slit eyes, snake slits for a nose, a thin
198// downturned mouth, smoke coming off the shoulders and Voldemort's own wand.
199function darkClawd(st: Stage): string {
200  const { spell, rand } = st
201  const px = (c: number) => n(X0 + c * U)
202  const py = (r: number) => n(Y0 + r * V)
203  const mid = X0 + 8 * U
204  const hemTop = Y0 + 4 * V
205  const teeth = times(7, i => {
206    const x = X0 + 1.4 * U + (i / 6) * 13.2 * U
207
208    return `${n(x)},${n(GROUND + (i % 2 === 0 ? 1 : -5))}`
209  }).reverse()
210  const hem = `${px(1.6)},${n(hemTop - 1)} ${px(14.4)},${n(hemTop - 1)} ${teeth.join(' ')}`
211  const robe = `<rect x="${px(2)}" y="${py(2)}" width="${12 * U}" height="${2 * V}"/>
212<rect x="${px(0)}" y="${py(2)}" width="${2 * U}" height="${V}"/><rect x="${px(14)}" y="${py(2)}" width="${2 * U}" height="${V}"/>
213<polygon points="${hem}"/>`
214  const smoke = times(6, () => {
215    const side = rand() < 0.5 ? -1 : 1
216    const seconds = 2.2 + rand() * 2
217
218    return `<ellipse cx="${n(mid + side * (20 + rand() * 30))}" cy="${n(Y0 + 2 * V + rand() * 2 * V)}" rx="${n(5 + rand() * 6)}" ry="${n(4 + rand() * 4)}" class="vsm" style="--x:${n(side * (8 + rand() * 14))}px;animation-duration:${n(seconds)}s;${delay(-rand() * seconds)}"/>`
219  }).join('')
220
221  return `<g class="hop">
222<g class="lit"><ellipse cx="${mid}" cy="${n(Y0 + 2.2 * V)}" rx="70" ry="44" fill="${spell.hex}" fill-opacity=".35" filter="url(#b8)"/></g>
223<ellipse cx="${mid}" cy="${n(Y0 + 2.2 * V)}" rx="58" ry="38" fill="${spell.hex}" fill-opacity=".14" filter="url(#b8)" class="aura"/>
224<g filter="url(#b4)">${smoke}</g>
225<g shape-rendering="crispEdges">
226<g fill="url(#vskin)">${clawdRects(X0, Y0, U, V)}</g>
227<g fill="url(#vcloak)" stroke="${spell.hex}" stroke-opacity=".55" stroke-width="1">${robe}</g>
228<polygon points="${n(mid - 11)},${py(2)} ${n(mid)},${n(Y0 + 2.75 * V)} ${n(mid + 11)},${py(2)}" fill="url(#vskin)"/>
229<path d="M${n(mid - 11)} ${py(2)}L${n(mid)} ${n(Y0 + 2.75 * V)}L${n(mid + 11)} ${py(2)}" stroke="#2c4a36" stroke-width="1.2" fill="none"/>
230<rect x="${px(15)}" y="${py(2)}" width="${U}" height="${V}" fill="url(#vskin)"/>
231<g fill="#050806" class="blink">${clawdEyes(X0, Y0, U, V)}</g>
232</g>
233<g fill="#ff2a2a" filter="url(#g2)" class="veye">${[4, 11].map(c => `<rect x="${n(X0 + c * U + U * 0.33)}" y="${n(Y0 + V * 0.18)}" width="${n(U * 0.34)}" height="${n(V * 0.64)}" rx=".6"/>`).join('')}</g>
234<path d="M${n(mid - 3)} ${n(Y0 + V * 1.2)}l-1 4.2M${n(mid + 3)} ${n(Y0 + V * 1.2)}l1 4.2" stroke="#1d3a26" stroke-width="1.5" stroke-linecap="round"/>
235<path d="M${n(mid - 9)} ${n(Y0 + V * 1.88)}Q${mid} ${n(Y0 + V * 1.72)} ${n(mid + 9)} ${n(Y0 + V * 1.88)}" stroke="#1d3a26" stroke-width="1.3" fill="none" stroke-linecap="round"/>
236${wand(spell, true)}</g>`
237}
238
239function clawd(st: Stage, eyes: boolean): string {
240  if (st.level === 5) {
241    return darkClawd(st)
242  }
243
244  const body = clawdRects(X0, Y0, U, V)
245  const eyeRects = clawdEyes(X0, Y0, U, V)
246  const glowingEyes = eyes
247    ? `<g class="lit"><g fill="${st.spell.hex}" filter="url(#g3)">${eyeRects}</g></g>`
248    : ''
249
250  return `<g class="hop"><g shape-rendering="crispEdges">
251<g fill="#d97757">${body}</g>
252<g class="lit"><g fill="url(#rim)">${body}</g></g>
253<g fill="#1a1210" class="blink">${eyeRects}</g>
254${glowingEyes}
255</g>${wand(st.spell, false)}</g>`
256}
257
258function glowFilter(id: string, blur: number, W: number): string {
259  return `<filter id="${id}" filterUnits="userSpaceOnUse" x="${-W}" y="${-2 * H}" width="${3 * W}" height="${5 * H}"><feGaussianBlur stdDeviation="${blur}" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
260}
261
262function blurFilter(id: string, blur: number, W: number): string {
263  return `<filter id="${id}" filterUnits="userSpaceOnUse" x="${-W}" y="${-2 * H}" width="${3 * W}" height="${5 * H}"><feGaussianBlur stdDeviation="${blur}"/></filter>`
264}
265
266function commonDefs(st: Stage): string {
267  const { W, spell } = st
268
269  return [
270    `<clipPath id="card"><rect width="${W}" height="${H}" rx="12"/></clipPath>`,
271    `<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${spell.sky[0]}"/><stop offset="1" stop-color="${spell.sky[1]}"/></linearGradient>`,
272    `<linearGradient id="ground" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${spell.glow}" stop-opacity=".16"/><stop offset="1" stop-color="#000" stop-opacity=".35"/></linearGradient>`,
273    `<radialGradient id="vig" cx=".4" cy=".5" r=".75"><stop offset=".55" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".55"/></radialGradient>`,
274    `<linearGradient id="shade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset=".4" stop-color="#000" stop-opacity=".35"/><stop offset="1" stop-color="#000" stop-opacity=".5"/></linearGradient>`,
275    `<linearGradient id="wood" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#8f5e34"/><stop offset=".5" stop-color="#5b361a"/><stop offset="1" stop-color="#2c180a"/></linearGradient>`,
276    `<radialGradient id="tipglow"><stop offset="0" stop-color="${spell.core}"/><stop offset=".35" stop-color="${spell.hex}" stop-opacity=".85"/><stop offset="1" stop-color="${spell.glow}" stop-opacity="0"/></radialGradient>`,
277    `<radialGradient id="rim" gradientUnits="userSpaceOnUse" cx="${n(TIP.x)}" cy="${n(TIP.y)}" r="130"><stop offset="0" stop-color="${spell.core}" stop-opacity=".95"/><stop offset=".4" stop-color="${spell.hex}" stop-opacity=".5"/><stop offset="1" stop-color="${spell.glow}" stop-opacity="0"/></radialGradient>`,
278    `<linearGradient id="ttl" x1="0" y1="0" x2="0" y2="1"><stop offset=".15" stop-color="${spell.core}"/><stop offset="1" stop-color="${spell.hex}"/></linearGradient>`,
279    `<linearGradient id="bone" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3eedf"/><stop offset=".6" stop-color="#d2c8ad"/><stop offset="1" stop-color="#9c9278"/></linearGradient>`,
280    `<linearGradient id="vskin" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d4f5dc"/><stop offset="1" stop-color="#6fbf86"/></linearGradient>`,
281    `<linearGradient id="vcloak" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#24452f"/><stop offset=".5" stop-color="#11241a"/><stop offset="1" stop-color="#08120c"/></linearGradient>`,
282    `<radialGradient id="burst"><stop offset="0" stop-color="${spell.core}"/><stop offset=".3" stop-color="${spell.hex}" stop-opacity=".85"/><stop offset="1" stop-color="${spell.glow}" stop-opacity="0"/></radialGradient>`,
283    glowFilter('g2', 2, W),
284    glowFilter('g3', 3, W),
285    glowFilter('g4', 4, W),
286    blurFilter('b2', 2, W),
287    blurFilter('b4', 4, W),
288    blurFilter('b8', 8, W),
289  ].join('')
290}
291
292// The wand's swing, Clawd's hop and the tip's charge: raised by 16%, flicked
293// at 19%, aimed from 21.5% while the curse plays.
294function commonCss(st: Stage): string {
295  return `:root{--cast:${st.D}s}
296.fb{transform-box:fill-box;transform-origin:center}
297.hop{animation:hop var(--cast) ease-in-out infinite}
298@keyframes hop{0%,11%{transform:translateY(0)}16%{transform:translateY(2px)}20.5%{transform:translateY(-3px)}28%,100%{transform:translateY(0)}}
299.wand{animation:wand var(--cast) cubic-bezier(.3,.7,.3,1) infinite}
300@keyframes wand{0%{transform:rotate(-24deg)}9%{transform:rotate(-30deg)}16%{transform:rotate(-66deg)}19%{transform:rotate(-4deg)}21.5%{transform:rotate(-14deg)}50%{transform:rotate(-12deg)}75%{transform:rotate(-15deg)}88%{transform:rotate(-14deg)}100%{transform:rotate(-24deg)}}
301.tip{transform-box:fill-box;transform-origin:center;animation:tip var(--cast) ease-out infinite}
302@keyframes tip{0%{transform:scale(1);opacity:.55}14%{transform:scale(1.7);opacity:1}19%{transform:scale(2.6);opacity:1}27%{transform:scale(1.3);opacity:.85}100%{transform:scale(1);opacity:.55}}
303.lit{animation:lit var(--cast) linear infinite}
304.blink{transform-box:fill-box;transform-origin:center;animation:blink 4.3s linear infinite}
305@keyframes blink{0%,93%{transform:scaleY(1)}95%{transform:scaleY(.12)}97%,100%{transform:scaleY(1)}}
306.cur{transform-box:fill-box;transform-origin:center;animation:pip 1.6s ease-in-out infinite}
307@keyframes pip{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.25);opacity:1}}
308.ttl{font-family:Luminari,'Apple Chancery',Trattatello,Palatino,Georgia,serif}
309.sub{font-family:'SF Mono',Menlo,Monaco,monospace;font-size:10.5px;letter-spacing:1.4px;fill:rgba(255,255,255,.72)}
310@keyframes spin{to{transform:rotate(360deg)}}
311.bolts path{fill:none;stroke-linejoin:round;stroke-linecap:round}`
312}
313
314// Film lightning: a trunk that meanders every `spacing` pixels, with sharp
315// kinks between, and forks.
316function bolt(a: Point, b: Point, spacing: number, amplitude: number, rand: () => number): Point[] {
317  const length = Math.hypot(b.x - a.x, b.y - a.y)
318  const coarse = jagged(a, b, Math.max(3, Math.round(length / spacing)), amplitude, rand)
319  const fine: Point[] = [a]
320
321  for (let i = 1; i < coarse.length; i++) {
322    fine.push(...jagged(coarse[i - 1] ?? a, coarse[i] ?? b, 4, amplitude * 0.4, rand).slice(1))
323  }
324
325  return fine
326}
327
328function forksOf(trunk: Point[], count: number, reach: number, amplitude: number, rand: () => number): string {
329  const first = trunk[0] ?? { x: 0, y: 0 }
330  const last = trunk[trunk.length - 1] ?? first
331  const heading = Math.atan2(last.y - first.y, last.x - first.x)
332
333  return times(count, () => {
334    const from = trunk[1 + Math.floor(rand() * Math.max(1, trunk.length - 3))] ?? first
335    const turn = heading + (rand() < 0.5 ? -1 : 1) * (0.35 + rand() * 0.75)
336    const length = reach * (0.4 + rand() * 0.6)
337    const to = { x: from.x + Math.cos(turn) * length, y: from.y + Math.sin(turn) * length }
338
339    return poly(jagged(from, to, 3 + Math.floor(rand() * 3), amplitude, rand))
340  }).join('')
341}
342
343// The Dark Lord behind the curse, more of him at every rank: at I only his
344// eyes, at VI a face the flashes keep lighting green.
345function voldemort(st: Stage): { css: string; defs: string; back: string } {
346  const { rand, spell, zx0, zx1, level: L } = st
347  const x = zx0 + (zx1 - zx0) * 0.55
348  const place = `translate(${n(x)} 33) scale(.95)`
349  const presence = [0.16, 0.24, 0.34, 0.46, 0.58, 0.7][L] ?? 0.4
350  const glare = [0.45, 0.55, 0.7, 0.85, 1, 1][L] ?? 1
351  const reveal = [0, 0, 0.22, 0.32, 0.42, 0.55][L] ?? 0
352  const smoke = times(2 + 2 * L, () => {
353    const side = rand() < 0.5 ? -1 : 1
354    const seconds = 3 + rand() * 3
355
356    return `<ellipse cx="${n(side * (34 + rand() * 40))}" cy="${n(50 + rand() * 70)}" rx="${n(10 + rand() * 14)}" ry="${n(6 + rand() * 8)}" class="vsm" style="--x:${n(side * (14 + rand() * 26))}px;animation-duration:${n(seconds)}s;${delay(-rand() * seconds)}"/>`
357  }).join('')
358  const eye = (side: number) =>
359    `<ellipse cx="${side * 8.5}" cy="-2.6" rx="3.2" ry="1.1" transform="rotate(${-side * 14} ${side * 8.5} -2.6)"/>`
360  const socket = (side: number) =>
361    `<ellipse cx="${side * 8.5}" cy="-3" rx="7" ry="4.4" transform="rotate(${-side * 14} ${side * 8.5} -3)"/>`
362  const face = `<path d="${HEAD}" fill="url(#vface)"/>
363<path d="M-16 -9Q-8 -12 -2 -6M16 -9Q8 -12 2 -6" stroke="#000" stroke-opacity=".25" stroke-width="2" fill="none"/>
364<g fill="#121815" fill-opacity=".85">${socket(-1)}${socket(1)}</g>
365<g fill="#000" fill-opacity=".18"><ellipse cx="-13" cy="10" rx="5.5" ry="9"/><ellipse cx="13" cy="10" rx="5.5" ry="9"/></g>
366<path d="M-1.6 -1L-2.4 7.5M1.6 -1L2.4 7.5" stroke="#1b221e" stroke-opacity=".3" stroke-width=".8" fill="none"/>
367<path d="M-3.4 8C-4.6 10.2 -4.8 12 -3.8 13.6M3.4 8C4.6 10.2 4.8 12 3.8 13.6" stroke="#1b221e" stroke-width="1.5" fill="none" stroke-linecap="round"/>
368<path d="M-9 21Q0 18.6 9 21" stroke="#1b221e" stroke-width="1.4" fill="none" stroke-linecap="round"/>
369<path d="M-5 23.4Q0 22.4 5 23.4" stroke="#1b221e" stroke-opacity=".35" stroke-width=".9" fill="none"/>
370<ellipse cx="-6" cy="-19" rx="8" ry="4.5" fill="#fff" fill-opacity=".14"/>`
371
372  return {
373    css: `.vbob{animation:vbob 6s ease-in-out infinite alternate}
374@keyframes vbob{from{transform:translateY(-2px)}to{transform:translateY(3px)}}
375.vsm{fill:#000;fill-opacity:.55;animation:vsm 4s ease-out infinite}
376@keyframes vsm{0%{transform:translate(0,0) scale(.7);opacity:0}25%{opacity:1}100%{transform:translate(var(--x),-18px) scale(1.6);opacity:0}}
377.veye{animation:veye 3s ease-in-out infinite alternate}
378.aura{transform-box:fill-box;transform-origin:center;animation:aura 1.4s ease-in-out infinite alternate}
379@keyframes aura{from{transform:scale(.9);opacity:.6}to{transform:scale(1.12);opacity:1}}
380@keyframes veye{from{opacity:${n(glare * 0.6)}}to{opacity:${glare}}}`,
381    defs: `<linearGradient id="vface" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#d9e3da"/><stop offset=".55" stop-color="#aab6ac"/><stop offset="1" stop-color="#5c6a60"/></linearGradient>
382<linearGradient id="vrobe" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#0d1d13"/><stop offset=".4" stop-color="#050a07"/><stop offset="1" stop-color="#020403"/></linearGradient>
383<radialGradient id="vaura"><stop offset="0" stop-color="#000" stop-opacity=".55"/><stop offset="1" stop-color="#000" stop-opacity="0"/></radialGradient>`,
384    back: `<g transform="${place}"><g class="vbob">
385<ellipse cx="0" cy="30" rx="120" ry="80" fill="url(#vaura)" fill-opacity="${n(0.3 + 0.12 * L)}"/>
386<g opacity="${presence}">
387<path d="${CHEST}" fill="#8f9c92"/>
388<path d="${ROBE}" fill="url(#vrobe)"/>
389<path d="M-8 34L0 64L8 34M-30 48C-36 70 -40 100 -44 150M30 48C36 70 40 100 44 150" stroke="#1c2a22" stroke-width="1.3" fill="none"/>
390<path d="${ROBE}" fill="none" stroke="${spell.hex}" stroke-opacity="${n(0.12 + 0.05 * L)}" stroke-width="1.5" filter="url(#b2)"/>
391${face}
392</g>
393${reveal > 0 ? `<g class="lit"><path d="${HEAD}" fill="${spell.hex}" fill-opacity="${reveal}" filter="url(#b2)"/></g>` : ''}
394<g fill="#ff3b30" filter="url(#g2)" class="veye">${eye(-1)}${eye(1)}</g>
395<g filter="url(#b4)">${smoke}</g>
396</g></g>`,
397  }
398}
399
400// The curse, rank by rank. `L` is 0 for rank I; every `L >= k` block is a
401// layer that rank k+1 adds and every rank above keeps.
402function avada(st: Stage): Layer {
403  const { W, D, rand, spell, zx0, zx1, level: L } = st
404  const Z = zx1 - zx0
405  const T = { x: TIP.x + (zx1 - 8 - TIP.x) * pick(REACH, L), y: TIP.y - 2 }
406  const body = `M${n(TIP.x)} ${n(TIP.y)}Q${n((TIP.x + T.x) / 2)} ${n(TIP.y - 7)} ${n(T.x)} ${n(T.y)}`
407  const width = pick(BEAM, L)
408  const hold = pick(HOLD, L)
409  const flash = pick(FLASH, L)
410  const lord = voldemort(st)
411  const css: string[] = [lord.css]
412  const back: string[] = [lord.back]
413  const front: string[] = []
414  const flashes: string[] = []
415
416  // How bright the scene is lit: a pulse at the cast, held as long as the jet.
417  css.push(
418    L === 5
419      ? flicker('lit', 0.2, hold, 0.035, 1, 0.7)
420      : `@keyframes lit{0%,20%{opacity:0}22%{opacity:${n(0.4 + 0.12 * L)}}27%{opacity:${n(0.2 + 0.08 * L)}}${pct(hold)}{opacity:${n(0.15 + 0.06 * L)}}${pct(hold + 0.06)},100%{opacity:0}}`,
421  )
422
423  // I: green sparks spit from the tip, and the curse fizzles.
424  const wisps = times(2 + 2 * L, k => `<circle r="${n(2 + rand() * (2 + L * 0.6))}" class="wisp fb" style="${delay(-k * 0.29)}"/>`).join('')
425  const fizzle = times(
426    6 + 3 * L,
427    () =>
428      `<g transform="rotate(${n(-120 + rand() * 240)})"><line x1="3" y1="0" x2="${n(6 + rand() * 5)}" y2="0" class="fiz" style="--d:${n(8 + rand() * (10 + 4 * L))}px;${at(0.195 + rand() * 0.02, D)}"/></g>`,
429  ).join('')
430  css.push(`.wisp{animation:wisp ${n(2.6 - 0.2 * L)}s ease-out infinite}
431@keyframes wisp{0%{transform:translate(0,0) scale(.6);opacity:0}15%{opacity:.55}100%{transform:translate(8px,-${24 + 4 * L}px) scale(1.9);opacity:0}}
432.fiz{animation:fiz var(--cast) ease-out infinite}
433@keyframes fiz{0%{transform:translateX(0);opacity:1}10%,100%{transform:translateX(var(--d));opacity:0}}
434.fl{animation:fl var(--cast) linear infinite}`)
435  front.push(`<g transform="translate(${n(TIP.x)} ${n(TIP.y)})"><g fill="${spell.hex}" filter="url(#b2)">${wisps}</g><g stroke="${spell.core}" stroke-width="1.4" stroke-linecap="round">${fizzle}</g></g>`)
436
437  // II: the jet, a crooked bolt that never holds one shape; full length from
438  // III, and with every rank thicker, more crooked, more forked, faster.
439  if (L >= 1) {
440    const variants = L + 2
441    const period = n(0.36 - 0.03 * L)
442    const spacing = 60 - 6 * L
443    const amplitude = 3 + 2.4 * L
444    const trunk = width * 0.6
445    const reveal = pct(L === 1 ? 0.24 : 0.225)
446    css.push(`.awin{animation:awin var(--cast) linear infinite}
447@keyframes awin{0%,19%{opacity:0}20%{opacity:1}${pct(hold - 0.05)}{opacity:1}${pct(hold)},100%{opacity:0}}
448.after{animation:after var(--cast) linear infinite}
449@keyframes after{0%,22.5%{opacity:0}23%{opacity:1}${pct(hold - 0.05)}{opacity:1}${pct(hold)},100%{opacity:0}}
450.abeam{fill:none;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:100 100;animation:abeam var(--cast) cubic-bezier(.2,.8,.3,1) infinite}
451@keyframes abeam{0%,19%{stroke-dashoffset:100}${reveal},100%{stroke-dashoffset:0}}
452.bv{animation:bv ${period}s steps(1) infinite}
453@keyframes bv{0%{opacity:1}${pct(1 / variants)},100%{opacity:0}}
454${L >= 4 ? `.abeam.throb{animation:abeam var(--cast) cubic-bezier(.2,.8,.3,1) infinite,throb .11s ease-in-out infinite alternate}@keyframes throb{from{stroke-width:${n(trunk)}}to{stroke-width:${n(trunk * 1.5)}}}` : ''}`)
455
456    const jets = times(variants, k => {
457      const points = bolt(TIP, T, spacing, amplitude, rand)
458      const d = poly(points)
459      const forks = forksOf(points, L === 1 ? 1 : 2 * L, 10 + 7 * L, 3 + L, rand)
460
461      return `<g class="bv" style="${delay((-k * period) / variants)}">
462<path d="${d}" pathLength="100" stroke="${spell.glow}" stroke-width="${n(trunk * 2.4)}" stroke-opacity=".55" filter="url(#b4)" class="abeam"/>
463<path d="${d}" pathLength="100" stroke="${spell.hex}" stroke-width="${n(trunk)}" class="abeam${L >= 4 ? ' throb' : ''}"/>
464<path d="${d}" pathLength="100" stroke="${spell.core}" stroke-width="${n(Math.max(1, trunk * 0.45))}" class="abeam"/>
465<g class="after bolts"><path d="${forks}" stroke="${spell.hex}" stroke-width="${n(Math.max(0.8, trunk * 0.35))}" filter="url(#g2)"/></g>
466</g>`
467    }).join('')
468    front.push(`<g class="awin">
469<path d="${body}" pathLength="100" stroke="${spell.glow}" stroke-width="${n(width * 2.6)}" stroke-opacity="${n(0.18 + 0.04 * L)}" filter="url(#b8)" class="abeam"/>
470${jets}
471</g>`)
472
473    const sparks = times(
474      4 * L,
475      () =>
476        `<g transform="rotate(${n(rand() * 360)})"><line x1="6" y1="0" x2="${n(10 + rand() * (4 + 2 * L))}" y2="0" class="spk" style="--d:${n(16 + rand() * (10 + 12 * L))}px;${at(0.225 + rand() * 0.04, D)}"/></g>`,
477    ).join('')
478    css.push(`.imp{animation:imp var(--cast) ease-out infinite}
479@keyframes imp{0%,22%{transform:scale(.2);opacity:0}23.5%{transform:scale(1.3);opacity:1}${pct(hold - 0.05)}{transform:scale(1);opacity:.8}${pct(hold + 0.04)},100%{transform:scale(1.6);opacity:0}}
480.spk{animation:spk var(--cast) ease-out infinite}
481@keyframes spk{0%{transform:translateX(0);opacity:1}18%,100%{transform:translateX(var(--d));opacity:0}}`)
482    front.push(`<g transform="translate(${n(T.x)} ${n(T.y)})"><circle r="${12 + 7 * L}" fill="url(#burst)" class="imp fb"/><g stroke="${spell.core}" stroke-width="1.8" stroke-linecap="round">${sparks}</g></g>`)
483  }
484
485  // III: smoke, shockwave rings, a flash over everything.
486  if (L >= 2) {
487    const smoke = times(
488      3 + L,
489      () =>
490        `<ellipse cx="${n((rand() - 0.5) * Z)}" cy="${n((rand() - 0.5) * 70)}" rx="${n(40 + rand() * 60)}" ry="${n(9 + rand() * 12)}" transform="rotate(${n(rand() * 180)})" fill="#0f7a38" fill-opacity="${n(0.1 + 0.04 * L)}" filter="url(#b8)"/>`,
491    ).join('')
492    const rings = times(L - 1, k => `<circle r="12" stroke="${spell.hex}" stroke-width="${n(1.6 + 0.4 * L)}" class="ring fb" style="${at(0.225 + k * 0.035, D)}"/>`).join('')
493    css.push(`.swirl{animation:spin ${60 - 6 * L}s linear infinite}
494.ring{fill:none;animation:ring var(--cast) ease-out infinite}
495@keyframes ring{0%{transform:scale(.2);opacity:1}22%,100%{transform:scale(${2 + L});opacity:0}}`)
496    back.push(`<g transform="translate(${n(zx0 + Z / 2)} 64)"><g class="swirl">${smoke}</g></g>`)
497    front.push(`<g transform="translate(${n(T.x)} ${n(T.y)})">${rings}</g>`)
498  }
499
500  css.push(
501    L === 5
502      ? flicker('fl', 0.21, hold, 0.045, flash, 0.08)
503      : `@keyframes fl{0%,20.5%{opacity:0}22%{opacity:${flash}}25%{opacity:${n(flash * 0.15)}}${L >= 3 ? `29%{opacity:${n(flash * 0.6)}}34%{opacity:.04}` : ''}40%,100%{opacity:0}}`,
504  )
505  flashes.push(`<rect width="${W}" height="${H}" fill="#2bff66" class="fl"/>`)
506
507  // IV: arcs that wrap the jet, green eyes, a shake. More arcs at V and VI.
508  if (L >= 3) {
509    const arcs = times(
510      L - 2,
511      k =>
512        `<path d="${poly(bolt(TIP, T, 40, 9 + 3 * L, rand))}" stroke="${k === 0 ? spell.core : spell.hex}" stroke-width="${n(0.9 + 0.2 * L)}" filter="url(#g2)" class="arc" style="${delay(-k * 0.09)}"/>`,
513    ).join('')
514    css.push(`.arc{animation:arc .27s steps(1) infinite}
515@keyframes arc{0%{opacity:1}45%,100%{opacity:0}}`)
516    front.push(`<g class="after bolts">${arcs}</g>`)
517    css.push(shake(0.205, L === 5 ? hold : 0.32, 0.012, pick(SHAKE, L), rand))
518  }
519
520  // V: forked lightning out of the sky, four strikes at VI.
521  if (L >= 4) {
522    const strikes = L === 5 ? 4 : 1
523    times(strikes, k => {
524      const x = zx0 + Z * (0.15 + rand() * 0.75)
525      const points = bolt({ x: x + (rand() - 0.5) * 60, y: -6 }, { x, y: GROUND - rand() * 30 }, 30, 9, rand)
526      const d = poly(points) + forksOf(points, 3, 26, 4, rand)
527      const q = 0.215 + k * ((hold - 0.27) / Math.max(1, strikes))
528      css.push(`@keyframes strike${k}{0%{opacity:1}1.2%{opacity:.15}2.4%{opacity:.9}4.5%,100%{opacity:0}}.strike${k}{animation:strike${k} var(--cast) linear infinite}`)
529      back.push(`<g class="strike${k} bolts" style="${at(q, D)}"><path d="${d}" stroke="${spell.hex}" stroke-width="5" stroke-opacity=".5" filter="url(#b4)"/><path d="${d}" stroke="${spell.core}" stroke-width="1.5"/></g>`)
530    })
531  }
532
533  // VI: it holds, and the whole bar burns at the edges.
534  if (L >= 5) {
535    flashes.push(`<g class="lit"><rect x="2" y="2" width="${W - 4}" height="${H - 4}" rx="11" fill="none" stroke="${spell.hex}" stroke-width="5" filter="url(#b4)"/></g>`)
536    back.push(`<g class="lit"><ellipse cx="${n(T.x)}" cy="${n(T.y)}" rx="${n(Z * 0.6)}" ry="70" fill="url(#burst)" fill-opacity=".35"/></g>`)
537  }
538
539  return {
540    css: css.join('\n'),
541    defs: lord.defs,
542    back: back.join('\n'),
543    front: front.join('\n'),
544    flash: flashes.join('\n'),
545    eyes: L >= 3,
546    shake: L >= 3,
547  }
548}
549
550function title(st: Stage, detail: string, layer: Layer): string {
551  const { W, spell, level } = st
552  const right = W - 22
553  const room = textRoom(W)
554  const name = `${spell.name} ${spell.rank}`
555  const size = n(Math.max(15, Math.min(34, (room - 8) / (name.length * 0.56))))
556  const gap = 17
557  const pips = SPELLS.map((other, i) => {
558    const x = right - 6 - (SPELLS.length - 1 - i) * gap
559
560    if (i > level) {
561      return `<path d="${sparkle(x, 99, 5.6)}" fill="#fff" fill-opacity=".05" stroke="#fff" stroke-opacity=".28" stroke-width=".8"/>`
562    }
563
564    return `<path d="${sparkle(x, 99, i === level ? 7.6 : 5.6)}" fill="${other.hex}" filter="url(#g2)"${i === level ? ' class="cur"' : ''}/>`
565  }).join('')
566  const words = `x="${right}" y="54" text-anchor="end" font-size="${size}"`
567
568  return `<g${layer.shake ? ' class="shake"' : ''}>
569<text ${words} class="ttl" fill="${spell.glow}" fill-opacity="${n(0.3 + 0.08 * level)}" filter="url(#b4)">${escape(name)}</text>
570<g class="lit"><text ${words} class="ttl" fill="${spell.core}" filter="url(#b8)">${escape(name)}</text></g>
571<text ${words} class="ttl" fill="url(#ttl)" stroke="#000" stroke-opacity=".55" stroke-width="3" paint-order="stroke">${escape(name)}</text>
572<text x="${right}" y="78" text-anchor="end" class="sub">${escape(detail)}</text>
573${pips}
574</g>`
575}
576
577// `width` is the room in CSS pixels; the banner is SCENE_HEIGHT tall, scaled
578// down only when the room is narrower than its 420 px floor.
579export function scene(tier: number, options: { width: number; detail: string }): string {
580  const spell = spellAt(tier)
581  const W = fitWidth(options.width)
582  const box = sceneBox(options.width)
583  const room = textRoom(W)
584  const st: Stage = {
585    spell,
586    level: SPELLS.indexOf(spell),
587    W,
588    D: spell.seconds,
589    zx0: TIP.x + 14,
590    zx1: W - room - 18,
591    rand: seeded(tier * 7919 + W),
592  }
593  const layer = avada(st)
594
595  return `<svg xmlns="http://www.w3.org/2000/svg" width="${box.width}" height="${box.height}" viewBox="0 0 ${W} ${H}">
596<defs>${commonDefs(st)}${layer.defs}<style>${commonCss(st)}
597${layer.css}</style></defs>
598<g clip-path="url(#card)">
599<rect width="${W}" height="${H}" fill="url(#sky)"/>
600<g${layer.shake ? ' class="shake"' : ''}>
601${layer.back}
602<rect y="${GROUND}" width="${W}" height="${H - GROUND}" fill="url(#ground)"/>
603<g class="lit"><ellipse cx="${n(TIP.x + 30)}" cy="${GROUND + 2}" rx="150" ry="10" fill="${spell.glow}" fill-opacity=".5" filter="url(#b4)"/></g>
604<ellipse cx="${X0 + 8 * U}" cy="${GROUND + 1}" rx="46" ry="3.5" fill="#000" fill-opacity=".6"/>
605${clawd(st, layer.eyes)}
606${layer.front}
607</g>
608${layer.flash}
609<rect width="${W}" height="${H}" fill="url(#vig)"/>
610<rect x="${W - room - 46}" width="${room + 46}" height="${H}" fill="url(#shade)"/>
611${title(st, options.detail, layer)}
612<rect x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="12" fill="none" stroke="${spell.hex}" stroke-opacity="${n(0.15 + 0.07 * st.level)}"/>
613</g>
614</svg>`
615}
616
hooks/spells.ts 126 lines
1// Six ranks of one curse, weakest first, each adding a layer to the one below:
2// one per position of the effort picker, ultracode the strongest. A model that
3// takes no effort setting, or one not seen yet, shows rank I.
4export type Spell = {
5  id: string
6  name: string
7  rank: string
8  effort: string
9  // Main colour, the glow around it, and the white-hot core.
10  hex: string
11  glow: string
12  core: string
13  // Sky gradient, top to bottom.
14  sky: [string, string]
15  // One cast, in seconds.
16  seconds: number
17}
18
19const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']
20const RANKS = ['I', 'II', 'III', 'IV', 'V', 'VI']
21const HEXES = ['#1f8a44', '#27a852', '#30c45f', '#3ddf6c', '#5cff86', '#9dffbb']
22const SKIES: Array<[string, string]> = [
23  ['#010302', '#050c07'],
24  ['#010402', '#061209'],
25  ['#010503', '#07170c'],
26  ['#010603', '#081d0f'],
27  ['#010804', '#0a2414'],
28  ['#020b05', '#0d2e19'],
29]
30const SECONDS = [3.2, 3.2, 3.4, 3.6, 3.8, 4.4]
31
32export const SPELLS: readonly Spell[] = EFFORTS.map((effort, i) => ({
33  id: effort,
34  name: 'Avada Kedavra',
35  rank: RANKS[i] ?? '',
36  effort,
37  hex: HEXES[i] ?? '#3ddf6c',
38  glow: '#00c853',
39  core: '#ecfff2',
40  sky: SKIES[i] ?? ['#000502', '#04190c'],
41  seconds: SECONDS[i] ?? 3.6,
42}))
43
44export function spellAt(tier: number): Spell {
45  return SPELLS[tier] ?? SPELLS[0]!
46}
47
48export function levelOf(spell: Spell): string {
49  return spell.effort === 'ultracode' ? 'ultracode' : `effort ${spell.effort}`
50}
51
52// A small seeded generator, so a scene comes out the same on every redraw.
53export function seeded(seed: number): () => number {
54  let state = seed >>> 0
55
56  return () => {
57    state = (state + 0x6d2b79f5) >>> 0
58    let t = state
59    t = Math.imul(t ^ (t >>> 15), t | 1)
60    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
61
62    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
63  }
64}
65
66// An integer thinking budget has no named level; these cut-offs are rough.
67const BUDGETS = [8000, 16000, 32000, 64000]
68
69export function tierOf(effort: string | null): number {
70  if (effort === null) {
71    return 0
72  }
73
74  const level = EFFORTS.indexOf(effort)
75
76  if (level >= 0) {
77    return level
78  }
79
80  const budget = Number(effort)
81
82  if (Number.isFinite(budget)) {
83    const below = BUDGETS.findIndex(cut => budget < cut)
84
85    return below < 0 ? 4 : below
86  }
87
88  return 0
89}
90
91// `/spell <arg>`: a number 1-6, a roman rank, or an effort level.
92export function tierNamed(arg: string): number | null {
93  const word = arg.trim().toLowerCase()
94  const index = Number(word)
95
96  if (Number.isInteger(index) && index >= 1 && index <= SPELLS.length) {
97    return index - 1
98  }
99
100  const found = SPELLS.findIndex(spell => spell.effort === word || spell.rank.toLowerCase() === word)
101
102  return found < 0 ? null : found
103}
104
105const NAMED = ['low', 'medium', 'high', 'xhigh']
106
107// The effort picker's position, from the flag settings the desktop applies it
108// as. The settings schema drops "max", so max reads as no level at all, which
109// only means max when the flags just moved off a level the picker set
110// (`wasSet`); at startup the desktop passes the effort as a flag of the
111// process instead. Ultracode is its own boolean, sent with effort xhigh.
112// Undefined when the flags cannot tell.
113export function pickerEffort(flag: Readonly<Record<string, unknown>>, wasSet: boolean): string | undefined {
114  const level = typeof flag['effortLevel'] === 'string' && NAMED.includes(flag['effortLevel']) ? flag['effortLevel'] : undefined
115
116  if (flag['ultracode'] === true && (level === undefined || level === 'xhigh')) {
117    return 'ultracode'
118  }
119
120  if (level !== undefined) {
121    return level
122  }
123
124  return wasSet ? 'max' : undefined
125}
126
types/index.d.ts 19 lines
1// What the main loop last asked for: `effort` is null for a model that takes none.
2export type Cast = {
3  effort: string | null
4  model: string
5  // `turn` from a model request, `live` from the picker as it moved.
6  source: 'turn' | 'live'
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'spell-bar': {
12      cast: Cast | null
13      // A tier the person pinned with /spell, or null to follow the effort.
14      preview: number | null
15      isHidden: boolean
16    }
17  }
18}
19