SLOPSHOPPER

exported-wisp

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

paneband
A shopper browsing a rack in a slop shop
README

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

Source 10 files
hooks/register.tsx 150 lines
1// 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}
150
hooks/draw.tsx 30 lines
1// 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}
30
hooks/drawing.ts 46 lines
1// 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}
46
hooks/eidola-types.ts 128 lines
1// 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}
128
hooks/sprite.ts 296 lines
1// 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}
296
hooks/field.ts 383 lines
1// 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}
383
hooks/stage.ts 199 lines
1// 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}
199
hooks/wisp.ts 140 lines
1// 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}
140
hooks/wisp-live.tsx 441 lines
1// 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
441
hooks/live-pixels.tsx 255 lines
1// 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