← ui

log

ui

a stream of log lines that follows the tail: time, level, source, message

npx shadcn add @ag/log
Already installed? Preview an update
npx shadcn add @ag/log --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • Its frame is Panel's (frame decides as before). A glass log is no longer clipped to its corners, which cut its own shadow.
Updated 2026-10-14 · release notes

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

log.demo.tsx
"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

log.tsx
"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"
    />
  )
}