{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline",
  "type": "registry:ui",
  "title": "Timeline",
  "description": "The steps of a run in order, such as a deploy: when each started, a status icon on a hairline rail, the label and how long it took, grouped into phases. In a proportional timeline long steps draw their time as a bar in the phase's data hue, with the last run beside it. A strip shows the whole run at a glance.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/timeline.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/timeline 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Timeline: the steps of a run in order (a deploy, a build, an import), with when each started, how long it\n * took and how it ended. Compose it from children: Timeline > TimelinePhase > TimelineStep, then a\n * TimelineResult. TimelineBar draws the whole run as one strip and sits beside it.\n *\n * - Layout (components.md, Timeline): an ordered list in four columns. The start (`at`) is the tabular caption in\n *   fg-subtle with tabular numbers, then the rail, then the label in the small size and fg, then the duration at\n *   the end in the tabular caption. A skipped or pending step's label is fg-subtle, and a skipped step's duration is\n *   the word \"skipped\". Rows are airy, 32px at least. The list measures itself (a container query), not the\n *   window: the start column shrinks on a narrow container and the labels wrap.\n * - Rail: a 1px line in line-strong that runs between the 16px status icons and stops at a phase's first and last\n *   step. It is drawn in two short pieces around each icon, so the icon never needs a background of its own.\n * - Status icons (16px, currentColor, the icon set's marks): done is a filled disc with the check cut out, in\n *   fg-muted; running is a ring half filled, in fg, and does not spin or pulse; pending is an empty ring in fg-subtle;\n *   skipped is a dashed ring in fg-subtle; failed is a filled disc with the x cut out, in danger. No dots. Every\n *   status is also said in hidden text, so color and shape are never the only signal.\n * - Phase: a heading row (the label small in fg-subtle, the total in the tabular caption) over a nested, labelled\n *   list. Its `hue` says which phase (R7): it colors the phase's rail bars here and its segments in TimelineBar.\n *   With no hue those marks are fg at 55%.\n * - Proportional: with `proportional`, a step with `seconds` gets a row at least `seconds * secondHeight` tall, and\n *   the rail below its icon becomes a 4px bar at the control radius (the Meter's rail) of that length, in the\n *   phase's mark tone. With `previousSeconds` a ghost bar at fg 12% shows the last run's length beside it, with\n *   \"was 31s\" at its foot. A step whose bar would be shorter than a row keeps the thin line. A failed step's bar is\n *   danger, the one status color a mark takes.\n * - Flat: done and now are ink marks (the tick cut from an ink disc, the half-filled ring), waiting and skipped take the line-control edge, and the result row has no rule.\n * - Result: the closing row under a hairline. The label is body size in success, danger or fg by `tone`, the total\n *   is tabular in fg at the end, and the tone is said in hidden text.\n * - Bar: a strip of the whole run from data, not children. 32px tall at the control radius with overflow hidden,\n *   segments sized by seconds, square, 2px apart. A skipped or zero-second step takes no width. Phase labels sit\n *   above each phase's span and the times at the phase boundaries sit below, in the tabular caption. Hovering a\n *   segment, or touching it, shows the Tooltip's look (solid surface-3, shadow-litt-tooltip: the label, then the tabular\n *   duration), the way the Chart does. A touch pins it until the next touch elsewhere.\n * - Semantics: each step reads in order, \"00:02, Run build commands, done, 19 seconds\". The bar is role=\"img\" with a\n *   summary, and its segments are not focusable: it always sits beside a Timeline or a table that lists the same\n *   steps, so a keyboard user and a screen reader get every step there.\n * - Motion: none in the timeline. A running step does not move. The bar's tooltip uses the responsive tier like\n *   every tooltip, and hover changes color only, at the instant tier.\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on) moves the timeline only as a run changes: a step\n *   whose status changes pops its icon in, a step that finishes draws its check as a pen would, a step that fails\n *   shakes once sideways, a phase's total arrives with a small pop, and a result added after the timeline mounted rises\n *   in on the expressive tier; a run that went live scatters a few dots in its phases' hues, once. A timeline that\n *   loads finished stays still. Each change hands back to the static markup when it lands. Reduced motion turns it off.\n */\nimport { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type Ref } from \"react\";\nimport { animate, AnimatePresence, motion, type Transition } from \"motion/react\";\nimport { useExit, useFun, useTransition, useTravel } from \"../../lib/litt/motion\";\n\n/* ----------------------------------------------------------------- types */\n\n/** How a step stands. Running is a half-filled circle that stays still. */\nexport type TimelineStatus = \"done\" | \"running\" | \"pending\" | \"skipped\" | \"failed\";\n\n/** The five data hues, in their fixed order (R7). A phase takes one, and it means that phase everywhere in the view. */\nexport type TimelineHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\";\n\n/** How a run ended: success, danger, or neither. It sets the result's color and a hidden word. */\nexport type TimelineTone = \"success\" | \"danger\" | \"neutral\";\n\n/* ----------------------------------------------------------------- lookups */\n\n/** Literal strings, so Tailwind sees every class. */\nconst MARK: Record<TimelineHue, string> = {\n  blue: \"bg-litt-data-blue\",\n  amber: \"bg-litt-data-amber\",\n  teal: \"bg-litt-data-teal\",\n  violet: \"bg-litt-data-violet\",\n  rose: \"bg-litt-data-rose\",\n};\n/** With no hue, a mark is the foreground at 55%, as in the Chart. */\nconst MARK_FG = \"bg-[color-mix(in_oklab,var(--litt-fg)_55%,transparent)]\";\nconst GHOST = \"bg-[color-mix(in_oklab,var(--litt-fg)_12%,transparent)]\";\n\nconst ICON_TONE: Record<TimelineStatus, string> = {\n  done: \"text-litt-fg-muted in-[[data-material=flat]]:text-litt-fg\",\n  running: \"text-litt-fg\",\n  pending: \"text-litt-fg-subtle in-[[data-material=flat]]:text-litt-line-control\",\n  skipped: \"text-litt-fg-subtle in-[[data-material=flat]]:text-litt-line-control\",\n  failed: \"text-litt-danger\",\n};\nconst RESULT_TONE: Record<TimelineTone, string> = {\n  success: \"text-litt-success\",\n  danger: \"text-litt-danger\",\n  neutral: \"text-litt-fg\",\n};\nconst RESULT_WORD: Record<TimelineTone, string> = { success: \", success\", danger: \", error\", neutral: \"\" };\n\n/* ----------------------------------------------------------------- numbers and words */\n\nconst ROW = 32; // the least height of a row\nconst RAIL_TOP = 24; // where the rail below an icon starts: the icon sits 8px down and is 16px tall\nconst BAR_MIN = 16; // a bar shorter than this is no better than the thin line\nconst CAPTION = 17; // a caption line at the caption size\n\nconst tidy = (n: number) => (Number.isInteger(n) ? String(n) : n < 10 ? n.toFixed(1) : String(Math.round(n)));\nconst isNum = (n: unknown): n is number => typeof n === \"number\" && Number.isFinite(n);\nconst clean = (n: unknown) => (isNum(n) ? Math.max(0, n) : 0);\n\n/** \"19s\", \"1m 12s\". */\nfunction formatSeconds(total: number): string {\n  const s = Math.max(0, total);\n  if ((s >= 10 ? Math.round(s) : s) < 60) return `${tidy(s)}s`;\n  const whole = Math.round(s);\n  const m = Math.floor(whole / 60);\n  const r = whole - m * 60;\n  return r ? `${m}m ${r}s` : `${m}m`;\n}\n\n/** \"19 seconds\", \"1 minute 12 seconds\": what a screen reader should say where the page shows \"19s\". */\nfunction speakSeconds(total: number): string {\n  const s = Math.max(0, total);\n  if ((s >= 10 ? Math.round(s) : s) < 60) {\n    const t = tidy(s);\n    return `${t} ${t === \"1\" ? \"second\" : \"seconds\"}`;\n  }\n  const whole = Math.round(s);\n  const m = Math.floor(whole / 60);\n  const r = whole - m * 60;\n  const minutes = `${m} ${m === 1 ? \"minute\" : \"minutes\"}`;\n  return r ? `${minutes} ${r} ${r === 1 ? \"second\" : \"seconds\"}` : minutes;\n}\n\n/** \"00:24\", or \"1:02:10\" past an hour: a time on the run's clock. */\nfunction clock(total: number): string {\n  const s = Math.max(0, Math.round(total));\n  const h = Math.floor(s / 3600);\n  const m = Math.floor((s % 3600) / 60);\n  const p = (n: number) => String(n).padStart(2, \"0\");\n  return h ? `${h}:${p(m)}:${p(s % 60)}` : `${p(m)}:${p(s % 60)}`;\n}\n\n/** A number is seconds and is formatted and spoken. A string is shown and spoken as it is. */\nfunction readTime(value: number | string) {\n  return typeof value === \"number\" ? { visual: formatSeconds(value), spoken: speakSeconds(value) } : { visual: value, spoken: value };\n}\n\n/* ----------------------------------------------------------------- context */\n\ntype TimelineContextValue = {\n  proportional: boolean;\n  secondHeight: number;\n  /** True once the timeline has mounted: what arrives after that is a change, which fun mode may move. */\n  mounted: { current: boolean };\n  /** The phases' hues, for a run's celebration in fun mode. */\n  hues: { current: Set<TimelineHue> };\n};\nconst TimelineContext = createContext<TimelineContextValue>({ proportional: false, secondHeight: 4, mounted: { current: true }, hues: { current: new Set() } });\nconst PhaseContext = createContext<{ hue?: TimelineHue }>({});\n\n/* ----------------------------------------------------------------- the icons */\n\n/* Done and failed are a filled disc with the mark cut out through a mask, the icon set's own drawing, so the cut shows\n   whatever is behind it and the icon still needs no background. */\nconst CUT: Partial<Record<TimelineStatus, string>> = { done: \"m5.25 8.25 2 2 3.5-4.25\", failed: \"m6 6 4 4m0-4-4 4\" };\n\nfunction StatusIcon({ status, ref }: { status: TimelineStatus; ref?: Ref<SVGSVGElement> }) {\n  const skipped = status === \"skipped\";\n  const cut = CUT[status];\n  const mask = useId().replace(/[^\\w-]/g, \"\");\n  return (\n    <svg\n      ref={ref}\n      width={16}\n      height={16}\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      className={\"absolute top-2 left-0 \" + ICON_TONE[status]}\n    >\n      {cut ? (\n        <>\n          <mask id={mask} maskUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"16\" height=\"16\">\n            <rect width=\"16\" height=\"16\" fill=\"white\" stroke=\"none\" /* litt-allow: mask luminance, white keeps */ />\n            <path d={cut} stroke=\"black\" /* litt-allow: mask luminance, black cuts */ />\n          </mask>\n          <circle cx=\"8\" cy=\"8\" r=\"6.25\" fill=\"currentColor\" strokeWidth={1} mask={`url(#${mask})`} />\n        </>\n      ) : (\n        <circle cx=\"8\" cy=\"8\" r=\"6.25\" strokeDasharray={skipped ? \"2.45\" : undefined} strokeLinecap={skipped ? \"butt\" : undefined} />\n      )}\n      {status === \"running\" && <path d=\"M8 1.75a6.25 6.25 0 0 1 0 12.5z\" fill=\"currentColor\" />}\n    </svg>\n  );\n}\n\n/* ----------------------------------------------------------------- Timeline */\n\nexport type TimelineProps = {\n  /** What the run is, said once for screen readers: \"Update pricing page, deploy steps\". */\n  label: string;\n  /** Sizes each step's row by its seconds and draws long steps as a bar on the rail. A step needs `seconds` for that. */\n  proportional?: boolean;\n  /** Pixels of row height per second in a proportional timeline. A 19 second step at 4 is a 76px bar. */\n  secondHeight?: number;\n  /** Phases and steps, then a result. Put steps in a TimelinePhase so the rail breaks between phases. */\n  children: ReactNode;\n  /** Extra classes for the list, such as a width. */\n  className?: string;\n};\n\n/** The steps of a run as an ordered list in four columns: start, rail, label, duration. Compose it from TimelinePhase, TimelineStep and TimelineResult. */\nexport function Timeline({ label, proportional = false, secondHeight = 4, children, className = \"\" }: TimelineProps) {\n  const mounted = useRef(false);\n  const hues = useRef(new Set<TimelineHue>());\n  useEffect(() => {\n    mounted.current = true;\n  }, []);\n  const value = useMemo(() => ({ proportional, secondHeight, mounted, hues }), [proportional, secondHeight]);\n  return (\n    <TimelineContext.Provider value={value}>\n      <ol role=\"list\" aria-label={label} className={\"@container m-0 list-none p-0 \" + className}>\n        {children}\n      </ol>\n    </TimelineContext.Provider>\n  );\n}\n\n/* ----------------------------------------------------------------- TimelinePhase */\n\nexport type TimelinePhaseProps = {\n  /** The phase's name, in sentence case: \"Build\". It labels the nested list. */\n  label: string;\n  /** The phase's total at the end of its heading. A number is seconds (\"24s\", read as \"24 seconds\"); a string is shown as given. Leave it out while the phase has not run. */\n  duration?: number | string;\n  /** The phase's data hue (R7). It colors this phase's rail bars and its segments in a TimelineBar. Without one they are the foreground at 55%. */\n  hue?: TimelineHue;\n  /** The phase's TimelineSteps. */\n  children: ReactNode;\n  /** Extra classes for the phase. */\n  className?: string;\n};\n\n/** A group of steps under a heading row (the label and the total), with a nested, labelled list. */\nexport function TimelinePhase({ label, duration, hue, children, className = \"\" }: TimelinePhaseProps) {\n  const id = useId();\n  const total = duration === undefined ? null : readTime(duration);\n  const value = useMemo(() => ({ hue }), [hue]);\n  const { hues } = useContext(TimelineContext);\n  useEffect(() => {\n    if (hue) hues.current.add(hue);\n  }, [hue, hues]);\n  /* Fun mode: a total that appears (the phase just finished) arrives with a small pop. */\n  const fun = useFun();\n  const totalRef = useRef<HTMLSpanElement>(null);\n  const hadTotal = useRef(total !== null);\n  useLayoutEffect(() => {\n    const el = totalRef.current;\n    if (fun && el && total && !hadTotal.current) {\n      animate(el, { opacity: [0, 1], y: [4, 0] }, fun.pop).then(() => Object.assign(el.style, { opacity: \"\", transform: \"\" }));\n    }\n    hadTotal.current = total !== null;\n  }, [total !== null]); // eslint-disable-line react-hooks/exhaustive-deps -- only its arrival matters\n  return (\n    <li className={\"not-first:mt-4 \" + className}>\n      <div className=\"flex min-h-8 items-center justify-between gap-4\">\n        <span id={id} className=\"text-litt-small text-litt-fg-subtle\">\n          {label}\n        </span>\n        {total && (\n          <span ref={totalRef} className=\"text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n            <span aria-hidden=\"true\">{total.visual}</span>\n            <span className=\"sr-only\">{total.spoken}</span>\n          </span>\n        )}\n      </div>\n      <PhaseContext.Provider value={value}>\n        <ol role=\"list\" aria-labelledby={id} className=\"m-0 list-none p-0\">\n          {children}\n        </ol>\n      </PhaseContext.Provider>\n    </li>\n  );\n}\n\n/* ----------------------------------------------------------------- TimelineStep */\n\nexport type TimelineStepProps = {\n  /** The step's start on the run's clock, in the first column: \"00:02\". Leave it out for a step that has not started. */\n  at?: string;\n  /** What the step does, in sentence case: \"Run build commands\". It wraps on a narrow container. */\n  label: string;\n  /** How the step stands. The icon shows it and hidden text says it. */\n  status: TimelineStatus;\n  /** How long the step took, or has taken so far while it runs. Shown at the end of the row (\"19s\") and read as \"19 seconds\". A proportional timeline sizes the row by it. Leave it out for a step that has not run. */\n  seconds?: number;\n  /** How long the step took last run. A proportional timeline draws a ghost bar of this length beside the bar, with \"was 31s\" at its foot. Ignored when the timeline is not proportional. */\n  previousSeconds?: number;\n  /** Extra classes for the row. */\n  className?: string;\n};\n\n/** One step: its start, a status icon on the rail, its label and its duration. In a proportional timeline a long step draws its time as a bar on the rail. */\nexport function TimelineStep({ at, label, status, seconds: took, previousSeconds, className = \"\" }: TimelineStepProps) {\n  const { proportional, secondHeight } = useContext(TimelineContext);\n  const { hue } = useContext(PhaseContext);\n\n  /* Fun mode: a status that changes pops the icon in, a finished step draws its check, a failed one shakes once. */\n  const fun = useFun();\n  const icon = useRef<SVGSVGElement>(null);\n  const was = useRef(status);\n  useLayoutEffect(() => {\n    const el = icon.current;\n    const changed = was.current !== status;\n    was.current = status;\n    if (!fun || !el || !changed || status === \"pending\") return;\n    const settle = () => Object.assign(el.style, { transform: \"\" });\n    if (status === \"failed\") animate(el, { x: fun.shake.x }, fun.shake.transition).then(settle);\n    else animate(el, { scale: [0.6, 1] }, fun.pop).then(settle);\n    const check = status === \"done\" ? el.querySelector<SVGPathElement>(\"path\") : null;\n    if (check) {\n      check.setAttribute(\"pathLength\", \"1\");\n      check.style.strokeDasharray = \"1\";\n      animate(check, { strokeDashoffset: [1, 0] }, fun.tick).then(() => {\n        check.removeAttribute(\"pathLength\");\n        Object.assign(check.style, { strokeDasharray: \"\", strokeDashoffset: \"\" });\n      });\n    }\n  }, [status]); // eslint-disable-line react-hooks/exhaustive-deps -- a status change is what moves it\n\n  const ran = isNum(took) && status !== \"skipped\" && status !== \"pending\";\n  const bar = proportional && ran ? clean(took) * secondHeight : 0;\n  const ghost = proportional && isNum(previousSeconds) && previousSeconds > 0 ? previousSeconds * secondHeight : 0;\n  /* A bar shows once it is longer than a row would be anyway, or whenever there is a ghost to compare it with. */\n  const showBar = bar >= BAR_MIN || (ghost > 0 && bar > 0);\n  const longest = Math.max(showBar ? bar : 0, ghost);\n  const captionTop = ghost > 0 ? Math.max(ROW, RAIL_TOP + ghost - CAPTION) : 0;\n  const height = Math.max(ROW, longest > 0 ? longest + RAIL_TOP : 0, ghost > 0 ? captionTop + CAPTION : 0);\n\n  const dim = status === \"skipped\" || status === \"pending\";\n  const shown = status === \"skipped\" ? \"skipped\" : ran ? formatSeconds(clean(took)) : \"\";\n  const spoken = ran ? speakSeconds(clean(took)) + (ghost > 0 ? `, was ${speakSeconds(previousSeconds!)} last run` : \"\") : \"\";\n  const barTone = status === \"failed\" ? \"bg-litt-danger\" : hue ? MARK[hue] : MARK_FG;\n\n  return (\n    <li\n      data-step=\"\"\n      style={height > ROW ? { minHeight: height } : undefined}\n      className={\n        \"grid min-h-8 grid-cols-[2.5rem_1rem_minmax(0,1fr)_auto] items-start gap-x-3 @min-[28rem]:grid-cols-[3rem_1rem_minmax(0,1fr)_auto] @min-[28rem]:gap-x-4 \" +\n        className\n      }\n    >\n      <span className=\"flex h-8 items-center whitespace-nowrap text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n        {at}\n        {at && <span className=\"sr-only\">,</span>}\n      </span>\n\n      {/* The rail is decoration: the status and the time are said in the text. */}\n      <span aria-hidden=\"true\" className=\"relative block self-stretch\">\n        <span className=\"absolute top-0 left-1/2 hidden h-2 w-px -translate-x-1/2 bg-litt-line-strong [[data-step]+[data-step]_&]:block\" />\n        <span className=\"absolute top-6 bottom-0 left-1/2 hidden w-px -translate-x-1/2 bg-litt-line-strong [[data-step]:has(+[data-step])_&]:block\" />\n        <StatusIcon ref={icon} status={status} />\n        {ghost > 0 && <span className={\"absolute top-6 left-3 w-1 rounded-litt-control \" + GHOST} style={{ height: ghost }} />}\n        {showBar && <span className={\"absolute top-6 left-1.5 z-10 w-1 rounded-litt-control \" + barTone} style={{ height: bar }} />}\n      </span>\n\n      <span className={\"relative min-w-0 self-stretch py-1.5 text-litt-small \" + (dim ? \"text-litt-fg-subtle\" : \"text-litt-fg\")}>\n        {label}\n        <span className=\"sr-only\">{`, ${status}`}</span>\n        {ghost > 0 && (\n          <span aria-hidden=\"true\" className=\"absolute left-0 whitespace-nowrap text-litt-caption font-normal tabular-nums text-litt-fg-subtle\" style={{ top: captionTop }}>\n            was {formatSeconds(previousSeconds!)}\n          </span>\n        )}\n      </span>\n\n      <span className=\"flex h-8 items-center justify-end whitespace-nowrap text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n        {shown && <span aria-hidden=\"true\">{shown}</span>}\n        {spoken && <span className=\"sr-only\">{`, ${spoken}`}</span>}\n      </span>\n    </li>\n  );\n}\n\n/* ----------------------------------------------------------------- TimelineResult */\n\nexport type TimelineResultProps = {\n  /** How the run ended, in words: \"Live on atelier-nord.com\". */\n  label: string;\n  /** The whole run's time at the end of the row. A number is seconds (\"42s\", read as \"42 seconds\"); a string is shown as given. */\n  total: number | string;\n  /** Success, danger or neither. It colors the label and is said in hidden text. */\n  tone?: TimelineTone;\n  /** Extra classes for the row. */\n  className?: string;\n};\n\n/** The closing row of a run, under a hairline: the outcome in words, colored by its tone, and the total in tabular figures. */\nexport function TimelineResult({ label, total, tone = \"neutral\", className = \"\" }: TimelineResultProps) {\n  const t = readTime(total);\n  /* Fun mode: a result that arrives after the timeline mounted (the run just ended) rises in, and a run that went live\n     scatters a few dots in its phases' hues, once. A timeline that loads finished stays still. */\n  const fun = useFun();\n  const { mounted, hues } = useContext(TimelineContext);\n  const rise = useTransition(\"expressive\");\n  const travel = useTravel();\n  const row = useRef<HTMLLIElement>(null);\n  const [burst, setBurst] = useState<TimelineHue[] | null>(null);\n  useLayoutEffect(() => {\n    const el = row.current;\n    if (!fun || !el || !mounted.current) return;\n    animate(el, { opacity: [0, 1], y: [travel, 0] }, rise).then(() => Object.assign(el.style, { opacity: \"\", transform: \"\" }));\n    if (tone === \"success\") setBurst(hues.current.size ? [...hues.current] : []);\n  }, []); // eslint-disable-line react-hooks/exhaustive-deps -- on arrival only\n  return (\n    <li ref={row} className={\"mt-4 flex items-baseline justify-between gap-4 border-t border-litt-line in-[[data-material=flat]]:border-transparent pt-4 \" + className}>\n      <span className={\"relative min-w-0 text-litt-body \" + RESULT_TONE[tone]}>\n        {label}\n        <span className=\"sr-only\">{RESULT_WORD[tone]}</span>\n        {fun && burst && <Burst hues={burst} transition={fun.burst} onDone={() => setBurst(null)} />}\n      </span>\n      <span className=\"text-litt-small tabular-nums whitespace-nowrap text-litt-fg\">\n        <span aria-hidden=\"true\">{t.visual}</span>\n        <span className=\"sr-only\">{`, ${t.spoken}`}</span>\n      </span>\n    </li>\n  );\n}\n\n/** A run that went live: twelve dots in the phases' hues (fg at 55% without any) scatter from the result and fade, once. */\nfunction Burst({ hues, transition, onDone }: { hues: TimelineHue[]; transition: Transition; onDone: () => void }) {\n  return (\n    <span aria-hidden=\"true\" className=\"pointer-events-none absolute top-1/2 left-0\">\n      {Array.from({ length: 12 }, (_, i) => {\n        const a = (i / 12) * Math.PI * 2;\n        const reach = 22 + (i % 3) * 9;\n        const hue = hues[i % Math.max(1, hues.length)];\n        return (\n          <motion.span\n            key={i}\n            className={\"absolute size-1.5 rounded-litt-pill \" + (hue ? MARK[hue] : MARK_FG)}\n            initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}\n            animate={{ x: Math.cos(a) * reach, y: Math.sin(a) * reach * 0.7, opacity: 0, scale: 0.4 }}\n            transition={transition}\n            onAnimationComplete={i === 11 ? onDone : undefined}\n          />\n        );\n      })}\n    </span>\n  );\n}\n\n/* ----------------------------------------------------------------- TimelineBar */\n\nexport type TimelineBarStep = {\n  /** The step's name, shown in the tooltip. */\n  label: string;\n  /** How long it took. A skipped step or one with zero seconds takes no width. */\n  seconds: number;\n  /** How it stands. A failed step's segment is danger. */\n  status: TimelineStatus;\n};\n\nexport type TimelineBarPhase = {\n  /** The phase's name, shown above its span with its total: \"Build · 24s\". */\n  label: string;\n  /** The phase's data hue. Use the same hue as the TimelinePhase it matches. */\n  hue?: TimelineHue;\n  /** The phase's steps, in order. */\n  steps: TimelineBarStep[];\n};\n\nexport type TimelineBarProps = {\n  /** What the run is, said at the start of the summary: \"Update pricing page\". */\n  label: string;\n  /** The run as data, in order. The same array can feed the Timeline beside it. */\n  phases: TimelineBarPhase[];\n  /** Height of the strip in px. The labels above and below add to it. */\n  height?: number;\n  /** Replaces the generated summary (\"Update pricing page: Build 24 seconds, Deploy 18 seconds, 42 seconds in all\"). */\n  summary?: string;\n  /** Extra classes for the whole bar, such as a top margin. */\n  className?: string;\n};\n\nconst FIGURE = 7.2; // advance of a 12px tabular figure in Geist (letters run narrower)\n\ntype Segment = TimelineBarStep & { start: number; hue?: TimelineHue };\ntype Span = { label: string; start: number; seconds: number };\n\nfunction arrange(phases: TimelineBarPhase[]) {\n  const segments: Segment[] = [];\n  const spans: Span[] = [];\n  let t = 0;\n  for (const phase of phases) {\n    const start = t;\n    for (const step of phase.steps) {\n      const s = clean(step.seconds);\n      if (step.status === \"skipped\" || s <= 0) continue;\n      segments.push({ ...step, seconds: s, start: t, hue: phase.hue });\n      t += s;\n    }\n    if (t > start) spans.push({ label: phase.label, start, seconds: t - start });\n  }\n  return { segments, spans, total: t };\n}\n\nfunction segmentTone(status: TimelineStatus, hue?: TimelineHue) {\n  if (status === \"failed\") return \"bg-litt-danger\";\n  if (status === \"pending\") return GHOST;\n  return hue ? MARK[hue] : MARK_FG;\n}\n\n/** The container's width, measured before paint and kept current. 0 until the first measure. */\nfunction useWidth() {\n  const ref = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const read = () => setWidth(Math.floor(el.getBoundingClientRect().width));\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, width] as const;\n}\n\n/** A whole run as one strip: a segment per step, sized by its seconds. It is a picture, so it sits beside a Timeline (or a table) that lists the same steps. */\nexport function TimelineBar({ label, phases, height = 32, summary, className = \"\" }: TimelineBarProps) {\n  const [boxRef, width] = useWidth();\n  const { segments, spans, total } = useMemo(() => arrange(phases), [phases]);\n\n  const [hover, setHover] = useState<number | null>(null);\n  const [pinned, setPinned] = useState<number | null>(null);\n  const [dismissed, setDismissed] = useState(false);\n  const active = dismissed ? null : (hover ?? pinned);\n  useEffect(() => setDismissed(false), [hover, pinned]);\n\n  /* Esc hides the tooltip, since nothing here takes focus to hide it from. */\n  useEffect(() => {\n    if (active === null) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setDismissed(true);\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [active]);\n\n  /* A tap pins the tooltip; the next touch anywhere else clears it. */\n  useEffect(() => {\n    if (pinned === null) return;\n    const away = (e: PointerEvent) => {\n      if (!boxRef.current?.contains(e.target as Node)) setPinned(null);\n    };\n    document.addEventListener(\"pointerdown\", away, true);\n    return () => document.removeEventListener(\"pointerdown\", away, true);\n  }, [pinned, boxRef]);\n\n  /* The segment under a pointer, found by time, so a 2px gap or a thin segment is as easy to reach as a wide one. */\n  const stripRef = useRef<HTMLDivElement>(null);\n  const indexAt = (clientX: number) => {\n    const box = stripRef.current?.getBoundingClientRect();\n    if (!box || box.width <= 0 || total <= 0) return null;\n    const t = ((clientX - box.left) / box.width) * total;\n    const i = segments.findIndex((s) => t >= s.start && t < s.start + s.seconds);\n    return i < 0 ? null : i;\n  };\n\n  const text = useMemo(() => {\n    if (summary) return summary;\n    const parts = spans.map((s) => `${s.label} ${speakSeconds(s.seconds)}`);\n    const failed = phases.flatMap((p) => p.steps).find((s) => s.status === \"failed\");\n    return `${label}: ${[...parts, `${speakSeconds(total)} in all`].join(\", \")}${failed ? `, failed at ${failed.label}` : \"\"}`;\n  }, [summary, label, spans, total, phases]);\n\n  /* The times under the strip: the start, each phase boundary and the end. Interior ones that would touch a neighbour are left out. */\n  const times = useMemo(() => {\n    if (total <= 0) return [];\n    const marks = [0, ...spans.slice(1).map((s) => s.start), total];\n    const items = marks.map((t, i) => {\n      const w = clock(t).length * FIGURE;\n      const x = (t / total) * width;\n      const edge = i === 0 ? \"start\" : i === marks.length - 1 ? \"end\" : \"middle\";\n      const left = edge === \"start\" ? 0 : edge === \"end\" ? width - w : x - w / 2;\n      return { t, edge, left, right: left + w, pct: (t / total) * 100 };\n    });\n    if (width <= 0) return items;\n    const lastLeft = items[items.length - 1]!.left;\n    let reach = items[0]!.right;\n    return items.filter((it, i) => {\n      if (i === 0 || i === items.length - 1) return true;\n      if (it.left < reach + 8 || it.right > lastLeft - 8) return false;\n      reach = it.right;\n      return true;\n    });\n  }, [spans, total, width]);\n\n  const seg = active === null ? null : segments[active];\n  const tipCx = seg && total > 0 ? ((seg.start + seg.seconds / 2) / total) * width : 0;\n\n  return (\n    <div ref={boxRef} role=\"img\" aria-label={text} className={\"w-full \" + className}>\n      {/* Phase labels sit over their spans. */}\n      <div aria-hidden=\"true\" className=\"relative mb-1 h-6\">\n        {total > 0 &&\n          spans.map((s) => (\n            <span\n              key={s.start}\n              className=\"absolute top-0 truncate pr-2 text-litt-caption font-normal tabular-nums text-litt-fg-subtle\"\n              style={{ left: `${(s.start / total) * 100}%`, width: `${(s.seconds / total) * 100}%` }}\n            >\n              {s.label} · {formatSeconds(s.seconds)}\n            </span>\n          ))}\n      </div>\n\n      <div className=\"relative\" style={{ height }}>\n        <div ref={stripRef} aria-hidden=\"true\" className=\"absolute inset-0 overflow-hidden rounded-litt-control\">\n          {total <= 0 && <span className=\"absolute inset-0 bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)]\" />}\n          {segments.map((s, i) => {\n            const left = (s.start / total) * 100;\n            const w = (s.seconds / total) * 100;\n            const cut = (i === 0 ? 0 : 1) + (i === segments.length - 1 ? 0 : 1);\n            return (\n              <span\n                key={i}\n                className={\"absolute inset-y-0 \" + segmentTone(s.status, s.hue)}\n                style={{ left: i === 0 ? `${left}%` : `calc(${left}% + 1px)`, width: `max(2px, calc(${w}% - ${cut}px))` }}\n              >\n                <span\n                  className={\n                    \"absolute inset-0 bg-[color-mix(in_oklab,var(--litt-fg)_16%,transparent)] transition-opacity duration-(--litt-dur-instant) ease-litt \" +\n                    (active === i ? \"opacity-100\" : \"opacity-0\")\n                  }\n                />\n              </span>\n            );\n          })}\n        </div>\n\n        {/* One layer takes the pointer, so a thin segment is as easy to reach as a wide one. On a finger it grows to 44px\n            tall, and a touch pins the tooltip. A vertical drag still scrolls the page. */}\n        <div\n          aria-hidden=\"true\"\n          className=\"absolute inset-x-0 inset-y-0 touch-pan-y select-none pointer-coarse:-inset-y-1.5\"\n          onPointerMove={(e) => {\n            const i = indexAt(e.clientX);\n            if (e.pointerType === \"touch\") {\n              if (e.buttons) setPinned(i);\n            } else setHover(i);\n          }}\n          onPointerLeave={(e) => {\n            if (e.pointerType !== \"touch\") setHover(null);\n          }}\n          onPointerDown={(e) => {\n            if (e.pointerType !== \"touch\") return;\n            const i = indexAt(e.clientX);\n            setPinned((p) => (p === i ? null : i));\n          }}\n        />\n\n        <AnimatePresence>\n          {seg && (\n            <Tip key=\"tip\" cx={tipCx} width={width}>\n              <span className=\"text-litt-fg-muted\">{seg.label}</span>\n              <span className=\"text-litt-caption font-normal tabular-nums\">{formatSeconds(seg.seconds)}</span>\n            </Tip>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {/* The times at the phase boundaries. */}\n      <div aria-hidden=\"true\" className=\"relative mt-2 h-6\">\n        {times.map((it) => (\n          <span\n            key={it.t}\n            className={\n              \"absolute top-0 text-litt-caption font-normal tabular-nums text-litt-fg-subtle \" + (it.edge === \"middle\" ? \"-translate-x-1/2\" : \"\")\n            }\n            style={it.edge === \"end\" ? { right: 0 } : { left: `${it.pct}%` }}\n          >\n            {clock(it.t)}\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- tooltip */\n\n/** The Tooltip's look, as the Chart draws it: solid surface-3, shadow-litt-tooltip, the label then the value in tabular figures. Above the strip, kept inside it. */\nfunction Tip({ cx, width, children }: { cx: number; width: number; children: ReactNode }) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  const ref = useRef<HTMLDivElement>(null);\n  const [w, setW] = useState(0);\n  const measure = useCallback(() => setW(ref.current?.offsetWidth ?? 0), []);\n  useLayoutEffect(measure);\n  const half = w / 2;\n  const left = Math.max(half, Math.min(width - half, cx));\n  return (\n    <div aria-hidden=\"true\" className=\"pointer-events-none absolute bottom-full z-10 mb-2 -translate-x-1/2\" style={{ left }}>\n      <motion.div\n        ref={ref}\n        className=\"flex origin-bottom items-center gap-3 rounded-litt-control bg-litt-surface-3 px-2 py-1.5 text-litt-small whitespace-nowrap text-litt-fg shadow-litt-tooltip inset-shadow-litt-highlight\"\n        initial={{ opacity: 0, scale: 0.98, y: travel / 4 }}\n        animate={{ opacity: 1, scale: 1, y: 0, transition: enter }}\n        exit={{ opacity: 0, scale: 0.98, transition: exit }}\n      >\n        {children}\n      </motion.div>\n    </div>\n  );\n}\n",
      "target": "components/litt/timeline.tsx"
    }
  ],
  "docs": "Import Timeline, TimelinePhase, TimelineStep, TimelineResult and TimelineBar from components/litt/timeline. Put steps in phases, give each step an at, a label, a status and its seconds, and close with a TimelineResult. Feed TimelineBar the same data as the list, and keep it beside the list.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
