"use client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
import { useAnimationLoop } from "@/hooks/use-animation-loop";
export type LedgerSeverity = "info" | "ok" | "warn" | "deny";
export interface LedgerEntry {
id: string;
source: string;
severity: LedgerSeverity;
text: string;
}
export interface LedgerProps {
entries?: LedgerEntry[];
speed?: number;
charsPerSecond?: number;
entryGap?: number;
jitter?: number;
maxEntries?: number;
group?: boolean;
showTimestamps?: boolean;
caret?: boolean;
loop?: boolean;
density?: "comfortable" | "compact";
accentColor?: string;
warnColor?: string;
denyColor?: string;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
function makeRandom(seed: number) {
let state = seed >>> 0 || 1;
return () => {
state ^= state << 13;
state >>>= 0;
state ^= state >>> 17;
state ^= state << 5;
state >>>= 0;
return state / 0xffffffff;
};
}
const RANK: Record<LedgerSeverity, number> = { info: 0, ok: 1, warn: 2, deny: 3 };
interface Row {
key: string;
entry: LedgerEntry;
stamp: number;
run: number;
}
export default function Ledger({
entries = [],
speed = 1,
charsPerSecond = 90,
entryGap = 520,
jitter = 0.35,
maxEntries = 9,
group = true,
showTimestamps = true,
caret = true,
loop = true,
density = "comfortable",
accentColor = "#a855f7",
warnColor = "#f0a830",
denyColor = "#f87171",
paused = false,
reducedMotion = false,
className,
}: LedgerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const scrollerRef = useRef<HTMLDivElement>(null);
const [rows, setRows] = useState<Row[]>([]);
const [chars, setChars] = useState(0);
const [behind, setBehind] = useState(0);
const [done, setDone] = useState(false);
const compact = density === "compact";
const halted = paused || reducedMotion;
const live = useRef({
entries,
speed,
charsPerSecond,
entryGap,
jitter,
maxEntries,
loop,
reducedMotion,
});
live.current = {
entries,
speed,
charsPerSecond,
entryGap,
jitter,
maxEntries,
loop,
reducedMotion,
};
const stuck = useRef(true);
const fresh = () => ({
total: 0,
chars: 0,
wait: 0,
clock: 0,
phase: "gap" as "type" | "gap",
rate: 1,
run: 0,
source: "",
random: makeRandom(0x5eed),
});
const stream = useRef(fresh());
useEffect(() => {
if (!reducedMotion) return;
const visible = entries.slice(-maxEntries);
let run = 0;
let source = "";
setRows(
visible.map((entry, i) => {
if (entry.source !== source) {
run += 1;
source = entry.source;
}
return { key: `r${i}`, entry, stamp: i * (entryGap / 1000), run };
}),
);
setChars(visible.length ? visible[visible.length - 1].text.length : 0);
setBehind(0);
setDone(true);
}, [reducedMotion, entries, maxEntries, entryGap]);
useEffect(() => {
if (reducedMotion) return;
stream.current = fresh();
setRows([]);
setChars(0);
setBehind(0);
setDone(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entries, reducedMotion]);
useEffect(() => {
if (loop) setDone(false);
}, [loop]);
const drawRef = useRef<((dt: number) => void | false) | null>(null);
drawRef.current = (dt: number) => {
const p = live.current;
if (p.entries.length === 0) return false;
if (p.reducedMotion) return false;
const s = stream.current;
const step = dt * p.speed;
s.clock += step;
if (s.phase === "type") {
const text = p.entries[(s.total - 1) % p.entries.length].text;
s.chars = Math.min(text.length, s.chars + p.charsPerSecond * s.rate * step);
if (s.chars >= text.length) {
s.phase = "gap";
s.wait = (p.entryGap / 1000) * (2 - s.rate);
}
setChars(Math.floor(s.chars));
return;
}
s.wait -= step;
if (s.wait > 0) return;
if (!p.loop && s.total >= p.entries.length) {
setDone(true);
return false;
}
const entry = p.entries[s.total % p.entries.length];
s.total += 1;
s.chars = 0;
s.phase = "type";
s.rate = Math.max(0.15, 1 + (s.random() * 2 - 1) * p.jitter);
if (entry.source !== s.source) {
s.run += 1;
s.source = entry.source;
}
const row: Row = { key: `e${s.total}`, entry, stamp: s.clock, run: s.run };
setRows((prev) => {
const next = prev.slice(Math.max(0, prev.length + 1 - p.maxEntries));
next.push(row);
return next;
});
setChars(0);
if (!stuck.current) setBehind((n) => n + 1);
};
const runtime = useAnimationLoop({
target: rootRef,
halted,
onFrame: ({ dt }) => (drawRef.current ? drawRef.current(dt) : false),
});
useEffect(() => {
if (!halted && !done) runtime.start();
}, [halted, done, entries, loop, runtime]);
const onScroll = () => {
const el = scrollerRef.current;
if (!el) return;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
stuck.current = atEnd;
if (atEnd) setBehind(0);
};
useLayoutEffect(() => {
const el = scrollerRef.current;
if (!el || !stuck.current) return;
el.scrollTop = el.scrollHeight;
}, [rows, chars]);
const catchUp = () => {
const el = scrollerRef.current;
if (!el) return;
stuck.current = true;
setBehind(0);
el.scrollTop = el.scrollHeight;
};
const tone = (severity: LedgerSeverity) =>
severity === "deny"
? denyColor
: severity === "warn"
? warnColor
: severity === "ok"
? accentColor
: "var(--sg-ink-mute)";
const blocks = useMemo(() => {
const out: Array<{ key: string; source: string; severity: LedgerSeverity; rows: Row[] }> =
[];
for (const row of rows) {
const head = out[out.length - 1];
if (group && head && head.key === `g${row.run}`) {
head.rows.push(row);
if (RANK[row.entry.severity] > RANK[head.severity]) {
head.severity = row.entry.severity;
}
continue;
}
out.push({
key: group ? `g${row.run}` : row.key,
source: row.entry.source,
severity: row.entry.severity,
rows: [row],
});
}
return out;
}, [rows, group]);
const newest = rows[rows.length - 1];
const running = !halted && !done && rows.length > 0;
return (
<div
ref={rootRef}
className={cn(
"flex h-full w-full min-h-0 flex-col overflow-hidden rounded-lg border border-hairline bg-panel",
className,
)}
>
<div className="flex shrink-0 items-center gap-2.5 border-b border-hairline px-3 py-2">
<motion.span
className="size-1.5 rounded-full"
style={{ backgroundColor: running ? accentColor : "var(--sg-ink-mute)" }}
animate={running ? { opacity: [1, 0.25, 1], scale: [1, 0.72, 1] } : { opacity: 1, scale: 1 }}
transition={
running
? { duration: 1.6, repeat: Infinity, ease: "easeInOut" }
: { duration: 0 }
}
aria-hidden
/>
<span className="font-display text-[10px] tracking-[0.24em] text-ink-mute uppercase">
{running ? "live" : done ? "complete" : "held"}
</span>
<span className="ml-auto font-mono text-[10px] tabular-nums text-ink-mute/70">
{rows.length} shown
</span>
</div>
<div className="relative min-h-0 flex-1">
<div
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-linear-to-b from-panel to-transparent"
aria-hidden
/>
<div
ref={scrollerRef}
onScroll={onScroll}
className={cn(
"h-full overflow-y-auto overscroll-contain",
compact ? "px-3 py-2" : "px-4 py-3",
)}
style={{ overflowAnchor: "none" }}
aria-live="polite"
aria-label="Agent activity"
>
{blocks.map((block) => (
<motion.div
key={block.key}
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={
reducedMotion
? { duration: 0 }
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] }
}
className={cn("border-l-2 pl-2.5", compact ? "mb-1.5" : "mb-2.5")}
style={{ borderColor: tone(block.severity) }}
>
<div className="flex items-baseline gap-2">
<span
className={cn(
"font-display tracking-[0.18em] uppercase",
compact ? "text-[9px]" : "text-[10px]",
)}
style={{ color: tone(block.severity) }}
>
{block.source}
</span>
{block.rows.length > 1 ? (
<span className="font-mono text-[10px] tabular-nums text-ink-mute/70">
×{block.rows.length}
</span>
) : null}
{showTimestamps ? (
<span className="ml-auto font-mono text-[10px] tabular-nums text-ink-mute/60">
+{block.rows[0].stamp.toFixed(1)}s
</span>
) : null}
</div>
{block.rows.map((row) => {
const typing = !done && row.key === newest?.key;
const text = typing ? row.entry.text.slice(0, chars) : row.entry.text;
return (
<p
key={row.key}
className={cn(
"font-mono leading-relaxed wrap-break-word text-ink-dim",
compact ? "text-[11px]" : "text-[12px]",
)}
>
{text}
{typing && text.length < row.entry.text.length ? (
<span className="sr-only">{row.entry.text.slice(chars)}</span>
) : null}
{typing && caret && !halted ? (
<motion.span
className="ml-px inline-block h-[1em] w-[0.5em] translate-y-[0.15em]"
style={{ backgroundColor: accentColor }}
animate={{ opacity: [1, 1, 0, 0] }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
aria-hidden
/>
) : null}
</p>
);
})}
</motion.div>
))}
</div>
<AnimatePresence>
{behind > 0 ? (
<motion.button
type="button"
onClick={catchUp}
initial={reducedMotion ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.2 }}
className="absolute inset-x-0 bottom-2 z-20 mx-auto w-fit rounded-full px-3 py-1 font-display text-[10px] tracking-[0.16em] text-on-accent uppercase shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-panel"
style={{ backgroundColor: accentColor }}
>
{behind} new ↓
</motion.button>
) : null}
</AnimatePresence>
</div>
</div>
);
}