a stream of log lines that follows the tail: time, level, source, message
npx shadcn add @ag/logAlready installed? Preview an update
npx shadcn add @ag/log --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-14
- Its frame is
Panel's (framedecides as before). A glass log is no longer clipped to its corners, which cut its own shadow.
also called logs, log viewer, log stream
shelf data & status
uses button, material, panel, scrims, scroll-thumb, telemetry, toggle-group
used by deployment
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import {
LOG_LEVELS,
type LogLevel,
type LogLine,
} from "@/registry/base-nova/lib/telemetry"
import { Log } from "@/registry/base-nova/ui/log"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
// #region service
/** A web service's lines, made up the same way on every visit: line `n` is drawn
* from its own seed, so a stream can append line after line and stay the same. */
function seeded(seed: number) {
let s = seed >>> 0
return () => {
s = (s + 0x6d2b79f5) >>> 0
let t = Math.imul(s ^ (s >>> 15), s | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const pick = <T,>(r: () => number, xs: readonly T[]): T =>
xs[Math.floor(r() * xs.length)] as T
const PATHS = ["/api/orders", "/api/users", "/api/search", "/api/cart"]
const HOSTS = ["node-07", "node-12", "node-19"]
const REGIONS = ["fra1", "iad1", "sfo1"]
/** Line `n` of the service's log, written `after` the instant before it. */
function serviceLine(n: number, after: number): LogLine {
const r = seeded(n * 7919 + 17)
const at = after + Math.round(40 + r() * r() * 1400)
const id = `l${n}`
const request = Math.floor(r() * 0xffffff)
.toString(16)
.padStart(6, "0")
const roll = r()
if (roll < 0.035)
return {
id,
at,
level: "error",
source: "api",
message:
"TypeError: Cannot read properties of undefined (reading 'id')\n at total (/app/api/orders.ts:42:17)\n at handler (/app/api/orders.ts:18:9)",
fields: { request_id: request, path: "/api/orders", status: 500 },
}
if (roll < 0.06)
return {
id,
at,
level: "error",
source: "db",
message: "connection reset by peer (db-primary:5432)",
fields: { pool: "primary", attempt: 3, gave_up: true },
}
if (roll < 0.1)
return {
id,
at,
level: "warn",
source: "db",
message: `slow query ${Math.round(900 + r() * 1600)} ms on orders (select by customer_id)`,
fields: { table: "orders", rows: Math.round(r() * 40000), index: false },
}
if (roll < 0.14)
return {
id,
at,
level: "warn",
source: "worker",
message: `retrying job ${8000 + Math.floor(r() * 999)}, attempt 2 of 5`,
}
if (roll < 0.32)
return {
id,
at,
level: "debug",
source: pick(r, HOSTS),
message:
r() < 0.5
? `cache ${r() < 0.7 ? "hit" : "miss"} session:${request}`
: `pool: ${Math.round(4 + r() * 28)} of 32 connections in use`,
}
const path = pick(r, PATHS)
const status = r() < 0.92 ? 200 : 404
const ms = Math.round(8 + r() * r() * 600)
return {
id,
at,
level: "info",
source: "api",
message: `GET ${path} ${status} ${ms} ms`,
fields: {
method: "GET",
path,
status,
duration_ms: ms,
region: pick(r, REGIONS),
request_id: request,
},
}
}
/** When the made-up service starts writing: 2026-10-12, 14:03:07 UTC. */
const START = Date.UTC(2026, 9, 12, 14, 3, 7, 112)
/** The service's first `count` lines. */
function serviceLines(count: number): LogLine[] {
const out: LogLine[] = []
for (let n = 0; n < count; n++)
out.push(serviceLine(n, out.at(-1)?.at ?? START))
return out
}
const SERVICE = serviceLines(60)
const THOUSANDS = serviceLines(5000)
// #endregion
// #region stream
/** How many lines the stream writes before it stops, and the gap between two. */
const STREAM_LIMIT = 400
const STREAM_GAP_MS = [250, 1200] as const
/** The service writing live: a line every quarter to whole second while the log
* is on screen, until it has written `STREAM_LIMIT`. */
function useServiceStream(start: number) {
const [lines, setLines] = React.useState(() => serviceLines(start))
const [seen, setSeen] = React.useState(false)
const box = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
const el = box.current
if (!el) return
const watch = new IntersectionObserver(([entry]) =>
setSeen(entry?.isIntersecting ?? false),
)
watch.observe(el)
return () => watch.disconnect()
}, [])
React.useEffect(() => {
if (!seen || lines.length >= STREAM_LIMIT) return
const [least, most] = STREAM_GAP_MS
const gap = least + seeded(lines.length)() * (most - least)
const timer = window.setTimeout(
() =>
setLines((ls) => [
...ls,
serviceLine(ls.length, ls.at(-1)?.at ?? START),
]),
gap,
)
return () => window.clearTimeout(timer)
}, [seen, lines])
return { lines, box }
}
function Live() {
const { lines, box } = useServiceStream(40)
return (
<div ref={box}>
<Log lines={lines} title="api · production" className="h-96" />
</div>
)
}
// #endregion
// #region deploy
/** A build's log, its time counted from the build's start. */
const BUILD_START = Date.UTC(2026, 9, 12, 9, 41, 0, 0)
const sinceBuild = (at: number) => `+${((at - BUILD_START) / 1000).toFixed(3)}s`
const DEPLOY: LogLine[] = (
[
[0, "info", "build", "cloning github.com/adriangalilea/ui (main, d019155)"],
[412, "info", "build", "restored build cache from the last deploy"],
[1206, "info", "build", "pnpm install"],
[4630, "info", "build", "1,204 packages installed in 3.4s"],
[
4702,
"warn",
"build",
"peer dependency @types/react 19.2 wanted by 2 packages, 19.1 found",
],
[4911, "info", "build", "next build"],
[6040, "debug", "build", "compiling with 10 workers"],
[13288, "info", "build", "compiled 214 modules in 7.2s"],
[13512, "info", "build", "generating 96 static pages"],
[
15870,
"error",
"build",
"/media: image wide.jpg could not be read (ENOENT), the page falls back to no poster",
],
[17401, "info", "build", "96 of 96 pages generated"],
[18012, "info", "deploy", "uploading 1,412 files (38.2 MB)"],
[21904, "info", "deploy", "assigned to ui.adriangalilea.com"],
[22110, "info", "deploy", "ready in 22.1s"],
] as const
).map(([ms, level, source, message], n) => ({
id: `d${n}`,
at: BUILD_START + ms,
level,
source,
message,
...(source === "deploy" && ms === 22110
? { fields: { region: "fra1", functions: 14, cold_start_ms: 212 } }
: {}),
}))
// #endregion
// #region level
const FLOOR_LABEL: Record<LogLevel, string> = {
trace: "all",
debug: "debug+",
info: "info+",
warn: "warn+",
error: "errors",
}
/** The level kept by the page: its own choice beside the log, each with how many
* lines it shows, and the log's own choice turned off. */
function OwnLevel({ lines }: { lines: readonly LogLine[] }) {
const [floor, setFloor] = React.useState<LogLevel>("warn")
const shows = (level: LogLevel) =>
lines.filter(
(l) => LOG_LEVELS.indexOf(l.level) >= LOG_LEVELS.indexOf(level),
).length
return (
<div className="space-y-3">
<ToggleGroup
aria-label="lowest level shown"
value={floor}
onValueChange={(v) => setFloor(v as LogLevel)}
>
{LOG_LEVELS.map((level) => (
<ToggleGroupItem key={level} value={level}>
{FLOOR_LABEL[level]}
<span className="font-mono text-current/50 tabular-nums">
{shows(level)}
</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<Log
lines={lines}
minLevel={floor}
onMinLevelChange={setFloor}
filter={false}
className="h-80"
/>
</div>
)
}
// #endregion
export default function LogDemo() {
return (
<div className="space-y-8">
<Sample
name="service"
glance="wide"
title="a service's log"
with="service"
>
<Log
lines={SERVICE}
title="api · production"
className="h-[13.5rem] w-full"
/>
</Sample>
<Sample
name="live"
title="following the tail"
description="a line arrives every second or so while this is on screen. scroll up and it stops following, counting what arrives; jump to latest, or scroll back to the bottom, and it follows again."
with="service stream"
>
<Live />
</Sample>
<Sample
name="level"
title="the level, kept by the page"
description="the page holds the lowest level shown and chooses it beside the log; the log's own choice is off."
with="service level"
>
<OwnLevel lines={SERVICE} />
</Sample>
<Sample
name="glass"
title="on glass"
description="a build's log, its time counted from the build's start (formatTime)."
with="deploy"
>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6">
<Log
lines={DEPLOY}
title="deploy · ui"
formatTime={sinceBuild}
className="h-80"
/>
</div>
</MaterialProvider>
</Sample>
<Sample
name="thousands"
title="5,000 lines"
description="only the lines near the view are mounted."
with="service"
>
<Log lines={THOUSANDS} title="api · last hour" className="h-[28rem]" />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import { ArrowDown, ChevronRight } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
fieldText,
isRaw,
LOG_LEVELS,
type LogEntry,
type LogLevel,
type LogLine,
type LogVerb,
} from "@/registry/base-nova/lib/telemetry"
import { Button } from "@/registry/base-nova/ui/button"
import {
type Material,
MaterialProvider,
type Scheme,
TONE_WORDS,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassWords } from "@/registry/base-nova/ui/material-glass"
import { Panel, useInFrame } from "@/registry/base-nova/ui/panel"
import { ScrollThumbs } from "@/registry/base-nova/ui/scroll-thumb"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
import "./edge-fade"
// A STREAM OF LOG LINES, read as Vercel's and Datadog's log views are read: one line
// a record, its time, its level, what wrote it, then the message.
//
// <Log lines={lines} title="api · production" className="h-96" />
//
// A LINE is four columns on one template, so every line's columns start where every
// other's do: the time (mono, tabular, dimmed; an instant in ms, HH:MM:SS.mmm in the
// reader's own clock unless `formatTime` says otherwise), the level as its word
// (debug and info quiet, warn in the tokens' --tone-warn, error in --tone-bad, the
// line washed in the same tone so a bad one is found while scrolling), the source
// (dim) and the message (mono, wrapped in full, never cut). The columns are sized in
// characters from every line the log holds, never the ones in view, so scrolling
// never moves them. Narrower than the fixed columns plus 40 characters of message,
// the message goes under its line's time instead of beside it.
//
// A line with `fields` opens (the chevron before it, a button, so Tab and Enter reach
// it) to show its key and value pairs under it, a description list. The chevron is
// drawn here, not a Button: a line is 24 px and the smallest Button 28, as a tree
// row's marks are its own. Opening a line is reading it, so following stops.
//
// IT FOLLOWS THE TAIL. While the reader is at the bottom, a new line keeps them at the
// bottom. Scrolling up (a wheel, a drag of the thumb, a key) lets go: what is in view
// stays where it is while lines arrive, trimmed or are filtered away, and a "jump to
// latest" button counts what arrived since; pressing it, or scrolling back to the
// bottom (End), follows again.
//
// THE LEVEL. A minimum (all, info+, warn+, errors), the consumer's (`minLevel` with
// `onMinLevelChange`) or the log's own (`defaultMinLevel`), chosen in the log's head
// unless `filter={false}`.
//
// THOUSANDS OF LINES. Only the lines near the view are mounted (a window of the view
// and a screen's worth round it). A line's height is measured once it is mounted and
// kept by its id; one never mounted is the height of the shortest line measured (one
// line of message). A line measured taller than its guess above the view moves the
// scroll by the difference, so nothing in view jumps. Give the log a height.
//
// The view hides the browser's bar and wears scroll-thumb's thumbs, and fades its cut
// edges (scrims' `ag-edge-fade-y`, read from the scroll: no fade where there is no
// more). It is not an ARIA live region: a windowed list mounts rows as it scrolls,
// and a live region would read each of them out.
//
// Plain, it draws a hairline box on whatever holds it. `material="glass"` (or a glass
// provider round it) is a surface of the material's glass; what is inside (the level
// choice, the jump button) follows it. Inside a frame already drawn (a panel, a tabs
// box given a `height`) it draws no box of its own and fills what it is given.
const RANK: Record<LogLevel, number> = {
trace: 0,
debug: 1,
info: 2,
warn: 3,
error: 4,
}
/** The level choice in the head: the lowest level shown, by what it shows. */
const FLOORS: { level: LogLevel; label: string }[] = [
{ level: "trace", label: "all" },
{ level: "info", label: "info+" },
{ level: "warn", label: "warn+" },
{ level: "error", label: "errors" },
]
/** The level's word: quiet for the routine, the tokens' tones for what is off. */
const LEVEL_WORD: Record<LogLevel, string> = {
trace: "text-current/30",
debug: "text-current/35",
info: "text-current/55",
warn: TONE_WORDS.warn,
error: TONE_WORDS.bad,
}
/** The line's wash, so a bad line is found while scrolling past it, and a step
* more under the hand: a toned line keeps its tone when hovered. */
const LEVEL_WASH: Record<LogLevel, string> = {
trace: "hover:bg-current/4",
debug: "hover:bg-current/4",
info: "hover:bg-current/4",
warn: "bg-tone-warn/8 hover:bg-tone-warn/14",
error: "bg-tone-bad/9 hover:bg-tone-bad/15",
}
/** The outcome a verb says, marked before the message; a plain line has none. */
const VERB_MARK: Partial<Record<LogVerb, { mark: string; tone: string }>> = {
success: { mark: "✓", tone: TONE_WORDS.ok },
fail: { mark: "⨯", tone: TONE_WORDS.bad },
wait: { mark: "○", tone: "text-current/50" },
ready: { mark: "▶", tone: TONE_WORDS.ok },
step: { mark: "•", tone: "text-current/40" },
}
/** An ended span's length as its line says it: "840 µs", "254 ms", "1.24 s". */
function tookWords(ms: number): string {
if (ms < 1) return `${Math.round(ms * 1000)} µs`
if (ms < 1000) return `${ms < 10 ? ms.toFixed(1) : Math.round(ms)} ms`
return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`
}
/** Every column but the message, in characters: the chevron's, the level's (its
* longest word), the gap between two columns and the padding at each side. */
const CHEVRON_CH = 2
const LEVEL_CH = 5
const GAP_CH = 1.5
const PAD_CH = 1
/** The least message beside a line's fixed columns; with less room it goes under. */
const MESSAGE_MIN_CH = 40
/** How far past the view the window mounts lines, above and below, px. */
const OVERSCAN_PX = 480
/** How close to the bottom still counts as at the bottom, px (a scroll position is
* fractional on a scaled display). */
const BOTTOM_PX = 4
/** A line's height before any is measured: one 20 px line and its padding, px. */
const FIRST_GUESS_PX = 24
const pad = (n: number, width: number) => String(n).padStart(width, "0")
/** HH:MM:SS.mmm in the reader's own clock: the default `formatTime`. */
export function formatLogTime(at: number): string {
const d = new Date(at)
return `${pad(d.getHours(), 2)}:${pad(d.getMinutes(), 2)}:${pad(d.getSeconds(), 2)}.${pad(d.getMilliseconds(), 3)}`
}
const COUNT = new Intl.NumberFormat("en-US")
/** The first index of a sorted `xs` whose value is at least `v`. */
function lowerBound(xs: ArrayLike<number>, v: number): number {
let lo = 0
let hi = xs.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if ((xs[mid] as number) < v) lo = mid + 1
else hi = mid
}
return lo
}
export type LogProps = Omit<
React.ComponentProps<"div">,
"children" | "title"
> & {
/** The lines, oldest first (lib/telemetry): events, and lines of a file that are no
* event, kept where they fell. A new one is appended; old ones may be trimmed. */
lines: readonly LogEntry[]
/** What the log is, at the head's start ("api · production"). */
title?: React.ReactNode
/** The lowest level shown, the consumer's: with `onMinLevelChange`. */
minLevel?: LogLevel
/** The lowest level shown at first, when the log keeps it. Default all. */
defaultMinLevel?: LogLevel
onMinLevelChange?: (level: LogLevel) => void
/** The chosen line (controlled), null for none: shown whatever the level, marked,
* and brought into view when it is chosen from outside (a trace's details). */
selected?: string | null
defaultSelected?: string | null
/** A line chosen by a press on it, or let go by a second press. */
onSelectedChange?: (id: string | null) => void
/** A span whose lines stand out (a trace's chosen span), null for none. */
span?: string | null
/** A line's span chosen from the line (its chip): where a trace beside the log
* goes to it. Without it a line's span is named, not a way anywhere. */
onSpanChange?: (id: string) => void
/** The level choice in the head. Default shown. */
filter?: boolean
/** A line's time from its instant. Default `formatLogTime`, HH:MM:SS.mmm. */
formatTime?: (at: number) => string
/** The view's name for assistive tech. Default "log". */
label?: string
/** Its own box (a hairline, its corners; in glass, a surface). Default drawn,
* except inside a frame already drawn (a panel, a tabs box: panel's `InFrame`). */
frame?: boolean
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
}
interface View {
top: number
height: number
width: number
/** One character of the lines' mono voice, px. */
ch: number
}
export function Log({
lines,
title,
minLevel,
defaultMinLevel = "trace",
onMinLevelChange,
selected,
defaultSelected = null,
onSelectedChange,
span,
onSpanChange,
filter = true,
formatTime = formatLogTime,
label = "log",
frame,
material,
scheme,
className,
style,
ref,
...props
}: LogProps) {
const m = useMaterial(material)
const inFrame = useInFrame()
const framed = frame ?? !inFrame
const [s, here] = useSurfaceScheme<HTMLElement>(scheme, ref)
const [ownFloor, setOwnFloor] = React.useState(defaultMinLevel)
const floor = minLevel ?? ownFloor
const setFloor = (level: LogLevel) => {
if (minLevel === undefined) setOwnFloor(level)
onMinLevelChange?.(level)
}
// ── What the log holds: every line checked once, and its columns' widths in
// characters, read from every line so scrolling never moves a column.
const columns = React.useMemo(() => {
const ids = new Set<string>()
let timeCh = 0
let sourceCh = 0
for (const line of lines) {
if (ids.has(line.id))
throw new Error(`log: two lines have id "${line.id}"`)
ids.add(line.id)
if (isRaw(line)) continue
if (RANK[line.level] === undefined)
throw new Error(`log: line "${line.id}" has no level "${line.level}"`)
if (!Number.isFinite(line.at))
throw new Error(
`log: line "${line.id}" has no instant (at: ${line.at})`,
)
timeCh = Math.max(timeCh, formatTime(line.at).length)
sourceCh = Math.max(sourceCh, line.source?.length ?? 0)
}
return { ids, timeCh, sourceCh }
}, [lines, formatTime])
const { timeCh, sourceCh } = columns
const sourced = sourceCh > 0
/** The fixed columns, gaps and padding: a line's width less its message. */
const fixedCh =
CHEVRON_CH +
timeCh +
LEVEL_CH +
(sourced ? sourceCh + GAP_CH : 0) +
3 * GAP_CH +
2 * PAD_CH
// ── The chosen line, controlled or not.
if (selected != null && !columns.ids.has(selected))
throw new Error(`log: selected "${selected}" is no line of the log`)
const [ownSelected, setOwnSelected] = React.useState(defaultSelected)
const chosenId = selected !== undefined ? selected : ownSelected
const choose = (id: string | null) => {
if (selected === undefined) setOwnSelected(id)
onSelectedChange?.(id)
}
/** The shown lines, as indices into `lines`, in order: those at the floor or
* above, every line that is no event (nothing says it is quiet), and the chosen
* line, so a line chosen from outside is there to see. */
const shown = React.useMemo(() => {
const out: number[] = []
lines.forEach((line, i) => {
if (
isRaw(line) ||
line.id === chosenId ||
RANK[line.level] >= RANK[floor]
)
out.push(i)
})
return out
}, [lines, floor, chosenId])
const scroller = React.useRef<HTMLDivElement>(null)
const probe = React.useRef<HTMLSpanElement>(null)
const [view, setView] = React.useState<View>({
top: 0,
height: 0,
width: 0,
ch: 0,
})
const narrow = view.ch > 0 && view.width / view.ch < fixedCh + MESSAGE_MIN_CH
// ── Following: on while the reader is at the bottom.
const [following, setFollowing] = React.useState(true)
const followingNow = React.useRef(following)
followingNow.current = following
/** The last line shown when following stopped: what "new" is counted after. */
const [pausedAfter, setPausedAfter] = React.useState<string | null>(null)
const lastShown = React.useRef<string | null>(null)
lastShown.current =
shown.length > 0 ? (lines[shown.at(-1) as number] as LogLine).id : null
// ── Heights: measured per mounted line, kept by id. A line never measured is
// guessed at the shortest measured (one line of message: two in the narrow
// layout), or at FIRST_GUESS_PX before any is.
const heights = React.useRef(new Map<string, number>())
const shortest = React.useRef<number | null>(null)
const mounted = React.useRef(new Map<string, HTMLElement>())
const [measured, setMeasured] = React.useState(0)
const [rows, setRows] = React.useState<ResizeObserver | null>(null)
/** Keeps a mounted line's height; says how far it is from what was assumed. */
const keep = React.useCallback((id: string, h: number): number => {
const was = heights.current.get(id) ?? shortest.current ?? FIRST_GUESS_PX
heights.current.set(id, h)
shortest.current = Math.min(shortest.current ?? h, h)
return h - was
}, [])
const offsets = React.useMemo(() => {
// `measured` is the cache's version: the heights it read changed.
void measured
const guess = shortest.current ?? FIRST_GUESS_PX
const out = new Float64Array(shown.length + 1)
shown.forEach((i, k) => {
const id = (lines[i] as LogLine).id
out[k + 1] = (out[k] as number) + (heights.current.get(id) ?? guess)
})
return out
}, [shown, lines, measured])
const total = offsets[shown.length] as number
// Lines trimmed off the log leave the cache once they outnumber the ones it holds.
React.useEffect(() => {
if (heights.current.size <= 2 * columns.ids.size) return
heights.current = new Map(
[...heights.current].filter(([id]) => columns.ids.has(id)),
)
}, [columns])
// ── The window: the lines within a screen's worth of the view, nothing before the
// view is measured (and so nothing on the server).
const first =
view.height === 0
? 0
: Math.max(0, lowerBound(offsets, view.top - OVERSCAN_PX + 1) - 1)
const last =
view.height === 0
? 0
: Math.min(
shown.length,
lowerBound(offsets, view.top + view.height + OVERSCAN_PX),
)
// The view's size and the lines' character, and the observer every mounted line
// reports its height to.
React.useLayoutEffect(() => {
const el = scroller.current
const one = probe.current
if (!el || !one) throw new Error("log: the view is not mounted")
const remeasure = () => {
heights.current = new Map()
shortest.current = null
for (const [id, row] of mounted.current)
keep(id, row.getBoundingClientRect().height)
setMeasured((v) => v + 1)
}
const size = new ResizeObserver(() => {
const ch = one.getBoundingClientRect().width
const next = { height: el.clientHeight, width: el.clientWidth, ch }
setView((v) =>
v.height === next.height && v.width === next.width && v.ch === ch
? v
: { ...v, ...next, top: el.scrollTop },
)
})
size.observe(el)
let width = el.clientWidth
const lineSizes = new ResizeObserver((entries) => {
// A new width rewraps every message: the cache is the old width's.
if (el.clientWidth !== width) {
width = el.clientWidth
remeasure()
return
}
const viewTop = el.getBoundingClientRect().top
let changed = false
let shift = 0
for (const entry of entries) {
const row = entry.target as HTMLElement
const id = row.dataset.id
if (id === undefined) throw new Error("log: a measured line has no id")
if (!row.isConnected) continue
const moved = keep(
id,
entry.borderBoxSize[0]?.blockSize ?? row.offsetHeight,
)
if (Math.abs(moved) < 0.5) continue
changed = true
// A line above the view grew or shrank: what is in view stays put.
if (row.getBoundingClientRect().top < viewTop) shift += moved
}
if (!changed) return
if (followingNow.current) el.scrollTop = el.scrollHeight
else if (shift !== 0) el.scrollTop += shift
setMeasured((v) => v + 1)
})
setRows(lineSizes)
return () => {
size.disconnect()
lineSizes.disconnect()
}
}, [keep])
// ── Holding the view. First the lines this render mounted are measured, before
// anything is placed by them: placed by guesses, the pin to the bottom moved the
// window, which mounted lines taller than guessed, which moved the bottom, and the
// two chased each other (a phone's two-line layout looped until React gave up).
// A changed height renders again with the true offsets. Following, the view is
// pinned to the bottom. Not following, the first line in view keeps its place
// whatever changed above it (lines trimmed, a level filtered away): found again by
// id, or the next line still shown after it. Its offset is a guess until it and the
// lines round it are mounted, so it is HELD: scrolled to the guess, then, pass by
// pass, to where the mounted line really is, until no height changes.
const anchor = React.useRef<{ id: string; into: number } | null>(null)
const held = React.useRef<{ id: string; into: number } | null>(null)
const anchoredShown = React.useRef(shown)
React.useLayoutEffect(() => {
const el = scroller.current
if (!el || view.height === 0) return
const viewTop = el.getBoundingClientRect().top
let changed = false
let shift = 0
for (const [id, row] of mounted.current) {
const box = row.getBoundingClientRect()
const moved = keep(id, box.height)
if (Math.abs(moved) < 0.5) continue
changed = true
if (box.top < viewTop) shift += moved
}
if (following) {
held.current = null
const bottom = el.scrollHeight - el.clientHeight
if (!changed && el.scrollTop < bottom - 0.5) el.scrollTop = bottom
} else {
if (anchoredShown.current !== shown && anchor.current) {
const { id, into } = anchor.current
const full = lines.findIndex((line) => line.id === id)
const k = full === -1 ? -1 : lowerBound(shown, full)
const at = shown[k]
held.current =
at === undefined
? null
: { id: (lines[at] as LogLine).id, into: at === full ? into : 0 }
if (k !== -1)
el.scrollTop = (offsets[k] as number) + (held.current?.into ?? 0)
}
const hold = held.current
const row = hold && mounted.current.get(hold.id)
if (hold && row)
el.scrollTop += row.getBoundingClientRect().top - viewTop + hold.into
else if (hold) {
// Not mounted yet: aimed again at its offset, truer with every pass.
const k = shown.findIndex((i) => (lines[i] as LogLine).id === hold.id)
el.scrollTop = (offsets[k] as number) + hold.into
} else if (shift !== 0) el.scrollTop += shift
if (!changed && row) held.current = null
}
anchoredShown.current = shown
const top = el.scrollTop
if (top !== view.top) setView((v) => ({ ...v, top }))
if (changed) {
setMeasured((v) => v + 1)
return
}
const k = Math.max(0, lowerBound(offsets, top + 0.5) - 1)
const at = shown[k]
anchor.current =
at === undefined
? null
: { id: (lines[at] as LogLine).id, into: top - (offsets[k] as number) }
})
// ── A line chosen from outside (a trace's details naming it) is brought to the
// middle of the view and held there, as reading it: following stops. One chosen
// here by a press is where the hand already is, and stays put. After the hold
// above, so it wins over the anchor kept from before the line was shown.
const chosenHere = React.useRef<string | null>(null)
React.useLayoutEffect(() => {
const el = scroller.current
if (chosenId === null || chosenId === chosenHere.current || !el) return
if (followingNow.current) {
setFollowing(false)
setPausedAfter(lastShown.current)
}
const line = shortest.current ?? FIRST_GUESS_PX
held.current = {
id: chosenId,
into: -Math.round((el.clientHeight - line) / 2),
}
chosenHere.current = chosenId
}, [chosenId])
// A press on a line chooses it, a second lets it go. Memoized for the rows, so it
// reads the choice as it is now.
const chosenIdNow = React.useRef(chosenId)
chosenIdNow.current = chosenId
const chooseNow = React.useRef(choose)
chooseNow.current = choose
const pick = React.useCallback((id: string) => {
chosenHere.current = id
chooseNow.current(id === chosenIdNow.current ? null : id)
}, [])
// A wheel over the jump button scrolls the view under it. The browser sends it
// to the button's ancestors, which the view is not; a line of a wheel is a line
// of the log, a page the view's height.
const [latest, setLatest] = React.useState<HTMLElement | null>(null)
React.useEffect(() => {
const el = scroller.current
if (!latest || !el) return
const onWheel = (event: WheelEvent) => {
event.preventDefault()
const unit =
event.deltaMode === WheelEvent.DOM_DELTA_LINE
? (shortest.current ?? FIRST_GUESS_PX)
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? el.clientHeight
: 1
el.scrollBy({ top: event.deltaY * unit })
}
latest.addEventListener("wheel", onWheel, { passive: false })
return () => latest.removeEventListener("wheel", onWheel)
}, [latest])
const lastTop = React.useRef(0)
const onScroll = () => {
const el = scroller.current
if (!el) return
const top = el.scrollTop
const atBottom = el.scrollHeight - top - el.clientHeight <= BOTTOM_PX
const rose = top < lastTop.current - 0.5
lastTop.current = top
setView((v) => (v.top === top ? v : { ...v, top }))
if (atBottom && !following) setFollowing(true)
else if (rose && !atBottom && following) {
setFollowing(false)
setPausedAfter(lastShown.current)
}
}
// Opening a line is reading it: following stops, so the line stays where it is
// while more arrive under it.
const [open, setOpen] = React.useState<ReadonlySet<string>>(new Set())
const toggle = React.useCallback((id: string, opening: boolean) => {
setOpen((was) => {
const next = new Set(was)
if (opening) next.add(id)
else next.delete(id)
return next
})
if (opening && followingNow.current) {
setFollowing(false)
setPausedAfter(lastShown.current)
}
}, [])
const arrived = (() => {
if (following || pausedAfter === null) return 0
const at = shown.findIndex((i) => (lines[i] as LogLine).id === pausedAfter)
return at === -1 ? 0 : shown.length - 1 - at
})()
const template = narrow
? `${CHEVRON_CH}ch ${timeCh}ch ${LEVEL_CH}ch minmax(0,1fr)`
: [
`${CHEVRON_CH}ch`,
`${timeCh}ch`,
`${LEVEL_CH}ch`,
...(sourced ? [`${sourceCh}ch`] : []),
"minmax(0,1fr)",
].join(" ")
const inWindow: number[] = []
for (let k = first; k < last; k++) inWindow.push(k)
return (
// A Panel (the one container), its surface drawn only where no frame is drawn
// round it already. Clipped to its corners, except a glass frame's: its
// shadow lies on its back layer, which a clip would cut.
<Panel
render={<div />}
frame={framed}
ref={here}
data-slot="log"
material={m}
scheme={s}
radius={m === "glass" ? "1rem" : undefined}
data-following={following || undefined}
className={cn(
"flex min-h-0 flex-col gap-0 p-0 text-[13px]",
!(framed && m === "glass") && "overflow-clip",
className,
)}
style={
{
...style,
"--ag-log-cols": template,
"--ag-log-gap": `${GAP_CH}ch`,
"--ag-log-pad": `${PAD_CH}ch`,
} as React.CSSProperties
}
{...props}
>
<MaterialProvider material={m} scheme={s}>
{(title || filter) && (
<header
data-slot="log-head"
// Narrow, the level choice wraps under the title and the count whole.
className="flex min-h-10 shrink-0 flex-wrap items-center justify-end gap-x-3 gap-y-1.5 border-current/10 border-b py-1.5 pr-1.5 pl-3"
>
{title && (
<div
data-slot="log-title"
className="mr-auto min-w-0 font-medium leading-5"
>
{title}
</div>
)}
<span
data-slot="log-count"
className="ml-auto shrink-0 whitespace-nowrap font-mono text-[11px] text-current/50 tabular-nums"
>
{shown.length === lines.length
? `${COUNT.format(lines.length)} ${lines.length === 1 ? "line" : "lines"}`
: `${COUNT.format(shown.length)} of ${COUNT.format(lines.length)}`}
</span>
{filter && (
<ToggleGroup
size="sm"
aria-label="lowest level shown"
value={floor}
onValueChange={(v) => {
const level = LOG_LEVELS.find((l) => l === v)
if (!level) throw new Error(`log: no level "${v}"`)
setFloor(level)
}}
>
{FLOORS.map((f) => (
<ToggleGroupItem key={f.level} value={f.level}>
{f.label}
</ToggleGroupItem>
))}
</ToggleGroup>
)}
</header>
)}
{/* The view's focus is drawn on the body, inside the log's edge: on the view
itself the edge fade would fade it and the log's clip cut it. */}
<div
data-slot="log-body"
className="relative min-h-0 flex-1 rounded-b-[inherit] has-[>[data-slot=log-view]:focus-visible]:focus-ring has-[>[data-slot=log-view]:focus-visible]:-outline-offset-2"
>
<section
ref={scroller}
data-slot="log-view"
aria-label={label}
// biome-ignore lint/a11y/noNoninteractiveTabindex: a scroller the keyboard scrolls (arrows, Page Up and Down, End to follow again)
tabIndex={0}
onScroll={onScroll}
className="ag-edge-fade-y absolute inset-0 font-mono text-xs outline-none [overflow-anchor:none] [--edge-fade:1.5rem]"
>
<ScrollThumbs scroller={scroller} />
<span
ref={probe}
aria-hidden
className="pointer-events-none invisible absolute h-0 w-[1ch]"
/>
<div
data-slot="log-lines"
className="relative h-(--ag-log-total)"
style={{ "--ag-log-total": `${total}px` } as React.CSSProperties}
>
<ol
className="absolute inset-x-0 top-(--ag-log-first) m-0 list-none p-0"
style={
{
"--ag-log-first": `${offsets[first]}px`,
} as React.CSSProperties
}
>
{inWindow.map((k) => {
const line = lines[shown[k] as number] as LogEntry
return (
<LogRow
key={line.id}
line={line}
position={k + 1}
count={shown.length}
open={open.has(line.id)}
chosen={line.id === chosenId}
inSpan={
span != null && !isRaw(line) && line.span?.id === span
}
onToggle={toggle}
onPick={pick}
onSpan={onSpanChange}
formatTime={formatTime}
sourced={sourced}
narrow={narrow}
glass={m === "glass"}
observer={rows}
mounted={mounted.current}
/>
)
})}
</ol>
</div>
</section>
{/* Beside the view, not in it: the view's fade is a mask, and glass inside
a masked element frosts nothing (the mask makes it a backdrop root).
A wheel over it scrolls the view (`latest`'s listener), as over a line. */}
{!following && (
<Button
ref={setLatest}
data-slot="log-latest"
size="sm"
corners="pill"
onClick={() => setFollowing(true)}
className="absolute bottom-8 left-1/2 -translate-x-1/2 shadow-md"
>
<ArrowDown />
jump to latest
{arrived > 0 && (
<span className="font-mono tabular-nums opacity-70">
{COUNT.format(arrived)} new
</span>
)}
</Button>
)}
</div>
</MaterialProvider>
</Panel>
)
}
const LogRow = React.memo(function LogRow({
line,
position,
count,
open,
chosen,
inSpan,
onToggle,
onPick,
onSpan,
formatTime,
sourced,
narrow,
glass,
observer,
mounted,
}: {
line: LogEntry
position: number
count: number
open: boolean
chosen: boolean
inSpan: boolean
onToggle: (id: string, opening: boolean) => void
onPick: (id: string) => void
onSpan: ((id: string) => void) | undefined
formatTime: (at: number) => string
sourced: boolean
narrow: boolean
glass: boolean
observer: ResizeObserver | null
/** The log's mounted lines by id, read when a new width rewraps them all. */
mounted: Map<string, HTMLElement>
}) {
const row = React.useRef<HTMLLIElement>(null)
React.useLayoutEffect(() => {
const el = row.current
if (!el || !observer) return
observer.observe(el)
mounted.set(line.id, el)
return () => {
observer.unobserve(el)
mounted.delete(line.id)
}
}, [observer, mounted, line.id])
const fieldsId = React.useId()
if (isRaw(line))
return (
<li
ref={row}
aria-posinset={position}
aria-setsize={count}
data-slot="log-line"
data-id={line.id}
data-raw=""
className="px-(--ag-log-pad) py-0.5 leading-5"
>
<span
data-slot="log-raw"
className="block whitespace-pre-wrap pl-[calc(var(--ag-log-pad)+2ch)] text-current/70 [overflow-wrap:anywhere]"
>
{line.raw}
</span>
</li>
)
const fields = line.fields ? Object.entries(line.fields) : []
const toned = line.level === "warn" || line.level === "error"
const mark = line.verb && VERB_MARK[line.verb]
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: choosing a line is a way to a trace beside the log, which its span's chip gives the keyboard
<li
ref={row}
aria-posinset={position}
aria-setsize={count}
aria-current={chosen || undefined}
data-slot="log-line"
data-id={line.id}
data-level={line.level}
data-open={open || undefined}
data-chosen={chosen || undefined}
data-in-span={inSpan || undefined}
onClick={() => onPick(line.id)}
className={cn(
"grid cursor-default grid-cols-(--ag-log-cols) gap-x-(--ag-log-gap) px-(--ag-log-pad) py-0.5 leading-5 tabular-nums",
LEVEL_WASH[line.level],
// The chosen span's lines carry a rule down their start; the chosen line a
// ring round it.
inSpan && "shadow-[inset_2px_0_0_var(--color-foreground)]",
chosen && "bg-current/10 ring-1 ring-current/30 ring-inset",
)}
>
{/* One line tall at the line's top, beside the message's first line. */}
<span data-slot="log-chevron" className="-my-0.5 flex h-6 self-start">
{fields.length > 0 && (
<button
type="button"
aria-expanded={open}
aria-controls={open ? fieldsId : undefined}
aria-label="fields"
onClick={(e) => {
e.stopPropagation()
onToggle(line.id, !open)
}}
className="flex w-full cursor-pointer items-center justify-center rounded-sm text-current/40 hover:bg-current/8 hover:text-current/80"
>
<ChevronRight
aria-hidden
className={cn(
"size-3.5 transition-transform duration-150 motion-reduce:transition-none",
open && "rotate-90",
)}
/>
</button>
)}
</span>
<time
data-slot="log-time"
dateTime={new Date(line.at).toISOString()}
className="text-current/45"
>
{formatTime(line.at)}
</time>
<span
data-slot="log-level"
className={cn(LEVEL_WORD[line.level], toned && glass && glassWords)}
>
{line.level}
</span>
{sourced && (
<span
data-slot="log-source"
className="min-w-0 text-current/45 [overflow-wrap:anywhere]"
>
{line.source}
</span>
)}
<span
data-slot="log-message"
className={cn(
"min-w-0 whitespace-pre-wrap text-current/90 [overflow-wrap:anywhere]",
narrow && "col-[2/-1]",
)}
>
{mark && (
<span aria-hidden className={cn("mr-[1ch]", mark.tone)}>
{mark.mark}
</span>
)}
{line.message}
{line.took !== undefined && (
<span className="ml-[1ch] text-current/45">
{tookWords(line.took)}
</span>
)}
{/* A span's own start and end name it in their message already. */}
{line.span && line.phase === undefined && (
<SpanChip span={line.span} onSpan={onSpan} />
)}
</span>
{open && fields.length > 0 && (
<dl
id={fieldsId}
data-slot="log-fields"
className="col-[2/-1] my-1 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-(--ag-log-gap) border-current/15 border-l pl-(--ag-log-gap)"
>
{fields.map(([key, value]) => (
<div
key={key}
data-slot="log-field"
className="col-span-2 grid grid-cols-subgrid"
>
<dt data-slot="log-field-key" className="text-current/45">
{key}
</dt>
<dd
data-slot="log-field-value"
className="m-0 min-w-0 whitespace-pre-wrap text-current/90 [overflow-wrap:anywhere]"
>
{fieldText(value)}
</dd>
</div>
))}
</dl>
)}
</li>
)
})
/** The span a line was written in, after its message: a way to it in a trace beside
* the log (`onSpanChange`), or only its name. A line is 24 px, so the chip is drawn
* here, not a Button, as the chevron is. */
function SpanChip({
span,
onSpan,
}: {
span: { id: string; name: string }
onSpan: ((id: string) => void) | undefined
}) {
// Quiet: every line of a request is in a span, so the chip repeats down the log
// and must not compete with the message; under the hand it is a way to the span.
const face =
"ml-[1ch] inline-flex max-w-full items-baseline gap-1 rounded-sm px-1 align-baseline text-[11px] text-current/35"
if (!onSpan)
return (
<span data-slot="log-span" className={face}>
<SpanMark />
{span.name}
</span>
)
return (
<button
type="button"
data-slot="log-span"
aria-label={`show ${span.name} in the trace`}
onClick={(e) => {
e.stopPropagation()
onSpan(span.id)
}}
className={cn(
face,
"cursor-pointer hover:bg-current/10 hover:text-current/85 focus-visible:text-current/85",
)}
>
<SpanMark />
{span.name}
</button>
)
}
/** A bar on an axis: the span's mark. */
function SpanMark() {
return (
<span
aria-hidden
className="inline-block h-1.5 w-2.5 shrink-0 self-center rounded-[1px] bg-current/45"
/>
)
}