SLOPSHOPPER

decorated

Teaches Claude a small tag language and draws it in its replies: colored spans, callouts, charts, heat maps, flow and sequence diagrams, trees, checklists and…

newrowscommandstatusprompttimer
v0.1.0MITupdated 2026-10-23thomas-whitaker1/decorated
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decorated
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /decorated ╭───────────────────────────────────────╮ │ D E C O R A T E D │ │ markup in replies, drawn by a mod │ ╰───────────────────────────────────────╯ Inline: build is green, 2 flaky, 1 skipped, see notes, an aside, look here, accent, Ctrl+O , PASS FAIL , a gradient run, coverage ████████▎· 82%, latency ▁▃▂▅▃▆█▄▃▂. ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ★ Callouts │ │ Hold any markdown, and other markup: like this. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Command output
╭───────────────────────────────────────╮ │ D E C O R A T E D │ │ markup in replies, drawn by a mod │ ╰───────────────────────────────────────╯ Inline: build is green, 2 flaky, 1 skipped, see notes, an aside, look here, accent, Ctrl+O , PASS FAIL , a gradient run, coverage ████████▎· 82%, latency ▁▃▂▅▃▆█▄▃▂. ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ★ Callouts │ │ Hold any markdown, and other markup: like this. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ flow: a layered graph ╭───────╮ │ parse │ ╰───┬───╯ │ ├─────────────╮ ▼ ▼ ╭───────────╮ ╭──────╮ │ typecheck │ │ lint │ ╰─────┬─────╯ ╰───┬──╯ │ │ ▼ │ ╭──────╮ │ │ emit │ │ ╰───┬──╯ │ │ │ ├─────────────╯ ▼ ╭────────╮ │ report │ ╰────────╯ seq: a sequence diagram ╭─────────╮ ╭─────╮ ╭──────╮ │ Browser │ │ API │ │ Auth │ ╰────┬────╯ ╰──┬──╯ ╰───┬──╯ │ POST /login │ │ ├──────────────►│ │ │ │ verify(token) │ │ ├─────────────────►│ │ │ ok │ │ │◄╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ ├─╮ mint session │ │ │◄╯ │ │ 200 + cookie │ │ │◄╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ │ │ │ bars: before → after main.js █████████████░░░ 412kB → 318kB −23% vendor.js ███████████████████████████████████████████████░ 1204kB → 1190kB −1% styles.css ███▉ 88kB → 97kB +10% chart: braille lines 180ms ┤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠉⠒⠢⢄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠉⠑⠒⠤⠤⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 130ms ┤⠀⠀⠀⠀⠀⠀⣀⣀⣀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠤⣀⠀⠀⠉⠉⠒⠒⠒⠒⠤⠤⠤⠤⠤⢄⣀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ │⠤⠔⠒⠒⠉⠉⠀⠀⠀⠉⠉⠉⠑⠒⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠉⠀⠀⠀⠀⠉⠒⠤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠉⠉⠒⠒⠒⠒⠒⠒⠒⠤⠤⠤⠤ │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠑⠒⠢⠤⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 80ms ┤⣀⣀⣀⣀⠤⠤⠤⠤⠤⠤⢄⣀⣀⣀⣀⣀⠤⠤⠤⠔⠒⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠉⠒⠒⠒⠒⠒⠒⠒⠤⠤⠤⠤⠤⠤⠤⣀⣀⣀⣀ └──────────────────────────────────────────────────────────────── Mon Tue Wed Thu Fri ━━ api ━━ web heat: a heat map M T W T F S S wk1 wk2 wk3 0 12 ╭────────────────────────────────────────────────────────────────────────────╮ ╭──────────────────── │ tree │ │ steps │ src/ │ │ ✓ Bump version │ ├── parse.ts tag scanner │ │ ● Build 3 of 5 tar │ ├── widgets/ │ │ ✘ Sign binary cert │ │ ├── + flow.ts │ │ ○ Publish │ │ └── ~ bars.ts │ ╰──────────────────── │ └── - old.ts │ ╰────────────────────────────────────────────────────────────────────────────╯ ● 2026-09-01 Kickoff │ ● 2026-10-01 Beta │ ● 2026-10-22 GA Branch main Commit 7e11040 Status clean svg: half-block pixels, real-glyph labels, SMIL
README

decorated

A Claude Code mod that lets Claude draw in its replies. Claude writes a small tag language alongside its Markdown, and the mod draws it in place: colored spans and badges, callouts, checklists, file trees, bar charts with before → after deltas, braille line charts, heat maps, sparklines, flow and sequence diagrams, timelines, side-by-side panels and inline SVG.

[!NOTE] decorated is experimental. The tags, the guide that teaches them and how often Claude reaches for them may all change between versions. Feedback is the point of this release: a drawing that came out wrong, a terminal it breaks in, a reply Claude over- or under-decorated. Please open an issue; see CONTRIBUTING.md for what helps.

<img src="docs/gallery.gif" width="480" alt="/decorated in Windows Terminal: a banner, colored inline spans and badges, a callout, flow and sequence diagrams, before-and-after bars, a braille line chart, a heat map, a tree and steps side by side, a timeline, key-value pairs, and two animated SVGs: a sun setting behind mountains and a dot tracing a curve">

Tests are <ok>green</ok> after the fix; one <warn>flaky</warn> case remains.

<bars title="Suite time" unit="s" better="lower">
unit: 41 -> 12
integration: 188 -> 140
</bars>

Claude uses the tags only where a picture says more than prose. Wide drawings fall back to a plain list on narrow terminals. Run /decorated to see every tag drawn.

Install

At the prompt of a Claude Code terminal session (v2.1.299 or later):

/plugin install decorated --marketplace thomas-whitaker1/decorated

From a shell:

claude plugin install decorated --marketplace thomas-whitaker1/decorated

Use

The mod teaches Claude its tags through the system prompt, alongside any output style. /decorated off stops the teaching (kept across sessions), /decorated on brings it back. /decorated low, medium (the default) or high sets how much the guide asks Claude to decorate, also kept across sessions: low keeps to colored words and badges, with a block only where it is plainly the clearest form; high draws a block whenever a reply has a shape. Every level teaches the same tags. Where another reply mod teaches tags of the same names, decorated steps aside for it, so one mod teaches and draws them.

The guide is about 860 tokens at every level, sent with each request while the tags are taught. It is cached with the rest of the system prompt; switching the tags on or off, or changing the level, misses the cache for one request. Headless runs (claude -p) get no guide.

Steadier decoration through CLAUDE.md

The level says how much to decorate in general. Lines in a CLAUDE.md can say which replies get which drawing, for steadier results: in ~/.claude/CLAUDE.md for every project, or in one project's own. Add only the lines you want:

## Decorated replies

When the system prompt carries the "Decorated replies" guide:
- Report test, build and lint results with a `<badge>` per check, and compare
  timings or counts with `<bars>`.
- Track multi-step work with `<steps>`, updated as each step finishes.
- Show the files a change touched as a `<tree>` with `+ ` `- ` `~ ` marks.
- Put the one caveat the reader must not miss in a `<callout tone="warn">`.

Without that guide, write plain Markdown: nothing draws the tags.

Keep the condition in the first line. decorated draws tags in a reply even when it is not teaching them, but where nothing draws them the tags show raw. A CLAUDE.md rule without it asks for tags in claude -p output and after /decorated off too.

Compatibility

Claude Code 2.1.299 or later. Builds up to 2.1.296 refuse a prop decorated draws with, so there it teaches Claude no tags and says why on the status line. A plugin manifest cannot declare a minimum version, so the install does not stop you.

Checked on 2.1.309 in October 2026:

WhereDrawsNotes
Terminal, fullscreenyesSVG animations play
Terminal, main screenyesSVG animations show their final frame
Claude DesktopyesSVG drawn natively; the vertical lines of flow, tree and seq drawings show small gaps
VS Code extension panelpartlyreplies draw; /decorated shows plain text, as the panel draws a command's output itself
Phone, through Remote Controlnoreplies show as plain text
TerminalDrawsNotes
Windows Terminalyes
VS Code integrated terminalyes
conhostpartlythe fractional blocks of bars, sparklines and meters are missing glyphs
kitty 0.41 (Linux)yes
Ghostty 1.3 (Linux)yes
tmux 3.2 and 3.5yes256 colors unless CLAUDE_CODE_TMUX_TRUECOLOR=1 is set: heat maps and SVG gradients band
iTerm2untesteddecorated draws only text: box drawing, braille, half blocks, 24-bit color

Light and dark themes both read well: tones follow the theme, and heat maps pick text that contrasts with each cell. With NO_COLOR set, every drawing keeps its shape in one color. Drawings wider than the window fall back to a list; at 50 columns nothing overflows.

Other reply mods. A mod seated above decorated that draws a reply itself replaces decorated's drawing; one that rewrites the reply's text or wraps its drawing works with it. A mod seated beneath decorated sees every reply, but on one that holds tags its drawing and any rewrite of the text give way to decorated's: a mod that watches replies (prompt-rail) sees them all, while open-links does not make the links in those replies clickable. Replies without tags reach every mod as usual.

Security

decorated only draws. Its module uses no network, no files ($.fs), no processes ($.process) and no model calls. claude plugin validate lists every engine call it makes: the clock (for animation frames), its /decorated command, the system prompt (to add its guide), the session's version, its own state and store, the status line, and drawing. Across sessions it stores two values: whether /decorated off turned the teaching off, and the level /decorated low|medium|high set.

It reads every reply that holds its tags, so what a reply can ask of it is bounded:

  • parsing takes time linear in the reply: an 80,000-character reply costs about 15 ms per streamed update;
  • an <svg> stops at 80 rows, 4,000 elements, 48 levels of nesting and a fixed amount of raster work, about 0.2 s; past that it draws what it got to;
  • a <flow> past 100 nodes or 300 edges is listed instead of laid out, a <tree> draws 32 levels, and callouts and columns nest 8 deep.

On Claude Desktop and the VS Code panel an <svg> goes to the surface's own SVG element. decorated writes that markup out again without scripts, event handlers, <style>, <image>, <foreignObject>, or any link or url() pointing outside the drawing, so a drawing loads nothing from elsewhere.

Known issues and limitations

Most of these are in the tables above; this is the whole list in one place.

  • Claude decides when to draw. The level and CLAUDE.md steer it; neither guarantees it. Expect the odd reply with a tag it didn't need, or none where one would have helped.
  • Older Claude Code. Below 2.1.299 decorated teaches nothing, and the install doesn't warn you: only the status line says so.
  • Phone (Remote Control): nothing draws; replies show as plain text.
  • VS Code extension panel: replies draw, but /decorated shows its gallery as plain text.
  • Claude Desktop: the vertical lines of flow, tree and seq drawings show small gaps.
  • conhost (the old Windows console): bars, sparklines and meters show missing-glyph boxes for their fractional blocks. Windows Terminal is fine.
  • tmux: 256 colors unless CLAUDE_CODE_TMUX_TRUECOLOR=1 is set, so heat maps and SVG gradients band.
  • iTerm2 is untested.
  • SVG animation plays only in the terminal's fullscreen layout, and an endless loop plays only in the newest reply that has one. Everywhere else a drawing shows its final frame.
  • Another reply mod teaching the same tags makes decorated step aside for the whole session, not reply by reply.
  • open-links doesn't make the links in a decorated reply clickable.
  • Big drawings are cut short at the bounds under Security: an <svg> draws what it got to, a large <flow> becomes a list.
  • Headless output (claude -p) is never drawn, so the guide isn't sent there; see the condition above.

Contributing

Bug reports, terminal results and pull requests are all welcome. See CONTRIBUTING.md for how to develop and test the mod. Agents working in the repo read .claude/CLAUDE.md.

License

MIT

Source 9 files
hooks/register.tsx 297 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { DEMO } from './demo'
5import { type Clock, drawBlocks, kitFor } from './draw'
6import { LEVELS, type Level, guideFor } from './guide'
7import { BLOCK_TAGS, INLINE_TAGS, hasMarkup, parseBlocks } from './parse'
8import { animation } from './svg'
9
10// The reply's bullet takes two columns; one more keeps wide drawings off the edge.
11const GUTTER = 3
12
13// ── the guide ───────────────────────────────────────────────────────────
14// The tags are taught by a section of the system prompt, in every session a
15// surface draws. `/decorated off` stops the teaching, in this session and
16// the next; replies already in the transcript keep their drawing.
17// `/decorated low|medium|high` sets how much the guide asks Claude to
18// decorate, kept the same way.
19
20const SECTION = 'decorated:guide'
21// Renders whose replies no one sees drawn: a headless run, the one-line
22// prompt, a teammate's copy of its lead's prompt.
23const UNDRAWN = new Set(['print', 'bare', 'teammate'])
24const TEACH_KEY = 'teach'
25const LEVEL_KEY = 'level'
26
27type Store = { store: { get: (key: string) => Promise<unknown> } }
28
29// Whether the person left the teaching on.
30async function isTeaching($: Store): Promise<boolean> {
31  return (await $.store.get(TEACH_KEY)) !== false
32}
33
34const isLevel = (value: unknown): value is Level => LEVELS.includes(value as Level)
35
36// The level the person set; medium where they set none.
37async function levelOf($: Store): Promise<Level> {
38  const level = await $.store.get(LEVEL_KEY)
39
40  return isLevel(level) ? level : 'medium'
41}
42
43// The oldest build that draws decorated's trees: up to 2.1.296 the engine
44// refuses Text's `mono`, and a reply there would show its tags raw.
45const FLOOR = [2, 1, 299] as const
46const TOO_OLD = `decorated is off: it needs Claude Code ${FLOOR.join('.')} or later`
47
48// Whether the engine is older than FLOOR. One that cannot say is; a build
49// not spelled as a release is taken to be new enough.
50async function isTooOld($: { session: { version: () => Promise<{ base?: string }> } }): Promise<boolean> {
51  let base: string | undefined
52  try {
53    base = (await $.session.version()).base
54  } catch {
55    return true
56  }
57  const parts = /^(\d+)\.(\d+)\.(\d+)/.exec(base ?? '')?.slice(1).map(Number)
58  if (!parts) return false
59  for (const [i, floor] of FLOOR.entries()) if (parts[i] !== floor) return parts[i]! < floor
60
61  return false
62}
63
64const SWITCHED = {
65  on: 'Tags on: Claude is taught them again from the next request.',
66  off: 'Tags off: Claude is no longer taught them, from the next request. Replies already drawn keep their drawing.',
67}
68const LEVELLED: Record<Level, string> = {
69  low: 'Level low: Claude is taught to decorate lightly, from the next request.',
70  medium: 'Level medium: Claude is taught to decorate where a tag shows more than prose, from the next request.',
71  high: 'Level high: Claude is taught to draw a block whenever a reply has a shape, from the next request.',
72}
73const UNCACHED = ' The system prompt changes, so that request is not served from the prompt cache.'
74const STILL_OFF = ' Tags are off; `/decorated on` teaches them again.'
75
76// ── another reply mod ───────────────────────────────────────────────────
77// The tag names stay bare. Where another plugin's section of the prompt
78// teaches tags of the same names, decorated steps aside: it teaches nothing
79// and leaves the replies to that mod's drawing, so Claude learns one set of
80// tags and one mod draws them.
81
82const OUR_TAG = new RegExp(`<(${[...INLINE_TAGS, ...BLOCK_TAGS].join('|')})[\\s/>]`, 'g')
83// Two of our names taught as tags make a guide to the same tags, not a mention.
84const SHARED_ENOUGH = 2
85
86// The plugin whose section teaches our tags, if one does.
87function rivalIn(sections: readonly { id: string; text: string }[]): string | undefined {
88  for (const { id, text } of sections) {
89    const owner = /^([^:]+):/.exec(id)?.[1]
90    if (!owner || owner === 'decorated') continue
91    const shared = new Set([...text.matchAll(OUR_TAG)].map(m => m[1]))
92    if (shared.size >= SHARED_ENOUGH) return owner
93  }
94
95  return undefined
96}
97
98// The plugin decorated last stepped aside for; the reply hook reads it.
99let yieldedTo: string | undefined
100// What the status line last said.
101let shownStatus: string | undefined
102
103// ── animation ───────────────────────────────────────────────────────────
104// An animated <svg> plays where a reply is drawn again in place: the
105// terminal's fullscreen layout, while the viewport shows it. Each frame is a
106// write of `tick`, which redraws only the replies that read it. A playing
107// drawing marks itself at every frame it draws and the ticker writes only
108// while a mark is fresh, so a drawing scrolled away or cleared stops it by
109// itself. A finite animation plays to its end; endless loops play on, but
110// only the newest reply's: an older one rests on its still.
111
112const tick = atom({ plugin: 'decorated', key: 'tick' } as const, 0)
113const FRAME_MS = 100
114// A drawing that has drawn no frame for this long is no longer playing.
115const STALE_MS = 2000
116
117// Module state, so a reload replays what is on screen. A drawing (by its
118// markup) → when it was first drawn whole; a looping drawing → its group,
119// the start of the first loop drawn in its reply; a playing drawing → when
120// it last drew a frame.
121const begun = new Map<string, number>()
122const loopGroup = new Map<string, number>()
123let newestGroup = -Infinity
124const playing = new Map<string, number>()
125
126// The value `key` holds, set to `value` the first time it is asked.
127function remember(map: Map<string, number>, key: string, value: number): number {
128  const known = map.get(key)
129  if (known !== undefined) return known
130  map.set(key, value)
131  if (map.size > 256) map.delete(map.keys().next().value!)
132
133  return value
134}
135
136function canAnimate(e: { surface: string; viewport?: { isFullscreen?: boolean }; props: object }): boolean {
137  return e.surface === 'terminal' && e.viewport?.isFullscreen === true && (e.props as { onScreen?: unknown }).onScreen !== null
138}
139
140// A clock for one reply's drawing pass, and whether anything it timed plays.
141function player(now: number): { clock: Clock; isPlaying: () => boolean } {
142  let isPlaying = false
143  // The reply's loops share the group of the first one drawn.
144  let group: number | undefined
145  const clock: Clock = (body, attrs) => {
146    const info = animation(body)
147    if (!info) return undefined
148    const key = JSON.stringify([body, attrs])
149    const start = remember(begun, key, now)
150    if (info.endMs === Infinity) {
151      group ??= loopGroup.get(key) ?? start
152      newestGroup = Math.max(newestGroup, remember(loopGroup, key, group))
153      if (loopGroup.get(key) !== newestGroup) return undefined
154    } else if (now >= start + info.endMs) return undefined
155    playing.set(key, now)
156    isPlaying = true
157
158    return (now - start) / 1000
159  }
160
161  return { clock, isPlaying: () => isPlaying }
162}
163
164// A player at the time now; none where the clock cannot be read, so the
165// drawing rests on its still rather than the reply going undrawn.
166async function playerNow($: { clock: { now: () => Promise<number> } }): Promise<ReturnType<typeof player> | undefined> {
167  try {
168    return player(await $.clock.now())
169  } catch {
170    return undefined
171  }
172}
173
174const ANIMATES = /<(?:animate|set|animateTransform|animateMotion|animateColor)[\s/>]/
175
176export const register: Register = on => {
177  on('session.start', async ($, e, next) => {
178    await $.command.register({
179      name: 'decorated',
180      description: 'Show every markup decorated draws; on or off turns the teaching of its tags, low, medium or high sets how much',
181      argumentHint: '[on|off|low|medium|high]',
182    })
183    // Each frame redraws the replies that read `tick`; the write after a
184    // play ends lets its reply draw the still and stop reading.
185    $.clock.every(FRAME_MS, () => {
186      if (playing.size === 0) return
187      void (async () => {
188        const now = await $.clock.now()
189        for (const [key, drawn] of playing) if (now - drawn > STALE_MS) playing.delete(key)
190        if (playing.size > 0) await update($, tick, () => now)
191      })()
192    })
193
194    return next(e)
195  })
196
197  on('prompt.compose', async ($, e, next) => {
198    const composed = await next(e)
199    if (e.surfaces.length === 0 || e.traits.some(t => UNDRAWN.has(t))) return composed
200    const tooOld = await isTooOld($)
201    // Composed again through every other plugin, to see one above decorated too.
202    const rival = tooOld
203      ? undefined
204      : (rivalIn(composed.sections) ?? rivalIn((await $.prompt.compose(e).catch(() => composed)).sections))
205    yieldedTo = rival
206    const status = tooOld ? TOO_OLD : rival && `decorated steps aside: ${rival} teaches tags of the same names`
207    if (status !== shownStatus) {
208      shownStatus = status
209      $.ui.status(status)
210    }
211    if (tooOld || rival || !(await isTeaching($))) return composed
212
213    return { sections: [...composed.sections, { id: SECTION, text: guideFor(await levelOf($)), scope: 'session' as const }] }
214  }).catch(($, e, next) => next(e))
215
216  on('command.run', { command: 'decorated' }, async ($, e) => {
217    const arg = e.args.trim().toLowerCase()
218    if (arg === 'on' || arg === 'off') {
219      const was = await isTeaching($)
220      const teaching = arg === 'on'
221      await $.store.set(TEACH_KEY, teaching)
222
223      return {
224        text: SWITCHED[arg] + (was === teaching ? '' : UNCACHED),
225        context: [`The person turned decorated's tags ${arg}; nothing to act on.`],
226      }
227    }
228    if (isLevel(arg)) {
229      const was = await levelOf($)
230      await $.store.set(LEVEL_KEY, arg)
231      const teaching = await isTeaching($)
232
233      return {
234        text: LEVELLED[arg] + (teaching && was !== arg ? UNCACHED : '') + (teaching ? '' : STILL_OFF),
235        context: [`The person set decorated's level to ${arg}; nothing to act on.`],
236      }
237    }
238
239    return {
240      text: DEMO,
241      context: ['That was a rendering demo of the decorated mod; nothing to act on.'],
242    }
243  })
244
245  on('ui.render', { component: 'CommandOutput', props: { command: 'decorated' } }, async ($, e, next) => {
246    if (e.props.isErrored) return next(e)
247    const T = $.ui.resolve(e)
248    const { Box, Text } = T
249    const avail = (e.viewport?.columns ?? 80) - GUTTER
250    // The engine heads a plugin's output with its name on the first line,
251    // which would hide a block tag opening that line: draw the name apart.
252    const label = /^([\w-]+):[ \t]*/.exec(e.props.text)
253    const text = label ? e.props.text.slice(label[0].length) : e.props.text
254    const play = canAnimate(e) && ANIMATES.test(text) ? await playerNow($) : undefined
255    const drawn = drawBlocks(kitFor(T, e.surface), parseBlocks(text), avail, play?.clock)
256    if (play?.isPlaying()) await read($, tick)
257
258    return (
259      <Box flexDirection="column" gap={1}>
260        {label && <Text dimColor>{label[1]}</Text>}
261        {drawn}
262      </Box>
263    )
264  }).catch(($, e, next) => next(e))
265
266  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
267    if (yieldedTo || e.props.isSummary || !hasMarkup(e.props.text)) return next(e)
268    // The plugins beneath still see the reply, for what they do besides
269    // drawing it (one records where replies are); their drawing gives way to
270    // decorated's. A rewrite of the text beneath does not reach it.
271    await next(e).catch(() => undefined)
272    const T = $.ui.resolve(e)
273    const { Box, Text } = T
274    const avail = (e.viewport?.columns ?? 80) - GUTTER
275    const play = canAnimate(e) && ANIMATES.test(e.props.text) ? await playerNow($) : undefined
276    const body = (
277      <Box flexDirection="column" gap={1}>
278        {drawBlocks(kitFor(T, e.surface), parseBlocks(e.props.text), avail, play?.clock)}
279      </Box>
280    )
281    // Read after drawing, so only a reply with something playing subscribes.
282    if (play?.isPlaying()) await read($, tick)
283    if (e.surface !== 'terminal') return body
284
285    return (
286      <Box flexDirection="row">
287        <Box minWidth={2} flexShrink={0}>
288          <Text>{e.props.isFirstOfReply ? '●' : ' '}</Text>
289        </Box>
290        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
291          {body}
292        </Box>
293      </Box>
294    )
295  }).catch(($, e, next) => next(e))
296}
297
hooks/demo.ts 91 lines
1// What `/decorated` prints: one of everything, drawn by the same hook.
2
3export const DEMO = `<banner sub="markup in replies, drawn by a mod">decorated</banner>
4
5Inline: build is <ok>green</ok>, <err>2 flaky</err>, <warn>1 skipped</warn>, <info>see notes</info>, <dim>an aside</dim>, <hl>look here</hl>, <accent>accent</accent>, <kbd>Ctrl+O</kbd>, <badge tone="ok">PASS</badge> <badge tone="err">FAIL</badge>, <grad>a gradient run</grad>, coverage <meter value="0.82"/>, latency <spark>3 5 4 8 6 9 12 7 5 4</spark>.
6
7<callout tone="tip" title="Callouts">
8Hold any **markdown**, and other markup: <ok>like this</ok>.
9</callout>
10
11<flow title="flow: a layered graph">
12parse -> typecheck -> emit
13parse -> lint {warn}
14lint -> report
15emit -> report {ok}
16</flow>
17
18<seq title="seq: a sequence diagram">
19Browser -> API: POST /login
20API -> Auth: verify(token)
21Auth --> API: ok
22API -> API: mint session
23API --> Browser: 200 + cookie {ok}
24</seq>
25
26<bars title="bars: before → after" unit="kB" better="lower">
27main.js: 412 -> 318
28vendor.js: 1204 -> 1190
29styles.css: 88 -> 97
30</bars>
31
32<chart title="chart: braille lines" unit="ms" height="6" x="Mon,Tue,Wed,Thu,Fri">
33api: 120 130 125 180 160 140 135 128 122 119
34web: 80 85 82 90 140 110 95 90 85 82
35</chart>
36
37<heat title="heat: a heat map" cols="M,T,W,T,F,S,S">
38wk1: 3 5 2 8 1 0 0
39wk2: 4 6 7 9 3 1 0
40wk3: 2 2 5 4 12 0 2
41</heat>
42
43<cols>
44<col title="tree">
45<tree>
46src/
47  parse.ts  # tag scanner
48  widgets/
49    + flow.ts
50    ~ bars.ts
51  - old.ts
52</tree>
53</col>
54<col title="steps">
55<steps>
56[x] Bump version
57[~] Build — 3 of 5 targets
58[!] Sign binary — cert expired
59[ ] Publish
60</steps>
61</col>
62</cols>
63
64<timeline>
652026-09-01 | Kickoff
662026-10-01 | Beta {warn}
672026-10-22 | GA {ok}
68</timeline>
69
70<kv>
71Branch: main
72Commit: \`7e11040\`
73Status: <ok>clean</ok>
74</kv>
75
76<svg title="svg: half-block pixels, real-glyph labels, SMIL" viewBox="0 0 120 60" cols="48">
77<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#22305a"/><stop offset="1" stop-color="#e98b4a"/></linearGradient></defs>
78<rect width="120" height="60" rx="6" fill="url(#sky)"/>
79<circle cx="88" cy="26" r="14" fill="#ffd36b"><animate attributeName="cy" from="26" to="46" dur="4s" fill="freeze"/></circle>
80<path d="M0 60 L28 30 L46 46 L70 22 L104 60 Z" fill="#2f5d8a"/>
81<path d="M0 60 Q30 46 60 54 T120 52 V60 Z" fill="#48968c"/>
82<text x="10" y="14" fill="#f5f5f5" font-weight="bold">dusk</text>
83</svg>
84
85<svg title="svg mode=braille: line art" viewBox="0 0 200 60" mode="braille" cols="48">
86<path d="M4 50 C40 0 80 60 120 30 S180 0 196 40" fill="none" stroke="accent" stroke-width="2"/>
87<circle r="4" fill="none" stroke="ok" stroke-width="2"><animateMotion path="M4 50 C40 0 80 60 120 30 S180 0 196 40" dur="3s" repeatCount="indefinite"/></circle>
88</svg>
89
90<rule tone="accent">end of demo</rule>`
91
hooks/draw.tsx 209 lines
1// Turns parsed blocks into the surface's elements: markdown goes to the
2// engine's own Markdown, everything decorated is rows of Text.
3
4import type { Elements, RenderNode } from 'claude-code'
5
6import { type Attrs, type Block, parseBlocks, rich, richRows } from './parse'
7import { nativeSvg, svg } from './svg'
8import { type Row, type Run, tone } from './text'
9import { WIDGETS, bannerParts } from './widgets'
10
11// A surface that draws SVG itself (desktop, the editor, mobile) carries `Svg`.
12export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Markdown'> & {
13  Svg?: Elements['desktop']['Svg']
14  isTerminal?: true
15  // Theme keys the surface draws in no color, and what stands in for each.
16  recolor?: Readonly<Record<string, string>>
17}
18
19// Seconds into an animated drawing's play, or undefined for its still.
20export type Clock = (body: string, attrs: Attrs) => number | undefined
21
22// The kit a surface draws with: `Svg` only off the terminal, whose table may
23// answer a name it does not draw.
24export function kitFor(T: Kit, surface: string): Kit {
25  if (surface === 'terminal') return { Box: T.Box, Text: T.Text, Markdown: T.Markdown, isTerminal: true }
26
27  return surface === 'vscode' ? { ...T, recolor: VSCODE_RECOLOR } : T
28}
29
30// The VS Code panel draws `warning` in its menu's text color. Its `yellow`,
31// the terminal's ANSI yellow, follows the editor's theme as the key would.
32const VSCODE_RECOLOR = { warning: 'yellow' }
33
34const paint = (T: Kit, color: string | undefined) => (color && T.recolor?.[color]) ?? color
35
36// Off the terminal rows are set in a browser's monospace face, where an
37// arrowhead comes from a fallback font a fraction of a cell wide (so the
38// lines it meets step aside) and ░ draws as a solid block: one-cell ASCII
39// stands in for them.
40const OFF_TERMINAL: Record<string, string> = { '►': '>', '◄': '<', '▼': 'v', '░': '·' }
41const offTerminal = (run: Run): Run => ({ ...run, text: run.text.replace(/[►◄▼░]/g, ch => OFF_TERMINAL[ch]!) })
42
43// The longest source a surface's `Svg` takes; past it the cells are drawn.
44const SVG_SOURCE_MAX = 131072
45
46const CALLOUT: Record<string, { icon: string; color: string; label: string }> = {
47  note: { icon: '✎', color: 'suggestion', label: 'Note' },
48  info: { icon: '›', color: 'suggestion', label: 'Info' },
49  tip: { icon: '★', color: 'autoAccept', label: 'Tip' },
50  ok: { icon: '✓', color: 'success', label: 'Done' },
51  warn: { icon: '▲', color: 'warning', label: 'Warning' },
52  err: { icon: '✘', color: 'error', label: 'Error' },
53}
54const CALLOUT_ALIAS: Record<string, string> = { success: 'ok', warning: 'warn', error: 'err', danger: 'err', caution: 'warn', important: 'warn' }
55
56function textOf(T: Kit, run: Run): RenderNode {
57  const { Text } = T
58
59  return (
60    <Text
61      color={paint(T, run.fg)}
62      backgroundColor={paint(T, run.bg)}
63      bold={run.bold}
64      italic={run.italic}
65      underline={run.underline}
66      strikethrough={run.strike}
67      dimColor={run.dim}
68      inverse={run.inverse}
69    >
70      {run.text}
71    </Text>
72  )
73}
74
75function rowOf(T: Kit, row: Row): RenderNode {
76  const { Box, Text } = T
77  const runs = row.runs.length > 0 ? row.runs.map(r => textOf(T, r)) : ' '
78  if (!row.lead) {
79    return (
80      <Text mono wrap={row.wrap ? 'wrap' : 'truncate-end'}>
81        {runs}
82      </Text>
83    )
84  }
85
86  return (
87    <Box flexDirection="row">
88      <Box flexShrink={0}>
89        <Text mono>{row.lead.map(r => textOf(T, r))}</Text>
90      </Box>
91      <Box flexGrow={1} flexShrink={1}>
92        <Text mono wrap="wrap">
93          {runs}
94        </Text>
95      </Box>
96    </Box>
97  )
98}
99
100function rowsOf(T: Kit, rows: readonly Row[]): RenderNode {
101  const { Box } = T
102  const set = T.isTerminal ? rows : rows.map(r => ({ ...r, lead: r.lead?.map(offTerminal), runs: r.runs.map(offTerminal) }))
103
104  return <Box flexDirection="column">{set.map(r => rowOf(T, r))}</Box>
105}
106
107// Callouts and columns drawn inside one another at most this deep; deeper,
108// a body shows as its markdown. Each level is a few boxes for the surface
109// to lay out, so a reply nesting thousands would ask for a tree thousands
110// of boxes deep.
111const MAX_NESTING = 8
112
113export function drawBlocks(T: Kit, blocks: readonly Block[], avail: number, clock?: Clock, depth = 0): RenderNode[] {
114  return blocks.map(b => drawBlock(T, b, avail, clock, depth))
115}
116
117function drawBlock(T: Kit, b: Block, avail: number, clock?: Clock, depth = 0): RenderNode {
118  const { Box, Markdown, Text } = T
119  if (b.kind === 'md') return <Markdown text={b.text} />
120  if (b.kind === 'rich') return rowsOf(T, richRows(b.text, avail))
121  if ((b.name === 'callout' || b.name === 'col' || b.name === 'cols') && depth >= MAX_NESTING) return <Markdown text={b.body} />
122
123  if (b.name === 'callout' || b.name === 'col') {
124    const kind = CALLOUT_ALIAS[b.attrs.tone ?? ''] ?? b.attrs.tone ?? (b.name === 'col' ? '' : 'note')
125    const preset = CALLOUT[kind]
126    const color = preset?.color ?? tone(b.attrs.tone) ?? 'inactive'
127    const heading = b.attrs.title ?? (b.name === 'callout' ? preset?.label : undefined)
128
129    return (
130      <Box flexDirection="column" borderStyle="round" borderColor={paint(T, color)} paddingX={1} flexGrow={b.name === 'col' ? 1 : 0}>
131        {heading !== undefined && (
132          <Text bold color={paint(T, color)}>
133            {preset && b.name === 'callout' ? `${preset.icon} ` : ''}
134            {rich(heading, { bold: true, fg: color }).map(r => textOf(T, r))}
135          </Text>
136        )}
137        <Box flexDirection="column" gap={1}>
138          {drawBlocks(T, parseBlocks(b.body), avail - 4, clock, depth + 1)}
139        </Box>
140      </Box>
141    )
142  }
143
144  if (b.name === 'cols') {
145    const inner = parseBlocks(b.body)
146    const cols = inner.filter(x => x.kind === 'tag' && x.name === 'col')
147    const n = Math.max(1, cols.length)
148    const isSideBySide = avail >= n * 28
149    const each = isSideBySide ? Math.floor((avail - (n - 1)) / n) : avail
150
151    return (
152      <Box flexDirection={isSideBySide ? 'row' : 'column'} gap={1}>
153        {(cols.length > 0 ? cols : inner).map(c => (
154          <Box flexDirection="column" flexGrow={1} flexShrink={1} width={isSideBySide ? each : undefined}>
155            {drawBlock(T, c, each, clock, depth + 1)}
156          </Box>
157        ))}
158      </Box>
159    )
160  }
161
162  if (b.name === 'svg') return drawSvg(T, b, avail, clock)
163  // Off the terminal a row is taller than its box-drawing glyphs, so a drawn
164  // border breaks into dashes: the surface's own border is whole.
165  if (b.name === 'banner' && !T.isTerminal && b.isClosed) return drawBanner(T, b)
166
167  const widget = WIDGETS[b.name]
168  if (!widget) return <Markdown text={b.body} />
169  const rows = widget(b.body, b.attrs, avail)
170  if (!b.isClosed && rows.length === 0) return <Text dimColor>drawing {b.name}…</Text>
171
172  return rowsOf(T, rows)
173}
174
175function drawBanner(T: Kit, b: Extract<Block, { kind: 'tag' }>): RenderNode {
176  const { Box, Text } = T
177  const { letters, sub, border } = bannerParts(b.body, b.attrs)
178
179  return (
180    <Box flexDirection="column" alignSelf="flex-start" alignItems="center" borderStyle="round" borderColor={paint(T, border.fg)} paddingX={3}>
181      <Text mono>{letters.map(r => textOf(T, r))}</Text>
182      {sub !== undefined && (
183        <Text mono dimColor italic>
184          {sub}
185        </Text>
186      )}
187    </Box>
188  )
189}
190
191// Handed whole to a surface that draws SVG, where SMIL plays by itself;
192// in cells elsewhere, at the moment `clock` gives.
193function drawSvg(T: Kit, b: Extract<Block, { kind: 'tag' }>, avail: number, clock?: Clock): RenderNode {
194  const { Box, Text, Svg } = T
195  const native = Svg && b.isClosed ? nativeSvg(b.body, b.attrs) : undefined
196  if (Svg && native && native.source.length <= SVG_SOURCE_MAX) {
197    return (
198      <Box flexDirection="column">
199        {b.attrs.title !== undefined && <Text bold>{rich(b.attrs.title, { bold: true }).map(r => textOf(T, r))}</Text>}
200        <Svg source={native.source} alt={native.alt} width={native.width} isInteractive={native.isAnimated} />
201      </Box>
202    )
203  }
204  const rows = Svg && !b.isClosed ? [] : svg(b.body, b.attrs, avail, b.isClosed ? clock?.(b.body, b.attrs) : undefined)
205  if (!b.isClosed && rows.length === 0) return <Text dimColor>drawing svg…</Text>
206
207  return rowsOf(T, rows)
208}
209
hooks/guide.ts 44 lines
1// The tag guide, a section of the system prompt while decorated draws.
2
3export const LEVELS = ['low', 'medium', 'high'] as const
4export type Level = (typeof LEVELS)[number]
5
6// How much to decorate: the one part of the guide a level changes, so every
7// level teaches the same tags.
8const HOW_MUCH: Record<Level, string> = {
9  low: 'Keep decoration light: color a word or mark a status with a badge. Draw a block only when it is plainly the clearest form, such as a before → after `<bars>` or a `<tree>` someone asked for. Most replies need no tags.',
10  medium: 'Use one only where it shows more than prose or a table would: a status, numbers to compare, a pipeline, a call sequence, a tree. Most replies need none, or one or two.',
11  high: 'Whenever a reply has a shape, draw it as a block: steps for multi-step work, `<kv>` for a summary, a callout for the result, bars or a chart for numbers, a flow or tree for structure. A one-line answer still needs none.',
12}
13
14export const guideFor = (level: Level) => `# Decorated replies
15
16This session draws the tags below in your replies; Markdown works around them. ${HOW_MUCH[level]} Data in a tag must be real: never chart numbers you did not measure or read.
17
18A block tag opens at the start of a line and closes on its own line. Tags in code spans or fences show as written. A trailing \`{tone}\` colors a line inside a block. Tones: ok, warn, err, info, tip, accent, dim, or a hex color.
19
20Inline: \`<ok>\` \`<warn>\` \`<err>\` \`<info>\` \`<accent>\` \`<dim>\` color text, \`<hl>\` marks the one thing to notice, \`<kbd>Ctrl+O</kbd>\`, \`<badge tone="ok">PASS</badge>\`, \`<spark>3 5 2 8</spark>\`, \`<meter value="0.82"/>\`.
21
22Blocks take \`title="…"\`; their content is one item per line:
23- \`<callout tone="note|tip|ok|warn|err">\` any markdown, for the one point not to miss
24- \`<steps>\` \`[x]\` done, \`[~]\` doing, \`[ ]\` to do, \`[!]\` failed, \`[-]\` skipped; \` — detail\`
25- \`<tree>\` 2-space indent, \`dir/\`, \`+ \` \`- \` \`~ \` added/removed/changed, \` # note\`
26- \`<flow>\` \`a -> b -> c\`, \`a -> b, c\`, \`node {ok}\`
27- \`<seq>\` \`A -> B: call\`, \`B --> A: reply\`, \`== phase ==\`
28- \`<bars unit="ms" better="lower">\` \`label: 412\` or \`label: 412 -> 318\`
29- \`<chart unit="…" x="Mon,Tue">\` a line of numbers, or \`name: 1 2 3\` per series
30- \`<heat cols="M,T,W">\` \`row: 1 2 3\`
31- \`<timeline>\` \`when | what\`; \`<kv>\` \`key: value\`
32- \`<cols>\` two or three \`<col title="…">\` panels
33- \`<svg viewBox="0 0 120 60">\` only for a spatial shape (a layout, geometry); opening tag on one line, explicit fills, short \`<text>\`
34- \`<rule>Title</rule>\` a divider
35
36\`\`\`
37Tests are <ok>green</ok>; one <warn>flaky</warn> case remains.
38
39<bars title="Suite time" unit="s" better="lower">
40unit: 41 -> 12
41integration: 188 -> 140
42</bars>
43\`\`\``
44
hooks/parse.ts 572 lines
1// Splits a reply into plain markdown and decorated parts, and turns inline
2// markup into styled runs. Only the tag names below are markup: anything
3// else in angle brackets (`Array<string>`, `<div>`) stays text, and nothing
4// inside a code span or a fence is ever read as a tag.
5
6import { bar, clip, hexOf, mix, nums, pad, runsWidth, spark, tone, type Row, type Run, type Style, width } from './text'
7
8export const BLOCK_TAGS = new Set([
9  'callout',
10  'cols',
11  'col',
12  'bars',
13  'chart',
14  'heat',
15  'flow',
16  'seq',
17  'tree',
18  'steps',
19  'timeline',
20  'kv',
21  'rule',
22  'banner',
23  'svg',
24])
25
26export const INLINE_TAGS = new Set([
27  'ok',
28  'warn',
29  'err',
30  'info',
31  'dim',
32  'accent',
33  'hl',
34  'c',
35  'kbd',
36  'badge',
37  'grad',
38  'spark',
39  'meter',
40])
41
42export type Attrs = Record<string, string>
43
44export type Block =
45  | { kind: 'md'; text: string }
46  | { kind: 'rich'; text: string }
47  | { kind: 'tag'; name: string; attrs: Attrs; body: string; isClosed: boolean }
48
49const ATTR = /([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>/]+)))?/g
50
51export function parseAttrs(s: string): Attrs {
52  const attrs: Attrs = {}
53  for (const m of s.matchAll(ATTR)) attrs[(m[1] ?? '').toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? ''
54
55  return attrs
56}
57
58const FENCE = /^\s*(`{3,}|~{3,})/
59
60// Code spans and fences blanked to spaces, so tag searches skip them, and
61// the offsets of the lines that start outside a fence.
62function masking(text: string): { mask: string; clean: Set<number> } {
63  let inFence: string | undefined
64  const clean = new Set<number>()
65  let at = 0
66  const lines = text.split('\n').map(line => {
67    if (!inFence) clean.add(at)
68    at += line.length + 1
69    const fence = FENCE.exec(line)?.[1]
70    if (inFence) {
71      if (fence && fence[0] === inFence[0] && fence.length >= inFence.length) inFence = undefined
72
73      return ' '.repeat(line.length)
74    }
75    if (fence) {
76      inFence = fence
77
78      return ' '.repeat(line.length)
79    }
80
81    return line.replace(/(`+)[^`]*?\1/g, m => ' '.repeat(m.length))
82  })
83
84  return { mask: lines.join('\n'), clean }
85}
86
87function masked(text: string): string {
88  return masking(text).mask
89}
90
91const INLINE_RE = new RegExp(`</?(?:${[...INLINE_TAGS].join('|')})(?=[\\s>/])`)
92
93export function hasInline(text: string): boolean {
94  return INLINE_RE.test(masked(text))
95}
96
97export function hasMarkup(text: string): boolean {
98  const m = masked(text)
99
100  return INLINE_RE.test(m) || new RegExp(`(^|\\n)\\s*<(?:${[...BLOCK_TAGS].join('|')})(?=[\\s>/])`).test(m)
101}
102
103// A tag's insides up to its `>`: a quoted value may hold `>` or `/>` (a title
104// with inline markup), and an unterminated quote falls back to a plain char.
105// Each piece is taken whole (a lookahead and its backreference), so a match
106// that fails never retries a quoted value as plain chars: hostile quotes
107// cost linear time, not exponential.
108const INSIDE = String.raw`(?:(?=(?<piece>"[^"]*"|'[^']*'|[^>]))\k<piece>)`
109// Groups: name, attributes, INSIDE's piece, self-close, the text after `>`.
110const OPEN_LINE = new RegExp(String.raw`^\s*<([a-z]+)((?:\s+${INSIDE}*?)?)\s*(\/?)>(.*)$`)
111// The most pieces a tag inside a body may hold before its `>`, so a body
112// full of unfinished tags is searched in linear time.
113const TAG_PIECES = 1024
114
115// Where `</name>` closes the tag opened before `from`, counting nested ones
116// of the same name, in text whose code spans and fences are masked.
117function findClose(m: string, name: string, from: number): { at: number; end: number } | undefined {
118  const re = new RegExp(`<(/?)${name}(?=[\\s>/])(${INSIDE}{0,${TAG_PIECES}}?)>`, 'g')
119  re.lastIndex = from
120  let depth = 1
121  for (let hit = re.exec(m); hit; hit = re.exec(m)) {
122    if (hit[1]) depth -= 1
123    else if (!(hit[2] ?? '').trimEnd().endsWith('/')) depth += 1
124    if (depth === 0) return { at: hit.index, end: hit.index + hit[0].length }
125  }
126
127  return undefined
128}
129
130export function parseBlocks(text: string): Block[] {
131  const blocks: Block[] = []
132  let md: string[] = []
133  const flush = () => {
134    if (md.length > 0) blocks.push(...splitRich(md.join('\n')))
135    md = []
136  }
137
138  // The reply is masked once, and each block's close searched in it from the
139  // block's start, so a reply of many blocks parses in one pass. A body that
140  // starts where that mask would differ from the body's own is masked apart.
141  const { mask, clean } = masking(text)
142  let at = 0
143  let inFence: string | undefined
144  while (at < text.length) {
145    const nl = text.indexOf('\n', at)
146    const end = nl === -1 ? text.length : nl
147    const next = nl === -1 ? text.length : nl + 1
148    const line = text.slice(at, end)
149    const fence = FENCE.exec(line)?.[1]
150    if (inFence) {
151      if (fence && fence[0] === inFence[0] && fence.length >= inFence.length) inFence = undefined
152      md.push(line)
153      at = next
154      continue
155    }
156    if (fence) {
157      inFence = fence
158      md.push(line)
159      at = next
160      continue
161    }
162
163    const open = OPEN_LINE.exec(line)
164    if (!open || !BLOCK_TAGS.has(open[1] ?? '')) {
165      md.push(line)
166      at = next
167      continue
168    }
169
170    flush()
171    const [, name = '', rawAttrs = '', , selfClose, tail = ''] = open
172    const attrs = parseAttrs(rawAttrs)
173    // The body starts right after `>`: on the same line when the tag holds
174    // text there, else on the next line.
175    const from = tail.trim() ? end - tail.length : next
176    if (selfClose) {
177      blocks.push({ kind: 'tag', name, attrs, body: '', isClosed: true })
178      at = from
179      continue
180    }
181
182    const isMasked = from === next ? clean.has(next) : clean.has(at) && !/[`~]/.test(line)
183    const close = isMasked ? findClose(mask, name, from) : shift(findClose(masked(text.slice(from)), name, 0), from)
184    if (!close) {
185      // Unclosed: a reply still streaming, or a forgotten close. Draw what is there.
186      blocks.push({ kind: 'tag', name, attrs, body: trimBody(text.slice(from)), isClosed: false })
187      break
188    }
189    blocks.push({ kind: 'tag', name, attrs, body: trimBody(text.slice(from, close.at)), isClosed: true })
190    const left = text.slice(close.end)
191    at = left.startsWith('\n') ? close.end + 1 : left.trim() ? close.end : close.end + /^[^\n]*\n?/.exec(left)![0].length
192  }
193  flush()
194
195  return blocks
196}
197
198function shift(span: { at: number; end: number } | undefined, by: number): { at: number; end: number } | undefined {
199  return span && { at: span.at + by, end: span.end + by }
200}
201
202function trimBody(s: string): string {
203  return s.replace(/^\s*\n/, '').replace(/\n\s*$/, '')
204}
205
206// Plain markdown stays one block for the engine's own renderer; only the
207// paragraphs holding inline markup are drawn by hand.
208function splitRich(text: string): Block[] {
209  if (!hasInline(text)) return text.trim() ? [{ kind: 'md', text }] : []
210
211  const groups: string[][] = [[]]
212  let inFence: string | undefined
213  for (const line of text.split('\n')) {
214    const fence = FENCE.exec(line)?.[1]
215    if (inFence) {
216      if (fence && fence[0] === inFence[0] && fence.length >= inFence.length) inFence = undefined
217    } else if (fence) {
218      inFence = fence
219    } else if (!line.trim()) {
220      groups.push([])
221      continue
222    }
223    groups[groups.length - 1]?.push(line)
224  }
225
226  const out: Block[] = []
227  for (const group of groups.filter(g => g.length > 0)) {
228    const chunk = group.join('\n')
229    const kind = hasInline(chunk) ? 'rich' : 'md'
230    const last = out[out.length - 1]
231    if (kind === 'md' && last?.kind === 'md') last.text += '\n\n' + chunk
232    else out.push({ kind, text: chunk })
233  }
234
235  return out
236}
237
238// ── inline ──────────────────────────────────────────────────────────────
239
240export type Inline =
241  | { t: 'text'; s: string }
242  | { t: 'code'; s: string }
243  | { t: 'tag'; name: string; attrs: Attrs; kids: Inline[] }
244
245const TAG_AT = /^<(\/?)([a-z]+)((?:\s+[\w-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>/]+))?)*)\s*(\/?)>/
246
247export function parseInline(s: string): Inline[] {
248  const root: Inline[] = []
249  const stack: Array<{ name: string; kids: Inline[] }> = [{ name: '', kids: root }]
250  let text = ''
251  const top = () => stack[stack.length - 1]?.kids ?? root
252  const flush = () => {
253    if (text) top().push({ t: 'text', s: text })
254    text = ''
255  }
256
257  for (let i = 0; i < s.length; ) {
258    const ch = s[i]
259    if (ch === '`') {
260      const run = /^`+/.exec(s.slice(i))![0]
261      const end = s.indexOf(run, i + run.length)
262      if (end !== -1) {
263        flush()
264        top().push({ t: 'code', s: s.slice(i + run.length, end).replace(/^ (.*) $/, '$1') })
265        i = end + run.length
266        continue
267      }
268    }
269    if (ch === '\\' && s[i + 1] === '<') {
270      text += '<'
271      i += 2
272      continue
273    }
274    if (ch === '<') {
275      const m = TAG_AT.exec(s.slice(i))
276      if (m && INLINE_TAGS.has(m[2] ?? '')) {
277        const [whole, closing, name = '', rawAttrs = '', selfClose] = m
278        flush()
279        if (closing) {
280          const at = stack.map(f => f.name).lastIndexOf(name)
281          if (at > 0) stack.length = at // closes it and anything left open inside
282        } else {
283          const kids: Inline[] = []
284          top().push({ t: 'tag', name, attrs: parseAttrs(rawAttrs), kids })
285          if (!selfClose) stack.push({ name, kids })
286        }
287        i += whole.length
288        continue
289      }
290    }
291    text += ch
292    i += 1
293  }
294  flush()
295
296  return root
297}
298
299function plain(nodes: readonly Inline[]): string {
300  return nodes.map(n => (n.t === 'tag' ? plain(n.kids) : n.s)).join('')
301}
302
303// A span holds at most 500 characters and a link's target 2048: unbounded,
304// each opener with no closer scans to the end of the line, and a long line
305// of them costs quadratic time.
306const EMPHASIS =
307  /(\*\*|__)(?=\S)(.{1,500}?)(?<=\S)\1|(?<![\w*])\*(?=\S)(.{1,500}?)(?<=\S)\*(?!\*)|(?<![\w_])_(?=\S)(.{1,500}?)(?<=\S)_(?![\w_])|~~(.{1,500}?)~~|\[([^\]]{1,500})\]\(([^)\s]{1,2048})\)/dg
308
309const BOLD = 1
310const ITALIC = 2
311const STRIKE = 4
312const LINK = 8
313const FLAG_OF: Record<number, number> = { 2: BOLD, 3: ITALIC, 4: ITALIC, 5: STRIKE, 6: LINK }
314
315// A styled piece; `isRaw` ones (code, keys, badges, gauges) hold no markdown.
316type Piece = Run & { isRaw?: boolean }
317
318// Markdown's inline emphasis and links over a whole line of pieces, so
319// `**bold <ok>tag</ok>**` spans the tag: the delimiters are found in the
320// joined text, cut out, and their styles laid over what they enclose.
321function emphasis(pieces: readonly Piece[]): Run[] {
322  const scan = pieces.map(p => (p.isRaw ? ''.repeat(p.text.length) : p.text)).join('')
323  const cut = new Uint8Array(scan.length)
324  const flags = new Uint8Array(scan.length)
325  const walk = (from: number, to: number) => {
326    for (const m of scan.slice(from, to).matchAll(EMPHASIS)) {
327      const group = [2, 3, 4, 5, 6].find(k => m[k] !== undefined)!
328      const [s, e] = m.indices![group]!
329      cut.fill(1, from + m.index, from + s)
330      cut.fill(1, from + e, from + m.index + m[0].length)
331      for (let i = from + s; i < from + e; i++) flags[i]! |= FLAG_OF[group]!
332      walk(from + s, from + e)
333    }
334  }
335  walk(0, scan.length)
336
337  const runs: Run[] = []
338  let at = 0
339  for (const { isRaw: _, ...p } of pieces) {
340    const end = at + p.text.length
341    for (let k = at; k < end; ) {
342      let j = k + 1
343      while (j < end && cut[j] === cut[k] && flags[j] === flags[k]) j++
344      if (!cut[k]) runs.push({ ...p, ...flagStyle(flags[k]!, p), text: p.text.slice(k - at, j - at) })
345      k = j
346    }
347    at = end
348  }
349
350  return runs
351}
352
353function flagStyle(f: number, p: Style): Style {
354  return {
355    ...(f & BOLD ? { bold: true } : {}),
356    ...(f & ITALIC ? { italic: true } : {}),
357    ...(f & STRIKE ? { strike: true } : {}),
358    ...(f & LINK ? { underline: true, fg: p.fg ?? 'suggestion' } : {}),
359  }
360}
361
362const TAG_TONE: Record<string, string> = {
363  ok: 'success',
364  warn: 'warning',
365  err: 'error',
366  info: 'suggestion',
367  accent: 'claude',
368}
369
370export function inlineRuns(nodes: readonly Inline[], base: Style = {}): Run[] {
371  return emphasis(pieces(nodes, base))
372}
373
374function pieces(nodes: readonly Inline[], base: Style): Piece[] {
375  const out: Piece[] = []
376  for (const node of nodes) {
377    if (node.t === 'text') out.push({ ...base, text: node.s })
378    else if (node.t === 'code') out.push({ ...base, fg: 'permission', text: node.s, isRaw: true })
379    else out.push(...tagRuns(node, base))
380  }
381
382  return out
383}
384
385function tagRuns(node: Extract<Inline, { t: 'tag' }>, base: Style): Piece[] {
386  const { name, attrs, kids } = node
387  const tag = tone(attrs.tone)
388  const raw = (runs: Run[]): Piece[] => runs.map(r => ({ ...r, isRaw: true }))
389  switch (name) {
390    case 'ok':
391    case 'warn':
392    case 'err':
393    case 'info':
394    case 'accent':
395      return pieces(kids, { ...base, fg: TAG_TONE[name], bold: base.bold || 'bold' in attrs })
396    case 'dim':
397      return pieces(kids, { ...base, fg: 'inactive' })
398    case 'hl':
399      return pieces(kids, { ...base, fg: tag ?? 'warning', inverse: true })
400    case 'c':
401      return pieces(kids, {
402        ...base,
403        fg: tone(attrs.fg ?? attrs.color ?? attrs.tone) ?? base.fg,
404        bg: tone(attrs.bg) ?? base.bg,
405        bold: base.bold || 'bold' in attrs,
406        italic: base.italic || 'italic' in attrs,
407        underline: base.underline || 'underline' in attrs,
408      })
409    case 'kbd':
410      return raw([{ ...base, inverse: true, bold: true, text: ` ${plain(kids).trim()} ` }])
411    case 'badge':
412      return raw([{ ...base, fg: tag ?? 'claude', inverse: true, bold: true, text: ` ${plain(kids).trim()} ` }])
413    case 'grad':
414      return raw(gradient(plain(kids), attrs, base))
415    case 'spark': {
416      const values = nums(attrs.values ?? plain(kids))
417
418      return raw([{ ...base, fg: tag ?? base.fg ?? 'claude', text: spark(values) }])
419    }
420    case 'meter': {
421      const value = Number(attrs.value ?? (plain(kids) || 0))
422      const max = Number(attrs.max ?? (value > 1 ? 100 : 1))
423      const cells = Math.max(3, Math.min(40, Number(attrs.width ?? 10)))
424      const frac = max > 0 ? value / max : 0
425      const fill = bar(frac, cells)
426      const color = tag ?? (frac >= 0.8 ? 'success' : frac >= 0.5 ? 'warning' : 'error')
427      const label = attrs.label ?? `${Math.round(frac * 100)}%`
428
429      return raw([
430        { ...base, fg: color, text: fill },
431        { ...base, fg: 'inactive', text: '·'.repeat(Math.max(0, cells - width(fill))) },
432        ...(label === 'none' ? [] : [{ ...base, text: ' ' + label }]),
433      ])
434    }
435    default:
436      return pieces(kids, base)
437  }
438}
439
440function gradient(s: string, attrs: Attrs, base: Style): Run[] {
441  const from = hexOf(attrs.from, '#d77757')
442  const to = hexOf(attrs.to, '#af87ff')
443  const chars = [...s]
444
445  return chars.map((ch, i) => ({
446    ...base,
447    bold: base.bold || 'bold' in attrs,
448    fg: mix(from, to, chars.length > 1 ? i / (chars.length - 1) : 0),
449    text: ch,
450  }))
451}
452
453// Inline markup and markdown emphasis in one line of text.
454export function rich(s: string, base: Style = {}): Run[] {
455  return inlineRuns(parseInline(s), base)
456}
457
458// A paragraph holding inline markup, drawn by hand: list items, headings,
459// quotes and tables keep their shape; soft line breaks join as markdown joins them.
460export function richRows(text: string, avail = 80): Row[] {
461  const rows: Row[] = []
462  let open: { lead?: Run[]; text: string; base: Style } | undefined
463  const close = () => {
464    if (open) rows.push({ lead: open.lead, runs: rich(open.text, open.base), wrap: true })
465    open = undefined
466  }
467  const all = text.split('\n')
468  for (let i = 0; i < all.length; i++) {
469    const line = all[i]!
470    if (line.includes('|') && isTableRule(all[i + 1])) {
471      close()
472      const rule = all[++i]!
473      const body: string[] = []
474      while (i + 1 < all.length && all[i + 1]!.includes('|')) body.push(all[++i]!)
475      rows.push(...table(line, rule, body, avail))
476      continue
477    }
478    const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line) as [string, string, string, string] | null
479    const heading = /^\s*(#{1,6})\s+(.*)$/.exec(line) as [string, string, string] | null
480    const quote = /^\s*>\s?(.*)$/.exec(line) as [string, string] | null
481    if (item) {
482      close()
483      const bullet = /\d/.test(item[2]) ? item[2] : '•'
484      open = { lead: [{ text: item[1] + bullet + ' ' }], text: item[3], base: {} }
485    } else if (heading) {
486      close()
487      open = { text: heading[2], base: { bold: true, underline: heading[1].length === 1 } }
488      close()
489    } else if (quote) {
490      close()
491      open = { lead: [{ fg: 'inactive', text: '▎ ' }], text: quote[1], base: { italic: true } }
492    } else if (open) open.text += ' ' + line.trim()
493    else open = { text: line.trim(), base: {} }
494  }
495  close()
496
497  return rows
498}
499
500// ── tables ──────────────────────────────────────────────────────────────
501
502function isTableRule(line: string | undefined): boolean {
503  return line !== undefined && /^[\s|:-]+$/.test(line) && line.includes('-') && line.includes('|')
504}
505
506// A table row's cells: `|` splits outside code spans, `\|` stays a pipe.
507function cells(line: string): string[] {
508  const out: string[] = []
509  let cur = ''
510  let tick = ''
511  for (let i = 0; i < line.length; i++) {
512    const ch = line[i]!
513    if (ch === '`') {
514      const run = /^`+/.exec(line.slice(i))![0]
515      if (!tick) tick = run
516      else if (run === tick) tick = ''
517      cur += run
518      i += run.length - 1
519    } else if (ch === '\\' && line[i + 1] === '|') {
520      cur += '\\|'
521      i += 1
522    } else if (ch === '|' && !tick) {
523      out.push(cur)
524      cur = ''
525    } else cur += ch
526  }
527  out.push(cur)
528  const trimmed = out.map(c => c.trim().replace(/\\\|/g, '|'))
529  if (line.trim().startsWith('|') && trimmed[0] === '') trimmed.shift()
530  if (line.trim().endsWith('|') && trimmed[trimmed.length - 1] === '') trimmed.pop()
531
532  return trimmed
533}
534
535// A markdown table whose cells hold inline markup, drawn boxed; too wide for
536// the room, each row becomes a block of `header  value` lines.
537function table(head: string, rule: string, body: string[], avail: number): Row[] {
538  const header = cells(head)
539  const n = header.length
540  const aligns = cells(rule).map(c => (c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left'))
541  const grid = [header, ...body.map(cells)].map(r => Array.from({ length: n }, (_, k) => r[k] ?? ''))
542  const runs = grid.map((r, ri) => r.map(c => rich(c, ri === 0 ? { bold: true } : {})))
543  const widths = header.map((_, k) => Math.max(3, ...runs.map(r => runsWidth(r[k]!))))
544  const dim: Style = { fg: 'inactive' }
545
546  if (widths.reduce((s, w) => s + w, 0) + 3 * n + 1 > avail) {
547    const keyW = Math.min(24, Math.max(...header.map(h => width(h))))
548    const out: Row[] = []
549    runs.slice(1).forEach((r, ri) => {
550      if (ri > 0) out.push({ runs: [] })
551      r.forEach((c, k) => out.push({ lead: [{ ...dim, text: pad(clip(plain(parseInline(header[k]!)), keyW), keyW) + '  ' }], runs: c, wrap: true }))
552    })
553
554    return out
555  }
556
557  const line = (l: string, m: string, r: string): Row => ({ runs: [{ ...dim, text: l + widths.map(w => '─'.repeat(w + 2)).join(m) + r }] })
558  const row = (r: Run[][]): Row => {
559    const out: Run[] = [{ ...dim, text: '│' }]
560    r.forEach((c, k) => {
561      const gap = widths[k]! - runsWidth(c)
562      const a = aligns[k] ?? 'left'
563      const left = a === 'right' ? gap : a === 'center' ? Math.floor(gap / 2) : 0
564      out.push({ text: ' '.repeat(left + 1) }, ...c, { text: ' '.repeat(gap - left + 1) }, { ...dim, text: '│' })
565    })
566
567    return { runs: out }
568  }
569
570  return [line('┌', '┬', '┐'), row(runs[0]!), line('├', '┼', '┤'), ...runs.slice(1).map(row), line('└', '┴', '┘')]
571}
572
hooks/svg.ts 1403 lines
1// Inline SVG rasterised into terminal cells. Half blocks (the default) give
2// each cell two full-color pixels, near square on a 1:2 cell; braille gives
3// 2×4 dots in one color, finer for line art. `<text>` is set in real glyphs
4// on the nearest cell, so labels stay crisp however small the drawing is.
5//
6// Shapes: rect (rounded), circle, ellipse, line, polyline, polygon, path
7// (every command, arcs included), g, use, text; solid, linear and radial
8// paint; transform, opacity, fill-rule, stroke-linecap. No filters, masks,
9// clipping, patterns or dashes.
10
11import type { Attrs } from './parse'
12import { HEX_OF, rgbToHex, type Row, type Run, tone, width } from './text'
13
14type El = { name: string; attrs: Attrs; kids: El[]; text: string }
15type Pt = [number, number]
16type Poly = { pts: Pt[]; isClosed: boolean }
17type M = [number, number, number, number, number, number]
18type Rgba = [number, number, number, number]
19
20// ── a forgiving XML reader, enough for the SVG a reply holds ────────────
21
22const TOKEN =
23  /<!--[\s\S]*?(?:-->|$)|<[?!][^>]*>|<(\/?)([\w:-]+)((?:\s+[\w:-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>/]+))?)*)\s*(\/?)>|([^<]+|<)/g
24const XATTR = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>/]+)))?/g
25const ENTITY: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
26
27function decode(s: string): string {
28  return s.replace(/&(#x[\da-f]+|#\d+|\w+);/gi, (m, e: string) => {
29    if (e[0] === '#') return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1)))
30
31    return ENTITY[e] ?? m
32  })
33}
34
35// Case kept, unlike block attributes: SVG spells `viewBox`, `fill-rule`.
36function xmlAttrs(s: string): Attrs {
37  const out: Attrs = {}
38  for (const m of s.matchAll(XATTR)) out[m[1]!] = decode(m[2] ?? m[3] ?? m[4] ?? '')
39
40  return out
41}
42
43// Past these the markup is left unread, as if it had not streamed in yet:
44// elements nested deeper than the walk draws, and more of them than any
45// drawing in a reply needs.
46const XML_MAX_DEPTH = 48
47const XML_MAX_ELEMENTS = 4000
48
49export function parseXml(src: string): El[] {
50  const root: El = { name: '', attrs: {}, kids: [], text: '' }
51  const stack = [root]
52  let count = 0
53  // Elements opened past XML_MAX_DEPTH, unread along with what they hold.
54  let unread = 0
55  for (const m of src.matchAll(TOKEN)) {
56    const top = stack[stack.length - 1]!
57    if (m[5] !== undefined) {
58      if (!unread) top.text += decode(m[5])
59      continue
60    }
61    if (!m[2]) continue
62    if (m[1]) {
63      if (unread) {
64        unread -= 1
65        continue
66      }
67      const at = stack.map(e => e.name).lastIndexOf(m[2])
68      if (at > 0) stack.length = at
69      continue
70    }
71    if (unread || stack.length > XML_MAX_DEPTH) {
72      if (!m[4]) unread += 1
73      continue
74    }
75    if (++count > XML_MAX_ELEMENTS) break
76    const el: El = { name: m[2], attrs: xmlAttrs(m[3] ?? ''), kids: [], text: '' }
77    top.kids.push(el)
78    if (!m[4]) stack.push(el)
79  }
80
81  return root.kids
82}
83
84// ── numbers, lengths, transforms ────────────────────────────────────────
85
86const NUM = /[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g
87
88function numList(s: string | undefined): number[] {
89  return (s?.match(NUM) ?? []).map(Number)
90}
91
92const ID: M = [1, 0, 0, 1, 0, 0]
93
94// `m` after `n`: a point goes through `n` first.
95function mul(m: M, n: M): M {
96  return [
97    m[0] * n[0] + m[2] * n[1],
98    m[1] * n[0] + m[3] * n[1],
99    m[0] * n[2] + m[2] * n[3],
100    m[1] * n[2] + m[3] * n[3],
101    m[0] * n[4] + m[2] * n[5] + m[4],
102    m[1] * n[4] + m[3] * n[5] + m[5],
103  ]
104}
105
106function apply(m: M, [x, y]: Pt): Pt {
107  return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
108}
109
110function parseTransform(s: string | undefined): M {
111  let m = ID
112  for (const t of s?.matchAll(/(\w+)\s*\(([^)]*)\)/g) ?? []) {
113    const v = numList(t[2])
114    const [a = 0, b, c] = v
115    const rad = (a * Math.PI) / 180
116    let n: M
117    switch (t[1]) {
118      case 'matrix':
119        n = v.length >= 6 ? (v.slice(0, 6) as M) : ID
120        break
121      case 'translate':
122        n = [1, 0, 0, 1, a, b ?? 0]
123        break
124      case 'scale':
125        n = [a, 0, 0, b ?? a, 0, 0]
126        break
127      case 'rotate':
128        n = [Math.cos(rad), Math.sin(rad), -Math.sin(rad), Math.cos(rad), 0, 0]
129        if (b !== undefined) n = mul(mul([1, 0, 0, 1, b, c ?? 0], n), [1, 0, 0, 1, -b, -(c ?? 0)])
130        break
131      case 'skewX':
132        n = [1, 0, Math.tan(rad), 1, 0, 0]
133        break
134      case 'skewY':
135        n = [1, Math.tan(rad), 0, 1, 0, 0]
136        break
137      default:
138        continue
139    }
140    m = mul(m, n)
141  }
142
143  return m
144}
145
146// ── geometry: every shape becomes polylines in user space ───────────────
147
148type Segs = (len: number) => number
149
150function ellipsePts(cx: number, cy: number, rx: number, ry: number, segs: Segs, from = 0, to = Math.PI * 2): Pt[] {
151  const n = segs(Math.abs(to - from) * Math.max(rx, ry))
152  const out: Pt[] = []
153  for (let k = 0; k <= n; k++) {
154    const t = from + ((to - from) * k) / n
155    out.push([cx + rx * Math.cos(t), cy + ry * Math.sin(t)])
156  }
157
158  return out
159}
160
161function arcPts(x1: number, y1: number, rx: number, ry: number, phi: number, isLarge: boolean, isSweep: boolean, x2: number, y2: number, segs: Segs): Pt[] {
162  if (x1 === x2 && y1 === y2) return []
163  if (rx === 0 || ry === 0) return [[x2, y2]]
164  rx = Math.abs(rx)
165  ry = Math.abs(ry)
166  const r = (phi * Math.PI) / 180
167  const co = Math.cos(r)
168  const si = Math.sin(r)
169  const dx = (x1 - x2) / 2
170  const dy = (y1 - y2) / 2
171  const xp = co * dx + si * dy
172  const yp = -si * dx + co * dy
173  const lam = (xp * xp) / (rx * rx) + (yp * yp) / (ry * ry)
174  if (lam > 1) {
175    rx *= Math.sqrt(lam)
176    ry *= Math.sqrt(lam)
177  }
178  const num = rx * rx * ry * ry - rx * rx * yp * yp - ry * ry * xp * xp
179  const den = rx * rx * yp * yp + ry * ry * xp * xp
180  const k = (isLarge === isSweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den))
181  const cxp = (k * rx * yp) / ry
182  const cyp = (-k * ry * xp) / rx
183  const cx = co * cxp - si * cyp + (x1 + x2) / 2
184  const cy = si * cxp + co * cyp + (y1 + y2) / 2
185  const angle = (ux: number, uy: number, vx: number, vy: number) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
186  const t1 = angle(1, 0, (xp - cxp) / rx, (yp - cyp) / ry)
187  let dt = angle((xp - cxp) / rx, (yp - cyp) / ry, (-xp - cxp) / rx, (-yp - cyp) / ry)
188  if (!isSweep && dt > 0) dt -= Math.PI * 2
189  if (isSweep && dt < 0) dt += Math.PI * 2
190  const n = segs(Math.abs(dt) * Math.max(rx, ry))
191  const out: Pt[] = []
192  for (let j = 1; j <= n; j++) {
193    const t = t1 + (dt * j) / n
194    out.push([cx + co * rx * Math.cos(t) - si * ry * Math.sin(t), cy + si * rx * Math.cos(t) + co * ry * Math.sin(t)])
195  }
196
197  return out
198}
199
200// Stops at the first thing it cannot read, so a path still streaming draws
201// as far as it has come.
202export function pathPolys(d: string, segs: Segs): Poly[] {
203  const polys: Poly[] = []
204  let cur: Pt[] = []
205  let x = 0
206  let y = 0
207  let sx = 0
208  let sy = 0
209  let ctrl: Pt = [0, 0]
210  let prev = ''
211  let cmd = ''
212  let i = 0
213  const skip = () => {
214    while (i < d.length && /[\s,]/.test(d[i]!)) i++
215  }
216  const n = (): number | undefined => {
217    skip()
218    const re = /[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/y
219    re.lastIndex = i
220    const m = re.exec(d)
221    if (!m) return undefined
222    i += m[0].length
223
224    return Number(m[0])
225  }
226  // Arc flags may run together: `a1 1 0 011 1`.
227  const flag = (): boolean | undefined => {
228    skip()
229    const c = d[i]
230    if (c !== '0' && c !== '1') return undefined
231    i += 1
232
233    return c === '1'
234  }
235  const nums = (k: number): number[] | undefined => {
236    const out: number[] = []
237    for (let j = 0; j < k; j++) {
238      const v = n()
239      if (v === undefined) return undefined
240      out.push(v)
241    }
242
243    return out
244  }
245  const flush = (isClosed: boolean) => {
246    if (cur.length > 1) polys.push({ pts: cur, isClosed })
247    cur = []
248  }
249  const to = (...pts: Pt[]) => {
250    if (cur.length === 0) cur.push([x, y])
251    cur.push(...pts)
252  }
253  const cubic = (p0: Pt, p1: Pt, p2: Pt, p3: Pt) => {
254    const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) + Math.hypot(p2[0] - p1[0], p2[1] - p1[1]) + Math.hypot(p3[0] - p2[0], p3[1] - p2[1])
255    const k = segs(len)
256    for (let j = 1; j <= k; j++) {
257      const t = j / k
258      const u = 1 - t
259      to([
260        u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
261        u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
262      ])
263    }
264  }
265  const quad = (p0: Pt, p1: Pt, p2: Pt) => cubic(p0, [p0[0] + (2 / 3) * (p1[0] - p0[0]), p0[1] + (2 / 3) * (p1[1] - p0[1])], [p2[0] + (2 / 3) * (p1[0] - p2[0]), p2[1] + (2 / 3) * (p1[1] - p2[1])], p2)
266
267  for (;;) {
268    skip()
269    if (i >= d.length) break
270    if (/[a-zA-Z]/.test(d[i]!)) cmd = d[i++]!
271    else if (!cmd) break
272    const isRel = cmd >= 'a'
273    const C = cmd.toUpperCase()
274    const ox = isRel ? x : 0
275    const oy = isRel ? y : 0
276    if (C === 'Z') {
277      if (cur.length > 0) flush(true)
278      x = sx
279      y = sy
280      prev = 'Z'
281      cmd = ''
282      continue
283    }
284    const at = i
285    let ok = true
286    if (C === 'M') {
287      const v = nums(2)
288      if (v) {
289        flush(false)
290        x = ox + v[0]!
291        y = oy + v[1]!
292        sx = x
293        sy = y
294        cur = [[x, y]]
295        cmd = isRel ? 'l' : 'L'
296      } else ok = false
297    } else if (C === 'L') {
298      const v = nums(2)
299      if (v) {
300        to([ox + v[0]!, oy + v[1]!])
301        ;[x, y] = [ox + v[0]!, oy + v[1]!]
302      } else ok = false
303    } else if (C === 'H' || C === 'V') {
304      const v = n()
305      if (v !== undefined) {
306        if (C === 'H') x = ox + v
307        else y = oy + v
308        to([x, y])
309      } else ok = false
310    } else if (C === 'C' || C === 'S') {
311      const v = nums(C === 'C' ? 6 : 4)
312      if (v) {
313        const p1: Pt = C === 'C' ? [ox + v[0]!, oy + v[1]!] : prev === 'C' || prev === 'S' ? [2 * x - ctrl[0], 2 * y - ctrl[1]] : [x, y]
314        const rest = C === 'C' ? v.slice(2) : v
315        const p2: Pt = [ox + rest[0]!, oy + rest[1]!]
316        const p3: Pt = [ox + rest[2]!, oy + rest[3]!]
317        cubic([x, y], p1, p2, p3)
318        ctrl = p2
319        ;[x, y] = p3
320      } else ok = false
321    } else if (C === 'Q' || C === 'T') {
322      const v = nums(C === 'Q' ? 4 : 2)
323      if (v) {
324        const p1: Pt = C === 'Q' ? [ox + v[0]!, oy + v[1]!] : prev === 'Q' || prev === 'T' ? [2 * x - ctrl[0], 2 * y - ctrl[1]] : [x, y]
325        const rest = C === 'Q' ? v.slice(2) : v
326        const p2: Pt = [ox + rest[0]!, oy + rest[1]!]
327        quad([x, y], p1, p2)
328        ctrl = p1
329        ;[x, y] = p2
330      } else ok = false
331    } else if (C === 'A') {
332      const v = nums(3)
333      const large = v && flag()
334      const sweep = large !== undefined ? flag() : undefined
335      const end = sweep !== undefined ? nums(2) : undefined
336      if (v && large !== undefined && sweep !== undefined && end) {
337        const ex = ox + end[0]!
338        const ey = oy + end[1]!
339        const pts = arcPts(x, y, v[0]!, v[1]!, v[2]!, large, sweep, ex, ey, segs)
340        if (pts.length) to(...pts)
341        ;[x, y] = [ex, ey]
342      } else ok = false
343    } else ok = false
344    if (!ok) {
345      i = at
346      break
347    }
348    prev = C
349  }
350  flush(false)
351
352  return polys
353}
354
355// ── paint ───────────────────────────────────────────────────────────────
356
357const CSS: Record<string, string> = {
358  black: '000000', white: 'ffffff', red: 'ff0000', green: '008000', blue: '0000ff', yellow: 'ffff00',
359  cyan: '00ffff', aqua: '00ffff', magenta: 'ff00ff', fuchsia: 'ff00ff', gray: '808080', grey: '808080',
360  orange: 'ffa500', purple: '800080', pink: 'ffc0cb', brown: 'a52a2a', navy: '000080', teal: '008080',
361  lime: '00ff00', maroon: '800000', olive: '808000', silver: 'c0c0c0', gold: 'ffd700', skyblue: '87ceeb',
362  steelblue: '4682b4', royalblue: '4169e1', dodgerblue: '1e90ff', tomato: 'ff6347', coral: 'ff7f50',
363  salmon: 'fa8072', crimson: 'dc143c', firebrick: 'b22222', indigo: '4b0082', violet: 'ee82ee',
364  orchid: 'da70d6', plum: 'dda0dd', tan: 'd2b48c', khaki: 'f0e68c', wheat: 'f5deb3', beige: 'f5f5dc',
365  ivory: 'fffff0', chocolate: 'd2691e', sienna: 'a0522d', saddlebrown: '8b4513', turquoise: '40e0d0',
366  seagreen: '2e8b57', forestgreen: '228b22', darkgreen: '006400', darkblue: '00008b', darkred: '8b0000',
367  lightgray: 'd3d3d3', lightgrey: 'd3d3d3', darkgray: 'a9a9a9', darkgrey: 'a9a9a9', dimgray: '696969',
368  slategray: '708090', lightblue: 'add8e6', lightgreen: '90ee90', hotpink: 'ff69b4', limegreen: '32cd32',
369}
370
371// [r, g, b, a] in 0..255 and 0..1; undefined for none.
372export function parseColor(s: string | undefined, current: string): Rgba | undefined {
373  const v = s?.trim().toLowerCase()
374  if (!v || v === 'none' || v === 'transparent') return undefined
375  if (v === 'currentcolor') return parseColor(current, '#000')
376  const hex = /^#([\da-f]{3,8})$/.exec(v)?.[1] ?? CSS[v]
377  if (hex && [3, 4, 6, 8].includes(hex.length)) {
378    const h = hex.length <= 4 ? [...hex].map(c => c + c).join('') : hex
379    const n = (k: number) => parseInt(h.slice(k, k + 2), 16)
380
381    return [n(0), n(2), n(4), h.length === 8 ? n(6) / 255 : 1]
382  }
383  const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(v)
384  if (fn) {
385    const parts = fn[2]!.split(/[\s,/]+/).filter(Boolean)
386    const val = (p: string | undefined, scale: number) => (p === undefined ? 1 : p.endsWith('%') ? (parseFloat(p) / 100) * scale : parseFloat(p))
387    const a = parts[3] === undefined ? 1 : val(parts[3], 1)
388    if (fn[1]!.startsWith('rgb')) return [val(parts[0], 255), val(parts[1], 255), val(parts[2], 255), a]
389    const [hh = 0, ss = 0, ll = 0] = [parseFloat(parts[0] ?? '0'), val(parts[1], 1), val(parts[2], 1)]
390    const f = (k: number) => {
391      const t = (k + hh / 30) % 12
392
393      return 255 * (ll - ss * Math.min(ll, 1 - ll) * Math.max(-1, Math.min(t - 3, 9 - t, 1)))
394    }
395
396    return [f(0), f(8), f(4), a]
397  }
398  // The style's own tone names: `fill="ok"`, `stroke="accent"`.
399  const themed = HEX_OF[tone(v) ?? '']
400
401  return themed ? parseColor(themed, current) : undefined
402}
403
404type Paint = Rgba | ((x: number, y: number) => Rgba)
405
406type Stop = { at: number; rgba: Rgba }
407
408function stopsOf(el: El, ids: Map<string, El>, current: string, seen = new Set<El>()): Stop[] {
409  const own = el.kids
410    .filter(k => k.name === 'stop')
411    .map(k => {
412      const p = props(k.attrs)
413      const at = p.offset?.endsWith('%') ? parseFloat(p.offset) / 100 : Number(p.offset ?? 0)
414      const c = parseColor(p['stop-color'] ?? 'black', current) ?? [0, 0, 0, 0]
415      const op = Number(p['stop-opacity'] ?? 1)
416
417      return { at: Math.max(0, Math.min(1, at || 0)), rgba: [c[0], c[1], c[2], c[3] * op] as Rgba }
418    })
419  if (own.length > 0) return own
420  const ref = ids.get(hrefOf(el.attrs) ?? '')
421  if (!ref || seen.has(ref)) return []
422  seen.add(el)
423
424  return stopsOf(ref, ids, current, seen)
425}
426
427function at(stops: Stop[], t: number): Rgba {
428  t = Math.max(0, Math.min(1, t))
429  let lo = stops[0]!
430  for (const s of stops) {
431    if (s.at >= t) {
432      const span = s.at - lo.at
433      const f = span > 0 ? (t - lo.at) / span : 1
434
435      return lo.rgba.map((v, k) => v + ((s.rgba[k] ?? v) - v) * f) as Rgba
436    }
437    lo = s
438  }
439
440  return lo.rgba
441}
442
443function gradient(el: El, ids: Map<string, El>, current: string, box: [number, number, number, number], m: M, alpha: number): Paint | undefined {
444  const stops = stopsOf(el, ids, current).sort((a, b) => a.at - b.at)
445  if (stops.length === 0) return undefined
446  if (stops.length === 1) return withAlpha(stops[0]!.rgba, alpha)
447  const a = el.attrs
448  const isUser = a.gradientUnits === 'userSpaceOnUse'
449  const [bx, by, bw, bh] = box
450  // A coordinate in the gradient's units, to user space.
451  const frac = (v: string | undefined, dflt: number) => (v === undefined ? dflt : v.endsWith('%') ? parseFloat(v) / 100 : Number(v))
452  const ux = (v: string | undefined, dflt: number) => (isUser ? (v?.endsWith('%') ? bx + frac(v, dflt) * bw : Number(v ?? dflt * bw + bx)) : bx + frac(v, dflt) * bw)
453  const uy = (v: string | undefined, dflt: number) => (isUser ? (v?.endsWith('%') ? by + frac(v, dflt) * bh : Number(v ?? dflt * bh + by)) : by + frac(v, dflt) * bh)
454  const gm = mul(m, parseTransform(a.gradientTransform))
455  if (el.name === 'linearGradient') {
456    const p1 = apply(gm, [ux(a.x1, 0), uy(a.y1, 0)])
457    const p2 = apply(gm, [ux(a.x2, 1), uy(a.y2, 0)])
458    const dx = p2[0] - p1[0]
459    const dy = p2[1] - p1[1]
460    const len2 = dx * dx + dy * dy || 1
461
462    return (x, y) => withAlpha(at(stops, ((x - p1[0]) * dx + (y - p1[1]) * dy) / len2), alpha)
463  }
464  const c = apply(gm, [ux(a.cx, 0.5), uy(a.cy, 0.5)])
465  const r = frac(a.r, 0.5)
466  const rx = (isUser ? r : r * bw) * Math.hypot(gm[0], gm[1]) || 1
467  const ry = (isUser ? r : r * bh) * Math.hypot(gm[2], gm[3]) || 1
468
469  return (x, y) => withAlpha(at(stops, Math.hypot((x - c[0]) / rx, (y - c[1]) / ry)), alpha)
470}
471
472function withAlpha(c: Rgba, alpha: number): Rgba {
473  return [c[0], c[1], c[2], c[3] * alpha]
474}
475
476function hrefOf(a: Attrs): string | undefined {
477  return (a.href ?? a['xlink:href'])?.replace(/^#/, '')
478}
479
480// Presentation attributes with the `style` declarations laid over them.
481function props(a: Attrs): Attrs {
482  if (!a.style) return a
483  const out = { ...a }
484  for (const decl of a.style.split(';')) {
485    const [k, ...v] = decl.split(':')
486    if (k?.trim() && v.length) out[k.trim()] = v.join(':').trim()
487  }
488
489  return out
490}
491
492// ── the raster: premultiplied RGBA samples, S×S per pixel ───────────────
493
494// A crossing's cost in a fill (finding it, sorting it, painting from it),
495// in units of reading an edge.
496const CROSSING_WORK = 16
497
498class Raster {
499  readonly buf: Float32Array
500
501  constructor(
502    readonly w: number,
503    readonly h: number,
504  ) {
505    this.buf = new Float32Array(w * h * 4)
506  }
507
508  // Scanline fill: each sample row's crossings, inside by the fill rule.
509  // Answers the work it took, in edges read, crossings sorted and samples
510  // painted, or Infinity where that passed `budget`: the fill stops at the
511  // row where it does.
512  fill(polys: readonly Pt[][], rule: string, paint: Paint, budget = Infinity): number {
513    type Edge = { x0: number; y0: number; x1: number; y1: number; dir: number }
514    const edges: Edge[] = []
515    let top = Infinity
516    let bottom = -Infinity
517    for (const pts of polys) {
518      for (let k = 0; k < pts.length; k++) {
519        const a = pts[k]!
520        const b = pts[(k + 1) % pts.length]!
521        if (a[1] === b[1]) continue
522        edges.push(a[1] < b[1] ? { x0: a[0], y0: a[1], x1: b[0], y1: b[1], dir: 1 } : { x0: b[0], y0: b[1], x1: a[0], y1: a[1], dir: -1 })
523        top = Math.min(top, a[1], b[1])
524        bottom = Math.max(bottom, a[1], b[1])
525      }
526    }
527    // A point that is not a number leaves the shape unpainted.
528    if (edges.length === 0 || Number.isNaN(top) || Number.isNaN(bottom)) return 0
529    const isEvenOdd = rule === 'evenodd'
530    const solid = typeof paint === 'function' ? undefined : paint
531    const from = Math.max(0, Math.floor(top))
532    const to = Math.min(this.h - 1, Math.ceil(bottom))
533    // Edges by their top, each joining the rows' active list when the rows
534    // reach it and leaving it past its bottom: a row reads only the edges
535    // that cross it.
536    edges.sort((a, b) => a.y0 - b.y0)
537    let work = edges.length
538    let next = 0
539    const active: Edge[] = []
540    const xs: Array<[number, number]> = []
541    for (let row = from; row <= to; row++) {
542      const y = row + 0.5
543      while (next < edges.length && edges[next]!.y0 <= y) active.push(edges[next++]!)
544      let kept = 0
545      for (const e of active) if (y < e.y1) active[kept++] = e
546      active.length = kept
547      xs.length = 0
548      for (const e of active) xs.push([e.x0 + ((y - e.y0) * (e.x1 - e.x0)) / (e.y1 - e.y0), e.dir])
549      work += kept * CROSSING_WORK
550      if (work > budget) return Infinity
551      if (xs.length < 2) continue
552      xs.sort((a, b) => a[0] - b[0])
553      let wind = 0
554      for (let k = 0; k < xs.length - 1; k++) {
555        wind += xs[k]![1]
556        const inside = isEvenOdd ? wind % 2 !== 0 : wind !== 0
557        if (!inside) continue
558        const c0 = Math.max(0, Math.ceil(xs[k]![0] - 0.5))
559        const c1 = Math.min(this.w, Math.ceil(xs[k + 1]![0] - 0.5))
560        for (let col = c0; col < c1; col++) this.over(col, row, solid ?? (paint as (x: number, y: number) => Rgba)(col + 0.5, y))
561        work += Math.max(0, c1 - c0)
562      }
563    }
564
565    return work
566  }
567
568  over(x: number, y: number, [r, g, b, a]: Rgba) {
569    if (a <= 0) return
570    const i = (y * this.w + x) * 4
571    const keep = 1 - a
572    const buf = this.buf
573    buf[i] = r * a + buf[i]! * keep
574    buf[i + 1] = g * a + buf[i + 1]! * keep
575    buf[i + 2] = b * a + buf[i + 2]! * keep
576    buf[i + 3] = a + buf[i + 3]! * keep
577  }
578}
579
580function orient(pts: Pt[]): Pt[] {
581  let area = 0
582  for (let k = 0; k < pts.length; k++) {
583    const a = pts[k]!
584    const b = pts[(k + 1) % pts.length]!
585    area += a[0] * b[1] - b[0] * a[1]
586  }
587
588  return area < 0 ? [...pts].reverse() : pts
589}
590
591// A stroke as filled pieces, every one wound the same way so the nonzero
592// rule unions them: a quad per segment, a disc per join and round cap.
593function strokePolys(pts: readonly Pt[], isClosed: boolean, w: number, cap: string): Pt[][] {
594  const h = w / 2
595  const out: Pt[][] = []
596  const line = isClosed ? [...pts, pts[0]!] : pts
597  const disc = (c: Pt) => orient(ellipsePts(c[0], c[1], h, h, () => Math.max(6, Math.min(16, Math.ceil(h * 2)))))
598  for (let k = 0; k + 1 < line.length; k++) {
599    let [ax, ay] = line[k]!
600    let [bx, by] = line[k + 1]!
601    const len = Math.hypot(bx - ax, by - ay)
602    if (len === 0) continue
603    const ux = (bx - ax) / len
604    const uy = (by - ay) / len
605    if (cap === 'square' && !isClosed) {
606      if (k === 0) [ax, ay] = [ax - ux * h, ay - uy * h]
607      if (k === line.length - 2) [bx, by] = [bx + ux * h, by + uy * h]
608    }
609    const nx = -uy * h
610    const ny = ux * h
611    out.push(orient([[ax + nx, ay + ny], [bx + nx, by + ny], [bx - nx, by - ny], [ax - nx, ay - ny]]))
612  }
613  if (h >= 1) {
614    const inner = isClosed ? line.slice(0, -1) : line.slice(1, -1)
615    for (const p of inner) out.push(disc(p))
616    if (cap === 'round' && !isClosed && line.length > 1) out.push(disc(line[0]!), disc(line[line.length - 1]!))
617  }
618
619  return out
620}
621
622// ── the walk: elements to fills, strokes and text ───────────────────────
623
624type Ctx = {
625  m: M
626  fill: string
627  stroke: string
628  sw: string
629  opacity: number
630  fillOpacity: number
631  strokeOpacity: number
632  rule: string
633  cap: string
634  fontSize: number
635  anchor: string
636  isBold: boolean
637  color: string
638}
639
640type Label = { x: number; y: number; text: string; anchor: string; rgba?: Rgba; isBold: boolean }
641
642// SMIL's elements: the frame drawn carries what they say (see below).
643const ANIMS = new Set(['animate', 'set', 'animateTransform', 'animateMotion', 'animateColor'])
644const SKIP = new Set(['defs', 'linearGradient', 'radialGradient', 'title', 'desc', 'metadata', 'style', 'clipPath', 'mask', 'pattern', 'symbol', 'marker', 'filter', 'script', 'mpath', ...ANIMS])
645
646// What one drawing may spend: elements walked (a `use` walks its target
647// again, so a few lines can ask for billions), and raster work, in edges
648// read per sample row, crossings, samples painted and stroke points built.
649// A unit measured 11 to 19 ns, so the most is about 150 to 230 ms (400
650// stroked circles take 8.7 million); past it the drawing stops where it
651// is, as if the rest had not streamed in yet.
652const SVG_MAX_VISITS = 20_000
653const SVG_MAX_WORK = 12_000_000
654// A stroke point's cost, in edges read: building its quad and disc takes
655// about as long as reading this many edges.
656const STROKE_POINT_WORK = 100
657
658type Scene = {
659  raster: Raster
660  ids: Map<string, El>
661  vb: [number, number, number, number]
662  S: number
663  labels: Label[]
664  drawn: number
665  visits: number
666  work: number
667}
668
669function len(v: string | undefined, ref: number, fontSize: number, dflt = 0): number {
670  if (v === undefined || v === '') return dflt
671  const n = parseFloat(v)
672  if (!Number.isFinite(n)) return dflt
673  if (v.endsWith('%')) return (n / 100) * ref
674  if (/em$/.test(v)) return n * fontSize
675
676  return n
677}
678
679function inherit(ctx: Ctx, attrs: Attrs): Ctx {
680  const p = props(attrs)
681  const fontSize = len(p['font-size'], ctx.fontSize, ctx.fontSize, ctx.fontSize)
682
683  return {
684    m: p.transform ? mul(ctx.m, parseTransform(p.transform)) : ctx.m,
685    fill: p.fill ?? ctx.fill,
686    stroke: p.stroke ?? ctx.stroke,
687    sw: p['stroke-width'] ?? ctx.sw,
688    opacity: ctx.opacity * Number(p.opacity ?? 1),
689    fillOpacity: p['fill-opacity'] !== undefined ? Number(p['fill-opacity']) : ctx.fillOpacity,
690    strokeOpacity: p['stroke-opacity'] !== undefined ? Number(p['stroke-opacity']) : ctx.strokeOpacity,
691    rule: p['fill-rule'] ?? ctx.rule,
692    cap: p['stroke-linecap'] ?? ctx.cap,
693    fontSize: Number.isFinite(fontSize) ? fontSize : ctx.fontSize,
694    anchor: p['text-anchor'] ?? ctx.anchor,
695    isBold: p['font-weight'] !== undefined ? /bold|[6-9]00/.test(p['font-weight']) : ctx.isBold,
696    color: p.color ?? ctx.color,
697  }
698}
699
700function shapePolys(el: El, ctx: Ctx, scene: Scene, segs: Segs): Poly[] {
701  const a = el.attrs
702  const [, , vw, vh] = scene.vb
703  const diag = Math.hypot(vw, vh) / Math.SQRT2
704  const L = (k: string, ref: number) => len(a[k], ref, ctx.fontSize)
705  switch (el.name) {
706    case 'rect': {
707      const x = L('x', vw)
708      const y = L('y', vh)
709      const w = L('width', vw)
710      const h = L('height', vh)
711      if (w <= 0 || h <= 0) return []
712      let rx = a.rx !== undefined ? L('rx', vw) : a.ry !== undefined ? L('ry', vh) : 0
713      let ry = a.ry !== undefined ? L('ry', vh) : rx
714      rx = Math.min(rx, w / 2)
715      ry = Math.min(ry, h / 2)
716      if (rx <= 0 || ry <= 0) return [{ pts: [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], isClosed: true }]
717      const q = Math.PI / 2
718
719      return [
720        {
721          pts: [
722            ...ellipsePts(x + w - rx, y + ry, rx, ry, segs, -q, 0),
723            ...ellipsePts(x + w - rx, y + h - ry, rx, ry, segs, 0, q),
724            ...ellipsePts(x + rx, y + h - ry, rx, ry, segs, q, 2 * q),
725            ...ellipsePts(x + rx, y + ry, rx, ry, segs, 2 * q, 3 * q),
726          ],
727          isClosed: true,
728        },
729      ]
730    }
731    case 'circle':
732    case 'ellipse': {
733      const rx = el.name === 'circle' ? L('r', diag) : L('rx', vw)
734      const ry = el.name === 'circle' ? rx : L('ry', vh)
735      if (rx <= 0 || ry <= 0) return []
736      const pts = ellipsePts(L('cx', vw), L('cy', vh), rx, ry, segs)
737
738      return [{ pts: pts.slice(0, -1), isClosed: true }]
739    }
740    case 'line':
741      return [{ pts: [[L('x1', vw), L('y1', vh)], [L('x2', vw), L('y2', vh)]], isClosed: false }]
742    case 'polyline':
743    case 'polygon': {
744      const v = numList(a.points)
745      const pts: Pt[] = []
746      for (let k = 0; k + 1 < v.length; k += 2) pts.push([v[k]!, v[k + 1]!])
747
748      return pts.length > 1 ? [{ pts, isClosed: el.name === 'polygon' }] : []
749    }
750    case 'path':
751      return pathPolys(a.d ?? '', segs)
752    default:
753      return []
754  }
755}
756
757function paintOf(spec: string, ctx: Ctx, alpha: number, scene: Scene, polys: Poly[]): Paint | undefined {
758  const url = /^url\(\s*['"]?#([^'")]+)['"]?\s*\)/.exec(spec.trim())
759  if (url) {
760    const el = scene.ids.get(url[1]!)
761    if (!el || (el.name !== 'linearGradient' && el.name !== 'radialGradient')) return undefined
762    const all = polys.flatMap(p => p.pts)
763    const xs = all.map(p => p[0])
764    const ys = all.map(p => p[1])
765    const box: [number, number, number, number] = [Math.min(...xs), Math.min(...ys), Math.max(...xs) - Math.min(...xs) || 1, Math.max(...ys) - Math.min(...ys) || 1]
766
767    return gradient(el, scene.ids, ctx.color, box, ctx.m, alpha)
768  }
769  const c = parseColor(spec, ctx.color)
770
771  return c && withAlpha(c, alpha)
772}
773
774function textOf(el: El): string {
775  return (el.text + el.kids.map(textOf).join('')).replace(/\s+/g, ' ')
776}
777
778function walk(els: readonly El[], ctx: Ctx, scene: Scene, depth = 0) {
779  if (depth > 32) return
780  for (const el of els) {
781    if (scene.visits >= SVG_MAX_VISITS || scene.work >= SVG_MAX_WORK) return
782    scene.visits += 1
783    if (SKIP.has(el.name)) continue
784    const p = props(el.attrs)
785    if (p.display === 'none' || p.visibility === 'hidden') continue
786    const c = inherit(ctx, el.attrs)
787    if (el.name === 'g' || el.name === 'svg' || el.name === 'a' || el.name === 'switch') {
788      walk(el.kids, c, scene, depth + 1)
789      continue
790    }
791    if (el.name === 'use') {
792      const ref = scene.ids.get(hrefOf(el.attrs) ?? '')
793      if (!ref) continue
794      const at: M = [1, 0, 0, 1, len(el.attrs.x, scene.vb[2], c.fontSize), len(el.attrs.y, scene.vb[3], c.fontSize)]
795      walk(ref.name === 'symbol' ? ref.kids : [ref], { ...c, m: mul(c.m, at) }, scene, depth + 1)
796      continue
797    }
798    if (el.name === 'text') {
799      const text = textOf(el).trim()
800      const tspan = el.kids.find(k => k.name === 'tspan')
801      const x = len(el.attrs.x ?? tspan?.attrs.x, scene.vb[2], c.fontSize)
802      const y = len(el.attrs.y ?? tspan?.attrs.y, scene.vb[3], c.fontSize)
803      // Glyphs are a cell tall whatever the font size: the baseline finds the row.
804      const [dx, dy] = apply(c.m, [x, y])
805      const fill = parseColor(c.fill, c.color)
806      if (text) scene.labels.push({ x: dx, y: dy, text, anchor: c.anchor, rgba: fill, isBold: c.isBold })
807      continue
808    }
809    const scale = Math.sqrt(Math.abs(c.m[0] * c.m[3] - c.m[1] * c.m[2])) || 1
810    const segs: Segs = l => Math.max(2, Math.min(96, Math.ceil((l * scale) / (scene.S * 0.75))))
811    const polys = shapePolys(el, c, scene, segs)
812    if (polys.length === 0) continue
813    scene.drawn += 1
814    const spend = (work: number) => (scene.work = work === Infinity ? SVG_MAX_WORK : scene.work + work)
815    const fill = el.name === 'line' ? undefined : paintOf(c.fill, c, c.opacity * c.fillOpacity, scene, polys)
816    if (fill) spend(scene.raster.fill(polys.map(poly => poly.pts.map(pt => apply(c.m, pt))), c.rule, fill, SVG_MAX_WORK - scene.work))
817    const stroke = scene.work < SVG_MAX_WORK ? paintOf(c.stroke, c, c.opacity * c.strokeOpacity, scene, polys) : undefined
818    if (stroke) {
819      // Each point of a stroke builds a quad and a disc of up to 17 points.
820      spend(polys.reduce((n, poly) => n + poly.pts.length, 0) * STROKE_POINT_WORK)
821      if (scene.work >= SVG_MAX_WORK) continue
822      // Hairlines widen to one pixel rather than vanish.
823      const w = Math.max(scene.S, len(c.sw, Math.hypot(scene.vb[2], scene.vb[3]) / Math.SQRT2, c.fontSize, 1) * scale)
824      const pieces = polys.flatMap(poly => strokePolys(poly.pts.map(pt => apply(c.m, pt)), poly.isClosed, w, c.cap))
825      spend(scene.raster.fill(pieces, 'nonzero', stroke, SVG_MAX_WORK - scene.work))
826    }
827  }
828}
829
830function indexIds(els: readonly El[], ids: Map<string, El>) {
831  for (const el of els) {
832    if (el.attrs.id) ids.set(el.attrs.id, el)
833    indexIds(el.kids, ids)
834  }
835}
836
837// ── SMIL: animate, set, animateTransform, animateMotion ─────────────────
838// The tree as it stands `t` seconds in: each animation's value laid over
839// its target's attributes, so the walk draws a frame as it draws a still.
840
841const ANIM_TAG = /<(?:animate|set|animateTransform|animateMotion|animateColor)[\s/>]/
842const COLOR_ATTRS = new Set(['fill', 'stroke', 'stop-color', 'color', 'flood-color', 'lighting-color'])
843
844type Anim = { el: El; target: El; begin: number; dur: number; end: number; ids: Map<string, El> }
845
846// Seconds in a clock value: `2s`, `150ms`, `1.5min`, `00:01.5`, `3`.
847function seconds(v: string | undefined): number | undefined {
848  const s = v?.trim()
849  if (!s) return undefined
850  if (s === 'indefinite') return Infinity
851  const clock = /^(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(s)
852  if (clock) return Number(clock[1] ?? 0) * 3600 + Number(clock[2]) * 60 + Number(clock[3])
853  const m = /^([-+]?\d*\.?\d+)\s*(h|min|s|ms)?$/.exec(s)
854  if (!m) return undefined
855  const unit: Record<string, number> = { h: 3600, min: 60, s: 1, ms: 0.001 }
856
857  return Number(m[1]) * (unit[m[2] ?? 's'] ?? 1)
858}
859
860// Every animation with its target and its interval. A begin it cannot
861// resolve (an event such as `click`) never comes: the target keeps its own
862// values, as a page would until the event.
863function timings(els: readonly El[]): Anim[] {
864  const ids = new Map<string, El>()
865  const found: Array<{ el: El; parent?: El }> = []
866  const visit = (list: readonly El[], parent?: El) => {
867    for (const el of list) {
868      if (el.attrs.id) ids.set(el.attrs.id, el)
869      if (ANIMS.has(el.name)) found.push({ el, parent })
870      else visit(el.kids, el)
871    }
872  }
873  visit(els)
874  const byId = new Map(found.filter(f => f.el.attrs.id).map(f => [f.el.attrs.id!, f]))
875  const memo = new Map<El, { begin: number; dur: number; end: number }>()
876  const resolve = (f: { el: El }, depth = 0): { begin: number; dur: number; end: number } => {
877    const known = memo.get(f.el)
878    if (known) return known
879    const never = { begin: Infinity, dur: Infinity, end: Infinity }
880    if (depth > 16) return never
881    const a = f.el.attrs
882    let begin = Infinity
883    for (const part of (a.begin ?? '0s').split(';')) {
884      const p = part.trim()
885      const offset = seconds(p)
886      if (offset !== undefined) {
887        if (Number.isFinite(offset)) begin = Math.min(begin, offset)
888        continue
889      }
890      const sync = /^([\w-]+)\.(begin|end)\s*(?:([+-])\s*(.+))?$/.exec(p)
891      const other = sync && byId.get(sync[1]!)
892      if (!sync || !other) continue
893      const r = resolve(other, depth + 1)
894      const shift = (seconds(sync[4]) ?? 0) * (sync[3] === '-' ? -1 : 1)
895      const at = (sync[2] === 'begin' ? r.begin : r.end) + shift
896      if (Number.isFinite(at)) begin = Math.min(begin, at)
897    }
898    if (!Number.isFinite(begin)) {
899      memo.set(f.el, never)
900
901      return never
902    }
903    // No `dur` is an indefinite simple duration: the value holds from its begin.
904    const dur = seconds(a.dur) ?? Infinity
905    const count = a.repeatCount
906    const repeatDur = seconds(a.repeatDur)
907    let active = dur
908    if (count === 'indefinite') active = Infinity
909    else if (Number(count) > 0) active = dur * Number(count)
910    if (repeatDur !== undefined) active = count ? Math.min(active, repeatDur) : repeatDur
911    const end = seconds(a.end)
912    if (end !== undefined && end > begin) active = Math.min(active, end - begin)
913    const r = { begin, dur, end: begin + active }
914    memo.set(f.el, r)
915
916    return r
917  }
918
919  return found.flatMap(f => {
920    const target = ids.get(hrefOf(f.el.attrs) ?? '') ?? f.parent
921    if (!target) return []
922    const r = resolve(f)
923
924    return Number.isFinite(r.begin) ? [{ el: f.el, target, ids, ...r }] : []
925  })
926}
927
928// When the drawing stops changing (Infinity for a loop), and the time of
929// the still it rests on: the last moment any finite animation changes.
930function settle(anims: readonly Anim[]): { endS: number; restS: number } {
931  const changes = anims.map(a => (Number.isFinite(a.dur) ? a.end : a.begin))
932  const finite = changes.filter(Number.isFinite)
933
934  return { endS: Math.max(0, ...changes), restS: Math.max(0, ...finite) }
935}
936
937// Whether a drawing animates, and for how long; undefined for a still.
938export function animation(body: string): { endMs: number } | undefined {
939  if (!ANIM_TAG.test(body)) return undefined
940  const anims = timings(rootOf(body, {}).els)
941  if (anims.length === 0) return undefined
942
943  return { endMs: settle(anims).endS * 1000 }
944}
945
946function lerpText(a: string, b: string, f: number): string | undefined {
947  const na = a.match(NUM) ?? []
948  const nb = b.match(NUM) ?? []
949  if (na.length === 0 || na.length !== nb.length) return undefined
950  let k = 0
951
952  return a.replace(NUM, () => {
953    const v = Number(na[k]) + (Number(nb[k]) - Number(na[k])) * f
954    k += 1
955
956    return String(+v.toFixed(4))
957  })
958}
959
960function lerp(name: string, a: string, b: string, f: number): string {
961  if (COLOR_ATTRS.has(name)) {
962    const ca = parseColor(a, '#000')
963    const cb = parseColor(b, '#000')
964    if (ca && cb) {
965      const c = ca.map((v, k) => v + ((cb[k] ?? v) - v) * f)
966
967      return `rgba(${Math.round(c[0]!)},${Math.round(c[1]!)},${Math.round(c[2]!)},${+c[3]!.toFixed(3)})`
968    }
969  }
970
971  // What does not interpolate switches halfway, as a discrete value would.
972  return lerpText(a, b, f) ?? (f < 0.5 ? a : b)
973}
974
975// A cubic-bezier ease: y at x, by bisection.
976function ease([x1 = 0, y1 = 0, x2 = 1, y2 = 1]: readonly number[], x: number): number {
977  const at = (t: number, p1: number, p2: number) => 3 * (1 - t) * (1 - t) * t * p1 + 3 * (1 - t) * t * t * p2 + t * t * t
978  let lo = 0
979  let hi = 1
980  for (let k = 0; k < 24; k++) {
981    const mid = (lo + hi) / 2
982    if (at(mid, x1, x2) < x) lo = mid
983    else hi = mid
984  }
985
986  return at((lo + hi) / 2, y1, y2)
987}
988
989// Where `p` (0..1 of a simple duration) falls among the values.
990function pick(a: Attrs, values: readonly string[], p: number, mode: string, mix: (x: string, y: string, f: number) => string): string {
991  const n = values.length
992  if (n === 1) return values[0]!
993  const given = a.keyTimes?.split(';').map(Number)
994  const times = given?.length === n && given.every(Number.isFinite) ? given : undefined
995  if (mode === 'discrete') {
996    if (!times) return values[Math.min(n - 1, Math.floor(p * n))]!
997    let i = 0
998    times.forEach((k, j) => {
999      if (k <= p) i = j
1000    })
1001
1002    return values[i]!
1003  }
1004  const kt = times ?? values.map((_, i) => i / (n - 1))
1005  let i = 0
1006  while (i < n - 2 && p >= kt[i + 1]!) i++
1007  const span = kt[i + 1]! - kt[i]!
1008  let f = Math.max(0, Math.min(1, span > 0 ? (p - kt[i]!) / span : 1))
1009  if (mode === 'spline') {
1010    const spline = a.keySplines?.split(';')[i]
1011    if (spline) f = ease(numList(spline), f)
1012  }
1013
1014  return mix(values[i]!, values[i + 1]!, f)
1015}
1016
1017// The progress through the simple duration at `t`, or undefined while the
1018// animation has no say (not begun, or ended without `fill="freeze"`).
1019function progress(an: Anim, t: number): number | undefined {
1020  if (t < an.begin) return undefined
1021  if (t < an.end) return Number.isFinite(an.dur) && an.dur > 0 ? ((t - an.begin) % an.dur) / an.dur : 0
1022  if (an.el.attrs.fill !== 'freeze') return undefined
1023  if (!Number.isFinite(an.dur) || an.dur <= 0) return 0
1024  const span = an.end - an.begin
1025  const rest = span - Math.floor(span / an.dur) * an.dur
1026
1027  // Frozen at a whole number of repeats: the last value, not the first.
1028  return rest < 1e-9 ? 1 : rest / an.dur
1029}
1030
1031function motionAt(an: Anim, p: number): string | undefined {
1032  const a = an.el.attrs
1033  const mpath = an.el.kids.find(k => k.name === 'mpath')
1034  let d = a.path ?? (mpath ? an.ids.get(hrefOf(mpath.attrs) ?? '')?.attrs.d : undefined)
1035  if (!d) {
1036    const pts = a.values?.split(';') ?? [a.from, a.to].filter((v): v is string => v !== undefined)
1037    if (pts.length > 0) d = 'M' + pts.join(' L')
1038  }
1039  const line = pathPolys(d ?? '', () => 16).flatMap(poly => poly.pts)
1040  if (line.length === 0) return undefined
1041  const lens = [0]
1042  for (let k = 1; k < line.length; k++) lens.push(lens[k - 1]! + Math.hypot(line[k]![0] - line[k - 1]![0], line[k]![1] - line[k - 1]![1]))
1043  // Paced: equal distance in equal time, whatever the calcMode.
1044  const want = Math.max(0, Math.min(1, p)) * lens[lens.length - 1]!
1045  let k = 1
1046  while (k < line.length - 1 && lens[k]! < want) k++
1047  const [ax, ay] = line[k - 1] ?? line[0]!
1048  const [bx, by] = line[k] ?? line[0]!
1049  const seg = (lens[k] ?? 0) - (lens[k - 1] ?? 0)
1050  const f = seg > 0 ? (want - lens[k - 1]!) / seg : 0
1051  const x = ax + (bx - ax) * f
1052  const y = ay + (by - ay) * f
1053  const auto = (Math.atan2(by - ay, bx - ax) * 180) / Math.PI
1054  const turn = a.rotate === 'auto' ? auto : a.rotate === 'auto-reverse' ? auto + 180 : Number(a.rotate) || 0
1055
1056  return `translate(${+x.toFixed(3)} ${+y.toFixed(3)})${turn ? ` rotate(${+turn.toFixed(2)})` : ''}`
1057}
1058
1059function animated(els: readonly El[], anims: readonly Anim[], t: number): El[] {
1060  const over = new Map<El, Attrs>()
1061  const motion = new Map<El, string>()
1062  for (const an of anims) {
1063    const p = progress(an, t)
1064    if (p === undefined) continue
1065    const a = an.el.attrs
1066    const base = over.get(an.target) ?? (({ style: _, ...rest }) => rest)(props(an.target.attrs))
1067    over.set(an.target, base)
1068    if (an.el.name === 'animateMotion') {
1069      const m = motionAt(an, p)
1070      if (m) motion.set(an.target, m)
1071      continue
1072    }
1073    const isTransform = an.el.name === 'animateTransform'
1074    const name = isTransform ? 'transform' : a.attributeName
1075    if (!name) continue
1076    const type = a.type ?? 'translate'
1077    const own = isTransform ? '' : base[name] ?? ''
1078    let values: string[]
1079    if (an.el.name === 'set') values = [a.to ?? '']
1080    else if (a.values) values = a.values.split(';').map(v => v.trim()).filter(Boolean)
1081    else if (a.by !== undefined && a.to === undefined) {
1082      const from = a.from ?? (isTransform ? a.by.replace(NUM, '0') : own || '0')
1083      values = [from, sum(from, a.by)]
1084    } else values = [a.from ?? own, a.to ?? own]
1085    if (values.length === 0) continue
1086    const mode = an.el.name === 'set' ? 'discrete' : (a.calcMode ?? 'linear')
1087    const v = pick(a, values, p, mode, (x, y, f) => lerp(name, x, y, f))
1088    if (isTransform) {
1089      const step = `${type}(${v})`
1090      base.transform = a.additive === 'sum' && base.transform ? `${base.transform} ${step}` : step
1091    } else base[name] = a.additive === 'sum' && own ? sum(own, v) : v
1092  }
1093  // Motion lies over every other transform of its target.
1094  for (const [el, m] of motion) {
1095    const attrs = over.get(el)!
1096    attrs.transform = attrs.transform ? `${m} ${attrs.transform}` : m
1097  }
1098  const copy = (el: El): El => ({ ...el, attrs: over.get(el) ?? el.attrs, kids: el.kids.map(copy) })
1099
1100  return els.map(copy)
1101}
1102
1103// Two values added number by number, as `additive="sum"` and `by` ask;
1104// `b` alone where their numbers do not line up.
1105function sum(a: string, b: string): string {
1106  const na = a.match(NUM) ?? []
1107  const nb = b.match(NUM) ?? []
1108  if (na.length === 0 || na.length !== nb.length) return b
1109  let k = 0
1110
1111  return a.replace(NUM, n => String(+(Number(n) + Number(nb[k++])).toFixed(4)))
1112}
1113
1114// ── native surfaces: the markup as written, for a surface that draws SVG ─
1115
1116const OWN = new Set(['title', 'cols', 'rows', 'mode', 'bg', 'background', 'width', 'height'])
1117const CASED: Record<string, string> = { viewbox: 'viewBox', preserveaspectratio: 'preserveAspectRatio' }
1118
1119function quoteAttr(v: string): string {
1120  return v.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
1121}
1122
1123function escapeText(s: string): string {
1124  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1125}
1126
1127// What a surface's own SVG element is handed holds nothing that reaches
1128// past the drawing: no script or handler (the surfaces strip those too),
1129// and nothing loaded from elsewhere. An animated drawing shows in a frame,
1130// where an external <image> would be fetched, and its URL could carry
1131// whatever a reply was steered into writing there. The cells draw none of
1132// these either.
1133const UNSAFE = new Set(['script', 'foreignObject', 'image', 'feImage', 'iframe', 'style', 'object', 'embed', 'audio', 'video'])
1134
1135function isSafeAttr(k: string, v: string): boolean {
1136  if (/^on/i.test(k)) return false
1137  if (/^(?:xlink:)?href$/i.test(k)) return v.trim().startsWith('#')
1138
1139  return !/url\(\s*['"]?\s*(?!#)/i.test(v)
1140}
1141
1142const TONED = new Set(['fill', 'stroke', 'stop-color', 'flood-color', 'color'])
1143
1144// An attribute as a browser should read it: the style's tone names turned
1145// to colors it knows.
1146function nativeAttr(k: string, v: string): string {
1147  const hex = TONED.has(k) && /^[\w-]+$/.test(v) && !CSS[v.toLowerCase()] ? HEX_OF[tone(v) ?? ''] : undefined
1148
1149  return ` ${k}="${quoteAttr(hex ?? v)}"`
1150}
1151
1152// The markup read as the cells read it and written again, well formed:
1153// unsafe elements left out with all they hold, unsafe attributes dropped,
1154// declarations and comments gone, every open element closed.
1155function nativeMarkup(src: string): string {
1156  let out = ''
1157  const stack: string[] = []
1158  // Elements opened inside an unsafe one, itself included.
1159  let skipped = 0
1160  for (const m of src.matchAll(TOKEN)) {
1161    if (m[5] !== undefined) {
1162      if (!skipped) out += escapeText(decode(m[5]))
1163      continue
1164    }
1165    const name = m[2]
1166    if (!name) continue
1167    if (m[1]) {
1168      if (skipped) {
1169        skipped -= 1
1170        continue
1171      }
1172      // A close with no open element of its name is dropped; one with
1173      // closes what was left open inside it too.
1174      const at = stack.lastIndexOf(name)
1175      if (at === -1) continue
1176      out += stack.splice(at).reverse().map(open => `</${open}>`).join('')
1177      continue
1178    }
1179    const attrs = xmlAttrs(m[3] ?? '')
1180    // An animation of a link could set it to an address elsewhere.
1181    const isUnsafe = UNSAFE.has(name) || (ANIMS.has(name) && /href$/i.test(attrs.attributeName ?? ''))
1182    if (skipped || isUnsafe) {
1183      if (!m[4]) skipped += 1
1184      continue
1185    }
1186    const safe = Object.entries(attrs).filter(([k, v]) => isSafeAttr(k, v))
1187    out += `<${name}${safe.map(([k, v]) => nativeAttr(k, v)).join('')}${m[4] ? '/' : ''}>`
1188    if (!m[4]) stack.push(name)
1189  }
1190
1191  return out + stack.reverse().map(name => `</${name}>`).join('')
1192}
1193
1194// The widest a surface's own drawing is set, in cells of 8 CSS pixels.
1195const NATIVE_MAX_COLS = 200
1196
1197// What a surface with its own SVG element is handed: a whole document.
1198export function nativeSvg(body: string, attrs: Attrs): { source: string; alt: string; width: number; isAnimated: boolean } {
1199  const { els } = rootOf(body, {})
1200  const isWhole = els.length === 1 && els[0]!.name === 'svg'
hooks/text.ts 215 lines
1// Styled text as plain data: everything the widgets draw is rows of runs,
2// which register.tsx turns into Text elements.
3
4export type Style = {
5  fg?: string
6  bg?: string
7  bold?: boolean
8  italic?: boolean
9  underline?: boolean
10  strike?: boolean
11  dim?: boolean
12  inverse?: boolean
13}
14
15export type Run = Style & { text: string }
16
17// `lead` is a fixed-width gutter (a bullet, a key) the wrapped `runs` hang from.
18export type Row = { lead?: Run[]; runs: Run[]; wrap?: boolean }
19
20// Tone names the model writes, mapped to the person's theme keys.
21const TONES: Record<string, string> = {
22  ok: 'success',
23  success: 'success',
24  done: 'success',
25  pass: 'success',
26  warn: 'warning',
27  warning: 'warning',
28  err: 'error',
29  error: 'error',
30  fail: 'error',
31  info: 'suggestion',
32  note: 'suggestion',
33  tip: 'autoAccept',
34  accent: 'claude',
35  claude: 'claude',
36  plan: 'planMode',
37  dim: 'inactive',
38  muted: 'inactive',
39}
40
41export const SERIES = ['claude', 'suggestion', 'success', 'autoAccept', 'warning', 'planMode']
42
43const NAMED = /^(black|red|green|yellow|blue|magenta|cyan|white|gray|grey)(Bright)?$/i
44const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
45
46export function tone(name: string | undefined): string | undefined {
47  if (!name) return undefined
48  const key = name.trim()
49  if (TONES[key.toLowerCase()]) return TONES[key.toLowerCase()]
50  if (HEX.test(key) || NAMED.test(key)) return key
51  if (/^[a-zA-Z]+$/.test(key)) return key // a theme key the person's theme holds
52
53  return undefined
54}
55
56export function isTone(name: string): boolean {
57  return Boolean(TONES[name.toLowerCase()])
58}
59
60function isWide(c: number): boolean {
61  return (
62    (c >= 0x1100 && c <= 0x115f) ||
63    (c >= 0x2e80 && c <= 0xa4cf) ||
64    (c >= 0xac00 && c <= 0xd7a3) ||
65    (c >= 0xf900 && c <= 0xfaff) ||
66    (c >= 0xfe30 && c <= 0xfe4f) ||
67    (c >= 0xff00 && c <= 0xff60) ||
68    (c >= 0xffe0 && c <= 0xffe6) ||
69    (c >= 0x1f300 && c <= 0x1faff) ||
70    (c >= 0x20000 && c <= 0x3fffd)
71  )
72}
73
74// Terminal cells a string takes: wide CJK and emoji count two, joiners none.
75export function width(s: string): number {
76  let w = 0
77  for (const ch of s) {
78    const c = ch.codePointAt(0) ?? 0
79    if (c === 0x200d || (c >= 0x300 && c <= 0x36f) || (c >= 0xfe00 && c <= 0xfe0f)) continue
80    w += isWide(c) ? 2 : 1
81  }
82
83  return w
84}
85
86export function runsWidth(runs: readonly Run[]): number {
87  return runs.reduce((n, r) => n + width(r.text), 0)
88}
89
90export function clip(s: string, cells: number): string {
91  if (width(s) <= cells) return s
92  let out = ''
93  for (const ch of s) {
94    if (width(out + ch) > cells - 1) break
95    out += ch
96  }
97
98  return out + '…'
99}
100
101export function pad(s: string, cells: number, align: 'left' | 'right' | 'center' = 'left'): string {
102  const gap = Math.max(0, cells - width(s))
103  if (align === 'right') return ' '.repeat(gap) + s
104  if (align === 'center') return ' '.repeat(Math.floor(gap / 2)) + s + ' '.repeat(Math.ceil(gap / 2))
105
106  return s + ' '.repeat(gap)
107}
108
109// The first number in a cell ("120ms", "1,204", "-3.5%"), or undefined.
110export function num(s: string): number | undefined {
111  const m = /-?\d[\d,]*(?:\.\d+)?|-?\.\d+/.exec(s)
112  if (!m) return undefined
113  const n = Number(m[0].replace(/,/g, ''))
114
115  return Number.isFinite(n) ? n : undefined
116}
117
118export function nums(s: string): number[] {
119  return s
120    .split(/[\s,;]+/)
121    .map(t => num(t))
122    .filter((n): n is number => n !== undefined)
123}
124
125const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
126
127// A horizontal bar of `cells` at most, to the nearest eighth of a cell.
128export function bar(frac: number, cells: number): string {
129  const total = Math.max(0, Math.min(1, Number.isFinite(frac) ? frac : 0)) * cells
130  const full = Math.floor(total)
131  const rest = Math.round((total - full) * 8)
132  if (rest === 8) return '█'.repeat(full + 1)
133
134  return '█'.repeat(full) + EIGHTHS[rest]
135}
136
137const TICKS = '▁▂▃▄▅▆▇█'
138
139export function spark(values: readonly number[]): string {
140  if (values.length === 0) return ''
141  const lo = Math.min(...values)
142  const hi = Math.max(...values)
143
144  return values
145    .map(v => (hi === lo ? TICKS[3] : TICKS[Math.round(((v - lo) / (hi - lo)) * (TICKS.length - 1))]))
146    .join('')
147}
148
149// Trailing `{tone}` on a line: `build {ok}` → ['build', 'success'].
150export function splitTone(s: string): [string, string | undefined] {
151  const m = /\s*\{([\w#-]+)\}\s*$/.exec(s)
152  if (!m) return [s.trim(), undefined]
153
154  return [s.slice(0, m.index).trim(), tone(m[1])]
155}
156
157export function hexToRgb(hex: string): [number, number, number] {
158  let h = hex.replace('#', '')
159  if (h.length === 3) h = [...h].map(c => c + c).join('')
160  const n = parseInt(h, 16)
161
162  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
163}
164
165export function rgbToHex(rgb: readonly number[]): string {
166  return '#' + rgb.slice(0, 3).map(v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('')
167}
168
169export function mix(a: string, b: string, t: number): string {
170  const x = hexToRgb(a)
171  const y = hexToRgb(b)
172
173  return rgbToHex(x.map((v, i) => v + ((y[i] ?? v) - v) * t))
174}
175
176// A ramp through several stops, `t` in 0..1.
177export function ramp(stops: readonly string[], t: number): string {
178  const c = Math.max(0, Math.min(1, t)) * (stops.length - 1)
179  const i = Math.min(stops.length - 2, Math.floor(c))
180
181  return mix(stops[i] ?? '#000000', stops[i + 1] ?? stops[i] ?? '#000000', c - i)
182}
183
184// Theme keys have no hex value a mod can read, so gradients use these.
185export const HEX_OF: Record<string, string> = {
186  claude: '#d77757',
187  success: '#4eba65',
188  warning: '#ffc107',
189  error: '#ff6b80',
190  suggestion: '#b1b9f9',
191  autoAccept: '#af87ff',
192  planMode: '#48968c',
193  inactive: '#999999',
194}
195
196export function hexOf(color: string | undefined, fallback: string): string {
197  if (!color) return fallback
198  if (HEX.test(color)) return color
199
200  return HEX_OF[tone(color) ?? color] ?? fallback
201}
202
203export const HEAT = ['#22305a', '#2f5d8a', '#2f8f9d', '#5fb88a', '#d8c25a', '#e98b4a', '#d9534f']
204
205// Relative luminance, 0 (black) to 1 (white), to pick text over a fill.
206export function luminance(hex: string): number {
207  const [r, g, b] = hexToRgb(hex).map(v => {
208    const c = v / 255
209
210    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
211  }) as [number, number, number]
212
213  return 0.2126 * r + 0.7152 * g + 0.0722 * b
214}
215
hooks/widgets.ts 982 lines
1// The block widgets: each takes a tag's body, its attributes and the width it
2// may use, and answers rows of styled runs. A widget that cannot fit answers
3// a plainer form of the same content rather than a broken drawing.
4
5import type { Attrs } from './parse'
6import { rich } from './parse'
7import {
8  bar,
9  clip,
10  HEAT,
11  hexOf,
12  mix,
13  luminance,
14  nums,
15  num,
16  pad,
17  ramp,
18  type Row,
19  type Run,
20  runsWidth,
21  SERIES,
22  splitTone,
23  type Style,
24  tone,
25  width,
26} from './text'
27
28const DIM: Style = { fg: 'inactive' }
29
30function lines(body: string): string[] {
31  return body.split('\n').filter(l => l.trim() && !/^\s*\/\//.test(l))
32}
33
34// At the first separator only, so a detail may hold another one.
35function splitOnce(s: string, sep: RegExp): [string, string | undefined] {
36  const m = sep.exec(s)
37
38  return m ? [s.slice(0, m.index), s.slice(m.index + m[0].length)] : [s, undefined]
39}
40
41function fmt(n: number): string {
42  const a = Math.abs(n)
43  if (a >= 1e9) return `${+(n / 1e9).toFixed(1)}B`
44  if (a >= 1e6) return `${+(n / 1e6).toFixed(1)}M`
45  if (a >= 1e4) return `${+(n / 1e3).toFixed(1)}k`
46  if (a >= 100 || Number.isInteger(n)) return String(Math.round(n))
47
48  return String(+n.toFixed(2))
49}
50
51function title(attrs: Attrs, extra: Run[] = []): Row[] {
52  if (!attrs.title) return []
53
54  return [{ runs: [...rich(attrs.title, { bold: true }), ...extra] }]
55}
56
57// ── canvas: a cell grid whose lines join by direction ───────────────────
58
59const U = 1
60const R = 2
61const D = 4
62const L = 8
63const JOIN: Record<number, string> = {
64  1: '│', 4: '│', 5: '│', 2: '─', 8: '─', 10: '─',
65  6: '╭', 12: '╮', 3: '╰', 9: '╯', 7: '├', 13: '┤', 14: '┬', 11: '┴', 15: '┼',
66}
67
68type Cell = { ch?: string; mask: number; style?: Style }
69
70class Canvas {
71  cells: Cell[][]
72  readonly w: number
73
74  constructor(w: number, h: number) {
75    this.w = w
76    this.cells = Array.from({ length: h }, () => Array.from({ length: w }, () => ({ mask: 0 })))
77  }
78
79  at(x: number, y: number): Cell | undefined {
80    return this.cells[y]?.[x]
81  }
82
83  text(x: number, y: number, s: string, style?: Style) {
84    let cx = x
85    for (const ch of s) {
86      const cell = this.at(cx, y)
87      if (cell) Object.assign(cell, { ch, style })
88      cx += width(ch)
89      // A wide glyph takes the next cell too: blank it so the row stays aligned.
90      if (width(ch) === 2 && this.at(cx - 1, y)) Object.assign(this.at(cx - 1, y)!, { ch: '', style })
91    }
92  }
93
94  join(x: number, y: number, mask: number, style?: Style) {
95    const cell = this.at(x, y)
96    if (!cell || cell.ch !== undefined) return
97    cell.mask |= mask
98    cell.style ??= style
99  }
100
101  vline(x: number, y0: number, y1: number, style?: Style) {
102    const [a, b] = y0 <= y1 ? [y0, y1] : [y1, y0]
103    for (let y = a; y <= b; y++) this.join(x, y, (y > a ? U : 0) | (y < b ? D : 0) || U | D, style)
104  }
105
106  hline(y: number, x0: number, x1: number, style?: Style) {
107    const [a, b] = x0 <= x1 ? [x0, x1] : [x1, x0]
108    for (let x = a; x <= b; x++) this.join(x, y, (x > a ? L : 0) | (x < b ? R : 0) || L | R, style)
109  }
110
111  box(x: number, y: number, w: number, label: string, style: Style, labelStyle: Style) {
112    this.text(x, y, '╭' + '─'.repeat(w - 2) + '╮', style)
113    this.text(x, y + 1, '│', style)
114    this.text(x + 1, y + 1, pad(' ' + label, w - 2), labelStyle)
115    this.text(x + w - 1, y + 1, '│', style)
116    this.text(x, y + 2, '╰' + '─'.repeat(w - 2) + '╯', style)
117  }
118
119  rows(): Row[] {
120    const out = this.cells.map(row => {
121      const runs: Run[] = []
122      for (const cell of row) {
123        const ch = cell.ch ?? JOIN[cell.mask] ?? ' '
124        const style = ch === ' ' ? undefined : cell.style
125        const last = runs[runs.length - 1]
126        if (last && sameStyle(last, style)) last.text += ch
127        else runs.push({ ...style, text: ch })
128      }
129      const tail = runs[runs.length - 1]
130      if (tail) tail.text = tail.text.trimEnd()
131
132      return { runs: runs.filter(r => r.text) }
133    })
134    while (out.length > 0 && out[out.length - 1]!.runs.length === 0) out.pop()
135
136    return out
137  }
138}
139
140function sameStyle(a: Style, b: Style | undefined): boolean {
141  const keys = ['fg', 'bg', 'bold', 'italic', 'underline', 'strike', 'dim', 'inverse'] as const
142
143  return keys.every(k => a[k] === b?.[k])
144}
145
146// ── flow: a layered graph of boxes ──────────────────────────────────────
147
148type FlowNode = { id: string; label: string; tone?: string; layer: number; order: number; w: number; x: number; isDummy: boolean }
149
150// The most edges a flow keeps: a line of fans (`a, b, … -> x, y, …`) makes
151// the product of its sides, so a short body could ask for millions.
152const FLOW_KEPT_EDGES = 2000
153
154function parseFlow(body: string) {
155  const nodes = new Map<string, { label: string; tone?: string }>()
156  const edges: Array<[string, string]> = []
157  const seen = new Set<string>()
158  let isCut = false
159  const edge = (a: string, b: string) => {
160    const key = a + '\u0000' + b
161    if (a === b || seen.has(key)) return
162    if (edges.length === FLOW_KEPT_EDGES) {
163      isCut = true
164
165      return
166    }
167    seen.add(key)
168    edges.push([a, b])
169  }
170  const node = (raw: string) => {
171    const [label, t] = splitTone(raw)
172    if (!label) return undefined
173    const known = nodes.get(label)
174    if (!known) nodes.set(label, { label, tone: t })
175    else if (t) known.tone = t
176
177    return label
178  }
179  for (const line of lines(body)) {
180    const steps = line.split(/\s*(?:-->|->|→|=>)\s*/).map(seg =>
181      seg
182        .split(/\s*,\s*/)
183        .map(node)
184        .filter((id): id is string => id !== undefined),
185    )
186    for (let i = 0; i + 1 < steps.length && !isCut; i++) for (const a of steps[i]!) for (const b of steps[i + 1]!) edge(a, b)
187  }
188
189  return { nodes, edges, isCut }
190}
191
192// Each node's edges, in the order given.
193function adjacency<T>(edges: ReadonlyArray<readonly [T, T]>, isReversed = false): Map<T, T[]> {
194  const out = new Map<T, T[]>()
195  for (const [a, b] of edges) {
196    const [from, to] = isReversed ? [b, a] : [a, b]
197    const list = out.get(from)
198    if (list) list.push(to)
199    else out.set(from, [to])
200  }
201
202  return out
203}
204
205// Past these a graph is listed, not laid out: it could not fit the width,
206// and its layout costs time with the square of its size.
207const FLOW_MAX_NODES = 100
208const FLOW_MAX_EDGES = 300
209
210export function flow(body: string, attrs: Attrs, avail: number): Row[] {
211  const { nodes, edges, isCut } = parseFlow(body)
212  if (nodes.size === 0) return []
213  const ids = [...nodes.keys()]
214  if (ids.length > FLOW_MAX_NODES || edges.length > FLOW_MAX_EDGES) {
215    const more: Row[] = isCut ? [{ runs: [{ ...DIM, text: `… edges past the first ${FLOW_KEPT_EDGES} not listed` }] }] : []
216
217    return [...title(attrs), ...flowList(ids, adjacency(edges), nodes), ...more]
218  }
219
220  // Back edges (cycles) are kept out of the layout and listed under it.
221  const back: Array<[string, string]> = []
222  const forward: Array<[string, string]> = []
223  {
224    const state = new Map<string, 'open' | 'done'>()
225    const outOf = adjacency(edges)
226    const visit = (v: string) => {
227      state.set(v, 'open')
228      for (const to of outOf.get(v) ?? []) {
229        const e: [string, string] = [v, to]
230        const s = state.get(e[1])
231        if (s === 'open') back.push(e)
232        else {
233          forward.push(e)
234          if (!s) visit(e[1])
235        }
236      }
237      state.set(v, 'done')
238    }
239    for (const v of ids) if (!state.has(v)) visit(v)
240  }
241
242  const succOf = adjacency(forward)
243  const predOf = adjacency(forward, true)
244  const succ = (v: string) => succOf.get(v) ?? []
245  const pred = (v: string) => predOf.get(v) ?? []
246
247  const isPath =
248    back.length === 0 &&
249    forward.length === ids.length - 1 &&
250    ids.every(v => succ(v).length <= 1 && pred(v).length <= 1)
251  if (isPath) {
252    const start = ids.find(v => pred(v).length === 0)!
253    const chain = [start]
254    for (let next = succ(start)[0]; next !== undefined; next = succ(next)[0]) chain.push(next)
255    const drawn = chainRow(chain.map(id => nodes.get(id)!), avail, attrs.dir !== 'down')
256    if (drawn) return [...title(attrs), ...drawn]
257  }
258
259  // Longest-path layering over the acyclic edges.
260  const layer = new Map<string, number>()
261  const depth = (v: string, seen = new Set<string>()): number => {
262    if (layer.has(v)) return layer.get(v)!
263    if (seen.has(v)) return 0
264    seen.add(v)
265    const d = Math.max(-1, ...pred(v).map(p => depth(p, seen))) + 1
266    layer.set(v, d)
267
268    return d
269  }
270  ids.forEach(v => depth(v))
271
272  // Edges that skip layers run through one-cell dummies, a layer at a time.
273  const all = new Map<string, FlowNode>()
274  for (const id of ids) {
275    const n = nodes.get(id)!
276    all.set(id, { id, label: n.label, tone: n.tone, layer: layer.get(id)!, order: 0, w: width(n.label) + 4, x: 0, isDummy: false })
277  }
278  const links: Array<[string, string]> = []
279  let dummies = 0
280  for (const [a, b] of forward) {
281    let from = a
282    for (let l = layer.get(a)! + 1; l < layer.get(b)!; l++) {
283      const id = `\u0000${dummies++}`
284      all.set(id, { id, label: '', layer: l, order: 0, w: 1, x: 0, isDummy: true })
285      links.push([from, id])
286      from = id
287    }
288    links.push([from, b])
289  }
290
291  const depthCount = Math.max(...[...all.values()].map(n => n.layer)) + 1
292  const layers: FlowNode[][] = Array.from({ length: depthCount }, () => [])
293  for (const n of all.values()) layers[n.layer]!.push(n)
294  layers.forEach(ls => ls.forEach((n, i) => (n.order = i)))
295
296  const upOf = adjacency(links, true)
297  const downOf = adjacency(links)
298  const up = (n: FlowNode) => (upOf.get(n.id) ?? []).map(id => all.get(id)!)
299  const down = (n: FlowNode) => (downOf.get(n.id) ?? []).map(id => all.get(id)!)
300  const mean = (xs: number[], dflt: number) => (xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : dflt)
301  // The middle parent (or child) so a node sits straight under one of them.
302  const median = (xs: number[], dflt: number) => {
303    if (xs.length === 0) return dflt
304    const sorted = [...xs].sort((a, b) => a - b)
305
306    return sorted[Math.floor((sorted.length - 1) / 2)] ?? dflt
307  }
308
309  // Barycentre sweeps to cut crossings.
310  for (let pass = 0; pass < 4; pass++) {
311    const range = pass % 2 === 0 ? layers.slice(1) : layers.slice(0, -1).reverse()
312    for (const ls of range) {
313      const key = new Map(ls.map(n => [n, mean((pass % 2 === 0 ? up(n) : down(n)).map(m => m.order), n.order)]))
314      ls.sort((a, b) => key.get(a)! - key.get(b)! || a.order - b.order)
315      ls.forEach((n, i) => (n.order = i))
316    }
317  }
318
319  const centre = (n: FlowNode) => n.x + Math.floor(n.w / 2)
320  const place = (gap: number) => {
321    const placeLayer = (ls: FlowNode[], toward: (n: FlowNode) => FlowNode[]) => {
322      let right = -gap
323      for (const n of ls) {
324        const want = median(toward(n).map(centre), centre(n)) - Math.floor(n.w / 2)
325        n.x = Math.max(Math.round(want), right + gap, 0)
326        right = n.x + n.w
327      }
328    }
329    let x = 0
330    for (const n of layers[0]!) {
331      n.x = x
332      x += n.w + gap
333    }
334    for (const ls of layers.slice(1)) placeLayer(ls, up)
335    for (const ls of layers.slice(0, -1).reverse()) placeLayer(ls, down)
336    for (const ls of layers.slice(1)) placeLayer(ls, up)
337
338    return Math.max(...[...all.values()].map(n => n.x + n.w))
339  }
340  let gap = 3
341  let total = place(gap)
342  while (total > avail && gap > 1) total = place(--gap)
343  if (total > avail) return [...title(attrs), ...flowList(ids, adjacency([...forward, ...back]), nodes)]
344
345  // Channels between layers: one track per source whose edges run sideways.
346  type Span = { src: FlowNode; lo: number; hi: number; targets: Set<number>; track: number }
347  const channels = layers.slice(0, -1).map(ls => {
348    const spans: Span[] = []
349    for (const src of ls) {
350      const xs = down(src).map(centre)
351      if (xs.length === 0) continue
352      const lo = Math.min(centre(src), ...xs)
353      const hi = Math.max(centre(src), ...xs)
354      if (lo !== hi) spans.push({ src, lo, hi, targets: new Set(xs), track: -1 })
355    }
356    spans.sort((a, b) => a.lo - b.lo)
357    const tracks: Span[][] = []
358    for (const s of spans) {
359      const clash = (t: Span) =>
360        s.lo <= t.hi && t.lo <= s.hi && !((s.hi === t.lo || t.hi === s.lo) && [...s.targets].some(x => t.targets.has(x) && (x === s.hi || x === s.lo)))
361      let i = tracks.findIndex(tr => !tr.some(clash))
362      if (i === -1) i = tracks.push([]) - 1
363      tracks[i]!.push(s)
364      s.track = i
365    }
366
367    return { spans, height: tracks.length + 2 }
368  })
369
370  const tops: number[] = []
371  let y = 0
372  layers.forEach((_, i) => {
373    tops.push(y)
374    y += 3 + (channels[i]?.height ?? 0)
375  })
376  const canvas = new Canvas(total, y)
377  const edge: Style = DIM
378
379  layers.forEach((ls, i) => {
380    const top = tops[i]!
381    for (const n of ls) {
382      if (n.isDummy) canvas.vline(n.x, top, top + 2, edge)
383      else {
384        const color = n.tone
385        canvas.box(n.x, top, n.w, n.label, { fg: color ?? 'inactive' }, { fg: color, bold: true })
386      }
387    }
388    const ch = channels[i]
389    if (!ch) return
390    const stub = top + 3
391    const arrow = stub + ch.height - 1
392    for (const src of ls) {
393      const px = centre(src)
394      const kids = down(src)
395      if (kids.length === 0) continue
396      // In the edge's color, not the box's, so a toned box doesn't tint its shaft.
397      if (!src.isDummy) canvas.text(px, top + 2, '┬', edge)
398      const span = ch.spans.find(s => s.src === src)
399      const row = span ? stub + 1 + span.track : arrow
400      canvas.vline(px, stub, row, edge)
401      if (span) canvas.hline(row, span.lo, span.hi, edge)
402      for (const kid of kids) {
403        const cx = centre(kid)
404        canvas.vline(cx, row, arrow, edge)
405        if (!kid.isDummy) canvas.text(cx, arrow, '▼', edge)
406      }
407    }
408  })
409
410  const notes: Row[] = back.map(([a, b]) => ({ runs: [{ ...DIM, text: `cycle: ${a} → ${b}` }] }))
411
412  return [...title(attrs), ...canvas.rows(), ...notes]
413}
414
415function chainRow(chain: Array<{ label: string; tone?: string }>, avail: number, isAllowed: boolean): Row[] | undefined {
416  const arrow = ' ──► '
417  const total = chain.reduce((s, n) => s + width(n.label) + 4, 0) + (chain.length - 1) * width(arrow)
418  if (!isAllowed || total > avail) return undefined
419  const canvas = new Canvas(total, 3)
420  let x = 0
421  chain.forEach((n, i) => {
422    const w = width(n.label) + 4
423    canvas.box(x, 0, w, n.label, { fg: n.tone ?? 'inactive' }, { fg: n.tone, bold: true })
424    x += w
425    if (i < chain.length - 1) {
426      canvas.text(x, 1, arrow, DIM)
427      x += width(arrow)
428    }
429  })
430
431  return canvas.rows()
432}
433
434function flowList(ids: string[], outOf: Map<string, string[]>, nodes: Map<string, { tone?: string }>): Row[] {
435  return ids
436    .map((id): Row | undefined => {
437      const to = outOf.get(id) ?? []
438      if (to.length === 0) return undefined
439      const runs: Run[] = [{ fg: nodes.get(id)?.tone, bold: true, text: id }, { ...DIM, text: ' → ' }]
440      to.forEach((b, i) => {
441        if (i) runs.push({ ...DIM, text: ', ' })
442        runs.push({ fg: nodes.get(b)?.tone, text: b })
443      })
444
445      return { runs, wrap: true }
446    })
447    .filter((r): r is Row => r !== undefined)
448}
449
450// ── seq: a sequence diagram ─────────────────────────────────────────────
451
452type SeqItem =
453  | { kind: 'msg'; from: number; to: number; label: string; isReply: boolean; tone?: string }
454  | { kind: 'divider'; label: string }
455
456export function seq(body: string, attrs: Attrs, avail: number): Row[] {
457  const names: string[] = []
458  const who = (s: string) => {
459    const name = s.trim()
460    let i = names.indexOf(name)
461    if (i === -1) i = names.push(name) - 1
462
463    return i
464  }
465  const items: SeqItem[] = []
466  for (const line of lines(body)) {
467    const divider = /^\s*={2,}\s*(.*?)\s*={2,}\s*$/.exec(line)
468    if (divider) {
469      items.push({ kind: 'divider', label: divider[1] ?? '' })
470      continue
471    }
472    const m = /^(.+?)\s*(-->|->|→|<--|<-|←)\s*(.+?)\s*(?::\s*(.*))?$/.exec(line) as [string, string, string, string, string?] | null
473    if (!m) continue
474    const [label, t] = splitTone(m[4] ?? '')
475    const isBackward = m[2].startsWith('<') || m[2] === '←'
476    const a = who(m[1])
477    const b = who(m[3])
478    items.push({
479      kind: 'msg',
480      from: isBackward ? b : a,
481      to: isBackward ? a : b,
482      label,
483      isReply: m[2].includes('--'),
484      tone: t,
485    })
486  }
487  if (names.length === 0) return []
488
489  // Columns far enough apart for every label drawn between them.
490  const hw = names.map(n => width(n) + 4)
491  const W = (i: number) => hw[i] ?? 0
492  const X = (i: number) => xs[i] ?? 0
493  const xs: number[] = [Math.floor(W(0) / 2)]
494  for (let j = 1; j < names.length; j++) {
495    let x = X(j - 1) + Math.ceil(W(j - 1) / 2) + Math.floor(W(j) / 2) + 2
496    for (const it of items) {
497      if (it.kind !== 'msg') continue
498      const lo = Math.min(it.from, it.to)
499      const hi = Math.max(it.from, it.to)
500      if (hi === j && lo < j) x = Math.max(x, X(lo) + width(it.label) + 4)
501      if (it.from === it.to && it.from === j - 1) x = Math.max(x, X(j - 1) + width(it.label) + 7)
502    }
503    xs.push(x)
504  }
505  const last = names.length - 1
506  let total = X(last) + Math.ceil(W(last) / 2)
507  for (const it of items) if (it.kind === 'msg' && it.from === it.to && it.from === last) total = Math.max(total, X(last) + 5 + width(it.label))
508  if (total > avail) return [...title(attrs), ...seqList(names, items)]
509
510  const height = 3 + items.reduce((h, it) => h + (it.kind === 'divider' ? 1 : 2), 0) + 1
511  const canvas = new Canvas(total, height)
512  names.forEach((n, i) => {
513    canvas.box(X(i) - Math.floor(W(i) / 2), 0, W(i), n, { fg: 'inactive' }, { bold: true })
514    canvas.text(X(i), 2, '┬', { fg: 'inactive' })
515  })
516
517  let y = 3
518  for (const it of items) {
519    if (it.kind === 'divider') {
520      const text = it.label ? ` ${it.label} ` : ''
521      const start = Math.max(0, Math.floor((total - width(text)) / 2))
522      canvas.text(0, y, '┈'.repeat(total), DIM)
523      canvas.text(start, y, text, { fg: 'inactive', italic: true })
524      y += 1
525      continue
526    }
527    const color = it.tone ?? (it.isReply ? 'inactive' : 'suggestion')
528    const style: Style = { fg: color }
529    const a = X(it.from)
530    const b = X(it.to)
531    if (it.from === it.to) {
532      canvas.hline(y, a, a + 2, style)
533      canvas.vline(a + 2, y, y + 1, style)
534      canvas.text(a + 4, y, it.label, { fg: it.tone })
535      canvas.hline(y + 1, a + 1, a + 2, style)
536      canvas.text(a + 1, y + 1, '◄', style)
537    } else {
538      const lo = Math.min(a, b)
539      const hi = Math.max(a, b)
540      canvas.text(lo + 1 + Math.max(0, Math.floor((hi - lo - 1 - width(it.label)) / 2)), y, it.label, { fg: it.tone, italic: it.isReply })
541      // Stops short of the target's lifeline, so only the source joins it.
542      canvas.hline(y + 1, a, b < a ? b + 1 : b - 1, style)
543      if (it.isReply)
544        for (let x = lo + 1; x < hi; x++) if (!xs.includes(x)) canvas.text(x, y + 1, '╌', style)
545      canvas.text(b < a ? b + 1 : b - 1, y + 1, b < a ? '◄' : '►', style)
546    }
547    y += 2
548  }
549  // Lifelines last: they join what crosses them and fill every gap.
550  xs.forEach(x => canvas.vline(x, 3, height - 1, DIM))
551
552  return [...title(attrs), ...canvas.rows()]
553}
554
555function seqList(names: string[], items: SeqItem[]): Row[] {
556  return items.map((it): Row =>
557    it.kind === 'divider'
558      ? { runs: [{ ...DIM, italic: true, text: `── ${it.label} ──` }] }
559      : {
560          wrap: true,
561          runs: [
562            { bold: true, text: names[it.from] ?? '' },
563            { fg: it.tone ?? (it.isReply ? 'inactive' : 'suggestion'), text: it.isReply ? ' --> ' : ' → ' },
564            { bold: true, text: names[it.to] ?? '' },
565            ...(it.label ? [{ ...DIM, text: ': ' }, { fg: it.tone, text: it.label }] : []),
566          ],
567        },
568  )
569}
570
571// ── bars: a horizontal bar chart, optionally before → after ────────────
572
573export function bars(body: string, attrs: Attrs, avail: number): Row[] {
574  type Item = { label: string; a: number; b?: number; shown: string; tone?: string }
575  const items: Item[] = []
576  for (const line of lines(body)) {
577    const m = /^(.*?)\s*[:|]\s*([^:|]*)$/.exec(line) as [string, string, string] | null
578    if (!m) continue
579    const [rawValue, t] = splitTone(m[2])
580    const [before, after] = rawValue.split(/\s*(?:->|→)\s*/)
581    const a = num(before ?? '')
582    if (a === undefined) continue
583    const b = after === undefined ? undefined : num(after)
584    items.push({ label: m[1].trim(), a, b, shown: rawValue, tone: t })
585  }
586  if (items.length === 0) return []
587
588  const better = attrs.better
589  const unit = attrs.unit ?? ''
590  const max = Number(attrs.max) || Math.max(...items.flatMap(i => [i.a, i.b ?? 0]))
591  const labelW = Math.min(24, Math.max(...items.map(i => width(i.label))))
592  const shown = items.map(i => {
593    if (i.b === undefined) return [{ text: i.shown + unit }] as Run[]
594    const delta = i.a === 0 ? 0 : ((i.b - i.a) / Math.abs(i.a)) * 100
595    const good = better === 'lower' ? delta < 0 : better === 'higher' ? delta > 0 : undefined
596    const sign = delta > 0 ? '+' : delta < 0 ? '−' : '±'
597
598    return [
599      { ...DIM, text: `${fmt(i.a)}${unit} → ` },
600      { bold: true, text: `${fmt(i.b)}${unit}` },
601      { fg: good === undefined || delta === 0 ? 'inactive' : good ? 'success' : 'error', text: ` ${sign}${Math.abs(Math.round(delta))}%` },
602    ] as Run[]
603  })
604  const valueW = Math.max(...shown.map(runsWidth))
605  const cells = Math.max(6, Math.min(48, avail - labelW - valueW - 3))
606  const base = tone(attrs.tone) ?? 'claude'
607
608  const rows: Row[] = items.map((it, i) => {
609    const color = it.tone ?? (items.length > 1 && 'series' in attrs ? SERIES[i % SERIES.length] : base)
610    const head: Run = { text: pad(clip(it.label, labelW), labelW) + ' ' }
611    if (it.b === undefined) {
612      const fill = bar(max ? it.a / max : 0, cells)
613
614      return { runs: [head, { fg: color, text: fill }, { text: ' '.repeat(cells - width(fill) + 1) }, ...shown[i]!] }
615    }
616    // Before and after on one row: the after bar solid, the before as a
617    // faint trace past it when it was longer. With a trace, the solid bar
618    // keeps to whole cells: an eighth block would leave a gap before it.
619    const nowFrac = max ? it.b / max : 0
620    const wasCells = Math.round((max ? it.a / max : 0) * cells)
621    const solid = Math.max(it.b > 0 ? 1 : 0, Math.round(nowFrac * cells))
622    const hasTrace = wasCells > solid
623    const now = hasTrace ? '█'.repeat(solid) : bar(nowFrac, cells)
624    const trace = hasTrace ? wasCells - solid : 0
625
626    return {
627      runs: [
628        head,
629        { fg: color, text: now },
630        { ...DIM, text: '░'.repeat(trace) },
631        { text: ' '.repeat(Math.max(0, cells - width(now) - trace) + 1) },
632        ...shown[i]!,
633      ],
634    }
635  })
636
637  return [...title(attrs), ...rows]
638}
639
640// ── chart: a braille line chart ─────────────────────────────────────────
641
642const BRAILLE = [
643  [0x01, 0x08],
644  [0x02, 0x10],
645  [0x04, 0x20],
646  [0x40, 0x80],
647]
648
649export function chart(body: string, attrs: Attrs, avail: number): Row[] {
650  const series: Array<{ name: string; values: number[]; tone?: string }> = []
651  const loose: number[] = []
652  if (attrs.values) loose.push(...nums(attrs.values))
653  for (const line of lines(body)) {
654    const m = /^\s*([^:\d-][^:]*):\s*(.+)$/.exec(line) as [string, string, string] | null
655    if (m) {
656      const [values, t] = splitTone(m[2])
657      series.push({ name: m[1].trim(), values: nums(values), tone: t })
658    } else loose.push(...nums(line))
659  }
660  if (loose.length) series.unshift({ name: '', values: loose })
661  const live = series.filter(s => s.values.length > 0)
662  if (live.length === 0) return []
663
664  const every = live.flatMap(s => s.values)
665  const lo = attrs.min !== undefined ? Number(attrs.min) : 'zero' in attrs ? Math.min(0, ...every) : Math.min(...every)
666  const hi = attrs.max !== undefined ? Number(attrs.max) : Math.max(...every)
667  const span = hi - lo || 1
668  const rowsN = Math.max(2, Math.min(24, Number(attrs.height) || 8))
669  const unit = attrs.unit ?? ''
670  const labels = [fmt(hi) + unit, fmt((hi + lo) / 2) + unit, fmt(lo) + unit]
671  const labelW = Math.max(...labels.map(width))
672  const cols = Math.max(8, Math.min(Number(attrs.width) || 64, avail - labelW - 2))
673  const dotsW = cols * 2
674  const dotsH = rowsN * 4
675
676  const grid = Array.from({ length: rowsN }, () => Array.from({ length: cols }, () => ({ bits: 0, owner: -1, count: 0 })))
677  const dot = (x: number, y: number, s: number) => {
678    const cell = grid[Math.floor(y / 4)]?.[Math.floor(x / 2)]
679    if (!cell) return
680    const bit = BRAILLE[y % 4]![x % 2]!
681    if (!(cell.bits & bit)) {
682      cell.bits |= bit
683      if (cell.owner === -1 || s < cell.owner) cell.owner = s
684    }
685  }
686  live.forEach((s, si) => {
687    const pts = s.values.map((v, i): [number, number] => [
688      s.values.length === 1 ? 0 : Math.round((i * (dotsW - 1)) / (s.values.length - 1)),
689      dotsH - 1 - Math.round(((Math.max(lo, Math.min(hi, v)) - lo) / span) * (dotsH - 1)),
690    ])
691    pts.forEach(([x, y], i) => {
692      dot(x, y, si)
693      const next = pts[i + 1]
694      if (!next) return
695      const steps = Math.max(Math.abs(next[0] - x), Math.abs(next[1] - y))
696      for (let k = 1; k < steps; k++) dot(Math.round(x + ((next[0] - x) * k) / steps), Math.round(y + ((next[1] - y) * k) / steps), si)
697    })
698  })
699
700  const colorOf = (si: number) => live[si]?.tone ?? (live.length === 1 ? tone(attrs.tone) ?? 'claude' : SERIES[si % SERIES.length])
701  const rows: Row[] = grid.map((row, r) => {
702    const label = r === 0 ? labels[0] : r === rowsN - 1 ? labels[2] : rowsN >= 5 && r === Math.floor((rowsN - 1) / 2) ? labels[1] : ''
703    const runs: Run[] = [{ ...DIM, text: pad(label ?? '', labelW, 'right') + (label ? ' ┤' : ' │') }]
704    for (const cell of row) {
705      const ch = String.fromCharCode(0x2800 + cell.bits)
706      const fg = cell.bits ? colorOf(cell.owner) : undefined
707      const prev = runs[runs.length - 1]!
708      if (prev.fg === fg && runs.length > 1) prev.text += ch
709      else runs.push({ fg, text: ch })
710    }
711
712    return { runs }
713  })
714  rows.push({ runs: [{ ...DIM, text: ' '.repeat(labelW + 1) + '└' + '─'.repeat(cols) }] })
715
716  const xLabels = (attrs.x ?? '').split(',').map(s => s.trim()).filter(Boolean)
717  if (xLabels.length) {
718    const line = Array.from({ length: cols }, () => ' ')
719    let free = 0
720    xLabels.forEach((t, i) => {
721      const at = xLabels.length === 1 ? 0 : Math.round((i * (cols - 1)) / (xLabels.length - 1))
722      const start = Math.min(cols - width(t), i === 0 ? 0 : Math.max(0, at - Math.floor(width(t) / 2)))
723      if (start < free) return
724      ;[...t].forEach((ch, k) => (line[start + k] = ch))
725      free = start + width(t) + 1
726    })
727    rows.push({ runs: [{ ...DIM, text: ' '.repeat(labelW + 2) + line.join('').trimEnd() }] })
728  }
729  if (live.length > 1 || live[0]!.name) {
730    const legend: Run[] = [{ text: ' '.repeat(labelW + 2) }]
731    live.forEach((s, i) => {
732      if (i) legend.push({ text: '   ' })
733      legend.push({ fg: colorOf(i), text: '━━ ' }, { text: s.name || 'series' })
734    })
735    rows.push({ runs: legend, wrap: true })
736  }
737
738  return [...title(attrs), ...rows]
739}
740
741// ── heat: a heat map ────────────────────────────────────────────────────
742
743export function heat(body: string, attrs: Attrs, avail: number): Row[] {
744  const data: Array<{ label: string; values: number[] }> = []
745  for (const line of lines(body)) {
746    const m = /^\s*(.*?)\s*[:|]\s*(.+)$/.exec(line) as [string, string, string] | null
747    if (m) data.push({ label: m[1], values: nums(m[2]) })
748    else data.push({ label: '', values: nums(line) })
749  }
750  if (data.length === 0) return []
751
752  const header = (attrs.cols ?? attrs.x ?? '').split(',').map(s => s.trim()).filter(Boolean)
753  const every = data.flatMap(d => d.values)
754  const lo = attrs.min !== undefined ? Number(attrs.min) : Math.min(...every)
755  const hi = attrs.max !== undefined ? Number(attrs.max) : Math.max(...every)
756  const n = Math.max(...data.map(d => d.values.length))
757  const showValues = 'values' in attrs
758  const labelW = Math.min(20, Math.max(0, ...data.map(d => width(d.label))))
759  let cellW = Math.max(2, Math.min(5, ...[Math.max(0, ...header.map(width))].map(w => Math.max(w, 2))))
760  if (showValues) cellW = Math.max(cellW, ...every.map(v => width(fmt(v)) + 1))
761  while (cellW > 1 && labelW + 1 + cellW * n > avail) cellW -= 1
762  const stops = attrs.colors ? attrs.colors.split(',').map(s => s.trim()) : HEAT
763
764  const rows: Row[] = []
765  if (header.length) {
766    rows.push({ runs: [{ ...DIM, text: ' '.repeat(labelW + 1) + header.map(h => pad(clip(h, cellW), cellW, 'center')).join('') }] })
767  }
768  for (const d of data) {
769    const runs: Run[] = [{ text: pad(clip(d.label, labelW), labelW) + ' ' }]
770    for (let i = 0; i < n; i++) {
771      const v = d.values[i]
772      if (v === undefined) {
773        runs.push({ text: ' '.repeat(cellW) })
774        continue
775      }
776      const bg = ramp(stops, hi === lo ? 0.5 : (v - lo) / (hi - lo))
777      const text = showValues ? pad(fmt(v), cellW, 'center') : ' '.repeat(cellW)
778      runs.push({ bg, fg: luminance(bg) > 0.5 ? '#111111' : '#f5f5f5', text })
779    }
780    rows.push({ runs })
781  }
782  const legend: Run[] = [{ text: ' '.repeat(labelW + 1) }, { ...DIM, text: fmt(lo) + ' ' }]
783  for (let i = 0; i < 8; i++) legend.push({ bg: ramp(stops, i / 7), text: ' ' })
784  legend.push({ ...DIM, text: ' ' + fmt(hi) + (attrs.unit ?? '') })
785  rows.push({ runs: legend })
786
787  return [...title(attrs), ...rows]
788}
789
790// ── tree ────────────────────────────────────────────────────────────────
791
792// Deeper lines draw at this depth: its rails already fill 128 cells.
793const TREE_MAX_DEPTH = 32
794
795export function tree(body: string, attrs: Attrs): Row[] {
796  const raw = body.split('\n').filter(l => l.trim())
797  const indents = raw.map(l => /^[ \t]*/.exec(l)![0].replace(/\t/g, '    ').length)
798  const steps = indents.filter(i => i > 0)
799  const unit = steps.length ? Math.min(...steps) : 2
800  const items = raw.map((l, i) => ({ depth: Math.min(TREE_MAX_DEPTH, Math.round(indents[i]! / unit)), text: l.trim() }))
801
802  // Whether a later sibling follows each item: read from the bottom up,
803  // `below[d]` says a line at depth d has come since the last shallower one.
804  const hasNext: boolean[] = []
805  const below: boolean[] = []
806  for (let i = items.length - 1; i >= 0; i--) {
807    const d = items[i]!.depth
808    hasNext[i] = below[d] === true
809    below.length = d
810    below[d] = true
811  }
812
813  // From the top down, `open[d]` says the nearest line at depth d or
814  // shallower is at d and has a later sibling: its rail runs on down.
815  const open: boolean[] = []
816  const rows: Row[] = items.map((it, i) => {
817    const prefix: string[] = []
818    for (let d = 1; d < it.depth; d++) prefix.push(open[d] ? '│   ' : '    ')
819    if (it.depth > 0) prefix.push(hasNext[i] ? '├── ' : '└── ')
820    open.length = it.depth
821    open[it.depth] = hasNext[i]!
822
823    let text = it.text
824    let mark: Style = {}
825    const sigil = /^([+~!-])\s+/.exec(text) as [string, '+' | '~' | '!' | '-'] | null
826    if (sigil) {
827      mark = { fg: { '+': 'success', '-': 'error', '~': 'warning', '!': 'error' }[sigil[1]] }
828      text = text.slice(sigil[0].length)
829    }
830    // A tone may close the line or the name before its note.
831    const [line, lineTone] = splitTone(text)
832    const [main, comment] = splitOnce(line, /\s+(?:#|—|--)\s+/)
833    const [name, nameTone] = splitTone(main)
834    const t = nameTone ?? lineTone
835    const isDir = name.endsWith('/')
836    const style: Style = { ...mark, fg: t ?? mark.fg ?? (isDir ? 'suggestion' : undefined), bold: isDir || it.depth === 0 }
837    const runs: Run[] = [{ ...DIM, text: prefix.join('') }]
838    if (sigil) runs.push({ ...mark, text: sigil[1] + ' ' })
839    runs.push(...rich(name, style))
840    if (comment) runs.push({ text: '  ' }, ...rich(comment, DIM))
841
842    return { runs }
843  })
844
845  return [...title(attrs), ...rows]
846}
847
848// ── steps: a checklist with states ──────────────────────────────────────
849
850// Marks every terminal font draws as text: ✔ is a color emoji in Windows
851// Terminal and Ghostty, and conhost has no ◐ or ⊘.
852const STEP: Record<string, { glyph: string; style: Style; text?: Style }> = {
853  x: { glyph: '✓', style: { fg: 'success' } },
854  ' ': { glyph: '○', style: DIM },
855  '~': { glyph: '●', style: { fg: 'claude', bold: true }, text: { bold: true } },
856  '>': { glyph: '▸', style: { fg: 'claude', bold: true }, text: { bold: true } },
857  '!': { glyph: '✘', style: { fg: 'error', bold: true } },
858  '-': { glyph: '–', style: DIM, text: { fg: 'inactive', strike: true } },
859  '?': { glyph: '?', style: { fg: 'warning', bold: true } },
860}
861
862export function steps(body: string, attrs: Attrs): Row[] {
863  const items = lines(body).map(l => {
864    const m = /^\s*(?:[-*]\s+)?\[([ xX~>!?-])\]\s*(.*)$/.exec(l) as [string, string, string] | null
865
866    return m ? { state: m[1].toLowerCase(), text: m[2] } : { state: ' ', text: l.trim() }
867  })
868  const done = items.filter(i => i.state === 'x').length
869  const rows: Row[] = items.map(({ state, text }) => {
870    const s = STEP[state] ?? STEP[' ']!
871    const [line, t] = splitTone(text)
872    const [main, detail] = splitOnce(line, /\s+(?:—|--)\s+/)
873
874    return {
875      lead: [{ ...s.style, text: s.glyph + ' ' }],
876      runs: [...rich(main, { ...s.text, ...(t ? { fg: t } : {}) }), ...(detail ? [{ text: '  ' }, ...rich(detail, DIM)] : [])],
877      wrap: true,
878    }
879  })
880
881  return [...title(attrs, [{ ...DIM, text: `  ${done}/${items.length}` }]), ...rows]
882}
883
884// ── timeline ────────────────────────────────────────────────────────────
885
886export function timeline(body: string, attrs: Attrs): Row[] {
887  const items = lines(body).map(l => {
888    const m = (/^\s*(.+?)\s+(?:\||—|--)\s+(.*)$/.exec(l) ?? /^\s*([^:]+?):\s+(.*)$/.exec(l)) as [string, string, string] | null
889    const [what, t] = splitTone(m ? m[2] : l)
890
891    return { when: m ? m[1].trim() : '', what, tone: t }
892  })
893  const whenW = Math.min(20, Math.max(0, ...items.map(i => width(i.when))))
894  const rows: Row[] = []
895  items.forEach((it, i) => {
896    rows.push({
897      lead: [{ fg: it.tone ?? 'claude', text: '● ' }, { ...DIM, text: pad(clip(it.when, whenW), whenW) + '  ' }],
898      runs: rich(it.what, { fg: it.tone }),
899      wrap: true,
900    })
901    if (i < items.length - 1) rows.push({ runs: [{ ...DIM, text: '│' }] })
902  })
903
904  return [...title(attrs), ...rows]
905}
906
907// ── kv: aligned keys and values ─────────────────────────────────────────
908
909export function kv(body: string, attrs: Attrs): Row[] {
910  const items = lines(body).map(l => {
911    const m = (/^\s*([^:=]+?)\s*[:=]\s+(.*)$/.exec(l) ?? /^\s*([^:=]+?)\s*[:=](.*)$/.exec(l)) as [string, string, string] | null
912
913    return m ? { key: m[1], value: m[2] } : { key: '', value: l.trim() }
914  })
915  const keyW = Math.min(24, Math.max(0, ...items.map(i => width(i.key))))
916  const keyStyle: Style = { fg: tone(attrs.tone) ?? 'inactive' }
917
918  return [
919    ...title(attrs),
920    ...items.map(it => ({ lead: [{ ...keyStyle, text: pad(clip(it.key, keyW), keyW) + '  ' }], runs: rich(it.value), wrap: true })),
921  ]
922}
923
924// ── rule and banner ─────────────────────────────────────────────────────
925
926export function rule(body: string, attrs: Attrs, avail: number): Row[] {
927  const label = (attrs.title ?? body).trim()
928  const color = tone(attrs.tone) ?? 'inactive'
929  const w = Math.min(avail, Number(attrs.width) || avail)
930  if (!label) return [{ runs: [{ fg: color, text: '─'.repeat(w) }] }]
931  const text = rich(label, { bold: true, fg: tone(attrs.tone) })
932  const side = Math.max(2, w - runsWidth(text) - 2)
933
934  return [{ runs: [{ fg: color, text: '──' + ' ' }, ...text, { fg: color, text: ' ' + '─'.repeat(Math.max(0, side - 2)) }] }]
935}
936
937// A banner's spaced, gradient letters, its subtitle and its border color.
938export function bannerParts(body: string, attrs: Attrs): { text: string; letters: Run[]; sub?: string; border: Style } {
939  const text = (attrs.title ?? body).trim().toUpperCase()
940  const chars = [...[...text].join(' ')]
941  const from = hexOf(attrs.from, '#d77757')
942  const to = hexOf(attrs.to, '#af87ff')
943  const letters: Run[] = chars.map((ch, i) => ({ fg: mix(from, to, chars.length > 1 ? i / (chars.length - 1) : 0), bold: true, text: ch }))
944
945  return { text, letters, sub: attrs.sub ?? attrs.subtitle, border: { fg: tone(attrs.tone) ?? 'inactive' } }
946}
947
948export function banner(body: string, attrs: Attrs, avail: number): Row[] {
949  const { text, letters, sub, border } = bannerParts(body, attrs)
950  const spaced = letters.map(r => r.text).join('')
951  const inner = Math.min(avail - 2, Math.max(width(spaced), sub ? width(sub) : 0) + 6)
952  if (width(spaced) + 4 > avail - 2) return [{ runs: rich(text, { bold: true, fg: 'claude' }) }]
953  const lead = Math.floor((inner - width(spaced)) / 2)
954
955  const rows: Row[] = [
956    { runs: [{ ...border, text: '╭' + '─'.repeat(inner) + '╮' }] },
957    { runs: [{ ...border, text: '│' + ' '.repeat(lead) }, ...letters, { ...border, text: ' '.repeat(inner - lead - width(spaced)) + '│' }] },
958  ]
959  if (sub) {
960    const s = clip(sub, inner - 2)
961    const l = Math.floor((inner - width(s)) / 2)
962    rows.push({ runs: [{ ...border, text: '│' + ' '.repeat(l) }, { ...DIM, italic: true, text: s }, { ...border, text: ' '.repeat(inner - l - width(s)) + '│' }] })
963  }
964  rows.push({ runs: [{ ...border, text: '╰' + '─'.repeat(inner) + '╯' }] })
965
966  return rows
967}
968
969export const WIDGETS: Record<string, (body: string, attrs: Attrs, avail: number) => Row[]> = {
970  flow,
971  seq,
972  bars,
973  chart,
974  heat,
975  tree,
976  steps,
977  timeline,
978  kv,
979  rule,
980  banner,
981}
982
types/index.d.ts 12 lines
1// The decorated mod's session state.
2
3// When the last animation frame was asked for, in ms since the epoch.
4export type Tick = number
5
6declare module 'claude-code' {
7  interface PluginState {
8    // The replies playing an animated <svg> read it, so each write draws a frame.
9    decorated: { tick: Tick }
10  }
11}
12