Exported Wisp, an Engram for Eidola, made from the bundled Wisp.

An Engram for Eidola, made from the bundled Wisp: an Engram plugin (contract 1). Eidola keeps the frame, the HUD, the corner and the picker; this plugin draws the creature.
engram.json: the Engram (id exported-wisp, vessel.scene "self", remixOf "wisp": while the drawing can't draw, Eidola draws the Wisp in its place).hooks/register.tsx: announces the Engram to Eidola and hands each drawing Eidola asks for to hooks/draw.tsx.hooks/draw.tsx and the files beside it: the Wisp's drawing, copied whole. Change the creature here.vessel/: the picker's stills.Before you share it, from the Eidola mod's folder:
node scripts/check-engram.mjs <this folder>
claude plugin validate <this folder>
To try it by hand, add the folder holding this one as a local marketplace, install it beside Eidola, and /awaken exported-wisp.
hooks/register.tsx 150 lines1// Exported Wisp: an Engram plugin for Eidola, made from the bundled Wisp by
2// Eidola's scripts/export-engram.mjs (contract 1, docs/spec/engram-plugin.md
3// in the Eidola mod). This module announces the Engram, and hands each
4// drawing Eidola asks for to hooks/draw.tsx, the Wisp's own drawing
5// module, copied here with everything it draws with: change the creature
6// there and in the files beside it. Run Eidola's checker on the folder
7// (node scripts/check-engram.mjs <folder>) before you share it.
8//
9// It writes none of Eidola's state and adds nothing to any prompt.
10
11import type { Elements, EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
12
13import { draw } from './draw'
14import type { DrawEls, DrawKit } from './drawing'
15import type { EidolaHandoff, EidolaWhere, Mood } from './eidola-types'
16import { FACES } from './sprite'
17
18/** This Engram's id: engram.json's. */
19const ID = 'exported-wisp'
20/** Its name, for the drawing's alt text: engram.json's. */
21const NAME = "Exported Wisp"
22/** The Box its drawing comes back in, and the slot Eidola leaves for it. */
23const SCENE = `eidola:${ID}`
24const SLOT = `eidola-slot:${ID}`
25/** Where a failure is reported from. */
26const FILE = 'hooks/register.tsx'
27/** How long the face shows the error effect after the ink rises, where no Client can run: in ms. */
28const ERROR_MS = 2000
29
30type Els = DrawEls & Pick<Elements['desktop'], 'Text'>
31
32// The backdrop last built for the pane and for the band. Its loops are
33// locked to the clock when it is built, so it is built again only when its
34// size changes: a redraw for anything else keeps the running drawing.
35const built = new Map<EidolaWhere, { key: string; svg: string }>()
36
37function memoFor(where: EidolaWhere): DrawKit['memo'] {
38 return (key, build) => {
39 const last = built.get(where)
40 if (last?.key === key) {
41 return last.svg
42 }
43 const svg = build()
44 built.set(where, { key, svg })
45 return svg
46 }
47}
48
49/** The creature, in one Box keyed eidola:<id>: live where a Client can run (the desktop), its face elsewhere (its error face while the ink is fresh). */
50function creature(els: Els, surface: RenderSurface, handoff: EidolaHandoff, now: number): RenderElement {
51 if (surface === 'desktop') {
52 const tree = draw(els, handoff, { name: NAME, now, memo: memoFor(handoff.where) })
53 return { ...(tree as object), props: { ...(tree as { props?: object }).props, key: SCENE } } as RenderElement
54 }
55 const { Box, Text } = els
56 return (
57 <Box key={SCENE} width={handoff.columns} height={handoff.rows}>
58 <Text>{FACES[handoff.ink > 0 && now - handoff.inkAt < ERROR_MS ? 'error' : handoff.mood]}</Text>
59 </Box>
60 )
61}
62
63/** Eidola's tree with this Engram's slot, wherever it is, filled; anything else as it came. */
64function fill(node: unknown, make: (columns: number, rows: number) => RenderElement): unknown {
65 if (node === null || typeof node !== 'object') {
66 return node
67 }
68 const element = node as { props?: { key?: unknown; width?: unknown; height?: unknown }; children?: unknown }
69 if (element.props?.key === SLOT) {
70 return make(Number(element.props.width) || 20, Number(element.props.height) || 5)
71 }
72 if (Array.isArray(element.children)) {
73 return { ...element, children: element.children.map(child => fill(child, make)) }
74 }
75 return node
76}
77
78/** True when the tree holds this Engram's slot. */
79function hasSlot(node: unknown): boolean {
80 if (node === null || typeof node !== 'object') {
81 return false
82 }
83 const element = node as { props?: { key?: unknown }; children?: unknown }
84 return element.props?.key === SLOT || (Array.isArray(element.children) && element.children.some(hasSlot))
85}
86
87/** Tells Eidola the drawing failed, with the file and the stack's line: it draws the fallback and marks the Engram not loading. */
88async function fault($: EngineInterface, error: unknown): Promise<void> {
89 try {
90 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
91 } catch {
92 // Eidola isn't there to tell.
93 }
94}
95
96/**
97 * One drawing, the pane's or the band's. Beneath Eidola (this plugin loaded
98 * after it), Eidola asks with the hand-off on e.props.eidola. Above it (this
99 * plugin loaded first), Eidola's tree comes back from next with a slot to
100 * fill, and the state is read from Eidola's.
101 */
102async function answer($: EngineInterface, where: EidolaWhere, surface: RenderSurface, els: Els, props: unknown, below: () => Promise<RenderElement>): Promise<RenderElement> {
103 const handoff = (props as { eidola?: EidolaHandoff }).eidola
104 if (handoff) {
105 if (handoff.engram !== ID) {
106 return below()
107 }
108 try {
109 return creature(els, surface, handoff, await $.clock.now())
110 } catch (error) {
111 await fault($, error)
112 return below()
113 }
114 }
115 const tree = await below()
116 if (!hasSlot(tree)) {
117 return tree
118 }
119 const { value: mood = 'idle' as Mood } = await $.state.get({ plugin: 'eidola', key: 'mood' })
120 const { value: moodBefore = 'idle' as Mood } = await $.state.get({ plugin: 'eidola', key: 'moodBefore' })
121 const { value: moodAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'moodAt' })
122 const { value: ink = 0 } = await $.state.get({ plugin: 'eidola', key: 'ink' })
123 const { value: inkAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'inkAt' })
124 const { value: epoch = 0 } = await $.state.get({ plugin: 'eidola', key: 'epoch' })
125 const now = await $.clock.now()
126 try {
127 return fill(tree, (columns, rows) => creature(els, surface, { contract: 1, engram: ID, where, columns, rows, mood, moodBefore, moodAt, ink, inkAt, epoch }, now)) as RenderElement
128 } catch (error) {
129 await fault($, error)
130 return tree
131 }
132}
133
134export const register: Register = on => {
135 // Announce: Eidola reads engram.json from this folder. Without Eidola there
136 // is nobody to announce to, and nothing else changes.
137 on('session.start', async ($, e, next) => {
138 try {
139 await $.eidola.announce({ root: $.plugin.root })
140 } catch {
141 // Eidola isn't installed: this plugin draws nothing.
142 }
143 return next(e)
144 })
145
146 // The pane and the band, one hook each, the same way.
147 on('ui.render', { component: 'Pane', requestId: 'eidola' }, ($, e, next) => answer($, 'pane', e.surface, $.ui.resolve(e) as unknown as Els, e.props, () => next(e)))
148 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => answer($, 'band', e.surface, $.ui.resolve(e) as unknown as Els, e.props, () => next(e)))
149}
150hooks/draw.tsx 30 lines1// The Wisp's drawing module (hooks/drawing.ts): its fen drawn once, the
2// spirit and its lanterns live over it by wisp-live.tsx. The Wisp draws only
3// in the band; the pane, opened by hand, draws the same band view. A row
4// taller than its rows, clipped to them, so the HUD meets its edges.
5//
6// Eidola draws the bundled Wisp through this, and an Engram plugin exported
7// from it (scripts/export-engram.mjs) draws with this same file.
8
9import { overdrawn } from './drawing'
10import type { Draw } from './drawing'
11import { renderScene } from './field'
12import { PX_PER_COLUMN } from './stage'
13import { fenScene } from './wisp'
14
15export const draw: Draw = (els, handoff, kit) => {
16 const { Box, Svg, Client } = els
17 const { columns: cols, rows, mood, ink } = handoff
18 const width = cols * PX_PER_COLUMN
19 const height = overdrawn(rows)
20 const fen = kit.memo(`wisp-fen:${cols}x${rows}`, () => renderScene(fenScene(width, height, cols, rows), kit.now))
21 return (
22 <Box width={cols} height={rows} overflow="hidden" alignItems="flex-start">
23 <Svg key="scene:band-fen" source={fen} alt={`${kit.name} in the fen`} width={width} height={height} isInteractive />
24 <Box position="absolute" top={0} left={0}>
25 <Client key="wisp-band" module="./wisp-live.tsx" props={{ mood, cols, rows, ink }} width={cols} height={rows} />
26 </Box>
27 </Box>
28 )
29}
30hooks/drawing.ts 46 lines1// The drawing interface (docs/spec/engram-plugin.md, "The hand-off"): what
2// draws a creature from the hand-off. Each bundled Engram's drawing module,
3// engrams/<id>/hooks/draw.tsx, is one of these, and Eidola draws the bundled
4// three through it as it asks an Engram plugin. scripts/export-engram.mjs
5// copies a bundled Engram's drawing module, and this file with it, into an
6// Engram plugin of its own, whose hooks call the very same function with the
7// hand-off Eidola gives them.
8
9import type { Elements, RenderElement } from 'claude-code'
10
11import type { EidolaHandoff } from './eidola-types'
12import { PX_PER_ROW } from './stage'
13
14/** The elements a drawing draws with: a desktop's, where a Client can run. */
15export type DrawEls = Pick<Elements['desktop'], 'Box' | 'Svg' | 'Client'>
16
17/** What the hook drawing the creature gives it besides the hand-off. */
18export type DrawKit = {
19 /** The Engram's name, as its engram.json gives it: for the drawing's alt text. */
20 name: string
21 /** The clock now (ms): a still backdrop's loops are locked to it when built. */
22 now: number
23 /**
24 * A still backdrop's SVG by key, built once and kept while the key holds:
25 * a rebuilt one (a new clock) would reload the drawing on the surface.
26 */
27 memo: (key: string, build: () => string) => string
28}
29
30/**
31 * A creature's drawing, `columns` × `rows` cells of the hand-off: one Box,
32 * as wide and tall, holding the creature. The Engram plugin's hooks key it
33 * `eidola:<id>` before they answer it; Eidola places a bundled one as it is.
34 */
35export type Draw = (els: DrawEls, handoff: EidolaHandoff, kit: DrawKit) => RenderElement
36
37/**
38 * A pane backdrop's height in px: a row taller than its rows at 19 px each,
39 * clipped to them. A row is drawn a fraction of a pixel taller than 19, so a
40 * backdrop of exactly rows × 19 stops a few pixels short of the HUD's bottom
41 * brackets, which sit on the rows; drawn taller and clipped it meets them.
42 */
43export function overdrawn(rows: number): number {
44 return (rows + 1) * PX_PER_ROW
45}
46hooks/eidola-types.ts 128 lines1// Eidola's public types (contract 1), copied from the Eidola mod's
2// types/index.d.ts by scripts/export-engram.mjs: what the drawing reads off
3// the hand-off and Eidola's state. Types alone; $.eidola and Eidola's state
4// are typed by the contract the engine lays into .claude-plugin/types/eidola/
5// once this plugin loads beside Eidola.
6
7export type VesselState =
8 | 'idle'
9 | 'thinking'
10 | 'working'
11 | 'attention'
12 | 'success'
13 | 'responding'
14 | 'error'
15 | 'waiting'
16 | 'sleeping'
17
18/**
19 * What the drawings follow: three moods, set by the turn rather than by each
20 * tool, so a turn changes the drawing two or three times, not dozens.
21 */
22export type Mood = 'idle' | 'working' | 'attention'
23
24/** What the status line shows: the state and a short line of detail under it. */
25export type VesselView = { state: VesselState; detail: string }
26
27/**
28 * An Engram page's HUD preview: the mood its chips set, or the error its Error
29 * button shows, drawn in the preview alone and never in the session.
30 */
31export type Preview = {
32 mood: Mood
33 /** The Error button was pressed since the last mood chip. */
34 isError: boolean
35 /** How many times the Error button was pressed: the preview's own ink. */
36 ink: number
37 /** When it was last pressed, and when the preview's mood last changed (ms). */
38 inkAt: number
39 moodAt: number
40 /** The preview's mood before the last change. */
41 moodBefore: Mood
42}
43
44/** The version of the public contract this Eidola keeps. */
45export type EidolaContract = 1
46
47/** What an Engram plugin passes to `$.eidola.announce`. */
48export type EidolaAnnounceInput = {
49 /** The plugin's own folder, the one holding engram.json: `$.plugin.root`. */
50 root: string
51}
52
53/** One reason Eidola could not take an Engram plugin's engram.json. */
54export type EidolaIssue = { file: string; problem: string; fix: string }
55
56/** What `$.eidola.announce` answers. */
57export type EidolaAnnounced = {
58 /** The contract this Eidola keeps. */
59 contract: EidolaContract
60 /** The Engram's id when it is in the registry; null when it isn't. */
61 engram: string | null
62 /** Why it isn't: each file, problem and fix. Empty when it is. */
63 issues: EidolaIssue[]
64}
65
66/** What an Engram plugin passes to `$.eidola.fault` when its own drawing fails. */
67export type EidolaFaultInput = {
68 /** The Engram's id. */
69 engram: string
70 /** What went wrong, in a line. */
71 message: string
72 /** The file that failed, relative to the plugin's folder (`hooks/creature.tsx`). */
73 file?: string
74 /** The line in that file, when known. */
75 line?: number
76 /** The error's stack, when there is one: Eidola reads the line off it when `line` is left out. */
77 stack?: string
78}
79
80/** What `$.eidola.fault` answers: whether Eidola marked the Engram not loading. */
81export type EidolaFaulted = { isMarked: boolean }
82
83/** Where Eidola is handing the drawing off: its pane, or the band above the prompt. */
84export type EidolaWhere = 'pane' | 'band'
85
86/**
87 * The hand-off, on `e.props.eidola` of the `ui.render` Eidola passes to
88 * `next`: present only when Eidola asks the awake Engram's plugin for its
89 * drawing. Draw `columns` × `rows` cells, in one Box keyed `eidola:<engram>`.
90 */
91export type EidolaHandoff = {
92 contract: EidolaContract
93 /** The awake Engram's id: draw only when it is yours. */
94 engram: string
95 where: EidolaWhere
96 /** The drawing area inside Eidola's frame, in cells. */
97 columns: number
98 rows: number
99 mood: Mood
100 moodBefore: Mood
101 moodAt: number
102 ink: number
103 inkAt: number
104 epoch: number
105}
106
107/** The `eidola` noun Eidola adds to `$`: what an Engram plugin calls. */
108export type Eidola = {
109 /**
110 * Tells Eidola an Engram plugin is here. Call it from `session.start`:
111 * Eidola reads `<root>/engram.json`, checks it, and adds the Engram to its
112 * registry. Works whichever plugin loaded first.
113 *
114 * @example
115 * on('session.start', async ($, e, next) => {
116 * await $.eidola.announce({ root: $.plugin.root })
117 * return next(e)
118 * })
119 */
120 announce: (input: EidolaAnnounceInput) => Promise<EidolaAnnounced>
121 /**
122 * Tells Eidola the plugin's own drawing failed: Eidola draws the Engram it
123 * was remixed from (or the resting sprite) instead, and marks it not
124 * loading with the file and line, until the plugin announces again.
125 */
126 fault: (input: EidolaFaultInput) => Promise<EidolaFaulted>
127}
128hooks/sprite.ts 296 lines1// Built-in sprite: 20x20 pixel frames composed from layers, emitted as one
2// SVG whose frames cycle with CSS steps(). No redraw per frame: the pane
3// redraws only when the state changes. Bundled Engrams now ship baked SVGs
4// from the Vessel Forge; this is the fallback and the media test's control.
5
6import type { VesselState } from './eidola-types'
7
8export const GRID = 20
9
10/** Character -> palette slot. '.' is transparent. */
11export type Palette = {
12 o: string // outline
13 b: string // body
14 h: string // highlight
15 e: string // eye
16 w: string // eye shine
17 c: string // cheek
18 m: string // mouth
19 a: string // accent (thought dots, z's)
20 r: string // alert
21 y: string // sparkle
22 d: string // sweat drop
23}
24
25/**
26 * The built-in sprite's colors. It draws the media test's control and
27 * stands in when a baked Vessel's file can't be read.
28 */
29export const SPRITE_PALETTE: Palette = {
30 o: '#3b2f8f',
31 b: '#cfc6ff',
32 h: '#f4f1ff',
33 e: '#1a1c2b',
34 w: '#ffffff',
35 c: '#ff9ec4',
36 m: '#3b2f8f',
37 a: '#9d93ff',
38 r: '#ff5c5c',
39 y: '#ffd25e',
40 d: '#6fc3ff',
41}
42
43type Grid = string[][]
44type Dot = [x: number, y: number, ch: keyof Palette]
45
46// The body: a domed spirit. Rows 0-4 stay free for accessories.
47const BODY = [
48 '....................',
49 '....................',
50 '....................',
51 '....................',
52 '....................',
53 '.......oooooo.......',
54 '.....oohhbbbboo.....',
55 '....ohhbbbbbbbbo....',
56 '...ohbbbbbbbbbbbo...',
57 '...obbbbbbbbbbbbo...',
58 '...obbbbbbbbbbbbo...',
59 '...obbbbbbbbbbbbo...',
60 '...obbbbbbbbbbbbo...',
61 '...obbbbbbbbbbbbo...',
62 '...obbbbbbbbbbbbo...',
63 '...obbbbbbbbbbbbo...',
64 '...obbbbbbbbbbbbo...',
65 '...obbbbbbbbbbbbo...',
66]
67
68// Two tail poses; frames alternate them so the spirit always drifts.
69const TAILS: [string[], string[]] = [
70 ['...obbbobbbbobbbo...', '....ooo.oooo.ooo....'],
71 ['...obbbbobbbbobbo...', '....oooo.oooo.oo....'],
72]
73
74// Faces: dots drawn over the body.
75const EYES = {
76 open: [
77 [6, 10, 'w'], [7, 10, 'e'], [6, 11, 'e'], [7, 11, 'e'],
78 [12, 10, 'w'], [13, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'],
79 ],
80 closed: [[6, 11, 'e'], [7, 11, 'e'], [12, 11, 'e'], [13, 11, 'e']],
81 happy: [[6, 11, 'e'], [7, 10, 'e'], [8, 11, 'e'], [11, 11, 'e'], [12, 10, 'e'], [13, 11, 'e']],
82 up: [
83 [6, 9, 'w'], [7, 9, 'e'], [6, 10, 'e'], [7, 10, 'e'],
84 [12, 9, 'w'], [13, 9, 'e'], [12, 10, 'e'], [13, 10, 'e'],
85 ],
86 focus: [[6, 11, 'e'], [7, 11, 'e'], [6, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'], [13, 10, 'e']],
87 wide: [
88 [6, 9, 'w'], [7, 9, 'e'], [6, 10, 'e'], [7, 10, 'e'], [6, 11, 'e'], [7, 11, 'e'],
89 [12, 9, 'w'], [13, 9, 'e'], [12, 10, 'e'], [13, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'],
90 ],
91 left: [
92 [5, 10, 'w'], [6, 10, 'e'], [5, 11, 'e'], [6, 11, 'e'],
93 [11, 10, 'w'], [12, 10, 'e'], [11, 11, 'e'], [12, 11, 'e'],
94 ],
95 right: [
96 [7, 10, 'w'], [8, 10, 'e'], [7, 11, 'e'], [8, 11, 'e'],
97 [13, 10, 'w'], [14, 10, 'e'], [13, 11, 'e'], [14, 11, 'e'],
98 ],
99 x: [
100 [5, 9, 'e'], [7, 9, 'e'], [6, 10, 'e'], [5, 11, 'e'], [7, 11, 'e'],
101 [12, 9, 'e'], [14, 9, 'e'], [13, 10, 'e'], [12, 11, 'e'], [14, 11, 'e'],
102 ],
103} satisfies Record<string, Dot[]>
104
105const CHEEKS: Dot[] = [[5, 12, 'c'], [14, 12, 'c']]
106
107const MOUTH = {
108 smile: [[8, 13, 'm'], [9, 14, 'm'], [10, 14, 'm'], [11, 13, 'm']],
109 small: [[9, 14, 'm'], [10, 14, 'm']],
110 open: [[9, 13, 'm'], [10, 13, 'm'], [9, 14, 'm'], [10, 14, 'm']],
111 frown: [[8, 14, 'm'], [9, 13, 'm'], [10, 13, 'm'], [11, 14, 'm']],
112 o: [[9, 14, 'm']],
113} satisfies Record<string, Dot[]>
114
115function plus(x: number, y: number, ch: keyof Palette): Dot[] {
116 return [[x, y - 1, ch], [x - 1, y, ch], [x, y, ch], [x + 1, y, ch], [x, y + 1, ch]]
117}
118
119const BANG: Dot[] = [[9, 0, 'r'], [10, 0, 'r'], [9, 1, 'r'], [10, 1, 'r'], [9, 2, 'r'], [10, 2, 'r'], [9, 4, 'r'], [10, 4, 'r']]
120const DROP: Dot[] = [[16, 6, 'd'], [16, 7, 'd'], [17, 7, 'd'], [16, 8, 'd']]
121const Z_SMALL: Dot[] = [[16, 3, 'a'], [17, 3, 'a'], [18, 3, 'a'], [17, 4, 'a'], [16, 5, 'a'], [17, 5, 'a'], [18, 5, 'a']]
122const Z_BIG: Dot[] = [
123 [15, 0, 'a'], [16, 0, 'a'], [17, 0, 'a'], [18, 0, 'a'], [17, 1, 'a'], [16, 2, 'a'],
124 [15, 3, 'a'], [16, 3, 'a'], [17, 3, 'a'], [18, 3, 'a'],
125]
126
127/** One frame: which tail, and which dots on top. */
128type Frame = { tail: 0 | 1; dots: Dot[] }
129
130export type Animation = {
131 frames: Frame[]
132 /** Seconds each frame shows. */
133 frameS: number
134 /** Seconds per bob up and down; 0 holds still. */
135 bobS: number
136}
137
138const face = (...parts: Dot[][]): Dot[] => parts.flat()
139
140export const ANIMATIONS: Record<VesselState, Animation> = {
141 idle: {
142 frameS: 0.5,
143 bobS: 2,
144 frames: [0, 1, 0, 1, 0, 1].map((tail, i) => ({
145 tail: tail as 0 | 1,
146 dots: face(i === 5 ? EYES.closed : EYES.open, CHEEKS, MOUTH.small),
147 })),
148 },
149 thinking: {
150 frameS: 0.45,
151 bobS: 1.6,
152 frames: [
153 { tail: 0, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a']]) },
154 { tail: 1, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a'], [16, 3, 'a']]) },
155 { tail: 0, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a'], [16, 3, 'a'], [18, 1, 'a']]) },
156 ],
157 },
158 working: {
159 frameS: 0.18,
160 bobS: 0.5,
161 frames: [
162 { tail: 0, dots: face(EYES.focus, CHEEKS, MOUTH.small, plus(18, 7, 'y')) },
163 { tail: 1, dots: face(EYES.focus, CHEEKS, MOUTH.small, plus(1, 9, 'y')) },
164 ],
165 },
166 attention: {
167 frameS: 0.4,
168 bobS: 0.8,
169 frames: [
170 { tail: 0, dots: face(EYES.wide, CHEEKS, MOUTH.o, BANG) },
171 { tail: 1, dots: face(EYES.wide, CHEEKS, MOUTH.o) },
172 ],
173 },
174 success: {
175 frameS: 0.3,
176 bobS: 0.6,
177 frames: [
178 { tail: 0, dots: face(EYES.happy, CHEEKS, MOUTH.smile, plus(2, 4, 'y'), [[17, 2, 'y']]) },
179 { tail: 1, dots: face(EYES.happy, CHEEKS, MOUTH.smile, plus(17, 3, 'y'), [[2, 6, 'y']]) },
180 ],
181 },
182 responding: {
183 frameS: 0.2,
184 bobS: 1.2,
185 frames: [
186 { tail: 0, dots: face(EYES.open, CHEEKS, MOUTH.open) },
187 { tail: 1, dots: face(EYES.open, CHEEKS, MOUTH.small) },
188 ],
189 },
190 error: {
191 frameS: 0.6,
192 bobS: 0,
193 frames: [
194 { tail: 0, dots: face(EYES.x, MOUTH.frown, DROP) },
195 { tail: 1, dots: face(EYES.x, MOUTH.frown) },
196 ],
197 },
198 waiting: {
199 frameS: 0.45,
200 bobS: 1.6,
201 frames: [
202 { tail: 0, dots: face(EYES.left, CHEEKS, MOUTH.small) },
203 { tail: 1, dots: face(EYES.right, CHEEKS, MOUTH.small) },
204 ],
205 },
206 sleeping: {
207 frameS: 0.9,
208 bobS: 3.6,
209 frames: [
210 { tail: 0, dots: face(EYES.closed, MOUTH.o, Z_SMALL) },
211 { tail: 1, dots: face(EYES.closed, MOUTH.o, Z_BIG) },
212 ],
213 },
214}
215
216export function compose(frame: Frame): Grid {
217 const rows = [...BODY, ...TAILS[frame.tail]]
218 const grid = rows.map(row => row.split(''))
219 for (const [x, y, ch] of frame.dots) {
220 const row = grid[y]
221 if (row !== undefined && x >= 0 && x < GRID) {
222 row[x] = ch
223 }
224 }
225 return grid
226}
227
228/** Horizontal runs of one color become one rect, to keep the SVG small. */
229export function rects(grid: Grid, palette: Palette): string {
230 const out: string[] = []
231 grid.forEach((row, y) => {
232 let x = 0
233 while (x < row.length) {
234 const ch = row[x]
235 if (ch === '.') {
236 x += 1
237 continue
238 }
239 let end = x + 1
240 while (end < row.length && row[end] === ch) end += 1
241 const fill = palette[ch as keyof Palette] ?? '#ff00ff'
242 out.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${fill}"/>`)
243 x = end
244 }
245 })
246 return out.join('')
247}
248
249const r = (n: number) => Math.round(n * 1000) / 1000
250
251/** The whole animated Vessel for one state, as SVG markup. */
252export function vesselSvg(state: VesselState, palette: Palette, size: number): string {
253 const anim = ANIMATIONS[state]
254 const n = anim.frames.length
255 const cycle = r(anim.frameS * n)
256 const slice = r(100 / n)
257
258 const delays = anim.frames
259 .map((_, i) => `.d${i}{animation-delay:-${r(((n - i) % n) * anim.frameS)}s}`)
260 .join('')
261 const bob =
262 anim.bobS > 0
263 ? `.bob{animation:bob ${anim.bobS}s steps(1,end) infinite}` +
264 '@keyframes bob{0%{transform:translateY(0)}50%{transform:translateY(-1px)}100%{transform:translateY(0)}}'
265 : ''
266 const css =
267 `.f{opacity:0;animation:fr ${cycle}s steps(1,end) infinite}.f0{opacity:1}` +
268 `@keyframes fr{0%{opacity:1}${slice}%{opacity:0}100%{opacity:0}}` +
269 delays +
270 bob +
271 '@media (prefers-reduced-motion:reduce){.f,.bob{animation:none}}'
272
273 const frames = anim.frames
274 .map((frame, i) => `<g class="f f${i === 0 ? '0' : 'n'} d${i}">${rects(compose(frame), palette)}</g>`)
275 .join('')
276
277 return (
278 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 ${GRID} ${GRID + 1}" ` +
279 `width="${size}" height="${Math.round((size * (GRID + 1)) / GRID)}" shape-rendering="crispEdges">` +
280 `<style>${css}</style><g class="bob">${frames}</g></svg>`
281 )
282}
283
284/** Terminal fallback: one line per state. */
285export const FACES: Record<VesselState, string> = {
286 idle: '( o_o )',
287 thinking: '( o_o ) . o O',
288 working: '( >_< ) *',
289 attention: '( O_O ) !',
290 success: '( ^_^ ) +',
291 responding: '( o0o )',
292 error: '( x_x )',
293 waiting: '( o_o)>',
294 sleeping: '( -_- ) z',
295}
296hooks/field.ts 383 lines1// The field engine. A scene is a sampler: for a point and a frame it gives
2// a layer and a brightness. The engine samples it onto a grid sized to the
3// slot, once per frame, and an ink turns the grid into SVG:
4//
5// glyph a character per cell, from a ramp of dim to dense
6// pixel a block per cell, dithered between a layer's shades
7// particle a dot per lit cell, sized by brightness
8//
9// Each layer names its ink, so one scene can mix them: a pixel creature in
10// particle water. Frames cycle by CSS, locked to the wall clock, so a
11// redraw for a new state carries the motion on unbroken.
12
13export type Ink = 'glyph' | 'pixel' | 'particle'
14export const INKS: readonly Ink[] = ['glyph', 'pixel', 'particle']
15
16/** How one layer of a scene looks in each ink. */
17export type LayerStyle = {
18 /** glyph: characters from dim to dense; a space draws nothing. */
19 chars: string
20 /** pixel: shades from dim to bright. */
21 shades: string[]
22 /** glyph and particle: the fill, a colour or a paint server. */
23 fill: string
24 opacity?: number
25 /** Drawn with a soft bloom. */
26 isGlowing?: boolean
27 /** The ink this layer draws in; the scene's when absent. */
28 ink?: Ink
29}
30
31/** [layer, brightness]: layer 0 draws nothing; brightness runs 0 to 1. */
32export type Cell = readonly [number, number]
33export const EMPTY: Cell = [0, 0]
34
35export type FieldScene = {
36 width: number
37 height: number
38 /** Index 0 is the empty layer and is never drawn. */
39 layers: LayerStyle[]
40 /** The ink of any layer that names none. */
41 ink: Ink
42 /**
43 * A point in pixels, a frame of `frames`: what moves there, or EMPTY.
44 * `want` is the layers this pass draws; a scene may skip work for others.
45 */
46 sample: (x: number, y: number, frame: number, frames: number, want: ReadonlySet<number>) => Cell
47 /**
48 * What lies still behind it: sampled once and drawn outside the frame
49 * cycle, so a backdrop costs one frame however many the scene has. A
50 * cell the moving layers reach in any frame is left out of it.
51 */
52 still?: (x: number, y: number, want: ReadonlySet<number>) => Cell
53 /**
54 * How the still backdrop moves: a small drift side to side (the default),
55 * or a sway from the top edge, as light shafts hang from the surface.
56 */
57 stillMotion?: 'drift' | 'sway'
58 /** Seconds for one pass through the frames. */
59 period: number
60 /** Markup drawn under the field and over it; defs and CSS the scene needs. */
61 under: string
62 over: string
63 defs: string[]
64 css: string[]
65}
66
67/** Cell size in pixels and frame count per ink: what the budget allows. */
68export const GRID: Record<Ink, { cw: number; ch: number; frames: number }> = {
69 glyph: { cw: 8, ch: 14, frames: 8 },
70 pixel: { cw: 8, ch: 8, frames: 3 },
71 particle: { cw: 9, ch: 9, frames: 3 },
72}
73
74const r1 = (n: number) => Math.round(n * 10) / 10
75
76/** A stable value in [0, 1) for a cell: the same every frame and every draw. */
77export function hash(x: number, y: number): number {
78 const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453
79 return s - Math.floor(s)
80}
81
82/** Smooth value noise in [0, 1). */
83export function noise(x: number, y: number): number {
84 const xi = Math.floor(x)
85 const yi = Math.floor(y)
86 const xf = x - xi
87 const yf = y - yi
88 const u = xf * xf * (3 - 2 * xf)
89 const v = yf * yf * (3 - 2 * yf)
90 const a = hash(xi, yi)
91 const b = hash(xi + 1, yi)
92 const c = hash(xi, yi + 1)
93 const d = hash(xi + 1, yi + 1)
94 return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
95}
96
97export const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n)
98export const smoothstep = (a: number, b: number, x: number) => {
99 const t = clamp01((x - a) / (b - a))
100 return t * t * (3 - 2 * t)
101}
102
103/** 4×4 ordered dither thresholds. */
104const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(n => (n + 0.5) / 16)
105
106type Sampled = { cols: number; rows: number; layer: Uint8Array[]; value: Float32Array[]; still?: { layer: Uint8Array; value: Float32Array } }
107
108/** The scene sampled on one ink's grid, keeping only the layers drawn in that ink. */
109function sampleAll(scene: FieldScene, cw: number, ch: number, frames: number, keep: Set<number>): Sampled {
110 const cols = Math.floor(scene.width / cw)
111 const rows = Math.floor(scene.height / ch)
112 const layer: Uint8Array[] = []
113 const value: Float32Array[] = []
114 for (let f = 0; f < frames; f += 1) {
115 const L = new Uint8Array(cols * rows)
116 const V = new Float32Array(cols * rows)
117 for (let y = 0; y < rows; y += 1) {
118 for (let x = 0; x < cols; x += 1) {
119 const [l, v] = scene.sample((x + 0.5) * cw, (y + 0.5) * ch, f, frames, keep)
120 if (l > 0 && v > 0 && keep.has(l)) {
121 L[y * cols + x] = l
122 V[y * cols + x] = clamp01(v)
123 }
124 }
125 }
126 layer.push(L)
127 value.push(V)
128 }
129 let still: Sampled['still']
130 if (scene.still) {
131 const L = new Uint8Array(cols * rows)
132 const V = new Float32Array(cols * rows)
133 for (let y = 0; y < rows; y += 1) {
134 for (let x = 0; x < cols; x += 1) {
135 const i = y * cols + x
136 if (layer.some(f => f[i] !== 0)) {
137 continue
138 }
139 const [l, v] = scene.still((x + 0.5) * cw, (y + 0.5) * ch, keep)
140 if (l > 0 && v > 0 && keep.has(l)) {
141 L[i] = l
142 V[i] = clamp01(v)
143 }
144 }
145 }
146 still = { layer: L, value: V }
147 }
148 return { cols, rows, layer, value, still }
149}
150
151function glyphFrame(scene: FieldScene, s: Grid, cw: number, ch: number): string {
152 const out: string[] = []
153 scene.layers.forEach((style, l) => {
154 if (l === 0) {
155 return
156 }
157 const texts: string[] = []
158 for (let y = 0; y < s.rows; y += 1) {
159 let line = ''
160 let first = -1
161 let last = -1
162 for (let x = 0; x < s.cols; x += 1) {
163 const i = y * s.cols + x
164 let c = ' '
165 if (s.layer[i] === l) {
166 // Lifted and jittered a little, so mid-tones don't band into rows of one character.
167 const v = clamp01(Math.pow(s.value[i]!, 0.75) + (hash(x, y) - 0.5) * 0.14)
168 c = style.chars[Math.min(style.chars.length - 1, Math.floor(v * style.chars.length))]!
169 }
170 if (c !== ' ') {
171 if (first < 0) {
172 first = x
173 }
174 last = x
175 }
176 line += c
177 }
178 if (first >= 0) {
179 texts.push(`<text x="${first * cw}" y="${r1(y * ch + ch * 0.78)}" textLength="${(last - first + 1) * cw}">${line.slice(first, last + 1)}</text>`)
180 }
181 }
182 if (texts.length) {
183 out.push(layerGroup(style, texts.join(''), style.fill))
184 }
185 })
186 return out.join('')
187}
188
189function pixelFrame(scene: FieldScene, s: Grid): string {
190 const out: string[] = []
191 scene.layers.forEach((style, l) => {
192 if (l === 0) {
193 return
194 }
195 const n = style.shades.length
196 const runs: string[][] = style.shades.map(() => [])
197 for (let y = 0; y < s.rows; y += 1) {
198 let runLevel = -1
199 let runStart = 0
200 const flush = (end: number) => {
201 if (runLevel >= 0) {
202 const len = end - runStart
203 runs[runLevel]!.push(`M${runStart} ${y}h${len}v1h-${len}z`)
204 }
205 }
206 for (let x = 0; x < s.cols; x += 1) {
207 const i = y * s.cols + x
208 let level = -1
209 if (s.layer[i] === l) {
210 const at = s.value[i]! * (n - 1)
211 const base = Math.floor(at)
212 level = Math.min(n - 1, at - base > BAYER[(y % 4) * 4 + (x % 4)]! ? base + 1 : base)
213 }
214 if (level !== runLevel) {
215 flush(x)
216 runLevel = level
217 runStart = x
218 }
219 }
220 flush(s.cols)
221 }
222 const paths = runs.map((d, k) => (d.length ? `<path fill="${style.shades[k]}" d="${d.join('')}"/>` : '')).join('')
223 if (paths) {
224 out.push(layerGroup(style, paths))
225 }
226 })
227 return out.join('')
228}
229
230/** One grid's worth: a frame, or the still backdrop. */
231type Grid = { cols: number; rows: number; layer: Uint8Array; value: Float32Array }
232
233const DOT_SIZES = [0.32, 0.55, 0.85]
234
235function particleFrame(scene: FieldScene, s: Grid, cw: number, ch: number): string {
236 const out: string[] = []
237 scene.layers.forEach((style, l) => {
238 if (l === 0) {
239 return
240 }
241 const buckets: string[][] = DOT_SIZES.map(() => [])
242 for (let y = 0; y < s.rows; y += 1) {
243 for (let x = 0; x < s.cols; x += 1) {
244 const i = y * s.cols + x
245 if (s.layer[i] !== l) {
246 continue
247 }
248 const v = s.value[i]!
249 if (v < 0.08) {
250 continue
251 }
252 const k = v < 0.4 ? 0 : v < 0.72 ? 1 : 2
253 const jx = (hash(x, y) - 0.5) * cw * 0.6
254 const jy = (hash(y, x) - 0.5) * ch * 0.6
255 buckets[k]!.push(`M${Math.round((x + 0.5) * cw + jx)} ${Math.round((y + 0.5) * ch + jy)}h0`)
256 }
257 }
258 const paths = buckets
259 .map((d, k) => (d.length ? `<path stroke="${style.fill}" stroke-width="${r1(DOT_SIZES[k]! * cw)}" d="${d.join('')}"/>` : ''))
260 .join('')
261 if (paths) {
262 out.push(layerGroup(style, paths))
263 }
264 })
265 return out.join('')
266}
267
268function layerGroup(style: LayerStyle, body: string, fill?: string): string {
269 const attrs =
270 (fill ? ` fill="${fill}"` : '') +
271 (style.opacity !== undefined ? ` opacity="${style.opacity}"` : '') +
272 (style.isGlowing ? ' filter="url(#bloom)"' : '')
273 return `<g${attrs}>${body}</g>`
274}
275
276const BLOOM =
277 '<filter id="bloom" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>'
278
279/** What a drawing may come to: under the Svg element's 131,072, with room to spare. */
280export const BUDGET = 120_000
281
282/** The slot area GRID's cell sizes were set for: a 412 × 1442 dock. */
283const REFERENCE_AREA = 412 * 1442
284
285/**
286 * The scene drawn, its frames cycling from where the clock says. Runs of
287 * layers that share an ink are one pass, each on its own grid, drawn in
288 * layer order. `only` draws every layer in one ink, to compare inks.
289 *
290 * Cells grow with the slot so a larger one costs no more: from a scale set
291 * by its area, and coarser again until the drawing fits the budget.
292 */
293export function renderScene(scene: FieldScene, nowMs: number, only?: Ink): string {
294 let scale = Math.max(1, Math.sqrt((scene.width * scene.height) / REFERENCE_AREA))
295 let svg = renderAt(scene, nowMs, only, scale)
296 for (let tries = 0; svg.length > BUDGET && tries < 6; tries += 1) {
297 scale *= 1.2
298 svg = renderAt(scene, nowMs, only, scale)
299 }
300 return svg
301}
302
303function renderAt(scene: FieldScene, nowMs: number, only: Ink | undefined, scale: number): string {
304 const W = Math.round(scene.width)
305 const H = Math.round(scene.height)
306 const P = scene.period
307 const clock = (nowMs / 1000) % P
308
309 const passes: Array<{ ink: Ink; layers: number[] }> = []
310 scene.layers.forEach((style, l) => {
311 if (l === 0) {
312 return
313 }
314 const ink = only ?? style.ink ?? scene.ink
315 const last = passes[passes.length - 1]
316 if (last?.ink === ink) {
317 last.layers.push(l)
318 } else {
319 passes.push({ ink, layers: [l] })
320 }
321 })
322
323 const css: string[] = []
324 const reduced: string[] = []
325 const body = passes
326 .map((pass, p) => {
327 const base = GRID[pass.ink]
328 const cw = Math.round(base.cw * scale)
329 const ch = Math.round(base.ch * scale)
330 const frames = base.frames
331 const s = sampleAll(scene, cw, ch, frames, new Set(pass.layers))
332 const draw = (g: Grid) =>
333 pass.ink === 'glyph' ? glyphFrame(scene, g, cw, ch) : pass.ink === 'pixel' ? pixelFrame(scene, g) : particleFrame(scene, g, cw, ch)
334 const slot = P / frames
335
336 let stillMarkup = s.still ? draw({ cols: s.cols, rows: s.rows, ...s.still }) : ''
337 if (stillMarkup) {
338 // The still backdrop drifts a little, so it never reads as frozen.
339 // Pixel ink draws in cell units, the others in pixels.
340 const drift = pass.ink === 'pixel' ? 0.5 : r1(cw * 0.5)
341 const delay = r1(-((nowMs / 1000) % 18))
342 css.push(
343 scene.stillMotion === 'sway'
344 ? `.s${p}{transform-box:view-box;transform-origin:${r1(W / 2)}px 0;animation:s${p} 7s ${delay}s ease-in-out infinite alternate}@keyframes s${p}{from{transform:skewX(-2.5deg)}to{transform:skewX(2.5deg)}}`
345 : `.s${p}{animation:s${p} 9s ${delay}s ease-in-out infinite alternate}@keyframes s${p}{from{transform:translate(-${drift}px,0)}to{transform:translate(${drift}px,0)}}`,
346 )
347 reduced.push(`.s${p}`)
348 stillMarkup = `<g class="s${p}">${stillMarkup}</g>`
349 }
350
351 const frameBodies = Array.from({ length: frames }, (_, f) => draw({ cols: s.cols, rows: s.rows, layer: s.layer[f]!, value: s.value[f]! }))
352 let frameMarkup = ''
353 if (frameBodies.some(b => b)) {
354 css.push(`.p${p}{opacity:0;animation:p${p} ${P}s steps(1,end) infinite}@keyframes p${p}{0%{opacity:1}${r1(100 / frames)}%{opacity:0}100%{opacity:0}}`)
355 reduced.push(`.p${p}`)
356 frameMarkup = frameBodies
357 .map((b, f) => {
358 const delay = r1(-(((P - f * slot) % P) + clock) % P)
359 return `<g class="p${p}${f === 0 ? ' f0' : ''}" style="animation-delay:${delay}s">${b}</g>`
360 })
361 .join('')
362 }
363
364 const attrs =
365 pass.ink === 'glyph'
366 ? ` font-family="ui-monospace,Menlo,Consolas,monospace" font-size="${r1(ch * 1.02)}" font-weight="700" style="white-space:pre"`
367 : pass.ink === 'pixel'
368 ? ` transform="scale(${cw} ${ch})" shape-rendering="crispEdges"`
369 : ' fill="none" stroke-linecap="round"'
370 return stillMarkup || frameMarkup ? `<g${attrs}>${stillMarkup}${frameMarkup}</g>` : ''
371 })
372 .join('')
373
374 if (reduced.length) {
375 css.push(`@media (prefers-reduced-motion:reduce){${reduced.join(',')}{animation:none}.f0{opacity:1}}`)
376 }
377 return (
378 `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
379 `<style>${css.join('')}${scene.css.join('')}</style><defs>${BLOOM}${scene.defs.join('')}</defs>` +
380 `${scene.under}${body}${scene.over}</svg>`
381 )
382}
383hooks/stage.ts 199 lines1// The stage: the Vessel set in a scene the size of the pane body, so the
2// pane reads as a place rather than a small image in a large box. A glow
3// and a wash in the state's colour, a soft floor shadow, motes rising at
4// the state's tempo. Every layer fades to transparent before the edge, so
5// an estimate a few pixels off never shows a seam, in either theme.
6
7import type { VesselState } from './eidola-types'
8
9// Measured on Claude Code Desktop with /eidola measure (2026-10-16): a
10// 52 × 76 cell dock body drew 412 × 1443 px, a 95-column band 756 px.
11export const PX_PER_COLUMN = 8
12export const PX_PER_ROW = 19
13
14/** The dark behind a band view drawn across the middle of the pane. */
15export const BACKDROP = '#07090c'
16
17/** The veil drawn over the resting scene while nothing is awake. */
18export const VEIL = '#000'
19
20/** The Svg element's limit on `source`. */
21export const SVG_LIMIT = 131072
22
23/**
24 * The band's square stage, in CSS pixels. Desktop draws the band at least
25 * about 170px tall whatever its tree, so this fills it rather than float.
26 */
27export const BAND_STAGE_PX = 140
28
29/** Inline the pane fits its tree, so the stage asks for no more than this. */
30const INLINE_ROWS = 10
31
32export type StageSize = { width: number; height: number }
33
34export const ACCENTS: Record<VesselState, string> = {
35 idle: '#8fa3bf',
36 thinking: '#8c7bff',
37 working: '#ffb547',
38 attention: '#ffd84a',
39 success: '#5fd38d',
40 responding: '#4fd1c5',
41 error: '#ff5c5c',
42 waiting: '#b39ddb',
43 sleeping: '#4a5a80',
44}
45
46/** Seconds per glow breath. */
47const BREATH: Record<VesselState, number> = {
48 idle: 6,
49 thinking: 3,
50 working: 1.6,
51 attention: 1.2,
52 success: 2.4,
53 responding: 2.4,
54 error: 1,
55 waiting: 4,
56 sleeping: 9,
57}
58
59/** Seconds for a mote to rise; busier states rise faster. */
60const RISE: Record<VesselState, number> = {
61 idle: 14,
62 thinking: 9,
63 working: 5,
64 attention: 7,
65 success: 6,
66 responding: 8,
67 error: 10,
68 waiting: 12,
69 sleeping: 22,
70}
71
72const MOTES = 18
73
74/** The pane body in CSS pixels, a cell short each way so it never overflows. */
75export function stageSize(columns: number, rows: number, placement: 'dock' | 'inline'): StageSize {
76 const usable = placement === 'inline' ? Math.min(rows, INLINE_ROWS) : rows
77 return {
78 width: Math.max(96, (columns - 1) * PX_PER_COLUMN),
79 height: Math.max(96, (usable - 1) * PX_PER_ROW),
80 }
81}
82
83/** A scene band's height: it fills the band's own minimum height. */
84export const BAND_SCENE_PX = 140
85
86/**
87 * The whole pane body in CSS pixels, for a scene that fills it edge to edge.
88 * The measured dock body was 4px under columns × 8 and 2px under rows × 19.
89 */
90export function sceneSize(columns: number, rows: number, placement: 'dock' | 'inline'): StageSize {
91 const usable = placement === 'inline' ? Math.min(rows, INLINE_ROWS) : rows
92 return {
93 width: Math.max(96, columns * PX_PER_COLUMN - 4),
94 height: Math.max(96, usable * PX_PER_ROW - 2),
95 }
96}
97
98type Box = { x: number; y: number; w: number; h: number }
99
100function viewBoxOf(svg: string): Box | undefined {
101 const m = /viewBox="([-\d.]+)[ ,]+([-\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)"/.exec(svg)
102 if (!m) {
103 return undefined
104 }
105 const [x, y, w, h] = [m[1], m[2], m[3], m[4]].map(Number) as [number, number, number, number]
106 return w > 0 && h > 0 ? { x, y, w, h } : undefined
107}
108
109/** A fixed sequence in [0, 1): the motes sit in the same places every draw. */
110function seeded(seed: number): () => number {
111 let a = seed >>> 0
112 return () => {
113 a = (a + 0x6d2b79f5) >>> 0
114 let t = a
115 t = Math.imul(t ^ (t >>> 15), t | 1)
116 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
117 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
118 }
119}
120
121const r1 = (n: number) => Math.round(n * 10) / 10
122
123/**
124 * The Vessel's SVG set on a stage of `size`, or undefined when it can't be:
125 * no viewBox to place it by, or the result past the Svg limit. The caller
126 * then draws the Vessel alone.
127 */
128export function stageSvg(vessel: string, state: VesselState, size: StageSize): string | undefined {
129 const box = viewBoxOf(vessel)
130 if (!box) {
131 return undefined
132 }
133 const { width: W, height: H } = size
134 const accent = ACCENTS[state]
135
136 // A tall dock seats the Vessel above centre, leaving room for its shadow.
137 const isTall = H > W * 1.2
138 const scale = Math.min((W * 0.72) / box.w, (H * (isTall ? 0.42 : 0.78)) / box.h)
139 const vw = box.w * scale
140 const vh = box.h * scale
141 const cx = W / 2
142 const cy = isTall ? H * 0.42 : H / 2
143 const vx = cx - vw / 2
144 const vy = cy - vh / 2
145 const reach = Math.max(vw, vh)
146 // The wash is an ellipse that ends at the nearest edge on each axis, and
147 // the glow a circle inside it, so neither tints an edge into a seam.
148 const rx = Math.max(1, Math.min(cx, W - cx))
149 const ry = Math.max(1, Math.min(cy, H - cy))
150 const glowR = Math.min(reach * 0.95, rx, ry)
151
152 const random = seeded(7)
153 const rise = Math.round(Math.min(H * 0.55, reach * 2.2))
154 const motes: string[] = []
155 for (let i = 0; i < MOTES; i += 1) {
156 const x = r1(cx + (random() - 0.5) * Math.min(W * 0.85, reach * 2))
157 const y = r1(cy + vh * 0.2 + random() * vh * 0.5)
158 const r = r1(0.8 + random() * 1.6)
159 const dur = r1(RISE[state] * (0.7 + random() * 0.6))
160 const delay = r1(-random() * dur)
161 motes.push(`<circle class="eds-mote" cx="${x}" cy="${y}" r="${r}" style="animation-duration:${dur}s;animation-delay:${delay}s"/>`)
162 }
163
164 // The Vessel nested at its place: its own width/height dropped, the slot's set.
165 const nested = vessel.replace(/<svg\b([^>]*)>/, (_match, attrs: string) => {
166 const kept = attrs.replace(/\s(width|height|x|y|preserveAspectRatio)="[^"]*"/g, '')
167 return `<svg x="${r1(vx)}" y="${r1(vy)}" width="${r1(vw)}" height="${r1(vh)}" preserveAspectRatio="xMidYMid meet"${kept}>`
168 })
169
170 const css =
171 `.eds-glow{animation:eds-breath ${BREATH[state]}s ease-in-out infinite alternate}` +
172 '@keyframes eds-breath{from{opacity:.6}to{opacity:1}}' +
173 `.eds-mote{fill:${accent};opacity:0;animation-name:eds-rise;animation-timing-function:linear;animation-iteration-count:infinite}` +
174 `@keyframes eds-rise{0%{transform:translateY(0);opacity:0}15%{opacity:.75}80%{opacity:.4}100%{transform:translateY(-${rise}px);opacity:0}}` +
175 '@media (prefers-reduced-motion:reduce){.eds-glow,.eds-mote{animation:none}.eds-mote{opacity:.35}}'
176
177 const out =
178 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
179 `<style>${css}</style>` +
180 '<defs>' +
181 `<radialGradient id="eds-wash" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" ` +
182 `gradientTransform="translate(${r1(cx)} ${r1(cy)}) scale(${r1(rx)} ${r1(ry)})">` +
183 `<stop offset="0" stop-color="${accent}" stop-opacity=".14"/><stop offset=".45" stop-color="${accent}" stop-opacity=".07"/>` +
184 `<stop offset=".75" stop-color="${accent}" stop-opacity=".02"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></radialGradient>` +
185 `<radialGradient id="eds-glow" gradientUnits="userSpaceOnUse" cx="${r1(cx)}" cy="${r1(cy)}" r="${r1(glowR)}">` +
186 `<stop offset="0" stop-color="${accent}" stop-opacity=".42"/><stop offset=".35" stop-color="${accent}" stop-opacity=".2"/>` +
187 `<stop offset=".65" stop-color="${accent}" stop-opacity=".06"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></radialGradient>` +
188 '<radialGradient id="eds-floor"><stop offset="0" stop-color="#000" stop-opacity=".45"/><stop offset="1" stop-color="#000" stop-opacity="0"/></radialGradient>' +
189 '</defs>' +
190 `<rect width="${W}" height="${H}" fill="url(#eds-wash)"/>` +
191 `<rect class="eds-glow" width="${W}" height="${H}" fill="url(#eds-glow)"/>` +
192 `<ellipse cx="${r1(cx)}" cy="${r1(vy + vh * 1.04)}" rx="${r1(vw * 0.45)}" ry="${r1(Math.max(3, vh * 0.05))}" fill="url(#eds-floor)"/>` +
193 `<g>${motes.join('')}</g>` +
194 nested +
195 '</svg>'
196
197 return out.length <= SVG_LIMIT ? out : undefined
198}
199hooks/wisp.ts 140 lines1// The Wisp's fen: a marsh at night, seen side on across the band. Indigo sky
2// and a few stars, a far line of trees in the mist, still black water, reeds,
3// and a boardwalk running the length of it. The fen is drawn once and never
4// changes; the Wisp, its glow and its reflection are drawn live over it
5// (wisp-live.tsx), on a grid both share through fenLayout.
6
7import type { VesselState } from './eidola-types'
8import { EMPTY, hash } from './field'
9import type { FieldScene, LayerStyle } from './field'
10import { PX_H, PX_W } from './live-pixels'
11
12/**
13 * Where things lie on the live drawing's pixel grid (8 × 9.5 px, two rows a
14 * cell row), for a band `cols` × `rows` cells: the row the water starts on,
15 * and the boardwalk's row above it.
16 */
17export function fenLayout(_cols: number, rows: number): { water: number; deck: number; height: number } {
18 const height = rows * 2
19 const water = height - 4
20 const deck = water - 1
21 return { water, deck, height }
22}
23
24const r1 = (n: number) => Math.round(n * 10) / 10
25
26/** The fen's layers: none move in the field; it is all markup and CSS. */
27function fenLayers(): LayerStyle[] {
28 return [{ chars: ' ', shades: ['#000'], fill: '#000' }]
29}
30
31/**
32 * The fen alone, `W` × `H` px, for a band `cols` × `rows` cells: the same
33 * whatever the mood, so it never reloads. Its motion is CSS: stars twinkle,
34 * the mist drifts, the reeds sway, light shivers on the water.
35 */
36export function fenScene(W: number, H: number, cols: number, rows: number): FieldScene {
37 const { water, deck } = fenLayout(cols, rows)
38 const waterY = water * PX_H
39 const deckY = deck * PX_H
40 const horizon = waterY - PX_H * 2.2
41 const defs = [
42 `<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#090818"/><stop offset=".6" stop-color="#16133a"/><stop offset="1" stop-color="#2a2152"/></linearGradient>`,
43 `<linearGradient id="pool" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#140f2e"/><stop offset="1" stop-color="#040309"/></linearGradient>`,
44 `<radialGradient id="haze" cx=".5" cy="1" r=".8"><stop offset="0" stop-color="#6b5bd6" stop-opacity=".18"/><stop offset="1" stop-color="#6b5bd6" stop-opacity="0"/></radialGradient>`,
45 `<filter id="soft" x="-20%" y="-50%" width="140%" height="200%"><feGaussianBlur stdDeviation="5"/></filter>`,
46 ]
47
48 // Stars: tiny, in the upper sky, each twinkling on its own clock.
49 const stars = Array.from({ length: Math.round(W / 22) }, (_, i) => {
50 const x = hash(i, 11) * W
51 const y = hash(i, 12) * horizon * 0.8
52 const t = r1(2 + hash(i, 13) * 4)
53 return `<rect x="${r1(x)}" y="${r1(y)}" width="${hash(i, 14) < 0.2 ? 2 : 1}" height="${hash(i, 14) < 0.2 ? 2 : 1}" style="animation:tw ${t}s ${r1(-hash(i, 15) * t)}s ease-in-out infinite alternate"/>`
54 }).join('')
55
56 // The far trees: a ragged dark line on the horizon, stepped as pixel art.
57 let trees = `M0 ${r1(horizon + 6)}`
58 for (let x = 0; x <= W; x += PX_W) {
59 const crown = hash(Math.floor(x / 40), 21) < 0.35 ? 10 + hash(Math.floor(x / 40), 22) * 16 : 0
60 const h = 4 + hash(x, 23) * 5 + crown * (1 - Math.abs(((x % 40) - 20) / 20))
61 trees += `V${r1(horizon - h)}h${PX_W}`
62 }
63 trees += `V${r1(waterY)}H0Z`
64
65 // Mist: soft bands low over the water, drifting slowly side to side.
66 const mist = [0, 1, 2]
67 .map(i => {
68 const y = horizon - 4 + i * PX_H * 0.7
69 const t = 18 + i * 7
70 return `<ellipse cx="${r1(W * (0.25 + i * 0.25))}" cy="${r1(y)}" rx="${r1(W * 0.45)}" ry="${r1(PX_H * 0.9)}" fill="#8d84c8" fill-opacity="${0.07 + i * 0.02}" filter="url(#soft)" style="animation:mist ${t}s ${-i * 5}s ease-in-out infinite alternate"/>`
71 })
72 .join('')
73
74 // The boardwalk: one plank's depth on the deck row, posts down into the water.
75 const posts = Array.from({ length: Math.ceil(W / 64) }, (_, i) => `<rect x="${i * 64 + 20}" y="${r1(deckY + PX_H)}" width="${PX_W}" height="${r1(PX_H * 2.2)}"/>`).join('')
76 const deckMarkup =
77 `<rect x="0" y="${r1(deckY)}" width="${W}" height="${r1(PX_H)}" fill="#1c1630"/>` +
78 `<rect x="0" y="${r1(deckY)}" width="${W}" height="2" fill="#3a2f5e"/>` +
79 `<g fill="#120e22">${posts}</g>`
80
81 // Light on the water: thin lines that shiver, under the drawing's reflections.
82 const glints = Array.from({ length: Math.round(W / 30) }, (_, i) => {
83 const x = hash(i, 31) * W
84 const y = waterY + PX_H * 1.2 + hash(i, 32) * (H - waterY - PX_H * 1.5)
85 const t = r1(1.5 + hash(i, 33) * 2.5)
86 return `<rect x="${r1(x)}" y="${r1(y)}" width="${8 + Math.round(hash(i, 34) * 16)}" height="1" style="animation:glint ${t}s ${r1(-hash(i, 35) * t)}s ease-in-out infinite alternate"/>`
87 }).join('')
88
89 // Reeds: clumps of thin dark blades rising out of the water near the
90 // front, each swaying from its root.
91 const reeds = Array.from({ length: Math.round(W / 46) }, (_, i) => {
92 const x = hash(i, 41) * W
93 const blades = 2 + Math.floor(hash(i, 42) * 3)
94 const t = r1(3 + hash(i, 43) * 3)
95 const root = H - hash(i, 44) * PX_H
96 const parts = Array.from({ length: blades }, (_, k) => {
97 const bx = x + (k - blades / 2) * 3
98 const tall = PX_H * (2.2 + hash(i * 7 + k, 45) * 2.4)
99 return `<rect x="${r1(bx)}" y="${r1(root - tall)}" width="2" height="${r1(tall)}"/>`
100 }).join('')
101 return `<g style="transform-origin:${r1(x)}px ${r1(root)}px;animation:sway ${t}s ${r1(-hash(i, 46) * t)}s ease-in-out infinite alternate">${parts}</g>`
102 }).join('')
103
104 const css = [
105 '@keyframes tw{from{opacity:.25}to{opacity:.9}}',
106 '@keyframes mist{from{transform:translateX(-30px)}to{transform:translateX(30px)}}',
107 '@keyframes glint{from{opacity:.05}to{opacity:.35}}',
108 '@keyframes sway{from{transform:rotate(-3deg)}to{transform:rotate(3deg)}}',
109 '@media (prefers-reduced-motion:reduce){rect,ellipse,g{animation:none!important}}',
110 ]
111
112 return {
113 width: W,
114 height: H,
115 layers: fenLayers(),
116 ink: 'pixel',
117 period: 6,
118 under:
119 `<rect width="${W}" height="${r1(waterY)}" fill="url(#sky)"/>` +
120 `<g fill="#d8d2ff">${stars}</g>` +
121 `<rect y="${r1(horizon - PX_H * 3)}" width="${W}" height="${r1(PX_H * 4)}" fill="url(#haze)"/>` +
122 `<path d="${trees}" fill="#0c0a1c"/>` +
123 mist +
124 `<rect y="${r1(waterY)}" width="${W}" height="${r1(H - waterY)}" fill="url(#pool)"/>` +
125 `<g fill="#a99cff">${glints}</g>` +
126 deckMarkup,
127 over: `<g fill="#07060f">${reeds}</g>`,
128 defs,
129 css,
130 sample() {
131 return EMPTY
132 },
133 }
134}
135
136/** The fen as a scene for a state, for anything that draws a scene by state: the state changes nothing in it. */
137export function fenStateScene(_state: VesselState, W: number, H: number): FieldScene {
138 return fenScene(W, H, Math.round(W / PX_W), Math.max(4, Math.round(H / (PX_H * 2))))
139}
140hooks/wisp-live.tsx 441 lines1// The Wisp, live, in its fen: a small spirit flying free over black water.
2// Drawn in pixels (8 × 9.5 px) over the fen (wisp.ts), which it shares a
3// grid with.
4//
5// It goes where it likes: off one side of the band and back from the other,
6// down under the water and up again, ripples spreading where it breaks the
7// surface. It and its glow take its mood's colour, a ghost fire flickering at
8// its edge, and the water glows with it from below: white at rest, teal-green at
9// work, yellow when it needs you, red when something fails (and it gutters,
10// smoking, and sinks).
11// Work finished, it circles up bright while fireflies rise from the reeds.
12// Now and then, whatever its mood, a star falls across the sky, and a drop
13// or a rising fish sets a ring spreading on the water.
14
15import type { ClientModule } from 'claude-code'
16
17import type { Mood } from './eidola-types'
18import { drawPixels, mixHex, short } from './live-pixels'
19import type { Steady } from './live-pixels'
20import { fenLayout } from './wisp'
21
22type Props = { mood: Mood; cols: number; rows: number; ink?: number }
23
24type Mote = { x: number; y: number; vx: number; vy: number; life: number; seed: number }
25/** A ring on the water: where, how far it has spread, how far it goes before it is gone. */
26type Ripple = { x: number; age: number; max: number }
27/** A falling star: where its head is, how it moves, ticks left. */
28type Meteor = { x: number; y: number; vx: number; vy: number; life: number }
29/** What it is doing: drifting somewhere, dipping under, leaving by a side to come back by the other, or swooping. */
30type Plan = 'drift' | 'dip' | 'leave' | 'swoop'
31
32type Live = {
33 mood: Mood
34 tick: number
35 seed: number
36 /** Where it is and how it moves, in pixels; where it is headed, and why. */
37 x: number
38 y: number
39 vx: number
40 vy: number
41 tx: number
42 ty: number
43 plan: Plan
44 planUntil: number
45 /** Which way it looks: -1 left, 0 at you, 1 right. */
46 look: number
47 /** Its glow's colour, eased: how far toward work's teal, attention's green, failure's red. */
48 work: number
49 heed: number
50 ash: number
51 /** Ticks left guttering after something failed; the ink it has seen. */
52 gutter: number
53 inkSeen: number
54 /** Ticks left celebrating work finished. */
55 cheer: number
56 motes: Mote[]
57 meteor: Meteor | null
58 ripples: Ripple[]
59 wasUnder: boolean
60 steady: Steady
61}
62type State = { live: Live }
63
64const TICK_MS = 100
65
66/** The spirit, 7 × 8 pixels: o outline, b body, h highlight, e eye. Its tail flickers between two shapes. */
67const BODY = ['..ooo..', '.ohhbo.', 'ohbbbbo', 'obbbbbo', 'obbbbbo', 'obbbbbo']
68const TAILS = [
69 ['.obobo.', '..o.o..'],
70 ['.obobo.', '.o...o.'],
71]
72
73/** A mood's look: the spirit's colours and its glow, outermost first. The whole of it takes the mood. */
74type Look = { o: string; b: string; h: string; e: string; glow: string[] }
75const WHITE: Look = { o: '#4a4a6a', b: '#e8e8f6', h: '#ffffff', e: '#1a1c2b', glow: ['#34344c', '#5c5c7a', '#9c9cb8', '#e0e0f2'] }
76const TEAL: Look = { o: '#1a5a48', b: '#a8f5d8', h: '#ebfff6', e: '#0c2420', glow: ['#0c2a24', '#12463a', '#1c6a56', '#2aa884'] }
77const YELLOW: Look = { o: '#7a6414', b: '#fff0a0', h: '#fffbe0', e: '#2a2208', glow: ['#2a2614', '#4a4218', '#7a6a20', '#b8a02a'] }
78const RED: Look = { o: '#6a1a24', b: '#ffb0b8', h: '#ffe8ea', e: '#2a0a0e', glow: ['#2a0f14', '#4a161e', '#7a1e2a', '#b82a3c'] }
79
80/** One look part way to another, every colour of it. */
81function mixLook(a: Look, b: Look, k: number): Look {
82 if (k <= 0.001) {
83 return a
84 }
85 const m = (x: string, y: string) => mixHex(x, y, Math.min(1, k))
86 return { o: m(a.o, b.o), b: m(a.b, b.b), h: m(a.h, b.h), e: m(a.e, b.e), glow: a.glow.map((g, i) => m(g, b.glow[i]!)) }
87}
88
89
90/** The water's dark, which what lies in it is mixed into; and the water's own blue, for what is under it. */
91const POOL = '#0a0818'
92const DEEP = '#16284a'
93const SMOKE = short('#3a3650')
94const RIPPLE = '#8f86d0'
95const FLY = ['#a39340', '#ffe680'].map(short)
96/** A falling star's head, then its tail, fading. */
97const METEOR = ['#ffffff', '#d8d8f4', '#9a9ac4', '#5c5c8a', '#36365a'].map(short)
98
99/** #rgb as #rrggbb, for mixing. */
100const long = (c: string) => (c.length === 4 ? `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}` : c)
101
102/** The drawing's own sequence in [0, 1): it decides the same way every run from the same start. */
103function random(live: Live): number {
104 live.seed = (live.seed + 0x6d2b79f5) >>> 0
105 let t = live.seed
106 t = Math.imul(t ^ (t >>> 15), t | 1)
107 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
108 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
109}
110
111// ── The spirit ────────────────────────────────────────────────────────
112
113const WispLive: ClientModule<Props, State> = (props, surface) => {
114 const { Box } = surface.elements
115 const { water, deck, height } = fenLayout(props.cols, props.rows)
116
117 let state = surface.state
118 if (!state) {
119 const live: Live = {
120 mood: props.mood,
121 tick: 0,
122 seed: 20261016,
123 x: props.cols * 0.35,
124 y: water - 6,
125 vx: 0,
126 vy: 0,
127 tx: props.cols * 0.35,
128 ty: water - 6,
129 plan: 'drift',
130 planUntil: 0,
131 look: 1,
132 work: props.mood === 'working' ? 1 : 0,
133 heed: props.mood === 'attention' ? 1 : 0,
134 ash: 0,
135 gutter: 0,
136 inkSeen: props.ink ?? 0,
137 cheer: 0,
138 motes: [],
139 meteor: null,
140 ripples: [],
141 wasUnder: false,
142 steady: { step: 0, calm: 0 },
143 }
144 state = { live }
145 surface.every(TICK_MS, () => {
146 step(live, props.cols, water, height)
147 surface.setState({ live })
148 })
149 surface.setState(state)
150 }
151 const live = state.live
152
153 // Work finished: it circles up bright, and fireflies rise from the reeds.
154 if (props.mood !== live.mood) {
155 if (live.mood === 'working' && props.mood === 'idle') {
156 live.cheer = 50
157 for (let i = 0; i < 24; i += 1) {
158 live.motes.push({
159 x: random(live) * props.cols,
160 y: water - random(live) * 2,
161 vx: (random(live) - 0.5) * 0.3,
162 vy: -0.05 - random(live) * 0.1,
163 life: 60 + Math.floor(random(live) * 50),
164 seed: i,
165 })
166 }
167 }
168 live.mood = props.mood
169 live.planUntil = 0
170 }
171 // Something failed: its glow goes red, it gutters, smoking, and sinks under for a while.
172 if ((props.ink ?? 0) > live.inkSeen) {
173 live.inkSeen = props.ink ?? 0
174 live.gutter = 60
175 live.cheer = 0
176 }
177
178 const look = mixLook(mixLook(mixLook(WHITE, TEAL, live.work), YELLOW, live.heed), RED, live.ash)
179 const body: Record<string, string> = { o: short(look.o), b: short(look.b), h: short(look.h), e: short(look.e) }
180 const glow = look.glow.map(short)
181 const wx = Math.round(live.x)
182 const wy = Math.round(live.y)
183 const tail = TAILS[Math.floor(live.tick / 3) % 2]!
184 const pulse = 0.5 + 0.5 * Math.sin(live.tick * 0.8)
185 // Its glow: small at rest, wider at work, pulsing hard when it needs you,
186 // widest celebrating, a stub guttering.
187 // A close glow: a ring or two round it, wider only when it needs you.
188 let reach = 3.6 + live.work * 0.4 + live.heed * (0.5 + pulse * 1.2)
189 if (live.cheer > 0) {
190 reach = 4.8 + pulse * 0.8
191 }
192 if (live.gutter > 0) {
193 reach = 2 + (live.tick % 5 < 2 ? 0 : 1)
194 }
195 // Its eyes: turned the way it goes, or straight at you when it needs you.
196 const facing = live.mood === 'attention' ? 0 : live.look
197 const eyes = facing < 0 ? [1, 3] : facing > 0 ? [3, 5] : [2, 4]
198
199 /** The spirit itself at a pixel, wherever it is: above the water or under it. */
200 const spirit = (x: number, py: number): string => {
201 const sx = x - (wx - 3)
202 const sy = py - (wy - 4)
203 if (sx < 0 || sx >= 7 || sy < 0 || sy >= 8) {
204 return ''
205 }
206 const row = sy < 6 ? BODY[sy]! : tail[sy - 6]!
207 let ch = row[sx]!
208 if (sy === 3 && eyes.includes(sx)) {
209 ch = 'e'
210 }
211 return ch === '.' ? '' : body[ch]!
212 }
213
214 /**
215 * A glow's shade at distance `d` of `reach`: its outermost ring dithered,
216 * the dither shifting tick to tick, so its edge flickers like ghost fire.
217 */
218 const glowAt = (ramp: string[], d: number, r: number, x: number, py: number): string => {
219 const i = Math.min(ramp.length - 1, Math.floor((1 - d / r) * ramp.length * 0.999))
220 return i === 0 && (x + py + (live.tick >> 1)) % 2 ? '' : ramp[i]!
221 }
222
223 /** The falling star at a pixel: its head, and its tail back along its path, dimming as it burns out. */
224 const meteorAt = (x: number, py: number): string => {
225 const m = live.meteor
226 if (!m) {
227 return ''
228 }
229 const dim = m.life < 4 ? 4 - m.life : 0
230 for (let k = 0; k < METEOR.length - dim; k += 1) {
231 if (Math.round(m.x - m.vx * k * 0.7) === x && Math.round(m.y - m.vy * k * 0.7) === py) {
232 return METEOR[Math.min(METEOR.length - 1, k + dim)]!
233 }
234 }
235 return ''
236 }
237
238 /** What is above the water at a pixel: the drawing without its reflection. */
239 const above = (x: number, py: number): string => {
240 const star = meteorAt(x, py)
241 if (star) {
242 return star
243 }
244 for (const m of live.motes) {
245 if (Math.round(m.x) === x && Math.round(m.y) === py) {
246 return FLY[(m.seed + live.tick) % 4 === 0 ? 0 : 1]!
247 }
248 }
249 const body = spirit(x, py)
250 if (body) {
251 return body
252 }
253 const d = Math.hypot((x - wx) * 0.85, py - (wy - 1))
254 if (wy < water && d < reach) {
255 const g = glowAt(glow, d, reach, x, py)
256 if (g) {
257 return g
258 }
259 }
260 // Smoke off a guttering wisp: a wavering column rising off its crown.
261 if (live.gutter > 0 && py < wy - 4 && py >= wy - 4 - Math.min(7, live.gutter / 6)) {
262 if (x === wx + Math.round(Math.sin(py * 0.9 + live.tick * 0.4))) {
263 return SMOKE
264 }
265 }
266 return ''
267 }
268
269 // The light it throws into the water from above, or carries down with it:
270 // brightest just under the surface, wider the closer it flies.
271 const near = Math.max(0, 1 - Math.abs(live.y - water) / 9)
272 const underReach = 2 + near * 4.5 + live.heed * pulse * 1.2
273 const underGlow = glow.map(g => short(mixHex(long(g), POOL, 0.6)))
274
275 const pixels = drawPixels(
276 Box,
277 props.cols,
278 props.rows,
279 (x, py) => {
280 if (py < water) {
281 // The boardwalk's row is the fen's, unless the spirit crosses it.
282 return py === deck && !spirit(x, py) ? '' : above(x, py)
283 }
284 const depth = py - water
285 const ripple = Math.round(Math.sin(py * 1.9 + live.tick * 0.35))
286 // Under the water: itself, wavering and blue-dark.
287 const sunk = spirit(x + ripple, py)
288 if (sunk) {
289 return short(mixHex(long(sunk), DEEP, 0.35 + depth * 0.08))
290 }
291 // Rings where it broke the surface, spreading and fading.
292 if (depth === 0) {
293 for (const r of live.ripples) {
294 // A drop's first moment: a fleck where it fell.
295 if (r.age < 1 && x === r.x) {
296 return short(RIPPLE)
297 }
298 if (Math.abs(Math.abs(x - r.x) - r.age) < 0.5) {
299 return short(mixHex(RIPPLE, POOL, Math.min(0.9, 0.3 + (r.age / r.max) * 0.6)))
300 }
301 }
302 }
303 // The water mirrors what is over it, broken by ripples and darkened.
304 const mirrored = 2 * water - 1 - py
305 const seen = mirrored >= 0 && mirrored < deck ? above(x + ripple, mirrored) : ''
306 if (seen) {
307 return short(mixHex(long(seen), POOL, 0.45 + depth * 0.1))
308 }
309 // Its glow from under the surface.
310 const d = Math.hypot((x - wx) * 0.55, (py - Math.max(water + 1, wy)) * 1.6)
311 if (d < underReach) {
312 return glowAt(underGlow, d, underReach, x, py)
313 }
314 return ''
315 },
316 undefined,
317 live.steady,
318 )
319 return pixels
320}
321
322/** A new plan for its mood: where to go next, and for how long at most. */
323function plan(live: Live, cols: number, water: number): void {
324 const r = random(live)
325 const working = live.mood === 'working'
326 if (r < (working ? 0.12 : 0.15)) {
327 // Off one side, to come back by the other.
328 live.plan = 'leave'
329 live.tx = live.x < cols / 2 ? -9 : cols + 9
330 live.ty = 3 + random(live) * (water - 6)
331 } else if (r < (working ? 0.45 : 0.4)) {
332 // Down under the water, then up again.
333 live.plan = 'dip'
334 live.tx = Math.max(4, Math.min(cols - 4, live.x + (random(live) - 0.5) * 30))
335 live.ty = water + 3 + random(live) * 2
336 } else if (working && r < 0.8) {
337 // A quick swoop across the band.
338 live.plan = 'swoop'
339 live.tx = random(live) * cols
340 live.ty = 3 + random(live) * (water - 5)
341 } else {
342 live.plan = 'drift'
343 live.tx = 6 + random(live) * (cols - 12)
344 live.ty = 3 + random(live) * (water - 6)
345 }
346 live.planUntil = live.tick + (working ? 30 : 70) + Math.floor(random(live) * (working ? 30 : 60))
347}
348
349/** One tick: where it heads, how it moves, its colour, the motes and ripples. */
350function step(live: Live, cols: number, water: number, height: number): void {
351 live.tick += 1
352 live.gutter = Math.max(0, live.gutter - 1)
353 live.cheer = Math.max(0, live.cheer - 1)
354
355 // Its colour eases toward its mood's, over a second or two.
356 const ease = (v: number, to: number) => v + (to - v) * 0.08
357 live.work = ease(live.work, live.mood === 'working' ? 1 : 0)
358 live.heed = ease(live.heed, live.mood === 'attention' ? 1 : 0)
359 live.ash = ease(live.ash, live.gutter > 0 ? 1 : 0)
360
361 if (live.gutter > 20) {
362 // Guttering: it sinks under and stays there a while.
363 live.tx = live.x
364 live.ty = water + 3
365 } else if (live.mood === 'attention') {
366 // Up front and centre, facing you, bobbing to be seen.
367 live.tx = cols / 2
368 live.ty = 6 + (Math.sin(live.tick * 0.9) > 0 ? 0 : 1.5)
369 } else if (live.cheer > 0) {
370 // Work finished: a bright circle in the middle of the band.
371 const a = live.tick * 0.25
372 live.tx = cols / 2 + Math.cos(a) * 10
373 live.ty = 6 + Math.sin(a) * 2.5
374 } else {
375 const there = Math.abs(live.x - live.tx) < 1.5 && Math.abs(live.y - live.ty) < 1.5
376 if (live.plan === 'leave' && (live.x < -6 || live.x > cols + 6)) {
377 // Gone off one side: it comes back by the other.
378 live.x = live.x < 0 ? cols + 6 : -6
379 live.vx = 0
380 plan(live, cols, water)
381 if (live.plan === 'leave') {
382 live.plan = 'drift'
383 live.tx = 6 + random(live) * (cols - 12)
384 }
385 } else if (live.plan === 'dip' && there) {
386 // Under: now up again.
387 live.plan = 'drift'
388 live.tx = live.x + (random(live) - 0.5) * 16
389 live.ty = 3 + random(live) * (water - 7)
390 } else if (there || live.tick >= live.planUntil) {
391 plan(live, cols, water)
392 }
393 }
394
395 // Eased toward its target, bobbing; quicker at work, needing you or celebrating. The water slows it.
396 const pull = live.mood === 'working' || live.cheer > 0 || live.mood === 'attention' ? 0.03 : 0.014
397 const drag = live.y > water ? 0.8 : 0.86
398 live.vx = (live.vx + (live.tx - live.x) * pull) * drag
399 live.vy = (live.vy + (live.ty - live.y) * pull) * drag
400 live.x += live.vx
401 live.y = Math.max(1, Math.min(height + 2, live.y + live.vy + Math.sin(live.tick * 0.18) * 0.12))
402 if (Math.abs(live.vx) > 0.05) {
403 live.look = Math.sign(live.vx)
404 }
405
406 // Breaking the surface, either way: a ring spreads from where it did.
407 const isUnder = live.y - 1 > water
408 if (isUnder !== live.wasUnder) {
409 live.ripples.push({ x: Math.round(live.x), age: 0, max: 9 })
410 live.wasUnder = isUnder
411 }
412 // Now and then a drop, or a fish rising: a smaller ring, somewhere on the water.
413 if (random(live) < 0.012) {
414 live.ripples.push({ x: Math.round(4 + random(live) * (cols - 8)), age: 0, max: 4 + random(live) * 3 })
415 }
416 live.ripples = live.ripples.filter(r => (r.age += 0.35) < r.max)
417
418 // Rarely, a star falls across the sky, high over the trees.
419 if (live.meteor) {
420 const m = live.meteor
421 m.x += m.vx
422 m.y += m.vy
423 m.life -= 1
424 if (m.life <= 0 || m.y > water - 6 || m.x < -6 || m.x > cols + 6) {
425 live.meteor = null
426 }
427 } else if (live.tick > 40 && random(live) < 1 / 350) {
428 const dir = random(live) < 0.5 ? -1 : 1
429 live.meteor = { x: 8 + random(live) * (cols - 16), y: 1 + random(live) * 3, vx: dir * (1.4 + random(live) * 0.8), vy: 0.4 + random(live) * 0.25, life: 12 + Math.floor(random(live) * 8) }
430 }
431
432 live.motes = live.motes.filter(m => {
433 m.x += m.vx + Math.sin((live.tick + m.seed * 7) * 0.3) * 0.15
434 m.y += m.vy
435 m.life -= 1
436 return m.life > 0 && m.y > 0
437 })
438}
439
440export default WispLive
441hooks/live-pixels.tsx 255 lines1// Pixels for the live drawings, shared by every live Engram: rows of Boxes,
2// two to a cell row (8 × 9.5 px on desktop), a coloured Box per run of one
3// colour and a bare Box for a gap. A frame that would hold too many Boxes is
4// drawn again with fewer shades, then with pixels twice the size each way.
5
6import type { ClientElements } from 'claude-code'
7
8export const PX_W = 8
9export const PX_H = 9.5
10/** Boxes a frame may hold: about 60,000 characters, well inside the 100,000 bound. */
11export const BOX_BUDGET = 700
12
13/** #rrggbb as #rgb, nearest: a few characters less on every Box. */
14export function short(hex: string): string {
15 const n = parseInt(hex.slice(1), 16)
16 return '#' + [16, 8, 0].map(k => Math.round(((n >> k) & 255) / 17).toString(16)).join('')
17}
18
19/** One colour part way to another, as #rrggbb. */
20export function mixHex(a: string, b: string, k: number): string {
21 const ca = parseInt(a.slice(1), 16)
22 const cb = parseInt(b.slice(1), 16)
23 const channel = (shift: number) => Math.round(((ca >> shift) & 255) * (1 - k) + ((cb >> shift) & 255) * k)
24 return '#' + ((channel(16) << 16) | (channel(8) << 8) | channel(0)).toString(16).padStart(6, '0')
25}
26
27/** A ramp part way to another, shade by shade; the shorter one stretched to the longer. */
28export function mixRamp(a: readonly string[], b: readonly string[], k: number): string[] {
29 const n = Math.max(a.length, b.length)
30 const at = (r: readonly string[], i: number) => r[Math.round((i * (r.length - 1)) / (n - 1))]!
31 return Array.from({ length: n }, (_, i) => mixHex(at(a, i), at(b, i), k))
32}
33
34/** A shade for a brightness, from at most `levels` of a ramp. */
35export function pick(ramp: readonly string[], v: number, levels: number): string {
36 const n = Math.min(levels, ramp.length)
37 const k = Math.min(n - 1, Math.max(0, Math.floor(v * n)))
38 return ramp[n === 1 ? ramp.length - 1 : Math.round((k * (ramp.length - 1)) / (n - 1))]!
39}
40
41/**
42 * The colour at pixel column `x` of pixel row `py` (two to a cell row), from
43 * at most `levels` shades; '' for nothing. A coarse pass asks only for even
44 * columns and rows.
45 */
46export type Colour = (x: number, py: number, levels: number) => string
47
48type Box = ClientElements['Box']
49
50/**
51 * Set only by the thumbnail bake (scripts/bake-thumbs.mjs): handed every
52 * frame's pixels before they are drawn, at full detail, budget or no.
53 */
54export const capturePixels: { to?: (cols: number, rows: number, colour: Colour) => void } = {}
55
56/**
57 * The pixels as one column of rows, within the Box budget. With a `focus`
58 * (a cell row), detail is spent where it matters: full-size pixels from that
59 * row outward for as far as the budget goes, and twice the size beyond it.
60 */
61export function drawPixels(Box: Box, cols: number, rows: number, colour: Colour, focus?: number, steady?: Steady) {
62 capturePixels.to?.(cols, rows, colour)
63 /** One pixel row as Boxes: a bare Box per gap, a coloured one per run of one shade. */
64 const pixelRow = (py: number, levels: number, step: number) => {
65 const boxes = []
66 let start = 0
67 const at = (x: number) => colour(x - (x % step), py, levels)
68 let c = at(0)
69 for (let x = 1; x <= cols; x += 1) {
70 const n = x < cols ? at(x) : '\u0000'
71 if (n !== c) {
72 if (c) {
73 boxes.push(<Box width={x - start} backgroundColor={c} />)
74 } else if (n !== '\u0000') {
75 boxes.push(<Box width={x - start} />)
76 }
77 start = x
78 c = n
79 }
80 }
81 return boxes
82 }
83
84 // Full detail if it fits: 8 × 9.5 px pixels, two rows to a cell row. Else
85 // fewer shades; and last, pixels twice as big each way (16 × 19 px, a row
86 // to a cell row), so something up at the glass reads as pixel art zoomed in.
87 const draw = (levels: number, isCoarse: boolean) => {
88 const out = []
89 let boxes = 0
90 let empty = 0
91 for (let y = 0; y < rows; y += 1) {
92 const halves = isCoarse ? [pixelRow(y * 2, levels, 2)] : [pixelRow(y * 2, levels, 1), pixelRow(y * 2 + 1, levels, 1)]
93 if (halves.every(h => !h.length)) {
94 empty += 1
95 continue
96 }
97 if (empty) {
98 out.push(<Box height={empty} />)
99 empty = 0
100 }
101 boxes += halves.reduce((sum, h) => sum + h.length, 0)
102 out.push(
103 isCoarse ? (
104 <Box flexDirection="row" height={1} alignItems="stretch">
105 {halves[0]}
106 </Box>
107 ) : (
108 <Box flexDirection="column" height={1}>
109 <Box flexDirection="row" height="50%" alignItems="stretch">
110 {halves[0]}
111 </Box>
112 <Box flexDirection="row" height="50%" alignItems="stretch">
113 {halves[1]}
114 </Box>
115 </Box>
116 ),
117 )
118 }
119 return { out, boxes }
120 }
121 if (focus !== undefined) {
122 return focused(Box, cols, rows, pixelRow, focus)
123 }
124 if (steady) {
125 return held(Box, cols, rows, draw, steady)
126 }
127 let drawn = draw(8, false)
128 for (const [levels, isCoarse] of STEPS.slice(1)) {
129 if (drawn.boxes <= BOX_BUDGET) {
130 break
131 }
132 drawn = draw(levels, isCoarse)
133 }
134 return (
135 <Box flexDirection="column" width={cols} height={rows}>
136 {drawn.out}
137 </Box>
138 )
139}
140
141/** The ways a frame may be drawn, finest first: shades, and whether its pixels are doubled. */
142const STEPS = [
143 [8, false],
144 [6, false],
145 [4, false],
146 [6, true],
147 [4, true],
148 [3, true],
149] as const
150
151/**
152 * A drawing's memory of how fine it drew last, so it does not flicker:
153 * deciding afresh each frame, one hovering at the budget flips between
154 * resolutions several times a second.
155 */
156export type Steady = { step: number; calm: number }
157
158/** Frames a drawing must fit comfortably before it tries a finer step again. */
159const CALM = 25
160/** How far under the budget a finer step must come to be taken. */
161const ROOM = 0.82
162
163type Draw = (levels: number, isCoarse: boolean) => { out: ReturnType<typeof h>[]; boxes: number }
164
165/**
166 * With hysteresis: coarser at once when a frame will not fit; finer only
167 * once frames have fitted for a while and the finer one fits with room.
168 */
169function held(Box: Box, cols: number, rows: number, draw: Draw, steady: Steady) {
170 let step = Math.min(Math.max(0, steady.step), STEPS.length - 1)
171 const at = (i: number) => draw(STEPS[i]![0], STEPS[i]![1])
172 let drawn = at(step)
173 if (drawn.boxes > BOX_BUDGET) {
174 while (drawn.boxes > BOX_BUDGET && step < STEPS.length - 1) {
175 step += 1
176 drawn = at(step)
177 }
178 steady.calm = 0
179 } else if (step > 0 && ++steady.calm >= CALM) {
180 const finer = at(step - 1)
181 if (finer.boxes <= BOX_BUDGET * ROOM) {
182 step -= 1
183 drawn = finer
184 }
185 steady.calm = 0
186 }
187 steady.step = step
188 return (
189 <Box flexDirection="column" width={cols} height={rows}>
190 {drawn.out}
191 </Box>
192 )
193}
194
195type PixelRow = (py: number, levels: number, step: number) => ReturnType<typeof h>[]
196
197/** Full-size pixels round the focus row, coarse ones beyond it, within the budget. */
198function focused(Box: Box, cols: number, rows: number, pixelRow: PixelRow, focus: number) {
199 let chosen: Array<{ fine?: ReturnType<typeof h>[][]; coarse: ReturnType<typeof h>[] }> = []
200 for (const levels of [8, 6, 4, 3]) {
201 const coarse = Array.from({ length: rows }, (_, y) => pixelRow(y * 2, levels, 2))
202 let total = coarse.reduce((sum, row) => sum + row.length, 0)
203 chosen = coarse.map(row => ({ coarse: row }))
204 if (total > BOX_BUDGET && levels > 3) {
205 continue
206 }
207 // Outward from the focus, one row above and one below in turn, while it fits.
208 const order = Array.from({ length: rows }, (_, y) => y).sort((a, b) => Math.abs(a - focus) - Math.abs(b - focus))
209 for (const y of order) {
210 const fine = [pixelRow(y * 2, levels, 1), pixelRow(y * 2 + 1, levels, 1)]
211 const more = fine[0]!.length + fine[1]!.length - coarse[y]!.length
212 if (total + more > BOX_BUDGET) {
213 break
214 }
215 total += more
216 chosen[y]!.fine = fine
217 }
218 break
219 }
220 const out = []
221 let empty = 0
222 for (const row of chosen) {
223 const isEmpty = row.fine ? row.fine.every(h => !h.length) : !row.coarse.length
224 if (isEmpty) {
225 empty += 1
226 continue
227 }
228 if (empty) {
229 out.push(<Box height={empty} />)
230 empty = 0
231 }
232 out.push(
233 row.fine ? (
234 <Box flexDirection="column" height={1}>
235 <Box flexDirection="row" height="50%" alignItems="stretch">
236 {row.fine[0]}
237 </Box>
238 <Box flexDirection="row" height="50%" alignItems="stretch">
239 {row.fine[1]}
240 </Box>
241 </Box>
242 ) : (
243 <Box flexDirection="row" height={1} alignItems="stretch">
244 {row.coarse}
245 </Box>
246 ),
247 )
248 }
249 return (
250 <Box flexDirection="column" width={cols} height={rows}>
251 {out}
252 </Box>
253 )
254}
255