feat(docs): add analytics and pattern showcase stories

This commit is contained in:
2026-03-25 19:59:42 +08:00
parent a5d75f42e9
commit f049736c8a
21 changed files with 5765 additions and 1204 deletions
File diff suppressed because it is too large Load Diff
+346
View File
@@ -0,0 +1,346 @@
import {
Chart,
ChartChange,
ChartDescription,
ChartHeader,
ChartHeaderAside,
ChartHeaderLeading,
ChartMetrics,
ChartTitle,
ChartValue,
type ChartSeries,
type ChartTooltipContext
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
type SalesImpactDatum = {
gross: number;
month: string;
revenue: number;
};
const salesImpactData: SalesImpactDatum[] = [
{ gross: 52450, month: "Jan", revenue: 71280 },
{ gross: 112400, month: "Feb", revenue: 92320 },
{ gross: 98420, month: "Mar", revenue: 138260 },
{ gross: 152800, month: "Apr", revenue: 165420 },
{ gross: 82450, month: "May", revenue: 125320 },
{ gross: 136200, month: "Jun", revenue: 186540 },
{ gross: 118320, month: "Jul", revenue: 172480 },
{ gross: 121560, month: "Aug", revenue: 149860 },
{ gross: 109440, month: "Sep", revenue: 143220 },
{ gross: 141880, month: "Oct", revenue: 176340 },
{ gross: 132760, month: "Nov", revenue: 167920 }
];
const compactCurrencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 1,
notation: "compact",
style: "currency"
});
const currencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency"
});
function formatCurrency(value: number) {
return currencyFormatter.format(value);
}
function formatCompactCurrency(value: number) {
return compactCurrencyFormatter.format(value);
}
const salesImpactSeries: ChartSeries<SalesImpactDatum>[] = [
{
getValue: (datum) => datum.revenue,
id: "revenue",
label: "Revenue",
style: "line-area",
tone: "primary",
valueFormatter: formatCurrency
},
{
getValue: (datum) => datum.gross,
id: "gross",
label: "Gross",
strokeDasharray: "6 8",
style: "line",
tone: "neutral",
valueFormatter: formatCurrency
}
];
function SalesImpactTooltip({
activeLabel,
series
}: ChartTooltipContext<SalesImpactDatum>) {
return (
<div className="grid gap-3">
<div className="text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{activeLabel}
</div>
<div className="grid gap-2">
{series.map((entry) => (
<div
className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 text-sm"
key={entry.id}
>
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-[var(--color-muted-foreground)]">{entry.label}</span>
<span className="font-medium text-[var(--color-foreground)]">
{entry.formattedValue}
</span>
</div>
))}
</div>
</div>
);
}
function SalesImpactPlayground() {
return (
<div className="w-full max-w-[960px]">
<Chart
data={salesImpactData}
defaultActiveIndex={4}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
header={
<ChartHeader>
<ChartHeaderLeading>
<ChartTitle>Sales Impact</ChartTitle>
<ChartDescription>
Use Chart when a workflow needs a compact trend surface with one active comparison
moment instead of a generic analytics embed.
</ChartDescription>
</ChartHeaderLeading>
<ChartHeaderAside>
<ChartMetrics>
<ChartValue>{formatCurrency(101820)}</ChartValue>
<ChartChange>
<>
<span className="inline-flex items-center rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-success)_14%,white_86%)] px-2.5 py-1 text-[color-mix(in_oklch,var(--color-success)_72%,var(--color-foreground))]">
+84 bps
</span>
<span>from the last month</span>
</>
</ChartChange>
</ChartMetrics>
</ChartHeaderAside>
</ChartHeader>
}
height={336}
renderTooltip={SalesImpactTooltip}
series={salesImpactSeries}
title="Sales Impact"
yAxisValueFormatter={formatCompactCurrency}
/>
</div>
);
}
function ChartStates() {
return (
<div className="grid w-full max-w-[1040px] gap-4 lg:grid-cols-2">
<Chart
data={salesImpactData}
defaultActiveIndex={7}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
legendValueMode="active"
series={[
salesImpactSeries[0],
{
...salesImpactSeries[1],
tone: "success"
}
]}
showLegend
title="Active comparison"
value={formatCurrency(149860)}
valueChange="Legend values follow the active point when the chart is interactive."
yAxisValueFormatter={formatCompactCurrency}
/>
<Chart
data={[] as SalesImpactDatum[]}
empty="Keep the surrounding card chrome and swap the plot for an explicit empty message instead of collapsing the whole panel."
getXAxisLabel={() => ""}
series={salesImpactSeries}
title="Empty state"
/>
</div>
);
}
function ChartAnatomy() {
return (
<div className="w-full max-w-[960px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Chart anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The stable review hooks are the header, plot, tooltip, and legend slots. The chart is
intentionally narrow: one cartesian trend surface with optional active comparison and
legend values.
</p>
</div>
<Chart
className="shadow-none"
data={salesImpactData}
defaultActiveIndex={5}
description="Use the tooltip slot for the floating active summary and the legend slot for persistent series labels or values."
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
renderTooltip={SalesImpactTooltip}
series={salesImpactSeries}
showLegend
title="Contracted slots"
value={formatCurrency(186540)}
yAxisValueFormatter={formatCompactCurrency}
/>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code> owns the
header frame, while <code className="text-[var(--color-foreground)]">data-slot="leading"</code>{" "}
and <code className="text-[var(--color-foreground)]">data-slot="aside"</code> let
product code compose a custom title stack or review chip without losing the chart's
slot contract.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="plot"</code> is the
bordered trend surface. <code className="text-[var(--color-foreground)]">data-slot="tooltip"</code>{" "}
stays optional, and <code className="text-[var(--color-foreground)]">data-slot="legend"</code>{" "}
exposes the persistent series labels below the plot.
</p>
</div>
</div>
</div>
);
}
function ChartAccessibility() {
return (
<div className="grid w-full max-w-[960px] gap-4">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Keyboard review
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Each x-position is backed by a focusable hit target, so the active point summary works on
hover and on keyboard focus. Tab across the chart to move the tooltip and legend without
relying on pointer-only interaction.
</p>
</div>
<Chart
data={salesImpactData}
defaultActiveIndex={3}
getActiveLabel={(datum) => `${datum.month} 2025`}
getPointAriaLabel={({ activeLabel, series }) =>
`${activeLabel}. ${series.map((entry) => `${entry.label} ${entry.formattedValue}`).join(". ")}.`
}
getXAxisLabel={(datum) => datum.month}
renderTooltip={SalesImpactTooltip}
series={[
salesImpactSeries[0],
{
...salesImpactSeries[1],
tone: "success"
}
]}
showLegend
title="Focusable active points"
value={formatCurrency(165420)}
valueChange="Hover or tab through the plot to move the active summary."
yAxisValueFormatter={formatCompactCurrency}
/>
</div>
);
}
function ChartMotion() {
return (
<div className="grid w-full max-w-[960px] gap-4">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Motion review
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Move across the plot and watch for three coordinated cues: the active guide should fade
to the next position, the point markers should bloom instead of snapping, and the tooltip
card should rise in with a short 120-180ms transition. The motion should stay calm enough
that the chart still reads like a dashboard surface, not a demo toy.
</p>
</div>
<SalesImpactPlayground />
</div>
);
}
const meta = {
title: "Components/Chart",
component: SalesImpactPlayground,
parameters: {
docs: {
description: {
component:
"Chart is Cadence UI's dashboard trend surface for compact multi-series analytics panels. Use it when a product workflow needs a source-owned line chart with an active comparison state, soft area treatment, and stable slots for header, tooltip, and legend content."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof SalesImpactPlayground>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <SalesImpactPlayground />
};
export const States: Story = {
render: () => <ChartStates />
};
export const Anatomy: Story = {
render: () => <ChartAnatomy />
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Chart motion is limited to the active comparison layer. The plot itself stays stable while the guide, marker, and tooltip transition together."
}
}
},
render: () => <ChartMotion />
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"The rendered SVG is decorative; the reviewable interaction contract comes from focusable hit targets layered over each x-position. That keeps the active comparison usable with a keyboard while avoiding pointer-only hover state."
}
}
},
render: () => <ChartAccessibility />
};
+301
View File
@@ -0,0 +1,301 @@
import { Button, Gauge } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
function GaugePanel({
description,
label,
shape = "dial",
tone = "default",
value,
variant = "default"
}: {
description: string;
label: string;
shape?: "dial" | "semi";
tone?: "default" | "subtle" | "accent";
value: number | null;
variant?: "default" | "success" | "warning" | "destructive";
}) {
return (
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<Gauge
description={description}
label={label}
shape={shape}
tone={tone}
value={value}
variant={variant}
/>
</article>
);
}
const meta = {
title: "Components/Gauge",
component: Gauge,
args: {
description: "Lead routing stays stable enough for this week's forecast handoff.",
label: "Forecast confidence",
shape: "dial",
size: "md",
tickCount: 0,
tone: "default",
value: 72,
variant: "default"
},
argTypes: {
className: {
control: false
},
description: {
control: "text"
},
label: {
control: "text"
},
shape: {
control: "radio",
options: ["dial", "semi"]
},
size: {
control: "radio",
options: ["sm", "md", "lg"]
},
tickCount: {
control: {
type: "range",
min: 0,
max: 24,
step: 1
}
},
tone: {
control: "radio",
options: ["default", "subtle", "accent"]
},
value: {
control: {
type: "range",
min: 0,
max: 100,
step: 1
}
},
valueFormatter: {
control: false
},
variant: {
control: "radio",
options: ["default", "success", "warning", "destructive"]
}
},
parameters: {
docs: {
description: {
component:
"Gauge is the system's radial meter for current measurements inside a known range. Use it for capacity, forecast confidence, health scores, and KPI thresholds where the UI should communicate the current level rather than the progress of an ongoing task. The component now stages its readout with a restrained sweep and count-up on entry, while reduced or static motion snaps directly to the final state."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof Gauge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div className="w-[380px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Capacity Watch
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Use Gauge for the current level of a metric, not for task completion.
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
This component is a meter. It reads like a dashboard instrument and belongs beside KPI
cards, score panels, and health indicators rather than uploads or async job states.
</p>
</div>
<div className="mt-6 flex justify-center">
<Gauge {...args} />
</div>
</div>
)
};
export const Shapes: Story = {
render: () => (
<div className="grid w-[860px] gap-4 md:grid-cols-3">
<GaugePanel
description="Sales planning is stable enough for next week's spend allocation."
label="Forecast confidence"
value={72}
/>
<GaugePanel
description="Coverage is drifting closer to the review threshold than finance would like."
label="Budget saturation"
shape="semi"
tone="subtle"
value={61}
variant="warning"
/>
<GaugePanel
description="The latest model health score is comfortably within the healthy range."
label="Model health"
tone="accent"
value={84}
variant="success"
/>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[780px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="space-y-5">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Gauge anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Gauge keeps the public structure small: one canvas, one SVG ring system, one value
plate, and optional framing copy below the visual.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<div className="flex justify-center">
<Gauge
description="Qualified coverage is still within the board's target band."
label="Pipeline health"
shape="semi"
tone="subtle"
value={68}
variant="success"
/>
</div>
</div>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="canvas"</code> owns the
responsive gauge frame, while <code className="text-[var(--color-foreground)]">data-slot="svg"</code>{" "}
holds the radial drawing primitives.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="track"</code> is the full
meter range, and <code className="text-[var(--color-foreground)]">data-slot="indicator"</code>{" "}
is the active measured arc. If a denser dashboard wants calibration marks, opt into
them with <code className="text-[var(--color-foreground)]">tickCount</code>, which
exposes <code className="text-[var(--color-foreground)]">data-slot="tick"</code>.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="value"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="label"</code>, and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="description"</code>
keep center readout and supporting copy stable for theming, docs, and tests.
</p>
</div>
</div>
</div>
)
};
function GaugeMotionShowcase() {
const [replayKey, setReplayKey] = useState(0);
return (
<div className="grid w-[760px] gap-5 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="max-w-[34rem] space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Gauge should arrive like an instrument waking up, not a number teleporting in.
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The indicator sweeps in once, the center value counts up with it, and reduced/static
motion still resolves instantly.
</p>
</div>
<Button onClick={() => setReplayKey((value) => value + 1)} size="sm" variant="secondary">
Replay startup
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-[var(--radius-lg)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_82%,white_18%)] p-5 shadow-[var(--shadow-xs)]">
<Gauge
key={`dial-${replayKey}`}
description="Lead scoring remains steady enough for this week's planning handoff."
label="Forecast confidence"
tone="accent"
value={72}
/>
</div>
<div className="rounded-[var(--radius-lg)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_82%,white_18%)] p-5 shadow-[var(--shadow-xs)]">
<Gauge
key={`semi-${replayKey}`}
description="Budget headroom is drifting closer to the review threshold."
label="Budget saturation"
shape="semi"
tickCount={7}
tone="subtle"
value={61}
variant="warning"
/>
</div>
</div>
</div>
);
}
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Gauge uses a one-time staged startup: the ring sweeps to the target, ticks wake up with the same progress, and the center value counts into place. The effect is intentionally calm and should still feel correct when motion is reduced."
}
}
},
render: () => <GaugeMotionShowcase />
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Gauge uses the ARIA meter pattern, not progressbar. Use it for a current reading inside a bounded range, provide a concrete label, and keep the center number or supporting copy readable enough that color is never the only signal."
}
}
},
render: () => (
<div className="grid w-[840px] gap-4 lg:grid-cols-[minmax(0,0.94fr)_minmax(0,1.06fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Review guidance
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Choose Gauge for current measurement. Choose Progress for ongoing completion.</p>
<p>Always give the meter a concrete label such as <code>Forecast confidence</code>.</p>
<p>Keep the numeric readout or description meaningful enough that color is supplemental.</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<Gauge
description="Lead scoring remains predictable enough for finance planning."
label="Forecast confidence"
value={72}
/>
</div>
</div>
)
};
+238
View File
@@ -0,0 +1,238 @@
import { Col, Row } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function GridTile({
body,
eyebrow,
title
}: {
body: string;
eyebrow: string;
title: string;
}) {
return (
<div className="flex min-h-28 flex-col justify-between rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_80%,white_20%),color-mix(in_oklch,var(--color-surface-container)_84%,white_16%))] p-4 shadow-[var(--shadow-xs)]">
<div className="space-y-1.5">
<p className="text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{eyebrow}
</p>
<p className="text-base font-semibold text-[var(--color-foreground)]">{title}</p>
</div>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">{body}</p>
</div>
);
}
const meta = {
title: "Components/Grid",
component: Row,
args: {
align: "stretch",
gap: "md"
},
argTypes: {
align: {
control: "select",
options: ["start", "center", "end", "stretch"]
},
className: {
control: false
},
gap: {
control: "select",
options: ["none", "xs", "sm", "md", "lg", "xl"]
},
xGap: {
control: "select",
options: ["none", "xs", "sm", "md", "lg", "xl"]
},
yGap: {
control: "select",
options: ["none", "xs", "sm", "md", "lg", "xl"]
}
},
parameters: {
docs: {
description: {
component:
"Use `Row` and `Col` when a screen needs a reusable 12-column layout contract instead of one-off `grid-cols-*` strings or manual width arithmetic. The primitives stay source-owned, tokenized for spacing, and keep responsive placement on the item itself through `span`, `offset`, and breakpoint props such as `md` or `xl`."
}
},
layout: "padded"
},
tags: ["autodocs"]
} satisfies Meta<typeof Row>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div className="w-full max-w-6xl">
<Row {...args}>
<Col span={12} md={7}>
<GridTile
body="Mission status, copy, and actions can stay in the wider column without any hardcoded pixel math."
eyebrow="Span 12 / md 7"
title="Launch Overview"
/>
</Col>
<Col span={12} md={5}>
<GridTile
body="The companion panel narrows responsively by changing only its column span."
eyebrow="Span 12 / md 5"
title="Launch Guardrails"
/>
</Col>
<Col span={12} md={4}>
<GridTile
body="Smaller summary surfaces can align beneath the main content without custom grid templates."
eyebrow="Span 12 / md 4"
title="Traffic"
/>
</Col>
<Col span={12} md={4}>
<GridTile
body="Spacing stays on the row via semantic gap tokens rather than ad hoc margins."
eyebrow="Span 12 / md 4"
title="Conversion"
/>
</Col>
<Col span={12} md={4}>
<GridTile
body="Each card keeps its own breakpoint behavior through the matching `Col` props."
eyebrow="Span 12 / md 4"
title="Retention"
/>
</Col>
</Row>
</div>
)
};
export const Responsive: Story = {
render: () => (
<div className="w-full max-w-6xl space-y-4">
<p className="max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
<code>Col</code> accepts either a direct span like <code>md=&#123;6&#125;</code> or
an object like <code>xl=&#123;&#123; span: 4, offset: 2 &#125;&#125;</code>. Base
layout stays mobile-first, so panels stack by default and only split when the
breakpoint prop applies.
</p>
<Row gap="lg">
<Col span={12} md={6} xl={4}>
<GridTile
body="Stacks full-width on mobile, becomes half width on medium screens, and three-up on extra wide layouts."
eyebrow="12 / md 6 / xl 4"
title="Overview"
/>
</Col>
<Col span={12} md={6} xl={4}>
<GridTile
body="Breakpoints live on the column rather than hidden in a one-off parent template string."
eyebrow="12 / md 6 / xl 4"
title="Active Cohorts"
/>
</Col>
<Col span={12} xl={{ span: 4, offset: 4 }}>
<GridTile
body="Offset creates deliberate asymmetry while keeping the DOM order unchanged."
eyebrow="12 / xl 4 + offset 4"
title="Approval Queue"
/>
</Col>
</Row>
</div>
),
parameters: {
docs: {
description: {
story:
"Use responsive `span` and `offset` on `Col` for the common split-layout cases. That keeps layout intent close to the item being placed instead of scattering width calculations through the parent container."
}
}
}
};
export const Anatomy: Story = {
render: () => (
<div className="w-full max-w-5xl space-y-4">
<Row gap="md">
<Col span={12}>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_74%,white_26%)] p-5">
<p className="text-sm font-medium text-[var(--color-foreground)]">
`Row` renders `data-slot="root"` and owns the shared gap and alignment contract.
</p>
</div>
</Col>
<Col span={12} md={6}>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-primary)]/35 bg-[color-mix(in_oklch,var(--color-primary-container)_42%,white_58%)] p-5">
<p className="text-sm font-medium text-[var(--color-foreground)]">
`Col` renders `data-slot="item"` and owns placement through `span`, `offset`,
and breakpoint props.
</p>
</div>
</Col>
<Col span={12} md={6}>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-tertiary)]/35 bg-[color-mix(in_oklch,var(--color-tertiary-container)_56%,white_44%)] p-5">
<p className="text-sm font-medium text-[var(--color-foreground)]">
Consumers can still layer normal surface components inside each grid item.
</p>
</div>
</Col>
</Row>
</div>
),
parameters: {
docs: {
description: {
story:
"The contract is intentionally small: one row container with stable layout hooks, and one item primitive that carries its own placement decisions."
}
}
}
};
export const Accessibility: Story = {
render: () => (
<div className="w-full max-w-5xl space-y-4">
<p className="max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep the source order equal to the reading order. `offset` should create spatial rhythm,
not fake a different semantic sequence. If the content reads as Step 1, Step 2, Step 3,
keep that order in the DOM and only let the grid change where the panels sit.
</p>
<Row gap="md">
<Col span={12} md={7}>
<GridTile
body="Users encounter this content first both visually and in the DOM."
eyebrow="Step 1"
title="Scan the shared dashboard summary"
/>
</Col>
<Col span={12} md={5}>
<GridTile
body="This still comes second semantically even when the widths differ."
eyebrow="Step 2"
title="Review the flagged metrics"
/>
</Col>
<Col span={12} md={{ span: 5, offset: 7 }}>
<GridTile
body="The third panel uses offset for placement, but not for reordering."
eyebrow="Step 3"
title="Approve the release gate"
/>
</Col>
</Row>
</div>
),
parameters: {
docs: {
description: {
story:
"Cadence UI intentionally omits visual reordering props here. Responsive layout should not create a second, less accessible reading order."
}
}
}
};
@@ -0,0 +1,176 @@
import {
Field,
FieldControl,
FieldDescription,
FieldError,
Input,
InputGroup,
InputGroupPrefix,
InputGroupSuffix,
Label
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function SearchIcon() {
return (
<svg aria-hidden="true" className="size-4" viewBox="0 0 16 16">
<path
d="M7.25 12.5a5.25 5.25 0 1 1 0-10.5a5.25 5.25 0 0 1 0 10.5Zm3.75-1.5 3 3"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
}
const meta = {
title: "Components/InputGroup",
component: InputGroup,
args: {
size: "md"
},
argTypes: {
className: {
control: false
},
disabled: {
control: "boolean"
},
invalid: {
control: "boolean"
},
readOnly: {
control: "boolean"
},
required: {
control: "boolean"
},
size: {
control: "select",
options: ["sm", "md", "lg"]
}
},
parameters: {
docs: {
description: {
component:
"Use InputGroup when a text field needs leading or trailing inline content such as a search icon, shortcut hint, status chip, or clear action. Keep plain Input for unadorned fields and let Field continue owning labels, descriptions, and errors."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof InputGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<InputGroup {...args} className="w-[360px]">
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input placeholder="Search launches, owners, or notes" />
<InputGroupSuffix>
<kbd className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[0.7rem] font-medium uppercase tracking-[var(--tracking-caps)]">
/
</kbd>
</InputGroupSuffix>
</InputGroup>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[760px] gap-4 sm:grid-cols-2">
<InputGroup>
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input defaultValue="Release workspace" />
<InputGroupSuffix>
<span className="text-xs uppercase tracking-[var(--tracking-caps)]">Live</span>
</InputGroupSuffix>
</InputGroup>
<InputGroup disabled>
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input defaultValue="Disabled search" />
</InputGroup>
<InputGroup invalid>
<InputGroupPrefix aria-hidden="true">
<span>@</span>
</InputGroupPrefix>
<Input defaultValue="ops" />
<InputGroupSuffix>
<span className="text-xs font-medium text-[var(--color-destructive)]">needs domain</span>
</InputGroupSuffix>
</InputGroup>
<InputGroup readOnly>
<InputGroupPrefix aria-hidden="true">
<span>#</span>
</InputGroupPrefix>
<Input defaultValue="release-quiet-cycle" />
<InputGroupSuffix>
<span className="text-xs uppercase tracking-[var(--tracking-caps)]">locked</span>
</InputGroupSuffix>
</InputGroup>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<InputGroup className="w-[420px]">
<InputGroupPrefix>prefix</InputGroupPrefix>
<Input aria-label="InputGroup anatomy" placeholder='data-slot="input"' />
<InputGroupSuffix>suffix</InputGroupSuffix>
</InputGroup>
),
parameters: {
docs: {
description: {
story:
"InputGroup exposes one control container plus explicit prefix, input, and suffix slots so consumers can style or test the affix anatomy without relying on absolute positioning."
}
}
}
};
export const Accessibility: Story = {
render: () => (
<Field invalid className="w-[420px]">
<Label requiredIndicator>Routing search</Label>
<FieldControl>
<InputGroup required>
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input defaultValue="ops" />
<InputGroupSuffix>
<span className="text-xs uppercase tracking-[var(--tracking-caps)]">scope</span>
</InputGroupSuffix>
</InputGroup>
<FieldDescription>Search lanes, owners, or note fragments before assigning work.</FieldDescription>
<FieldError>Add a narrower query before applying bulk actions.</FieldError>
</FieldControl>
</Field>
),
parameters: {
docs: {
description: {
story:
"Decorative affixes should usually be marked `aria-hidden`, while the actual field labelling and error messaging still come from Field plus the inner Input."
}
}
}
};
@@ -0,0 +1,236 @@
import {
Button,
MetricCard,
MetricCardActions,
MetricCardAside,
MetricCardDelta,
MetricCardDescription,
MetricCardEyebrow,
MetricCardFooter,
MetricCardHeader,
MetricCardLabel,
MetricCardLeading,
MetricCardMedia,
MetricCardMetric,
MetricCardValue,
Progress,
Sparkbar,
StatCard,
StatCardDelta,
StatCardDescription,
StatCardHeader,
StatCardLabel,
StatCardMetric,
StatCardValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const reductionBars = [
52, 55, 58, 60, 64, 66, 68, 72, 74, 72, 70, 71, 68, 66, 64, 62, 60, 58, 56, 54
] as const;
function ReductionBars() {
return <Sparkbar height={74} highlightRange={[0, 12]} values={reductionBars} />;
}
function RevenueMetricCard({
layout = "default",
tone = "default"
}: {
layout?: "default" | "split";
tone?: "default" | "subtle" | "accent" | "inverse" | "hero";
}) {
const mediaTone = tone === "hero" ? "hero" : tone === "inverse" ? "inverse" : tone;
const footerTextClassName =
tone === "hero" || tone === "inverse" ? "text-white/68" : "text-[var(--color-muted-foreground)]";
return (
<MetricCard className="w-full max-w-[760px]" interactive layout={layout} tone={tone}>
<MetricCardHeader>
<MetricCardLeading>
<MetricCardEyebrow>Margin watch</MetricCardEyebrow>
<MetricCardLabel>Operational cost reduction</MetricCardLabel>
</MetricCardLeading>
<MetricCardAside>
<MetricCardDelta tone="primary">42%</MetricCardDelta>
</MetricCardAside>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>$38,250</MetricCardValue>
<MetricCardDelta tone="success">+78</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription>
Assisted routing and tighter triage are keeping servicing overhead inside the target
window.
</MetricCardDescription>
<MetricCardMedia tone={mediaTone}>
<ReductionBars />
</MetricCardMedia>
<MetricCardFooter>
<div className={`flex items-center justify-between gap-4 text-sm ${footerTextClassName}`}>
<span>Quarter target</span>
<span>$101,820</span>
</div>
<Progress tone="subtle" value={42} variant="success" />
<p className={`text-xs leading-5 ${footerTextClassName}`}>
Routing efficiency has stayed inside the guided operating band for six consecutive
cycles.
</p>
</MetricCardFooter>
<MetricCardActions>
<Button variant="secondary">Review forecast</Button>
<Button variant="ghost">Open board</Button>
</MetricCardActions>
</MetricCard>
);
}
const meta = {
title: "Components/MetricCard",
component: RevenueMetricCard,
parameters: {
docs: {
description: {
component:
"MetricCard extends the same KPI language as StatCard but adds structured regions for media, footer detail, and next-step actions. Use it for analytics panels that still need a clear headline metric instead of turning into unstructured custom cards."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof RevenueMetricCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Hover the card directly in the canvas. MetricCard should feel richer than StatCard, but the motion still needs hierarchy: the root slab lifts first, the media surface follows with a softer delayed response, and the actions tray trails last."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
The richer KPI panel should stage its hover response.
</h3>
<p className="max-w-[44rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
MetricCard carries more structure, so the hover response can have a little more depth.
The media region and actions tray should follow the card lift, not compete with it.
</p>
</div>
<div className="grid gap-4">
<RevenueMetricCard layout="split" />
<RevenueMetricCard tone="hero" />
</div>
</div>
)
};
export const Layouts: Story = {
render: () => (
<div className="grid w-[1040px] gap-4">
<RevenueMetricCard layout="default" />
<RevenueMetricCard layout="split" tone="accent" />
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[820px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Metric card anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
MetricCard reuses the same KPI slots as StatCard and adds dedicated regions for media,
footer detail, and action groups. That keeps dashboard panels consistent even when they
need richer structure.
</p>
</div>
<RevenueMetricCard layout="split" />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="eyebrow"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="label"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="value"</code>, and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="delta"</code> stay
aligned with StatCard so analytics panels and compact KPI cards share one core contract.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="leading"</code> and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="aside"</code> keep
header layouts structured, so icons, badges, or next-state chips stop leaking into
story-local flex wrappers.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="media"</code> owns the
chart or visual evidence, while <code className="text-[var(--color-foreground)]">data-slot="footer"</code>{" "}
and <code className="text-[var(--color-foreground)]">data-slot="actions"</code> cover
the follow-up details that should not be improvised ad hoc.
</p>
</div>
</div>
</div>
)
};
export const Composition: Story = {
parameters: {
docs: {
description: {
story:
"Reach for StatCard when the panel is just one KPI and a sentence. Move to MetricCard when the same KPI needs supporting evidence, target progress, or explicit next actions."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]">
<StatCard className="w-full">
<StatCardHeader>
<StatCardLabel>Forecast confidence</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="primary">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
The signal is healthy enough for planning, but still worth watching week to week.
</StatCardDescription>
</StatCard>
<RevenueMetricCard layout="split" />
</div>
)
};
export const EmphasisTones: Story = {
parameters: {
docs: {
description: {
story:
"Use `inverse` for dark tonal KPI panels and `hero` when the card needs to carry the visual emphasis for a dashboard row without falling back to a raw `Card`."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 lg:grid-cols-2">
<RevenueMetricCard tone="inverse" />
<RevenueMetricCard tone="hero" />
</div>
)
};
@@ -0,0 +1,175 @@
import { useState } from "react";
import { SegmentedControl, SegmentedControlItem } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
type RevenueLens = "revenue" | "support" | "forecast";
type RevenueLensControlProps = {
includeDisabled?: boolean;
orientation?: "horizontal" | "vertical";
};
const lensContent: Record<
RevenueLens,
{
body: string;
eyebrow: string;
title: string;
}
> = {
revenue: {
body:
"Lead scoring, sequence timing, and route suggestions are lifting close quality without making the top-line motion feel synthetic.",
eyebrow: "Revenue lens",
title: "Commercial coverage stays compact while the active segment changes the read."
},
support: {
body:
"Classification, summaries, and next-step hints are reducing queue drag while still keeping team-lead review visible at the handoff edge.",
eyebrow: "Support lens",
title: "Use the segmented switch when the layout already owns the content panel."
},
forecast: {
body:
"A lighter control works well for near-peer views, especially when the panel chrome and surrounding card structure already exist outside the switcher.",
eyebrow: "Forecast lens",
title: "Choose Tabs instead when the control needs to own a tabpanel relationship."
}
};
function RevenueLensControl({
includeDisabled = false,
orientation = "horizontal"
}: RevenueLensControlProps) {
const [value, setValue] = useState<RevenueLens>("revenue");
const activeContent = lensContent[value];
return (
<div className="grid w-[720px] gap-5">
<div className={orientation === "vertical" ? "flex items-start" : undefined}>
<SegmentedControl
aria-label="Revenue dashboard lens"
orientation={orientation}
onValueChange={(nextValue) => setValue(nextValue as RevenueLens)}
value={value}
>
<SegmentedControlItem value="revenue">Revenue</SegmentedControlItem>
<SegmentedControlItem value="support">Support</SegmentedControlItem>
<SegmentedControlItem value="forecast">Forecast</SegmentedControlItem>
{includeDisabled ? (
<SegmentedControlItem disabled value="handoff">
Handoff
</SegmentedControlItem>
) : null}
</SegmentedControl>
</div>
<div className="rounded-[1.75rem] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-card)_88%,white_12%),color-mix(in_oklch,var(--color-primary-container)_18%,var(--color-card)))] p-5 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{activeContent.eyebrow}
</p>
<h3 className="mt-3 text-xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
{activeContent.title}
</h3>
<p className="mt-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
{activeContent.body}
</p>
</div>
</div>
);
}
const meta = {
title: "Components/SegmentedControl",
component: SegmentedControl,
parameters: {
docs: {
description: {
component:
"SegmentedControl is the compact single-select switch for lightweight dashboard lenses, filters, and mode changes when the surrounding layout already owns the content surface. Use Tabs instead when the control needs to own tab panels and the full in-place content relationship. The checked state now rides on a shared pill so the control reads as one moving slab rather than separate button fills."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof SegmentedControl>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <RevenueLensControl />
};
export const States: Story = {
render: () => <RevenueLensControl includeDisabled />
};
export const Anatomy: Story = {
render: () => (
<div className="w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
SegmentedControl anatomy
</p>
<RevenueLensControl includeDisabled />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="root"</code> carries{" "}
<code className="text-[var(--color-foreground)]">data-orientation</code> so the
segment stack can switch between horizontal and vertical layouts without changing the
item API.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="control"</code> exposes
each segment, while <code className="text-[var(--color-foreground)]">data-state="checked"</code>{" "}
and <code className="text-[var(--color-foreground)]">data-disabled</code> stay stable
for styling and tests.
</p>
</div>
</div>
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"The checked segment now carries a shared indicator that glides between items with the same calm timing as the rest of the system. The control should feel tactile and cohesive, not snappy or game-like."
}
}
},
render: () => <RevenueLensControl includeDisabled />
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Keep labels brief, use segmented controls for single-select peer views, and avoid using them as page navigation. If the control needs a persistent tabpanel relationship, choose Tabs instead."
}
}
},
render: () => (
<div className="grid w-[760px] gap-4 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)]">
Accessibility notes
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Use it for one active choice, not for multi-select filters.</p>
<p>Keep labels short so the active segment remains easy to scan at a glance.</p>
<p>Do not use it as a substitute for top-level page navigation.</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<RevenueLensControl orientation="vertical" />
</div>
</div>
)
};
@@ -0,0 +1,232 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Sparkbar
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const revenueTrend = [18, 24, 30, 22, 15, 12, 20, 28, 38, 46, 58, 70, 62, 48] as const;
const costTrend = [52, 55, 58, 60, 64, 66, 68, 72, 74, 72, 70, 71, 68, 66, 64, 62, 60, 58] as const;
const steadyTrend = [28, 32, 31, 34, 38, 36, 39, 41, 42, 44, 46, 45] as const;
const meta = {
title: "Components/Sparkbar",
component: Sparkbar,
args: {
height: 72,
size: "md",
tone: "default",
values: steadyTrend,
variant: "default"
},
argTypes: {
className: {
control: false
},
height: {
control: {
type: "range",
min: 40,
max: 120,
step: 2
}
},
highlightRange: {
control: false
},
maxValue: {
control: {
type: "number",
min: 1
}
},
size: {
control: "radio",
options: ["sm", "md", "lg"]
},
tone: {
control: "radio",
options: ["default", "subtle", "contrast"]
},
values: {
control: false
},
variant: {
control: "radio",
options: ["default", "success", "warning", "destructive"]
}
},
parameters: {
docs: {
description: {
component:
"Sparkbar is Cadence UI's micro-bar primitive for compact trend evidence inside KPI cards and dashboard tiles. Use it when the surface needs a small embedded bar trend with minimal configuration, not axes, hover interaction, or full chart chrome."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof Sparkbar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div className="w-[460px]">
<Card className="overflow-hidden border-transparent bg-[radial-gradient(circle_at_top,color-mix(in_oklch,var(--color-tertiary-container)_58%,transparent),transparent_40%),linear-gradient(145deg,color-mix(in_oklch,var(--color-foreground)_90%,black_10%),color-mix(in_oklch,var(--color-foreground)_78%,var(--color-primary)_22%))] text-white shadow-[0_28px_72px_color-mix(in_oklch,var(--color-foreground)_22%,transparent)]">
<CardHeader className="gap-3 pb-3">
<CardTitle className="text-white">Sales growth</CardTitle>
<CardDescription className="max-w-sm text-white/62">
Use Sparkbar when a card needs compact directional evidence without graduating into a
full chart.
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Sparkbar
{...args}
className="rounded-[1.35rem] bg-white/6 px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.09)]"
highlightRange={[8, 12]}
tone="contrast"
values={revenueTrend}
variant="success"
/>
</CardContent>
</Card>
</div>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[1040px] gap-4 lg:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle>All bars active</CardTitle>
<CardDescription>
The default case is a fully active sparkbar with no highlighted sub-range.
</CardDescription>
</CardHeader>
<CardContent>
<Sparkbar height={68} values={steadyTrend} />
</CardContent>
</Card>
<Card className="border-transparent bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-foreground)_88%,black_12%),color-mix(in_oklch,var(--color-foreground)_76%,var(--color-primary)_24%))] text-white">
<CardHeader className="pb-3">
<CardTitle className="text-white">Highlighted range</CardTitle>
<CardDescription className="text-white/62">
Emphasize a contiguous run when only the recent lift should read as active.
</CardDescription>
</CardHeader>
<CardContent>
<Sparkbar
className="rounded-[1.2rem] bg-white/6 px-4 py-4"
height={72}
highlightRange={[8, 12]}
tone="contrast"
values={revenueTrend}
variant="success"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>Muted tail</CardTitle>
<CardDescription>
Prefix emphasis works for KPI cards where the active run is the planned or completed
slice.
</CardDescription>
</CardHeader>
<CardContent>
<Sparkbar height={74} highlightRange={[0, 11]} values={costTrend} />
</CardContent>
</Card>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[820px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Sparkbar anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Sparkbar keeps the public structure intentionally tiny: one root, repeated bar slots,
and an optional highlighted run for contiguous emphasis.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<Sparkbar height={74} highlightRange={[0, 11]} values={costTrend} />
</div>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="root"</code> owns the
compact layout and size/tone/variant state for the whole micro-visual.
</p>
<p>
Each repeated <code className="text-[var(--color-foreground)]">data-slot="bar"</code>{" "}
exposes its index, and highlighted bars opt into{" "}
<code className="text-[var(--color-foreground)]">data-active</code> so tests and theme
overrides can target the emphasized run without custom DOM.
</p>
</div>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Sparkbar defaults to decorative rendering because most uses sit beside visible KPI labels and values. If the sparkbar itself needs an accessible meaning, provide an explicit accessible name so it upgrades to an image-like contract."
}
}
},
render: () => (
<div className="grid w-[920px] gap-4 lg:grid-cols-2">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Decorative in a card
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
When the surrounding card already says what the metric means, keep Sparkbar decorative so
assistive tech does not hear duplicated context.
</p>
<div className="mt-5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-5">
<Sparkbar height={68} values={steadyTrend} />
</div>
</article>
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Named standalone visual
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
If the sparkbar stands on its own, give it an accessible name so it can be announced as
a self-contained visual summary.
</p>
<div className="mt-5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-5">
<Sparkbar
aria-label="Operational cost reduction trend over the current quarter"
height={74}
highlightRange={[0, 11]}
values={costTrend}
/>
</div>
</article>
</div>
)
};
@@ -0,0 +1,206 @@
import {
StatCard,
StatCardDelta,
StatCardDescription,
StatCardEyebrow,
StatCardHeader,
StatCardLabel,
StatCardMetric,
StatCardValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function RevenueStatCard({
interactive = true,
tone = "default"
}: {
interactive?: boolean;
tone?: "default" | "subtle" | "accent";
}) {
return (
<StatCard className="w-[360px]" interactive={interactive} tone={tone}>
<StatCardHeader>
<StatCardEyebrow>Revenue pulse</StatCardEyebrow>
<StatCardLabel>Monthly recurring revenue</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>$101,820</StatCardValue>
<StatCardDelta tone="success">+8.4%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
Assisted follow-up and steadier route timing are lifting close quality this month.
</StatCardDescription>
</StatCard>
);
}
const meta = {
title: "Components/StatCard",
component: RevenueStatCard,
parameters: {
docs: {
description: {
component:
"StatCard is the compact KPI surface for one headline metric, one supporting delta, and one short line of context. Use it when a dashboard needs a readable value slab without the extra regions that belong to a richer analytics panel. The default treatment stays lightly hover-ready so high-value summaries feel like lit objects instead of flat admin tiles."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof RevenueStatCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Hover the interactive cards directly in the canvas. StatCard motion should stay compact: the slab lifts as one object, the value sharpens slightly, and the delta chip follows with a softer secondary response instead of turning the panel into a busy dashboard tile."
}
}
},
render: () => (
<div className="grid w-[840px] gap-4 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Keep the KPI slab buoyant, not theatrical.
</h3>
<p className="max-w-[40rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
The card should read like one lifted object. The headline value and delta chip can echo
the lift, but the motion should still feel quieter than the richer choreography used by
MetricCard.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<RevenueStatCard />
<StatCard className="w-full" interactive tone="accent">
<StatCardHeader>
<StatCardEyebrow>Pipeline pulse</StatCardEyebrow>
<StatCardLabel>Qualified expansion forecast</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="primary">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
The uplift signal is strong enough to justify a wider follow-up wave this week.
</StatCardDescription>
</StatCard>
</div>
</div>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[820px] gap-4 md:grid-cols-2 xl:grid-cols-3">
<RevenueStatCard />
<StatCard className="w-full" interactive={false} tone="subtle">
<StatCardHeader>
<StatCardEyebrow>Risk watch</StatCardEyebrow>
<StatCardLabel>Qualified pipeline</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>$82,450</StatCardValue>
<StatCardDelta tone="warning">-3.1%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
Mid-market deal spread is still wider than the board would like.
</StatCardDescription>
</StatCard>
<StatCard className="w-full" interactive tone="accent">
<StatCardHeader>
<StatCardEyebrow>AI influence</StatCardEyebrow>
<StatCardLabel>Forecast confidence</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="primary">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
Commercial planning is stable enough to expand the next follow-up wave.
</StatCardDescription>
</StatCard>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Stat card anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The contract stays intentionally small: framing copy, one metric group, and one line of
context. If the panel needs media, actions, or footer detail, move up to
<code className="ml-1 text-[var(--color-foreground)]">MetricCard</code>.
</p>
</div>
<RevenueStatCard />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code> groups
the eyebrow and label.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="metric"</code> holds
the headline value and its delta badge.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="value"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="delta"</code>, and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="description"</code>
keep KPI styling hooks stable for docs and consumers.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-interactive</code> keeps the
hover-ready treatment explicit when the stat should feel more lifted than surrounding
utility panels.
</p>
</div>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Keep stat-card labels concrete, keep delta color from being the only signal, and reserve the description for the reason the number matters right now rather than repeating the value."
}
}
},
render: () => (
<div className="grid w-[840px] gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Review guidance
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Use a label that names the metric, not just a vague business area.</p>
<p>Delta should still be readable as text such as <code>+8.4%</code> or <code>-3.1%</code>.</p>
<p>Use the chip tone and shape as reinforcement, not as the only signal that the number moved.</p>
<p>The description should explain the current signal, not restate the number.</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<RevenueStatCard />
</div>
</div>
)
};
@@ -0,0 +1,172 @@
import {
Button,
Field,
FieldControl,
FieldDescription,
FieldError,
Label,
ValueField,
ValueFieldPrefix,
ValueFieldSuffix,
ValueFieldValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function CopyGlyph() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<rect height="8" rx="1.5" stroke="currentColor" strokeWidth="1.4" width="6.5" x="6.25" y="3.75" />
<path
d="M4.25 10.25H3.5A1.75 1.75 0 0 1 1.75 8.5v-5A1.75 1.75 0 0 1 3.5 1.75h5A1.75 1.75 0 0 1 10.25 3.5v.75"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.4"
/>
</svg>
);
}
const meta = {
title: "Components/ValueField",
component: ValueField,
args: {
size: "md"
},
argTypes: {
className: {
control: false
},
disabled: {
control: "boolean"
},
invalid: {
control: "boolean"
},
readOnly: {
control: "boolean"
},
required: {
control: "boolean"
},
size: {
control: "select",
options: ["sm", "md", "lg"]
}
},
parameters: {
docs: {
description: {
component:
"Use ValueField when product needs to show a single-line read-only value that still belongs to form-like field anatomy, such as backup codes, generated ids, or immutable environment labels. Prefer it over `Input readOnly` when the user is not editing the value and the surface should read as display-first instead of text-entry."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof ValueField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<Field className="w-[420px]">
<Label>Manual backup code</Label>
<FieldControl>
<ValueField {...args}>
<ValueFieldValue className="font-mono font-semibold uppercase tracking-[0.18em]">
ORBT-7X92-KLL9-001P
</ValueFieldValue>
<ValueFieldSuffix>
<Button aria-label="Copy backup code" size="icon" type="button" variant="ghost">
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
<FieldDescription>Use this code if you cannot scan the QR graphic.</FieldDescription>
</FieldControl>
</Field>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[780px] gap-4 sm:grid-cols-2">
<ValueField>
<ValueFieldValue>prod-eu-central-1</ValueFieldValue>
</ValueField>
<ValueField disabled>
<ValueFieldValue>launch-window-locked</ValueFieldValue>
</ValueField>
<ValueField invalid>
<ValueFieldPrefix aria-hidden="true">#</ValueFieldPrefix>
<ValueFieldValue className="font-mono">release</ValueFieldValue>
<ValueFieldSuffix>
<span className="text-xs font-medium text-[var(--color-destructive)]">duplicate</span>
</ValueFieldSuffix>
</ValueField>
<ValueField size="lg">
<ValueFieldValue className="font-mono uppercase tracking-[0.18em]">
ORBT-7X92-KLL9-001P
</ValueFieldValue>
<ValueFieldSuffix>
<Button aria-label="Copy code" size="icon" type="button" variant="ghost">
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<ValueField className="w-[420px]">
<ValueFieldPrefix>prefix</ValueFieldPrefix>
<ValueFieldValue>data-slot="value"</ValueFieldValue>
<ValueFieldSuffix>suffix</ValueFieldSuffix>
</ValueField>
),
parameters: {
docs: {
description: {
story:
"ValueField keeps the display surface on the root, exposes the actual displayed string through `data-slot=\"value\"`, and uses optional prefix/suffix slots for supporting inline affordances such as icons or copy actions."
}
}
}
};
export const Accessibility: Story = {
render: () => (
<Field invalid className="w-[440px]">
<Label>Deployment target</Label>
<FieldControl>
<ValueField>
<ValueFieldValue>prod-eu-central-1</ValueFieldValue>
<ValueFieldSuffix>
<Button aria-label="Copy deployment target" size="icon" type="button" variant="ghost">
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
<FieldDescription>
Keep the value readable and label-connected even when copy actions are present.
</FieldDescription>
<FieldError>This value is stale and needs regeneration.</FieldError>
</FieldControl>
</Field>
),
parameters: {
docs: {
description: {
story:
"Use ValueField inside `Field` when labels, descriptions, and validation messaging still matter, but the displayed value itself is not editable. This keeps the value surface descriptive without pretending to be an input."
}
}
}
};
@@ -0,0 +1,274 @@
import {
AppShell,
AppShellBody,
AppShellFooter,
AppShellHeader,
AppShellMain,
AppShellSidebar,
Badge,
Button,
PageFooter,
PageFooterActions,
PageFooterDescription,
PageFooterLeading,
PageFooterTitle,
PageHeader,
PageHeaderActions,
PageHeaderDescription,
PageHeaderEyebrow,
PageHeaderLeading,
PageHeaderTitle,
SidebarNav,
SidebarNavContent,
SidebarNavHeader,
SidebarNavItem,
SidebarNavItemIcon,
SidebarNavItemLabel,
SidebarNavItems,
SidebarNavSection,
SidebarNavSectionLabel,
StatCard,
StatCardDelta,
StatCardDescription,
StatCardHeader,
StatCardLabel,
StatCardMetric,
StatCardValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function ShellMark() {
return (
<span className="flex size-10 items-center justify-center rounded-[1rem] bg-[linear-gradient(145deg,var(--color-foreground),color-mix(in_oklch,var(--color-foreground)_74%,var(--color-primary)_26%))] text-[var(--color-background)]">
<svg aria-hidden="true" className="size-5" fill="none" viewBox="0 0 24 24">
<path
d="M5.25 7.2c0-1.08.87-1.95 1.95-1.95h9.6c1.08 0 1.95.87 1.95 1.95v9.6c0 1.08-.87 1.95-1.95 1.95H7.2a1.95 1.95 0 0 1-1.95-1.95V7.2Z"
fill="currentColor"
opacity="0.24"
/>
<path
d="m8.25 8.85 3.2 3.2-3.2 3.2M11.55 12.05h4.2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.9"
/>
</svg>
</span>
);
}
function RailIcon({ kind }: { kind: "overview" | "pipeline" | "team" }) {
switch (kind) {
case "pipeline":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path d="M6 7.5h12M6 12h8m-8 4.5h12" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
);
case "team":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="9" cy="9" r="2.25" stroke="currentColor" strokeWidth="1.7" />
<circle cx="16" cy="10" r="1.9" stroke="currentColor" strokeWidth="1.7" opacity="0.7" />
<path
d="M5.5 18c.35-2.55 2.22-4 4.5-4s4.15 1.45 4.5 4M14.2 18c.16-1.36.93-2.28 2.25-2.9"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
case "overview":
default:
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<rect x="4" y="4" width="6" height="6" rx="1.5" fill="currentColor" />
<rect x="14" y="4" width="6" height="10" rx="1.5" fill="currentColor" opacity="0.75" />
<rect x="4" y="14" width="10" height="6" rx="1.5" fill="currentColor" opacity="0.65" />
</svg>
);
}
}
function AppShellExample() {
return (
<div className="w-full max-w-[1320px]">
<AppShell layout="sidebar" surface="panel">
<AppShellSidebar>
<SidebarNav className="h-full">
<SidebarNavHeader>
<div className="flex items-center gap-3">
<ShellMark />
<div>
<p
className="text-[1.35rem] font-semibold tracking-[-0.04em] text-[var(--color-foreground)]"
style={{ fontFamily: "var(--font-display)" }}
>
ScaleOps
</p>
<p className="text-sm text-[var(--color-muted-foreground)]">Workspace shell</p>
</div>
</div>
</SidebarNavHeader>
<SidebarNavContent>
<SidebarNavSection>
<SidebarNavSectionLabel>Main</SidebarNavSectionLabel>
<SidebarNavItems>
<SidebarNavItem active>
<SidebarNavItemIcon>
<RailIcon kind="overview" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Overview</SidebarNavItemLabel>
</SidebarNavItem>
<SidebarNavItem>
<SidebarNavItemIcon>
<RailIcon kind="pipeline" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Pipeline</SidebarNavItemLabel>
</SidebarNavItem>
<SidebarNavItem>
<SidebarNavItemIcon>
<RailIcon kind="team" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Team</SidebarNavItemLabel>
</SidebarNavItem>
</SidebarNavItems>
</SidebarNavSection>
</SidebarNavContent>
</SidebarNav>
</AppShellSidebar>
<AppShellBody>
<AppShellHeader>
<PageHeader density="compact" variant="default">
<PageHeaderLeading>
<PageHeaderEyebrow>Workspace shell</PageHeaderEyebrow>
<PageHeaderTitle>One shared frame for navigation, header, and page body.</PageHeaderTitle>
<PageHeaderDescription>
AppShell owns the outer layout contract so product pages can swap content without
reinventing the sidebar and body structure.
</PageHeaderDescription>
</PageHeaderLeading>
<PageHeaderActions>
<Button>Primary action</Button>
<Button variant="secondary">Secondary action</Button>
</PageHeaderActions>
</PageHeader>
</AppShellHeader>
<AppShellMain>
<div className="grid gap-4 lg:grid-cols-2">
<StatCard>
<StatCardHeader>
<StatCardLabel>Live revenue pulse</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>$101,820</StatCardValue>
<StatCardDelta tone="success">+84</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
AI-assisted follow-up is still the strongest lift across the board window.
</StatCardDescription>
</StatCard>
<StatCard>
<StatCardHeader>
<StatCardLabel>Forecast confidence</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="primary">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
Strong enough for planning, but still worth watching through the anomaly lane.
</StatCardDescription>
</StatCard>
</div>
</AppShellMain>
<AppShellFooter>
<PageFooter tone="subtle">
<PageFooterLeading>
<PageFooterTitle>Shell state is synchronized across the workspace.</PageFooterTitle>
<PageFooterDescription>
The page footer closes the shell with status and low-emphasis follow-up actions.
</PageFooterDescription>
</PageFooterLeading>
<PageFooterActions>
<Badge tone="primary" variant="subtle">
Ready for review
</Badge>
</PageFooterActions>
</PageFooter>
</AppShellFooter>
</AppShellBody>
</AppShell>
</div>
);
}
const meta = {
title: "Patterns/AppShell",
component: AppShellExample,
parameters: {
docs: {
description: {
component:
"AppShell is the shared outer layout pattern for workspace-style screens. Use it when a product view needs a repeatable sidebar/body frame that can host `SidebarNav`, `PageHeader`, `PageFooter`, and arbitrary content panels without each scene redefining the same shell grid. The shell should feel quietly awake through soft surface motion and focus-within depth changes, while leaving stronger animation work to the content living inside it."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof AppShellExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Anatomy: Story = {
render: () => (
<div className="grid w-[1320px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
App shell anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The stable shell regions are the sidebar, body, header, main content lane, and footer.
That keeps full-page scenes aligned around one layout contract instead of local CSS grids.
</p>
</div>
<AppShellExample />
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"The panel shell should feel a little more alive than a static CSS grid, but it should still read as infrastructure. Hover and focus inside the shell to check the subtle surface wake-up without competing with the contained components."
}
}
},
render: () => (
<div className="grid w-[1320px] gap-5 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_80%,white_20%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<p className="max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
AppShell now carries a restrained surface glow and depth response so the workspace frame
feels staged, not inert. The motion should stay in the background behind the content.
</p>
</div>
<AppShellExample />
</div>
)
};
@@ -0,0 +1,200 @@
import { ChallengeProgress } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import type { ComponentProps } from "react";
function ChallengeMark() {
return (
<svg aria-hidden="true" className="size-5" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.25" stroke="currentColor" strokeOpacity="0.22" strokeWidth="1.8" />
<path
d="M12 4.8a7.2 7.2 0 1 1-6.54 4.2"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2.2"
/>
<path d="M7.6 5.4h4.2v4.2" stroke="currentColor" strokeLinecap="round" strokeWidth="2.2" />
</svg>
);
}
function ChallengeProgressExample({
items = [
{
max: 8_000,
maxLabel: "$8,000",
resultValue: "$8,000",
statusLabel: "Passed",
statusTone: "success",
targetLabel: "Profit target",
targetValue: "$8,000",
value: 8_000,
variant: "success"
},
{
max: 10_000,
maxLabel: "$10,000",
resultValue: "$4,000",
statusLabel: "Phase 2",
statusTone: "primary",
targetLabel: "Profit target",
targetValue: "$10,000",
value: 4_000
}
]
}: {
items?: ComponentProps<typeof ChallengeProgress>["items"];
}) {
return <ChallengeProgress className="w-full max-w-[980px]" icon={<ChallengeMark />} items={items} title="Challenge progress" />;
}
const meta = {
title: "Patterns/ChallengeProgress",
component: ChallengeProgressExample,
parameters: {
docs: {
description: {
component:
"ChallengeProgress is the stacked target panel for challenge-style scoreboards, payout milestones, or multi-phase trading goals. Use it when a view needs several related progress rows with target, result, and compact status chips in one shared slab instead of improvising custom card markup around `Progress`."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof ChallengeProgressExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const States: Story = {
render: () => (
<div className="grid w-[1040px] gap-4">
<ChallengeProgressExample />
<ChallengeProgressExample
items={[
{
max: 12_000,
maxLabel: "$12,000",
progressLabel: "78%",
resultValue: "$9,350",
statusLabel: "Review",
statusTone: "warning",
targetLabel: "Payout target",
targetValue: "$12,000",
value: 9_350,
variant: "warning"
},
{
max: 6_000,
maxLabel: "$6,000",
resultValue: "$0",
statusLabel: "Queued",
statusTone: "neutral",
targetLabel: "Buffer target",
targetValue: "$6,000",
value: null
}
]}
/>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="grid w-[1040px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Challenge progress anatomy
</p>
<p className="max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
The pattern keeps the outer slab, repeated challenge rows, status cluster, and footer
values stable so dashboard scenes can reuse one contract instead of restyling `Card` and
`Progress` each time.
</p>
</div>
<ChallengeProgressExample />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code> and{" "}
<code className="text-[var(--color-foreground)]">data-slot="title"</code> own the shared
panel framing.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="list"</code> wraps the
repeated rows, and each <code className="text-[var(--color-foreground)]">data-slot="item"</code>{" "}
exposes <code className="text-[var(--color-foreground)]">data-state</code> and{" "}
<code className="text-[var(--color-foreground)]">data-variant</code> for complete,
active, or indeterminate styling.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="status"</code> groups the
two right-side chips, while <code className="text-[var(--color-foreground)]">data-slot="meter"</code>{" "}
contains the actual segmented <code className="text-[var(--color-foreground)]">Progress</code>{" "}
primitive.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="footer"</code> keeps the
result readout and the right-aligned max label consistent across rows.
</p>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Keep every row's progressbar label concrete, keep the textual status chips visible, and do not let tone alone communicate whether a challenge passed, is active, or is still queued."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Review guidance
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Name the progressbar after the thing being measured, not just the phase.</p>
<p>Keep the status text explicit, such as <code>Passed</code>, <code>Review</code>, or <code>Queued</code>.</p>
<p>Use the bottom result row to restate the numeric outcome so the bar is not the only source of truth.</p>
<p>If a row is waiting on external work, a pending chip and indeterminate progress should still make sense without motion.</p>
</div>
</article>
<ChallengeProgressExample
items={[
{
max: 10_000,
maxLabel: "$10,000",
progressAriaLabel: "Phase two profit target progress",
resultValue: "$4,000",
statusLabel: "Phase 2",
statusTone: "primary",
targetLabel: "Profit target",
targetValue: "$10,000",
value: 4_000
},
{
max: 6_000,
maxLabel: "$6,000",
progressAriaLabel: "Buffer target progress",
resultValue: "$0",
statusLabel: "Queued",
statusTone: "neutral",
targetLabel: "Buffer target",
targetValue: "$6,000",
value: null
}
]}
/>
</div>
)
};
@@ -0,0 +1,102 @@
import {
Badge,
Button,
PageFooter,
PageFooterActions,
PageFooterDescription,
PageFooterLeading,
PageFooterMeta,
PageFooterTitle
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function PageFooterExample({
tone = "default"
}: {
tone?: "default" | "subtle" | "accent";
}) {
return (
<div className="w-full max-w-[1100px]">
<PageFooter tone={tone}>
<PageFooterLeading>
<PageFooterMeta>
<Badge tone="primary" variant="subtle">
Board synced
</Badge>
<Badge tone="success" variant="outline">
Forecast live
</Badge>
</PageFooterMeta>
<PageFooterTitle>
Revenue command center is ready for the weekly board review.
</PageFooterTitle>
<PageFooterDescription>
Assisted routing, forecast activity, and anomaly monitoring are all updated from the
same operating window.
</PageFooterDescription>
</PageFooterLeading>
<PageFooterActions>
<Button variant="ghost">Open audit log</Button>
<Button variant="secondary">Share snapshot</Button>
</PageFooterActions>
</PageFooter>
</div>
);
}
const meta = {
title: "Patterns/PageFooter",
component: PageFooterExample,
parameters: {
docs: {
description: {
component:
"PageFooter is the shared low-emphasis closing bar for pages and workspaces that still need status, audit, or follow-up actions after the main content. Use it instead of hand-rolling one more bordered slab at the bottom of every screen. Its motion should stay soft and infrastructural: a tiny slab lift and coordinated badge/action polish, not a second hero panel."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof PageFooterExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Tones: Story = {
render: () => (
<div className="grid w-[1100px] gap-4">
<PageFooterExample tone="default" />
<PageFooterExample tone="subtle" />
<PageFooterExample tone="accent" />
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Hover the badges and action cluster to review the closing-bar motion. The footer should feel slightly buoyant and coordinated, but it should remain quieter than the primary page content above it."
}
}
},
render: () => (
<div className="grid w-[1100px] gap-5 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_80%,white_20%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<p className="max-w-2xl text-sm leading-6 text-[var(--color-muted-foreground)]">
The footer should read like a softly lifted closing slab. Its badges and actions can wake
up on hover, but the whole region should stay calm and secondary.
</p>
</div>
<PageFooterExample tone="subtle" />
</div>
)
};
@@ -0,0 +1,135 @@
import {
Badge,
Button,
PageHeader,
PageHeaderActions,
PageHeaderDescription,
PageHeaderEyebrow,
PageHeaderLeading,
PageHeaderMeta,
PageHeaderTitle
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function PageHeaderExample({
align = "start",
density = "comfortable",
variant = "default"
}: {
align?: "start" | "end";
density?: "comfortable" | "compact";
variant?: "hero" | "default" | "compact";
}) {
return (
<div className="w-full max-w-[1100px]">
<PageHeader align={align} density={density} variant={variant}>
<PageHeaderLeading>
<PageHeaderMeta>
<PageHeaderEyebrow>Friday, Dec 5, 2025</PageHeaderEyebrow>
<Badge tone="primary" variant="subtle">
Calm pulse
</Badge>
</PageHeaderMeta>
<PageHeaderTitle>Welcome back, Nathan.</PageHeaderTitle>
<PageHeaderDescription>
Revenue operations is holding a calm pulse today. Growth is still being led by
AI-assisted follow-up, while cost pressure stays within the board target window.
</PageHeaderDescription>
</PageHeaderLeading>
<PageHeaderActions>
<Button>Morning brief</Button>
<Button variant="secondary">Watch anomalies</Button>
</PageHeaderActions>
</PageHeader>
</div>
);
}
const meta = {
title: "Patterns/PageHeader",
component: PageHeaderExample,
parameters: {
docs: {
description: {
component:
"PageHeader is the shared top-of-page pattern for workspace and dashboard views. Use it when a screen needs one primary title stack with supporting context and a separate action cluster, instead of reassembling the same flex and spacing rules in each scene. Its motion should stay structural and calm: the header itself carries only a light ornamental wake-up, while badges and actions inherit a restrained lift that keeps the top rail feeling alive without turning it into a hero animation."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof PageHeaderExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Variants: Story = {
render: () => (
<div className="mx-auto grid w-full max-w-[1100px] gap-8">
<PageHeaderExample align="end" variant="hero" />
<PageHeaderExample variant="default" />
<PageHeaderExample density="compact" variant="compact" />
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="mx-auto grid w-full max-w-[1100px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Page header anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep the title stack in the leading slot and keep controls in the actions slot. The
pattern stays flexible enough for badges, dates, or status markers through the meta slot
without turning every page header into custom layout glue. Use `variant`, `density`, and
`align` on the root when a page needs hero weight, a quieter default header, or a tighter
compact treatment.
</p>
</div>
<PageHeaderExample />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="leading"</code> owns the
meta, title, and description stack.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="actions"</code> is reserved
for the page-level control cluster so page scenes stop improvising this split every time.
</p>
</div>
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Hover the badges and action cluster. The pattern itself should feel gently staged through ornament and shared transition polish, but the main title stack should stay anchored and readable."
}
}
},
render: () => (
<div className="mx-auto grid w-full max-w-[1100px] gap-5 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<p className="max-w-2xl text-sm leading-6 text-[var(--color-muted-foreground)]">
PageHeader should not animate like a card. It should feel like polished infrastructure:
the slab wakes up through a light glow and the attached controls carry the interaction lift.
</p>
</div>
<PageHeaderExample align="end" variant="hero" />
</div>
)
};
@@ -0,0 +1,236 @@
import {
Badge,
Button,
SidebarNav,
SidebarNavContent,
SidebarNavFooter,
SidebarNavHeader,
SidebarNavItem,
SidebarNavItemBadge,
SidebarNavItemIcon,
SidebarNavItemLabel,
SidebarNavItems,
SidebarNavSection,
SidebarNavSectionLabel
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function PatternMark() {
return (
<span className="flex size-11 items-center justify-center rounded-[1.1rem] bg-[linear-gradient(145deg,var(--color-foreground),color-mix(in_oklch,var(--color-foreground)_72%,var(--color-primary)_28%))] text-[var(--color-background)] shadow-[0_18px_40px_color-mix(in_oklch,var(--color-foreground)_16%,transparent)]">
<svg aria-hidden="true" className="size-5" fill="none" viewBox="0 0 24 24">
<path
d="M5.25 7.2c0-1.08.87-1.95 1.95-1.95h9.6c1.08 0 1.95.87 1.95 1.95v9.6c0 1.08-.87 1.95-1.95 1.95H7.2a1.95 1.95 0 0 1-1.95-1.95V7.2Z"
fill="currentColor"
opacity="0.24"
/>
<path
d="m8.25 8.85 3.2 3.2-3.2 3.2M11.55 12.05h4.2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.9"
/>
</svg>
</span>
);
}
function NavIcon({ kind }: { kind: "overview" | "pipeline" | "messages" | "team" | "billing" }) {
switch (kind) {
case "pipeline":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path d="M6 7.5h12M6 12h8m-8 4.5h12" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
);
case "messages":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M6.5 7.25h11a2 2 0 0 1 2 2v5.25a2 2 0 0 1-2 2H12l-3.9 2.85c-.6.43-1.43 0-1.43-.74V16.5H6.5a2 2 0 0 1-2-2V9.25a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.7"
/>
</svg>
);
case "team":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="9" cy="9" r="2.25" stroke="currentColor" strokeWidth="1.7" />
<circle cx="16" cy="10" r="1.9" stroke="currentColor" strokeWidth="1.7" opacity="0.7" />
<path
d="M5.5 18c.35-2.55 2.22-4 4.5-4s4.15 1.45 4.5 4M14.2 18c.16-1.36.93-2.28 2.25-2.9"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
case "billing":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<rect x="4.75" y="6.5" width="14.5" height="11" rx="2" stroke="currentColor" strokeWidth="1.7" />
<path d="M4.75 10.25h14.5" stroke="currentColor" strokeWidth="1.7" />
</svg>
);
case "overview":
default:
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<rect x="4" y="4" width="6" height="6" rx="1.5" fill="currentColor" />
<rect x="14" y="4" width="6" height="10" rx="1.5" fill="currentColor" opacity="0.75" />
<rect x="4" y="14" width="10" height="6" rx="1.5" fill="currentColor" opacity="0.65" />
</svg>
);
}
}
function SidebarNavExample() {
return (
<SidebarNav className="w-full max-w-[320px]">
<SidebarNavHeader>
<div className="space-y-5">
<div className="flex items-center gap-3">
<PatternMark />
<div>
<p
className="text-[1.55rem] font-semibold tracking-[-0.04em] text-[var(--color-foreground)]"
style={{ fontFamily: "var(--font-display)" }}
>
ScaleOps
</p>
<p className="text-sm text-[var(--color-muted-foreground)]">
Revenue command center
</p>
</div>
</div>
<div className="h-px bg-[linear-gradient(90deg,var(--color-border),transparent)]" />
</div>
</SidebarNavHeader>
<SidebarNavContent>
<SidebarNavSection>
<SidebarNavSectionLabel>Main</SidebarNavSectionLabel>
<SidebarNavItems>
<SidebarNavItem active>
<SidebarNavItemIcon>
<NavIcon kind="overview" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Dashboard</SidebarNavItemLabel>
</SidebarNavItem>
<SidebarNavItem>
<SidebarNavItemIcon>
<NavIcon kind="pipeline" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Pipeline</SidebarNavItemLabel>
</SidebarNavItem>
<SidebarNavItem>
<SidebarNavItemIcon>
<NavIcon kind="messages" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Messages</SidebarNavItemLabel>
<SidebarNavItemBadge>
<Badge size="sm" tone="success" variant="subtle">
2
</Badge>
</SidebarNavItemBadge>
</SidebarNavItem>
</SidebarNavItems>
</SidebarNavSection>
<SidebarNavSection>
<SidebarNavSectionLabel>Management</SidebarNavSectionLabel>
<SidebarNavItems>
<SidebarNavItem>
<SidebarNavItemIcon>
<NavIcon kind="team" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Team performance</SidebarNavItemLabel>
</SidebarNavItem>
<SidebarNavItem>
<SidebarNavItemIcon>
<NavIcon kind="billing" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Billing</SidebarNavItemLabel>
</SidebarNavItem>
</SidebarNavItems>
</SidebarNavSection>
</SidebarNavContent>
<SidebarNavFooter>
<div className="rounded-[1.5rem] border border-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-border))] bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_78%,white_22%),color-mix(in_oklch,var(--color-tertiary-container)_74%,white_26%))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Next pulse
</p>
<p className="mt-2 text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Team forecast review at 09:30
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Sales, finance, and ops owners are aligned on the board prep.
</p>
<Button className="mt-4 w-full" variant="secondary">
Open agenda
</Button>
</div>
</SidebarNavFooter>
</SidebarNav>
);
}
const meta = {
title: "Patterns/SidebarNav",
component: SidebarNavExample,
parameters: {
docs: {
description: {
component:
"SidebarNav is the reusable navigation rail pattern for dashboard and workspace shells. Use it when a product needs grouped navigation, a branded header, and a supporting footer region without improvising every rail from `Card` and `Button`."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof SidebarNavExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Anatomy: Story = {
render: () => (
<div className="grid w-[960px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Sidebar nav anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The stable hooks are the rail header, grouped sections, item rows, and footer region. That
lets one product family share navigation structure without forcing every app to share the
exact same icons or footer callout.
</p>
</div>
<SidebarNavExample />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="content"</code>, and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="footer"</code> define the
rail frame.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="section"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="item"</code>, and the
nested <code className="text-[var(--color-foreground)]">icon</code> / <code className="text-[var(--color-foreground)]">label</code> / <code className="text-[var(--color-foreground)]">badge</code> slots keep
grouped navigation predictable for styling and tests.
</p>
</div>
</div>
)
};
@@ -0,0 +1,372 @@
import { useRef, useState, type ClipboardEvent } from "react";
import {
Button,
Card,
CardContent,
Col,
Field,
FieldControl,
FieldDescription,
Input,
Label,
Row,
ValueField,
ValueFieldSuffix,
ValueFieldValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const backupCode = "ORBT-7X92-KLL9-001P";
const qrPattern = [
"111111001011",
"100001101001",
"101101001111",
"101101110001",
"100001011101",
"111111001011",
"001100111101",
"110011001011",
"101010111001",
"001111000111",
"110001011001",
"101111100111"
] as const;
function CopyGlyph() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<rect height="8" rx="1.5" stroke="currentColor" strokeWidth="1.4" width="6.5" x="6.25" y="3.75" />
<path
d="M4.25 10.25H3.5A1.75 1.75 0 0 1 1.75 8.5v-5A1.75 1.75 0 0 1 3.5 1.75h5A1.75 1.75 0 0 1 10.25 3.5v.75"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.4"
/>
</svg>
);
}
function CornerGuide({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={`pointer-events-none absolute h-7 w-7 border-[2px] border-[color-mix(in_oklch,var(--color-tertiary)_52%,transparent)] ${className ?? ""}`}
/>
);
}
function FakeQrCode() {
return (
<div className="relative mx-auto grid h-[12.25rem] w-[12.25rem] place-items-center">
<CornerGuide className="left-[0.35rem] top-[0.35rem] h-8 w-8 border-b-0 border-r-0 rounded-tl-[0.95rem]" />
<CornerGuide className="right-[0.35rem] top-[0.35rem] h-8 w-8 border-b-0 border-l-0 rounded-tr-[0.95rem]" />
<CornerGuide className="bottom-[0.35rem] left-[0.35rem] h-8 w-8 border-r-0 border-t-0 rounded-bl-[0.95rem]" />
<CornerGuide className="bottom-[0.35rem] right-[0.35rem] h-8 w-8 border-l-0 border-t-0 rounded-br-[0.95rem]" />
<div className="relative grid h-[9.25rem] w-[9.25rem] place-items-center rounded-[1.35rem] border border-[var(--color-border)] bg-[var(--color-surface-bright)] p-[0.72rem] shadow-[var(--shadow-sm)]">
<div className="pointer-events-none absolute left-1/2 top-1/2 h-1 w-[7.6rem] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[linear-gradient(90deg,transparent,var(--color-tertiary-container),transparent)] opacity-85 shadow-[0_0_24px_color-mix(in_oklch,var(--color-tertiary)_28%,transparent)]" />
<div className="grid grid-cols-12 gap-[0.17rem] rounded-[0.95rem] bg-[var(--color-surface-bright)] p-[0.45rem]">
{qrPattern.flatMap((row, rowIndex) =>
row.split("").map((cell, cellIndex) => (
<span
aria-hidden="true"
className={
cell === "1"
? "size-[0.53rem] rounded-[0.14rem] bg-[var(--color-foreground)]"
: "size-[0.53rem] rounded-[0.14rem] bg-transparent"
}
key={`${rowIndex}-${cellIndex}`}
/>
))
)}
</div>
</div>
</div>
);
}
function VerificationTokenFields() {
const [digits, setDigits] = useState(() => Array.from({ length: 6 }, () => ""));
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const isComplete = digits.every(Boolean);
const updateDigit = (index: number, nextValue: string) => {
const value = nextValue.replace(/\D/g, "").slice(-1);
setDigits((current) => {
const next = [...current];
next[index] = value;
return next;
});
if (value) {
inputsRef.current[index + 1]?.focus();
}
};
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const pastedDigits = event.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
if (!pastedDigits) {
return;
}
event.preventDefault();
setDigits(Array.from({ length: 6 }, (_, index) => pastedDigits[index] ?? ""));
const focusIndex = Math.min(pastedDigits.length, 5);
inputsRef.current[focusIndex]?.focus();
};
return (
<Field className="gap-3" required>
<div className="flex flex-wrap items-center gap-2">
<Label requiredIndicator>Verification token</Label>
</div>
<FieldControl>
<div className="grid w-fit grid-cols-6 gap-2 sm:gap-3" onPaste={handlePaste}>
{digits.map((digit, index) => (
<Input
aria-label={`Verification digit ${index + 1}`}
className="size-10 rounded-[1rem] border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-0 text-center text-base font-semibold tracking-[0.08em] shadow-[var(--shadow-xs)] sm:size-12 [font-variant-numeric:tabular-nums]"
inputMode="numeric"
key={index}
maxLength={1}
onChange={(event) => updateDigit(index, event.target.value)}
onKeyDown={(event) => {
if (event.key === "Backspace" && !digits[index] && index > 0) {
inputsRef.current[index - 1]?.focus();
}
}}
ref={(node) => {
inputsRef.current[index] = node;
}}
required
value={digit}
/>
))}
</div>
<FieldDescription className="text-[13px] leading-5 tracking-[-0.01em] sm:whitespace-nowrap">
Paste from your authenticator app, or enter each digit manually.
</FieldDescription>
</FieldControl>
<div className="flex items-center justify-between gap-3 pt-1">
<Button className="min-w-32" disabled={!isComplete}>
Continue
</Button>
<div className="flex justify-end">
<Button variant="ghost">Cancel</Button>
</div>
</div>
</Field>
);
}
function TwoFactorSetupScene() {
const [copied, setCopied] = useState(false);
return (
<div className="relative isolate overflow-hidden rounded-[1.8rem] border border-[var(--ui-panel-border)] bg-[var(--ui-panel-bg)] shadow-[var(--ui-panel-shadow)]">
<div className="pointer-events-none absolute inset-0">
<div className="absolute left-[-2rem] top-[-1.5rem] h-32 w-32 rounded-full bg-[color-mix(in_oklch,var(--color-primary-container)_60%,transparent)] blur-3xl" />
<div className="absolute bottom-[-3rem] right-[-1rem] h-40 w-40 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary-container)_66%,transparent)] blur-3xl" />
</div>
<Row className="relative" gap="none">
<Col
className="border-b border-[var(--color-border)] bg-[var(--ui-card-subtle-bg)] px-5 py-7 sm:px-6 sm:py-8 lg:border-b-0 lg:border-r"
lg={5}
span={12}
>
<div className="flex h-full min-h-[21.5rem] items-center justify-center">
<div className="grid w-full max-w-[12.25rem] justify-items-center gap-4 text-center">
<FakeQrCode />
<div className="grid w-full justify-items-center gap-2">
<h3
className="text-[1.3rem] font-semibold tracking-[-0.03em] text-[var(--color-foreground)]"
style={{ fontFamily: "var(--font-display)" }}
>
Secure protocol
</h3>
<p className="max-w-[11.75rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
Position your camera within the frame to authorize this session.
</p>
</div>
</div>
</div>
</Col>
<Col className="grid gap-4 p-5 sm:p-6" lg={7} span={12}>
<div className="grid gap-2">
<div className="grid gap-1.5">
<h2
className="text-[clamp(1.9rem,3.6vw,2.7rem)] font-semibold tracking-[-0.04em] text-[var(--color-foreground)]"
style={{ fontFamily: "var(--font-display)", lineHeight: 1.02 }}
>
2FA Setup
</h2>
<p className="text-[13px] leading-5 tracking-[-0.01em] text-[var(--color-muted-foreground)] sm:whitespace-nowrap">
Scan with your authenticator app to enable Level 4 access.
</p>
</div>
</div>
<Card interactive={false} tone="subtle" className="rounded-[1.35rem] border-transparent shadow-none">
<CardContent className="grid gap-2 p-4">
<Label>Manual backup code</Label>
<ValueField className="rounded-[1.1rem]" size="lg">
<ValueFieldValue
aria-label="Manual backup code"
className="font-mono text-sm font-semibold uppercase tracking-[0.18em]"
>
{backupCode}
</ValueFieldValue>
<ValueFieldSuffix className="pr-1">
<Button
aria-label={copied ? "Backup code copied" : "Copy backup code"}
onClick={() => {
setCopied(true);
void globalThis.navigator?.clipboard?.writeText?.(backupCode);
}}
size="icon"
variant="ghost"
>
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
{copied
? "Copied for the current review session."
: "Use this code if you cannot scan the QR graphic."}
</p>
</CardContent>
</Card>
<Card interactive={false} tone="default" className="rounded-[1.35rem] border-transparent shadow-none">
<CardContent className="p-4">
<VerificationTokenFields />
</CardContent>
</Card>
</Col>
</Row>
</div>
);
}
function TwoFactorSetupPattern() {
return (
<div
className="min-h-screen bg-[var(--color-background)] px-4 py-8 sm:px-6 sm:py-12"
style={{ backgroundImage: "var(--ui-canvas-image)" }}
>
<div className="mx-auto w-full max-w-[42.5rem]">
<TwoFactorSetupScene />
</div>
</div>
);
}
const meta = {
title: "Patterns/TwoFactorSetup",
component: TwoFactorSetupPattern,
parameters: {
docs: {
description: {
component:
"TwoFactorSetup is a docs-only auth flow pattern, not a new base component. Use it as a composition reference when product needs a modal or setup scene for QR enrollment, backup-code fallback, and one-time-token entry. It lives under `Patterns` because the reusable pieces already exist in the system, while the flow itself is a scenario-specific arrangement."
}
},
layout: "fullscreen"
},
tags: ["autodocs"]
} satisfies Meta<typeof TwoFactorSetupPattern>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Anatomy: Story = {
render: () => (
<div
className="min-h-screen bg-[var(--color-background)] px-4 py-8 sm:px-6 sm:py-12"
style={{ backgroundImage: "var(--ui-canvas-image)" }}
>
<div className="mx-auto grid w-full max-w-[1100px] gap-5">
<div className="grid gap-2 rounded-[1.75rem] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Placement rationale
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep `Dialog` generic and keep this under `Patterns`. The flow is reusable as a product
scene, but not stable enough yet to justify a new public OTP or auth component contract.
</p>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
Reused building blocks: <code className="text-[var(--color-foreground)]">Button</code>,{" "}
<code className="text-[var(--color-foreground)]">Card</code>,{" "}
<code className="text-[var(--color-foreground)]">Field</code>,{" "}
<code className="text-[var(--color-foreground)]">Input</code>, and{" "}
<code className="text-[var(--color-foreground)]">ValueField</code>.
</p>
<p>
One-off scene styling stays local to the story so the package surface does not pick up
a premature authentication abstraction.
</p>
</div>
</div>
<TwoFactorSetupScene />
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Keep a manual fallback visible, label each token input clearly, and avoid using color or motion alone to communicate security state. The pattern should remain calm and readable when dropped into the existing dialog surface."
}
}
},
render: () => (
<div
className="min-h-screen bg-[var(--color-background)] px-4 py-8 sm:px-6 sm:py-12"
style={{ backgroundImage: "var(--ui-canvas-image)" }}
>
<div className="mx-auto w-full max-w-[1220px]">
<Row gap="lg">
<Col span={12} xl={5}>
<div className="grid h-full content-start gap-4 rounded-[1.75rem] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-xl font-semibold tracking-[var(--tracking-tight)]">
Accessibility notes
</h3>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Expose a readable title and description when the scene is mounted inside `Dialog`.</p>
<p>Keep the backup code visible so the flow does not fail when scanning is unavailable.</p>
<p>Use per-digit labels and allow paste to reduce friction for keyboard and switch users.</p>
<p>
The light treatment is intentional. Security UI does not need a dark theme to feel
trustworthy.
</p>
</div>
</div>
</Col>
<Col span={12} xl={7}>
<TwoFactorSetupScene />
</Col>
</Row>
</div>
</div>
)
};
@@ -0,0 +1,170 @@
import {
Avatar,
AvatarFallback,
Badge,
Button,
Input,
InputGroup,
InputGroupPrefix,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
WorkspaceToolbar,
WorkspaceToolbarActions,
WorkspaceToolbarContent,
WorkspaceToolbarFilters,
WorkspaceToolbarLeading,
WorkspaceToolbarSearch,
WorkspaceToolbarStatus
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function SearchGlyph() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="5.5" stroke="currentColor" strokeWidth="1.8" />
<path d="m16 16 4 4" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
);
}
function WorkspaceToolbarExample({
showFilters = false,
showLeading = false,
surface = "default"
}: {
showFilters?: boolean;
showLeading?: boolean;
surface?: "default" | "panel";
}) {
return (
<div className="w-full max-w-[1180px]">
<WorkspaceToolbar surface={surface}>
{showLeading ? (
<WorkspaceToolbarLeading>
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Revenue desk
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Use the toolbar for search, narrow filters, live status, and quick actions that
belong above a dense workspace, not inside the page title stack.
</p>
</WorkspaceToolbarLeading>
) : null}
<WorkspaceToolbarContent>
<WorkspaceToolbarSearch>
<InputGroup
className="w-full rounded-[1.25rem] bg-[color-mix(in_oklch,var(--color-surface)_86%,white_14%)]"
size="lg"
>
<InputGroupPrefix className="pointer-events-none">
<SearchGlyph />
</InputGroupPrefix>
<Input aria-label="Search workspace" placeholder="Search the workspace" size="lg" />
</InputGroup>
</WorkspaceToolbarSearch>
{showFilters ? (
<WorkspaceToolbarFilters>
<Select defaultValue="all">
<SelectTrigger aria-label="Filter lanes" className="w-[11rem]">
<SelectValue placeholder="Filter lanes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All lanes</SelectItem>
<SelectItem value="ready">Ready</SelectItem>
<SelectItem value="watching">Watching</SelectItem>
</SelectContent>
</Select>
<Button variant="ghost">Reset view</Button>
</WorkspaceToolbarFilters>
) : null}
<WorkspaceToolbarStatus>
<Badge tone="primary" variant="subtle">
Live board
</Badge>
<div className="flex items-center -space-x-2">
{["MK", "DV", "LS"].map((initials, index) => (
<Avatar
className="border-2 border-[var(--color-background)]"
key={initials}
size="sm"
tone={index === 0 ? "accent" : index === 1 ? "subtle" : "default"}
>
<AvatarFallback delayMs={0}>{initials}</AvatarFallback>
</Avatar>
))}
</div>
</WorkspaceToolbarStatus>
<WorkspaceToolbarActions>
<Button variant="secondary">Export board</Button>
<Button variant="ghost">Share snapshot</Button>
</WorkspaceToolbarActions>
</WorkspaceToolbarContent>
</WorkspaceToolbar>
</div>
);
}
const meta = {
title: "Patterns/WorkspaceToolbar",
component: WorkspaceToolbarExample,
parameters: {
docs: {
description: {
component:
"WorkspaceToolbar is the shared control row for desk and workspace screens that need search, narrow filters, live status chips, and quick actions above the main page content. Use it between the outer shell and the page header instead of improvising one more flex wrapper for every workbench view."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof WorkspaceToolbarExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const PanelSurface: Story = {
render: () => <WorkspaceToolbarExample showFilters showLeading surface="panel" />
};
export const Anatomy: Story = {
render: () => (
<div className="grid w-[1180px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Workspace toolbar anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep title hierarchy in `PageHeader`. Use `WorkspaceToolbar` for operational controls
that change the current desk view without becoming the page&apos;s semantic heading.
</p>
</div>
<WorkspaceToolbarExample showFilters showLeading surface="panel" />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="leading"</code> is optional
framing copy for the current workbench or desk.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="content"</code> owns the
live control row, while <code className="text-[var(--color-foreground)]">search</code>,{" "}
<code className="text-[var(--color-foreground)]">filters</code>,{" "}
<code className="text-[var(--color-foreground)]">status</code>, and{" "}
<code className="text-[var(--color-foreground)]">actions</code> keep each cluster stable
for styling and tests.
</p>
</div>
</div>
)
};
File diff suppressed because it is too large Load Diff
+977
View File
@@ -0,0 +1,977 @@
import { useState, type ReactNode } from "react";
import {
AppShell,
AppShellBody,
AppShellFooter,
AppShellHeader,
AppShellMain,
AppShellSidebar,
Avatar,
AvatarFallback,
Badge,
Button,
Chart,
ChartDescription,
ChartHeader,
ChartHeaderAside,
ChartHeaderLeading,
ChartTitle,
PageFooter,
PageFooterActions,
PageFooterDescription,
PageFooterLeading,
PageFooterMeta,
PageFooterTitle,
PageHeader,
PageHeaderActions,
PageHeaderDescription,
PageHeaderEyebrow,
PageHeaderLeading,
PageHeaderMeta,
PageHeaderTitle,
SidebarNav,
SidebarNavContent,
SidebarNavFooter,
SidebarNavHeader,
SidebarNavItem,
SidebarNavItemBadge,
SidebarNavItemIcon,
SidebarNavItemLabel,
SidebarNavItems,
SidebarNavSection,
SidebarNavSectionLabel,
WorkspaceToolbar,
WorkspaceToolbarActions,
WorkspaceToolbarContent,
WorkspaceToolbarSearch,
WorkspaceToolbarStatus,
type ChartSeries,
type ChartTooltipContext,
Input,
InputGroup,
InputGroupPrefix,
MetricCard,
MetricCardAside,
MetricCardDelta,
MetricCardDescription,
MetricCardFooter,
MetricCardHeader,
MetricCardLabel,
MetricCardLeading,
MetricCardMedia,
MetricCardMetric,
MetricCardValue,
Progress,
Sparkbar,
StatCard,
StatCardDelta,
StatCardDescription,
StatCardHeader,
StatCardLabel,
StatCardMetric,
StatCardValue,
SegmentedControl,
SegmentedControlItem
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
type NavIcon =
| "dashboard"
| "transactions"
| "messages"
| "reports"
| "team"
| "customers"
| "channels"
| "orders"
| "roles"
| "billing"
| "integrations";
type ActivityVariant = "default" | "success" | "warning";
type ContributionView = "sales" | "support" | "forecast";
type SalesImpactDatum = {
gross: number;
month: string;
revenue: number;
};
type NavigationItem = {
active?: boolean;
badge?: string;
icon: NavIcon;
label: string;
};
type NavigationSection = {
items: NavigationItem[];
label: string;
};
const navigationSections: NavigationSection[] = [
{
label: "Main",
items: [
{ active: true, icon: "dashboard", label: "Dashboard" },
{ icon: "transactions", label: "Transactions" },
{ badge: "2", icon: "messages", label: "Messages" },
{ icon: "reports", label: "Reports & Analysis" },
{ icon: "team", label: "Team Performance" }
]
},
{
label: "Customers",
items: [
{ icon: "customers", label: "Customer List" },
{ icon: "channels", label: "Channels" },
{ icon: "orders", label: "Order Management" }
]
},
{
label: "Management",
items: [
{ icon: "roles", label: "Roles & Permissions" },
{ icon: "billing", label: "Billing & Subscription" },
{ icon: "integrations", label: "Integrations" }
]
}
];
const sparkBars = [
18, 24, 30, 22, 15, 12, 20, 28, 38, 46, 58, 70, 62, 48, 34, 24, 16, 18, 22, 28, 19, 14,
18, 26, 16, 12, 14, 18
] as const;
const reductionBars = [
52, 55, 58, 60, 64, 66, 68, 72, 74, 72, 70, 71, 68, 66, 64, 62, 60, 58, 56, 54
] as const;
const activityRows: Array<{
description: string;
label: string;
value: number;
variant: ActivityVariant;
}> = [
{
description: "Lead routing and personalized follow-up",
label: "Automation Models",
value: 45,
variant: "success"
},
{
description: "Deal-scoring and weighted pipeline hints",
label: "Predictive Models",
value: 30,
variant: "warning"
},
{
description: "Support Bot NLP",
label: "Language Models",
value: 15,
variant: "default"
},
{
description: "Exception watch and anomaly review",
label: "Anomaly Detection",
value: 10,
variant: "default"
}
];
const teamMembers = [
{ initials: "MK", tone: "accent" },
{ initials: "DV", tone: "subtle" },
{ initials: "LS", tone: "default" }
] as const;
const salesImpactData: SalesImpactDatum[] = [
{ gross: 52450, month: "Jan", revenue: 71280 },
{ gross: 112400, month: "Feb", revenue: 92320 },
{ gross: 98420, month: "Mar", revenue: 138260 },
{ gross: 152800, month: "Apr", revenue: 165420 },
{ gross: 82450, month: "May", revenue: 125320 },
{ gross: 136200, month: "Jun", revenue: 186540 },
{ gross: 118320, month: "Jul", revenue: 172480 },
{ gross: 121560, month: "Aug", revenue: 149860 },
{ gross: 109440, month: "Sep", revenue: 143220 },
{ gross: 141880, month: "Oct", revenue: 176340 }
];
const compactCurrencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 1,
notation: "compact",
style: "currency"
});
const currencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency"
});
function formatCurrency(value: number) {
return currencyFormatter.format(value);
}
function formatCompactCurrency(value: number) {
return compactCurrencyFormatter.format(value);
}
const salesImpactSeries: ChartSeries<SalesImpactDatum>[] = [
{
getValue: (datum) => datum.revenue,
id: "revenue",
label: "Revenue",
style: "line-area",
tone: "primary",
valueFormatter: formatCurrency
},
{
getValue: (datum) => datum.gross,
id: "gross",
label: "Gross",
strokeDasharray: "6 8",
style: "line",
tone: "neutral",
valueFormatter: formatCurrency
}
];
function AppMark() {
return (
<span className="flex size-11 items-center justify-center rounded-[1.1rem] bg-[linear-gradient(145deg,var(--color-foreground),color-mix(in_oklch,var(--color-foreground)_72%,var(--color-primary)_28%))] text-[var(--color-background)] shadow-[0_18px_40px_color-mix(in_oklch,var(--color-foreground)_16%,transparent)]">
<svg aria-hidden="true" className="size-5" fill="none" viewBox="0 0 24 24">
<path
d="M5.25 7.2c0-1.08.87-1.95 1.95-1.95h9.6c1.08 0 1.95.87 1.95 1.95v9.6c0 1.08-.87 1.95-1.95 1.95H7.2a1.95 1.95 0 0 1-1.95-1.95V7.2Z"
fill="currentColor"
opacity="0.24"
/>
<path
d="m8.25 8.85 3.2 3.2-3.2 3.2M11.55 12.05h4.2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.9"
/>
</svg>
</span>
);
}
function SearchGlyph() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="5.25" stroke="currentColor" strokeWidth="1.8" />
<path d="m16 16 3.75 3.75" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
);
}
function NavGlyph({ icon }: { icon: NavIcon }) {
switch (icon) {
case "dashboard":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<rect x="4" y="4" width="6" height="6" rx="1.5" fill="currentColor" />
<rect x="14" y="4" width="6" height="10" rx="1.5" fill="currentColor" opacity="0.75" />
<rect x="4" y="14" width="10" height="6" rx="1.5" fill="currentColor" opacity="0.65" />
</svg>
);
case "transactions":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M6 7.5h12M6 12h8m-8 4.5h12"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.8"
/>
</svg>
);
case "messages":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M6.5 7.25h11a2 2 0 0 1 2 2v5.25a2 2 0 0 1-2 2H12l-3.9 2.85c-.6.43-1.43 0-1.43-.74V16.5H6.5a2 2 0 0 1-2-2V9.25a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.7"
/>
</svg>
);
case "reports":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M5.5 18.5h13M7.5 16V9m4 7V5m4 11v-6"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.8"
/>
</svg>
);
case "team":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="9" cy="9" r="2.25" stroke="currentColor" strokeWidth="1.7" />
<circle cx="16" cy="10" r="1.9" stroke="currentColor" strokeWidth="1.7" opacity="0.7" />
<path
d="M5.5 18c.35-2.55 2.22-4 4.5-4s4.15 1.45 4.5 4M14.2 18c.16-1.36.93-2.28 2.25-2.9"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
case "customers":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="8.5" r="2.75" stroke="currentColor" strokeWidth="1.7" />
<path
d="M6 18c.45-2.95 2.88-4.75 6-4.75S17.55 15.05 18 18"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
case "channels":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="7" cy="7" r="2" fill="currentColor" />
<circle cx="17" cy="7" r="2" fill="currentColor" opacity="0.72" />
<circle cx="12" cy="17" r="2" fill="currentColor" opacity="0.52" />
<path
d="M8.6 8.2 10.8 15m4.6-6.8-2.2 6.8M9 7h6"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.4"
/>
</svg>
);
case "orders":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M6.75 7.25h10.5l-1.05 8.05a1.5 1.5 0 0 1-1.48 1.3H9.3a1.5 1.5 0 0 1-1.48-1.3L6.75 7.25Zm2.2 11.2a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9Zm5.9 0a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.7"
/>
</svg>
);
case "roles":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M12 4.75 18 8v8l-6 3.25L6 16V8l6-3.25Zm0 5.1a2.15 2.15 0 1 0 0 4.3 2.15 2.15 0 0 0 0-4.3Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.7"
/>
</svg>
);
case "billing":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<rect x="4.75" y="6.5" width="14.5" height="11" rx="2" stroke="currentColor" strokeWidth="1.7" />
<path d="M4.75 10.25h14.5" stroke="currentColor" strokeWidth="1.7" />
</svg>
);
case "integrations":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="7" cy="12" r="2.1" fill="currentColor" />
<circle cx="17" cy="7" r="2.1" fill="currentColor" opacity="0.72" />
<circle cx="17" cy="17" r="2.1" fill="currentColor" opacity="0.52" />
<path
d="M9 11.2 14.8 7.8M9 12.8l5.8 3.4"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
/>
</svg>
);
}
}
function DeltaBadge({
children,
className,
tone = "success"
}: {
children: ReactNode;
className?: string;
tone?: "primary" | "success";
}) {
return (
<Badge
className={className}
size="sm"
tone={tone}
variant="subtle"
>
{children}
</Badge>
);
}
function SalesImpactTooltip({
activeLabel,
series
}: ChartTooltipContext<SalesImpactDatum>) {
return (
<div className="grid gap-3">
<div className="text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{activeLabel}
</div>
<div className="grid gap-2">
{series.map((entry) => (
<div
className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 text-sm"
key={entry.id}
>
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-[var(--color-muted-foreground)]">{entry.label}</span>
<span className="font-medium text-[var(--color-foreground)]">
{entry.formattedValue}
</span>
</div>
))}
</div>
</div>
);
}
function RevenueSidebar() {
return (
<SidebarNav className="h-full">
<SidebarNavHeader>
<div className="space-y-5">
<div className="flex items-center gap-3">
<AppMark />
<div>
<p
className="text-[1.55rem] font-semibold tracking-[-0.04em] text-[var(--color-foreground)]"
style={{ fontFamily: "var(--font-display)" }}
>
ScaleOps
</p>
<p className="text-sm text-[var(--color-muted-foreground)]">
Revenue command center
</p>
</div>
</div>
<div className="h-px bg-[linear-gradient(90deg,var(--color-border),transparent)]" />
</div>
</SidebarNavHeader>
<SidebarNavContent>
{navigationSections.map((section) => (
<SidebarNavSection key={section.label}>
<SidebarNavSectionLabel>{section.label}</SidebarNavSectionLabel>
<SidebarNavItems>
{section.items.map((item) => (
<SidebarNavItem active={item.active} key={item.label}>
<SidebarNavItemIcon>
<NavGlyph icon={item.icon} />
</SidebarNavItemIcon>
<SidebarNavItemLabel>{item.label}</SidebarNavItemLabel>
{item.badge ? (
<SidebarNavItemBadge>
<Badge
className={item.active ? "bg-white/12 text-white" : ""}
size="sm"
tone="success"
variant="subtle"
>
{item.badge}
</Badge>
</SidebarNavItemBadge>
) : null}
</SidebarNavItem>
))}
</SidebarNavItems>
</SidebarNavSection>
))}
</SidebarNavContent>
<SidebarNavFooter>
<div className="mt-auto rounded-[1.5rem] border border-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-border))] bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_78%,white_22%),color-mix(in_oklch,var(--color-tertiary-container)_74%,white_26%))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Next pulse
</p>
<p className="mt-2 text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Team forecast review at 09:30
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Sales, finance, and ops owners are all on pace for the weekly board.
</p>
<Button className="mt-4 w-full" variant="secondary">
Open agenda
</Button>
</div>
</SidebarNavFooter>
</SidebarNav>
);
}
function Toolbar() {
return (
<WorkspaceToolbar>
<WorkspaceToolbarContent>
<WorkspaceToolbarSearch className="xl:max-w-[34rem]">
<InputGroup
className="w-full rounded-[1.25rem] bg-[color-mix(in_oklch,var(--color-surface)_86%,white_14%)]"
size="lg"
>
<InputGroupPrefix className="pointer-events-none">
<SearchGlyph />
</InputGroupPrefix>
<Input
aria-label="Search dashboard"
placeholder="Type here..."
size="lg"
/>
</InputGroup>
</WorkspaceToolbarSearch>
<WorkspaceToolbarStatus>
<DeltaBadge tone="primary">Live board</DeltaBadge>
<div className="flex items-center -space-x-2">
{teamMembers.map((member) => (
<Avatar
key={member.initials}
className="border-2 border-[var(--color-background)]"
size="sm"
tone={member.tone}
>
<AvatarFallback delayMs={0}>{member.initials}</AvatarFallback>
</Avatar>
))}
</div>
</WorkspaceToolbarStatus>
<WorkspaceToolbarActions>
<Button variant="secondary">Export board</Button>
</WorkspaceToolbarActions>
</WorkspaceToolbarContent>
</WorkspaceToolbar>
);
}
function DashboardFooter() {
return (
<PageFooter className="bg-[color-mix(in_oklch,var(--color-surface)_88%,white_12%)]" tone="subtle">
<PageFooterLeading>
<PageFooterMeta>
<DeltaBadge tone="primary">Board synced</DeltaBadge>
<Badge tone="success" variant="outline">
Forecast live
</Badge>
</PageFooterMeta>
<PageFooterTitle>
Revenue command center is ready for the weekly board review.
</PageFooterTitle>
<PageFooterDescription>
Assisted routing, forecast activity, and anomaly monitoring are all updated from the
same operating window.
</PageFooterDescription>
</PageFooterLeading>
<PageFooterActions>
<Button variant="ghost">Open audit log</Button>
<Button variant="secondary">Share snapshot</Button>
</PageFooterActions>
</PageFooter>
);
}
function SalesGrowthCard() {
return (
<MetricCard interactive tone="hero">
<MetricCardHeader>
<MetricCardLeading>
<div className="flex items-center gap-3">
<span className="flex size-11 items-center justify-center rounded-[1rem] bg-[color-mix(in_oklch,var(--color-warning)_65%,white_35%)] text-[var(--color-foreground)]">
<svg aria-hidden="true" className="size-5" fill="none" viewBox="0 0 24 24">
<path
d="M7 15.5 9.75 9l2.15 4.15 1.95-2.8L17 15.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</svg>
</span>
<div>
<p className="text-sm font-medium text-white/78">Sales Growth</p>
<p className="text-sm text-white/56">Commercial momentum this month</p>
</div>
</div>
</MetricCardLeading>
<MetricCardAside>
<MetricCardDelta tone="primary">+$12,180</MetricCardDelta>
</MetricCardAside>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>$101,820</MetricCardValue>
<MetricCardDelta tone="success">+84</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription className="max-w-sm">
The mix is being lifted by automated follow-up, better route timing, and steadier
close velocity in the enterprise lane.
</MetricCardDescription>
<MetricCardMedia tone="hero">
<Sparkbar
className="rounded-[1.35rem] bg-white/6 px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.09)]"
columns={14}
height={70}
highlightRange={[9, 13]}
tone="contrast"
values={sparkBars}
variant="success"
/>
</MetricCardMedia>
</MetricCard>
);
}
function CostReductionCard() {
return (
<MetricCard interactive>
<MetricCardHeader>
<MetricCardLeading>
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-[0.95rem] bg-[color-mix(in_oklch,var(--color-surface-container-highest)_78%,white_22%)] text-[var(--color-foreground)]">
<svg aria-hidden="true" className="size-4.5" fill="none" viewBox="0 0 24 24">
<path
d="M8 8.75h8M8 12h8M8 15.25h4"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.8"
/>
</svg>
</span>
<MetricCardLabel>Operational Cost Reduction</MetricCardLabel>
</div>
</MetricCardLeading>
<MetricCardAside>
<MetricCardDelta tone="primary">42%</MetricCardDelta>
</MetricCardAside>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>$38,250</MetricCardValue>
<MetricCardDelta tone="success">+78</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription>
Lower servicing overhead through assisted routing and tighter triage.
</MetricCardDescription>
<MetricCardMedia>
<Sparkbar height={74} highlightRange={[0, 12]} values={reductionBars} />
</MetricCardMedia>
<MetricCardFooter>
<div className="flex items-center justify-between text-sm text-[var(--color-muted-foreground)]">
<span>Quarter target</span>
<span>$101,820</span>
</div>
<Progress tone="subtle" value={42} variant="success" />
</MetricCardFooter>
</MetricCard>
);
}
function SalesImpactCard() {
return (
<Chart
className="self-start"
data={salesImpactData}
defaultActiveIndex={4}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
header={
<ChartHeader>
<ChartHeaderLeading>
<ChartTitle>Sales Impact</ChartTitle>
<ChartDescription>
Revenue lift versus gross pipeline over the current operating arc.
</ChartDescription>
</ChartHeaderLeading>
<ChartHeaderAside>
<DeltaBadge tone="primary">Live forecast</DeltaBadge>
</ChartHeaderAside>
</ChartHeader>
}
height={336}
renderTooltip={SalesImpactTooltip}
series={salesImpactSeries}
showActiveGuide={false}
showYAxis={false}
title="Sales Impact"
yAxisValueFormatter={formatCompactCurrency}
/>
);
}
function ActivityBreakdownCard() {
return (
<MetricCard interactive>
<MetricCardHeader>
<MetricCardLabel>AI Model Activity Breakdown</MetricCardLabel>
</MetricCardHeader>
<MetricCardDescription>
The active mix remains automation-heavy, with anomaly review intentionally
constrained until the current forecast stabilizes.
</MetricCardDescription>
<MetricCardMedia tone="subtle">
<div className="grid gap-4">
{activityRows.map((row) => (
<div key={row.label} className="grid gap-2.5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-[var(--color-foreground)]">{row.label}</p>
<p className="text-xs text-[var(--color-muted-foreground)]">{row.description}</p>
</div>
<span className="text-sm font-medium text-[var(--color-foreground)]">
{row.value}%
</span>
</div>
<Progress tone="subtle" value={row.value} variant={row.variant} />
</div>
))}
</div>
</MetricCardMedia>
<MetricCardFooter className="border-none pt-0">
<div className="rounded-[1.35rem] bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_58%,white_42%),color-mix(in_oklch,var(--color-surface)_82%,white_18%))] p-4">
<p className="text-sm leading-6 text-[var(--color-foreground)]">
Automation is doing the visible volume work, but the real stability signal is the
low anomaly load across the same operating window.
</p>
</div>
</MetricCardFooter>
</MetricCard>
);
}
function ContributionPanel({ view }: { view: ContributionView }) {
switch (view) {
case "sales":
return (
<div className="flex flex-wrap gap-2">
<Badge tone="primary">Lead scoring</Badge>
<Badge tone="primary" variant="outline">
Predictive sales
</Badge>
<Badge tone="success" variant="outline">
Follow-up assist
</Badge>
</div>
);
case "support":
return (
<div className="rounded-[1.45rem] border border-[var(--color-border)] bg-[color-mix(in_oklch,var(--color-card)_84%,white_16%)] p-4">
<p className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Queue handling is faster without increasing escalations.
</p>
<p className="mt-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
Summaries, classification, and priority hints reduce review time while keeping
human handoff visible for the team lead.
</p>
</div>
);
case "forecast":
return (
<div className="rounded-[1.45rem] border border-[var(--color-border)] bg-[color-mix(in_oklch,var(--color-card)_84%,white_16%)] p-4">
<p className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Forecast confidence is healthy, but the board is still watching conversion
spread in the mid-market segment.
</p>
<p className="mt-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
Current guidance is strong enough for commercial planning, not strong enough to
stop watching the anomaly lane.
</p>
</div>
);
}
}
function ContributionCard() {
const [view, setView] = useState<ContributionView>("sales");
return (
<MetricCard
className="overflow-hidden border-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%),color-mix(in_oklch,var(--color-primary-container)_26%,var(--color-card)))]"
>
<MetricCardHeader className="grid gap-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<MetricCardLabel>AI Contribution Overview</MetricCardLabel>
<MetricCardDescription>
Assisted work is improving the quality of each commercial pass instead of only
adding more volume.
</MetricCardDescription>
</div>
<div className="flex flex-col items-start gap-3 sm:items-end">
<Badge tone="primary" variant="solid">
AI + Revenue
</Badge>
<SegmentedControl
aria-label="Contribution lens"
onValueChange={(nextValue) => setView(nextValue as ContributionView)}
value={view}
>
<SegmentedControlItem value="sales">Sales</SegmentedControlItem>
<SegmentedControlItem value="support">Support</SegmentedControlItem>
<SegmentedControlItem value="forecast">Forecast</SegmentedControlItem>
</SegmentedControl>
</div>
</div>
</MetricCardHeader>
<MetricCardMedia padding="flush" tone="accent">
<div className="grid gap-5">
{view === "sales" ? (
<StatCard className="shadow-none">
<StatCardHeader>
<StatCardLabel>Assisted revenue influence</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="success">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
AI-enhanced lead processing, automated follow-up, and route suggestions are
improving accuracy while giving the team more time for high-leverage deals.
</StatCardDescription>
</StatCard>
) : null}
<ContributionPanel view={view} />
</div>
</MetricCardMedia>
<MetricCardFooter className="border-none pt-0">
<div className="flex flex-wrap items-center justify-between gap-4 rounded-[1.35rem] bg-[color-mix(in_oklch,var(--color-surface)_86%,white_14%)] px-4 py-3">
<div className="flex items-center -space-x-2">
{teamMembers.map((member) => (
<Avatar
key={`contribution-${member.initials}`}
className="border-2 border-[var(--color-background)]"
size="sm"
tone={member.tone}
>
<AvatarFallback delayMs={0}>{member.initials}</AvatarFallback>
</Avatar>
))}
</div>
<Button variant="ghost">Review forecast</Button>
</div>
</MetricCardFooter>
</MetricCard>
);
}
function RevenueDashboardScene() {
return (
<div className="min-h-screen bg-[var(--color-background)] px-4 py-6 text-[var(--color-foreground)] sm:px-6 lg:px-8">
<div className="mx-auto max-w-[1500px]">
<AppShell className="relative" layout="sidebar" surface="panel">
<div className="pointer-events-none absolute inset-0">
<div className="motion-drift absolute -left-10 top-12 h-40 w-40 rounded-full bg-[color-mix(in_oklch,var(--color-primary-container)_54%,transparent)] blur-3xl" />
<div className="motion-drift absolute right-0 top-0 h-32 w-32 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary-container)_58%,transparent)] blur-3xl" />
<div className="motion-breathe absolute bottom-8 right-16 h-36 w-36 rounded-full bg-[color-mix(in_oklch,var(--color-primary)_12%,transparent)] blur-3xl" />
</div>
<AppShellSidebar>
<RevenueSidebar />
</AppShellSidebar>
<AppShellBody>
<AppShellHeader>
<Toolbar />
</AppShellHeader>
<AppShellMain>
<section className="grid gap-4">
<PageHeader align="end" variant="hero">
<PageHeaderLeading>
<PageHeaderMeta>
<PageHeaderEyebrow>Friday, Dec 5, 2025</PageHeaderEyebrow>
<DeltaBadge tone="primary">Calm pulse</DeltaBadge>
</PageHeaderMeta>
<PageHeaderTitle>Welcome back, Nathan.</PageHeaderTitle>
<PageHeaderDescription>
Revenue operations is holding a calm pulse today. Growth is still being
led by AI-assisted follow-up, while cost pressure stays within the board
target window.
</PageHeaderDescription>
</PageHeaderLeading>
<PageHeaderActions>
<Button>Morning brief</Button>
<Button variant="secondary">Watch anomalies</Button>
</PageHeaderActions>
</PageHeader>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<SalesGrowthCard />
<CostReductionCard />
</div>
</section>
<section className="grid items-start gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(0,0.75fr)]">
<SalesImpactCard />
<div className="grid gap-4">
<ActivityBreakdownCard />
<ContributionCard />
</div>
</section>
</AppShellMain>
<AppShellFooter>
<DashboardFooter />
</AppShellFooter>
</AppShellBody>
</AppShell>
</div>
</div>
);
}
const meta = {
title: "Scenes/Revenue Dashboard",
component: RevenueDashboardScene,
parameters: {
docs: {
description: {
component:
"Revenue Dashboard is a composition scene that demonstrates how the existing Cadence UI components can assemble a polished analytics workspace with a sidebar, KPI slabs, chart surfaces, and AI summary panels without adding a parallel component set."
}
},
layout: "fullscreen"
},
tags: ["autodocs"]
} satisfies Meta<typeof RevenueDashboardScene>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};