SLOPSHOPPER

fables

Claude Fables: turns what the agent is doing into little animated cartoons above the prompt, written live by Sonnet or Haiku.

newbandguardcommandpromptmodel
★ 23v0.1.0MITupdated 2026-10-02henrik-thevibe/Claude-Fables
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fables
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /fables ⎿ fables: Claude Fables is off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="assets/styles.gif" alt="Claude, the little orange critter, playing out coding moments in all eleven styles: Pixel Art, the Original, Cave Painting, Blueprint, Mosaic, Frutiger Aero, Copperplate Engraving, Millefleur Tapestry, Golden Age Comic, Ukiyo-e and Kamon" width="960">

<h1 align="center">Claude Fables</h1>

<b>Your work, told as a cartoon while Claude codes.</b><br> A Claude Code mod for the desktop app that plays a small animated story in the band above the prompt.

<img alt="Claude Code mod" src="https://img.shields.io/badge/Claude_Code-mod-d97757"> <img alt="Desktop app" src="https://img.shields.io/badge/runs_in-the_desktop_app-3b3a36"> <img alt="11 styles" src="https://img.shields.io/badge/styles-11-6a5acd"> <img alt="20 actions" src="https://img.shields.io/badge/actions-20-2e8b57"> <img alt="7 scenes" src="https://img.shields.io/badge/scenes-7-c46a2a">

<a href="#quick-start">Quick start</a> · <a href="#how-it-works">How it works</a> · <a href="#use">Use</a> · <a href="#the-scenes">The scenes</a> · <a href="#captions">Captions</a> · <a href="#styles">Styles</a> · <a href="#develop">Develop</a>

[!NOTE] Claude Fables was developed entirely in Claude Code cloud environments and tested locally in the desktop app as well. Still, there may be bugs and rough edges. Issues and pull requests are very welcome.

While Claude works, Fables watches each tool call it makes and each line it says. Every few seconds it asks Sonnet (or Haiku, if you prefer) to retell the latest moment as a scene. Bug hunts turn into nature documentaries and bad regexes get pulled over by the train police. Claude appears as a small orange critter walking, sneaking or flying through the story. When the turn ends there is a closing scene, and it stays up for 30 seconds.

  • A scene every few seconds: each tool call and line Claude says becomes a moment in a story.
  • 20 actions: Claude walks, sneaks, digs, trips, shrugs, sleeps and celebrates, matched to the work.
  • 7 hand-lit scenes: forest, space, city, desert, volcano, lab and night village.
  • 11 styles: from pixel art to ukiyo-e, every element redrawn in the medium.
  • Captions that read like a terminal: files, functions, numbers, failures and successes each set apart.
  • Safe by design: the model writes data, never code, and a bad reply is simply skipped.

Quick start

  1. Clone this repo somewhere permanent, for example ~/code/Claude-Fables.
  2. Add it to the env block in ~/.claude/settings.json:
   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/code/Claude-Fables"
     }
   }

To work on the mod with hot reload, also add "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1".

  1. Restart the desktop app and give Claude a task in the Code tab. The cartoon appears above the prompt after the first few seconds.

To try it from the terminal for one session instead, run claude --plugin-dir ~/code/Claude-Fables. Note that in the terminal the mod only watches and draws nothing.

How it works

tool calls, Claude's own words ──► activity log (last 14 lines)
                                         │  as each scene is read, ≥5s apart
                                         ▼
        Sonnet or Haiku ($.model.complete) ──► JSON scene ──► parseScene (validate + clamp)
                                                                  │
                                                                  ▼
                                    sceneToSvg ──► one self-animating SVG (SMIL)
                                                                  │
                                                                  ▼
                                        <Svg isInteractive/> in the AbovePrompt band
  • The model writes data, not code. Each scene is a small declarative JSON object: a backdrop, Claude's action, particles, a caption and its tone. hooks/scene.ts validates it strictly: unknown fields are dropped, numbers clamped, strings flattened and cut, and colors must be 3- or 6-digit hex. A bad reply can't break anything; it just doesn't show.
  • The animation runs inside the SVG. hooks/svg.ts compiles a scene into one SVG document that animates itself with SMIL: walk cycles, bobbing, scrolling trains, twinkling stars, and a speech bubble that types itself out. Once the scene is drawn, the desktop needs no redraws for it.
  • Every line gets read. Scenes play from a queue, one at a time. Each one stays up until its bubble has typed out and been on screen long enough to read, which takes longer for a longer caption. Nothing cuts in: not a failure, not the end of the turn, not the next prompt. The narrator asks for the next scene a little before the current one is read, timed to how quickly the model has been answering, so it is ready on time. A scene that comes back early waits in the queue. News (a failure, the turn ending) is asked for straight away and joins the queue behind the current scene. The narrator is told what the hero has just said, so the news breaks in within the story ("Wait-", "Oh!") rather than on screen. When a new prompt comes in, the scene that is up is still read through, and a closing scene still on its way plays before the new story starts. Once a turn is over, its last scene stays up for 30 seconds, then the band clears.
  • Changes are smooth. A scene in a new setting fades in from the last one; one in the same setting carries straight on. Each scene is drawn once and kept, so the desktop plays it through. The desktop draws the band again whenever its props change, and the end of a turn always changes them. So every redraw of a scene that is up (the turn ending, a resize, a new style) is set to the scene's own clock: the caption is exactly as far typed as it was, and Claude is exactly as far along.
  • Captions fit the bubble. The narrator is asked for at most 70 characters. The hard limit is 80, the most the bubble shows whole in four lines. A longer caption is cut after its last full sentence, or else after a whole word with an ellipsis, never mid-word. The bubble always shows every word it is given.
  • It has limits. Only one model request runs at a time, scenes come at least 5 seconds apart, and after errors it backs off exponentially, up to 60 seconds. The prompt is always bounded (the last 14 activity lines and the last 4 scenes), so a long session can't outgrow the context window.
  • It fits the window. The band always gets a cartoon as wide as it is and 192 px tall. A wider window shows more of the scene, not a bigger one, so the art and the text stay the same size. Resizing the window redraws it.
  • Desktop only. In the terminal the band is left exactly as the engine draws it.

| File | What it does || --- | --- | | hooks/register.tsx | The hooks: watches tool calls, replies and turns; draws the band; handles /fables | | hooks/narrator.ts | The narrator's system prompt, prompt building, reply parsing, backoff | | hooks/director.ts | The narrator's loop: what it remembers, when it asks, what it does with a reply. The plugin and the viewer run the same one | | hooks/activity.ts | Boils each tool call down to one readable line | | hooks/scene.ts | The scene format and its validator | | hooks/sprites.ts | The pixel-art library: Claude in two walk frames, plus 30+ props | | hooks/svg.ts | Scene → animated SVG: backdrops, particles, props, hero, caption | | hooks/looks.ts | The looks: Pixel Art, the Original, and the registry of styles | | hooks/styles/*.ts | The nine gallery styles, each an art bible: inks, how each kind of element is drawn, Claude, caption, tag, frame | | hooks/art/roles.ts, hooks/art/painter.ts, hooks/art/ink.ts | The roles scenes paint their elements under, and the painter a style redraws them with | | hooks/grade.ts | The medium a grid style sets the drawn stage into: tesserae or a weave | | hooks/clawd3d.ts | The 3D Claude: the box model, its motions and its projection (ported from the gallery) | | hooks/hero3d.ts | Bakes the posed 3D model into SVG frames that SMIL plays in turn | | hooks/scenery.ts | The seven authored scenes: light maps, materials, reflections | | types/index.d.ts | The scene types and the mod's $.state contract |

Use

  • /fables: toggle the mod on or off. /fables on and /fables off also work. The setting is remembered across sessions.
  • /fables style <name>: draw every scene in one of the styles below, for example /fables style ukiyo-e or /fables style golden age. /fables style lists them, and /fables style off goes back to the default, Pixel Art. It is remembered across sessions.
  • /fables pixel off and /fables pixel on still work: they switch between the original look drawn smooth and its pixel art.
  • /fables model haiku and /fables model sonnet: pick who writes the story. Sonnet is the default and writes wittier scenes; Haiku answers in about a second and costs less, but writes plainer captions and slips a little more often (a bad reply is simply skipped). /fables model shows which one is on. The choice is remembered across sessions; the config menu's Scene model (pluginConfigs.fables.model) sets the default.

Every scene is one small model request, so this costs a few requests per minute while Claude is working.

The scenes

Claude is drawn as the gallery's 3D model: the same box body, arms, legs and pill eyes, lit and depth-sorted. The band's frame runs no script, so the model can't be drawn live. hooks/clawd3d.ts poses it 6 to 12 times per motion, hooks/hero3d.ts bakes each pose into flat SVG polygons, and the scene flips through them with SMIL. The model can't follow the cursor or be dragged; those need the live engine.

The narrator picks one of 20 actions for each scene, each tied to a kind of work:

playsactions
crossing the stage, from from to towalk, run, fly, carry (moving files), sneak (bug hunts), jump, tumble (obstacles, retries)
in place, loopingdig (searching), inspect (reading and editing code), think, point (found it), peek, spin (refactors), wave (hello), sleep (long waits), panic (errors), dance, celebrate (milestones)
in place, oncetrip (a test fails), shrug (nothing found)

A scene can chain a second action with then, played in place where the first one ends: a sneak then a peek, a trip then a shrug, a jump then a celebration. Without one, Claude stands idle after crossing or after a one-off, and an idle Claude looks about, blinks, shifts its weight and stretches. Along the way its eyes go wide, narrow in focus, cross when it's dizzy and worry under a brow.

Each of the seven backdrops in hooks/scenery.ts is an authored scene with one brief, one light source and a small palette:

BackdropThe scene
forestDawn in an old forest: a low sun behind two rows of firs on brown trunks, mist between them, light falling through in shafts and pooling in a clearing, two great trunks framing the edges
spaceEarthrise over a lunar outpost: a low sun rakes the regolith, so every swell has a lit crest, every crater a black bowl and a bright far wall, every rock a long shadow
cityBlue hour after rain: towers with lit west edges and dark east faces, offices lit a floor at a time, a spire, an elevated train, and the whole skyline mirrored in the wet street
desertMesa sunset: the sun sets in a notch the ranges leave clear, so they face us in violet shade with their sunward sides burning, and their shadows fan toward us across the sand
volcanoA night eruption: the crater lights its own ash column from beneath, lava runs down a gullied cone, and a stream crosses a black crust crazed with glowing cracks
labWorking late: an architect's lamp warms the board-formed concrete and the bench, dust turns in its cone, rain beads on the window over a city opened into bokeh, and the polished floor mirrors the room
nightA sleeping village: hills under a high moon, cottages with one lit window and a thread of smoke, a great oak framing the view, fireflies

Every surface is painted twice. First as light: a warm key where the light source reaches, cool shade facing away, deep tones where surfaces meet. Then that light map is multiplied by a material, built in an SVG filter from seeded noise cut into a small palette of related colors: needles, bark, grass, basalt, sandstone, sand, regolith, concrete, wood, asphalt. Light that has to brighten a dark material (shafts, lava glow, lamp pools) is added on top with a screen blend instead. Glows bloom, far layers sit slightly out of focus, rims of light appear only where the light can actually reach, and a lens pass adds fine grain and a vignette over the whole frame, Claude included.

Wet and polished floors reflect: the city's skyline and the lab's room are drawn once, then placed again upside down, blurred and broken by ripples, strongest in the puddles.

Depth comes from atmospheric perspective: further layers are lighter and nearer the sky's color. Focal points sit off the middle, so the middle of the stage, where Claude and the caption are, stays calm. Silhouettes come from smooth noise rather than repeated tiles, so there is no seam at any width. Everything is clipped to the stage, and motion is slow and belongs to the story.

The band's frame takes at most 131,072 characters, so repeated things are drawn once and placed many times: the firs, the grass clumps and the furthest tree line are templates. A scene that would still run over is redrawn leaner, then without particles, and as a last resort on the flat stage.

Captions

The story is told in words. Props aren't drawn on the scene, so nothing competes with Claude and the caption.

The caption is one standard bubble of cartoon paper with a tail pointing at Claude, set in Monocraft by Idrees Hassan (SIL Open Font License, fonts/Monocraft-OFL.txt), embedded as a 5 KB subset so it reads the same everywhere. It stays with Claude and never covers it: it takes a spot just beside, above or (for a flying Claude) below, checked against Claude's whole path, jumps and sways included. While Claude walks, the bubble walks along at the same distance, so its tail always points at Claude. Above Claude, it can sit anywhere along Claude's head, and a narrower wrap is tried, to find a spot the stage's edges never hold back. When a walk is too long for any spot, the bubble waits unseen until Claude is far enough in, then appears beside Claude and walks on with it. That wait is added to the scene's reading time. Inside it, kinds of words are set apart, so a caption reads like a terminal:

KindExampleLooks like
code and commands (in backticks, or a known command)npm testteal
files and pathsdates.ts, src/authblue
functionsdaysInMonth()purple
numbers and timings312, 2.41s, 42/42orange, bold
failuresfailed, TypeError, N+1red, bold
successespassed, clean, greengreen, bold
ASCII faces and symbols^_^ >_< \o/ -> [OK]warm accent

The narrator also picks a tone for the moment. Trouble leads the caption with a red ✗ and a milestone with a green ✓; a celebration is a milestone unless it says otherwise. The narrator is asked to write like a developer, with backticks around code and the odd ASCII face.

In the Pixel Art look (the default), the whole stage, scenery and Claude together, goes through a pixelizer: one SVG filter that samples the drawing at the middle of every two-unit square and spreads each sample over its square, so everything reads as pixel art without any of it being redrawn, and no blur washes the colors out. The grid starts at the stage's corner, so the pixels line up. Claude gets a sprite filter of its own on the same grid: its body sampled crisp, its eyes (finer than a pixel) found by their darkness and thickened just enough to land on whole pixels, and a one-pixel dark outline round it, as a pixel-art character would have. The caption sits above it all, already in pixel type, so it stays sharp. In the Original look, scenes are drawn smooth and the scenery takes a slight blur instead, so Claude and the caption read first.

Styles

Two looks draw the authored scenes as they are: Pixel Art (pixel, the default) and the Original (original), the same scenes drawn smooth.

Nine more styles, from the Claude Mascot Style Gallery, are artworks of their own. Each one redraws every element of every scene, Claude, the caption bubble, the chapter tag and the frame in its own medium, translated from the gallery's original plate. They are cosmetic only: the story, the scenery's composition and Claude's path stay as they are, and nothing is added to a scene.

Style/fables style …The artwork
Cave PaintingcaveOchre, soot and pale earth rubbed thin on torch-lit limestone; no sky, only the wall; broken soot outlines; Claude in red ochre
BlueprintblueprintWhite line work on a cyanotype sheet; shade section-hatched, air and light as phantom lines; Claude as a patent drawing with hidden edges dashed
MosaicmosaicLaid in the floor's stones and set as tesserae in grout, outlined in rows of dark stones; a meander border
Frutiger AeroaeroGlossy gradients with a white rim on every surface, after the Vista and 7 wallpapers: an azure sky with a sun flare, Bliss-green grass, aqua glass towers, pearl rooms; Frutiger Aurora at night with teal meadows, moonlit clouds and bokeh stars; Claude as tangerine jelly
Copperplate EngravingengravingOne sepia ink on laid paper, every tone cut as hatching along the grain of what it is; a plate mark
Millefleur TapestrytapestryWoven in madder, woad, weld and walnut wool on the loom's grid; grass becomes the field of a thousand flowers
Golden Age ComicgoldenFlat newsprint inks, shade in Ben-Day dots, heavy keylines; a lettered balloon
Ukiyo-eukiyoeFlat woodblock inks over a key line, bokashi skies, kasumi mist, a vermilion sun; a cartouche and seal
KamonkamonCream planes on black silk parted by cuts of one width; Claude as a crest; the vermilion hanko

Every element of a scene is handed to a painter under a role that says what it is and how deep it stands (hooks/art/roles.ts): a fir, a mist bank, a mesa, the lamp's cone, the wet street. The original looks keep the lit, photographic painting. A style's painter (hooks/art/painter.ts, hooks/styles/*.ts) reads that painting for its shapes and tones and draws it again:

  • the lit look's blooms, materials and lens are left out;
  • each lit color becomes one of the style's inks, wools, stones or threads, chosen by the element's family and depth;
  • shapes take the style's line;
  • what has no edge in the art form (sun and moon, mist, light, water) is redrawn by the style's own conventions.

Claude is drawn by each style's hero painter, from the 3D model's faces, its parts' outline hulls and its edges, which are classed as outline, crease or hidden as in the gallery's engine. A style made on a grid (Mosaic, Tapestry) names that grid as its medium: the drawn stage is set into tesserae or a weave, and its grout or the weave's ribs are laid over it. The narrator hears each style's voice, so a caption can sound like a 1938 comic or a woodblock print while still being about the real work.

The styles draw as fast as the original look or faster, since they leave out the lit look's material filters, and every one fits the band's size limit at every width.

Develop

claude plugin validate .              # manifest, hooks and state contract
claude plugin test .                  # unit tests plus engine tests (stubbed Sonnet)
bun scripts/preview.ts > gallery.html # render sample scenes to a page in the browser
bun scripts/preview.ts --look all > styles.html # every look

scripts/scenarios.ts holds seven whole sessions: the prompt, each tool call and its result, Claude's words, and what Sonnet and what Haiku write back each time the narrator asks, as raw text, quirks included (code fences, a word of chatter, a missing caption, an empty answer). scripts/rehearse.ts plays a session through the narrator's own loop on a clock of its own, so the asks land when the plugin would make them, see exactly the prompt it would send, and bad replies are skipped and backed off from the same way. The viewer's Sessions tab shows it side by side: Claude Code's activity, the narrator's asks (with each prompt) and the model's replies (with each raw reply and the validator's verdict), the band, and a box to paste a reply of your own and see what the plugin would make of it. A test rehearses every session with both models. bun scripts/preview.ts my-scenes.json renders your own scenes, which is handy for tuning sprites or trying out what Sonnet sent back.

The mod API is early access and may change between Claude Code releases. This mod was built against Claude Code 2.1.287. If something stops drawing, run claude --debug: the log line will name what the engine refused.

The images in this README are drawn by the mod itself: bun scripts/readme-gifs.ts renders them to assets/. The launch video is made the same way; see video/.

Built in the cloud

Claude Fables was developed entirely in Claude Code cloud environments: the mod, its tests, the scenes and styles, this README's images and the launch video. It was also tested locally in the desktop app, but there may still be bugs, and some may only show up on your setup. If something looks off, run claude --debug and open an issue with what the log says.

Credits

Source 27 files
hooks/register.tsx 209 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { DEFAULT_MODEL, Director, findModel, type Host, MODEL_LABELS, NARRATOR_MODELS, type NarratorModel } from './director'
5import { DEFAULT_LOOK, findLook, LOOK_NAMES, LOOKS, lookFor } from './looks'
6import type { FablesScene } from '../types'
7
8import { H, MAX_SVG, resumeAt, sceneToSvg, speaksAfter, W } from './svg'
9
10const scene = atom({ plugin: 'fables', key: 'scene' } as const, null)
11const enabled = atom({ plugin: 'fables', key: 'enabled' } as const, true)
12const style = atom({ plugin: 'fables', key: 'style' } as const, DEFAULT_LOOK)
13
14const STORE_ENABLED = 'enabled'
15const STORE_PIXEL = 'pixelArt'
16const STORE_STYLE = 'style'
17const STORE_MODEL = 'model'
18/** Every scene is drawn with the 3D Claude, in the style the person chose (looks.ts). */
19const FIGURE = '3d'
20/** This plugin's own tools, if it ever registers any, are not part of the story. */
21const OWN_TOOLS = 'mcp__fables__'
22
23/**
24 * The band's code font advance in CSS pixels: the desktop measures the band in
25 * cells of it, and the Svg wants pixels.
26 */
27const PX_PER_COLUMN = 8
28/** CSS pixels per stage unit: the stage's H units come out this many times taller. */
29const SCALE = 1.5
30
31/**
32 * The band's box in CSS pixels: its whole width, at a fixed height so the art and
33 * the caption keep one size whatever the window; the stage widens to fill it.
34 */
35function bandBox(columns: number): { width: number; height: number } {
36  const width = Number.isFinite(columns) && columns > 0 ? Math.round(columns * PX_PER_COLUMN) : W * SCALE
37  return { width, height: Math.round(H * SCALE) }
38}
39
40/** The narrator's host in a session: Claude Code's clock, its model, the band, and how the band draws a scene. */
41function host($: EngineInterface, band: Band): Host {
42  return {
43    now: () => $.clock.now(),
44    complete: ask => $.model.complete(ask),
45    show: drawn => update($, scene, () => drawn),
46    speaksAfter: async next => speaksAfter((await draw($, band, next)).base) * 1000,
47  }
48}
49
50async function chooseLook($: EngineInterface, n: Director, name: string) {
51  const look = lookFor(name)
52  n.look = look.name
53  await $.store.set(STORE_STYLE, look.name)
54  await update($, style, () => look.name)
55  return { text: `Scenes are now drawn as ${look.label}.` }
56}
57
58async function chooseModel($: EngineInterface, n: Director, model: NarratorModel) {
59  n.model = model
60  await $.store.set(STORE_MODEL, model)
61  return { text: `${MODEL_LABELS[model]} now writes the story.` }
62}
63
64async function setOn($: EngineInterface, n: Director, at: Host, value: boolean) {
65  await n.setOn(at, value)
66  await $.store.set(STORE_ENABLED, value)
67  await update($, enabled, () => value)
68}
69
70/**
71 * The scene up now, when it went up, and its drawing in the box and style last
72 * asked for. The band is drawn again whenever its props change (the turn
73 * ending flips isWorking, the window is resized) and the desktop may start the
74 * frame over then; so every drawing after the first is set to the scene's own
75 * clock, and it carries on where it was instead of typing its caption again.
76 */
77type Drawn = { scene: string; at: number; key: string; base: string }
78/** The drawing kept, and the band's box as last drawn, so a scene can be drawn the moment it goes up. */
79type Band = { drawn?: Drawn; box: { width: number; height: number } }
80
81/** The scene in the band's box and style, drawn once and kept: a scene going up starts its clock. */
82async function draw($: EngineInterface, band: Band, next: FablesScene): Promise<Drawn> {
83  const look = await read($, style)
84  const key = `${band.box.width}x${band.box.height}|${look}`
85  const same = JSON.stringify(next)
86  const kept = band.drawn
87  if (kept?.scene === same && kept.key === key) return kept
88  const at = kept?.scene === same ? kept.at : await $.clock.now()
89  band.drawn = { scene: same, at, key, base: sceneToSvg(next, { ...band.box, look, figure: FIGURE }) }
90  return band.drawn
91}
92
93export const register: Register = (on, options) => {
94  const n = new Director()
95  const band: Band = { box: bandBox(NaN) }
96  // The config menu's choice is the default; /fables model overrides it.
97  const configured = findModel(options.model) ?? DEFAULT_MODEL
98  n.model = configured
99  n.look = DEFAULT_LOOK
100
101  on('session.start', async ($, e, next) => {
102    n.isOn = (await $.store.get(STORE_ENABLED)) !== false
103    n.model = findModel(await $.store.get(STORE_MODEL)) ?? configured
104    await update($, enabled, () => n.isOn)
105    const saved = await $.store.get(STORE_STYLE)
106    // Pixel art was once a switch of its own: someone who turned it off keeps the original look, drawn smooth.
107    const smooth = (await $.store.get(STORE_PIXEL)) === false
108    n.look = typeof saved === 'string' && LOOKS[saved] ? saved : smooth ? 'original' : DEFAULT_LOOK
109    await update($, style, () => n.look)
110    await $.command.register({
111      name: 'fables',
112      description: 'Claude Fables: turn the cartoons above the prompt on or off, pick a style, or pick the model that writes them',
113      argumentHint: '[on|off|style [name|off]|model [sonnet|haiku]]',
114    })
115    $.clock.every(1000, () => void n.tick(host($, band)))
116    return next(e)
117  })
118
119  on('command.run', { command: 'fables' }, async ($, e) => {
120    const arg = e.args.trim().toLowerCase()
121    const md = /^(?:model|models|narrator)\b\s*(.*)$/.exec(arg)
122    if (md) {
123      const want = (md[1] ?? '').trim()
124      if (!want) {
125        const list = NARRATOR_MODELS.map(m => `${m === n.model ? '▸' : ' '} ${m} · ${MODEL_LABELS[m]}`).join('\n')
126        return { text: `${MODEL_LABELS[n.model]} writes the story. Pick another with /fables model <name>:\n${list}` }
127      }
128      const model = findModel(want)
129      if (!model) return { text: `No narrator called "${want}". Try /fables model sonnet or /fables model haiku.` }
130      return chooseModel($, n, model)
131    }
132    const st = /^(?:style|styles|look)\b\s*(.*)$/.exec(arg)
133    if (st) {
134      const want = (st[1] ?? '').trim()
135      if (!want) {
136        const list = LOOK_NAMES.map(name => `${name === n.look ? '▸' : ' '} ${name} · ${lookFor(name).label}`).join('\n')
137        return { text: `Scenes are drawn in ${lookFor(n.look).label}. Pick a style with /fables style <name>, or /fables style off for the default:\n${list}` }
138      }
139      const look = /^(off|none|default|plain)$/.test(want) ? lookFor(DEFAULT_LOOK) : findLook(want)
140      if (!look) return { text: `No style called "${want}". /fables style lists them.` }
141      return chooseLook($, n, look.name)
142    }
143    // Pixel art is a style now; the old switch still works, as a way to pick it or the smooth original.
144    const px = /^pixel(?:\s+(on|off))?$/.exec(arg)
145    if (px) return chooseLook($, n, px[1] === 'off' || (!px[1] && n.look === 'pixel') ? 'original' : 'pixel')
146    const value = arg === 'on' ? true : arg === 'off' ? false : !n.isOn
147    await setOn($, n, host($, band), value)
148    return {
149      text: value
150        ? `Claude Fables is on: cartoons written by ${MODEL_LABELS[n.model]} play above the prompt while Claude works.`
151        : 'Claude Fables is off.',
152    }
153  })
154
155  on('prompt.submit', async ($, e, next) => {
156    n.submit(e.text)
157    return next(e)
158  })
159
160  on('tool.call', async ($, e, next) => {
161    const tool = String(e.tool)
162    const isTold = e.agentId === undefined && !tool.startsWith(OWN_TOOLS)
163    if (isTold) n.tool(tool, e)
164    const ran = await next(e)
165    if (isTold && (ran.deny !== undefined || ran.isError === true)) n.failed(tool, e)
166    return ran
167  })
168
169  on('session.append', async ($, e, next) => {
170    if (e.door === 'response' && e.agentId === undefined && Array.isArray(e.message.content)) {
171      const said = e.message.content
172        .map(block => (block.type === 'text' ? block.text : ''))
173        .join(' ')
174        .trim()
175      n.said(said)
176    }
177    return next(e)
178  })
179
180  on('turn.complete', async ($, e, next) => {
181    if (e.agentId === undefined) n.complete(e.reason)
182    return next(e)
183  })
184
185  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186    if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
187    const current = await read($, scene)
188    if (!current || !(await read($, enabled))) return next(e)
189    const { Svg } = $.ui.resolve(e)
190    // The interactive frame does not size itself from the markup (left alone it
191    // is a 300x150 box), so give it the band's box; a new width draws anew.
192    band.box = bandBox(e.props.bodyColumns)
193    const { width, height } = band.box
194    const { base, at } = await draw($, band, current)
195    // To a tenth of a second, so drawings in the same moment stay the same.
196    const along = Math.floor(((await $.clock.now()) - at) / 100) / 10
197    const resumed = resumeAt(base, along)
198    return (
199      <Svg
200        source={resumed.length <= MAX_SVG ? resumed : base}
201        alt={current.caption}
202        width={width}
203        height={height}
204        isInteractive
205      />
206    )
207  })
208}
209
hooks/director.ts 245 lines
1/**
2 * The narrator's loop, free of any host: what it remembers, when it asks the
3 * model for the next scene, what it does with the reply. The plugin runs it on
4 * Claude Code's clock and model (register.tsx); the viewer runs the very same
5 * loop on a scripted clock and scripted replies, so a session rehearsed there
6 * plays out as it would live.
7 */
8import type { FablesScene } from '../types'
9
10import { type Activity, pushActivity, summarizeSpeech, summarizeTool } from './activity'
11import { backoffMs, buildPrompt, readMs, remember, sceneFromReply, type StoryBeat, SYSTEM } from './narrator'
12import { lookFor } from './looks'
13
14/** The models that can write the story: Sonnet by default, Haiku for quicker, cheaper scenes. */
15export const NARRATOR_MODELS = ['sonnet', 'haiku'] as const
16export type NarratorModel = (typeof NARRATOR_MODELS)[number]
17export const DEFAULT_MODEL: NarratorModel = 'sonnet'
18export const MODEL_LABELS: Record<NarratorModel, string> = { sonnet: 'Sonnet', haiku: 'Haiku' }
19
20/** A model name as a person might type it, or as an older config holds it, to one of the narrators. */
21export function findModel(name: unknown): NarratorModel | undefined {
22  const s = typeof name === 'string' ? name.trim().toLowerCase() : ''
23  return NARRATOR_MODELS.find(m => s === m || s.startsWith(`claude-${m}`) || s.includes(m))
24}
25
26/** How long the last scene of a turn stays up once the turn is over (and at least until it is read). */
27export const LINGER_MS = 30000
28/** What the model is expected to take to answer until it has been timed, and the bounds of that guess. */
29export const FIRST_LATENCY_MS = 3000
30const LATENCY_BOUNDS = [1000, 10000] as const
31
32export type Ending = 'answer' | 'aborted' | 'error' | 'refusal'
33
34/** What the narrator asks of the model, as the host's model call takes it. */
35export type Ask = { model: NarratorModel; system: string; prompt: string; maxTokens: number; effort: 'low'; timeoutMs: number }
36export type Answer = { isAnswered: boolean; text: string }
37
38/** What happens inside the loop, for whoever wants to watch it (the viewer does). */
39export type Trace =
40  | { kind: 'ask'; at: number; model: NarratorModel; prompt: string; closing?: Ending }
41  | { kind: 'reply'; at: number; model: NarratorModel; text: string; isAnswered: boolean; scene: FablesScene | null; isStale: boolean }
42  | { kind: 'failed'; at: number; error: string }
43  | { kind: 'wait'; at: number; until: number; failures: number }
44  | { kind: 'hold'; at: number; until: number }
45  | { kind: 'clear'; at: number }
46
47/** The host's side: its clock, its model, and the band the scene goes to. */
48export type Host = {
49  now(): number | Promise<number>
50  complete(ask: Ask): Promise<Answer>
51  show(scene: FablesScene | null): void | Promise<void>
52  /** How long the scene's bubble waits for Claude to walk into reach before it appears, in ms, as the band draws it. */
53  speaksAfter?(scene: FablesScene): number | Promise<number>
54  trace?(event: Trace): void
55}
56
57/** A scene come back from the model, waiting for its turn on the band. */
58type Queued = { scene: FablesScene; closing: Ending | undefined; turn: number }
59
60/** The most scenes that wait at once: the next one, and the turn's closing scene behind it. */
61export const MAX_QUEUE = 2
62
63/**
64 * The band plays scenes from a queue, one at a time, and each one stays up
65 * until its caption has typed out and been read, however the work runs on:
66 * nothing cuts in, not a failure, not the turn's end, not the next prompt.
67 * The narrator asks for the next scene so it comes back as the one on the
68 * band is read through; news (a failure, the turn ending) is asked for at
69 * once and waits its turn in the queue.
70 */
71export class Director {
72  model: NarratorModel = DEFAULT_MODEL
73  isOn = true
74  /** The style scenes are drawn in, so the narrator can write in its voice. */
75  look = 'pixel'
76  ask = ''
77  log: Activity[] = []
78  story: StoryBeat[] = []
79  isTurnRunning = false
80  isDirty = false
81  isAsking = false
82  ending: Ending | undefined = undefined
83  nextAt = 0
84  failures = 0
85  turn = 0
86  /** The scene on the band, if any, and when it went up. */
87  shown: FablesScene | undefined = undefined
88  shownAt = 0
89  /** Until then the scene on the band is still being typed out and read: nothing replaces it. */
90  readUntil = 0
91  /** Scenes come back while the one on the band is still being read, oldest first. */
92  queue: Queued[] = []
93  /** Something failed since the last ask: the next scene is asked for at once. */
94  isUrgent = false
95  /** How long the model takes to answer, smoothed, so the next scene is asked for in time to follow on. */
96  latency = FIRST_LATENCY_MS
97
98  private note(entry: Activity) {
99    this.log = pushActivity(this.log, entry)
100    this.isDirty = true
101  }
102
103  /**
104   * The person asked something: a new turn, a fresh log. The scene on the band
105   * is still read through, and a closing scene still waiting plays first; the
106   * old turn's other scenes are behind the story now and are dropped.
107   */
108  submit(text: string) {
109    this.turn++
110    this.ask = text.replace(/\s+/g, ' ').trim().slice(0, 300)
111    this.log = []
112    this.ending = undefined
113    this.isTurnRunning = true
114    this.isDirty = true
115    this.isUrgent = false
116    this.queue = this.queue.filter(q => q.closing !== undefined)
117  }
118
119  /** The agent called a tool. */
120  tool(tool: string, input: Readonly<Record<string, unknown>>) {
121    this.note({ kind: 'tool', text: summarizeTool(tool, input) })
122  }
123
124  /** A tool call was denied or came back an error. */
125  failed(tool: string, input: Readonly<Record<string, unknown>>) {
126    this.note({ kind: 'failed', text: summarizeTool(tool, input) })
127    this.isUrgent = true
128  }
129
130  /** The agent said something between its tool calls. */
131  said(text: string) {
132    const flat = text.trim()
133    if (flat) this.note({ kind: 'said', text: summarizeSpeech(flat) })
134  }
135
136  /** The turn ended; if anything happened, a closing scene follows. */
137  complete(reason: Ending) {
138    if (!this.isTurnRunning) return
139    this.isTurnRunning = false
140    if (this.isOn && this.log.length > 0) this.ending = reason
141  }
142
143  /**
144   * Called once a second, with the host to ask through. Puts the next scene in
145   * the queue up once the one on the band has been read, clears the band once
146   * the turn is over and its last scene has lingered, and asks for the next
147   * scene when there is news.
148   */
149  async tick(host: Host) {
150    if (!this.isOn) return
151    const now = await host.now()
152    const next = this.queue[0]
153    if (next && now >= this.readUntil) await this.present(host, next, now)
154    else if (this.isIdle && this.shown && now >= this.readUntil && now >= this.shownAt + LINGER_MS) {
155      this.shown = undefined
156      await host.show(null)
157      host.trace?.({ kind: 'clear', at: now })
158    }
159    if (this.isAsking) return
160    // The closing scene is asked for at once, and may wait behind one other scene.
161    if (this.ending) return this.queue.length < MAX_QUEUE ? this.narrate(host, now) : undefined
162    if (!this.isTurnRunning || !this.isDirty || this.queue.length > 0) return
163    if (now < this.nextAt) return
164    if (!this.isUrgent && now < this.readUntil - this.latency) return
165    return this.narrate(host, now)
166  }
167
168  /** Nothing more to come: the turn is over, and no scene is asked for or waiting. */
169  private get isIdle() {
170    return !this.isTurnRunning && !this.isAsking && !this.ending && this.queue.length === 0
171  }
172
173  private async narrate(host: Host, startedAt: number) {
174    this.isAsking = true
175    this.isDirty = false
176    const forTurn = this.turn
177    const closing = this.ending
178    this.ending = undefined
179    const isUrgent = this.isUrgent || closing !== undefined
180    this.isUrgent = false
181    // The line the hero will still be saying when this scene comes back: news breaks in on it, in the story.
182    const before = this.queue.at(-1)?.scene ?? this.shown
183    const isMidLine = this.queue.length > 0 || startedAt + this.latency < this.readUntil
184    const interrupts = isUrgent && before && isMidLine ? { why: closing ? ('ended' as const) : ('failed' as const), line: before.caption } : undefined
185    const look = lookFor(this.look)
186    const prompt = buildPrompt({ ask: this.ask, log: this.log, story: this.story, ending: closing, look: look.voice ? look : undefined, interrupts })
187    host.trace?.({ kind: 'ask', at: startedAt, model: this.model, prompt, closing })
188    try {
189      const reply = await host.complete({ model: this.model, system: SYSTEM, prompt, maxTokens: 2000, effort: 'low', timeoutMs: 30000 })
190      const now = await host.now()
191      if (reply.isAnswered) {
192        const [lo, hi] = LATENCY_BOUNDS
193        this.latency = Math.round(Math.min(hi, Math.max(lo, this.latency * 0.6 + (now - startedAt) * 0.4)))
194      }
195      const drawn = reply.isAnswered ? sceneFromReply(reply.text) : null
196      // A scene that lands after a newer turn began belongs to a story nobody is following, unless it closes that story.
197      const isStale = forTurn !== this.turn && !closing
198      host.trace?.({ kind: 'reply', at: now, model: this.model, text: reply.text, isAnswered: reply.isAnswered, scene: drawn, isStale })
199      if (!drawn) {
200        this.failures++
201        return
202      }
203      this.failures = 0
204      if (isStale || !this.isOn) return
205      const queued: Queued = { scene: drawn, closing, turn: forTurn }
206      // A closing scene goes ahead of any scene of a newer turn already waiting.
207      const at = closing ? this.queue.findIndex(q => q.turn > forTurn) : -1
208      if (at >= 0) this.queue.splice(at, 0, queued)
209      else this.queue.push(queued)
210      if (this.queue[0] === queued && now >= this.readUntil) await this.present(host, queued, now)
211      else host.trace?.({ kind: 'hold', at: now, until: this.readUntil })
212    } catch (err) {
213      this.failures++
214      host.trace?.({ kind: 'failed', at: await host.now(), error: err instanceof Error ? err.message : String(err) })
215    } finally {
216      this.isAsking = false
217      const now = await host.now()
218      this.nextAt = now + backoffMs(this.failures)
219      host.trace?.({ kind: 'wait', at: now, until: this.nextAt, failures: this.failures })
220    }
221  }
222
223  /** Puts the next scene on the band, and holds it there until it has been read. */
224  private async present(host: Host, next: Queued, now: number) {
225    this.queue = this.queue.filter(q => q !== next)
226    // A new setting fades in from the last; the same one carries straight on.
227    const scene: FablesScene = this.shown && this.shown.backdrop !== next.scene.backdrop ? { ...next.scene, enter: 'fade' } : next.scene
228    this.story = remember(this.story, next.scene)
229    this.shown = scene
230    this.shownAt = now
231    this.readUntil = now + readMs(scene) + ((await host.speaksAfter?.(scene)) ?? 0)
232    await host.show(scene)
233  }
234
235  /** Turned off: no more asking, and the band is cleared. */
236  async setOn(host: Host, value: boolean) {
237    this.isOn = value
238    if (value) return
239    this.queue = []
240    this.shown = undefined
241    this.readUntil = 0
242    await host.show(null)
243  }
244}
245
hooks/looks.ts 183 lines
1/**
2 * Graphic styles ("looks") a scene can be drawn in: the original look, its
3 * pixel art, and the art styles of the Claude Mascot Style Gallery
4 * (github.com/henrik-thevibe/Claude-Mascot-Style-Gallery).
5 *
6 * A look never changes what a scene is, only how it is drawn. The original
7 * look is the lit scenery as authored; its pixel art pixelizes the whole stage.
8 * Each gallery style is an artwork of its own (styles/*.ts): every element of
9 * the scenery and Claude himself redrawn in its medium, with its own texture,
10 * frame, chapter tag and caption bubble. A style made on a grid (tesserae,
11 * a loom) also names that grid as its medium.
12 */
13
14import type { Painter } from './art/roles'
15import type { HeroPainter } from './hero3d'
16import { AERO } from './styles/aero'
17import { BLUEPRINT } from './styles/blueprint'
18import { CAVE } from './styles/cave'
19import { ENGRAVING } from './styles/engraving'
20import { GOLDEN } from './styles/golden'
21import { KAMON } from './styles/kamon'
22import { MOSAIC } from './styles/mosaic'
23import { TAPESTRY } from './styles/tapestry'
24import { UKIYOE } from './styles/ukiyoe'
25
26/** How one art pixel of a sprite is drawn. */
27export type Cell = 'solid'
28
29export type CaptionStyle = { fill: string; stroke: string; ink: string; radius: number }
30
31/** The kinds of words a caption sets apart (svg.ts). */
32export type WordKind = 'code' | 'path' | 'fn' | 'num' | 'bad' | 'good' | 'face'
33
34/** The caption's cartoon paper in a look: the card, its ink, and the color of each kind of word. */
35export type Paper = { card: string; ink: string; kinds: Record<WordKind, string> }
36
37/** The chapter tag as a look draws it, at the top left, and the room it takes. */
38export type Tag = { svg: string; w: number; h: number }
39
40type Pt = [number, number]
41/**
42 * The caption's bubble as a style draws it: its type, and its frame and tail
43 * under the text. `tail` runs from two points on the frame to a tip by Claude.
44 */
45export type Bubble = {
46  font: string
47  size: number
48  /** The type's average advance, in stage units, to fit the bubble to its lines. */
49  charW: number
50  line: number
51  /** The first line's baseline below the bubble's top. */
52  base: number
53  /** Set the caption in capitals, as a drafting hand would. */
54  upper?: boolean
55  draw: (box: { x: number; y: number; w: number; h: number }, tail: { base: [Pt, Pt]; tip: Pt }) => string
56}
57
58/**
59 * A style drawn as an artwork of its own: every element of the scenery and
60 * Claude himself repainted in its medium, rather than the finished picture graded.
61 */
62export type Art = {
63  /** Paints the scenery's elements, for a stage `sw` wide. */
64  painter: (sw: number) => Painter
65  /** Paints Claude. */
66  hero: () => HeroPainter
67  /** The sky and ground colors past the stage's edges. */
68  sky: string
69  ground: string
70  /** Definitions the painters refer to (patterns, gradients), laid down once under the stage. */
71  defs?: (sw: number, h: number) => string
72  /** The ground everything is drawn on (a wall, a floor of stones), under the scenery. */
73  under?: (sw: number, h: number) => string
74}
75
76export type Look = {
77  name: string
78  /** What the look is called, after the gallery's style it comes from. */
79  label: string
80  /** A few words for the narrator, so the caption's voice can suit the look. */
81  voice: string
82  cell: Cell
83  /** How the hero is drawn here unless the person says otherwise: the pixel sprite or the 3D model. */
84  figure: 'pixel' | '3d'
85  font: string
86  /** The font's average advance at the caption's size, in stage units, to fit the bubble. */
87  charW: number
88  /** The caption on the flat stage. */
89  caption: CaptionStyle
90  /** The caption's cartoon paper on the rich stage; absent, the default paper. */
91  paper?: Paper
92  /** How far a frame the look draws reaches in, so the chapter tag clears it. */
93  inset?: number
94  /** The chapter tag's color; absent, the scene's accent. */
95  titleColor?: string
96  /**
97   * The medium a style is made on, as filter primitives from SourceGraphic over
98   * the drawn stage: the grid of tesserae or weave its elements
99   * are set into, or the wavering of daubed pigment.
100   */
101  grade?: (sw: number, h: number, pixel: boolean) => string
102  /** The color past the stage's edges, where a box wider than the stage shows. */
103  edge?: string
104  /** Over the drawn stage: paper, weave, grout. Static, so it costs nothing per frame. */
105  texture?: (sw: number, h: number, ground: number, pixel: boolean) => string
106  /** A frame round the stage, over the texture. */
107  frame?: (sw: number, h: number, ground: number) => string
108  /** The chapter tag, drawn in the look; absent, the plain tag. */
109  tag?: (text: string, inset: number) => Tag
110  /** Draw the stage as pixel art: only the original look's own pixel style does. */
111  pixel?: boolean
112  /** Set when the look is an artwork in its own right (see Art). */
113  art?: Art
114  /** The caption's bubble, when the style draws its own. */
115  bubble?: Bubble
116}
117
118const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
119
120/** The default caption paper (svg.ts draws it unchanged when a look names none). */
121export const PAPER: Paper = {
122  card: '#f6f1e7',
123  ink: '#2b2420',
124  kinds: { code: '#186a5a', path: '#2b5f9e', fn: '#7b3fa0', num: '#b5541a', bad: '#b3261e', good: '#2e7d32', face: '#c4613f' },
125}
126
127// ---------------------------------------------------------------- the looks
128
129/** The original look: the authored scenes as they are lit, drawn smooth. */
130const ORIGINAL: Look = {
131  name: 'original',
132  label: 'Original',
133  voice: '',
134  cell: 'solid',
135  figure: 'pixel',
136  font: MONO,
137  charW: 5.7,
138  caption: { fill: '#2b1c1a', stroke: '#cfc8b8', ink: '#ece9df', radius: 2 },
139}
140
141/** The original look in pixel art: the whole stage pixelized, Claude a sprite with an outline. The default. */
142const PIXEL: Look = { ...ORIGINAL, name: 'pixel', label: 'Pixel Art', pixel: true }
143
144export const LOOKS: Record<string, Look> = {
145  pixel: PIXEL,
146  original: ORIGINAL,
147
148  cave: CAVE,
149
150  blueprint: BLUEPRINT,
151
152  mosaic: MOSAIC,
153
154  aero: AERO,
155
156  engraving: ENGRAVING,
157
158  tapestry: TAPESTRY,
159
160  golden: GOLDEN,
161
162  ukiyoe: UKIYOE,
163
164  kamon: KAMON,
165}
166
167export const LOOK_NAMES = Object.keys(LOOKS)
168export const DEFAULT_LOOK = 'pixel'
169/** The styles from the gallery: every look but the original and its pixel art. */
170export const STYLE_NAMES = LOOK_NAMES.filter(name => name !== 'pixel' && name !== 'original')
171
172/** The look by name; an unknown name draws the default. */
173export const lookFor = (name: string | undefined): Look => LOOKS[name ?? DEFAULT_LOOK] ?? PIXEL
174
175const squash = (text: string) => text.toLowerCase().replace(/[^a-z]/g, '')
176
177/** The look a person means, leniently: "Ukiyo-e", "golden age", "frutiger aero" and "Copperplate" all find theirs. */
178export function findLook(text: string): Look | undefined {
179  const want = squash(text)
180  if (!want) return undefined
181  return Object.values(LOOKS).find(look => squash(look.name) === want || squash(look.label) === want || squash(look.label).includes(want) || squash(look.name).startsWith(want))
182}
183
hooks/svg.ts 1003 lines
1import type { FablesHeroAction, FablesProp, FablesScene } from '../types'
2
3import { type Motion, MOTION_TIMING } from './clawd3d'
4import { type HeroPainter, motionSvg } from './hero3d'
5import { gradeFilter } from './grade'
6import { MONOCRAFT, MONOCRAFT_BOLD } from './monocraft'
7import { ENTRANCE_SECONDS, TYPE_SECONDS_PER_CHAR } from './scene'
8import { richBackdrop } from './scenery'
9import { type Cell, type Look, lookFor, PAPER as CARD_PAPER, type Paper, type Tag, type WordKind } from './looks'
10import { HERO_FRAMES, PALETTE, SPRITES, type SpriteName } from './sprites'
11
12/**
13 * The logical stage: always H tall, and as wide as the band's shape asks, from
14 * MIN_W to MAX_W (W when nothing asks), so a wide window shows more of the
15 * scene rather than a bigger one.
16 */
17export const W = 640
18export const H = 128
19export const MIN_W = 320
20export const MAX_W = 1600
21/** One art pixel, in stage units. */
22export const U = 4
23const GROUND_Y = 104
24/** The desktop's Svg element takes at most this many characters. */
25export const MAX_SVG = 131072
26const BUDGET = 126000
27
28const FONT = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
29const INK = '#1f1e1d'
30const PAPER = '#ece9df'
31
32// ---------------------------------------------------------------- helpers
33
34export function escapeXml(text: string): string {
35  return text
36    .replace(/&/g, '&amp;')
37    .replace(/</g, '&lt;')
38    .replace(/>/g, '&gt;')
39    .replace(/"/g, '&quot;')
40    .replace(/'/g, '&apos;')
41}
42
43/** A small deterministic PRNG (mulberry32), so a scene always draws the same. */
44export function rng(seedText: string): () => number {
45  let seed = 2166136261
46  for (let i = 0; i < seedText.length; i++) seed = Math.imul(seed ^ seedText.charCodeAt(i), 16777619)
47  return () => {
48    seed = (seed + 0x6d2b79f5) | 0
49    let t = seed
50    t = Math.imul(t ^ (t >>> 15), t | 1)
51    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
52    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
53  }
54}
55
56type Rect = { x: number; y: number; w: number; h: number }
57
58const n = (v: number) => (Math.round(v * 100) / 100).toString()
59
60/**
61 * Pixel rows to compact SVG, in art-pixel units: one <path> per color, each
62 * horizontal run of a color one `M x y h w v 1 h -w z` segment.
63 */
64export function pixelPaths(rows: readonly string[], colorOf: (key: string) => string | undefined, _cell: Cell = 'solid'): string {
65  const byColor = new Map<string, string[]>()
66  rows.forEach((row, y) => {
67    let x = 0
68    while (x < row.length) {
69      const key = row[x] ?? '.'
70      const color = key === '.' ? undefined : colorOf(key)
71      let end = x + 1
72      while (end < row.length && row[end] === key) end++
73      if (color) {
74        const list = byColor.get(color) ?? []
75        list.push(`M${x} ${y}h${end - x}v1h-${end - x}z`)
76        byColor.set(color, list)
77      }
78      x = end
79    }
80  })
81  return [...byColor].map(([color, d]) => `<path fill="${color}" d="${d.join('')}"/>`).join('')
82}
83
84const widthOf = (rows: readonly string[]) => Math.max(0, ...rows.map(r => r.length))
85
86type Art = { rows: readonly string[]; colorOf: (key: string) => string | undefined }
87
88function artFor(prop: FablesProp): Art {
89  if (typeof prop.sprite === 'string') {
90    const sprite = SPRITES[prop.sprite as SpriteName] ?? SPRITES.star
91    const accent = prop.color ?? sprite.accent
92    return { rows: sprite.rows, colorOf: k => (k === 'a' ? accent : PALETTE[k]) }
93  }
94  const { pixels, colors } = prop.sprite
95  return { rows: pixels, colorOf: k => colors[k] }
96}
97
98// ---------------------------------------------------------------- backdrops
99
100type Stage = {
101  sky: string
102  ground: string
103  /** Where the hero's and props' feet rest. */
104  floor: number
105  back: string
106  front: string
107}
108
109function backdrop(scene: FablesScene, rand: () => number, sw: number): Stage {
110  const accent = scene.palette.accent
111  const parts: string[] = []
112  const front: string[] = []
113  const at = (v: number) => Math.round(v / U) * U
114  let sky = '#262624'
115  let ground = '#3a3833'
116  let floor = GROUND_Y
117
118  switch (scene.backdrop) {
119    case 'forest': {
120      sky = '#20261f'
121      ground = '#3c6e34'
122      for (let i = 0; i < Math.round((9 * sw) / W); i++) {
123        const x = at(rand() * sw)
124        const h = at(24 + rand() * 30)
125        parts.push(
126          `<rect x="${x}" y="${GROUND_Y - h}" width="${U * 3}" height="${h}" fill="#2a3a26"/>`,
127          `<rect x="${x - U * 2}" y="${GROUND_Y - h - U * 3}" width="${U * 7}" height="${U * 5}" fill="#2f4a2a"/>`,
128        )
129      }
130      for (let x = 0; x < sw; x += U * 5) {
131        const h = U * (1 + Math.floor(rand() * 3))
132        front.push(`<rect x="${x + at(rand() * 8)}" y="${GROUND_Y - h}" width="${U}" height="${h}" fill="#5e9c4a"/>`)
133      }
134      break
135    }
136    case 'space': {
137      sky = '#14121c'
138      ground = '#14121c'
139      floor = 92
140      for (let i = 0; i < 3; i++) {
141        const r = 4 + rand() * 12
142        parts.push(`<circle cx="${n(rand() * sw)}" cy="${n(10 + rand() * 60)}" r="${n(r)}" fill="${['#54408a', '#7b5fb5', '#8a8780'][i]}" opacity=".7"/>`)
143      }
144      break
145    }
146    case 'city': {
147      sky = '#1e2230'
148      ground = '#33353d'
149      let x = 0
150      while (x < sw) {
151        const w = at(24 + rand() * 40)
152        const h = at(30 + rand() * 50)
153        parts.push(`<rect x="${x}" y="${GROUND_Y - h}" width="${w - U}" height="${h}" fill="#2b2f3d"/>`)
154        for (let wy = GROUND_Y - h + U * 2; wy < GROUND_Y - U * 2; wy += U * 3) {
155          for (let wx = x + U; wx < x + w - U * 2; wx += U * 3) {
156            if (rand() < 0.35) {
157              const blink = rand() < 0.2 ? `<animate attributeName="opacity" values="1;.2;1" dur="${n(2 + rand() * 4)}s" repeatCount="indefinite"/>` : ''
158              parts.push(`<rect x="${wx}" y="${wy}" width="${U}" height="${U}" fill="#e3b341" opacity=".8">${blink}</rect>`)
159            }
160          }
161        }
162        x += w
163      }
164      break
165    }
166    case 'desert': {
167      sky = '#2e2620'
168      ground = '#c9a46a'
169      parts.push(`<circle cx="${n(80 + rand() * (sw - 160))}" cy="26" r="14" fill="#f0c060"/>`)
170      for (let i = 0; i < Math.round((4 * sw) / W); i++) {
171        const cx = rand() * sw
172        parts.push(`<ellipse cx="${n(cx)}" cy="${GROUND_Y}" rx="${n(60 + rand() * 60)}" ry="${n(8 + rand() * 10)}" fill="#a8844f"/>`)
173      }
174      break
175    }
176    case 'volcano': {
177      sky = '#2a1a17'
178      ground = '#4a2c20'
179      const vx = at(sw * 0.6 + rand() * (sw * 0.4 - 120))
180      parts.push(
181        `<polygon points="${vx},${GROUND_Y} ${vx + 50},${GROUND_Y - 70} ${vx + 66},${GROUND_Y - 70} ${vx + 116},${GROUND_Y}" fill="#5a3520"/>`,
182        `<rect x="${vx + 50}" y="${GROUND_Y - 74}" width="16" height="6" fill="#f06a2b"><animate attributeName="fill" values="#f06a2b;#e3b341;#f06a2b" dur="1.4s" repeatCount="indefinite"/></rect>`,
183      )
184      for (let i = 0; i < 6; i++) {
185        const dx = (rand() - 0.5) * 60
186        parts.push(
187          `<rect x="${vx + 56}" y="${GROUND_Y - 76}" width="${U}" height="${U}" fill="#f06a2b">` +
188            `<animateTransform attributeName="transform" type="translate" values="0 0;${n(dx)} -30;${n(dx * 1.6)} 10" dur="${n(1.6 + rand())}s" begin="${n(rand() * 2)}s" repeatCount="indefinite"/>` +
189            `<animate attributeName="opacity" values="1;1;0" dur="${n(1.6 + rand())}s" repeatCount="indefinite"/></rect>`,
190        )
191      }
192      break
193    }
194    case 'lab': {
195      sky = '#202628'
196      ground = '#3b3e47'
197      for (let x = 0; x < sw; x += 32) parts.push(`<rect x="${x}" y="0" width="1" height="${GROUND_Y}" fill="#2a3134"/>`)
198      for (let y = 0; y < GROUND_Y; y += 32) parts.push(`<rect x="0" y="${y}" width="${sw}" height="1" fill="#2a3134"/>`)
199      for (let x = 0; x < sw; x += U * 4) front.push(`<rect x="${x}" y="${GROUND_Y}" width="${U * 2}" height="${U}" fill="#5b5f6b"/>`)
200      break
201    }
202    case 'night':
203    default: {
204      sky = '#1b1d26'
205      ground = '#2c2e36'
206      parts.push(`<circle cx="${n(60 + rand() * (sw - 120))}" cy="24" r="10" fill="#e8e3c8"/>`)
207      break
208    }
209  }
210  if (accent) front.push(`<rect x="0" y="${GROUND_Y}" width="${sw}" height="2" fill="${accent}" opacity=".7"/>`)
211  return {
212    sky: scene.palette.sky ?? sky,
213    ground: scene.palette.ground ?? ground,
214    floor,
215    back: parts.join(''),
216    front: front.join(''),
217  }
218}
219
220/** The flat stage in the shape the scene draws: its front details sit on the ground, behind the props. */
221const flatStage = (s: Stage) => ({ sky: s.sky, ground: s.ground, floor: s.floor, groundTop: GROUND_Y, back: s.back, near: s.front, keep: [] as Rect[], lens: '' })
222
223// ---------------------------------------------------------------- particles
224
225function particles(scene: FablesScene, rand: () => number, sw: number): string {
226  if (!scene.particles) return ''
227  const { kind, density } = scene.particles
228  const count = Math.round((density * 36 * sw) / W)
229  const out: string[] = []
230  for (let i = 0; i < count; i++) {
231    const x = n(rand() * sw)
232    const y = n(rand() * GROUND_Y)
233    const dur = n(1.5 + rand() * 3)
234    const begin = n(-rand() * 4)
235    switch (kind) {
236      case 'stars':
237        out.push(
238          `<rect x="${x}" y="${n(rand() * 80)}" width="2" height="2" fill="#e3d9a0"><animate attributeName="opacity" values="1;.15;1" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></rect>`,
239        )
240        break
241      case 'rain':
242        out.push(
243          `<rect x="${x}" y="-8" width="1" height="6" fill="#6fa8d0"><animateTransform attributeName="transform" type="translate" values="0 0;-8 ${GROUND_Y + 8}" dur="${n(0.6 + rand() * 0.5)}s" begin="${begin}s" repeatCount="indefinite"/></rect>`,
244        )
245        break
246      case 'snow':
247        out.push(
248          `<rect x="${x}" y="-4" width="2" height="2" fill="${PAPER}"><animateTransform attributeName="transform" type="translate" values="0 0;6 ${GROUND_Y / 2};-4 ${GROUND_Y + 4}" dur="${n(4 + rand() * 4)}s" begin="${begin}s" repeatCount="indefinite"/></rect>`,
249        )
250        break
251      case 'bubbles':
252        out.push(
253          `<circle cx="${x}" cy="${GROUND_Y}" r="${n(1 + rand() * 2)}" fill="none" stroke="#9fd3e0"><animateTransform attributeName="transform" type="translate" values="0 0;4 -${GROUND_Y / 2};-2 -${GROUND_Y}" dur="${n(3 + rand() * 3)}s" begin="${begin}s" repeatCount="indefinite"/></circle>`,
254        )
255        break
256      case 'sparks':
257        out.push(
258          `<rect x="${x}" y="${y}" width="2" height="2" fill="#e3b341"><animateTransform attributeName="transform" type="translate" values="0 0;${n((rand() - 0.5) * 20)} -20" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></rect>`,
259        )
260        break
261      case 'leaves':
262        out.push(
263          `<rect x="${x}" y="-4" width="3" height="2" fill="${rand() < 0.5 ? '#c98a3a' : '#5e9c4a'}"><animateTransform attributeName="transform" type="translate" values="0 0;30 ${GROUND_Y / 2};10 ${GROUND_Y + 4}" dur="${n(4 + rand() * 3)}s" begin="${begin}s" repeatCount="indefinite"/></rect>`,
264        )
265        break
266    }
267  }
268  return out.join('')
269}
270
271// ---------------------------------------------------------------- props
272
273/** A hex color as rgb(), as prop labels have always been written. */
274function fixed(hex: string): string {
275  const m = /^#([0-9a-f]{6})$/i.exec(hex)
276  if (!m?.[1]) return hex
277  const v = parseInt(m[1], 16)
278  return `rgb(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255})`
279}
280
281/** Label colors: the prop's own tint. */
282export type TagStyle = { fill: string; ink: string; stroke: string }
283
284function label(text: string, cx: number, y: number, tag: TagStyle, sw: number): string {
285  const w = text.length * 5 + 8
286  const x = Math.min(sw - w - 2, Math.max(2, cx - w / 2))
287  const top = Math.max(2, y - 13)
288  return (
289    `<rect x="${n(x)}" y="${n(top)}" width="${w}" height="11" fill="${fixed(tag.fill)}" stroke="${fixed(tag.stroke)}" stroke-width="1"/>` +
290    `<text x="${n(x + 4)}" y="${n(top + 8)}" font-family="${FONT}" font-size="8" fill="${fixed(tag.ink)}">${escapeXml(text)}</text>`
291  )
292}
293
294/** One prop, drawn flat on the pixel stage, with its label tag above it. */
295function propSvg(prop: FablesProp, floor: number, index: number, sw: number, cell: Cell): string {
296  const art = artFor(prop)
297  const w = widthOf(art.rows) * U
298  const h = art.rows.length * U
299  const x = Math.round(((sw - w) * prop.x) / 100)
300  const y = prop.y === 'ground' ? floor - h : prop.y === 'air' ? 58 - h / 2 : 8
301  const cx = x + w / 2
302  const cy = y + h / 2
303  const body = `<g transform="translate(${x} ${n(y)}) scale(${U})">${pixelPaths(art.rows, art.colorOf, cell)}</g>`
304  const slow = n(2 + (index % 3) * 0.7)
305  let motion = ''
306  switch (prop.motion) {
307    case 'bob':
308      motion = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -${U};0 0" dur="${slow}s" repeatCount="indefinite"/>`
309      break
310    case 'drift':
311      motion = `<animateTransform attributeName="transform" type="translate" values="0 0;24 -2;0 0" dur="${n(6 + index)}s" repeatCount="indefinite"/>`
312      break
313    case 'shake':
314      motion = `<animateTransform attributeName="transform" type="translate" values="0 0;-2 0;2 0;0 0" dur=".25s" repeatCount="indefinite"/>`
315      break
316    case 'fall':
317      motion = `<animateTransform attributeName="transform" type="translate" values="0 -${n(y + h)};0 0;0 0" keyTimes="0;.4;1" dur="3s" repeatCount="indefinite"/>`
318      break
319    case 'spin':
320      motion = `<animateTransform attributeName="transform" type="rotate" values="0 ${n(cx)} ${n(cy)};360 ${n(cx)} ${n(cy)}" dur="${slow}s" repeatCount="indefinite"/>`
321      break
322    case 'blink':
323      motion = `<animate attributeName="opacity" values="1;.25;1" dur="1s" repeatCount="indefinite"/>`
324      break
325    case 'scroll':
326      motion = `<animateTransform attributeName="transform" type="translate" values="${sw - x} 0;${-x - w} 0" dur="${n(7 + index)}s" repeatCount="indefinite"/>`
327      break
328  }
329  const tint = prop.color ?? '#d9d4c7'
330  const style: TagStyle = { fill: INK, ink: tint, stroke: tint }
331  const tag = prop.label ? label(prop.label, cx, y, style, sw) : ''
332  return `<g>${body}${tag}${motion}</g>`
333}
334
335// ---------------------------------------------------------------- hero
336
337const HERO_W = 13 * U
338const HERO_H = 9 * U
339const heroColor = (k: string) => PALETTE[k]
340
341/** The hero's drawing, where it ends up, and when it gets there (seconds). */
342type HeroPlan = { svg: string; startX: number; endX: number; endY: number; arrive: number }
343
344/** How the hero is drawn: the pixel sprite in a cell style, or the 3D model painted one way. */
345export type Figure = { kind: 'pixel'; cell: Cell } | { kind: '3d'; hero?: HeroPainter; lean?: boolean; second?: number }
346
347/** The 3D model stands a little taller than the sprite, arms reaching past its box. */
348const MODEL_STAGE_H = 40
349
350/** The pixel sprite knows fewer moves: each newer action stands in as the nearest one it has. */
351const SPRITE_ACTION: Record<FablesHeroAction, FablesHeroAction> = {
352  walk: 'walk', run: 'run', fly: 'fly', dig: 'dig', inspect: 'inspect', celebrate: 'celebrate', think: 'think',
353  carry: 'walk', sneak: 'walk', jump: 'run', tumble: 'run',
354  panic: 'inspect', sleep: 'think', dance: 'celebrate', spin: 'celebrate', wave: 'celebrate', point: 'inspect', peek: 'inspect',
355  trip: 'think', shrug: 'think',
356}
357
358/** How fast Claude crosses the stage, per travelling action (units a second). */
359const SPEED: Partial<Record<FablesHeroAction, number>> = { run: 110, fly: 80, tumble: 90, jump: 60, carry: 30, sneak: 25 }
360
361/** How high Claude's moves take it above its box, for the caption to keep clear of. */
362export const heroReach = (action: FablesHeroAction): number =>
363  action === 'celebrate' ? 16 : action === 'jump' ? 18 : action === 'dance' ? 6 : action === 'fly' || action === 'panic' ? 2 : 0
364
365/** A loop that hands over to another plays this long first (seconds). */
366const LOOP_FIRST = (motion: Motion) => Math.max(2.4, 2 * MOTION_TIMING[motion].dur)
367
368function hero(scene: FablesScene, floor: number, sw: number, figure: Figure, tint: (svg: string) => string = svg => svg): HeroPlan {
369  const cell = figure.kind === 'pixel' ? figure.cell : 'solid'
370  // The sprite plays the nearest move it has, and no second one.
371  const action = figure.kind === 'pixel' ? SPRITE_ACTION[scene.hero.action] : scene.hero.action
372  const then = figure.kind === 'pixel' ? undefined : scene.hero.then
373  const kind = MOTION_TIMING[action].kind
374  const isMoving = kind === 'travel'
375  const span = sw - HERO_W
376  const fromX = Math.round((span * scene.hero.from) / 100)
377  const toX = Math.round((span * scene.hero.to) / 100)
378  const baseY = action === 'fly' ? 34 : floor - HERO_H
379  const speed = SPEED[action] ?? 45
380  const moveDur = isMoving ? Math.max(0.6, Math.abs(toX - fromX) / speed) : 0
381  const step = action === 'run' ? 0.18 : 0.32
382  const steps = isMoving ? Math.max(2, Math.round(moveDur / step)) : 0
383  const flip = toX < fromX ? ` translate(${HERO_W} 0) scale(-1 1)` : ''
384  // When the first move gives way to the next: on arrival, after a single play, or after a loop has had its turn.
385  // Flying keeps going unless told otherwise; walking, running and the rest stand idle on arrival.
386  const second: Motion | undefined =
387    figure.kind === 'pixel' ? undefined : (then ?? (kind === 'once' || (isMoving && action !== 'fly') ? 'idle' : undefined))
388  const handover = second === undefined ? undefined : isMoving ? moveDur : kind === 'once' ? MOTION_TIMING[action].dur : LOOP_FIRST(action)
389
390  const legs = (frame: number, values: string) =>
391    `<g opacity="${frame === 0 ? 1 : 0}" transform="scale(${U})">${pixelPaths(HERO_FRAMES[frame] ?? [], heroColor, cell)}` +
392    (steps
393      ? `<animate attributeName="opacity" values="${values}" dur="${step * 2}s" calcMode="discrete" repeatCount="${steps / 2}"/>`
394      : '') +
395    `</g>`
396  const model = (motion: Parameters<typeof motionSvg>[0], when: { until?: number; from?: number; maxFrames?: number } = {}) =>
397    figure.kind === '3d'
398      ? motionSvg(motion, { height: MODEL_STAGE_H, cx: HERO_W / 2, floor: HERO_H, yaw: 0.55, hero: figure.hero, ...when }).svg
399      : ''
400  const frames =
401    figure.kind === 'pixel'
402      ? legs(0, '1;0') + legs(1, '0;1')
403      : second === undefined || handover === undefined
404        ? model(action)
405        : model(action, { until: handover }) + model(second, { from: handover, maxFrames: figure.kind === '3d' ? (figure.lean ? 2 : figure.second) : undefined })
406
407  // A move's own sway, for the moves the 3D model does not bake into its frames; it stops when the next move begins.
408  const until = handover === undefined ? 'repeatCount="indefinite"' : `repeatDur="${n(handover)}s"`
409  let inner = ''
410  const baked = figure.kind === '3d'
411  switch (baked && action !== 'fly' && action !== 'inspect' ? 'baked' : action) {
412    case 'fly':
413      inner = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -${U};0 0" dur="1.2s" ${until} additive="sum"/>`
414      break
415    case 'dig':
416      inner = `<animateTransform attributeName="transform" type="rotate" values="-6 ${HERO_W / 2} ${HERO_H};6 ${HERO_W / 2} ${HERO_H};-6 ${HERO_W / 2} ${HERO_H}" dur=".4s" repeatCount="indefinite" additive="sum"/>`
417      break
418    case 'inspect':
419      inner = `<animateTransform attributeName="transform" type="translate" values="0 0;-${U * 2} 0;0 0;${U * 2} 0;0 0" dur="3s" ${until} additive="sum"/>`
420      break
421    case 'celebrate':
422      inner = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -16;0 0" keyTimes="0;.4;1" dur=".7s" repeatCount="indefinite" additive="sum"/>`
423      break
424    case 'walk':
425    case 'run':
426      inner = steps
427        ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -${U / 2}" dur="${step}s" calcMode="discrete" repeatCount="${steps}" additive="sum"/>`
428        : ''
429      break
430  }
431
432  // A move's flourishes; with a second move each set shows only while its own move plays.
433  const shown = (svg: string, begin?: number, end?: number) =>
434    !svg || (begin === undefined && end === undefined)
435      ? svg
436      : `<g visibility="hidden">${svg}<set attributeName="visibility" to="visible"${begin ? ` begin="${n(begin)}s"` : ''}${end !== undefined ? ` end="${n(end)}s"` : ''}/></g>`
437  const extras = shown(flourish(action, figure), handover === undefined ? undefined : 0, handover) + (second && handover !== undefined ? shown(flourish(second, figure), handover) : '')
438
439  const travel = isMoving
440    ? `<animateTransform attributeName="transform" type="translate" values="${fromX} ${baseY};${toX} ${baseY}" dur="${n(moveDur)}s" fill="freeze"/>`
441    : ''
442  const at = isMoving ? fromX : toX
443  const svg =
444    `<g transform="translate(${at} ${baseY})">${travel}` +
445    `<g>${inner}<g transform="${flip.trim() || 'translate(0 0)'}">${frames}</g>${tint(extras)}</g></g>`
446  return { svg, startX: isMoving ? fromX : toX, endX: toX, endY: baseY, arrive: isMoving ? moveDur : 0 }
447}
448
449/** The bits a move throws off: dirt from digging, confetti, the sprite's thinking dots, a sleeper's z's. */
450function flourish(action: Motion, figure: Figure): string {
451  // The pixel sprite thinks in dots; the 3D Claude's caption already sits where they would.
452  if (action === 'think' && figure.kind === 'pixel') {
453    return [0, 1, 2]
454      .map(
455        i =>
456          `<rect x="${HERO_W + 2 + i * 6}" y="-6" width="${U}" height="${U}" fill="${PAPER}"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.3;.8;1" dur="1.5s" begin="${i * 0.3}s" repeatCount="indefinite"/></rect>`,
457      )
458      .join('')
459  }
460  if (action === 'dig') {
461    return [0, 1, 2, 3]
462      .map(
463        i =>
464          `<rect x="${HERO_W / 2}" y="${HERO_H - 2}" width="${U}" height="${U}" fill="#7a4a2b"><animateTransform attributeName="transform" type="translate" values="0 0;${(i - 1.5) * 14} -18;${(i - 1.5) * 22} 4" dur=".9s" begin="${i * 0.2}s" repeatCount="indefinite"/></rect>`,
465      )
466      .join('')
467  }
468  if (action === 'celebrate') {
469    return [0, 1, 2, 3, 4]
470      .map(
471        i =>
472          `<rect x="${i * 12}" y="-4" width="3" height="3" fill="${['#e3b341', '#e05252', '#6fc2c9', '#5e9c4a', '#7b5fb5'][i]}"><animateTransform attributeName="transform" type="translate" values="0 0;${(i - 2) * 6} -20" dur="1s" begin="${i * 0.15}s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0" dur="1s" begin="${i * 0.15}s" repeatCount="indefinite"/></rect>`,
473      )
474      .join('')
475  }
476  // A sleeper's Z's: big outlined letters puffing up from over its head, growing as they drift away.
477  if (action === 'sleep') {
478    return [0, 1, 2, 3]
479      .map(
480        i =>
481          `<g transform="translate(${HERO_W * 0.62} ${HERO_H - MODEL_STAGE_H + 2})" opacity="0"><animateTransform attributeName="transform" type="translate" values="${HERO_W * 0.62} ${HERO_H - MODEL_STAGE_H + 2};${HERO_W * 0.62 + 10 + (i % 2) * 6} ${HERO_H - MODEL_STAGE_H - 30}" dur="2.8s" begin="${n(i * 0.7)}s" repeatCount="indefinite"/>` +
482          `<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.15;.7;1" dur="2.8s" begin="${n(i * 0.7)}s" repeatCount="indefinite"/>` +
483          `<g><animateTransform attributeName="transform" type="scale" values=".5;1.8" dur="2.8s" begin="${n(i * 0.7)}s" repeatCount="indefinite"/>` +
484          `<text x="0" y="0" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="900" fill="#f6f1e2" stroke="#1d1a2e" stroke-width="1.4" stroke-linejoin="round" paint-order="stroke">Z</text></g></g>`,
485      )
486      .join('')
487  }
488  // Tripped: stars circling over the fallen Claude while it lies dazed.
489  if (action === 'trip') {
490    const { dur } = MOTION_TIMING.trip
491    const star = (x: number, y: number) => `<path d="M${x} ${y - 5}l1.5 3.5 3.5 1.5-3.5 1.5-1.5 3.5-1.5-3.5-3.5-1.5 3.5-1.5z"/>`
492    return (
493      `<g opacity="0"><animate attributeName="opacity" values="0;0;1;1;0;0" keyTimes="0;.32;.36;.68;.72;1" dur="${dur}s" fill="freeze"/>` +
494      `<g transform="translate(${HERO_W / 2 + 8} ${HERO_H - MODEL_STAGE_H - 10}) scale(1 .45)"><g fill="#ffd84a" stroke="#3a2a10" stroke-width=".8" stroke-linejoin="round">` +
495      `<animateTransform attributeName="transform" type="rotate" values="0;360" dur=".9s" repeatCount="indefinite"/>` +
496      `${star(15, 0)}${star(-7.5, 13)}${star(-7.5, -13)}</g></g></g>`
497    )
498  }
499  return ''
500}
501
502// ---------------------------------------------------------------- caption
503
504type P = [number, number]
505
506/** The cartoon paper the caption is cut from: a soft drop shadow, an ink outline, then the paper. */
507export type Tone = 'work' | 'trouble' | 'milestone'
508const MONO_FONT = "Monocraft, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
509const MONO_FACE =
510  `<defs><style>@font-face{font-family:Monocraft;font-weight:400;src:url(data:font/woff2;base64,${MONOCRAFT}) format("woff2")}` +
511  `@font-face{font-family:Monocraft;font-weight:700;src:url(data:font/woff2;base64,${MONOCRAFT_BOLD}) format("woff2")}</style></defs>`
512const paper = (shape: string, skin: Paper) =>
513  `<g transform="translate(1.2 1.8)" fill="black" opacity=".22">${shape}</g>` +
514  `<g fill="${skin.ink}" stroke="${skin.ink}" stroke-width="2.2" stroke-linejoin="round">${shape}</g><g fill="${skin.card}">${shape}</g>`
515
516/** What a word of the caption is, so it can be set apart: the bubble stays the same, the words change. */
517type Kind = 'plain' | 'code' | 'path' | 'fn' | 'num' | 'bad' | 'good' | 'face'
518/** How a kind of word is set: in its color from the paper, and numbers, failures and successes in bold. */
519const kindAttrs = (kind: WordKind, skin: Paper) => `fill="${skin.kinds[kind]}"${kind === 'num' || kind === 'bad' || kind === 'good' ? ' font-weight="700"' : ''}`
520const FACE = /^(\^_\^|\^\^;?|>_<|>\.<|o_O|O_o|o\.O|:-?[)(DPpO3|/]|;-?\)|[xX]D|T_T|-_-|\._\.|\\o\/|<3|¯\\_\(ツ\)_\/¯|\(•_•\)|\(⌐■_■\)|->|=>|<-|~>|>>>|\.\.\.|\[(OK|ok|WIP|TODO|DONE)\]|\/\/|#!|\$|&&|\|\|)$/
521const BAD = /^(✗|FAIL(ED)?|failed|failing|fails|broke|broken|crash(ed|es)?|red|\w*Error|\w*Exception|N\+1|panic|segfault|404|500)$/
522const GOOD = /^(✓|PASS(ED)?|passed|passes|passing|green|clean|fixed|ships?|shipped|done|OK|merged|liftoff|LGTM)$/i
523const NUM = /^[~+\-]?\d[\d.,]*(%|x|×|s|ms|kb|mb|gb|k)?$|^\d+\/\d+$/i
524const FN = /^[\w.$#]+\(\)$/
525const PATH = /^[\w@~./-]*\w\.(tsx?|jsx?|mjs|cjs|py|rb|go|rs|java|kt|swift|json|ya?ml|toml|css|scss|html|md|sql|sh|lock|env|test\.ts)$|^~?\.?\/?[\w@.-]+\/[\w@./-]*$/i
526const CMD = /^(npm|npx|pnpm|yarn|bun|git|pytest|tsc|eslint|cargo|go|make|pip|docker|curl|gh)$|^--?\w/
527
528type Word = { lead: string; core: string; tail: string; kind: Kind }
529
530/** The caption as words with their kinds. `backticks` mark code, and are not shown. */
531function words(text: string, tone: Tone | undefined): Word[] {
532  const out: Word[] = []
533  // The tone leads the caption as a mark, unless the narrator already wrote one.
534  const mark = tone === 'trouble' ? '✗' : tone === 'milestone' ? '✓' : ''
535  if (mark && !text.trimStart().startsWith(mark)) out.push({ lead: '', core: mark, tail: '', kind: tone === 'trouble' ? 'bad' : 'good' })
536  let inCode = false
537  for (const raw of text.split(' ').filter(Boolean)) {
538    const opens = raw.startsWith('`')
539    const ticks = (raw.match(/`/g) ?? []).length
540    const code = inCode || opens
541    if (ticks % 2 === 1) inCode = !inCode
542    const bare = raw.replace(/`/g, '')
543    if (!bare) continue
544    if (FACE.test(bare)) {
545      out.push({ lead: '', core: bare, tail: '', kind: code ? 'code' : 'face' })
546      continue
547    }
548    // A call keeps its own brackets; only what follows them is punctuation.
549    const call = /^(.*\(\))([.,;:!?]*)$/.exec(bare)
550    const m = call && FN.test(call[1] ?? '') ? ['', '', call[1], call[2]] : /^([("'[]*)(.*?)([.,;:!?)"'\]]*)$/.exec(bare)
551    const [lead, core, tail] = m ? [m[1] ?? '', m[2] ?? '', m[3] ?? ''] : ['', bare, '']
552    const kind: Kind = !core
553      ? 'plain'
554      : code || CMD.test(core)
555        ? 'code'
556        : BAD.test(core)
557          ? 'bad'
558          : GOOD.test(core)
559            ? 'good'
560            : FN.test(core)
561              ? 'fn'
562              : PATH.test(core)
563                ? 'path'
564                : NUM.test(core)
565                  ? 'num'
566                  : 'plain'
567    out.push({ lead, core, tail, kind })
568  }
569  return out
570}
571/** Columns a string takes in a monospace font: East Asian wide characters take two. */
572const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/g
573const cols = (t: string) => t.length + (t.match(WIDE)?.length ?? 0)
574const wordLen = (w: Word) => cols(w.lead) + cols(w.core) + cols(w.tail)
575
576/** The most lines a bubble takes, so it stays clear of the ground. */
577const MAX_LINES = 4
578
579/** Greedy wrap of words into lines of at most `width` characters; a word longer than the line is cut. */
580function wrapWords(list: readonly Word[], width: number): Word[][] {
581  const lines: Word[][] = []
582  let line: Word[] = []
583  let len = 0
584  for (const w0 of list) {
585    const w = wordLen(w0) > width ? { ...w0, core: w0.core.slice(0, Math.max(1, width - w0.lead.length - w0.tail.length)) } : w0
586    if (line.length && len + 1 + wordLen(w) > width) {
587      lines.push(line)
588      line = []
589      len = 0
590    }
591    len += (line.length ? 1 : 0) + wordLen(w)
592    line.push(w)
593  }
594  if (line.length) lines.push(line)
595  return lines
596}
597const lineLen = (line: readonly Word[]) => line.reduce((t, w) => t + wordLen(w), 0) + line.length - 1
598const lineSvg = (line: readonly Word[], skin: Paper) =>
599  line.map(w => escapeXml(w.lead) + (w.kind === 'plain' ? escapeXml(w.core) : `<tspan ${kindAttrs(w.kind, skin)}>${escapeXml(w.core)}</tspan>`) + escapeXml(w.tail)).join(' ')
600
601const overlap = (a: Rect, b: Rect) =>
602  Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y))
603
604/** Where a prop and its label sit while it stays put; a scrolling prop crosses the whole stage and blocks nothing. */
605function propRects(prop: FablesProp, floor: number, sw: number, unit: number): Rect[] {
606  if (prop.motion === 'scroll') return []
607  const art = artFor(prop)
608  const w = widthOf(art.rows) * unit
609  const h = art.rows.length * unit
610  const x = Math.round(((sw - w) * prop.x) / 100)
611  const y = prop.y === 'ground' ? floor - h : prop.y === 'air' ? 58 - h / 2 : 8
612  const reach = prop.motion === 'drift' ? 24 : prop.motion === 'bob' ? 0 : 2
613  const rects: Rect[] = [{ x: x - 2, y: y - 4, w: w + reach + 4, h: h + 6 }]
614  if (prop.label) {
615    const lw = prop.label.length * 5 + 8
616    rects.push({ x: Math.min(sw - lw - 2, Math.max(2, x + w / 2 - lw / 2)), y: Math.max(2, y - 13), w: lw, h: 11 })
617  }
618  return rects
619}
620
621/**
622 * The speech bubble. It stays with Claude: of the places just beside and just
623 * above Claude it takes the one that covers the least of Claude, the props,
624 * the chapter tag and the scene's focal points, and while Claude walks it
625 * travels along. On the rich stage it is cut from cartoon paper with a tail
626 * pointing back at Claude, and kinds of words (files, functions, commands,
627 * numbers, failures, successes, ASCII faces) are set apart in the text.
628 */
629function caption(
630  text: string,
631  hero: { startX: number; x: number; y: number; arrive: number; jump: number; sway: number },
632  idPrefix: string,
633  sw: number,
634  look: Look,
635  avoid: readonly Rect[],
636  tone?: Tone,
637  /** When the caption starts typing, in seconds: later when the scene fades in. */
638  start = 0.2,
639): string {
640  const { fill, stroke, radius } = look.caption
641  const onPaper = tone !== undefined
642  const skin = look.paper ?? CARD_PAPER
643  const ink = onPaper ? skin.ink : look.caption.ink
644  const heroX = hero.x
645  const heroY = hero.y
646  // Claude's box, reaching as high as Claude jumps.
647  const heroBox: Rect = { x: heroX - 4 - hero.sway, y: heroY - 8 - hero.jump, w: HERO_W + 8 + hero.sway * 2, h: HERO_H + 12 + hero.jump }
648  const top = heroY + HERO_H - MODEL_STAGE_H - hero.jump
649  const list = onPaper ? words(look.bubble?.upper ? text.toUpperCase() : text, tone) : text.split(' ').filter(Boolean).map((core): Word => ({ lead: '', core, tail: '', kind: 'plain' }))
650  // On paper the caption is set in Monocraft, whose pixel is a ninth of its size: at 9 units
651  // its pixels land on whole device pixels in the band. Elsewhere the look's own type.
652  const type = onPaper ? (look.bubble ?? { font: MONO_FONT, size: 9, charW: 6, line: 12, base: 13 }) : { font: look.font, size: 9.5, charW: look.charW, line: 11, base: 12 }
653  // Claude's path while it walks: the bubble must stay clear of Claude at every point of it.
654  const KEYS = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]
655  const heroAt = (k: number) => hero.startX + (heroX - hero.startX) * k
656  const boxAt = (k: number): Rect => ({ ...heroBox, x: heroAt(k) - 4 - hero.sway })
657  const clampX = (v: number, w: number) => Math.min(sw - w - 2, Math.max(2, v))
658  // How far the stage's edge holds the bubble back from Claude at a point of the path: while it
659  // does, the bubble stands still as Claude walks on, and its tail no longer points at Claude.
660  const slipAt = (k: number, ox: number, w: number) => Math.abs(clampX(heroAt(k) + ox, w) - (heroAt(k) + ox))
661  // The point of the walk from which the bubble travels with Claude to the end; before it the
662  // bubble waits, unseen, so it never stands about with its tail pointing away from Claude.
663  const FINE = Array.from({ length: 101 }, (_, i) => i / 100)
664  const joinsAt = (ox: number, w: number) => {
665    if (!hero.arrive) return 0
666    let k = 1
667    while (k > 0 && slipAt(Math.round((k - 0.01) * 100) / 100, ox, w) < 1) k = Math.round((k - 0.01) * 100) / 100
668    return FINE.includes(k) && slipAt(k, ox, w) < 1 ? k : 1
669  }
670  // The bubble's offsets that keep it on stage the whole way, so it travels with Claude as one.
671  const lo = 2 - Math.min(hero.startX, heroX)
672  const hi = (w: number) => sw - w - 2 - Math.max(hero.startX, heroX)
673  const isRightward = heroX > hero.startX
674  // A narrower wrap, taller, is tried when the wide one finds no clear place; one that
675  // would need more than four lines is not, as every word of the caption is shown.
676  let best = { x: 2, y: 4, ox: 0, score: Infinity, lines: [] as Word[][], w: 0, h: 0 }
677  const wraps = [32, 25, 19, 15].map(width => wrapWords(list, width))
678  const fits = wraps.filter(lines => lines.length <= MAX_LINES)
679  for (const [wi, lines] of (fits.length ? fits : wraps.slice(0, 1)).entries()) {
680    const w = Math.ceil(Math.max(...lines.map(lineLen)) * type.charW + 14)
681    const h = lines.length * type.line + 7
682    const beside = Math.min(GROUND_Y - h - 4, Math.max(4, top - 4))
683    const above = top - h - 8
684    const below = heroY + HERO_H + 9
685    // Where the bubble sits relative to Claude: beside it at head height, or above it.
686    const spots: P[] = [
687      [HERO_W + 8, beside],
688      [-w - 8, beside],
689      [HERO_W / 2 - w / 2, above],
690      [HERO_W - 10, above],
691      [-w + 10, above],
692      // Under Claude, for when it flies and there is no room above it.
693      [HERO_W / 2 - w / 2, below],
694    ]
695    for (const [si, [ox0, y0]] of spots.entries()) {
696      const y = y0 === below ? Math.min(H - h - 3, y0) : Math.min(GROUND_Y - h - 4, Math.max(4, y0))
697      // Over or under Claude the bubble may sit anywhere along it, its tail still on Claude:
698      // slid along, it can often travel the whole walk without meeting an edge.
699      // When no offset keeps it on stage the whole way, it keeps clear of the edge Claude walks
700      // towards, so the edge can only hold it back at the start, and it joins Claude early.
701      const isOver = si >= 2
702      const [a, b] = [Math.max(lo, -w + 14), Math.min(hi(w), HERO_W - 14)]
703      const along = Math.min(HERO_W - 14, Math.max(-w + 14, ox0))
704      const ox = !isOver ? ox0 : a <= b ? Math.min(b, Math.max(a, ox0)) : isRightward ? Math.max(-w + 14, Math.min(along, hi(w))) : Math.min(HERO_W - 14, Math.max(along, lo))
705      const x = clampX(heroX + ox, w)
706      // Covering Claude at any moment is ruled out in all but name; being held back by an edge
707      // while Claude walks on nearly so; the rest is preference.
708      const covers = KEYS.reduce((t, k) => t + overlap({ x: clampX(heroAt(k) + ox, w), y, w, h }, boxAt(k)), 0)
709      // Every second the bubble would wait for Claude counts against it.
710      const waits = joinsAt(ox, w) * hero.arrive
711      const score = covers * 50 + waits * 3000 + avoid.reduce((t, a) => t + overlap({ x, y, w, h }, a) * 3, 0) + si * 6 + wi * 40
712      if (score < best.score) best = { x, y, ox, score, lines, w, h }
713    }
714  }
715  const { lines, w, h, x, y, ox } = best
716  // A walk too long for any bubble to travel with Claude the whole way: the bubble waits until
717  // Claude is far enough in, then appears beside it and travels on with it, tail on Claude.
718  const joins = joinsAt(ox, w)
719  const appear = joins * hero.arrive
720  let delay = start + appear
721  const rows = lines
722    .map((line, i) => {
723      const dur = Math.max(0.2, lineLen(line) * TYPE_SECONDS_PER_CHAR)
724      const id = `${idPrefix}${i}`
725      const reveal =
726        `<clipPath id="${id}"><rect x="${n(x)}" y="${n(y + 2 + i * type.line)}" width="0" height="${type.line + 2}">` +
727        `<animate attributeName="width" from="0" to="${w}" dur="${n(dur)}s" begin="${n(delay)}s" fill="freeze"/></rect></clipPath>`
728      delay += dur
729      return reveal + `<text clip-path="url(#${id})" x="${n(x + 7)}" y="${n(y + type.base + i * type.line)}" font-family="${type.font}" font-size="${type.size}" fill="${ink}">${lineSvg(line, skin)}</text>`
730    })
731    .join('')
732  // While Claude walks, the bubble walks with it along the same path, held inside the stage;
733  // from the moment it joins Claude it moves as one with it, so that moment is a key of its own.
734  const follows = [...new Set([0, joins, ...KEYS.filter(k => k > joins)])].sort((p, q) => p - q)
735  const follow =
736    hero.arrive && hero.startX !== heroX
737      ? ` transform="translate(${n(clampX(heroAt(0) + ox, w) - x)} 0)"><animateTransform attributeName="transform" type="translate" values="${follows.map(k => `${n(clampX(heroAt(k) + ox, w) - x)} 0`).join(';')}" keyTimes="${follows.join(';')}" dur="${n(hero.arrive)}s" fill="freeze"/`
738      : ''
739  const fade = `<animate attributeName="opacity" values="0;1" dur=".2s"${appear ? ` begin="${n(appear)}s"` : ''} fill="freeze"/>`
740  // The wait is written on the bubble, so the narrator's loop can give the caption its full reading time after it.
741  const hidden = appear ? ` opacity="0" data-speaks="${n(appear)}"` : ''
742  if (onPaper) {
743    const hx = heroX + HERO_W / 2
744    const hy = top + 10
745    let base: [P, P]
746    if (x >= hx) base = [[x + 2, y + h - 13], [x + 2, y + h - 5]]
747    else if (x + w <= hx) base = [[x + w - 2, y + h - 13], [x + w - 2, y + h - 5]]
748    else {
749      const cx = Math.min(x + w - 12, Math.max(x + 12, hx))
750      base = y + h < hy ? [[cx - 4, y + h - 2], [cx + 4, y + h - 2]] : [[cx - 4, y + 2], [cx + 4, y + 2]]
751    }
752    const mx = (base[0][0] + base[1][0]) / 2
753    const my = (base[0][1] + base[1][1]) / 2
754    const len = Math.hypot(hx - mx, hy - my) || 1
755    const reach = Math.min(10, len * 0.6)
756    const tip: P = [mx + ((hx - mx) / len) * reach, my + ((hy - my) / len) * reach]
757    if (look.bubble) return `<g data-part="speech" data-tone="${tone}"${hidden}${follow || ''}>${look.bubble.font.includes('Monocraft') ? MONO_FACE : ''}${look.bubble.draw({ x, y, w, h }, { base, tip })}${rows}${fade}</g>`
758    const shape =
759      `<rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" rx="6"/>` +
760      `<path d="M${n(base[0][0])} ${n(base[0][1])}L${n(tip[0])} ${n(tip[1])}L${n(base[1][0])} ${n(base[1][1])}z"/>`
761    return `<g data-part="speech" data-tone="${tone}"${hidden}${follow || ''}>${MONO_FACE}${paper(shape, skin)}${rows}${fade}</g>`
762  }
763  return `<g data-part="speech"${hidden}${follow || ''}><rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" rx="${radius}" fill="${fill}" stroke="${stroke}" stroke-width="1"/>${rows}${fade}</g>`
764}
765
766function title(text: string, color: string, look: Look): string {
767  const w = text.length * (look.charW - 0.3) + 16
768  const inset = look.inset ?? 0
769  return (
770    `<g font-family="${look.font}" font-size="9" fill="${color}" transform="translate(${inset} ${inset})">` +
771    `<path d="M6 12V6h6M${n(6 + w)} 12V6h-6" fill="none" stroke="${color}" stroke-width="1"/>` +
772    `<text x="14" y="10">${escapeXml(text)}</text></g>`
773  )
774}
775
776/** The chapter tag as the look draws it, or the plain tag with the room it takes. */
777function chapterTag(text: string, color: string, look: Look, rich: boolean): Tag {
778  const inset = look.inset ?? 0
779  if (look.tag) return look.tag(text, inset)
780  const shown = rich ? { ...look, font: MONO_FONT, charW: 6.3 } : look
781  return { svg: title(text, color, shown), w: text.length * look.charW + 34 + inset, h: 16 + inset }
782}
783
784// ---------------------------------------------------------------- the scene
785
786/** The stage width for a box of the given shape, so the scene fills it exactly. */
787export function stageWidth(width: number, height: number): number {
788  const fit = Number.isFinite(width / height) && width > 0 && height > 0 ? (width * H) / height : W
789  return Math.round(Math.min(MAX_W, Math.max(MIN_W, fit)))
790}
791
792/** The pixel-art Claude's eye and outline colors. */
793const SPRITE_EYE = '#1f1412'
794const SPRITE_OUTLINE = '#3a1e14'
795/** How many stage units one pixel of the pixelized stage takes. */
796const PIXEL = 2
797/**
798 * Turns everything drawn into pixel art, the scenery and Claude alike, without
799 * redrawing any of it: one sample is taken at the middle of every PIXEL square
800 * (a tiny dot of a tiled grid, cut out of the drawing) and spread over its whole
801 * square. The grid starts at the stage's corner, so pixels line up with it.
802 */
803const PIXELIZE = (sw: number, claude: Rect, grade = '', sprite = { eye: SPRITE_EYE, outline: SPRITE_OUTLINE }) => {
804  const dot = 0.4
805  const r = n((PIXEL - dot) / 2)
806  // The grid: one dot tiled every PIXEL, seeded inside the filter's region on a whole pixel
807  // from the stage's corner, so every filter here samples the same squares.
808  const grid = (at: Rect) => {
809    const gx = Math.ceil(at.x / PIXEL) * PIXEL
810    const gy = Math.ceil(at.y / PIXEL) * PIXEL
811    return (
812      `<feFlood x="${n(gx + (PIXEL - dot) / 2)}" y="${n(gy + (PIXEL - dot) / 2)}" width="${dot}" height="${dot}" flood-color="black"/>` +
813      `<feComposite x="${n(gx)}" y="${n(gy)}" width="${PIXEL}" height="${PIXEL}"/><feTile result="grid"/>`
814    )
815  }
816  // A sample at the middle of each square, spread over the square.
817  const sample = (from: string, result: string) =>
818    `<feComposite in="${from}" in2="grid" operator="in"/><feMorphology operator="dilate" radius="${r}" result="${result}"/>`
819  const region = (at: Rect) => `filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" x="${n(at.x)}" y="${n(at.y)}" width="${n(at.w)}" height="${n(at.h)}"`
820  const stage = { x: 0, y: 0, w: sw, h: H }
821  return (
822    // Claude, on the same grid, as a sprite: its solid body sampled crisp; its dark features
823    // (eyes, finer than a pixel) found by their darkness, thickened just enough to land on
824    // whole pixels and set solid; a one-pixel dark outline round the body. The translucent
825    // ground shadow is sampled too, under it all, but kept out of the outline and the eyes.
826    `<filter id="sc-pixelize-claude" ${region(claude)} color-interpolation-filters="sRGB">` +
827    grid(claude) +
828    `<feComponentTransfer in="SourceGraphic" result="solid"><feFuncA type="discrete" tableValues="0 1"/></feComponentTransfer>` +
829    sample('SourceGraphic', 'all') +
830    sample('solid', 'sampled') +
831    `<feComponentTransfer in="sampled" result="body"><feFuncA type="discrete" tableValues="0 1"/></feComponentTransfer>` +
832    `<feColorMatrix in="SourceGraphic" type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  -9 -17.7 -3.3 0 3.6"/>` +
833    `<feComposite in2="solid" operator="in"/><feMorphology operator="dilate" radius=".7" result="dark"/>` +
834    sample('dark', 'darkpx') +
835    `<feComponentTransfer in="darkpx"><feFuncA type="discrete" tableValues="0 1"/></feComponentTransfer>` +
836    `<feComposite in2="body" operator="in" result="eyemask"/>` +
837    `<feFlood flood-color="${sprite.eye}"/><feComposite in2="eyemask" operator="in" result="eyes"/>` +
838    `<feMorphology in="body" operator="dilate" radius="${PIXEL}"/><feComposite in2="body" operator="out" result="ring"/>` +
839    `<feFlood flood-color="${sprite.outline}"/><feComposite in2="ring" operator="in" result="outline"/>` +
840    `<feMerge><feMergeNode in="all"/><feMergeNode in="outline"/><feMergeNode in="body"/><feMergeNode in="eyes"/></feMerge></filter>` +
841    // The rest sampled crisp, at the middle of each square, so colors stay as rich as drawn. A look's
842    // grade runs first in the same filter: one pass over the stage costs far less than two nested.
843    `<filter id="sc-pixelize" ${region(stage)} color-interpolation-filters="sRGB">` +
844    (grade ? `${grade}<feMerge result="graded"><feMergeNode/></feMerge>` : '') +
845    `${grid(stage)}${sample(grade ? 'graded' : 'SourceGraphic', 'px')}</filter>`
846  )
847}
848
849/**
850 * Compiles a validated scene into one self-animating SVG document (SMIL), so
851 * the desktop plays it with no redraws. The stage takes the box's shape
852 * (`width` by `height` CSS pixels; absent, the default stage at its own size).
853 * `look` names the graphic style (looks.ts); absent, plain pixel art.
854 * Stays under the Svg element's limit by shedding particles, then props, if a
855 * scene is too rich.
856 */
857export function sceneToSvg(
858  scene: FablesScene,
859  options: {
860    width?: number
861    height?: number
862    look?: string
863    figure?: 'auto' | 'pixel' | '3d'
864  } = {},
865): string {
866  const sw = options.width && options.height ? stageWidth(options.width, options.height) : W
867  const width = options.width ?? sw
868  const height = options.height ?? Math.round((width * H) / sw)
869  const look = lookFor(options.look)
870  const rand = rng(`${scene.backdrop}|${scene.caption}`)
871  const wants = options.figure && options.figure !== 'auto' ? options.figure : look.figure
872  // The 3D Claude gets scenery and props with depth to match; the pixel sprite keeps the flat pixel stage.
873  const rich = wants === '3d'
874  // The pixel-art look (the default) pixelizes the whole stage crisply; drawn smooth, the scenery takes a soft blur instead.
875  const pixelArt = rich && look.pixel === true
876  // A style drawn as an artwork repaints every element of the scenery, and Claude, in its own medium.
877  const art = rich ? look.art : undefined
878  const painter = art?.painter(sw)
879  const figure: Figure = rich ? { kind: '3d', hero: art?.hero() } : { kind: 'pixel', cell: look.cell }
880  // Smooth, the scenery is softened so Claude reads first; a style brings its own treatment instead,
881  // and softening under a grade would be redone on every frame.
882  const stageAt = (lean: boolean) =>
883    rich ? richBackdrop(scene, rng(`${scene.backdrop}|${scene.caption}|stage`), sw, GROUND_Y, W, lean, !pixelArt && !look.grade && !art, painter) : flatStage(backdrop(scene, rand, sw))
884  let stage = stageAt(false)
885  const dust = painter ? painter.el('particles', particles(scene, rand, sw)) : particles(scene, rand, sw)
886  // The rich stage tells the story in words alone: no props stand about the scene.
887  const props = rich ? [] : scene.props.map((p, i) => propSvg(p, stage.floor, i, sw, look.cell))
888  // A style paints Claude's confetti and dug-up earth in its own inks too.
889  const tint = painter ? (svg: string) => painter.el('particles', svg) : undefined
890  let plan = hero(scene, stage.floor, sw, figure, tint)
891  const accent = scene.palette.accent ?? '#d9d4c7'
892  const idPrefix = `fable${Math.floor(rand() * 2 ** 31).toString(36)}-`
893  const entrance = scene.enter === 'fade' ? ENTRANCE_SECONDS : 0
894  const sky = art?.sky ?? stage.sky
895  const ground = art?.ground ?? stage.ground
896  // A look's grade takes the stage, Claude and all, before any pixelizing; past the stage's edges its own color shows.
897  const gradeBody = look.grade?.(sw, H, pixelArt) ?? ''
898  // Smooth, the grade is a filter of its own; on a pixel-art stage it runs inside the pixelizer.
899  const grade = gradeBody && !pixelArt ? `<defs>${gradeFilter('lk-grade', sw, H, gradeBody)}</defs>` : ''
900  const edge = look.edge ?? sky
901  const tag = scene.title ? chapterTag(scene.title, look.titleColor ?? (rich ? '#efe6d2' : accent), look, rich) : undefined
902  // What the speech bubble keeps clear of: the props still drawn, their labels, the chapter tag.
903  const avoid = (propCount: number): Rect[] => [
904    ...scene.props.slice(0, propCount).flatMap(p => propRects(p, stage.floor, sw, U)),
905    ...(tag ? [{ x: 0, y: 0, w: tag.w, h: tag.h }] : []),
906    ...stage.keep,
907  ]
908  // Everywhere Claude goes, jumps and digs included: the region its own pixelizing covers.
909  const claudeBox: Rect = { x: Math.min(plan.startX, plan.endX) - 30, y: plan.endY - 40, w: Math.abs(plan.endX - plan.startX) + HERO_W + 60, h: HERO_H + 56 }
910  const fore = (withDust: boolean, propCount: number) =>
911    (withDust ? dust : '') + props.slice(0, propCount).join('') + (pixelArt ? `<g filter="url(#sc-pixelize-claude)">${plan.svg}</g>` : plan.svg)
912
913  // Sky and ground run far past the stage, so a box the stage could not match
914  // (past MIN_W or MAX_W) shows more sky and ground instead of the frame's page.
915  const build = (withDust: boolean, propCount: number) =>
916    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${sw} ${H}" width="${width}" height="${height}" ` +
917    `shape-rendering="crispEdges" preserveAspectRatio="xMidYMid meet" style="display:block;background:${look.grade ? edge : ground}"${look.grade || art ? ` data-look="${look.name}"` : ''}>` +
918    grade +
919    `<rect x="${-sw * 4}" y="${-H * 4}" width="${sw * 9}" height="${H * 4 + GROUND_Y}" fill="${look.grade ? edge : sky}"/>` +
920    (art?.defs ? `<defs>${art.defs(sw, H)}</defs>` : '') +
921    (pixelArt ? `<defs>${PIXELIZE(sw, claudeBox, gradeBody)}</defs><g filter="url(#sc-pixelize)">` : '') +
922    (grade ? `<g filter="url(#lk-grade)">` : '') +
923    // The graded stage carries its own sky, so the grade sees the whole picture.
924    (look.grade ? `<rect width="${sw}" height="${H}" fill="${sky}"/>` : '') +
925    (art?.under?.(sw, H) ?? '') +
926    stage.back +
927    `<rect x="${-sw * 4}" y="${stage.groundTop}" width="${sw * 9}" height="${H * 4}" fill="${ground}"/>` +
928    // Ground-level scenery goes down before anything that stands on it.
929    stage.near +
930    fore(withDust, propCount) +
931    stage.lens +
932    (grade ? '</g>' : '') +
933    (pixelArt ? '</g>' : '') +
934    (look.texture?.(sw, H, GROUND_Y, pixelArt) ?? '') +
935    (look.frame?.(sw, H, GROUND_Y) ?? '') +
936    (tag?.svg ?? '') +
937    caption(scene.caption, { startX: plan.startX, x: plan.endX, y: plan.endY, arrive: plan.arrive, jump: Math.max(heroReach(scene.hero.action), scene.hero.then ? heroReach(scene.hero.then) : 0), sway: scene.hero.action === 'inspect' ? 2 * U : 0 }, idPrefix, sw, look, avoid(propCount), rich ? (scene.tone ?? (scene.hero.action === 'celebrate' ? 'milestone' : 'work')) : undefined, entrance + 0.2) +
938    // A new setting fades in from the dark, so the band never jumps from one place to the next.
939    (entrance ? `<rect x="${-sw * 4}" y="${-H * 4}" width="${sw * 9}" height="${H * 9}" fill="#0b0b10" pointer-events="none"><animate attributeName="opacity" values="1;0" dur="${n(entrance)}s" fill="freeze"/></rect>` : '') +
940    `</svg>`
941
942  const propsShown = props.length
943  let svg = build(true, propsShown)
944  // Over the limit, Claude's second move is baked in fewer poses first; then the scenery
945  // thins out, then the particles go, and only then the props.
946  if (svg.length > BUDGET && figure.kind === '3d') {
947    plan = hero(scene, stage.floor, sw, { ...figure, second: 6 }, tint)
948    svg = build(true, propsShown)
949  }
950  if (svg.length > BUDGET && rich) {
951    stage = stageAt(true)
952    svg = build(true, propsShown)
953  }
954  if (svg.length > BUDGET) svg = build(false, propsShown)
955  // Then Claude, once he has arrived, stands breathing in two poses instead of a full idle loop.
956  if (svg.length > BUDGET && figure.kind === '3d') {
957    plan = hero(scene, stage.floor, sw, { ...figure, lean: true }, tint)
958    svg = build(false, propsShown)
959  }
960  // Still over (a look whose colors spell long), the rich stage gives way to the flat one
961  // before any prop goes; the flat stage always fits.
962  if (svg.length > BUDGET && rich) {
963    stage = flatStage(backdrop(scene, rand, sw))
964    svg = build(false, propsShown)
965  }
966  for (let count = propsShown - 1; svg.length > BUDGET && count >= 0; count--) svg = build(false, count)
967  return svg
968}
969
970/**
971 * The same drawing, its clock already `seconds` along: every animation begins
972 * that much earlier, so a scene drawn again while it is up (the band redrawn
973 * when the turn ends, a new width, a new style) carries on from where it was:
974 * the caption as far typed as it was, Claude as far along. Every begin in a
975 * scene is a plain offset in seconds, and an animation without one begins at 0.
976 *
977 * An animation whose whole run would fall before the drawing starts is never
978 * played at all, its frozen last frame included (SMIL drops an interval that
979 * ends before its parent begins), so the caption would vanish: one that holds
980 * its last frame is moved back only so far that it ends just after the start.
981 */
982export function resumeAt(svg: string, seconds: number): string {
983  if (!(seconds > 0)) return svg
984  const secs = (attrs: string, name: 'dur' | 'repeatDur') => {
985    const m = (name === 'dur' ? /\sdur="([\d.]+)s"/ : /\srepeatDur="([\d.]+)s"/).exec(attrs)
986    return m ? Number(m[1]) : undefined
987  }
988  return svg.replace(/<(animate|animateTransform|animateMotion|set)\b([^>]*?)(\/?)>/g, (_, tag: string, attrs: string, close: string) => {
989    const at = /\sbegin="(-?[\d.]+)s"/.exec(attrs)
990    let begin = (at ? Number(at[1]) : 0) - seconds
991    if (/\sfill="freeze"/.test(attrs)) {
992      const count = /\srepeatCount="([\d.]+)"/.exec(attrs)
993      const run = secs(attrs, 'repeatDur') ?? (secs(attrs, 'dur') ?? 0) * (count ? Number(count[1]) : 1)
994      begin = Math.max(begin, 0.01 - run)
995    }
996    const moved = at ? attrs.replace(at[0], ` begin="${n(begin)}s"`) : `${attrs} begin="${n(begin)}s"`
997    return `<${tag}${moved}${close}>`
998  })
999}
1000
1001/** How long a drawn scene's bubble waits for Claude before it appears, in seconds (0 when it appears at once). */
1002export const speaksAfter = (svg: string) => Number(/data-part="speech"[^>]*\sdata-speaks="([\d.]+)"/.exec(svg)?.[1] ?? 0)
1003
hooks/activity.ts 60 lines
1/** What the main agent did, boiled down to one line the narrator can read. */
2export type Activity = { kind: 'tool' | 'failed' | 'said'; text: string }
3
4export const MAX_LOG = 14
5const MAX_LINE = 160
6
7const basename = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
8
9const cut = (text: string, max = MAX_LINE) => {
10  const flat = text.replace(/\s+/g, ' ').trim()
11  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
12}
13
14const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
15
16/** One readable line per tool call: the tool and the part of its input that tells the story. */
17export function summarizeTool(tool: string, input: Readonly<Record<string, unknown>>): string {
18  const path = str(input.file_path) ?? str(input.notebook_path) ?? str(input.path)
19  switch (tool) {
20    case 'Bash':
21      return `ran shell: ${cut(str(input.description) ?? str(input.command) ?? '', 100)}`
22    case 'Read':
23      return `read ${path ? basename(path) : 'a file'}`
24    case 'Edit':
25    case 'MultiEdit':
26    case 'NotebookEdit':
27      return `edited ${path ? basename(path) : 'a file'}`
28    case 'Write':
29      return `wrote ${path ? basename(path) : 'a file'}`
30    case 'Grep':
31      return `searched code for "${cut(str(input.pattern) ?? '', 60)}"`
32    case 'Glob':
33      return `looked for files matching ${cut(str(input.pattern) ?? '', 60)}`
34    case 'WebFetch':
35      return `fetched ${cut(str(input.url) ?? 'a web page', 80)}`
36    case 'WebSearch':
37      return `searched the web for "${cut(str(input.query) ?? '', 60)}"`
38    case 'Agent':
39    case 'Task':
40      return `sent a helper agent to: ${cut(str(input.description) ?? str(input.prompt) ?? '', 80)}`
41    case 'TodoWrite':
42    case 'TaskCreate':
43    case 'TaskUpdate':
44      return 'updated the to-do list'
45    default:
46      return path ? `used ${tool} on ${basename(path)}` : `used ${tool}`
47  }
48}
49
50/** The assistant's own words between tool calls are the best story material. */
51export function summarizeSpeech(text: string): string {
52  return cut(text, 220)
53}
54
55export function pushActivity(log: Activity[], entry: Activity): Activity[] {
56  const last = log[log.length - 1]
57  if (last && last.kind === entry.kind && last.text === entry.text) return log
58  return [...log, { kind: entry.kind, text: cut(entry.text, 240) }].slice(-MAX_LOG)
59}
60
hooks/narrator.ts 96 lines
1import type { FablesScene } from '../types'
2
3import type { Activity } from './activity'
4import { CAPTION_BUDGET, ENTRANCE_SECONDS, extractJson, parseScene, TYPE_SECONDS_PER_CHAR } from './scene'
5
6/** Remembered between scenes so the story stays continuous; capped so it never grows. */
7export type StoryBeat = { backdrop: string; caption: string }
8export const MAX_STORY = 4
9
10export const MIN_GAP_MS = 5000
11export const MAX_BACKOFF_MS = 60000
12
13/** How long the bubble takes to type a caption out. */
14export const typeMs = (caption: string) => Math.round(200 + caption.length * TYPE_SECONDS_PER_CHAR * 1000)
15/** However short the caption, it stays up this long once typed. */
16export const GLANCE_MS = 2000
17/** How long a scene stays up to be read through: faded in, typed out, then read at a calm pace. */
18export const readMs = (scene: Pick<FablesScene, 'caption' | 'enter'>) =>
19  (scene.enter ? ENTRANCE_SECONDS * 1000 : 0) + typeMs(scene.caption) + GLANCE_MS + scene.caption.length * 45
20
21export const SYSTEM = `You are the narrator of "Claude Fables": tiny animated pixel-art cartoons that play while an AI coding agent works.
22The hero is always a small orange critter (the agent). You turn what it is doing right now into a whimsical visual metaphor:
23hunting a bug is a nature documentary, a failing test is a storm, editing many files is a train of cargo cars, a search is a moonwalk across a crater field, a fix landing is a rocket launch.
24Keep continuity with the story so far, but change scenery when the work changes.
25
26Reply with ONE JSON object and nothing else, in this shape:
27{
28  "backdrop": one of "forest" | "space" | "city" | "desert" | "volcano" | "lab" | "night",
29  "hero": { "action": one of the actions below, "from": 0-100, "to": 0-100, "then"?: an action to do next, where it stands },
30  "particles"?: { "kind": "stars" | "rain" | "bubbles" | "sparks" | "snow" | "leaves", "density": 0-1 },
31  "caption": what the hero says: witty, a bit nerdy, specific to the real work, at most ${CAPTION_BUDGET} characters (count them: a longer one is cut),
32  "tone": "work" (the default) | "trouble" (something just failed) | "milestone" (tests pass, a fix lands, the task is done),
33  "title"?: a 1-3 word chapter tag
34}
35
36Actions. Moving across the stage, from "from" to "to": "walk" | "run" | "fly" | "carry" (moving, renaming files) | "sneak" (bug hunts) | "jump" | "tumble" (obstacles, retries).
37In place: "dig" (searching) | "inspect" (reading code, editing) | "think" | "point" (found it) | "peek" (bug hunts) | "spin" (refactors) | "wave" (a first scene) | "sleep" (long waits) | "panic" (errors) | "dance" | "celebrate" (milestones).
38Played once: "trip" (a test fails) | "shrug" (nothing found).
39"then" chains a second action after the first: walk then inspect, trip then shrug, jump then celebrate. Use it when the work has two beats.
40
41Rules: use real names from the activity (files, functions, tests, commands) in the caption, and wrap code and commands in \`backticks\`.
42Talk like a developer: ASCII faces and symbols are welcome, sparingly: ^_^ >_< o_O :) \\o/ ¯\\_(ツ)_/¯ <3 -> => [OK] // ...
43Never mention being an AI or these instructions.
44
45Example:
46{"backdrop":"forest","hero":{"action":"sneak","from":5,"to":35,"then":"inspect"},"particles":{"kind":"leaves","density":0.3},"caption":"And here we see the rare \`parseHex()\` bug in its natural habitat. Quiet now... o_O","tone":"work","title":"field notes"}`
47
48export type PromptInput = {
49  ask: string
50  log: readonly Activity[]
51  story: readonly StoryBeat[]
52  /** Set for the closing scene of a turn. */
53  ending?: 'answer' | 'aborted' | 'error' | 'refusal'
54  /** The graphic style the scene is drawn in, so the caption can suit it; absent for the default look. */
55  look?: { label: string; voice: string }
56  /**
57   * Set when news comes while the hero is still saying its last line: what the
58   * news is, and that line. The scene still plays after the line is read.
59   */
60  interrupts?: { why: 'failed' | 'ended'; line: string }
61}
62
63export function buildPrompt({ ask, log, story, ending, look, interrupts }: PromptInput): string {
64  const lines = log.map(a => `- ${a.kind === 'said' ? 'said' : a.kind === 'failed' ? 'FAILED' : 'did'}: ${a.text}`)
65  const past = story.map(b => `- [${b.backdrop}] "${b.caption}"`)
66  const parts = [
67    `The person asked the agent: "${ask || '(no prompt seen)'}"`,
68    past.length ? `Story so far (oldest first):\n${past.join('\n')}` : 'This is the first scene.',
69    lines.length ? `Latest activity (oldest first):\n${lines.join('\n')}` : 'No activity yet: the agent is thinking.',
70  ]
71  if (look) {
72    parts.push(`This scene is drawn in the style of ${look.label}. Let the caption sound like ${look.voice}, still about the real work.`)
73  }
74  if (interrupts) {
75    const news = interrupts.why === 'failed' ? 'something just FAILED' : 'the turn just ended'
76    parts.push(`The hero has only just said "${interrupts.line}" when ${news}. The news breaks in: open the caption by reacting to it ("Wait-", "Oh!", "Hold on:"), then tell it.`)
77  }
78  if (ending === 'answer') parts.push('The agent just FINISHED the task. Draw a short, happy closing scene (celebrate or dance).')
79  else if (ending === 'aborted') parts.push('The person just interrupted the agent. Draw a sheepish closing scene (shrug).')
80  else if (ending) parts.push('The turn just ended badly. Draw a brave-but-battered closing scene (trip, then shrug).')
81  else parts.push('Draw the next scene, about the LATEST activity.')
82  return parts.join('\n\n')
83}
84
85/** A model reply to a validated scene, or null; never throws. */
86export function sceneFromReply(text: string): FablesScene | null {
87  return parseScene(extractJson(text))
88}
89
90export const backoffMs = (failures: number) =>
91  failures <= 0 ? MIN_GAP_MS : Math.min(MAX_BACKOFF_MS, MIN_GAP_MS * 2 ** failures)
92
93export function remember(story: readonly StoryBeat[], scene: FablesScene): StoryBeat[] {
94  return [...story, { backdrop: scene.backdrop, caption: scene.caption }].slice(-MAX_STORY)
95}
96
hooks/art/roles.ts 188 lines
1/**
2 * The vocabulary the scenes paint in. Every element of every scene is handed to
3 * a painter under a role that says what it is, so a style can draw a fir as a
4 * woodblock fir, a drafted fir or a stitched fir without the scene knowing which.
5 * The scene still composes the element (its geometry, its place, its motion) and
6 * paints it in the lit, photographic way; that painting is what the default look
7 * shows, and what a style reads to find the element's shapes and tones.
8 */
9
10/** What kind of thing an element is: a style paints a family, and may refine a role within it. */
11export type Family =
12  | 'sky'
13  | 'body'
14  | 'stars'
15  | 'cloud'
16  | 'air'
17  | 'beam'
18  | 'glow'
19  | 'land'
20  | 'foliage'
21  | 'bark'
22  | 'grass'
23  | 'ground'
24  | 'rock'
25  | 'built'
26  | 'glass'
27  | 'water'
28  | 'fire'
29  | 'lamp'
30  | 'shadow'
31  | 'mark'
32  | 'life'
33  | 'lens'
34
35export type RoleInfo = {
36  family: Family
37  /** How far into the picture it stands: 0 at the horizon, 1 at the very front. */
38  depth: number
39}
40
41const r = (family: Family, depth: number): RoleInfo => ({ family, depth })
42
43/** Every role a scene paints, by name. A test checks each style paints every one of them. */
44export const ROLES = {
45  // Forest at dawn.
46  'forest.sky': r('sky', 0),
47  'forest.sun': r('body', 0),
48  'forest.clouds': r('cloud', 0.05),
49  'forest.ridge': r('land', 0.1),
50  'forest.firs-far': r('foliage', 0.15),
51  'forest.mist': r('air', 0.2),
52  'forest.firs-mid': r('foliage', 0.3),
53  'forest.fir-trunks': r('bark', 0.45),
54  'forest.firs': r('foliage', 0.45),
55  'forest.shafts': r('beam', 0.4),
56  'forest.motes': r('life', 0.5),
57  'forest.meadow': r('grass', 0.5),
58  'forest.shrubs': r('foliage', 0.5),
59  'forest.groundmist': r('air', 0.5),
60  'forest.path': r('ground', 0.6),
61  'forest.litter': r('mark', 0.6),
62  'forest.sunpatch': r('glow', 0.6),
63  'forest.grass': r('grass', 0.85),
64  'forest.ferns': r('foliage', 0.95),
65  'forest.trunks': r('bark', 1),
66  'forest.canopy': r('foliage', 1),
67  // Earthrise on the moon.
68  'space.sky': r('sky', 0),
69  'space.milkyway': r('stars', 0),
70  'space.stars': r('stars', 0),
71  'space.earth': r('body', 0),
72  'space.regolith': r('ground', 0.5),
73  'space.swells': r('land', 0.5),
74  'space.craters': r('rock', 0.6),
75  'space.rocks': r('rock', 0.7),
76  'space.pits': r('mark', 0.6),
77  'space.base': r('built', 0.4),
78  'space.lamps': r('lamp', 0.4),
79  // Blue hour in the city.
80  'city.sky': r('sky', 0),
81  'city.stars': r('stars', 0),
82  'city.clouds': r('cloud', 0.05),
83  'city.towers-far': r('built', 0.15),
84  'city.windows-far': r('lamp', 0.15),
85  'city.spire': r('built', 0.25),
86  'city.towers-mid': r('built', 0.3),
87  'city.windows-mid': r('lamp', 0.3),
88  'city.rail': r('built', 0.4),
89  'city.train': r('built', 0.4),
90  'city.towers-low': r('built', 0.45),
91  'city.windows-low': r('lamp', 0.45),
92  'city.street': r('ground', 0.7),
93  'city.reflection': r('water', 0.7),
94  'city.curb': r('built', 0.6),
95  'city.lamps': r('lamp', 0.8),
96  // Mesa sunset.
97  'desert.sky': r('sky', 0),
98  'desert.clouds': r('cloud', 0.05),
99  'desert.sun': r('body', 0),
100  'desert.hawk': r('life', 0.1),
101  'desert.mesas-far': r('rock', 0.15),
102  'desert.glare': r('glow', 0.2),
103  'desert.mesas': r('rock', 0.35),
104  'desert.sand': r('ground', 0.6),
105  'desert.ripples': r('mark', 0.6),
106  'desert.scrub': r('foliage', 0.7),
107  'desert.saguaro': r('foliage', 0.85),
108  'desert.boulders': r('rock', 1),
109  // Night eruption.
110  'volcano.sky': r('sky', 0),
111  'volcano.stars': r('stars', 0),
112  'volcano.glow': r('glow', 0),
113  'volcano.ridges': r('land', 0.15),
114  'volcano.puffs': r('cloud', 0.2),
115  'volcano.ash': r('cloud', 0.2),
116  'volcano.crater': r('fire', 0.25),
117  'volcano.cone': r('rock', 0.3),
118  'volcano.lava': r('fire', 0.3),
119  'volcano.embers': r('fire', 0.3),
120  'volcano.plain': r('ground', 0.6),
121  'volcano.cracks': r('fire', 0.6),
122  'volcano.stream': r('fire', 0.7),
123  'volcano.boulders': r('rock', 1),
124  // The lab at night.
125  'lab.wall': r('built', 0.2),
126  'lab.window': r('glass', 0.2),
127  'lab.board': r('built', 0.25),
128  'lab.bench': r('built', 0.5),
129  'lab.books': r('built', 0.45),
130  'lab.lamp': r('built', 0.45),
131  'lab.mug': r('built', 0.45),
132  'lab.monitors': r('glass', 0.45),
133  'lab.rack': r('built', 0.4),
134  'lab.pool': r('glow', 0.5),
135  'lab.cone': r('beam', 0.5),
136  'lab.motes': r('life', 0.5),
137  'lab.floor': r('ground', 0.8),
138  'lab.reflection': r('water', 0.8),
139  // A village under the moon.
140  'night.sky': r('sky', 0),
141  'night.stars': r('stars', 0),
142  'night.moon': r('body', 0),
143  'night.clouds': r('cloud', 0.05),
144  'night.hills-far': r('land', 0.1),
145  'night.mist': r('air', 0.2),
146  'night.hill': r('land', 0.3),
147  'night.crest': r('mark', 0.3),
148  'night.trees': r('foliage', 0.3),
149  'night.cottages': r('built', 0.3),
150  'night.meadow': r('grass', 0.6),
151  'night.path': r('ground', 0.6),
152  'night.grass': r('grass', 0.95),
153  'night.oak': r('foliage', 1),
154  'night.fireflies': r('life', 0.7),
155  // Every scene.
156  particles: r('life', 0.6),
157  lens: r('lens', 1),
158} as const satisfies Record<string, RoleInfo>
159
160export type Role = keyof typeof ROLES
161
162/** Geometry a scene hands along with an element, for a style that redraws it rather than repaints it. */
163export type Meta = {
164  /** A focal point: the sun, the moon, a crater, a lamp. */
165  cx?: number
166  cy?: number
167  r?: number
168  /** A line through the element: a ridge's crest, a stream's course. */
169  d?: string
170  /** The stage's width and the top of its ground, for elements that span it. */
171  sw?: number
172  y?: number
173  h?: number
174  [key: string]: unknown
175}
176
177/**
178 * Paints one element. `svg` is the element as the lit scene paints it; the
179 * default painter returns it as it is.
180 */
181export type Painter = {
182  el: (role: Role, svg: string, meta?: Meta) => string
183  /** A block of definitions: gradients, templates, clip paths, materials. */
184  defs: (svg: string) => string
185}
186
187export const LIT: Painter = { el: (_role, svg) => svg, defs: svg => svg }
188
hooks/hero3d.ts 103 lines
1/**
2 * Bakes the 3D Clawd (clawd3d.ts) into SVG: one group of flat polygons per
3 * pose, shown in turn by discrete SMIL so the frame needs no script.
4 */
5import { build, MODEL_H, type Model, type Motion, MOTION_TIMING, poseAt } from './clawd3d'
6
7/** Clawd's colors, from the gallery: the faces shade from `dark` to `light` by how they face the light. */
8const SKIN = {
9  body: { dark: '#a9583d', light: '#f0ac8e' },
10  arm: { dark: '#a9583d', light: '#eba083' },
11  leg: { dark: '#8f4a33', light: '#d4785a' },
12  eye: '#1f1412',
13}
14/** Shading steps: a few flat tones per face, as the gallery paints it. */
15const STEPS = 6
16
17/**
18 * Paints one posed model, its feet on y = `floor`, centred on x = `cx`. A style
19 * brings its own, drawn the way its art form would draw Clawd; absent, the
20 * gallery's lit 3D model.
21 */
22export type HeroPainter = (m: Model, cx: number, floor: number) => string
23
24const n1 = (v: number) => (Math.round(v * 10) / 10).toString()
25
26function mix(a: string, b: string, t: number): string {
27  const ch = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
28  return `#${[0, 1, 2]
29    .map(i => Math.round(ch(a, i) + (ch(b, i) - ch(a, i)) * t).toString(16).padStart(2, '0'))
30    .join('')}`
31}
32
33const shadeOf = (part: keyof typeof SKIN, light: number) => {
34  const skin = SKIN[part === 'eye' ? 'body' : part]
35  const step = Math.round(Math.pow(light, 0.8) * (STEPS - 1)) / (STEPS - 1)
36  return mix(skin.dark, skin.light, step)
37}
38
39const poly = (pts: readonly (readonly [number, number])[], dx: number, dy: number) =>
40  `M${pts.map(([x, y]) => `${n1(x + dx)} ${n1(y + dy)}`).join('L')}Z`
41
42/** One posed model as SVG, its feet on y = `floor`, centred on x = `cx`. */
43export function modelSvg(m: Model, cx: number, floor: number): string {
44  const shadow = `<path fill="#1f1e1d" opacity=".22" d="${poly(m.shadow, cx, floor)}"/>`
45  // Consecutive faces of one color share a path; draw order is kept, so overlaps stay right.
46  const runs: { fill: string; d: string[] }[] = []
47  for (const f of m.faces) {
48    const fill = shadeOf(f.part, f.light)
49    const last = runs[runs.length - 1]
50    if (last && last.fill === fill) last.d.push(poly(f.pts, cx, floor))
51    else runs.push({ fill, d: [poly(f.pts, cx, floor)] })
52  }
53  // A hairline in the face's own color closes the seams between faces; its width and join are set once for the frame.
54  const paths = runs.map(r => `<path fill="${r.fill}" stroke="${r.fill}" d="${r.d.join('')}"/>`).join('')
55  const faces = `<g stroke-width=".5" stroke-linejoin="round">${paths}</g>`
56  const eyes = m.eyes
57    .map(e =>
58      e.poly
59        ? `<path fill="${SKIN.eye}" d="${poly(e.poly, cx, floor)}"/>`
60        : `<path fill="none" stroke="${SKIN.eye}" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" d="M${(e.line ?? []).map(([x, y]) => `${n1(x + cx)} ${n1(y + floor)}`).join('L')}"/>`,
61    )
62    .join('')
63  return shadow + faces + eyes
64}
65
66export type Frames3d = {
67  /** The loop's frames, each a group that SMIL shows in its slot. */
68  svg: string
69  frames: number
70}
71
72/**
73 * A motion as a loop of baked frames. `active` says when the loop plays: always,
74 * only for the first `until` seconds, or only from `from` seconds on.
75 */
76export function motionSvg(
77  motion: Motion,
78  o: { height: number; cx: number; floor: number; yaw: number; hero?: HeroPainter; until?: number; from?: number; maxFrames?: number },
79): Frames3d {
80  // A scene short of room may bake a motion in fewer poses, played over the same time.
81  const frames = Math.min(MOTION_TIMING[motion].frames, o.maxFrames ?? Infinity)
82  const { dur } = MOTION_TIMING[motion]
83  const s = o.height / MODEL_H
84  const timing =
85    o.until !== undefined
86      ? `repeatDur="${n1(o.until)}s"`
87      : o.from !== undefined
88        ? `begin="${n1(o.from)}s" repeatCount="indefinite"`
89        : 'repeatCount="indefinite"'
90  // Every frame waits hidden and its animation shows it in its slot, so a loop
91  // that has not begun or has ended draws nothing and another can take over.
92  const groups = Array.from({ length: frames }, (_, k) => {
93    const model = build(poseAt(motion, k / frames, o.yaw), s)
94    const at = (v: number) => (Math.round((v / frames) * 10000) / 10000).toString()
95    const anim =
96      k === 0
97        ? `<animate attributeName="visibility" values="visible;hidden" keyTimes="0;${at(1)}" calcMode="discrete" dur="${dur}s" ${timing}/>`
98        : `<animate attributeName="visibility" values="hidden;visible;hidden" keyTimes="0;${at(k)};${at(k + 1)}" calcMode="discrete" dur="${dur}s" ${timing}/>`
99    return `<g visibility="hidden">${(o.hero ?? modelSvg)(model, o.cx, o.floor)}${anim}</g>`
100  })
101  return { svg: groups.join(''), frames }
102}
103
hooks/styles/aero.ts 340 lines
1/**
2 * Frutiger Aero, after the gallery's "Frutiger Aero: glossy eco-tech,
3 * mid-2000s" (henrik-styles.js, style 29, `eaero`), and the look's own sources:
4 * the Windows Vista and 7 wallpapers, "Bliss", the eco-tech stock art of
5 * 2004 to 2013.
6 *
7 * The art bible, translated to the Fables' worlds:
8 *
9 * - Saturated but balanced, never washed out and never harsh. Skies are a
10 *   clear azure to pale aqua at the horizon; grass is a vivid Bliss green;
11 *   water and the city's towers are clear aqua glass; rooms are pearl, cool
12 *   silver and soft blue. The darks are navy, forest green or a glassy
13 *   blue-violet basalt, never grey or brown. Only lava and lamps are warm.
14 * - Every surface is a smooth gradient with a gloss: lighter where it faces
15 *   up, a little deeper below, and a thin white highlight along its top.
16 *   Nothing carries a dark outline.
17 * - Clouds are puffy white cumulus with soft blue undersides. The sun is
18 *   white, with a bloom and a lens flare. Light falling through the air is
19 *   drawn as soft beams; stars and sparks are bokeh, soft rings of light;
20 *   motes and dust are little soap bubbles.
21 * - Night is Frutiger Aurora, the Vista look: deep navy fading to teal, with
22 *   ribbons of aurora green and cyan across the sky, the meadows a deep teal
23 *   green, the clouds and ash moonlit blue, the land with the same gloss.
24 * - Claude is the gallery's tangerine jelly: rounded, light at the top left
25 *   and deep at the bottom right, a glossy window cap over his top and a
26 *   white hot spot, dark glassy eyes with a white glint.
27 * - The screen is framed in rounded glass; the chapter is a glossy pill, and
28 *   the caption a frosted glass panel.
29 */
30import type { HeroPainter } from '../hero3d'
31import { type Model, outlinedFaces } from '../clawd3d'
32import { isGreen, isWarm, lum, meanOf, mix, num as n, poly, step, t1 } from '../art/ink'
33import { painter, type Ctx } from '../art/painter'
34import type { Family } from '../art/roles'
35import type { Look } from '../looks'
36
37const K = { light: '#ffbd5c', mid: '#ff7f17', deep: '#e0480a', edge: '#b23a06', eye: '#5c1d05' }
38const SANS = "'Segoe UI', 'Frutiger', 'Myriad Pro', 'Helvetica Neue', Arial, sans-serif"
39
40/** Aero's ramps, dark to light: saturated all the way down. */
41const R = {
42  green: ['#12501c', '#1b7024', '#28922a', '#46b232', '#84d44a', '#c4ee80'],
43  aqua: ['#063456', '#0a5a88', '#1280b8', '#2aa4da', '#6ccbee', '#c4eefc'],
44  steel: ['#0f2a46', '#1b4670', '#2e6896', '#4f8cb8', '#86b6d8', '#cfe6f4'],
45  // Pearl: Aero's interiors and buildings, cool silvers and soft blues rather than raw cyan.
46  pearl: ['#16324e', '#284e72', '#47729a', '#7097bc', '#a6c4dc', '#d8e8f4'],
47  // Moonlit green: the Frutiger Aurora meadow, deep teal greens.
48  night: ['#072a1e', '#0c402c', '#13583a', '#1d7048', '#2f8a5a', '#58aa78'],
49  // Glass: the city's towers, clear aqua curtain walls.
50  glass: ['#0a3a60', '#11598a', '#2079b0', '#4499cc', '#82c4e6', '#cbe9f8'],
51  // Basalt: dark rock under a night sky, a glassy blue-violet, never brown.
52  basalt: ['#121833', '#1e2748', '#2d3a64', '#43548a', '#6577a8', '#98a8cc'],
53  warm: ['#8a2a04', '#b23a06', K.deep, K.mid, K.light, '#ffe6b0'],
54  sand: ['#5a3a12', '#8a5e22', '#c08a3a', '#e6b864', '#f6dca0', '#fff4d8'],
55}
56
57/** Whether the scene being painted is under a night sky: its sky is painted first, and says so. */
58let night = false
59
60/** A lit color in the scene's own lightness, carried onto Aero's saturated ramps. */
61function tone(color: string, family: Family): string {
62  let t = Math.min(0.99, Math.pow(lum(color), 0.9) * 1.1)
63  const warm = isWarm(color)
64  switch (family) {
65    // Lava glows tangerine to amber, never white; a lamp burns a little hotter.
66    case 'fire':
67      return step(R.warm, Math.min(0.8, Math.max(0.4, t)))
68    case 'lamp':
69      return step(R.warm, Math.max(0.5, t))
70    case 'foliage':
71    case 'grass':
72      return step(night ? R.night : R.green, t)
73    // The land is never chalk: its palest tone stays the ramp's fourth, so it keeps its color under the sky.
74    case 'land':
75      t = Math.min(t, night ? 0.5 : 0.66)
76      if (warm && lum(color) < 0.4) return step(R.basalt, t)
77      return warm ? step(R.sand, t) : isGreen(color) ? step(night ? R.night : R.green, t) : step(R.steel, t)
78    case 'water':
79    case 'glass':
80      return step(R.aqua, t)
81    case 'bark':
82      return step(R.sand, t * 0.6)
83    case 'ground':
84    case 'rock':
85    case 'mark':
86      t = Math.min(t, 0.66)
87      if (!isGreen(color) && (warm || brownish(color)) && lum(color) < 0.4) return step(R.basalt, t)
88      return isGreen(color) ? step(R.green, t) : warm || brownish(color) ? step(R.sand, t) : step(R.steel, t)
89    case 'built':
90      return warm && lum(color) > 0.5 ? step(R.sand, t) : step(R.pearl, Math.min(t, 0.84))
91    default:
92      return warm ? step(R.warm, t) : isGreen(color) ? step(R.green, t) : step(R.aqua, t)
93  }
94}
95function brownish(color: string): boolean {
96  const c = color.replace('#', '')
97  if (c.length !== 6) return false
98  return parseInt(c.slice(0, 2), 16) > parseInt(c.slice(4, 6), 16) + 16
99}
100
101/** A surface's gradient: its tone lighter at the top, deeper below, as glossy plastic is. */
102let made = new Set<string>()
103let pending = ''
104function glossy(color: string): string {
105  const id = `ae-g${color.slice(1)}`
106  if (!made.has(id)) {
107    made.add(id)
108    pending += `<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(color, '#ffffff', 0.28)}"/><stop offset=".5" stop-color="${color}"/><stop offset="1" stop-color="${mix(color, '#001428', 0.22)}"/></linearGradient>`
109  }
110  return `url(#${id})`
111}
112
113/**
114 * The sky: day is deep azure to aqua at the horizon; dusk keeps the blue above
115 * a warm glow at the horizon; night is Frutiger Aurora, navy to teal with ribbons
116 * of aurora across it.
117 */
118function sky(svg: string, c: Ctx): string {
119  const g = c.gradient(svg)
120  const stops = g?.stops ?? []
121  const top = stops[0]?.color ?? '#3060a0'
122  const low = stops[stops.length - 1]?.color ?? '#80a0c0'
123  const sw = c.meta.sw ?? 640
124  night = lum(top) < 0.2 && lum(low) < 0.45
125  const dusk = !night && isWarm(low)
126  if (night) {
127    return (
128      `<rect width="${sw}" height="128" fill="url(#ae-night)"/>` +
129      `<g opacity=".7"><path d="M-20 34C${n(sw * 0.2)} 6 ${n(sw * 0.45)} 54 ${n(sw * 0.7)} 20S${n(sw + 20)} 30 ${n(sw + 20)} 30v14C${n(sw * 0.8)} 34 ${n(sw * 0.6)} 64 ${n(sw * 0.35)} 40S-20 46 -20 46z" fill="url(#ae-aurora)">` +
130      `<animate attributeName="opacity" values=".8;1;.8" dur="9s" repeatCount="indefinite"/></path>` +
131      `<path d="M-20 58C${n(sw * 0.3)} 36 ${n(sw * 0.55)} 72 ${n(sw + 20)} 44v8C${n(sw * 0.55)} 82 ${n(sw * 0.3)} 46 -20 66z" fill="url(#ae-aurora2)" opacity=".7"/></g>`
132    )
133  }
134  return `<rect width="${sw}" height="128" fill="url(#${dusk ? 'ae-dusk' : 'ae-day'})"/>`
135}
136
137/**
138 * Bokeh: each star or spark a soft round of light. Every shape becomes a group
139 * keeping its own attributes and animation, holding a soft disc at each point
140 * the shape was drawn at.
141 */
142function bokeh(svg: string, c: Ctx): string {
143  const discs = (pts: [string, string][]) => pts.map(([x, y]) => `<circle cx="${x}" cy="${y}" r="1.3"/>`).join('')
144  return c
145    .repaint(svg)
146    .replace(/<(path|rect)\b([^>]*?)(\/?)>/g, (_all, tag: string, attrs: string, close: string) => {
147      const pts: [string, string][] =
148        tag === 'path'
149          ? [...(/\sd="([^"]*)"/.exec(attrs)?.[1] ?? '').matchAll(/M([\d.-]+) ([\d.-]+)/g)].map(m => [m[1]!, m[2]!])
150          : [[/\sx="([^"]*)"/.exec(attrs)?.[1] ?? '0', /\sy="([^"]*)"/.exec(attrs)?.[1] ?? '0']]
151      const kept = attrs.replace(/\s(d|x|y|width|height|fill|stroke|stroke-width|stroke-linecap)="[^"]*"/g, '')
152      return `<g${kept} fill="url(#ae-bokeh)">${discs(pts)}${close ? '</g>' : ''}`
153    })
154    .replace(/<\/(path|rect)>/g, '</g>')
155}
156
157/** Carries an element's glossy surfaces from one ramp onto another, tone for tone. */
158function swap(svg: string, from: readonly string[], to: readonly string[]): string {
159  return from.reduce((out, color, i) => out.split(`url(#ae-g${color.slice(1)})`).join(glossy(to[i] ?? color)), svg)
160}
161
162const scenery = () => {
163  night = false
164  made = new Set()
165  pending = ''
166  const p = painter(
167    {
168      ink: (color, family, _depth, attr) =>
169        // A gradient can fill or stroke, but not be a currentColor: lines, stops and colors take the plain tone.
170        attr !== 'fill' ? tone(color, family) : family === 'stars' || family === 'life' ? '#ffffff' : glossy(tone(color, family)),
171      // A gradient of the lit scene becomes one glossy surface in its overall tone; a wash of light is not drawn.
172      // A radial wash of light stays a soft falloff, carried over in Aero's colors.
173      url: (_id, family, attr, g) =>
174        g?.tag === 'radialGradient' ? undefined : attr === 'stroke' ? tone(g ? meanOf(g).color : '#808080', family) : g && meanOf(g).opacity < 0.5 ? 'none' : glossy(tone(g ? meanOf(g).color : '#808080', family)),
175      // The gloss: a thin white highlight round the nearer surfaces, never a dark outline.
176      line: (family, depth) =>
177        family === 'sky' || family === 'stars' || family === 'life' || family === 'lens' || family === 'air' || family === 'beam' || family === 'glow' || family === 'fire' || family === 'lamp' || depth < 0.25
178          ? ''
179          : `stroke="#ffffff" stroke-opacity="${n(0.18 + depth * 0.22)}" stroke-width="${n(0.35 + depth * 0.3)}" stroke-linejoin="round"`,
180      lineless: 0.6,
181      faint: 'hide',
182      redraw: {
183        sky,
184        // The city's towers are glass, and the volcano's cone dark rock under the night.
185        'city.towers-far': (svg, c) => swap(c.repaint(svg), R.pearl, R.glass),
186        'city.towers-mid': (svg, c) => swap(c.repaint(svg), R.pearl, R.glass),
187        'city.towers-low': (svg, c) => swap(c.repaint(svg), R.pearl, R.glass),
188        'city.spire': (svg, c) => swap(c.repaint(svg), R.pearl, R.glass),
189        'volcano.cone': (svg, c) => swap(c.repaint(svg), R.sand, R.basalt),
190        lens: () => '',
191        glow: () => '',
192        stars: bokeh,
193        // A white sun with its bloom and a lens flare toward the middle of the stage; the Earth a glossy globe.
194        body: (_svg, c) => {
195          const cx = c.meta.cx ?? 0
196          const cy = c.meta.cy ?? 0
197          const r = c.meta.r ?? 8
198          if (c.role === 'space.earth') return `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r)}" fill="url(#ae-earth)"/><ellipse cx="${n(cx - r * 0.25)}" cy="${n(cy - r * 0.55)}" rx="${n(r * 0.55)}" ry="${n(r * 0.28)}" fill="#fff" opacity=".45"/>`
199          if (c.meta.part === 'halo') return `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 6)}" fill="url(#ae-flare)"/>`
200          if (c.role === 'night.moon') return `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 3)}" fill="url(#ae-flare)" opacity=".6"/><circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 1.1)}" fill="#eaf8ff"/>`
201          const rings = [0.3, 0.55, 0.8].map((k, i) => `<circle cx="${n(cx + (320 - cx) * k)}" cy="${n(cy + (80 - cy) * k)}" r="${[4, 8, 3][i]}" fill="${i === 1 ? 'url(#ae-ring)' : 'none'}" stroke="#fff" stroke-opacity=".35" stroke-width=".7"/>`).join('')
202          return `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 3)}" fill="url(#ae-flare)"/><circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 1.05)}" fill="#ffffff"/>` + rings
203        },
204        // Cumulus: white, its underside a soft blue.
205        // At night, and in the ash over the volcano, the cumulus is moonlit: a soft blue rather than white.
206        cloud: (svg, c) => {
207          const lit = /\sfill="(#[0-9a-fA-F]{6})"/.exec(svg)?.[1] ?? c.gradient(svg)?.stops[0]?.color ?? '#ffffff'
208          const fill = lum(lit) < 0.4 ? 'url(#ae-cloudn)' : 'url(#ae-cloud)'
209          return `<g>${c.repaint(svg).replace(/\sfill="[^"]*"/g, ` fill="${fill}"`).replace(/\sopacity="[^"]*"/g, '')}</g>`
210        },
211        air: (svg, c) => `<g opacity=".22">${c.repaint(svg).replace(/\sfill="[^"]*"/g, ' fill="#cdeeff"')}</g>`,
212        // Light falling through the air: soft white beams.
213        beam: (svg, c) => `<g opacity=".3">${c.repaint(svg).replace(/\sfill="[^"]*"/g, ' fill="url(#ae-beam)"')}</g>`,
214      },
215    },
216    'ae',
217  )
218  // Hand the glossy gradients along with the element that first needs them.
219  return {
220    el: (role: Parameters<typeof p.el>[0], svg: string, meta?: Parameters<typeof p.el>[2]) => {
221      const out = p.el(role, svg, meta)
222      const defs = pending
223      pending = ''
224      return (defs ? `<defs>${defs}</defs>` : '') + out
225    },
226    defs: p.defs,
227  }
228}
229
230// ---------------------------------------------------------------- Claude
231
232/** Claude as tangerine jelly: rounded, lit from the top left, a glossy cap and a hot spot. */
233const hero = (): HeroPainter => (m: Model, cx: number, floor: number) => {
234  const hulls = m.parts.map(pt => poly(pt.hull, cx, floor)).join('')
235  const body = m.parts.find(p => p.name === 'body')
236  let cap = ''
237  if (body) {
238    const xs = body.hull.map(p => p[0] + cx)
239    const ys = body.hull.map(p => p[1] + floor)
240    const x0 = Math.min(...xs)
241    const x1 = Math.max(...xs)
242    const y0 = Math.min(...ys)
243    const y1 = Math.max(...ys)
244    const w = x1 - x0
245    const h = y1 - y0
246    cap =
247      `<rect x="${t1(x0 + w * 0.1)}" y="${t1(y0 + h * 0.06)}" width="${t1(w * 0.8)}" height="${t1(h * 0.4)}" rx="${t1(h * 0.18)}" fill="url(#ae-cap)"/>` +
248      `<ellipse cx="${t1(x0 + w * 0.25)}" cy="${t1(y0 + h * 0.18)}" rx="${t1(w * 0.07)}" ry="${t1(h * 0.05)}" fill="#fff" opacity=".95"/>`
249  }
250  // The faces turn the jelly faintly, so its shape still reads.
251  const faces = outlinedFaces(m)
252    .filter(f => f.part === 'body' && f.name !== 'front')
253    .map(f => `<path fill="${f.name === 'top' ? '#ffeec8' : '#aa3205'}" opacity="${f.name === 'top' ? 0.3 : n(0.22 * (1 - f.light))}" d="${poly(f.pts, cx, floor)}"/>`)
254    .join('')
255  const eyes = m.eyes
256    .map(e =>
257      e.poly
258        ? `<path fill="url(#ae-eye)" stroke="#ffe6c8" stroke-opacity=".55" stroke-width=".5" d="${poly(e.poly, cx, floor)}"/><circle cx="${t1((e.poly[3]?.[0] ?? 0) + cx - 0.2)}" cy="${t1((e.poly[3]?.[1] ?? 0) + floor - 0.2)}" r=".6" fill="#fff"/>`
259        : `<path fill="none" stroke="#7a2809" stroke-width="1" stroke-linecap="round" d="${poly(e.line ?? [], cx, floor, false)}"/>`,
260    )
261    .join('')
262  return (
263    `<path fill="url(#ae-shadow)" d="${poly(m.shadow, cx, floor)}"/>` +
264    // The jelly: its edge deep and rounded, then its body, light top left to deep bottom right.
265    `<path fill="${K.edge}" stroke="${K.edge}" stroke-width="2.4" stroke-linejoin="round" d="${hulls}"/>` +
266    `<path fill="url(#ae-jelly)" stroke="url(#ae-jelly)" stroke-width="1.2" stroke-linejoin="round" d="${hulls}"/>` +
267    faces +
268    cap +
269    eyes
270  )
271}
272
273const escape = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
274
275export const AERO: Look = {
276  name: 'aero',
277  label: 'Frutiger Aero',
278  voice: 'an upbeat mid-2000s eco-tech ad',
279  cell: 'solid',
280  figure: '3d',
281  font: SANS,
282  charW: 5.2,
283  caption: { fill: '#f4fbff', stroke: '#2a8ad8', ink: '#14385a', radius: 8 },
284  paper: { card: '#f4fbff', ink: '#14385a', kinds: { code: '#0a7a8a', path: '#1d6fc4', fn: '#6a4ac4', num: '#e0702a', bad: '#d0303a', good: '#2e9a2e', face: '#e0702a' } },
285  inset: 4,
286  titleColor: '#ffffff',
287  art: {
288    painter: scenery,
289    hero,
290    sky: '#0d78dc',
291    ground: '#1f8a1c',
292    defs: () =>
293      `<linearGradient id="ae-day" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1a62c4"/><stop offset=".4" stop-color="#2f8ae0"/><stop offset=".75" stop-color="#72c2f0"/><stop offset="1" stop-color="#c6ecfb"/></linearGradient>` +
294      `<linearGradient id="ae-dusk" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0a3a8a"/><stop offset=".45" stop-color="#2a7ad0"/><stop offset=".75" stop-color="#8ac8ea"/><stop offset="1" stop-color="#ffc878"/></linearGradient>` +
295      `<linearGradient id="ae-night" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#020c2a"/><stop offset=".55" stop-color="#06305a"/><stop offset="1" stop-color="#0a6a7a"/></linearGradient>` +
296      `<linearGradient id="ae-aurora" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#3cff9a" stop-opacity="0"/><stop offset=".3" stop-color="#3cff9a" stop-opacity=".55"/><stop offset=".65" stop-color="#3ce0ff" stop-opacity=".5"/><stop offset="1" stop-color="#3ce0ff" stop-opacity="0"/></linearGradient>` +
297      `<linearGradient id="ae-aurora2" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#5affd0" stop-opacity="0"/><stop offset=".5" stop-color="#5affd0" stop-opacity=".4"/><stop offset="1" stop-color="#7a9aff" stop-opacity="0"/></linearGradient>` +
298      `<linearGradient id="ae-cloud" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset=".6" stop-color="#f4fbff"/><stop offset="1" stop-color="#b8dcf4"/></linearGradient>` +
299      `<linearGradient id="ae-cloudn" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c8dcf0"/><stop offset=".6" stop-color="#8eaad0"/><stop offset="1" stop-color="#5a76a8"/></linearGradient>` +
300      `<linearGradient id="ae-beam" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>` +
301      `<radialGradient id="ae-bokeh"><stop offset="0" stop-color="#fff" stop-opacity=".95"/><stop offset=".6" stop-color="#cff4ff" stop-opacity=".5"/><stop offset="1" stop-color="#cff4ff" stop-opacity="0"/></radialGradient>` +
302      `<radialGradient id="ae-ring"><stop offset=".6" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#bff0ff" stop-opacity=".35"/></radialGradient>` +
303      `<radialGradient id="ae-flare"><stop offset="0" stop-color="#fff" stop-opacity=".95"/><stop offset=".15" stop-color="#fffff0" stop-opacity=".6"/><stop offset=".45" stop-color="#fff" stop-opacity=".15"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>` +
304      `<radialGradient id="ae-earth" cx=".35" cy=".35" r=".75"><stop offset="0" stop-color="#9fe0f2"/><stop offset=".6" stop-color="#1478a8"/><stop offset="1" stop-color="#0a3d73"/></radialGradient>` +
305      `<linearGradient id="ae-jelly" x1="0" y1="0" x2=".7" y2="1"><stop offset="0" stop-color="${K.light}"/><stop offset=".4" stop-color="${K.mid}"/><stop offset="1" stop-color="${K.deep}"/></linearGradient>` +
306      `<linearGradient id="ae-cap" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".9"/><stop offset=".6" stop-color="#fff" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
307      `<linearGradient id="ae-eye" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2c0c02"/><stop offset=".6" stop-color="${K.eye}"/><stop offset="1" stop-color="#b8460f"/></linearGradient>` +
308      `<radialGradient id="ae-shadow"><stop offset="0" stop-color="#ff9628" stop-opacity=".5"/><stop offset=".6" stop-color="#1e5a0a" stop-opacity=".25"/><stop offset="1" stop-color="#1e5a0a" stop-opacity="0"/></radialGradient>` +
309      `<linearGradient id="ae-gloss" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".22"/><stop offset=".3" stop-color="#fff" stop-opacity=".04"/><stop offset=".31" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
310      `<linearGradient id="ae-pill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7ad0f7"/><stop offset=".5" stop-color="#2a8ad8"/><stop offset="1" stop-color="#1a6ab8"/></linearGradient>`,
311  },
312  texture: (sw, h) => `<rect width="${sw}" height="${h}" fill="url(#ae-gloss)"/>`,
313  frame: (sw, h) => `<rect x="2" y="2" width="${sw - 4}" height="${h - 4}" rx="8" fill="none" stroke="#fff" stroke-opacity=".85" stroke-width="1.4"/><rect x="3.4" y="3.4" width="${sw - 6.8}" height="${h - 6.8}" rx="7" fill="none" stroke="#2a8ad8" stroke-opacity=".7" stroke-width=".8"/>`,
314  tag: (text, inset) => {
315    const x = inset + 5
316    const y = inset + 4
317    const w = text.length * 5.6 + 14
318    return {
319      svg:
320        `<rect x="${x}" y="${y}" width="${n(w)}" height="13" rx="6.5" fill="url(#ae-pill)" stroke="#fff" stroke-width=".8"/>` +
321        `<rect x="${x + 2}" y="${y + 1}" width="${n(w - 4)}" height="5" rx="2.5" fill="#fff" opacity=".4"/>` +
322        `<text x="${x + 7}" y="${y + 9.4}" font-family="${SANS}" font-size="8" font-weight="700" fill="#fff">${escape(text)}</text>`,
323      w: x + w + 4,
324      h: y + 17,
325    }
326  },
327  bubble: {
328    font: SANS,
329    size: 9,
330    charW: 5.2,
331    line: 12,
332    base: 13,
333    draw: ({ x, y, w, h }, { base, tip }) =>
334      // Frosted glass: a pale panel, a gloss along its top half, a blue rim and a white one.
335      `<path fill="#f4fbff" fill-opacity=".9" d="M${n(base[0][0])} ${n(base[0][1])}L${n(tip[0])} ${n(tip[1])}L${n(base[1][0])} ${n(base[1][1])}z"/>` +
336      `<rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" rx="7" fill="#f4fbff" fill-opacity=".9" stroke="#2a8ad8" stroke-width="1"/>` +
337      `<rect x="${n(x + 1.5)}" y="${n(y + 1.2)}" width="${w - 3}" height="${n(h * 0.42)}" rx="5.5" fill="#fff" opacity=".7"/>`,
338  },
339}
340
hooks/styles/blueprint.ts 264 lines
1/**
2 * Blueprint, after the gallery's "Blueprint: patent drawing"
3 * (index.html, style 13, `blueprint`).
4 *
5 * The art bible, translated from the gallery's sheet to the Fables' worlds:
6 *
7 * - The scene is a drawing on cyanotype: a mottled Prussian sheet, a fine
8 *   grid with every fifth line heavier, a double ruled border. Nothing is
9 *   painted, everything is drawn in white line.
10 * - The painter's knock-out: every shape is filled with the sheet itself,
11 *   grid and all, so a nearer shape hides the lines of what stands behind it,
12 *   and only its own outline is drawn. Line weight follows depth: far ridges
13 *   in hairlines, the front in a firm line.
14 * - Shade is not painted but hatched: the dark faces of rock, buildings and
15 *   ground take section hatching; the ground takes the earth hatch of the
16 *   gallery's ground line.
17 * - What has no edge is drawn as a convention: clouds, mist and beams of
18 *   light as phantom lines (long, short, short); the sun and the moon as
19 *   circles with centre marks; stars as small crosses; anything that glows
20 *   (windows, lava, lamps, sparks) as a solid white mark.
21 * - Claude is drawn as the gallery draws him: the sheet knocked back in over
22 *   each part, visible edges solid, creases thin, hidden edges dashed, a
23 *   dash-dot centre line through his body, eyes in line work with centre lines.
24 * - The chapter is "FIG. 1"; the caption is lettered in drafting capitals in a
25 *   ruled title block, with a leader running to Claude.
26 */
27import type { HeroPainter } from '../hero3d'
28import { type Model, outlinedOrder } from '../clawd3d'
29import { lum, num as n, poly, t1 } from '../art/ink'
30import { painter, type Ctx } from '../art/painter'
31import type { Family } from '../art/roles'
32import type { Look } from '../looks'
33
34const SHEET = '#1f4f9a'
35const LINE = '#e8f0ff'
36const DARK = '#173e80'
37const SANS = "'Arial Narrow', 'Helvetica Neue', Helvetica, Arial, sans-serif"
38/** A phantom line: long, short, short. */
39const PHANTOM = 'stroke-dasharray="7 1.8 1.4 1.8 1.4 1.8"'
40
41/** Families drawn as conventions rather than outlined shapes. */
42const UNLINED: readonly Family[] = ['sky', 'stars', 'life', 'lens', 'fire', 'lamp', 'glow', 'beam', 'air', 'cloud', 'body', 'water', 'mark']
43
44/**
45 * Families made of many small shapes (needles, blades, fronds, leaves): outlined at full
46 * weight they would fill in white, so they take a hairline.
47 */
48const DENSE: readonly Family[] = ['foliage', 'grass']
49const denseLine = (depth: number) =>
50  `stroke="${LINE}" stroke-width="${n(0.12 + depth * 0.16)}" stroke-opacity="${n(0.5 + depth * 0.3)}" stroke-linejoin="round"`
51
52/** Families whose darkest faces take section hatching. */
53const HATCHED: readonly Family[] = ['rock', 'built', 'land', 'bark']
54
55/** How a lit color reads on the sheet: the knock-out, a hatch, a white mark, or nothing. */
56function ink(color: string, family: Family, attr: string): string {
57  const l = lum(color)
58  if (attr === 'stroke') return LINE
59  if (attr === 'color') return SHEET
60  switch (family) {
61    case 'stars':
62    case 'life':
63    case 'fire':
64    case 'lamp':
65      return LINE
66    case 'glow':
67    case 'beam':
68    case 'air':
69      return 'none'
70    case 'ground':
71    case 'grass':
72      return l > 0.8 ? 'none' : 'url(#bp-earth)'
73    default:
74      // Lit slivers (rims, caps, glints) are light, not shape: a drawing leaves them out.
75      if (l > 0.78) return 'none'
76      if (HATCHED.includes(family) && l < 0.2) return 'url(#bp-hatch)'
77      return 'url(#bp-sheet)'
78  }
79}
80
81/** A line of the drawing for a shape at a depth: heavier toward the front. */
82const lineFor = (depth: number) =>
83  `stroke="${LINE}" stroke-width="${n(0.22 + depth * 0.5)}" stroke-opacity="${n(0.55 + depth * 0.4)}" stroke-linejoin="round"`
84
85/** Outlines only, as phantom lines: the conventional drawing of things without an edge. */
86const phantom = (svg: string, c: Ctx, opacity = 0.6) =>
87  `<g fill="none" stroke="${LINE}" stroke-opacity="${opacity}" stroke-width=".4" ${PHANTOM}>` +
88  c.repaint(svg).replace(/\sfill="[^"]*"/g, ' fill="none"').replace(/\sstroke="[^"]*"/g, '').replace(/\sstroke-width="[^"]*"/g, '') +
89  `</g>`
90
91/** A circle with its centre marked, as a drawing shows a round thing: the sun, the moon. */
92const centred = (cx: number, cy: number, r: number) => {
93  const w = 0.7
94  const m = r + 3
95  return (
96    `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r)}" fill="url(#bp-sheet)" stroke="${LINE}" stroke-width="${w}"/>` +
97    `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 1.45)}" fill="none" stroke="${LINE}" stroke-opacity=".5" stroke-width=".35" stroke-dasharray="1.3 1.3"/>` +
98    `<path stroke="${LINE}" stroke-opacity=".75" stroke-width=".35" stroke-dasharray="2.2 .9 .5 .9" d="M${n(cx - m)} ${n(cy)}h${n(m * 2)}M${n(cx)} ${n(cy - m)}v${n(m * 2)}"/>`
99  )
100}
101
102const scenery = (_sw: number) =>
103  painter(
104    {
105      ink: (color, family, _depth, attr) => ink(color, family, attr),
106      // A gradient fill is light or a lit surface: lamps' cones and pools are left out, surfaces knocked out.
107      url: (_id, family, attr) => (attr === 'stroke' ? LINE : family === 'lamp' ? 'none' : ink('#808080', family, attr)),
108      line: (family, depth) => (UNLINED.includes(family) ? '' : DENSE.includes(family) ? denseLine(depth) : lineFor(depth)),
109      lineless: 0.6,
110      faint: 'hide',
111      templateFamily: 'foliage',
112      redraw: {
113        // The sky is the bare sheet, its grid and all.
114        sky: (_svg, c) => `<rect width="${c.meta.sw ?? 640}" height="128" fill="url(#bp-sheet)"/>`,
115        lens: () => '',
116        glow: () => '',
117        // A sun or moon drawn as a circle with its centre marked; its halo is light, and is left out.
118        body: (_svg, c) => {
119          if (c.meta.part === 'halo') return ''
120          const r = (c.meta.r ?? 8) * (c.role === 'space.earth' ? 1 : 1.3)
121          const cx = c.meta.cx ?? 0
122          const cy = c.meta.cy ?? 0
123          if (c.role !== 'space.earth') return centred(cx, cy, r)
124          // The Earth: the circle, its equator and a meridian, as a globe is drafted.
125          return (
126            centred(cx, cy, r) +
127            `<g fill="none" stroke="${LINE}" stroke-opacity=".6" stroke-width=".35"><ellipse cx="${n(cx)}" cy="${n(cy)}" rx="${n(r)}" ry="${n(r * 0.3)}"/><ellipse cx="${n(cx)}" cy="${n(cy)}" rx="${n(r * 0.42)}" ry="${n(r)}"/></g>`
128          )
129        },
130        cloud: (svg, c) => phantom(svg, c, 0.55),
131        beam: (svg, c) => phantom(svg, c, 0.4),
132        air: (svg, c) => {
133          // Mist as two phantom lines across the scene, drifting as it did.
134          const y = c.meta.y ?? 80
135          const h = c.meta.h ?? 12
136          const w = c.meta.sw ?? 640
137          return (
138            `<g fill="none" stroke="${LINE}" stroke-opacity=".45" stroke-width=".4" ${PHANTOM}>` +
139            `<path d="M${n(-20)} ${n(y + h * 0.4)}H${n(w + 20)}M${n(-10)} ${n(y + h * 0.75)}H${n(w + 20)}"/>${c.motion(svg)}</g>`
140          )
141        },
142        'space.milkyway': (svg, c) => phantom(svg, c, 0.35),
143        water: (svg, c) => `<g opacity=".45">${c.repaint(svg)}</g>`,
144        mark: (svg, c) => `<g stroke-opacity=".6">${c.repaint(svg).replace(/\sstroke-width="([\d.]+)"/g, (_, w: string) => ` stroke-width="${Math.min(0.5, +w)}"`)}</g>`,
145      },
146    },
147    'bp',
148  )
149
150// ---------------------------------------------------------------- Claude
151
152/** Claude as the gallery's patent drawing: knock-out per part, solid outline, thin creases, dashed hidden edges. */
153const hero =
154  (): HeroPainter =>
155  (m: Model, cx: number, floor: number) => {
156    const sil = 0.75
157    const crease = 0.4
158    const seg = (e: { a: readonly [number, number]; b: readonly [number, number] }) => poly([e.a, e.b], cx, floor, false)
159    let parts = ''
160    let hidden = ''
161    for (const pt of outlinedOrder(m)) {
162      // The sheet knocked back in over whatever is behind this part, outlined: a box's outline is its hull.
163      parts += `<path fill="url(#bp-sheet)" d="${poly(pt.hull, cx, floor)}"/>`
164      const c = pt.edges.filter(e => e.kind === 'crease').map(seg).join('')
165      if (c) parts += `<path class="c" d="${c}"/>`
166      hidden += pt.edges.filter(e => e.kind === 'hidden').map(seg).join('')
167    }
168    // The eyes in line work.
169    const eyes = m.eyes.map(e => (e.poly ? `<path fill="none" d="${poly(e.poly, cx, floor)}"/>` : `<path fill="none" d="${poly(e.line ?? [], cx, floor, false)}"/>`)).join('')
170    // A dash-dot centre line through the body's axis.
171    const body = m.parts.find(p => p.name === 'body')
172    let axis = ''
173    if (body) {
174      const xs = body.hull.map(p => p[0])
175      const ys = body.hull.map(p => p[1])
176      const mx = (Math.min(...xs) + Math.max(...xs)) / 2 + cx
177      axis = `<path stroke-width=".3" stroke-opacity=".45" stroke-dasharray="6 1.6 1.2 1.6" d="M${t1(mx)} ${t1(Math.min(...ys) + floor - 3)}V${t1(floor + 2)}"/>`
178    }
179    return (
180      `<style>.c{stroke-width:${crease}px;stroke-opacity:.82}</style>` +
181      `<g fill="none" stroke="${LINE}" stroke-width="${sil}" stroke-linecap="round" stroke-linejoin="round">` +
182      parts +
183      (hidden ? `<path stroke-width=".35" stroke-opacity=".5" stroke-dasharray="1.6 1.2" d="${hidden}"/>` : '') +
184      `<g stroke-width=".55">${eyes}</g>` +
185      axis +
186      `</g>`
187    )
188  }
189
190// ---------------------------------------------------------------- the sheet
191
192const escape = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
193
194export const BLUEPRINT: Look = {
195  name: 'blueprint',
196  label: 'Blueprint',
197  voice: 'an engineer annotating a technical drawing',
198  cell: 'solid',
199  figure: '3d',
200  font: SANS,
201  charW: 5.4,
202  caption: { fill: DARK, stroke: LINE, ink: LINE, radius: 0 },
203  paper: { card: DARK, ink: LINE, kinds: { code: '#bfe6ff', path: '#a8d8ff', fn: '#d8c8ff', num: '#ffffff', bad: '#ffb4a0', good: '#b8ffd0', face: '#ffe08a' } },
204  inset: 5,
205  titleColor: LINE,
206  art: {
207    painter: scenery,
208    hero,
209    sky: SHEET,
210    ground: SHEET,
211    defs: () =>
212        // The sheet: Prussian ground, a fine grid every 3.2 units and a heavier line every fifth.
213        `<pattern id="bp-sheet" width="16" height="16" patternUnits="userSpaceOnUse"><rect width="16" height="16" fill="${SHEET}"/>` +
214        `<path d="M3.2 0v16M6.4 0v16M9.6 0v16M12.8 0v16M0 3.2h16M0 6.4h16M0 9.6h16M0 12.8h16" stroke="${LINE}" stroke-opacity=".075" stroke-width=".38"/>` +
215        `<path d="M0 0v16M0 0h16" stroke="${LINE}" stroke-opacity=".17" stroke-width=".6"/></pattern>` +
216        // Section hatching for shade, on the sheet.
217        `<pattern id="bp-hatch" width="16" height="16" patternUnits="userSpaceOnUse"><rect width="16" height="16" fill="url(#bp-sheet)"/>` +
218        `<path d="M-4 4l8 -8M0 16L16 0M12 20l8 -8M-4 12l8 -8M4 20L20 4" stroke="${LINE}" stroke-opacity=".38" stroke-width=".35"/></pattern>` +
219        // The ground's earth hatch: short strokes under a ground line, scattered.
220        `<pattern id="bp-earth" width="9" height="7" patternUnits="userSpaceOnUse"><rect width="9" height="7" fill="url(#bp-sheet)"/>` +
221        `<path d="M3 1l-2.6 2.8" stroke="${LINE}" stroke-opacity=".32" stroke-width=".35"/></pattern>` +
222        // The cyanotype's mottling: soft light and dark clouds in the print.
223        `<filter id="bp-mottle" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency=".012 .03" numOctaves="4" seed="13"/>` +
224        `<feColorMatrix values="0 0 0 0 .6  0 0 0 0 .78  0 0 0 0 1  0 0 0 -2.2 1.15"/></filter>` +
225        `<radialGradient id="bp-vignette" cx=".5" cy=".5" r=".75"><stop offset=".55" stop-color="#08184a" stop-opacity="0"/><stop offset="1" stop-color="#08184a" stop-opacity=".45"/></radialGradient>`,
226  },
227  texture: (sw, h) =>
228    `<rect width="${sw}" height="${h}" filter="url(#bp-mottle)" opacity=".16"/>` + `<rect width="${sw}" height="${h}" fill="url(#bp-vignette)"/>`,
229  frame: (sw, h) =>
230    `<rect x="2.5" y="2.5" width="${sw - 5}" height="${h - 5}" fill="none" stroke="${LINE}" stroke-opacity=".9" stroke-width="1.1"/>` +
231    `<rect x="5" y="5" width="${sw - 10}" height="${h - 10}" fill="none" stroke="${LINE}" stroke-opacity=".8" stroke-width=".45"/>`,
232  tag: (text, inset) => {
233    const t = `FIG. 1 — ${text.toUpperCase()}`
234    const w = t.length * 5.1 + 10
235    const x = inset + 4
236    const y = inset + 4
237    return {
238      svg:
239        `<rect x="${x}" y="${y}" width="${n(w)}" height="12" fill="${DARK}" fill-opacity=".7" stroke="${LINE}" stroke-width=".8"/>` +
240        `<text x="${x + 5}" y="${y + 8.6}" font-family="${SANS}" font-size="7" font-weight="700" letter-spacing=".9" fill="${LINE}">${escape(t)}</text>`,
241      w: x + w + 4,
242      h: y + 16,
243    }
244  },
245  bubble: {
246    font: SANS,
247    size: 8.4,
248    charW: 5.6,
249    line: 11,
250    base: 12,
251    upper: true,
252    draw: ({ x, y, w, h }, { base, tip }) => {
253      // A ruled title block, and a leader from its edge to a dot at Claude, as a callout runs to a part.
254      const mx = (base[0][0] + base[1][0]) / 2
255      const my = (base[0][1] + base[1][1]) / 2
256      return (
257        `<path d="M${n(mx)} ${n(my)}L${n(tip[0])} ${n(tip[1])}" stroke="${LINE}" stroke-width=".6"/><circle cx="${n(tip[0])}" cy="${n(tip[1])}" r="1.1" fill="${LINE}"/>` +
258        `<rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" fill="${DARK}" fill-opacity=".92" stroke="${LINE}" stroke-width=".95"/>` +
259        `<rect x="${n(x + 1.8)}" y="${n(y + 1.8)}" width="${w - 3.6}" height="${h - 3.6}" fill="none" stroke="${LINE}" stroke-opacity=".55" stroke-width=".35"/>`
260      )
261    },
262  },
263}
264
hooks/styles/cave.ts 202 lines
1/**
2 * Cave Painting, after the gallery's "Cave Painting: Lascaux, c. 15,000 BC"
3 * (index.html, style 01, `cave`).
4 *
5 * The art bible, translated from the gallery's wall to the Fables' worlds:
6 *
7 * - There is no sky and no ground, only the wall: torch-lit limestone, pale
8 *   and mottled, with its grain, cracks and stains. Everything else is
9 *   pigment laid on it.
10 * - Four pigments: red ochre, yellow ochre, soot, and a pale earth for
11 *   highlights. Masses are rubbed on flat and thin, so the rock shows through,
12 *   far things fainter, near things stronger; their edges are blown, not cut.
13 * - Outlines are soot, drawn with a finger: thick, broken, wavering.
14 * - The sun is a ring of ochre with red at its heart; the moon a pale earth
15 *   disc ringed in soot; stars and sparks are dots of red ochre; mist and
16 *   light are not painted at all, the bare wall is the light.
17 * - Claude is the gallery's: rubbed in red ochre with paler dabs where the
18 *   light falls, a broken soot contour, soot legs, charcoal eyes.
19 * - The chapter is scratched beside tally marks; the caption is daubed on a
20 *   smoothed patch of wall, ringed in soot. A torch warms the wall.
21 */
22import type { HeroPainter } from '../hero3d'
23import { type Model, outlinedFaces } from '../clawd3d'
24import { isGreen, isWarm, lum, meanOf, num as n, poly } from '../art/ink'
25import { painter } from '../art/painter'
26import type { Family } from '../art/roles'
27import type { Look } from '../looks'
28
29const SOOT = '#1b130f'
30const OCH = '#a4452a'
31const OCH_L = '#c96b40'
32const OCH_P = '#dc9a6a'
33const OCH_D = '#66241a'
34const YOCH = '#cf8a2e'
35const WALL = '#cdb892'
36const PALE = '#e6d6b2'
37const PIGMENT_FONT = "'Marker Felt', 'Chalkboard SE', 'Comic Neue', 'Trebuchet MS', sans-serif"
38
39/** A lit color as pigment: soot for the dark, ochres for warm and middle tones, pale earth for light. */
40function pigment(color: string, family: Family): string {
41  const l = lum(color)
42  if (family === 'fire' || family === 'lamp') return l > 0.7 ? YOCH : OCH
43  if (family === 'stars' || family === 'life') return OCH
44  if (l > 0.72) return PALE
45  if (l < 0.18) return SOOT
46  if (isGreen(color)) return l < 0.35 ? '#3b2a1e' : '#6a5a3a'
47  if (isWarm(color)) return l > 0.45 ? YOCH : l > 0.3 ? OCH_L : OCH
48  return l < 0.32 ? '#3b2a1e' : l < 0.5 ? OCH_D : '#8a6a4a'
49}
50
51/** How thick the pigment is rubbed on: far things faint on the rock, near things strong. */
52const strength = (depth: number) => 0.38 + depth * 0.5
53
54const scenery = () =>
55  painter(
56    {
57      ink: (color, family, _depth, attr) => (attr === 'stroke' ? SOOT : pigment(color, family)),
58      // A radial gradient is a pool of light: the bare wall is the light, so it is not painted.
59      url: (_id, family, attr, g) => (attr === 'stroke' ? SOOT : g && (meanOf(g).opacity < 0.5 || g.tag === 'radialGradient') ? 'none' : pigment(g ? meanOf(g).color : '#808080', family)),
60      // The soot line: thick, broken in places, wavering with the finger that drew it.
61      line: (family, depth) =>
62        family === 'sky' || family === 'stars' || family === 'life' || family === 'lens' || family === 'air' || family === 'beam' || family === 'glow' || family === 'fire'
63          ? ''
64          : `stroke="${SOOT}" stroke-width="${n(0.5 + depth * 0.9)}" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="${n(14 + depth * 10)} ${n(1.5 + depth)}" stroke-opacity=".8"`,
65      // Rubbed thin: the rock shows through every mass, but rock, hills and buildings are laid on thick enough to hide what stands behind them.
66      post: (svg, family, depth) => `<g opacity="${n(family === 'rock' || family === 'land' || family === 'built' ? 0.92 : strength(depth))}">${svg}</g>`,
67      lineless: 0.6,
68      faint: 'hide',
69      // A pool of lamplight is a smear of yellow ochre rubbed into the wall.
70      pool: e =>
71        `<ellipse cx="${n(e.cx)}" cy="${n(e.cy)}" rx="${n(e.rx)}" ry="${n(e.ry)}" fill="${YOCH}" opacity=".55"/>` +
72        `<ellipse cx="${n(e.cx)}" cy="${n(e.cy)}" rx="${n(e.rx * 0.5)}" ry="${n(e.ry * 0.5)}" fill="${OCH_P}" opacity=".7"/>`,
73      redraw: {
74        sky: () => '',
75        lens: () => '',
76        glow: () => '',
77        air: () => '',
78        beam: () => '',
79        // The Milky Way as a spray of red dots, its band not painted.
80        'space.milkyway': (svg, c) => c.repaint(svg.replace(/<ellipse[^>]*\/>/, '')),
81        'forest.sun': (_s, c) => sun(c.meta.cx ?? 0, c.meta.cy ?? 0, (c.meta.r ?? 7) * 1.5, c.meta.part),
82        'desert.sun': (_s, c) => sun(c.meta.cx ?? 0, c.meta.cy ?? 0, (c.meta.r ?? 11) * 1.2, c.meta.part),
83        'night.moon': (_s, c) =>
84          `<circle cx="${n(c.meta.cx ?? 0)}" cy="${n(c.meta.cy ?? 0)}" r="${n((c.meta.r ?? 9) * 1.2)}" fill="${OCH_P}" stroke="${SOOT}" stroke-width="1.4" stroke-dasharray="9 2" opacity=".85"/>`,
85      },
86    },
87    'cv',
88  )
89
90/** The sun as a cave painter would make it: an ochre ring with rays, red at its heart. */
91function sun(cx: number, cy: number, r: number, part: unknown): string {
92  if (part === 'halo') return ''
93  let rays = ''
94  for (let i = 0; i < 12; i++) {
95    const a = (i / 12) * Math.PI * 2
96    rays += `M${n(cx + Math.cos(a) * (r + 3))} ${n(cy + Math.sin(a) * (r + 3))}L${n(cx + Math.cos(a) * (r + 7))} ${n(cy + Math.sin(a) * (r + 7))}`
97  }
98  return (
99    `<g opacity=".85"><circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r)}" fill="${YOCH}"/><circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 0.45)}" fill="${OCH}"/>` +
100    `<path d="${rays}" stroke="${OCH}" stroke-width="1.6" stroke-linecap="round"/></g>`
101  )
102}
103
104// ---------------------------------------------------------------- Claude
105
106/** Claude rubbed in red ochre: paler where the light falls, a broken soot contour, soot legs, charcoal eyes. */
107const hero = (): HeroPainter => (m: Model, cx: number, floor: number) => {
108  const body = m.parts.filter(p => p.name !== 'leg').map(pt => poly(pt.hull, cx, floor)).join('')
109  const legs = m.parts.filter(p => p.name === 'leg').map(pt => poly(pt.hull, cx, floor)).join('')
110  let dabs = ''
111  for (const f of outlinedFaces(m)) {
112    if (f.part === 'leg') continue
113    const tone = f.light > 0.65 ? OCH_P : f.light > 0.4 ? OCH_L : OCH_D
114    dabs += `<path fill="${tone}" opacity="${n(0.35 + f.light * 0.4)}" d="${poly(f.pts, cx, floor)}"/>`
115  }
116  const eyes = m.eyes
117    .map(e =>
118      e.poly
119        ? `<path fill="${SOOT}" d="${poly(e.poly, cx, floor)}"/>`
120        : `<path fill="none" stroke="${SOOT}" stroke-width="1.3" stroke-linecap="round" d="${poly(e.line ?? [], cx, floor, false)}"/>`,
121    )
122    .join('')
123  return (
124    `<path fill="${SOOT}" opacity=".3" stroke="${SOOT}" stroke-width="1.6" stroke-linejoin="round" d="${legs}"/>` +
125    `<path fill="${SOOT}" opacity=".85" d="${legs}"/>` +
126    // The soot contour goes on first and the ochre over it, so only its outer half shows: one line round the whole figure.
127    `<path fill="none" stroke="${SOOT}" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="11 2.5 6 2" d="${body}"/>` +
128    `<path fill="${OCH}" d="${body}"/>` +
129    `<g filter="url(#cv-blow)">${dabs}</g>` +
130    eyes +
131    // A grain of the rock over the paint.
132    `<path fill="url(#cv-pits)" d="${body}"/>`
133  )
134}
135
136// ---------------------------------------------------------------- the wall
137
138const escape = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
139
140export const CAVE: Look = {
141  name: 'cave',
142  label: 'Cave Painting',
143  voice: 'a storyteller by the fire, in short plain words',
144  cell: 'solid',
145  figure: '3d',
146  font: PIGMENT_FONT,
147  charW: 5.4,
148  caption: { fill: PALE, stroke: SOOT, ink: SOOT, radius: 6 },
149  paper: { card: '#e2d2b0', ink: '#2e2119', kinds: { code: '#6a3a1e', path: '#4a3a5a', fn: '#6a3a1e', num: '#8a3220', bad: '#8a3220', good: '#4a5a2a', face: '#8a3220' } },
150  inset: 4,
151  titleColor: SOOT,
152  // The pigments' edges blown and wavering, as if daubed and sprayed on rock.
153  grade: () =>
154    `<feTurbulence type="fractalNoise" baseFrequency=".2" numOctaves="2" seed="3" result="wob"/>` +
155    `<feDisplacementMap in="SourceGraphic" in2="wob" scale="2.2" xChannelSelector="R" yChannelSelector="G"/>`,
156  art: {
157    painter: scenery,
158    hero,
159    sky: WALL,
160    ground: WALL,
161    defs: () =>
162      // The wall: limestone mottled pale, grey and ochre-stained.
163      `<filter id="cv-wall" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB"><feTurbulence type="fractalNoise" baseFrequency=".012 .02" numOctaves="4" seed="5"/>` +
164      `<feComponentTransfer><feFuncR type="table" tableValues=".67 .84 .93"/><feFuncG type="table" tableValues=".57 .74 .88"/><feFuncB type="table" tableValues=".43 .59 .78"/></feComponentTransfer></filter>` +
165      `<filter id="cv-rock" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB"><feTurbulence type="fractalNoise" baseFrequency=".035 .05" numOctaves="4" seed="9"/>` +
166      `<feDiffuseLighting surfaceScale="4" diffuseConstant="1.1" lighting-color="#f4ead6"><feDistantLight azimuth="235" elevation="40"/></feDiffuseLighting></filter>` +
167      `<filter id="cv-blow" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation=".8"/></filter>` +
168      `<pattern id="cv-pits" width="7" height="6" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r=".45" fill="#54392a" opacity=".35"/><circle cx="5.4" cy="4.6" r=".35" fill="#fff6e2" opacity=".35"/></pattern>` +
169      `<radialGradient id="cv-torch" cx="0" cy="1" r="1.1"><stop offset="0" stop-color="#ffa050" stop-opacity=".35"/><stop offset=".5" stop-color="#ff8c38" stop-opacity=".12"/><stop offset="1" stop-color="#ff7832" stop-opacity="0"/></radialGradient>` +
170      `<radialGradient id="cv-dark" cx=".5" cy=".5" r=".7"><stop offset=".55" stop-color="#1a0c05" stop-opacity="0"/><stop offset="1" stop-color="#1a0c05" stop-opacity=".5"/></radialGradient>`,
171    // The bare wall, under everything painted on it.
172    under: (sw, h) => `<rect width="${sw}" height="${h}" filter="url(#cv-wall)"/>`,
173  },
174  texture: (sw, h) =>
175    `<rect width="${sw}" height="${h}" filter="url(#cv-rock)" style="mix-blend-mode:multiply" opacity=".55"/>` +
176    `<rect width="${sw}" height="${h}" fill="url(#cv-torch)"/><rect width="${sw}" height="${h}" fill="url(#cv-dark)"/>`,
177  tag: (text, inset) => {
178    const x = inset + 6
179    const y = inset + 5
180    const marks = [0, 1, 2, 3].map(i => `M${n(x + i * 2.4)} ${y}l.4 9`).join('') + `M${x - 1} ${y + 7}l10 -5`
181    return {
182      svg:
183        `<rect x="${x - 4}" y="${y - 3}" width="${n(text.length * 6.2 + 26)}" height="15" rx="6" fill="${PALE}" opacity=".75"/>` +
184        `<path d="${marks}" stroke="${SOOT}" stroke-width="1.2" stroke-linecap="round" fill="none"/>` +
185        `<text x="${x + 14}" y="${y + 8}" font-family="${PIGMENT_FONT}" font-size="8.4" letter-spacing=".6" fill="${SOOT}">${escape(text.toUpperCase())}</text>`,
186      w: x + 14 + text.length * 6.2 + 6,
187      h: y + 14,
188    }
189  },
190  bubble: {
191    font: PIGMENT_FONT,
192    size: 9,
193    charW: 5.4,
194    line: 12,
195    base: 13,
196    draw: ({ x, y, w, h }, { base, tip }) =>
197      // A smoothed patch of wall, its rim a broken soot line; a soot stroke points at Claude.
198      `<path d="M${n((base[0][0] + base[1][0]) / 2)} ${n((base[0][1] + base[1][1]) / 2)}L${n(tip[0])} ${n(tip[1])}" stroke="${SOOT}" stroke-width="1.6" stroke-linecap="round"/>` +
199      `<rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" rx="7" fill="#e8dcbe" stroke="${SOOT}" stroke-width="1.5" stroke-dasharray="16 2.5 9 2"/>`,
200  },
201}
202
hooks/styles/engraving.ts 308 lines
1/**
2 * Copperplate Engraving, after the gallery's "Copperplate Engraving: natural
3 * history plate, 18th century" (index.html, style 08, `engraving`).
4 *
5 * The art bible, translated from the gallery's plate to the Fables' worlds:
6 *
7 * - One ink, sepia-black, on laid cream paper. There is no color and no wash:
8 *   every tone is cut as lines, close and heavy where it is dark, sparse and
9 *   fine where it is light, with a second set crossing them in the shadows
10 *   and a third in the deepest.
11 * - Each kind of thing is cut along its own grain, as an engraver would:
12 *   skies and water ruled level, growing denser toward the horizon; bark,
13 *   facades and grass upright; hills and rock on the slant.
14 * - Light is the bare paper: the sun is a reserved disc with a ring of rays,
15 *   the moon a reserved disc, glowing windows and lava left uncut, mist the
16 *   paper showing through the lines.
17 * - Every shape is bounded by a firm contour that lightens with distance.
18 * - Claude is cut as the gallery cuts him: each face hatched along its own
19 *   axis (front level, sides upright, top on the slant), a heavy contour
20 *   round him, solid eyes with a reserved highlight.
21 * - The plate: a pressed plate mark on the sheet, a fine border rule, "Pl. I."
22 *   and the chapter in spaced capitals; the caption is an engraved legend.
23 */
24import type { HeroPainter } from '../hero3d'
25import { type Model, outlinedFaces } from '../clawd3d'
26import { lum, meanOf, num as n, poly, t1 } from '../art/ink'
27import { MIST_TILE, mistBanks, tiled } from '../art/mist'
28import { painter, type Ctx } from '../art/painter'
29import type { Family } from '../art/roles'
30import type { Look } from '../looks'
31
32const INK = '#1c1611'
33const PAPER = '#f2ebda'
34const HI = '#f6f0e2'
35const SERIF = "'Big Caslon', 'Caslon', 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif"
36const DIDONE = "Didot, 'Bodoni 72', 'Bodoni MT', 'Iowan Old Style', Georgia, serif"
37
38/** The grain each family is cut along. */
39type Grain = 'h' | 'v' | 'd'
40const GRAIN: Partial<Record<Family, Grain>> = {
41  sky: 'h', cloud: 'h', air: 'h', water: 'h', ground: 'h', mark: 'h', glass: 'h', stars: 'h',
42  bark: 'v', built: 'v', grass: 'v', foliage: 'd',
43  land: 'd', rock: 'd', fire: 'h', lamp: 'h', body: 'h', beam: 'h', glow: 'h', life: 'h', lens: 'h',
44}
45const LEVELS = 6
46const ANGLE: Record<Grain, number> = { h: 0, v: 90, d: -38 }
47
48/** The cut for a tone: lines this far apart, this heavy, crossed this many times. */
49const CUT = [
50  { w: 0, cross: 0 },
51  { w: 0.18, cross: 0 },
52  { w: 0.3, cross: 0 },
53  { w: 0.42, cross: 1 },
54  { w: 0.55, cross: 1 },
55  { w: 0.72, cross: 2 },
56]
57const GAP = 1.7
58
59/** The patterns every tone and grain is cut in, defined once for the plate. */
60function hatches(): string {
61  let out = ''
62  for (const g of ['h', 'v', 'd'] as Grain[]) {
63    for (let k = 1; k < LEVELS; k++) {
64      const c = CUT[k]!
65      // One set of lines, rotated to the grain; the cross sets as their own rotated patterns over it.
66      out +=
67        `<pattern id="eg-${g}${k}" width="8" height="${GAP}" patternUnits="userSpaceOnUse" patternTransform="rotate(${ANGLE[g]})">` +
68        `<rect width="8" height="${GAP}" fill="${PAPER}"/><path d="M0 ${n(GAP / 2)}H8" stroke="${INK}" stroke-width="${c.w}"/></pattern>`
69      if (c.cross) {
70        out +=
71          `<pattern id="eg-${g}${k}x" width="8" height="${n(GAP * 1.15)}" patternUnits="userSpaceOnUse" patternTransform="rotate(${ANGLE[g] + 55})">` +
72          `<rect width="8" height="${n(GAP * 1.15)}" fill="url(#eg-${g}${k})"/><path d="M0 ${n(GAP * 0.55)}H8" stroke="${INK}" stroke-width="${n(c.w * 0.8)}"/></pattern>`
73      }
74      if (c.cross > 1) {
75        out +=
76          `<pattern id="eg-${g}${k}xx" width="8" height="${n(GAP * 1.3)}" patternUnits="userSpaceOnUse" patternTransform="rotate(${ANGLE[g] - 55})">` +
77          `<rect width="8" height="${n(GAP * 1.3)}" fill="url(#eg-${g}${k}x)"/><path d="M0 ${n(GAP * 0.6)}H8" stroke="${INK}" stroke-width="${n(c.w * 0.6)}"/></pattern>`
78      }
79    }
80  }
81  return out
82}
83
84/** The fill that cuts a darkness `t` (0 paper, 1 black) along a grain. */
85function cut(t: number, g: Grain): string {
86  const k = Math.max(0, Math.min(LEVELS - 1, Math.floor(t * LEVELS)))
87  if (k === 0) return PAPER
88  const c = CUT[k]!
89  return `url(#eg-${g}${k}${c.cross > 1 ? 'xx' : c.cross ? 'x' : ''})`
90}
91
92/** How dark a lit color is cut, by family: things that give light are left as paper. */
93function darkness(color: string, family: Family): number {
94  const d = 1 - Math.min(1, Math.pow(lum(color), 0.75) * 1.1)
95  switch (family) {
96    case 'fire':
97    case 'lamp':
98    case 'body':
99    case 'stars':
100    case 'life':
101      return 0
102    case 'sky':
103    case 'cloud':
104      return d * 0.55
105    case 'air':
106    case 'beam':
107    case 'glow':
108      return 0
109    default:
110      return d
111  }
112}
113
114function ink(color: string, family: Family, attr: string): string {
115  if (attr === 'stroke') return family === 'fire' || family === 'lamp' ? PAPER : INK
116  if (attr === 'color') return INK
117  if (attr === 'stop-color') return lum(color) > 0.5 ? PAPER : INK
118  // Lit slivers are not cut: an engraver leaves the paper.
119  if (lum(color) > 0.82 && family !== 'stars' && family !== 'life') return PAPER
120  if (family === 'stars' || family === 'life') return INK
121  return cut(darkness(color, family), GRAIN[family] ?? 'h')
122}
123
124const UNLINED: readonly Family[] = ['sky', 'air', 'beam', 'glow', 'stars', 'life', 'lens', 'water']
125
126/** A sky ruled level, the lines heavier and closer toward the horizon, broken where clouds would be. */
127function ruledSky(c: Ctx, dark: number): string {
128  const sw = c.meta.sw ?? 640
129  const bands = 7
130  let out = ''
131  for (let b = 0; b < bands; b++) {
132    const y0 = 3 + b * 15
133    const k = b / (bands - 1)
134    // A dark sky is cut heavy all the way up; a bright one only toward the horizon.
135    const w = n(Math.min(0.95, 0.12 + dark * 0.55 + (0.2 + dark * 0.35) * k))
136    let d = ''
137    for (let y = y0; y < y0 + 15 && y < 112; y += GAP) d += `M0 ${n(y)}H${sw}`
138    // Long dashes broken at seeded places: the gaps read as cloud.
139    const dash = `${80 + b * 23} ${6 + (b % 3) * 5}`
140    out += `<path d="${d}" stroke="${INK}" stroke-width="${w}" stroke-dasharray="${dash}" stroke-dashoffset="${b * 37}"/>`
141  }
142  return `<rect width="${sw}" height="128" fill="${PAPER}"/>${out}`
143}
144
145/** The sun or moon: a reserved disc, ringed, the sun with its rays. */
146function reserved(c: Ctx, rays: boolean): string {
147  if (c.meta.part === 'halo') return ''
148  const cx = c.meta.cx ?? 0
149  const cy = c.meta.cy ?? 0
150  const r = (c.meta.r ?? 8) * 1.2
151  let d = ''
152  if (rays) for (let i = 0; i < 24; i++) {
153    const a = (i / 24) * Math.PI * 2
154    const r1 = r + 2.2
155    const r2 = r + (i % 2 ? 5 : 8)
156    d += `M${n(cx + Math.cos(a) * r1)} ${n(cy + Math.sin(a) * r1)}L${n(cx + Math.cos(a) * r2)} ${n(cy + Math.sin(a) * r2)}`
157  }
158  return (
159    `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r + (rays ? 9 : 3))}" fill="${PAPER}"/>` +
160    `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r)}" fill="${PAPER}" stroke="${INK}" stroke-width=".5"/>` +
161    (d ? `<path d="${d}" stroke="${INK}" stroke-width=".35"/>` : '')
162  )
163}
164
165const scenery = () =>
166  painter(
167    {
168      ink: (color, family, _depth, attr) => ink(color, family, attr),
169      // A gradient is cut at its overall tone; a stroke is a line of ink.
170      url: (_id, family, attr, g) => (attr === 'stroke' ? INK : g && meanOf(g).opacity < 0.5 ? 'none' : ink(g ? meanOf(g).color : '#808080', family, 'fill')),
171      line: (family, depth) => (UNLINED.includes(family) ? '' : `stroke="${INK}" stroke-width="${n(0.18 + depth * 0.4)}" stroke-linejoin="round"`),
172      lineless: 0.6,
173      faint: 'hide',
174      // A pool of lamplight is left as bare paper, its edge flicked in short strokes of the graver.
175      pool: e =>
176        `<ellipse cx="${n(e.cx)}" cy="${n(e.cy)}" rx="${n(e.rx)}" ry="${n(e.ry)}" fill="${PAPER}" stroke="${INK}" stroke-width=".45" stroke-dasharray="2 1.6"/>` +
177        `<ellipse cx="${n(e.cx)}" cy="${n(e.cy)}" rx="${n(e.rx * 0.6)}" ry="${n(e.ry * 0.6)}" fill="none" stroke="${INK}" stroke-width=".3" stroke-dasharray=".8 2.4"/>`,
178      redraw: {
179        sky: (svg, c) => {
180          const g = c.gradient(svg)
181          return ruledSky(c, g ? 1 - lum(meanOf(g).color) : 0.4)
182        },
183        // The Milky Way as a drift of fine dots, no band.
184        'space.milkyway': (svg, c) => c.repaint(svg.replace(/<ellipse[^>]*\/>/, '')),
185        lens: () => '',
186        // No reflections: the street and the floor stay plain.
187        water: () => '',
188        glow: () => '',
189        'forest.sun': (_s, c) => reserved(c, true),
190        'desert.sun': (_s, c) => reserved(c, true),
191        'night.moon': (_s, c) => reserved(c, false),
192        // Mist is reserved paper, its banks cut round with a fine contour along their tops and a few
193        // level strokes of shade beneath them, as an engraver sculpts cloud.
194        air: (svg, c) => {
195          const banks = mistBanks({ w: Math.min(MIST_TILE, c.meta.sw ?? 640), y: c.meta.y ?? 80, h: c.meta.h ?? 12, seed: 8, rows: (c.meta.h ?? 12) < 12 || c.meta.veil ? 1 : 2, lean: c.meta.lean === true })
196          const shade = banks.map(b => b.under).join('')
197          const art =
198            `<path fill="${PAPER}" stroke="${INK}" stroke-width=".45" stroke-dasharray="40 2 12 1.5" d="${banks.map(b => b.d).join('')}"/>` +
199            `<path fill="none" stroke="${INK}" stroke-width=".35" d="${shade}"/>` +
200            `<path fill="none" stroke="${INK}" stroke-width=".22" stroke-opacity=".7" d="${banks.map(b => b.echo).join('')}"/>`
201          // Drawn for one tile and repeated, the banks drifting as the mist they replace did.
202          return `<g>${tiled(`eg-mist-${Math.round(c.meta.y ?? 80)}-${Math.round(c.meta.h ?? 12)}`, art, c.meta.sw ?? 640)}${c.motion(svg)}</g>`
203        },
204        beam: (svg, c) => `<g opacity=".35">${c.repaint(svg).replace(/\sfill="[^"]*"/g, ` fill="${PAPER}"`)}</g>`,
205      },
206    },
207    'eg',
208  )
209
210// ---------------------------------------------------------------- Claude
211
212/** Claude cut as the gallery cuts him: each face along its own axis, its darkness from the light. */
213const hero = (): HeroPainter => (m: Model, cx: number, floor: number) => {
214  const hulls = m.parts.map(pt => poly(pt.hull, cx, floor)).join('')
215  // Faces cut alike in a row share one path; draw order is kept, so overlaps stay right.
216  let faces = ''
217  let fill = ''
218  let d = ''
219  for (const f of outlinedFaces(m)) {
220    const g: Grain = f.name === 'front' || f.name === 'back' ? 'h' : f.name === 'top' ? 'd' : 'v'
221    const t = Math.min(0.95, (1 - f.light) * 0.85 + (f.part === 'leg' ? 0.25 : 0.08))
222    const c = cut(t, g)
223    if (c !== fill && d) {
224      faces += `<path fill="${fill}" d="${d}"/>`
225      d = ''
226    }
227    fill = c
228    d += poly(f.pts, cx, floor)
229  }
230  if (d) faces += `<path fill="${fill}" d="${d}"/>`
231  const eyes = m.eyes
232    .map(e =>
233      e.poly
234        ? `<path fill="${INK}" d="${poly(e.poly, cx, floor)}"/><circle cx="${t1((e.poly[3]?.[0] ?? 0) + cx)}" cy="${t1((e.poly[3]?.[1] ?? 0) + floor)}" r=".4" fill="${HI}"/>`
235        : `<path fill="none" stroke="${INK}" stroke-width=".9" stroke-linecap="round" d="${poly(e.line ?? [], cx, floor, false)}"/>`,
236    )
237    .join('')
238  // A cross-hatched shadow cast on the ground.
239  return (
240    `<path fill="url(#eg-h4x)" opacity=".7" d="${poly(m.shadow, cx, floor)}"/>` +
241    `<path fill="${PAPER}" stroke="${INK}" stroke-width="1.3" stroke-linejoin="round" d="${hulls}"/>` +
242    `<g stroke="${INK}" stroke-width=".4" stroke-linejoin="round">${faces}</g>` +
243    eyes
244  )
245}
246
247// ---------------------------------------------------------------- the plate
248
249const escape = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
250
251export const ENGRAVING: Look = {
252  name: 'engraving',
253  label: 'Copperplate Engraving',
254  voice: 'a learned 18th-century natural-history plate',
255  cell: 'solid',
256  figure: '3d',
257  font: SERIF,
258  charW: 5.1,
259  caption: { fill: HI, stroke: INK, ink: INK, radius: 0 },
260  paper: { card: HI, ink: INK, kinds: { code: '#3a2a1c', path: '#3a2a1c', fn: '#3a2a1c', num: '#6a2a1a', bad: '#6a2a1a', good: '#2a3a1c', face: '#6a2a1a' } },
261  inset: 6,
262  titleColor: INK,
263  art: {
264    painter: scenery,
265    hero,
266    sky: PAPER,
267    ground: PAPER,
268    defs: () =>
269      hatches() +
270      // Laid paper: fine chain lines and a scatter of fibres and foxing.
271      `<filter id="eg-paper" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency=".7 .9" numOctaves="2" seed="808"/>` +
272      `<feColorMatrix values="0 0 0 0 .35  0 0 0 0 .27  0 0 0 0 .15  -1.5 0 0 0 .95"/></filter>`,
273  },
274  texture: (sw, h) => `<rect width="${sw}" height="${h}" filter="url(#eg-paper)" opacity=".18"/>`,
275  frame: (sw, h) =>
276    // The plate mark: the sheet pressed into the plate, shadowed on two sides and catching light on two.
277    `<rect x="1" y="1" width="${sw - 2}" height="${h - 2}" fill="none" stroke="${PAPER}" stroke-width="3"/>` +
278    `<path d="M2.6 ${h - 2.6}V2.6H${sw - 2.6}" fill="none" stroke="#6a5030" stroke-opacity=".5" stroke-width=".8"/>` +
279    `<path d="M${sw - 2.6} 2.6V${h - 2.6}H2.6" fill="none" stroke="#fffdf6" stroke-width=".8"/>` +
280    `<rect x="5.5" y="5.5" width="${sw - 11}" height="${h - 11}" fill="none" stroke="${INK}" stroke-width=".5"/>`,
281  tag: (text, inset) => {
282    const t = text.toUpperCase()
283    const x = inset + 4
284    const y = inset + 4
285    const w = t.length * 6.6 + 34
286    return {
287      svg:
288        `<rect x="${x}" y="${y}" width="${n(w)}" height="12" fill="${PAPER}"/>` +
289        `<text x="${x + 3}" y="${y + 9}" font-family="${SERIF}" font-style="italic" font-size="8" fill="${INK}">Pl. I.</text>` +
290        `<text x="${x + 27}" y="${y + 9}" font-family="${DIDONE}" font-size="7.5" font-weight="700" letter-spacing="1.6" fill="${INK}">${escape(t)}</text>`,
291      w: x + w + 4,
292      h: y + 15,
293    }
294  },
295  bubble: {
296    font: SERIF,
297    size: 9,
298    charW: 5.1,
299    line: 12,
300    base: 13,
301    draw: ({ x, y, w, h }, { base, tip }) =>
302      // An engraved legend: a paper panel ruled twice, its pointer cut as a fine wedge.
303      `<path fill="${INK}" d="M${n(base[0][0])} ${n(base[0][1])}L${n(tip[0])} ${n(tip[1])}L${n(base[1][0])} ${n(base[1][1])}z"/>` +
304      `<rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" fill="${HI}" stroke="${INK}" stroke-width=".6"/>` +
305      `<rect x="${n(x + 1.8)}" y="${n(y + 1.8)}" width="${w - 3.6}" height="${h - 3.6}" fill="none" stroke="${INK}" stroke-width=".25"/>`,
306  },
307}
308