{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"insight-cards","title":"Insight Cards","description":"Paged agent insights with scrub-ready live charts.","dependencies":["liveline@0.0.7"],"registryDependencies":["https://ward.so/r/foundation.json"],"files":[{"path":"registry/ward/InsightCards.tsx","content":"\"use client\";\n/*\n * MIT License\n *\n * Copyright (c) 2026 Shane Levine\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport \"./styles/foundation.css\";\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 formatMoney = (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 *\n          (2 * p1 +\n            (-p0 + p2) * t +\n            (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 +\n            (-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\"));\n    update();\n    const observer = new MutationObserver(update);\n    observer.observe(root, { attributes: true, attributeFilter: [\"class\"] });\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}`} />\n      @{name}\n    </span>\n  );\n}\n\nfunction Mono({ children, tone }: { children: React.ReactNode; tone: \"red\" | \"green\" }) {\n  return (\n    <code className={`font-mono text-[11.5px] ${tone === \"red\" ? \"text-red\" : \"text-green\"}`}>\n      {children}\n    </code>\n  );\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=\"insight-chart-tooltip\">\n      {rows.map((row) => (\n        <span key={row.label} className=\"insight-chart-tooltip-item\">\n          <span className=\"insight-chart-tooltip-dot\" 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: \"Mint Chip\",\n    values: [-2.9, -3.4, -3.05, -3.86, -3.52, -4.1, -3.82, -4.41],\n    sub: \"-$2,377.66\",\n    tone: \"red\",\n    dot: \"bg-orange\",\n    color: \"#f68f3c\",\n    tooltipColor: \"var(--orange)\",\n  },\n  {\n    name: \"Pistachio\",\n    values: [0.22, 0.58, 0.42, 0.91, 0.76, 1.08, 0.96, 1.15],\n    sub: \"+$617.22\",\n    tone: \"green\",\n    dot: \"bg-[#3d9aff]\",\n    color: \"#3d9aff\",\n    tooltipColor: \"#3d9aff\",\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(\n    () => series.map((s) => smoothPoints(s.values, 42)),\n    [series],\n  );\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-card 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 className={`block text-[17px] font-semibold tracking-[-0.01em] tabular-nums ${s.tone === \"red\" ? \"text-red\" : \"text-green\"}`}>\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\">\n            Trend snapshot\n          </span>\n          <span className=\"rounded-full bg-field px-2 py-0.5 text-[10.5px] font-medium text-ink-2\">\n            Snapshot\n          </span>\n        </div>\n        <div\n          className=\"insight-chart-stage relative h-[166px]\"\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            <span className=\"insight-chart-cursor\" style={{ left: `${(hoverIndex / (pointCount - 1)) * 100}%` }} />\n            <span className=\"insight-chart-tooltip-anchor\" style={{ left: `${Math.min(Math.max((hoverIndex / (pointCount - 1)) * 100, 28), 72)}%` }}>\n              <ChartTooltip rows={series.map((s, i) => ({ label: s.name, value: formatPercent(points[i][hoverIndex].value), color: s.tooltipColor }))} />\n            </span>\n          </>}\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/* content shape for the anomaly card's two toggled metric series */\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: bars with threshold + big spent value */\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(\n    () => makePoints(anomaly.spend, 7),\n    [anomaly],\n  );\n  const usage = useMemo(\n    () => makePoints(anomaly.usage, 7),\n    [anomaly],\n  );\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,112\" : \"82 kWh\";\n  const moneyLabel = formatMoney(spend.at(-1)?.value ?? 2112);\n\n  return (\n    <div className=\"min-h-[278px] rounded-card bg-card 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 width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"var(--red)\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M12 19V5M5 12l7-7 7 7\" /></svg>\n          High freezer spend\n        </span>\n        <span className=\"rounded-full bg-field px-2 py-0.5 text-[10.5px] font-medium text-ink-2\">\n          Snapshot\n        </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\n              ? metric === \"spend\"\n                ? formatMoney(data[hoverIndex].value)\n                : `${Math.round(data[hoverIndex].value)} kWh`\n              : `${threshold} threshold`}\n          </span>\n          <span className=\"flex rounded-full 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-full 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-card text-ink shadow-btn\" : \"text-ink-3 hover:text-ink-2\"\n                }`}\n              >\n                {item === \"spend\" ? \"Spend\" : \"Usage\"}\n              </button>\n            ))}\n          </span>\n        </div>\n        <div\n          className=\"insight-chart-stage relative h-[166px]\"\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={(v) => (metric === \"spend\" ? formatMoney(v) : `${Math.round(v)} kWh`)}\n          />\n          {hoverIndex !== null && <>\n            <span className=\"insight-chart-cursor\" style={{ left: `${(hoverIndex / (data.length - 1)) * 100}%` }} />\n            <span className=\"insight-chart-tooltip-anchor\" style={{ left: `${Math.min(Math.max((hoverIndex / (data.length - 1)) * 100, 28), 72)}%` }}>\n              <ChartTooltip rows={[{ label: metric === \"spend\" ? \"Spend\" : \"Usage\", value: metric === \"spend\" ? formatMoney(data[hoverIndex].value) : `${Math.round(data[hoverIndex].value)} kWh`, color: \"var(--red)\" }]} />\n            </span>\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          {moneyLabel} spent\n        </span>\n        <Mono tone=\"red\">+$1,834.66</Mono>\n        <span className=\"text-[11px] text-ink-3\">vs 3 months</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: \"VAN\", label: \"Vanilla\", pct: 72.5, amount: \"$51,785\", cls: \"bg-orange\", tone: \"text-orange\" },\n  { name: \"CHOC\", label: \"Chocolate\", pct: 22.8, amount: \"$16,278\", cls: \"bg-line-strong\", tone: \"text-ink-2\" },\n  { name: \"MINT\", label: \"Mint\", pct: 4.7, amount: \"$3,357\", cls: \"bg-line\", tone: \"text-ink-3\" },\n];\n\n/* 3 — allocation: hero number + segmented bar + legend */\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-card 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          V\n        </span>\n        Vanilla allocation\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-full 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-full ${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-full 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-full 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          Contribution snapshot across current inventory value. Segment selection 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 worst performer in your <Entity name=\"Studio\" tone=\"bg-orange\" /> is\n        Rocky Road — down <Mono tone=\"red\">-6%</Mono> or <Mono tone=\"red\">-$2,453.44</Mono>.\n      </>\n    ),\n    Card: CompareCard,\n    pill: \"Should I rebalance projects?\",\n  },\n  {\n    key: \"anomaly\",\n    prose: (\n      <>\n        Unusually high freezer bill on <span className=\"font-medium text-ink\">Dec 13</span> —{\" \"}\n        <Mono tone=\"red\">+$1,834.66</Mono> above your average.\n      </>\n    ),\n    Card: AnomalyCard,\n    pill: \"Get tips on cutting freezer costs\",\n  },\n  {\n    key: \"allocation\",\n    prose: (\n      <>\n        You’re heavily invested in <Entity name=\"Vanilla\" tone=\"bg-orange\" /> — it’s{\" \"}\n        <span className=\"font-medium text-ink\">72.5%</span> of your case.\n      </>\n    ),\n    Card: AllocationCard,\n    pill: \"If we look at seasonals, 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-[6px] 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 width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                <path d={d} />\n              </svg>\n            </button>\n          ))}\n        </span>\n      </div>\n\n      {/* page content — blurred crossfade */}\n      <div\n        className=\"transition-[opacity,filter] duration-250\"\n        style={{ opacity: 1, filter: \"blur(0)\" }}\n      >\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-full bg-card 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/ward/InsightCards.tsx"}],"meta":{"variants":["Default"],"version":"1.1.0","source":"https://github.com/slev12397/beautiful-ui/blob/44a274e598395ab61e7c96c26fda2758780253b7/components/primitives/InsightCards.tsx","access":"free"},"categories":["data"],"type":"registry:component"}