{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "insight-cards",
  "type": "registry:component",
  "title": "Insight cards",
  "description": "Paged agent insights with scrub-ready live charts.",
  "categories": [
    "ai-interface"
  ],
  "dependencies": [
    "liveline"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json"
  ],
  "files": [
    {
      "path": "src/registry/components/insight-cards/insight-cards.tsx",
      "content": "\"use client\";\n\nimport { Liveline, type LivelinePoint, type LivelineSeries } from \"liveline\";\nimport { useEffect, useMemo, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * INSIGHT CARDS\n * Embedded mini-visualizations in an \"Insights N ‹ ›\"\n * carousel. Autoplay yields as soon as a person uses it.\n * ───────────────────────────────────────────────────────── */\n\nconst EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\nconst formatPercent = (v: number) => `${v > 0 ? \"+\" : \"\"}${v.toFixed(2)}%`;\nconst formatCount = (v: number) => Math.round(v).toLocaleString(\"en-US\");\n/* anchor the snapshot to *call* time (inside each card's mount-time memo) —\n * a module-load constant goes stale, and once the points age past the chart\n * window the canvas renders empty */\nfunction makePoints(values: number[], gap = 6): LivelinePoint[] {\n  const end = Math.floor(Date.now() / 1000);\n  return values.map((value, index) => ({\n    time: end - (values.length - 1 - index) * gap,\n    value,\n  }));\n}\n\n/* Catmull-Rom resample — turn a sparse series into a dense, smoothly curved\n * one so both the line and the hover cursor glide instead of stepping between\n * a handful of points. */\nfunction smooth(values: number[], perSegment = 9): number[] {\n  if (values.length < 3) return values.slice();\n  const out: number[] = [];\n  const n = values.length;\n  for (let i = 0; i < n - 1; i += 1) {\n    const p0 = values[Math.max(0, i - 1)];\n    const p1 = values[i];\n    const p2 = values[i + 1];\n    const p3 = values[Math.min(n - 1, i + 2)];\n    for (let s = 0; s < perSegment; s += 1) {\n      const t = s / perSegment;\n      const t2 = t * t;\n      const t3 = t2 * t;\n      out.push(\n        0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3),\n      );\n    }\n  }\n  out.push(values[n - 1]);\n  return out;\n}\n\n/* dense, smoothed points spanning exactly `spanSecs` — keeps the chart window\n * unchanged while multiplying the resolution. */\nfunction smoothPoints(values: number[], spanSecs: number): LivelinePoint[] {\n  const dense = smooth(values);\n  return makePoints(dense, spanSecs / (dense.length - 1));\n}\n\nfunction useDarkMode() {\n  const [dark, setDark] = useState(false);\n\n  useEffect(() => {\n    const root = document.documentElement;\n    const update = () => setDark(root.classList.contains(\"dark\") || root.getAttribute(\"data-theme\") === \"dark\");\n    update();\n    const observer = new MutationObserver(update);\n    observer.observe(root, { attributes: true, attributeFilter: [\"class\", \"data-theme\"] });\n    return () => observer.disconnect();\n  }, []);\n\n  return dark;\n}\n\n/* inline @entity mention */\nfunction Entity({ name, tone }: { name: string; tone: string }) {\n  return (\n    <span className=\"inline-flex items-center gap-1 align-baseline font-medium text-ink\">\n      <span className={`inline-block size-2.5 rounded-full ${tone}`} />@{name}\n    </span>\n  );\n}\n\nfunction Mono({ children, tone }: { children: React.ReactNode; tone: \"red\" | \"green\" }) {\n  return <code className={`font-mono text-[11.5px] ${tone === \"red\" ? \"text-red\" : \"text-green\"}`}>{children}</code>;\n}\n\nfunction chartIndexFromPointer(event: React.PointerEvent<HTMLDivElement>, pointCount: number) {\n  const rect = event.currentTarget.getBoundingClientRect();\n  const progress = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));\n  return Math.round(progress * (pointCount - 1));\n}\n\nfunction ChartTooltip({ rows }: { rows: { label: string; value: string; color: string }[] }) {\n  return (\n    <div className=\"flex items-center gap-3 whitespace-nowrap rounded-control border border-line-strong bg-tooltip-bg px-[9px] py-[5px] text-[11.5px] tabular-nums text-tooltip-fg shadow-overlay\">\n      {rows.map((row) => (\n        <span key={row.label} className=\"inline-flex items-center gap-1.5\">\n          <span className=\"size-2 flex-[0_0_8px] rounded-full\" style={{ background: row.color }} />\n          {row.value}\n        </span>\n      ))}\n    </div>\n  );\n}\n\n/* content shape for the return-comparison card's two plotted series */\nexport type CompareSeries = {\n  name: string;\n  values: number[];\n  sub: string;\n  tone: \"red\" | \"green\";\n  dot: string;\n  color: string;\n  tooltipColor: string;\n};\n\nconst COMPARE_SERIES: CompareSeries[] = [\n  {\n    name: \"capydb.dev\",\n    values: [-2.9, -3.4, -3.05, -3.86, -3.52, -4.1, -3.82, -4.41],\n    sub: \"-2,378 visitors\",\n    tone: \"red\",\n    dot: \"bg-orange\",\n    color: \"#f68f3c\",\n    tooltipColor: \"var(--orange)\",\n  },\n  {\n    name: \"notdocs.dev\",\n    values: [0.22, 0.58, 0.42, 0.91, 0.76, 1.08, 0.96, 1.15],\n    sub: \"+617 visitors\",\n    tone: \"green\",\n    dot: \"bg-accent\",\n    color: \"#3d9aff\",\n    tooltipColor: \"var(--accent)\",\n  },\n];\n\n/* 1 — return comparison: 2 series, legend + big deltas + line chart */\nfunction CompareCard({ series = COMPARE_SERIES }: { series?: CompareSeries[] }) {\n  const dark = useDarkMode();\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const points = useMemo(() => series.map((s) => smoothPoints(s.values, 42)), [series]);\n  const pointCount = points[0]?.length ?? 0;\n\n  const chartSeries: LivelineSeries[] = useMemo(\n    () =>\n      series.map((s, i) => ({\n        id: s.name,\n        label: \"\",\n        data: points[i],\n        value: points[i].at(-1)?.value ?? s.values.at(-1) ?? 0,\n        color: s.color,\n      })),\n    [series, points],\n  );\n\n  return (\n    <div className=\"min-h-[278px] rounded-card bg-surface p-3 shadow-hairline\">\n      <div className=\"flex items-center gap-4\">\n        {series.map((s, i) => (\n          <div key={s.name} className=\"flex-1\">\n            <span className=\"flex items-center gap-1.5 text-[11.5px] text-ink-2\">\n              <span className={`size-2 rounded-full ${s.dot}`} />\n              {s.name}\n            </span>\n            <span\n              className={`block text-[17px] font-semibold tracking-[-0.01em] tabular-nums ${s.tone === \"red\" ? \"text-red\" : \"text-green\"}`}\n            >\n              {formatPercent(points[i].at(-1)?.value ?? s.values.at(-1) ?? 0)}\n            </span>\n            <Mono tone={s.tone}>{s.sub}</Mono>\n          </div>\n        ))}\n      </div>\n      <div className=\"mt-2 overflow-hidden rounded-control bg-inset shadow-hairline\">\n        <div className=\"flex items-center justify-between border-b border-line px-2.5 py-1.5\">\n          <span className=\"text-[11px] text-ink-3 tabular-nums\">Trend snapshot</span>\n          <span className=\"rounded-full bg-field px-2 py-0.5 text-[10.5px] font-medium text-ink-2\">Snapshot</span>\n        </div>\n        <div\n          className=\"relative h-[166px] overflow-hidden [@media(hover:none)_and_(pointer:coarse)]:touch-pan-y\"\n          onPointerDown={(event) => setHoverIndex(chartIndexFromPointer(event, pointCount))}\n          onPointerMove={(event) => setHoverIndex(chartIndexFromPointer(event, pointCount))}\n          onPointerLeave={() => setHoverIndex(null)}\n          onPointerCancel={() => setHoverIndex(null)}\n          onPointerUp={() => setHoverIndex(null)}\n        >\n          <Liveline\n            data={[]}\n            value={0}\n            series={chartSeries}\n            theme={dark ? \"dark\" : \"light\"}\n            grid={false}\n            pulse={false}\n            window={42}\n            paused\n            scrub={false}\n            cursor=\"default\"\n            lineWidth={2.25}\n            padding={{ top: 40, right: 0, bottom: 22, left: 0 }}\n            formatValue={formatPercent}\n          />\n          {hoverIndex !== null && (\n            <>\n              <span\n                className=\"pointer-events-none absolute inset-y-0 z-4 w-px bg-ink opacity-26\"\n                style={{ left: `${(hoverIndex / (pointCount - 1)) * 100}%` }}\n              />\n              <span\n                className=\"pointer-events-none absolute top-2 z-5 -translate-x-1/2\"\n                style={{ left: `${Math.min(Math.max((hoverIndex / (pointCount - 1)) * 100, 28), 72)}%` }}\n              >\n                <ChartTooltip\n                  rows={series.map((s, i) => ({\n                    label: s.name,\n                    value: formatPercent(points[i][hoverIndex].value),\n                    color: s.tooltipColor,\n                  }))}\n                />\n              </span>\n            </>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/* content shape for the anomaly card's two toggled metric series: `spend` is\n * plotted as pageviews and `usage` as events */\nexport type AnomalyData = {\n  spend: number[];\n  usage: number[];\n};\n\nconst ANOMALY_DATA: AnomalyData = {\n  spend: [274, 289, 264, 307, 331, 1210, 1718, 2112],\n  usage: [18, 19, 17, 21, 22, 58, 81, 96],\n};\n\n/* 2 — anomaly: a pageview spike against its alert line + the week's total */\nfunction AnomalyCard({ data: anomaly = ANOMALY_DATA }: { data?: AnomalyData }) {\n  const dark = useDarkMode();\n  const [metric, setMetric] = useState<\"spend\" | \"usage\">(\"spend\");\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const spend = useMemo(() => makePoints(anomaly.spend, 7), [anomaly]);\n  const usage = useMemo(() => makePoints(anomaly.usage, 7), [anomaly]);\n\n  const data = metric === \"spend\" ? spend : usage;\n  const value = data.at(-1)?.value ?? (metric === \"spend\" ? 2112 : 96);\n  const threshold = metric === \"spend\" ? \"2,000 pageviews\" : \"82 events\";\n  const format = (v: number) => (metric === \"spend\" ? `${formatCount(v)} pageviews` : `${Math.round(v)} events`);\n  const totalLabel = formatCount(spend.at(-1)?.value ?? 2112);\n\n  return (\n    <div className=\"min-h-[278px] rounded-card bg-surface p-3 shadow-hairline\">\n      <div className=\"flex items-center justify-between\">\n        <span className=\"flex items-center gap-1.5 text-[12px] font-medium text-ink\">\n          <svg\n            width=\"12\"\n            height=\"12\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"var(--red)\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <path d=\"M12 19V5M5 12l7-7 7 7\" />\n          </svg>\n          Pageview spike\n        </span>\n        <span className=\"rounded-full bg-field px-2 py-0.5 text-[10.5px] font-medium text-ink-2\">Snapshot</span>\n      </div>\n      <div className=\"mt-2 overflow-hidden rounded-control bg-inset shadow-hairline\">\n        <div className=\"flex items-center justify-between border-b border-line px-2.5 py-1.5\">\n          <span className=\"text-[11px] text-ink-3 tabular-nums\">\n            {hoverIndex !== null ? format(data[hoverIndex].value) : `Alert at ${threshold}`}\n          </span>\n          <span className=\"flex rounded-button bg-field p-0.5\">\n            {([\"spend\", \"usage\"] as const).map((item) => (\n              <button\n                key={item}\n                type=\"button\"\n                aria-pressed={metric === item}\n                onClick={() => setMetric(item)}\n                className={`rounded-button px-2 py-0.5 text-[10.5px] font-medium transition-[background-color,color,box-shadow,transform] duration-150 active:scale-[0.96] ${\n                  metric === item ? \"bg-surface text-ink shadow-btn\" : \"text-ink-3 hover:text-ink-2\"\n                }`}\n              >\n                {item === \"spend\" ? \"Pageviews\" : \"Events\"}\n              </button>\n            ))}\n          </span>\n        </div>\n        <div\n          className=\"relative h-[166px] overflow-hidden [@media(hover:none)_and_(pointer:coarse)]:touch-pan-y\"\n          onPointerDown={(event) => setHoverIndex(chartIndexFromPointer(event, data.length))}\n          onPointerMove={(event) => setHoverIndex(chartIndexFromPointer(event, data.length))}\n          onPointerLeave={() => setHoverIndex(null)}\n          onPointerCancel={() => setHoverIndex(null)}\n          onPointerUp={() => setHoverIndex(null)}\n        >\n          <Liveline\n            data={data}\n            value={value}\n            theme={dark ? \"dark\" : \"light\"}\n            color=\"#ee5c61\"\n            grid\n            scrub={false}\n            fill={false}\n            pulse={false}\n            momentum={false}\n            paused\n            window={49}\n            lineWidth={2.25}\n            cursor=\"crosshair\"\n            padding={{ top: 34, right: 0, bottom: 22, left: 0 }}\n            formatValue={format}\n          />\n          {hoverIndex !== null && (\n            <>\n              <span\n                className=\"pointer-events-none absolute inset-y-0 z-4 w-px bg-ink opacity-26\"\n                style={{ left: `${(hoverIndex / (data.length - 1)) * 100}%` }}\n              />\n              <span\n                className=\"pointer-events-none absolute top-2 z-5 -translate-x-1/2\"\n                style={{ left: `${Math.min(Math.max((hoverIndex / (data.length - 1)) * 100, 28), 72)}%` }}\n              >\n                <ChartTooltip\n                  rows={[\n                    {\n                      label: metric === \"spend\" ? \"Pageviews\" : \"Events\",\n                      value: format(data[hoverIndex].value),\n                      color: \"var(--red)\",\n                    },\n                  ]}\n                />\n              </span>\n            </>\n          )}\n        </div>\n      </div>\n      <div className=\"mt-1.5 flex items-baseline gap-2\">\n        <span className=\"text-[17px] font-semibold tracking-[-0.01em] text-ink tabular-nums\">\n          {totalLabel} pageviews\n        </span>\n        <Mono tone=\"red\">+1,835</Mono>\n        <span className=\"text-[11px] text-ink-3\">vs 4-week average</span>\n      </div>\n    </div>\n  );\n}\n\n/* content shape for one allocation segment */\nexport type AllocationSegment = {\n  name: string;\n  label: string;\n  pct: number;\n  amount: string;\n  cls: string;\n  tone: string;\n};\n\nconst ALLOCATION_SEGMENTS: AllocationSegment[] = [\n  { name: \"Desktop\", label: \"Desktop\", pct: 62, amount: \"11,309 visitors\", cls: \"bg-orange\", tone: \"text-orange\" },\n  { name: \"Mobile\", label: \"Mobile\", pct: 33.3, amount: \"6,074 visitors\", cls: \"bg-line-strong\", tone: \"text-ink-2\" },\n  { name: \"Tablet\", label: \"Tablet\", pct: 4.7, amount: \"857 visitors\", cls: \"bg-line\", tone: \"text-ink-3\" },\n];\n\n/* 3 — allocation: hero number + segmented bar + legend (visitors by device) */\nfunction AllocationCard({ segments = ALLOCATION_SEGMENTS }: { segments?: AllocationSegment[] }) {\n  const [selected, setSelected] = useState(segments[0].name);\n  const active = segments.find((segment) => segment.name === selected) ?? segments[0];\n\n  return (\n    <div className=\"min-h-[278px] rounded-card bg-surface p-3 shadow-hairline\">\n      <span className=\"flex items-center gap-1.5 text-[12px] font-medium text-ink\">\n        <span className=\"flex size-3.5 items-center justify-center rounded-full bg-orange text-[8px] font-bold text-white\">\n          D\n        </span>\n        Desktop share\n      </span>\n      <span className=\"mt-1 block text-[20px] font-semibold tracking-[-0.01em] text-ink tabular-nums\">\n        {active.amount}\n      </span>\n      <div\n        className=\"mt-3 flex h-9 gap-0.5 overflow-hidden rounded-button bg-field p-0.5\"\n        role=\"group\"\n        aria-label=\"Allocation segments\"\n      >\n        {segments.map((s) => (\n          <button\n            key={s.name}\n            type=\"button\"\n            aria-pressed={selected === s.name}\n            aria-label={`${s.label}: ${s.pct}%`}\n            onClick={() => setSelected(s.name)}\n            className={`relative h-full overflow-hidden rounded-button ${s.cls} transition-[opacity,transform,box-shadow] duration-300 active:scale-[0.98]`}\n            style={{\n              width: `${s.pct}%`,\n              opacity: selected === s.name ? 1 : 0.58,\n              boxShadow: selected === s.name ? \"inset 0 0 0 1px rgba(255,255,255,0.22)\" : undefined,\n              transitionTimingFunction: EASE,\n            }}\n          >\n            <span\n              className=\"absolute inset-y-1 left-1 rounded-button bg-white/20 transition-[width,opacity] duration-500\"\n              style={{\n                width: selected === s.name ? \"calc(100% - 8px)\" : \"0%\",\n                opacity: selected === s.name ? 1 : 0,\n                transitionTimingFunction: EASE,\n              }}\n            />\n          </button>\n        ))}\n      </div>\n      <div className=\"mt-2 flex items-center gap-1.5\">\n        {segments.map((s) => (\n          <button\n            key={s.name}\n            type=\"button\"\n            aria-pressed={selected === s.name}\n            onClick={() => setSelected(s.name)}\n            className={`flex items-center gap-1 rounded-button px-1.5 py-0.5 text-[11px] transition-[background-color,color,transform] duration-150 active:scale-[0.96] ${\n              selected === s.name ? \"bg-field text-ink\" : \"text-ink-2 hover:bg-hover hover:text-ink\"\n            }`}\n          >\n            <span className={`size-1.5 rounded-full ${s.cls}`} />\n            {s.name} <span className=\"tabular-nums\">{s.pct}%</span>\n          </button>\n        ))}\n      </div>\n      <div className=\"mt-3 min-h-16 rounded-control bg-inset px-2.5 py-2 shadow-hairline\">\n        <span className={`block text-[11.5px] font-medium ${active.tone}`}>{active.label}</span>\n        <span className=\"mt-1 block text-[11px] leading-relaxed text-ink-3\">\n          Share of this week’s 18,240 visitors. Selecting a segment changes the inspected group without moving the card.\n        </span>\n      </div>\n    </div>\n  );\n}\n\n/* content shape for one insight page in the carousel */\nexport type InsightPage = {\n  key: string;\n  prose: React.ReactNode;\n  Card: React.ComponentType;\n  pill: string;\n};\n\nconst PAGES: InsightPage[] = [\n  {\n    key: \"compare\",\n    prose: (\n      <>\n        The weakest website this week is <Entity name=\"capydb.dev\" tone=\"bg-orange\" /> — down{\" \"}\n        <Mono tone=\"red\">-4.41%</Mono> or <Mono tone=\"red\">-2,378</Mono> visitors.\n      </>\n    ),\n    Card: CompareCard,\n    pill: \"Which pages lost visitors?\",\n  },\n  {\n    key: \"anomaly\",\n    prose: (\n      <>\n        Unusually high traffic on <span className=\"font-medium text-ink\">13 Oct</span> — <Mono tone=\"red\">+1,835</Mono>{\" \"}\n        pageviews above your average.\n      </>\n    ),\n    Card: AnomalyCard,\n    pill: \"Show where the visits came from\",\n  },\n  {\n    key: \"allocation\",\n    prose: (\n      <>\n        Most visits come from <Entity name=\"Desktop\" tone=\"bg-orange\" /> — it’s{\" \"}\n        <span className=\"font-medium text-ink\">62%</span> of this week’s traffic.\n      </>\n    ),\n    Card: AllocationCard,\n    pill: \"If we look at mobile, what changes?\",\n  },\n];\n\nexport type InsightCardsLabels = {\n  /** carousel heading shown before the page count */\n  title: string;\n};\n\nconst DEFAULT_INSIGHT_LABELS: InsightCardsLabels = {\n  title: \"Insights\",\n};\n\nexport default function InsightCards({\n  pages = PAGES,\n  labels,\n}: {\n  variant?: string;\n  pages?: InsightPage[];\n  labels?: Partial<InsightCardsLabels>;\n} = {}) {\n  const l = { ...DEFAULT_INSIGHT_LABELS, ...labels };\n  const [page, setPage] = useState(0);\n\n  const move = (direction: -1 | 1) => {\n    setPage((current) => (current + direction + pages.length) % pages.length);\n  };\n\n  const { prose, Card, pill } = pages[page];\n\n  return (\n    <div className=\"min-h-[408px] w-full max-w-86\">\n      {/* pager header */}\n      <div className=\"flex items-center justify-between\">\n        <span className=\"flex items-baseline gap-1.5\">\n          <span className=\"text-[13px] font-semibold text-ink\">{l.title}</span>\n          <span className=\"text-[13px] text-ink-3 tabular-nums\">{pages.length}</span>\n        </span>\n        <span className=\"flex items-center gap-0.5\">\n          {([\"M15 18l-6-6 6-6\", \"M9 6l6 6-6 6\"] as const).map((d, i) => (\n            <button\n              key={i}\n              aria-label={i === 0 ? \"Previous insight\" : \"Next insight\"}\n              onClick={() => move(i === 0 ? -1 : 1)}\n              className=\"flex size-6 items-center justify-center rounded-button text-ink-3\n                transition-[background-color,color,transform] duration-100 hover:bg-hover\n                hover:text-ink active:scale-[0.96]\"\n            >\n              <svg\n                width=\"13\"\n                height=\"13\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2.2\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              >\n                <path d={d} />\n              </svg>\n            </button>\n          ))}\n        </span>\n      </div>\n\n      {/* page content — blurred crossfade */}\n      <div className=\"transition-[opacity,filter] duration-250\" style={{ opacity: 1, filter: \"blur(0)\" }}>\n        <p className=\"mt-1.5 text-[12.5px] leading-relaxed text-ink-2\">{prose}</p>\n        <div className=\"mt-2\">\n          <Card />\n        </div>\n        <button\n          className=\"mt-2 rounded-button bg-surface px-3 py-1.5 text-left text-[12px] text-ink\n            shadow-btn transition-colors duration-100 hover:bg-hover\"\n        >\n          {pill}\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/insight-cards/insight-cards.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/insight-cards",
  "meta": {
    "kind": "component",
    "group": "AI interface",
    "export": "InsightCards",
    "docs": "https://irisui.dev/components/insight-cards"
  }
}
