Insight Cards
Paged agent insights with scrub-ready live charts.
"use client";
/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import "./styles/foundation.css";
import { Liveline, type LivelinePoint, type LivelineSeries } from "liveline";
import { useEffect, useMemo, useState } from "react";
/* ─────────────────────────────────────────────────────────
* INSIGHT CARDS
* Embedded mini-visualizations in an "Insights N ‹ ›"
* carousel. Autoplay yields as soon as a person uses it.
* ───────────────────────────────────────────────────────── */
const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
const formatPercent = (v: number) => `${v > 0 ? "+" : ""}${v.toFixed(2)}%`;
const formatMoney = (v: number) => `$${Math.round(v).toLocaleString("en-US")}`;
/* anchor the snapshot to *call* time (inside each card's mount-time memo) —
* a module-load constant goes stale, and once the points age past the chart
* window the canvas renders empty */
function makePoints(values: number[], gap = 6): LivelinePoint[] {
const end = Math.floor(Date.now() / 1000);
return values.map((value, index) => ({
time: end - (values.length - 1 - index) * gap,
value,
}));
}
/* Catmull-Rom resample — turn a sparse series into a dense, smoothly curved
* one so both the line and the hover cursor glide instead of stepping between
* a handful of points. */
function smooth(values: number[], perSegment = 9): number[] {
if (values.length < 3) return values.slice();
const out: number[] = [];
const n = values.length;
for (let i = 0; i < n - 1; i += 1) {
const p0 = values[Math.max(0, i - 1)];
const p1 = values[i];
const p2 = values[i + 1];
const p3 = values[Math.min(n - 1, i + 2)];
for (let s = 0; s < perSegment; s += 1) {
const t = s / perSegment;
const t2 = t * t;
const t3 = t2 * t;
out.push(
0.5 *
(2 * p1 +
(-p0 + p2) * t +
(2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 +
(-p0 + 3 * p1 - 3 * p2 + p3) * t3),
);
}
}
out.push(values[n - 1]);
return out;
}
/* dense, smoothed points spanning exactly `spanSecs` — keeps the chart window
* unchanged while multiplying the resolution. */
function smoothPoints(values: number[], spanSecs: number): LivelinePoint[] {
const dense = smooth(values);
return makePoints(dense, spanSecs / (dense.length - 1));
}
function useDarkMode() {
const [dark, setDark] = useState(false);
useEffect(() => {
const root = document.documentElement;
const update = () => setDark(root.classList.contains("dark"));
update();
const observer = new MutationObserver(update);
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return dark;
}
/* inline @entity mention */
function Entity({ name, tone }: { name: string; tone: string }) {
return (
<span className="inline-flex items-center gap-1 align-baseline font-medium text-ink">
<span className={`inline-block size-2.5 rounded-full ${tone}`} />
@{name}
</span>
);
}
function Mono({ children, tone }: { children: React.ReactNode; tone: "red" | "green" }) {
return (
<code className={`font-mono text-[11.5px] ${tone === "red" ? "text-red" : "text-green"}`}>
{children}
</code>
);
}
function chartIndexFromPointer(event: React.PointerEvent<HTMLDivElement>, pointCount: number) {
const rect = event.currentTarget.getBoundingClientRect();
const progress = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
return Math.round(progress * (pointCount - 1));
}
function ChartTooltip({ rows }: { rows: { label: string; value: string; color: string }[] }) {
return (
<div className="insight-chart-tooltip">
{rows.map((row) => (
<span key={row.label} className="insight-chart-tooltip-item">
<span className="insight-chart-tooltip-dot" style={{ background: row.color }} />
{row.value}
</span>
))}
</div>
);
}
/* content shape for the return-comparison card's two plotted series */
export type CompareSeries = {
name: string;
values: number[];
sub: string;
tone: "red" | "green";
dot: string;
color: string;
tooltipColor: string;
};
const COMPARE_SERIES: CompareSeries[] = [
{
name: "Mint Chip",
values: [-2.9, -3.4, -3.05, -3.86, -3.52, -4.1, -3.82, -4.41],
sub: "-$2,377.66",
tone: "red",
dot: "bg-orange",
color: "#f68f3c",
tooltipColor: "var(--orange)",
},
{
name: "Pistachio",
values: [0.22, 0.58, 0.42, 0.91, 0.76, 1.08, 0.96, 1.15],
sub: "+$617.22",
tone: "green",
dot: "bg-[#3d9aff]",
color: "#3d9aff",
tooltipColor: "#3d9aff",
},
];
/* 1 — return comparison: 2 series, legend + big deltas + line chart */
function CompareCard({ series = COMPARE_SERIES }: { series?: CompareSeries[] }) {
const dark = useDarkMode();
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const points = useMemo(
() => series.map((s) => smoothPoints(s.values, 42)),
[series],
);
const pointCount = points[0]?.length ?? 0;
const chartSeries: LivelineSeries[] = useMemo(
() =>
series.map((s, i) => ({
id: s.name,
label: "",
data: points[i],
value: points[i].at(-1)?.value ?? (s.values.at(-1) ?? 0),
color: s.color,
})),
[series, points],
);
return (
<div className="min-h-[278px] rounded-card bg-card p-3 shadow-hairline">
<div className="flex items-center gap-4">
{series.map((s, i) => (
<div key={s.name} className="flex-1">
<span className="flex items-center gap-1.5 text-[11.5px] text-ink-2">
<span className={`size-2 rounded-full ${s.dot}`} />
{s.name}
</span>
<span className={`block text-[17px] font-semibold tracking-[-0.01em] tabular-nums ${s.tone === "red" ? "text-red" : "text-green"}`}>
{formatPercent(points[i].at(-1)?.value ?? (s.values.at(-1) ?? 0))}
</span>
<Mono tone={s.tone}>{s.sub}</Mono>
</div>
))}
</div>
<div className="mt-2 overflow-hidden rounded-control bg-inset shadow-hairline">
<div className="flex items-center justify-between border-b border-line px-2.5 py-1.5">
<span className="text-[11px] text-ink-3 tabular-nums">
Trend snapshot
</span>
<span className="rounded-full bg-field px-2 py-0.5 text-[10.5px] font-medium text-ink-2">
Snapshot
</span>
</div>
<div
className="insight-chart-stage relative h-[166px]"
onPointerDown={(event) => setHoverIndex(chartIndexFromPointer(event, pointCount))}
onPointerMove={(event) => setHoverIndex(chartIndexFromPointer(event, pointCount))}
onPointerLeave={() => setHoverIndex(null)}
onPointerCancel={() => setHoverIndex(null)}
onPointerUp={() => setHoverIndex(null)}
>
<Liveline
data={[]}
value={0}
series={chartSeries}
theme={dark ? "dark" : "light"}
grid={false}
pulse={false}
window={42}
paused
scrub={false}
cursor="default"
lineWidth={2.25}
padding={{ top: 40, right: 0, bottom: 22, left: 0 }}
formatValue={formatPercent}
/>
{hoverIndex !== null && <>
<span className="insight-chart-cursor" style={{ left: `${(hoverIndex / (pointCount - 1)) * 100}%` }} />
<span className="insight-chart-tooltip-anchor" style={{ left: `${Math.min(Math.max((hoverIndex / (pointCount - 1)) * 100, 28), 72)}%` }}>
<ChartTooltip rows={series.map((s, i) => ({ label: s.name, value: formatPercent(points[i][hoverIndex].value), color: s.tooltipColor }))} />
</span>
</>}
</div>
</div>
</div>
);
}
/* content shape for the anomaly card's two toggled metric series */
export type AnomalyData = {
spend: number[];
usage: number[];
};
const ANOMALY_DATA: AnomalyData = {
spend: [274, 289, 264, 307, 331, 1210, 1718, 2112],
usage: [18, 19, 17, 21, 22, 58, 81, 96],
};
/* 2 — anomaly: bars with threshold + big spent value */
function AnomalyCard({ data: anomaly = ANOMALY_DATA }: { data?: AnomalyData }) {
const dark = useDarkMode();
const [metric, setMetric] = useState<"spend" | "usage">("spend");
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const spend = useMemo(
() => makePoints(anomaly.spend, 7),
[anomaly],
);
const usage = useMemo(
() => makePoints(anomaly.usage, 7),
[anomaly],
);
const data = metric === "spend" ? spend : usage;
const value = data.at(-1)?.value ?? (metric === "spend" ? 2112 : 96);
const threshold = metric === "spend" ? "$2,112" : "82 kWh";
const moneyLabel = formatMoney(spend.at(-1)?.value ?? 2112);
return (
<div className="min-h-[278px] rounded-card bg-card p-3 shadow-hairline">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[12px] font-medium text-ink">
<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>
High freezer spend
</span>
<span className="rounded-full bg-field px-2 py-0.5 text-[10.5px] font-medium text-ink-2">
Snapshot
</span>
</div>
<div className="mt-2 overflow-hidden rounded-control bg-inset shadow-hairline">
<div className="flex items-center justify-between border-b border-line px-2.5 py-1.5">
<span className="text-[11px] text-ink-3 tabular-nums">
{hoverIndex !== null
? metric === "spend"
? formatMoney(data[hoverIndex].value)
: `${Math.round(data[hoverIndex].value)} kWh`
: `${threshold} threshold`}
</span>
<span className="flex rounded-full bg-field p-0.5">
{(["spend", "usage"] as const).map((item) => (
<button
key={item}
type="button"
aria-pressed={metric === item}
onClick={() => setMetric(item)}
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] ${
metric === item ? "bg-card text-ink shadow-btn" : "text-ink-3 hover:text-ink-2"
}`}
>
{item === "spend" ? "Spend" : "Usage"}
</button>
))}
</span>
</div>
<div
className="insight-chart-stage relative h-[166px]"
onPointerDown={(event) => setHoverIndex(chartIndexFromPointer(event, data.length))}
onPointerMove={(event) => setHoverIndex(chartIndexFromPointer(event, data.length))}
onPointerLeave={() => setHoverIndex(null)}
onPointerCancel={() => setHoverIndex(null)}
onPointerUp={() => setHoverIndex(null)}
>
<Liveline
data={data}
value={value}
theme={dark ? "dark" : "light"}
color="#ee5c61"
grid
scrub={false}
fill={false}
pulse={false}
momentum={false}
paused
window={49}
lineWidth={2.25}
cursor="crosshair"
padding={{ top: 34, right: 0, bottom: 22, left: 0 }}
formatValue={(v) => (metric === "spend" ? formatMoney(v) : `${Math.round(v)} kWh`)}
/>
{hoverIndex !== null && <>
<span className="insight-chart-cursor" style={{ left: `${(hoverIndex / (data.length - 1)) * 100}%` }} />
<span className="insight-chart-tooltip-anchor" style={{ left: `${Math.min(Math.max((hoverIndex / (data.length - 1)) * 100, 28), 72)}%` }}>
<ChartTooltip rows={[{ label: metric === "spend" ? "Spend" : "Usage", value: metric === "spend" ? formatMoney(data[hoverIndex].value) : `${Math.round(data[hoverIndex].value)} kWh`, color: "var(--red)" }]} />
</span>
</>}
</div>
</div>
<div className="mt-1.5 flex items-baseline gap-2">
<span className="text-[17px] font-semibold tracking-[-0.01em] text-ink tabular-nums">
{moneyLabel} spent
</span>
<Mono tone="red">+$1,834.66</Mono>
<span className="text-[11px] text-ink-3">vs 3 months</span>
</div>
</div>
);
}
/* content shape for one allocation segment */
export type AllocationSegment = {
name: string;
label: string;
pct: number;
amount: string;
cls: string;
tone: string;
};
const ALLOCATION_SEGMENTS: AllocationSegment[] = [
{ name: "VAN", label: "Vanilla", pct: 72.5, amount: "$51,785", cls: "bg-orange", tone: "text-orange" },
{ name: "CHOC", label: "Chocolate", pct: 22.8, amount: "$16,278", cls: "bg-line-strong", tone: "text-ink-2" },
{ name: "MINT", label: "Mint", pct: 4.7, amount: "$3,357", cls: "bg-line", tone: "text-ink-3" },
];
/* 3 — allocation: hero number + segmented bar + legend */
function AllocationCard({ segments = ALLOCATION_SEGMENTS }: { segments?: AllocationSegment[] }) {
const [selected, setSelected] = useState(segments[0].name);
const active = segments.find((segment) => segment.name === selected) ?? segments[0];
return (
<div className="min-h-[278px] rounded-card bg-card p-3 shadow-hairline">
<span className="flex items-center gap-1.5 text-[12px] font-medium text-ink">
<span className="flex size-3.5 items-center justify-center rounded-full bg-orange text-[8px] font-bold text-white">
V
</span>
Vanilla allocation
</span>
<span className="mt-1 block text-[20px] font-semibold tracking-[-0.01em] text-ink tabular-nums">
{active.amount}
</span>
<div
className="mt-3 flex h-9 gap-0.5 overflow-hidden rounded-full bg-field p-0.5"
role="group"
aria-label="Allocation segments"
>
{segments.map((s) => (
<button
key={s.name}
type="button"
aria-pressed={selected === s.name}
aria-label={`${s.label}: ${s.pct}%`}
onClick={() => setSelected(s.name)}
className={`relative h-full overflow-hidden rounded-full ${s.cls} transition-[opacity,transform,box-shadow] duration-300 active:scale-[0.98]`}
style={{
width: `${s.pct}%`,
opacity: selected === s.name ? 1 : 0.58,
boxShadow: selected === s.name ? "inset 0 0 0 1px rgba(255,255,255,0.22)" : undefined,
transitionTimingFunction: EASE,
}}
>
<span
className="absolute inset-y-1 left-1 rounded-full bg-white/20 transition-[width,opacity] duration-500"
style={{
width: selected === s.name ? "calc(100% - 8px)" : "0%",
opacity: selected === s.name ? 1 : 0,
transitionTimingFunction: EASE,
}}
/>
</button>
))}
</div>
<div className="mt-2 flex items-center gap-1.5">
{segments.map((s) => (
<button
key={s.name}
type="button"
aria-pressed={selected === s.name}
onClick={() => setSelected(s.name)}
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] ${
selected === s.name ? "bg-field text-ink" : "text-ink-2 hover:bg-hover hover:text-ink"
}`}
>
<span className={`size-1.5 rounded-full ${s.cls}`} />
{s.name} <span className="tabular-nums">{s.pct}%</span>
</button>
))}
</div>
<div className="mt-3 min-h-16 rounded-control bg-inset px-2.5 py-2 shadow-hairline">
<span className={`block text-[11.5px] font-medium ${active.tone}`}>{active.label}</span>
<span className="mt-1 block text-[11px] leading-relaxed text-ink-3">
Contribution snapshot across current inventory value. Segment selection changes the inspected group without moving the card.
</span>
</div>
</div>
);
}
/* content shape for one insight page in the carousel */
export type InsightPage = {
key: string;
prose: React.ReactNode;
Card: React.ComponentType;
pill: string;
};
const PAGES: InsightPage[] = [
{
key: "compare",
prose: (
<>
The worst performer in your <Entity name="Studio" tone="bg-orange" /> is
Rocky Road — down <Mono tone="red">-6%</Mono> or <Mono tone="red">-$2,453.44</Mono>.
</>
),
Card: CompareCard,
pill: "Should I rebalance projects?",
},
{
key: "anomaly",
prose: (
<>
Unusually high freezer bill on <span className="font-medium text-ink">Dec 13</span> —{" "}
<Mono tone="red">+$1,834.66</Mono> above your average.
</>
),
Card: AnomalyCard,
pill: "Get tips on cutting freezer costs",
},
{
key: "allocation",
prose: (
<>
You’re heavily invested in <Entity name="Vanilla" tone="bg-orange" /> — it’s{" "}
<span className="font-medium text-ink">72.5%</span> of your case.
</>
),
Card: AllocationCard,
pill: "If we look at seasonals, what changes?",
},
];
export type InsightCardsLabels = {
/** carousel heading shown before the page count */
title: string;
};
const DEFAULT_INSIGHT_LABELS: InsightCardsLabels = {
title: "Insights",
};
export default function InsightCards({
pages = PAGES,
labels,
}: {
variant?: string;
pages?: InsightPage[];
labels?: Partial<InsightCardsLabels>;
} = {}) {
const l = { ...DEFAULT_INSIGHT_LABELS, ...labels };
const [page, setPage] = useState(0);
const move = (direction: -1 | 1) => {
setPage((current) => (current + direction + pages.length) % pages.length);
};
const { prose, Card, pill } = pages[page];
return (
<div className="min-h-[408px] w-full max-w-86">
{/* pager header */}
<div className="flex items-center justify-between">
<span className="flex items-baseline gap-1.5">
<span className="text-[13px] font-semibold text-ink">{l.title}</span>
<span className="text-[13px] text-ink-3 tabular-nums">{pages.length}</span>
</span>
<span className="flex items-center gap-0.5">
{(["M15 18l-6-6 6-6", "M9 6l6 6-6 6"] as const).map((d, i) => (
<button
key={i}
aria-label={i === 0 ? "Previous insight" : "Next insight"}
onClick={() => move(i === 0 ? -1 : 1)}
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
transition-[background-color,color,transform] duration-100 hover:bg-hover
hover:text-ink active:scale-[0.96]"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
</button>
))}
</span>
</div>
{/* page content — blurred crossfade */}
<div
className="transition-[opacity,filter] duration-250"
style={{ opacity: 1, filter: "blur(0)" }}
>
<p className="mt-1.5 text-[12.5px] leading-relaxed text-ink-2">{prose}</p>
<div className="mt-2">
<Card />
</div>
<button
className="mt-2 rounded-full bg-card px-3 py-1.5 text-left text-[12px] text-ink
shadow-btn transition-colors duration-100 hover:bg-hover"
>
{pill}
</button>
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ward/insight-cardsnpx shadcn@latest add @ward/insight-cardsyarn dlx shadcn@latest add @ward/insight-cardsbunx --bun shadcn@latest add @ward/insight-cardsAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/insight-cards.json
Install the dependencies.
npm install liveline@0.0.7Add the Ward items it builds on.
npx shadcn@latest add @ward/foundationCopy each file from the Code tab into:
components/ward/InsightCards.tsx
Update the import paths to match your project.
Usage
import InsightCards from "@/components/ward/InsightCards";<InsightCards labels={{ title: "Weekly insights" }} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | string | "Default" | Accepted for registry parity; the component has a single variant. |
pages | InsightPage[] | PAGES | Insight pages to step through, each with a summary sentence, a card component and a follow-up prompt. |
labels | Partial<InsightCardsLabels> | - | Overrides for the heading shown before the page count, which is "Insights" when not set. |
Ward · Version 1.1.0 · Registry manifest