← ui

timeline

ui

a medium's time as rows under one ruler that zooms: chapters, stills, claims

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

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

Latest changes · 2026-10-14
  • Its frame is Panel's, plain or glass.
  • A folded row still says which row it is: its name stays, in the outline rows' quiet voice (smaller, mono, fainter), and the folded row is a little taller to hold it (16 px). Before, a folded row showed only its arrow.
Updated 2026-10-14 · release notes

also called video editor

shelf motion

comes with timeline-view

uses clip, timebase, playback, button, kbd, keys, key-router, material, panel

the demo above, verbatim

timeline.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
// #region interview-media
import INTERVIEW_FACES from "@/public/apollo-11/cronkite-aldrin.faces.json"
// #endregion
// #region speech-media
import MOON_FACES from "@/public/kennedy/moon.faces.json"
import MOON_SAID from "@/public/kennedy/moon.transcript.json"
import INTERVIEW_LOUDNESS from "@/public/timeline/cronkite-aldrin.loudness.json"
import MOON_LOUDNESS from "@/public/timeline/moon.loudness.json"
// #endregion
import type { Chapter, Clip } from "@/registry/base-nova/lib/clip"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Playback } from "@/registry/base-nova/ui/playback"
import { PlayerChrome, PlayerTransport } from "@/registry/base-nova/ui/player"
import {
  type CurvePoint,
  Timeline,
  type TimelineAnswer,
  type TimelineClaim,
} from "@/registry/base-nova/ui/timeline"
import {
  VideoPicture,
  VideoPlayback,
} from "@/registry/base-nova/ui/video-playback"

// #region claims
/** A face found every half second (`[from, to, x, y, r]`, perceive's samples) as the
 *  spans the face is on screen: samples that touch join into one claim, the machine's. */
function onScreen(who: string, samples: number[][]): TimelineClaim[] {
  const spans: { from: number; to: number }[] = []
  for (const [from, to] of samples) {
    if (from === undefined || to === undefined)
      throw new Error(`${who}: a face sample is short`)
    const last = spans[spans.length - 1]
    if (last && from - last.to <= 500) last.to = to
    else spans.push({ from, to })
  }
  return spans.map((when, i) => ({
    id: `${who}-${i}`,
    when,
    answer: who,
    by: "machine",
  }))
}

/** A loudness file's `[ms, value]` pairs as a curve. */
const curveOf = (pairs: number[][]): CurvePoint[] =>
  pairs.map(([at, value]) => ({ at: at as number, value: value as number }))
// #endregion

// #region interview
// Walter Cronkite questions Buzz Aldrin, 5 July 1969 (NASA, public domain): the camera
// cuts between them; perceive found their faces, the sound is the clip's own.
const INTERVIEW_FPS = 30000 / 1001
const TURNS: Chapter[] = [
  { start: 0, title: "is the public overconfident?" },
  { start: 33_000, title: "the reaction we hoped for" },
  { start: 60_800, title: "if, or when?" },
  { start: 76_500, title: "when we land, not if" },
]
const CRONKITE = onScreen("cronkite", INTERVIEW_FACES.cronkite)
const ALDRIN = onScreen("aldrin", INTERVIEW_FACES.aldrin)
/** Where the picture cuts to the other man: every on-screen span's start but the
 *  first, an instant each. */
const CUTS: TimelineClaim[] = [...CRONKITE, ...ALDRIN]
  .map((c) => ("from" in c.when ? c.when.from : c.when.at))
  .filter((at) => at > 0)
  .sort((a, b) => a - b)
  .map((at) => ({ id: `cut-${at}`, when: { at }, by: "machine" }))
/** Who speaks, as a person said it so far: the first two turns. */
const SAID_SO_FAR: TimelineClaim[] = [
  {
    id: "said-0",
    when: { from: 640, to: 32_000 },
    answer: "cronkite",
    by: "person",
  },
  {
    id: "said-1",
    when: { from: 33_220, to: 60_000 },
    answer: "aldrin",
    by: "person",
  },
]
// #endregion

function Interview() {
  const [said, setSaid] = React.useState(SAID_SO_FAR)
  // A person's answer becomes their claim: kept here, handed back in the row.
  const answer = ({ when, answer }: TimelineAnswer) =>
    setSaid((was) => [
      ...was,
      { id: `said-${was.length}`, when, answer, by: "person" },
    ])
  return (
    <VideoPlayback
      src="/apollo-11/cronkite-aldrin.mp4"
      poster="/apollo-11/cronkite-aldrin.jpg"
      label="Cronkite questions Aldrin, July 1969"
      fps={INTERVIEW_FPS}
      chapters={TURNS}
    >
      <div className="flex flex-col gap-3">
        <VideoPicture
          aspect="626 / 476"
          className="mx-auto w-full max-w-sm rounded-xl"
        >
          <PlayerChrome>
            <PlayerTransport only={["play"]} />
          </PlayerChrome>
        </VideoPicture>
        <Timeline
          label="Cronkite questions Aldrin"
          filmstrip={{
            still: (ms) =>
              `/gallery-stills/cronkite-aldrin/${Math.min(108, Math.floor(ms / 1000))}.jpg`,
            every: 1000,
            aspect: 626 / 476,
          }}
          rows={[
            {
              id: "speaks",
              kind: "claims",
              label: "who speaks",
              claims: said,
              answers: ["cronkite", "aldrin"],
            },
            {
              id: "cronkite",
              kind: "claims",
              label: "cronkite on screen",
              claims: CRONKITE,
            },
            {
              id: "aldrin",
              kind: "claims",
              label: "aldrin on screen",
              claims: ALDRIN,
            },
            { id: "cuts", kind: "claims", label: "cuts", claims: CUTS },
            {
              id: "loud",
              kind: "curve",
              label: "loudness",
              points: curveOf(INTERVIEW_LOUDNESS),
            },
          ]}
          onAnswer={answer}
        />
      </div>
    </VideoPlayback>
  )
}

// #region speech
// John F. Kennedy at Rice University, 12 September 1962: his words as listen timed
// them, his face where perceive found it (the camera leaves him for the crowd).
const MOON_FPS = 24000 / 1001
const MOON_PARTS: Chapter[] = [
  { start: 0, title: "why climb the highest mountain" },
  { start: 9_060, title: "we choose to go to the moon" },
  { start: 19_860, title: "because they are hard" },
  { start: 39_720, title: "and we intend to win" },
]
const WORDS: TimelineClaim[] = MOON_SAID.map((u, i) => ({
  id: `said-${i}`,
  when: u.span,
  answer: u.text,
}))
const KENNEDY = onScreen("kennedy", MOON_FACES.kennedy)
// #endregion

function Speech() {
  return (
    <VideoPlayback
      src="/kennedy/moon.mp4"
      poster="/kennedy/moon.jpg"
      label="John F. Kennedy, September 1962"
      fps={MOON_FPS}
      chapters={MOON_PARTS}
    >
      <div className="flex flex-col gap-3">
        <VideoPicture
          aspect="640 / 480"
          className="mx-auto w-full max-w-xs rounded-xl"
        >
          <PlayerChrome>
            <PlayerTransport only={["play"]} />
          </PlayerChrome>
        </VideoPicture>
        <Timeline
          label="We choose to go to the Moon"
          filmstrip={{
            still: (ms) =>
              `/timeline/moon/${Math.min(49, Math.floor(ms / 1000))}.jpg`,
            every: 1000,
            aspect: 640 / 480,
          }}
          rows={[
            { id: "said", kind: "claims", label: "said", claims: WORDS },
            {
              id: "kennedy",
              kind: "claims",
              label: "kennedy on screen",
              claims: KENNEDY,
            },
            {
              id: "loud",
              kind: "curve",
              label: "loudness",
              points: curveOf(MOON_LOUDNESS),
            },
          ]}
        />
      </div>
    </VideoPlayback>
  )
}

// #region many
// An hour of drawn time with ten thousand machine claims and a value every 100 ms:
// what a long recording's detections look like. Seeded, so every visit is the same.
const HOUR: Clip = {
  duration: 3_600_000,
  chapters: [0, 1, 2, 3].map((q) => ({
    start: q * 900_000,
    title: `quarter ${q + 1}`,
  })),
}
const seeded = (seed: number) => () => {
  seed = (seed * 1_103_515_245 + 12_345) % 2 ** 31
  return seed / 2 ** 31
}
const random = seeded(11)
const GUESSES: TimelineClaim[] = Array.from({ length: 10_000 }, (_, i) => {
  const from = Math.floor(random() * 3_590_000)
  return {
    id: `guess-${i}`,
    when: { from, to: from + 500 + Math.floor(random() * 8_000) },
    answer: "a ball",
    by: "machine",
  }
})
// #endregion

function Many() {
  return (
    <Playback clip={HOUR} autoPlay={false}>
      <Timeline
        label="an hour of detections"
        rows={[
          { id: "ball", kind: "claims", label: "ball seen", claims: GUESSES },
        ]}
      />
    </Playback>
  )
}

export default function TimelineDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="interview"
        title="an interview, every row"
        description="Click in the timeline and the keys are an editor's: J K L shuttle, ← → a frame, ⌥ ← → the next edge, I and O set the selection, 1 and 2 say who speaks over it, R repeats it, B goes back to where you were. A pinch zooms at the hand down to the frame; a drag selects, snapping to every edge."
        with="interview-media claims interview"
      >
        <Interview />
      </Sample>
      <Sample
        name="speech"
        title="a speech, in glass"
        with="speech-media claims speech"
      >
        <MaterialProvider material="glass">
          <div className="rounded-2xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-4 sm:p-8">
            <Speech />
          </div>
        </MaterialProvider>
      </Sample>
      <Sample
        name="many"
        glance="wide"
        title="ten thousand claims"
        description="The whole hour paints the row; zoom in and it becomes claims you can point at, each drawn only while it is in view."
        with="many"
      >
        <Many />
      </Sample>
    </div>
  )
}

the source, every file an install writes

timeline.tsx
"use client"

// THE TIMELINE: a medium's time laid out as rows under one ruler, to look at what is
// known about it and to say more. It follows the timebase around it (VideoPlayback,
// AudioPlayback, Playback: one owner, this is a follower) and stores nothing about the
// medium: the consumer hands its claims in, and labelling hands new ones back.
//
//   the view      the window of time the width shows (lib/timeline-view). A pinch or
//                 ⌘-wheel zooms at the hand, from the whole medium down to a dozen
//                 frames; a sideways swipe pans; both stop at the ends, nothing bands,
//                 and every delta a trackpad sends moves it at full weight (no momentum
//                 of our own, none projected: the lightbox's lessons). + - 0 at the
//                 playhead. The ruler's labels never touch (frames and timecode close
//                 in), and an overview under it shows where the view is in the whole.
//   the rows      the outline (each level a row of its divisions), a filmstrip of
//                 stills along the view, then the consumer's rows: claims drawn as
//                 bands (spans) and pins (instants), a person's solid, a machine's
//                 outlined, overlapping ones stacked; curves drawn as ridges, every
//                 peak kept at any width. A row collapses to a line. Thousands of claims
//                 cost what the view shows (lib/timeline-rows); past a few hundred in
//                 view a row is painted on a canvas instead of drawn as nodes.
//   the hand      resting on the rows shows that instant in the picture (skimming,
//                 the preview every timeline here makes); a press seeks; a drag
//                 selects a span, its ends snapping to every claim's edge, the outline,
//                 the playhead (⌥ held, free); a press on a claim selects it, found
//                 by the row's index under the hand, painted or drawn as nodes.
//   the keys      an editor's table, not the watching player's (lib/timeline-shuttle,
//                 EDIT_KEYS): J K L shuttle, ← → a frame, ⌥ the next edge, ⇧ the
//                 selection's end, ↑ ↓ the row, I O in and out, X the claim under the
//                 playhead, R repeats the selection (the timebase's loop), B back to
//                 where you were (a big move leaves the way back on the ruler), 1-9
//                 answer the row's question. One table drives the handler, a legend
//                 and `aria-keyshortcuts` alike. Its own while focused: the player's
//                 keys never hear them, and a control inside it keeps its own.
//   labelling     a selection (or the playhead), a row (its question) and a number
//                 key (its answer) become a claim, handed to `onAnswer`; the consumer
//                 keeps it and hands it back in the row, by a person.
//   heard         one focusable widget (`application`, named by `label`), its active
//                 descendant the row in hand or the claim chosen in it, each claim
//                 named by its row's question, answer, when and whose; an answer, an
//                 in, an out, a repeat and the way back said in a polite live region.
//
// Both materials (ui/material): plain on the page's surfaces, glass over a picture;
// `scheme` the colour scheme it stands in.

import { ChevronDown, ChevronRight, Maximize2, Minus, Plus } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  divisionSpans,
  type Level,
  type Span,
} from "@/registry/base-nova/lib/clip"
import { capOf } from "@/registry/base-nova/lib/keys"
import { timeLabel } from "@/registry/base-nova/lib/timebase"
import {
  type ClaimIndex,
  type CurvePoint,
  claimAt,
  countIn,
  decimate,
  edgesOf,
  indexClaims,
  isInstant,
  nextEdge,
  type Painting,
  pack,
  repaintOf,
  spanOf,
  type TimelineClaim,
  visibleIn,
  type When,
} from "@/registry/base-nova/lib/timeline-rows"
import {
  EDIT_KEYS,
  EDIT_SHORTCUTS,
  type EditAction,
  goBack,
  isJump,
  resolveEdit,
  shuttle,
} from "@/registry/base-nova/lib/timeline-shuttle"
import {
  clampView,
  instantAt,
  minSpanOf,
  msPerPx,
  onFrame,
  panBy,
  snapTo,
  ticksOf,
  type View,
  viewAround,
  viewFollowing,
  wholeView,
  xOf,
  zoomAt,
} from "@/registry/base-nova/lib/timeline-view"
import { Button } from "@/registry/base-nova/ui/button"
import { Kbd } from "@/registry/base-nova/ui/kbd"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import {
  useHoverIntent,
  useOutline,
  useTimebase,
  useTimebaseState,
} from "@/registry/base-nova/ui/playback"

export type { CurvePoint, TimelineClaim, When }
export { EDIT_KEYS }

/** A row of claims: one question about the medium (its label), the claims made about
 *  it, and, when a person may answer it here, the answers the number keys give. */
export interface TimelineClaimRow {
  id: string
  label: string
  kind: "claims"
  claims: readonly TimelineClaim[]
  /** The answers to its question, in key order: 1 the first. */
  answers?: readonly string[]
}

/** A row of a curve: values 0..1 over time (how loud, how sure). */
export interface TimelineCurveRow {
  id: string
  label: string
  kind: "curve"
  points: readonly CurvePoint[]
}

export type TimelineRow = TimelineClaimRow | TimelineCurveRow

/** A person's answer: the row (its question), when, and what. The consumer makes it a
 *  claim, by a person, and hands it back in the row. */
export interface TimelineAnswer {
  row: string
  when: When
  answer: string
}

/** The stills along the view: the one at an instant, a source's address. */
export interface TimelineFilmstrip {
  still: (ms: number) => string
  /** How often the source has a still, ms: no two tiles ask for one between. */
  every?: number
  /** The medium's width over its height. */
  aspect: number
}

export interface TimelineProps {
  /** What it is the timeline of: its accessible name. */
  label: string
  rows: readonly TimelineRow[]
  /** Levels finer than the timebase's own outline (scenes, shots), coarse to fine. */
  levels?: readonly Level[]
  filmstrip?: TimelineFilmstrip
  /** A person answered a row's question (1-9) for the selection or the playhead. */
  onAnswer?: (answer: TimelineAnswer) => void
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  className?: string
}

/** A span selected, and the claim it is when a claim was chosen (by its id, never
 *  read back from what the page drew). */
type Selection = Span & { claim?: { row: string; id: string } }

/** A claim row as laid out: its claims indexed and stacked. */
type Packed = {
  index: ClaimIndex
  line: ReadonlyMap<string, number>
  lines: number
}

/** When, in words: timecode when the medium has frames, a clock when not. */
const whenWords = (when: When, fps: number | null) => {
  const form = fps ? "timecode" : "clock"
  const s = spanOf(when)
  return isInstant(when) || s.to === s.from
    ? `at ${timeLabel(s.from, form, fps)}`
    : `${timeLabel(s.from, form, fps)} to ${timeLabel(s.to, form, fps)}`
}

/** A claim's name for assistive tech: the row's question, its answer, when, by whom. */
const claimWords = (
  row: TimelineClaimRow,
  claim: TimelineClaim,
  fps: number | null,
) =>
  [
    `${row.label}: ${claim.answer ?? "no answer"}`,
    whenWords(claim.when, fps),
    claim.by && `by a ${claim.by}`,
  ]
    .filter(Boolean)
    .join(", ")

/** The element id of a chosen claim, the timeline's active descendant: one per claim,
 *  so choosing another is heard as a move. */
const chosenIdOf = (uid: string, row: string, claim: string) =>
  `${uid}-claim-${encodeURIComponent(row)}-${encodeURIComponent(claim)}`

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

const RULER_H = 28
const OVERVIEW_H = 14
const LINE_H = 22
const MAX_LINES = 4
/** A folded row: one line of its name in the quiet voice of the outline's rows. */
const COLLAPSED_H = 16
const CURVE_H = 40
const OUTLINE_H = 22
const FILM_H = 44
/** Past this many claims in view, a row is painted, not drawn as nodes. */
const MAX_NODES = 300
/** How near (px) a drag's end comes to an edge before it is pulled onto it. */
const SNAP_PX = 7
/** How near (px) a press comes to a claim to land on it: a pin is a hair wide. */
const HIT_PX = 4
/** A press that moves less than this (px) is a press, not a drag. */
const DRAG_PX = 4
/** After the hand moves the view, how long playing leaves it alone, ms. */
const HANDS_OFF_MS = 1800
/** The ruler's type: tabular mono at 10 px, its advance a share of the em. */
const RULER_CHAR_PX = 6.2
/** 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

const FACE = {
  lane: { plain: "bg-foreground/[0.035]", glass: "bg-foreground/[0.07]" },
} as const

type RowItem =
  | { kind: "outline"; key: string; level: Level; height: number }
  | { kind: "film"; key: string; height: number }
  | {
      kind: "claims"
      key: string
      row: TimelineClaimRow
      height: number
      collapsed: boolean
    }
  | {
      kind: "curve"
      key: string
      row: TimelineCurveRow
      height: number
      collapsed: boolean
    }

export function Timeline({
  label,
  rows,
  levels,
  filmstrip,
  onAnswer,
  material,
  scheme,
  className,
}: TimelineProps) {
  const m = useMaterial(material)
  const timebase = useTimebase()
  const duration = useTimebaseState((s) => Math.max(1, s.duration))
  const fps = useTimebaseState((s) => s.fps)
  const range = useTimebaseState((s) => s.range)
  // The instant is never read up here: it changes every frame while it plays, and
  // only the playhead (written straight to its element), the ruler's value, the
  // overview's line and the readout follow it, each on its own.
  const outline = useOutline()
  const uid = React.useId()
  const minSpan = minSpanOf(fps)

  // ── the view, and the width it is drawn at ──
  const root = React.useRef<HTMLDivElement>(null)
  // Over a picture it is read by the part of it under the timeline.
  const [sch, here] = useSurfaceScheme(scheme, root)
  const lanes = React.useRef<HTMLDivElement>(null)
  const [width, setWidth] = React.useState(0)
  React.useEffect(() => {
    const el = lanes.current
    if (!el) return
    const observer = new ResizeObserver(() => setWidth(el.clientWidth))
    observer.observe(el)
    setWidth(el.clientWidth)
    return () => observer.disconnect()
  }, [])
  const [viewState, setViewState] = React.useState<View | null>(null)
  // One object per view, so a render that moved nothing (a drag's selection) hands
  // the lanes the same view and they draw nothing again.
  const view = React.useMemo(
    () => clampView(viewState ?? wholeView(duration), duration, minSpan),
    [viewState, duration, minSpan],
  )
  const live = React.useRef({ view, width, duration, minSpan, fps })
  live.current = { view, width, duration, minSpan, fps }
  const handsAt = React.useRef(0)
  const moveView = React.useCallback(
    (next: (v: View) => View, byHand = true) => {
      if (byHand) handsAt.current = performance.now()
      setViewState((was) => {
        const { duration: d, minSpan: min } = live.current
        return clampView(next(clampView(was ?? wholeView(d), d, min)), d, min)
      })
    },
    [],
  )

  // ── session state: the selection, the row in hand, the way back, the shuttle ──
  const [selection, setSelection] = React.useState<Selection | null>(null)
  // What the last key did, said to a screen reader (a polite live region): an answer
  // given, an in or an out set, the way back taken.
  const [said, setSaid] = React.useState("")
  // The ink a painted row draws in: the timeline's own text colour, read once per
  // scheme (its own, or the page's theme class changing under it), never per paint.
  // Only the root's class is watched: a style written on it per frame (a scroll lock,
  // a variable) must not cost a style read each time.
  const [ink, setInk] = React.useState("")
  // biome-ignore lint/correctness/useExhaustiveDependencies: read again when the scheme or the material changes the ink, not only on mount
  React.useLayoutEffect(() => {
    const el = root.current
    if (!el) return
    const read = () => setInk(getComputedStyle(el).color)
    read()
    const theme = new MutationObserver(read)
    theme.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    })
    return () => theme.disconnect()
  }, [sch, m])
  const [rowAt, setRowAt] = React.useState(0)
  const [back, setBack] = React.useState<number | null>(null)
  const [rate, setRate] = React.useState(0)
  const [collapsed, setCollapsed] = React.useState<ReadonlySet<string>>(
    () => new Set(),
  )

  // ── the rows, as laid out ──
  const shownLevels = React.useMemo(
    () =>
      [...outline, ...(levels ?? [])].filter(
        (l) => l.divisions.length > 1 || !!l.divisions[0]?.title,
      ),
    [outline, levels],
  )
  const packed = React.useMemo(
    () =>
      new Map<string, Packed>(
        rows
          .filter((r): r is TimelineClaimRow => r.kind === "claims")
          .map((r) => [
            r.id,
            { index: indexClaims(r.claims), ...pack(r.claims) },
          ]),
      ),
    [rows],
  )
  const items: RowItem[] = React.useMemo(
    () => [
      ...shownLevels.map(
        (level): RowItem => ({
          kind: "outline",
          key: `outline:${level.name}`,
          level,
          height: OUTLINE_H,
        }),
      ),
      ...(filmstrip
        ? [{ kind: "film", key: "film", height: FILM_H } as RowItem]
        : []),
      ...rows.map((row): RowItem => {
        const folded = collapsed.has(row.id)
        if (row.kind === "curve")
          return {
            kind: "curve",
            key: row.id,
            row,
            collapsed: folded,
            height: folded ? COLLAPSED_H : CURVE_H,
          }
        const lines = packed.get(row.id)?.lines ?? 1
        return {
          kind: "claims",
          key: row.id,
          row,
          collapsed: folded,
          height: folded ? COLLAPSED_H : Math.min(MAX_LINES, lines) * LINE_H,
        }
      }),
    ],
    [shownLevels, filmstrip, rows, collapsed, packed],
  )
  const focusRow = rows[Math.min(rowAt, rows.length - 1)]
  // How many of each claim row's claims the view holds: said with the row's name.
  const inViewCount = React.useMemo(
    () => new Map([...packed].map(([id, p]) => [id, countIn(p.index, view)])),
    [packed, view],
  )

  // Every instant a hand aims for: claims' edges, the outline's divisions.
  const edges = React.useMemo(() => {
    const all = [
      ...rows.flatMap((r) => (r.kind === "claims" ? edgesOf(r.claims) : [])),
      ...shownLevels.flatMap((l) => l.divisions.map((d) => d.start)),
      0,
      duration,
    ]
    return [...new Set(all)].sort((a, b) => a - b)
  }, [rows, shownLevels, duration])
  const rowEdges = React.useMemo(
    () => (focusRow?.kind === "claims" ? edgesOf(focusRow.claims) : edges),
    [focusRow, edges],
  )

  // ── the playhead, the hand's line and the way back: written, never re-rendered ──
  const playhead = React.useRef<HTMLDivElement>(null)
  const shuttling = React.useRef(false)
  React.useEffect(() => {
    let last = { at: timebase.get().at, wall: performance.now() }
    const place = () => {
      const s = timebase.get()
      const { view: v, width: w, duration: d } = live.current
      const el = playhead.current
      if (el && w > 0) {
        const x = xOf(v, w, s.at)
        el.style.transform = `translateX(${x}px)`
        el.style.opacity = x < -1 || x > w + 1 ? "0" : "1"
      }
      const now = performance.now()
      // A big move leaves a way back: the instant it left.
      if (
        s.preview === null &&
        !shuttling.current &&
        isJump(last.at, s.at, now - last.wall, s.playing ? s.rate : 0)
      )
        setBack(last.at)
      if (s.preview === null) last = { at: s.at, wall: now }
      // Playing on past the view's edge turns the page, unless a hand just moved it.
      if (
        s.playing &&
        now - handsAt.current > HANDS_OFF_MS &&
        (s.at > v.to || s.at < v.from)
      )
        moveView((view) => viewFollowing(view, s.at, d), false)
    }
    place()
    return timebase.subscribe(place)
  }, [timebase, moveView])
  // The playhead follows a view or width change too.
  // biome-ignore lint/correctness/useExhaustiveDependencies: placed again on each view
  React.useLayoutEffect(() => {
    const el = playhead.current
    if (!el || width === 0) return
    const x = xOf(view, width, timebase.get().at)
    el.style.transform = `translateX(${x}px)`
    el.style.opacity = x < -1 || x > width + 1 ? "0" : "1"
  }, [view.from, view.to, width, timebase])

  // ── the shuttle: forwards through the timebase's own speed, backwards by seeking ──
  React.useEffect(() => {
    const { controls } = timebase
    shuttling.current = rate !== 0
    if (rate === 0) return
    if (rate > 0 && controls.setRate) {
      controls.setRate(rate, { remember: false, pitch: "follows" })
      controls.play()
      return () => controls.setRate?.(1, { remember: false })
    }
    controls.pause()
    let frame = 0
    let was = performance.now()
    // Its own position, moved by the clock and sought to: never read back from the
    // timebase, whose presented frame lands at or before a seek's instant (a decoder
    // shows what it has), so reading it back would run away backwards.
    let pos = timebase.get().at
    const tick = (now: number) => {
      const { duration: end } = timebase.get()
      pos += Math.max(0, now - was) * rate
      was = now
      if (pos <= 0 || pos >= end) {
        controls.seek(Math.min(Math.max(0, pos), end))
        setRate(0)
        return
      }
      controls.seek(pos)
      frame = requestAnimationFrame(tick)
    }
    frame = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frame)
  }, [rate, timebase])
  // A pause from anywhere else (the bar, space) ends a forward shuttle: a fall from
  // playing to paused, never "not playing yet" (the play a shuttle asks for lands a
  // frame or more after it).
  React.useEffect(() => {
    if (rate <= 0) return
    let was = timebase.get().playing
    return timebase.subscribe(() => {
      const now = timebase.get().playing
      if (was && !now) setRate(0)
      was = now
    })
  }, [rate, timebase])

  // ── the hand on the rows ──
  const xToMs = (clientX: number) => {
    const r = lanes.current?.getBoundingClientRect()
    if (!r) return 0
    return instantAt(view, Math.max(1, r.width), clientX - r.left)
  }
  const snap = (ms: number, free: boolean) => {
    if (free) return onFrame(ms, fps)
    const candidates = [...edges, timebase.get().at].sort((a, b) => a - b)
    const snapped = snapTo(ms, candidates, view, Math.max(1, width), SNAP_PX)
    return snapped.snapped ? snapped.at : onFrame(snapped.at, fps)
  }
  // Skimming: a hand resting on the rows shows that instant, while it is paused; a
  // preview holds the clock, and playing on under a resting hand is what was asked.
  const intent = useHoverIntent(
    (p) => {
      if (!timebase.get().playing) timebase.controls.preview(xToMs(p.clientX))
    },
    () => timebase.controls.endPreview(),
  )
  // What a press lands on, from the layout the timeline itself made (the ruler, then
  // each row's height, each claim row's lines) and the rows' own index, never from
  // the element under the hand: a row painted on a canvas answers as one of nodes. An
  // instant (x), a row (y), then the index's binary search for the claims near it.
  const hitAt = (clientX: number, clientY: number): Selection | null => {
    const r = lanes.current?.getBoundingClientRect()
    if (!r) return null
    const ms = xToMs(clientX)
    const y = clientY - r.top
    let top = RULER_H
    for (const item of items) {
      if (y < top) return null
      if (y >= top + item.height) {
        top += item.height
        continue
      }
      if (item.kind === "outline") {
        const d = divisionSpans(item.level, duration).find(
          (d) => d.start <= ms && ms < d.end,
        )
        return d ? { from: d.start, to: d.end } : null
      }
      if (item.kind !== "claims") return null
      const p = packed.get(item.row.id)
      if (!p) throw new Error(`timeline: row ${item.row.id} was never packed`)
      const lines = item.collapsed ? 1 : p.lines
      const claim = claimAt(
        p.index,
        ms,
        HIT_PX * msPerPx(view, Math.max(1, width)),
        item.collapsed
          ? undefined
          : {
              of: (id) => p.line.get(id) ?? 0,
              at: Math.min(
                lines - 1,
                Math.floor((y - top) / (item.height / lines)),
              ),
            },
      )
      return claim
        ? { ...spanOf(claim.when), claim: { row: item.row.id, id: claim.id } }
        : null
    }
    return null
  }
  const drag = React.useRef<{
    x: number
    from: number
    moved: boolean
    claim: Selection | null
    scrub: boolean
  } | null>(null)
  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (e.button !== 0) return
    intent.leave()
    // A press in the timeline gives it the keys: the timeline itself holds the focus
    // (nothing inside the lanes can take it).
    root.current?.focus({ preventScroll: true })
    const r = lanes.current?.getBoundingClientRect()
    const onRuler = !!r && e.clientY - r.top < RULER_H
    e.currentTarget.setPointerCapture(e.pointerId)
    drag.current = {
      x: e.clientX,
      from: snap(xToMs(e.clientX), e.altKey),
      moved: false,
      claim: onRuler ? null : hitAt(e.clientX, e.clientY),
      scrub: onRuler,
    }
    if (onRuler) timebase.controls.seek(onFrame(xToMs(e.clientX), fps))
  }
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    const d = drag.current
    if (!d) return intent.move(e)
    if (d.scrub) return timebase.controls.seek(onFrame(xToMs(e.clientX), fps))
    if (!d.moved && Math.abs(e.clientX - d.x) < DRAG_PX) return
    d.moved = true
    const to = snap(xToMs(e.clientX), e.altKey)
    setSelection({ from: Math.min(d.from, to), to: Math.max(d.from, to) })
  }
  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
    const d = drag.current
    drag.current = null
    if (!d || d.scrub) return
    e.currentTarget.releasePointerCapture(e.pointerId)
    if (d.moved) return
    // A press: on a claim selects it and goes to its start (an outline's division as
    // a span, a claim by its id, a pin too); elsewhere goes there.
    if (d.claim) {
      choose(d.claim)
      timebase.controls.seek(d.claim.from)
    } else timebase.controls.seek(d.from)
  }
  // A claim (or a division) chosen, by a press or by X: selected, and its row the row
  // in hand, so the claim is the timeline's active descendant and is heard by name.
  const choose = (chosen: Selection) => {
    setSelection(chosen.claim || chosen.to > chosen.from ? chosen : null)
    const row = chosen.claim?.row
    if (row !== undefined) setRowAt(rows.findIndex((r) => r.id === row))
  }

  // ── the wheel: a pinch zooms at the hand, a sideways swipe pans ──
  React.useEffect(() => {
    const el = lanes.current
    if (!el) return
    const onWheel = (e: WheelEvent) => {
      const unit = e.deltaMode === 1 ? LINE_PX : 1
      const dx = e.deltaX * unit
      const dy = e.deltaY * unit
      const { view: v, width: w } = live.current
      if (e.ctrlKey || e.metaKey) {
        e.preventDefault()
        const r = el.getBoundingClientRect()
        const anchor = instantAt(v, Math.max(1, r.width), e.clientX - r.left)
        moveView((view) =>
          zoomAt(
            view,
            anchor,
            Math.exp(-dy * ZOOM_PER_PX),
            live.current.duration,
            live.current.minSpan,
          ),
        )
        return
      }
      const sideways = e.shiftKey
        ? dy || dx
        : Math.abs(dx) > Math.abs(dy)
          ? dx
          : 0
      // Up and down stay the page's.
      if (!sideways) return
      e.preventDefault()
      moveView((view) =>
        panBy(view, sideways * msPerPx(v, w), live.current.duration),
      )
    }
    el.addEventListener("wheel", onWheel, { passive: false })
    return () => el.removeEventListener("wheel", onWheel)
  }, [moveView])

  // ── the keys: an editor's, while the timeline has focus ──
  const kHeld = React.useRef(false)
  const answer = (row: TimelineRow | undefined, n: number) => {
    if (row?.kind !== "claims" || !row.answers || !onAnswer) return false
    const text = row.answers[n - 1]
    if (!text) return false
    const when: When =
      selection && selection.to > selection.from
        ? { from: selection.from, to: selection.to }
        : { at: onFrame(timebase.get().at, fps) }
    onAnswer({ row: row.id, when, answer: text })
    setSaid(`${row.label}: ${text}, ${whenWords(when, fps)}`)
    return true
  }
  // One table drives it (lib/timeline-shuttle's EDIT_KEYS, `resolveEdit`): a key not
  // in it is never the timeline's, and a press a control inside it owns (a zoom
  // button's Space, a field's letters, a claim's own button) is that control's
  // (key-router's `ownedHere`, counted up to the timeline itself, which holds the
  // focus).
  const onKey = React.useEffectEvent((e: KeyboardEvent) => {
    if (ownedHere(e, root.current)) return false
    const hit = resolveEdit(e, kHeld.current)
    if (!hit) return false
    // Held, a key that keeps going goes on (the arrows, the zoom, a frame step under
    // K); any other is one press: a shuttle speeds up by pressing again, never by
    // holding.
    if (e.repeat && !hit.row.repeats) return true
    const { controls } = timebase
    const s = timebase.get()
    const frame = fps ? 1000 / fps : 100
    const seekTo = (ms: number) =>
      controls.seek(Math.min(duration, Math.max(0, onFrame(ms, fps))))
    const dirOf = (back: EditAction) => (hit.action === back ? -1 : 1)
    switch (hit.action) {
      case "shuttle-back":
      case "shuttle-forward":
        setRate((r) => shuttle(r, hit.action === "shuttle-back" ? "j" : "l"))
        return true
      case "held-back":
      case "held-forward": {
        const dir = dirOf("held-back")
        if (fps) controls.step(dir)
        else controls.jump(dir * 100)
        return true
      }
      case "stop":
        kHeld.current = true
        setRate(0)
        controls.pause()
        return true
      case "toggle":
        setRate(0)
        controls.toggle()
        return true
      case "extend-back":
      case "extend-forward": {
        const dir = dirOf("extend-back")
        const anchor = selection ? selection.from : s.at
        const end = selection ? selection.to + dir * frame : s.at + dir * frame
        setSelection({ from: Math.min(anchor, end), to: Math.max(anchor, end) })
        return true
      }
      case "edge-back":
      case "edge-forward": {
        const edge = nextEdge(rowEdges, s.at, dirOf("edge-back"))
        if (edge !== null) seekTo(edge)
        return true
      }
      case "frame-back":
      case "frame-forward": {
        const dir = dirOf("frame-back")
        if (fps && !s.playing) controls.step(dir)
        else seekTo(s.at + dir * frame)
        return true
      }
      case "row-up":
      case "row-down":
        setRowAt((i) =>
          Math.min(
            Math.max(0, rows.length - 1),
            Math.max(0, i + dirOf("row-up")),
          ),
        )
        return true
      case "in":
      case "out": {
        const here = onFrame(s.at, fps)
        const from = hit.action === "in" ? here : (selection?.from ?? 0)
        const to = hit.action === "out" ? here : (selection?.to ?? duration)
        const next = { from: Math.min(from, to), to: Math.max(from, to) }
        setSelection(next)
        setSaid(`${hit.action}: ${whenWords(next, fps)}`)
        return true
      }
      case "select-claim": {
        // The claim under the playhead, found by the index (the row in hand's, else
        // any claim row's), and chosen by its id: it becomes the active descendant,
        // heard by its name.
        const pool =
          focusRow?.kind === "claims"
            ? [focusRow]
            : rows.filter((r): r is TimelineClaimRow => r.kind === "claims")
        for (const r of pool) {
          const p = packed.get(r.id)
          const under = p && claimAt(p.index, s.at, 0)
          if (under && !isInstant(under.when)) {
            choose({
              ...spanOf(under.when),
              claim: { row: r.id, id: under.id },
            })
            return true
          }
        }
        return true
      }
      case "repeat":
        if (s.range) {
          controls.setRange(null)
          setSaid("repeat off")
        } else if (selection && selection.to > selection.from) {
          controls.setRange({ from: selection.from, to: selection.to })
          controls.seek(selection.from, { play: true })
          setSaid(`repeating ${whenWords(selection, fps)}`)
        }
        return true
      case "go-back": {
        const way = goBack(back, s.at)
        if (way) {
          setBack(way.back)
          controls.seek(way.to)
          moveView((v) => viewFollowing(v, way.to, duration), false)
          setSaid(`back ${whenWords({ at: way.to }, fps)}`)
        }
        return true
      }
      case "zoom-in":
      case "zoom-out":
        moveView((v) =>
          zoomAt(
            v,
            s.at,
            hit.action === "zoom-out" ? 0.5 : 2,
            duration,
            minSpan,
          ),
        )
        return true
      case "whole":
        moveView(() =>
          selection && selection.to > selection.from
            ? viewAround(selection, duration, minSpan)
            : wholeView(duration),
        )
        return true
      case "clear":
        if (!selection && !s.range) return false
        setSelection(null)
        if (s.range) controls.setRange(null)
        setSaid("selection cleared")
        return true
      case "answer":
        return answer(focusRow, Number(capOf(e)))
    }
  })
  React.useEffect(() => {
    const el = root.current
    if (!el) return
    // On the timeline itself, so a press handled here never reaches the page's keys
    // (the watching player's, on the key router).
    const down = (e: KeyboardEvent) => {
      if (onKey(e)) {
        e.preventDefault()
        e.stopPropagation()
      }
    }
    // K held makes J and L frame steps until it is let go, or until the window loses
    // the keyboard (⌘Tab away with K down never sends its keyup here).
    const up = (e: KeyboardEvent) => {
      if (capOf(e).toLowerCase() === "k") kHeld.current = false
    }
    const lost = () => {
      kHeld.current = false
    }
    el.addEventListener("keydown", down)
    el.addEventListener("keyup", up)
    window.addEventListener("blur", lost)
    return () => {
      el.removeEventListener("keydown", down)
      el.removeEventListener("keyup", up)
      window.removeEventListener("blur", lost)
    }
  }, [])

  const toggle = (id: string) =>
    setCollapsed((was) => {
      const next = new Set(was)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })
  const lane = FACE.lane[m]
  const repeating =
    !!range &&
    !!selection &&
    range.from === selection.from &&
    range.to === selection.to
  const chosen = selection?.claim
  // The active descendant: the claim chosen in the row in hand, else that row.
  const active =
    chosen && chosen.row === focusRow?.id
      ? chosenIdOf(uid, chosen.row, chosen.id)
      : focusRow
        ? `${uid}-row-${rows.indexOf(focusRow)}`
        : undefined

  return (
    // THE TIMELINE IS ONE WIDGET with its own keys (J K L, I O, the numbers), so it
    // is the focus itself (`application`: a screen reader hands it the keys), not a
    // ruler posing as a slider. Its active descendant is the row in hand, said by
    // name with its claims and answers, or the claim chosen in it (a press, or X),
    // said by its row's question, answer, when and whose; each row's claims are a
    // named list (a painted row says how many it holds and to zoom in). What a key did
    // is said in a polite live region.
    // Its frame is the one container's (Panel), plain or glass.
    <Panel
      render={<div />}
      ref={here}
      material={m}
      scheme={sch}
      role="application"
      aria-roledescription="timeline"
      aria-label={label}
      aria-describedby={`${uid}-hint`}
      aria-keyshortcuts={EDIT_SHORTCUTS}
      aria-activedescendant={active}
      tabIndex={0}
      data-slot="timeline"
      className={cn(
        "relative isolate flex flex-col gap-2 p-3 text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
        className,
      )}
    >
      <p id={`${uid}-hint`} className="sr-only">
        an editor: J K L shuttle, arrows step a frame, up and down change the
        row, I and O set in and out, X chooses the claim under the playhead, and
        a number answers the row in hand.
      </p>
      <p aria-live="polite" className="sr-only">
        {said}
      </p>
      <div className="flex">
        {/* The labels: one per row, the same heights as the rows beside them. */}
        <div className="flex w-36 shrink-0 flex-col pr-3">
          <div
            className="flex items-center gap-0.5"
            style={{ height: RULER_H }}
          >
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="zoom out"
              title="zoom out (-)"
              onClick={() =>
                moveView((v) =>
                  zoomAt(v, timebase.get().at, 0.5, duration, minSpan),
                )
              }
            >
              <Minus />
            </Button>
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="zoom in"
              title="zoom in (+)"
              onClick={() =>
                moveView((v) =>
                  zoomAt(v, timebase.get().at, 2, duration, minSpan),
                )
              }
            >
              <Plus />
            </Button>
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="the whole medium"
              title="the whole medium (0)"
              onClick={() => moveView(() => wholeView(duration))}
            >
              <Maximize2 />
            </Button>
          </div>
          {items.map((item) => (
            <RowLabel
              key={item.key}
              item={item}
              id={
                item.kind === "claims" || item.kind === "curve"
                  ? `${uid}-row-${rows.indexOf(item.row)}`
                  : undefined
              }
              rows={rows}
              inView={
                item.kind === "claims"
                  ? (inViewCount.get(item.row.id) ?? 0)
                  : null
              }
              focused={
                (item.kind === "claims" || item.kind === "curve") &&
                item.row.id === focusRow?.id
              }
              onToggle={toggle}
              onFocusRow={(id) => setRowAt(rows.findIndex((r) => r.id === id))}
            />
          ))}
        </div>
        {/* The lanes: the ruler, every row, and over them the selection, the
            playhead and the hand. */}
        <div
          ref={lanes}
          className="relative min-w-0 flex-1 cursor-text touch-none select-none"
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={() => {
            drag.current = null
          }}
          onPointerLeave={() => {
            if (!drag.current) intent.leave()
          }}
        >
          <Ruler view={view} width={width} fps={fps} back={back} />
          {width > 0 &&
            items.map((item) => (
              <Lane
                key={item.key}
                item={item}
                view={view}
                width={width}
                lane={lane}
                picked={
                  item.kind === "claims" && chosen?.row === item.row.id
                    ? chosen.id
                    : null
                }
                uid={uid}
                fps={fps}
                ink={ink}
                filmstrip={filmstrip}
                duration={duration}
                packed={
                  item.kind === "claims" ? packed.get(item.row.id) : undefined
                }
              />
            ))}
          {selection && width > 0 && (
            <div
              aria-hidden
              data-slot="timeline-selection"
              className={cn(
                "pointer-events-none absolute bottom-0 border-x border-foreground/70",
                repeating ? "bg-foreground/15" : "bg-foreground/[0.08]",
              )}
              style={{
                top: RULER_H,
                left: xOf(view, width, selection.from),
                width: Math.max(
                  1,
                  xOf(view, width, selection.to) -
                    xOf(view, width, selection.from),
                ),
              }}
            />
          )}
          <div
            ref={playhead}
            aria-hidden
            data-slot="timeline-playhead"
            className="pointer-events-none absolute top-0 bottom-0 left-0 w-px bg-foreground"
          >
            <span className="absolute -top-0.5 -left-[3.5px] size-2 rotate-45 rounded-[1px] bg-foreground" />
          </div>
        </div>
      </div>
      <Overview
        view={view}
        duration={duration}
        onView={(next) => moveView(() => next)}
        className="ml-36"
      />
      <Readout
        fps={fps}
        selection={selection}
        rate={rate}
        repeating={!!range}
        row={focusRow}
        canAnswer={!!onAnswer}
      />
    </Panel>
  )
}

/** A row's name and its fold; the answers its number keys give are in the readout
 *  while it is the row in hand. */
function RowLabel({
  item,
  id,
  rows,
  inView,
  focused,
  onToggle,
  onFocusRow,
}: {
  item: RowItem
  /** Its id as the timeline's active descendant (a row of the consumer's). */
  id?: string
  /** The consumer's rows, for this one's place among them. */
  rows: readonly TimelineRow[]
  /** How many of its claims the view holds (a claim row). */
  inView: number | null
  focused: boolean
  onToggle: (id: string) => void
  onFocusRow: (id: string) => void
}) {
  if (item.kind === "outline" || item.kind === "film")
    return (
      <div
        className="flex items-center truncate font-mono text-[11px] text-foreground/45 lowercase"
        style={{ height: item.height }}
      >
        {item.kind === "film" ? "stills" : item.level.name}
      </div>
    )
  const { row } = item
  const count = row.kind === "claims" ? row.claims.length : null
  const place = rows.indexOf(row) + 1
  // What a screen reader says when this row is the one in hand.
  const said = [
    `${row.label}, row ${place} of ${rows.length}`,
    row.kind === "claims" ? `${inView} of ${count} claims in view` : "a curve",
    row.kind === "claims" && row.answers
      ? `answers: ${row.answers
          .slice(0, 9)
          .map((a, i) => `${i + 1} ${a}`)
          .join(", ")}`
      : null,
  ]
    .filter(Boolean)
    .join("; ")
  return (
    <fieldset
      id={id}
      aria-label={said}
      className={cn(
        "-ml-1.5 m-0 flex min-w-0 gap-1 rounded-md border-0 p-0 pl-0.5 transition-colors",
        item.collapsed ? "items-center text-[11px]" : "items-start text-[13px]",
        focused ? "bg-foreground/[0.06] text-foreground" : "text-foreground/70",
      )}
      style={{ height: item.height }}
    >
      <button
        type="button"
        aria-expanded={!item.collapsed}
        aria-label={`${item.collapsed ? "open" : "fold"} ${row.label}`}
        onClick={() => onToggle(row.id)}
        className={cn(
          "grid shrink-0 cursor-pointer place-items-center rounded text-foreground/50 hover:bg-foreground/8 hover:text-foreground",
          item.collapsed
            ? "size-4 [&_svg]:size-3"
            : "mt-px size-5 [&_svg]:size-3.5",
        )}
      >
        {item.collapsed ? <ChevronRight /> : <ChevronDown />}
      </button>
      {/* Folded, the row still says which it is, in the outline rows' quiet voice */}
      <button
        type="button"
        onClick={() => onFocusRow(row.id)}
        className={cn(
          "flex min-w-0 flex-1 cursor-pointer flex-col items-start text-left",
          !item.collapsed && "pt-0.5",
        )}
      >
        <span
          className={cn(
            "w-full truncate lowercase",
            item.collapsed && "font-mono text-foreground/45",
          )}
          title={row.label}
        >
          {row.label}
          {count !== null && (
            <span className="ml-1.5 font-mono text-[10px] text-foreground/40">
              {count}
            </span>
          )}
        </span>
      </button>
    </fieldset>
  )
}

/** The ruler: the view's divisions, labels that never touch, and where the way back
 *  is. A drawing: the time is said by the readout under it, the keys are the
 *  timeline's own. */
function Ruler({
  view,
  width,
  fps,
  back,
}: {
  view: View
  width: number
  fps: number | null
  back: number | null
}) {
  const ticks = React.useMemo(
    () =>
      width > 0 ? ticksOf(view, width, { fps, charPx: RULER_CHAR_PX }) : [],
    [view, width, fps],
  )
  return (
    <div
      aria-hidden
      data-slot="timeline-ruler"
      className="relative cursor-ew-resize overflow-hidden rounded-md"
      style={{ height: RULER_H }}
    >
      {ticks.map((t) => (
        <span
          key={t.at}
          className="absolute bottom-0"
          style={{ left: xOf(view, width, t.at) }}
        >
          <span
            className={cn(
              "absolute bottom-0 w-px",
              t.major ? "h-2.5 bg-foreground/50" : "h-1.5 bg-foreground/25",
            )}
          />
          {t.label && (
            <span className="absolute bottom-3 left-1 whitespace-nowrap font-mono text-[10px] text-foreground/55 tabular-nums">
              {t.label}
            </span>
          )}
        </span>
      ))}
      {back !== null && (
        <span
          title="back to where you were (B)"
          className="absolute top-0.5 -ml-[5px] size-0 border-x-[5px] border-x-transparent border-t-[7px] border-t-foreground/55"
          style={{ left: xOf(view, width, back) }}
        />
      )}
    </div>
  )
}

/** One row's strip. */
function Lane({
  item,
  view,
  width,
  lane,
  picked,
  uid,
  fps,
  ink,
  filmstrip,
  duration,
  packed,
}: {
  item: RowItem
  view: View
  width: number
  lane: string
  /** The claim chosen in this row, by id. */
  picked: string | null
  /** The timeline's id, the prefix of its active descendant's. */
  uid: string
  fps: number | null
  /** The colour a painted row draws in. */
  ink: string
  filmstrip?: TimelineFilmstrip
  duration: number
  packed?: Packed
}) {
  const box = "relative overflow-hidden rounded-md"
  if (item.kind === "outline")
    return (
      <div
        data-slot="timeline-outline"
        className={box}
        style={{ height: item.height }}
      >
        {divisionSpans(item.level, duration).map((d, i) => {
          const left = xOf(view, width, d.start)
          const right = xOf(view, width, d.end)
          if (right < 0 || left > width) return null
          return (
            <span
              key={`${d.start}`}
              title={d.title}
              className={cn(
                "absolute inset-y-0.5 flex items-center overflow-hidden rounded-[4px] px-1.5 text-[11px] text-foreground/75",
                i % 2 ? "bg-foreground/[0.10]" : "bg-foreground/[0.06]",
              )}
              style={{ left: left + 1, width: Math.max(1, right - left - 2) }}
            >
              <span className="truncate">{d.title}</span>
            </span>
          )
        })}
      </div>
    )
  if (item.kind === "film" && filmstrip)
    return (
      <Filmstrip
        filmstrip={filmstrip}
        view={view}
        width={width}
        duration={duration}
      />
    )
  if (item.kind === "curve")
    return (
      <div
        data-slot="timeline-curve"
        className={cn(box, lane)}
        style={{ height: item.height }}
      >
        <Ridge
          points={item.row.points}
          view={view}
          width={width}
          height={item.height}
        />
      </div>
    )
  if (item.kind !== "claims" || !packed) return null
  return (
    <ClaimLane
      row={item.row}
      view={view}
      width={width}
      height={item.height}
      collapsed={item.collapsed}
      packed={packed}
      picked={picked}
      uid={uid}
      fps={fps}
      ink={ink}
      className={cn(box, lane)}
    />
  )
}

/** Claims as bands and pins, stacked in the lines `pack` gave them; nodes while the
 *  view holds a few hundred, a painting past that. Memoized, and painted only when
 *  `repaintOf` says what it shows changed (the claims, the view, its size, its lines,
 *  the ink of its scheme): a selection dragged over it, a hand skimming it and the
 *  playhead change none of them, and the claim chosen is drawn over the painting,
 *  never by painting again. A press on a claim is found by the index (the timeline's
 *  hit test), so a painted claim is as pressable as a node. For assistive tech the row
 *  is a named list: its claims while they are nodes, the chosen one alone while it is
 *  painted, each named by its row's question, its answer, when and whose, the chosen
 *  one carrying the id the timeline names as its active descendant. */
const ClaimLane = React.memo(function ClaimLane({
  row,
  view,
  width,
  height,
  collapsed,
  packed,
  picked,
  uid,
  fps,
  ink,
  className,
}: {
  row: TimelineClaimRow
  view: View
  width: number
  height: number
  collapsed: boolean
  packed: Packed
  picked: string | null
  uid: string
  fps: number | null
  ink: string
  className: string
}) {
  const shown = React.useMemo(
    () => visibleIn(packed.index, view, msPerPx(view, width) * 4),
    [packed.index, view, width],
  )
  const lineH = collapsed ? height : height / Math.max(1, packed.lines)
  const lineOf = (id: string) => (collapsed ? 0 : (packed.line.get(id) ?? 0))
  const canvas = React.useRef<HTMLCanvasElement>(null)
  const painting = React.useRef<Painting | null>(null)
  const painted = shown.length > MAX_NODES
  React.useEffect(() => {
    const c = canvas.current
    if (!painted || !c || !ink) {
      painting.current = null
      return
    }
    const next: Painting = {
      index: packed.index,
      view,
      width,
      height,
      dpr: window.devicePixelRatio || 1,
      line: collapsed ? null : packed.line,
      lines: collapsed ? 1 : packed.lines,
      ink,
    }
    const todo = repaintOf(painting.current, next)
    if (!todo.paint) return
    // Setting a canvas's size reallocates (and clears) it: only when it changed.
    if (todo.resize) {
      c.width = Math.round(width * next.dpr)
      c.height = Math.round(height * next.dpr)
    }
    const ctx = c.getContext("2d")
    if (!ctx) throw new Error("timeline: no 2d canvas")
    ctx.setTransform(next.dpr, 0, 0, next.dpr, 0, 0)
    ctx.clearRect(0, 0, width, height)
    ctx.fillStyle = ink
    const h = height / next.lines
    for (const claim of shown) {
      const s = spanOf(claim.when)
      const x = xOf(view, width, s.from)
      const cw = Math.max(1, xOf(view, width, s.to) - x)
      const y = (next.line?.get(claim.id) ?? 0) * h + 1
      ctx.globalAlpha = claim.by === "person" ? 0.75 : 0.35
      ctx.fillRect(x, y, cw, Math.max(1, h - 2))
    }
    painting.current = next
  }, [painted, packed, view, width, height, collapsed, ink, shown])
  const chosen = picked ? packed.index.byId.get(picked) : undefined
  // The nodes: what the view holds, or only the chosen claim over a painting; the
  // chosen claim always, in view or not, so the active descendant is always there.
  const nodes = painted
    ? chosen
      ? [chosen]
      : []
    : chosen && !shown.includes(chosen)
      ? [...shown, chosen]
      : shown
  return (
    <div data-slot="timeline-claims" className={className} style={{ height }}>
      {painted && (
        <canvas
          ref={canvas}
          aria-hidden
          className="absolute inset-0 size-full"
        />
      )}
      <ul
        aria-label={`${row.label}: ${shown.length} of ${row.claims.length} claims in view${painted ? ", too many to list; zoom in to list them" : ""}`}
        className="absolute inset-0 m-0 list-none p-0"
      >
        {nodes.map((claim) => {
          const s = spanOf(claim.when)
          const left = xOf(view, width, s.from)
          const top = lineOf(claim.id) * lineH
          const words = claimWords(row, claim, fps)
          const here = claim === chosen
          const named = {
            id: here ? chosenIdOf(uid, row.id, claim.id) : undefined,
            "aria-label": words,
            "aria-current": here || undefined,
            title: words,
          }
          if (painted)
            return (
              <li
                key={claim.id}
                {...named}
                data-slot="timeline-claim-chosen"
                className="pointer-events-none absolute rounded-[3px] ring-2 ring-foreground ring-inset"
                style={{
                  left: left - 1,
                  width: Math.max(3, xOf(view, width, s.to) - left + 2),
                  top,
                  height: Math.max(3, lineH),
                }}
              />
            )
          if (isInstant(claim.when))
            return (
              <li
                key={claim.id}
                {...named}
                className={cn(
                  "absolute w-3 -translate-x-1/2 cursor-pointer rounded-[3px]",
                  here && "ring-2 ring-foreground ring-inset",
                )}
                style={{ left, top, height: lineH }}
              >
                <span
                  className={cn(
                    "absolute inset-y-0.5 left-1/2 w-0.5 -translate-x-1/2 rounded-full",
                    claim.by === "person"
                      ? "bg-foreground"
                      : "bg-foreground/55",
                  )}
                />
                {!collapsed && (
                  <span
                    className={cn(
                      "absolute top-0.5 left-1/2 size-2 -translate-x-1/2 rounded-full",
                      claim.by === "person"
                        ? "bg-foreground"
                        : "border border-foreground/70 bg-background",
                    )}
                  />
                )}
              </li>
            )
          const w = Math.max(2, xOf(view, width, s.to) - left)
          return (
            <li
              key={claim.id}
              {...named}
              className={cn(
                "absolute flex cursor-pointer items-center overflow-hidden rounded-[4px] px-1.5 text-[11px] leading-none",
                claim.by === "person"
                  ? "bg-foreground/75 text-background"
                  : claim.by === "machine"
                    ? "border border-foreground/55 text-foreground/80"
                    : "bg-foreground/20 text-foreground/85",
                here && "ring-2 ring-foreground ring-inset",
              )}
              style={{
                left,
                width: w,
                top: top + 1,
                height: Math.max(2, lineH - 2),
              }}
            >
              {!collapsed && w > 36 && lineH >= 14 && (
                <span aria-hidden className="truncate">
                  {claim.answer}
                </span>
              )}
            </li>
          )
        })}
      </ul>
    </div>
  )
})

/** A curve as a ridge: each px column's greatest value, so no peak is lost. */
function Ridge({
  points,
  view,
  width,
  height,
}: {
  points: readonly CurvePoint[]
  view: View
  width: number
  height: number
}) {
  const d = React.useMemo(() => {
    const columns = Math.max(1, Math.floor(width))
    const cols = decimate(points, view, columns)
    if (cols.length === 0) return ""
    const y = (v: number) => height - Math.min(1, Math.max(0, v)) * (height - 2)
    const top = cols.map((c) => `L ${(c.x / columns) * width} ${y(c.high)}`)
    const first = cols[0] as (typeof cols)[number]
    const last = cols[cols.length - 1] as (typeof cols)[number]
    return `M ${(first.x / columns) * width} ${height} ${top.join(" ")} L ${(last.x / columns) * width} ${height} Z`
  }, [points, view, width, height])
  return (
    <svg
      aria-hidden
      width={width}
      height={height}
      className="absolute inset-0 text-foreground"
    >
      <path d={d} fill="currentColor" fillOpacity={0.4} />
    </svg>
  )
}

/** Stills along the view, anchored to time: each tile an instant's still, as wide as
 *  the medium's shape at the row's height or wider, never asking for two stills the
 *  source cannot tell apart. */
function Filmstrip({
  filmstrip,
  view,
  width,
  duration,
}: {
  filmstrip: TimelineFilmstrip
  view: View
  width: number
  duration: number
}) {
  const every = filmstrip.every ?? 1000
  const tilePx = FILM_H * filmstrip.aspect
  const per = msPerPx(view, width)
  const tileMs = Math.max(every, Math.ceil((tilePx * per) / every) * every)
  const first = Math.floor(view.from / tileMs)
  const last = Math.ceil(view.to / tileMs)
  const tiles: number[] = []
  for (let k = first; k <= last; k++)
    if (k * tileMs < duration) tiles.push(k * tileMs)
  return (
    <div
      aria-hidden
      data-slot="timeline-filmstrip"
      className="relative overflow-hidden rounded-md bg-foreground/[0.06]"
      style={{ height: FILM_H }}
    >
      {tiles.map((t) => {
        const left = xOf(view, width, t)
        const w =
          Math.min(
            xOf(view, width, Math.min(duration, t + tileMs)),
            width + tilePx,
          ) - left
        return (
          // biome-ignore lint/performance/noImgElement: a still from the consumer's source, many at once, sized by the strip
          <img
            key={t}
            src={filmstrip.still(t)}
            alt=""
            draggable={false}
            loading="lazy"
            decoding="async"
            className="absolute inset-y-0 h-full border-background/60 border-r object-cover"
            style={{ left, width: Math.max(1, w) }}
          />
        )
      })}
    </div>
  )
}

/** The whole medium in small, the view a window on it: drag the window to pan, press
 *  elsewhere to go there. */
function Overview({
  view,
  duration,
  onView,
  className,
}: {
  view: View
  duration: number
  onView: (view: View) => void
  className?: string
}) {
  const bar = React.useRef<HTMLDivElement>(null)
  const grab = React.useRef<{ x: number; from: number } | null>(null)
  const span = view.to - view.from
  const whole = span >= duration - 1
  const msAt = (clientX: number) => {
    const r = bar.current?.getBoundingClientRect()
    if (!r) return 0
    return ((clientX - r.left) / Math.max(1, r.width)) * duration
  }
  return (
    <div
      ref={bar}
      aria-hidden
      data-slot="timeline-overview"
      className={cn(
        "relative touch-none rounded-full bg-foreground/[0.06]",
        whole && "opacity-40",
        className,
      )}
      style={{ height: OVERVIEW_H }}
      onPointerDown={(e) => {
        e.currentTarget.setPointerCapture(e.pointerId)
        const ms = msAt(e.clientX)
        const inside = ms >= view.from && ms <= view.to
        const from = inside ? view.from : ms - span / 2
        grab.current = { x: e.clientX, from }
        if (!inside) onView({ from, to: from + span })
      }}
      onPointerMove={(e) => {
        const g = grab.current
        if (!g) return
        const r = bar.current?.getBoundingClientRect()
        if (!r) return
        const from = g.from + ((e.clientX - g.x) / r.width) * duration
        onView({ from, to: from + span })
      }}
      onPointerUp={() => {
        grab.current = null
      }}
    >
      <span
        className="absolute inset-y-0 cursor-grab rounded-full border border-foreground/40 bg-foreground/15"
        style={{
          left: `${(view.from / duration) * 100}%`,
          width: `${Math.max(1, (span / duration) * 100)}%`,
        }}
      />
      <OverviewPlayhead duration={duration} />
    </div>
  )
}

/** The playhead in the overview: its own follower, the only part redrawn each frame. */
function OverviewPlayhead({ duration }: { duration: number }) {
  const at = useTimebaseState((s) => s.at)
  return (
    <span
      className="absolute inset-y-0 w-px bg-foreground"
      style={{ left: `${(at / duration) * 100}%` }}
    />
  )
}

/** Under it all, in metadata's voice: where the playhead is, the selection, the
 *  shuttle's speed, and whether it repeats. */
function Readout({
  fps,
  selection,
  rate,
  repeating,
  row,
  canAnswer,
}: {
  fps: number | null
  selection: Span | null
  rate: number
  repeating: boolean
  row: TimelineRow | undefined
  canAnswer: boolean
}) {
  const at = useTimebaseState((s) => s.at)
  const form = fps ? "timecode" : "clock"
  return (
    <div
      data-slot="timeline-readout"
      className="flex flex-wrap items-center gap-x-4 gap-y-1 pl-36 font-mono text-[11px] text-foreground/55 tabular-nums"
    >
      <span className="text-foreground/80">{timeLabel(at, form, fps)}</span>
      {selection && selection.to > selection.from && (
        <span>
          {timeLabel(selection.from, form, fps)} to{" "}
          {timeLabel(selection.to, form, fps)} (
          {((selection.to - selection.from) / 1000).toFixed(2)} s)
        </span>
      )}
      {repeating && <span>repeating</span>}
      {rate !== 0 && (
        <span>
          {rate < 0 ? "◀" : "▶"} {Math.abs(rate)}×
        </span>
      )}
      {canAnswer && row?.kind === "claims" && row.answers && (
        // The answers the number keys give, here where there is room for every one:
        // under the row's name they spilled into the rows below.
        <span className="ml-auto flex flex-wrap items-center gap-x-2.5 gap-y-1 text-foreground/45">
          <span className="lowercase">{row.label}</span>
          {row.answers.slice(0, 9).map((a, i) => (
            <span
              key={a}
              className="flex items-center gap-1 text-foreground/60"
            >
              <Kbd keys={String(i + 1)} className="text-[10px]" />
              {a}
            </span>
          ))}
        </span>
      )}
    </div>
  )
}
timeline-rows.ts
// A timeline's ROWS as data: claims about a medium laid out to be drawn, and the
// arithmetic that keeps thousands of them cheap. Framework-free and pure.
//
// A claim (ui/CLAUDE.md "Time") is a fact about the medium: what is asked (its row's
// question), when (an instant or a span, in the medium's own time), the answer, and
// who said it. `ui` stores none of them: a consumer hands its claims in, and a person
// labelling hands a new one back. How a claim is DRAWN is the timeline's business
// (an instant a pin, a span a band, a curve a ridge); those drawing words are never
// the data's.
//
//   visibleIn   the claims a view shows, found by binary search over claims kept in
//               order of start, one list per length class (each class's longest at
//               most twice its shortest), so a long claim begun before the view is
//               never missed, one as long as the medium costs one claim and not a
//               walk from the first, and a view over 10 000 claims reads only the
//               handful it shows.
//   pack        overlapping spans into stacked lines, each claim the first line free
//               at its start: no two bands in one line ever overlap.
//   decimate    a curve down to the width it is drawn at: each px column's least and
//               greatest value, so no peak is lost however many samples it had.
//   edges       the instants a hand aims for in a row: every claim's ends, sorted,
//               what the playhead and a selection snap to.
//   claimAt     the claim a hand lands on, read from the index (by instant and line),
//               never from what the page drew: a row painted on a canvas has no nodes
//               to ask, and its claims are pressable all the same.
//   repaintOf   what a canvas showing a row must do for the next painting: reallocate
//               only when its device size changed, paint only when something it shows
//               changed (the claims, the view, the size, the lines, the ink).

import type { Span } from "@/registry/base-nova/lib/clip"
import type { View } from "@/registry/base-nova/lib/timeline-view"

function assert(ok: unknown, why: string): asserts ok {
  if (!ok) throw new Error(`timeline-rows: ${why}`)
}

/** When a claim holds: an instant (`at`) or a span (`from`, `to`), ms. A claim about
 *  the whole medium is its whole span. */
export type When = { at: number } | Span

export const isInstant = (when: When): when is { at: number } => "at" in when

/** The span a `when` covers: an instant's is itself. */
export const spanOf = (when: When): Span =>
  isInstant(when) ? { from: when.at, to: when.at } : when

/** One fact about the medium on a row: when, its answer in words (what a band or a
 *  pin is called), and whose it is: a person's drawn solid, a machine's outlined. */
export interface TimelineClaim {
  id: string
  when: When
  answer?: string
  by?: "person" | "machine"
}

/** Claims of one length class, in order of start: the longest at most twice the
 *  shortest (instants and claims under 2 ms are one class). */
interface LengthClass {
  longest: number
  claims: readonly TimelineClaim[]
  starts: readonly number[]
}

/** A row's claims in the order the arithmetic needs. Built once per row, never per
 *  frame. */
export interface ClaimIndex {
  /** Every claim, in order of start. */
  claims: readonly TimelineClaim[]
  /** The claims by how long they hold, each class in order of start. No claim of a
   *  class starts more than its `longest` before an instant and still reaches it, so a
   *  view begins each class by binary search and walks past only claims that end
   *  within one class length before it. */
  classes: readonly LengthClass[]
  /** Each claim by its id: a selection names its claim, never its span. */
  byId: ReadonlyMap<string, TimelineClaim>
}

const lengthClass = (ms: number) => (ms < 2 ? 0 : Math.floor(Math.log2(ms)))

export function indexClaims(claims: readonly TimelineClaim[]): ClaimIndex {
  const sorted = [...claims].sort(
    (a, b) => spanOf(a.when).from - spanOf(b.when).from,
  )
  const byClass = new Map<number, TimelineClaim[]>()
  for (const c of sorted) {
    const s = spanOf(c.when)
    assert(s.to >= s.from, `${c.id} ends before it starts`)
    const k = lengthClass(s.to - s.from)
    const members = byClass.get(k)
    if (members) members.push(c)
    else byClass.set(k, [c])
  }
  const byId = new Map(sorted.map((c) => [c.id, c]))
  assert(byId.size === sorted.length, "two claims share an id")
  return {
    claims: sorted,
    classes: [...byClass.values()].map((members) => ({
      longest: members.reduce((most, c) => {
        const s = spanOf(c.when)
        return Math.max(most, s.to - s.from)
      }, 0),
      claims: members,
      starts: members.map((c) => spanOf(c.when).from),
    })),
    byId,
  }
}

/** Every claim touching `from`..`to`, class by class (in order of start within one). */
function eachIn(
  index: ClaimIndex,
  from: number,
  to: number,
  visit: (claim: TimelineClaim) => void,
): void {
  for (const k of index.classes) {
    // The first claim that could reach `from`: any before it ended before.
    const earliest = from - k.longest
    let lo = 0
    let hi = k.starts.length
    while (lo < hi) {
      const mid = (lo + hi) >> 1
      if ((k.starts[mid] as number) < earliest) lo = mid + 1
      else hi = mid
    }
    for (let i = lo; i < k.claims.length; i++) {
      if ((k.starts[i] as number) > to) break
      const c = k.claims[i] as TimelineClaim
      if (spanOf(c.when).to >= from) visit(c)
    }
  }
}

/** The claim a hand at `ms` lands on: of those within `slopMs` of it (a pin is a hair
 *  wide, so a hand needs room either side), on line `line` when the row is stacked
 *  (`line.of` says each claim's), the most specific: a span holding `ms` before
 *  anything only near it, the shortest such span first; else the nearest by its nearer
 *  end. Read from the index, so a row drawn as a painting answers as one drawn as
 *  nodes, and a press reads only the claims near it. Null when no claim is near. */
export function claimAt(
  index: ClaimIndex,
  ms: number,
  slopMs: number,
  line?: { of: (id: string) => number; at: number },
): TimelineClaim | null {
  assert(slopMs >= 0, `slop ${slopMs}`)
  let best: TimelineClaim | null = null
  let bestScore = Number.POSITIVE_INFINITY
  eachIn(index, ms - slopMs, ms + slopMs, (c) => {
    if (line && line.of(c.id) !== line.at) return
    const s = spanOf(c.when)
    const holds = !isInstant(c.when) && s.from <= ms && ms <= s.to
    // Holding ranks below every near miss: a duration is finite, so the offset keeps
    // any holding span ahead of any claim merely near.
    const score = holds
      ? s.to - s.from
      : Number.MAX_SAFE_INTEGER / 2 +
        Math.min(Math.abs(ms - s.from), Math.abs(ms - s.to))
    if (score < bestScore) {
      best = c
      bestScore = score
    }
  })
  return best
}

/** The claims touching the view (with `marginMs` either side), in order of start. */
export function visibleIn(
  index: ClaimIndex,
  view: View,
  marginMs = 0,
): TimelineClaim[] {
  const out: TimelineClaim[] = []
  eachIn(index, view.from - marginMs, view.to + marginMs, (c) => out.push(c))
  return out.sort((a, b) => spanOf(a.when).from - spanOf(b.when).from)
}

/** How many claims touch the view: `visibleIn`'s length, nothing gathered. */
export function countIn(index: ClaimIndex, view: View): number {
  let n = 0
  eachIn(index, view.from, view.to, () => {
    n++
  })
  return n
}

/** What a row painted on a canvas shows, all of it: two equal paintings are the same
 *  pixels. `line` is null when the row is folded to one line. */
export interface Painting {
  index: ClaimIndex
  view: View
  /** css px. */
  width: number
  height: number
  /** Device px per css px. */
  dpr: number
  line: ReadonlyMap<string, number> | null
  lines: number
  /** The colour it is painted in, as the page computes it. */
  ink: string
}

/** What a canvas showing `was` must do to show `next`: `resize` (reallocate, which
 *  also clears it) only when its device size changed, `paint` only when anything it
 *  shows changed. A selection, a hover or a playhead changes none of it. */
export function repaintOf(
  was: Painting | null,
  next: Painting,
): { resize: boolean; paint: boolean } {
  const device = (p: Painting) =>
    `${Math.round(p.width * p.dpr)}x${Math.round(p.height * p.dpr)}`
  const resize = !was || device(was) !== device(next)
  const paint =
    resize ||
    was.index !== next.index ||
    was.view.from !== next.view.from ||
    was.view.to !== next.view.to ||
    was.width !== next.width ||
    was.height !== next.height ||
    was.line !== next.line ||
    was.lines !== next.lines ||
    was.ink !== next.ink
  return { resize, paint }
}

/** Overlapping spans stacked into lines: each claim in the first line free at its
 *  start (instants take no room and share line 0's). Returns each claim's line and how
 *  many lines the row needs. Order of the input decides nothing: it is laid by start. */
export function pack(claims: readonly TimelineClaim[]): {
  line: ReadonlyMap<string, number>
  lines: number
} {
  const line = new Map<string, number>()
  const ends: number[] = []
  const sorted = [...claims].sort(
    (a, b) => spanOf(a.when).from - spanOf(b.when).from,
  )
  for (const c of sorted) {
    if (isInstant(c.when)) {
      line.set(c.id, 0)
      continue
    }
    const s = spanOf(c.when)
    let i = ends.findIndex((end) => end <= s.from)
    if (i === -1) {
      i = ends.length
      ends.push(s.to)
    } else ends[i] = s.to
    line.set(c.id, i)
  }
  return { line, lines: Math.max(1, ends.length) }
}

/** Every claim's ends in a row, sorted and without repeats: where a hand snaps. */
export function edgesOf(claims: readonly TimelineClaim[]): number[] {
  const all = claims.flatMap((c) => {
    const s = spanOf(c.when)
    return s.from === s.to ? [s.from] : [s.from, s.to]
  })
  return [...new Set(all)].sort((a, b) => a - b)
}

/** The edge after (`dir` 1) or before (-1) `ms`, or null past the last. */
export function nextEdge(
  edges: readonly number[],
  ms: number,
  dir: 1 | -1,
): number | null {
  if (dir === 1) return edges.find((e) => e > ms + 0.5) ?? null
  return edges.findLast((e) => e < ms - 0.5) ?? null
}

// ── curves ───────────────────────────────────────────────────────────────────────

/** A curve's sample: its value 0..1 at an instant. */
export interface CurvePoint {
  at: number
  value: number
}

/** The curve as drawn at `columns` px across the view: each column's least and
 *  greatest value (`low`, `high`), so a peak one sample wide still reaches its height;
 *  columns with no sample carry the line across from their neighbours. */
export function decimate(
  points: readonly CurvePoint[],
  view: View,
  columns: number,
): { x: number; low: number; high: number }[] {
  assert(columns > 0, `${columns} columns`)
  const span = view.to - view.from
  const cols: { low: number; high: number }[] = Array.from(
    { length: columns },
    () => ({ low: Number.POSITIVE_INFINITY, high: Number.NEGATIVE_INFINITY }),
  )
  // The samples in view and one either side, so the line runs to both edges.
  let lo = 0
  let hi = points.length
  while (lo < hi) {
    const mid = (lo + hi) >> 1
    if ((points[mid] as CurvePoint).at < view.from) lo = mid + 1
    else hi = mid
  }
  const first = Math.max(0, lo - 1)
  for (let i = first; i < points.length; i++) {
    const p = points[i] as CurvePoint
    const c = Math.min(
      columns - 1,
      Math.max(0, Math.floor(((p.at - view.from) / span) * columns)),
    )
    const col = cols[c] as { low: number; high: number }
    col.low = Math.min(col.low, p.value)
    col.high = Math.max(col.high, p.value)
    if (p.at > view.to) break
  }
  const out: { x: number; low: number; high: number }[] = []
  let last: { low: number; high: number } | null = null
  for (let c = 0; c < columns; c++) {
    const col = cols[c] as { low: number; high: number }
    if (col.high >= col.low) last = col
    if (last) out.push({ x: c + 0.5, low: last.low, high: last.high })
  }
  return out
}

/** The curve's value at `ms`, straight between the samples either side (null outside
 *  the curve): what a readout under the hand says. */
export function valueAt(
  points: readonly CurvePoint[],
  ms: number,
): number | null {
  if (points.length === 0) return null
  const first = points[0] as CurvePoint
  const last = points[points.length - 1] as CurvePoint
  if (ms < first.at || ms > last.at) return null
  let lo = 0
  let hi = points.length - 1
  while (hi - lo > 1) {
    const mid = (lo + hi) >> 1
    if ((points[mid] as CurvePoint).at <= ms) lo = mid
    else hi = mid
  }
  const a = points[lo] as CurvePoint
  const b = points[hi] as CurvePoint
  if (b.at === a.at) return a.value
  return a.value + ((ms - a.at) / (b.at - a.at)) * (b.value - a.value)
}

/** Spans where a curve stays at or over `threshold`, shorter gaps than `bridgeMs`
 *  closed: a curve read as the claims it implies (where the face is, where it is
 *  loud), named after the curve it came from by the caller. */
export function spansOver(
  points: readonly CurvePoint[],
  threshold: number,
  bridgeMs = 0,
): Span[] {
  const out: Span[] = []
  let open: number | null = null
  let prev: CurvePoint | null = null
  for (const p of points) {
    if (p.value >= threshold && open === null) open = p.at
    if (p.value < threshold && open !== null && prev) {
      out.push({ from: open, to: prev.at })
      open = null
    }
    prev = p
  }
  if (open !== null && prev) out.push({ from: open, to: prev.at })
  return out.reduce<Span[]>((acc, s) => {
    const before = acc[acc.length - 1]
    if (before && s.from - before.to <= bridgeMs) before.to = s.to
    else acc.push({ ...s })
    return acc
  }, [])
}
timeline-shuttle.ts
// The EDITING keys of a timeline, as data and pure steps: a separate table from the
// watching player's (ui/player PlayerKeys, where J and L jump), because an editor's J
// K L are a SHUTTLE, the trained keys of every editing suite:
//
//   J  plays backwards; pressed again, faster (1, 2, 4, 8 times)
//   K  stops; held with J or L, one frame back or forward per press
//   L  plays forwards; pressed again, faster
//
// A J while going forwards turns round at the slowest speed, and an L while going
// backwards too: the hand reverses, it never jumps from 8× one way to 8× the other.
//
// And BACK TO WHERE YOU WERE: a big move (a click far along the axis, a jump to a
// claim, a seek from anywhere) leaves the instant it left as `back`, one slot of
// session state, never a claim. Going back swaps: the instant you left becomes the
// place to come back to, so the key toggles between the two. A move counts as big
// past `BIG_MOVE_MS` beyond what playing would have travelled in the same time.
// Framework-free.

import {
  ariaKeyshortcuts,
  type Press,
  pressed,
} from "@/registry/base-nova/lib/keys"

/** The shuttle's speeds, slowest first. */
export const SHUTTLE = [1, 2, 4, 8] as const

/** The rate a press leaves: negative backwards, 0 stopped. */
export function shuttle(rate: number, key: "j" | "k" | "l"): number {
  if (key === "k") return 0
  const dir = key === "l" ? 1 : -1
  const speed = Math.abs(rate)
  // Turning round, or starting: the slowest speed in the new direction.
  if (rate === 0 || Math.sign(rate) !== dir) return dir * (SHUTTLE[0] as number)
  const next = SHUTTLE.find((s) => s > speed) ?? SHUTTLE[SHUTTLE.length - 1]
  return dir * (next as number)
}

/** How far past ordinary travel a move must go to leave a way back, ms. */
export const BIG_MOVE_MS = 1500

/** Whether going from `from` to `to` in `elapsedMs` at `rate` is a jump (a seek, not
 *  playing): further than the clock could have gone, by more than BIG_MOVE_MS. */
export function isJump(
  from: number,
  to: number,
  elapsedMs: number,
  rate: number,
): boolean {
  const travelled = Math.abs(rate) * Math.max(0, elapsedMs)
  return Math.abs(to - from) > travelled + BIG_MOVE_MS
}

/** Going back: where to go, and the new way back (the instant being left). */
export function goBack(
  back: number | null,
  at: number,
): { to: number; back: number } | null {
  return back === null ? null : { to: back, back: at }
}

/** What an editing key asks the timeline to do. */
export type EditAction =
  | "shuttle-back"
  | "stop"
  | "shuttle-forward"
  | "held-back"
  | "held-forward"
  | "toggle"
  | "frame-back"
  | "frame-forward"
  | "edge-back"
  | "edge-forward"
  | "extend-back"
  | "extend-forward"
  | "row-up"
  | "row-down"
  | "in"
  | "out"
  | "select-claim"
  | "repeat"
  | "answer"
  | "go-back"
  | "zoom-in"
  | "zoom-out"
  | "whole"
  | "clear"

/** A row of the editing table: its keys (lib/keys notation, each pressed for the
 *  action beside it), what they do in words, and how they behave. */
export interface EditKey {
  keys: readonly string[]
  actions: readonly EditAction[]
  does: string
  /** Pressed with K held (an editor's frame step). */
  held?: "k"
  /** Held down, it keeps going (the arrows, the zoom); else a held key is one press. */
  repeats?: boolean
  /** How a legend writes the keys, where listing them all would not help ("1-9"). */
  shown?: string
}

const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]

/** Every editing key, in the order a legend lists them. ONE table: the handler
 *  resolves through it (`resolveEdit`), a legend reads it, `aria-keyshortcuts` is
 *  derived from it, so none of them can disagree. */
export const EDIT_KEYS: readonly EditKey[] = [
  {
    keys: ["j", "k", "l"],
    actions: ["shuttle-back", "stop", "shuttle-forward"],
    does: "shuttle: back, stop, forward; again, faster",
  },
  {
    keys: ["j", "l"],
    actions: ["held-back", "held-forward"],
    does: "a frame back, forward (K held)",
    held: "k",
    repeats: true,
    shown: "K J, K L",
  },
  { keys: ["space"], actions: ["toggle"], does: "play, pause" },
  {
    keys: ["left", "right"],
    actions: ["frame-back", "frame-forward"],
    does: "a frame back, forward (a tenth of a second without frames)",
    repeats: true,
  },
  {
    keys: ["alt+left", "alt+right"],
    actions: ["edge-back", "edge-forward"],
    does: "the previous, next edge in the row",
  },
  {
    keys: ["shift+left", "shift+right"],
    actions: ["extend-back", "extend-forward"],
    does: "the selection's end a step back, forward",
    repeats: true,
  },
  {
    keys: ["up", "down"],
    actions: ["row-up", "row-down"],
    does: "the row above, below",
  },
  {
    keys: ["i", "o"],
    actions: ["in", "out"],
    does: "the selection starts, ends here",
  },
  {
    keys: ["x"],
    actions: ["select-claim"],
    does: "select the claim under the playhead",
  },
  {
    keys: ["r"],
    actions: ["repeat"],
    does: "repeat the selection, again to stop",
  },
  {
    keys: DIGITS,
    actions: DIGITS.map(() => "answer" as const),
    does: "answer the row's question for the selection",
    shown: "1-9",
  },
  { keys: ["b"], actions: ["go-back"], does: "back to where you were" },
  {
    keys: ["+", "=", "-"],
    actions: ["zoom-in", "zoom-in", "zoom-out"],
    does: "zoom in, out at the playhead",
    repeats: true,
    shown: "+ -",
  },
  {
    keys: ["0"],
    actions: ["whole"],
    does: "the whole medium; with a selection, the selection",
  },
  { keys: ["esc"], actions: ["clear"], does: "clear the selection" },
]

for (const row of EDIT_KEYS)
  if (row.keys.length !== row.actions.length)
    throw new Error(`EDIT_KEYS: ${row.does} has a key without its action`)

/** The editing row and action a press asks for, with K held or not: a row made for K
 *  held answers only then, and wins then. Null when no editing key is pressed. */
export function resolveEdit(
  press: Press,
  kHeld: boolean,
): { action: EditAction; row: EditKey } | null {
  const rows = kHeld
    ? [
        ...EDIT_KEYS.filter((r) => r.held === "k"),
        ...EDIT_KEYS.filter((r) => !r.held),
      ]
    : EDIT_KEYS.filter((r) => !r.held)
  for (const row of rows) {
    const i = row.keys.findIndex((k) => pressed(k, press))
    if (i >= 0) return { action: row.actions[i] as EditAction, row }
  }
  return null
}

/** The editing keys as `aria-keyshortcuts` names them (K-held steps are a chord no
 *  attribute can say, and are left to the legend). */
export const EDIT_SHORTCUTS = ariaKeyshortcuts(
  EDIT_KEYS.filter((r) => !r.held).flatMap((r) => r.keys),
)