the terminal's script: parsed, timed, and drawn as an SVG still without React
npx shadcn add @ag/terminal-sessionAlready installed? Preview an update
npx shadcn add @ag/terminal-session --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-09-30
sessionTimeline(…).clip: the session as a clip, one chapter per command titled with it, as typed (no$), markedcode.- New:
STILL, the still's frame and type in px (the defaults ofrenderSessionSvg), andstillSize(), the still's type as a share of its width incqw, so a live terminal in an inline-size container draws the frame the still draws.
# comments drop $ awake 2h ~ lid closed, on battery, no display @500 still working
3 lines · 1144 ms · 753 bytes of svg
the demo above, verbatim
import { Sample } from "@/app/samples"
import {
parseSession,
renderSessionSvg,
sessionTimeline,
} from "@/registry/base-nova/lib/terminal-session"
// #region session
const SESSION = `# comments drop
$ awake 2h
~ lid closed, on battery, no display
@500 still working`
// #endregion
export default function Demo() {
const lines = parseSession(SESSION)
const timeline = sessionTimeline(lines)
const svg = renderSessionSvg(lines, { accent: "#e7a13c" })
return (
<div className="space-y-12">
<Sample name="still" glance title="the still alone" with="session">
{/* biome-ignore lint/performance/noImgElement: an inline data URL */}
<img
alt="The session rendered as a still"
className="w-full rounded-xl border border-border"
src={`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`}
/>
</Sample>
<Sample name="script" title="a script, parsed and drawn" with="session">
<div className="space-y-6">
<pre className="rounded-xl border border-border bg-sidebar p-4 font-mono text-xs">
{SESSION}
</pre>
<div className="font-mono text-xs text-muted-foreground">
{lines.length} lines · {timeline.total} ms · {svg.length} bytes of
svg
</div>
{/* biome-ignore lint/performance/noImgElement: an inline data URL */}
<img
alt="The session rendered as a still"
className="w-full rounded-xl border border-border"
src={`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`}
/>
</div>
</Sample>
</div>
)
}the source, every file an install writes
// The terminal-session DSL: ONE source feeding two emitters, the animated web
// terminal (ui/terminal.tsx) and the static SVG still (renderSessionSvg, rasterized
// by a CLI). A session .txt doubles as an animation script: one line = one reveal
// step. Framework-free by contract: nothing here may import React or touch the DOM.
//
// The DSL, one line each (XML/HTML escaping is each emitter's job; sessions are
// written verbatim):
// $ cmd args prompt line: dim $, near-white command
// plain text output, full accent
// ~ text muted output (secondary info, refusals, metadata)
// # anything comment, dropped
// @800 <line> a pause of 800 ms before this line lands (any line kind)
// (blank) vertical breathing room
import type { Clip } from "@/registry/base-nova/lib/clip"
export type SessionLine = (
| { kind: "command"; text: string }
| { kind: "output"; text: string }
| { kind: "muted"; text: string }
| { kind: "blank" }
) & {
/** Milliseconds the emitter waits before this line lands. 0 when unset. */
delay: number
}
const DELAY = /^@(\d+)\s?(.*)$/
/** Parse a session script. Comments drop, trailing blanks collapse (the cursor line
* supplies the ending in both emitters). */
export function parseSession(source: string): SessionLine[] {
const lines = source.split("\n").filter((l) => !l.startsWith("#"))
while (lines.at(-1)?.trim() === "") lines.pop()
return lines.map((raw) => {
const m = DELAY.exec(raw)
const delay = m ? Number(m[1]) : 0
const line = m ? (m[2] as string) : raw
if (line.trim() === "") return { kind: "blank", delay }
if (line.startsWith("$ "))
return { kind: "command", text: line.slice(2), delay }
if (line.startsWith("~ "))
return { kind: "muted", text: line.slice(2), delay }
return { kind: "output", text: line, delay }
})
}
/** One accent, tiers by mixing: one hue, intensity is the only variable. */
function mix(
hex: string,
toward: readonly [number, number, number],
t: number,
): string {
const n = hex.replace("#", "")
const r = Number.parseInt(n.slice(0, 2), 16)
const g = Number.parseInt(n.slice(2, 4), 16)
const b = Number.parseInt(n.slice(4, 6), 16)
const c = (a: number, z: number) => Math.round(a + (z - a) * t)
return `#${[c(r, toward[0]), c(g, toward[1]), c(b, toward[2])]
.map((v) => v.toString(16).padStart(2, "0"))
.join("")}`
}
const BLACK = [0, 0, 0] as const
const WHITE = [255, 255, 255] as const
export interface TerminalPalette {
accent: string
bg: string
bar: string
dot: string
edge: string
prompt: string
muted: string
command: string
}
/** The phosphor palette every terminal wears (default accent: phosphor green).
* Mix ratios are LOAD-BEARING: committed stills were rendered with them, so a
* change here re-colors published media on next render. */
export function terminalPalette(accent = "#38ff6e"): TerminalPalette {
return {
accent,
bg: mix(accent, BLACK, 0.955),
bar: mix(accent, BLACK, 0.925),
dot: mix(accent, BLACK, 0.8),
// The window's hairline: the accent at low alpha, so the frame parts from any
// page (its near-black melts into a dark one) without a second colour. CSS only.
edge: `color-mix(in oklab, ${accent} 16%, transparent)`,
prompt: mix(accent, BLACK, 0.55),
muted: mix(accent, WHITE, 0.4),
command: mix(accent, WHITE, 0.82),
}
}
/** Line pitch shared by both emitters, in em. */
export const LINE_HEIGHT = 1.68
// ── the timeline: lines → milliseconds ──
//
// Commands type char by char; output lines land whole after a beat; blanks are a
// short breath; `@ms` adds a pause before the line. The unit is milliseconds, so a
// script's natural duration is the sum, and `progress` (0..1) scrubs it.
export const TYPE_MS = 28
const LAND_MS = 140
const BLANK_MS = 90
export interface SessionTimeline {
lines: SessionLine[]
/** Time (ms) at which each line STARTS landing (after its delay). */
starts: number[]
/** Time (ms) at which each line is complete. */
ends: number[]
total: number
/** The session as a clip, for any driver: one chapter per command, titled with
* it, starting as it starts to type (the first at 0). */
clip: Clip
}
export function sessionTimeline(lines: SessionLine[]): SessionTimeline {
let at = 0
const starts: number[] = []
const ends: number[] = []
for (const l of lines) {
at += l.delay
starts.push(at)
at +=
l.kind === "command"
? l.text.length * TYPE_MS + LAND_MS
: l.kind === "blank"
? BLANK_MS
: LAND_MS
ends.push(at)
}
const total = Math.max(1, at)
const chapters = lines
.flatMap((l, i) =>
l.kind === "command"
? [
{
start: (starts[i] as number) - l.delay,
title: l.text,
code: true,
},
]
: [],
)
.map((c, k) => (k === 0 ? { ...c, start: 0 } : c))
return {
lines,
starts,
ends,
total,
clip: { duration: total, chapters },
}
}
// ── the still: a session as one SVG frame ──
export interface StillOptions {
accent?: string
fontSize?: number
width?: number
height?: number
}
/** The still's frame and type, in px. */
export const STILL = { fontSize: 38, width: 1920, height: 1200 } as const
/** The type size at which the live terminal fills its box the way a still fills its
* frame: the still's type as a share of its width, in the box's own width. The box
* must be an inline-size container. */
export function stillSize(): string {
return `${(STILL.fontSize / STILL.width) * 100}cqw`
}
const esc = (s: string) =>
s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
/** Render a session as a phosphor terminal frame (title bar, three dots, the lines,
* a block-cursor prompt at the end). Throws when the session overflows the frame:
* cut lines or shrink the font, never clip silently. */
export function renderSessionSvg(
lines: SessionLine[],
{
accent = "#38ff6e",
fontSize = STILL.fontSize,
width = STILL.width,
height = STILL.height,
}: StillOptions = {},
): string {
const { bg, bar, dot, prompt, muted, command } = terminalPalette(accent)
const step = Math.round(fontSize * LINE_HEIGHT)
const x = 96
let y = 96 + Math.round(fontSize * 3.2)
const rows: string[] = []
for (const line of lines) {
if (line.kind === "blank") {
y += step
continue
}
if (line.kind === "command") {
rows.push(
`<text x="${x}" y="${y}" fill="${prompt}">$ <tspan fill="${command}">${esc(line.text)}</tspan></text>`,
)
} else {
const fill = line.kind === "muted" ? muted : accent
rows.push(
`<text x="${x}" y="${y}" fill="${fill}">${esc(line.text)}</text>`,
)
}
y += step
}
y += step
rows.push(
`<text x="${x}" y="${y}" fill="${prompt}">$ <tspan fill="${dot}">█</tspan></text>`,
)
if (y > height - 64) {
throw new Error(
`session overflows the frame (${y} > ${height - 64}): cut lines or shrink fontSize`,
)
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<rect width="${width}" height="${height}" fill="${bg}"/>
<rect width="${width}" height="96" fill="${bar}"/>
<circle cx="64" cy="48" r="13" fill="${dot}"/>
<circle cx="108" cy="48" r="13" fill="${dot}"/>
<circle cx="152" cy="48" r="13" fill="${dot}"/>
<g font-family="Menlo, Monaco, monospace" font-size="${fontSize}" xml:space="preserve">
${rows.join("\n ")}
</g>
</svg>
`
}
// Runnable example: scripts/examples/session-still.ts (the lib carries no runtime).