← ui

trace

ui

one request's spans as a waterfall: who called whom, how long each took, what failed

npx shadcn add @ag/trace
Already installed? Preview an update
npx shadcn add @ag/trace --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), so in glass it has the panel's spill and an edge over its content.
Updated 2026-10-14 · release notes

also called waterfall, spans, distributed tracing, flame graph

shelf data & status

uses clip, timeline-view, tree, readout, button, key-router, panel, scroll-thumb, scrims, material, telemetry

used by deployment

the demo above, verbatim

trace.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import type { TraceSpan } from "@/registry/base-nova/lib/telemetry"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Trace } from "@/registry/base-nova/ui/trace"

// #region checkout
/** One checkout as four services traced it, in ms from the request's arrival. The
 *  first charge times out at the acquirer and the retry goes through; the receipt is
 *  a queued job the request never waits for, so it runs past its parent and says so. */
const checkout: TraceSpan[] = [
  {
    id: "a1",
    name: "POST /checkout",
    service: "frontend",
    from: 0,
    to: 412.6,
    status: "ok",
    attributes: {
      "http.method": "POST",
      "http.route": "/checkout",
      "http.status_code": 200,
      "user.tier": "plus",
    },
  },
  {
    id: "a2",
    parentId: "a1",
    name: "verify session",
    service: "frontend",
    from: 2.1,
    to: 19.8,
    status: "ok",
  },
  {
    id: "a3",
    parentId: "a2",
    name: "redis GET session",
    service: "frontend",
    from: 3,
    to: 5.4,
    status: "ok",
    attributes: { "db.system": "redis", "cache.hit": true },
  },
  {
    id: "a4",
    parentId: "a2",
    name: "decode jwt",
    service: "frontend",
    from: 5.9,
    to: 6.4,
    status: "ok",
  },
  {
    id: "b1",
    parentId: "a1",
    name: "PlaceOrder",
    service: "checkout",
    from: 22.4,
    to: 404.9,
    status: "ok",
    attributes: { "rpc.system": "grpc", "rpc.method": "PlaceOrder" },
  },
  {
    id: "b2",
    parentId: "b1",
    name: "load cart",
    service: "checkout",
    from: 24,
    to: 61.3,
    status: "ok",
  },
  {
    id: "b3",
    parentId: "b2",
    name: "SELECT cart_items",
    service: "checkout",
    from: 25.2,
    to: 46.8,
    status: "ok",
    attributes: { "db.system": "postgresql", "db.rows": 4 },
  },
  {
    id: "b4",
    parentId: "b2",
    name: "SELECT products",
    service: "checkout",
    from: 47.5,
    to: 59.9,
    status: "ok",
    attributes: { "db.system": "postgresql", "db.rows": 4 },
  },
  {
    id: "b10",
    parentId: "b1",
    name: "validate address",
    service: "checkout",
    from: 61.5,
    to: 62.8,
    status: "ok",
  },
  {
    id: "c1",
    parentId: "b1",
    name: "Reserve",
    service: "inventory",
    from: 63,
    to: 148.2,
    status: "ok",
    attributes: { "rpc.system": "grpc", "inventory.skus": 4 },
  },
  {
    id: "c2",
    parentId: "c1",
    name: "lock sku rows",
    service: "inventory",
    from: 65.1,
    to: 81.7,
    status: "ok",
  },
  {
    id: "c3",
    parentId: "c1",
    name: "UPDATE stock",
    service: "inventory",
    from: 83,
    to: 131.4,
    status: "ok",
    attributes: { "db.system": "postgresql", "db.rows": 4 },
  },
  {
    id: "c4",
    parentId: "c1",
    name: "publish stock.reserved",
    service: "inventory",
    from: 132.9,
    to: 145.5,
    status: "ok",
    attributes: { "messaging.system": "kafka" },
  },
  {
    id: "b5",
    parentId: "b1",
    name: "price order",
    service: "checkout",
    from: 150.3,
    to: 176,
    status: "ok",
  },
  {
    id: "b6",
    parentId: "b5",
    name: "tax quote",
    service: "checkout",
    from: 151.8,
    to: 171.2,
    status: "ok",
    attributes: { "tax.region": "ES", "tax.rate": 0.21 },
  },
  {
    id: "d1",
    parentId: "b1",
    name: "Charge",
    service: "payments",
    from: 179.6,
    to: 371,
    status: "ok",
    attributes: { "payment.amount": 84.5, "payment.currency": "EUR" },
  },
  {
    id: "d2",
    parentId: "d1",
    name: "fraud score",
    service: "payments",
    from: 182,
    to: 213.7,
    status: "ok",
    attributes: { "fraud.score": 0.07 },
  },
  {
    id: "d3",
    parentId: "d1",
    name: "POST /v1/charges",
    service: "payments",
    from: 215.2,
    to: 316,
    status: "error",
    attributes: {
      "http.status_code": 504,
      "error.type": "timeout",
      "retry.attempt": 1,
      "net.peer.name": "api.acquirer.example",
    },
  },
  {
    id: "d4",
    parentId: "d1",
    name: "backoff",
    service: "payments",
    from: 316.1,
    to: 328,
    status: "ok",
  },
  {
    id: "d5",
    parentId: "d1",
    name: "POST /v1/charges",
    service: "payments",
    from: 328.4,
    to: 366.9,
    status: "ok",
    attributes: {
      "http.status_code": 201,
      "retry.attempt": 2,
      "net.peer.name": "api.acquirer.example",
    },
  },
  {
    id: "b7",
    parentId: "b1",
    name: "INSERT orders",
    service: "checkout",
    from: 373,
    to: 391.8,
    status: "ok",
    attributes: { "db.system": "postgresql", "db.rows": 1 },
  },
  {
    id: "b8",
    parentId: "b1",
    name: "publish order.placed",
    service: "checkout",
    from: 392.5,
    to: 401.3,
    status: "ok",
    attributes: { "messaging.system": "kafka" },
  },
  {
    id: "b9",
    parentId: "b8",
    name: "send receipt",
    service: "checkout",
    from: 402,
    to: 458.6,
    status: "ok",
    detached: "a queued job: the request does not wait for the email",
    attributes: { "messaging.system": "kafka", "email.template": "receipt" },
  },
  {
    id: "a6",
    parentId: "a1",
    name: "set cookie",
    service: "frontend",
    from: 405.1,
    to: 405.9,
    status: "ok",
  },
  {
    id: "a5",
    parentId: "a1",
    name: "render confirmation",
    service: "frontend",
    from: 406.2,
    to: 411.9,
    status: "ok",
  },
]
// #endregion

// #region search
/** A search fanned out over five services, five hundred spans, made the same every
 *  time (a seeded generator): each span's children share its time in order or fan
 *  out side by side, and a few of them fail. */
function searchTrace(count: number): TraceSpan[] {
  let seed = 7
  const random = () => {
    seed = (seed * 1664525 + 1013904223) % 2 ** 32
    return seed / 2 ** 32
  }
  const services = ["edge", "search", "ranker", "index", "cache"]
  const verbs = [
    "query",
    "fetch",
    "score",
    "merge",
    "lookup",
    "decode",
    "filter",
  ]
  const root: TraceSpan = {
    id: "s0",
    name: "GET /search",
    service: "edge",
    from: 0,
    to: 2400,
    status: "ok",
  }
  const spans = [root]
  const waiting = [root]
  for (let parent = waiting.shift(); parent; parent = waiting.shift()) {
    const room = parent.to - parent.from
    const kids = 4 + Math.floor(random() * 6)
    // A fan-out: the children run side by side, each most of the parent's time.
    const fanOut = random() < 0.5
    const slot = (room * 0.96) / kids
    for (let k = 0; k < kids && spans.length < count; k++) {
      const length = fanOut
        ? room * (0.4 + random() * 0.5)
        : slot * (0.5 + random() * 0.4)
      const from = fanOut
        ? parent.from + random() * (room - length)
        : parent.from + room * 0.02 + k * slot + random() * slot * 0.1
      const span: TraceSpan = {
        id: `s${spans.length}`,
        parentId: parent.id,
        name: `${verbs[Math.floor(random() * verbs.length)]} ${spans.length}`,
        service: services[Math.floor(random() * services.length)] as string,
        from,
        to: from + length,
        status: random() < 0.01 ? "error" : "ok",
        attributes: { shard: k },
      }
      spans.push(span)
      waiting.push(span)
    }
  }
  return spans
}
const search = searchTrace(500)
// #endregion

export default function TraceDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="capped"
        glance="large"
        title="a checkout, eight rows at a time"
        with="checkout"
      >
        <Trace spans={checkout} label="POST /checkout" visibleRows={8} />
      </Sample>
      <Sample
        name="checkout"
        title="a checkout, its error chosen"
        description="click a span or press Enter on it for its details; ← and → fold and open a parent, Esc lets the span go. ⌘ or ctrl with the wheel (or a pinch) zooms at the pointer, a sideways wheel pans, + − 0 from the keys; the details' magnifier zooms to the span. The charge's first attempt timed out; the receipt is a queued job that runs past the request, drawn hollow."
        with="checkout"
      >
        <Trace spans={checkout} label="POST /checkout" defaultSelected="d3" />
      </Sample>
      <Sample
        name="narrow"
        title="narrow: the details below"
        description="under 48 rem of its own width the details are a band of fixed height under the waterfall, there before anything is chosen, so a choice never moves what is below."
        with="checkout"
      >
        <div className="max-w-xl">
          <Trace spans={checkout} label="POST /checkout" visibleRows={10} />
        </div>
      </Sample>
      <Sample
        name="folded"
        title="subtrees folded"
        description="the inventory reservation and the charge start folded: their bars stay, their children wait behind the chevron."
        with="checkout"
      >
        <Trace
          spans={checkout}
          label="POST /checkout"
          defaultCollapsed={["c1", "d1"]}
        />
      </Sample>
      <Sample name="glass" title="on glass" with="checkout">
        <MaterialProvider material="glass">
          <div className="rounded-2xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-4 sm:p-8">
            <Trace
              spans={checkout}
              label="POST /checkout"
              defaultSelected="b6"
              visibleRows={10}
            />
          </div>
        </MaterialProvider>
      </Sample>
      <Sample
        name="large"
        title="five hundred spans"
        description="only the rows in view are mounted; the thumb says where you are, the edges fade where there is more."
        with="search"
      >
        <Trace spans={search} label="GET /search" visibleRows={20} />
      </Sample>
    </div>
  )
}

the source, every file an install writes

trace.tsx
"use client"

// ONE REQUEST'S SPANS AS A WATERFALL, as Jaeger, Honeycomb and Vercel draw a trace: a
// row per span, its name indented under its parent, and on one time axis shared by
// every row a bar from its start to its end. What it is for is reading where the time
// went: which call waited on which, what ran side by side, what failed.
//
//   the data      flat spans, each a span of time (`{from, to}`, ms, any epoch) with an
//                 id, its parent's id (none for the root), a name, the service that
//                 ran it, ok or error, and attributes. The tree is asserted whole: one
//                 root, every parent there, every span reached from the root, and a
//                 child inside its parent's span unless it says why not (`detached`:
//                 a queued job the request does not wait for, two hosts' clocks).
//   the view      the window of the trace the axis shows (lib/timeline-view, the one
//                 view model; instants are offsets from the trace's start, so the
//                 trace is its "medium"). The whole trace at first; ⌘ or ctrl with the
//                 wheel, or a pinch, zooms at the hand; a sideways wheel (or ⇧) pans,
//                 stopping at the ends; + − 0 from the keys, the buttons beside the
//                 legend, and a chosen span's own zoom. The narrowest view is four of
//                 the trace's shortest span, so its shortest leaf can be read.
//   the axis      bars and their places are shares of the view (percent), so nothing
//                 is measured to draw a row; only the ruler measures its width, to
//                 pick the tick step whose labels fit (1, 2, 5 × 10ⁿ). The column ends
//                 a duration label's width short of the row's end, so every label sits
//                 after its bar, the root's included. A bar that runs past the view is
//                 cut at its edge with a fade, never a hard edge.
//   colour        the service: a small fixed palette, the tokens' info tone turned
//                 round the hue circle at its own lightness and chroma, so it follows
//                 the scheme the tones do; services take it in order of first
//                 appearance down the rows. An error is the tokens' bad tone, never a
//                 service colour: a mark before the name, the row washed in it as a
//                 log washes an error line, the duration in it.
//   lines         a log's own lines (`lines`), listed in a span's details; the one
//                 under the hand there, or chosen (`line`), draws its instant as a
//                 playhead down the waterfall. Never a mark per line on every bar.
//   the tree      ui/tree-state's headless tree (`useTree`): the WAI-ARIA tree keys
//                 (↑ ↓ move, → opens or steps in, ← closes or steps out, Home End,
//                 type-ahead, * opens siblings), the roving focus, and the window of
//                 rows mounted, so five hundred spans cost what a screenful does. A
//                 click or Enter chooses a span; Esc lets it go.
//   the details   a region of its own that never moves the page: from 48 rem of the
//                 trace's own width a fixed column beside the waterfall, as tall as
//                 it; narrower, a band of fixed height under it. Always there, a quiet
//                 line until a span is chosen; then its name, service, start, duration,
//                 status and attributes as a readout, scrolling inside the region
//                 (scrims' `ag-edge-fade-y`) when they are many.
//   the scroller  capped at `visibleRows` (or, `"fill"`, as tall as the height the
//                 trace is given leaves it), the browser's bar hidden, overlay thumbs
//                 (scroll-thumb), and its cut edges faded where there is more
//                 (`ag-edge-fade-y`); the ruler stands above it, outside the fade.
//
// Both materials (ui/material): plain on the page's surfaces, glass over a picture;
// `scheme` the colour scheme it stands in. Inside a frame already drawn (a panel, a
// tabs box) it draws no box of its own.

import {
  ChevronRight,
  Maximize2,
  Minus,
  Plus,
  TriangleAlert,
  X,
  ZoomIn,
} from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import type { Span } from "@/registry/base-nova/lib/clip"
import {
  fieldText,
  isRaw,
  type LogEntry,
  type LogLine,
  type TraceSpan,
} from "@/registry/base-nova/lib/telemetry"
import {
  clampView,
  instantAt,
  msPerPx,
  panBy,
  type View,
  viewAround,
  wholeView,
  xOf,
  zoomAt,
} from "@/registry/base-nova/lib/timeline-view"
import { Button } from "@/registry/base-nova/ui/button"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
  type Material,
  type Scheme,
  TONE_WORDS,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants } from "@/registry/base-nova/ui/material-glass"
import { Panel, useInFrame } from "@/registry/base-nova/ui/panel"
import { Readout, ReadoutRow } from "@/registry/base-nova/ui/readout"
import {
  NO_SCROLLBAR,
  ScrollThumbs,
} from "@/registry/base-nova/ui/scroll-thumb"
import { useTree } from "@/registry/base-nova/ui/tree-state"
import "./edge-fade"

export interface TraceProps {
  spans: readonly TraceSpan[]
  /** What the trace is of, its accessible name ("POST /checkout"). */
  label: string
  /** The span whose details show (controlled), null for none. */
  selected?: string | null
  defaultSelected?: string | null
  onSelectedChange?: (id: string | null) => void
  /** The lines written in its spans (a log's own entries, lib/telemetry): each a dot
   *  on its span's bar where it was written, and listed in its span's details. Lines
   *  of spans not in the trace, and lines that are no event, are left out. */
  lines?: readonly LogEntry[]
  /** The line shown as chosen (controlled), null for none: its tick and its row in
   *  the details stand out. With `onLineChange`, a log beside the trace names it. */
  line?: string | null
  /** A line chosen in the details: where a log beside the trace goes to it. */
  onLineChange?: (id: string) => void
  /** Parents whose children start folded away. */
  defaultCollapsed?: readonly string[]
  /** How many rows show before the waterfall scrolls; `"fill"`, as many as the
   *  height the trace is given holds (a tabs box's `height`, a dashboard's cell).
   *  Default 16. */
  visibleRows?: number | "fill"
  /** Its own box (a hairline, its corners and ground; 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
  className?: string
}

/** A span as a node of ui/tree-state's tree. */
type SpanNode = { id: string; span: TraceSpan; children?: SpanNode[] }

/** The trace as drawn: its one root, the whole of it, each service's colour, the
 *  parents (whose subtrees fold) and its shortest span (how far it zooms). */
interface Shape {
  roots: readonly SpanNode[]
  byId: ReadonlyMap<string, TraceSpan>
  whole: Span
  inks: ReadonlyMap<string, string>
  branches: readonly string[]
  errors: number
  shortest: number
}

// ── sizes, px ────────────────────────────────────────────────────────────────────

/** One row: the tree's compact density. */
const ROW_H = 24
/** The indent's grid, the tree's: a level is the chevron's gutter and its gap. */
const GRID = { pad: 6, gutter: 16, gap: 2 }
const STEP = GRID.gutter + GRID.gap
/** Mono at 10 px: one character's advance, the ruler's and the durations' type. */
const CHAR_PX = 6.2
/** Between a bar's end and its duration. */
const LABEL_GAP = 6
/** The longest duration label, in characters: "12.4 ms", "9.99 ms". */
const LABEL_CHARS = 7
/** The room after the axis: the longest duration beside a bar that ends there. */
const GUTTER_PX = LABEL_GAP + LABEL_CHARS * CHAR_PX
/** Room a ruler label keeps clear after itself, past its own characters. */
const TICK_ROOM_PX = 14
/** How far into the axis a bar cut by the view fades out. */
const CUT_PX = 16
/** The narrowest view, in the trace's shortest spans: that span a quarter of it. */
const SHORTEST_IN_VIEW = 4
/** What a wheel's line is in px, when a mouse reports lines. */
const LINE_PX = 16
/** The zoom a pinch's delta buys: exp(-delta × this). */
const ZOOM_PER_PX = 0.01

/** The service palette: the tokens' info tone at these hues, its own lightness and
 *  chroma kept, so each reads as one family with the tones in either scheme. Eight,
 *  30° apart from 110° to 320°: none near the bad tone's red (27°), which is the
 *  error's alone, nor the warn tone's amber. Taken in this order, so the first
 *  services are the furthest apart; a ninth repeats the first. */
const HUES = [230, 110, 320, 170, 260, 140, 290, 200] as const
const inkOf = (n: number) =>
  `oklch(from var(--tone-info) l c ${HUES[n % HUES.length]})`

/** The tree's own parts, whose keys it answers itself. */
const TREE_ROLES = ["tree", "treeitem", "group"]

/** The name column and the axis, the same for the ruler's head and every row. */
const COLUMNS = "grid grid-cols-[minmax(12rem,34%)_minmax(0,1fr)]"
const ROW =
  "group/row absolute inset-x-0 top-0 h-(--ag-trace-row) translate-y-(--ag-trace-y) cursor-default select-none items-center text-foreground/80 focus-visible:-outline-offset-2 aria-selected:text-foreground"
/** Plain: rows lit in the foreground's tiers. Glass: the chosen row is a pane of the
 *  material's glass, the others lit under the hand only (the tree's faces). */
const ROW_FACE: Record<Material, string> = {
  // Square, edge to edge, as the log's lines are: washed rows side by side are one
  // band, never a column of rounded pills notched where their corners meet.
  plain: "hover:bg-foreground/5 aria-selected:bg-foreground/10",
  glass:
    "rounded-(--glass-radius) [--glass-radius:8px] hover:not-aria-selected:bg-foreground/8",
}
/** A failed span's row washed in the bad tone, as the log washes an error line, so
 *  both views say it the same way and a failure is found while scrolling; a step
 *  more under the hand and when chosen. After ROW_FACE, so it wins over its washes. */
const ERROR_WASH =
  "bg-tone-bad/9 hover:bg-tone-bad/15 aria-selected:bg-tone-bad/20"
const META = "font-mono text-[11px] text-foreground/50 tabular-nums"
const SWATCH = "size-2 shrink-0 rounded-full bg-(--ag-trace-ink)"
/** The instant a line was written, drawn only when asked for (a line hovered in the
 *  details, a line chosen in a log beside the trace), as a playhead: a hairline down
 *  the waterfall and a caret on the ruler, in the line's tone. Marks on every bar for
 *  every line were noise (most lines fall at a span's start or end) and fought the
 *  bars and their durations in a 24 px row. */
const CURSOR: Record<LogLine["level"], string> = {
  trace: "bg-foreground/70",
  debug: "bg-foreground/70",
  info: "bg-foreground/70",
  warn: "bg-tone-warn",
  error: "bg-tone-bad",
}

/** A length of time in three figures: "840 µs", "12.4 ms", "1.24 s". */
export function durationWords(ms: number): string {
  if (ms < 1) return `${Math.round(ms * 1000)} µs`
  const [n, unit] = ms < 1000 ? [ms, "ms"] : [ms / 1000, "s"]
  return `${n.toFixed(n < 10 ? 2 : n < 100 ? 1 : 0)} ${unit}`
}

/** The spans as a tree, asserted whole (see the header). */
function shapeOf(spans: readonly TraceSpan[]): Shape {
  if (spans.length === 0) throw new Error("trace: no spans")
  const byId = new Map<string, TraceSpan>()
  for (const s of spans) {
    if (byId.has(s.id)) throw new Error(`trace: two spans are "${s.id}"`)
    if (!Number.isFinite(s.from) || !Number.isFinite(s.to) || s.to < s.from)
      throw new Error(
        `trace: span "${s.id}" runs from ${s.from} to ${s.to}, no span of time`,
      )
    byId.set(s.id, s)
  }
  const roots = spans.filter((s) => s.parentId === undefined)
  if (roots.length !== 1)
    throw new Error(
      `trace: ${roots.length} spans have no parent (${roots.map((r) => `"${r.id}"`).join(", ")}); a trace has one root`,
    )
  const kids = new Map<string, TraceSpan[]>()
  for (const s of spans) {
    if (s.parentId === undefined) continue
    const parent = byId.get(s.parentId)
    if (!parent)
      throw new Error(
        `trace: span "${s.id}" names parent "${s.parentId}", which is not in the trace`,
      )
    if (!s.detached && (s.from < parent.from || s.to > parent.to))
      throw new Error(
        `trace: span "${s.id}" (${s.from} to ${s.to}) runs outside its parent "${parent.id}" (${parent.from} to ${parent.to}); say why with \`detached\` if it is meant to`,
      )
    const list = kids.get(parent.id) ?? []
    list.push(s)
    kids.set(parent.id, list)
  }
  const branches: string[] = []
  let reached = 0
  const build = (s: TraceSpan): SpanNode => {
    reached++
    const children = (kids.get(s.id) ?? [])
      .toSorted((a, b) => a.from - b.from || b.to - a.to)
      .map(build)
    if (children.length > 0) branches.push(s.id)
    return children.length > 0
      ? { id: s.id, span: s, children }
      : { id: s.id, span: s }
  }
  const root = build(roots[0] as TraceSpan)
  if (reached !== spans.length)
    throw new Error(
      `trace: ${spans.length - reached} spans are never reached from the root "${root.id}": their parents name each other round in a loop`,
    )
  // Services take their colours in the order the rows show them.
  const inks = new Map<string, string>()
  const walk = (n: SpanNode) => {
    if (!inks.has(n.span.service)) inks.set(n.span.service, inkOf(inks.size))
    for (const c of n.children ?? []) walk(c)
  }
  walk(root)
  const whole = {
    from: Math.min(...spans.map((s) => s.from)),
    to: Math.max(...spans.map((s) => s.to)),
  }
  if (whole.to <= whole.from)
    throw new Error("trace: every span is of no length; there is no axis")
  return {
    roots: [root],
    byId,
    whole,
    inks,
    branches,
    errors: spans.filter((s) => s.status === "error").length,
    // The root has a length (asserted above), so there is always one.
    shortest: Math.min(
      ...spans.map((s) => s.to - s.from).filter((length) => length > 0),
    ),
  }
}

/** Where an offset falls along the view, as a share of the axis held to its ends. */
const shareIn = (view: View, ms: number) =>
  Math.min(100, Math.max(0, xOf(view, 100, ms)))

/** The ruler's label for an instant: in the unit the view's length is said in
 *  (`durationWords`'), with the decimals its step needs ("0.2 s", "215.4 ms"). */
function tickWords(at: number, step: number, length: number) {
  if (at === 0) return "0"
  const [unit, per] =
    length < 1 ? ["µs", 0.001] : length < 1000 ? ["ms", 1] : ["s", 1000]
  const decimals = Math.max(0, -Math.floor(Math.log10(step / per) + 1e-9))
  return `${(at / per).toFixed(decimals)} ${unit}`
}

/** The ruler's ticks across `view` drawn `px` wide: the smallest step of 1, 2 or
 *  5 × 10ⁿ whose widest label fits before the next tick. */
function ticksOf(view: View, px: number) {
  if (px <= 0) return []
  const length = view.to - view.from
  // No step narrower than one character and its room can fit: the search starts at
  // the power of ten holding that one, so it never lays out more ticks than the width
  // has room for.
  const least = (length * (CHAR_PX + TICK_ROOM_PX)) / px
  const first = Math.floor(Math.log10(least))
  for (let power = first; power <= first + 3; power++)
    for (const m of [1, 2, 5]) {
      const step = m * 10 ** power
      if (step < least) continue
      const ats: number[] = []
      for (
        let k = Math.ceil(view.from / step - 1e-9);
        k * step <= view.to + step * 1e-9;
        k++
      )
        ats.push(k * step)
      const words = ats.map((at) => tickWords(at, step, length))
      const widest = Math.max(...words.map((w) => w.length)) * CHAR_PX
      if ((step / length) * px >= widest + TICK_ROOM_PX)
        return ats.map((at, i) => ({ at, label: words[i] as string }))
    }
  throw new Error(`trace: no tick step fits ${length} ms in ${px} px`)
}

export function Trace({
  spans,
  label,
  selected,
  defaultSelected = null,
  onSelectedChange,
  lines,
  line,
  onLineChange,
  defaultCollapsed = [],
  visibleRows = 16,
  frame,
  material,
  scheme,
  className,
}: TraceProps) {
  const m = useMaterial(material)
  const inFrame = useInFrame()
  const framed = frame ?? !inFrame
  const cap = visibleRows === "fill" ? null : visibleRows * ROW_H
  const root = React.useRef<HTMLElement>(null)
  const [sch, here] = useSurfaceScheme<HTMLElement>(scheme, root)
  const shape = React.useMemo(() => shapeOf(spans), [spans])
  const { whole, inks, byId } = shape
  // Each span's lines, in the order written; a span's own start and end are its bar's
  // ends, so they are no lines of it here.
  const linesOf = React.useMemo(() => {
    const out = new Map<string, LogLine[]>()
    for (const l of lines ?? []) {
      if (isRaw(l) || !l.span || l.phase || !byId.has(l.span.id)) continue
      const list = out.get(l.span.id) ?? []
      list.push(l)
      out.set(l.span.id, list)
    }
    return out
  }, [lines, byId])
  // The line whose instant is drawn: one hovered in the details, else the chosen one.
  const [hovered, setHovered] = React.useState<string | null>(null)
  const cursor = React.useMemo(() => {
    const id = hovered ?? line
    if (!id) return undefined
    for (const list of linesOf.values()) {
      const found = list.find((l) => l.id === id)
      if (found) return found
    }
    return undefined
  }, [hovered, line, linesOf])
  const total = whole.to - whole.from
  const minSpan = Math.min(total, shape.shortest * SHORTEST_IN_VIEW)

  // ── the chosen span, controlled or not.
  if (selected != null && !byId.has(selected))
    throw new Error(`trace: selected "${selected}" is no span of the trace`)
  const [own, setOwn] = React.useState(defaultSelected)
  const chosenId = selected !== undefined ? selected : own
  const chosen = chosenId === null ? undefined : byId.get(chosenId)
  const choose = (id: string | null) => {
    if (selected === undefined) setOwn(id)
    onSelectedChange?.(id)
  }

  // ── the view: offsets from the trace's start, the whole trace until moved.
  const [viewState, setViewState] = React.useState<View | null>(null)
  const view = React.useMemo(
    () => clampView(viewState ?? wholeView(total), total, minSpan),
    [viewState, total, minSpan],
  )
  const live = React.useRef({ total, minSpan })
  live.current = { total, minSpan }
  const moveView = React.useCallback((next: (v: View) => View) => {
    setViewState((was) => {
      const { total: t, minSpan: min } = live.current
      return clampView(next(clampView(was ?? wholeView(t), t, min)), t, min)
    })
  }, [])
  const zoomed = view.from > 0 || view.to < total
  const zoomBy = (factor: number) =>
    moveView((v) => zoomAt(v, (v.from + v.to) / 2, factor, total, minSpan))
  // A span shorter than the narrowest view is widened round its middle first, so it
  // lands in the middle of the view (viewAround widens from its start).
  const zoomTo = (s: Span) => {
    const middle = (s.from + s.to) / 2 - whole.from
    const half = Math.max(s.to - s.from, minSpan) / 2
    moveView(() =>
      viewAround({ from: middle - half, to: middle + half }, total, minSpan),
    )
  }
  const showWhole = () => moveView(() => wholeView(total))

  // ── the tree: keys, focus, the rows mounted. What is kept is what was folded, not
  // what is open: a trace that grows (a build still running) gets new parents, and
  // each opens as the first ones did.
  const [folded, setFolded] = React.useState<ReadonlySet<string>>(
    () => new Set(defaultCollapsed),
  )
  const expanded = React.useMemo(
    () => shape.branches.filter((id) => !folded.has(id)),
    [shape.branches, folded],
  )
  const t = useTree<SpanNode>({
    items: shape.roots,
    label: nameOf,
    selection: "none",
    expanded,
    onExpandedChange: (open) => {
      const now = new Set(open)
      setFolded(new Set(shape.branches.filter((id) => !now.has(id))))
    },
    onActivate: (n) => choose(n.id),
    rowHeight: ROW_H,
  })
  const { rows, scroller } = t
  // A span chosen from the start is in the middle of the view, and the row the keys
  // start from: details of a row nobody can see answer nothing.
  // biome-ignore lint/correctness/useExhaustiveDependencies: on mount only
  React.useLayoutEffect(() => {
    const at = chosenId === null ? undefined : t.rowAt.get(chosenId)
    const el = scroller.current
    if (at === undefined || chosenId === null || !el) return
    el.scrollTop = Math.max(0, (at + 0.5) * ROW_H - el.clientHeight / 2)
    t.setActive(chosenId)
  }, [])
  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (!e.currentTarget.contains(e.target as Node)) return
    if (ownedHere(e.nativeEvent, e.currentTarget, TREE_ROLES)) return
    t.keys(e, (e) => {
      const mod = e.metaKey || e.ctrlKey || e.altKey
      if (e.key === "Escape" && chosen !== undefined) choose(null)
      else if (!mod && (e.key === "+" || e.key === "=")) zoomBy(2)
      else if (!mod && e.key === "-") zoomBy(0.5)
      else if (!mod && e.key === "0") showWhole()
      else return false
      e.preventDefault()
      return true
    })
  }

  // ── the ruler: its width picks the step and turns a wheel's px into ms.
  const ruler = React.useRef<HTMLDivElement>(null)
  const [rulerPx, setRulerPx] = React.useState(0)
  React.useLayoutEffect(() => {
    const el = ruler.current as HTMLDivElement
    const measure = () => setRulerPx(el.clientWidth)
    measure()
    const ro = new ResizeObserver(measure)
    ro.observe(el)
    return () => ro.disconnect()
  }, [])
  const ticks = React.useMemo(() => ticksOf(view, rulerPx), [view, rulerPx])

  // ── the wheel over the waterfall: ⌘ or ctrl (a pinch) zooms at the hand, sideways
  // (or ⇧) pans; up and down stay the rows' scroll.
  const waterfall = React.useRef<HTMLDivElement>(null)
  React.useEffect(() => {
    const el = waterfall.current as HTMLDivElement
    const onWheel = (e: WheelEvent) => {
      const axis = ruler.current?.getBoundingClientRect()
      if (!axis || axis.width <= 0) return
      const unit = e.deltaMode === 1 ? LINE_PX : 1
      const dx = e.deltaX * unit
      const dy = e.deltaY * unit
      if (e.ctrlKey || e.metaKey) {
        e.preventDefault()
        const x = Math.min(axis.width, Math.max(0, e.clientX - axis.left))
        moveView((v) =>
          zoomAt(
            v,
            instantAt(v, axis.width, x),
            Math.exp(-dy * ZOOM_PER_PX),
            live.current.total,
            live.current.minSpan,
          ),
        )
        return
      }
      const sideways = e.shiftKey
        ? dy || dx
        : Math.abs(dx) > Math.abs(dy)
          ? dx
          : 0
      if (!sideways) return
      e.preventDefault()
      moveView((v) =>
        panBy(v, sideways * msPerPx(v, axis.width), live.current.total),
      )
    }
    el.addEventListener("wheel", onWheel, { passive: false })
    return () => el.removeEventListener("wheel", onWheel)
  }, [moveView])

  const glass = m === "glass"
  /** A span's place in the view: its start and its length as shares of the axis,
   *  held to the axis's ends, and where its duration goes. */
  const placeOf = (s: Span) => {
    const from = shareIn(view, s.from - whole.from)
    const to = shareIn(view, s.to - whole.from)
    return {
      "--ag-trace-from": `${from}%`,
      "--ag-trace-width": `${to - from}%`,
      "--ag-trace-end": `${to}%`,
    }
  }
  const band = chosen && placeOf(chosen)
  // The playhead only where the view shows its instant.
  const cursorIn =
    cursor !== undefined &&
    cursor.at - whole.from >= view.from &&
    cursor.at - whole.from <= view.to
  // The axis fades out at an edge where the view cuts the trace.
  const cut = zoomed
    ? `linear-gradient(to right, transparent, #000 ${view.from > 0 ? CUT_PX : 0}px, #000 calc(100% - ${view.to < total ? CUT_PX : 0}px), transparent)`
    : "none"
  const tickAt = (at: number) =>
    ({ "--ag-trace-at": `${xOf(view, 100, at)}%` }) as React.CSSProperties

  // The trace is a Panel (the one container, plain or glass), its surface drawn
  // only where no frame is drawn round it already.
  const face = {
    ref: here,
    "data-slot": "trace",
    className: cn(
      "@container/trace relative isolate flex flex-col gap-0 p-3 text-[13px] text-foreground",
      className,
    ),
    style: {
      "--ag-trace-row": `${ROW_H}px`,
      "--ag-trace-gutter": `${GUTTER_PX}px`,
      "--ag-trace-cut": cut,
    } as React.CSSProperties,
  }
  const inner = (
    <>
      {/* Given a height (`fill`), the waterfall takes what the details leave and
          its rows the rest of that. */}
      <div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 @3xl/trace:grid-cols-[minmax(0,1fr)_20rem] @3xl/trace:grid-rows-[minmax(0,1fr)]">
        <div
          ref={waterfall}
          data-slot="trace-waterfall"
          className="flex min-h-0 min-w-0 flex-col gap-2"
        >
          <div
            data-slot="trace-legend"
            className={cn(
              "flex flex-wrap items-center gap-x-3 gap-y-1 pl-1.5",
              META,
            )}
          >
            {[...inks].map(([service, ink]) => (
              <span
                key={service}
                data-slot="trace-service-key"
                className="flex items-center gap-1.5"
                style={{ "--ag-trace-ink": ink } as React.CSSProperties}
              >
                <span aria-hidden className={SWATCH} />
                {service}
              </span>
            ))}
            <span className="ml-auto flex items-center gap-3">
              {shape.errors > 0 && (
                <span className={TONE_WORDS.bad}>
                  {shape.errors} {shape.errors === 1 ? "error" : "errors"}
                </span>
              )}
              <span>
                {spans.length} spans, {durationWords(total)}
              </span>
              <span data-slot="trace-zoom" className="flex items-center">
                <Button
                  variant="ghost"
                  size="icon-sm"
                  material={m}
                  aria-label="zoom out (-)"
                  disabled={!zoomed}
                  onClick={() => zoomBy(0.5)}
                >
                  <Minus />
                </Button>
                <Button
                  variant="ghost"
                  size="icon-sm"
                  material={m}
                  aria-label="zoom in (+)"
                  disabled={view.to - view.from <= minSpan}
                  onClick={() => zoomBy(2)}
                >
                  <Plus />
                </Button>
                <Button
                  variant="ghost"
                  size="icon-sm"
                  material={m}
                  aria-label="the whole trace (0)"
                  disabled={!zoomed}
                  onClick={showWhole}
                >
                  <Maximize2 />
                </Button>
              </span>
            </span>
          </div>

          <div
            data-slot="trace-head"
            className={cn(COLUMNS, "h-7 border-foreground/10 border-b")}
          >
            <span className={cn("self-end pb-1 pl-1.5", META, "text-[10px]")}>
              span
            </span>
            <div
              ref={ruler}
              aria-hidden
              data-slot="trace-ruler"
              data-zoomed={zoomed || undefined}
              className="relative mr-(--ag-trace-gutter)"
            >
              {band && (
                <span
                  data-slot="trace-ruler-chosen"
                  // The chosen span's reach on the ruler: a line along its foot, not
                  // a block behind its labels.
                  className="absolute bottom-0 left-(--ag-trace-from) h-0.5 w-(--ag-trace-width) min-w-px rounded-full bg-foreground/60"
                  style={band as React.CSSProperties}
                />
              )}
              {cursorIn && cursor && (
                <span
                  data-slot="trace-cursor-caret"
                  className={cn(
                    "absolute bottom-0 left-(--ag-trace-at) h-2 w-0.5 -translate-x-1/2 rounded-t-full",
                    CURSOR[cursor.level],
                  )}
                  style={tickAt(cursor.at - whole.from)}
                />
              )}
              {ticks.map((tick) => (
                <span
                  key={tick.at}
                  className="absolute bottom-0 left-(--ag-trace-at)"
                  style={tickAt(tick.at)}
                >
                  <span className="absolute bottom-0 h-1.5 w-px bg-foreground/35" />
                  <span className="absolute bottom-2 left-1 whitespace-nowrap font-mono text-[10px] text-foreground/55 tabular-nums">
                    {tick.label}
                  </span>
                </span>
              ))}
            </div>
          </div>

          <div
            ref={scroller}
            role="tree"
            aria-label={label}
            data-slot="trace-rows"
            onKeyDown={onKeyDown}
            onScroll={t.onScroll}
            className={cn(
              "ag-edge-fade-y relative overflow-x-hidden [--edge-fade:1.5rem]",
              cap === null ? "min-h-0 flex-1" : "max-h-(--ag-trace-max)",
              NO_SCROLLBAR,
            )}
            style={
              cap === null
                ? undefined
                : ({ "--ag-trace-max": `${cap}px` } as React.CSSProperties)
            }
          >
            <ScrollThumbs scroller={scroller} material={m} />
            <div
              className="relative h-(--ag-trace-height)"
              style={
                {
                  "--ag-trace-height": `${rows.length * ROW_H}px`,
                } as React.CSSProperties
              }
            >
              {/* The ruler's ticks carried down the rows, and the chosen span's band. */}
              <div
                aria-hidden
                data-slot="trace-grid"
                className={cn(COLUMNS, "pointer-events-none absolute inset-0")}
              >
                <span />
                <span className="relative mr-(--ag-trace-gutter)">
                  {band && (
                    <span
                      className="absolute inset-y-0 left-(--ag-trace-from) w-(--ag-trace-width) min-w-px bg-foreground/[0.05]"
                      style={band as React.CSSProperties}
                    />
                  )}
                  {ticks.map((tick) => (
                    <span
                      key={tick.at}
                      data-slot="trace-grid-line"
                      className="absolute inset-y-0 left-(--ag-trace-at) w-px bg-foreground/[0.06]"
                      style={tickAt(tick.at)}
                    />
                  ))}
                  {cursorIn && cursor && (
                    <span
                      data-slot="trace-cursor"
                      data-level={cursor.level}
                      className={cn(
                        "absolute inset-y-0 left-(--ag-trace-at) w-px -translate-x-1/2",
                        CURSOR[cursor.level],
                      )}
                      style={tickAt(cursor.at - whole.from)}
                    />
                  )}
                </span>
              </div>
              {t.drawn.map((i) => {
                const row = rows[i] as (typeof rows)[number]
                const s = row.node.span
                const error = s.status === "error"
                const running = s.status === "running"
                const isChosen = chosenId === row.id
                const took = running ? "running" : durationWords(s.to - s.from)
                // Wholly before or after the view: no bar and no duration to show.
                const inView =
                  s.to - whole.from >= view.from &&
                  s.from - whole.from <= view.to
                return (
                  // biome-ignore lint/a11y/useKeyWithClickEvents: the tree's onKeyDown is every row's keyboard
                  <div
                    key={row.id}
                    role="treeitem"
                    data-slot="trace-row"
                    data-id={row.id}
                    data-status={s.status}
                    data-detached={s.detached ? "" : undefined}
                    aria-level={row.depth + 1}
                    aria-setsize={row.size}
                    aria-posinset={row.index}
                    aria-expanded={row.branch ? row.expanded : undefined}
                    aria-selected={isChosen}
                    aria-label={`${s.name}, ${s.service}, ${took}, from +${durationWords(s.from - whole.from)}${error ? ", error" : ""}${linesOf.has(s.id) ? `, ${(linesOf.get(s.id) as LogLine[]).length} lines` : ""}`}
                    tabIndex={row.id === t.activeId ? 0 : -1}
                    className={cn(
                      glass && isChosen && glassVariants({ shape: "surface" }),
                      COLUMNS,
                      ROW,
                      ROW_FACE[m],
                      // A chosen row in glass is a pane of glass, which a wash
                      // would cover.
                      error && !(glass && isChosen) && ERROR_WASH,
                    )}
                    style={
                      {
                        "--ag-trace-y": `${i * ROW_H}px`,
                        "--ag-trace-indent": `${GRID.pad + row.depth * STEP}px`,
                        "--ag-trace-ink": inks.get(s.service) as string,
                        ...placeOf(s),
                      } as React.CSSProperties
                    }
                    onFocus={() => {
                      if (t.activeId !== row.id) t.setActive(row.id)
                    }}
                    onClick={() => {
                      t.setActive(row.id)
                      choose(row.id)
                    }}
                    onDoubleClick={() => {
                      if (row.branch) t.toggle(row.id)
                    }}
                  >
                    <span
                      data-slot="trace-name"
                      className="flex min-w-0 items-center pr-3 pl-(--ag-trace-indent)"
                    >
                      <span
                        aria-hidden
                        data-slot="trace-disclosure"
                        className="grid size-4 shrink-0 place-items-center text-foreground/40 hover:text-foreground"
                        onClick={(e) => {
                          if (!row.branch) return
                          e.stopPropagation()
                          t.setActive(row.id)
                          t.toggle(row.id)
                        }}
                        onDoubleClick={(e) => e.stopPropagation()}
                      >
                        {row.branch && (
                          <ChevronRight
                            className={cn(
                              "size-3 transition-transform duration-150 ease-out motion-reduce:transition-none",
                              row.expanded && "rotate-90",
                            )}
                          />
                        )}
                      </span>
                      <span aria-hidden className={cn(SWATCH, "ml-0.5")} />
                      {error && (
                        <TriangleAlert
                          aria-hidden
                          data-slot="trace-error"
                          className="ml-1.5 size-3.5 shrink-0 text-tone-bad"
                        />
                      )}
                      {/* The service is shown whole or not at all: with no room for
                          it beside the name it wraps onto a second line, which the
                          one line's height hides (cut to a letter, "p", it read as
                          noise). The name truncates on the first. Under 36 rem of
                          the trace it is left to the legend, which names its
                          colour; with one service the legend says it once and
                          no row repeats it. */}
                      <span className="ml-1.5 flex h-5 min-w-0 flex-1 flex-wrap items-baseline gap-x-2 overflow-hidden leading-5">
                        <span className="min-w-0 max-w-full truncate">
                          {s.name}
                        </span>
                        {inks.size > 1 && (
                          <span
                            data-slot="trace-service"
                            className={cn(
                              "shrink-0 @max-xl/trace:hidden",
                              META,
                              "text-foreground/40",
                            )}
                          >
                            {s.service}
                          </span>
                        )}
                      </span>
                    </span>
                    <span className="relative h-full">
                      {/* The axis: cut by the view, faded where it cuts. */}
                      {/* Clipped only while zoomed, where a bar runs past the view:
                          whole, a bar at the trace's start or end keeps its error
                          ring, which stands outside the bar. */}
                      <span
                        data-slot="trace-axis"
                        className={cn(
                          "absolute inset-y-0 right-(--ag-trace-gutter) left-0",
                          zoomed && "overflow-hidden mask-(--ag-trace-cut)",
                        )}
                      >
                        {inView && (
                          <span
                            data-slot="trace-bar"
                            className={cn(
                              "absolute top-1/2 left-(--ag-trace-from) h-2.5 w-(--ag-trace-width) min-w-px -translate-y-1/2 rounded-[3px]",
                              s.detached
                                ? "border border-(--ag-trace-ink) bg-(--ag-trace-ink)/25"
                                : "bg-(--ag-trace-ink)",
                              // Its end not written: it fades out where the stream
                              // ends, still going for all the stream knows.
                              running &&
                                "rounded-r-none mask-[linear-gradient(to_right,#000_calc(100%-24px),transparent)]",
                            )}
                          />
                        )}
                      </span>
                      {inView && (
                        <span
                          data-slot="trace-duration"
                          className="pointer-events-none absolute inset-y-0 right-(--ag-trace-gutter) left-0"
                        >
                          <span
                            className={cn(
                              "absolute top-1/2 left-(--ag-trace-end) ml-1.5 -translate-y-1/2 whitespace-nowrap font-mono text-[10px] tabular-nums",
                              error ? TONE_WORDS.bad : "text-foreground/55",
                            )}
                          >
                            {took}
                          </span>
                        </span>
                      )}
                    </span>
                  </div>
                )
              })}
            </div>
          </div>
        </div>

        <Details
          span={chosen}
          whole={whole}
          ink={chosen ? (inks.get(chosen.service) as string) : undefined}
          material={m}
          spanLines={chosen ? (linesOf.get(chosen.id) ?? []) : []}
          line={line}
          onLine={onLineChange}
          onHover={setHovered}
          onZoom={() => chosen && zoomTo(chosen)}
          onClose={() => {
            if (!chosen) return
            choose(null)
            t.handle.focus(chosen.id)
          }}
        />
      </div>
      {/* The view in words for assistive tech, said as it moves. */}
      <p aria-live="polite" className="sr-only">
        {zoomed
          ? `showing +${durationWords(view.from)} to +${durationWords(view.to)} of ${durationWords(total)}`
          : ""}
      </p>
    </>
  )
  return (
    <Panel {...face} frame={framed} material={m} scheme={sch}>
      {inner}
    </Panel>
  )
}

const nameOf = (n: SpanNode) => n.span.name

/** A span's line in its details: when, in its tone, what. */
const LINE_ROW =
  "grid grid-cols-[9ch_minmax(0,1fr)] items-baseline gap-x-2 rounded-md px-1.5 py-0.5 font-mono text-[11px] leading-4 [&>:first-child]:whitespace-nowrap"

function LineWords({ line, from }: { line: LogLine; from: number }) {
  return (
    <>
      <span className="text-foreground/45 tabular-nums">
        +{durationWords(line.at - from)}
      </span>
      <span
        className={cn(
          "min-w-0 [overflow-wrap:anywhere]",
          line.level === "warn"
            ? TONE_WORDS.warn
            : line.level === "error"
              ? TONE_WORDS.bad
              : "text-foreground/80",
        )}
      >
        {line.message}
      </span>
    </>
  )
}

/** The details region: the same size whatever it holds (a column beside the
 *  waterfall as tall as it, or a band of fixed height under it), so a choice never
 *  moves the page. A quiet line until a span is chosen; then what it is, where it
 *  starts and how long it took, its status, then its attributes, scrolling inside
 *  the region when they are many. */
function Details({
  span,
  whole,
  ink,
  material,
  spanLines,
  line,
  onLine,
  onHover,
  onZoom,
  onClose,
}: {
  span: TraceSpan | undefined
  whole: Span
  ink: string | undefined
  material: Material
  /** The chosen span's own lines, in the order written. */
  spanLines: readonly LogLine[]
  line: string | null | undefined
  onLine: ((id: string) => void) | undefined
  onHover: (id: string | null) => void
  onZoom: () => void
  onClose: () => void
}) {
  const error = span?.status === "error"
  const attributes = Object.entries(span?.attributes ?? {})
  return (
    <div
      data-slot="trace-details"
      data-empty={span ? undefined : ""}
      className="relative h-48 min-w-0 border-foreground/10 border-t @3xl/trace:h-auto @3xl/trace:border-t-0 @3xl/trace:border-l"
      style={
        ink ? ({ "--ag-trace-ink": ink } as React.CSSProperties) : undefined
      }
    >
      <div className="ag-edge-fade-y absolute inset-0 px-1.5 pt-3 pb-1 [--edge-fade:1rem] @3xl/trace:pt-0 @3xl/trace:pl-4">
        {!span ? (
          <p className={cn("m-0 pt-1", META, "text-foreground/40")}>
            choose a span to read its details
          </p>
        ) : (
          <div className="grid gap-3">
            <div className="flex min-w-0 items-center gap-2">
              <span aria-hidden className={SWATCH} />
              {error && (
                <TriangleAlert
                  aria-hidden
                  className="size-3.5 shrink-0 text-tone-bad"
                />
              )}
              <h3
                data-slot="trace-details-name"
                className="m-0 min-w-0 truncate font-medium text-sm"
              >
                {span.name}
              </h3>
              <span className={cn("shrink-0", META)}>{span.service}</span>
              <span className="ml-auto flex shrink-0 items-center">
                <Button
                  variant="ghost"
                  size="icon-sm"
                  material={material}
                  aria-label="zoom to this span"
                  onClick={onZoom}
                >
                  <ZoomIn />
                </Button>
                <Button
                  variant="ghost"
                  size="icon-sm"
                  material={material}
                  aria-label="close the span's details (Esc)"
                  onClick={onClose}
                >
                  <X />
                </Button>
              </span>
            </div>
            <SpanLines
              span={span}
              lines={spanLines}
              line={line}
              onLine={onLine}
              onHover={onHover}
            />
            <div className="grid gap-x-8 gap-y-3 @xl/trace:grid-cols-2 @3xl/trace:grid-cols-1">
              <Readout material={material} data-slot="trace-details-span">
                <ReadoutRow label="starts">
                  +{durationWords(span.from - whole.from)}
                </ReadoutRow>
                <ReadoutRow label="duration">
                  {span.status === "running"
                    ? `${durationWords(span.to - span.from)} so far`
                    : durationWords(span.to - span.from)}
                </ReadoutRow>
                <ReadoutRow label="status" tone={error ? "bad" : undefined}>
                  {span.status}
                </ReadoutRow>
                {span.detached && (
                  <ReadoutRow label="detached">{span.detached}</ReadoutRow>
                )}
                <ReadoutRow label="span id">{span.id}</ReadoutRow>
              </Readout>
              {attributes.length > 0 && (
                <Readout
                  material={material}
                  aria-label="attributes"
                  data-slot="trace-details-attributes"
                >
                  {attributes.map(([key, value]) => (
                    <ReadoutRow
                      key={key}
                      label={<span className="font-mono text-xs">{key}</span>}
                    >
                      {fieldText(value)}
                    </ReadoutRow>
                  ))}
                </Readout>
              )}
            </div>
          </div>
        )}
      </div>
    </div>
  )
}

/** A span's lines in its details, first under its name: the way from the trace to
 *  the log beside it. A line is a button when there is a log to go to, words when
 *  not. */
function SpanLines({
  span,
  lines,
  line,
  onLine,
  onHover,
}: {
  span: TraceSpan
  lines: readonly LogLine[]
  line: string | null | undefined
  onLine: ((id: string) => void) | undefined
  /** A line under the hand or the focus, null when it leaves: the waterfall draws
   *  its instant. */
  onHover: (id: string | null) => void
}) {
  if (lines.length === 0) return null
  return (
    <section aria-label="lines" data-slot="trace-details-lines">
      <h4 className={cn("m-0 mb-1", META, "text-foreground/40")}>
        {lines.length} {lines.length === 1 ? "line" : "lines"}
      </h4>
      <ol className="m-0 grid list-none gap-px p-0">
        {lines.map((l) => (
          <li
            key={l.id}
            onPointerEnter={() => onHover(l.id)}
            onPointerLeave={() => onHover(null)}
            onFocus={() => onHover(l.id)}
            onBlur={() => onHover(null)}
          >
            {onLine ? (
              <button
                type="button"
                data-slot="trace-details-line"
                data-chosen={l.id === line || undefined}
                aria-current={l.id === line || undefined}
                onClick={() => onLine(l.id)}
                className={cn(
                  LINE_ROW,
                  "w-full cursor-pointer text-left hover:bg-foreground/6",
                  l.id === line && "bg-foreground/10",
                )}
              >
                <LineWords line={l} from={span.from} />
              </button>
            ) : (
              <div data-slot="trace-details-line" className={LINE_ROW}>
                <LineWords line={l} from={span.from} />
              </div>
            )}
          </li>
        ))}
      </ol>
    </section>
  )
}