feat(docs): add analytics and pattern showcase stories
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 />
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
};
|
||||
@@ -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={6}</code> or
|
||||
an object like <code>xl={{ span: 4, offset: 2 }}</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'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
@@ -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 = {};
|
||||
@@ -0,0 +1,54 @@
|
||||
# Analytics Workspace Scene
|
||||
|
||||
- Status: `completed`
|
||||
- Owner: `codex`
|
||||
- Date: `2026-03-25`
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new Storybook `Scenes` entry that mirrors the supplied logistics analytics reference page
|
||||
while staying inside Cadence UI's active tonal Material-inspired design language and leaning on the
|
||||
current shared component set first.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope:
|
||||
- add a new `Scenes/Analytics Workspace` story under `apps/docs/src`
|
||||
- match the reference page's main information architecture: sidebar, top workspace toolbar,
|
||||
analytics header, KPI cards, two upper chart panels, and one wide lower trend panel
|
||||
- reuse existing shared components for shell, navigation, cards, charts, inputs, badges, and
|
||||
segmented controls whenever possible
|
||||
- keep the scene responsive across mobile and desktop Storybook widths
|
||||
- Out of scope:
|
||||
- pixel-matching the supplied screenshot's white-label brand
|
||||
- replacing the active Cadence tonal system with a new one-off skin
|
||||
- changing shared component APIs unless a clear gap blocks the composition
|
||||
|
||||
## Constraints
|
||||
|
||||
- Follow `DESIGN.md` over the raw screenshot when style details conflict.
|
||||
- Do not disturb unrelated in-progress workspace changes.
|
||||
- Keep any missing visual glue local to the scene unless it is clearly reusable.
|
||||
|
||||
## Affected Surfaces
|
||||
|
||||
- `apps/docs/src`
|
||||
- `docs/exec-plans`
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add an execution plan for the new analytics workspace scene.
|
||||
2. Compose the new scene from the current shell, nav, toolbar, stat, metric, and chart components.
|
||||
3. Fill any remaining screenshot-specific presentation gaps with lightweight scene-local helpers.
|
||||
4. Run the narrowest useful docs validation for the changed surface.
|
||||
|
||||
## Validation
|
||||
|
||||
- `pnpm harness:validate:changed`
|
||||
|
||||
## Status Log
|
||||
|
||||
- `2026-03-25 20:14` started plan for a new analytics workspace scene based on the provided logistics dashboard reference
|
||||
- `2026-03-25 20:31` added `Scenes/Analytics Workspace` with a sidebar shell, workspace toolbar, KPI cards, two upper chart panels, and a wide lower automation trend panel
|
||||
- `2026-03-25 20:36` verified `apps/docs/src/analytics-workspace.stories.tsx` with `pnpm exec eslint apps/docs/src/analytics-workspace.stories.tsx` and `pnpm --filter @ai-ui/docs typecheck`
|
||||
- `2026-03-25 20:37` noted `pnpm harness:validate:changed` is currently blocked by unrelated pre-existing lint errors in `packages/ui/src/components/gauge.tsx` and `packages/ui/src/components/progress.test.tsx`
|
||||
@@ -0,0 +1,59 @@
|
||||
# Analytics Workspace Showcase
|
||||
|
||||
- Status: `completed`
|
||||
- Owner: `codex`
|
||||
- Date: `2026-03-25`
|
||||
|
||||
## Goal
|
||||
|
||||
Add a full-screen Storybook scene that proves the current Cadence UI component set can
|
||||
compose a polished revenue operations dashboard similar to the provided reference without
|
||||
introducing one-off primitives or drifting away from the active Material-inspired design
|
||||
language.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope:
|
||||
- add one new `Scenes` Storybook story for a revenue analytics workspace
|
||||
- compose the page from the existing component surface plus lightweight layout-only markup
|
||||
- keep the scene responsive so it remains readable on desktop and mobile widths
|
||||
- validate the result in Storybook and through the docs build
|
||||
- Out of scope:
|
||||
- adding new shared components or changing existing component APIs
|
||||
- matching the reference screenshot's exact monochrome branding over Cadence UI's active tonal system
|
||||
- adding this scene to the curated smoke contract unless it becomes a required regression surface
|
||||
|
||||
## Constraints
|
||||
|
||||
- Follow `DESIGN.md` over the raw screenshot when visual details conflict.
|
||||
- Reuse existing components such as `Card`, `Input`, `Badge`, `Avatar`, `Button`, `Tabs`, and `Progress`.
|
||||
- Keep the implementation isolated to docs surfaces unless a clear shared gap appears.
|
||||
|
||||
## Affected Surfaces
|
||||
|
||||
- `apps/docs/src`
|
||||
- `docs/exec-plans`
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add an execution plan so the scene work is resumable and reviewable.
|
||||
2. Build a new full-screen Storybook scene that mirrors the reference information architecture:
|
||||
sidebar, hero header, KPI cards, trend chart, and AI summary panels.
|
||||
3. Use the running Storybook instance to inspect spacing, hierarchy, and responsive behavior.
|
||||
4. Run the narrowest useful docs validation suite and record the outcome.
|
||||
|
||||
## Validation
|
||||
|
||||
- `pnpm harness:validate:docs`
|
||||
- browser review against the local Storybook instance on `http://localhost:6006/`
|
||||
|
||||
## Orchestration Task Sketch
|
||||
|
||||
- `T1`: add the new dashboard showcase scene
|
||||
- `T2 -> T1`: inspect and validate the scene in Storybook
|
||||
|
||||
## Status Log
|
||||
|
||||
- `2026-03-25 15:45` started plan for a new Storybook dashboard scene based on the supplied analytics reference
|
||||
- `2026-03-25 16:02` added `Scenes/Revenue Dashboard`, verified `pnpm harness:validate:docs`, and reviewed desktop/mobile rendering against the local Storybook iframe
|
||||
- `2026-03-25 16:04` noted an unrelated existing Storybook dev-index failure from `apps/docs/src/components/progress.stories.tsx` that does not block direct iframe review of the new scene
|
||||
Reference in New Issue
Block a user