{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "run-list",
  "type": "registry:ui",
  "title": "Run list",
  "description": "An agent's runs for a sidebar or an inbox: each run's title, its status in words and a mark (running, needs you, done, failed, stopped), where it has got and when it last changed, the runs that need you first, the selected run as a raised block that glides from row to row, a filter by status with counts, and Stop or Archive on a row.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/glide.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/select.json",
    "https://littmd.vercel.app/r/states.json",
    "https://littmd.vercel.app/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/run-list.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/run-list 2026.11.05. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Run list: an agent's runs for an app's sidebar or an inbox (components.md, \"Run list\"; proposals/agents-2.md; R3, R4,\n * R5, R7, R8, R9). Each run says what it is for, where it stands in words and a mark, how far it has got and when it\n * last changed. The runs that need the person come first, one run is open, and a Select filters by status. A row opens\n * the run's conversation or its Run card; it replaces neither.\n *\n * - Anatomy: a header 32px tall (the label at the small size and the title weight in --litt-fg, the count at the caption\n *   size, tabular, in --litt-fg-subtle, and a sm Select named \"Filter runs by status\" whose options carry their counts,\n *   \"Needs you 2\", with an empty status disabled), then the rows. With runs that need you and runs that do not, two\n *   groups under the headings \"Needs you\" and \"Runs\" (caption, the label weight, --litt-fg-subtle); one group has no heading.\n *   `lift` is the one place the rule lives: the runs that need you first, then the rest, each in the caller's order.\n *   Nothing is sorted by time, and nothing reads a clock: `updated` is the caller's words.\n * - Row: a uniform 56px at the control radius, padded 8px by 12px, flat (R3). The 16px mark, the title (small, the\n *   title weight, --litt-fg; a done run --litt-fg-muted; cut at the end) over the status line (caption, --litt-fg-subtle: \"Running ·\n *   Step 2 of 5 · 40%\", cut at the end) with the `updated` words at its end, tabular. Marks and words come from one\n *   table: running is the half-filled ring in --litt-fg, needs you the empty ring in --litt-fg with \"Needs you\" in --litt-fg at the\n *   title weight, done the check in --litt-fg-muted, failed the x in --litt-danger with \"Failed\", stopped the dashed ring in\n *   --litt-fg-subtle. Color sits on the mark only (R5, R7). A row is a link (`href`) or a button.\n * - Selected is raised (R3): a text-free backplate of the block (--litt-block, --litt-block-lift) with a layoutId, so it glides\n *   from row to row, and aria-current on the row. Hover is the Glide's one highlight moving row to row, under the block.\n * - Verbs: Stop on a running row and Archive on a finished one (done, failed, stopped), ghost sm icon-only in a\n *   Tooltip, named with the run (\"Stop Fix the Halo build\"), at the row's end in a column every row keeps, so nothing\n *   shifts when one shows. They show on hover and focus-within, always on a coarse pointer at 44px. A run that needs\n *   you has none: you answer it in the run.\n * - The moment (R4): press a row and the block glides to it on the responsive tier. Pick a filter and the rows that\n *   stay glide to their new places (layout=\"position\") while the ones that arrive fade in, on the responsive tier.\n *   A run the agent changes moves to its new place at once, with no motion, because no hand asked: the filter press\n *   records the filter it asked for, and only the render that shows it glides (cleared in a layout effect after the\n *   commit). Every other render moves rows with Motion's instant transition, the one it uses under reduced motion.\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on): the block glides on fun mode's glide spring, the\n *   hover highlight stretches (the Glide's own fun path), and a run that becomes done, failed or needs you pops its\n *   mark once (0.6 to 1 on the pop spring), the one motion here that answers the agent, as a Plan step's arrival does.\n *   The pop's transform is cleared when it lands, so at rest the list is Calm's exactly.\n * - Keys and assistive tech: the rows take one Tab stop with a roving focus (the run last focused, else the selected,\n *   else the first). ↑ and ↓ move between runs, Home and End go to the ends, → moves to the row's verb and ← comes\n *   back, and Enter or Space opens the run. The Select keeps its own keys. A list is named by the label, or by its\n *   heading when there are two. Each row is named by its title, its status line and its `updated` words. One polite\n *   status says \"Fix the Halo build needs you\", \"Move the Northwind site failed\" or \"Import the Cosmos catalog is done\"\n *   when a run changes into one of those, never on the first render and never for a run that only appears. When a\n *   pressed verb had focus and its row leaves, focus moves to the row that takes its place.\n * - Motion: only transform and opacity move. Colors fade on the instant tier, verbs fade in on the instant tier, and a\n *   mark swaps at once. Reduced motion stills it all (the hooks return zero).\n * - Touch: verbs always show and grow to 44px; the Select grows to md; a pressed row takes the hover fill. A tap never\n *   glides the highlight. No swipe this round: Stop and Archive are buttons.\n * - Empty: \"No runs yet.\" (or `empty`). With a filter that shows nothing: \"No runs need you.\" and a ghost \"Show all\n *   runs\".\n * - Disabled or read-only: nothing to disable. Without `onStop` and `onArchive` the rows have no verbs and keep no\n *   column for them; without `onSelect` a row still opens its `href`.\n * - Forced colors: the block keeps a border and takes Highlight, and the selected row's words and mark HighlightText. Those words opt out of forced colors, or the browser would paint its backplate under them.\n */\nimport { Fragment, useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from \"react\";\nimport { animate, LayoutGroup, motion, type AnimationPlaybackControls, type Transition } from \"motion/react\";\nimport { Button } from \"./button\";\nimport { useGlide, GLIDE_ROW, type Glide } from \"../../lib/litt/glide\";\nimport { ArchiveIcon, StatusIcon, StopIcon, type StepStatus } from \"./icons\";\nimport { Select } from \"./select\";\nimport { Empty } from \"./states\";\nimport { Tooltip } from \"./tooltip\";\nimport { useDevice, useFun, useTransition } from \"../../lib/litt/motion\";\n\n/** Where an agent's run stands. \"needs-you\" is a run waiting on the person: an approval, a question. */\nexport type RunStatus = \"running\" | \"needs-you\" | \"done\" | \"failed\" | \"stopped\";\n\n/** Which runs the list shows: every run, or the runs in one status. */\nexport type RunFilter = \"all\" | RunStatus;\n\n/** One of an agent's runs, as a row. */\nexport type AgentRun = {\n  /** A stable id. It keys the row and is what `selectedId` names. Unique in the list; a later duplicate is left out. */\n  id: string;\n  /** What the run is for, in a few words, sentence case and no trailing dots: \"Fix the Halo build\". */\n  title: string;\n  /** Where it stands. The row says it in words and a mark. */\n  status: RunStatus;\n  /** What it is on or why it stopped, after the status word: \"Step 2 of 5\", \"Approve npm run deploy\". */\n  detail?: string;\n  /** How far a running run has got, from 0 to 1, shown as a whole percent after the detail. Ignored in any other status. */\n  progress?: number;\n  /** When it last changed, in your words, shown and read as given: \"1m ago\", \"Yesterday\". The list reads no clock. */\n  updated: string;\n  /** Makes the row a link to the run, such as its conversation. Without it the row is a button. */\n  href?: string;\n};\n\nexport type RunListProps = {\n  /** The runs, in your order, newest change first. The ones that need the person are lifted above the rest; each group keeps your order. */\n  runs: AgentRun[];\n  /** The list's name, shown in its header with the count and read out for the list. */\n  label?: string;\n  /** The id of the open run. Its row is the raised block. You own this state. */\n  selectedId?: string;\n  /** Called with the run whose row was pressed, for a link too. Set `selectedId` from it, or from your route. */\n  onSelect?: (run: AgentRun) => void;\n  /** Shows Stop on running rows, and is called with the run. Without it running rows have none. */\n  onStop?: (run: AgentRun) => void;\n  /** Shows Archive on finished rows (done, failed, stopped), and is called with the run. Without it finished rows have none. */\n  onArchive?: (run: AgentRun) => void;\n  /** The status filter, when you control it. Pair it with onFilterChange. */\n  filter?: RunFilter;\n  /** The filter to start with when you do not control it. */\n  defaultFilter?: RunFilter;\n  /** Called with the filter the person picked, or \"all\" from Show all runs. */\n  onFilterChange?: (filter: RunFilter) => void;\n  /** What the list shows when there are no runs at all, in place of \"No runs yet.\", such as an Empty with a New run button. */\n  empty?: ReactNode;\n  /** Extra classes for the list, such as a width. It fills its container and does not scroll itself. */\n  className?: string;\n};\n\n/* ----------------------------------------------------------------- the one table */\n\ntype Verb = \"stop\" | \"archive\";\n\n/** Everything a status decides. A new status is one row here; nothing else branches on it. */\ntype Look = {\n  /** The status word that starts the status line. */\n  word: string;\n  /** The word's own look: needs you is the one word in --litt-fg at the title weight. */\n  wordLook: string;\n  mark: StepStatus;\n  markTone: string;\n  titleTone: string;\n  /** Lifted above the rest. */\n  needsYou: boolean;\n  verb: Verb | null;\n  /** What the live region says when a run changes into this status, and whether its mark pops in fun mode. */\n  voice: ((title: string) => string) | null;\n};\n\nconst STATUS: Record<RunStatus, Look> = {\n  running: { word: \"Running\", wordLook: \"\", mark: \"running\", markTone: \"text-litt-fg\", titleTone: \"text-litt-fg\", needsYou: false, verb: \"stop\", voice: null },\n  \"needs-you\": {\n    word: \"Needs you\",\n    wordLook: \"font-litt-title text-litt-fg\",\n    mark: \"pending\",\n    markTone: \"text-litt-fg\",\n    titleTone: \"text-litt-fg\",\n    needsYou: true,\n    verb: null,\n    voice: (title) => `${title} needs you`,\n  },\n  done: { word: \"Done\", wordLook: \"\", mark: \"done\", markTone: \"text-litt-fg-muted\", titleTone: \"text-litt-fg-muted\", needsYou: false, verb: \"archive\", voice: (title) => `${title} is done` },\n  failed: { word: \"Failed\", wordLook: \"\", mark: \"failed\", markTone: \"text-litt-danger\", titleTone: \"text-litt-fg\", needsYou: false, verb: \"archive\", voice: (title) => `${title} failed` },\n  stopped: { word: \"Stopped\", wordLook: \"\", mark: \"skipped\", markTone: \"text-litt-fg-subtle\", titleTone: \"text-litt-fg\", needsYou: false, verb: \"archive\", voice: null },\n};\n\nconst VERB: Record<Verb, { word: string; icon: ReactNode }> = {\n  stop: { word: \"Stop\", icon: <StopIcon /> },\n  archive: { word: \"Archive\", icon: <ArchiveIcon /> },\n};\n\nconst FILTERS: readonly RunFilter[] = [\"all\", \"needs-you\", \"running\", \"done\", \"failed\", \"stopped\"];\nconst FILTER_WORD: Record<RunFilter, string> = {\n  all: \"All\",\n  \"needs-you\": STATUS[\"needs-you\"].word,\n  running: STATUS.running.word,\n  done: STATUS.done.word,\n  failed: STATUS.failed.word,\n  stopped: STATUS.stopped.word,\n};\nconst NOTHING: Record<RunFilter, string> = {\n  all: \"No runs yet.\",\n  \"needs-you\": \"No runs need you.\",\n  running: \"No runs are running.\",\n  done: \"No runs are done.\",\n  failed: \"No runs failed.\",\n  stopped: \"No runs were stopped.\",\n};\n\n/** The Select hands back a string; this is the one place it becomes a filter. */\nconst toFilter = (value: string): RunFilter => FILTERS.find((f) => f === value) ?? \"all\";\n\n/* ----------------------------------------------------------------- what the runs say */\n\ntype Group = { key: \"needs-you\" | \"rest\"; heading: string; runs: AgentRun[] };\n\ntype Reading = {\n  /** Every run once, in the caller's order. */\n  runs: AgentRun[];\n  counts: Record<RunFilter, number>;\n  /** The groups that have runs, needs you first. */\n  groups: Group[];\n  /** The visible runs in reading order, for the keys. */\n  order: AgentRun[];\n};\n\n/** The rule \"the runs that need you come first\", written once: a stable partition of the caller's order. */\nfunction lift(runs: readonly AgentRun[]): Group[] {\n  const groups: Group[] = [\n    { key: \"needs-you\", heading: \"Needs you\", runs: runs.filter((r) => STATUS[r.status].needsYou) },\n    { key: \"rest\", heading: \"Runs\", runs: runs.filter((r) => !STATUS[r.status].needsYou) },\n  ];\n  return groups.filter((g) => g.runs.length > 0);\n}\n\nfunction readRuns(given: readonly AgentRun[], filter: RunFilter): Reading {\n  const seen = new Set<string>();\n  const runs = given.filter((r) => !seen.has(r.id) && !!seen.add(r.id));\n  const counts: Record<RunFilter, number> = { all: runs.length, \"needs-you\": 0, running: 0, done: 0, failed: 0, stopped: 0 };\n  for (const r of runs) counts[r.status] += 1;\n  const groups = lift(filter === \"all\" ? runs : runs.filter((r) => r.status === filter));\n  return { runs, counts, groups, order: groups.flatMap((g) => g.runs) };\n}\n\nconst clamp01 = (n: number) => (n > 0 ? Math.min(1, n) : 0);\n\n/** The status line after its word: the detail, then a running run's whole percent, rounded down. */\nfunction lineParts(run: AgentRun): string[] {\n  const parts = run.detail ? [run.detail] : [];\n  if (run.status === \"running\" && run.progress !== undefined) parts.push(`${Math.floor(clamp01(run.progress) * 100 + 1e-9)}%`);\n  return parts;\n}\n\n/* ----------------------------------------------------------------- pieces */\n\n/** A pause a screen reader hears and the eye does not: the row's parts read as a sentence. */\nconst Pause = () => <span className=\"sr-only\">, </span>;\n\n/** The 16px mark, aligned to the title's line. In fun mode it pops once in the render its run arrived in a new status. */\nfunction Mark({ status, pop, className }: { status: RunStatus; pop: boolean; className: string }) {\n  const fun = useFun();\n  const ref = useRef<HTMLSpanElement>(null);\n  const landing = useRef<AnimationPlaybackControls | null>(null);\n  /* Before the paint, so the first frame of a pop is its smallest and the rest frame never flashes first. A run that\n     changes group mounts a new row, so the list says when to pop, not the mark's own memory. The next render says\n     pop={false}, and the pop still lands: only a new pop or leaving stops it. */\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!fun || !el || !pop) return;\n    landing.current?.stop();\n    const run = animate(el, { scale: [0.6, 1] }, fun.pop);\n    landing.current = run;\n    void run.then(() => {\n      if (landing.current === run) el.style.transform = \"\";\n    });\n  }, [pop, status, fun]);\n  useLayoutEffect(() => () => landing.current?.stop(), []);\n  const look = STATUS[status];\n  return (\n    <span ref={ref} aria-hidden=\"true\" className={\"flex h-5 items-center transition-colors duration-(--litt-dur-instant) ease-litt \" + look.markTone + className}>\n      <StatusIcon status={look.mark} />\n    </span>\n  );\n}\n\n/* Flat and quiet: the block under it says selected, the Glide's highlight under that says hover. */\nconst ROW =\n  \"relative z-1 grid h-14 w-full cursor-pointer grid-cols-[1rem_minmax(0,1fr)] content-center items-start gap-x-3 rounded-litt-control px-3 \" +\n  \"text-left no-underline select-none [-webkit-tap-highlight-color:transparent] \" +\n  \"transition-colors duration-(--litt-dur-instant) ease-litt pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] \" +\n  \"group-data-coarse/runs:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\";\n\n/** The verb's column. Every row keeps it while the list has verbs, so the updated words line up and nothing shifts.\n    A coarse pointer is read twice, from CSS and from useDevice (data-coarse), so a forced phone preview gets it too. */\nconst ROOM = \"pr-10 pointer-coarse:pr-13 group-data-coarse/runs:pr-13\";\n\nconst VERB_SLOT =\n  \"absolute top-1/2 right-1 z-2 -translate-y-1/2 opacity-0 transition-opacity duration-(--litt-dur-instant) ease-litt \" +\n  \"group-hover/run:opacity-100 group-focus-within/run:opacity-100 pointer-coarse:opacity-100 group-data-coarse/runs:opacity-100\";\n\n/** Motion's own instant transition, which it uses under reduced motion: the layout jumps and nothing moves. */\nconst AT_ONCE: Transition = { type: false };\n\n/* ----------------------------------------------------------------- RunList */\n\n/**\n * An agent's runs as a list: a filter with counts, then a row per run with its mark, title, status line and when it\n * changed. The runs that need you come first, the open run is the raised block that glides to the row you press, and\n * Stop or Archive sits on the row.\n */\nexport function RunList({\n  runs,\n  label = \"Runs\",\n  selectedId,\n  onSelect,\n  onStop,\n  onArchive,\n  filter: filterProp,\n  defaultFilter = \"all\",\n  onFilterChange,\n  empty,\n  className = \"\",\n}: RunListProps) {\n  const id = useId();\n  const labelId = `${id}-label`;\n  const { coarse } = useDevice();\n  const fun = useFun();\n  const enter = useTransition(\"responsive\");\n\n  const [inner, setInner] = useState<RunFilter>(defaultFilter);\n  const filter = filterProp ?? inner;\n  const reading = readRuns(runs, filter);\n  const { order } = reading;\n  const headed = reading.groups.length > 1;\n  const hasVerbs = !!(onStop || onArchive);\n  const handlerFor = (verb: Verb | null) => (verb === \"stop\" ? onStop : verb === \"archive\" ? onArchive : undefined);\n\n  /* R4 in structure. A filter press records the filter it asked for, and only the render that shows it glides; the\n     commit clears it, so a render the agent causes (a status, a new run) moves rows at once. The selection glides\n     when it changes, which a press does. */\n  const asked = useRef<RunFilter | null>(null);\n  const byHand = asked.current === filter;\n  const lastSelected = useRef(selectedId);\n  const selectionMoved = lastSelected.current !== selectedId;\n  useLayoutEffect(() => {\n    asked.current = null;\n    lastSelected.current = selectedId;\n  });\n  const rowMove = byHand ? enter : AT_ONCE;\n  const blockMove = byHand || selectionMoved ? (fun?.glide ?? enter) : AT_ONCE;\n\n  const pick = (next: RunFilter) => {\n    asked.current = next;\n    if (filterProp === undefined) setInner(next);\n    onFilterChange?.(next);\n  };\n\n  /* Hover: the Glide's one highlight, for a mouse only. A tap shows the row's own pressed fill and leaves nothing lit. */\n  const glideRef = useRef<Glide | null>(null);\n  const glide = useGlide({\n    forcedHighlight: false,\n    onPointerRow: (index, _row, type) => {\n      if (type === \"mouse\") glideRef.current?.setActive(index, \"pointer\");\n    },\n  });\n  glideRef.current = glide;\n\n  /* One Tab stop: the run last focused, else the selected one, else the first. */\n  const [active, setActive] = useState<string | null>(null);\n  const tabStop = order.find((r) => r.id === active) ?? order.find((r) => r.id === selectedId) ?? order[0];\n  const rows = useRef(new Map<string, HTMLElement>());\n  const verbs = useRef(new Map<string, HTMLElement>());\n  const keep = (map: Map<string, HTMLElement>, key: string) => (el: HTMLElement | null) => {\n    if (el) map.set(key, el);\n    else map.delete(key);\n  };\n\n  const move = (e: KeyboardEvent<HTMLElement>, run: AgentRun) => {\n    const at = order.findIndex((r) => r.id === run.id);\n    const to = e.key === \"ArrowDown\" ? at + 1 : e.key === \"ArrowUp\" ? at - 1 : e.key === \"Home\" ? 0 : e.key === \"End\" ? order.length - 1 : null;\n    if (to === null) return false;\n    e.preventDefault();\n    const next = order[Math.max(0, Math.min(order.length - 1, to))];\n    rows.current.get(next.id)?.focus();\n    return true;\n  };\n  const onRowKey = (e: KeyboardEvent<HTMLElement>, run: AgentRun) => {\n    if (move(e, run)) return;\n    if (e.key === \"ArrowRight\") {\n      const verb = verbs.current.get(run.id);\n      if (verb) {\n        e.preventDefault();\n        verb.focus();\n      }\n    } else if (e.key === \" \" && run.href) {\n      /* A link opens on Enter by itself; Space opens it too, as it presses a button row. */\n      e.preventDefault();\n      e.currentTarget.click();\n    }\n  };\n  const onVerbKey = (e: KeyboardEvent<HTMLElement>, run: AgentRun) => {\n    if (move(e, run)) return;\n    if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      rows.current.get(run.id)?.focus();\n    }\n  };\n\n  /* A pressed verb or Show all runs that had focus can leave with what it acted on. After the commit, focus goes to\n     the row that takes its place, so a keyboard never falls back to the page. */\n  const refocus = useRef<{ id: string | null; at: number } | null>(null);\n  useLayoutEffect(() => {\n    const was = refocus.current;\n    if (!was) return;\n    refocus.current = null;\n    if (was.id && verbs.current.has(was.id)) return;\n    const next = order.find((r) => r.id === was.id) ?? order[Math.min(was.at, order.length - 1)];\n    if (next) rows.current.get(next.id)?.focus({ preventScroll: true });\n  });\n  const hadFocus = (e: MouseEvent<HTMLElement>) => e.currentTarget === document.activeElement;\n\n  /* The runs that arrived in needs you, failed or done since the last commit: the voice says them, and in fun mode\n     their marks pop. Nothing on the first render, and nothing for a run that only appears. */\n  const [said, setSaid] = useState(\"\");\n  const statuses = useRef<Map<string, RunStatus> | null>(null);\n  const arrived = new Map<AgentRun, string>();\n  for (const r of reading.runs) {\n    const was = statuses.current?.get(r.id);\n    const voice = STATUS[r.status].voice;\n    if (was !== undefined && was !== r.status && voice) arrived.set(r, voice(r.title));\n  }\n  useLayoutEffect(() => {\n    statuses.current = new Map(reading.runs.map((r) => [r.id, r.status]));\n    if (arrived.size) setSaid([...arrived.values()].join(\". \"));\n  });\n\n  const options = FILTERS.map((f) => ({\n    value: f,\n    textValue: FILTER_WORD[f],\n    disabled: f !== \"all\" && reading.counts[f] === 0,\n    label: (\n      <span className=\"flex w-full items-baseline justify-between gap-3\">\n        <span className=\"truncate\">{FILTER_WORD[f]}</span>\n        <span className=\"tabular-nums text-litt-fg-subtle\">{reading.counts[f]}</span>\n      </span>\n    ),\n  }));\n\n  let body: ReactNode;\n  if (reading.runs.length === 0) {\n    body = empty ?? (\n      <Empty bare className=\"px-3 py-2\">\n        {NOTHING.all}\n      </Empty>\n    );\n  } else if (order.length === 0) {\n    body = (\n      <Empty\n        bare\n        role=\"status\"\n        className=\"px-3 py-2\"\n        action={\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"-ml-litt-control-pad-sm\"\n            onClick={(e) => {\n              if (hadFocus(e)) refocus.current = { id: null, at: 0 };\n              pick(\"all\");\n            }}\n          >\n            Show all runs\n          </Button>\n        }\n      >\n        {NOTHING[filter]}\n      </Empty>\n    );\n  } else {\n    body = (\n      <LayoutGroup id={id}>\n        {/* isolate: the rows' z-1 and the verbs' z-2 stack inside the list, never over a menu or tooltip portaled to the page. */}\n        <div ref={glide.ref} {...glide.props} className=\"relative isolate grid\">\n          {glide.highlight}\n          {reading.groups.map((group) => {\n            const headingId = `${id}-${group.key}`;\n            return (\n              <Fragment key={group.key}>\n                {headed && (\n                  <motion.p\n                    id={headingId}\n                    layout=\"position\"\n                    transition={rowMove}\n                    initial={byHand ? { opacity: 0 } : false}\n                    animate={{ opacity: 1, transition: enter }}\n                    className=\"flex h-8 items-center px-3 text-litt-caption text-litt-fg-subtle litt-legend\"\n                  >\n                    {group.heading}\n                  </motion.p>\n                )}\n                <ul role=\"list\" aria-labelledby={headed ? headingId : labelId} className=\"m-0 list-none p-0\">\n                  {group.runs.map((run) => {\n                    const look = STATUS[run.status];\n                    const selected = run.id === selectedId;\n                    const verb = handlerFor(look.verb) ? look.verb : null;\n                    const lit = selected ? \" forced-colors:[forced-color-adjust:none] forced-colors:text-[HighlightText] forced-colors:**:text-[HighlightText]\" : \"\";\n                    const shared = {\n                      ref: keep(rows.current, run.id),\n                      tabIndex: run.id === tabStop?.id ? 0 : -1,\n                      \"aria-current\": selected ? (\"true\" as const) : undefined,\n                      onClick: () => onSelect?.(run),\n                      onFocus: () => setActive(run.id),\n                      onKeyDown: (e: KeyboardEvent<HTMLElement>) => onRowKey(e, run),\n                      className: ROW + (hasVerbs ? \" \" + ROOM : \"\"),\n                    };\n                    const content = (\n                      <>\n                        <Mark status={run.status} pop={arrived.has(run)} className={lit} />\n                        <span className=\"grid min-w-0\">\n                          <span className={\"truncate text-litt-small font-litt-title transition-colors duration-(--litt-dur-instant) ease-litt \" + look.titleTone + lit}>\n                            {run.title}\n                          </span>\n                          <span className={\"flex min-w-0 items-baseline gap-3 text-litt-caption font-normal text-litt-fg-subtle\" + lit}>\n                            <span className=\"min-w-0 truncate\">\n                              <Pause />\n                              <span className={look.wordLook}>{look.word}</span>\n                              {lineParts(run).map((part, i) => (\n                                <Fragment key={i}>\n                                  <span aria-hidden=\"true\"> · </span>\n                                  <Pause />\n                                  {part}\n                                </Fragment>\n                              ))}\n                            </span>\n                            <span className=\"ml-auto shrink-0 tabular-nums\">\n                              <Pause />\n                              {run.updated}\n                            </span>\n                          </span>\n                        </span>\n                      </>\n                    );\n                    return (\n                      <motion.li\n                        key={run.id}\n                        {...GLIDE_ROW}\n                        layout=\"position\"\n                        transition={rowMove}\n                        initial={byHand ? { opacity: 0 } : false}\n                        animate={{ opacity: 1, transition: enter }}\n                        className=\"group/run relative\"\n                      >\n                        {selected && (\n                          <motion.span\n                            layoutId=\"selected\"\n                            transition={blockMove}\n                            aria-hidden=\"true\"\n                            className=\"absolute inset-0 rounded-[calc(var(--litt-radius-surface)-4px)] bg-litt-block shadow-(--litt-block-lift) forced-colors:border forced-colors:bg-[Highlight]\"\n                          />\n                        )}\n                        {run.href ? (\n                          <a href={run.href} {...shared}>\n                            {content}\n                          </a>\n                        ) : (\n                          <button type=\"button\" {...shared}>\n                            {content}\n                          </button>\n                        )}\n                        {verb && (\n                          <span className={VERB_SLOT}>\n                            <Tooltip content={VERB[verb].word}>\n                              <Button\n                                ref={keep(verbs.current, run.id)}\n                                variant=\"ghost\"\n                                size={coarse ? \"md\" : \"sm\"}\n                                iconOnly\n                                tabIndex={-1}\n                                aria-label={`${VERB[verb].word} ${run.title}`}\n                                onKeyDown={(e) => onVerbKey(e, run)}\n                                onClick={(e) => {\n                                  if (hadFocus(e)) refocus.current = { id: run.id, at: order.indexOf(run) };\n                                  handlerFor(verb)?.(run);\n                                }}\n                              >\n                                {VERB[verb].icon}\n                              </Button>\n                            </Tooltip>\n                          </span>\n                        )}\n                      </motion.li>\n                    );\n                  })}\n                </ul>\n              </Fragment>\n            );\n          })}\n        </div>\n      </LayoutGroup>\n    );\n  }\n\n  return (\n    <div data-coarse={coarse ? \"\" : undefined} className={\"group/runs grid min-w-0 content-start gap-2 \" + className}>\n      <div className=\"flex min-h-8 items-center gap-3\">\n        <p id={labelId} className=\"flex min-w-0 flex-1 items-baseline gap-2 pl-3\">\n          <span className=\"truncate text-litt-small font-litt-title text-litt-fg\">{label}</span>\n          <span className=\"text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">{reading.counts.all}</span>\n        </p>\n        <div className=\"w-36 shrink-0\">\n          <Select size={coarse ? \"md\" : \"sm\"} aria-label=\"Filter runs by status\" value={filter} onValueChange={(v) => pick(toFilter(v))} options={options} />\n        </div>\n      </div>\n      {body}\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {said}\n      </div>\n    </div>\n  );\n}\n",
      "target": "components/litt/run-list.tsx"
    }
  ],
  "docs": "Import RunList from components/litt/run-list. Pass runs ({ id, title, status, detail?, progress?, updated, href? }) in your order, selectedId and onSelect, and onStop and onArchive for the row actions. updated is words you give (\"2m ago\"): the list reads no clock.",
  "meta": {
    "version": "2026.11.05",
    "released": "2026-11-05",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
